本期作者
黄忆旻
哔哩哔哩资深开发工程师
0x00 前言
我们哔哩哔哩的 iOS 工程使用了基于 Bazel 的 Monorepo 模式(不了解 Monorepo 的小伙伴可以参考这篇文章 基于 Bazel 的 iOS Monorepo 工程实践)。
Monorepo 和 Bazel 给我们带了很多优势,比如快速、正确、易扩展、易协作等。虽然 Bazel 早期版本对于 iOS 开发并不太友好,甚至让一些团队产生了放弃使用 Bazel 的想法;但 Bazel 本身极其优秀的扩展性,使得社区对其一直抱有极高的热情,开发出了许多优秀的 Rules,促使社区和 Bazel 本身一直向前进化,如今的 Bazel 已不同往日,一些早期令人诟病的问题也被一一解决。目前我们正在使用 Bazel 6.1.2 版本,且即将紧跟官方的脚步更新到 6.3.0 版本。今天我们就来聊聊我们哔哩哔哩近一年(主要是Bazel 5.0.0 版本以后)所应用的一些新特性、新 rules 以及在自建 rules 方面的探索。
0x01 Bazel Modules
在 Bazel 5.0.0 版本以前,Bazel 的外部依赖管理问题就一直被人诟病。虽然在 Monorepo 内部,包的依赖管理由于没有版本的概念,而变得非常简洁明了。但我们总会使用到一些 Monorepo 外的第三方库。这些外部依赖在早期的 Bazel 中被设计在 WORKSPACE 文件中管理,于是问题出现了:
WORKSPACE 模式存在的问题
不确定的依赖版本
虽然可以在外部依赖项中使用 deps.bzl 来声明它自己的其他外部依赖,而做到在 Monorepo 中隐藏递归依赖项,但这样很可能会引起菱形依赖问题。
菱形依赖问题简单来说就是 A 模块直接依赖 B 和 C 模块,B 直接依赖 D 的 1.1 版本,而 C 直接依赖 D 的 1.2 版本,那么最终 A 到底依赖的是 D 的哪个版本——这类问题。这个问题在 WORKSPACE 模式的 Bazel 工程中,我们很难评估最终使用的 D 到底是哪个版本,这由声明的顺序以及 load 语句所在位置等等复杂的因素相关,而通过 deps.bzl 的隐式依赖往往会被我们忽视,导致之前在 WORKSPACE 中声明或者重写的版本被覆盖。在 WORKSPACE 模式下,如果进行了外部依赖的修改或升级,甚至只是调换了声明的顺序,我们都很难直观地判断最终到底使用了 D 的哪个版本,D 模块变成了薛定谔的猫一样的存在:在执行构建前,没有人能拍着胸脯说 D 到底使用了哪个版本!这是一件非常可怕的事情,这意味着即使是对外部依赖的细微修改也可能会导致构建过程不在我们的控制之中。
冗长的 WORKSPACE 文件
由于很难评估外部依赖的版本,因此一般情况下,我们需要在 WORKSPACE 文件中声明我们所需的所有递归的外部依赖项。这样就会使得我们的 WORKSPACE 文件变得异常冗长,难以维护。冗长的 WORKSPACE 又加剧了评估外部依赖版本号的难度。
就拿我们自研的一个容器化自动编排的 rules 仓库举例,WORKSPACE 文件大概长这样:
workspace(name = "build_bazel_rules_gripper")load("@build_bazel_rules_gripper//:repositories.bzl","gripper_rules_dependencies",)gripper_rules_dependencies()load("@com_github_buildbuddy_io_rules_xcodeproj//xcodeproj:repositories.bzl","xcodeproj_rules_dependencies",)xcodeproj_rules_dependencies()load("@build_bazel_rules_swift//swift:repositories.bzl","swift_rules_dependencies",)swift_rules_dependencies()load("@build_bazel_rules_swift//swift:extras.bzl","swift_rules_extra_dependencies",)swift_rules_extra_dependencies()load("@cgrindel_rules_spm//spm:deps.bzl","spm_rules_dependencies",)spm_rules_dependencies()load("@cgrindel_rules_spm//spm:defs.bzl", "spm_pkg", "spm_repositories")spm_repositories(    name = "swift_pkgs",    dependencies = [        spm_pkg("https://github.com/apple/swift-syntax",            name = "SwiftSyntax",            revision = "swift-5.7-DEVELOPMENT-SNAPSHOT-2022-08-02-a",            products = ["SwiftSyntaxParser", "SwiftSyntaxBuilder"],        ),        spm_pkg("https://github.com/apple/swift-argument-parser",            name = "swift-argument-parser",            from_version = "1.1.3",            products = ["ArgumentParser"],        ),    ],)
这已经是一个非常精简的 Bazel 项目了,但 WORKSPACE 文件还是要有50行,相信有一大部分同学在第一次接触这个 WORKSPACE 时就已经打起退堂鼓了。
无法优雅地使用统一代理
在 Monorepo 中我们往往会有一些 github 或 google 的第三方外部依赖,在不通过代理服务器的情况下,直连往往无法稳定地访问和获取这些外部依赖。在 WORKSPACE 模式下,我们通常的做法是通过代理服务把这些外部依赖拉到本地,然后再 push 到内网的 gitlab 上,然后通过修改 WORKSPACE 中外部依赖指向的 URL 来达到稳定拉取这些第三方依赖的效果。但由于我们的外部依赖非常多,而且有许多隐式的间接依赖项,因此找出所有的依赖项,并重复这样的操作非常低效而且枯燥。我们迫切地希望可以在 Bazel 工程中做一些配置来指定统一的企业内的代理服务器来访问这些依赖,但是在 WORKSPACE 模式下我们无法优雅地实现这个诉求。
Bazel Modules(Bzlmod)
Bazel 官方其实也发现了这些问题,因此在 bazel 5.0.0 版本推出了 Bazel Modules 作为 WORKSPACE 的替代方案。
通过在构建指令中增加 option:--enable_bzlmod,即可开启 Bazel Modules。
一个 Bazel Module 本质上就是一个 Bazel 项目,项目可以包含多个版本,每个版本发布它所依赖的其他 bazel module 的 metadata。这和其他依赖管理系统的概念非常相似。
当我们需要依赖一个 Bazel Module 时,我们只需要指定 module 名和版本号,而不是 URL。Bazel 在构建过程中会去 Bazel Central Registry (BCR)上查找真正的依赖。BCR 可以看作一个依赖管理的注册中心,有官方仓库,也可进行私有化部署,熟悉 CocoaPods 的同学,可以将 BCR 类比于 CocoaPods 中 Specs 仓库,它们的作用几乎完全一致,某种程度上来说这也属于殊途同归了。
在我们把外部依赖方式转换到 Bzlmod 以后,终于发现整个世界变得如此美好,且听我细说它带来的优势:
确定的依赖版本
菱形依赖问题是依赖管理的核心问题之一,Bzlmod 使用 Go 模块系统中引入的最低版本选择 (MVS) 算法,MVS 假定 Moudle 的所有新版本都向后兼容,因此会选择依赖图中指定的最高版本,比如在下面这个菱形依赖问题的例子里它选择的就是1.2版本。
另外 Bzlmod 还引入了“兼容性级别”的概念,由每个 Module 版本的 module() 指令中指定。有了这些信息,Bazel 可以在解析的依赖关系图中检测到兼容程度不同的同一模块的版本时,就会抛出错误。之前不确定的依赖版本问题不复存在。
精简的外部依赖管理文件
使用 Bzlmod 后,MODULE.bazel 替代了原本 WORKSPACE 文件的位置。MODULE.bazel 相较于老的 WORKSPACE 文件,变得异常简洁清晰,可读性更强,更容易进行管理。熟悉 CocoaPods 的同学可以直接将其类比为 Podfile 文件。
还是拿我们自研的容器化自动编排的 rules 仓库举例,改造后的 MODULE.bazel 文件大概长这样:
module(name = "rules_gripper", version = "0.2.4")bazel_dep(name = "rules_swift", version = "1.6.0")bazel_dep(name = "rules_apple", version = "2.1.0")bazel_dep(name = "swift_argument_parser", version = "1.2.0")bazel_dep(name = "swift_syntax", version = "0.50700.1")bazel_dep(name = "swiftgraph", version = "3.1.0")bazel_dep(name = "swiftast", version = "0.0.4")bazel_dep(name = "rules_xcodeproj", version = "1.2.0")
原本50行的 WORKSPACE 文件所描述的内容,在 MODULE.bazel 中10行内就可以结束战斗。泰酷辣!
优雅地使用统一代理
通过修改 BCR 的配置,我们可以非常优雅地指定统一代理服务器来稳定地访问和获取 github 以及 google 的外部依赖。不用修改任何 URL,也不用再做把外网 github 上的依赖 push 到内网 gitlab 上这样重复枯燥的工作了。
由于上面的这些优势,Bzlmod 的外部依赖管理能力已经远远超出了5.0.0版本以前的 WORKSPACE 模式。目前我们哔哩哔哩的 iOS 大仓已经完全切换到了 Bzlmod。
0x02 Rules Xcodeproj
早期 Bazel 在 iOS 开发上另一个比较大的黑点是非常差的开发调试体验,本身 bazel 项目的构建是可以完全脱离 Xcode 的,但是想要进行编辑和调试,还是离不开 IDE 的支持。
蛮荒时代—— Tulsi
早期 Bazel 通过 Tulsi 这个工具来生成 xcodeproj 工程来解决开发调试的问题。Tulsi 把工程伪装成 Xcode 生成的原生工程,工程使用原生的 Xcode 索引和调试机制,但是用 Bazel 接管了构建系统。
哔哩哔哩 iOS 客户端大约有400万行代码,是一个非常庞大的客户端工程。由于本身 Xcode 对于大型项目就已经很捉襟见肘了,再加上 Xcode 本身的 Build Settings 和实际 Bazel 构建指令的割裂,Xcode 又无法还原 Bazel 的构建顺序而导致在索引阶段会执行错误的编译指令,于是我们得到了这样的一个工程:
  • 巨慢的索引速度
  • IDE 经常性卡死
  • 时好时坏的 Auto-complete 功能
  • 时好时坏的代码跳转
  • 时好时坏的 lldb
这样的研发环境,称之为灾难也不为过。
于是哔哩哔哩尝试了2条路,一条是打造一套基于 VSCode 的 iOS 研发环境,另一条是使用近期非常活跃的 rules_xcodeproj 作为 Tulsi 的上位替代。两种方案各有各的优势。
打造基于 VSCode 的 iOS 研发环境有单独的一篇技术文章进行讨论,感兴趣的同学可以移步到这篇文章:用VSCode基于Bazel打造Apple生态开发环境,本文不再赘述。
这里我们还是集中讨论 rules_xcodeproj,毕竟很大一部分 iOS 研发还是习惯使用 Xcode 进行开发调试。
Tulsi 的上位替代—— rules_xcodeproj
使用 Tulsi 的时候我们首先需要创建并配置复杂的 tulsiproj 以及 tulsigen 文件,这些配置文件的主要作用是为将要生成的 xcodeproj 工程文件提供信息。而 rules_xcodeproj 简化了这些配置,只需要在 BUILD 文件中引入并使用 xcodeproj 这个 rule,用它来包装一下 ios_application 或者 ios_unit_test 即可完成声明:
# 该文件路径 srcs/binary/BUILDload("@rules_xcodeproj//xcodeproj:defs.bzl", "top_level_target","xcodeproj")ios_application(    name = "bili-universal",    ...)xcodeproj(    name = "my-xcodeproj",    # 决定了生成的 xcodeproj 文件的名字    project_name = "bili-universal",    # 一般指定 manual ,在通配规则 //... 下排除此 target    tags = ["manual"],    # 指定顶层 targets,一般为 ios_application 或 ios_unit_test    # target_environments 中 "device"指真机, "simulator"指模拟器    top_level_targets = [        top_level_target(":bili-universal", target_environments = ["device", "simulator"]),    ],    # 设置 bazel 则为 BwB 模式,设置 xcode 则为 BwX 模式,不填默认 BwB 模式    build_mode = "bazel",    # 设定模拟器架构,一般设定 x86_64,如果不需要模拟器则不设定    ios_simulator_cpus = "x86_64",    # 编译前执行的脚本    pre_build = "${SRCROOT}/tools/pre_build.sh",    # 编译完成后执行的脚本    post_build = "${SRCROOT}/tools/post_build.sh",)
相较于 Tulsi,rules_xcodeproj 不再需要复杂的配置文件,只需要在 BUILD 文件中配置对应的属性,甚至大部分属性都可以使用默认配置,大大简化了配置难度,降低了开发者使用的心智负担。
那么如何生成 xcodeproj 文件呢?只需要执行:
bazel run //srcs/binary:my-xocdeproj
即可生成 Xcode 工程,双击打开 srcs/binary/bili-universal.xcodeproj 即可正常进行开发调试。
为什么说 rules_xcodeproj 是 Tulsi 的上位替代?
  • rules_xcodeproj 简化了 Tulsi 繁琐的配置文件,使用起来更方便
  • rules_xcodeproj 支持2种构建模式:BwB(Build with Bazel) 和 BwX(Build with Xcode) 模式——BwX 模式下可以真正脱离 Bazel,只使用原生的 Xcode 工具链进行构建。
  • 而 BwB 模式的优势在于它不再使用 Xcode 原生的工具链进行索引,而是通过 MobileNativeFoundation/index-import(https://github.com/MobileNativeFoundation/index-import) 来实现更高效的索引,这个索引方案的实际体验完全超越了 Xcode 原生的索引。
由于 rules_xcodeproj 的 BwB 模式基本脱离了原生的 Xcode 工具链而和 Bazel 构建系统高度自洽,因此我们最终选择了 rules_xcodeproj 的 BwB 模式,之前 Tulsi 产生的一切编辑和调试的问题几乎都完全消失无踪了,研发体验蹭蹭地涨。
0x03 Fastbuild Rule
虽然 Bazel 本身的增量构建特性使得它在构建速度上很有优势,但是在某些情况下增量构建还是有失效的可能。
我们先来了解一下 Bazel 的增量构建时缓存命中的原理:
Bazel 的构建过程中会生成一个动作图(Action Graph),这个动作图包含了构建过程中所有需要执行的 Actions,每个 Action 有自己 Inputs (.h,.m,.swift 及其依赖头文件等)和 Outputs(.o,.a,.swiftmodule 等),这个 Action 就相当于原子任务,一个 Action 的 Inputs 和 Command 对应一个 Action Hash,如果 Action Hash 一致,那么就认为这个 Action 的产物一致,可以直接复用上一次构建的结果;如果 Inputs 或者 Command 发生变化,那么 Action Hash 一定发生变化,那么这个 Action 就不能复用上次的结果,需要重新执行这个 Action。
从编译原理上来说,下层被依赖库的头文件/接口的修改会导致上层库的重编;特别是 C 家族的源码文件,import 的头文件在预编译时会被拷贝到上层的源码文件中,对 import 的头文件作出修改,相当于修改了上层的源码文件。因此底层库的一些信息(如头文件等)需要作为上层库的 Inputs,因此底层库头文件的修改会穿透到上层,改变上层的 Action Hash 导致上层缓存失效,需要重新执行 Action,也就是重新编译。
也就是说最能发挥 Bazel 增量编译优势的工程一定是底层库较为稳定,而上层业务会经常迭代更新的工程。
但很可惜的是哔哩哔哩的客户端工程不是这样的工程,首先哔哩哔哩客户端处于发展期,底层库的迭代更新(比如核心模块播放器、弹幕、网络等)犹如家常便饭,经常会增加一些新能力,这些新能力往往是 break change,当然本身 Monorepo 也拥抱这种变化。
但是这样带来的问题就是——底层库的头文件修改会影响到上层的缓存命中,所有上层都需要重新编译。特别是底层库开发的同学会特别痛,改一行底层库头文件,编译两小时并不是什么都市传说。
问题分析
上层真的需要全部重编吗?
我们首先把问题简化为最简单的3个C语言库的依赖关系:
A 需要引入 B 的 b.h,而 B 的 b.h 比较干净,不需要引入 C 的 c.h,而只需要在 B 的实现文件 b.c 中引入 c.h;那么对于库 A 而言,c.h 并不是必须项,c.h 只需要参与 B 的编译,而不需要直接参与 A 的编译。所以底层库的头文件并不一定需要作为 Inputs 穿透到顶层。这种状况,我们可以认为 C 是 B 的私有依赖,A 甚至不需要知道 C 的存在。
那么 Bazel 的 rules 是否支持私有依赖的写法呢?我们欣喜地发现 swift_library 和 cc_library 中实现了类似的属性 private_deps 和 implementation_deps,然而 objc_library 并没有!而我们的客户端工程的 ObjC 代码保有量巨大,这代表我们目前并没有行之有效的手段来切段底层库的 Inputs 穿透。
于是我们给 Bazel 官方提了 feature request,并详细阐述了理由:Add an implementation_deps field to objc_library(https://github.com/bazelbuild/bazel/issues/17646
没想到 Bazel 官方实在是太给力了,很快给了我们回应,并告知会在下一个版本 6.3.0 中支持:[6.3.0] Add implementation deps support for Objective-C(https://github.com/bazelbuild/bazel/pull/18372
除了等待 Bazel 官方的支持以外,
我们是不是还可以做进一步的优化?
在等待官方的支持的这段时间,我们也在思考是否还有进一步优化的空间:其实在绝大多数情况下,下层被依赖库的头文件修改后,如果不需要上层库做出修改,而直接使用上层库前一次的编译产物直接进行链接,大概率还是可以链接通过的(结构体、宏定义、内联函数等的修改可能产生问题)。因此我们就想利用这一特性,牺牲了一定的编译正确性来达到加速编译的效果。我们称之为 Fastbuild 模式(并非 Bazel 的 fastbuild compilation mode)。
Fastbuild 模式下,我们打算使用库本身文件内容+编译配置来做 Fastbuild Hash。Bazel 原生的 Action Hash 做到了最细粒度的区分,而相对于 Action Hash,Fastbuild Hash 更加宽容。一个库在编译配置相同的情况下,无论它依赖的下层是否有变化,只要库本身的文件没有改变,我们就认为可以复用它上一次构建的二进制产物进行链接,本地编译时如果发现远程存在这个 Lib Hash 的二进制,则直接下载,而不需要重新编译源代码,节省了上层编译源码的时间。我们每个准入的 pipeline 都会上传所有库的二进制切片,master 分支覆盖上传,保持这个库当前 Lib Hash 的二进制是最新最正确的。
正确性如何?
刚才也说到 Fastbuild 模式其实是会牺牲一定的编译正确性的。拿最简单的结构体来举例:
structPerson {char name[20];int age;float height;};
在编译完成后,这个结构体的内存布局可能是这样的(实际根据编译器和操作系统,内存布局可能产生变化):
Offset

Data Type

Variable

0

char[20]name[20]
20

intage
24

floatheight
这时候上层访问 person.height 访问的是这个 person 结构体实例的偏移 24 个字节的这个 height 变量。
但假如给这个结构体新增一个变量 weight:
structPerson {char name[20];int age;float weight;float height;};
就很有可能导致内存布局的变更如下:
Offset

Data TypeVariable
0

char[20]name[20]
20

intage
24floatweight
28floatheight
那么这是如果我使用上次上层的编译产物,我本意是想访问 person.height,然而因为上层没有重编,则仍然访问了结构体实例偏移 24 个字节的变量——weight,而并不是我想要的 height,就会导致一些运行时的意外或错误。
宏定义和内联函数也是同理,在 Fastbuild 模式下,使用上一次的编译产物都有可能导致一些编译或运行时的意外和错误。
好在我们现在都是面向对象开发,结构体用得比较少,类似这样的问题并不太常见,偶尔几个月可能会遇到1次。而且由于 master 分支会覆盖上传产物,清除本地缓存并重新 rebase 代码后,这种错误也是可恢复的,相较于编译速度的巨大收益,牺牲的这些正确性是在可接受范围内的。
Fastbuild Rule 的实现
实现 Fastbuild 模式,我们使用了 Bazel 的 Aspects 扩展特性。Aspects 可以很方便地为每个 target 的 Output 添加额外内容。
Aspects 本身和 Rules 非常类似,它除了普通 Rules 的实现方法以外,还有一个它所传播的所有属性的列表。假设我们有这样一张依赖图:
我们设置 Aspect 沿着 "deps" 属性传播。Aspect 应用于上图的 X 节点时,就会生成一个新节点 A(X),并且会递归应用于所有"deps"属性引用的所有目标,生成这样一张图:
利用这样的特性,我们可以非常方便地给整棵依赖树所有 targets 添加计算 Fastbuild Hash 的 Aspect:
def _fastbuild_aspect_impl(target, ctx):    # 计算 Fastbuild Hash 的工具    dump_tool = ctx.executable._dump_tool    # 声明计算 Fastbuild Hash 的产物    fastbuild_hash = ctx.actions.declare_file(        ctx.label.name + ".fb.json",    )    ...    # 实际执行计算 Fastbuild Hash 的 Action    ctx.actions.run(        executable = dump_tool,        arguments = [fastbuild_hash.path],        inputs = inputs,        tools = [dump_tool],        outputs = [fastbuild_hash],    )return [OutputGroupInfo(fastbuild_info_json = [fastbuild_hash])]fastbuild_aspect = aspect(    implementation = _fastbuild_aspect_impl,    # 沿着 "deps" 属性传播    attr_aspects = ["deps"],    attrs = {        # 计算 Fastbuild Hash 的工具"_dump_tool": attr.label(default = Label("@//rules/fastbuild:fastbuild_hash"),            allow_files = True,            executable = True,            cfg = "exec",        ),    },)
在构建时我们会利用 Aspect 把依赖树里所有 Targets 的 Fastbuild Hash 计算完毕。然后我们再通过 --override_repository 参数修改标准的 objc_library,cc_library 这些 Rule 为我们自建的 Rule,在自建 Rule 中下载对应二进制产物,跳过了编译文件的过程,直接把产物提供给上层。
在 Fastbuild Rule 完全实装后,我们本地编译效率得到了大幅度的提升,特别是对于底层库开发的同学特别友好。
以下是在本地 Bazel Clean 后(意味着本地没有任何缓存),修改某底层库头文件后的编译耗时:
初次编译耗时

非初次编译耗时
未启用 Fastbuild120 mins60 mins
开启 Fastbuild15 mins1 min
原本修改底层库的头文件会导致上层库缓存破坏,本地编译耗时接近 clean build 耗时,但在 fastbuild 模式下,这个时间可以缩短到2分钟以内,开发效率提升90%以上。
0x04 总结
Bazel 从5年前诞生以来,就不断地进行着完善和进化。我们哔哩哔哩作为国内第一个吃螃蟹的移动端团队,见证了它从青涩到成熟的过程;与此同时,我们也看到一些技术团队想要拥抱 Bazel,但是因为各种各样的原因变成了从入门到放弃,令人惋惜。我想说的是,现在的 Bazel 已然成为了一个优秀的现代化跨平台构建系统,这归功于饱含着技术热情的社区,希望更多的团队可以认识和尝试接触 Bazel!今天我们把哔哩哔哩 iOS 团队在 Bazel 上做的一点微小的工作和经验分享给大家,欢迎大家一起交流!
以上是今天的分享内容,如果你有什么想法或疑问,欢迎大家在留言区与我们互动,如果喜欢本期内容的话,欢迎点个“在看”吧!
往期精彩指路
继续阅读
阅读原文