likes
comments
collection
share

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

作者站长头像
站长
· 阅读数 22

SPI机制

什么是SPI机制

SPI(Service Provider Interface)是Java中一种服务提供者接口的设计模式,它提供了一种机制,允许组件在不同的实现之间进行插拔,从而实现松耦合的架构。SPI通常用于实现插件化、可扩展的应用程序,使开发人员能够轻松地添加、替换或定制系统中的功能模块。

解析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() 等。
public interface Logger {
    void info(String message);
    void debug(String message);
    void error(String message);
    // ...其他日志方法
}
  1. 编写日志实现类: 然后,为不同的日志实现编写实现类,这些实现类分别对接各种日志框架,比如 Log4j、Logback、JDK Logging 等。
// 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
# 文件:META-INF/services/org.slf4j.Logger
com.example.logging.Log4jLogger
  1. 使用日志接口: 在应用程序中,您只需要使用 SLF4J 提供的接口进行日志记录,而不需要关心具体的日志实现。
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>方法的具体实现:

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

首先,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机制的缺陷:

  • 不能按需加载,需要遍历所有的实现,并实例化,然后在循环中才能找到我们需要的实现。如果不想用某些实现类,或者某些类实例化很耗时,它也被载入并实例化了,这就造成了浪费。
  • 如果扩展点加载失败,连扩展点的名称都拿不到了。比如: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 优势

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

Dubbo SPI原理

Dubbo 扩展加载流程

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

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

主要步骤为 4 个:

  • 读取并解析配置文件
  • 缓存所有扩展实现
  • 基于用户执行的扩展名,实例化对应的扩展实现
  • 进行扩展实例属性的 IOC 注入以及实例化扩展的包装类,实现 AOP 特性

如何使用 Dubbo 扩展能力进行扩展

下面以扩展协议为例进行说明如何利用 Dubbo 提供的扩展能力扩展 Triple 协议。

(1) 在协议的实现 jar 包内放置文本文件:META-INF/dubbo/org.apache.dubbo.remoting.api.WireProtocol

tri=org.apache.dubbo.rpc.protocol.tri.TripleHttp2Protocol

(2) 实现类内容

@Activate
public class TripleHttp2Protocol extends Http2WireProtocol {
    // ...
}

说明下:Http2WireProtocol 实现了 WireProtocol 接口

(3) Dubbo 配置模块中,扩展点均有对应配置属性或标签,通过配置指定使用哪个扩展实现。比如:

<dubbo:protocol name="tri" />

从上面的扩展步骤可以看出,用户基本在黑盒下就完成了扩展。

Dubbo 扩展的应用

Dubbo 的扩展能力非常灵活,在自身功能的实现上无处不在。

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

Dubbo 扩展能力使得 Dubbo 项目很方便的切分成一个一个的子模块,实现热插拔特性。用户完全可以基于自身需求,替换 Dubbo 原生实现,来满足自身业务需求。

Dubbo SPI 源码分析

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

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

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

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

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

  1. 通过 getExtensionClasses 获取所有的拓展类
  2. 通过反射创建拓展对象
  3. 向拓展对象中注入依赖
  4. 将拓展对象包裹在相应的 Wrapper 对象中

以上步骤中,第一个步骤是加载拓展类的关键,第三和第四个步骤是 Dubbo IOC 与 AOP 的具体实现。在接下来的章节中,将会重点分析 getExtensionClasses 方法的逻辑,以及简单介绍 Dubbo IOC 的具体实现。

我们在通过名称获取拓展类之前,首先需要根据配置文件解析出拓展项名称到拓展类的映射关系表(Map<名称, 拓展类>),之后再根据拓展项名称从映射关系表中取出相应的拓展类即可。相关过程的代码分析如下: 解析SPI机制:实现灵活插件式架构

这里也是先检查缓存,若缓存未命中,则通过 synchronized 加锁。加锁后再次检查缓存,并判空。此时如果 classes 仍为 null,则通过 loadExtensionClasses 加载拓展类。下面分析 loadExtensionClasses 方法的逻辑。 解析SPI机制:实现灵活插件式架构

loadExtensionClasses 方法总共做了两件事情,一是对 SPI 注解进行解析,二是调用 loadDirectory 方法加载指定文件夹配置文件。SPI 注解解析过程比较简单,无需多说。下面我们来看一下 loadDirectory 做了哪些事情。

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

loadDirectory 方法先通过 classLoader 获取所有资源链接,然后再通过 loadResource 方法加载资源。我们继续跟下去,看一下 loadResource 方法的实现。

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

loadResource 方法用于读取和解析配置文件,并通过反射加载类,最后调用 loadClass 方法进行其他操作。loadClass 方法用于主要用于操作缓存,该方法的逻辑如下:

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

如上,loadClass 方法操作了不同的缓存,比如 cachedAdaptiveClass、cachedWrapperClasses 和 cachedNames 等等。除此之外,该方法没有其他什么逻辑了。