Blog · Loji44AboutTAGSRSS🔍SEARCH

1. SPI机制介绍与使用

Java SPI是Java用于构建具备可扩展性的应用的一种机制。所谓的具备可扩展性,就是可以通过某种方式让使用者在不更改这个应用源代码的基础上去在这个应用上添加新的功能特性。

先看看三个概念:

  • Service:就是具备可扩展性的应用服务,可扩展的对外接口就是由它定义;
  • Service provider interface (SPI):Service定义的一组开放的接口,称为SPI接口;
  • Service Provider:服务提供者,负责实现SPI接口,提供具体的实现。例如生产商。

Service定义SPI接口并以jar包的方式对外发布。若第三方(Service Provider)想在Service应用上实现自己想要的特性,那Service Provider就可以引入这个SPI jar包,并提供自己的实现。

spi

上图就是SPI机制的运作流程。接下来介绍Service如何发现并加载Service Provider提供的SPI接口的实现。

假设有一个Service:打印机服务应用,对用户提供打印服务。这个打印机服务应用很强大,可以做到适配任何厂家的打印设备,因为这个打印服务应用在设计的时候考虑到了可扩展性。

这个打印服务应用定义的一个标准SPI接口:com.loji44.spi.Printer,这个接口会以jar包的方式对外发布。

package com.loji44.spi;

public interface Printer {
    String print(String text);
}

然后有一个打印机设备厂商佳能引入这个jar包依赖,并提供自己的实现:

package com.canon.printer;
import com.loji44.spi.Printer;

public class CanonPrinter implements Printer {
    @Override
    public String print(String text) {
        System.out.println("佳能打印机为您服务:" + text);
        return "佳能打印机为您服务:" + text;
    }
}

做好实现后,根据SPI机制的规范,佳能厂商需要在自己源代码的src/main/resources/META-INF/services目录下新建一个名为SPI接口全限定名的文本文件,里面的内容为自己实现类的全限定名,如下图所示:

spi-canon-provider

接下来,打印服务应用(Service)引入佳能厂商提供的实现jar包(例如通过Maven引入):

<dependency>
    <groupId>com.canon</groupId>
    <artifactId>canon-printer</artifactId>
    <version>1.0.0</version>
</dependency>

引入了佳能厂商提供的实现jar包依赖之后,就可以加载并使用佳能打印设备提供的打印服务了:

import java.util.ServiceLoader;
import com.loji44.spi.Printer;

// 我是打印机服务应用,这里是启动打印服务的入口
public class PrinterLauncher {
    public static void main(String[] args) {
        ServiceLoader<Printer> serviceLoader = ServiceLoader.load(Printer.class);
        serviceLoader.forEach(printer -> {
            printer.print("我要打印很多钱");
        });
    }
}

// 运行结果
佳能打印机为您服务我要打印很多钱

我们在没有修改打印机服务应用(PrinterLauncher)的源代码的情况下,通过SPI扩展机制成功使用了佳能的打印设备!

同样的,如果有另一家打印机设备厂商(例如惠普)以同样的做法提供了自己的实现,那么打印机服务应用(PrinterLauncher)同样只需要引入惠普厂商提供的实现jar包依赖,就可以使用其提供的打印服务。这就是我们说的功能扩展了。

2. SPI发现与加载的奥秘

在上一节中,我们通过以下几行代码就可以发现、加载并使用服务提供商提供的SPI实现:

ServiceLoader<Printer> serviceLoader = ServiceLoader.load(Printer.class);
serviceLoader.forEach(printer -> {
    printer.print("我要打印很多钱");
});

没错,Java SPI服务提供商提供的实现是通过java.util.ServiceLoader这个类来完成查找、加载并使用的。我们来看看它是如何查找并加载SPI实现类的:

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

ClassLoader cl = Thread.currentThread().getContextClassLoader() 这行代码是获取当前线程的线程上下文类加载器,用来加载厂商提供的实现类(类加载肯定是由类加载器来完成)。

这里为什么要使用线程上下文类加载器,而不是使用其他类加载器,例如启动类加载器(Bootstrap Classloader)或者扩展类加载器(Extension Classloader)

首先,我们要明确:

  • 启动类加载器(Bootstrap Classloader)只会加载JAVA_HOME/lib目录下的Java基础类库,而且只会加载名字符合的类库,例如rt.jar。对于加载范围和类库名称有着严格的要求,所以就算你把自己的jar包放入JAVA_HOME/lib下面,也不会被启动类加载器加载;
  • 扩展类加载器(Extension Classloader)也一样,它只会加载JAVA_HOME/lib/ext目录下的类库;
  • 应用类加载器(Application Classloader)用来加载用户Classpath下面的类库,即加载我们开发中引入的各种第三方类库。

我们知道,线程上下文类加载器是跟线程绑定的,创建线程时,我们可以为该线程设置一个类加载器(即线程上下文类加载器)。如果不设置,线程会自动继承父线程的上下文类加载器,而Java应用运行的初始线程绑定的上下文类加载器就是系统类加载器(Application Classloader)。

java.util.ServiceLoaderrt.jar类库里面的一个类,肯定是由Bootstrap Classloader加载的。厂商的类其实是放在用户Classpath下面的,让ServiceLoader去加载厂商的类,如果不使用线程上下文类加载器,那么JVM会默认使用加载java.util.ServiceLoader类的类加载器,即Bootstrap Classloader去加载厂商的类。Bootstrap Classloader是不可能认识厂商类的,因为它只认识JAVA_HOME/lib目录下的类库,那我要加载厂商的类怎么办?

答案就是Bootstrap Classloader委托应用类加载器(Application Classloader)去加载厂商的类,因为Application Classloader就是专门用来加载用户Classpath的类库的。这里其实破坏了JVM类加载的「双亲委派」模型,即父类加载器委托子类加载器去加载一个类。

JVM类加载的「双亲委派」加载模型规定:当JVM收到一个类加载请求,它不会直接加载这个类,而是委托它的父类加载器去加载,每一层都是这样,逐层往上传递请求,直到顶层类加载器Bootstrap Classloader。如果父类加载器加载不到,子类加载器才会自己执行加载动作。

说了这么多,总结起来就一点:Bootstrap Classloader不认识用户Classpath下的类,那么它就通过线程上下文类加载器来做一些「变通」,因为线程上下文类加载器在没有人为设置过的时候默认就是Application Classloader,这样我就可以加载到厂商的类了。

可能有人又要问:那为什么java.util.ServiceLoader不直接指定Application Classloader去加载厂商的类,而非要通过线程上下文类加载器去转一道?
代码直接写死指定使用Application Classloader也可以,但是丧失了灵活性。作为基础通用的框架,你要留给用户一个选择的余地,因为通过线程上下文类加载器,用户是可以设置这个线程使用其他类加载器的,例如用户的程序里面自定义了其他类加载器。

好了,关于加载厂商类的类加载器相关内容就说到这里。继续我们的 ServiceLoader.load,其实最终就是new一个ServiceLoader的实例:

public static <S> ServiceLoader<S> load(Class<S> service, ClassLoader loader) {
    return new ServiceLoader<>(service, loader);
}

来看看ServiceLoader的私有构造器:

public final class ServiceLoader<S> implements Iterable<S> {
    // 将要被加载的SPI接口类的Class对象,例如 Printer.class
    private final Class<S> service;
    // 用于加载SPI服务提供商提供的实现类的类加载器
    private final ClassLoader loader;
    // SPI服务提供商提供的实现类的实例的缓存:加载到并实例化后,缓存起来
    private LinkedHashMap<String,S> providers = new LinkedHashMap<>();
    // ServiceLoader自己实现的一个懒加载的迭代器:这个是重点
    private LazyIterator lookupIterator;
        
    // 私有构造器    
    private ServiceLoader(Class<S> svc, ClassLoader cl) {
        service = Objects.requireNonNull(svc, "Service interface cannot be null");
        loader = (cl == null) ? ClassLoader.getSystemClassLoader() : cl;
        acc = (System.getSecurityManager() != null) ? AccessController.getContext() : null;
        reload();
    }
}

ServiceLoader私有构造器里面前面三行代码主要做一些初始化检查工作,重点在于reload()方法:

public void reload() {
    providers.clear();
    lookupIterator = new LazyIterator(service, loader);
}
  • 先清理之前加载过的实现类,即每次调用ServiceLoader.load的时候,都会保证清理之前加载的实现类缓存;
  • 初始化一个懒加载的迭代器。

到这里ServiceLoader.load其实就走完了,可以看到ServiceLoader.load其实不是真正去查找、加载厂商的实现类,而是做一些初始化的工作,其中最重要的就是初始化一个LazyIterator。

那厂商的实现类在什么时候进行查找、加载呢?

答案是在使用的时候才进行实现类的加载:

ServiceLoader<Printer> serviceLoader = ServiceLoader.load(Printer.class);
serviceLoader.forEach(printer -> {
    printer.print("我要打印很多钱");
});

刚才说了,调用ServiceLoader.load的时候会初始化一个LazyIterator,当我们使用serviceLoader.forEach去遍历厂商实现类的时候,其实就是查找并加载厂商的实现类。

LazyIterator是ServiceLoader的一个内部类,它实现了Iterator接口,用于迭代加载实现类。先来看LazyIterator的nextService方法:

private S nextService() {
    if (!hasNextService())
        throw new NoSuchElementException();
    
    String cn = nextName;
    nextName = null;
    Class<?> c = null;

    c = Class.forName(cn, false, loader);
    // 此处省略不重要的代码 ...
    S p = service.cast(c.newInstance());
    providers.put(cn, p);
    // 此处省略不重要的代码 ...
    return p;
}

nextService方法做了四件事情:

  • 调用hasNextService方法来判断是不是还有可迭代的实现类。这里注意,如果是首次调用hasNextService方法,hasNextService会去查找META-INF/services下面以SPI接口全限定名命名的文本文件并逐行读取文件内容,放入一个叫pending的缓存变量。后面每次迭代调用hasNextService时,都直接从缓存中获取下一行的内容放入nextName变量返回;
  • nextName就是实现类的全限定名,nextService方法中调用Class.forName来加载此实现类,传入的loader就是我们之前说的线程上下文类加载器(默认为Application Classloader);
  • 如果成功加载,则通过反射创建该实现类的实例;
  • 将该实现类的实例缓存到providers这个Map变量中,后续再迭代,就会直接从缓存中获取,不会再创建实例了。

hasNextService方法其实就是会在首次调用的时候去读取以SPI接口全限定名命名的文本文件,读取文件中的每一行内容,因为每一行就代表一个实现类(厂商可以提供多个实现类),并将每一个实现类的全限定名保存到一个pending变量中。

private class LazyIterator implements Iterator<S> {

    Class<S> service;
    ClassLoader loader;
    Enumeration<URL> configs = null;
    Iterator<String> pending = null;
    String nextName = null;
    
    private boolean hasNextService() {
        if (configs == null) {
            try {
                // 这里的PREFIX就是 "META-INF/services/"
                // fullName在这里就是 "META-INF/services/com.loji44.spi.Printer"
                String fullName = PREFIX + service.getName();
                if (loader == null)
                    configs = ClassLoader.getSystemResources(fullName);
                else
                    // 读取文件资源
                    // 这就是为什么要求提供实现的厂商在 "src/main/resources/META-INF/services" 下创建以SPI接口全限定名为文件名的文本文件
                    configs = loader.getResources(fullName);
            } catch (IOException x) {
                fail(service, "Error locating configuration files", x);
            }
        }
        while ((pending == null) || !pending.hasNext()) {
            if (!configs.hasMoreElements()) {
                return false;
            }
            // 逐行解析 com.loji44.spi.Printer 文件内容
            pending = parse(service, configs.nextElement());
        }
        // 文件中每一行内容就是一个实现类的全限定名
        nextName = pending.next();
        return true;
    }
}

总结一下:

  • ServiceLoader.load只是初始化了加载实现类所需要的一些参数;
  • 当使用serviceLoader.forEach去迭代实现类的实例的时候,才会真正触发厂商实现类的查找、加载。

3. SPI机制的应用

Java SPI在实际中有很多应用,例如我们提到的打印机的例子。

还有一些更常用的例如JDK的数据库驱动管理。JDK里面定义了一个标准的数据库驱动的接口java.sql.Driver让各个数据库厂商(例如MySQL、PostgreSQL)去提供自己的实现。各个数据库厂商只需要遵循java.sql.Driver接口的规范来开发自己的驱动,然后用户在使用的时候只需要引入各个数据库厂商的jar包依赖即可使用对应的数据库。

最后,JDK还提供了一个类来加载、管理各个厂商的数据库驱动:java.sql.DriverManager,而DriverManager里面正是使用SPI的ServiceLoader来完成驱动的加载的。

package java.sql.DriverManager;

public class DriverManager {
    // ... ...
    
    static {
        // DriverManager在类加载阶段就会去加载并初始化数据库驱动
        loadInitialDrivers();
        println("JDBC DriverManager initialized");
    }
    
    // ... ... 
    
    private static void loadInitialDrivers() {
        // ... ...
        
        // 如果Driver是以SPI的Service Provider方式提供,就使用ServiceLoader来加载Driver
        AccessController.doPrivileged(new PrivilegedAction<Void>() {
            public Void run() {
                ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class);
                Iterator<Driver> driversIterator = loadedDrivers.iterator();
                try{
                    // 执行迭代:真正触发驱动类的查找和加载、实例化
                    while(driversIterator.hasNext()) {
                        driversIterator.next();
                    }
                } catch(Throwable t) {
                // Do nothing
                }
                return null;
            }
        });

       // ... ...
    }
}

再看看MySQL提供的实现类的jar包里面:没错,就是标准的SPI方式提供的实现了。

mysql-driver

4. 写在最后

  • ServiceLoader实例是线程不安全的,多个线程同时迭代查找、加载实现类的时候,里面并没有任何同步措施;
  • SPI虽说使用的是懒加载迭代器,但是在执行迭代的时候,不管用没用到对应的实现,SPI都会全部加载、创建实例,而不能根据我们所需指定加载某个实现类。