上一讲从教务系统的需求泥潭出发,讨论了几个规模很不一样的程序:十行左右的汉诺塔、不断长大的二维码生成工具,以及服务整个学校的教务系统。它们都有架构问题。即使需求看起来已经明确,实现它的方式仍然很多;一个“脑子一热”想出来、确实能够工作的架构,也往往还有很大的改进空间。
“代码之外有客户的规律;客户之外有社会的规律;社会之外有宇宙的规律。”跳出当前的代码,可能发现一个更合适的数学结构、一套已经成熟的业务规则,或者一种能够重新组织问题的抽象。这一讲继续追问:好的架构究竟替我们做了什么?当需求还在变化、未来还没有到来时,我们又能设计什么?
从意图(Intent)到规约(Spec),再到实现(Impl),并不是一条唯一的翻译路径。“让学生方便地选课”可以对应先到先得、志愿排序、抽签等不同规约;即使选定其中一种规约,也还有许多数据结构、模块划分和交互方式可供选择。每一步都在作决定,每一个未经确认的决定也都可能让结果逐渐偏离原来的意图。
软件工程的本质,是驾驭这些不断增长的复杂性。其中一部分来自问题领域本身,称为本质复杂性(essential complexity)。一门课有容量限制,学生要满足先修条件,两门课的时间不能冲突,转专业后还要认定已经修过的课程。即使完全不用计算机,改成教务员拿纸笔办理,这些问题也依然存在。
另一部分是实现方式额外带来的附带复杂性(accidental complexity)。同一个学生的信息保存了五份,改一份就要同步另外四份;几个模块使用不同的日期格式,连接起来需要不断转换;一套规则被复制到多个页面,修改时总会漏掉一处。这些麻烦并非客户的工作天然要求,而是我们组织软件的方式制造出来的。
需求工程首先要直面本质复杂性,收缩意图空间中的不确定性。说“课程容量为 100 人”还不够:候补学生算不算?跨院预留名额能不能借用?特殊批准能不能突破上限?补充这些条件,会减少实现者自行猜测的空间。但需求也不是越长越好。每写下一条承诺,就多了一条需要检查、维护并在需求变化时重新审视的约束。到了生成式软件工程里,这些工作还会体现为上下文长度、token 开销,以及模型在复杂约束下出错的机会。
架构设计承担另一项工作:分解本质复杂性,减少附带复杂性。我们不能通过“分模块”让先修要求凭空消失,却可以把资格、容量、时间冲突分别建模,让学生选课、管理员补选和批量导入都调用相同的规则。模块职责、数据归属和接口一旦明确,规约与实现中大量随意组合的可能性就被排除了。判断一个架构是否有帮助,可以从一个很朴素的问题开始:需求改了一处,系统里需要跟着改多少处?
Dahl、Dijkstra 和 Hoare 的《Structured Programming》讨论的许多程序,今天看来都很小。但它们面对的问题一点也不过时:人能同时精确理解的东西非常有限,怎样让这种有限的能力足以构造和检查一个大程序?
Dijkstra 在第一部分的“On our inability to do much”里直言,自己的脑袋很小,最好学会尊重这种限制,而不是假装它不存在。程序规模增大,并不只是多写几遍同样的代码。能在脑中追踪十个状态,不意味着能用同样的办法追踪一万个状态。接着,他讨论了三种思维辅助:枚举、归纳和抽象。枚举帮助检查有限的分支,归纳帮助理解循环与递归,抽象则让我们使用一个操作时,只依赖它承诺的效果,不必每次重新展开内部过程。原文:Notes on Structured Programming。
书中的“珍珠项链”比喻,进一步把程序看成一串相互衔接的抽象层。每颗珍珠承担一项设计决定,上层使用下层提供的概念。如果两种实现提供相同的接口,就有机会替换其中一颗,而保留其余部分。重要的不只是切出了多少块,还包括决定的顺序:例如,过早把一幅图像解释成一行一行的表示,中间各层就都被迫理解“行”;推迟这个表示决定,它们便能继续用“图像”思考。表示细节传播得越远,修改时牵动的地方通常就越多。
第三部分“Hierarchical Program Structures”把这些想法落实到 SIMULA 的语言机制里。类描述一组相关对象的结构和行为,对象保存自己的状态。这样,我们可以先用业务概念思考,再把概念逐层落到机器能够执行的操作上。对象抽象解决了“这一份数据和哪些操作属于同一个概念”的问题;协程则帮助处理另一种耦合:几个程序片段之间,谁必须服从谁的控制流?
子程序的基本关系是调用与返回:主程序调用它,它完成工作后返回。协程可以在执行中暂停,保留自己的局部状态和执行位置,之后从暂停处恢复。书中用两个游戏程序互相对弈来说明这个区别:双方都要计算自己的下一步、交给对方、等待回应,再继续。强行把其中一方改成另一方的子程序,会让原本对等的合作关系变得别扭。把它们看作交替恢复的协程,就能保留双方各自的程序结构。原文第四节,184—185 页。
第三部分第七节“Concept Hierarchies”尤其值得顺着读一次:先有 TWLIST 提供双向链表操作,再在其上构造离散事件模拟机制 MINISIM,最后用这些机制描述车间里的订单和机器。底层负责链表连接,中层负责模拟时间、等待和唤醒,上层则可以表达“请求一台机器,加工一段时间,再释放机器”。每向上一层,使用者就获得一套更贴近问题的词汇,也能暂时忘掉一批已经处理好的细节。
今天可以让 AI 带着我们读这段程序,但有价值的提问应当落到原文上:这一层新引入了什么概念?调用者因此不再需要关心什么?哪些操作会破坏底层的假设?让 AI 指出对应代码和页码,再去核对,比让它概括“这本书很经典”有用得多。我们并不需要照搬几十年前的全部设计;要理解的是,那些设计怎样帮助有限的头脑掌控更大的系统。
Guy L. Steele Jr. 在 SICP 的前言中谈到,人类很擅长给东西命名;看到一个名字,就能借助联想记忆唤起相关的概念。一个好名字可以承载已经理解过的知识。程序员于是可以用“事务”“队列”“选课资格”思考,而不必每次都回到它们的全部实现细节。
Steele 本人也是这些抽象工具的重要创造者:他与 Gerald Jay Sussman 共同设计了 Scheme,撰写了 Common Lisp: The Language,并参与编写 Java 语言规范。在 1998 年的 OOPSLA 主题演讲 Growing a Language 中,他甚至让听众亲身经历了一次语言的“生长”:从单音节词出发,逐步定义新词、建立构词规则,再用这套共同词汇表达复杂思想。
这场演讲的一个关键观点,是设计一个东西固然很好,设计一种允许后来者继续创造的模式,可能更有价值,也更困难。模式规定各部分怎样配合,同时留下可以以后填入的空位。哪些选择现在固定,哪些选择交给未来的使用者,本身就是设计的一部分。语言设计因而不仅是在决定今天有哪些功能,也是在设计人们以后怎样扩充这门语言。
把这个思想放回软件架构,就得到本讲的核心观点:好的架构不是只选出一个解,而是设计开放需求空间所对应的解空间。 今天可以确定数据交换的规则,未来仍然允许增加新的处理步骤;今天可以确定业务对象的接口,未来仍然允许更换实现。我们不可能提前猜中所有需求,但可以让一类未来变化有合理的落脚点。
这也解释了为什么经典值得阅读。海量教程、拼凑的经验和 AI Slop 很容易让人习惯于“这样能跑就行”,有些“防自学教科书”又把有用的思想埋在术语里。经典中的许多设计,是顶级工程师在长期工作中反复碰壁后积累下来的判断。读它们,可以帮助我们建立品味:看到一个系统时,能辨认它固定了什么、开放了什么,以及这些决定会把使用者带向哪里。
UNIX 哲学常被概括为三句话:每个程序做好一件事;让程序能够协作;让程序处理文本流,因为文本是一种通用接口。前两条容易赞同,真正让它们落地的关键往往是第三条。没有共同接口,“各自做好一件事”的工具仍可能是一堆无法连接的孤岛。
文本流让人和程序、程序和程序有机会使用同一种接口。一个工具的输出,既可以直接显示给人看,也可以保存到文件,或交给下一个工具。流水线出了问题,我们还能在中间截取结果,看看前一步究竟产生了什么。组合和检查,因而成为日常操作的一部分。
不过,“都是文本”还不足以保证协作。命令行参数怎样区分选项与操作数,数据从哪里输入,诊断信息输出到哪里,成功与失败怎样报告,都需要共同约定。POSIX 的工具语法约定区分短选项、选项参数和操作数,并规定了用 -- 结束选项解析的通用规则;具体工具仍可能保留历史例外。标准输入、标准输出和标准错误提供不同通道,Shell 的管道默认把前一个程序的标准输出连接到后一个程序的标准输入。POSIX 工具约定、Shell 命令语言。
长期的工具实践还形成了一些很有用的习惯:简单记录一行一条,复杂结构选择 TSV、JSON 等明确格式;供机器处理的输出不要随意混入进度条、颜色控制符和“操作成功!”;数据与错误说明分开;退出状态另行表达执行结果。分隔符也是接口的一部分,文件名能包含换行时,就不能假设“按行切开”总能无歧义地还原文件列表。看似琐碎的约定,正是在保护组合的可靠性。
现代工具继续扩展了这种组合能力。jq 能把 JSON 中需要的字段提取出来,fzf 能从输入候选中让人交互选择,再输出选中的结果。于是“读取书目数据、提取书名、让人选一本”可以成为一条流水线,人的判断也被安插进程序之间。面向 X11 的 xdotool 又把窗口查找、键盘输入、鼠标操作等变成命令接口。将窗口候选交给选择器,再激活选定的窗口,就可以组合出自己的窗口切换工具。
这些工具的作者不必提前设计好每一种书目管理或窗口管理需求。设计者提供组合的规则,使用者便可以创造设计者没有想到的用途。 UNIX 留给后来者的,不只是若干好用的命令,还有一个可以继续生长的解空间。
关系数据库提供了另一种影响深远的分离。E. F. Codd 在 1970 年的论文中,希望保护用户,使他们不必因为数据表示方式、数据规模和访问需求变化,就跟着重写对数据的使用方式。关键是把“数据在逻辑上意味着什么”和“数据在机器里怎样组织”分开。A Relational Model of Data for Large Shared Data Banks。
以图书馆为例,现实里发生了“某位读者借走某册书”。系统可以用读者、馆藏和借阅关系表达这个事实。我们既可以问某位读者借了哪些书,也可以问某册书被谁借走;这些问题都建立在同一组逻辑关系上。底层究竟采用哪种索引,记录放在哪个数据页,执行查询时先扫描哪张表,则属于另一个层次。
在这一抽象之上,SQL 用声明式查询描述要得到什么结果,查询优化器选择具体执行方案,事务机制组织需要作为一个整体处理的读写。由此,创建、读取、更新、删除,即 CRUD,成为大量业务系统的共同骨架。一个程序员可以把精力放在课程和选课关系上,不必先写一个磁盘存储系统。
这里同样存在开放空间:数据库设计者没有提前列出全世界的图书馆报表、商城查询和教务统计,却提供了表达关系、约束和查询的规则。用户可以在其上提出新的问题,底层也可以改进存储与执行方式。逻辑关系稳定下来,双方就获得了一定程度的独立演化能力。
Internet 把类似的思想带到了全球通信中。以手机通过 TCP 访问 HTTPS 服务为例,应用提出请求,安全与传输协议处理相应的数据,IP 层组织跨网络传递,链路层负责相邻设备之间的传送。请求经过多个网络到达数据中心,接收端再逐层处理。应用不必知道途中每一段链路使用什么硬件,也不必为每一次物理路径变化重新设计业务逻辑。
1996 年的 RFC 1958:Architectural Principles of the Internet 开篇就强调,信息技术一直在变化,因此不能把架构理解为一张永远不变的施工图。文中用城市作类比:街道和建筑不断改建,城市仍然继续运转。共同的协作规则,使局部替换和整体延续能够同时发生。
从用途看,远程登录逐渐从 Telnet 转向 SSH,许多文件传输和信息访问场景转向 HTTPS,电子邮件则延续了 SMTP、IMAP 等协议体系。应用在变化,安全要求在变化,承载它们的基础通信能力仍然可以继续服务。这里的“做好数据传输这一件事”,是说基础设施应提供足够通用的通信抽象,让上层应用有自己的演化空间。
UNIX、关系数据库和 Internet 都属于支撑通用应用的系统。它们的价值不仅是自己解决了多大的问题,还在于它们让多少别的问题变得可以解决。操作系统、图形交互系统、Web 平台乃至移动平台,每一轮计算浪潮中都能看到这种抽象层的力量。接下来,把视线从这些基础设施移到具体客户的软件:一个教务系统,又应该怎样组织?
有了操作系统、数据库和网络,最直接的 Web 应用思路就是:一个页面对应一段请求处理,背后执行一组数据库操作。学生点击选课,服务器读取课程信息,检查条件,写入选课记录,再返回页面。把其中需要保持一致的数据库读写放进事务,一个朴素的业务流程就能工作起来。
20 世纪 90 年代后期的 Java Web 浪潮,让这类应用开发变得普及。虚拟机提供了跨平台的运行基础,自动内存管理解救了许多需要手工管理内存的程序员。当然,部署配置又贡献了新的复杂性,于是“Write once, run anywhere”也被调侃成了“Write once, configure everywhere”。
页面生成还有一个很实际的问题:如果在 Java 代码里一行行输出 HTML,页面结构很快就会淹没在字符串拼接里。JSP 把这个关系反了过来,允许在 HTML 模板里嵌入服务器端代码,容器再把页面翻译成 Servlet 执行。早期写法使用脚本片段和表达式,之后又引入表达式语言与标准标签,让循环展示用户列表之类的工作更接近模板描述。Oracle 的 JSP 说明。
这在当时是非常直接、实用的改进:页面仍然长得像页面。但“能直接写进去”,也意味着业务规则很容易被顺手塞进页面。学生选课页检查一次课程容量,管理员补选页再写一次,批量导入又补一份。以后修改容量规则,就要找到所有入口。如果漏掉一个,页面看起来都正常,业务却开始自相矛盾。把脚本改成标签,也不会自动解决这种职责混杂。
MVC,即 Model–View–Controller,提供了关注点分离的思路。它在 1979 年的早期设计中面向交互式界面,后来又在 Web 应用中形成了不同的具体组织方式。我们可以用三个问题来理解它:系统知道什么、怎样展示这些知识、怎样接收和解释用户的操作?
Model 封装数据、状态与业务规则;View 负责展示;Controller 接收输入并协调后续处理。在教务系统里,Model 包括课程、选课记录,以及容量和时间冲突等规则,并不只是数据库表的另一个名字。Controller 把一次“选课”请求转交给相应业务操作,View 则把成功结果或失败原因呈现出来。
这样,课表从列表换成周历,主要牵涉展示方式;选课限制变化,主要牵涉业务规则;新增管理员补选入口,则应复用已有业务能力,并显式表达它与普通选课不同的授权规则。真正重要的是变化有了归属。同一项业务决定无需散落在每个页面里,理解界面时也不必同时展开所有数据库和业务细节。
不过,仅有业务数据,还不足以决定用户看到的界面。同一份课程列表,搜索框里的关键词不同,显示的课程就不同;展开哪个菜单、选中了哪一行、鼠标停在哪个元素上,也都会改变界面。这些信息通常不进入业务数据库,却仍然是程序需要管理的状态。
因此,我们可以把界面写成一个概念上的关系:View = render(Model, ViewState)。其中 ViewState 表示界面自身的状态。是否需要持久保存,与是否值得认真建模,是两件事。如果把这部分信息藏在各个控件和事件回调里,程序员仍然要记住“点这里之后,要顺手改那三个地方”。
MVVM,即 Model–View–ViewModel,把面向展示的状态和行为进一步组织成 ViewModel。用课程筛选理解它很直观:原始课程数据是一部分状态,筛选词 query 是另一部分,当前可见课程 visibleCourses 则由二者共同决定。我们应描述这个依赖关系,让框架维护展示结果。
Vue 提供了一种实现这种思路的方式:用响应式状态保存筛选词,用 computed 表达“可见课程由课程列表和筛选词计算而来”。计算属性会追踪它读到的响应式依赖,并在相关依赖变化时更新结果。这样,就不必在每个输入事件里手工维护另一份可见课程列表。Vue 计算属性。
输入框与筛选词之间又有两条连接:状态决定输入框显示的值,用户输入反过来更新状态。Vue 的 v-model 把这两条连接包装起来。所谓双向绑定,落实到这里就是明确的值传递和输入事件处理,并不意味着所有数据都可以随意双向流动。Vue 表单输入绑定。
React 采用另一种表达方式:组件根据当前的属性与状态描述界面,用户事件通过状态更新函数请求下一次渲染。对于相同的筛选功能,可以用 useState 保存 query,在渲染时从课程列表和 query 计算出可见课程。既然列表能够从现有数据算出来,就没有必要再给它安排一份独立的 State。Thinking in React。
React 的状态还可以理解为每次渲染拿到的一份快照。调用 setQuery 请求更新,不会把当前这次渲染已经得到的变量立即改掉;下一次渲染会使用新的状态。这使得“某次界面是由哪些输入算出来的”更容易说清楚。State as a Snapshot。
Vue 的绑定和响应式依赖追踪,与 React 的状态快照和显式更新,在编程体验上有明显区别,但它们都把“界面应该怎样依赖状态”放到了更显眼的位置。课程列表变化或筛选词变化之后,我们希望程序沿着已表达的依赖得到新界面,而不必靠程序员记住一串零散的同步操作。这个问题很快就会超出前端的范围。
MVC 和 CRUD 对许多信息管理功能非常有效:业务对象比较稳定,一次请求完成一小段局部操作,页面展示已有信息,再提供新增、修改或删除入口。个人博客、课程信息查询,以及社交网络中的许多基础功能,都能从中受益。
但业务一旦跨越很长的时间,情况就变了。电商的一笔订单可能经历下单、锁库存、优惠计算、支付、风控、发货、退款、售后和对账。任何一步失败,都可能影响前后多个环节。教务系统也一样:修满学分之后,还有毕业设计、资产归还、特殊政策与批准记录。把这些代码统一挪进一个叫 Model 的目录,并没有解决本质复杂性。
会计账本是一个特别值得研究的例子。复式记账要求同一笔业务通过相互对应的账户记录出来,借方金额合计等于贷方金额合计。这里的“借”“贷”是记账方向,不能简单理解为日常语言里的借入和借出。例如,一笔借款到账,银行存款增加,同时债务也增加;归还其中一部分本金,存款和债务相应减少。这些变化共同维持“资产 = 负债 + 所有者权益”的关系。复式记账的基本说明。
这套结构提供了可检查的约束,但算得平不等于记得对。把业务归错类、重复录入一笔金额相同的借贷记录,都可能仍然平衡。原始凭证、业务含义和记账动作之间的对应关系,仍然需要核对。
课堂中的记账工具采用“Event Sourcing 与 LLM as a Compiler”的思路:把原始凭证和发生的业务交给语言模型理解,翻译成受约束的记账动作,再由程序按规则执行状态变化。模型在这里像一个编译器前端,把人的材料翻译成系统可以检查和执行的表示。例如,报表_支出_财务费用(CNY(75)) 表达的是一项具体记账动作,CNY 则把金额放进人民币的金额表示里。案例的记账规则表列出了各类动作怎样影响资产负债表、利润表和现金流量表。
这个例子中的银行收支记录包含两笔动作:财务费用支出 75 元,投资收益中的利息收入 22.07 元。执行后,货币资金的净变化是 22.07 - 75 = -52.93 元;利润表上相应记录费用与收益,净利润减少 52.93 元,相关权益和现金流量项目也按该工具的规则得到结果。报表上的许多数字共同变化,但我们不希望让模型逐格填写这些数字,而希望它们来自同一组动作及其计算规则。
工资或个人劳务的结算,也可以用受约束的操作表达。下面是案例中的一个实现片段,它依赖项目内的金额类型、分类表和账本操作,不能脱离这些定义单独运行:
@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})
这里先按业务类别确定应付款项,检查相应余额是否满足结算要求,再同时减少现金和对应的应付余额。代扣款应先通过独立动作记账,然后支付净额。这样,“确认费用”“形成应付”“实际支付”就能够被区分,避免每次付款时又把费用重复算一遍。语言模型负责提出符合材料的动作,账本代码负责这些动作的确定含义和约束;编译出来的动作是否符合真实业务,仍然是需要审查的环节。
记账为什么麻烦?一个“当前状态”,常常同时背着过去、现在和未来。过去签订过合同,现在可能正在等待付款,未来还要交货、结算,也可能发生坏账。如果只盯着当前余额和一个状态字段,很多不同的历史会被压进同一种表示,很多尚未发生的预期又会与已经发生的事实混在一起。
事件溯源(Event Sourcing)提供了一种重新分解问题的方法:保存已经发生的事件,再从事件计算需要的状态。这里的事件是“发生了什么”,例如某笔支付已经完成、某项费用已经确认;它与“希望接下来付款”这样的命令或预期应当区分。当前状态由历史事实逐步处理得到,也可以为了查询效率保存计算结果。Event Sourcing。
在这种架构中,过去是事实,现在是事实经规则计算得到的视图,未来则是预期。如果事情没有按预期发展,就追加新的事实来解释变化。之前记录错了,也可以记录更正、撤销或冲销,让系统保留发生过什么、后来为什么改变的线索。“过去不可更改”的含义,是不靠悄悄抹掉历史来解释今天的状态。
比如,先前预计一笔应收款会收到,后来确认无法收回。这个结果不应使“曾经形成过应收”以及后续处理过程从历史中消失。新的业务事实应按照相应规则改变当前的计算结果。同样,已经支付又发生退款,与从未支付过,虽然可能具有相同的净现金变化,却不是同一段业务历史。
这里并没有消灭复杂性。事件怎样定义、先后怎样处理、更正作用于哪个事实,仍然需要设计。但原来揉在一起的问题开始有了边界:哪些是我们知道发生过的事,哪些是对这些事的解释,哪些是还没有兑现的期待。解释可以检查,计算可以重新执行,预期也可以由后来的事实修正。
重算能力还有两个具体前提。第一,要复现当时的结果,就必须知道当时使用的规则和外部输入,例如哪一版培养方案、某次计算采用的汇率,而不能偷偷换成今天的数据。第二,重放账本可以重新计算余额,却不能把已经完成的付款再执行一次。对外部世界的动作,需要与内部状态重建区分开来。Fowler 对事件溯源的讨论也专门分析了外部查询与外部更新带来的这两类问题。
把同样的想法带回教务系统,就能看见一个熟悉的问题。数据库里可能同时存着成绩记录、已修课程列表、总学分、各类别学分和“是否满足毕业条件”。如果把这些都当作可以各自修改的独立事实,更正一门课的成绩时,就必须记得去修改后面的每一项。遗漏一处,系统里便同时存在几个互相矛盾的“现在”。
其实,其中许多状态本来就是别的信息的结果。选课、成绩与认定记录决定当前通过了哪些课程;通过的课程加上去重、替代和分类规则,决定各类学分;这些结果再结合适用的培养方案,决定课程准出条件是否满足。毕业设计、资产归还等其他要求,则应进入各自明确的判断。课程条件满足,不能悄悄被当成所有毕业条件都已满足。
假设某门必修课的成绩经过复核,从“不通过”更正为“通过”。我们希望系统记录这次更正,重新计算受影响的已通过课程、学分和审核结论,并能解释为什么发生变化。如果某门交换课程只能计入一个类别,或者两门交换课程合抵一门必修课,就应让认定记录和对应规则参与计算,而不是在总学分字段上直接加一个数字了事。
经过批准的特例也要有自己的位置。某个学生获得了课程替代或条件豁免,应当保存批准对象、适用范围和依据,使后续计算可以使用它。否则,一个没有来由的“审核通过”只是在隐藏复杂性,下一次重新审核时,系统仍然不知道为什么要通过。
这是一条显式的数据依赖链:事实与规则作为输入,逐层产生结果。每个结论都可以追问“依据哪些数据,经过哪个计算得到”。发生“反悔”时,不再靠人到处寻找需要手工修改的副本,而是沿依赖关系重新得到派生结果。当然,计算规则仍可能有错误,派生结果的更新也可能暂时滞后;区别在于,我们保留了查证与修复所需的依据。
看到这里,可以对 SQL 提出一个带点挑衅的批评:它没有把跨数据的任意计算关系充分作为一等公民来表达和维护。这也算一个“Billion Dollar Mistake”吧。所谓一等公民,是说这类关系应当成为可以直接声明、组合、检查和追踪的对象,而不只是散落在应用代码里的一些约定。
SQL 当然能够表达查询,数据库也有视图、约束和物化视图;例如 PostgreSQL 的物化视图可以保存查询结果,并通过刷新重新生成。这里批评的焦点,是跨过数据库、应用逻辑、缓存乃至界面的计算依赖:一条基础记录变了,所有依赖它的结果由谁负责更新,怎样判断哪些结果已经失效,又怎样追溯一个结果的来源?PostgreSQL 物化视图。
从这个角度看,MVVM、React、Dataflow 和 Event Sourcing 都在用不同方式补上这类表达能力。Dataflow 把数据之间的计算关系组织为流动和依赖;前端框架让界面从状态派生;事件溯源让当前状态从历史事实派生。它们所处的层次和具体机制各不相同,但都在尝试把“这个结果依赖什么”显式化。把它们放在一起,是一种理解架构的视角,不是在断言它们都因 SQL 的某个缺陷而诞生。
由此得到一个很有力量的设计原则:系统应保存足以重建其余信息的事实依据(source of truth),其他状态尽可能成为计算的结果。 为了性能,可以保存汇总、索引和缓存,但应当清楚它们从哪里来、何时失效,以及怎样重建。一个业务决定如果必须由人作出,就把决定及其依据保存下来;能够推导的东西,则尽量不要再引入一份独立的决定。
复杂性仍然存在,却从“怎样保证许多份状态永远一致”,转移到了“这段计算是否正确”。后一个问题往往更容易管理:给定输入可以重跑,可以写有意义的测试,可以审计计算步骤,可以分析依赖。发现旧规则有错误时,也有机会修正规则,在明确的适用范围和时间边界内重新计算,而不是对着一堆已经失去来源的数字逐个打补丁。
对生成式软件工程而言,这种转移尤其重要。让 AI 面对散落各处的隐含同步要求,它就需要反复猜测哪些地方必须一起改;把事实、规则和依赖表达清楚之后,生成与检查才有了共同的对象。代码越容易生产,我们越有理由认真选择这种表示:让系统中的每一个重要结果,都能回答“我是怎样算出来的”。