原始碼級深度理解 Java SPI

2022-11-07 18:02:34

作者:vivo 網際網路伺服器團隊- Zhang Peng

SPI 是一種用於動態載入服務的機制。它的核心思想就是解耦,屬於典型的微核心架構模式。SPI 在 Java 世界應用非常廣泛,如:Dubbo、Spring Boot 等框架。本文從原始碼入手分析,深入探討 Java SPI 的特性、原理,以及在一些比較經典領域的應用。

一、SPI 簡介

SPI 全稱 Service Provider Interface,是 Java 提供的,旨在由第三方實現或擴充套件的 API,它是一種用於動態載入服務的機制。Java 中 SPI 機制主要思想是將裝配的控制權移到程式之外,在模組化設計中這個機制尤其重要,其核心思想就是 解耦。

Java SPI 有四個要素:

  • SPI 介面:為服務提供者實現類約定的的介面或抽象類。
  • SPI 實現類:實際提供服務的實現類。
  • SPI 設定:Java SPI 機制約定的組態檔,提供查詢服務實現類的邏輯。組態檔必須置於 META-INF/services 目錄中,並且,檔名應與服務提供者介面的完全限定名保持一致。檔案中的每一行都有一個實現服務類的詳細資訊,同樣是服務提供者類的完全限定名稱。
  • ServiceLoader:Java SPI 的核心類,用於載入 SPI 實現類。ServiceLoader 中有各種實用方法來獲取特定實現、迭代它們或重新載入服務。

 

二、SPI 範例

正所謂,實踐出真知,我們不妨通過一個具體的範例來看一下,如何使用 Java SPI。

2.1 SPI 介面

首先,需要定義一個 SPI 介面,和普通介面並沒有什麼差別。

package io.github.dunwu.javacore.spi;

public interface DataStorage {
    String search(String key);
}

2.2 SPI 實現類

假設,我們需要在程式中使用兩種不同的資料儲存——MySQL 和 Redis。因此,我們需要兩個不同的實現類去分別完成相應工作。

MySQL查詢 MOCK 類

package io.github.dunwu.javacore.spi;

public class MysqlStorage implements DataStorage {
    @Override
    public String search(String key) {
        return "【Mysql】搜尋" + key + ",結果:No";
    }
}

Redis 查詢 MOCK 類

package io.github.dunwu.javacore.spi;

public class RedisStorage implements DataStorage {
    @Override
    public String search(String key) {
        return "【Redis】搜尋" + key + ",結果:Yes";
    }
}

service 傳入的是期望載入的 SPI 介面型別 到目前為止,定義介面,並實現介面和普通的 Java 介面實現沒有任何不同。

2.3 SPI 設定

如果想通過 Java SPI 機制來發現服務,就需要在 SPI 設定中約定好發現服務的邏輯。組態檔必須置於 META-INF/services 目錄中,並且,檔名應與服務提供者介面的完全限定名保持一致。檔案中的每一行都有一個實現服務類的詳細資訊,同樣是服務提供者類的完全限定名稱。以本範例程式碼為例,其檔名應該為io.github.dunwu.javacore.spi.DataStorage,

檔案中的內容如下:

io.github.dunwu.javacore.spi.MysqlStorage
io.github.dunwu.javacore.spi.RedisStorage

2.4 ServiceLoader

完成了上面的步驟,就可以通過 ServiceLoader 來載入服務。範例如下:

import java.util.ServiceLoader;

public class SpiDemo {

    public static void main(String[] args) {
        ServiceLoader<DataStorage> serviceLoader = ServiceLoader.load(DataStorage.class);
        System.out.println("============ Java SPI 測試============");
        serviceLoader.forEach(loader -> System.out.println(loader.search("Yes Or No")));
    }

}

輸出:

============ Java SPI 測試============
【Mysql】搜尋Yes Or No,結果:No
【Redis】搜尋Yes Or No,結果:Yes

三、SPI 原理

上文中,我們已經瞭解 Java SPI 的要素以及使用 Java SPI 的方法。你有沒有想過,Java SPI 和普通 Java 介面有何不同,Java SPI 是如何工作的。實際上,Java SPI 機制依賴於 ServiceLoader 類去解析、載入服務。因此,掌握了 ServiceLoader 的工作流程,就掌握了 SPI 的原理。ServiceLoader 的程式碼本身很精練,接下來,讓我們通過走讀原始碼的方式,逐一理解 ServiceLoader 的工作流程。

3.1 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;

    // ...
}

3.2 ServiceLoader 的工作流程

(1)ServiceLoader.load 靜態方法

應用程式載入 Java SPI 服務,都是先呼叫 ServiceLoader.load 靜態方法。

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<T> 介面,所以,它是可以迭代遍歷的。實際上,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
}

3.3 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() 獲取執行緒上下文類載入器。

3.4 Java SPI 的不足

Java SPI 存在一些不足:

  • 不能按需載入,需要遍歷所有的實現,並範例化,然後在迴圈中才能找到我們需要的實現。如果不想用某些實現類,或者某些類範例化很耗時,它也被載入並範例化了,這就造成了浪費。

  • 獲取某個實現類的方式不夠靈活,只能通過 Iterator 形式獲取,不能根據某個引數來獲取對應的實現類。

  • 多個並行多執行緒使用 ServiceLoader 類的範例是不安全的。

四、SPI 應用場景

SPI 在 Java 開發中應用十分廣泛。首先,在 Java 的 java.util.spi package 中就約定了很多 SPI 介面。下面,列舉一些 SPI 介面:

除此以外,SPI 還有很多應用,下面列舉幾個經典案例。

4.1 SPI 應用案例之 JDBC DriverManager

作為 Java 工程師,尤其是 CRUD 工程師,相必都非常熟悉 JDBC。眾所周知,關係型資料庫有很多種,如:MySQL、Oracle、PostgreSQL 等等。JDBC 如何識別各種資料庫的驅動呢?

4.1.1 建立資料庫連線

我們先回顧一下,JDBC 如何建立資料庫連線的呢?

在 JDBC4.0 之前,連線資料庫的時候,通常會用 Class.forName(XXX) 方法來載入資料庫相應的驅動,然後再獲取資料庫連線,繼而進行 CRUD 等操作。

Class.forName("com.mysql.jdbc.Driver")

而 JDBC4.0 之後,不再需要用Class.forName(XXX) 方法來載入資料庫驅動,直接獲取連線就可以了。顯然,這種方式很方便,但是如何做到的呢?

(1)JDBC 介面:首先,Java 中內建了介面 java.sql.Driver。

(2)JDBC 介面實現:各個資料庫的驅動自行實現 java.sql.Driver 介面,用於管理資料庫連線。

  • MySQL:在 MySQL的 Java 驅動包 mysql-connector-java-XXX.jar 中,可以找到 META-INF/services 目錄,該目錄下會有一個名字為java.sql.Driver 的檔案,檔案內容是com.mysql.cj.jdbc.Driver。

com.mysql.cj.jdbc.Driver 正是 MySQL 版的 java.sql.Driver 實現。如下圖所示:

圖片
  • PostgreSQL 實現:在 PostgreSQL 的 Java 驅動包 postgresql-42.0.0.jar 中,也可以找到同樣的組態檔,檔案內容是 org.postgresql.Driver,org.postgresql.Driver 正是 PostgreSQL 版的 java.sql.Driver 實現。

(3)建立資料庫連線

以 MySQL 為例,建立資料庫連執行緒式碼如下:

final String DB_URL = String.format("jdbc:mysql://%s:%s/%s", DB_HOST, DB_PORT, DB_SCHEMA);
connection = DriverManager.getConnection(DB_URL, DB_USER, DB_PASSWORD);

4.1.2 DriverManager

從前文,我們已經知道 DriverManager 是建立資料庫連線的關鍵。它究竟是如何工作的呢?

可以看到是載入範例化驅動的,接著看 loadInitialDrivers 方法:

private static void loadInitialDrivers() {
  String drivers;
  try {
    drivers = AccessController.doPrivileged(new PrivilegedAction<String>() {
      public String run() {
        return System.getProperty("jdbc.drivers");
      }
    });
  } catch (Exception ex) {
    drivers = null;
  }
  // 通過 classloader 獲取所有實現 java.sql.Driver 的驅動類
  AccessController.doPrivileged(new PrivilegedAction<Void>() {
    public Void run() {
            // 利用 SPI,記載所有 Driver 服務
      ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class);
            // 獲取迭代器
      Iterator<Driver> driversIterator = loadedDrivers.iterator();
      try{
                // 遍歷迭代器
        while(driversIterator.hasNext()) {
          driversIterator.next();
        }
      } catch(Throwable t) {
      // Do nothing
      }
      return null;
    }
  });

    // 列印資料庫驅動資訊
  println("DriverManager.initialize: jdbc.drivers = " + drivers);

  if (drivers == null || drivers.equals("")) {
    return;
  }
  String[] driversList = drivers.split(":");
  println("number of Drivers:" + driversList.length);
  for (String aDriver : driversList) {
    try {
      println("DriverManager.Initialize: loading " + aDriver);
            // 嘗試範例化驅動
      Class.forName(aDriver, true,
          ClassLoader.getSystemClassLoader());
    } catch (Exception ex) {
      println("DriverManager.Initialize: load failed: " + ex);
    }
  }
}

上面的程式碼主要步驟是:

  1. 從系統變數中獲取驅動的實現類。

  2. 利用 SPI 來獲取所有驅動的實現類。

  3. 遍歷所有驅動,嘗試範例化各個實現類。

  4. 根據第 1 步獲取到的驅動列表來範例化具體的實現類。

需要關注的是下面這行程式碼:

ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class);

這裡實際獲取的是java.util.ServiceLoader.LazyIterator 迭代器。呼叫其 hasNext 方法時,會搜尋 classpath 下以及 jar 包中的 META-INF/services 目錄,查詢 java.sql.Driver 檔案,並找到檔案中的驅動實現類的全限定名。呼叫其 next 方法時,會根據驅動類的全限定名去嘗試範例化一個驅動類的物件。

4.2 SPI 應用案例之 Common-Loggin

common-logging(也稱 Jakarta Commons Logging,縮寫 JCL)是常用的紀錄檔門面工具包。common-logging 的核心類是入口是 LogFactory,LogFatory 是一個抽象類,它負責載入具體的紀錄檔實現。

其入口方法是 LogFactory.getLog 方法,原始碼如下:

public static Log getLog(Class clazz) throws LogConfigurationException {
  return getFactory().getInstance(clazz);
}

public static Log getLog(String name) throws LogConfigurationException {
  return getFactory().getInstance(name);
}

從以上原始碼可知,getLog 採用了工廠設計模式,是先呼叫 getFactory 方法獲取具體紀錄檔庫的工廠類,然後根據類名稱或型別建立紀錄檔範例。

LogFatory.getFactory 方法負責選出匹配的紀錄檔工廠,其原始碼如下:

public static LogFactory getFactory() throws LogConfigurationException {
  // 省略...

  // 載入 commons-logging.properties 組態檔
  Properties props = getConfigurationFile(contextClassLoader, FACTORY_PROPERTIES);

  // 省略...

    // 決定建立哪個 LogFactory 範例
  // (1)嘗試讀取全域性屬性 org.apache.commons.logging.LogFactory
  if (isDiagnosticsEnabled()) {
    logDiagnostic("[LOOKUP] Looking for system property [" + FACTORY_PROPERTY +
            "] to define the LogFactory subclass to use...");
  }

  try {
        // 如果指定了 org.apache.commons.logging.LogFactory 屬性,嘗試範例化具體實現類
    String factoryClass = getSystemProperty(FACTORY_PROPERTY, null);
    if (factoryClass != null) {
      if (isDiagnosticsEnabled()) {
        logDiagnostic("[LOOKUP] Creating an instance of LogFactory class '" + factoryClass +
                "' as specified by system property " + FACTORY_PROPERTY);
      }
      factory = newFactory(factoryClass, baseClassLoader, contextClassLoader);
    } else {
      if (isDiagnosticsEnabled()) {
        logDiagnostic("[LOOKUP] No system property [" + FACTORY_PROPERTY + "] defined.");
      }
    }
  } catch (SecurityException e) {
      // 例外處理
  } catch (RuntimeException e) {
      // 例外處理
  }

    // (2)利用 Java SPI 機制,嘗試在 classpatch 的 META-INF/services 目錄下尋找 org.apache.commons.logging.LogFactory 實現類
  if (factory == null) {
    if (isDiagnosticsEnabled()) {
      logDiagnostic("[LOOKUP] Looking for a resource file of name [" + SERVICE_ID +
              "] to define the LogFactory subclass to use...");
    }
    try {
      final InputStream is = getResourceAsStream(contextClassLoader, SERVICE_ID);

      if( is != null ) {
        // This code is needed by EBCDIC and other strange systems.
        // It's a fix for bugs reported in xerces
        BufferedReader rd;
        try {
          rd = new BufferedReader(new InputStreamReader(is, "UTF-8"));
        } catch (java.io.UnsupportedEncodingException e) {
          rd = new BufferedReader(new InputStreamReader(is));
        }

        String factoryClassName = rd.readLine();
        rd.close();

        if (factoryClassName != null && ! "".equals(factoryClassName)) {
          if (isDiagnosticsEnabled()) {
            logDiagnostic("[LOOKUP]  Creating an instance of LogFactory class " +
                    factoryClassName +
                    " as specified by file '" + SERVICE_ID +
                    "' which was present in the path of the context classloader.");
          }
          factory = newFactory(factoryClassName, baseClassLoader, contextClassLoader );
        }
      } else {
        // is == null
        if (isDiagnosticsEnabled()) {
          logDiagnostic("[LOOKUP] No resource file with name '" + SERVICE_ID + "' found.");
        }
      }
    } catch (Exception ex) {
      // note: if the specified LogFactory class wasn't compatible with LogFactory
      // for some reason, a ClassCastException will be caught here, and attempts will
      // continue to find a compatible class.
      if (isDiagnosticsEnabled()) {
        logDiagnostic(
          "[LOOKUP] A security exception occurred while trying to create an" +
          " instance of the custom factory class" +
          ": [" + trim(ex.getMessage()) +
          "]. Trying alternative implementations...");
      }
      // ignore
    }
  }

  // (3)嘗試從 classpath 目錄下的 commons-logging.properties 檔案中查詢 org.apache.commons.logging.LogFactory 屬性

  if (factory == null) {
    if (props != null) {
      if (isDiagnosticsEnabled()) {
        logDiagnostic(
          "[LOOKUP] Looking in properties file for entry with key '" + FACTORY_PROPERTY +
          "' to define the LogFactory subclass to use...");
      }
      String factoryClass = props.getProperty(FACTORY_PROPERTY);
      if (factoryClass != null) {
        if (isDiagnosticsEnabled()) {
          logDiagnostic(
            "[LOOKUP] Properties file specifies LogFactory subclass '" + factoryClass + "'");
        }
        factory = newFactory(factoryClass, baseClassLoader, contextClassLoader);

        // TODO: think about whether we need to handle exceptions from newFactory
      } else {
        if (isDiagnosticsEnabled()) {
          logDiagnostic("[LOOKUP] Properties file has no entry specifying LogFactory subclass.");
        }
      }
    } else {
      if (isDiagnosticsEnabled()) {
        logDiagnostic("[LOOKUP] No properties file available to determine" + " LogFactory subclass from..");
      }
    }
  }

  // (4)以上情況都不滿足,範例化預設實現類 org.apache.commons.logging.impl.LogFactoryImpl

  if (factory == null) {
    if (isDiagnosticsEnabled()) {
      logDiagnostic(
        "[LOOKUP] Loading the default LogFactory implementation '" + FACTORY_DEFAULT +
        "' via the same classloader that loaded this LogFactory" +
        " class (ie not looking in the context classloader).");
    }

    factory = newFactory(FACTORY_DEFAULT, thisClassLoader, contextClassLoader);
  }

  if (factory != null) {
    /**
     * Always cache using context class loader.
     */
    cacheFactory(contextClassLoader, factory);

    if (props != null) {
      Enumeration names = props.propertyNames();
      while (names.hasMoreElements()) {
        String name = (String) names.nextElement();
        String value = props.getProperty(name);
        factory.setAttribute(name, value);
      }
    }
  }

  return factory;
}

從 getFactory 方法的原始碼可以看出,其核心邏輯分為 4 步:

  • 首先,嘗試查詢全域性屬性org.apache.commons.logging.LogFactory,如果指定了具體類,嘗試建立範例。

  • 利用 Java SPI 機制,嘗試在 classpatch 的 META-INF/services 目錄下尋找org.apache.commons.logging.LogFactory 的實現類。

  • 嘗試從 classpath 目錄下的 commons-logging.properties 檔案中查詢org.apache.commons.logging.LogFactory 屬性,如果指定了具體類,嘗試建立範例。

  • 以上情況如果都不滿足,則範例化預設實現類,即org.apache.commons.logging.impl.LogFactoryImpl。

4.3 SPI 應用案例之 Spring Boot

Spring Boot 是基於 Spring 構建的框架,其設計目的在於簡化 Spring 應用的設定、執行。在 Spring Boot 中,大量運用了自動裝配來儘可能減少設定。

下面是一個 Spring Boot 入口範例,可以看到,程式碼非常簡潔。

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@SpringBootApplication
@RestController
public class DemoApplication {

    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }

    @GetMapping("/hello")
    public String hello(@RequestParam(value = "name", defaultValue = "World") String name) {
        return String.format("Hello %s!", name);
    }
}

那麼,Spring Boot 是如何做到寥寥幾行程式碼,就可以執行一個 Spring Boot 應用的呢。我們不妨帶著疑問,從原始碼入手,一步步探究其原理。

4.3.1 @SpringBootApplication 註解

首先,Spring Boot 應用的啟動類上都會標記一個

@SpringBootApplication 註解。

@SpringBootApplication 註解定義如下:

@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@SpringBootConfiguration
@EnableAutoConfiguration
@ComponentScan(
    excludeFilters = {@Filter(
    type = FilterType.CUSTOM,
    classes = {TypeExcludeFilter.class}
), @Filter(
    type = FilterType.CUSTOM,
    classes = {AutoConfigurationExcludeFilter.class}
)}
)
public @interface SpringBootApplication {
    // 略
}

除了 @Target、 @Retention、@Documented、@Inherited 這幾個元註解, @SpringBootApplication 註解的定義中還標記了 @SpringBootConfiguration、@EnableAutoConfiguration、@ComponentScan 三個註解。

4.3.2 @SpringBootConfiguration 註解

從@SpringBootConfiguration 註解的定義來看,@SpringBootConfiguration 註解本質上就是一個 @Configuration 註解,這意味著被@SpringBootConfiguration 註解修飾的類會被 Spring Boot 識別為一個設定類。

@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Configuration
public @interface SpringBootConfiguration {
    @AliasFor(
        annotation = Configuration.class
    )
    boolean proxyBeanMethods() default true;
}

4.3.3 @EnableAutoConfiguration 註解

@EnableAutoConfiguration 註解定義如下:

@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@AutoConfigurationPackage
@Import({AutoConfigurationImportSelector.class})
public @interface EnableAutoConfiguration {
    String ENABLED_OVERRIDE_PROPERTY = "spring.boot.enableautoconfiguration";

    Class<?>[] exclude() default {};

    String[] excludeName() default {};
}

@EnableAutoConfiguration 註解包含了 @AutoConfigurationPackage與 @Import({AutoConfigurationImportSelector.class}) 兩個註解。

4.3.4 @AutoConfigurationPackage 註解

@AutoConfigurationPackage 會將被修飾的類作為主設定類,該類所在的 package 會被視為根路徑,Spring Boot 預設會自動掃描根路徑下的所有 Spring Bean(被 @Component 以及繼承 @Component 的各個註解所修飾的類)。——這就是為什麼 Spring Boot 的啟動類一般要置於根路徑的原因。這個功能等同於在 Spring xml 設定中通過 context:component-scan 來指定掃描路徑。@Import 註解的作用是向 Spring 容器中直接注入指定元件。@AutoConfigurationPackage 註解中註明了@Import({Registrar.class})。Registrar 類用於儲存 Spring Boot 的入口類、根路徑等資訊。

4.3.5 SpringFactoriesLoader.loadFactoryNames 方法

@Import(AutoConfigurationImportSelector.class) 表示直接注入AutoConfigurationImportSelector。

AutoConfigurationImportSelector 有一個核心方法getCandidateConfigurations 用於獲取候選設定。該方法呼叫了SpringFactoriesLoader.loadFactoryNames 方法,這個方法即為 Spring Boot SPI 的關鍵,它負責載入所有 META-INF/spring.factories 檔案,載入的過程由 SpringFactoriesLoader 負責。

Spring Boot 的 META-INF/spring.factories 檔案本質上就是一個 properties 檔案,資料內容就是一個個鍵值對。

SpringFactoriesLoader.loadFactoryNames 方法的關鍵原始碼:

// spring.factories 檔案的格式為:key=value1,value2,value3
// 遍歷所有 META-INF/spring.factories 檔案
// 解析檔案,獲得 key=factoryClass 的類名稱
public static List<String> loadFactoryNames(Class<?> factoryType, @Nullable ClassLoader classLoader) {
  String factoryTypeName = factoryType.getName();
  return loadSpringFactories(classLoader).getOrDefault(factoryTypeName, Collections.emptyList());
}

private static Map<String, List<String>> loadSpringFactories(@Nullable ClassLoader classLoader) {
  // 嘗試獲取快取,如果快取中有資料,直接返回
  MultiValueMap<String, String> result = cache.get(classLoader);
  if (result != null) {
    return result;
  }

  try {
    // 獲取資原始檔路徑
    Enumeration<URL> urls = (classLoader != null ?
        classLoader.getResources(FACTORIES_RESOURCE_LOCATION) :
        ClassLoader.getSystemResources(FACTORIES_RESOURCE_LOCATION));
    result = new LinkedMultiValueMap<>();
    // 遍歷所有路徑
    while (urls.hasMoreElements()) {
      URL url = urls.nextElement();
      UrlResource resource = new UrlResource(url);
      // 解析檔案,得到對應的一組 Properties
      Properties properties = PropertiesLoaderUtils.loadProperties(resource);
      // 遍歷解析出的 properties,組裝資料
      for (Map.Entry<?, ?> entry : properties.entrySet()) {
        String factoryTypeName = ((String) entry.getKey()).trim();
        for (String factoryImplementationName : StringUtils.commaDelimitedListToStringArray((String) entry.getValue())) {
          result.add(factoryTypeName, factoryImplementationName.trim());
        }
      }
    }
    cache.put(classLoader, result);
    return result;
  }
  catch (IOException ex) {
    throw new IllegalArgumentException("Unable to load factories from location [" +
        FACTORIES_RESOURCE_LOCATION + "]", ex);
  }
}

歸納上面的方法,主要作了這些事:

載入所有 META-INF/spring.factories 檔案,載入過程有 SpringFactoriesLoader 負責。

  • 在 CLASSPATH 中搜尋所有 META-INF/spring.factories 組態檔。

  • 然後,解析 spring.factories 檔案,獲取指定自動裝配類的全限定名。

4.3.6 Spring Boot 的 AutoConfiguration 類

Spring Boot 有各種 starter 包,可以根據實際專案需要,按需取材。在專案開發中,只要將 starter 包引入,我們就可以用很少的設定,甚至什麼都不設定,即可獲取相關的能力。通過前面的 Spring Boot SPI 流程,只完成了自動裝配工作的一半,剩下的工作如何處理呢 ?

以 spring-boot-starter-web 的 jar 包為例,檢視其 maven pom,可以看到,它依賴於 spring-boot-starter,所有 Spring Boot 官方 starter 包都會依賴於這個 jar 包。而 spring-boot-starter 又依賴於 spring-boot-autoconfigure,Spring Boot 的自動裝配祕密,就在於這個 jar 包。

從 spring-boot-autoconfigure 包的結構來看,它有一個 META-INF/spring.factories ,顯然利用了 Spring Boot SPI,來自動裝配其中的設定類。

圖片

下圖是 spring-boot-autoconfigure 的 META-INF/spring.factories 檔案的部分內容,可以看到其中註冊了一長串會被自動載入的 AutoConfiguration 類。

圖片

以 RedisAutoConfiguration 為例,這個設定類中,會根據 @ConditionalXXX 中的條件去決定是否範例化對應的 Bean,範例化 Bean 所依賴的重要引數則通過 RedisProperties 傳入。

圖片

RedisProperties 中維護了 Redis 連線所需要的關鍵屬性,只要在 yml 或 properties 組態檔中,指定 spring.redis 開頭的屬性,都會被自動裝載到 RedisProperties 範例中。

圖片

通過以上分析,已經一步步解讀出 Spring Boot 自動裝載的原理。

五、SPI 應用案例之 Dubbo

Dubbo 並未使用 Java SPI,而是自己封裝了一套新的 SPI 機制。Dubbo SPI 所需的組態檔需放置在 META-INF/dubbo 路徑下,設定內容形式如下:

optimusPrime = org.apache.spi.OptimusPrime
bumblebee = org.apache.spi.Bumblebee

與 Java SPI 實現類設定不同,Dubbo SPI 是通過鍵值對的方式進行設定,這樣可以按需載入指定的實現類。Dubbo SPI 除了支援按需載入介面實現類,還增加了 IOC 和 AOP 等特性。

5.1 ExtensionLoader 入口

Dubbo SPI 的相關邏輯被封裝在了 ExtensionLoader 類中,通過 ExtensionLoader,可以載入指定的實現類。

ExtensionLoader 的 getExtension 方法是其入口方法,其原始碼如下:

public T getExtension(String name) {
    if (name == null || name.length() == 0)
        throw new IllegalArgumentException("Extension name == null");
    if ("true".equals(name)) {
        // 獲取預設的拓展實現類
        return getDefaultExtension();
    }
    // Holder,顧名思義,用於持有目標物件
    Holder<Object> holder = cachedInstances.get(name);
    if (holder == null) {
        cachedInstances.putIfAbsent(name, new Holder<Object>());
        holder = cachedInstances.get(name);
    }
    Object instance = holder.get();
    // 雙重檢查
    if (instance == null) {
        synchronized (holder) {
            instance = holder.get();
            if (instance == null) {
                // 建立拓展範例
                instance = createExtension(name);
                // 設定範例到 holder 中
                holder.set(instance);
            }
        }
    }
    return (T) instance;
}

 可以看出,這個方法的作用就是:首先檢查快取,快取未命中則呼叫 createExtension 方法建立拓展物件。那麼,createExtension 是如何建立拓展物件的呢,其原始碼如下:

private T createExtension(String name) {
    // 從組態檔中載入所有的拓展類,可得到「設定項名稱」到「設定類」的對映關係表
    Class<?> clazz = getExtensionClasses().get(name);
    if (clazz == null) {
        throw findException(name);
    }
    try {
        T instance = (T) EXTENSION_INSTANCES.get(clazz);
        if (instance == null) {
            // 通過反射建立範例
            EXTENSION_INSTANCES.putIfAbsent(clazz, clazz.newInstance());
            instance = (T) EXTENSION_INSTANCES.get(clazz);
        }
        // 向範例中注入依賴
        injectExtension(instance);
        Set<Class<?>> wrapperClasses = cachedWrapperClasses;
        if (wrapperClasses != null && !wrapperClasses.isEmpty()) {
            // 迴圈建立 Wrapper 範例
            for (Class<?> wrapperClass : wrapperClasses) {
                // 將當前 instance 作為引數傳給 Wrapper 的構造方法,並通過反射建立 Wrapper 範例。
                // 然後向 Wrapper 範例中注入依賴,最後將 Wrapper 範例再次賦值給 instance 變數
                instance = injectExtension(
                    (T) wrapperClass.getConstructor(type).newInstance(instance));
            }
        }
        return instance;
    } catch (Throwable t) {
        throw new IllegalStateException("...");
    }
}

 createExtension 方法的的工作步驟可以歸納為:

  1. 通過 getExtensionClasses 獲取所有的拓展類

  2. 通過反射建立拓展物件

  3. 向拓展物件中注入依賴

  4. 將拓展物件包裹在相應的 Wrapper 物件中

以上步驟中,第一個步驟是載入拓展類的關鍵,第三和第四個步驟是 Dubbo IOC 與 AOP 的具體實現。

5.2 獲取所有的拓展類

Dubbo 在通過名稱獲取拓展類之前,首先需要根據組態檔解析出拓展項名稱到拓展類的對映關係表(Map<名稱, 拓展類>),之後再根據拓展項名稱從對映關係表中取出相應的拓展類即可。相關過程的程式碼分析如下:

private Map<String, Class<?>> getExtensionClasses() {
    // 從快取中獲取已載入的拓展類
    Map<String, Class<?>> classes = cachedClasses.get();
    // 雙重檢查
    if (classes == null) {
        synchronized (cachedClasses) {
            classes = cachedClasses.get();
            if (classes == null) {
                // 載入拓展類
                classes = loadExtensionClasses();
                cachedClasses.set(classes);
            }
        }
    }
    return classes;
}

這裡也是先檢查快取,若快取未命中,則通過 synchronized 加鎖。加鎖後再次檢查快取,並判空。此時如果 classes 仍為 null,則通過 loadExtensionClasses 載入拓展類。下面分析 loadExtensionClasses 方法的邏輯。

private Map<String, Class<?>> loadExtensionClasses() {
    // 獲取 SPI 註解,這裡的 type 變數是在呼叫 getExtensionLoader 方法時傳入的
    final SPI defaultAnnotation = type.getAnnotation(SPI.class);
    if (defaultAnnotation != null) {
        String value = defaultAnnotation.value();
        if ((value = value.trim()).length() > 0) {
            // 對 SPI 註解內容進行切分
            String[] names = NAME_SEPARATOR.split(value);
            // 檢測 SPI 註解內容是否合法,不合法則丟擲異常
            if (names.length > 1) {
                throw new IllegalStateException("more than 1 default extension name on extension...");
            }

            // 設定預設名稱,參考 getDefaultExtension 方法
            if (names.length == 1) {
                cachedDefaultName = names[0];
            }
        }
    }

    Map<String, Class<?>> extensionClasses = new HashMap<String, Class<?>>();
    // 載入指定資料夾下的組態檔
    loadDirectory(extensionClasses, DUBBO_INTERNAL_DIRECTORY);
    loadDirectory(extensionClasses, DUBBO_DIRECTORY);
    loadDirectory(extensionClasses, SERVICES_DIRECTORY);
    return extensionClasses;
}

 loadExtensionClasses 方法總共做了兩件事情,一是對 SPI 註解進行解析,二是呼叫 loadDirectory 方法載入指定資料夾組態檔。SPI 註解解析過程比較簡單,無需多說。下面我們來看一下 loadDirectory 做了哪些事情。

private void loadDirectory(Map<String, Class<?>> extensionClasses, String dir) {
    // fileName = 資料夾路徑 + type 全限定名
    String fileName = dir + type.getName();
    try {
        Enumeration<java.net.URL> urls;
        ClassLoader classLoader = findClassLoader();
        // 根據檔名載入所有的同名檔案
        if (classLoader != null) {
            urls = classLoader.getResources(fileName);
        } else {
            urls = ClassLoader.getSystemResources(fileName);
        }
        if (urls != null) {
            while (urls.hasMoreElements()) {
                java.net.URL resourceURL = urls.nextElement();
                // 載入資源
                loadResource(extensionClasses, classLoader, resourceURL);
            }
        }
    } catch (Throwable t) {
        logger.error("...");
    }
}

 loadDirectory 方法先通過 classLoader 獲取所有資源連結,然後再通過 loadResource 方法載入資源。我們繼續跟下去,看一下 loadResource 方法的實現。

private void loadResource(Map<String, Class<?>> extensionClasses,
  ClassLoader classLoader, java.net.URL resourceURL) {
    try {
        BufferedReader reader = new BufferedReader(
            new InputStreamReader(resourceURL.openStream(), "utf-8"));
        try {
            String line;
            // 按行讀取設定內容
            while ((line = reader.readLine()) != null) {
                // 定位 # 字元
                final int ci = line.indexOf('#');
                if (ci >= 0) {
                    // 擷取 # 之前的字串,# 之後的內容為註釋,需要忽略
                    line = line.substring(0, ci);
                }
                line = line.trim();
                if (line.length() > 0) {
                    try {
                        String name = null;
                        int i = line.indexOf('=');
                        if (i > 0) {
                            // 以等於號 = 為界,擷取鍵與值
                            name = line.substring(0, i).trim();
                            line = line.substring(i + 1).trim();
                        }
                        if (line.length() > 0) {
                            // 載入類,並通過 loadClass 方法對類進行快取
                            loadClass(extensionClasses, resourceURL,
                                      Class.forName(line, true, classLoader), name);
                        }
                    } catch (Throwable t) {
                        IllegalStateException e = new IllegalStateException("Failed to load extension class...");
                    }
                }
            }
        } finally {
            reader.close();
        }
    } catch (Throwable t) {
        logger.error("Exception when load extension class...");
    }
}

 loadResource 方法用於讀取和解析組態檔,並通過反射載入類,最後呼叫 loadClass 方法進行其他操作。loadClass 方法用於主要用於操作快取,該方法的邏輯如下:

private void loadClass(Map<String, Class<?>> extensionClasses, java.net.URL resourceURL,
    Class<?> clazz, String name) throws NoSuchMethodException {

    if (!type.isAssignableFrom(clazz)) {
        throw new IllegalStateException("...");
    }

    // 檢測目標類上是否有 Adaptive 註解
    if (clazz.isAnnotationPresent(Adaptive.class)) {
        if (cachedAdaptiveClass == null) {
            // 設定 cachedAdaptiveClass快取
            cachedAdaptiveClass = clazz;
        } else if (!cachedAdaptiveClass.equals(clazz)) {
            throw new IllegalStateException("...");
        }

    // 檢測 clazz 是否是 Wrapper 型別
    } else if (isWrapperClass(clazz)) {
        Set<Class<?>> wrappers = cachedWrapperClasses;
        if (wrappers == null) {
            cachedWrapperClasses = new ConcurrentHashSet<Class<?>>();
            wrappers = cachedWrapperClasses;
        }
        // 儲存 clazz 到 cachedWrapperClasses 快取中
        wrappers.add(clazz);

    // 程式進入此分支,表明 clazz 是一個普通的拓展類
    } else {
        // 檢測 clazz 是否有預設的構造方法,如果沒有,則丟擲異常
        clazz.getConstructor();
        if (name == null || name.length() == 0) {
            // 如果 name 為空,則嘗試從 Extension 註解中獲取 name,或使用小寫的類名作為 name
            name = findAnnotationName(clazz);
            if (name.length() == 0) {
                throw new IllegalStateException("...");
            }
        }
        // 切分 name
        String[] names = NAME_SEPARATOR.split(name);
        if (names != null && names.length > 0) {
            Activate activate = clazz.getAnnotation(Activate.class);
            if (activate != null) {
                // 如果類上有 Activate 註解,則使用 names 陣列的第一個元素作為鍵,
                // 儲存 name 到 Activate 註解物件的對映關係
                cachedActivates.put(names[0], activate);
            }
            for (String n : names) {
                if (!cachedNames.containsKey(clazz)) {
                    // 儲存 Class 到名稱的對映關係
                    cachedNames.put(clazz, n);
                }
                Class<?> c = extensionClasses.get(n);
                if (c == null) {
                    // 儲存名稱到 Class 的對映關係
                    extensionClasses.put(n, clazz);
                } else if (c != clazz) {
                    throw new IllegalStateException("...");
                }
            }
        }
    }
}

 如上,loadClass 方法操作了不同的快取,比如 cachedAdaptiveClass、cachedWrapperClasses 和 cachedNames 等等。除此之外,該方法沒有其他什麼邏輯了。

參考資料