28 解耦
从知识、状态、行为和时序四类耦合入手,减少火车残骸式调用与全局依赖。
学习目标
- 能把一条调用链拆成依赖类型、状态所有者、边界合同和首个变化证据
- 能把“只管命令不要询问”和“避免全局数据”改写为可拒绝、可测试的 API 行为
- 能在一次只改变一个条件的实验中定位首差,重置后从原始输入重放解耦结论
为什么解耦不是把线全部剪断
想象仓库里有五个工作台:收货、库存、拣货、出库和通知。它们当然需要协作,但如果拣货员必须先翻开库存台的抽屉,再读取通知台的计数器,最后把修改写回收货台,任何一台改动都会迫使其他人记住更多内部细节。真正的问题不是“有线”,而是线没有主人、没有方向,也没有失败时的停点。
28 解耦把这类关系变成可以验收的边界:先识别依赖,再声明所有者;让调用者发送意图,让拥有数据的对象完成改变;最后从 API 的输入、输出与拒绝条件验证变化是否停在局部。读者不需要背一条漂亮口号,而要能回答“谁改变了什么、谁接收结果、哪里第一次与预测不同”。
本页依据 David Thomas、Andrew Hunt《程序员修炼之道:通向务实的最高境界(第2版)》,云风译,电子工业出版社,2020年4月,ISBN 9787121384356 的公开完整中文目录,独立重构 28 解耦、提示44:解耦代码让改变更容易、提示45:只管命令不要询问、提示46:不要链式调用方法、提示47:避免全局数据 与 提示48:如果全局唯一非常重要,那么将它包装到API 中。示例、代码、图示、实验和练习均为本课程重新设计,不复制原书正文、插图、练习答案或代码。
先把变化对象写在纸上
进入实验前,先写出对象、当前状态、下游接收者和拒绝条件。这里的基线是一个订单库存服务:调用者提交 reserve(itemId, quantity),库存模块拥有可变数量,通知模块只接收“已预留”的结果。请先预测:如果有人直接改共享计数器,首差会出现在命令、状态还是 API 边界?再打开实验台,推进到全局数据节点观察变化。
第 1 / 5 步:耦合 已留下边界证据。
第 1 / 5 步 · 耦合:先画出依赖边
先预测全局旁路会在哪里变成首差,再重置并比较修复后的边界。
常见误区与回退
五节点模型:让依赖通过边界说话
一个系统可能有必要的协作,但每条协作都应该是有名字、有方向、有 owner 的合同。<Term def="一个部分的变化会迫使另一个部分跟着变化的关系;解耦要做的是控制这种变化传播。">耦合</Term>不是一个需要被追求为零的分数,而是需要被定位、命名和验证的关系。
28 解耦:先识别谁被谁牵动
把一次“替换库存实现”的请求沿五个节点推进:耦合描述变化可能沿哪条边扩散;调用链显示调用者知道了多少内部步骤;全局数据揭示有没有绕过所有者的旁路;命令查询分开改变和读取;API 边界则给出最终可复核的输入、输出与拒绝条件。五个节点不是五个层级模板,而是五种观察问题。
提示44:解耦代码让改变更容易
改变更容易,不是因为代码变少,而是因为一个实现的替换不会要求无关调用者同步了解内部结构。选择“替换库存索引”为唯一变化,先预测业务规则、通知结果和审计事件不变,再用契约测试检查边界;如果通知突然依赖索引的内部字段,首差就在 API 之前,而不是等整套回归结束才发现。
提示45:只管命令不要询问
调用者如果先询问余额、再计算、再把新值写回,就承担了本应属于状态所有者的规则。命令表达“请按合同预留”,返回结果表达“已接受、拒绝或需要重试”;调用者不必知道内部如何锁定、校验和持久化。这样数量规则改动时,变化停在拥有数据的对象。
<Term def="把改变状态的命令和读取状态的查询分开,让调用意图和副作用更容易被看见。">命令查询分离</Term>让这条边界可以被测试:命令检查写入和拒绝,查询检查稳定读取,二者都不把内部容器交给调用者。
提示46:不要链式调用方法
<Term def="沿着多个对象逐层取值的调用链,调用者因此知道了内部结构和空值路径。">火车残骸式调用</Term>例如 order.customer().address().city() 看起来只是取值,却把内部结构和空值路径暴露给调用者。一次需求变化可能同时修改 customer、address 和 city 的接口。把需要的业务意图提升为 order.shippingCity() 或一个稳定查询,让内部链条留在 owner 内部,并让边界测试验证缺失地址时的拒绝结果。
提示47:避免全局数据
<Term def="由多个不相关模块直接读写、任何人都可能改变的共享状态;它会让局部变化产生远处的副作用。">全局状态</Term>让依赖变得不可见:测试必须按某种顺序运行,重试计数器会影响结算,配置变更会改变并未参与本次操作的模块。把状态封装进拥有者,或者将它变成显式依赖;若真的需要共享事实,就通过带版本和 owner 的 API 暴露,而不是暴露可写变量。
提示48:如果全局唯一非常重要,那么将它包装到API 中
有些东西确实应该只有一个,例如租约协调器、审计序列或当前选举出的 leader。问题不在于它唯一,而在于所有调用者都直接触碰它的实现。用 <Term def="规定调用者能传什么、得到什么以及失败如何返回的交接合同;它是依赖可见化的最后一道边界。">API 边界</Term>包装唯一性,让调用者只依赖命令、查询、版本和拒绝条件;未来替换存储或部署方式时,唯一性仍由同一份合同守住。
读图:五个复核坐标怎样连成因果链
图中从“耦合”走向“API 边界”,每一步都把一种隐含知识转成可观察证据。先看完整路径,再用分步演示检查哪里拥有状态、哪里只发送意图;如果中间出现 <Term def="两个动作必须依赖某个未声明的先后顺序才能正确工作的隐藏依赖。">时序耦合</Term>,不要用“通常如此”继续推进,而要把确认、版本或消息写进合同。
第 1 / 5 步 · 耦合:先画出依赖边
每一步只把一个依赖变成可观察的边界合同。
1. 画出耦合与调用链
第 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 边界
模块与外部交接的合同,写清输入、输出、失败方式和谁拥有状态。
- 时序耦合
两个动作必须按照某个未声明的先后顺序执行才不会出错;把顺序写进协议才能复核。
来源与改写范围
- 作者与英文版页面:核对20周年版书名、版本和 Topic 28 的英文目录坐标。
- 中文公开目录:核对“28 解耦”与提示44至提示48的中文目录位置。
- 书目与版本记录:辅助核对译者、出版社、出版时间与 ISBN。
本页是基于公开目录的 independent rewrite;五节点模型、TypeScript 示例、专属图示、故障实验和练习均为本课程重新设计,不声称提供原书全文或原书答案。