• 从实现到原理,聊聊Java中的SPI动态扩展


    原创:微信公众号 码农参上,欢迎分享,转载请保留出处。

    八股文背多了,相信大家都听说过一个词,SPI扩展

    有的面试官就很喜欢问这个问题,SpringBoot的自动装配是如何实现的?

    基本上,你一说是基于spring的SPI扩展机制,再把spring.factories文件和EnableAutoConfiguration提一下,那么这个问题就答的八九不离十了。

    就像四五年前,我去面试的时候被问到这个问题,SPI动态扩展机制这几个词从嘴里一说出来,就把面试官唬的一愣一愣的。可能他们也没见过这么能装逼的,一句话能简简单单说明白,非要拽一个听上去很高大上的词。

    话说回来,被唬住的可不止是面试官,其实还有我自己。至于SPI扩展究竟是个啥,是怎么实现的,我当时也根本不明白。

    不过现在的面试就是这样,对线八股文,要想唬住面试官,就得先唬住自己。

    那么我们今天暂且不提spring的SPI扩展,先来看看java本身自带的SPI扩展机制是怎么一回事。

    1、简介

    SPI的全称是Service Provider Interface,翻译过来就是服务提供者的接口,它所实现的其实是一种服务的发现机制。

    这么说起来可能还是有点不好理解,我举个例子来类比一下。

    在spring项目中,写service层代码前,会约定俗成的会添加一个接口层。然后通过spring中的依赖注入,可以借助@Autowired等方式注入这个接口的实现类的实例对象,之后对于service的调用一般也基于接口操作。

    简单形容就是这样的:

    如图所示,接口、实现类都是由服务提供方提供,我们可以把controller看作服务调用者,调用方只管调用接口就可以了。

    虽然也有声音认为,大部分情况下service只有一个实现类,接口层显得有些多余。但是在《Head First Design Patterns》这本书中,大佬们还是建议过:

    Program to an interface, not an implementation.

    没错,就是常说的要面向接口编程。至于好处,也不外乎是降低耦合度、方便日后扩展、提高了代码的灵活性和可维护性等等。

    在上面这个例子里,这个接口层和其中的方法我们可以称之为API,而我们要讨论的SPI和它相比,有类似也有差异,还是先看图:

    简单来说,就是服务的调用方定义一个接口规范,可以由不同的服务提供者实现。并且,调用方能够通过某种机制来发现服务提供方,并通过接口调用它的能力。

    通过对比,我们可以看出它们虽然都有着接口这一层面,但还是有很大的不同:

    API中的接口是服务提供者给服务调用者的一个功能列表,而SPI中更多强调的是,服务调用者对服务实现的一种约束,服务提供者根据这种约束实现的服务,可以被服务调用者发现。

    说白了,Java中的SPI实现的就是,你按我的接口规范实现服务,我就能通过某种机制为这个接口寻找到这个服务。

    这么说起来可能还有些抽象,下面我们举一个例子,类比具体描述一下这个过程。

    2、定义接口

    说起智能家居系统,大家现在都比较熟悉了,只要是相同品牌下的产品,连上wifi就能够通过手机app控制了,非常方便。

    虽然产品不断更新换代,型号更新层出不穷,但是同种家电在app上操作起来,功能一般都是一样的。就拿空调来说,我们在app上操作起来一般也就三个主要功能:开关选模式调节温度

    假设我现在在客厅、卧室、书房安装了3款不同型号的空调,并把它们都接入到了我app中,那么之后的操作都是相同的几个按键,简单粗暴。

    思考一下,无论是开关还是调温,都是通过app去调用设备的接口罢了,那么如果不同型号的空调各写各的接口,后端app在开发的时候光对接接口都麻烦的要死。

    解决方法也很简单,我先定义一套接口规范,不管你以后什么型号的空调,都按我的规范来实现接口。以后只要我能发现你的设备,那么都可以按相同的方法来调用接口。

    那么下面就先来定义这么一套接口规范,如果你以后想要接入智能家居系统,那么就要遵循这个规范来开发接口。

    新建一个项目作为标准,就叫aircondition-standard好了,然后创建一个接口。除了3个操作以外,我们再添加一个获取空调型号的方法。

    public interface IAircondition {
        // 获取型号
        String getType();
        
        // 开关
        void turnOnOff();
    
        // 调节温度
        void adjustTemperature(int temperature);
    
        // 模式变更
        void changeModel(int modelId);
    }
    

    这个接口后面要给服务的实现方来使用,用maven把它打成jar包:

    mvn clean install
    

    之后服务提供者在项目中就可以引入这个jar包了,有了这套规范,就保证了产品后期不管怎么更新换代,都能接入到系统来。

    3、服务实现

    制定并发布完规则后,挂式空调作为第一个服务提供者就来了,新建一个项目aircondition-hanging-type,并引入刚才打好的jar包:

    <dependency>
        <groupId>com.cn.hydragroupId>
        <artifactId>aircondition-standardartifactId>
        <version>1.0-SNAPSHOTversion>
    dependency>
    

    创建服务类,并实现前面定义的接口:

    public class HangingTypeAircondition
            implements IAircondition{
        public String getType() {
            return "HangingType";
        }
        
        public void turnOnOff() {
            System.out.println("挂式空调开关");
        }
    
        public void adjustTemperature(int i) {
            System.out.println("挂式空调调节温度");
        }
    
        public void changeModel(int i) {
            System.out.println("挂式空调更换模式");
        }
    }
    

    在项目的resources的目录下,创建META-INF/services目录,然后以前面定义的接口名com.cn.hydra.IAircondition创建文件,并在文件中写入实现类的全限定名。

    com.cn.hydra.HangingTypeAircondition
    

    整个项目结构非常简单:

    这样,一个服务方的简单实现就搞定了,用maven打成jar包,之后就可以提供给调用方使用了。

    同理,我们可以再创建一个立式空调的项目aircondition-vertical-type,也只创建一个服务类:

    public class VerticalTypeAircondition
            implements IAircondition{
        public String getType() {
            return "VerticalType";
        }
        
        public void turnOnOff() {
            System.out.println("立式空调开关");
        }
    
        public void adjustTemperature(int i) {
            System.out.println("立式空调调节温度");
        }
    
        public void changeModel(int i) {
            System.out.println("立式空调更换模式");
        }
    }
    

    还是按上面的命名规则,创建一个配置文件:

    com.cn.hydra.VerticalTypeAircondition
    

    同样,打成jar包就完事了,至于服务调用者如何去发现和调用这两个服务,下面详细再说。

    4、服务发现

    现在两个服务提供方都实现了接口,下面关键的一步就是服务发现,这一步java中的spi发现机制已经帮我们实现好了。

    创建一个新项目aircondition-app,引入上面打好的两个jar包。

    <dependencies>
        <dependency>
            <groupId>com.cn.hydragroupId>
            <artifactId>aircondition-hanging-typeartifactId>
            <version>1.0-SNAPSHOTversion>
        dependency>
    
        <dependency>
            <groupId>com.cn.hydragroupId>
            <artifactId>aircondition-vertical-typeartifactId>
            <version>1.0-SNAPSHOTversion>
        dependency>
    dependencies>
    

    按照上面的说法,虽然每个服务提供者对于接口都有不同的实现,但是作为调用者来说,它并不需要关心具体的实现类,我们要做的是通过接口来调用服务提供者实现的方法。

    下面,就是关键的服务发现环节,我们写一个方法,根据型号去调用对应空调的开关方法。

    public class AirconditionApp {
        public static void main(String[] args) {
            new AirconditionApp().turnOn("VerticalType");
        }
    
        public void turnOn(String type){
            ServiceLoader load = ServiceLoader
                    .load(IAircondition.class);
    
            for (IAircondition iAircondition : load) {
                System.out.println("检测到:"+iAircondition.getClass().getSimpleName());
                if (type.equals(iAircondition.getType())){
                    iAircondition.turnOnOff();
                }
            }
        }
    }
    

    测试结果:

    可以看到,测试过程中,通过定义的接口IAircondition发现了两个实现类,并通过参数,调用了特定实现类的某个方法。整段代码中没有出现过具体的服务实现类,操作都是通过接口调用。

    5、原理

    了解了spi的工作流程,我们再来看看它的实现,其实最关键的就是上面代码中出现的ServiceLoader这个类。

    上面的示例代码中,对于ServiceLoaderload()方法的结果,我们用for循环进行了遍历,这一点我们看一下源码就能明白,因为ServiceLoader实现了Iterable这一接口,而整个服务发现的核心,就在它的iterator()方法中。

    注意这里面有两个关键的东西,找一下在源码中定义的地方:

    注释写的非常明白,providers就是一个缓存,在迭代器中如果先从这里面进行查找,如果里面有就继续往下找,没有了的话就用这个懒加载的lookupIterator查找。

    那么就简单了,接着往下看LazyIterator,看看它里面的hasNext()next()两个方法是怎么实现的。

    这个acc是一个安全管理器,在前面通过System.getSecurityManager()判断并赋值,debug看一下这里都是null,所以直接看hasNextService()nextService()方法就可以了。

    hasNextService()方法中,会取出接口取出实现类的类名放到nextName中:

    接下来,在nextService()方法中,则会先加载这个实现类,然后实例化对象,最终放入缓存中去。

    在迭代器的迭代过程中,会完成所有实现类的实例化,其实归根结底,还是基于java反射去实现的。

    6、应用

    要说spi的实际应用,大家最常见的应该就是日志框架slf4j了,它利用spi实现了插槽式接入其他具体的日志框架。

    说白了,slf4j本身就是个日志门面,并不提供具体的实现,需要绑定其他具体实现才能真正的引入日志功能。

    例如我们可使用log4j2作为具体的绑定器,只需要在pom中引入slf4j-log4j12,就可以使用具体功能。

    <dependency>
        <groupId>org.slf4jgroupId>
        <artifactId>slf4j-apiartifactId>
        <version>2.0.3version>
    dependency>
    <dependency>
        <groupId>org.slf4jgroupId>
        <artifactId>slf4j-log4j12artifactId>
        <version>2.0.3version>
    dependency>
    

    引入项目后,点开它的jar包看一下具体结构:

    有没有发现一个彩蛋,先说为什么我们pom中引入的明明是slf4j-log4j12,实际上引入的是slf4j-reload4j?翻一下官网的文档:

    大意就是在2015年和2022年,log4j1.x就已经宣布end of life终止了,原因也不难猜,估计是因为频繁爆出的漏洞。在那之后,slf4j-log4j在构建阶段就会自动重定向到slf4j-reload4j了,并且官方也强烈建议使用slf4j-reload4j作为替代。

    再回头看一下jar包的META-INF.services里面,通过spi注入了Reload4jServiceProvider这个实现类,它实现了SLF4JServiceProvider这一接口,在它的初始化方法initialize()中,会完成初始化等工作,后续可以继续获取到LoggerFactoryLogger等具体日志对象。

    7、总结

    Java中的SPI提供了一种比较特别的服务发现和调用机制,通过接口灵活的将服务调用与服务提供者分离,用于提供给第三方实现扩展时还是很方便的。但是也有缺点,比方说一旦加载一个接口,就会把所有实现类都加载进来,可能会加载到不需要的冗余服务。不过站在整体角度上,还是给我们提供了一种非常不错的框架扩展、集成的思路。

    那么,这次的分享就到这里,我是Hydra,我们下篇再见。

    作者简介,码农参上,一个热爱分享的公众号,有趣、深入、直接,与你聊聊技术,关注领取大量学习资料。
    也欢迎添加我好友,多多交流。

  • 相关阅读:
    iMazing 3.0.0.3 for mac 中文破解版2024最新图文安装教程
    【前端技巧】css篇
    Linux升级gcc到最新版本gcc-11.2.0
    Kubernetes config 配置访问多集群
    Layui + Flask | 表单组件(组件篇)(07)
    在VS Code 中调试远程服务器的PHP代码
    北理工团队在不同注意力状态下肢体运动意图的鲁棒神经解码方面取得重要研究进展...
    迎接金九银十的狂风暴雨,最强Java面经八股文,跳槽必备。
    141、★并查集-LeetCode-冗余连接Ⅰ和Ⅱ
    R语言使用mlr包创建随机森林分类模型、网格搜索、交叉验证获取随机森林的最佳超参数组合(random forest classification)
  • 原文地址:https://www.cnblogs.com/trunks2008/p/17126410.html