SPI机制:Java SPI原理及源码剖析、应用场景分析与自实现案例实战详解
文章目录
- 一、背景
- 二、基本概念
- 2.1 何谓API
- 2.2 何谓SPI
- 2.3 SPI与API差异
- 三、Java SPI 工作原理与源码分析
- 3.1 SPI 原理总述
- 3.2 核心类ServiceLoader源码剖析
- 3.3 SPI 机制在JVM层面的体现
- 3.4 SPI 机制对双亲委派的打破
- 四、SPI 使用步骤
- 五、SPI 机制广泛应用
- 5.1 数据库驱动
- 5.2 日志框架
- 5.3 MapStruct工具
- 5.4 MyBatis框架
- 六、Java SPI 优缺点
- 6.1 优点
- 6.2 缺点
- 七、自实现SPI练手案例
- 7.1 定义接口
- 7.2 创建实现类
- 7.3 配置文件
- 7.4 加载使用服务
- 八、总结
- 九、参考资料
一、背景
在当今互联网公司中,各个团队经常会开发一些功能组件,那对于我们开发人员来说,如何实现高效的组件化和模块化已经成为了一个重要的问题。那么在这些组件实现及接入的过程当中,可能经常会听到这样一个名词:SPI机制
。其实这个名词并不陌生,大家在学习Java 生态里的相关组件的原理时,可能时常会听说过,但是很少会去深入探究。那么本着学习与总结的目的,在本篇博客中,我们将深入探讨 Java SPI 的概念、实现原理、优缺点、应用场景和使用步骤,并通过实战演示来说明如何使用 Java SPI 实现各种功能。
二、基本概念
2.1 何谓API
API,全称Application Programming Interface,字面意思:“应用程序编程接口” 。API是一组规则和定义,允许一个软件应用与另一个软件应用、库或操作系统进行交互。它定义了如何进行数据传输、请求服务或执行特定功能的协议和工具。API为开发人员提供了一种标准化的方式来访问和使用预先构建的功能,而无需了解这些功能内部的复杂实现细节。
2.2 何谓SPI
SPI,全称Service Provider Interface,字面意思就是:“服务提供者的接口”,其是JDK内置的一种服务提供发现机制。它用于实现框架或库的扩展点,允许在运行时动态地加载实现了某个接口或抽象类的具体实现类。SPI的关键特性就是可插拔性和动态加载。
SPI提供了一种框架来发现和加载服务实现,使得软件模块能够灵活地选择和使用不同的服务提供商。SPI的核心思想是将接口的定义和实现分离,通过配置文件的形式来动态加载实现类,从而实现解耦。
这种方式使得服务的消费者不需要直接依赖于具体的服务实现,符合面向对象设计原则中的“对扩展开放,对修改关闭”原则(Open/Closed Principle)。
SPI整体机制图如下:
2.3 SPI与API差异
从SPI与API的定义来看,它俩都是一种接口,那么它俩的差异我们该如何理解呢?从面向接口编程的思想来看,「服务调用方」应该通过调用「接口」而不是「具体实现」来处理逻辑。那么,对于「接口」的定义,应该在「服务调用方」还是「服务提供方」呢?
情况1: 先来看看「接口」属于「提供方」的情况。这个很容易理解,提供方同时提供了「接口」和「实现类」,「调用方」可以调用接口来达到调用某实现类的功能,这就是我们日常使用的 API 。
API 的显著特征:接口和实现都在服务提供方中。自定义接口,自己去实现这个接口,也就是提供实现类,最后提供给外部去使用
情况2: 那么再来看看「接口」属于「调用方」的情况。这个其实就是 SPI 机制。以 JDBC 驱动为例,「调用方」定义了java.sql.Driver接口(没有实现这个接口),这个接口位于「调用方」JDK 的包中,各个数据库厂商(也就是服务提供方)实现了这个接口,比如 MySQL 驱动 com.mysql.jdbc.Driver 。
SPI的显著特征:「接口」在「调用方」的包,「调用方」定义规则,而实现类在「服务提供方」中
差异总结如下表格:
名称 | 目的 | 使用者 | 举例 |
---|---|---|---|
SPI | 支持可插拔的架构,便于组件和服务的替换与扩展。 | 主要由服务提供者(如库、框架开发者)实现,但也需要应用开发者配置以启用特定的服务实现。(这里说的配置一般就是引入jar或者maven、gradle的坐标即可) | Java中,SL4J日志的加载就是一个典型的SPI应用。 |
API | 简化开发过程,提高效率,促进不同系统间的互操作性。 | 通常由应用开发者使用,以集成外部服务或内部模块。 | 语音识别 API、文件上传 API等 |
三、Java SPI 工作原理与源码分析
在了解完API和SPI的差异之后,那么Java SPI 究竟是如何工作的呢?本节先介绍SPI机制实现原理的几大步骤,再具体分析一下核心加载类ServiceLoader。实际上,Java SPI 机制依赖于 ServiceLoader 类去解析、加载服务。因此,掌握 ServiceLoader 的工作流程是掌握SPI 的原理的基本前提。
3.1 SPI 原理总述
Java SPI 的实现原理基于 Java 类加载机制和反射机制。
SPI 的工作原理可以大致简化为如下4步:
1. 定义服务接口
接口的定义一般由Java核心库或框架定义标准接口(如java.sql.Driver)完成,而接口的加载通常由启动类加载器(Bootstrap ClassLoader)
完成,属于核心类库。
2. 实现服务接口
接口的实现一般由第三方服务提供方来实现,(如MySQL的com.mysql.cj.jdbc.Driver),实现类位于应用类路径,而实现类的加载由应用程序类加载器(Application ClassLoader)
完成。
3. 注册服务提供者
注册服务需要在第三方JAR包的META-INF/services/目录下创建以接口全限定名命名的文件(如java.sql.Driver),然后往文件里写入实现类的全限定名(如com.mysql.cj.jdbc.Driver)。
4. 加载服务实现
使用工具类 ServiceLoader.load方法
加载服务时,会检查 META-INF/services 目录
下是否存在以接口全限定名命名的文件。如果文件存在,则读取文件内容,获取实现该接口的类的全限定名,并通过 Class.forName() 方法
加载对应的类。在加载类之后,ServiceLoader 会通过反射机制创建对应类的实例,并将其缓存起来。在这里还涉及到一个懒加载迭代器的思想:
当我们调用 ServiceLoader.load方法时,并不会立即将所有实现了该接口的类都加载进来,而是返回一个懒加载迭代器,只有在使用迭代器遍历时,才会按需加载对应的类并创建其实例。
懒加载思想好处:
1.节省内存:如果一次性将所有实现类全部加载进来,可能会导致内存占用过大,影响程序的性能。
2.增强灵活性:由于 ServiceLoader 是动态加载的,因此可以在程序运行时添加或删除实现类,而无需修改代码或重新编译。
3.2 核心类ServiceLoader源码剖析
由于 Java 的 SPI 机制是需要依赖 ServiceLoader 来实现的,那么本小节我们就进入到 ServiceLoader源码中来分析一番,看看具体是怎么实现的:
(1)首先我们从官方给的定义或源码都能发现这个ServiceLoader 类是一个 final 类型的,所以其是不可被继承修改的,同时它实现了 Iterable 接口。之所以实现了迭代器,它是为了方便后续我们能够通过迭代的方式得到对应的服务实现。
public final class ServiceLoader<S> implements Iterable<S> { xxx...}
(2)然后再来看一下 ServiceLoader 类的成员变量,先大致在脑海了有个印象,后面的源码中都会使用到:
// SPI 配置文件目录
private static final String PREFIX = "META-INF/services/";
// 定义将要被加载的 SPI 服务接口 或实现类
// The class or interface representing the service being loaded
private final Class<S> service;
// 用于加载 SPI 服务的类加载器
// The class loader used to locate, load, and instantiate providers
private final ClassLoader loader;
// ServiceLoader 创建时的访问控制上下文
// The access control context taken when the ServiceLoader is created
private final AccessControlContext acc;
// SPI 服务缓存,按实例化的顺序排列
// Cached providers, in instantiation order
private LinkedHashMap<String,S> providers = new LinkedHashMap<>();
// 懒查询迭代器
// The current lazy-lookup iterator
private LazyIterator lookupIterator;
(3)在对ServiceLoader类的定义和成员变量有了基本了解后,再往下可以先分析该类的功能入口:
// 使用线程上下文的类加载器来创建ServiceLoader(service传入的是期望加载的 SPI 接口类型)
public static <S> ServiceLoader<S> load(Class<S> service) {
ClassLoader cl = Thread.currentThread().getContextClassLoader();
return ServiceLoader.load(service, cl);
}
// 为指定的服务使用指定的类加载器来创建一个ServiceLoader(service传入的是期望加载的 SPI接口类型,loader是用于加载SPI服务的类加载器)
public static <S> ServiceLoader<S> load(Class<S> service,
ClassLoader loader) {
return new ServiceLoader<>(service, loader);
}
//私有构造器
//使用指定的类加载器和服务创建服务加载器
//如果没有指定类加载器,使用系统类加载器,就是应用类加载器。
private ServiceLoader(Class<S> svc, ClassLoader cl) {
// 安全性校验
service = Objects.requireNonNull(svc, "Service interface cannot be null");
// 指定类加载 ClassLoader 和访问控制上下文,这里是使用的应用程序类加载器进行加载,下面会进行验证。
loader = (cl == null) ? ClassLoader.getSystemClassLoader() : cl;
acc = (System.getSecurityManager() != null) ? AccessController.getContext() : null;
// 然后,重新加载 SPI 服务
reload();
}
public void reload() {
// 清空缓存中所有已实例化的 SPI 服务
providers.clear();
// 根据 ClassLoader 和 SPI 类型,创建懒加载迭代器
lookupIterator = new LazyIterator(service, loader);
}
通过上面源码也能看出:其解决第三方类加载的机制其实就蕴含在 ClassLoader cl = Thread.currentThread().getContextClassLoader(); 中,cl 就是线程上下文类加载器
(Thread Context ClassLoader)。这是每个线程持有的类加载器,JDK 的设计允许应用程序或容器(如 Web 应用服务器)设置这个类加载器,以便核心类库能够通过它来加载应用程序类。线程上下文类加载器默认情况下是应用程序类加载器
(Application ClassLoader),它负责加载 classpath 上的类。当核心库需要加载应用程序提供的类时,它可以使用线程上下文类加载器来完成。这样,即使是由引导类加载器加载的核心库代码,也能够加载并使用由应用程序类加载器加载的类。这里也可以很方便的在控制台打印验证线程上下文类加载器具体是哪个类加载器:
public class SpiStudyTest {
public static void main(String[] args) {
System.out.println(Thread.currentThread().getContextClassLoader());
System.out.println(ClassLoader.getSystemClassLoader());
}
}
代码验证执行效果如下:
(4)根据上面源码得出 reload() 方法中是通过一个内部类 LazyIterator 实现的,创建LazyIterator 懒加载迭代器对象,并没有马上去加载SPI的具体服务。当我们实际使用循环时,会触发迭代器,由于ServiceLoader 实现了 Iterable 接口的 iterator 方法后,其具有了迭代的能力,在这个 iterator 方法被调用时,首先会在 ServiceLoader 的 Provider 缓存中进行查找,如果缓存中没有命中那么则在 LazyIterator 中进行查找,代码如下:
// 返回遍历服务提供者的迭代器
// 以懒加载的方式加载可用的服务提供者
// 懒加载的实现是:解析配置文件和实例化服务提供者的工作由迭代器本身完成
public Iterator<S> iterator() {
return new Iterator<S>() {
// 第一次调用时 providers 是空集合,下面会调用lookupIterator也就是LazyIterator的实例
Iterator<Map.Entry<String, S>> knownProviders
= providers.entrySet().iterator();
public boolean hasNext() {
// 缓存里有,返回true
if (knownProviders.hasNext())
return true;
// 缓存里没有,调用LazyIterator
return lookupIterator.hasNext();
}
public S next() {
// 缓存里有,从缓存里获取值
if (knownProviders.hasNext())
return knownProviders.next().getValue();
// 缓存里没有,调用LazyIterator
return lookupIterator.next(); // 调用 LazyIterator
}
public void remove() {
throw new UnsupportedOperationException();
}
};
}
因为上面 iterator 方法中会调用到 LazyIterator ,下面看 LazyIterator 的实现具体的类加载就在这里:LazyIterator 的 hasNext 和 next方法 中判断了 acc 是否是null,其中 acc = (System.getSecurityManager() != null) ? AccessController.getContext() : null; 这段代码是在 ServiceLoader初始化的时候执行的,主要用于判断 当前Java运行环境中是否存在安全管理器(SecurityManager),默认情况下不会配置,如果配置了SecurityManager,某些敏感操作(比如文件访问、网络连接等)可能会受到安全策略的限制或需要相应的权限检查。这个不重要,我们还是看具体的方法 hasNextService 和 nextService方法。
// 服务提供者查找的懒加载迭代器
private class LazyIterator
implements Iterator<S>
{
Class<S> service; // 服务提供者接口
ClassLoader loader;// 类加载器
Enumeration<URL> configs = null;// 保存实现类的url
Iterator<String> pending = null;// 迭代器保存实现类的全名
String nextName = null;// 迭代器中下一个实现类的全名
private LazyIterator(Class<S> service, ClassLoader loader) {
this.service = service;
this.loader = loader;
}
private boolean hasNextService() {
if (nextName != null) {
return true;
}
if (configs == null) {
try {
// 通过PREFIX(META-INF/services/)和类名拼接成全限定类名称
String fullName = PREFIX + service.getName();
// 利用应用程序类加载器获取对应的配置文件
if (loader == null)
configs = ClassLoader.getSystemResources(fullName);
else
configs = loader.getResources(fullName);
} catch (IOException x) {
fail(service, "Error locating configuration files", x);
}
}
// 迭代器如果为空或者没有值
while ((pending == null) || !pending.hasNext()) {
// 配置内也没有值
if (!configs.hasMoreElements()) {
return false;
}
// 解析资源文件中的内容,获取SPI接口的实现类的全限定名nextName
pending = parse(service, configs.nextElement());
}
// 迭代器不为空,则直接获取下个值
nextName = pending.next();
return true;
}
private S nextService() {
if (!hasNextService())
throw new NoSuchElementException();
// nextName 在hasNextService中已经被赋值了
String cn = nextName;
nextName = null;
Class<?> c = null;
try {
// 关键点:最终还是利用Java的反射机制完成类的加载
// hasNextService()方法已经解析出了SPI实现类的的全限定名nextName,通过反射,获取SPI实现类的类定义Class
c = Class.forName(cn, false, loader);
} catch (ClassNotFoundException x) {
fail(service,
"Provider " + cn + " not found");
}
// 判断加载的Class的类型是否实现了给定的SPI接口
if (!service.isAssignableFrom(c)) {
fail(service,
"Provider " + cn + " not a subtype");
}
try {
// 通过反射实例化SPI的实现类并且转换成SPI接口类型
S p = service.cast(c.newInstance());
// 保存到LinkedHashMap<String,S> providers缓存中
providers.put(cn, p);
return p;
} catch (Throwable x) {
fail(service,
"Provider " + cn + " could not be instantiated",
x);
}
throw new Error(); // This cannot happen
}
public boolean hasNext() {
if (acc == null) {
// 调用LazyIterator的hasNextService()方法
return hasNextService();
} else {
PrivilegedAction<Boolean> action = new PrivilegedAction<Boolean>() {
// 调用LazyIterator的hasNextService()方法
public Boolean run() { return hasNextService(); }
};
return AccessController.doPrivileged(action, acc);
}
}
public S next() {
if (acc == null) {
// 调用LazyIterator的nextService()方法
return nextService();
} else {
PrivilegedAction<S> action = new PrivilegedAction<S>() {
// 调用LazyIterator的nextService()方法
public S run() { return nextService(); }
};
return AccessController.doPrivileged(action, acc);
}
}
public void remove() {
throw new UnsupportedOperationException();
}
}
可以从上面源码中发现解析资源文件中的内容,获取SPI接口的实现类的全限定名nextName在于parse方法,该方法源码如下:
private Iterator<String> parse(Class<?> service, URL u)
throws ServiceConfigurationError
{
InputStream in = null;
BufferedReader r = null;
// 保存解析配置文件内的 全限定类名
ArrayList<String> names = new ArrayList<>();
try {
in = u.openStream();
r = new BufferedReader(new InputStreamReader(in, "utf-8"));
int lc = 1;
// 一行一行的读取配置信息 并将每行解析出的全限定类名 保存到 names
// parseLine 方法不用看了 就是解析字符串的
while ((lc = parseLine(service, u, r, lc, names)) >= 0);
} catch (IOException x) {
fail(service, "Error reading configuration file", x);
} finally {
try {
// 开启了流进行读取,因此为防止溢出必须放在finally代码块里关闭流
if (r != null) r.close();
if (in != null) in.close();
} catch (IOException y) {
fail(service, "Error closing configuration file", y);
}
}
// 返回 拥有所有实现SPI接口的服务提供者全限定类名集合的迭代器
return names.iterator();
}
当梳理完这个ServiceLoader类的源码后,再回过头看前面介绍SPI 原理总述,不禁叹道:原来如此。
ServiceLoader加载步骤可以简化为如下:
ServiceLoader加载简要步骤:
1.获取上下文类加载器:ServiceLoader使用当前线程的上下文类加载器(默认是应用程序类加载器)。
2.扫描配置文件:在类路径下查找META-INF/services/目录中的接口文件。
3.加载实现类:使用上下文类加载器加载配置文件中声明的类,并实例化。
3.3 SPI 机制在JVM层面的体现
在JVM层面,SPI的运作依赖于以下机制:
1.类加载器协作: 通过上下文类加载器加载实现类。
2.资源发现机制: JVM规范约定从META-INF/services读取服务配置,实现类的全限定名按行存储。
3.动态加载: ServiceLoader在运行时按需加载实现类,支持懒加载和迭代器模式。
3.4 SPI 机制对双亲委派的打破
之前有学过JVM相关知识的朋友可能还会有印象:在JVM类加载过程当中,类的加载一般是遵循双亲委派机制的,但是有几种方式会破坏双亲委派机制,而其中之一就是本文介绍的SPI机制。
利用SPI打破双亲委派机制的典型案例就是利用JDBC进行数据库连接。SPI机制通过上下文类加载器(ThreadContextClassLoader)间接打破了双亲委派模型
,原因如下:双亲委派机制是指的类加载器加载类时,先委托父加载器尝试加载。父加载器无法完成时,子加载器才自己加载。父加载器(如启动类加载器)加载了接口(如 JDBC 的 Driver),但实现类(如 MySQL 驱动)在子加载器路径下。而按照双亲委派模型,BootstrapClassLoader 是无法找到 SPI 的实现类的。SPI机制使用 ServiceLoader 加载接口实现类。启动类加载器(父)加载了 Driver 接口。ServiceLoader 在加载实现类时,切换类加载器,用线程上下文类加载器(应用类加载器/子加载器)去加载 Driver 的实现类。父加载器通过子加载器完成了原本不可能的任务。这一结论不论是在上文中的ServiceLoader 类的源码剖析,亦或是下文中SPI机制在JDBC中的应用中都有所体现。
SPI机制本身不直接破坏双亲委派,但为了解决跨类加载器的类加载问题,需借助上下文类加载器绕过双亲委派,从而间接打破了这一机制。
四、SPI 使用步骤
在掌握了SPI 使用原理后,我们也不难总结出SPI的使用步骤:
1.定义接口: 首先需要定义一个接口,所有实现该接口的类都将被注册为服务提供者。
2.创建实现类: 创建一个或多个实现接口的类,这些类将作为服务提供者。
3.配置文件: 在 META-INF/services 目录下创建一个以接口全限定名命名的文件,文件内容为实现该接口的类的全限定名,每个类名占一行。
4.加载使用服务: 使用 java.util.ServiceLoader 类的静态方法 load(Class service) 加载服务,默认情况下会加载 classpath 中所有符合条件的提供者。调用 ServiceLoader 实例的 iterator() 方法获取迭代器,遍历迭代器即可获取所有实现了该接口的类的实例。
五、SPI 机制广泛应用
既然SPI 机制这么好用,那么它在Java开发中有那些应用场景呢?它是如何实现的呢?接下来本文会着重详细介绍几个典型应用场景:
5.1 数据库驱动
Java中SPI机制最典型的应用莫过于JDBC,但当时大家学习的时候可能并没有注意。这里可以探讨一下Java 数据库驱动加载的原理,下面是用 JDBC 去连接 MySQL 数据库的基本步骤,程序在加载DriverManager 类时,会将 MySQL 的 Driver 对象注册进 DriverManager 中,这是 SPI 思想的一个典型的实现。得益于 SPI 思想,应用程序中无需指定类似 “com.mysql.cj.jdbc.Driver” 这种全类名,尽可能地将第三方驱动从应用程序中解耦出来。
注意:
在JDK6之前,我们开发有连接数据库的时候,通常会用Class.forName(“com.mysql.jdbc.Driver”)这句先加载数据库相关的驱动,然后再进行获取连接等的操作。而JDK6之后不需要用Class.forName(“com.mysql.jdbc.Driver”)来加载驱动,直接获取连接就可以了,现在这种方式就是使用了Java的SPI扩展机制来实现。
上面有提醒到在JDK6之后不需要显示调用注册驱动,那这个是为什么呢?不急,我们慢慢往下看。首先,在mysql的驱动包中我们不难找到META-INF/services目录,该目录下会有一个名字为java.sql.Driver的文件,文件内容是com.mysql.jdbc.Driver,这里面的内容就是针对Java中定义的接口的实现。
按照第四章的使用步骤来分析学习,既然已经在META-INF/services目录找到了接口的实现,那么接下来研究一下它是什么时候被加载的。这个时候我们需要重点关注DriverManager类,它是管理 JDBC 驱动的基础服务类,位于 Java.sql 包中,由 boot 类加载器来进行加载。加载该类时,会先执行如下代码块:
public class DriverManager {
// List of registered JDBC drivers
private final static CopyOnWriteArrayList<DriverInfo> registeredDrivers = new CopyOnWriteArrayList<>();
private static volatile int loginTimeout = 0;
private static volatile java.io.PrintWriter logWriter = null;
private static volatile java.io.PrintStream logStream = null;
// Used in println() to synchronize logWriter
private final static Object logSync = new Object();
/* Prevent the DriverManager class from being instantiated. */
private DriverManager(){}
/**
* Load the initial JDBC drivers by checking the System property
* jdbc.properties and then use the {@code ServiceLoader} mechanism
*/
static {
loadInitialDrivers();
println("JDBC DriverManager initialized");
}
xxxx.....
}
然后我们点进loadInitialDrivers()
方法,是不是有种似曾相识的感觉,没错,这就是咱们前面第三章源码分析的那个ServiceLoader类,帮大家回忆一下,主要是下面这 4 点:
- 应用程序加载 Java SPI 服务,都是先调用 ServiceLoader.load 静态方法,这个方法会new ServiceLoader对象
- 当应用程序调用 ServiceLoader 的 iterator 方法时,ServiceLoader 会先判断缓存 providers 中是否有数据:如果有,则直接返回缓存 providers 的迭代器;如果没有,则返回懒加载迭代器的迭代器。
- driversIterator.hasNext():此方法用于查找Driver类;
- driversIterator.next():在实现的"next()"方法中进行类加载,使用上面的线程上下文类加载器。
注意:DriverManager类和Driver接口位于rt.jar包中,由启动类加载器加载,而mysql实现类由应用程序类加载器加载,这是打破双亲委派机制的典型案例。
当看到这里的时候也就能想明白为啥前面说JDK6之后不需要显示地调用Class.forName(“com.mysql.jdbc.Driver”)了,那是因为DriverManager类在被扫描的时候,会调用JDK6后官方发布的ServiceLoader类进行加载,因此,就又回到了SPI原理中ServiceLoader源码阶段了。
5.2 日志框架
在Java程序中,SPI还有一个典型的应用就是日志框架应用,如SLF4J (Simple Logging Facade for Java) 和Logback、Log4j等,它们利用SPI机制来发现和加载具体的日志实现。用户可以根据需要选择或更换日志实现,而无需修改应用程序代码。应用程序只需要和slf4j进行交互,slf4j选择使用哪一个日志框架的具体实现。
有日志打印经验的的朋友如果切换过日志框架,就会在实战中发现切换过程中实例Java代码并没有变化,只是将引用具体日志框架的实现进行了替换,例如依赖从simple替换为logback,具体日志服务实现就替换成了logback,这到底是怎么实现的?下面我们通过阅读源码回答这个问题,下面都是以Github上官方代码来进行分析:
根据日常使用日志的经验,往日志类里点进去就会发现日志实现的绑定主要在于performInitialization()
方法里:
private static final void performInitialization() {
// 绑定关系
bind();
if (INITIALIZATION_STATE == 3) {
versionSanityCheck();
}
}
private static final void bind() {
try {
// 日志框架利用SPI机制的核心代码
List<SLF4JServiceProvider> providersList = findServiceProviders();
reportMultipleBindingAmbiguity(providersList);
if (providersList != null && !providersList.isEmpty()) {
// 获取第一个实现
PROVIDER = (SLF4JServiceProvider)providersList.get(0);
PROVIDER.initialize();
INITIALIZATION_STATE = 3;
reportActualBinding(providersList);
} else {
INITIALIZATION_STATE = 4;
Reporter.warn("No SLF4J providers were found.");
Reporter.warn("Defaulting to no-operation (NOP) logger implementation");
Reporter.warn("See https://www.slf4j.org/codes.html#noProviders for further details.");
Set<URL> staticLoggerBinderPathSet = findPossibleStaticLoggerBinderPathSet();
reportIgnoredStaticLoggerBinders(staticLoggerBinderPathSet);
}
postBindCleanUp();
} catch (Exception var2) {
failedBinding(var2);
throw new IllegalStateException("Unexpected initialization failure", var2);
}
}
不同的适配器实现的SLF4JServiceProvider接口,这些实现需要被slf4j发现。在slf4j-api中,org.slf4j.LoggerFactory就是用来实现SPI发现的类。关键函数是findServiceProviders()
:
根据上面源码中会发现ServiceLoader类会加载SLF4JServiceProvider.class的实现,那就这个案例,logback实现是如何被绑定的呢?是否是按照了SPI思想实现了SLF4JServiceProvider接口?答案是肯定的。
根据jar包里的存放关系能够看出:要想使用logback实现类打印日志,其全限定类名同样需要记录在META-INF/services目录里,并且该实现类实现SLF4JServiceProvider接口,那这也是它能够被加载绑定上的前提。那么举一反三,其他的日志实现是如何使用的想必就不难理解了。
5.3 MapStruct工具
MapStruct工具作为优雅的对象转换神器,它在日常开发中还是使用的比较多的。而我在前面文章中也有介绍过【MapStruct】高性能对象转换神器MapStruct使用教程从基础到进阶(一)和【MapStruct】深入浅出带你学会从编译调试走进MapStruct源码(二),在MapStruct的原理篇中就有提到过SPI机制,但是只是一笔带过,那个时候看可能比较茫然,现在回头看可能是轻舟已过万重山。
首先,在 META-INF/services 目录下有以接口全限定名命名的文件,文件内容为实现该接口的类的全限定名,每个类名占一行。
然后,同样也是利用ServiceLoader类完成各个Processor类的加载。
5.4 MyBatis框架
Spring框架和MyBatis框架等框架中有着和普通SPI机制很类似的加载机制。spring.factories
是Spring框架中的一种特殊配置文件,用于自动化配置和加载Spring应用中的扩展点。在springboot的自动装配过程中,最终会加载META-INF/spring.factories
文件,而加载的过程是由SpringFactoriesLoader加载的。从CLASSPATH下的每个Jar包中搜寻所有META-INF/spring.factories配置文件,然后将解析properties文件,找到指定名称的配置后返回。需要注意的是,其实这里不仅仅是会去ClassPath路径下查找,会扫描所有路径下的Jar包,只不过这个文件只会在Classpath下的jar包中。如下图:
开发者可以在spring.factories文件中注册各种扩展点,例如自定义的EnableAutoConfiguration
、BeanFactoryPostProcessor
、ApplicationListener
等。这些扩展点可以是自己编写的类,也可以是第三方库提供的。Spring框架在启动时会自动扫描所有jar包中META-INF/spring.factories文件中定义的扩展点,从而实现自动化配置和加载。这使得Spring应用的开发和管理更加简便,可以方便地集成各种第三方库和自定义功能。
它的加载过程和普通SPI机制的加载过程有异曲同工之妙,这里只截取部分源码:
// ========================================以下截取SpringFactoriesLoader部分源码==================================================
public static final String FACTORIES_RESOURCE_LOCATION = "META-INF/spring.factories";
// spring.factories文件的格式为:key=value1,value2,value3
// 从所有的jar包中找到META-INF/spring.factories文件
// 然后从文件中解析出key=factoryClass类名称的所有value值
private static Map<String, List<String>> loadSpringFactories(@Nullable ClassLoader classLoader) {
MultiValueMap<String, String> result = cache.get(classLoader);
if (result != null) {
return result;
}
try {
// 取得资源文件的URL
Enumeration<URL> urls = (classLoader != null ?
classLoader.getResources(FACTORIES_RESOURCE_LOCATION) :
ClassLoader.getSystemResources(FACTORIES_RESOURCE_LOCATION));
result = new LinkedMultiValueMap<>();
// 遍历所有的URL
while (urls.hasMoreElements()) {
URL url = urls.nextElement();
UrlResource resource = new UrlResource(url);
// 根据资源文件URL解析properties文件,得到对应的一组@Configuration类
Properties properties = PropertiesLoaderUtils.loadProperties(resource);
for (Map.Entry<?, ?> entry : properties.entrySet()) {
List<String> factoryClassNames = Arrays.asList(
StringUtils.commaDelimitedListToStringArray((String) entry.getValue()));
// 组装数据,并返回
result.addAll((String) entry.getKey(), factoryClassNames);
}
}
cache.put(classLoader, result);
return result;
}
catch (IOException ex) {
throw new IllegalArgumentException("Unable to load factories from location [" +
FACTORIES_RESOURCE_LOCATION + "]", ex);
}
}
// ========================================以下截取SpringApplication部分源码==================================================
private <T> Collection<T> getSpringFactoriesInstances(Class<T> type,
Class<?>[] parameterTypes, Object... args) {
ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
// Use names and ensure unique to protect against duplicates
// 得到全限定类名称集合
Set<String> names = new LinkedHashSet<>(
SpringFactoriesLoader.loadFactoryNames(type, classLoader));
// 加载
List<T> instances = createSpringFactoriesInstances(type, parameterTypes,
classLoader, args, names);
AnnotationAwareOrderComparator.sort(instances);
return instances;
}
@SuppressWarnings("unchecked")
private <T> List<T> createSpringFactoriesInstances(Class<T> type,
Class<?>[] parameterTypes, ClassLoader classLoader, Object[] args,
Set<String> names) {
List<T> instances = new ArrayList<>(names.size());
for (String name : names) {
try {
// 核心点:还是利用反射机制
Class<?> instanceClass = ClassUtils.forName(name, classLoader);
Assert.isAssignable(type, instanceClass);
Constructor<?> constructor = instanceClass
.getDeclaredConstructor(parameterTypes);
T instance = (T) BeanUtils.instantiateClass(constructor, args);
instances.add(instance);
}
catch (Throwable ex) {
throw new IllegalArgumentException(
"Cannot instantiate " + type + " : " + name, ex);
}
}
return instances;
}
从设计思想上看和SPI机制很像,只是约定文件从META-INF/services/变为META-INF/spring.factories,且spring将所有自定义扩展整合到一个配置文件中,故该方式又被称为:Spring的SPI机制。
当然,除了这里介绍的一些典型的SPI应用以外,SPI机制还有很多其他的应用。比如SPI机制还应用在Dubbo框架中,Dubbo框架也使用了SPI思想,通过接口注解@SPI声明扩展点接口,并在classpath下的META-INF/dubbo目录中提供实现类的配置文件,来实现扩展点的动态加载。SPI机制还应用在JAX-WS (Java API for XML Web Services) 和 JAX-RS (Java API for RESTful Web Services): 这些Java Web服务技术框架使用SPI来发现和加载实现特定功能的服务提供者,比如SOAP绑定和HTTP连接器。总之,SPI的应用非常广泛,这里就不一 一去介绍了,有兴趣的朋友可以去找一下SPI机制的各个应用,并且深入学习一下。
六、Java SPI 优缺点
6.1 优点
低耦合性
- SPI具有很好的低耦合性,SPI 使得接口的定义与具体业务实现分离,使得应用程序可以根据实际业务情况启用或替换具体组件。
高拓展性
- 通过SPI,应用程序可以为同一个接口定义多个实现类。这使得应用程序更容易扩展和适应变化。
易使用性
- 使用SPI,应用程序只需要定义接口并指定实现类的类名,即可轻松地使用新的服务提供者。
动态性
- 运行时按需加载实现类,支持热插拔(如更换数据库驱动无需重启应用)。
6.2 缺点
配置较麻烦
- SPI需要在META-INF/services目录下创建配置文件,并将实现类的类名写入其中,这使得配置相对较为繁琐。
性能有损失
- 不能按需加载,只能通过 Iterator 形式遍历所有的实现。如果不想用某些实现类,或者某些类实例化很耗时,它也被载入并实例化了,这就造成了浪费。
安全性不足
- SPI提供者必须将其实现类名称写入到配置文件中,因此如果未正确配置,则可能存在安全风险。
多实现顺序不可控
- 当存在多个实现时,加载顺序由文件中的声明顺序决定,可能影响逻辑,要想控制使用逻辑,需要添加优先级判断排序逻辑。
七、自实现SPI练手案例
在经历了SPI机制源码分析、SPI应用分析几个章节后,相信大家已经摩拳擦掌,那本节就来自己动手实现一个SPI的小案例。本节案例基于这样一个背景:官方机构提供云计算标准接口,然后各厂商来根据云计算标准接口来提供自家厂商的云计算服务。
7.1 定义接口
先定义云计算标准SPI接口,并发布到本地仓库供华为云和阿里云实现:
①项目总览:
②定义标准SPI接口:
/**
* 标准SPI接口
*/
public interface CloudCompute {
/**
* 计算方法
*/
void compute();
}
②定义加载并提供云计算的服务入口类:
/**
* 加载具体的服务实现
*/
public class CloudComputeService {
private static volatile CloudComputeService instance;
private final CloudCompute cloudCompute;
private final List<CloudCompute> cloudComputes;
/**
* 加载服务(这里简单的直接使用JDK原生的ServiceLoader类)
* */
private CloudComputeService() {
ServiceLoader<CloudCompute> loader = ServiceLoader.load(CloudCompute.class);
List<CloudCompute> list = new ArrayList<>();
for (CloudCompute cloudCompute : loader) {
list.add(cloudCompute);
}
cloudComputes = list;
if (!list.isEmpty()) {
// 取第一个
cloudCompute = list.get(0);
} else {
cloudCompute = null;
}
}
/**
* CloudComputeService 双重检验锁单例加载
* */
public static CloudComputeService getInstance() {
if (instance == null) {
synchronized (CloudComputeService.class) {
if (instance == null) {
instance = new CloudComputeService();
}
}
}
return instance;
}
public void compute(){
if(cloudComputes.isEmpty()){
System.out.println("CloudCompute服务未加载!");
}else {
CloudCompute cloudCompute = cloudComputes.get(0);
cloudCompute.compute();
}
}
}
④构建完标准SPI项目后,因为这个小案例是在本地测试,那么就需要将其打包到本地仓库。因此,我们首先需要在项目根目录下执行mvn clean install
命令,打包完成后会在本地仓库生成相应的jar包文件,当打包完成后可以自行追踪到本地仓库确认,由于我这里本地仓库是 E:\openrepository
,因此打包完效果如下图:
7.2 创建实现类
华为云实现自己的云计算解决方案:
①项目总览:
②引入标准云计算jar包进而创建实现类实现标准SPI接口:
/**
* 华为云计算服务
*/
public class HuaWeiCloudCompute implements CloudCompute {
@Override
public void compute() {
System.out.println("华为云提供特有云计算服务!");
}
}
③在 META-INF/services 目录下创建一个以接口全限定名命名的文件,文件内容为HuaWeiCloudCompute类全限定名。
④最后进行项目打包,打包到本地仓库,步骤和上文介绍的一样,效果如下图:
阿里云实现自己的云计算解决方案:
①项目总览:
②引入标准云计算jar包进而创建实现类实现标准SPI接口:
/**
* 阿里云计算服务
*/
public class AliCloudCompute implements CloudCompute {
@Override
public void compute() {
System.out.println("阿里云提供特有云计算服务!");
}
}
③在 META-INF/services 目录下创建一个以接口全限定名命名的文件,文件内容为AliCloudCompute类的全限定名。
④最后进行项目打包,打包到本地仓库,步骤和上文介绍的一样,效果如下图:
7.3 配置文件
两个厂商的实现类的名称都被记录在类路径下的META-INF/services目录
下的文件中,文件为普通文件即可,但是需要注意: 一定是接口的类的全限定名
,例如本例中的:com.cloud.compute.cloudcomputestandard.CloudCompute。
7.4 加载使用服务
最后在云计算服务使用方引入上述两个不同厂商实现的云计算服务jar包,例如这里在本地任意项目中的pom文件中引入jar包如下:
然后直接创建一个SPI测试主类:
public class SpiStudyTest {
public static void main(String[] args) {
CloudComputeService cloudComputeService = CloudComputeService.getInstance();
cloudComputeService.compute();
}
}
运行后执行效果如下图:
此时如果服务使用方觉得阿里云的云计算方案不匹配,需要更换华为云的方案,操作起来非常简单,只需要注释掉阿里云的坐标,添加华为云的坐标,业务代码完全不用改。到这里,这个简单练手小案例就完全结束了,相信多少还是有点收获的!这里需要额外提到的是:在编写服务加载类这个环节中,其实有很多需要注意的点,也有很多不同的选择,可以设置优先级,可以利用iterator,甚至也可以实现自己的ServiceLoader类等等,总之可以多学习下别人源码的实现!
八、总结
在本文中,我们深入探讨了Java SPI的概念、实现原理、优缺点、应用场景的源码部分、使用步骤以及自实现SPI小案例。通过学习SPI,我们可以充分利用Java的动态扩展机制,实现插件化开发和可扩展性架构。同时,我们也了解到SPI在多个领域中具有很广泛的应用,包括日志、数据库、框架等方面。要使用SPI,需要遵循一定的规范和标准,例如META-INF/services目录下的配置文件等。在总结完Java SPI的优缺点后,通过一个简单的示例,详细演示了如何实现自己的SPI接口,并动态加载不同的实现类,以便提高我们的SPI实战能力。
希望本文能够帮助您深入理解Java SPI的相关知识,提高技术水平和实践能力。最后,祝您工作顺利,生活愉快!
九、参考资料
https://blog.csdn.net/qq_52423918/article/details/130968307
https://blog.csdn.net/owoaa/article/details/137391631
https://blog.csdn.net/qq_37883866/article/details/139000021