接着,SPI 机制源码探究

2023年 9月 27日 58.5k 0

作者:三哥,j3code.cn

个人项目:社交支付项目(小老板)

内网穿透部署地址:admire.j3code.cn/small-boss/

1、简介

SPI 全称 Service Provider Interface ,翻译过来就是:“服务提供者的接口”。咋一看,不太理解对吧!没关系,我用我的理解解释一遍:“向外提供功能的一个接口”。

他的本质还是围绕 接口 展开,让使用者只需要关注接口就行,不用关注这个接口的具体实现细节,并且能够不修改代码的情况下切换接口实现细节。

通常情况下我们进行接口编程的时候,都是:接口A a = new 接口A的实现类B,这种方式来调用具体的功能实现。但是如果 A 接口有很多个实现,我们在某种情况下需要更换 A 的实现,此时是不是就需要改代码来实现,而为了解决这种现象,SPI 就出现了。它提供了一个机制,能够通过某种方法拿到接口的所有实现类,这样就能避免修改上面说的情况。

2、案例

下面我来写个案例,让你感受一下使用 SPI 机制,实现不改变代码更换功能。

1)创建一个纯 maven 的项目

2)创建如下模块

模块介绍:

  • api 模块就是一个功能的抽象,它里面定义了各种接口
  • consumer 毫无疑问就是对生产者的一个消费,他需要那个生产者就引入那个生产者模块,我提供了两个生产者,one 和 two
  • one 和 two 是 api 模块中接口的两种不同的实现
  • 模块的依赖关系:

    • 生产者模块依赖 api 模块,因为他要实现 api 接口,提供功能
    • 消费者模块按需引入生产者模块,因为他要消费生产者提供的功能,当然消费者也可以引入所有的生产者依赖。

    3)api 模块中定义接口:

    4)两个生产者的接口实现

    • one

    • two

    5)消费者测试

    1、我们先来测试没有 SPI 机制的时候,要获取博主的 CSDN 身份该如何实现:

    先消费者引入 CSDN 依赖,如图:

    编写代码:

    可以看到功能正常执行,那如果这个功能运行一段时间之后,我们需要更换一下这个博主的身份,变为掘金则该如何修改呢!

    首先修改依赖,然后修改代码,将 CsdnJ3code 类换为 JueJinJ3code 类即可,修改如下:

    2、SPI 机制实现上述功能

    首先,如果我们需要在 one 和 two 生产者模块的 resources 资源目录加上如下配置:

    one

    two

    解释一下就是:在资源目录下面新建 META-INF/services 目录,并且创建一个以接口全路径类名为名称的文件,文件内容就写上该接口的实现类的权限定类名。

    注:这是 SPI 的规定(文件名是接口全限定接口名,内容是接口实现全限定类名)

    接着改造 Demo 类,如下:

    public class Demo {
        public static void main(String[] args) {
            // 加载 J3code 接口
            ServiceLoader j3codes = ServiceLoader.load(J3code.class);
            Iterator iterator = j3codes.iterator();
            // 循环获取 J3code 接口的实现
            while (iterator.hasNext()) {
                J3code j3code = iterator.next();
                // 调用 J3code 接口的获取身份方法
                j3code.standing();
            }
        }
    }
    

    最后,如果需要获取 CSDN 身份就只要加入 CSDN 依赖即可;如果需要获取掘金身份,同理也是加入对应的依赖就可以,这样我们就实现了,不修改代码,从而达到需要的效果。

    上述两种方式的图示:

    通过案例 + 图,相信你已经知道了 SPI 是个啥了(是不是有点类似 IOC 容器),下面咱们再深入一点,看看其内部实现的原理是啥!

    3、原理

    在分析原理之前,咱们考虑下面这些问题:

  • 为什么必须在 META-INF/services 目录下文件,其他目录是否也可以
  • 为什么文件名必须是接口的全限定接口名,其他的名字是否也可以
  • 文件内容为什么需要填写接口的具体实现类
  • SPI 是如何发现接口的实现类,并创建对象提供功能的
  • ok,带着这些问题,我们开始分析源码(JDK11)。

    先来看看 ServiceLoader.load(J3code.class) 干了啥:

    @CallerSensitive
    public static  ServiceLoader load(Class service) {
        // 获取当前线程的加载器,也即应用类加载器
        ClassLoader cl = Thread.currentThread().getContextClassLoader();
        // 创建 ServiceLoader 对象,传入反射对象、加载器和业务接口
        return new ServiceLoader(Reflection.getCallerClass(), service, cl);
    }
    
    private ServiceLoader(Class caller, Class svc, ClassLoader cl) {
        Objects.requireNonNull(svc);
    
        if (VM.isBooted()) {
            checkCaller(caller, svc);
            if (cl == null) {
                cl = ClassLoader.getSystemClassLoader();
            }
        } else {
    
            // if we get here then it means that ServiceLoader is being used
            // before the VM initialization has completed. At this point then
            // only code in the java.base should be executing.
            Module callerModule = caller.getModule();
            Module base = Object.class.getModule();
            Module svcModule = svc.getModule();
            if (callerModule != base || svcModule != base) {
                fail(svc, "not accessible to " + callerModule + " during VM init");
            }
    
            // restricted to boot loader during startup
            cl = null;
        }
        // 上门是判断 vm 是否正常启动
    
        // 这里就是对象属性赋值
    
        
        // 正在加载的服务的类或接口
        this.service = svc;
        // 服务类或者接口的全限定名称
        this.serviceName = svc.getName();
        // 默认 null
        this.layer = null;
        // 类加载器。
        this.loader = cl;
        // 上下文,默认 null
        this.acc = (System.getSecurityManager() != null)
                ? AccessController.getContext()
                : null;
    }
    

    可以看到通过 ServiceLoader.load(J3code.class) 来加载 J3code 接口,实际上就是创建了一个 ServiceLoader 对象并做了一些属性赋值工作。所以,没找到问题的关键,继续往下分析源码。

    j3codes.iterator() 源码如下:

    public Iterator iterator() {
    
        // create lookup iterator if needed
        if (lookupIterator1 == null) {
            // 迭代器为空,调用 newLookupIterator 方法创建一个
            lookupIterator1 = newLookupIterator();
        }
        // 返回一个匿名 Iterator 对象,以后所有通过迭代器调用 hasNext 和 next 方法都会走到下面
        // 两个方法中
        // 但是,其底层的调用还是以来上面创建的 lookupIterator1 对象提供的两个方法实现
        // 也即,这里只是包装了一下,底层干活的并不是这个匿名内部类
        return new Iterator() {
    
            // record reload count
            // 将加载器的 reloadCount 付给 expectedReloadCount
            final int expectedReloadCount = ServiceLoader.this.reloadCount;
    
            // index into the cached providers list
            int index;
    
            /**
             * Throws ConcurrentModificationException if the list of cached
             * providers has been cleared by reload.
             */
            private void checkReloadCount() {
                // 加载器的 加载次数 和 创建迭代器是赋值的 expectedReloadCount 属性是否一致
                if (ServiceLoader.this.reloadCount != expectedReloadCount)
                    // 不一致,那就是加载器被重新加载过,也即一边在遍历,一边再用 reload() 方法清空
                    // 并发修改异常
                    throw new ConcurrentModificationException();
            }
    
            @Override
            public boolean hasNext() {
                // 检查 reload 方法是否被调用(遍历的时候不允许调用这个方法)
                checkReloadCount();
                // 已遍历数量 小于 已经实例化的服务提供者集合数量,那就表明还有数据,直接返回 true
                if (index < instantiatedProviders.size())
                    return true;
                // 否则,调用迭代器,进行判断是否还有服务提供者
                return lookupIterator1.hasNext();
            }
    
            @Override
            public S next() {
                // 检查 reload 方法是否被调用(遍历的时候不允许调用这个方法)
                checkReloadCount();
                // 定义服务提供者对象 next
                S next;
                if (index < instantiatedProviders.size()) {
                    // 当前遍历下标小于已经实例化服务提供着集合大小,直接根据下标获取
                    next = instantiatedProviders.get(index);
                } else {
                    // 否则,调用迭代器,进行获取
                    next = lookupIterator1.next().get();
                    // 获取出来的服务提供者放进 集合 中缓存起来
                    instantiatedProviders.add(next);
                }
                // 遍历下标加一
                index++;
                // 返回结果
                return next;
            }
    
        };
    }
    

    这个方法逻辑很清晰,如果迭代器为空则调用 newLookupIterator() 方法创建一个,紧接着创建一个匿名内部类返回出去。该匿名内部类对外提供的功能底层都是调用 newLookupIterator() 方法创建的迭代器来实现的,所以下面我们需要来分析他。

    匿名内部类 next 方法说明:

    第一次获取数据的时候,集合中是没有数据的,所以会通过 lookupIterator1 迭代器的 next 方法寻找获取服务提供者并缓存起来,然后直接将结果返回出去。可以看出,对于服务提供者的获取,并不是一次性将所有的都加载出来,而是通过迭代器遍历一个,加载一个。

    newLookupIterator() 源码:

    private Iterator newLookupIterator() {
        // layer 和 loader 不能同时为空
        assert layer == null || loader == null;
        // 我们 debug 发现,layer 为空,loader 不为空,所以直接走下面 else 逻辑
        
        if (layer != null) {
            return new LayerLookupIterator();
        } else {
            // ModuleServicesLookupIterator 不看,他是和 JDK9 出来的模块化有关,咱们这里没有涉及
            // 所以直接看下面的类
            Iterator first = new ModuleServicesLookupIterator();
            // LazyClassPathLookupIterator 类就是我们需要关注的了,他构造器是个空
            Iterator second = new LazyClassPathLookupIterator();
            // 创建匿名内部类并返回出去
            // 以后通过迭代器调用 hasNext 和 next 方法,最终都会来到下面这两个方法中
            return new Iterator() {
                // 上面说了,我们这里不是模块化的东西,所以下面的方法每次调用 first 对象的方法
                // 时都是 false。即,真正起作用的是:second 
                
                @Override
                public boolean hasNext() {
                    // 下一个是否有只,最终会调用 second.hasNext() 方法
                    return (first.hasNext() || second.hasNext());
                }
                @Override
                public Provider next() { // 获取下一个元素
                    if (first.hasNext()) {
                        return first.next();
                    } else if (second.hasNext()) {
                        // 最终会调用这个方法
                        return second.next();
                    } else {
                        throw new NoSuchElementException();
                    }
                }
            };
        }
    }
    

    上述方法会创建两个的迭代器对象 ModuleServicesLookupIterator 和 LazyClassPathLookupIterator,而我注释中提过 ModuleServicesLookupIterator 对象是和 JDK9 出来的模块化内容有关不在本次讨论范围内,也即 first.hasNext() 结果为 false。所以上述方法只有 second 对象会生效,即 second.hasNext() 和 second.next() 两个方法。

    这样分析下来,该方法返回的匿名对象,如果被调用了 hasNext 方法,其底层生效的就是 second.hasNext() 方法。如果被调用的是 next() 方法,其底层生效的就是 second.next() 方法。好,那么下面就来看看这两个方法源码:

    LazyClassPathLookupIterator 类的构造器为空,所以没什么好分析的

    second.hasNext() 源码:

    public boolean hasNext() {
        // 在 ServiceLoader 初始化的时候 System.getSecurityManager() 
        // 返回的是 null,所以 acc 的值为 null
        if (acc == null) {
            // 所以直接走这个方法
            return hasNextService();
        } else {
            PrivilegedAction action = new PrivilegedAction() {
                public Boolean run() { return hasNextService(); }
            };
            return AccessController.doPrivileged(action, acc);
        }
    }
    

    通过 debug 和代码分析 acc 就是为 null,所以我们只需要分析 hasNextService 方法,源码如下:

    private boolean hasNextService() {
        // 默认情况下 nextProvider 和 nextError 都为 null,进入 循环
        while (nextProvider == null && nextError == null) {
            try {
                // 通过 nextProviderClass 方法获取服务提供者的 class 对象
                Class clazz = nextProviderClass();
                if (clazz == null)
                    // 为 null 表明没有,直接返回 false 结束循环
                    return false;
                // class 为模块化则忽略
                if (clazz.getModule().isNamed()) {
                    // ignore class if in named module
                    continue;
                }
                // 判断获得的 class 与指定的 service 是有关系,
                // 也即 class 对象是否是 service 的实现类
                if (service.isAssignableFrom(clazz)) {
                    // 将获取的 class 向上转型
                    Class nextProviderClass() {
        if (configs == null) {
            try {
                // 拼接文件路径
                /*
                PREFIX = "META-INF/services/"
                service.getName() = 接口的全限定名称
                所以 fullName = META-INF/services/cn.j3code.api.J3code
                */
                String fullName = PREFIX + service.getName();
                // 没有指定加载器,用系统加载器加载 fullName
                if (loader == null) {
                    configs = ClassLoader.getSystemResources(fullName);
    
                // 指定加载器不为空,看看是否等于 BootLoader 加载器,是则用这个加载器加载
                } else if (loader == ClassLoaders.platformClassLoader()) {
                    // The platform classloader doesn't have a class path,
                    // but the boot loader might.
                    if (BootLoader.hasClassPath()) {
                        configs = BootLoader.findResources(fullName);
                    } else {
                        configs = Collections.emptyEnumeration();
                    }
                } else {
                    // 如果都不是,则使用指定类加载器来加载
                    configs = loader.getResources(fullName);
                }
            } catch (IOException x) {
                // 加载出错
                fail(service, "Error locating configuration files", x);
            }
        }
        // 第一次加载,pending 肯定是 null,进入 while 循环调用 parse 方法解析文件
        // 第二次 pending 肯定不为 null,则判断 pending.hasNext() ,是否还有值,有则不进入
        // 没有,则进入 while 循环判断 configs.hasMoreElements() 是否还有下一个 jar 报存在 SPI
        // 配置,有则获取下一个配置,调用 parse 方法解析。所以此方法就是获取项目中的所有 jar 包中的
        // spi 配置进行解析,一个 jar 包解析完了,解析下一个,直至全部解析/
        while ((pending == null) || !pending.hasNext()) {
            if (!configs.hasMoreElements()) {
                return null;
            }
            // 解析配置文件内容
            pending = parse(configs.nextElement());
        }
        // 获取解析内容的值
        String cn = pending.next();
        try {
            // 通过 Class.forName 进行类加载
            return Class.forName(cn, false, loader);
        } catch (ClassNotFoundException x) {
            fail(service, "Provider " + cn + " not found");
            return null;
        }
    }
    

    通过我上面的代码注释,可知这个方法会先拼接一个加载路径即:META-INF/services/ + 接口全限定名称。接着会通过加载器(系统、BootLoader、指定加载器)来加载项目中所有 jar 和模块下对应路径的 资源,然后 while 循环进行 parse 解析,最后从解析后的值中获取我们想要的服务提供者并通过 Class.forName 对服务提供者进行类加载。

    到这里我们就可以开始回答探究源码前提出的几个问题了:

    问题一:为什么必须在 META-INF/services 目录下文件,其他目录是否也可以

    答:这个路径是代码写死,规定了就是从这个资源目录中获取对应的配置资源

    问题二:为什么文件名必须是接口的全限定接口名,其他的名字是否也可以

    答:注意这段代码 fullName = PREFIX + service.getName(),他去找你的服务提供者是根据写死的文件路径 + 你传入的接口权限的名称去找到,如果你写的文件名不是接口全限定名称是不是就永远也找不到你配置的资源了。

    问题三:文件内容为什么需要填写接口的具体实现类

    答:注意看这段代码 service.isAssignableFrom(clazz),如果配置文件中配置的实现类和接口没有关系,这里就为 false ,代码也随之报错,所以文件配置的类一定要是接口的实现类

    问题三:SPI 是如何发现接口的实现类,并创建对象提供功能的

    答:通过固定文件路径 + 接口名称去找到项目中所有配置的资源,然后通过解析,一个个的将配置的内容获取出来,最后通过反射(Class.forName) 将对象创建出来。

    通过上面的 hasNextService 代码分析我们已经知道了,他其实把我们需要的服务提供者找出来了并封装到了 nextProvider 属性中。而紧接着的 next() 方法,就肯定是从这个属性中获取需要的服务提供者了,继续看源码验证。

    second.next() 源码:

    public Provider next() {
        // acc 为 null
        if (acc == null) {
            // 直接走这个方法
            return nextService();
        } else {
            PrivilegedAction action = new PrivilegedAction() {
                public Provider run() { return nextService(); }
            };
            return AccessController.doPrivileged(action, acc);
        }
    }
    

    这里也是一样,我们只需要分析 nextService 方法,源码如下:

    private Provider nextService() {
        // 再次调用 hasNextService 方法,判断是否可以从 nextProvider 中获取到值
        // 这里我们可以知道,如果没有并发,也即没有其他线程干扰,调用了 hasNext 方法后(为 true),
        // next 就肯定是能获取到值得,所以这里的 hasNextService 方法会返回 true(直接不走里面的 while 循环,因为 nextProvider 有值)
        if (!hasNextService())
            throw new NoSuchElementException();
        // 定义中间变量
        Provider provider = nextProvider;
        if (provider != null) {
            // 不为 null,则将 nextProvider 置空,便于下次 hasNext 方法判断并解析值
            nextProvider = null;
            // 直接返回出去(封装的服务提供者对象)
            return provider;
        } else {
            // 到这里就是说明出错了,需要抛出错误,这也是证明了 hasNext 方法出错了不会立即抛出,
            // 而是在 next 方法的 这个地方抛出
            ServiceConfigurationError e = nextError;
            assert e != null;
            nextError = null;
            throw e;
        }
    }
    

    这个 nextService 方法比较简单,先再次调用 hasNextService 方法判断是否有值,有则继续往下执行判断 nextProvider 属性是否有值,有则返回 nextProvider 的值并将其置空(方便下次循环赋值);反之没有那就是出错了,直接抛出。

    注意:Provider 接口中有个 get 方法,他会通过反射的形式创建我们的具体服务提供者对象

    public S get() {
        if (factoryMethod != null) {
            return invokeFactoryMethod();
        } else {
            // 反射创建对象
            return newInstance();
        }
    }
    
    // get 方法会在 java.util.ServiceLoader#iterator 中的 next() 方法中调用
    

    通过 next 方法我们已经获取到了对应接口的服务提供者对象,所以到此,我们已经分析完了 SPI 的主要源码了(细枝末节没有说的那么清)。

    那最后咱们针对这个源码,对 SPI 做个优缺点总结:

    优点

    • 解耦,将业务代码与服务提供代码解耦
    • 只要服务提供者的接口一样,不用修改业务代码,就可以直接根据不同情况更换具体服务提供者逻辑

    不足

    • 虽然 SPI 加载服务服务提供者是通过懒加载(只有调用 hashNext 才回去加载解析服务提供者),所以在使用的时候不能一下子找到我们需要的服务提供者,需要不同的循环判断,最坏的结果就是所有提供者都遍历实例化出来之后,才找到我们需要的那个。
    • 并发情况下 ServiceLoader 类不安全

    4、应用

    对于 SPI 的应用我相信大家肯定能一下子就说出两个:

  • SpringBoot
  • 数据库驱动
  • 当然还有日志框架、Dubbo 等。下面我就来说我比较熟悉的前两个吧!

    1)SpringBoot

    在 SpringBoot 项目中引入下面依赖:

    
      org.springframework.boot
      spring-boot-starter-web
    
    

    就能使 web 功能生效,这个原因以前我也和大家分析过也出个过视频(大家有时间可以去看看):www.bilibili.com/video/BV1R8…

    这里,我就贴张图,大家应该就能明白(SPI 的应用):

    2)数据库驱动

    数据库驱动是一个规范,他的具体实现会落在如 MySQL、Oracle 等具体数据库厂商中,而我们只需要引入对应的依赖,数据库驱动就能自动帮我们找到对应的实现,这里就是 SPI 的应用了。

    MySQL

    Oracle

    ok,到此我们的 SPI 内容就告一段落了,希望通过这边文章,你们能对 SPI 机制有个清晰的认识。

    相关文章

    JavaScript2024新功能:Object.groupBy、正则表达式v标志
    PHP trim 函数对多字节字符的使用和限制
    新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
    使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
    为React 19做准备:WordPress 6.6用户指南
    如何删除WordPress中的所有评论

    发布评论