導讀:
需求變化是程序員生命中唯一不變的事情,本文將介紹 JDK/Spring/Dubbo 中的 SPI 機制,以此來幫助我們編寫出一套可擴展性強,易於維護的代碼框架。
文|楊亮 網易雲商高級 Java 開發工程師
一、什麼是 SPI?
SPI(Service Provider Interface)是一種旨在由第三方實現或者擴展的 API。它可以用於啓用、擴展甚至替換框架中的組件。 SPI 的目的是爲了在不修改原來的代碼庫的基礎上,開發人員可以使用新的插件或者模塊來增強框架功能。如我們常使用的 JDBC,在 Java 的核心類庫中,並沒有規定開發者需要使用何種類型的數據庫,開發者可以根據自身需求來選擇不同的數據庫類型,可以是 MySQL、Oracle。
所以 Java 的核心類庫只提供了數據庫驅動的接口 Java.sql.Driver,不同的數據庫服務提供商可以實現此接口,而開發者只需配置相應數據庫驅動的實現類,JDBC 框架就能自行加載第三方的服務以達到客戶端訪問不同類型的數據庫的功能。
在很多主流的開發框架中,我們都可以看到 SPI 的身影,除了 JDK 提供的 SPI 機制外,還有諸如 Spring、Spring cloud Alibaba Dubbo 等等,接下來筆者將介紹如何使用它們及其實現原理。
二、JDK SPI
(一)案例
- 定義接口規範
package com.demo.jdkspi.api;public interface SayHelloService { String sayHello(String name);}
- 定義接口實現類
public class SayHelloImpl implements SayHelloService { public String sayHello(String name) { return "你好"+name+",歡迎關注網易雲商!"; }}
-
配置文件
在 resources 目錄下添加純文本文件 META-INF/services/com.demo.jdkspi.api.SayHelloService, 內容如下:
com.demo.jdkspi.impl.SayHelloServiceImpl
-
編寫測試類
客戶端引入依賴,並使用 ServiceLoader 加載接口:
public static void main(String[] args) { // 1. 根據SayHelloService.class創建ServiceLoader實例,此時SayHelloService實例並沒有被創建(懶加載) ServiceLoader<SayHelloService> loader = ServiceLoader.load(SayHelloService.class); // 2. SayHelloService實例是在遍歷的時候創建的 loader.forEach(sayHelloService ->{ System.out.println(sayHelloService.sayHello("Jack")); });}
運行結果如下:
(二) JDK SPI 原理解析
通過案例我們可以知道 JDK SPI 機制主要是通過 ServiceLoader 來實現的, 需要注意的是,實現類的加載是一種懶加載機制,創建 ServiceLoader 並不會去加載接口實現,而是在遍歷的時候再去加載。
創建 ServiceLoader 實例流程:
主要流程描述
- 獲取線程上下文的 ClassLoader: 由於 ServiceLoader 是在 rt.jar 下的,而接口實現類是在 classpath 下面,這打破了雙親委派模型,所以需要從線程上下文中獲取 AppClassLoader 用於加載目標接口及其實現類。
- 清空 providers 緩存: 清空歷史加載緩存。
- 創建 LazyIterator,後續遍歷所有實現類的時候會使用此迭代器。
加載目標服務流程:
主要流程描述
- 在迭代器開始遍歷前,SayHelloService 會去加載 ClassPath(由前文提到的 AppClassLoader 決定的)下所有的目標接口的配置信息。
- 接口實現類的實例化主要是先通過 Class.forName 創建一個 Class 對象,然後通過反射創建實例。
- 在實現類實例化後,ServiceLoader 會根據實現類的全限定名爲標識將實例緩存起來。
(三)JDK SPI 總結
優點:
- 解耦: JDK SPI 使得第三方服務模塊加載控制的邏輯與調用者的業務代碼分離,從而實現解耦。
- 懶加載: 在創建 ServiceLoader 實例的時候並不會去加載第三方服務模塊,而是在遍歷的時候去加載。
缺點
- 只能通過遍歷的方式去獲取所有的接口實現類,並沒有實現按需加載。
- 如果接口實現類依賴了其他擴展實現,JDK SPI 並沒有實現依賴注入的功能。
三、Spring SPI
Spring Boot Starter 是一種依賴的集合,它使得我們只需要進行簡單的配置就能獲取 Spring 和相關技術的一站式服務。而 Spring Boot Starter 的實現也離不開 SPI 思想,下面我們通過實現一個簡單的 starter 組件來體會一下它的魅力。
(一)Spring Boot Starter 案例
-
編寫 SayHello Service 的實現類及 Spring 配置類
創建一個獨立的項目 greeter-spring-boot-starter,並編寫 SayHelloService 實現類及 Spring 配置類
public class Greeter implements SayHelloService, InitializingBean { public String sayHello(String name) { return "你好"+name+",歡迎關注網易雲商!"; } public void afterPropertiesSet() throws Exception { System.out.println("網易雲商服務加載完畢,歡迎使用!"); }}
@Configurationpublic class TestAutoConfiguration { @Bean public SayHelloService sayHelloService(){ return new Greeter(); }}
-
配置文件
在 resources/META-INF 目錄下創建 spring.factories 文件,內容如下:
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\com.demo.springspi.TestAutoConfiguration
-
引入依賴
在客戶端項目中引用 greeter-spring-boot-starter 依賴
<dependency> <groupId>com.spi.demo</groupId> <artifactId>greeter-spring-boot-starter</artifactId> <version>1.0.0-SNAPSHOT</version></dependency>
-
效果展示
在客戶端 Spring 項目啓動的時候,可以清楚的看見,我們編寫的 Greeter 會被 Spring IoC 容器加載。
(二)Spring Boot Starter 原理解析
在 Spring SPI 中,也有一個類似於 ServiceLoader 的類——SpringFactoriesLoader,在 Spring 容器啓動的時候,會通過 SpringFactoriesLoader 去“META-INF/spring.factories”獲取配置類信息,然後將這些配置類信息封裝成 BeanDefinition,這樣 Spring IoC 容器就能管理這些 Bean 了,主要流程如下:
主要流程描述:
- SpringFactoriesLoader 加載配置類信息發生在構建 SpringApplication 實例的時候,SpringFactoriesLoader 會讀取“META-INF/spring.factories”下的配置信息並緩存起來。
- AutoConfigurationImportSelector 是在 @EnableAutoConfiguration 中引入的, AutoConfigurationImportSelector 的核心功能是:獲取 “org.springframework.boot.autoconfigure.EnableAutoConfiguration” 的配置類列表,並且會篩選一遍(如我們在 @EnableAutoConfiguration 中配置了 exclude 屬性),得到最終需要加載的配置類列表。
- ConfigurationClassPostProcessor 會將最終需要加載的配置類列表並將其加載爲 BeanDefinition,後續在解析 BeanClass 的時候,也會調用 Class.forName 來獲取配置類的 Class 對象。Spring Bean 的裝載流程本文不再贅述。
(三)Spring SPI 總結
- 通過將第三方服務實現類交給 Spring 容器管理,很好解決了 JDK SPI 沒有實現依賴注入的問題。
- 配合 Spring Boot 條件裝配,可以在一定條件下實現按需加載第三方服務,而不是加載所有的擴展點實現。
四、Dubbo SPI
SPI 機制在 Dubbo 中也有所應用,Dubbo 通過 SPI 機制加載所有的組件,只不過 Dubbo 並未使用 Java 原生的 SPI 機制,而是對其進行了增強。在 Dubbo 源碼中,經常能看到如下代碼,它們分別是指定名稱擴展點,激活擴展點和自適應擴展點:
ExtensionLoader.getExtensionLoader(XXX.class).getExtension(name);ExtensionLoader.getExtensionLoader(XXX.class).getActivateExtension();ExtensionLoader.getExtensionLoader(XXX.class).getAdaptiveExtension(url,key);
Dubbo SPI 的相關邏輯都封裝在了 ExtensionLoader 類中,通過 ExtensionLoader 我們可以加載指定的實現類,Dubbo 的 SPI 擴展有兩個規則:
- 需要在 resources 目錄下創建任意目錄結構: META-INF/dubbo、META-INF/dubbo/internal、META-INF/services 在對應的目錄下創建以接口全路徑名命名的文件。
- 文件內容是 Key 和 Value 形式的數據, Key 是一個字符串,Value 是一個對應擴展點的實現。
(一)指定名稱擴展點
案例
-
聲明擴展點接口
在一個依賴了 Dubbo 框架的工程中,創建一個擴展點接口及一個實現,擴展點接口需要使用 @SPI 註解,代碼如下:
@SPIpublic interface SayHelloService { String sayHello(String name);}
public class SayHelloServiceImpl implements SayHelloService { @Override public String sayHello(String name) { return "你好"+name+",歡迎關注網易雲商!"; }}
-
配置文件
在 resources 目錄下添加純文本文件 META-INF/dubbo/com.spi.api.dubbo.SayHelloService,內容如下:
neteaseSayHelloService=com.spi.impl.dubbo.SayHelloServiceImpl
- 編寫測試類
public static void main(String[] args) { ExtensionLoader<SayHelloService> extensionLoader = ExtensionLoader.getExtensionLoader(SayHelloService.class); SayHelloService sayHelloService = extensionLoader.getExtension("neteaseSayHelloService"); System.out.println(sayHelloService.sayHello("Jack"));}
(二)激活擴展點
有些時候一個擴展點可能有多個實現,我們希望獲取其中的某一些實現類來實現複雜的功能,Dubbo 爲我們定義了 @Activate 註解來標註實現類,表明該擴展點爲激活擴展點。其中 Dubbo Filter 是我們平時常用的激活擴展點。
案例
在服務提供者端實現兩個功能,一個是在服務調用的時候打印調用日誌,第二個是檢查系統狀態,如果系統未就緒,則直接返回報錯。
- 定義打印日誌的 filter
/** * group = {Constants.PROVIDER}表示在服務提供者端生效 * order表示執行順序,越小越先執行 */@Activate(group = {Constants.PROVIDER}, order = Integer.MIN_VALUE)public class LogFilter implements Filter { @Override public Result invoke(Invoker<?> invoker, Invocation invocation) throws RpcException { System.out.println("打印調用日誌"); return invoker.invoke(invocation); }}
- 定義系統狀態檢查的filter
@Activate(group = {Constants.PROVIDER},order = 0)public class SystemStatusCheckFilter implements Filter { @Override public Result invoke(Invoker<?> invoker, Invocation invocation) throws RpcException { // 校驗系統狀態,如果系統未就緒則調用失敗 if(!sysEnable()) { throw new RuntimeException("系統未就緒,請稍後再試"); } System.out.println("系統準備就緒,能正常使用"); Result result = invoker.invoke(invocation); return result; }}
-
配置文件
在 resources 目錄下添加純文本文件 META-INF/dubbo/com.alibaba.dubbo.rpc.Filter,內容如下:
logFilter=com.springboot.dubbo.springbootdubbosampleprovider.filter.LogFiltersystemStatusCheckFilter=com.springboot.dubbo.springbootdubbosampleprovider.filter.SystemStatusCheckFilter
-
執行效果
在服務提供者端,執行目標方法之前,會先去執行我們定義的兩個 Filter,效果如圖所示:
(三)自適應擴展點
自適應擴展點就是能根據上下文動態匹配一個擴展類,有時候有些擴展並不想在框架啓動階段被加載,而是希望在擴展方法被調用時,根據運行時參數進行加載。
案例
- 定義自適應擴展點接口
@SPI("default")public interface SimpleAdaptiveExt { /** * serviceKey表示會根據URL參數中serviceKey的值來尋找對應的擴展點實現, * 如果沒有找到就使用默認的擴展點。 */ @Adaptive("serviceKey") void sayHello(URL url, String name);}
- 定義擴展點實現類
public class DefaultExtImp implements SimpleAdaptiveExt { @Override public void sayHello(URL url, String name) { System.out.println("Hello " + name); }}
public class OtherExtImp implements SimpleAdaptiveExt { @Override public void sayHello(URL url, String name) { System.out.println("Hi " + name); }}
-
配置文件
在 resources 目錄下添加純文本文件 META-INF/dubbo/com.spi.impl.dubbo.adaptive.SimpleAdaptiveExt,內容如下:
default=com.spi.impl.dubbo.adaptive.DefaultExtImpother=com.spi.impl.dubbo.adaptive.OtherExtImp
- 編寫測試類
public static void main(String[] args) { SimpleAdaptiveExt simpleExt = ExtensionLoader.getExtensionLoader(SimpleAdaptiveExt.class).getAdaptiveExtension(); Map<String, String> map = new HashMap<String, String>(); URL url = new URL("http", "127.0.0.1", 1010, "path", map); // 調用默認擴展點DefaultExtImp.sayHello方法 simpleExt.sayHello(url, "Jack"); url = url.addParameter("serviceKey", "other"); // 此時serviceKey=other,會調用擴展點OtherExtImp.sayHello方法 simpleExt.sayHello(url, "Tom");}
(四)Dubbo 擴展點原理分析
獲取 ExtensionLoader 實例
ExtensionLoader.getExtensionLoader 這個方法主要返回一個 ExtensionLoader 實例,主要邏輯如下:
- 先從緩存“EXTENSION_LOADERS”中獲取擴展類對應的實例;
- 如果緩存未命中,則創建一個新的實例,保存在 EXTENSION_LOADERS 中;
- 在ExtensionLoader構造方法中,會初始化一個ExtensionFactory;
獲取擴展點方法 getExtension
- 先從緩存 cachedClasses 中獲取擴展類,如果沒有就從 META-INF/dubbo/internal/ 、META-INF/dubbo/、META-INF/services/三個目錄中加載。
- 獲取到擴展類以後,檢查緩存 EXTENSION_INSTANCES 中是否有該擴展類的實現,如果沒有就通過反射實例化後放入緩存中。
- 實現依賴注入,如果當前實例依賴了其他擴展實現,那麼 Dubbo 會將依賴注入到當前實例中。
- 將擴展類實例通過 Wrapper 裝飾器進行包裝。
以上步驟中,第一個步驟是加載擴展類的關鍵,第三和第四個步驟是 Dubbo IoC 與 AOP 的具體實現。其中依賴注入是通過調用 injectExtension 來實現的且只支持 setter 方式的注入。
獲取自適應擴展點方法 getAdaptiveExtension
- 調用 getAdaptiveExtensionClass 方法獲取自適應擴展 Class 對象。
- 通過反射進行實例化。調用 injectExtension 方法向擴展類實例中注入依賴。
雖然上述三個流程和和普通擴展點的獲取方法類似,但是在處理 Class 對象的時候,Dubbo 會動態生成自適應擴展點的動態代理類,然後使用 javassist(默認)編譯源碼,得到代理類 Class 實例。其中動態生成的自適應擴展類的源碼如下(以上述代碼中的 SimpleAdaptiveExt 爲例):
package com.spi.impl.dubbo.adaptive;import org.apache.dubbo.common.extension.ExtensionLoader;public class SimpleAdaptiveExt$Adaptive implements com.spi.impl.dubbo.adaptive.SimpleAdaptiveExt { public void sayHello(org.apache.dubbo.common.URL arg0, java.lang.String arg1) { if (arg0 == null) throw new IllegalArgumentException("url == null"); org.apache.dubbo.common.URL url = arg0; String extName = url.getParameter("serviceKey", "default"); if(extName == null) throw new IllegalStateException("Failed to get extension (com.spi.impl.dubbo.adaptive.SimpleAdaptiveExt) name from url (" + url.toString() + ") use keys([serviceKey])"); com.spi.impl.dubbo.adaptive.SimpleAdaptiveExt extension = (com.spi.impl.dubbo.adaptive.SimpleAdaptiveExt)ExtensionLoader.getExtensionLoader(com.spi.impl.dubbo.adaptive.SimpleAdaptiveExt.class).getExtension(extName); extension.sayHello(arg0, arg1); }}
從上述代碼中我們可以看到,在方法 SayHello 中,會去獲取 url 中 serviceKey 對應的值,如果有就使用該值對應的擴展點實現,否則使用默認的擴展點實現。
(五)Dubbo SPI 總結
Dubbo 的擴展點加載從 JDK SPI 擴展點發現機制加強而來,並且改進了 JDK SPI 的以下問題:
- JDK SPI 會一次性實例化擴展點所有實現,而 Dubbo 可以使用自適應擴展點,在擴展方法調用的時候再實例化。
- 增加了對 IoC 的支持,一個擴展點可以通過 setter 方式來注入其他擴展點。
- 增加了 AOP 的支持,基於 Wrapper 包裝器類來增強原有擴展類實例。
五、多租戶系統中定製技術結合 SPI 展望
多租戶系統中動態個性化配置與定製技術能滿足不同租戶的個性化要求,但是大量的定製任務可能使系統變得十分複雜。
爲了方便管理及維護不同租戶的個性化配置,結合 SPI 可以使用不同擴展實現來啓用或擴展框架中的組件的思想,我們可以設計一個租戶個性化定製管理平臺,該平臺能管理各個租戶的定製化配置, 開發人員將不同租戶的個性化差異抽象爲一個個的定製點,定製管理平臺能收集並管理這些定製點信息,業務系統在運行時能從定製平臺中獲取租戶的個性化配置並加載相應的擴展實現,從而滿足不同租戶的個性化需求。整體架構如下:
租戶個性化定製管理平臺主要功能及特性如下:
- 抽象定製點: 開發人員將租戶特徵抽象成不同的定製點接口,對於不同特徵的租戶有不同的擴展實現。
- 定製點發現: 每個服務的定製點及實現信息需要上報給定製管理平臺。
- 定製租戶個性化配置: 運營人員可以根據租戶的特徵配置不同的定製點實現。
- 動態加載: 在租戶訪問業務系統的具體服務時,業務系統能從管理平臺中獲取到相應租戶的配置信息,並且可以通過責任鏈/裝飾器模式來組裝一個或者多個定製點實現。
- 租戶隔離: 運營人員爲租戶設置好個性化配置後,定製管理平臺能夠將配置信息以租戶的維度存儲,從而實現不同租戶定製內容的隔離。
- 定製複用: 對租戶共有特徵進行重用配置或者對那些沒有配置的租戶採用默認配置。
租戶個性化定製管理平臺可以將租戶個性化特徵以元數據的方式進行管理, 後續只要新租戶的個性化需求能通過現有定製點的元數據進行描述,那麼只需要修改配置的方式來滿足新需求,即使滿足不了,也只需要新增或者實現定製點接口並且上報給定製管理平臺,這使得系統易於維護,代碼複用性也會更高。
參考資料
《Dubbo 2.7 開發指南》
《Spring Cloud Alibaba 微服務原理與實戰》
作者介紹
楊亮,網易雲商高級 Java 開發工程師,負責雲商平臺公共業務模塊和內部中間件的設計與開發。