mirror of
https://github.com/zakirullin/cognitive-load.git
synced 2025-10-09 13:42:36 +03:00
Update README.zh-cn.md
This commit is contained in:
419
README.zh-cn.md
419
README.zh-cn.md
@@ -1,145 +1,142 @@
|
||||
# 认知负荷才是关键
|
||||
|
||||
[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)
|
||||
> 认知负荷是指开发者为了完成一项任务需要动多少脑子。
|
||||
|
||||
> “认知负荷”(Cognitive load)指的是开发者为了完成一项任务而需要投入的思考量。
|
||||
阅读代码时,你会把变量的取值、控制流逻辑、调用序列等“装”进脑子里。普通人的工作记忆大约能同时容纳[四个这样的信息块](https://github.com/zakirullin/cognitive-load/issues/16)。一旦认知负荷接近这个阈值,理解就会变得困难得多。
|
||||
|
||||
在阅读代码时,人们会将诸如变量值、控制流逻辑和调用序列等内容记在脑海里。通常情况下,一般人在[工作记忆](https://baike.baidu.com/item/%E5%B7%A5%E4%BD%9C%E8%AE%B0%E5%BF%86/5197761)中大约可以保存[四个信息块](https://github.com/zakirullin/cognitive-load/issues/16)。一旦认知负荷达到这个临界值,理解事物就变得更加困难。
|
||||
|
||||
*假设我们被要求对一个完全不熟悉的项目进行修复工作。我们被告知在此之前,有一位非常聪明的开发人员贡献过这个项目,并在里面应用了许多复杂高级的架构、花哨的库和流行的技术。换句话说,**这位开发人员给我们制造了很高的认知负荷。***
|
||||
*假设我们被要求去修补一个完全陌生的项目。有人告诉我们,之前有位非常聪明的开发者贡献过:用了很多酷炫的架构、花哨的库、时髦的技术。也就是说,**作者为我们制造了极高的认知负荷。***
|
||||
|
||||
<div align="center">
|
||||
<img src="/img/cognitiveloadv6.png" alt="Cognitive load" width="750">
|
||||
<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="Intrinsic vs Extraneous" width="600">
|
||||
<img src="/img/smartauthorv14thanksmari.png" alt="如果外在负荷占据了主导地位,情况就不再是喜闻乐见了" width="600">
|
||||
</div>
|
||||
|
||||
接下来让我们直接看一些“外在的”认知负荷的具体实例。
|
||||
下面直接看一些外在认知负荷的具体、可操作的例子。
|
||||
|
||||
---
|
||||
|
||||
我们将认知负荷的程度定义如下:
|
||||
我们将这样标注认知负荷的程度:
|
||||
`🧠`:工作记忆刚刚清空,认知负荷为零
|
||||
`🧠++`:工作记忆里已经有两个事实,认知负荷上升
|
||||
`🤯`:认知过载,超过 4 个事实
|
||||
|
||||
`🧠`: 刚初始化的“工作记忆”,此时不存在认知负荷
|
||||
|
||||
`🧠++`: 在“工作记忆”中放入了两项内容,认知负荷有所增加(`+`越多,负荷越多)
|
||||
|
||||
`🤯`:在“工作记忆”中放入了超过4项内容,致使“工作记忆” 出现 “溢出” 状况
|
||||
|
||||
> 我们的大脑实际要更加复杂而神秘,这里只是运用这个简单模型对认知负荷的程度进行简要描述。
|
||||
> 我们的大脑远比这复杂且尚未被充分理解,但这个简化模型足以用来说明问题。
|
||||
|
||||
## 复杂的条件控制(Complex conditionals)
|
||||
|
||||
```go
|
||||
if val > someConstant // 🧠+
|
||||
&& (condition2 || condition3) // 🧠+++, 前一个条件必须是 true, c2 和 c3 中的任意一个应该为 true
|
||||
&& (condition4 && !condition5) { // 🤯, 然后我们就被这个地方整懵逼了
|
||||
&& (condition2 || condition3) // 🧠+++, 前置条件需为 true,c2 或 c3 必须其一为 true
|
||||
&& (condition4 && !condition5) { // 🤯, 到这一步我们基本已经被绕晕了
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
我们可以通过命名语义化的中间变量来降低认知负荷:
|
||||
用有意义的中间变量重构:
|
||||
```go
|
||||
isValid = val > someConstant
|
||||
isAllowed = condition2 || condition3
|
||||
isSecure = condition4 && !condition5
|
||||
// 🧠,我们无需记住这些判断条件,因为这些变量名称是自描述的
|
||||
isSecure = condition4 && !condition5
|
||||
// 🧠,无需死记条件表达式,变量名已自描述
|
||||
if isValid && isAllowed && isSecure {
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
## 多层嵌套的 ifs (Nested ifs)
|
||||
|
||||
## 多层嵌套的 if 判断(Nested ifs)
|
||||
```go
|
||||
if isValid { // 🧠+, 这一步我们目前只关心 isValid 这一个变量(是否有效)
|
||||
if isSecure { // 🧠++, 这一步我们要同时关心 isValid 和 isSecure 两个变量(是否有效并且安全)
|
||||
if isValid { // 🧠+, 嵌套代码仅适用于有效输入
|
||||
if isSecure { // 🧠++, 仅对有效且安全的输入执行
|
||||
stuff // 🧠+++
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
和“提早返回”(先判断条件不满足时直接返回)做对比:
|
||||
|
||||
对比一下“提前返回”式的:
|
||||
```go
|
||||
if !isValid
|
||||
return
|
||||
|
||||
|
||||
if !isSecure
|
||||
return
|
||||
|
||||
// 🧠, 我们不用去关心已经返回的东西,走到这一步代表所有校验已经通过
|
||||
// 🧠, 不必关心之前的 return;能走到这里就表示一切 OK
|
||||
|
||||
stuff // 🧠+
|
||||
```
|
||||
|
||||
通过这种写法,我们可以专注于主流程,从而使“工作记忆”从各种先决条件中解放出来。
|
||||
|
||||
## 多继承噩梦 (Inheritance nightmare)
|
||||
|
||||
我们接到要求,要针对管理员用户对某些内容进行修改 `🧠`:
|
||||
## 多继承噩梦(Inheritance nightmare)
|
||||
我们要对管理员用户做一些改动:`🧠`
|
||||
|
||||
`AdminController extends UserController extends GuestController extends BaseController`
|
||||
|
||||
噢,一部分的功能代码在`BaseController`,让我们来看看:`🧠+`
|
||||
哦,部分功能在 `BaseController`,看看先:`🧠+`
|
||||
基础角色机制是在 `GuestController` 引入的:`🧠++`
|
||||
一些逻辑在 `UserController` 被部分改写了:`🧠+++`
|
||||
终于来到 `AdminController`,开始干活吧!`🧠++++`
|
||||
|
||||
在`GuestController` 中引入了基本的角色机制:`🧠++`
|
||||
等等,还有个 `SuperuserController` 继承了 `AdminController`。改 `AdminController` 可能会破坏子类逻辑,所以先去看 `SuperuserController`:`🤯`
|
||||
|
||||
在`UserController` 中修改了部分内容:`🧠+++`
|
||||
尽量倾向组合而非继承。细节不展开——参考资料已经[汗牛充栋](https://www.youtube.com/watch?v=hxGOiiR9ZKg)。
|
||||
|
||||
终于,我们来到了`AdminController`,让我们开始编码吧!:`🧠++++`
|
||||
## 过多微型方法,类或模块(Too many small methods, classes or modules)
|
||||
> 在这里,“方法”“类”“模块”可以互换理解
|
||||
|
||||
“方法少于 15 行”“类应该小”这些准则,实践下来并不总是对的。
|
||||
|
||||
Oh,等下,这里还有一个`SuperuserController`,它继承自`AdminController`。如果修改了`AdminController`,我们可能会破坏继承类中的功能,所以我们要先了解一下`SuperuserController`:`🤯`
|
||||
|
||||
这里推荐使用组合而非继承来实现功能。关于这一点,这里就不详细阐述了 —— [参考资料](https://www.youtube.com/watch?v=hxGOiiR9ZKg)有很多。
|
||||
|
||||
## 存在数量过多的小方法,类或模块(Too many small methods, classes or modules)
|
||||
|
||||
> 在下述描述中,方法、类和模块的术语是可以相互替换的。
|
||||
|
||||
诸如 “方法应该少于 15 行代码” 或 “类应该很小” 之类的观点,经实践证明是存在一定错误的。
|
||||
|
||||
**深模块**(Deep module)—— 接口简单,内部实现复杂的
|
||||
|
||||
**浅模块**(Shallow module)—— 接口相对于自身所提供的(简单)功能而言相对复杂
|
||||
**深模块(Deep module)**——接口简单,但功能强大、实现复杂
|
||||
**浅模块(Shallow module)**——相较其提供的少量功能,对外提供的接口反而比较复杂
|
||||
|
||||
<div align="center">
|
||||
<img src="/img/deepmodulev8.png" alt="Deep module" width="700">
|
||||
<img src="/img/deepmodulev8.png" alt="相较于深度“抽象”的浅模块其内部犬牙交错的调用关系,深模块可能因为链条清晰而让理解与维护成为可能" width="700">
|
||||
</div>
|
||||
|
||||
倘若项目中存在过多的浅模块,项目就会变得晦涩难懂。**因为人们不仅要记住每个模块所承担的职责,还要记住它们之间的所有交互关系**。为了弄明白浅模块的用途,我们首先得查看所有与之相关的模块的逻辑。`🤯`
|
||||
浅模块过多会让项目难以理解。**不仅要记住每个模块的职责,还要记住它们之间的所有交互**。要理解一个浅模块的目的,往往得先看完与之相关的所有模块功能。频繁在这些浅组件之间跳转令人心力交瘁,<a target="_blank" href="https://blog.separateconcerns.com/2023-09-11-linear-code.html">线性思维</a>对人类更自然。
|
||||
|
||||
> [信息隐藏](https://baike.baidu.com/item/%E4%BF%A1%E6%81%AF%E9%9A%90%E8%97%8F/3230616)至关重要,当然我们并不会在浅模块中隐藏大量复杂性。
|
||||
> 信息隐藏至关重要;而浅模块隐藏的复杂性并不多。
|
||||
|
||||
举个真实例子,我有两个业余项目,每个项目都有约 5 千行代码。第一个项目包含 80 个浅类(shallow class),而第二个项目仅有 7 个深类(deep class),我已经有一年半没对这两个项目进行维护了。
|
||||
我有两个宠物项目,都大概 5K 行代码。第一个有 80 个浅类,第二个只有 7 个深类。我一年半没维护它们了。
|
||||
|
||||
有一次我回过头来维护项目,我发现要理清第一个项目中那 80 个类之间的所有交互关系,简直难如登天。在我开始编码之前,我不得不重新构建大量的认知负荷。而至于第二个项目,我能迅速理解上手,因为它只包含几个有着简单接口的深类。
|
||||
回头再看,第一个项目中 80 个类之间的交互简直难以理清。在能动手写代码之前,我得先重建一大坨认知负荷。相反,第二个项目很快就能上手,因为它只有少数几个接口简单的深类。
|
||||
|
||||
|
||||
> 最优秀的组件既能够提供强大的功能,又具有简单易用的接口设计。
|
||||
>
|
||||
> 最好的组件是功能强大而接口简单的组件。
|
||||
> **John K. Ousterhout**
|
||||
|
||||
UNIX I/O 的接口就非常简单。它只有五个基本调用:
|
||||
|
||||
```c
|
||||
UNIX I/O 的接口很简单,只有 5 个基本调用:
|
||||
```python
|
||||
open(path, flags, permissions)
|
||||
read(fd, buffer, count)
|
||||
write(fd, buffer, count)
|
||||
@@ -147,237 +144,259 @@ lseek(fd, offset, referencePosition)
|
||||
close(fd)
|
||||
```
|
||||
|
||||
事实上,这个接口的实现多达**数十万行代码**。大量的复杂性被隐藏在内部。不过因为接口设计简单,所以使用起来依然很便捷。
|
||||
现代实现可以有**数十万行代码**。尽管背后是大量复杂性逻辑,但因为接口设计简单,使用就容易。
|
||||
|
||||
> 这个深模块的示例源于 John K. Ousterhout 的 [《软件设计的哲学》](https://web.stanford.edu/~ouster/cgi-bin/book.php)一书。这本书不仅涵盖了软件开发中复杂性的核心,还对 Parnas 颇具影响力的论文 [《分解系统模块的标准》](https://www.win.tue.nl/~wstomv/edu/2ip30/references/criteria_for_modularization.pdf)进行了最佳的诠释。这两本书都是必读之作。其他相关阅读包括:[可能是时候停止推荐《代码整洁之道》了](https://qntm.org/clean)、[小函数可能是有害的](https://copyconstruct.medium.com/small-functions-considered-harmful-91035d316c29)。
|
||||
> 这个深模块的例子来自 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. 如果你以为我们是在为臃肿、职责过多的“万能对象”摇旗呐喊,那你就误解了。
|
||||
|
||||
## 浅模块和单一职责原则(Shallow modules and SRP)
|
||||
## 关于“仅为一件事负责” (Responsible for one thing)
|
||||
我们常常遵循一种模糊的原则,结果造出一堆浅模块:“一个模块应该只负责一件事,而且仅此一件”。但这“一件事”到底是什么?实例化一个对象也算一件事,对吧?那么 [MetricsProviderFactoryFactory](https://minds.md/benji/frameworks) 就理所当然?**这类类名和接口带来的心智负担,往往比它们整个实现还高——这算什么抽象?**哪儿不对劲了。
|
||||
|
||||
很多时候,我们会遵循“一个模块应该只负责一件事”这一模糊的原则,最终创建出大量浅模块。可这“一件事”究竟指的是什么?比如实例化一个对象也算是“一件事”,对吧?如此看来,像 [MetricsProviderFactoryFactory](https://minds.md/benji/frameworks) 这样的模块似乎并无不妥。然而,这样的类名和接口往往比其本身实现更令人费解,这到底是一种怎样的抽象呢?显然有一些不对劲的地方。
|
||||
我们修改系统是为满足用户和利益相关方的诉求。我们应当对他们负责。
|
||||
|
||||
> 在这些浅组件(Shallow components)之间来回切换是非常耗费心力的,[线性思维](https://blog.separateconcerns.com/2023-09-11-linear-code.html)对于我们人类来说是更自然的思维方式。
|
||||
> 一个模块应当且只应当对一个用户或利益相关方负责。
|
||||
|
||||
我们对系统的更改是为了满足用户和利益利益相关方的需求,我们对他们负责。
|
||||
这才是单一职责原则(SRP)的真正内涵。通俗地说,如果我们在一个地方引入了 bug,然后两位不同业务条线的人都来投诉——我们就违反了该原则。它与我们在模块中做了多少事情无关。
|
||||
|
||||
> 一个模块应当有且只对一个特定的用户或者利益相关方负责。
|
||||
但即便如此,这条规则也可能“利少弊多”。每个人对它的理解都不尽相同。更好的做法是看它们带来了多少认知负荷。记住“一个地方的变更会在不同业务流中引发一串连锁反应”本身就很费脑力。就这样,无须再学什么花哨术语。
|
||||
|
||||
这才是单一职责原则的真正含义。简单来说,如果我们在某个地方引入了一个 bug,随后有两个不同业务模块的人员都来投诉,那就意味着我们违反了这个原则。它与我们在模块中做了多少事情无关。
|
||||
## 过多的“浅微服务” (Too many shallow microservices)
|
||||
“浅-深模块”原则与规模无关,同样适用于微服务架构。浅微服务太多,毫无益处——行业正朝着某种“宏服务”发展,即不那么浅(=更深)的服务。最糟且最难修复的现象之一是所谓“分布式单体”,它往往源于过度细粒度、过度浅化的拆分。
|
||||
|
||||
但就目前而言,这种解释仍然可能造成更多的误解。每个人对这一条规则的理解也可能不一样。因此,更好的判断模块是否遵循单一职责的方法是看其带来了多少认知负荷。像记住一个模块的变化会引发一系列业务流的连锁反应是需要一定心智负担的。这才是重点。
|
||||
我曾为一家初创公司做咨询,五个开发做了 17(!)个微服务。他们比计划落后了 10 个月,离正式发布遥遥无期。每个新需求都需要修改 4+ 个微服务。在这样一个分布式系统里,复现和调试一个问题要花掉极长时间。正式推出的估时和认知负荷都高得令人无法接受。`🤯`
|
||||
|
||||
## 过多的“浅微服务”(Too many shallow microservices)
|
||||
这是应对新系统不确定性的正确方式吗?在一开始就厘清正确的逻辑边界极其困难。关键是在合理的范围内尽可能晚地做决定,因为那时你手头掌握的信息最多。若一上来就引入网络层,我们的设计决策从一开始就变得难以回退。团队给出的唯一理由是:“FAANG(脸书、亚马逊、苹果、网飞和谷歌)证明了微服务架构是有效的”。*醒醒吧,别再做不切实际的美梦了。*
|
||||
|
||||
浅模块与深模块与可扩展性无关,所以我们还能够把这个原理运用到微服务架构中。过多的浅微服务(shallow microservices)并无益处 —— 行业发展趋势正朝着“宏服务”方向发展,即不那么浅的服务(=深服务)。在微服务架构里,最糟糕且最难解决的现象之一是所谓的分布式单体,它通常是过度细化拆分颗粒度所导致的结果。
|
||||
[Tanenbaum-Torvalds 辩论](https://en.wikipedia.org/wiki/Tanenbaum%E2%80%93Torvalds_debate)曾指出,Linux 的单体设计有缺陷且过时,应改用微内核架构。确实,从“理论与美学”的角度看,微内核似乎更优。然而从实践的角度看——三十年过去了,基于微内核的 GNU Hurd 仍在开发中,而单体结构的 Linux 无处不在。你现在阅读的这个网页由 Linux 伺服,你的智能茶壶都跑在 Linux 上,单体的 Linux。
|
||||
|
||||
举个真实的例子,我曾经为一家初创公司提供咨询服务,这家公司团队仅有五名开发人员,却创建了 17 个微服务!问题在于项目的进度比计划落后了将近10个月,并且距离正式发布上线还有很长的路要走。每当有新需求出现,就会涉及4个或者更多的微服务需要修改。这导致在进行集成联调时,排查问题的难度也是急剧上升。上线所需时间和开发人员的认知负荷都高到令人难以接受。`🤯`
|
||||
经过精心设计,由模块良好隔离的单体系统,往往比由众多微服务组成的架构更灵活,维护所需的心智负担也更小。只有当“独立部署”的需求变得至关重要时(比如团队规模扩张),才应考虑在模块之间加入网络层,逐步演进为未来的微服务。
|
||||
|
||||
这是应对新系统不确定性的正确方式吗?一开始就期望找到正确的逻辑边界是极为困难的。关键在于要尽量推迟做决策,因为越到后期,可供参考的信息就越多,从而能更好地做出决策。如果我们一开始就引入网络层,之后再想修改这个设计就会变得极其艰难。然而,该团队使用此架构的唯一理由是:“FAANG(脸书、亚马逊、苹果、网飞和谷歌)这些公司已经证实了微服务架构是行之有效的”。*醒醒吧,别再做不切实际的美梦了*。
|
||||
## 特性丰富的编程语言(Feature-rich languages)
|
||||
当喜欢的语言发布新特性(Feature) 时,作为开发者我们往往会很兴奋。我们会学习它们,并在代码里用起来。
|
||||
|
||||
[Tanenbaum-Torvalds 辩论](https://en.wikipedia.org/wiki/Tanenbaum%E2%80%93Torvalds_debate)指出 Linux 的单体设计是有缺陷并且过时的,应该用微内核架构取而代之。确实,从“理论和美学”的角度来看,微内核设计似乎更优越。但从实践的角度来看 —— 三十年过去了,基于微内核的 GNU Hurd 仍在开发中,而采用单体结构的 Linux 无处不在。你现在浏览的页面运行在 Linux 之上,你的智能茶壶也是由 Linux 系统提供支持的。(正是单体结构的 Linux)。
|
||||
如果特性很多,我们可能会花费半小时,通过写几行代码来试试某个功能或者另一个功能。这多少有点浪费时间。但更糟的是,**等你过一阵子再回头看,你还得重新推演一遍当时的思路!**
|
||||
|
||||
**你不仅要理解这个复杂的程序,还得搞明白:当时写代码的人,为什么会觉得用这些特性来解决问题是个好办法。** `🤯`
|
||||
|
||||
经过精心设计且拥有真正隔离模块的单体系统,在很多情况下会比由众多微服务组成的架构更加灵活。同时,维护这样的单体系统所需的认知成本要低得多。只有在单独部署的需求至关重要的情况下,例如扩展开发团队,才应该去考虑在模块之间添加网络层,逐步转向未来的微服务。
|
||||
这些话来自 Rob Pike。
|
||||
|
||||
## 具有强大特性和功能的语言(Feature-rich languages)
|
||||
> 减少选择,就能降低认知负荷。
|
||||
|
||||
当我们特别喜爱的某种编程语言发布新特性时,我们往往会兴奋不已。随后,我们会花些时间去学习这些新特性,并在编码时运用他们。
|
||||
|
||||
要是一门语言的新特性众多,我们可能会花上半小时去尝试写几行代码以使用这些特性,这着实有些浪费时间。然而更糟的是,当你日后再回过头来看这些代码时,你还得重新构建当时的思路!
|
||||
|
||||
**你不但需要理解这个复杂的程序,还需要理解为什么开发人员选择用这些特性来解决问题。**`🤯`
|
||||
|
||||
这些内容是 Rob Pike 提出的。
|
||||
|
||||
> 通过减少可选项来降低认知负荷。
|
||||
|
||||
使用语言特性并无不妥,前提是它们彼此独立(正交,在这里表示语言特性之间相互独立,不会相互干扰或冲突)。
|
||||
编程语言的特性是很好的,只要这些特性之间互不干扰。
|
||||
|
||||
<details>
|
||||
<summary><b>一位拥有20年C++经验的工程师的想法 ⭐️</b></summary>
|
||||
<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),第二种情况则是常见的逻辑或(logical OR)运算符,并且它们的行为并不一致。<br><br>
|
||||
在 C++20 之前,对于普通类型(trivial type),不能简单地分配空间后直接使用<code>memcpy</code>复制一组字节 —— 这样不会启动对象的生命周期。在 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>我热爱这种语言。只是我现在有些累了。
|
||||
前几天我看我的 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)
|
||||
|
||||
在后端,我们返回以下内容:
|
||||
## 业务逻辑与HTTP状态码(Business logic and HTTP status codes)
|
||||
在后端我们返回:
|
||||
`401` 表示 JWT 令牌过期
|
||||
`403` 表示权限不足
|
||||
`418` 表示用户被封禁
|
||||
|
||||
`401` 表示jwt令牌已经过期
|
||||
前端的工程师在实现登录功能时,会用到后端 API。于是他们必须在脑子里临时记住这样一组映射:
|
||||
`401` 表示 JWT 令牌过期 // `🧠+`,好,先记着
|
||||
`403` 表示权限不足 // `🧠++`
|
||||
`418` 表示用户被封禁 // `🧠+++`
|
||||
|
||||
`403` 表示访问权限不足
|
||||
前端开发者(希望如此)会在代码里放一个“数字状态码 -> 语义”的映射,这样后面加入的贡献者就不用再在脑子里重新构建这套映射关系了。
|
||||
|
||||
`418` 表示用户被禁用
|
||||
|
||||
前端开发人员利用后端的 API 来实现登录功能时,不得不暂时在大脑中承担以下认知负荷:
|
||||
|
||||
`401` 表示jwt令牌过期 // `🧠+`,好吧只是暂时在脑子里记一下。
|
||||
|
||||
`403` 表示访问权限不够 // `🧠++`
|
||||
|
||||
`418` 表示用户被禁用 // `🧠+++`
|
||||
|
||||
前端开发人员将(希望)在他们代码中引入某种 “数字状态码 -> 含义” 的映射字典,这样后续的开发人员就无需在脑海中重现构建这种映射关系了。
|
||||
|
||||
接着,QA人员参与到项目中:“嘿,我收到了`403`状态码,这是令牌过期了还是权限不足呢?”
|
||||
**QA 人员无法直接开展测试工作,因为他们首先得重新梳理后端人员创建的认知负荷。**
|
||||
|
||||
我们为什么要在工作记忆中保留这种自定义映射呢?更好的做法是将业务细节从 HTTP 传输协议中抽象出来,直接在响应正文中返回具有自描述性质的状态码,比如:
|
||||
然后 QA 进场:
|
||||
“嘿,我遇到 `403`,这是令牌过期还是权限不足?”
|
||||
**QA 不能马上开始测试,因为他们得先重建后端工程师当初在脑中构建的认知负荷。**
|
||||
|
||||
为什么要把这种自定义映射强行塞进我们的记忆中呢?更好的做法是把业务细节从 HTTP 传输协议中抽离,直接在响应体里返回自描述的代码:
|
||||
```json
|
||||
{
|
||||
"code": "jwt_has_expired"
|
||||
}
|
||||
```
|
||||
|
||||
如此一来,前端开发人员的认知负荷:`🧠`(清空状态,脑中无需记住任何信息)
|
||||
QA 人员的认知负荷:`🧠`
|
||||
前端侧认知负荷:`🧠`(清空,脑中无须再记映射)
|
||||
QA 侧认知负荷:`🧠`
|
||||
|
||||
同样的规则适用于所有类型的数字状态码(无论在数据库中还是其他任何地方)。**优先使用自描述的字符串**。毕竟,现在早已不是需要为仅有 640K 内存的计算机优化内存使用的时代了。
|
||||
同样的规则适用于各种“数字类型的状态码”(无论在数据库里还是别处)——**优先使用自描述的字符串**。我们早就不处在需要为 640K 内存做优化的年代了。
|
||||
|
||||
> 人们常常花费时间在 `401` 和 `403` 的含义区别上争论不休,并且依据各自的理解来做出决定。每当有新的开发人员加入,他们需要建立新的思维过程。或许你已经为代码的 “设计决策原因”(ADRs)做了文档记录,以便新人理解当初你们做的决定。但到头来,这样的方式没有任何意义。我们虽然能够明确地把错误划分成“用户相关”和“服务器相关”的类型。但在这两种类型之外,错误的归属和原因就变得模糊不清了。
|
||||
> 人们会为 `401` 和 `403` 争论不休,依据各自的心智模型(mental model) 下判断。新的开发者加入后,他们又得重新推演一遍当时的思路。也许你写了架构决策文档(Architecture Decision Record, ADR)来记录“为什么”要这样做,帮助新人理解当时的决策。但归根结底,这并不明智。我们可以大体把错误分成“用户相关”和“服务器相关”,除此之外,界线其实很模糊。
|
||||
|
||||
附注:区分 “认证”(authentication)和 “授权”(authorization)通常是一种精神负担。我们可以使用更简单的术语,如 [“登录”(login)和 “权限”(permissions)](https://ntietz.com/blog/lets-say-instead-of-auth/)来降低认知负荷。
|
||||
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 曾经说过:
|
||||
Rob Pike 说过:
|
||||
|
||||
> 适当的复制要优于不必要的依赖。
|
||||
> 适度的复制好过引入新的依赖
|
||||
|
||||
我们往往不愿重造轮子,但却为了使用现成的实现而去引入大型且复杂的库,尽管这些简单功能我们自己也能轻松实现。
|
||||
我们常常过于抗拒“重复造轮子”,以至于为了使用一个很小的函数,不惜引入庞大而笨重的库。而这些库我们本可以轻松自己写出来。
|
||||
|
||||
**你所依赖的所有库都如同你的代码。** 当出现问题时,如果你需要追踪某个依赖库 10 层以上的堆栈来排查问题,那是很痛苦的。
|
||||
**你的依赖库,也是你代码的一部分。** Bug 来去无踪,不可预测,当你为了定位问题的根源而不得不翻阅某个依赖库的的十几层调用链时,你会知道什么叫做折磨。
|
||||
|
||||
## 与框架紧密耦合(Tight coupling with a framework)
|
||||
## 框架的紧耦合(Tight coupling with a framework)
|
||||
框架里有很多“魔法”(A.K.A. 奇技淫巧)。当我们过度依赖某个框架时,**会迫使后来的开发者先去学习这些魔法**,这可能要花上几个月。虽然框架能让我们在几天内推出 MVP,但从长远看,它们往往带来不必要的复杂性与认知负荷。
|
||||
|
||||
框架中存在着许多 “魔法”。如果我们过于依赖框架,**就会迫使所有后续的开发人员首先去学习这些 “魔法”**。而这一过程可能耗时数月之久。虽然框架可以帮助我们在短短几天内启动 MVP【最小可行产品】,但长远来看,它们往往会徒增不必要的复杂性和认知负荷。
|
||||
更糟糕的是,当遇到一个与架构格格不入的新需求时,框架可能会成为最大的约束。于是人们开始 fork 框架并维护自有的定制版。想象下新人要积累多少认知负荷(也就是学会这个定制框架)才能产出价值。`🤯`
|
||||
|
||||
更糟的是,在某些情况下,当出现一个与现有架构不匹配的新需求时,框架很可能会成为需求实现的最大阻碍。自此,人们会在当前框架基础上开辟新的分支,并维护一个定制版本。设想一下,对于一个新加入的成员,为了能够开展工作,需要承受多大的认知负荷(即学习这个定制版本的框架)。`🤯`
|
||||
**当然,我们绝不是提倡从零开始造轮子!!**
|
||||
|
||||
**但这绝不意味着我们提倡所有东西都从零开始开发!**
|
||||
我们可以以相对“框架无关”的方式编写代码。业务逻辑不应被放在框架内部;相反,它应该仅使用框架的组件。把框架放在核心逻辑之外,用它像用库(library)一样。这样一来,新加入的贡献者从第一天就能带来价值,而不需要先在框架的复杂细节中摸爬滚打。
|
||||
|
||||
我们可以采用不受特定框架束缚的方式来编写代码。例如,业务逻辑不应该被放置在框架内部;相反,它应该通过调用框架所提供的组件来实现。我们可以在核心业务逻辑之外搭建一个框架,用来处理一些通用功能。这样一来,其他开发人员就能够像使用类库一样调用这个框架。这样做能够让新加入的成员从一开始就能投入工作,而不必先去了解和框架相关的复杂内容。
|
||||
> [《为什么我讨厌“框架”》](https://minds.md/benji/frameworks)
|
||||
|
||||
> [为什么我讨厌“框架”](https://minds.md/benji/frameworks)
|
||||
## 分层架构(Layered architecture)
|
||||
这些东西,确实能让工程师的肾上腺素飙一飙。
|
||||
|
||||
## 分层架构(Layered Architecture)
|
||||
我自己曾经是六边形架构(Hexagonal Architecture) / 洋葱架构(Onion Architecture) 的热情拥护者,持续了好几年。这里用、那里也用,还鼓励其他团队用。随后项目复杂度上去了,单看文件数量就翻了一倍。我们像是在写大量“胶水代码”。在需求持续变更的背景下,我们得在多层抽象里同时改动,一切变得繁琐乏味。`🤯`
|
||||
|
||||
在工程学领域,这些架构确实能激发一种独特的兴奋感。
|
||||
抽象本应隐藏复杂性,但这里它只是增加了[间接层](https://fhur.me/posts/2024/thats-not-an-abstraction)。要快速定位问题,理解哪里出错了、缺了什么,通常需要沿着调用链逐步跟踪。但在这种分层架构中,层与层之间的解耦导致我们需要额外的、甚至割裂的多层调用链来找到错误点。每一条这样的调用链,都会占据我们有限的工作记忆。`🤯`
|
||||
|
||||
多年来,我一直热衷于提倡洋葱架构(Hexagonal/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>
|
||||
|
||||
这种架构乍一看在直觉上是合理的,但每次当我们在项目中尝试应用时,总会发现其弊大于利。最终,我们彻底放弃了洋葱架构,转而支持经典的依赖倒置原则(Dependency Inversion Principle)。如此一来,我们**无需学习端口 / 适配器概念,也不用引入不必要的水平抽象层,从而避免了无关的认知负荷。**
|
||||
如果你以为分层能让你快速替换数据库或其他依赖,那就错了。更换存储会带来大量问题;相信我们,数据访问层的抽象是你最不该担心的事情。最理想情况下,抽象也就省个 10% 的迁移时间(如果真能省的话);真正的痛点在于数据模型不兼容、通信协议、分布式系统挑战,以及[隐式接口](https://www.hyrumslaw.com)。
|
||||
|
||||
倘若你认为这种分层方式能够让你迅速地更换数据库或者其他的依赖对象,那就大错特错了。更改存储往往会引发诸多问题,相信我们,对数据访问层进行抽象往往是最不该考虑的事情。即便是在最好的情况下,抽象或许能节省 10% 的迁移时间(如果有的话),但真正难点在于数据模型的不兼容、通信协议问题、分布式系统挑战以及[隐式接口](https://www.hyrumslaw.com/)。
|
||||
|
||||
> 一旦一个 API 拥有足够多的用户,
|
||||
> 契约中承诺的内容就变得无关紧要:
|
||||
> 系统的所有可观察行为
|
||||
> 当 API 的用户足够多时,
|
||||
> 你在契约里承诺了什么已不再重要:
|
||||
> 系统一切可观察到的行为
|
||||
> 都会被某些人所依赖。
|
||||
|
||||
我们做过一次存储迁移,花了大约 10 个月的时间。旧系统是单线程的,因此暴露的事件是顺序的。我们的所有系统都依赖于这种行为。但这个行为并不在 API 合约中,也没有在代码中体现出来。新的分布式存储没有这种保证——这意味着事件是乱序的。我们花了几个小时编写了一个新的存储适配器,但接下来的 10 个月,我们都在处理乱序事件和其他挑战。现在回头看,说分层架构能帮助我们快速更换组件就很搞笑。
|
||||
我们做过一次存储迁移,而这花了大约 10 个月。老系统是单线程的,因此对外暴露的事件是顺序的。我们的系统都依赖这种可观察到的行为。但这种行为既不在 API 的契约里,也不在代码中体现。新的分布式存储没有这种特性,因此无法保证顺序,事件就会乱序触发。我们只花了几个小时就写好了新的存储适配器(storage adapter),这的确要归功于抽象化。**接下来的 10 个月,我们都花在处理乱序事件及其他挑战上。** 现在再说“抽象/分层能帮助我们快速更换组件”,有点可笑。
|
||||
|
||||
**所以,如果这种架构在未来不能带来回报,我们又为什么要为其承担高认知负荷的代价呢?** 而且,在大多数情况下,未来可能根本不会出现更换某些核心组件的需求。
|
||||
**既然分层架构带来了如此高的认知负荷,而未来可能没有回报,为什么要付出这样的代价?** 况且在大多数情况下,所谓“未来要替换某些核心组件”的场景根本不会发生。
|
||||
|
||||
这些架构并非最基础的架构,它们是在更基础的架构基础上,因主观和偏见而产生的。我们为何要遵循这些主观偏见呢?更好的做法是:遵循依赖倒置原则、重视认知负荷和信息隐藏这些基础架构理念。[可点击这里参与讨论](https://github.com/zakirullin/cognitive-load/discussions/24)
|
||||
这些架构本身并非基础性的原则,而是一些主观的、带偏见的实现方式。为什么要依赖这些主观解释?我们应该遵循更基础的规则:依赖倒置原则、单一事实来源(single source of truth)、认知负荷控制、信息隐藏。业务逻辑不应该依赖数据库、UI 或框架等底层模块。我们应该能够在不依赖基础设施的前提下,为核心逻辑编写测试,这就足够了。[参与讨论](https://github.com/zakirullin/cognitive-load/discussions/24)。
|
||||
|
||||
不要为了架构而盲目添加抽象层。只有当你出于实际需求且需要合理扩展时才考虑添加。
|
||||
不要为了“架构”本身去叠加抽象层。只有在确实需要扩展点、并且有实际理由支撑时,才去增加抽象层。
|
||||
|
||||
[抽象层并非毫无代价的](https://blog.jooq.org/why-you-should-not-implement-layered-architecture),**它们会占据我们的工作记忆空间**
|
||||
**[抽象层不是免费的](https://blog.jooq.org/why-you-should-not-implement-layered-architecture),它们会占用我们有限的工作记忆**。
|
||||
|
||||
<div align="center">
|
||||
<img src="/img/layers.png" alt="Layers" width="400">
|
||||
</div>
|
||||
|
||||
## 领域驱动设计(DDD)
|
||||
|
||||
## 领域驱动设计(Domain-driven design, DDD)
|
||||
尽管领域驱动设计(Domain-driven design,缩写为 DDD)常常遭受误解,但其在某些方面的确有卓越之处。人们通常说的 “我们用 DDD 写代码”,这种说法其实是有些奇怪的,因为 DDD 是和问题空间(problem space)相关的,而不是和解决方案空间(solution space)相关的。
|
||||
|
||||
> 译注:
|
||||
>
|
||||
> - problem space: 问题空间,简单理解就是当前环境下业务所面临的一系列问题和背后的需求。
|
||||
>
|
||||
> - solution space: 解决方案空间,则是针对问题空间的解决方案,它思考的是如何设计实现软件系统以解决这些问题,它属于工程设计实施阶段,通常是技术专家主导的解决方案设计和实现。
|
||||
> 译注:<br>
|
||||
> problem space: 问题空间,简单理解就是当前环境下业务所面临的一系列问题和背后的需求。<br>
|
||||
> solution space: 解决方案空间,则是针对问题空间的解决方案,它思考的是如何设计实现软件系统以解决这些问题,它属于工程设计实施阶段,通常是技术专家主导的解决方案设计和实现。
|
||||
|
||||
无处不在的语言、领域、有界上下文(bounded context)、聚合、事件风暴(event storming)等概念都属于问题空间的范畴。它们旨在协助我们洞察领域并确定其边界。DDD 能够让开发人员、领域专家和业务人员使用一种统一的语言来实现高效沟通。然而,我们往往侧重于特定的文件夹结构、服务、存储库以及其他解决方案空间的技术,却忽视了 DDD 在问题空间上的问题。
|
||||
|
||||
我们对 DDD 的阐释很有可能具有独特性和主观性。如果我们依照这种理解来构建代码(即如果我们制造了很多无关的认知负荷)—— 那么未来接手的开发人员注定要完蛋。`🤯`
|
||||
|
||||
## 示例(Examples)
|
||||
通用语言(ubiquitous language)、领域(domain)、有界上下文(bounded context)、聚合(aggregate)、事件风暴(event storming)——这些都属于问题空间的范畴。它们帮助我们洞悉领域、划定边界。DDD 让开发者、领域专家与业务人员能用一种统一语言高效沟通。然而,相较于关注这些问题空间的方面,我们常常强调特定的文件夹结构、服务(service)、仓库(repository) 以及其他解决方案层面的技术,而忽略了DDD在问题空间上的问题。
|
||||
|
||||
- 我们的架构是标准的 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 的理解往往是独特且主观的。如果我们在这种理解的基础上去写代码,就会制造大量额外的认知负荷 —— 未来的开发者只能陷入困境。`🤯`
|
||||
|
||||
这些架构相当枯燥,也很容易理解,任何人都能轻松掌握,无需耗费太多脑力。
|
||||
|
||||
安排初级开发人员参与架构审查。他们会助力你识别出那些对心智要求颇高的代码。
|
||||
Team Topologies 提供了一个更好、更易理解的框架,帮助我们在团队之间分担认知负荷。工程师在学习 Team Topologies 后,往往能形成较为接近的心智模型。相比之下,DDD 往往让 10 个读者形成 10 种不同的理解。它本该是“共同语言”,结果却成了“辩论场”.
|
||||
|
||||
## 熟悉的项目中的认知负荷(Cognitive load in familiar projects)
|
||||
|
||||
> 问题在于,熟悉并不等同于简单。二者给人的*感觉*看似是一样的 —— 都能让人不用费太多脑力就能轻松地在代码之间穿梭 —— 但原因却截然不同。你所使用的每一个 看似“聪明”(实则是“自我放纵”)和非惯用的技巧,都会让其他人付出学习的代价。一旦他们完成学习,他们会发现与代码打交道变得不那么困难。因此,要认识到如何去简化自己已经熟悉的代码并非易事。这就是为什么我尽量让“新手”在他们还没完全融入之前先评价项目代码!
|
||||
> 问题在于,**熟悉并不等于简单**。二者给人的*感觉*看似是一样的 —— 都让人不用费太多脑力就能轻松的在代码之间穿梭 —— 但原因完全不同。你使用的每一个看似“聪明”(其实是“自我炫技”)且非大家惯用的技巧,都会让别人付出额外的学习成本。一旦他们学会了,那与代码相处就没那么难了。这就是为什么你很难看出如何简化你已经熟悉的代码。我会尽量让“新来的开发者”在他们还没被环境同化之前来评审代码!
|
||||
>
|
||||
> 这巨大混乱的项目代码很可能是之前的开发人员一点点造就出来的,而不是一次性创造出来的。而你是第一个不得不一次性理解它的人。
|
||||
> 之前的作者很可能是一点点把代码写乱的,而非一次性造成的。你可能是第一个必须一次性搞清楚整个烂摊子的人。
|
||||
>
|
||||
> 在我的课堂上,我曾描述过我们有一天见到的一个庞大的 SQL 存储过程,其巨大的 WHERE 子句中包含了数百行条件语句。有人问,怎么会有人能把它弄得如此糟糕。我告诉他们:“当只有两三个条件语句时,再添加一个似乎没有影响。当有二三十个条件语句时,再另添加一个看起来也没差!”
|
||||
> 我在课堂上描述过某天遇到的一个冗长 SQL 存储过程,其巨大的 WHERE 子句里有数百行条件。有人问,怎么会有人让代码变得这么糟。我告诉他们:“当只有两三个条件时,再加一个没什么差别;当有二三十个条件时,再加一个也没什么差别!”
|
||||
>
|
||||
> 除了你有意为之的选择之外,代码库并不会受到任何 “简化之力”的作用。简化需要付出努力,然而人们却经常无暇顾及。
|
||||
> 在代码库里,没有任何“简化的力量”会自动发生。唯一能让代码变简单的,就是你自己做出的有意识的选择。简化需要付出努力,而人们往往无暇顾及。
|
||||
>
|
||||
> *感谢 [Dan North](https://dannorth.net)的评论*。
|
||||
> —— 感谢 [Dan North](https://dannorth.net) 的评论。
|
||||
|
||||
如果你已经将这个项目的心智模型内化到长期记忆中,你就不会承受高认知负荷。
|
||||
如果你已把项目的心智模型(mental model) 内化到长期记忆里,你就不会感到高认知负荷。
|
||||
|
||||
<div align="center">
|
||||
<img src="/img/mentalmodelsv15.png" alt="Mental models" width="700">
|
||||
<img src="/img/mentalmodelsv15.png" alt="心智模型" width="700">
|
||||
</div>
|
||||
|
||||
需要学习的心智模型越多,新手投入工作前熟悉项目所需的时间就越长,这也意味着更晚才能为项目创造价值。
|
||||
要学习的心智模型越多,新成员产出价值所需的时间就越长。
|
||||
|
||||
每当你给新人做入职培训的时候,可以尝试去衡量他们的困惑程度(结对编程或许会有所帮助)。倘若他们持续困惑的时间超过 40 分钟,—— 这意味着你的代码存在需要改进的地方。
|
||||
当你为新人做项目的入职培训时,试着衡量他们的困惑程度(结对编程会有帮助)。如果他们连续 ~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)
|
||||
|
||||
[Readable version](https://zakirullin.md/cognitive)
|
||||
[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>认知负荷与干扰因素(Cognitive load and interruptions)</b></summary>
|
||||
<img src="img/interruption.jpeg"><br>
|
||||
</details>
|
||||
<details>
|
||||
<summary><b>编码原理和经验(Coding principles and experience)</b></summary>
|
||||
<img src="img/complexity.png"><br>
|
||||
<a href="https://twitter.com/flaviocopes">@flaviocopes</a>
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user