通俗易懂的解释 Java 中的 SPI 机制

前言

讲 SPI 机制之前,先说说 API ,从面向接口编程的思想来看,「服务调用方」应该通过调用「接口」而不是「具体实现」来处理逻辑。那么,对于「接口」的定义,应该在「服务调用方」还是「服务提供方」呢?

一般来说,会有两种选择:

  1. 「接口」定义在「服务提供方」
  2. 「接口」定义在「服务调用方」

情况1: 先来看看「接口」属于「提供方」的情况。这个很容易理解,提供方同时提供了「接口」和「实现类」,「调用方」可以调用接口来达到调用某实现类的功能,这就是我们日常使用的 API 。

API 的显著特征:接口和实现都在服务提供方中。自定义接口,自己去实现这个接口,也就是提供实现类,最后提供给外部去使用

情况2: 那么再来看看「接口」属于「调用方」的情况。这个其实就是 SPI 机制。以 JDBC 驱动为例,「调用方」定义了java.sql.Driver接口(没有实现这个接口),这个接口位于「调用方」JDK 的包中,各个数据库厂商(也就是服务提供方)实现了这个接口,比如 MySQL 驱动 com.mysql.jdbc.Driver 。

SPI的显著特征:「接口」在「调用方」的包,「调用方」定义规则,而实现类在「服务提供方」中

总结一下:

  1. API 其实是服务提供方,它把接口和实现都做了,然后提供给服务调用方来用,服务提供方是处于主导地位的,此时服务调用方是被动的
  2. SPI 则是服务调用方去定义了某种标准(接口),你要给我提供服务,就必须按照我的这个标准来做实现,此时服务调用方的处于主导的,而服务提供方是被动的

概念

SPI 全称:Service Provider Interface,是Java提供的一套用来被第三方实现或者扩展的接口,它可以用来启用框架扩展和替换组件。

这是一种JDK内置的一种服务发现的机制,用于制定一些规范,实际实现方式交给不同的服务厂商。如下图:
image.png

面向的对象的设计里,我们一般推荐模块之间基于接口编程,模块之间不对实现类进行硬编码。一旦代码里涉及具体的实现类,就违反了可插拔的原则,如果需要替换一种实现,就需要修改代码。

Q:上图的基于接口编程体现在哪里?可插拔又是指什么?
A:调用方只会去依赖上图的标准服务接口,而不会去管实现类,这样做的优点有哪些呢?

  1. 如果你想再增加一个实现类,你只需要去实现这个接口就可以,其他地方的代码都不用动,这是扩展性的体现
  2. 又或者是某天你不需要实现类A了,那直接把实现类A去掉就可以了,对整个系统不会有大的改动,这就是可插拔和组件化思想的好处,此时整个系统还实现了充分的解偶

SPI 应用案例之 JDBC DriverManager

众所周知,关系型数据库有很多种,如:MySQL、Oracle、PostgreSQL 等等。Java 的 JDBC 提供了一套 API 供 Java 应用与数据库进行交互,但是,不同的数据库在底层实现上是有区别的呀,我现在想用这一套 API 对所有数据库都适用,那怎么办勒?此时就出现了一个东西,这个东西就是驱动。

**举个例子:**这就相比于你说中文,但是你的客户可能有说英语、法语、德语等等,此时你是不是希望有个翻译,而且是有多个翻译,有翻译成英语的,翻译法语的等等(假设一个翻译只能把中文翻译成一种语言),有了不同的翻译之后,这样就可以把你说的中文翻译给不同语言的人听,而驱动就是翻译

实现 SPI 的四步:

  1. 服务的调用者要先定义好接口
  2. 服务的提供者提供了接口的实现
  3. 需要在类目录下的 META-INF/services/ 目录里创建一个以服务接口命名的文件,这个文件里的内容就是这个接口的具体的实现类。
  4. 当其他的程序需要这个服务(服务提供者提供的)的时候,就可以通过查找这个jar包(一般都是以jar包做依赖)的 META-INF/services/ 中的配置文件,配置文件中有接口的具体实现类名,可以根据这个类名进行加载实例化,就可以使用该服务了。

接下来看看 JDBC 的实践是怎么做的:

  1. 这是 java.sql.Driver(JDK)中定义驱动的接口(对应在服务的调用者要先定义好接口)

图1

  1. 这是MySQL驱动中的Driver类,它实现了上面的Driver接口

图2

  1. 并且我们发现在META-INF/services/ 目录里创建一个以服务接口(java.sql.Driver)命名的文件,这个文件里的内容就是这个接口的具体的实现类

图3

  1. 怎么去把驱动的服务提供给调用者呢?现在常用的就是直接引入依赖就可以

图4

SPI 原理

上文中,我们了解了使用 Java SPI 的方法。那么 Java SPI 是如何工作的呢?实际上,Java SPI 机制依赖于 ServiceLoader 类去解析、加载服务。因此,掌握了 ServiceLoader 的工作流程,就掌握了 SPI 的原理。ServiceLoader 的代码本身很精练,接下来,让我们通过读源码的方式,逐一理解 ServiceLoader 的工作流程。

ServiceLoader 的成员变量

先看一下 ServiceLoader 类的成员变量,大致有个印象,后面的源码中都会使用到。

public final class ServiceLoader<S> implements Iterable<S> {

    // SPI 配置文件目录
    private static final String PREFIX = "META-INF/services/";

    // 将要被加载的 SPI 服务
    private final Class<S> service;

    // 用于加载 SPI 服务的类加载器
    private final ClassLoader loader;

    // ServiceLoader 创建时的访问控制上下文
    private final AccessControlContext acc;

    // SPI 服务缓存,按实例化的顺序排列
    private LinkedHashMap<String,S> providers = new LinkedHashMap<>();

    // 懒查询迭代器
    private LazyIterator lookupIterator;

    // ...
}
ServiceLoader 的工作流程

(1)ServiceLoader.load 静态方法
image.png
应用程序加载 Java SPI 服务,都是先调用 ServiceLoader.load 静态方法,这个方法会new ServiceLoader对象
ServiceLoader.load 静态方法的作用是:

  1. 指定类加载 ClassLoader 和访问控制上下文;
  2. 然后,重新加载 SPI 服务
  • 清空缓存中所有已实例化的 SPI 服务
  • 根据 ClassLoader 和 SPI 类型,创建懒加载迭代器

这里,摘录 ServiceLoader.load 相关源码,如下:

// service 传入的是期望加载的 SPI 接口类型
// loader 是用于加载 SPI 服务的类加载器
public static <S> ServiceLoader<S> load(Class<S> service, ClassLoader loader) {
  return new ServiceLoader<>(service, loader);
}
  
// 私有构造方法
// 重新加载 SPI 服务
private ServiceLoader(Class<S> svc, ClassLoader cl) {
  service = Objects.requireNonNull(svc, "Service interface cannot be null");
    // 指定类加载 ClassLoader 和访问控制上下文
  loader = (cl == null) ? ClassLoader.getSystemClassLoader() : cl;
  acc = (System.getSecurityManager() != null) ? AccessController.getContext() : null;
    // 然后,重新加载 SPI 服务
  reload();
}  
  
public void reload() {
    // 清空缓存中所有已实例化的 SPI 服务
  providers.clear();
    // 根据 ClassLoader 和 SPI 类型,创建懒加载迭代器
  lookupIterator = new LazyIterator(service, loader);
}

(2)应用程序通过 ServiceLoader 的 iterator 方法遍历 SPI 实例

ServiceLoader 的类定义,明确了 ServiceLoader 类实现了 Iterable 接口,所以,它是可以迭代遍历的。实际上,ServiceLoader 类维护了一个缓存 providers( LinkedHashMap 对象),缓存 providers 中保存了已经被成功加载的 SPI 实例,这个 Map 的 key 是 SPI 接口实现类的全限定名,value 是该实现类的一个实例对象。
当应用程序调用 ServiceLoader 的 iterator 方法时,ServiceLoader 会先判断缓存 providers 中是否有数据:如果有,则直接返回缓存 providers 的迭代器;如果没有,则返回懒加载迭代器的迭代器。

public Iterator<S> iterator() {
  return new Iterator<S>() {

        // 缓存 SPI providers
    Iterator<Map.Entry<String,S>> knownProviders
      = providers.entrySet().iterator();

        // lookupIterator 是 LazyIterator 实例,用于懒加载 SPI 实例
    public boolean hasNext() {
      if (knownProviders.hasNext())
        return true;
      return lookupIterator.hasNext();
    }

    public S next() {
      if (knownProviders.hasNext())
        return knownProviders.next().getValue();
      return lookupIterator.next();
    }

    public void remove() {
      throw new UnsupportedOperationException();
    }

  };
}

(3)懒加载迭代器的工作流程

上面的源码中提到了,lookupIterator 是 LazyIterator 实例,而 LazyIterator 用于懒加载 SPI 实例。那么, LazyIterator 是如何工作的呢?
这里,摘取 LazyIterator 关键代码

hasNextService 方法:

  • 拼接 META-INF/services/ + SPI 接口全限定名
  • 通过类加载器,尝试加载资源文件
  • 解析资源文件中的内容,获取 SPI 接口的实现类的全限定名 nextName

nextService 方法:

  • hasNextService () 方法解析出了 SPI 实现类的的全限定名 nextName,通过反射,获取 SPI 实现类的 类定义 Class。
  • 然后,尝试通过 Class 的 newInstance 方法实例化一个 SPI 服务对象。如果成功,则将这个对象加入到缓存 providers 中并返回该对象。
private boolean hasNextService() {
  if (nextName != null) {
    return true;
  }
  if (configs == null) {
    try {
            // 1.拼接 META-INF/services/ + SPI 接口全限定名
            // 2.通过类加载器,尝试加载资源文件
            // 3.解析资源文件中的内容
      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 s");
  }
  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
}

SPI 和类加载器

通过上面两个章节中,走读 ServiceLoader 代码,我们已经大致了解 Java SPI 的工作原理,即通过 ClassLoader 加载 SPI 配置文件,解析 SPI 服务,然后通过反射,实例化 SPI 服务实例。我们不妨思考一下,为什么加载 SPI 服务时,需要指定类加载器 ClassLoader 呢?
学习过 JVM 的读者,想必都了解过类加载器的双亲委派模型(Parents Delegation Model)。双亲委派模型要求除了顶层的 BootstrapClassLoader 外,其余的类加载器都应有自己的父类加载器。这里类加载器之间的父子关系一般通过组合(Composition)关系来实现,而不是通过继承(Inheritance)的关系实现。
双亲委派机制约定了:一个类加载器首先将类加载请求传送到父类加载器,只有当父类加载器无法完成类加载请求时才尝试加载。

双亲委派的好处:
使得 Java 类伴随着它的类加载器,天然具备一种带有优先级的层次关系,从而使得类加载得到统一,不会出现重复加载的问题:

  1. 系统类防止内存中出现多份同样的字节码
  2. 保证 Java 程序安全稳定运行

例如:java.lang.Object 存放在 rt.jar 中,如果编写另外一个 java.lang.Object 的类并放到 classpath 中,程序可以编译通过。因为双亲委派模型的存在,所以在 rt.jar 中的 Object 比在 classpath 中的 Object 优先级更高,因为 rt.jar 中的 Object 使用的是启动类加载器,而 classpath 中的 Object 使用的是应用程序类加载器。正因为 rt.jar 中的 Object 优先级更高,因为程序中所有的 Object 都是这个 Object。

双亲委派的限制:
子类加载器可以使用父类加载器已经加载的类,而父类加载器无法使用子类加载器已经加载的。—— 这就导致了双亲委派模型并不能解决所有的类加载器问题。Java SPI 就面临着这样的问题:

  1. SPI 的接口是 Java 核心库的一部分,是由 BootstrapClassLoader 加载的;
  2. 而 SPI 实现的 Java 类一般是由 AppClassLoader 来加载的。BootstrapClassLoader 是无法找到 SPI 的实现类的,因为它只加载 Java 的核心库。它也不能代理给 AppClassLoader,因为它是最顶层的类加载器。这也解释了本节开始的问题 —— 为什么加载 SPI 服务时,需要指定类加载器 ClassLoader 呢?因为如果不指定 ClassLoader,则无法获取 SPI 服务。

如果不做任何的设置,Java 应用的线程的上下文类加载器默认就是 AppClassLoader。在核心类库使用 SPI 接口时,传递的类加载器使用线程上下文类加载器,就可以成功的加载到 SPI 实现的类。线程上下文类加载器在很多 SPI 的实现中都会用到。
通常可以通过 Thread.currentThread ().getClassLoader () 和 Thread.currentThread ().getContextClassLoader () 获取线程上下文类加载器。

再回顾

在前文中,我们通过 JDBC 作为例子(1.3节)详细介绍了 SPI 机制(1.4节)。现在,让我们在理解了上述知识的基础上,深入探讨一下 Java 数据库驱动加载的原理,下面是用 JDBC 去连接 MySQL 数据库的示例代码,程序在加载DriverManager 类时,会将 MySQL 的 Driver 对象注册进 DriverManager 中,这是 SPI 思想的一个典型的实现。得益于 SPI 思想,应用程序中无需指定类似 “com.mysql.cj.jdbc.Driver” 这种全类名,尽可能地将第三方驱动从应用程序中解耦出来。

public static void main(String[] args) {
    // JDBC连接URL
    String url = "jdbc:mysql://localhost:3306/mydatabase";
    String username = "root";
    String password = "password";

    try {
        // 创建数据库连接
        Connection connection = DriverManager.getConnection(url, username, password);
        
        // 在此处执行数据库操作
        
        // 关闭连接
        connection.close();
    } catch (SQLException e) {
        e.printStackTrace();
    }
}
}

源码分析:
DriverManager 是管理 JDBC 驱动的基础服务类,位于 Java.sql 包中,由 boot 类加载器来进行加载。加载该类时,会先执行如下代码块:
image.png

然后我们点进loadInitialDrivers()方法,是不是对下图的红框部分有种似曾相识的感觉,没错,这就是咱们前面 1.4.2 小节第 2 点提到的,帮大家回忆一下,主要是下面这 4 点

  1. 应用程序加载 Java SPI 服务,都是先调用 ServiceLoader.load 静态方法,这个方法会new ServiceLoader对象
  2. 当应用程序调用 ServiceLoader 的 iterator 方法时,ServiceLoader 会先判断缓存 providers 中是否有数据:如果有,则直接返回缓存 providers 的迭代器;如果没有,则返回懒加载迭代器的迭代器。
  3. driversIterator.hasNext():此方法用于查找Driver类;
  4. driversIterator.next():在实现的"next()"方法中进行类加载,使用上面的线程上下文类加载器。

image.png
image.png
如果你这里还是没看明白的话,建议再去 1.4.2 节理解一下,next() 方法会最终调用到 nextService() 方法,会尝试通过 Class 的 newInstance 方法实例化一个 SPI 服务对象。如果成功,则将这个对象加入到缓存 providers 中并返回该对象。
经历了上述ServiceLoader类中的一系列操作之后(包括服务发现和类加载),位于 MySQL 驱动包中的Driver类会被初始化。该类如下所示
image.png
我们点进去这个注册方法,会发现这里会将该 MySQL 驱动添加到成员变量 registeredDrivers 中,该成员变量存放已注册的 JDBC 驱动列表,
image.png
这样一来,服务发现、类加载、驱动注册便到此结束。接下来,应用程序执行数据库连接操作时,会调用getConnection方法,里面会遍历 registeredDrivers,获取驱动,建立数据库连接。

Q: 这里又会引申出一个问题,既然是遍历 registeredDrivers 列表,那是怎么区分多个驱动的呢?
A: 我们来看看 getConnection 方法的核心步骤,DriverManager.getConnection 中会遍历所有已经加载的驱动实例去创建连接,(aDriver.driver.connect这个方法会测试输入的 URL 的这个驱动是否匹配)当一个驱动创建连接成功时就会返回这个连接,同时不再调用其他的驱动实例。

image.png

大总结:
现在我们再来看看下面这段话,我相信理解起来没有任何难度

当应用程序需要使用某项服务时,它会在类路径下查找 META-INF/services 目录下的配置文件,文件中列出了对应服务接口的实现类。然后,应用程序可以通过 Java 标准库提供的 ServiceLoader 类动态加载并实例化这些服务提供者,从而使用其功能。这种机制被广泛应用于 Java 中各种框架和组件的扩展开发,例如数据库驱动、日志模块等。

Java SPI 的优点和不足

Java SPI 的一些优点:

  1. 实现解耦:SPI 使得接口的定义与具体业务实现分离,使得应用程序可以根据实际业务情况启用或替换具体组件。
  2. 可插拔性和可扩展性:SPI 允许第三方提供新的服务实现模块,并通过配置文件进行声明,在运行时动态加载,这样可以轻松地扩展和替换系统中的功能模块,实现了可插拔性和可扩展性。

Java SPI 存在一些不足:

  1. 不能按需加载,需要遍历所有的实现,并实例化,然后在循环中才能找到我们需要的实现。即使某些实现类未被需要,或者实例化过程耗时较长,它们也会被加载并实例化,导致资源浪费。
  2. 获取某个实现类的方式不够灵活,只能通过 Iterator 形式获取,不能根据某个参数来获取对应的实现类。
  3. 多个并发多线程使用 ServiceLoader 类的实例是不安全的。
  • 58
    点赞
  • 28
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值