Tomcat 發展這么多年,已經比較成熟穩定。在如今『追新求快』的時代,Tomcat 作為 Java Web 開發必備的工具似乎變成了『熟悉的陌生人』,難道說如今就沒有必要深入學習它了么?學習它我們又有什么收獲呢?
靜下心來,細細品味經典的開源作品 。提升我們的「內功」,具體來說就是學習大牛們如何設計、架構一個中間件系統,并且讓這些經驗為我所用。
美好的事物往往是整潔而優雅的。但這并不等于簡單,而是要將復雜的系統分解成一個個小模塊,并且各個模塊的職責劃分也要清晰合理。
與此相反的是凌亂無序,比如你看到城中村一堆互相糾纏在一起的電線,可能會感到不適。維護的代碼一個類幾千行、一個方法好幾百行。方法之間相互耦合糅雜在一起,你可能會說 what the f*k!
Tomcat 作為一個 「Http 服務器 + Servlet 容器」,對我們屏蔽了應用層協議和網絡通信細節,給我們的是標準的 Request 和 Response 對象;對于具體的業務邏輯則作為變化點,交給我們來實現。我們使用了SpringMVC 之類的框架,可是卻從來不需要考慮 TCP 連接、 Http 協議的數據處理與響應。就是因為 Tomcat 已經為我們做好了這些,我們只需要關注每個請求的具體業務邏輯。
Tomcat 內部也隔離了變化點與不變點,使用了組件化設計,目的就是為了實現「俄羅斯套娃式」的高度定制化(組合模式),而每個組件的生命周期管理又有一些共性的東西,則被提取出來成為接口和抽象類,讓具體子類實現變化點,也就是模板方法設計模式。
當今流行的微服務也是這個思路,按照功能將單體應用拆成「微服務」,拆分過程要將共性提取出來,而這些共性就會成為核心的基礎服務或者通用庫。「中臺」思想亦是如此。
設計模式往往就是封裝變化的一把利器,合理的運用設計模式能讓我們的代碼與系統設計變得優雅且整潔。
這就是學習優秀開源軟件能獲得的「內功」,從不會過時,其中的設計思想與哲學才是根本之道。從中借鑒設計經驗,合理運用設計模式封裝變與不變,更能從它們的源碼中汲取經驗,提升自己的系統設計能力。
在工作過程中,我們對 Java 語法已經很熟悉了,甚至「背」過一些設計模式,用過很多 Web 框架,但是很少有機會將他們用到實際項目中,讓自己獨立設計一個系統似乎也是根據需求一個個 Service 實現而已。腦子里似乎沒有一張 Java Web 開發全景圖,比如我并不知道瀏覽器的請求是怎么跟 Spring 中的代碼聯系起來的。
為了突破這個瓶頸,為何不站在巨人的肩膀上學習優秀的開源系統,看大牛們是如何思考這些問題。
學習 Tomcat 的原理,我發現 Servlet 技術是 Web 開發的原點,幾乎所有的 Java Web 框架(比如 Spring)都是基于 Servlet 的封裝,Spring 應用本身就是一個 Servlet(DispatchSevlet),而 Tomcat 和 Jetty 這樣的 Web 容器,負責加載和運行 Servlet。如圖所示:
學習 Tomcat ,我還發現用到不少 Java 高級技術,比如 Java 多線程并發編程、Socket 網絡編程以及反射等。之前也只是了解這些技術,為了面試也背過一些題。但是總感覺「知道」與會用之間存在一道溝壑,通過對 Tomcat 源碼學習,我學會了什么場景去使用這些技術。
還有就是系統設計能力,比如面向接口編程、組件化組合模式、骨架抽象類、一鍵式啟停、對象池技術以及各種設計模式,比如模板方法、觀察者模式、責任鏈模式等,之后我也開始模仿它們并把這些設計思想運用到實際的工作中。
今天咱們就來一步一步分析 Tomcat 的設計思路,一方面我們可以學到 Tomcat 的總體架構,學會從宏觀上怎么去設計一個復雜系統,怎么設計頂層模塊,以及模塊之間的關系;另一方面也為我們深入學習 Tomcat 的工作原理打下基礎。
Tomcat 啟動流程:startup.sh -> catalina.sh start ->java -jar org.apache.catalina.startup.Bootstrap.main()
Tomcat 實現的 2 個核心功能:
所以 Tomcat 設計了兩個核心組件連接器(Connector)和容器(Container)。連接器負責對外交流,容器負責內部 處理
Tomcat為了實現支持多種 I/O 模型和應用層協議,一個容器可能對接多個連接器,就好比一個房間有多個門。
Tomcat整體架構
每個組件都有對應的生命周期,需要啟動,同時還要啟動自己內部的子組件,比如一個 Tomcat 實例包含一個 Service,一個 Service 包含多個連接器和一個容器。而一個容器包含多個 Host, Host 內部可能有多個 Contex t 容器,而一個 Context 也會包含多個 Servlet,所以 Tomcat 利用組合模式管理組件每個組件,對待過個也想對待單個組一樣對待。整體上每個組件設計就像是「俄羅斯套娃」一樣。
在開始講連接器前,我先鋪墊一下 Tomcat支持的多種 I/O 模型和應用層協議。
Tomcat支持的 I/O 模型有:
Tomcat 支持的應用層協議有:
所以一個容器可能對接多個連接器。連接器對 Servlet 容器屏蔽了網絡協議與 I/O 模型的區別,無論是 Http 還是 AJP,在容器中獲取到的都是一個標準的 ServletRequest 對象。
細化連接器的功能需求就是:
需求列清楚后,我們要考慮的下一個問題是,連接器應該有哪些子模塊?優秀的模塊化設計應該考慮高內聚、低耦合。
我們發現連接器需要完成 3 個高內聚的功能:
因此 Tomcat 的設計者設計了 3 個組件來實現這 3 個功能,分別是 EndPoint、Processor 和 Adapter。
網絡通信的 I/O 模型是變化的, 應用層協議也是變化的,但是整體的處理邏輯是不變的,EndPoint 負責提供字節流給 Processor,Processor負責提供 Tomcat Request 對象給 Adapter,Adapter負責提供 ServletRequest對象給容器。
因此 Tomcat 設計了一系列抽象基類來封裝這些穩定的部分,抽象基類 AbstractProtocol實現了 ProtocolHandler接口。每一種應用層協議有自己的抽象基類,比如 AbstractAjpProtocol和 AbstractHttp11Protocol,具體協議的實現類擴展了協議層抽象基類。
這就是模板方法設計模式的運用。
應用層協議抽象
總結下來,連接器的三個核心組件 Endpoint、Processor和 Adapter來分別做三件事情,其中 Endpoint和 Processor放在一起抽象成了 ProtocolHandler組件,它們的關系如下圖所示。
連接器
主要處理 網絡連接 和 應用層協議 ,包含了兩個重要部件 EndPoint 和 Processor,兩個組件組合形成 ProtocoHandler,下面我來詳細介紹它們的工作原理。
EndPoint是通信端點,即通信監聽的接口,是具體的 Socket 接收和發送處理器,是對傳輸層的抽象,因此 EndPoint是用來實現 TCP/IP 協議數據讀寫的,本質調用操作系統的 socket 接口。
EndPoint是一個接口,對應的抽象實現類是 AbstractEndpoint,而 AbstractEndpoint的具體子類,比如在 NioEndpoint和 Nio2Endpoint中,有兩個重要的子組件:Acceptor和 SocketProcessor。
其中 Acceptor 用于監聽 Socket 連接請求。SocketProcessor用于處理 Acceptor 接收到的 Socket請求,它實現 Runnable接口,在 Run方法里調用應用層協議處理組件 Processor 進行處理。為了提高處理能力,SocketProcessor被提交到線程池來執行。
我們知道,對于 Java 的多路復用器的使用,無非是兩步:
在 Tomcat 中 NioEndpoint 則是 AbstractEndpoint 的具體實現,里面組件雖然很多,但是處理邏輯還是前面兩步。它一共包含 LimitLatch、Acceptor、Poller、SocketProcessor和 Executor 共 5 個組件,分別分工合作實現整個 TCP/IP 協議的處理。
工作流程如下所示:
NioEndPoint
Processor 用來實現 HTTP 協議,Processor 接收來自 EndPoint 的 Socket,讀取字節流解析成 Tomcat Request 和 Response 對象,并通過 Adapter 將其提交到容器處理,Processor 是對應用層協議的抽象。
從圖中我們看到,EndPoint 接收到 Socket 連接后,生成一個 SocketProcessor 任務提交到線程池去處理,SocketProcessor 的 Run 方法會調用 HttpProcessor 組件去解析應用層協議,Processor 通過解析生成 Request 對象后,會調用 Adapter 的 Service 方法,方法內部通過 以下代碼將請求傳遞到容器中。
// Calling the containerconnector.getService().getContainer().getPipeline().getFirst().invoke(request, response);
由于協議的不同,Tomcat 定義了自己的 Request 類來存放請求信息,這里其實體現了面向對象的思維。但是這個 Request 不是標準的 ServletRequest ,所以不能直接使用 Tomcat 定義 Request 作為參數直接容器。
Tomcat 設計者的解決方案是引入 CoyoteAdapter,這是適配器模式的經典運用,連接器調用 CoyoteAdapter 的 Sevice 方法,傳入的是 Tomcat Request 對象,CoyoteAdapter負責將 Tomcat Request 轉成 ServletRequest,再調用容器的 Service方法。
連接器負責外部交流,容器負責內部處理。具體來說就是,連接器處理 Socket 通信和應用層協議的解析,得到 Servlet請求;而容器則負責處理 Servlet請求。
容器:顧名思義就是拿來裝東西的, 所以 Tomcat 容器就是拿來裝載 Servlet。
Tomcat 設計了 4 種容器,分別是 Engine、Host、Context和 Wrapper。Server 代表 Tomcat 實例。
要注意的是這 4 種容器不是平行關系,屬于父子關系,如下圖所示:
容器
你可能會問,為啥要設計這么多層次的容器,這不是增加復雜度么?其實這背后的考慮是,Tomcat 通過一種分層的架構,使得 Servlet 容器具有很好的靈活性。因為這里正好符合一個 Host 多個 Context, 一個 Context 也包含多個 Servlet,而每個組件都需要統一生命周期管理,所以組合模式設計這些容器
Wrapper 表示一個 Servlet ,Context 表示一個 Web 應用程序,而一個 Web 程序可能有多個 Servlet ;Host 表示一個虛擬主機,或者說一個站點,一個 Tomcat 可以配置多個站點(Host);一個站點( Host) 可以部署多個 Web 應用;Engine 代表 引擎,用于管理多個站點(Host),一個 Service 只能有 一個 Engine。
可通過 Tomcat 配置文件加深對其層次關系理解。
<Server port="8005" shutdown="SHUTDOWN"> // 頂層組件,可包含多個 Service,代表一個 Tomcat 實例 <Service name="Catalina"> // 頂層組件,包含一個 Engine ,多個連接器 <Connector port="8080" protocol="HTTP/1.1" connectionTimeout="20000" redirectPort="8443" /> <!-- Define an AJP 1.3 Connector on port 8009 --> <Connector port="8009" protocol="AJP/1.3" redirectPort="8443" /> // 連接器 // 容器組件:一個 Engine 處理 Service 所有請求,包含多個 Host <Engine name="Catalina" defaultHost="localhost"> // 容器組件:處理指定Host下的客戶端請求, 可包含多個 Context <Host name="localhost" appBase="webapps" unpackWARs="true" autoDeploy="true"> // 容器組件:處理特定 Context Web應用的所有客戶端請求 <Context></Context> </Host> </Engine> </Service></Server>
如何管理這些容器?我們發現容器之間具有父子關系,形成一個樹形結構,是不是想到了設計模式中的 組合模式 。
Tomcat 就是用組合模式來管理這些容器的。具體實現方法是,所有容器組件都實現了 Container接口,因此組合模式可以使得用戶對單容器對象和組合容器對象的使用具有一致性。這里單容器對象指的是最底層的 Wrapper,組合容器對象指的是上面的 Context、Host或者 Engine。Container 接口定義如下:
public interface Container extends Lifecycle { public void setName(String name); public Container getParent(); public void setParent(Container container); public void addChild(Container child); public void removeChild(Container child); public Container findChild(String name);}
我們看到了getParent、SetParent、addChild和 removeChild等方法,這里正好驗證了我們說的組合模式。我們還看到 Container接口拓展了 Lifecycle ,Tomcat 就是通過 Lifecycle 統一管理所有容器的組件的生命周期。通過組合模式管理所有容器,拓展 Lifecycle 實現對每個組件的生命周期管理 ,Lifecycle 主要包含的方法init()、start()、stop() 和 destroy()。
一個請求是如何定位到讓哪個 Wrapper 的 Servlet 處理的?答案是,Tomcat 是用 Mapper 組件來完成這個任務的。
Mapper 組件的功能就是將用戶請求的 URL 定位到一個 Servlet,它的工作原理是:Mapper組件里保存了 Web 應用的配置信息,其實就是容器組件與訪問路徑的映射關系,比如 Host容器里配置的域名、Context容器里的 Web應用路徑,以及 Wrapper容器里 Servlet 映射的路徑,你可以想象這些配置信息就是一個多層次的 Map。
當一個請求到來時,Mapper 組件通過解析請求 URL 里的域名和路徑,再到自己保存的 Map 里去查找,就能定位到一個 Servlet。請你注意,一個請求 URL 最后只會定位到一個 Wrapper容器,也就是一個 Servlet。
假如有用戶訪問一個 URL,比如圖中的http://user.shopping.com:8080/order/buy,Tomcat 如何將這個 URL 定位到一個 Servlet 呢?
user.shopping.com
,因此 Mapper 會找到 Host2 這個容器。連接器中的 Adapter 會調用容器的 Service 方法來執行 Servlet,最先拿到請求的是 Engine 容器,Engine 容器對請求做一些處理后,會把請求傳給自己子容器 Host 繼續處理,依次類推,最后這個請求會傳給 Wrapper 容器,Wrapper 會調用最終的 Servlet 來處理。那么這個調用過程具體是怎么實現的呢?答案是使用 Pipeline-Valve 管道。
Pipeline-Valve 是責任鏈模式,責任鏈模式是指在一個請求處理的過程中有很多處理者依次對請求進行處理,每個處理者負責做自己相應的處理,處理完之后將再調用下一個處理者繼續處理,Valve 表示一個處理點(也就是一個處理閥門),因此 invoke方法就是來處理請求的。
public interface Valve { public Valve getNext(); public void setNext(Valve valve); public void invoke(Request request, Response response)}
繼續看 Pipeline 接口
public interface Pipeline { public void addValve(Valve valve); public Valve getBasic(); public void setBasic(Valve valve); public Valve getFirst();}
Pipeline中有 addValve方法。Pipeline 中維護了 Valve鏈表,Valve可以插入到 Pipeline中,對請求做某些處理。我們還發現 Pipeline 中沒有 invoke 方法,因為整個調用鏈的觸發是 Valve 來完成的,Valve完成自己的處理后,調用 getNext.invoke() 來觸發下一個 Valve 調用。
其實每個容器都有一個 Pipeline 對象,只要觸發了這個 Pipeline 的第一個 Valve,這個容器里 Pipeline中的 Valve 就都會被調用到。但是,不同容器的 Pipeline 是怎么鏈式觸發的呢,比如 Engine 中 Pipeline 需要調用下層容器 Host 中的 Pipeline。
這是因為 Pipeline中還有個 getBasic方法。這個 BasicValve處于 Valve鏈表的末端,它是 Pipeline中必不可少的一個 Valve,負責調用下層容器的 Pipeline 里的第一個 Valve。
整個過程分是通過連接器中的 CoyoteAdapter 觸發,它會調用 Engine 的第一個 Valve:
@Overridepublic void service(org.apache.coyote.Request req, org.apache.coyote.Response res) { // 省略其他代碼 // Calling the container connector.getService().getContainer().getPipeline().getFirst().invoke( request, response); ...}
Wrapper 容器的最后一個 Valve 會創建一個 Filter 鏈,并調用 doFilter() 方法,最終會調到 Servlet的 service方法。
前面我們不是講到了 Filter,似乎也有相似的功能,那 Valve 和 Filter有什么區別嗎?它們的區別是:
前面我們看到 Container容器 繼承了 Lifecycle 生命周期。如果想讓一個系統能夠對外提供服務,我們需要創建、組裝并啟動這些組件;在服務停止的時候,我們還需要釋放資源,銷毀這些組件,因此這是一個動態的過程。也就是說,Tomcat 需要動態地管理這些組件的生命周期。
如何統一管理組件的創建、初始化、啟動、停止和銷毀?如何做到代碼邏輯清晰?如何方便地添加或者刪除組件?如何做到組件啟動和停止不遺漏、不重復?
設計就是要找到系統的變化點和不變點。這里的不變點就是每個組件都要經歷創建、初始化、啟動這幾個過程,這些狀態以及狀態的轉化是不變的。而變化點是每個具體組件的初始化方法,也就是啟動方法是不一樣的。
因此,Tomcat 把不變點抽象出來成為一個接口,這個接口跟生命周期有關,叫作 LifeCycle。LifeCycle 接口里定義這么幾個方法:init()、start()、stop() 和 destroy(),每個具體的組件(也就是容器)去實現這些方法。
在父組件的 init() 方法里需要創建子組件并調用子組件的 init() 方法。同樣,在父組件的 start()方法里也需要調用子組件的 start() 方法,因此調用者可以無差別的調用各組件的 init() 方法和 start() 方法,這就是組合模式的使用,并且只要調用最頂層組件,也就是 Server 組件的 init()和start() 方法,整個 Tomcat 就被啟動起來了。所以 Tomcat 采取組合模式管理容器,容器繼承 LifeCycle 接口,這樣就可以向針對單個對象一樣一鍵管理各個容器的生命周期,整個 Tomcat 就啟動起來。
我們再來考慮另一個問題,那就是系統的可擴展性。因為各個組件init() 和 start() 方法的具體實現是復雜多變的,比如在 Host 容器的啟動方法里需要掃描 webapps 目錄下的 Web 應用,創建相應的 Context 容器,如果將來需要增加新的邏輯,直接修改start() 方法?這樣會違反開閉原則,那如何解決這個問題呢?開閉原則說的是為了擴展系統的功能,你不能直接修改系統中已有的類,但是你可以定義新的類。
組件的 init() 和 start() 調用是由它的父組件的狀態變化觸發的,上層組件的初始化會觸發子組件的初始化,上層組件的啟動會觸發子組件的啟動,因此我們把組件的生命周期定義成一個個狀態,把狀態的轉變看作是一個事件。而事件是有監聽器的,在監聽器里可以實現一些邏輯,并且監聽器也可以方便的添加和刪除,這就是典型的觀察者模式。
以下就是 Lyfecycle 接口的定義:
Lyfecycle
再次看到抽象模板設計模式。
有了接口,我們就要用類去實現接口。一般來說實現類不止一個,不同的類在實現接口時往往會有一些相同的邏輯,如果讓各個子類都去實現一遍,就會有重復代碼。那子類如何重用這部分邏輯呢?其實就是定義一個基類來實現共同的邏輯,然后讓各個子類去繼承它,就達到了重用的目的。
Tomcat 定義一個基類 LifeCycleBase 來實現 LifeCycle 接口,把一些公共的邏輯放到基類中去,比如生命狀態的轉變與維護、生命事件的觸發以及監聽器的添加和刪除等,而子類就負責實現自己的初始化、啟動和停止等方法。
public abstract class LifecycleBase implements Lifecycle{ // 持有所有的觀察者 private final List<LifecycleListener> lifecycleListeners = new CopyOnWriteArrayList<>(); /** * 發布事件 * * @param type Event type * @param data Data associated with event. */ protected void fireLifecycleEvent(String type, Object data) { LifecycleEvent event = new LifecycleEvent(this, type, data); for (LifecycleListener listener : lifecycleListeners) { listener.lifecycleEvent(event); } } // 模板方法定義整個啟動流程,啟動所有容器 @Override public final synchronized void init() throws LifecycleException { //1. 狀態檢查 if (!state.equals(LifecycleState.NEW)) { invalidTransition(Lifecycle.BEFORE_INIT_EVENT); } try { //2. 觸發 INITIALIZING 事件的監聽器 setStateInternal(LifecycleState.INITIALIZING, null, false); // 3. 調用具體子類的初始化方法 initInternal(); // 4. 觸發 INITIALIZED 事件的監聽器 setStateInternal(LifecycleState.INITIALIZED, null, false); } catch (Throwable t) { ExceptionUtils.handleThrowable(t); setStateInternal(LifecycleState.FAILED, null, false); throw new LifecycleException( sm.getString("lifecycleBase.initFail",toString()), t); } }}
Tomcat 為了實現一鍵式啟停以及優雅的生命周期管理,并考慮到了可擴展性和可重用性,將面向對象思想和設計模式發揮到了極致,Containaer接口維護了容器的父子關系,Lifecycle 組合模式實現組件的生命周期維護,生命周期每個組件有變與不變的點,運用模板方法模式。分別運用了組合模式、觀察者模式、骨架抽象類和模板方法。
如果你需要維護一堆具有父子關系的實體,可以考慮使用組合模式。
觀察者模式聽起來 “高大上”,其實就是當一個事件發生后,需要執行一連串更新操作。實現了低耦合、非侵入式的通知與更新機制。
Container 繼承了 LifeCycle,StandardEngine、StandardHost、StandardContext 和 StandardWrapper 是相應容器組件的具體實現類,因為它們都是容器,所以繼承了 ContainerBase 抽象基類,而 ContainerBase 實現了 Container 接口,也繼承了 LifeCycleBase 類,它們的生命周期管理接口和功能接口是分開的,這也符合設計中接口分離的原則。
我們知道 JVM的類加載器加載 Class 的時候基于雙親委派機制,也就是會將加載交給自己的父加載器加載,如果 父加載器為空則查找Bootstrap 是否加載過,當無法加載的時候才讓自己加載。JDK 提供一個抽象類 ClassLoader,這個抽象類中定義了三個關鍵方法。對外使用loadClass(String name) 用于子類重寫打破雙親委派:loadClass(String name, boolean resolve)。
public Class<?> loadClass(String name) throws ClassNotFoundException { return loadClass(name, false);}protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException{ synchronized (getClassLoadingLock(name)) { // 查找該 class 是否已經被加載過 Class<?> c = findLoadedClass(name); // 如果沒有加載過 if (c == null) { // 委托給父加載器去加載,遞歸調用 if (parent != null) { c = parent.loadClass(name, false); } else { // 如果父加載器為空,查找 Bootstrap 是否加載過 c = findBootstrapClassOrNull(name); } // 若果依然加載不到,則調用自己的 findClass 去加載 if (c == null) { c = findClass(name); } } if (resolve) { resolveClass(c); } return c; }}protected Class<?> findClass(String name){ //1. 根據傳入的類名 name,到在特定目錄下去尋找類文件,把.class 文件讀入內存 ... //2. 調用 defineClass 將字節數組轉成 Class 對象 return defineClass(buf, off, len);}// 將字節碼數組解析成一個 Class 對象,用 native 方法實現protected final Class<?> defineClass(byte[] b, int off, int len){ ...}
JDK 中有 3 個類加載器,另外你也可以自定義類加載器,它們的關系如下圖所示。
類加載器
這些類加載器的工作原理是一樣的,區別是它們的加載路徑不同,也就是說 findClass這個方法查找的路徑不同。雙親委托機制是為了保證一個 Java 類在 JVM 中是唯一的,假如你不小心寫了一個與 JRE 核心類同名的類,比如 Object類,雙親委托機制能保證加載的是 JRE里的那個 Object類,而不是你寫的 Object類。這是因為 AppClassLoader在加載你的 Object 類時,會委托給 ExtClassLoader去加載,而 ExtClassLoader又會委托給 BootstrapClassLoader,BootstrapClassLoader發現自己已經加載過了 Object類,會直接返回,不會去加載你寫的 Object類。我們最多只能 獲取到 ExtClassLoader這里注意下。
Tomcat 本質是通過一個后臺線程做周期性的任務,定期檢測類文件的變化,如果有變化就重新加載類。我們來看 ContainerBackgroundProcessor具體是如何實現的。
protected class ContainerBackgroundProcessor implements Runnable { @Override public void run() { // 請注意這里傳入的參數是 " 宿主類 " 的實例 processChildren(ContainerBase.this); } protected void processChildren(Container container) { try { //1. 調用當前容器的 backgroundProcess 方法。 container.backgroundProcess(); //2. 遍歷所有的子容器,遞歸調用 processChildren, // 這樣當前容器的子孫都會被處理 Container[] children = container.findChildren(); for (int i = 0; i < children.length; i++) { // 這里請你注意,容器基類有個變量叫做 backgroundProcessorDelay,如果大于 0,表明子容器有自己的后臺線程,無需父容器來調用它的 processChildren 方法。 if (children[i].getBackgroundProcessorDelay() <= 0) { processChildren(children[i]); } } } catch (Throwable t) { ... }
Tomcat 的熱加載就是在 Context 容器實現,主要是調用了 Context 容器的 reload 方法。拋開細節從宏觀上看主要完成以下任務:
在這個過程中,類加載器發揮著關鍵作用。一個 Context 容器對應一個類加載器,類加載器在銷毀的過程中會把它加載的所有類也全部銷毀。Context 容器在啟動過程中,會創建一個新的類加載器來加載新的類文件。
Tomcat 的自定義類加載器 WebAppClassLoader打破了雙親委托機制,它首先自己嘗試去加載某個類,如果找不到再代理給父類加載器,其目的是優先加載 Web 應用自己定義的類。具體實現就是重寫 ClassLoader的兩個方法:findClass和 loadClass。
org.apache.catalina.loader.WebappClassLoaderBase#findClass;為了方便理解和閱讀,我去掉了一些細節:
public Class<?> findClass(String name) throws ClassNotFoundException { ... Class<?> clazz = null; try { //1. 先在 Web 應用目錄下查找類 clazz = findClassInternal(name); } catch (RuntimeException e) { throw e; } if (clazz == null) { try { //2. 如果在本地目錄沒有找到,交給父加載器去查找 clazz = super.findClass(name); } catch (RuntimeException e) { throw e; } //3. 如果父類也沒找到,拋出 ClassNotFoundException if (clazz == null) { throw new ClassNotFoundException(name); } return clazz;}
再來看 Tomcat 類加載器的 loadClass方法的實現,同樣我也去掉了一些細節:
public Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { synchronized (getClassLoadingLock(name)) { Class<?> clazz = null; //1. 先在本地 cache 查找該類是否已經加載過 clazz = findLoadedClass0(name); if (clazz != null) { if (resolve) resolveClass(clazz); return clazz; } //2. 從系統類加載器的 cache 中查找是否加載過 clazz = findLoadedClass(name); if (clazz != null) { if (resolve) resolveClass(clazz); return clazz; } // 3. 嘗試用 ExtClassLoader 類加載器類加載,為什么? ClassLoader javaseLoader = getJavaseClassLoader(); try { clazz = javaseLoader.loadClass(name); if (clazz != null) { if (resolve) resolveClass(clazz); return clazz; } } catch (ClassNotFoundException e) { // Ignore } // 4. 嘗試在本地目錄搜索 class 并加載 try { clazz = findClass(name); if (clazz != null) { if (resolve) resolveClass(clazz); return clazz; } } catch (ClassNotFoundException e) { // Ignore } // 5. 嘗試用系統類加載器 (也就是 AppClassLoader) 來加載 try { clazz = Class.forName(name, false, parent); if (clazz != null) { if (resolve) resolveClass(clazz); return clazz; } } catch (ClassNotFoundException e) { // Ignore } } //6. 上述過程都加載失敗,拋出異常 throw new ClassNotFoundException(name);}
主要有六個步驟:
Tomcat 作為 Servlet容器,它負責加載我們的 Servlet類,此外它還負責加載 Servlet所依賴的 JAR 包。并且 Tomcat本身也是也是一個 Java 程序,因此它需要加載自己的類和依賴的 JAR 包。首先讓我們思考這一下這幾個問題:
Tomcat 的解決方案是自定義一個類加載器 WebAppClassLoader, 并且給每個 Web 應用創建一個類加載器實例。我們知道,Context 容器組件對應一個 Web 應用,因此,每個 Context容器負責創建和維護一個 WebAppClassLoader加載器實例。這背后的原理是,不同的加載器實例加載的類被認為是不同的類,即使它們的類名相同。這就相當于在 Java 虛擬機內部創建了一個個相互隔離的 Java 類空間,每一個 Web 應用都有自己的類空間,Web 應用之間通過各自的類加載器互相隔離。
本質需求是兩個 Web 應用之間怎么共享庫類,并且不能重復加載相同的類。在雙親委托機制里,各個子加載器都可以通過父加載器去加載類,那么把需要共享的類放到父加載器的加載路徑下不就行了嗎。
因此 Tomcat 的設計者又加了一個類加載器 SharedClassLoader,作為 WebAppClassLoader的父加載器,專門來加載 Web 應用之間共享的類。如果 WebAppClassLoader自己沒有加載到某個類,就會委托父加載器 SharedClassLoader去加載這個類,SharedClassLoader會在指定目錄下加載共享類,之后返回給 WebAppClassLoader,這樣共享的問題就解決了。
如何隔離 Tomcat 本身的類和 Web 應用的類?
要共享可以通過父子關系,要隔離那就需要兄弟關系了。兄弟關系就是指兩個類加載器是平行的,它們可能擁有同一個父加載器,基于此 Tomcat 又設計一個類加載器 CatalinaClassloader,專門來加載 Tomcat 自身的類。
這樣設計有個問題,那 Tomcat 和各 Web 應用之間需要共享一些類時該怎么辦呢?
老辦法,還是再增加一個 CommonClassLoader,作為 CatalinaClassloader和 SharedClassLoader的父加載器。CommonClassLoader能加載的類都可以被 CatalinaClassLoader和 SharedClassLoader使用。
通過前面對 Tomcat 整體架構的學習,知道了 Tomcat 有哪些核心組件,組件之間的關系。以及 Tomcat 是怎么處理一個 HTTP 請求的。下面我們通過一張簡化的類圖來回顧一下,從圖上你可以看到各種組件的層次關系,圖中的虛線表示一個請求在 Tomcat 中流轉的過程。
Tomcat 整體組件關系
Tomcat 的整體架構包含了兩個核心組件連接器和容器。連接器負責對外交流,容器負責內部處理。連接器用 ProtocolHandler接口來封裝通信協議和 I/O模型的差異,ProtocolHandler內部又分為 EndPoint和 Processor模塊,EndPoint負責底層 Socket通信,Proccesor負責應用層協議解析。連接器通過適配器 Adapter調用容器。
對 Tomcat 整體架構的學習,我們可以得到一些設計復雜系統的基本思路。首先要分析需求,根據高內聚低耦合的原則確定子模塊,然后找出子模塊中的變化點和不變點,用接口和抽象基類去封裝不變點,在抽象基類中定義模板方法,讓子類自行實現抽象方法,也就是具體子類去實現變化點。
運用了組合模式 管理容器、通過 觀察者模式 發布啟動事件達到解耦、開閉原則。骨架抽象類和模板方法抽象變與不變,變化的交給子類實現,從而實現代碼復用,以及靈活的拓展。使用責任鏈的方式處理請求,比如記錄日志等。
Tomcat 的自定義類加載器 WebAppClassLoader為了隔離 Web 應用打破了雙親委托機制,它首先自己嘗試去加載某個類,如果找不到再代理給父類加載器,其目的是優先加載 Web 應用自己定義的類。防止 Web 應用自己的類覆蓋 JRE 的核心類,使用 ExtClassLoader 去加載,這樣即打破了雙親委派,又能安全加載。
學習是一個反人類的過程,是比較痛苦的。尤其學習我們常用的優秀技術框架本身比較龐大,設計比較復雜,在學習初期很容易遇到 “挫折感”,debug 跳來跳去陷入恐怖細節之中無法自拔,往往就會放棄。
找到適合自己的學習方法非常重要,同樣關鍵的是要保持學習的興趣和動力,并且得到學習反饋效果。
學習優秀源碼,我們收獲的就是架構設計能力,遇到復雜需求我們學習到可以利用合理模式與組件抽象設計了可拓展性強的代碼能力。
比如我最初在學習 Spring 框架的時候,一開始就鉆進某個模塊啃起來。然而由于 Spring 太龐大,模塊之間也有聯系,根本不明白為啥要這么寫,只覺得為啥設計這么 “繞”。
比如某些知識點是面試的熱點,那學習目標就是徹底理解和掌握它,當被問到相關問題時,你的回答能夠使得面試官對你刮目相看,有時候往往憑著某一個亮點就能影響最后的錄用結果。
又或者接到一個稍微復雜的需求,學習從優秀源碼中借鑒設計思路與優化技巧。
最后就是動手實踐,將所學運用在工作項目中。只有動手實踐才會讓我們對技術有最直觀的感受。有時候我們聽別人講經驗和理論,感覺似乎懂了,但是過一段時間便又忘記了。
簡單的分析了 Tomcat 整體架構設計,從 【連接器】 到 【容器】,并且分別細說了一些組件的設計思想以及設計模式。接下來就是如何學以致用,借鑒優雅的設計運用到實際工作開發中。學習,從模仿開始。
在工作中,有這么一個需求,用戶可以輸入一些信息并可以選擇查驗該企業的 【工商信息】、【司法信息】、【中登情況】等如下如所示的一個或者多個模塊,而且模塊之間還有一些公共的東西是要各個模塊復用。
這里就像一個請求,會被多個模塊去處理。所以每個查詢模塊我們可以抽象為 處理閥門,使用一個 List 將這些 閥門保存起來,這樣新增模塊我們只需要新增一個閥門即可,實現了開閉原則,同時將一堆查驗的代碼解耦到不同的具體閥門中,使用抽象類提取 “不變的”功能。
具體示例代碼如下所示:
首先抽象我們的處理閥門, NetCheckDTO是請求信息。
/** * 責任鏈模式:處理每個模塊閥門 */public interface Valve { /** * 調用 * @param netCheckDTO */ void invoke(NetCheckDTO netCheckDTO);}
定義抽象基類,復用代碼。
public abstract class AbstractCheckValve implements Valve { public final AnalysisReportLogDO getLatestHistoryData(NetCheckDTO netCheckDTO, NetCheckDataTypeEnum checkDataTypeEnum){ // 獲取歷史記錄,省略代碼邏輯 } // 獲取查驗數據源配置 public final String getModuleSource(String querySource, ModuleEnum moduleEnum){ // 省略代碼邏輯 }}
定義具體每個模塊處理的業務邏輯,比如 【百度負面新聞】對應的處理。
@Slf4j@Servicepublic class BaiduNegativeValve extends AbstractCheckValve { @Override public void invoke(NetCheckDTO netCheckDTO) { }}
最后就是管理用戶選擇要查驗的模塊,我們通過 List 保存。用于觸發所需要的查驗模塊。
@Slf4j@Servicepublic class NetCheckService { // 注入所有的閥門 @Autowired private Map<String, Valve> valveMap; /** * 發送查驗請求 * * @param netCheckDTO */ @Async("asyncExecutor") public void sendCheckRequest(NetCheckDTO netCheckDTO) { // 用于保存客戶選擇處理的模塊閥門 List<Valve> valves = new ArrayList<>(); CheckModuleConfigDTO checkModuleConfig = netCheckDTO.getCheckModuleConfig(); // 將用戶選擇查驗的模塊添加到 閥門鏈條中 if (checkModuleConfig.getBaiduNegative()) { valves.add(valveMap.get("baiduNegativeValve")); } // 省略部分代碼....... if (CollectionUtils.isEmpty(valves)) { log.info("網查查驗模塊為空,沒有需要查驗的任務"); return; } // 觸發處理 valves.forEach(valve -> valve.invoke(netCheckDTO)); }}
需求是這樣的,可根據客戶錄入的財報 excel 數據或者企業名稱執行財報分析。
對于非上市的則解析 excel -> 校驗數據是否合法->執行計算。
上市企業:判斷名稱是否存在 ,不存在則發送郵件并中止計算-> 從數據庫拉取財報數據,初始化查驗日志、生成一條報告記錄,觸發計算-> 根據失敗與成功修改任務狀態 。
重要的 ”變“ 與 ”不變“,
整個算法流程是固定的模板,但是需要將算法內部變化的部分具體實現延遲到不同子類實現,這正是模板方法模式的最佳場景。
public abstract class AbstractAnalysisTemplate { /** * 提交財報分析模板方法,定義骨架流程 * @param reportAnalysisRequest * @return */ public final FinancialAnalysisResultDTO doProcess(FinancialReportAnalysisRequest reportAnalysisRequest) { FinancialAnalysisResultDTO analysisDTO = new FinancialAnalysisResultDTO(); // 抽象方法:提交查驗的合法校驗 boolean prepareValidate = prepareValidate(reportAnalysisRequest, analysisDTO); log.info("prepareValidate 校驗結果 = {} ", prepareValidate); if (!prepareValidate) { // 抽象方法:構建通知郵件所需要的數據 buildEmailData(analysisDTO); log.info("構建郵件信息,data = {}", JSON.toJSONString(analysisDTO)); return analysisDTO; } String reportNo = FINANCIAL_REPORT_NO_PREFIX + reportAnalysisRequest.getUserId() + SerialNumGenerator.getFixLenthSerialNumber(); // 生成分析日志 initFinancialAnalysisLog(reportAnalysisRequest, reportNo); // 生成分析記錄 initAnalysisReport(reportAnalysisRequest, reportNo); try { // 抽象方法:拉取財報數據,不同子類實現 FinancialDataDTO financialData = pullFinancialData(reportAnalysisRequest); log.info("拉取財報數據完成, 準備執行計算"); // 測算指標 financialCalcContext.calc(reportAnalysisRequest, financialData, reportNo); // 設置分析日志為成功 successCalc(reportNo); } catch (Exception e) { log.error("財報計算子任務出現異常", e); // 設置分析日志失敗 failCalc(reportNo); throw e; } return analysisDTO; }}
最后新建兩個子類繼承該模板,并實現抽象方法。這樣就將上市與非上市兩種類型的處理邏輯解耦,同時又復用了代碼。
需求是這樣,要做一個萬能識別銀行流水的 excel 接口,假設標準流水包含【交易時間、收入、支出、交易余額、付款人賬號、付款人名字、收款人名稱、收款人賬號】等字段?,F在我們解析出來每個必要字段所在 excel 表頭的下標。但是流水有多種情況:
也就是我們要根據解析對應的下標找到對應的處理邏輯算法,我們可能在一個方法里面寫超多 if else
的代碼,整個流水處理都偶合在一起,假如未來再來一種新的流水類型,還要繼續改老代碼。最后可能出現 “又臭又長,難以維護” 的代碼復雜度。
這個時候我們可以用到策略模式,將不同模板的流水使用不同的處理器處理,根據模板找到對應的策略算法去處理。即使未來再加一種類型,我們只要新加一種處理器即可,高內聚低耦合,且可拓展。
定義處理器接口,不同處理器去實現處理邏輯。將所有的處理器注入到 BankFlowDataHandler 的data_processor_map中,根據不同的場景取出對已經的處理器處理流水。
public interface DataProcessor { /** * 處理流水數據 * @param bankFlowTemplateDO 流水下標數據 * @param row * @return */ BankTransactionFlowDO doProcess(BankFlowTemplateDO bankFlowTemplateDO, List<String> row); /** * 是否支持處理該模板,不同類型的流水策略根據模板數據判斷是否支持解析 * @return */ boolean isSupport(BankFlowTemplateDO bankFlowTemplateDO);}// 處理器的上下文@Service@Slf4jpublic class BankFlowDataContext { // 將所有處理器注入到 map 中 @Autowired private List<DataProcessor> processors; // 找對對應的處理器處理流水 public void process() { DataProcessor processor = getProcessor(bankFlowTemplateDO); for(DataProcessor processor :processors) { if (processor.isSupport(bankFlowTemplateDO)) { // row 就是一行流水數據 processor.doProcess(bankFlowTemplateDO, row); break; } } }}
定義默認處理器,處理正常模板,新增模板只要新增處理器實現 DataProcessor即可。
/** * 默認處理器:正對規范流水模板 * */@Component("defaultDataProcessor")@Slf4jpublic class DefaultDataProcessor implements DataProcessor { @Override public BankTransactionFlowDO doProcess(BankFlowTemplateDO bankFlowTemplateDO) { // 省略處理邏輯細節 return bankTransactionFlowDO; } @Override public String strategy(BankFlowTemplateDO bankFlowTemplateDO) { // 省略判斷是否支持解析該流水 boolean isDefault = true; return isDefault; }}
通過策略模式,我們將不同處理邏輯分配到不同的處理類中,這樣完全解耦,便于拓展。
使用內嵌 Tomcat 方式調試源代碼:GitHub: https://github.com/UniqueDong/tomcat-embedded。
完美分割線,由于篇幅限制對于如何借鑒 Tomcat 的設計思想運用到實際開發中的綜合例子就放到下回講解了。本篇干貨滿滿,建議收藏以后多多回味。
本文鏈接:http://www.www897cc.com/showinfo-26-44411-0.htmlTomcat 架構原理解析到架構設計借鑒
聲明:本網頁內容旨在傳播知識,若有侵權等問題請及時與本網聯系,我們將在第一時間刪除處理。郵件:2376512515@qq.com
上一篇: 編譯型與解釋型語言:編程世界的兩種范式