Cargo 工作区重构实录:把单仓拆成多个 crate 的决策过程

Cargo 工作区重构实录:把单仓拆成多个 crate 的决策过程

一、单仓的舒适区和它悄悄累积的债

问题 1:编译太慢。 我只改了 format.rs 里一行代码,但 cargo build 要等将近 20 秒。每次等待都让我分心去刷手机。

问题 2:依赖混乱。 所有 crate 都挤在 Cargo.toml 里,reqwestclap 这些只被部分模块用到的依赖没法区分——它们都被编译进最终的二进制,哪怕某个功能没启用。

问题 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-formatcargo check 直接报 cycle detected。解决方法是把那个工具函数上提到工作区根部的 src/common.rs,两边都引用它。

坑2:feature flag 传播。 meta-cliCargo.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.2s2.8s85%
meta-cli 的 main.rs19.2s4.1s79%
全量 cargo build --release182s195s-7%
CI 增量编译35s12s66%

全量构建时间反而微增了 7%,这是 crate 边界带来的额外编译开销。但对于日常开发的增量编译收益远超这个代价。

另一个意外收获:团队成员分工更清晰了。负责 API 的同学只需要在 meta-api 里工作,不用担心改 I/O 逻辑。

五、总结

Cargo 工作区这件事,我想说三点:

  1. 拆 crate 的主因不是"文件太多",而是"编译太慢 + 无法复用"。 如果项目不大,单体 crate + 内部模块拆分就足够,不需要过度工程化。

  2. 先拆模块再拆 crate。 模块拆分是练内功,crate 拆分是练外功。如果你连内部的模块边界都没理清,拆 crate 只会把混乱放大。

  3. [workspace.dependencies] 是管理版本的神器。 之前我手动在每个 crate 里写依赖版本,改一个版本要改 6 个文件。统一管理后只改一处。

这个项目经过两次重构(先拆模块、再拆 crate),目前在 GitHub 上有 300 多 star,算是我自学 Rust 以来第一个"像样的作品"。下一篇文章我会分享 WebAssembly AI 插件的工程模式,那又是另一个维度的挑战了。


欢迎分享你的 Cargo Workspace 使用经验。

评论 6
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值