本专栏对应Dubbo版本:
2.7.8
。官方文档地址:dubbo.apache.org/zh/docsv2.7…
官方GitHub地址:github.com/apache/dubb…
前言
这篇文章我们主要学习Dubbo中的SPI机制,为什么专栏开篇我们就要学些SPI呢?主要是因为,Dubbo采用的是Microkernel(微内核)+Plugin(插件)
的设计方式,Microkernel 只负责组装 Plugin,Dubbo 自身的功能也是通过扩展点实现的,也就是 Dubbo 的所有功能点都可被用户自定义扩展所替换。
上面这段话当然不是我瞎掰的,其实是官网原话
基于此,我们想要对Dubbo有深入的了解,首先要学习便是其SPI机制的实现原理。
在聊到SPI时,大家可以首先想起的是jdk中原生的SPI机制,所以我们碰到的第一个问题来了:jdk中已经有了SPI,为什么Dubbo还要自己实现SPI呢?其实官网已经给出了标准答案
如果你仅仅是为了应付面试,ok,背吧,八股文而已。
但是如果你想更了解这个框架,我们不应该仅仅追求答案是什么,更应该思考的是:为什么是这个答案?
为了说清楚这个问题,我们先需要对jdk中的SPI做一个简单介绍
JDK中的SPI
示例
-
准备一个SPI接口及其实现类
public interface SpiService { void say(String words); } // 内置的默认实现 public class InternalSpiServiceImpl implements SpiService { @Override public void say(String words) { System.out.println("dmz say " + words); } }
-
在
/META-INF/services
目录下建一个跟接口同名的文件|--resources | |--META-INF | | |--services | | | |--com.easy4coding.jdk.spi.service.SpiService
-
运行测试代码
public class JdkSPI { public static void main(String[] args) { // 核心类就是ServiceLoader final ServiceLoader<DmzService> load = ServiceLoader.load(DmzService.class); final Iterator<DmzService> iterator = load.iterator(); while (iterator.hasNext()) { iterator.next().say("hello"); } } }
如果是第一次了解jdk的SPI的同学应该会有一个疑问,为什么jdk中的SPI要使用迭代器来获取到对于的扩展实现类呢? 按照我们的直觉似乎下面的代码更加合理
DmzService dmzService = ServiceLoader.load(DmzService.class);
因为绝大部分情况下,我们真正想要加载及使用的类只会有一个。
事实上,尽管我们真正使用的类只有一个,但是问题在于,jdk并不能确认你到底需要使用哪一个,默认使用第一个加载的吗?还是最后一个呢?好像都不合适,如果仅仅依赖加载顺序的话,整个程序是不可控的,最终加载并使用的类跟程序的启动命令有关。
我们可以看一个例子:
假设main.jar是我们的应用程序,ext.jar是我们依赖的一个jar包,在这个jar包中使用了SPI的方式对外提供了扩展能力,并提供了默认实现。现在我们想覆盖其默认实现,使用我们main.jar中自定义的实现类,而当我们下面两个启动命令启动应用程序时,整个程序的资源加载顺序是完全不一样的
java -cp ext.jar:a.jar:b.jar:main.jar example.Main
java -cp main.jar:ext.jar:a.jar:b.jar example.Main
如果使用的是第一条命令,main.jar中的所有资源是最后加载的,而使用第二条命令,这些资源将是最先加载的。
所以,当我们实际使用jdk中的SPI时,往往会自己制定加载策略,这里我们可以看一看Apollo中的SPI的使用
// 这个类是Apollo对jdk中SPI的封装
public class ServiceBootstrap {
// 加载第一个
public static <S> S loadFirst(Class<S> clazz) {
Iterator<S> iterator = loadAll(clazz);
return iterator.next();
}
// 先加载并实例化所有的扩展点,排序后取第一个
public static <S extends Ordered> S loadPrimary(Class<S> clazz) {
List<S> candidates = loadAllOrdered(clazz);
return candidates.get(0);
}
// 简单封装了jdk中的ServiceLoader
public static <S> Iterator<S> loadAll(Class<S> clazz) {
ServiceLoader<S> loader = ServiceLoader.load(clazz);
return loader.iterator();
}
public static <S extends Ordered> List<S> loadAllOrdered(Class<S> clazz) {
Iterator<S> iterator = loadAll(clazz);
List<S> candidates = Lists.newArrayList(iterator);
Collections.sort(candidates, new Comparator<S>() {
@Override
public int compare(S o1, S o2) {
// the smaller order has higher priority
return Integer.compare(o1.getOrder(), o2.getOrder());
}
});
return candidates;
}
}
笔者在实际工作中也大多是这种用法,大致可以分为两种
loadFirst
:使用最先加载的那一个,这种情况下只会加载并实例化一个SPI实现类loadPrimary
:自定义优先级,选取优先级最高的那一个,这种情况下需要加载并实例化所有的实现类,拿到实例化后的对象后才能进行排序
前文已经说过了,依赖类的加载顺序程序的健壮性就变低了,而自定义优先级进行加载的话,又需要一次性实例化SPI接口的所有实现,如果某一个实现非常消耗资源但是实际我们又没有使用的话,就非常浪费。这就是我们在使用原生SPI时会碰到的一个最大的缺陷。
总结
在上文中我们分析了jdk中的SPI,知道了原生的SPI的问题所在,也就是Dubbo官网中提高的第一点:JDK 标准的 SPI 会一次性实例化扩展点所有实现,如果有扩展实现初始化很耗时,但如果没用上也加载,会很浪费资源。 除此之外,官网中还提到了两点
- 如果扩展点加载失败,连扩展点的名称都拿不到了。比如:JDK 标准的 ScriptEngine,通过
getName()
获取脚本类型的名称,但如果 RubyScriptEngine 因为所依赖的 jruby.jar 不存在,导致 RubyScriptEngine 类加载失败,这个失败原因被吃掉了,和 ruby 对应不起来,当用户执行 ruby 脚本时,会报不支持 ruby,而不是真正失败的原因。 - 增加了对扩展点 IoC 和 AOP 的支持,一个扩展点可以直接 setter 注入其它扩展点。
对于提到的:如果扩展点加载失败,连扩展点的名称都拿不到了。其实这个更加看使用方如何处理异常,在官网提到的ScriptEngine这个例子中,确实选择吞掉了异常
可以看到上面的代码直接catch了一个error,不过这并不是SPI的问题,而是看使用者如何处理,如果我们选择不catch任何异常,自然可以抛出异常的具体原因。
至于官网中另外提到的依赖注入及AOP,存粹是Dubbo自己做的一个功能扩展罢了。
Dubbo中的SPI
上文中我们已经分析了jdk中SPI的缺陷,Dubbo实现自己的SPI机制自然改善了这些缺陷,同时Dubbo自己做了一些扩展。
- Dubbo可以加载指定名称的扩展点,避免了实例化不必要的SPI实现
- 在实例化扩展点的过程中可以完成AOP及IOC(依赖注入)
我们来看一个使用示例
基本使用示例
-
准备一个SPI接口及其实现类
// 注意,接口上必须要加上SPI注解 @SPI public interface SpiService { void say(String words); } // 一个内部的默认实现 public class InternalSpiServiceImpl implements SpiService { @Override public void say(String words) { System.out.println("dmz say " + words); } } // 另外一个实现类 public class OtherImpl implements SpiService { @Override public void say(String words) { System.out.println("others say " + words); } }
-
在
/META-INF/dubbo
目录下建一个跟接口同名的文件|--resources | |--META-INF | | |--dubbo | | | |--com.easy4coding.dubbo.spi.service.SpiService
文件内容如下:
# "="号前面的internal代表Dubbo中SPI扩展点的名称 internal=com.easy4coding.dubbo.spi.service.impl.InternalSpiServiceImpl other=com.easy4coding.dubbo.spi.service.impl.OtherImpl
-
测试代码
public class DubboSPI { public static void main(String[] args) { final ExtensionLoader<SpiService> extensionLoader = ExtensionLoader.getExtensionLoader(SpiService.class); // 这里指定加载name为internal的扩展点 extensionLoader.getExtension("internal").say("hello dubbo"); extensionLoader.getExtension("other").say("hello dubbo"); } }
从这个例子中我们可以看到,Dubbo提供了API能让我们直接创建指定的扩展点对象,避免了实例化所有扩展点实例。
AOP示例
Dubbo中SPI的AOP是通过装饰的方式实现的,如果要对某一个扩展点进行AOP增强,我们需要提供一个wrapper
类。例如,我们想要对上面的SpiService
进行增强,我们可以新增如果一个wrapper类
public class SpiServiceWrapper implements SpiService {
private final SpiService spiService;
// ❤️ 一定要有这个构造方法
// 这是Dubbo判断这个类是否是一个wrapper的关键
// 构造方法只有一个参数,即你想进行增强的类
public SpiServiceWrapper(SpiService spiService) {
this.spiService = spiService;
}
@Override
public void say(String words) {
System.out.println("do something before");
try {
spiService.say(words);
} catch (Exception e) {
e.printStackTrace();
System.out.println("do something when occur error");
}
System.out.println("do something after");
}
}
除此之外,还需要在之前提到的文件中加入这个类的全名
internal=com.easy4coding.dubbo.spi.service.impl.InternalSpiServiceImpl
other=com.easy4coding.dubbo.spi.service.impl.OtherImpl
# 这一行是新增的配置,wrapper类不需要配置名称
com.easy4coding.dubbo.spi.service.wrapper.SpiServiceWrapper
代码运行结果如下:
do something before
dmz say hello dubbo
do something after
可以看到通过这个方式,我们在Dubbo中就能对SPI实现类进行AOP增强
IOC示例
我们还是以之前的代码为例,我们在InternalSpiServiceImpl
中添加了一个setter方法,如下:
public class InternalSpiServiceImpl implements SpiService {
private OrderService orderService;
@Override
public void say(String words) {
System.out.println("dmz say " + words);
}
// 新增一个方法用于测试
// 如果没有注释掉之前的wrapper类的话,记得要在wrapper中实现新增的pay方法,并调用此类中的方法
// 希望大家不要纠结我为啥用Integer代表金额,只是为了方便
@Override
public void pay(Integer money) {
orderService.pay(money);
}
// ❤️ setter方法是必须的
public void setOrderService(OrderService orderService) {
this.orderService = orderService;
System.out.println("setter invoke " + orderService);
}
}
我们期望在创建InternalSpiServiceImpl
这个扩展点实例时,同时程序会帮我们注入OrderService
。为了达到这个目的,我们需要确保
OrderService
是一个SPI接口OrderService
存在(Adaptive SPI)自适应扩展
实现,关于自适应扩展
我们会在下节中介绍
关于OrderService
我们有3个实现类如下:
// ❤️ 注意这个@Adaptive注解
// 通过这个注解表明这是OrderService的一个自适应扩展实现
@Adaptive
public class OrderAdaptiveService implements OrderService {
@Override
public void pay(Integer money) {
// 根据支付的金额决定实际使用哪种支付方式
if (money > 100) { ExtensionLoader.getExtensionLoader(OrderService.class).getExtension("card").pay(money);
} else { ExtensionLoader.getExtensionLoader(OrderService.class).getExtension("cash").pay(money);
}
}
}
// 现金支付
public class CashServiceImpl implements OrderService {
@Override
public void pay(Integer money) {
System.out.println("使用现金支付:" + money);
}
}
// 信用卡支付
public class CardServiceImpl implements OrderService {
@Override
public void pay(Integer money) {
System.out.println("使用信用卡支付:" + money);
}
}
最后不要忘记在/META-INF/dubbo
目录下新建一个接口同名文件,内容如下:
com.easy4coding.dubbo.spi.service.ioc.OrderAdaptiveService
card=com.easy4coding.dubbo.spi.service.ioc.CardServiceImpl
cash=com.easy4coding.dubbo.spi.service.ioc.CashServiceImpl
测试代码如下:
public static void main(String[] args) {
final ExtensionLoader<SpiService> extensionLoader = ExtensionLoader.getExtensionLoader(SpiService.class);
final SpiService internal = extensionLoader.getExtension("internal");
internal.pay(200);
internal.pay(50);
}
程序输出:
setter invoke com.easy4coding.dubbo.spi.service.ioc.OrderAdaptiveService@73a8dfcc
使用信用卡支付:200
使用现金支付:50
可以看到通过上面的例子,我们不仅实现了依赖注入,还可以根据传入的参数调用不同的service。
在上面的例子中,我们提到了(Adaptive SPI)自适应扩展
,什么叫自适应扩展
呢?一句话总结:可以根据不同的参数,调用不同的扩展点实现,类似于策略模式、适配器模式,接下来我们就来学习Dubbo中的自适应扩展。
自适应扩展
首先我们要明白,自适应扩展的核心在于:可以根据不同的参数,调用不同的扩展点实现,精髓就是可以根据参数去适配不同的实现。Dubbo提供了两种自适应扩展的方式
- 直接将
@Adaptive
注解添加到具体的实现类上,由使用者自己实现适应的逻辑。我们在之前的例子中使用的就是这种方式,OrderAdaptiveService
上添加了@Adaptive
注解,同时在其pay
方法中,我们实现了根据金额不同来使用不同实现类进行支付,当金额超过100时,我们调用的是CardServiceImpl
,当金额低于100时,我们调用的是CashServiceImpl
。 - 实际上另外一种更加常见的方式是通过将
@Adaptive
注解添加到SPI接口中的方法上,Dubbo会自动生成一个对应的实现类,在这个实现类中Dubbo会帮我们去做适配。接下来的示例使用的便是这种方式
示例
@SPI
public interface AdaptiveSpi {
/**
* 注意这个方法有两个特征
* <p>
* 1.方法上有adaptive注解,注解中我们给了一个值"adaptive",注意这个值
* <p>
* 2.方法的参数中有一个URL,需要注意的是这是一个org.apache.dubbo.common.URL,不是java.net.URL
*/
@Adaptive("adaptive")
void adaptiveMethod(URL url);
}
// 第一个扩展点
public class FirstAdaptiveSpiImpl implements AdaptiveSpi {
@Override
public void adaptiveMethod(URL url) {
System.out.println("first");
}
}
// 第二个扩展点
public class SecondAdaptiveSpiImpl implements AdaptiveSpi {
@Override
public void adaptiveMethod(URL url) {
System.out.println("second");
}
}
注意,AdaptiveSpi
是一个Dubbo的扩展点,所以需要在/META-INF/dubbo
建一个接口同名文件,并加入以下内容
first=com.easy4coding.dubbo.spi.service.adaptive.FirstAdaptiveSpiImpl
second=com.easy4coding.dubbo.spi.service.adaptive.SecondAdaptiveSpiImpl
测试方法
public static void main(String[] args) {
//❤️ 注意这里使用的API发生了变化
final AdaptiveSpi adaptiveExtension = ExtensionLoader.getExtensionLoader(AdaptiveSpi.class).getAdaptiveExtension("");
//❤️ 注意这里URL的区别哈,我们在URL后面拼接了一个key-value,
// key为adptive,value是我们想要使用的扩展点的名称:second,key要跟我们在@Adaptive注解中指定的值保持一致
// 这里程序会输出:second,代表我们实际使用的扩展点实现是SecondAdaptiveSpiImpl
adaptiveExtension.adaptiveMethod(URL.valueOf("dubbo://easy4coding.com?adaptive=second"));
//❤️ 这里URL后的参数是adaptive=first
// 这里程序会输出:first,代表我们实际使用的扩展点实现是FirstAdaptiveSpiImpl
adaptiveExtension.adaptiveMethod(URL.valueOf("dubbo://easy4coding.com?adaptive=first"));
}
在上面的例子中大家要注意一下几点
-
自适应扩展也是Dubbo中SPI的一种,所以接口上必须要有
@SPI
注解,此外,我们也要将真正的扩展点实现配置到/META-INF/dubbo
文件中 -
AdaptiveSpi
中必须要有被@Adaptive
注解修饰的方法 -
被
@Adaptive
修饰的方法,必须要有URL
(Dubbo中的)类型的参数,获取参数中存在URL的getter方法,例如在上面的例子中,我们可以在AdaptiveSpi
新增一个方法如下:@SPI public interface AdaptiveSpi { @Adaptive("adaptive") void adaptiveMethod(URL url); @Adaptive("adaptive") void adaptiveMethod(URLHolder url); // 存在一个URL的getter方法 class URLHolder { private final URL url; public URLHolder(URL url) { this.url = url; } public URL getUrl() { return url; } } }
通过以下方式测试,程序仍然可以正常运行
final AdaptiveSpi adaptiveExtension = ExtensionLoader.getExtensionLoader(AdaptiveSpi.class).getAdaptiveExtension(); adaptiveExtension.adaptiveMethod(new AdaptiveSpi.URLHolder(URL.valueOf("dubbo://easy4coding.com?adaptive=second"))); adaptiveExtension.adaptiveMethod(new AdaptiveSpi.URLHolder(URL.valueOf("dubbo://easy4coding.com?adaptive=first")));
小总结
Dubbo中的自适应扩展核心思想就是:根据方法传入的参数去适配一个具体的实现。由于要根据参数进行适配,所以传入的参数必须要遵守一定的规则,一个简单的想法:让所有的参数都实现一个固定的接口,如下:
public interface ExtNameAccessor {
/**
* 通过这个方法暴露真实要使用的扩展点名称
*/
String getExtName();
}
有或者,我们可以约定参数中必须存在一个Map,我们可以通过调用map.getKey("adaptvieKey")的方式来获取到扩展点的名称。
不过,通过上面对Dubbo中的自适应扩展的学习,我们知道Dubbo并没有使用我们上面提到的这些方式,而是约定了
- 参数有必须要有URL类型的参数
- 如果没有的话,必有要能通过某个参数的getter方法获取到URL
总之,必须要能从参数中获取到URL类型的一个实例。Dubbo为什么要这么做呢?不知道大家还记不记得在文开头我给大家展示的一张图
在前文我们只提到了Dubbo的内核+插件的设计原则,除此之外,在官方文档中还提到了:采用URL作为配置新的统一格式,所有扩展点都通过传递URL携带配置信息。正因为URL中携带了所有配置信息,所以Dubbo在设计自适应扩展时,强依赖了URL。
整体来说,Dubbo的自适应扩展并不是很优雅,官方文档也将其列为整个框架的坏味道之一,有兴趣的同学可以查看这个链接:dubbo.apache.org/zh/docsv2.7…
Spring中的SPI
接下来要介绍的是SpringBoot中的SPI,不知道大家有没有发现SPI的几个特点
- 首先,SPI机制,需要有一个配置文件,例如在JDK中的SPI,我们会在
/META-INF/services
建一个接口同名文件,在Dubbo中的SPI,我们会在/META-INF/dubbo
建一个接口同名文件 - 其次,不管哪种SPI机制都会提供一个加载实现类的API,例如JDK中的SPI提供了
ServiceLoader.load
方法,在Dubbo中的SPI提供了ExtensionLoader.getExtensionLoader(xxx.class).get(name)
这一系列API
相对应的Spring中的SPI也提供了这种机制,只不过略有差异,在Spring中配置文件是固定的META-INF/spring.factories
文件,而在我们前文提到的JDK及Dubbo中的SPI,都是一个SPI接口对应一个配置文件。下面是一段SpringBoot中的配置(SpringBoot中的自动装配借助了Spring提供的SPI能力):
# Initializers
org.springframework.context.ApplicationContextInitializer=\
org.springframework.boot.autoconfigure.SharedMetadataReaderFactoryContextInitializer,\
org.springframework.boot.autoconfigure.logging.ConditionEvaluationReportLoggingListener
# Application Listeners
org.springframework.context.ApplicationListener=\
org.springframework.boot.autoconfigure.BackgroundPreinitializer
对应Spring也提供了加载扩展点的API,如下:
SpringFactoriesLoader.loadFactories(接口, 类加载器);
接下来我们来看一个实际使用示例
使用示例
// SPI接口
public interface SpringSpiInterface {
void say();
}
// 下面是两个实现类
public class OtherSpringSpiImpl implements SpringSpiInterface{
@Override
public void say() {
System.out.println("other say hello spring spi ");
}
}
public class SpringSpiInterfaceImpl implements SpringSpiInterface {
@Override
public void say() {
System.out.println("hello spring spi");
}
}
接下来我们还需要编写META-INF/spring.factories
,内容如下:
com.easy4coding.spring.service.SpringSpiInterface=\
com.easy4coding.spring.service.SpringSpiInterfaceImpl,\
com.easy4coding.spring.service.OtherSpringSpiImpl
测试代码如下:
public class SpringSpi {
public static void main(String[] args) {
final List<SpringSpiInterface> springSpiInterfaces = SpringFactoriesLoader.loadFactories(SpringSpiInterface.class, SpringSpi.class.getClassLoader());
springSpiInterfaces.forEach(
SpringSpiInterface::say
);
}
}
// 程序输出:
// hello spring spi
// other say hello spring spi
总结
大家可以看到,在Spring中使用SPI相比于JDK及Dubbo,最大的差异在于Spring将所有SPI接口的配置集中到了一个文件中META-INF/spring.factories
,相比于前两者而言,这种做法更加简洁,查找起来也更加方便。Spring的SPI如果你直接按上文中提到的API使用的话,也会一次性加载并实例化所有的扩展点实现。
不过,目前Spring中的SPI主要使用在SpirngBoot中,SpringBoot通过SPI+条件注解的方式实现了自动装配,同时结合条件注解也避免了一次性创建所有实例的问题。关于SpringBoot的内容,我后面写一篇文章详细介绍!
最终对比
JDK SPI | DUBBO SPI | Spring SPI | |
---|---|---|---|
文件方式 | 每个扩展点单独一个文件 | 每个扩展点单独一个文件 | 所有的扩展点在一个文件 |
获取某个固定的实现 | 不支持,只能按顺序获取所有实现 | 有“别名”的概念,可以通过名称获取扩展点的某个固定实现,配合Dubbo SPI的注解很方便 | 不支持,只能按顺序获取所有实现。但由于Spring Boot ClassLoader会优先加载用户代码中的文件,所以可以保证用户自定义的spring.factoires文件在第一个,通过获取第一个factory的方式就可以固定获取自定义的扩展 |
其他 | 无 | 支持Dubbo内部的依赖注入,通过目录来区分Dubbo 内置SPI和外部SPI,优先加载内部,保证内部的优先级最高 | 无 |
文档完整度 | 文章 & 三方资料足够丰富 | 文档 & 三方资料足够丰富 | 文档不够丰富,但由于功能少,使用非常简单 |
IDE支持 | 无 | 无 | IDEA 完美支持,有语法提示 |
三种 SPI 机制对比之下,JDK 内置的机制是最弱鸡的,但是由于是 JDK 内置,所以还是有一定应用场景,毕竟不用额外的依赖;Dubbo 的功能最丰富,但机制有点复杂了,而且只能配合 Dubbo 使用,不能完全算是一个独立的模块;Spring 的功能和JDK的相差无几,最大的区别是所有扩展点写在一个 spring.factories 文件中,也算是一个改进,并且 IDEA 完美支持语法提示。
唠唠叨叨
本文并没有对各种SPI实现的原理进行深究,更多是想让大家对各种SPI的实现有一个全局的了解。下篇文章我们对Dubbo中SPI的实现进行详细的源码分析!
参考: