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

📅 2026/7/26 18:53:51
Cargo 与 monorepo:Rust 大型项目的代码组织范式对比与选择
Cargo 与 monorepoRust 大型项目的代码组织范式对比与选择一、项目膨胀时的选择焦虑我的 AI CLI 工具最初只有一个 crate——main.rs加lib.rs总共不到 2000 行。当时觉得 Cargo 太方便了一个cargo build就从头到尾搞定。一年后代码膨胀到了 6 万行LLM Provider 适配层、Prompt 模板引擎、配置管理系统、数据库 ORM 层、CLI 交互界面……全部塞在一个 crate 里。编译时间从 10 秒涨到 3 分钟任何改动都要重新编译整个项目。更要命的是不同模块之间的依赖关系乱成一团——Provider 适配层引用了 CLI 的工具函数数据库层又依赖了 Prompt 模板的类型。这就是大型 Rust 项目迟早要面对的抉择拆成多 cratemonorepo 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() - Boxdyn 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 会索引所有代码——即使你的分支只启用clifeaturerust-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.rsWorkspace 的 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定义 traitMessageSourcellm-provider实现这个 trait。// crates/prompt-engine/src/lib.rs // prompt-engine 不依赖 llm-provider而是定义 trait /// 消息源 trait——由上层实现 pub trait MessageSource { fn get_messages(self) - VecMessage; fn get_system_prompt(self) - str; } /// 模板引擎只依赖 trait不关心具体实现 pub fn render_templateS: 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) - VecMessage { 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 够用了。