使用 Bzlmod 管理外部依赖项

报告问题 查看源代码 每夜 build · 7.4 .

Bzlmod 是 Bazel 5.0 中引入的新外部依赖项系统的代号。引入它是为了解决旧系统无法逐步修复的几个痛点;如需了解详情,请参阅原始设计文档的“问题陈述”部分

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

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

Bazel 模块

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

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

模块只需使用 nameversion 对(而非 WORKSPACE 中的特定网址)指定其依赖项。然后,系统会在 Bazel 注册表(默认为 Bazel 中央注册表)中查找依赖项。在您的工作区中,每个模块都会变成一个代码库。

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 规定,版本的“release”部分必须由 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 文件发现明显代码库名称的使用情况,因此可以很容易地捕获并解决冲突,而不会影响其他依赖项。

严格依赖项

借助新的依赖项规范格式,我们可以执行更严格的检查。具体而言,我们现在强制要求模块只能使用根据其直接依赖项创建的代码库。这有助于防止在传递依赖项图中发生更改时出现意外且难以调试的故障。

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

  • 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 文件,包含有关如何获取此模块版本源代码的信息。
      • 默认类型为“archive”,包含以下字段:
        • url:源代码归档的网址。
        • integrity:归档文件的子资源完整性校验和。
        • strip_prefix:解压缩源代码归档文件时要剥离的目录前缀。
        • patches:一个字符串列表,其中每个字符串都指定要应用于解压缩的归档文件的补丁文件的名称。补丁文件位于 /modules/$MODULE/$VERSION/patches 目录下。
        • patch_strip:与 Unix 补丁的 --strip 参数相同。
      • 您可以更改类型,以使用以下字段中的本地路径:
        • typelocal_path
        • path:代码库的本地路径,按如下方式计算:
          • 如果路径是绝对路径,则会按原样使用。
          • 如果路径是相对路径且 module_base_path 是绝对路径,则路径将解析为 <module_base_path>/<path>
          • 如果 path 和 module_base_path 都是相对路径,path 会解析为 <registry_path>/<module_base_path>/<path>。注册表必须在本地托管,并由 --registry=file://<registry_path> 使用。否则,Bazel 会抛出错误。
    • patches/:包含补丁文件的可选目录,仅在 source.json 的类型为“归档”时使用。

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 可用于指定要从中请求模块的注册表列表,以便您将项目设置为从第三方或内部注册表提取依赖项。较早的注册表会优先考虑。为方便起见,您可以在项目的 .bazelrc 文件中放置 --registry 标志列表。

模块扩展

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

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

          [ 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.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”。

扩展定义

模块扩展的定义与代码库规则类似,使用 module_extension 函数。这两者都有实现函数;但虽然代码库规则具有多个属性,但模块扩展具有多个 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 规则以生成代码库:

# @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 代码库规则,使用解析结果创建多个代码库。