SPI 概念

面向对象设计鼓励模块间基于接口而非具体实现编程,以降低模块间的耦合,遵循依赖倒置原则,并支持开闭原则(对扩展开放,对修改封闭)。

SPI(Service Provider Interface)服务提供了一种服务发现机制,允许在程序外部动态指定具体实现。这与控制反转(IoC)的思想相似,将组件装配的控制权移交给了程序之外。SPI 主要用于实现模块化开发和插件化扩展。

SPI 将服务接口和具体的服务实现分离开来,将服务调用方和服务实现者解耦,能够提升程序的扩展性、可维护性。修改或者替换服务实现并不需要修改调用方。

设计思想:

  • 面向接口
  • 配置文件
  • 反射技术
  • 策略模式

本文源代码详见文末。

SPI 和 API 的区别

广义上来说它们都是接口。

image.png

一般模块之间都是通过接口进行通讯,因此我们在服务调用方和服务实现方(也称服务提供者)之间引入一个“接口”。

  • 当实现方提供了接口和实现,我们可以通过调用实现方的接口从而拥有实现方给我们提供的能力,这就是 API。这种情况下,接口和实现都是放在实现方的包中。调用方通过接口调用实现方的功能,而不需要关心具体的实现细节。
  • 当接口存在于调用方这边时,这就是 SPI 。由接口调用方确定接口规则,然后由不同的厂商根据这个规则对这个接口进行实现,从而提供服务。
例子感受 SPI

我们使用的主流 Java 开发框架中,几乎都使用到了 SPI 机制,比如 Servlet 容器、日志框架、ORM 框架、Spring 框架、Dubbo 的扩展实现。

例子 1:公司 H 是一家科技公司,新设计了一款芯片,然后现在需要量产了,而市面上有好几家芯片制造业公司,这个时候,只要 H 公司指定好了这芯片生产的标准(定义好了接口标准),那么这些合作的芯片公司(服务提供者)就按照标准交付自家特色的芯片(提供不同方案的实现,但是给出来的结果是一样的)。

例子 2:SLF4J (Simple Logging Facade for Java)是 Java 的一个日志门面(接口),其具体实现有几种,比如:Logback、Log4j、Log4j2 等等,而且还可以切换,在切换日志具体实现的时候我们是不需要更改项目代码的,只需要在 Maven 依赖里面修改一些 pom 依赖就好了。
image.png

例子 3:一个典型的 SPI 应用场景是 JDBC(Java 数据库连接库),不同的数据库驱动程序开发者可以使用 JDBC 库,然后定制自己的数据库驱动程序。image.png

Java SPI 机制

一个标准的 SPI,由 3 个组件构成,分别是:

  • 1️⃣Service:一个公开的接口或抽象类,定义了一个抽象的功能模块。
  • 2️⃣Service Provider:是 Service 接口的实现类
  • 3️⃣ServiceLoader:SPI 机制中的核心组件(对应 JDK 中的 ServiceLoader 类),负责在运行时发现并加载 Service Provider

image.png

Java SPI 的规范要素:

  1. 规范的配置文件:
    • 文件路径:必须在 JAR 包中的 META-INF/services 目录下
    • 文件名称:Service 接口的全限定名
    • 文件内容:Service 实现类(即 Service Provider 类)的全限定名。如果有多个实现类,那么每一个实现类在文件中单独占据一行
  2. Service 接口的实现类 Service Provider 类,必须具备无参的默认构造方法。因为随后通过反射技术实例化它时是不带参数的。
  3. 保证能加载到配置文件和 Service Provider 类。
    • 方式一:将 Service Provider 的 JAR 包放到 classpath 中(最常用)
    • 方式二:将 JAR 包安装到 JRE 的扩展目录中
    • 方式三:自定义一个 ClassLoader

手写 SPI 应用示例

本示例的背景介绍:有一家公司 A,它需要连接互联网。它定义了一个连接网络的 API,由中国移动和中国联通来提供网络服务。

这个场景涉及到了以下角色:

  1. 消费者:公司 A。提供使用以下模块:
    • simple-company
    • simple-api
  2. 服务提供者:中国移动、中国联通
    • simple-isp-mobile(中国移动提供的 jar 包)
    • simple-isp-unicom(中国联通提供的 jar 包)

image.png

使用 Java 的 ServiceLoader

image.png

公司 A 在 simple-api 提供的服务接口(这就是 SPI 中 1️ Service 接口部分 1️⃣):

1
2
3
4
5
6
7
8
package top.uuanqin;

/**
* @author uuanqin
*/
public interface InternetService {
void connectInternet();
}

服务提供者中国移动在 simple-isp-mobilepom.xml 中导入了 simple-api,并为其编写了实现类(北京移动 BeijingMobile 和苏州移动 SuzhouMobile 的联网服务,这些就是 SPI 中的 Service Provider 部分 2️⃣):

1
2
3
4
5
6
7
<dependencies>
<dependency>
<groupId>top.uuanqin</groupId>
<artifactId>simple-api</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
</dependencies>
1
2
3
4
5
6
7
8
9
10
11
12
13
package cn.mobile;

import top.uuanqin.InternetService;

/**
* @author uuanqin
*/
public class BeijingMobile implements InternetService {
@Override
public void connectInternet() {
System.out.println("【中国移动(北京移动)】欢迎使用中国移动联网服务!");
}
}

中国移动在配置目录中放置 SPI 配置文件,文件名是 Service 接口的全限定名,内容是 Service Provider 类的全限定名。多行表示多个 Service Provider。

1
2
cn.mobile.BeijingMobile  
cn.mobile.SuzhouMobile

中国移动 simple-isp-mobile 目录结构如下:

image.png

同样的,服务提供者中国联通 simple-isp-unicom 也编写了实现类,假设它只编写了一个实现类 ChinaUnicom

image.png

公司 A 在实际的应用项目 simple-company 中使用了上网服务(包含 SPI 的 ServiceLoader 部分 3️⃣):

1
2
3
4
5
6
7
8
9
10
11
12
package top.uuanqin;

import java.util.ServiceLoader;

public class Main {
public static void main(String[] args) {
ServiceLoader<InternetService> loader = ServiceLoader.load(InternetService.class);
for(InternetService provider: loader){
provider.connectInternet();
}
}
}

pom.xml 中除了引用自己公司定义的 api 外,我想要使用哪个公司的上网服务,就导入哪个公司的 jar 包。

1
2
3
4
5
6
7
8
9
10
11
12
<dependencies>
<dependency>
<groupId>top.uuanqin</groupId>
<artifactId>simple-api</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>cn.mobile</groupId>
<artifactId>simple-isp-mobile</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
</dependencies>

运行 Main 方法结果如下:

1
2
【中国移动(北京移动)】欢迎使用中国移动联网服务!
【中国移动(苏州移动)】欢迎使用中国移动联网服务!

如果 pom.xml 导入的是中国联通的 jar 包,那么 Main 方法返回:

1
【中国联通】欢迎使用中国联通的无线上网服务!
如果没打印成功,检查一下 META-INF.services 目录是一个文件夹还是两级文件夹!

在 IDEA 中选择这个取消压缩中间文件夹名:
image.png

尝试写一个简单的 ServiceLoader

在阅读 Java ServiceLoader 源码之前,可以先看一下下面的代码进行理解。下面代码是一个简化了的 ServiceLoader。

代码引入工具包以减少编写的代码量:

1
2
3
4
5
6
<!-- https://doc.hutool.cn/ -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.16</version>
</dependency>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
package top.uuanqin;

import cn.hutool.core.io.resource.ResourceUtil;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.net.URL;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;

public class MyServiceLoader<S> implements Iterable<S> {

// 对应的接口 Class 模板
private final Class<S> service;

// 对应实现类的 可以有多个,用 List 进行封装
private final List<S> providers = new ArrayList<>();

// 类加载器
private final ClassLoader classLoader;

// 暴露给外部使用的方法,通过调用这个方法可以开始加载自己定制的实现流程。
public static <S> MyServiceLoader<S> load(Class<S> service) {
return new MyServiceLoader<>(service);
}

// 构造方法私有化
private MyServiceLoader(Class<S> service) {
this.service = service;
this.classLoader = Thread.currentThread().getContextClassLoader();
doLoad();
}

// 关键方法,加载具体实现类的逻辑
private void doLoad() {
List<URL> resources = null;
// 读取所有 jar 包里面 META-INF/services 包下面的文件,这个文件名就是接口名,然后文件里面的内容就是具体的实现类的路径加全类名
resources = ResourceUtil.getResources("META-INF/services/" + service.getName());
for (URL resource : resources) {
try {
InputStreamReader inputStreamReader = new InputStreamReader(resource.openStream());
BufferedReader bufferedReader = new BufferedReader(inputStreamReader);
String line;
while ((line = bufferedReader.readLine()) != null) {
// 通过反射拿到实现类的实例
Class<?> clazz = Class.forName(line, false, classLoader);
// 如果声明的接口跟这个具体的实现类是属于同一类型,(可以理解为Java的一种多态,接口跟实现类、父类和子类等等这种关系。)则构造实例
if (service.isAssignableFrom(clazz)) {
S instance = (S) clazz.newInstance();
// 把当前构造的实例对象添加到 Provider的列表里面
providers.add(instance);
}
}
} catch (Exception e) {
System.out.println("类加载异常");
}
}
}

// 返回spi接口对应的具体实现类列表
public List<S> getProviders() {
return providers;
}

// ************** 迭代器的实现 ********************** //
@Override
public Iterator<S> iterator() {
return providers.iterator();
}
}

把上例中 Main 函数的 ServiceLoader 换成 MyServiceLoader 将得到同样的结果。

更定制化的 ServiceLoader 实现以及相关应用场景详见这篇文章:SPI 概念及案例实践(下)

【拓展】Java SPI 与 SpringBoot 的自动配置

SpringBoot 自动配置,即大名鼎鼎的 Auto-Configuration 是指基于你引入的依赖 Jar 包,对 SpringBoot 应用进行自动配置。提供了自动配置功能的依赖 Jar 包通常被称为 starter,例如 mybatis-spring-boot-starter 等等。

image.png

SpringBoot 与 jdk 在 SPI 机制上,存在些许的差别,但本质上还是事先定义一套规范,来完成对实现类或者组件的动态发现。

  • 在获取实现类名称集合的层面上,SpringBoot 借助于 SpringFactoriesLoader 加载 spring.factories 配置文件,而 jdk 借助于 ServiceLoader 读取指定路径。
  • 在是否实例化实现类的层面上,SpringBoot 会依据 @Conditional 注解来判断是否进行实例化并注入进容器中,而 jdk 会在 next() 方法内部懒加载实现类。

更多参考详见本站文章:SpringBoot 的原理以及写一个自定义 Starter

源代码

Readme Card

GitHub 仓库地址:uuanqin/SPI-simple-example (github.com)

本文参考