Java SPI 机制

Demon.Lee 2024年03月31日 550次浏览

本文实践环境:

Operating System: macOS 14.4.1
Kernel: Darwin 23.4.0
Architecture: arm64

Java version: 21

听某个 Java 专栏时,无意中知道 Java 里面还有一个 SPI 机制,想着抽空总结一下,可一转眼几个月就过去了。这也许就是我与优秀者之间的差距,行动上的矮子,没有执行力。最近在看《Effective Java 中文版(第3版) (豆瓣) 》,里面又提到了这个机制,于是便有了这篇文章。

什么是 SPI

SPI 的全称是 Service Provider Interface 的缩写,即服务提供者接口。得益于 AI 的发展,想了解某个知识点要比之前方便快捷的多,我们先来问问:

可以看到,SPI 是一种 JDK 内置的服务发现机制,使用方定义接口,不同的提供方给出各自的实现。与日常开发有所不同的是,这里的接口与实现类不在一个代码工程(即传统意义上一个 jar 包)中,从而实现了解耦。编译时,即使实现类没有,也不会有影响。等运行时,通过动态加载,我们便可以在不同的实现类中切换或选择。

这么说,可能还比较抽象,结合前面提供的实现步骤,我们通过具体的代码案例进行理解。

SPI 实战

笔者创建了一个 Java 项目工程 java-spi-practice,下面有 4 个子项目:

├── README.md
├── build.gradle
├── java-spi-chinese-greeter/
│   ├── build.gradle
│   └── src/
├── java-spi-english-greeter/
│   ├── build.gradle
│   └── src/
├── java-spi-greeter-api/
│   ├── build.gradle
│   └── src/
├── java-spi-main/
│   ├── build.gradle
│   └── src/
└── settings.gradle

1)根项目 java-spi-practicebuild.gradle 配置如下:

plugins {
    id 'java'
    id 'maven-publish'
}

allprojects {
    apply plugin: 'java'
}

subprojects {
    apply plugin: 'maven-publish'

    publishing {
        publications {
            mavenJava(MavenPublication) {
                from components.java
            }
        }
    }

    group = 'tech.demonlee'
    version = '1.0-SNAPSHOT'

    repositories {
        mavenCentral()
    }

    dependencies {
        testImplementation platform('org.junit:junit-bom:5.9.1')
        testImplementation 'org.junit.jupiter:junit-jupiter'
    }

    test {
        useJUnitPlatform()
    }
}

2)java-spi-greeter-api 项目用于定义服务接口,对应 build.gradle 没有配置,即完全使用父项目的配置。

3)java-spi-chinese-greeter 项目是一种 Greeter 的实现,即中文问候,对应的 build.gradle 配置如下:

dependencies {
    implementation('tech.demonlee:java-spi-greeter-api:1.0-SNAPSHOT')
}

4)java-spi-english-greeter 项目是另一种 Greeter 的实现,即英文问候,对应的 build.gradle 配置同上。

5)java-spi-main 项目用于模拟使用者主程序,对应的 build.gradle 配置如下:

dependencies {
    implementation('tech.demonlee:java-spi-greeter-api:1.0-SNAPSHOT')
    implementation('tech.demonlee:java-spi-chinese-greeter:1.0-SNAPSHOT')
    implementation('tech.demonlee:java-spi-english-greeter:1.0-SNAPSHOT')
}

创建好工程之后,我们开始写程序。

第 1 步:定义服务接口,对应工程为 java-spi-greeter-api , 比如笔者这里定义的 Greeter 接口:

package tech.demonlee.greeter;

public interface Greeter {
    void greet(String name);
}

第 2 步:服务提供者实现接口,这里以 java-spi-english-greeter 为例,对应代码为:

package tech.demonlee.greeter.english;

import tech.demonlee.greeter.Greeter;

public class EnglishGreeter implements Greeter {

    @Override
    public void greet(String name) {
        System.out.println("Hi, " + name);
    }
}

第 3 步:为方便 SPI 查找实现类,在 META-INF/services 目录下配置一个文件,文件名为接口全限定名,这里就是 tech.demonlee.greeter.Greeter ,文件中的内容是实现类的全限定名,比如这里的 tech.demonlee.greeter.english.EnglishGreeter

$ tree
.
├── java
│   └── tech
│       └── demonlee
│           └── greeter
│               └── english
│                   └── EnglishGreeter.java
└── resources
    └── META-INF
        └── services
            └── tech.demonlee.greeter.Greeter

9 directories, 2 files

$ cat resources/META-INF/services/tech.demonlee.greeter.Greeter
tech.demonlee.greeter.english.EnglishGreeter

第 4 步:测试验证,在 java-spi-main 工程中编写如下代码:

package tech.demonlee;

import tech.demonlee.greeter.Greeter;
import java.util.ServiceLoader;

public class Main {

    public static void main(String[] args) {
        greet();
    }

    private static void greet() {
        ServiceLoader<Greeter> greeterServiceLoader = ServiceLoader.load(Greeter.class);
        for (Greeter greeter : greeterServiceLoader) {
            System.out.println("greeter is: " + greeter.getClass().getCanonicalName());
            greeter.greet("Jack.Ma");
        }
    }
}

输出日志如下:

greeter is: tech.demonlee.greeter.chinese.ChineseGreeter
你好,Jack.Ma
greeter is: tech.demonlee.greeter.english.EnglishGreeter
Hi, Jack.Ma

完整的代码,笔者已上传到 GitHub,可以点击这里获取。

根据上面的代码实战,我们也可以看出一些端倪,那就是 SPI 机制的一些默认约定,主要有三点:

  • 实现类需要有一个无参构造函数;
  • 实现方在 META-INF/services 下配置对应接口文件;
  • 实现类对应 jar 文件需要放到 classpath 中,因为这还是一个进程内部的服务发现。

这里补充说明一下 ServiceLoader 加载 SPI 接口及其实现类的类加载器,我们先看一看 ServiceLoader#load(Class<S> service) 方法的源码:

    /**
     * Creates a new service loader for the given service type, using the
     * current thread's {@linkplain java.lang.Thread#getContextClassLoader
     * context class loader}.
     *
     * @apiNote Service loader objects obtained with this method should not be
     * cached VM-wide. For example, different applications in the same VM may
     * have different thread context class loaders. A lookup by one application
     * may locate a service provider that is only visible via its thread
     * context class loader and so is not suitable to be located by the other
     * application. Memory leaks can also arise. A thread local may be suited
     * to some applications.
     *
     * ...
     * ...
     *
     * @revised 9
     */
    @CallerSensitive
    public static <S> ServiceLoader<S> load(Class<S> service) {
        ClassLoader cl = Thread.currentThread().getContextClassLoader();
        return new ServiceLoader<>(Reflection.getCallerClass(), service, cl);
    }

SPI 默认使用线程上下文加载器来加载对应的接口实现类,而默认情况下,线程上下文加载器是应用类加载器(AppClassLoader)。而我们知道,JVM 主要有三类加载器:

1)BootClassLoader :启动类加载器,Java 核心类由该加载器进行加载,比如 java.base 等。启动类加载器由 C++ 实现,没有对应的 Java 对象,所以在 Java 中就使用 null 表示。

2)PlatformClassLoader :平台类加载器,对应 Java 9 之前的扩展类加载器,Java SE 中除了几个关键的模块由启动类加载器加载外,其他都由平台类加载器加载。

3)AppClassLoader:应用类加载器,它主要负责加载应用程序中的类,即 classpath 中指定的类文件。

package jdk.internal.loader;

// ...
// ...

/**
 * Creates and provides access to the built-in platform and application class
 * loaders. It also creates the class loader that is used to locate resources
 * in modules defined to the boot class loader.
 */
public class ClassLoaders {

    private ClassLoaders() { }

    private static final JavaLangAccess JLA = SharedSecrets.getJavaLangAccess();

    // the built-in class loaders
    private static final BootClassLoader BOOT_LOADER;
    private static final PlatformClassLoader PLATFORM_LOADER;
    private static final AppClassLoader APP_LOADER;

    // ...
    // ...
}

三个加载器之间的关系:AppClassLoader 的父加载器是 PlatformClassLoader,而 PlatformClassLoader 的父加载器是 BootClassLoader。下面是笔者写的一个单元测试,在 Java 21 中可以验证通过:

class ClassLoaderTest {

    @Test
    void should_validate_parent_class_loader() {
        ClassLoader classLoader = this.getClass().getClassLoader();
        Assertions.assertEquals("app", classLoader.getName());
        ClassLoader parentClassLoader = classLoader.getParent();
        Assertions.assertEquals("platform", parentClassLoader.getName());
        ClassLoader grandpaClassLoader = parentClassLoader.getParent();
        Assertions.assertNull(grandpaClassLoader);
    }
}

回到 ServiceLoader 类加载器的问题上,为何要限定为线程上下文加载器?这是因为某些 SPI 接口是由 Java 核心类库提供的。前面提到,核心类库基本都由 BootClassLoaderPlatformClassLoader 进行加载,但 SPI 接口的实现类一般是第三方提供的。根据 JVM 的类加载机制, BootClassLoaderPlatformClassLoader 是无法加载第三方类库的,为了打破这个限制,便使用了 AppClassLoader 。当 SPI 接口是核心类库时,AppClassLoader 会委派给父加载器去加载,而 SPI 接口实现类如果是第三方提供的,则由 AppClassLoader 自己进行加载。

下面是笔者写的一个代码示例:

class SpiClassLoaderTest {

    @Test
    void should_validate_spi_class_loader() {
        Class<java.sql.Driver> javaSqlDriverClass = java.sql.Driver.class;
        ClassLoader driverClassLoader = javaSqlDriverClass.getClassLoader();
        System.out.println("java.sql.Driver class loader is: " + driverClassLoader.getClass().getCanonicalName());

        ServiceLoader<java.sql.Driver> driverServiceLoader = ServiceLoader.load(javaSqlDriverClass);
        Assertions.assertTrue(driverServiceLoader.stream().findAny().isPresent());

        driverServiceLoader.stream().forEach(s -> {
            java.sql.Driver driver = s.get();
            printDriverInfo(driver);

            ClassLoader classLoader = driver.getClass().getClassLoader();
            System.out.println("driver class loader is: " + classLoader.getClass().getCanonicalName());
        });
    }

    private static void printDriverInfo(Driver driver) {
        System.out.println("driver: " + driver.getClass());
        System.out.println("driver.minorVersion: " + driver.getMinorVersion() +
                ", majorVersion: " + driver.getMajorVersion());
    }
}

为了能够演示,需要一个 JDBC 的驱动,为此笔者在 build.gradle 中增加了 MySQL JDBC 的依赖:

dependencies {
    // ...
    // ...

    implementation ('mysql:mysql-connector-java:8.0.33')
}

输出结果为:

java.sql.Driver class loader is: jdk.internal.loader.ClassLoaders.PlatformClassLoader
driver: class com.mysql.cj.jdbc.Driver
driver.minorVersion: 0, majorVersion: 8
driver class loader is: jdk.internal.loader.ClassLoaders.AppClassLoader

从输出的日志可以看到,SPI 接口 java.sql.Driver 是由平台类加载器进行加载的,而 MySQL Driver 则是由应用类加载器加载。既然提到了 JDBC,笔者便来梳理一下 JDBC 的演进思路。这部分内容主要来自刘欣老师的《码农翻身 (豆瓣) 》,最近他又出了《码农翻身 2》,有兴趣的小伙伴可以去瞅瞅。

JDBC 的演化

既然 mysql-connector-java 驱动实现了 JDBC 规范,并且遵循 SPI 约定,那么我们就来看看相关配置:

对应 Driver 类的代码如下,它继承自 NonRegisteringDriver

package com.mysql.cj.jdbc;

import java.sql.DriverManager;
import java.sql.SQLException;

public class Driver extends NonRegisteringDriver implements java.sql.Driver {
    public Driver() throws SQLException {
    }

    static {
        try {
            DriverManager.registerDriver(new Driver());
        } catch (SQLException var1) {
            throw new RuntimeException("Can't register driver!");
        }
    }
}
package com.mysql.cj.jdbc;

import com.mysql.cj.Constants;
//...
//...

public class NonRegisteringDriver implements Driver {
    public NonRegisteringDriver() throws SQLException {
    }

    // ...
    // ...

    public boolean acceptsURL(String url) throws SQLException {
        try {
            return ConnectionUrl.acceptsUrl(url);
        } catch (CJException var3) {
            throw SQLExceptionsMapping.translateException(var3);
        }
    }

    public Connection connect(String url, Properties info) throws SQLException {
        try {
            try {
                if (!ConnectionUrl.acceptsUrl(url)) {
                    return null;
                } else {
                    ConnectionUrl conStr = ConnectionUrl.getConnectionUrlInstance(url, info);
                    switch (conStr.getType()) {
                        case SINGLE_CONNECTION:
                            return ConnectionImpl.getInstance(conStr.getMainHost());
                        case FAILOVER_CONNECTION:
                        case FAILOVER_DNS_SRV_CONNECTION:
                            return FailoverConnectionProxy.createProxyInstance(conStr);
                        case LOADBALANCE_CONNECTION:
                        case LOADBALANCE_DNS_SRV_CONNECTION:
                            return LoadBalancedConnectionProxy.createProxyInstance(conStr);
                        case REPLICATION_CONNECTION:
                        case REPLICATION_DNS_SRV_CONNECTION:
                            return ReplicationConnectionProxy.createProxyInstance(conStr);
                        default:
                            return null;
                    }
                }
            } catch (UnsupportedConnectionStringException var5) {
                return null;
            } catch (CJException var6) {
                throw (UnableToConnectException)ExceptionFactory.createException(UnableToConnectException.class, Messages.getString("NonRegisteringDriver.17", new Object[]{var6.toString()}), var6);
            }
        } catch (CJException var7) {
            throw SQLExceptionsMapping.translateException(var7);
        }
    }

  // ...
  // ...
}

可以看到,前面提到 SPI 的三点约定,mysql-connector-java 中都是有的,只不过 MySQL Driver 通过 DriverManager.registerDriver(new Driver()); 自己主动注册驱动类,至于为什么,下文将会聊到。

如果让我们来设计 JDBC,我们会怎么做?估计很难,但基本的东西应该会有,比如:

  • Connection:连接,对接数据库的通信协议;
  • PreparedStatement:预处理语句,向数据库发送需要执行的 SQL 语句;
  • ResultSet:结果集,接收数据库执行 SQL 后返回的数据;
  • ……
// 伪代码
public class JDBCExample1 {
    public static void main(String[] args) throws Exception {
        Properties properties = new Properties();
        properties.put("host", "127.0.0.1");
        properties.put("port", "3306");
        properties.put("database", "test_db");
        properties.put("username", "root");
        properties.put("password", "123123");
        Connection connection = new MysqlConnection(properties);

        String query = "SELECT * FROM employees WHERE department = ?";
        PreparedStatement preparedStatement = connection.prepareStatement(query);
        preparedStatement.setString(1, "Engineering");

        ResultSet rs = preparedStatement.executeQuery();
        while (rs.next()) {
            String id = rs.getString("id");
            // ...
        }

        rs.close();
        preparedStatement.close();
        connection.close();
    }
}

上面的伪代码看上去还不错,第三方提供 jar 包,比如 mysql-connector-java-1.0.jar ,然后就可以跑起来了。但仔细一想就发现扩展性不足,换一个 Oracle 数据库,上面硬编码的 MysqlConnection 就得改,即使不换数据库,也不能保证第三方不会在 mysql-connector-java-2.0.jar 新版本中把 MysqlConnection 改名,比如改成 MysqlConnectionImpl

为此,我们需要继续抽象,把数据库细节屏蔽掉,于是便有了 Driver,一个简单的方式是使用简单工厂,比如下面的代码:

// 伪代码
public class Driver {
  
  public static Connection getConnection(String dbType, Properties properties) {
    if("mysql".equals(dbType)) {
       return new MysqlConnection(properties);
    }
    if("oracle".equals(dbType)) {
       return new OracleConnection(properties);
    }
    // ...
    // ...
  }
}

跟之前一版相比,已有进步,但仍然有硬编码,即 MysqlConnectionOracleConnection 等。 如果要加新的数据库,还得改 Driver 类,怎么办?我们可以将数据库类型与对应连接的实现类外挂到配置文件中,比如 connection-type.properties

mysql=com.mysql.jdbc.MysqlConnection
oracle=com.oracle.jdbc.OracleConnection
db2=com.ibm.jdbc.DB2Connection
#...
#...

接着,我们调整 Driver 类,让其从配置文件中加载:

// 伪代码
public class Driver {
  
  public static Connection getConnection(String dbType, Properties properties) {
    Class<?> clazz = getConnectionClass(dbType);
    try {
        Constructor<?> constructor = clazz.getConstructor(Properties.class);
        return (Connection)constructor.newInstance(properties);
    } catch (Exception ex) {
      //...
    }
  }

  private static Class<?> getConnectionClass(String dbType) {
    // get Class from `connection-type.properties`
    //...
  }
}

到这里,前面提到的问题都解决了,但每个使用者都得提供一个叫 connection-type.properties 的配置文件,如果写错了呢?还是不够自动和灵活。能否将创建过程扔给各个厂商,从而隐藏 Connection 实例的创建过程?答案是肯定的。为此,我们可以将 Driver 从一个实现类,变成一个接口,然后让各个厂商去实现它。于是便有了本小节开头的代码和截图,即 mysql-connector-java 的实现方式。

让各个厂商自己实现 Driver ,由每个 Driver 自己创建自己的 Connection(这种方式又叫工厂方法),即:

  • mysql Driver --> new MysqlConnection()
  • oracle Driver --> new OracleConnection()
  • ……
// 伪代码
public class XxxDriver implements java.sql.Driver {
  // ...

  @Override
  public Connection connect(String url, Properties info) throws SQLException {
    // ...
  }

  // ...
}

那我们该如何使用 Driver ?一种方式就是使用反射,比如:

// 伪代码
public class JDBCExample2 {
  
  public Connection createConnection(String url, Properties properties) {
      Class<?> clazz = Class.forName("com.mysql.jdbc.Driver");
      Driver driver = (Driver)clazz.newInstance();
      return driver.connect(url, properties);
  }
}

但使用反射,还是不够友好,如果有多个不同的 Driver 呢?更好的方式,则是提供一个全局 Driver 调度器,让各个厂商自己注册 Driver ,以便使用者可以统一管理。这个调度器就是 DriverManager

public class DriverManager {

    // List of registered JDBC drivers
    private static final CopyOnWriteArrayList<DriverInfo> registeredDrivers = new CopyOnWriteArrayList<>();

    // ...

    public static void registerDriver(java.sql.Driver driver) throws SQLException {
        registerDriver(driver, null);
    }

    public static void registerDriver(java.sql.Driver driver, DriverAction da) throws SQLException {
        /* Register the driver if it has not already been added to our list */
        if (driver != null) {
            registeredDrivers.addIfAbsent(new DriverInfo(driver, da));
        } else {
            // This is for compatibility with the original DriverManager
            throw new NullPointerException();
        }
        println("registerDriver: " + driver);
    }

    // ...

    public static Connection getConnection(String url, java.util.Properties info) throws SQLException {
        return (getConnection(url, info, Reflection.getCallerClass()));
    }

    private static Connection getConnection(String url, java.util.Properties info, Class<?> caller) throws SQLException {
      // ...
      for (DriverInfo aDriver : registeredDrivers) {
        // ...
            try {
                Connection con = aDriver.driver.connect(url, info);
                if (con != null) {
                    return con;
                }
            } catch (SQLException ex) {
                if (reason == null) {
                    reason = ex;
                }
            }
          // ...
        }
      //...
    }
}

于是,各个厂商实现的 Driver 就像下面这样了:

// 伪代码
public class XxxDriver implements java.sql.Driver {
  static {
    DriverManager.registerDriver(new XxxDriver());
  }

  // ...
  public boolean acceptsURL(String url) throws SQLException {
      return url.startWith("jdbc:Xxxdb");
  }

  @Override
  public Connection connect(String url, Properties info) throws SQLException {
    if(acceptsURL(url)) {
      return new XxxConnection(info); 
    }
    return null;
  }

  // ...
}

如此一来,使用起来就更方便了:

// 伪代码
public class JDBCExample3 {

  public Connection createConnection(String url, Properties properties) {
      Class<?> clazz = Class.forName("com.mysql.jdbc.Driver");
      return DriverManager.getConnection(url, properties);
  }
}

现在我们知道,初始化 Driver 已经不需要使用 Class.forName() ,SPI 对底层进行了封装,调用 ServiceLoader.load() 方法之后,DriverManager 就已经注册了对应的 Driver

// 伪代码
public class JDBCExample4 {

  public Connection createConnection(String url, Properties properties) {
       ServiceLoader<java.sql.Driver> driverServiceLoader = ServiceLoader.load(java.sql.Driver.class);
      return DriverManager.getConnection(url, properties);
  }
}

当然,内部实现的细节肯定比笔者这里的分析要复杂的多,但弄懂了基本原理,需要深入分析的时候也就不会觉得手足无措了。