Bazel 快速入门与核心知识

Bazel 简介

Bazel 是一款与 Make、Maven 和 Gradle 类似的开源构建和测试工具。 它使用人类可读的高级构建语言。Bazel 支持多种语言的项目 (C/C++, Java, Python, …),可为多个平台构建输出。Bazel 支持跨多个代码库和大量用户的大型代码库。

本文是作者结合 Bazel 官方文档以及一些其他博客总结的学习笔记,凝炼了个人认为最核心的一些 Bazel 知识。通过此文,希望能让大家不仅看懂并编译一个通过 Bazel 构建的项目,同时还能够使用 Bazel 对自己的项目完成构建。

使用 Bazel 的基本流程

如需使用 Bazel 构建或测试项目,您通常要执行以下操作:

  1. 设置 Bazel。下载并安装 Bazel

  2. 设置项目工作区,这是 Bazel 在其中查找 build 输入和 BUILD 文件以及用于存储 build 输出的目录。

  3. 编写 BUILD 文件,告知 Bazel 要构建什么以及如何构建它。

    如需编写 BUILD 文件,您可以使用领域特定语言 Starlark 声明构建目标。(请查看此处的示例。)

    构建目标指定了一组 Bazel 将要构建的输入工件及其依赖项,Bazel 将用于构建它的构建规则,以及用于配置构建规则的选项。

    build 规则用于指定 Bazel 将使用的构建工具,例如编译器和链接器。Bazel 附带多条构建规则,这些规则涵盖受支持平台上以支持的语言显示的最常见工件类型。

  4. 通过命令行运行 Bazel。Bazel 会将您的输出内容放在工作区中。

Bazel 构建流程

运行构建或测试时,Bazel 会执行以下操作:

  1. 加载与目标相关的 BUILD 文件。
  2. 分析输入及其依赖项,应用指定的构建规则,并生成操作图表。
  3. 对输入执行构建操作,直到生成最终构建输出。

由于之前的所有构建工作都已缓存,因此 Bazel 可以识别并重复使用缓存的 artifacts,并且只会重新构建或重新测试发生更改的内容。为了进一步强制执行正确性,您可以设置 Bazel,以通过沙盒化的方式运行构建和测试,从而最大限度地减少偏差并最大限度地提高可重现性

Bazel C++ demo

目录组织结构如下。下面尝试用 bazel 构建 stage3/main 中以 hello-world.cc 为入口的 hello-world 程序,该程序依赖于同路径下的 hello-greet 以及 stage3/lib 路径下的 hello-time

examples
└── cpp-tutorial
    ├──stage1
    │  ├── main
    │  │   ├── BUILD
    │  │   └── hello-world.cc
    │  └── WORKSPACE
    ├──stage2
    │  ├── main
    │  │   ├── BUILD
    │  │   ├── hello-world.cc
    │  │   ├── hello-greet.cc
    │  │   └── hello-greet.h
    │  └── WORKSPACE
    └──stage3
       ├── main
       │   ├── BUILD
       │   ├── hello-world.cc
       │   ├── hello-greet.cc
       │   └── hello-greet.h
       ├── lib
       │   ├── BUILD
       │   ├── hello-time.cc
       │   └── hello-time.h
       └── WORKSPACE
  1. stage3 目录下创建了名为 WORKSPACE 的空文件,标记了这是一个 bazel 的工作区

  2. stage3/mainstage3/lib 目录下创建名为 BUILD 的文件,用于指示 bazel 构建工作,一个拥有 BUILD 文件的目录就是一个包 (软件包)

    # lib/BUILD 文件
    
    # 定义了名为"hello-time"的一个目标(target),这个目标是cc_library规则(rule)的一个实例,cc_library规则定义的是构建C/C++库(library)的规则
    cc_library(
        name = "hello-time",
        # 构建此库目标所需的C/C++文件列表,路径相对于BUILD文件所处目录
        srcs = ["hello-time.cc"],
        # 描述此库目标的C/C++头文件列表,路径相对于BUILD文件所处目录
        hdrs = ["hello-time.h"],
        # 使用可见性属性让 lib/BUILD 中的 //lib:hello-time 目标对 main/BUILD 中的目标显式可见。这是因为,默认情况下,只有同一 BUILD 文件中的其他目标才会看到这些目标。
        visibility = ["//main:__pkg__"],
    )
    
    # main/BUILD 文件
    
    # 定义了名为"hello-greet"的一个目标(target),这个目标是cc_library规则(rule)的一个实例,cc_library规则定义的是构建C/C++库(library)的规则
    cc_library(
        name = "hello-greet",
        # 构建此库目标所需的C/C++文件列表,路径相对于BUILD文件所处目录
        srcs = ["hello-greet.cc"],
        # 描述此库目标的C/C++头文件列表,路径相对于BUILD文件所处目录
        hdrs = ["hello-greet.h"],
    )
    
    # 定义了名为"hello-world"的一个目标(target),这个目标是cc_binary规则(rule)的一个实例,cc_binary规则定义的是构建C/C++二进制程序(binary)的规则
    cc_binary(
        name = "hello-world",
        # 构建此二进制目标所需的C/C++文件列表,路径相对于BUILD文件所处目录
        srcs = ["hello-world.cc"],
        # 要链接到二进制目标的其他库的列表
        deps = [
            ":hello-greet",  # 同一包下可省略包路径和//
            "//lib:hello-time",  # 不同包下必须严格按照 //包路径:目标名 的标签写法
        ],
    )
    

    目标间的依赖关系如下:

    “hello-world”的依赖关系图显示了文件修改后依赖关系的变化。

  3. 执行构建:在 stage3 目录下,执行:

    bazel build //main:hello-world
    

    Bazel 会生成如下内容:

    INFO: Found 1 target...
    Target //main:hello-world up-to-date:
      bazel-bin/main/hello-world
    INFO: Elapsed time: 0.167s, Critical Path: 0.00s
    

    现在已经构建完成了,继续执行 bazel-bin/main/hello-world 即可运行 hello-world 程序

Bazel 核心知识

工作区 (workspace)

  • 一个 Workspace 就可以认为就是一个独立的 C/C++ Project。譬如上面 cpp-tutorial 目录下分别由 stage1stage2stage3 三个项目,每个项目的根目录下有一个 WORKSPACE 文件(空的就行)。
  • Bazel 会将包含一个 WORKSPACEWORKSPACE.bazel 文件的目录识别为一个项目,每个项目之间互不干扰是完全独立的。
    • 可以同时包含 WORKSPACEWORKSPACE.bazel,此时 .bazel 那个优先级更高。
  • 一个 Workspace 里可以包含多个 Packages (包),每个 Package 中包含一组相关的源文件和一个 BUILD 文件。BUILD 文件指定可以从源代码构建哪些输出。例如,stage3 下就包含了两个 Package:mainlib
  • 工作区有时也叫代码库。

BUILD & 包

  • 软件包 (包) 指的是包含名为 BUILDBUILD.bazel 的 BUILD 文件的目录。

    • 可以同时包含 BUILDBUILD.bazel,此时 .bazel 那个优先级更高。
  • 软件包包含其目录中的所有文件,以及其下的所有子目录,但那些本身包含 BUILD 文件的子目录除外。根据此定义,任何文件或目录都不能包含在两个不同的软件包中。

    例如,以下目录树中有两个软件包:my/app 和子软件包 my/app/tests。请注意,my/app/data 不是软件包,而是属于软件包 my/app 的目录。

    src/
    └─ my
        └─ app
            ├─ BUILD
            ├─ app.cc
            ├─ data
            │   └─ input.txt
            └─ tests
                ├─ BUILD
                └─ test.cc
    
  • BUILD 文件采用 Starlark 语言对模块构建进行描述,语法类似于 Python

    • 每个 BUILD 文件都需要至少一条规则 (rule) 作为一组指令,告诉 Bazel 如何构建所需的输出,例如可执行文件或库。
    • BUILD 文件中定义的规则 (rule) 的实例都称为一个目标 (target),并指向一组特定的源文件和依赖项。 目标还可以指向其他目标。从逻辑上来说即每个 package 可以包含多个 targets,而具体的 target 则采用 Starlark 语法定义在一个 BUILD 文件中。

BUILD 文件核心语法

规则 (rule)
  • 规则用于在 BUILD 文件(例如 cc_library)中定义如何生成一个目标 (target)。从 BUILD 文件作者的角度来看,规则由一组属性和黑盒逻辑组成。

  • 在简单的 BUILD 文件中,规则声明可以随意重新排序,而不改变行为。

  • bazel 定义了很多原生规则,可以直接在 BUILD 文件中使用,而无需 load 语句引入

    • 可以在 .bzl 文件中自定义规则,并在 BUILD 中load 语句引入

    • 原生规则可以在 .bzl 文件中需要使用 native 模块来引用(如 native.cc_binary),但在 BUILD 文件中原生规则可以直接使用。

    • 详细的各项原生规则及其API见文档Bazel 构建函数百科全书 (google.cn)

      在这里插入图片描述

    • 我们常用的原生规则包括 cc_binarycc_library 等,分别用来构建二进制可执行程序和库(静态库/动态库)。

      # 例子:在BUILD中使用bazel内置的原生规则: cc_binary
      cc_binary(
          name = "hello-world",
          srcs = ["hello-world.cc"],
          deps = [
              ":hello-greet",
              "//lib:hello-time",
          ],
      )
      
  • 大多数规则都具有类似的命名方案。例如,cc_binarycc_librarycc_test 分别是 C++ 二进制文件、库和测试的构建规则。其他语言会采用相同的命名方案,但采用不同的前缀,例如适用于 Java 的 java_*

    • *_binary 规则可用于构建给定语言的可执行程序。
    • *_test 规则是 *_binary 规则的专用规则,用于自动测试。测试只是在成功时返回零的程序。
    • *_library 规则以指定给定的编程语言指定单独编译的模块。库可以依赖于其他库,二进制文件和测试可以依赖于库,并且具有预期的单独编译行为。
  • 一个规则一般具有很多属性(见后面的小节)。

目标 (target)
  • 规则和目标是定义和实现的关系。也就是说,目标是规则的一个实例

    • 一个 Rule 由很多 attribute 构成,这点采用面向对象的概念来看,Rule 就好比是 class,而 attribute 就好比是 class 的 member。

    • 下面这段代码实际上就是定义了一个 target,每个实例必须要有一个名字在同一个 package 中和其他 target 实例进行区分。所以 name 这个 attribute 是必须有的,其他 attribute 是可选的,不写则按默认值定义。

      # 例子:定义一个name为"hello-world"的target,它是cc_binary规则的一个实例 
      cc_binary(
          name = "hello-world",
          srcs = ["hello-world.cc"],
          deps = [
              ":hello-greet",
              "//lib:hello-time",
          ],
      )
      
  • 可以使用标签来唯一标识一个目标(详下节)。

标签 (label)
  • 标签是目标的标识符。简单来说,标签就是唯一标识一个 target 的 ID

  • 大部分情况下,我们引用的都是同一个 workspace 中的 target,此时标签的语法如下

    //path/to/package:target-name
    
    • // 开始,接下来的 path/to/package 也就是这个 target 所在 package 在 workspace 中的相对路径。然后是一个 : 后面跟着一个 target-name 即上面说的一个 target 中的 name 那个属性的字符串值。

    • 如果要引用不同 workspace 中的 target,就必须使用标签的完整语法,见:标签 | Bazel (google.cn)

    • 如果是引用同一个包中的 target,那么标签语法可进一步简化,以下两种方式均可:

      //:target-name
      :target-name
      
  • 特别地,可以使用 //path/to/package:__pkg__ 来表示一个包下地所有 target。

属性 (attribute) 及依赖
  • 属性是规则的参数,用于表示每个目标的 build 信息。如果在 BUILD 中实例化规则时没有显式指定某个属性的值,则该属性会使用默认值。

  • 大多数规则常见的属性包括 names (必需)、srcsdepsdatavisibilityincludescopts等,它们分别声明目标的源文件、依赖项和自定义编译器选项。给定目标可用的特定属性取决于其规则类型。

  • 原生规则的具体属性需要参见文档:Bazel 构建函数百科全书 (google.cn)。以 cc_library 规则为例,说明它的一些常用属性:

    • names: 目标的唯一名称。
    • deps: 此库所依赖的其他库的列表(可以通过标签来引用)。
    • srcs: 为创建库目标而处理的 C 和 C++ 文件的列表,包括源文件和头文件
    • data: 此库在运行时所需的文件列表。
    • hdrs: 伴随此库发布的头文件,并且可以被其他依赖这个库的目标(如其他 cc_librarycc_binary)使用,Bazel 在构建过程中会确保这些头文件能够被正确找到和使用。cc_binary等规则是没有此属性的。
    • visibility: 指定此库在其他库中的可见性(可以通过标签来引用)。默认情况下,一个目标只对相同库中的其他目标显式可见。
    • includes: 要添加到编译行的 include 目录列表。
    • copts: 将这些选项添加到 C++ 编译命令中。比如这里可以写 -Imy_libpath 来将 my_libpath 加入编译时的头文件搜索路径,可以写 -pthread 来表明使用了多线程库。
      • includescopts 中设置 -I 都可以指定头文件位置。但是,前者会为该规则即依赖该规则的所有规则都设置头文件位置,而后者只会为该规则设置头文件位置(因为本身只是一次编译命令的选项)。

    注意到这里并没有指定生成 .a 静态库还是 .so 动态库,实际上静态库还是动态库由引用此库的 cc_binary 规则决定,具体来说,cc_binary 可以指定 linksharedlinkstatic 为 True 还是 False 来决定链接时使用动态库还是静态库。默认情况下,linkshared 是 False,linkstatic 是 True。

    此外需要说明的是,srcsdatahdrsincludes 等属性中设置的地址都是相对于当前包路径而言的,即相对于当前 BUILD 文件所处的目录

    以下给出一个示例:最终目标是构建可执行程序 foo,它依赖于源文件 foo.cc 和头文件 foo.h,同时还依赖于库 bar。库 bar 则依赖于源文件 bar.cc 和头文件 bar-impl.h,它同时还依赖于另一个库 baz,库 bar 中的接口由 bar.h 这个头文件所定义。另一个库 baz 依赖于源文件 baz.ccbaz-impl.h,库 baz 中的接口由 baz.h 这个头文件所定义。

    cc_binary(
        name = "foo",
        srcs = [
            "foo.cc",
            "foo.h",
        ],
        deps = [":bar"],
    )
    
    cc_library(
        name = "bar",
        srcs = [
            "bar.cc",
            "bar-impl.h",
        ],
        hdrs = ["bar.h"],
        deps = [":baz"],
    )
    
    cc_library(
        name = "baz",
        srcs = [
            "baz.cc",
            "baz-impl.h",
        ],
        hdrs = ["baz.h"],
    )
    
  • 在 srcs, deps 等依赖属性中,可以使用 bazel 提供的 glob 函数来查找与特定路径模式匹配的所有文件,详细语法见:glob

构建命令

详细的各项参数见:使用 Bazel 构建程序 (google.cn)

使用 bazel build 来完成对目标的构建:

# 以 // 开头的所有目标模式都是相对于当前工作区而言的。
bazel build //path/to/package:target-name

# 以 // 开头的目标模式会根据当前的工作目录进行解析。
bazel build path/to/package:target-name

案例:

# 构建workspace下的foo/bar包中的wiz目标
bazel build //foo/bar:wiz

# 构建workspace下的foo/bar包中的bar目标,等同于 //foo/bar:bar
bazel build //foo/bar

# 构建workspace下的foo/bar包中的全部目标
bazel build //foo/bar:all

# 构建当前目录下定义的foo目标
bazel build :foo

# 构建当前目录下的bar子目录下定义的foo目标
bazel build bar:wiz

参考文献

Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐