Cargo 与 monorepo:Rust 大型项目的代码组织范式对比与选择

Cargo 与 monorepo:Rust 大型项目的代码组织范式对比与选择

一、项目膨胀时的选择焦虑

我的 AI CLI 工具最初只有一个 crate——main.rslib.rs,总共不到 2000 行。当时觉得 Cargo 太方便了,一个 cargo build 就从头到尾搞定。

一年后,代码膨胀到了 6 万行:LLM Provider 适配层、Prompt 模板引擎、配置管理系统、数据库 ORM 层、CLI 交互界面……全部塞在一个 crate 里。编译时间从 10 秒涨到 3 分钟,任何改动都要重新编译整个项目。更要命的是,不同模块之间的依赖关系乱成一团——Provider 适配层引用了 CLI 的工具函数,数据库层又依赖了 Prompt 模板的类型。

这就是大型 Rust 项目迟早要面对的抉择:拆成多 crate(monorepo polyrepo),还是用 feature flag 做模块化(单 crate 多 feature)。这篇文章我会把两种方案在真实项目里的表现拆开讲。

二、单 Crate 多 Feature:最轻量的模块化

Feature flag 是 Cargo 内置的编译期条件编译机制。通过 feature,你可以在一个 crate 里按需启用不同的模块。

# Cargo.toml
[package]
name = "ai-cli"
version = "0.5.0"
edition = "2021"

[features]
# 定义 feature 树
default = ["cli", "config-file"]  # 默认开启 CLI 和文件配置

# 各个子模块作为独立 feature
llm-openai = ["reqwest"]          # OpenAI 适配,依赖 reqwest
llm-deepseek = ["reqwest"]        
llm-anthropic = ["reqwest"]
cli = ["clap", "dialoguer"]       # CLI 模块
config-file = ["toml"]            # 文件配置
config-env = []                   # 环境变量配置
database = ["sqlx"]               # 数据库模块
http-server = ["axum", "tower"]   # HTTP 服务端模块

# 依赖声明
[dependencies]
reqwest = { version = "0.12", features = ["json", "stream"], optional = true }
clap = { version = "4.5", features = ["derive"], optional = true }
dialoguer = { version = "0.11", optional = true }
toml = { version = "0.8", optional = true }
sqlx = { version = "0.7", features = ["runtime-tokio", "postgres"], optional = true }
axum = { version = "0.7", optional = true }
tower = { version = "0.4", features = ["limit"], optional = true }

对应的代码结构:

// src/lib.rs —— 所有模块都声明,但编译期按 feature 剔除
// 这样做的代价:即使不需要数据库,代码仍然在源码树里

/// 主库入口
pub mod core;       // 核心类型——始终编译
pub mod config;     // 配置模块

// 条件编译——只有启用了对应 feature 才编译这些模块
#[cfg(feature = "llm-openai")]
pub mod llm_openai;

#[cfg(feature = "llm-deepseek")]
pub mod llm_deepseek;

#[cfg(feature = "cli")]
pub mod cli;

#[cfg(feature = "database")]
pub mod db;

/// 根据 feature 组合选择的 Provider
pub fn create_provider() -> Box<dyn core::LlmProvider> {
    #[cfg(feature = "llm-openai")]
    {
        return Box::new(llm_openai::OpenAiProvider::new());
    }
    
    #[cfg(feature = "llm-deepseek")]
    {
        return Box::new(llm_deepseek::DeepSeekProvider::new());
    }
    
    // 编译期保证:至少有一个 LLM feature 被启用
    #[allow(unreachable_code)]
    panic!("至少需要启用一个 LLM Provider feature (llm-openai 或 llm-deepseek)")
}

// main.rs 里的选择性编译
#[cfg(feature = "cli")]
fn main() {
    // CLI 模式的入口
    cli::run();
}

#[cfg(not(feature = "cli"))]
fn main() {
    println!("此构建不包含 CLI 功能");
}

单 crate 方案的优劣:

优点

  • 零额外开销——不需要维护多个 crate 的版本号、不需要在 crates.io 发布;
  • 模块间的私有访问——同一个 crate 内的 pub(crate) 在所有 feature 间共享;
  • 原子提交——改一个 bug 只需要一次 commit。

缺点

  • IDE 会索引所有代码——即使你的分支只启用 cli feature,rust-analyzer 还是会检查 http-server 的代码。项目大了之后 IDE 卡顿明显;
  • 编译时间不会线性减少——Cargo 的增量编译在 feature 粒度上不如 crate 边界清晰;
  • 隐式依赖风险——一个 feature 启用了某个依赖,其他 feature 的代码也可能"不小心"引用它。

三、多 Crate Monorepo:工业级项目的标准答案

当项目超过 3 万行,我推荐用多 crate 的 workspace 模式——一个仓库,多个子 crate。

ai-tool/                          # workspace 根目录
├── Cargo.toml                     # workspace 声明
├── crates/
│   ├── core-types/               # 核心类型——所有 crate 的公共依赖
│   │   ├── Cargo.toml
│   │   └── src/
│   │       ├── lib.rs
│   │       ├── config.rs          # Config 类型定义
│   │       └── error.rs           # 统一错误类型
│   │
│   ├── llm-provider/             # LLM Provider 适配层
│   │   ├── Cargo.toml
│   │   └── src/
│   │       ├── lib.rs
│   │       ├── openai.rs
│   │       ├── deepseek.rs
│   │       └── router.rs
│   │
│   ├── prompt-engine/            # Prompt 模板引擎
│   │   ├── Cargo.toml
│   │   └── src/
│   │       ├── lib.rs
│   │       ├── template.rs
│   │       └── context.rs
│   │
│   ├── config-layer/             # 配置加载层
│   │   ├── Cargo.toml
│   │   └── src/
│   │       ├── lib.rs
│   │       ├── file.rs
│   │       └── env.rs
│   │
│   ├── storage/                  # 数据存储层
│   │   ├── Cargo.toml
│   │   └── src/
│   │       ├── lib.rs
│   │       ├── sqlite.rs
│   │       └── postgres.rs
│   │
│   ├── app-cli/                  # CLI 应用入口
│   │   ├── Cargo.toml
│   │   └── src/
│   │       └── main.rs
│   │
│   └── app-server/               # HTTP 服务端入口
│       ├── Cargo.toml
│       └── src/
│           └── main.rs
│
└── benches/                      # 基准测试
    └── inference_bench.rs

Workspace 的 Cargo.toml:

# ai-tool/Cargo.toml (workspace 根)
[workspace]
members = [
    "crates/core-types",
    "crates/llm-provider",
    "crates/prompt-engine",
    "crates/config-layer",
    "crates/storage",
    "crates/app-cli",
    "crates/app-server",
]

# 所有 crate 共用的版本约束
[workspace.dependencies]
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
anyhow = "1"
thiserror = "1"

# 内部 crate 使用 workspace 依赖(保证版本统一)
# [dependencies]
# serde = { workspace = true }
# tokio = { workspace = true }

子 crate 的 Cargo.toml 示例:

# crates/llm-provider/Cargo.toml
[package]
name = "ai-llm-provider"
version = "0.1.0"
edition = "2021"

[dependencies]
# 内部依赖——使用路径引用,保证版本一致
ai-core-types = { path = "../core-types" }
ai-prompt-engine = { path = "../prompt-engine" }

# 外部依赖——统一从 workspace 继承
serde = { workspace = true }
tokio = { workspace = true }
reqwest = { version = "0.12", features = ["json", "stream"] }

多 crate 方案的优劣:

优点

  • 编译隔离——改 app-cli 不会触发 llm-provider 的重新编译;
  • 显式依赖图——每个 crate 的 Cargo.toml 清楚写了依赖谁,不存在隐式引用;
  • IDE 性能——rust-analyzer 只需要索引当前工作的 crate 和它的直接依赖;
  • 访问控制——pub 标记天然形成了 API 边界。

缺点

  • 版本同步——7 个子 crate 全部改版本号时容易漏掉;
  • 循环依赖——多 crate 禁止循环依赖,前期需要仔细规划依赖图;
  • 发布成本——如果需要发布到 crates.io,每个子 crate 要单独维护。

四、依赖图设计与避免循环依赖

多 crate 模式最难的是依赖图设计。一个常见的坑是:core-types 定义了一个 Message 类型,prompt-engine 需要操作 Message,而 llm-provider 又需要知道 Message 的格式——如果 core-types 依赖了 prompt-engine,就形成了循环。

解决循环依赖的两种方法:

  1. 提取公共类型到 core-types——被多个 crate 依赖的类型往上提;
  2. 依赖反转(trait 定义在上层)——prompt-engine 定义 trait MessageSourcellm-provider 实现这个 trait。
// crates/prompt-engine/src/lib.rs
// prompt-engine 不依赖 llm-provider,而是定义 trait

/// 消息源 trait——由上层实现
pub trait MessageSource {
    fn get_messages(&self) -> Vec<Message>;
    fn get_system_prompt(&self) -> &str;
}

/// 模板引擎只依赖 trait,不关心具体实现
pub fn render_template<S: MessageSource>(
    source: &S,
    template: &PromptTemplate,
) -> String {
    let messages = source.get_messages();
    // ... 渲染逻辑
}

// crates/llm-provider/src/lib.rs
// llm-provider 实现 trait
use ai_prompt_engine::MessageSource;

impl MessageSource for OpenAiProvider {
    fn get_messages(&self) -> Vec<Message> {
        self.conversation.clone()
    }
    
    fn get_system_prompt(&self) -> &str {
        &self.system_prompt
    }
}

我们的 monorepo 在拆成 6 个 crate 后,cargo build 全量编译从 4.1 分钟涨到了 5.8 分钟——因为 crate 之间多了序列化/反序列化的开销。但增量编译从 1.2 分钟降到了 11 秒。这个 trade-off 值不值?对日常开发来说绝对值——每天省下的编译等待时间,累计起来比 CI 多花的 40 秒全量时间重要一百倍。

五、总结

单体 crate 和多 crate 的最后选择取决于两个指标:

  1. 编译时间——cargo build --timings 看哪个 crate 是编译瓶颈。如果改一行代码需要等 3 分钟,就该拆了;
  2. 团队分工——前端组只管 app-cli,后端组只管 app-server,拆开后可以并行开发互不影响。

我的建议路径:初创项目(< 5000 行)→ 单 crate + feature flag;成长到中等规模(5000-30000 行)→ 拆出 core-typesconfig-layer,其他保留单体;大型项目(> 30000 行或 3 人以上团队)→ 完整的多 crate workspace。

但也要给出一个人的警示:不要过早拆分。我见过一个 5000 行项目拆成了 8 个 crate,每次改一个类型要从 core-typesadapterengineapp 一路改上来,版本同步的痛苦远超编译时间的节省。拆分是手段不是目的,编译时间没超过 1 分钟之前,单 crate 够用了。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值