2.9

查看英文版

2.9 软件构建

概述与动机

软件构建是设计变为可运行代码的地方。它是编码、验证、单元测试、集成测试和调试的具体工作。Software Engineering Body of Knowledge(SWEBOK)指南把构建视为一个独立的知识领域,这是有道理的:这正是你日常工作的大部分所在。你逐行做出的选择()如何遏制复杂度、如何处理错误、把代码的可读性留在什么水平()决定了一个系统在未来多年内是否能够被理解、被修改、被信任。

在大型团队中,构建是一项集体工作,而不是个人独立完成的事。数百名工程师在一个共享代码库中写入代码,而这个代码库的寿命会超过任何一个人在团队中的任期。所以标准不是「今天在我的机器上能跑」,而是「五年后一个陌生人能否安全地修改它」。构建向上连接到需求(第 2.8 章)和设计(第 2.2 章),它们告诉你要构建什么、构建成什么形状。它横向连接到编码标准(第 2.1 章)、测试(第 2.4 章)和代码评审(第 2.5 章),它们塑造着这项工作如何被表达、被验证、被检查。良好的构建把一个健全的设计变成一项可维护的资产。糟糕的构建则会把即使是一个好的设计也变成一项负债。

在企业和政府场景中,构建承载着额外的分量。这些系统生命周期长、受到严格监管,且往往关乎安全或公民切身利益。防御性编码、有纪律的错误处理,以及明显正确的代码,在这里不是锦上添花,而是保障、审计和跨越数十年及人员更替所要求的持续性所必需的。目标是代码能传达其意图、能抵御失败,并且能被验证。仅仅能运行的代码是不够的。

关键原则

  • 首要目标是最小化复杂度;大规模构建的头号敌人,是没有人能完全理解的代码。
  • 预判变化;构建时要让未来可能出现的修改能够局部化、代价低廉。
  • 为可验证性而构建;编写正确性易于通过测试、评审和推理来检验的代码。
  • 有意识地复用;建立在可信的既有组件之上,而不是重复造轮子,但要避免与错误的抽象耦合。
  • 遵循标准;代码库的一致性能降低未来每一次改动的认知成本。
  • 显式地处理错误和无效状态;让失败模式可见,而不是悄无声息。
  • 保持代码可读;构建首先是与未来的维护者沟通,其次才是与编译器沟通。

建议

把最小化复杂度作为首要纪律

把降低复杂度()无论是本质复杂度还是偶发复杂度()当作你的核心目标。编写小型的、单一用途的函数和模块。用清晰的命名,而不是取巧的技巧。保持嵌套浅层、控制流线性。让决策局部化,这样理解一段代码不需要你把整个系统都装进脑子里。复杂度正是让大型代码库变得难以改动、动辄出错的根源,所以评估每一个选择时,都要看它是增加了复杂度,还是消除了复杂度。在小尺度上也要应用第 2.2 章的设计原则:高内聚、低耦合,以及清晰的关注点分离,在单个函数中和在架构层面同样重要。

为变化而构建,为可验证性而构建

提前思考最可能出现的变化(新的业务规则、新的集成、新的法规),把它们隔离在稳定的接口背后,让变化保持局部化。与此同时,编写易于验证的代码:在可能的地方使用纯函数(相同的输入始终产生相同的输出,没有副作用)、最小化隐藏状态、并让依赖关系显式化,以便测试能够替换它们。难以测试的代码,通常也是难以理解和难以修改的代码。可测试性(第 2.4 章)是一个设计信号,而不仅仅是质量保证部门关心的事。

有意识地复用并实现标准化

在重写基础逻辑之前,先寻求维护良好、值得信赖的库和内部组件,并通过清晰的接口来使用它们(第 2.3 章)。只有在真正存在第二个使用场景时才构建可复用的组件,因为过早泛化本身就是一种复杂度。统一应用你的组织的编码标准和风格(第 2.1 章),理想情况下由自动化的格式化工具和代码检查工具强制执行,这样整个代码库读起来就像出自同一位细心的作者之手。

有判断力地实践防御性编程

在信任边界(外部请求、文件和网络 I/O、用户输入)验证输入,并把任何跨越这些边界的数据都当作有敌意的,直到证明并非如此。然而在一个经过充分测试的模块内部,不要在每一行都塞满冗余检查,掩盖逻辑,压制真正的失败。规则很简单:在边界处设防,在边界内信任。使用断言来记录并强制执行那些在正确的程序中永远不应为假的不变量。使用异常和错误处理来应对运行时可能合理发生的情况。把两者分开:断言守护的是程序员的假设,错误处理管理的是预期中的失败。

显式处理错误并安全失败

对每一种错误,都要审慎地决定该怎么做:恢复、重试、传播,还是快速失败。永远不要悄悄吞掉一个异常,或忽略一个返回的错误;一个被压制的失败,日后会以一个神秘缺陷的形式卷土重来。在错误消息和日志中保留足够上下文,让失败可以被诊断。在关乎安全或公民切身利益的系统中,要失败进入一个安全的、已知的状态,而不是在一个已损坏的状态下继续运行。给错误路径与给正常路径同等的思考,因为在生产环境中,信任的得失正是在错误路径上决定的。

在构建过程中就把质量内置进去

质量是内置的,而不是事后检查出来的。在编写代码的同时编写单元测试,持续运行静态分析和代码检查工具,并让函数保持足够小、便于推理。使用自解释的命名和结构,让注释能够解释「为什么」,而不是「是什么」。随手重构,保持代码宜居。代码评审(第 2.5 章)是人工的最后一道保障,但大部分质量需要在评审开始之前就已经到位。

选择并标准化构建工具

把工具链(编译器、构建系统、格式化工具、代码检查工具、静态分析器、调试器、依赖管理器和 IDE 配置)标准化,让每一位工程师都在一致的、可复现的环境中工作。把这些工具接入流水线,这样质量检查就不是可选项。有意识地引入 AI 辅助编码工具,并把它们的输出当作一份草稿,需要通过与其他任何代码相同的标准、评审和测试。

权衡:优点与缺点

实践优点缺点
积极最小化复杂度可读、可改动、缺陷率低可能感觉进展缓慢;应用不当时有过度抽象的风险
大量防御性检查及早捕获错误状态,边界稳健使逻辑变得杂乱;过度使用会掩盖真正的缺陷
用断言保证不变量记录并强制执行假设在某些生产构建中会被禁用;不是错误处理
大量复用库自己需要维护的代码更少;交付更快依赖风险、耦合、供应链暴露面
严格的标准和代码检查代码库统一、摩擦低前期设置成本;对个人可能显得僵化
为可测试性而构建代码可验证、可改动可能增加一些人视为形式主义的间接层

构建中的核心权衡是短期速度与长期可改动性之间的取舍。走捷径(跳过错误处理、容忍复杂度、忽视标准)在当下感觉更快,而在系统的整个生命周期中,这几乎总是代价更高的选择。相反的失败模式是过度工程:过多的防御性、投机性的抽象,以及没有人需要的泛化能力。熟练的构建活在中间地带:尽可能简单,按边界所要求的程度设防,仅此而已。

与团队讨论的问题

  1. 我们对「过于复杂」这个词有没有一个共享的、具体的定义,我们在哪里、在合并之前强制执行它? 「最小化复杂度」是构建的核心纪律,但作为一句口号,它在截止日期面前总是败下阵来。在一个数百人共同写入同一代码库的大型团队中,复杂度必须是可度量的,所以要就你们真正会据以行动的信号达成一致:函数长度、嵌套深度、圈复杂度,以及一名读者为理解一次改动必须在脑子里同时记住多少东西。把你们遇到过的最糟糕的案例带到会议上,问一问你们当前的评审流程是否能够拦下它。这个问题的答案应当转化为流水线中的一道关卡,或评审清单中的一项,因为由工具强制执行的阈值,比靠意志力强制执行的原则更有价值,也能让你们下一位新员工免于承受一堆没有人敢碰的代码缓慢累积的痛苦。

  2. 在生产环境中,我们的错误路径是否按我们设计的方式运作,我们上一次刻意演练某个错误路径是什么时候? 构建方面的建议是要给错误路径与正常路径同等的思考,然而错误路径往往是你们代码库中测试最少的部分,而在一个关乎公民切身利益或安全的系统中,信任的得失恰恰在那里决定。一个被压制的异常,或一个被忽略的返回码,会在数周之后变成一个神秘的缺陷,而「失败进入安全状态」这个承诺,如果你从未亲眼见证它发生过,你就无法保证兑现。带上你们的事件历史:过去有多少次故障可以追溯到一个被吞掉的错误,或一条未经测试的恢复路径?应采取的行动是刻意测试失败情形(注入被拒的信用卡、超时、格式错误的输入),并要求每一个错误都必须被处理、带上上下文记录到日志,或被传播出去,绝不能悄无声息地被丢弃。

  3. 我们代码库的哪些部分难以测试,这种困难在告诉我们关于设计的什么信息? 抗拒测试的代码,几乎总是隐藏了状态、耦合到了错误的依赖,或者做得太多的代码,所以可测试性是一个设计信号,而不是质量保证的事后补充。在一个长生命周期的企业系统中,这一点尤为重要,因为今天难以为其编写测试的模块,正是五年后陌生人不敢去修改的模块。带上一个你们团队害怕为其编写测试的类或服务,问一问原因:是状态被隐藏了?是依赖无法被替换?还是这个函数在做三件事?答案应当推动重构走向纯函数、显式依赖,以及小型的单一用途单元,因为让代码可验证,与让代码可理解、且改动成本低廉,本质上是同一项工作。

  4. 我们在什么时候复用外部库,什么时候自己构建这项能力,我们承担的供应链风险由谁负责? 使用一个可信的库比重新发明基础逻辑更快,然而你添加的每一个依赖,都是你不掌控、无法轻易审计、且必须在它被攻破的当天就打补丁的代码。在一个大型团队中,危险在于一百名工程师各自引入自己的传递依赖,直到没有人能说清代码库实际运行的是什么。带上你们的依赖清单,问三个具体问题:有多少库已经无人维护,有多少库带有已知漏洞,有多少库封装的逻辑简单到你们完全可以自己拥有。这里的对立考量是真实存在的,因为自己实现加密或日期处理,几乎总是比使用一个久经考验的库更糟糕,所以目标是一套审慎的复用政策,而不是一概拒绝复用。在企业和政府场景中,加上采购和许可证合规的角度,因为一个未经审查的依赖,可能带有与你的义务不兼容的许可证,或者带有任何审计人员都无法接受的来源。

  5. 我们如何让 AI 生成的代码遵循与人工编写代码相同的构建标准,在关键时刻我们能分辨出两者的区别吗? AI 编码助手能快速产出看似合理的草稿,诱惑在于因为它能编译、看起来符合惯例,就把它的输出当作最终成品。本章的规则是,生成的代码要通过与其他任何代码相同的评审、测试和标准,一个大型团队必须让这条规则真正可操作,而不仅仅是一个美好愿望。带上最近上线的一些 AI 辅助改动的例子,问一问每一个是否配有测试、通过了静态分析,并且真正被提交它的那个人所理解,还是仅凭信任就被放行了。与之对立的压力是速度,因为这些助手确实能提升生产力,让每一条建议都被拖慢到蜗牛速度,就会丢掉这种收益。在受监管和政府场景中,加上来源和问责的角度,因为你可能需要证明谁对某一行代码负责,以及一段生成的代码片段是否带有你无法回答的许可证或版权问题。

  6. 我们的构建工具链是否真正实现了标准化,并在流水线中强制执行,还是个人仍在使用互不兼容的配置? 一套共享的格式化工具、代码检查工具、静态分析器、构建系统和依赖管理器工具链,能让工程师自信地穿梭于陌生的服务之间,因为代码读起来像同一个声音,检查在任何地方都是一致的。一旦出现漂移,每个团队都会重新发明自己的配置,评审时间被花在争论风格上,而本可以被某个团队的分析器捕获的缺陷,却从另一个团队的漏网之处溜走。带上那些没有在每次提交时运行标准检查的仓库清单,问一问每一个仓库为什么选择退出。这里的张力在于,单一的强制配置对确实有不同需求的团队而言可能显得僵化,所以要决定在哪些地方统一性值得付出这份摩擦,在哪些地方一个有文档记录的例外是可以接受的。对大型企业或公共机构而言,把这一点与可复现性和审计联系起来,因为一个你无法从一套受控工具链中逐字节复现的构建产物,就是一个你多年后无法向评估人员辩护的构建产物。

行业视角

初创企业。 速度制胜,所以在第一天就配置好一个共享的格式化工具和代码检查工具,在你唯一的外部边界处验证输入,让内部代码保持整洁,而不是在每一行都设防。跳过投机性的抽象和繁重的流程:两三名工程师的团队,整个代码库都装在大家的脑子里,真正的风险是复杂度的寿命超过了这份共享记忆。对任何基础性的东西都依赖可信的库,这样你需要自己维护的代码就能保持在最少。

小型企业。 在没有专职构建工程师、预算又紧张的情况下,优先选用你现有工具免费提供的约定:随语言一起提供的格式化工具和代码检查工具、合理的默认设置,以及一小组每个人都能记住的规则。购买或采用维护良好的库,而不是构建你没有人手维护的基础设施。把你有限的纪律花在被忽视时伤害最大的两件事上:在边界处验证输入,永远不悄悄吞掉一个错误。

企业。 在数百名工程师共同写入共享代码的情况下,优先事项是统一性和强制执行:一套接入流水线的标准工具链、静态分析关卡,以及在各处统一应用的边界验证规则,让人们能自信地在各服务之间迁移。把依赖和供应链风险作为一项受治理的流程来管理,而不是让各团队各自即兴处理,并使用断言来编码必须在所有团队中都成立的领域不变量。把构建标准视为让代码库在数十年和人员更替中保持宜居的基础。

政府部门。 长生命周期、关乎公民切身利益的系统,让有纪律的构建成为保障和问责的问题。把易变的规则,例如法规,隔离在稳定接口背后,让变化保持局部化,并可追溯到需求;失败要进入安全的已知状态,而不是在已损坏的状态下继续运行;每个模块都要随附能同时充当审计证据的测试。采购和透明度义务意味着,你的工具链、依赖和错误处理必须被充分记录,以便多年后到来的公务员,或一位外部审计人员,能够验证代码是正确的。

示例

初创企业。 一家三人工程师团队的初创公司在第一天就配置好了一个共享的格式化工具和代码检查工具,并在每次提交时运行它们,这样即使后来加入了承包商,代码库读起来仍像出自同一个声音。他们在 API 边界处验证输入,把外部来的一切都当作有敌意的,但内部逻辑保持整洁,而不是被冗余检查所淹没。当一个支付 webhook 开始出错时,修复很快,因为从来没有任何异常被悄悄吞掉,而错误消息带有足够的上下文,能直接指向原因。整套设置花了一个下午,却让他们免于承受本会让下一位新员工的第一周痛苦不堪的复杂度缓慢累积。

企业。 一家全球支付公司在数百名工程师中强制执行一套共享工具链:每次提交都自动格式化和代码检查、流水线中的静态分析关卡,以及一条所有外部输入都必须在服务边界处验证的规则。领域逻辑使用断言来强制执行诸如「一笔账目分录必须始终平衡」这样的不变量,而像信用卡被拒这样的运行时情况,则被作为显式的、被记录到日志的结果来处理。由于标准是统一的,错误从不被悄悄吞掉,工程师们能够自信地在陌生的服务之间迁移,生产事件也能够直接从日志中诊断出来。

政府部门。 一家国家税务机关正在构建一个预期将在不断变化的立法下运行数十年的长生命周期评税系统。构建工作把每一条税务规则隔离在一个稳定接口背后,这样每年的立法变更就能保持局部化,并可追溯到需求(第 2.8 章)。防御性验证守护着每一个面向公民的输入。错误路径会失败进入一个安全状态,绝不会悄无声息地发出一个错误的评税结果。每个模块都随附单元测试作为审计证据。由于构建被标准化且有良好的文档记录,新入职的公务员能够安全地维护那些早已离职的前任所编写的代码。

商业论证:动机、投资回报率与总拥有成本

有纪律的构建带来的回报,是长期以低成本、安全地改动软件的能力,而这正是一个系统总拥有成本大部分被决定的地方。关于软件经济学的研究一致表明,一个系统生命周期成本的大部分是维护成本,而维护成本主要取决于代码的可理解性和可改动性。最小化复杂度、显式处理错误、遵循标准,能直接降低未来每一次改动和每一次生产事件的成本。

采纳成本是适度的,且大多是前期投入:制定标准,配置好代码检查工具和分析器,并养成编写可验证、有防御性的代码的习惯。而疏于治理的代价则会不断累积。复杂度累积成难以改动、动辄出错的代码。悄无声息的错误变成代价高昂的生产事件。不一致的风格成倍增加每一次评审和每一次入职引导的工作量。要向领导层论证,把构建质量与变更失败率、平均恢复时间、缺陷逃逸率和入职引导时间联系起来,这些都是构建纪律能直接改善的指标。

反模式与陷阱

  • 复杂度蔓延: 累积取巧的、深度嵌套的、或杂乱蔓延的代码,直到没有人能理解它。
  • 悄悄吞掉错误: 空的捕获块和被忽略的返回码,把失败变成未来的谜团。
  • 防御性编程过度: 到处都是冗余检查,掩埋了逻辑,也掩盖了真正的缺陷。
  • 混淆断言与错误处理: 用断言来应对运行时情况,或用异常来表达程序员的不变量。
  • 复制粘贴式构建: 复制逻辑而不是复用,导致修复必须在多处进行。
  • 投机性泛化: 为永远不会到来的需求构建抽象和可配置性。
  • 忽视标准: 每个工程师按自己的方式编码,让整个代码库的认知负担成倍增加。
  • 未经测试的构建: 编写代码却不配套编写测试,把验证推迟到一个永远不会到来的阶段。

成熟度模型

  • 第一级(启动): 构建是临时应对、被动反应的;复杂度和错误处理因人而异;几乎不存在标准,悄无声息的失败很常见。
  • 第二级(发展): 编码标准、格式化工具和代码检查工具已经存在,基本的错误处理和单元测试是被期望的,但实践并不一致,各团队做法各异。
  • 第三级(标准化): 复杂度最小化、边界验证、显式错误处理和可测试性已被文档化,并在流水线和评审中于全组织范围内强制执行,因此整个代码库读起来就像出自同一位细心的作者之手。
  • 第四级(管理): 构建质量对照基线进行度量;团队追踪圈复杂度、缺陷逃逸率、变更失败率、错误路径测试覆盖率和代码评审发现,并依据趋势而不是个人意见采取行动。
  • 第五级(协同): 构建实践在整个组织范围内被持续改进和整合;防御性模式、标准和度量指标反馈回重构和工具建设;AI 辅助工具运行在相同的质量关卡之下,实践随语言、法规和风险的变化而调整。

讨论议题

  • 你们代码库中偶发复杂度最容易在哪里积累,是哪些构建习惯造成了它?
  • 你们团队对于何处验证输入、何处信任输入,实际的规则是什么?
  • 你们的工程师能区分断言和错误处理吗,这种区分是否始终如一?
  • 你们的质量有多少是在构建过程中就被内置进去的,又有多少是后来在评审或测试中才被发现的?
  • 在考虑到供应链风险的情况下,你们如何决定何时复用一个库、何时自己构建?
  • AI 生成的代码应该如何遵循与人工编写代码相同的构建标准?

关键要点

  • 构建是设计变成可维护代码的地方;最小化复杂度是其核心纪律。
  • 为变化和为可验证性而构建:可测试、可改动的代码就是可理解的代码。
  • 在信任边界处设防,在边界内信任,绝不悄悄吞掉错误。
  • 用断言表达不变量,用错误处理应对预期中的运行时情况;不要混淆两者。
  • 把工具和风格标准化,有意识地复用,把质量内置进去,而不是事后检查。

参考文献与延伸阅读

  • IEEE Computer Society, SWEBOK Guide (Guide to the Software Engineering Body of Knowledge), Software Construction knowledge area
  • Steve McConnell, Code Complete: A Practical Handbook of Software Construction
  • Robert C. Martin, Clean Code: A Handbook of Agile Software Craftsmanship
  • Andrew Hunt and David Thomas, The Pragmatic Programmer
  • Martin Fowler, Refactoring: Improving the Design of Existing Code
  • John Ousterhout, A Philosophy of Software Design