过度设计、YAGNI 与糟糕的上下文押注
过度设计不是“设计太多”,而是一笔糟糕的上下文交易。本章把过度设计与设计不足看作对预期上下文成本的错误定价,并给出判断结构何时值得付出的静态与时间两条标准。
本文为中文版,根据英文原版改写,而非逐句直译。
一个团队在做 B2B SaaS 产品。产品还没有第二个客户,就有人提议先做一层多租户:租户感知的查询、按租户划分的缓存键、按租户路由的后台任务、按租户生成的报表。一位评审者说这是整洁架构,另一位说这是过度设计。两人回应的都是真实的成本,只是他们在为不同的未来定价。
这就是关于过度设计的争论常常无解的原因。争的很少只是抽象本身好不好,而是:今天付出的上下文成本,是否值得换取明天可能省下的上下文成本。
过度设计(over-engineering)不是”设计太多”,而是一笔糟糕的上下文交易:某个设计手法增加了上下文成本——一条新边界、一个新概念、一次额外的跳转、一份需要学习的索引——却没有相应地削减、迁移或索引足够多的上下文成本。
要判断一个设计决策是否值得,最好区分两种情形。第一种情形中,相关的修改流——也就是我们预期要做的那类修改——已经明确或高度受限,评判设计近乎一次记账。第二种情形中,我们身处一个仍在演进的系统,未来需求并不确定,设计决策就成了一次押注,而不是一笔账目。
1. 静态标准:当修改已经明确
先看简单的情形:假设我们已经知道会发生哪类修改。也许我们知道这个服务需要接入三家支付服务商,也许我们知道这条导入管线要支持十种文件格式。这时可以问一个非常实际的问题:
这个设计能否收回成本?
抽象、分层、接口、注册表、配置和设计模式本身无所谓好坏。只有当它们节省的上下文多于迫使下一位工程师加载的上下文时,才值得加入。
一个设计手法通常从三个方面增加成本:
- 边界成本——每个抽象都给工程师带来一个新接口要学习:名称、输入、输出、错误行为、不变量和边界情况。好的边界能收回成本,因为人们可以在边界处止步,不必读完整个实现。坏的边界是浅的:学习接口的成本几乎和它隐藏的实现一样高,几乎没有减少你需要加载的上下文总量。这与 Ousterhout 在 A Philosophy of Software Design 中提出的深模块与浅模块是同一种直觉。
- 携带深度——有些设计选择会变成收费站,许多未来的修改都得从这里经过,即使这些修改并不受益于该设计。如果每条数据库查询都要经过一层多租户路由,那么即便是一份简单的内部报表,也得先理解这一层。这笔准备成本不是一次付清,而是每次有人碰到附近的代码都要再付一次。
- 概念面——有些设计会给代码库增加词汇:新概念、规则、约定、注册表、生成文件,或者”我们这里做 X 的方式”。即使是做无关功能的工程师,在命名、重构、评审、调试或入职时也可能需要掌握这些词汇。比如,一旦代码库采用了自己的错误处理约定——比如一个自定义 Result 类型,每个函数都应返回它、每个调用方都必须拆包——工程师在做与这个约定的初衷毫不相干的功能时,也得带着这套词汇。
只要设计能在别处消除更大的成本,这些成本就可以接受。设计收回成本的常见方式有:
- 跳过深度——可靠的接口让你读契约即可,不必读内部实现。
- 收拢闭包——反复出现的修改模式被集中到一处,未来的修改不必再到处搜寻分散的位置。
- 索引闭包——相关位置可能仍是分散的,但可以通过注册表、穷尽匹配、生成构件、命名约定,甚至仅仅是注释轻松找到。
- 检查隐性上下文——测试、类型和语言机制能捕捉那些否则只存在于人脑中的假设。
所以静态规则很简单:
当一个设计手法削减、迁移、索引或检查的上下文,大于它增加的边界成本、携带深度和概念面时,它就是合理的。
2. 时间标准:当未来是一场押注
上面的规则适用于我们已知未来修改形态的情况。大多数真实代码库没有这么奢侈。我们添加结构,往往是因为认为某个未来需求可能会来。
这让不确定性下的设计更像押注,而不是记账。我们现在付出看得见的成本,换取一个日后未必兑现的收益。过度设计,就是这笔押注定价过高的结果。
推理这个问题的清晰方式,是比较两个世界。
在现在就建的世界里,我们今天就加入结构。假设团队现在就搭好多租户接缝:每条查询、每个缓存键、每个后台任务、每份报表、每条会话路径都变得租户感知。从此以后,工程师在代码库中工作时都得带着这个模型。只有当未来的 SaaS 需求大致以这种形态出现时,回报才会兑现。
在推迟的世界里,我们暂不搭建这个接缝。代码保持单租户、直接明了,避免了今天的携带成本。但如果日后 SaaS 真的来了,就得有人找出每一处假设只有一个租户的地方:查询、缓存键、URL 路由、会话、报表、测试、日志、权限、部署假设,等等。
问题不是”抽象好不好”,而是:
我们避免的未来上下文成本,是否可能大于我们现在付出的上下文成本?
当未来的修改不太可能发生、规模小、范围局部或日后容易解决时,YAGNI 是正确的选择。当等待会给下一位工程师留下一大堆分散、难以发现的假设去还原时,提前构建结构才是正确的。
这里有两种风险值得关注。
形状错误风险是建错接缝的风险。也许我们加了行级 tenant_id 模型,而后来真正的需求是物理层面的租户隔离。这时,抽象并没有给我们买来所付钱的那个未来。更糟的是,它可能变成多余的机制,未来的工作还得绕着它走。
闭包未索引风险是推迟得太随意的风险。如果每条查询和每个缓存键都默默假设只有一个租户,那么单租户决策其实已经散布到整个系统。如果没有任何东西为这个假设命名或建立索引,未来的改造之所以困难,就不只是因为要改的地方多,还因为工程师不知道搜索何时才算完整。
这两种风险方向相反。形状错误风险提醒我们不要建得太早;闭包未索引风险提醒我们不要让重要假设在没有名字的情况下四处泄漏。YAGNI 和”三次法则”(Rule of three)之所以有用,是因为它们帮助校准时机:等得足够久,让真实形态浮现;但又不能等太久,以免决策变得不可见、四处分散。
由此得到四种常见结果:
- 合理的提前构建:结构现在有成本,但它避免或索引了一个更大的未来修改闭包。
- 符合 YAGNI 的推迟:未来的修改始终足够小、足够局部或足够容易发现,现在构建结构的成本会超过它节省的成本。
- 过度设计:接口负担、携带深度、概念面和形状错误风险,大于实际避免的未来成本。
- 设计不足:今天的简单让一个现实可能发生的未来修改散落各处、无名无姓,使得日后还原所需上下文的代价高昂或不可靠。
好的设计不是”更多抽象”,也不是”更少抽象”。好的设计只在能让当前或未来的修改更易理解的地方,付出看得见的成本。
设计不足(under-engineering)是过度设计的镜像,也会躲在同样诱人的口号后面。过度设计常以”整洁架构”或”面向未来的设计”自居;设计不足则以”简单可靠”自居:抽象更少、活动部件更少、需要解释的机制更少。当未来的修改很小或不太可能发生时,这完全正确。但当”简单”的代码悄悄散布了一个重要假设,却没有给未来的工程师留下名字、索引或检查时,它就成了设计不足。今天的代码感觉很轻松,因为没有加任何抽象、一切都能跑;隐藏的账单则在日后由未来的修改者支付:他们必须重新发现这个假设藏在哪里、什么依赖它、修改可以在哪里停下。
3. 让隐藏成本更容易讨论
这条标准给不出精确的数字。你通常无法算出某个未来需求出现的概率,也算不出改造的确切成本。不过这没关系——精确从来不是目标,重要的是让这笔交易变得可见。
许多设计争论卡在口味标签上:
- “这是整洁架构。”
- “这抽象过头了。”
- “YAGNI。”
- “我们需要让它可扩展。”
CMP 把这种争论拆成两个独立的问题:
- 预测问题:这个设计假设了什么样的未来修改?我们押注的是更多租户、更多支付服务商、更多文件格式、更严格的合规、更高的流量,还是别的修改流?
- 设计问题:如果那个未来真的发生,这个设计是处理它的好办法吗?它削减、迁移、索引或检查的上下文,是否多于它通过边界成本、携带深度和概念面增加的上下文?
这种拆分之所以重要,是因为团队常常在争论设计时,暗自假设着不同的未来。一位工程师评估多租户层时,默认 SaaS 很可能到来;另一位评估同一层时,默认产品会一直是内部工具。他们其实还没有在代码上产生分歧,分歧在于代码背后的预测。
一旦把预测说清楚,设计争论就会变得具体:在这个未来假设下,设计能否收回成本?如果能,它可能是合理的提前构建;如果不能,那么即使那个未来真的到来,它也是过度设计;如果未来假设本身就站不住,YAGNI 大概是更好的选择。
许多架构争论,其实是披着设计风格外衣的关于未来的争论。过度设计和设计不足都不是风格问题,而是给上下文错误定价的两种方式:太早付出太多,或者打着”简单可靠”的旗号,把太多未命名的东西留给以后。