JavaSPI详解

时间:2022-10-30 22:06:12

目录

  • 一个问题
  • 什么是SPI
  • API 与 SPI
  • 一个简单的例子
  • SPI机制的实现
  • Java SPI的问题
  • 为什么SPI机制打破了双亲委派模型
  • 参考资料

一个问题

在项目开发中,经常会使用到数据库驱动,我们连接的数据库可能是MySQL也有可能是Oracle,但是不管使用什么数据库都是引入数据库驱动配置相应的地址、用户、密码信息就可以使用而不用修改业务代码。

这是因为在JDK中提供了一个java.sql.Driver接口。各个数据库厂商只需要实现这个接口,当我们引入相应驱动,连接数据库的时候,就会使用厂商提供的实现,那么又是如何知道厂商实现的类路径的呢??

什么是SPI

SPI全名Service Provider interface,翻译过来就是“服务提供接口”,再说简单就是提供某一个服务的接口, 提供给服务开发者或者服务生产商来进行实现。
Java SPIJDK内置的一种动态加载扩展点的实现。
这个机制在一般的业务代码中很少用到(个人接触到的业务没有用到过),但是再底层框架中却被大量使用,包括JDBCDubboSpring框架、日志接口中都有用到,不同的是有的使用Java原生的实现,有的框架则自己实现了一套SPI机制

API 与 SPI

API 全称Application Programming Interface, 翻译为“应用程序接口”,指的是应用程序为外部提供服务的接口,这个接口通常由服务提供者自行开发,定义好接口后很少改动。APISPI示意图如图1,图2所示

JavaSPI详解
图1  API示意图
JavaSPI详解
图2  SPI示意图

一般应用(模块)之间通过接口进行通讯,服务提供方提供接口并进行实现后,调用方就可以通过调用这个接口拥有服务提供发提供的能力,这个就是API
当接口是由服务调用方提供,并且由服务提供方进行实现时,服务调用方就可以根据自己的需要选择特定实现,而不用更改业务代码以获取相应的功能,这个就是SPI

一个简单的例子

这个功能是向注册中心注册服务的一个示例(过于简单的示例),

首先定义一个接口Registry, 这个接口只有一个功能,就是向注册中心注册一个服务,但是我现在并不确定我选的是什么注册中心,于是提供了一个统一的接口,由各个厂商进行实现

package cn.bmilk.chat.spi;

public interface Registry {
    void  registry(String host, int port);
}

厂商实现好后,需要在其META-INF/services文件夹下新增一个文件,文件名为该接口的全限定名即:cn.bmilk.chat.spi.Registry, 内容为接口实现的全限定名,这里我写了两个

cn.bmilk.chat.spi.EurekaRegistry
cn.bmilk.chat.spi.ZookeeperRegistry

两个类的都是空实现,内容如下

    @Override
    public void registry(String host, int port) {
        System.out.println(this + "registry , host = " + host +"  port = " + port);
    }

下面编写测试主类,通过 ServiceLoader 加载 Registry 实现

public class MainTest {

    public static void main(String[] args) {

        ServiceLoader<Registry> load = ServiceLoader.load(Registry.class);
        Iterator<Registry> iterator = load.iterator();
        while (iterator.hasNext()){
            Registry registry = iterator.next();
            registry.registry("127.0.0.1", 10086);
        }
    }
}

运行结果

class cn.bmilk.chat.spi.EurekaRegistry
cn.bmilk.chat.spi.EurekaRegistry@12a3a380registry , host = 127.0.0.1  port = 10086
class cn.bmilk.chat.spi.ZookeeperRegistry
cn.bmilk.chat.spi.ZookeeperRegistry@29453f44registry , host = 127.0.0.1  port = 10086

从运行结果中可以看到EurekaRegistryZookeeperRegistry都被实例化并且生成相应的对象。但是我们全程并没有显示的加载和生成EurekaRegistryZookeeperRegistry类对象,那么是怎么来的呢?

SPI机制的实现

SPI机制的核心就是ServiceLoader类。其主要的属性如下:

    // 指出接口配置文件的位置,也就是为什么要在META-INF/services/下创建接口的全限定名文件的原因
    private static final String PREFIX = "META-INF/services/";

    // 正在被加载的类(接口)的class对象
    private final Class<S> service;

    // 加载使用的类加载器
    private final ClassLoader loader;

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

    // 缓存已经加载的实现, 按实例化顺序缓存
    private LinkedHashMap<String,S> providers = new LinkedHashMap<>();

    // The current lazy-lookup iterator
    private LazyIterator lookupIterator;

load()方法的实现如下:

    public static <S> ServiceLoader<S> load(Class<S> service) {
        ClassLoader cl = Thread.currentThread().getContextClassLoader();
        // 使用当前线程的ClassLoader进行加载待加载的实现类
        return ServiceLoader.load(service, cl);
    }

    public static <S> ServiceLoader<S> load(Class<S> service,
                                            ClassLoader loader)
    {
        // load 方法本质是创建一个ServiceLoader对象
        return new ServiceLoader<>(service, loader);
    }

    // new ServiceLoader<>(service, loader)的实现
    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();
    }

    public void reload() {
        providers.clear();
        // 根据接口类型(父类)和类加载器初始化LazyIterator
        lookupIterator = new LazyIterator(service, loader);
    }

    private LazyIterator(Class<S> service, ClassLoader loader) {
            this.service = service;
            this.loader = loader;
        }

跟踪load()方法发现其本质是创建了一个ServiceLoader对象,其共有两个参数,分别是代加载的类父类(接口)Class类对象和类加载器。在构造方法中完成了两件事,一个是变量赋值,一个是调用reload()方法。reload()方法则根据接口类型(父类)和类加载器初始化LazyIterator

当执行ServiceLoader#iterator()时,会创建java.util.Iterator匿名内部类实现:

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

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

            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();
            }

        };
    }

当执行hasNext() 方法时,会先去providers 查找已经加载的缓存实现,如果不存在,则会继续调用LazyIterator#hasNext()用于发现尚未加载的实现,最后的实现在LazyIterator#hasNextService()

LazyIterator的关键属性

// 缓存所有需要查找jar包(文件)路径,
Enumeration<URL> configs = null;
// 缓存所有被查找到的实现类全限定名
Iterator<String> pending = null;
// 迭代器使用,下一个需要被加载的类全限定名
String nextName = null;

hasNextService()实现核心如下:

// 获取所由需要扫描的包路径
configs = loader.getResources(fullName);
// 循环扫描configs中所有的包路径,解析META-INF/services中的指定文件(上例中的cn.bmilk.chat.spi.Registry文件)
// 
while ((pending == null) || !pending.hasNext()) {
    if (!configs.hasMoreElements()) {
        return false;
    }
    // pending缓存了所有查找到的类全限定名
    pending = parse(service, configs.nextElement());
}

在知道是否存在接口的实现后,就是通过next()方法获取实现,核心功能由nextService()贡献,核心实现如下:

// 获取一个实现类全限定名
String cn = nextName;
// 加载这个类
Class<?> c = Class.forName(cn, false, loader);
// 使用反射创建对象
c.newInstance()

hasNextService()完成堆配置文件的读取,nextService()完成类的加载和对象的创建,这个一切都没有在ServiceLoader创建时完成,这也是体现了延迟Lazy的一个含义

load()与loadInstalled()

loadInstalled()load()一样,本质都是创建了一个ServiceLoaderd对象,不同点是使用的加载器不同,load()使用的是Thread.currentThread().getContextClassLoader()当前线程的上下文加载器, loadInstalled()使用的是ExtClassLoader加载器来加载

具体实现如下:

    public static <S> ServiceLoader<S> loadInstalled(Class<S> service) {
        ClassLoader cl = ClassLoader.getSystemClassLoader();
        ClassLoader prev = null;
        while (cl != null) {
            prev = cl;
            cl = cl.getParent();
        }
        return ServiceLoader.load(service, prev);
    }


使用这个方法将只扫描JDK安装目录jre/lib/ext下的jar包中指定的实现,我们应用程序类路径下的实现将会被忽略掉

Java SPI的问题

  • Java SPI虽然使用了懒加载机制,但是其获取一个实现时,需要使用迭代器循环加载所有的实现类
  • 当需要某一个实现类时,需要通过循环一遍来获取

这个两个问题,在Dubbo实现自己的SPI机制时进行了增强,可以仅加载自己想要的扩展实现。

为什么SPI机制打破了双亲委派模型 ??

想不明白 说不清楚,想明白再补充

参考资料