Featured image of post 理解 Java SPI:JDBC、SLF4J、Spring Boot 背后的机制

理解 Java SPI:JDBC、SLF4J、Spring Boot 背后的机制

ServiceLoader 是怎么工作的,Dubbo / Spring Boot 怎么把 SPI 改造成更强大的版本——一篇看懂 Java SPI

一个看似平淡的问题

写过 Java 的人都用过 JDBC:

1
2
Class.forName("com.mysql.cj.jdbc.Driver");   // 早期写法
Connection conn = DriverManager.getConnection(url);

但不少人没想过——DriverManager 怎么知道要去加载 MySQL 的驱动? 它代码里又没写 import com.mysql...

你换成 PostgreSQL 时,只要 pom 里依赖换一下,DriverManager 就自动识别了 PG 的驱动——没改一行 Java 代码。

这背后的机制叫 SPI(Service Provider Interface)。它是 JDK 留给框架开发者的"扩展点",让框架定义接口、第三方提供实现、运行时自动发现并加载。

理解 SPI 是看懂 JDBC、Servlet、SLF4J、Spring Boot 自动装配、Dubbo 等所有"自动加载实现"框架的钥匙。


一、SPI 在做什么

API 与 SPI 的对比:

API(Application Programming Interface)SPI(Service Provider Interface)
调用方向应用调框架框架调应用
谁定义接口框架框架
谁实现接口框架应用 / 第三方
例子Math.max() / List.add()JDBC Driver / SLF4J Logger 实现 / 编解码器

最简洁的理解:API 是『你调我』,SPI 是『我调你』


二、JDK 原生 SPI:ServiceLoader

JDK 提供的最朴素 SPI 工具——java.util.ServiceLoader。三步:

1. 框架定义接口

1
2
3
4
5
package org.example.payment;
public interface PaymentChannel {
    String getName();
    boolean charge(String userId, BigDecimal amount);
}

2. 应用/第三方写实现

1
2
3
4
5
package com.alipay.spi;
public class AlipayChannel implements PaymentChannel {
    @Override public String getName() { return "alipay"; }
    @Override public boolean charge(String u, BigDecimal a) { ... }
}

3. 在 META-INF/services 下声明

文件名:META-INF/services/org.example.payment.PaymentChannel

文件内容:

1
2
com.alipay.spi.AlipayChannel
com.wechat.spi.WechatChannel

框架加载所有实现

1
2
3
4
ServiceLoader<PaymentChannel> loader = ServiceLoader.load(PaymentChannel.class);
for (PaymentChannel c : loader) {
    System.out.println("found: " + c.getName());
}

只要 classpath 里有对应实现的 jar,就自动被发现——这就是 JDBC 不需要写 Class.forName 也能加载驱动的秘密。


三、JDBC 4.0 后的 SPI 实践

JDBC 4.0 之前,每次都要 Class.forName("com.mysql.jdbc.Driver")——靠类初始化触发驱动注册

1
2
3
4
// MySQL Driver 类内部
static {
    DriverManager.registerDriver(new Driver());
}

JDBC 4.0 之后,MySQL 驱动 jar 里加了:

1
2
META-INF/services/java.sql.Driver
└─ com.mysql.cj.jdbc.Driver

DriverManager 启动时用 ServiceLoader 自动找所有 Driver 实现,Class.forName 这行代码就成了历史遗物


四、ServiceLoader 的几个限制

1. 加载所有实现,不能选择性加载

ServiceLoader.load()实例化所有实现——10 个支付通道都用不上你只想要支付宝?没办法,全部都会被 new 出来。

2. 实现必须有无参构造器

1
2
public AlipayChannel(String appId) { ... }   // ❌ 不行
public AlipayChannel() { ... }               // ✓

需要参数?只能后续 setter 注入。

3. 没有"按 key 取实现"的能力

业务里通常是"知道用户选了 alipay,加载支付宝实现"。ServiceLoader 只能一股脑把所有实现都返回,再让你自己按 getName() 过滤。

4. 不支持 IoC 注入

实现里如果想 @Autowired 其他 Bean?不行——ServiceLoader 不知道 Spring 容器存在。

5. 类加载器问题

线程上下文 ClassLoader 是个老大难——容器化部署、OSGi 等场景下 ServiceLoader.load() 看不到某些 jar 的实现。


五、Dubbo SPI:JDK SPI 的强化版

Dubbo 团队觉得 JDK SPI 不够用,自己写了一套 SPI——位置一样在 META-INF/services / META-INF/dubbo 下,但能力翻倍。

文件格式:key = 实现类

1
2
3
4
# META-INF/dubbo/org.apache.dubbo.rpc.Protocol
dubbo = org.apache.dubbo.rpc.protocol.dubbo.DubboProtocol
http  = org.apache.dubbo.rpc.protocol.http.HttpProtocol
hessian = org.apache.dubbo.rpc.protocol.hessian.HessianProtocol

按 key 取实现

1
2
ExtensionLoader<Protocol> loader = ExtensionLoader.getExtensionLoader(Protocol.class);
Protocol dubbo = loader.getExtension("dubbo");        // ★ 按 key 加载

不需要把所有实现都 new 一遍。

自适应扩展(Adaptive)

最强大的特性——根据运行时 URL 参数自动选择实现

1
2
3
4
5
@Adaptive
public interface Protocol {
    Exporter export(Invoker invoker) throws RpcException;
    Invoker refer(Class type, URL url);
}

调用时根据 url.protocol 决定具体走哪个实现,业务代码完全不感知。这是 Dubbo 路由、协议适配、序列化适配等很多机制的底座。

依赖注入

Dubbo SPI 加载实现时会自动注入它依赖的其他 SPI 实现——有简化版 IoC

1
2
3
4
public class DubboProtocol implements Protocol {
    private Codec codec;       // 自动按类型注入
    public void setCodec(Codec codec) { this.codec = codec; }
}

IOC + AOP

加上 Wrapper 机制,能在所有实现外包一层"拦截器"——这就是 Dubbo 链式 Filter 的原理。

JDK SPIDubbo SPI
按 key 加载
懒加载实现
依赖注入
AOP 包装
自适应扩展

六、Spring Factories:Spring 自己的 SPI

Spring Boot 也实现了类似的 SPI——spring.factories

1
2
3
4
5
6
# META-INF/spring.factories
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
  com.example.MyAutoConfiguration

org.springframework.boot.env.EnvironmentPostProcessor=\
  com.example.MyEnvProcessor

Spring Boot 启动时会扫描所有 jar 的 spring.factories,按 key 加载对应类——这就是 starter 自动装配的本质

Spring Boot 2.7 后逐步迁移到 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports,理由是按"接口名 = 文件名"的方式更清晰。

总结这几套 SPI 的演进:


七、写一个自己的 SPI 框架(极简版)

理解 SPI 最快的方式是自己写一个。来个最小可工作版本:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public class MySpi {
    private static final Map<Class<?>, Map<String, Object>> CACHE = new HashMap<>();

    public static <T> T load(Class<T> type, String name) {
        Map<String, Object> map = CACHE.computeIfAbsent(type, MySpi::loadAll);
        return (T) map.get(name);
    }

    private static Map<String, Object> loadAll(Class<?> type) {
        Map<String, Object> map = new HashMap<>();
        try {
            String path = "META-INF/myspi/" + type.getName();
            Enumeration<URL> urls = MySpi.class.getClassLoader().getResources(path);
            while (urls.hasMoreElements()) {
                try (BufferedReader br = new BufferedReader(
                        new InputStreamReader(urls.nextElement().openStream()))) {
                    String line;
                    while ((line = br.readLine()) != null) {
                        // 格式:key=class
                        String[] kv = line.split("=", 2);
                        Class<?> impl = Class.forName(kv[1].trim());
                        map.put(kv[0].trim(), impl.getDeclaredConstructor().newInstance());
                    }
                }
            }
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
        return map;
    }
}

使用:

1
2
3
# META-INF/myspi/org.example.PaymentChannel
alipay = com.alipay.AlipayChannel
wechat = com.wechat.WechatChannel
1
2
PaymentChannel c = MySpi.load(PaymentChannel.class, "alipay");
c.charge(...);

加上"懒加载 + IoC + AOP"就是 Dubbo 那一套——核心思想都是"配置文件声明 + ClassLoader 加载"


八、SPI 的常见踩坑

1. 文件路径写错

META-INF/services/全限定接口名——文件名要完整包名,不是简单名字。少一个 org.example. 就什么都加载不到。

2. 文件 BOM

UTF-8 with BOM 在某些 JDK 版本里会被读成"乱码包名"。用纯 UTF-8 不带 BOM 保存。

3. 实现类必须 public 无参构造器

AbstractXxxFactory 这种抽象类、private 构造器、没有无参构造器都加载失败。

4. 类加载器隔离

容器化部署时,框架代码可能用不同的 ClassLoader——ServiceLoader.load(Class) 默认用调用线程的 ContextClassLoader,复杂场景要显式指定。

5. SPI 失败静默

ServiceLoader 加载失败默认抛出 ServiceConfigurationError,但异常往往被框架吞了。生产排查时需要打开 SPI 加载的 DEBUG 日志。


九、什么场景该自己用 SPI

不要为了"看起来高级"而用 SPI——只有真正需要"框架代码不知道实现,但运行时能加载"时才值得

✅ 适合 SPI 的场景:

  • 写一个插件式框架——核心库定义接口,第三方提供实现
  • 写一个SDK——支持多种序列化、多种存储后端
  • 写一个多通道适配器——支付、消息推送、对象存储

❌ 不适合 SPI 的场景:

  • 业务逻辑分发——直接用 Map<String, Strategy> 注入更清晰
  • 简单的策略模式——枚举 + 工厂方法更简单
  • 编译时已知所有实现——用接口注入就够了

记住一条:"SPI 是给框架开发者的工具"——业务开发用 Spring 的 Map<String, T> 注入或者一个普通工厂类就够了。


小结

把全文压一句:

SPI 把"框架 → 实现"的依赖反转过来——框架定义接口,运行时由配置文件决定加载哪些实现。

理解 SPI 的几个层次:

  1. JDK ServiceLoader:最朴素,全加载
  2. Spring Factories:按 key 配置,能自动装配
  3. Dubbo SPI:加上 IoC、AOP、自适应——可扩展性的天花板

工程上记住:

  • 写框架/SDK 时考虑 SPI
  • 业务里别滥用,简单策略用 Map 注入更清爽
  • 文件路径、命名、字符编码三个坑特别多

把 SPI 看懂,你看 JDBC、Servlet、SLF4J、Spring Boot 自动装配的底层原理时会突然有种『原来都是这一套』的顿悟感

使用 Hugo 构建
主题 StackJimmy 设计