25 断言式编程
用断言表达真正不可能的内部状态,并区分断言、输入校验和可恢复业务错误。
学习目标
- 能解释断言为什么只保护内部不变量,并区分它与输入校验、业务错误的责任边界
- 能修改一段订单处理代码,在不可恢复的内部状态处停止并保留诊断上下文
- 能回答:面对一个负数输入和一个被破坏的库存状态,哪一个应该返回业务错误,哪一个应该触发断言?
为什么这一单元不可压缩
想象机场有两道不同的门:第一道检查旅客是否带着有效证件,第二道检查已经登机的行李是否仍然符合舱内规则。第一道面对外部来物,应该礼貌地拒绝并告诉旅客如何修正;第二道如果发现行李在内部凭空变成了“不可能的形状”,就必须马上停住并保留现场。
如果把两道门混成一扇,坏输入会被伪装成正常请求,或者真正的内部损坏只收到一条模糊的“请重试”。这会让用户不知道如何修正,也让维护者失去最接近原因的证据。
本页依据 David Thomas、Andrew Hunt《程序员修炼之道:通向务实的最高境界(第2版)》,云风译,电子工业出版社,2020年4月,ISBN 9787121384356 的公开完整中文目录,独立重构 25 断言式编程 与 提示39:使用断言去预防不可能的事情。示例、代码、图示、实验和练习均为本课程重新设计,不复制原书正文、插图或答案。
先看两道门的分流
请先猜一猜:把 amount = -2 送进订单流程时,程序应该用哪一道门拒绝?如果库存已经变成 -1,它又应该怎样留下现场?切换实验台的故障开关,再观察错误究竟停在输入边界还是越过内部安全线。
第 1 / 5 步:外部输入 已留下可检查证据。
常见误区与回退
关键模型:两道门保护不同的事实
断言只守内部不变量
先定义 <Term def="程序员对内部状态作出的不可妥协检查;条件被破坏时,程序停止继续走这条不安全路径。">断言</Term>。它不是“所有错误的快捷写法”,而是把开发者确信必须成立的事实写在代码旁边。比如,库存服务在完成扣减后仍应满足 <Term def="一个状态在某个边界上必须持续成立的条件,例如库存数量不能为负。">不变量</Term>:库存数量不能小于零、已确认订单必须有订单号。
断言的价值在于缩短距离:状态刚被破坏时,调用栈、对象身份和版本仍在眼前。它应该让错误显眼、可定位,而不是把异常转换成一个看似合法的 0。断言失败并不意味着用户输入错了;它更像一盏内部警报,提醒团队检查代码、并发、数据迁移或依赖合同。
输入校验与业务错误面对可修正的来物
用户输入、请求参数、文件内容和外部消息都还没有经过系统信任边界。<Term def="在外部数据进入内部流程前检查格式、范围和权限,并给出明确拒绝原因的边界动作。">输入校验</Term>应该在入口发生:amount = -2 可以被解释、被拒绝,也可以在用户修正后重试。
这类拒绝属于 <Term def="业务流程能够预期并向调用者解释的失败,例如余额不足、库存不够或参数范围不合法。">可恢复业务错误</Term>。它应当带着字段、原因和修正方向返回,而不是冒充内部崩溃。校验通过后,如果服务自己算出了负库存,那就不是“用户再试一次”能解决的事情了。
| 场景 | 谁负责发现 | 对调用者的结果 | 是否适合断言 |
|---|---|---|---|
amount = -2 | 输入边界 | 说明字段范围的业务错误 | 否 |
库存扣减后变成 -1 | 内部状态边界 | 停止当前路径并保留现场 | 是 |
| 供应商超时 | 依赖适配器 | 按合同重试、降级或补偿 | 通常否 |
生产行为要有证据,不要吞掉警报
断言会影响 <Term def="程序在真实运行中对外呈现的写入、通知、返回和副作用;它应当遵守已经声明的状态合同。">生产行为</Term>:失败后不应继续写入、不应发布成功事件,也不能静默地换一个默认值。至于进程是否退出,要由服务的隔离、可用性和数据一致性合同决定;“触发了断言”不等于“已经完成恢复”。
每次停止都要生成 <Term def="让复核者能重建一次失败现场的记录,至少包含对象、状态版本、首个失败条件、请求身份和恢复线索。">诊断上下文</Term>。最小记录可以是 requestId、对象 ID、旧值、新值、期望条件、实际值、代码版本和下一步动作。这样,断言既能阻止错误扩散,也能把修复工作交给有证据的后续流程。
从假设到停止的五个节点
这条专属图示把“断言式编程”落在一次库存扣减上:外部输入先走可修正的入口,可信数据再进入内部不变量检查;一旦不可能状态出现,生产行为立即停止,诊断上下文交给恢复者。
逐步重放一次内部状态破坏
先预测每一步应该看到什么,再使用同一个 order-42 重放。练习的关键不是让流程永远成功,而是确认第一处不可能状态没有被包装成成功结果。
1. 先在入口区分可修正输入
接收 amount = -2 时,记录字段和范围错误,返回可恢复的业务错误;此时没有理由触发内部断言。
代码对照:把两道门写成两种结果
先让入口返回可以解释的失败形状。它面对的是调用者可以修正的参数,所以不应使用内部断言代替提示。
type AmountResult =
| { ok: true; amount: number }
| { ok: false; field: "amount"; reason: string };
function validateAmount(raw: string): AmountResult {
const amount = Number(raw);
if (!Number.isInteger(amount) || amount <= 0) {
return { ok: false, field: "amount", reason: "请输入正整数" };
}
return { ok: true, amount };
}validateAmount 只处理外部数据的格式和范围。它不能证明库存扣减后的状态正确,因此内部边界还需要另一个明确的检查。
function assertInventoryInvariant(stock: number, requestId: string) {
if (stock < 0) {
throw new Error(
JSON.stringify({
kind: "assertion-failed",
requestId,
expected: "stock >= 0",
actual: stock,
}),
);
}
}实际服务可以使用项目统一的断言库、结构化日志和错误边界;不可省略的部分是期望条件、实际值、请求身份与当前路径。异常被捕获后,恢复流程还要确认是否已经产生数据库写入或外部通知。
正常、边界与单故障矩阵
| 样本 | 唯一变化 | 预期首个结果 | 必存证据 |
|---|---|---|---|
| 正常 | 正整数、库存可用 | 通过校验,内部状态仍非负 | 输入、旧库存、新库存、订单 ID |
| 边界 | 数量等于剩余库存 | 按库存合同接受或拒绝,不造假成功 | 阈值、比较结果、状态版本 |
| 单故障 | 扣减后注入 stock = -1 | 断言停止,不能发布确认事件 | 期望条件、实际值、请求 ID、恢复动作 |
三种样本共享订单身份与起始状态,复核者才能判断差异来自哪一个条件。若故障发生在数据库提交之后,还要把事务、幂等键和已发出的外部副作用单独列出来;断言负责暴露问题,不负责替系统猜测回退方式。
本章回顾
- 断言保护开发者确信必须成立的内部不变量。
- 输入校验负责拒绝可修正的外部数据,并说明如何修正。
- 可恢复业务错误不应伪装成内部崩溃,内部损坏也不应被默认值吞掉。
- 生产停止必须留下诊断上下文,恢复者再决定隔离、回退或重放。
练习:让断言成为可验收的代码行为
练习
问题 1:划分两道门。 amount = -2 与“扣减后库存为 -1”分别应该由哪一个边界处理?请为每种情况写出调用者能看到的结果。
问题 2:改 Demo 代码。 在本章实验台中打开“不可能状态”故障开关,补写一个测试:断言触发后,写入次数和成功通知次数都必须为 0;再点击重置并验证正常样本仍能完成。
问题 3:设计恢复记录。 断言发生在部分事务已经写入之后,写出三项恢复信息,并说明为什么不能只把库存改回原值后删除日志。
名词解释
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 断言
写在代码里的内部警报:如果开发者确信必须成立的条件被破坏,就停止继续走这条路径。
- 不变量
状态在某个边界上必须一直满足的条件,例如库存数量不能小于零。
- 输入校验
外部数据进入内部流程前的检查,负责发现格式、范围或权限问题并说明如何修正。
- 可恢复业务错误
调用者能够理解、修正并再次尝试的失败,例如参数范围不合法或库存不足。
- 生产行为
程序在真实运行中产生的写入、通知、返回和其他副作用。
- 诊断上下文
用来重建失败现场的记录,通常包括对象、状态版本、请求身份、期望条件和实际值。
来源与改写范围
- 作者与英文版页面:核对 20 周年版书名、版本与 Topic 25 的英文目录坐标。
- 中文公开目录:核对“25 断言式编程”与“提示39:使用断言去预防不可能的事情”的中文目录位置。
- 本页是基于公开目录的 independent rewrite;断言分流、TypeScript 示例、图示、实验和练习均为本课程重新设计,不声称提供原书全文或原书答案。