在Java里实现一套自己的SPI机制

之前我们有使用过dubbo的SPI机制,并且java也有自己的SPI机制

java的SPI不能按需加载,没有优先级,只能空参构造;dubbo的SPI和自己深度结合,无法单独使用。我们需要一个满足以下特性的SPI机制

  1. 和其它SPI机制类似,支持按需加载
  2. 可以将实现类和其优先级定义在一起,可以按优先级加载,也可以全部加载
  3. 在某种程度上支持参数构造,因为可能某一接口的全部实现类都依赖相同的运行时参数

说明

使用java类似的定义方案,但是新增一个优先级,使用逗号分隔:优先级是一个数字,默认加载最大数字的实现类

比如对于plume项目,假设有三个模块:main、custom、adapter

  • main是启动模块,依赖custom
  • custom里是一些组件的默认实现
  • adapter里可能有一些增强实现,但不一定会引用

main在启动时候会使用一个自定义的BeanPostProcessor,在custom和adapter里都有实现

如何实现在adapter引用时就调用adapter里的BeanPostProcessor

  1. 首先在custom和adapter的resources目录添加一个目录/META-INF/plume并添加文件org.springframework.beans.factory.config.BeanPostProcessor
  2. 在custom的上述接口文件里新增:io.cana.plume.custom.initial.BootBeanPostProcessor,20
  3. 在adapter的上述接口文件里新增:io.cana.plume.adapter.initial.BootBeanPostProcessor,100
  4. 在main里获取实现类时使用constructImplementObject("/META-INF/plume/org.springframework.beans.factory.config.BeanPostProcessor")即可

读取和解析

首先肯定要读取到所有针对当前接口的定义文件,使用ClassLoader既可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
* 按文件名读取所有resource,包括jar内
*
* @param resourceName 全路径,包括目录
* @return 读取到的资源url枚举
*/
private static Enumeration<URL> loadResourceUrl(String resourceName) throws IOException {
return Thread.currentThread().getContextClassLoader().getResources(resourceName);
}

/**
* 根据url读取文件内容,放入map
*
* @param names key是优先级
* @param url 要读取的url
*/
private static void parseResourceUrl(TreeMap<Integer, String> names, URL url) throws IOException {
try (final InputStream in = url.openStream();
final BufferedReader br = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) {
int lc = 1;
while ((lc = parseResourceInnerLine(names, br, lc)) >= 0) ;
}
}

parseResourceInnerLine是对每个文件里的每一行解析,首先要去掉#后边的注释部分,然后按逗号分隔

分隔之后拿到的类全名如果包含Character.isJavaIdentifierPart和点之外的字符则认为文件错误,不继续进行解析

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
/**
* 解析文件里的每一行数据
*
* @param lc 读取的行数
*/
private static int parseResourceInnerLine(TreeMap<Integer, String> names, BufferedReader br, int lc) throws IOException {
int p = 0;
// 读不到数据代表解析完了
String ln = br.readLine();
if (ln == null) {
return -1;
}
// 去除掉注释
int ci = ln.indexOf('#');
if (ci >= 0) {
ln = ln.substring(0, ci);
}
ln = ln.trim();
int n = ln.length();
if (n != 0) {
// 按逗号分隔
final int idx = ln.indexOf(',');
if (idx >= 0) {
p = Integer.parseInt(ln.substring(idx + 1).trim());
ln = ln.substring(0, idx).trim();
n = ln.length();
}
// 下面都是对错误格式的处理,出现错误整个文件都放弃
if ((ln.indexOf(' ') >= 0) || (ln.indexOf('\t') >= 0)) {
log.error("Illegal configuration-file syntax: {}", ln);
return -1;
}
int cp = ln.codePointAt(0);
if (!Character.isJavaIdentifierStart(cp)) {
log.error("Illegal provider-class name: " + ln);
return -1;
}

for (int i = Character.charCount(cp); i < n; i += Character.charCount(cp)) {
cp = ln.codePointAt(i);
if (!Character.isJavaIdentifierPart(cp) && (cp != '.')) {
log.error("Illegal provider-class name: " + ln);
return -1;
}
}
if (!names.containsValue(ln)) {
names.put(p, ln);
}
}
return lc + 1;
}

按需加载

拿到的TreeMap里只有实现类的类全名

  1. 按需要加载只要实例化优先级最高的即可,即只需要lastEntry
  2. 全部加载只需要返回整个TreeMap,由用户手动实例化
  3. 如果有构造的实际参数,就能拿到对应的构造方法从而实现参数构造
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
/**
* 根据类全名和构造参数值,寻找其实现,并实例化优先级最高的实现类
*
* @param interfaceName 接口名,其实也可以是抽象类等
* @param constructorParams 构造参数的值,要求实现类的构造函数里不能用基本类型
* @param <T> 存粹的占位符,在这里强制
*/
@SuppressWarnings("unchecked")
public static <T> T constructImplementObject(String interfaceName, Object... constructorParams)
throws ClassNotFoundException, NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {
if (null == constructorParams || constructorParams.length == 0) {
return (T) loadImplementClass(interfaceName).newInstance();
}

final Class<?>[] constructorClasses = new Class[constructorParams.length];
for (int i = 0; i < constructorParams.length; i++) {
final Object param = constructorParams[i];
constructorClasses[i] = param.getClass();
}
final Constructor<T> clazz = loadImplementConstructor(interfaceName, constructorClasses);
return clazz.newInstance(constructorParams);
}

/**
* 获取类的构造方法
*/
@SuppressWarnings("unchecked")
public static <T> Constructor<T> loadImplementConstructor(String interfaceName, Class<?>... types)
throws ClassNotFoundException, NoSuchMethodException {
final Class<?> clazz = loadImplementClass(interfaceName);
return (Constructor<T>) clazz.getDeclaredConstructor(types);
}

/**
* 从当前的ClassLoader读取给定类
*/
public static Class<?> loadImplementClass(String interfaceName) throws ClassNotFoundException {
// loadImplementName就是读取TreeMap的lastEntry
return Class.forName(loadImplementName(interfaceName), false, Thread.currentThread().getContextClassLoader());
}

使用限制

  1. constructImplementObject实际需要输入的是文件相对于classpath的完整路径,所以最好在封装一次
  2. 如果定义了两个一样的优先级。因为TreeMap只会保留一个,而且不确定是哪一个
  3. 构造参数不能使用基础类型,如果要用只能使用包装类。因为Object传递参数时会被自动装箱

在Java里实现一套自己的SPI机制
https://back.pub/post/hh-code-java-spi-implement/
作者
Dash
发布于
2019年9月1日
许可协议