第 32 条:搭建持续集成(CI)系统

持续集成系统是一种在代码库发生变更时,自动运行一系列工具对代码进行检测的机制。

搭建持续集成系统的建议不是 Rust 特有的,所以本章节的内容涵盖了通用建议以及一些 Rust 特有的工具的建议。

CI 步骤

具体而言,你应该在 CI 系统中包含哪些步骤?很明显,至少应该包含以下内容:

  • 构建代码
  • 测试代码

CI 应该顺畅、快速且确定的运行每个步骤,并且不能产生结果误报。更多内容将在下一节详细介绍。

“确定性”要求还引出了对构建步骤的建议:使用 rust-toolchain.toml 指定所用工具链的固定版本

rust-toolchain.toml 文件指定了构建代码时所用的 Rust 版本号,可以是一个具体的版本(例如:1.70),也可以是某个渠道(stablebeta 或者 nightly),渠道后也可以跟随具体的构建日期(例如:nightly-2023-09-191。如果选择的是不固定的渠道值,那么随着工具链新版本的发布,可能会导致每次 CI 构建结果不同;使用固定的版本号可以使得构建结果是确定的,屏蔽掉工具链发布新版本带来的结果不确定问题。

在本书的其他章节都有建议通过使用工具来提升代码质量,在 CI 系统中,亦是如此。例如,针对 CI 系统中的两个基本步骤,可以采用一些工具来提升效果:

  • 构建代码
    • 第 26 条建议使用特征来根据条件编译不同的代码片段。如果你的 crate 中包含特征,应在 CI 系统中构建所有可能的特征的组合,但是这会导致 2N 种可能的组合方式,所以建议避免 crate 的特征蔓延。
    • 第 33 条建议考虑尽可能将库代码设计成 no_std 环境兼容的。只有在 CI 系统通过 no_std 的兼容性测试,才可以确保代码是 no_std 兼容的。可以利用 Rust 编译器的交叉编译能力,显式指定 no_std 目标构建(例如:thumbv6m-none-eabi)。
    • 第 21 条中探讨了代码支持的最低 Rust 版本(MSRV)的问题,如果你的代码包含了 MSRV,那么应该在 CI 系统中包含针对对应 Rust 版本的测试
  • 测试代码
    • 第 30 条讲述 Rust 项目不同类型的测试,应在 CI 系统中执行全部的测试。有些类型的测试可以在 cargo test 中自动包含(例如:单体测试、集成测试、文档测试等),但是另外一些类型(比如:示例代码)的测试就需要显式触发了。

除此之外,还有一些工具和建议可以用来提升代码质量:

  • 第 29 条中,阐述了在代码上使用 Clippy 的好处,同样的,应在 CI 中运行 Clippy。在 CI 中运行 Clippy 的时候,为了确保对失败进行明确的标记,应该设置 -Dwarnings 选项(cargo clippy -- -Dwarnings)。
  • 第 27 条建议为公开 API 编写完整的文档。使用 cargo doc 来测试文档的正确性以及其中的超级链接是否都被正确的解析。
  • 第 25 条中提到了使用诸如 cargo-udepscargo-deny 这样的工具来管理依赖图。在 CI 系统中也应该运行这些命令以避免潜在的问题。
  • 第 31 条讲述了 Rust 的工具生态系统。应当考虑在 CI 系统中定期使用这些工具。例如,可以使用 rustfmt 或者 cargo fmt 来检测未遵守格式规范的代码。为了确保标记失败点,请设置 --check 选项。

你也可以在 CI 系统中包含一些用来测量代码某些切面的步骤:

  • 使用 cargo-tarpaulin 生成代码覆盖统计数据,可以显示测试用例对代码的覆盖率。
  • 使用 cargo bench(见第 30 条)执行基准测试,以检测在某些关键场景下的代码性能。但是,很多 CI 系统是运行在共享环境中的,外部因素影响测试结果是不可避免的。如果希望得到更加精准的基准测试数据,那就需要更加独立的 CI 环境。

由于测量结果只有在和之前的结果对比来看才有意义,所以在 CI 系统中搭建用于测量的步骤略显复杂。理想情况下,当代码发生变动,CI 系统需要检测测试用例是否完全覆盖了这些变动以及这些变动是否对性能存在负面影响,这通常需要集成外部跟踪系统。

下面是一些关于 CI 中步骤的建议,不一定适用于你的代码库,但是不妨了解一下:

  • 如果是一个库项目,请记住(见第 25 条)项目中所包含的 Cargo.lock 文件会被使用这个库的用户忽略。理论上,Cargo.toml 中语义化版本号(见第 21 条)约束应该可以让使用库的项目正常工作。实际上,应该在 CI 系统中包含不依赖 Cargo.lock 构建项目的步骤,以检测所用依赖的当前版本(根据语义化版本规则拉取到最新的符合要求的依赖)是否可以正常工作。
  • 如果项目中包含机器生成的资源,并且这些资源提交到版本控制系统(例如使用 prost 命令生成的 protocol buffer 消息),那么,建议在 CI 系统中包含重生这些资源的步骤,并和版本控制系统中的比较,确保其一致性。
  • 如果你的代码库包含平台特有(例如:#[cfg(target_arch = "arm")])的代码,应该在 CI 系统中包含相应的步骤,确保针对特有平台可以成功构建且可以正常工作。由于 Rust 的工具链具有交叉编译的能力,所以前者比后者更简单。
  • 如果你的项目需要处理诸如访问令牌或加密密钥等保密信息,考虑在 CI 系统中引入检查代码是否包含机密信息的步骤。如果你的项目是公开的,这一点尤为重要(在这种情况下,建议将该检查从 CI 阶段前置到版本控制预提交检查阶段)。

CI 系统中不是所有的步骤都需要和 Cargo 或者其他 Rust 工具链集成,有时,一个简单的 shell 脚本文件即可获得很好的效果。例如,你的代码库中包含了一些并非普遍遵循的特有约定:所有包含 panic 的方法调用都应该具有包含特殊标记的注释(见第 18 条),或者,每个 TODO: 项都要有所有者,可以是他的名字,也可以是一个跟踪 ID。这种情况下,shell 脚本文件是可以满足需求的理想工具。

最后,可以通过研究公开的 Rust 项目所用的 CI 系统,来获取一些适用于你自己项目的额外 CI 步骤。例如,Cargo 项目就有包含很多步骤的 CI 系统,希望可以给你一些启发。

CI 基本原则

从前面提到的具体的指导之外,还有一些总体原则可以给你 CI 系统细节方面的指导。

最基本的原则是:不要浪费人类的时间。如果 CI 系统不必要的浪费了人们的时间,就需要寻找避免这种浪费的解决办法。

最让工程师恼火的时间浪费是不稳定的测试,这种测试有时能够通过,有时无法通过,哪怕是配置和代码库都完全相同。对不稳定的测试要毫不留情:搞定它!从长远来看,花时间来研究和修复导致不稳定测试结果的原因,这种投入是值得的。

另外一种常见的时间浪费就是 CI 系统执行时间过长,并且,它仅在发起代码审查请求之后才运行。在这种情况下,可能浪费了作者和代码审查者两个人的时间,后者可能不得不花费一定的时间指出代码中的问题,而这些问题本来可以由 CI 机器人标记出来的。

为了解决这个问题,尽量让手动执行 CI 检查变得容易些,并且独立于自动化的体系。这可以让工程师养成定期执行检查的习惯,代码审查者就不会看到 CI 能够标记的问题了。更好的做法是,通过在编辑器或者 IDE 中集成一些工具,让检查用起来更顺畅,例如,糟糕格式的代码永远不会被保存在磁盘上。

如果 CI 系统中存在一些耗时较长但是很少发现问题的测试,那么应该将其拆分开来,仅作为测试极端场景的后备方案。

更普遍地说,一个大型项目要根据 CI 检查运行的频率来拆分开来:

  • 集成到每个工程师开发环境中的检查,例如:rustfmt
  • 每次代码审查时要做的检查,并且最好可以方便地手动执行。例如:cargo buildcargo clippy
  • 每次向主干分支合并代码时要做的检查,例如使用 cargo test 在所有支持的环境中的完整测试
  • 定期执行的检查,可能是每天或者每周运行一次,可以在事后捕获到罕见的回归问题。例如:长时间运行的集成测试和基准对比测试
  • 随时在代码上运行的检查,例如,模糊测试

将 CI 系统和代码审查系统集成是非常重要的,这样一来,代码审查者可以清晰地看到由 CI 标注的通过检查的部分,让其可以集中关注代码的意义,而无需在琐碎的细节上花费时间。

同样,一个成功的构建也要求代码可以通过 CI 系统所有的检查,无一例外。即使偶尔你需要处理工具产生的误报,这也是值得的。一旦你的 CI 系统中存在可接受的失败(“哦,大家都知道这个测试从来没有通过”),那么发现新的回归问题将会变得非常困难。

第 30 条建议,在修复 bug 之前,先增加能够重现 bug 的测试用例。这个建议也适用于 CI 系统:当你发现 CI 系统中存在流程问题,那么在修复问题之前,增加一个检测问题的步骤。例如,如果你发现一些自动生成的代码和它的源代码不同步,先在 CI 系统中增加一个检查步骤,直到问题修复,否则这个检查一直是失败的 —— 这让你可以确信在将来不会再次发生类似的错误。

公共 CI 系统

如果你的代码库是开源的并且是公开的,使用 CI 系统时有一些额外的事情需要考虑。

首先,好消息是,有很多免费的、可信赖的开源的 CI 系统。截至成稿时,GitHub Actions 可能是最好的选择,但不是唯一的选择,还有很多可选的 CI 系统。

其次,对于开源项目,值得牢记的是,你的 CI 系统可以作为设置代码库先决条件的指南。对于纯 Rust crate 而言不是问题,但是如果你的代码库需要额外的依赖项 —— 例如数据库、用于 FFI 代码的替代工具链、自定义的配置等 —— 那么 CI 脚本就是如何让这个项目在一个全新环境上成功运行的有力佐证。把这些步骤编码到可重用的安装脚本中,可以让人们以及自动化机器人轻松获得一个可正常运行的系统。

最后,对于公共可见的 crate ,坏消息就是存在被滥用或被攻击的可能性。可能包括:利用你的 CI 系统挖矿、窃取代码库令牌、供应链攻击等。为了降低这些风险,请考虑以下指南:

  • 限制访问。确保 CI 脚本仅对已知的协作者自动运行,对于新加入的协作者需要手动执行。
  • 所使用的任何外部脚本,都固定到可信的版本,或者,更优的做法是固定到已知的脚本哈希值。
  • 密切关注任何对代码库超出读取权限的集成步骤。

原文点这里查看


注释

1

如果你的代码确实依赖一些仅在每日构建版本的编译器中提供的特征,可以通过 rust-toolchain.toml 文件来清晰指明该工具链的依赖。