第7讲 · 需求和架构 (2):怎样驾驭复杂性

本讲稿由课堂录像字幕重构整理,术语、章节骨架与外部链接对齐课程讲义与幻灯片。讲义与幻灯片版权归作者所有,采用 CC BY-NC 4.0 许可;原课程站备案信息见 苏 ICP 备 2020049101 号。 文中的「编者说明 / 编者拓展」为本书编者所加,以区分于讲师原意。

课程 南京大学《生成式软件工程》(2026 春季,蒋炎岩)
本讲主题 需求与架构(二)——好的架构究竟替我们做了什么?当需求还在变化、未来还没有到来时,我们又能设计什么?
课程主页 https://jyywiki.cn/GSE/2026/
本讲讲义 https://jyywiki.cn/GSE/2026/lect7.md 上一讲:https://jyywiki.cn/GSE/2026/lect6.md
本讲幻灯片 https://jyywiki.cn/GSE/2026/p7/
视频 https://www.bilibili.com/video/BV1JGap6LE74/

对应讲次:第7讲(南京大学 生成式软件工程)

制作模型:deepseek-flash(vb compose,据 run.log)


导言:两条主线

(参考时间: 00:00:01)

这一讲从上一讲的教务系统需求泥潭继续出发。上一讲讨论了几个规模差别很大的程序:十来行的汉诺塔、不断长大的二维码生成工具,以及服务整个学校的教务系统。它们都有架构问题。即使需求看起来已经明确,实现它的方式仍然很多;一个「脑子一热」想出来、确实能工作的架构,往往也还有很大的改进空间。

(参考时间: 00:00:53)

讲师回顾了自己上一讲的做法:用浏览器操作的方式,「逆向」出了与教师相关的教务系统需求,得到一份写得相当规范的需求规格说明书。这既符合软件工程实践,也符合做产品的需求方法。但作为系统的设计者或架构师,还要跳到需求之外去看——你当然可以选一个架构、选一个数据库,把这些需求全部实现,交付一个百分之百满足客户需求的产品;但这个产品很可能在未来的维护中陷入长期的泥潭。做任何设计的那一刻起,你就背上了一定程度的技术债,而这份技术债要一直背下去。

「代码之外有客户的规律;客户之外有社会的规律;社会之外有宇宙的规律。」

站在哪一个层面看需求,决定了你能看到什么样的规律:跳出当前的代码,可能发现一个更合适的数学结构、一套已经成熟的业务规则,或者一种能够重新组织问题的抽象。

上一讲:从 10 行到 500,000 行的三类程序

📷 上一讲:从 10 行到 500,000 行的三类程序
回到原视频 (00:00:20)

本讲沿两条主线推进:需求与架构。它们其实是分不开的——正是那个 Intent→Specification→Implementation 的 gap,把两者拴在一起。

Intent → Spec → Impl 的翻译路径

📷 Intent → Spec → Impl 的翻译路径
回到原视频 (00:00:11)


需求与架构:怎样驾驭复杂性

(参考时间: 00:03:01)

软件工程的本质:驾驭复杂性

Brooks 说过,软件工程的本质是驾驭复杂性。当你翻开一份需求规格说明书,就会知道现实中的软件有多复杂。而这些复杂性里,有一部分没有办法消除。

(参考时间: 00:03:23)

这份本质复杂性(essential complexity)来自问题领域本身:教务系统要处理课程的容量限制、学生的先修要求、两门课的时间冲突,还要排课、考虑教室;系统里甚至支持老师和同学一起借教室。这些麻烦并非客户的工作天然要求,而是物理世界在信息世界里的投影本来就复杂——即使完全不用计算机,改成教务员拿纸笔办理,这些问题也依然存在。

本质复杂性:问题领域本身的复杂性

📷 本质复杂性:问题领域本身的复杂性
回到原视频 (00:03:23)

(参考时间: 00:03:58)

与之相对的是附带复杂性(accidental complexity):实现方式额外带来的麻烦。同一个学生的信息保存了五份,改一份就要同步另外四份;几个模块使用不同的日期格式,连接起来需要不断转换;一套规则被复制到多个页面,修改时总会漏掉一处。

讲师用数据库与文件系统的取舍来说明这类复杂性。文件系统管理数据很方便,想 hack 它也很容易,但代价是:性能可能远不如数据库,而且文件系统天然不支持事务——它不能保证「读多个数据、写多个数据」这一段看起来是原子的,不能像上了一把锁、stop the world 那样。数据库能帮你实现事务,但缺点是你每次调试都要穿过一个中间层,不能像文件系统那样直接拿所有 UNIX 工具去组合它。这些都是任何技术选型都会引入的 accidental complexity。

编者拓展|No Silver Bullet:本质复杂性 / 附带复杂性这一对区分,来自 Fred Brooks 1986 年的论文 No Silver Bullet — Essence and Accident in Software Engineering。Brooks 的论断是:软件开发的根本困难(把复杂的概念结构变得精确、可理解)无法被任何单项技术「银弹」消灭;被消灭的只是「附带困难」。理解这一区分,是理解本讲后续所有架构讨论的前提。

需求工程:直面本质复杂性,收缩意图空间

(参考时间: 00:05:44)

需求工程直接面对系统的本质复杂性:它承认软件系统就是复杂的,然后不停地和客户沟通,把「我到底要什么」想清楚。无论是过去用 GPT 写一份需求规格说明书,还是今天用某个 AI 编程助手的「grill me」式追问技能,目的都是收缩意图空间中的不确定性。

需求工程:直面 Essential Complexity

📷 需求工程:直面 Essential Complexity
回到原视频 (00:05:44)

(参考时间: 00:06:00)

讲师用一个非常具体的登录例子说明这件事。假设你要做一个购物车:

仅仅是「登录」这一件事,就有无穷的细节。 顺着往后想,你会发现自己已经进入了一个泥潭。所以做需求时,一方面可以深度优先去做一个 MVP,快速知道哪些最重要的功能点是否被满足、满足到什么程度、需求要不要重做;另一方面思维方式也需要一定的广度优先,去覆盖决策的各个维度(登录框选哪种、界面怎么布局、首页怎么展现)。深度优先和广度优先并不矛盾,个人要在两者之间找到一个较好的平衡——这只能靠多看、多做。

(参考时间: 00:08:56)

而且,需求终究要以某种方式写出来。理论上说,代码本身就是需求:你可以把文档全部删掉,让 AI 去运行、阅读代码,再把需求点还原出来。只不过还原出来的需求点会非常长,未必有一个自顶向下的清晰结构。所以整个系统的设计宗旨仍是:不要搞太复杂,要简化复杂性。能交给第三方管的东西,尽量交出去——这是一种设计哲学。

编者说明:讲师在这里承认,「需求工程好像没有特别的办法,只能看得多做得多」。这句话本身就是本讲的一个态度:架构与需求的品味,来自大量亲手比较过不同设计的经验,而不是记住某套模板。

架构设计:分解本质复杂性,封堵随意组合

(参考时间: 00:10:58)

需求之外就是架构,它有两个主要作用。

第一,分解本质复杂性。 我们不能通过「分模块」让先修要求凭空消失,却可以把资格、容量、时间冲突分别建模,让每一部分都能单独理解。数据库本身就是一个非常好的架构设计:当数据放进数据库之后,你再也不用关心它是怎么存储的,它给你一个很好的契约(contract)。尤其当你使用云厂商的数据库时,它通常承诺「你的数据不会丢」——最多是一段时间降低可用性(availability),但备份很快能顶上。

(参考时间: 00:12:07)

同样的思想也用在学校的信息系统里。学校最希望打造的是一套全部打通的信息系统:填任何信息,不需要这里填一次、那里填一次。为此花了很长时间才完成统一身份认证。但学校里还有很多系统,比如财务系统,它带有大量领域知识——要把账做平、要接受审计——所以财务系统和其他部分至今没有很好地连起来(数据共享、通知推送等)。这本身是一个「我们希望得到、也希望需求与架构能做到」的功能。

架构设计:分解 Essential、减少 Accidental

📷 架构设计:分解 Essential、减少 Accidental
回到原视频 (00:10:58)

第二,减少附带复杂性。 模块职责、数据归属和接口一旦明确,规约与实现中大量随意组合的可能性就被排除了。判断一个架构是否有帮助,可以从一个很朴素的问题开始:

需求改了一处,系统里需要跟着改多少处?


尊重自己的「小脑袋」

(参考时间: 00:14:24)

讲师从「需求和架构好像讲不清楚」的困惑出发,重新读了一批老论文,发现一个有意思的事实:在软件工程诞生初期、甚至更早,已经有大思想家思考过程序应该怎样构造。

(参考时间: 00:14:57)

他现场打开了一本老书——Dahl、Dijkstra 和 Hoare 的 Structured Programming。里面的程序今天看来都很小,讲的是顺序结构、选择结构、循环结构,争论的是「goto 是不是有害」。让人印象深刻的是它讨论的层次:哪怕只是一个比较小的函数,它也在认真地争论。

现场打开《Structured Programming》原文

📷 现场打开《Structured Programming》原文
回到原视频 (00:15:23)

编者说明:书中的许多程序今天看来都很小,但它们面对的问题一点也不过时:人能同时精确理解的东西非常有限,怎样让这种有限的能力足以构造和检查一个大程序?

Dijkstra:承认自己的能力有限

(参考时间: 00:19:41)

Dijkstra 在第一部分的「On our inability to do much」里直言:我们的脑袋很小,最好学会尊重这种限制,而不是假装它不存在。

...I have a very small head and I had better learn to live with it and to respect my limitations and give them full credit, rather than to try to ignore them...

On Our Inability to Do Much

📷 On Our Inability to Do Much
回到原视频 (00:19:41)

讲师把这句话拉到今天重读:人类就是一个参数有限的大模型。程序规模增大,并不只是多写几遍同样的代码。能在脑中追踪十个状态,不意味着能用同样的办法追踪一万个状态。 所以我们才需要层次化的分解——把「人不能一次性理解的东西」变成「人可以逐层理解的东西」。

编者拓展|Dijkstra 与结构化程序设计:这篇「Notes on Structured Programming」是 Dijkstra 1972 年为 Structured Programming 一书所写的开篇,原文可在 EWD249 找到。当时「goto 语句有害」还是热门争论(即所谓的「goto 论战」),Dijkstra 主张只用顺序、选择、循环三种控制结构,就足以表达任何程序逻辑,从而让程序的结构可以被有限的大脑检查。今天看起来这个结论已经变成常识,但它背后的动机——为有限的头脑设计可检查的构造方式——正是本讲反复出现的母题。

枚举、归纳与抽象:三种思维辅助

(参考时间: 00:21:04)

接着,Dijkstra 讨论了三种思维辅助(mental aids):

Our Mental Aids:枚举、归纳、抽象

📷 Our Mental Aids:枚举、归纳、抽象
回到原视频 (00:21:04)

「珍珠项链」:设计决策之间的依赖

(参考时间: 00:21:22)

书中的「珍珠项链」(necklace / pearls)比喻,把程序看成一串相互衔接的抽象层:每颗珍珠承担一项设计决定,上层使用下层提供的概念。如果两种实现提供相同的接口,就有机会替换其中一颗,而保留其余部分。

重要的不只是切出了多少块,还包括决定的顺序。例如,过早把一幅图像解释成「一行一行」的表示,中间各层就都被迫理解「行」;如果推迟这个表示决定,它们便能继续用「图像」思考。表示细节传播得越远,修改时牵动的地方通常就越多。

编者说明:这正是本讲后来反复出现的判断准则的雏形——一个架构好不好,要看「一个变化会牵动多少处」。把表示决策尽量推迟、把细节尽量锁在少数几层里,就是在为未来留出修改空间。

DP 调试为什么是灾难:一个身边的例子

(参考时间: 00:27:02)

讲师抛出书里一个有意思的观察:学生写的程序为什么这么烂?是因为一次要同时在脑子里的东西太多。 他随即给出一个我们都很熟悉的例子。

平时写 OJ 题,你觉得哪类题最难调?可能不是模拟题,而是需要一点数学性质的题,比如动态规划。如果第一次就对了、样例对了、数据也对了,那还好。但如果是一两百行的动态规划,一旦错了,你根本不知道怎么调起。

原因是:动态规划比其他程序更难分层。 如果是模拟题,你多少还能把几个大的部分分开,各自封装成一个 class,单独测试、单独调试;而且你很容易把程序的中间状态映射到一个有物理含义、你能理解的状态上。而一段两三百行的 DP,中间是纯粹的数学表示,你调它就是灾难。

但这件事是可以扳倒的。我们书上写 DP 都是 dp[i][j][k],然后三层 for 循环;只要有一个小地方弄错,整个就全错了,而且它不一定 crash——crash 反而好找,你知道越界了;不 crash 时它只是算出一个错值。可实际上,当我们谈论 dp[i][j][k] 时,背后其实有一个优化问题,这个优化问题本身有图结构,动态规划本质上是在一个状态空间上做——它有一条有向无环图、有无后效性。这就是一层抽象。

而我们把 dp[i][j][k] 写下来的时候,把这层抽象给扔掉了。所以在思考问题时我们工作在这个抽象层级上,写代码时却把它丢掉了。我们可以把「状态」这个概念放回来:说清楚 dp[i][j][k] 到底代表什么含义,把它可视化出来,把关联的状态和迁移打印出来——例如实现一个类似 get(i, j, k) 的函数,返回所有可能的状态迁移。有了这层抽象,程序就可以调试了;调完之后,再把它改写成等价的、更高效的形式。

借助 AI 把 DP 的状态抽象放回来

📷 借助 AI 把 DP 的状态抽象放回来
回到原视频 (00:29:19)

这是一个小小的例子,但它示范了同一件事:抽象让人能够以熟悉的方式去观测本来无法观测的中间状态。

层次化程序结构:类、对象与协程

(参考时间: 00:22:57)

书的第三部分「Hierarchical Program Structures」把这些想法落实到了 SIMULA 的语言机制里。类(class)描述一组相关对象的结构和行为,对象(object)保存自己的状态。这样,我们可以先用业务概念思考,再把概念逐层落到机器能够执行的操作上。

Hierarchical Program Structures

📷 Hierarchical Program Structures
回到原视频 (00:22:57)

对象抽象解决了「这一份数据和哪些操作属于同一个概念」的问题;而协程(coroutine)则帮助处理另一种耦合:几个程序片段之间,谁必须服从谁的控制流?

(参考时间: 00:23:11)

讲师用一个类比来讲协程:协程很像人。 共享内存(shared memory)就是大家共处的物理空间,人和人之间是协作的。每个人都有自己的记忆(memory),知道「我做到哪一步了、今天要干什么、明天要干什么」;同时又在这个共享空间里有共同的事情。人与人之间的通信就成为一种协议(protocol)。这样就能把复杂性隔离开——你可以把一项工作分解成三条线,它们通过一个数据结构共享状态,各自不断向前推进,靠相互通信来完成整个任务。

(参考时间: 00:24:11)

书中用两个游戏程序互相对弈来说明协程与子程序的区别:双方都要计算自己的下一步、交给对方、等待回应,再继续。强行把其中一方改成另一方的子程序,会让原本对等的合作关系变得别扭;把它们看作交替恢复的协程,就能保留双方各自的程序结构。

编者拓展|协程的历史与今天:协程的概念早在 1960 年代就已出现,比今天的「轻量级线程」用法更早。今天我们在操作系统课上第一次听说协程,往往是在 Go 的 goroutine 或 Python 的 async/await 语境里——它们更多是为了性能而实现的轻量级线程,切换与簿记开销极小,让程序员可以像写线程那样用地很开心。但回到它被发明的那个时间点,它要回答的其实是「控制流解耦」这个更根本的问题:几段程序谁服从谁?被调用的那段能不能保留自己的执行位置、而不是从头再来?理解这层动机,比记住某个 API 更有价值。

让 AI 带我们读第七章:概念层次

(参考时间: 00:25:46)

讲师提出一个今天特有的读书方法:让 AI 带我们读。他现场让 AI 读原文、翻译、去掉不必要的部分,并特别指出:有价值的提问应当落到原文和页码上,比如「这一层新引入了什么概念?调用者因此不再需要关心什么?哪些操作会破坏底层的假设?」——让 AI 指出对应代码和页码,再去核对,远比让它概括「这本书很经典」有用。

让 AI 带我们读第七章:Concept Hierarchies

📷 让 AI 带我们读第七章:Concept Hierarchies
回到原视频 (00:25:46)

(参考时间: 00:26:14)

书里第三部分第七节「Concept Hierarchies」尤其值得顺着读一次:先有 TWLIST 提供双向链表操作,再在其上构造离散事件模拟机制 MINISIM,最后用这些机制描述车间里的订单和机器。

每向上一层,使用者就获得一套更贴近问题的词汇,也能暂时忘掉一批已经处理好的细节。讲师进一步指出,抽象的本质就是「把数据加操作打包成一个概念,再用某种机制把概念一层一层搭起来」——无论叫 class、method 还是 module,你都需要把实现内部的复杂性隔离出去。比如「选课」这件事,就要和「教室管理」适当地隔离开。

Concept Hierarchies:TWLIST → MINISIM → Job Shop

📷 Concept Hierarchies:TWLIST → MINISIM → Job Shop
回到原视频 (00:30:33)

!!! note "讲师用 AI 找到的对应案例" 当讲师让 AI「举一个符合现代软件前端的例子」来说明 hold / wait / activate / simulate 这套抽象时,AI 找到了 React 的 Fiber 架构。讲师借此感叹:这套三十多年前设计出来的机制,和今天前端最核心的调度模型在思想上是一脉相承的。

编者拓展|React Fiber 与协作式调度:React Fiber 是 React 16 引入的重写后的协调(reconciliation)引擎。它的核心思路正是「可中断、可恢复」:把渲染工作切分成一个个小单元,在执行中可以暂停,把控制权交还出去,之后再从暂停处恢复。这与协程「保留执行位置、交替恢复」的思想完全一致——只不过它服务的目标是让浏览器在高优先级交互(如输入)到来时能及时响应。讲师用这段类比想说明的是:抽象的动机往往比具体的 API 更能穿越时间。

(参考时间: 00:31:21)

讲师还谈到,自己最近发现 GPT 的实时语音模式很好用——大约从今年开始,它的实时语音模型可以在对话的同时,在后台调用一个更强的思考模型。于是可以一边开车一边把论文「扔」给它:先问一些简单问题它立即回答,再问一个「你能不能给我想一个符合现代软件前端的例子」,取决于提示词和你的想象力。这背后也依赖同一个思想:把任务分解、把复杂的部分交给更合适的机制去处理。


好的架构,设计的是一片解空间

命名的力量与 Steele 的《Growing a Language》

(参考时间: 00:34:36)

从 1972 年的结构化程序设计,讲师把视线移到 1990 年代。他找到一场很有意思的演讲——讲者是 Scheme 的设计者,也是《Java 语言规范》的合作者。

Guy L. Steele Jr. 在 SICP(《计算机程序的构造和解释》)的前言中谈到,人类很擅长给东西命名:看到一个名字,就能借助联想记忆唤起相关的概念。 一个好名字可以承载已经理解过的知识,于是程序员可以用「事务」「队列」「选课资格」思考,而不必每次都回到它们的全部实现细节。

阅读经典(1990s):Steele 与 SICP 前言

📷 阅读经典(1990s):Steele 与 SICP 前言
回到原视频 (00:34:36)

Steele 本人也是这些抽象工具的重要创造者:

(参考时间: 00:36:40)

在 1998 年的 OOPSLA 主题演讲 Growing a Language 中,他做了一件非常「炫技」的事:全篇只用单音节英文词,凡是遇到双音节以上的词,必须当场定义。 听众亲身经历了一次语言的「生长」:从单音节词出发,逐步定义新词、建立构词规则,再用这套共同词汇表达复杂思想。

Growing a Language:从单音节词开始的演讲

📷 Growing a Language:从单音节词开始的演讲
回到原视频 (00:36:40)

打开 Growing a Language 原文

📷 打开 Growing a Language 原文
回到原视频 (00:36:00)

编者拓展|单音节演讲的用意与门槛:这场演讲的文字稿本身就是一个设计样本——它把「语言必须能生长」这一论点,用「只用单音节词就必须不断定义新词」这件难事演给你看。讲师评价说,这是一种 AI slip 写不出来的品味,至少从今天的角度看,还需要人类的创造力才能想到。这也提醒我们:读经典不只是读结论,更是读作者「怎么把一件事做到极致」。

语言的生长:小语言与大语言的取舍

(参考时间: 00:38:33)

这场演讲借语言的生长,讲了一个编程语言里的核心问题:所有的编程语言相对于英语、相对于用户需求,都很小。 原因是语言需要一个形式语义(formal semantics)——如果语言里出现「woman」这种概念,它的形式语义就定义不出来;而必须有形式语义的东西,才能在计算机上执行。所以编程语言都相对较小。

(参考时间: 00:39:02)

如果一个语言在设计时是最极简(minimal)的,会怎样?讲师举了 Brainfuck:它基本上就是把图灵机直接编码成一串字符,用一个指针在纸带上左右移动、做加减、判断。它的「Hello World」程序人类几乎无法阅读。它确实满足「最小」,但完全无法用。

Brainfuck:最极简的语言不可读

📷 Brainfuck:最极简的语言不可读
回到原视频 (00:39:02)

(参考时间: 00:39:56)

相对可用一点的语言,比如 C,是一个设计得非常经典的语言,但 C 也带来了很多设计问题。它没有运算符重载,所以当你写 C 写得多时,「往队列里放东西」是一件很恐怖的事。再比如 C 标准库里的 qsort——它需要一个 int 的比较函数指针,签名还是 const void *,再配上数组长度和元素大小两个参数。这是一个设计得相当糟糕的 API。原因正是在于:C 没有能力让你自定义比较好的语言抽象,所以它在表达任何东西时都会遇到困难。

这样一来,两件事就对照起来了:

C 就是这种「小而丑但成功」的典型:我们喜欢 C,很大程度上是因为它直接和机器一一对应,没有额外的抽象,所以它是可以移植到几乎任何地方的编程语言。而在人力写代码的年代,写一个编译器又是一件非常复杂的事。

(参考时间: 00:43:17)

演讲还举了一些例子:Pascal 之类的老语言、C 到 C++ 的「生长」。讲师认为 C++ 长得并不算好——模板本来是一个非常重要的架构,但在 C++11/14/17 之前,模板被滥用到了 Boost 里那些没人看得懂的程度。模板是一个好机制,却「长出」了一些比较奇怪的东西。Steele 给 Java 的建议是只加三样:泛型(很重要,没有泛型构造抽象时就得到处强制类型转换)、运算符重载(Java 至今没有加入)、轻量用户自定义类。

扩展必须是一等公民

(参考时间: 00:45:12)

讲师把这场演讲概括为一句关于 grow ability 的架构哲学:

扩展必须是一等公民,而且扩展之间可以组合——这,就是一个可生长的架构。

它不是说「我有一个需求清单,然后把清单全部实现,这叫正确」。我们做 OJ 就有一点这个味道:从进大学开始疯狂做 OJ,会被训练成解题的机器——因为做 OJ 不需要任何扩展,题对了就行,代码是用后即扔的。久而久之,人就会不自觉地养成「用后即扔」的倾向,在这个方面的思考变少。这门课某种程度上要把这一点拉回来。

扩展必须是一等公民:给语言以组合机制,而不是需求清单

📷 扩展必须是一等公民:给语言以组合机制,而不是需求清单
回到原视频 (00:45:44)

阅读经典,建立品味

(参考时间: 00:47:29)

讲师有一个评论:有太多宝藏,被埋在这个快速发展的时代里了。 同学们进了组,总听到「我们最近又发表了一篇 state-of-the-art 的文章」「师兄又发了一篇」,某种程度上,大家的品味就被这些海量的人类产物(比如 CSDN 上的内容)和 AI slop、被那些「防自学教科书」污染了。

阅读经典,建立品味

📷 阅读经典,建立品味
回到原视频 (00:47:29)

他坦言,自己从本科高年级到博士毕业这段时间,也不可能把这些经典全读完——即使他算读书比较多的人,也只读了其中很少的一部分,然后有一些自己的想法。但今天,每一个人都可以快速、而且是原汁原味地去了解作者想传达的原始想法。

于是他表达了一个期待——「大学毁灭计划」:有没有可能,把大一的第一门程序设计课的知识点全拆散,不用某本教科书(那可能正是上课老师写的),而是把 1970 年、1980 年、1990 年那些经典的东西全部拆开,再重新串联起来,为学生生成一本教材。

好的架构:设计一片解空间

(参考时间: 00:49:20)

在进入具体案例之前,讲师把整讲的核心观点收束成一句:

好的架构,不是只选出一个解,而是设计开放需求空间所对应的解空间。

今天可以确定数据交换的规则,未来仍然允许增加新的处理步骤;今天可以确定业务对象的接口,未来仍然允许更换实现。我们不可能提前猜中所有需求,但可以让一类未来变化有合理的落脚点。

好的架构:设计解空间

📷 好的架构:设计解空间
回到原视频 (00:49:20)


UNIX:组合规则比现成用途更有力量

三条原则与文本接口

(参考时间: 00:49:39)

第一个经典案例当然是大名鼎鼎的 UNIX 哲学,常被概括为三句话:

设计解空间:UNIX Philosophy

📷 设计解空间:UNIX Philosophy
回到原视频 (00:49:39)

讲师强调:前两条容易赞同,真正让它们落地的关键往往是第三条。文本接口统一了「人–程序」和「程序–程序」的接口。如果只是二进制的字节流,对人来说很不友好。这就像用数据库时会觉得「隔了一层纱」——每次查询都要经过一个 SQL query,而这个 query 对你来说是间接的(indirect),人并不适应在做任何事时都要加一层间接。模型可以遵循「每说一句话就加个喵」这种指令,但人要这样做就会很痛苦。

没有共同接口,各自做好一件事的工具仍可能是一堆无法连接的孤岛。

(参考时间: 00:50:35)

这个想法其实非常早。讲师引用了 McIlroy 在 1964 年的备忘录——他很早就提出「像连接花园水管一样连接程序」,认为这应当成为连接程序到管道的方式。后来管道在 1983 年才引入 UNIX。可见「组合」这个想法,早在系统还不存在的时候就有人想过。

文本让人和程序、程序和程序有机会使用同一种接口。 一个工具的输出,既可以直接显示给人看,也可以保存到文件,或交给下一个工具。流水线出了问题,我们还能在中间截取结果,看看前一步究竟产生了什么——组合和检查,因而成为日常操作的一部分。

组合需要共同约定

(参考时间: 00:51:42)

不过,「都是文本」还不足以保证协作。命令行参数怎样区分选项与操作数,数据从哪里输入,诊断信息输出到哪里,成功与失败怎样报告,都需要共同约定。

UNIX:组合需要共同约定

📷 UNIX:组合需要共同约定
回到原视频 (00:51:42)

(参考时间: 00:52:35)

讲师用一个「打开文件的菜单」说明这种组合性的力量。设计一个软件,通常需要一个「打开文件」的菜单:要么自己实现一个,要么使用操作系统的。但如果你对这两个都不满意,你有办法吗?——在传统 GUI 程序里,没有办法。他举了 Adobe 全家桶的例子:它的打开菜单是自己的,因为要提供预览之类的功能;说它好用也好用,可当它不符合你的使用习惯时,就非常难用;而操作系统的文件对话框集成了绝大部分常用功能,却也未必顺手。

(参考时间: 00:53:27)

UNIX 里答案不一样。 无论你用的是 Vim 还是 Emacs,或者 nano/neovim,你都可以为它配一个自己的菜单——比如把第一课的 index 交给 nano 打开。这就是工具的组合性。(这个组合性多少带有历史的偶然,系统的设计者未必预见到后来会发生什么。)当它把你手上的文本接口、编程语言、UNIX 管道放在一起,就得到了一个无限组合和复用的空间。

讲师顺带说:你们都是程序员——从你使用 Linux/UNIX 的那一刻起,你做的每一个命令都是一个小程序,而这个小程序负责把各种各样「do one thing and do it well」的工具组合起来。这种可组合性非常关键,到今天你都很难在一个 GUI 程序里得到这样的组合性。

UNIX 文件菜单:自己组合出的打开方式

📷 UNIX 文件菜单:自己组合出的打开方式
回到原视频 (00:53:44)

使用者创造新的用途

(参考时间: 00:53:52)

长期的工具实践还形成了一些很有用的习惯:简单记录一行一条,复杂结构选择 TSV、JSON 等明确格式;供机器处理的输出不要随意混入进度条、颜色控制符和「操作成功!」;数据与错误说明分开;退出状态另行表达执行结果。分隔符也是接口的一部分——文件名能包含换行时,就不能假设「按行切开」总能无歧义地还原文件列表。

UNIX:使用者创造新的用途

📷 UNIX:使用者创造新的用途
回到原视频 (00:53:52)

现代工具继续扩展了这种组合能力:

于是「读取书目数据 → 提取书名 → 让人选一本」可以成为一条流水线;「窗口候选 → 选择 → 激活选定的窗口」也可以组合出自己的窗口切换工具。这些工具的作者不必提前设计好每一种书目管理或窗口管理需求。设计者提供组合的规则,使用者便可以创造设计者没有想到的用途。

编者说明:UNIX 留给后来者的,不只是若干好用的命令,还有一个可以继续生长的解空间。这正是上一节「好的架构设计解空间」的第一个实例:它固定的只是协作规则(文本流、参数约定、退出状态),开放的是具体用途。


关系数据库:分开理解数据与存放数据

(参考时间: 00:55:07)

(参考时间: 00:55:22)

第二个案例是关系数据库。如果你用文件系统去管理数据,你面对的是一个「原始」(raw)接口:好处是 UNIX 世界里的工具都可以复用,坏处是你太过自由,以至于没有统一的管理。

案例二:关系数据库

📷 案例二:关系数据库
回到原视频 (00:55:07)

(参考时间: 00:55:38)

E. F. Codd 在 1970 年的论文 做了一个非常了不起的抽象。他的原话是:希望保护用户,使他们不必因为数据表示方式、数据规模和访问需求的变化,就跟着重写对数据的使用方式。关键是把「数据在逻辑上意味着什么」和「数据在机器里怎样组织」分开。

Codd 实际上是对世界建立了一个模型:世界里的数据可以用一张张二维表来表示,表与表之间再通过一些操作(比如 join)来组合。你几乎可以用这些表和表之间的操作来处理任何你所需的数据管理,而数据怎么管、怎么存、可靠不可靠,全部交给底下的数据库系统实现。

编者拓展|数据独立性:Codd 这篇论文的目标常被概括为「数据独立性」——逻辑数据的组织方式,不应被物理存储结构的变化所破坏。讲师提到自己做过一段时间的程序合成,凡是和人类世界相关的程序合成,几乎都绕不开 Codd 1970 年的这篇论文。原因不难理解:现实世界的很多问题,本质上是「实体与实体之间的逻辑关系」,而关系模型给了它一个可以通用的、可计算的表示。

(参考时间: 00:56:02)

以图书馆为例:现实里发生了「某位读者借走某册书」。系统可以用读者、馆藏和借阅关系表达这个事实。我们既可以问某位读者借了哪些书,也可以问某册书被谁借走——这些问题都建立在同一组逻辑关系上。底层究竟采用哪种索引、记录放在哪个数据页、执行查询时先扫描哪张表,则属于另一个层次。

关系数据库:现实、关系与存储

📷 关系数据库:现实、关系与存储
回到原视频 (00:56:02)

(参考时间: 00:56:34)

在这一抽象之上,SQL 用声明式查询描述「要得到什么结果」,查询优化器选择具体执行方案,事务机制组织需要作为一个整体处理的读写。SQL 本身也是一种语言——和 Shell 一样。当你「生长」一个东西时,它多多少少都会带上语言/接口的性质;而接口足够灵活,它就会像一个编程语言。

由此,创建、读取、更新、删除,即 CRUD,成为大量业务系统的共同骨架。一个程序员可以把精力放在课程和选课关系上,不必先写一个磁盘存储系统。

(参考时间: 00:57:03)

数据库在任何时刻都可以理解为:若干二维表就是当前世界的一个状态。经过一次数据库的事务(transaction),状态从当前迁移到下一个;数据库还可以有视图(view)——当前状态可以用一个查询来创建一个切片,作为「我对这个世界当前的理解」。再加上近乎无限的扩展能力,数据库带起了整个人类世界信息化的一波很大浪潮:图书馆里的书可以信息化、学籍管理可以信息化、财务信息可以信息化、在线商城的订单可以信息化。

关系数据库与 CRUD

📷 关系数据库与 CRUD
回到原视频 (00:57:16)

(参考时间: 00:58:22)

这里同样存在开放空间:数据库设计者没有提前列出全世界的图书馆报表、商城查询和教务统计,却提供了表达关系、约束和查询的规则。用户可以在其上提出新的问题,底层也可以改进存储与执行方式。 逻辑关系稳定下来,双方就获得了一定程度的独立演化能力——这也是一个非常顶级的、隔离了复杂性的抽象。


Internet:在持续变化中保持协作

(参考时间: 00:58:31)

第三个案例是 Internet。它解决一个非常简单的问题——把整个世界连起来。今天我可以站在教室里上课、同时推流,我的手机还能兼看推流结果和弹幕,靠的就是它。

案例三:The Internet

📷 案例三:The Internet
回到原视频 (00:58:31)

(参考时间: 00:59:01)

以手机通过 TCP 访问 HTTPS 服务为例,应用提出请求,安全与传输协议处理相应的数据,IP 层组织跨网络传递,链路层负责相邻设备之间的传送。请求经过多个网络到达数据中心,接收端再逐层处理。应用不必知道途中每一段链路使用什么硬件,也不必为每一次物理路径变化重新设计业务逻辑。

graph TD
    A[应用层:HTTPS 请求] --> B[传输层:TCP,可靠传输]
    B --> C[网络层:IP,跨网络路由]
    C --> D[链路层:相邻设备传送]
    D --> E[物理媒介]

(参考时间: 00:59:45)

1996 年的 RFC 1958:Architectural Principles of the Internet 开篇就强调:信息技术一直在变化,因此不能把架构理解为一张永远不变的施工图。讲师把它概括为一句极有分量的话:

唯一应该永存的原则,就是变化本身。

Internet:在持续变化中演化(RFC 1958)

📷 Internet:在持续变化中演化(RFC 1958)
回到原视频 (00:59:45)

(参考时间: 01:01:09)

从用途看,应用层一直在变,而底层只需做好「数据传输」这一件事:

(参考时间: 01:02:04)

应用在变化,安全要求在变化,而承载它们的基础通信能力仍然可以继续服务。今天你访问的所有微服务(microservices),只要出了本地、要和互联网上的另一个服务商通信,几乎全都是 HTTPS;在上面又长出了 RESTful API 这样的规范(当然只是建议,你也可以不严格遵循)。这里的「做好数据传输这一件事」,是说基础设施应提供足够通用的通信抽象,让上层应用有自己的演化空间。

在共同规则之上,持续更换局部

📷 在共同规则之上,持续更换局部
回到原视频 (01:02:37)

它就像建设了一个城市的交通网络,而建筑一直在不断向前演进——街道与建筑逐步更新,城市仍持续运转。少量合作规则,可以生成庞大、多样、不断演化的技术空间。


从「一个页面一段 SQL」到 MVC

每一轮浪潮的新抽象

(参考时间: 01:03:42)

UNIX、关系数据库和 Internet 都属于支撑通用应用的 Systems——它们的价值不仅是自己解决了多大的问题,还在于它们让多少别的问题变得可以解决。从 IBM System/360、UNIX、面向对象的编程语言,到 Internet、Android(讲师说会专门用一次课来讲),再到云原生里的 Kubernetes,每一轮计算浪潮中都能看到这种抽象层的力量。

Systems:每一轮浪潮的新抽象

📷 Systems:每一轮浪潮的新抽象
回到原视频 (01:03:42)

面向客户的软件呢?

(参考时间: 01:04:45)

有了操作系统、数据库和网络,这个舞台其实已经为人类搭建好了,各种各样的业务逻辑系统(教务系统、图书管理系统……)都可以上线了。那么,面向客户需求的软件架构又该是什么样?

如果是面向客户需求的软件呢?

📷 如果是面向客户需求的软件呢?
回到原视频 (01:04:45)

最直接的 Web 应用思路就是:一个页面对应一段请求处理,背后执行一组数据库操作——也就是「每个页面都是一个 SQL transaction」。因为数据库已经完成了物理世界在信息世界里的投影,需求几乎可以被投射成不同的数据库事务,再为当前状态提供各种各样的视图(view)切片。比如想知道「谁选了这门生成式软件工程课」,就是写一个 select。

一次典型的选课请求

📷 一次典型的选课请求
回到原视频 (01:05:28)

以选课为例:学生点击选课,服务器读取课程信息,检查条件,写入选课记录,再返回页面。把其中需要保持一致的读写放进事务,一个朴素的业务流程就能工作起来。在需求点相对独立、每个需求点都对应一个数据库事务查询时,这个架构其实是非常好的,于是直接带来了一波应用浪潮(图书管理系统在那个时候集中爆发)。

graph TD
    U[学生点击选课] --> C[Controller 接收请求]
    C --> M[Model:读课程 → 检查条件 → 写选课记录]
    M --> T[数据库事务]
    T --> V[返回页面]

编者说明:注意这里已经埋下了两个伏笔——① 「每个页面一段 SQL」非常适合局部、稳定的业务对象;② 但它把「系统知道什么」和「怎样展示」揉在了一起。后者会在下一节引出 MVC。

Java Web 与模板语言

(参考时间: 01:07:20)

20 世纪 90 年代后期的 Java Web 浪潮,让这类应用开发普及起来。Java 虚拟机提供了跨平台的运行基础,「一次编写,到处运行」;自动内存管理解救了许多需要手工管理内存的程序员。当然,部署配置又贡献了新的复杂性,于是「Write once, run anywhere」也被调侃成了「Write once, configure everywhere」。

Java Web:直接在 HTML 里写代码

📷 Java Web:直接在 HTML 里写代码
回到原视频 (01:07:20)

(参考时间: 01:07:40)

页面生成还有一个很实际的问题。讲师用一个今天写 AI 应用的人都会遇到的场景来说明:如果你要自己拼一个 prompt,会不会写成 prompt = A + B + C + ...?这样拼当然可以,但写很多很多 prompt 时会很难维护。一个更好的办法是用一个模板语言:把 prompt 写成 you are ...、{instructions} 的形式,渲染模板时传入一个 instructions 变量,它就贴在那里;需要今天的日期,就把日期传进去。这样 prompt 就变得好维护了。这个特性最早在 PHP/ASP 时代就已经开始探索——如何在页面里嵌入服务端脚本。

(参考时间: 01:09:11)

在 Java 时代,JSP(Java Server Pages)把这个关系反过来:允许在 HTML 模板里嵌入服务器端代码,容器再把页面翻译成 Servlet 执行。早期写法使用脚本片段(scriptlet)和表达式,之后又引入表达式语言(EL)与标准标签库(JSTL),让「循环展示用户列表」之类的工作更接近模板描述。

JSP:从 Java 片段到表达式与标签

📷 JSP:从 Java 片段到表达式与标签
回到原视频 (01:09:11)

<%@ page contentType="text/html;charset=UTF-8" %>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<html><body>
    <% String msg = "Hello, JSP!"; %> <!-- Scriptlet:执行 Java -->
    <h1><%= msg %></h1>
    <ul>
        <c:forEach var="user" items="${users}"> <!-- JSTL + EL -->
            <li>${user.name}</li>
        </c:forEach>
    </ul>
</body></html>

这在当时是非常直接、实用的改进:页面仍然长得像页面。 同时,HTML 还提供了一个很好的设计——DOM 结构与样式分离(CSS),HTML 不管样式,但可以给不同的元素不同的样式。再加上可以动态生成内容,几乎任何形式的界面都能被排出来,信息系统于是可以集中爆发。

但「能直接写进去」,也意味着业务规则很容易被顺手塞进页面。 学生选课页检查一次课程容量,管理员补选页再写一次,批量导入又补一份。以后修改容量规则,就要找到所有入口;如果漏掉一个,页面看起来都正常,业务却开始自相矛盾。把脚本改成标签,也不会自动解决这种职责混杂。

MVC:让不同的变化各有归属

(参考时间: 01:10:53)

为了解决「业务代码应该放在哪里」,诞生了一个非常重要的模式:MVC(Model–View–Controller)。

MVC:让不同的变化各有归属

📷 MVC:让不同的变化各有归属
回到原视频 (01:10:53)

讲师现场打开了 1979 年的原始设计——它只有两页纸,最初面向交互式界面。我们可以用三个问题来理解它:

MVC:一次选课请求

📷 MVC:一次选课请求
回到原视频 (01:12:25)

在教务系统里:

(参考时间: 01:12:23)

这样,课表从列表换成周历,主要牵涉展示方式;选课限制变化,主要牵涉业务规则;新增管理员补选入口,则应复用已有业务能力,并显式表达它与普通选课不同的授权规则。

真正重要的是,变化有了归属。 同一项业务决定无需散落在每个页面里,理解界面时也不必同时展开所有数据库和业务细节。

编者拓展|MVC 的历史脉络:MVC 最早由 Trygve Reenskaug 于 1979 年在施乐 PARC 提出,原始目标是交互式图形界面——同一个业务对象,可以有不同的展示和操作方式。后来它在 Web 应用中又演化出「Web MVC」「前端 MVC」「MVVM」等不同组织方式。讲师特意强调:原始文献只有两页,但读它能看清这个模式最初的动机,而不是只记住「Model 放数据、View 放界面、Controller 放逻辑」这类口号。


界面状态也是状态:MVVM 与 React

为什么不能只靠 Model:ViewState

(参考时间: 01:12:55)

MVC 在「每次点按钮、页面重新刷新」的传统模式下成立。但在今天,界面上有大量元素,它们的状态并不在业务 Model 里。

把 ViewState 也当成 Model

📷 把 ViewState 也当成 Model
回到原视频 (01:12:55)

同一份课程列表,搜索框里的关键词不同,显示的课程就不同;展开哪个菜单、选中了哪一行、鼠标停在哪个元素上,也都会改变界面。教务系统令人讨厌的地方之一,就是搜索被做成了一个数据库事务:你要先输入、再点搜索、它把结果返给你;而很多时候,列表已经在你手上了,你只需要一个过滤器,它根本不涉及 Model 状态的改变——这个临时状态(比如过滤词、hover 的元素)通常不进入业务数据库,却仍然是程序需要管理的状态。

于是我们可以把界面写成一个概念上的关系:

$$ \mathrm{View} = \mathrm{render}(\mathrm{Model},\ \mathrm{ViewState}) $$

其中 ViewState 表示界面自身的状态。是否需要持久保存,与是否值得认真建模,是两件事。 如果把这部分信息藏在各个控件和事件回调里,程序员仍然要记住「点这里之后,要顺手改那三个地方」。

编者拓展|Presentation Model:把界面状态与行为从具体控件中提取出来,这个思想在 Fowler 2004 年前后被称为 Presentation Model。它的关键判断是:界面状态哪怕不持久化,也值得作为一个显式的对象来建模——因为「不持久化」和「不需要管理」不是一回事。

MVVM:让界面绑定状态

(参考时间: 01:14:57)

MVVM(Model–View–ViewModel)把面向展示的状态和行为进一步组织成 ViewModel。

MVVM:让界面绑定状态

📷 MVVM:让界面绑定状态
回到原视频 (01:14:57)

用课程筛选理解它很直观:

原始课程数据是一部分状态,筛选词 query 是另一部分,当前可见课程 visibleCourses 则由二者共同决定。我们应描述这个依赖关系,让框架维护展示结果。

Vue 提供了一种实现这种思路的方式:用响应式状态保存筛选词,用 computed 表达「可见课程由课程列表和筛选词计算而来」。计算属性会追踪它读到的响应式依赖,并在相关依赖变化时更新结果。于是不必在每个输入事件里手工维护另一份可见课程列表(Vue 计算属性)。

const query = ref('')
const courses = ref([ /* ... */ ])
// 派生结果:不另存一份,由课程列表与筛选词计算而来
const visibleCourses = computed(() =>
  courses.value.filter(c => c.name.includes(query.value))
)

输入框与筛选词之间又有两条连接:状态决定输入框显示的值,用户输入反过来更新状态。Vue 的 v-model 把这两条连接包装起来:

<input v-model="query">
<!-- 等价于两条显式的连接: -->
<!-- 状态 → 控件: :value="query" -->
<!-- 输入 → 状态: @input="query = $event.target.value" -->

所谓双向绑定,落实到这里就是明确的值传递和输入事件处理,并不意味着所有数据都可以随意双向流动(Vue 表单输入绑定)。

React:用状态快照描述界面

(参考时间: 01:17:11)

React 采用另一种表达方式:组件根据当前的属性与状态描述界面,用户事件通过状态更新函数请求下一次渲染。

React:用状态快照描述界面

📷 React:用状态快照描述界面
回到原视频 (01:17:11)

对于相同的筛选功能,可以用 useState 保存 query,在渲染时从课程列表和 query 计算出可见课程:

const [query, setQuery] = useState('')
// 派生列表不必另存为 State:每次渲染从现有数据算出来即可
const visibleCourses = courses.filter(c => c.name.includes(query))

return (
  <>
    {/* 状态 → 控件 */}
    <input value={query} onChange={e => setQuery(e.target.value)} />
    <ul>{visibleCourses.map(c => <li key={c.id}>{c.name}</li>)}</ul>
  </>
)

既然列表能够从现有数据算出来,就没有必要再给它安排一份独立的 State(Thinking in React)。

React 的状态还可以理解为每次渲染拿到的一份快照:调用 setQuery 请求更新,不会把当前这次渲染已经得到的变量立即改掉;下一次渲染会使用新的状态。这使得「某次界面是由哪些输入算出来的」更容易说清楚(State as a Snapshot)。

(参考时间: 01:16:47)

一个很自然的问题是:能不能干脆写 dom = f(state)——定义一个纯函数,输入当前所有界面状态,直接算出 DOM?可以,但这里有一个非常严重的性能问题:网页上可能有几万个元素,当你敲进一个字符、改变 query string 时,整个状态全部要重算。于是 React 在系统里做了大量 virtual DOM 的工作,把这些复杂性藏起来。你只要按它的模型声明「状态是什么、状态怎么变」,剩下全部交给它更新。

Vue 与 React:同一个课程筛选框

(参考时间: 01:17:54)

Vue 与 React:同一个课程筛选框

📷 Vue 与 React:同一个课程筛选框
回到原视频 (01:17:54)

Vue 更像是由框架帮你建立一个依赖关系图:你写 input 绑定什么的时候,它就建立依赖,并帮你保持同步;React 则更「纯粹」一些,让你显式地描述「此刻应该显示什么」。讲师说自己更偏向 React,因为它的 View 对开发者来说比较直观,写过一点前端就能立即上手。不过两者随着版本演进已经越来越像——它们的区别更多在编程体验上。

但它们都把「界面应该怎样依赖状态」放到了更显眼的位置。 课程列表变化或筛选词变化之后,我们希望程序沿着已表达的依赖得到新界面,而不必靠程序员记住一串零散的同步操作。这背后是同一个目标:把「有些变的东西」和「不变的东西」分离开,把逻辑和数据的源头分离开——因为我们是「small head」,当我们关注状态时,最好不要同时关注这个状态是怎么变成 DOM 的。

Vue 用绑定语法连接输入与状态;React 在事件中显式调用状态更新函数。需要注意:v-model 的双向连接只针对绑定点;Vue 的组件 Props 与 React 的组件数据流,都是从父到子。


把复杂性放进 Model,还远远不够

(参考时间: 01:19:09)

MVC 和 CRUD 对许多信息管理功能非常有效:业务对象比较稳定,一次请求完成一小段局部操作,页面展示已有信息,再提供新增、修改或删除入口。个人博客、课程信息查询,以及社交网络中的许多基础功能,都能从中受益。

应对复杂性

📷 应对复杂性
回到原视频 (01:19:09)

但业务一旦跨越很长的时间,情况就变了。 讲师举了电商的例子:一笔订单可能经历

graph LR
    P[下单] --> L[锁库存] --> D[优惠计算] --> Pay[支付] --> R[风控] --> S[发货] --> Ref[退款] --> A[售后] --> Rec[对账]

任何一步失败,都可能影响前后多个环节。教务系统也一样:修满学分之后,还有毕业设计、资产归还、特殊政策与批准记录。把这些代码统一挪进一个叫 Model 的目录,并没有解决本质复杂性。

(参考时间: 01:20:24)

问题出在「用一个当前状态来表示整条流程」。我们很自然地会放一张 order 表,里面有一列 status:1 代表下单,2 代表锁库存,3……或者干脆展开成一堆布尔列。这时状态机会变得非常复杂。更麻烦的是在那个节点上:

(参考时间: 01:21:37)

根源在于:业务流程可能是一整条很长的流程,它既要编码过去,又要编码现在,又要编码未来。 本质上,当前状态是过去的一个快照,但这个快照是由过去推演过来的;一旦「它怎么过来的」这条线索丢了,后来的不一致就会变得很难处理。

编者拓展|状态机的组合爆炸:把长流程压进「一个状态字段」,会带来两个后果——① 状态数与转移数随流程长度迅速膨胀,难以画全、难以测试;② 任何一次「例外处理」(风控豁免、特批、回滚)都会让不同字段之间出现历史性的矛盾,而「当前状态」这种表示无法记录矛盾是怎么产生的。这正是下一节 Event Sourcing 要重新分解的问题。


把过去、现在和未来分开

复式记账的启发

(参考时间: 01:23:26)

讲师先举了一个「更简单」的例子:记账。如果你想要真正严肃地记账,会计上有一个要求——复式记账法(double-entry bookkeeping)。

复式记账:同一笔业务,保持会计等式

📷 复式记账:同一笔业务,保持会计等式
回到原视频 (01:23:26)

(参考时间: 01:23:34)

它是为了防错——防止数据库出现不一致:当你发生不一致时,它能够检查出来。它要求每一笔交易都同时计入两个或两个以上的账户,且金额相等、方向相反。只记一条虽然简单,但无法反映业务的完整结构,也不具备自平衡的校验能力。人类发明它,就是因为一个「当前状态」很可能是损坏(corrupted)的——它要把「为什么不对」一定程度上找出来。

这里的「借」「贷」是记账方向,不能简单理解为日常语言里的借入和借出。例如,一笔借款到账,银行存款增加,同时债务也增加;归还其中一部分本金,存款和债务相应减少。这些变化共同维持

$$ \text{资产} = \text{负债} + \text{所有者权益} $$

的会计等式(复式记账的基本说明)。

结构提供了可检查的约束,但算得平不等于记得对。 把业务归错类、重复录入一笔金额相同的借贷记录,都可能仍然平衡。原始凭证、业务含义和记账动作之间的对应关系,仍然需要核对。

Event Sourcing 与 LLM as a Compiler

(参考时间: 01:25:07)

讲师坦白自己对会计没有任何知识。那么,如何在没有领域知识的情况下设计一个好的记账信息系统?他用了一般的东西——Event Sourcing(事件溯源)。

(参考时间: 01:25:33)

思路是:不再记录当前状态,而是记录所有状态的迁移。 与其维护一个数据结构「当前的样子」,不如记录它是怎么来的——一笔流水账:「今天收了 1000 块,明天减了 1000 块,原因是……」。

讲师进一步设计了一个像编译器一样的小东西。

记账架构:Event Sourcing & LLM as a Compiler

📷 记账架构:Event Sourcing & LLM as a Compiler
回到原视频 (01:26:32)

graph LR
    V[原始凭证 / 业务事件] -->|LLM 编译器| A[受约束的记账动作]
    A -->|逐条 reduce / 重放| R[资产负债表 · 利润表 · 现金流量表]

一个具体的记账动作长这样:报表_支出_财务费用(CNY(75))——它表达的是一项具体记账动作,CNY 则把金额放进人民币的金额表示里。案例的记账规则表列出了各类动作怎样影响资产负债表、利润表和现金流量表。这些表格完全是 AI 生成的——讲师选择相信 AI,也相信最终那个校验能发现问题。

由 AI 生成的记账规则表

📷 由 AI 生成的记账规则表
回到原视频 (01:26:47)

当前状态的资产负债表

📷 当前状态的资产负债表
回到原视频 (01:25:07)

编者说明:理解这个例子的关键是——模型在这里像一个编译器前端,把人的材料翻译成系统可以检查和执行的表示;真正的含义和约束由账本代码负责。编译出来的动作是否符合真实业务,仍然是需要审查的环节。

工资结算:受约束的操作

(参考时间: 01:28:43)

讲师还分享了一个细节:一开始 GPT 写的代码非常长、非常糟,于是他用了一个函数式编程里很常用的小技巧——给 payroll 加上一个参数(相当于把 state 预先确定),把函数「柯里化」压缩成一个小操作。下面是案例中的一个实现片段,它依赖项目内的金额类型、分类表和账本操作,不能脱离这些定义单独运行:

@Curry
def pay_payroll(state, category, amount, *, kind):
    """按实际付款金额结算薪酬或个人劳务;代扣款先单独记账,再支付净额。"""
    flow = PAYROLL_FLOWS[category]
    key = payable(flow, kind == "个人劳务")
    state.require(key, amount)
    cash(state, -amount, flow, balances={key: -amount})

这里先按业务类别确定应付款项,检查相应余额是否满足结算要求,再同时减少现金和对应的应付余额。代扣款应先通过独立动作记账,然后支付净额。这样,「确认费用」「形成应付」「实际支付」就能够被区分,避免每次付款时又把费用重复算一遍。

编者说明:@Curry 是一个柯里化(currying)装饰器——它把多参数函数变成可逐步传参的函数,这里用来把状态 state 预先「钉死」,让上层只需要提供业务参数。它本身与记账规则无关,属于把长函数变短、把重复参数抽出的工程技巧。

从分录到报表

(参考时间: 01:29:32)

以银行收支为例,一份记账条目包含两笔动作:财务费用支出 75 元、投资收益中的利息收入 22.07 元。执行后,货币资金的净变化是

$$ 22.07 - 75 = -52.93\ \text{元} $$

利润表上相应记录费用与收益,净利润减少 52.93 元,相关权益和现金流量项目也按该工具的规则得到结果。

从分录到报表

📷 从分录到报表
回到原视频 (01:29:32)

银行收支在报表上的 side effect

📷 银行收支在报表上的 side effect
回到原视频 (01:29:19)

报表上的许多数字共同变化,但我们不希望让模型逐格填写这些数字,而希望它们来自同一组动作及其计算规则。

记账为什么麻烦:当前状态背着过去、现在和未来

(参考时间: 01:30:34)

记账为什么麻烦?

📷 记账为什么麻烦?
回到原视频 (01:30:34)

一个「当前状态」,常常同时背着过去、现在和未来:

如果只盯着当前余额和一个状态字段,很多不同的历史会被压进同一种表示,很多尚未发生的预期又会与已经发生的事实混在一起。

(参考时间: 01:30:53)

这就是为什么用数据库管理「现在」会出问题。数据库很自然地管理的是当前事实(学校现在的状态、每个学生是什么、这学期有哪些课),而这个状态是可变的。可一旦你的逻辑改变、或逻辑有错,状态就很容易造成不一致——你会从一个对的状态走到一个错的状态。而我们还希望架构是可生长的:突然多了一个新需求,或者过去的某个规则改掉了,这时你就需要为状态生成一个补丁。数据库迁移是一个非常危险的操作。

讲师讲了一个极其生动的例子:

(参考时间: 01:31:03)

本质复杂性里有大量的这种依赖关系:我依赖你,你依赖我。

(参考时间: 01:32:32)

如果用「当前状态数据库」处理,你把这门课的成绩从「一」改成「零」,但你对数据库剩下的部分到底做了什么,是很难评估的,很容易漏掉。

Event Sourcing 和复杂性分解

(参考时间: 01:32:48)

那么为什么要用 Event Sourcing?因为你做的事情不是维护一个当前状态,而是搞清楚所有原始数据之间的依赖(dependency)。

Event Sourcing 和复杂性分解

📷 Event Sourcing 和复杂性分解
回到原视频 (01:32:48)

在这种架构中:

比如,LLM 编译器从一条记账条目到一个操作也可能出错。但错了以后,因为有人工审核,发现翻译错了就直接改正,不会付出任何代价;如果真发生了一个影响其他东西的事实(比如超额选课影响了其他课程),在这个架构里你也知道:你改了这个事实,就可以把所有计算重新做一遍——哪怕用一个自动的数据流分析,你都知道「通过了 ICS 课」从「通过」变成「不通过」,会影响哪些结论。

因为你把 data flow 显式地(explicit)表达出来了,而不是以状态的形式藏在当前状态里。 所以所有的计算逻辑都是可溯源的。

(参考时间: 01:37:38)

以「计算机系统基础课的学分」为例:课分可能是会变的。如果有 Event Sourcing,你就知道「在当前这个时刻,计算机系统基础的学分是几」,就不会出现「确实减了学分,那你学分还够不够毕业」这种被历史压扁后无法回答的问题。

(参考时间: 01:34:41)

Event Sourcing 还有一个非常好玩的特点:它是 replay-based 的——当前状态完全由过去决定,所以你可以用它来推演未来的状态。比如一个订单流程走到「支付」时,你可以推演:如果风控成功,状态会到什么样;如果风控失败,状态会怎么样。也就是说,如果你有一个账本,你甚至可以在它之上评估「我到底会不会破产、破产的概率有多大」——只要有一个模型,知道比如应收账款有多少回不来,就能对未来做评估。

跳出问题看问题:现在是过去的结果(推演未来)

📷 跳出问题看问题:现在是过去的结果(推演未来)
回到原视频 (01:35:14)

编者拓展|Event Sourcing 的两个前提:Martin Fowler 在 Event Sourcing 中专门分析过重放的两个限制——① 要复现当时的结果,就必须知道当时使用的规则和外部输入(例如哪一版培养方案、某次计算采用的汇率),而不能偷偷换成今天的数据;② 重放账本可以重新计算余额,却不能把已经完成的付款再执行一次。也就是说,对外部世界的动作,需要与内部状态重建区分开来(隔离外部副作用)。这两点提醒我们:Event Sourcing 并不消灭复杂性,它只是把「状态一致性」问题转换成「计算正确性」问题。


教务系统:让毕业结论有一条可追溯的计算路径

(参考时间: 01:36:18)

把同样的想法带回教务系统,就能看见一个熟悉的问题。数据库里可能同时存着成绩记录、已修课程列表、总学分、各类别学分和「是否满足毕业条件」。如果把这些都当作可以各自修改的独立事实,更正一门课的成绩时,就必须记得去修改后面的每一项。遗漏一处,系统里便同时存在几个互相矛盾的「现在」。

跳出问题看问题:「现在」是「过去」的结果

📷 跳出问题看问题:「现在」是「过去」的结果
回到原视频 (01:36:18)

其实,其中许多状态本来就是别的信息的结果。看这条显式的数据依赖链:

graph TD
    A[选课 / 成绩 / 认定记录] --> B[当前已通过课程集合]
    B --> C[各类别学分]
    D[适用的培养方案] --> E[课程准出条件是否满足]
    C --> E
    E --> F[毕业结论]
    G[毕业设计 / 资产归还 / 特殊批准] --> F

(参考时间: 01:31:39)

假设某门必修课的成绩经过复核,从「不通过」更正为「通过」。我们希望系统记录这次更正,重新计算受影响的已通过课程、学分和审核结论,并能解释为什么发生变化。如果某门交换课程只能计入一个类别,或者两门交换课程合抵一门必修课,就应让认定记录和对应规则参与计算,而不是在总学分字段上直接加一个数字了事。

经过批准的特例也要有自己的位置:某个学生获得了课程替代或条件豁免,应当保存批准对象、适用范围和依据,使后续计算可以使用它。否则,一个没有来由的「审核通过」只是在隐藏复杂性——下一次重新审核时,系统仍然不知道为什么要通过。

编者说明:这是一条显式的数据依赖链——事实与规则作为输入,逐层产生结果。每个结论都可以追问「依据哪些数据、经过哪个计算得到」。发生「反悔」时,不再靠人到处寻找需要手工修改的副本,而是沿依赖关系重新得到派生结果。当然,计算规则仍可能有错误,派生结果的更新也可能暂时滞后;区别在于,我们保留了查证与修复所需的依据。


从状态一致性问题,转向计算正确性问题

(参考时间: 01:37:58)

看到这里,可以对 SQL 提出一个带点挑衅的批评:它没有把跨数据的任意计算关系,充分作为一等公民来表达和维护。 讲师半开玩笑地说,这也算一个「Billion Dollar Mistake」吧。

SQL 的 Billion Dollar Mistake

📷 SQL 的 Billion Dollar Mistake
回到原视频 (01:37:58)

所谓一等公民(first-class citizen),是说这类关系应当成为可以直接声明、组合、检查和追踪的对象,而不只是散落在应用代码里的一些约定。

SQL 当然能够表达查询,数据库也有视图、约束和物化视图;例如 PostgreSQL 的物化视图 可以保存查询结果,并通过刷新重新生成。这里批评的焦点,是跨过数据库、应用逻辑、缓存乃至界面的计算依赖:一条基础记录变了,所有依赖它的结果由谁负责更新?怎样判断哪些结果已经失效?又怎样追溯一个结果的来源?

(参考时间: 01:38:33)

比如数据库里有一列「能不能毕业」,它的值只能是 0 或 1——几乎所有人都会这样设计,因为你必须能快速把它查询出来。但关系代数无法为这种「任意图灵可计算的计算关系」建模,于是一个字段就成数据库里潜在的不一致点。从这个角度看,MVVM、React、Dataflow 和 Event Sourcing 都在用不同方式补上这类表达能力:

它们所处的层次和具体机制各不相同,但都在尝试把「这个结果依赖什么」显式化。

编者说明:把它们放在一起,是一种理解架构的视角,而不是在断言它们都因 SQL 的某个缺陷而诞生。讲师特别提醒这一点,是为了避免我们把这些工具简单地贴标签、排座次。

编者拓展|「Billion Dollar Mistake」出自谁:这个说法来自 Tony Hoare——他在 2009 年的一次演讲中,为 1965 年发明空指针(null reference)而道歉,称其为「我的十亿美元错误」(My Billion Dollar Mistake),因为空指针导致的大量程序崩溃造成了巨大损失。讲师借这个说法来调侃关系模型「没把任意计算关系作为一等公民」这一设计选择。要注意:当年的关系模型本身是一个极其可扩展、非常好的架构,这个批评是站在今天回望时的一种补充,而不是对它历史价值的否定。

(参考时间: 01:39:22)

由此得到一个很有力量的设计原则:

系统应保存足以重建其余信息的事实依据(source of truth),其他状态尽可能成为计算的结果。

从状态一致性到计算正确性

📷 从状态一致性到计算正确性
回到原视频 (01:39:22)

为了性能,可以保存汇总、索引和缓存,但应当清楚它们从哪里来、何时失效、以及怎样重建。一个业务决定如果必须由人作出,就把决定及其依据保存下来;能够推导的东西,则尽量不要再引入一份独立的决定。

(参考时间: 01:39:32)

复杂性仍然存在,却从「怎样保证许多份状态永远一致」转移到了「这段计算是否正确」。后一个问题往往更容易管理:给定输入可以重跑,可以写有意义的测试,可以审计计算步骤,可以分析依赖。发现旧规则有错误时,也有机会修正规则,在明确的适用范围和时间边界内重新计算,而不是对着一堆已经失去来源的数字逐个打补丁。

对生成式软件工程而言,这种转移尤其重要。 让 AI 面对散落各处的隐含同步要求,它就需要反复猜测哪些地方必须一起改;把事实、规则和依赖表达清楚之后,生成与检查才有了共同的对象。代码越容易生产,我们越有理由认真选择这种表示:让系统中的每一个重要结果,都能回答「我是怎样算出来的」。


本讲小结


附:参考与延伸阅读

以下讲义原文链接来自课程官方讲义(注明了出处文件);编者补充是撰写本书时另加的,不与讲义混淆。

讲义原文链接

编者补充