Day05 畫過一張請求生命週期圖,當時 Filter 跟 Interceptor 兩站直接跳過。今天把這兩站補上:
Client 發出請求
↓
Filter ← 今天的主角之一(Servlet 層)
↓
DispatcherServlet
↓
HandlerMapping (找出要交給哪個 Controller 方法)
↓
Interceptor ← 今天的主角之二(Spring MVC 層)
↓
Controller → Service → Repository
↓
Interceptor → Filter(反向執行後處理)
↓
回應送回 Client
從 Laravel 過來,第一個疑問一定是:Laravel 只有一種 Middleware,為什麼 Spring 要分兩種?
Laravel 的 Middleware 大家都很熟:
class LogRequestTime
{
public function handle(Request $request, Closure $next)
{
$start = microtime(true); // 前處理
$response = $next($request); // 交給下一站
$ms = (microtime(true) - $start) * 1000;
$response->headers->set('X-Response-Time', round($ms) . 'ms'); // 後處理
return $response;
}
}
$next($request) 前面是前處理、後面是後處理,一個方法就包完整個請求,也就是常聽到的「洋蔥模型」。要套用到全部請求就在 bootstrap/app.php 註冊成全域 Middleware,只要套到某些路由就用 Route::middleware()。
關鍵在它們站的位置不同:
DispatcherServlet 前面,屬於 Servlet 規範(Jakarta Servlet),不是 Spring MVC 的東西。請求進來時還沒經過 HandlerMapping,所以 Filter 不知道這個請求最後會交給哪個 Controller 方法,它只看得到最原始的 HTTP 請求簡單分工原則:
| 需求 | 用哪個 |
|---|---|
| 跟「要去哪個 Controller」無關,每個請求都要做(加 Request ID、CORS、改寫回應 header) | Filter |
| 需要知道目標 Controller 方法,或只針對部分路徑(依註解判斷權限、只記錄 API 的耗時) | Interceptor |
Laravel 其實也有類似的分界:全域 Middleware 在路由比對之前執行,這時候 $request->route() 還是 null;路由 Middleware 則在比對之後執行,拿得到路由資訊。只是 Laravel 把兩者都叫做 Middleware、寫法完全一樣,差別只在註冊的位置;Spring 則把它們做成兩種不同的東西,介面、註冊方式都不一樣。
縮網址服務之後部署到 Raspberry Pi 上(Day28),出問題時要能從一大堆 log 裡找出「同一個請求」的所有紀錄。常見做法是幫每個請求發一個唯一 ID,放在回應 header 裡,也寫進 log:
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
class RequestIdFilter extends OncePerRequestFilter {
private static final String HEADER = "X-Request-Id";
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
String requestId = Optional.ofNullable(request.getHeader(HEADER))
.filter(id -> !id.isBlank())
.orElseGet(() -> UUID.randomUUID().toString());
response.setHeader(HEADER, requestId); // 前處理:在回應寫出之前先放好 header
MDC.put("requestId", requestId); // 放進 log 的上下文,Day22 會用到
try {
chain.doFilter(request, response); // 交給下一站,等同 Laravel 的 $next($request)
} finally {
MDC.remove("requestId"); // 後處理:一定要清掉,原因在下一節
}
}
}
幾個跟 Laravel 對照的點:
chain.doFilter() 就是 $next($request):前面是前處理、後面是後處理,洋蔥模型完全一樣。差別在 Laravel 的 $next() 會回傳 $response 讓你改,Spring 的 doFilter() 沒有回傳值,回應物件從參數傳進來,要改就直接改那個物件OncePerRequestFilter:直接實作 Filter 介面的話,同一個請求在某些情況下(例如內部 forward、錯誤頁面轉發)可能會經過同一個 Filter 兩次。OncePerRequestFilter 是 Spring 提供的基底類別,保證一個請求只執行一次@Component 就等於註冊成全域 Middleware:Spring Boot 看到 Filter 類型的 Bean 會自動套用到所有請求,不用另外設定。@Order 決定多個 Filter 的執行順序,數字越小越早執行MDC 是什麼:Mapped Diagnostic Context,是 log 框架提供的「目前這個執行緒的 log 上下文」。放進去之後,這個請求期間印出的每一行 log 都能自動帶上 requestId,詳細設定留到 Day22(日誌與可觀測性)Day04 講 Bean 預設是 Singleton 的時候說過:整個應用程式只有一個實例,所有請求共用,所以不能把「這次請求的狀態」存在 Bean 的成員變數裡。Filter 跟 Interceptor 都是 Bean,這個提醒在這裡最容易被違反。
假設想在 Interceptor 裡計算 API 耗時,直覺會這樣寫:
// ❌ 錯誤示範
@Component
class ApiTimingInterceptor implements HandlerInterceptor {
private long startTime; // 所有請求共用這一個變數!
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
startTime = System.currentTimeMillis();
return true;
}
...
}
本機自己一個人測永遠正常,上線後兩個請求同時進來,第二個請求就會把第一個請求的 startTime 蓋掉,算出來的耗時全亂。PHP 每個請求都重新建立物件,完全不會遇到這個問題,所以從 Laravel 過來特別容易踩(Day02 講的「長駐進程 vs 每請求重建」差異,又出現了一次)。
正確做法是把狀態跟著請求本身走,存在 request attribute 裡:
request.setAttribute("startTime", System.currentTimeMillis());
同理,上一節 RequestIdFilter 的 MDC 是綁在執行緒上的,而 Tomcat 會重複使用執行緒處理不同請求,所以 finally 裡一定要 MDC.remove(),不然下一個剛好分到同一條執行緒的請求,會帶著上一個請求的 ID。
/api/** 的耗時@Component
class ApiTimingInterceptor implements HandlerInterceptor {
private static final Logger log = LoggerFactory.getLogger(ApiTimingInterceptor.class);
private static final String START_TIME = ApiTimingInterceptor.class.getName() + ".startTime";
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
request.setAttribute(START_TIME, System.currentTimeMillis());
return true; // 回傳 false 就會中斷請求,不會進到 Controller
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
Object handler, Exception ex) {
long elapsed = System.currentTimeMillis() - (long) request.getAttribute(START_TIME);
String target = (handler instanceof HandlerMethod method)
? method.getBeanType().getSimpleName() + "#" + method.getMethod().getName()
: handler.toString();
log.info("{} {} → {} ({}), {}ms",
request.getMethod(), request.getRequestURI(), target, response.getStatus(), elapsed);
}
}
註冊的方式是寫一個實作 WebMvcConfigurer 的設定類別(Day04 提過,@Configuration 類別大致對應 Laravel 的 Service Provider):
@Configuration
class WebConfig implements WebMvcConfigurer {
private final ApiTimingInterceptor apiTimingInterceptor;
WebConfig(ApiTimingInterceptor apiTimingInterceptor) {
this.apiTimingInterceptor = apiTimingInterceptor;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(apiTimingInterceptor)
.addPathPatterns("/api/**");
}
}
addPathPatterns("/api/**") 的效果就像 Laravel 的:
Route::middleware('api.timing')->prefix('api')->group(function () { ... });
短網址的重定向 GET /{code} 不在 /api/** 底下,所以不會被記錄,只有管理用的 API 才會。
log 印出來大概長這樣,可以看到 Interceptor 知道這個請求是交給 LinkController#store 處理的,這就是 Filter 做不到的地方:
POST /api/links → LinkController#store (200), 23ms
Interceptor 的三個方法:
| 方法 | 執行時機 | Laravel 對應 |
|---|---|---|
preHandle |
Controller 執行前,回傳 false 可中斷請求 |
handle() 裡 $next() 之前 |
postHandle |
Controller 執行後、回應寫出前(但有陷阱,見下節) | handle() 裡 $next() 之後 |
afterCompletion |
整個請求結束後,就算 Controller 丟例外也會執行 | terminate() |
postHandle 改不了 @RestController 的回應既然 postHandle 是在 Controller 執行之後,直覺上會想在這裡幫回應加個 X-Response-Time header,就像 Laravel Middleware 在 $next() 之後改 $response 一樣。但實際跑起來 header 根本不會出現——@RestController 的方法回傳值會在 postHandle 執行之前就被序列化成 JSON 寫進回應,這時候回應已經送出(committed),再加 header 或改內容都不會有效果,而且不會報錯,就是默默沒作用。
postHandle 比較適合傳統伺服器端渲染頁面(Controller 回傳 View、還沒渲染的情境)。前後端分離的 API 專案,要改回應 header 就在 Filter 的 chain.doFilter() 之前處理(像 RequestIdFilter 那樣先 setHeader);要改回應內容,Spring 另外有 ResponseBodyAdvice 可以用。
@RestControllerAdvice 攔不到Day10 寫的 GlobalExceptionHandler 是 Spring MVC 的機制,只管得到 DispatcherServlet 裡面發生的例外——也就是 Interceptor、Controller、Service 丟出來的例外。Filter 站在 DispatcherServlet 外面,在 Filter 裡丟例外,會直接跳過 Day10 整理好的統一錯誤格式,回傳 Servlet 容器預設的錯誤回應。
所以 Filter 裡如果要擋請求(例如驗證失敗要回 401),要自己直接寫回應,不要丟例外:
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json");
response.getWriter().write("{\"message\":\"未授權\",\"errors\":[]}");
return; // 不呼叫 chain.doFilter(),請求就停在這裡
這個差異 Laravel 完全沒有——Laravel 的 Middleware 丟例外,一樣會被全域的例外處理接住。
原本 Laravel 裡最常見的 Middleware 應該就是 auth:sanctum 了。Spring 的認證授權框架 Spring Security,本質上就是一整串 Filter,掛在前面介紹的 Filter 鏈裡。今天把 Filter 的運作方式搞懂,明天 Day13 看 Spring Security 的時候,會比較清楚它到底在請求生命週期的哪一站動手。