28 解耦

从知识、状态、行为和时序四类耦合入手,减少火车残骸式调用与全局依赖。

学习目标

  • 能把一条调用链拆成依赖类型、状态所有者、边界合同和首个变化证据
  • 能把“只管命令不要询问”和“避免全局数据”改写为可拒绝、可测试的 API 行为
  • 能在一次只改变一个条件的实验中定位首差,重置后从原始输入重放解耦结论

为什么解耦不是把线全部剪断

想象仓库里有五个工作台:收货、库存、拣货、出库和通知。它们当然需要协作,但如果拣货员必须先翻开库存台的抽屉,再读取通知台的计数器,最后把修改写回收货台,任何一台改动都会迫使其他人记住更多内部细节。真正的问题不是“有线”,而是线没有主人、没有方向,也没有失败时的停点。

28 解耦把这类关系变成可以验收的边界:先识别依赖,再声明所有者;让调用者发送意图,让拥有数据的对象完成改变;最后从 API 的输入、输出与拒绝条件验证变化是否停在局部。读者不需要背一条漂亮口号,而要能回答“谁改变了什么、谁接收结果、哪里第一次与预测不同”。

本页依据 David Thomas、Andrew Hunt《程序员修炼之道:通向务实的最高境界(第2版)》,云风译,电子工业出版社,2020年4月,ISBN 9787121384356 的公开完整中文目录,独立重构 28 解耦提示44:解耦代码让改变更容易提示45:只管命令不要询问提示46:不要链式调用方法提示47:避免全局数据提示48:如果全局唯一非常重要,那么将它包装到API 中。示例、代码、图示、实验和练习均为本课程重新设计,不复制原书正文、插图、练习答案或代码。

先把变化对象写在纸上

进入实验前,先写出对象、当前状态、下游接收者和拒绝条件。这里的基线是一个订单库存服务:调用者提交 reserve(itemId, quantity),库存模块拥有可变数量,通知模块只接收“已预留”的结果。请先预测:如果有人直接改共享计数器,首差会出现在命令、状态还是 API 边界?再打开实验台,推进到全局数据节点观察变化。

Topic 28 · 解耦边界实验台
解耦路径:让变化停在拥有数据的 API 边界画出依赖 → 找到责任 → 只发命令 → 封装状态 → 重放验证耦合数据 / 控制 / 时间先画出依赖边变化从哪里进入?调用链谁调用谁?停止跨对象索取责任在哪一层?全局数据谁拥有状态?首差:全局旁路能否移除共享写入?命令查询意图与结果分开命令只改自己的状态调用者需要知道什么?API 边界变化停在契约重放并验证首差下游收到什么?验收合同:只改一个实现,只有声明的 API 结果穿过边界必要依赖留下 owner、输入、输出与失败动作;不相关变化在边界停止提示44:解耦代码让改变更容易先预测首差,再推进一个节点;重置后应能用同一输入重建结论。

第 1 / 5 步:耦合 已留下边界证据。

第 1 / 5 步 · 耦合:先画出依赖边

先预测全局旁路会在哪里变成首差,再重置并比较修复后的边界。

故障开关只移除状态所有权这一项条件;其余输入保持不变,差异才有意义。

常见误区与回退

五节点模型:让依赖通过边界说话

一个系统可能有必要的协作,但每条协作都应该是有名字、有方向、有 owner 的合同。<Term def="一个部分的变化会迫使另一个部分跟着变化的关系;解耦要做的是控制这种变化传播。">耦合</Term>不是一个需要被追求为零的分数,而是需要被定位、命名和验证的关系。

28 解耦:先识别谁被谁牵动

把一次“替换库存实现”的请求沿五个节点推进:耦合描述变化可能沿哪条边扩散;调用链显示调用者知道了多少内部步骤;全局数据揭示有没有绕过所有者的旁路;命令查询分开改变和读取;API 边界则给出最终可复核的输入、输出与拒绝条件。五个节点不是五个层级模板,而是五种观察问题。

提示44:解耦代码让改变更容易

改变更容易,不是因为代码变少,而是因为一个实现的替换不会要求无关调用者同步了解内部结构。选择“替换库存索引”为唯一变化,先预测业务规则、通知结果和审计事件不变,再用契约测试检查边界;如果通知突然依赖索引的内部字段,首差就在 API 之前,而不是等整套回归结束才发现。

提示45:只管命令不要询问

调用者如果先询问余额、再计算、再把新值写回,就承担了本应属于状态所有者的规则。命令表达“请按合同预留”,返回结果表达“已接受、拒绝或需要重试”;调用者不必知道内部如何锁定、校验和持久化。这样数量规则改动时,变化停在拥有数据的对象。

<Term def="把改变状态的命令和读取状态的查询分开,让调用意图和副作用更容易被看见。">命令查询分离</Term>让这条边界可以被测试:命令检查写入和拒绝,查询检查稳定读取,二者都不把内部容器交给调用者。

提示46:不要链式调用方法

<Term def="沿着多个对象逐层取值的调用链,调用者因此知道了内部结构和空值路径。">火车残骸式调用</Term>例如 order.customer().address().city() 看起来只是取值,却把内部结构和空值路径暴露给调用者。一次需求变化可能同时修改 customeraddresscity 的接口。把需要的业务意图提升为 order.shippingCity() 或一个稳定查询,让内部链条留在 owner 内部,并让边界测试验证缺失地址时的拒绝结果。

提示47:避免全局数据

<Term def="由多个不相关模块直接读写、任何人都可能改变的共享状态;它会让局部变化产生远处的副作用。">全局状态</Term>让依赖变得不可见:测试必须按某种顺序运行,重试计数器会影响结算,配置变更会改变并未参与本次操作的模块。把状态封装进拥有者,或者将它变成显式依赖;若真的需要共享事实,就通过带版本和 owner 的 API 暴露,而不是暴露可写变量。

提示48:如果全局唯一非常重要,那么将它包装到API 中

有些东西确实应该只有一个,例如租约协调器、审计序列或当前选举出的 leader。问题不在于它唯一,而在于所有调用者都直接触碰它的实现。用 <Term def="规定调用者能传什么、得到什么以及失败如何返回的交接合同;它是依赖可见化的最后一道边界。">API 边界</Term>包装唯一性,让调用者只依赖命令、查询、版本和拒绝条件;未来替换存储或部署方式时,唯一性仍由同一份合同守住。

读图:五个复核坐标怎样连成因果链

图中从“耦合”走向“API 边界”,每一步都把一种隐含知识转成可观察证据。先看完整路径,再用分步演示检查哪里拥有状态、哪里只发送意图;如果中间出现 <Term def="两个动作必须依赖某个未声明的先后顺序才能正确工作的隐藏依赖。">时序耦合</Term>,不要用“通常如此”继续推进,而要把确认、版本或消息写进合同。

解耦路径:让变化停在拥有数据的 API 边界画出依赖 → 找到责任 → 只发命令 → 封装状态 → 重放验证耦合数据 / 控制 / 时间先画出依赖边变化从哪里进入?调用链谁调用谁?停止跨对象索取责任在哪一层?全局数据谁拥有状态?首差:全局旁路能否移除共享写入?命令查询意图与结果分开命令只改自己的状态调用者需要知道什么?API 边界变化停在契约重放并验证首差下游收到什么?验收合同:只改一个实现,只有声明的 API 结果穿过边界必要依赖留下 owner、输入、输出与失败动作;不相关变化在边界停止提示44:解耦代码让改变更容易先预测首差,再推进一个节点;重置后应能用同一输入重建结论。

第 1 / 5 步 · 耦合:先画出依赖边

每一步只把一个依赖变成可观察的边界合同。

解耦的目标不是删除所有协作,而是让必要协作可见、可拒绝、可重放。
分步1 / 3

1. 画出耦合与调用链

解耦路径:让变化停在拥有数据的 API 边界画出依赖 → 找到责任 → 只发命令 → 封装状态 → 重放验证耦合数据 / 控制 / 时间先画出依赖边变化从哪里进入?调用链谁调用谁?停止跨对象索取责任在哪一层?全局数据谁拥有状态?首差:全局旁路能否移除共享写入?命令查询意图与结果分开命令只改自己的状态调用者需要知道什么?API 边界变化停在契约重放并验证首差下游收到什么?验收合同:只改一个实现,只有声明的 API 结果穿过边界必要依赖留下 owner、输入、输出与失败动作;不相关变化在边界停止提示44:解耦代码让改变更容易先预测首差,再推进一个节点;重置后应能用同一输入重建结论。

第 1 / 5 步 · 耦合:先画出依赖边

每一步只把一个依赖变成可观察的边界合同。

解耦的目标不是删除所有协作,而是让必要协作可见、可拒绝、可重放。

把订单、库存、通知和共享计数器列为对象,标注数据、控制、时间和环境依赖。先预测替换库存实现时哪些对象不应变化,再圈出调用者必须知道的最小 API;没有 owner 的边先标为风险,不能假装它是稳定协议。

代码对照:命令拥有改变,查询拥有读取

先把“读—算—写”的链式责任收回库存模块。调用者只表达意图,模块内部负责校验和状态变化;返回值不泄露内部对象。

type ReserveResult =
  | { ok: true; itemId: string; remaining: number }
  | { ok: false; reason: "not-found" | "insufficient" | "version-conflict" };
 
class Inventory {
  private readonly quantities = new Map<string, number>();
 
  reserve(itemId: string, quantity: number): ReserveResult {
    const available = this.quantities.get(itemId);
    if (available === undefined) return { ok: false, reason: "not-found" };
    if (quantity <= 0 || available < quantity)
      return { ok: false, reason: "insufficient" };
    const remaining = available - quantity;
    this.quantities.set(itemId, remaining);
    return { ok: true, itemId, remaining };
  }
 
  remaining(itemId: string) {
    return this.quantities.get(itemId) ?? 0;
  }
}

这里的 reserve 是命令,remaining 是查询;调用者不能取得 quantities 的引用,也不能把计算后的数字写回。真实系统还需要版本、幂等键和事务边界,但它们都应出现在合同里,而不是藏在链式调用者的记忆中。

下一段故意保留一个错误模式:全局计数器被通知与结算共同写入。先猜一猜替换通知实现后谁会变化,再用测试断言“库存 owner 之外没有写入”。

function placeOrder(inventory: Inventory, itemId: string, quantity: number) {
  const result = inventory.reserve(itemId, quantity);
  if (!result.ok) return result;
  return { ...result, event: "inventory-reserved" as const };
}
 
function readReservation(inventory: Inventory, itemId: string) {
  return { itemId, remaining: inventory.remaining(itemId) };
}

代码只返回稳定的结果形状;如果下游仍需要内部字段,说明边界还没有完成。测试要覆盖正常、恰好库存、重复命令、版本冲突和查询发生在命令之前这几类条件,并把首差和拒绝原因保存下来。

正常、边界与单故障矩阵

样本只改变的变量预期必存证据
正常提交一个有效的 reserve 命令owner 更新局部状态,API 返回稳定结果输入、owner、输出与状态版本
边界数量刚好等于库存或版本已过期明确接受或拒绝,不产生假成功阈值、拒绝原因、写入次数
单故障只注入全局数据旁路在全局数据节点暴露首差并停止首差、旁路对象、恢复与重放

三类样本共享同一订单、代码版本和起始状态。若同时更换存储、消息协议和重试策略,结果就不能归因于“解耦”这一项;应恢复基线,重新只动一个变量。

可重放的解耦记录

decoupling_record:
  unit: tpp20-topic-28-decoupling
  object: inventory-reservation
  owner: inventory-service
  declared_api: reserve(itemId, quantity) -> ReserveResult
  dependency_map: data, control, time, environment
  injected_change: replace-inventory-index
  first_difference: global-counter-write
  rejected_path: caller-read-then-write
  recovery: restore-baseline-and-replay-original-input
  proof: command, query, boundary, and downstream-call assertions

这份记录让独立复核者不需要相信作者的解释:他可以使用相同输入,检查 owner 是否唯一,触发一个故障,比较第一处状态变化,再验证修复后的 API 拒绝旁路。若查询在命令之前返回了旧值,那是一个要被写入合同的并发边界,而不是可以被日志掩盖的偶发性。

跨团队迁移:保留语义,替换实现

迁移到云服务、消息系统或 AI 辅助开发时,先保留“谁拥有状态、谁接收命令、谁只读结果”这三个语义,再替换存储、传输或生成工具。自动化工具可以画依赖图、生成接口或补测试,但通过一条静态检查不等于证明没有运行期耦合;仍要用真实输入跑一项边界和一项单故障。

独立复核者只接收 API 合同、起始状态、故障变量和验收断言,不接收作者期待的操作顺序。结果不一致时,比较首个分叉和原始日志;若团队无法说出一个状态的 owner,就先拒绝扩大变更范围。

本章回顾

  • 解耦控制变化传播,不追求删除所有必要协作。
  • 命令把改变交给状态 owner,查询只读取稳定结果。
  • 链式调用和全局状态把内部知识泄漏给调用者,应收回边界。
  • 唯一性可以存在,但必须通过 API 暴露输入、输出和拒绝条件。
  • 每次只改变一个条件,记录首差,重置后从原始输入重放。

可验证练习

练习

本组练习覆盖 28 解耦提示44:解耦代码让改变更容易提示45:只管命令不要询问提示46:不要链式调用方法提示47:避免全局数据提示48:如果全局唯一非常重要,那么将它包装到API 中。每题都要写出对象、owner、唯一变化、首差和恢复证据。

问题 1:改 Demo 代码。placeOrder 改成一个只接受 reserve 命令结果的函数,禁止调用者直接读取库存数量再写回;为“库存不足”写一条断言。

问题 2:定位链式调用的首差。 order.customer().address().city() 在地址可选后开始偶发失败。你会把哪一段提升为边界 API?应记录什么证据?

问题 3:单故障与恢复。 实验台注入全局数据旁路后,通知模块读到了一个提前增加的计数。请写出首差、拒绝动作和恢复动作,并说明为什么不能直接把计数减回去。

名词解释

名词解释

本章出现的专业名词,用大白话再讲一遍。

耦合

一个地方的变化会牵动另一个地方的关系;解耦就是让这种牵动有边界、有主人。

火车残骸式调用

沿着多个对象一节一节取值的调用写法,像拉着一串车厢,也把内部结构暴露给调用者。

命令查询分离

改变状态的操作和读取状态的操作分开,调用者更容易知道一次调用会不会产生副作用。

全局状态

很多模块都能直接读写的共享数据;它方便但会让变化从一个角落偷偷跑到远处。

API 边界

模块与外部交接的合同,写清输入、输出、失败方式和谁拥有状态。

时序耦合

两个动作必须按照某个未声明的先后顺序执行才不会出错;把顺序写进协议才能复核。

来源与改写范围

本页是基于公开目录的 independent rewrite;五节点模型、TypeScript 示例、专属图示、故障实验和练习均为本课程重新设计,不声称提供原书全文或原书答案。

前后导航

讨论

评论区加载中…