写点什么

JDK、Spring、Dubbo SPI 原理介绍

作者:网易云信
  • 2022 年 4 月 06 日
  • 本文字数:7393 字

    阅读完需:约 24 分钟

JDK、Spring、Dubbo SPI 原理介绍

导读

需求变化是程序员生命中唯一不变的事情,本文将介绍 JDK/Spring/Dubbo 中的 SPI 机制,以此来帮助我们编写出一套可扩展性强,易于维护的代码框架。


文|杨亮 网易云商高级 Java 开发工程师

一、什么是 SPI?

SPI(Service Provider Interface)是一种旨在由第三方实现或者扩展的 API。它可以用于启用、扩展甚至替换框架中的组件。 SPI 的目的是为了在不修改原来的代码库的基础上,开发人员可以使用新的插件或者模块来增强框架功能。如我们常使用的 JDBC,在 Java 的核心类库中,并没有规定开发者需要使用何种类型的数据库,开发者可以根据自身需求来选择不同的数据库类型,可以是 MySQL、Oracle。


所以 Java 的核心类库只提供了数据库驱动的接口 Java.sql.Driver,不同的数据库服务提供商可以实现此接口,而开发者只需配置相应数据库驱动的实现类,JDBC 框架就能自行加载第三方的服务以达到客户端访问不同类型的数据库的功能。


在很多主流的开发框架中,我们都可以看到 SPI 的身影,除了 JDK 提供的 SPI 机制外,还有诸如 Spring、Spring cloud Alibaba Dubbo 等等,接下来笔者将介绍如何使用它们及其实现原理。




点击并拖拽以移动

二、JDK SPI

(一)案例

  • 定义接口规范


package com.demo.jdkspi.api;public interface SayHelloService {    String sayHello(String name);}
复制代码


  • 定义接口实现类


public class SayHelloImpl implements SayHelloService {    public String sayHello(String name) {        return "你好"+name+",欢迎关注网易云商!";    }}
复制代码


  • 配置文件

  • 在 resources 目录下添加纯文本文件 META-INF/services/com.demo.jdkspi.api.SayHelloService, 内容如下:


com.demo.jdkspi.impl.SayHelloServiceImpl
复制代码


点击并拖拽以移动


  • 编写测试类

  • 客户端引入依赖,并使用 ServiceLoader 加载接口:


public static void main(String[] args) {    // 1. 根据SayHelloService.class创建ServiceLoader实例,此时SayHelloService实例并没有被创建(懒加载)    ServiceLoader<SayHelloService> loader = ServiceLoader.load(SayHelloService.class);    // 2. SayHelloService实例是在遍历的时候创建的    loader.forEach(sayHelloService ->{        System.out.println(sayHelloService.sayHello("Jack"));    });}
复制代码


运行结果如下:




点击并拖拽以移动

(二) JDK SPI 原理解析

通过案例我们可以知道 JDK SPI 机制主要是通过 ServiceLoader 来实现的, 需要注意的是,实现类的加载是一种懒加载机制,创建 ServiceLoader 并不会去加载接口实现,而是在遍历的时候再去加载。


创建 ServiceLoader 实例流程:




点击并拖拽以移动


主要流程描述


  1. 获取线程上下文的 ClassLoader: 由于 ServiceLoader 是在 rt.jar 下的,而接口实现类是在 classpath 下面,这打破了双亲委派模型,所以需要从线程上下文中获取 AppClassLoader 用于加载目标接口及其实现类。

  2. 清空 providers 缓存: 清空历史加载缓存。

  3. 创建 LazyIterator,后续遍历所有实现类的时候会使用此迭代器。


加载目标服务流程:




点击并拖拽以移动


主要流程描述


  1. 在迭代器开始遍历前,SayHelloService 会去加载 ClassPath(由前文提到的 AppClassLoader 决定的)下所有的目标接口的配置信息。

  2. 接口实现类的实例化主要是先通过 Class.forName 创建一个 Class 对象,然后通过反射创建实例。

  3. 在实现类实例化后,ServiceLoader 会根据实现类的全限定名为标识将实例缓存起来。

(三)JDK SPI 总结

优点:


  • 解耦: JDK SPI 使得第三方服务模块加载控制的逻辑与调用者的业务代码分离,从而实现解耦。

  • 懒加载: 在创建 ServiceLoader 实例的时候并不会去加载第三方服务模块,而是在遍历的时候去加载。


缺点


  • 只能通过遍历的方式去获取所有的接口实现类,并没有实现按需加载。

  • 如果接口实现类依赖了其他扩展实现,JDK SPI 并没有实现依赖注入的功能。

三、Spring SPI

Spring Boot Starter 是一种依赖的集合,它使得我们只需要进行简单的配置就能获取 Spring 和相关技术的一站式服务。而 Spring Boot Starter 的实现也离不开 SPI 思想,下面我们通过实现一个简单的 starter 组件来体会一下它的魅力。

(一)Spring Boot Starter 案例

  • 编写 SayHello Service 的实现类及 Spring 配置类

  • 创建一个独立的项目 greeter-spring-boot-starter,并编写 SayHelloService 实现类及 Spring 配置类


public class Greeter implements SayHelloService, InitializingBean {    public String sayHello(String name) {        return "你好"+name+",欢迎关注网易云商!";    }    public void afterPropertiesSet() throws Exception {        System.out.println("网易云商服务加载完毕,欢迎使用!");    }}​​​​​​
复制代码


@Configurationpublic class TestAutoConfiguration {    @Bean    public SayHelloService sayHelloService(){        return new Greeter();    }}
复制代码


  • 配置文件

  • 在 resources/META-INF 目录下创建 spring.factories 文件,内容如下:​​​​​​​


org.springframework.boot.autoconfigure.EnableAutoConfiguration=\com.demo.springspi.TestAutoConfiguration
复制代码


  • 引入依赖

  • 在客户端项目中引用 greeter-spring-boot-starter 依赖​​​​​​​


<dependency>    <groupId>com.spi.demo</groupId>    <artifactId>greeter-spring-boot-starter</artifactId>    <version>1.0.0-SNAPSHOT</version></dependency>
复制代码


  • 效果展示

  • 在客户端 Spring 项目启动的时候,可以清楚的看见,我们编写的 Greeter 会被 Spring IoC 容器加载。



点击并拖拽以移动


(二)Spring Boot Starter 原理解析

在 Spring SPI 中,也有一个类似于 ServiceLoader 的类——SpringFactoriesLoader,在 Spring 容器启动的时候,会通过 SpringFactoriesLoader 去“META-INF/spring.factories”获取配置类信息,然后将这些配置类信息封装成 BeanDefinition,这样 Spring IoC 容器就能管理这些 Bean 了,主要流程如下:




点击并拖拽以移动



点击并拖拽以移动



主要流程描述:


  1. SpringFactoriesLoader 加载配置类信息发生在构建 SpringApplication 实例的时候,SpringFactoriesLoader 会读取“META-INF/spring.factories”下的配置信息并缓存起来。

  2. AutoConfigurationImportSelector 是在 @EnableAutoConfiguration 中引入的, AutoConfigurationImportSelector 的核心功能是:获取 “org.springframework.boot.autoconfigure.EnableAutoConfiguration” 的配置类列表,并且会筛选一遍(如我们在 @EnableAutoConfiguration 中配置了 exclude 属性),得到最终需要加载的配置类列表。

  3. ConfigurationClassPostProcessor 会将最终需要加载的配置类列表并将其加载为 BeanDefinition,后续在解析 BeanClass 的时候,也会调用 Class.forName 来获取配置类的 Class 对象。Spring Bean 的装载流程本文不再赘述。

(三)Spring SPI 总结

  1. 通过将第三方服务实现类交给 Spring 容器管理,很好解决了 JDK SPI 没有实现依赖注入的问题。

  2. 配合 Spring Boot 条件装配,可以在一定条件下实现按需加载第三方服务,而不是加载所有的扩展点实现。

四、Dubbo SPI

SPI 机制在 Dubbo 中也有所应用,Dubbo 通过 SPI 机制加载所有的组件,只不过 Dubbo 并未使用 Java 原生的 SPI 机制,而是对其进行了增强。在 Dubbo 源码中,经常能看到如下代码,它们分别是指定名称扩展点,激活扩展点和自适应扩展点:​​​​​​​


ExtensionLoader.getExtensionLoader(XXX.class).getExtension(name);ExtensionLoader.getExtensionLoader(XXX.class).getActivateExtension();ExtensionLoader.getExtensionLoader(XXX.class).getAdaptiveExtension(url,key);
复制代码


Dubbo SPI 的相关逻辑都封装在了 ExtensionLoader 类中,通过 ExtensionLoader 我们可以加载指定的实现类,Dubbo 的 SPI 扩展有两个规则:


  1. 需要在 resources 目录下创建任意目录结构: META-INF/dubbo、META-INF/dubbo/internal、META-INF/services 在对应的目录下创建以接口全路径名命名的文件。

  2. 文件内容是 Key 和 Value 形式的数据, Key 是一个字符串,Value 是一个对应扩展点的实现。

(一)指定名称扩展点

案例

  • 声明扩展点接口

  • 在一个依赖了 Dubbo 框架的工程中,创建一个扩展点接口及一个实现,扩展点接口需要使用 @SPI 注解,代码如下:​​​​​​​


@SPIpublic interface SayHelloService {    String sayHello(String name);}​​​​​​​
复制代码


public class SayHelloServiceImpl implements SayHelloService {    @Override    public String sayHello(String name) {        return "你好"+name+",欢迎关注网易云商!";    }}
复制代码


  • 配置文件

  • 在 resources 目录下添加纯文本文件 META-INF/dubbo/com.spi.api.dubbo.SayHelloService,内容如下:​​​​​​​


neteaseSayHelloService=com.spi.impl.dubbo.SayHelloServiceImpl
复制代码


点击并拖拽以移动


  • 编写测试类


public static void main(String[] args) {    ExtensionLoader<SayHelloService> extensionLoader = ExtensionLoader.getExtensionLoader(SayHelloService.class);    SayHelloService sayHelloService = extensionLoader.getExtension("neteaseSayHelloService");    System.out.println(sayHelloService.sayHello("Jack"));}
复制代码

(二)激活扩展点

有些时候一个扩展点可能有多个实现,我们希望获取其中的某一些实现类来实现复杂的功能,Dubbo 为我们定义了 @Activate 注解来标注实现类,表明该扩展点为激活扩展点。其中 Dubbo Filter 是我们平时常用的激活扩展点。

案例

在服务提供者端实现两个功能,一个是在服务调用的时候打印调用日志,第二个是检查系统状态,如果系统未就绪,则直接返回报错。


  • 定义打印日志的 filter


/** * group = {Constants.PROVIDER}表示在服务提供者端生效 * order表示执行顺序,越小越先执行 */@Activate(group = {Constants.PROVIDER}, order = Integer.MIN_VALUE)public class LogFilter implements Filter {    @Override    public Result invoke(Invoker<?> invoker, Invocation invocation) throws RpcException {        System.out.println("打印调用日志");        return invoker.invoke(invocation);    }}
复制代码


  • 定义系统状态检查的 filter​​​​​​​


@Activate(group = {Constants.PROVIDER},order = 0)public class SystemStatusCheckFilter implements Filter {    @Override    public Result invoke(Invoker<?> invoker, Invocation invocation) throws RpcException {        // 校验系统状态,如果系统未就绪则调用失败        if(!sysEnable()) {            throw  new RuntimeException("系统未就绪,请稍后再试");        }        System.out.println("系统准备就绪,能正常使用");        Result result = invoker.invoke(invocation);        return result;    }}
复制代码


  • 配置文件

  • 在 resources 目录下添加纯文本文件 META-INF/dubbo/com.alibaba.dubbo.rpc.Filter,内容如下:​​​​​​​


logFilter=com.springboot.dubbo.springbootdubbosampleprovider.filter.LogFiltersystemStatusCheckFilter=com.springboot.dubbo.springbootdubbosampleprovider.filter.SystemStatusCheckFilter
复制代码


  • 执行效果

  • 在服务提供者端,执行目标方法之前,会先去执行我们定义的两个 Filter,效果如图所示:



点击并拖拽以移动


(三)自适应扩展点

自适应扩展点就是能根据上下文动态匹配一个扩展类,有时候有些扩展并不想在框架启动阶段被加载,而是希望在扩展方法被调用时,根据运行时参数进行加载。

案例

  • 定义自适应扩展点接口


@SPI("default")public interface SimpleAdaptiveExt {    /**     * serviceKey表示会根据URL参数中serviceKey的值来寻找对应的扩展点实现,     * 如果没有找到就使用默认的扩展点。     */    @Adaptive("serviceKey")    void sayHello(URL url, String name);}
复制代码


  • 定义扩展点实现类


public class DefaultExtImp implements SimpleAdaptiveExt {    @Override    public void sayHello(URL url, String name) {        System.out.println("Hello " + name);    }}
复制代码


public class OtherExtImp implements SimpleAdaptiveExt {    @Override    public void sayHello(URL url, String name) {        System.out.println("Hi " + name);    }}
复制代码


  • 配置文件

  • 在 resources 目录下添加纯文本文件 META-INF/dubbo/com.spi.impl.dubbo.adaptive.SimpleAdaptiveExt,内容如下:​​​​​​​


default=com.spi.impl.dubbo.adaptive.DefaultExtImpother=com.spi.impl.dubbo.adaptive.OtherExtImp
复制代码


  • 编写测试类


public static void main(String[] args) {    SimpleAdaptiveExt simpleExt = ExtensionLoader.getExtensionLoader(SimpleAdaptiveExt.class).getAdaptiveExtension();    Map<String, String> map = new HashMap<String, String>();    URL url = new URL("http", "127.0.0.1", 1010, "path", map);    // 调用默认扩展点DefaultExtImp.sayHello方法    simpleExt.sayHello(url, "Jack");    url = url.addParameter("serviceKey", "other");    // 此时serviceKey=other,会调用扩展点OtherExtImp.sayHello方法    simpleExt.sayHello(url, "Tom");}
复制代码

(四)Dubbo 扩展点原理分析

获取 ExtensionLoader 实例

ExtensionLoader.getExtensionLoader 这个方法主要返回一个 ExtensionLoader 实例,主要逻辑如下:


  1. 先从缓存“EXTENSION_LOADERS”中获取扩展类对应的实例;

  2. 如果缓存未命中,则创建一个新的实例,保存在 EXTENSION_LOADERS 中;

  3. 在 ExtensionLoader 构造方法中,会初始化一个 ExtensionFactory;

获取扩展点方法 getExtension

  1. 先从缓存 cachedClasses 中获取扩展类,如果没有就从 META-INF/dubbo/internal/ 、META-INF/dubbo/、META-INF/services/三个目录中加载。

  2. 获取到扩展类以后,检查缓存 EXTENSION_INSTANCES 中是否有该扩展类的实现,如果没有就通过反射实例化后放入缓存中。

  3. 实现依赖注入,如果当前实例依赖了其他扩展实现,那么 Dubbo 会将依赖注入到当前实例中。

  4. 将扩展类实例通过 Wrapper 装饰器进行包装。


以上步骤中,第一个步骤是加载扩展类的关键,第三和第四个步骤是 Dubbo IoC 与 AOP 的具体实现。其中依赖注入是通过调用 injectExtension 来实现的且只支持 setter 方式的注入。

获取自适应扩展点方法 getAdaptiveExtension

  1. 调用 getAdaptiveExtensionClass 方法获取自适应扩展 Class 对象。

  2. 通过反射进行实例化。调用 injectExtension 方法向扩展类实例中注入依赖。


虽然上述三个流程和和普通扩展点的获取方法类似,但是在处理 Class 对象的时候,Dubbo 会动态生成自适应扩展点的动态代理类,然后使用 javassist(默认)编译源码,得到代理类 Class 实例。其中动态生成的自适应扩展类的源码如下(以上述代码中的 SimpleAdaptiveExt 为例):


package com.spi.impl.dubbo.adaptive;import org.apache.dubbo.common.extension.ExtensionLoader;public class SimpleAdaptiveExt$Adaptive implements com.spi.impl.dubbo.adaptive.SimpleAdaptiveExt {  public void sayHello(org.apache.dubbo.common.URL arg0, java.lang.String arg1)  {    if (arg0 == null) throw new IllegalArgumentException("url == null");    org.apache.dubbo.common.URL url = arg0;    String extName = url.getParameter("serviceKey", "default");    if(extName == null) throw new IllegalStateException("Failed to get extension (com.spi.impl.dubbo.adaptive.SimpleAdaptiveExt) name from url (" + url.toString() + ") use keys([serviceKey])");    com.spi.impl.dubbo.adaptive.SimpleAdaptiveExt extension = (com.spi.impl.dubbo.adaptive.SimpleAdaptiveExt)ExtensionLoader.getExtensionLoader(com.spi.impl.dubbo.adaptive.SimpleAdaptiveExt.class).getExtension(extName);    extension.sayHello(arg0, arg1);  }}
复制代码


从上述代码中我们可以看到,在方法 SayHello 中,会去获取 url 中 serviceKey 对应的值,如果有就使用该值对应的扩展点实现,否则使用默认的扩展点实现。

(五)Dubbo SPI 总结

Dubbo 的扩展点加载从 JDK SPI 扩展点发现机制加强而来,并且改进了 JDK SPI 的以下问题:


  1. JDK SPI 会一次性实例化扩展点所有实现,而 Dubbo 可以使用自适应扩展点,在扩展方法调用的时候再实例化。

  2. 增加了对 IoC 的支持,一个扩展点可以通过 setter 方式来注入其他扩展点。

  3. 增加了 AOP 的支持,基于 Wrapper 包装器类来增强原有扩展类实例。

五、多租户系统中定制技术结合 SPI 展望

多租户系统中动态个性化配置与定制技术能满足不同租户的个性化要求,但是大量的定制任务可能使系统变得十分复杂。


为了方便管理及维护不同租户的个性化配置,结合 SPI 可以使用不同扩展实现来启用或扩展框架中的组件的思想,我们可以设计一个租户个性化定制管理平台,该平台能管理各个租户的定制化配置, 开发人员将不同租户的个性化差异抽象为一个个的定制点,定制管理平台能收集并管理这些定制点信息,业务系统在运行时能从定制平台中获取租户的个性化配置并加载相应的扩展实现,从而满足不同租户的个性化需求。整体架构如下:



点击并拖拽以移动





点击并拖拽以移动


租户个性化定制管理平台主要功能及特性如下:


  1. 抽象定制点: 开发人员将租户特征抽象成不同的定制点接口,对于不同特征的租户有不同的扩展实现。

  2. 定制点发现: 每个服务的定制点及实现信息需要上报给定制管理平台。

  3. 定制租户个性化配置: 运营人员可以根据租户的特征配置不同的定制点实现。

  4. 动态加载: 在租户访问业务系统的具体服务时,业务系统能从管理平台中获取到相应租户的配置信息,并且可以通过责任链/装饰器模式来组装一个或者多个定制点实现。

  5. 租户隔离: 运营人员为租户设置好个性化配置后,定制管理平台能够将配置信息以租户的维度存储,从而实现不同租户定制内容的隔离。

  6. 定制复用: 对租户共有特征进行重用配置或者对那些没有配置的租户采用默认配置。


租户个性化定制管理平台可以将租户个性化特征以元数据的方式进行管理, 后续只要新租户的个性化需求能通过现有定制点的元数据进行描述,那么只需要修改配置的方式来满足新需求,即使满足不了,也只需要新增或者实现定制点接口并且上报给定制管理平台,这使得系统易于维护,代码复用性也会更高。

参考资料

《Dubbo 2.7 开发指南》


《Spring Cloud Alibaba 微服务原理与实战》

作者介绍

杨亮,网易云商高级 Java 开发工程师,负责云商平台公共业务模块和内部中间件的设计与开发。


发布于: 刚刚阅读数: 2
用户头像

网易云信

关注

还未添加个人签名 2021.03.12 加入

来自网易云信,专注音视频技术,全面负责网易实时音视频、互动白板、直播、互动直播、传输网等项目的架构设计与研发,对音视频、高性能服务器以及网络通讯等领域均有多年的工作与项目经验 。

评论

发布
暂无评论
JDK、Spring、Dubbo SPI 原理介绍_Java_网易云信_InfoQ写作平台