Cargo 工作区配置实践:多 crate 项目的工程化管理

Cargo 工作区配置实践:多 crate 项目的工程化管理 Cargo 工作区配置实践多 crate 项目的工程化管理一、为什么需要 Cargo 工作区我的第一个 Rust 项目是单 crate 结构所有代码塞在一个src/目录下。当项目超过 5000 行后编译时间变得难以忍受——改一行代码要等 30 秒。原因是 Cargo 对单 crate 是全量编译即使只改了一个函数。Cargo 工作区Workspace的解决方案是把项目拆成多个 crate每个 crate 独立编译。修改一个 crate 的代码只需要重新编译这个 crate 和依赖它的 crate。对于大型项目增量编译时间可以从 30 秒降到 3 秒。工作区的另一个好处是强制模块边界。单 crate 中所有模块可以互相访问pub(crate)拆成多 crate 后只有pub的 API 可以跨 crate 访问。这逼着你设计清晰的接口避免模块间的隐式耦合。二、Cargo 工作区的底层机制依赖解析与编译共享Cargo 工作区的核心是共享target/目录和Cargo.lock文件。所有 crate 的编译产物放在同一个target/下依赖库只编译一次多个 crate 共享。flowchart TB A[Workspace 根目录] -- B[Cargo.tomlbr/workspace 定义] A -- C[Cargo.lockbr/统一锁文件] A -- D[target/br/共享编译目录] B -- E[成员 crate 1br/api-server/] B -- F[成员 crate 2br/db-layer/] B -- G[成员 crate 3br/common/] E -- H[Cargo.tomlbr/name api-server] F -- I[Cargo.tomlbr/name db-layer] G -- J[Cargo.tomlbr/name common] E --|依赖| G F --|依赖| G E --|依赖| F subgraph 依赖共享 K[serde 1.0.200br/只编译一次] L[tokio 1.37br/只编译一次] end K -- D L -- D subgraph 编译优化 M[修改 api-serverbr/只重编译 api-server] N[修改 commonbr/重编译所有依赖它的 crate] O[修改 db-layerbr/重编译 db-layer api-server] end M N O -- D关键认知工作区中同一个依赖库只编译一次即使多个 crate 使用它。但版本不同时每个版本各编译一次。所以工作区内应该统一依赖版本避免同一个库编译多个版本。三、生产级代码实现工作区配置与最佳实践3.1 工作区根配置# 工作区根目录的 Cargo.toml [workspace] # 定义工作区成员 # 为什么用 members 而非默认的 # 自动发现自动发现会包含 # target/ 下的临时目录 # 显式声明更可控 members [ crates/api-server, crates/db-layer, crates/common, crates/cli, ] # 排除不需要编译的目录 exclude [ examples, benches, ] # 工作区级别的依赖管理 # 为什么用 workspace.dependencies # 统一管理所有 crate 的依赖版本 # 避免同一个库的不同版本被 # 多次编译成员 crate 通过 # workspace true 引用 [workspace.dependencies] serde { version 1.0, features [derive] } serde_json 1.0 tokio { version 1, features [full] } thiserror 1.0 anyhow 1.0 tracing 0.1 tracing-subscriber 0.3 # 内部 crate 依赖 # 为什么把内部依赖也放在 # workspace.dependencies # 统一管理版本避免路径 # 硬编码散落在各处 common { path crates/common } db-layer { path crates/db-layer }3.2 成员 crate 配置# crates/api-server/Cargo.toml [package] name api-server version 0.1.0 edition 2021 [dependencies] # 从工作区继承依赖 # 为什么用 workspace true # 不需要重复写版本号 # 版本统一由根 Cargo.toml 管理 # 修改版本时只改一处 serde { workspace true } serde_json { workspace true } tokio { workspace true } thiserror { workspace true } tracing { workspace true } # 内部 crate 依赖 common { workspace true } db-layer { workspace true } # 仅此 crate 使用的依赖 axum 0.7 tower-http { version 0.5, features [cors] } [dev-dependencies] # 测试专用依赖 tokio-test 0.4# crates/common/Cargo.toml [package] name common version 0.1.0 edition 2021 [dependencies] serde { workspace true } thiserror { workspace true } tracing { workspace true } # common 是基础 crate # 不应该依赖其他内部 crate # 为什么common 被所有其他 # crate 依赖如果 common # 依赖 db-layer就会形成 # 循环依赖基础 crate # 应该保持零内部依赖3.3 工作区管理脚本// crates/common/src/lib.rs // 公共类型和工具函数 pub mod error; pub mod config; // 重导出常用类型 // 为什么重导出让其他 crate // 可以通过 common::serde 使用 // 而不需要单独依赖 serde // 减少成员 crate 的依赖声明 pub use serde; pub use thiserror;#!/bin/bash # 工作区管理脚本 # 检查所有 crate 是否编译通过 # 为什么用 --workspace 而非 # 逐个编译--workspace 一次性 # 编译所有成员共享依赖编译结果 cargo check --workspace # 运行所有测试 cargo test --workspace # 检查依赖是否有重复版本 # 为什么关注重复版本同一个库 # 的不同版本会被分别编译 # 增加编译时间和二进制体积 cargo tree --duplicates # 格式化所有代码 cargo fmt --all # 检查代码规范 cargo clippy --workspace -- -D warnings # 只编译修改过的 crate需要 cargo-watch # 为什么用 cargo-watch监听文件变化 # 自动执行命令开发时不用手动 # 反复运行 cargo check cargo watch -x check -p api-server3.4 条件编译与特性管理# crates/db-layer/Cargo.toml [package] name db-layer version 0.1.0 edition 2021 [features] # 默认启用 MySQL 支持 default [mysql] # 数据库后端特性 # 为什么用 feature 而非 # 运行时配置编译期决定 # 可以去掉不需要的依赖 # 减小二进制体积 # 运行时配置需要 # 所有后端的依赖都编译进去 mysql [sqlx/mysql] postgres [sqlx/postgres] sqlite [sqlx/sqlite] # 测试专用特性 # 为什么用 test-util 而非 # #[cfg(test)]#[cfg(test)] # 只在当前 crate 的测试中 # 生效其他 crate 依赖 # 此 crate 时 test 代码 # 不会被编译test-util # 是显式的 feature # 可以跨 crate 使用 test-util [tokio/test-util] [dependencies] sqlx { version 0.7, features [ runtime-tokio, tls-rustls, ] } tokio { workspace true } common { workspace true }// crates/db-layer/src/lib.rs // 条件编译根据 feature 启用不同后端 #[cfg(feature mysql)] pub mod mysql; #[cfg(feature postgres)] pub mod postgres; #[cfg(feature sqlite)] pub mod sqlite; // 公共 trait所有后端共享 #[async_trait::async_trait] pub trait Database: Send Sync { async fn execute( self, query: str, ) - Resultu64, DbError; async fn query_one( self, query: str, ) - ResultRow, DbError; } // 根据特性提供默认实现 // 为什么用 cfg 而非运行时判断 // 编译期决定可以消除未使用 // 后端的代码和依赖 #[cfg(feature mysql)] pub fn create_pool( url: str ) - Boxdyn Database { Box::new(mysql::MySqlPool::new(url)) } #[cfg(all(not(feature mysql), feature postgres))] pub fn create_pool( url: str ) - Boxdyn Database { Box::new(postgres::PgPool::new(url)) }四、Cargo 工作区的边界不适合的场景小型项目如果项目不到 3000 行拆工作区反而增加管理成本。每个 crate 需要独立的 Cargo.toml、lib.rs 和测试目录文件数量翻倍。频繁跨 crate 重构工作区的编译加速基于 crate 边界的稳定性。如果你经常修改 common crate 的 API所有依赖它的 crate 都要重新编译反而比单 crate 更慢。发布到 crates.io工作区内的 crate 如果要单独发布需要各自维护版本号和 CHANGELOG。如果 crate 之间有版本依赖关系发布顺序也很重要——先发布被依赖的 crate再发布依赖它的 crate。CI 缓存工作区的缓存策略比单 crate 复杂。每个 crate 的缓存 key 不同缓存命中需要精确匹配依赖图。建议用Swatinem/rust-cacheGitHub Action它支持工作区缓存。五、总结Cargo 工作区的核心价值是编译加速和强制模块边界。配置要点用workspace.dependencies统一管理依赖版本用workspace true在成员中引用用 feature 做条件编译。拆分原则common crate 零内部依赖被依赖的 crate 尽量稳定频繁修改的代码放在独立 crate。不适合小于 3000 行的项目不适合频繁跨 crate 重构的阶段。落地建议是先从单 crate 开始当代码超过 3000 行且编译时间超过 20 秒时再拆分。