暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

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

写在文章开头

Hi,我是 sharkChili ,是个不断在硬核技术上作死的 java coder ,是 CSDN的博客专家 ,也是开源项目 Java Guide 的维护者之一,熟悉 Java 也会一点 Go ,偶尔也会在 C源码 边缘徘徊。写过很多有意思的技术博客,也还在研究并输出技术的路上,希望我的文章对你有帮助,非常欢迎你关注我的公众号:写代码的SharkChili

因为近期收到很多读者的私信,所以也专门创建了一个交流群,感兴趣的读者可以通过上方的公众号获取笔者的联系方式完成好友添加,点击备注  “加群”  即可和笔者和笔者的朋友们进行深入交流。


什么是SPI,它有什么作用

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

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

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

SPI使用示例

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

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

public interface Application {
    // 获取设备名称
    String getName();

    // 开关
    void turnOnOff(boolean flag);


}

复制

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

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

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

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

复制

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

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

复制

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

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

<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的调用:

 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的实现类加载到缓存中并完成返回给用户进行调用:

ServiceLoader<Application> load = ServiceLoader.load(Application.class);

复制

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

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

复制

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


private LinkedHashMap<String,S> providers = new LinkedHashMap<>();

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


复制

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

//providers在调用load方法时就被清空了
 Iterator<Map.Entry<String,S>> knownProviders
                = providers.entrySet().iterator();

//查看是否有可用的Class
public boolean hasNext() {
                if (knownProviders.hasNext())
                    return true;
                return lookupIterator.hasNext();
            }

复制

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

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();
的不断迭代尝试获取示例,可以看到netxt
方法通过lookupIterator.next()
获取实例。

public S next() {
                if (knownProviders.hasNext())
                    return knownProviders.next().getValue();
                return lookupIterator.next();
            }

复制

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

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 的原理是什么?:https://mp.weixin.qq.com/s/AA9rugKgEOMZ5O8dgXSmsw


文章转载自写代码的SharkChili,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论