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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

这里容易混淆两件事。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

平台能够消除的,是已经被标准化的选择、路径和重复工作。

剩下的问题,主要来自某项具体变更与既有状态、依赖、权限和责任之间的关系。实现一旦开始碰到这些关系,就不再只是一个可以随时删掉重来的方案。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

前文把工程体系处理这些问题的能力称为“变更吸收能力”。

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

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

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

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

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

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

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

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

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

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

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

完整地说,变更吸收能力包括三件事:

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

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

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

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

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

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

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

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

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

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

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

什么可以影响结果?

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

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

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

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

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

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

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

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

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,它们可以很快修改不同模块。单独看,每项修改可能都很合理;组合起来,却可能增加依赖数量、扩大工具链版本范围、引入重复抽象,或者让构建和测试矩阵不断膨胀。代码生成能力越强,验证队列反而越容易成为瓶颈。

因此,工程体系需要的不只是更快的流水线,还需要处理变更压力的能力。这里可以把它称为“变更吸收能力”。它不是把更多修改吞进系统的速度,而是把一项变更转化为接受、拒绝、推迟、缩小范围或重新设计等明确的处置决定,并为这些决定提供与风险相称的证据。

例如,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 技术栈》这个我认为最重要的一篇文章以建立起我们的运维技术脉络。

09. August 2020 · 3 comments · Categories: 技术文章 · Tags: ,

Bash 大概是程序员们既是最熟悉的而又是最陌生的。说熟悉,Bash 几乎存在于程序员身边的绝大多数电脑上。说陌生,Bash 作为最常用的脚本语言,很少有程序员们了解 Bash 的特性和语法,而且因为不了解还产生了很多的误解。比如什么 Bash 作为一个脚本语言,其语法怪异且不严禁,仅适合编写简单的脚本,我们应该用 Python 代替 Bash。其实这完全是错误的理解。甚至很多程序员并不知道他们当前使用的 Bash 版本是多少,用的是 3.x?还是 4.x?还是 5.x?你知道你用的是哪个版本吗?

其实 Bash 的语法是图灵完备的,也就是说理论上我们可以用 Bash 实现任何其他语言可以实现的功能。话虽如此,但不同的语言有其业务场景的适用性,我们当然应该把 Bash 用在最适合的场景下,这才能发挥出 Bash 的强大威力。

完全没有道理用自己熟悉的编程语言去比较其他语言,如果不符合自己熟悉的习惯就评价为怪异。作为程序员一定要保持开放的心态去学习。

这篇文章介绍了大家对 Bash 的常见误解,看看你知道多少?

Bash 的 if 语句后面其实不是括号

这应该是被人误解最多的,吐槽最多的一点。比如 Bash 的语法非常怪异,if 后面的括号竟然是方括号,而且括号的前后都要有空格!

事实上,Bash 的 if 语法定义上其实并没有定义括号,而且命令序列。执行命令 help if 可以看到 if 的使用方法

if COMMANDS; then COMMANDS; 
[ elif COMMANDS; then COMMANDS; ]... 
[ else COMMANDS; ] 
fi

实际上所谓的方括号 [ 其实是一个命令,这个命令既是 Bash 的内建命令,也是一个外部命令,根据系统的不同可能位于 /bin 或者 /usr/bin 目录下。比如 macOS 上执行命令 ls -l “/bin/[” 就可以找到。

当我们知道了方括号 [ 是个命令后,这些怪异的语法就变得很容易理解了。因为 if 后面是命令,所以当然也可以用方括号 [ 命令了。既然这是一个命令,那命令的参数和命令本身自然也需要有空格来做分割了。事实上方括号 [ 命令等同于 test 命令,但是要求命令的最后一个参数必须是 ]。哈哈,如果一个括号只有开头而没有结尾,这才是最怪异的呢。

不过还有一种常见的是双方括号 [[,这个是 Bash 的内建关键字。虽然可以把关键字当作命令来看到,但是 Bash 会对关键字做特殊处理。比如在 [[ 与其结尾参数 ]] 之间是可以使用 &&、|| 这样的逻辑操作的,如果我们把 [[ 1 -eq 1 && 2 -eq 2 ]] 当作两条命令来看到,第二个命令是 2,这肯定会执行失败了。这就是 Bash 对关键字做的一个特殊处理。而 [ 是一个命令,不管是内建命令也好外部命令也好,[ 1 -ne 0 && 2 -ne 3 ] 一定会出错的,因为这将执行两条命令,第一条 [ 1 -ne 0 将会因为 [ 命令的最后一个参数不是 ] 而出错,第二条命令 2- ne 3 ] 可能将会因为在 PATH 环境变量中没有找到一个名为 2 的命令而出错。

说到这里,我们可以想想看。假如有一个 Java 程序员竟然对 Java 的 if 语句的语法不熟悉,这个程序员怎么可能写出好的 Java 程序?把 Java 换成 Bash 也是一样的。

真的无法阻止 Bash 中的函数修改全局的变量吗?

这应该是第二个被人误解最多的一个 Bash 特性,也常被用来证明 Bash 的语言多么不可靠。原因是如果在函数中没有使用 local 关键字来声明变量,那函数里的变量将会对全局产生影响。或许看起来有些绕口,请看如下的例子:

#!/usr/bin/env bash
x=42
bar=answer

function foo() {
  local x=999
  bar=world
  echo foo: x=$x bar=$bar
}

foo
echo x=$x bar=$bar

执行的结果是

foo: x=999 bar=world
x=42 bar=world

在函数 foo 中用 local 关键字声明了变量 x,这使得函数内变量 x 的作用域限于函数 foo 内,不同于外部的全局变量 x。而函数内使用变量 bar 时没有用 local 关键字声明,这使得函数内对变量 bar 的赋值实际上是修改了函数之外的变量 bar。这可能会造成我们在编写函数的时候,必须显式的使用 local 关键字来声明局部变量,否则会发生无意中使用了全局的同名变量,导致该全局变量被无意中修改的问题。

这个问题真的很严重,也的确是有些人吐槽 Bash 不严谨的地方之一。事实上,这也恰巧说明了我们可能并不了解 Bash 中定义函数的语法。

大家都知道,在 Bash 中定义函数时,把函数体放到花括号中。看上去这点和很多编程语言也非常类似,比如 C、Java 等。但却不会把变量的作用域限制在花括号的范围内。其实 Bash 中声明函数时,其语法要求函数体是一个组合命令即可。所以,花括号并不是必须的,我们还可以用圆括号。请看下面的例子中函数 foo 的函数体用了圆括号:

#!/usr/bin/env bash
x=42
bar=answer

function foo() ( # <--
  local x=999
  bar=world
  echo foo: x=$x bar=$bar
) # <--

foo
echo x=$x bar=$bar

执行的结果是:

foo: x=999 bar=world
x=42 bar=answer

在第二个例子中,我们把函数体放到了圆括号里面,这使得这个函数将会在子进程中执行。而子进程是无法修改父进程中的任何变量的。所以,即使在函数中没有用 local 关键字声明变量 bar,对这个变量的变更也不会影响到全局的同名变量。

因为在定义 Bash 的函数时需要的是组合命令,所以下面的函数定义也是合法的:

function foo()
for param; do
  echo "=> $param";
done

执行 foo a b c 会得到

=> a
=> b
=> c

是的,出乎大家的意料,定义 Bash 函数的时候连什么括号都可以不要。

所以,我想再吐槽一次。如果有人连 Bash 的语法都不了解不熟悉,有很多的误解。他们是如何得出 Bash 的语法不严禁,功能不如其他语言这些结论的?

Bash 也支持直接执行目录名来切换到该目录中

很多使用 Zsh 的用户在举例 Zsh 与 Bash 的区别时,这是一个常用来对比的特性。很可惜,这不是 Zsh 的独特特性,Bash 其实也支持。实际上 Zsh 下这个特性也是默认关闭的,只是很多人都会使用 Oh My Zsh 这个知名的配置工程,而 Oh My Zsh 默认开启了这个特性。在 Bash 下开启这个特性的命令是 shopt -s autocd,开启之后我们就可以直接执行目录名来切换到该目录了。

Bash 其实也支持 ** 通配符来匹配多级子目录

这个特性也是有些人会误认为 ** 通配符是 Zsh 比 Bash 好用的一个特性,但其实 Bash 也是支持的。

开启命令是 shopt -s globstar

然后我们就可以用 ls **/*.txt 来列出当前目录及其子目录下的所有 *.txt 文件了。

所以,以后也请不要那这个特性来对比 Zsh 和 Bash 了。

Bash 在命令行上其实也支持撤销、粘贴等操作

当然有很多人知道在命令行下如何快速跳转光标、删除单词或者删除到行尾等常用操作,但很少有人会知道的 Bash 在命令行也支持撤销、粘贴等操作,而且粘贴功能事实上比操作系统默认的更强大。

撤销的快捷键是 Ctrl-/,反复按下会继续撤销之前的操作。

粘贴的快捷键是 Ctrl-y。当在命令行使用了 Ctrl-k 删除到行尾、Ctrl-w 删除前一个单词、Alt-d 删除下一个单词、Ctrl-u 删除整行等操作后,Bash 会将删除掉的内容放到自己的剪贴板中。要注意的是这个 Bash 的剪贴板不同于操作系统的剪贴板。删除之后,可以按下 Ctrl-y 在当前光标处粘贴最后一次删除的内容,重复按下会重复粘贴。

如果删除过多次,按下 Ctrl-y 粘贴出最后一次删除的内容后,再紧接着按下 Alt-y 就会替换为前一次的内容,重复按下 Alt-y 会继续替换更早之前删除的历史内容。

Bash 中使用 I/O 重定向的时候,重定向可以不用放在命令的最后

我们经常使用 >、>>、<、<< 来把命令结果保存到文件、或者从文件中读取内容。通常我们都是把重定向放在命令的最后。比如 ls -ld * >/tmp/files.txt

实际上,重定向可以位于一条命令的任何位置,放在中间或者最前面都是可以的。比如以下命令都是一样的

>/tmp/files.txt ls -ld *

ls >/tmp/files.txt -ld *

有时候不把重定向放在命令的最后会给我们快速复用并修改之前的命令非常方便。比如我们只需要快速搜索到之前执行的命令 >/tmp/files.txt ls -ld *,就可以很容易的修改最后一个参数了。

顺便说以下,看上去 Bash 中的 I/O 重定向很简单、很易使用。但事实上Bash 的 I/O 重定向比绝大多数人认为的更强大且复杂。或许我会在另外的文章中来详细介绍一下。

Bash 支持直接访问网络

Bash 的网络功能是很多人不知道的一个特性,不需要什么 nc、socat 这些命令就可以访问网络资源。只需要使用 I/O 重定向直接访问设备文件 /dev/tcp/<host>/<port> 即可,把 <host> 和 <port> 换成你希望访问的网络地址和端口。比如 /dev/tcp/baidu.com/80 就是访问主机 baidu.com 的 80 端口。

请看下面的命令,用 Bash 的内建命令实现了一个简单的 HTTP 客户端功能,访问 baidu.com 并打印出响应。

#!/usr/bin/env bash
exec 6<>/dev/tcp/baidu.com/80;
printf "GET / HTTP/1.1\nHOST: baidu.com\nConnection: close\n\n" >&6;
while read -r -u 6 line; do
  echo "$line";
done;
exec 6>&-

执行结果

HTTP/1.1 200 OK
Date: Sat, 08 Aug 2020 23:31:36 GMT
Server: Apache
Last-Modified: Tue, 12 Jan 2010 13:48:00 GMT
ETag: "51-47cf7e6ee8400"
Accept-Ranges: bytes
Content-Length: 81
Cache-Control: max-age=86400
Expires: Sun, 09 Aug 2020 23:31:36 GMT
Connection: Close
Content-Type: text/html

<html>
<meta http-equiv="refresh" content="0;url=http://www.baidu.com/">
</html>

其他的 Shell,比如 Zsh、Sh 等可能不支持这种直接访问网络的方式,但是 Zsh 的网络支持其实更强大,只是方式不同。

Bash 的受限模式让自动化运维更安全

在启动 Bash 的时候,使用选项 -r 可以让 Bash 进入受限模式。在受限模式下,会有很多的限制,比如:

  • 不能用 cd 命令切换目录
  • 不能修改 PATH 环境变量
  • 不可以通过指定路径的方式来运行命令
  • 不能使用 I/O 重定向
  • ……

Bash 受限模式的一个常见的使用场景是自动化运维。想想看,如果系统会根据一些用户的请求来自动生成并执行一些命令,在这些命令中可能会使用一些用户的输入,这可能会产生类似 SQL 注入的漏洞攻击。再比如,在一些自动化运维的时候难免要临时切换到特权用户下执行命令。为了让系统更安全,在临时的特权用户环境里面只能执行一些特定的命令或者脚本,以尽量减少系统被攻击的可能性。使用 Bash 的受限模式就可以避免非期望的命令或者脚本被执行,让系统更加安全。

有些「Bash 命令」其实和 Bash 无关

或许有的人会把 ls、rm、mkdir、…… 等命令称为 Bash 命令,其实这些命令与 Bash 并没有关系。比如在 GNU/Linux 上,这些命令属于 GNU Coreutils 这个项目。而 Bash 命令应该是 Bash 的内建命令,比如 alias、exec、printf 等等。有些 Coreutils 中的命令因为使用的很多,为了避免无谓的进程创建开销,所以 Bash 也将其内建了,比如 echo、test 等。不过要注意的是,Bash 的内建命令的选项与 Coreutils 中的同名命令可能不同。

比如执行下列命令可能会得到不同的结果:

echo --help
/bin/echo --help

# macOS 上用 brew install coreutils 后的默认路径
/usr/local/opt/coreutils/libexec/gnubin/echo --help

我们也能够为 Bash 写单元测试

Bash 还有一个最易被人诟病的一点就是,很难去测试一个 Bash 脚本。所以这也是很多人说应该用 Python 代替 Bash 的一个理由,因为我们可以为 Python 写单元测试,而不能为 Bash 写单元测试。虽然已经有了一些 Bash 的测试框架,比如 Batsshunit2 等。但这些测试框架都是集成测试框架。这意味着,为了执行 Bash 脚本的测试用例,我们需要事先安装好被测脚本中依赖的各种工具,配置好所需的目录、配置等。比如在我们的脚本中使用了 Bazel 或者 CMake,如果没有安装 Bazel 或者 CMake,很显然这些测试都将失败。

如果可以为 Bash 脚本写单元测试,这就意味着这些测试可以在任意时候、任意的机器上乱序执行任意的测试都应该在秒级的时间内得到相同的执行结果。无论是在 Windows 上执行 GNU/Linux 的 Bash 脚本的单元测试,还是在一个全新的极简的环境中执行一个使用了很多第三方工具的复杂的 Bash 脚本的单元测试,都应该得到稳定的测试结果。

现在有一个名为 Bach 的测试框架(Bach Testing Framework),这是目前唯一的一个真正可以为 Bash 脚本编写单元测试的框架。Bach 测试框架的独特之处在于它不会执行任何在 PATH 环境变量中的命令,而且还提供了丰富的模拟命令用于模拟其它命令的行为。Bach 测试框架非常适合于测试 Bash 脚本的执行逻辑,而不用实现安装并配置好被测脚本中使用到的各个工具。

请看下面的例子:

#!/usr/bin/env bash
set -euo pipefail
source bach.sh

test-rm-rf() {
    # Write your test case

    project_log_path=/tmp/project/logs
    sudo rm -rf "$project_log_ptah/" # 这里写错了变量名,导致了一个严重的错误
}
test-rm-rf-assert() {
    # Verify your test case
    sudo rm -rf /   # 这就是将要执行的命令
                    # 别慌!Bach 不会让这个命令真的执行!
}

Bach 测试框架的每一个测试用例默认是由两个函数来组成,一个是用于编写测试用例,比如上例中的 test-rm-rf,与之对应的是用于验证测试结果的测试验证函数 test-rm-rf-assert。Bach 测试框架会分别运行这两个函数,并对比执行中调用的命令序列是否一致。