Java SPI机制全解析:ServiceLoader源码与扩展点设计思想
Java SPI机制全解析:ServiceLoader源码与扩展点设计思想
适读人群:想深入理解Java插件化、框架扩展点设计的开发者 | 阅读时长:约17分钟
开篇故事
刚工作那两年,我一直搞不清楚 JDBC 为什么这么神奇。代码里写 Class.forName("com.mysql.cj.jdbc.Driver"),然后就能连 MySQL;把依赖换成 PostgreSQL 的驱动包,什么都不用改,就能连 PostgreSQL。
后来有个同事说了句话点醒了我:"JDBC 只定义了接口,驱动包是实现,你加哪个驱动包,它就用哪个——这就是 SPI。"
当时的我只是一知半解地点了点头。直到后来真的去翻 java.util.ServiceLoader 的源码,翻 META-INF/services/java.sql.Driver 文件,才真正理解了什么叫"面向接口编程的极致形态"。
SPI 是 Java 生态里大量框架的基础设施:JDBC、日志门面(SLF4J)、Spring Boot 自动装配、Dubbo 扩展……都用到了 SPI 或其变种。搞懂它,你就能看穿大多数"魔法"背后的真相。
一、SPI 是什么,解决了什么问题
1.1 API vs SPI
这两个概念经常被混淆:
| 概念 | 方向 | 例子 |
|---|---|---|
| API(Application Programming Interface) | 框架 → 调用方 | java.util.List,调用方使用 |
| SPI(Service Provider Interface) | 框架 ← 实现方 | java.sql.Driver,驱动包实现 |
API 是你调用别人提供的,SPI 是你实现别人定义的接口,让框架来调用你。
1.2 没有 SPI 的世界
假设没有 SPI,JDBC 要支持 MySQL 和 Oracle,代码可能是这样的:
// 反模式:硬编码实现
public Connection getConnection(String dbType) {
if ("mysql".equals(dbType)) {
return new MysqlConnection(); // 强耦合
} else if ("oracle".equals(dbType)) {
return new OracleConnection(); // 每加一种数据库都要改这里
}
throw new RuntimeException("不支持的数据库类型");
}这违背了开闭原则,每次新增数据库类型都要修改框架代码。SPI 的出现让框架和实现完全解耦。
二、ServiceLoader 源码解析
2.1 工作流程
2.2 核心源码精读
ServiceLoader 的源码在 java.util.ServiceLoader,JDK 11 以后有较大重构,我们以 JDK 8 的版本为例分析核心逻辑:
// ServiceLoader.java 核心逻辑(简化版)
public final class ServiceLoader<S> implements Iterable<S> {
// SPI文件存放的前缀目录
private static final String PREFIX = "META-INF/services/";
// 服务接口
private final Class<S> service;
// 类加载器
private final ClassLoader loader;
// 已加载的服务缓存(按类名存储,避免重复加载)
private LinkedHashMap<String, S> providers = new LinkedHashMap<>();
// 懒加载迭代器
private LazyIterator lookupIterator;
// 核心加载方法
public static <S> ServiceLoader<S> load(Class<S> service) {
// 使用线程上下文类加载器,解决父加载器无法加载子类的问题
ClassLoader cl = Thread.currentThread().getContextClassLoader();
return ServiceLoader.load(service, cl);
}
// LazyIterator:真正的懒加载逻辑
private class LazyIterator implements Iterator<S> {
Class<S> service;
ClassLoader loader;
Enumeration<URL> configs = null;
Iterator<String> pending = null;
String nextName = null;
private boolean hasNextService() {
if (nextName != null) {
return true;
}
if (configs == null) {
try {
// 拼接SPI文件路径,例如 META-INF/services/java.sql.Driver
String fullName = PREFIX + service.getName();
if (loader == null) {
configs = ClassLoader.getSystemResources(fullName);
} else {
configs = loader.getResources(fullName);
}
} catch (IOException x) {
fail(service, "Error locating configuration files", x);
}
}
while ((pending == null) || !pending.hasNext()) {
if (!configs.hasMoreElements()) {
return false;
}
// 解析SPI配置文件,读取实现类名列表
pending = parse(service, configs.nextElement());
}
nextName = pending.next();
return true;
}
private S nextService() {
if (!hasNextService()) throw new NoSuchElementException();
String cn = nextName;
nextName = null;
Class<?> c = null;
try {
// 用类加载器加载实现类
c = Class.forName(cn, false, loader);
} catch (ClassNotFoundException x) {
fail(service, "Provider " + cn + " not found");
}
if (!service.isAssignableFrom(c)) {
fail(service, "Provider " + cn + " not a subtype");
}
try {
// 实例化(必须有无参构造器)
S p = service.cast(c.newInstance());
providers.put(cn, p);
return p;
} catch (Throwable x) {
fail(service, "Provider " + cn + " could not be instantiated", x);
}
throw new Error();
}
}
}2.3 几个关键设计点
线程上下文类加载器(Thread Context ClassLoader)
这是 SPI 里最微妙的一个设计。Java 的双亲委派模型里,Bootstrap ClassLoader 加载 JDK 核心类,Application ClassLoader 加载应用类。
问题是:java.sql.Driver 接口在 rt.jar(Bootstrap 加载),而 MySQL 驱动实现在应用的 classpath 里(App ClassLoader 加载)。ServiceLoader 在 JDK 核心包里,默认用 Bootstrap ClassLoader——它根本看不到 MySQL 驱动。
解决方案:通过 Thread.currentThread().getContextClassLoader() 拿到应用的类加载器,从而突破双亲委派的限制。这是 Java 里少数几个"合理破坏双亲委派"的场景之一。
三、完整代码实现
3.1 场景:可插拔的消息推送渠道
我们做一个支持微信、钉钉、企业微信三种推送方式的通知系统,用 SPI 实现可插拔。
项目结构:
notification-demo/
├── notification-api/ # 只有接口,无实现
│ └── src/main/java/com/laozhang/notification/
│ └── NotificationSender.java
├── notification-wechat/ # 微信实现
│ └── src/main/java/com/laozhang/notification/wechat/
│ └── WechatNotificationSender.java
│ └── src/main/resources/META-INF/services/
│ └── com.laozhang.notification.NotificationSender
└── notification-app/ # 主程序,不依赖具体实现接口定义(notification-api 模块):
package com.laozhang.notification;
/**
* 通知发送接口
* 这是 SPI 的核心:接口在 API 模块,实现在各自的独立模块
*/
public interface NotificationSender {
/**
* 发送类型标识,用于区分不同实现
*/
String type();
/**
* 发送消息
*
* @param target 接收方标识(openId、userId等)
* @param title 消息标题
* @param content 消息内容
* @return 是否发送成功
*/
boolean send(String target, String title, String content);
/**
* 是否支持富文本(Markdown)
*/
default boolean supportsRichText() {
return false;
}
}微信实现(notification-wechat 模块):
package com.laozhang.notification.wechat;
import com.laozhang.notification.NotificationSender;
/**
* 微信公众号消息推送实现
*/
public class WechatNotificationSender implements NotificationSender {
@Override
public String type() {
return "wechat";
}
@Override
public boolean send(String target, String title, String content) {
// 实际项目里这里调用微信API
System.out.printf("[WeChat] 发送给 %s: %s - %s%n", target, title, content);
return true;
}
@Override
public boolean supportsRichText() {
return true;
}
}notification-wechat 模块的 SPI 注册文件: src/main/resources/META-INF/services/com.laozhang.notification.NotificationSender
com.laozhang.notification.wechat.WechatNotificationSender钉钉实现(notification-dingtalk 模块):
package com.laozhang.notification.dingtalk;
import com.laozhang.notification.NotificationSender;
public class DingtalkNotificationSender implements NotificationSender {
@Override
public String type() {
return "dingtalk";
}
@Override
public boolean send(String target, String title, String content) {
System.out.printf("[DingTalk] 发送给 %s: %s - %s%n", target, title, content);
return true;
}
}notification-dingtalk 模块的 SPI 注册文件: src/main/resources/META-INF/services/com.laozhang.notification.NotificationSender
com.laozhang.notification.dingtalk.DingtalkNotificationSender主程序:SPI 加载与策略选择(notification-app 模块):
package com.laozhang.notification.app;
import com.laozhang.notification.NotificationSender;
import java.util.HashMap;
import java.util.Map;
import java.util.ServiceLoader;
/**
* 通知服务工厂
* 通过 SPI 自动发现所有可用的通知实现,按 type 做路由
*/
public class NotificationFactory {
// 按类型索引的发送器 Map
private final Map<String, NotificationSender> senders = new HashMap<>();
public NotificationFactory() {
// ServiceLoader 扫描 classpath 里所有实现了 NotificationSender 的类
ServiceLoader<NotificationSender> loader =
ServiceLoader.load(NotificationSender.class);
for (NotificationSender sender : loader) {
senders.put(sender.type(), sender);
System.out.println("发现通知实现:" + sender.type()
+ " -> " + sender.getClass().getName());
}
}
/**
* 获取指定类型的发送器
*/
public NotificationSender getSender(String type) {
NotificationSender sender = senders.get(type);
if (sender == null) {
throw new IllegalArgumentException("不支持的通知类型:" + type);
}
return sender;
}
/**
* 向所有渠道广播
*/
public void broadcast(String target, String title, String content) {
senders.values().forEach(sender ->
sender.send(target, title, content)
);
}
}3.2 Java 9+ 的 ServiceLoader 新特性
Java 9 对 ServiceLoader 做了增强,支持模块系统,并提供了更方便的 API:
// Java 9+ 新增的 stream() 方法,避免提前实例化
ServiceLoader<NotificationSender> loader =
ServiceLoader.load(NotificationSender.class);
// 不实例化,只扫描提供者,按条件过滤后再实例化
Optional<NotificationSender> wechatSender = loader.stream()
.filter(provider -> {
// Provider.type() 返回的是类对象,可以读注解
return provider.type().isAnnotationPresent(PrimaryChannel.class);
})
.map(ServiceLoader.Provider::get) // 这里才实例化
.findFirst();
// 也支持直接找第一个
Optional<NotificationSender> first = loader.findFirst();四、踩坑实录
坑1:SPI 文件名必须是接口的全限定类名
症状:ServiceLoader 加载时一个实现都找不到。
根因:META-INF/services/ 目录下的文件名必须和接口的完整类名完全一致,包括包名里的.都不能变成/。
错误:META-INF/services/NotificationSender
错误:META-INF/services/com/laozhang/notification/NotificationSender
正确:META-INF/services/com.laozhang.notification.NotificationSender坑2:实现类必须有公共无参构造器
症状:ServiceLoader 在实例化时报 InstantiationException。
根因:ServiceLoader 内部通过 newInstance() 反射创建实例,要求实现类有 public 的无参构造器。
解决:如果实现类需要注入依赖,可以用单例懒加载模式:
public class WechatNotificationSender implements NotificationSender {
// 通过静态方法或工厂方法获取配置,不通过构造器注入
private final String appId;
private final String appSecret;
// 必须有public无参构造器
public WechatNotificationSender() {
// 从环境变量或配置文件读取
this.appId = System.getenv("WECHAT_APP_ID");
this.appSecret = System.getenv("WECHAT_APP_SECRET");
}
}坑3:ServiceLoader 不是线程安全的
症状:多线程环境下并发调用 ServiceLoader.iterator() 偶发 ConcurrentModificationException。
根因:ServiceLoader 的内部 providers 是 LinkedHashMap,没有同步。
解决:在应用启动时加载一次,存入线程安全的容器:
@Bean
public NotificationFactory notificationFactory() {
// Spring容器里是单例,只加载一次
return new NotificationFactory();
}
// NotificationFactory内部用ConcurrentHashMap存储
private final Map<String, NotificationSender> senders = new ConcurrentHashMap<>();坑4:classpath 里有多个 JAR 都有同名 SPI 文件
症状:引入了两个不同的 SMS 实现,但每次 ServiceLoader.load() 只加载到其中一个。
实际情况:不是只加载一个——ServiceLoader 会合并所有 JAR 里同名 SPI 文件的内容。如果 JAR-A 和 JAR-B 都有 META-INF/services/com.laozhang.sms.SmsClient,两个文件里的实现类都会被加载。
所以如果你确实只想用一个,需要在应用层面过滤,而不是依赖 SPI 的加载机制。
坑5:SPI 的加载顺序不可靠
症状:本地测试用的是某个实现,部署到 CI 环境后换了另一个实现(因为 JAR 顺序变了)。
根因:ServiceLoader 加载多个实现时,顺序取决于 classpath 里 JAR 的顺序,这在不同环境下可能不同。
解决方案:不要依赖 SPI 的加载顺序,改用 @Priority 注解 + 手动排序,或者在实现类上加 @Order(Spring 场景),在工厂里按优先级选择:
// 实现类上加自定义注解标记优先级
@SpiPriority(10) // 数字越小优先级越高
public class AliyunSmsClient implements SmsClient { ... }
@SpiPriority(20)
public class TencentSmsClient implements SmsClient { ... }
// 加载时按优先级排序
ServiceLoader<SmsClient> loader = ServiceLoader.load(SmsClient.class);
List<SmsClient> clients = new ArrayList<>();
loader.forEach(clients::add);
clients.sort(Comparator.comparingInt(c ->
c.getClass().getAnnotation(SpiPriority.class).value()
));
SmsClient primaryClient = clients.get(0);五、总结与延伸
Java SPI 是一个设计简单但威力巨大的机制:
- 核心思想:接口和实现分离,通过约定好的文件路径(
META-INF/services/接口全限定名)实现运行时发现 - 本质:是一种控制反转,调用方不再
new实现类,而是由ServiceLoader来"注入" - 局限:不支持按条件加载、不支持懒加载(JDK 8)、不支持依赖注入
正因为这些局限,Spring 做了 SpringFactoriesLoader(下一篇主题),Dubbo 做了 ExtensionLoader(457篇),都是在 Java SPI 的思想基础上的增强版本。
读懂 Java SPI,再看这些增强版本,你会发现思路其实是一脉相承的。
