Spring Boot 异常处理 - 良好实践

作者 ximinghui 写于 2025年12月5日

一、背景

本篇浅谈Spring Boot项目中的异常处理。

假设 Spring Boot 项目如下:

说明:TokenFilter可以是Servlet过滤器(jakarta.servlet.Filter),也可以是继承自Servlet过滤器的Spring过滤器(如:OncePerRequestFilter),两者在异常处理流程中无大的差异,本文章中将它们视为一类。Controller和RestController两者在异常处理流程中无大的差异,本文章中将它们视为一类,因此可能会混用,但指的同一类东西。

报错的场景如下:

  1. Controller接口抛出异常;
  2. Filter过滤器抛异常;
  3. Spring Security抛出异常(以StrictHttpFirewall为例)

说明1:可以请求一个路径带 "//" 的端点来触发StrictHttpFirewall抛出RequestRejectedException异常。后面就以RequestRejectedException异常代指第三种报错场景。

说明2:StrictHttpFirewall旨在拦截不安全的或存在歧义的一些请求,比如url中带有 //、 /../ 之类的,发现并抛出RequestRejectedException异常。

大体的前后流程是:

  1. HTTP请求(前端)
  2. Servlet容器(如Tomcat)
  3. 挂在Spring Security的FilterChain中的一堆过滤器(可能非Servlet过滤器),
  4. Servlet过滤器和Spring过滤器
  5. Controller端点

说明:之所以把它排在Servler过滤器前面并不是说它绝对的早于Servlet过滤器,而是通常大多数情况下,Spring Security的过滤器链都会注册到较为靠前的位置。Spring Security的过滤器链肯定还得以 Servlet过滤器 的形式注册到Servlet容器中,当然可以手动注册一个 @Order(Ordered.HIGHEST_PRECEDENCE) 的Servlet/Spring过滤器插在Spring Security前面。

二、Spring Boot项目(含Servlet)中的异常处理着手点

不严谨的说,Spring Boot项目中的异常处理主要3中地方:

  1. @ExceptionHandler 注解的方法
  2. HandlerExceptionResolver
  3. BasicErrorController(即 spring.web.error.path 默认的 /error 端点)

1. @ExceptionHandler 注解的方法

说到Spring Boot异常处理,很多人都会说有 @ExceptionHandler 、 还有 @ControllerAdvance ,其实后者不是异常处理,下面会展开讲讲。

先说 @ExceptionHandler 方法。

为了处理项目中的异常,我们可以写一个专门用于处理异常的异常处理器类:

public class MyExceptionHandler { @ExceptionHandler(AbcException e) public Object handle() { ... } @ExceptionHandler(XxxException e) public Object handle() { ... } ... }

类写好了,但是如何让它生效呢?我们理所应当的想到把它注册为一个bean对象,于是在 MyExceptionHandler 类上加上了 @Component 注解。测试发现,哎?它不起作用啊?!!

这就对了,因为Spring Boot中负责扫描异常处理的组件(ExceptionHandlerExceptionResolver)它不扫描 @Component ,只扫描 @Controller 、 @ControllerAdvance 这两类bean中的异常处理方法。

说明1:@ControllerAdvance 中的异常处理只处理Controller中的异常,其它地方的异常(如过滤器)则不会被处理。

说明2:为什么设计只扫描 @Controller 、 @ControllerAdvance 这两类bean?作者猜测可能是由于目前Spring Boot的异常处理只对Controller生效,其它的地方(如过滤器等)不能生效,而使用 @Controller 、 @ControllerAdvance 很好的表达了作用于Controller的意图,而使用通用的 @Component 可能会让人误解和疑惑应该/为什么过滤器不生效。将来若对过滤器等非Controller的地方也能生效,可能就会支持使用 @Component 注解吧。

@ExceptionHandler 方法和 @ControllerAdvance 的用法就不再说了,很多资料也很容易理解。 @ExceptionHandler 方法可以位于Controller中,也可以位于 @ControllerAdvance 中。除此之外通常不会再见到其它形式(本文中将会见到),它俩经常一起出现,所以大家才容易混淆觉得“@ControllerAdvance”就是异常处理。

@ControllerAdvance 是一种对Controller层进行AOP切面的设计,它的应用场景,比如将 @InitBinder 方法配置的数据绑定相关设置生效于所有的Controller、非纯后端项目的Model中添加公共属性、统一处理响应体结构(如加 code: 200, data: {xxxx})、又或者对请求体进行一些预处理等等。

能够生效的报错场景:

  1. Controller接口抛出异常;

不能生效的报错场景:

  1. Filter过滤器抛异常;
  2. StrictHttpFirewall RequestRejectedException 异常

3. BasicErrorController(即 spring.web.error.path 默认的 /error 端点)

解释2之前,需要有一些关于3的背景,所以这里先介绍3。

BasicErrorController这个Controller很简单,就监听了任何请求方法 /error 端点。其核心两个方法的源码如下:

@RequestMapping(produces = MediaType.TEXT_HTML_VALUE) public ModelAndView errorHtml(HttpServletRequest request, HttpServletResponse response) { HttpStatus status = getStatus(request); Map<String, Object> model = Collections .unmodifiableMap(getErrorAttributes(request, getErrorAttributeOptions(request, MediaType.TEXT_HTML))); response.setStatus(status.value()); ModelAndView modelAndView = resolveErrorView(request, response, status, model); return (modelAndView != null) ? modelAndView : new ModelAndView("error", model); } @RequestMapping public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) { HttpStatus status = getStatus(request); if (status == HttpStatus.NO_CONTENT) { return new ResponseEntity<>(status); } Map<String, @Nullable Object> body = getErrorAttributes(request, getErrorAttributeOptions(request, MediaType.ALL)); return new ResponseEntity<>(body, status); }

基于Spring框架的内容协商,若请求者偏好的Content-Type为html(如浏览器),则由errorHtml方法处理;其它情况(如客户端),则降级为通用的error方法处理(该方法将响应处理为json格式)。

至此,我们知道了有 /error 这个端点可以响应错误场景时的信息。

Servlet容器(Tomcat)有一些配置错误端点的设计,它旨在告诉Servlet容器当遇到异常时(如Spring项目中的异常最终抛到了Tomcat那里)该如何处理。Spring会将 /error 配置为Servlet遇到异常的转发端点。

由于Servlet是更低级的容器,现在有了上面 /error 兜底的配置,所以整个Spring项目怎么玩都不会崩,再不济也是异常抛到了tomcat那里,根据配置转发 /error 端点,于是Spring框架的 BasicErrorController 就进行一个简单的回应 (Spring默认的Json异常响应格式 / Spring默认的白标错误页面)。

2. HandlerExceptionResolver

HandlerExceptionResolver 是Spring mvc中的一种统一的异常处理方案。接口很简单,源码如下:

public interface HandlerExceptionResolver { @Nullable ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, @Nullable Object handler, Exception ex); }

框架回调 HandlerExceptionResolver 实现类的 resolveException 方法。在实现类的 resolveException 方法中,判断若支持处理该异常,则进行异常处理操作并最终返回一个 ModelAndView 对象。若不支持该异常,则return null,框架就知道该 HandlerExceptionResolver 对象不处理这个异常,于是继续寻找下一个 HandlerExceptionResolver 对象。若遇到所有 HandlerExceptionResolver 对象都不支持处理的异常,则会进入 BasicErrorController 这个最后的底线,并由它进行异常处理(准确说是一种异常情况下的基本响应而不是异常处理)。

现在知道了 HandlerExceptionResolver ,就可以进行高级探索了。

其实 @ExceptionHandler 它本质上也是 HandlerExceptionResolver。就像上一段中说的,项目中有多个 HandlerExceptionResolver ,其中优先级高的就是 ExceptionHandlerExceptionResolver,这哥们就是前面说的那个只从 @Controller / @ControllerAdvance 中扫描 @ExceptionHandler 异常处理器的家伙。它会先看看目前所有的 @ExceptionHandler 中有没有能处理当前发生异常的处理器,如果有就调用它来处理,异常处理的流程就结束了。

既然 HandlerExceptionResolver 和 @ExceptionHandler 都可以处理异常,那么应该用哪个呢?毫无疑问,肯定@ExceptionHandler嘛。如果HandlerExceptionResolver就很好,为什么还额外设计@ExceptionHandler?不就是为了开发者更加简单、方便、优雅的处理异常嘛。@ExceptionHandler是基于HandlerExceptionResolver的,越封装肯定越高级。

接下来说说 ResponseStatusException 这个异常,用过吧,为了方便开发者抛异常控制响应的。为什么 throw new ResponseStatusException 异常后,就能自动被处理成对应的响应码和响应体呢?其实它的原理,本质上也是HandlerExceptionResolver(注意:指项目非开启的 RFC 9457 问题详情 的情况)。没错,就是众多的 HandlerExceptionResolver 对象之一,对应类为 ResponseStatusExceptionResolver,优先级过完 ExceptionHandlerExceptionResolver 就数到它了。ResponseStatusExceptionResolver的处理方式也很简单,根据 ResponseStatusException 异常的状态,作为参数调用 HttpServletResponse对象的sendError(int sc)方法,之后tomcat会转发到 BasicErrorController 进行响应。

三、Spring Boot项目中的非Controller异常如何处理?

了解了上诉知识和原理后,我提出一个新的困境:

实际的项目中可能不是完全理想的用Controller等实现业务逻辑,很常见的场景如用过滤器实现租户、授权、Spring Controller边界的路由校验、Spring Security的StrictHttpFirewall等逻辑代码,它们也需要抛异常。由于这些逻辑可能在DispatcherServlet的外围/前面,而这些异常并不能优雅的用Spring框架的@ControllerAdvice、@ExceptionHandler机制来处理,也不能复用它的return值自动Json处理等逻辑。所以,就需要自造轮子进行手动的各种处理。面对这种现状,应该如何寻找更佳的处理方案?

作者认为有一种通过注册过滤器将异常桥接到 HandlerExceptionResolver 的方案。首先我们注册一个优先级非常高/最高的过滤器,该过滤器将执行后续链的代码try catch起来,在catch块调用 HandlerExceptionResolver 处理异常:

import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.NonNull; import lombok.RequiredArgsConstructor; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; import org.springframework.web.servlet.HandlerExceptionResolver; import org.springframework.web.servlet.ModelAndView; import java.io.IOException; @RequiredArgsConstructor @Component @Order(Ordered.HIGHEST_PRECEDENCE) // 注册为最高优先级 public class BestExceptionFilter2 extends OncePerRequestFilter { // 注意注入的bean名字应为 “handlerExceptionResolver”,某些情况(如变量名不叫handlerExceptionResolver或编译元数据未开启)可能需要明确的显示指定bean名 private final HandlerExceptionResolver handlerExceptionResolver; @Override public void doFilterInternal(@NonNull HttpServletRequest httpRequest, @NonNull HttpServletResponse httpResponse, @NonNull FilterChain filterChain) throws ServletException, IOException { try { // 将整个后续过滤器链调用都try起来 filterChain.doFilter(httpRequest, httpResponse); } catch (Exception e) { // 遇到任何异常都会进入这里 // 1. 尝试使用 handlerExceptionResolver 处理异常,这包括: // - @ExceptionHandler方法 // - ResponseStatusExceptionResolver 等 ModelAndView mav = handlerExceptionResolver.resolveException(httpRequest, httpResponse, null, e); if (mav != null) return; // 注意:如果mav为null,说明 handlerExceptionResolver 没有找到任何异常处理,且该异常仍未处理,因此需要再次抛出,交由Servler容器转到 /error 兜底处理。若不抛出,则任何未处理的异常都会200(OK)且无任何响应体。 throw e; } } }

自此,我们就搞定了过滤器中的异常处理。这是不是就万事大吉了?

并不是!接下来说说 /error 的重要性。

有些异常并不会抛到Servlet过滤器中来,而是框架自己内部消化了。比如 Spring Security的 Http防火墙,StrictHttpFirewall抛出RequestRejectedException异常,但Spring Security自己(HttpStatusRequestRejectedHandler)又捕捉处理了,因此对于Servlet来说,它不知道过滤器的内部发生了异常。那 HttpStatusRequestRejectedHandler 又是如何处理的呢?

HttpStatusRequestRejectedHandler源码:

public class HttpStatusRequestRejectedHandler implements RequestRejectedHandler { private static final Log logger = LogFactory.getLog(HttpStatusRequestRejectedHandler.class); private final int httpError; public HttpStatusRequestRejectedHandler() { this.httpError = HttpServletResponse.SC_BAD_REQUEST; } public HttpStatusRequestRejectedHandler(int httpError) { this.httpError = httpError; } @Override public void handle(HttpServletRequest request, HttpServletResponse response, RequestRejectedException requestRejectedException) throws IOException { logger.debug(LogMessage.format("Rejecting request due to: %s", requestRejectedException.getMessage()), requestRejectedException); response.sendError(this.httpError); } }

很简单,它就一行代码,就是调 HttpServletResponse 的sendError(int sc)方法,和ResponseStatusExceptionResolver一样,后续自然是转到了 BasicErrorController 那里进行响应。

所以说, /error (BasicErrorController) 是很重要的兜底处理。

对于 Spring Security 这种框架里的异常,其实已经不太算业务部分了,而是技术细节,且框架已经做出了异常处理,因此通常没有必要对这种异常进行处理。但若真的需要处理,则可以从覆盖 BasicErrorController 或 自定义DefaultErrorAttributes 作为着手点。

四、最佳实践

尽管上一步已经做到了可以集中处理包含过滤器在内的异常,但概念上,过滤器的异常经过 handlerExceptionResolver 调用了 @ControllerAdvance ,感觉似乎又那么点说不过去:过滤器作为前面的/低级的东西,跑到 Controller 概念里处理异常。

可是 @ExceptionHandler 注解的方法又不能用 @Component 注解啊,怎么办?

我们可以用继承的思想。首先创建一个不包含 @ControllerAdvance 注解的、通用的、面向Controller和过滤器的异常处理器类,如上面的MyExceptionHandler。然后创建一个 Controller异常处理器,它继承MyExceptionHandler,并添加 @ControllerAdvance。

嗯,看起来很不错了。

说明:其实了解 RFC 9457 问题详情 就会知道,有 ResponseEntityExceptionHandler 类处理了很多异常,而它的设计也是如此。观察就会发现ResponseEntityExceptionHandler没有 @ControllerAdvance,然后再专门一个ProblemDetailsExceptionHandler实现类继承它,并添加@ControllerAdvice。

五、RFC 9457 问题详情

不想写了,感兴趣参考:

https://docs.spring.io/spring-framework/reference/7.0/web/webmvc/mvc-ann-rest-exceptions.html

https://docs.spring.io/spring-boot/4.0/reference/web/servlet.html#web.servlet.spring-mvc.error-handling

六、启用 RFC 9457 后 BasicErrorController 的表现不一致问题

经过观察发现启用 RFC 9457 后 BasicErrorController 的表现不一致问题,而BasicErrorController目前通过Map类型的 ErrorAttributes 方式决定响应结果。虽然可以通过 getErrorAttributeOptions 方法未使用的mediaType预留字段对html和json两种场景提供 ProblemDetail 支持,但是概念上,ProblemDetail 和 传统Spring默认错误响应(ErrorAttributes)属于两种独立的模式,因此依赖 ErrorAttributes 实现有点不合适。而开发者决定 “我们可能还需要重新审视底层基础架构” ,这意味着将来 BasicErrorController 的设计和写本文章时的设计可能有所改变。

跟进:该issue Render global errors as Problem Details #43850 就是跟进BasicErrorController的RFC 9457支持,计划 Spring Boot 4.x 里程碑中添加支持。

BasicErrorController 当前并不支持 RFC 9457,仍会返回旧的Spring Boot默认格式。说明见: https://github.com/spring-projects/spring-boot/issues/48392