来聊聊大厂常问的SPI工作原理

写在文章开头

你好,我叫sharkchili,目前还是在一线奋斗的Java开发,经历过很多有意思的项目,也写过很多有意思的文章,是CSDN Java领域的博客专家,也是Java Guide的维护者之一,非常欢迎你关注我的公众号:写代码的SharkChili,这里面会有笔者精心挑选的并发、JVM、MySQL数据库专栏,也有笔者日常分享的硬核技术小文。

本文会本文会以代码示例并结合源码分析的形式带读者深入剖析SPI这一概念。

什么是SPI,它有什么作用

在我们日常的web开发或者第三方服务调用时,会根据接口要求传入指定参数得到相应结果,这种为用户提供服务的服务方我们统称为API

SPI 则是另一种设计理念,它全称是Service Provide Interface ,即服务提供接口,它更强调定义一组规范,让服务提供者根据规范实现接口的个性化功能。然后服务调用方调用时通过调用接口,让提供方通过某种服务发现机制 得到接口的实现类为从而响应结果,这种解耦调用者和服务提供者的方式也正是人们常说的面向接口编程

是不是觉得很抽象呢?没有关系,本文的整体结构如下,笔者会通过一段代码示例和并基于源码剖析SPI类加载、创建、缓存 和以及调用 的过程让读者对SPI工作原理有着更加充分的认识。

SPI使用示例

可能上面说的有些抽象,我们不妨基于一个例子来了解一下SPI ,假设我们现在有这样一个需求,我们的操作系统开发了一款万能遥控器,各大厂商希望将遥控功能在这个APP 上集成,这时我们就可以基于SPI的思想对这些厂商提供一套接口规范,各大厂商只需基于这套规范进行相应的实现和配置,即可在我们的APP上操作他们的电子设备。

所以我们定义了下面这样一个接口:

arduino 复制代码
public interface Application {
    // 获取设备名称
    String getName();

    // 开关
    void turnOnOff(boolean flag);


}

所以我们对厂商提供这样一个接口规范,并将这个依赖的接口打个一个jar包,要求厂商做到以下3点:

  1. 引入我们的依赖。
  2. 实现getName返回设备名称。
  3. 实现turnOnOff,传入对应的布尔值实现电器的各种变化。
  4. 配置实现类的全路径,确保后续APP集成时可以加载到该实现类并完成调用。

我们以一个电灯的厂商的角度来完成这个集成工作,首先自然是将接口规范的依赖引入:

xml 复制代码
   <dependencies>
        <dependency>
            <groupId>com.sharkchili</groupId>
            <artifactId>application</artifactId>
            <version>1.0-SNAPSHOT</version>
        </dependency>
    </dependencies>

然后完成对应的接口实现类:

typescript 复制代码
public class Light implements Application {
    public String getName() {
        return "电灯";
    }

    public void turnOnOff(boolean b) {
        if (b) {
            System.out.println("打开电灯");
            return;
        }

        System.out.println("关闭电灯");

    }
}

最后一步就是配置,我们在这个maven 项目中的resources 目录下的一个文件夹创建一个名为META-INF.services 的文件夹中,创建一个我们接口全路径的文件com.sharkchili.Application内容为类的全路径,并在其内部指明实现类的全路径

复制代码
com.sharkchili.Light

配置示例如下图所示,完成这些步骤后,我们将依赖打包:

最后我们的应用只需引入这接口和实现类的两个依赖便可开始进行调用:

xml 复制代码
<dependencies>
        <dependency>
            <groupId>com.sharkchili</groupId>
            <artifactId>light</artifactId>
            <version>1.0-SNAPSHOT</version>
        </dependency>

        <dependency>
            <groupId>com.sharkchili</groupId>
            <artifactId>application</artifactId>
            <version>1.0-SNAPSHOT</version>
        </dependency>
    </dependencies>

如下所示,我们的调用示例代码如下,我们通过ServiceLoaderload 方法加载当前项目中所有的Application 实现类,然后遍历实现类完成turnOnOff的调用:

arduino 复制代码
 public static void main(String[] args) {
        ServiceLoader<Application> load = ServiceLoader.load(Application.class);

        for (Application application : load) {
            application.turnOnOff(true);
        }
    }

对应输出结果如下,可以看到它成功发现了电灯厂商的实现类,并完成对turnOnOff的调用:

复制代码
打开电灯

SPI工作原理

我们从这段代码入手,ServiceLoader 的load方法在内部会获取当前线程的类加载器,然后创建一个LazyIterator 的迭代器,然后在上文示例代码中的迭代步骤时,将Application的实现类加载到缓存中并完成返回给用户进行调用:

ini 复制代码
ServiceLoader<Application> load = ServiceLoader.load(Application.class);

步入代码,可以看到它取得当前线程的类加载器后调用的ServiceLoader 的内部的load方法。

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

我们跳过繁琐的步骤,这个私有的load 方法最终会调用reload 方法,它首先会清空providers ,然后创建一个LazyIterator用于后续来加载Application实现类。

typescript 复制代码
private LinkedHashMap<String,S> providers = new LinkedHashMap<>();

public void reload() {
        providers.clear();
        //基于类加载器和Application.class生成一个来加载迭代器
        lookupIterator = new LazyIterator(service, loader);
    }

随后我们的代码进入for循环进行迭代,而循环步骤就会走到LazyIteratorhasNext 方法,因为我们此时的providers在load方法是被清空了,所以对应的entrySet 引用对象knownProviders 自然也是空的,于是调用lookupIterator.hasNext()查看是否有可用的Class 并通过反射创建然后存入providers中。

typescript 复制代码
 Iterator<Map.Entry<String,S>> knownProviders
                = providers.entrySet().iterator();

public boolean hasNext() {
                if (knownProviders.hasNext())
                    return true;
                return lookupIterator.hasNext();
            }

重点来了,上述方法通过PREFIX + service.getName()获取文件即resource 下以Application 实现全路径命名的文件,然后解析这个文件生成所有的类名,并将这些类名存入pending 这个迭代器中,最后让nextName 指针指向第一个类名,并返回true

ini 复制代码
private boolean hasNextService() {
            if (nextName != null) {
                return true;
            }
            if (configs == null) {
                try {
                //基于fullName 获取com.sharkchili.Application文件下所有类名
                    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);
                }
            }
            //解将配置文件中的类名存入pending 中
            while ((pending == null) || !pending.hasNext()) {
                if (!configs.hasMoreElements()) {
                    return false;
                }
                pending = parse(service, configs.nextElement());
            }
            //nextName 指向pending迭代器中的第一个元素
            nextName = pending.next();
            return true;
        }

通过上述步骤完成类名加载后返回true,所以我们的for循环继续了方法调用**lookupIterator.next();**方法。

ruby 复制代码
public S next() {
                if (knownProviders.hasNext())
                    return knownProviders.next().getValue();
                return lookupIterator.next();
            }

最终来到的核心步骤,nextService 通过hasNext拿到的nextName 反射生成电灯类并以全路径类名为key,反射生成的对象为value 存入providers中再返回出去,实现类的复用。

typescript 复制代码
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 {
            //反射生成类并存入providers缓存中
                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
        }

SPI在实际场景的运用

SPI 最典型的运用就是日志框架Slf4J 了,可以看到其内部也是通过services 指定指定的加载类完成插槽式接入各种日志框架,这也就是为什么我们的Spring Boot 项目引入Slf4jlog4j 之后,调用Slf4J 的接口方法依然可以通过log4j完成日志打印的原因所在。

小结

我是sharkchiliCSDN Java 领域博客专家开源项目---JavaGuide contributor ,我想写一些有意思的东西,希望对你有帮助,如果你想实时收到我写的硬核的文章也欢迎你关注我的公众号: 写代码的SharkChili ,同时我的公众号也有我精心整理的并发编程JVMMySQL数据库个人专栏导航。

参考资料

美团:SPI 的原理是什么? :mp.weixin.qq.com/s/AA9rugKgE...

相关推荐
wuyikeer4 分钟前
Spring Framework 中文官方文档
java·后端·spring
Victor3569 分钟前
MongoDB(61)如何避免大文档带来的性能问题?
后端
Victor35618 分钟前
MongoDB(62)如何避免锁定问题?
后端
wuyikeer1 小时前
Spring BOOT 启动参数
java·spring boot·后端
子木HAPPY阳VIP2 小时前
Ubuntu 22.04 VMware 设置固定IP配置
人工智能·后端·目标检测·机器学习·目标跟踪
人间打气筒(Ada)2 小时前
如何基于 Go-kit 开发 Web 应用:从接口层到业务层再到数据层
开发语言·后端·golang
开心就好20252 小时前
使用Wireshark进行TCP数据包抓包分析:三次握手与四次挥手详解
后端·ios
用户4419395054872 小时前
OpenClaw服务器部署保姆级教程
后端
zdl6862 小时前
springboot集成onlyoffice(部署+开发)
java·spring boot·后端
Soofjan2 小时前
sync.Mutex讲解
后端