迷迷糊糊?似懂非懂?一文让你从此对SPI了如指掌
- 前言
- 一、SPI 与 API
- 1. SPI 在生活中的类比
- 2. SPI 在代码上的例子
- 3. API 与 SPI 的关系
- 二、JAVA 的 SPI 机制
- 1. JAVA 中的 SPI 例子
- 2. SPI 机制的四大组件
- 3. SPI 机制的实现
- 4. JAVA SPI的不足
前言
你是不是有很多次,学了一些框架的知识,过不了多久又忘记了,如此反复,感觉浪费了很多时间。其实这种现象很正常,一来是掌握可能是死记硬背+走马观花,本身就掌握不深刻。二来是知识没成体系,孤零零的知识点本身就容易忘记,今天,就让我们深刻-全面的学习一下SPI吧
一、SPI 与 API
大多数开发者对于 API 的概念已经很熟了,很多时候我们需要使用某种服务,都会要求其开发者提供 API 列表,这些列表规定了入参、出参、具体功能。而 SPI 很多人的理解就比较模糊了,其实不能怪各位读者,IT领域确实有很多概念描述得很抽象,一些有联系的内容很难准确分割。
不管怎样,我们先把SPI具体的内涵说一下,再进行诠释:SPI是Service Provider Interface(服务提供者接口)的缩写,概念起源于java平台,是一种在应用程序中使用插件的实现方式,它允许在运行时动态地替换和添加组件,同时支持扩展接口的实现方式。SPI通常由两部分组成:接口和实现类。接口定义了一组方法,实现类是实现这些方法的具体类。在应用程序中,SPI的使用者只需要定义接口,并在运行时加载合适的实现类即可
1. SPI 在生活中的类比
我们以一个生活上的例子来说明两者的联系与不同:
小明是个超市老板,小红是水果商。小红表示,只要你给我发短信“我需要些货”的时候,我就会准备价值十万的水果卖给你。
这样,我们就说小红提供了一个API,入参是一条短信,出参是价值十万块的水果。
就这样,过了一段时间,小明觉得这样一条短信就能办事太舒服了,想要把这种模式发扬光大。于是他告知冰箱厂的王厂长,当我给你发短信“我需要些货”时,你就给我准备总价十万的冰箱;又告诉卖文具的张老板,当我给你发短信“我需要些货”时,你就给我准备总价十万的文具… … 最后,小明干脆贴了个告示,跟我的超市合作的,当我给你发短信“我需要些货”时,你就准备好价值十万的货。
这样一来,短信还是一样的内容,但是小明选择发给不同的人,就能得到不同的结果,而且后续想和小明合作的人,也都接受了这种模式,那么我们说这种情况就形成了个SPI
所以SPI更像一种设计模式,调用者把接口变成公开的协议,对后来者开放,后来者要想加入,就要遵循这个壳(示例中的发短信),来实现相似但并不一样的功能(都是备货,但各厂家备的货不一样)
2. SPI 在代码上的例子
除了生活中的例子,我们再来看看实际代码上的例子,最广为人知的莫过于 JDBC(java database connectivity)规范:
我们知道现在大部分JAVA应用都是需要连接数据库的,但是市面上的数据库有很多种,如果每个数据库的驱动都自搞一套,对于java开发者来说,连接数据库都将变成沉重又复杂的负担。这个时候JAVA就推出了JDBC规范,JDBC API定义了一套接口(如Driver、Connection、Statement等),并广而告之。需要和JAVA平台对接的数据库厂商选择接受了这个规范,按照JAVA的接口要求,在这层壳之下开发各自的驱动
如下图,我们可以看到jdk中规定的接口
再来看看Mysql 提供的驱动里对这些类的实现 (不知为何,我总感觉这段注释有些许怨念…)
3. API 与 SPI 的关系
其实从上面两个例子,我们不难看出,API 和 SPI 有很深的联系,但 API 接口的定义权通常在被调用方(功能实现方),由被调用者主动提供,,即主动“暴露”出去的功能,调用者得按照这个API的说明来使用该功能;SPI则是基于API,但是这是调用方反客为主,自己拟定出一系列API,要求所有被调用者都来遵守。
所以,SPI机制可以认为调用方掌握了API的定制权,并把该AP形成规范的一种机制,这样一来,应用程序只需要使用统一的接口,而无需关心不同人各自的实现,只要按需选择一个人,调用该接口即可。此时这个所谓的API,现在也可以被叫做SPI
二、JAVA 的 SPI 机制
我们提到过,SPI机制的说法 ,是由JAVA率先提出的,那么JAVA自己到底是如何运用这个机制的呢?
1. JAVA 中的 SPI 例子
除了上面提过的 Driver驱动 的例子,其实 java 中还有一些使用SPI的例子。在JDK1.8下,我们可以看见这几种示例
我们可以简单介绍其中的一些:
- 货币名称提供程序 (CurrencyNameProvider):
为货币类提供本地化的货币符号。 - 区域设置名称提供程序 (LocaleNameProvider):
提供区域设置类的本地化名称。 - 时区名称提供程序 (TimeZoneNameProvider):
为时区类提供本地化的时区名称. - 日期格式提供程序 (DateFormatProvider):
为指定的区域设置提供日期和时间格式. - 数字格式提供程序 (NumberFormatProvider):
为 NumberFormat 类提供货币、整数和百分比值. - 驱动 (Driver):
从版本 4.0 开始,JDBC API 支持 SPI 模式。旧版本使用 Class.forName() 方法来加载驱动程序
2. SPI 机制的四大组件
- 服务
一组公开的编程接口和类,提供对某些特定应用程序功能或特性的访问 - 服务提供者接口
充当服务的代理或端点的接口或抽象类。如果服务是一个接口,则它与服务提供程序接口相同。 - 服务提供者
SPI的具体实现。服务提供程序包含一个或多个实现或扩展服务类型的具体类。通过我们放入资源目录 META-INF/services 中的提供程序配置文件来配置和标识服务提供程序。文件名是SPI的完全限定名称,其内容是SPI实现的完全限定名。服务提供程序是以扩展的形式安装的,这是一个jar文件,我们将其放置在应用程序类路径、Java扩展类路径或用户定义的类路径中。 - 服务装载机
SPI的核心是 ServiceLoader 类。这具有惰性地发现和加载实现的作用。它使用上下文类路径来定位提供程序实现,并将它们放在内部缓存中。
3. SPI 机制的实现
其中最为重要的就是 ServiceLoader 类,这个是SPI机制实现的核心,我们可以看一下这个类的注释:
一个简单的服务提供商加载工具。
服务是一组众所周知的接口和(通常是抽象的)类。 服务提供商是服务的特定实现。 提供程序中的类通常实现接口,并对服务本身中定义的类进行子类。 服务提供程序可以以扩展的形式安装在Java平台的实现中,即放置在任何常用扩展目录中的jar文件。 还可以通过将提供程序添加到应用程序的类路径或通过其他特定于平台的方式来提供提供程序。
出于加载的目的,服务由单个类型表示,即单个接口或抽象类。 (可以使用具体类,但不建议这样做。 给定服务的提供程序包含一个或多个具体类,这些类使用特定于提供程序的数据和代码扩展此服务类型。 提供程序类通常不是整个提供程序本身,而是一个代理,其中包含足够的信息来确定提供程序是否能够满足特定请求以及可以按需创建实际提供程序的代码。 提供程序类的详细信息往往是高度特定于服务的; 没有一个类或接口可以统一它们,所以这里没有定义这样的类型。 此工具强制实施的唯一要求是提供程序类必须具有零参数构造函数,以便可以在加载期间实例化它们。
通过将提供程序配置文件放在资源目录 META-INF/services 中来标识服务提供程序。 文件名是服务类型的完全限定二进制名称。 该文件包含具体提供程序类的完全限定二进制名称的列表,每行一个。 每个名称周围的空格和制表符以及空行将被忽略。 注释字符为“#”(“#”,数字符号); 在每一行上,第一个注释字符后面的所有字符都将被忽略。 该文件必须以 UTF-8 编码。
如果特定的具体提供程序类在多个配置文件中命名,或者在同一配置文件中多次命名,则会忽略重复项。 命名特定提供程序的配置文件不必与提供程序本身位于同一 jar 文件或其他分发单元中。 必须可从最初查询的同一类装入器访问提供程序以查找配置文件; 请注意,这不一定是实际从中装入文件的类装入器。
提供程序是延迟定位和实例化的,即按需。 服务加载程序维护到目前为止已加载的提供程序的缓存。 迭代器方法的每次调用都会返回一个迭代器,该迭代器首先按实例化顺序生成缓存的所有元素,然后延迟查找并实例化任何剩余的提供程序,依次将每个提供程序添加到缓存中。 可以通过重新加载方法清除缓存。
服务加载程序始终在调用方的安全上下文中执行。 受信任的系统代码通常应从特权安全上下文中调用此类中的方法以及它们返回的迭代器的方法。
此类的实例不能安全地由多个并发线程使用。
然后其中最关键的方法,我们结合代码来看
private static final String PREFIX = "META-INF/services/";
// 清除,并重新读取某个服务的所有提供者,可以看到,实际是以一个迭代器的形式存储的
public void reload() {
providers.clear();
lookupIterator = new LazyIterator(service, loader);
}
private class LazyIterator
implements Iterator<S>
{
Class<S> service;
ClassLoader loader;
Enumeration<URL> configs = null;
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 {
// 获取我们在包 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;
}
pending = parse(service, configs.nextElement());
}
nextName = pending.next();
return true;
}
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 {
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
}
public boolean hasNext() {
if (acc == null) {
return hasNextService();
} else {
PrivilegedAction<Boolean> action = new PrivilegedAction<Boolean>() {
public Boolean run() { return hasNextService(); }
};
return AccessController.doPrivileged(action, acc);
}
}
public S next() {
if (acc == null) {
return nextService();
} else {
PrivilegedAction<S> action = new PrivilegedAction<S>() {
public S run() { return nextService(); }
};
return AccessController.doPrivileged(action, acc);
}
}
public void remove() {
throw new UnsupportedOperationException();
}
}
不难发现,JAVA自带的SPI机制,是要求服务提供者,在包下 “META-INF/services/” 内写上服务实现类的全限定名,然后在我们使用服务的时候,懒加载这个服务的所有实现类,如下来获取Mysql驱动:
// 获取所有驱动
ServiceLoader<Driver> loader = ServiceLoader.load(Driver.class);
Driver driver = null;
// 遍历找到Mysql驱动
for (Driver d : loader) {
if (d.getClass().getName().equals("com.mysql.jdbc.Driver")) {
driver = d;
break;
}
}
if (driver == null) {
throw new RuntimeException("mysql driver not found");
}
当然,其实远不用这么写,比如我们要获得Mysql驱动,只需执行如下代码
Connection conn = DriverManager.getConnection(url, user, password);
这是因为java自带了 DriverManager 类,这个类在初始化时,就会执行 ServiceLoader loader = ServiceLoader.load(Driver.class); 相当于帮我们把工作做好了。在执行 getConnection 时,会遍历这些服务提供实现类,直到能返回连接才终止循环。
4. JAVA SPI的不足
Java自带的SPI机制是一种较为简单的服务发现机制,具有以下缺点:
-
功能有限:Java自带的SPI机制只能实现服务的发现和加载,无法解决服务的版本升级、路由控制、负载均衡、容错等问题。
-
依赖于类加载器:Java自带的SPI机制依赖于类加载器,需要将服务实现类打包成jar文件,并将其放到类路径下,才能被ServiceLoader发现并加载。这种机制限制了服务实现类的灵活性和可扩展性。
-
全加载:Java自带的SPI机制在对某个服务的提供者进行加载时,采用的是全部加载进来,再选择使用哪个或哪几个。一些不会被利用的服务实现也被加载进内存了。