API 和 SPI 两个词都以 Interface 结尾,很容易被当成同一种东西。它们确实都是接口,但描述的是两个方向的关系:API 是我们去调用别人实现好的能力,SPI 是别人定好接口、我们来提供实现,等着在运行时被找出来。
两行代码就能看出区别。调用 API:
java
List<String> list = new ArrayList<>();
list.add("a");
写一个 SPI 实现:
java
public class EnglishGreeter implements Greeter {
public String greet() { return "hello"; }
}
前者我们主动依赖 List、ArrayList;后者我们的类反过来依赖一个别人定好的 Greeter,至于谁会来调它,我们并不知道。
API
API(Application Programming Interface)是一组已经实现好的类和方法,写业务代码时直接调。String.format、Map.get、ObjectMapper.readValue 都是 API。
它的特点是调用方在编译期就知道要调什么。类名、方法名、参数类型全都看得见,IDE 能补全,写错了编译不过。库这边对使用者有明确的承诺:这个方法叫什么、收什么、返回什么、抛什么异常。
依赖方向是单边的:我们的代码 → 库的代码。
SPI
SPI(Service Provider Interface)把方向掉过来了。接口由使用方(通常是 JDK 或者框架)定义,实现由第三方 jar 提供,使用方在运行时才去找有哪些实现。
最典型的是 java.sql.Driver。这个接口在 JDK 里,DriverManager 也是 JDK 写的,但它们都不知道 MySQL、PostgreSQL 的驱动长什么样。驱动 jar 负责实现 Driver,部署到 classpath 上,DriverManager 启动的时候自己去发现。
编译框架的时候,它只认识
Driver这个接口;具体跑的是哪个驱动,要等 JVM 起来、classpath 定下来之后才知道。这就是常说的依赖倒置:高层模块不依赖低层实现,两边都依赖接口。
SPI 对扩展方的约定不是「你能调用什么」,而是「你要实现什么、怎么声明自己是实现」。
ServiceLoader
JDK 自带的 SPI 机制只有一个类:java.util.ServiceLoader。我们写个小例子跑一遍。
接口:
java
package com.example.spi;
public interface Greeter {
String greet();
}
两个实现:
java
package com.example.impl;
public class EnglishGreeter implements Greeter {
public String greet() { return "hello"; }
}
java
package com.example.impl;
public class ChineseGreeter implements Greeter {
public String greet() { return "你好"; }
}
调用方完全不知道这两个类的存在:
java
ServiceLoader<Greeter> loader = ServiceLoader.load(Greeter.class);
int n = 0;
for (Greeter g : loader) {
System.out.println(g.getClass().getName() + " -> " + g.greet());
n++;
}
System.out.println("loaded = " + n);
光有类还不够,得告诉 ServiceLoader 去哪找实现。在 classpath 上放一个文件:
text
META-INF/services/com.example.spi.Greeter
文件名就是接口的全限定名,内容一行一个实现类:
text
com.example.impl.EnglishGreeter
com.example.impl.ChineseGreeter
跑起来:
text
com.example.impl.EnglishGreeter -> hello
com.example.impl.ChineseGreeter -> 你好
loaded = 2
把那个文件删掉,同样的一段 main 输出是 loaded = 0,一行实现都不加载。接口和实现之间唯一的联系就是那个文本文件。
ServiceLoader 内部做的事并不神秘:沿着 classpath 把所有同名文件找出来,读每一行的类名,Class.forName 拿到 Class,再 getDeclaredConstructor().newInstance() 造对象。所以实现类必须是 public 的,必须有无参构造器。这整条链路就是反射的应用。
Java 9 之后模块化项目不用这个文件了,改成在 module-info.java 里声明:
java
provides com.example.spi.Greeter with com.example.impl.EnglishGreeter;
现实里的 SPI
JDBC 是最标准的例子。JDBC 4.0 之后,驱动 jar 里带一个 META-INF/services/java.sql.Driver,内容是 com.mysql.cj.jdbc.Driver 这样的类名,DriverManager 用 ServiceLoader 把它们全找出来。
早年写
Class.forName("com.mysql.jdbc.Driver")是为了触发驱动类静态块里的注册逻辑。有了 SPI 之后这一步省掉了,现在不写forName也能连上数据库。反射那篇里讲forName会触发类初始化,说的就是那个年代的事。
SLF4J 也是。我们的代码只依赖 org.slf4j.Logger,具体是 logback 还是 log4j2,由 LoggerFactory 在启动时通过 SPI 找出来。换实现只换 jar,代码一行不改。
两者的关系
同一个库里往往两个都有,以 JDBC 为例:
| 角色 | 例子 | 谁定义 | 谁实现 | 谁调用 |
|---|---|---|---|---|
| API | Connection、Statement |
JDK | 驱动厂商 | 我们的业务代码 |
| SPI | Driver |
JDK | 驱动厂商 | DriverManager |
Connection 是我们主动调的,所以是 API;Driver 是 DriverManager 在运行时找出来调的,所以是 SPI。两者都定义在 java.sql 里、都由驱动厂商实现,区别只在于调用是谁发起的、发生在什么阶段。
| API | SPI | |
|---|---|---|
| 依赖方向 | 调用方 → 实现方 | 实现方 → 接口 ← 调用方 |
| 绑定时机 | 编译期 | 运行期 |
| 谁写实现 | 库/框架 | 第三方 |
| 典型 | List、Connection |
java.sql.Driver、SLF4J provider |
SPI 的限制
ServiceLoader 只解决「找到所有实现」,再往下的问题它不管:
- 不能按名字挑。
load()拿到的是一串实现,要么全用,要么自己在外面再选。Dubbo 的ExtensionLoader就是在ServiceLoader思路上加了一层:给每个实现起个名字,用@SPI("dubbo")标默认实现,按名字取。 - 没有优先级。 加载顺序取决于 classpath 上文件里写的顺序,
ServiceLoader不提供排序。 - 只能无参构造。 实现类必须 public + 无参构造器,想注入依赖得自己套一层。
- 实例作用域是 loader 级的。 每次
ServiceLoader.load()都返回新的 loader,各自缓存自己的实例;同一个 loader 迭代两次拿到的是同一批对象,跨 loader 就不是了。
所以 ServiceLoader 本身只是一个很轻的发现机制,框架要做按需选择、带优先级的扩展点,都会在它上面再包一层。