技術乾貨 | JDK、Spring、Dubbo SPI 原理介紹

語言: CN / TW / HK

導讀: 需求變化是程式設計師生命中唯一不變的事情,本文將介紹 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 例項流程:

主要流程描述

  1. 獲取執行緒上下文的 ClassLoader: 由於 ServiceLoader 是在 rt.jar 下的,而介面實現類是在 classpath 下面,這打破了雙親委派模型,所以需要從執行緒上下文中獲取 AppClassLoader 用於載入目標介面及其實現類。

  2. 清空 providers 快取: 清空歷史載入快取。

  3. 建立 LazyIterator ,後續遍歷所有實現類的時候會使用此迭代器。

載入目標服務流程:

主要流程描述

  1. 在迭代器開始遍歷前 ,SayHelloService 會去載入 ClassPath(由前文提到的 AppClassLoader 決定的)下所有的目標介面的配置資訊。

  2. 介面實現類的例項化 主要是先通過 Class.forName 建立一個 Class 物件,然後通過反射建立例項。

  3. 在實現類例項化後 ,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("網易雲商服務載入完畢,歡迎使用!");
}
}


@Configuration
public 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 了,主要流程如下:

主要流程描述:

  1. SpringFactoriesLoader 載入配置類資訊發生在構建 SpringApplication 例項的時候,SpringFactoriesLoader 會讀取“META-INF/spring.factories”下的配置資訊並快取起來。

  2. AutoConfigurationImportSelector 是在 @EnableAutoConfiguration 中引入的, AutoConfigurationImportSelector 的核心功能是:獲取 “org.springframework.boot.autoconfigure.EnableAutoConfiguration” 的配置類列表,並且會篩選一遍(如我們在 @EnableAutoConfiguration 中配置了 exclude 屬性),得到最終需要載入的配置類列表。

  3. ConfigurationClassPostProcessor 會將最終需要載入的配置類列表並將其載入為 BeanDefinition,後續在解析 BeanClass 的時候,也會呼叫 Class.forName 來獲取配置類的 Class 物件。Spring Bean 的裝載流程本文不再贅述。

Spring SPI 總結 

  1. 通過將第三方服務實現類交給 Spring 容器管理,很好解決了 JDK SPI 沒有實現依賴注入的問題。

  2. 配合 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 擴充套件有兩個規則:

  1. 需要在 resources 目錄下建立任意目錄結構: META-INF/dubbo、META-INF/dubbo/internal、META-INF/services 在對應的目錄下建立以介面全路徑名命名的檔案。

  2. 檔案內容是 Key 和 Value 形式的資料, Key 是一個字串,Value 是一個對應擴充套件點的實現。

指定名稱擴充套件點 

案例

  • 宣告擴充套件點介面

    在一個依賴了 Dubbo 框架的工程中,建立一個擴充套件點介面及一個實現,擴充套件點介面需要使用 @SPI 註解,程式碼如下:

@SPI
public 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.LogFilter
systemStatusCheckFilter=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.DefaultExtImp
other=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 例項, 主要邏輯如下:

  1. 先從快取“EXTENSION_LOADERS”中獲取擴充套件類對應的例項;

  2. 如果快取未命中,則建立一個新的例項,儲存在 EXTENSION_LOADERS 中;

  3. 在ExtensionLoader構造方法中,會初始化一個ExtensionFactory;

獲取擴充套件點方法 getExtension

  1. 先從快取 cachedClasses 中獲取擴充套件類,如果沒有就從 META-INF/dubbo/internal/ 、META-INF/dubbo/、META-INF/services/三個目錄中載入。

  2. 獲取到擴充套件類以後,檢查快取 EXTENSION_INSTANCES 中是否有該擴充套件類的實現,如果沒有就通過反射例項化後放入快取中。

  3. 實現依賴注入,如果當前例項依賴了其他擴充套件實現,那麼 Dubbo 會將依賴注入到當前例項中。

  4. 將擴充套件類例項通過 Wrapper 裝飾器進行包裝。

以上步驟中,第一個步驟是載入擴充套件類的關鍵,第三和第四個步驟是 Dubbo IoC 與 AOP 的具體實現。 其中依賴注入是通過呼叫 injectExtension 來實現的且只支援 setter 方式的注入。

獲取自適應擴充套件點方法 getAdaptiveExtension

  1. 呼叫 getAdaptiveExtensionClass 方法獲取自適應擴充套件 Class 物件。

  2. 通過反射進行例項化。呼叫 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 的以下問題:

  1. JDK SPI 會一次性例項化擴充套件點所有實現,而 Dubbo 可以使用自適應擴充套件點,在擴充套件方法呼叫的時候再例項化。

  2. 增加了對 IoC 的支援,一個擴充套件點可以通過 setter 方式來注入其他擴充套件點。

  3. 增加了 AOP 的支援,基於 Wrapper 包裝器類來增強原有擴充套件類例項。

多租戶系統中定製技術結合 SPI 展望

多租戶系統中動態個性化配置與定製技術能滿足不同租戶的個性化要求,但是大量的定製任務可能使系統變得十分複雜。

為了方便管理及維護不同租戶的個性化配置,結合 SPI 可以使用不同擴充套件實現來啟用或擴充套件框架中的元件的思想,我們可以設計一個租戶個性化定製管理平臺, 該平臺能管理各個租戶的定製化配置, 開發人員將不同租戶的個性化差異抽象為一個個的定製點,定製管理平臺能收集並管理這些定製點資訊,業務系統在執行時能從定製平臺中獲取租戶的個性化配置並載入相應的擴充套件實現,從而滿足不同租戶的個性化需求。整體架構如下:

租戶個性化定製管理平臺主要功能及特性如下:

  1. 抽象定製點: 開發人員將租戶特徵抽象成不同的定製點介面,對於不同特徵的租戶有不同的擴充套件實現。

  2. 定製點發現: 每個服務的定製點及實現資訊需要上報給定製管理平臺。

  3. 定製租戶個性化配置: 運營人員可以根據租戶的特徵配置不同的定製點實現。

  4. 動態載入: 在租戶訪問業務系統的具體服務時,業務系統能從管理平臺中獲取到相應租戶的配置資訊,並且可以通過責任鏈/裝飾器模式來組裝一個或者多個定製點實現。

  5. 租戶隔離: 運營人員為租戶設定好個性化配置後,定製管理平臺能夠將配置資訊以租戶的維度儲存,從而實現不同租戶定製內容的隔離。

  6. 定製複用: 對租戶共有特徵進行重用配置或者對那些沒有配置的租戶採用預設配置。

租戶個性化定製管理平臺可以將租戶個性化特徵以元資料的方式進行管理, 後續只要新租戶的個性化需求能通過現有定製點的元資料進行描述,那麼只需要修改配置的方式來滿足新需求,即使滿足不了,也只需要新增或者實現定製點介面並且上報給定製管理平臺,這使得系統易於維護,程式碼複用性也會更高。

參考資料 

《Dubbo 2.7 開發指南》

《Spring Cloud Alibaba 微服務原理與實戰》

作者介紹 

楊亮,網易雲商高階 Java 開發工程師,負責雲商平臺公共業務模組和內部中介軟體的設計與開發。

相關閱讀推薦