近年来,头条工程经历了以下几个阶段:
Dolphin 是字节内部分布式缓存系统。Dolphin通过最小化的降低真正需要编译的源文件的数量,来提升整个的编译效率。当编译一个源文件的时候,Dolphin 会通过一系列的计算,判断这个源文件是否需要真正的调用编译器进行复杂的全流程编译,如果发现满足若干条件,Dolphin 会直接从自己的缓存系统的获取编译产物,这个时长比编译器本身编译要短很多。
然而,这些方案也带来了一些明显的副作用:
考虑到头条工程所面临的问题,于 2021 年中开始探讨采用 Monorepo + Bazel 的解决方案。在《iOS Monorepo 全源码解决方案》一文中,提到了使用 Bazel 构建 Monorepo 工程的优势,包括:
具体来说,头条工程的迁移至 Bazel 可分为两个阶段:
第一阶段:迁移业务组件。 基于 JoJo 完成了业务组件源码化(约 10,000 文件数)及 Bazel(3.x)化改造,初步解决了构建耗时问题,取得了不错的效能提升。
第二阶段:迁移二三方组件 。 在依赖管理和统一构建上做更激进的尝试,基于新版本的 Bazel(5.x)和 Rules 对二三方组件进行源码化(约 18,000 文件数)和 Bazel 化改造,并完全移除依赖管理工具 CocoaPods,统一构建,提升工程稳定性,BitSky 项目基于该阶段提出。
本文接下来的内容,将重点介绍第二阶段过程中,头条是如何基于 BitSky 结合 Bazel 进行工程改造的。
在 BitSky 中,Bazel 作为构建系统的核心,负责管理和构建应用程序和库文件的编译、链接、测试等过程,并支持自定义构建规则和工具链的集成。为了更好地理解 BitSky 的构建系统,我们先介绍 Bazel 的基础概念,再介绍 BitSky 的构建系统分层。
Bazel 是 Google 内部使用(Blaze)并开源的一个通用构建系统,并且内置支持构建客户端和服务端软件,包括 Android 和 iOS 平台的客户端应用程序;还提供了一个可扩展的框架(Rules),可以使用它来开发自己的构建规则。Hermeticity(封闭性)是 Bazel 构建系统的一个重要概念,指的是构建过程的可重现性和可靠性。在 Hermeticity 的理念下,Bazel 会尽可能地隔离构建过程中的环境和依赖项,从而确保构建结果的一致性和可重复性。
以下是一些 Bazel 常用基础概念,了解其中一些概念有助于理解本文后续的内容:
Bazel 常用基础概念:https://bazel.build/concepts/build-ref
BUILD files(构建文件):用于描述一个 Bazel 项目的构建规则和依赖关系,类似于 CocoaPods 中的.podspec
文件。
.podspec
文件中的源文件和资源文件,我们可以在BUILD
文件中声明需要编译的源文件、资源文件和其他依赖库等信息。.podspec
文件类似,BUILD
文件也支持类似于版本控制的语法,可以指定具体的版本、分支或者提交号等信息。Workspace(工作空间):每个工作空间都包含一个WORKSPACE
文件,用于管理一个 Bazel 项目的依赖关系,类似于 CocoaPods 中的Podfile
文件。
Podfile
中的 pod,我们可以在WORKSPACE
文件中声明依赖关系,指定需要依赖的库、二进制文件或者其他项目。Podfile.lock
,WORKSPACE
文件不会锁定依赖的版本,而是在每次构建时重新解析依赖关系,以确保构建的一致性和可重复性。WORKSPACE
文件还可以定义一些全局配置,比如编译器选项、构建工具选项等等。这些配置可以被整个项目共享,确保项目的构建和依赖关系的一致性和可重复性。Packages(包):是包含一个BUILD
文件的目录,用于组织和管理代码库中的源代码和构建规则。
Targets(目标):表示构建规则和依赖项的目标,它可以是源文件、库文件、可执行文件等等。
Labels(标签):标识构建规则和依赖项,它由包名和目标名组成。
本章节介绍为 BitSky 中的构建系统模块,完整的 BitSky 模块架构请关注之前文章:《iOS Monorepo 全源码解决方案》。BitSky 的构建系统分为五个分层,包括工程配置、软件服务、构建系统、构建规则和构建工具,如下图所示:
工程配置
为宿主工程提供客制化的配置能力,以及语义化的调用命令。这一层的作用是让用户可以方便地配置 BitSky 构建系统。
软件服务
包含 BitSky、Tulsi 和 BuildService 三个工具。这一层的作用是为用户提供一系列构建工具,使得用户可以更加方便地使用 BitSky 构建系统。
.xcodeproj
工程文件,基于自研的轻量依赖管理能力,自动转换为 Bazel 构建所需的WORKSPACE
/BUILD
文件,不需要终端用户手工生成工程物料。构建系统
Bazel 构建系统的设计理念是分层架构,其中构建系统层、构建规则层和构建工具层是由 Bazel 决定的。所以构建系统层也是 BitSky 的核心层,包含 Bazel、Dep Server 和 Remote Execution Services,这一层的作用是为了支持分布式计算集群上执行构建和测试任务。
构建规则
Rules(构建规则)是 Bazel 构建系统的执行基石——定义构建规则和依赖项的基本单元。Rules 是基于 Starlark 语言(Python 子集语言),将构建过程分解为一系列可重复的步骤,并定义了这些步骤之间的依赖关系。这一层的作用是为了保证构建的正确性和可重复性。
objc_library
、cc_library
等常用规则,用于定义和管理编译、链接、测试等构建规则和依赖项。apple_binary
、apple_library
等,用于定义和管理苹果平台应用程序和库文件的编译、链接和打包规则。.podspec
文件转换成BUILD
文件。Bazel 构建系统能正常运行取决于调用编译器、链接器等工具链时编译参数以及构建依赖的正确性,通过该研发工具将构建规则迁移至 Bazel 体系。构建工具
是 Bazel 构建系统的工具集合,包含 Rules 提供的 Wrapped Tools 和宿主工程提供的 Custom Toolchains 两个部分。这一层的作用是为了提供必要的构建工具。
如前文所说,头条工程在前几年已经完成了组件化的演进,近 500+ 个组件都由 CocoaPods 进行包管理,每个组件都有一个.podspec
文件,用于描述组件的版本信息、构建所需的源文件和配置等。从这个角度来看,BUILD
文件和.podspec
文件的作用类似。在迁移到 Bazel 构建系统时,头条工程只需要添加WORKSPACE
文件,并将.podspec
文件转换为BUILD
文件即可。WORKSPACE
文件主要用于描述外部依赖,接入成本较低,因此不在此讨论,本文重点介绍BUILD
文件的转换思路。
在 Bazel 构建系统中,Objective-C 源文件可以使用objc_library
规则作为最小编译目标,而 Swift 源文件则对应swift_library
规则。以objc_library
为例,bazel_generator 将.podspec
文件转换为BUILD
文件的过程如下:
解析.podspec
文件,包括依赖库、源文件、编译选项等。
生成BUILD
文件。根据.podspec
文件中的信息,bazel_generator 会生成适用于 Bazel 的BUILD
文件。对于objc_library
规则,生成的BUILD
文件通常包括以下内容:
处理依赖关系。由于 Bazel 和 CocoaPods 的依赖管理方式不同,bazel_generator 需要处理依赖关系。具体来说,bazel_generator 会将 CocoaPods 中的依赖库转换为 Bazel 中的依赖库,并将其添加到BUILD
文件中的 deps 列表中。
处理资源文件。如果.podspec
文件中包含资源文件,bazel_generator 会将其转换为 Bazel 中的 data 属性,并将其添加到BUILD
文件中。
处理其他配置项。bazel_generator 还会处理其他一些配置项,例如编译选项、头文件搜索路径等。
通过这些步骤,bazel_generator 将.podspec
文件转换为适用于 Bazel 的BUILD
文件,并自动处理依赖关系、资源文件等问题,从而简化了迁移过程。
在理解了.podspec
文件转换为BUILD
文件的过程后,下面我们介绍一下头条是如何完成组件迁移的。首先,头条对 500+ 二进制化组件进行拆分,分为业务组件和二三方组件。
由于业务组件仅集成到头条工程,并不会提供给其他工程复用,因此头条优先考虑将业务组件全源码化并集成到主仓的Module
目录中。这样做的好处是,可以减少二进制依赖,提高构建效率,同时也方便开发人员进行维护和升级。
BUILD
文件,头条会首次自动生成,后续则由研发人员进行维护和升级。Pods
目录中,并通过 CocoaPods 进行集成。为了方便管理和更新二三方组件,头条将其全源码化并集成到主仓的External
目录中,并通过monorepo_config.yml
和deps.yml
这两个文件提供版本和依赖管理的凭证。
BUILD
文件,头条使用 bazel_generator 工具,通过.podspec
文件自动转换生成。这样做的好处是,可以减少手动编写BUILD
文件的工作量,提高工作效率。monorepo_config.yml
文件记录了所有组件对应的仓库信息(Git 来源/二进制链接来源)和组件版本信息,用于组件源代码回溯。这样可以方便地查找和管理组件的源代码,同时也可以保证组件的版本一致性。deps.yml
文件记录了工程中各个 Target 的依赖组件及包含的 subspecs。这样可以清晰地了解工程中各个 Target 的依赖关系,便于管理和维护。经历完第二阶段后,头条已经完成了组件的 Monorepo 全源码化,放弃了 CocoaPods 作为依赖管理工具,转而将所有组件放到宿主工程中。这带来了以下问题:
BUILD
文件中控制,而 CocoaPods 的 Hook 调用时机在集成阶段,统一对组件的构建选项做修改,属于中心化管理。因此,构建系统需要具备管理感知能力,Bazel 成为更好的选择。我们不应该在依赖管理工具中介入构建选项,应该将其隔离开来。为了满足宿主工程的定制化需求,我们需要提供具备以下能力的机制:
为了更好地介绍这个机制,我们首先需要了解 BitSky 和 Bazel 的调用时序:
从上图可看出,我们在 Plugin(插件)中可以根据不同宿主工程的需求提供定制化的构建选项,从而也降低了 BitSky 和 Bazel 之间的耦合度。为了达到该目的,我们结合 Bazel 的特性,把插件的组成划分为以下 4 个部分,接下来的内容将会一一介绍各个部分。
钩子函数是工程配置插件化的重要组成部分,其作用是在特定的时机执行额外的配置工作或操作。BitSky Plugin 提供了四个钩子函数,如下图所示:
pre_generate_material_hook(obj)
和post_generate_material_hook(obj)
分别在生成宿主工程WORKSPACE
文件和BUILD
文件之前和之后调用,并且通过 obj 提供所需的构建参数。可以在这个时机进行额外的配置工作,如根据不同的构建场景拉取对应的配置文件等。pre_build_hook(obj)
和post_build_hook(obj)
则在构建前后调用,可以用于执行额外的操作,如清理、打包、上传等。defs.bzl
是 Bazel 的一个规则文件,用于定义自定义的规则和函数。在 BitSky Plugin 中,defs.bzl
文件定义了宿主工程所需的构建选项和自定义规则,并提供了统一管理宿主工程的构建目标入参的功能,解决组件 构建选项管理的问题。如下图所示,可定向对ios_application
依赖的objc_library
规则传入客制化的 copts。
结合 Bazel 的特性,defs.bzl
能够解决以下具体问题:
defs.bzl
文件通过传值机制,可以帮助开发者针对不同的宿主工程进行定向配置构建目标参数,确保 BitSky 不需要关注各宿主工程的具体目标参数,而是由各宿主工程自行决策。defs.bzl
文件可以帮助开发者定义自己的规则和函数,实现更加灵活和定制化的配置。BUILD
文件可能会有不同的构建目标入参,defs.bzl
文件可以帮助开发者实现统一管理宿主工程的构建目标入参,包括 BitSky 自动生成的和研发维护的BUILD
文件。defs.bzl
文件可以帮助开发者定义自己的规则和函数,实现更加灵活和定制化的配置,增强工程配置的可扩展性。在 Bazel 官网的最佳实践中提及:工程的特定选项可使用.bazelrc
文件管理。通过--bazelrc=<path to rc>
传入指定.bazelrc
文件,用于设置 Bazel 的运行时参数和环境变量。
Bazel-最佳实践:https://bazel.build/configure/best-practices#bazelrc-file
.bazelrc
文件可以定义诸如构建选项、构建缓存、构建工具链、构建输出路径等等配置选项。配置使用.bazelrc
文件有以下好处:
.bazelrc
文件可以定义构建选项,例如编译器的版本、编译参数、构建输出路径等等,可以根据不同的需求和场景进行灵活的配置和扩展,满足不同项目的需求。.bazelrc
文件可以定义构建工具链,可以根据不同的需求和场景进行配置,方便管理和维护构建工具链。.bazelrc
文件可以定义构建选项和构建工具链,可以根据不同的需求和场景进行配置,提高构建的可移植性,使得构建结果更加稳定和可靠。综上,.bazelrc
文件可以帮助开发者更好地管理和维护构建配置,提高配置的灵活性、可维护性、可复用性、可扩展性和可移植性。
conditions(条件)是 Bazel 中用于根据不同的条件选择性地应用构建规则的一种机制。通过 conditions,开发者可以根据不同的条件(如操作系统、编译器版本、CPU 架构等)选择性地应用构建规则,从而实现更加灵活和定制化的构建流程,解决组件 配置条件管理的问题。以下是头条工程中的一个应用场景:
//conditions:default
,为统一代码风格,宿主工程可在conditions
目录下的BUILD
文件中声明自定义配置条件,便于用以下方式访问自定义的条件标签://conditions:debug
或//conditions:release
。select()
函数区分 debug / release 配置下所需的选项,通过这种机制对齐 Xcode 中的 Debug / Release 配置;对于有多个配置的工程,可以按需增加config_setting
规则。头条工程在各个阶段的迁移中,均取得了不错的效能提升:
在头条工程迁移至 Bazel 过程中,除了上文提及的问题,还遇到了三个主要的挑战,包括:
为了解决这些问题,头条工程采取了一系列的措施,保证迁移的顺利进行。
由于历史原因,头条工程在pod analyze
阶段会忽略.podspec
文件声明的组件依赖信息,完全置信于Podfile
文件维护的组件版本。如果是集成二进制后的二三方组件,这个方案能极大地降低pod analyze
阶段的耗时。若集成全源码化的二三方组件,因组件.podspec
文件中声明的组件依赖信息和Podfile
文件中声明的未必一致,有较大概率导致构建失败;同时,使得原本能在pod analyze
阶段发现的问题,推迟到了构建甚至运行时才能被发现,也使得组件的增删改等维护变得复杂。在迁移的过程中,头条工程会处于构建系统双跑阶段,为保障 Bazel 构建成功率,也同步进行二三方组件依赖治理:
.podspec
文件中的组件依赖信息。二三方组件迁移的过程中,由集成二进制文件改成集成源码,而各组件二进制化的构建环境和宿主工程当前的构建环境未必一致,这就造成二三方组件迁移后产出的二进制文件和原本的不一致,最终导致程序在运行时的表现有差异。为此,在构建系统双跑阶段,会自动触发校验工具对比迁移前后的产物,并输出有差异的符号。
segment_command_64
获取映射到程序地址空间的位置和大小,然后基于.linkmap
文件中的符号信息,计算出这些符号真正的文件偏移量地址,然后读出内存数据进行对比。具体实践会在后续的系列文章中介绍。头条工程的构建环境可以分为两种:CI-CD 和本地研发。CI-CD 是在云服务上部署的,构建环境是统一且可控的,集群内的设备都部署了相同版本的工具链。而本地研发的构建环境则更加多样化且不受控制。其中最明显的差异是构建所使用的Xcode版本。在本地研发环境下,头条工程会生成多个 Xcode 版本的构建缓存,影响本地研发的构建缓存复用。此外,不同的 Xcode 版本包含的工具链版本也不同,因此无法保证本地研发和 CI-CD 的构建产物一致,如下图所示:
Xcode | cctools | ld64 | LLVM | Clang | Swift |
---|---|---|---|---|---|
14.0 | 1001.2 | 819.6 | 14.0.0 | 14.0.0 (clang-1400.0.29.102) | 5.7 (swiftlang-5.7.0.127.4 clang-1400.0.29.50) |
14.0.1 | 1001.2 | 819.6 | 14.0.0 | 14.0.0 (clang-1400.0.29.102) | 5.7 (swiftlang-5.7.0.127.4 clang-1400.0.29.50) |
14.1 | 1001.2 | 820.1 | 14.0.0 | 14.0.0 (clang-1400.0.29.202) | 5.7.1 (swiftlang-5.7.1.135.3 clang-1400.0.29.51) |
14.2 | 1001.2 | 820.1 | 14.0.0 | 14.0.0 (clang-1400.0.29.202) | 5.7.2 (swiftlang-5.7.2.135.5 clang-1400.0.29.51) |
14.3 | 1005.2 | 857.1 | 15.0.0 | 14.0.3 (clang-1403.0.22.14.1) | 5.8 (swiftlang-5.8.0.124.1 clang-1403.0.22.11.100) |
14.3.1 | 1005.2 | 857.1 | 15.0.0 | 14.0.3 (clang-1403.0.22.14.1) | 5.8.1 (swiftlang-5.8.0.124.5 clang-1403.0.22.11.100) |
通过上文提及的限定配置条件,指定支持的 Xcode 版本,可解决部分构建环境不统一的问题:
conditions/BUILD
文件定义xcode_config
规则,标签是//conditions:host_xcodes
,用于声明支持的 Xcode 版本。.bazelrc
文件设置--xcode_version_config=//conditions:host_xcodes
,指定当前工程使用的 Xcode 版本。本文主要介绍了头条迁移至 Bazel 的历程,包括构建系统的架构分层和接入方案,以及结合 Bazel 的特性来优化工程配置的管理。头条迁移至 Bazel 后,研发效率和构建稳定性都有显著提升。由于采用了 Monorepo 全源码的集成方案,头条也加快了依赖治理和架构演进方向的工作进展。然而,由于篇幅限制,本文只介绍了迁移 Bazel 过程中的部分细节,而 Infra 团队在这一路的探索远不止于此。在本系列的文章中,我们将继续介绍头条在本地研发IDE和开发流程迁移方面所面临的挑战。