写在文章开头
你好,我叫sharkchili,目前还是在一线奋斗的Java开发,经历过很多有意思的项目,也写过很多有意思的文章,是CSDN Java领域的博客专家,也是Java Guide的维护者之一,非常欢迎你关注我的公众号:「写代码的SharkChili」,这里面会有笔者精心挑选的并发、JVM、MySQL数据库专栏,也有笔者日常分享的硬核技术小文。
本文会本文会以代码示例并结合源码分析的形式带读者深入剖析SPI这一概念:

什么是SPI,它有什么作用
在我们日常的web开发或者第三方服务调用时,会根据接口要求传入指定参数得到相应结果,这种为用户提供服务的服务方我们统称为「API」。

而「SPI」则是另一种设计理念,它全称是「Service Provide Interface」,即服务提供接口,它更强调定义一组规范,让服务提供者根据规范实现接口的个性化功能。然后服务调用方调用时通过调用接口,让提供方通过某种「服务发现机制」得到接口的实现类为从而响应结果,这种解耦调用者和服务提供者的方式也正是人们常说的「面向接口编程」。

是不是觉得很抽象呢?没有关系,本文的整体结构如下,笔者会通过一段代码示例和并基于源码剖析「SPI类加载、创建、缓存」和以及「调用」的过程让读者对「SPI」工作原理有着更加充分的认识。

SPI使用示例
可能上面说的有些抽象,我们不妨基于一个例子来了解一下「SPI」,假设我们现在有这样一个需求,我们的操作系统开发了一款万能遥控器,各大厂商希望将遥控功能在这个「APP」上集成,这时我们就可以基于「SPI」的思想对这些厂商提供一套接口规范,各大厂商只需基于这套规范进行相应的实现和配置,即可在我们的APP上操作他们的电子设备。
所以我们定义了下面这样一个接口:
public interface Application {
// 获取设备名称
String getName();
// 开关
void turnOnOff(boolean flag);
}
所以我们对厂商提供这样一个接口规范,并将这个依赖的接口打个一个「jar」包,要求厂商做到以下3点:
-
引入我们的依赖。 -
实现「getName」返回设备名称。 -
实现「turnOnOff」,传入对应的布尔值实现电器的各种变化。 -
配置实现类的全路径,确保后续APP集成时可以加载到该实现类并完成调用。
我们以一个电灯的厂商的角度来完成这个集成工作,首先自然是将接口规范的依赖引入:
<dependencies>
<dependency>
<groupId>com.sharkchili</groupId>
<artifactId>application</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
</dependencies>
然后完成对应的接口实现类:
public class Light implements Application {
public String getName() {
return "电灯";
}
public void turnOnOff(boolean b) {
if (b) {
System.out.println("打开电灯");
return;
}
System.out.println("关闭电灯");
}
}
最后一步就是配置,我们在这个「maven」项目中的「resources」目录下的一个文件夹创建一个名为「META-INF.services」的文件夹中,创建一个我们接口全路径的文件「com.sharkchili.Application内容为类的全路径」,并在其内部指明实现类的全路径
com.sharkchili.Light
配置示例如下图所示,完成这些步骤后,我们将依赖打包:

最后我们的应用只需引入这接口和实现类的两个依赖便可开始进行调用:
<dependencies>
<dependency>
<groupId>com.sharkchili</groupId>
<artifactId>light</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>com.sharkchili</groupId>
<artifactId>application</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
</dependencies>
如下所示,我们的调用示例代码如下,我们通过「ServiceLoader」的「load」方法加载当前项目中所有的「Application」实现类,然后遍历实现类完成「turnOnOff」的调用:
public static void main(String[] args) {
ServiceLoader<Application> load = ServiceLoader.load(Application.class);
for (Application application : load) {
application.turnOnOff(true);
}
}
对应输出结果如下,可以看到它成功发现了电灯厂商的实现类,并完成对「turnOnOff」的调用:
打开电灯
SPI工作原理
我们从这段代码入手,「ServiceLoader」的load方法在内部会获取当前线程的类加载器,然后创建一个「LazyIterator」的迭代器,然后在上文示例代码中的迭代步骤时,将「Application」的实现类加载到缓存中并完成返回给用户进行调用:
ServiceLoader<Application> load = ServiceLoader.load(Application.class);
步入代码,可以看到它取得当前线程的类加载器后调用的「ServiceLoader」的内部的「load」方法。
public static <S> ServiceLoader<S> load(Class<S> service) {
ClassLoader cl = Thread.currentThread().getContextClassLoader();
return ServiceLoader.load(service, cl);
}
我们跳过繁琐的步骤,这个私有的「load」方法最终会调用「reload」方法,它首先会清空「providers」 ,然后创建一个LazyIterator用于后续来加载「Application」实现类。
private LinkedHashMap<String,S> providers = new LinkedHashMap<>();
public void reload() {
providers.clear();
//基于类加载器和Application.class生成一个来加载迭代器
lookupIterator = new LazyIterator(service, loader);
}
随后我们的代码进入for循环进行迭代,而循环步骤就会走到「LazyIterator」的「hasNext」方法,因为我们此时的providers在load方法是被清空了,所以对应的「entrySet」引用对象「knownProviders」自然也是空的,于是调用「lookupIterator.hasNext()「查看是否有可用的」Class」并通过反射创建然后存入「providers」中。
Iterator<Map.Entry<String,S>> knownProviders
= providers.entrySet().iterator();
public boolean hasNext() {
if (knownProviders.hasNext())
return true;
return lookupIterator.hasNext();
}
重点来了,上述方法通过「PREFIX + service.getName()「获取文件即」resource」下以「Application」实现全路径命名的文件,然后解析这个文件生成所有的类名,并将这些类名存入「pending」 这个迭代器中,最后让「nextName」指针指向第一个类名,并返回「true」:
private boolean hasNextService() {
if (nextName != null) {
return true;
}
if (configs == null) {
try {
//基于fullName 获取com.sharkchili.Application文件下所有类名
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);
}
}
//解将配置文件中的类名存入pending 中
while ((pending == null) || !pending.hasNext()) {
if (!configs.hasMoreElements()) {
return false;
}
pending = parse(service, configs.nextElement());
}
//nextName 指向pending迭代器中的第一个元素
nextName = pending.next();
return true;
}
通过上述步骤完成类名加载后返回true,所以我们的「for」循环继续了方法调用**lookupIterator.next();**方法。
public S next() {
if (knownProviders.hasNext())
return knownProviders.next().getValue();
return lookupIterator.next();
}
最终来到的核心步骤,「nextService」通过hasNext拿到的「nextName」反射生成电灯类并以全路径类名为key,反射生成的对象为「value」存入「providers」中再返回出去,实现类的复用。
private S nextService() {
if (!hasNextService())
throw new NoSuchElementException();
//拿到类的全路径名
String cn = nextName;
nextName = null;
Class<?> c = null;
try {
c = Class.forName(cn, false, loader);
} catch (ClassNotFoundException x) {
fail(service,
"Provider " + cn + " not found");
}
if (!service.isAssignableFrom(c)) {
fail(service,
"Provider " + cn + " not a subtype");
}
try {
//反射生成类并存入providers缓存中
S p = service.cast(c.newInstance());
providers.put(cn, p);
return p;
} catch (Throwable x) {
fail(service,
"Provider " + cn + " could not be instantiated",
x);
}
throw new Error(); // This cannot happen
}
SPI在实际场景的运用
「SPI」最典型的运用就是日志框架「Slf4J」了,可以看到其内部也是通过「services」指定指定的加载类完成插槽式接入各种日志框架,这也就是为什么我们的「Spring Boot」项目引入「Slf4j」和「log4j」之后,调用「Slf4J」的接口方法依然可以通过「log4j」完成日志打印的原因所在。

小结
我是「sharkchili」,「CSDN Java 领域博客专家」,「开源项目—JavaGuide contributor」,我想写一些有意思的东西,希望对你有帮助,如果你想实时收到我写的硬核的文章也欢迎你关注我的公众号:「写代码的SharkChili」,同时我的公众号也有我精心整理的「并发编程」、「JVM」、「MySQL数据库」个人专栏导航。
参考资料
美团:SPI 的原理是什么? :https://mp.weixin.qq.com/s/AA9rugKgEOMZ5O8dgXSmsw
原文始发于微信公众号(写代码的SharkChili):来聊聊大厂常问的SPI工作原理
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
文章由极客之家整理,本文链接:https://www.bmabk.com/index.php/post/201343.html