好的判断力是经验的结果,经验是错误判断的结果。

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 包。

types
types
interfaces
interfaces
consensus
consensus
p2p
p2p
messaging
messaging
execution
execution
replica
replica
test-utils
test-utils
dev-dependency
dev-dependency
dependency
dependency
Viewer does not support full SVG 1.1

IC 包依赖关系图的一部分。 typesinterfaces 是两个类型二依赖项中心,并且是类型一依赖项中心,replica test-utils 既是类型一,又是类型二。

依赖中心是不可取的,因为它们会对增量编译产生破坏性影响。如果您修改具有许多反向依赖项的软件包(例如, types ),cargo 必须重新编译所有这些依赖项以检查您的更改。

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

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

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

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

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

但是为什么我们需要这个 types 包呢?类型是接口的一个组成部分。为什么不在包中 interfaces 定义它们?

问题是某些接口引用了该 ReplicatedState 类型。 replicated_state 包依赖于包中的 types 类型定义。如果所有类型都存在于包中 interfaces ,则 replicated_stateinterfaces 之间将存在循环依赖关系。

types
types
interfaces
interfaces
consensus
consensus
p2p
p2p
messaging
messaging
execution
execution
replica
replica
test-utils
test-utils
dev-dependency
dev-dependency
dependency
dependency
Viewer does not support full SVG 1.1

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

此属性允许我们打破 interfacesreplicated_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 的包。您希望良好的测试覆盖率和易于编写的测试。因此,您引入了另一个包含许多测试实用程序的包,用于 foofoo-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中会降低构建时间。
  • 将代码分解到许多有凝聚力的包中是最具可扩展性的方法。
  • 所有隐式状态都是令人讨厌的。