Tomcat 發展這麼多年,已經比較成熟穩定。在現在『追新求快』的時代,Tomcat 做爲 Java Web 開發必備的工具彷佛變成了『熟悉的陌生人』,難道說現在就沒有必要深刻學習它了麼?學習它咱們又有什麼收穫呢?
靜下心來,細細品味經典的開源做品 。提高咱們的「內功」,具體來講就是學習大牛們如何設計、架構一箇中間件系統,而且讓這些經驗爲我所用。java
美好的事物每每是整潔而優雅的。但這並不等於簡單,而是要將複雜的系統分解成一個個小模塊,而且各個模塊的職責劃分也要清晰合理。git
與此相反的是凌亂無序,好比你看到城中村一堆互相糾纏在一塊兒的電線,可能會感到不適。維護的代碼一個類幾千行、一個方法好幾百行。方法之間相互耦合糅雜在一塊兒,你可能會說 what the f*k!github
宏觀上看web
Tomcat 做爲一個 「Http
服務器 + Servlet
容器」,對咱們屏蔽了應用層協議和網絡通訊細節,給咱們的是標準的 Request
和 Response
對象;對於具體的業務邏輯則做爲變化點,交給咱們來實現。咱們使用了SpringMVC
之類的框架,但是卻歷來不須要考慮 TCP
鏈接、 Http
協議的數據處理與響應。就是由於 Tomcat 已經爲咱們作好了這些,咱們只須要關注每一個請求的具體業務邏輯。面試
微觀上看算法
Tomcat
內部也隔離了變化點與不變點,使用了組件化設計,目的就是爲了實現「俄羅斯套娃式」的高度定製化(組合模式),而每一個組件的生命週期管理又有一些共性的東西,則被提取出來成爲接口和抽象類,讓具體子類實現變化點,也就是模板方法設計模式。數據庫
當今流行的微服務也是這個思路,按照功能將單體應用拆成「微服務」,拆分過程要將共性提取出來,而這些共性就會成爲核心的基礎服務或者通用庫。「中臺」思想亦是如此。apache
設計模式每每就是封裝變化的一把利器,合理的運用設計模式能讓咱們的代碼與系統設計變得優雅且整潔。編程
這就是學習優秀開源軟件能得到的「內功」,從不會過期,其中的設計思想與哲學纔是根本之道。從中借鑑設計經驗,合理運用設計模式封裝變與不變,更能從它們的源碼中汲取經驗,提高本身的系統設計能力。設計模式
在工做過程當中,咱們對 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 個核心功能:
Socket
鏈接,負責網絡字節流與 Request
和 Response
對象的轉化。Servlet
,以及處理具體的 Request
請求。因此 Tomcat 設計了兩個核心組件鏈接器(Connector)和容器(Container)。鏈接器負責對外交流,容器負責內部 處理
Tomcat
爲了實現支持多種 I/O
模型和應用層協議,一個容器可能對接多個鏈接器,就比如一個房間有多個門。
每一個組件都有對應的生命週期,須要啓動,同時還要啓動本身內部的子組件,好比一個 Tomcat 實例包含一個 Service,一個 Service 包含多個鏈接器和一個容器。而一個容器包含多個 Host, Host 內部可能有多個 Contex t 容器,而一個 Context 也會包含多個 Servlet,因此 Tomcat 利用組合模式管理組件每一個組件,對待過個也想對待單個組同樣對待。總體上每一個組件設計就像是「俄羅斯套娃」同樣。
在開始講鏈接器前,我先鋪墊一下 Tomcat
支持的多種 I/O
模型和應用層協議。
Tomcat
支持的 I/O
模型有:
NIO
:非阻塞 I/O
,採用 Java NIO
類庫實現。NIO2
:異步I/O
,採用 JDK 7
最新的 NIO2
類庫實現。APR
:採用 Apache
可移植運行庫實現,是 C/C++
編寫的本地庫。Tomcat 支持的應用層協議有:
HTTP/1.1
:這是大部分 Web 應用採用的訪問協議。AJP
:用於和 Web 服務器集成(如 Apache)。HTTP/2
:HTTP 2.0 大幅度的提高了 Web 性能。因此一個容器可能對接多個鏈接器。鏈接器對 Servlet
容器屏蔽了網絡協議與 I/O
模型的區別,不管是 Http
仍是 AJP
,在容器中獲取到的都是一個標準的 ServletRequest
對象。
細化鏈接器的功能需求就是:
HTTP/AJP
)解析字節流,生成統一的 Tomcat Request
對象。Tomcat Request
對象轉成標準的 ServletRequest
。Servlet
容器,獲得 ServletResponse
。ServletResponse
轉成 Tomcat Response
對象。Tomcat Response
轉成網絡字節流。需求列清楚後,咱們要考慮的下一個問題是,鏈接器應該有哪些子模塊?優秀的模塊化設計應該考慮高內聚、低耦合。
咱們發現鏈接器須要完成 3 個高內聚的功能:
Tomcat Request/Response
與 ServletRequest/ServletResponse
的轉化。所以 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 協議的處理。
Acceptor
跑在一個單獨的線程裏,它在一個死循環裏調用 accept
方法來接收新鏈接,一旦有新的鏈接請求到來,accept
方法返回一個 Channel
對象,接着把 Channel
對象交給 Poller 去處理。Poller
的本質是一個 Selector
,也跑在單獨線程裏。Poller
在內部維護一個 Channel
數組,它在一個死循環裏不斷檢測 Channel
的數據就緒狀態,一旦有 Channel
可讀,就生成一個 SocketProcessor
任務對象扔給 Executor
去處理。getHandler().process(socketWrapper, SocketEvent.CONNECT_FAIL);
代碼則是獲取 handler 並執行處理 socketWrapper,最後經過 socket 獲取合適應用層協議處理器,也就是調用 Http11Processor 組件來處理請求。Http11Processor 讀取 Channel 的數據來生成 ServletRequest 對象,Http11Processor 並非直接讀取 Channel 的。這是由於 Tomcat 支持同步非阻塞 I/O 模型和異步 I/O 模型,在 Java API 中,相應的 Channel 類也是不同的,好比有 AsynchronousSocketChannel 和 SocketChannel,爲了對 Http11Processor 屏蔽這些差別,Tomcat 設計了一個包裝類叫做 SocketWrapper,Http11Processor 只調用 SocketWrapper 的方法去讀寫數據。Executor
就是線程池,負責運行 SocketProcessor
任務類,SocketProcessor
的 run
方法會調用 Http11Processor
來讀取和解析請求數據。咱們知道,Http11Processor
是應用層協議的封裝,它會調用容器得到響應,再把響應經過 Channel
寫出。工做流程以下所示:
Processor 用來實現 HTTP 協議,Processor 接收來自 EndPoint 的 Socket,讀取字節流解析成 Tomcat Request 和 Response 對象,並經過 Adapter 將其提交到容器處理,Processor 是對應用層協議的抽象。
從圖中咱們看到,EndPoint 接收到 Socket 鏈接後,生成一個 SocketProcessor 任務提交到線程池去處理,SocketProcessor 的 Run 方法會調用 HttpProcessor 組件去解析應用層協議,Processor 經過解析生成 Request 對象後,會調用 Adapter 的 Service 方法,方法內部經過 如下代碼將請求傳遞到容器中。
// Calling the container connector.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:
@Override public 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
有什麼區別嗎?它們的區別是:
Valve
是 Tomcat
的私有機制,與 Tomcat 的基礎架構 API
是緊耦合的。Servlet API
是公有的標準,全部的 Web 容器包括 Jetty 都支持 Filter 機制。Valve
工做在 Web 容器級別,攔截全部應用的請求;而 Servlet Filter
工做在應用級別,只能攔截某個 Web
應用的全部請求。若是想作整個 Web
容器的攔截器,必須經過 Valve
來實現。前面咱們看到 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
接口的定義:
再次看到抽象模板設計模式。
有了接口,咱們就要用類去實現接口。通常來講實現類不止一個,不一樣的類在實現接口時每每會有一些相同的邏輯,若是讓各個子類都去實現一遍,就會有重複代碼。那子類如何重用這部分邏輯呢?其實就是定義一個基類來實現共同的邏輯,而後讓各個子類去繼承它,就達到了重用的目的。
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 個類加載器,另外你也能夠自定義類加載器,它們的關係以下圖所示。
BootstrapClassLoader
是啓動類加載器,由 C 語言實現,用來加載 JVM
啓動時所須要的核心類,好比rt.jar
、resources.jar
等。ExtClassLoader
是擴展類加載器,用來加載\jre\lib\ext
目錄下 JAR 包。AppClassLoader
是系統類加載器,用來加載 classpath
下的類,應用程序默認用它來加載類。這些類加載器的工做原理是同樣的,區別是它們的加載路徑不一樣,也就是說 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; }
AppClassLoader
。ClassNotFound
異常。再來看 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); }
主要有六個步驟:
ExtClassLoader
去加載,由於 ExtClassLoader
會委託給 BootstrapClassLoader
去加載,BootstrapClassLoader
發現本身已經加載了 Object 類,直接返回給 Tomcat 的類加載器,這樣 Tomcat 的類加載器就不會去加載 Web 應用下的 Object 類了,也就避免了覆蓋 JRE 核心類的問題。ExtClassLoader
加載器加載失敗,也就是說 JRE
核心類中沒有這類,那麼就在本地 Web 應用目錄下查找並加載。Class.forName
調用交給系統類加載器的,由於Class.forName
的默認加載器就是系統類加載器。ClassNotFound
異常。Tomcat 做爲 Servlet
容器,它負責加載咱們的 Servlet
類,此外它還負責加載 Servlet
所依賴的 JAR 包。而且 Tomcat
自己也是也是一個 Java 程序,所以它須要加載本身的類和依賴的 JAR 包。首先讓咱們思考這一下這幾個問題:
Servlet
,可是功能不一樣,Tomcat 須要同時加載和管理這兩個同名的 Servlet
類,保證它們不會衝突,所以 Web 應用之間的類須要隔離。Spring
,那 Spring
的 JAR 包被加載到內存後,Tomcat
要保證這兩個 Web 應用可以共享,也就是說 Spring
的 JAR 包只被加載一次,不然隨着依賴的第三方 JAR 包增多,JVM
的內存會膨脹。
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 的總體架構包含了兩個核心組件鏈接器和容器。鏈接器負責對外交流,容器負責內部處理。鏈接器用 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 @Service public class BaiduNegativeValve extends AbstractCheckValve { @Override public void invoke(NetCheckDTO netCheckDTO) { } }
最後就是管理用戶選擇要查驗的模塊,咱們經過 List 保存。用於觸發所須要的查驗模塊
@Slf4j @Service public 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 接口,假設標準流水包含【交易時間、收入、支出、交易餘額、付款人帳號、付款人名字、收款人名稱、收款人帳號】等字段。如今咱們解析出來每一個必要字段所在 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 @Slf4j public 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") @Slf4j public 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 的設計思想運用到實際開發中的綜合例子就放到下回講解了。本篇乾貨滿滿,建議收藏之後多多回味,也但願讀者 「點贊」「分享」「在看」三連就是最大的鼓勵。
後臺回覆 「加羣」 進入專屬技術羣一塊兒成長