代码库设计
设计深模块:把大量行为放在一个小接口后面,安放在一个干净的接缝处,并可通过这个接口测试。凡是正在设计或重构代码的地方,都使用这套语言和这些原则。目标是:让调用方得到杠杆,让维护者得到局部性,让所有人得到可测试性。
术语表
严格使用这些术语:不要替换成「组件」「服务」「API」或「边界」。语言一致正是全部意义所在。
模块:任何有接口和实现的东西。刻意不限定规模:它可以是一个函数、一个类、一个包,或者一个横跨多层的切片。避免:单元、组件、服务。
接口:调用方为了正确使用这个模块而必须知道的一切:类型签名,还有不变量、顺序约束、错误模式、必需的配置,以及性能特征。避免:API、签名(太窄,它们只指类型层面的表面)。
实现:模块内部的东西,也就是它的代码主体。与适配器不同:一个东西可以是一个实现很大的小适配器(一个 Postgres 仓储),也可以是一个实现很小的大适配器(一个内存里的假实现)。当话题是接缝时用「适配器」,其他情况用「实现」。
深度:接口处的杠杆。调用方(或测试)每学一单位接口,能驱动多少行为。当一个模块把大量行为放在一个小接口后面时,它是深的;当接口几乎和实现一样复杂时,它是浅的。
接缝 (Michael Feathers):一个你可以在不编辑该处代码的前提下改变行为的地方;它是模块的接口所在的位置。接缝放在哪里本身就是一项设计决策,与接缝后面放什么无关。避免:边界(这个词与 DDD 的限界上下文相冲突)。
适配器:在某个接缝处满足某个接口的具体东西。它描述的是角色(占据哪个位置),而不是实质(里面是什么)。
杠杆:调用方从深度中得到的东西。每学一单位接口,就多一分能力。一份实现,在 N 个调用点和 M 个测试上得到回报。
局部性:维护者从深度中得到的东西。改动、缺陷、知识和验证都集中在一处,而不是散布到各个调用方。修一次,处处都修好。
深与浅
深模块 = 小接口 + 大量实现:
┌─────────────────────┐ │ Small Interface │ ← Few methods, simple params ├─────────────────────┤ │ │ │ Deep Implementation│ ← Complex logic hidden │ │ └─────────────────────┘
浅模块 = 大接口 + 少量实现(应当避免):
┌─────────────────────────────────┐ │ Large Interface │ ← Many methods, complex params ├─────────────────────────────────┤ │ Thin Implementation │ ← Just passes through └─────────────────────────────────┘
设计一个接口时,问自己:
- 我能减少方法的数量吗?
- 我能简化参数吗?
- 我能把更多复杂度藏在里面吗?
原则
- 深度是接口的属性,不是实现的属性。 一个深模块内部可以由小的、可模拟、可替换的部件组成;它们只是不属于接口的一部分。一个模块除了接口处的外部接缝,还可以有内部接缝(对它的实现私有,供它自己的测试使用)。
- 删除测试。 想象把这个模块删掉。如果复杂度消失了,它就是一个直通管道。如果复杂度在 N 个调用方那里重新出现,那它就证明了自己存在的价值。
- 接口就是测试面。 调用方和测试穿过同一个接缝。如果你想绕过接口去测试,这个模块的形状大概就不对。
- 一个适配器意味着假想的接缝,两个适配器才意味着真实的接缝。 除非确实有东西在接缝两侧发生变化,否则不要引入接缝。
为可测试性而设计
好的接口让测试变得自然:
-
接受依赖,不要自己创建依赖。
typescript// Testable function processOrder(order, paymentGateway) {} // Hard to test function processOrder(order) { const gateway = new StripeGateway(); } -
返回结果,不要制造副作用。
typescript// Testable function calculateDiscount(cart): Discount {} // Hard to test function applyDiscount(cart): void { cart.total -= discount; } -
表面积要小。 方法更少 = 需要的测试更少。参数更少 = 测试的准备更简单。
关系
- 一个模块恰好有一个接口(它向调用方和测试呈现的表面)。
- 深度是模块的属性,对照它的接口来衡量。
- 接缝是模块的接口所在的地方。
- 适配器位于接缝处,满足接口。
- 深度为调用方产生杠杆,为维护者产生局部性。
被否决的说法
- 把深度看作实现行数与接口行数的比值(Ousterhout):这会奖励往实现里灌水。我们改用「深度即杠杆」。
- 把「接口」理解为 TypeScript 的
interface关键字或一个类的公开方法:太窄了:这里的接口包含调用方必须知道的每一个事实。 - 「边界」:与 DDD 的限界上下文冲突。请说接缝或接口。
深入下去
- 在给定依赖的情况下加深一簇代码,见 DEEPENING.md:依赖分类、接缝纪律,以及「替换而不是分层」的测试方式。
- 探索备选接口,见 DESIGN-IT-TWICE.md:起多个并行子代理,用几种截然不同的方式设计这个接口,然后按深度、局部性和接缝位置做比较。


