这是一篇2019年左右的记录, 内容可能过时, 也不太全面
杂谈
Bazel是Google为Monorepo服务而开发的构建工具.
首先是巨大,当问题的规模变大,事情总是会变得更复杂. 而Google面对的"巨大Monorepo",应该是世间罕有的.
然后是Monorepo,这极大的影响了代码的组织风格.例如,你要写一个操作系统内核ProjectOS,还要写一个游戏ProjectGame.在传统的开发习惯中,这两个项目会组织到两个不同的Repo里,PorjectOS和ProjectGame之间无法直接相互引用,例如,你在ProjectOS里写了一个高级的数据结构,想要在Game里也使用,要么直接复制粘贴,要么是创建一个新的CommonRepo,把可公用的代码都放在Common里,然后两个项目各自引入Common作为依赖.
使用MonoRepo则不存在这个问题,Game可以直接依赖OS内的组件,按照Bazel的语法描述,就是在Game中可以直接使用@ProjectOS//path/to/package:AdvancedStruct
.当然,你仍然可以选择重构一个Common出来,但是现在已经没有这种必要了.
从技术层面说,在使用"巨大Monorepo"时,Bazel相对现有编译系统的优势能充分展现.你的项目离"巨大Monorepo"越远,使用Bazel的优势就越不明显. 宽泛的来说,对于大部分项目,使用Bazel都不能带来明显的技术收益.
一般而言,使用Bazel的优势是并不是体现在技术层面.而是让你的代码能无痛使用谷歌的开源组件. 原因很简单, Google的项目都是基于Bazel的,如果你不用Bazel,就需要自己做一些额外的工作.
换个角度来看,这也是谷歌的"阴谋":如果Google外的开发者不用Bazel,那么当Google需要依赖外部项目时,就需要手动把这些项目转换成Bazel的;而如果全世界的程序都用Bazel来组织编译,那么Google就可以无缝的从开源世界汲取力量.
吹Bazel的帖子到处都有,所以这里只说黑点,如果这些黑点你并不很care,那么使用Bazel应该是个不错的选择
- Bazel是后来者,熟悉它的开发者比较少,加上学习成本高,可能需要有若干"Bazel"专家负责整个构建系统的维护.
- 对于已经成熟的大中型项目,迁移到Bazel的时间/人力成本会比较高,而这一般也不会带来明显的收益.最好在项目早期使用Bazel
- Bazel是Java写的, 也就是说,它依赖JVM
- 使用Bazel的低级功能收益有限,而使用高级功能的学习成本又很大.
- 本质上说,BUILD文件和
rule
语法服务于构建action_graph,这是一种"声明式编程",如果你熟悉TF等框架的计算图,那么理解起来应该不难. 如果你不熟悉,那么可能就会需要适应一下. - sandbox对构建系统也许是有帮助的,但是对开发者的心智和头发绝对是不友好的.
- 例如1. 你生成了一些头文件,由于这些头文件在sandbox内,你必须用一些tricky的方法才能让IDE识别到这些头文件.
- 例如2. 编译器报了某个文件的错误,你打开报错的文件一顿修改… 结果发现修改的只是sandbox内的副本.
How Bazel Works
Bazel的学习成本很高,为了实现很多高级功能,引入了一些复杂的概念,如果你不懂这些概念,那么有可能连帮助文档都看不懂.
Bazel的典型文件组织
- Repo的根目录下有一个唯一的WORKSPACE文件,这个文件只用于描述该repo依赖的其他repo
- 子目录下可以有BUILD文件,有BUILD文件的目录称为PACKAGE,BUILD文件用于描述target,PACKAGE之间没有交集.
BUILD
文件所在目录为DIR,那么DIR内(递归)的所有文件都是PACKAGE的一个target,这个target的name就是相对路径- 例如,若存在
/dir/foo/BUILD
和/dir/foo/bar/some_code.cc
,那么foo
这个PACKAGE内就存在一个name为bar/some_code.cc
的target
- 例如,若存在
- 每个target都有一个全局唯一的label,这个label主要通过路径名和target名组合生成,典型结构为
@repo_name//path_to_pacakge:target_name
@repo_name
表示label所在的repo@
是一个特殊的case,它表示项目的绝对root repo
- 例如,
repoA
在WORKSAPCE内依赖repoB
,那么在这种场景下,repoB
内的@
实际代表了repoA
.而对于单独的repoB
,@
代表了它自己 - 在引用
label
时,存在各种语法糖来简化label的写法,这些语法糖并不重要
Bazel的运行流程
理解Bazel的运行流程是正常使用Bazel的前提. 然而, 即便是官方文档,对此也是只言片语的三句话"Loading,Analysis,Execution". 在此,我将从类比Python的角度去描述Bazel的执行流程.
这里可以先记忆以下信息
- Bazel相当于是Starlark语言的Interpreter.
- Bazel执行时,会创建一个Sandbox, 这个Sandbox体现为一个独立的工作目录,用于隔离文件操作.用户写的各类rule,target等操作都是在sandbox内执行的.只有bazel自身能访问sandbox外的原始文件.
- Bazel的执行逻辑大致可以用下面的伪代码描述.
- Bazel的Loading/Analysis截断是在构建一个称为Action Graph的图, 这个图的每个节点都是一个编译动作, 对应了一个编译Target, target之间的依赖决定了图的结构, 也决定了编译的顺序.
注意: Bazel里的macro是"函数"的意思,并不是C++那样的,在预处理阶段通过复制粘贴的形式展开到调用处。
############### Loading
import WORKSPACE # import is same as `load`
import ALL_BUILD_FILE # import BUILD recursively
############### Analysis
bazel_build_flag,commandline_target = parse_cli_args()
action_graph=Graph()
sorted_target = topo_sort(global_target_map.to_list())
if do_analysis:
for target in sorted_target:
target.update()
ret_info = target.impl(target.ctx)
new_action_nodes = create_new_nodes(target.ctx,ret_info)
action_graph.insert(new_action_nodes)
############### Execution
if do_execution and action_graph.isValid()
exec_in_sandbox(action_graph,commandline_target)
- Load阶段将会加载所有的脚本,加载脚本的顺序为:先加载repo根目录的
WORKSPACE
文件,然后递归的加载所有子目录下的BUILD
文件.WORKSPACE
文件仅用于调用repo_rule
,repo_rule
执行后,会把第三方依赖组织到sandbox的extern
内(下载或者软链接)BUILD
文件仅能用于调用rule
,rule
执行后,仅仅是创建了Target
对象.- 逻辑上看,
rule
创建的target会注册到一个全局的global_map
中 rule
语句创建新target时, 可以使用其他target的label
来描述自己的依赖,且依赖label
对应的target可以不存在(声明式)- 在 WORKSPACE/BUILD load的过程中,一些额外的
.bzl
文件也会被load进来,这些.bzl
仅能用于定义marco/rule/repo_rule - 所有starlark脚本都能在当前文件的全局作用域定义变量,对
.bzl
而言,这些变量由_
命名控制可见性.(BUILD/WORKSPACE文件不会被其他文件load,也就不存在可见性的概念)
- Analysis阶段则会
- 先根据
Target
之间的依赖关系对所有Target进行拓扑排序 - 按照拓扑序顺序调用
target.update();target.impl(target.ctx)
. - 执行
update()
主要是根据已有的信息更新target自身的部分属性值,这些值需要从依赖中获得,而由于拓扑排序,target
的所有依赖一定已经执行过impl()
了,一定是可以提供信息的. - 执行
impl()
用于更新自己的状态,并向后续的其他target提供信息. - Bazel会根据
impl()
返回的信息和target.ctx
来构建新的action_node
,并插入action_graph中 - target.ctx内会包含所有的候选
action
- impl()返回的信息
ret_info
是一个列表,可以包含多种信息,最基本的是DefaultInfo
,这个信息的file
属性将标明当前target的输出文件.而只有与输出文件相关联的候选action
才会被构建成action_node
- 先根据
Execution
阶段则比较简单.- 只需要根据command_line给出的build target,逆着 action_graph 回溯,得到编译target所需的最小子图,然后执行这个最小子图即可.
- Execution阶段是完全在sandbox内执行的,action所需要的文件由Bazel软链接到sandbox内.
- 换言之,如果action的描述不正确,那么就可能有文件没有被拷贝到sandbox,进一步导致编译失败.例如,编译
a.cc
时需要a.h
,而在描述action时,若没有把a.h
对应到文件加入依赖列表,那么a.h
就不会被拷贝,在sandbox内就会编译失败,提示"找不到a.h" - 仅在execution阶段才会动态的逐步向sandbox内添加文件.
注意,WORKSPACE内调用repo_rule时,从功能上说类似于"函数",调用的repo_rule会立即执行,并不会产生
target
对象;而在BUILD文件内调用rule
时,rule
从功能上说则是class
,仅仅用于创建新的target
对象.
其他细节
- 在Ananysis阶段,
target.update()
会做很多事,其中比较重要的一项就是更新target.ctx
- 例如
ctx.files
就会被更新为具体的文件路径.例如,ctx.attr.depend_target
是一个file型label,那么更新后,ctx.file.depend_target
就是一个字符串,代表了对应的文件路径.
- 例如
- bazel rule的impl函数返回的
DeafultInfo
直接决定了最终的action_graph包含了哪些action.而没有进入action_graph的action,都不会被执行. - python是bazel的依赖,支持bazel的平台一定会有python,但是shell则不一定,所以跨平台项目中,当需要调用自定义脚本时,最好是python脚本
- 只能使用
--workspace_status_command
来在原始的repo目录内执行用户自定义脚本,自定义脚本输出到std::cout
的信息可以被bazel
重定向到两个特殊的文本文件. - impl()函数内创建候选action时,粒度越细越好,这样可以充分利用bazel的cache及action_graph最小执行.
- bazel的最小执行和cache系统都非常激进,如果impl给出的候选action和DefaultInfo不正确… 那么cache/最小执行就很可能不能正确工作.
- action的输入/输出不能是目录
Bazel Document Note
bazel的platform分为三个概念: host:执行bazel程序的主机,execution:执行构建程序的主机,target:运行最终产物的主机
.bazelrc文件用于自动为bazel 执行提供额外的参数
build文件描述的依赖关系必须严格和实际代码一致,且包含所有的直接依赖,否则bazel的cache系统就无法正确的识别出依赖,导致使用错误的cache。