一个模块今天一行源码都没有修改。

但是它依赖的库升级了,编译器换了版本,一个会影响构建结果的环境变量也发生了变化。

持续集成还能直接复用昨天的缓存吗?

如果只看源码版本,答案很容易是“可以”。但我们最终交付的不是源码目录,而是源码在一组具体依赖、工具链、配置和环境中形成的制品。只要其中真正参与结果的条件发生变化,原来的缓存就未必还对应现在的软件。

这类问题并不只出现在构建缓存里。

同一次变更还会继续带来更多问题:哪些下游需要重新构建?哪些测试结论已经失效?容器镜像里应该包含什么?流水线到底需要执行哪些任务?一个 Coding Agent 应该先阅读哪些代码?某个开源组件出现漏洞以后,哪些产品真正包含了它?两个版本为什么会产生不同制品?

前两篇文章分别从构建工程和整个软件工程出发,讨论了实现成本下降以后,为什么工程重心会逐渐转向变更处置能力(Change Handling)

但只知道工程体系应该识别影响、建立证据、控制边界,并对变更作出接受、调整、拆分、推迟、拒绝或者重新设计等明确处置,还没有回答一个更实际的问题:

这些能力怎样真正进入日常工程?怎样落地?

本文主要讨论变更处置所依赖的技术事实和可验证执行怎样建立。至于一项影响最终是否可以接受、由谁承担剩余风险,仍然属于前两篇文章讨论的决策边界,这里不再展开。

如果落地方式仍然是人工维护更多依赖清单、流水线、制品描述、供应链清单和 Agent 提示词,那么我们只是把原来的隐式知识重新复制到了更多地方。

真正需要改变的,是工程体系用什么对象描述软件,又让这些对象之间的关系怎样参与实际执行。

软件应该由什么样的工程事实描述

先建立工程单元

软件工程里最容易被当成基本对象的是源码文件、目录、仓库和模块。

它们很重要,却不是天然的工程边界。

一个编译器没有业务源码,却会直接改变大量制品。一个系统类库可能来自操作系统,却是软件运行时的一部分。一个环境变量看起来只是一个字符串,如果它参与编译选项或者特性选择,它同样可能改变最终结果。

所以,工程单元不能只是“软件模块”的另一个名字。

我更愿意把工程单元理解成:

工程体系需要对它建立独立身份、输入输出和变化边界,并且需要知道它与其他对象之间关系的东西。

它可以很大。

一个产品系列可以是一个工程单元,一个具体产品、子系统或者平台也可以。

它也可以逐渐变细,成为一个组件、模块、工具链、系统类库或者文件。在必要的时候,一个能够独立改变结果的配置项,甚至一个环境变量,也可以成为单独的工程单元。

工程单元也未必一定要编译出二进制。补丁、文档、测试报告、验证结果或者一组环境设置,都可以成为工程单元的产出。重要的是,每个工程单元都要有一个或者多个语义明确的输出,使直接下游知道自己实际消费的是什么。

粒度不是由代码量决定的。

真正决定粒度的是:这个对象是否需要被独立识别、复用、验证、替换和追踪变化,以及它是否存在值得单独表达的工程边界。

这里的“工程单元”也不是为了替代配置项、构建目标、制品或者测试任务等已有术语。它只是本文为了讨论这些不同层次的对象怎样在同一次变更中被识别、组合和追踪而使用的上位称呼。进入具体领域以后,仍然应该使用更准确的专业名称。

这实际上是一种很朴素的分而治之。它体现的是一种典型的工程思维:先把复杂系统拆成边界清楚、可以独立理解和验证的单元,再通过明确的关系把它们重新组织起来。

一个产品级工程单元不应该直接关心几千个源码文件。它只需要知道自己直接依赖哪些产品配置、平台或者子系统,以及这些直接上游向它提供了什么确定的工程结果。

子系统再关心自己的直接上游,模块继续管理更细粒度的依赖。

整个产品最终仍然形成一张完整的工程关系图,但每个工程单元只需要理解与自己直接连接的部分。产品级别的判断不用一直穿透到文件,文件级别的变化也不需要把所有内部细节暴露给产品。

细节可以封装,真实关系不能因此消失。

这和代码模块化的思路没有本质区别。不同的只是,我们把这种分而治之从源码接口继续扩展到了构建、工具链、环境、制品、产品和运行条件。

工程关系表达的是产出怎样形成

有了工程单元以后,接下来才是真正建立工程关系。

这里的关系不能只理解成源码里的 import、函数调用或者仓库依赖。

假设一个二进制由源码 A 编译得到。编译过程使用工具链 B,链接系统类库 C,通过配置 D 选择功能,又由环境输入 E 决定一个构建选项。

那么 A、B、C、D 和 E 都参与了这个制品的形成。

只画源码调用图,会漏掉真正能够改变结果的关系。

工程关系的主干,应该表达工程单元之间形成产出的因果和派生关系:一个单元依赖哪些直接上游,这些上游向它提供什么,它又在什么条件下形成自己的结果。

一个模块自己的源码没有变化,并不能说明它没有发生工程变化。如果它依赖的工具链、系统类库或者其他上游工程单元发生变化,那么“这份源码在什么条件下形成什么制品”已经和过去不同。

反过来,也不能简单地把机器上所有东西都算作输入。

一个环境变量如果根本不会被读取,就不应该因为它碰巧不同而让整个下游图失效。工程关系要表达的是实际因果关系,而不是把环境里能够观察到的一切都机械加入模型。

这也是工程模型需要追求的精确性:

该传播的变化不能漏掉,不参与结果的变化也不能被无意义地放大。

如果把软件暂时类比成一座乐高城堡,源码、工具链、系统类库和配置都可以看作参与搭建的积木。

知道用了哪些积木,还不能说明最终会得到什么城堡。

没有拼装手册,同样一批积木交给不同的人,在不同时间、不同地点组装,最后可能得到不同的结果。真正决定这座城堡是什么的,不只是积木清单,还有它们怎样组合。

工程关系图就像一份能够被机器理解的拼装手册。

它不只记录“有什么”,还记录“这些东西怎样共同形成最终结果”。

对于要求可复现的构建类工程单元,如果拼装手册和输入相同,执行过程又受到控制,就应该得到相同的结果。确实包含时间、随机性、签名或者其他副作用的产出,则需要显式表达它的非确定性,不能继续使用普通可复现结果的身份和缓存规则。

这里所说的完整工程关系,也不是声称工程模型已经知道真实系统中的一切。

它指的是,对于一个确定产出,当前模型中所有被允许影响它的因素都需要进入对应的因果边界。模型之外仍然存在的真实关系,需要在后续执行、失败和运行反馈中继续被发现。

从系统工程的视角看,这类围绕系统技术身份、组成关系及其变更的工作,可以放在技术状态管理(Configuration Management)的谱系中理解。

只是到了现代软件里,实际需要维护的技术状态不能只停留在源码版本和配置文件。依赖、工具链、环境、制品,以及这些对象之间的派生关系,都需要进入可以追溯和持续修正的工程模型。

单一代码仓可以为源码提供共同的事实来源,却不能单独回答最终制品为什么是现在这个样子。

工程状态描述的是一个单元当前到底是什么

今天的软件工程大量依赖人类可读的名字。

某个模块叫 foo,某个库版本是 3.2.1,某个镜像叫 latest,某次构建对应一个 Git commit。

这些名字适合沟通,却不足以决定一个工程结果是否真的相同。

这里至少要区分三个层次。

工程单元回答的是“这是哪个对象”。同一个编译器、模块或者产品,可以在不同时间形成多个工程状态。

工程状态回答的是“这个对象在什么源码、依赖、工具链、配置和环境条件下形成”。

产出内容则回答“这次实际得到了什么”。

同一个工程单元可以形成不同工程状态。两个不同工程状态也可能在某次执行中碰巧产生字节完全相同的制品,但它们的形成原因和来源事实仍然不同,已经建立的验证证据也不能因此自动互换。

所以,真正用于构建和来源判断的身份,需要包含所有会影响当前工程状态的输入。

可变的源码引用,在形成一次确定工程状态时应该收敛到确定内容;实际选中的工具链和系统类库需要具有确定身份;真正影响结果的配置和环境输入也需要进入这次工程状态。

这些事实不应该依靠工程师在另一张表里手工同步。

工程体系应该在实际解析和执行过程中把能够确定的内容自动记录下来。

秘密和敏感信息当然不需要因此保存成明文。重要的是留下足以判断身份和变化的受控表示,而不是用完整记录换掉原来的安全边界。

只要完整工程状态发生了变化,原来的状态就不能直接代表现在。即使新旧状态最终产生了相同内容,它们的因果来源仍然不同。

只有当下游明确只依赖一个经过定义和验证的输出边界时,才可以按输出内容复用,而不继续继承工程单元内部的全部推导差异。

封装降低的是下游需要理解和继承的细节,不是删除产出的来源事实。

一项变更怎样落到同一个工程对象上

如果工程关系只保存“现在是什么”,我们仍然很难回答长期研发中最常见的一个问题:

为什么两个版本不一样?

一个正式产出在形成时,应该同时冻结一份与它对应的工程关系快照。

这份快照不只包含源码版本。

它还包括真正参与该产出形成的直接和传递关系:依赖状态、工具链、相关系统类库、构建配置、目标平台,以及会影响结果的环境输入。

这样,一个制品就不再只是一个孤立的文件,而是和“它为什么会成为这个样子”绑定在一起。

当新的变更到来时,我们首先可以比较新旧两个工程状态。

某个模块自身源码没有变化,但是编译器变了,这会直接出现在差异里;某个系统类库升级了,可以沿关系继续看到哪些下游需要重新判断;一个会影响制品的环境输入发生变化,也不会继续藏在某台 CI 机器上。

所以,在真正执行构建以前,我们就已经能够知道:

哪些工程单元的推导条件发生了变化,旧结果为什么已经不能直接代表现在,以及变化会沿哪些已知关系继续传播。

这时候得到的是变化原因和当前模型能够计算出的影响范围。

真正执行以后,工程体系又会得到另一类事实:

实际产生了什么制品,完成了哪些测试和验证,又有哪些关系或者假设在执行中被证明不成立。

测试也需要进入同一套工程模型。

构建和测试可以位于同一个工程单元,也可以拆成不同的工程单元。怎样拆分,取决于它们是否需要独立执行、独立复用和独立形成证据。测试报告和验证结论本身也可以成为具有明确语义的产出。

如果一张产品工程关系图完整描述了软件怎样被制作出来,却没有任何工程单元产生测试或验证结果,那么它只建立了构建的因果边界,还没有建立软件是否符合预期的证据边界。

新旧工程状态之间的差异,因此可以形成一份比 Git Diff 更完整的工程变更。

代码变化只是其中一部分。

工具链变化、系统类库变化、依赖状态变化、环境输入变化、当前可计算的影响范围、新产生的制品、已经完成的验证,以及仍然没有解决的模型缺口,都可以属于同一项工程变更。

这也是前两篇文章里的两个问题真正落到同一个工程对象上的地方:

执行以前,工程状态告诉我们为什么旧结果失效、可能影响什么;
执行以后,同一个工程状态继续绑定实际结果和验证证据。

工程体系不再先靠代码差异猜影响,然后再去另一套流水线和报告里寻找证据。

变更的原因、传播和结果开始属于同一组工程事实。

工程模型是在执行中形成和修正的

建立这样一套模型,最现实的困难是:很多项目从来没有完整表达过这些关系。

构建脚本里有一部分,CI 里有一部分,容器定义里有一部分,开发者的机器和经验里还有一部分。

AI 在这里很有价值。

它可以阅读项目结构、构建脚本、流水线、依赖声明和现有文档,帮助识别可能的工程单元,提出候选关系,也可以在构建失败以后帮助分析当前模型可能漏掉了什么。

对于大型遗留系统,这会显著降低从脚本、文档和人员经验中恢复初始工程模型的成本。

但这里有一条不能越过的边界:

AI 可以帮助建立和补充工程关系,工程关系本身不能靠 AI 说“我认为是这样”成为工程事实。

人工也一样。

一个资深工程师说“这个模块应该只依赖这些东西”,可以是很好的建模起点,却不能代替客观验证。

AI 推断、人工声明和执行中观察到的关系,可以共同进入建模过程,但在经过实际执行或者其他独立验证以前,它们不应该具有相同的可信程度。

一个模型只有真正参与实际决定,才会持续承受现实压力。

构建顺序从工程关系推导,缓存复用根据工程状态判断,需要重新验证的范围从关系传播得到,真正使用的源码提交、工具链、依赖和环境条件也在这个过程中自动进入工程状态。

这时,工程模型不再是一份需要工程师定期同步的资产台账,而是软件正常构建、验证和发布过程中自然形成的事实。

真正的验证也必须客观。

对于构建类工程单元,如果声明的输入已经完整,那么在相同工程状态下应该能够独立得到相同结果;如果实际执行仍然读取了模型之外的工具、文件、环境或者网络资源,这种不一致应该由执行过程本身暴露出来。

人和 AI 负责提出对世界的描述,实际执行负责检验这份描述是否足以解释结果。

但一次执行成功不能证明模型已经包含所有真实关系。

模型如果漏掉了一条下游关系,也可能因为没有调度那个下游而暂时看不到失败。

所以,让工程模型参与实际执行,只是让它变得更容易被现实反驳,不是让它自动获得完备性。

对于日常开发,可以让构建、测试和持续使用不断暴露缺失关系。对于需要更强保证的场景,还可以定期进行完整构建,或者在不同时间、不同隔离级别和差异较大的环境中交叉验证,避免工程模型只看到自己选择执行的那部分事实。

隔离以后构建突然失败,某个看起来无关的修改影响了下游,Agent 总是在请求声明之外的工具,或者运行中出现了模型无法解释的结果——这些都不只是执行失败。

它们也是工程模型需要修正的证据。

因此,目标不是建立一张宣称永远完整的全局工程图。

真正需要的是一个可修正的工程模型:

人和 AI 提出候选关系;
实际执行把能够确定的内容变成工程事实;
封闭执行和交叉复现不断检验这些事实;
现实一旦证明模型解释不了结果,新关系重新进入模型,并改变下一次判断。

技术状态管理只能管理已经进入工程模型的技术事实。

工程体系还必须具备修正模型本身的能力。

一次三阶段自举怎样检验工程模型

我们第一次让 AI 在这个工程模型下做的一个复杂任务,是让 AI 从一个空白目录开始,完整建立 GCC 的源码自举构建。

这不是在一个已经准备好的项目里让 Agent 改几行代码。

它需要自己去找到 GCC 源码,识别源码怎样获得,继续寻找构建 GCC 所需要的依赖、工具和系统条件,再逐步把这些东西建立成工程单元和候选关系。

整个过程持续了一天多。

从外面看,AI 的工作过程并不优雅。

它很像在一个迷宫里探索。

走几步,发现缺了一个条件;补上以后继续走,又发现原来的路线不成立;有时候退回来一小段,有时候甚至回到很早的位置重新选择另一条路径。

工程关系也在这个过程中不断被补全。

新的依赖被发现,原来隐藏在环境里的工具被显式表达,一些最初认为正确的边界在真正执行以后又被修改。

最后完成的是 GCC 的三阶段自举构建,而不只是让 GCC “编译成功一次”。

真正重要的也不是 AI 最后告诉我们:

“我已经成功建立了 GCC 的构建关系。”

如果结论停在这里,这份工程模型仍然只是 AI 的一种解释。

我们把同样的工程关系拿到其他环境中,重新执行整个三阶段自举过程。

它能够按照相同关系再次完成。

这虽然不能从逻辑上证明工程模型已经包含所有可能存在的关系,但它形成了一份不依赖最初 Agent 和人工解释、能够被其他环境重复检查的证据。

而且,复现发生在不同时间、不同环境,还可以进一步进入不同隔离级别或者差异更大的硬件环境。环境差异越大,隐藏依赖在所有环境中同时保持不变的可能性就越低,证据强度也越高。

这个过程说明了一条很重要的边界:

AI 很适合帮助我们探索工程模型,但模型最终需要能够在没有原 Agent、也没有人工主观判断的情况下被独立验证。

否则,我们只是把过去依赖人的隐式经验,换成了依赖 AI 的隐式经验。

不要从整个组织开始

说到这里,很容易得到一个看起来合理、实际上很危险的落地方案:

先把公司的所有代码、工具链、平台、环境、制品和服务关系全部建模。

这样的项目通常很难完成。

范围越大,越容易先建出一套脱离实际执行的元数据系统。所有团队开始补字段,真正的缓存判断、CI 流水线和发布系统却继续沿用旧逻辑。

更合理的起点,是选择一个已经反复产生真实成本的问题。

可以先从缓存开始。

选一个经常错误失效或者错误复用的构建单元,把源码、直接上游、工具链、配置和环境真正纳入工程状态。如果模型正确,它应该直接改变缓存判断。

也可以从持续集成开始。

选择一条很长、每次修改都需要跑大量无关任务的流水线,先建立能够计算受影响工程单元的关系,再让下一次变更的流水线直接由这个关系生成。

还可以从大型代码库中的 Agent 定位开始。

先给一个产品或者子系统建立足够可靠的工程边界,让 Agent 从候选工程子图开始,而不是从整个仓库开始搜索。

或者从供应链开始。

选一组长期难以确认影响范围的第三方依赖,让每个正式制品都能够回答自己实际使用了什么,以及一个有风险的组件究竟会沿哪些关系进入哪些产品。

关键不是从哪个案例开始。

关键是第一份工程模型必须控制一个真实决定。

如果模型只是供人查看,它迟早会成为另一张过期架构图。

如果缓存、CI、制品生成、组件定位或者 Agent 工作边界真正依赖它,错误和遗漏就会在使用过程中不断暴露出来。

局部关系稳定以后,再沿直接上游和下游逐渐扩展。

产品内部稳定以后,再扩展到跨产品、跨平台和系统之系统的技术状态关系。

这是一条从真实工程事实逐步扩大的路径,而不是先设计一个覆盖整个组织的理想世界。

同一组工程事实怎样进入实际研发

到这里,真正需要建立的基础已经比较清楚了:

工程单元表达对象,工程关系表达产出因果,工程状态固定一次具体条件,状态快照保存历史事实;执行以前,新旧状态差异给出变化原因和当前可计算的影响,执行以后,实际结果和验证证据继续绑定到新的状态。

接下来的问题不再是继续扩展这套模型,而是已有的工程系统怎样使用同一组事实。

持续集成、制品和供应链、Coding Agent 关心的问题并不相同,所以它们会形成不同的视图,也不能只凭图上的可达性使用同一种判断规则。

但它们不应该再分别维护第二份“这个软件是什么、由什么形成”的事实。

构建和持续集成

今天很多 CI 系统的基本对象是一份长期存在的流水线。

工程师维护一个 Jenkinsfile、GitHub Actions Workflow 或其他任务定义,然后不断往里面增加条件:

某个目录变化时运行 A,另一个平台运行 B,只有修改公共组件时才执行 C。

随着产品、平台和测试越来越多,这些条件最终又形成一套独立的依赖系统。

它需要长期维护,也很容易与真正的软件结构发生漂移。

如果工程关系本身已经能够回答:

这次变更使哪些工程单元的工程状态失效了?

那么流水线没有必要继续承担依赖建模职责。

对于每一项候选变更,工程体系可以根据新旧工程状态之间的差异,自动得到本次必须重新建立结果的构建和测试单元,再生成一份只属于这次变更的执行计划。

执行计划中的任务与工程单元对应,任务之间的依赖关系也直接来自工程单元之间的真实关系。

应该并行的任务可以并行,必须等待直接上游结果的任务才进入等待。流水线不再依靠人工经验把若干模块捆进一个任务,或者用大量条件近似描述真实依赖。

在日常增量流水线中,没有受到这次变更影响、已有结果仍然有效,也没有被独立验证要求重新执行的工程单元,不需要进入本次执行计划。

一个公共工具链发生变化时,它可能接近完整流水线;一个边界良好的局部修改,则可能只需要运行完整工程图中的很小一部分。

进入这条流水线的每个构建任务都有明确原因:

它对应的工程状态因为本次变更而失效,需要重新建立结果。

不再需要把大量任务先塞进固定流水线,然后再通过 if、路径判断和环境猜测决定“今天到底要不要跑”。

针对这次变更生成的流水线实例执行完以后甚至可以删除。

长期需要保存的是产生它的工程关系、执行计划生成规则、对应工程状态和实际执行记录,而不是一张需要人工持续维护的固定任务拓扑。

CI 从一份长期人工维护的任务图,变成了一次具体变更在当前工程关系上的执行视图。

这会同时改变几个成本。

首先,流水线本身的维护成本下降。

其次,每次变更只需要调度真正受影响的工程单元,执行规模下降。

更重要的是,真实并行度会提高。

流水线配置了多少并发,不等于真正获得了多少并发。人工编排的任务通常混入了团队划分、历史兼容和过去的性能优化。一些本来没有依赖关系的模块被放进同一个大任务,只能串行执行;另一些存在实际先后关系的工作,又可能为了缩短表面耗时被安排成并行,从而产生偶发且不稳定的构建失败。

当任务边界和依赖顺序直接来自工程单元的实际关系以后,流水线可以更接近工程上能够安全达到的并行上限。

构建环境也会随之变化。

传统流水线经常把产品和环境绑定在一起。产品 A 使用一套构建镜像,产品 B 维护另一套镜像;某个平台依赖特殊编译器,就长期保留一组专用执行节点。

如果每个工程单元已经明确知道自己真正需要哪些工具链、系统类库、配置和平台条件,执行基础设施就不必再长期“扮演某个产品的机器”。

相同的通用构建基础设施可以承载更多原本环境不同的工程单元,每个单元在执行时获得自己明确声明的条件。

容器镜像也不必继续作为大量人工维护的环境模板存在。构建需要的环境和运行制品需要包含的依赖,都可以根据对应工程关系生成。

这也意味着,本地构建和 CI 不应该具有不同的软件语义。

CI 可以运行在不同机器上,本地也可以有完全不同的宿主环境。只要真正参与结果的输入相同,并且执行没有越过声明边界,构建类工程单元的工程状态和结果就应该相同。

CI 中由工程输入导致的问题应该能够在本地复现。

如果相同工程状态仍然反复出现不稳定失败,就应该继续寻找尚未声明的关系,或者把问题归到硬件、存储和调度等更底层的基础设施,而不是笼统地归因于“CI 环境不一样”。

反过来,本地已经完成的构建结果,在工程状态一致、结果经过客观验证并满足当前信任策略时,也可以被 CI 安全复用。

某些高信任场景仍然可以要求在独立受控环境中重新构建,但那是在增加独立证据,而不是因为本地和 CI 天生属于两套不同的软件。

一个 900 多模块的产品

这些变化在一个拥有 900 多个模块的大型产品中表现得很直接。

这个产品原来长期维护一条完全由人工编排的持续集成流水线,大约有 200 多个任务节点,任务之间又有复杂的依赖关系。

有的构建任务只负责一个模块,有的会一次构建多个模块。

为什么这样划分,并不是从产品真实工程结构直接推导出来的,而是在多年的团队划分和性能优化中逐渐形成的。

流水线维护者会根据经验,把若干模块合并进一个任务,也会调整任务之间的并行关系。

但这套编排并不等于产品真正的工程关系。

有些本来可以并行的模块被放进同一个大任务,只能依次构建;有些存在实际先后关系的任务,又因为一个耗时长、另一个耗时短,被人为安排成并行,偶尔会产生难以稳定复现的构建失败。

更麻烦的是,全局结构和局部细节很难同时集中在一个人身上。

团队里只有极少数工程师对整个流水线有比较完整的认识,但他们不可能同时理解每个任务内部具体怎样构建。各个研发团队又往往有人非常熟悉自己负责的局部任务,却缺少整个产品的全局关系。

因此,每一次较大的流水线优化都要重新协调这两类知识。特性分支里如果修改了流水线,最后除了代码要合并,另一套人工维护的流水线逻辑也要继续合并。

我们后来按照独立工程单元重新建立了整个产品的关系。

第一次直接从工程关系生成完整流水线时,得到的是 900 多个任务。

(这个复杂流水线的一个局部)

这次完整流水线甚至比原来 200 多个任务的人工流水线还慢。

原因很直接:工程单元的粒度细了,调度节点从 200 多个增加到 900 多个,调度器、执行资源和任务启动本身都产生了额外负荷。

这件事反而说明,仅仅把模块切得更细,并不会自动得到更高性能。

真正的变化发生在第一次完整运行以后。

这些工程单元已经形成可以安全复用的结果以后,后续每一次变更就不再需要生成 900 多个任务。

新旧工程状态一比较,可以直接知道哪些工程单元需要重新建立结果,以及变化会沿哪些下游关系继续传播。

在实际研发中,大多数日常变更最终影响的工程单元大约只有 30 个左右。

工具链或者公共基础模块变化时,影响范围仍然可能接近完整流水线。这是正常结果,原来的团队遇到这种变化时同样会主动进行完整构建。

新的体系不是让所有变更都变得局部,而是不再把每一次普通变更都当成全局变化处理。

于是,一条日常变更的流水线从原来的 200 多个人工任务,通常收缩成大约 30 个真正需要重新建立结果的任务。

新的任务通常还比旧任务更小。

旧流水线的一个任务经常同时构建多个模块,新流水线中的任务则直接对应已经声明的工程单元。任务之间的依赖顺序也与工程单元之间的实际依赖一一对应。

可以并行的工作不再被大任务强行串行,不能并行的工作也不会因为人工平衡耗时而被错误地同时执行。

这里所说的“数十倍”,并不是由 200 ÷ 30 推导出来的精确比例。

它描述的是开发人员最直接感受到的结果:代码推送以后,从开始集成到完成构建的等待时间缩短到了原来的很小一部分,在大多数日常变更中表现为数十倍量级的提升;执行过程中占用的构建资源,也下降到了原来的数十分之一量级。

造成差异的不是一个孤立的性能技巧。

需要执行的任务更少,单个任务更小,任务依赖更准确,安全并行度更高,错误并行造成的不稳定失败也随之减少。

同一批构建资源完成一项变更以后,很快可以被下一项变更或者另一个项目继续使用,资源周转率明显提高。

持续集成团队的工作也随之改变。

他们不再把主要精力花在维护一张 200 多节点的人工任务图,以及不断调整“这个任务到底负责哪些模块”。

真正值得维护的是工程单元的边界:哪些输入决定它的工程状态,它依赖哪些直接上游,向哪些直接下游提供结果。

流水线变成了这些关系自动生成的结果。

一次只改 CMake 的对照

这种性能差异并不只来自增量构建和缓存。

在另一个 CMake 构建工程里,我们只重构了 CMake 文件,没有修改源码、工具链或者需要构建的模块。

新旧两套构建都运行在同一台 32 核机器上,都限制为 8 个并发,构建相同模块,其他条件保持一致。

新的构建框架耗时只有旧框架的四分之一。

外部设置的并发数相同,不代表实际得到的并行度相同。

旧框架中存在人工形成的任务边界和先后关系,很多时候没有足够的就绪任务填满 8 个执行位置,或者一个大任务内部仍然串行。

按照实际工程关系重新组织以后,更多真正独立的任务能够同时进入执行,关键路径明显缩短。

这个案例基本隔离掉了增量缓存、变更范围和机器差异,留下的主要就是任务关系本身。

它说明,在复杂工程中,人工编排能够达到的并行上限,通常低于根据真实工程关系能够得到的可达上限。

制品和供应链

工程关系快照在制品侧首先解决的是来源问题。

如果一个正式制品绑定了形成它的工程状态,那么我们不仅知道“这个文件是什么”,还能够知道它来自什么源码、使用什么工具链、经过哪些依赖和配置形成。

所以,对比两个软件版本也不必只比较 Git Diff。

我们可以比较两个制品各自绑定的工程关系快照。

源码没有变化但编译器变了,会直接出现在差异里;系统类库升级了,可以继续沿关系看到它进入了哪些制品;一个会影响结果的环境条件发生变化,也不会继续隐藏在构建机器里。

同样的事实还可以直接成为软件供应链信息的重要来源。

用了哪些第三方组件,具体是什么版本,通过什么关系进入哪个制品和产品,由什么工具链生成,这些内容本来就已经存在于形成制品的工程事实中。

当某个开源组件出现漏洞时,也不需要只根据仓库搜索判断“哪些项目可能使用过它”。

可以从这个工程单元出发,沿下游关系找到哪些制品包含或者依赖它,哪些产品继续使用这些制品,哪些历史版本的工程状态中存在这个组件。

这里首先解决的是:

这个组件究竟在哪里,经过什么关系进入了哪些产出。

至于是否需要立即清除、重新发布或者采用其他临时处置,是后续风险判断。

同一组工程事实还可以继续支持来源证明、第三方许可证和制品组成分析。

前提仍然是这张图足够真实。

如果模型漏掉了一个宿主系统偷偷提供的动态库,那么供应链视图同样会漏掉它。

所以,供应链信息的准确性最终还是回到最初的问题:形成制品的关系是否真正进入了工程模型,并且能够被客观验证。

从源码可信继续走到制品可信

对于运营商、政府、军方、航空航天、汽车、医疗设备等具有较高安全和审查要求的软件,仅仅交付源码供人检查,通常还不够。

即使审查方已经检查过源码,没有发现已知漏洞或者恶意代码,仍然存在一个必须继续回答的问题:

真正部署到产品上的软件,确实是由这份经过审查的源码构建出来的吗?

如果这个关系无法被证明,那么源码审查和最终产品之间仍然断着一段。

而且,即使能够说明最终制品来自这份源码,构建过程本身仍然可能引入新的不确定性。

源码使用了哪个编译器?链接了哪些系统类库?经过了哪些代码生成器和补丁?构建过程中读取了什么环境变量?有没有从宿主机器取得没有声明的文件?有没有临时使用另一套工具,或者从网络获得额外输入?

只要这些因素还有一部分依赖“构建机器当时就是这样”,就很难说明最终制品到底是怎样形成的。

工程关系图可以把源码审查继续向最终制品延伸。

源码是其中的工程单元,工具链、系统类库、构建配置和其他会影响结果的条件同样进入关系图。每一步产生的结果又成为下一步的明确输入,直到形成真正交付到产品里的制品。

这样,我们得到的不再只是:

这份源码经过了审查。

还可以继续回答:

这个最终制品由哪一份源码形成,经过了哪些工程单元,每一步使用了什么输入,又产生了什么输出。

构建过程本身因此也能够进入审计范围。

但更重要的一步仍然是独立复现。

如果这份工程关系能够在另一台机器、另一个时间,甚至差异较大的隔离环境中重新执行,并再次得到相同的指定制品,那么第三方就不需要只接受供应商关于“这个二进制就是由那份源码构建出来的”这一声明。

它可以自己重新执行这条关系,并比较得到的结果。

验证强度还可以继续提高。

对一般研发,能够在受控环境稳定重建可能已经足够。对安全等级更高的场景,可以进一步使用不同时间、不同隔离级别甚至异构环境进行交叉验证。每增加一种独立检查,我们都在继续缩小那些只能依赖信任、却无法验证的范围。

这并不意味着建立工程关系图以后,软件就自动变成“绝对可信”。

源码审查仍然可能遗漏问题,工具链可能存在缺陷,测试和验证本身也存在边界。

真正发生变化的是,原来只能通过组织信誉、流程声明和人的经验来连接的几个环节,逐渐变成可以被独立检查的工程事实。

从:

这份源码是可信的,请相信最终软件就是这样构建出来的。

逐渐变成:

可以检查源码,可以追溯它怎样形成最终制品,可以审计中间每一步,还可以在独立环境中重新得到同样的结果。

这时,我们才开始从源码可信、构建过程可信和制品来源可信,一步步逼近端到端的可信。

真正的可信,不是要求别人相信你的结论。

而是让别人能够独立验证你的结论。

Coding Agent

工程关系对于 Coding Agent 的第一个直接价值,是给大型项目提供一个比全局搜索更有结构的起点。

在小仓库里,从需求里的关键词开始搜索,再沿调用关系阅读几个文件,通常可以工作。

到了大型项目,这种方法很容易失效。

Sourcegraph 对 1,281 次 Agent 运行的分析里,反复出现的失败包括在代码库中不断游走、选错文件或符号、只完成局部修改、反复搜索和回退,以及读入大量无关代码以后耗尽有效上下文。

CodeScaleBench也把大型代码库中的代码修改,与文件、符号和依赖关系的发现及理解分别评测。代码生成能力和找到完整工程上下文并不是同一件事。

工程关系能提供的,不是另外一种全文搜索。

它更像一张地图。

如果让 Agent 在一个陌生城市里找一栋楼,传统做法有点像告诉它城市名字,然后让它从整个城市开始逐条街搜索。

完整的工程关系提供的是另一种路径。

任务先落到一个产品或者产品系列,再进入相关子系统,然后根据关系继续缩小到平台、模块、组件、目录、文件和具体符号。

它不是一下子把整个城市的全部细节塞给 Agent,而是像:

城市 → 街区 → 街道 → 小区 → 楼栋

一样,沿已有工程结构逐步收敛搜索范围。

这也是为什么大粒度和小粒度工程单元都需要存在。

如果图里只有文件,Agent 仍然必须从数十万个文件开始理解全局;如果只有产品级节点,又无法继续落到具体实现。

到了足够小的工程范围以后,语义检索、符号引用、调用分析和代码阅读再继续工作。

工程关系负责回答:

先往哪个方向走。

代码智能继续回答:

到了这里以后,具体是哪一个实现。

Agent 最后才判断应该怎样修改。

找到候选位置以后,工程关系还可以进一步呈现不同修改位置已经能够确定的结构差异。

修改公共组件可能代码很少,却影响大量下游;修改适配层可能代码更多,但传播范围更小;增加一个新工程单元又会形成新的长期关系。

这里不需要让系统自动选择“影响最小”的方案。它的作用是让这些结构后果在实现以前就能够进入判断,而不是等代码写完以后才发现传播范围。

工程单元明确以后,同样的边界还可以成为 Agent 的工作边界。

Agent 可以读取这个单元需要的源码和直接上游结果,使用已经声明的工具,在限定范围里持续搜索、修改、构建和验证。

如果任务需要新的工具、额外网络访问或者模型中没有声明的依赖,它不应该通过临时安装软件、修改 PATH 或直接改变机器状态完成任务。

它应该提出一项新的工程变更。

这使工程边界的价值不再只是“限制 Agent”。

恰恰因为边界足够清楚,边界内部反而可以给予更高自治,把少量真正需要升级的边界变化和大量可以自主完成的工作分开。

在最近一次实际开发中,这种反馈已经出现过。

Agent 完成了一版修改以后,新的工程状态显示,这项修改影响到了原本不应该受到影响的工程单元,传播范围比任务预期明显扩大。

Agent 根据这个变化重新判断了自己的实现,主动撤回之前的提交,换了一条实现路径重新修改。

等我们注意到这个过程时,它已经完成了修正。

这个案例真正有价值的地方,不是“AI 自己发现错误”。

它发现问题依靠的不是重新读一遍自己刚写的代码,然后再次使用同一套理解判断自己对不对。

工程关系给了它一个独立于代码文本的反馈:

你这次修改造成的工程传播,和任务原本应该触动的范围不一致。

代码层面的实现判断和工程层面的影响判断因此不再是同一个信息源。

搜索帮助 AI 找到代码。

工程关系还能在修改以后告诉它:

你实际上改变了什么。

同样的边界也会改变一部分人的协作方式。

在工程状态传递这一层,上游不必把所有内部细节无差别广播给所有参与者,只需要向真正依赖它的下游提供确定的工程状态。

这不能消除跨团队设计、产品决策和非局部质量属性管理,但可以减少大量原本依靠会议、群聊和人工同步完成的状态传播。

这些能力并不是 AI 时代才出现的

走到这里,很容易把这些做法理解成一套“AI 时代的新工程方法”。

其实并不是。

工程单元一直都存在,只是过去可能叫产品、组件、目标、工具链、制品或者环境。

工程关系也一直都存在。

一个构建工程师知道哪个编译器会影响哪些产品,一个发布工程师知道某个系统库升级以后哪些版本需要重新验证,一个平台工程师知道哪条依赖改变以后哪些流水线会失效,一个维护多年的工程师能够迅速判断某个看起来很小的修改实际上会碰到哪些历史关系。

前面那个 900 多模块产品中的旧流水线,其实也是这些关系存在的证明。

只不过它们被压缩到了 200 多个人工任务里,又进一步分散在少数全局流水线维护者和各个团队的局部经验中。

这些知识过去大量存在于脚本、流程、文档和人的经验里。

它们一直参与软件研发,却没有总是被作为一套统一的工程事实表达出来。

有点像软件工程里的“暗物质”。

我们能够从很多现象中知道它存在:缓存为什么会失效,为什么某个修改需要全量测试,为什么这个产品只能在那套环境构建,为什么某个资深工程师一眼就知道不能改这里。

但真正的关系本身经常不可见。

过去实现速度比较慢,人的经验还有时间在这些缝隙之间补足模型。

AI 把实现速度迅速提高以后,这些隐含知识越来越难继续靠人临场填补。Agent 不会天然拥有某个工程师十年积累下来的项目直觉,CI 也不会因为代码生成更快就突然理解整个产品的真实关系。

因此,AI 时代真正暴露出来的,并不是一种过去不存在的新工程需求。

它让我们更难继续忽略那些一直存在、却没有被完整表达的工程事实。

与此同时,AI 也显著降低了从遗留脚本、文档和人员经验中恢复初始工程模型的成本。

不是增加流程,而是改变工程事实怎样产生

这条路径并不要求所有团队采用同一套工具,也不要求把所有状态放进一个系统。

需要统一的是工程身份、关系语义和可验证接口,不是物理存储位置或者工具实现。

真正需要建立的是这样一套结构:

软件由可以独立识别和组合的工程单元构成;单元之间建立能够表达产出因果关系的工程关系;每个单元的工程状态由真正参与结果的输入决定;正式产出绑定对应的工程关系快照;新旧状态之间的差异形成工程变更;实际执行继续产生结果和验证证据,并反过来修正原来的工程模型。

构建、缓存、持续集成、制品管理、供应链和 Agent 不需要因此变成同一个系统。

它们只需要共享同一组已经能够被验证的工程事实,再按照自己的语义使用这些事实。

这并不是建立一个更复杂的元数据世界。

恰恰相反,它是在减少同一件事情被构建脚本、CI、容器镜像、制品库、安全系统、Agent 和人的记忆重复描述。

前两篇文章讨论的是一项变更怎样被理解、验证和处置。

到这里,真正落下来的基础是:

先让工程体系准确保存这个软件为什么成为现在这样,再让每一次变化都沿这些事实被发现、执行、验证和修正。

实现可以重新生成,一次流水线实例可以随时删除,构建环境可以重新建立,容器镜像也可以重新派生。

真正需要长期留下来的,是那些决定结果的工程单元、它们之间可验证的关系,以及现实不断迫使我们修正这些关系的证据。

现在,一个应用从需求描述到完成测试、打包和部署,可能只需要几分钟。

开发者不必逐页查找文档,也不必亲手写完每一段脚本。AI 可以识别项目结构、安装依赖、生成流水线,根据执行结果修复错误,最后把应用部署到一个真正能够提供服务的环境里。

站在完成任务的人的位置上,许多过去熟悉的软件工程工作确实像是消失了。原来要花几天完成的事情,现在可能只剩下一句目标和几轮确认。

这种变化是真实的。大量人工实现和操作会减少,以这些操作为主要价值的岗位也可能随之收缩。平台和 AI 甚至可能真正降低软件生产的总成本,而不只是把同样的工作转移给另一批人。

但这里要讨论的,不是未来还需要多少软件工程师,也不是为了证明软件工程成本一定会上升。真正的问题是:

当一份实现需要长期运行、保存状态、被其他系统依赖,并开始对现实产生后果时,哪些问题会因为实现和部署变简单而消失,哪些仍然需要平台、组织或者自动化机制继续处理?

前一篇文章选择构建工程作为切口,是因为这里很容易看见一个转折:一份可以随时删掉重写的代码,怎样开始变成需要测试、发布、维护并承担后果的软件。

让我们继续往下追:一份可以随时重新生成的实现,怎样开始碰到状态、依赖、权限和责任关系,并因此越来越难被直接替换。

一次成功只说明一条路径走通了

假设 AI 为一个应用生成了部署脚本,拉取运行依赖,完成测试和打包,最后一次部署成功。

这次成功至少说明,在当时的代码、依赖版本、网络条件、权限配置和平台状态下,存在一条能够完成任务的路径。

对于一个短期使用、风险很低的内部工具,这可能已经足够。软件工程并不要求每个临时程序都建立完整的供应链证明、跨环境验证和长期维护体系。应该做到什么程度,本来就取决于软件的用途、寿命和失败后果。

但是,一次成功没有自动回答接下来的问题。

代码修改以后,还能不能得到符合要求的结果?依赖升级会不会改变程序行为?外部服务不可用时,应用会怎样退化?这次成功是否依赖环境中某个没有声明的软件包?部署脚本实际获得了哪些权限?数据能否迁移和恢复?新版本还能不能读取已有状态?

这些问题不一定都需要开发者亲自处理,也不必在第一次部署以前全部解决。但只要软件开始被持续使用,它们就不会因为第一次部署很顺利而消失。

这里容易混淆两件事。

第一件事,是实现并执行一条已知路径的成本正在快速下降。

第二件事,是让软件在持续变化、故障和长期运行中仍然保持可理解、可控制和可恢复的成本,也在同步下降。

前一个变化已经非常明显。后一个变化也可能通过平台化、标准化和进一步自动化发生,但不能只根据一次部署成功得出结论。

平台确实会让一部分工程消失

AI 只靠一句话就能完成部署,并不只是因为模型变聪明了。

它还依赖一个已经被高度工程化的环境。

云平台把计算资源、网络、证书、域名、容器运行、日志、扩缩容和权限控制整理成稳定接口。包管理系统规定依赖怎样描述、解析和下载。应用框架约定项目结构、启动方式和配置位置。CI 系统提供可以反复调用的执行环境。平台 API 又把这些能力组合成可以被程序控制的操作。

AI 面对的并不是一个完全开放、没有结构的现实世界,而是一组经过长期设计、能够接收明确输入并给出明确反馈的工程接口。

平台也不只是把应用团队原来的工作搬到另一层。

一个托管平台统一处理证书签发和续期以后,不需要再为每个应用维护一套相同的人工流程。平台只允许少数经过验证的部署方式时,大量自定义脚本、环境组合和相应故障也会直接消失。

平台最有效的地方,往往不是把一百种部署方式全部自动处理,而是让其中大部分方式不再成为可选项。

应用团队不再管理物理服务器,也不意味着平台团队逐台接管了同样数量的手工操作。标准化、共享实现和多租户平台可以同时降低人工成本和故障率。

但平台没有标准化的关系仍然存在。

数据状态、业务权限、外部依赖、兼容要求和特殊运行条件,不会因为部署接口变简单就自动得到处理。平台本身还可能引入新的供应商依赖、控制面复杂性和不可见的内部状态。

所以,更准确的问题不是“复杂性被搬到了哪里”,而是:

哪些问题已经通过标准化被消除,哪些被共享和摊薄,哪些只是换了处理位置,平台又引入了哪些新的关系?

当同样的 AI 进入旧系统、专有网络、特殊硬件、历史数据和内部安全规则大量存在的环境时,效果往往会明显下降。

这不一定是 AI 突然变笨了。更常见的原因是,这些环境里仍然存在许多没有被标准化的关系:真实依赖没有完整记录,事实来源并不可靠,各个系统之间又有足够大的差异,不值得或者无法被统一成一套接口。

平台能够消除的,是已经被标准化的选择、路径和重复工作。剩下的问题,主要来自某项具体变更与既有状态、依赖、权限和责任之间的关系。

从系统工程的视角看,这类围绕系统技术身份、组成关系及其变更的工作,可以放在技术状态管理(Configuration Management)的谱系中理解。源码仓可以为源码状态提供单一事实来源,但要知道一个制品究竟由什么形成、一次变更又会影响什么,还需要把依赖、工具链、配置、环境以及制品之间的派生关系纳入可追溯、可修正的工程模型。

实现一旦开始碰到这些关系,就不再只是一个可以随时删掉重来的方案。

一份实现怎样变成系统中的一次变更

前一篇文章用“实现可以重写,系统关系不能一键重来”概括了这种不对称。

AI 刚生成的一段代码,通常很容易替换。发现方向不对,可以删掉重做;换一种实现,代价也可能不大。

这里可以把这种还没有被系统接受、仍然能够自由替换的方案称为“候选实现”。

真正困难的部分,出现在候选实现准备进入一个已经运行的系统时。因为这个系统并不是空白的,它已经形成了两类关系。

第一类是已经存在的系统事实。

数据已经按照某种格式写入,某个行为已经部署并被用户使用,组件之间已经建立依赖,接口也已经被其他系统实际调用。这些事实会直接影响后续的兼容、迁移、恢复和验证。

第二类是系统对外形成的承诺。

公共接口、客户合同、交付计划、用户预期,以及监管、认证和合规义务,都可能限制系统以后可以怎样变化。有些承诺甚至会在第一行代码出现以前就已经成立。

系统事实和外部承诺形成的时间并不同步。

一段代码可能已经进入生产制品,但仍然被功能开关隔离。它没有写入数据,也没有被外部用户使用。此时虽然发生了部署,新增的系统事实和外部承诺仍然有限。

反过来,一个功能可能尚未开始实现,但公共接口、交付合同和合规要求已经确定。代码仍然可以自由选择,系统未来必须满足的条件却已经增加。

所以,一次发布并不是所有约束同时生效的分界线。

当一份实现必须连同已有数据、依赖关系、权限边界和外部承诺一起接受影响判断时,它就从候选实现变成了面向既有系统的“候选变更”。

候选变更首先要面对已经存在的事实和承诺。它如果被系统接受,还会继续写入新的数据、建立新的依赖、形成新的行为预期,并留下新的长期责任。

我们可以把一项变更当前需要处理的这些影响称为“变更负荷”。

变更负荷不是代码改了多少行,而是这次修改碰到了哪些已有关系。这些关系是否包含重要状态,能不能快速撤销,影响是否跨越多个系统,又需要多少验证和协调,都会改变实际负荷。

把变更接受以后留下的长期义务称为“承诺成本”。

它可能来自新的数据格式、公共 API、消息协议、权限模型、用户行为预期或者合同义务。以后要继续维护、修改或者退出这些关系,就需要承担兼容、迁移、验证、协调和责任成本。

变更负荷和承诺成本通常有关,但并不是同一件事。

一次内部重构可能需要修改很多底层代码,当前变更负荷很高,但如果它没有改变数据、接口和外部行为,就未必增加多少长期承诺。

一个公共 API 第一次实现起来可能很简单,但一旦被外部用户依赖,未来修改和退出它的成本就可能很高。

今天被接受的变更,会成为明天系统的一部分。它写入的数据、建立的依赖和形成的承诺,会变成下一次变更必须面对的既有条件。

长期软件正是在一次次接受和再次修改中形成历史的。已有关系可以被迁移、退役或者结束,但通常不能无成本清空。

这并不只发生在有固定硬件的系统中。

硬件会带来资源、时序、产品变体和认证限制。纯软件系统虽然没有同样的物理约束,却会逐渐积累数据格式、公共接口、消息协议、权限模型、合同和制度要求。

这些约束形成的原因不同,但都会减少系统以后可以自由选择的空间。

工程的作用因此不只是证明一个实现能够工作。它还要识别已有承诺,避免不必要的关系过早固定下来,把高风险变更拆成可以观察和撤销的步骤,并在必要时保留兼容窗口、迁移路径和退出方式。

这些过渡结构本身也有成本。兼容层、双轨运行和分阶段迁移会增加临时状态、额外观测和旧路径清理工作。只有在它们保留下来的选择空间足够有价值时,这些复杂性才值得承担。

当然,这些机制无法证明需求本身是正确的。把错误的目标稳定、可靠地做进系统,仍然是在做错事情。

“应该做什么”与“怎样把它可靠地带进一个长期运行的系统”,是两个不同的问题。本文主要讨论后一个。

实现变便宜以后,为什么变更处置更容易成为主要成本

AI 最直接的变化,是让候选实现的生成成本下降。

它同样可以帮助分析、测试、调试和运维,但这些工作更多受到已有状态、外部依赖和证据要求的限制。AI 可以快速写出一个数据库迁移脚本,却不能因此消除已有数据。它可以生成一套兼容方案,却仍然需要知道哪些系统实际依赖了旧行为。

前文把工程体系处理这些问题的能力称为“变更处置能力”(Change Handling)

它指的是:工程体系能否看清一项变更会碰到什么,为关键判断建立足够证据,并在该继续、该调整和该停止时作出明确处置。

这里处理的不是一组完全相同、可以按提交数量计算的修改。

一百个彼此隔离、能够自动验证并快速回退的小修改,可能比一次涉及历史数据和公共接口的迁移更容易处置。

反过来,一个发布频率很低的系统,也可能因为一次协议或者数据库格式变更,承担巨大的验证、协调和迁移成本。

多个小变更还可能相互影响。它们单独看都很安全,组合以后却可能扩大依赖版本范围、引入配置冲突,或者同时影响同一批下游系统。

而且,变更负荷通常只能在开始以前粗略估计。真正的构建、验证和运行结果,可能暴露原来没有发现的依赖和失效方式,迫使工程体系重新判断影响范围。

即使工程体系还没有超载,实现成本下降得更快以后,影响分析、验证和风险处置也可能先成为一项变更的主要成本。

过去主要花在写代码上的时间,现在可能更多地花在几个问题上:这次修改究竟影响什么,需要什么证据,失败以后能否恢复,以及它是否应该继续进入系统。

只有当持续到来的变更负荷接近或者超过某类处理能力时,问题才会进一步表现为排队、降低准入标准和长期复杂性积累。

这里还要注意,工程能力并不是一个统一的总容量。

数据库迁移、安全评估、硬件验证和跨团队协调依赖的是不同能力。整个研发体系看起来仍有余量,不代表每一类变更都能被及时处理。

完整地说,变更处置能力包括三件事:

理解候选变更会触动哪些已有关系;为这些影响建立与风险相称的证据;判断变更被接受以后会形成哪些新的事实和承诺,并据此决定接受、拆分、推迟、缩小范围、拒绝或者重新设计。

工程体系不会独自决定所有产品、合同和组织承诺,但它需要把一项变更会怎样兑现、扩大或者违背这些承诺说明清楚,也要阻止未经授权的后果悄然进入系统。

因此,变更处置能力不能用流水线每小时处理多少任务来衡量,也不能简单看放行率或者拒绝率。

如果工程流程只会帮助每项变更寻找通过方式,却没有真实的背压和否决能力,发布数量可能提高,系统承担的长期复杂性也会继续增加。

反过来,如果标准路径只会拒绝真实存在的必要差异,复杂性也不会消失。团队会转向私有脚本、人工操作和平台看不见的例外路径,最终让整个组织更难理解系统实际是怎样运行的。

好的工程体系要让可以接受的变更及时前进,让不合适的变更尽早改变形状或者停止,同时不稀释原有的权限边界和证据要求。

平台和架构会同时影响变更负荷、变更处置能力和未来的承诺成本。

标准化接口、限制可选状态和建立稳定模块边界,可以减少一项修改需要触动的关系。自动测试、执行隔离、影响分析和恢复机制,可以提高工程体系建立证据和处理失败的能力。

更好的设计还会直接改变变更本身的形状,让它更局部、更容易观察,也更容易停止和撤销。这样既能降低当前负荷,也能避免系统形成不必要的新关系。

工程体系需要管理的三类边界

要处置一项变更,工程体系至少需要回答三个问题:

什么可以影响结果?

现有证据究竟证明了什么?

谁有权让这件事继续发生?

这三个问题分别对应因果边界、证据边界和决策边界。

什么可以影响结果:因果边界

因果边界关心的是,哪些输入、状态和外部条件被允许影响制品或者运行结果。

源码、依赖、工具链、配置、网络资源、模型、数据和可调用工具,都可能进入实际的因果链。

把这些输入写进声明,只是第一步。真正重要的是实际执行是否仍然遵守这条边界。

如果构建脚本可以读取未声明的宿主文件,可以直接调用环境中碰巧存在的工具,或者在受控入口之外访问网络,那么声明中的输入就不等于真正影响结果的输入。

Agent 系统也是如此。系统可能声明 Agent 只能使用几种工具,但如果它还能绕过这些工具直接访问其他资源,实际因果边界就已经超出了声明。

因果边界必须能够被执行、观察和验证,不能只存在于配置文件或者设计文档里。

现有证据究竟证明了什么:证据边界

一项测试、评测或者运行记录,并不笼统地证明“这个软件没有问题”。

它只能支持一个更具体的结论:哪个对象,在什么环境和时间下,满足了哪些实际被检查的条件。

首先,证据要绑定到身份明确的对象。

前文强调的“确定的制品”,就是为了这一点:测试、安全检查和发布记录需要说明自己针对的是哪一个制品,以及这个制品从哪里来。让同一个正式制品依次进入测试和发布阶段,是降低对象识别成本的一种常见做法,但这并不意味着制品在物理上只能构建一次。

其次,证据不能在不同对象之间随意转移。

可复现构建解决的是:另一方获得相同源码、相同构建指令,以及项目声明为相关的环境条件以后,能否重新得到逐字节一致的制品。

如果两个阶段产生的是不同制品,就需要说明它们在哪些性质上可以被视为等价。

两个制品功能行为相同,不代表它们的性能、安全属性和来源也相同。分别通过同一组测试,也只能说明它们满足了这些测试实际检查的条件。

证据能够从一个对象转移到另一个对象的范围,不能超过原有证据检查的内容,也不能超过等价证明覆盖的性质。

最后,证据还需要必要的独立性。

同一个 Agent 根据同一份需求理解,连续生成实现、测试和说明,整个过程即使可以追溯,也不等于已经形成了相互独立的证据。

问题不只是是否换了另一个模型。真正需要检查的是,关键证据会不会因为同一个错误假设而一起失效。

对于风险较高的变更,不能让所有证据都依赖同一份需求解释、环境假设、数据来源或者测试判定方式。

对于影响有限、容易发现并且容易恢复的问题,同一个 Agent 的自我检查仍然有价值。证据要求应该与后果相称,而不是机械地追求形式上的独立。

谁有权让它继续发生:决策边界

决策边界关心的是,谁有权接受哪些剩余风险,又有权授权哪些外部副作用。

构建系统、策略引擎和其他自动化机制可以执行已经确定的规则。例如,测试失败就停止发布,权限不足就拒绝操作,金额超过限制就要求额外授权。

但自动化系统不能因为自己找到了一条能够通过的路径,就替真正承担后果的人或组织决定某项风险值得接受。

有权决定项目进度,不代表有权豁免合同、安全或者监管义务。拥有生产环境操作权限,也不代表有权改变所有业务数据。

如果风险最终由谁承担、谁有权决定本身就不清楚,这种不确定性也应该被暴露出来,而不能由流水线或者 Agent 默默替组织作出决定。

这三类边界并不会因为写进文档就自动变得正确。

在遗留系统里,真正危险的往往是没人知道的依赖、无法完整枚举的产品变体,以及只存在于少数人经验中的历史假设。

此时第一步不是假装系统边界已经完整,而是围绕后果较大的变更,通过隔离、观测、影子运行、失败反馈和逐步建模,发现原来没有表达出来的关系。

明确边界不等于已经完全掌握真实系统。它至少要求工程体系把已知的未知、信任假设和验证缺口记录下来,而不是默认它们不存在。

因果边界决定当前能够表达、观察和限制哪些影响。

证据边界决定这些影响被理解和验证到了什么程度。

决策边界决定在仍有不确定性时,谁有资格让变更继续。

三者共同构成一项候选变更被接受、调整、推迟或者拒绝的基础。新的运行结果一旦暴露出未知关系,原有判断也需要重新评估。

AI 系统怎样改变这些边界的表达和范围

AI 没有创造一套完全不同的软件工程问题,但它改变了这些边界的表达方式,也扩大了需要管理的因果范围和现实后果。

交互方式会改变,精确表达不会消失

随着模型能力提高,人通过编程语言逐句描述实现的比例很可能继续下降。

在标准化程度较高的领域,自然语言、图形化描述、示例和已有系统状态,可能成为人与软件生产系统交互的主要入口。代码更多地由 AI 生成,再交给工具执行和检查。

但因果、证据和决策边界不能只存在于一次临时对话中。

一句“把这个应用部署到内网”,对于人来说可能已经表达了主要目标。对于执行系统来说,仍然需要确定部署到哪个环境、使用什么身份、允许访问哪些网络和数据,以及失败以后怎样处理。

AI 可以推断这些条件,但推断本身就是一次实际选择。不同推断可能产生完全不同的权限范围和现实后果。

这也不意味着系统的所有行为都必须固定。

推荐、调度和交互式 Agent 本来就可能允许动态甚至概率性的结果。真正需要稳定表达的,是会影响系统风险、外部副作用、兼容承诺和责任判断的条件。

允许变化的部分,也需要说明变化范围和不可突破的限制。

编程语言可以逐渐退到交互界面的后台,但精确、稳定、可检查的表达能力不能消失。否则,每次执行都相当于重新解释一次意图。

Agent 运行时扩大了因果和证据范围

传统程序的大部分行为可能已经包含在编译后的制品里。

以 Agent 为执行节点的系统,最终行为还可能取决于模型、系统提示、检索数据、可调用工具、外部服务状态、当前时间、会话状态和运行权限。

即使代码和制品完全没有变化,其中任何一个运行时条件改变,都可能得到不同结果。

构建过程和运行时过程都需要管理来源、授权和追溯,但它们不能使用完全相同的保证方式。

封闭构建可以限制输入、隔离环境,并追求确定的制品或者可复现构建。

开放、概率性的运行系统会持续接收外部输入,与不断变化的数据和服务交互,还可能产生无法撤销的外部副作用。即使记录了模型、提示和检索内容,也未必能够重新得到完全相同的执行轨迹。

因此,这类系统需要管理的不是全面确定性。

有些条件必须成为不能突破的硬约束,例如权限范围、金额上限和禁止访问的数据。

有些性质只能通过统计方式判断,例如错误率、危险动作发生率和结果分布。

工程体系还需要提前定义:什么情况下停止高风险动作,什么时候降级到已经验证的路径,何时隔离某项能力,以及哪些变化会触发重新评估。

这不等于任何异常都要停止整个系统。不同后果需要不同的停止和降级条件。

运行时证据也有明确的对象、环境和时间范围。模型升级、外部 API 改变、数据分布变化、权限扩大或者新工具接入以后,过去的测试和评测可能已经不足以支持原来的结论。

证据记录本身还受到隐私、敏感数据和保留成本的限制。目标应该是为具体工程判断留下足够证据,而不是无限记录所有可能影响结果的内容。

Agent 把决策边界直接连接到现实动作

当 Agent 能够搜索资料、修改配置、调用服务和执行多步操作时,模型输出就不再只是文本,也可能成为现实动作的起点。

模型可以提出动作,但不能由它自己决定这个动作是否有资格发生。

系统需要预先规定,哪些来源和通道具有指令权。即使不可信内容影响了模型提出的动作,也不能因此获得新的执行资格。

具体工具调用及其参数,仍然应该经过模型之外的权限和策略判断。这套判断不能被同一个 Agent 任意修改或者绕过。

策略执行机制不必先证明一段自然语言在语义上“不是指令”。它需要保证,无论模型怎样理解这段内容,都不能因此突破已经确定的能力、参数和副作用边界。

当然,外部策略本身也不会天然正确。它同样需要版本化、测试、观测和审计。

它的价值在于把动作生成与动作授权分开,避免同一次模型误判同时控制目标解释和最终授权。

权限正确也不等于动作正确。

一个 Agent 可以在合法权限内误解目标,也可能因为重试而重复执行一个不能安全重复的操作。风险取决于它的自治程度、权限范围、行动频率、影响范围和可撤销性,而不只是模型看起来有多聪明。

外部机制不能证明业务意图本身正确,但可以限制错误动作的范围,为高后果操作增加独立判断点,并阻止一次模型误解直接变成不可控制的现实后果。

AI 降低了提出和执行动作的成本,也让工程体系需要管理的因果、证据和决策边界,从发布前的变更控制延伸到了持续运行过程。

哪些工作会消失,什么时候不需要更多工程

讨论软件工程还在解决什么问题,没有必要否认大量工作会被自动化。

按照文档组合已有组件,编写常见部署脚本,修复普通依赖问题,根据日志反复调整环境,这些工作已经越来越适合交给 AI。成熟平台提供足够明确的接口以后,人甚至不需要理解其中的大部分实现细节。

如果一个岗位的主要价值长期停留在这些操作上,它确实可能被压缩,甚至不再需要。

软件仍然需要可靠性、权限控制和恢复能力,也不能因此推出每个应用团队都要保留原有数量和分工的岗位。

这些能力可以集中到平台层,也可以通过共享机制和统一控制降低每个应用的边际成本。很多过去由各个团队分别承担的工作,会被平台一次性解决。

同样,并不是每个软件都值得建立完整的控制机制。

一个寿命很短、没有重要状态、影响范围有限、失败以后可以直接删除重来,也不需要兼容旧版本的工具,采用很轻的工程控制可能就是合理选择。

工程不是把每个系统都做得尽可能复杂,而是根据寿命、状态、依赖、权限和失败后果,决定哪些关系值得被明确管理。

临时工具真正危险的地方,通常不是它一开始少做了工程,而是它在被长期使用、开始保存重要状态或者获得更高权限以后,仍然继续按照临时工具对待。

随着软件承担的关系和现实后果增加,原来可以忽略的条件会逐渐变得需要表达、验证和控制。

结语

AI 和平台可以真实降低,甚至消除大量实现、测试和部署成本。软件工程不需要把一句目标重新展开成更多人工步骤,来证明自己仍然存在。

当实现和部署不再稀缺,软件工程更需要处理的是:一份可以随时重写的实现,怎样成为面向既有系统的一次变更;它会触动哪些已有事实和外部承诺;被接受以后,又会留下哪些新的长期关系。

工程体系需要理解这项变更当前带来的负荷,判断它会形成什么新的事实和承诺,并通过因果、证据和决策边界,决定应该接受、调整、推迟还是拒绝它。

实现可以越来越容易重来,但一次接受所形成的关系,会成为下一次变更必须继续面对的历史。

软件工程要管理的,正是这段历史怎样形成、怎样改变,以及它的后果最终由谁承担。

假设一个产品系列使用了多种处理器、不同容量的内存和几套外围硬件。它们共享大部分代码,但工具链、配置选项和最终制品并不完全相同。现在要修改其中一个公共模块,AI 很快就能理解接口、生成代码并补上测试,过去几天的工作可能缩短到几十分钟。

但对构建团队来说,工作才刚刚开始:这次修改影响哪些产品?旧型号还能不能编译?不同工具链产生的制品是否仍然满足相同的验收要求?新增的依赖能不能用于所有硬件平台?本地构建成功,是因为声明完整,还是因为开发机器上刚好安装了某个软件包?

AI 降低了得到一份实现的成本,却没有自动回答这些问题。实现越容易产生,工程体系越需要有能力判断:哪些变更可以进入系统,会引起哪些构建和重建,又该怎样验证。构建之外的功能、安全和运行影响,还需要其他工程环节共同判断。

实现可以重写,系统关系不能一键重来

AI 降低的不只是编写第一份代码的成本,也降低了修改、推翻和重新尝试的成本。让它换一种实现、去掉一个依赖或者保留旧接口,通常只需要补充几条要求。尚未进入系统的代码,越来越像一种用于探索问题的中间产物,而不再天然代表已经形成的工程承诺。

但是,这种低成本只存在于变更还没有深入系统的时候。

一项实现一旦进入系统,就会逐渐与其他组件、接口、数据、工具链、运行环境和交付要求建立关系。这些约束并不按行业整齐分开:通信设备既有 CPU 架构、内存规格、厂商 SDK 和产品型号这样的显性边界,也要持续演进 API、协议和运行环境;大型互联网系统虽然通常没有同样固定的硬件产品组合,却同样受基础镜像、语言运行时、数据库结构、公开 API 和消息协议等约束,其中不少一旦被大量服务依赖,也会成为不能随意突破的边界。

因此,AI 可以很快重写一个公共模块,也可以很快修改一个服务,但成本已经不再只来自实现本身。一个底层组件的变化可能牵动几十种硬件组合,也可能沿着公共库、接口和服务依赖传播到大量软件系统。行业不同,约束的形状不同,变化的节奏和验证方式也不同;真正共同的问题,是如何在这些既有关系中处置变更。代码可以很快重写,已经形成的系统关系却不能一键清除。数据库格式、公开接口、硬件协议、编译工具链、测试资产和产品维护承诺,都会让后续修改承担相应的兼容、重建和验证成本。

这里还有一个容易忽略的不对称:重新生成实现的成本下降了,理解实现的成本未必下降。AI 可以很快重写一个模块,但评审者仍然需要理解它改变了什么隐含行为,为什么引入新的抽象,是否增加了故障模式,以及其他产品配置会不会受到影响。

生成速度越快,理解、验证和集成在总成本中的占比反而可能越高。

为什么验证不会自然地以同样速度变便宜

既然 AI 可以生成代码,当然也可以用 AI 评审代码、编写测试和分析影响。那么,生成和验证是不是可以一起变便宜?

AI 确实能降低一部分分析和验证成本,但完成一项实现与正式接受一项变更并不是同一件事。对 AI 而言,完成实现通常意味着找到一条能够满足当前要求的路径;对工程体系而言,接受这项变更,还需要针对重要的失效方式取得足够证据。

例如,一个嵌入式驱动在默认开发板上编译通过,测试也没有报错,并不能说明它已经适用于整个产品系列。某些型号可能使用不同的编译器版本,某些型号的内存空间更紧张,还有一些配置可能启用了不同的中断、并发或者低功耗路径。这些差异平时不一定出现在 AI 获得的局部代码上下文中,却可能决定最终制品能不能使用。

这并不意味着所有产品组合都必须在每次修改后完整验证。工程仍然需要根据影响范围和失败后果选择验证强度。影响有限、容易发现并且可以恢复的变更,可以采用较轻的验证方式;涉及公共底层模块、硬件接口或者安全边界的变更,则需要更充分的证据。

AI 可以参与实现,也可以参与验证,但不能因为同一个 AI 智能体(Agent)同时生成了代码和测试,就认为这些测试已经提供了独立证据。它可能在两个阶段都沿用了同一个错误假设。

所以,AI 能够降低验证成本,却没有消除验证责任。实现的生成速度和工程体系接受变更的速度,仍然受不同条件约束。

真正的瓶颈是怎样处置变更

AI 不一定会让所有行业更快地发布软件。在车规、通信设备、工业控制和大型遗留系统中,产品准入和验证流程仍然可能保持原来的节奏。真正发生变化的,是更多实现和修改能够以更低成本到达工程体系的入口。

如果一个大规模软件项目同时使用多个编码 Agent,它们可以很快修改不同模块。单独看,每项修改可能都很合理;组合起来,却可能增加依赖数量、扩大工具链版本范围、引入重复抽象,或者让构建和测试矩阵不断膨胀。代码生成能力越强,验证队列反而越容易成为瓶颈。

因此,工程体系需要的不只是更快的流水线,还需要持续处置变更的能力。这里把它称为“变更处置能力”(Change Handling)。它不是更快地把更多修改处理完,而是理解变更影响、建立与风险相称的证据,并形成接受、拒绝、推迟、缩小范围或重新设计等明确的处置决定。

例如,AI 为一个公共模块引入了新的软件包,但这个软件包只支持部分产品使用的工具链。合理的处理方式未必是让构建团队想办法把所有平台都适配过去,也可能是要求重新设计,把新依赖限制在真正需要它的范围内。

能及时阻止一项不合适的变更,同样是工程能力。否则,所谓提高交付速度,只是把今天节省的时间变成未来需要偿还的复杂性。

“变更处置能力”也不应该成为新的产能指标。生成了多少代码、创建了多少合并请求、执行了多少次构建,都不能单独说明工程效率提高了。真正重要的是:工程体系能否在证据不被稀释、责任不被模糊、复杂性不持续失控的情况下,对这些变更形成明确处置。

工程的平衡从边界开始

要判断一项变更应该接受、调整还是拒绝,前提是知道哪些条件可以权衡,哪些边界不能突破。

技术选择很少在所有方向上都更好。提高性能可能增加复杂性,扩大兼容范围会提高验证成本,更严格的隔离也可能降低开发便利性。工程不是追求所有指标同时达到最好,而是在现实约束中寻找合适的平衡。

但平衡不等于什么都可以交换。不可突破的边界一旦明确,权衡才发生在剩余空间里。

在嵌入式和车载系统中,产品侧的资源预算、实时性和安全要求,不能因交付压力而突破;构建侧已经确定的执行隔离、工具链范围和依赖约束,也不能仅仅为了缩短构建时间而被关闭。纯软件系统同样存在不能随意交换的边界,例如已经对外承诺的接口兼容性、数据格式、运行平台要求和合规约束。边界的具体形态会随产品而不同,但一旦成为明确的产品或工程约束,就不能再简单拿它们与开发进度交换。

在这些边界以内,仍然存在大量需要平衡的选择。例如,是每次修改都验证所有硬件规格,还是先根据影响范围选择必要的构建和测试集合?是继续支持一个使用量很低的旧工具链,还是把它冻结到维护分支?是扩大缓存提高速度,还是减少缓存共享来增强隔离?

这才是“工程是一门妥协的艺术”的准确含义。妥协不是为降低标准寻找理由,而是在不可突破的条件下,明确选择保护什么、放弃什么,并承担相应的代价。

风险接受权本身也有边界。项目不能因为进度紧张,就通过一次内部批准把法律、监管、合同或者安全要求重新定义成普通的技术选项。有权决定项目计划,不代表有权豁免外部义务。

构建工程要控制什么能够影响制品

当实现供应越来越快,只依靠工程师记忆产品差异、手工检查环境和逐项维护流水线,很难持续处理不断增长的变更压力。已经明确的工程要求,需要尽可能变成可以重复执行的系统能力。

本文所说的构建工程,不只是运行一次编译命令,而是围绕制品生产形成的构建与验证体系。它包括依赖关系、工具链、执行环境、测试、打包、制品来源和准入过程,关心的是一次代码变更怎样成为可以交付的制品,以及这个过程能否被解释和验证。完整的业务决策和生产运行治理,则不属于本文讨论的构建工程范围。

做好这件事,至少需要同时解决两个问题。

第一个问题是说清楚哪些因素允许影响制品。源码、第三方依赖、编译器、系统软件、构建参数、硬件规格和产品配置,都可能改变最终结果。对于拥有多个产品系列的团队来说,“同一份源码”远远不足以确定一个制品,目标硬件、功能选项和工具链同样是输入。

第二个问题是确保在明确的构建信任边界内,只有这些被声明和授权的输入能够参与构建。构建边界之外仍不可避免地依赖的平台、硬件和服务,都应被明确列为信任假设。依赖声明写得再完整,只要构建脚本仍然可以随意读取开发机器上的文件、调用未声明的工具或者访问外部网络,实际结果就仍然可能被环境中的偶然因素改变。

一个常见场景是:某位工程师的机器上已经安装了厂商 SDK,构建脚本通过默认搜索路径找到了它,所以本地一直能够成功。换到另一台机器或者 CI 环境后,构建突然失败。更麻烦的情况是,两边都能构建成功,却因为找到的 SDK 版本不同而产生了不同的制品。

这里需要限定一下:构建系统擅长计算的是构建和重建影响。例如,一项输入变化后,哪些组件以及哪些产品配置对应的制品需要重新构建;根据预先建立的验证规则,哪些相关验证需要重新执行。至于功能行为、安全风险和运行影响,还不能只靠构建图判断。

这类问题不能只靠提醒开发者“记得声明依赖”解决。执行环境必须真正限制构建能够读取的文件、工具和网络资源。如果构建需要未声明的输入,它应该直接失败,让缺失的依赖暴露出来,而不是悄悄使用当前机器上碰巧存在的内容。

可以把这两个条件理解为“说清楚”和“管得住”:前者明确哪些输入可以影响制品,后者阻止未声明的输入影响构建结果。只有声明而没有执行限制,工程系统仍然依赖自觉;只有隔离而没有清楚的声明,系统又很难解释为什么能够构建。

后续工程首先需要一个确定的制品

过去一个常见做法是,开发团队构建一份软件用于自测,测试团队拿到相同代码后重新构建,上线前又在发布环境中构建一次。表面上三次使用的是同一份代码,实际却经过了三套可能存在差异的工具链、配置和系统环境。开发验证过的、测试通过的和最后上线的,未必是同一个软件制品。

即使团队统一了研发环境,也很难保证环境长期完全一致。一次工具升级、一项配置调整,或者机器上多安装了一个软件包,都可能改变构建结果。源码没有变化,并不代表制品没有变化。

因此,构建工程首先要解决的是确定性:相同的输入应当得到相同的输出。更进一步,面向交付的正式制品应当具有稳定、可验证的身份。通常可以让同一个制品依次进入测试、安全检查、发布和部署;如果在不同阶段重新构建,也必须能够证明它们使用了相同的输入并得到了相同的输出,而不能仅仅因为源码相同,就把它们视为同一个软件制品。

这一步是后续工程实践的基础。测试团队需要知道自己验证的就是最终交付的制品;安全和合规检查需要绑定到确定的制品;发布系统需要准确记录它的来源;运维系统才能可靠地部署、回滚和追踪它。连制品本身都不能确定,后面的证据和控制就失去了稳定的对象。

构建工程因此处在一个承上启下的位置:它承接研发产生的源码、依赖和配置,把它们变成确定的制品;另一边则连接质量验证、供应链安全、产品准入、发布部署和运维管理。它不是这些控制面的替代者,却为它们提供了共同工作的基础。

不是所有要求都能变成构建规则

确定的制品为后续验证提供了稳定对象,但它并不意味着所有工程属性都能由构建系统自动证明。

构建工程能够把很多要求转化成机器可以执行的控制,例如依赖版本、工具链范围、访问权限、制品大小、接口兼容检查和必须通过的测试。但并不是所有工程边界都能由构建系统独立证明。

例如,一个通信设备在特定负载下是否能够长期稳定运行,可能需要真实硬件和长时间测试;一个控制系统在异常条件下能否正确降级,可能需要台架或者系统级验证;某些安全要求还依赖部署方式和运行流程。

成熟的工程体系不应该假装只要策略语言足够强,所有要求最终都能写进流水线。构建工程真正需要做的是,把能够机器化的部分转化成不能轻易绕过的控制,同时明确暴露哪些条件仍然依赖人工评估、专门测试或者运行环境中的证据。

“无法自动验证”本身也应该是一项可见的工程事实,而不应该被隐藏在某位资深工程师的经验里。

Agent 越聪明,能力边界越重要

Agent 让执行边界变得更加重要,而不是不再重要。

假设 Agent 在构建过程中发现缺少一个头文件。为了尽快完成任务,它可能从网络下载一个软件包,也可能调整搜索路径,使用宿主机上已经安装的版本。从 Agent 的角度看,它成功解决了构建问题;从工程角度看,它可能绕过了依赖声明,引入了一个无法稳定复现的环境条件。

Agent 还会读取仓库里的说明文档、问题记录和测试日志。这些内容既可能包含有用信息,也可能包含错误甚至恶意指令。即使最终代码看起来合理,也不能据此相信整个执行过程只使用了允许的输入。

因此,Agent 应当像其他高能力执行主体一样受到最小权限、输入隔离和行为审计。它能够解释自己做了什么,不代表这些解释可以代替系统证据;它表现得越智能,越不能只依赖它自觉遵守边界。

AI 能力扩大了自动化可以完成的工作范围,也放大了错误操作可能造成的影响。能力越强,权限和执行边界越需要由外部系统控制。

构建系统提供证据,但不替组织作出决定

即使依赖声明完整、执行环境封闭、所有构建和测试都通过,也不能由此证明一项工程选择本身一定明智。构建系统负责执行已经确定的验证要求,并记录哪些已经完成、哪些仍然缺失。至于什么样的证据才算足够,仍然需要相关工程团队根据产品用途和风险作出判断。

例如,升级一套公共工具链以后,构建系统可以计算哪些产品需要重新生成制品并执行相应验证,同时记录新旧结果。它还可以发现某些旧硬件无法通过验证。但是否停止维护旧型号,是否扩大验证范围,或者是否接受某项剩余风险,并不是构建系统能够自行决定的事情。

“接受”不是流水线自动计算出的技术真理。相关专业人员需要先说明变更的技术后果。随后,再由具备相应授权的责任主体,结合用途、适用环境、关键假设、剩余风险和现有证据作出决定。同一份制品可以用于内部实验,却未必具备用于正式产品发布的证据。

反过来,构建系统也不应该只是帮助项目寻找通过方式的工具。当一项约束无法落实、验证成本远超预期,或者新的结果推翻了原有假设时,工程流程应当迫使原来的决策重新进入评估。

例如,一次看似普通的公共组件升级,经过影响分析后发现需要重新验证几十种硬件组合。正确的处理方式不一定是继续增加机器、缩短测试或者申请跳过检查,也可能是重新讨论升级范围,拆分变更,或者延后部分产品的迁移。

决策产生约束,工程系统落实并验证约束;执行产生的新证据,又可能迫使决策重新评估。这种反馈能力很重要。没有真实的背压和否决机制,流水线就只是自动化手续,而不是工程控制。

构建工程师在建设什么

成熟的构建工程早已不只是维护 Makefile、Shell 脚本和 CI 配置。依赖图、增量构建、缓存一致性、工具链管理、跨平台兼容、可复现性和软件供应链安全,本来就是构建团队长期处理的问题。

AI 不会突然把构建工程师从脚本编写者提升成约束管理者。更准确的变化是:AI 会减少其中大量局部、手工和经验驱动的操作,同时放大构建工程原本承担的系统性责任。

这里需要区分人和系统。构建工程师不是控制面,构建工程才是自动化软件生产体系中的关键技术控制面。它不是全部软件治理,而是通过依赖关系、执行隔离、权限规则、测试要求、制品准入和证据记录等机制,把架构、安全和质量等方面能够自动执行的要求落实到软件生产过程中。

构建工程师负责设计、维护和演进这套机制。他们把安全、架构、产品维护和组织风险要求中能够工程化的部分,转化成生产过程里不能轻易绕过的规则;对于无法自动落实或者无法自动验证的部分,则负责把缺口明确暴露出来。

因此,构建工程师的价值不在于亲手完成每一次构建,也不在于不断为每个新产品复制一套流水线,而在于建设一套能够持续处理产品差异、环境变化和大规模自动化变更的工程体系。

过去,一些依赖关系和产品差异还可以依靠团队经验维持。AI 让实现和修改更快地出现以后,这种依靠人脑和临场处理的方式会越来越难以跟上。构建工程的重要性不是因为脚本变得更难写,而是因为整个软件生产过程越来越需要明确、稳定且不能被轻易绕过的控制。

结语

AI 可以比过去更快地产生变更,却不能自动让这些变更变得可接受。代码容易生成,不代表影响容易理解;实现容易重写,不代表系统变更容易撤销;Agent 能够自我检查,也不代表它可以自我证明。

工程的目标不是阻止变化,也不是建立一个永远不会失败的系统,而是让已知风险受到明确控制,让未知失效更难扩散、更容易暴露和追溯,并让组织在事故发生以后仍然能够恢复、学习和继续演进。

对于构建团队来说,AI 时代真正需要建设的,不是更多由人手工维护的脚本,而是一套能够持续回答这些问题的工程体系:

哪些因素可以影响制品?一项修改会使哪些产品和硬件配置对应的制品需要重新构建?预先要求的验证是否已经完成?哪些条件仍然无法自动证明?当证据不支持原有决定时,系统能不能真正阻止相关变更继续向前?

实现越容易产生,接受实现的条件越应该明确;自动化能力越强,边界、证据和否决能力越不能依赖临场判断。

在云原生大行其道的今天,如果一名架构师说:“我不想用 Kubernetes,我想直接用 Linux Systemd 部署”,很多人可能会觉得他在开倒车。

我们沉迷于 Kubernetes 提供的服务发现、负载均衡和自愈能力;我们习惯于将域名解析、CDN 甚至安全策略全盘托付给 Cloudflare。这些工具像魔法一样掩盖了底层的复杂性。

但这种便利往往伴随着一种隐秘的焦虑:如果这些魔法失效了怎么办?

这不是杞人忧天。当 Cloudflare 发生全球性宕机,控制台无法访问,甚至无法修改 NS 记录将流量切走时,我们除了看着 503 错误发呆,什么也做不了。当 Kubernetes 集群的 etcd 数据损坏且无法恢复,我们是否还有能力在几台裸金属服务器上,仅凭标准 Linux 指令让业务跑起来?

Kubernetes 应该是我们的一个选项,而不应是唯一的生存土壤。 真正的健壮性,来源于应用对环境的“低依赖”以及对原生能力的“高兼容”。

那些让我们陷入“温水煮青蛙”的强依赖

除了 Kubernetes 和 Cloudflare,现代技术栈中还有太多让我们丧失“原生生存能力”的陷阱。我们需要审视以下几类典型的强依赖:

1. Serverless 与 FaaS 的“代码级锁定”

AWS Lambda 或 Vercel Functions 极大地简化了运维,但它们往往要求代码按照特定的 Handler 签名编写,并依赖特定的触发器(Trigger)机制。

  • 风险: 你的代码不再是一个标准的 HTTP Server,而是一个只能在特定云厂商运行时里跑的“碎片”。一旦云厂商涨价或改变策略,迁移成本极高,因为你无法直接将这些碎片扔进一台普通的 Docker 容器里运行。
  • 原生替代思考: 应用应当首先是一个标准的 Web Server(如 Go 的 net/http 或 Python 的 WSGI/ASGI)。FaaS 应该只是最外层的一层薄薄的适配器(Adapter),而不是代码的骨架。

2. 专有数据库服务(Vendor-Specific DBs)

使用 AWS DynamoDB、Google Firestore 或 Azure Cosmos DB 非常诱人,它们提供了无限的扩展性和极简的 API。

  • 风险: 与 MySQL 或 PostgreSQL 这种基于标准协议的开源数据库不同,专有数据库的 API 是独占的。一旦你的业务逻辑深度耦合了 DynamoDB 的 Single Table Design,要想迁移到自建的 SQL 或 NoSQL 环境,基本等同于重写整个数据层。
  • 原生替代思考: 是否设计了良好的 Repository 模式?如果脱离了云厂商的 IAM 认证和专有 API,你的应用还能连接一个本地运行的标准数据库吗?

3. 身份认证即服务(IDaaS)

Auth0、Okta 或 AWS Cognito 接管了我们的用户系统。

  • 风险: 如果服务商封禁了你的账户,或者发生服务中断,你的所有用户瞬间无法登录。更可怕的是,用户数据并不在你手中,导出和迁移用户哈希数据的难度极大。
  • 原生替代思考: 即使使用 IDaaS,是否保留了同步用户数据到本地数据库的能力?应用是否支持标准的 OIDC/OAuth2 协议,以便在紧急情况下切换到开源的 Keycloak 或自研的简单的 JWT 服务?

“原生回退”:不仅仅是备选方案,更是架构试金石

你可能会问:“维护两套完全不同的部署逻辑(如 K8s 和 Systemd),成本不是翻倍了吗?”

恰恰相反,这种差异性正是检验架构解耦程度的最佳试金石。

如果我们强制要求应用必须具备在“原生操作系统”上运行的能力,这会迫使我们思考很多本质问题,从而优化整体架构:

1. 服务的发现(Service Discovery)

  • K8s 依赖: 代码里直接写死 http://my-service,依赖 K8s 的 CoreDNS 和 iptables 转发。
  • 原生思考: 如果没有 K8s DNS 怎么办?这迫使我们在代码层面支持配置化的服务地址。在裸机模式下,它可能是 localhost:8080 或配置在 /etc/hosts 中。应用不应假设网络拓扑,而应通过配置感知拓扑。

2. 配置与密钥管理(Configuration & Secrets)

  • K8s 依赖: 极度依赖 ConfigMap 和 Secret 挂载,甚至依赖 Sidecar 动态注入。
  • 原生思考: 如果没有 Sidecar 怎么办?这促使我们回归标准:环境变量(Environment Variables)。这是最通用的标准,无论是 K8s、Systemd、Docker 还是 Shell 脚本,都原生支持环境变量。

3. 负载均衡与入口(Ingress)

  • Cloudflare/Ingress 依赖: 依赖边缘节点的路由规则和 SSL 卸载。
  • 原生思考: 如果外层壳子没了,我们是否有一个标准的 Nginx 配置文件作为备用?这要求我们的路由规则不能过于依赖特定厂商的非标准语法(如 Cloudflare Workers 里的特殊逻辑),而应尽量保持在 HTTP 协议的标准范畴内。

构建“逃生舱”策略

为了避免“傻眼干等”,我们需要将“备选方案”从文档变成可执行的代码。

1. 定义“最小生存环境”

不需要在备选方案中复刻 K8s 的所有高级功能。我们需要定义一个“最小生存环境(Minimum Viable Environment)”:

  • 计算: 标准 Linux VM 或裸金属服务器。
  • 进程管理: Systemd 或 Supervisord(操作系统原生能力)。
  • 网络: 标准 DNS 解析,Nginx 反向代理。

2. “双模”部署验证

不要让备选方案只停留在理论上。可以采取以下策略:

  • 开发环境原生化: 强制开发人员在本地(非 K8s 环境)直接运行二进制文件或简单容器。如果开发者本地跑不起来,说明对平台依赖过重。
  • 灾备演练: 定期(如每季度)尝试在一个完全隔离的、没有 K8s、没有 Cloudflare 的纯净 Linux 环境中部署全套系统。

3. 基础设施即代码(IaC)的抽象

使用 Terraform 或 Ansible 时,尝试将“应用逻辑”与“基础设施胶水”分离。

  • Plan A (K8s): Terraform -> AWS EKS -> Helm Charts.
  • Plan B (Native): Terraform -> EC2/DigitalOcean Droplet -> Ansible Playbook (安装 Systemd Service)。

回归朴素的技术价值观

我们并不反对 Kubernetes,也不否认 Cloudflare 的伟大。我们反对的是“无意识的依赖”

拥有在原生操作系统中部署运行的能力,是一种底气。这种底气意味着:虽然我选择了云,但我拥有随时下云的自由;虽然我使用了复杂的编排工具,但我从未忘记如何用最基础的命令让代码跑起来。

当我们将应用与基础设施解耦,减少对特定运行时环境的黑盒依赖时,我们得到的不仅是一个可用的灾备方案,更是一个结构清晰、边界明确、生命力顽强的软件系统。

柴锋(Odd-e)

在现代软件工程的复杂世界里,“依赖管理”这个词汇对开发者来说再熟悉不过。我们习惯于在 package.jsonpom.xml 中审视那些明确列出的软件包,认为这就是依赖管理的全部。然而,这仅仅是冰山一角。一个真实的、贯穿始终的故事,将揭示冰山之下那片更广阔、也更危险的水域,它最终将引向一个更深层次的命题:在大规模软件系统中,“信任”究竟从何而来?

一场关于“信任”的三重考验

这个故事,我是亲历者,也是全程的参与者。2018年,一家通信设备公司面临某国政府公开对其产品存在“后门”的尖锐质疑。为了自证清白,公司开启了一场长达两年的马拉松式技术验证,这期间经历了三轮不断升级的严苛考验。这段经历不仅是本文探讨问题的缘起,更是一次宝贵的契机——我与客户方一位核心技术管理者在共同应对挑战时,因对工程哲学的深刻共鸣而开启了此后几年的深度合作研究。故事,就从这里开始。

第一重考验:源码审计

要证明产品安全,最直接的办法就是审查源码。于是,公司将产品的全部源码提交给对方政府,由其联合多家顶级安全与软件公司进行长达半年的逐行审计。最终的结论是:源码是干净的,未发现任何后门。

然而,第一个问题随之而来:源码是安全的,产品就一定安全吗?听起来答案显而易见,但事实远非如此。

第二重考验:构建复现

质疑方很快提出了一个更深入骨髓的问题:“我们承认源码是干净的,但我们部署在网络里的产品,真的是用这份源码构建出来的吗?”

这是一个极为合理的疑问,直指“阴阳源码”的可能性。为了打消顾虑,公司必须在对方完全控制的基础设施中,重现整个构建过程,且最终产物必须与线上部署的产品做到“比特级别一致”,就算有差异,也必须是可以解释的,比如由时间戳、文件路径、排序等引起的差异。这是一个近乎疯狂的任务,因为复现一个规模庞大、技术栈复杂的系统本就困难重重。但经过又一个半年的努力,构建成功复现,证明了产品与源码的一致性。

第三重考验:终极质询

源码可信,产品由源码构建,这下总该放心了。但对方抛出了第三个,也是最致命的问题:“你们怎么证明,在如此复杂的构建过程中,每一个步骤都是安全、可审计的?没有任何机会可以悄无声息地引入风险和后门?”

这个问题直指现代软件工程的核心——生产过程。它不再纠结于可见的源码,而是拷问那些看不见的环节:构建脚本里有没有隐藏任务?构建过程中用到的所有工具,有没有可能故意修改源码或结果?这并非杞人忧天,2024年的 “xz 后门事件” 就是一个血淋淋的教训:攻击者没有修改源码,而是通过修改构建脚本注入了漏洞,成功绕过了源码审查。如果不是被意外发现,全世界大量的网络服务器都将门户大开。

为了回答这个终极问题,公司又投入了约一年的时间,最终证明了其构建流程的每一步都是安全的。故事的结局出人意料,但也在意料之中,尽管公司证明了一切,对方政府依然决定在未来几年内全面替换其产品。

从“信任”到“工程化信任”

看似两年的巨大投入付诸东流,但该公司高层却认为这是一次宝贵的学习机会,是一场深刻的公司内部革命。他们甚至想感谢当初提出这些问题的审查机构负责人,因为正是这些问题,帮助公司照见了自己工程体系中最深层次的挑战和不足。

为此,他们在前后5年里累计投入约20亿美元,专门用于提升软件工程能力。他们得出一个核心结论:在大规模、长周期的复杂系统中,“信任”早已不是一种感觉或承诺。信任,本身必须被设计和实现出来。信任,是一种工程能力。

而构建这种“工程化的信任”,其核心,就是本文真正要讨论的主题:依赖管理

重新定义问题:看见依赖的整座冰山

一提到依赖管理,我们脑海里浮现的通常是 package.json 里的 dependencies,或是 pom.xml 里的 dependency 标签。我们知道,这些依赖的变更会影响产品的功能和构建,它们的错误或缺失会导致功能异常或无法构建。

但这,就是依赖管理的全部了吗?难道只有这些变化才会影响软件制品及其构建过程吗?

让我们想一想,你用的哪个版本的编译器(这是 工具链依赖 )、一个细微差异的环境变量(这是 环境依赖 )、Makefile 里的编译选项(这是 配置依赖 ),甚至用于代码生成的模板文件(这是 数据依赖 )。这些看不见的“隐性”依赖,和代码变更一样,都能导致产品失败,或者引入致命的漏洞。它们的变化对产品功能和构建产生的影响,与那些软件包是一样的。既然随着研发的进行,这些要素也会变更、升级,那为什么不能也叫它们是依赖呢?

因此,我们必须将视角拉高,站在软件“生产”的全过程来看,并引入一个更为宏大的概念——“广义依赖”

“广义依赖”是指所有会影响软件生产过程和最终结果的要素。这不仅包括我们熟知的代码依赖(可称之为“狭义依赖”),更涵盖了以上提到的所有“隐性”依赖。

这个“广义依赖”网络,在真实世界里到底有多复杂?在一个真实的、拥有超过8400万行代码、由1500多名开发者维护超过10年的大型通信产品中,其“广义依赖”网络错综复杂,仅仅是其构建视图的15%就足以让人望而生畏。

之所以要从这样一个巨大规模的案例来看,是因为随着规模的增长,很多原本细小琐碎、经常被我们无视的问题,就会被指数级放大。大规模研发场景就像一个放大镜,帮助我们看清问题背后的本质。

当这个“广义依赖”网络变得如此庞大且大部分不可见时,我们日常工作中的那些痛点就来了。这些看似混乱的现象,可以归结为三大核心痛点:

  1. 变更扩散路径不清晰:底层一个库的小改动,到底会影响谁?影响范围有多大?没人能完全说清楚,精确评估影响范围成了一个不可能的任务。至于开发环境、构建环境的变更所带来的影响,则常常被忽视。一次看似平常的工具链升级,就可能让整条流水线停摆,故障不断。
  2. 集成滞后且脆弱:我们的CI/CD充满了不确定性,“本地能跑,CI挂了”或“CI出错了,本地无法复现”的场景屡见不鲜。随着规模增加,我们不得不在“快但不完整”和“完整但很慢”之间痛苦地选择。我们的集成过程,充满了大量的人工干预,以及……祈祷。
  3. 构建效率低下:一次完整的发布构建,需要7到8个小时。这意味着开发者提交代码后,要到第二天才能知道结果。为了提速,我们只好退而求其次,每次提交代码只做局部的集成和测试。但这样会把更大范围的集成问题,推迟到每个月发布版本的时候。每次整体集成时,团队都要花上一周甚至更久的时间来解决这些累积的集成问题。

必然的演进:从“黑盒遍历”到“显性工程化治理”

面对这样的困境,我们必须寻求演进。在小规模时,我们依赖构建工具的“黑盒遍历”模式。比如我们运行 make,它会自己扫描文件,计算和发现应该做什么。这很直接,也很有效。但随着规模增长,哪怕你一行代码都没改,仅仅是“全局扫描”这个动作本身,就成了巨大的浪费,是研发流程的最大瓶颈。

在一个有大约2000名开发者、使用 Bazel 构建的有数百万行代码的项目中,“黑盒遍历”的瓶颈也暴露无遗。这里有两个巨大的时间开销:首先,哪怕你一行代码都没改,仅仅是启动构建后,Bazel 对整个代码仓进行扫描分析,这个“全量扫描”阶段,就需要6到8分钟。除此之外,为了利用缓存,系统还需要下载约40G由大量小文件组成的中间制品,光是下载就需要30多分钟。大家可以想象一下,在CI流水线上,有上千个构建节点同时运行,并发地进行着全量扫描,又同时去下载这40G的缓存,这常常会引发网络风暴,直接拖垮网络,让所有人的构建时间都变得更长,而且极不稳定。

所以,新的模式是必然的选择,那就是“显性工程化治理”。简单来说就是“分而治之”。它的核心思想非常简单:我们不能再被动地依赖工具去“发现”了,我们必须主动地、显式地去定义和管理 我们的工程体系。我们要把过去隐性的东西,全部显性化。

这种“分而治之”,和我们过去为了提速而做的“局部集成”,有着本质区别。过去的局部集成,往往依赖开发者的直觉和手动选择,但这很容易造成影响范围的错误评估,也很容易遗漏掉隐藏的依赖,最终把风险和问题都推迟到最后的大集成阶段。而这里所说的“分而治之”,是基于精确、显性的广义依赖网络 ,进行系统性的、可预测的拆分。它的目标不是简单地“少构建一点”,而是精准地“只构建必要的部分”

这样带来的改变是极为明显的:系统能够自动、快速地计算出任何变更的精确影响范围,然后只针对这个范围进行完整的构建和测试,而其他所有未受影响的部分则可以直接复用缓存。并且这种复用是以构建单元为粒度进行打包和下载的,避免了过去下载海量小文件的低效方式,效率也更高。对于开发者来说,体验就从“为了快,我只测一小块,然后祈祷别出问题”变成了“我每次都运行完整的全局构建,但系统能让它在几分钟内就完成”。这就在速度和完整性之间找到了完美的平衡。

显性治理的三大支柱:拆解、封装、验证

如何实现“显性工程化治理”?其答案可以归结为三大核心实践支柱,它们环环相扣,共同构建起一个可靠的软件生产体系。

支柱一:拆解 (Chunking) —— 定义清晰的边界与输入

“拆解”是“分而治之”的前提,其核心是“小而有界” (Make it small and bounded)。它要求我们将庞大的系统,拆解成一系列具有清晰边界、可独立管理的构建单元。然而,这项工作之所以困难,其根源在于我们用来指导拆解的“地图”——那张广义依赖全景图,本身就是不可见和不完整的

要有效拆解,我们必须首先解决这张“地图”本身的三大缺陷,并采取相应的实践方法:

  1. 挑战:边界不清晰为了方便维护,我们常常会把源码各部分对环境的依赖合并后集中管理。这带来了好处,但恰恰是这种好处,模糊了每个构建单元的真实广义依赖边界。声明的依赖冗余了,就会在无关变更时重复构建,浪费资源;声明的依赖遗漏了,就会错误地命中缓存,造成难以排查的集成问题,最终只能靠“清空缓存再试一次”来解决。
  2. 挑战:存在“隐含依赖”CI服务器上的一个环境变量,基础镜像里的一个系统类库版本,这些真实地影响着构建结果的要素,却从未被正式地声明和管理,导致我们的依赖地图是不完整的。
  3. 挑战:连接被“切断”一个C++库怎么影响到一个Java服务?这种关系往往隐藏在某个CI脚本里,从任何一个语言的依赖工具看,这条连接都是不可见的。还有更糟糕的“幽灵依赖”,比如通过相对路径直接引用文件,这完全绕开了构建系统的感知。

为了绘制一幅精准的地图,我们的实践核心是将所有隐性要素显性化

  • 精准拆解构建上下文:我们必须明白,相同的源码并不能确保得到相同的制品。因此,我们拆的不仅仅是源码,更是它背后的完整构建上下文。每一个构建单元都必须拥有一个独立的、精准且完备的构建上下文定义,完整地捕获这个单元所有的广义依赖:工具链、库、配置等等。而且,拆解的过程也可能包括合并,比如把几个小单元合并成一个更大的构建单元。在这个过程中,就必须显式地解决它们各自广义依赖之间的冲突。
  • 发现并固化隐式依赖:让所有“不可见”都变得“可见”。这就要把CI环境、基础镜像、外部工具这些过去被认为是“理所应当”的生产要素,全部进行版本化和声明化管理。虽然我们已经实践了基础设施即代码(IaaC),但很多时候,它也仅仅是把配置用代码的形式保存在了代码仓里。如果我们真的认为这些是代码,那就应该更进一步:不仅要声明,还要像代码一样建立起与业务代码的依赖关系。一旦建立了这种关系,被依赖的要素,就应该被自动化地创建和提供出来。
  • 用声明式关系修复连接断裂:单元与单元之间的依赖关系,必须被显式地声明出来,避免通过命令、相对路径或者一段胶水脚本来连接。

支柱二:封装 (Fabrication) —— 打造可靠且确定性的生产过程

在通过“拆解”定义了清晰的输入单元后,“封装”的目标是“完整可交” (Make it whole and shippable)。它要求依据单元所声明的全部广义依赖,通过一个确定性的生产过程,像一个现代化的工厂一样,可靠地将其构建并组装成可交付的产品。其黄金标准是实现可复现构建 (Reproducible Build)

然而,这一过程面临两大核心挑战:输入的“非确定性”与过程的“不可靠性”

  1. 挑战:输入的非确定性当一套代码需要支持跨平台、多硬件以及多样的用户定制化需求时,有效的产品配置组合便会呈指数级增长,形成“产品配置组合爆炸”。每一个差异化的产品,都是源码、编译选项、功能开关、定制化配置等一系列输入的特定组合。这些组合中的细微差异,都可能通过复杂的构建逻辑判断引入未被预期的行为。更棘手的是,许多无法复现的问题,其根源还隐藏在那些未被版本化管理的“幽灵依赖”中,例如特定的运行时环境变量,或调试时手工指定的命令行参数。问题的复现之所以变得极为困难,正是因为我们难以精确还原导致问题发生的那一组特定的、非确定性的完整快照。
  2. 挑战:过程的不可靠性构建过程中访问了网络,或者依赖了当前的时间戳,这些都会引入未声明的输入,污染我们的广义依赖集。更常见的是为了应急的手工热修复,绕过了标准流程,引入了无法追踪的变更,比如在构建过程中动态修改文件、打补丁等。随着规模增加,构建和发布过程会演变成一个由大量任务组成的复杂依赖图,这又会把不稳定的影响放大,降低整体的可靠性。

为了应对这些挑战,打造一条可靠的生产线,必须遵循三大实践原则:

  • 管理配置组合爆炸 (Managing Configuration Explosion):要解决输入的非确定性,必须从“命令式”转向“声明式”,将不同的产品配置(如功能开关)本身也作为一种显性的依赖来管理。这能极大简化构建逻辑,让复杂的配置组合变得可追踪、可审计,而不是隐藏在脚本的 if-else 判断里。
  • 建立封闭的构建行为 (Hermetic Builds):为了确保过程的可靠,必须将构建过程隔离在“沙箱”中。这能确保输入源的纯净,杜绝一切未声明的外部干扰,如网络访问、随机文件读写、未定义的环境变量等。
  • 保障过程的幂等性 (Idempotency):这是可靠性的核心保证,即“相同的输入,永远得到相同的结果”。更进一步说,一个幂等的构建任务,在成功执行一次之后,立刻再次执行,结果也必须完全相同,不能对环境或产物造成任何累加的副作用。这不仅是为了消除时间戳、随机数等不确定性,更是为了防止构建过程本身污染工作区和环境。只有保证了幂等性,我们才能确保每一次构建都是一次“干净”的执行,从而放心地依赖缓存,也为最终实现可复现构建打下了坚实的基础。

支柱三:验证 (Verification) —— 建立完整且不可篡改的信任链

通过“拆解”我们看清了输入,通过“封装”我们规范了过程。现在,我们来到了最后,也是最关键的一环:“验证 (Verification)”。其目标是“可证可溯” (Prove it and trace it),即为我们每一个交付的制品,提供一份无可辩驳的“身份证明”,让它的全部广义依赖和整个生产过程,都变得透明、可追溯、可审计。

这项工作的核心挑战在于,传统模式下我们缺乏一条统一的、覆盖所有广义依赖的证据链。开篇故事中的三重质询,就完美地揭示了这条证据链是如何层层断裂的:

  1. “源码可信吗?” —— 这仅仅验证了广义依赖中很小的一部分,证据链在此刚刚开始。
  2. “产品是用这份源码构建的吗?” —— 这开始挑战从“源码”到“产品”的转换过程,这是证据链上的第一处关键断裂。
  3. “构建过程本身安全吗?” —— 这实际上是在拷问,构建过程所依赖的其它所有广义依赖,比如基础镜像、工具链,它们都可信吗?这是更深层次、也更隐蔽的断裂。

因此,“验证”的核心实践,就是要为每一个制品,建立一条从所有广义依赖源头,到最终产物,完整、统一、不可篡改的证据链。这条证据链必须是完整的,要超越源码版本,记录所有影响构建的广义依赖——工具链、脚本解释器、环境变量、运行时参数配置、构建中用到的系统类库等等。它也必须是准确的,最终产物的任何一个字节级的差异,都必须能在这条证据链里,找到对应的某个广义依赖输入的变化。

可复现构建 (Reproducible Build),正是对整条广义依赖证据链最强有力的物理保证。它将“信任”,从一句主观的“相信我”,变成了一项客观的、任何人都可以去验证的事实:“你可以自己去验证”。

结论:构建数据驱动的自验证闭环

“拆解(Chunking)”、“封装(Fabrication)”和“验证(Verification)”这三大支柱,并非孤立存在,而是共同构成了一个数据驱动的、自验证的工程闭环,这正是整套体系的核心精髓所在。

首先,它们形成了一条清晰的价值链:精确的“拆解”,为我们定义了清晰、有边界的单元及其广义依赖,这是所有信任的起点。这些清晰的输入,驱动了可靠的“封装”生产过程。可靠的过程,产出了带有完整广义依赖证据链的制品,从而可以用于“验证”。

更重要的是,“验证”的结果为这个闭环提供了强大的反馈机制。一方面,像一次不可复现的构建失败,会立刻暴露我们“拆解”时的不足,驱动我们去发现并固化新的隐式依赖,从而完善我们的依赖地图。另一方面,也是最关键的一点:既然一条完整的证据链能够解释产物的任何差异,那么它本身就构成了一份经过事实检验的、最权威的广义依赖清单。这意味着,我们可以从最终的证据链中反向推导出构建这个产物所需要的一切输入,从而用最终的结果,来验证我们最初对输入的定义是否精准、完备。

至此,一个自验证的闭环便形成了。我们不再是单向地“定义输入 -> 执行过程 -> 得到输出”,而是让输出的结果反过来证明我们对输入的定义是正确的。

回顾我们日常工作中遇到的构建效率、可靠性、漏洞追溯、架构治理等看似孤立、分散的问题,其实它们拥有一个共同的根源——广义依赖管理的缺失。通过“拆解-封装-验证”这套显性治理框架,我们可以在一个统一的视角下,系统性地解决这些问题。最终目标,是建立一个数据驱动的、可追溯、可自验证的,真正值得信赖的现代化软件生产体系。

(END)

在现代软件工程里,研发与构建是构筑软件从无到有的两个核心环节。这个过程不仅仅是技术和工具的堆砌,更是对复杂性管理和创新思维的深刻体现。软件研发是将复杂问题逐步拆解为小问题并逐一实现的过程;软件构建则是将各个细节的实现逐步集成为完整系统的过程。

拆解不易,集成同样艰难。表面上看,集成似乎是拆解的逆过程,但两者解决的问题截然不同。拆解聚焦于设计领域,强调逻辑分解与结构优化,以降低系统复杂性;集成专注于工程领域,关注依赖组合与整体实现,确保组件兼容与系统稳定。虽然拆解和集成在表面上看似是逆过程,但它们解决的问题和使用的方法都截然不同。

软件研发的第一步是拆解,即将一个复杂的软件需求分解成一系列更小、更易于管理和实现的问题。这个过程要求开发者具备深厚的设计能力和对问题的深刻理解。在拆解的过程中,开发者需要识别出系统的关键组件和它们之间的关系。这不仅涉及到技术层面的分解,比如将一个大型的软件系统分解为多个模块和子系统,还涉及到业务逻辑的梳理,确保每个部分都能独立运作,同时又能够协同工作。

如果说拆解是将问题简化的过程,那么集成则是将这些简化后的部分重新组合起来,形成一个完整的系统。集成的挑战在于,它需要开发者在宏观层面上把握整个系统的运作。这不仅涉及到技术层面的集成,比如确保不同模块之间的接口能够正确对接,还包括业务流程的整合,确保整个系统的业务逻辑是连贯的。集成过程中,开发者需要解决各种可能出现的冲突和问题,比如版本兼容性、性能瓶颈、安全漏洞等,这些都是确保软件系统能够稳定运行的关键因素。

在实际的软件开发过程中,拆解和集成是相辅相成的。一个良好的拆解可以为集成打下坚实的基础,而一个成功的集成则能够验证拆解的有效性。两者共同作用,使得软件系统能够在保持高内聚低耦合的同时,实现功能的完整性和稳定性。

拆解与集成不仅仅是软件工程中的具体实践,更是一种思维方式的体现。它们代表了从分解复杂性到实现整体性的完整过程,体现了我们对问题的深刻理解和技术掌控力,也彰显了软件工程能力的艺术。

在当今数字化时代,软件已成为社会核心基础设施中不可或缺的一部分。无论是通信、金融、医疗、交通、航空航天,还是日常生活中的娱乐、购物和教育,软件无处不在。软件研发的质量和可靠性不仅影响用户体验和商业价值,还直接关系到公共安全。从社交媒体到智能家居和可穿戴设备,软件已经深入我们生活的每一个角落。对于全球商业和金融行业,高效运转的电子商务、支付平台以及金融证券交易依赖于高度可信的软件系统。而市政部门则依赖复杂的软件来支持电网运行、城市交通调度以及紧急医疗响应。这些广泛而深远的应用让软件成为推动经济发展和社会进步的关键力量,同时也加深了我们对软件的依赖。

然而,随着软件研发的规模和复杂性迅速增长,现代软件开发面临前所未有的挑战。技术栈日益多样化,从前端到后端、从云计算到边缘设备,生态系统愈加复杂;研发团队规模动辄达到数千甚至上万人,分布式协作和管理的难度显著增加;市场竞争加剧,产品需要在短周期内快速迭代以满足多样化需求。此外,全球软件供应链对开源社区和第三方组件的依赖也日益显著,但这也带来了潜在的安全隐患。一个简单的手机应用可能依赖数百个开源库,而大型互联网服务或智能硬件设备可能涉及多种编程语言混合开发的数千万乃至上亿行代码、上万个第三方组件、以及全球化协作以及部署运维的复杂管理。

这些深远的变化和迅猛的发展,虽然为社会和经济带来了巨大的机遇,但也不可避免地伴随着一系列复杂且严峻的问题与挑战:

  1. 安全性、隐私问题与用户信任危机
    • 软件漏洞与安全攻击:软件漏洞和网络攻击频发,对社会和企业造成巨大损失。
    • 数据泄露与隐私风险:敏感数据在存储和传输中面临泄露、盗用、滥用等风险,进一步削弱用户对软件系统的信任。
    • 第三方依赖的隐患:第三方组件和开源库带来的潜在安全隐患增加了系统风险。
    • 复杂系统的不透明加剧信任危机:用户无法理解系统的工作原理或开发过程,对其运行机制缺乏可见性,导致仅靠“安全”标签已不足以满足用户对质量和可靠性的基本期望。
    • 频发的安全事件:数据泄露与系统故障不断发生,进一步引发对软件质量和隐私保护的普遍质疑。
  2. 复杂的供应链风险
    • 第三方组件的隐性威胁:供应链中使用的第三方组件可能被恶意篡改或包含漏洞。有些后门并非直接存在于源码中,而是通过构建过程、更新机制等环节悄然注入,绕过传统的源码安全检查。
    • 开发环境的潜在风险:不仅仅是软件组件本身,开发工具、构建系统和流水线同样可能被攻击,攻击者可以渗透构建流程、自动化工具或专有源代码,获得从源头注入漏洞与后门的机会。
    • 风险响应的复杂性:软件产品研发常依赖于大量的第三方组件。当某个组件被发现存在风险时,快速识别其影响范围至关重要。需要明确受影响的产品和客户,同时在研发过程中防止引入已知风险的组件,但这在复杂的依赖关系下带来非常大的挑战。
    • 管理难度的升级:随着开发环境和供应链的复杂化,团队在依赖管理、工具链可见性以及统一的安全控制方面面临巨大的挑战,缺乏清晰的管理机制可能进一步放大风险。
  3. 市场竞争与快速迭代的压力
    • 快速迭代的发布周期:激烈的市场竞争要求企业不断缩短产品发布周期,快速迭代模式下,功能开发优先于质量保障,导致测试和优化环节被压缩。
    • 多规格产品的支持:为了满足不同市场需求,企业需要快速调整并支持多个产品型号和规格。不同产品的特性和需求差异增加了代码复用的难度和依赖管理的复杂性,进一步导致开发和维护成本上升。
    • 长期支持产品的复杂性:某些产品需要在长达5年甚至更久的时间内持续支持,包括兼容新技术、修复发现的缺陷以及提供必要的安全更新。这种长期支持要求开发团队具备复现多年前产品构建和测试环境的能力,带来了工程管理上的显著挑战。
  4. 研发过程透明性不足
    • 版本管理的复杂性:跨团队协作中,版本管理不仅容易混乱,还可能因组件源码的不同编译选项产生多个制品,导致版本组合复杂且缺乏清晰性。这种混乱使得依赖管理和问题定位变得更加困难。
    • 构建流水线的稳定性问题:复杂的构建流水线常因错误的缓存使用、并发构建冲突、基础设施耦合和非幂等的构建过程而不稳定。优化与维护这些流水线的成本高昂,却是确保高效交付的关键。
    • 环境一致性的挑战:开发、构建、测试与生产环境的不一致可能引发测试不稳定、部署失败和运维风险,同时,不同组件对基础设施的特殊需求进一步加剧了环境管理的复杂性。使用统一环境可能造成资源浪费,而为每个任务配置独立环境则增加了管理和配置的复杂性。
    • 架构实现偏离设计:为了快速交付,开发团队可能在实现过程中违反架构设计原则。这种偏离如果未能及时发现和修正,会随着时间推移积累技术债务,严重影响系统的长期稳定性和可维护性。
  5. 效率和成本问题
    • 自动化不足与测试低效:缺乏有效的自动化测试,测试用例的创建和维护复杂耗时,过度依赖人工测试,既增加了测试成本,又难以及时发现研发问题。流水线运行效率和稳定性不足,进一步限制了自动化测试的充分执行,导致问题被延迟到后期修复,显著抬高维护成本。
    • 构建效率瓶颈与流水线复杂性:随着产品研发规模的扩大,构建耗时长、资源占用高,成为制约快速迭代的瓶颈。同时,流水线复杂度迅速增加,可能涉及数十到上百个任务节点,不同版本需求还需独立流水线支持,优化、维护成本高昂。
    • 资源分配与管理的挑战:构建流水线需占用大量基础设施资源,不同任务可能对运行环境有特殊要求。统一环境会造成资源浪费,而独立环境则增加配置复杂性。如何平衡资源利用与效率成为关键难题。
  6. 合规与法规挑战
    • 法规合规的复杂性:不同领域的软件必须符合相关行业法规,如GDPR、医疗设备标准或汽车行业的软件安全法规。不达标可能带来严重的法律和财务后果。
    • 严格标准的研发压力:医疗设备、汽车软件等领域对产品安全性和可靠性的要求极为严格,研发过程需要经过大量验证和审查,增加了项目的复杂性和时间成本。
    • 客户透明性需求:重要客户可能要求审查源代码和研发流程,并提供透明性报告,以确保产品符合安全性和可靠性的高标准。这不仅提高了审核难度,还增加了项目管理的复杂性。
    • 高成本的全流程追踪与审计:满足法规和客户透明性要求需要端到端的研发流程追踪和审计,这需要投入大量人力和资源。为满足这些需求,研发过程可能被迫调整或妥协,进一步影响整体研发效率。

这些问题反映了现代软件研发中所面临的多层次复杂挑战。在全球数字化加速的背景下,构建和维护可信赖的软件系统已成为企业实现持续发展的关键基础。这不仅帮助企业提升市场竞争力,也为用户和社会提供了更高的安全感和价值。面对这些挑战,我们必须重新审视并优化整个研发生命周期中的方法与实践,以更高效地构建更加可靠、可信的软件系统。

在讨论软件工程能力时,许多人首先想到的是开发速度、代码质量或持续集成的效率。然而,这些能力背后隐藏着一个至关重要但常常被忽视的因素,那就是依赖关系管理。尽管依赖关系表面上看似简单,甚至显得微不足道,但精准且全面的依赖关系管理是构建高效、稳定的软件工程能力的基石。无论是在代码编写、构建过程,还是最终的产品交付中,依赖关系的管理贯穿于整个软件开发生命周期。然而,许多团队对这一关键点重视不足,导致工程能力削弱,项目质量不稳定。

依赖关系管理的关键要素

在软件构建过程中,依赖关系管理是不可忽视的核心因素,尽管团队往往不会在这个问题上投入足够的时间和精力。当提到依赖时,人们通常会想到软件类库和源码等。这些依赖的变化可能对最终的软件产物产生显著影响,不仅在功能和性能方面,还可能表现在诸如目录名称、时间戳等与功能无关的方面。如果依赖管理不当,可能导致软件制品无法正确构建或出现严重问题。

现代软件开发通常涉及多种编程语言,每种语言都有其特定的依赖关系管理工具和方法,这带来了跨语言依赖管理的挑战。例如,一个 Java 程序可能调用由 C++ 开发的动态库,而该动态库又依赖于 Rust 开发的类库。如何在这些不同语言和工具之间建立起全面且准确的依赖关系,是软件工程中必须解决的难题。

除了语言间的依赖关系,还有一些依赖关系是通过构建过程建立起来的,例如通过构建脚本。这些依赖关系通常不会由依赖关系声明文件来维护,而是隐藏在构建过程中的某些步骤中。此外,构建工具和环境等因素通常不被视为依赖,但实际上,它们的变化同样会影响软件制品的稳定性。如果工具缺失、配置错误或存在缺陷,软件制品可能无法正确构建,甚至质量会显著下降。因此,这些传统上不被视为依赖的开发工具也应纳入依赖管理的范畴。

从广义的依赖关系角度来看,依赖管理不仅包括软件库和源码,还涵盖编译器、构建工具、操作系统、系统类库、环境变量、构建脚本等所有可能影响构建结果的要素。基于这一视角,依赖锁文件(lock 文件)需要为每次构建生成,确保每一个构建产物都有其对应的依赖锁文件。这种全面记录所有影响因素的做法,类似于对软件制品进行全面的“白盒化”描述。如果依赖关系保持一致,构建结果也应保持一致;反之,若两个构建产物存在差异,它们的依赖声明必定有所不同。

依赖关系的准确性和全面性直接决定了构建工程的成败。错误的依赖关系可能导致构建失败,而未能全面管理的依赖关系则可能引发构建的不稳定性问题(如常见的“在我电脑上没问题”现象)。构建稳定的工程能力需要掌握准确而全面的依赖关系清单,以简化和优化复杂且成本高昂的工程实践,显著提升软件工程的整体质量和效率。

构建工程中的常见误区

依赖关系的最重要的一个应用场景就是构建。然而,许多团队往往忽视构建工程的重要性,轻视依赖关系管理,从而未能给予构建工程应有的关注。构建工程常被认为是简单且低技术含量的任务,因此通常交由新手程序员负责。然而,由于缺乏经验,这些新人难以为构建工程设计出合理的架构或实现高质量的解决方案。这种对依赖关系管理和构建工程的轻视,直接导致了构建脚本维护成本高、质量低下,流水线不稳定,构建过程频繁出错,甚至难以复现缺陷。即便是经验丰富的开发者,如果不重视构建脚本的质量,认为脚本的编写质量不如 Java、C++ 等代码重要,依然会导致上述问题的出现。工程能力不足的团队通常表现为构建时间过长、构建失败率高、难以定位和解决问题,且无法有效扩展和维护构建系统。这些问题的积累最终显著削弱了团队的整体工程能力。

此外,许多团队过度关注持续集成和流水线管理,甚至为适应流水线而调整构建工程。这种做法实际上是本末倒置。流水线的实现因持续集成环境和基础设施的不同而异,强行让构建去适应流水线只会增加对现有基础设施的耦合度,导致构建工程复杂化、维护成本上升,并使构建质量变得不稳定。相比之下,工程能力强的团队通常由经验丰富的开发者维护构建脚本。这些开发者深刻理解依赖关系管理的重要性,并具备全局视角,能够在设计和实现上做出合理的架构决策。这种做法不仅保障了构建工程的稳定性和高效性,还确保了依赖关系管理的精准性和全面性,从而提升了整体工程能力。此外,优秀的构建工程也具有很高的兼容性和可迁移性,能够轻松适应不同的持续集成流水线以及构建环境。

构建工程的核心角色

软件构建工程是整个软件工程能力的核心,决定了从源码到最终产品的全过程,包括代码检查、分析、编译、测试、打包和发布等任务。例如,在使用 Gradle 的情况下,构建任务涵盖了从代码检查 (lint)、编译 (compile, compileJava)、测试 (check, test) 到打包 (jar, war, package) 和发布 (publish, deploy) 的各个环节。这些任务之间存在紧密的依赖关系,而流水线的实现则是在调用这些构建任务,通过将任务分配给不同的流水线节点,实现高性能、高效率的流水线。

构建工程的质量直接影响着其他工程实践的有效性和质量。持续集成、流水线管理、构建集群、缺陷管理、增量构建、分布式构建、自动化测试和发布管理等工程实践都围绕着构建工程展开。如果构建中的测试极其不稳定或总是失败,即使流水线运行得再高效,其实际价值也会大幅降低。

依赖关系管理的实际应用

当通过精准且全面的依赖关系管理构建出稳定且可重复的构建时,可以在此基础上将其应用于更多场景。例如,可以根据不同的 CI 服务器自动生成构建流水线,实现高精度的增量构建;在代码提交时实时监测依赖变更,甚至在不实际构建的情况下精确对比不同构建产物之间的差异,从而快速识别依赖关系变化的来源。此外,这种依赖关系管理还可以用于漏洞追溯,精准识别漏洞的影响范围和链路,甚至能在秒级时间内分析出一个已知漏洞影响了哪些客户的哪些产品。这种管理方式同样适用于分析架构设计与实现的差异,进一步优化系统设计。

流水线节点的配置实际上反映了构建工程中的任务依赖关系,因此可以通过构建工程中的任务依赖关系推导出流水线结构,甚至自动优化流水线节点的配置。这不仅简化了流水线的设计过程,还确保了其与构建工程的紧密配合,从而最大限度地提高整体工程效率和稳定性。

结语

精准且全面的依赖关系管理不仅包括第三方库或源码的引用,还涵盖操作系统、编译器、构建环境等所有可能影响构建结果的因素。依赖关系是软件构建工程的核心,而软件构建工程又是整个软件工程能力的核心。只有通过正确理解和管理依赖关系,才能构建出高效、稳定的软件工程能力。从依赖关系的精准且全面的管理开始,确保一切有序进行,这正是高效、稳定的软件工程能力的基石。

写过很多 Bash 脚本的人都知道,Bash 的坑不是一般的多^。所以大家都认可应该用 Python 而不是 Bash 的一个理由就是 Bash 脚本不可靠,不仅运行不稳定,而且还很难维护。关于 Bash 脚本很难维护,在《关于 Bash 的 10 个常见误解》里面有提到,其实是很多人对于 Bash 的语法就根本不熟悉。连最常用的 if 表达式的语法都不甚了解,更何谈什么进程替代啊,或者来做Bash 脚本的单元测试呢?没有人会认为使用一个自己不熟悉的语言来做开发维护工作是一件容易的事情。

不过呢,就算是我们对 Bash 语法不熟悉,但我们只是来实现一个简单的功能。Bash 脚本也没写几行,甚至在命令行都一条一条的验证过,放到 Bash 脚本里面运行起来就是会莫名其妙的出错。有些在服务器上的出错根本没法儿在本地重现,本地测试的好好的,刚部署上去也好好的,一到晚上或者假期就是各种出错。所以,有人说这是因为 Bash 这个语言其实不严禁,导致其运行不可靠,不稳定,所以得用 Python。但同样的功能用 Python 实现起来的代码量比 Bash 多,所以只是为了个简单的功能,还是直接写到 Bash 脚本里面吧,在 Python 脚本里面用 os.command 把很多命令一个一个包装起来也是挺烦人的。

从一个简单的例子开始

下面我们就以一个简单的例子来看一下为什么 Bash 脚本会如此不可靠。用 Bash 脚本为 GNU/Linux 实现一个很常见的、也很简单的功能,重启 Tomcat 服务。

Tomcat 是 Java 开发里面很常见的一个服务器应用,安装和使用都很简单。只要系统上已经安装了对应的 JDK,配置好 JAVA_HOME 环境变量,把 Tomcat 下载回来后解压就可以使用了。在 Tomcat 的安装目录里面执行 ./bin/startup.sh 就可以启动 Tomcat 服务,运行 ./bin/shutdown.sh 就可以停止 Tomcat 服务。

如果你读到这里,请稍微暂停来思考一下:如果由你来实现「重启 Tomcat」这个脚本,需要考虑哪些情况呢?

重启 Tomcat 服务,无非就是先「停止」再「启动」就可以了。我们在命令行的 Tomcat 目录下执行了 ./bin/startup.sh 后,打开浏览器验证了 Tomcat 已经启动。然后再执行 ./bin/shutdown.sh,再次打开浏览器发现无法访问了。所以要重启 Tomcat 就是先后执行这两个命令就可以了。

但是有一个要考虑的,如果 Tomcat 已经停止了,再次执行命令来停止 Tomcat 会不会出错呢?我们在命令行下测试了反复执行 ./bin/shutdown.sh 之后,发现除了打印出一个无关紧要的可以被忽略的出错信息后,执行过程并不会出错。

看起来实现重启 Tomcat 这个功能的 Bash 脚本无非就是要做如下几步:1、切换到 Tomcat 的安装目录;2、停止 Tomcat;3、启动 Tomcat。

#!/bin/bash
export JAVA_HOME=/opt/jdk1.8.0_262
export PATH="$JAVA_HOME/bin:$PATH"
cd /home/chaifeng/apache-tomcat-8.5.57
./bin/shutdown.sh
./bin/startup.sh

为了防止 Bash 脚本没有获取到 JAVA_HOME 这个环境变量的设置,我们在脚本里面还额外设置了一下,这又能避免一些奇怪的场景下无法获得这个变量的问题。看起来这个脚本很完美了,在本地也执行了一下,确实如我们期望的 Tomcat 已经重启了。用 ps 命令也确认了重启前后的 java 进程 ID 也发生了变化。

但是当我们把这个脚本部署到服务器上以后,情况却不一样了,大部分时候这个脚本都无法重启 Tomcat。明明 Tomcat 已经运行,但是服务端口处于无法访问的状态,甚至还会出现好几个 Tomcat 进程。难道是 ./bin/shutdown.sh 不起作用吗?当我们登录到服务器上,用 kill -9 杀掉这些 java 进程后,手动执行命令启动 Tomcat,验证没有问题。然后再手动运行命令停止 Tomcat,发现也没问题。更诡异的是,不管我们在命令行反复测试多少次,这两个启动和停止命令都正常工作。

明明在命令行下测试过都正常工作的命令,只有放到 Bash 脚本里面执行才有问题。看来 Bash 脚本的执行确实有时候不知何故的会出问题。难道真的是 Bash 是个不严禁的语言,运行不稳定吗?

为什么连这个简单的 Bash 脚本都运行不稳定?

为什么在命令行下一条一条的执行 Bash 脚本中的命令都没有问题,但是放到一个脚本中执行却会出问题呢?这两种方式下的命令都一样,除了用 Bash 语言不严禁这个理由来解释外,好像也没什么其他原因了。

实际上,有一个很重要的因素很容易被我们所忽略,就是:两条命令执行之间的时间差

在命令行上手工执行 Tomcat 的停止命令 ./bin/shutdown.sh 后,到输入了启动命令 ./bin/startup.sh 准备按下回车键执行之间,已经过去了好几秒。即使我们是快速复用命令行的历史命令,也要耗费2、3秒。而放到一个脚本里面执行,前一个命令执行结束到下一个命令开始运行,中间大概只有几个毫秒的时间差。几秒与几毫秒的时间差的区别就是这个简单的重启 Tomcat 脚本运行不稳定的根本原因。

当执行了 ./bin/shutdown.sh 来停止 Tomcat,实际上只是给 Tomcat 服务器发送了一个「停止」的信号,然后命令就退出。但此时,Tomcat 收到了「停止」信号后开始运行停止前的清理任务,包括但不限于等待尚未结束运行的客户端连接、释放数据库资源、网络资源、清理临时文件等。所以 Tomcat 还在运行,并没有立刻停止。这个停止运行前的清理工作的耗费时间依据我们的项目不同可能会差别很大,从零点几秒到几十秒,甚至几分钟都有可能。所以,这里是第一个关键的地方,./bin/shutdown.sh 命令执行结束后,Tomcat 可能还在后台进行清理工作,而没有停止运行!

等我们输入了 ./bin/startup.sh 后,时间已经过去了几秒,这个时间差已经足够让 Tomcat 在大多数的情况下真正停止运行了。所以 Tomcat 就又正常启动了。

放在脚本中运行,由于两条命令之间的时间差可能只有几毫秒,这么短的时间不足以让 Tomcat 停止运行。当我们在本地测试运行这个 Bash 脚本时,停止命令和启动命令都已经先后结束运行后。此时在后台,前一个老 Tomcat 进程还尚未结束运行,一个新的 Tomcat 进程就已经启动。但这个新的 Tomcat 可能还需要几秒钟来初始化资源,才能够开始绑定服务端口,这又为老 Tomcat 进程的停止争取到宝贵的几秒时间。当这个新的 Tomcat 结束了资源初始化后开始绑定服务端口的时候,老 Tomcat 可能已经释放了这个服务端口并停止运行。所以在本地测试时,这个重启 Tomcat 的 Bash 脚本表现的非常完美。

当部署到服务器上以后,Tomcat 服务可能会因为资源使用很多而需要更久的时间来释放资源,仅仅几秒是不够的。这就使得当一个新的 Tomcat 结束了资源初始化后,由于老的 Tomcat 服务还没有结束资源释放且没有释放服务端口,导致新的 Tomcat 会因为服务端口被占用而启动失败。

这就是为什么这么简单的一个重启 Tomcat 的 Bash 脚本会出现运行不稳定的原因。而且也不会因为我们用 Python 重写这个 Bash 脚本就可以解决这个问题。

让这个简单的例子变的稳定

既然找到了原因,那就好说了,启动 Tomcat 之前先把现有的 Tomcat 进程杀掉。因为 Tomcat 本身是一个 Java 应用,所以也就是杀掉 java 进程。

现在的问题是「如何杀掉进程」。有人会说这很简单啊,用 kill -9 就可以杀掉进程。没错,这样的确可以。而且如果大家去搜索「Linux 杀掉进程」,很多的搜索结果都提到要用 kill -9 来杀掉进程。为什么要使用选项 -9 呢?有人会说,因为有时候直接用 kill 命令杀不掉进程,反复执行也杀不掉,用了选项 -9 之后就可以了。

事实上,kill -9是对系统危害最大的命令之一。也有人会说,我一直用 kill -9 来杀进程,也从来没破坏过系统啊。这是典型的幸存者偏差,就跟开车不系安全带、骑车不带头盔一样,确实有很多人不系安全带不带头盔,也没见过出事儿。但是只要发生一次,那就是严重伤害。

直接用 kill 命令去杀一个进程,默认是给进程发送了 SIGTERM 信号,当进程收到这个终止信号后,可能会开始终止前的清理工作。当清理工作结束后,该进程就会自行终止,如果不需要清理就直接结束了。如果清理时间稍微长一些,就会让我们产生 kill 命令不工作的错觉。但我们可能只要稍微多等待几秒,进程就会结束了。而使用 kill -9 则类似于一枪爆头,没有给进程在自杀前善后工作的机会。如果进程打开了很大的文件,或者正在写入数据库。暴力终止进程的执行是很有可能会导致文件或者数据库的损坏。

作为一名称职的司机,我们一定会系安全带的。作为一名称职的运维工程师,一定不会滥用 kill -9 这个命令的。现在抬头看看你的周围,有多少不称职的运维工程师呢?

既然在执行了停止 Tomcat 的命令后,不能立即强行终止 java 进程,那就需要等待。很显然,我们不能预计要等待多久。如果固定的等待一个比较长的时间,而 Tomcat 早已经停止,这就会让别人觉得这个 Bash 脚本的性能太差了。现在我们修改一下这个重启 Tomcat 的 Bash 脚本:

#!/bin/bash
export JAVA_HOME=/opt/jdk1.8.0_262
export PATH="$JAVA_HOME/bin:$PATH"
cd /home/chaifeng/apache-tomcat-8.5.57
./bin/shutdown.sh
for ((i=0;i<60;i++)); do
    pgrep java || break
    sleep 1
done
pgrep java | xargs --no-run-if-empty kill -9
./bin/startup.sh

新增了一个等待循环,每秒钟都检查一下 java 进程,如果没找到就终止循环,最多等待 60 秒。退出循环后,再次查找 java 进程,这里用了 GNU xargs 的选项 -—no-run-if-empty 可以避免 xargs 没有从管道接受了数据而去执行 kill -9 命令,导致无谓的出错。

到这里,这个简单的重启 Tomcat 的 Bash 脚本看上去没啥问题了,不管是本地还是服务器上都可以正常的稳定的工作了。而且我们应该也要注意到,讨论的这个问题不管我们用 Python 还是 Bash 还是其他什么语言来实现这个功能,都同样是需要考虑的。要承认,这个造成 Bash 脚本运行不稳定的原因与 Bash 一点儿关系都没有。

这个简单的例子还没有结束

看到这里,或许有人会疑惑还需要考虑什么呢?停止 Tomcat 后,如果在 60 秒内 Tomcat 还没有停止就会强制停止,然后再启动 Tomcat。完美,没什么要考虑的了。

如果在系统上除了 Tomcat 这个 java 进程外,还有其他的 Java 进程,我们怎样确保这个重启脚本不会杀掉其他无关的 Java 进程呢?

如果 Tomcat 是以某个特定用户来运行,如何阻止其他用户无意中执行这个脚本呢?

如果系统上运行了多个 Tomcat 实例,如果确保这个重启脚本只会重启特定的 Tomcat 而不是其他的 Tomcat 服务呢?

如何能够让不同的 Tomcat 复用同一个重启脚本,而不用是为每个 Tomcat 的安装实例复制多份脚本呢?

如何让这个脚本自动判断 Tomcat 版本并选择对应的 JDK?以避免使用错误的 JDK 来启动 Tomcat 呢?

如何让这个重启脚本自动判断当前目录是否位于一个 Tomcat 安装目录中,运行后重启的就是这个 Tomcat,这样就避免了每次使用可能需要传入 Tomcat 目录作为参数了。万一输入了错误的目录呢?

如何避免使用特权用户执行这个脚本来重启 Tomcat?否则不仅会引入额外的安全风险,也会让因一些文件所有者发生变化而导致非特权用户以后无法再管理 Tomcat。

如何让每次重新开机后可以自动执行这个脚本来重启 Tomcat?如果要做开机自动启动脚本,是不是还要考虑 System V 还是 Systemd ?

作为开机的自动启动脚本时,如何自动判断 Tomcat 的所属用户,并自动切换到该用户的权限下执行?这样可以避免无谓的启动配置。

如果 Tomcat 上部署的服务依赖其他的服务,比如 MySQL,如何让这个脚本自动判断依赖的服务已经运行?避免 Tomcat 启动后服务运行后失败

如何让重启脚本判断 Tomcat 重启后服务已经成功运行,否则将以某种方式发出通知?这样就避免了重启不成功,但是我们不知道。

如何避免这个脚本互斥运行?以防止这个脚本在不同的终端上分别同时运行而导致的运行冲突。

如何避免远程登录到一个服务器上运行了脚本,并确认 Tomcat 运行正常,结果退出登录后,Tomcat 服务也会终止运行的问题。

等等……等等……

为了写出一个稳定、可靠、易用的 Tomcat 重启脚本,我们有很多要考虑的事情。列出了这么多可能需要考虑的问题,有哪个问题是和 Bash 有关的?很显然没有。如果我们用 Python 去开发这样一个功能,是否也需要考虑这些问题呢?答案是肯定的。

结论,Bash 脚本不稳定的原因其实就是我们自己。用 Python 重写 Bash 脚本后,原先该有的问题还是存在,只是我们不能再怪罪是 Python 这个语言不严谨,Python 运行不稳定了。只能乖乖从我们自身去寻找问题,然后慢慢得以解决。

如果一开始我们就能够清楚的意识到我们对于一些基础技能的欠缺,就可以提前避免很多不该有的问题。比如对 Bash 的语法不熟悉,也存在很多的误解,那我们怎么能写好 Bash?缺乏对于操作系统的一些功能理解,比如滥用 SIGKILL 信号,不明白什么时候该忽略 SIGHUP 信号等等,那我们怎么能管理好系统?其次,我们缺乏一个系统的运维知识体系,认为运维工作就是去使用一个又一个的工具,不理解这些工具在运维知识体系中的位置。那我们如何能够有效的利用这些不同工具的组合来解决问题呢?再者,我们缺乏从全局系统角度去思考问题,解决了一个又冒出另一个。或者就没明白这些不同的问题之间可能存在一个共同的根因,总是就问题解决问题,那我们要浪费多少时间和人力才可以维护好一个系统?最后,做好运维工作,提升运维技能不只是运维工程师才需要掌握的。对于开发工程师也有提升自己工作效率,更有效的与运维团队协作和沟通,无论是对自己、对团队、对项目都有好处。

要想了解运维工作的知识体系以及技术脉络请看DevOps 技术栈。再看看你是否也有关于 Bash 的 10 个常见误解?要想写出可靠稳定的 Bash 脚本,那一定要写Bash 脚本的单元测试

为什么要为 Bash 脚本写单元测试?

因为 Bash 脚本通常都是在执行一些与操作系统有关的操作,可能会对运行环境造成一些不可逆的操作,比如修改或者删除文件、升级系统中的软件包等。所以为了确保 Bash 脚本的安全可靠,在生产环境中部署之前一定需要做好足够的测试以确保其行为符合我们的预期。

如何能够安全可靠的去测试 Bash 脚本呢?有人可能会说我们可以用 Docker 容器。是的,这样做即安全又方便。在容器隔离出来的环境中不用担心脚本会破坏我们的系统,而且也能非常简单的快速重建出一个可用的测试环境。

不过呢,请考虑以下的几个常见的场景:

场景一:在执行 Bash 脚本测试前,我们需要需要事先安装好所有在 Bash 脚本中会用到的第三方工具,否则这些测试将会因为命令找不到而执行失败。例如,我们在脚本中使用了 Bazel 这个构建工具。我们必须提前安装并配置好 Bazel,而且不要忘记为了能够正常使用 Bazel 还得需要一个支持使用 Bazel 构建的工程。

场景二:测试结果的稳定性可能取决于脚本中访问的第三方服务的稳定性。比如,我们在脚本中使用 curl 命令从一个网络服务中获取数据,但这个服务有时候可能会访问失败。有可能是因为网络不稳定导致的,也可能是因为这个服务本身不稳定。再或者如果我们需要第三方服务返回不同的数据以便测试脚本的不同分支逻辑,但我们可能很难去修改这个第三方服务的数据。

场景三:Bash 脚本的测试用例的执行时间取决于脚本中使用的命令的执行时间。例如,如果我们中脚本中使用了 Gradle 来构建一个工程,由于不同的工程大小 Gradle 的一个构建可能要执行3分钟或者3个小时。这还只是一个测试用例,如果我们还有20个或者100个测试用例呢?我们是否还能在几秒内获得测试报告呢?

即使使用了容器来执行 Bash 脚本测试,也一样无法避免上面的几个问题。环境的准备过程可能会随着测试用例的增多而变的繁琐,测试用例的稳定性和执行时长取决于第三方命令和服务的稳定性和执行时长,还可能很难做到使用不同数据来覆盖不同的测试场景。

对于测试 Bash 脚本来说,我们真正要验证的是 Bash 脚本的执行逻辑。比如在 Bash 脚本中可能会根据传入的参数来组合出内部所调用的命令的选项和参数,我们要验证的是这些选项和参数确实如我们预期的。至于调用的命令在接受了这些选项和参数后由于什么原因而失败,可能我们并不关心这所有的可能原因。因为这会有更多的外部影响因素,比如硬件和网络都是否工作正常、第三方服务是否正常运行、构建工程所需的编译器是否安装并配置妥当、授权和认证信息是否都有效、等等。但对于 Bash 脚本来说,这些外部原因导致的结果就是所调用的命令执行成功或者失败了。所以 Bash 脚本只要关注的是脚本中调用的命令是否能够成功执行,以及命令输出了哪些,并决定随后执行脚本中的哪些不同分支逻辑。

如果说我们就是想知道这个命令搭配上这些选项参数是否能按我们预期的那样工作呢?很简单,那就单独在命令行里面去执行一下。如果在命令行中也不能按预期的工作,放到 Bash 脚本里面也一样不会按预期的工作。这种错误和 Bash 脚本几乎没什么关系了。

所以,为了尽量去除影响 Bash 脚本验证的那些外部因素,我们应该考虑为 Bash 脚本编写单元测试,以关注在 Bash 脚本的执行逻辑上。

什么样的测试才是 Bash 脚本的单元测试?

首先,所有存在于 PATH 环境变量的路径中的命令都不应该在单元测试中被执行。对 Bash 脚本来说,被调用的这些命令可以正常运行,有返回值,有输出。但脚本中调用的这些命令都是被模拟出来的,用于模拟对应的真实命令的行为。这样,我们在 Bash 脚本的单元测试中就避免了很大一部分的外部依赖,而且测试的执行速度也不会受到真实命令的影响了。

其次,每个单元测试用例之间都应该是独立的。这意味着,这些测试用例可以独立执行或者被任意乱序执行,而不会影响验证结果。

最后,这些测试用例可以在不同的操作系统上执行,且都应该得到相同的验证结果。比如 Bash 脚本中使用了只有 GNU/Linux 上才有的命令,对应的单元测试也可以在 Windows 或者 macOS 上执行,且结果一致。

怎样为 Bash 脚本写单元测试?

与其他编程语言一样,Bash 也有多个测试框架,比如 BatsShunit2 等,但这些框架实际上并不能隔离所有 PATH 环境变量中的命令。有一个名为 Bach Testing Framework 的测试框架是目前唯一一个可以为 Bash 脚本编写真正的单元测试的框架。

Bach Testing Framework 的最独特的特性就是默认不会执行任何位于 PATH 环境变量中的命令,因此 Bach Testing Framework 非常适用于验证 Bash 脚本的执行逻辑。并且还带来了以下好处:

  • 简单
    什么也不用安装。我们就可以执行这些测试。比如可以在一个全新的环境中执行一个调用了大量第三方命令的 Bash 脚本。

  • 因为所有的命令都不会被真正执行,所以每一个测试用例的执行都非常快。
  • 安全
    因为不会执行任何外部的命令,所以即使因为 Bash 脚本中的某些错误导致执行了一个危险的命令,比如 rm -rf *。Bach 会保证这些危险命令不会被执行。
  • 与运行环境无关
    可以在 Windows 上去执行只能工作在 GNU/Linux 上的脚本的测试。

由于操作系统和 Bash 的一些限制,Bach Testing Framework 无法做到:

  • 拦截使用绝对路径调用的命令
    事实上我们应该避免在 Bash 脚本中使用绝对路径,如果不可避免的要使用,我们可以把这个绝对路径抽取为一个变量,或者放入到一个函数中,然后用 @mock API 去模拟这个函数。
  • 拦截诸如 >>><< 等等这样的 I/O 重定向
    是的,无法拦截 I/O 重定向。我们也同样可以把这些重定向操作隔离到一个函数中,然后再模拟这个函数。

Bach Testing Framework 的使用

Bach Testing Framework 需要 Bash v4.3 或更高版本。在 GNU/Linux 上还需要 Coreutils 和 Diffutils,在常用的发行版中都已经默认安装好了。Bach 在 Linux/macOS/Cygwin/Git Bash/FreeBSD 等操作系统或者运行环境中验证通过。
Bash v4.3+
Coreutils (GNU/Linux)
Diffutils (GNU/Linux)

安装 Bach Testing Framework

Bach Testing Framework 的安装很简单,只需要下载 https://github.com/bach-sh/bach/raw/master/bach.sh 到你的项目中,在测试脚本中用 source 命令导入 Bach Testing Framework 的 bach.sh 即可。

比如:

source path/to/bach.sh

一个简单的例子

与其它的测试框架不同,Bach Testing Framework 的每一个测试用例都是由两个 Bash 函数组成,一个是以 test- 开头的测试执行函数,另一个是同名的以 -assert 结尾的测试验证函数。

比如在下面的例子中,有两个测试用例,分别是
test-rm-rf
test-rm-your-dot-git

一个完整的测试用例:

#!/usr/bin/env bash
set -euo pipefail

source bach.sh # 导入 Bach Testing Framework

test-rm-rf() {
    # Bach 的标准测试用例是由两个方法组成
    #   - test-rm-rf
    #   - test-rm-rf-assert
    # 这个方法 `test-rm-rf` 是测试用例的执行

    project_log_path=/tmp/project/logs
    sudo rm -rf "$project_log_ptah/" # 注意,这里有个笔误!
}
test-rm-rf-assert() {
    # 这个方法 `test-rm-rf-assert` 是测试用例的验证
    sudo rm -rf /   # 这就是真实的将会执行的命令
                    # 不要慌!使用 Bach 测试框架不会让这个命令真的执行!
}

test-rm-your-dot-git() {
    # 模拟 `find` 命令来查找你的主目录下的所有 `.git` 目录,假设会找到两个目录

    @mock find ~ -type d -name .git === @stdout ~/src/your-awesome-project/.git \
                                                ~/src/code/.git

    # 开始执行!删除你的主目录下的所有 `.git` 目录!
    find ~ -type d -name .git | xargs -- rm -rf
}
test-rm-your-dot-git-assert() {
    # 验证在 `test-rm-your-dot-git` 这个测试执行方法中最终是否会执行以下这个命令。

    rm -rf ~/src/your-awesome-project/.git ~/src/code/.git
}

Bach 会分别运行每一个测试用例的两个方法,去验证两个方法中执行的命令及其参数是否是一致的。比如,第一个方法 test-rm-rf 是 Bach 的测试用例的执行,与之对应的测试验证方法就是 test-rm-rf-assert 这个方法

在第二个测试用例 test-rm-your-dot-git 中使用了 @mock API 来模拟了命令 find ~ type d -name .git 的行为,这个命令用来找出用户目录下的所有 .git 目录。模拟之后,这个命令并不会真的执行,而是利用了 @stdout API 在标准终端上输出了两个虚拟的目录名。

然后我们就可以执行真正的命令了,将 find 命令的输出结果传递给 xargs 命令,并组合到 rm -rf 命令之后。

在对应的测试验证函数 test-rm-your-dot-git-assert 里面就验证是 find ~ -type d -name .git | xargs -- rm -rf 的运行结果是否等同于命令 rm -rf ~/src/your-awesome-project/.git ~/src/code/.git

@mock 是 Bach Testing Framework 中很重要的一个 API,利用这个 API 我们就可以模拟 Bash 脚本中所使用的任意命令的行为或者输出。

比如

@mock curl --silent google.com === \
    @stdout "baidu.com"

模拟了命令 curl --silent google.com 的执行结果是输出 baidu.com。在真实的正常场景下,我们是无法做到访问 google.com 得到的是 baidu.com。这样模拟之后就可以用来验证 Bash 脚本中处理一个命令不同响应时的行为了。

@mock API 甚至还支持更复杂的行为模拟,我们可以自定义一个复杂的模拟逻辑,比如:


@mock ls <<\CMD
    if [[ "$var" -eq 1 ]]; then
        @stdout one
    else
        @stdout others
    fi
CMD

在这个模拟中,会根据变量 $var 的值来决定命令 ls 的输出 one 还是 others

@mock API 模拟的命令在任何时候执行的时候都是同样的行为。但如果要模拟同一个命令重复执行的时候要返回不同的值,Bach Testing Framework 还提供了一个 @@mock 这个 API,比如:

@@mock uuid === @stdout aaaa-1111-2222
@@mock uuid === @stdout bbbb-3333-4444
@@mock uuid === @stdout cccc-5555-6666

这三个模拟命令模拟了 uuid 在重复执行三次的时候都返回不同的结果,按照模拟的先后顺序分别输出对应的模拟输出。如果在执行完所有的模拟输出后,再重复执行将会始终输出最后一个模拟的输出。

更详细的 API 介绍请在 Bach Testing Framework 的官网 https://bach.sh 查看。

使用 Bach Testing Framework 还可以让我们更安全方便的练习 Bash 编程。

比如,我们希望实现一个函数 cleanup 用来删除参数上指定的文件。一个实现可能是:

function cleanup() {
    rm $1
}

这个函数的实现其实是有安全问题的,因为对于 Bash 来说,有没有把一个变量用双引号包含起来是非常重要的。在这个实现中,变量 $1 就没有用双引号,这会带来严重的后果。下面我们将使用 @touch API 来创建几个文件,其中将有一个文件名中含有特殊字符 的文件 bar。我们都知道,对于含有特殊字符的文件名是要放入到双引号中的。现在这个这个 cleanup 的实现里面没有使用双引号,但是传参的时候使用了双引号,那是否还会按照我们的预期来执行呢?


function cleanup() {
    rm -rf $1
}

test-learn-bash-no-double-quote-star() {
    # 创建了三个文件,其中有一个名为 "bar*" 的文件
    @touch bar1 bar2 bar3 "bar*"

    # 要删除这个错误的文件名 bar*,而不删除其他文件,使用了双引号来传参,这是正确的
    cleanup "bar*"
}

test-learn-bash-no-double-quote-star-assert() {
    rm -rf "bar*"
}

这个测试用例将会失败,从验证结果中我们可以看到,期望只删除文件 bar,但是在函数 cleanup 里面,因为遗漏了双引号,会导致变量被二次展开。实际执行的命令是 rm -rf "bar*" bar1 bar2 bar3

现在修复函数 cleanup,把变量 $1 放入双引号:

function cleanup() {
    rm -rf "$1"
}

再次执行测试,会发现确实执行的是命令 rm -rf "bar*"

Bach Testing Framework 目前已经在宝马集团和华为内部使用了。在宝马集团的一个有数千人规模的大型项目里,Bach Testing Framework 保证了数个非常重要的构建脚本的维护。这些脚本的可靠性和稳定性决定了数千人团队的工作效率,现在就可以在本地快速验证这些构建脚本的执行逻辑,也避免了在本地很难复现一些构建集群中的特殊场景的问题。

如果大家在使用上有啥问题和建议可以去Bach 测试框架的项目上留言。

即使为 Bash 脚本写了单元测试后,我们可能还会遇到为什么 Bash 脚本总是不稳定?难道真的是 Bash 不严禁不稳定吗?另外还有《关于 Bash 的 10 个常见误解》,看看你有多少误解。以及在学习和掌握运维技术栈时,除了 Bash 以外可能还要学有很多工具,比如 Python、Ansible、Kubernetes等等。这些工具在运维技术栈里处于什么位置,他们之间的关系又是如何呢?我们是应该学 Bash 还是 Python 还是 Ansible 呢?请看《DevOps 技术栈》这个我认为最重要的一篇文章以建立起我们的运维技术脉络。