Workspace 与项目管理

本页目录

Workspace 让多个相关 package 共享依赖解析记录和构建管理入口。它适合核心库、命令行程序和服务端共同演进的工程,但不会自动消除重复依赖版本,也不强制每个成员一起发布。

设计拆分时优先考虑接口和职责:一个 crate 的变化是否应影响另一个、能否独立测试、是否需要对外发布。过多小 crate 也会增加维护与编译边界成本。

根 Cargo.toml 与 Cargo.lock成员列表、继承配置、统一解析记录calc-core:库 package维护可复用业务接口demo-cli:二进制 package通过 path 依赖 calc-coreWorkspace 共享管理入口,成员仍是独立 package共享 target 目录不保证每个依赖只编译一次,也不保证依赖图只有一个版本。

一个完整的双成员工程

根 Cargo.toml:

[workspace]
members = ["crates/calc-core", "crates/demo-cli"]
default-members = ["crates/demo-cli"]
resolver = "3"

[workspace.package]
version = "0.1.0"
edition = "2024"

[workspace.dependencies]
calc-core = { path = "crates/calc-core", version = "0.1.0" }

根只有 [workspace],是虚拟 manifest。resolver=3 与 Rust 2024 同期引入,需 Rust/Cargo 1.85 或更高;虚拟 workspace 没有根 package edition 可用来推断,应显式选择。resolver=3 对不兼容 rust-version 的依赖采用相应回退策略,但不替代最低工具链实测。依赖解析器

crates/calc-core/Cargo.toml:

[package]
name = "calc-core"
version.workspace = true
edition.workspace = true

crates/calc-core/src/lib.rs:

pub fn doubled(value: u32) -> Option<u32> { value.checked_mul(2) }

crates/demo-cli/Cargo.toml:

[package]
name = "demo-cli"
version.workspace = true
edition.workspace = true

[dependencies]
calc-core.workspace = true

crates/demo-cli/src/main.rs:

fn main() {
    assert_eq!(calc_core::doubled(21), Some(42));
    assert_eq!(calc_core::doubled(u32::MAX), None);
    println!("42");
}
cargo run -p demo-cli
cargo check --workspace
cargo test --workspace
cargo metadata --no-deps --format-version 1

这里 checked_mul 明确处理溢出,避免把 release 与 debug 的默认算术检查差异带入业务接口。

用真正的库测试观察成员选择

在 calc-core/src/lib.rs 后追加下面的测试。边界明确使用 u32::MAX / 2,验证最大可加倍值与紧邻的溢出值,而不依赖默认溢出检查开关。

#[cfg(test)]
mod tests {
    use super::doubled;

    #[test]
    fn boundary() {
        assert_eq!(doubled(0), Some(0));
        assert_eq!(doubled(u32::MAX / 2), Some(u32::MAX - 1));
        assert_eq!(doubled(u32::MAX / 2 + 1), None);
    }
}

在 workspace 根运行 cargo test 时,default-members 只选择 demo-cli。calc-core 会作为依赖编译,但它的库单元测试并不会因此运行。改用 cargo test -p calc-core 或 cargo test --workspace,才会看到 boundary 测试执行。这解释了为什么“应用构建依赖了核心库”和“核心库的测试已覆盖”不能互相替代。

依赖编译了,库测试也运行了吗?

同一条依赖箭头在三个命令中都没有改变,变的是主动选择哪些包的测试目标。默认命令通过 CLI 用到了 calc-core,却不意味着 calc-core 的 #[test] 已执行。将命令切换为 -p calc-core 或 --workspace,才能观察到这项覆盖的变化。

正在呈现知识画面
依赖编译了,库测试也运行了吗?

default-members=[demo-cli] 时默认测试选择 CLI,calc-core 作为依赖编译不会自动运行它的库单测。

继承是显式选择,不是根配置自动覆盖一切

成员必须通过 workspace=true 选择相应继承项。workspace.dependencies 的 path 相对于 workspace 根解析;直接写在成员 dependencies 的 path 则相对于成员 manifest。版本要求与 path 同时存在时,本地包也必须满足要求,发布语义还会涉及注册表版本。

成员可在继承依赖时增加 feature,根启用的 feature 不会被成员任意减掉。共同声明减少重复配置,却不能阻止其他传递依赖引入另一个不兼容版本。Workspaces

root 的 profiles 与 patch 等配置影响工作区构建;把 profile 放进成员文件后期待独立生效通常会得到忽略提示。lints、package 元信息等也应按对应继承语法明确选择。

lint 继承同样需要成员接入

根 manifest 增加:

[workspace.lints.rust]
unsafe_code = "forbid"

需要遵守该约束的每个成员都增加:

[lints]
workspace = true

这适合让纯业务 crate 共同禁止 unsafe。它不是对依赖源码施加全局政策:外部依赖仍有自己的编译上下文,原生库也不由该 lint 证明安全。若专门的 FFI 成员确实承担 unsafe 边界,应给它明确的检查方式与配置,而不是误以为根表会无条件覆盖所有成员。工作区继承配置

共享锁文件不等于所有依赖只有一份

Cargo.lock 可以同时记录 foo 1.x 与 foo 2.x。来自不同 package 版本的类型具有不同身份,即使同名结构体字段相似,也未必能互传。cargo tree -d 能帮助发现这些情况,cargo tree -i 可继续找引用来源。

target 目录也只是共享产物位置。目标架构、profile、feature 集合、宿主构建依赖及编译选项不同,都可能产生不同编译单元。不能从“共享缓存”推导所有依赖只编译一次。

命令作用范围要写清楚

在虚拟 workspace 根不指定 -p 时,default-members 控制默认成员选择;--workspace 显式选择所有成员。对指定包可用 -p,包内有多个二进制时再用 --bin。

cargo test --workspace 的测试 target、文档测试与普通构建不是完全相同的配置。发布检查也应对准备发布的具体 package 执行,工作区构建成功不代表每个包独立打包后都包含正确文件和可解析依赖。

根目录 cargo testdefault-members:demo-clicargo test --workspace成员:demo-cli + calc-corecalc-core 作为库依赖构建不运行其中的 boundary 单元测试calc-core 的测试 target 也构建运行 boundary 单元测试先选 package,再选 target;依赖编译不等于依赖测试图示使用本页双成员配置;-p、--lib、--bin、--test 分别控制不同选择层次。
命令(在 workspace 根)主动选择什么不能由此推断什么
cargo check默认成员 demo-cli其他成员所有 target 都已检查
cargo test -p calc-core --lib核心库单元测试 targetCLI、集成测试和文档测试都已运行
cargo test --workspace各成员的默认测试集合每种 feature 和每个目标平台都通过
cargo check --workspace --all-targets成员的库、二进制、测试、示例和 benchmark 等 target文档测试已执行或二进制已完成链接
cargo test --workspace --exclude demo-cli选择成员时排除 CLI被其他包依赖的代码绝不会构建

这里的 Cargo target(lib/bin/test 等构建目标)与 --target 指定的平台三元组是两个维度。排查“为什么还编译这个包”时,先看它是否作为依赖出现;排查“为什么漏了这个测试”时,再看 package 和测试 target 是否被选中。测试目标选择

patch 是向某个来源补入候选包

假设注册表中的 codec 包需要一个本地修复,根 manifest 可写:

[patch.crates-io]
codec = { path = "vendor/codec" }

这要求 vendor/codec 真正包含名为 codec、版本能满足依赖约束的 package。patch 不无条件覆盖所有同名版本;锁文件与候选版本选择可能使它没有被采用,应检查 Cargo 提示、cargo tree 和锁文件,必要时针对该包更新解析。

它也不是“所有依赖要求必须完全相同”才可使用:多个相容范围可解析到同一版本,不相容版本可能继续共存。替换另一个主版本需要分别提供合适候选,而不是只改 manifest 的版本数字却不维护 API。

库自己声明的 patch 不会自动成为下游应用的根配置。若只是本地验证修复,记录使用来源及移除条件;若要发布,确保下游能从正式依赖来源取得需要的版本。覆盖依赖

用边界减少构建耦合

核心库尽量暴露稳定类型和明确错误,CLI 负责参数与输出;不要为了共享一个常量就把网络运行时和构建生成器带进所有成员。公共依赖重导出、feature 并集和 build.rs 输出都会扩大耦合,应在修改接口时一起评估。

生成代码与原生构建的职责见 Cargo.toml,条件分支的验证见 cfg。

独立打包会暴露工作区内看不见的依赖

先以无外部依赖的 calc-core 练习本地打包:

cargo package -p calc-core --list
cargo package -p calc-core

第一条检查将被纳入的文件,第二条生成 .crate 并默认解包构建验证。检查列表时应关注运行或构建必需的模板、头文件、配置样例,而不是只看 src 是否存在。build.rs 若依赖工作区外相邻目录,仓库内构建可能成功,独立打包后却没有那些输入。

打包时 Cargo 会规范化 manifest,处理继承字段,并移除 workspace、patch 等根配置。对 demo-cli 而言,calc-core 的 path 只解决本地开发位置,version 才描述注册表依赖;正式分发必须确保需要的版本在对应来源可取得。两个包放在同一个仓库,并不会让发布后的 CLI 自动访问那个相对路径。cargo package 的处理步骤

如果这些成员仅供内部一起构建,可在相应 package 中设置 publish=false 防止误发布;这不影响正常的本地 path 依赖。若要分别发布,围绕核心库的公共 API 决定兼容版本,并更新使用方要求。统一继承 version 只是统一填值,不会自动分析语义兼容性或安排发布先后。

新成员接入时,沿实际使用链检查

新增服务包后,先确认 cargo metadata 中的 workspace_members 与 workspace_default_members 是否符合预期;再检查它对核心库的正常依赖、仅测试依赖和构建依赖是否放在正确表中。最后从用户实际入口运行一个端到端用例,例如 CLI 参数转为业务数值、调用 doubled、将 None 转为可理解错误。

目录拆分只有对应接口边界才产生价值。核心库返回 Option 是这个小例子的完整契约;如果以后需要区分非法输入、溢出与资源错误,就应升级为明确错误类型,并让各入口负责展示。避免让核心层直接打印或退出进程,否则 Web 服务和测试即使复用了函数,也无法自由决定错误响应方式。