SPI机制
什么是SPI机制
SPI(Service Provider Interface)
是Java中一种服务提供者接口的设计模式,它提供了一种机制,允许组件在不同的实现之间进行插拔,从而实现松耦合的架构。SPI通常用于实现插件化、可扩展的应用程序,使开发人员能够轻松地添加、替换或定制系统中的功能模块。
当服务的提供者提供了一种接口的实现之后,需要在classpath下的META-INF/services/
目录里创建一个以服务接口命名的文件,这个文件里的内容就是这个接口的具体的实现类。当其他的程序需要这个服务的时候,就可以通过查找这个jar包(一般都是以jar包做依赖)的META-INF/services/
中的配置文件,配置文件中有接口的具体实现类名,可以根据这个类名进行加载实例化,就可以使用该服务了。JDK中查找服务的实现的工具类是:java.util.ServiceLoader
。
SPI示例
一个常见的 SPI 示例是 Java 的日志框架 SLF4J(Simple Logging Facade for Java)。SLF4J 允许应用程序在运行时选择不同的日志实现,而无需修改代码。以下是一个简化的示例:
- 定义日志接口: 首先,定义一个日志接口,例如
Logger
,其中包含了常见的日志方法,如info()
,debug()
,error()
等。
java
public interface Logger {
void info(String message);
void debug(String message);
void error(String message);
// ...其他日志方法
}
- 编写日志实现类: 然后,为不同的日志实现编写实现类,这些实现类分别对接各种日志框架,比如 Log4j、Logback、JDK Logging 等。
java
// Log4jLogger.java
public class Log4jLogger implements Logger {
private org.apache.log4j.Logger logger;
public Log4jLogger(Class<?> clazz) {
logger = org.apache.log4j.Logger.getLogger(clazz);
}
// 实现 Logger 接口的方法,使用 Log4j 进行日志记录
// ...
}
- SPI 配置文件: 在
META-INF/services
目录下创建一个文件,以接口全限定名为文件名,写入实现类的全限定名。对于 SLF4J,该文件名为org.slf4j.Logger
。
shell
# 文件:META-INF/services/org.slf4j.Logger
com.example.logging.Log4jLogger
- 使用日志接口: 在应用程序中,您只需要使用 SLF4J 提供的接口进行日志记录,而不需要关心具体的日志实现。
java
import org.slf4j.LoggerFactory;
public class Main {
private static final Logger logger = LoggerFactory.getLogger(Main.class);
public static void main(String[] args) {
logger.info("This is an info message.");
logger.debug("This is a debug message.");
logger.error("This is an error message.");
}
}
通过这种方式,我们可以轻松地更改底层的日志实现,而不需要修改应用程序的代码。这就是 SPI 的一个实际应用示例,它展示了如何通过接口、实现类、SPI 配置文件以及运行时加载机制,实现插拔式的日志框架。
SPI原理
SPI(Service Provider Interface)的原理涉及 Java 的类加载机制、反射以及配置文件加载。以下是SPI的工作原理:
-
接口定义: 首先,您需要定义一个接口,该接口描述了一组服务或功能的方法。这个接口将作为服务提供者和服务使用者之间的约定。
-
服务提供者接口: 在SPI中,您通常会定义一个专门的接口,用于服务提供者注册和实例化。这个接口可能包括方法用于获取特定的服务实例。
-
服务提供者实现: 不同的模块、库或插件可以提供针对接口的不同实现。每个实现都需要提供一个特定的类,实现服务提供者接口,并在实现类中提供相关的功能代码。
-
服务配置文件: SPI的核心是一个配置文件,通常命名为
META-INF/services/<接口全限定名>
。在这个文件中,您列出了实现您接口的类的名称。- 对于每个接口,都可以在
META-INF/services
目录下创建一个以接口全限定名为文件名的文件。 - 在这个文件中,每一行包含一个实现类的全限定名。这些实现类是用于提供特定服务的。
- 对于每个接口,都可以在
-
加载机制: 当需要使用某个服务时,您可以通过 Java 的类加载机制以及反射来加载并实例化相应的实现类。具体过程如下:
- 通过 SPI 配置文件,找到接口对应的实现类的全限定名列表。
- 使用 ClassLoader 加载这些实现类的类对象。
- 使用反射实例化这些类,得到服务提供者的实例。
-
服务使用者: 服务使用者是通过服务提供者接口来获取服务实例的组件。它可以从已注册的服务提供者中选择一个合适的实现,然后使用该实现的功能。
在运行时,SPI机制允许系统自动加载并实例化服务提供者的实现类,从而实现插拔式的架构。这样,您可以在不修改核心代码的情况下,通过添加新的实现类来扩展系统功能,实现更好的可扩展性和灵活性。
下面我们看下JDK中ServiceLoader<S>
方法的具体实现:
首先 ,ServiceLoader实现了Iterable
接口,所以它有迭代器的属性,这里主要都是实现了迭代器的hasNext
和next
方法。这里主要都是调用的lookupIterator
的相应hasNext
和next
方法,lookupIterator
是懒加载迭代器。
其次 ,LazyIterator
中的hasNext
方法,静态变量PREFIX就是"META-INF/services/"
目录,这也就是为什么需要在classpath
下的META-INF/services/
目录里创建一个以服务接口命名的文件。
最后 ,通过反射方法Class.forName()
加载类对象,并用newInstance
方法将类实例化,并把实例化后的类缓存到providers
对象中,(LinkedHashMap<String,S>
类型)然后返回实例对象。
所以我们可以看到ServiceLoader
不是实例化以后,就去读取配置文件中的具体实现,并进行实例化。而是等到使用迭代器去遍历的时候,才会加载对应的配置文件去解析,调用hasNext
方法的时候会去加载配置文件进行解析,调用next
方法的时候进行实例化并缓存。
所有的配置文件只会加载一次,服务提供者也只会被实例化一次,重新加载配置文件可使用reload
方法
SPI机制的缺陷
通过上面的解析,可以发现,我们使用SPI机制的缺陷:
- 不能按需加载,需要遍历所有的实现,并实例化,然后在循环中才能找到我们需要的实现。如果不想用某些实现类,或者某些类实例化很耗时,它也被载入并实例化了,这就造成了浪费。
- 如果扩展点加载失败,连扩展点的名称都拿不到了。比如:JDK 标准的 ScriptEngine,通过 getName() 获取脚本类型的名称,但如果 RubyScriptEngine 因为所依赖的 jruby.jar 不存在,导致 RubyScriptEngine 类加载失败,这个失败原因被吃掉了,和 ruby 对应不起来,当用户执行 ruby 脚本时,会报不支持 ruby,而不是真正失败的原因。
- 多个并发多线程使用 ServiceLoader 类的实例是不安全的。
Dubbo SPI机制
Dubbo 就是通过 SPI 机制加载所有的组件。不过,Dubbo 并未使用 Java 原生的 SPI 机制,而是对其进行了增强,使其能够更好的满足需求。在 Dubbo 中,SPI 是一个非常重要的模块。基于 SPI,我们可以很容易的对 Dubbo 进行拓展。 Dubbo 中,SPI 主要有两种用法,一种是加载固定的扩展类,另一种是加载自适应扩展类。这两种方式会在下面详细的介绍。 需要特别注意的是: 在 Dubbo 中,基于 SPI 扩展加载的类是单例的。
Dubbo SPI 优势
- 按需加载,Dubbo SPI配置文件采用KV格式存储,key 被称为扩展名,当我们在为一个接口查找具体实现类时,可以指定扩展名来选择相应的扩展实现,只实例化这一个扩展实现即可,无须实例化 SPI 配置文件中的其他扩展实现类,避免资源浪费,此外通过 KV 格式的 SPI 配置文件,当我们使用的一个扩展实现类所在的 jar 包没有引入到项目中时,Dubbo SPI 在抛出异常的时候,会携带该扩展名信息,而不是简单地提示扩展实现类无法加载。这些更加准确的异常信息降低了排查问题的难度,提高了排查问题的效率。;
- 增加扩展类的 IOC 能力,Dubbo 的扩展能力并不仅仅只是发现扩展服务实现类,而是在此基础上更进一步,如果该扩展类的属性依赖其他对象,则 Dubbo 会自动的完成该依赖对象的注入功能;
- 增加扩展类的 AOP 能力,Dubbo 扩展能力会自动的发现扩展类的包装类,完成包装类的构造,增强扩展类的功能;
Dubbo SPI原理
Dubbo 扩展加载流程
Dubbo 加载扩展的整个流程如下:
主要步骤为 4 个:
- 读取并解析配置文件
- 缓存所有扩展实现
- 基于用户执行的扩展名,实例化对应的扩展实现
- 进行扩展实例属性的 IOC 注入以及实例化扩展的包装类,实现 AOP 特性
如何使用 Dubbo 扩展能力进行扩展
下面以扩展协议为例进行说明如何利用 Dubbo 提供的扩展能力扩展 Triple 协议。
(1) 在协议的实现 jar 包内放置文本文件:META-INF/dubbo/org.apache.dubbo.remoting.api.WireProtocol
properties
tri=org.apache.dubbo.rpc.protocol.tri.TripleHttp2Protocol
(2) 实现类内容
java
@Activate
public class TripleHttp2Protocol extends Http2WireProtocol {
// ...
}
说明下:Http2WireProtocol 实现了 WireProtocol 接口
(3) Dubbo 配置模块中,扩展点均有对应配置属性或标签,通过配置指定使用哪个扩展实现。比如:
xml
<dubbo:protocol name="tri" />
从上面的扩展步骤可以看出,用户基本在黑盒下就完成了扩展。
Dubbo 扩展的应用
Dubbo 的扩展能力非常灵活,在自身功能的实现上无处不在。
Dubbo 扩展能力使得 Dubbo 项目很方便的切分成一个一个的子模块,实现热插拔特性。用户完全可以基于自身需求,替换 Dubbo 原生实现,来满足自身业务需求。
Dubbo SPI 源码分析
上面看了 Dubbo SPI 通过 ExtensionLoader
加载扩展。 ExtensionLoader
的 getExtensionLoader
方法获取一个 ExtensionLoader
实例,然后再通过 ExtensionLoader
的 getExtension
方法获取拓展类对象。这其中,getExtensionLoader
方法用于从缓存中获取与拓展类对应的 ExtensionLoader
,若缓存未命中,则创建一个新的实例。该方法的逻辑比较简单,本章就不进行分析了。下面我们从 ExtensionLoader
的 getExtension
方法作为入口,对拓展类对象的获取过程进行详细的分析。
上面代码的逻辑比较简单,首先检查缓存,缓存未命中则创建拓展对象。下面我们来看一下创建拓展对象的过程是怎样的。
createExtension 方法的逻辑稍复杂一下,包含了如下的步骤:
- 通过 getExtensionClasses 获取所有的拓展类
- 通过反射创建拓展对象
- 向拓展对象中注入依赖
- 将拓展对象包裹在相应的 Wrapper 对象中
以上步骤中,第一个步骤是加载拓展类的关键,第三和第四个步骤是 Dubbo IOC 与 AOP 的具体实现。在接下来的章节中,将会重点分析 getExtensionClasses 方法的逻辑,以及简单介绍 Dubbo IOC 的具体实现。
我们在通过名称获取拓展类之前,首先需要根据配置文件解析出拓展项名称到拓展类的映射关系表(Map<名称, 拓展类>),之后再根据拓展项名称从映射关系表中取出相应的拓展类即可。相关过程的代码分析如下:
这里也是先检查缓存,若缓存未命中,则通过 synchronized 加锁。加锁后再次检查缓存,并判空。此时如果 classes 仍为 null,则通过 loadExtensionClasses 加载拓展类。下面分析 loadExtensionClasses 方法的逻辑。
loadExtensionClasses 方法总共做了两件事情,一是对 SPI 注解进行解析,二是调用 loadDirectory 方法加载指定文件夹配置文件。SPI 注解解析过程比较简单,无需多说。下面我们来看一下 loadDirectory 做了哪些事情。
loadDirectory 方法先通过 classLoader 获取所有资源链接,然后再通过 loadResource 方法加载资源。我们继续跟下去,看一下 loadResource 方法的实现。
loadResource 方法用于读取和解析配置文件,并通过反射加载类,最后调用 loadClass 方法进行其他操作。loadClass 方法用于主要用于操作缓存,该方法的逻辑如下:
如上,loadClass 方法操作了不同的缓存,比如 cachedAdaptiveClass、cachedWrapperClasses 和 cachedNames 等等。除此之外,该方法没有其他什么逻辑了。