依赖解析

Cargo 的主要任务之一是根据每个包中指定的版本要求确定要使用的依赖项版本。此过程称为“依赖解析”,由“解析器”执行。解析的结果存储在 Cargo.lock 文件中,该文件将依赖项“锁定”到特定版本,并使其在一段时间内保持固定。 cargo tree 命令可用于可视化解析器的结果。

约束和启发式

在许多情况下,没有单一的“最佳”依赖解析。解析器在各种约束和启发式方法下运行,以找到普遍适用的解析。要理解这些是如何相互作用的,粗略了解依赖解析的工作原理会很有帮助。

以下伪代码近似于 Cargo 解析器的工作方式

#![allow(unused)] fn main() { pub fn resolve(workspace: &[Package], policy: Policy) -> Option<ResolveGraph> { let dep_queue = Queue::new(workspace); let resolved = ResolveGraph::new(); resolve_next(pkq_queue, resolved, policy) } fn resolve_next(dep_queue: Queue, resolved: ResolveGraph, policy: Policy) -> Option<ResolveGraph> { let Some(dep_spec) = policy.pick_next_dep(dep_queue) else { // Done return Some(resolved); }; if let Some(resolved) = policy.try_unify_version(dep_spec, resolved.clone()) { return Some(resolved); } let dep_versions = dep_spec.lookup_versions()?; let mut dep_versions = policy.filter_versions(dep_spec, dep_versions); while let Some(dep_version) = policy.pick_next_version(&mut dep_versions) { if policy.needs_version_unification(dep_version, &resolved) { continue; } let mut dep_queue = dep_queue.clone(); dep_queue.enqueue(dep_version.dependencies); let mut resolved = resolved.clone(); resolved.register(dep_version); if let Some(resolved) = resolve_next(dep_queue, resolved) { return Some(resolved); } } // No valid solution found, backtrack and `pick_next_version` None } }

关键步骤

  • 遍历依赖项 (pick_next_dep):依赖项的遍历顺序会影响同一依赖项的相关版本要求如何解析(请参阅统一版本),以及解析器回溯的程度,从而影响解析器性能。
  • 统一版本 (try_unify_version, needs_version_unification):Cargo 尽可能重用版本,以减少构建时间并允许通用依赖项的类型在 API 之间传递。如果多个版本本来可以统一,但由于其 依赖项规范 中的冲突,Cargo 将回溯,如果找不到解决方案则报错,而不是选择多个版本。依赖项规范或 Cargo 可能会确定某个版本是不 желаемым,宁愿回溯或报错,也不愿使用它。
  • 首选版本 (pick_next_version):Cargo 可能会决定它应该首选特定版本,并在回溯时回退到下一个版本。

版本号

通常,Cargo 优先选择当前可用的最高版本。

例如,如果您的解析图中有一个包,其中包含

[dependencies] bitflags = "*"

如果在生成 Cargo.lock 文件时,bitflags 的最高版本是 1.2.1,则该包将使用 1.2.1

有关可能例外的示例,请参阅 Rust 版本

版本要求

包通过 版本要求 指定它们支持的版本,拒绝所有其他版本。

例如,如果您的解析图中有一个包,其中包含

[dependencies] bitflags = "1.0" # meaning `>=1.0.0,<2.0.0`

如果在生成 Cargo.lock 文件时,bitflags 的最高版本是 1.2.1,则该包将使用 1.2.1,因为它是在兼容范围内的最高版本。如果发布了 2.0.0,它仍将使用 1.2.1,因为 2.0.0 被认为是不兼容的。

SemVer 兼容性

Cargo 假定包遵循 SemVer,并且如果它们根据 Caret 版本要求SemVer 兼容的,则将统一依赖项版本。如果两个兼容版本由于版本要求冲突而无法统一,Cargo 将报错。

有关“兼容”更改的指南,请参阅 SemVer 兼容性 章节。

示例

以下两个包将统一它们对 bitflags 的依赖项,因为选择的任何版本都将彼此兼容。

# Package A [dependencies] bitflags = "1.0" # meaning `>=1.0.0,<2.0.0` # Package B [dependencies] bitflags = "1.1" # meaning `>=1.1.0,<2.0.0`

以下包将报错,因为版本要求冲突,选择了两个不同的兼容版本。

# Package A [dependencies] log = "=0.4.11" # Package B [dependencies] log = "=0.4.8"

以下两个包将不会统一它们对 rand 的依赖项,因为每个包只有不兼容的版本可用。相反,将解析和构建两个不同的版本(例如 0.6.5 和 0.7.3)。这可能会导致潜在的问题,请参阅 版本不兼容性危害 章节了解更多详细信息。

# Package A [dependencies] rand = "0.7" # meaning `>=0.7.0,<0.8.0` # Package B [dependencies] rand = "0.6" # meaning `>=0.6.0,<0.7.0`

通常,以下两个包将不会统一它们的依赖项,因为不兼容的版本可用于满足版本要求:相反,将解析和构建两个不同的版本(例如 0.6.5 和 0.7.3)。其他约束或启发式方法的应用可能会导致这些版本被统一,从而选择一个版本(例如 0.6.5)。

# Package A [dependencies] rand = ">=0.6,<0.8.0" # Package B [dependencies] rand = "0.6" # meaning `>=0.6.0,<0.7.0`

版本不兼容性危害

当解析图中出现多个版本的 crate 时,当这些 crate 使用的类型被公开时,可能会导致问题。这是因为 Rust 编译器认为类型和项是不同的,即使它们具有相同的名称。库在发布 SemVer 不兼容版本(例如,在使用 1.0.0 之后发布 2.0.0)时应格外小心,特别是对于广泛使用的库。

semver trick”是解决发布破坏性更改同时保持与旧版本兼容性问题的解决方法。链接的页面详细介绍了问题是什么以及如何解决它。简而言之,当库想要发布 SemVer 破坏性版本时,发布新版本,并发布先前版本的点版本,该版本重新导出新版本的类型。

这些不兼容性通常表现为编译时错误,但有时它们只会表现为运行时行为不当。例如,假设有一个名为 foo 的通用库,最终在解析图中同时出现版本 1.0.02.0.0。如果在使用版本 1.0.0 的库创建的对象上使用 downcast_ref,并且调用 downcast_ref 的代码正在向下转换为版本 2.0.0 的类型,则向下转换将在运行时失败。

重要的是要确保如果您有多个库版本,您正在正确使用它们,特别是如果不同版本的类型有可能一起使用。 cargo tree -d 命令可用于识别重复版本及其来源。同样,如果您发布常用库的 SemVer 不兼容版本,则必须考虑对生态系统的影响。

Rust 版本

为了支持使用最低支持的 Rust 版本 开发软件,解析器可以考虑依赖项版本与您的 Rust 版本的兼容性。这由配置字段 resolver.incompatible-rust-versions 控制。

使用 fallback 设置,解析器将优先选择 Rust 版本等于或高于您自己的 Rust 版本的包。例如,您正在使用 Rust 1.85 开发以下包

[package] name = "my-cli" rust-version = "1.62" [dependencies] clap = "4.0" # resolves to 4.0.32

解析器将选择版本 4.0.32,因为它具有 1.60.0 的 Rust 版本。

  • 未选择 4.0.0,因为它是一个 较低的版本号,尽管它也具有 1.60.0 的 Rust 版本。
  • 未选择 4.5.20,因为它与 my-cli 的 1.62 Rust 版本不兼容,尽管它具有更高的 版本,并且它具有 1.74.0 的 Rust 版本,该版本与您的 1.85 工具链兼容。

如果版本要求不包含与 Rust 版本兼容的依赖项版本,则解析器不会报错,而是会选择一个版本,即使该版本可能不是最优的。例如,您更改了对 clap 的依赖项

[package] name = "my-cli" rust-version = "1.62" [dependencies] clap = "4.2" # resolves to 4.5.20

没有版本的 clap 符合与 Rust 版本 1.62 兼容的 版本要求。然后,解析器将选择一个不兼容的版本,例如 4.5.20,尽管它的 Rust 版本为 1.74。

当解析器选择包的依赖项版本时,它不知道最终将对该版本具有传递依赖项的所有工作区成员,因此它不能仅考虑与该依赖项相关的 Rust 版本。当工作区成员具有不同的 Rust 版本时,解析器具有启发式方法来找到“足够好”的解决方案。这甚至适用于没有 Rust 版本的工作区中的包。

当工作区具有不同 Rust 版本的成员时,解析器可能会选择比必要的版本更低的依赖项版本。例如,您有以下工作区成员

[package] name = "a" rust-version = "1.62" [package] name = "b" [dependencies] clap = "4.2" # resolves to 4.5.20

尽管包 b 没有 Rust 版本,并且可以使用更高的版本(如 4.5.20),但由于包 a 的 Rust 版本为 1.62,因此将选择 4.0.32。

或者解析器可能会选择过高的版本。例如,您有以下工作区成员

[package] name = "a" rust-version = "1.62" [dependencies] clap = "4.2" # resolves to 4.5.20 [package] name = "b" [dependencies] clap = "4.5" # resolves to 4.5.20

尽管每个包对 clap 的版本要求都满足其自身的 Rust 版本,但由于 版本统一,解析器将需要选择一个在这两种情况下都适用的版本,那将是像 4.5.20 这样的版本。

功能特性

为了生成 Cargo.lock,解析器构建依赖关系图,就像所有 功能特性 的所有 工作区 成员都已启用一样。这确保了任何可选依赖项都可用,并在使用 --features 命令行标志 添加或删除功能特性时与图的其余部分正确解析。解析器第二次运行以确定编译 crate 时使用的实际功能特性,这基于命令行上选择的功能特性。

依赖项使用在其上启用的所有功能特性的并集进行解析。例如,如果一个包依赖于启用了 serde 依赖项im 包,而另一个包依赖于启用了 rayon 依赖项im 包,则 im 将在启用这两个功能特性的情况下构建,并且 serderayon crate 将包含在解析图中。如果没有任何包依赖于具有这些功能特性的 im,则这些可选依赖项将被忽略,并且它们不会影响解析。

在工作区中构建多个包时(例如使用 --workspace 或多个 -p 标志),所有这些包的依赖项的功能特性都会被统一。如果您在某种情况下希望避免为不同的工作区成员进行统一,则需要通过单独的 cargo 调用来构建它们。

解析器将跳过缺少必需功能特性的包版本。例如,如果一个包依赖于具有 perf 功能特性regex^1 版本,则它可以选择的最旧版本是 1.3.0,因为该版本之前的版本不包含 perf 功能特性。同样,如果从新版本中删除了功能特性,则需要该功能特性的包将停留在包含该功能特性的旧版本上。不鼓励在 SemVer 兼容版本中删除功能特性。请注意,可选依赖项也定义了一个隐式功能特性,因此删除可选依赖项或使其成为非可选依赖项可能会导致问题,请参阅 删除可选依赖项

功能特性解析器版本 2

当在 Cargo.toml 中指定 resolver = "2" 时(请参阅下面的 解析器版本),将使用不同的功能特性解析器,该解析器使用不同的算法来统一功能特性。版本 "1" 解析器将统一包的功能特性,无论它在哪里指定。版本 "2" 解析器将避免在以下情况下统一功能特性

  • 如果当前未构建目标,则不会启用特定于目标的依赖项的功能特性。例如

    [dependencies.common] version = "1.0" features = ["f1"] [target.'cfg(windows)'.dependencies.common] version = "1.0" features = ["f2"]

    当为非 Windows 平台构建此示例时,不会启用 f2 功能特性。

  • build-dependencies 或 proc-macros 上启用的功能特性在这些相同的依赖项用作正常依赖项时不会被统一。例如

    [dependencies] log = "0.4" [build-dependencies] log = {version = "0.4", features=['std']}

    在构建构建脚本时,log crate 将使用 std 功能特性构建。在构建包的库时,它不会启用该功能特性。

  • dev-dependencies 上启用的功能特性在这些相同的依赖项用作正常依赖项时不会被统一,除非当前正在构建这些 dev-dependencies。例如

    [dependencies] serde = {version = "1.0", default-features = false} [dev-dependencies] serde = {version = "1.0", features = ["std"]}

    在此示例中,库通常将链接到不带 std 功能特性的 serde。但是,当作为测试或示例构建时,它将包含 std 功能特性。例如,cargo testcargo build --all-targets 将统一这些功能特性。请注意,依赖项中的 dev-dependencies 始终被忽略,这仅与顶级包或工作区成员相关。

links 字段 用于确保只有一个本机库副本链接到二进制文件中。解析器将尝试查找图中每个 links 名称只有一个实例的图。如果它无法找到满足该约束的图,它将返回错误。

例如,如果一个包依赖于 libgit2-sys 版本 0.11,而另一个包依赖于 0.12,则会出错,因为 Cargo 无法统一这些版本,但它们都链接到 git2 本机库。由于此要求,如果您的库被广泛使用,则鼓励在进行具有 links 字段的 SemVer 不兼容版本发布时非常小心。

Yanked 版本

Yanked 版本 是那些被标记为不应使用的版本。当解析器构建图时,它将忽略所有 yanked 版本,除非它们已存在于 Cargo.lock 文件中,或者由 cargo update--precise 标志(仅限 nightly)显式请求。

依赖项更新

依赖项解析由所有需要了解依赖关系图的 Cargo 命令自动执行。例如,cargo build 将运行解析器以发现要构建的所有依赖项。首次运行后,结果将存储在 Cargo.lock 文件中。后续命令将运行解析器,如果可以,则将依赖项锁定到 Cargo.lock 中的版本。

如果 Cargo.toml 中的依赖项列表已修改,例如将依赖项的版本从 1.0 更改为 2.0,则解析器将为该依赖项选择一个与新要求匹配的新版本。如果该新依赖项引入了新要求,则这些新要求也可能触发其他更新。 Cargo.lock 文件将使用新结果进行更新。 --locked--frozen 标志可用于更改此行为,以防止在要求更改时自动更新,而是返回错误。

cargo update 可用于在新版本发布时更新 Cargo.lock 中的条目。在没有任何选项的情况下,它将尝试更新锁定文件中的所有包。 -p 标志可用于针对特定包进行更新,其他标志(如 --recursive--precise)可用于控制如何选择版本。

覆盖

Cargo 具有多种机制来覆盖图中的依赖项。 覆盖依赖项 章节详细介绍了如何使用覆盖。覆盖显示为注册表的叠加层,将修补版本替换为新条目。否则,解析将像往常一样执行。

依赖项种类

包中有三种依赖项:正常、builddev。在大多数情况下,从解析器的角度来看,所有这些的处理方式都相同。一个不同之处在于,非工作区成员的 dev-dependencies 始终被忽略,并且不影响解析。

具有 [target] 表的 平台特定依赖项 的解析方式就好像所有平台都已启用一样。换句话说,解析器忽略平台或 cfg 表达式。

dev-dependency 循环

通常,解析器不允许图中存在循环,但它允许 dev-dependencies 存在循环。例如,项目 “foo” 对 “bar” 有 dev-dependency,而 “bar” 对 “foo” 有正常依赖项(通常作为 “path” 依赖项)。这是允许的,因为从构建产物的角度来看,实际上没有循环。在此示例中,构建了 “foo” 库(不需要 “bar”,因为 “bar” 仅用于测试),然后可以构建依赖于 “foo” 的 “bar”,然后可以构建链接到 “bar” 的 “foo” 测试。

请注意,这可能会导致令人困惑的错误。在构建库单元测试的情况下,实际上有两个库副本链接到最终的测试二进制文件中:一个是与 “bar” 链接的副本,另一个是包含单元测试的构建副本。与 版本不兼容性危害 章节中强调的问题类似,两者之间的类型不兼容。在这种情况下,从 “bar” 公开 “foo” 的类型时要小心,因为 “foo” 单元测试不会将它们视为与本地类型相同。

如果可能,请尝试将您的包拆分为多个包并重组它,使其保持严格的非循环性。

解析器版本

可以通过 Cargo.toml 中的解析器版本指定不同的解析器行为,如下所示

[package] name = "my-package" version = "1.0.0" resolver = "2"

解析器是一个全局选项,它会影响整个工作区。依赖项中的 resolver 版本将被忽略,仅使用顶级包中的值。如果使用 虚拟工作区,则应在 [workspace] 表中指定版本,例如

[workspace] members = ["member1", "member2"] resolver = "2"

MSRV: 需要 1.51+

建议

以下是一些关于在您的包中设置版本以及指定依赖项要求的建议。这些是应适用于常见情况的通用指南,但当然某些情况可能需要指定不寻常的要求。

  • 在决定如何更新版本号以及是否需要进行 SemVer 不兼容的版本更改时,请遵循 SemVer 指南

  • 在大多数情况下,对依赖项使用 Caret 要求,例如 "1.2.3"。这确保了解析器在选择版本时可以最大限度地灵活,同时保持构建兼容性。

    • 指定您当前使用的版本的所有三个组件。这有助于设置将使用的最低版本,并确保其他用户不会最终使用可能缺少您的包所需内容的旧版本依赖项。
    • 避免 * 要求,因为它们在 crates.io 上是不允许的,并且它们可能会在正常的 cargo update 期间拉入 SemVer 破坏性更改。
    • 避免版本要求过于宽泛。例如,>=2.0.0 可以拉入任何 SemVer 不兼容版本,例如版本 5.0.0,这可能会导致将来构建中断。
    • 如果可能,避免版本要求过于狭窄。例如,如果您指定一个 Tilde 要求,如 bar="~1.3",而另一个包指定了 bar="1.4" 的要求,则这将无法解析,即使次要版本应该是兼容的。
  • 尝试使依赖项版本与您的库所需的实际最低版本保持同步。例如,如果您有 bar="1.0.12" 的要求,然后在将来的版本中,您开始使用 “bar” 的 1.1.0 版本中添加的新功能,请将您的依赖项要求更新为 bar="1.1.0"

    如果您未能做到这一点,则可能不会立即显而易见,因为当您运行 blanket cargo update 时,Cargo 可能会机会主义地选择最新版本。但是,如果另一个用户依赖于您的库,并运行 cargo update your-library,则如果 “bar” 锁定在其 Cargo.lock 中,它不会自动更新 “bar”。只有在依赖项声明也更新的情况下,它才会在该情况下更新 “bar”。未能这样做可能会导致使用 cargo update your-library 的用户出现令人困惑的构建错误。

  • 如果两个包紧密耦合,则 = 依赖项要求可能有助于确保它们保持同步。例如,具有配套 proc-macro 库的库有时会在两个库之间做出假设,如果两者不同步(并且永远不会期望独立使用这两个库),这些假设将无法很好地工作。父库可以在 proc-macro 上使用 = 要求,并重新导出宏以方便访问。

  • 0.0.x 版本可用于永久不稳定的包。

一般来说,您对依赖项要求越严格,解析器失败的可能性就越大。相反,如果您使用的要求过于宽松,则可能会发布新版本,从而破坏构建。

故障排除

以下说明您可能遇到的一些问题以及一些可能的解决方案。

为什么包含依赖项?

假设您在 cargo check 输出中看到依赖项 rand,但不认为需要它,并且想了解为什么会引入它。

您可以运行

$ cargo tree --workspace --target all --all-features --invert rand rand v0.8.5 └── ... rand v0.8.5 └── ...

为什么在此依赖项上启用了该功能特性?

您可能会发现是激活的功能特性导致 rand 出现。 要找出哪个包激活了功能特性,您可以添加 --edges features

$ cargo tree --workspace --target all --all-features --edges features --invert rand rand v0.8.5 └── ... rand v0.8.5 └── ...

意外的依赖项重复

当您运行时,您会看到 rand 的多个实例

$ cargo tree --workspace --target all --all-features --duplicates rand v0.7.3 └── ... rand v0.8.5 └── ...

解析器算法已收敛于一个解决方案,该解决方案包含一个依赖项的两个副本,而一个副本就足够了。例如

# Package A [dependencies] rand = "0.7" # Package B [dependencies] rand = ">=0.6" # note: open requirements such as this are discouraged

在此示例中,Cargo 可能会构建 rand crate 的两个副本,即使版本 0.7.3 的单个副本也足以满足所有要求。这是因为解析器的算法倾向于为包 B 构建最新可用版本的 rand,在撰写本文时为 0.8.5,这与包 A 的规范不兼容。解析器的算法目前不尝试在这种情况下“去重”。

不鼓励在 Cargo 中使用像 >=0.6 这样的开放式版本要求。但是,如果您遇到这种情况,可以使用带有 --precise 标志的 cargo update 命令手动删除此类重复项。

为什么没有选择较新版本?

假设您注意到在您运行时未选择依赖项的最新版本

$ cargo update

您可以启用一些额外的日志记录来查看发生这种情况的原因

$ env CARGO_LOG=cargo::core::resolver=trace cargo update

注意: Cargo 日志目标和级别可能会随时间变化。

SemVer 破坏性补丁版本破坏了构建

有时,项目可能会不经意地发布带有 SemVer 破坏性更改的点版本。当用户使用 cargo update 更新时,他们将获取此新版本,然后他们的构建可能会中断。在这种情况下,建议项目应该 yank 该版本,并删除 SemVer 破坏性更改,或将其作为新的 SemVer 主要版本增加发布。

如果更改发生在第三方项目中,如果可能,请尝试(礼貌地!)与项目合作以解决问题。

在等待版本被 yanked 期间,一些解决方法取决于具体情况

  • 如果您的项目是最终产品(例如二进制可执行文件),只需避免在 Cargo.lock 中更新有问题的包。这可以使用 cargo update 中的 --precise 标志来完成。
  • 如果您在 crates.io 上发布二进制文件,则可以暂时添加 = 要求,以强制依赖项使用特定的良好版本。
    • 二进制项目也可以建议用户将 --locked 标志与 cargo install 一起使用,以使用包含已知良好版本的原始 Cargo.lock
  • 库也可以考虑发布具有更严格要求的临时新版本,以避免有问题的依赖项。您可能需要考虑使用范围要求(而不是 =)来避免可能与其他使用相同依赖项的包冲突的过于严格的要求。一旦问题得到解决,您可以发布另一个点版本,将依赖项放宽回 Caret 要求。
  • 如果看起来第三方项目无法或不愿意 yank 该版本,那么一种选择是更新您的代码以与更改兼容,并更新依赖项要求以将最低版本设置为新版本。您还需要考虑这是否是您自己的库的 SemVer 破坏性更改,例如,如果它公开了来自依赖项的类型。