Java SPI機制從原理到實戰

語言: CN / TW / HK

1. 什麼是 SPI

1. 背景

在面向對象的設計原則中,一般推薦模塊之間基於接口編程,通常情況下調用方模塊是不會感知到被調用方模塊的內部具體實現。一旦代碼裏面涉及具體實現類,就違反了開閉原則。如果需要替換一種實現,就需要修改代碼。

為了實現在模塊裝配的時候不用在程序裏面動態指明,這就需要一種服務發現機制。Java SPI 就是提供了這樣一個機制:為某個接口尋找服務實現的機制。這有點類似 IOC 的思想,將裝配的控制權移交到了程序之外。

SPI 英文為 Service Provider Interface 字面意思就是:“服務提供者的接口”,我的理解是:專門提供給服務提供者或者擴展框架功能的開發者去使用的一個接口。

SPI 將服務接口和具體的服務實現分離開來,將服務調用方和服務實現者解耦,能夠提升程序的擴展性、可維護性。修改或者替換服務實現並不需要修改調用方。

2. 使用場景

很多框架都使用了 Java 的 SPI 機制,比如:數據庫加載驅動,日誌接口,以及 dubbo 的擴展實現等等。

3. SPI 和 API 有啥區別

説到 SPI 就不得不説一下 API 了,從廣義上來説它們都屬於接口,而且很容易混淆。下面先用一張圖説明一下:

一般模塊之間都是通過通過接口進行通訊,那我們在服務調用方和服務實現方(也稱服務提供者)之間引入一個“接口”。

當實現方提供了接口和實現,我們可以通過調用實現方的接口從而擁有實現方給我們提供的能力,這就是 API ,這種接口和實現都是放在實現方的。

當接口存在於調用方這邊時,就是 SPI ,由接口調用方確定接口規則,然後由不同的廠商去根絕這個規則對這個接口進行實現,從而提供服務,舉個通俗易懂的例子:公司 H 是一家科技公司,新設計了一款芯片,然後現在需要量產了,而市面上有好幾家芯片製造業公司,這個時候,只要 H 公司指定好了這芯片生產的標準(定義好了接口標準),那麼這些合作的芯片公司(服務提供者)就按照標準交付自家特色的芯片(提供不同方案的實現,但是給出來的結果是一樣的)。

2. 實戰演示

Spring框架提供的日誌服務 SLF4J 其實只是一個日誌門面(接口),但是 SLF4J 的具體實現可以有幾種,比如:Logback、Log4j、Log4j2 等等,而且還可以切換,在切換日誌具體實現的時候我們是不需要更改項目代碼的,只需要在 Maven 依賴裏面修改一些 pom 依賴就好了。

這就是依賴 SPI 機制實現的,那我們接下來就實現一個簡易版本的日誌框架。

1. Service Provider Interface

新建一個 Java 項目 service-provider-interface 目錄結構如下:

├─.idea
└─src
    ├─META-INF
    └─org
        └─spi 
            └─service
                ├─Logger.java
                ├─LoggerService.java
                ├─Main.java
                └─MyServicesLoader.java

新建 Logger 接口,這個就是 SPI , 服務提供者接口,後面的服務提供者就要針對這個接口進行實現。

package org.spi.service;

public interface Logger {
    void info(String msg);

    void debug(String msg);
}

接下來就是 LoggerService 類,這個主要是為服務使用者(調用方)提供特定功能的。如果存在疑惑的話可以先往後面繼續看。

package org.spi.service;

import java.util.ArrayList;
import java.util.List;
import java.util.ServiceLoader;

public class LoggerService {
    private static final LoggerService SERVICE = new LoggerService();

    private final Logger logger;

    private final List<Logger> loggerList;

    private LoggerService() {
        ServiceLoader<Logger> loader = ServiceLoader.load(Logger.class);
        List<Logger> list = new ArrayList<>();
        for (Logger log : loader) {
            list.add(log);
        }
        // LoggerList 是所有 ServiceProvider
        loggerList = list;
        if (!list.isEmpty()) {
            // Logger 只取一個
            logger = list.get(0);
        } else {
            logger = null;
        }
    }

    public static LoggerService getService() {
        return SERVICE;
    }

    public void info(String msg) {
        if (logger == null) {
            System.out.println("info 中沒有發現 Logger 服務提供者");
        } else {
            logger.info(msg);
        }
    }

    public void debug(String msg) {
        if (loggerList.isEmpty()) {
            System.out.println("debug 中沒有發現 Logger 服務提供者");
        }
        loggerList.forEach(log -> log.debug(msg));
    }
}

新建 Main 類(服務使用者,調用方),啟動程序查看結果。

package org.spi.service;

public class Main {
    public static void main(String[] args) {
        LoggerService service = LoggerService.getService();

        service.info("Hello SPI");
        service.debug("Hello SPI");
    }
}

程序結果:

info 中沒有發現 Logger 服務提供者

將整個程序直接打包成 jar 包,可以直接通過 IDEA 將項目打包成一個 jar 包。

2. Service Provider

接下來新建一個項目用來實現 Logger 接口

新建項目 service-provider 目錄結構如下:

├─.idea
├─lib
│   └─service-provider-interface.jar
└─src
    ├─META-INF
    │   └─services
    │       └─org.spi.service.Logger
    └─org
        └─spi
            └─provider
                 └─Logback.java

新建 Logback 類

package org.spi.provider;

import org.spi.service.Logger;

public class Logback implements Logger {

    @Override
    public void info(String msg) {
        System.out.println("Logback info 的輸出:" + msg);
    }

    @Override
    public void debug(String msg) {
        System.out.println("Logback debug 的輸出:" + msg);
    }
}

service-provider-interface 的 jar 導入項目中。

新建 lib 目錄,然後將 jar 包拷貝過來,再添加到項目中。

再點擊 OK 。

接下來就可以在項目中導入 jar 包裏面的一些類和方法了,就像 JDK 工具類導包一樣的。

實現 Logger 接口,在 src 目錄下新建 META-INF/services 文件夾,然後新建文件 org.spi.service.Logger (SPI 的全類名),文件裏面的內容是: org.spi.provider.Logback (Logback 的全類名,即 SPI 的實現類的包名 + 類名)。

這是 JDK SPI 機制 ServiceLoader 約定好的標準

接下來同樣將 service-provider 項目打包成 jar 包,這個 jar 包就是服務提供方的實現。通常我們導入 maven 的 pom 依賴就有點類似這種,只不過我們現在沒有將這個 jar 包發佈到 maven 公共倉庫中,所以在需要使用的地方只能手動的添加到項目中。

3. 效果展示

接下來再回到 service-provider-interface 項目。

導入 service-provider jar 包,重新運行 Main 方法。

運行結果如下:

Logback info 的輸出:Hello SPI

説明導入 jar 包中的實現類生效了。

通過使用 SPI 機制,可以看出 服務(LoggerService)和 服務提供者兩者之間的耦合度非常低,如果需要替換一種實現(將 Logback 換成另外一種實現),只需要換一個 jar 包即可。這不就是 SLF4J 原理嗎?

如果某一天需求變更了,此時需要將日誌輸出到消息隊列,或者做一些別的操作,這個時候完全不需要更改 Logback 的實現,只需要新增一個 服務實現(service-provider)可以通過在本項目裏面新增實現也可以從外部引入新的服務實現 jar 包。我們可以在服務(LoggerService)中選擇一個具體的 服務實現 (service-provider) 來完成我們需要的操作。

loggerList.forEach(log -> log.debug(msg));

或者

loggerList.get(1).debug(msg);

loggerList.get(2).debug(msg);

這裏需要先理解一點:ServiceLoader 在加載具體的 服務實現 的時候會去掃描所有包下 src 目錄的 META-INF/services 的內容,然後通過反射去生成對應的對象,保存在一個 list 列表裏面,所以可以通過迭代或者遍歷的方式得到你需要的那個 服務實現。

3. ServiceLoader

想要使用 Java 的 SPI 機制是需要依賴 ServiceLoader 來實現的,那麼我們接下來看看 ServiceLoader 具體是怎麼做的:

ServiceLoader 是 JDK 提供的一個工具類, 位於 package java.util; 包下。

A facility to load implementations of a service.

這是 JDK 官方給的註釋:一種加載服務實現的工具。

再往下看,我們發現這個類是一個 final 類型的,所以是不可被繼承修改,同時它實現了 Iterable 接口。之所以實現了迭代器,是為了方便後續我們能夠通過迭代的方式得到對應的服務實現。

public final class ServiceLoader<S> implements Iterable<S>{ xxx...}

可以看到一個熟悉的常量定義:

private static final String PREFIX = "META-INF/services/";

下面是 load 方法:可以發現 load 方法支持兩種重載後的入參;

public static <S> ServiceLoader<S> load(Class<S> service) {
    ClassLoader cl = Thread.currentThread().getContextClassLoader();
    return ServiceLoader.load(service, cl);
}

public static <S> ServiceLoader<S> load(Class<S> service,
                                        ClassLoader loader) {
    return 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();
    lookupIterator = new LazyIterator(service, loader);
}

根據代碼的調用順序,在 reload() 方法中是通過一個內部類 LazyIterator 實現的。先繼續往下面看。

ServiceLoader 實現了 Iterable 接口的方法後,具有了迭代的能力,在這個 iterator 方法被調用時,首先會在 ServiceLoader 的 Provider 緩存中進行查找,如果緩存中沒有命中那麼則在 LazyIterator 中進行查找。

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(); // 調用 LazyIterator 
        }

        public S next() {
            if (knownProviders.hasNext())
                return knownProviders.next().getValue();
            return lookupIterator.next(); // 調用 LazyIterator 
        }

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

    };
}

在調用 LazyIterator 時,具體實現如下:

public boolean hasNext() {
    if (acc == null) {
        return hasNextService();
    } else {
        PrivilegedAction<Boolean> action = new PrivilegedAction<Boolean>() {
            public Boolean run() {
                return hasNextService();
            }
        };
        return AccessController.doPrivileged(action, acc);
    }
}

private boolean hasNextService() {
    if (nextName != null) {
        return true;
    }
    if (configs == null) {
        try {
            //通過PREFIX(META-INF/services/)和類名 獲取對應的配置文件,得到具體的實現類
            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;
}


public S next() {
    if (acc == null) {
        return nextService();
    } else {
        PrivilegedAction<S> action = new PrivilegedAction<S>() {
            public S run() {
                return nextService();
            }
        };
        return AccessController.doPrivileged(action, acc);
    }
}

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

4. 總結

其實不難發現,SPI 機制的具體實現本質上還是通過反射完成的。即:我們按照規定將要暴露對外使用的具體實現類在 META-INF/services/ 文件下聲明。

其實 SPI 機制在很多框架中都有應用:Spring 框架的基本原理也是類似的反射。還有 dubbo 框架提供同樣的 SPI 擴展機制。

通過 SPI 機制能夠大大地提高接口設計的靈活性,但是 SPI 機制也存在一些缺點,比如:

  1. 遍歷加載所有的實現類,這樣效率還是相對較低的;
  2. 當多個 ServiceLoader 同時 load 時,會有併發問題。