优秀的开源网关【shenyu】框架剖析: 是如何实现spi机制的?
hello 大家好,我是爱抄中间件代码的路人丙,一个刚毕业就有一年工作经验的非水货码农,今天想跟大家分享一下:笔者研究开源组件shenyu中spi机制实现的思考和记录
shenyu 是一个开源的网关组件,如果你没听过它,那你肯定听过gateway,简单理解shenyu 是一个跟gateway同类型的组件,好比RocketMQ 和 Kafka,不过本篇文章并不去剖析shenyu ,而只是去剖析其中的一个点:关于shenyu 如何实现spi机制的。
以下是笔者去研究spi机制三点原因:
- 第一点:看到一个公司内部自研api网关分享,里面使用到了微内核的插件式架构,感觉可拔插的设计细想应对复杂应用太爽了,所以自己也想去了解一下其中的原理,其中spi设计思想是基础,所以就找了一个开源框架,去了解其spi的实现,其他的spi设计应该也差不太多,感兴趣的小伙伴可以再多研究几个开源组件
- 第二点:好奇心,看见厉害的思想设计,就想把它吸收过来,但是又因为自己的技术视野窄,所以只能通过研究开源组件的方式去吸收其中的精华
- 第三点:感觉公司有些产品的扩展性极差,比如笔者现在待的公司有一个图产品应用,它的图数据库选型有很多种,但是具体设计的时候根本没考虑到,目前实现直接焊死一种图数据库,如果要支持其他类型的图书库,只能拉一个新的分支去改业务代码,本身这个需求还是很常见的,因为这个图产品应用是为交付项目提供服务的,不同的客户完全有可能因为业务场景不同而选择不同的图数据库存储
一、shenyu源码 介绍
笔者这里直接在github拉取的master分支:
笔者要研究的内容主要在:shenyu-spi包下。看上面的图,笔者发现shenyu的包管理还是很清晰的的,这很大程度上降低了阅读源码的门槛
那么应该怎么去看呢?
笔者认为:主要有以下3点
- 首先,我们阅读源码要有目的性 :比如我自己看源码是为了知道shenyu组件在spi机制方面具体如何实现以及其中实现的细节,这样的好处在于,当我们在工作中业务落地的设计中需要考虑spi的时候,可以作为背书和参考方案
- 我们看源码的时候,尽量从入口开始进行尝试 ,比如 源码中自带的测试用例,可以让你快速熟悉某部分类对外提供的服务,同时你可以通过debug测试用例作为入口开始进行探索
- 耐心 ,做任何事情都不可能一蹴而就,肯定会有一个过程,所以一定要 有耐心
二 从官方测试用例开始深入
2.1 如何使用shenyu spi
/**
* Test spi.
*/
@Test
public void testSPI() {
JdbcSPI jdbcSPI = ExtensionLoader.getExtensionLoader(JdbcSPI.class).getJoin("mysql");
System.out.println(jdbcSPI);// 这一行代码是笔者加的
assertThat(jdbcSPI.getClass().getName(), is(MysqlSPI.class.getName()));
}
上面的代码块位于:org.apache.shenyu.spi 包下ExtensionLoaderTest最简单的测试用例
先简单跑一下用例:结果如下
通过结果我们知道,通过第一行代码,我们就拿到了一个MysqlSPI实列
在进入源码之前,我们先看第一行代码传入参数:JdbcSPI.class
JdbcSPI
@SPI
public interface JdbcSPI {
/**
* Gets class name.
*
* @return the class name
*/
@SuppressWarnings("unused")
String getClassName();
}
JdbcSPI是一个接口,定义了一个方法
我们看一下,它有哪些实现类
这个测试用例获取的结果就是其中一个实现类,熟悉原生Java spi实现的小伙伴,肯定一下就想到了META-INF下肯定有对应的接口的配置文件:
果然,如我们的猜测,同时我们还可以发现,配置文件中的key恰好是上面测试用列第一行代码调用第二个方法传入的参数,mysql
看到这里,相信大家大概应该知道shenyu spi如何使用了
确实如此么?答案是还不够
因为上面还有2个关键的注解没有提到:@SPI 和 @Join
所以当我们了解@SPI 和 @Join基本原理后,使用shenyu spi就不是大问题了
@SPI 和 @Join这2个注解会在下面源码剖析的时候提到
2.2 通过测试用例开启源码深入
回到最初的测试用例:
/**
* Test spi.
*/
@Test
public void testSPI() {
JdbcSPI jdbcSPI = ExtensionLoader.getExtensionLoader(JdbcSPI.class).getJoin("mysql");//断点打在这
System.out.println(jdbcSPI);// 这一行代码是笔者加的
assertThat(jdbcSPI.getClass().getName(), is(MysqlSPI.class.getName()));
}
2.3 源码解读:ExtensionLoader.getExtensionLoader()
首先我们先看第一个方法调用
public static <T> ExtensionLoader<T> getExtensionLoader(final Class<T> clazz) {
return getExtensionLoader(clazz, ExtensionLoader.class.getClassLoader());
}
public static <T> ExtensionLoader<T> getExtensionLoader(final Class<T> clazz, final ClassLoader cl) {
// 校验
Objects.requireNonNull(clazz, "extension clazz is null");
if (!clazz.isInterface()) {
throw new IllegalArgumentException("extension clazz (" + clazz + ") is not interface!");
}
// 如果使用shenyu spi设计的接口没有@SPI注解修饰,会直接异常
if (!clazz.isAnnotationPresent(SPI.class)) {
throw new IllegalArgumentException("extension clazz (" + clazz + ") without @" + SPI.class + " Annotation");
}
// (1)LOADERS 缓存map 避免反射带来的性能损耗
// 缓存有就直接返回
ExtensionLoader<T> extensionLoader = (ExtensionLoader<T>) LOADERS.get(clazz);
if (Objects.nonNull(extensionLoader)) {
return extensionLoader;
}
// (2) 缓存没有 从新构造ExtensionLoader 实列
LOADERS.putIfAbsent(clazz, new ExtensionLoader<>(clazz, cl));
// (3) 很明显 这个方法的核心就是获取ExtensionLoader 说明shenyu关于spi的核心实现主要都封装在ExtensionLoader类
return (ExtensionLoader<T>) LOADERS.get(clazz);
}
上面方法主要做了2件事!
- 校验
- 获取ExtensionLoader实列(这里是做了缓存的,key就是我们传进来的大class对象,测试用例中即JdbcSPI.clsss)
这里的核心其实主要在注释(2)处:new ExtensionLoader<>(clazz, cl),关于如何构造ExtensionLoader实例
2.3.1 构造ExtensionLoader实例
private ExtensionLoader(final Class<T> clazz, final ClassLoader cl) {
// (1)
this.clazz = clazz;
this.classLoader = cl;
if (!Objects.equals(clazz, ExtensionFactory.class)) {
// (2)
ExtensionLoader.getExtensionLoader(ExtensionFactory.class).getExtensionClassesEntity();
}
}
这里注意2个地方
第一个:(1)处,参数clazz 赋值给了 ExtensionLoader实例的clazz属性,这说明一个接口对应一个ExtensionLoader实列
第二个:(2)处,if条件成功后,又调用了ExtensionLoader.getExtensionLoader(ExtensionFactory.class)方法,测试用例也是从这个方法进入的,但是参数传递的是ExtensionFactory,class,这说明ExtensionFactory在后面肯定也是有作用的(笔者第一次跟源码的时候没注意这个条件,所以debug的时候,把自己绕进去了)
所以到这里大家应该明白ExtensionLoader.getExtensionLoader()方法的作用了,其实就是:
- 把我们传入的class封装为ExtensionLoader实例,并放入到静态map LOADERS属性中(当然,如果LOADERS中有,就不会生成新的ExtensionLoader实列)
- 在ExtensionLoader.getExtensionLoader()第一次被调用的时候,还会生成一个ExtensionLoader实列(包装的是ExtensionFactory.class)
代码(2)处,在获取包装了ExtensionFactory.class的 ExtensionLoader实列后又调用了getExtensionClassesEntity()方法,那么这个方法又做了些什么呢?
这个方法可以说是shenyu spi实现的核心流程了,这里先不分析,后面会详细分析到
2.3.2 shenyu spi 核心链路入口:ExtensionLoader.getJoin()
还是回到官方的测试用例:
/**
* Test spi.
*/
@Test
public void testSPI() {
JdbcSPI jdbcSPI = ExtensionLoader.getExtensionLoader(JdbcSPI.class).getJoin("mysql");//断点打在这
System.out.println(jdbcSPI);// 这一行代码是笔者加的
assertThat(jdbcSPI.getClass().getName(), is(MysqlSPI.class.getName()));
}
前面我们已经跟着源码分析了ExtensionLoader.getExtensionLoader()方法,它主要把我们传的参数包装成ExtensionLoader实列,至此为止我们还没看到任务初始化JdbcSPI的实现类的代码,接下来我们就跟着ExtensionLoader.getJoin()的源码看一看,它是怎么拿到JdbcSPI的实现类之一MysqlSPI的
public T getJoin(final String name) {
// 采用了 懒加载的思想 这一点跟java spi 机制类似
if (StringUtils.isBlank(name)) {
throw new NullPointerException("get join name is null");
}
// (1)cachedInstances 用来缓存 具体实现类的包装类Holder实列,其中Holder实列的value属性即是具体实现类
Holder<Object> objectHolder = cachedInstances.get(name);
if (Objects.isNull(objectHolder)) {
cachedInstances.putIfAbsent(name, new Holder<>());
objectHolder = cachedInstances.get(name);
}
Object value = objectHolder.getValue();
// (2)双重校验 说明shenyu spi是线程安全的
if (Objects.isNull(value)) {
// (3)锁的范围很小 cachedInstances
synchronized (cachedInstances) {
value = objectHolder.getValue();
if (Objects.isNull(value)) {
// (4)创建Holder实例
createExtension(name, objectHolder); // 核心方法
value = objectHolder.getValue();
if (!objectHolder.isSingleton()) {
Holder<Object> removeObj = cachedInstances.remove(name);
removeObj = null;
}
}
}
}
return (T) value;
}
通过上面代码会发现,原来shenyu spi 是通过name去拿到对应的实现类的,并且还做了缓存,而且还是线程安全的(线程安全的方式是通过代码段加锁实现的,锁的粒度还是比较小的),意味着多个线程去操作使用ExtensionLoader实列都是安全的
接下来我们继续跟核心代码:createExtension(name, objectHolder);
private void createExtension(final String name, final Holder<Object> holder) {
// (1)核心方法
ClassEntity classEntity = getExtensionClassesEntity().get(name);
if (Objects.isNull(classEntity)) {
throw new IllegalArgumentException(name + "name is error");
}
// (2) 这里说明 我们需要的实现类被封装在ClassEntity 对象
Class<?> aClass = classEntity.getClazz();
Object o = joinInstances.get(aClass);
if (Objects.isNull(o)) {
try {
if (classEntity.isSingleton()) {
// (3) 这里做了单例判断 单列会缓存起来
joinInstances.putIfAbsent(aClass, aClass.newInstance());
o = joinInstances.get(aClass);
} else {
// (4) 非单列 会通过大Class对象重新生成实例
o = aClass.newInstance();
}
} catch (InstantiationException | IllegalAccessException e) {
throw new IllegalStateException("Extension instance(name: " + name + ", class: "
+ aClass + ") could not be instantiated: " + e.getMessage(), e);
}
}
// 最后塞到holder实例
// 这里holder有一个order 说明可能支持排序
holder.setOrder(classEntity.getOrder());
holder.setValue(o);
holder.setSingleton(classEntity.isSingleton());
}
这个方法主要做了以下4件事:
- 获取具体实现类的包装对象ClassEntity (核心)
- 根据ClassEntity实列判断,具体实现类为单列:缓存起来
- 根据ClassEntity实列判断,具体实现类为非单列:大Class对象调用newInstance()方法重新生成
- 将具体实现类塞回传入的参数holder实例
接下来,我们具体看一下怎么拿到ClassEntity 包装对象的
getExtensionClassesEntity()
private Map<String, ClassEntity> getExtensionClassesEntity() {
// (1)第一次进来 这个Map肯定是空的 注意这个方法直接返回的是一个map
// cachedClasses 是ExtensionLoader的属性 也可以理解为缓存
Map<String, ClassEntity> classes = cachedClasses.getValue();
// 双重校验
if (Objects.isNull(classes)) {
synchronized (cachedClasses) {
classes = cachedClasses.getValue();
if (Objects.isNull(classes)) {
// (2) 我们直接进入核心方法 是怎么去构造这个map的 cachedClasses
classes = loadExtensionClass();
cachedClasses.setValue(classes);
cachedClasses.setOrder(0);
}
}
}
return classes;
}
通过这个味方法,我们大概知道这个classes比较重要,第一次这个map肯定是空,我们直接看这个map是怎么初始化好的:
上述代码11行 classes = loadExtensionClass();
private Map<String, ClassEntity> loadExtensionClass() {
// (1) 这个clazz 在构造ExtensionLoader时候已经提到过了,其实就是JdbcSPI.class
SPI annotation = clazz.getAnnotation(SPI.class);
if (Objects.nonNull(annotation)) {
String value = annotation.value();
if (StringUtils.isNotBlank(value)) {
// (2) 这里说明 我们可以给spi注解 定义value
cachedDefaultName = value;
}
}
Map<String, ClassEntity> classes = new HashMap<>(16);
// (3) 核心方法
loadDirectory(classes);
return classes;
}
代码13行: loadDirectory(classes); 超级核心代码
private void loadDirectory(final Map<String, ClassEntity> classes) {
// (1) 这里拼了一个路径: fileName = META-INF/shenyu/JdbcSPI
// 看到这里笔者也就明白了,跟Java spi原生类似,只是shenyu 拼的前缀为:META-INF/shenyu
// 后面的代码其实就跟 Java 原生spi的代码差不太多
// shenyu会把spi的具体实现类封装在参数classes map里
String fileName = SHENYU_DIRECTORY + clazz.getName();
try {
Enumeration<URL> urls = Objects.nonNull(this.classLoader) ? classLoader.getResources(fileName)
: ClassLoader.getSystemResources(fileName);
if (Objects.nonNull(urls)) {
while (urls.hasMoreElements()) {
URL url = urls.nextElement();
// (2)核心方法
loadResources(classes, url);
}
}
} catch (IOException t) {
LOG.error("load extension class error {}", fileName, t);
}
}
这里虽然已经可以看到shenyu spi 跟原生java spi 一样会去 META-INF/shenyu/路径下找配置文件,但是并没有看到初始化具体实现类的代码,那么接下来继续跟一下核心代码: loadResources(classes, url);
private void loadResources(final Map<String, ClassEntity> classes, final URL url) throws IOException {
try (InputStream inputStream = url.openStream()) {
// (1)拿到url的inputStream流
Properties properties = new Properties();
properties.load(inputStream);
properties.forEach((k, v) -> {
// (2) 遍历每一个键值对
// mysql=org.apache.shenyu.spi.fixture.MysqlSPI
// oracle=org.apache.shenyu.spi.fixture.OracleSPI
// canNotInstantiated=org.apache.shenyu.spi.fixture.CanNotInstantiatedSPI
String name = (String) k; // key => name
String classPath = (String) v;// value => 具体实现类的全路径类名
if (StringUtils.isNotBlank(name) && StringUtils.isNotBlank(classPath)) {
try {
// (1)核心方法 根据类的全路径类名,即classPath ,然后通过反射生成对应的实现类
loadClass(classes, name, classPath);
} catch (ClassNotFoundException e) {
throw new IllegalStateException("load extension resources error", e);
}
}
});
} catch (IOException e) {
throw new IllegalStateException("load extension resources error", e);
}
}
这一步主要通过URL去解析其中的k-v,我们继续跟一下源码:16行 ,loadClass(classes, name, classPath);
private void loadClass(final Map<String, ClassEntity> classes,
final String name, final String classPath) throws ClassNotFoundException {
// (1)反射 classPath就是配置在 资源路径下的类的全类名
Class<?> subClass = Objects.nonNull(this.classLoader) ? Class.forName(classPath, true, this.classLoader) : Class.forName(classPath);
// (2)校验
if (!clazz.isAssignableFrom(subClass)) {
throw new IllegalStateException("load extension resources error," + subClass + " subtype is not of " + clazz);
}
// 校验 获取实现类 必须被@Join 注解修饰 否则这里就会抛异常了
if (!subClass.isAnnotationPresent(Join.class)) {
throw new IllegalStateException("load extension resources error," + subClass + " without @" + Join.class + " annotation");
}
// (3)把生成的具体实现包装成ClassEntity,并放到classes map里,其中key,就是我们在配置文件中设置的key
// 所以这也是为什么 通过ExtensionLoader.getExtensionLoader(JdbcSPI.class).getJoin("mysql") 就可以拿到mysql实现的具体实现类
// 为什么要把实现类包装成ClassEntity,其实是一种面向对象的思想,在原有基础上增加2个特性:(1)支持单列、多列 (2)支持具体实现类排序
ClassEntity oldClassEntity = classes.get(name);
if (Objects.isNull(oldClassEntity)) {
// 把 subClass 包装 成ClassEntity对象
// Join注解可支持排序
Join joinAnnotation = subClass.getAnnotation(Join.class);
ClassEntity classEntity = new ClassEntity(name, joinAnnotation.order(), subClass, joinAnnotation.isSingleton());
classes.put(name, classEntity);
} else if (!Objects.equals(oldClassEntity.getClazz(), subClass)) {
throw new IllegalStateException("load extension resources error,Duplicate class " + clazz.getName() + " name "
+ name + " on " + oldClassEntity.getClazz().getName() + " or " + subClass.getName());
}
}
看到这里笔者豁然开朗,shenyu spi本质上跟原生Java spi实现一样,都是通过读取配置文件中具体实现类的全路径类名,然后通过反射去生成对应的实例:具体可见上述代码 第4行,通过这个方法我们还发现具体实现类被封装为ClassEntity对象,大家可以去看一ClassEntity的构造方法,代码第22行,应该就明白了:
我们可以在具体实现类上通过@Join注解的 order 来设置实现类顺序,isSingleton来设置实列是否为单例
2.3.3 简单小结一下 - shenyu spi 的特点
这一章,我们主要通过官方提供的最简单测试用例,深入的剖析了shenyu spi实现的原理,通过源码的分析,我们可以得出以下结论:
(1)shenyu spi 使用上是线程安全的:通过synchronized代码段锁保证,粒度相对还是比较小的
(2)每一个被spi注解修饰的soi扩展接口,都对应一个ExtensionLoader实列
(3)shenyu spi 实现本质,同Java 原生spi:扫描配置文件,然后通过反射的方式生成对应实现类 (相同点)
(4)shenyu spi将具体实现类包装成了ClassEntity对象,在原有基础上提供了可排序、可单列、非单列加强功能,具体实现是通过实现类上添加@join注解,配置其中2个属性:order 和 isSingleton
2.4 通过shenyu 源码,去了解shenyu自己是如何使用shenyu spi机制的
上面我们已经分析了shenyu spi实现的源码,大概清楚是怎么一回事了,以及大概知道怎么使用shenyu spi,同时遇到同类型的需求,我们大概也有一个思考和借鉴的方向
接下来,我们看看shenyu 源码其他模块中是如何使用shenyu spi完成扩展的:
笔者随便找了一处:
public final class RateLimiterAlgorithmFactory {
private RateLimiterAlgorithmFactory() {
}
/**
* New instance rate limiter algorithm.
*
* @param name the name
* @return the rate limiter algorithm
*/
public static RateLimiterAlgorithm<?> newInstance(final String name) {
return Optional.ofNullable(ExtensionLoader.getExtensionLoader(RateLimiterAlgorithm.class).getJoin(name)).orElseGet(TokenBucketRateLimiterAlgorithm::new);
}
}
代码位置:
通过上面的代码,我们可以知道,定义了一个工厂类把ExtensionLoader进行了再一次封装,我们只需要传入name 即可获取对应实现类(前提使用的时候配置了 对应接口的配置文件),如果我们没有配置默认使用:TokenBucketRateLimiterAlgorithm类,看这个类的名字应该是token桶限流算法,感兴趣的小伙伴可以自己研究一下
简单小结一下,通过源码研究shenyu 本身关于spi的使用上,还会使用工厂模式封装一层,笔者认为这样的好处:代码更加优雅,代码健壮性也会更好,因为如果使用人员未进行配置的化,则shenyu框架也会提供默认实现
三 、shenyu spi机制源码研究收获:谈谈笔者自己的收获和思考
3.1 为什么要去看shenyu spi的机制实现呢?
笔者去研究spi机制主要有以下三点原因:
- 第一点:看到一个公司内部自研api网关分享,里面使用到了微内核的插件式架构,感觉可拔插的设计细想应对复杂应用太爽了,所以自己也想去了解一下其中的原理,其中spi设计思想是基础,所以就找了一个开源框架,去了解其spi的实现,其他的spi设计应该也差不太多,感兴趣的小伙伴可以再多研究几个开源组件
- 第二点:好奇心,看见厉害的思想设计,就想把它吸收过来,但是又因为自己的技术视野窄,所以只能通过研究开源组件的方式去吸收其中的精华
- 第三点:感觉公司有些产品的扩展性极差,比如笔者现在待的公司有一个图产品应用,它的图数据库选型有很多种,但是具体设计的时候根本没考虑到,目前实现直接焊死一种图数据库,如果要支持其他类型的图书库,只能拉一个新的分支去改业务代码,本身这个需求还是很常见的,因为这个图产品应用是为交付项目提供服务的,不同的客户完全有可能因为业务场景不同而选择不同的图数据库存储
3.2 shenyu spi 和Java 原生 spi 的差异和相同点
shenyu spi | java spi | |
---|---|---|
功能上 | 可扩展、支持排序、单列非单列选择 | 仅可扩展 |
安全上 | 使用上保证线程安全 | 使用上不保证线程安全 |
配置文件路径 | /META_INF/shenyu | /META_INF |
获取对应实现类实例方式 | 通过name直接获取(通常情况下O(1)) | 遍历获取(O(n)) |
底层实现原理 | Java 反射 | Java 反射 |
以上大概就是shenyu spi 和Java原生spi 的异同了
3.3 研究shenyu spi机制给我带来了什么?
笔者认为最直观的收益有以下几个:
(1)对于spi机制有一个大概的理解和认识
(2)提升了自己基于解决问题去研究源码的能力
(3)为进一步研究可拔插式的设计思想打下了基础
(4)拓宽了笔者自己的技术视野
(5)为解决同类型的需求时,提供了参考方案和背书
当然最有价值的,笔者认为还是spi的思想:为使用者提供一种优雅的可扩展方式
笔者认为这种思想是与语言无关的,它更抽象、更宏观、更像一种通用的解决方案