委派模式——從SLF4J說起
一、前言
熟悉JAVA服務器開發的同學應該都使用過日志模塊,并且大概率使用過"log4j-over-slf4j"和“slf4j-log4j”這兩個包。那么這兩個包的區別是什么?為什么會互相引用包含呢?這篇文章會解釋下這幾個概念的區別。
首先說一下SLF4J。
二、從SLF4J開始
SLF4J全稱"Simple Logging Facade for Java (SLF4J) ", 它誕生之初的目的,是為了針對不同的log解決方案,提供一套統一的接口適配標準,從而讓業務代碼無須關心使用到的第三方模塊都使用了哪些log方案。
舉個例子, Apache Dubbo和RabbitMQ使用到的日志模塊便不相同。從某種意義上而言,SLF4J只是一個facade,類似于當年的ODBC(針對不同的數據庫廠商而制定的統一接口標準, 下文會涉及到)。而這個facade對應的包名,是 “slf4j-api-xxx.xxx.xxx.jar”。所以,當你應用了"slf4j-api-xxx.jar"的包時,其實只是引入了一個日志接口標準,而并沒有引入日志具體實現。
2.1、業內實現
SLF4J標準在應用層的核心類,就是兩個: org.slf4j.Logger 和 org.slf4j.LoggerFactory。其中,自版本1.6.0后,如果并沒有具體的實現,slf4j-api會默認提供一個啥也不干的Logger實現(org.slf4j.helpers.NOPLogger)。
在當前(本稿件于2022-03-01擬制)的市面上,既有的實現SLF4J的方案有以下幾種:
整體層次如下圖:
綜上而言:以SLF4J-開頭的jar包,一般指的是采用某種第三方框架實現的slf4j解決方案。
2.2 工作機制
那么整個SLF4J的工作機制是如何運作的呢,換句話說,系統是如何知道應該使用哪個實現方案的呢?
對于那種不需要適配器的原生實現方式,直接引入對應的包即可。
對于那種需要適配器的委托式實現方式,則需要通過另外的一個渠道來告知SLF4J應該使用哪個實現類: SPI機制。
舉個例子,我們看一下slf4j-log4j的包結構:
我們先看pom文件,就包含兩個依賴:
slf4j-log4j同時引入了slf4j-api和log4j。那么slf4j-log4j本身的作用不言而喻:使用LOG4J的功能,實現SLF4J的接口標準。
整體的接口/類關系路徑如下圖:
但是這仍然沒有解決本章節開始提出的問題(程序怎么知道應該用哪個Logger)。
可以從源碼入手:(??slf4j/slf4j-log4j12 at master · qos-ch/slf4j · GitHub??),我們看到了以下關鍵的文件:
也就是說:slf4j-log4j使用了java的SPI機制告知JVM在運行時調用具體哪一個實現類。由于SPI機制暫不屬于本文章討論范圍,讀者可以去官網獲取信息。
讀者可以去??GitHub - qos-ch/slf4j: Simple Logging Facade for Java??看其他的實現方式的適配器是如何工作的。
那么本章開始的問題答案便是:
- SLF4J制定一套日志打印流程,然后把核心類抽象出接口給外部去實現;
- 適配器使用第三方日志組件實現了這些核心類接口,并采用SPI機制,讓JAVA運行時意識到核心接口的具體實現類。
而上述兩點,構成了本文接下來要講述的知識點:委派模式。
三、委派模式
從上文中,我們從SLF4J的案例,引出了"委派模式"這個概念,下面我們就重點討論委派模式(delegation)。
接下來我們按照認知流程,依次從三個問題,解釋委派模式:
- 為什么使用委派模式
- 什么是委派模式
- 如何使用委派模式
然后會在下一章,用業內的典型案例,分析委派模式的使用情況。
3.1 為什么采用委派模式?
我們回到SLF4J。為什么它會用委派模式呢?因為日志打印功能存在各種不同的實現方式。對于應用開發者而言,最好需要一個標準的打印流程,其他第三方組件可以在某些地方有些不同,但是核心流程是最好不要變。對于標準制定者 而言,他無法控制每一個第三方組件的所有細節,所以只能暴露出有限的自定制能力。
而我們放大到軟件領域,或者在互聯網開發領域,不同的開發者的協作模式,主要靠jar包應用:第三方開發一個工具包,放在中心倉庫中(maven, gradle), 使用者從其他信息渠道(csdn, stackoverflow等等)根據問題定位到這個jar包,然后在代碼工程中引用。理論上,如果這個第三方jar包很穩定(例如c3p0),那么該jar包的維護者就很少甚至幾乎不會和使用者建立聯系。如果某些中間件開發者覺得不滿足自己公司/部門的需求,會根據該jar包再做一次自定義封裝。
縱觀上述整個過程,不難發現兩點:
- 工具包開發者和使用者沒有建立穩定的協同渠道
- 工具包開發者對自己成品的發展掌控很薄弱
那么如果有人想要建立一套標準呢?比如log標準,比如數據庫連接標準,那么只能有幾個大公司聯盟,或者著名的開發團隊聯盟,制定一個標準,并實現其中核心鏈路部分。至于為什么不實現全部鏈路,原因也很簡單:軟件領域的協同本身就是弱中心化的 ,否則你不帶別人玩,別人也不會采用你的標準(參考當年IBM推廣的COBOL)。
綜上而言:委派模式是基于當前軟件領域的協作特性,采取的較好的軟件結構模式。
所以啥時候采用委派模式呢?
- 存在設定某個標準并由中心化團隊負責的必要
- 使用者有強烈的需求自定制某些局部實現
這里就舉一個硬件領域的反例:快充標準。在2018年甚至更早,消費者就需要一個快充的功能。但是快充需要定制很多硬件才能實現,所以此時就具備了條件一,但是當時并沒有任何一個團隊或者公司能夠掌控安卓手機硬件整個生態,無法共同推出一個中心化團隊去負責,從而導致各個手機廠商的快充功能百花齊放:A公司的快充線,無法給B公司的手機快充。
3.2 什么是委派模式?
基于上述的討論,委派模式的核心構成就顯而易見了:核心鏈路, 開放接口。
核心鏈路指的是:為了達到某個目的,特定的一組構件,按照特定的順序,特定的協同標準,共同執行計算的邏輯。
開放接口指的是:給定特定的輸入和輸出,將實現細節交給外部的功能接口。
舉個比較現實的例子:傳統汽車。
幾乎每一輛傳統汽車,都按照三大件進行集成和協作:發動機,變速器,底盤。發動機做功, 通過變速器將動力傳輸給底盤(這么說并不標準,甚至在汽車工業的工人眼中,這種描述幾乎是謬論,但是大致是這樣)。也基于此,發動機的接口, 變速箱的接口,底盤的接口都已經固定,剩下的就各個廠商去實現了:三菱的發動機, 日產的發動機,愛信的變速箱,采埃孚的變速箱,倫福德的底盤,天合的底盤等等。甚至連輪胎的接口都制定好了:大陸的輪胎,普利司通的輪胎,固特異的輪胎。
不同的汽車廠商,選擇不同公司的組件,集成出某個汽車型號。當然也有公司自己去實現某個標準:比如大眾自己生產EA888發動機,PSA自己生產并調教的底盤并引以為傲。
如果大家覺得不夠熟悉,那么可以舉一個tomcat的例子。
經歷過00年代的軟件開發者,應該知道當時開發一個web應用是多么的困難:如何監聽socket, 如何編碼解碼,如何處理并發,如何管理進程等等。但是有一點是共通的:每一個Web開發者都想要一個框架去管理整個http服務的協議層和內核層。于是出現了JBoss, WebSphere, Tomcat(笑到了最后)。
這些產品,都是指定了核心的鏈路:監聽socket → 讀數據包→ 封裝成http報文 → 派發給處理池子 → 處理池的線程調用處理邏輯去處理 → 編碼返回的報文 → 編組成tcp包 → 調用內核函數→ 發出數據。
基于這個核心鏈路,制定標準:業務處理邏輯的輸入是什么,輸出是什么,如何讓web框架識別到業務處理模塊。
Tomcat的方案就是web.xml。開發者只要遵從web.xml標準去實現servlet即可。也就是說,在整個http服務器鏈路中,Tomcat將特定的幾個流程處理構件(listener, filter, interceptor, servlet)委派給了業務開發者去實現。
3.3 如何使用委派模式
在使用委派模式之前,先根據上文的模式匹配條件進行自我判斷:
- 存在設定某個標準并由中心化團隊負責的必要
- 使用者有強烈的需求自定制某些局部實現
如果并不符合條件一,那么就不需要考慮使用委派模式;如果符合條件一但是不符合條件二,那就先預留好接口,采用依賴注入的方式,自己開發接口實現類并注入到主流程中。這個做法在很多的第三方依賴包中能夠看到,比如spring的BeanFactory, BeanAware等等,還有各個公司開發SSO時預留的一些hook和filter等等。
在確定使用委派模式后,第一件事就是“確定核心鏈路”,這一步最難,因為往往使用者都有某種期望,但是讓他們具體描述出來,卻又經常不夠精準,甚至有時候后主次顛倒。筆者的建議是:直接讓他們說出原始的需求/痛點,然后自己嘗試給出方案,再對比他們的方案,進行溝通,并逐漸將兩個方案統一。統一的過程也就是不斷試探和確定的過程。
上述的過程是筆者自己的經驗,僅當借鑒。
在確定核心流程后,再將流程中的一些需要自定制的功能抽象成接口暴露出去。接口的定義中,盡量減少對整個流程中其他類的調用依賴。
所以整體的流程分為三步:確認使用該模式;提取核心流程;抽象開放接口。
至于是采用SPI機制還是像TOMCAT一樣使用XML配置識別,需要看具體情況,在此不做涉及。
四、 業內案例
4.1 JDBC
JDBC的誕生很大程度上是借鑒了ODBC的思想,為JAVA設計了專用的數據庫連接規范JDBC(JAVA Database Connectivity)。JDBC期望的目標是讓Java開發人員在編寫數據庫應用程序時,可以有統一的接口,無須依賴特定數據庫API,達到“ 一次開發,適用所有數據庫”。雖然實際開發中,經常會因為使用了數據庫特定的語法、數據類型或函數等而無法達到目標,但JDBC的標準還是大大簡化了開發工作。
整體而言,JDBC的接入結構大致如下圖:
但是實際上,在JDBC誕生之初,市面上并未有很多的廠家響應SUN公司(那時候SUN還并未被ORACLE收購), 于是SUN公司就使用了本文介紹的橋接模式,如下圖:
也是說,形式上,出現了初步委派的結構形式。
下文會只針對單次委托的JDBC層級做分析。
按照上文所言,每一個委派結構,必然存在兩個要素:核心路徑和開放接口。我們從這兩個維度開始分析JDBC。
JDBC的核心路徑分為六步, 包含委托機制需要的兩步(引入包,聲明委托承接人),總共八步,如下:
- 引入JDBC實現包
- 注冊JDBC Driver
- 和數據庫建立連接
- 發起transaction(必要的話),創建statement
- 執行statement并讀取返回,塞入ResultSet
- 處理ResultSet
- 關閉ResultSet, 關閉Statement
- 關閉Connection
縱觀整個過程,核心的參與者為:Driver, Connection, Statement, ResultSet。transaction實際上是基于Connection的三個方法(setAutoCommit, commit, rollback)包裝而成的會話層,理論上不屬于標準層。
以mysql-connector-java為例,具體實現JDBC接口的情況如下:
通過Java自帶的overriding機制,只要使用com.mysql.jdbc.Driver,那么其他組件的實現類便直接被應用實現。具體細節不做討論。那么mysql-connector-java是如何告知JVM應該使用com.mysql.jdbc.Driver呢?
兩種模式
- 明文模式——在業務代碼中明文使用Class.forName("com.mysql.jdbc.Driver")
- SPA機制
其實上述的兩種方法,核心就是初始化com.mysql.jdbc.Driver,執行以下類初始化邏輯。
也就是說,JDBC通過DriveManager維護委托承接者的信息。讀者如果有興趣查看DriverManager的源碼,會發現JDBC的另一種實現類發現方式。不過考慮行文長度,筆者在此不表。
4.2 Apach Dubbo
Dubbo的核心路徑大致如下(不考慮服務管理那一套):
consumer調用 → 參數序列化 → 網絡請求 → 接收請求 → 參數反序列化 → provider計算并返回 → 結果序列化 → 網絡返回 → consumer方接收 → 結果反序列化
(斜體代表consumer方的dubbo職責,下劃線代表provider方的dubbo職責)
Dubbo的可定制接口有很多,整體大量采用了“類SPI”機制,為整個RPC流程的很多環節,提供了自定制的注入機制。相較于傳統的Java SPI, Dubbo SPI在封裝性和實現類發現性上做了很多的擴展和自定制。
Dubbo SPI整體實現機制及工作機制不在本文范圍,但為了行文方便,在此做一些必要說明。整體的Dubbo SPI機制可以分為三部分:
- @SPI注解——聲明當前接口類為可擴展接口。
- @Adaptive注解——聲明當前接口類(或者當前接口類的當前方法)能根據特定條件(注解中的value),動態調用具體實現類實現方法。
- @Activate注解——聲明當前類/方法實現了某個可擴展接口(或者可擴展接口的某個具體方法的實現),并注明被激活的條件,以及所有的被激活實現類中的排序信息。
我們以Dubbo-Auth(??dubbo/dubbo-plugin/dubbo-auth at 3.0 · apache/dubbo · GitHub??)為例,從核心路徑和開放接口兩個維度進行分析。
Dubbo-Auth的實現邏輯,是基于Dubbo-filter的原理,也就是說:Dubbo-Auth本身就是Dubbo整體流程中的某一個環節的委派實現方。
Dubbo-Auth的核心入口(也就是核心路徑的起始點), 是ProviderAuthFilter,是org.apache.dubbo.auth.filter的具體實現, 也就是說:
- org.apache.dubbo.auth.filter是dubbo核心鏈路中對外暴露的一個開發接口(類定義上標注了@SPI)。
- ProviderAuthFilter是實現了dubbo核心鏈路中對外暴露的開發接口Filter(ProviderAuthFilter實現類定義上標注了@Activate)。
ProviderAuthFilter的核心路徑比較簡單:獲取Authenticator對象,使用Authenticator對象進行auth驗證。
具體代碼如下:
注意,上文代碼中
url.getParameter(Constants.AUTHENTICATOR, Constants.DEFAULT_AUTHENTICATOR)
是dubbo spi 的Adaptive機制中的選擇條件,讀者可以深究,本文在此略過。
由于核心路徑包含了Authenticator ,那么Authenticator 自然就很可能是對外暴露的開發接口了。也就是說,Authenticator 的聲明類中,必然是注解了@SPI。
上述代碼證明了筆者的猜想。
在Dubbo-Auth中,提供了一個默認的Authenticator :AccessKeyAuthenticator。在這個實現類中,核心路徑被再次具體化:
- 獲取accessKeyPai;
- 使用accessKeyPair, 計算簽名;
- 對比請求中的簽名和計算的簽名是否相同。
在此核心路徑中,由于引入了accessKeyPair概念,于是就引出一個環節:如何獲取accessKeyPair, 針對此, dubbo-auth又定義了一個開放接口:AccessKeyStorage。
4.3 LOG4J
最后一個案例,我們又回到了日志組件,而之所以介紹LOG4J, 是由于它使用了非常規的“反向委派”機制。
LOG4J借鑒了SLF4J的思想(或者LOG4J在前?SLF4J借鑒的LOG4J ?), 也采用了 接口標準+ 適配器+第三方方案的思路來實現委派。
那么顯然,這里就有個問題:SLF4J確認了自己的核心路徑,然后暴露出待實現接口,SLF4J-LOG4J在嘗試實現SLF4J的待實現接口時,又使用了委托機制,把相關的路徑細節外包了出去,從而形成了一個環。
所以說,如果我同時引入了"log4j-over-slf4j"和"slf4j-log4j",會造成stackoverflow。
這個問題非常典型, google一下就可能看到很多的案例,比如??Analysis of stack overflow exception of log4j-over-slf4j and slf4j-log4j12 coexistence - actorsfit??等等, 官方也給出了警告(??SLF4J Error Codes??)。
由于此文的關注點在委派模式,所以關于此問題并不詳細討論。而此案例的重點,是說明了一件事:委派模式的缺點,就是對于開放接口的實現邏輯不可控。如果第三方實現存在重大機制性隱患,會導致整體核心流程出現問題。
五、總結
綜上所述,
委派模式的使用場景是:
- 存在設定某個標準并由中心化團隊負責的必要;
- 使用者有強烈的需求自定制某些局部實現。
委派模式的核心點: 核心路徑, 開放接口。
委派模式的隱藏機制:實現方式的注冊/發現。
參考資料:
- ?SLF4J Manual?
- ??Using log4j2 with slf4j: java.lang.StackOverflowError - Stack Overflow???
- ??Creating Extensible Applications (The Java? Tutorials > The Extension Mechanism > Creating and Using Extensions) (oracle.com)???
- ??slf4j/slf4j-log4j12 at master · qos-ch/slf4j · GitHub???
- ??Delegation pattern - Wikipedia???
- ??What is a JDBC driver? - IBM Documentation???
- ??Lesson: JDBC Basics (The Java? Tutorials > JDBC Database Access) (oracle.com)???
- ?GitHub - apache/dubbo: Apache Dubbo is a high-performance, java based, open source RPC framework.?