使用 Bzlmod 管理外部依赖项

Bzlmod 是 Bazel 5.0 中引入的新外部依赖项系统的代号。引入这一问题是为了解决旧系统中存在的无法以增量方式解决的几个痛点;如需了解详情,请参阅原始设计文档的“问题陈述”部分

在 Bazel 5.0 中,Bzlmod 默认处于停用状态;需要指定 --experimental_enable_bzlmod 标志才能使以下各项生效。顾名思义,此功能目前处于实验性阶段;在该功能正式发布之前,API 和行为可能会发生变化。

如需将项目迁移到 Bzlmod,请按照 Bzlmod 迁移指南进行操作。您还可以在示例代码库中找到 Bzlmod 用法示例。

Bazel 模块

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

模块本质上是一个可以有多个版本的 Bazel 项目,每个版本都发布其所依赖的其他模块的相关元数据。这类似于其他依赖项管理系统中熟悉的概念:Maven 工件、npm 软件包、Cargo crate、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 模块版本。此外,如果与 Bazel 模块版本进行比较,如果相同的保全相同,SemVer 版本 ab 会比较 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_dep 中的 repo_name 属性指定的名称。
    • 对于模块扩展代码库:通过 use_repo 引入的代码库名称。

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

Strict 依赖项

新的依赖项规范格式允许我们执行更严格的检查。具体而言,我们现在强制要求模块只能使用通过其直接依赖项创建的代码库。这有助于防止在传递依赖项图中的内容发生变化时,出现意外且难以调试的破坏。

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

  • 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 对象列表,每个对象均对应注册表中该模块的维护者的信息。请注意,此名称不一定与项目的 authors 作者相同。
      • versions:可在此注册表中找到的此模块的所有版本的列表。
      • yanked_versions:此模块的拉取版本列表。这目前是一个空操作,但将来,系统会跳过拉取的版本或生成错误。
  • /modules/$MODULE/$VERSION:包含以下文件的目录:
    • MODULE.bazel:此模块版本的 MODULE.bazel 文件。
    • source.json:一个 JSON 文件,包含有关如何获取此模块版本的源代码的信息。
      • 默认类型为“归档”,包含以下字段:
        • url:来源归档的网址。
        • integrity:归档的子资源完整性校验和。
        • strip_prefix:提取源归档时要去除的目录前缀。
        • patches:一个字符串列表,其中每个字符串都命名一个要应用于已提取归档的补丁文件。补丁文件位于 /modules/$MODULE/$VERSION/patches 目录下。
        • patch_strip:与 Unix 补丁的 --strip 参数相同。
      • 可将类型更改为使用包含以下字段的本地路径:
        • typelocal_path
        • path:代码库的本地路径,计算公式如下:
          • 如果路径是绝对路径,将按原样使用。
          • 如果路径是相对路径,而 module_base_path 是绝对路径,则路径解析为 <module_base_path>/<path>
          • 如果路径和 module_base_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 文件。此文件指定一些可用于对此模块版本的有效性进行健全性检查的基本构建和测试目标,并由 BCR 的 CI 流水线用于确保 BCR 中模块之间的互操作性。

选择注册表

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

模块扩展

借助模块扩展,您可以扩展模块系统,方法是从依赖项图读取模块的输入数据,执行必要的逻辑来解析依赖项,最后通过调用代码库规则创建代码库。它们在功能上与当今的 WORKSPACE 宏类似,但更适用于模块和传递依赖项。

模块扩展名是在 .bzl 文件中定义的,就像 Repo 规则或 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.2D 1.3 上实际具有 bazel_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”。

扩展程序定义

模块扩展的定义类似于 Repo 规则,并使用 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 对象,以授予对依赖关系图和所有相关标记的访问权限。然后,实现函数应调用代码库规则以生成代码库:

# @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 实用程序库来联系 Maven 并执行解析。最后,我们根据解析结果通过假设的 maven_single_jar 代码库规则创建多个代码库。