Cargo 与 monorepo:Rust 大型项目的代码组织范式对比与选择
一、项目膨胀时的选择焦虑
我的 AI CLI 工具最初只有一个 crate——main.rs 加 lib.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 会索引所有代码——即使你的分支只启用
clifeature,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,就形成了循环。
解决循环依赖的两种方法:
- 提取公共类型到 core-types——被多个 crate 依赖的类型往上提;
- 依赖反转(trait 定义在上层)——
prompt-engine定义 traitMessageSource,llm-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 的最后选择取决于两个指标:
- 编译时间——
cargo build --timings看哪个 crate 是编译瓶颈。如果改一行代码需要等 3 分钟,就该拆了; - 团队分工——前端组只管
app-cli,后端组只管app-server,拆开后可以并行开发互不影响。
我的建议路径:初创项目(< 5000 行)→ 单 crate + feature flag;成长到中等规模(5000-30000 行)→ 拆出 core-types 和 config-layer,其他保留单体;大型项目(> 30000 行或 3 人以上团队)→ 完整的多 crate workspace。
但也要给出一个人的警示:不要过早拆分。我见过一个 5000 行项目拆成了 8 个 crate,每次改一个类型要从 core-types→adapter→engine→app 一路改上来,版本同步的痛苦远超编译时间的节省。拆分是手段不是目的,编译时间没超过 1 分钟之前,单 crate 够用了。

7967

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



