iT邦幫忙

2026 iThome 鐵人賽

DAY 12
0
Software Development

從 Laravel 到 Spring Boot:30 天打造縮網址服務系列 第 12 篇

Day12 - Middleware/Interceptor/Filter vs Laravel Middleware

  • 分享至 

  • xImage
  •  

回到 Day05 那張地圖

Day05 畫過一張請求生命週期圖,當時 Filter 跟 Interceptor 兩站直接跳過。今天把這兩站補上:

Client 發出請求
   ↓
Filter            ← 今天的主角之一(Servlet 層)
   ↓
DispatcherServlet
   ↓
HandlerMapping    (找出要交給哪個 Controller 方法)
   ↓
Interceptor       ← 今天的主角之二(Spring MVC 層)
   ↓
Controller → Service → Repository
   ↓
Interceptor → Filter(反向執行後處理)
   ↓
回應送回 Client

從 Laravel 過來,第一個疑問一定是:Laravel 只有一種 Middleware,為什麼 Spring 要分兩種?

Laravel Middleware:一種就夠了

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()。

為什麼 Spring 要分兩層

關鍵在它們站的位置不同:

  • Filter 站在 DispatcherServlet 前面,屬於 Servlet 規範(Jakarta Servlet),不是 Spring MVC 的東西。請求進來時還沒經過 HandlerMapping,所以 Filter 不知道這個請求最後會交給哪個 Controller 方法,它只看得到最原始的 HTTP 請求
  • Interceptor 站在 HandlerMapping 後面,屬於 Spring MVC。這時候 Spring 已經決定好要交給哪個 Controller 方法了,所以 Interceptor 拿得到目標方法本身,連方法上標了什麼註解都讀得到

簡單分工原則:

需求 用哪個
跟「要去哪個 Controller」無關,每個請求都要做(加 Request ID、CORS、改寫回應 header) Filter
需要知道目標 Controller 方法,或只針對部分路徑(依註解判斷權限、只記錄 API 的耗時) Interceptor

Laravel 其實也有類似的分界:全域 Middleware 在路由比對之前執行,這時候 $request->route() 還是 null;路由 Middleware 則在比對之後執行,拿得到路由資訊。只是 Laravel 把兩者都叫做 Middleware、寫法完全一樣,差別只在註冊的位置;Spring 則把它們做成兩種不同的東西,介面、註冊方式都不一樣。

實作 Filter:幫每個請求加上 Request ID

縮網址服務之後部署到 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 對照的點:

  1. chain.doFilter() 就是 $next($request):前面是前處理、後面是後處理,洋蔥模型完全一樣。差別在 Laravel 的 $next() 會回傳 $response 讓你改,Spring 的 doFilter() 沒有回傳值,回應物件從參數傳進來,要改就直接改那個物件
  2. 為什麼繼承 OncePerRequestFilter:直接實作 Filter 介面的話,同一個請求在某些情況下(例如內部 forward、錯誤頁面轉發)可能會經過同一個 Filter 兩次。OncePerRequestFilter 是 Spring 提供的基底類別,保證一個請求只執行一次
  3. @Component 就等於註冊成全域 Middleware:Spring Boot 看到 Filter 類型的 Bean 會自動套用到所有請求,不用另外設定。@Order 決定多個 Filter 的執行順序,數字越小越早執行
  4. MDC 是什麼:Mapped Diagnostic Context,是 log 框架提供的「目前這個執行緒的 log 上下文」。放進去之後,這個請求期間印出的每一行 log 都能自動帶上 requestId,詳細設定留到 Day22(日誌與可觀測性)

兌現 Day04 的提醒:狀態不能存在成員變數

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。

實作 Interceptor:只記錄 /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 可以用。

雷二:Filter 裡丟的例外,@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 丟例外,一樣會被全域的例外處理接住。

預告:Spring Security 其實就是一串 Filter

原本 Laravel 裡最常見的 Middleware 應該就是 auth:sanctum 了。Spring 的認證授權框架 Spring Security,本質上就是一整串 Filter,掛在前面介紹的 Filter 鏈裡。今天把 Filter 的運作方式搞懂,明天 Day13 看 Spring Security 的時候,會比較清楚它到底在請求生命週期的哪一站動手。


上一篇
Day11 - 設定管理 application.yml/Profile vs Laravel .env/config
下一篇
Day13 - Spring Security 入門 vs Laravel Sanctum(API 認證)
系列文
從 Laravel 到 Spring Boot:30 天打造縮網址服務 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言