Cargo 工作区重构实录:把单仓拆成多个 crate 的决策过程
Cargo 工作区重构实录把单仓拆成多个 crate 的决策过程一、单仓的舒适区和它悄悄累积的债问题 1编译太慢。我只改了format.rs里一行代码但cargo build要等将近 20 秒。每次等待都让我分心去刷手机。问题 2依赖混乱。所有 crate 都挤在Cargo.toml里reqwest和clap这些只被部分模块用到的依赖没法区分——它们都被编译进最终的二进制哪怕某个功能没启用。问题 3没法拆分可复用库。我的文件元数据提取逻辑写得不错想在另一个项目里复用但我没法直接引用——它嵌在主 crate 里抽出来就是一顿大手术。这三个痛点指向同一个方案Cargo Workspace工作区。把单体 crate 拆成多个子 crate让每个 crate 有独立的职责和编译边界。flowchart TD subgraph before[重构前单体 crate] A[meta-cli (唯一 crate)\n527行 main.rs\n所有依赖混在一起\n编译全量重编] end subgraph after[重构后Cargo Workspace] B[meta-core (库)\n元数据提取核心逻辑\n无 IO/网络依赖] C[meta-io (库)\n文件系统操作\n依赖 meta-core] D[meta-api (库)\nHTTP 通信层\n依赖 meta-core] E[meta-cli (二进制)\nCLI 入口 参数解析\n依赖 meta-core meta-io meta-api] end before --|拆分| after二、拆分决策什么样的逻辑应该独立成 crate不是项目大了就要拆。拆分前我问了自己三个问题flowchart LR Q1{这个模块有独立\n的编译边界吗} -- Q2{这个模块会被\n其他项目复用吗} Q2 -- Q3{拆分后编译时间\n能显著缩短吗} Q1 --|否| SKIP[暂不拆分] Q2 --|否| SKIP Q3 --|否| SKIP Q1 --|是| CHECK[考虑拆分]我的拆分决策表模块独立编译边?可复用性拆分后提速结论元数据提取核心逻辑是高大幅拆文件 I/O 封装是中中等拆HTTP API 通信是中中等拆CLI 参数 main否低小幅保留输出格式化弱依赖中中等拆三、具体实施步骤从 1 个 crate 到 5 个 crate步骤 1创建工作区根 Cargo.toml# 根目录 /Cargo.toml —— 工作区清单 [workspace] # 声明所有子 crate members [ crates/meta-core, # 核心库元数据提取 crates/meta-io, # I/O 封装文件/目录操作 crates/meta-api, # API 通信HTTP 请求封装 crates/meta-format, # 格式化输出转换 crates/meta-cli, # 二进制入口CLI ] # 工作区级别的依赖版本统一管理 [workspace.dependencies] serde { version 1, features [derive] } serde_json 1 thiserror 1 # ... 其他共享依赖统一在这里声明版本步骤 2提取核心库 meta-core核心库不能依赖项目中的任何其他 crate它应该是纯 Rust 逻辑# crates/meta-core/Cargo.toml [package] name meta-core version 0.1.0 edition 2021 description 文件元数据提取的核心数据结构与逻辑 [dependencies] serde.workspace true # 使用工作区统一版本 serde_json.workspace true thiserror.workspace true chrono { version 0.4, features [serde] }// crates/meta-core/src/lib.rs //! 文件元数据提取的核心库 //! 不包含任何 I/O 或网络操作纯数据处理 use serde::{Deserialize, Serialize}; /// 文件元数据结构体 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FileMetadata { /// 文件名不含路径 pub name: String, /// 文件大小字节 pub size: u64, /// 文件类型根据扩展名和内容推断 pub file_type: FileType, /// SHA-256 哈希值 pub hash: OptionString, /// 最后修改时间 pub modified: String, } /// 支持的文件类型枚举 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum FileType { Text, Image, Video, Audio, Archive, Binary, Unknown, } /// 根据文件扩展名推断文件类型 pub fn detect_file_type(extension: str) - FileType { match extension.to_lowercase().as_str() { txt | md | rs | py | js | ts | json | yaml | toml | csv { FileType::Text } png | jpg | jpeg | gif | webp | svg FileType::Image, mp4 | mkv | avi | mov FileType::Video, mp3 | wav | flac | ogg FileType::Audio, zip | tar | gz | rar | 7z FileType::Archive, exe | dll | so | dylib | bin FileType::Binary, _ FileType::Unknown, } }步骤 3提取 I/O 层依赖 meta-core# crates/meta-io/Cargo.toml [package] name meta-io version 0.1.0 edition 2021 description 文件系统操作封装 [dependencies] meta-core { path ../meta-core } # 引用同工作区的兄弟 crate thiserror.workspace true walkdir 2 # 递归目录遍历 sha2 0.10 # SHA-256 哈希计算步骤 4重建 CLI 入口依赖所有子 crate# crates/meta-cli/Cargo.toml [package] name meta-cli version 0.2.0 edition 2021 description 文件元数据提取工具——命令行入口 [[bin]] name meta-cli path src/main.rs [dependencies] meta-core { path ../meta-core } meta-io { path ../meta-io } meta-api { path ../meta-api } meta-format { path ../meta-format } clap { version 4, features [derive] } tokio { version 1, features [full] }// crates/meta-cli/src/main.rs use clap::Parser; use meta_core::FileMetadata; use meta_io::Scanner; /// 文件元数据提取工具 #[derive(Parser)] #[command(name meta-cli, version)] struct Cli { /// 目标文件或目录路径 path: String, /// 输出格式 #[arg(short, long, default_value table)] format: String, /// 递归扫描子目录 #[arg(short, long)] recursive: bool, } fn main() - Result(), Boxdyn std::error::Error { let cli Cli::parse(); let scanner Scanner::new(cli.recursive); let files scanner.scan(cli.path)?; for file in files { let metadata meta_io::extract_metadata(file)?; meta_format::print(metadata, cli.format)?; } Ok(()) }步骤 5一键构建所有 crate# 从工作区根目录执行 cargo build --workspace # 构建所有 crate cargo test --workspace # 运行所有测试 cargo run -p meta-cli # 只运行二进制 crate实战踩坑记录把项目拆成 workspace 的过程不算太痛苦但有几个坑新手容易踩。坑1循环依赖。有一版设计里我把meta-format依赖了meta-core同时meta-core里一个工具函数引用了meta-format。cargo check直接报cycle detected。解决方法是把那个工具函数上提到工作区根部的src/common.rs两边都引用它。坑2feature flag 传播。meta-cli的Cargo.toml里我习惯性地写了tokio { features [full] }然后工作区的meta-api也声明了tokio。结果链接了两个版本的 tokio symbol二进制体积膨胀了 3MB。后来统一用[workspace.dependencies]管理tokio的 feature只在 bin crate 级别激活full库 crate 都用default-features false。坑3CI 里cargo test --workspace的陷阱。拆完后第一次跑 CI 发现测试时间从 45 秒涨到 90 秒——因为工作区里的测试是串行编译的默认行为。解决方式在 CI 脚本里加cargo test --workspace -j 4强制并行编译测试二进制。四、拆分后的实际收益flowchart LR subgraph benefits[拆分收益] B1[编译时间\n减少约 50%] B2[库可复用\nmeta-core 被 3 个项目引用] B3[测试隔离\n各 crate 独立测试] B4[依赖清晰\n按 crate 管理版本] end具体数字改meta-core里一行代码后增量编译从 20 秒降到 3 秒。因为只重编meta-core然后链接——依赖它的 crate 不需要重编只要 API 没变。完整的编译时间对比MacBook Air M2, 16GB场景拆分前拆分后节省改meta-core一行19.2s2.8s85%改meta-cli的 main.rs19.2s4.1s79%全量cargo build --release182s195s-7%CI 增量编译35s12s66%全量构建时间反而微增了 7%这是 crate 边界带来的额外编译开销。但对于日常开发的增量编译收益远超这个代价。另一个意外收获团队成员分工更清晰了。负责 API 的同学只需要在meta-api里工作不用担心改 I/O 逻辑。五、总结Cargo 工作区这件事我想说三点拆 crate 的主因不是文件太多而是编译太慢 无法复用。如果项目不大单体 crate 内部模块拆分就足够不需要过度工程化。先拆模块再拆 crate。模块拆分是练内功crate 拆分是练外功。如果你连内部的模块边界都没理清拆 crate 只会把混乱放大。[workspace.dependencies]是管理版本的神器。之前我手动在每个 crate 里写依赖版本改一个版本要改 6 个文件。统一管理后只改一处。这个项目经过两次重构先拆模块、再拆 crate目前在 GitHub 上有 300 多 star算是我自学 Rust 以来第一个像样的作品。下一篇文章我会分享 WebAssembly AI 插件的工程模式那又是另一个维度的挑战了。欢迎分享你的 Cargo Workspace 使用经验。