25 断言式编程

用断言表达真正不可能的内部状态,并区分断言、输入校验和可恢复业务错误。

学习目标

  • 能解释断言为什么只保护内部不变量,并区分它与输入校验、业务错误的责任边界
  • 能修改一段订单处理代码,在不可恢复的内部状态处停止并保留诊断上下文
  • 能回答:面对一个负数输入和一个被破坏的库存状态,哪一个应该返回业务错误,哪一个应该触发断言?

为什么这一单元不可压缩

想象机场有两道不同的门:第一道检查旅客是否带着有效证件,第二道检查已经登机的行李是否仍然符合舱内规则。第一道面对外部来物,应该礼貌地拒绝并告诉旅客如何修正;第二道如果发现行李在内部凭空变成了“不可能的形状”,就必须马上停住并保留现场。

如果把两道门混成一扇,坏输入会被伪装成正常请求,或者真正的内部损坏只收到一条模糊的“请重试”。这会让用户不知道如何修正,也让维护者失去最接近原因的证据。

本页依据 David Thomas、Andrew Hunt《程序员修炼之道:通向务实的最高境界(第2版)》,云风译,电子工业出版社,2020年4月,ISBN 9787121384356 的公开完整中文目录,独立重构 25 断言式编程提示39:使用断言去预防不可能的事情。示例、代码、图示、实验和练习均为本课程重新设计,不复制原书正文、插图或答案。

先看两道门的分流

请先猜一猜:把 amount = -2 送进订单流程时,程序应该用哪一道门拒绝?如果库存已经变成 -1,它又应该怎样留下现场?切换实验台的故障开关,再观察错误究竟停在输入边界还是越过内部安全线。

Topic 25 · 两道门实验台
断言式编程:把外部拒绝和内部警报分开可修正输入 → 输入校验;不可能状态 → 断言停止;现场 → 诊断上下文同一请求:order-421外部输入amount = -2用户可修正能说明原因吗?2输入校验返回业务错误边界拒绝不要伪装成功3内部断言stock >= 0检查不变量不可能就停4生产行为不写入、不通知切断副作用谁不能接收?5诊断上下文ID + 版本 + 首差交给恢复者怎样重放?验收:外部错误可修正;内部不可能状态必须显眼并停止断言保护不变量,不替代输入校验,也不承诺自动恢复图示中的“停止”只切断当前不安全路径,不把失败伪装成成功

第 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-421外部输入amount = -2用户可修正能说明原因吗?2输入校验返回业务错误边界拒绝不要伪装成功3内部断言stock >= 0检查不变量不可能就停4生产行为不写入、不通知切断副作用谁不能接收?5诊断上下文ID + 版本 + 首差交给恢复者怎样重放?验收:外部错误可修正;内部不可能状态必须显眼并停止断言保护不变量,不替代输入校验,也不承诺自动恢复图示中的“停止”只切断当前不安全路径,不把失败伪装成成功
外部输入先得到可修正的拒绝;内部不可能状态则在生产副作用前触发断言并留下现场。

逐步重放一次内部状态破坏

先预测每一步应该看到什么,再使用同一个 order-42 重放。练习的关键不是让流程永远成功,而是确认第一处不可能状态没有被包装成成功结果。

分步1 / 3

1. 先在入口区分可修正输入

接收 amount = -2 时,记录字段和范围错误,返回可恢复的业务错误;此时没有理由触发内部断言。

断言式编程:把外部拒绝和内部警报分开可修正输入 → 输入校验;不可能状态 → 断言停止;现场 → 诊断上下文同一请求:order-421外部输入amount = -2用户可修正能说明原因吗?2输入校验返回业务错误边界拒绝不要伪装成功3内部断言stock >= 0检查不变量不可能就停4生产行为不写入、不通知切断副作用谁不能接收?5诊断上下文ID + 版本 + 首差交给恢复者怎样重放?验收:外部错误可修正;内部不可能状态必须显眼并停止断言保护不变量,不替代输入校验,也不承诺自动恢复图示中的“停止”只切断当前不安全路径,不把失败伪装成成功
外部输入先得到可修正的拒绝;内部不可能状态则在生产副作用前触发断言并留下现场。

代码对照:把两道门写成两种结果

先让入口返回可以解释的失败形状。它面对的是调用者可以修正的参数,所以不应使用内部断言代替提示。

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 示例、图示、实验和练习均为本课程重新设计,不声称提供原书全文或原书答案。

前后导航

讨论

评论区加载中…