Bazel Notes

这是一篇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应该是个不错的选择

  1. Bazel是后来者,熟悉它的开发者比较少,加上学习成本高,可能需要有若干"Bazel"专家负责整个构建系统的维护.
  2. 对于已经成熟的大中型项目,迁移到Bazel的时间/人力成本会比较高,而这一般也不会带来明显的收益.最好在项目早期使用Bazel
  3. Bazel是Java写的, 也就是说,它依赖JVM
  4. 使用Bazel的低级功能收益有限,而使用高级功能的学习成本又很大.
  5. 本质上说,BUILD文件和rule语法服务于构建action_graph,这是一种"声明式编程",如果你熟悉TF等框架的计算图,那么理解起来应该不难. 如果你不熟悉,那么可能就会需要适应一下.
  6. 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的执行流程.

这里可以先记忆以下信息

  1. Bazel相当于是Starlark语言的Interpreter.
  2. Bazel执行时,会创建一个Sandbox, 这个Sandbox体现为一个独立的工作目录,用于隔离文件操作.用户写的各类rule,target等操作都是在sandbox内执行的.只有bazel自身能访问sandbox外的原始文件.
  3. Bazel的执行逻辑大致可以用下面的伪代码描述.
  4. 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。