什麼是SPI
SPI 是一種服務接口擴展的機制,全名service provider interface。通常由JDK定義接口,第三方做接口的實現,它的核心類是java.util.ServiceLoader。
SPI出現的背景
目的是將服務的定義與服務實現分離以達到解耦,從而提升程序可擴展性的機制。當我們開發一套框架、一套機制或者一套API的時候,如果需要第三方的服務支持(接口實現或者抽象類的實現),可以直接寫死到代碼裏,但是這種方式的耦合性太強,不利於多個三方服務的切換。比較好的辦法是通過配置文件去指定服務的實現方,這時候SPI機制就體現出價值了。
SPI應用場景示例
Java中的SPI
比如我們經常使用的數據庫驅動,由JDK提供統一的規範接口(java.sql.Driver),不同數據庫的服務商針對該接口提供各自的數據庫處理邏輯實現。當用到數據庫時,直接引入不同的SPI服務實現即可。如圖所示:
- 服務提供者定義了接口標準,如:數據庫驅動提供了Java.sql.Driver的接口規範。
- 第三方廠商針對該接口,提供自己的實現。
- 在項目jar包的META-INF/services目錄下,創建一個文本文件,名稱爲接口的全名(包路徑+接口名),內容爲實現類的全名,具體見下圖:
Oracle驅動
MySQL驅動
- 服務調用者引入對應的jar包,放到classpath路徑下。
- 服務調用者(客戶端)通過java.util.ServiceLoader來動態加載數據庫驅動實現,具體就是掃描classpath下所有的jar包中的META-INF/services目錄下,按照約束格式定義的文件,把文件中指定的實現類進行加載。
上面重複提到了META-INF/services 這個路徑,有人可能會有疑問爲啥一定要在這個路徑下搞事情,那麼接下來看下ServiceLoader源碼。
ServiceLoader部分源碼解析
在該類的第一行便指定了接口實現類的配置路徑
- public final class ServiceLoader<S> implements Iterable<S>
- {
- //定義了配置文件路徑
- private static final String PREFIX = "META-INF/services/";
-
- }
|
再看下源碼中如何讀取META-INF/services/路徑下配置文件的
- try {
- String fullName = PREFIX + service.getName();
- if (loader == null)
- configs = ClassLoader.getSystemResources(fullName);
- else
- configs = loader.getResources(fullName);
- } catch (IOException x) {
- fail(service, "Error locating configuration files", x);
- }
|
如上代碼在Serviceloader類的子類LazyIterator(懶加載器)中的代碼段,會掃描所有jar包中的配置文件,然後解析全限定名,獲得實現類路徑,然後在後續的遍歷中通過Class.forName()進行實例化。
ServiceLoader基本操作流程如下(源碼就不貼出來了):
- 通過ServiceLoader的load(Class<S> service)方法進入程序內部;
- 上面load方法內獲得當前線程的ClassLoader,並再此調用內部的load(Class<S> service,lassLoader loader)方法,該方法內會創建ServiceLoader對象,並初始化一些常量。
- ServiceLoader的構造方法內會調用reload方法,來清理緩存,初始化LazyIterator,注意此處是懶加載,此時並不會去加載文件下的內容。
- 當遍歷器被遍歷時,纔會去讀取配置文件。
- 最終被加載的實現類都會緩存到內存中,已經加載過的不重複加載。
模擬實現SPI
我們可以定義自己的一套接口規範,通過SPI的方式對接口進行不同實現。
首先我們創建一個客戶端maven工程命名爲driver-custom,由該工程定義一個接口規範:
在該工程中我們創建一個DatabaseDriver接口,代碼如下:
- package com.demo.spi;
-
- /**
- * @Description: 數據庫驅動
- * @Author xu
- * @Date 2019-12-14
- * @Version V1.0
- */
- public interface DatabaseDriver {
- /**
- * @Author xu
- * @Description 創建數據庫連接
- * @Date 2019-12-14 15:05
- * @Param [text]
- * @return java.lang.String
- */
- String buildConnection(String text);
-
- }
|
然後我們創建一個服務實現的工程,用來實現DatabaseDriver接口,工程命名爲oracle-driver。該工程引入客戶端工程jar包。
- <dependency>
- <groupId>com.demo.spi</groupId>
- <artifactId>driver-custom</artifactId>
- <version>1.0-SNAPSHOT</version>
- </dependency>
|
實現類OracleDriver代碼如下(輸出簡單語句):
- package com.demo.spi;
-
- /**
- * @Description: TODO
- * @Author xuzhiyuan
- * @Date 2019-12-14
- * @Version V1.0
- */
- public class OracleDriver implements DatabaseDriver{
- public String buildConnection(String s) {
- return "Oracle driver"+s;
- }
- }
|
同時需要在該工程resources目錄下創建META-INF/services路徑,在該路徑下以客戶端接口全限定名創建文本文件,在該文件中寫入實現類的全限定名稱。具體如圖所示:
兩個工程創建好之後,我們來進行下測試,爲了少創建一個工程,我們使用客戶端工程寫一個程序調用入口。這裏注意客戶端工程需要引用服務端工程的jar包,兩個工程jar包進行了相互引用,在實現開發中應該避免這種情況。
程序調用入口代碼如下:
- public class App {
- public static void main(String[] args) {
- //加載獲取接口實現類,(如果有多個jar包,會加載多個實現類)
- ServiceLoader<DatabaseDriver> serviceLoader = ServiceLoader.load(DatabaseDriver.class);
- for(DatabaseDriver databaseDriver:serviceLoader){
- System.out.println(databaseDriver.buildConnection(" test"));
- }
- }
- }
|
最後運行App,控制檯輸出運行結果:Oracle driver test
通過以上可以看出,JDK本身提供的SPI機制存在一定缺陷,它會加載classpath下面的所有文件,而不是用哪個去指定加載哪個。
接下來我們看下常用的Dubbo框架是如何使用SPI機制的。
Dubbo中的SPI
Dubbo SPI機制的體現
Dubbo大家肯定不陌生,我們大多數服務都是基於Dubbo實現的。作爲一款高性能RPC框架,通過很好的擴展機制,形成了豐富的核心RPC生態。
- 服務註冊中心發現支持Nacos、etcd、zookeeper、Consul
- 配置中心支持Apollo、Nacos、zookeeper、etcd
- 協議層支持REST、JSONRPC、Dubbo、Http等
- 序列化Hession2、fast-json、Kryo、Java serialize等
- 斷路器(高可用組件)支持Hystrix、Sentinel、Resilience4j
以上可見Dubbo本身定義的協議可以支持很多主流第三方組件的擴展,同時開發者也可以自定義Dubbo協議進行擴展。
擴展點加載是Dubbo的核心功能點,官網對該功能點做了如下描述:
來源:
Dubbo 的擴展點加載從 JDK 標準的 SPI (Service Provider Interface) 擴展點發現機制加強而來。
Dubbo 改進了 JDK 標準的 SPI 的以下問題:
- JDK 標準的 SPI 會一次性實例化擴展點所有實現,如果有擴展實現初始化很耗時,但如果沒用上也加載,會很浪費資源。
- 如果擴展點加載失敗,連擴展點的名稱都拿不到了。比如:JDK 標準的 ScriptEngine,通過
getName() 獲取腳本類型的名稱,但如果 RubyScriptEngine 因爲所依賴的 jruby.jar 不存在,導致 RubyScriptEngine 類加載失敗,這個失敗原因被吃掉了,和 ruby 對應不起來,當用戶執行 ruby 腳本時,會報不支持 ruby,而不是真正失敗的原因。
- 增加了對擴展點 IoC 和 AOP 的支持,一個擴展點可以直接 setter 注入其它擴展點。
約定:
在擴展類的 jar 包內 [1],放置擴展點配置文件 META-INF/dubbo/ 接口全限定名 ,內容爲:配置名 = 擴展實現類全限定名 ,多個實現類用換行符分隔。
|
官網鏈接:http://dubbo.apache.org/zh-cn/docs/dev/SPI.html
在Dubbo官網,開發者指南中有一個 “SPI擴展實現” 的章節針對Dubbo所有功能點的擴展進行了講解,我就不在這裏贅述了。我針對自定義協議擴展和調用攔截擴展寫兩個demo說明下。
自定義協議擴展
比如現在我們對協議進行擴展,實現自己的功能(Dubbo實現的Dubbo協議、Http協議,Rest協議等太高端,滿足不了我低端需求的時候),我要自定義自己的協議,這裏只更改下協議的默認版本號,方便查看測試效果。
創建一個名爲Myprotocol的類,實現Protocol接口,設置默認端口號爲999,具體如下
- public class MyProtocol implements Protocol {
- @Override
- public int getDefaultPort() {
- return 999;
- }
- /**
- * @Author xu
- * @Description 暴露服務
- * @Date 2019-12-16 14:27
- * @Param [invoker]
- * @return org.apache.dubbo.rpc.Exporter<T>
- */
- @Override
- public <T> Exporter<T> export(Invoker<T> invoker) throws RpcException {
- return null;
- }
- /**
- * @Author xu
- * @Description 引用、調用服務
- * @Date 2019-12-16 14:27
- * @Param [type, url]
- * @return org.apache.dubbo.rpc.Invoker<T>
- */
- @Override
- public <T> Invoker<T> refer(Class<T> type, URL url) throws RpcException {
- return null;
- }
- /**
- * @Author 銷燬服務
- * @Description TODO
- * @Date 2019-12-16 14:28
- * @Param []
- * @return void
- */
- @Override
- public void destroy() {
-
- }
|
然後需要在工程的resources目錄下創建META-INF/dubbo的文件夾,在該文件夾下以Protocol的全限定名創建文本文件,文本內容爲:
myprotocol=com.example.dubbo.rest.demodubbo.protocol.MyProtocol
|
最後創建一個執行類,測試下自定義協議是否被Dubbo識別生效,具體如下:
- public class DemoProtocol {
- public static void main(String[] args) {
- Protocol protocol = ExtensionLoader.getExtensionLoader(Protocol.class).getExtension("myprotocol");
- System.out.println(protocol.getDefaultPort());
- }
- }
|
執行程序,控制打印如下,輸出端口號999,說明自定義協議加載成功:
(dubbo官網的協議擴展說明:http://dubbo.apache.org/zh-cn/docs/dev/impls/protocol.html)
自定義攔截擴展
比如我們要實現一個功能,對Dubbo每個服務接口進行請求入參和響應出參以及接口處理時長的日誌打印功能。這時候我們就可以基於org.apache.dubbo.rpc.Filter進行擴展。官網也有簡要說明,我們這裏還是直接上栗子吧。
創建一個名爲MonitorServiceFilter的類,實現對Filter的擴展,具體代碼如下:
- @Activate
- @Slf4j
- public class MonitorServiceFilter implements Filter {
- @Override
- public Result invoke(Invoker<?> invoker, Invocation invocation) throws RpcException {
- Result result = null;
- long spendTime = 0L;
- try {
- String interfaceName = invoker.getInterface().getName();
- String methodName = invocation.getMethodName();
- String logPrefix = interfaceName+"."+methodName;
- long startTime = System.currentTimeMillis();
- log.info("interface:{},request:{}",logPrefix,invocation.getArguments());
- result = invoker.invoke(invocation);
- if(result.hasException()){
- Throwable e = result.getException();
- if(e.getClass().getName().equals("java.lang.RuntimeException")){
- log.error("interface:{},RuntimeException->{}",logPrefix, JSONObject.toJSONString(result));
- }else{
- log.error("interface:{},Exception->{}",logPrefix, JSONObject.toJSONString(result));
- }
- if(result.getException() instanceof Exception){
- throw new Exception(result.getException());
- }
- }else{
- spendTime = System.currentTimeMillis()-startTime;
- log.info("interface:{},response:{},spendTime:{} ms",logPrefix,JSONObject.toJSONString(result.getValue()),spendTime);
- }
- } catch (Exception e) {
- log.error("Exception:{},request{},curr error:{},msg:{}",invocation.getClass(),invocation.getArguments(),
- e.toString(), ExceptionUtils.getRootCause(e));
- return result;
- }
- return result;
- }
- }
|
以上代碼中,@Activate爲激活擴展點的註解,作用就是將我們自定義實現的擴展激活,在Dubbo容器中能夠被加載到。
MonitorServiceFilter類創建好之後,需要在工程的resources目錄下創建META-INF/dubbo的文件夾,在該文件夾下以 dubbo Filter的全限定名創建一個文本文件,文件內容爲KV格式,key爲我們自定義的擴展名(在dubbo配置文件中會被用到),value 爲我們實現擴展類的全限定名。具體如圖所示:
最後在application.properties 中加入如下配置 :
- #接口入參出參響應監聽
- dubbo.provider.filter=monitorServiceFilter
|
執行服務自測類,查看日誌輸出如下:
- [2019-12-16 14:16:33:513] [INFO][main] - cn.fl.preloan.filter.MonitorServiceFilter.invoke(MonitorServiceFilter.java:25) - interface:cn.fl.preloan.insurance.ICapFeeOutLogService.modifyCapFeeOutLog,request:[ModifyCapFeeOutLogRequest(dto=CapFeeOutLogDTO(thrFlag=null, paySchId=null, feeTypCd=null, handleWayCd=null, outAmt=null, outDt=null, outRem=測試一下, isDel=null, paySchNo=null, crtUsrNm=null, paySchDId=null, bizDataId=null, feeTypCdNm=null, handleWayCdNm=null, feeAmt=null, fundId=null, outFlag=0))]
- [2019-12-16 14:16:33:860] [INFO][main] - cn.fl.preloan.filter.MonitorServiceFilter.invoke(MonitorServiceFilter.java:39) - interface:cn.fl.preloan.insurance.ICapFeeOutLogService.modifyCapFeeOutLog,response:{"code":"000000","message":"成功","success":false,"timestamp":1576476993514},spendTime:260 ms
|
以上是兩個簡單例子體現的Dubbo的SPI擴展機制,Dubbo本身的SPI擴展通過查看Dubbo jar包中的META-INF目錄我們可以瞭解更多。
SPI機制是Dubbo架構的核心,我們瞭解了Dubbo SPI的使用以及原理後,對於通過源碼瞭解Dubbo架構思想會起到事半功倍的效果,起碼在讀源碼的過程中,不會不知道很多第三方類是從哪裏來的。
另外Dubbo官網文檔是很棒的學習資料,核心源碼的註釋解讀在官網都有體現,希望大傢伙通過官網能夠得到自己更多的心得體會。
SPI和API區別
SPI:客戶端定義接口規範,外部第三方服務針對該接口做實現,更偏向於客戶端的解耦。
API:服務方定義接口並實現該接口,提供給客戶端調用,客戶端別無選擇,更偏向服務端的解耦。