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
的项目。
在根目录中创建文件WORKSPACE
,及其同名的子目录polyflow
;然后在子目录polyflow
中,根据系统架构将其分解为两个基本的模块core
, util
。
需要注意的是,Bazel强烈建议将头文件、实现文件、测试文件放在同一个目录,并非将include, src, test
目录相互分离。一方面,这样的项目组织避免了频发地切换目录,而且文件跳转也方便多了。另一方面,Bazel可以控制规则可见性,将头文件的依赖控制在合理的范围内。
再次提醒,-I
目录始于WORKSPACE
所在的目录。因此,包含头文件时,需要包含项目的完整路径。例如,graph.cc
的第一行代码所示。
#include "polyflow/core/graph.h"
如果习惯于包含单一文件名的风格,可能需要适应一下。事实上,单一文件名风格,为了避免潜在的文件名冲突,一般也会将文件名命名得特别长,例如polyflow-core-graph.h
。此时,两者实现效果是等价的,除非忽略潜在冲突的可能性。
包:Package
含有BUILD
或BUILD.bazel
文件的目录,Bazel在构建时将其标识为包(Package)。如果一个目录不包含BUILD
或BUILD.bazel
文件,则它只是一个纯粹的目录,隶属于最近的父包(包含BUILD
或BUILD.bazel
文件)。
包名
包名始于WORKSPACE
所在的目录,由路径名组成。例如app/core
, app/core/mem
。需要注意的是,路径名./app/core
与包名app/core
不等价,它们之间存在微妙的差异。
特殊地(常见于遗留系统中),如果BUILD
文件与WORKSPACE
都在根目录中,此时该包名为空,而不是一个点号.
。
子包
在一个包中,递归地包含该目录下的所有文件。但是,如果某个子目录自身包含BUILD
文件,则独立成为一个包,它并不隶属于上一级的父包;但是,习惯于将其称为上级的子包。也就是说,子包独立于父包,父包不包括子包。
例如,包app/core
不包括包app/core/mem
,它们相互独立。但是,习惯依然称app/core/mem
为 app/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_test
的srcs
属性定义中,标签placement_test.cc
略去了域名和包名,及其可选的冒号。也就是说,在//cub/base/BUILD
中,如下标签是等价的。
//cub/base:placement_test.cc
:placement_test.cc
placement_test.cc # 推荐
需要注意的是,Bazel一律使用Label引用文件,而不能使用传统的文件系统的路径名,例如,..
或.
形式的相对路径,或绝对路径。
规则标签
而在规则placement_test
的deps
属性定义中,标签: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
直接依赖于b
和c
,b
依赖于c
和d
。此时,a
需要显式声明依赖与b
和c
,但不用声明依赖于d
。切忌不能依赖于依赖的传递性,而省略对a
对c
的依赖声明,因为这样会导致潜在的错误。
为了说明这个问题,举个简单的例子。初始,a
直接依赖于b
,b
直接依赖于c
。此时直接依赖图与实际依赖图是相同的。
随后,a
也直接依赖于b
。此时,如果错误地借助于既有依赖的传递性,此时构建是成功的。实际依赖图中增加了a->c
的依赖边,但在声明的依赖图中,并为显式化a->c
的依赖边。这此时的依赖关系是脆弱的,极易被破坏,甚至出现混乱。
例如,经过重构,b
不再依赖于c
,而是依赖于d
。此时,a->c
的事实不再满足,构建是失败的。
总而言之,管理依赖的一个基本原则:显式声明所有的直接依赖。