Java SPI機(jī)制的理解與應(yīng)用

背景

一位前輩在一次技術(shù)分享中指出我們目前的包管理不規(guī)范,模塊間職責(zé)有重疊,理解成本高不易維護(hù),提出在開發(fā)過程中應(yīng)當(dāng)明確按照職責(zé)將服務(wù)劃分到對(duì)應(yīng)的模塊中。

比如我們把所有服務(wù)都放在service層,但其實(shí)服務(wù)也是分為基礎(chǔ)服務(wù)和業(yè)務(wù)邏輯服務(wù)的,或許把類似業(yè)務(wù)數(shù)據(jù)查詢組裝服務(wù)放在service層,把具體業(yè)務(wù)邏輯服務(wù)統(tǒng)一放在business層會(huì)更好,更利于基礎(chǔ)服務(wù)的復(fù)用。

但當(dāng)服務(wù)拆離到不同模塊進(jìn)行復(fù)用時(shí),可能在開發(fā)過程中出現(xiàn)服務(wù)依賴的問題,這部分依賴問題的解耦可以用到Java的SPI機(jī)制。顯然,我從來沒有聽說過SPI是什么,也不明白這有什么好處。

SPI是什么

翻遍各種網(wǎng)上資料,來來回回都是車轱轆話,互相抄來抄去講得并不通俗易懂,這里就用我自己的理解來解釋。

SPI(Service Provider Interface),大意是“服務(wù)提供者接口”,是指在服務(wù)使用方角度提出的“接口要求”,是對(duì)“服務(wù)提供方”提出的約定,簡(jiǎn)單說就是:“我需要這樣的服務(wù),現(xiàn)在你們來滿足”。

API(Application Programming Interface)與之相對(duì),是站在服務(wù)提供方角度提供的無需了解底層細(xì)節(jié)的操作入口,即“我有這樣的服務(wù)可以給你使用”。

SPI與API的出發(fā)點(diǎn)截然不同,但作用與目的是相同的,即面向接口編程,也就是解耦。同時(shí)SPI使用的是一種“插件思維”,即服務(wù)提供者負(fù)責(zé)所有的使用維護(hù),當(dāng)替換服務(wù)提供方時(shí)不要說調(diào)用方不修改代碼,連配置文件都不需要修改(不過可能要修改依賴的jar)。

模塊化插件

為什么要用SPI

  • 在某些情況下,我們無法預(yù)知將會(huì)使用哪一個(gè)服務(wù),比如無比經(jīng)典的JDBC驅(qū)動(dòng)、日志輸出;
  • 某些情況下,服務(wù)提供方發(fā)生變化時(shí)服務(wù)調(diào)用方修改/維護(hù)代碼或配置的成本非常高,如Dubbo、Motan、Spring等框架實(shí)現(xiàn)擴(kuò)展。

舉個(gè)例子,隔壁部門覺得我們的一個(gè)現(xiàn)有服務(wù)很棒,希望我們?cè)谄鋵S铆h(huán)境部署一份,同時(shí)希望以后的所有迭代能夠給他們也更新。但是使用的自研中間件我們使用的內(nèi)網(wǎng)版本他們使用公網(wǎng)版本,支付上我們對(duì)接支付寶他們對(duì)接微信......在業(yè)務(wù)邏輯不變但切換基礎(chǔ)服務(wù)時(shí)應(yīng)該如何維護(hù)使成本最???

方案 優(yōu)點(diǎn) 缺點(diǎn)
維護(hù)兩套代碼 邏輯一致 實(shí)現(xiàn)簡(jiǎn)單但維護(hù)成本高
同一套代碼,在業(yè)務(wù)邏輯中區(qū)分環(huán)境 維護(hù)成本低,統(tǒng)一管理 邏輯復(fù)雜,需要硬編碼,當(dāng)再出現(xiàn)新環(huán)境時(shí)還得折騰
SPI“插件”方式 維護(hù)成本低,無需針對(duì)實(shí)現(xiàn)方硬編碼,更多新環(huán)境或服務(wù)提供方變化時(shí)修改簡(jiǎn)單且不影響原有邏輯 理解成本提高

這也許就是一些框架在發(fā)展過程中經(jīng)歷過的階段,可以發(fā)現(xiàn)使用“插件”能更好滿足這個(gè)需求。

SPI原理

試想一下,如果要實(shí)現(xiàn)這樣的解耦方式,理想情況下應(yīng)該如何做?不外乎就是以下幾點(diǎn):

  1. 服務(wù)調(diào)用方定義接口,并在主干服務(wù)中設(shè)置接入點(diǎn)
  2. 服務(wù)提供方實(shí)現(xiàn)接口,并按照約定將實(shí)現(xiàn)類放在調(diào)用方可達(dá)的位置
  3. 調(diào)用方基于約定找到對(duì)應(yīng)位置,將對(duì)應(yīng)接口的實(shí)現(xiàn)類加載到內(nèi)存并連接至接入點(diǎn)
  4. 后續(xù)服務(wù)提供方發(fā)生變更/替換時(shí),只要仍然保持按照約定將新的提供方實(shí)現(xiàn)類替換到對(duì)應(yīng)位置即可,調(diào)用方無需任何修改

這是一種與IOC相同的思路,將裝配控制權(quán)轉(zhuǎn)移至程序外,由配置決定,切換成本低。

java.util.ServiceLoader提供的SPI加載方式

這個(gè)類非常簡(jiǎn)單,是原生支持的SPI加載方式,實(shí)際代碼量也就200行左右。

關(guān)鍵點(diǎn):

  1. 關(guān)鍵方法簽名:public static <S> ServiceLoader<S> load(Class<S> service)
    • 實(shí)現(xiàn)了前文中的第1點(diǎn),即提供接入點(diǎn)設(shè)置
    • 在服務(wù)的接入中,形如ServiceLoader<SomeService> loader = ServiceLoader.load(SomeService.class);可設(shè)置接入對(duì)應(yīng)的接口
  2. 常量:private static final String PREFIX = "META-INF/services/";
    • 約定了上述第2點(diǎn)中指定的位置,基于約定的配置讀取會(huì)從這里查找,當(dāng)然這是指服務(wù)提供方提供的jar中的META-INF/services/目錄
  3. 服務(wù)提供方的實(shí)現(xiàn)類在jar中,而只要在提供方定義好實(shí)現(xiàn)類與調(diào)用方接口之間的關(guān)系即可滿足調(diào)用方的加載需求
    • 實(shí)現(xiàn)了上述第4點(diǎn)中的,只需要提供方按照約定提供實(shí)現(xiàn)類及實(shí)現(xiàn)關(guān)系,可以做到提供方替換時(shí)調(diào)用方無需任何修改
    • 在對(duì)應(yīng)位置META-INF/services/下,文件名應(yīng)為接口全限定名,內(nèi)容每行為一個(gè)實(shí)現(xiàn)類全限定名
  4. 類簽名:public final class ServiceLoader<S> implements Iterable<S>
    • ServiceLoader實(shí)現(xiàn)了Iterable接口,因?yàn)閷?shí)現(xiàn)類與接口之間是多對(duì)一關(guān)系,服務(wù)提供方是有可能對(duì)一個(gè)接口提供多種實(shí)現(xiàn)的,因此加載時(shí)也可以加載多個(gè)實(shí)現(xiàn)類
    • 迭代器簽名:private class LazyIterator implements Iterator<S>,實(shí)現(xiàn)了懶加載迭代,即迭代到對(duì)應(yīng)的類才加載對(duì)應(yīng)的類
  5. 迭代器中的方法:private boolean hasNextService()private S nextService()
    • 分別對(duì)應(yīng)了迭代器中的hasNext()方法和next()方法
    • 實(shí)現(xiàn)了前文中第3點(diǎn),即從約定位置讀取實(shí)現(xiàn)類的全限定名稱,并從jar中加載對(duì)應(yīng)的類
    • 使用Class.forName加載類,使用newInstance初始化實(shí)例,cast進(jìn)行強(qiáng)制類型轉(zhuǎn)換最終得到實(shí)例,因此實(shí)現(xiàn)類必須提供無參構(gòu)造方法

怎樣使用SPI

清楚原理后,使用方式就很好理解。

step.1 調(diào)用方定義接口

package com.xxx;

public interface IHelloWorld {
    void sayHello();
}

step.? 使用API方式實(shí)現(xiàn)接口

非必選,對(duì)照看一下非SPI的方式。

package com.xxx;

public class HelloWorldApi implements IHelloWorld {
    @Override
    public void sayHello() {
        System.out.println("Hello API!");
    }
}

step.2 調(diào)用方在業(yè)務(wù)代碼中使用ServiceLoader

package com.xxx;

import java.util.ServiceLoader;

public class Main {
    public static void main(String[] args) {
        // 使用API
        IHelloWorld helloWorldApi = new HelloWorldApi();
        helloWorldApi.sayHello();

        // 使用SPI
        ServiceLoader<IHelloWorld> loader = ServiceLoader.load(IHelloWorld.class);
        for (IHelloWorld helloWorldSpi : loader) {
            helloWorldSpi.sayHello();
        }
    }
}

主要區(qū)別在于SPI方式并不需要知道實(shí)現(xiàn)類是誰,完全面向接口使用,類似RPC調(diào)用的情況;而API要求在業(yè)務(wù)方代碼/配置中指明實(shí)現(xiàn)類。

step.3 提供方實(shí)現(xiàn)接口

這里提供兩個(gè)實(shí)現(xiàn)類。

package com.xxx;

public class HelloWorldSpi1 implements IHelloWorld {
    @Override
    public void sayHello() {
        System.out.println("Hello SPI 1!");
    }
}
package com.xxx;

public class HelloWorldSpi2 implements IHelloWorld {
    @Override
    public void sayHello() {
        System.out.println("Hello SPI 2!");
    }
}

可以看出,實(shí)現(xiàn)方式與API方式完全一致。

step.4 提供方提供配置

文件位于/resources/META-INF/services,文件名為com.xxx.IHelloWorld即接口全限定名稱。

/resources/META-INF/services/com.xxx.IHelloWorld的內(nèi)容為兩個(gè)實(shí)現(xiàn)類的全限定名稱:

com.xxx.HelloWorldSpi1
com.xxx.HelloWorldSpi2

ps. 通常調(diào)用方與提供方不在同一個(gè)jar中

輸出結(jié)果

Hello API!
Hello SPI 1!
Hello SPI 2!

具體應(yīng)用方式

參考我們常用的JDBC,我們?cè)谕惶状a中可能需要利用相同接口但不同實(shí)現(xiàn)的情況下,可以在代碼中利用SPI接入面向接口編程,在業(yè)務(wù)中不考慮具體的底層實(shí)現(xiàn)。

具體的底層實(shí)現(xiàn)可以分離出來,將每組實(shí)現(xiàn)和SPI配置文件打包成不同的jar,在具體使用時(shí)根據(jù)需要使用不同的jar即可。

具體實(shí)現(xiàn)可隨時(shí)替換,不修改業(yè)務(wù)代碼或配置

mysql-connector-java:5.1.47包的META-INF/services/目錄下有個(gè)java.sql.Driver文件,內(nèi)容為:

com.mysql.jdbc.Driver
com.mysql.fabric.jdbc.FabricMySQLDriver

這是JDBC 4.0之后使用SPI機(jī)制直接獲取實(shí)現(xiàn),避免之前使用Class.forName("com.mysql.jdbc.Driver")方式加載MySQL驅(qū)動(dòng)時(shí)的硬編碼。詳情可見java.sql.DriverManager類中的靜態(tài)代碼塊:

static {
    loadInitialDrivers();   // 這里使用ServiceLoader獲取具體的Driver接口實(shí)現(xiàn)
    println("JDBC DriverManager initialized");
}

原生SPI的缺點(diǎn)

  1. 只能根據(jù)提供方的配置來獲取實(shí)現(xiàn)類,當(dāng)提供方提供多個(gè)實(shí)現(xiàn)時(shí)無法直接指定具體使用哪一個(gè)實(shí)現(xiàn)。當(dāng)然,這正是這個(gè)解耦機(jī)制上必須要做的犧牲,否則就破壞了“不修改代碼”的初衷。但是這一點(diǎn)可以在自定義擴(kuò)展時(shí)優(yōu)化
  2. 非單例,每次load都會(huì)創(chuàng)建新的實(shí)例,建議自行優(yōu)化,注意并發(fā)問題

參考資料

理解的Java中SPI機(jī)制 - 掘金

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容