美团二面:深入理解Java中的SPI机制及其在SpringBoot自动装配中的应用

SPI概述

在面试中,许多人可能听说过一个术语,SPI扩展。面试官常常会问,SpringBoot是如何实现自动装配的? 通常,回答涉及到Spring的SPI扩展机制,并提及spring.factories文件及EnableAutoConfiguration。这足以证明你的理解。

回想四五年前,当我第一次在面试中提及SPI动态扩展机制时,面试官惊讶的表情让我意识到,掌握这些术语是多么重要。甚至在我自己对SPI的理解上,也曾显得有些模糊。然而,如果想要在面试中给人留下深刻印象,就必须先掌握这些概念。

今天,我们将专注于Java自带的SPI扩展机制,逐步了解其工作原理。

SPI的定义和工作原理

什么是SPI?

SPI的全称是Service Provider Interface,翻译为服务提供者接口。它本质上是一种服务发现机制,帮助我们在代码中灵活地发现和使用服务提供者。

为了便于理解,我们可以通过一个例子来说明。在Spring项目中,开发人员通常会在服务层中定义接口,并通过Spring的依赖注入机制(如@Autowired)来获得该接口的实现。调用服务层时,开发者只需依赖接口,不必关注具体实现。

简而言之,服务提供者提供实现类,而调用者只需调用接口。这种接口的实现方式在一定程度上降低了代码的耦合性,提升了代码的灵活性和可维护性。

SPI与API的区别

虽然SPI与API都涉及接口,但它们的侧重点有所不同。在API中,接口通常是服务提供者向调用者提供的功能列表,而在SPI中,更加强调的是,调用者对服务实现的约束。服务提供者则需依据这些约束来实现服务,以便被调用者发现和使用。

在Java中,SPI的实现方式是:调用者定义接口,并根据该接口规范来实现服务。这样一来,调用者就能通过某种机制发现服务提供者并调用其实现。

接口的定义

接下来,我们将通过一个智能家居系统的例子来说明SPI的应用。

现代的智能家居设备通常支持通过同一款手机应用进行控制。假设我在客厅、卧室和书房分别安装了三款不同品牌的空调,并将它们接入到同一个应用中。无论空调的型号如何,用户对其操控的方式都是相似的,主要功能包括:开关选择模式调节温度

为了避免不同型号空调各自实现独立接口的复杂性,我们可以定义一套标准接口规范。这样,无论未来更新或增添何种空调,只需遵循这一规范,就能够与系统顺利集成。

我们可以创建一个名为aircondition-standard的新项目,定义如下接口:

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

此接口将被服务实现方使用,并打包成jar文件供后续使用。

服务实现

一旦接口规范设定完毕,首先来实现这一接口的服务提供者将是挂式空调。我们创建一个名为aircondition-hanging-type的新项目,并引入刚才打好的jar包:

<dependency>
    <groupId>com.cn.hydra</groupId>
    <artifactId>aircondition-standard</artifactId>
    <version>1.0-SNAPSHOT</version>
</dependency>

然后,我们实现服务类:

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

resources目录下创建META-INF/services文件夹,并以com.cn.hydra.IAircondition命名创建文件,写入实现类的全限定名。

接下来,我们可以创建另一个立式空调的项目aircondition-vertical-type,实现相同的接口。

服务发现

至此,我们已经完成了两个服务提供者的实现。接下来,关键的一步是服务发现。Java中的SPI机制可以帮助我们完成这个过程。

我们创建一个新项目aircondition-app,并引入之前打好的两个jar包:

<dependencies>
    <dependency>
        <groupId>com.cn.hydra</groupId>
        <artifactId>aircondition-hanging-type</artifactId>
        <version>1.0-SNAPSHOT</version>
    </dependency>
    <dependency>
        <groupId>com.cn.hydra</groupId>
        <artifactId>aircondition-vertical-type</artifactId>
        <version>1.0-SNAPSHOT</version>
    </dependency>
</dependencies>

作为调用者,无需关注具体的实现类,而是通过接口调用服务提供者的方法。我们将实现一个方法,根据类型调用相应空调的开关方法:

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

测试时,我们通过定义的接口IAircondition发现了两个实现类,并根据参数调用特定实现类的方法,而没有直接涉及到服务实现类。

SPI的实现原理

了解了SPI的工作流程后,我们接下来分析其实现。核心在于ServiceLoader类。它实现了Iterable接口,服务发现的核心在于其iterator()方法中。

ServiceLoaderload()方法返回的结果可以通过for循环访问,迭代器将服务提供者的实现类依次返回。其过程主要依赖Java反射机制。

SPI的实际应用

SPI的常见应用之一是日志框架slf4j。它通过SPI实现了对其他具体日志框架的插拔式接入。slf4j本身作为日志门面,不提供具体实现,而是需要绑定其他日志框架才能实现记录功能。

例如,我们可以使用log4j2作为具体的日志实现,只需在pom.xml中引入slf4j-log4j12即可。

<dependency>
    <groupId>org.slf4j</groupId>
    <artifactId>slf4j-api</artifactId>
    <version>2.0.3</version>
</dependency>
<dependency>
    <groupId>org.slf4j</groupId>
    <artifactId>slf4j-log4j12</artifactId>
    <version>2.0.3</version>
</dependency>

通过查看jar包的结构,我们会发现,尽管我们在pom中引入的是slf4j-log4j12,实际使用的是slf4j-reload4j。这是因为随着log4j1.x的EOL,slf4j在构建阶段会自动重定向到slf4j-reload4j

结论

Java中的SPI为服务发现与调用提供了一种灵活的机制,使开发者能够通过接口将服务调用与服务提供者分离。这一机制在扩展和集成第三方服务时尤为方便。尽管SPI也存在加载所有实现类的潜在缺陷,但总的来看,它为我们提供了一个有效的框架扩展思路。