Spring Boot 異常處理 - 良好實踐
作者 ximinghui 寫於 2025年12月5日
源:https://blog.ximinghui.org/efade41d/index.html
一、背景
本篇淺談Spring Boot項目中的異常處理。
假設 Spring Boot 項目如下:
- 使用 Spring Security 確保應用安全;
- 使用 TokenFilter 處理請求中攜帶的授權頭;
- 使用 @RestController 類提供一些 API 端點。
説明:TokenFilter可以是Servlet過濾器(jakarta.servlet.Filter),也可以是繼承自Servlet過濾器的Spring過濾器(如:OncePerRequestFilter),兩者在異常處理流程中無大的差異,本文章中將它們視為一類。Controller和RestController兩者在異常處理流程中無大的差異,本文章中將它們視為一類,因此可能會混用,但指的同一類東西。
報錯的場景如下:
- Controller接口拋出異常;
- Filter過濾器拋異常;
- Spring Security拋出異常(以StrictHttpFirewall為例)
説明1:可以請求一個路徑帶 "//" 的端點來觸發StrictHttpFirewall拋出RequestRejectedException異常。後面就以RequestRejectedException異常代指第三種報錯場景。
説明2:StrictHttpFirewall旨在攔截不安全的或存在歧義的一些請求,比如url中帶有 //、 /../ 之類的,發現並拋出RequestRejectedException異常。
大體的前後流程是:
- HTTP請求(前端)
- Servlet容器(如Tomcat)
- 掛在Spring Security的FilterChain中的一堆過濾器(可能非Servlet過濾器),
- Servlet過濾器和Spring過濾器
- Controller端點
説明:之所以把它排在Servler過濾器前面並不是説它絕對的早於Servlet過濾器,而是通常大多數情況下,Spring Security的過濾器鏈都會註冊到較為靠前的位置。Spring Security的過濾器鏈肯定還得以 Servlet過濾器 的形式註冊到Servlet容器中,當然可以手動註冊一個 @Order(Ordered.HIGHEST_PRECEDENCE) 的Servlet/Spring過濾器插在Spring Security前面。
二、Spring Boot項目(含Servlet)中的異常處理着手點
不嚴謹的説,Spring Boot項目中的異常處理主要3中地方:
- @ExceptionHandler 註解的方法
- HandlerExceptionResolver
- 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})、又或者對請求體進行一些預處理等等。
能夠生效的報錯場景:
- Controller接口拋出異常;
不能生效的報錯場景:
- Filter過濾器拋異常;
- 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