03-依赖解析
14 分钟阅读
译文 · 基于 The Cargo Book
依赖解析
原文链接: https://doc.rust-lang.org/cargo/reference/resolver.html
Cargo 的主要任务之一,是根据各包(package)中指定的版本需求,确定要使用的依赖版本。这一过程称为「依赖解析(dependency resolution)」,由「解析器(resolver)」执行。解析结果保存在 Cargo.lock 文件中,将依赖「锁定」到特定版本,并随时间保持不变。cargo tree 命令可用于可视化解析器的结果。
约束与启发式
在许多情况下,并不存在唯一的「最佳」依赖解析结果。解析器在多种约束与启发式下工作,以找到普遍适用的解析方案。要理解这些要素如何相互作用,对依赖解析的工作方式有一个粗略了解会很有帮助。
以下伪代码近似描述了 Cargo 解析器的行为:
| |
关键步骤:
- 遍历依赖(
pick_next_dep): 依赖的遍历顺序会影响 同一依赖的相关版本需求如何被解析,参见版本统一, 以及解析器回溯的程度,从而影响解析器性能。 - 统一版本(
try_unify_version、needs_version_unification): Cargo 在可能的情况下复用版本,以减少构建时间,并允许来自共同依赖的类型在 API 之间传递。 若多个版本本可统一,但因依赖规格(dependency specifications)冲突而无法统一,Cargo 将回溯,若找不到解则报错,而不是选择多个版本。 依赖规格(dependency specification)或 Cargo 可能判定某版本不可取, 宁愿回溯或报错也不使用它。 - 偏好版本(
pick_next_version): Cargo 可能决定应优先选择特定版本, 回溯时再尝试下一个版本。
版本号
一般而言,Cargo 偏好当前可用的最高版本。
例如,若解析图中某包包含:
| |
若在生成 Cargo.lock 文件时,bitflags 的最高版本为 1.2.1,则该包将使用 1.2.1。
可能例外的说明,见 Rust 版本。
版本需求
包通过版本需求指定其支持的版本,并拒绝所有其他版本。
例如,若解析图中某包包含:
| |
若在生成 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 的依赖将被统一,因为所选任意版本彼此兼容。
| |
以下包将报错,因为版本需求冲突,会选中两个不同的兼容版本。
| |
以下两个包对 rand 的依赖不会被统一,因为各自仅有不兼容版本可用。
相反,将解析并构建两个不同版本(例如 0.6.5 与 0.7.3)。
这可能引发潜在问题,详见版本不兼容风险一节。
| |
一般而言,以下两个包不会统一其依赖,因为有满足版本需求的不兼容版本可用: 相反,将解析并构建两个不同版本(例如 0.6.5 与 0.7.3)。 其他约束或启发式的应用可能导致它们被统一, 并选中一个版本(例如 0.6.5)。
| |
版本不兼容风险
当解析图中出现同一 crate 的多个版本时,若使用这些 crate 的上层 crate 暴露了其中的类型,就可能出现问题。
这是因为 Rust 编译器会将这些类型与项视为不同,即使名称相同。库在发布 SemVer 不兼容版本时(例如在使用 1.0.0 之后发布 2.0.0)应格外谨慎,尤其是被广泛使用的库。
「semver trick」是在发布破坏性变更的同时保持与旧版本兼容的变通方法。链接页面详细说明了问题所在及应对方式。简而言之,当库希望发布 SemVer 破坏性版本时,应发布新版本,同时发布旧版本的一个补丁版本,从新版本重新导出类型。
这些不兼容通常表现为编译期错误,但有时仅表现为运行时行为异常。例如,假设解析图中同时出现名为 foo 的常用库的版本 1.0.0 与 2.0.0。若对使用 1.0.0 版本的库创建的对象使用 downcast_ref,而调用 downcast_ref 的代码向下转型为 2.0.0 版本的类型,则向下转型会在运行时失败。
若你使用了某库的多个版本,务必确保正确使用它们,尤其当不同版本的类型可能被一起使用时。cargo tree -d 命令可用于识别重复版本及其来源。同样,若你要发布某流行库的 SemVer 不兼容版本,也应考虑对生态系统的影响。
锁文件
在使用时,Cargo 对 Cargo.lock 文件中包含的版本给予最高优先级。
这旨在在可重现构建与随清单变更而调整之间取得平衡。
例如,若解析图中某包包含:
| |
若在生成 Cargo.lock 文件时,bitflags 的最高版本为 1.2.1,则该包将使用 1.2.1,并记录在 Cargo.lock 文件中。
当 Cargo 再次运行时,bitflags 1.3.5 已发布。
在解析依赖时,
仍会使用 1.2.1,因为它存在于 Cargo.lock 文件中。
随后该包被编辑为:
| |
bitflags 1.2.1 不符合此版本需求,因此 Cargo.lock 中的该条目被忽略,现在将使用版本 1.3.5,并记录在 Cargo.lock 文件中。
Rust 版本
为支持以最低支持的 Rust 版本开发软件,
解析器可以考虑依赖版本与你 Rust 版本的兼容性。
这由配置字段 resolver.incompatible-rust-versions 控制。
在 fallback 设置下,解析器将偏好 Rust 版本小于或等于你当前 Rust 版本的包。
例如,你使用 Rust 1.85 开发以下包:
| |
解析器会选择 4.0.32,因为其 Rust 版本为 1.60.0。
- 不会选择 4.0.0,尽管其 Rust 版本也是 1.60.0,但它是较低版本号。
- 不会选择 4.5.20,尽管其版本号更高,且其 Rust 版本 1.74.0 与你的 1.85 工具链兼容,但它与
my-cli的 Rust 版本 1.62 不兼容。
若版本需求不包含与 Rust 版本兼容的依赖版本,
解析器不会报错,而是仍会选中一个版本,即使可能并非最优。
例如,你将 clap 的依赖改为:
| |
没有 clap 版本既满足该版本需求,又与 Rust 版本 1.62 兼容。
解析器随后会选中不兼容版本,例如 Rust 版本为 1.74 的 4.5.20。
当解析器为包选择依赖版本时, 它并不知道最终哪些工作空间成员会通过传递依赖用到该版本, 因此无法仅考虑与该依赖相关的 Rust 版本。 当工作空间成员具有不同 Rust 版本时,解析器会使用启发式方法寻找「足够好」的解。 这即使对工作空间中没有 Rust 版本的包也适用。
当工作空间成员具有不同 Rust 版本时, 解析器可能选中比必要更低的依赖版本。 例如,你有以下工作空间成员:
| |
尽管包 b 没有 Rust 版本,本可使用更高版本如 4.5.20,
但由于包 a 的 Rust 版本为 1.62,将选中 4.0.32。
或者解析器可能选中过高的版本。 例如,你有以下工作空间成员:
| |
尽管每个包对 clap 的版本需求都能满足各自的 Rust 版本,
但由于版本统一,
解析器需要选中一个对两者都适用的版本,例如 4.5.20。
特性
为生成 Cargo.lock,解析器在构建依赖图时,假定所有工作空间(workspace)成员的特性(feature)均已启用。这确保在通过 --features 命令行标志添加或移除特性时,任何可选依赖都可用,并与图的其余部分正确解析。
解析器会第二次运行,以根据命令行所选特性确定编译 crate 时实际使用的特性。
依赖以在其上启用的全部特性的并集进行解析。例如,若一个包依赖 im 包并启用了 [serde 依赖],另一个包依赖它并启用了 [rayon 依赖],则 im 将同时启用这两个特性构建,serde 与 rayon crate 将包含在解析图中。若没有包以这些特性依赖 im,则这些可选依赖会被忽略,不会影响解析。
在工作空间中构建多个包时(例如使用 --workspace 或多个 -p 标志),这些包依赖的特性会被统一。若你希望为不同工作空间成员避免这种统一,需要通过分别调用 cargo 来构建它们。
解析器会跳过缺少所需特性的包版本。例如,若某包依赖 regex 的 ^1 版本并启用了 [perf 特性],则它可选的最旧版本为 1.3.0,因为更早版本不包含 perf 特性。同样,若某特性在新版本中被移除,则需要该特性的包会停留在仍包含该特性的旧版本上。不建议在 SemVer 兼容版本发布中移除特性。请注意,可选依赖也会定义隐式特性,因此移除可选依赖或将其改为非可选可能引发问题,见移除可选依赖。
特性解析器版本 2
当在 Cargo.toml 中指定 resolver = "2" 时(见下文解析器版本),会使用不同的特性解析器,其统一特性时采用不同算法。版本 "1" 解析器无论包在何处被指定都会统一其特性。
版本 "2" 解析器在以下情况下会避免统一特性:
若当前未构建对应目标,则不会启用特定于目标的依赖的特性。例如:
1 2 3 4 5 6 7[dependencies.common] version = "1.0" features = ["f1"] [target.'cfg(windows)'.dependencies.common] version = "1.0" features = ["f2"]在非 Windows 平台上构建此示例时,
f2特性将不会被启用。在构建依赖或 proc-macro 上启用的特性,当相同依赖作为普通依赖使用时不会统一。例如:
1 2 3 4 5[dependencies] log = "0.4" [build-dependencies] log = {version = "0.4", features=['std']}构建构建脚本时,
logcrate 会启用std特性构建。构建你包的库时,不会启用该特性。在开发依赖上启用的特性,当相同依赖作为普通依赖使用时不会统一,除非这些开发依赖当前正在被构建。例如:
1 2 3 4 5[dependencies] serde = {version = "1.0", default-features = false} [dev-dependencies] serde = {version = "1.0", features = ["std"]}在此示例中,库通常链接不带
std特性的serde。但作为测试或示例构建时,会包含std特性。例如,cargo test或cargo build --all-targets会统一这些特性。请注意,依赖中的开发依赖始终被忽略,这仅与顶层包或工作空间成员相关。
links
links 字段用于确保二进制中只链接一份原生库副本。解析器会尝试找到每个 links 名称仅出现一次的图。若无法找到满足该约束的图,将返回错误。
例如,若一个包依赖 libgit2-sys 版本 0.11,另一个依赖 0.12,则为错误,因为 Cargo 无法统一它们,但它们都链接到 git2 原生库。由于此要求,若你的库被广泛使用,在使用 links 字段发布 SemVer 不兼容版本时应非常谨慎。
被 yank 的版本
被 yank 的发布 指标记为不应使用的版本。解析器构建图时会忽略所有被 yank 的发布,除非它们已存在于 Cargo.lock 文件中,或由 cargo update 的 --precise 标志显式请求。
依赖更新
所有需要了解依赖图的 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 中的解析器版本指定不同的解析器行为,例如:
| |
"1"(默认)"2"(edition = "2021"默认):引入特性统一方面的变更。详见特性章节。"3"(edition = "2024"默认,需要 Rust 1.84+):将resolver.incompatible-rust-versions的默认值从allow改为fallback
解析器是全局选项,影响整个工作空间。依赖中的 resolver 版本会被忽略,仅使用顶层包中的值。若使用虚拟工作空间,应在 [workspace] 表中指定版本,例如:
| |
MSRV: 需要 1.51+
建议
以下是在包内设置版本以及指定依赖需求的一些建议。这些是适用于常见情况的通用指南,当然某些情况可能需要指定不寻常的需求。
在决定如何更新版本号以及是否需要 SemVer 不兼容版本变更时,遵循 SemVer 指南。
在大多数情况下,对依赖使用插入符需求,例如
"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"。若未这样做,可能不会立即显现问题,因为不加区分地运行
cargo update时 Cargo 可能伺机选择最新版本。然而,若其他用户依赖你的库并运行cargo update your-library,在其Cargo.lock已锁定的情况下,不会自动更新 “bar”。仅当依赖声明也被更新时,该情况下才会更新 “bar”。未这样做可能导致使用cargo update your-library的用户遇到令人困惑的构建错误。若两个包紧密耦合,则
=依赖需求可能有助于保持同步。例如,带有配套 proc-macro 库的库有时会在两个库之间做出假设,若两者不同步则无法良好工作(且从不期望独立使用这两个库)。父库可对 proc-macro 使用=需求,并重新导出宏以便访问。0.0.x版本可用于永久不稳定的包。
一般而言,依赖需求越严格,解析器失败的可能性越大。反之,若需求过于宽松,可能发布破坏构建的新版本。
故障排查
以下说明你可能遇到的一些问题及可能的解决方案。
为什么包含某依赖?
假设你在 cargo check 输出中看到依赖 rand,但不认为需要它,想了解为何被拉入。
你可以运行
| |
为什么该依赖上启用了那个特性?
你可能发现是某已激活特性导致 rand 出现。要找出哪个包激活了该特性,可添加 --edges features
| |
意外的依赖重复
运行以下命令时看到 rand 的多个实例:
| |
解析器算法收敛到一个包含两份依赖的解,而一份即可满足。例如:
| |
在此示例中,Cargo 可能构建两份 rand crate,尽管单一版本 0.7.3 即可满足所有需求。这是因为解析器算法倾向于为包 B 构建 rand 的最新可用版本,在撰写本文时为 0.8.5,这与包 A 的规格不兼容。解析器算法目前不会在此情况下尝试「去重」。
Cargo 不鼓励使用 >=0.6 这类开放式版本需求。
但若遇到此情况,可使用带 --precise 标志的 cargo update 命令手动消除此类重复。
为什么没有选中较新版本?
假设你注意到运行以下命令时未选中依赖的最新版本:
| |
你可以启用额外日志查看原因:
| |
注意: Cargo 日志目标与级别可能随时间变化。
SemVer 破坏性补丁发布导致构建失败
有时项目可能无意中发布了带有 SemVer 破坏性变更的补丁版本。用户通过 cargo update 更新时会获得该新版本,随后构建可能失败。此情况下,建议项目应 yank 该发布,并要么移除 SemVer 破坏性变更,要么将其作为新的 SemVer 主版本发布。
若变更发生在第三方项目中,若可能请尝试(礼貌地!)与项目协作解决问题。
在等待发布被 yank 期间,一些变通方法取决于具体情况:
- 若你的项目是最终产物(例如二进制可执行文件),只需避免在
Cargo.lock中更新有问题的包。可通过cargo update的--precise标志实现。 - 若在 crates.io 上发布二进制,可临时添加
=需求以强制依赖使用特定良好版本。- 二进制项目也可建议用户使用
cargo install的--locked标志,以使用包含已知良好版本的原始Cargo.lock。
- 二进制项目也可建议用户使用
- 库也可考虑发布临时新版本,使用更严格的需求以避开有问题的依赖。你可能希望考虑使用范围需求(而非
=),以避免与其他使用同一依赖的包产生过于严格的需求冲突。问题解决后,可再发布一个补丁版本,将依赖放宽回插入符需求。 - 若第三方项目似乎无法或不愿 yank 该发布,一个选项是更新代码以兼容变更,并将依赖需求的最低版本设为新发布。你还需要考虑这是否是你自身库的 SemVer 破坏性变更,例如若它暴露了依赖中的类型。