手写Dubbo SPI机制和源码解析
版本
2.7.8
SPI机制
官方文档介绍如下
SPI 全称为 Service Provider Interface,是一种服务发现机制。SPI 的本质是将接口实现类的全限定名配置在文件中,并由服务加载器读取配置文件,加载实现类。这样可以在运行时,动态为接口替换实现类。正因此特性,我们可以很容易的通过 SPI 机制为我们的程序提供拓展功能。
说白了,SPI是一种第三方框架常用的扩展手段:第三方框架定义接口,使用者来写实现类,通过SPI机制框架运行时可以获取到这个实现类,通过反射创建对象,并使用这个对象来继续完成业务
所以通过SPI机制,第三方框架可以将某一段业务逻辑交由使用者自定义实现
比如Dubbo的负载均衡策略,内置提供了多种常用策略(Random/RoundRobin..),但依然无法满足所有用户的需求
通过SPI机制,可以让用户自己实现负载均衡策略,Dubbo再发送RPC调用时通过SPI获取到用户实现的策略,并使用这个策略来决策最终选择调用的服务端
Java SPI
jdk提供了简单的SPI功能,可以再运行时获取某接口的所有扩展实现类,比如,定义一个接口:Car
public interface Car {
void run();
}
定义两个实现:Audi
(奥迪)和Buick
(别克)
public class Audi implements Car {
@Override
public void run() {
System.out.println("Audi is running");
}
}
public class Buick implements Car {
@Override
public void run() {
System.out.println("Buick is running");
}
}
在 META-INF/services 文件夹下创建一个文件,名称为 Car 的全限定名 com.pq.pure.spi.Car。文件内容为实现类的全限定的类名,如下:
com.pq.pure.spi.Buick
com.pq.pure.spi.Audi
Java SPI Dubbo SPI 的相关逻辑被封装在了ServiceLoader
下,测试一下
public class SPITest {
@Test
public void run() {
ServiceLoader serviceLoader = ServiceLoader.load(Car.class);
serviceLoader.forEach(Car::run);
}
}
输出如下
成功加载了两个实现类,并实例化且循环执行了run方法
Dubbo SPI
Java提供的SPI可以获取某个接口的所有实现,一般后续代码就是全部循环执行,可以新增,但不能只指定其中某一个执行,比较使用的场景比如~后置处理器
而Dubbo需要的场景一般是从接口的实现中指定某一个(比如从多个负载均衡器中选用一个)去执行,这种场景Java SPI就很难实现了,所以Dubbo自己实现了一套SPI机制:
- 可以给每个实现取一个名字
- 可以按名字获取对应的实现
可以理解为JAVA SPI的所有实现是一个LIST
,可以循环但不能指定某一个,而Dubbo SPI所有实现是一个MAP
,可以根据key获取指定的一个
接下来就测试一下Dubbo SPI的使用,还是刚才的一个接口和两个实现,在
META-INF/dubbo目录下文件夹下以 Car 的全限定名创建文件,内容如下
Buick=com.pq.pure.spi.Buick
Audi=com.pq.pure.spi.Audi
等号前面是key后面是value
Dubbo SPI 的相关逻辑被封装在了ExtensionLoader
类中,测试一下(需要给Car接口加上@SPI注解)
public class DubboSPITest {
public static void main(String[] args) {
// 初始化Car接口的扩展类加载器
ExtensionLoader extensionLoader = ExtensionLoader.getExtensionLoader(Car.class);
// 获取名为"Buick"的扩展实现
Car buick = extensionLoader.getExtension("Buick");
// 运行
buick.run();
}
}
运行结果如下
这就是Dubbo SPI的更强的地方,可以按名称获取某一个实现,这样就可以实现通过配置来切换实现,也方便自定义扩展实现
模拟实现
接下来再深度研究下Dubbo SPI如何做到的
首先如果自己去实现,如何做到?大概整理一下实现流程,其实很简单
- 约定一个地址,让用户去里面定义文件,以key=value的形式填写实现名称和全限定名
- 当获取某个接口的某个名称实现时,去约定地址下读取名为接口全限定名文件,扫描文件内容,获取到key为该名称的value(即实现的全限定名),通过类加载器加载这个类,然后通过反射创建实例返回
思路屡清了,很简单,接下来尝试实现一下
做一个接口扩展加载器,用泛型代表接口类型,并约定好扩展文件地址: META-INF/pq下
public class ExtensionLoader {
/**
* 接口的类
*/
private final Class type;
/**
* 约定好的地址
*/
private final static String dir = "META-INF/pq/";
public ExtensionLoader(Class type) {
this.type = type;
}
}
在约定路径 META-INF/pq 下创建扩展文件com.pq.pure.spi.Car
,内容与之前一样
实现类加载功能,即把扩展文件中的内容转换为MAP内存结构
代码如下
/**
* 获取当前接口的扩展类,即把扩展文件中的的数据转换为map结构
*
* @return
*/
private Map> loadExtensionClasses() {
try {
// 存储结果
Map> extensionClasses = new HashMap<>();
// 扩展文件名称
String fileName = dir + type.getName();
// jdk 类加载器
ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
// 加载配置文件
Enumeration urls = classLoader.getResources(fileName);
if (urls != null) {
while (urls.hasMoreElements()) {
URL resourceURL = urls.nextElement();
loadResource(extensionClasses, classLoader, resourceURL);
}
}
return extensionClasses;
} catch (Throwable t) {
throw new IllegalStateException();
}
}
/**
* 读取扩展文件
* @param extensionClasses
* @param classLoader
* @param resourceURL
*/
private void loadResource(Map> extensionClasses, ClassLoader classLoader,
URL resourceURL) {
try {
try (BufferedReader reader = new BufferedReader(new InputStreamReader(resourceURL.openStream(), StandardCharsets.UTF_8))) {
String line;
// 逐行读取
while ((line = reader.readLine()) != null) {
line = line.trim();
if (line.length() > 0) {
// 找到等号
int i = line.indexOf('=');
// name
String name = line.substring(0, i).trim();
// 实现类全限定名
line = line.substring(i + 1).trim();
extensionClasses.put(name, Class.forName(line, true, classLoader));
}
}
}
} catch (Throwable t) {
throw new IllegalStateException();
}
}
主要用到了类加载的功能,和文件读取的一些逻辑
最后,根据名字获取某个实现的实例,很简单,从map中读取class,反射实例化即可
/**
* 根据name获取实现实例
* @param name
* @return
*/
public T getExtension(String name) {
// todo 省去了缓存逻辑
// 获取改名字的实现类
Class> clazz = loadExtensionClasses().get(name);
try {
// 反射实例化
return (T) clazz.newInstance();
} catch (Exception e) {
throw new IllegalStateException();
}
}
测试一下
package com.pq.pure.spi;
import com.pq.pure.spi.extension.ExtensionLoader;
public class MyDubboTest {
public static void main(String[] args) {
ExtensionLoader extensionLoader = new ExtensionLoader(Car.class);
// 获取别克的实现
Car buick = extensionLoader.getExtension("Audi");
// 运行
buick.run(); // 输出 Audi is running
}
}
至此就实现了一个运行时按名称获取对应实现的SPI功能~
源码
回头看dubbo的实现源码,其实上一步的模拟实现就是从源码摘抄的主线代码,但是省略了很多功能、异常校验、线程安全、缓存等,下面来学习下Dubbo源码的优秀写法
缓存
因为读取文件,反射这些代码都是比较耗时的,而且读取一次之后完全可以缓存起来下次直接使用,所以Dubbo的ExtensionLoader源码中包含大量的缓存逻辑,比如
// 缓存每个可扩展接口的扩展加载器,是一个静态全局缓存
static ConcurrentMap, ExtensionLoader>> EXTENSION_LOADERS
// 缓存当前扩展加载器每个扩展实现类的实例
ConcurrentMap, Object> EXTENSION_INSTANCES
// 缓存当前扩展加载器每个实现名称对应的实现类
Holder
// 缓存当前扩展加载器每个实现名称对应的实现类实例
ConcurrentMap> cachedInstances
配置路径
在我们的模拟实现中使用一个静态变量来约定配置的路径:
private final static String dir = "META-INF/pq/";
而dubbo的实现更具有扩展性,并且是使用Java SPI来实现这种扩展性
首先dubbo定义了一个加载策略的接口
最重要的方法就是directory()
,返回的就是配置路径
在ExtensionLoader类中存放静态的加载策略实现数组
public class ExtensionLoader {
//...
private static volatile LoadingStrategy[] strategies = loadLoadingStrategies();
private static LoadingStrategy[] loadLoadingStrategies() {
// 通过JAVA SPI加载策略实现类
return stream(ServiceLoader.load(LoadingStrategy.class).spliterator(), false)
.sorted()
.toArray(LoadingStrategy[]::new);
}
//...
}
可以到Dubbo jar包中META-INF/services下找到其实现配置文件
内置三个策略,分别对应"META-INF/dubbo/internal/","META-INF/dubbo/","META-INF/services/"三个路径,而且可以继续扩展
AOP
Dubbo SPI还支持面向切面编程,回到Dubbo SPI那个例子,我们的Car接口有两种实现:别克&奥迪,如果有个需求是不管使用什么车,都要加一个行车记录仪,这时只需要加一个行车记录仪的装饰器即可
public class CarRecorderWrapper implements Car {
private Car car;
public CarRecorderWrapper(Car car) {
this.car = car;
}
@Override
public void run() {
car.run();
System.out.println("driving recorder");
}
}
配置文件也加入
CarRecorder=com.pq.pure.spi.CarRecorderWrapper
这时再次运行原测试用例,结果如下
而其实现方式:再扫描实现时看看你的这个类是不是装饰器类,如果是,就缓存起来
而是否是装饰器类就是看是否有以该接口为参数的构造方法
熟悉装饰器模式的人应该一看就懂
再实例化时createExtension,会使用所有装饰器对其装饰一遍,以达到代理的目的
线程安全
这事挺不足道的,但感觉写的挺巧妙就研究一下
源码中线程安全考虑主要是对以上这些缓存,比如 cachedInstances
保存的是一个名称到实例映射的 ConcurrentMap
,但是依然会出问题:
比如两个线程同时要获取相同名称的实例,发现缓存中没有,于是两个线程同时开始进行读取配置创建实例一系列操作,但最终只有先实例化完的线程成功的把实例存入缓存(使用putIfAbsent),而另一个线程就耗费了时间和资源去实例化了一个无用的对象
一般自己写代码这种情况其实也可以接受,至少不会出现bug,只是有点性能浪费
如果想保证只有一个线程加载实例化,用锁即可,比如用synchronized给cachedInstances上锁就可以解决,但是锁的粒度太大,会导致其它实现的实例化过程都被阻塞
Dubbo解决问题的方法还是挺巧妙的:
如果细心看可以发现cachedInstances存储的并不是 ConcurrentMap
public class Holder {
private volatile T value;
public void set(T value) {
this.value = value;
}
public T get() {
return value;
}
}
就是一个存放对象的类,使用volatile保证对象的可见性,初看感觉这个类莫名其妙,继续往下看getExtension
中
以上代码逻辑:当获取某名称实例时,会走getOrCreateHolder
方法在缓存中该名称位置存放一个空Holder对象,且只有第一个线程创建,其它线程只是获取,该方法返回holder对象,通过synchronized给holder上锁,然后检查是否为空,如果为空就创建,这样就保证只有一个线程会实际执行实例化的代码,而且synchronized的锁粒度只是当前名称的实现,不妨碍其它实现的实例化
所以可以说Holder的存在就是为了控制synchronized的锁粒度
其它
DUBBO SPI还支持IOC,并且涉及到一个扩展点自适应机制,相对复杂一点,留下一篇研究~
共有 0 条评论