# SPI
# Flag
- https://github.com/McModLauncher/modlauncher (opens new window)
- https://github.com/SpongePowered/Mixin (opens new window)
- https://github.com/FabricMC (opens new window)
- https://github.com/MinecraftForge/MinecraftForge (opens new window)
- https://github.com/Chocohead/OptiFabric (opens new window)
- https://github.com/Mojang/brigadier (opens new window)
- https://github.com/osgi (opens new window)
- OSGI(Open Service Gateway Initiative),是一个由OSGi Alliance发起的以Java为技术平台的动态模块化规范
- https://github.com/bndtools/bnd (opens new window)
- https://github.com/eclipse-equinox (opens new window)
- Jigsaw Java9 modules
- 观察者设计模式 Java设计模式之观察者模式 (opens new window)
- Spring 实现
ApplicationContextAware
接口获取到指定接口的所有实现
- https://github.com/Enaium/BullPlugin (opens new window)
- springboot插件式开发框架 https://gitee.com/starblues/springboot-plugin-framework-parent (opens new window)
- 从Java SPI机制实现到Dubbo SPI扩展 (opens new window)
- 从Java SPI机制实现到Spring Boot SPI扩展 (opens new window)
- AVA SPI机制详解 (opens new window)
- 深入理解SPI机制 (opens new window)
热插拔/热加载/热部署/热更新/HotSwap
- https://www.jrebel.com/products/jrebel/download (opens new window)
- https://github.com/spring-projects/spring-loaded (opens new window)
- https://github.com/HotswapProjects (opens new window)
- https://github.com/dcevm (opens new window)
- https://github.com/fakereplace (opens new window)
- https://github.com/jmarranz/relproxy (opens new window)
- https://github.com/cm4j (opens new window)
- https://github.com/dmitry-zhuravlev/hotswap-agent-intellij-plugin#solution (opens new window)
- 深入探索 Java 热部署 (opens new window)
- java热更新class如何实现? (opens new window)
- Java的类加载机制及热部署的原理 (opens new window)
- JAVA热部署,通过agent进行代码增量热替换 (opens new window)
- Java 调试技术 JPDA 架构解读 (opens new window)
# 什么是 SPI?
SPI 全称为(Service Provider Interface),字面意思为服务提供者接口,是JDK 内置的一种服务提供发现机制。 这一机制为很多框架的扩展提供了可能,比如在 Dubbo、JDBC、Spring Boot 中都使用到了 SPI 机制。 说白了就是提供给“服务提供厂商”或者“插件开发者”使用的接口
SPI 是一种动态发现替换机制,例如我们在学习 Java Web 的时候连接数据库使用的 java.sql.Driver 接口,可以根据不同的驱动, 连接不同的数据库,如常用的 MySQL 或者 Oracle 数据库,,我们在使用 JDBC 连接数据库的时候首先需要的就是连接驱动:
Class.forName("com.mysql.jdbc.Driver")
加载 MySQL 驱动后,就会 执行其中的静态代码,把 Driver 注册到 DriverManager 中那么通过数据库的 url、用户名、密码, 我们就可以成功连接到你的 MySQL 数据库并可以进行相应的操作,如果你要更换成 Oracle 数据库,那么就需要更换对应的驱动, 下面是 JDBC 连接数据库的一个步骤,帮助大家回忆:
//声明数据库驱动,数据源的 url,用于登录数据库的账户和密码(将其他功能封装成方法的时候方便使用)
String driver = "数据库驱动名称";
String url = "数据库连接地址"String user = "用来连接数据库的用户名";
String pwd = "用来连接数据库的密码";
//加载数据库驱动
Class.forName(driver);
//根据 url 创建数据库连接对象 Connection
Connection con = DriverManager.getConnection(url,user,pwd);
//用数据库连接对象创建 Statement 对象(或 PrepareStatement)
Statement s = con.createStatement();
//或
PrepareStatement ps = con.PrepareStatement(sql);
//做数据库的增删改查工作
ResultSet rs = ps.executeQuery();
//关闭结果集对象 Resultset,statement 对象,connection 对象,
rs.close();
s.close();
con.close();
//各个步骤的异常处理
结合上面的代码和下面的图片来简单分析一下。
我们在使用 MySQL 的数据库时,需要导入一个 MySQL 的连接驱动包,打开这个驱动包,你会发现在下图的目录中有一个文件,
Class.forName(driver)
它会去找到这个 com.mysql.jdbc.Driver 的类,然后用 DriverManager 加载这个类,
然后再去使用这个类中的方法,例如 con.PrepareStatement(sql);
就是使用的 com.mysql.jdbc.Driver 这个类中的方法,
同理如果你将驱动换成 Oracle,那么 DriverManager 就会得到 Oracle 的连接对象,那么 con.PrepareStatement(sql);
调用的就是 Oracle 对应驱动中的方法,也就是说,如果我们将数据库换成 Orale,理论上,上面的操作数据库的代码是不需要变动的,
只需要更换驱动、url 和账号密码,这部分我们后面都是以配置文件的形式写入,所以很好的将代码和数据库解耦了。
下图是我在网上找到图片:
如果你还是不能很好的理解,没有关系,接下来,我们就慢慢剖析这个 SPI。
# JDK 中的 SPI
# 实例以及测试
我们先从 JDK 开始,通过一个很简单的例子来看下它是怎么用的。
这是例子的代码结构:
首先,我们需要定义一个接口 SPIService。
这个接口只有一个打印的方法:
public interfaceSPIService {
voidprint();
}
然后我们再定义一个实现类,只做打印输出:
public classSPIServiceImplimplementsSPIService{
publicvoidprint(){
System.out.println("print..............");
}
}
然后我们需要在 resources 下创建文件夹:META-INF/services 然后在 services 文件夹下创建文件,文件名就是服务接口的全限定类名:
文件的内容就是该接口的实现类的全限定类名。
文件内容:
com.spi.service.impl.SPIServiceImpl
然后我们就可以通过 ServiceLoader.load 方法拿到实现类的实例,并调用它的方法。
我们在启动类中测试:
package com.spi;
import com.spi.service.SPIService;
import java.util.Iterator;
import java.util.ServiceLoader;
publicclassSPIApplication{
publicstaticvoidmain(String[] args){
//加载类
ServiceLoader<SPIService> load = ServiceLoader.load(SPIService.class);
Iterator<SPIService> iterator = load.iterator();
while (iterator.hasNext()){
//获取类的实例
SPIService service = iterator.next();
service.print();
}
}
}
运行结果:
# 源码分析
首先,我们先来了解下 ServiceLoader,看看它的类结构:
public finalclassServiceLoader<S> implementsIterable<S>{
//配置文件的路径privatestaticfinal String PREFIX = "META-INF/services/";
//加载的服务类或接口privatefinal Class<S> service;
//已加载的服务类集合private LinkedHashMap<String,S> providers = new LinkedHashMap<>();
//类加载器privatefinal ClassLoader loader;
//内部类,真正加载服务类private LazyIterator lookupIterator;
}
当我们调用 load 方法时,并没有真正的去加载和查找服务类。而是调用了 ServiceLoader 的构造方法, 在这里最重要的是实例化了内部类 LazyIterator,ServiceLoader 才是接下来的主角:
private ServiceLoader(Class<S> svc, ClassLoadercl) {
//要加载的接口
service = Objects.requireNonNull(svc, "Service interface cannot be null");
//类加载器
loader = (cl == null) ? ClassLoader.getSystemClassLoader() : cl;
//访问控制器
acc = (System.getSecurityManager() != null) ? AccessController.getContext() : null;
//先清空
providers.clear();
//实例化内部类
LazyIterator lookupIterator = new LazyIterator(service, loader);
}
查找实现类和创建实现类的过程,都在 LazyIterator 完成。当我们调用 iterator.hasNext 和 iterator.next 方法的时候, 实际上调用的都是 LazyIterator 的相应方法:
public Iterator<S> iterator() {
returnnew Iterator<S>() {
publicbooleanhasNext(){
return lookupIterator.hasNext();
}
public S next(){
return lookupIterator.next();
}
.......
};
}
因此,我们重点关注 lookupIterator.hasNext()
方法,它最终会调用到 hasNextService
,在这里返回实现类名称:
private classLazyIteratorimplementsIterator<S>{
Class<S> service;
ClassLoader loader;
Enumeration<URL> configs = null;
Iterator<String> pending = null;
String nextName = null;
privatebooleanhasNextService(){
// 第二次调用的时候,已经解析完成了,直接返回
if (nextName != null) {
returntrue;
}
if (configs == null) {
// META-INF/services/ 加上接口的全限定类名,就是文件服务类的文件
// META-INF/services/com.viewscenes.netsupervisor.spi.SPIService
String fullName = PREFIX + service.getName();
//将文件路径转成 URL 对象
configs = loader.getResources(fullName);
}
while ((pending == null) || !pending.hasNext()) {
//解析 URL 文件对象,读取内容,最后返回
pending = parse(service, configs.nextElement());
}
//拿到第一个实现类的类名
nextName = pending.next();
returntrue;
}
}
然后当我们调用 next()
方法的时候,调用到 lookupIterator.nextService
,它通过反射的方式,创建实现类的实例并返回:
private S nextService() {
//全限定类名
String cn = nextName;
nextName = null;
//创建类的 Class 对象
Class<?> c = Class.forName(cn, false, loader);
//通过 newInstance 实例化
S p = service.cast(c.newInstance());
//放入集合,返回实例
providers.put(cn, p);
return p;
}
到这为止,已经获取到了类的实例,这就是 SPI 机制的一个内部原理。
# SPI 如何实现代码的解耦?
其实在前面提到 JDBC 的时候已经大致了解了 SPI 解耦,那么我们就再结合前面的实例,用通俗的语言概述一下吧。
我们可以通过下面这个简单的流程图来进一步理解 SPI 是如何解耦和扩展的。
首先需要定义一个标准化的服务接口,例如上面的实例 SPIService,里面有一些服务例如 print,然后实现这个接口, 我们暂定它为实现类 A(SPIServiceImpl),它实现的 print 方法里面可以自定义实现内容,如果现在要求说要再增加一种打印的方式, 但是保留之前的 print 的打印方式,那么我们就可以再定义一个实现类 B 去实现这个标准化的服务接口,如果后面再增加新的打印方式也一样, 直接加,或者某一种不需要了,直接去掉即可。
那么由此可见,它是将标准的服务接口与服务提供方实现类进行解耦的,我们可以思考一下,它进行扩展是非常方便的,只需要实现标准服务接口,
然后在 META-INF/services
对应的文件中添加该实现类的全限定类名,然后在实现类的方法中填充实现就可以了。
但如果你要是想修改标准服务接口的方法定义,那么就比较麻烦了,只要是实现这个接口的类都需要改,也就是软件设计原则提到的开闭原则, 因此我们需要一开始就设计好标准服务接口的内容,保证软件系统的稳定性和延续性。
# SPI 适合什么场景下使用?
比较常见的例子:
- 数据库驱动加载接口实现类的加载:JDBC 加载不同类型数据库的驱动。
- 日志门面接口实现类加载:SLF4J 加载不同提供商的日志实现类。
- Spring:Spring 中大量使用了 SPI,比如:对 servlet3.0 规范对 ServletContainerInitializer 的实现、 自动类型转换 Type Conversion SPI(Converter SPI、Formatter SPI)等。
- Spring Boot 的自动配置:Spring Boot 的 Web 应用都能正常运行,Spring Boot 正是依靠自动配置来完成, 自动配置就是依靠 SpringFactoriesLoader 来加载的。
- Dubbo:Dubbo 中也大量使用 SPI 的方式实现框架的扩展,不过它对 Java 提供的原生 SPI 做了封装,允许用户扩展实现 Filter 接口。
概括地说,适用于:调用者根据实际使用需要,启用、扩展、或者替换方案(框架)的实现策略。
很多地方写的是替换框架,但是我在这里改成了方案,更方便理解(如果这里有一些争议的话,欢迎大家在评论区留言), 实际上我们可以把每一个实现类都叫做一种方案,例如我们前面提到的 SPIService 的实现类 A 和 B,就是对 print 这个方法的两种方案, SLF4J 加载不同提供商的日志实现类实际上也是加载不同的日志方案,因此实际上如果我们想在自己的项目中运用到 SPI, 那么它最好的使用就是对某一个事件不同方案的处理,例如给公司员工计算月/年薪资(里面包含了绩效、KPI、考勤、奖金等等), 公司针对不同的层级或者区域的员工有不同的方案,这个例子的事件就是薪资计算,不管你什么类型什么等级的员工, 计算薪资的总和都是这几项加起来的结果,不同的是每一项的根据不同的实现方案计算内容有一定的差别。
# 使用 SPI 的优势和劣势在哪里?
优点:
不用多说,优势就是实现解耦,使得第三方服务模块的装配控制的逻辑与调用者的业务代码分离,而不是耦合在一起。 应用程序可以根据实际业务情况启用框架扩展或替换框架组件,或者调整不同的方案。满足软件设计的开闭原则。
缺点:
虽然 ServiceLoader 也算是使用的延迟加载,但是基本只能通过遍历全部获取,也就是接口的实现类全部加载并实例化一遍, 如果你并不想用某些实现类,它也被加载并实例化了,这就造成了浪费。
获取某个实现类的方式不够灵活,只能通过 Iterator 形式获取,不能根据某个参数来获取对应的实现类。
一般我们在实际项目开发的时候,会使用枚举来确定不同方案,然后每个方案都有一个 getSchemeId 的方法用于返回这个方案对应的 ID, 通过这个 id 去筛选当前需要的方案。
小例子:
//动态加载 SPIService 的所有方案实现类
List<SPIService> services = SpiServiceLoader.getService(SPIService.class);
for (SPIService service: services) {
//获取当前这个人对应方案的实现类
if (Objects.equals(service.getSchemeId(), person.getSchemeId())) {
//使用这个方案的实现类
service.print();
break;
}
}
这个里面 SpiServiceLoader 是自己封装的 SPI 加载器,相当于把前面实例中的 main 中的 ServiceLoader 和 Iterator 封装起来了。
- 多个并发多线程使用 ServiceLoader 类的实例是不安全的。 实际上这个可以解决,我们在第 2 点的基础上修改。
自己封装的 SpiServiceLoader 在使用 getService 中会调用 ServiceLoader,那么我们给 getService 方法加上锁 synchronized
就可以解决并发的问题了。
这里至于为什么没有放 SpiServiceLoader,是因为这个代码是公司的代码,不能随便用来写文章,所以,这里提供了思路,实现就没有那么难了。
以上就是我对 SPI 机制的理解和总结,实际上真实的项目中远比这个要复杂。