site logo

Marico's space

让 ServiceLoader 更易用:提供者工厂

编程技术 2026-07-21 11:29:37 9

最近在项目里又绕回来了java.util.ServiceLoader。之前用它来实现一个 JSON 抽象层,让核心代码不直接依赖任何特定的 JSON 库,这样换实现的时候调用方完全不用动。同样的思路也用在 JWT 处理上——具体库可能是 jose4j 或者别的 JOSE 实现——还有各种解耦场景。动机始终不变:应用应该依赖一种能力,而不是依赖某个厂商。

之前写过一篇讲这个思路的文章,核心观点是把ServiceLoader当作能力发现机制,而不是插件系统。那篇文章遇到了所有人都踩过的坑——无参构造器——然后用默认构造器加动态代理绕过去了,每次调用都通过工厂构建真实对象。能用,但这是事后硬加的间接层,不是设计出来的。这篇文章要补完的是我一直没写清楚的那部分:把这个 workaround 变成一个小而明确的模式。

下面的示例是一个模拟支付系统,有支付宝和微信支付两个实现,足够紧凑能从头到尾展示清楚。JSON 和 JWT 的场景用同样的结构就能搭出来。

ServiceLoader 留给你的两个局限

第一个是无参构造器。ServiceLoader实例化的类必须有一个公开的、无参数的构造函数。我的支付宝支付服务需要 API 密钥,所以它不可能是ServiceLoader直接加载的类——除非在构造之后硬塞一个初始化步骤,这个我不太想干。

第二个是选择,或者说缺少选择。ServiceLoader把找到的所有实现都返回给你,大致按类路径顺序。没有 id、没有优先级、也没办法问某个实现是否在当前环境适用。类路径上放了两个后端但只配了一个,要判断用哪个是你的问题,不是加载器的问题。

Java 9 加了个静态provider()方法,ServiceLoader会调用它而不是构造器。我先试了这条路,后来放弃了。它能构建对象,没问题,但没有任何可选择的东西,而选择恰恰是重点所在。

加载工厂,而不是服务

思路是不要加载服务,而是加载一个小工厂。

public interface ServiceProvider<S> { String id(); // (1) default int priority() { return 0; } // (2) default boolean supports(Configuration configuration) { return true; } // (3) S create(Configuration configuration); // (4)
}
  1. 一个稳定的标识符,用来显式选择某个提供者
  2. 多个提供者都适用时,优先级高的胜出
  3. 提供者检查配置不存在时优雅退出
  4. 服务在这里构建,所以S可以有任何你需要的构造器

提供者是放在META-INF/services里的类型,所以提供者才需要简单的无参构造器。服务在create()内部构建,用它自己喜欢的任何构造器。这也比之前那篇文章里的代理更清晰:提供者是一个真实对象而不是替身,所以它可以携带ServiceLoader从来不给我们的元数据。一个 id,一个优先级,一个supports()检查让提供者在配置不存在时退出。如果你用过java.sql.Driver,这个结构很眼熟:驱动是连接的工厂。这就是同一个东西,只不过泛化了。

关键点,在真实类上展示

看实际的配对类比看抽象描述更清楚。服务用自己需要的构造器:

public final class StripePaymentService implements PaymentService { private final String apiKey; public StripePaymentService(String apiKey) { this.apiKey = apiKey; } @Override public Receipt pay(Order order) { /* uses apiKey */ }
}

提供者有无参构造器,而且服务是在提供者内部构建的:

public final class StripeProvider implements PaymentServiceProvider { @Override public String id() { return "stripe"; } @Override public int priority() { return 100; } @Override public boolean supports(Configuration cfg) { return cfg.get("payments.stripe.apiKey").isPresent(); // (1) } @Override public PaymentService create(Configuration cfg) { return new StripePaymentService(cfg.require("payments.stripe.apiKey")); // (2) }
}
  1. 没有配置支付宝密钥时,提供者干净利落地退出
  2. 直接new用真实构造器——不用反射,不用代理

注册到META-INF/services的也只有提供者:

META-INF/services/com.acme.payments.api.PaymentServiceProvider

com.acme.payments.stripe.StripeProvider

ServiceLoader只通过无参构造器构建StripeProviderStripePaymentService和它的 API 密钥是在create()里手动组装的。构造器限制落在了工厂上,在那里不花什么代价,而服务永远不受影响。这就是全部了,这也是这个模式存在的全部原因。

关于反射的一点说明

这也是这个模式比代理版本更强的地方。它没有去掉反射:ServiceLoader仍然通过无参构造器实例化每个提供者。但这只是每个提供者一次,在加载时。之后的每一步都是普通 Java。服务在create()里用直接new构建,调用直接打到它,没有动态代理、没有热路径上的Method.invoke——这些是之前代理方案无法避免的。在 GraalVM native image 下也有收益:唯一的反射面是需要注册的无参提供者构建,工具已经知道怎么处理,没有代理需要配置,真实服务通过普通构造器调用到达。

一个通用注册中心,加一个编译器报错

我想要一个注册中心能加载任何服务类型的提供者——包括 JSON 和 JWT,不只是支付。这时明显的写法编译不过:

ServiceLoader.load(ServiceProvider<PaymentService>.class) // does not compile

确实编译不了。参数化类型没有类字面量;类型擦除意味着ServiceProvider<PaymentService>.class根本不存在,而ServiceLoader.load需要一个真实的Class<P>。绕过去的方法是加一个标记接口,每个领域一个:

public interface PaymentServiceProvider extends ServiceProvider<PaymentService> { }

PaymentServiceProvider.class确实存在。它把类型参数钉死,让 uses 和 provides 子句保持可读性。这是泛型版本要求的唯一一点样板代码,我已经接受它了。

ServiceRegistry<PaymentService> registry = ServiceRegistry.load(PaymentServiceProvider.class);

内部实现是注册中心一次性构建提供者并按优先级排序:

List<P> loaded = ServiceLoader.load(providerType).stream() .map(ServiceLoader.Provider::get) // (1) .sorted(Comparator.comparingInt((P p) -> p.priority()).reversed()) // (2) .collect(Collectors.toUnmodifiableList()); // (3)
  1. stream()是惰性的,所以这里才是每个提供者真正被实例化的地方
  2. 优先级最高的排在前面
  3. 反射成本只付一次,然后持有一个不可变列表

关键的一行是.map(Provider::get)。做一次,持有不可变列表,反射成本只付一次再也不用付。接下来的选择有两种形式:get("paypal", cfg)当你已经知道要用哪个的时候,getDefault(cfg)返回优先级最高的且supports(cfg)为真的那个。

新行为往哪放

一条规则让设计多年不跑偏:提供者契约不变。任何新东西都挂在装饰器上。接缝是一个小接口。

public interface ServiceResolver<S> { S get(String id, Configuration configuration); S getDefault(Configuration configuration); List<String> available();
}

ServiceRegistry实现它,缓存也实现它,作为装饰器:

public S get(String id, Configuration configuration) { return cache.computeIfAbsent( keyFunction.apply(id, configuration), key -> delegate.get(id, configuration));
}

ConcurrentHashMap上的computeIfAbsent对每个 key 是原子的,所以即使多个线程抢同一个 id,最终也只有一个实例。key 是可插拔的,默认为 id,这让 map 大小受限于提供者数量。不增长,不泄漏。但如果传一个由易变配置值生成的 key,就会无限增长;这正是它不是默认值的原因,也是我暂不加淘汰策略的原因,直到真正需要的时候。

服务本身的横切关注点(比如日志或重试代理)通过加载时传入的后处理器处理,就是一个普通的UnaryOperator<S>

ServiceRegistry.load(PaymentServiceProvider.class, LoggingPaymentService::wrap);

生命周期复用AutoCloseable而不是什么自定义的Lifecycle接口。如果服务实现了它,缓存——它才是真正持有实例的地方——负责关闭它:

if (service instanceof AutoCloseable closeable) { try { closeable.close(); } catch (Exception ignored) { }
}

不缓存的实现只创建不使用,所以它不拥有任何东西,也不需要关闭任何东西。

配置,以及为什么不用 Properties

Configuration是一个接口,唯一实现把数据放在不可变Map里。Properties只是作为解析器出现:

public static Configuration ofClasspath(String resource) { Properties parsed = new Properties(); // load... return ofProperties(parsed); // copy into a Map, then let parsed go out of scope
}

我比它实际需要的更在意这个细节。Properties继承自Hashtable,所以每次读取都要加锁,而且可能拖着一串defaults在后面。整个配置生命周期都保持一个 Properties 对象,就等于为一个重得多、有同步的结构付出不必要的代价。把值拷贝到不可变Map,让 Properties 对象超出作用域,剩下的就是一个紧凑、无锁的快照,安全发布。

我没有加进去的东西

加一个CacheStrategy接口是很容易的。TTL 策略、provider 注册事件总线、可变运行时注册中心、也许再塞个小 DI 容器——我都没加。ConcurrentHashMap加可插拔 key 已经覆盖了我实际遇到的场景,等哪天需要 TTL 了,换掉装饰器里的 map 就行,其他代码都不用动。create()保持简单工厂的样子,提供者契约保持在四个方法。一旦别人依赖上了再想把东西抽出来比真正需要时加进去难太多了。

ServiceLoader实例。它读一次变成不可变列表后就丢弃了,所以设计有意识地放弃加载器自己的提供者缓存和reload()。这是个真实的权衡,我接受:你会得到一个不可变的、线程安全可共享的快照,而不是运行时重新发现提供者的那种。如果真需要动态行为,诚实的方式是构建一个新的注册中心,而不是原地修改这个。

关于模块路径

在 JPMS(Java 平台模块系统)下同样的提供者可以通过声明方式发现:API 模块里用uses,每个实现里用provides ... with。类路径的META-INF/services路线继续有效,所以两部分可以共存,部分构建还在往模块迁移的时候。

相关方案

这个结构不是新的,了解周围的邻居有帮助。Spring 用自己的元数据文件和SpringFactoriesLoader解决同样的发现问题,把契约映射到实现列表,类似META-INF/services做的事。它也遇到了本文讨论的两个局限,在框架内部回答了:ordering 和@Conditional解决选择问题;以及从 Spring Framework 6 开始,有了SpringFactoriesLoader.ArgumentResolver在加载时解析构造器参数,这样工厂实现不再需要无参构造器。同样的问题,Spring 选择扩展加载器来解决,这里选择在加载器前面放一个工厂。两种机制在这里有更详细的对比。

如果手写META-INF/services条目让你不爽,Google 的 AutoService 用注解生成它。当真的需要隔离类加载器、生命周期回调或热加载的时候,像 PF4J 或 OSGi Declarative Services 这样的重量级框架才是正确选择。这个模式故意停在那之前:标准 JDK、无依赖,加上选择和配置能力,不需要容器。

设计一览表

整个东西用一张小表就能装下。每行是一个决策以及它带来的好处。

组件 解决了什么问题
提供者工厂(ServiceProvider 绕过无参构造器限制;服务保留任何需要的构造器
标记接口(PaymentServiceProvider 提供类型擦除本来会拒绝的具体的Class<P>令牌
priority() + supports() ServiceLoader 没有的选择能力:为当前环境挑选正确的提供者
不可变快照(读一次,丢弃加载器) 线程安全可复用,不需要照看缓存或reload()状态
ServiceResolver上的装饰器(缓存) 在不触碰提供者契约的前提下添加缓存和生命周期
后处理器(UnaryOperator<S> 横切关注点(日志、监控、重试)放在一边,不进入契约
AutoCloseable处理生命周期 复用标准惯用法而不是自定义Lifecycle接口

结语

这些完全不限于支付场景。定义一个领域接口,加一行标记,实现层保持不动。这也是之前那篇文章的主线:你不是在接插件,而是在声明一种能力,让运行时来提供。ServiceLoader处理发现和实例化;放在它前面的工厂覆盖ServiceLoader覆盖不了的部分——从选对实现到用完关闭。