Bazel是Google开源的构建工具,最大的特点是构建速度快、可复现、跨平台,而且对依赖关系的描述非常清晰。C++项目一旦规模变大,传统的Makefile会越来越难维护,这时候Bazel的优势就体现出来了。它通过显式声明依赖,只重新编译真正变化的部分,在大项目中能节省大量编译时间。本文将从安装开始,一步步演示如何为一个C++项目配置Bazel。

Bazel的核心概念与安装步骤
在动手配置之前,需要先理解Bazel的几个基本概念。Bazel把工作区(Workspace)作为项目根目录,根目录下必须有WORKSPACE或MODULE.bazel文件,用来声明项目名称和外部依赖。每个目录下可以有一个BUILD文件,里面定义这个目录里的构建目标(target),比如库、二进制、测试等。
C++相关的规则主要有三个:cc_library定义库,cc_binary定义可执行程序,cc_test定义测试。这三个规则的组合几乎覆盖了C++项目的全部构建需求。安装Bazel比较简单,推荐使用Bazelisk,它会自动下载和管理Bazel版本。以Ubuntu为例:
# 方式一:通过npm安装bazelisk npm install -g @bazel/bazelisk # 方式二:直接下载二进制,重命名为bazel wget https://github.com/bazelbuild/bazelisk/releases/latest/download/bazelisk-linux-amd64 chmod +x bazelisk-linux-amd64 mv bazelisk-linux-amd64 /usr/local/bin/bazel # 验证安装 bazel version
安装完成后执行bazel version能看到版本号就说明环境准备好了。建议同时创建.bazelversion文件写入固定版本号,比如7.0.0,这样团队里所有人用的都是同一个Bazel版本,避免因为版本差异导致的构建行为不一致。
从零配置一个C++项目
假设项目结构如下:根目录下有main.cc入口文件,lib目录存放业务逻辑库,third_party目录存放第三方代码。这是比较典型的多模块C++项目布局。第一步是在根目录创建WORKSPACE文件:
# WORKSPACE文件内容 workspace(name = "my_demo_project")
新版Bazel推荐用MODULE.bazel替代WORKSPACE,但两者目前都支持,初学阶段用WORKSPACE更直观。接下来为lib目录创建BUILD文件,把业务代码封装成库:
# lib/BUILD文件
package(default_visibility = ["//visibility:public"])
cc_library(
name = "math_utils",
srcs = ["math_utils.cc"],
hdrs = ["math_utils.h"],
includes = ["."],
)这里的参数含义要弄清楚:srcs是源文件列表,hdrs是头文件列表,includes声明了头文件的搜索路径。把头文件也写进hdrs很重要,因为Bazel需要通过头文件追踪依赖关系,漏写的话下游目标可能编译失败。default_visibility设为public表示这个库可以被项目内其他目录引用。
然后在根目录创建BUILD文件,定义可执行程序:
# 根目录BUILD文件
cc_binary(
name = "demo_app",
srcs = ["main.cc"],
deps = [
"//lib:math_utils",
],
)deps里用标签语法//lib:math_utils引用刚才定义的库,双斜杠表示从工作区根目录开始的路径,冒号后面是目标名。最后写一个main.cc来验证:
#include <iostream>
#include "lib/math_utils.h"
int main() {
std::cout << "add(3, 5) = " << add(3, 5) << std::endl;
return 0;
}注意main.cc中include的路径写法。因为includes = ["."]声明了lib目录本身为搜索路径,所以既可以写math_utils.h也可以写lib/math_utils.h,取决于workspace根路径是否在编译命令的搜索范围里。建议统一采用相对工作区根目录的完整路径写法,可读性更好。
一切就绪后执行编译命令:
bazel build //:demo_app # 编译产物在bazel-bin目录下,实际是符号链接 ./bazel-bin/demo_app
第一次编译会下载一些工具链组件,耗时稍长,之后就是增量编译了。改动某个.cc文件后再次build,只会重编受影响的目标,速度提升非常明显。
依赖管理与第三方库的引入
真实项目离不开第三方库,Bazel引入第三方依赖主要有两种方式。第一种适合规模不大、直接以源码形式放在仓库里的库,比如把abseil、json.hpp直接复制到third_party目录,然后写一个BUILD文件把它们包装成cc_library:
# third_party/fmt/BUILD文件
cc_library(
name = "fmt",
srcs = ["src/format.cc"],
hdrs = glob(["include/fmt/**/*.h"]),
includes = ["include"],
visibility = ["//visibility:public"],
)这里用了glob函数自动收集头文件,省得一个个手写。第二种方式是通过网络拉取,在WORKSPACE中使用http_archive规则:
# WORKSPACE中追加
load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")
http_archive(
name = "fmt",
build_file = "//third_party:fmt.BUILD",
sha256 = "散列值",
strip_prefix = "fmt-10.1.1",
urls = ["https://github.com/fmtlib/fmt/releases/download/10.1.1/fmt-10.1.1.zip"],
)由于fmt官方仓库自带BUILD文件,如果第三方库本身支持Bazel,可以省略build_file参数直接使用。引用时写@fmt//:fmt即可。两种方式各有取舍:源码入库的方式构建稳定、离线可用,但仓库体积会变大;http_archive方式仓库干净,但依赖网络且需要处理版本锁定的问题。团队项目建议在CI上做好缓存,两全其美。
常见报错排查与构建提速技巧
配置过程中最常见的报错是头文件找不到,典型错误信息是file not found后跟某个.h路径。排查思路是先确认该头文件所在的cc_library是否声明了hdrs,再确认includes路径是否正确,最后检查引用方是否在deps里声明了依赖。Bazel的沙箱机制很严格,不像gcc那样能隐式找到系统里任意路径的头文件,任何没声明的依赖都无法通过编译。这看似麻烦,实际上是Bazel保证构建可复现的关键设计。
另一个高频问题是循环依赖,报错形如circular dependency。这说明两个库互相引用了对方,需要把公共部分抽出来放到第三个库里,重构依赖结构。还有链接期的符号重复问题,通常是把同一个库同时以静态和动态方式链接,或者srcs里重复包含了文件,检查deps列表即可定位。
构建提速方面,有几个实用手段。开启磁盘缓存:
# .bazelrc文件 build --disk_cache=~/.bazel_cache build --jobs=16 build --compilation_mode=fastbuild
.bazelrc是Bazel的配置文件,放在工作区根目录,每次构建自动加载。团队协作时建议把公共配置放进.bazelrc,把个人偏好放进~/.bazelrc。如果团队有自建缓存服务器,还可以配置--remote_cache实现共享缓存,新人第一次拉代码编译也能直接命中大部分缓存。另外,用bazel test //...跑测试、用bazel clean清理产物、用bazel query 'deps(//:demo_app)'查看依赖树,这些命令在日常调试中都很常用。
整体来看,Bazel的学习曲线比CMake略陡,前期配置要写不少BUILD文件,但一旦项目结构稳定下来,后续的维护成本极低,增量编译和跨语言构建的能力是传统工具难以匹敌的。如果你的C++项目模块超过十个,或者有跨平台发布需求,值得花时间迁移到Bazel。