一個 error 引發兩小時的 SpringMVC 原始碼 debug

前言

最近入職新公司,先臨時接手一個認證專案,對於本人這種有程式碼優雅強迫症的,看到不爽的程式碼毫無疑問就是改!改!改!然而改完之後前端給我反饋了介面總是報 401 錯誤。我的內心:我草?難道是我改出 bug 了?不應該吧,這麼簡單的東西怎麼會有 bug !於是我自己測試了下,還真是有問題,但不是我的問題,下面開始分析!

虛擬碼場景還原

登入介面,模擬報錯

@PostMapping(“/user/login”)public LoginResult login(@RequestBody LoginRequest request) { throw new RuntimeException(“模擬登入介面報錯”);}

接著貼出攔截器,如果需要認證的請求沒有攜帶 token ,或者 redis 中查不到該 token 相關使用者,就丟擲異常

public class UserLoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle( HttpServletRequest request, HttpServletResponse response, Object handler) { String token = request。getHeader(“token”); if (token == null) { throw new UnauthorizedException(“未認證或token已過期”); } else { if(redis。get(token) == null) { throw new UnauthorizedException(“未認證或token已過期”); } //。。。將token和使用者資訊設定到 ThreadLocal } return true; }}

攔截器配置

@Configurationpublic class WebConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry。addInterceptor(new UserLoginInterceptor()) 。excludePathPatterns(“/user/login”) 。addPathPatterns(“/**”); }}

這個專案是透過攔截器中獲取 token 去 Redis 查使用者資訊放到 ThreadLocal 裡面的,由於一個請求從 Controller → Service → Mapper 執行緒 ID 都是一致的,這樣一條請求鏈都能從這個 ThreadLocal 裡面拿到當前登入使用者資訊。可以看到 /user/login 是被攔截器放行的,然而當這個請求的 Controller 報錯,預期的 message 資訊應該是 模擬登入介面報錯,然而執行時報的居然是下面未認證的錯,這說明我們的請求走到了攔截器

{ “path”: “/error”, “message”: “com。yinshan。auth。core。exception。UnauthorizedException: 未認證或token已過期”, “error”: “Unauthorized”, “status”: 401, “timestamp”: “2021-09-22T14:03:39。986559500”}

當然這個錯誤資訊格式是我自己處理過的,這個不重要,重點是我在登入介面中報 500 的錯,為啥變成了攔截器中的 401 未認證。

除錯分析

廢話少說,直接 debug 走起,在拋異常的程式碼上打個斷點,再把攔截器中打個斷點

一個 /error 引發兩小時的 SpringMVC 原始碼 debug

一個 /error 引發兩小時的 SpringMVC 原始碼 debug

結果在登入介面按下 F9 之後,斷點確實走到了攔截器中,

一個 /error 引發兩小時的 SpringMVC 原始碼 debug

說實話我當時真的是這個表情,這特麼已經被攔截器放行的介面報錯關攔截器什麼事?然而在除錯面板仔細一看 preHandle 這個方法的請求引數詳細資訊發現了貓膩。

一個 /error 引發兩小時的 SpringMVC 原始碼 debug

圖中箭頭指向是很重要的資訊:

是當前請求的上下文,正常請求走攔截器時是沒有這個上下文

請求的分發型別,正常請求的值是 REQUEST

特別顯眼的是這個請求資源 uri,根本不是我請求的 /user/login,而是一個 /error

看到這裡大致就明白了,這個斷點走到攔截器,不是因為 /user/login 這個請求,而是另一個 /error 請求。那麼這個 /error 是怎麼來的?由於圖中的 TomcatEmbededContext 上下文是 SpringBoot 內嵌的 Tomcat 中的一個類,我猜這個請求應該是 SpringMVC 控制器遇到未處理的報錯重新內部發起的一個 /error 請求。

也許你會疑惑,這不是找到問題了嗎?好像挺快的呀,你為啥搞了兩個小時呢?

因為我菜啊!

我除錯的時候壓根就沒關心這個引數是啥,而是一步一步 F8 → F7 → F8 → F7 …… 過五關斬六將。。。最後除錯到了 DispatchServlet 的時候我才反應過來,這特麼怎麼跑到請求轉發了,最後終於明白了,人都麻了。

查詢官方文件

果然在 SpringMVC 的官方文件找到了說明

一個 /error 引發兩小時的 SpringMVC 原始碼 debug

官網說的很清楚了,如果異常沒有被預設的異常處理器處理,那麼 Servlet 容器將會用 DispatchServlet 分派一個 /error 請求,也可以對 /error 請求進行定製化處理,詳情可以參考 SpringMVC 官方文件

具體原因

SpringMVC 的控制器報錯之後伺服器會弄一個 /error 的請求,由於我們的攔截器沒有放行這個 /error 請求,所以會在 DispatchServlet 中執行該請求的攔截器(我突然想起兩年前還寫過自定義 SpringBoot 異常頁面,就是處理的 /error 請求)

protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception { //。。。 //判斷並執行攔截器的 preHandle() if (!mappedHandler。applyPreHandle(processedRequest, response)) { return; }

上面是 DispatchServlet 的 doDispatch 部分原始碼,我相信大多數人對於 doDispatch 的理解都停留在為了面試,背 SpringMVC 執行流程的時候。其實網上對於 SpringMVC 執行流程畫的圖都是幾個關鍵節點,並沒有這麼細緻,如果說沒有真正帶著問題除錯過這段原始碼,那麼大機率也是不懂這個問題的。

解決方案

明白了問題的原因,解決就很簡單了。只要在我們自定義的認證攔截器中排除掉對 /error 的攔截即可

@Overridepublic void addInterceptors(InterceptorRegistry registry) { registry。addInterceptor(new UserLoginInterceptor()) 。excludePathPatterns(“/error”)。addPathPatterns(“/**”);}

談談攔截器

其實上面的問題很大一部分都是因為對攔截器沒有真正的理解,只是知道它能夠攔截一個請求,而沒有研究過它在什麼階段攔截,在 SpringMVC 中又是怎麼去實現的。那麼接下來深入分析一下攔截器

攔截器與過濾器的使用範圍

檢視 Filter 介面原始碼就能發現,它是 javax。servlet 包下的,而 HandlerInterceptor 是 org。springframework。web。servlet 包下的,攔截器是 SpringMVC 實現的,實際上它只是一個或者多個 Java 類組合實現攔截而已,和 web 應用沒有必然聯絡。這意味著過濾器只能在 web 應用中使用,而攔截器可以用在任何可以用 Spring 和 SpringMVC 的地方,比如桌面應用程式。

攔截器和過濾器的執行順序&執行流程

過濾器的執行是在請求到達 Servlet 之前透過 ApplicationFilterChain。doFilter() 進行鏈式呼叫的,在 doFilter() 內部獲取到下一個過濾器例項,執行過濾方法,它的執行順序是 filter1 → ApplicaitonFilterChain。doFilter() → filter2 → ApplicationFilterChain。doFilter() → filter3 → ……

如下圖

一個 /error 引發兩小時的 SpringMVC 原始碼 debug

而攔截器的執行是請求到達 DispatchServlet 之後針對 Controller 方法執行前、執行後做的一些事情,如下圖,這裡的過濾器鏈就是上面那張圖

一個 /error 引發兩小時的 SpringMVC 原始碼 debug

很明顯 preHandle() 才是攔截的關鍵,只有它是在請求到達 Controller 目標方法之前執行的,該方法透過返回 true/false 決定請求是否需要被攔截。

doDispatch 內部對攔截器的處理部分原始碼

我們都知道 DispatchServlet 的 doDispatch() 方法是處理所有請求的,內部和攔截器相關的程式碼如下

//呼叫 Controller 目標方法前執行攔截器的 preHandle()if (!mappedHandler。applyPreHandle(processedRequest, response)) { return;}mv = ha。handle(processedRequest, response, mappedHandler。getHandler());//反射呼叫 Controller 目標方法/** * 。。。省略 * */mappedHandler。applyPostHandle(processedRequest, response, mv);//Controller 目標方法執行完後呼叫攔截器 postHandle()//請求完成之後執行攔截器的 afterCompletion()processDispatchResult(processedRequest, response, mappedHandler, mv, dispatchException);

其實真正帶著問題除錯過原始碼的話,根本就不需要背 SpringMVC 的執行流程面試題啦~~~ 我就背不下來,但是從原始碼除錯過程中,我已經很清楚了 DispatchServlet 在請求轉發過程中都做了那些事情,結合之前說過的 引數校驗神器 hibernate-validator 配合統一異常處理 自然也明白了 SpringMVC 是怎樣實現請求引數的解析、轉換的。

結語

遇到問題不要慌,原始碼除錯沒有那麼難,我覺得帶著問題去看原始碼更能夠讓印象更深刻。來新公司不到一個月,我已經帶著問題看了好幾次原始碼了……正好趕上換技術元件的大版本,總是有各種奇奇怪怪的問題。

平時多看看框架、技術元件的官方文件真的是一個非常好的習慣,不要總侷限於某些影片教程。多讀官方文件,才能發現元件可能存在的問題,出現問題的原因。

作者:暮色妖嬈丶

連結:https://juejin。cn/post/7013636541566156813