为什么你的全局异常处理拦不住Controller的返回值
为什么你的全局异常处理拦不住Controller的返回值
线上出现了一个奇怪的问题:用户登录失败时,前端收到的是 HTTP 200,但 body 里是一段 HTML 错误页面,而不是正常的 JSON 响应。这说明异常确实被抛出了,但没有被全局异常处理器捕获,而是被 Tomcat 的默认错误页机制接走了。
这是一个 Spring MVC 异常处理的经典陷阱。
问题现场
登录接口:
@PostMapping("/login")
public UserVO login(@RequestBody LoginRequest request) {
User user = userService.login(request.getUsername(), request.getPassword());
return convertToVO(user);
}
当用户不存在时,userService.login 抛出一个自定义异常:
public User login(String username, String password) {
User user = userDao.findByUsername(username);
if (user == null) {
throw new UserNotFoundException("用户不存在");
}
if (!passwordEncoder.matches(password, user.getPassword())) {
throw new PasswordMismatchException("密码错误");
}
return user;
}
全局异常处理器:
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(UserNotFoundException.class)
public ApiResponse<Void> handleUserNotFound(UserNotFoundException e) {
return ApiResponse.error(404, e.getMessage());
}
@ExceptionHandler(PasswordMismatchException.class)
public ApiResponse<Void> handlePasswordMismatch(PasswordMismatchException e) {
return ApiResponse.error(401, e.getMessage());
}
@ExceptionHandler(Exception.class)
public ApiResponse<Void> handleGenericException(Exception e) {
log.error("系统异常", e);
return ApiResponse.error(500, "系统繁忙");
}
}
理论上应该返回 {"code": 404, "message": "用户不存在", ...},但实际返回的是一段 HTML。
根因分析
问题在于 UserService.login 返回了 null,而不是抛出异常:
public User login(String username, String password) {
User user = userDao.findByUsername(username);
if (user == null) {
return null; // 这里返回了 null,没有抛异常
}
// ...
}
当 user 为 null 时,convertToVO(user) 抛出 NullPointerException,而 NullPointerException 被全局异常处理器里的 Exception 捕获,返回了 500 错误。
但 HTML 页面是 Tomcat 的 Error Report 机制产生的,说明异常根本没有进入 Spring 的异常处理链。
可能的原因
原因一:异常在 Filter 层级抛出
如果异常在 Spring MVC 的 DispatcherServlet 之前被抛出,比如在 Filter 或 Interceptor 中,Spring 的 @ExceptionHandler 是无法捕获的。
检查是否有自定义 Filter:
@Component
@Order(1)
public class AuthFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
if (!isPublicEndpoint(request) && !validateToken(request)) {
// 这里直接写入了 response
response.setStatus(401);
response.setContentType("application/json");
response.getWriter().write("{\"code\":401,\"message\":\"未授权\"}");
return; // 没有抛异常,直接返回
}
filterChain.doFilter(request, response);
}
}
这个 Filter 正确处理了未授权情况:直接写入 JSON 响应,而不是抛异常。
但如果写成这样:
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
if (!isPublicEndpoint(request) && !validateToken(request)) {
throw new UnauthorizedException("未授权"); // 抛异常
}
filterChain.doFilter(request, response);
}
这个异常会被 FilterChain 抛出,但 Spring 的 @ExceptionHandler 捕获不到,因为 Filter 在 Servlet 之前。
原因二:Controller 返回了 null
@GetMapping("/user/{id}")
public UserVO getUser(@PathVariable Long id) {
return userService.findById(id) // 返回 null
.map(this::convertToVO)
.orElse(null);
}
当 userService.findById 返回 Optional.empty() 时,Controller 返回 null。Spring 的 RequestResponseBodyMethodProcessor 处理返回值时,发现是 null,会检查 @ResponseBody 注解,然后写入空响应体。但有些配置下,Spring 会认为这是异常情况,交给 Tomcat 的默认错误处理。
原因三:异常被 Interceptor 抛出但未被处理
@Component
public class AuthInterceptor extends HandlerInterceptorAdapter {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
if (!isPublicEndpoint(request) && !validateToken(request)) {
throw new UnauthorizedException("未授权"); // 抛异常
}
return true;
}
}
在 Spring MVC 中,Interceptor 抛出的异常不会经过 @ExceptionHandler,而是直接交给 DispatcherServlet 的 processHandlerException 方法处理。如果这个方法没有配置适当的异常处理,会交给 Servlet 容器(Tomcat)处理。
正确的全局异常处理配置
为了确保所有异常都能被正确处理,需要覆盖所有可能的异常入口:
1. 配置 ExceptionHandlerExceptionResolver
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Override
public void configureHandlerExceptionResolvers(
List<HandlerExceptionResolver> resolvers) {
resolvers.add(exceptionHandlerExceptionResolver());
}
@Bean
public ExceptionHandlerExceptionResolver exceptionHandlerExceptionResolver() {
ExceptionHandlerExceptionResolver resolver =
new ExceptionHandlerExceptionResolver();
resolver.setComponentPackages(Arrays.asList("com.example.handlers"));
resolver.afterPropertiesSet();
return resolver;
}
}
2. 添加 ErrorController
这是最后一层保障,用于处理所有未被 @ExceptionHandler 捕获的异常:
@RestController
public class GlobalErrorController implements ErrorController {
@RequestMapping("/error")
public ApiResponse<Void> handleError(HttpServletRequest request) {
Integer status = (Integer) request.getAttribute("javax.servlet.error.status_code");
String message = (String) request.getAttribute("javax.servlet.error.message");
if (status == null || message == null) {
return ApiResponse.error(500, "系统异常");
}
return ApiResponse.error(status, message);
}
}
注意:Spring Boot 2.3+ 需要手动引入 server.error.include-message=always 等配置,或者实现自定义 ErrorAttributes。
3. 处理 Filter 中的异常
需要在 Filter 中明确处理异常:
@Component
@Order(1)
public class AuthFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
try {
if (!isPublicEndpoint(request) && !validateToken(request)) {
throw new UnauthorizedException("未授权");
}
filterChain.doFilter(request, response);
} catch (UnauthorizedException e) {
writeJsonResponse(response, 401, "未授权");
} catch (Exception e) {
writeJsonResponse(response, 500, "系统异常");
}
}
private void writeJsonResponse(HttpServletResponse response, int status, String message)
throws IOException {
response.setStatus(status);
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
ApiResponse<Void> apiResponse = ApiResponse.error(status, message);
response.getWriter().write(JSON.toJSONString(apiResponse));
}
}
更好的实践:统一返回值
用 AOP 拦截 Controller 的返回值,确保不会有 null 漏出去:
@Aspect
@Component
public class ControllerReturnValueAspect {
@Around("execution(* com.example.controller..*.*(..))")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
Object result = joinPoint.proceed();
// 如果返回值是 null,包装成统一的错误响应
if (result == null) {
HttpServletRequest request =
((ServletRequestAttributes) RequestContextHolder
.getRequestAttributes()).getRequest();
// 检查是否有异常
Exception exception =
(Exception) request.getAttribute("javax.servlet.error.exception");
if (exception != null) {
throw exception;
}
return ApiResponse.error(404, "资源不存在");
}
return result;
}
}
但这个 AOP 有个问题:它无法区分“正常返回 null”和“异常返回 null”。更好的做法是在 Service 层就确保不返回 null。
Service 层 Optional 改造
回到开头的问题,正确的姿势是 Service 层返回 Optional:
public Optional<User> login(String username, String password) {
User user = userDao.findByUsername(username);
if (user == null) {
return Optional.empty();
}
if (!passwordEncoder.matches(password, user.getPassword())) {
throw new PasswordMismatchException("密码错误");
}
return Optional.of(user);
}
Controller:
@PostMapping("/login")
public ApiResponse<UserVO> login(@RequestBody LoginRequest request) {
Optional<User> userOpt = userService.login(
request.getUsername(),
request.getPassword()
);
return userOpt
.map(this::convertToVO)
.map(ApiResponse::success)
.orElseGet(() -> ApiResponse.error(404, "用户不存在"));
}
这样 Controller 层不会有 null,也不会有异常(业务异常在 Service 层抛出,被 @ExceptionHandler 捕获)。
总结
全局异常处理拦不住 Controller 返回值,通常是这几个原因:
- 异常在 Filter 层级抛出:Filter 异常需要 Filter 自己处理,或者交给容器
- Service 返回 null:而不是抛异常或返回 Optional
- Interceptor 抛异常:需要配置
HandlerExceptionResolver - 缺少 ErrorController:兜底的错误处理
最佳实践:
- Service 层用 Optional 表示“可能没有”,用异常表示“出错了”
- Controller 层确保返回值不为 null
- Filter 自己 catch 异常并写入响应
- 添加 ErrorController 作为兜底
