mirror of
https://github.com/zakirullin/cognitive-load.git
synced 2025-10-09 13:42:36 +03:00
403 lines
35 KiB
Markdown
403 lines
35 KiB
Markdown
# 认知负荷才是关键
|
||
|
||
[Prompt](README.prompt.md) | [MindsMD](https://minds.md/zakirullin/cognitive) | [English](README.md) | [Korean](README.ko.md) | [Turkish](README.tr.md) | [Japanese](README.ja.md)
|
||
|
||
*这是一份持续更新的文档,最后更新:**2025 年 9 月**。欢迎你的贡献!*
|
||
|
||
*本译文基于原文 [4a6d92d](https://github.com/zakirullin/cognitive-load/commit/4a6d92d4b0cf326d82b9beaf43d05696b03d22f9) 版本进行翻译。*
|
||
|
||
## 简介(Introduction)
|
||
这世上充斥着各种流行术语与“最佳实践”,但大多数最终都失灵了。我们需要更基础、更不可能出错的东西。
|
||
|
||
有时我们在阅读代码时会感到困惑。困惑消耗时间和金钱。困惑源于过高的“认知负荷”。它不是某种花哨的抽象概念,而是**人类的一种基本约束**。它不是臆想出来的,它的确存在,而且我们能真切感受到。
|
||
|
||
鉴于我们在阅读与理解代码上所花费的时间远多于书写代码所花费的,我们应当持续地自省:我们是否正在把过多的认知负荷添加到代码中。
|
||
|
||
## 认知负荷(Cognitive load)
|
||
> 认知负荷是指开发者为了完成一项任务需要动多少脑子。
|
||
|
||
阅读代码时,你会把变量的取值、控制流逻辑、调用序列等“装”进脑子里。普通人的工作记忆大约能同时容纳[四个这样的信息块](https://github.com/zakirullin/cognitive-load/issues/16)。一旦认知负荷接近这个阈值,理解就会变得困难得多。
|
||
|
||
*假设我们被要求去修补一个完全陌生的项目。有人告诉我们,之前有位非常聪明的开发者贡献过:用了很多酷炫的架构、花哨的库、时髦的技术。也就是说,**作者为我们制造了极高的认知负荷。***
|
||
|
||
<div align="center">
|
||
<img src="/img/cognitiveloadv6.png" alt="随着开发的深入,程序和脑子都变成了一团浆糊" width="750">
|
||
</div>
|
||
|
||
我们应该尽可能降低项目中的认知负荷。
|
||
|
||
<details>
|
||
<summary><b>认知负荷与打断</b></summary>
|
||
<img src="img/interruption.jpeg"><br>
|
||
</details>
|
||
|
||
> 我们会以一种非正式的方式使用“认知负荷”这个术语;有时它与认知负荷的科学概念一致,但我们并不确切知道在哪些地方一致、哪些地方不一致。
|
||
|
||
## 认知负荷的类型(Types of cognitive load)
|
||
**内在负荷(Intrinsic)**——由任务本身的固有难度引起。它不可消减,是软件开发的核心所在。
|
||
|
||
**外在负荷(Extraneous)**——由信息的呈现方式引入。由与任务不直接相关的因素造成,比如“聪明作者”的各种癖好。它可以被大幅削减。本文将聚焦于这种外在认知负荷。
|
||
|
||
<div align="center">
|
||
<img src="/img/smartauthorv14thanksmari.png" alt="如果外在负荷占据了主导地位,情况就不再是喜闻乐见了" width="600">
|
||
</div>
|
||
|
||
下面直接看一些外在认知负荷的具体、可操作的例子。
|
||
|
||
---
|
||
|
||
我们将这样标注认知负荷的程度:
|
||
`🧠`:工作记忆刚刚清空,认知负荷为零
|
||
`🧠++`:工作记忆里已经有两个事实,认知负荷上升
|
||
`🤯`:认知过载,超过 4 个事实
|
||
|
||
> 我们的大脑远比这复杂且尚未被充分理解,但这个简化模型足以用来说明问题。
|
||
|
||
## 复杂的条件控制(Complex conditionals)
|
||
```go
|
||
if val > someConstant // 🧠+
|
||
&& (condition2 || condition3) // 🧠+++, 前置条件需为 true,c2 或 c3 必须其一为 true
|
||
&& (condition4 && !condition5) { // 🤯, 到这一步我们基本已经被绕晕了
|
||
...
|
||
}
|
||
```
|
||
|
||
用有意义的中间变量重构:
|
||
```go
|
||
isValid = val > someConstant
|
||
isAllowed = condition2 || condition3
|
||
isSecure = condition4 && !condition5
|
||
// 🧠,无需死记条件表达式,变量名已自描述
|
||
if isValid && isAllowed && isSecure {
|
||
...
|
||
}
|
||
```
|
||
|
||
## 多层嵌套的 if 判断(Nested ifs)
|
||
```go
|
||
if isValid { // 🧠+, 嵌套代码仅适用于有效输入
|
||
if isSecure { // 🧠++, 仅对有效且安全的输入执行
|
||
stuff // 🧠+++
|
||
}
|
||
}
|
||
```
|
||
|
||
对比一下“提前返回”式的:
|
||
```go
|
||
if !isValid
|
||
return
|
||
|
||
if !isSecure
|
||
return
|
||
|
||
// 🧠, 不必关心之前的 return;能走到这里就表示一切 OK
|
||
|
||
stuff // 🧠+
|
||
```
|
||
|
||
通过这种写法,我们可以专注于主流程,从而使“工作记忆”从各种先决条件中解放出来。
|
||
|
||
## 多继承噩梦(Inheritance nightmare)
|
||
我们要对管理员用户做一些改动:`🧠`
|
||
|
||
`AdminController extends UserController extends GuestController extends BaseController`
|
||
|
||
哦,部分功能在 `BaseController`,看看先:`🧠+`
|
||
基础角色机制是在 `GuestController` 引入的:`🧠++`
|
||
一些逻辑在 `UserController` 被部分改写了:`🧠+++`
|
||
终于来到 `AdminController`,开始干活吧!`🧠++++`
|
||
|
||
等等,还有个 `SuperuserController` 继承了 `AdminController`。改 `AdminController` 可能会破坏子类逻辑,所以先去看 `SuperuserController`:`🤯`
|
||
|
||
尽量倾向组合而非继承。细节不展开——参考资料已经[汗牛充栋](https://www.youtube.com/watch?v=hxGOiiR9ZKg)。
|
||
|
||
## 过多微型方法,类或模块(Too many small methods, classes or modules)
|
||
> 在这里,“方法”“类”“模块”可以互换理解
|
||
|
||
“方法少于 15 行”“类应该小”这些准则,实践下来并不总是对的。
|
||
|
||
**深模块(Deep module)**——接口简单,但功能强大、实现复杂
|
||
**浅模块(Shallow module)**——相较其提供的少量功能,对外提供的接口反而比较复杂
|
||
|
||
<div align="center">
|
||
<img src="/img/deepmodulev8.png" alt="相较于深度“抽象”的浅模块其内部犬牙交错的调用关系,深模块可能因为链条清晰而让理解与维护成为可能" width="700">
|
||
</div>
|
||
|
||
浅模块过多会让项目难以理解。**不仅要记住每个模块的职责,还要记住它们之间的所有交互**。要理解一个浅模块的目的,往往得先看完与之相关的所有模块功能。频繁在这些浅组件之间跳转令人心力交瘁,<a target="_blank" href="https://blog.separateconcerns.com/2023-09-11-linear-code.html">线性思维</a>对人类更自然。
|
||
|
||
> 信息隐藏至关重要;而浅模块隐藏的复杂性并不多。
|
||
|
||
我有两个宠物项目,都大概 5K 行代码。第一个有 80 个浅类,第二个只有 7 个深类。我一年半没维护它们了。
|
||
|
||
回头再看,第一个项目中 80 个类之间的交互简直难以理清。在能动手写代码之前,我得先重建一大坨认知负荷。相反,第二个项目很快就能上手,因为它只有少数几个接口简单的深类。
|
||
|
||
|
||
> 最好的组件是功能强大而接口简单的组件。
|
||
> **John K. Ousterhout**
|
||
|
||
UNIX I/O 的接口很简单,只有 5 个基本调用:
|
||
```python
|
||
open(path, flags, permissions)
|
||
read(fd, buffer, count)
|
||
write(fd, buffer, count)
|
||
lseek(fd, offset, referencePosition)
|
||
close(fd)
|
||
```
|
||
|
||
现代实现可以有**数十万行代码**。尽管背后是大量复杂性逻辑,但因为接口设计简单,使用就容易。
|
||
|
||
> 这个深模块的例子来自 John K. Ousterhout 的著作 [《软件设计的哲学》](https://web.stanford.edu/~ouster/cgi-bin/book.php)。此书不仅直击软件开发复杂性的本质,还对 Parnas 那篇影响深远的论文 [《On the Criteria To Be Used in Decomposing Systems into Modules》(论将系统分解为模块的标准)](https://www.win.tue.nl/~wstomv/edu/2ip30/references/criteria_for_modularization.pdf)做了极佳的诠释。二者皆为必读。更多延伸阅读:[《〈软件设计的哲学〉与〈代码整洁之道〉》](https://github.com/johnousterhout/aposd-vs-clean-code)、[《可能是时候停止推荐〈代码整洁之道〉了》](https://qntm.org/clean)、[《小型函数的弊端》](https://copyconstruct.medium.com/small-functions-considered-harmful-91035d316c29)。
|
||
|
||
P.S. 如果你以为我们是在为臃肿、职责过多的“万能对象”摇旗呐喊,那你就误解了。
|
||
|
||
## 关于“仅为一件事负责” (Responsible for one thing)
|
||
我们常常遵循一种模糊的原则,结果造出一堆浅模块:“一个模块应该只负责一件事,而且仅此一件”。但这“一件事”到底是什么?实例化一个对象也算一件事,对吧?那么 [MetricsProviderFactoryFactory](https://minds.md/benji/frameworks) 就理所当然?**这类类名和接口带来的心智负担,往往比它们整个实现还高——这算什么抽象?**哪儿不对劲了。
|
||
|
||
我们修改系统是为满足用户和利益相关方的诉求。我们应当对他们负责。
|
||
|
||
> 一个模块应当且只应当对一个用户或利益相关方负责。
|
||
|
||
这才是单一职责原则(SRP)的真正内涵。通俗地说,如果我们在一个地方引入了 bug,然后两位不同业务条线的人都来投诉——我们就违反了该原则。它与我们在模块中做了多少事情无关。
|
||
|
||
但即便如此,这条规则也可能“利少弊多”。每个人对它的理解都不尽相同。更好的做法是看它们带来了多少认知负荷。记住“一个地方的变更会在不同业务流中引发一串连锁反应”本身就很费脑力。就这样,无须再学什么花哨术语。
|
||
|
||
## 过多的“浅微服务” (Too many shallow microservices)
|
||
“浅-深模块”原则与规模无关,同样适用于微服务架构。浅微服务太多,毫无益处——行业正朝着某种“宏服务”发展,即不那么浅(=更深)的服务。最糟且最难修复的现象之一是所谓“分布式单体”,它往往源于过度细粒度、过度浅化的拆分。
|
||
|
||
我曾为一家初创公司做咨询,五个开发做了 17(!)个微服务。他们比计划落后了 10 个月,离正式发布遥遥无期。每个新需求都需要修改 4+ 个微服务。在这样一个分布式系统里,复现和调试一个问题要花掉极长时间。正式推出的估时和认知负荷都高得令人无法接受。`🤯`
|
||
|
||
这是应对新系统不确定性的正确方式吗?在一开始就厘清正确的逻辑边界极其困难。关键是在合理的范围内尽可能晚地做决定,因为那时你手头掌握的信息最多。若一上来就引入网络层,我们的设计决策从一开始就变得难以回退。团队给出的唯一理由是:“FAANG(脸书、亚马逊、苹果、网飞和谷歌)证明了微服务架构是有效的”。*醒醒吧,别再做不切实际的美梦了。*
|
||
|
||
[Tanenbaum-Torvalds 辩论](https://en.wikipedia.org/wiki/Tanenbaum%E2%80%93Torvalds_debate)曾指出,Linux 的单体设计有缺陷且过时,应改用微内核架构。确实,从“理论与美学”的角度看,微内核似乎更优。然而从实践的角度看——三十年过去了,基于微内核的 GNU Hurd 仍在开发中,而单体结构的 Linux 无处不在。你现在阅读的这个网页由 Linux 伺服,你的智能茶壶都跑在 Linux 上,单体的 Linux。
|
||
|
||
经过精心设计,由模块良好隔离的单体系统,往往比由众多微服务组成的架构更灵活,维护所需的心智负担也更小。只有当“独立部署”的需求变得至关重要时(比如团队规模扩张),才应考虑在模块之间加入网络层,逐步演进为未来的微服务。
|
||
|
||
## 特性丰富的编程语言(Feature-rich languages)
|
||
当喜欢的语言发布新特性(Feature) 时,作为开发者我们往往会很兴奋。我们会学习它们,并在代码里用起来。
|
||
|
||
如果特性很多,我们可能会花费半小时,通过写几行代码来试试某个功能或者另一个功能。这多少有点浪费时间。但更糟的是,**等你过一阵子再回头看,你还得重新推演一遍当时的思路!**
|
||
|
||
**你不仅要理解这个复杂的程序,还得搞明白:当时写代码的人,为什么会觉得用这些特性来解决问题是个好办法。** `🤯`
|
||
|
||
这些话来自 Rob Pike。
|
||
|
||
> 减少选择,就能降低认知负荷。
|
||
|
||
编程语言的特性是很好的,只要这些特性之间互不干扰。
|
||
|
||
<details>
|
||
<summary><b>一位拥有 20 年 C++ 经验的工程师的想法 ⭐️</b></summary>
|
||
<br>
|
||
前几天我看我的 RSS 阅读器,发现“C++”标签下居然有三百来篇未读文章。我从去年夏天起就没读过一篇关于 C++ 的文章,而且我觉得我的感觉一如既往的好!<br><br>
|
||
我用 C++ 已经 20 年了,差不多是我人生的三分之二。我的大部分经验都花在语言的边边角角(各种未定义行为)上。这不是可复用的经验,如今要把它们全都丢下,多少有点奇怪和不安。<br><br>
|
||
比如你能想象吗,<code>||</code> 在 <code>requires ((!P<T> || !Q<T>))</code> 和 <code>requires (!(P<T> || Q<T>))</code> 中含义不同。前者是约束析取(constraint disjunction),后者是我们熟悉的逻辑或运算符,而且它们的表现是不同的。<br><br>
|
||
你不能仅仅为一个平凡类型(trivial type) 分配内存然后直接 <code>memcpy</code> 将字节复制过去——那并不会启动对象的生命周期。这在 C++20 之前是这样的。在C++20的版本里修了,但语言的认知负荷却只增不减。<br><br>
|
||
即便有些问题被修正了,认知负荷却在不断增加。作为专业的开发者,我需要知道什么被修了、何时修的、修之前是什么样。没错,C++ 对遗留的特性支持很好,这也意味着你<b>得直面</b>那些遗留。例如上个月同事问了我一个 C++03 的行为。<code>🤯</code><br><br>
|
||
以前有 20 种初始化方式。后来加了统一初始化语法。现在我们有 21 种了。顺带一提,还有谁能记住从初始化列表里选择构造函数的规则吗?好像是跟隐式转换和“信息损失最小”有关,<i>但如果</i>值在编译期是已知的,那就是另一回事了……<code>🤯</code><br><br>
|
||
<b>这些额外的认知负荷并非来自当前的业务任务,也不是领域的内在复杂性。它只是因为历史原因而存在的</b>(<i>外在负荷</i>)。<br><br>
|
||
我不得不定些规则。比如,如果某行代码不那么一目了然,我还得去回忆标准,那我最好别那样写。顺便说,标准大概 1500 页。<br><br>
|
||
<b>我绝不是在指责 C++。</b>我热爱这门语言。只是我累了。<br><br>
|
||
<p>感谢 <a href="https://0xd34df00d.me" target="_blank">0xd34df00d</a> 的来稿。</p>
|
||
</details>
|
||
|
||
|
||
## 业务逻辑与HTTP状态码(Business logic and HTTP status codes)
|
||
在后端我们返回:
|
||
`401` 表示 JWT 令牌过期
|
||
`403` 表示权限不足
|
||
`418` 表示用户被封禁
|
||
|
||
前端的工程师在实现登录功能时,会用到后端 API。于是他们必须在脑子里临时记住这样一组映射:
|
||
`401` 表示 JWT 令牌过期 // `🧠+`,好,先记着
|
||
`403` 表示权限不足 // `🧠++`
|
||
`418` 表示用户被封禁 // `🧠+++`
|
||
|
||
前端开发者(希望如此)会在代码里放一个“数字状态码 -> 语义”的映射,这样后面加入的贡献者就不用再在脑子里重新构建这套映射关系了。
|
||
|
||
然后 QA 进场:
|
||
“嘿,我遇到 `403`,这是令牌过期还是权限不足?”
|
||
**QA 不能马上开始测试,因为他们得先重建后端工程师当初在脑中构建的认知负荷。**
|
||
|
||
为什么要把这种自定义映射强行塞进我们的记忆中呢?更好的做法是把业务细节从 HTTP 传输协议中抽离,直接在响应体里返回自描述的代码:
|
||
```json
|
||
{
|
||
"code": "jwt_has_expired"
|
||
}
|
||
```
|
||
|
||
前端侧认知负荷:`🧠`(清空,脑中无须再记映射)
|
||
QA 侧认知负荷:`🧠`
|
||
|
||
同样的规则适用于各种“数字类型的状态码”(无论在数据库里还是别处)——**优先使用自描述的字符串**。我们早就不处在需要为 640K 内存做优化的年代了。
|
||
|
||
> 人们会为 `401` 和 `403` 争论不休,依据各自的心智模型(mental model) 下判断。新的开发者加入后,他们又得重新推演一遍当时的思路。也许你写了架构决策文档(Architecture Decision Record, ADR)来记录“为什么”要这样做,帮助新人理解当时的决策。但归根结底,这并不明智。我们可以大体把错误分成“用户相关”和“服务器相关”,除此之外,界线其实很模糊。
|
||
|
||
P.S. 区分“authentication”和“authorization”本身就令人头大。用更简单的词比如[“登录”(login)和“权限”(permissions)](https://ntietz.com/blog/lets-say-instead-of-auth/)可以降低认知负荷。
|
||
|
||
## 滥用 DRY 原则(Abusing DRY principle)
|
||
|
||
“不要重复你自己”(Do not repeat yourself, DRY)——这是作为软件工程师最早学到的原则之一。它深入骨髓,以至于我们难以容忍多出的几行重复代码。尽管它总体上是个良好且基础的准则,但过度使用会带来难以承受的认知负荷。
|
||
|
||
如今大家都基于逻辑上分离的组件构建软件。它们往往分布在多个代码库中,代表不同的服务。当你致力于消除一切重复时,可能最终会在本不相关的组件之间建立起紧耦合。结果是,一个地方的变更会在其他看似无关的地方产生意想不到的后果。这也会阻碍单独替换或修改某个组件的能力,因为它会牵一发而动全身。`🤯`
|
||
|
||
事实上,即使在单个模块内,也会出现同样的问题。你可能会基于一些看起来相似但长远来看根本不存在的共性而会过早地抽取出所谓的“通用功能”。这会导致产生一些不必要的抽象,难以修改或扩展。
|
||
|
||
Rob Pike 说过:
|
||
|
||
> 适度的复制好过引入新的依赖
|
||
|
||
我们常常过于抗拒“重复造轮子”,以至于为了使用一个很小的函数,不惜引入庞大而笨重的库。而这些库我们本可以轻松自己写出来。
|
||
|
||
**你的依赖库,也是你代码的一部分。** Bug 来去无踪,不可预测,当你为了定位问题的根源而不得不翻阅某个依赖库的的十几层调用链时,你会知道什么叫做折磨。
|
||
|
||
## 框架的紧耦合(Tight coupling with a framework)
|
||
框架里有很多“魔法”(A.K.A. 奇技淫巧)。当我们过度依赖某个框架时,**会迫使后来的开发者先去学习这些魔法**,这可能要花上几个月。虽然框架能让我们在几天内推出 MVP,但从长远看,它们往往带来不必要的复杂性与认知负荷。
|
||
|
||
更糟糕的是,当遇到一个与架构格格不入的新需求时,框架可能会成为最大的约束。于是人们开始 fork 框架并维护自有的定制版。想象下新人要积累多少认知负荷(也就是学会这个定制框架)才能产出价值。`🤯`
|
||
|
||
**当然,我们绝不是提倡从零开始造轮子!!**
|
||
|
||
我们可以以相对“框架无关”的方式编写代码。业务逻辑不应被放在框架内部;相反,它应该仅使用框架的组件。把框架放在核心逻辑之外,用它像用库(library)一样。这样一来,新加入的贡献者从第一天就能带来价值,而不需要先在框架的复杂细节中摸爬滚打。
|
||
|
||
> [《为什么我讨厌“框架”》](https://minds.md/benji/frameworks)
|
||
|
||
## 分层架构(Layered architecture)
|
||
这些东西,确实能让工程师的肾上腺素飙一飙。
|
||
|
||
我自己曾经是六边形架构(Hexagonal Architecture) / 洋葱架构(Onion Architecture) 的热情拥护者,持续了好几年。这里用、那里也用,还鼓励其他团队用。随后项目复杂度上去了,单看文件数量就翻了一倍。我们像是在写大量“胶水代码”。在需求持续变更的背景下,我们得在多层抽象里同时改动,一切变得繁琐乏味。`🤯`
|
||
|
||
抽象本应隐藏复杂性,但这里它只是增加了[间接层](https://fhur.me/posts/2024/thats-not-an-abstraction)。要快速定位问题,理解哪里出错了、缺了什么,通常需要沿着调用链逐步跟踪。但在这种分层架构中,层与层之间的解耦导致我们需要额外的、甚至割裂的多层调用链来找到错误点。每一条这样的调用链,都会占据我们有限的工作记忆。`🤯`
|
||
|
||
这种架构最初看起来很直观,但我们每次在项目中实践,收效都不如预期,得不偿失。最终我们回归朴素的依赖倒置原则。**不用再学什么端口/适配器,不要非必要的的横向抽象层,不制造外在负荷。**
|
||
|
||
<details>
|
||
<summary><b>编码原则与工作年限</b></summary>
|
||
<img src="img/complexity.png"><br>
|
||
<a href="https://twitter.com/flaviocopes">@flaviocopes</a>
|
||
</details>
|
||
|
||
如果你以为分层能让你快速替换数据库或其他依赖,那就错了。更换存储会带来大量问题;相信我们,数据访问层的抽象是你最不该担心的事情。最理想情况下,抽象也就省个 10% 的迁移时间(如果真能省的话);真正的痛点在于数据模型不兼容、通信协议、分布式系统挑战,以及[隐式接口](https://www.hyrumslaw.com)。
|
||
|
||
> 当 API 的用户足够多时,
|
||
> 你在契约里承诺了什么已不再重要:
|
||
> 系统一切可观察到的行为
|
||
> 都会被某些人所依赖。
|
||
|
||
我们做过一次存储迁移,而这花了大约 10 个月。老系统是单线程的,因此对外暴露的事件是顺序的。我们的系统都依赖这种可观察到的行为。但这种行为既不在 API 的契约里,也不在代码中体现。新的分布式存储没有这种特性,因此无法保证顺序,事件就会乱序触发。我们只花了几个小时就写好了新的存储适配器(storage adapter),这的确要归功于抽象化。**接下来的 10 个月,我们都花在处理乱序事件及其他挑战上。** 现在再说“抽象/分层能帮助我们快速更换组件”,有点可笑。
|
||
|
||
**既然分层架构带来了如此高的认知负荷,而未来可能没有回报,为什么要付出这样的代价?** 况且在大多数情况下,所谓“未来要替换某些核心组件”的场景根本不会发生。
|
||
|
||
这些架构本身并非基础性的原则,而是一些主观的、带偏见的实现方式。为什么要依赖这些主观解释?我们应该遵循更基础的规则:依赖倒置原则、单一事实来源(single source of truth)、认知负荷控制、信息隐藏。业务逻辑不应该依赖数据库、UI 或框架等底层模块。我们应该能够在不依赖基础设施的前提下,为核心逻辑编写测试,这就足够了。[参与讨论](https://github.com/zakirullin/cognitive-load/discussions/24)。
|
||
|
||
不要为了“架构”本身去叠加抽象层。只有在确实需要扩展点、并且有实际理由支撑时,才去增加抽象层。
|
||
|
||
**[抽象层不是免费的](https://blog.jooq.org/why-you-should-not-implement-layered-architecture),它们会占用我们有限的工作记忆**。
|
||
|
||
<div align="center">
|
||
<img src="/img/layers.png" alt="Layers" width="400">
|
||
</div>
|
||
|
||
## 领域驱动设计(Domain-driven design, DDD)
|
||
尽管领域驱动设计(Domain-driven design,缩写为 DDD)常常遭受误解,但其在某些方面的确有卓越之处。人们通常说的 “我们用 DDD 写代码”,这种说法其实是有些奇怪的,因为 DDD 是和问题空间(problem space)相关的,而不是和解决方案空间(solution space)相关的。
|
||
|
||
> 译注:<br>
|
||
> problem space: 问题空间,简单理解就是当前环境下业务所面临的一系列问题和背后的需求。<br>
|
||
> solution space: 解决方案空间,则是针对问题空间的解决方案,它思考的是如何设计实现软件系统以解决这些问题,它属于工程设计实施阶段,通常是技术专家主导的解决方案设计和实现。
|
||
|
||
|
||
|
||
通用语言(ubiquitous language)、领域(domain)、有界上下文(bounded context)、聚合(aggregate)、事件风暴(event storming)——这些都属于问题空间的范畴。它们帮助我们洞悉领域、划定边界。DDD 让开发者、领域专家与业务人员能用一种统一语言高效沟通。然而,相较于关注这些问题空间的方面,我们常常强调特定的文件夹结构、服务(service)、仓库(repository) 以及其他解决方案层面的技术,而忽略了DDD在问题空间上的问题。
|
||
|
||
问题在于,我们对 DDD 的理解往往是独特且主观的。如果我们在这种理解的基础上去写代码,就会制造大量额外的认知负荷 —— 未来的开发者只能陷入困境。`🤯`
|
||
|
||
Team Topologies 提供了一个更好、更易理解的框架,帮助我们在团队之间分担认知负荷。工程师在学习 Team Topologies 后,往往能形成较为接近的心智模型。相比之下,DDD 往往让 10 个读者形成 10 种不同的理解。它本该是“共同语言”,结果却成了“辩论场”.
|
||
|
||
## 熟悉的项目中的认知负荷(Cognitive load in familiar projects)
|
||
|
||
> 问题在于,**熟悉并不等于简单**。二者给人的*感觉*看似是一样的 —— 都让人不用费太多脑力就能轻松的在代码之间穿梭 —— 但原因完全不同。你使用的每一个看似“聪明”(其实是“自我炫技”)且非大家惯用的技巧,都会让别人付出额外的学习成本。一旦他们学会了,那与代码相处就没那么难了。这就是为什么你很难看出如何简化你已经熟悉的代码。我会尽量让“新来的开发者”在他们还没被环境同化之前来评审代码!
|
||
>
|
||
> 之前的作者很可能是一点点把代码写乱的,而非一次性造成的。你可能是第一个必须一次性搞清楚整个烂摊子的人。
|
||
>
|
||
> 我在课堂上描述过某天遇到的一个冗长 SQL 存储过程,其巨大的 WHERE 子句里有数百行条件。有人问,怎么会有人让代码变得这么糟。我告诉他们:“当只有两三个条件时,再加一个没什么差别;当有二三十个条件时,再加一个也没什么差别!”
|
||
>
|
||
> 在代码库里,没有任何“简化的力量”会自动发生。唯一能让代码变简单的,就是你自己做出的有意识的选择。简化需要付出努力,而人们往往无暇顾及。
|
||
>
|
||
> —— 感谢 [Dan North](https://dannorth.net) 的评论。
|
||
|
||
如果你已把项目的心智模型(mental model) 内化到长期记忆里,你就不会感到高认知负荷。
|
||
|
||
<div align="center">
|
||
<img src="/img/mentalmodelsv15.png" alt="心智模型" width="700">
|
||
</div>
|
||
|
||
要学习的心智模型越多,新成员产出价值所需的时间就越长。
|
||
|
||
当你为新人做项目的入职培训时,试着衡量他们的困惑程度(结对编程会有帮助)。如果他们连续 ~40 分钟仍很困惑——那你的代码就有改进空间。
|
||
|
||
如果你把认知负荷控制在较低水平,新人可以在入职培训后的几个小时内就能对你的代码库做出贡献。
|
||
|
||
## 示例(Examples)
|
||
- “我们的架构是标准的 CRUD 应用架构,[一个基于 Postgres 的 Python 单体](https://danluu.com/simple-architectures/)”
|
||
- Instagram 如何在仅[3 名工程师](https://read.engineerscodex.com/p/how-instagram-scaled-to-14-million)的情况下扩展至 1400 万用户
|
||
- 那些让我们惊呼“哇,这些人[聪明绝顶](https://kenkantzer.com/learnings-from-5-years-of-tech-startup-code-audits/)”的公司,大多都失败了
|
||
- 一个函数串起整个系统。如果你想知道系统如何运转——[去读它](https://www.infoq.com/presentations/8-lines-code-refactoring)
|
||
|
||
这些架构相当“无聊”且易懂。任何人无需太多心力就能掌握。
|
||
|
||
在架构评审中让初级开发者参与进来,他们能帮你识别出那些对脑力要求很高的部分。
|
||
|
||
**维护软件很难**,总会出问题,我们需要尽可能节省每一分心力。系统组件越少,问题也就越少。调试也会更省心。
|
||
|
||
> 调试的难度是写代码的两倍。因此,如果你把代码写得尽可能“聪明”,那意味着你根本不够聪明去调试它。
|
||
|
||
总体而言,抱着 “哇,这个架构真舒服!”这样的心态是有误导性的。那只是“某一时刻”的主观感受,丝毫不能代表现实。更好的方法是长期观察它的后果:
|
||
- 基础设施是否易于维护?
|
||
- 是否有大量组件/库/框架需要更新?
|
||
- 是否容易复现和调试问题?
|
||
- 我们能否快速修改代码,还是存在很多未知因素让人不敢碰?
|
||
- 新人能否快速添加功能?是否有一些独特的心智模型需要学习?
|
||
|
||
> 独特的心智模型是什么?是一套规则的组合,通常是 DDD/CQRS/Clean Architecture/事件驱动架构的混搭。这是作者对他最兴奋之物的自我诠释,是他主观的心智模型。也是他人需要内化的额外的认知负荷。
|
||
|
||
这些问题很难追踪,人们也往往不愿直接回答。看看世界上最复杂且经得住时间考验的软件系统——Linux、Kubernetes、Chrome 和 Redis(见下方评论)。你不会在它们身上找到什么花哨的东西——大多“很无聊”,这恰恰是好事。
|
||
|
||
## 结论(Conclusion)
|
||
想象一下,如果我们在第二章里推断出的结论其实并不正确。那意味着,我们刚刚否定掉的结论,以及之前章节里我们认为正确的那些结论,也可能都不成立。`🤯`
|
||
|
||
感受到了吗?你不仅需要在文章中来回跳转才能抓住意思(浅模块!),而且这个段落本就难懂。我们刚刚在你的脑中制造了不必要的认知负荷。**不要把这种事加诸在你的同事身上。**
|
||
|
||
<div align="center">
|
||
<img src="/img/smartauthorv14thanksmari.png" alt="Smart author" width="600">
|
||
</div>
|
||
|
||
我们应当减少一切超出工作本身所带来的认知负荷。
|
||
|
||
---
|
||
[LinkedIn](https://www.linkedin.com/in/zakirullin/), [X](https://twitter.com/zakirullin), [GitHub](https://github.com/zakirullin), artemzr(аt)g-yоu-knоw-com
|
||
|
||
<details>
|
||
<summary><b>评论</b></summary>
|
||
<br>
|
||
<p><strong>Rob Pike</strong><br>好文章</p>
|
||
<p><strong><a href="https://x.com/karpathy/status/1872038630405054853" target="_blank">Andrej Karpathy</a></strong> <i>(ChatGPT, Tesla)</i><br>一篇关于软件工程的好文。也许是最真实、却最少被践行的观点。</p>
|
||
<p><strong><a href="https://x.com/elonmusk/status/1872346903792566655" target="_blank">Elon Musk</a></strong><br>确实如此。</p>
|
||
<p><strong><a href="https://www.linkedin.com/feed/update/urn:li:activity:7277757844970520576/" target="_blank">Addy Osmani</a></strong> <i>(Chrome,世界上最复杂的软件系统之一)</i><br>我见过无数项目中,聪明的开发者用最新的设计模式和微服务构建了令人印象深刻的架构。但当新成员尝试做改动时,他们花了好几周也只是试图弄清楚各部分如何拼在一起。认知负荷高到离谱,生产力直线下降,bug 倍增。</p>
|
||
<p>讽刺的是?许多引入复杂性的模式,都是以“整洁代码”的名义被实施的。</p>
|
||
<p>真正重要的是减少不必要的认知负担。有时这意味着用更少、更“深”的模块替代许多“浅”的模块。有时这意味着把相关逻辑放在一起,而不是拆成无数微小函数。</p>
|
||
<p>有时这也意味着选择无聊、直接的方案而非聪明的方案。最好的代码并非最优雅、最复杂的——而是未来的开发者(包括你自己)能很快看懂的代码。</p>
|
||
<p>你的文章让我非常有共鸣,尤其是对于我们在浏览器开发中面临的挑战。你说现代浏览器是最复杂的软件系统之一,这完全正确。管理 Chromium 的复杂性是一项持续的挑战,也与文中关于认知负荷的许多观点高度契合。</p>
|
||
<p>我们在 Chromium 中处理复杂性的做法之一,是谨慎的组件隔离和在子系统(如渲染、网络、JavaScript 执行等)之间定义清晰的接口。这类似你关于 Unix I/O 的“深模块”例子——我们追求在相对简单的接口背后提供强大的功能。比如,我们的渲染管线处理着令人难以置信的复杂性(布局、合成、GPU 加速),但开发者可以通过清晰的抽象层与之交互。</p>
|
||
<p>你关于避免不必要抽象的观点也很扎心。在浏览器开发中,我们持续在“让代码库对新贡献者更友好”与“处理网络标准和兼容性的内在复杂性”之间做平衡。</p>
|
||
<p>有时,在复杂系统里,最简单的方案才是最好的。</p>
|
||
<p><strong><a href="https://x.com/antirez" target="_blank">antirez</a></strong> <i>(Redis)</i><br>完全同意 :) 我认为《A Philosophy of Software Design》里欠缺的一个概念是“设计牺牲”。也就是,有时你牺牲一些东西,换来简单、性能,或两者兼得。我一直都在践行这个理念,但它常常不被理解。</p>
|
||
<p>一个例子是,我一直拒绝让哈希字段(hash items)支持过期。这是一次“设计牺牲”,因为如果某些属性只存在于顶层条目(键本身),设计更简单,值就只是对象。当 Redis 引入哈希字段过期时,功能是不错,但确实需要在许多地方做很多改动,复杂性上升。</p>
|
||
<p>另一个例子是我正在做的 Vector Sets,新的 Redis 数据类型。我决定 Redis 不做向量的“事实来源(source of truth)”,而只是存储近似版本,因此我可以在写入时做归一化、量化,而无需在磁盘上保存大规模的浮点向量等等。很多向量数据库不会牺牲“保留用户放入的全精度向量”这点。</p>
|
||
<p>这只是两个随机例子,但我到处都在应用这种理念。关键是:当然要牺牲对的东西。往往 5% 的特性就占据了非常大比例的复杂性:那正是该砍掉的 :D</p>
|
||
<p><strong><a href="https://working-for-the-future.medium.com/about" target="_blank">一位来自互联网的开发者</a></strong><br>你们可能不会雇我……我靠“发过多少企业项目”这类履历吃饭。</p>
|
||
<p>我曾和一个能“说设计模式”的人共事。我从来不会那样说话,尽管我是少数能很好理解他的人之一。管理者很喜欢他,他能主导任何开发讨论。但周围人说,他走到哪儿都留下一地鸡毛。别人说我是第一个能理解他项目的人。可维护性很重要。我最在乎 TCO(总拥有成本)。对很多公司来说,这才重要。</p>
|
||
<p>我很久没上 Github 了,某次登录后不知为何跳到某个看起来随机的人仓库里的文章上。我在想“这是什么”,又一时回不了主页,于是就读了。起初没在意,但它太棒了。每个开发者都该读一读。它基本告诉我们:几乎我们被灌输的所有编程最佳实践,都会导致过量的“认知负荷”,意思是我们的脑子被过度的消耗。我早就有这感觉,尤其在云、安全、DevOps 的要求下。</p>
|
||
<p>我也喜欢它,因为它描述了我几十年来一直在做、但不太好意思承认(因为不流行)的实践……我写的东西非常复杂,需要尽可能多的帮助。</p>
|
||
<p>想想看,如果我是对的,那它会出现在 Github 首页,是因为 Github 的聪明人觉得开发者应该看看它。我赞同。</p>
|
||
<p><a href="https://news.ycombinator.com/item?id=45074248" target="_blank">Hacker News 上的评论</a>(<a href="https://news.ycombinator.com/item?id=42489645" target="_blank">2</a>)</p>
|
||
</details>
|