Java SPI机制的理解与应用

微信扫一扫,分享到朋友圈

Java SPI机制的理解与应用

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

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

但当服务拆离到不同模块进行复用时,可能在开发过程中出现服务依赖的问题,这部分依赖问题的解耦可以用到JavaSPI机制。显然,我从来没有听说过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)
    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>
    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");
}
复制代码

透视HTTP协议-HTTP的传输、连接、重定向及Cookie机制

上一篇

小区担心辐射大阻挠5G基站施工:三大运营商拆设备退场

下一篇

你也可能喜欢

Java SPI机制的理解与应用

长按储存图像,分享给朋友