第五章 项目结构
本章提供了一些关于如何构建Rust
项目的思路. 对于简单的项目来说, 通过cargo new
创建的项目结构通常不需要太多关注. 你可能会添加一些模块来拆分代码, 增加一些依赖来扩展功能, 也就仅此而已. 然而, 随着项目规模和复杂度的增加, 你会发现你需要超越这些结构. 也许你的crate
的编译时间已经失控了, 或者你需要条件性依赖, 或者你需要一个更好的持续集成策略. 在这一章中, 我们将探讨Rust
语言以及特别是Cargo
提供的一些工具, 它们能帮助你更轻松管理这些东西.
特性(Features)
features
是Rust
定制项目的主要工具. 从本质上讲, feature
只是一个构建标志, crate
可以传递给依赖项, 以增加可选功能. features
本身并没有任何语义--你可以自行决定某个feature
对你的crate
意味着什么.
通常我们通过三种方式使用features
: 启用可选依赖, 有条件地包含crate
的额外组件, 以及增强代码的行为. 请注意, 这些用途都是"增量式"的; features
可以扩展crate
的功能, 但它们通常不应用于删除模块, 替换类型或函数签名的操作. 这源于一个原则: 如果开发者仅在Cargo.toml
做了一个简单的修改, 比如添加一个新的依赖关系或启用一个feature
, crate
不应该因此而无法编译. 如果一个crate
有互斥的features
, 那么这个原则很快就会失效, 如果crate A依赖于crate C的一个features
, 而crate B依赖于C的另一个互斥的features
, 那么增加对crate B的依赖就会破坏crate A! 因此我们通常遵循这样的原则: 如果crate A能在某些features
成功编译crate C, 那么当crate C上启用所有features
时, 它也应能成功编译.
Cargo
在这一原则上执行得非常严格. 例如, 如果两个crates(A 和 B) 都依赖于crate C, 但它们各自在C上启用不同的features
, Cargo
将只会编译 crate C一次, 并启用A或B所需要的所有features
. 也就是说, 它会取A和B对C所请求features
的并集. 因此, 通常在Rust
的crate
中添加互斥的features
是困难的; 因为很有可能某两个依赖项会将依赖于具有不同features
的同一个crate
, 如果这些功能是互斥的, 最终下游crate
就会构建失败.
注意: 我强烈建议你在持续集成系统中配置检查, 以确保您的
crate
在任意features
组合下都能成功编译. 有一个可以帮助你完成这项工作的工具cargo-hack
, 你可以在https://github.com/taiki-e/cargo-hack
上找到它
定义和包含features
features
在Cargo.toml
中定义. 清单5-1展示了一个名为foo
的crate
示例, 它包含一个简单的feature
, 启用可选依赖项syn
.
#![allow(unused)] fn main() { [package] name = "foo" ... [features] derive = ["syn"] [dependencies] syn = { version = "1", optional = true } // 清单 5-1: 启用可选依赖项的`features` }
当Cargo
编译这个crate
时, 默认情况下它不会编译syn crate
, 从而减少了编译时间(通常是显著的). 只有当下游的组件需要使用derive
特性所启用的API, 并显式选择使用时, 才会编译syn
. 清单5-2显示了这样一个下游crate
如何启用derive
特性, 从而引入syn
依赖.
#![allow(unused)] fn main() { [package] name = "bar" ... [dependencies] foo = { version = "1",features = ["derive"] } // 清单 5-2: 启用依赖项的`features` }
有些features
使用得非常频繁, 因此crate
默认启用它们会更合理(而不是显式启用). 因此, Cargo
允许你为crate
定义一组默认启用的features
. 同样, 它也允许你禁用默认features
. 清单5-3显示了foo
如何使其derive
设为默认启用, 同时禁用syn
的一些默认features
, 仅启用derive
所需的features
.
#![allow(unused)] fn main() { [package] name = "foo" ... [features] derive = ["syn"] default = ["derive"] [dependencies.syn] version = "1" default-features = false features = ["derive", "parsing", "printing"] optional = true // 清单 5-3: 添加和选择默认`features`, 以及可选的依赖项 }
在这里, 如果某个crate
依赖于foo
, 并且没有显式禁用默认features
, 那么它会编译foo
的syn
依赖. 接着, syn
只会启用列出的那三个features
, 其它features
都不会启用. 通过这种方式禁用默认features
, 只启用真正需要的部分, 是减少编译时间的绝佳手段!
作为
features
的可选依赖项当你定义一个
feature
时, 等号后面的列表本身就是一个features
列表. 这听起来可能有点奇怪, 在清单5-3中,syn
是一个依赖项, 而不是一个features
. 事实证明,Cargo
将每个可选的依赖关系都变成了与该依赖同名的features
. 如果你试图添加一个与可选依赖同名的feature
, 你会发现这一点;Cargo
不允许这样做.Cargo
正在为features
和依赖提供不同的命名空间支持, 但在撰写本文时还没有稳定下来. 同时, 如果你想让一个features
以依赖命名, 你可以用package = ""
来重命名依赖, 以避免名称冲突. 一个feature
启用的features
列表也可以包括依赖的features
. 例如, 你可以写derive = ["syn/derive"]
来让你的derive
特性启用syn
依赖关系的derive feature
.
在你的代码中使用features
当使用features
时, 你需要确保你的代码只在依赖可用的情况下使用它. 如果你的feature
启用了一个特定的组件, 你需要确保如果该feature
未被启用, 该组件就不会被包括在内.
你可以使用条件编译来实现这一点, 它允许你使用注解来给出某段代码应该或不应该被编译的条件. 条件性编译主要通过#[cfg]
属性来表达. 还有一个密切相关的cfg!
宏, 它可以让你根据类似的条件改变运行时行为. 你可以用条件编译做很多巧妙的事情, 我们在本章后面会看到, 但最基本的形式是#[cfg(feature = "some-feature")]
, 它表示: 只有启用some-feature
特性时, 紧接着的代码才会被编译. 类似地, if cfg!(feature = "some-feature")
等同于if true
只有在启用 derive
时才会被编译(否则为false
).
#[cfg]
属性比cfg!
宏更常被使用, 因为cfg!
宏会根据feature
修改运行时行为, 这就很难保证features
是相加的. 你可以把#[cfg]
放在某些Rust
项目的前面--比如函数和类型定义、impl
块、模块和use
语句--也可以放在某些其他结构体字段、函数参数和语句前面. 但是#[cfg]
属性并不是哪里都能用; Rust
语言团队对它的使用位置进行了严格限制, 以防止条件编译带来过于奇怪且难以调试的情况.
记住, 修改API的某些公共部分可能会无意间使某个feature
变得不可叠加, 从而导致部分用户无法编译你的crate
. 你通常可以使用向后兼容的修改规则作为经验法则--例如, 如果你使一个枚举变量或一个公共结构字段成为某个feature
的条件, 那么该类型也必须用#[non_exhaustive]
来注解. 否则, 如果由于依赖关系树中的某个crate
添加了该feature
, 那么没有启用该feature
的依赖crate
可能就无法再进行编译.
注意: 如果你正在编写一个大的
crate
, 你期望用户只需要其中的一部分功能, 那你应该考虑用features
来保护较大的组件(通常是模块). 这样, 用户只会选择启用他们真正需要的部分, 也只会为这些部分付出编译成本.
工作区(Workspaces)
在Rust
中, Crates
扮演着多种角色--它们是依赖关系图中的节点, 是trait
一致性的边界, 也是编译features
的作用域. 因此, 每个crate
都被当作一个单独的编译单元来管理; Rust
编译器基本上把crate
当作一个大的源文件来编译, 一次性编译, 最终生成一个单一的二进制输出(可以是二进制或库).
虽然这简化了编译器的许多方面, 但它也意味着大的crate
在工作时可能会很痛苦. 如果你在应用程序的某个部分修改了一个单元测试、一个注释或一个类型, 编译器必须重新评估整个crate
, 以判断是否有实际变动. 在内部, 编译器实现了一些机制来加速这一过程, 如增量重新编译和并行代码生成, 但归根结底, crate
的大小仍然是影响项目编译时间的一个重要因素.
正因如此, 随着项目规模的扩大, 你可能希望将其拆分为多个内部相互依赖的crate
. Cargo
提供了正好适用于这种情况的机制: 工作区(workspace
). 工作区是一组crate
(通常称为子crate
)的集合, 由一个顶级的Cargo.toml
文件组织起来, 如清单 5-4 所示.
#![allow(unused)] fn main() { [workspace] members = [ "foo", "bar/one", "bar/two", ] // 清单 5-4: 工作空间 }
members
数组是一个目录列表, 每个目录都包含工作区中的crate
. 这些crate
都在各自的子目录中都有独立的Cargo.toml
文件, 但它们共享一个Cargo.lock
文件和一个输出目录. crate
的名字不需要与member
中的条目完全一致. 虽然不是强制要求, 一个工作区中的crate
共享一个名称前缀是很常见的, 但不是必须的, 这个前缀通常选择主crate
的名称. 例如, 在tokio
这个crate
中, 成员被称为tokio
、tokio-test
、tokio-macros
等.
工作区最重要的特性之一是: 你可以在工作区的根目录下调用Cargo
来操作工作区的所有成员crate
. 想检查它们是否都能编译, Cargo check
会检查它们. 想运行所有的测试? cargo test
会对它们进行测试. 这并不像把所有东西都放在一个crate
里那么方便, 所以不建议将项目拆得过于细碎, 但工作区已经是一个非常不错的折中方案了.
注意:
Cargo
命令通常会在工作区执行"正确的操作". 如果你需要手动消除歧义(比如工作区的crate
都有一个同名的二进制文件), 请使用-p
标志(指明具体package
). 如果你在一个特定工作区的子目录中, 你可以通过--workspace
来执行整个工作区的命令.
一旦你有了工作区级别的Cargo.toml
和members
数组, 你就可以使用路径依赖来设置你的crate
依赖关系, 如清单5-5所示.
#![allow(unused)] fn main() { bar/two/Cargo.toml [dependencies] one = { path = "../one" } bar/one/Cargo.toml [dependencies] foo = { path = "../../foo" } // 清单 5-5: 工作区`crate`之间的`crate`依赖关系 }
现在, 如果你对bar/two
中的crate
做了改变, 那么只有这个crate
会被重新编译, 因为foo
和bar/one
并没有改变. 甚至从头开始编译整个项目可能会更快, 因为编译器不需要为了优化机会而评估整个项目的源代码.
指定工作区内部的依赖关系
在工作区中的一个
crate
依赖于另一个crate
, 最直接的方法是使用path
路径指定器, 如清单5-5所示. 然而, 如果你的crate
是打算公开发布供他人使用的, 你可能更倾向于使用版本来指定依赖关系.假设你有一个
crate
, 它通过one = { git = "..." }
依赖了bar
工作区中的one crate
(见示例 5-5), 同时又通过foo = "1.0.0"
依赖了已经发布的foo crate
(同样来自bar
).Cargo
按规则获拉取包含整个bar
工作区的Git仓库, 并看到该仓库又依赖于工作区中位于.../../foo
的foo
. 但Cargo
不知道发布的版本foo = "1.0.0"
和Git仓库中的foo
是同一个crate
! 它会把它们当成两个完全不同的依赖.你可能已经猜到接下来会发生什么了. 如果你尝试将来自
foo
(1.0.0)的某个类型, 用在one
的某个API上, 而该API接受的是来自foo
的同名类型, 编译器会拒绝编译你的代码. 即使这些类型有名字一样, 编译器也不能知道它们是同一个底层类型. 而用户会被彻底搞糊涂, 因为编译器会报错类似: "expectedfoo::Type
, gotfoo::Type
".解决这个问题的最佳方式是: 只有在子
crate
依赖未发布的更改时才使用路径依赖. 只要one
与foo
(1.0.0)一起工作, 它的依赖项中就应该写foo = "1.0.0"
. 只有当你对foo
做了one
需要的修改时, 才应该让one
改为使用路径依赖. 一旦你发布了one
可以依赖的新版本的foo
, 你就应该将路径依赖移除, 恢复为正常的版本依赖.这种方法也有其不足之处. 现在, 如果你改变了
foo
, 然后运行one
的测试, 你会看到一个将使用旧的foo
进行测试, 这可能不是你所期望的. 你可能想配置你的持续集成基础设施, 使其在以下两种场景下分别测试每个子crate
: 一种是使用其他子crate
的最新发布版本, 另一种是将所有子crate
配置为使用路径依赖.
项目配置
运行cargo new
后, 你会得到一个最小的Cargo.toml
, 其中有crate
的名称、版本号、一些作者信息和一个空的依赖列表. 这已经能满足大多数基本需求, 但随着项目的发展, 你可能想在Cargo.toml
中添加一些有用的配置项.
包元数据(Crate Metadata)
首先要添加到Cargo.toml
中基础的内容, 就是Cargo
支持的元数据指令. 除了description
和homepage
等字段外, 还可以包括一些额外信息, 如crate
的README
文件路径、用cargo run
时默认运行的二进制文件(通过default-run
指定), 以及用于帮助cates.io
对crate
进行分类的额外关键词(keywords
)和类别(categories
).
对于项目结构更复杂的crate
, 设置include
和exclude
这些元数据字段也非常有用. 它们决定了哪些文件会被包含进最终发布的包中. 默认情况下, Cargo
会包含crate
目录下的所有文件, 除了你的.gitignore
文件中列出的文件, 但如果你在目录下还有大型测试数据、无关脚本或其他辅助数据, 而你又希望它们处于版本控制之下, 这种默认行为可能并不是你想要的. 顾名思义, include
允许你仅包含特定文件集合, 而exclude
允许你排除符合特定模式的文件.
注意: 如果你有一个
crate
不应该被发布, 或者只应该被发布到某些替代的注册中心(也就是说, 不是发布到crates.io
), 你可以将publish
指令设置为false
或者允许的注册中心列表.
你可以使用的元数据指令的列表在不断增加, 所以请定期查看Cargo
参考资料中的清单格式页面(https://doc.rustlang.org/cargo/reference/manifest.html)
.
构建配置(Build Configuration)
Cargo.toml
还可以让你控制Cargo
如何构建你的crate
. 最常用的工具是build
参数, 它允许你为crate
编写一个完全自定义的构建程序(我们将在第11章再次讨论这个问题). 然而, Cargo
还提供了两个较小但非常实用的机制, 我们将在这里探讨: patches
和profiles
.
[patch]
Cargo.toml
的 [patch]
部分允许你为某个依赖临时指定一个不同的来源, 无论这个被修补的依赖出现在你的依赖中的哪个位置. 当你需要针对某个经过修改的传递依赖版本来编译你的crate
, 以测试BUG修复、性能改进或即将发布的新次版本时, 这种功能非常宝贵. 清单5-6展示了一个如何临时使用一组依赖关系的变体的标例子.
#![allow(unused)] fn main() { [patch.crates-io] use a local (presumably modified) source regex = { path = "/home/jon/regex" } use a modification on a git branch serde = { git = "https://github.com/serde-rs/serde.git", branch = "faster" } patch a git dependency [patch.'https://github.com/jonhoo/project.git'] project = { path = "/home/jon/project" } // 清单 5-6: 使用 `[patch]` 重写`Cargo.toml`中的依赖源 }
即使你对某个依赖进行了patch
, Cargo
也会注意检查crate
的版本, 避免你不小心patch
到错误的主版本. 如果出于某种原因, 你通过传递依赖使用了同一个crate
的多个主版本, 你可以通过为每个版本指定不同的标识符来分别patch
它们, 如清单5-7中所示.
#![allow(unused)] fn main() { [patch.crates-io] nom4 = { path = "/home/jon/nom4", package = "nom" } nom5 = { path = "/home/jon/nom5", package = "nom" } // 清单 5-7: 使用 `[patch]` 在`Cargo.toml`中重写同一`crate`的多个版本 }
Cargo
会查看每个路径中的Cargo.toml
, 意识到/nom4
包含主版本4, /nom5
包含主版本5, 并相应地对这两个版本进行patch
. package
关键字告诉Cargo
在这两种情况下以nom
的名字来寻找一个crate
, 而不是像默认情况下那样使用依赖标识符(即等号左边的部分). 你在常规依赖中也可以用这种方式使用package
来为依赖重命名.
请记住, 当你发布一个crate
时, patch
不会被包含在上传文件包中. 依赖于你的crate
的crate
将只使用它自己的[patch]
部分(即使是空的), 而不是你的crate
的[patch]
部分.
crates VS. packages
你可能想知道
package
和crate
的区别是什么. 这两个术语在非正式场合经常交替使用. 这取决于你是在谈论Rust
编译器、Cargo
、crates.io
还是其他什么内容, 它们有着特定的定义. 我个人认为,crate
是一个Rust
模块的层次结构, 从一个根.rs
文件开始(在这个文件中你可以使用crate
级别的属性, 如#![feature]
)--通常是lib.rs
或main.rs
这样的文件. 相比之下,package
是一个包含多个crate
和元数据的集合, 本质上就是由Cargo.toml
文件描述的所有内容. 这可能包括一个库crate
、多个二进制crate
、一些集成测试crate
, 甚至可能包含多个拥有自己Cargo.toml
文件的workspace
成员.
[profile]
[profile]
配置段允许你向Rust
编译器传递额外的选项, 以改变crate
编译方式. 这些选项主要分为三类: 性能选项、调试选项, 以及以用户定义的方式改变代码行为的选项. 这些选项在调试模式(debug mode)或发布模式(release mode)有不同的默认值(其他模式也存在).
三个主要的性能选项是opt-level
、codegen-units
和lto
. opt-level
选项通过告诉编译器优化你的程序来调整运行时的性能(0表示"不优化", 3表示"尽可能优化"). 设置越高, 代码优化程度越高, 可能会使代码运行得更快. 但是, 额外的优化会增加编译时间, 这就是为什么优化通常只在发布版本中启用.
注意: 你也可以将
opt-level
设置为"s", 以优化二进制大小, 这在嵌入式平台上可能很重要.
codegen-units
选项涉及的是编译时性能. 它告诉编译器可以将单个crate
的编译分成多少个独立的编译任务(即代码生成单元). 一个大型crate
的编译被分割成越多的部分, 编译就越快, 因为可以有更多的线程平行编译crate
. 不幸的是, 为了实现这种加速, 这些线程大致都是独立工作, 这意味着代码优化受到影响. 例如, 想象一下, 在一个线程中编译的crate
时, 可能会受益于将另一个crate
中的一些代码的内联进来--因为这两个crate
是独立的, 所以内联就无法发生! 那么, 这个设置在编译时间性能和运行时性能之间做出了权衡. 默认情况下, Rust
在调试模式下使用无限制数量的codegen
单元(基本上就是"尽快编译"), 在发布模式下使用较少的数量(在撰写本文时为16).
lto
设置开启了链接时优化(LTO), 它允许编译器(或者更准确地说, 是链接器)对原本是分开编译的程序部分(称为编译单元)进行联合优化. LTO的具体细节超出了本书的范围, 但其基本思想是, 每个编译单元的输出会包含关于其源代码的额外信息. 在所有单元被编译完成后, 链接器会再次遍历它们, 并使用这些附加信息对整个合并后的可执行代码进行优化. 这个额外的过程会增加编译时间, 但可以回收因为拆分编译单元而损失的大部分运行时性能. 特别是, LTO可以为那些对性能敏感的程序提供显著的性能提升, 尤其是在能从跨crate
情况下的优化中获益. 不过要注意的是, 跨crate
的LTO可能会大大增加编译时间.
Rust
默认会在每个crate
内的所有编码单元中执行LTO, 试图弥补因使用多个编码单元而造成的优化损失. 由于LTO仅限单个crate
内执行, 而不是跨crate
, 所以这个额外的过程并不繁琐, 而且增加的编译时间应该低于使用大量编码单元所节省的时间. Rust
还提供了一种被称为"thin LTO"的技术, 它的优势是LTO优化过程大部分可以并行执行, 缺点是会漏掉一些完整LTO能捕捉到的优化机会.
注意: LTO 在很多情况下也可以用来跨越外部函数接口的边界进行优化. 参见
linker-plugin-lto rustc
标志以了解更多细节.
[profile]
配置段还支持一些有助于调试的标志, 如debug
, debug-assertions
和overflow-checks
.
debug
标志告诉编译器在编译的二进制文件中包含调试符号. 这增加了二进制文件的体积, 但这意味着在栈回溯或性能分析中, 你能看到函数名称等信息, 而不仅仅是指令地址.debug-assertions
标志启用了debug_assert!
宏和其他相关的调试代码, 否则不会被编译(通过cfg(debug_assertions)
). 这样的代码可能会使你的程序运行变慢, 但在运行时更容易捕捉到可疑行为.- 溢出检查(
overflow-checks
)标志, 顾名思义, 用于启用整数运算的溢出检查. 这会让整数运算变慢(注意到一个趋势了吗?), 但能帮助你更早地发现棘手的 bug.
默认情况下, 这些都是在调试模式下启用, 在发布模式下禁用.
[profile.*.panic]
[profile]
配置段还有一个值得单独讨论的标志: panic
. 这个选项决定了当你的程序中的代码调用panic
(无论是直接调用还是通过类似unwrap
的方式间接触发)时会发生什么. 你可以将panic
设置为unwind
(大多数平台上的默认值)或abort
. 我们将在第9章中更多地讨论panic
和unwinding
, 但我将在这里做一个简单的概述.
通常在Rust
中, 当你的程序发生panic
时, 解发panic
的线程开始展开(unwinding
)它的调用栈. 你可以将展开堆栈理解为: 从当前函数开始, 一层层强制返回, 一直到该线程调用栈的底部. 也就是说, 如果main
调用了foo
, foo
调用了bar
, 而bar
调用了baz
, 那么baz
中的panic
将强行从baz
返回, 然后是bar
, 然后是foo
, 最后是main
, 最终程序退出. 展开调用栈的线程会按照正常方式销毁堆栈上的所有值, 这样它们就有机会清理资源、报告错误等. 这种机制使得即使发生panic
, 系统仍有机会优雅地退出.
当某个线程发生panic
并展开调用栈时, 其他线程不会受到影响, 会继续运行. 只有当(并且仅当)运行main
的线程退出时, 整个程序才会终止. 也就是说, panic
通常只影响发生panic
的线程.
这意味着栈展开(unwinding)是一把双刃剑; 程序可能会在某些组件失败的情况下继续运行, 从而引发各种奇怪的行为. 举个例子, 一个线程在更新Mutex
中的状态到一半时发生了panic
. 接下来任意获得该Mutex
的线程都必须准备好处理这一个"部分更新、不一致"的状态. 因此, 一些同步原语(如Mutex
)会记住它们上次访问时是否发生过panic
, 并在之后有线程尝试访问时将这个信息传达出去. 如果某个线程遇到了这样的状态, 它通常也会发生panic
, 这将导致一连串的panic
, 最终导致整个程序终止. 但这通常比在损坏的状态下继续运行要好!
支持栈展开(unwinding
)所需的"账日管理"(bookkeeping)并不是免费的, 通常还需要编译器和目标平台的特别支持. 例如, 许多嵌入式平台根本无法高效地进行栈展开操作. 因此, Rust
支持另一种panic
模式: abort
, 它确保当panic
发生时, 整个程序立即退出. 在这种模式下, 没有任何线程能够进行清理操作. 这看起来很严重, 而且确实如此, 但它确保了程序永远不会在"半工作"状态下运行, 而且同时也能立即暴露错误.
警告
panic
设置是全局性的--如果你把它设置为abort
, 你所有的依赖也会被编译成abort
.
你可能已经注意到, 当一个线程panic
时, 它往往会打印一个回溯: 及导致panic
发生的函数调用轨迹. 这也是一种栈展开(unwinding)的形式, 尽管它与我们之前讨论的panic
行为中的栈展开是两个不同的机制. 你可以通过给rustc
传递-Cforce-unwind-tables
即使在panic=abort
情况下也可以获取回溯信息, 这样会让rustc
在生成代码时包含必要的栈信息, 从而支持栈回溯, 即便程序仍然会在panic
时直接终止.
配置文件覆盖(PROFILE OVERRIDES)
你可以使用配置覆盖(profile overrides)机制, 为特定的依赖关系或特定的
profile
设置profile
选项. 例如, 清单5-8显示了如何使用[profile.<profile-name>.package.<crate-name>]
语法, 在调试模式下为serde crate
启用积极的优化, 为所有其他crate
启用适度的优化.#![allow(unused)] fn main() { [profile.dev.package.serde] opt-level = 3 [profile.dev.package."*"] opt-level = 2 // 清单 5-8: 覆盖特定依赖关系或特定模式的配置文件选项 }
如果某些依赖在调试模式下运行非常慢, 比如解压缩或视频编码, 而你可能需要对其进行优化, 以便你的测试套件不至于花上几天时间来完成, 那么这种优化覆盖机制就非常有用. 你也可以在
~/.cargo/config
的Cargo
配置文件中使用[profile.dev]
(或类似)部分来指定全局配置文件的默认值.当你为一个特定的依赖关系设置优化参数时, 请记住, 这些参数只适用于作为该
crate
的一部分被编译的代码; 例如, 如果本例中的serde
有一个通用的方法或类型被你的crate
中使用, 该方法或类型的代码将在你的crate
中被单态化和优化, 此时生效的是你自己crate
的构建配置, 而不是serde
的构建配置覆盖项.
条件编译
你写的大多数Rust
代码都是通用的--无论运行在哪种CPU或操作系统上, 它们的行为都是一致的. 但有时你必须做一些特殊处理, 让代码能在Windows、ARM芯片等平台上正常运行, 或者在针对特定平台的应用二进制接口(ABI)进行编译时也需如此. 或者你想在某个CPU指令可用时, 编写该函数的优化版本, 或者在持续集成(CI)环境中禁用某些缓慢但无关紧要的初始化代码. 为了应对这些情况, Rust
提供了条件编译机制, 即只有在编译环境的某些条件为真时, 才会编译一个特定的代码段.
我们用cfg
关键字来表示有条件的编译, 你在本章前面的"在你的crate
中使用features
"中已经见过. 它通常以#[cfg(condition)]
属性的形式出现, 意思是: 只有在condition
为真时, 才会编译紧随其后的代码项. Rust
还提供了#[cfg_attr(condition, attribute)]
, 如果condition
成立, 则编译为 #[attribute]
, 否则什么也不做. 你也可以使用cfg!(condition)
宏将cfg
条件作为布尔表达式求值.
每个cfg
构造都接受一个由选项组成的条件, 比如feature = "some-feature"
, 还可以使用组合器all
、any
和not
, 它们的行为与你所期望的类似. 选项要么是简单的名字, 比如unix
, 要么是键/值对, 比如feature = "some-feature"
, 后者在features
条件中常见.
你可以依据很多有趣的选项来控制代码是否被编译. 下面我们将按使用频率从高到低一一介绍这些选项:
Feature选项
你已经看过这些示例了. Feature
选项的形式是feature = "name-of-feature"
, 如果指定的特性被启用, 该条件就会被视为为真. 你可以通过组合器在一个条件中检查多个feature
. 例如, any(feature = "f1", feature = "f2")
表示只要功能f1
或f2
中有一个被启用, 该条件就为真。
操作系统选项
这些使用键值对语法, 键为target_os
, 值为windows
、macos
和linux
. 你也可以用target_family
指定一个操作系统系列, 它的值是windows
或unix
. 这些都很常见, 以至于有了简写形式, 因此你可以直接使用cfg(windows)
和cfg(unix)
. 例如, 如果你想让一个特定的代码段只在macOS
和Windows
上编译, 你可以这样写: #[cfg(any(windows, target_os = "macos"))]
.
上下文选项
这些选项可以让你根据特定的编译上下文来调整代码. 其中最常见的是test
选项, 只有当crate
使用测试配置文件, 编译时才为true. 请记住, test
仅对当前正在测试的crate
生效, 而不包括它的任何依赖项. 这也意味着, 在运行集成测试时, test
不会在你的crate
中被设置; 是那些集成测试文件在使用测试配置文件编译, 而你的实际crate
按正常方式编译(也就是没有设置test
). 这同样适用于doc
和doctest
选项, 它们仅在生成文档或编译文档中的测试代码(doctest
)时才被设置. 还有debug_assertions
选项, 它在调试模式(debug mode)下默认被启用.
Tool 选项
一些工具, 比如clippy
和Miri
, 会设置自定义选项(稍后会详细介绍), 你可以借助这些选项在工具运行时自定义编译行为. 通常, 这些选项是以相关的工具命名的. 例如, 如果你希望某个计算密集型的测试在Miri
下不运行, 你可以给它一个属性#[cfg_attr(miri, ignore)]
.
架构选项
这些选项允许你根据编译器所针对的CPU指令集来进行编译. 你可以用target_arch
来指定一个特定的架构, 它的值是x86
、mips
和arch64
, 或者你可以用target_feature
来指定一个特定的平台特性, 它的值是avx
或sse2
. 对于非常低层的代码, 你可能还会发现target_endian
和target_pointer_width
选项很有用.
编译器选项
这些选项允许你根据所编译的平台ABI调整代码, 并且可以通过target_env
的值(如gnu
、msvc
和musl
)获得. 由于历史原因, 这个值通常是空的, 尤其是在GNU
平台上. 通常只有在你需要直接与环境ABI交互时才会用到这个选项, 例如当使用#[link]
与ABI特定的符号名链接时.
虽然cfg
条件通常是用来自定义代码的, 但有些也可以用来自定义依赖关系. 例如, 依赖关系winrt
通常只在Windows上有意义, 而nix crate
可能只在基于Unix的平台上有用. 清单5-9给出了一个如何使用cfg
条件的例子.
#![allow(unused)] fn main() { [target.'cfg(windows)'.dependencies] winrt = "0.7" [target.'cfg(unix)'.dependencies] nix = "0.17" // 清单 5-9: 条件性依赖 }
在这里, 我们指定只有在cfg(windows)
(在Windows上)条件下winrt
版本0.7才会被视为依赖项, 而只有在cfg(unix)
(在Linux、macOS和其他基于Unix的平台上)条件下nix
版本0.17才会被视为依赖. 需要注意的一点是, [dependencies]
部分是在构建过程的早期就被评估, 此时只有某些cfg
选项可用. 特别是, feature
和上下文选项在这个时候还不能用, 因此无法使用此语法根据feature
和上下文拉取依赖项. 然而, 你可以使用仅依赖于目标或架构的任何cfg
, 以及由调用rustc
的工具显式设置的任何选项(如cfg(miri)
).
注意: 在我们讨论依赖性规范的时候, 我强烈建议你的CI基础设施, 使用
cargo-deny
和cargo-audit
等工具对你的依赖性进行基本审计. 这些工具可以检测到以下情况: 你间接依赖某个依赖项的多个主版本, 你依赖了已经无人维护或有已知安全漏洞的crate
, 或者你使用了可能不希望采用的许可证, 使用此类工具是一种自动化地提升代码质量的绝佳方式.
添加自定义条件编译选项也非常简单. 你只需要确保当rustc
编译你的crate
时, 传递了--cfg=myoption
参数即可. 最简单的方法是将你的--cfg
添加到RUSTFLAGS
环境变量中. 这在CI中很有用, 你可能想根据你的测试套件是在CI上还是在开发机器上运行来定制测试行为: 在你的CI设置中把--cfg=ci
添加到 RUSTFLAGS
, 然后在你的代码中使用cfg(ci)
和 cfg(not(ci))
. 这样设置的选项也可以在Cargo.toml
的依赖关系中使用.
版本控制(Versioning)
所有的Rust
的crates
都是有版本的, 并应遵循Cargo
对语义化版本控制的实现方式. 语义化版本控制定义了不同类型的变更需要如何对应版本增长, 以及哪些版本之间是兼容的, 以何种方式兼容. RFC 1105
标准本身非常值得一读(并不晦涩难懂), 但总结起来, 它将更改分为三类: 破坏性更改, 需要增加主版本号; 功能新增, 需要增加次版本号; 以及BUG修复, 只需要增加补丁版本. RFC 1105
很好地概述了什么是Rust
中的破坏性更改进行了清晰的界定, 本书其他部分也涉及了一些相关内容.
这里我不会详细讲解各种变更类型的具体语义. 我想强调的是, 在Rust
生态系统中, 版本号出现的一些不那么直观的情况, 这些情况在你给自己的crate
决定版本号时需要牢记在心.
最小支持的Rust
版本
第一个Rust
特有的问题是最小支持的Rust
版本(MSRV
). 关于项目在MSRV
和版本控制方面应遵循什么策略, Rust
社区内部有很多争议, 而且没有真正"完美"的答案. 问题的核心在于, 一些Rust
用户只能使用旧版本的编译器, 通常是在企业环境中, 他们往往没有升级的选择权. 如果我们持续使用新稳定下来的API, 这些用户将无法编译我们的crates
, 从而被迫"掉队".
crate
作者可以使用两种技术, 来让处于这种情况的用户的生活稍微轻松一些. 第一种是建立一个MSRV
策略, 承诺crate
的新版本始终能够与过去X个月的任一稳定版本一起编译. 具体的时间跨度因项目而异, 但通常是6或12个月. 鉴于Rust
每六周发布一个稳定版本, 这相当于支持最近的4或8个稳定版本. 项目中引入的任何新代码都必须能用MSRV
编译器成功编译(通常由CI检查), 否则就需要推迟合并, 直到MSRV
策略允许其合并为止. 这有时确实会很麻烦, 因为这意味着这些crates
无法利用语言中最新和最强大的特性, 但这确实能给你的用户带来更多便利.
第二个做法是: 确保在MSRV
发生变化时, 增加你的crate
的次要版本号. 因此, 如果你发布了crate
的2.7.0版本, 并将你的MSRV从Rust 1.44
提升到Rust 1.45
, 那么一个受限于Rust 1.44
的项目就可以通过使用依赖版本规范version = "2, <2.7"
来继续使用你旧版本的crate
, 这样该项目就能继续正常工作, 直到它有机会升级到Rust 1.45
上. 重要的是, 你要增加次版本号, 而不仅仅是补丁版本号, 这样, 如果有必要, 你仍然可以为旧的MSRV
版本发布新的补丁版本, 以修复关键的安全问题.
有些项目对MSRV
(最低支持的Rust
版本)的支持非常严肃, 以至于他们把MSRV
的变化是视为破坏性的变更, 并相应的提升主版本号. 这意味着下游项目必须主动选择接收MSRV
变化, 而不是默认接受然后再想办法"退出", 但这也意味着那些 对MSRV
没有严格要求的用户, 如果不升级依赖, 就无法获得未来的bug修复, 这可能需要他们也发布一个破坏性的变化. 正如我所说, 没有一种方案是完全没有缺点的.
在当前的Rust
生态系统中, 强制执行MSRV
是一项挑战. 只有少部分crate
提供了MSRV
保证, 即使你的依赖项做到了, 你也需要不断监控它们何时提升了自己的MSRV
. 一旦发生这种情况, 你就必须发布你自己的crate
的新版本, 用之前提到的方式来限制依赖的版本范围, 以确保你的MSRV
不会因此间接发生变化. 这反过来可能迫使你放弃一些依赖库的安全性或性能更新, 因为你必须继续使用旧版本, 直到你的MSRV
策略允许升级为止. 而且这个决定还会传导到依赖你crate
的下游项目. 社区已经提出将MSRV
检查机制直接集成到Cargo
中, 但截至目前, 还没有稳定的、可用的实现.
最小的依赖版本
当你第一次添加依赖时, 通常很难判断应该为该依赖指定什么样的版本号. 程序员通常会选择最新的版本, 或者只选择当前的主版本号, 但很可能这两种选择都是不合适的. 我所说的"错误"并不是指你的crate
不能编译, 而是说这种选择在未来可能会给使用你crate
的用户带来麻烦. 我们来看一下, 这两种做法各自存在什么问题.
首先, 考虑这样一种情况: 你添加了hugs = "1.7.3"
的依赖, 这是当前最新发布的版本. 现在, 假设有另一个开发者依赖了你的crate
, 但他们也依赖于另一个crate foo
, 而foo
本身也依赖于hugs
. 再进一步设想, foo
的作者非常严格的遵循MSRV
策略, 因此他们的依赖hugs = "1, <1.6"
. 这时你就会遇到问题了. Cargo
在处理依赖时, 看到你的声明的是hugs = "1.7.3"
时, 它只考虑>=1.7
的版本. 但是它又发现foo
对hugs
的依赖要求<1.6
, 于是Cargo
会放弃并报错, 并报告说没有哪个hugs
版本可以同时满足所有依赖的需求.
注意: 实际上, 有很多原因会导致某个
crate
明确不希望使用某个依赖的较新版本. 最常见的原因是为了执行MSRV
策略, 另一个是满足企业审计要求(较新的版本将包含未被审计的代码), 还包括确保可重现构建(即始终使用精确列出的依赖版本).
这就很不幸了, 因为你的crate
可能实际上在hugs 1.5.6
中能正常编译. 甚至可能在任何1.X版本中都能正常工作. 但如果你使用的是某个最新的小版本号, 你就相当于告诉Cargo
: 只考虑该小版本及其之后的版本. 那是不是说, 应该改用hugs = "1"
来解决呢? 不, 这也不完全正确. 你的代码确实依赖于hugs 1.6
中添加的东西, 因此虽然1.6.2
没有问题, 但1.5.6
就不行了. 如果你一直在一个能使用新版本hugs
的环境中编译你的crate
, 你可能完全不会注意到这个问题, 但如果依赖图中的某个crate
指定hugs = "1, <1.5"
, 你的crate
就无法编译了.
正确的策略是列出包含你crate
所依赖所有功能的最早版本, 并且在你不断为crate
添加新代码时, 也要确保这个前提仍然成立. 但除了翻changelog
或者靠试错法, 你怎么确定这个最小版本呢? 你最好的办法是使用Cargo
的不稳定的功能-Zminimal-versions
参数, 这个参数让Cargo
使用所有依赖项的最小可接受版本, 而不是最大版本. 接着, 把你的所有依赖项都设置为只指定最新的主版本号, 尝试编译, 对那些无法编译通过的依赖项添加具体的小版本号, 重复这个过程, 直到所有依赖都能顺利编译, 你现在有了最低版本要求.
值得注意的是, 就像MSRV
一样, 最小的版本检查也面临着一个生态系统的采用纳的问题. 即使你已经正确设置了所有版本号约束, 你依赖的那些项目可能并没有这么做. 这导致Cargo
的最小版本检查功能在实践中难以使用(这也是为什么它至今仍是不稳定功能的原因). 如果你依赖foo
, 而foo
依赖bar
, 并指定bar="1"
, 而实际上它需要bar="1.4"
, 那么无论你怎么指定 foo
, Cargo
都会报告编译foo
失败, 因为-Zminimal-versions
标志会让Cargo
总是选择最小可接受版本. 你可以通过在你的依赖列表中显式添加bar
, 并指定正确的版本来解决这个问题, 但这种变通方式设置起来很麻烦, 维护起来也痛苦. 你可能不得不显式列出大量仅通过间接依赖引入的库, 而且你还得不断维护更新这些版本信息.
注意: 目前有一个提义: 引入一个标志, 即对当前的
crate
倾向于使用最小的版本, 但对依赖项倾向于使用最大的版本, 这个方案似乎看起来非常有前景.
变更记录(Changelogs)
除了最简单的crate
, 我强烈建议你维护一份变更日志. 没有什么比看到某个依赖升级了主版本, 然后还得翻Git提交日志来搞清楚到底改了什么、你的代码要怎么改更让人沮丧的了. 我建议你不要只是把Git提交日志原封不动地塞进一个叫changelog
的文件里, 而是手动维护一份变更日志. 这样写出来的changelog
才更可能对用户真正有用.
一个简单但很不错的changelog
格式是Keep a Changelog上推荐的格式, 该格式记录在 https://keepachangelog.com
.
未发布的版本
即使依赖的来源是本地目录或Git仓库, Rust
也会考虑它的版本号. 这意味着, 即使你还没有把crate
发布到crates.io
发布版本, 语义版本管理依然很重要; 在不同的版本之间, 你的Cargo.toml
里的版本号是依然是有意义的. 语义化版本规范并没有规定在这种情况下该怎么做, 但我会提供一个还算不错、负担不大的工作流程供你参考.
在你发布了一个版本后, 立即将Cargo.toml
中的版本号更新为下一个补丁版本, 并加上类似后缀为-alpha.1
. 如果你刚刚发布了2.0.3
, 就把新版本定为2.0.4-alpha.1
. 如果你发布的是一个alpha
版本, 那就把alpha
的编号递增, 比如从2.0.4-alpha.1
到2.0.4-alpha.2
.
当两个版本发布之间对代码进行修改时, 要注意是否有新加功能或破坏性的变更. 如果发生了这样的更改, 并且对应的版本号自上次发布后还没有变动, 那么就应当更新版本号. 例如, 如果上一个正式发布的版本是2.0.3
, 而当前的开发版本是2.0.4-alpha.2
, 此时你进行了新增功能的修改, 那么就应该把新版本改为2.1.0-alpha.1
, 如果你做了一个破坏性的修改, 就变成3.0.0-alpha.1
. 如果相应的版本号已经增加过了, 那就只需要递增alpha
的编号即可.
当你发布正式版本时, 移除版本号中的后缀(除非你想发布的是预发布版本), 然后发布这个版本, 并从头开始新的迭代.
这个流程之所以有效, 是因为它让两个常见的工作流程更加流畅. 首先, 想象一下, 一个开发者依赖于你的crate
的主要版本2, 但他们需要一个目前只在Git中提供的功能. 然后你提交了一个破坏性的修改. 如果你没有同时增加主版本, 他们的代码就可能会以意想不到的方式突然出错--要么无法编译, 要么在运行时出现奇怪的问题. 但如果你按照本文介绍的流程操作, Cargo
会在他们使用你的crate
的时间发出提醒, 指出发生了破坏性变更, 这样他们就必须要么解决这个问题, 要么将依赖锁定到某个特定提交.
接下来, 想象一位开发者需要使用他们刚刚为你的crate
贡献的一个功能, 但这个功能尚未发布在任何一个正式版本中. 他们已经通过Git依赖的方式使用你的crate
一段时间了, 所以他们项目中的其他开发者本地保存的仍是你crate
仓库的旧版本. 如果你没有在Git中更新主版本号, 该开发者就无法向其它人传达他们的项目现在依赖了那个刚合并的新功能. 如果他们把变更推送到项目中, 其他开发者会发现项目不再能编译了, 因为Cargo
会继续使用本地旧版本的仓库副本. 而如果该开发者可以为Git依赖更新次版本号, Cargo
就会意识到旧的仓库副本已经过时.
这个工作流程并不完美. 它无法很好地传达在两个版本发布之间发生的多个次版本或主版本变更, 并且你仍然需要花些精力来跟踪版本变化. 不过, 它确实解决了Rust
开发者在使用Git依赖时最常遇到的两个问题, 而且即便你在发布版本之间做了多次这样的更改, 这个工作流程仍能帮助你避免许多问题.
如果你不太在意发布版本中版本号变得不够小或连续, 你可以通过在每次变更时始终递增相应的版本号部分, 来改进上述建议的工作流程. 但要注意, 具体取决于你变更的频率, 这可能会让你的版本号变得非常大.
总结
在本章中, 我们探讨了多种用于配置、组织和发布crate
的机制, 这些机制既能让你自己受益, 也有助于他人使用你的crate
. 我们还回顾了在使用Cargo
的依赖和features
时一些常见的坑, 希望你以后不会再被这些问题困扰. 在下一章中, 我们将转向测试主题, 深入探讨如何超越我们所熟知和喜爱的Rust
的简单#[test]
测试函数.