什么是 SPI
背景
在面向對象的設計原則中,一般推薦模塊之間基於接口編程,通常情況下調用方模塊是不會感知到被調用方模塊的內部具體實現。一旦代碼里面涉及具體實現類,就違反了開閉原則。如果需要替換一種實現,就需要修改代碼。
為了實現在模塊裝配的時候不用在程序里面動態指明,這就需要一種服務發現機制。Java SPI 就是提供了這樣一個機制:為某個接口尋找服務實現的機制
。這有點類似 IOC 的思想,將裝配的控制權移交到了程序之外。
SPI
英文為 Service Provider Interface
字面意思就是:“服務提供者的接口”,我的理解是:專門提供給服務提供者或者擴展框架功能的開發者去使用的一個接口。
SPI 將服務接口和具體的服務實現分離開來,將服務調用方和服務實現者解耦,能夠提升程序的擴展性、可維護性。修改或者替換服務實現並不需要修改調用方。
使用場景
很多框架都使用了 Java 的 SPI 機制,比如:數據庫加載驅動,日志接口,以及 dubbo 的擴展實現等等。
SPI 和 API 區別
說到 SPI 就不得不說一下 API 了,從廣義上來說它們都屬於接口,而且很容易混淆。下面先用一張圖說明一下:
一般模塊之間都是通過通過接口進行通訊,那我們在服務調用方和服務實現方(也稱服務提供者)之間引入一個“接口”。
API
- 當實現方提供了接口和實現,我們可以通過調用實現方的接口從而擁有實現方給我們提供的能力,這就是 API ,這種接口和實現都是放在實現方的。
SPI
- 當接口存在於調用方這邊時,就是 SPI ,由接口調用方確定接口規則,然后由不同的廠商去根絕這個規則對這個接口進行實現,從而提供服務
實戰演示
Spring 框架提供的日志服務 SLF4J
其實只是一個日志門面(接口),但是 SLF4J 的具體實現可以有幾種,比如:Logback、Log4j、Log4j2 等等,而且還可以切換,在切換日志具體實現的時候我們是不需要更改項目代碼的,只需要在 Maven 依賴里面修改一些 pom 依賴就好了。
這就是依賴 SPI 機制實現的,那我們接下來就實現一個簡易版本的日志框架。
Service Provider Interface
新建 Logger 接口,這個就是 SPI , 服務提供者接口,后面的服務提供者就要針對這個接口進行實現。
public interface Logger {
void info(String msg);
void debug(String msg);
}
接下來就是 LoggerService 類,這個主要是為服務使用者(調用方)提供特定功能的
public class LoggerService {
private static final LoggerService SERVICE = new LoggerService();
private final Logger logger;
private final List<Logger> loggerList;
private LoggerService() {
ServiceLoader<Logger> loader = ServiceLoader.load(Logger.class);
List<Logger> list = new ArrayList<>();
for (Logger log : loader) {
list.add(log);
}
// LoggerList 是所有 ServiceProvider
loggerList = list;
if (!list.isEmpty()) {
// Logger 只取一個
logger = list.get(0);
} else {
logger = null;
}
}
public static LoggerService getService() {
return SERVICE;
}
public void info(String msg) {
if (logger == null) {
System.out.println("info 中沒有發現 Logger 服務提供者");
} else {
logger.info(msg);
}
}
public void debug(String msg) {
if (loggerList.isEmpty()) {
System.out.println("debug 中沒有發現 Logger 服務提供者");
}
loggerList.forEach(log -> log.debug(msg));
}
}
新建 Main 類(服務使用者,調用方),啟動程序查看結果。
public class Main {
public static void main(String[] args) {
LoggerService service = LoggerService.getService();
service.info("Hello SPI");
service.debug("Hello SPI");
}
}
程序結果
info 中沒有發現 Logger 服務提供者
debug 中沒有發現 Logger 服務提供者
Service Provider
接下來新建一個項目用來實現 Logger 接口, 導入 Service Provider Interface的jar包
服務實現類
public class Logback implements Logger {
@Override
public void info(String msg) {
System.out.println("Logback info 的輸出:" + msg);
}
@Override
public void debug(String msg) {
System.out.println("Logback debug 的輸出:" + msg);
}
}
在resource目錄下新建META-INF/services
創建文件名為Logger
路徑, 內容指定 Logback類路徑
測試
在其他項目中引入上面兩個項目
public class SpiTest {
public static void main(String[] args) {
Main.main(null);
}
}
輸出結果
Logback info 的輸出:Hello SPI
Logback debug 的輸出:Hello SPI
說明導入 jar 包中的實現類生效了。通過使用 SPI 機制,可以看出 服務(LoggerService)和 服務提供者兩者之間的耦合度非常低,如果需要替換一種實現(將 Logback 換成另外一種實現),只需要換一個 jar 包即可。這也是 SLF4J 實現原理
查看mysql-connector包 也可以發現SPI機制的實現
線程上下文類加載器
查看 java.util.ServiceLoader.load
方法
發現加載的時候是通過當前線程的上下文類加載器來加載類文件的。
這是因為SPI 的接口是 Java 核心庫的一部分,是由啟動類加載器(Bootstrap ClassLoader)
來加載的,但是SPI 實現的 Java 類一般是由應用程序類加載器(App-ClassLoader)
來加載的。
啟動類加載器是無法找到 SPI 的實現類的,因為它只加載 Java 的核心庫。它也不能代理給應用程序類加載器,因為它是應用程序類加載器的祖先類加載器。也就是說,類加載器的代理模式無法解決這個問題。
線程上下文類加載器正好解決了這個問題。如果不做任何的設置,Java 應用的線程的上下文類加載器默認就是應用程序類加載器。在 SPI 接口的代碼中使用線程上下文類加載器,就可以成功的加載到 SPI 實現的類。線程上下文類加載器在很多 SPI 的實現中都會用到
Dubbo SPI
Dubbo 中實現了一套新的 SPI 機制,功能更強大,也更復雜一些。相關邏輯被封裝在了ExtensionLoader
類中,通過 ExtensionLoader,我們可以加載指定的實現類。Dubbo SPI 所需的配置文件需放置在 META-INF/dubbo
路徑下
與 Java SPI 實現類配置不同,Dubbo SPI 是通過鍵值對的方式進行配置,這樣我們可以按需加載指定的實現類。另外在使用時還需要在接口上標注 @SPI
注解
案例
@SPI
public interface Robot {
void sayHello();
}
public class OptimusPrime implements Robot {
@Override
public void sayHello() {
System.out.println("Hello, I am Optimus Prime.");
}
}
public class Bumblebee implements Robot {
@Override
public void sayHello() {
System.out.println("Hello, I am Bumblebee.");
}
}
public class DubboSPITest {
@Test
public void sayHello() throws Exception {
ExtensionLoader<Robot> extensionLoader =
ExtensionLoader.getExtensionLoader(Robot.class);
Robot optimusPrime = extensionLoader.getExtension("optimusPrime");
optimusPrime.sayHello();
Robot bumblebee = extensionLoader.getExtension("bumblebee");
bumblebee.sayHello();
}
}
Dubbo SPI 和 JDK SPI 最大的區別就在於支持別名
,可以通過某個擴展點的別名來獲取固定的擴展點。就像上面的例子中,我可以獲取 Robot 多個 SPI 實現中別名為“optimusPrime”的實現,也可以獲取別名為“bumblebee”的實現,這個功能非常有用!
通過 @SPI
注解的 value 屬性,還可以默認一個“別名”的實現。比如在Dubbo 中,默認的是Dubbo 私有協議
在 Protocol 接口上,增加了一個 @SPI 注解,而注解的 value 值為 Dubbo ,通過 SPI 獲取實現時就會獲取 Protocol SPI 配置中別名為dubbo的那個實現,com.alibaba.dubbo.rpc.Protocol文件如下:
然后只需要通過getDefaultExtension
,就可以獲取到 @SPI 注解上value對應的那個擴展實現了
Dubbo 的 SPI 中還有一個“加載優先級”,優先加載內置(internal)的,然后加載外部的(external),按優先級順序加載,如果遇到重復就跳過不會加載了。
Spring SPI
Spring 的 SPI 配置文件是一個固定的文件 - META-INF/spring.factories
,功能上和 JDK 的類似,每個接口可以有多個擴展實現,使用起來非常簡單
Spring SPI 中,將所有的配置放到一個固定的文件中,省去了配置一大堆文件的麻煩。Spring的SPI 雖然屬於spring-framework(core),但是目前主要用在spring boot中
和前面兩種 SPI 機制一樣,Spring 也是支持 ClassPath 中存在多個spring.factories
文件的,加載時會按照 classpath 的順序依次加載這些 spring.factories 文件,添加到一個 ArrayList 中。由於沒有別名,所以也沒有去重的概念,有多少就添加多少
但由於 Spring 的 SPI 主要用在 Spring Boot 中,而 Spring Boot 中的 ClassLoader 會優先加載項目中的文件,而不是依賴包中的文件。所以如果在你的項目中定義個spring.factories
文件,那么你項目中的文件會被第一個加載,得到的Factories中,項目中spring.factories
里配置的那個實現類也會排在第一個
如果我們要擴展某個接口的話,只需要在你的項目(spring boot)里新建一個META-INF/spring.factories
文件,只添加你要的那個配置,不要完整的復制一遍 Spring Boot 的spring.factories
文件然后修改
總結
JDK SP | DUBBO SPI | Spring SPI | |
---|---|---|---|
文件方式 | 每個擴展點單獨一個文件 | 每個擴展點單獨一個文件 | 所有的擴展點在一個文件 |
獲取某個固定的實現 | 不支持,只能按順序獲取所有實現 | 有“別名”的概念,可以通過名稱獲取擴展點的某個固定實現,配合Dubbo SPI的注解很方便 | 不支持,只能按順序獲取所有實現。但由於Spring Boot ClassLoader會優先加載用戶代碼中的文件,所以可以保證用戶自定義的spring.factoires文件在第一個,通過獲取第一個factory的方式就可以固定獲取自定義的擴展 |
其他 | 無 | 支持Dubbo內部的依賴注入,通過目錄來區分Dubbo 內置SPI和外部SPI,優先加載內部,保證內部的優先級最高 | 無 |
文檔完整度 | 文章 & 三方資料足夠豐富 | 文檔 & 三方資料足夠豐富 | 文檔不夠豐富,但由於功能少,使用非常簡單 |
IDE支持 | 無 | 無 | IDEA 完美支持,有語法提示 |
三種 SPI 機制對比之下
- JDK 內置的機制是最弱的,但是由於是 JDK 內置,所以還是有一定應用場景,畢竟不用額外的依賴
- Dubbo 的功能最豐富,但機制有點復雜了,而且只能配合 Dubbo 使用,不能完全算是一個獨立的模塊
- Spring 的功能和JDK的相差無幾,最大的區別是所有擴展點寫在一個
spring.factories
文件中,也算是一個改進,並且 IDEA 完美支持語法提示。