Java中的魔法: SPI

Java中的魔法: SPI

SPI 全称为 (Service Provider Interface) ,是JDK内置的一种服务提供发现机制,常用于创建可扩展、可替换组件的应用程序,是java中模块化插件化的关键。

SPI 框架包含3个基本组件:

  1. 服务接口 Service Interface
  2. 服务接口的实现类,Service Provider
  3. 服务加载器 Service Loader

Service Interface是一组定义标准的接口和类。Service Provider是服务接口的特定实现,需要实现接口,并子类化服务本身中定义的类。java.util包下的ServiceLoader类就是SPI机制的核心类,主要功能是通过相关的类加载器扫描并加载provider的jar包,并且通过反射实例化服务的实现类。服务Provider程序可以以扩展的形式安装在Java平台的实现中,也就是说,可以将provider的jar文件放置在任何常用扩展目录中,也可以通过将其jar包添加到应用程序的类路径或通过其他一些特定于平台的方式来使使用方来调用。

ServiceLoader机制允许用户在其应用程序代码保持不变的前提下扩展程序功能或者添加新功能。例如SLF4J本身只是API,日常编程中我们只需要使用LogFactory获得log实例,而不用关心底层是的日志实现框架是Logback还是Log4J;java.sql.Driver是在java中定义的标准SQL服务API,如果需要从oracal数据库切换到mysql数据库,我们的数据访问层代码不需要任何修改,只需要替换掉jdbc 驱动包即可。

JDK中包含了非常多的SPI服务功能(如servlet、邮件服务、音频服务、SQL驱动,大部分位于javax),以供不同的服务厂商或者插件商基于标准定义实现自己的方案。除了能够服务于厂商或插件商,JavaSPI 也为我们实现框架扩展提供了一个不错思路。当一个功能可能会有两种以上的实现方案时,可以在应用程序中预留出 SPI 接口,剩下的适配工作便留给了开发者,这样可以在不侵入代码的前提下,通过增删依赖来扩展框架

解密SPI

目标:实现消息推送功能,要求后期能够切换不同推送厂商的推送服务(例如极光推送、小米推送、百度推送等等)

后期需要切换服务提供商,我们可以通过SPI机制来保证程序的可扩展 。我们新建一个spi-demo工程,用于演示用SPI机制来达到该目标的具体过程。工程中其中包含4个module(为了方便演示和运行,放在一个工程中,正常应该是一个调用工程、一个接口定义工程、两个服务Provider工程),该demo工程源码:https://github.com/ifengkou/spi-demo

1
2
3
4
5
6
7
$ tree -L 1
.
├── invoker #测试工程,服务接口的调用程序
├── pom.xml
├── push_interface #推送服务接口
├── push_jiguang_provider #实现方式:极光推送
├── push_mi_provider #实现方式:小米推送

其中

push_interface工程: 定义推送的接口,约定需要传入的参数(手机号码,推送的消息),输出的为推送成功的条数

push_jiguang_provider/push_mi_provider工程: 根据极光推送的sdk或小米推送的sdk,实现了push_interface的push接口

invoker工程: 用来测试的工程,主要是通过spi机制调用

三者之间的关系如图:

在demo 工程中,为了演示方便,测试的模块都是module,实际上是可以拆分为独立的工程

1.Interface定义服务:

push_interface/Pusher.java
1
2
3
4
5
6
7
8
9
10
public interface Pusher {

/**
* 向 mobiles 推送 msg 消息
* @param mobiles
* @param msg
* @return
*/
int push(String[] mobiles, String msg);
}

Pusher接口定义了一个push方法,主要约定输入参数和返回结果。

2. Provider 实现服接口

push_jiguang_provider/push_mi_provider工程,需要依赖push_interface工程,是push接口服务的特定实现

push_mi_provider/XiaomiPusher.java
1
2
3
4
5
6
7
8
9
package cn.ifengkou.spi.provider;
import cn.ifengkou.spi.Pusher;
public class XiaoMiPusher implements Pusher {
public int push(String[] mobiles, String msg) {
System.out.println("小米推送,推送消息:"+msg);
//TODO 实现小米推送的逻辑
return mobiles.size();
}
}

provider 工程需要引入pusher 工程依赖

push_mi_provider/pom.xml
1
2
3
4
5
6
7
<dependencies>
<dependency>
<groupId>cn.ifengkou</groupId>
<artifactId>push_interface</artifactId>
<version>1.0</version>
</dependency>
</dependencies>

push_jiguang_provider类似。

3. Provider 注册

要实现spi的机制,光实现接口类还不够,还需要按照spi的规范写一个配置文件以便JVM在扫描依赖包时发现和加载该provider

1.在main 目录下创建目录 “resources/META-INF/services”

1
mkdir -p resources/META-INF/services

2.再在该目录下创建以接口全限定名(包名+类名)的配置文件,写入内容为Provider类(XiaoMiPusher)的全限定名。

Pusher的全限定名为 cn.ifengkou.spi.Pusher

XiaoMiPusher的全限定名为 cn.ifengkou.spi.provider.XiaoMiPusher

1
echo "cn.ifengkou.spi.provider.XiaoMiPusher" > resources/META-INF/services/cn.ifengkou.spi.Pusher

如果要注册多个实现类,则按行分割放到该配置文件

完成后的工程结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# src/main 目录下
> tree
.
├── java
│   └── cn
│   └── ifengkou
│   └── spi
│   └── provider
│   └── XiaoMiPusher.java
└── resources
└── META-INF
└── services
└── cn.ifengkou.spi.Pusher
> cat resources/META-INF/services/cn.ifengkou.spi.Pusher
cn.ifengkou.spi.provider.XiaoMiPusher

push_jiguang_provider工程也同样操作,这样我们就得到了Pusher 的两个实现

4. 模拟测试

Invoker 工程是我们的测试工程,主要是如何加载

Invoker/Main.java
1
2
3
4
5
6
7
8
9
10
11
//调用主类
public class MainApp {
public static void main(String[] args) {
String[] mobiles = new String[]{"1890000000"};
String msg = "你好 Spi";
ServiceLoader<Pusher> printerLoader = ServiceLoader.load(Pusher.class);
for (Pusher pusher : printerLoader) {
pusher.push(mobiles, msg);
}
}
}

ServiceLoader 是 java.util 提供的用于加载固定类路径下文件的一个加载器,正是它加载了对应接口声明的实现类。

测试工程需要依赖 Interface 工程。

Invoker/pom.xml
1
2
3
4
5
6
7
<dependencies>
<dependency>
<groupId>cn.ifengkou</groupId>
<artifactId>push_interface</artifactId>
<version>1.0</version>
</dependency>
</dependencies>

这个时候Invoker工程没有引入任何pusher的实现类,所以执行是无任何打印。这时候只需要将Provider的jar包放到该工程的classpath路径下。

为了测试,我们将依赖直接加到pom文件中

Invoker/pom.xml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<dependencies>
<dependency>
<groupId>cn.ifengkou</groupId>
<artifactId>push_interface</artifactId>
<version>1.0</version>
</dependency>
<dependency>
<groupId>cn.ifengkou</groupId>
<artifactId>push_jiguang_provider</artifactId>
<version>1.0</version>
</dependency>
<!--<dependency>
<groupId>cn.ifengkou</groupId>
<artifactId>push_mi_provider</artifactId>
<version>1.0</version>
</dependency>-->
</dependencies>

引入push_jiguang_provider,执行

1
极光推送,推送消息:你好 Spi

引入push_mi_provider,执行

1
小米推送,推送消息:你好 Spi

虽然我们没有显式使用Pusher的实现类XiaoMiPusher/JiGuangPusher,但是ServiceLoader帮我们自动加载了实现类。在实现类切换时,程序并不需要做改动,只需要将实现类放置到指定位置,java spi机制就会自动加载实现类执行具体的实现方法

真实场景模拟

上面为了方便快速测试,所以将provider 作为依赖写到了pom 文件中。在真实场景中,只需要将provider 的包,放在程序运行环境下,SPI机制就能够自动生效。我个人测试有三种方式可以实现:

  1. 启动命令中附带-Djava.exts.dir 配置
  2. 启动命令中附带-Xbootclasspath/a配置
  3. 拷贝到${JAVA_HOME}/jre/lib/ext/目录
1
2
3
4
5
6
7
8
9
10
11
12
13
14
#在工程目录中执行

# 方式一: copy 到jre/lib/ext/目录
sudo cp push_jiguang_provider/target/push_jiguang_provider-1.0-jar-with-dependencies.jar ${JAVA_HOME}/jre/lib/ext/

java -jar invoker/target/invoker-1.0-jar-with-dependencies.jar

# 方式二:改变默认的扩展目录,ext.dirs
java -Djava.ext.dirs=./push_jiguang_provider/target/ -jar invoker/target/invoker-1.0-jar-with-dependencies.jar

java -Djava.ext.dirs=./push_mi_provider/target/ -jar invoker/target/invoker-1.0-jar-with-dependencies.jar

# 方式三:Xbootclasspath
java -Xbootclasspath/a:./push_jiguang_provider/target/push_jiguang_provider-1.0-jar-with-dependencies.jar -jar invoker/target/invoker-1.0-jar-with-dependencies.jar

备注:#provider工程打包需要把依赖打到jar包中去,否则会报异常,NoClassDefFoundError: cn/ifengkou/spi/Pusher;可以用maven-assembly-plugin,具体见源码

5 不支持动态加载Provider程序

虽然ServiceLoader有reload 方法,但是在实际使用中reload 并不是指动态加载Provider程序。默认的类加载器不是动态的,classpath在jvm启动时就已经确定了,因此不会更新。如果需要实现该行为,则需要使用自定义类加载器,例如JavaEE服务器部署WebAPP程序或者OSGI的类加载器

https://stackoverflow.com/questions/49353106/does-serviceloader-really-load-providers-dynamically

6 缺点

  • 不能按需加载。虽然 ServiceLoader 做了延迟载入,但是基本只能通过遍历全部获取,也就是接口的实现类得全部载入并实例化一遍。如果你并不想用某些实现类,或者某些类实例化很耗时,它也被载入并实例化了,这就造成了浪费。
  • 获取某个实现类的方式不够灵活,只能通过 Iterator 形式获取,不能根据某个参数来获取对应的实现类。

鉴于 SPI 的诸多缺点,很多系统都是自己实现了一套类加载机制,例如 dubbo-spi、spring.factories。用户也可以自定义classloader+反射机制来加载,实现并不复杂

7 附录: ServiceLoader源码分析

再来看看ServiceLoader,ServiceLoader是一个工具类,并没有太多逻辑,大致逻辑为扫描META-INF/services下面的文件,然后加载具体的实现类。核心就是一个LazyIterator迭代器,通过ClassLoader迭代实例化provider。其中实例provider 的代码

ServiceLoader.class
1
2
3
//cn 为 扫描文件获得的 provider 全限定名(包名+类名)
Class<?> c = Class.forName(cn, false, loader);
S p = service.cast(c.newInstance());

Class.forName的用法很熟悉,再JDBC4.0之前,连接数据库的时候,通常会用Class.forName("com.mysql.jdbc.Driver")显式地将mysql-jdbc驱动注册到java.sql.DriverManager中,然后再进行获取连接等的操作。在之后就很少见这种写法了,原因就是使用了java 的spi 扩展机制来替代了

ServiceLoader 部分源码

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
public final class ServiceLoader<S> implements Iterable<S> {
private static final String PREFIX = "META-INF/services/";
// The class or interface representing the service being loaded
private final Class<S> service;

// The class loader used to locate, load, and instantiate providers
private final ClassLoader loader;

// The access control context taken when the ServiceLoader is created
private final AccessControlContext acc;

// Cached providers, in instantiation order
private LinkedHashMap<String,S> providers = new LinkedHashMap<>();

// The current lazy-lookup iterator
private LazyIterator lookupIterator;
// .....
private class LazyIterator implements Iterator<S> {
// .....
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
}
// .....

Comments

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×