阅读前提示

为了更好地理解文章,建议先阅读 SPI 概念及案例实践(上)

这篇文章 中我们学习并实践了 Java 原生的 SPI 机制。但我们还想更便捷的使用 SPI。设想这样一个场景:当公司 A 使用互联网连接服务时,我们直接这样调用服务:

1
2
3
4
// 获得互联网连接服务,具体的实现类为中国移动公司提供互联网连接服务
InternetService internetService1 = InternetServiceFactory("cn-mobile-beijing");
// 在同样的应用中,我们可以获取另一种互联网连接服务的实现
InternetService internetService2 = InternetServiceFactory("cn-unicom");

这样就不需要在 pom.xml 中导入不同的 jar 包以获取实现类,又或者在多个实现类中选择一个具体的服务实现。编写好一个 SPI 框架,我们能更好地调用和扩展 SPI 服务。

本文源代码详见文末。

写一个 SPI 框架

重写 ServiceLoader

现在,公司 A 决定写一套 SPI 框架。

新建模块 simple-company-frame。在 simple-company-frame 模块 pom.xml 中导入以下模块以提供日志功能:

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
<dependencies>
<dependency>
<groupId>top.uuanqin</groupId>
<artifactId>simple-api</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>

<!-- https://doc.hutool.cn/ -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.16</version>
</dependency>

<!-- https://mvnrepository.com/artifact/ch.qos.logback/logback-classic -->
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>1.3.12</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.32</version>
<scope>compile</scope>
</dependency>
</dependencies>

重写 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
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
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
package top.uuanqin.frame;

import cn.hutool.core.io.resource.ResourceUtil;
import lombok.extern.slf4j.Slf4j;


import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.net.URL;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/**
* SPI 加载器(支持键值对映射)
*/
@Slf4j
public class SpiLoader {

/**
* 存储已加载的类:接口名 =>(key => 实现类)
*/
private static Map<String, Map<String, Class<?>>> loaderMap = new ConcurrentHashMap<>();

/**
* 对象实例缓存(避免重复 new),类路径 => 对象实例,单例模式
*/
private static Map<String, Object> instanceCache = new ConcurrentHashMap<>();

/**
* 系统 SPI 目录
*/
private static final String RPC_SYSTEM_SPI_DIR = "META-INF/rpc/system/";

/**
* 用户自定义 SPI 目录
*/
private static final String RPC_CUSTOM_SPI_DIR = "META-INF/rpc/custom/";

/**
* 扫描路径
*/
private static final String[] SCAN_DIRS = new String[]{RPC_SYSTEM_SPI_DIR, RPC_CUSTOM_SPI_DIR};

/**
* 获取某个接口的实例
*
* @param tClass
* @param key
* @param <T>
* @return
*/
public static <T> T getInstance(Class<?> tClass, String key) {
String tClassName = tClass.getName();
Map<String, Class<?>> keyClassMap = loaderMap.get(tClassName);
if (keyClassMap == null) {
throw new RuntimeException(String.format("SpiLoader 未加载 %s 类型", tClassName));
}
if (!keyClassMap.containsKey(key)) {
throw new RuntimeException(String.format("SpiLoader 的 %s 不存在 key=%s 的类型", tClassName, key));
}
// 获取到要加载的实现类型
Class<?> implClass = keyClassMap.get(key);
// 从实例缓存中加载指定类型的实例
String implClassName = implClass.getName();
if (!instanceCache.containsKey(implClassName)) {
try {
instanceCache.put(implClassName, implClass.newInstance());
} catch (InstantiationException | IllegalAccessException e) {
String errorMsg = String.format("%s 类实例化失败", implClassName);
throw new RuntimeException(errorMsg, e);
}
}
return (T) instanceCache.get(implClassName);
}

/**
* 加载某个类型
*
* @param loadClass
*/
public static Map<String, Class<?>> load(Class<?> loadClass) {
log.info("加载类型为 {} 的 SPI", loadClass.getName());
// 扫描路径,用户自定义的 SPI 优先级高于系统 SPI
Map<String, Class<?>> keyClassMap = new HashMap<>();
for (String scanDir : SCAN_DIRS) {
// ResourceUtil.getResources 获得资源的URL 路径用/分隔
List<URL> resources = ResourceUtil.getResources(scanDir + loadClass.getName()); // 获取配置文件是使用了 ResourceUtil.getResources,而不是通过文件路径获取。因为如果框架作为依赖被引入,是无法得到正确文件路径的。
// 读取每个资源文件
for (URL resource : resources) {
try {
InputStreamReader inputStreamReader = new InputStreamReader(resource.openStream());
BufferedReader bufferedReader = new BufferedReader(inputStreamReader);
String line;
while ((line = bufferedReader.readLine()) != null) {
String[] strArray = line.split("=");
if (strArray.length > 1) {
String key = strArray[0];
String className = strArray[1];
keyClassMap.put(key, Class.forName(className));
}
}
} catch (Exception e) {
log.error("spi resource load error", e);
}
}
}
loaderMap.put(loadClass.getName(), keyClassMap);
return keyClassMap;
}
}

相当于一个工具类,提供了读取配置并加载实现类的方法。

关键实现如下:

  • 用 Map 来存储已加载的配置信息 键名 => 实现类。
  • 扫描指定路径,读取每个配置文件,获取到 键名 => 实现类 信息并存储在 Map 中。
  • 定义获取实例方法,根据用户传入的接口和键名,从 Map 中找到对应的实现类,然后通过反射获取到实现类对象。可以维护一个对象实例缓存,创建过一次的对象从缓存中读取即可。

我们扫描的 SPI 目录有两个:

  • META-INF/my/system/ 框架的 SPI 目录
  • META-INF/my/custom/ 用户自定义的 SPI 目录(优先级高)

为框架编写自己 InternetService 的实现类

simple-company-frame 模块新建包 internet,存放与互联网连接相关的代码。

写随便两个公司自己编写的实现类,用于后续测试。比如:

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

import top.uuanqin.InternetService;

/**
* @author uuanqin
*/
public class LocalConnection implements InternetService {
@Override
public void connectInternet() {
System.out.println("访问 127.0.0.1 ......");
}
}

写一个 InternetService 工厂类

互联网连接服务对象是可以复用的,没必要每次执行连接操作前都创建一个新的对象。所以我们可以使用设计模式中的 工厂模式 + 单例模式 来简化创建和获取互联网连接服务对象的操作。

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
package top.uuanqin.frame.internet;



import top.uuanqin.InternetService;
import top.uuanqin.frame.SpiLoader;

/**
* 互联网连接工厂(用于获取互联网连接服务对象)
*
* 互联网连接服务对象是可以复用的,没必要每次执行连接操作前都创建一个新的对象。所以我们可以使用设计模式中的 工厂模式 + 单例模式 来简化创建和获取互联网连接服务对象的操作
*/
public class InternetServiceFactory {

// 使用静态代码块,在工厂首次加载时,就会调用 SpiLoader 的 load 方法加载序列化器接口的所有实现类,之后就可以通过调用 getInstance 方法获取指定的实现类对象了。

static {
SpiLoader.load(InternetService.class);
}

/**
* 默认连接对象
*/
private static final InternetService DEFAULT_SERIALIZER = new LocalConnection();

/**
* 获取实例
*
* @param key
* @return
*/
public static InternetService getInstance(String key) {
return SpiLoader.getInstance(InternetService.class, key);
}

}

写配置

image.png

测试

回到 simple-company 模块。

pom.xml 中引入框架:

1
2
3
4
5
<dependency>
<groupId>top.uuanqin</groupId>
<artifactId>simple-company-frame</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>

编写测试用的主函数:

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

import top.uuanqin.frame.internet.InternetServiceFactory;

public class SpiMain {
public static void main(String[] args) {
InternetService internetService = InternetServiceFactory.getInstance("local");
internetService.connectInternet();
}
}

返回结果:

1
访问 127.0.0.1 ......

如果 main 函数代码改成这样,将得到不一样的结果:

1
2
3
4
5
6
InternetService internetService = InternetServiceFactory.getInstance("fake");
// 结果:
// 你没有插网线!
InternetService internetService = InternetServiceFactory.getInstance("ddd");
// 错误:
// Exception in thread "main" java.lang.RuntimeException: SpiLoader 的 top.uuanqin.InternetService 不存在 key=ddd 的类型

服务拓展

既然公司 A 写了一套框架,那么现在公司 A 要求所有的运营商:

  • 把所有连接互联网的 SPI 实现通通放在 META-INF/my/system/ 下!
  • 实现服务类名前面加上个 Key!

中国移动和中国联通运营商们都乖乖照做了:

image.png

框架 simple-company-framepom.xml 中同时导入了这两个服务供应商的包:

1
2
3
4
5
6
7
8
9
10
<dependency>
<groupId>cn.mobile</groupId>
<artifactId>simple-isp-mobile</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>cn.unicom</groupId>
<artifactId>simple-isp-unicom</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>

下面回到 simple-company 模块测试:

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

import top.uuanqin.frame.internet.InternetServiceFactory;

public class SpiMain {
public static void main(String[] args) {
InternetService internetService = InternetServiceFactory.getInstance("cn-mobile-beijing");
internetService.connectInternet();
}
}

输出:

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

这样,公司 A 可以通过切换 key 随时指定需要的服务了!

用户自定义实现类

公司 A 想自己写几个临时实现类,但是不想让框架 simple-company-frame 导入。这时 META-INF/my/custom/ 目录就起作用了。

simple-company 模块新建了自己的临时实现类:

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

/**
* @author uuanqin
*/
public class TempConnection implements InternetService {
@Override
public void connectInternet() {
System.out.println("公司 A 的临时连接。");
}
}

编写 SPI 配置:

image.png

测试:

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

import top.uuanqin.frame.internet.InternetServiceFactory;

public class SpiMain {
public static void main(String[] args) {
InternetService internetService = InternetServiceFactory.getInstance("temp");
internetService.connectInternet();
}
}

输出:

1
公司 A 的临时连接。

改进与拓展思路

SPI 框架复用

在上面的代码中,SpiLoader 并没有存在于「互联网服务」相关的硬编码。我们可以仿照上面的工厂写法,写出更多的服务工厂。这样我们的 Main 函数就可以是:

1
2
3
4
InternetService internetService = InternetServiceFactory.getInstance("cn-mobile-suzhou");
TVService tvService = TVServiceFactory.getInstance("xiaomi");
FoodService foodService = FoodServiceFactory.getInstance("kfc");
// ...

常量存储

写一个接口类,专门存储字符串常量以供使用:

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

/**
* @author uuanqin
*/
public interface ConnectionKeys {
String FAKE = "fake";

String LOCAL = "local";

String CN_MOBILE_SUZHOU = "cn-mobile-suzhou";
}

配合全局配置

结合 这篇文章,我们可以通过修改配置文件直接选择我们需要的服务。

源代码

Readme Card

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

本文参考