使用 Bzlmod 管理外部依赖项

Bzlmod 是 Bazel 5.0 中引入的新外部依赖项系统的代号。它旨在解决旧系统的一些痛点,这些痛点无法通过增量方式切实可行地修复;如需了解详情,请参阅原始设计文档的问题陈述部分

在 Bazel 5.0 中,Bzlmod 默认处于关闭状态;需要指定 --experimental_enable_bzlmod 标志才能使以下内容生效。正如标志名称所暗示的那样,此功能目前处于实验阶段;在正式发布之前,API 和行为可能会发生变化。

如需将项目迁移到 Bzlmod,请参阅 Bzlmod 迁移指南。您还可以在 examples 代码库中找到 Bzlmod 用法示例。

Bazel 模块

旧的基于 WORKSPACE 的外部依赖项系统以通过代码库规则(或代码库规则)创建的代码库(或代码库)为中心。虽然 repo 在新系统中仍然是一个重要概念,但模块是依赖项的核心单元。

模块本质上是一个可以有多个版本的 Bazel 项目,每个版本都会发布有关其所依赖的其他模块的元数据。这类似于其他依赖项管理系统中的熟悉概念:Maven 制品、npm 软件包、Cargo 、Go 模块等。

模块只需使用 nameversion 对指定其依赖项,而无需在 WORKSPACE 中指定具体网址。然后,在 Bazel 注册表(默认情况下为 Bazel Central Registry)中查找依赖项。在工作区中,每个模块随后都会变成一个代码库。

MODULE.bazel

每个模块的每个版本都有一个 MODULE.bazel 文件,用于声明其依赖项和其他元数据。下面是一个基本示例:

module(
    name = "my-module",
    version = "1.0",
)

bazel_dep(name = "rules_cc", version = "0.0.1")
bazel_dep(name = "protobuf", version = "3.19.0")

MODULE.bazel 文件应位于工作区目录的根目录下(与 WORKSPACE 文件位于同一目录中)。与 WORKSPACE 文件不同,您无需指定传递依赖项;相反,您只需指定直接依赖项,系统会处理依赖项的 MODULE.bazel 文件,以自动发现传递依赖项。

MODULE.bazel 文件与 BUILD 文件类似,不支持任何形式的控制流;此外,它还禁止使用 load 语句。MODULE.bazel 文件支持的指令包括:

版本格式

Bazel 拥有多样化的生态系统,项目使用各种版本控制方案。目前最受欢迎的是 SemVer,但也有一些知名项目使用不同的方案,例如 Abseil,其版本基于日期(例如 20210324.2)。

为此,Bzlmod 采用了更宽松的 SemVer 规范。不同之处包括:

  • 根据 SemVer 的规定,版本中的“发布”部分必须包含 3 个段:MAJOR.MINOR.PATCH。在 Bazel 中,此要求放宽了,允许任意数量的段。
  • 在 SemVer 中,“发布”部分中的每个段都必须仅包含数字。在 Bazel 中,此限制放宽为允许使用字母,并且比较语义与“预发布”部分中的“标识符”相匹配。
  • 此外,系统不会强制执行主要版本、次要版本和补丁版本递增的语义。(不过,如需详细了解我们如何表示向后兼容性,请参阅兼容性级别。)

任何有效的 SemVer 版本都是有效的 Bazel 模块版本。此外,两个 SemVer 版本 ab 比较结果为 a < b,当且仅当它们作为 Bazel 模块版本进行比较时结果也为 a < b

版本分辨率

菱形依赖项问题是版本化依赖项管理领域中的一个常见问题。假设您有以下依赖关系图:

       A 1.0
      /     \
   B 1.0    C 1.1
     |        |
   D 1.0    D 1.1

应使用哪个版本的 D?为了解决这个问题,Bzlmod 使用 Go 模块系统中引入的最小版本选择 (MVS) 算法。MVS 假设模块的所有新版本都向后兼容,因此只需选择任何依赖项指定的最高版本(在我们的示例中为 D 1.1)。之所以称为“最低”,是因为此处的 D 1.1 是满足我们要求的最低版本;即使存在 D 1.2 或更新版本,我们也不会选择它们。这样做的好处是,版本选择具有高保真度可重现性

版本解析是在本地机器上执行的,而不是由注册表执行的。

兼容性级别

请注意,MVS 关于向后兼容性的假设是可行的,因为它只是将模块的向后不兼容版本视为单独的模块。就 SemVer 而言,这意味着 A 1.x 和 A 2.x 被视为不同的模块,并且可以共存于已解析的依赖关系图中。这之所以可行,是因为 Go 中的软件包路径中编码了主要版本,因此不会出现任何编译时或链接时冲突。

在 Bazel 中,我们没有此类保证。因此,我们需要一种方法来表示“主要版本”号,以便检测向后不兼容的版本。此数字称为兼容性级别,由每个模块版本在其 module() 指令中指定。有了这些信息,当我们在已解析的依赖关系图中检测到同一模块的不同版本具有不同的兼容性级别时,就可以抛出错误。

代码库名称

在 Bazel 中,每个外部依赖项都有一个仓库名称。有时,同一依赖项可能会通过不同的代码库名称使用(例如,@io_bazel_skylib@bazel_skylib 都表示 Bazel skylib),或者同一代码库名称可能会用于不同项目中的不同依赖项。

在 Bzlmod 中,存储库可由 Bazel 模块和模块扩展程序生成。为了解决代码库名称冲突问题,我们在新系统中采用了代码库映射机制。以下是两个重要概念:

  • 规范代码库名称:每个代码库的全局唯一代码库名称。这将是代码库所在的目录名称。
    其结构如下(警告:规范名称格式不是您应该依赖的 API,它可能会随时更改):

    • 对于 Bazel 模块代码库:module_name~version
      示例@bazel_skylib~1.0.3
    • 对于模块扩展代码库:module_name~version~extension_name~repo_name
      示例@rules_cc~0.0.1~cc_configure~local_config_cc
  • 表面上的代码库名称:要在代码库内的 BUILD.bzl 文件中使用的代码库名称。同一依赖项在不同代码库中可能具有不同的显示名称。
    的确定方式如下:

    • 对于 Bazel 模块代码库:默认情况下为 module_name,或者为 bazel_deprepo_name 属性指定的名称。
    • 对于模块扩展代码库:通过 use_repo 引入的代码库名称。

每个代码库都有一个直接依赖项的代码库映射字典,该字典是从表观代码库名称到规范代码库名称的映射。我们在构建标签时使用代码库映射来解析代码库名称。请注意,规范代码库名称不会发生冲突,并且可以通过解析 MODULE.bazel 文件发现表观代码库名称的用法,因此可以轻松捕获和解决冲突,而不会影响其他依赖项。

严格的依赖项

新的依赖项规范格式可让我们执行更严格的检查。具体而言,我们现在强制规定,模块只能使用从其直接依赖项创建的代码库。这有助于防止在传递依赖关系图中的某些内容发生更改时出现意外且难以调试的损坏。

严格依赖项是基于代码库映射实现的。基本上,每个代码库的代码库映射都包含其所有直接依赖项,任何其他代码库都不可见。每个代码库的可见依赖项按如下方式确定:

  • Bazel 模块代码库可以通过 bazel_depuse_repo 查看 MODULE.bazel 文件中引入的所有代码库。
  • 模块扩展代码库可以查看提供扩展的模块的所有可见依赖项,以及由同一模块扩展生成的所有其他代码库。

注册表

Bzlmod 通过从 Bazel 注册表请求依赖项的信息来发现依赖项。Bazel 注册表只是一个 Bazel 模块数据库。唯一受支持的注册表形式是索引注册表,它是一个遵循特定格式的本地目录或静态 HTTP 服务器。未来,我们计划添加对单模块注册表的支持,这些注册表只是包含项目源代码和历史记录的 Git 代码库。

索引注册表

索引注册表是一个本地目录或静态 HTTP 服务器,其中包含有关模块列表的信息,包括模块的首页、维护者、每个版本的 MODULE.bazel 文件,以及如何获取每个版本的源代码。值得注意的是,它需要自行提供源归档文件。

指数注册表必须遵循以下格式:

  • /bazel_registry.json:包含注册表元数据的 JSON 文件,例如:
    • mirrors,用于指定要用于源归档的镜像列表。
    • module_base_path,用于指定 source.json 文件中具有 local_repository 类型的模块的基本路径。
  • /modules:一个目录,其中包含此注册表中每个模块的子目录。
  • /modules/$MODULE:一个目录,其中包含相应模块的每个版本的子目录,以及以下文件:
    • metadata.json:包含模块信息的 JSON 文件,具有以下字段:
      • homepage:项目的首页网址。
      • maintainers:JSON 对象列表,每个对象都对应于注册表中模块维护者的信息。 请注意,这不一定与项目的作者相同。
      • versions:此注册表中要查找的相应模块的所有版本。
      • yanked_versions:相应模块的已撤消版本的列表。目前,此操作不会执行任何操作,但在未来,系统会跳过已撤消的版本或生成错误。
  • /modules/$MODULE/$VERSION:包含以下文件的目录:
    • MODULE.bazel:相应模块版本的 MODULE.bazel 文件。
    • source.json:一个 JSON 文件,其中包含有关如何获取相应模块版本来源的信息。
      • 默认类型为“archive”,包含以下字段:
        • url:源归档的网址。
        • integrity:归档的子资源完整性校验和。
        • strip_prefix:解压缩源归档时要剥离的目录前缀。
        • patches:一个字符串列表,每个字符串都指定了要应用于提取的归档文件的补丁文件。补丁文件位于 /modules/$MODULE/$VERSION/patches 目录下。
        • patch_strip:与 Unix patch 的 --strip 实参相同。
      • 可以使用以下字段将类型更改为使用本地路径:
        • typelocal_path
        • path:代码库的本地路径,计算方式如下:
          • 如果路径是绝对路径,则会按原样使用。
          • 如果 path 是相对路径,而 module_base_path 是绝对路径,则 path 会解析为 <module_base_path>/<path>
          • 如果 path 和 module_base_path 均为相对路径,则 path 会解析为 <registry_path>/<module_base_path>/<path>。注册表必须在本地托管,并由 --registry=file://<registry_path> 使用。 否则,Bazel 将抛出错误。
    • patches/:包含补丁文件的可选目录,仅在 source.json 具有“archive”类型时使用。

Bazel 中央注册表

Bazel 中央注册中心 (BCR) 是一个位于 bcr.bazel.build 的索引注册中心。其内容由 GitHub 代码库 bazelbuild/bazel-central-registry 提供支持。

BCR 由 Bazel 社区维护;欢迎贡献者提交拉取请求。请参阅 Bazel 中央注册表政策和规程

除了遵循常规索引注册表的格式之外,BCR 还要求每个模块版本 (/modules/$MODULE/$VERSION/presubmit.yml) 都有一个 presubmit.yml 文件。此文件指定了一些基本的 build 和测试目标,可用于对相应模块版本的有效性进行初步检查,并由 BCR 的 CI 流水线使用,以确保 BCR 中模块之间的互操作性。

选择注册表

可重复的 Bazel 标志 --registry 可用于指定要从中请求模块的注册表列表,因此您可以设置项目以从第三方或内部注册表中获取依赖项。较早的注册优先。为方便起见,您可以将 --registry 标志的列表放在项目的 .bazelrc 文件中。

模块扩展

模块扩展功能可让您通过以下方式扩展模块系统:从依赖关系图中的模块读取输入数据,执行必要的逻辑来解析依赖项,最后通过调用 repo 规则来创建 repo。它们在功能上与当前的 WORKSPACE 宏类似,但在模块和传递依赖项的世界中更适用。

模块扩展程序在 .bzl 文件中定义,就像库规则或 WORKSPACE 宏一样。它们不会被直接调用;相反,每个模块都可以指定称为标记的数据片段供扩展程序读取。然后,在完成模块版本解析后,运行模块扩展程序。每个扩展程序在模块解析后(但在实际构建之前)运行一次,并可以读取整个依赖关系图中的所有属于它的标记。

          [ A 1.1                ]
          [   * maven.dep(X 2.1) ]
          [   * maven.pom(...)   ]
              /              \
   bazel_dep /                \ bazel_dep
            /                  \
[ B 1.2                ]     [ C 1.0                ]
[   * maven.dep(X 1.2) ]     [   * maven.dep(X 2.1) ]
[   * maven.dep(Y 1.3) ]     [   * cargo.dep(P 1.1) ]
            \                  /
   bazel_dep \                / bazel_dep
              \              /
          [ D 1.4                ]
          [   * maven.dep(Z 1.4) ]
          [   * cargo.dep(Q 1.1) ]

在上面的依赖关系图示例中,A 1.1B 1.2 等是 Bazel 模块;您可以将每个模块视为一个 MODULE.bazel 文件。每个模块都可以为模块扩展程序指定一些标记;这里为扩展程序“maven”指定了一些标记,为“cargo”指定了一些标记。当此依赖关系图最终确定后(例如,可能 B 1.2 实际上对 D 1.3bazel_dep 依赖关系,但由于 C 而升级到 D 1.4),系统会运行扩展程序“maven”,并读取所有 maven.* 标记,然后使用其中的信息来决定要创建哪些代码库。 “cargo”扩展也是如此。

扩展程序使用情况

扩展程序本身托管在 Bazel 模块中,因此要在模块中使用扩展程序,您需要先在该模块上添加 bazel_dep,然后调用 use_extension 内置函数将其纳入作用域。请考虑以下示例,该代码段来自 MODULE.bazel 文件,用于使用 rules_jvm_external 模块中定义的假设的“maven”扩展程序:

bazel_dep(name = "rules_jvm_external", version = "1.0")
maven = use_extension("@rules_jvm_external//:extensions.bzl", "maven")

将扩展程序纳入范围后,您可以使用点语法为其指定标记。请注意,标记需要遵循相应标记类定义的架构(请参阅下方的扩展定义)。以下示例指定了一些 maven.depmaven.pom 标记。

maven.dep(coord="org.junit:junit:3.0")
maven.dep(coord="com.google.guava:guava:1.2")
maven.pom(pom_xml="//:pom.xml")

如果扩展服务生成的代码库要在模块中使用,请使用 use_repo 指令声明这些代码库。这是为了满足严格的依赖项条件并避免本地代码库名称冲突。

use_repo(
    maven,
    "org_junit_junit",
    guava="com_google_guava_guava",
)

扩展程序生成的代码库是其 API 的一部分,因此从您指定的标记中,您应该知道“maven”扩展程序将生成一个名为“org_junit_junit”的代码库和一个名为“com_google_guava_guava”的代码库。借助 use_repo,您可以选择在模块范围内重命名它们,例如此处重命名为“guava”。

扩展程序定义

模块扩展的定义方式与代码库规则类似,都是使用 module_extension 函数。两者都有实现函数;但 repo 规则有多个属性,而模块扩展有多个 tag_class,每个 tag_class 都有多个属性。标记类定义了此扩展程序所用标记的架构。继续沿用上文中的假设“maven”扩展程序示例:

# @rules_jvm_external//:extensions.bzl
maven_dep = tag_class(attrs = {"coord": attr.string()})
maven_pom = tag_class(attrs = {"pom_xml": attr.label()})
maven = module_extension(
    implementation=_maven_impl,
    tag_classes={"dep": maven_dep, "pom": maven_pom},
)

这些声明清楚地表明,可以使用上述定义的属性架构来指定 maven.depmaven.pom 标记。

实现函数类似于 WORKSPACE 宏,但它会获取 module_ctx 对象,该对象可授予对依赖关系图和所有相关标记的访问权限。然后,实现函数应调用 repo 规则来生成 repo:

# @rules_jvm_external//:extensions.bzl
load("//:repo_rules.bzl", "maven_single_jar")
def _maven_impl(ctx):
  coords = []
  for mod in ctx.modules:
    coords += [dep.coord for dep in mod.tags.dep]
  output = ctx.execute(["coursier", "resolve", coords])  # hypothetical call
  repo_attrs = process_coursier(output)
  [maven_single_jar(**attrs) for attrs in repo_attrs]

在上面的示例中,我们遍历了依赖关系图 (ctx.modules) 中的所有模块,每个模块都是一个 bazel_module 对象,其 tags 字段会公开模块上的所有 maven.* 标记。然后,我们调用 CLI 实用程序 Coursier 来联系 Maven 并执行解析。最后,我们使用解析结果,通过假设的 maven_single_jar 代码库规则创建多个代码库。