Java SPI机制以及和Dubbo/Spring SPI对比

什么是 SPI

背景

在面向对象的设计原则中,一般推荐模块之间基于接口编程,通常情况下调用方模块是不会感知到被调用方模块的内部具体实现。一旦代码里面涉及具体实现类,就违反了开闭原则。如果需要替换一种实现,就需要修改代码。
为了实现在模块装配的时候不用在程序里面动态指明,这就需要一种服务发现机制。Java SPI 就是提供了这样一个机制:为某个接口寻找服务实现的机制。这有点类似 IOC 的思想,将装配的控制权移交到了程序之外。

SPI 英文为 Service Provider Interface 字面意思就是:“服务提供者的接口”,我的理解是:专门提供给服务提供者或者扩展框架功能的开发者去使用的一个接口。

SPI 将服务接口和具体的服务实现分离开来,将服务调用方和服务实现者解耦,能够提升程序的扩展性、可维护性。修改或者替换服务实现并不需要修改调用方。

使用场景

很多框架都使用了 Java 的 SPI 机制,比如:数据库加载驱动,日志接口,以及 dubbo 的扩展实现等等。

SPI 和 API 区别

说到 SPI 就不得不说一下 API 了,从广义上来说它们都属于接口,而且很容易混淆。下面先用一张图说明一下:
image

一般模块之间都是通过通过接口进行通讯,那我们在服务调用方和服务实现方(也称服务提供者)之间引入一个“接口”。

API

  • 当实现方提供了接口和实现,我们可以通过调用实现方的接口从而拥有实现方给我们提供的能力,这就是 API ,这种接口和实现都是放在实现方的。

SPI

  • 当接口存在于调用方这边时,就是 SPI ,由接口调用方确定接口规则,然后由不同的厂商去根绝这个规则对这个接口进行实现,从而提供服务

实战演示

Spring 框架提供的日志服务 SLF4J 其实只是一个日志门面(接口),但是 SLF4J 的具体实现可以有几种,比如:Logback、Log4j、Log4j2 等等,而且还可以切换,在切换日志具体实现的时候我们是不需要更改项目代码的,只需要在 Maven 依赖里面修改一些 pom 依赖就好了。
image

这就是依赖 SPI 机制实现的,那我们接下来就实现一个简易版本的日志框架。

Service Provider Interface

新建 Logger 接口,这个就是 SPI , 服务提供者接口,后面的服务提供者就要针对这个接口进行实现。

public interface Logger {

    void info(String msg);

    void debug(String msg);
}

接下来就是 LoggerService 类,这个主要是为服务使用者(调用方)提供特定功能的

public class LoggerService {

    private static final LoggerService SERVICE = new LoggerService();

    private final Logger logger;

    private final List<Logger> loggerList;

    private LoggerService() {
        ServiceLoader<Logger> loader = ServiceLoader.load(Logger.class);
        List<Logger> list = new ArrayList<>();
        for (Logger log : loader) {
            list.add(log);
        }
        // LoggerList 是所有 ServiceProvider
        loggerList = list;
        if (!list.isEmpty()) {
            // Logger 只取一个
            logger = list.get(0);
        } else {
            logger = null;
        }
    }

    public static LoggerService getService() {
        return SERVICE;
    }

    public void info(String msg) {
        if (logger == null) {
            System.out.println("info 中没有发现 Logger 服务提供者");
        } else {
            logger.info(msg);
        }
    }

    public void debug(String msg) {
        if (loggerList.isEmpty()) {
            System.out.println("debug 中没有发现 Logger 服务提供者");
        }
        loggerList.forEach(log -> log.debug(msg));
    }
}

新建 Main 类(服务使用者,调用方),启动程序查看结果。

public class Main {
    public static void main(String[] args) {
        LoggerService service = LoggerService.getService();

        service.info("Hello SPI");
        service.debug("Hello SPI");
    }
}

程序结果

info 中没有发现 Logger 服务提供者
debug 中没有发现 Logger 服务提供者

Service Provider

接下来新建一个项目用来实现 Logger 接口, 导入 Service Provider Interface的jar包

image

服务实现类

public class Logback implements Logger {
    @Override
    public void info(String msg) {
        System.out.println("Logback info 的输出:" + msg);
    }

    @Override
    public void debug(String msg) {
        System.out.println("Logback debug 的输出:" + msg);
    }
}

在resource目录下新建META-INF/services 创建文件名为Logger路径, 内容指定 Logback类路径

测试

在其他项目中引入上面两个项目

public class SpiTest {

    public static void main(String[] args) {
        Main.main(null);
    }
}

输出结果

Logback info 的输出:Hello SPI
Logback debug 的输出:Hello SPI

说明导入 jar 包中的实现类生效了。通过使用 SPI 机制,可以看出 服务(LoggerService)和 服务提供者两者之间的耦合度非常低,如果需要替换一种实现(将 Logback 换成另外一种实现),只需要换一个 jar 包即可。这也是 SLF4J 实现原理

查看mysql-connector包 也可以发现SPI机制的实现
image

线程上下文类加载器

查看 java.util.ServiceLoader.load 方法
image

发现加载的时候是通过当前线程的上下文类加载器来加载类文件的。

这是因为SPI 的接口是 Java 核心库的一部分,是由启动类加载器(Bootstrap ClassLoader)来加载的,但是SPI 实现的 Java 类一般是由应用程序类加载器(App-ClassLoader)来加载的。

启动类加载器是无法找到 SPI 的实现类的,因为它只加载 Java 的核心库。它也不能代理给应用程序类加载器,因为它是应用程序类加载器的祖先类加载器。也就是说,类加载器的代理模式无法解决这个问题。

线程上下文类加载器正好解决了这个问题。如果不做任何的设置,Java 应用的线程的上下文类加载器默认就是应用程序类加载器。在 SPI 接口的代码中使用线程上下文类加载器,就可以成功的加载到 SPI 实现的类。线程上下文类加载器在很多 SPI 的实现中都会用到

Dubbo SPI

Dubbo 中实现了一套新的 SPI 机制,功能更强大,也更复杂一些。相关逻辑被封装在了ExtensionLoader 类中,通过 ExtensionLoader,我们可以加载指定的实现类。Dubbo SPI 所需的配置文件需放置在 META-INF/dubbo 路径下

与 Java SPI 实现类配置不同,Dubbo SPI 是通过键值对的方式进行配置,这样我们可以按需加载指定的实现类。另外在使用时还需要在接口上标注 @SPI 注解

案例

@SPI
public interface Robot {
    void sayHello();
}

public class OptimusPrime implements Robot {
    
    @Override
    public void sayHello() {
        System.out.println("Hello, I am Optimus Prime.");
    }
}

public class Bumblebee implements Robot {

    @Override
    public void sayHello() {
        System.out.println("Hello, I am Bumblebee.");
    }
}

public class DubboSPITest {

    @Test
    public void sayHello() throws Exception {
        ExtensionLoader<Robot> extensionLoader = 
            ExtensionLoader.getExtensionLoader(Robot.class);
        Robot optimusPrime = extensionLoader.getExtension("optimusPrime");
        optimusPrime.sayHello();
        Robot bumblebee = extensionLoader.getExtension("bumblebee");
        bumblebee.sayHello();
    }
}

Dubbo SPI 和 JDK SPI 最大的区别就在于支持别名,可以通过某个扩展点的别名来获取固定的扩展点。就像上面的例子中,我可以获取 Robot 多个 SPI 实现中别名为“optimusPrime”的实现,也可以获取别名为“bumblebee”的实现,这个功能非常有用!

通过 @SPI 注解的 value 属性,还可以默认一个“别名”的实现。比如在Dubbo 中,默认的是Dubbo 私有协议
image

在 Protocol 接口上,增加了一个 @SPI 注解,而注解的 value 值为 Dubbo ,通过 SPI 获取实现时就会获取 Protocol SPI 配置中别名为dubbo的那个实现,com.alibaba.dubbo.rpc.Protocol文件如下:
image

然后只需要通过getDefaultExtension,就可以获取到 @SPI 注解上value对应的那个扩展实现了

Dubbo 的 SPI 中还有一个“加载优先级”,优先加载内置(internal)的,然后加载外部的(external),按优先级顺序加载,如果遇到重复就跳过不会加载了。

Spring SPI

Spring 的 SPI 配置文件是一个固定的文件 - META-INF/spring.factories,功能上和 JDK 的类似,每个接口可以有多个扩展实现,使用起来非常简单
image

image

Spring SPI 中,将所有的配置放到一个固定的文件中,省去了配置一大堆文件的麻烦。Spring的SPI 虽然属于spring-framework(core),但是目前主要用在spring boot中

和前面两种 SPI 机制一样,Spring 也是支持 ClassPath 中存在多个spring.factories文件的,加载时会按照 classpath 的顺序依次加载这些 spring.factories 文件,添加到一个 ArrayList 中。由于没有别名,所以也没有去重的概念,有多少就添加多少

但由于 Spring 的 SPI 主要用在 Spring Boot 中,而 Spring Boot 中的 ClassLoader 会优先加载项目中的文件,而不是依赖包中的文件。所以如果在你的项目中定义个spring.factories文件,那么你项目中的文件会被第一个加载,得到的Factories中,项目中spring.factories里配置的那个实现类也会排在第一个

如果我们要扩展某个接口的话,只需要在你的项目(spring boot)里新建一个META-INF/spring.factories文件,只添加你要的那个配置,不要完整的复制一遍 Spring Boot 的spring.factories文件然后修改

总结

JDK SP DUBBO SPI Spring 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 完美支持语法提示。
posted @ 2022-01-15 23:58  狻猊的主人  阅读(851)  评论(0编辑  收藏  举报