Java SPI機制的理解與應用

背景

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

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

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

SPI是什么

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

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

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

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

模塊化插件

為什么要用SPI

  • 在某些情況下,我們無法預知將會使用哪一個服務,比如無比經典的JDBC驅動、日志輸出;
  • 某些情況下,服務提供方發(fā)生變化時服務調用方修改/維護代碼或配置的成本非常高,如Dubbo、Motan、Spring等框架實現擴展。

舉個例子,隔壁部門覺得我們的一個現有服務很棒,希望我們在其專用環(huán)境部署一份,同時希望以后的所有迭代能夠給他們也更新。但是使用的自研中間件我們使用的內網版本他們使用公網版本,支付上我們對接支付寶他們對接微信......在業(yè)務邏輯不變但切換基礎服務時應該如何維護使成本最?。?/p>

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

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

SPI原理

試想一下,如果要實現這樣的解耦方式,理想情況下應該如何做?不外乎就是以下幾點:

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

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

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

這個類非常簡單,是原生支持的SPI加載方式,實際代碼量也就200行左右。

關鍵點:

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

怎樣使用SPI

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

step.1 調用方定義接口

package com.xxx;

public interface IHelloWorld {
    void sayHello();
}

step.? 使用API方式實現接口

非必選,對照看一下非SPI的方式。

package com.xxx;

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

step.2 調用方在業(yè)務代碼中使用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方式并不需要知道實現類是誰,完全面向接口使用,類似RPC調用的情況;而API要求在業(yè)務方代碼/配置中指明實現類。

step.3 提供方實現接口

這里提供兩個實現類。

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!");
    }
}

可以看出,實現方式與API方式完全一致。

step.4 提供方提供配置

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

/resources/META-INF/services/com.xxx.IHelloWorld的內容為兩個實現類的全限定名稱:

com.xxx.HelloWorldSpi1
com.xxx.HelloWorldSpi2

ps. 通常調用方與提供方不在同一個jar中

輸出結果

Hello API!
Hello SPI 1!
Hello SPI 2!

具體應用方式

參考我們常用的JDBC,我們在同一套代碼中可能需要利用相同接口但不同實現的情況下,可以在代碼中利用SPI接入面向接口編程,在業(yè)務中不考慮具體的底層實現。

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

具體實現可隨時替換,不修改業(yè)務代碼或配置

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

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

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

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

原生SPI的缺點

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

參考資料

理解的Java中SPI機制 - 掘金

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

相關閱讀更多精彩內容

友情鏈接更多精彩內容