Service Provider Interface(SPI) 思想

2,189 阅读7分钟

什么是 SPI

Service Provider Interface(SPI) 直译是服务提供者接口,它里面有两个概念

  • Service Interface : 服务接口。有面向对象编程经验的人肯定能理解接口是什么,那这里的服务怎么理解,其实跟我们常说的服务一样,这里也是指能够提供某种功能的东西。Service Interface 负责制定服务的标准
  • Service Provider : 服务提供者。按照标准实现具体的服务PostgreSQL

上面这两个概念和面向接口编程中的 接口提供方和接口实现方相似。

Service Provider Interface(SPI) 通过服务接口服务提供者,实现了服务规范的制定和服务具体实现的分离!当然,如果仅此而已,那么它和面向接口编程的思想也没有什么区别。

重点来了,Service Provider Interface(SPI) 还引入了服务发现的概念。(这里的服务发现zookeeper 中的服务发现可不是同一个东西)

SPI 的服务发现

java 为例,我一般使用 Map 类型时会这么写

// 创建一个具体的 Map 实例
Map<String, String> map = new HashMap<>();
// 使用 Map
map.put("","");
...

这样写有没有什么问题?有的读者可能会说,这还能有什么问题,JDK 源码都是这样用的。

但是在 SPI 看来,这段代码将 Map 接口的实现类写死了,假如代码上线后发现系统访问量特别大,HashMap 出现了多线程并发的问题,需要改成 ConcurrentHashMap 怎么办

有的人可能会说,哪把代码改一下

// 创建一个具体的 Map 实例
java.util.Map<String, String> map = new ConcurrentHashMap<>();
// 使用 Map
map.put("","");
...

这样固然可以,但是有没有一种更好的方法,能够在不修改代码的情况下,动态的指定 Map 的实现类呢?

SPI 说当然可以,我们可以设置一种规则,代码按照这种规则就能找到接口的对应实现。例如我们把接口的实现类写在配置文件中,然后在代码中读取该配置

mapImpl.properties 文件

mapImpl=java.util.concurrent.ConcurrentHashMap

伪代码如下

//找到配置文件
ResourceBundle resourceBundle = ResourceBundle.getBundle("mapImpl.properties");
// 找到实现类的完全限定名
String name = resourceBundle.getString("mapImpl");
// 加载实现类,并实例化
Map<String, String> map = (Map<String, String>) Class.forName(name).newInstance();
// 使用 Map
map.put("","");
...

这样 Map 的实现类就不是写死在代码中而是在配置文件中配置

这种通过某种规则找到服务的实现,就被 SPI 称作服务发现,有点类似 IOC 将装配的控制权移到程序之外的思想

上面用 Map 举例不是很恰当,但并不妨碍我们理解 SPI服务发现的含义

SPI 思想总结

我只制定两个东西

  1. 服务(接口)规范
  2. 一种找到该服务(接口)的实现的规则

重点是第二点,我们一般使用接口需要直接依赖接口的具体实现,有了SPI后我们使用接口只需要一种能找到接口实现的规则就行,不需要直接依赖接口的具体实现

这就是 SPI 思想的核心,通过制定接口其实现的发现机制将接口和接口的实现了进一步解耦合

SPI 思想的运用

SPI 思想运用非常广泛,以 java 生态为例,JDK、Dubbo、Spring 都提供了SPI 的实现

java SPI

java 1.6 开始内置了 SPI 的实现,这个类就是 java.util.ServiceLoader,它的服务发现机制如下

  • 文件放在 ClassPathMETA-INF/services/ 路径下
  • 文件名为接口完全限定名
  • 文件内容为接口实现类的完全限定名

例如接口为 java.util.Map,则需要在 ClassPath 下创建 META-INF/services/java.util.Map 文件,内容比如说

java.util.HashMap
java.util.concurrent.ConcurrentHashMap

java 其实早在 1.4 版中就实现了 SPI,类为 javax.imageio.spi.ServiceRegistryjava.util.ServiceLoader 的大部分内容都沿袭自该类,包括 META-INF/services/ 路径以及使用接口完全限定名作为文件名等,而且 javax.imageio.spi.ServiceRegistry 功能还更加强大,提供了实现类的排序。

java SPI 的使用

java.util.ServiceLoader 使用更为广泛,这里主要介绍它的用法

还是以 java.util.Map 接口为例

// 加载 META-INF/services/java.util.Map 文件
ServiceLoader<java.util.Map> serviceLoader = ServiceLoader.load(java.util.Map.class);
// 获取到 META-INF/services/java.util.Map 文件中所有的实现类
Iterator<java.util.Map> iterator = serviceLoader.iterator();
// 加载每一个实现类
 while(iterator.hasNext()) {
     iterator.next();
 }

注意 iterator 虽然拿到了所有的实现类,但它是惰性加载,因此必须调用 Iterator.next()方法将实现类加载到 JVM

// META-INF/services/java.util.Map 文件可能定义了多个实现类,具体要用哪一个可以自定义,比如我选择最后一个实现类
List<java.util.Map> list = new ArrayList<>();
while (iterator.hasNext()) {
//加载并初始化实现类
java.util.Map map = iterator.next();
list.add(map);
}
// 对最后一个configuration类调用configure方法
java.util.Map map = list.get(list.size() - 1);
map.put("", "");

java SPI 的不足

主要是指 java.util.ServiceLoader 的不足

  1. 不能按需加载,需要遍历所有的实现,并实例化,然后在循环中才能找到我们需要的实现。如果不想用某些实现类,或者某些类实例化很耗时,它也被载入并实例化了,这就造成了浪费。

  2. 获取某个实现类的方式不够灵活,只能通过 Iterator 形式获取,不能根据某个参数来获取对应的实现类。

  3. 多个并发多线程使用 ServiceLoader 类的实例是不安全的。

JDBC 对 java SPI 的运用

JDBCjava.sql.Driver 接口是使用 java SPI 的一个非常好的例子。

JDBC 只负责制定数据库驱动的规范,具体某个数据库驱动的实现交由各种的数据库厂商自己去实现,然后通过 java SPI 的服务发现加载具体的数据库驱动。

JDBC 也只能这么做,因为你根本不知道未来还会出现什么数据库

JDBC 加载数据库驱动核心源码

JDBC 通过 DriverManager 这个类来加载一个具体的 java.sql.Driver 接口的实例

package java.sql;

public class DriverManager {

    static {
        loadInitialDrivers();
        println("JDBC DriverManager initialized");
    }
}

如上在静态代码块中执行了 loadInitialDrivers(); 这个方法,该方法的具体内容如下

package java.sql;

public class DriverManager {

	private static void loadInitialDrivers() {
        ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class);
		Iterator<Driver> driversIterator = loadedDrivers.iterator();
       
        while(driversIterator.hasNext()) {
            driversIterator.next();
        }
    }
}

我把所有不相关的代码都去掉了,其实就是拿到 META-INF/services/java.sql.Driver 中的所有实现类,然后调用 Iterator.next()方法加载实现类

你可能会疑惑,上面的代码只是加载了 Driver 的实现类,但是没有选择某个具体的 Driver,是的,因为选择权应该交由用户,比如我及引入了 mysql 又引入 PostgreSQL ,难道你要让 DriverManager 写死,比如永远使用第一个 Driver

既然 Driver 是用户引入的,用户肯定知道自己想使用哪个,例如在 spring boot 中,我通过 maven引入了 MySQLPostgreSQL 的驱动,但是使用时,我指定使用 mysql 的驱动

spring:
  datasource:
    url: jdbc:mysql://127.0.0.1/test
    username: root
    password: 123456
    driver-class-name: com.mysql.cj.jdbc.Driver

mysql 驱动中的 META-INF/services/java.sql.Driver 文件

image-20210716152010076.png

Java SPI 破坏了双亲委派模型?

网上有些文章拿上面的 JDBC 加载驱动为例子,认为 Java SPI 破坏了双亲委派模型,他们的论证如下

public static void main(String[] args) {
    Enumeration<Driver> drivers = DriverManager.getDrivers();
    Driver driver;
    while (drivers.hasMoreElements()) {
        driver = drivers.nextElement();
        System.out.println(driver.getClass() + "------" + driver.getClass().getClassLoader());
    }
    System.out.println(DriverManager.class.getClassLoader());
}

使用 JDBC 加载驱动,并查看比较他们的类加载器

class com.mysql.cj.jdbc.Driver------sun.misc.Launcher$AppClassLoader@18b4aac2
null

从结果可以看出

com.mysql.cj.jdbc.Driver 是通过 Application ClassLoader 加载进来的

DriverManagernull,即通过 Bootstrap ClassLoader 加载进来的

由于双亲委派模型,父加载器是拿不到通过子加载器加载的类的。而这里,通过 Bootstrap ClassLoader 加载进来的 DriverManager,可以拿到 Application ClassLoader 加载进来的 com.mysql.cj.jdbc.Driver ,因此他们说 Java SPI 破坏了双亲委派模型

看上去蛮有道理的,但仔细思考就会发现,Java SPI 只是对 SPI 思想的一种实现,跟双亲委派模型有什么关系?

哪上面的问题怎么解释呢?其实很简单,因为 Java SPI 是通过 ServiceLoader.load() 这个方法来加载实现类的,你只需要点进 ServiceLoader.load() 这个方法中去你就知道是什么原因了

public static <S> ServiceLoader<S> load(Class<S> service) {
    ClassLoader cl = Thread.currentThread().getContextClassLoader();
    return ServiceLoader.load(service, cl);
}

每一个线程都有自己的 ContextClassLoader,默认为 SystemClassLoader 。但是我们可以通过 Thread.setContextClassLoader(ClassLoader cl) 方法,把一个 ClassLoader 放在一个线程的实例之中,让该 ClassLoader 成为一个相对共享的实例,这样即便是使用 Bootstrap 类加载器加载的代码也可以通过这种方式访问应用类加载器中的类了。

也就是说不是 Java SPI 破坏了双亲委派模型,而是 Thread.setContextClassLoader(ClassLoader cl) 这个方法破坏了双亲委派模型

Spring SPI

Spring 也实现了自己的 SPI ,功能和使用方式上和 Java SPI 的类似,实现类的完全限定名放在 META-INF/spring.factories 这个固定的文件中

例如 image.png

使用方式也很简单,例如

//获取所有 factories 文件中配置的 MongoRepositoryFactory
List<MongoRepositoryFactory> factories = SpringFactoriesLoader.loadFactories(MongoRepositoryFactory.class, Thread.currentThread().getContextClassLoader());

ClassPath 中存在多个 spring.factories 文件,Spring SPI 也是支持加载时会按照 classpath 的顺序依次加载这些 spring.factories 文件,添加到一个 ArrayList

Spring SPI 是将所有接口的实现放到一个固定的文件中,而 java SPI 是将不同接口的实现放到对应的文件中。至于是用一个文件好,还是每个接口单独一个文件好,这个问题就见仁见智了

Spring Boot 中, ClassLoader 会优先加载项目中的文件,而不是依赖包中的文件。所以如果在你的项目中定义个 spring.factories 文件,那么你项目中的文件会被第一个加载,得到的 Factories 中,项目中 spring.factories 里配置的那个实现类也会排在第一个

参考

javase 6 api

javase 1.4 api

# JDK/Dubbo/Spring 三种 SPI 机制,谁更好?

jdbc 类加载器,与 spi 服务机制

# SPI 的 ClassLoader 问题

ClassLoader 与双亲委托模式以及「SPI」

Java 中的 ClassLoader 和 SPI 机制

Java SPI 详解

java 类加载器以及 spi

Java SPI 思想梳理

Java-SPI 机制

设计原则:小议 SPI 和 API

# 深入理解 java SPI 机制