依赖项解析

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,则会将其统一。如果两个兼容版本由于冲突的版本要求而无法统一,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 暴露了这些 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,因为它要求的 Rust 版本是 1.60.0。

  • 未选择 4.0.0,因为它是一个较低的版本号,尽管它要求的 Rust 版本也是 1.60.0。
  • 未选择 4.5.20,因为它与 my-cli 要求的 Rust 版本 1.62 不兼容,尽管它的版本号高得多,并且它要求的 Rust 版本 1.74.0 与您的 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。

特性 (Features)

为了生成 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-macros 上启用的特性,在这些相同的依赖项被用作普通依赖项时不会被统一。例如

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

    构建构建脚本时,log crate 将在启用 std 特性的情况下构建。构建您的包的库时,它不会启用该特性。

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

    [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 不兼容版本时要非常小心。

已撤回版本 (Yanked versions)

已撤回版本是指那些被标记为不应使用的版本。当解析器构建图时,它将忽略所有已撤回的版本,除非它们已经存在于 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 等其他标志来控制如何选择版本。

覆盖 (Overrides)

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 不兼容变更,例如如果它暴露了来自该依赖项的类型。