Bazel Build:基本概念

Bazel Build:基本概念

Bazel的核心领域模型非常简单,如下图所示。Workspace包含零个或多个Package,每个Package包括零个或多个Target;其中,Target包括File, Rule, PackageGroup三种类型。

核心概念

工作区:Workspace

一般地,在项目的根目录创建一个WORKSPACE文件,Bazel据此在构建过程中创建一个隔离的工作区 (Workspace),用于标识该项目的起始位置。

在工作区内,项目所包含的所有文件,包括待构建的源文件、构建过程中生成的文件,及其所有外部依赖都归属于该工作区。

WORKSPACE文件用于声明项目名称,及其该项目的外部依赖;如果项目不存在外部依赖,WORKSPACE的内容可为空。

工作区作为隔离区

工作区为项目构建提供了一个安全的隔离环境。例如,本地存在两个待构建的项目A和B,它们都依赖于第三方库D。不幸的是,它们分别依赖了不同的版本实现v1和v2。如下图所示,此时问题变得非常棘手,系统目录/usr/local/include, /usr/local/lib应该放置D的哪个版本呢?

版本冲突

归功于Bazel良好的隔离性,每个项目的工作区都是独立的。它们将所有依赖控制在各自的工作区,避免了第三方库的名字和版本冲突,如下图所示。

隔离的工作区

工作区作为代码仓库

一般地,当前工作区所在目录称为是代码仓库(Repository),它包含了所有待构建的源文件、数据和构建脚本。当前代码仓库由匿名的@标识,而外部依赖的代码仓库由@external_repo标识。

例如,在当前代码仓库下,目标//cub/base:placement_test等价于@//cub/base:placement_test;为了简化,一般略去@前缀。

而对于外部依赖的代码仓库,必须在当前代码仓库的WORKSPACE中显式地通过http_repository声明外部依赖的名称@xunit_cut,并在当前代码仓库中使用@xunit_cut引用该外部依赖的代码仓库。

外部依赖

惯例优于配置

按照Bazel的惯例,在项目根目录同时创建一个与项目名称同名的文件夹,以此定义项目命名空间的起始位置。例如,如下图所示,创建一个名为polyflow的项目。

Bazel工程的典型模式

在根目录中创建文件WORKSPACE,及其同名的子目录polyflow;然后在子目录polyflow中,根据系统架构将其分解为两个基本的模块core, util

需要注意的是,Bazel强烈建议将头文件、实现文件、测试文件放在同一个目录,并非将include, src, test目录相互分离。一方面,这样的项目组织避免了频发地切换目录,而且文件跳转也方便多了。另一方面,Bazel可以控制规则可见性,将头文件的依赖控制在合理的范围内。

再次提醒,-I目录始于WORKSPACE所在的目录。因此,包含头文件时,需要包含项目的完整路径。例如,graph.cc的第一行代码所示。

#include "polyflow/core/graph.h"

如果习惯于包含单一文件名的风格,可能需要适应一下。事实上,单一文件名风格,为了避免潜在的文件名冲突,一般也会将文件名命名得特别长,例如polyflow-core-graph.h。此时,两者实现效果是等价的,除非忽略潜在冲突的可能性。

包:Package

含有BUILDBUILD.bazel文件的目录,Bazel在构建时将其标识为包(Package)。如果一个目录不包含BUILDBUILD.bazel文件,则它只是一个纯粹的目录,隶属于最近的父包(包含BUILDBUILD.bazel文件)。

包名

包名始于WORKSPACE所在的目录,由路径名组成。例如app/core, app/core/mem。需要注意的是,路径名./app/core与包名app/core不等价,它们之间存在微妙的差异。

特殊地(常见于遗留系统中),如果BUILD文件与WORKSPACE都在根目录中,此时该包名为空,而不是一个点号.

包名为空

子包

在一个包中,递归地包含该目录下的所有文件。但是,如果某个子目录自身包含BUILD文件,则独立成为一个包,它并不隶属于上一级的父包;但是,习惯于将其称为上级的子包。也就是说,子包独立于父包,父包不包括子包。

例如,包app/core不包括包app/core/mem,它们相互独立。但是,习惯依然称app/core/memapp/core的子包。

子包

目标:Target

在一个包(Package)中,可以包含零个或多个目标(Target)。一般地,目标包括文件(File),规则(Rule),包集合(Package Group)三种基本类型。

文件

文件包括源文件派生文件两种基本类型。例如,程序员实现的头文件和实现文件称为源文件,而由protoc自动生成的头文件和实现文件则称为派生文件。

规则

在 BUILD 文件中, 可以定义零个或多个规则 (Rule)。 规则由输入、输出、动作三元 祖构成。因为 Bazel 使用声明式的表达方式,规则的动作往往是隐式的。例如,使用规则 cc_library 时,无需显式地实现 g++ a.cc -o a.o。

规则输入的文件,可能是源文件,也可以是其他规则生成的派生文件,常通过 srcs, hdrs 等属性表示。但是,规则输出的文件,必然是派生文件。此外,规则输出的派生文件,必然 与该规则属于同一个包;但是,规则输入的源文件可以来自于其他包。

规则的输入也可以是规则,常通过 deps 属性表示。通过规则之间的依赖关系,构建了 运行时的 DAG 图。一般地,依赖关系具有多种表现形式,而且与编程语言极其相关。例如,在编译时,A 依赖于 B 的头文件;在链接时,A 依赖于 B 的符号;在运行时,A 依赖于 B 的数据。

包集合

包集合较为特殊,它标识一组包。它由 package_group 定义。使用包集合,可以很方便 地将某一个规则的可见性一并赋予给该包集合,使得该集合所包含的包都可以访问该规则。

标签:Label

每个目标都存在一个全局的名字,称为标签 (Label),由如下三个部分组成;其中,@repo_name, @package_name, :target_name在一些特殊场景下都可省略。

标签

在本工作区内,完全可略去@repo_name。但是,如果引用外部依赖,@repo_name则是必须的,而且必须保证在当前代码空间内全局唯一。

需要注意的是,@//a/b:c//a/b:c之间的区别。前者引用主仓库的目标,而后者应用当前仓库的目标。特殊地,如果在主仓库中定义的规则,而在外部仓库引用,如果添加@//则显式地引用主仓库的规则,而省略@前缀,则会在外部仓库中引用规则。

如果在同一个包内,也可完全略去//package_name;按照惯例,对于同包内依赖的文件,习惯略去冒号;而对于规则,则保留冒号。

更有甚者,如果package_name的后缀刚好与target_name相同,则可以略去target_name。也就是说,//package_name/target:target//package_name/target等价。

标签举例

例如,在cub/base/BUILD中定义了一个placement的基础类库,它基本覆盖了所有形态的标签格式。

package(
    default_visibility = [    
        "//visibility:public",    
    ],
)

cc_library(
    name = "placement",
    hdrs = ["placement.h"],
)

cc_test(
    name = "placement_test",
    srcs = ["placement_test.cc"],
    deps = [ 
        ":placement",
        "//cub/algo:loop",
        "//cub/dci",      
        "@xunit_cut//:cut",
        "@xunit_cut//:cut_main",
    ],  
)

仓库名称

标签@xunit_cut//:cut来自于第三方库xunit_cut,因此需要显式地加上域名。外部依赖的名称定义于WORKSPACE之中。按照惯例,域名推荐使用DNS名称,例如github_horanceliu_cut,将极大降低包的名字冲突概率。

http_archive(
    name = "xunit_cut",
    sha256 = "f7c2c339a5ab06dc1d16cb03b157a96e6c591f9833f5c072f56af4a8f8013b53",
    strip_prefix = "cut-master",
    urls = [
        "https://github.com/horance-liu/cut/archive/master.tar.gz",
    ],
)

其中,sha256的值可以通过执行如下命令得到,它有效地保证构建的可重入性。

$ curl -L https://github.com/horance-liu/cut/archive/master.tar.gz | sha256sum

文件标签

在规则placement_testsrcs属性定义中,标签placement_test.cc略去了域名和包名,及其可选的冒号。也就是说,在//cub/base/BUILD中,如下标签是等价的。

//cub/base:placement_test.cc
:placement_test.cc
placement_test.cc # 推荐

需要注意的是,Bazel一律使用Label引用文件,而不能使用传统的文件系统的路径名,例如,...形式的相对路径,或绝对路径。

规则标签

而在规则placement_testdeps属性定义中,标签:placement略去了域名和包名,但保留了冒号,用于显式地标识它是一个规则。也就是说,在//cub/base/BUILD中,如下标签是等价的。

//cub/base:placement
:placement  # 推荐
placement

但是,标签//cub/algo:loop来自于另一个包cub/algo,所以需要显式地给出全路径。

同名标签

特殊地,标签//cub/dci等价于//cub/dci:dci。也就是说,当target_name等于其目录名,则常常略去target_name。其中,标签//cub/dci:dci定义如下。

# cub/dci/BUILD
package(
    default_visibility = [    
        "//visibility:public",    
    ],
)

cc_library(
    name = "dci",
    hdrs = ["role.h"],
)

注意,标签//cub/dci切忌不可略去//,标签名以//开头,而cub/dci仅仅为包名。也就是说,在同一个//cub/dci/BUILD文件中,如下标签都是等价的。

//cub/dci:dci
//cub/dci
:dci
dci

依赖

在构建系统中,目标之间的依赖关系构成DAG的依赖图。一般地,存在两种依赖类型:

  • 直接依赖:依赖路径等于1
  • 传递依赖:依赖路径大于1

而依赖图存在两种类型:

  • 实际的依赖图:在构建时产生的依赖关系
  • 声明的依赖图:在构建文件中显式表达的依赖关系

案例分析

每个规则必须显式声明所有的直接依赖,而不应该尝试列出间接导入。例如,a直接依赖于bcb依赖于cd。此时,a需要显式声明依赖与bc,但不用声明依赖于d。切忌不能依赖于依赖的传递性,而省略对ac的依赖声明,因为这样会导致潜在的错误。

为了说明这个问题,举个简单的例子。初始,a直接依赖于bb直接依赖于c。此时直接依赖图与实际依赖图是相同的。

image.png

随后,a也直接依赖于b。此时,如果错误地借助于既有依赖的传递性,此时构建是成功的。实际依赖图中增加了a->c的依赖边,但在声明的依赖图中,并为显式化a->c的依赖边。这此时的依赖关系是脆弱的,极易被破坏,甚至出现混乱。

image.png

例如,经过重构,b不再依赖于c,而是依赖于d。此时,a->c的事实不再满足,构建是失败的。

image.png

总而言之,管理依赖的一个基本原则:显式声明所有的直接依赖

你可能感兴趣的:(Bazel Build:基本概念)