spi概念

bplan2010發表於2024-08-07

參見:https://zhuanlan.zhihu.com/p/692383462


----- 部落格原文 -----

SPI機制介紹

前言

講 SPI 機制之前,先說說 API ,從面向介面程式設計的思想來看,「服務呼叫方」應該透過呼叫「介面」而不是「具體實現」來處理邏輯。那麼,對於「介面」的定義,應該在「服務呼叫方」還是「服務提供方」呢?

一般來說,會有兩種選擇:

  • 「介面」定義在「服務提供方」
  • 「介面」定義在「服務呼叫方」

情況1: 先來看看「介面」屬於「提供方」的情況。這個很容易理解,提供方同時提供了「介面」和「實現類」,「呼叫方」可以呼叫介面來達到呼叫某實現類的功能,這就是我們日常使用的 API 。 API 的顯著特徵就是:

介面和實現都在服務提供方中。自定義介面,自己去實現這個介面,也就是提供實現類,最後提供給外部去使用

情況2: 那麼再來看看「介面」屬於「呼叫方」的情況。這個其實就是 SPI 機制。以 JDBC 驅動為例,「呼叫方」定義了java.sql.Driver介面(沒有實現這個介面),這個介面位於「呼叫方」JDK 的包中,各個資料庫廠商(也就是服務提供方)實現了這個介面,比如 MySQL 驅動 com.mysql.jdbc.Driver 。 SPI最顯著的特徵就是:

「介面」在「呼叫方」的包,「呼叫方」定義規則,而實現類在「服務提供方」中

總結一下:

  • API 其實是服務提供方,它把介面和實現都做了,然後提供給服務呼叫方來用,服務提供方是處於主導地位的,此時服務呼叫方是被動的
  • SPI 則是服務呼叫方去定義了某種標準(介面),你要給我提供服務,就必須按照我的這個標準來做實現,此時服務呼叫方的處於主導的,而服務提供方是被動的

概念

SPI 全稱:Service Provider Interface,是Java提供的一套用來被第三方實現或者擴充套件的介面,它可以用來啟用框架擴充套件和替換元件。

這是一種JDK內建的一種服務發現的機制,用於制定一些規範,實際實現方式交給不同的服務廠商。如下圖:

面向的物件的設計裡,我們一般推薦模組之間基於介面程式設計,模組之間不對實現類進行硬編碼。一旦程式碼裡涉及具體的實現類,就違反了可插拔的原則,如果需要替換一種實現,就需要修改程式碼。

Q:上圖的基於介面程式設計體現在哪裡?可插拔又是指什麼? A:呼叫方只會去依賴上圖的標準服務介面,而不會去管實現類,這樣做的優點有哪些呢? 1. 如果你想再增加一個實現類,你只需要去實現這個介面就可以,其他地方的程式碼都不用動,這是擴充套件性的體現 2. 又或者是某天你不需要實現類A了,那直接把實現類A去掉就可以了,對整個系統不會有大的改動,這就是可插拔和元件化思想的好處,此時整個系統還實現了充分的解偶

SPI 應用案例之 JDBC DriverManager

眾所周知,關係型資料庫有很多種,如:MySQL、Oracle、PostgreSQL 等等。Java 的 JDBC 提供了一套 API 供 Java 應用與資料庫進行互動,但是,不同的資料庫在底層實現上是有區別的呀,我現在想用這一套 API 對所有資料庫都適用,那怎麼辦勒?此時就出現了一個東西,這個東西就是驅動。

舉個例子:這就相比於你說中文,但是你的客戶可能有說英語、法語、德語等等,此時你是不是希望有個翻譯,而且是有多個翻譯,有翻譯成英語的,翻譯法語的等等(假設一個翻譯只能把中文翻譯成一種語言),有了不同的翻譯之後,這樣就可以把你說的中文翻譯給不同語言的人聽,而驅動就是翻譯

實現 SPI 的四步:

  1. 服務的呼叫者要先定義好介面
  2. 服務的提供者提供了介面的實現
  3. 需要在類目錄下的 META-INF/services/ 目錄裡建立一個以服務介面命名的檔案,這個檔案裡的內容就是這個介面的具體的實現類。
  4. 當其他的程式需要這個服務(服務提供者提供的)的時候,就可以透過查詢這個jar包(一般都是以jar包做依賴)的META-INF/services/中的配置檔案,配置檔案中有介面的具體實現類名,可以根據這個類名進行載入例項化,就可以使用該服務了。

接下來看看 JDBC 的實踐是怎麼做的:

  1. 這是 java.sql.Driver(JDK)中定義驅動的介面(對應在服務的呼叫者要先定義好介面)

1.這是MySQL驅動中的Driver類,它實現了上面的Driver介面

1.並且我們發現在META-INF/services/ 目錄裡建立一個以服務介面(java.sql.Driver)命名的檔案,這個檔案裡的內容就是這個介面的具體的實現類

1.怎麼去把驅動的服務提供給呼叫者呢?現在常用的就是直接引入依賴就可以

SPI 原理

上文中,我們瞭解了使用 Java SPI 的方法。那麼 Java SPI 是如何工作的呢?實際上,Java SPI 機制依賴於 ServiceLoader 類去解析、載入服務。因此,掌握了 ServiceLoader 的工作流程,就掌握了 SPI 的原理。ServiceLoader 的程式碼本身很精練,接下來,讓我們透過讀原始碼的方式,逐一理解 ServiceLoader 的工作流程。

ServiceLoader 的成員變數

先看一下 ServiceLoader 類的成員變數,大致有個印象,後面的原始碼中都會使用到。

public final class ServiceLoader<S> implements Iterable<S> {

    // SPI 配置檔案目錄
    private static final String PREFIX = "META-INF/services/";

    // 將要被載入的 SPI 服務
    private final Class<S> service;

    // 用於載入 SPI 服務的類載入器
    private final ClassLoader loader;

    // ServiceLoader 建立時的訪問控制上下文
    private final AccessControlContext acc;

    // SPI 服務快取,按例項化的順序排列
    private LinkedHashMap<String,S> providers = new LinkedHashMap<>();

    // 懶查詢迭代器
    private LazyIterator lookupIterator;

    // ...
}

ServiceLoader 的工作流程

(1)ServiceLoader.load 靜態方法

應用程式載入 Java SPI 服務,都是先呼叫 ServiceLoader.load 靜態方法,這個方法會new ServiceLoader物件 ServiceLoader.load 靜態方法的作用是: ① 指定類載入 ClassLoader 和訪問控制上下文; ② 然後,重新載入 SPI 服務

  • 清空快取中所有已例項化的 SPI 服務
  • 根據 ClassLoader 和 SPI 型別,建立懶載入迭代器

這裡,摘錄 ServiceLoader.load 相關原始碼,如下:

// service 傳入的是期望載入的 SPI 介面型別
// loader 是用於載入 SPI 服務的類載入器
public static <S> ServiceLoader<S> load(Class<S> service, ClassLoader loader) {
  return new ServiceLoader<>(service, loader);
}

public void reload() {
    // 清空快取中所有已例項化的 SPI 服務
  providers.clear();
    // 根據 ClassLoader 和 SPI 型別,建立懶載入迭代器
  lookupIterator = new LazyIterator(service, loader);
}

// 私有構造方法
// 重新載入 SPI 服務
private ServiceLoader(Class<S> svc, ClassLoader cl) {
  service = Objects.requireNonNull(svc, "Service interface cannot be null");
    // 指定類載入 ClassLoader 和訪問控制上下文
  loader = (cl == null) ? ClassLoader.getSystemClassLoader() : cl;
  acc = (System.getSecurityManager() != null) ? AccessController.getContext() : null;
    // 然後,重新載入 SPI 服務
  reload();
}

(2)應用程式透過 ServiceLoader 的 iterator 方法遍歷 SPI 例項 ServiceLoader 的類定義,明確了 ServiceLoader 類實現了 Iterable 介面,所以,它是可以迭代遍歷的。實際上,ServiceLoader 類維護了一個快取 providers( LinkedHashMap 物件),快取 providers 中儲存了已經被成功載入的 SPI 例項,這個 Map 的 key 是 SPI 介面實現類的全限定名,value 是該實現類的一個例項物件。 當應用程式呼叫 ServiceLoader 的 iterator 方法時,ServiceLoader 會先判斷快取 providers 中是否有資料:如果有,則直接返回快取 providers 的迭代器;如果沒有,則返回懶載入迭代器的迭代器。

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

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

        // lookupIterator 是 LazyIterator 例項,用於懶載入 SPI 例項
    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();
    }

  };
}

(3)懶載入迭代器的工作流程 上面的原始碼中提到了,lookupIterator 是 LazyIterator 例項,而 LazyIterator 用於懶載入 SPI 例項。那麼, LazyIterator 是如何工作的呢? 這裡,摘取 LazyIterator 關鍵程式碼 hasNextService 方法:

  • 拼接 META-INF/services/ + SPI 介面全限定名
  • 透過類載入器,嘗試載入資原始檔
  • 解析資原始檔中的內容,獲取 SPI 介面的實現類的全限定名 nextName

nextService 方法:

  • hasNextService () 方法解析出了 SPI 實現類的的全限定名 nextName,透過反射,獲取 SPI 實現類的類定義 Class。
  • 然後,嘗試透過 Class 的 newInstance 方法例項化一個 SPI 服務物件。如果成功,則將這個物件加入到快取 providers 中並返回該物件。
private boolean hasNextService() {
  if (nextName != null) {
    return true;
  }
  if (configs == null) {
    try {
            // 1.拼接 META-INF/services/ + SPI 介面全限定名
            // 2.透過類載入器,嘗試載入資原始檔
            // 3.解析資原始檔中的內容
      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);
    }
  }
  while ((pending == null) || !pending.hasNext()) {
    if (!configs.hasMoreElements()) {
      return false;
    }
    pending = parse(service, configs.nextElement());
  }
  nextName = pending.next();
  return true;
}

private S nextService() {
  if (!hasNextService())
    throw new NoSuchElementException();
  String cn = nextName;
  nextName = null;
  Class<?> c = null;
  try {
    c = Class.forName(cn, false, loader);
  } catch (ClassNotFoundException x) {
    fail(service,
       "Provider " + cn + " not found");
  }
  if (!service.isAssignableFrom(c)) {
    fail(service,
       "Provider " + cn  + " not a s");
  }
  try {
    S p = service.cast(c.newInstance());
    providers.put(cn, p);
    return p;
  } catch (Throwable x) {
    fail(service,
       "Provider " + cn + " could not be instantiated",
       x);
  }
  throw new Error();          // This cannot happen
}

SPI 和類載入器

透過上面兩個章節中,走讀 ServiceLoader 程式碼,我們已經大致瞭解 Java SPI 的工作原理,即透過 ClassLoader 載入 SPI 配置檔案,解析 SPI 服務,然後透過反射,例項化 SPI 服務例項。我們不妨思考一下,為什麼載入 SPI 服務時,需要指定類載入器 ClassLoader 呢? 學習過 JVM 的讀者,想必都瞭解過類載入器的雙親委派模型(Parents Delegation Model)。雙親委派模型要求除了頂層的 BootstrapClassLoader 外,其餘的類載入器都應有自己的父類載入器。這裡類載入器之間的父子關係一般透過組合(Composition)關係來實現,而不是透過繼承(Inheritance)的關係實現。 雙親委派機制約定了:一個類載入器首先將類載入請求傳送到父類載入器,只有當父類載入器無法完成類載入請求時才嘗試載入。 雙親委派的好處:使得 Java 類伴隨著它的類載入器,天然具備一種帶有優先順序的層次關係,從而使得類載入得到統一,不會出現重複載入的問題:

  1. 系統類防止記憶體中出現多份同樣的位元組碼
  2. 保證 Java 程式安全穩定執行

例如:java.lang.Object 存放在 rt.jar 中,如果編寫另外一個 java.lang.Object 的類並放到 classpath 中,程式可以編譯透過。因為雙親委派模型的存在,所以在 rt.jar 中的 Object 比在 classpath 中的 Object 優先順序更高,因為 rt.jar 中的 Object 使用的是啟動類載入器,而 classpath 中的 Object 使用的是應用程式類載入器。正因為 rt.jar 中的 Object 優先順序更高,因為程式中所有的 Object 都是這個 Object。 雙親委派的限制:子類載入器可以使用父類載入器已經載入的類,而父類載入器無法使用子類載入器已經載入的。—— 這就導致了雙親委派模型並不能解決所有的類載入器問題。Java SPI 就面臨著這樣的問題:

  • SPI 的介面是 Java 核心庫的一部分,是由 BootstrapClassLoader 載入的;
  • 而 SPI 實現的 Java 類一般是由 AppClassLoader 來載入的。BootstrapClassLoader 是無法找到 SPI 的實現類的,因為它只載入 Java 的核心庫。它也不能代理給 AppClassLoader,因為它是最頂層的類載入器。這也解釋了本節開始的問題 —— 為什麼載入 SPI 服務時,需要指定類載入器 ClassLoader 呢?因為如果不指定 ClassLoader,則無法獲取 SPI 服務。

如果不做任何的設定,Java 應用的執行緒的上下文類載入器預設就是 AppClassLoader。在核心類庫使用 SPI 介面時,傳遞的類載入器使用執行緒上下文類載入器,就可以成功的載入到 SPI 實現的類。執行緒上下文類載入器在很多 SPI 的實現中都會用到。 通常可以透過 Thread.currentThread ().getClassLoader () 和 Thread.currentThread ().getContextClassLoader () 獲取執行緒上下文類載入器。

再回顧

剛才 1.3 講了 JDBC 這個例子,1.4 講了 SPI 原理,理解了上面的知識之後,我們再來看一下,Java 資料庫驅動載入的原理是怎樣的,下面是用 JDBC 去連線 MySQL 資料庫的示例程式碼,程式在載入DriverManager 類時,會將 MySQL 的 Driver 物件註冊進 DriverManager 中,這是 SPI 思想的一個典型的實現。得益於 SPI 思想,應用程式中無需指定類似 "com.mysql.cj.jdbc.Driver" 這種全類名,儘可能地將第三方驅動從應用程式中解耦出來。

public static void main(String[] args) {
    // JDBC連線URL
    String url = "jdbc:mysql://localhost:3306/mydatabase";
    String username = "root";
    String password = "password";

    try {
        // 建立資料庫連線
        Connection connection = DriverManager.getConnection(url, username, password);

        // 在此處執行資料庫操作

        // 關閉連線
        connection.close();
    } catch (SQLException e) {
        e.printStackTrace();
    }
}
}

原始碼分析: DriverManager 是管理 JDBC 驅動的基礎服務類,位於 Java.sql 包中,由 boot 類載入器來進行載入。載入該類時,會先執行如下程式碼塊:

然後我們點進loadInitialDrivers()方法,是不是對下圖的紅框部分有種似曾相識的感覺,沒錯,這就是咱們前面 1.4.2 小節第 2 點提到的,幫大家回憶一下,主要是下面這 4 點

  1. 應用程式載入 Java SPI 服務,都是先呼叫 ServiceLoader.load 靜態方法,這個方法會new ServiceLoader物件
  2. 當應用程式呼叫 ServiceLoader 的 iterator 方法時,ServiceLoader 會先判斷快取 providers 中是否有資料:如果有,則直接返回快取 providers 的迭代器;如果沒有,則返回懶載入迭代器的迭代器。
  3. driversIterator.hasNext():此方法用於查詢Driver類;
  4. driversIterator.next():在實現的"next()"方法中進行類載入,使用上面的執行緒上下文類載入器。

如果你這裡還是沒看明白的話,建議再去 1.4.2 節理解一下,next() 方法會最終呼叫到 nextService() 方法,會嘗試透過 Class 的 newInstance 方法例項化一個 SPI 服務物件。如果成功,則將這個物件加入到快取 providers 中並返回該物件。 經歷了上述ServiceLoader類中的一系列操作之後(包括服務發現和類載入),位於 MySQL 驅動包中的Driver類會被初始化。該類如下所示

我們點進去這個註冊方法,會發現這裡會將該 MySQL 驅動新增到成員變數 registeredDrivers 中,該成員變數存放已註冊的 JDBC 驅動列表,

這樣一來,服務發現、類載入、驅動註冊便到此結束。接下來,應用程式執行資料庫連線操作時,會呼叫“getConnection”方法,裡面會遍歷 registeredDrivers,獲取驅動,建立資料庫連線。

Q:這裡又會引申出一個問題,既然是遍歷 registeredDrivers 列表,那是怎麼區分多個驅動的呢? A:我們來看看 getConnection 方法的核心步驟,DriverManager.getConnection 中會遍歷所有已經載入的驅動例項去建立連線,(aDriver.driver.connect這個方法會測試輸入的URL的這個驅動是否匹配)當一個驅動建立連線成功時就會返回這個連線,同時不再呼叫其他的驅動例項。

大總結: 現在我們再來看看下面這段話,我相信理解起來沒有任何難度

當應用程式需要使用某項服務時,它會在類路徑下查詢 META-INF/services 目錄下的配置檔案,檔案中列出了對應服務介面的實現類。然後,應用程式可以透過 Java 標準庫提供的 ServiceLoader 類動態載入並例項化這些服務提供者,從而使用其功能。這種機制被廣泛應用於 Java 中各種框架和元件的擴充套件開發,例如資料庫驅動、日誌模組等。

Java SPI 的優點和不足

Java SPI 的一些優點:

  1. 實現解耦:SPI 使得介面的定義與具體業務實現分離,使得應用程式可以根據實際業務情況啟用或替換具體元件。
  2. 可插拔性和可擴充套件性:SPI 允許第三方提供新的服務實現模組,並透過配置檔案進行宣告,在執行時動態載入,這樣可以輕鬆地擴充套件和替換系統中的功能模組,實現了可插拔性和可擴充套件性。

Java SPI 存在一些不足:

  1. 不能按需載入,需要遍歷所有的實現,並例項化,然後在迴圈中才能找到我們需要的實現。如果不想用某些實現類,或者某些類例項化很耗時,它也被載入並例項化了,這就造成了浪費。
  2. 獲取某個實現類的方式不夠靈活,只能透過 Iterator 形式獲取,不能根據某個引數來獲取對應的實現類。
  3. 多個併發多執行緒使用 ServiceLoader 類的例項是不安全的。
釋出於 2024-04-14 11:24・IP 屬地北京













相關文章