什麼是 SPI
1. 背景
在面向對象的設計原則中,一般推薦子產品之間基于接口程式設計,通常情況下調用方子產品是不會感覺到被調用方子產品的内部具體實作。一旦代碼裡面涉及具體實作類,就違反了開閉原則。如果需要替換一種實作,就需要修改代碼。
為了實作在子產品裝配的時候不用在程式裡面動态指明,這就需要一種服務發現機制。Java SPI 就是提供了這樣一個機制:為某個接口尋找服務實作的機制。這有點類似 IOC 的思想,将裝配的控制權移交到了程式之外。
SPI 英文為 Service Provider Interface 字面意思就是:“服務提供者的接口”,我的了解是:專門提供給服務提供者或者擴充架構功能的開發者去使用的一個接口。
SPI 将服務接口和具體的服務實作分離開來,将服務調用方和服務實作者解耦,能夠提升程式的擴充性、可維護性。修改或者替換服務實作并不需要修改調用方。
2. 使用場景
很多架構都使用了 Java 的 SPI 機制,比如:資料庫加載驅動,日志接口,以及 dubbo 的擴充實作等等。
3. SPI 和 API 有啥差別
說到 SPI 就不得不說一下 API 了,從廣義上來說它們都屬于接口,而且很容易混淆。下面先用一張圖說明一下:
一般子產品之間都是通過通過接口進行通訊,那我們在服務調用方和服務實作方(也稱服務提供者)之間引入一個“接口”。
當實作方提供了接口和實作,我們可以通過調用實作方的接口進而擁有實作方給我們提供的能力,這就是 API ,這種接口和實作都是放在實作方的。
當接口存在于調用方這邊時,就是 SPI ,由接口調用方确定接口規則,然後由不同的廠商去根據這個規則對這個接口進行實作,進而提供服務,舉個通俗易懂的例子:公司 H 是一家科技公司,新設計了一款晶片,然後現在需要量産了,而市面上有好幾家晶片制造業公司,這個時候,隻要 H 公司指定好了這晶片生産的标準(定義好了接口标準),那麼這些合作的晶片公司(服務提供者)就按照标準傳遞自家特色的晶片(提供不同方案的實作,但是給出來的結果是一樣的)。
實戰示範
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 服務提供者
debug 中沒有發現 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
Logback debug 的輸出: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 機制也存在一些缺點,比如:
- 周遊加載所有的實作類,這樣效率還是相對較低的;
- 當多個 ServiceLoader 同時 load 時,會有并發問題。
寫在最後
Freemen App是一款專注于IT程式員求職招聘的一個求職平台,旨在幫助IT技術工作者能更好更快入職及努力協調IT技術者工作和生活的關系,讓工作更自由!
本文轉載自江璇Up