Java-深入理解ServiceLoader类与SPI机制

2020-02-17 23:41:50 浏览数 (1)

Java-ServiceLoader类与SPI机制

引子

对于Java中的Service类和SPI机制的透彻理解,也算是对Java类加载模型的掌握的不错的一个反映。

了解一个不太熟悉的类,那么从使用案例出发,读懂源代码以及代码内部执行逻辑是一个不错的学习方式。


一、使用案例

通常情况下,使用ServiceLoader来实现SPI机制。 SPI 全称为 (Service Provider Interface) ,是JDK内置的一种服务提供发现机制。SPI是一种动态替换发现的机制, 比如有个接口,想运行时动态的给它添加实现,你只需要添加一个实现。

SPI机制可以归纳为如下的图:

起始这样说起来还是比较抽象,那么下面举一个具体的例子,案例为JDBC的调用例子:

案例如下:

JDBC中的接口即为:java.sql.Driver

SPI机制的实现核心类为:java.util.ServiceLoader

Provider则为:com.mysql.jdbc.Driver

外层调用则是我们进行增删改查JDBC操作所在的代码块,但是对于那些现在还没有学过JDBC的小伙伴来说(不难学~),这可能会有点难理理解,所以我这里就举一个使用案例:

按照上图的SPI执行逻辑,我们需要写一个接口、至少一个接口的实现类、以及外层调用的测试类。

但是要求以这样的目录书结构来定义项目文件,否则SPI机制无法实现(类加载机制相关,之后会讲):

代码语言:javascript复制
E:.
│  MyTest.java
│
├─com
│  └─fisherman
│      └─spi
│          │  HelloInterface.java
│          │
│          └─impl
│                  HelloJava.java
│                  HelloWorld.java
│
└─META-INF
    └─services
            com.fisherman.spi.HelloInterface

其中:

  1. MyTest.java为测试java文件,负责外层调用;
  2. HelloInterface.java为接口文件,等待其他类将其实现;
  3. HelloJava.java 以及 HelloWorld.java 为接口的实现类;
  4. META-INF └─services com.fisherman.spi.HelloInterface 为配置文件,负责类加载过程中的路径值。

首先给出接口的逻辑:

代码语言:javascript复制
public interface HelloInterface {
    void sayHello();
}

其次,两个实现类的代码:

代码语言:javascript复制
public class HelloJava implements HelloInterface {
    @Override
    public void sayHello() {
        System.out.println("HelloJava.");
    }
}
代码语言:javascript复制
public class HelloWorld implements HelloInterface {
    @Override
    public void sayHello() {
        System.out.println("HelloWorld.");
    }
}

然后,配置文件:com.fisherman.spi.HelloInterface

代码语言:javascript复制
com.fisherman.spi.impl.HelloWorld
com.fisherman.spi.impl.HelloJava

最后测试文件:

代码语言:javascript复制
public class MyTest26 {

    public static void main(String[] args) {

        ServiceLoader<HelloInterface> loaders = ServiceLoader.load(HelloInterface.class);
        
        for (HelloInterface in : loaders) {
            in.sayHello();
        }
        
    }

}

测试文件运行后的控制台输出:

代码语言:javascript复制
HelloWorld.
HelloJava.

我们从控制台的打印信息可知我们成功地实现了SPI机制,通过 ServiceLoader 类实现了等待实现的接口和实现其接口的类之间的联系。

下面我们来深入探讨以下,SPI机制的内部实现逻辑。


二、ServiceLoader类的内部实现逻辑

Service类的构造方法是私有的,所以我们只能通过掉用静态方法的方式来返回一个ServiceLoader的实例:

方法的参数为被实现结构的Class对象。

代码语言:javascript复制
ServiceLoader<HelloInterface> loaders = ServiceLoader.load(HelloInterface.class); 

其内部实现逻辑如所示,不妨按调用步骤来分步讲述:

1.上述load方法的源代码:

代码语言:javascript复制
public static <S> ServiceLoader<S> load(Class<S> service) {
    ClassLoader cl = Thread.currentThread().getContextClassLoader();
    return ServiceLoader.load(service, cl);
}

完成的工作:

  1. 得到当前线程的上下文加载器,用于后续加载实现了接口的类
  2. 调用另一个load方法的重载版本(多了一个类加载器的引用参数)

2.被调用的另一个load重载方法的源代码:

代码语言:javascript复制
    public static <S> ServiceLoader<S> load(Class<S> service,
                                            ClassLoader loader)
    {
        return new ServiceLoader<>(service, loader);
    }

完成的工作:

  • 调用了类ServiceLoader的私有构造器

3.私有构造器的源代码:

代码语言:javascript复制
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();
}

完成的工作:

  1. 空指针和安全性的一些判断以及处理;
  2. 并对两个重要要的私有实例变量进行了赋值: private final Class<S> service; private final ClassLoader loader;
  3. reload()方法来迭代器的清空并重新赋值

SercviceLoader的初始化跑完如上代码就结束了。但是实际上联系待实现接口和实现接口的类之间的关系并不只是在构造ServiceLoader类的过程中完成的,而是在迭代器的方法hasNext()中实现的。

这个联系通过动态调用的方式实现,其代码分析就见下一节吧:


三、动态调用的实现

在使用案例中写的forEach语句内部逻辑就是迭代器,迭代器的重要方法就是hasNext()

ServiceLoader是一个实现了接口Iterable接口的类。

hasNext()方法的源代码:

代码语言:javascript复制
public boolean hasNext() {
    if (acc == null) {
        return hasNextService();
    } else {
        PrivilegedAction<Boolean> action = new PrivilegedAction<Boolean>() {
            public Boolean run() { return hasNextService(); }
        };
        return AccessController.doPrivileged(action, acc);
    }
}

抛出复杂的确保安全的操作,可以将上述代码看作就是调用了方法:hasNextService.

hasNextService()方法的源代码:

代码语言:javascript复制
private boolean hasNextService() {
    if (nextName != null) {
        return true;
    }
    if (configs == null) {
        try {
            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;
}

上述代码中比较重要的代码块是:

代码语言:javascript复制
String fullName = PREFIX   service.getName();
            if (loader == null)
                configs = ClassLoader.getSystemResources(fullName);

此处PREFIX(前缀)是一个常量字符串(用于规定配置文件放置的目录,使用相对路径,说明其上层目录为以项目名为名的文件夹):

代码语言:javascript复制
private static final String PREFIX = "META-INF/services/";

那么fullName会被赋值为:"META-INF/services/com.fisherman.spi.HelloInterface"

然后调用方法getSystemResourcesgetResources将fullName参数视作为URL,返回配置文件的URL集合 。

代码语言:javascript复制
pending = parse(service, configs.nextElement());

parse方法是凭借 参数1:接口的Class对象 和 参数2:配置文件的URL来解析配置文件,返回值是含有配置文件里面的内容,也就是实现类的全名(包名 类名)字符串的迭代器;

最后调用下面的代码,得到下面要加载的类的完成类路径字符串,相对路径。在使用案例中,此值就可以为:

com.fisherman.spi.impl.HelloWorldcom.fisherman.spi.impl.HelloJava

代码语言:javascript复制
nextName = pending.next();

这仅仅是迭代器判断是否还有下一个迭代元素的方法,而获取每轮迭代元素的方法为:nextService()方法。

nextService()方法源码:

代码语言:javascript复制
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 subtype");
    }
    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
}

抛出一些负责安全以及处理异常的代码,核心代码为:

1.得到接口实现类的完整类路径字符串:

代码语言:javascript复制
String cn = nextName;

2使用loader引用的类加载器来加载cn指向的接口实现类,并返回其Class对象(但是不初始化此类):

代码语言:javascript复制
c = Class.forName(cn, false, loader);

3.调用Class对象的newInstance()方法来调用无参构造方法,返回Provider实例:

代码语言:javascript复制
S p = service.cast(c.newInstance());
代码语言:javascript复制
//cast方法只是在null和类型检测通过的情况下进行了简单的强制类型转换
public T cast(Object obj) {
    if (obj != null && !isInstance(obj))
        throw new ClassCastException(cannotCastMsg(obj));
    return (T) obj;
}

4.将Provider实例放置于providers指向的HashMap中:

代码语言:javascript复制
providers.put(cn, p);

5.返回provider实例:

代码语言:javascript复制
return p;

ServiceLoader类的小总结:

  1. 利用创建ServiceLoader类的线程对象得到上下文类加载器,然后将此加载器用于加载provider类;
  2. 利用反射机制来得到provider的类对象,再通过类对象的newInstance方法得到provider的实例;
  3. ServiceLoader负责provider类加载的过程数据类的动态加载;
  4. provider类的相对路径保存于配置文件中,需要完整的包名,如:com.fisherman.spi.impl.HelloWorld

四、总结与评价

  1. SPI的理念:通过动态加载机制实现面向接口编程,提高了框架和底层实现的分离;
  2. ServiceLoader 类提供的 SPI 实现方法只能通过遍历迭代的方法实现获得Provider的实例对象,如果要注册了多个接口的实现类,那么显得效率不高;
  3. 虽然通过静态方法返回,但是每一次Service.load方法的调用都会产生一个ServiceLoader实例,不属于单例设计模式;
  4. ServiceLoader与ClassLoader是类似的,都可以负责一定的类加载工作,但是前者只是单纯地加载特定的类,即要求实现了Service接口的特定实现类;而后者几乎是可以加载所有Java类;
  5. 对于SPi机制的理解有两个要点:
    1. 理解动态加载的过程,知道配置文件是如何被利用,最终找到相关路径下的类文件,并加载的;
    2. 理解 SPI 的设计模式:接口框架 和底层实现代码分离
  6. 之所以将ServiceLoader类内部的迭代器对象称为LazyInterator,是因为在ServiceLoader对象创建完毕时,迭代器内部并没有相关元素引用,只有真正迭代的时候,才会去解析、加载、最终返回相关类(迭代的元素);

五、相关引用

ServiceLoader使用及原理分析

Create Extensible Applications using Java ServiceLoader

0 人点赞