SPI(Service Provider Interface)
是JAVA中一种服务提供者接口的设计模式,它提供了一种机制,允许组件在不同的实现之间进行插拔,从而实现松耦合的架构。SPI通常用于实现插件化、可扩展的应用程序,使开发人员能够轻松地添加、替换或定制系统中的功能模块。
当服务的提供者提供了一种接口的实现之后,需要在classpath下的META-INF/services/
目录里创建一个以服务接口命名的文件,这个文件里的内容就是这个接口的具体的实现类。当其他的程序需要这个服务的时候,就可以通过查找这个jar包(一般都是以jar包做依赖)的META-INF/services/
中的配置文件,配置文件中有接口的具体实现类名,可以根据这个类名进行加载实例化,就可以使用该服务了。JDK中查找服务的实现的工具类是:java.util.ServiceLoader
。
一个常见的 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); // ...其他日志方法 }
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 进行日志记录 // ... }
META-INF/services
目录下创建一个文件,以接口全限定名为文件名,写入实现类的全限定名。对于 SLF4J,该文件名为 org.slf4j.Logger
。shell
# 文件:META-INF/services/org.slf4j.Logger com.example.logging.Log4jLogger
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(Service Provider Interface)的原理涉及 Java 的类加载机制、反射以及配置文件加载。以下是SPI的工作原理:
接口定义: 首先,您需要定义一个接口,该接口描述了一组服务或功能的方法。这个接口将作为服务提供者和服务使用者之间的约定。
服务提供者接口: 在SPI中,您通常会定义一个专门的接口,用于服务提供者注册和实例化。这个接口可能包括方法用于获取特定的服务实例。
服务提供者实现: 不同的模块、库或插件可以提供针对接口的不同实现。每个实现都需要提供一个特定的类,实现服务提供者接口,并在实现类中提供相关的功能代码。
服务配置文件: SPI的核心是一个配置文件,通常命名为 META-INF/services/<接口全限定名>
。在这个文件中,您列出了实现您接口的类的名称。
META-INF/services
目录下创建一个以接口全限定名为文件名的文件。加载机制: 当需要使用某个服务时,您可以通过 Java 的类加载机制以及反射来加载并实例化相应的实现类。具体过程如下:
服务使用者: 服务使用者是通过服务提供者接口来获取服务实例的组件。它可以从已注册的服务提供者中选择一个合适的实现,然后使用该实现的功能。
在运行时,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机制的缺陷:
Dubbo 就是通过 SPI 机制加载所有的组件。不过,Dubbo 并未使用 Java 原生的 SPI 机制,而是对其进行了增强,使其能够更好的满足需求。在 Dubbo 中,SPI 是一个非常重要的模块。基于 SPI,我们可以很容易的对 Dubbo 进行拓展。 Dubbo 中,SPI 主要有两种用法,一种是加载固定的扩展类,另一种是加载自适应扩展类。这两种方式会在下面详细的介绍。 需要特别注意的是: 在 Dubbo 中,基于 SPI 扩展加载的类是单例的。
Dubbo 加载扩展的整个流程如下:
主要步骤为 4 个:
下面以扩展协议为例进行说明如何利用 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 SPI 通过 ExtensionLoader
加载扩展。 ExtensionLoader
的 getExtensionLoader
方法获取一个 ExtensionLoader
实例,然后再通过 ExtensionLoader
的 getExtension
方法获取拓展类对象。这其中,getExtensionLoader
方法用于从缓存中获取与拓展类对应的 ExtensionLoader
,若缓存未命中,则创建一个新的实例。该方法的逻辑比较简单,本章就不进行分析了。下面我们从 ExtensionLoader
的 getExtension
方法作为入口,对拓展类对象的获取过程进行详细的分析。
上面代码的逻辑比较简单,首先检查缓存,缓存未命中则创建拓展对象。下面我们来看一下创建拓展对象的过程是怎样的。
createExtension 方法的逻辑稍复杂一下,包含了如下的步骤:
以上步骤中,第一个步骤是加载拓展类的关键,第三和第四个步骤是 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 等等。除此之外,该方法没有其他什么逻辑了。