Apache Tomcat 架構演進

Tomcat 作爲一款知名的輕量級應用服務器,它的架構設計可以值得我們借鑑。因爲 Tomcat 作爲開源以久的 Web 服務器,它的架構還是挺複雜的。這篇博客主要是介紹 Tomcat 的總體架構,通過由淺到深的方案介紹 Tomcat 的架構演進。首先我們先拋開 Tomcat 的現有架構,自己來設計一個 Web 服務器。

本文主要截取於書籍 – 《Tomcat 架構解析》。

1、Server

作爲一個 Web 服務器,它應該有下面的功能

它接口客戶端發送過來的請求。首先對請求進行解析,然後完成相應的業務處理,最後把處理結果響應給客戶端

這個屬於網絡編程,Java 提供了基於 Socket 的網絡編程。所以我們最開始就可以定義一個最簡單的 Web 服務器。

在這裏插入圖片描述
通過 start 方法啓動服務,並且通過 Socket 綁定端口並接收並處理客戶端發送過的請求。

2、Connector 和 Container

將請求監聽與請求處理放在一起擴展性就會很差,並且不符面向對象的單一職責原則。這裏我們就需要把對請求的監聽與請求的處理分離開來。通過 Connector 連接請求處理解析不同的協議,然後處理業務邏輯處理交易容器來處理。容器這個概念是 Tomcat 裏面非常重要的一個概念。

在這裏插入圖片描述

一個 Server 可以包含多個 Connector 和 Container。它們都擁有自己的 start 和 stop 方法來加載和釋放自己維護的資源。

但是這個設計有個明顯的缺陷。就是 Server 可以包含多個 Connect 和 Container ,如何把 Connector 請求與 Container 處理映射請求呢。可以維護一個複雜的映射規則,但是這樣不夠靈活。可以通過引入 Service 這個概念來解決。請求的解析和處理都在一個 Service 裏,而一個 Server 可以包含多個 Service。

在這裏插入圖片描述
在 Tomcat 中 Container 是一個通用的概念。爲了和 Tomcat 的組件一致,我們將 Container 重新命名爲 Engine。用於處理 整個 Servlet 請求。

需要注意的是, Engine 表示整個 Servlet 引擎,而非 Servlet 容器。表示整個 Servlet 容器的是 Server。引擎只負責請求的處理,並不需要考慮請求鏈接、協議處理等。

在這裏插入圖片描述

3、Container 設計

在上面的架構中我們解耦了 Web 請求的接收與處理這兩個動作。

但是 Web 服務器是用來部署並運行 Web 應用的,是一個運行環境,而不是一個獨立的業務處理系統。在一個容器當中可能有多個 Web 服務。因爲我們需要在 Engine 容器中支持多個 Web 應用,並且當接口到 Connector 的處理請求時, Engine 容器能夠找到一個合適的 Web 應用來處理。所以這裏就引入 Context 這個概念。

在這裏插入圖片描述
這裏一個 Context 表示一個 Web 應用,並且一個 Engine 可以包含多個 Context。

Context 也擁有 start 與 stop 方法,用來啓動時候加載資源以及停止時釋放資源。採用這種設計方式,我們將加載與卸載資源的過程分解到每個組件當中,使組件充分解耦,提高服務器的可擴展性和可維護性。在後續的過程中,新增組件多數也人有相同的方法,就不在贅述了。(在開源框架 Spring 中也有同樣的設計)

這是不是個合理的方案呢?

設想我們有一臺主機,它承擔了多個域名的服務,如 news.mycompany.com 和 article.mycompany.com 均由該主機處理,我們應該如果實現呢?當然,我們可以在該主機上運行多個服務器實例,但是如果我們希望運行一個服務器實例呢?因爲作爲 Web 服務器,我們應提供儘量靈活的部署方式。

既然需要提供多個域名的服務,那麼就可以將每個域名視爲一個虛擬的主機,在每個虛擬主機下包含多個 Web 應用。因爲對於客戶端來說,他們並不瞭解服務端使用幾臺主機來爲他們提供服務,只知道每個域名提供了哪些服務。因此,應用服務器將每個域名抽象爲一個虛擬主機從概念上是合理的。

在這裏插入圖片描述
Host 表示虛擬主機的概念,一個 Host 可以包含多個 Context。在這裏插入圖片描述
在 Servlet 規範中,在 Web 應用中,可以包含多個 Servlet 實例來處理不同的鏈接。所以我們還需要一個組件概念來表示 Servlet 定義。在 Tomcat 當中,Servlet 定義被稱爲 Wrappe。

在這裏插入圖片描述
在之前多次提到容器這個概念,有時候指 Engine,有時候指 Context,其實它代表了一類組件。這類組件的具體作用就是處理接收客戶端請求並且返回響應數據。儘管具體操作可能會委派到子組件完成,但是從行爲定義上,它們是一致的。基於這個概念,再次修正我們的設計。

我們使用 Container 來表示容器, Container 可以添加並維護子容器,因引 Engine、Host、Context、Wrapper 均繼承自 Container。我們將它們之前的組合關係改爲虛線,表示它們之間是弱依賴的關係,它們之前的關係是通過 Container 的父子容器的概念體現的。不過 Service 持有的是 Engine 接口(8.5.6 版本之前爲 Container 接口,更加通用)。

既然 Tomcat 的 Container 可以表示不同的概念級別:Servlet 引擎、虛擬主機、Web 應用和 Servlet,那麼我們就可以將不同級別的容器作爲處理客戶端請求的組件。這具體由我們提供的服務器的複雜度決定。假如我們需要以嵌入式的方式啓動 Tomat,且運行極其簡單的請求處理,不必支持多 Web 應用場景。那麼我們完全可以只在 Service 中維護一個簡化版的 Engine(8.5.6 之前甚至可以直接由 Service 維護一個 Context)。當然,Tomcat 的默認實現採用了下圖這種是靈活的方式。只是,我們要了解 Tomcat 的模型設計理論上的可伸縮性,這也是一箇中間件產品架構設計所需要重點關注的。

在這裏插入圖片描述
Tomcat 的 Container 還有一個很重要的功能,就是後臺處理。在很多情況下, Container 需要執行一些異步處理,而且是定期執行,如每隔 30 秒執行一次, Tomcat 對於文件變更的掃描就是通過這種機制來實現的。Tomcat 針對後臺處理,在 Container 上定義了 backgroudProcess() 方法,它的基礎抽象類 (ContainerBase) 確保在啓動組件的同時,異步後臺處理。所以各個容器組件僅需要實現 Container 的 backgroudProcess() 方法就可以了,不必考慮創建異步線程。

4、Lifecycle

通過上面的架構設計,所有的組件都存在啓動、停止等生命週期方法,擁有生命週期管理的特性。因此,我們可以基於生命週期管理進行一次接口抽象。

在 Tomcat 中抽象了一個 Lifecycle 通用生命週期接口,這個接口定義了生命週期管理的核心方法:

  • init():初始化組件
  • start():啓動組件
  • stop():停止組件
  • destroy():銷燬組件

在這裏插入圖片描述
同時,該接口支持組件狀態以及狀態之間的轉換,支持添加事件監聽器(LifecycleListner) 用於監聽組件的狀態變化。如此,我們可以採用一致的機制來初始化、啓動、停止以及銷燬各個組件。如 Tomcat 核心組件的默唸實現均繼承自 LifecycleMBeanBase 抽象類,該類不但負責組件各個狀態的轉換和事件處理,還將組件自身註冊爲 MBean,以便通過 Tomcat 的管理工具進行動態維護。

Tomcat 中的 Lifecycle 接口狀態圖:
在這裏插入圖片描述
每個生命週期方法可以對應數個狀態的轉換,以 start() 爲例,分爲啓動前、啓動中、已啓動,這 3 個狀態之間自動轉換(所有標識爲 auto 的轉換路徑都是在生命週期方法中自動轉換的,不再需要額外的方法調用)。

其次,並不是每個狀態都會觸發生命週期事件,也不是所有生命週期事件均存在對應狀態。狀態與應用生命週期事件的對應如下所示:

在這裏插入圖片描述

5、Pipeline 和 Valve

從架構設計的角度來考慮,應用服務器設計主要完成了我們對核心概念的分解,確保了整體架構的可伸縮性和可擴展性。除此之外,我們還要考慮如何提高每個組件的靈活性,讓它易於擴展。

在增強組件的靈活性和可擴展性方面,職責鏈模式是一種比較好的選擇。 Tomcat 就採用了這種模式來實現客戶端請求的處理 – 請求處理也是職責鏈模式典型的應用場景之一。換句話說,在 Tomcat 中每個 Container 組件通過執行一個職責鏈來完成具體的請求處理。Tomcat 中每個 Container 組件都是通過執行職責鏈來完成具體的請求處理。

Tomcat 定義了 Pipeline(管道) 和 Valve (閥) 兩個接口。前者用於構造職責鏈,後者代表職責鏈上的每個處理器。其設計如下圖:

在這裏插入圖片描述
Pipeline 中維護了一個基礎的 Valve,它始終位於 Pipeline 的末端(即最後執行),封裝了具體的請求處理和輸出響應的處理。然後,通過 addValu() 方法,我們可以爲 Pipeline 添加其它的 Valve。後添加的 Valve 位於基礎 Valve 之前,並按照添加順序執行。Pipeline 通過獲取首個 Value 來啓動整個鏈條的執行。

Tomcat 每個層級的容器(Engine、Host、Context、Wrapper) 都有對應 Valve 的實現,同時維護了一個 Pipeline 實例。也就是說,我們可以在任何層級的容器上針對請求處理進行擴展。

在這裏插入圖片描述

6、Connector 設計

在前面主要討論了容器組件的設計,集中於如何設計才能確保容器的靈活性和可擴展性,並做到合理的解耦。下面我們就來細化一下服務器設計中的另一個重要組件 – Connnector。

要想與 Container 配置實現一個完整的服務器功能,Connector 至少需要包含以下幾項功能:

  • 監聽服務器端口,讀取來自客戶端的請求
  • 將請求數據按照指定協議進行解析
  • 根據請求地址匹配正確的容器進行處理
  • 將響應返回客戶端

只有這樣才能保證將接收到的客戶端請求交由與請求地址匹配的容器處理。

Tomcat 支持多種協議,默認支持 HTTP 和 AJP。同時,Tomcat 還支持多種 I/O 方式,包括 BIO、NIO、APR。並且在 Tomcat 8 之後還新增了對 NIO2 和 HTTP2 協議的支持。所以對協議和 I/O 進行抽象和建模需要重點關注:

在這裏插入圖片描述
在 Tomcat 中,ProtocolHandler 表示一個協議處理器,針對不同協議和 I/O 方式,提供了不同的實現。如 Http11NioProtocol 表示基於 NIO 的 HTTP 1.1 協議的處理器。ProtocolHandler 包含一個 Endpoint 用於啓動 Socket 監聽,這個接口按照 I/O 方式進行分類實現,如 Nio2Endpoint 表示非阻塞式 Socket I/O。來包含一個 Processor 用於按照指定協議讀取數據,並將請求交給容器處理,如 Http11NioProcessor 表示在 NIO 的方式下 HTTP 請求的處理類。

Tomcat 並沒有 Endpoint 接口,僅有 AbstractEndpoint 抽象類,這裏只是作爲概念討論。

在 Connector 啓動時,Endpoint 會啓動線程來監聽服務器端口,並在接收到請求後調用 Processor 進行數據讀取。

當 Processor 讀取客戶端請求後,需要按照請求地址映射到具體的容器進行處理。這個過程就是請求映射。由於 Tomcat 各個組件採用能用的生命週期管理,而且可以通過管理工具進行狀態變更,所以請求映射除了考慮映射規則的時候外,還需要考慮容器組件ener,用於的註冊與銷燬。

Tomcat 通過 Mapper 和 MapperListerner 兩個類實現上述功能。前者用於維護容器映射信息,同時按照映射規則(Servlet 規範定義) 查詢容器。後者實現了 ContainerLintener 和 LifecycleListener 用於在容器組件狀態發生變更時,註冊或者取消對應容器映射信息。爲了實現上述功能, MapperListener 實現了 Lifecycle 接口,當其啓動時(在 Service 啓動時啓動),會自動作爲監聽器註冊到容器組件上,同時將已創建的容器註冊到 Mapper。

在 Tomcat 7 及之前的版本中, Mapper 由 Connnector 維護,而在 Tomcat 8 中,改由 Service 維護,國爲 Service 本來就是用於維護 Connector 和 Container 的組合,兩者從概念上講更密切一些

Tomcat 通過配置器模式(Adapter) 實現了 Connector 與 Mapper、 Container 的解耦。 Tomcat 默認的 Connector 實現(Coyote) 對應的配置器爲 CoyoteAdapter。也就是說,如果你希望使用 Tomcat 的鏈接器方案,但是又想脫離 Sevlet 容器(雖然這種情況幾乎不可能出現,但是從架構可擴展性的角度來講,還是值得討論一下),此時只需要實現我們自己的 Adapter 即可。當前,我們還需要按照 Container 的定義開發我們自己的容器實現(不一定遵從 Servlet 規範)。

在這裏插入圖片描述

7、Executor

在完成 Connector 設計之後,很明顯我們忽略了一個問題 – 併發。我們不可能讓所有來自客戶端請求均以串行的方式執行。

既然 Tomcat 提供了一致的可插拔的組件環境,那麼我們自然希望線程池作爲一個組件進行統一管理。因此,Tomcat 提供了 Executor 接口來表示一個可以在組件間共享的線程池,這個接口同樣繼承自Lifecycle,可按照通用的組件進行管理。

其次,線程池的共享範圍如何確定?在 Tomcat 中 Executor 由 Service 維護,因爲同一個 Servcie 中的組件可以共享一個線程池。

在 Tomcat 中,Endpoint 會啓動一組線程來監聽 Socket 端口,當接收到客戶端請求後,會創建請求處理對象,並交給線程池處理,由引支持併發處理客戶端請求。

在這裏插入圖片描述

8、Bootstrap 和 Catalina

前面幾個小節分析了 Tomcat 總體架構中的主要核心組件,它們代表應用服務器程序本身,就偈樓房的主體。除了主體建築之外,樓房還需要外牆等裝飾。 Tomcat 還需要提供一套配置環境來支持系統的可配置性,便於我們通過修改相關配置來優化應用服務器。

Tomcat 通過 Catalina 提供了一個 Shell 程序,用於解析 service 創建各個組件,同時,負責啓動、停止應用服務器。Tomcat 使用 Digester 解析 XML 文件,包括 server.xml及 web.xml。

最後,Tomcat 提供了 Boottrap 作爲應用服務器啓動入口。 Bootstrap 負責創建 Catalina 實例,根據執行參數調用 Catalina 相關方法完成針對應用服務器的操作(啓動、停止)。

也許你會有疑問,爲什麼 Tomcat 不直接通過 Catalina 啓動,而是又提供 Bootstrap 呢?你可以查看一下 Tomcat 的發佈包目錄,Boostrap 並不位置 Tomcat 的依賴目錄下 ($CATALINA_HOME/lib),而是直接在 $CATALINA_HOME/lib 目錄下。 Boostrap 和 Tomcat 應用服務器完成鬆耦合(通過反射調用 Catalina 實例),它可以直接依賴 JRE 運行併爲 Tomcat 應用服務器創建共享類加載器,用於構造 Catalina 實例以及整個 Tomcat 服務器。

在這裏插入圖片描述

Tomcat 的啓動方式可以作爲非常好的示範來指導中間件產品設計。它實現了啓動入口與核心環境的解耦,這樣不權簡化了啓動(不必配置各種依賴庫,因爲只有獨立的幾個 API),而且便於我們更靈活的組件中間件產品的結構,尤其是類加載器的方案。否則,我們所有的依賴庫將統一放置到一個類加載器中,而無法做到靈活定製。

上述是 Tomcat 標準的啓動方式。既然 Server 及其子組件代表了應用服務器本身,那麼我們就可能不通過 Bootstrap 和 Catalina 來啓動服務器。

Tomcat 提供了一個同名類 org.apache.catalina.stratup.Tomcat,使用它我們可以將 Tomcat 服務器嵌入到我們的應用系統中並進行啓動。當然,你可以自己編寫代碼來啓動 Server,也可以自定義其他配置方式啓動,如 YAML。這就是 Tomcat 靈活的架構設計帶給我們的便利,也是我們設計中間件產品的架構關注點之一。

引用地址:

  • 書籍 – 《Tomcat 架構解析》
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章