Java SPI 全过程原理
SPI 是 Java 生态里非常重要的扩展机制。JDBC 驱动加载、日志框架适配、Dubbo 扩展点、Spring Boot 自动配置思想,都和“面向接口 + 外部实现 + 运行时发现”这条线有关。
一句话先建立直觉:
SPI 是 Service Provider Interface 的缩写。调用方只依赖接口,不直接依赖实现;实现方把实现类写到约定配置文件里;运行时由
ServiceLoader扫描配置并创建实现对象。
学习目标
学完这一页,你要能说清楚:
- API 和 SPI 有什么区别。
- 为什么框架需要 SPI,而不是把所有实现写死。
- Java 原生 SPI 的目录和文件命名规则。
ServiceLoader.load到底做了哪些事。- SPI 为什么是懒加载。
- SPI 和反射、类加载器有什么关系。
- JDBC 驱动为什么只加依赖就能被发现。
- Java 原生 SPI 有哪些缺点,Dubbo SPI 为什么要增强。
- 商业项目里如何用 SPI 做支付渠道、文件存储、消息发送扩展。
- SPI 加载不到实现时怎么排查。
API 和 SPI 的区别
API 是调用方使用别人提供的能力:
List<String> list = new ArrayList<>();
list.add("Tom");这里 List、ArrayList 对业务开发者来说是 API。你是使用者。
SPI 是框架定义接口,让别人提供实现:
public interface PaymentProvider {
String channel();
void pay(String orderNo, int amount);
}框架只认识 PaymentProvider 接口,具体是支付宝、微信、银行卡,由外部模块提供。
| 对比项 | API | SPI |
|---|---|---|
| 谁定义接口 | 服务提供方 | 框架或平台 |
| 谁调用 | 业务使用方 | 框架或平台 |
| 谁实现 | 服务提供方 | 扩展提供方 |
| 关注点 | 使用能力 | 扩展能力 |
| 例子 | 调用 Redis 客户端 API | JDBC 驱动、Dubbo 扩展点 |
通俗理解:API 是“我调用你”,SPI 是“你按我的规则接进来,我来发现你并调用你”。
为什么需要 SPI
假设平台支持多个支付渠道。如果不用 SPI,代码可能写成:
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);
}问题很明显:
- 新增渠道要改平台核心代码。
- 平台要依赖所有渠道实现。
- 渠道越多,判断越多。
- 第三方无法在不改源码的情况下接入。
SPI 的目标是把核心框架和具体实现解耦:
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。
它约定:
- 先定义一个接口。
- 实现方实现这个接口。
- 在 classpath 下创建文件:
META-INF/services/接口全限定名。 - 文件内容写实现类全限定名,每行一个。
- 调用方使用
ServiceLoader.load(接口.class)加载。
目录结构:
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配置文件内容:
com.example.spi.AlipayProvider
com.example.spi.WechatProvider注意:文件名必须是接口全限定名,不是实现类名,也不是随便起的名字。
完整可运行 Demo:支付渠道 SPI
1. 定义接口
package com.example.spi;
public interface PaymentProvider {
String channel();
void pay(String orderNo, int amount);
}2. 提供实现类
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);
}
}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);
}
}实现类要注意:
- 通常需要 public。
- 通常需要无参构造。
- 必须能被当前类加载器加载到。
3. 创建 SPI 配置文件
文件路径:
src/main/resources/META-INF/services/com.example.spi.PaymentProvider文件内容:
com.example.spi.AlipayProvider
com.example.spi.WechatProvider4. 使用 ServiceLoader 加载
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);
}
}
}运行结果类似:
发现支付渠道: alipay
支付宝支付,订单号=PO202607060001,金额=100
发现支付渠道: wechat
微信支付,订单号=PO202607060001,金额=100ServiceLoader 加载流程
ServiceLoader.load(PaymentProvider.class) 不是立刻把所有实现对象都创建出来。它会先创建一个 ServiceLoader 对象,真正遍历时才逐步加载。
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 的核心依赖三件事:
- 类加载器:能不能找到配置文件和实现类。
- 反射:能不能创建实现对象。
- 约定配置:文件路径和实现类全限定名是否正确。
SPI 为什么是懒加载
ServiceLoader 实现了 Iterable。通常只有在遍历时,才会真正解析和创建实现。
ServiceLoader<PaymentProvider> loader = ServiceLoader.load(PaymentProvider.class);
// 到这里还不一定创建所有 provider
for (PaymentProvider provider : loader) {
// 遍历时逐个加载
}懒加载的好处:
- 不用一开始就实例化所有实现。
- 启动成本更低。
- 如果调用方只取第一个实现,可以少加载后面的实现。
但它也带来一个排查点:异常可能不是在 load 那一行抛出,而是在遍历时抛出。
SPI 和 ClassLoader
默认写法:
ServiceLoader.load(PaymentProvider.class);内部通常会使用当前线程上下文类加载器:
Thread.currentThread().getContextClassLoader()也可以显式指定:
ClassLoader classLoader = PaymentProvider.class.getClassLoader();
ServiceLoader<PaymentProvider> loader = ServiceLoader.load(PaymentProvider.class, classLoader);为什么这很重要?
在普通 Java 应用里,一个 AppClassLoader 往往够用。但在 Tomcat、插件系统、IDE 插件、复杂中间件里,不同模块可能用不同类加载器。如果上下文类加载器不对,就可能出现:
- 配置文件找不到。
- 实现类找不到。
- 接口类由 A 加载,实现类由 B 加载,导致类型不匹配。
所以 SPI 排查不能只看代码,还要看类加载器和依赖打包。
SPI 和反射
配置文件里只有字符串:
com.example.spi.AlipayProviderServiceLoader 要把字符串变成对象,大致需要:
Class<?> clazz = Class.forName("com.example.spi.AlipayProvider", false, classLoader);
Object instance = clazz.getDeclaredConstructor().newInstance();
PaymentProvider provider = (PaymentProvider) instance;所以 SPI 实现类没有无参构造、构造方法不是可访问的、类名写错、依赖缺失,都可能导致加载失败。
这也说明 SPI 和 反射全过程原理 是一条线上的知识点。
JDBC 驱动为什么能自动加载
早期 JDBC 常见写法:
Class.forName("com.mysql.jdbc.Driver");这是手动触发 MySQL Driver 类加载,让它注册到 DriverManager。
JDBC 4.0 之后,驱动 jar 可以通过 SPI 暴露驱动实现。MySQL 驱动 jar 里会有类似配置:
META-INF/services/java.sql.Driver内容是驱动实现类:
com.mysql.cj.jdbc.DriverDriverManager 可以通过 ServiceLoader 加载所有 java.sql.Driver 实现,所以很多时候只要把驱动 jar 放到 classpath,就不需要手写 Class.forName。
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 增强了:
- 按名称加载扩展,比如
protocol=dubbo。 - 支持默认扩展。
- 支持扩展自动包装 Wrapper。
- 支持自适应扩展 Adaptive。
- 支持依赖注入。
- 支持激活扩展 Activate。
简单理解:
Java SPI:给我一个接口,我把所有实现都找出来。
Dubbo SPI:给我一个接口和名字,我精确找到需要的扩展,并支持包装、注入、条件激活。这就是为什么 Dubbo 的 SPI 可以支撑复杂的 RPC 扩展生态。
商业 Demo:按渠道选择支付实现
原生 ServiceLoader 会加载全部实现,商业项目里通常会再做一层注册表,方便按名字选择。
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;
}
}使用:
public class PaymentBusinessDemo {
public static void main(String[] args) {
PaymentRegistry registry = new PaymentRegistry();
PaymentProvider provider = registry.get("alipay");
provider.pay("PO1001", 100);
}
}这个结构适合:
- 支付渠道扩展。
- 文件存储扩展,本地、MinIO、OSS、S3。
- 消息发送扩展,短信、邮件、站内信、企微。
- 数据导入解析扩展,CSV、Excel、JSON。
- 加密算法扩展,AES、RSA、SM4。
商业场景设计
文件存储扩展
接口:
public interface FileStorage {
String type();
String upload(byte[] content, String filename);
}实现:
LocalFileStorage
MinioFileStorage
OssFileStorage业务系统只依赖 FileStorage,具体用哪种实现由配置或环境决定。
消息通知扩展
public interface MessageSender {
String type();
void send(String receiver, String content);
}实现:
SmsSender
EmailSender
WechatWorkSender订单支付成功后,业务只调用统一接口,不把短信、邮件、企微逻辑写死在订单服务里。
数据采集解析扩展
医疗采集、资产平台、批量导入系统经常面对不同文件格式:
public interface DataParser {
String supportType();
List<Record> parse(byte[] content);
}新增一种解析器,只需要新增实现和 SPI 配置,核心采集流程不用改。
不这样会怎样
如果不用 SPI 或类似扩展机制:
| 问题 | 后果 |
|---|---|
| 核心代码写死实现类 | 新增实现必须改核心代码 |
| 平台依赖所有实现 | 依赖膨胀,模块边界混乱 |
| 到处 if/else 判断类型 | 可维护性差,容易漏改 |
| 第三方无法独立接入 | 扩展生态做不起来 |
| 测试困难 | 每个实现都和核心流程强耦合 |
SPI 的价值就是把“变化的实现”从“稳定的框架流程”里剥离出去。
常见坑
文件路径写错
正确:
META-INF/services/com.example.spi.PaymentProvider错误:
META-INF/service/com.example.spi.PaymentProvider
META-INF/services/PaymentProvider路径少一个 s 或文件名不是接口全限定名,都会加载不到。
实现类名写错
配置文件里必须写实现类全限定名:
com.example.spi.AlipayProvider包名、类名、大小写错了都会失败。
实现类没有无参构造
public class AlipayProvider implements PaymentProvider {
public AlipayProvider(String appId) {
}
}原生 SPI 反射创建时无法知道 appId 从哪里来。复杂依赖更适合交给 Spring 容器管理,或者在 SPI 实现内部延迟读取配置。
配置文件没有打进 jar
Maven/Gradle 构建时,如果 resources 没有正确打包,运行环境里就没有 META-INF/services 文件。
排查时要打开 jar 看:
jar tf your-provider.jar | findstr META-INF/services多个 jar 提供同一个 SPI
这是允许的。ServiceLoader 会从 classpath 里找到所有同名配置文件并合并读取。
但如果多个实现的 channel() 返回同一个名字,业务注册表可能要报错,否则后加载的实现覆盖前一个实现,结果不可控。
加载异常发生在遍历时
ServiceLoader<PaymentProvider> loader = ServiceLoader.load(PaymentProvider.class);这一行可能不报错。真正报错可能发生在:
for (PaymentProvider provider : loader) {
}所以排查日志要看遍历位置。
线上排查流程
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 类和可用无参构造"]排查建议:
- 先确认接口全限定名和配置文件名完全一致。
- 确认
META-INF/services文件在最终 jar 里。 - 确认实现类全限定名能
Class.forName成功。 - 确认实现类实现了正确的接口,不是不同类加载器下的同名接口。
- 确认实现类有可访问的无参构造。
- 注意异常可能在遍历
ServiceLoader时才出现。 - 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 和强依赖。
