前言
博主在使用 Sa-Token 框架的過程中,越用越感嘆框架設計的精妙。于是,最近在學習如何給 Sa-Token 貢獻自定義框架。為 Sa-Token 的開源盡一份微不足道的力量。我將分三篇文章從 0 到 1 講解如何為 Sa-Token 自定義一個插件,這一集將是前沿知識 —— SPI
那為什么要學 SPI 呢?[Sa-Token 官方描述](https://gitee.com/sa-tokens/sa-token-three-plugin/blob/master/README_PR_STEP.md)
由此可見,Sa-Token 的第三方插件是基于 SPI 機制實現的裝配,我們要知其然且知其所以然,不僅要學會開發插件還要學會大佬們的設計思路
廢話不多說,現在正式開始!
1. 什么是 SPI?
SPI
全稱是Service Provider Interface
(服務提供者接口),是一種 "插件化" 架構思想
它是一種服務發現機制,它允許第三方提供者為核心庫或主框架提供實現或擴展。這種設計允許核心庫/主框架在不修改自身代碼的情況下,通過第三方實現來增強現有功能
舉個例子:
SPI 機制就像 USB 接口:
- 由你定義 USB 接口規范,規定這個 USB 接口需要做什么(SPI)
- 不同的 USB 廠商按照規范做 U 盤/鼠標/鍵盤(不同實現)
- 將 USB 接口插上電腦就能使用(自動加載)
在 JDK 中提供了原生的 SPI,在 Spring 框架中也有一套自己的 SPI 機制。下面,我將分別給大家介紹下這兩套 SPI 機制
- JDK 原生的 SPI
- 定義和發現:
JDK
的SPI
主要通過在META-INF/services/
目錄下放置特定的文件來指定哪些類實現了給定的服務接口。這些文件的名稱要命名為接口的全限定名,內容為實現該接口的全限定類名 - 加載機制:
ServiceLoader
類使用Java
的類加載器機制從META-INF/services/
目錄下加載和實例化服務提供者。例如,ServiceLoader.load(MyServiceInterface.class)
會返回一個實現了MyServiceInterface
的實例迭代器 - 缺點:
JDK
原生的SPI
每次通過ServiceLoader
加載時都會初始化一個新的實例,沒有實現類的緩存,也沒有考慮單例等高級功能
- Spring 框架的 SPI
- 更加靈活:
Spring
的SPI
不僅僅是服務發現,它提供了一套完整的插件機制。例如,可以為Spring
定義新的PropertySource
,ApplicationContextInitializer
等 - 與 IoC 集成:與
JDK
的SPI
不同,Spring
的SPI
與其IoC
(Inversion of Control
) 容器集成,使得在SPI
實現中可以利用Spring
的全部功能,如依賴注入 - 條件匹配:
Spring
提供了基于條件的匹配機制,這允許在某些條件下只加載特定的SPI
實現,例如,可以基于當前運行環境的不同來選擇加載哪個數據庫驅動 - 配置:
Spring
允許通過spring.factories
文件在META-INF
目錄下進行配置,這與JDK
的SPI
很相似,但它提供了更多的功能和靈活性
2. 為什么需要 SPI?
上節介紹了 SPI 機制,那我們為什么需要 SPI 機制呢?
假設有如下需求:電商平臺現在要集成支付功能(支付寶、微信支付、銀聯),但未來可能會擴充新的支付方式
第一種實現方式
大家想到的第一種實現方式是什么?是不是使用一個枚舉類來維護支付類型,在具體的代碼中根據不同的支付類型調用不同的邏輯呢?
// 支付方式枚舉
public enum PaymentType {ALIPAY,WECHAT_PAY,UNION_PAY
}// 支付服務類(緊耦合)
public class PaymentService {public void pay(String orderId, PaymentType type) {switch (type) {case ALIPAY:new AlipayService().pay(orderId);break;case WECHAT_PAY:new WechatPayService().pay(orderId);break;case UNION_PAY:new UnionPayService().pay(orderId); // 新增支付方式需要修改這里break;default:throw new IllegalArgumentException("不支持的支付方式");}}
}// 使用示例
public class OrderController {public void createOrder() {PaymentService paymentService = new PaymentService();paymentService.pay("ORDER_456", PaymentType.ALIPAY);}
}
這種方式沒有使用 SPI 機制,那大家思考下這樣的實現真的合適嗎?
弊端:
- 違反開閉原則:每新增一種支付方式都要修改
PaymentService
類 - 循環依賴風險:支付服務類需要知道所有具體實現
- 編譯期依賴:必須提前引入所有支付 SDK 的 jar 包
- 測試困難:無法單獨測試某個支付方式的實現
第二種實現方式
使用 SPI 機制解耦實現
// 1. 定義SPI接口(與方案1相同)
public interface PaymentService {void pay(String orderId);
}// 2. 各支付實現類(與方案1相同)
public class AlipayService implements PaymentService { /*...*/ }
public class WechatPayService implements PaymentService { /*...*/ }// 3. 注冊服務提供者(新增支付方式只需添加文件)
// META-INF/services/com.example.PaymentService
// 文件內容:
// com.example.AlipayService
// com.example.WechatPayService// 4. 動態加載服務(核心優勢)
public class PaymentGateway {public void processPayment(String orderId) {ServiceLoader<PaymentService> services = ServiceLoader.load(PaymentService.class);// 自動發現所有支付方式for (PaymentService service : services) {service.pay(orderId);}}
}// 使用示例(完全解耦)
public class OrderController {public void createOrder() {PaymentGateway gateway = new PaymentGateway();gateway.processPayment("ORDER_456");}
}
大家思考下,這樣實現的優勢在哪?
優勢:
- 開閉原則:新增支付方式只需添加實現類 + 注冊文件,無需修改已有代碼
- 運行時發現:通過
ServiceLoader
動態加載所有實現 - 模塊化部署:每個支付渠道可以獨立打包為 jar,按需加載
- 熱插拔:可通過類加載器實現運行時替換實現(高級用法)
通過上面的真實案例,相信大家能夠很明顯的感受到 SPI 機制的優點。但需要注意的是,沒有任何一種完美的機制,一切都要以自己公司的需求為主。不要為了用而用!
SPI 機制實現的代碼由于涉及到動態加載,所以性能是比不過硬編碼這種方式,給出證據:
方案 | 平均耗時 | 內存占用 | 啟動速度 |
硬編碼實現 | 28ms | 45MB | 1.2s |
SPI動態加載 | 35ms | 48MB | 1.5s |
3. SPI 在 JDK 中的應用示例
在Java
的生態系統中,SPI
是一個核心概念,允許開發者提供擴展和替代的實現,而核心庫或應用不必更改。下面,我將通過代碼來說明
實現步驟:
- 創建一個 SpringBoot 項目(省略)
- 定義一個服務接口
/*** @Description SPI接口 —— 支付服務* @Author Mr.Zhang* @Date 2025/4/12 20:36* @Version 1.0*/
public interface PaymentService {/*** 支付,具體實現由實現類實現** @param orderId 訂單號*/void pay(String orderId);
}
- 根據不同支付廠商定義不同實現類,為服務接口提供具體實現
/*** @Description 微信支付實現類* @Author Mr.Zhang* @Date 2025/4/12 20:40* @Version 1.0*/
public class WechatServiceImpl implements PaymentService {@Overridepublic void pay(String orderId) {System.out.println("微信支付");}
}
/*** @Description 支付寶支付服務實現類* @Author Mr.Zhang* @Date 2025/4/12 20:38* @Version 1.0*/
public class AlipayServiceImpl implements PaymentService {@Overridepublic void pay(String orderId) {System.out.println("支付寶支付");}
}
- 注冊服務提供者
在資源目錄(通常是src/main/resources/
)下創建一個名為META-INF/services/
的文件夾。在這個文件夾中,創建一個名為com.zhang.spijdkdemo.service.PaymentService
的文件(這是我們接口的全限定名),這個文件沒有任何文件擴展名,所以不要加上.txt
這樣的后綴!文件的內容應為我們所有實現類的全限定名,每個類路徑占一行
注意:
META-INF/services/
是Java SPI
機制中約定俗成的特定目錄!!它不是隨意選擇的,而是SPI
規范中明確定義的。因此,在使用JDK
的ServiceLoader
類來加載服務提供者時,它會特意去查找這個路徑下的文件- 請確保文件的每一行只有一個名稱,并且沒有額外的空格或隱藏的字符,文件使用
UTF-8
編碼。
- 在程序啟動時使用
ServiceLoader.load()
加載和使用服務
public class SpiJdkDemoApplication {public static void main(String[] args) {// load() 方法 會自動加載 META-INF/services/com.zhang.spijdkdemo.service.PaymentService 文件ServiceLoader<PaymentService> loaders = ServiceLoader.load(PaymentService.class);for (PaymentService loader : loaders) {loader.pay("281729172817");}}}
運行結果如下:
Alipay finish... >281729172817
Wechat pay finish... >281729172817