7 需求和架构(二):设计解空间
好的架构固定协作规则、保留局部选择,为开放的需求设计一片解空间;业务系统保存事实依据,其余状态由计算得到。
7 需求和架构(二):设计解空间
我的理解
- 架构设计的任务:分解本质复杂性,减少附带复杂性。本质复杂性(essential complexity)来自问题领域本身,附带复杂性(accidental complexity)来自实现方式。
- 设计、理解架构有三种思维辅助:枚举、归纳和抽象。枚举帮助检查有限的分支,归纳帮助理解循环与递归,抽象让我们使用一个操作时只依赖它承诺的效果,不必每次展开内部过程。
- 好的架构不是只选出一个解,而是为开放的需求空间设计对应的解空间。
- 从状态一致性转向计算正确性:CRUD 把结论当事实存,丢掉了产生结论的推导过程。历史被修正时,无法沿依赖找到受影响的结论,只能被动维护多份状态的一致。保留事实和规则、让状态由计算得到,才能把「状态是否一致」变成「计算是否正确」。
需求与架构:分别收缩两段空间
第 6 讲讨论复杂性安放在何处。这一讲继续追问:好的架构替我们做了什么;未来尚未到来时,能设计什么。
Intent 到 spec 再到 impl,并非唯一的翻译路径。「让学生方便地选课」可对应先到先得、志愿排序或抽签;选定规约后,数据结构与模块划分仍有多种选择。两项工作分别收缩这些空间:
- 需求工程直面本质复杂性,收缩 intent 空间。需求越完备,歧义越少,但每一条都是须持续核对的承诺,在今天体现为 token 开销与模型出错的机会。
- 架构设计分解本质复杂性、减少附带复杂性,收缩 spec 与 impl 空间。判断标准:需求改一处,系统要跟着改几处。
讲师以登录为例:密码保护、确认密码、找回密码、短信或邮件,一个常见需求便有无穷细节;外包给微信登录,又要付费并注册公司实体。需求阶段因此兼需深度优先与广度优先:前者用 MVP 验证核心功能,后者覆盖登录方式、界面布局等分散的决策。两者的平衡没有捷径,只能多看多做。
尊重自己的「小脑袋」
Dijkstra 在《Structured Programming》(1972)中承认自己的脑袋很小,应尊重这一限制。讲师的说法是:人也是一个参数有限的大模型。能追踪十个状态,不意味着能用同样的办法追踪一万个。
- 三种思维辅助:枚举检查有限分支,归纳理解循环与递归,抽象使调用者只依赖操作承诺的效果。
- 珍珠项链:程序是一串抽象层,每颗珍珠承载一项设计决策。决策的顺序同样是设计:过早把图像解释为「行」,中间各层都须理解行;推迟这一表示决策,更多层便能独立于它。
- 类与协程:SIMULA 的类把数据与操作合为一个概念;协程解决控制流的耦合。两个下棋程序互相对弈,强令一方成为另一方的子程序,它须自行保存进度;作为协程,双方各自保留执行位置,交替恢复。
- 概念层次:TWLIST 提供链表,MINISIM 在其上提供模拟时间、挂起与唤醒,车间模型只需写「请求机器,加工,释放」。每上一层,词汇更贴近问题,细节暂时可忘。
讲师用动态规划说明抽象的用处。两三百行的 DP 难以调试,因为思考时工作在状态空间这层抽象上,写代码时却把它丢了。把状态的含义放回来,打印状态与转移,调通后再改写为高效形式。
讲师也建议用 AI 读一手经典,但提问须落到原文:这一层引入了什么概念,调用者因此不必关心什么,哪些操作会破坏底层假设,并要求给出页码以便核对。
好的架构:设计解空间
Steele 在《Growing a Language》(OOPSLA 1998)中全程只用单音节词,多音节词须先定义,让听众亲历语言的生长。其背后是语言设计的两难:极小的语言不可用;一次设计完满的大语言交付太慢,会在成功前被小而丑的语言取代。出路是生长:设计一种模式,固定协作规则,留出以后可填的空位,使扩展成为一等公民且可以组合。他引用建筑师 Alexander 的俄勒冈实验:预先定死总体规划,会夺走使用者塑造未来的机会。
本讲的核心观点由此而来:==好的架构不是选出一个解,而是为开放的需求空间设计对应的解空间==。不必猜中所有需求,只需让一类未来变化有合理的落脚点。讲师顺带批评 OJ:代码用后即扔,无需扩展,久而久之便不再思考可生长性。
通用系统:固定规则,开放用途
| 系统 | 固定的规则 | 开放的空间 |
|---|---|---|
| UNIX | 文本流;POSIX 约定(选项语法、--、stdin/stdout/stderr、退出状态) | jq、fzf、xdotool 组合出设计者未曾想到的用途 |
| 关系数据库 | 数据的逻辑关系与存储分离(Codd 1970) | 用户提出新查询,底层独立改进索引与执行计划 |
| Internet | 分层,只做好数据传输(RFC 1958) | 应用不断更替:Telnet → SSH,FTP → HTTPS |
- UNIX 三原则的关键在第三条。没有共同接口,「各做好一件事」的工具仍是孤岛。分隔符、机器输出不混入进度条等琐碎约定,保护的是组合的可靠性。讲师认为,使用 UNIX 的那一刻起每个人都是程序员;GUI 的「打开文件」对话框不合意,用户却别无他法。
- 一个系统能够生长,往往会长出一门语言:shell 是语言,SQL 也是。
- RFC 1958 以城市作比:街道与建筑不断改建,城市照常运转。唯一不变的原则是变化本身。
业务系统:让变化各有归属
在操作系统、数据库与网络之上,最直接的业务架构是一个页面对应一个 SQL 事务。它催生了一波应用,但业务规则会散落到每个入口:选课页、补选页、批量导入各写一遍容量检查。JSP 让页面结构清晰,也让规则更容易被顺手写进页面。
MVC 以关注点分离回应这一问题。Model 是数据、状态与业务规则,不只是数据库表;课表从列表改为周历只改 View,选课限制变化只改 Model。
界面状态也是状态:View = render(Model, ViewState)。筛选词、展开的菜单不进业务数据库,仍值得建模;讲师批评教务系统把列表过滤做成数据库查询。Vue 的 computed 与 React 的状态快照遵循同一原则:只保存必要状态,派生数据由计算得到。讲师的概括是:关注状态时,不必同时关注状态如何变成 DOM。
当前状态:压缩了过去与未来
MVC 与 CRUD 适合业务对象稳定、请求只做局部操作的系统。长流程与强规则则不然:订单从下单、支付、风控到售后、对账,毕业从学分、毕业设计到资产归还。用一个 status 字段表示,再经特批覆盖风控之类的操作,便会出现「既能毕业又不能毕业」的数据,且常常是静默损坏。
讲师以记账为例。复式记账让每笔业务同时记入金额相等、方向相反的账户,使会计等式可以检查;但平衡不等于正确,归错类仍可能平衡。讲师不懂会计,做法是 event sourcing 加 LLM as a Compiler:
- 持久、只追加的流水记录发生了什么,原始凭证即事件。
- LLM 把每条流水编译为受约束的记账动作,如
报表_支出_财务费用(CNY(75))。 - 程序按规则执行动作,reduce 出资产负债表、利润表与现金流量表。
模型译错,人工审核改正即可,代价很小。报表数字来自同一组动作,而非由模型逐格填写。
由此得到 event sourcing 的另一种表述:过去是事实,现在是视图,未来是预期;世界未按预期发展时,追加新的事实。领导特批某位同学毕业,也是追加一条记录。重放还能在流程中途推演:风控成功会怎样,失败又会怎样。
计算正确性:取代状态一致性
教务系统中,成绩、已修课程、学分、能否毕业常被存为可各自修改的字段。成绩复核由不通过改为通过,后续每一项都须记得同步,遗漏一处,便有几个互相矛盾的「现在」。其实它们构成一条依赖链:选课与成绩决定通过的课程,加上替代与分类规则得到学分,再结合培养方案判断课程准出条件;毕业设计、资产归还另有各自的判断。经批准的特例须保存对象、范围与依据,否则无来由的「审核通过」只是在隐藏复杂性。
讲师借 Hoare 自嘲空指针的说法,批评 SQL 的「Billion Dollar Mistake」:跨数据的任意计算关系不是一等公民。数据库有视图与物化视图,但跨越数据库、应用、缓存与界面的依赖由谁维护,仍无答案。MVVM、React、Dataflow、event sourcing 处于不同层次,都在把派生状态与依赖显式化。
收束为一条原则:==系统保存足以重建其余信息的事实依据(source of truth),其他状态尽可能由计算得到==。缓存与汇总可以保存,但须清楚其来源、失效时机与重建方式。复杂性并未消失,而是从「多份状态如何保持一致」转为「这段计算是否正确」;后者可以重跑、测试、审计与分析依赖。前提有二:复现当时的结果,须用当时的规则与外部输入,如当年的培养方案、当日的汇率;重放可以重算余额,不能再打一次款。
对生成式软件工程而言,AI 面对散落的隐含同步要求,只能反复猜测哪些地方须一起改;事实、规则与依赖表达清楚,生成与检查才有共同的对象。