iT邦幫忙

2026 iThome 鐵人賽

DAY 20
0
Software Development

30 篇淺談 Spring 框架的核心底層組件 系列 第 20 篇

Day 20: Filter —— Servlet 容器規範與請求前置攔截的責任鏈設計

  • 分享至 

  • xImage
  •  

昨天我們簡單講了 DispatcherServlet 跟 Spring MVC 的核心組件,裡面有提到「GenericServlet 定義了基本的 Servlet 骨架」,今天我們回頭聊一下什麼是 Servlet。「Servlet」這個詞可以代表的事情很多,它可以是白紙黑字的協議與規範、可以是一組 Java 的基本介面、也可以是一個被 new 出來的物件。為了方便閱讀,後續這三個名詞我會改為 servlet 規範、servlet 介面、和實體 servlet 這三種說法。Servlet 介面是 javax.servlet(現代的話是 jakarta.servlet)套件下的一個基本介面,是基於 servlet 規範衍生出來的產物,內部定義了 5 個方法,扣掉兩個 getter 以外,剩餘的 3 個則是實體 Servlet 生命週期依序會使用到的方法:init 負責做初始化,在容器建立實體 Servlet 之後會呼叫一次;service 方法是實際處理請求的統一入口,每個請求進來都會呼叫;destroy 顧名思義,是銷毀時調用的方法,通常用來釋放資源跟記一點日誌。

這裡有一個小東西可以注意:上面說到的「容器」指的並不是之前講的 ApplicationContext(或其他衍生類別),它指的是「實作了 Servlet 規範的 Web 容器。」像是 Tomcat 或 Jetty ...等。因為這些容器本身就有創建實體 servlet 的能力,所以我們在實務的開發上,從來都不需要關心封包的拆裝、三方交握的細節過程 ...等底層邏輯。早期的容器曾有過一段「一個請求對應一個實體 servlet」的時期,但因為效能的緣故,後續的 Servlet 規範就把這個機制廢棄並移除了,所以從當前的角度看,實體 Servlet 的運作原理跟我們熟悉的 Spring 很類似,都是以單例的方式在運行程式。也因此,實務上在撰寫實體 Servlet 的協作組件(通常指的就是 Filter)的時候,會不建議使用成員變數保存與請求相關的狀態,改用區域變數(或者 ThreadLocal)紀錄請求當中的相關訊息。

Servlet 規範除了定義介面應該需要有哪些能力以外,也規定了其餘的協作組件本身帶有哪些職責:以 Filter 為例,Servlet 規範將 Filter 設計成一種雙向都會進行攔截的責任鏈模式,Filter 介面內部定義了 doFilter 方法,並攜帶 request, response, 和 filterChain 這三個參數。只要我們不在 doFilter 裡面呼叫下一層的 filterChain.doFilter(),那麼請求就會在這裡被迫中斷,進不到 Controller, Service ...等後續的業務邏輯細節。這種天然的短路機制,讓我們可以很輕易在 Filter 這裡完成全域類型的 CORS、Token 驗證、黑白名單、統一的日誌紀錄 ...等功能。也可以利用「雙向」的特性,進行自定義的請求加解密、或者是 API 的耗時監測。但值得注意的是:因為 Filter 拿到的 Servlet Request 預設是單向的(意思是讀一次就不能再讀了)因此通常 Filter 的操作只會針對請求頭、或其他 non request body 的內容進行操作,或者是將預設的 request 進行包裝,調整為可以多次讀取的型別(以 Spring boot 為例的話就是改用 ContentCachingRequestWrapper),再進行 Request Body 的閱讀。

這裡還有一個東西可以注意:雖然 Spring boot 提供了「可以透過 @Component 註解動態掛載新 Filter」的能力,也就代表它是一個可以被 Spring 容器管理的普通 Bean,但從理論的角度接入,都會建議盡可能不要(或完全不要)對 Filter 的相關組件做跟 IoC 容器強耦合的相關邏輯,當然也不會做 AOP 切面之類的後續代理。原因也很單純,因為從更為宏觀的角度往下看,Filter 本就是容器的一種 AOP,也就不會讓目標物件本身再去切代理物件;此外,如果我們從 DDD(或者是其他分層架構)的角度來看,外圍的物件本身就不應該感知過多內層物件的相關細節(就像你不會在 Controller 寫一些跟業務邏輯或 DB 有關的判斷),同樣也不該放太多 IoC 容器的程式在 Filter 裡面。不過雖然這麼說,我自己在實務上,還是會在 Filter 依賴一些環境變數(或配置的相關檔案),就還是視專案的需求和規模而定。

最後,我們簡單談一下「Spring boot 可以透過 @Component 掛載 Filter」這件事。在原始的架構上(指的是 Spring 框架或 Spring MVC 框架這兩種架構),Servlet 容器 / IoC 容器本來就是一條單向的線,IoC 容器能對 Servlet 呼叫的手段極其有限,因此在過去的情況下,如果我們想要新增 Filter,最原始的方式是透過 Servlet 提供的 ServletContextListener 介面,通過實作 contextInitialized 方法,手動取得 Servlet 容器,並動態註冊新的 Filter 上去;或者是利用 Servlet 的 SPI 機制,實作 ServletContainerInitializer 類別,完成裡面的 onStartup,再透過約定的位置(META-INF/services/...)做到啟動時的動態掛載;或 Spring MVC 的 WebApplicationInitializer ,原理也跟 Servlet 的 SPI 類似 ...。無論是上述的哪種作法,最後都繞不開的問題是「環境還是先需要有一個 Tomcat 容器,後面才可以做 IoC 的注入跟掛勾」,這對有在維護或開發早期環境的 Java 開發者應該不陌生,系統都是要先等 Tomcat / JBoss 啟動之後,才可以掛載後面的 java 服務。

這點其實在後續的 Spring boot 也有做過一次調整:因為Spring boot 主打的邏輯是一個 jar 就可以隨時運行,因此在啟動的順序上,不再像傳統邏輯先硬綁定一個 Servlet 容器,再把 IoC 容器掛上 Servlet。它的做法是先啟動 IoC 容器(也就是先運行 ApplicationContext 裡面的 onRefresh 邏輯),後續再找一個適當的時機,把容器擴展為「帶有 IoC 容器相關參數的 Servlet 容器」並創建底層的 Servlet 服務(預設是 Tomcat)。這也是前一天 FrameworkServlet 那裡,說到「專注於 Servlet 與 Spring IoC 容器深度綁定」的其中一個細節。這段內容後續會再找詳細的時間展開聊聊,今天只是先簡單提及。明天開始會簡單介紹昨天有談到的幾個核心組件,後續也會在 Interceptor 的章節簡單比較兩者之間的差異。

期待後續各位的閱讀與分享,我是 Pax,我們明天見。


上一篇
Day 19: DispatcherServlet —— 請求分發與 Spring MVC 核心處理流程
下一篇
Day 21: HandlerMapping —— Spring MVC 的路由與執行鏈組裝機制
系列文
30 篇淺談 Spring 框架的核心底層組件 共 24 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言