Rust 模块组织技巧:mod、use、pub 和 workspace 怎么放
Rust 项目变乱,通常不是因为语法难。
而是因为模块边界一开始没想清楚。
很多新手项目会把所有代码堆在 main.rs:
1 | main.rs |
项目小的时候没问题。
但功能一多,就会变成:
1 | 路由、配置、数据库、业务逻辑、错误类型、工具函数全部混在一起 |
Rust 的模块系统并不复杂。真正要理解的是:哪些代码应该公开,哪些代码应该藏起来,哪些代码应该拆成 crate。
这篇文章整理一些日常项目里很实用的模块组织技巧。
一、先分清 package、crate、module
Rust 官方文档里有三个概念:
1 | package |
简单理解:
1 | package:一个 Cargo.toml 管理的包 |
一个普通项目通常长这样:
1 | myapp/ |
这里:
myapp是 packagesrc/main.rs是 binary crate 的入口main.rs里可以声明多个 module
如果加上 src/lib.rs:
1 | myapp/ |
那这个 package 同时包含:
- 一个 library crate
- 一个 binary crate
这是很多 Rust 应用的推荐结构。
二、尽早把核心逻辑放进 lib.rs
不要把所有业务逻辑都写在 main.rs。
更好的方式是:
1 | src/ |
main.rs 只做启动:
1 | fn main() -> anyhow::Result<()> { |
核心逻辑放到 lib.rs:
1 | pub mod config; |
这样有几个好处:
- 业务逻辑可以被测试直接调用
- CLI、服务端、定时任务可以复用同一套逻辑
main.rs不会变成几千行- 以后拆 crate 更容易
我的习惯是:只要项目超过 demo 阶段,就把可复用逻辑放进 lib.rs。
三、mod 是声明模块,不是导入文件
很多人第一次接触 Rust 模块,会把 mod 理解成其他语言的 import。
这不准确。
mod user; 的意思更接近:
1 | 把 user 模块纳入当前 crate 的模块树。 |
例如:
1 | src/ |
lib.rs 中写:
1 | mod user; |
Rust 会去找:
1 | src/user.rs |
如果是目录模块:
1 | src/ |
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 | mod 负责告诉编译器模块在哪里 |
这两个概念分清后,Rust 模块系统会清楚很多。
五、默认私有是好事
Rust 模块里的内容默认是私有的。
这不是麻烦,是保护边界。
比如:
1 | pub struct UserService { |
这里 UserService 和 new 是公开的,但字段 repo 不是公开的。
外部代码不能随便改内部状态。
这比所有字段默认 public 更适合长期维护。
六、优先用 pub(crate),少用无脑 pub
很多代码会到处写:
1 | pub struct Xxx |
但不是所有东西都需要暴露给外部 crate。
如果只想在当前 crate 内可见,可以用:
1 | pub(crate) struct UserRepo; |
如果只想在父模块内可见,可以用:
1 | pub(super) fn normalize_name(name: &str) -> String { |
我的建议是:
1 | 能私有就私有 |
公开 API 是承诺。
承诺越多,将来重构越痛。
七、模块按业务分,还是按技术分
很多后端项目会这样分:
1 | src/ |
这是按技术层分。
另一种写法是按业务域分:
1 | src/ |
哪种更好?
小项目按技术层分,简单直接。
业务多起来后,我更倾向按业务域分。
因为真实改需求时,你通常不是“只改所有 service”,而是改:
1 | 用户模块 |
按业务域分,相关代码离得更近,跨目录跳转更少。
八、不要把工具函数塞成 utils 大杂烩
很多项目最后都会长出一个:
1 | utils.rs |
然后里面什么都有:
1 | 时间格式化 |
这很容易变成垃圾桶。
更好的方式是按语义拆:
1 | src/ |
或者放进具体业务模块内部。
如果一个函数只被 user 模块使用,那就先放在 user 模块里,不要急着提升成全局工具函数。
共享太早,也是一种耦合。
九、错误类型可以单独放 error.rs
中型项目里,建议有一个统一的错误模块:
1 | src/ |
例如:
1 |
|
业务代码里就可以写:
1 | use crate::error::Result; |
这样函数签名会干净很多。
当然,如果项目很小,用 anyhow::Result 也没问题。不要为了形式感过度设计错误类型。
十、什么时候需要 workspace
Cargo workspace 适合管理多个相关 package。
比如:
1 | my-system/ |
根目录 Cargo.toml:
1 | [workspace] |
适合 workspace 的场景:
- API 服务和 worker 共用核心逻辑
- CLI 和 server 共用一套 library
- 多个 crate 需要一起开发
- 想统一依赖版本和构建命令
不适合的场景:
- 项目刚开始
- 只有一个很小的二进制
- 拆 crate 只是为了看起来专业
我的判断是:当你已经有明确的复用边界,再上 workspace。
不要先拆,再找理由。
十一、一个推荐的后端项目结构
普通 Rust 后端服务,可以从这个结构开始:
1 | src/ |
main.rs:
1 | fn main() -> anyhow::Result<()> { |
lib.rs:
1 | pub mod app; |
这里有一个细节:
1 | pub mod app; |
app 公开,user 不一定公开。
外部只需要调用你的应用入口,不一定要知道所有内部模块。
十二、我的建议
Rust 模块组织不要追求一开始就完美。
但可以守住几个原则:
main.rs保持薄- 核心逻辑放进
lib.rs mod只负责声明模块use只负责缩短路径- 默认私有,不要无脑
pub - 优先用
pub(crate)控制 crate 内可见 - 小项目按技术层分,大项目按业务域分
- 不要把
utils写成垃圾桶 - workspace 等边界清楚后再上
Rust 的模块系统本质上是在逼你认真设计边界。
边界清楚,代码就不容易散。