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: Option<String>,
/// 最后修改时间
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<(), Box<dyn 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,两边都引用它。
坑2:feature 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。
坑3:CI 里 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.2s | 2.8s | 85% |
改 meta-cli 的 main.rs | 19.2s | 4.1s | 79% |
全量 cargo build --release | 182s | 195s | -7% |
| CI 增量编译 | 35s | 12s | 66% |
全量构建时间反而微增了 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 使用经验。

634

被折叠的 条评论
为什么被折叠?



