使用 Bzlmod 管理外部依赖项

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

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

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

Bazel 模块

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

一个 模块本质上是一个 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 1.0
      /     \
   B 1.0    C 1.1
     |        |
   D 1.0    D 1.1

应使用哪个版本的 D?为了解决这个问题,Bzlmod 使用了 最小版本选择 (MVS) 算法,该算法在 Go 模块系统中引入。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 默认情况下为 ,或 repo_namebazel_dep属性指定的名称。
    • 对于模块扩展仓库:通过 use_repo引入的代码库名称。

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

严格依赖项

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

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

  • Bazel 模块仓库可以看到通过 bazel_depuse_repoMODULE.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 补丁的 --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 Central Registry

Bazel Central Registry (BCR) 是一个索引注册表,位于 bcr.bazel.build。其内容 由 GitHub 仓库 bazelbuild/bazel-central-registry提供支持。

BCR 由 Bazel 社区维护;欢迎贡献者提交 pull 请求。请参阅 Bazel Central Registry 政策和程序

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

选择注册表

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

模块扩展

借助模块扩展,您可以通过读取依赖项关系图中模块的输入数据 、执行必要的逻辑来解析 依赖项,并最终通过调用仓库规则来创建仓库,从而扩展模块系统。它们在功能上类似于今天的 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 实际上对 bazel_depD 1.3,但由于 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_classes,每个都有许多 属性。标记类定义了此 扩展使用的标记的架构。继续上面的假设“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 实用程序 Coursier 来联系 Maven 并执行解析。最后,我们使用解析 结果来创建多个仓库,使用假设的 maven_single_jar 仓库规则。