
一个系统可以完整实现今天的需求,却在下一次修改时陷入泥潭。能够工作只回答了当前问题,架构还必须回答另外两个问题:哪些决定可以独立改变,新的需求能够在哪里落下。如果一个功能变化就要同时修改许多页面、数据库字段和同步脚本,系统的复杂性已经超过了单个功能的复杂性。
从结构化程序设计、可生长的语言,到 UNIX、关系数据库和 Internet,许多经典设计都在做相似的事:提供少量稳定的概念和协作规则,让更多用途可以在它们之上被创造。业务系统也需要这样的能力。尤其当 AI 可以快速生产实现时,清楚表达事实、状态和计算依赖,比让模型不断修补结果更能决定系统能否长期演化。
一、需求和架构分别收缩什么空间
人的意图、系统规约和实际实现之间,都存在多种可能。想让学生方便地选课,可能采用先到先得,也可能采用志愿排序或抽签;即使业务规则确定了,数据结构、模块边界、页面交互和运行方式仍有不同选择。需求工程逐步确认真正需要什么,减少意图中的歧义;架构则确定职责、接口和约束,减少规约与实现之间随意组合的空间。
这些选择面对两种不同的复杂性。本质复杂性来自问题领域:课程有容量,学生要满足先修要求,教室和时间会冲突,转专业后还要认定已经修过的课程。换成教务人员用纸笔处理,这些约束也不会消失。附带复杂性来自实现:同一信息保存多份,模块之间不断转换格式,一个规则散落在多个页面,缓存与数据库各自保存可以被修改的结果。这些工作并非业务天然要求,而是软件组织方式带来的负担。
架构可以把资格、容量和时间安排分别建模,让每一块都能单独理解,再通过接口协作。它不能让先修要求消失,却可以让学生选课、管理员补选和批量导入共用同一套规则。数据库也提供了类似的隔离:应用可以依赖事务与存储接口,把部分原子性、持久化和并发控制交给专门的系统,而不必在每个业务功能里重新实现。
不过,任何隔离都会引入取舍。普通文件容易用命令行工具查看和处理,但跨多个文件的更新并不会自动获得数据库事务的保证;数据库提供了更强的管理能力,却也要求应用遵循查询接口和运行约束。技术选型是在交换不同的复杂性,不能仅凭一个工具“更先进”就决定它适合所有任务。
登录功能尤其容易暴露这种连锁关系。用户名和密码看起来只是两个输入框,继续展开就会遇到注册、确认密码、忘记密码、身份恢复、邮件或短信发送等问题。第三方登录又带来接入条件、服务依赖和费用。一个模糊的“需要登录”,已经包含许多会影响后续维护的决定;让模型直接实现,只是把这些决定交给它猜测。
因此,MVP 的深度优先和需求思考的广度优先应当配合。先走通一条关键路径,获得可运行的反馈,同时检查几个影响深远的替代方案。需求也不是越长越好:每项承诺都需要审查、维护,在 AI 工作流里还会占用上下文并增加约束遗漏的机会。重要的是找到必须明确的决定,并知道哪些假设仍然允许调整。
二、分层首先是在尊重人的认知限制
Dahl、Dijkstra 和 Hoare 的《Structured Programming》讨论了许多今天看来很小的程序,却提出了并未过时的问题:人能同时精确思考的元素有限,怎样用这种有限的能力构造和检查一个更大的系统?把所有细节堆在一个函数里,等于要求人每次都重新理解整个问题。
枚举、归纳和抽象提供了不同的辅助。枚举用于检查有限分支,归纳帮助理解循环和递归,抽象让人只依赖一项操作承诺的效果。使用队列时,可以先讨论入队和出队,不必同时考虑数组下标、内存分配和扩容策略;理解业务时,可以先讨论选课资格,不必把数据库访问一起展开。
“珍珠项链”的比喻关注的也不只是把代码切成几块。每颗珍珠可以承担一项设计决定,决定之间的依赖影响哪些部分能够独立改变。如果很早就把图像固定为逐行表示,许多中间层都可能被迫理解“行”;如果推迟这个决定,更多层就可以继续操作“图像”这个概念。实现细节传播得越远,替换它时牵动的范围越大。
类和对象把数据与操作包成一个概念,协程则帮助分离控制流。子程序围绕调用和返回组织工作;协程能够暂停、保留局部状态与执行位置,再从原处继续。两个下棋程序轮流行动时,双方都要等待对方,也都要继续自己的计算。让其中一方成为另一方的普通子程序,可能破坏原有结构;协程可以更直接地表达这种对等合作。它的意义并不只在于降低线程切换开销,也在于让不同活动保留各自的过程。
动态规划调试同样有架构问题。代码只剩下 dp[i][j][k] 和多层循环时,状态的业务或数学含义往往被压掉了,一个错误转移就可能静默地产生错误结果。把状态名称、可达关系、转移来源重新表达出来,甚至打印或可视化状态图,便能在更容易理解的层次检查程序。验证之后再改写成等价的紧凑实现,效率与可理解性才有共同依据。
从链表操作到离散事件模拟,再到车间中的订单和机器,也可以逐层建立概念:底层管理连接,中层管理模拟时间和等待,上层描述请求资源、加工和释放资源。每增加一层,使用者获得更贴近问题的词汇,也能暂时忘记一批已经处理好的细节。这正是分层的实际收益。
三、可生长的设计提供原语和组合方式
Guy L. Steele Jr. 的《Growing a Language》用一场受约束的演讲呈现语言的生长:先从单音节词出发,复杂词汇需要经过定义或已建立的构词规则才能使用。听众因此经历了从少量原语到复杂表达的过程。一个词汇极少的语言容易掌握,却会让稍复杂的意思都变得难以表达;一次性塞进所有功能的大语言,又可能难以实现、学习和交付。
编程语言也面对这种取舍。语言足够小,并不意味着实际程序足够简单。C 的通用排序接口需要元素数量、元素大小和比较函数,调用者要自己遵守类型与内存约定;缺少某些抽象机制时,复杂性会转移到使用者的代码里。反过来,扩展机制如果只能产生难以理解的技巧,也可能使语言和程序越来越难维护。
可生长的设计关心的是:后来者能否建立新的概念,这些概念能否组合,组合后是否仍然遵循清楚的规则。设计者不必提前列完未来的用途,但需要提供足以构造它们的原语、接口和扩展机制。语言的用户可以写新库,系统的用户可以增加新工具,业务软件也可以在既有边界中加入新的规则或实现。
这样的架构给未来留下的是一组有约束的选择。哪些协作规则必须稳定,哪些实现可以替换,哪些空位由后来的人填充,都属于当前的设计。它要求一定程度的前瞻,同时避免为所有想象中的需求预先实现一套复杂机制。一个很实际的检查是:某项需求变了,究竟需要跟着改多少处?
经典材料在这里提供了判断的来历。阅读它们的价值,是理解一个抽象为什么出现、消除了什么负担,又保留了哪些限制。AI 可以辅助检索、解释和联系已有知识,但原文中的具体机制和例子仍然是核对对象。把书名交给模型换取一段“很经典”的概括,无法替代对设计决定的理解。
四、UNIX 把共同接口变成组合能力
UNIX 哲学强调每个程序做好一件事,让程序协作,并用文本流作为通用接口。第三点让前两点真正能够落地:输出既能给人阅读,也能保存或交给另一个工具,组合过程的中间结果还可以随时检查。使用者不必要求某个工具作者预先实现整条工作流。
管道只是连接机制,可靠的组合还依赖共同约定。输入和输出怎样组织,诊断信息走哪个通道,参数怎样解释,退出状态怎样表示结果,都影响工具能否合作。标准输出承载数据,标准错误承载诊断,便能避免错误提示混进下一步要解析的记录。文本也需要确定的结构;文件名可能含有换行时,仅靠“一行一个文件”就不足以无歧义地传递列表。
工具的能力可以在组合中被重新解释。jq 提取 JSON 字段,fzf 接收候选并让人选择,编辑器打开选择结果,就可以构造一个符合个人习惯的打开文件流程。每个组件仍然处理自己的局部任务,人的判断也能够成为其中一步。共同接口允许使用者创造设计者没有预想到的用途。
这是一种可生长架构的具体形式。作者提供工具及其协作规则,用户负责新的组合;一个组件的内部实现可以改变,外部协议则保护其他组件。相比把所有能力塞进一个庞大的程序,这种结构更容易替换局部、观察故障和复用已有工作。当然,前提是参与者继续尊重接口,不能随意把进度条或展示格式当作机器数据输出。
五、数据库和 Internet 如何容纳变化
关系数据库把数据的逻辑含义与物理组织分开。图书馆需要表达读者、馆藏和借阅关系,应用可以从同一组关系查询“某人借了哪些书”或“某册书被谁借走”。记录实际放在哪些数据页、使用什么索引、先扫描哪张表,不必成为每个应用功能都要理解的内容。
SQL 描述需要的结果,查询优化器选择执行计划,事务组织需要保持整体性的读写。在这层抽象之上,大量系统可以围绕创建、读取、更新和删除,也就是 CRUD,组织功能。数据库没有预先列出所有学校、商店和图书馆的问题,而是提供关系和查询的表达能力,让各个应用定义自己的模式并提出新的问题。
Internet 提供了类似的开放性。应用依赖通信接口,传输层、网络层与链路层分别处理相应问题;应用无须理解沿途每段物理链路的实现。TCP 提供有序字节流和相应的可靠传输机制,连接失败仍然需要被处理;上层可以继续增加安全协议和新的业务协议,而不必把每一种用途都塞进基础网络。
RFC 1958 讨论 Internet 架构时强调持续的技术变化。远程登录、文件传输和网络服务的具体方式会演进,基础通信能力仍要为不同应用提供空间。稳定的协作规则允许局部替换,整体系统因此能够继续工作。
UNIX、数据库和 Internet 分别固定了工具交换、逻辑数据和通信协作中的部分规则,也分别把用途、存储实现和上层应用留给后来者。它们的共同贡献,是搭建一个足够通用的抽象层,让更多系统可以在其上被构造。业务架构需要进一步决定,具体客户的复杂规则如何安放在这些基础设施之上。
六、MVC 为不同变化安排归属
早期 Web 信息系统可以把每个页面看成一段数据库操作:接收请求、查询数据、检查条件、写入记录,再返回 HTML。模板语言降低了组织页面的成本,也能让 AI 应用中的提示词更容易维护;把变量放进明确的模板,比把大量字符串和条件拼接在一起更接近最终要表达的内容。
但页面里能够直接嵌入代码,也意味着业务逻辑容易被随手放进去。学生选课页检查一次容量,管理员补选页再检查一次,导入脚本又复制一份。以后规则改变,就需要找到所有入口。改用标签或模板,并不会自动获得职责分离。
MVC,即 Model–View–Controller,把数据与业务状态、展示、用户输入处理区分开来。Model 封装课程、选课记录和规则,View 展示结果,Controller 接收输入并协调业务操作。Model 包含业务含义,不能仅理解成数据库表;Controller 也不应该成为所有规则最终汇集的地方。
这样的边界让变化有了归属。课表从列表改成周历,主要影响 View;课程容量和先修规则改变,主要影响 Model;增加管理员补选入口,应该复用已有业务能力,同时表达额外的授权条件。维护者可以在一个层次处理问题,不必每次同时理解页面、查询和所有规则。
MVC 与 CRUD 很适合一批局部信息管理任务,但只是把业务复杂性放到了更明确的位置。目录拆成三个名字,并不会自动使业务变简单;真正起作用的是接口、规则复用以及数据修改的责任边界。
七、界面状态也需要一个模型
业务数据库里的课程列表不变,搜索框里的关键词改变,页面显示的课程就会改变。展开的菜单、选中的行、临时输入和悬停状态,也都属于决定界面的信息。这些状态通常不需要写回业务数据库,却不能因此散落在一堆控件和事件回调中。
可以把界面概括为 View = render(Model, ViewState):业务模型与界面状态共同决定此刻应显示什么。将状态和渲染分开后,维护者可以先检查输入状态,再检查展示计算。局部状态是否需要持久化,与它是否值得被明确建模,是两个问题。
MVVM 进一步用 ViewModel 组织展示所需的状态与行为。以课程筛选为例,课程数据和筛选词是输入,可见课程列表是计算结果。Vue 可以用响应式状态保存筛选词,用 computed 表达列表怎样依赖它。v-model 包装状态决定控件值、输入事件更新状态这两条连接,框架再根据依赖更新展示。
React 则通过组件、状态和显式更新表达类似关系。组件根据当前的属性与状态描述界面,事件请求状态更新,下一次渲染使用新的状态快照。筛选后的列表既然可以由课程和关键词得到,就不必再独立存成一份 State,否则又需要维护两份数据之间的一致性。
两种表达方式的编程体验不同,共同目标都是把依赖写清楚。框架负责相应的更新工作,应用负责说明哪些状态是源头、结果怎样产生。虚拟 DOM、依赖追踪和更新策略可以隐藏部分实现复杂性,但不能替应用判断业务规则是否正确,也不能把所有 UI 状态自动变成没有副作用的纯计算。
八、长流程暴露了当前快照的局限
一笔订单可能经历下单、锁库存、优惠计算、支付、风控、发货、退款、售后和对账。某一步失败,会影响之前已经发生的动作和之后允许发生的动作;人工特批还可能绕过正常路径。教务系统也会遇到成绩、学分、毕业设计、资产归还和特殊政策之间的长期关联。
用一个状态码或一组可独立修改的字段表示这些过程,很容易把不同历史压成同一种“现在”。风控失败但被批准继续处理,与没有触发风控,可能都显示为下一环节;支付后退款,与从未支付,净余额也可能相同。只保留当前结果,系统便失去了理解这种差别的依据。
状态往往还混合了未来预期:等待支付表示某件事尚未发生,应收款表示一项需要继续处理的权利或期待,不能简单当作已经收到现金。事情没有按预期发展时,仅靠覆盖当前字段,可能让已经发生的历史也一起消失。
这类问题属于真实业务的复杂性。把全部代码放进 Model,或者画出一个更大的状态机,只是换了表达位置。架构需要进一步区分事实、对事实的解释以及预期,并说明它们之间的依赖。
九、事件溯源让报表从事实中计算出来
复式记账为理解这种结构提供了一个例子。同一笔业务在相关账户中形成对应记录,借方合计与贷方合计相等,资产、负债和所有者权益之间也存在可检查的关系。它增加了发现错误的手段,但平衡并不意味着业务归类一定正确;重复记账或把金额记到错误科目,也可能保持平衡。
记账工具可以采用 Event Sourcing 与 LLM as a Compiler 的组合:保留原始凭证和发生的业务,让语言模型提出受约束的记账动作,再由确定含义的程序计算这些动作怎样影响账本和报表。模型处理人的材料,程序执行规则,核对环节检查动作与真实业务是否对应。
这种架构避免让模型分别猜资产负债表、利润表和现金流量表的每个格子。多个报表的相关数字来自同一组动作及其计算规则,一个动作影响哪些项目也有明确含义。确认费用、形成应付和实际支付可以分别表达,避免付款时再次把已经确认过的费用算一遍。
事件溯源把过去、现在与未来分开:过去保留已经发生的事实,现在的视图由事实经过规则得到,未来预期则允许被后续事实修正。错误、更正、撤销和冲销也需要留下记录,当前结果的变化便能说明来由。保存历史不是要求错误永远生效,而是避免靠悄悄删除历史解释新的结果。
重放提供了重新生成视图的能力,也为情景推演留下空间。在不改变真实账本的前提下,可以使用假设事件比较应收款无法收回等情况的影响。这是基于明确假设的计算,并不自动产生可靠的风险概率;预测仍然依赖输入与模型是否合理。
重算还需要规则和外部输入的时间边界。复现过去的报表,应使用对应的规则与汇率;按新规则重新评估,则必须说明新规则适用于哪些事实。更重要的是,重放内部计算不能再次执行付款等外部动作。恢复状态与改变外部世界必须分开,否则一次修复可能制造新的业务事件。
十、把同步问题转成可检查的计算问题
教务系统里,一门课程的成绩会影响已通过课程集合,已通过课程又影响学分、先修资格和毕业条件。如果这些结果都作为独立事实保存并允许直接修改,成绩更正时就必须手工更新每个后果。漏掉一项,系统便会同时存在相互矛盾的结论。
更清楚的表示是保留课程、成绩、认定记录和适用规则,把已修课程、学分和准出结论作为派生结果。某门课从通过改为不通过,不只是翻转一个标记,而是产生一项更正,并重新计算受影响的关系。培养方案和课程学分可能随时间改变,计算必须知道该使用哪个版本,不能拿今天的数据代替当时的依据。
特批也要进入事实体系。课程替代、条件豁免和人工批准,应保存对象、适用范围与依据,再由后续规则使用。直接把“可以毕业”改成真,留下的只是一个结果;下一次审核或重算,系统仍然无法解释这个决定。课程条件满足与所有毕业要求满足,也需要保持明确的区别。
关键在于让数据依赖能够被追踪。每个重要结果都应能回答:使用了哪些事实,依据什么规则,经过什么计算?计算过程可以重跑、测试和审计,依赖明确后也更容易判断一次更正影响哪些结论。程序仍然可能有错误,结果更新也可能滞后,但查证与修复需要的依据已经保留下来。
SQL 本身可以表达查询,数据库也提供视图、约束和物化视图;困难通常出现在数据库、应用逻辑、缓存和界面之间。一个基础记录改变后,跨越这些层次的任意计算关系并不会自动得到统一维护。一个总学分字段如果失去了来源与重建方式,即使放在事务数据库里,也可能与真实课程记录不一致。
MVVM、React、Dataflow 和 Event Sourcing 在不同层次让派生状态与依赖变得更明确。它们并非同一种技术,也不能互相直接替代,却可以放在同一个设计问题下理解:哪些信息必须作为事实保存,哪些信息应该由它们算出来?为了性能保存索引、汇总或缓存没有问题,前提是知道来源、失效条件和重建方式。
软件的复杂性由此发生转移。维护许多份可独立修改的状态,需要程序员不断记住同步关系;保存必要事实并表达计算,则把更多责任集中到计算是否正确。这个问题更容易形成可复查的输入、输出和证据。对生成式软件工程来说,明确这些对象,也为 AI 的生成、修改和检查提供了共同依据。
好的架构因此既要有组合和扩展的空间,也要让结果拥有来历。抽象限制每次需要理解的内容,接口安排变化的责任,事实与规则支撑结果的重建。代码可以更快地被生成,系统仍需要能够说明自己为什么处于当前状态,以及下一次需求变化时哪些决定需要重新检查。
