Java SPI機制的理解與應用

背景

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

好比咱們把全部服務都放在service層,但其實服務也是分爲基礎服務和業務邏輯服務的,或許把相似業務數據查詢組裝服務放在service層,把具體業務邏輯服務統一放在business層會更好,更利於基礎服務的複用。mysql

但當服務拆離到不一樣模塊進行復用時,可能在開發過程當中出現服務依賴的問題,這部分依賴問題的解耦能夠用到Java的SPI機制。顯然,我歷來沒有據說過SPI是什麼,也不明白這有什麼好處。sql

SPI是什麼

翻遍各類網上資料,來來回回都是車軲轆話,互相抄來抄去講得並不通俗易懂,這裏就用我本身的理解來解釋。shell

SPI(Service Provider Interface),大意是「服務提供者接口」,是指在服務使用方角度提出的「接口要求」,是對「服務提供方」提出的約定,簡單說就是:「我須要這樣的服務,如今大家來知足」。編程

API(Application Programming Interface)與之相對,是站在服務提供方角度提供的無需瞭解底層細節的操做入口,即「我有這樣的服務能夠給你使用」。微信

SPI與API的出發點大相徑庭,但做用與目的是相同的,即面向接口編程,也就是解耦。同時SPI使用的是一種「插件思惟」,即服務提供者負責全部的使用維護,當替換服務提供方時不要說調用方不修改代碼,連配置文件都不須要修改(不過可能要修改依賴的jar)。併發

模塊化插件框架

爲何要用SPI

  • 在某些狀況下,咱們沒法預知將會使用哪個服務,好比無比經典的JDBC驅動、日誌輸出;
  • 某些狀況下,服務提供方發生變化時服務調用方修改/維護代碼或配置的成本很是高,如Dubbo、Motan、Spring等框架實現擴展。

舉個例子,隔壁部門以爲咱們的一個現有服務很棒,但願咱們在其專用環境部署一份,同時但願之後的全部迭代可以給他們也更新。可是使用的自研中間件咱們使用的內網版本他們使用公網版本,支付上咱們對接支付寶他們對接微信......在業務邏輯不變但切換基礎服務時應該如何維護使成本最小?ide

方案 優勢 缺點
維護兩套代碼 邏輯一致 實現簡單但維護成本高
同一套代碼,在業務邏輯中區分環境 維護成本低,統一管理 邏輯複雜,須要硬編碼,當再出現新環境時還得折騰
SPI「插件」方式 維護成本低,無需針對實現方硬編碼,更多新環境或服務提供方變化時修改簡單且不影響原有邏輯 理解成本提升

這也許就是一些框架在發展過程當中經歷過的階段,能夠發現使用「插件」能更好知足這個需求。模塊化

SPI原理

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

  1. 服務調用方定義接口,並在主幹服務中設置接入點
  2. 服務提供方實現接口,並按照約定將實現類放在調用方可達的位置
  3. 調用方基於約定找到對應位置,將對應接口的實現類加載到內存並鏈接至接入點
  4. 後續服務提供方發生變動/替換時,只要仍然保持按照約定將新的提供方實現類替換到對應位置便可,調用方無需任何修改

這是一種與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 調用方在業務代碼中使用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();
        }
    }
}
複製代碼

主要區別在於SPI方式並不須要知道實現類是誰,徹底面向接口使用,相似RPC調用的狀況;而API要求在業務方代碼/配置中指明實現類。

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接入面向接口編程,在業務中不考慮具體的底層實現。

具體的底層實現能夠分離出來,將每組實現和SPI配置文件打包成不一樣的jar,在具體使用時根據須要使用不一樣的jar便可。

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

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類中的靜態代碼塊:

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

原生SPI的缺點

  1. 只能根據提供方的配置來獲取實現類,當提供方提供多個實現時沒法直接指定具體使用哪個實現。固然,這正是這個解耦機制上必需要作的犧牲,不然就破壞了「不修改代碼」的初衷。可是這一點能夠在自定義擴展時優化
  2. 非單例,每次load都會建立新的實例,建議自行優化,注意併發問題

參考資料

理解的Java中SPI機制 - 掘金

本文搬自個人博客,歡迎參觀!

image
相關文章
相關標籤/搜索