工程构建
包、create、模块和工作空间
Rust 程序由包组成。每个包都是一个 Rust 项目,包含一个独立的库或可执行文件的全部源代码,以及相关的测试、示例、工具、配置和其他东西。此外Rust还有模块的概念,模块既是 Rust 的命名空间,也是函数、类型、常量等构成 Rust 程序或库的容器。包主要解决项目间代码共享的问题,而模块主要解决项目内代码组织的问题。
包与模块分别对应Rust中两个重要的术语:crate和module。
包
在Rust中,包是指含有一个或多个 create,并带有一个Cargo.toml
文件的文件夹。Cargo.toml
文件描述了如何去构建这些 crate。
crate
crate可以翻译为箱,它是Rust的基本编译单元,分为可执行程序crate和库crate两种类型,每个 create 对应生成一个可执行文件或一个库二进制文件(.lib/.dll/.so/.exe
)。二进两者最大区别是,可执行程序crate有一个main
函数作为程序主入口,而库crate是一组可以在其他项目中重用的模块,没有main
函数。它们的入口文件分别是src/main.rs
和src/lib.rs
。
注意,如果一个包同时含有 src/main.rs
和 src/lib.rs
,则它有两个 crate:一个可执行程序和一个库的,且名字都与包相同。
模块
模块是用于在crate内部继续进行分层和封装的机制。模块内部又可以包含模块。Rust中的模块是一个典型的树形结构,每个crate会自动产生一个跟当前crate同名的模块,作为这个树形结构的根节点。
在crate内部创建模块有三种方式:
-
直接使用
mod
关键字在一个源文件中创建内嵌模块。模块内容使用大括号包裹。mod my_module{ pub fn items(){ // ...... } } // 调用模块函数。格式 模块名::函数名 my_module::items();
-
新建一个源文件就是新建一个模块。文件名即是模块名。
- 新建
m2.rs
pub fn func2(){ println!("func2"); }
- 必须在这个crate的入口处声明子模块(如果是库crate,入口是
lib.rs
文件,在该文件中声明;如果是可执行程序crate,入口是main.rs
文件),否则模块不会被当成该项目源码进行编译。
mod m2;
- 新建
-
新建一个文件夹来创建模块。文件夹名即模块名。文件夹内必须包含一个
mod.rs
文件,该文件就是此模块的入口。新建module3文件夹,并在文件夹中新建
mod.rs
、code1.rs
和code2.rs
源文件:// code1.rs pub fn method1(){}
// code2.rs pub fn method2(){}
在入口文件
mod.rs
中声明所有子模块:pub mod code1; pub mod code2;
在crate的入口处声明子模块(此处为
main.rs
文件):mod module3; fn main() { module3::code1::method1(); module3::code2::method2(); }
项目结构如下:
这里需要注意,模块内部元素的可见性默认都是私有的,只能在模块内部使用,如需公开被外部访问,则应添加关键字pub
修饰。如上例中,子模块、函数前都可以使用pub
修饰。
Rust为了更加准确的控制元素在哪一层可见,增强了pub
的用法,提供了pub(crate)
、pub(self)
、pub(super)
和pub(in xxx_mod)
语法:
pub(self) fn method1(){}
pub(crate) fn method2(){}
pub(in crate::module3) fn method3(){}
其中pub
后面的括号,表示限定的范围,如pub(self)
表示限定当前模块可见,那么被标记的函数就只能在当前模块及其子模块中使用。关于其他限定词,见后面的模块路径这章节。
导入与导出
上例中,我们使用module3::code1::method1();
这样的代码调用子模块中的函数,显得十分冗长,当嵌套层级更深时,代码可读性更差,不推荐此写法。要想代码更加简洁,可以有两种处理方法:
-
使用
use
导入子模块,并起一个别名fn main() { use module3::code2 as m3; m3::method2(); }
-
在子模块(
mod.rs
文件)中使用pub use
导出函数mod code1; pub mod code2; pub use self::code1::method1;
调用函数:
fn main() { module3::method1(); }
推荐使用pub use
导出函数的处理方式。这样,我们可以在mod.rs
文件中去除子模块的pub
修饰,从而对子模块进行封装,如上例,code1
模块是私有的,但它内部的method1
函数却被导出了。当一个大的模块由多个子模块构成时,我们可以控制只导出该模块的公开API,其内部的子模块构成无需暴露外部知晓。
关于use
用法小结:
-
use
语句可用大括号一次导入多个元素,且大括号可以嵌套use a::b::{c, d, e::{f, g::{h, i} } };
-
use
语句可以使用星号通配符导入 -
use
语句不仅用在模块中,还可以用在函数、trait
、impl
块等地方 -
use
语句可以使用as
关键字起别名
模块路径
Rust中,每个crate都是独立的基本编译单元。src/main.rs
或src/lib.rs
是crate的根模块,每个模块都可以用一个精确的路径来表示,形如:a::b::c
。与文件系统类似,模块的路径也分为绝对路径和相对路径。为此,Rust提供了crate
、self
和super
三个关键字来分别表示绝对路径和相对路径。
crate
关键字表示当前crate,crate::a::b::c
表示从根模块开始的绝对路径。
self
关键字表示当前模块,self::a
表示当前模块中的子模块a
。self关键字最常用的场景是“use a::{self, b};”,表示导入当前模块a及其子模块b。
super
关键字表示当前模块的父模块,super::a
表示当前模块的父模块中的子模块a
。模块路径中可以使用*
通配符,它会导入命名空间中所有公开的项,use a::*;
表示导入a模块中所有使用pub
标识的模块、函数和类型定义等。使用通配符导入极易引起命名冲突,请慎用!
包管理
Rust的一大特点是提供了一个现代化包管理工具Cargo。Cargo主要做了四件事:1.使用两个元数据(Cargo.toml
和Cargo.lock
)文件来记录各种项目信息;2.获取并构建项目的依赖关系;3.使用正确的参数调用rustc
或其他构建工具来构建项目;4.为Rust开发生态建立了统一标准的工作流。
如需了解
Cargo.toml
中的详细配置详细,查看清单文档。
Cargo常用命令
# 创建可执行程序包foo
cargo new foo --bin
# 创建一个库 bar
cargo new bar --lib
# 编译项目,在 target/debug 下生成一个可执行文件
cargo build
# 会进行优化,在 target/release 下生成一个高性能的用户版可执行程序
cargo build --release
# 编译并运行
cargo run
# 清理构建
cargo clean
第三方包
要在Rust中使用第三方包,只需在Cargo.toml
文件的[dependencies]
标签下面添加依赖的包即可:
[dependencies]
async-std="1.5.0"
在IDE中会自动更新,也可以手动执行命令cargo update
更新依赖。
像上面这样使用Rust社区的中央存储仓库的依赖包,必须指定一个版本号,关于版本号的常用规则如下:
Rust社区公开的第三方crate都在crates.io
网站,国内可以通过配置镜像源提高下载速度。镜像有两种配置方式,一种是全局配置,一种是根据项目配置。
全局镜像配置:
类Unix系统(Linux、MacOS)中,打开~/.cargo/config
文件(不存在则新建一个),添加如下内容:
[source.crates-io]
registry = "https://github.com/rust-lang/crates.io-index"
# 指定使用哪个镜像(这里指定的是ustc,如果速度不够,再切换tuna)
replace-with = 'ustc'
# 中国科学技术大学源
[source.ustc]
registry = "https://mirrors.ustc.edu.cn/crates.io-index"
# 清华大学源
[source.tuna]
registry = "https://mirrors.tuna.tsinghua.edu.cn/git/crates.io-index.git"
在Windows系统中,可以在命令行输入命令echo %USERPROFILE%\.cargo
查看路径,然后进入该路径下创建config
文件,添加以上配置。
项目镜像配置:
与全局配置不同,可以在当前项目中添加镜像配置,只对当前项目生效。
进入项目的根目录,查看是否存在Cargo.toml
文件,若存在,则新建.cargo
文件夹,并在文件夹中创建config
文件,添加上面面的配置即可。
注意,cargo管理的依赖包,可以来自中央存储仓库crates.io,一个Git存储库或者本地路径。
依赖 git 存储库
[dependencies]
rand = { git = "https://github.com/rust-lang-nursery/rand" }
甚至可以指定git分支或tag:
rand = { git = "https://github.com/rust-lang-nursery/rand", branch = "next" }
依赖本地路径
[dependencies]
hello_utils = { path = "../hello_utils" }
或者指定一个版本号
hello_utils = { path = "../hello_utils", version = "0.1.0" }
path
字段指定本地文件路径,可以是绝对路径也可以是相对路径。
workspace
假如我们现在有一个大型项目,需要把它拆分成了多个crate来组织,就会出现一个问题:不同的crate会有各自不同的Cargo.toml
文件,编译时它们会各自产生不同的Cargo.lock
,每个crate都有自己的构建目录target
,其中包含此crate所有依赖的独立构建。这些构建目录完全是独立的。即便两个crate有相同的依赖,也不能共享任何编译后的代码,无法保证它们使用相同版本号的依赖。为了让不同的crate之间能共享一些信息,cargo提供了一个workspace的概念,它还可以节省编译时间和磁盘空间。
一个workspace可以包含多个crate;所有crate共享同一个Cargo.lock
文件,共享同一个输出目录;workspace内的所有crate的公共依赖项都是相同版本。
要使用workspace,只需在项目根目录下创建一个 Cargo.toml
文件,然后将所有需要的crate配置进去:
[workspace]
members = [
"project1", "lib1"
]
接下来可以在根目录下执行cargo build
命令开始构建。
其项目结构如下:
需要注意,虽然每个crate都有自己的Cargo.toml
文件,可以配置各自的依赖,但是每个crate下面不会再生成一个Cargo.lock
文件,而是统一在workspace下生成Cargo.lock
文件。如果多个crate都依赖同一个外部库,那么它们都是依赖的同一个版本。
构建脚本
cargo工具还允许用户在正式编译之前执行一些自定义的逻辑,譬如调用gcc编译一个C库,根据某些配置,自动生成源码等等,此功能相当于C/C++语言中的构建脚本。
要使用这种构建脚本,可以在Cargo.toml
中配置一个build
属性:
[package]
# ......
build = "build.rs"
然后将自定义逻辑写在build.rs
文件中,当执行cargo build
命令时,cargo会先把这个build.rs
编译成一个可执行程序,然后运行该程序,执行完成后才真正开始编译项目。build.rs
里面也可以依赖其他的库。但需要在build-dependencies
标签下面指定:
[build-dependencies]
rand = "1.0"
cargo在执行这个程序之前已经预先设置了一些环境变量,这样就能通过这些环境变量读取当前crate的一些信息:
CARGO_MANIFEST_DIR
:当前crate的Cargo.toml
文件路径CARGO_PKG_NAME
:当前crate的名称OUT_DIR
:build.rs
的输出路径HOST
:当前rustc编译器的平台特性OPT_LEVEL
:优化级别PROFILE
:判断是release还是debug版本
创建工具
如果我们要在同一个项目中生成两个二进制可执行文件,可以新建一个 bin
文件夹,然后通过将源文件放在该目录中来生成另一个可执行文件。
结构如下:
foo
├── Cargo.toml
└── src
├── main.rs
└── bin
└── other_bin.rs
other_bin.rs
:
fn main(){
println!("other");
}
项目中默认的二进制可执行程序名称是 main
,通过执行cargo build --all
可以看到,输出目录中同时生成了两个可执行程序,main
和other_bin
。利用这一点,我们可以在bin
中添加另一个可执行程序代码,编写一个和项目相关的工具。
二进制项目的关注点分离
很多二进制项目都会面临同样的组织结构问题:它们将过多的功能、过多的任务放到了main函数中。对此,Rust社区开发了一套二进制程序关注点分离的指导性原则:
将程序拆分为main.rs
和lib.rs
,并将实际的业务逻辑放入lib.rs
。main.rs
负责运行程序,而lib.rs
则负责处理所有真正的业务逻辑。虽然你无法直接测试main
函数,但因为我们将大部分代码都移动到了lib.rs
中,所以我们依然可以测试几乎所有的程序逻辑。保留在main.rs
中的代码量应该小到可以直接通过阅读来进行正确性检查。
随着项目规模逐渐增长,你也许会发现自己的代码包越来越臃肿,并想要将它进一步拆分为多个代码包。针对这种需求,我们可以使用前文提到的工作空间(workspace)功能,它可以有效帮助开发者管理多个相互关联且需要协同开发的包。
关注公众号:编程之路从0到1
了解更多技术干货