Rust 模块组织技巧:mod、use、pub 和 workspace 怎么放
wxk1991 Lv5

Rust 模块组织技巧:mod、use、pub 和 workspace 怎么放

Rust 项目变乱,通常不是因为语法难。

而是因为模块边界一开始没想清楚。

很多新手项目会把所有代码堆在 main.rs

1
main.rs

项目小的时候没问题。

但功能一多,就会变成:

1
路由、配置、数据库、业务逻辑、错误类型、工具函数全部混在一起

Rust 的模块系统并不复杂。真正要理解的是:哪些代码应该公开,哪些代码应该藏起来,哪些代码应该拆成 crate。

这篇文章整理一些日常项目里很实用的模块组织技巧。


一、先分清 package、crate、module

Rust 官方文档里有三个概念:

1
2
3
package
crate
module

简单理解:

1
2
3
package:一个 Cargo.toml 管理的包
crate:一次编译单元,可以是 lib,也可以是 bin
module:crate 内部的代码组织单元

一个普通项目通常长这样:

1
2
3
4
myapp/
Cargo.toml
src/
main.rs

这里:

  • myapp 是 package
  • src/main.rs 是 binary crate 的入口
  • main.rs 里可以声明多个 module

如果加上 src/lib.rs

1
2
3
4
5
myapp/
Cargo.toml
src/
lib.rs
main.rs

那这个 package 同时包含:

  • 一个 library crate
  • 一个 binary crate

这是很多 Rust 应用的推荐结构。


二、尽早把核心逻辑放进 lib.rs

不要把所有业务逻辑都写在 main.rs

更好的方式是:

1
2
3
4
5
6
7
8
src/
lib.rs
main.rs
config.rs
error.rs
service/
mod.rs
user.rs

main.rs 只做启动:

1
2
3
fn main() -> anyhow::Result<()> {
myapp::run()
}

核心逻辑放到 lib.rs

1
2
3
4
5
6
7
8
9
pub mod config;
pub mod error;
pub mod service;

pub fn run() -> anyhow::Result<()> {
let config = config::load()?;
service::start(config)?;
Ok(())
}

这样有几个好处:

  • 业务逻辑可以被测试直接调用
  • CLI、服务端、定时任务可以复用同一套逻辑
  • main.rs 不会变成几千行
  • 以后拆 crate 更容易

我的习惯是:只要项目超过 demo 阶段,就把可复用逻辑放进 lib.rs


三、mod 是声明模块,不是导入文件

很多人第一次接触 Rust 模块,会把 mod 理解成其他语言的 import。

这不准确。

mod user; 的意思更接近:

1
把 user 模块纳入当前 crate 的模块树。

例如:

1
2
3
src/
lib.rs
user.rs

lib.rs 中写:

1
mod user;

Rust 会去找:

1
src/user.rs

如果是目录模块:

1
2
3
4
src/
service/
mod.rs
user.rs

service/mod.rs 可以写:

1
pub mod user;

然后 user.rs 就成为 service::user

关键点是:mod 通常只在模块入口声明一次。

不要在每个文件里都乱写 mod xxx;


四、use 只是缩短路径

use 不会声明模块。

它只是把路径带进当前作用域。

例如:

1
use crate::service::user::UserService;

之后就可以直接写:

1
let service = UserService::new();

而不是每次都写:

1
crate::service::user::UserService::new()

一个简单判断方法:

1
2
mod 负责告诉编译器模块在哪里
use 负责让当前文件少写长路径

这两个概念分清后,Rust 模块系统会清楚很多。


五、默认私有是好事

Rust 模块里的内容默认是私有的。

这不是麻烦,是保护边界。

比如:

1
2
3
4
5
6
7
8
9
pub struct UserService {
repo: UserRepo,
}

impl UserService {
pub fn new(repo: UserRepo) -> Self {
Self { repo }
}
}

这里 UserServicenew 是公开的,但字段 repo 不是公开的。

外部代码不能随便改内部状态。

这比所有字段默认 public 更适合长期维护。


六、优先用 pub(crate),少用无脑 pub

很多代码会到处写:

1
2
pub struct Xxx
pub fn xxx()

但不是所有东西都需要暴露给外部 crate。

如果只想在当前 crate 内可见,可以用:

1
pub(crate) struct UserRepo;

如果只想在父模块内可见,可以用:

1
2
3
pub(super) fn normalize_name(name: &str) -> String {
name.trim().to_lowercase()
}

我的建议是:

1
2
3
能私有就私有
需要 crate 内共享,用 pub(crate)
真的作为外部 API,再用 pub

公开 API 是承诺。

承诺越多,将来重构越痛。


七、模块按业务分,还是按技术分

很多后端项目会这样分:

1
2
3
4
5
src/
handler/
service/
repository/
model/

这是按技术层分。

另一种写法是按业务域分:

1
2
3
4
5
6
7
8
9
10
11
src/
user/
handler.rs
service.rs
repository.rs
model.rs
order/
handler.rs
service.rs
repository.rs
model.rs

哪种更好?

小项目按技术层分,简单直接。

业务多起来后,我更倾向按业务域分。

因为真实改需求时,你通常不是“只改所有 service”,而是改:

1
2
3
用户模块
订单模块
支付模块

按业务域分,相关代码离得更近,跨目录跳转更少。


八、不要把工具函数塞成 utils 大杂烩

很多项目最后都会长出一个:

1
utils.rs

然后里面什么都有:

1
2
3
4
5
6
时间格式化
字符串处理
加密
路径拼接
HTTP 辅助函数
数据库转换

这很容易变成垃圾桶。

更好的方式是按语义拆:

1
2
3
4
5
src/
time.rs
crypto.rs
path_ext.rs
text.rs

或者放进具体业务模块内部。

如果一个函数只被 user 模块使用,那就先放在 user 模块里,不要急着提升成全局工具函数。

共享太早,也是一种耦合。


九、错误类型可以单独放 error.rs

中型项目里,建议有一个统一的错误模块:

1
2
src/
error.rs

例如:

1
2
3
4
5
6
7
8
9
10
#[derive(Debug, thiserror::Error)]
pub enum AppError {
#[error("config error: {0}")]
Config(String),

#[error(transparent)]
Io(#[from] std::io::Error),
}

pub type Result<T> = std::result::Result<T, AppError>;

业务代码里就可以写:

1
2
3
4
5
use crate::error::Result;

pub fn load_config() -> Result<Config> {
// ...
}

这样函数签名会干净很多。

当然,如果项目很小,用 anyhow::Result 也没问题。不要为了形式感过度设计错误类型。


十、什么时候需要 workspace

Cargo workspace 适合管理多个相关 package。

比如:

1
2
3
4
5
6
7
my-system/
Cargo.toml
crates/
api/
worker/
core/
cli/

根目录 Cargo.toml

1
2
3
4
5
6
7
[workspace]
members = [
"crates/api",
"crates/worker",
"crates/core",
"crates/cli",
]

适合 workspace 的场景:

  • API 服务和 worker 共用核心逻辑
  • CLI 和 server 共用一套 library
  • 多个 crate 需要一起开发
  • 想统一依赖版本和构建命令

不适合的场景:

  • 项目刚开始
  • 只有一个很小的二进制
  • 拆 crate 只是为了看起来专业

我的判断是:当你已经有明确的复用边界,再上 workspace。

不要先拆,再找理由。


十一、一个推荐的后端项目结构

普通 Rust 后端服务,可以从这个结构开始:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
src/
main.rs
lib.rs
config.rs
error.rs
app.rs
db.rs
user/
mod.rs
handler.rs
service.rs
repository.rs
model.rs
health/
mod.rs
handler.rs
tests/
api_test.rs

main.rs

1
2
3
fn main() -> anyhow::Result<()> {
myapp::run()
}

lib.rs

1
2
3
4
5
6
7
8
9
10
11
pub mod app;
pub mod config;
pub mod error;

mod db;
mod user;
mod health;

pub fn run() -> anyhow::Result<()> {
app::start()
}

这里有一个细节:

1
2
pub mod app;
mod user;

app 公开,user 不一定公开。

外部只需要调用你的应用入口,不一定要知道所有内部模块。


十二、我的建议

Rust 模块组织不要追求一开始就完美。

但可以守住几个原则:

  • main.rs 保持薄
  • 核心逻辑放进 lib.rs
  • mod 只负责声明模块
  • use 只负责缩短路径
  • 默认私有,不要无脑 pub
  • 优先用 pub(crate) 控制 crate 内可见
  • 小项目按技术层分,大项目按业务域分
  • 不要把 utils 写成垃圾桶
  • workspace 等边界清楚后再上

Rust 的模块系统本质上是在逼你认真设计边界。

边界清楚,代码就不容易散。


参考资料