7351 字
37 分钟
Rust学习笔记:构建体系

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 程序可以只有一个文件:

hello.rs
fn main() {
println!("hello, rust");
}

直接用 rustc 编译:

Terminal window
rustc hello.rs
./hello

这里的 rustc 就是 Rust 编译器。它读入一个 crate root,也就是当前 crate 的根文件,然后从这个根文件出发找到模块、类型、函数和依赖,最后生成可执行文件或库。

也可以显式指定 edition:

Terminal window
rustc --edition=2024 hello.rs

或者把单文件编成库:

Terminal window
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 外面做工程层面的工作:

  1. 读取 Cargo.toml
  2. 解析当前 package 有哪些 target。
  3. 解析第三方依赖和 feature。
  4. 生成或读取 Cargo.lock
  5. 按依赖拓扑顺序编译每一个 crate。
  6. 调用 rustc 生成 target/debugtarget/release 下的产物。

可以画成这样:

Rust build pipeline

常用命令大致是:

命令用途
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 声明形成模块树;模块树里的 structenumfntrait 等 item 才是这个 crate 的实际代码内容。

3.1 package:Cargo 管理的项目单元#

一个 package 通常就是一个带 Cargo.toml 的目录:

hello-rust/
├── Cargo.toml
└── src/
└── main.rs

Cargo.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.rssrc/main.rssrc/bin/convert.rs 都可以是 crate root;src/src/bin/crates/core/ 这些目录本身不是 crate root。

Cargo 根据默认目录约定自动发现 target,也可以在 Cargo.toml 里显式配置每个 target 的 root 文件。

默认约定是:

文件位置Cargo 发现的 targetcrate root
src/lib.rs默认 library targetsrc/lib.rs
src/main.rs默认 binary targetsrc/main.rs
src/bin/convert.rs名为 convert 的额外 binary targetsrc/bin/convert.rs
examples/local_client.rs名为 local_client 的 example targetexamples/local_client.rs
tests/api_test.rs名为 api_test 的 integration test targettests/api_test.rs
benches/parse_bench.rs名为 parse_bench 的 bench targetbenches/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 控制库的产物格式,例如 rlibcdylibstaticlib;那是同一个 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:

targetcrate rootcrate 类型
默认 library targetsrc/lib.rslibrary crate
默认 binary targetsrc/main.rsbinary crate
resize binary targetsrc/bin/resize.rsbinary crate
inspect binary targetsrc/bin/inspect.rsbinary crate

src/main.rssrc/bin/resize.rssrc/bin/inspect.rs 是三个彼此独立的 binary crate root。它们不会自动共享彼此的模块,也不能用 use 去引用另一个 binary target。

如果有共用逻辑,通常放到 src/lib.rs 对应的 library crate 里,然后这些 binary crate 像依赖普通库一样依赖这个 library crate。

例如 package 名字是 image-tool,library crate 在代码里的名字通常是 image_toolsrc/lib.rs 可以这样暴露公共模块:

src/lib.rs
pub mod operations;
src/operations.rs
pub fn resize_image(input: &str) {
println!("resize {input}");
}

然后 binary target 里可以引用 image_tool 这个 library crate:

src/bin/resize.rs
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::...

src/operations.rs
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 名则用于运行可执行程序,例如:

Terminal window
cargo run --bin resize

3.4 lib 和 bin target 的数量限制#

Cargo 对 library target 和 binary target 的数量限制不同。规则本身很简单:

target 类型一个 package 里能有几个?默认发现位置显式配置
library target最多 1 个src/lib.rs[lib]
binary target0 个或多个src/main.rssrc/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 里有多个可执行程序很自然,例如:

  • server
  • worker
  • migrate
  • admin-cli

每个 binary target 都有自己的 crate root 文件和自己的 main 函数,最后生成一个独立可执行程序:

src/bin/migrate.rs
fn main() {
println!("run database migration");
}
src/bin/admin-cli.rs
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.rssrc/main.rssrc/bin/resize.rs
  • 子模块文件的位置由声明它的父模块决定,例如 src/lib.rs 里的 mod db; 会找 src/db.rssrc/db/mod.rs,而 src/db.rs 里的 mod model; 会找 src/db/model.rs

例如下面这个结构里,model.rs 不在 src/ 第一层,但它仍然属于当前 library crate:

src/
├── lib.rs
├── db.rs
└── db/
└── model.rs
src/lib.rs
mod db;
src/db.rs
mod model;

这里的模块树是 crate -> db -> model。目录只是模块文件的存放位置;真正决定文件是否进入 crate 的,仍然是从 crate root 开始的一连串 mod 声明。

最小例子:

src/lib.rs
mod image;
pub fn run() {
image::load();
}
src/image.rs
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.rs
src/lib.rs
mod image;

这里 image.rs 属于当前 library crate,unused.rs 不属于。它只是一个躺在目录里的文件,编译器不会自动读取它。

这和 C++ 很不一样。C++ 常见组织方式是 .h 声明、.cpp 实现、构建系统决定哪些 .cpp 参与编译;Rust 则是从 crate root 出发,用 mod 形成一棵模块树。

这几个概念的关系可以这样理解:

Package crate module model

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

对应的模块声明可能是:

src/lib.rs
pub mod config;
pub mod db;
pub mod api;
src/db/mod.rs
mod model;
mod query;
pub use model::User;
pub use query::find_user;
src/api/mod.rs
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/ 文件夹里:

src/db.rs
mod model;
mod query;
pub use model::User;
pub use query::find_user;

两种方式都可以。新项目里我更倾向于 db.rs + db/xxx.rs,因为它避免很多目录里都出现 mod.rs,在编辑器标签页里更容易识别。

5. moduse、路径和可见性#

这几个关键字很容易被混在一起:

  • mod:声明模块,把一个文件或 inline module 纳入模块树。
  • use:把某个路径引入当前作用域,类似创建一个局部别名。
  • pub:控制 item 能不能被模块外访问。

5.1 use 不负责加载文件#

看这个例子:

src/lib.rs
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 这个路径是否真的存在。
  • 路径上的 dbconfig 模块对当前位置是否可见。
  • 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
└── service

load_secret 定义在 config 里,它的父模块是 config,祖先模块是 dbcrate。它不是直接属于 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 的子模块
}
}

这段代码说明两件事:

  1. config::sub 可以访问 config::load_secret,因为 subconfig 的子模块。
  2. 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 引入当前作用域
}
}
}

如果希望 dbdb 下的兄弟模块 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_configdb 这个范围里可见:db 自己可以访问,db::service 这种 db 的子模块也可以访问。

可以画成下面这样:

Rust module visibility

最后再强调一次路径问题。就算某个 item 写了 pub(crate),也不代表任何地方都一定能写出它的完整路径;路径上的模块也要能访问。换句话说:

  • normalize_db_url 自己允许整个 crate 访问。
  • 但如果 config 这个模块路径对某个位置不可见,那仍然不能通过 crate::db::config::normalize_db_url() 访问。
  • 常见解决方式是用 pub use 在更合适的模块边界重导出一个稳定路径。

例如:

src/db/model.rs
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 类型公开,newname 公开,但字段 idname 仍然私有。外部用户必须通过构造函数和方法使用 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 里重导出:

src/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

Terminal window
cargo add anyhow
cargo 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.rs

workspace 根目录的 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 = true
license.workspace = true
[dependencies]
anyhow.workspace = true
serde.workspace = true
my-product-core = { path = "../core" }
my-product-storage = { path = "../storage" }

workspace 的组织方式可以画成这样:

Rust workspace organization

这里建议把业务边界拆成 crate,而不是把所有代码都塞进一个巨大 crate:

  • core:领域模型、核心 trait、纯逻辑。
  • storage:数据库、文件、缓存等持久化实现。
  • api:HTTP/gRPC 接口层。
  • cli:命令行入口。
  • xtask:项目内部脚本和代码生成任务。

拆成多个 crate 的好处是:

  1. 编译边界更清楚,一个 crate 的 public API 就是它对别的 crate 的契约。
  2. 依赖方向更容易控制,例如 core 不应该依赖 api
  3. 测试和复用更自然。
  4. 以后要拆库、发包、复用 CLI,都有空间。

代价也存在:crate 太多会增加 Cargo.toml 维护成本,也可能让改一个类型牵动多个 crate 的版本和 API。一般可以先从一个 package + 清晰 module 开始,等边界稳定后再拆 workspace。

8. 依赖、引用和编译顺序怎么处理?#

在一个 crate 内,模块之间通过路径引用:

crate::config::Config
super::model::User
self::parser::parse

在多个 crate 之间,通过 Cargo.toml 依赖引用:

[dependencies]
my-product-core = { path = "../core" }

代码里用 library crate 名:

use my_product_core::User;

Cargo 会把依赖关系整理成图,再按顺序编译。大致流程是:

core -> storage -> api -> cli

如果 api 依赖 storagestorage 又依赖 core,那么 Cargo 会先编译 core,再编译 storage,最后编译 api。同一版本的依赖通常只编译一次,产物缓存到 target/

一个重要限制是:crate 之间不能循环依赖。

如果你发现:

api -> storage -> core -> api

这通常说明边界设计有问题。解决方式一般是:

  • 把共同的 trait 或类型下沉到 core
  • 让高层依赖低层,低层不要反过来依赖高层。
  • 用 trait 抽象反转依赖,而不是直接引用具体实现。

例如:

// core crate
pub trait UserRepository {
fn find_user(&self, id: u64) -> anyhow::Result<Option<User>>;
}
// storage crate
use 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 crate
use 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(())
}

这样 apistorage 都依赖 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 = 3
lto = "thin"
codegen-units = 1
panic = "abort"

一般开发时:

Terminal window
cargo check
cargo test
cargo run

发布前:

Terminal window
cargo test
cargo build --release

如果你只是想确认代码能不能通过编译,优先用 cargo check。如果要看最终性能或发布二进制,再用 cargo build --release

10. 如何分发自己的代码?#

Rust 代码的分发方式取决于你要分发什么。

10.1 分发可执行程序#

如果是 CLI 或服务端程序,最直接:

Terminal window
cargo build --release

产物在:

target/release/my-tool

本机安装可以用:

Terminal window
cargo install --path .

开源工具常见做法是 CI 为 Linux/macOS/Windows 分别构建 release artifact,再上传到 GitHub Releases。

10.2 发布 library 到 crates.io#

如果是库,发布前通常需要准备好:

  • name
  • version
  • edition
  • license
  • description
  • repository
  • readme
  • 合理的 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:

Terminal window
cargo package
cargo publish --dry-run

确认没问题后:

Terminal window
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 的构建体系,我建议按这个顺序理解:

  1. 先知道 rustc hello.rs 能编译单文件,但日常不用它直接管理项目。
  2. cargo new 建一个最小 binary package。
  3. 分清 target 和 crate root 文件:src/main.rs 是默认 binary target 的 root 文件,src/lib.rs 是默认 library target 的 root 文件。
  4. 理解 crate 包含哪些文件:从 crate root 文件出发,被 mod 挂进模块树的文件才属于这个 crate。
  5. 在一个 crate 里练习 modusepubpub(crate)
  6. 学会用 pub use 整理对外 API。
  7. [dependencies] 引入第三方库。
  8. 项目变大后再引入 workspace,把稳定边界拆成多个 package / crate。

一句话总结:Rust 的构建体系不是围绕”一个文件夹就是一个 crate”组织的,而是围绕 package 发布边界、target 构建目标、crate 编译边界、module 可见性边界来组织的。理解这几条边界之后,Cargo 的目录结构和命令就清楚很多了。

参考资料#

Rust学习笔记:构建体系
https://blog.gzher.com/posts/rust-building-system/
作者
中会
发布于
2026-06-05
许可协议
CC BY-NC-SA 4.0