最近在想系统的学习一下Dubbo的实现原理,本来想着平时使用最多的就是Dubbo的服务注册,就先从这一块着手去学习。但是在看源码的时候发现有的地方显得晦涩难懂,例如Dubbo在发布一个协议的时候,无法理解是Dubbo如何在多个协议中,自动的去找到一个最合适的协议的。
在这种情况下,决定先从Dubbo是如何加载插件入手进行学习,于是就有了这篇Dubbo的SPI机制。
在了解Dubbo的SPI机制之前,我们可以先了解一下什么叫做微内核架构,因为Dubbo的设计采用的就是采用 Microkernel + Plugin 模式,也就是核心系统 + 插件模块的微内核模式。
微内核架构也被称为插件式架构,它是一种面向功能进行拆分的架构模式,除了我们接下来要聊的Dubbo以外,我们日常使用的IDEA、Eclipse这类IDE软件,操作系统,银行系统等都是采用了这种架构模式。
微内核架构包含两类组件:核心系统和插件模块。
上图中的核心系统是相对比较稳定的,随着业务或需求的变化,我们只需要修改插件模块,或引入新的插件模块即可。将变化封装在插件里面,达到快速灵活的扩展的目的,而且也不会影响架构的整体稳定性。
微内核架构设计的关键点有三个:插件管理、插件连接、插件通信。
Dubbo在设计之初就保持一个原则,就是Dubbo的架构一定要有高度的扩展能力,方面使用者自行扩展,这也是为什么Dubbo选择使用微内核架构。
Dubbo的设计原则
对于Dubbo来说,它的功能都是通过扩展点来实现的,并且所有的扩展点都是可以被用户自定义替换的。
同时,使用URL来携带配置信息,贯穿Dubbo的整个生命周期,所有在Dubbo生命周期中使用到的扩展点,都会体现在URL上。
例如下面就是一个简单的Dubbo接口对应url。
dubbo://192.168.0.111:20882/com.ls.dubbo.api.HelloApi?anyhost=true&application=spi-provider&cluster=failfast&deprecated=false&dubbo=2.0.2&dynamic=true&generic=false&interface=com.ls.dubbo.api.HelloApi&loadbalance=leastactive&metadata-type=remote&methods=sayHello&pid=5888&release=2.7.8&retries=1&revision=1.0&side=provider&threads=100×tamp=1642085450506&version=1.0
为了更清楚的显示配置的内容,下面把这个URL美化了一下:
dubbo://192.168.0.111:20882/com.ls.dubbo.api.HelloApi
anyhost=true
application=spi-provider
cluster=failfast
deprecated=false
dubbo=2.0.2
dynamic=true
generic=false
interface=com.ls.dubbo.api.HelloApi
loadbalance=leastactive
metadata-type=remote
methods=sayHello
pid=5888
release=2.7.8
retries=1
revision=1.0
side=provider
threads=100
timestamp=1642085450506
version=1.0
从上面至少可以看出使用的协议、IP端口号、接口地址、负载均衡策略等等,URL和后面要聊到的扩展点息息相关。
下面是一张从官网的扒下来架构图,左边蓝色的部分是Consumer
端使用的,右边绿色的部分是Provider
端使用的,中轴线上则是两端共用部分。
重点看一下最右侧的两个标记:
API
:指的是框架的使用者需要使用的部分,例如我们日常开发中写的接口,配置文件等。SPI
:指的是框架的开发者或者拓展者使用的部分,例如负载均衡策略、集群容错策略、协议、序列化方式等等,属于SPI这部分的,就是可以拓展的拓展点。为了更加直观的感受一下Dubbo的拓展点,我又扒了另外一张图调用链路图下来,在这张图中,目之所及的所有的绿色的节点,都是Dubbo的扩展点。
对Dubbo
的扩展点有了一点感觉之后,我们现在可以尝试从微内核架构的两个角度 - 核心系统和插件模块去分析一下Dubbo。
有了上面两幅图的基础,Dubbo的插件模块就十分好理解了,上述的所有的扩展点,就组成了Dubbo的插件模块。
接下来,我们重点分析一下Dubbo的核心系统。
要分析Dubbo的核心系统,首选要找到Dubbo的核心功能是什么。
对于这个问题,我是这么理解的,Dubbo首先是一个RPC框架,它最主要的是RPC的远程调用功能,其次才是一个分布式治理框架,加入了许多分布式治理的插件。
那什么是RPC呢?
RPC翻译过来是远程过程调用,就体现在这个远程上,如果让我去实现一个最简单的RPC调用,不用考虑用户可以透明调用、容错、负载、传输性能等等。
那我完全可以直接创建一个TCP连接,用统一的通信协议,统一的序列化方式,把客户端和服务端连起来,然后就可以传输数据了。
远程调用使用到的传输协议,序列化方式等,对应Dubbo架构中的Protocol层,及Protocol层下面的Remoting部分包含的三层。
那对于Dubbo来讲,这个几层就是它的核心系统了吗?
对于Dubbo
的核心层,官网上是这么说的:
在 RPC 中,Protocol 是核心层,也就是只要有 Protocol + Invoker + Exporter 就可以完成非透明的 RPC 调用
这句话中提到了3个概念:Protocol
,Invoker
, Exporter
Dubbo
, REST
, HTTP
,inJVM
等等。有了这几个概念的理解,那这句话的意思就很明白了,Dubbo
最核心的层就是Protocol
,远程连接、序列化等也不是必须要的。
因为Dubbo在设计时考虑了一个场景,即Provider和Consumer在同一个JVM中运行,这种情况下完全可以将远程调用直接转换成本地调用,这就是上面提到的inJVM
协议的作用。
那Protocol就是Dubbo的核心系统吗?
我们再回过头看一下,核心系统的职责:负责加载插件,只包含使系统可运行的最少功能。
显然,Protocol
并没有加载插件的能力,而且Protocol本身也是可以扩展的。
Dubbo的扩展能力是通过SPI机制来实现的,而它的核心系统应是下面我们聊到的Dubbo的扩展类加载器 - ExtensionLoader
。
SPI 全称为 Service Provider Interface,翻译为: 服务提供程序接口。
它是一种服务发现机制,本质就是将接口的实现类的完全限定名配置在文件中,服务在运行的过程中,可以通过加载器读取配置文件并加载实现类,从而达到在运行时动态的为接口替换实现。
如何更通俗的理解SPI?
其实SPI与我们日常工作中使用到的API接口是有相似之处的,我们不妨先看一看API的实现方式。
API的实现方式
API的实现方式对我们来说已经非常简单了,服务的提供方对外提供API接口,调用方直接引用接口进行调用。
下面是我撸了一张简图:
我们接下来看SPI的实现方式,感受一下两者的区别与相似之处。
SPI的实现方式
用说人话的方式来描述,SPI就是服务的调用方定义接口规则,交由服务提供方去做实现。
调用方:只关系获取到的结果,而不关心接口内部如何实现的。
提供方:只需要按照服务使用方提供的规则进行实现即可。
综上,API和SPI都是服务调用方依赖接口而不依赖具体的实现,区别在于接口的规则是由提供方制定,还是由调用方制定。
在提供方完成了SPI接口的实现之后,该如何交给调用方使用呢?
其实上面已经提到了,将实现类的完全限定名写在文件中,按照约定优于配置的原则,提供方将这个文件放到一个约定好的位置,调用方去扫描这个位置的文件,就可以获取到完全限定名,通过类加载器将实现类加载到服务中就可以使用了。
下面是几种常见的SPI实现机制。
简单的实现一个Java对SPI的应用,只需要4步。
ServiceLoader
加载此Interface下的所有实现。其中,1、4是服务调用方做的,2、3由服务提供方实现,下面是一个简单的代码实现示例。
public interface JavaSpi {
void sayHello();
}
public class JavaSpiA implements JavaSpi {
@Override
public void sayHello() {
System.out.println("Hello! I'm JavaSpiA");
}
}
public class JavaSpiB implements JavaSpi {
@Override
public void sayHello() {
System.out.println("Hello! I'm JavaSpiB");
}
}
配置文件
在约定的位置META-INF/services
,按照Interface的完全限定名作为文件名,如com.ls.dubbo.consumer.spi.java.JavaSpi
创建配置文件。
加载实现类
做完了上面的步骤之后,就可以使用ServiceLoader
加载了。
@Test
public void testJavaSpi() {
ServiceLoader<JavaSpi> javaSpis = ServiceLoader.load(JavaSpi.class);
System.out.println("Java SPI");
javaSpis.forEach(JavaSpi::sayHello);
}
最后打印出结果:
Dubbo没有直接使用Java的SPI,而是在这基础上重新实现了一套功能更强的SPI机制。
为什么不直接使用Java的SPI呢?
Dubbo之所以不直接使用Java的SPI机制,主要是两个方面的考虑:
第一个是性能方面的考虑:
从上面的示例也看看出,ServiceLoader
一次性将JavaSpiA,JavaSpiB
都加载出来,如果我只想使用JavaSpiA
而恰好JavaSpiB
的初始化过程又比较慢的时候,就会影响到对JavaSpiA
的使用体验。
我们可以以负载均衡策略来想象一下,在2.7.8版本中默认的负载均衡策略有5种,如果我在项目中只需要使用到默认的random
策略,其他4种策略是完全不需要初始化的。
第二个功能增强:
Dubbo对扩展点之间的通信提供了IoC
与AOP
的增强,可以通过setter
注入的方式来注入其他的扩展点。
Dubbo的SPI简单使用
与Java的SPI实现方式非常类似,同样也是4步:
@SPI
注解,标记为Dubbo的扩展点。META-INF/Dubbo
目录下按照约定创建配置文件。ExtensionLoader
加载扩展点。实现如下:
扩展点代码实现
// Dubbo 的扩展点接口需要加上@SPI注解标记
@SPI
public interface DubboSpi {
void sayHello();
}
public class DubboSpiA implements DubboSpi {
@Override
public void sayHello() {
System.out.println("Hello! I'm DubboSpiA");
}
}
public class DubboSpiB implements DubboSpi {
@Override
public void sayHello() {
System.out.println("Hello! I'm DubboSpiB");
}
}
配置文件
Dubbo的配置文件名还是接口的完全限定名,但填充的内容变成了key
,value
的形式。
扩展点实现加载
优化了Java的SPI中一次性把接口下的实现全部加载的情况。
Dubbo的SPI可以根据配置文件中的key
按需加载,如图所谓,想加载哪个就加载哪个。
如何拓展Dubbo生命周期中的组件?
上边看到的是Dubbo的SPI的简单使用方式,但我们在日常开发中更加需要的可能是对Dubbo生命周期中的某个组件进行扩展和替换。
在上面2.1中,Dubbo的调用流程图中已经看到过了,那些绿色的节点就是Dubbo预留了扩展点接口,并且提供了一系列的默认实现,我们可以选择使用哪一个默认实现。如果这些默认实现都不满足要求,我们也可以根据扩展点接口做自定义实现。
Dubbo约定好配置文件的存放目录一共有3个:
META-INF/dubbo/internal
:存放Dubbo内部已经定义好的扩展点实现META-INF/dubbo
:存放用户自定义的扩展点实现META-INF/services
:用于兼容Java的SPI一般情况下,只有我们在做拓展的时候,才会使用到META-INF/dubbo
这个文件路径,另外两个我们自己会用到的情况比较少。
再回到我们的主题,如何拓展Dubbo已有的生命周期组件。
其实非常简单,以负载均衡的拓展为例,先找到Dubbo提供的LoadBalance
接口,然后按照上面的步骤做一遍就可以了。
聊到这里,问题来了,难道我们在日常开发中拓展的实现,还需要我们自己手动通过getExtension
去加载吗?上图中的@Adaptive("loadbalance")
又是什么意思呢?
我们接下来就去看一下,Dubbo对于扩展点的加载方式。
在Dubbo中的扩展点一共有三种加载方式,分别为:
就是上面代码中写的拓展点的加载方式,指定一个key
去进行加载,例如:
ExtensionLoader.getExtensionLoader(LoadBalance.class).getExtension("random");
如何实现的呢?
上面的代码可以看到,获取扩展点的实现分为了两步:
key
作为标识,获取扩展点实例。获取extensionLoader:
尝试从缓存中获取类加载器,获取不到就创建一个。
可以看到,类加载器本身的也就通过ExtensionFactory
这个类加载器来实现的,那么一定有一个默认的类加载器。
这里是通过getAdaptiveExtension()
做扩展点自适应,来获取默认的ExtensionFactory
的,这个后面再提。
获取扩展实例:
如果忽略缓存的逻辑,扩展实例对象的获取一共分为5个步骤:
key
,value
加载到一个Map中。key
获取到对应的实现类的完全限定名。依赖注入是通过setter方法来注入的,获取到对应的setter方法的参数,通过参数获取到扩展点,再注入到当前的扩展点中。
以Protocol为例来解释一下包装,通过配置文件可以看到,Protocol
这个扩展接口有包装类的实现,例如:
在ProtocolFilterWrapper
中,使用构造方法做了一下包装,包装的目的就是为了增强结构的功能,例如这个Filter的包装就是为了在执行方法调用的时候,可以进入到过滤器链中。
通过以上的处理,就可以获取到一个扩展点的实例了。
一个简化的流程图如下所示:
扩展点自适应就是通过上下文信息,取自动的选择一个合适的扩展点进行加载。这里的上下文信息,其实就是Dubbo的URL。
扩展点要做到自适应,需要标记上@Adaptive
注解,这个注解可以加在类上,也可以加载方法上。
加在类上:表示在使用getAdaptiveExtension()
,直接返回这个类的实例对象。
我们在4.1中分析getExtensionLoader
源码的时候出现的扩展点自适应加载,就是这种类型。
ExtensionLoader.getExtensionLoader(ExtensionFactory.class).getAdaptiveExtension();
其中AdaptiveExtensionFactory
就是一个可以自适应的扩展点,所以上面的结果就是返回AdaptiveExtensionFactory
的实例。
加在方法上:会在通过动态代理在运行时生成一个代理对象,重写打了@Adaptive
注解的方法,在重写的会解析URL
,获取上下文参数中的key
,再通过指定名称进行加载。
以Protocol
接口为例,这个接口中两个方法标注了@Adaptive
注解,分别是export和refer。
@SPI("dubbo")
public interface Protocol {
@Adaptive
<T> Exporter<T> export(Invoker<T> invoker) throws RpcException;
@Adaptive
<T> Invoker<T> refer(Class<T> type, URL url) throws RpcException;
}
将这个代码美化了一下,可以直观看到,扩展点的自适应加载就是在从URL
中获取协议的值,如果没有获取到就默认使用Dubbo协议,然后使用获取到的协议名称通过指定名称的加载方式来加载扩展点。
结合上面的流程图,就是这个样子:
对于可以同时加载多个实现的集合类型的扩展点,使用扩展点的自动激活,可以起到简化配置的作用。
所谓的集合类型的扩展点,就是类似于Filter
这样的接口,在一次请求中,需要执行的可能不是一个过滤器,而是过滤器链。我们就可以使用扩展点自动激活的方式去拓展这个过滤器链。
或获取一个可以自动激活的扩展点,只需要在扩展点的实现上加入@Activate
注解就可以了,例如:
@Activate
public class MyActiveFilter implements Filter
有时候还需要区分过滤器是属于provider
还是consumer
端,可以使用group
进行区分,例如标记一个只会在provider
端自动激活的过滤器:
@Activate(group = PROVIDER)
public class MyActiveFilter implements Filter
除此之外,如果需要满足某个条件才能触发,还可以使用value
进行标识,例如在URL中出现了myActiveFilter
就自动激活:
@Activate(group = PROVIDER, value = "myActiveFilter")
public class MyActiveFilter implements Filter
如何剔除过滤器?
如果在某些场景下,我们自定义过滤器是为了替换Dubbo原有的默认过滤器,在配置文件中剔除即可,例如,在provider
中剔除默认的exception
过滤器,只需要在配置文件中加入:
dubbo.provider.filter=-excepton
-
:表示剔除。
先说一个我在测试时遇到的坑,其实是Idea
的锅。
我在创建META-INF/dubbo
目录的时候,没有注意创建包路径与创建文件夹路径的区别,习惯性的创建。
在这种路径下放的配置文件,无论如何都加载不到,最后才发现的文件夹路径的问题,可以看一下Idea中的文件夹路径,正确的路径和错误的路径显示的一模一样:
在Idea
上开发需要注意这一点。
以Filter为例,先创建一个扩展类实现。
import org.apache.dubbo.rpc.*;
/**
* @author liushuang
*/
public class MyFilter implements Filter {
@Override
public Result invoke(Invoker<?> invoker, Invocation invocation) throws RpcException {
System.out.println("方法调用前执行");
Result result = invoker.invoke(invocation);
System.out.println("方法调用后执行");
return result;
}
}
此时,扩展点已经加入到Dubbo中了,此时可以写一个Dubbo接口的调用过程,在@Reference中指定filter
@Service
public class HelloService {
@DubboReference(version = "1.0", filter = {"myFilter"})
private HelloApi helloApi;
public String sayHello(String name) {
return helloApi.sayHello(name);
}
}
最后,去调用sayHello
方法,从控制台输出的内容可以确定,已经进入了Myfilter做过滤操作。
扩展点自动激活的实现方式
对于Filter
这种集合性质的扩展点,可以使用自动激活的方式,使用这种方式的话,在注解上都不需要加入标识了,例如在Provider
端加入一个统计过滤器:
@Activate(group = PROVIDER)
@Component
public class MyActiveFilter implements Filter {
private MyCounter myCounter;
@Autowired
public void setMyCounter(MyCounter myCounter) {
this.myCounter = myCounter;
}
@Override
public Result invoke(Invoker<?> invoker, Invocation invocation) throws RpcException {
System.out.println("进入自定义过滤器");
if ("sayHello".equals(invocation.getMethodName())) {
myCounter.count();
}
return invoker.invoke(invocation);
}
}
然后在配置文件上写上,就可以生效了。