Rust 的项目组织一开始很容易让人混乱,因为它把几个概念拆得很细:
rustc是真正的编译器。- Cargo 是包管理器、构建工具和项目入口。
- package 是 Cargo 管理的发布/构建单元。
- target 是 Cargo 在一个 package 里要构建的具体目标。
- crate 是 Rust 编译器一次编译的单元。
- module 是 crate 内部组织代码和控制可见性的命名空间。
如果有 C++ 背景,可以先粗略类比一下:rustc 有点像 clang++ / g++,Cargo 有点像 “CMake + Conan/vcpkg + Ninja/Make + test runner” 的组合;但是 Rust 里的 package、target、crate、module 不是 C++ 头文件、源文件、命名空间的简单对应物,需要单独理解。
这篇笔记按一个实际问题展开:从单文件程序,到普通 Cargo 项目,再到多 crate 的大型工程,Rust 到底是怎么组织、编译、引用和分发代码的。
1. 单文件怎么编译?
最小的 Rust 程序可以只有一个文件:
fn main() { println!("hello, rust");}直接用 rustc 编译:
rustc hello.rs./hello这里的 rustc 就是 Rust 编译器。它读入一个 crate root,也就是当前 crate 的根文件,然后从这个根文件出发找到模块、类型、函数和依赖,最后生成可执行文件或库。
也可以显式指定 edition:
rustc --edition=2024 hello.rs或者把单文件编成库:
rustc --crate-type=lib math.rs不过,单独使用 rustc 只适合非常小的例子。只要出现下面这些需求,就应该切到 Cargo:
- 需要引入第三方依赖。
- 需要编译多个 binary / library target。
- 需要运行测试、文档测试、benchmark。
- 需要 debug/release profile、feature、workspace。
- 需要把代码发布到 crates.io 或内部 registry。
平时写 Rust,几乎总是用 Cargo 调 rustc,而不是自己直接调 rustc。
2. Cargo 到底做了什么?
Cargo 是 Rust 官方的包管理器和构建工具。它并不替代 rustc,而是负责在 rustc 外面做工程层面的工作:
- 读取
Cargo.toml。 - 解析当前 package 有哪些 target。
- 解析第三方依赖和 feature。
- 生成或读取
Cargo.lock。 - 按依赖拓扑顺序编译每一个 crate。
- 调用
rustc生成target/debug或target/release下的产物。
可以画成这样:
常用命令大致是:
| 命令 | 用途 |
|---|---|
cargo new app | 新建一个 package |
cargo check | 只做类型检查和借用检查,不生成最终可执行文件,通常最快 |
cargo build | 编译 debug 版本 |
cargo build --release | 编译 release 版本,启用优化 |
cargo run | 编译并运行默认 binary |
cargo test | 运行单元测试、集成测试和文档测试 |
cargo doc --open | 生成并打开文档 |
cargo publish | 发布 package 到 registry |
cargo check 很重要。Rust 编译器做的静态检查比较重,开发时频繁跑 cargo check,通常比每次都 cargo build 更舒服。
3. 先分清 package、target、crate、module
Rust 工程里最容易混的就是这四个词。它们不是并列的”文件夹层级”,而是一条构建链路:
package -> target -> crate root -> module tree -> items也就是说,Cargo 先在一个 package 里确定要构建哪些 target;每个 target 有自己的 crate root;rustc 从这个 crate root 出发,通过 mod 声明形成模块树;模块树里的 struct、enum、fn、trait 等 item 才是这个 crate 的实际代码内容。
3.1 package:Cargo 管理的项目单元
一个 package 通常就是一个带 Cargo.toml 的目录:
hello-rust/├── Cargo.toml└── src/ └── main.rsCargo.toml 描述这个 package 的名字、版本、edition、依赖、feature、构建配置等:
[package]name = "hello-rust"version = "0.1.0"edition = "2024"
[dependencies]anyhow = "1"package 是 Cargo 看项目的单位,也是发布到 crates.io 时的单位。更准确地说,crates.io 上发布的是一个 package 的某个版本,虽然日常口语里大家经常说”发布一个 crate”。
严格说,不是所有带 Cargo.toml 的目录都是 package。如果根 Cargo.toml 只有 [workspace],没有 [package],那它只是 workspace 根目录,不是一个可被单独构建和发布的 package。真正的 package 要有自己的 [package] 表。
3.2 target:Cargo 要构建的目标
在 Cargo 项目里,不是先问”这个文件夹是不是一个 crate”,而是先问:这个 package 里有哪些 target?
target 是 Cargo 要构建的目标。常见 target 有:
- library target:构建出一个 library crate,给其他 crate 依赖。一个 package 最多只能有一个 library target。
- binary target:构建出一个 binary crate,最后生成可执行文件。一个 package 可以有多个 binary target。
- example target:示例程序。
- test target:集成测试程序。
- bench target:benchmark 程序。
每个 target 都有一个 crate root,也就是这个 crate 的入口文件。crate root 是一个具体的 .rs 文件,不是文件夹。例如 src/lib.rs、src/main.rs、src/bin/convert.rs 都可以是 crate root;src/、src/bin/、crates/core/ 这些目录本身不是 crate root。
Cargo 根据默认目录约定自动发现 target,也可以在 Cargo.toml 里显式配置每个 target 的 root 文件。
默认约定是:
| 文件位置 | Cargo 发现的 target | crate root |
|---|---|---|
src/lib.rs | 默认 library target | src/lib.rs |
src/main.rs | 默认 binary target | src/main.rs |
src/bin/convert.rs | 名为 convert 的额外 binary target | src/bin/convert.rs |
examples/local_client.rs | 名为 local_client 的 example target | examples/local_client.rs |
tests/api_test.rs | 名为 api_test 的 integration test target | tests/api_test.rs |
benches/parse_bench.rs | 名为 parse_bench 的 bench target | benches/parse_bench.rs |
也可以不按默认文件名,而是在 Cargo.toml 里显式写:
[lib]name = "image_tool"path = "src/library.rs"
[[bin]]name = "resize"path = "src/tools/resize.rs"
[[bin]]name = "inspect"path = "src/tools/inspect.rs"这个配置的意思是:
- library target 的 crate root 是
src/library.rs。 resize这个 binary target 的 crate root 是src/tools/resize.rs。inspect这个 binary target 的 crate root 是src/tools/inspect.rs。
所以,crate 的边界不是由文件夹天然决定的,而是由 target 的 crate root 文件决定起点,再由这个 root 文件里声明的模块树继续扩展。
3.3 crate:一次编译的代码单元
crate 是 rustc 的编译单元。Cargo 发现一个 target 后,会把这个 target 交给 rustc 编译;从 rustc 角度看,这个 target 就是一个 crate。
crate 的类型由 target 类型决定:
- library target 编译成 library crate。
- binary target 编译成 binary crate。
- example、integration test、bench 也会各自作为独立 crate 编译,只是用途不同。
这里说的 library / binary,是 Cargo target 层面的分类。[lib] 里还可以用 crate-type 控制库的产物格式,例如 rlib、cdylib、staticlib;那是同一个 library target 生成什么格式的问题,不是”一个 package 有多个 library target”。
例如:
image-tool/├── Cargo.toml└── src/ ├── lib.rs # library target 的 crate root,生成 library crate: image_tool ├── operations.rs # 被 src/lib.rs 通过 mod 挂进 library crate ├── main.rs # 默认 binary target 的 crate root └── bin/ ├── resize.rs # resize binary target 的 crate root └── inspect.rs # inspect binary target 的 crate root这里不是”一个文件夹就是一个 crate”。更准确地说,这个 package 里有四个 target,因此会编译出四个 crate:
| target | crate root | crate 类型 |
|---|---|---|
| 默认 library target | src/lib.rs | library crate |
| 默认 binary target | src/main.rs | binary crate |
resize binary target | src/bin/resize.rs | binary crate |
inspect binary target | src/bin/inspect.rs | binary crate |
src/main.rs、src/bin/resize.rs、src/bin/inspect.rs 是三个彼此独立的 binary crate root。它们不会自动共享彼此的模块,也不能用 use 去引用另一个 binary target。
如果有共用逻辑,通常放到 src/lib.rs 对应的 library crate 里,然后这些 binary crate 像依赖普通库一样依赖这个 library crate。
例如 package 名字是 image-tool,library crate 在代码里的名字通常是 image_tool。src/lib.rs 可以这样暴露公共模块:
pub mod operations;pub fn resize_image(input: &str) { println!("resize {input}");}然后 binary target 里可以引用 image_tool 这个 library crate:
use image_tool::operations::resize_image;
fn main() { resize_image("cat.png");}也就是说,同一个 package 里的 binary target 要引用本 package 的 library crate,也从这个 library crate 的名字开始写。默认情况下,这个名字来自 package 名:image-tool 这个 package 的 library crate 名通常是 image_tool,所以 binary 里写 use image_tool::...。
但在 src/lib.rs 以及它下面的 library 模块内部,引用本 library crate 自己的其他模块时,通常写 crate::...:
use crate::config::ImageConfig;注意,这里的 image_tool 不是 binary target,而是 package 里的 library crate;operations 也不是 binary target,而是 library crate 里通过 pub mod operations; 暴露出来的模块。src/bin/resize.rs 这个 binary target 只是一个可执行入口,它不会变成可被 use image_tool::resize 引用的库模块。
package 名字可以带短横线,比如 image-tool;默认 library crate 名在代码里通常会转成下划线,也就是 image_tool。binary target 名则用于运行可执行程序,例如:
cargo run --bin resize3.4 lib 和 bin target 的数量限制
Cargo 对 library target 和 binary target 的数量限制不同。规则本身很简单:
| target 类型 | 一个 package 里能有几个? | 默认发现位置 | 显式配置 |
|---|---|---|---|
| library target | 最多 1 个 | src/lib.rs | [lib] |
| binary target | 0 个或多个 | src/main.rs、src/bin/*.rs | [[bin]] |
也就是说:
- 一个 package 可以没有 library target。
- 一个 package 可以有一个 library target。
- 一个 package 不能有两个 library target。
- 一个 package 可以有零个、一个或多个 binary target。
为什么 lib 最多只能有一个?
library target 是这个 package 被别的 package 依赖时的入口。依赖方在 Cargo.toml 里写的是 package 名:
[dependencies]image-tool = "0.1"然后在 Rust 代码里使用这个 package 暴露出来的 library crate:
use image_tool::operations::resize_image;这里必须有一个唯一、明确的库入口。这个入口就是该 package 暴露的唯一 library crate。默认情况下,package 名 image-tool 对应到代码里的 library crate 名 image_tool。
这也解释了一个常见误区:Cargo.toml 里的 [dependencies] image-tool = "0.1" 引入的是这个 package 的 library target,不是它的 binary target。binary target 是用来编译可执行程序的,不能被其他 crate 用 use image_tool::resize_cli 当作库来导入。
如果一个 package 能同时暴露多个 library target,依赖方就会遇到几个不清楚的问题:
image-tool = "0.1"到底导入哪一个 library crate?- 这些 library crate 是否共享同一组 feature?
- 文档、版本号、public API 边界应该挂在哪个库入口上?
- 发布到 registry 时,一个 package 版本对应多个库入口,依赖解析会更复杂。
所以 Cargo 的设计是:一个 package 只有一个对外依赖入口,也就是最多一个 library target。如果确实需要多个可独立依赖的库,就应该拆成多个 package,通常放在同一个 workspace 里。
my-product/├── Cargo.toml└── crates/ ├── core/ # 一个 package,一个 lib ├── storage/ # 一个 package,一个 lib └── api/ # 一个 package,一个 lib这样每个库都有自己的 package 名、Cargo.toml、版本、依赖边界和 public API。
还要注意,[lib] 里的 crate-type 不违反这个限制:
[lib]crate-type = ["rlib", "cdylib"]这表示同一个 library target 生成多种产物格式,而不是定义了多个 library target。
为什么 bin 可以有多个?
binary target 是可执行入口,不是给别的 package 作为库依赖的入口。一个 package 里有多个可执行程序很自然,例如:
serverworkermigrateadmin-cli
每个 binary target 都有自己的 crate root 文件和自己的 main 函数,最后生成一个独立可执行程序:
fn main() { println!("run database migration");}fn main() { println!("run admin command");}这些 binary crate 可以共享同一个 package 里的 library crate,把业务逻辑放在 src/lib.rs,不同 binary 只负责组装不同入口。因此 Cargo 允许一个 package 有多个 [[bin]] target,或者通过 src/bin/*.rs 自动发现多个 binary target。
如果一个 package 只有 binary target、没有 src/lib.rs 或 [lib],那它就是一个纯可执行项目。此时并不存在一个同名 library crate 可供 use package_name::... 引用。这个 binary crate 内部要拆文件,仍然通过自己的 crate root 里的 mod 声明组织模块;其他项目想使用它,一般是运行它的可执行文件,或把可复用逻辑拆到单独的 library package。
3.5 module:crate 内部的命名空间
module 是 crate 里的代码组织和可见性边界。它不是 Cargo 的发布单位,也不是 rustc 的独立编译单位。
一个 crate 到底包含哪些 .rs 文件?答案是:从这个 crate 的 crate root 文件出发,所有被 mod 声明挂进模块树的文件,才属于这个 crate。
这不等于所有 .rs 文件都必须直接放在 src/ 下面。src/ 只是默认 target root 的常见目录;模块可以继续嵌套到子目录里。模块查找大致遵循两层关系:
- crate root 由 target 决定,例如
src/lib.rs、src/main.rs、src/bin/resize.rs。 - 子模块文件的位置由声明它的父模块决定,例如
src/lib.rs里的mod db;会找src/db.rs或src/db/mod.rs,而src/db.rs里的mod model;会找src/db/model.rs。
例如下面这个结构里,model.rs 不在 src/ 第一层,但它仍然属于当前 library crate:
src/├── lib.rs├── db.rs└── db/ └── model.rsmod db;mod model;这里的模块树是 crate -> db -> model。目录只是模块文件的存放位置;真正决定文件是否进入 crate 的,仍然是从 crate root 开始的一连串 mod 声明。
最小例子:
mod image;
pub fn run() { image::load();}pub fn load() { println!("load image");}mod image; 的意思是:在当前 crate 里声明一个名为 image 的模块,模块内容从 src/image.rs 读取。
关键点是:Rust 不会因为文件存在就自动把它编进项目。一个 .rs 文件必须通过 crate root 或其他模块里的 mod xxx; 挂到模块树上,才是当前 crate 的一部分。
例如目录里有一个没有被 mod 引用的文件:
src/├── lib.rs├── image.rs└── unused.rsmod image;这里 image.rs 属于当前 library crate,unused.rs 不属于。它只是一个躺在目录里的文件,编译器不会自动读取它。
这和 C++ 很不一样。C++ 常见组织方式是 .h 声明、.cpp 实现、构建系统决定哪些 .cpp 参与编译;Rust 则是从 crate root 出发,用 mod 形成一棵模块树。
这几个概念的关系可以这样理解:
4. 文件夹是怎么组织的?
一个稍微正常一点的 Rust package 可能长这样:
my-service/├── Cargo.toml├── Cargo.lock├── build.rs├── src/│ ├── lib.rs│ ├── main.rs│ ├── config.rs│ ├── db/│ │ ├── mod.rs│ │ ├── model.rs│ │ └── query.rs│ └── api/│ ├── mod.rs│ ├── route.rs│ └── handler.rs├── tests/│ └── api_test.rs└── examples/ └── local_client.rs对应的模块声明可能是:
pub mod config;pub mod db;pub mod api;mod model;mod query;
pub use model::User;pub use query::find_user;mod handler;mod route;
pub use route::build_router;这里有两个常见风格。
旧一点、仍然常见的写法:
src/└── db/ ├── mod.rs ├── model.rs └── query.rs现代更常见的写法:
src/├── db.rs└── db/ ├── model.rs └── query.rs也就是把父模块写在 db.rs,子模块放到 db/ 文件夹里:
mod model;mod query;
pub use model::User;pub use query::find_user;两种方式都可以。新项目里我更倾向于 db.rs + db/xxx.rs,因为它避免很多目录里都出现 mod.rs,在编辑器标签页里更容易识别。
5. mod、use、路径和可见性
这几个关键字很容易被混在一起:
mod:声明模块,把一个文件或 inline module 纳入模块树。use:把某个路径引入当前作用域,类似创建一个局部别名。pub:控制 item 能不能被模块外访问。
5.1 use 不负责加载文件
看这个例子:
mod db;
use db::User;
pub fn create_user() -> User { User::new("Alice")}mod db; 才是把 db 模块加入当前 crate 的动作。use db::User; 只是让后面可以直接写 User,不用每次写 db::User。
如果删掉 use,也可以写成:
pub fn create_user() -> db::User { db::User::new("Alice")}如果删掉 mod db;,只留下 use db::User;,编译器找不到 db 模块。
5.2 路径从哪里开始?
Rust 里常见路径有几种:
crate::db::User // 从当前 crate root 开始super::model::User // 从父模块开始self::helper::parse // 从当前模块开始db::User // 相对当前作用域,或来自 extern prelude外部依赖这里要区分两个名字:
Cargo.toml里写的是 dependency 的 package 名。- Rust 代码里的路径从这个依赖暴露出来的 crate 名开始。
对普通第三方依赖来说,Rust 代码里看到的这个 crate,就是该 package 暴露的唯一 library crate;不是 package 本身,也不是它的 binary target。多数情况下 package 名和 library crate 名一样,所以看起来像是直接从 package 名开始:
use serde::{Deserialize, Serialize};use anyhow::Result;对应的依赖是:
[dependencies]serde = { version = "1", features = ["derive"] }anyhow = "1"但如果 package 名里有短横线,代码里的 crate 名通常会变成下划线。例如 package 名是 image-tool,代码里通常写 image_tool:
[dependencies]image-tool = "0.1"use image_tool::operations::resize_image;也可以在依赖里显式重命名。左边是当前项目里使用的依赖名,也会成为代码里的 crate 名;package 字段才是 registry 上真实的 package 名:
[dependencies]img = { package = "image-tool", version = "0.1" }use img::operations::resize_image;所以更准确地说:Cargo 根据 [dependencies] 里的 package 信息下载和编译依赖;Rust 代码里的 use 路径从可见的 crate 名开始。
同一个 package 内部也是类似规则:binary target 引用本 package 的 library crate 时,从 library crate 名开始;library crate 自己内部引用自身模块时,通常从 crate:: 开始。
use 后面跟的也是一个路径。这个路径最终指向某个 item,例如模块、结构体、函数、trait、枚举变体等。常见写法有:
use crate::db::User; // 从当前 crate root 开始use super::model::User; // 从父模块开始use self::helper::parse; // 从当前模块开始use serde::{Deserialize, Serialize}; // 从外部 crate 名开始use std::collections::HashMap; // 从标准库 crate 名开始use 的作用是把路径末尾的名字引入当前作用域,或者用 as 起别名:
use crate::db::User;use crate::db::User as DbUser;它不负责加载文件,也不会绕过可见性检查。也就是说,use crate::db::config::read_pool_config; 能不能写成功,取决于:
crate::db::config::read_pool_config这个路径是否真的存在。- 路径上的
db、config模块对当前位置是否可见。 read_pool_config本身对当前位置是否可见。
5.3 可见性:默认私有,路径逐层公开
Rust 的 item 默认是私有的。不写 pub 时,item 只能在定义它的模块及其子模块中访问;兄弟模块、父模块和外部 crate 都不能直接访问。
pub 也不是简单的”全局公开”。一个 item 即使写了 pub,外部代码也必须能访问它所在路径上的每一层模块,才能真正用到它。也就是说,Rust 的可见性是沿着模块路径逐层生效的。
常见写法:
| 写法 | 含义 |
|---|---|
不写 pub | 当前模块及子模块可见 |
pub | 对所有能访问到完整模块路径的代码公开 |
pub(crate) | 当前 crate 内公开,对 crate 外隐藏 |
pub(super) | 在父模块范围内公开,也就是父模块及其子模块可见 |
pub(in crate::xxx) | 只在指定祖先模块范围内公开 |
这里最容易误解的是”父模块、子模块、兄弟模块”之间的关系。假设模块树是:
crate└── db ├── config │ ├── load_secret │ ├── read_pool_config │ └── sub └── serviceload_secret 定义在 config 里,它的父模块是 config,祖先模块是 db 和 crate。它不是直接属于 db,也不是直接属于 crate。完整路径是:
crate::db::config::load_secret如果不写 pub:
mod db { mod config { fn load_secret() {}
mod sub { fn verify_child_module_access() { super::load_secret(); // 可以:sub 是 config 的子模块 } }
fn load_current_module_secret() { load_secret(); // 可以:就在 config 当前模块里 } }
fn initialize_pool_from_parent() { // config::load_secret(); // 不可以:db 是 config 的父模块,不是 config 的子模块 }}这段代码说明两件事:
config::sub可以访问config::load_secret,因为sub是config的子模块。db不能访问config::load_secret,因为父模块不会自动拥有子模块里的私有 item。
还要注意,“有访问权限”和”名字在当前作用域里”不是一回事。在 sub 里可以访问 load_secret,但通常要写路径:
mod config { fn load_secret() {}
mod sub { fn load_secret_from_child_module() { // load_secret(); // 名字不在 sub 当前作用域里,通常找不到 super::load_secret(); // 可以:通过父模块路径访问 } }}也可以先 use 引入名字:
mod config { fn load_secret() {}
mod sub { use super::load_secret;
fn load_imported_secret() { load_secret(); // 可以:已经用 use 引入当前作用域 } }}如果希望 db 或 db 下的兄弟模块 service 也能访问 config 里的函数,就要把可见性扩大到父模块范围:
mod db { mod config { fn load_secret() {} pub(super) fn read_pool_config() {} pub(crate) fn normalize_db_url() {} }
mod service { fn connect_with_pool_config() { // super::config::load_secret(); // 不可以:load_secret 只对 config 及其子模块可见 super::config::read_pool_config(); // 可以:read_pool_config 对 db 范围可见 super::config::normalize_db_url(); // 可以:normalize_db_url 对当前 crate 可见 } }
fn initialize_pool() { // config::load_secret(); // 不可以 config::read_pool_config(); // 可以 config::normalize_db_url(); // 可以 }}这里 pub(super) 的 super 指的是 config 的父模块,也就是 db。因此 read_pool_config 在 db 这个范围里可见:db 自己可以访问,db::service 这种 db 的子模块也可以访问。
可以画成下面这样:
最后再强调一次路径问题。就算某个 item 写了 pub(crate),也不代表任何地方都一定能写出它的完整路径;路径上的模块也要能访问。换句话说:
normalize_db_url自己允许整个 crate 访问。- 但如果
config这个模块路径对某个位置不可见,那仍然不能通过crate::db::config::normalize_db_url()访问。 - 常见解决方式是用
pub use在更合适的模块边界重导出一个稳定路径。
例如:
pub struct User { id: u64, name: String,}
impl User { pub fn new(id: u64, name: String) -> Self { Self { id, name } }
pub fn name(&self) -> &str { &self.name }
pub(crate) fn id(&self) -> u64 { self.id }}这里 User 类型公开,new 和 name 公开,但字段 id、name 仍然私有。外部用户必须通过构造函数和方法使用 User,不能随手改字段。
pub(crate) 很适合工程内部 API。例如某个函数需要在 crate 内多个模块共享,但不想暴露给库使用者:
pub(crate) fn normalize_email(email: &str) -> String { email.trim().to_lowercase()}一个常见设计原则是:模块内部可以乱一点,模块边界要干净;crate 内部可以宽一点,crate 对外 API 要克制。
5.4 pub use:重导出,整理对外 API
实际工程里不希望使用者知道你的内部文件结构。例如内部是:
src/├── lib.rs└── db/ ├── mod.rs ├── model.rs └── query.rs如果直接公开内部路径,用户可能要写:
use my_service::db::model::User;use my_service::db::query::find_user;更好的做法是在 db/mod.rs 里重导出:
mod model;mod query;
pub use model::User;pub use query::find_user;然后用户只需要:
use my_service::db::{find_user, User};pub use 是 Rust 组织公共 API 很常用的手段。内部文件可以继续拆得很细,对外暴露的路径保持稳定。
6. 如何导入第三方 lib?
第三方依赖写在 Cargo.toml 里:
[dependencies]anyhow = "1"serde = { version = "1", features = ["derive"] }serde_json = "1"tokio = { version = "1", features = ["rt-multi-thread", "macros"] }然后在代码里使用:
use anyhow::{Context, Result};use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]struct Config { name: String,}
fn load_config(text: &str) -> Result<Config> { let config = serde_json::from_str(text) .context("failed to parse config")?; Ok(config)}如果使用 cargo add,可以让 Cargo 自动改 Cargo.toml:
cargo add anyhowcargo add serde --features derive依赖来源不只 crates.io,也可以是本地路径或 Git:
[dependencies]my-core = { path = "../my-core" }my-proto = { git = "https://github.com/example/my-proto", tag = "v0.3.0" }几类依赖也要分清:
| 表 | 用途 |
|---|---|
[dependencies] | 正常编译和运行需要的依赖 |
[dev-dependencies] | 测试、examples、benchmarks 使用的依赖 |
[build-dependencies] | build.rs 构建脚本使用的依赖 |
feature 是 Cargo 的条件编译和依赖开关。例如一个库可以默认不启用重依赖:
[features]default = []json = ["dep:serde", "dep:serde_json"]
[dependencies]serde = { version = "1", optional = true, features = ["derive"] }serde_json = { version = "1", optional = true }代码里再按 feature 编译:
#[cfg(feature = "json")]pub fn from_json(text: &str) -> serde_json::Result<Config> { serde_json::from_str(text)}使用者可以这样启用:
[dependencies]my-config = { version = "0.1", features = ["json"] }7. 大型项目怎么组织?
小项目一个 package 就够了。大型项目通常会变成 workspace。
workspace 是多个 package 的集合,共用一个 Cargo.lock 和一个 target/ 输出目录,也可以共享依赖版本、profile、lint 等配置。
典型结构:
my-product/├── Cargo.toml├── Cargo.lock├── crates/│ ├── core/│ │ ├── Cargo.toml│ │ └── src/lib.rs│ ├── storage/│ │ ├── Cargo.toml│ │ └── src/lib.rs│ ├── api/│ │ ├── Cargo.toml│ │ └── src/lib.rs│ └── cli/│ ├── Cargo.toml│ ├── src/main.rs│ └── tests/│ └── smoke.rsworkspace 根目录的 Cargo.toml:
[workspace]members = ["crates/*"]resolver = "3"
[workspace.package]edition = "2024"license = "MIT OR Apache-2.0"
[workspace.dependencies]anyhow = "1"serde = { version = "1", features = ["derive"] }tokio = { version = "1", features = ["rt-multi-thread", "macros"] }某个成员 package 可以继承这些配置:
[package]name = "my-product-api"version = "0.1.0"edition.workspace = truelicense.workspace = true
[dependencies]anyhow.workspace = trueserde.workspace = truemy-product-core = { path = "../core" }my-product-storage = { path = "../storage" }workspace 的组织方式可以画成这样:
这里建议把业务边界拆成 crate,而不是把所有代码都塞进一个巨大 crate:
core:领域模型、核心 trait、纯逻辑。storage:数据库、文件、缓存等持久化实现。api:HTTP/gRPC 接口层。cli:命令行入口。xtask:项目内部脚本和代码生成任务。
拆成多个 crate 的好处是:
- 编译边界更清楚,一个 crate 的 public API 就是它对别的 crate 的契约。
- 依赖方向更容易控制,例如
core不应该依赖api。 - 测试和复用更自然。
- 以后要拆库、发包、复用 CLI,都有空间。
代价也存在:crate 太多会增加 Cargo.toml 维护成本,也可能让改一个类型牵动多个 crate 的版本和 API。一般可以先从一个 package + 清晰 module 开始,等边界稳定后再拆 workspace。
8. 依赖、引用和编译顺序怎么处理?
在一个 crate 内,模块之间通过路径引用:
crate::config::Configsuper::model::Userself::parser::parse在多个 crate 之间,通过 Cargo.toml 依赖引用:
[dependencies]my-product-core = { path = "../core" }代码里用 library crate 名:
use my_product_core::User;Cargo 会把依赖关系整理成图,再按顺序编译。大致流程是:
core -> storage -> api -> cli如果 api 依赖 storage,storage 又依赖 core,那么 Cargo 会先编译 core,再编译 storage,最后编译 api。同一版本的依赖通常只编译一次,产物缓存到 target/。
一个重要限制是:crate 之间不能循环依赖。
如果你发现:
api -> storage -> core -> api这通常说明边界设计有问题。解决方式一般是:
- 把共同的 trait 或类型下沉到
core。 - 让高层依赖低层,低层不要反过来依赖高层。
- 用 trait 抽象反转依赖,而不是直接引用具体实现。
例如:
// core cratepub trait UserRepository { fn find_user(&self, id: u64) -> anyhow::Result<Option<User>>;}// storage crateuse my_product_core::{User, UserRepository};
pub struct PostgresUserRepository;
impl UserRepository for PostgresUserRepository { fn find_user(&self, id: u64) -> anyhow::Result<Option<User>> { let _ = id; Ok(None) }}// api crateuse my_product_core::UserRepository;
pub fn handle_get_user(repo: &impl UserRepository, id: u64) -> anyhow::Result<()> { let user = repo.find_user(id)?; println!("{user:?}"); Ok(())}这样 api 和 storage 都依赖 core,但 core 不依赖它们,依赖方向就干净了。
9. Cargo.lock、profile 和构建产物
Cargo.lock 记录依赖解析后的精确版本。它解决的是”这次构建到底用了哪些依赖版本”的问题。
常见经验:
- 应用程序、服务、命令行工具:建议提交
Cargo.lock,保证 CI、部署和本地构建使用同一组依赖版本。 - 纯 library:是否提交
Cargo.lock取决于团队习惯;但下游使用你的库时,不会靠你的 lockfile 锁住它的整个依赖树。
构建产物默认在:
target/├── debug/└── release/debug 和 release 主要由 profile 控制:
[profile.release]opt-level = 3lto = "thin"codegen-units = 1panic = "abort"一般开发时:
cargo checkcargo testcargo run发布前:
cargo testcargo build --release如果你只是想确认代码能不能通过编译,优先用 cargo check。如果要看最终性能或发布二进制,再用 cargo build --release。
10. 如何分发自己的代码?
Rust 代码的分发方式取决于你要分发什么。
10.1 分发可执行程序
如果是 CLI 或服务端程序,最直接:
cargo build --release产物在:
target/release/my-tool本机安装可以用:
cargo install --path .开源工具常见做法是 CI 为 Linux/macOS/Windows 分别构建 release artifact,再上传到 GitHub Releases。
10.2 发布 library 到 crates.io
如果是库,发布前通常需要准备好:
nameversioneditionlicensedescriptionrepositoryreadme- 合理的 public API 和文档注释
示例:
[package]name = "my-config"version = "0.1.0"edition = "2024"license = "MIT OR Apache-2.0"description = "A small config loader"repository = "https://github.com/example/my-config"readme = "README.md"发布前先 dry run:
cargo packagecargo publish --dry-run确认没问题后:
cargo publish发布后,别人就可以:
[dependencies]my-config = "0.1"需要注意:crates.io 上已经发布的版本不能覆盖。发现问题后通常是发新版本;如果某个版本不希望新项目继续解析到,可以 cargo yank,但 yank 不会删除已经发布的包,也不会影响已经锁定该版本的项目。
10.3 内部分发
公司或个人项目内部也可以不走 crates.io:
[dependencies]my-lib = { path = "../my-lib" }或者:
[dependencies]my-lib = { git = "ssh://git@example.com/team/my-lib.git", tag = "v1.2.0" }更正式的组织可以使用私有 registry,或者在 CI 里做 cargo vendor,把依赖源码固定到仓库或构建环境中。
11. 一个建议的学习顺序
如果刚开始学 Rust 的构建体系,我建议按这个顺序理解:
- 先知道
rustc hello.rs能编译单文件,但日常不用它直接管理项目。 - 用
cargo new建一个最小 binary package。 - 分清 target 和 crate root 文件:
src/main.rs是默认 binary target 的 root 文件,src/lib.rs是默认 library target 的 root 文件。 - 理解 crate 包含哪些文件:从 crate root 文件出发,被
mod挂进模块树的文件才属于这个 crate。 - 在一个 crate 里练习
mod、use、pub、pub(crate)。 - 学会用
pub use整理对外 API。 - 用
[dependencies]引入第三方库。 - 项目变大后再引入 workspace,把稳定边界拆成多个 package / crate。
一句话总结:Rust 的构建体系不是围绕”一个文件夹就是一个 crate”组织的,而是围绕 package 发布边界、target 构建目标、crate 编译边界、module 可见性边界来组织的。理解这几条边界之后,Cargo 的目录结构和命令就清楚很多了。