依赖解析

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,如果它们的 SemVer 根据 Caret 版本要求 兼容,则会统一依赖项版本。如果两个兼容版本由于冲突的版本要求而无法统一,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 技巧”是一种解决方法,用于解决发布重大更改同时保持与旧版本兼容的问题。链接的页面详细介绍了问题是什么以及如何解决它。简而言之,当库想要发布 SemVer 重大版本时,请发布新版本,并发布以前版本的点发布,该版本重新导出新版本的类型。

这些不兼容通常会表现为编译时错误,但有时它们只会在运行时表现为错误。例如,假设有一个名为 foo 的常用库,它最终在解析图中以 1.0.02.0.0 两个版本出现。如果 downcast_ref 用在由使用 1.0.0 版本的库创建的对象上,并且调用 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 的 Rust 1.62 版本不兼容,尽管它具有更高的 版本,并且它具有 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

尽管每个包都具有满足其自身 Rust 版本的 clap 版本要求,但由于 版本统一,解析器将需要选择一个在两种情况下都适用的版本,该版本将类似于 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 功能。

  • 当相同的依赖项用作正常依赖项时,构建依赖项或 proc 宏上启用的功能将不会被统一。例如

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

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

  • 开发依赖项上启用的特性,当这些相同的依赖项被用作普通依赖项时,将不会被统一,除非这些开发依赖项当前正在构建。例如:

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

    在这个例子中,库通常会链接到没有 std 特性的 serde。但是,当作为测试或示例构建时,它将包含 std 特性。例如,cargo testcargo build --all-targets 将统一这些特性。请注意,依赖项中的开发依赖项始终被忽略,这仅与顶层包或工作区成员相关。

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

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

已撤回的版本

已撤回的发布版本是那些被标记为不应使用的版本。当解析器构建图时,它将忽略所有已撤回的版本,除非它们已经存在于 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 有几种机制可以覆盖图中的依赖项。覆盖依赖项章节详细介绍了如何使用覆盖。覆盖项显示为注册表的覆盖层,将修补后的版本替换为新条目。否则,像平常一样执行解析。

依赖项类型

包中有三种依赖项:普通依赖项、构建依赖项开发依赖项。从解析器的角度来看,在大多数情况下,它们的处理方式相同。一个区别是,对于非工作区成员,开发依赖项始终被忽略,并且不影响解析。

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

开发依赖项循环

通常,解析器不允许图中存在循环,但允许开发依赖项存在循环。例如,项目“foo”对“bar”有一个开发依赖项,而“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 指南

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

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

    如果您未能做到这一点,则可能不会立即显现出来,因为当您运行一揽子 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 更新时,他们将获取此新版本,然后他们的构建可能会中断。在这种情况下,建议项目撤回该版本,并删除破坏 SemVer 的更改,或将其作为新的 SemVer 主要版本增加发布。

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

在等待撤回发布版本时,一些解决方法取决于具体情况:

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