2.2 软件设计原则
概述与动机
软件设计原则是一套关于如何组织代码的启发式方法,好让你能够随着时间的推移理解它、修改它、扩展它。它们包括一些著名的缩写词(SOLID 代表五项面向对象设计原则,DRY 代表”不要重复自己”,KISS 代表”保持简单”,YAGNI 代表”你不会需要它”)、结构性概念(耦合、内聚、关注点分离)、被编目的设计模式、诸如领域驱动设计(Domain-Driven Design,用业务领域的语言来对软件建模)这样更高层次的建模方法,以及在面向对象、函数式和面向数据风格之间的取舍。这些都不是定律,而是被压缩的经验,你必须凭借判断力去运用它们。
对大型团队而言,共享原则的价值在于协调。当数百名工程师在同一个系统上工作时,他们需要一套共同的词汇来进行设计讨论,也需要一套共同的默认选择,才能让独立编写的模块彼此契合。好的设计,正是让许多人能够并行修改一个系统、而不至于不断相互碰撞的原因。它也正是让一个系统在十年之后(企业和政府系统的正常寿命,远超其最初作者的任期)依然可以被修改的原因。
真正关键的技能不是背诵这些原则,而是知道每一个原则在什么情况下会把你引入歧途。每一个原则都有它的失效模式:DRY 可能产生错误的抽象,SOLID 可能产生不必要的间接层,YAGNI 可能扼杀你真正需要的可扩展性。本章把这些原则当作有适用范围的工具来对待,并把耦合与内聚这两个更深层的属性,作为这些缩写词真正想要服务的目标来强调。
关键原则
- 优先管理耦合与内聚;大多数被命名的原则,都是改善这两个属性的间接手段。
- 为变化而优化:好的设计能把你未来真正需要做出的那些变更的成本降到最低。
- 优先选用当下就能奏效的最简单设计,但要在变化可能发生的地方保留边界。
- 重复的代价低于错误的抽象;要等到模式变得清晰之后再动手。
- 让依赖关系显式化,并让它们指向稳定的事物。
- 用领域的语言为领域建模;让软件边界与业务边界保持一致。
- 依据问题本身来选择范式,而不是依据意识形态;大多数大型系统在实用主义上都是混合的。
建议
把 SOLID 当作一副透镜,而不是一份检查清单
在真正存在某个边界的地方,运用单一职责原则来保持模块内聚,运用依赖倒置原则让依赖指向抽象,并在扩展点确实存在的地方运用开闭原则。不要仅仅为了满足这个缩写词,就在只有一种实现、也看不到第二种实现的情况下,硬造出接口、工厂和层次。间接层是有代价的,你在每一次阅读代码时都要为它买单。
把 DRY 应用于知识,而不是文本
DRY 讲的是不要重复某一份权威的知识,而不是消除那些仅仅看起来相似的代码行。两段看起来相似、却因不同原因而变化的代码,应当保持分离。与其过早地共享一个把不相关事物耦合在一起的抽象,不如容忍一点重复。等真正的模式出现两三次之后,再提炼出这个抽象。
让 KISS 和 YAGNI 抵御投机式设计
针对你现有的需求去构建,而不是针对你想象出来的需求。避免投机式的泛化,比如没有任何人要求过的可配置框架、插件系统和扩展点。与之相平衡的是,有些灵活性确实值得提早构建进去,比如一个稳定的接口或一道干净的接缝。YAGNI 反对的是投机式的实现,而不是反对经过深思熟虑的边界设计。
明确地为低耦合、高内聚而设计
让每个模块只做一件定义明确的事情(内聚),并通过狭窄的接口,尽可能少地依赖其他模块(低耦合)。在评审一个设计时,要问一问哪些变更会跨越模块边界产生连锁反应。那些连锁反应正是耦合的真实度量。关注点分离,就是把这同一个理念应用到层次和横切关注点上。
把设计模式当作词汇来使用,把反模式当作警示信号来运用
模式是为反复出现的解决方案提供的一套有用的共享名称。当问题真正与某个模式相匹配时,就去运用它。不要为了显得高深而强加各种模式,因为堆满模式的代码往往是过度工程的征兆。要把常见的反模式(上帝对象、不恰当场合下的贫血模型、大泥球、分布式单体)当作诊断标签来学习。
在领域足够复杂的地方采用领域驱动设计
对于拥有丰富业务规则的系统,使用 DDD 的战术性和战略性工具:与领域专家共享的通用语言、把系统划分为可被独立建模的各个部分的限界上下文,以及描述这些部分之间关系的上下文映射图。限界上下文在企业规模下尤其有价值,因为它让团队的所有权与模型边界对齐。对简单的 CRUD(增、删、改、查)系统而言,DDD 是过度的。
按适配度来选择范式
用面向对象来封装有状态的行为并为领域建模。用函数式风格来处理转换、并发,并通过不可变性获得可预测性。在性能和缓存行为占主导地位的地方,使用面向数据的设计。大型系统会把这三者混合在一起使用。要按组件逐一做出选择,并让不同风格之间的边界保持干净。
权衡:利与弊
| 原则/方法 | 运用得当时 | 失效模式 |
|---|---|---|
| SOLID | 在变化发生之处形成清晰的接缝;单元可测试 | 接口和层次的泛滥;没有回报的间接层 |
| DRY | 为真实知识提供单一权威数据源 | 错误的抽象把不相关的代码耦合在一起 |
| KISS/YAGNI | 精简、易于理解的系统 | 设计不足的接缝;日后为补上所需灵活性而付出高昂改造成本 |
| 设计模式 | 共享词汇;经过验证的结构 | 模式的盲目照搬;意外的复杂度 |
| 领域驱动设计 | 模型与团队对齐;驯服复杂度 | 在简单领域上大搞仪式;上下文边界划错地方 |
| 函数式/不可变 | 可预测性;更安全的并发 | 与本质上有状态的问题格格不入;性能上的意外 |
反复出现的张力,存在于设计不足与设计过度之间。设计不足的系统会不断累积耦合,变得僵化。设计过度的系统则会淹没在必须有人去理解和维护的抽象之中。答案不是一个固定的点,而是一种纪律:把决策推迟到你掌握了足够的信息之后再做,同时保留那些让你能够改变主意的接缝。
需要与团队讨论的问题
你们提炼共享抽象的具体门槛是什么,你们如何防止 DRY 产生错误的抽象? 本章明确指出,重复的代价低于错误的抽象,而且你应当等到某个模式出现过两三次之后,再动手提炼。在一个大型团队里,危险之处在于,有人把两段看起来相似的代码片段,跨团队边界地重构成一个共享模块,从此对其中一个调用方的每一次未来修改,都会连锁反应到另一个调用方。值得带来讨论的信号是:这些重复的代码,是因为同一个原因而变化,还是此刻只是看起来相似而已。就”三次法则”达成一致,并要求一个候选抽象在真正一起变化过之后,才能把调用方耦合起来。就这一条约定,就能防止一类一旦被许多团队依赖、就代价高昂到难以解开的耦合。
你们如何在设计评审中让耦合和内聚变得可见,而不是任由它们凭直觉判断? 关键原则把耦合和内聚置于所有缩写词之上,并把耦合定义为那些跨越模块边界产生连锁反应的变更。直觉无法在数百名工程师之间扩展,因为每个人只看得到系统中自己那一角。带来一台机器能够产出的证据:依赖关系图,以及显示哪些模块总是在同一次提交中被一起修改的协同变更数据。在评审中加入一个明确的问题:这次变更迫使你跨越了哪些模块边界。当两个模块总是一起变化时,这就是提示你要么把它们合并,要么修复它们之间的边界。
在你们的系统中,一个复杂到足以证明采用领域驱动设计是合理的领域,与一个用它就是大材小用的普通 CRUD 应用之间的分界线在哪里? 本章推荐 DDD 的限界上下文,正是因为它们能让团队所有权与模型边界对齐,同时也警告说,对简单的增删改查系统而言 DDD 是大材小用,会退化成没有真正建模价值的形式主义。在任何一个方向上判断失误,代价都很高:在一个单薄的领域上大搞 DDD,会把一个简单的应用埋进仪式感里;而一个横跨许多团队的庞大共享模型,则会迫使团队之间不断进行协调。带来真正能决定这一点的信号:业务规则的密度,以及有多少个团队需要独立拥有各自的部分。把战略性的机制留给复杂的核心,让简单的边缘保持简单。这样你就能同时避开 DDD 形式主义和大泥球这两种陷阱。
一个抽象、接口或设计模式,什么时候才值得它所带来的那份间接层,谁有权认定一个设计是过度工程? 本章明确指出,间接层是有代价的,你在每一次阅读代码时都要为它买单,而仅仅为了满足 SOLID 或显得高深,就硬造出接口、工厂和层次,正是一种失效模式。在一个大型团队里,压力往往朝相反方向施加:评审者会因为额外的抽象看起来很规范而放行,没有人愿意成为那个主张”少一点结构”的人。这里相互竞争的考量是真实存在的,因为有些接缝确实物有所值,日后移除它们代价高昂。带来具体的证据来讨论:一个接口今天实际上有多少种实现、这个扩展点曾经真正被使用过几次,以及一个读者要打开多少个文件才能追完一条代码路径。要达成共识:一个只有一种实现、看不到第二种实现的抽象,默认就应该被内联,并明确指定谁有权把一个设计标记为过度工程,而不会被当成一种冒犯。在那些寿命比作者的任期还要长十年的企业和政府系统中,没必要的间接层,是每一位未来的维护者都要缴纳的税,所以要把”这个抽象给我们带来了什么”当作一个常设的评审问题,而不是一次针对个人的挑战。
你们如何决定每个组件使用哪种范式()面向对象、函数式,还是面向数据()你们又如何让它们之间的边界保持干净? 本章主张,大型系统在实用主义上是混合的,你应当按组件、依据适配度逐一选择:用面向对象来处理有状态的领域,用函数式风格来处理转换和并发,在性能和缓存行为占主导的地方使用面向数据的设计。如果放任不管,范式的选择就会变成”谁先写这个模块就听谁的”,可变状态就会渗入本应是纯转换的地方,或者一种函数式的纯粹主义,会去硬碰一个本质上有状态的问题。值得带来的证据,是你们真正的痛点所在:哪些组件因为隐藏的状态而难以测试、哪些热路径受限于缓存,以及当前的风格在哪里逼出了别扭的变通方案。要有意识地为每一层决定默认范式,并把不同风格之间接缝的位置写下来,这样一个函数式的核心和一个命令式的边缘,就不会相互渗透。对于一个受监管或政府系统而言,如果某项计算必须在给定期间内可审计、可复现,一个不可变的函数式核心往往是一项合规要求,而不是一种品味,这项约束应当驱动这条边界的划定,而不是跟随它。
你们如何防止这些原则硬化成教条,你们又在哪里记录一个设计决策背后的推理,好让未来的团队能够重新审视它? 本章中的每一项原则都有其适用范围和失效模式,整个框架都把它们当作需要凭判断力来运用的工具,而不是需要强制执行的法律。在一个大型团队里,一项原则会悄悄变成一条规则:DRY 禁止任何形式的重复,SOLID 强制要求每个类都有一个接口,而那些引用缩写词、却不谈结果的人,会在评审中把务实的例外挡在门外。这里的张力在于,一定程度的一致性确实有助于数百名工程师协调,所以你不能简单地宣布每一项原则都是可选的。带来那些严格遵循某项原则、结果却产生了更糟设计的例子,也带来能解释某个边界或抽象为何存在的决策记录(如果有的话)。达成共识:这些原则是默认选项,工程师可以在有记录的理由下偏离它们,并把有分量的设计选择,记录进一份简短的架构决策记录,让下一个团队继承的是推理过程,而不仅仅是代码。在企业和公共部门系统中,最初的作者早已离开,审计会追问系统为何是现在这个样子,这份书面记录,正是未来团队能够安全修改这个设计、还是不敢触碰它的分水岭。
行业视角
初创公司。 优先选择能够交付的最简单设计,保留一个组织良好的模块,直到一个真实的第二个用例迫使你划出一道接缝。你最稀缺的资源是工程注意力,所以过早的接口、层次和投机式框架纯粹是成本。在提炼任何共享抽象之前,先遵循三次法则,让 YAGNI 去扼杀那些还没有人要求过的扩展点。
小型企业。 由于没有专职架构师,预算也紧张,要依靠你所购买的框架和库中已经内置的设计,而不是自己发明模式。把自定义设计的精力,留给那少数真正属于你们业务本身的规则,让其余一切都保持常规,这样一位承包商或新员工也能看懂。一点你自己理解的重复,胜过一个只有作者本人才能维护的巧妙抽象。
企业。 共享原则的回报,在于跨众多团队的协调:一套用于设计评审的共同词汇,以及能让模型边界与团队所有权对齐、从而让各组独立演进的限界上下文。用依赖关系和协同变更数据,明确地管理耦合和内聚,并记录有分量的设计决策,让系统在其作者离开之后很久,依然保持可修改性。要同等地防范那种把团队耦合在一起的错误抽象,以及那种向每一位读者征税的过度工程。
政府。 可审计性和可复现性常常主导着设计。一个不可变的函数式核心,能让你为给定的时期精确复现一次历史计算,而一个带有隐藏可变状态的、纠缠不清的对象图无法保证这一点。在上下文边界处,优先选用显式发布的契约,而不是共享的数据表,并让设计及其决策记录,对审计人员、以及十年后继承这个系统的任何一个团队来说,都保持清晰易读。
示例
初创公司。 一家三人规模的初创公司在构建他们的第一款产品,他们抵住了把每个功能都拆分成一层层接口和工厂的冲动,保留一个组织良好的单一模块,直到一个真实的第二个用例出现。当同一段逻辑在注册和计费流程中第三次出现时,他们提炼出一个小小的共享函数,而不是一个投机式的框架。这让代码库保持足够小,小到他们中的任何一个人都能把它装在脑子里,而他们所划出的那少数几道接缝,恰好落在产品最可能发生变化的地方。
企业。 一个大型保险平台把保单、理赔和计费建模为各自独立的限界上下文,每一个都由一个专职团队拥有,各自有自己的数据模型和服务边界。在这些上下文相遇的地方,比如一项理赔引用了一份保单时,它们通过显式发布的契约来沟通,而不是通过共享的数据库表。这让这三个团队能够独立演进,而通用语言让与核保人员和精算师的对话保持精确。此前的一个版本曾经共享过一个庞大的单体模型,每一次变更都需要跨团队协调。
政府。 一个国家级税务处理系统,在其计算引擎中刻意选用了面向数据、函数式的核心。税务规则被表达为对不可变输入记录的纯转换,这让它们可审计、可测试,并能为给定的纳税年度精确复现。命令式的、有状态的部分(工作流、通知)则被留在边缘。审计人员能够指向某个具体的规则版本,精确复现任何一次历史计算,这是一项法定要求,而一个带有隐藏可变状态的、纠缠不清的对象图无法保证这一点。
商业理由:动机、投资回报率与总拥有成本
设计质量,是对一个系统可变性的一项投资,而可变性主导着总拥有成本。一个系统的大部分成本,发生在它首次发布之后的修改和扩展阶段。设计良好的系统,能让每次变更的成本随时间大致保持平稳。设计糟糕的系统,则会看到每一次变更的成本不断攀升,直到这个系统实际上变得无法修改,不得不被重写()这是所有结局中代价最高的一种。
采纳这些原则的成本,主要是技能和评审纪律:传授这些原则,并在前期投入设计时间。不采纳它们的代价,则是技术债务的缓慢累积、交付速度的下降、缺陷率的上升,以及最终代价高昂的重写。要向领导层阐明这一理由,就要把设计纪律与交付的可预测性、以及避免重写项目联系起来,并随时间追踪诸如变更失败率、以及实现同类功能所需时间这样的领先指标。同时也要警惕相反的失败:为不确定的未来过度投资于设计,同样会破坏价值。因此,这里的论点是为了恰如其分的设计,依据未来变化的可能性和成本来校准。
反模式与陷阱
- 投机式泛化: 为永远不会到来的想象中的需求构建可扩展性。
- 错误的抽象: 为了满足 DRY 而强行把不相关的代码拼在一起,产生比重复更糟糕的耦合。
- 模式的盲目照搬: 为了模式本身而运用设计模式,增加了没有收益的间接层。
- 贫血模型或上帝对象: 没有行为的模型,或者什么都做的对象;两者都预示着职责被放错了地方。
- 分布式单体: 服务在物理上被拆分,却仍然紧密耦合,兼具两种方式的成本。
- 大泥球: 没有可辨认的结构;每一次变更都危及一切。
- DDD 形式主义: 只采用了它的词汇和文件夹结构,却没有那份真正赋予其价值的领域建模。
成熟度模型
- 第 1 级,启动: 设计是临时且被动的;耦合不受控制地累积;这些原则不为人知,或仅仅被当作口号来引用,抽象的出现和消失全凭个人习惯。
- 第 2 级,发展: 团队了解这些原则并加以运用,但做法不一致,常常流于教条;一些小组有意识地管理耦合和内聚,另一些则没有,整个组织也没有共同的词汇。
- 第 3 级,标准化: 一套共享的设计词汇、一条用于提炼抽象的三次法则、耦合与内聚分析,以及与团队对齐的限界上下文,都被记录下来,并在全组织范围内被期望遵循、在设计评审中被一致运用,而不是任凭个人喜好。
- 第 4 级,管理: 设计健康度依据基线被度量:耦合和协同变更数据、变更失败率,以及实现同类功能所需的时间,都会被随时间追踪,因此抽象和边界的增加、保留或移除都建立在证据之上,过度工程和错误的抽象由数据、而不是意见来发现。
- 第 5 级,编排: 设计纪律与整个组织的交付和风险规划整合在一起;这些原则被细致入微地运用,人们了解其已知的失效模式;范式和边界的选择是审慎的,并被持续重新审视,组织会随着领域和证据的变化,常态化地重构、重新界定范围,并淘汰各种抽象。
讨论思路
- 在你还没有掌握未来需求之前,你如何区分一道真正需要的接缝和一种投机式的泛化?
- DRY 曾在什么时候把你的团队引向了错误的抽象,你们是怎么发现的?
- 限界上下文的边界应该划在哪里,它们应该在多大程度上映射组织架构图?
- 在你所处的情境下,应该有多少设计工作先于编码进行,你们又如何记录这些决策?
- 你系统的哪些部分,会从更偏函数式或更偏面向数据的风格中受益?
- 你如何防止设计原则硬化成一种抵制务实例外的教条?
关键要点
- 耦合与内聚才是真正重要的属性;那些缩写词只是通往这两个目标的手段。
- 每一项原则都有其失效模式;要知道每一个原则在什么情况下会把你引入歧途。
- 优先选择一点重复,而不是一个过早或错误的抽象。
- 用 DDD 和限界上下文,让复杂的领域与团队所有权对齐。
- 按适配度来选择范式;大型系统在实用主义上是混合的。
- 为你真正会需要的变更而设计,同时避免设计不足和设计过度这两个极端。
参考资料与延伸阅读
- Robert C. Martin,Clean Architecture 与 Agile Software Development, Principles, Patterns, and Practices
- Eric Evans,Domain-Driven Design: Tackling Complexity in the Heart of Software
- Vaughn Vernon,Implementing Domain-Driven Design
- Erich Gamma、Richard Helm、Ralph Johnson、John Vlissides,Design Patterns: Elements of Reusable Object-Oriented Software
- Martin Fowler,Refactoring: Improving the Design of Existing Code 与 Patterns of Enterprise Application Architecture
- David L. Parnas,On the Criteria to Be Used in Decomposing Systems into Modules
- Sandi Metz,Practical Object-Oriented Design