跳到内容
观澜涉川 · 札记EN

Duplication over DRY:用重复换取解耦

· 约 9 分钟阅读

写在前面

本文由 AI 执笔,人类立意。
Written by AI, imagined by a human.

过去十几年里,软件架构的审美发生了一次静悄悄却很彻底的转向。曾经 DRY(Don’t Repeat Yourself)几乎是不可挑战的第一原则,代码里但凡出现一点相似,我们的手就痒,非要把它抽出来复用;而在今天的现代软件架构、尤其是微服务架构里,“Duplication over DRY” 正逐渐成为一种共识——宁可容忍一点重复,也不轻易引入一个抽象。

我不打算假装中立。这篇文章就是来给这个立场站台的:我认为,在大多数现代系统里,“重复优先” 是一种更成熟、更诚实的审美。下面我先讲这股共识是怎么形成的、它到底买到了什么、代价又是什么,再把它延伸到 Cloud Engineering:多环境 IaC(基础设施即代码),以及多个 IaC 项目之间的依赖。

为什么”重复优先”会形成共识

一、DRY 的本意被长期误读了。 《The Pragmatic Programmer》里的原始表述是:“Every piece of knowledge must have a single, authoritative representation within a system.” 关键词是 knowledge——它约束的是”知识”的单点,不是”代码文本”的单点。两段长得像的代码,背后未必是同一份知识;强行合并,反而是把两件不同的知识焊死在一起。

二、错误的抽象比重复更贵。 这是这股共识的核心论据,Sandi Metz 把它总结成了一句几乎可以直接贴在墙上的名言:

“Duplication is far cheaper than the wrong abstraction.” —— Sandi Metz

在我看来,这句话之所以成立,是因为两者的成本结构完全不同:重复是局部的、可见的、随时可消除的;而错误的抽象是全局的、隐蔽的、复利式增长的。一旦两个本应独立演化的东西被合并,日后每次需求分叉,你都只能往里塞参数、加开关,最后它变成谁都不敢碰的泥球。我愿意把话再说过头一点:好的抽象难求,烂的抽象难逃。

三、微服务把”独立部署”抬成了黄金法则。 Sam Newman 在《Building Microservices》里把 Independent Deployability(独立部署性) 视为第一原则,并明确提醒:跨服务共享一个库,会把多个服务锁死在同一条发布节奏上——一次改动要让所有消费方一起升级。那等于把分布式系统又收回成”分布式单体”。为了独立部署,宁可容忍一点重复。

同一取舍在分布式架构里还有几个更标准的说法,值得一提:

  • Data Redundancy / Data Replication(数据冗余 / 数据复制):订单服务里保存一份客户资料的副本(名称、联系方式等),并随用户服务的事件保持最终一致,这样下单、查单时都不必在运行时回查用户服务——用一点数据的重复,换服务之间的解耦。(注意区分:这里复制的是”用户那份数据”的副本,会随上游更新;如果存的是下单那一刻的联系方式,那其实是订单自己的数据,属于快照、不算这个模式——正如订单的收货电话未必等于用户当前的手机号。)
  • Share Nothing Architecture(无共享架构):服务之间宁可各自拥有一整套代码、逻辑甚至数据库,也绝不共享任何资产,以此换取极致的解耦。

相比之下,“A little copying is better than a little dependency.”(Rob Pike, Go Proverbs)——这句话我几乎想印在工位上。

四、服务与团队的边界是真实的。 共享代码一旦跨越团队边界,任何改动都要所有消费方协调;而”各复制一份”把协调成本直接降到零。这背后有 Conway 定律的影子:系统的结构终会映射组织的沟通结构。与其和现实对抗,我更愿意顺着边界切。

五、领域驱动设计(DDD)给了它理论外壳。 在 限界上下文(Bounded Context) 之下,同一个”商品”在仓储系统和销售系统里长得再像,也属于不同上下文,就该是两套模型,而不是共用一个类。这一点我年轻时不信,现在深信。

六、还有一条朴素的经验法则:Rule of Three。 第一次照抄、第二次照抄,等到第三次再考虑抽象——因为到那时你才真正看清”共同点”到底是什么。

重复买到了什么

  • 切断变更耦合:改一处不必改另一处。
  • 切断部署耦合:各自发布、各自回滚,互不阻塞。
  • 局部可读、无间接层:直接看代码就知道发生了什么,不必在层层抽象之间跳来跳去。
  • 缩小爆炸半径:一份坏了,不牵连另一份。
  • 更低的协调成本:跨团队不必为了改一行代码而开会同步。

代价:重复不是免费的

我不想把话说得太满。重复有它真实的账单:

  • 漂移:两份实现会逐渐发散,甚至走向不一致。
  • 成倍维护:缺陷修复、安全补丁、依赖升级,每多一份实现就多一份。
  • 认知负担:面对多份实现,先得回答”哪份才是对的?”
  • 失去单一来源:全局一致性与可追溯性更难保证。

所以真正的问题从来不是”要不要重复”,而是——重复的到底是不是同一份知识。 是知识,就必须单点;只是碰巧长得像的工件,重复往往更划算。既然代价真实存在,就需要纪律与护栏来兜底。

延伸到 Cloud Engineering

前面这套取舍,落到云上的基础设施同样成立——甚至更明显:在 IaC 里,“必须独立演化、独立部署”的边界,被环境天然地切好了。下面我以用 Pulumi 管理基础设施为例,把它拆成两件事来讲(换成 Terraform、CDK 之类,道理完全一样)。

多环境 IaC:资源取舍分环境,资源形状单点

维护 dev / test / staging / prod 多套环境时,我的分工原则很简单——按”要创建哪些资源”和”资源长什么样”这两件事来切:

  • 要创建哪些资源,在每个环境自己的部署栈里定义——需要什么、要不要建,都由这份配置说了算。不同环境可以不一样;就算暂时长得一样,那也是”各自声明”的结果,而不是从一份共享模板派生出来的。
  • 资源长什么样(命名、结构、父子关系、通用标签),放进共享的 Python 代码——这是必须保持一致的知识,所以单点。

示意一下:

environments/dev.yaml
storage:
- name: logs
sku: Standard_LRS

Python 侧读取这份 YAML,按统一命名规范把 name 拼成 stdevlogs...,再创建资源。于是”重复”发生在资源声明层:每个环境各写各的。

为什么这么切?因为这两半的”命运”正好相反——“资源长什么样”必须始终一致,“要创建哪些资源”却会随环境演进分叉。 举个真实的例子:

  • 项目成长期:部署与放行顺序天然是 dev → test → staging → prod,一路验证、一路提升。哪怕四个环境最终的状态非常接近,它们在开发过程中也是在不同的时机被验证和部署的——dev 先上,prod 最后。
  • 维护阶段(开发进入低活跃期):需求变少,方向反转,我们可能反过来收缩——逐步下线 dev / test,只保留 staging / prod。

也就是说,“哪个环境还有哪些资源”本来就是会变、且各不相同的。把它预先抽成一份共享模板,只会让你不断往模板里打补丁。而命名、结构这类东西,我们希望所有环境一字不差,那正是该收敛到单点的知识。

反过来最容易踩的坑,是把”要创建哪些资源”写进共享代码,比如 if env == "prod": ...。我反对的正是这个:这不是”代码还是配置”的问题,而是把”应该独立演化的东西”当成了”必须保持一致的知识”——结果改一处、牵连全环境,独立演化与爆炸半径隔离同时失效。(如果代码是纯数据驱动的——一套机制配一张”每环境有哪些资源”的表——那还好,本质和 YAML 没区别;真正的坑是逻辑分支。)

有人会说:既然 Python 生成逻辑本来就是共享的,那为什么不把”每个环境要哪些资源”也一并抽象掉,写一份”基础配置”,再由各环境派生?答案还是那句:会分叉的东西,别共享。 四个环境跑在各自的部署栈上,这本来就是常态;该共享的是”资源长什么样”的知识(Python),不是”每个环境要建哪些资源”这份意图。后者一旦抽象,四个环境就又被焊在一起——动一处模板,四个环境一起变。

好处也很直接:dev 里可以随手新建、改配置、跑一段时间验证,稳定后再提升到更高环境;每个环境都是一个能独立折腾的”雪花”,而不是被一份共享模板绑在一起。

多个 IaC 项目之间:把依赖变成显式契约

大型项目往往会被拆成多个 IaC 项目,项目之间必然有依赖。常见做法是用 StackReference / terraform_remote_state / 共享模块,在部署时自动读取上游的输出。它”方便”,却把两个项目的部署时序、状态、生命周期绑在了一起——上游没部署完下游就起不来,上游输出一改名下游就静默炸掉。在我看来,这恰恰是”错误的抽象”在基础设施层的翻版,而且更难排查。

我更偏爱另一种做法:不引入任何运行时/部署时的自动引用,而是手动把上游的稳定标识(资源标识、名字、端点)复制到下游项目里,作为显式输入。

这里要诚实:这并没有消除依赖。资源确实存在,也确实被引用。改变的是依赖的形态——从”隐式、自动、会牵连部署顺序”,变成”显式、单向、可读”。好处随之而来:

  • 独立部署:下游只依赖一个已经落地的值,不需要上游在线。
  • 执行顺序一眼可读:先后关系写在配置里,而不是藏在部署引擎的依赖图里。
  • 爆炸半径可控:一个项目出问题,不会顺着引用链扩散。
  • 边界清晰:这就是限界上下文在基础设施层的翻版。

代价同样存在,所以我会配几条护栏:

  1. 尽量让接口稳定:与其复制不透明的资源标识,不如复制稳定命名——此时命名规范本身就是公开契约。
  2. 契约校验:持续集成里加一步,检查下游引用的上游值是否存在、是否符合约定。
  3. 明确所有权:谁拥有上游,变更时如何通知下游。
  4. 只在”变更/部署耦合”确实带来痛苦时重复;否则回到抽象。

一个简单的判断清单

想抽象还是想重复?我会先问自己四个问题:

  1. 两边是同一份知识,还是碰巧长得像?(同一份 → 抽象)
  2. 它们需要独立演化 / 独立部署吗?(需要 → 重复)
  3. 合并之后,需求一旦分叉,会不会在抽象里反复打补丁?(会 → 别合并)
  4. 重复带来的漂移风险,有没有可承受的护栏?(没有 → 别轻易重复)

结语

“Duplication over DRY” 之所以在微服务与云架构里逐渐成为共识,是因为大家终于算清了这笔账:重复的成本是局部的、可见的、可控的,而错误抽象的成本是全局的、隐蔽的、复利的。

但它不是教条,而是一种需要勇气的权衡——因为它逆着我们”看到重复就想消除”的直觉。要守住的底线只有一条:知识必须单点。 在这条底线之外,为了独立部署、独立演化、清晰的边界与可读的执行顺序,主动接受一份重复,往往比硬凑一个错误的抽象更便宜,也更好维护。

Duplication is a design decision, not a sin.

参考

  • The Pragmatic Programmer — DRY(知识的单一权威表示)
  • Sandi Metz, The Wrong Abstraction (2016)
  • Rob Pike, Go Proverbs (2015)
  • Sam Newman, Building Microservices — Independent Deployability
  • Eric Evans, Domain-Driven Design — Bounded Context