什么是 SPI,和 API 有啥区别?
结论:API(Application Programming Interface)是"我提供、你调用"——实现方定义并实现,调用方编译期依赖它;SPI(Service Provider Interface)是"我定接口、你实现"——框架只定义扩展点,第三方提供实现,通过 META-INF/services/ 下的配置文件(JDK 9+ 也可用 module-info 的 provides)声明,运行期由 ServiceLoader 发现并实例化。SPI 把依赖方向倒置了,是"可插拔扩展"的标准做法。
1. 依赖方向的区别
API: 你的代码 ──编译期依赖──→ 库(接口 + 实现都在库的 jar 里)
SPI: 框架 ──定义接口──→ 你实现接口 ──写 META-INF/services 注册──→
框架运行期 ServiceLoader 扫描 classpath ──→ 实例化你的实现
(框架编译期只依赖接口,不依赖任何实现)
典型例子:JDBC 只提供 java.sql.Driver 接口,MySQL/Oracle 各自实现;SLF4J 只提供日志门面,Logback/Log4j2 各自实现;你换驱动不用改一行业务代码,只换 jar。
2. 对照表
| 维度 | API | SPI |
|---|---|---|
| 依赖方向 | 调用方依赖提供方 | 提供方依赖调用方定义的接口 |
| 实现位置 | 与接口同一个 jar | 由第三方 jar 提供 |
| 发现时机 | 编译期直接引用 | 运行期 ServiceLoader 扫描 |
| 目的 | 复用功能 | 可插拔扩展、解耦 |
| 谁控制实现 | 提供方 | 使用方(通过 classpath 选择) |
| 例子 | HttpClient、List | java.sql.Driver、CharsetProvider |
3. 代码:自定义一个 SPI
// 1) 框架侧:定义扩展点
package com.example.codec;
public interface Codec {
String name();
byte[] encode(byte[] raw);
}
// 2) 提供方:实现接口
package com.example.codec.gzip;
public class GzipCodec implements Codec {
@Override public String name() { return "gzip"; }
@Override public byte[] encode(byte[] raw) { /* 省略实现 */ return raw; }
}
# 3) 提供方 jar 内的文件(路径与文件名都必须精确匹配接口全限定名)
src/main/resources/META-INF/services/com.example.codec.Codec
内容(UTF-8,一行一个实现类,可有 # 注释):
com.example.codec.gzip.GzipCodec
// 4) 框架侧:运行期加载
ServiceLoader<Codec> loader = ServiceLoader.load(Codec.class);
for (Codec c : loader) {
if ("gzip".equals(c.name())) {
return c;
}
}
throw new IllegalStateException("no codec found");
ServiceLoader 的行为:扫描所有 classpath 下同名配置文件,逐个 Class.forName 并调用无参构造器实例化;它实现了 Iterable,遍历时才真正加载(延迟),但一旦遍历就会实例化全部实现。
4. SPI 的经典坑
| 坑 | 说明 | 解法 |
|---|---|---|
| 类加载器看不到实现 | 框架用自身类加载器加载,第三方 jar 在别处 | 用线程上下文类加载器(TCCL),JDBC 就是这么做的 |
| 无法按需/排序 | 只能全量遍历,无法指定优先级 | 用 @Order 或自研扩展加载器(Dubbo ExtensionLoader) |
| 配置写错难排查 | 文件名、包名拼错时静默无实现 | 启动时校验并打印已加载的实现列表 |
| 全量实例化开销 | 构造函数有成本时不划算 | 框架侧缓存实例,或用 Provider 延迟 get() |
| 与 Spring 冲突 | Spring 有自己的扩展机制 | Spring Boot 用 spring.factories / AutoConfiguration.imports,不是标准 SPI |
5. 常见追问
| 追问 | 回答要点 |
|---|---|
JDBC 为什么能只写 Class.forName? | JDK 6+ 起 DriverManager 用 SPI 自动发现,Class.forName 只是兼容旧代码的显式加载 |
| 上下文类加载器怎么打破双亲委派? | Thread.currentThread().getContextClassLoader() 由调用方设置,让父加载器加载的框架代码能看到子加载器里的实现 |
| JDK 9 模块化后怎么写? | module-info.java 里 provides com.example.Codec with com.example.GzipCodec;,消费方 uses com.example.Codec; |
| SPI 和 API 能同时存在吗? | 能,通常对外是 API、对内是 SPI,例如 Spring 的 BeanPostProcessor 既是 API 也是扩展点 |
为什么配置文件必须放 META-INF/services? | 这是 ServiceLoader 约定的扫描路径,文件名必须是接口的二进制名 |
| SPI 能保证线程安全吗? | 不能,ServiceLoader 本身不是线程安全的,且实例通常是新对象 |
小结
API 是"调用",SPI 是"被发现"。判断一段代码是不是 SPI,看三点:接口由使用方定义、实现由第三方提供、通过配置在运行期发现。回答时补一句"JDBC 用 TCCL 解决类加载器可见性",基本就完整了。