原創:微信公眾號
碼農參上
,歡迎分享,轉載請保留出處。
八股文背多了,相信大家都聽說過一個詞,SPI擴充套件。
有的面試官就很喜歡問這個問題,SpringBoot的自動裝配是如何實現的?
基本上,你一說是基於spring的SPI擴充套件機制,再把spring.factories
檔案和EnableAutoConfiguration
提一下,那麼這個問題就答的八九不離十了。
就像四五年前,我去面試的時候被問到這個問題,SPI動態擴充套件機制這幾個詞從嘴裡一說出來,就把面試官唬的一愣一愣的。可能他們也沒見過這麼能裝逼的,一句話能簡簡單單說明白,非要拽一個聽上去很高大上的詞。
話說回來,被唬住的可不止是面試官,其實還有我自己。至於SPI擴充套件究竟是個啥,是怎麼實現的,我當時也根本不明白。
不過現在的面試就是這樣,對線八股文,要想唬住面試官,就得先唬住自己。
那麼我們今天暫且不提spring的SPI擴充套件,先來看看java本身自帶的SPI擴充套件機制是怎麼一回事。
SPI的全稱是Service Provider Interface
,翻譯過來就是服務提供者的介面,它所實現的其實是一種服務的發現機制。
這麼說起來可能還是有點不好理解,我舉個例子來類比一下。
在spring專案中,寫service層程式碼前,會約定俗成的會新增一個介面層。然後通過spring中的依賴注入,可以藉助@Autowired
等方式注入這個介面的實現類的範例物件,之後對於service的呼叫一般也基於介面操作。
簡單形容就是這樣的:
如圖所示,介面、實現類都是由服務提供方提供,我們可以把controller看作服務呼叫者,呼叫方只管呼叫介面就可以了。
雖然也有聲音認為,大部分情況下service只有一個實現類,介面層顯得有些多餘。但是在《Head First Design Patterns》這本書中,大佬們還是建議過:
Program to an interface, not an implementation.
沒錯,就是常說的要面向介面程式設計。至於好處,也不外乎是降低耦合度、方便日後擴充套件、提高了程式碼的靈活性和可維護性等等。
在上面這個例子裡,這個介面層和其中的方法我們可以稱之為API,而我們要討論的SPI和它相比,有類似也有差異,還是先看圖:
簡單來說,就是服務的呼叫方定義一個介面規範,可以由不同的服務提供者實現。並且,呼叫方能夠通過某種機制來發現服務提供方,並通過介面呼叫它的能力。
通過對比,我們可以看出它們雖然都有著介面這一層面,但還是有很大的不同:
API中的介面是服務提供者給服務呼叫者的一個功能列表,而SPI中更多強調的是,服務呼叫者對服務實現的一種約束,服務提供者根據這種約束實現的服務,可以被服務呼叫者發現。
說白了,Java中的SPI實現的就是,你按我的介面規範實現服務,我就能通過某種機制為這個介面尋找到這個服務。
這麼說起來可能還有些抽象,下面我們舉一個例子,類比具體描述一下這個過程。
說起智慧家居系統,大家現在都比較熟悉了,只要是相同品牌下的產品,連上wifi就能夠通過手機app控制了,非常方便。
雖然產品不斷更新換代,型號更新層出不窮,但是同種家電在app上操作起來,功能一般都是一樣的。就拿空調來說,我們在app上操作起來一般也就三個主要功能:開關,選模式,調節溫度。
假設我現在在客廳、臥室、書房安裝了3款不同型號的空調,並把它們都接入到了我app中,那麼之後的操作都是相同的幾個按鍵,簡單粗暴。
思考一下,無論是開關還是調溫,都是通過app去呼叫裝置的介面罷了,那麼如果不同型號的空調各寫各的介面,後端app在開發的時候光對接介面都麻煩的要死。
解決方法也很簡單,我先定義一套介面規範,不管你以後什麼型號的空調,都按我的規範來實現介面。以後只要我能發現你的裝置,那麼都可以按相同的方法來呼叫介面。
那麼下面就先來定義這麼一套介面規範,如果你以後想要接入智慧家居系統,那麼就要遵循這個規範來開發介面。
新建一個專案作為標準,就叫aircondition-standard
好了,然後建立一個介面。除了3個操作以外,我們再新增一個獲取空調型號的方法。
public interface IAircondition {
// 獲取型號
String getType();
// 開關
void turnOnOff();
// 調節溫度
void adjustTemperature(int temperature);
// 模式變更
void changeModel(int modelId);
}
這個介面後面要給服務的實現方來使用,用maven把它打成jar包:
mvn clean install
之後服務提供者在專案中就可以引入這個jar包了,有了這套規範,就保證了產品後期不管怎麼更新換代,都能接入到系統來。
制定並行布完規則後,掛式空調作為第一個服務提供者就來了,新建一個專案aircondition-hanging-type
,並引入剛才打好的jar包:
<dependency>
<groupId>com.cn.hydra</groupId>
<artifactId>aircondition-standard</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
建立服務類,並實現前面定義的介面:
public class HangingTypeAircondition
implements IAircondition{
public String getType() {
return "HangingType";
}
public void turnOnOff() {
System.out.println("掛式空調開關");
}
public void adjustTemperature(int i) {
System.out.println("掛式空調調節溫度");
}
public void changeModel(int i) {
System.out.println("掛式空調更換模式");
}
}
在專案的resources
的目錄下,建立META-INF/services
目錄,然後以前面定義的介面名com.cn.hydra.IAircondition
建立檔案,並在檔案中寫入實現類的全限定名。
com.cn.hydra.HangingTypeAircondition
整個專案結構非常簡單:
這樣,一個服務方的簡單實現就搞定了,用maven打成jar包,之後就可以提供給呼叫方使用了。
同理,我們可以再建立一個立式空調的專案aircondition-vertical-type
,也只建立一個服務類:
public class VerticalTypeAircondition
implements IAircondition{
public String getType() {
return "VerticalType";
}
public void turnOnOff() {
System.out.println("立式空調開關");
}
public void adjustTemperature(int i) {
System.out.println("立式空調調節溫度");
}
public void changeModel(int i) {
System.out.println("立式空調更換模式");
}
}
還是按上面的命名規則,建立一個組態檔:
com.cn.hydra.VerticalTypeAircondition
同樣,打成jar包就完事了,至於服務呼叫者如何去發現和呼叫這兩個服務,下面詳細再說。
現在兩個服務提供方都實現了介面,下面關鍵的一步就是服務發現,這一步java中的spi發現機制已經幫我們實現好了。
建立一個新專案aircondition-app
,引入上面打好的兩個jar包。
<dependencies>
<dependency>
<groupId>com.cn.hydra</groupId>
<artifactId>aircondition-hanging-type</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>com.cn.hydra</groupId>
<artifactId>aircondition-vertical-type</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
</dependencies>
按照上面的說法,雖然每個服務提供者對於介面都有不同的實現,但是作為呼叫者來說,它並不需要關心具體的實現類,我們要做的是通過介面來呼叫服務提供者實現的方法。
下面,就是關鍵的服務發現環節,我們寫一個方法,根據型號去呼叫對應空調的開關方法。
public class AirconditionApp {
public static void main(String[] args) {
new AirconditionApp().turnOn("VerticalType");
}
public void turnOn(String type){
ServiceLoader<IAircondition> load = ServiceLoader
.load(IAircondition.class);
for (IAircondition iAircondition : load) {
System.out.println("檢測到:"+iAircondition.getClass().getSimpleName());
if (type.equals(iAircondition.getType())){
iAircondition.turnOnOff();
}
}
}
}
測試結果:
可以看到,測試過程中,通過定義的介面IAircondition
發現了兩個實現類,並通過引數,呼叫了特定實現類的某個方法。整段程式碼中沒有出現過具體的服務實現類,操作都是通過介面呼叫。
瞭解了spi的工作流程,我們再來看看它的實現,其實最關鍵的就是上面程式碼中出現的ServiceLoader
這個類。
上面的範例程式碼中,對於ServiceLoader
的load()
方法的結果,我們用for
迴圈進行了遍歷,這一點我們看一下原始碼就能明白,因為ServiceLoader
實現了Iterable
這一介面,而整個服務發現的核心,就在它的iterator()
方法中。
注意這裡面有兩個關鍵的東西,找一下在原始碼中定義的地方:
註釋寫的非常明白,providers
就是一個快取,在迭代器中如果先從這裡面進行查詢,如果裡面有就繼續往下找,沒有了的話就用這個懶載入的lookupIterator
查詢。
那麼就簡單了,接著往下看LazyIterator
,看看它裡面的hasNext()
和next()
兩個方法是怎麼實現的。
這個acc
是一個安全管理器,在前面通過System.getSecurityManager()
判斷並賦值,debug看一下這裡都是null
,所以直接看hasNextService()
和nextService()
方法就可以了。
在hasNextService()
方法中,會取出介面取出實現類的類名放到nextName
中:
接下來,在nextService()
方法中,則會先載入這個實現類,然後範例化物件,最終放入快取中去。
在迭代器的迭代過程中,會完成所有實現類的範例化,其實歸根結底,還是基於java反射去實現的。
要說spi的實際應用,大家最常見的應該就是紀錄檔框架slf4j
了,它利用spi實現了插槽式接入其他具體的紀錄檔框架。
說白了,slf4j
本身就是個紀錄檔門面,並不提供具體的實現,需要繫結其他具體實現才能真正的引入紀錄檔功能。
例如我們可使用log4j2
作為具體的繫結器,只需要在pom中引入slf4j-log4j12
,就可以使用具體功能。
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>2.0.3</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-log4j12</artifactId>
<version>2.0.3</version>
</dependency>
引入專案後,點開它的jar包看一下具體結構:
有沒有發現一個彩蛋,先說為什麼我們pom中引入的明明是slf4j-log4j12
,實際上引入的是slf4j-reload4j
?翻一下官網的檔案:
大意就是在2015年和2022年,log4j1.x
就已經宣佈end of life
終止了,原因也不難猜,估計是因為頻繁爆出的漏洞。在那之後,slf4j-log4j
在構建階段就會自動重定向到slf4j-reload4j
了,並且官方也強烈建議使用slf4j-reload4j
作為替代。
再回頭看一下jar包的META-INF.services
裡面,通過spi注入了Reload4jServiceProvider
這個實現類,它實現了SLF4JServiceProvider
這一介面,在它的初始化方法initialize()
中,會完成初始化等工作,後續可以繼續獲取到LoggerFactory
和Logger
等具體紀錄檔物件。
Java中的SPI提供了一種比較特別的服務發現和呼叫機制,通過介面靈活的將服務呼叫與服務提供者分離,用於提供給第三方實現擴充套件時還是很方便的。但是也有缺點,比方說一旦載入一個介面,就會把所有實現類都載入進來,可能會載入到不需要的冗餘服務。不過站在整體角度上,還是給我們提供了一種非常不錯的框架擴充套件、整合的思路。
那麼,這次的分享就到這裡,我是Hydra,我們下篇再見。
作者簡介,
碼農參上
,一個熱愛分享的公眾號,有趣、深入、直接,與你聊聊技術,關注領取大量學習資料。
也歡迎新增我好友,多多交流。