JavaSPI詳解

2022-10-31 06:06:48

目錄

  • 一個問題
  • 什麼是SPI
  • API 與 SPI
  • 一個簡單的例子
  • SPI機制的實現
  • Java SPI的問題
  • 為什麼SPI機制打破了雙親委派模型
  • 參考資料

一個問題

在專案開發中,經常會使用到資料庫驅動,我們連線的資料庫可能是MySQL也有可能是Oracle,但是不管使用什麼資料庫都是引入資料庫驅動設定相應的地址、使用者、密碼資訊就可以使用而不用修改業務程式碼。

這是因為在JDK中提供了一個java.sql.Driver介面。各個資料庫廠商只需要實現這個介面,當我們引入相應驅動,連線資料庫的時候,就會使用廠商提供的實現,那麼又是如何知道廠商實現的類路徑的呢??

什麼是SPI

SPI全名Service Provider interface,翻譯過來就是「服務提供介面」,再說簡單就是提供某一個服務的介面, 提供給服務開發者或者服務生產商來進行實現。
Java SPIJDK內建的一種動態載入擴充套件點的實現。
這個機制在一般的業務程式碼中很少用到(個人接觸到的業務沒有用到過),但是再底層框架中卻被大量使用,包括JDBCDubboSpring框架、紀錄檔介面中都有用到,不同的是有的使用Java原生的實現,有的框架則自己實現了一套SPI機制

API 與 SPI

API 全稱Application Programming Interface, 翻譯為「應用程式介面」,指的是應用程式為外部提供服務的介面,這個介面通常由服務提供者自行開發,定義好介面後很少改動。APISPI示意圖如圖1,圖2所示


圖1  API示意圖

圖2  SPI示意圖

一般應用(模組)之間通過介面進行通訊,服務提供方提供介面並進行實現後,呼叫方就可以通過呼叫這個介面擁有服務提供發提供的能力,這個就是API
當介面是由服務呼叫方提供,並且由服務提供方進行實現時,服務呼叫方就可以根據自己的需要選擇特定實現,而不用更改業務程式碼以獲取相應的功能,這個就是SPI

一個簡單的例子

這個功能是向註冊中心註冊服務的一個範例(過於簡單的範例),

首先定義一個介面Registry, 這個介面只有一個功能,就是向註冊中心註冊一個服務,但是我現在並不確定我選的是什麼註冊中心,於是提供了一個統一的介面,由各個廠商進行實現

package cn.bmilk.chat.spi;

public interface Registry {
    void  registry(String host, int port);
}

廠商實現好後,需要在其META-INF/services資料夾下新增一個檔案,檔名為該介面的全限定名即:cn.bmilk.chat.spi.Registry, 內容為介面實現的全限定名,這裡我寫了兩個

cn.bmilk.chat.spi.EurekaRegistry
cn.bmilk.chat.spi.ZookeeperRegistry

兩個類的都是空實現,內容如下

    @Override
    public void registry(String host, int port) {
        System.out.println(this + "registry , host = " + host +"  port = " + port);
    }

下面編寫測試主類,通過 ServiceLoader 載入 Registry 實現

public class MainTest {

    public static void main(String[] args) {

        ServiceLoader<Registry> load = ServiceLoader.load(Registry.class);
        Iterator<Registry> iterator = load.iterator();
        while (iterator.hasNext()){
            Registry registry = iterator.next();
            registry.registry("127.0.0.1", 10086);
        }
    }
}

執行結果

class cn.bmilk.chat.spi.EurekaRegistry
cn.bmilk.chat.spi.EurekaRegistry@12a3a380registry , host = 127.0.0.1  port = 10086
class cn.bmilk.chat.spi.ZookeeperRegistry
cn.bmilk.chat.spi.ZookeeperRegistry@29453f44registry , host = 127.0.0.1  port = 10086

從執行結果中可以看到EurekaRegistryZookeeperRegistry都被範例化並且生成相應的物件。但是我們全程並沒有顯示的載入和生成EurekaRegistryZookeeperRegistry類物件,那麼是怎麼來的呢?

SPI機制的實現

SPI機制的核心就是ServiceLoader類。其主要的屬性如下:

    // 指出介面組態檔的位置,也就是為什麼要在META-INF/services/下建立介面的全限定名檔案的原因
    private static final String PREFIX = "META-INF/services/";

    // 正在被載入的類(介面)的class物件
    private final Class<S> service;

    // 載入使用的類載入器
    private final ClassLoader loader;

    // 建立 ServiceLoader 時採用的存取控制上下文
    private final AccessControlContext acc;

    // 快取已經載入的實現, 按範例化順序快取
    private LinkedHashMap<String,S> providers = new LinkedHashMap<>();

    // The current lazy-lookup iterator
    private LazyIterator lookupIterator;

load()方法的實現如下:

    public static <S> ServiceLoader<S> load(Class<S> service) {
        ClassLoader cl = Thread.currentThread().getContextClassLoader();
        // 使用當前執行緒的ClassLoader進行載入待載入的實現類
        return ServiceLoader.load(service, cl);
    }

    public static <S> ServiceLoader<S> load(Class<S> service,
                                            ClassLoader loader)
    {
        // load 方法本質是建立一個ServiceLoader物件
        return new ServiceLoader<>(service, loader);
    }

    // new ServiceLoader<>(service, loader)的實現
    private ServiceLoader(Class<S> svc, ClassLoader cl) {
        service = Objects.requireNonNull(svc, "Service interface cannot be null");
        loader = (cl == null) ? ClassLoader.getSystemClassLoader() : cl;
        acc = (System.getSecurityManager() != null) ? AccessController.getContext() : null;
        reload();
    }

    public void reload() {
        providers.clear();
        // 根據介面型別(父類別)和類載入器初始化LazyIterator
        lookupIterator = new LazyIterator(service, loader);
    }

    private LazyIterator(Class<S> service, ClassLoader loader) {
            this.service = service;
            this.loader = loader;
        }

跟蹤load()方法發現其本質是建立了一個ServiceLoader物件,其共有兩個引數,分別是代載入的類父類別(介面)Class類物件和類載入器。在構造方法中完成了兩件事,一個是變數賦值,一個是呼叫reload()方法。reload()方法則根據介面型別(父類別)和類載入器初始化LazyIterator

當執行ServiceLoader#iterator()時,會建立java.util.Iterator匿名內部類實現:

    public Iterator<S> iterator() {
        return new Iterator<S>() {

            Iterator<Map.Entry<String,S>> knownProviders
                = providers.entrySet().iterator();

            public boolean hasNext() {
                if (knownProviders.hasNext())
                    return true;
                return lookupIterator.hasNext();
            }

            public S next() {
                if (knownProviders.hasNext())
                    return knownProviders.next().getValue();
                return lookupIterator.next();
            }

            public void remove() {
                throw new UnsupportedOperationException();
            }

        };
    }

當執行hasNext() 方法時,會先去providers 查詢已經載入的快取實現,如果不存在,則會繼續呼叫LazyIterator#hasNext()用於發現尚未載入的實現,最後的實現在LazyIterator#hasNextService()

LazyIterator的關鍵屬性

// 快取所有需要查詢jar包(檔案)路徑,
Enumeration<URL> configs = null;
// 快取所有被查詢到的實現類全限定名
Iterator<String> pending = null;
// 迭代器使用,下一個需要被載入的類全限定名
String nextName = null;

hasNextService()實現核心如下:

// 獲取所由需要掃描的包路徑
configs = loader.getResources(fullName);
// 迴圈掃描configs中所有的包路徑,解析META-INF/services中的指定檔案(上例中的cn.bmilk.chat.spi.Registry檔案)
// 
while ((pending == null) || !pending.hasNext()) {
    if (!configs.hasMoreElements()) {
        return false;
    }
    // pending快取了所有查詢到的類全限定名
    pending = parse(service, configs.nextElement());
}

在知道是否存在介面的實現後,就是通過next()方法獲取實現,核心功能由nextService()貢獻,核心實現如下:

// 獲取一個實現類全限定名
String cn = nextName;
// 載入這個類
Class<?> c = Class.forName(cn, false, loader);
// 使用反射建立物件
c.newInstance()

hasNextService()完成堆組態檔的讀取,nextService()完成類的載入和物件的建立,這個一切都沒有在ServiceLoader建立時完成,這也是體現了延遲Lazy的一個含義

load()與loadInstalled()

loadInstalled()load()一樣,本質都是建立了一個ServiceLoaderd物件,不同點是使用的載入器不同,load()使用的是Thread.currentThread().getContextClassLoader()當前執行緒的上下文載入器, loadInstalled()使用的是ExtClassLoader載入器來載入

具體實現如下:

    public static <S> ServiceLoader<S> loadInstalled(Class<S> service) {
        ClassLoader cl = ClassLoader.getSystemClassLoader();
        ClassLoader prev = null;
        while (cl != null) {
            prev = cl;
            cl = cl.getParent();
        }
        return ServiceLoader.load(service, prev);
    }


使用這個方法將只掃描JDK安裝目錄jre/lib/ext下的jar包中指定的實現,我們應用程式類路徑下的實現將會被忽略掉

Java SPI的問題

  • Java SPI雖然使用了懶載入機制,但是其獲取一個實現時,需要使用迭代器迴圈載入所有的實現類
  • 當需要某一個實現類時,需要通過迴圈一遍來獲取

這個兩個問題,在Dubbo實現自己的SPI機制時進行了增強,可以僅載入自己想要的擴充套件實現。

為什麼SPI機制打破了雙親委派模型 ??

想不明白 說不清楚,想明白再補充

參考資料