4.Dubbo的SPI扩展点加载机制

4.1 加载机制概述

4.1.1 Java SPI

在讲Dubbo SPI之前,先来了解一下Java SPI,SPI全称叫Service Provider Interface,起初是提供给厂商做插件开发的。Java SPI使用了策略模式,一个接口多种实现,我们只声明接口,具体实现由程序之外的配置掌控,步骤如下:

  • 定义一个接口及对应的方法。
  • 编写该接口的一个实现类。
  • 在META-INF/services目录下,创建一个以接口全路径命名的文件,如com.test.spi.PrintService。
  • 文件内容为具体实现类的全路径名,如果有多个,用分行符隔开。
  • 在代码通过java.util.ServiceLoader来加载具体的实现类。
public static void main(String[] args) {
  ServiceLoader<PrintService> serviceLoader = ServiceLoader.load(PrintService.class);
  for (PrintService printService : serviceLoader) {
    printService.printInfo();
  }
}

4.1.2 Dubbo对SPI的改进

  • Java SPI会一次性实例化扩展点所有实现,如果有扩展实现则初始化很耗时,如果没用上,则造成资源浪费。
  • Java SPI如果扩展失败,如ScriptEngine的实现类RubyScriptEngine因为所依赖的jruby.jar不存在,导致加载失败,这个失败原因会被“吃掉”,当用户执行Ruby脚本,会报不支持Ruby,而不是真正的原因。
  • Dubbo增加了对扩展IOC和AOP的支持。
// 第一步,在目录/META-INF/dubbo/internal下简历配置文件com.test.spi.PrintService
// 文件内容:impl=com.test.spi.PrintServiceImpl

// 第二步,接口加上SPI注解
@SPI("impl")
public interface PrintService {
  void printInfo();
}

public class PrintServiceImpl implements PrintService {
  @Override
  public void printInfo() {
    System.out.println("hello");
  }
}

// 第三步,通过ExtensionLoader加载
public static void main(String[] args) {
  PrintService printService = ExtensionLoader.getExtensionLoader(PrintService.class).getDefaultExtension():
  pritnService.printInfo();
}

4.1.3 Dubbo扩展点的配置规范

Dubbo SPI和Java SPI类似,需要在META-INF/dubbo/下放置对应的SPI配置文件,文件名为接口的全路径名。配置文件内容为key=扩展点实现类全路径名,如果有多个实现类则使用换行符分隔。其中,key会作为@SPI注解中的传入参数。另外,Dubbo SPI还同时兼容Java SPI的配置路径和内容配置方式。在Dubbo启动时,会默认扫四个目录下的配置:META-INF/services、META-INF/dubbo、META-INF/dubbo/internal 、META-INF/dubbo/external。

4.1.4 Dubbo扩展点的分类与缓存

Dubbo SPI可以分为Class缓存、实例缓存。

  • Class缓存:Dubbo SPI获取扩展类时,会先从缓存读取,如果缓存中不存在,则加载配置文件,根据配置把Class缓存到内存中,但并不会直接实例化。
  • 实例缓存:每次获取实例时,同样是先从实例缓存中读取,如果读不到,则重新加载并缓存。因此Dubbo SPI是按需实例化并缓存的机制,性能更好。

被缓存的Class和对象实例可以根据不同的特性分为不同的类别:

  • 普通扩展类。最基础的,配置在SPI配置文件中的扩展类实现。
  • 包装扩展类。这种Wrapper类没有具体实现,只是做了通用逻辑的抽象,并且需要在构造方法中传入一个 具体的扩展接口的实现。属于Dubb的自动包装特性。
  • 自适应扩展类。一个扩展接口会有多种实现类,具体使用哪个实现类可以不写死在配置或代码中,在运行时,通过传入的URL中的某些参数动态选择实现类,会使用@Adaptive注解。
  • 其他缓存,如扩展类加载器缓存、扩展名缓存等。

4.1.5 扩展点的特性

  1. 自动包装(AOP)
    ExtensionLoader在加载扩展时,如果发现这个扩展类的构造方法有其他扩展点作为构造函数,则会被认为是一个Wrapper类。
public class ProtocolFilterWrapper implements Protocol {
  private final Protocol protocol;
  public ProtocolFilterWrapper(Protocol protocol) {
    if (protocol == null) {
      throw new IllegalArgumentException("protocol == null");
    }
    this.protocol = protocol;
  }
  ...
}
  1. 自动加载(IOC)
    如果某个扩展类是另外一个扩展点类的成员属性,并且拥有setter方法,那么框架会自动注入对应的扩展点实例。此处存在一个问题,如果依赖的扩展有多种实现,具体会注入哪一个呢?这就涉及到第3个特性-自适应了。
    3.自适应
    在Dubbo SPI中,我们可以使用@Adaptive注解,动态地依据URL中的某个参数来确定使用哪个实现类,从而解决自动加载中的实例注入问题。
@SPI("netty")
public interface Transproter {
  @Adaptive({Constants.SERVER_KEY, Constants.TRANSPORTER_KEY})
  Server bind(URL url, ChannelHandler handler) throws RemotingException;

  @Adaptive({Constants.CLIENT_KEY, Constants.TRANSPORTER_KEY})
  Client connect(URL url, ChannelHandler handler) throws RemotingException;
}

@Adaptive传入了两个参数,分别是“server”和"transporter",外部调用Transporter#bind方法时,会动态从传入的参数URL中提取key参数“server”对应的value值,如果能匹配上唯一一个扩展实现类则直接使用对应的实现类;如果不能匹配,则继续通过第二个key参数"transporter"的value值来匹配。都没有匹配上,则抛出异常。
4.自动激活
使用@Activate注解,可以标记对应的扩展点默认被激活启用。该注解可以通过传入不同的参数,设置扩展点在不同的条件下被自动激活。这个注解的主要使用场景是某个扩展点的多个实现类需要同时启用(比如Filter扩展点)。

4.2 扩展点注解

4.2.1 @SPI

@SPI注解通常用在接口上,标记这个接口是一个Dubbo SPI接口。该注解可以接收一个value参数,用于指定这个接口的默认实现类。

4.2.2 @Adaptive

@Adaptive可以标记在类、接口、方法上,但大多数情况用在方法上更多。方法级别注解在第一次getExtension时,会自动生成和编译一个动态的Adaptive类,从而打到动态实现类的效果。
如前面的例子,Transproter接口在bind和connect方法上添加了@Adaptive注解,Dubbo在初始化扩展点时,会生成一个Transporter$Adaptive类,里面会实现这两个方法,方法里会有一些抽象的通用逻辑,通过@Adaptive中传入的参数找到并调用真正的实现类,跟装饰者模式有点类似。下面是自动生成的Transporter$Adaptive#bind的代码:

public Server bind(URL arg0, ChannelHandler arg1)  throws RemotingException {
  URL url = arg0;
  String extName = url.getParameter("server", url.getParameter("transporter", "netty"));
  ...
  try {
    extension = (Transporter)ExtensionLoader.getExtensionLoader(Transporter.class).getExtension(extName);
  } catch (Exception e) {
    extension = (Transporter)ExtensionLoader.getExtensionLoader(Transporter.class).getExtension("netty");
  }
  return extension.bind(arg0, arg1);
}

如果该注解放在实现类上,则整个实现类会直接作为默认实现,不需要自动生成上面的动态代码,如果有多个实现类,只有一个实现类可以加上@Adaptive注解,万一多个实现类都注解了会抛异常。因此在实现类上使用这个注解的效果,与@SPI的参数指定实现类类似(但读完后面的源代码其实还是不一样的,@SPI指定的默认实现类用于getExtension方法,@Adaptive指定的用于getAdaptiveExtension方法)。

4.2.3 @Activate

@Activate可以标记在类、接口和方法上。主要用于有多个扩展点实现,需要根据不同条件来激活的场景,比如Filter需要多个同时激活。@Activate可传入的参数很多:

  • String[] group 指定URL中的分组,如果匹配则激活,可以设置多个。
  • String[] value 查找URL如果含有该key的值,则会激活。
  • String[] before 填写扩展点列表,表示这些扩展点要在本扩展点之前
  • String[] after 同上,表示之后
  • int order 直接的顺序信息

4.3 ExtensionLoader的工作原理

4.3.1 工作流程

ExtensionLoader的逻辑入口可以分为getExtension、getAdaptiveExtension、getActivateExtension三个,总体逻辑都是从调用这三个方法开始的。

  1. getActivateExtension只是根据不同的条件同时激活多个普通扩展类,因此此方法只会做一些通用的判断逻辑,比如是否包含@Activate注解,匹配条件是否符合等。最终还是会调用getExtension方法来获得具体的实现类实例。

  2. getExtension(String name)是整个扩展加载器最核心的方法,实现了一个完整的普通扩展类加载的过程。加载过程中的每一步,都会先检查缓存中是否已经存在所需的数据,如果存在则直接从缓存中读取,没有则重新加载。这个方法每次只会根据名称返回一个扩展点实现类。实例化的过程可以分为4步:
    (1) 读取SPI对应路径下的配置文件,并根据配置加载所有扩展类Class并缓存。(这个应该是只读getExtensionLoader(Class)传递的class对象所对应的配置文件)
    (2) 根据传入的name参数实例化对应的扩展类。
    (3) 尝试查找符合条件的包装类:包含扩展点的setter方法;包含与扩展类型相同的构造函数,例如本次实例化了一个Class A对象。实例化完成后,会寻找构造参数中需要Class A的包装类。(居然是主动查找?)
    (4) 返回对应的扩展类实例。

  3. getAdaptiveExtension也相对独立,只加载配置信息部分与getExtension共用了同一个方法。和获取普通扩展类一样,框架会先检查缓存中是否有已经实例化好的Adaptive实例,没有则调用createAdaptiveExtension进行实例化。
    (1) 和getExtension()一样先加载配置文件。
    (2) 生成自适应类的代码字符串。
    (3) 获取类加载器和编译器,并用编译器编译刚才生成的代码字符串。Dubbo一共有三种类型的编译器实现,后面会讲。
    (4)返回对应的自适应类实例。

4.3.2 getExtension的实现原理

用法示例

ExtensionLoader.getExtensionLoader(XXX.class).getExtension("xxx"):

当调用getExtension(String name)方法时,会先检查缓存中是否有现成的数据,没有则调用createExtension开始创建。这里有个特殊点,如果getExtension的name参数时"true",会加载并返回默认扩展类,内部实质会调用getDefaultExtension()。
在调用createExtension的过程中,也会先检查缓存中是否有配置信息,如果不存在,则会从META-INF/services、META-INF/dubbo、META-INF/dubbo/internal、META-INF/dubbo/external这几个路径中读取所有的配置文件,得到扩展点实现类的全称。


getExtension

createExtension

先看createExtension()中通过getExtensionClasses()获取实现类Class的实现:


ExtensionLoader#getExtensionClasses

ExtensionLoader#loadExtensionClasses

ExtensionLoader#loadDirectory

ExtensionLoader#loadResource

ExtensionLoader#loadClass

接下来再回头看createExtension()拿到实现类Class之后,剩余的部分:


ExtensionLoader#createExtension

ExtensionLoader#injectExtension

个人思考与尝试:
1.有一点不太理解为什么依赖注入要用getAdaptiveExtension而不用getExtension呢?我自己写了两个@SPI接口,实现类和接口都没有@Adaptive注解测试了下依赖注入,发现getAdaptiveExtension确实会抛异常,必须要求依赖注入的@SPI接口是自适应类型的,这个设计有点奇怪。不过通过查看dubbo本身的源码,用到依赖注入的确实基本上都是@Adaptive的。
2.关于循环依赖,我自己写了两个@Adaptive类互相依赖,发现并不会出现异常,看了很久发现原来是因为依赖注入的必然是XXX$Adaptive类,此类是通过代码生成再编译的方式得到的,通过调试和反编译看到生成的类内部不会再有更深层依赖注入,而是在用到依赖的地方使用ExtensionLoader.getExtensionLoader().getExtension()的方式来延迟获取。其实想明白Adaptive本身就是要动态切换所依赖的实现类这层含义,就不会有循环依赖这个疑问。

4.3.3 getAdaptiveExtension的实现原理

由前面的流程我们可以知道,在getAdaptiveExtension()方法中,会为扩展点接口自动生成实现类字符串,实现类主要包含以下逻辑:为接口中每个有@Adaptive注解的方法生成默认实现(没有注解的方法则生成空实现),每个默认实现都会从URL(或者directory中的url)中提取Adaptive参数值,并以此为依据动态加载扩展点。然后,框架会使用不同的编译器,把实现类代码字符串编译成Class并返回。
生成代码的逻辑主要分7步:
(1) 生成package、import、类名称等头部信息。此处import只有ExtensionLoader一个类。为了步import其他类,其他类都用全路径来使用。生成的类名称为“接口名称$Adaptive”。
(2) 遍历接口所有方法,获取方法的返回类型、参数类型、异常类型。为第(3)步判断是否为空做准备。
(3) 生成校验代码,如参数是否为空。如果有远程调用,还会添加Invocation参数是否为空的校验。
(4) 如果@Adaptive注解没有设定value,生成默认url参数名,按照类名称驼峰转点的方式生成,如YyyInvokerWrapper会以yyy.invoker.wrapper作为默认url参数名。
(5) 生成获取扩展点名称的代码。如@Adaptive("protocol"),会生成url.getProtocol()。
(6) 生成获取具体扩展实现类的代码。即通过ExtensionLoader.getExtensionLoader().getExtension(extName)获取到url中自适应参数对于的实现类。如果url中拿不到参数值,则换下一个参数值,如果都不行,则使用默认实现类别名来作为extName去加载实现类实例。
(7) 生成调用实现类方法的代码。

书中居然省略了这么重要的自适应的源码分析。。。无语,具体可参考官网:
https://dubbo.apache.org/zh/docs/v2.7/dev/source/adaptive-extension/

4.3.4 getActivateExtension的实现原理

通过getActivateExtension(URL url, String key, String group)方法可以获取所有自动激活的扩展点。其流程分4步:
(1) 检查缓存,如果缓存中没有,则初始化所有扩展类实现的集合。
(2) 遍历整个@Activate注解集合,根据传入的URL、key、group参数匹配符合条件的扩展类实现。然后根据@Activate中配置的before、after、order等参数进行排序。
(3) 遍历所有用户自定义扩展类名称,根据用户URL配置的顺序,调整扩展点激活顺序(遵循用户在URL中配置的顺序,例如URL为test://localhost/test?ext=order1,default,那么扩展点ext的激活顺序会遵循先order1再default,其中default代表所有@Activate注解的扩展点)
(4) 返回所有自动激活类集合。
注意点1:获取Activate扩展类实现,也是通过调用getExtension得到的。
注意点2:如果URL的参数中传入了-default,则所有默认的@Activate都不会被激活,只有URL参数中指定的扩展点会被激活。如果传入了"-"+扩展点名,则该扩展点也不会被自动激活。

ExtensionLoader#getActivateExtension

虽然这段代码实现相对不那么重要,但因为不容易看明白,所以还是贴以下代码并加了点方便理解的注释。

4.3.5 ExtensionFactory的实现原理

前面在依赖注入的实现中已经有提到过,通过ExtensionLoader中的objectFactory除了可以注入其他扩展点,还能注入Spring bean,这个又是如何实现的呢?我们知道objectFacotry的类型是ExtensionFactory,这是一个@SPI接口提供了SpiExtensionFactory、SpringExtensionFactory、AdaptiveExtensionFactory三个实现。


ExtensionFactory

那么具体用的是哪个实现呢?翻看源码可以知道AdaptiveExtensionFactory在类上使用了@Adaptive注解,所以在ExtensionLoader的构造方法中调用getAdaptiveExtension()会返回AdaptiveExtensionFactory实例。


ExtensionLoader构造方法

AdaptiveExtensionFactory

再来看SpiExtensionFactory的实现:
SpiExtensionFactory

最后是SpringExtensionFactory:


SpringExtensionFactory#getExtension

那么Spring的上下文又是什么时候被保存起来的呢?我们通过代码搜索得知,在ReferenceBean和ServiceBean中会调用静态方法保存Spring上下文,即一个服务被发布或者被引用的时候。

4.4 扩展点动态编译的实现

动态编译是自适应特性的基础,因为动态生成的自适应类只是字符串,需要通过编译才能得到真正的Class。虽然我们可以通过反射来动态代理一个类,但是在性能上和直接编译好的Class会有一定的差距,所以Dubbo SPI采用代码动态生成,并配合动态编译器,灵活地在原始类的基础上创建新的自适应类。
Dubbo有三种代码编译器,分别是JdkCompiler,JavassistCompiler和AdaptiveCompiler,他们都实现了Complier接口。接口Compiler上含有@SPI注解,默认值为@SPI("javassist"),即使用JavassistCompiler作为默认编译器。用户想修改可以通过配置<dubbo:application compiler="jdk" />进行修改。
AdaptiveCompiler类上面有@Adaptive注解,说明它作为自适应的默认实现,其作用与AdaptiveExtensionFactory类似,用于管理其他Compiler。


AdaptiveCompiler

然后看一下AbstractCompiler,它是一个抽象类,里面封装了通用的模板逻辑,还定义了一个抽象方法doCompile,留给子类来实现。
AbstractCompiler的主要抽象逻辑:

  • 通过正则匹配出包路径、类名,在根据包路径、类名拼接出全路径类名。
  • 尝试通过Class.forName加载该类,防止重复编译。如果类加载器中没有,则进入下一步。
  • 调用doCompile方法进行编译。

4.4.2 Javassist动态代码编译

先看一个Javassist生成Hello World的使用例子:

ClassPool classPool = ClassPool.getDefault();
CtClass ctClass = classPool.makeClass("HelloWord");
CtMethod ctMethod = CtNewMethod.make("
    public static void test() {
        System.out.println(\"Hello Word\");
    }
", ctClass);
ctClass.addMethod(ctMethod);
Class clazz = ctClass.toClass();
Object object = clazz.newInstance();
Method m = clazz.getDeclaredMethod("test", null);
m.invoke(object, null);

看完使用示例,Dubbo的JavassistCompiler的实现就很容易猜到了,就是通过正则匹配不同部位的代码,然后调用Javassist的api来生成添加进ctClass中,最后得到一个完整的Class对象。具体步骤:
(1) 初始化Javassist,如设置classpath。
(2) 正则匹配所有import包,使用Javassist添加import。
(3) 正则匹配所有extends,创建Class对象,并使用Javassist添加extends。
(4) 正则匹配所有implements,并使用Javassist添加implements。
(5) 正则匹配类里面{}中所有的内容,在通过正则匹配所有的方法,并使用Javassist添加类方法。
(6) 生成Class对象。

4.4.3 JDK动态编译

JDK自带的编译器位于javax.tools下,Dubbo主要使用了JavaFileObject接口、JavaFileManager接口、JavaCompiler.CompilationTask方法。整个编译过程可以总结为:先初始化一个JavaFileObject对象,并把代码字符串作为参数传入构造方法,然后调用JavaCompiler.CompilationTask方法编译出具体的类。JavaFileManager负责管理类文件的输入/输出位置。

  • JavaFileObject接口。字符串代码会被包装成一个文件对象,并提供二进制流的接口。Dubbo框架中的JavaFileObjectImpl类可以看作该接口一种扩展实现,构造方法中需要传入生成好的字符串代码,此文件对象的输入和输出都是ByteArray流。
  • JavaFileManager接口。主要管理文件的读取和输出位置。JDK中没有可以直接使用的实现类,唯一的实现类是ForwardingJavaFileManager构造器又是protect类型。因此Dubbo中定制化实现了一个JavaFileManagerImpl类,并通过一个自定义加载器ClassLoaderImpl完成资源的加载。
  • JavaCompiler.CompilationTask把JavaFileObject对象编译成具体的类。
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,214评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,307评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,543评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,221评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,224评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,007评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,313评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,956评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,441评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,925评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,018评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,685评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,234评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,240评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,464评论 1 261
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,467评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,762评论 2 345

推荐阅读更多精彩内容