一次导入的三种失败

商品数量为负数,目录已经关闭,以及导入结束后两个内部索引数量不一致,都可以让程序停止,但它们暴露的问题不同。负数量来自调用参数;目录关闭说明当前对象状态不允许执行操作;索引不一致则违反程序建立并依赖的内部假设。如果统一抛出一个含糊的 RuntimeException,调用者很难判断该修正请求、调整调用时机,还是报告实现缺陷。

Guava 33.5.0-jre 的 Preconditions 和 Verify 给这些检查提供了短写法。checkArgument、checkState、checkNotNull 分别产生不同的异常,Verify 使用 VerifyException 表达假设失败。它们只在实际调用处检查表达式,不会自动证明系统其他位置已经满足不变量。Preconditions、Verify

第 04 篇把库调用放回业务契约中比较;本章继续使用商品导入,检查异常类型、消息文本与消息构造时机。实验的 Java 8 基线在 Zulu 8u472、Corretto 21.0.11 上分别运行,未使用耗时数字推导性能优势。

检查位置决定错误归属

一个入口方法若要求 quantity >= 0,就应在产生依赖此条件的修改前检查。先写入商品再抛参数异常,会留下“调用失败但状态已变化”的额外契约。Preconditions 不提供事务回滚,短写法不能替代操作顺序设计。

checkState(open, "catalog closed") 通常用于对象生命周期,例如关闭后的目录不能接受导入。合法参数仍可能在错误时机调用,因此参数检查与状态检查可以同时存在。若其他线程能改变 open,单独检查再执行也没有建立原子性;是否需要锁或状态机由并发协议决定。本章的状态反例只验证异常分类,不声称验证了并发关闭。

内部索引一致性检查则可以使用 Verify.verify。在已经完成输入验证后,程序预期主索引和辅助索引表达同一组商品,这时数量不一致更接近实现假设失败。Verify 文档把它与调用者应满足的前置条件区分开,提示它常用来核验依赖外部组件或其他实现细节的假设。它也不是故障恢复器:抛出异常后如何告警、回滚或隔离数据,仍需由外层处理。

异常类别不是 HTTP 状态码映射表。把所有 IllegalArgumentException 自动转换为客户端错误,可能隐藏由内部代码误用造成的失败;把 VerifyException 直接返回用户也可能泄露内部信息。对外错误应在明确的边界根据实际来源转换,内部日志保留原因链与必要的定位字段。

null 检查与显式条件

只有“对象必须存在”这一条要求时,JDK 8 的 Objects.requireNonNull 已经足够。它返回原引用,可以用于构造函数赋值;Guava checkNotNull 也返回原引用。本章分别断言返回对象的引用身份和 null 时的消息,确保替换时没有引入复制或默认对象。Objects.requireNonNull

Verify.verifyNotNull 的失败类别则是 VerifyException。它适合某个内部查找按程序假设应有结果的情形,不应仅因名字也带 NotNull 就批量替换公开入口的 checkNotNull。调用方若捕获特定异常、监控按异常类聚合或测试锁定了契约,异常类别变化本身就是行为变化。

显式 if 仍然是可读的方案,尤其需要构造自定义领域异常、附带错误码或执行多步恢复时。库函数的价值是统一普通检查的表达方式。仅为一个 null 检查新增 Guava 依赖没有必要;已有 Guava 的项目也无需为了风格统一,把本来更清楚的分支塞进复杂布尔表达式。

下面的完整示例展示三个失败入口。例子没有把检查写成一大串复合表达式,因此每个失败都有明确归属;异常由 main 捕获并打印,便于独立执行观察。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import com.google.common.base.Preconditions;
import com.google.common.base.Verify;

public final class ImportChecks {
static void accept(int quantity, boolean open, int primary, int secondary) {
Preconditions.checkArgument(quantity >= 0, "quantity=%s", quantity);
Preconditions.checkState(open, "catalog closed");
Verify.verify(primary == secondary, "index mismatch: %s/%s", primary, secondary);
}
public static void main(String[] args) {
Runnable[] cases = {
() -> accept(-1, true, 2, 2),
() -> accept(1, false, 2, 2),
() -> accept(1, true, 2, 1)
};
for (Runnable action : cases) {
try {
action.run();
throw new AssertionError("expected failure");
} catch (RuntimeException failure) {
System.out.println(failure.getClass().getSimpleName() + ": " + failure.getMessage());
}
}
}
}

快速失败与批量错误报告

商品文件导入通常有两种不同的错误体验。逐行接口可以在发现首个非法字段时终止;文件预检则可能希望一次返回全部错误行,避免用户重复上传。Preconditions 的调用形态天然适合快速失败,但并不禁止先收集结构化校验结果,再决定是否接受整批。选择检查工具之前,应该明确调用者需要哪一种反馈。

如果预检阶段已经完整检查输入,执行阶段仍可以保留关键不变量检查。前者报告可修正的数据错误,后者防止代码修改或并发状态改变使旧假设失效。两类检查不应都产生同一种模糊的“参数有误”,否则执行阶段出现内部缺陷时,监控也会把它误分类为普通输入噪声。

检查的先后顺序也可能成为可观察行为。商户引用为 null 且数量为负时,先查商户会抛 NullPointerException,先查数量会抛 IllegalArgumentException。若协议只要求拒绝非法输入,可以不承诺全部错误中的固定优先级;若测试或调用方已经依赖第一个错误,则重排检查也需要考虑兼容性。示例明确把数量、状态、索引依次检查,避免同时失败时解释不清。

复合条件容易遮蔽空值处理。例如在一个布尔表达式里先调用字段方法,再检查字段是否为 null,短路规则无法挽救已经发生的解引用。与其追求一个 checkArgument 写完全部约束,不如先检查引用存在,再验证长度或内容。失败消息因此也能准确地指出违反哪条规则。

使用 Java 的 assert 语句替换这些公开入口检查还会改变运行要求。assert 是否执行受运行配置控制,而这里的前置条件必须在普通启动方式下持续生效。内部调试断言有自己的用途,但不能因为代码更短就拿来替代必须执行的业务校验。本章的 JUnit 断言属于测试验收,和生产方法中使用 assert 的选择也不是同一件事。JLS 8 assert

消息本身也应服务于失败定位。quantity=-1 比“bad input”多提供了字段与数值,但对含敏感数据的字段,应只保留字段名或安全摘要。错误码可用于机器分流,消息用于解释;为了保持消息完全稳定而限制诊断信息,或者为了方便展示而把敏感对象全部序列化,两者都不符合导入边界的实际需求。

消息参数会先于检查求值

checkArgument(valid, "snapshot=%s", buildSnapshot()) 的表达式 buildSnapshot() 在进入方法前就已执行。即使 valid 为 true,快照仍会创建;如果快照构造抛异常,正常的参数检查甚至没有机会开始。这个行为来自 Java 的方法参数求值规则,不是 Guava 是否优化日志字符串的选择。JLS 8 §15.7.4

固定版本 Preconditions 在布尔条件失败的分支里调用 Strings.lenientFormat,所以“格式化延迟到失败时”和“参数表达式延迟到失败时”必须区分。一个已经创建好的普通对象可能仅在失败分支才被转成字符串,但传递它之前发生的查询、分配、拼接都已经完成。把 expensive() 包成参数并不让它惰性执行。固定版本 Preconditions 源码

实验用 AtomicInteger 计数,成功的 checkArgument 仍令参数中的 incrementAndGet() 执行一次。另一个反例让消息函数直接抛 IllegalStateException,即使检查条件为 true,调用仍以这个异常结束。这是正确性差异:诊断代码可能影响原本合法的业务请求。计数器只用来记录调用次数,不意味着被测操作存在并发。

JDK 8 Objects.requireNonNull(value, Supplier<String>) 可以把消息生成移入 supplier。value 非 null 时 supplier 的 get 不执行,null 时执行。lambda 表达式及其捕获仍要被求值;“lambda 方法体没有执行”不能扩写成“绝对零分配、零开销”。普通的参数校验若也需要昂贵消息,清楚的 if (!valid) { throw ...; } 能直接控制构造时机。

Guava 的 checkArgument 没有把任意消息参数当作 Supplier 执行的协议。把 supplier 对象作为 %s 参数传进去,不会自动调用它的 get;必须选择确实支持惰性消息的 API,或者显式写出条件分支。不能只根据变量类型推断库的执行策略。

格式不是 printf,诊断也有边界

Strings.lenientFormat 使用 %s 替换,额外参数会附加到消息末尾。测试把模板写成 "bad %d: %s",传入 "A" 和 7,得到 "bad %d: A [7]"。其中 %d 保持字面值。这能帮助消息在参数个数不匹配时保留额外信息,却不能替代 String.format 的完整格式语法。Strings.lenientFormat

固定源码还防护了对象 toString 抛异常的情形,但这不等于消息表达式的所有异常都被保护。参数表达式在外层已经执行的事实不变。诊断代码宜采用局部变量,计算应廉价,输出前应脱敏;例如只保留商品编号和错误字段名称。补充错误消息不应再调用远端服务,也不宜输出整个包含个人信息的导入对象。固定版本 Strings 源码

本章锁定消息文本是为了验证所选择的重载与格式语义,不建议把第三方库产生的整段异常消息当成跨版本机器协议。服务需要稳定错误码时,应定义自己的错误模型,并将第三方异常作为原因或实现细节处理。依赖升级后消息改变,应区分“业务协议被破坏”和“诊断文本更新”,分别验收。

检查失败后的状态同样值得断言。若入口只做纯校验,失败时目录应保持原样;若此前已经执行写入,测试就需要证明回滚或补偿策略。异常类别测试只能证明抛出了哪种异常,不能单独证明业务没有留下部分结果。因此本章将实验范围限制在纯检查和求值过程。

实测结果与改动练习

下载 完整测试并按 运行说明执行。Java 8 与 Java 21 各完成本章 3 个测试,失败、错误、跳过均为 0;本章没有微基准或并发实验。结果由异常类别、消息、引用身份与调用次数断言组成。

场景 结果
非法数量 IllegalArgumentException,消息 quantity=-1
非法状态 IllegalStateException,消息 catalog closed
假设失败 VerifyException,消息 index mismatch: A
两种普通 null 检查 NullPointerException,消息 merchant
成功检查带计数参数 参数仍执行一次
成功 requireNonNull 带消息 supplier supplier 方法体不执行

手算题:检查条件为 true,但第二个消息参数调用 failingMessage(),该方法抛异常,最终能正常返回吗?不能。参数从左到右求值,参数求值异常使方法调用提前结束,方法体内的条件尚未处理。

改动练习:把消息生成改成读取一个可变计数器,分别使用 eager 字符串、消息 supplier 和显式 if。为合法、非法两条路径断言计数,要求合法路径不产生额外诊断副作用,非法路径只生成一次消息。异常类型与字段名称仍保持不变。

可迁移做法 适用边界
按参数、状态、内部假设分配检查位置 明确责任后再选择异常类别
单独验证诊断表达式的求值时机 消息依赖计算、查询或可变状态时

下一篇:Optional 迁移的值语义与类型边界。系列入口:可复现基线。