构建大型 Rust 项目: pacakges, crates 和 modules
好的判断力是经验的结果,经验是错误判断的结果。
Mark Twain.
IC 公司的 Rust 代码库从 2019 年 6 月的空存储库增长到 2022 年初的近 350,000 行代码。这种快速增长告诉我,对于相对较小的项目来说,运作良好的决策可能会随着时间的推移开始拖累项目。本文评估了 Rust 代码组织选项,并提出了有效使用它们的方法。
戏剧人物
Rust 术语被证明是令人困惑的,因为术语 crates
令人困惑。例如,令人尊敬的 The Rust Programming Language 一书的第一版包含以下误导性段落
Rust 有两个与模块系统相关的不同术语:“crate”和“module”。crate在其他语言中是“库”或“包”的同义词。因此,“Cargo”作为 Rust 包裹管理工具的名称:您将crate与 Cargo 一起运送给其他人。Crate 可以生成可执行文件或库,具体取决于项目。
等一下,库和包是不同的概念,不是吗?混淆这些概念会导致挫败感,即使你已经有几个月的 Rust 经验。工具约定也会导致混乱:如果 Rust 包定义了库 crate, cargo
则会自动从包名派生库名称, 虽然您可以覆盖此行为,但请不要这样做。
让我们熟悉我们将要处理的概念。
- 模块
模块是代码组织的单元。它是函数、类型和嵌套模块的容器。模块还指定它们定义或重新导出的名称的可见性。
- crate
crate是编译和链接的单位。Crates是语言的一部分(
crate
是一个关键字),但你在源代码中没有太多提及它们。库和可执行文件是最常见的 crate 类型。
- 包
包是软件分发的单位。包不是语言的一部分,而是 Rust 包管理器 Cargo 的工件。软件包可以包含一个或多个 crate:最多一个库和任意数量的可执行文件。
模块与crate
当您将大型代码库分解为组件时,有两种极端:拥有几个包含大量模块的大包或具有大量小包。
拥有少量包含大量模块的软件包具有一些优点:
- 添加或删除模块比添加或删除包工作量更少。
- 模块更加灵活。例如,同一 crate 中的模块可以形成依赖循环:模块
foo
可以使用来自模块bar
的定义,而模块bar
又可以使用来自模块bar
的定义。相反,包依赖项关系图必须是非循环的。 - 您不必每次重新排列模块时都修改
Cargo.toml
文件。
在 Rust 即时编译的理想世界中,将存储库转换为包含许多模块的庞大包将是最方便的设置。痛苦的现实是 Rust 需要相当长的时间来编译,而模块并不能帮助你缩短编译时间:
- 编译的基本单元是一个crate,而不是一个模块。您必须重新编译 crate 中的所有模块,即使您只更改一个模块。放入crate的代码越多,编译所需的时间就越长。
- Cargo 跨 crate 并行编译,而不是在crate内。如果你有几个大包,你就不能充分利用多核CPU的潜力。
它归结为便利性和编译速度之间的权衡。模块很方便,但不能帮助编译器减少工作量。包不太方便,但随着代码库的增长,编译速度会更好。
代码组织建议
拆分依赖项中心。
有两种类型的依赖项中心:
- 具有大量依赖项的包。IC代码库中的两个示例是包含集成测试辅助代码)的
test-utils
包以及实例化所有组件的包replica
,。 - 具有大量反向依赖项的包。IC代码库中的示例包括包含通用类型定义的
types
包和指定组件接口的interfaces
包。
IC 包依赖关系图的一部分。 types
和 interfaces
是两个类型二依赖项中心,并且是类型一依赖项中心,replica
test-utils
既是类型一,又是类型二。
依赖中心是不可取的,因为它们会对增量编译产生破坏性影响。如果您修改具有许多反向依赖项的软件包(例如, types
),cargo 必须重新编译所有这些依赖项以检查您的更改。
有时可以消除依赖关系中心。例如,包 test-utils
是独立实用程序的联合。我们可以按它们帮助测试的组件对这些实用程序进行分组,并将代码分解到多个 *<component>*-test-utils
包中。
但是,更常见的是,依赖中心将不得不保留。某些 types
类型是普遍存在的。包含这些类型的包注定是第二类依赖项中心。连接所有组件的 replica
包注定是第一类依赖中心。您能做的最好的事情就是使它们小而稳定。
考虑使用泛型和关联类型来消除依赖项。
这个建议需要一个例子,所以请耐心等待。
types
、 interfaces
和 replicated_state
是 IC 代码库中的首批封装之一。types
包包含通用类型定义,该包定义软件组件的特征, interfaces
和 replicated_state
包定义 IC 的复制状态机数据结构, ReplicatedState
类型位于根目录。
但是为什么我们需要这个 types
包呢?类型是接口的一个组成部分。为什么不在包中 interfaces
定义它们?
问题是某些接口引用了该 ReplicatedState
类型。 replicated_state
包依赖于包中的 types
类型定义。如果所有类型都存在于包中 interfaces
,则 replicated_state
和 interfaces
之间将存在循环依赖关系。
、 interfaces
和 replicated_state
包的 types
依赖关系图。
当我们需要打破循环依赖时,我们可以将公共定义移动到新包中或合并一些包。 replicated_state
包装很重; 我们不想将其内容与interfaces
合并, 因此,我们采用了第一个选项,将包replicated_state
与包 interfaces
之间共享的类型移动到 types
包中。
interfaces
包中特征定义的一个属性是特征仅取决于 ReplicatedState
类型名称。这些特征不需要知道 ReplicatedState
的定义。
interfaces
包中依赖于 ReplicatedState
类型的特征定义示例。
trait StateManager {
fn get_latest_state(&self) -> ReplicatedState;
fn commit_state(&self, state: ReplicatedState, version: Version);
}
此属性允许我们打破 interfaces
和 replicated_state
之间的直接依赖关系。我们只需要用泛型类型参数替换确切的类型。
不依赖于 ReplicatedState
trait 的 StateManager
通用版本。
trait StateManager {
type State; //< We turned a specific type into an associated type.
fn get_latest_state(&self) -> State;
fn commit_state(&self, state: State, version: Version);
}
现在,我们不需要在每次向复制状态添加新字段时重新编译 interfaces
包及其众多依赖项。
首选运行时多态性。
我们的设计选择之一是如何连接软件组件。我们应该将组件的实例作为Arc<dyn Interface>
(运行时多态性)还是作为泛型类型参数(编译时多态性)传递 ?
使用运行时多态性组合组件。
pub struct Consensus {
artifact_pool: Arc<dyn ArtifactPool>,
state_manager: Arc<dyn StateManager>,
}
使用编译时多态性组合组件。
pub struct Consensus<AP: ArtifactPool, SM: StateManager> {
artifact_pool: AP,
state_manager: SM,
}
编译时多态性是必不可少的工具,但却是重量级的工具。运行时多态性需要更少的代码,并导致更少的二进制膨胀。大多数团队成员也发现该 dyn
版本更易于阅读。
首选显式依赖项。
新开发人员在开发频道上最常问的问题之一是“为什么我们要显式传递 Logger ?全局 Logger 似乎工作得很好。多么好的问题。我会在2019年问同样的事情!
全局变量很糟糕,但我以前的经验表明,Logger 很特殊。哦,好吧,他们并不是。
隐式状态依赖的常见问题在 Rust 中尤为突出。
- 大多数 Rust 库不依赖于真正的全局变量。传递隐式状态的常用方法是使用线程局部变量,当您生成新线程时,这可能会出现问题。新线程倾向于继承并保留线程局部变量的意外值。
- 默认情况下,Cargo 测试二进制文件中并行运行测试。如果不小心通过调用堆栈对 Logger 进行线程处理,测试输出可能会变得无比的混乱。当后台线程需要访问日志时,通常会出现此问题。显式传递记录器可以消除该问题。
- 在多线程环境中,测试依赖于隐式状态的代码通常变得困难或不可能。记录指标的代码就是代码。它也值得测试。
- 如果使用依赖于隐式状态的库,则在依赖于不同包中不兼容的库版本时,可能会引入细微的错误。
后一点迫切需要一个例子。所以这里有一个小侦探故事。
我们使用 prometheus
包进行指标记录。此包可以将指标注册表保留在全局变量中。
有一天,我们发现了一个错误:我们无法看到某些组件的指标。我们的代码看起来是正确的,但指标却缺失了。
其中一个软件包依赖于普罗米修斯版本 0.9
,而所有其他软件包都使用 0.10
。根据semver的说法,这些版本是不兼容的,因此cargo将两个版本链接到二进制文件中,引入了两个隐式注册表。我们仅通过 HTTP 接口公开 0.10
版本注册表。正如您正确猜测的那样,缺少的组件将指标记录到注册表中 0.9
。
传递记录器、指标注册表和异步运行时会显式地将运行时 bug 转换为编译时错误。切换到显式传递指标注册表帮助我找到并修复了该错误。
古老的 slog 包的官方文档还建议明确传递记录器:
原因是:手动传递
Logger
提供了最大的灵活性。使用 将slog_scope
日志记录数据结构绑定到堆栈跟踪,这与软件的逻辑结构不同。特别是库应该向用户展示充分的灵活性,而不是使用隐式日志记录行为。 通常Logger
实例非常整齐地适合表示资源的代码中的数据结构,因此在构造函数中传递它们并在任何地方使用info!(self.log, ...)
并不难。
通过隐式传递状态,您可以获得暂时的便利,但会使代码不那么清晰、更不易于测试且更容易出错。我们隐式传递的每种类型的资源作用域记录器,普罗米修斯指标注册表,人造丝线程池,Tokio运行时,仅举几例。导致难以诊断的问题并浪费大量工程时间。
其他编程社区的人们也意识到全局 Logger 是邪恶的。您可能会喜欢阅读没有静态 Logger 的日志记录。
删除重复的依赖项。
Cargo 使添加依赖项变得容易,但这种便利是有代价的。您可能会意外引入同一包的不兼容版本。
同一软件包的多个版本可能会导致正确性问题,尤其是对于主要版本组件为 ( ) 0.y.z
为零的软件包。如果您依赖于单个 0.1
二进制文件中的版本和 0.2
同一包,cargo 会将两个版本链接到可执行文件中。如果你曾经试图弄清楚为什么你会得到“there is no reactor running”的错误,你知道这些问题调试起来有多痛苦。
工作区依赖项和Cargo update将帮助您保持依赖项图井然有序。
您不必跨工作区包统一同一依赖项的功能集。由于功能统一机制,Cargo 将每个依赖项版本编译一次。
将单元测试放入单独的文件中。
Rust 允许你在生产代码旁边编写单元测试。
在同一文件 () foo.rs
中具有单元测试和生产代码的模块。
pub fn frobnicate(x: &Foo) -> u32 {
todo!("implement frobnication")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_frobnication() {
assert!(frobnicate(&Foo::new()), 5);
}
}
此功能非常方便,但它会减慢测试编译时间。当您修改文件时,Cargo 构建缓存可能会感到困惑,诱骗Cargo 在两个 dev
test
配置文件下重新编译crate,即使您只触及测试部分。通过反复试验,我们发现如果测试位于单独的文件中,则不会出现此问题。
将单元测试移动到 foo/tests.rs
中。
pub fn frobnicate(x: &Foo) -> u32 {
todo!("implement frobnication")
}
// The contents of the module moved to foo/tests.rs.
#[cfg(test)]
mod tests;
这种技术收紧了我们的编辑-检查-测试循环,使代码更易于导航。
常见陷阱
本节介绍 Rust 新手可能遇到的常见问题。我自己也经历过这些问题,看到几位同事在努力解决这些问题。
令人困惑的crate和packages
假设您有一个包 image-magic
,它定义了一个用于图像处理的库,并为图像转换提供了一个名为 transmogrify
的命令行实用程序。当然,您希望使用库来实现 transmogrify
.
的内容 image-magic/Cargo.toml
。
[package]
name = "image-magic"
version = "1.0.0"
edition = 2018
[lib]
[[bin]]
name = "transmogrify"
path = "src/transmogrify.rs"
# dependencies...
现在你打开 transmogrify.rs
并写下类似下面的内容:
use crate::{Image, transform_image}; //< Compile error.
编译器会变得不高兴并告诉你类似
error[E0432]: unresolved imports `crate::Image`, `crate::transform_image`
--> src/transmogrify.rs:1:13
|
1 | use crate::{Image, transform_image};
| ^^^^^ ^^^^^^^^^^^^^^^ no `transform_image` in the root
| |
| no `Image` in the root
哦,这是怎么回事? lib.rs
不是在 transmogrify.rs
同一个crate里吗?不,他们不是。包 image-magic
定义了两个crate:一个名为(请注意,Cargo 用下划线替换了包名称中的破折号)和一个名为 image_magic
transmogrify
的二进制crate。
因此,当您编写 use crate::Image
transmogrify.rs
时,您告诉编译器查找在同一二进制文件中定义的类型。crate 与 image_magic
任何其他库 transmogrify
一样在外部,因此我们必须在 use 声明中指定库名称:
use image_magic::{Image, transform_image}; //< OK.
准循环依赖关系
要了解此问题,我们将首先了解 Cargo 构建配置文件。生成配置文件名为编译器配置。例如:
- release
生产二进制文件的配置文件。最高的优化级别,禁用调试断言,较长的编译时间。当您运行 cargo build --release
时,Cargo 使用此配置文件。
- dev
正常开发周期的配置文件。启用调试断言和溢出检查,禁用优化以缩短编译时间。当您运行 cargo build
时,Cargo 使用此配置文件。
- test
与开发配置文件基本相同。当您测试库箱时,cargo 会使用 test
配置文件构建库,并注入执行测试工具的 main 函数。运行 时 cargo test
将启用此配置文件。Cargo 使用开发配置文件构建待测crate的依赖项。
想象一下,现在您有一个带有库 foo
的包。您希望良好的测试覆盖率和易于编写的测试。因此,您引入了另一个包含许多测试实用程序的包,用于 foo
。 foo-test-utils
用于 foo-test-utils
测试 foo
本身也感觉很自然。让我们添加 foo-test-utils
为 的开发 foo
依赖项。
的内容 foo/Cargo.toml
。
[package]
name = "foo"
version = "1.0.0"
edition = "2018"
[lib]
[dev-dependencies]
foo-test-utils = { path = "../foo-test-utils" }
的内容 foo-test-utils/Cargo.toml
。
[package]
name = "foo-test-utils"
version = "1.0.0"
edition = "2018"
[lib]
[dependencies]
foo = { path = "../foo" }
等等,我们不是创建了一个依赖循环吗? foo
取决于 foo-test-utils
foo
那取决于,对吧?
没有循环依赖关系,因为 cargo 编译 foo
了两次:一次使用要链接 foo-test-utils
的开发配置文件,一次使用测试配置文件来添加测试工具。
库测试的 foo
依赖关系图。
是时候写一些测试了!
的内容 foo-test-utils/src/lib.rs
。
use foo::Foo;
pub fn make_test_foo() -> Foo {
Foo {
name: "John Doe".to_string(),
age: 32,
}
}
的内容 foo/src/lib.rs
。
#[derive(Debug)]
pub struct Foo {
pub name: String,
pub age: u32,
}
fn private_fun(x: &Foo) -> u32 {
x.age / 2
}
pub fn frobnicate(x: &Foo) -> u32 {
todo!("complete frobnication")
}
#[test]
fn test_private_fun() {
let x = foo_test_utils::make_test_foo();
private_fun(&x);
}
但是,当我们尝试运行时 cargo test -p foo
,我们得到一个神秘的编译错误:
error[E0308]: mismatched types
--> src/lib.rs:14:17
|
14 | private_fun(&x);
| ^^ expected struct `Foo`, found struct `foo::Foo`
|
= note: expected reference `&Foo`
found reference `&foo::Foo`
这意味着什么?编译器告诉我们,测试中的类型定义和开发 foo
版本中的类型定义是不兼容的。从技术上讲,这些是不同的、不兼容的crate,即使这些crate共享名称。
解决问题的方法是在 foo
包中定义一个单独的集成测试箱,并将测试移动到那里。此方法允许您仅测试 foo
库的公共接口。
的内容 foo/tests/foo_test.rs
。
#[test]
fn test_foo_frobnication() {
let foo = foo_test_utils::make_test_foo();
assert_eq!(foo::frobnicate(&foo), 2);
}
上面的测试编译得很好,因为 cargo 将测试 foo_test_utils
与 . foo
集成测试的 foo_test
依赖关系图。
准循环依赖关系令人困惑。它们还大大增加了增量编译时间。我的建议是尽可能避免它们。
结论
在本文中,我们研究了 Rust 的代码组织工具。关键要点:
- 了解模块、crate和包之间的区别。
- Rust 的模块系统很方便,但将许多模块打包到一个crate中会降低构建时间。
- 将代码分解到许多有凝聚力的包中是最具可扩展性的方法。
- 所有隐式状态都是令人讨厌的。