Search Posts

Rust开发的项目达到一定的规模时,要如何组织代码以避免危机(上篇)

Rust开发的项目达到一定的规模时,要如何组织代码,特别是package、crate和module?这些概念在项目规模增大的时候尤其重要,甚至影响项目后续的生命力。

本文是来自 参考链接[1]的Rust专家的建议的《上篇》,可以帮助避免常见的陷阱、性能问题或编译问题。

中文文章内容如下:

IC (一个开源的区块链项目)的 Rust 代码库从 2019 年 6 月的空存储库增长到 2022 年初的近 350000 行代码。这种快速增长告诉我,对于相对较小的项目来说,运作良好的决策可能会随着时间的推移开始拖累项目。本文评估了 Rust 代码组织选项,并提出了有效使用它们的方法。

Rust的重要“角色”

Rust 的一些术语容易令人困惑,例如术语crate(中文“单元包”,在下文将保留为crate,不再翻译为 单元包)就不太直观。即使是令人尊敬的 《The Rust Programming Language》一书的第一版也包含以下误导性段落:

Rust 有两个与模块系统相关的不同术语:“crate”和“module”。crate在其他语言中是“库”或“包”的同义词。因此,“Cargo”作为 Rust 包裹管理工具的名称:您将crate 与 Cargo 一起分享给其他人。Crate 可以生成可执行文件或库(.so 文件 或 .dll 等都属于动态库),具体取决于项目。

然而,库和包是不同的概念,不是吗?混淆这些概念会导致挫败感,即使你已经有几个月的 Rust 经验。工具约定也会导致混乱:如果 Rust 包定义了库 crate, cargo 则会自动从包名派生库名称。您可以覆盖此行为,但请不要这样做。

接下来让我们熟悉经常打交道的几个概念。

Rust 的 Module (模块)

Module 模块 是代码组织的单元。它是函数、类型和嵌套模块的容器。模块还指定它们定义或重新导出的名称的可见性。

Rust 的 Crate (单元包)

Crate 是编译和链接的单位。Crates是语言的一部分( crate 是一个关键字),但你在源代码中没有太多提及它们。库和可执行文件是最常见的 crate 类型。

Rust 的 Package (包)

包是软件分发的单位。包不是语言的一部分,而是 Rust 包管理器 Cargo 的工件 。一个 Package 可以包含一个或多个 crate:最多一个库和任意数量的可执行文件。

再论 Modules 与 Crates

当您将大型代码库分解为组件时,有两种极端情况:拥有几个包含大量模块的大包(但包数量不多)或具有大量小包(包拆分后数量较多)。

对于前一种情况,即拥有少量包含大量模块的软件包,具有一些优点:

  1. 添加或删除模块比添加或删除包工作量更少。

  2. 模块更加灵活。例如,同一 crate 中的模块可以形成依赖循环:模块可以使用来自模块的定义,而模块又可以使用来自其他模块如 foo bar foo 的定义。相反,包依赖项关系图必须是非循环的。

  3. 您不必每次重新排列模块时都修改 Cargo.toml 文件。

在 Rust 即时编译的理想世界中,将存储库转换为包含许多模块的庞大包将是最方便的设置。目前痛苦的现实是,Rust 需要相当长的时间来编译,而模块并不能帮助你缩短编译时间:

编译的基本单元是一个crate,而不是一个模块。您必须重新编译 crate 中的所有模块,即使您只更改一个模块。放入crate的代码越多,编译所需的时间就越长。

编译项目时,cargo对不同的crate可以并行编译,而不是在逐个crate编译。所以如果你有几个大包,你就不能充分利用多核CPU的潜力。

这两种拆分方式,是便利性和编译速度之间的权衡。Modules模块很方便,但不能帮助编译器减少工作量。Package包不太方便,但随着代码库的增长,编译速度会更好。

项目代码结构的建议

拆分依赖项中心。

有两种类型的依赖项中心:

  1. 具有大量依赖项的包。例如IC代码库中的两个示例(examples)是包含集成测试辅助代码(proptest策略,模拟和伪造组件实现,帮助程序函数等)的 test-utils replica 包,以及实例化所有组件的包。

  2. 具有大量反向依赖项的包。例如IC代码库中的示例,包含通用类型定义的 types 封装,以及指定元件接口的 interfaces 封装。

Rust项目 Internet Computer 的包依赖关系图
types
interfaces
consensus
p2p
messaging
execution
replica
test-utils
dev-dependency
dependency

IC项目的包依赖关系图的一部分。图中 types 和 interfaces 是二类依赖中心,relica是一类依赖中心,test-utils 既是一类,又是二类依赖中心。

依赖中心是及其关键的,因为它们会对增量编译速度产生重大影响。如果您修改具有许多反向依赖项的软件包(例如图中的 types ),cargo 必须重新编译所有这些依赖项以检查您的更改。

有时可以消除依赖关系中心。例如,包 test-utils 是一些独立实用程序的联合。我们可以按它们所属的测试组件对这些实用程序进行分组,并将对应的实用程序代码分解到多个 -test-utils 包中。

但是,更常见的是,依赖中心将不得不保留。某些 types 类型是普遍存在的。包含这些类型的包注定是二类依赖项中心。连接所有组件的 replica 包注定是一类依赖中心。您能做的最好的事情就是本地化连结并使它们小而稳定。

请考虑使用泛型和关联类型来消除依赖项。

这个建议需要一个例子,所以请耐心等待。

types 、 interfaces 和 replicated_state 是 IC 代码库中的首批封装之一。该 types 包,含有通用类型定义,interfaces包定义软件组件的特征,replicated_state 包定义 IC 的复制状态机数据结构, ReplicatedState 类型位于根目录。

但是为什么我们需要这个 types 包呢?既然Types是接口的一个组成部分,那为什么不在interfaces 包内部定义Types呢?

原因是某些接口引用了该 ReplicatedState 类型。 replicated_state 包依赖于types包中的类型定义。如果所有类型都存在于 interfaces 包中,可能导致 replicated_state 和 interfaces 之间存在循环依赖关系。

如图,types、 interfaces 和 replicated_state 包的依赖关系图。

当我们需要打破循环依赖时,我们可以将公共定义移动到新包中或合并一些包。 replicated_state 包很重;我们不想将其内容合并入interfaces包。因此,我们采用了第一个选项:将不同interface 和replicated_state 包之间共享的类型移动到 types 包中。

interfaces 包的特征定义有个特点:特征仅取决于 ReplicatedState 类型名称。这些特征不需要知道 ReplicatedState 的定义。

trait StateManager {
  fn get_latest_state(&self) -> ReplicatedState;

  fn commit_state(&self, state: ReplicatedState, version: Version);
}

这段代码是interfaces包的特征定义的示例,它依赖于ReplicatedState类型。

interfaces 包中有个例子演示了依赖于 ReplicatedState 类型的特征定义。

此属性允许我们打破interfaces 与 replicated_state 之间的 直接依赖关系。我们只需要用泛型类型参数替换确切的类型。

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);
}

不依赖于 ReplicatedState 的 StateManager的特征定义的通用版本。

基于此,我们不再需要在每次向复制状态添加新字段时重新编译 interfaces 包及其众多依赖项。

运行时多态性是首选。

我们设计的考量之一是如何连接软件组件。我们应该像Arc的方式把组件的实例以运行时多态性传递,还是作为泛型类型参数(编译时多态性)传递 ?

pub struct Consensus {
  artifact_pool: Arc,
  state_manager: Arc,
}

上段代码是使用运行时多态性组合组件。

pub struct Consensus {
  artifact_pool: AP,
  state_manager: SM,
}

上段代码使用编译时多态性组合组件。

编译时多态性是必不可少的工具,更是重量级的工具。运行时多态性需要更少的代码,且有助于更少的二进制膨胀。大多数团队成员也发现该 dyn 版本(即上述第一段代码)更易于阅读。

首选显式依赖项。

新开发人员在开发频道上最常问的问题之一是“为什么我们要显式传递loggers?全局的loggers似乎也能工作得很好”。这是个好问题。如果回到2019年我也会问同样的问题!

全局变量很糟糕,但我以前的经验表明,日志对象loggers和指标接收器(metric sinks)很特殊。哦,好吧,其实也没有那么特殊。

隐式状态依赖的常见问题在 Rust 中尤为突出。

大多数 Rust 库不依赖于真正的全局变量。传递隐式状态的常用方法是使用线程局部变量,当您生成新线程时,这可能会成为问题。新线程倾向于继承并保留线程局部变量的意外值。

默认情况下,Cargo 在测试二进制文件中并行运行测试。如果不小心通过调用堆栈对loggers进行线程处理,测试输出可能会变得无形的混乱。当后台线程需要访问日志时,通常会出现此问题。而通过显式传递loggers的方式则可以消除该问题。

在多线程环境中,对依赖于隐式状态代码的测试很困难甚至不可能。记录指标的代码就是代码。它也值得测试。

如果使用依赖于隐式状态的库,则在依赖于不同包中不兼容的库版本时,可能会引入细微的BUG。

对于这个观点,迫切需要一个例子。这里有一个小合适的故事作为映证:

我们使用普罗米修斯软件包进行指标记录。此包可以将指标注册表保留在全局变量中。

突然有一天,我们遇到了一个错误:我们无法看到某些组件的指标。我们的代码看起来是正确的,但指标却缺失了。

其中一个软件包依赖于普罗米修斯版本 0.9 ,而所有其他软件包都使用 0.10 。根据semver的说法,这些版本是不兼容的,因此cargo将两个版本链接到二进制文件中,引入了两个隐式注册表。我们仅通过 HTTP 接口公开 0.10 版本注册表。正如您正确猜测的那样,缺少的组件将指标记录到注册表中 0.9 。

而传递loggers、指标注册表和异步运行时的方式会显式地将运行时 bug 转换为编译时错误。切换到显式传递指标注册表帮助我找到并修复了该错误。

古老的 slog 包的官方文档还建议明确传递loggers:

原因是:手动传递 Logger 提供了最大的灵活性。使用slog_scope 将日志记录数据结构绑定到堆栈跟踪,这与软件的逻辑结构不同。特别是库应该向用户展示充分的灵活性,而不是使用隐式日志记录行为。

通常 Logger 实例非常适合表示的代码中的资源的数据结构,因此在构造函数中传递它们并在任何地方使用,并不难,像这样:

info!(self.log, ...)

通过隐式传递状态,您可以获得暂时的便利,但会使代码不那么清晰、更难以测试且更容易出错。我们隐式传递的每种类型的资源,都潜在这些隐患。

作用域记录器,普罗米修斯指标注册表,Rayon线程池,Tokio运行时项目,都有一些此类负面例子。总之,隐式传递导致难以诊断的问题并浪费大量工程时间。

其他编程社区的人们也意识到全局变量是邪恶的。您可能会更喜欢阅读没有全局静态logger变量的代码。

删除重复的依赖项。

Cargo 使添加依赖项变得容易,但这种便利是有代价的。您可能会意外引入同一包的不兼容版本。

同一软件包的多个版本可能会导致正确性问题,尤其是对于主要版本组件为 0.y.z 版本号以零开始的软件包。如果您依赖于单个 0.1 二进制文件中的版本和 0.2 同一包,cargo 会将两个版本链接到可执行文件中。如果你曾经试图弄清楚为什么你会得到“there is no reactor running”的错误,你知道这些问题调试起来有多痛苦。

工作区依赖项和cargo 更新将帮助您保持依赖关系图井然有序。

您不必跨工作区包统一同一依赖项的功能集。得益于功能统一机制,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.rs 文件保留的代码:
pub fn frobnicate(x: &Foo) -> u32 {
    todo!("implement frobnication")
}

将单元测试的代码移动到foo/tests.rs 文件内。

//foo/tests.rs.
#[cfg(test)]
mod tests;

这种技术收紧了我们的编辑-检查-测试循环,使代码更易于导航。

常见陷阱

本节介绍 Rust 新手可能遇到的常见问题。我自己也经历过这些问题,看到几位同事在努力解决这些问题。

令人困惑的crate和package

假设您有一个包 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里吗?实际上不是,他们没在同一个crate里。包 image-magic 定义了两个crate :一个名为image_magic(请注意,cargo用下划线替换了包名称中的破折号),另一个名为 image_magic transmogrify 的二进制crate。

因此,当您在transmogrify.rs文件中编写

use crate::Image

时,您告诉编译器查找在同一个二进制文件中定义的类型。但对于二进制transmogrify来说,image_magic这个crate同任何其他库一样是外部crate,因此我们必须在 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 包中定义一个单独的集成测试crate,并将测试代码移动到那里。此方法允许您仅测试 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 将test和 foo_test_utils 与开发版本的 foo crate链接。

集成测试的 foo_test 依赖关系图
集成测试的 foo_test 依赖关系图

准循环依赖关系令人困惑。它们还大大增加了增量编译时间。我的建议是尽可能避免它们。

结论

在本文中,我们研究了 Rust 的代码组织思路。关键要点为:

  1. 了解modules、crates和package之间的区别。

  2. Rust 的模块系统很方便,但将许多模块打包到一个crate中会减少构建时间。

  3. 将代码分解到许多高内聚的包中是最具可扩展性的方法。

  4. 所有隐式状态都是令人讨厌的。

延伸阅读

  1. 讨论这篇关于 r/rust 的文章
  2. 阿列克谢·克拉多夫(Alexey Kladov)就同一主题写了一篇精彩的博客文章系列,《十万行Rust》
    If you liked this article, consider reading Scaling Rust builds with Bazel.
  3. 如果你喜欢这篇文章,可以考虑阅读 《用Bazel加速Rust的编译》 Scaling Rust build with Bazel。

参考链接:

  1. https://rust-journey.com/rust-at-scale-packages-crates-and-modules/
  2. https://mmapped.blog/posts/03-rust-packages-crates-modules.html
加好友请备注:chinaoss
您可以在微信公众号联系我们
我们将24小时内回复。
取消