API 设计看错误何时暴露:从构造器、Builder 到内部 DSL
new Rule("sms", "payment", 60, 2)把渠道和团队、级别和频率全部写反,编译通过,运行也不报错,只是告警发错了人。同一条规则换成类型状态 Builder,漏填一个必填项就编译失败。API 的好坏,很大程度上取决于它让错误在什么时候暴露。
读懂一个系统的设计 提到,接口不只是 Java 的 interface,程序库的方法签名、构造方式、配置写法都是接口。本文从写 API 的一方出发,比较几种常见写法在可读性、错误暴露时机、IDE 可发现性和二进制兼容上的差别,再讨论什么时候值得做一个 DSL。实验环境:JDK 21.0.5。
一、先说结论
- 评价一个 API 有四把尺子:调用处是否读得懂、错误在什么时候暴露、IDE 补全能否引导使用、新版本发布后已编译的调用方还能不能运行。
- 同类型的多个参数是最常见的隐患:写反了不会报错。参数超过三个,或者有两个以上同类型参数时,考虑 Builder 或值类型。
- 普通 Builder 解决了可读性,没有解决完整性:漏填必填项要到
build()才报错;类型状态 Builder 能把它提前到编译期,代价是更多样板代码。 - 公开的构造器和
record组件是二进制契约:给record加一个组件,没有重新编译的调用方会抛NoSuchMethodError;Builder 新增一个可选方法则不会。 - DSL 是模型之上的一层表达:先有稳定的领域模型,再决定用内部 DSL 还是外部 DSL;模型没想清楚时做 DSL,只是把混乱换了一种语法。
二、一个 API 有三类读者
写 API 时通常只想到调用方,实际上它同时面对三类读者:
| 读者 | 关心什么 | 对应的设计手段 |
|---|---|---|
| 写调用代码的人 | 调用处能否看懂,IDE 能否提示下一步该做什么 | 有名字的参数(Builder)、流式调用、合理的默认值 |
| 编译器 | 能否在编译期发现错误用法 | 值类型、密封类型、类型状态 |
| 已经编译好的旧代码 | 升级依赖后不重新编译还能不能运行 | 不改公开签名,只新增;把可选项放进 Builder |
后两类读者经常被忽略。前者决定错误暴露的时机,后者决定一个程序库能不能平滑升级。
三、四种构造方式的实测
用一条告警路由规则做例子:团队、渠道两个必填项,最低级别、每小时上限两个可选项。
3.1 多参数构造器:写反了也能运行
record Rule(String team, String channel, int minLevel, int maxPerHour) {}
Rule swapped = new Rule("sms", "payment", 60, 2);构造器参数写反也能编译: Rule[team=sms, channel=payment, minLevel=60, maxPerHour=2]两个 String、两个 int,编译器无法区分。这种错误不会在测试环境报错,只会在某次告警没发到正确的人时才被发现。可选项一多,还会演变成一组重载的构造器,互相调用,调用方要数逗号才知道哪个参数是什么。
有两种改法,可以一起用:
- 值类型:把
String team换成record TeamId(String value),把String channel换成enum Channel或record ChannelName(String value)。类型不同,写反就编译失败。 - 有名字的参数:Java 没有命名参数,Builder 是最常见的替代。
3.2 普通 Builder:可读,但漏填到运行时才报错
new RuleBuilder().channel("sms").build(); // 漏了 teamBuilder 漏填 team,运行时: NPE team调用处的每个值都有名字,可选项可以不写,这是 Builder 的主要收益。代价是:必填项漏了,编译器不知道,只能在 build() 里校验并抛异常。如果这段代码在一个很少走到的分支里,错误可能直到生产环境才出现。
3.3 类型状态 Builder:把必填检查交给编译器
interface NeedTeam { NeedChannel team(String team); }
interface NeedChannel { Optionals channel(String channel); }
interface Optionals { Optionals minLevel(int v); Optionals maxPerHour(int v); Rule build(); }
static NeedTeam rule() {
return team -> channel -> new Optionals() {
int min = 1, max = 60;
public Optionals minLevel(int v) { min = v; return this; }
public Optionals maxPerHour(int v) { max = v; return this; }
public Rule build() { return new Rule(team, channel, min, max); }
};
}
Rule r = rule().team("payment").channel("sms").minLevel(2).build();漏填 team,直接调用 channel:
Missing.java:2: error: cannot find symbol
public static void main(String[] a) { System.out.println(Styles.rule().channel("sms").build()); }
symbol: method channel(String)
location: interface NeedTeam每个接口只暴露下一步合法的方法,IDE 补全也就只会提示这些方法,调用方不需要读文档就知道下一步填什么。代价是每个必填项多一个接口,必填项超过四五个时,样板代码和阅读成本都会明显上升。适合场景是:API 被很多团队调用、漏填后果严重、必填项不多。
3.4 Lambda 配置器
Rule r = rule(spec -> {
spec.team = "payment";
spec.channel = "sms";
spec.minLevel = 2;
});这种写法把「配置什么」交给调用方的一段代码,常见于框架配置。它的好处是嵌套结构清晰、容易扩展,缺点是必填检查同样发生在运行时。Spring Security 走的就是这条路:在 Spring Security 7.1.1 的 HttpSecurity 中,csrf()、authorizeHttpRequests() 等方法只剩接收 Customizer 的版本,原来无参数、靠 and() 串起来的写法已经没有了。
3.5 四种写法的比较
| 写法 | 调用处可读性 | 必填项漏填 | 参数写反 | IDE 引导 | 新增可选项 |
|---|---|---|---|---|---|
| 多参数构造器 | 差 | 编译期(参数个数不对) | 不报错 | 只提示参数类型 | 破坏二进制兼容 |
| 普通 Builder | 好 | 运行时 | 按名字赋值,不易写反 | 列出全部方法 | 兼容 |
| 类型状态 Builder | 好 | 编译期 | 不易写反 | 只列出下一步 | 兼容 |
| Lambda 配置器 | 好,适合嵌套 | 运行时 | 不易写反 | 列出配置对象的成员 | 兼容 |
四、二进制兼容:已编译的调用方
程序库发布新版本后,依赖它的服务往往不会同时重新编译,比如一个公共 jar 被多个服务引用,或者两个依赖传递引入了不同版本。这时要看的不是源码能否编译,而是已经编译好的字节码能否链接。
实验:v1 的 Notice 是三个组件的 record,客户端分别用 Builder 和构造器创建它,编译一次。然后发布 v2,给 record 加一个 priority 组件,Builder 加一个可选方法 priority(int)。客户端不重新编译,直接换上 v2 运行:
--- run with v1
builder 调用方: Notice[to=ops, subject=disk, body=90%]
构造器调用方: Notice[to=ops, subject=disk, body=90%]
--- run with v2 (client not recompiled)
builder 调用方: Notice[to=ops, subject=disk, body=90%, priority=3]
Exception in thread "main" java.lang.NoSuchMethodError: 'void lib.Notice.<init>(java.lang.String, java.lang.String, java.lang.String)'给 record 加组件,会改变它的规范构造器签名,旧字节码里调用的三参数构造器已经不存在。错误在运行到那一行时才出现,编译期和启动时都发现不了。Builder 的调用方不受影响,因为它调用的方法都还在。
由此可以得到几条发布公共 API 的规则:
- 只新增,不修改:新增方法、新增重载、新增 Builder 方法都是兼容的;修改参数列表、返回类型、删除方法都不兼容。
record适合作为值,不适合作为会演化的公开构造入口:对外暴露工厂方法或 Builder,把规范构造器留给库内部。- 给接口加方法要带
default实现,否则所有实现类都会在编译期或运行期出错(运行期表现为AbstractMethodError)。 - 删除之前先弃用:Spring Security 在 6.x 中把
and()等方法标为弃用(6.2.3 的字节码中已带@Deprecated),官方迁移文档提前说明 7.0 起必须使用 Lambda DSL,到 7.x 才真正删除。
五、从流式 API 到 DSL
5.1 内部 DSL:用宿主语言写出领域句子
当一类配置被反复书写,而且有明确的领域词汇时,可以在 Java 里做一个内部 DSL。下面是 通知路由案例 中路由规则的另一种写法:
Router router = new Router(List.of(
when(team("payment")).and(atLeast(P2)).notify("支付高优先级", phone("oncall-payment"), chat("payment-alerts")),
when(team("payment")).notify("支付全部", chat("payment-alerts"))));实现只有十几行静态方法和一个小 record:
static Predicate<Alert> team(String t) { return a -> a.team().equals(t); }
static Predicate<Alert> atLeast(Severity s) { return a -> a.severity().compareTo(s) <= 0; }
static RouteSpec when(Predicate<Alert> p) { return new RouteSpec(p); }
record RouteSpec(Predicate<Alert> when) {
RouteSpec and(Predicate<Alert> more) { return new RouteSpec(when.and(more)); }
Route notify(String name, Target... to) { return new Route(name, when, List.of(to)); }
}注意它的结构:Alert、Route、Target、Router 这些模型先存在,DSL 只是构造它们的一层表面。去掉 DSL,直接 new Route(...) 也能工作,行为完全一样。模型决定能表达什么,DSL 只决定表达得是否顺手。
5.2 内部 DSL 还是外部 DSL
当规则需要由不写 Java 的人维护,或者需要在不发版的情况下修改,就要考虑外部 DSL,也就是一段独立的文本:
route 支付高优先级: team=payment severity<=P2 -> phone:oncall-payment, chat:payment-alerts| 内部 DSL | 外部 DSL | |
|---|---|---|
| 谁来写 | 开发者 | 值班负责人、运营、业务人员 |
| 修改后如何生效 | 重新发布 | 重新加载配置 |
| 错误何时暴露 | 编译期(类型检查) | 加载时(需要自己写校验和报错) |
| 工具支持 | IDE 补全、重构、跳转 | 需要自己做预览、校验,甚至编辑器 |
| 主要成本 | 设计方法名和类型 | 解析器、错误提示、版本演化、权限与审计 |
外部 DSL 的成本经常被低估。解析本身不难,难的是让写错的人知道错在哪里:行号、哪个条件无法识别、哪个渠道不存在,一次报出全部错误,而不是遇到第一个就抛异常。案例文章里的规则解析器就把这些错误放在加载时统一报告。
判断是否值得做外部 DSL,可以先问两个问题:这类修改多频繁?现在每次修改要花多少人力和等待时间?如果规则一年改两次,写在代码里、走正常发布流程往往是最便宜的。
六、常见误区
- 「Builder 总比构造器好」:两三个不同类型的必填参数,构造器或静态工厂方法更简单;Builder 适合可选项多或同类型参数多的情况。
- 「
record天然适合做公共 API」:它的规范构造器是公开签名,增删组件都会破坏二进制兼容。 - 「流式 API 就是 DSL」:链式调用只是语法形式,没有领域词汇和稳定模型的链式调用,只是一个很长的 setter 序列。
- 「DSL 越接近自然语言越好」:DSL 的目标是准确表达领域规则,并在写错时给出清楚的报错,而不是像一句话。
- 「API 能编译通过就是兼容的」:源码兼容和二进制兼容是两回事,公共 jar 还要考虑没有重新编译的调用方。
小结
设计一个 API 时,先列出调用方可能犯的错误,再决定每一种错误应该在什么时候暴露:能交给类型系统的交给类型系统,不能的就在加载或启动时集中校验,尽量不留到运行中的某一次调用。对外发布的签名只新增、不修改。DSL 是模型成熟之后的锦上添花:先让模型能直接用,再考虑让它读起来更像领域语言。
配套实验
- codesphere-labs/design/api-evolution:四种构造方式的错误时机与
record新增组件后的NoSuchMethodError(验证记录) - codesphere-labs/design/notification-routing:内部 DSL 与规则文本解析的完整代码(验证记录)
可以用 make verify 一条命令复现;链接固定在实验仓库的 c9f6692 版本。
参考资料
- Joshua Bloch,《Effective Java》第 3 版,条目 2「遇到多个构造器参数时要考虑使用构建器」
- Java Language Specification, Chapter 13: Binary Compatibility
- JEP 395: Records
- Spring Security 6.5 Reference: Preparing for 7.0 – Configuration Migrations
- Martin Fowler,《Domain-Specific Languages》,内部 DSL 与外部 DSL 的划分