Dubbo之外,一文看懂jdk、dubbo、spring中的SPI

692 阅读16分钟

本专栏对应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 的所有功能点都可被用户自定义扩展所替换。

上面这段话当然不是我瞎掰的,其实是官网原话

image-20230104171803068

基于此,我们想要对Dubbo有深入的了解,首先要学习便是其SPI机制的实现原理。

在聊到SPI时,大家可以首先想起的是jdk中原生的SPI机制,所以我们碰到的第一个问题来了:jdk中已经有了SPI,为什么Dubbo还要自己实现SPI呢?其实官网已经给出了标准答案

image-20230104171827666

如果你仅仅是为了应付面试,ok,背吧,八股文而已。

但是如果你想更了解这个框架,我们不应该仅仅追求答案是什么,更应该思考的是:为什么是这个答案?

为了说清楚这个问题,我们先需要对jdk中的SPI做一个简单介绍

JDK中的SPI

示例

  1. 准备一个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);
        }
    }
    
  2. /META-INF/services目录下建一个跟接口同名的文件

    |--resources
    | |--META-INF
    | | |--services
    | | | |--com.easy4coding.jdk.spi.service.SpiService
    
  3. 运行测试代码

    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这个例子中,确实选择吞掉了异常

image-20230104171953771

可以看到上面的代码直接catch了一个error,不过这并不是SPI的问题,而是看使用者如何处理,如果我们选择不catch任何异常,自然可以抛出异常的具体原因。

至于官网中另外提到的依赖注入及AOP,存粹是Dubbo自己做的一个功能扩展罢了。

Dubbo中的SPI

上文中我们已经分析了jdk中SPI的缺陷,Dubbo实现自己的SPI机制自然改善了这些缺陷,同时Dubbo自己做了一些扩展。

  1. Dubbo可以加载指定名称的扩展点,避免了实例化不必要的SPI实现
  2. 在实例化扩展点的过程中可以完成AOP及IOC(依赖注入)

我们来看一个使用示例

基本使用示例

  1. 准备一个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);
        }
    }
    
  2. /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
    
  3. 测试代码

    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。为了达到这个目的,我们需要确保

  1. OrderService是一个SPI接口
  2. 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提供了两种自适应扩展的方式

  1. 直接将@Adaptive注解添加到具体的实现类上,由使用者自己实现适应的逻辑。我们在之前的例子中使用的就是这种方式,OrderAdaptiveService上添加了@Adaptive注解,同时在其pay方法中,我们实现了根据金额不同来使用不同实现类进行支付,当金额超过100时,我们调用的是CardServiceImpl,当金额低于100时,我们调用的是CashServiceImpl
  2. 实际上另外一种更加常见的方式是通过将@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"));
}

在上面的例子中大家要注意一下几点

  1. 自适应扩展也是Dubbo中SPI的一种,所以接口上必须要有@SPI注解,此外,我们也要将真正的扩展点实现配置到/META-INF/dubbo文件中

  2. AdaptiveSpi中必须要有被@Adaptive注解修饰的方法

  3. @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并没有使用我们上面提到的这些方式,而是约定了

  1. 参数有必须要有URL类型的参数
  2. 如果没有的话,必有要能通过某个参数的getter方法获取到URL

总之,必须要能从参数中获取到URL类型的一个实例。Dubbo为什么要这么做呢?不知道大家还记不记得在文开头我给大家展示的一张图

image-20230104172226342

在前文我们只提到了Dubbo的内核+插件的设计原则,除此之外,在官方文档中还提到了:采用URL作为配置新的统一格式,所有扩展点都通过传递URL携带配置信息。正因为URL中携带了所有配置信息,所以Dubbo在设计自适应扩展时,强依赖了URL。

整体来说,Dubbo的自适应扩展并不是很优雅,官方文档也将其列为整个框架的坏味道之一,有兴趣的同学可以查看这个链接:dubbo.apache.org/zh/docsv2.7…

Spring中的SPI

接下来要介绍的是SpringBoot中的SPI,不知道大家有没有发现SPI的几个特点

  1. 首先,SPI机制,需要有一个配置文件,例如在JDK中的SPI,我们会在/META-INF/services建一个接口同名文件,在Dubbo中的SPI,我们会在/META-INF/dubbo建一个接口同名文件
  2. 其次,不管哪种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的内容,我后面写一篇文章详细介绍!

最终对比

参考:segmentfault.com/a/119000003…

JDK SPIDUBBO SPISpring 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的实现进行详细的源码分析!

参考:

segmentfault.com/a/119000003…

dubbo.apache.org/zh/docsv2.7…