Skip to content

Java SPI 实战:ServiceLoader 的正确打开方式(含类加载器坑)

这是偏门但很实用的一篇:插件化、SPI、可插拔架构都绕不开 ServiceLoader

先说结论

ServiceLoader 能跑起来的关键有三件事:

  1. META-INF/services 文件必须放在实现类所在的 jar
  2. 加载时要使用正确的 ClassLoader
  3. 同一个 SPI 最好只有一个默认实现,否则顺序不可控

一、最小可用的 SPI 示例

1) 定义接口(SPI)

java
public interface PayStrategy {
    String name();
    void pay(int amount);
}

2) 实现类

java
public class WechatPay implements PayStrategy {
    @Override
    public String name() { return "wechat"; }
    @Override
    public void pay(int amount) { /* ... */ }
}

3) 注册文件

路径:META-INF/services/全限定接口名

文件内容:

com.example.pay.WechatPay

4) 加载

java
ServiceLoader<PayStrategy> loader = ServiceLoader.load(PayStrategy.class);
for (PayStrategy s : loader) {
    System.out.println(s.name());
}

二、最容易踩的 5 个坑

1) 注册文件放错 jar

接口在 api.jar,实现类在 impl.jar
META-INF/services/... 必须跟着 实现类 的 jar 走。

2) 类加载器不对

在容器/插件体系里经常出现:
ServiceLoader.load() 找不到实现类。

解决方式:明确传入 ClassLoader。

java
ClassLoader cl = Thread.currentThread().getContextClassLoader();
ServiceLoader<PayStrategy> loader = ServiceLoader.load(PayStrategy.class, cl);

3) 多实现顺序不可控

ServiceLoader 的顺序与 jar 扫描顺序相关,不能依赖
如果有默认实现,建议按 name() 显式选择。

4) 实现类没有无参构造

ServiceLoader 通过反射创建实例,必须要无参构造。

5) 打包时资源被过滤

某些构建工具会把 META-INF/services 过滤掉,
最终运行时找不到任何实现。


三、一个“够用”的选择器写法

java
public class PayStrategyFactory {
    private static final Map<String, PayStrategy> CACHE = new HashMap<>();

    static {
        ServiceLoader<PayStrategy> loader =
            ServiceLoader.load(PayStrategy.class);
        for (PayStrategy s : loader) {
            CACHE.put(s.name(), s);
        }
    }

    public static PayStrategy get(String name) {
        return CACHE.get(name);
    }
}

这样你就能按业务类型拿到对应实现,而不是靠加载顺序“碰运气”。


四、排查清单(实战版)

如果 SPI 加载失败,我通常按这个顺序排:

  1. META-INF/services 文件是否打进 jar
  2. 文件内容是否是实现类全限定名
  3. 实现类是否在 classpath
  4. ClassLoader 是否正确
  5. 是否存在多个 jar 提供同名实现

最后总结

ServiceLoader 很轻,但坑不少。
它真正适合的场景是:少量可插拔实现 + 简单配置

只要把注册文件、类加载器和实现选择这三件事做好,
SPI 就能很稳定地跑起来。