6 需求和架构(一):控制复杂性
需求说不清且持续变化,架构的任务是控制复杂性:易变的决策封装于模块之内,以稳定的接口与中间表示承接变化。
6 需求和架构(一):控制复杂性
我的理解
- 需求不明晰且持续变化,技术债从实现之初便无法避免。架构设计的任务,是在业务复杂度增长时,控制代码的复杂度。
- 高内聚、低耦合:易变的决策封装于模块之内,模块之间以稳定的接口协作,使一次变化局限于一处。两方难以直接解耦时,在其间引入中间层,使两侧各自变化而互不牵连,如 GMP 中的 P、命令行解析中的 parsing state。
- 设计的方法,是以 first principle 拆解需求,识别其中易变之处,即跳出问题看问题。
- 架构能力来自对优秀设计、其他领域与使用者的积累,再将其压缩为可推导的模式。
- DDD、event sourcing、CQRS 是这一原则在业务系统中的成熟模式。了解它们,设计时多一种参照,指导模型时也有准确的术语。
需求的复杂性
第 5 讲的结论是:实现变得廉价,intent 与验收的价值随之上升。这一讲继续追问:需求说不清且持续变化时,系统应如何组织,才能承受下一次变化。
需求的困难不在单条规则,而在规则的交汇。以教务系统为例,转专业、交换课程抵学分、缓考、重修,单看都不复杂;落在同一位学生身上时,便需要有人作出业务决定。系统无法表达的特例,最终只能依靠「视为满足」的兜底入口,或直接修改数据库。
确定需求还须共情使用者。工程师关注数据库与性能,临近退休的教务老师在意的却是字号与按钮间距。这类 intent 不在教科书与题目中,只能与人打交道时体察。
AI 使 MVP 极为廉价,试用能暴露用户未曾说出的需求。但原型只覆盖正常路径,真正的考验在需求变化之后:「一门交换课程替代一门本校课程」改为「两门合抵一门」,影响的是课程对应、学分计算、毕业审核与成绩单,变化沿依赖关系穿透整个系统。
需求冲突、历史包袱与持续变化仍须由人处理。Brooks 在《No Silver Bullet》中区分两类复杂性:本质复杂性来自问题本身,无法消除,只能换一种表示;偶然复杂性来自工具与写法,可以消除。业务规则的交汇属于前者,架构的任务是不在其上叠加后者。问题由此转向:复杂性应当安放在何处。
架构与表示
架构即控制复杂性,与规模无关。编程语言的构件同样服务于此:函数表达边界,类型约束表示,作用域限制可见范围,调用栈保存未完成的工作。
讲师用不同规模的例子说明:
| 规模 | 例子 | 复杂性的安放 |
|---|---|---|
| 十行 | getopt | 将杂乱的 argv 解析为 parsing state,业务代码只读取这份结构 |
| 十行 | 非递归汉诺塔 | 同一问题三种表示:数学规律、任务栈、模拟函数调用的状态机 |
| 两千行 | Excel 生成二维码 | 识别为编译问题,引入中间表示;Python 后端用于验证,Excel 后端用于交付 |
三者遵循同一模式:在易变的两端之间引入一层稳定的中间表示,使每类变化各有归属。更换 Excel 版本只改算子的翻译规则,性能优化只在中间表示上改写。汉诺塔的第三种表示不依赖题目的规律:理解函数调用的小步语义,即压入栈帧、弹出栈帧、记录执行位置,任何递归都能机械地改写为循环。
这一模式的原理,==是 Parnas 1972 年提出的信息隐藏:模块不按处理步骤划分,而按易变的设计决策划分,每个模块隐藏一项决策,决策改变时只需修改该模块。高内聚、低耦合即其概括。中间层并非越多越好:每增加一层,都应说明它让哪种变化更容易,又带来什么维护责任==。
架构能力:积累与压缩
架构能力来自所学的一切,前提是所学足够优雅,且促使人思考。积累有三个来源:
- 优秀的设计:读到好的代码与项目,追问其为何如此,而非只记写法。
- 其他领域:代码之外有客户的规律,客户之外有社会的规律。表示方式常取自别处,如二维码生成器取自编译,event sourcing 取自账本。
- 使用者:架构取决于对需求的掌握程度,而需求来自人。只有与教科书和题目之外的人打交道,才能理解他们如何看待系统。
积累之后须加以==压缩==:把不同的例子归为同一模式,如 getopt 与二维码生成器本是同一种设计。压缩之后,面对新问题,便能从 first principle 推导应当如何。
CRUD 的局限
以上例子从十行到两千行。到了五十万行的业务系统,CRUD 本身也是一种表示,问题在于它压缩掉了什么。
CRUD 只保存当前状态,适合简单的数据维护。其局限在于把结论当作事实存储:can_graduate = true 将成绩、培养方案版本、审批与豁免压缩为一个值,既无法解释其来源,也无从判断上游变化会影响哪些结论。
院系更名同样如此:沿用旧 ID,历史被改写;新建 ID,关联随之断裂。须区分组织身份、组织属性与某一时刻的隶属关系。软删除与审计表亦不能根治:前者仍是状态;后者记录字段变化而非业务含义,且当前状态仍是唯一权威。
业务系统的架构模式
针对 CRUD 的局限,软件工程积累了几种模式:event sourcing 保存历史,CQRS 分离读写,DDD 划定业务边界,绞杀者模式改造旧系统。它们相互独立,可按问题分别采用。
Event sourcing:以事实为准
讲师从持久化数据结构推出 event sourcing:状态、日志、增量是同一历史的三种视图,但只能由日志推出状态,反之不成立。
- 事件是已被接受的事实,只追加、不修改;录入有误时,追加更正事件。
- 状态由重放事件得到;投影与快照是可重建的缓存。事件表是唯一的事实来源,审计表则依附于状态表。
- 追加事件仍需并发控制:按业务对象维护序号,追加前检查版本,冲突时重新读取再判断。
- 业务生效时间与系统得知时间分别记录,过去与现在均可查询。
- 重放时以模拟的外部环境替代真实调用,避免重复发送通知或扣费。
- 规则与事件格式各自演化,须分别管理版本。按新规则重算,只得到「今日如何判定」,不改写已作出的决定。
CQRS
CQRS(Command Query Responsibility Segregation)将接受命令的写模型与提供查询的读模型分开。写模型维护业务约束,如选课时检查先修、容量与时间冲突;读模型按使用者组织数据,学生看课表,教师看点名册,可适当冗余。读模型异步更新时会短暂滞后;抢占最后一个名额这类强约束,须在写模型中原子完成。CQRS 与 event sourcing 相互独立。
DDD
DDD(Domain-Driven Design)以业务概念而非数据库表组织模型,使选课、学分认定、毕业审批进入代码的结构与语言。同一术语在一个限界上下文内含义一致;不同上下文可以各有模型,选课关心名额,财务关心到账。
上下文之间传递信息时,由反腐败层负责翻译,避免一方的模型渗入另一方。「课程已通过」是学分认定的输入,能否计入毕业学分由学分认定的规则决定;两者压成同一个 passed 字段,便无法解释「通过了课程,却不满足毕业要求」。
绞杀者模式
旧系统无法推倒重写。讲师建议从一条业务链逐步改进:先整理毕业审核的事实来源、规则版本与审批流程,新旧审核并行计算,解释差异,再逐步切换权威写入入口。旧系统提供基线快照,须注明时间与来源;已丢失的历史,不能伪装成亲眼记录的事件。
应对需求变化
回到开头的问题:需求持续变化时,系统如何承受。一次毕业审核除结论外,还记录所用输入的版本、规则版本与审批依据;成绩更正时,便能沿依赖找到受影响的审核,已发布的决定进入复核流程。培养方案、课程替代、组织身份映射是各自易变的决策,各有负责的模块与接口;起初可同处一个程序,需要独立发布时再拆为服务。追溯与重算有成本,宜用于确需解释依据的业务,简单的信息维护仍可采用 CRUD。
讲师认为,这些概念并非前提,从 first principle 出发同样可以推出正确的架构。代码越容易生成,越值得在少数影响深远的决定上投入时间。