锦囊篇|Java中的SPI机制

2,443 阅读7分钟

一起用Gradle Transform API + ASM完成代码织入呀~这篇文章中我曾经提及关于SPI的方案,这篇文章针对的内容有三点:为什么当初要选择SPI,他的实现流程是什么样的,以及它存在什么样的问题。

什么是SPI

Service Provider Interface翻译成中文就是服务提供接口,简称SPI,它是JDK内置的一种机制,用途就是本地服务发现和提供。

用一个简单的案例来说明上面的图:

今天是星期六没得上班,也就意味着小易同学得在家里把吃饭(调用方) 的问题解决了,那这个时候小易疯狂转动大脑想该吃啥(标准服务接口),摆在小易面前有两个选择:外卖、楼下的饭店(服务提供方)。最后小易同学选择吃了楼下便宜又方便的大排面,毕竟贫穷限制了选择空间。

Java中通过基于接口的编程+策略模式+配置文件来实现SPI这一套机制。

另外这里需要提及的内容有一点就是设计模式之六大原则中的接口隔离,一般我们是不会在一个接口类型中定义过多的方法,这也是为了保障更改后最小化的影响。

关于接口隔离等设计模式的内容,详见于设计模式的十八般武艺

如何使用

// 1
public interface Robot {
    void sayHello();
}
// 2
public class OptimusPrime implements Robot {
    @Override
    public void sayHello() {
        System.out.println("Hello, I am Optimus Prime.");
    }
}
// 3
public class Bumblebee implements Robot {
    @Override
    public void sayHello() {
        System.out.println("Hello, I am Bumblebee.");
    }
}

先定义上述的三个类,23分别接入了接口1并完成具体的实现。然后你要在资源目录下创建一个文件,当然他存在固定的创建方式,创建META-INF/services/文件,文件名同样存在创建的要求包名.接口类名 如果你的module没有接入它时,其实会出现这样的结果。 而如果存在时,你就可以加入你的实现,而这个文件中加入的类将成为之后去用于发现服务的基础。

这里我们使用系统提供的ServiceLoader来完成服务的发现,ServiceLoader<Robot> services = ServiceLoader.load(Robot.class);。使用ServiceLoader.iterator()可以直接用迭代器的方式来完成数据的遍历,如果出现了上述具体类中的打印数据说明获取成功了。

源码分析

整个ServiceLoader的代码数量级其实也才587行。还是以前的分析法,我们从调用点开始

public static <S> ServiceLoader<S> load(Class<S> service) {
        ClassLoader cl = Thread.currentThread().getContextClassLoader(); //如果我们不提供类加载器,他会提供,同样的这个加载器你可以进行自定义
        return ServiceLoader.load(service, cl); // 1 -->
    }
    
// 1-->
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(); // 2 -->
    }
    
// 2 -->
public void reload() {
        providers.clear();
        lookupIterator = new LazyIterator(service, loader);
    }

一整条主线下来其实就是初始化了几个变量,所以这里需要先清楚以下需要实例化都有谁。

1. Class<S> service; // 那些会被加载进来的类或接口
2. ClassLoader loader; // 用于定位,加载和实例化providers的类加载器,之后会在创建service时使用
3. AccessControlContext acc; // 创建ServiceLoader时采用的访问控制上下文
4. LinkedHashMap<String,S> providers // 缓存providers,按实例化的顺序排列
5. LazyIterator lookupIterator; // 懒查找迭代器

读到这里是不是会有一定的质疑,既然有文件写入,一定会有一个文件读出的位置。但是从整条脉络下来根本没有任何跟文件读取相关的内容,但是根据网上各种的用法来说这样肯定是没有问题,但是为什么就没找到呢?

private static final String PREFIX = "META-INF/services/";

这里我们只好用他已经定义好的静态变量来进行寻找了,

private class LazyIterator implements Iterator<S> {
		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;
        }
}

通过寻找我们能够发现他被藏在了一个叫做LazyIterator类的hashNextService() 方法中,这里需要先做一下回忆,是不是在那里见过这个LazyIterator,如果没有印象的读者往前翻到reload()方法中,里面有这样的一段代码lookupIterator = new LazyIterator(service, loader);也就是说其实最后的内容会被保存在这个变量中,几个重要的目标点找到了,那我们就找一下这个方法的整个调用链是什么样的。

hasNextService() <-- hasNext() <-- ServiceLoader.hasNext()
				   |-- nextService() <-- next() <-- ServiceLoader.next()

所以其实你在调用ServiceLodaer自定义的迭代器的时候就可以去发现藏在项目的各种服务。但是为什么我们都会说ServiceLodaer是一个消耗性能的方案呢,我们用一段代码作证。

Class.forName(cn, false, loader);

这段代码存在于调用链nextService()中,而这个方法想来读者都比较熟悉了,就是反射了,就是它的一个消耗性能但是百试不爽的方案。

场景分析

这里将拿我之前的碰见的情况来做一个分析,这里需要做一个道歉,我这几天想这个主题最后发现其实是我当时想差了的问题,其实是可以不存在数据前置获取的问题的。当时的代码情况是这样的:

下面用一串伪代码表示,因为当时使用是公司内部封装过的ServiceLoader,如果找不到方法时会多做一层兜底,用代理生成一个对象。

main {
	if (X) { A = B }
    if (A) { do() }
}

ok~,大概就是上述的情况,两个存在相关性的判断语句,与真实情况大致相仿。这个X的数据来源源于我们的网络,但是我作为线下工具,我希望能够获得一个全量的数据,那我势必是需要突破这个判断的。

那这个时候我就想说有兜底,ok!那我就用这个代理兜底来加入这一层的判断,那我们不是ok了?代码就变成了这样。

main {
	if (X or ServiceLoader.load(M).class != Proxy.class) { A = B }
    if (A) { do() }
}

只要加入这样的一句话,我们似乎就可以非常轻松的闯过前置的判断,do()这个函数也能非常顺利的执行,这样看来这个方案唯一最大的缺点就是性能损耗了。那如果我说我的代码里面有十来需要这样的去突破的口子的呢?你还会觉得这是一个好的方案吗?

另外这里还需要从几个维度出发考虑:

  1. 改动成本,既然是要一个api层,那肯定要有impl层,而上述这个需要兜底的库肯定也要接入api,那如果未来我的api改变,那兜底的库和impl层都同时需要改变,改动成本相对较大。

  2. 控制粒度 / 现存的服务数量控制,如果后来有人接手了这个项目,他同样继承了我的这个接口,然后注册为成为了一个服务,而恰好这个服务是要作为线上服务存在的,但是他不知道我在这里会被使用来进行判断,那最后导致的结果就是上线以后,被我的这个判断影响,导致本应该存量上报的数据,最后全量上报,致使流量剧烈波动最后导致的结果是小,但是对于用户体验而言我们那里面带有的操作可能会出现一个全局性的影响。

通过以上几点分析以后,我们最后才把方案选择为了插桩。但是SPI他真的毫无用处吗?结合我们上述存在的问题,先从改动成本说,如果api是一个基本可以说一尘不变的接口,那实现他的服务其实很自然的就能够避免这件事情,而我们的注意力就可以很自然的聚焦在实现上。 那一般什么样的场景下我们才会使用SPI机制呢?(这里倾向个人理解)

  • 解耦
  • 根据实际需求 diy 接入实现

DubboJDBC等库都是对SPI机制的最佳实践。

总结

缺点:

  1. 这来自于ServiceLoader本身,即便使用了懒加载,但还是遍历获取,最差的结果就是导致所有的具体实现全部被实例化一次。但这样的情况对于可能只需要特定的你而言是一种资源浪费,如果接入了过多的实现,那这个问题就会被无限放大。

  2. 多线程使用ServiceLoader类的线程不安全问题。

参考文档

  1. 高级开发必须理解的Java中SPI机制:www.jianshu.com/p/46b42f7f5…