<返回更多

解析SPI机制:实现灵活插件式架构

2023-08-27  稀土掘金  半亩方塘立身
加入收藏

什么是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 允许应用程序在运行时选择不同的日志实现,而无需修改代码。以下是一个简化的示例:

  1. 定义日志接口: 首先,定义一个日志接口,例如 Logger,其中包含了常见的日志方法,如 info(), debug(), error() 等。

 
java
复制代码
public interface Logger { void info(String message); void debug(String message); void error(String message); // ...其他日志方法 }
  1. 编写日志实现类: 然后,为不同的日志实现编写实现类,这些实现类分别对接各种日志框架,比如 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 进行日志记录 // ... }
  1. SPI 配置文件:META-INF/services 目录下创建一个文件,以接口全限定名为文件名,写入实现类的全限定名。对于 SLF4J,该文件名为 org.slf4j.Logger

 
shell
复制代码
# 文件:META-INF/services/org.slf4j.Logger com.example.logging.Log4jLogger
  1. 使用日志接口: 在应用程序中,您只需要使用 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的工作原理:

  1. 接口定义: 首先,您需要定义一个接口,该接口描述了一组服务或功能的方法。这个接口将作为服务提供者和服务使用者之间的约定。

  2. 服务提供者接口: 在SPI中,您通常会定义一个专门的接口,用于服务提供者注册和实例化。这个接口可能包括方法用于获取特定的服务实例。

  3. 服务提供者实现: 不同的模块、库或插件可以提供针对接口的不同实现。每个实现都需要提供一个特定的类,实现服务提供者接口,并在实现类中提供相关的功能代码。

  4. 服务配置文件: SPI的核心是一个配置文件,通常命名为 META-INF/services/<接口全限定名>。在这个文件中,您列出了实现您接口的类的名称。

    • 对于每个接口,都可以在 META-INF/services 目录下创建一个以接口全限定名为文件名的文件。
    • 在这个文件中,每一行包含一个实现类的全限定名。这些实现类是用于提供特定服务的。
  5. 加载机制: 当需要使用某个服务时,您可以通过 Java 的类加载机制以及反射来加载并实例化相应的实现类。具体过程如下:

    • 通过 SPI 配置文件,找到接口对应的实现类的全限定名列表。
    • 使用 ClassLoader 加载这些实现类的类对象。
    • 使用反射实例化这些类,得到服务提供者的实例。
  6. 服务使用者: 服务使用者是通过服务提供者接口来获取服务实例的组件。它可以从已注册的服务提供者中选择一个合适的实现,然后使用该实现的功能。

在运行时,SPI机制允许系统自动加载并实例化服务提供者的实现类,从而实现插拔式的架构。这样,您可以在不修改核心代码的情况下,通过添加新的实现类来扩展系统功能,实现更好的可扩展性和灵活性。

下面我们看下JDK中ServiceLoader<S>方法的具体实现:

首先,ServiceLoader实现了Iterable接口,所以它有迭代器的属性,这里主要都是实现了迭代器的hasNextnext方法。这里主要都是调用的lookupIterator的相应hasNextnext方法,lookupIterator是懒加载迭代器。

其次LazyIterator中的hasNext方法,静态变量PREFIX就是”META-INF/services/”目录,这也就是为什么需要在classpath下的META-INF/services/目录里创建一个以服务接口命名的文件。

最后,通过反射方法Class.forName()加载类对象,并用newInstance方法将类实例化,并把实例化后的类缓存到providers对象中,(LinkedHashMap<String,S>类型)然后返回实例对象。

所以我们可以看到ServiceLoader不是实例化以后,就去读取配置文件中的具体实现,并进行实例化。而是等到使用迭代器去遍历的时候,才会加载对应的配置文件去解析,调用hasNext方法的时候会去加载配置文件进行解析,调用next方法的时候进行实例化并缓存。

所有的配置文件只会加载一次,服务提供者也只会被实例化一次,重新加载配置文件可使用reload方法

SPI机制的缺陷

通过上面的解析,可以发现,我们使用SPI机制的缺陷:

Dubbo SPI机制

Dubbo 就是通过 SPI 机制加载所有的组件。不过,Dubbo 并未使用 Java 原生的 SPI 机制,而是对其进行了增强,使其能够更好的满足需求。在 Dubbo 中,SPI 是一个非常重要的模块。基于 SPI,我们可以很容易的对 Dubbo 进行拓展。 Dubbo 中,SPI 主要有两种用法,一种是加载固定的扩展类,另一种是加载自适应扩展类。这两种方式会在下面详细的介绍。 需要特别注意的是: 在 Dubbo 中,基于 SPI 扩展加载的类是单例的。

Dubbo SPI 优势

  1. 按需加载,Dubbo SPI配置文件采用KV格式存储,key 被称为扩展名,当我们在为一个接口查找具体实现类时,可以指定扩展名来选择相应的扩展实现,只实例化这一个扩展实现即可,无须实例化 SPI 配置文件中的其他扩展实现类,避免资源浪费,此外通过 KV 格式的 SPI 配置文件,当我们使用的一个扩展实现类所在的 jar 包没有引入到项目中时,Dubbo SPI 在抛出异常的时候,会携带该扩展名信息,而不是简单地提示扩展实现类无法加载。这些更加准确的异常信息降低了排查问题的难度,提高了排查问题的效率。;
  2. 增加扩展类的 IOC 能力,Dubbo 的扩展能力并不仅仅只是发现扩展服务实现类,而是在此基础上更进一步,如果该扩展类的属性依赖其他对象,则 Dubbo 会自动的完成该依赖对象的注入功能;
  3. 增加扩展类的 AOP 能力,Dubbo 扩展能力会自动的发现扩展类的包装类,完成包装类的构造,增强扩展类的功能;

Dubbo SPI原理

Dubbo 扩展加载流程

Dubbo 加载扩展的整个流程如下:

主要步骤为 4 个:

如何使用 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加载扩展。 ExtensionLoadergetExtensionLoader 方法获取一个 ExtensionLoader 实例,然后再通过 ExtensionLoadergetExtension 方法获取拓展类对象。这其中,getExtensionLoader 方法用于从缓存中获取与拓展类对应的 ExtensionLoader,若缓存未命中,则创建一个新的实例。该方法的逻辑比较简单,本章就不进行分析了。下面我们从 ExtensionLoadergetExtension 方法作为入口,对拓展类对象的获取过程进行详细的分析。

carbon (5).png

上面代码的逻辑比较简单,首先检查缓存,缓存未命中则创建拓展对象。下面我们来看一下创建拓展对象的过程是怎样的。

createExtension 方法的逻辑稍复杂一下,包含了如下的步骤:

  1. 通过 getExtensionClasses 获取所有的拓展类
  2. 通过反射创建拓展对象
  3. 向拓展对象中注入依赖
  4. 将拓展对象包裹在相应的 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 等等。除此之外,该方法没有其他什么逻辑了。


作者:半亩方塘立身
链接:https://juejin.cn/post/7271597656118624275
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
关键词:插件式架构      点击(12)
声明:本站部分内容来自互联网,如有版权侵犯或其他问题请与我们联系,我们将立即删除或处理。
▍相关推荐
更多插件式架构相关>>>