为什么你的全局异常处理拦不住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,没有抛异常
    }
    // ...
}

usernull 时,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,而是直接交给 DispatcherServletprocessHandlerException 方法处理。如果这个方法没有配置适当的异常处理,会交给 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 返回值,通常是这几个原因:

  1. 异常在 Filter 层级抛出:Filter 异常需要 Filter 自己处理,或者交给容器
  2. Service 返回 null:而不是抛异常或返回 Optional
  3. Interceptor 抛异常:需要配置 HandlerExceptionResolver
  4. 缺少 ErrorController:兜底的错误处理

最佳实践:

  • Service 层用 Optional 表示“可能没有”,用异常表示“出错了”
  • Controller 层确保返回值不为 null
  • Filter 自己 catch 异常并写入响应
  • 添加 ErrorController 作为兜底
最后更新 4/20/2026, 6:02:32 AM