Java SPI机制的理解与应用

背景

一位前辈在一次技术分享中指出我们目前的包管理不规范,模块间职责有重叠,理解成本高不易维护,提出在开发过程中应当明确按照职责将服务划分到对应的模块中。

比如我们把所有服务都放在service层,但其实服务也是分为基础服务和业务逻辑服务的,或许把类似业务数据查询组装服务放在service层,把具体业务逻辑服务统一放在business层会更好,更利于基础服务的复用。

但当服务拆离到不同模块进行复用时,可能在开发过程中出现服务依赖的问题,这部分依赖问题的解耦可以用到Java的SPI机制。显然,我从来没有听说过SPI是什么,也不明白这有什么好处。

SPI是什么

翻遍各种网上资料,来来回回都是车轱辘话,互相抄来抄去讲得并不通俗易懂,这里就用我自己的理解来解释。

SPI(Service Provider Interface),大意是“服务提供者接口”,是指在服务使用方角度提出的“接口要求”,是对“服务提供方”提出的约定,简单说就是:“我需要这样的服务,现在你们来满足”。

API(Application Programming Interface)与之相对,是站在服务提供方角度提供的无需了解底层细节的操作入口,即“我有这样的服务可以给你使用”。

SPI与API的出发点截然不同,但作用与目的是相同的,即面向接口编程,也就是解耦。同时SPI使用的是一种“插件思维”,即服务提供者负责所有的使用维护,当替换服务提供方时不要说调用方不修改代码,连配置文件都不需要修改(不过可能要修改依赖的jar)。

模块化插件

为什么要用SPI

  • 在某些情况下,我们无法预知将会使用哪一个服务,比如无比经典的JDBC驱动、日志输出;
  • 某些情况下,服务提供方发生变化时服务调用方修改/维护代码或配置的成本非常高,如Dubbo、Motan、Spring等框架实现扩展。

举个例子,隔壁部门觉得我们的一个现有服务很棒,希望我们在其专用环境部署一份,同时希望以后的所有迭代能够给他们也更新。但是使用的自研中间件我们使用的内网版本他们使用公网版本,支付上我们对接支付宝他们对接微信......在业务逻辑不变但切换基础服务时应该如何维护使成本最小?

方案 优点 缺点
维护两套代码 逻辑一致 实现简单但维护成本高
同一套代码,在业务逻辑中区分环境 维护成本低,统一管理 逻辑复杂,需要硬编码,当再出现新环境时还得折腾
SPI“插件”方式 维护成本低,无需针对实现方硬编码,更多新环境或服务提供方变化时修改简单且不影响原有逻辑 理解成本提高

这也许就是一些框架在发展过程中经历过的阶段,可以发现使用“插件”能更好满足这个需求。

SPI原理

试想一下,如果要实现这样的解耦方式,理想情况下应该如何做?不外乎就是以下几点:

  1. 服务调用方定义接口,并在主干服务中设置接入点
  2. 服务提供方实现接口,并按照约定将实现类放在调用方可达的位置
  3. 调用方基于约定找到对应位置,将对应接口的实现类加载到内存并连接至接入点
  4. 后续服务提供方发生变更/替换时,只要仍然保持按照约定将新的提供方实现类替换到对应位置即可,调用方无需任何修改

这是一种与IOC相同的思路,将装配控制权转移至程序外,由配置决定,切换成本低。

java.util.ServiceLoader提供的SPI加载方式

这个类非常简单,是原生支持的SPI加载方式,实际代码量也就200行左右。

关键点:

  1. 关键方法签名:public static <S> ServiceLoader<S> load(Class<S> service)
    • 实现了前文中的第1点,即提供接入点设置
    • 在服务的接入中,形如ServiceLoader<SomeService> loader = ServiceLoader.load(SomeService.class);可设置接入对应的接口
  2. 常量:private static final String PREFIX = "META-INF/services/";
    • 约定了上述第2点中指定的位置,基于约定的配置读取会从这里查找,当然这是指服务提供方提供的jar中的META-INF/services/目录
  3. 服务提供方的实现类在jar中,而只要在提供方定义好实现类与调用方接口之间的关系即可满足调用方的加载需求
    • 实现了上述第4点中的,只需要提供方按照约定提供实现类及实现关系,可以做到提供方替换时调用方无需任何修改
    • 在对应位置META-INF/services/下,文件名应为接口全限定名,内容每行为一个实现类全限定名
  4. 类签名:public final class ServiceLoader<S> implements Iterable<S>
    • ServiceLoader实现了Iterable接口,因为实现类与接口之间是多对一关系,服务提供方是有可能对一个接口提供多种实现的,因此加载时也可以加载多个实现类
    • 迭代器签名:private class LazyIterator implements Iterator<S>,实现了懒加载迭代,即迭代到对应的类才加载对应的类
  5. 迭代器中的方法:private boolean hasNextService()private S nextService()
    • 分别对应了迭代器中的hasNext()方法和next()方法
    • 实现了前文中第3点,即从约定位置读取实现类的全限定名称,并从jar中加载对应的类
    • 使用Class.forName加载类,使用newInstance初始化实例,cast进行强制类型转换最终得到实例,因此实现类必须提供无参构造方法

怎样使用SPI

清楚原理后,使用方式就很好理解。

step.1 调用方定义接口

package com.xxx;

public interface IHelloWorld {
    void sayHello();
}

step.? 使用API方式实现接口

非必选,对照看一下非SPI的方式。

package com.xxx;

public class HelloWorldApi implements IHelloWorld {
    @Override
    public void sayHello() {
        System.out.println("Hello API!");
    }
}

step.2 调用方在业务代码中使用ServiceLoader

package com.xxx;

import java.util.ServiceLoader;

public class Main {
    public static void main(String[] args) {
        // 使用API
        IHelloWorld helloWorldApi = new HelloWorldApi();
        helloWorldApi.sayHello();

        // 使用SPI
        ServiceLoader<IHelloWorld> loader = ServiceLoader.load(IHelloWorld.class);
        for (IHelloWorld helloWorldSpi : loader) {
            helloWorldSpi.sayHello();
        }
    }
}

主要区别在于SPI方式并不需要知道实现类是谁,完全面向接口使用,类似RPC调用的情况;而API要求在业务方代码/配置中指明实现类。

step.3 提供方实现接口

这里提供两个实现类。

package com.xxx;

public class HelloWorldSpi1 implements IHelloWorld {
    @Override
    public void sayHello() {
        System.out.println("Hello SPI 1!");
    }
}
package com.xxx;

public class HelloWorldSpi2 implements IHelloWorld {
    @Override
    public void sayHello() {
        System.out.println("Hello SPI 2!");
    }
}

可以看出,实现方式与API方式完全一致。

step.4 提供方提供配置

文件位于/resources/META-INF/services,文件名为com.xxx.IHelloWorld即接口全限定名称。

/resources/META-INF/services/com.xxx.IHelloWorld的内容为两个实现类的全限定名称:

com.xxx.HelloWorldSpi1
com.xxx.HelloWorldSpi2

ps. 通常调用方与提供方不在同一个jar中

输出结果

Hello API!
Hello SPI 1!
Hello SPI 2!

具体应用方式

参考我们常用的JDBC,我们在同一套代码中可能需要利用相同接口但不同实现的情况下,可以在代码中利用SPI接入面向接口编程,在业务中不考虑具体的底层实现。

具体的底层实现可以分离出来,将每组实现和SPI配置文件打包成不同的jar,在具体使用时根据需要使用不同的jar即可。

具体实现可随时替换,不修改业务代码或配置

mysql-connector-java:5.1.47包的META-INF/services/目录下有个java.sql.Driver文件,内容为:

com.mysql.jdbc.Driver
com.mysql.fabric.jdbc.FabricMySQLDriver

这是JDBC 4.0之后使用SPI机制直接获取实现,避免之前使用Class.forName("com.mysql.jdbc.Driver")方式加载MySQL驱动时的硬编码。详情可见java.sql.DriverManager类中的静态代码块:

static {
    loadInitialDrivers();   // 这里使用ServiceLoader获取具体的Driver接口实现
    println("JDBC DriverManager initialized");
}

原生SPI的缺点

  1. 只能根据提供方的配置来获取实现类,当提供方提供多个实现时无法直接指定具体使用哪一个实现。当然,这正是这个解耦机制上必须要做的牺牲,否则就破坏了“不修改代码”的初衷。但是这一点可以在自定义扩展时优化
  2. 非单例,每次load都会创建新的实例,建议自行优化,注意并发问题

参考资料

理解的Java中SPI机制 - 掘金

推荐阅读更多精彩内容