Stereotypes。相信大家都听过一个词,SPI扩展。有的面试官喜欢问这个问题,SpringBoot的自动组装是怎么实现的?基本上,一旦你说它是基于spring的SPI扩展机制,再提到spring.factories文件和EnableAutoConfiguration,那么这个问题的答案就差不多了。就像四五年前,我去面试的时候被问到这个问题。当SPI动态扩展机制这几个字从我嘴里说出来的时候,面试官愣了一下。也许他们从来没有见过这么自命不凡的人,谁能简单的一句话就解释清楚,硬要扯一个听起来高大上的词。话虽如此,被唬住的不只是面试官,还有我自己。至于SPI扩展是什么,是怎么实现的,当时完全不懂。但是现在的面试是这样的。如果你想吓唬面试官,你必须先吓唬自己。那么今天先不说spring的SPI扩展,先来看看java本身自带的SPI扩展机制。1.简介SPI的全称是ServiceProviderInterface,翻译过来就是服务提供者的接口。它实现的其实是一种服务发现机制。可能还是有点难以理解,我举个例子打个比方。在spring项目中,在写服务层代码之前,习惯上要加一个接口层。然后在spring中通过依赖注入,可以通过@Autowired等方式注入这个接口的实现类的实例对象,之后对service的调用一般都是基于接口操作。简单的描述是这样的:如图所示,接口和实现类由服务商提供。我们可以把controller看做是服务的调用者,调用者只需要调用接口即可。虽然有声音认为,在大多数情况下,服务只有一个实现类,接口层显得多余。但是在《Head First Design Patterns》一书中,大佬们还是建议:Programtoaninterface,notanimplementation。是的,人们常说要面向接口编程。至于好处,无非就是减少耦合,方便以后的扩展,提高代码的灵活性和可维护性等等。在上面的例子中,这个接口层和它的方法都可以称为API,我们要讨论的SPI与之相比,既有相同点也有不同点。先看图:简单来说,就是服务的调用者定义了一个接口规范,可以被不同的服务提供者实现。而且,调用者可以通过某种机制发现服务提供者,并通过接口调用其能力。通过比较我们可以看出,虽然它们都有接口层面,但它们还是有很大区别的:API中的接口是服务提供者提供给服务调用者的功能列表,而更侧重于SPI。服务调用者对服务实现施加的约束,服务提供者根据这个约束实现的服务可以被服务调用者发现。说白了,Java中的SPI实现的就是你按照我的接口规范实现服务,我可以通过某种机制为这个接口找到这个服务。这么说可能有点抽象,下面举个例子来类比详细描述一下这个过程。2.定义接口说起智能家居系统,现在大家都很熟悉了。只要是同一品牌下的产品,连接wifi后就可以通过手机APP进行控制,非常方便。虽然产品不断更新,型号也不断更新,但同一种家电在APP上操作时,功能大体相同。以空调为例。当我们在APP上操作它时,一般会有三个主要功能:开关、模式选择、温度调节。假设我在客厅、卧室、书房分别安装了3台不同型号的空调,并连接到我的app上,那么后面的操作都是一样的几个按钮,简单粗暴。想一想,不管是开关还是温度调节,都是通过app调用设备的接口。如果不同型号的空调自己写接口,开发后台APP的时候接口会很麻烦。解决方法也很简单。我先定义一套接口规范。不管你以后有什么型号的空调,你都按照我的规范来实现接口。只要以后我能发现你的设备,我就可以用同样的方式调用接口。那我们先定义这么一套接口规范。如果以后要接入智能家居系统,就必须按照这个规范来开发接口。创建一个新项目作为标准,将其命名为aircondition-standard,并创建一个接口。除了这3个操作之外,我们还增加了一个获取空调型号的方法。publicinterfaceIAircondition{//获取模型StringgetType();//开关无效turnOnOff();//调整温度voidadjustTemperature(inttemperature);//模式改变voidchangeModel(intmodelId);供实施者使用,用maven打包成jar包:mvncleaninstall后,服务商可以在项目中引入这个jar包。有了这套规范,就可以保证以后无论产品如何更新,都可以接入系统。3.服务实现制定并发布规则后,挂机作为第一个服务提供者,新建项目aircondition-hanging-type,导入刚刚创建的jar包:com.cn。hydraaircondition-standard1.0-SNAPSHOT创建服务类并实现之前定义的接口:publicclassHangingTypeAirconditionimplementsIAircondition{publicStringgetType(){返回"悬挂类型";}publicvoidturnOnOff(){System.out.println("挂空调开关");}publicvoidadjustTemperature(inti){System.out.println("挂机调节温度");}publicvoidchangeModel(inti){System.out.println("挂机空调更换模式");}}在项目的资源目录下,创建META-INF/services目录,然后使用前面定义的接口名com.cn.hydra.IAircondition创建一个文件,写上实现类的全限定名在文件中。com.cn.hydra.HangingTypeAircondition整个项目结构非常简单:这样就完成了一个简单的服务端实现,用maven打包成jar包,然后提供给调用者。同样,我们可以再创建一个立式空调项目aircondition-vertical-type,只创建一个服务类:publicclassVerticalTypeAirconditionimplementsIAircondition{publicStringgetType(){return"VerticalType";}publicvoidturnOnOff(){System.out.println("立式空调开关");}publicvoidadjustTemperature(inti){System.out.println("立式空调调节温度");}publicvoidchangeModel(inti){System.out.println("立式空调更换模式");}}还是按照上面的命名规则,创建一个配置文件:com.cn.hydra.VerticalTypeAircondition同理,做成jar包。至于服务调用者是如何发现并调用这两个服务的,下面会详细讲到。4.服务发现既然两个服务提供者都已经实现了接口,接下来的关键步骤就是服务发现。在这一步,java中的spi发现机制已经帮我们实现了。新建工程aircondition-app,导入上面创建的两个jar包。com.cn.hydraaircondition-hanging-type1.0-SNAPSHOTcom.cn.hydraaircondition-vertical-type1.0-SNAPSHOT按照上面的说法,虽然每个服务商都有是接口的不同实现,但是作为调用者,不需要关心具体的实现类。我们要做的就是通过接口调用服务提供者实现的方法。以下是关键服务发现链接。我们写一个方法,根据机型调用对应空调的开关方法。公共类AirconditionApp{publicstaticvoidmain(String[]args){newAirconditionApp().turnOn("VerticalType");}publicvoidturnOn(Stringtype){ServiceLoaderload=ServiceLoader.load(IAircondition.class);for(IAirconditioniAircondition:load){System.out.println("Detected:"+iAircondition.getClass().getSimpleName());如果(type.equals(iAircondition.getType())){iAircondition.turnOnOff();}}}}测试结果:测试时可以看到,通过定义的接口IAircondition找到了两个实现类,通过参数调用了具体实现类的一个方法。整个代码中没有具体的服务实现类,操作都是通过接口调用的。5.原理了解了spi的工作流程之后,我们来看一下它的实现。其实最重要的是上面代码中出现的ServiceLoader类。在上面的示例代码中,对于ServiceLoader的load()方法的结果,我们使用了for循环来遍历。这个我们看源码就可以理解,因为ServiceLoader实现了Iterable接口,而整个服务发现的核心,就在它的iterator()方法中。注意这里有两个关键的东西,找到源码中定义的地方:注释说的很清楚了,providers是一个缓存,如果在iterator中先从这里开始查找,有的话继续往下看,如果有没有了,用这个懒加载的lookupIterator来找。那么就简单了,再往下看LazyIterator,看看里面的hasNext()和next()方法是如何实现的。这个acc是一个安全经理。由前面的System.getSecurityManager()判断赋值。如果用debug看,这里全是null,所以直接看hasNextService()和nextService()方法就可以了。在hasNextService()方法中,会取出接口取出实现类的类名,放在nextName中:接下来,在nextService()方法中,先加载实现类,再加载对象被实例化,最后放入缓存。在迭代器的迭代过程中,会完成所有实现类的实例化。其实说到底,还是基于java反射实现的。6.应用如果要说spi的实际应用,最常见的就是日志框架slf4j,它使用spi实现对其他具体日志框架的槽式访问。说白了,slf4j本身就是一个日志门面,并没有提供具体的实现。需要绑定其他具体实现,才能真正引入日志功能。比如我们可以使用log4j2作为具体的binder,只需要在pom中引入slf4j-log4j12就可以使用具体的功能。<依赖>org.slf4jslf4j-api2.0.3org.slf4jslf4j-log4j122.0.3导入项目后,点击其jar包查看具体结构:找到彩蛋了吗,我来说说关于为什么我们的pom中明明是引入了slf4j-log4j12,但实际上引入了slf4j-reload4j?翻看官网文档:大致思路是在2015年和2022年,log4j1.x已经宣布结束生命周期,原因不难猜测,可能是因为频繁泄露。之后slf4j-log4j会在构建阶段自动重定向到slf4j-reload4j,官方也强烈推荐使用slf4j-reload4j作为替代。回头看jar包的META-INF.services,通过spi注入了实现类Reload4jServiceProvider,实现了SLF4JServiceProvider的接口。在它的初始化方法initialize()中,会完成初始化等工作,后续可以继续获取LoggerFactory、Logger等具体的日志对象。7.总结Java中的SPI提供了一种特殊的服务发现和调用机制,通过接口将服务调用与服务提供者灵活分离,非常方便提供给第三方实现扩展。但也有缺点。比如,一旦加载了一个接口,所有的实现类都会被加载进去,可能会加载不必要的冗余服务。不过从整体来看,还是给我们提供了一个很好的框架扩展和集成的思路。