正式开工:如何组织、编译、打包复杂的Rust项目?
本课程为精品小课,不标配音频
你好,我是文强。
这节课开始,我们就正式来写消息队列架构中的元数据集群部分。首先我们需要初始化一个项目,接下来我会详细讲解如何组织项目结构,以及如何编译打包 Rust 项目。
Rust 的 bin、lib 和 mod
在初始化项目之前,我们先来学习 3 个基础概念,cargo 中的 bin、lib、mod。
在 Rust 中,项目代码是通过 bin、lib、mod 这三种形式来组织的,先介绍下它们的功能。
1. bin
用来存放主入口 main 函数的目录。如下所示,它是通过 cargo.toml 中的 [[bin]] 语法指定的。name 表示编译生成的二进制文件的名称,path 表示主入口 main 函数所在的文件。当然,你可以在 cargo 文件中指定多个 [[bin]] ,生成多个目标二进制文件。
[[bin]]
name = "placement-center"
path = "src/placement-center/server.rs"
[[bin]]
name = "placement-center1"
path = "src/placement-center/server1.rs"
2. lib
这是 Library 的简写,表示功能库的集合。比如我们有一批通用的功能,就可以通过 lib 来组织,封装成一个独立的 lib,给其他项目调用。在 https://crates.io/ 上的各种基础功能库都是lib 的形式。从功能来看,Rust 中 lib 的概念相当于 Java Maven 中的 module。
lib 的特征是在 src 的目录下必须有一个 lib.rs 文件。比如我们在 common 目录下定义一个 base 的 Library,则它的结构如下:
├── common
│ └── base
│ ├── Cargo.toml
│ ├── src
│ │ └── lib.rs
│ └── tests
│ └── test.rs
3. mod
这是 Module 的缩写,从功能上来看,它相当于 Java 中的 package,它用来在 lib 中组织独立的代码功能。所以可以简单理解,mod 是包含在 lib 中的。比如我们在 common 中定义一个config 的 mod,此时目录结构如下:
├── common
│ └── base
│ ├── Cargo.toml
│ ├── src
│ │ ├── config
│ │ │ └── mod.rs
│ │ └── lib.rs
│ └── tests
│ └── test.rs
一般情况下,从功能上来看: 一个 lib 会包含多个 mod,一个 bin 需要调用多个 lib。
接下来我们来看一下如何组织我们的代码结构。
如何组织代码结构
如果你对我之前推荐的资料看得比较仔细,可能会关注到有这一章节 《标准的 Package 目录结构》。这里推荐了一个项目目录结构:
├── Cargo.lock
├── Cargo.toml
├── src/
│ ├── lib.rs
│ ├── main.rs
│ └── bin/
│ ├── named-executable.rs
│ ├── another-executable.rs
│ └── multi-file-executable/
│ ├── main.rs
│ └── some_module.rs
├── benches/
│ ├── large-input.rs
│ └── multi-file-bench/
│ ├── main.rs
│ └── bench_module.rs
├── examples/
│ ├── simple.rs
│ └── multi-file-example/
│ ├── main.rs
│ └── ex_module.rs
└── tests/
├── some-integration-tests.rs
└── multi-file-test/
├── main.rs
└── test_module.rs
从编码的角度来看,推荐的这个项目结构适合单体项目,比如微服务架构中某个服务的项目结构,它不适合复杂的项目。这是因为, 在这个项目结构中,只有一个 lib 和 bin,当项目中需要多个 lib 和多个 bin 的时候,这个目录结构就不够用了。而大部分项目,都是需要多个 lib 和多个 bin 的。
在 cargo 的定义中, 在业务逻辑比较复杂的项目中,一般需要通过 workspace 来组织多个 lib 和 bin。
那项目结构应该是什么样子呢?我们来看一下项目初始化后的目录结构,如下所示:
├── Cargo.toml # Cargo 的定义文件
├── LICENSE # 项目 的LICENSE 文件,比如Apache2.0
├── README.md # 项目说明 README文件
├── benches # 压测代码所在目录
├── bin # 项目启动命令存放的目录
├── build.rs # cargo中的构建脚本 build.rs。可参考这个文档:https://course.rs/cargo/reference/build-script/intro.html
├── config # 存放配置文件的目录
├── docs # 存放技术文档的目录
├── example # 存放项目调用 Demo 的项目
├── makefile # 用于编译打包项目的makefile文件
├── src # 源代码目录
│ ├── cmd
│ │ ├── Cargo.toml
│ │ ├── src
│ │ │ └── placement-center
│ │ │ └── server.rs
│ │ └── tests
│ │ └── test.rs
│ ├── placement-center
│ │ ├── Cargo.toml
│ │ ├── src
│ │ │ └── lib.rs
│ │ └── tests
│ │ └── test.rs
│ └── protocol
│ ├── Cargo.toml
│ ├── src
│ │ └── lib.rs
│ └── tests
│ └── test.rs
├── tests # 存放测试用例代码的文件
└── version.ini # 记录版本信息的文件
各个文件和目录的说明已经有标注,就不再赘述了。我们重点来看 src 目录的组织结构。这个目录结构得配合根目录的 cargo.toml 和子目录的 cargo 文件来解释,下面分别是根目录和子目录的 cargo 文件。
- 根目录 cargo.toml
[workspace]
members = [
"src/common/base",
"src/placement-center",
"src/cmd",
"src/protocol",
]
resolver = "2"
[workspace.package]
version = "0.0.1"
edition = "2021"
license = "Apache-2.0"
[workspace.dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
## workspaces members
placement-center = { path = "src/placement-center" }
cmd = { path = "src/cmd" }
protocol = { path = "src/protocol" }
common-base = { path = "src/common/base" }
- 子目录 cargo.toml
[package]
name = "cmd"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
[[bin]]
name = "placement-center"
path = "src/placement-center/server.rs"
这个项目结构的核心是:在项目的根目录通过 workspace 来组织管理 cmd、protocol、placement-center、common-base 等 4 个子项目。从定义上看,protocol、placement-center、common-base 是 lib 类型,分别完成相关业务逻辑,cmd 是 bin 类型。也就是说主入口 main 函数是写在 cmd/src/placement-center/server.rs 中的。
当然,在 cmd 中可以支持多个主入口 main 函数,来支持启动多个不同类型的服务。比如在最开始的架构图中有一个 Broker Server,我们就可以在 cmd 中的 toml 加一个 bin,如下所示:
[package]
name = "cmd"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
[[bin]]
name = "placement-center"
path = "src/placement-center/server.rs"
[[bin]]
name = "mqtt-broker"
path = "src/mqtt-broker/server.rs"
从实践的角度看, 对于大多数项目 ,这个 项目结构基本是通用的,可以直接复制。接下来,我们来看一下如何打包项目。
通过 Cargo build 编译打包
在 Rust 中,打包项目是一件很简单的事情,就是在项目根目录直接执行 cargo build 即可。基于上面的 cargo 文件,执行完会在 target/debug目录下生成一个 placement-center 文件,效果如下:
此时,你可以执行 ./placement-center 命令,它会调用 cmd/src/placement-center/server.rs 中的主入口 main 函数,从而启动服务。如下图所示,会输出:
Get Started
因为我们在 cmd/src/placement-center/server.rs 中的内容是:
fn main() { println!("Get Started"); }
这里有一点需要注意的是: 你必须先了解 Cargo 中 Profile 的含义。
Profile 是 Cargo 的一个功能,详细内容你可以参考这个文档 《发布配置 Profile》。Profile 默认包含 dev、 release、 test 和 bench 4 种配置项。正常情况下,我们无需去指定,Cargo 会根据我们使用的命令来自动进行选择。例如:
-
cargo build 自动选择 dev profile
-
cargo test 则是 test profile
-
cargo build --release 自动选择 release profile
-
cargo bench 则是 bench profile
从运行的角度,编译器会根据这 4 种不同的配置提供不同的优化机制,比如优化编译速度、优化运行速度等等。例如在开发时,我们需要更快的构建速度来验证代码。此时,我们可以牺牲运行性能来换取编译性能,所以应该选择 dev 模式。而在线上环境,我们希望代码运行得更快,可以接受编译速度降低,则需要选择 release 模式。
因为默认情况下是 dev 模式,所以我们在开发测试时,编译时可以直接使用:
cargo build
而发布线上包,则需要使用:
cargo build -- release
编译生成可执行的二进制文件后,不清楚你会不会有疑问。我们平时下载的开源软件包,一般是 .tar.gz 的形式,而且下载解压完成后的目录结构一般是下面这种形式:
.
├── bin
├── config
└── libs
在这种结构中,bin 目录一般放启动脚本,config 目录一般放配置文件,libs 一般放一些依赖的可执行文件。启动时通过 bin 中的启动脚本来启动服务。
那能通过 Cargo 打出这种形式的 tar.gz 的包吗? 答案是不行的。那应该怎样打出这种包呢?
从实践来看,如果要实现类似的效果,一般需要依赖 make 和 makefile 来完成打包。
通过 make 和 makefile 来编译打包
可以说, 是否掌握 make 和 makefile,某种程度上意味着你是否掌握了构建大型项目的能力。所以建议你要去了解一下 make 命令和 makefile 的语法。这块语法,网上教程很多,你直接搜一下即可,我就不推荐了。
从功能上看,make 是一个编译命令,makefile 是 make 命令的语法文件。当执行 make 命令的时候,会默认在当前目录下寻找名称为 makefile 的文件,解析文件内容,并执行完成编译。
接下来,来看我们项目的 makefile 文件,通过这个文件看一下应该如何写 makefile。
TARGET = robustmq-geek
BUILD_FOLD = ./build
VERSION:=$(shell cat version.ini)
PACKAGE_FOLD_NAME = ${TARGET}-$(VERSION)
release:
# 创建对应目录
mkdir -p ${BUILD_FOLD}
mkdir -p $(BUILD_FOLD)/${PACKAGE_FOLD_NAME}
mkdir -p $(BUILD_FOLD)/${PACKAGE_FOLD_NAME}/bin
mkdir -p $(BUILD_FOLD)/${PACKAGE_FOLD_NAME}/libs
mkdir -p $(BUILD_FOLD)/${PACKAGE_FOLD_NAME}/config
# 编译 release 包
cargo build --release
# 拷贝 bin目录下的脚本、config中的配置文件、编译成功的可执行文件
cp -rf target/release/placement-center $(BUILD_FOLD)/${PACKAGE_FOLD_NAME}/libs
cp -rf bin/* $(BUILD_FOLD)/${PACKAGE_FOLD_NAME}/bin
cp -rf config/* $(BUILD_FOLD)/${PACKAGE_FOLD_NAME}/config
chmod -R 777 $(BUILD_FOLD)/${PACKAGE_FOLD_NAME}/bin/*
# 将目录打包成.tar.gz 文件
cd $(BUILD_FOLD) && tar zcvf ${PACKAGE_FOLD_NAME}.tar.gz ${PACKAGE_FOLD_NAME} && rm -rf ${PACKAGE_FOLD_NAME}
echo "build release package success. ${PACKAGE_FOLD_NAME}.tar.gz "
test:
sh ./scripts/integration-testing.sh
clean:
cargo clean
rm -rf build
在上面的 makefile 中,我们定义了 release、test、clean 等 3 个 target,即我们的 make 支持下面三个命令:
make release # 编译项目并打包成名称为robustmq-geek-{***}-beta.tar.gz的安装包
make test # 通过脚本./scripts/integration-testing.sh 运行测试用例,
make clean # 清理编译文件
接下来我们用 release target 来讲一下 makefile 的语法。
-
首先,定义了 TARGET、BUILD_FOLD、VERSION、PACKAGE_FOLD_NAME 4 个变量,分别表示项目的名称、构建完成后的包的存放目录、包的版本、项目名称+版本号组成的安装包的名称。
-
release target 里面是一段 shell 代码,拆解开来主要有下面四部分逻辑:
-
创建对应目录
-
编译 release 包
-
拷贝 bin 目录下的脚本、config 中的配置文件、编译成功的可执行文件
-
将目录打包成 .tar.gz 文件
-
当我们写完 makefile 后,接下来就可以执行 make release 命令打包即可,打包过程如下:
打包完成后,会在 build 目录下生成一个 robustmq-geek-0.0.1-beta.tar.gz 安装包,解压后效果如下:
到这里,我们就完成了项目的初始化、编译、打包的整个流程了。
总结
tips:从本节课开始,每节课的代码都能在项目 https://github.com/robustmq/robustmq-geek 中找到源码,有兴趣的同学可以下载源码来看。
这节课的核心,我们完成了 如何组织项目结构 和 如何编译打包项目 两个工作。如果你要初始化一个项目,你直接按照这节课的思路去组织项目就可以了。
在组织复杂的 Rust 项目时,workspace 是需要重点关注的一个功能。另外在项目组织这块,你需要多去了解 Cargo 的各种语法。在 Cargo 中提供了很多好用的命令,比如 cargo bench 可以帮你压测代码的性能,cargo test 可以运行测试用例等等。
值得一提的是,Rust 在语言基础设施这块做得非常好,所以,当你熟悉了它的各种语法后,实际的工作量是很低的。
思考题
从这节课开始,我们的思考题换个方式。
我会在 https://github.com/robustmq/robustmq 项目中发布一些 good first issue 的任务,让你来完成,目的是让你有真正动手的机会,你可以选择自己感兴趣的任务来执行。当然如果你基础更好,也可以完成一些复杂的任务。当你完成自己认领的任务后,在评论区回复即可,我会找时间 check 一下大家的完成情况。
这里是本节课推荐的相关 issue 的任务列表,请点击查看 《Good First Issue》。 欢迎给我的项目 https://github.com/robustmq/robustmq 点个 Star 啊!