生成式软件工程
搜索
开始阅读/第 4 部分 · 工程判断/第 12 章

分层与分工

过去六十年里最耐用的几个设计,用的都是同一招:找到一条稳定的界线,让两边各自变化,互不打扰。

这一页讲什么
  • UNIX 为什么把文本流当成通用接口,管道怎样让工具组合出设计者没想到的用途;
  • 关系数据库怎样把「数据意味着什么」和「数据怎么存」分开;
  • Internet 分层如何在持续变化中保持协作;
  • MVC、MVVM、React 各自把界面里的什么问题放到了显眼位置;
  • 复式记账、领域驱动设计与命令查询分离,以及把数据依赖链显式化的价值。

一、UNIX:先定组合规则,再让人造用途

UNIX 哲学常被概括成三句话:每个程序做好一件事;让程序能够协作;让程序处理文本流,因为文本是一种通用接口。

前两条很容易点头同意,真正让它们生效的是第三条。没有共同接口,「各自做好一件事」的工具仍然是一堆连不起来的孤岛。文本流给人、程序、程序之间提供了一种共同的接口:一个工具的输出,既能直接显示给人看,也能存进文件,还能交给下一个工具处理;流水线出了问题,可以在中间截一段看看前一步到底产出了什么。

不过「都是文本」还不足以保证协作,还得有一堆约定:命令行参数怎么区分选项与操作数,数据从哪进、诊断信息往哪出,成功与失败怎么报告。POSIX 的工具语法约定区分短选项、选项参数与操作数,规定用 -- 结束选项解析;标准输入、标准输出、标准错误提供三条不同的通道,Shell 的管道默认把前一个程序的标准输出接到后一个程序的标准输入。

长期的实践还攒下一批很实用的习惯:简单的记录一行一条,复杂结构选 TSV、JSON 这类明确格式;供机器处理的输出不要混进进度条、颜色控制符和「操作成功!」;数据与错误说明分开;退出状态单独表达执行结果。分隔符本身也是接口的一部分——文件名可以包含换行,那么「按行切开」就还原不出准确的文件列表。

现代工具把这种组合能力又扩了一圈。jq 从 JSON 里提出需要的字段,fzf 让人从候选里交互选择、再把选中的结果输出,于是一条流水线就能完成「读书目、提书名、让人挑一本」,人的判断被安插进程序之间。面向 X11 的 xdotool 把窗口查找、键盘输入、鼠标操作变成命令,把窗口候选交给选择器、再激活选中的那个,就组合出一个自己的窗口切换工具。这些工具的作者不必提前设计好每一种书目管理或窗口管理需求。

设计者提供组合的规则,使用者便能创造设计者没有想到的用途。UNIX 留给后来者的,除了若干好用的命令,还有一片可以继续生长的解空间。 —— 课程材料原句
生活类比 食堂的餐盘之所以好用,因为它的尺寸和窗口的托盘架是配套的:哪个窗口打的菜都能放上去,端到哪张桌子都稳。假如每个窗口都发明一种形状的盘子,组合出一顿完整的饭就成了体力活。

二、关系数据库:数据意味着什么,与数据怎么存

关系数据库提供了另一种分离。Codd 在 1970 年的论文里想保护用户:不要因为数据的表示方式、数据规模、访问需求发生变化,就被迫重写使用数据的方式。关键在于把「数据在逻辑上意味着什么」与「数据在机器里怎样组织」分开。

以图书馆为例,现实里发生的事实是「某位读者借走了某册书」。系统可以用读者、馆藏、借阅关系来表达这件事:既可以问某位读者借了哪些书,也可以问某册书被谁借走,两个问题建立在同一组逻辑关系上。至于底层用哪种索引、记录放在哪个数据页,属于另一个层次的事,换存储引擎时上面那组关系不必跟着改。

在这个抽象之上,SQL 用声明式的方式描述「要得到什么结果」,查询优化器负责挑具体执行方案,事务机制把需要作为整体处理的读写组织起来,CRUD 成为大量业务系统的共同骨架。数据库的设计者没有提前列出全世界的图书馆报表和教务统计,却提供了表达关系、约束和查询的规则;用户可以在上面提新问题,底层也可以不断改进存储与执行方式。

层次回答的问题变化时影响谁
逻辑层有哪些实体、它们之间什么关系、有什么约束使用数据的人与程序
查询层我要得到什么结果,不关心怎样算声明写一次,执行方案由优化器决定
存储层数据放在哪、用什么索引、怎么分页换引擎时上层通常不必改

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

Internet 把类似的想法带到了全球通信的尺度。以手机通过 TCP 访问一个 HTTPS 服务为例:应用提出请求,安全与传输协议处理相应的数据,IP 层负责组织跨网络传递,链路层负责相邻设备之间的传送。

这样分层的效果是:应用不必知道途中每一段链路用的是什么硬件,也不必因为某一次物理路径变化就重新设计业务逻辑。1996 年的一份 RFC 在开篇就强调,信息技术一直在变化,因此不能把架构理解成一张永远不变的施工图;文中拿城市作类比——街道和建筑不断改建,城市仍然继续运转。

从用途上看,变化确实一直在发生:远程登录从 Telnet 逐渐转向 SSH,文件传输和信息访问大量转向 HTTPS,电子邮件延续了 SMTP、IMAP 这套协议体系。应用在变、安全要求在变,承载它们的基础通信能力仍然可以继续服务。

关键结论 UNIX、关系数据库和 Internet 的价值,除了它们各自解决了多大的问题,还在于它们让多少别的问题变得可以解决。它们都把一条界线放在「变化最快的部分」与「变化最慢的部分」之间,然后规定两边怎样打招呼。

四、MVC:变化有了归属

有了操作系统、数据库和网络,最直接的 Web 应用思路是:一个页面对应一段请求处理,背后执行一组数据库操作。学生点选课,服务器读课程信息、检查条件、写入选课记录、返回页面。

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

页面生成还有一个很实际的问题:在 Java 代码里一行行输出 HTML,页面结构很快就被字符串拼接淹没了。JSP 把这个关系翻了过来,允许在 HTML 模板里嵌入服务器端代码,容器再把页面翻译成 Servlet 执行。

这在当时是很直接、很实用的改进。可「能直接写进去」也意味着业务规则很容易被顺手塞进页面:学生选课页检查一次容量,管理员补选页再写一次,批量导入又补一份。日后修改容量规则,就得把所有入口翻一遍;漏掉一个,页面看起来都正常,业务已经开始自相矛盾。

MVC(Model–View–Controller)提供的是关注点分离的思路,可以用三个问题理解它:系统知道什么?怎样展示这些知识?怎样接收和解释用户的操作?Model 封装数据、状态与业务规则,View 负责展示,Controller 接收输入并协调后续处理。在教务系统里,Model 包括课程、选课记录,以及容量和时间冲突这些规则,它的内容远不止数据库表换个名字。

这三个问题一分开,「变化各有归属」这件事就变得具体了。业务规则改了只动 Model,展示方式改了只动 View,入口的授权规则改了只动 Controller,谁也不必把整块逻辑重写一遍。

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

五、MVVM 与 React:把「界面依赖状态」摊到明面上

只有业务数据还不够,因为同一份课程列表,搜索框里的关键词不同,显示就不同;展开哪个菜单、选中哪一行,也会改变界面。这些信息通常不进业务数据库,却仍然是程序必须管理的界面状态(ViewState)。

把界面写成一个概念上的关系,会清楚很多:

View = render(Model, ViewState)

MVVM(Model–View–ViewModel)把面向展示的状态和行为进一步组织成 ViewModel:原始课程数据是一部分状态,筛选词 query 是另一部分,当前可见课程 visibleCourses 由这两者共同决定。你描述这层依赖关系,框架负责维护展示结果。

Vue 用响应式状态保存筛选词,用 computed 表达「可见课程由课程列表和筛选词计算而来」,计算属性会追踪它读到的依赖,在相关依赖变化时更新;v-model 把两条连接包在一起:状态决定输入框显示的值,用户输入反过来更新状态。所谓双向绑定,落到实现上就是明确的值传递加上输入事件处理。

React 用另一种方式表达:组件根据当前的属性与状态描述界面,用户事件通过状态更新函数请求下一次渲染。可以用 useState 保存 query,渲染时从课程列表和 query 算出可见课程;既然列表能从现有数据算出来,就没必要再给它安排一份独立的 State。React 的状态还可以理解成每次渲染拿到的一份快照:调用 setQuery 请求更新,当前这次渲染已经拿到的变量不会立刻变。

两种框架的编程体验差别明显,共同点是把「界面应该怎样依赖状态」放到了更显眼的位置:程序沿着已经表达的依赖算出新界面,不必靠程序员记住一串零散的同步操作。

flowchart LR
  M["业务数据 课程列表"] --> V["可见课程"]
  Q["界面状态 筛选词"] --> V
  V --> R["渲染结果 界面"]
  R -->|"用户输入"| Q

六、复式记账:过去、现在、未来各自归位

MVC 和 CRUD 对大量信息管理功能非常有效。可业务一旦跨越很长时间,情况就变了:一笔订单可能经历下单、锁库存、优惠计算、支付、风控、发货、退款、售后、对账;教务系统也一样,修满学分之后还有毕业设计、资产归还、特殊政策与批准记录。把这些代码统一挪进一个叫 Model 的目录,本质复杂性一点没少。

会计账本特别值得研究。复式记账要求同一笔业务通过相互对应的账户记录出来,借方合计等于贷方合计。这里的「借」「贷」是记账方向,不能按日常语言里的借入借出去理解:一笔借款到账,银行存款增加,同时债务也增加;归还一部分本金,存款和债务一起减少——这些变化共同维持「资产 = 负债 + 所有者权益」的关系。

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

有一类记账工具采用「事件溯源加语言模型当编译器」的思路:把原始凭证和发生的业务交给语言模型理解,翻译成受约束的记账动作,再由程序按规则执行状态变化。模型在这里像编译器前端,把人的材料翻译成系统可以检查、可以执行的表示。例如 报表_支出_财务费用(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})

这段代码先按业务类别确定应付款项,检查相应余额是否满足结算要求,再同时减少现金和对应的应付余额;代扣款要先通过独立动作记账,然后支付净额。这样「确认费用」「形成应付」「实际支付」三件事被区分开,每次付款时就不会把费用重复算一遍。语言模型负责提出符合材料的动作,账本代码负责这些动作的确定含义与约束;翻译出来的动作是否符合真实业务,仍然需要人审查。

记账为什么麻烦?一个「当前状态」常常同时背着过去、现在和未来:过去签过合同,现在可能正在等待付款,未来还要交货、结算,也可能发生坏账。只盯着当前余额和一个状态字段,很多不同的历史会被压进同一种表示,很多尚未发生的预期会和已经发生的事实混在一起。

事件溯源提供了另一种分解方式:保存已经发生的事件,再从事件算出需要的状态。事件是「发生了什么」,例如某笔支付已经完成;它跟「希望接下来付款」这种命令或预期必须区分开。这样一来,过去是事实,现在是从事实经规则算出的视图,未来是预期。事情没按预期发展,就追加新的事实来解释变化;之前记错了,就记录更正、撤销或冲销,让系统保留「发生过什么、后来为什么改」的线索。

生活类比 家里的账目只需要保存每一张原始发票和转账记录,月度汇总、分类统计、年度报表全部从它们算出来。汇总表丢了可以重算,改了发票才需要认真对待;反过来,如果汇总表和发票各改各的,很快就谁也说不清哪份可信。

七、DDD 与 CQRS:让下一次需求变化有地方可去

1. 限界上下文:同一个词,边界内必须有同一个意思

领域驱动设计(Domain-Driven Design,缩写 DDD)里的领域,就是软件试图处理的那个业务世界。开发者与业务人员共同建立模型,让「选课」「学分认定」「毕业审批」这些概念进入软件的结构和语言。在明确边界之内,同一个词应当有一致的含义,这就是统一语言;可庞大的系统不必强迫所有参与者共享一份无所不包的模型。

DDD 用限界上下文(Bounded Context)明确模型适用的范围,再描述不同范围之间怎样协作。选课业务关心学生有没有资格、课程还有没有名额;财务业务关心应收和到账;毕业审核关心认定学分与适用方案。它们都在谈「学生」,需要维护的状态和规则并不相同。

边界之间传递信息时也要说清含义。「课程成绩已经通过」可以成为学分认定的输入,能不能计入某个专业的毕业学分,要由学分认定的规则决定。直接把两个概念压成同一个 passed 字段,以后就很难解释「通过了这门课,却仍然不满足这项毕业要求」的情况。

2. 命令查询分离:接受操作与提供查询分开

命令查询职责分离(Command Query Responsibility Segregation,缩写 CQRS)把负责接受业务操作的写模型,与负责提供查询结果的读模型分别设计。

命令表达一个业务意图,例如「申请选课」;写模型检查先修资格、课程容量、时间冲突,再接受或拒绝。查询则服务于不同的使用者:学生要看课表,教师要看点名册,教务员要看开课统计,这些结果没必要被迫使用同一种展示结构。写模型集中维护业务约束,读模型可以为查询准备专门的数据结构,适当冗余或预先计算。CQRS 的重点在两类模型的职责划分,完全可以在同一个进程、同一个数据库里实现;要不要再上事件溯源,是另一个决定。

生活类比 餐厅里,收银台记录每一单,叫号大屏只展示「哪些号可以取餐」。大屏晚几秒刷新很正常,收银台必须立刻告诉你这单接没接——各司其职,谁也不用迁就谁的格式。

读模型如果是异步更新的,就会出现短暂的间隙:选课已经确认,课表还没刷新。界面要能解释这个状态,系统要明确「选课成功」究竟由哪个环节确认,不能让用户因为查询视图还没显示就重复提交。还有一个更硬的约束:只剩最后一个名额,两位学生同时申请,不能让两人都占位成功。检查容量与确认占位必须作为一个不会被其他申请穿插破坏的操作完成;用事件存储时同样需要并发控制,例如追加前检查所读取的事件流版本,发现变了就重新读取和判断。于是「哪些部分必须立即一致、哪些查询允许稍后更新」成为可以拿出来讨论的决定。

八、把数据依赖链写出来

教务系统的数据库里可能同时存着成绩记录、已修课程列表、总学分、各类别学分,以及「是否满足毕业条件」。把这些都当成可以各自修改的独立事实,更正一门课的成绩时,就得记得去改后面每一项;漏一处,系统里便同时存在几个互相矛盾的「现在」。

可其中许多状态本来就是别的信息算出来的。把它们的关系画出来,是一条清晰的数据依赖链:

flowchart TB
  A["选课记录 与 成绩记录"] --> B["已通过课程"]
  B --> C["各类别学分"]
  C --> D["课程准出条件"]
  D --> E["毕业审核结论"]
  P["培养方案版本"] -.->|"决定规则"| C
  F["人工豁免与替代审批"] -.->|"参与计算"| E

这条链上,事实与规则是输入,逐层算出结果,每个结论都能追问「依据哪些数据、经过哪个计算」。假设某门必修课的成绩复核后从不通过改成通过,我们希望系统记录这次更正,重新计算受影响的已通过课程、学分和审核结论,并能解释为什么发生变化。经过批准的特例也该有自己的位置:学生获得课程替代或条件豁免,就要保存批准对象、适用范围和依据,让后续计算能用上它;否则一个没有来由的「审核通过」只是在把复杂性藏起来。

发生「反悔」时,人不必到处寻找需要手工修改的副本,沿依赖关系重新计算就得到派生结果。计算规则仍可能出错,派生结果的更新也可能暂时滞后;区别在于,我们保留了查证和修复所需的依据。

顺着这个视角,可以对 SQL 提一个带点挑衅的批评:它没有把「跨数据的任意计算关系」充分当成一等公民来表达和维护。所谓一等公民,是说这类关系应当成为可以直接声明、组合、检查、追踪的对象。SQL 当然能表达查询,数据库也有视图、约束和物化视图;批评的焦点在于跨过数据库、应用逻辑、缓存乃至界面的计算依赖——一条基础记录变了,所有依赖它的结果由谁负责更新,怎么判断哪些结果已经失效,又怎么追溯一个结果的来源。

从这个角度看,MVVM、React、事件溯源都在用不同方式补这类表达能力:前端框架让界面从状态派生,事件溯源让当前状态从历史事实派生。它们所处的层次不同,放在一起比较是一种理解架构的视角。

关键结论 系统应当保存足以重建其余信息的事实依据,其他状态尽可能作为计算的结果。为了性能可以保存汇总、索引和缓存,但要清楚它们从哪来、何时失效、怎样重建。一个业务决定必须由人作出,就把决定连同依据一起保存;能够推导的东西,尽量不要再引入一份独立的决定。

复杂性依然存在,只是从「怎样保证许多份状态永远一致」转移到了「这段计算是否正确」。后一个问题往往更好管:给定输入可以重跑,可以写有意义的测试,可以审计计算步骤,可以分析依赖;发现旧规则有错,也有机会修正规则并在一段明确的适用范围内重新计算。对生成式软件工程来说,这个转移尤其重要:让 AI 面对散落各处的隐含同步要求,它得反复猜测哪些地方必须一起改;把事实、规则和依赖表达清楚之后,生成与检查才有了共同的对象。

代码越容易生产,我们越有理由认真挑选这种表示——让系统里每一个重要结果,都能回答一句「我是怎样算出来的」。

现在就做 三件事,三十分钟:
① 在终端里体验一次管道组合:把一条命令的输出交给另一条命令处理(例如先列出目录内容,再筛出含某个关键词的行),中间用 tee 存一份,看看前一步到底产出了什么;
② 用 Vue 的 computed 或 React 的 useState 做一个课程筛选:输入筛选词,可见课程列表跟着变,然后写下你描述的那条依赖关系;
③ 拿一张自己真实的外卖付款记录,写出它在「现金」与「费用」两个账户上各自的记账方向,核对借方合计是否等于贷方合计。

自测

1. UNIX 哲学三条里,为什么说第三条(处理文本流)是让前两条生效的关键?
没有共同接口,「各自做好一件事」的工具仍然连不起来。文本流让人、程序、程序之间使用同一种接口:输出可以直接显示、可以存文件、可以交给下一个工具;再配合 POSIX 对选项与操作数、标准输入输出、退出状态的约定,组合和检查才成为日常操作。设计者提供组合规则,使用者就能造出设计者没想到的用途。
2. 关系数据库把哪两件事分开了?分开之后带来什么好处?
把「数据在逻辑上意味着什么」与「数据在机器里怎样组织」分开了。以图书馆为例,读者、馆藏、借阅这组逻辑关系可以同时支持「某读者借了哪些书」和「某册书被谁借走」两类问题;底层用哪种索引、记录放在哪一页属于另一个层次。好处是换存储引擎、换访问方式时,使用数据的那一层不必跟着重写,SQL 还可以用声明式方式描述要什么结果,把执行方案交给优化器。
3. MVC 用哪三个问题理解?容量规则变化时应该改哪一部分?
三个问题是:系统知道什么(Model)、怎样展示这些知识(View)、怎样接收和解释用户的操作(Controller)。容量规则属于业务规则,封装在 Model 里;变化时只动 Model,学生选课页、管理员补选、批量导入都调用同一份规则,页面不必各自维护一次容量检查。这正是「变化有了归属」的含义。
4. Vue 的 computed 与 React 的状态快照,共同把什么问题放到了显眼位置?
共同把「界面应该怎样依赖状态」放到显眼位置。Vue 用响应式依赖追踪描述「可见课程由课程列表和筛选词计算而来」,依赖变化时自动重算;React 用状态快照与显式更新函数,让「某一次界面由哪些输入算出来」更容易说清楚。两者都让程序沿着已经表达的依赖算出新界面,减少靠程序员记住的零散同步动作。
5. 复式记账为什么「算得平不等于记得对」?记账工具里语言模型扮演什么角色?
借贷平衡只是记账内部的约束,把业务归错类、重复录入一笔金额相同的借贷记录,账面依然平衡,原始凭证与记账动作的对应关系仍需核对。语言模型像编译器前端:把原始凭证和业务材料翻译成受约束的记账动作,账本代码负责动作的确定含义和约束;翻译结果是否符合真实业务,还要人审查。
6. DDD 的限界上下文解决什么问题?「通过了课程」与「满足毕业学分」为什么要分成两个概念?
限界上下文明确一个模型适用的范围,并在范围内保持同一个词的一致含义(统一语言),同时允许不同范围各自维护自己需要的状态与规则:选课关心资格与名额,财务关心应收到账,毕业审核关心认定学分与适用方案。把两者压成同一个 passed 字段,就解释不了「通过了这门课,却仍不满足这项毕业要求」的情况,因为一个讲的是成绩事实,另一个讲的是培养方案规则下的认定结论。
7. CQRS 之下,「选课已确认但课表还没刷新」该怎么处理?最后一个名额被两人同时申请又该怎么办?
界面要能解释这个短暂状态,系统要明确「选课成功」由哪个环节确认,避免用户因为查询视图未刷新而重复提交。最后一个名额的检查与占位必须作为一个不会被打断的操作完成;用事件存储时仍需并发控制,例如追加前先检查读到的事件流版本,发现已变化就重新读取和判断,保证不会两人都占位成功。
8. 把数据依赖链显式化,把哪一类问题换成了哪一类问题?
换掉的是「怎样保证许多份状态永远一致」,换成的是「这段计算是否正确」。例如成绩更正后,人不必去找每一处副本手工修改,只消沿依赖关系重算已通过课程、学分与审核结论;汇总、索引、缓存仍然可以保留,但要清楚它们从哪来、何时失效、怎样重建。后一类问题可以重跑、可以测试、可以审计,发现规则错误时也能在明确范围内重新计算。