原创

用 ServiceLoader 做多 Jar 插件:从服务声明到发现失败

应用只依赖接口,换一个 Jar 就增加一种实现,这件事用 ServiceLoader 可以做得很小。难处通常出在打包以后:实现类能直接 new,自动发现却一项也没有;或者本地找到两个,合成胖 Jar 后只剩一个。

下面做一个文本编码插件程序。接口、十六进制实现、Base64 实现、主程序分别打成四个 Jar。主程序不导入两个实现类,也不通过包扫描猜测插件,发现关系由每个实现 Jar 的服务声明文件提供。

环境固定 JDK 17,使用普通 classpath,不使用 Java 模块系统。命令在 Bash 中执行,Linux 和 macOS 的类路径分隔符是冒号;Windows 命令行需要改成分号。源码、编译和打包都在新的 service-demo 目录进行,不要求安装 Maven。

四个 Jar 各放什么

目录如下。resources 中那两个没有扩展名的文件是关键,它们不是 .txt 配置,也不是放在源码包里的 Java 文件。

service-demo/
  api/src/demo/spi/TextCodec.java
  hex/src/demo/hex/HexCodec.java
  hex/resources/META-INF/services/demo.spi.TextCodec
  base64/src/demo/base64/Base64Codec.java
  base64/resources/META-INF/services/demo.spi.TextCodec
  app/src/demo/app/Main.java
  build.sh

接口只定义业务需要的能力:可用于路由的名称,以及编码动作。名称作为应用协议由我们自己决定,并不等于实现类名。

api/src/demo/spi/TextCodec.java:

package demo.spi;
public interface TextCodec {
    String name();
    String encode(String input);
}

hex/src/demo/hex/HexCodec.java:

package demo.hex;
import demo.spi.TextCodec;
import java.nio.charset.StandardCharsets;
import java.util.HexFormat;
public final class HexCodec implements TextCodec {
    public HexCodec() { }
    public String name() { return "hex"; }
    public String encode(String input) {
        return HexFormat.of().formatHex(input.getBytes(StandardCharsets.UTF_8));
    }
}

base64/src/demo/base64/Base64Codec.java:

package demo.base64;
import demo.spi.TextCodec;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
public final class Base64Codec implements TextCodec {
    public Base64Codec() { }
    public String name() { return "base64"; }
    public String encode(String input) {
        return Base64.getEncoder().encodeToString(input.getBytes(StandardCharsets.UTF_8));
    }
}

两个服务文件分别写入实现类的全限定名,一行一个。文件名却必须是接口的全限定名。把这两个位置写反,是“类都在,服务却没找到”的常见原因。

hex/resources/META-INF/services/demo.spi.TextCodec:

demo.hex.HexCodec

base64/resources/META-INF/services/demo.spi.TextCodec:

demo.base64.Base64Codec

主程序先建立明确的业务索引

主程序遍历发现结果,再按 name() 建立索引。它拒绝同名插件,而不是让最后一个实现静默覆盖前一个。输出使用 TreeMap 排序,结果不依赖类加载器发现 Jar 的先后顺序。

package demo.app;
import demo.spi.TextCodec;
import java.util.Map;
import java.util.ServiceLoader;
import java.util.TreeMap;
public final class Main {
    public static void main(String[] args) {
        Map<String, TextCodec> codecs = new TreeMap<>();
        for (TextCodec codec : ServiceLoader.load(TextCodec.class)) {
            if (codecs.putIfAbsent(codec.name(), codec) != null) {
                throw new IllegalStateException("duplicate codec name: " + codec.name());
            }
        }
        if (codecs.isEmpty()) throw new IllegalStateException("no providers");
        System.out.println("providers=" + codecs.size());
        codecs.forEach((name, codec) -> System.out.println(name + "=" + codec.encode("Hi")));
    }
}

不能把“找到的第一个”直接当成默认插件,再假设升级依赖后默认值仍然不变。发现顺序受加载路径等因素影响,它不是应用的业务优先级协议。若确实需要优先级,应把这个规则放进接口或配置中,并明确相同优先级怎样处理。

ServiceLoader 也不是每遍历到一个类名就立刻在启动时检查全部实现。发现与实例化具有惰性,错误可能发生在遍历时。因此程序选择在启动阶段完整建立索引,把打包错误提前暴露;如果只保存一个 loader,等第一笔请求进来才取值,失败时点也会跟着后移。

编译、打包、运行

build.sh 的完整内容:

#!/usr/bin/env bash
set -euo pipefail
mkdir -p build/api build/hex build/base64 build/app
javac --release 17 -d build/api api/src/demo/spi/TextCodec.java
jar --create --file build/api.jar -C build/api .
javac --release 17 -cp build/api.jar -d build/hex hex/src/demo/hex/HexCodec.java
jar --create --file build/hex.jar -C build/hex . -C hex/resources .
javac --release 17 -cp build/api.jar -d build/base64 base64/src/demo/base64/Base64Codec.java
jar --create --file build/base64.jar -C build/base64 . -C base64/resources .
javac --release 17 -cp build/api.jar -d build/app app/src/demo/app/Main.java
jar --create --file build/app.jar --main-class demo.app.Main -C build/app .
java -cp 'build/app.jar:build/api.jar:build/hex.jar:build/base64.jar' demo.app.Main

在 service-demo 目录执行:

bash build.sh

已经核对的输出为:

providers=2
base64=SGk=
hex=4869

实现模块的编译类路径里只有接口 Jar。主程序编译时同样只有接口 Jar;直到运行命令,两个实现 Jar 才同时出现。这才检验了插件边界。如果把四部分都编译进同一输出目录,程序能跑,却很难证明发布时哪一份产物必须携带什么文件。

还可以直接检查发布产物:

jar --list --file build/hex.jar
jar --list --file build/base64.jar

各自都应包含实现类和 META-INF/services/demo.spi.TextCodec。IDE 能看见资源目录,不代表打包插件一定把它放进 Jar。排查服务发现时,检查最终产物比反复清理 IDE 缓存更直接。

三个故障实验,比只跑成功路径有用

先保持所有文件不变,仅从运行类路径移除 base64.jar。程序应只输出十六进制实现。随后也移除 hex.jar,程序会因 no providers 失败。这说明接口存在与实现存在是两件事,空发现结果要由应用决定是可接受状态,还是启动错误。

再把十六进制声明文件中的实现类名写成不存在的名字,重新运行 bash build.sh。此时错误不再是简单的零实现,而会在发现过程中抛出 ServiceConfigurationError。这类错误指出部署关系已经损坏。不要用一个空的 catch 把它转换成“插件暂时不可用”,否则应用可能带着残缺能力对外服务。

第三个实验是把实现类显式声明的无参构造器改成 private,或者只保留带参数构造器。仅仅删除显式构造器不够:没有声明其他构造器的 public 类可能得到隐式的公共无参构造器。本文采用 classpath 服务声明的方式,提供者需要满足相应构造条件。不要把模块系统中支持的其他提供者形式混进这套命令,以为只要有个静态工厂就一定能加载。改回文件后重新构建,确认两个实现都恢复。

自动发现还没有解决热更新

ServiceLoader.load(TextCodec.class) 使用当前线程的上下文类加载器。在应用服务器、测试容器或插件隔离环境里,不同线程可能拥有不同加载视野。“主程序能加载接口”并不能证明当前加载器能看见实现声明。需要隔离加载时,选择带 ClassLoader 参数的入口,并明确接口由谁加载,避免两个同名接口来自不同类加载器,最终出现类型不相容。

reload() 清除的是 loader 的发现缓存,不会自动卸载旧类、关闭旧插件占用的连接,也不会替换业务已经保存的对象引用。若插件持有资源,应给能力接口额外设计生命周期,由插件管理器负责停止接收调用、等待在途任务并关闭旧实例。只替换磁盘上的 Jar 不构成完整更新流程。

胖 Jar 还有一个单独的打包问题:多个实现原本各有同路径服务文件,简单覆盖资源时可能只留下最后一份。合包工具需要按服务文件语义合并提供者,而不是任意选择一个文件。本教程保留四个独立 Jar,正是为了先看清这个边界。验证插件系统时,应把产物检查和实际启动加入交付步骤;源码能编译,只说明第一段关系成立。

还有一个值得单独保留的取舍:插件构造器最好只完成轻量初始化。发现阶段就发网络请求、启动线程或者修改全局状态,会让一次能力枚举变成不可预测的启动动作。需要连接外部系统时,可以先发现并验证元数据,再由显式启动方法完成资源获取;启动失败的插件应留下清楚的状态,而不是从列表中无声消失。接口越小,宿主程序就越容易把发现、选择和生命周期分别测试清楚。

正文到此结束
评论插件初始化中...
Loading...