Skip to content

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,只是把混乱换了一种语法。
越往左,错误越早暴露,修复越便宜编译期类型状态 Builder漏填必填项无法调用 build参数包装成值类型TeamId 与 Channel 不能互换启动或加载时规则文本校验未知渠道、错误条件容器启动检查缺失依赖、配置绑定失败运行到调用点普通 Builder 漏填build() 抛出 NPE旧调用方遇到新 recordNoSuchMethodError从不报错同类型参数写反数据错了但能运行布尔参数含义不明send(a, true, false)
图 1 · 同一个错误,越早暴露越便宜:类型状态 Builder 把漏填变成编译错误,规则文本在加载时报错,普通 Builder 在运行到 build() 时报错,同类型参数写反则永远不报错(JDK 21.0.5 实测)

二、一个 API 有三类读者 ​

写 API 时通常只想到调用方,实际上它同时面对三类读者:

读者关心什么对应的设计手段
写调用代码的人调用处能否看懂,IDE 能否提示下一步该做什么有名字的参数(Builder)、流式调用、合理的默认值
编译器能否在编译期发现错误用法值类型、密封类型、类型状态
已经编译好的旧代码升级依赖后不重新编译还能不能运行不改公开签名,只新增;把可选项放进 Builder

后两类读者经常被忽略。前者决定错误暴露的时机,后者决定一个程序库能不能平滑升级。

三、四种构造方式的实测 ​

用一条告警路由规则做例子:团队、渠道两个必填项,最低级别、每小时上限两个可选项。

3.1 多参数构造器:写反了也能运行 ​

java
record Rule(String team, String channel, int minLevel, int maxPerHour) {}

Rule swapped = new Rule("sms", "payment", 60, 2);
text
构造器参数写反也能编译: 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:可读,但漏填到运行时才报错 ​

java
new RuleBuilder().channel("sms").build();   // 漏了 team
text
Builder 漏填 team,运行时: NPE team

调用处的每个值都有名字,可选项可以不写,这是 Builder 的主要收益。代价是:必填项漏了,编译器不知道,只能在 build() 里校验并抛异常。如果这段代码在一个很少走到的分支里,错误可能直到生产环境才出现。

3.3 类型状态 Builder:把必填检查交给编译器 ​

NeedTeamNeedChannelOptionalsRuleteam()channel()build()minLevel() / maxPerHour()rule().channel("sms") 编译失败NeedTeam 上没有 channel(),错误在编译期暴露代价:每个必填项多一个接口,字段较多时样板代码明显增加
图 2 · 每个必填步骤是一个只暴露下一步方法的接口,可选项和 build() 只在必填项齐全后出现;IDE 补全只会提示当前合法的方法,漏填时编译器报 cannot find symbol
java
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:

text
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 配置器 ​

java
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 运行:

text
--- 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。下面是 通知路由案例 中路由规则的另一种写法:

java
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:

java
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,也就是一段独立的文本:

text
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 是模型成熟之后的锦上添花:先让模型能直接用,再考虑让它读起来更像领域语言。


配套实验

可以用 make verify 一条命令复现;链接固定在实验仓库的 c9f6692 版本。

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权