Skip to content

Java SPI 全过程原理

SPI 是 Java 生态里非常重要的扩展机制。JDBC 驱动加载、日志框架适配、Dubbo 扩展点、Spring Boot 自动配置思想,都和“面向接口 + 外部实现 + 运行时发现”这条线有关。

一句话先建立直觉:

SPI 是 Service Provider Interface 的缩写。调用方只依赖接口,不直接依赖实现;实现方把实现类写到约定配置文件里;运行时由 ServiceLoader 扫描配置并创建实现对象。

学习目标

学完这一页,你要能说清楚:

  1. API 和 SPI 有什么区别。
  2. 为什么框架需要 SPI,而不是把所有实现写死。
  3. Java 原生 SPI 的目录和文件命名规则。
  4. ServiceLoader.load 到底做了哪些事。
  5. SPI 为什么是懒加载。
  6. SPI 和反射、类加载器有什么关系。
  7. JDBC 驱动为什么只加依赖就能被发现。
  8. Java 原生 SPI 有哪些缺点,Dubbo SPI 为什么要增强。
  9. 商业项目里如何用 SPI 做支付渠道、文件存储、消息发送扩展。
  10. SPI 加载不到实现时怎么排查。

API 和 SPI 的区别

API 是调用方使用别人提供的能力:

java
List<String> list = new ArrayList<>();
list.add("Tom");

这里 ListArrayList 对业务开发者来说是 API。你是使用者。

SPI 是框架定义接口,让别人提供实现:

java
public interface PaymentProvider {
    String channel();

    void pay(String orderNo, int amount);
}

框架只认识 PaymentProvider 接口,具体是支付宝、微信、银行卡,由外部模块提供。

对比项APISPI
谁定义接口服务提供方框架或平台
谁调用业务使用方框架或平台
谁实现服务提供方扩展提供方
关注点使用能力扩展能力
例子调用 Redis 客户端 APIJDBC 驱动、Dubbo 扩展点

通俗理解:API 是“我调用你”,SPI 是“你按我的规则接进来,我来发现你并调用你”。

为什么需要 SPI

假设平台支持多个支付渠道。如果不用 SPI,代码可能写成:

java
if ("alipay".equals(channel)) {
    new AlipayProvider().pay(orderNo, amount);
} else if ("wechat".equals(channel)) {
    new WechatProvider().pay(orderNo, amount);
} else if ("bank".equals(channel)) {
    new BankProvider().pay(orderNo, amount);
}

问题很明显:

  1. 新增渠道要改平台核心代码。
  2. 平台要依赖所有渠道实现。
  3. 渠道越多,判断越多。
  4. 第三方无法在不改源码的情况下接入。

SPI 的目标是把核心框架和具体实现解耦:

mermaid
flowchart TD
    A["平台定义 PaymentProvider 接口"] --> B["支付宝模块实现接口"]
    A --> C["微信模块实现接口"]
    A --> D["银行卡模块实现接口"]
    B --> E["META-INF/services 配置实现类"]
    C --> E
    D --> E
    E --> F["ServiceLoader 运行时发现实现"]
    F --> G["平台按接口调用"]

这样新增渠道只需要新增实现 jar,不一定要改平台核心逻辑。

Java 原生 SPI 规范

Java 原生 SPI 使用 java.util.ServiceLoader

它约定:

  1. 先定义一个接口。
  2. 实现方实现这个接口。
  3. 在 classpath 下创建文件:META-INF/services/接口全限定名
  4. 文件内容写实现类全限定名,每行一个。
  5. 调用方使用 ServiceLoader.load(接口.class) 加载。

目录结构:

text
src/main/java/com/example/spi/PaymentProvider.java
src/main/java/com/example/spi/AlipayProvider.java
src/main/java/com/example/spi/WechatProvider.java
src/main/resources/META-INF/services/com.example.spi.PaymentProvider

配置文件内容:

text
com.example.spi.AlipayProvider
com.example.spi.WechatProvider

注意:文件名必须是接口全限定名,不是实现类名,也不是随便起的名字。

完整可运行 Demo:支付渠道 SPI

1. 定义接口

java
package com.example.spi;

public interface PaymentProvider {
    String channel();

    void pay(String orderNo, int amount);
}

2. 提供实现类

java
package com.example.spi;

public class AlipayProvider implements PaymentProvider {
    @Override
    public String channel() {
        return "alipay";
    }

    @Override
    public void pay(String orderNo, int amount) {
        System.out.println("支付宝支付,订单号=" + orderNo + ",金额=" + amount);
    }
}
java
package com.example.spi;

public class WechatProvider implements PaymentProvider {
    @Override
    public String channel() {
        return "wechat";
    }

    @Override
    public void pay(String orderNo, int amount) {
        System.out.println("微信支付,订单号=" + orderNo + ",金额=" + amount);
    }
}

实现类要注意:

  1. 通常需要 public。
  2. 通常需要无参构造。
  3. 必须能被当前类加载器加载到。

3. 创建 SPI 配置文件

文件路径:

text
src/main/resources/META-INF/services/com.example.spi.PaymentProvider

文件内容:

text
com.example.spi.AlipayProvider
com.example.spi.WechatProvider

4. 使用 ServiceLoader 加载

java
package com.example.spi;

import java.util.ServiceLoader;

public class PaymentSpiDemo {
    public static void main(String[] args) {
        ServiceLoader<PaymentProvider> loader = ServiceLoader.load(PaymentProvider.class);

        for (PaymentProvider provider : loader) {
            System.out.println("发现支付渠道: " + provider.channel());
            provider.pay("PO202607060001", 100);
        }
    }
}

运行结果类似:

text
发现支付渠道: alipay
支付宝支付,订单号=PO202607060001,金额=100
发现支付渠道: wechat
微信支付,订单号=PO202607060001,金额=100

ServiceLoader 加载流程

ServiceLoader.load(PaymentProvider.class) 不是立刻把所有实现对象都创建出来。它会先创建一个 ServiceLoader 对象,真正遍历时才逐步加载。

mermaid
flowchart TD
    A["调用 ServiceLoader.load(接口.class)"] --> B["确定使用的 ClassLoader"]
    B --> C["构造配置文件路径 META-INF/services/接口全限定名"]
    C --> D["从 classpath 查找所有同名配置文件"]
    D --> E["逐行读取实现类全限定名"]
    E --> F["过滤空行、注释、重复项"]
    F --> G["迭代时按类名加载 Class"]
    G --> H["反射调用无参构造创建对象"]
    H --> I["返回接口类型实例"]

所以 SPI 的核心依赖三件事:

  1. 类加载器:能不能找到配置文件和实现类。
  2. 反射:能不能创建实现对象。
  3. 约定配置:文件路径和实现类全限定名是否正确。

SPI 为什么是懒加载

ServiceLoader 实现了 Iterable。通常只有在遍历时,才会真正解析和创建实现。

java
ServiceLoader<PaymentProvider> loader = ServiceLoader.load(PaymentProvider.class);

// 到这里还不一定创建所有 provider

for (PaymentProvider provider : loader) {
    // 遍历时逐个加载
}

懒加载的好处:

  1. 不用一开始就实例化所有实现。
  2. 启动成本更低。
  3. 如果调用方只取第一个实现,可以少加载后面的实现。

但它也带来一个排查点:异常可能不是在 load 那一行抛出,而是在遍历时抛出。

SPI 和 ClassLoader

默认写法:

java
ServiceLoader.load(PaymentProvider.class);

内部通常会使用当前线程上下文类加载器:

java
Thread.currentThread().getContextClassLoader()

也可以显式指定:

java
ClassLoader classLoader = PaymentProvider.class.getClassLoader();
ServiceLoader<PaymentProvider> loader = ServiceLoader.load(PaymentProvider.class, classLoader);

为什么这很重要?

在普通 Java 应用里,一个 AppClassLoader 往往够用。但在 Tomcat、插件系统、IDE 插件、复杂中间件里,不同模块可能用不同类加载器。如果上下文类加载器不对,就可能出现:

  1. 配置文件找不到。
  2. 实现类找不到。
  3. 接口类由 A 加载,实现类由 B 加载,导致类型不匹配。

所以 SPI 排查不能只看代码,还要看类加载器和依赖打包。

SPI 和反射

配置文件里只有字符串:

text
com.example.spi.AlipayProvider

ServiceLoader 要把字符串变成对象,大致需要:

java
Class<?> clazz = Class.forName("com.example.spi.AlipayProvider", false, classLoader);
Object instance = clazz.getDeclaredConstructor().newInstance();
PaymentProvider provider = (PaymentProvider) instance;

所以 SPI 实现类没有无参构造、构造方法不是可访问的、类名写错、依赖缺失,都可能导致加载失败。

这也说明 SPI 和 反射全过程原理 是一条线上的知识点。

JDBC 驱动为什么能自动加载

早期 JDBC 常见写法:

java
Class.forName("com.mysql.jdbc.Driver");

这是手动触发 MySQL Driver 类加载,让它注册到 DriverManager

JDBC 4.0 之后,驱动 jar 可以通过 SPI 暴露驱动实现。MySQL 驱动 jar 里会有类似配置:

text
META-INF/services/java.sql.Driver

内容是驱动实现类:

text
com.mysql.cj.jdbc.Driver

DriverManager 可以通过 ServiceLoader 加载所有 java.sql.Driver 实现,所以很多时候只要把驱动 jar 放到 classpath,就不需要手写 Class.forName

mermaid
flowchart TD
    A["项目引入 mysql-connector-j"] --> B["jar 中包含 META-INF/services/java.sql.Driver"]
    B --> C["DriverManager 使用 ServiceLoader"]
    C --> D["发现 MySQL Driver"]
    D --> E["连接 jdbc:mysql://..."]

这就是 SPI 在 Java 基础设施里的典型应用。

Java 原生 SPI 的缺点

Java 原生 SPI 简单,但并不完美。

缺点说明
只能按接口加载全部实现不能按名称直接拿某一个实现
缺少优先级和条件筛选多个实现时选择逻辑要自己写
配置文件只是类名不能写复杂元数据
依赖无参构造不适合复杂依赖注入
缺少 IOC 生命周期创建对象后没有完整生命周期管理
错误通常在遍历时暴露排查时容易误以为 load 没问题

所以大型框架常常会增强 SPI。

Dubbo SPI 为什么要增强

Dubbo 是典型的强扩展框架。协议、序列化、负载均衡、集群容错、过滤器、注册中心都需要可插拔。

Java 原生 SPI 对 Dubbo 来说不够用,所以 Dubbo SPI 增强了:

  1. 按名称加载扩展,比如 protocol=dubbo
  2. 支持默认扩展。
  3. 支持扩展自动包装 Wrapper。
  4. 支持自适应扩展 Adaptive。
  5. 支持依赖注入。
  6. 支持激活扩展 Activate。

简单理解:

text
Java SPI:给我一个接口,我把所有实现都找出来。
Dubbo SPI:给我一个接口和名字,我精确找到需要的扩展,并支持包装、注入、条件激活。

这就是为什么 Dubbo 的 SPI 可以支撑复杂的 RPC 扩展生态。

商业 Demo:按渠道选择支付实现

原生 ServiceLoader 会加载全部实现,商业项目里通常会再做一层注册表,方便按名字选择。

java
package com.example.spi;

import java.util.HashMap;
import java.util.Map;
import java.util.ServiceLoader;

public class PaymentRegistry {
    private final Map<String, PaymentProvider> providers = new HashMap<String, PaymentProvider>();

    public PaymentRegistry() {
        ServiceLoader<PaymentProvider> loader = ServiceLoader.load(PaymentProvider.class);
        for (PaymentProvider provider : loader) {
            if (providers.containsKey(provider.channel())) {
                throw new IllegalStateException("重复支付渠道: " + provider.channel());
            }
            providers.put(provider.channel(), provider);
        }
    }

    public PaymentProvider get(String channel) {
        PaymentProvider provider = providers.get(channel);
        if (provider == null) {
            throw new IllegalArgumentException("不支持的支付渠道: " + channel);
        }
        return provider;
    }
}

使用:

java
public class PaymentBusinessDemo {
    public static void main(String[] args) {
        PaymentRegistry registry = new PaymentRegistry();
        PaymentProvider provider = registry.get("alipay");
        provider.pay("PO1001", 100);
    }
}

这个结构适合:

  1. 支付渠道扩展。
  2. 文件存储扩展,本地、MinIO、OSS、S3。
  3. 消息发送扩展,短信、邮件、站内信、企微。
  4. 数据导入解析扩展,CSV、Excel、JSON。
  5. 加密算法扩展,AES、RSA、SM4。

商业场景设计

文件存储扩展

接口:

java
public interface FileStorage {
    String type();

    String upload(byte[] content, String filename);
}

实现:

text
LocalFileStorage
MinioFileStorage
OssFileStorage

业务系统只依赖 FileStorage,具体用哪种实现由配置或环境决定。

消息通知扩展

java
public interface MessageSender {
    String type();

    void send(String receiver, String content);
}

实现:

text
SmsSender
EmailSender
WechatWorkSender

订单支付成功后,业务只调用统一接口,不把短信、邮件、企微逻辑写死在订单服务里。

数据采集解析扩展

医疗采集、资产平台、批量导入系统经常面对不同文件格式:

java
public interface DataParser {
    String supportType();

    List<Record> parse(byte[] content);
}

新增一种解析器,只需要新增实现和 SPI 配置,核心采集流程不用改。

不这样会怎样

如果不用 SPI 或类似扩展机制:

问题后果
核心代码写死实现类新增实现必须改核心代码
平台依赖所有实现依赖膨胀,模块边界混乱
到处 if/else 判断类型可维护性差,容易漏改
第三方无法独立接入扩展生态做不起来
测试困难每个实现都和核心流程强耦合

SPI 的价值就是把“变化的实现”从“稳定的框架流程”里剥离出去。

常见坑

文件路径写错

正确:

text
META-INF/services/com.example.spi.PaymentProvider

错误:

text
META-INF/service/com.example.spi.PaymentProvider
META-INF/services/PaymentProvider

路径少一个 s 或文件名不是接口全限定名,都会加载不到。

实现类名写错

配置文件里必须写实现类全限定名:

text
com.example.spi.AlipayProvider

包名、类名、大小写错了都会失败。

实现类没有无参构造

java
public class AlipayProvider implements PaymentProvider {
    public AlipayProvider(String appId) {
    }
}

原生 SPI 反射创建时无法知道 appId 从哪里来。复杂依赖更适合交给 Spring 容器管理,或者在 SPI 实现内部延迟读取配置。

配置文件没有打进 jar

Maven/Gradle 构建时,如果 resources 没有正确打包,运行环境里就没有 META-INF/services 文件。

排查时要打开 jar 看:

text
jar tf your-provider.jar | findstr META-INF/services

多个 jar 提供同一个 SPI

这是允许的。ServiceLoader 会从 classpath 里找到所有同名配置文件并合并读取。

但如果多个实现的 channel() 返回同一个名字,业务注册表可能要报错,否则后加载的实现覆盖前一个实现,结果不可控。

加载异常发生在遍历时

java
ServiceLoader<PaymentProvider> loader = ServiceLoader.load(PaymentProvider.class);

这一行可能不报错。真正报错可能发生在:

java
for (PaymentProvider provider : loader) {
}

所以排查日志要看遍历位置。

线上排查流程

mermaid
flowchart TD
    A["SPI 实现加载不到"] --> B["检查配置文件路径"]
    B --> C["META-INF/services/接口全限定名"]
    A --> D["检查配置文件内容"]
    D --> E["实现类全限定名是否正确"]
    A --> F["检查 jar 是否包含配置"]
    F --> G["jar tf 查看 resources"]
    A --> H["检查类加载器"]
    H --> I["上下文 ClassLoader 是否能看到实现 jar"]
    A --> J["检查实现类构造方法"]
    J --> K["public 类和可用无参构造"]

排查建议:

  1. 先确认接口全限定名和配置文件名完全一致。
  2. 确认 META-INF/services 文件在最终 jar 里。
  3. 确认实现类全限定名能 Class.forName 成功。
  4. 确认实现类实现了正确的接口,不是不同类加载器下的同名接口。
  5. 确认实现类有可访问的无参构造。
  6. 注意异常可能在遍历 ServiceLoader 时才出现。
  7. Web 容器、插件系统里重点查线程上下文类加载器。

面试标准回答

SPI 是什么

SPI 是 Service Provider Interface,是 Java 的服务发现扩展机制。框架定义接口,第三方提供实现,并在 META-INF/services/接口全限定名 文件中声明实现类。运行时通过 ServiceLoader 扫描配置文件、加载实现类并创建对象,从而实现面向接口的可插拔扩展。

API 和 SPI 有什么区别

API 是别人提供能力给我调用,调用方是业务代码;SPI 是框架定义扩展接口,让别人按规则提供实现,调用方通常是框架。API 偏使用能力,SPI 偏扩展能力。

Java SPI 的加载流程

ServiceLoader.load 会根据接口全限定名拼出 META-INF/services/接口全限定名 路径,通过类加载器从 classpath 查找所有配置文件,读取实现类全限定名,遍历时再加载 Class,并通过反射调用无参构造创建实现对象,最后以接口类型返回。

SPI 为什么和类加载器有关

SPI 配置文件和实现类都要通过类加载器找到。普通应用问题不明显,但 Tomcat、插件系统、中间件隔离场景里,不同模块可能有不同类加载器。如果上下文类加载器看不到实现 jar,或者接口和实现由不同类加载器加载,就可能加载失败或类型不匹配。

Java 原生 SPI 有哪些缺点

原生 SPI 只能按接口加载所有实现,不能按名称精确加载;缺少优先级、条件筛选、依赖注入、生命周期管理;配置文件只能写实现类名;实现类通常需要无参构造。大型框架通常会增强 SPI,比如 Dubbo SPI 支持按名称加载、自适应扩展、包装扩展和依赖注入。

JDBC 驱动为什么可以自动发现

JDBC 4.0 之后,数据库驱动 jar 可以在 META-INF/services/java.sql.Driver 中声明驱动实现类。DriverManager 通过 ServiceLoader 发现这些 Driver 实现,所以很多时候只要引入驱动依赖,不需要手写 Class.forName

项目中怎么用 SPI

适合用于可插拔扩展,比如支付渠道、文件存储、消息发送、数据解析、加密算法、报表导出。核心系统定义接口和调用流程,具体实现由不同模块提供,通过 SPI 或类似机制注册进来,从而减少核心代码 if/else 和强依赖。

关联知识点