且构网

分享程序员开发的那些事...
且构网 - 分享程序员编程开发的那些事

构建系统之我见

更新时间:2022-01-20 04:31:05

译者注

在持续集成的流程中,软件构建是最重要的环节之一,负责生成测试和部署用的软件包。在一个大型软件项目中软件构建是一个很复杂和很耗时的工程,提高构建的效率和准确性对软件开发团队的工程效率和软件质量是至关重要的。在Google的Blaze出现之前,大部分C/C++项目都是使用GNU make作为构建工具。make应付小型的项目还可以,对于大型软件项目来说make的构建速度,准确性和多语言扩展等都是一个巨大的挑战,需要耗费大量的开发资源和服务器资源。Blaze的出现像是开了一扇天窗,受其影响出现了Buck/Pants/Please等一系列新的构建工具,继而Google开源了Blaze(命名为Bazel)。构建系统技术领域一下子活跃了起来。本文是一位构建系统的资深专家写于2018年的文章,我尝试翻译一下,和读者一起学习了解构建系统设计的一些核心要素。里面有小部分内容译者没有完全领会,且感觉不影响本文的核心内容,就选择性跳过了(手动狗头)

原文:https://ruudvanasseldonk.com/2018/09/03/build-system-insights

------ 以下为翻译内容 ------

前言

最近一些新一代的构建系统吸引了很多关注,加入到了本已挺多的构建工具的行列里。尽管这些新的构建系统的来源和设计目标不尽相同,但他们有一些共性的东西。笔者最近尝试使用了多种不同的构建系统,引发了一些思考,慢慢地形成了关于构建系统关键原则的一些浅见。在这里我列一下这些见解,探讨一下构建系统该如何工作。

缓存和增量构建

第一个同时也是最核心的见解是关于缓存。可以把构建步骤看成一个纯函数式编程的结构,output = func(input),在输入固定的情况下输出是固定的,这是缓存工作的基础。在构建系统中,构建步骤的输入决定了其构建步骤的输出,换句话说,构建输出的内容应该存储于可根据输入内容寻址的存储结构里。如果输出内容已经存在,其构建步骤可以跳过,因为即使重复执行其输出应该是始终一样的。理想情况下重复执行同样的构建步骤其输出内容应该是字节相等的,但实际场景中可能因为各种原因只能达到功能等价,这通常不是问题。如果输出内容不存在,这时缓存就能派上用场,输出内容可以从远端缓存直接获取,而不需要执行构建过程。

缓存的特点:

  1. 缓存可以存储一个构建目标的不同制品。同一个构建目标,因为代码版本变化(譬如切换了代码分支)或配置变更(譬如打开或关闭了优化开关)会生成不同的制品。如果同样条件(版本和配置)的制品已经在缓存中存在了,就没必要重复执行构建过程。
  2. 缓存可以安全地在多个不相关的代码仓库间共享。一个共享库如果被两个项目使用,没必要构建两次。
  3. 缓存可以安全地在多个机器间共享。持续集成的工作流或另一个同事构建生成的构建制品可以被从远端的缓存直接获取过来,和本地机器无关。

把构建步骤看做一个纯函数使得缓存的实现相对容易,大多数现代的构建工具都使用某种方式的不可变的根据输入寻址的缓存技术。Nix使用这种缓存技术来做系统包的管理,Bazel和SCons用来管理细粒度的构建目标,Stack用它来实现在不同仓库间共享依赖,Goma则根据输入文件和编译命令的哈希来缓存构建制品。

这里有个比较大的问题:获取一个构建步骤的所有输入可能是很困难的。这里的输入包含所有可能影响构建过程的输入文件,构建命令及参数,已经使用的环境变量等。一些构建步骤所使用的工具链可能隐式地从环境中获取一些状态,譬如从CXX环境变量或默认的头文件路径中获取状态。实际上,Nix和Bazel的实现在不遗余力的防止意外获取这些影响构建结果的输入状态,从而为工具链提供一个受控的和可重现的环境。

构建目标定义

构建目标定义内容应该尽可能和源代码放在一起

相比于在一个中心的仓库定义全局的构建目标(然后被各个模块引用),把构建目标定义在各自的模块源代码中的方式在大型的软件项目中可维护性更好。

我所了解的一些最大的代码仓库的软件项目都在使用这一原则。譬如Chromium的构建系统GN,以及它的前辈GYP。也包括Blaze及其衍生的(Pants和Buck等)构建系统。

构建目标应尽可能细粒度

相比于少量大粒度的构建目标,大量细粒度的构建目标对缓存效率和并行构建更加友好。如果一个构建步骤的输入的内容有变更而需要重新构建,更细粒度的构建目标定义可以有效缩小重新构建的范围。互相没有依赖关系的构建目标可以并行构建,所以细粒度的目标定义可以解锁更大的并行度。再者,在构建过程中一个构建目标需要等到其所有依赖的目标都构建完成后才能开始构建,如果构建目标实际只依赖其中一小部分目标,多余的构建目标将没必要的拉长构建过程的关键路径,增加了非必要的构建时间。在CPU核足够多的情况下,细粒度目标的构建总是能显著的快于粗粒度目标的构建。

我在使用Bazel的时候体会到了细粒度目标的重要性,其实这是为什么Bazel能快速的构建大型依赖图的原因。跟Bazel类似的构建工具Buck曾提及它能内部自动把粗粒度目标图转化为细粒度目标图,这也是Buck为什么快的其中一个原因。

延迟计算构建目标定义

延迟计算只计算真正需要构建的目标,可能大部分构建目标不需要解析,延迟计算能带来更高的性能,对大型仓库亦如是。

Bazel的延迟计算的实现方式是每个模块定义一个BUILD文件,以及使依赖路径和文件系统路径一致。使得没有被依赖的构建目标的文件甚至都不想要被加载。

工具链和依赖

构建工具需要管理运行时和编译器工具链

当工具链或一个依赖需要从外部系统获取的时候,一个简单的构建步骤可能变为一个耗时很长的依赖下载或涉及琐碎的配置问题,给构建过程带来很大的不确定性。构建系统的可重复性将深受其害。

一个真正的具有可重复性的构建需要受控的构建环境。
通过语言包管理器固定其所管理的构建环境的依赖包是可重复性的重要的一步,但不能完全解决问题,只要在构建过程中对构建环境有隐性的依赖(譬如通过系统包管理器安装的一些共享库或工具),”只在我的机器上能工作“的问题就始终存在。

有两种方式可以用来创建受控的构建环境:

  1. 追踪所有的隐含依赖,将它们变为显式依赖。在一个沙箱环境中构建,使得所有未声明的依赖无效,能有效的识别出隐式依赖。举个例子,如果构建步骤没有指定GCC的依赖,PATH中就没有gcc可用。Nix就是用这个方式实现的。
  2. 直接固定整个构建环境,而不是具体的依赖,譬如在Docker容器或虚拟机中构建。不过需要注意环境在初始化后不能被修改,例如,在已有容器中运行apt update将会使得构建环境成为不确定的状态。

工程效率

性能是构建系统的一个重要功能,启动时间很重要

软件开发过程中一个常见的操作是修改一小部分代码然后重新构建系统,在这个场景下最关键的是快速定位哪些构建步骤需要重新执行,这个过程中解释器或JIT编译器的开销可能是比较可观的,构建语言的设计也能影响到构建启动的快慢。

以我使用Bazel的经验来看,虽然Bazel编译大型项目很快,但启动过程比较慢。因为Bazel是在JVM上运行的,有时在一个很小的代码仓库里做一个空操作都需要好几秒,而另一个跟它相似的但是用Go实现的构建工具Please就比它快捷得多。构建目标是否能被高效的计算也是性能的一个影响因素,譬如,虽然make和Ninja都是native的工具,但因为Ninja语法更简洁,能更高效的计算构建目标,构建过程就比make要快。

小结

本文中我列出了一些关于构建系统的见解,一些可能相对深入,一些则比较粗浅。一个共同的主题是函数式编程的一些原则同样适用于构建工具,特别地,把构建步骤比作纯函数,把构建制品看做不可变的,使得高效和正确的缓存技术自然涌现出来。在操作实践方面,把构建目标定义尽可能贴近源代码使得代码仓库更易维护,细粒度的构建目标可以解锁更高的并行度从而使构建更快速。像所有好的想法一样,这些见解可能事后看起来很明显,我仍然希望看到它们能被更多的应用于构建系统的设计当中。