Dubbo 之于 SPI 扩展机制的实现分析
SPI (Service Provider Interfaces) 是 JDK 1.5 引入的一种服务扩展内置机制。在面向接口编程的范畴下,SPI 能够基于配置的方式声明应用的具体扩展接口实现。之前在写接口限流器时曾遇到过这样一个场景,针对服务端的限流策略一般需要从多个维度进行控制,比如具体接口、IP、用户、设备,以及调用方等等,假设限流器接口 org.zhenchao.spi.ApiRateLimiter
定义为:
1 | public interface RateLimiter { |
针对该接口在各个维度的实现类定义如下:
1 | org.zhenchao.spi.RateLimiter |
现在的问题是,不同业务所需要的限流维度可能不同,一些业务甚至还需要扩展实现属于自身特有维度的限流策略,如何能够对限流策略进行组合、定制,并站在更高的层次上对这些限流策略进行统一调度?这个时候正是 SPI 机制发挥其作用的时候。
SPI 扩展机制虽然早已存在,但是你可能对其并不熟悉,因为其应用场景更多的是用来编写框架、插件,以及基础组件等,比如 commons-logging、JDBC 中都有 SPI 的身影,但是了解这一机制有时候能够让你在 coding 时多一种思路,从而写出更加优雅的代码。
曾经在阅读一个遗留项目源码时,之前的开发者曾将一个接口部分实现类的 simple name 写在配置文件中,并在程序中读取该配置,循环以包名前缀拼接相应实现类 simple name 的方式得到类的限定名,然后反射创建类对象进行调用,这样的实现除了不够优雅,也阉割了程序的可扩展性,是一种程序设计的坏味道。
SPI 扩展机制除了 JDK 内置的实现外,Dubbo RPC 框架也提供了相应的实现版本,并在功能上进行了增强,接下来将分别介绍 JDK SPI 和 Dubbo SPI 的使用方式,并对 Dubbo 之于 SPI 扩展机制的实现内幕进行分析。
JDK SPI
继续前面给出的例子,如果我们的业务只希望从接口、IP,以及用户 3 个维度实施限流,基于 JDK 内置的 SPI 该如何实现呢?我们首先需要在 /META-INF/services
目录下面新建一个与接口同名的名为 org.zhenchao.spi.RateLimiter
的文件,内容为:
1 | org.zhenchao.spi.ApiRateLimiter |
然后基于 java.util.ServiceLoader
加载 SPI 扩展实现类,具体如下:
1 | ServiceLoader<RateLimiter> rateLimiters = ServiceLoader.load(RateLimiter.class); |
到此,我们就完成了基于 JDK SPI 实现对限流策略的定制化。我们可以在 SPI 配置文件中定义任意维度限流策略的组合,如果已有的策略实现无法满足业务需求,我们也可以实现 RateLimiter 接口定义自己的限流策略,只需要将新增实现类限定名配置到 META-INF/services/org.zhenchao.spi.RateLimiter
中即可。
Dubbo SPI
Dubbo RPC 框架在设计和实现上采用“微内核 + 插件”的方式,具备良好的定制性和可扩展性,整体架构非常简单、精美,即使你工作中不使用它,也建议你阅读一下其源码以学习其中的设计思想。Dubbo 的可扩展性基于 SPI 扩展机制实现,不过它并没有采用 JDK 内置的 SPI,而是自己另起炉灶实现了一套,之所以这样“重复造轮子”,官方给出的理由如下:
- JDK 标准的 SPI 会一次性实例化扩展点所有实现,如果有扩展实现初始化很耗时,但如果没用上也加载,会很浪费资源。
- 如果扩展点加载失败,连扩展点的名称都拿不到了。比如:JDK 标准的 ScriptEngine,通过
ScriptEngine#getName
获取脚本类型的名称,但如果 RubyScriptEngine 因为所依赖的jruby.jar
不存在,导致 RubyScriptEngine 类加载失败,这个失败原因被吃掉了,和 ruby 对应不起来,当用户执行 ruby 脚本时,会报不支持 ruby,而不是真正失败的原因。 - 增加了对扩展点 IoC 和 AOP 的支持,一个扩展点可以直接 setter 注入其它扩展点。
下面继续就前面的例子给出基于 Dubbo SPI 的实现,和 JDK SPI 类似,我们首先需要在 /META-INF/dubbo
目录(Dubbo SPI 也兼容 /META-INF/services
目录)下新建一个与接口同名的名为 org.zhenchao.spi.RateLimiter
的配置文件,内容为:
1 | api=org.zhenchao.spi.ApiRateLimiter |
不同于 JDK SPI,Dubbo 要求被扩展的接口必须采用注解 @SPI
进行修饰,所以 RateLimiter 接口需要更改为:
1 |
|
然后基于 com.alibaba.dubbo.common.extension.ExtensionLoader
类进行调度(如下),ExtensionLoader 是整个 Dubbo SPI 最核心的实现,稍后会对其实现进行详细分析:
1 | ExtensionLoader<RateLimiter> extensionLoader = ExtensionLoader.getExtensionLoader(RateLimiter.class); |
上面的例子仅仅演示了 Dubbo SPI 的基本使用,基本看不出和 JDK SPI 的区别,实际上从例子中我们还是可以初步看出 Dubbo SPI 是按需加载的。Dubbo SPI 基于我们指定的扩展名称加载相应的扩展实现,而 JDK SPI 则是一股脑全部给加载了,这也就是前面列举 Dubbo 为什么 “重复造轮子” 的 第 1 个原因 。相对于 JDK SPI 来说,Dubbo SPI 还是多做了一些,接下来我们继续从源码层面对整个 SPI 扩展机制的设计和实现进行分析。
Dubbo 整个 SPI 的实现位于 com.alibaba.dubbo.common.extension
包下面,代码总量也就 1000 行左右,可谓是短小而精悍,extension 包中的类组织结构如下:
1 | extension |
上面的例子中我们已经接触到了 @SPI
注解和 ExtensionLoader 类,这也是 Dubbo SPI 最核心的两个实现,接下去的分析过程将从这两个类展开。SPI 扩展机制虽然听起来高大上并且好用,但是说得简单点也就是 基于配置指定扩展接口的具体一个或多个实现,解析并反射实例化扩展实现类的过程 ,所以不管实现上怎么添油加醋,也都是围绕着这么一个基本的运行机制展开。
下面首先来看一下注解 @SPI
的定义,Dubbo 通过该注解标识接口是一个扩展接口,接口的实现类能够被 Dubbo SPI 托管,@SPI
的定义比较简单:
1 |
|
该注解仅声明了一个属性,用于指定默认扩展名,Dubbo SPI 的配置一般采用 key=value
的形式,我们可以在注解接口时指定默认实现类的扩展名称。
接下来分析 ExtensionLoader 实现,前面已经提及过这是 Dubbo SPI 最核心的一个实现类,回忆一下前面的例子对于扩展类的调度实际上也就分为 2 步,第 1 步拿到
RateLimiter 对应的 ExtensionLoader 对象,第 2 步调用该对象的 getExtension 方法基于扩展名称获取扩展实现类对象:
1 | 1. ExtensionLoader<RateLimiter> extensionLoader = ExtensionLoader.getExtensionLoader(RateLimiter.class); |
针对每一个 SPI 扩展类型,Dubbo 都会为其绑定一个 ExtensionLoader 对象,上面第 1 步调用 getExtensionLoader(Class<T> type)
方法就是在获取扩展类型对应的 ExtensionLoader,该方法的实现如下:
1 | public static <T> ExtensionLoader<T> getExtensionLoader(Class<T> type) { |
该方法会限制入参必须是被 @SPI
注解的接口类型,这也是 Dubbo 对于扩展类型仅有的约束,在这方面 JDK SPI 则没有相应的限制,扩展类型既可以是类也可以是接口,并且不需要额外注解,不过既然是面向接口编程,所以扩展类型还是推荐设计成接口类型。对于满足约束条件的入参,方法接下去会先尝试从缓存中获取绑定的 ExtensionLoader 实例,这里采用一个线程安全的 ConcurrentHashMap 缓存扩展类型及其绑定的 ExtensionLoader,该属性定义如下:
1 | /** 缓存扩展类型及其绑定的 ExtensionLoader 对象(每一个扩展类型都拥有属于自己的 ExtensionLoader) */ |
对于第一次调用该方法的扩展类型会创建一个新的 ExtensionLoader 对象,并记录到 EXTENSION_LOADERS 中。接下来就可以调用 ExtensionLoader 对象的 getExtension(String name)
方法获取指定的扩展类型实现类实例:
1 | public T getExtension(String name) { |
该方法的执行流程可以概括如下 3 步:
- 如果
name=true
则获取注解默认指定的扩展类型实例; - 尝试从缓存中获取之前创建的扩展类型实例;
- 如果缓存不命中则说明是第一次获取指定扩展类型,执行创建对应的实例。
依据前面对 @SPI
注解实现的介绍,我们知道该注解持有一个 String 类型的属性,允许我们指定当前扩展类型的默认扩展名称,在本方法中如果入参 name=true
则 Dubbo 会认为我们希望获取默认的扩展类型实现,接下来会转而走 getDefaultExtension()
逻辑,默认扩展名称的获取我们会在稍后的解析过程中提及到,方法 getDefaultExtension()
本质上也是调用了 getExtension(String name)
方法,所以这里不展开说明。对于其他有效入参来说,方法会首先尝试获取缓存的实例,这些实例记录在 cachedInstances 属性中,这也是一个线程安全的 ConcurrentHashMap 类型:
1 | /** 记录扩展名称与对应持有扩展类型实例的 {@link Holder} 对象 */ |
对于缓存不命中的的扩展名称,接下来会调用 createExtension(String name)
方法创建扩展类型实例,该方法所做的工作可以概括为:
- 基于扩展名称获取对应的扩展实现类 Class 对象;
- 如果之前没有访问过该 Class 则会基于反射创建相应的实例,并缓存;
- 遍历实例所有的方法,反射调用参数类型为扩展类型的 setter,注入相应的扩展实例属性;
- 应用包装类对实例逐层包装。
上述过程中 3 和 4 是 Dubbo SPI 相对于 JDK SPI 进行的增强,在一些较复杂场景下实为一种有用的设计。方法 createExtension(String name)
的具体实现如下:
1 | private T createExtension(String name) { |
在获取扩展名称对应的实现类 Class 对象之前,先是调用了 getExtensionClasses()
方法,实际上 ExtensionLoader 在多个方法中都有调用该方法,主要是因为通过该方法能够获取到当前 ExtensionLoader 对象所关联的扩展类型对应配置的所有扩展实现类 Class 对象,并在首次访问时触发 SPI 配置的加载过程。该方法的实现如下:
1 | private Map<String, Class<?>> getExtensionClasses() { |
方法利用 cachedClasses 缓存扩展类对应的 SPI 配置,cachedClasses 是一个 Holder 类型的属性,持有 Map 类型的数据,其中 key 为扩展名称,value 为对应的扩展实现类 Class 对象,属性定义如下:
1 | /** 记录正向映射关系 */ |
对于首次调用而言会触发调用 loadExtensionClasses()
方法,该方法的主要作用就是从 META-INF/dubbo/internal/
、META-INF/dubbo/
,以及 META-INF/services/
目录下检索 SPI 配置,并执行加载和解析过程:
1 | private Map<String, Class<?>> loadExtensionClasses() { |
方法首先会解析 @SPI
注解中配置的默认扩展名称,并记录到属性 cachedDefaultName 中,Dubbo 要求只能指定一个默认的扩展名称。然后会调用 loadFile(Map<String, Class<?>> extensionClasses, String dir)
方法从之前所列举的各个目录中检索、加载,以及解析 SPI 配置,最终以扩展名称为 key,对应配置的扩展实现类 Class 对象为 value 记录到 Map 集合中,整个过程这里不展开说明,比较简单。
下面继续来看一下 injectExtension(T instance)
方法,前面曾提到过借助该方法,Dubbo SPI 能够调用参数类型同样为扩展类型的 setter 方法注入相应的扩展实现类对象到当前实例中,这是 JDK SPI 所不具备的。该方法的实现如下:
1 | private T injectExtension(T instance) { |
方法一开始就判断属性 objectFactory 是否为 null,该属性在 ExtensionLoader 的构造函数中被创建(如下),然后方法会依据 java bean 定义规范筛选出 setter 方法,并以此得到 setter 方法的参数类型及其对应的属性名称,然后基于 ExtensionFactory#getExtension
方法获取相应的 SPI 扩展实现类实例注入到当前实例中。
1 | private ExtensionLoader(Class<?> type) { |
对于除 ExtensionFactory 以外的其他扩展类型来说,这里本质上返回的是 AdaptiveExtensionFactory 类型,Dubbo 默认对于 ExtensionFactory 的扩展配置如下:
1 | adaptive=com.alibaba.dubbo.common.extension.factory.AdaptiveExtensionFactory |
由于 AdaptiveExtensionFactory 采用注解 @Adaptive
修饰,所以在加载扩展配置时会将其 Class 对象记录到类属性 cachedAdaptiveClass 中,这是一个适配器类,其中持有了配置的 ExtensionFactory 扩展实现类实例,当我们调用 AdaptiveExtensionFactory#getExtension
方法获取指定扩展类型实例时,实际上是在遍历应用其持有的 ExtensionFactory 实例。
扩展点自适应实际上也是 Dubbo SPI 增强的特性之一(“重复造轮子” 中列举的 第 3 个原因 ),这一机制依赖于 @Adaptive
注解,上面对于该注解的应用是修饰在类型上,实际上该注解更多的应用场合在于修饰方法。前面我们曾介绍过 Dubbo SPI 在构造扩展类型实例时,针对实例的参数类型同样为扩展类型的 setter 会执行依赖注入,这个时候注入的并不是一个具体的扩展类型,而是一个扩展类型的适配器类实例。例如在对扩展类型 A 实例执行 setter 注入 B 扩展类型时,此时注入的实际上是 B 类型的适配器类型实例(由程序自动生成),而不是具体的 B 类型实现类实例,因为对于一个扩展类型来说我们并不知道当前依赖的具体类型是谁,需要依据 URL 中的入参待到运行时才能决定。
这里以前面的 IpRateLimiter 举例说明,假设 IpRateLimiter 需要依赖于一个 IP 解析器 IpResolver,这是一个接口,围绕该接口有两个具体实现类:LocalIpResolver 和 RemoteIpResolver,接口的定义如下:
1 |
|
依赖于 IpResolver 的 IpRateLimiter 定义如下:
1 | public class IpRateLimiter implements RateLimiter { |
Dubbo SPI 在构造 IpRateLimiter 实例时检测到它有一个参数类型为 SPI 扩展类型的 setter(即 setIpResolver),这个时候就会构造 IpResolver 的适配器类型实例进行注入,相应的适配器实现由程序按照规则自动生成。待到实际执行 IpResolver#resolve
方法时,会从入参 URL 中查询 key 为 resolver
的参数值,以此决定具体注入的扩展类型实例。
这一自适应机制简单的说就是在依赖注入时先用一个适配器类实例占坑,待到运行时再动态代理到具体的实现类去执行相应的操作。这是一个比较常用且优雅的设计,记得之前在设计动态 IoC 时就采用了类似的实现机制,针对一个 service 类存在多个实现,具体使用哪个实现类需要等到运行时才能依据入参决定,但是 Spring IoC 又是在容器启动时完成 singleton 类型的注入,如果不希望把 Spring IoC 退化成一个大工厂使用,就可以在依赖注入时先注入一个适配器类实例,并在运行时由该实例动态代理具体的 service 实现类。
关于上述扩展点自适应机制,下面一起来看一下相应的源码实现,这里将焦点聚焦到 ExtensionLoader#getAdaptiveExtension
方法,ExtensionLoader 利用属性 cachedAdaptiveInstance 记录当前扩展类型对应的适配器类实例,所以该方法的逻辑就是先尝试从 cachedAdaptiveInstance 中获取,如果不存在则会调用 ExtensionLoader#createAdaptiveExtension
方法进行创建:
1 | private T createAdaptiveExtension() { |
上述方法的逻辑比较简单,前面曾说明过 @Adaptive
注解既可以注解类型,也可以注解方法,如果一个扩展类型的某个实现类已经被该注解所修饰,那么此时就没有必要为该扩展类型再自动生成一个适配器,相当于我们已经为其手动创建了一个,前面的 AdaptiveExtensionFactory 就是这样一个手动创建的适配器类。对于其他情况来说,就需要调用 ExtensionLoader#createAdaptiveExtensionClass
方法自动生成相应的适配器类,并编译返回对应的 Class 对象,具体实现如下:
1 | private Class<?> createAdaptiveExtensionClass() { |
方法首先会调用 ExtensionLoader#createAdaptiveExtensionClassCode
方法按照规则生成扩展类型的适配器类,相应实现比较冗长,但是逻辑并不复杂。以前面的 IpResolver 接口举例来说,该方法会为其生成一个名为 IpResolver$Adaptive
的适配器类,该类会实现被 @Adaptive
修饰的方法(其他方法直接抛出 UnsupportedOperationException 异常),其逻辑就是从 URL 中获取注解指定参数对应的参数值,并以该参数值为扩展类型名称获取相应的扩展实例,然后调用具体实现类对应的方法,具体形式如下(为了排版美观,进行了一些微调):
1 | public class IpResolver$Adaptive implements IpResolver { |
上面说明了“重复造轮子”的原因 1 和原因 3。针对原因 2,Dubbo SPI 定义了一个 exceptions 属性,这是一个 Map 类型集合,在加载 SPI 配置时,如果某一行配置存在问题导致加载失败,Dubbo SPI 会以该行的具体配置为 key 记录相应的异常信息,并在获取指定扩展名称对应扩展实现类 Class 对象失败时打印相应的异常链,从而方便定位错误。除了这 3 个原因(也可以说是 Dubbo SPI 对于 JDK SPI 进行的增强),Dubbo SPI 还定义了 @Activate
注解,我们可以调用 ExtensionLoader#getActivateExtension
方法获取被该注解修饰的扩展类型实现,从而简化配置和编码,具体的实现比较简单,不再展开说明。
针对 Dubbo SPI 机制,除了内嵌在 Dubbo 中的实现,Dubbo 的作者也曾将其抽取出来成为一个独立的项目,即 cooma。在实现上,comma 对 Dubbo SPI 进行了一些精简和优化,Dubbo 的作者此举是认为 Dubbo SPI 在实现上耦合了微容器之外 RPC 的概念,在功能上划分不够清晰,所以将其独立出来划清与 Dubbo 的界限,方便独立发展和改进,但是二者在设计思想上还是一致的。如果仅仅是希望学习 Dubbo SPI,可以参阅 cooma 的源码,780 行的微容器实现,短小而精悍,并且 SPI 机制(包括适配器机制)有时候并不是我们不需要它,而是不知道它的存在,以致于写了一些不够优雅的代码。
总结
本文主要介绍和分析了 JDK SPI 和 Dubbo SPI,关于两种 SPI 机制的选择,个人觉得如果不是写框架、基础组件类代码,大部分时候 JDK SPI 都能够胜任我们的需求,JDK SPI 相对于 Dubbo SPI 虽然在功能上弱化了许多,但是使用简单、不增加学习成本,也不坑后来人,所以 JDK SPI 理应成为我们的首选,当然 Dubbo SPI 的设计思想和实现也值得我们去借鉴。