Rust-编程秘籍-全-

Rust 编程秘籍(全)

原文:annas-archive.org/md5/0a0b5c8f2cb0afc3a96ad327a3fbb826

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

几年前,我像许多程序员一样,每年开始学习一门新的编程语言。了解另一种编程语言可以从中获得许多范式、规则和见解,然后我发现了 Rust。Rust 真的很有趣,以至于我无法仅仅为了继续前进而放弃它。在学习曲线陡峭之后,它变得更加有趣。所以,就这样了:学习了总共两种额外的(TypeScript 和 Rust)语言后,我坚持学习 Rust。为什么?让我们来看看。

Rust 是一种系统编程语言,它通过默认的垃圾回收器提供内存安全,这影响了其运行时行为。尽管如此,Rust 是一种非常灵活的语言,可以应用于各种领域,无论是网页编程、游戏引擎还是网页客户端。此外,它挑战了关于作用域和内存分配的传统思维,使你在任何语言(无论是 C#、Java 还是 Python)中都能成为更好的程序员。亚马逊、微软和谷歌等公司最新的推动力表明,生态系统已经发展到足够稳定,足以用于企业级应用——这对未来的 Rust 专业人士来说是一个好兆头。

在本书中,我们收集了最有用的实验和合理的用例,以帮助您快速提高生产力。我们试图涵盖广泛的应用,希望您能找到有用的概念以及可以直接应用于您日常开发工作的解决方案。

本书面向的对象

这本书不是一本典型的学习编程语言的书籍。我们不想深入探讨概念,而是想展示一系列可能的项目、解决方案和应用,这些可以提供进一步链接到概念和其他资源。因此,我们认为这本书适合任何希望快速进入实际应用的人——无论他们的 Rust 经验如何。然而,编程基础(在任何语言中)是必需的——它们是您的 Rust 技能的基础。

本书涵盖的内容

第一章,从 Rust 开始,介绍了如何在您的计算机上设置 Rust 工具链以及语言的根本结构。这些包括编写测试和基准测试,以及循环、if 表达式、特性和结构体等语言结构。

第二章,深入 Rust 进阶,回答了关于语言更深入特性的问题以及创建有意义的程序的模式。主题包括复杂场景中的借用和所有权、Option 类型、模式匹配、泛型和显式生命周期以及枚举。

第三章,使用 Cargo 管理项目,使用 cargo 工具来管理额外的 crate、扩展、项目和测试。您将找到能够让您在更大的项目中工作并解决管理挑战的食谱。

第四章,无畏并发,深入探讨了构建安全、快速程序的一些最佳实践和技巧。除此之外,还介绍了如 Rayon 这样的流行库,我们展示了 Rust 是一种非常适合执行各种并发任务的语言。

第五章,处理错误和其他结果,解释了 Rust 如何使用Result类型和 panic 来执行错误处理,将大多数失败案例整合到需要处理的常规工作流程中。本章展示了避免意外崩溃和不必要的复杂性的适用模式和最佳实践。

第六章,使用宏表达自我,解释了 Rust 独特的宏系统如何在编译前扩展程序的功能——以一种类型安全的方式。这些宏可以用于许多可能的定制场景,许多 crate 都使用了这种能力。本章全部关于创建有用的宏,使你的生活更轻松,程序更安全。

第七章,将 Rust 与其他语言集成,使用和 Rust 内部的不同二进制单位和语言,以便移植遗留软件或利用更好的 SDK。这主要通过外部函数接口FFI)实现,它使得与其他原生二进制快速且容易地集成成为可能。除此之外,还可以使用 WebAssembly 从 Rust 发布到npm(Node.js 包仓库)。本章讨论了这些以及其他相关内容。

第八章,Web 安全编程,使用最先进的 Web 框架来展示 Web 编程的基础知识——actix-web,它展示了基于 actor 的处理请求的方法,这种方法在微软的生产环境中得到了应用。

第九章,轻松进行系统编程,解释了 Rust 是运行在资源有限的小型设备上的工作负载的绝佳选择。特别是,没有垃圾回收器和由此产生的可预测的运行时,使其非常适合运行传感器数据收集器。本章我们将介绍如何创建这样的循环,以及读取数据所需的驱动程序。

第十章,用 Rust 实践,涵盖了 Rust 编程中的实际考虑因素,例如解析命令行参数、与神经网络(使用 PyTorch 的 C++ API 进行机器学习)一起工作、搜索、正则表达式、网络请求等等。

为了充分利用这本书

在编程基础方面,我们考虑了一些事情,并假设你已经熟悉这些概念。以下是在编程环境中你应该能够解释的术语列表:

  • 类型与枚举

  • 控制语句和执行流程

  • 程序架构和模式

  • 流和迭代器

  • 链接

  • 泛型

拥有这些知识,您可以使用您选择的编辑器(我们推荐 Visual Studio Code (code.visualstudio.com),以及官方的 Rust 扩展(marketplace.visualstudio.com/items?itemName=rust-lang.rust))开始学习。虽然 Rust 是一种跨平台编程语言,但某些菜谱在 Linux 或 macOS 上更容易实现。我们鼓励 Windows 用户使用 Windows Subsystem for Linux (docs.microsoft.com/en-us/windows/wsl/install-win10)以获得更好的体验。

下载示例代码文件

您可以从www.packt.com的账户下载本书的示例代码文件。如果您在其他地方购买了此书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。

您可以通过以下步骤下载代码文件:

  1. www.packt.com登录或注册。

  2. 选择“支持”选项卡。

  3. 点击“代码下载”。

  4. 在搜索框中输入书籍名称,并遵循屏幕上的说明。

下载文件后,请确保使用最新版本的软件解压缩或提取文件夹:

  • WinRAR/7-Zip for Windows

  • Zipeg/iZip/UnRarX for Mac

  • 7-Zip/PeaZip for Linux

本书代码包也托管在 GitHub 上,地址为github.com/PacktPublishing/Rust-Programming-Cookbook。如果代码有更新,它将在现有的 GitHub 仓库中更新。

我们还有其他来自我们丰富的图书和视频目录的代码包可供选择,请访问github.com/PacktPublishing/。查看它们吧!

下载彩色图像

我们还提供了一份包含本书中使用的截图/图表彩色图像的 PDF 文件。您可以从这里下载:static.packt-cdn.com/downloads/9781789530667_ColorImages.pdf

代码实战

访问以下链接查看代码运行的视频:bit.ly/2oMSy1J

使用的约定

本书使用了许多文本约定。

CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“将下载的WebStorm-10*.dmg磁盘映像文件作为系统中的另一个磁盘挂载。”

代码块设置如下:

macro_rules! strange_patterns {
    (The pattern must match precisely) => { "Text" };
    (42) => { "Numeric" };
    (;<=,<=;) => { "Alpha" };
}

当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:

#[test]
#[should_panic]
fn test_failing_make_fn() {
    make_fn!(fail, {assert!(false)});
    fail();
 } 

任何命令行输入或输出都如下所示:

$ cargo run

粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词在文本中显示如下。以下是一个示例:“从管理面板中选择系统信息。”

警告或重要提示看起来像这样。

技巧和窍门看起来像这样。

部分

在这本书中,您会发现几个经常出现的标题(准备就绪如何操作...工作原理...还有更多...另请参阅)。

为了清楚地说明如何完成食谱,请按照以下方式使用这些部分:

准备就绪

本节告诉您在食谱中可以期待什么,并描述如何设置任何软件或任何为食谱所需的初步设置。

如何操作…

本节包含遵循食谱所需的步骤。

工作原理…

本节通常包含对前一个部分发生情况的详细解释。

还有更多…

本节包含有关食谱的附加信息,以便您对食谱有更多的了解。

另请参阅

本节提供了对食谱有用的链接,以获取其他相关信息。

联系我们

我们始终欢迎读者的反馈。

一般反馈:如果您对本书的任何方面有疑问,请在邮件主题中提及书名,并通过customercare@packtpub.com与我们联系。

勘误:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将不胜感激,如果您能向我们报告。请访问www.packtpub.com/support/errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。

盗版:如果您在互联网上以任何形式发现我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过copyright@packt.com与我们联系,并附上材料的链接。

如果您有兴趣成为作者:如果您在某个主题上具有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com

评论

请留下评论。一旦您阅读并使用过这本书,为什么不留下您从购买网站获得的评论呢?潜在读者可以查看并使用您的客观意见来做出购买决定,Packt 公司可以了解您对我们产品的看法,我们的作者也可以看到他们对书籍的反馈。谢谢!

有关 Packt 的更多信息,请访问 packt.com

第一章:Rust 入门

在过去一年中,Rust 的生态系统有了显著的增长,特别是 2018 版本,它推动了稳定性的重大推进。工具正在发展,重要的库正在成熟到许多大型公司将其用于生产的程度。

Rust 的一个特点是学习曲线陡峭——这主要归因于对内存分配方式的根本性改变。对于其他语言(如 C#)中的经验丰富的程序员来说,对 Rust 中的做法感到不知所措并不罕见。在本章中,我们将尝试克服这一点,降低入门门槛!

在本章中,我们将介绍以下内容:

  • 准备一切就绪

  • 与命令行 I/O 一起工作

  • 创建和使用数据类型

  • 控制执行流程

  • 使用 crates 和模块拆分你的代码

  • 编写测试和基准测试

  • 记录你的代码

  • 测试你的文档

  • 在不同类型之间共享代码

  • Rust 中的序列类型

  • 调试 Rust

设置你的环境

由于编程语言附带各种工具链、工具、链接器和编译器版本,因此选择最适合的变体并不容易。此外,Rust 适用于所有主要操作系统——这又增加了一个变量。

然而,当使用 rustup (rustup.rs/) 时,安装 Rust 已经变成了一件 trivial 的事情。在网站上,可以下载一个有用的脚本(或 Windows 上的安装程序),它会负责检索和安装所需的组件。同样的工具还允许你在这些组件之间切换、更新(以及卸载)它们。这是推荐的方式。

选择使用 Microsoft Visual Studio CompilerMSVC)与 Rust 一起使用时,需要安装额外的软件,例如 Visual C++ 运行时和编译器工具。

要编写代码,还需要一个编辑器。由于 Visual Studio Code 拥有一些 Rust 功能,因此与 Rust 扩展一起使用是一个很好的选择。它是由微软开发的开源编辑器,在全球和 Rust 社区中广受欢迎。在本食谱中,我们将安装以下组件:

  • Visual Studio Code (code.visualstudio.com/)

  • rustup (rustup.rs)

  • rustc(以及编译器工具链的其他部分)

  • cargo

  • RLS(代表 Rust Language Server——这是用于自动完成的)

  • Visual Studio Code 对 Rust 语言的支持

准备工作

在运行 macOS、Linux 或 Windows 的计算机上,只需要一个网络浏览器和互联网连接。请注意,Windows 的安装方式与使用脚本的 ***nix 系统(Linux 和 macOS)略有不同。

如何操作...

每个部分都需要我们导航到它们各自的网站,下载安装程序,并遵循它们的说明:

  1. 打开浏览器并导航到rustup.rscode.visualstudio.com/

  2. 选择适合您操作系统的安装程序。

  3. 下载后,运行安装程序并按照它们的说明进行操作,选择stable分支。

  4. 一旦成功安装,我们将更深入地探讨每个安装过程。

现在,让我们深入了解安装过程。

使用 rustup.rs 管理 Rust 安装

要测试使用rustup安装 Rust 工具链是否成功,可以在终端(或在 Windows 上的 PowerShell)中运行rustc命令:

$ rustc --version
rustc 1.33.0 (2aa4c46cf 2019-02-28)

注意,当您运行此命令时,您将有一个较新的版本。如果您坚持使用 2018 版代码,这无关紧要。

Rust 需要在您的系统上安装本机链接器。在 Linux 或 Unix 系统(如 macOS)上,Rust 调用cc进行链接,而在 Windows 上,首选的链接器是 Microsoft Visual Studio 的链接器,这取决于是否已安装 Microsoft Visual C++构建工具。虽然 Windows 上也可以使用开源工具链,但这种练习留给更高级的用户。

即使是 2018 版,一些有用的功能仍然只在nightly版本中可用。要安装rustc的 nightly 版本,请执行以下步骤:

  1. 在终端或 PowerShell 窗口中运行rustup install nightly(如果您不使用 GNU 工具链,则在 Windows 上使用nightly-msvc)。

  2. 命令完成后,默认工具链(在cargo中使用)可以使用rustup default nightly进行切换。

安装 Visual Studio Code 和扩展

在其原始版本中,Visual Studio Code 为许多语言提供了语法高亮。然而,为了自动完成或/和检查语法,需要一个扩展。Rust 项目提供了这个扩展:

  1. 打开 Visual Studio Code。

  2. 使用Ctrl + P (cmd + P在 macOS 上)打开命令行界面,然后输入ext install rust-lang.rust来安装扩展。过程应如下所示:

该扩展使用 RLS 进行静态代码分析并提供自动完成和语法检查。扩展应该自动安装 RLS 组件,但有时它将无法完成此操作。一种解决方案是将以下配置添加到 Visual Studio Code 的settings.json文件中(使用Ctrl + P/cmd + P来查找它):

{
    "rust-client.channel":"stable"
}

或者,rustup也会通过rustup component add rls命令安装 RLS。

故障排除

有时,更新工具会导致错误,例如文件丢失或无法覆盖。这可能是由多种原因造成的,但完全重置安装可以帮助解决问题。在 Linux 或 macOS 系统上,以下命令负责删除rustup安装的所有内容:

$ rm -Rf ~/.rustup

Windows 的 PowerShell 现在支持许多类似 Linux 的命令:

PS> rm ~/.rustup

这会导致相同的结果。在删除当前安装后,从头开始安装rustup——这应该会安装最新版本。

现在,让我们深入了解代码背后的原理。

它是如何工作的...

脚本rustup.sh是安装 Rust 的好方法,也是今天安装 Rust 和其他组件的主要方式。实际上,在 CI 系统中通常也会使用这个脚本安装编译器和其它工具。

rustup是一个由 Rust 项目维护的开源项目,可以在 GitHub 上找到:github.com/rust-lang/rustup.rs

我们已经成功学习了如何设置我们的环境。现在让我们继续到下一个菜谱。

使用命令行 I/O

在命令行上与用户通信的传统方式是使用标准流。Rust 包含了一些有用的宏来处理这些简单情况。在这个菜谱中,我们将探索经典Hello World程序的基本工作原理。

如何做到这一点...

只需五步,我们将探索命令行 I/O 和格式化:

  1. 打开一个终端窗口(Windows 上的 PowerShell)并运行cargo new hello-world命令,它将在hello-world文件夹中创建一个新的 Rust 项目。

  2. 一旦创建,使用cd hello-world切换到目录,并用 Visual Studio Code 打开src/main.rscargo默认生成的代码如下:

fn main() {
    println!("Hello, world!");
}
  1. 让我们扩展它!这些是前面传统print语句的变体,展示了格式化选项、参数和写入流等。让我们从一些常见的打印(和导入)开始:
use std::io::{self, Write};
use std::f64;

fn main() {
    println!("Let's print some lines:");
    println!();
    println!("Hello, world!");
    println!("{}, {}!", "Hello", "world");
    print!("Hello, ");
    println!("world!");

然而,我们可以做更多复杂的参数组合:

    println!("Arguments can be referred to by their position: {0}, 
    {1}! and {1}, {0}! are built from the same arguments", "Hello", 
    "world");

    println!("Furthermore the arguments can be named: \"{greeting}, 
    {object}!\"", greeting = "Hello", object = "World");

    println!("Number formatting: Pi is {0:.3} or {0:.0} for short", 
    f64::consts::PI);

    println!("... and there is more: {0:>0width$}={0:>width$}=
    {0:#x}", 1535, width = 5);

    let _ = write!(&mut io::stdout(), "Underneath, it's all writing 
    to a stream...");
    println!();

    println!("Write something!");
    let mut input = String::new();
    if let Ok(n) = io::stdin().read_line(&mut input) {
        println!("You wrote: {} ({} bytes) ", input, n);
    }
    else {
        eprintln!("There was an error :(");
    }
}

这应该提供了几种读取和写入控制台的不同变体。

  1. 回到终端并导航到Cargo.toml所在的目录。

  2. 使用cargo run来查看代码片段的输出:

$ cargo run
   Compiling hello-world v0.1.0 (/tmp/hello-world)
    Finished dev [unoptimized + debuginfo] target(s) in 0.37s
     Running 'target/debug/hello-world'
Let's print some lines:

Hello, world!
Hello, world!
Hello, world!
Arguments can be referred to by their position: Hello, world! and world, Hello! are built from the same arguments
Furthermore the arguments can be named: "Hello, World!"
Number formatting: Pi is 3.142 or 3 for short
... and there is more: 01535= 1535=0x5ff
Underneath, it's all writing to a stream...
Write something!
Hello, world!
You wrote: Hello, world!
 (14 bytes) 

输出中的每一行都代表了一种将文本打印到控制台的方法!我们建议尝试不同的变体,看看它是如何改变结果的。顺便说一下,rustc会在任何println!()format!()调用中检查正确的参数数量。

现在,让我们深入了解代码背后的原理。

它是如何工作的...

让我们分析代码以了解执行流程。

cargo在本书的第二章,使用 Cargo 管理项目中进行了深入描述。

初始片段是在步骤 1中执行cargo new hello-world时生成的。作为一个二进制类型的项目,需要一个main函数,rustc将会寻找它。在调用cargo run时,cargo会协调编译(使用rustc)和链接(在 Windows 上是msvc,在nix上是cc),并通过其入口点:main函数运行生成的二进制文件(步骤 5)。

在我们创建的步骤 3中的函数中,我们编写了一系列print!/println!/eprintln!语句,这些是 Rust 宏。这些宏简化了写入命令行应用程序的标准输出或标准错误通道的编写,并包括额外的参数。实际上,如果缺少参数,编译器将不会编译程序。

Rust 的宏直接作用于语言的语法树,提供类型安全和检查参数和参数的能力。因此,它们可以被视为具有一些特殊能力的函数调用——更多关于这一点将在第六章中介绍,使用宏表达自己

各种参数和模板字符串通过格式化器组合,这是一种强大的方法,可以在不需要连接或类似解决方案的情况下将实际变量添加到输出中。这将减少分配的数量,显著提高性能和内存效率。格式化数据类型的方法有很多;要深入了解,请查看 Rust 的优秀文档(doc.rust-lang.org/std/fmt/)。

最后一步展示了各种组合产生的输出。

我们已经成功学习了如何使用命令行 I/O。现在,让我们继续到下一个菜谱。

创建和使用数据类型

Rust 具有所有基本类型:64 位宽度的有符号和无符号整数;64 位浮点类型;字符类型;以及布尔类型。当然,任何程序都需要更复杂的数据结构以保持可读性。

如果您对 Rust 中的单元测试(或一般而言)不熟悉,我们建议您首先阅读本章中的编写测试和基准菜谱。

在这个菜谱中,我们将探讨创建和使用数据类型的一些良好基本实践。

如何做到这一点...

让我们用 Rust 的单元测试作为一些数据类型实验的游乐场:

  1. 使用cargo new data-types -- lib创建一个新的项目,并使用编辑器打开projects目录。

  2. 在您最喜欢的文本编辑器(Visual Studio Code)中打开src/lib.rs

  3. 在那里,您将找到一个运行测试的小片段:

#[cfg(test)]
mod tests {
    #[test]
    fn it_works() {
        assert_eq!(2 + 2, 4);
    }
}
  1. 让我们替换默认测试,以玩转各种标准数据类型。这个测试使用了几种处理数据类型及其数学函数的方法,以及可变性和溢出:
    #[test]
    fn basic_math_stuff() {
        assert_eq!(2 + 2, 4);

        assert_eq!(3.14 + 22.86, 26_f32);

        assert_eq!(2_i32.pow(2), 4);
        assert_eq!(4_f32.sqrt(), 2_f32);

        let a: u64 = 32;
        let b: u64 = 64;

        // Risky, this could overflow
        assert_eq!(b - a, 32);
        assert_eq!(a.overflowing_sub(b), (18446744073709551584, 
        true));
        let mut c = 100;
        c += 1;
        assert_eq!(c, 101);
    }
  1. 在覆盖了基本的数值类型之后,让我们检查一个主要限制:溢出!当发生溢出时,Rust 会恐慌,因此我们将期望使用#[should_panic]属性(如果它没有恐慌,测试实际上会失败):
    #[test]
    #[should_panic]
    fn attempt_overflows() {
        let a = 10_u32;
        let b = 11_u32;

        // This will panic since the result is going to be an 
        // unsigned type which cannot handle negative numbers
        // Note: _ means ignore the result
        let _ = a - b; 
    }
  1. 接下来,让我们创建一个自定义类型。Rust 的类型是struct,它们在内存中不增加开销。该类型具有一个new()(按惯例的构造函数)和一个sum()函数,我们将在测试函数中调用这两个函数:

// Rust allows another macro type: derive. It allows to "auto-implement"
// supported traits. Clone, Debug, Copy are typically handy to derive.
#[derive(Clone, Debug, Copy)]
struct MyCustomStruct {
    a: i32,
    b: u32,
    pub c: f32
}

// A typical Rust struct has an impl block for behavior
impl MyCustomStruct {

    // The new function is static function, and by convention a 
    // constructor
    pub fn new(a: i32, b: u32, c: f32) -> MyCustomStruct {
        MyCustomStruct {
            a: a, b: b, c: c
        }
    }

    // Instance functions feature a "self" reference as the first 
    // parameter
    // This self reference can be mutable or owned, just like other 
    // variables
    pub fn sum(&self) -> f32 {
        self.a as f32 + self.b as f32 + self.c
    }
}
  1. 为了看到新的struct函数的实际应用,让我们添加一个测试来对类型进行一些操作并克隆内存技巧(注意:请注意断言):
    use super::MyCustomStruct;

    #[test]
    fn test_custom_struct() {
        assert_eq!(mem::size_of::<MyCustomStruct>(), 
            mem::size_of::<i32>() + mem::size_of::<u32>() + 
            mem::size_of::<f32>());

        let m = MyCustomStruct::new(1, 2, 3_f32);
        assert_eq!(m.a, 1);
        assert_eq!(m.b, 2);
        assert_eq!(m.c, 3_f32);

        assert_eq!(m.sum(), 6_f32);
        let m2 = m.clone();
        assert_eq!(format!("{:?}", m2), "MyCustomStruct { a: 1, b: 
         2, 
        c: 3.0 }");

        let mut m3 = m;        
        m3.a = 100;

        assert_eq!(m2.a, 1);
        assert_eq!(m.a, 1);
        assert_eq!(m3.a, 100);
    }
  1. 最后,让我们看看所有这些是否都有效。在data-types目录中运行cargo test,你应该会看到以下输出:
$ cargo test
Compiling data-types v0.1.0 (Rust-Cookbook/Chapter01/data-types)
warning: method is never used: `new`
  --> src/lib.rs:13:5
   |
13 | pub fn new(a: i32, b: u32, c: f32) -> MyCustomStruct {
   | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
   |
   = note: #[warn(dead_code)] on by default

warning: method is never used: `sum`
  --> src/lib.rs:19:5
   |
19 | pub fn sum(&self) -> f32 {
   | ^^^^^^^^^^^^^^^^^^^^^^^^

    Finished dev [unoptimized + debuginfo] target(s) in 0.50s
     Running target/debug/deps/data_types-33e3290928407ff5

running 3 tests
test tests::basic_math_stuff ... ok
test tests::attempt_overflows ... ok
test tests::test_custom_struct ... ok

test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

   Doc-tests data-types

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

现在,让我们幕后了解代码,以便更好地理解它。

它是如何工作的...

这个菜谱涉及了几个概念,所以让我们在这里逐一解释。在步骤 1步骤 3中,我们设置了一个库来处理单元测试作为我们的实验场,然后创建第一个测试来处理一些内置数据类型,以便在步骤 4步骤 5中了解基础知识。由于 Rust 对类型转换特别挑剔,测试对不同类型的输出和输入应用了一些数学函数。

对于经验丰富的程序员来说,这里没有什么新东西,除了有一个overflow_sub()类型操作允许溢出操作之外。除此之外,由于(故意的)缺乏隐式转换,Rust 可能有点冗长。在步骤 5中,我们故意引发溢出,这会导致运行时恐慌(这是我们想要的测试结果)。

步骤 5所示,Rust 提供struct作为复杂类型的基础,它可以附加实现块以及派生(#[derive(Clone, Copy, Debug)])实现(如DebugCopy特性)。在步骤 6中,我们将探讨使用该类型及其含义:

  • 自定义类型没有开销:struct的大小正好等于其属性总和

  • 一些操作隐式调用特性实现——例如赋值运算符或Copy特性(本质上是一种浅拷贝)

  • 改变属性值需要整个struct函数的可变性

由于默认的分配策略是尽可能使用栈(或者如果没有提到其他东西),因此这种工作方式有几个方面。因此,数据的浅拷贝执行的是实际数据的拷贝,而不是对其的引用,这与堆分配发生的情况不同。在这种情况下,Rust 强制显式调用clone(),以便将引用后面的数据也进行拷贝。

我们已经成功地学习了如何创建和使用数据类型。现在,让我们继续下一个菜谱。

控制执行流程

在 Rust 中,控制程序的执行流程不仅限于简单的ifwhile语句。我们将在这个菜谱中看到如何做到这一点。

如何做到这一点...

对于这个菜谱,步骤如下:

  1. 使用cargo new execution-flow -- lib创建一个新的项目,并在编辑器中打开该项目。

  2. 基本条件语句,如if语句,在其他任何语言中工作方式相同,所以让我们从这些开始,并替换文件中的默认mod tests { ... }语句:

#[cfg(test)]
mod tests {
    #[test]
    fn conditionals() {
        let i = 20;
        // Rust's if statement does not require parenthesis
        if i < 2 {
            assert!(i < 2);
        } else if i > 2 {
            assert!(i > 2);
        } else {
            assert_eq!(i, 2);
        }
    }
}
  1. Rust 中的条件语句可以做更多的事情!这里有一个额外的测试来展示它们能做什么——在最后一个闭括号之前添加它:
    #[test]
    fn more_conditionals() {
        let my_option = Some(10);

        // If let statements can do simple pattern matching
        if let Some(unpacked) = my_option {
            assert_eq!(unpacked, 10);
        } 

        let mut other_option = Some(2);
        // there is also while let, which does the same thing
        while let Some(unpacked) = other_option {

            // if can also return values in assignments
            other_option = if unpacked > 0 {
                Some(unpacked - 1)
            } else { 
                None
            }
        }
        assert_eq!(other_option, None)
    }
  1. 条件语句并不是唯一可以用来改变执行流程的语句。当然,还有循环及其变体。让我们也为它们添加另一个测试,从一些基础知识开始:
    #[test]
    fn loops() {

        let mut i = 42;
        let mut broke = false;

        // a basic loop with control statements
        loop {
            i -= 1;
            if i < 2 {
                broke = true;
                break;
            } else if i > 2 {
                continue;
            }
        }
        assert!(broke);

        // loops and other constructs can be named for better 
        readability ...
        'outer: loop {
            'inner: loop {
                break 'inner; // ... and specifically jumped out of
            }
            break 'outer;
        }
  1. 接下来,我们将向测试中添加更多代码,以查看循环是常规语句,可以返回值,并且范围可以在 for 循环中使用:
        let mut iterations: u32 = 0;

        let total_squared = loop {
            iterations += 1;

            if iterations >= 10 {
                break iterations.pow(2);
            }
        };
        assert_eq!(total_squared, 100);

        for i in 0..10 { 
            assert!(i >= 0 && i < 10)
        }

        for v in vec![1, 1, 1, 1].iter() {
            assert_eq!(v, &1);
        }
    }

  1. 准备好这三个测试后,让我们运行 cargo test 来看看它们是如何工作的:
$ cargo test
   Compiling execution-flow v0.1.0 (Rust-Cookbook/Chapter01/execution-flow)
warning: value assigned to `broke` is never read
  --> src/lib.rs:20:17
   |
20 | let mut broke = false;
   | ^^^^^
   |
   = note: #[warn(unused_assignments)] on by default
   = help: maybe it is overwritten before being read?

    Finished dev [unoptimized + debuginfo] target(s) in 0.89s
     Running target/debug/deps/execution_flow-5a5ee2c7dd27585c

running 3 tests
test tests::conditionals ... ok
test tests::loops ... ok
test tests::more_conditionals ... ok

test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

现在,让我们幕后看看,以便更好地理解代码。

它是如何工作的...

虽然与许多语言的控制语句没有太大差异,但 Rust 的基本结构可以改变你对变量赋值的思考方式。它确实改变了我们的思维模式,使其更加关注数据。这意味着,与其思考“如果这个条件成立,将这个其他值赋给变量”,不如反过来“如果这个条件成立,将这个其他值赋给变量”——或者更简洁地说,“如果这个条件适用,就转换这个变量”——可能会更加流行。

这是 Rust 编程语言中的函数流,它非常适合缩短和聚焦代码中的重要部分。从循环结构中也可以得出类似的结论,因为一切都是作用域,并且有返回值。使用这些功能将使每个程序都更加可读和简短,尤其是如果只是简单的操作。

我们已经成功学习了如何控制执行流程。现在,让我们继续到下一个菜谱。

使用 crates 和模块拆分你的代码

Rust 知道两种代码单元:crates 和 modules。一个 crate 是一个外部库,包括它自己的 Cargo.toml 配置文件、依赖项、测试和代码。另一方面,modules 将 crate 拆分成逻辑部分,只有当用户导入特定函数时才对用户可见。自从 Rust 2018 版本以来,使用这些结构封装的差异已经最小化。

准备工作

这次,我们将创建两个项目:一个提供某种类型的函数,另一个用于使用它。因此,使用 cargo 创建这两个项目:cargo new rust-pilib --libcargo new pi-estimator。第二个命令创建了一个二进制可执行文件,这样我们就可以运行编译结果,而前者是一个库(crate)。

这个菜谱将创建一个小程序,打印出 π 的估计值()并将它们四舍五入到两位小数。这并不复杂,任何人都能理解。

给出 crate 的名字很难。主要仓库 (crates.io/) 非常宽容,并且已经看到了名称占用(人们保留名称的目的是为了出售——想想像 YouTubeFacebook 这样的名字,这些名字可以成为这些公司的优秀 API 客户端名称),许多 crate 是 C 库的重新实现或包装。一个好的做法是将存储库或目录命名为 rust-mycoolCwrapper,并使用 mycoolCwrapper 来命名 crate 本身。这样,只有与你的 crate 相关的问题会进来,而名称在人们的依赖中又很容易猜测!

如何做到这一点...

在几个步骤之后,我们将与不同的模块一起工作:

  1. 首先,我们将实现 rust-pilib crate。作为一个简单的例子,它使用蒙特卡洛方法估计常数 pi。这种方法与向飞镖板投掷飞镖并计算命中次数有些相似。在维基百科上了解更多信息(en.wikipedia.org/wiki/Monte_Carlo_method)。将以下片段添加到 tests 子模块中:
use rand::prelude::*;

pub fn monte_carlo_pi(iterations: usize) -> f32 {
    let mut inside_circle = 0; 
    for _ in 0..iterations {

        // generate two random coordinates between 0 and 1
        let x: f32 = random::<f32>();
        let y: f32 = random::<f32>();

        // calculate the circular distance from 0, 0
        if x.powi(2) + y.powi(2) <= 1_f32 {
            // if it's within the circle, increase the count
            inside_circle += 1;
        }
    }
    // return the ratio of 4 times the hits to the total     
    iterations
    (4_f32 * inside_circle as f32) / iterations as f32
}
  1. 此外,蒙特卡洛方法使用随机数生成器。由于 Rust 的标准库中没有提供,因此需要外部 crate!修改 rust-pilib 项目的 Cargo.toml 以添加依赖项:
[dependencies]
rand = "⁰.5"
  1. 作为优秀的工程师,我们也会为我们的新库添加测试。用以下测试替换原始的 test 模块,以使用蒙特卡洛方法近似计算 pi
#[cfg(test)]
mod tests {
    // import the parent crate's functions
    use super::*;

    fn is_reasonably_pi(pi: f32) -> bool {
        pi >= 3_f32 && pi <= 4.5_f32
    }

    #[test]
    fn test_monte_carlo_pi_1() {
        let pi = monte_carlo_pi(1);
        assert!(pi == 0_f32 || pi == 4_f32);
    }

    #[test]
    fn test_monte_carlo_pi_500() {
        let pi = monte_carlo_pi(500);
        assert!(is_reasonably_pi(pi));
    }

我们甚至可以超过 500 次迭代:

    #[test]
    fn test_monte_carlo_pi_1000() {
        let pi = monte_carlo_pi(1000);
        assert!(is_reasonably_pi(pi));
    }

    #[test]
    fn test_monte_carlo_pi_5000() {
        let pi = monte_carlo_pi(5000);
        assert!(is_reasonably_pi(pi));
    }
}

  1. 接下来,让我们运行测试,以确保我们产品的质量。在 rust-pilib 项目的根目录中运行 cargo test。输出应该类似于这样:
$ cargo test
   Compiling libc v0.2.50
   Compiling rand_core v0.4.0
   Compiling rand_core v0.3.1
   Compiling rand v0.5.6
   Compiling rust-pilib v0.1.0 (Rust-Cookbook/Chapter01/rust-pilib)
    Finished dev [unoptimized + debuginfo] target(s) in 3.78s
     Running target/debug/deps/rust_pilib-d47d917c08b39638

running 4 tests
test tests::test_monte_carlo_pi_1 ... ok
test tests::test_monte_carlo_pi_500 ... ok
test tests::test_monte_carlo_pi_1000 ... ok
test tests::test_monte_carlo_pi_5000 ... ok

test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

   Doc-tests rust-pilib

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
  1. 现在我们希望向用户提供 crate 的功能,这就是为什么我们为用户创建了一个第二个项目来执行。在这里,我们声明首先使用其他库作为外部 crate。在 pi-estimator 项目中的 Cargo.toml 文件中添加以下内容:
[dependencies]
rust-pilib = { path = '../rust-pilib', version = '*'}
  1. 然后,让我们看一下 src/main.rs 文件。Rust 会在这里查找一个 main 函数来运行,并且默认情况下,它简单地输出 Hello, World! 到标准输出。让我们用一个函数调用替换它:
// import from the module above
use printer::pretty_print_pi_approx;

fn main() {
    pretty_print_pi_approx(100_000);
}
  1. 现在,这个新函数在哪里呢?它有自己的模块:
// Rust will also accept if you implement it right away
mod printer {
    // import a function from an external crate (no more extern 
    declaration required!)
    use rust_pilib::monte_carlo_pi;

    // internal crates can always be imported using the crate 
    // prefix
    use crate::rounding::round;

    pub fn pretty_print_pi_approx(iterations: usize) {
        let pi = monte_carlo_pi(iterations);
        let places: usize = 2;

        println!("Pi is ~ {} and rounded to {} places {}", pi, 
        places, round(pi, places));
    }
}
  1. 此模块是内联实现的,这在测试中很常见——但几乎就像它是一个自己的文件一样。查看 use 语句,我们仍然缺少一个模块:rounding。在 main.rs 相同目录下创建一个名为 rounding.rs 的文件。将此公共函数及其测试添加到文件中:

pub fn round(nr: f32, places: usize) -> f32 {
    let multiplier = 10_f32.powi(places as i32);
    (nr * multiplier + 0.5).floor() / multiplier 
}

#[cfg(test)]
mod tests {
    use super::round;

    #[test]
    fn round_positive() {
       assert_eq!(round(3.123456, 2), 3.12);
       assert_eq!(round(3.123456, 4), 3.1235);
       assert_eq!(round(3.999999, 2), 4.0);
       assert_eq!(round(3.0, 2), 3.0);
       assert_eq!(round(9.99999, 2), 10.0); 
       assert_eq!(round(0_f32, 2), 0_f32);
    }

    #[test]
    fn round_negative() {
       assert_eq!(round(-3.123456, 2), -3.12);
       assert_eq!(round(-3.123456, 4), -3.1235);
       assert_eq!(round(-3.999999, 2), -4.0);
       assert_eq!(round(-3.0, 2), -3.0);
       assert_eq!(round(-9.99999, 2), -10.0);
    }
}
  1. 到目前为止,编译器忽略了该模块,因为它从未被声明。让我们这样做,并在 main.rs 的顶部添加两行:
// declare the module by its file name
mod rounding;
  1. 最后,我们想看看一切是否正常工作。切换到 pi-estimator 项目的根目录,并运行 cargo run。输出应该类似于这样(注意,库 crate 和依赖项实际上是用 pi-estimator 构建的):
$ cargo run
   Compiling libc v0.2.50
   Compiling rand_core v0.4.0
   Compiling rand_core v0.3.1
   Compiling rand v0.5.6
   Compiling rust-pilib v0.1.0 (Rust-Cookbook/Chapter01/rust-pilib)
   Compiling pi-estimator v0.1.0 (Rust-Cookbook/Chapter01/pi-
   estimator)
    Finished dev [unoptimized + debuginfo] target(s) in 4.17s
     Running `target/debug/pi-estimator`
    Pi is ~ 3.13848 and rounded to 2 places 3.14
  1. 库的包不是唯一需要测试的。运行 cargo test 来执行新 pi-estimator 项目中的测试:
$ cargo test
   Compiling pi-estimator v0.1.0 (Rust-Cookbook/Chapter01/pi-
   estimator)
    Finished dev [unoptimized + debuginfo] target(s) in 0.42s
     Running target/debug/deps/pi_estimator-1c0d8d523fadde02

running 2 tests
test rounding::tests::round_negative ... ok
test rounding::tests::round_positive ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

现在,让我们深入了解代码,以更好地理解它。

它是如何工作的...

在这个菜谱中,我们探讨了包和模块之间的关系。Rust 支持将代码封装成单元的几种方式,2018 版本使得这变得更加容易。经验丰富的 Rust 程序员可能会怀念文件顶部的 extern crate 声明(s),这在当今只有特殊情况下才是必要的。相反,可以直接在 use 语句中使用包的内容。

这样,模块和包之间的界限现在变得模糊。然而,由于模块是项目的一部分,只需要在根模块中声明即可编译,因此创建模块要简单得多。这个声明是通过 mod 语句完成的,它还支持在主体中实现——这在测试中经常使用。无论实现的位置如何,使用外部或内部函数都需要一个 use 语句,通常以 crate:: 为前缀,以提示其位置。

除了简单的文件外,模块也可以是一个包含至少一个 mod.rs 文件的目录。这样,大型代码库可以相应地嵌套和结构化它们的特性和结构体。

关于函数可见性的说明:Rust 的默认参数是模块可见性。因此,在模块中声明和实现的函数只能在模块内部看到。与这相反,pub 修饰符将函数导出给外部用户。这同样适用于附加到结构体的属性和函数。

我们已经成功学习了如何使用包和模块来分割我们的代码。现在,让我们继续下一个菜谱。

编写测试和基准测试

当我们开始开发时,测试往往不是首要任务。当时可能有必要这样做的原因有很多,但无法设置测试框架和环境不是其中之一。与许多语言不同,Rust 支持开箱即用的测试。这个菜谱涵盖了如何使用这些工具。

尽管我们在这里主要讨论单元测试,即对函数/struct级别的测试,但集成测试的工具仍然是相同的。

准备工作

再次强调,这个菜谱最好在自己的项目空间中完成。使用 cargo new testing --lib 创建项目。在项目目录内,创建另一个文件夹并命名为 tests

此外,基准测试功能仍然只在 Rust 的 nightly 分支上可用。需要安装 Rust 的 nightly 版本构建:rustup install nightly

如何进行...

按照以下步骤了解如何为您的 Rust 项目创建测试套件:

  1. 一旦创建,库项目就已经包含了一个非常简单的测试(可能是为了鼓励你编写更多)。cfg(test)test 属性告诉 cargo(测试运行器)如何处理模块:
#[cfg(test)]
mod tests {
    #[test]
    fn it_works() {
        assert_eq!(2 + 2, 4);
    }
}
  1. 在添加更多测试之前,让我们添加一个需要测试的主题。在这种情况下,让我们使用一些有趣的东西:从我们的另一本书(Hands-On Data Structures and Algorithms with Rust)中改编的单链表,使其泛型化。它由三部分组成。首先是节点类型:
#[derive(Clone)]
struct Node<T> where T: Sized + Clone {
    value: T,
    next: Link<T>,
}

impl<T> Node<T> where T: Sized + Clone {
    fn new(value: T) -> Rc<RefCell<Node<T>>> {
        Rc::new(RefCell::new(Node {
            value: value,
            next: None,
        }))
    }
}

第二,我们有一个Link类型,使编写更简单:

type Link<T> = Option<Rc<RefCell<Node<T>>>>;

最后一种类型是包含添加和删除节点功能的完整列表。首先,我们有类型定义:

#[derive(Clone)]
pub struct List<T> where T: Sized + Clone {
    head: Link<T>,
    tail: Link<T>,
    pub length: usize,
}

impl块中,我们可以指定类型的操作:

impl<T> List<T> where T: Sized + Clone {
    pub fn new_empty() -> List<T> {
        List { head: None, tail: None, length: 0 }
    }

    pub fn append(&mut self, value: T) {
        let new = Node::new(value);
        match self.tail.take() {
            Some(old) => old.borrow_mut().next = Some(new.clone()), 
            None => self.head = Some(new.clone())
        }; 
        self.length += 1;
        self.tail = Some(new);
    }

    pub fn pop(&mut self) -> Option<T> {
        self.head.take().map(|head| {
            if let Some(next) = head.borrow_mut().next.take() {
                self.head = Some(next);
            } else {
                self.tail.take();
            }
            self.length -= 1;
            Rc::try_unwrap(head)
                .ok()
                .expect("Something is terribly wrong")
                .into_inner()
                .value
        })
    }
}
  1. 列表准备好测试后,让我们为每个函数添加一些测试,从基准测试开始:

#[cfg(test)]
mod tests {
    use super::*;
    extern crate test;
    use test::Bencher;

    #[bench]
    fn bench_list_append(b: &mut Bencher) {
        let mut list = List::new_empty();
        b.iter(|| {
            list.append(10);
        });
    }

test模块内部添加更多基本列表功能的测试:

    #[test]
    fn test_list_new_empty() {
        let mut list: List<i32> = List::new_empty();
        assert_eq!(list.length, 0);
        assert_eq!(list.pop(), None);
    } 

    #[test]
    fn test_list_append() {
        let mut list = List::new_empty();
        list.append(1);
        list.append(1);
        list.append(1);
        list.append(1);
        list.append(1);
        assert_eq!(list.length, 5);
    }

    #[test]
    fn test_list_pop() {
        let mut list = List::new_empty();
        list.append(1);
        list.append(1);
        list.append(1);
        list.append(1);
        list.append(1);
        assert_eq!(list.length, 5);
        assert_eq!(list.pop(), Some(1));
        assert_eq!(list.pop(), Some(1));
        assert_eq!(list.pop(), Some(1));
        assert_eq!(list.pop(), Some(1));
        assert_eq!(list.pop(), Some(1));
        assert_eq!(list.length, 0);
        assert_eq!(list.pop(), None);
    }
}
  1. 也有一个很好的主意,即进行一个端到端测试来测试库。为此,Rust 在项目中提供了一个特殊的文件夹,称为tests,可以存放将库视为黑盒的附加测试。创建并打开tests/list_integration.rs文件,添加一个测试,将 10,000 个项目插入我们的列表:
use testing::List;

#[test]
fn test_list_insert_10k_items() {
    let mut list = List::new_empty();
    for _ in 0..10_000 {
        list.append(100);
    }
    assert_eq!(list.length, 10_000);
}
  1. 太好了,现在每个函数都有一个测试。通过在testing/根目录中运行cargo +nightly test来尝试它。结果应该看起来像这样:
$ cargo test
   Compiling testing v0.1.0 (Rust-Cookbook/Chapter01/testing)
    Finished dev [unoptimized + debuginfo] target(s) in 0.93s
     Running target/debug/deps/testing-a0355a7fb781369f

running 4 tests
test tests::test_list_new_empty ... ok
test tests::test_list_pop ... ok
test tests::test_list_append ... ok
test tests::bench_list_append ... ok

test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

     Running target/debug/deps/list_integration-77544dc154f309b3

running 1 test
test test_list_insert_10k_items ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

   Doc-tests testing

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
  1. 要运行基准测试,请执行cargo +nightly bench
cargo +nightly bench
   Compiling testing v0.1.0 (Rust-Cookbook/Chapter01/testing)
    Finished release [optimized] target(s) in 0.81s
     Running target/release/deps/testing-246b46f1969c54dd

running 4 tests
test tests::test_list_append ... ignored
test tests::test_list_new_empty ... ignored
test tests::test_list_pop ... ignored
test tests::bench_list_append ... bench: 78 ns/iter (+/- 238)

test result: ok. 0 passed; 0 failed; 3 ignored; 1 measured; 0 filtered out

现在,让我们幕后了解一下代码。

它是如何工作的...

测试框架在许多编程语言中是第三方库,尽管经过良好测试的代码应该是默认的!通过提供(微小的)测试框架、测试运行器和甚至一个小型的基准测试框架(截至本文写作时仅在nightly上提供),测试 Rust 代码的门槛显著降低。尽管还有一些缺失的功能(例如,模拟),但社区正在通过外部 crate 提供许多这些功能。

在完成步骤 1的所有设置后,步骤 2创建了一个单链表作为测试对象。单链表是一系列相同节点类型,通过某种指针连接起来的。在这个菜谱中,我们决定使用内部可变性模式,这允许在运行时以可变借用方式修改它所指向的节点。附加的操作(append()pop())就是利用这个模式。然后步骤 3创建了我们用来验证代码是否按预期工作的测试。这些测试涵盖了列表的基本功能:创建一个空列表,添加一些项目,然后使用pop再次移除它们。

可以使用各种assert!宏来失败测试。它们包括等于(assert_eq!)、不等于(assert_ne!)、布尔条件(assert!)和非发布模式编译(仅debug_assert!)。有了这些可用,以及如#[should_panic]这样的属性,就没有任何情况无法覆盖。此外,这本优秀的 Rust 书还提供了一些有趣的阅读材料:doc.rust-lang.org/book/ch11-01-writing-tests.html

第 4 步在一个单独的文件中添加了一个特殊的集成测试。这限制了程序员像 crate 的用户一样思考,没有访问内部模块和函数,这些模块和函数可以在嵌套的tests模块中可用。作为一个简单的测试,我们向列表中插入 10,000 个条目,看看它是否能够处理这个量级。

+nightly参数指示cargo使用nightly工具链执行此命令。

只有在第 5 步中,我们才准备好使用cargo +nightly test运行基准测试,但测试不是自动基准化的。更不用说,基准测试(cargo +nightly bench)使用--release标志编译代码,从而添加了可能导致与cargo +nightly test不同结果(包括调试时的头疼)的几个优化。

第 6 步显示了基准测试工具的输出,每个循环执行的纳秒级精度(以及标准差)。在进行任何类型的性能优化时,都要准备好基准测试来证明它确实有效!

其他 Rust 文档工具添加到测试中的不错功能是doctests。这些是编译并执行,同时作为文档渲染的代码片段。我们非常高兴,甚至为它专门制定了一个食谱!所以,让我们继续下一个食谱。

记录你的代码

文档是软件工程的重要组成部分。我们不喜欢仅仅编写一些函数,然后凭直觉将它们串联起来,我们更喜欢编写可重用和可读的代码。这其中的一个部分也是编写合理的文档——在理想情况下,它可以渲染成其他格式,如 HTML 或 PDF。像许多默认提供语言支持的编程语言一样,Rust 提供了一个工具和语言支持:rustdoc

准备工作

由于我们没有达到软件工程的高标准,我们没有记录上一个食谱中的代码!为了改变这一点,让我们将一个包含要记录的代码的项目(如上一个食谱中的编写测试和基准测试)加载到编辑器中。

如何做...

只需几个步骤就能将代码注释编译成闪亮的 HTML:

  1. Rust 的文档字符串(明确表示为要渲染的文档的字符串)由///表示(而不是常规的//)。在这些部分中,可以使用 markdown——HTML 的简写语言——来创建完整的文档。让我们在List<T>声明之前添加以下内容:
/// 
/// A singly-linked list, with nodes allocated on the heap using 
///`Rc`s and `RefCell`s. Here's an image illustrating a linked list:
/// 
/// 
/// ![](https://upload.wikimedia.org/wikipedia/commons/6/6d/Singly-
///linked-list.svg)
/// 
/// *Found on https://en.wikipedia.org/wiki/Linked_list*
/// 
/// # Usage
/// 
/// ```

/// let list = List::new_empty();

/// ```rs
/// 
#[derive(Clone)]
pub struct List<T> where T: Sized + Clone {
[...]
  1. 这使得代码变得更加冗长,但这是否值得?让我们用cargo doc来看看,这是一个子命令,它会在代码上运行rustdoc并输出项目target/doc目录中的 HTML。在浏览器中打开,target/doc/testing/index.html页面显示了以下内容(还有更多):

testing替换为你的项目名称!

  1. 太好了,让我们在代码中添加更多文档。甚至有一些特殊的部分,编译器(按照惯例)会识别:
    ///
    /// Appends a node to the list at the end.
    /// 
    /// 
    /// # Panics
    /// 
    /// This never panics (probably).
    /// 
    /// # Safety
    /// 
    /// No unsafe code was used.
    /// 
    /// # Example
    /// 
    /// ```

    /// use testing::List;

    ///

    /// let mut list = List::new_empty();

    /// list.append(10);

    /// ```rs
    /// 
    pub fn append(&mut self, value: T) {
    [...]
  1. ///注释为随后的表达式添加了文档。这对于模块来说可能是个问题:我们应该在当前模块外部放置文档吗?不。这不仅会让维护者感到困惑,而且也有局限性。让我们使用//!从模块内部进行文档说明:
//!
//! A simple singly-linked list for the Rust-Cookbook by Packt 
//! Publishing. 
//! 
//! Recipes covered in this module:
//! - Documenting your code
//! - Testing your documentation
//! - Writing tests and benchmarks
//! 
  1. 快速运行一次cargo doc就可以揭示它是否工作:

  1. 虽然在任何 Rust 项目中拥有类似外观的文档有一些好处,但企业营销通常喜欢有一些像标志或自定义 favicon 这样的东西来脱颖而出。rustdoc通过模块级别的属性来支持这一点——它们可以直接添加到模块文档下方(注意:这是我的 Rust 博客的标志,blog.x5ff.xyz):
#![doc(html_logo_url = "https://blog.x5ff.xyz/img/main/logo.png")]
  1. 为了查看它是否工作,让我们再次运行cargo doc

现在,让我们深入了解幕后,以便更好地理解代码。

它是如何工作的...

Markdown 是一种非常好的语言,它允许快速创建格式化的文档。然而,功能支持通常很棘手,所以请查看 Rust 的 RFC 以了解支持的格式化(github.com/rust-lang/rfcs/blob/master/text/0505-api-comment-conventions.md),以了解是否可以使用一些更高级的语句。一般来说,编写文档是大多数开发者所厌恶的,这就是为什么让它尽可能简单和容易是至关重要的。///模式相当常见,并且在 Rust 中得到了扩展,以便文档可以应用于随后的代码(///)或包含它的代码(//!)。示例可以在 步骤 1步骤 4 中看到。

Rust 项目选择的方法允许有几行来解释(public)函数,然后rustdoc编译器(在 步骤 2 中通过cargo doc调用)完成其余工作:暴露公共成员、交叉链接、列出所有可用的类型和模块,等等。虽然输出是完全可定制的(步骤 6),但默认设置已经非常吸引人(我们认为)。

默认情况下,cargo doc为整个项目构建文档——包括依赖项。

特殊部分(步骤 3)为文档输出添加了另一个维度:它们允许集成开发环境(IDE)或编辑器对提供的信息有所理解,并突出显示,例如,一个函数可能崩溃的情况。在你新生成的文档中的示例部分甚至可以编译并运行以doctests形式存在的代码(参见 测试你的文档 菜谱),这样你将在示例变得无效时得到通知。

rustdoc的输出也与网络服务器无关,这意味着它可以在支持静态托管的地方使用。事实上,Rust 项目构建并服务于托管在crates.iodocs.rs上的每个 crate 的文档。

现在我们已经成功创建了文档,我们应该继续到下一个食谱。

测试你的文档

过时的文档和无法按承诺工作的示例是许多技术的遗憾事实。然而,这些示例可以作为(黑盒)回归测试来确保我们在改进代码的过程中没有破坏任何东西,那么它们如何被用作这样的测试呢?Rust 的文档字符串(///)可以包含可执行代码片段——它们可以在www.rust-lang.org/learn的各个地方看到!

准备工作

我们将继续改进之前食谱中的链表,但更多地关注文档。然而,添加的代码将在任何项目中工作,所以选择一个你想添加文档的项目,并在你喜欢的编辑器中打开它。

如何操作...

本食谱的步骤如下:

  1. 找到一个函数或struct(或模块)来添加文档字符串,例如,List<T>new_empty()函数:
    ///
    /// Creates a new empty list.
    /// 
    /// 
    pub fn new_empty() -> List<T> { 
        ...
  1. 使用特殊的(H1)部分# 示例为编译器提供一个提示,以运行该部分中包含的任何代码片段:
    ///
    /// Creates a new empty list.
    /// 
    /// 
    /// # Example
  1. 现在让我们添加一个代码示例。由于doctests被认为是黑盒测试,我们导入struct(当然,只有它是公开的)并展示我们想要展示的内容:
    ///
    /// Creates a new empty list.
    /// 
    /// 
    /// # Example
    /// 
    /// ```

    /// use testing::List;

    ///

    /// let mut list: List<i32> = List::new_empty();

    /// ```rs
    /// 
  1. 准备就绪后,让我们看看测试是否工作:在项目的根目录中运行cargo +nightly test。你可以看到我们稍微作弊了一下,还向其他函数添加了测试:
$ cargo +nightly test
   Compiling testing v0.1.0 (Rust-Cookbook/Chapter01/testing)
    Finished dev [unoptimized + debuginfo] target(s) in 0.86s
     Running target/debug/deps/testing-a0355a7fb781369f

running 6 tests
[...]
   Doc-tests testing

running 4 tests
test src/lib.rs - List (line 44) ... ok
test src/lib.rs - List<T>::new_empty (line 70) ... ok
test src/lib.rs - List<T>::append (line 94) ... ok
test src/lib.rs - List<T>::pop (line 121) ... ok

test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
  1. 代码显然已经增加了几个在此情况下运行的示例——这总是我们想要的吗?有时,一切都关乎输出,添加所有必要的导入以使测试成功运行是一件痛苦的事情。因此,有选项可以添加到围栏区域(rs` 围栏内```rs ````),而ignore将不会编译也不会运行代码:
/// 
/// A singly-linked list, with nodes allocated on the heap using `Rc`s and `RefCell`s. Here's an image illustrating a linked list:
/// 
/// 
/// ![](https://upload.wikimedia.org/wikipedia/commons/6/6d/Singly-linked-list.svg)
/// 
/// *Found on https://en.wikipedia.org/wiki/Linked_list*
/// 
/// # Example
/// 
/// ```ignore

///

/// let list = List::new_empty();

/// ```rs
/// 
#[derive(Clone)]
pub struct List<T> where T: Sized + Clone { 
[...]
  1. 再次运行cargo test,我们可以看到输出中的变化:
$ cargo test
[...]
   Doc-tests testing

running 4 tests
test src/lib.rs - List (line 46) ... ignored
test src/lib.rs - List<T>::append (line 94) ... ok
test src/lib.rs - List<T>::new_empty (line 70) ... ok
test src/lib.rs - List<T>::pop (line 121) ... ok

test result: ok. 3 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out
  1. 让我们检查一下 HTML 输出:运行cargo doc以生成一个包含所有显示文档所需的CSS/HTML/JavaScript/...target/doc/目录。使用你喜欢的浏览器打开target/doc/testing/index.html

图片

注意:将testing替换为你的项目名称。

  1. 让我们移除片段顶部的丑陋的 use 语句。到那时,它增加了显示的行数,但没有添加任何内容——rustdoc 也提供了一个简单的方法来做这件事。在问题行前添加 #
    ///
    /// Creates a new empty list.
    /// 
    /// 
    /// # Example
    /// 
    /// ```

    /// # use testing::List;

    /// let list: List<i32> = List::new_empty();

    /// ```rs
    /// 
    pub fn new_empty() -> List<T> {
        [...] 
  1. 最后,还有其他方法可以配置 doctests 的测试行为。在这种情况下,让我们通过 拒绝 警告(同时忽略(允许)未使用的变量)将警告更改为错误:
#![doc(html_logo_url = "https://blog.x5ff.xyz/img/main/logo.png",
       test(no_crate_inject, attr(allow(unused_variables), 
        deny(warnings))))]
  1. 再一次,让我们检查输出是否符合我们的预期,并运行 cargo doc

图片

现在,让我们看看我们能否了解更多关于代码是如何工作的。

它是如何工作的...

Rust 的文档非常灵活,允许在单个菜谱中不可能涵盖的 doctests 变体。然而,这些工具的文档也非常出色,所以,要了解更多细节,请查看 doc.rust-lang.org/rustdoc/documentation-tests.html

在这个菜谱中我们涵盖的内容是一个很好的方法,通过添加将在每次测试运行时编译和运行的示例来在代码中记录 structs 和函数。这不仅对读者和回归测试有帮助,而且也要求你思考代码作为一个黑盒是如何工作的。这些测试在文档的 Example 部分遇到代码 (rs` 在一个围栏 ```rs ````) 时执行。在 步骤 2步骤 3 中,我们创建这些示例,并在 步骤 4步骤 10 中查看结果。

如果你现在想知道为什么某些文档在代码应该运行时却只显示了代码的一部分,步骤 8 展示了解决这个谜题的方法:# 可以在执行时隐藏单独的行。然而,有时代码根本不会执行,正如 步骤 5 所示。我们可以将一个部分声明为 ignore,这样代码就不会执行(输出中没有任何视觉指示)。

此外,这些测试可以像任何其他测试一样失败,通过恐慌(这也可以允许)或通过 assert! 宏跳过。总的来说,通过隐藏样板代码或其他非必要代码,读者可以专注于重要的部分,同时测试仍然覆盖了所有内容。

我们已经成功测试了我们的文档——我们可以安心地继续到下一个菜谱。

在不同类型之间共享代码

Rust 编程语言的一个不寻常的特性是决定使用特质而不是接口。后者在现代面向对象语言中非常常见,它将类的 API(或类似)统一到调用者,使得在不让调用者知道的情况下切换整个实现成为可能。在 Rust 中,这种分离略有不同:特质更像是抽象类,因为它们不仅提供了 API 方面,还提供了默认实现。struct 可以实现各种特质,从而与其他实现相同特质的结构体提供相同的行为。

如何做到这一点...

让我们按以下步骤进行:

  1. 使用 cargo 创建一个新的项目,cargo new traits --lib,或者从本书的 GitHub 仓库克隆它(github.com/PacktPublishing/Rust-Programming-Cookbook)。使用 Visual Studio Code 和终端打开项目的目录。

  2. 实现一个简单的配置管理服务。为此,我们需要一些结构体来工作:

use std::io::{Read, Write};

///
/// Configuration for our application
/// 
pub struct Config {
    values: Vec<(String, String)>
}

///
/// A service for managing a configuration
/// 
pub struct KeyValueConfigService {}

此外,一些构造函数使它们更容易使用:

// Impls

impl Config {
    pub fn new(values: Vec<(String, String)>) -> Config {
        Config { values: values }
    }
}

impl KeyValueConfigService {
    pub fn new() -> KeyValueConfigService {
        KeyValueConfigService { }
    }
}
  1. 为了使用与其他潜在实现统一的接口,我们有一些特质来共享接口:
///
/// Provides a get() function to return values associated with
/// the specified key.
/// 
pub trait ValueGetter {
    fn get(&self, s: &str) -> Option<String>;
}

///
/// Write a config
/// 
pub trait ConfigWriter {
    fn write(&self, config: Config, to: &mut impl Write) -> std::io::Result<()>;
}

///
/// Read a config
/// 
pub trait ConfigReader {
    fn read(&self, from: &mut impl Read) -> std::io::Result<Config>;
}
  1. Rust 对每个特质都要求有一个自己的实现块:
impl ConfigWriter for KeyValueConfigService {
    fn write(&self, config: Config, mut to: &mut impl Write) -> std::io::Result<()> {
        for v in config.values {
            writeln!(&mut to, "{0}={1}", v.0, v.1)?;
        }
        Ok(())
    }
}

impl ConfigReader for KeyValueConfigService {
    fn read(&self, from: &mut impl Read) -> std::io::Result<Config> {
        let mut buffer = String::new();
        from.read_to_string(&mut buffer)?;

        // chain iterators together and collect the results
        let values: Vec<(String, String)> = buffer
            .split_terminator("\n") // split
            .map(|line| line.trim()) // remove whitespace
            .filter(|line| { // filter invalid lines
                let pos = line.find("=")
                    .unwrap_or(0);
                pos > 0 && pos < line.len() - 1
            })
            .map(|line| { // create a tuple from a line 
                let parts = line.split("=")
                                .collect::<Vec<&str>>();
                (parts[0].to_string(), parts[1].to_string())
            })
            .collect(); // transform it into a vector
        Ok(Config::new(values))
    }
}

impl ValueGetter for Config {
    fn get(&self, s: &str) -> Option<String> {
        self.values.iter()
            .find_map(|tuple| if &tuple.0 == s {
                    Some(tuple.1.clone())
                } else {
                    None
            })
    }
}
  1. 接下来,我们需要一些测试来展示其功能。为了涵盖一些基础知识,让我们添加最佳情况单元测试:
#[cfg(test)]
mod tests {
    use super::*;
    use std::io::Cursor;

    #[test]
    fn config_get_value() {
        let config = Config::new(vec![("hello".to_string(), 
        "world".to_string())]);
        assert_eq!(config.get("hello"), Some("world".to_string()));
        assert_eq!(config.get("HELLO"), None);
    }

    #[test]
    fn keyvalueconfigservice_write_config() {
        let config = Config::new(vec![("hello".to_string(), 
        "world".to_string())]);

        let service = KeyValueConfigService::new();
        let mut target = vec![];
        assert!(service.write(config, &mut target).is_ok());

        assert_eq!(String::from_utf8(target).unwrap(), 
        "hello=world\n".to_string());
    }

     #[test]
    fn keyvalueconfigservice_read_config() {

        let service = KeyValueConfigService::new();
        let readable = &format!("{}\n{}", "hello=world", 
        "a=b").into_bytes();

        let config = service.read(&mut Cursor::new(readable))
            .expect("Couldn't read from the vector");

        assert_eq!(config.values, vec![
                ("hello".to_string(), "world".to_string()),
                ("a".to_string(), "b".to_string())]);
    }
}
  1. 最后,我们运行 cargo test 并看到一切正常:
$ cargo test
   Compiling traits v0.1.0 (Rust-Cookbook/Chapter01/traits)
    Finished dev [unoptimized + debuginfo] target(s) in 0.92s
     Running target/debug/deps/traits-e1d367b025654a89

running 3 tests
test tests::config_get_value ... ok
test tests::keyvalueconfigservice_write_config ... ok
test tests::keyvalueconfigservice_read_config ... ok

test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

   Doc-tests traits

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

现在,让我们深入了解代码以更好地理解它。

它是如何工作的...

使用特质而不是接口和其他面向对象结构对整体架构有许多影响。实际上,常见的架构思维可能会导致代码更加复杂和冗长,而且性能可能更差!让我们从《设计模式》(1994 年,四人帮所著)一书中考察流行的面向对象原则:

  • 面向接口而非实现编程:这个原则在 Rust 中需要一些思考。在 2018 版本中,函数可以接受一个 impl MyTrait 参数,而早期版本必须使用 Box<MyTrait>o: T,后来是 where T: MyTrait,所有这些都存在他们自己的问题。这是每个项目的权衡:要么使用具体类型获得更简单的抽象,要么为了更干净的封装而使用更多的泛型和复杂性。

  • 优先使用对象组合而非类继承:虽然这仅在一定程度上适用(Rust 中没有继承),但对象组合仍然是一个看起来不错的想法。在你的结构体中添加特质类型属性而不是实际类型。然而,除非它是一个 boxed 特质(即,较慢的动态分发),否则编译器无法确切知道应该预留多少空间——一个类型实例的大小可能是其他特质大小的 10 倍。因此,需要一个引用。不幸的是,这引入了显式的生命周期——使得代码变得更加冗长和复杂。

Rust 明显倾向于将行为从数据中分离出来,其中前者进入特质,而后者保留在原始结构体中。在本菜谱中,KeyValueConfigService不需要管理任何数据——它的任务是读取和写入Config实例。

步骤 2中创建这些结构体之后,我们在步骤 3中创建了行为特性。在那里,我们将任务拆分为两个单独的特性,以保持它们的小巧和易于管理。任何东西都可以实现这些特性,从而获得编写或读取配置文件或通过其键检索特定值的能力。

我们还将特质上的函数保持泛型,以便进行简单的单元测试(我们可以使用Vec<T>而不是伪造文件)。使用 Rust 的impl特质功能,我们只关心传递进来的任何东西是否实现了std::io::Readstd::io::Write

步骤 4在单个impl块中为结构体实现特质。ConfigReader策略是简单的:按行分割,在第一个=字符处分割这些行,并分别声明左右部分为键和值。然后ValueGetter实现遍历键值对以找到请求的键。我们在这里选择了VecString元组,以保持简单,例如,HashMap可以显著提高性能。

步骤 5中实现的测试提供了系统工作方式和如何通过它们实现的特质无缝使用类型的概述。Vec作为读写流,无需类型转换。为了确保测试实际运行,我们在步骤 6中运行cargo test

在本节课关于代码结构之后,让我们继续下一个菜谱。

Rust 中的序列类型

Rust 支持多种形式的序列。常规数组是严格实现的:它必须在编译时定义(使用字面量)并且只能为单一数据类型,并且不能改变大小。元组可以有不同类型的成员,但也不能改变大小。Vec<T>是一个泛型序列类型(无论你定义为什么类型T),它提供动态调整大小——但T只能为单一类型。总的来说,它们各自都有其用途,在本菜谱中,我们将探索每一个。

如何做...

本菜谱的步骤如下:

  1. 使用cargo创建一个新的项目,cargo new sequences --lib,或者从本书的 GitHub 仓库克隆它(github.com/PacktPublishing/Rust-Programming-Cookbook)。使用 Visual Studio Code 和终端打开项目的目录。

  2. 在测试模块准备就绪后,让我们从数组开始。Rust 中的数组有熟悉的语法,但它们遵循更严格的定义。我们可以尝试 Rust 数组的各种能力:

    #[test]
    fn exploring_arrays() {
        let mut arr: [usize; 3] = [0; 3];
        assert_eq!(arr, [0, 0, 0]);

        let arr2: [usize; 5] = [1,2,3,4,5];
        assert_eq!(arr2, [1,2,3,4,5]);

        arr[0] = 1;
        assert_eq!(arr, [1, 0, 0]);
        assert_eq!(arr[0], 1);
        assert_eq!(mem::size_of_val(&arr), mem::size_of::<usize>()
         * 3);
    }
  1. 更新编程语言和数据科学/数学环境的使用者也会熟悉元组,这是一种固定大小的变量类型集合。添加一个测试来处理元组:
    struct Point(f32, f32);

    #[test]
    fn exploring_tuples() {
        let mut my_tuple: (i32, usize, f32) = (10, 0, -3.42);

        assert_eq!(my_tuple.0, 10);
        assert_eq!(my_tuple.1, 0);
        assert_eq!(my_tuple.2, -3.42);

        my_tuple.0 = 100;
        assert_eq!(my_tuple.0, 100);

        let (_val1, _val2, _val3) = my_tuple;

        let point = Point(1.2, 2.1);
        assert_eq!(point.0, 1.2);
        assert_eq!(point.1, 2.1);
    }
  1. 作为最后一个集合,向量是所有其他快速可扩展数据类型的基础。创建以下测试,其中包含几个断言,展示了如何使用 vec! 宏和向量的内存使用:
    use std::mem;

    #[test]
    fn exploring_vec() {
        assert_eq!(vec![0; 3], [0, 0, 0]);
        let mut v: Vec<i32> = vec![];

        assert_eq!(mem::size_of::<Vec<i32>>(),
         mem::size_of::<usize>
         () * 3);

        assert_eq!(mem::size_of_val(&*v), 0);

        v.push(10);

        assert_eq!(mem::size_of::<Vec<i32>>(),
         mem::size_of::<i32>() * 6);

测试的其余部分展示了如何修改和读取向量:

        assert_eq!(v[0], 10);

        v.insert(0, 11);
        v.push(12);
        assert_eq!(v, [11, 10, 12]);
        assert!(!v.is_empty());

        assert_eq!(v.swap_remove(0), 11);
        assert_eq!(v, [12, 10]);

        assert_eq!(v.pop(), Some(10));
        assert_eq!(v, [12]);

        assert_eq!(v.remove(0), 12);

        v.shrink_to_fit();
        assert_eq!(mem::size_of_val(&*v), 0);
    }

  1. 运行 cargo test 来查看正在运行的测试:
$ cargo test
   Compiling sequences v0.1.0 (Rust-Cookbook/Chapter01/sequences)
    Finished dev [unoptimized + debuginfo] target(s) in 1.28s
     Running target/debug/deps/sequences-f931e7184f2b4f3d

running 3 tests
test tests::exploring_arrays ... ok
test tests::exploring_tuples ... ok
test tests::exploring_vec ... ok

test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

   Doc-tests sequences

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

现在,让我们深入了解代码,以更好地理解它。

它是如何工作的...

序列类型是复合类型,它们为更快、更易于访问而分配内存的连续部分。Vec<T> 创建了一个简单、堆分配的数组版本,它可以动态地增长(和缩小)(步骤 4)。

原始数组 (步骤 2) 在栈上分配内存,并且必须在编译时有一个已知的大小,这是使用它的一个重要因素。两者都可以使用切片迭代和查看 (doc.rust-lang.org/book/ch04-03-slices.html)。

元组 (步骤 3) 是一种不同的生物,因为它们不适合切片,而更像是一组具有语义关系的变量——就像二维空间中的一个点。另一个用例是在不使用额外的结构体或误用集合类型的情况下,将多个变量返回给函数的调用者。

Rust 中的序列之所以特殊,是因为它们产生的开销很低。Vec<T> 的大小是指向堆上 n * size of T 内存的一个指针,以及分配的内存大小和使用了多少。对于数组,容量是当前的大小(编译器可以在编译期间填充),而元组在三个不同的变量之上几乎是语法糖。这三种类型都提供了方便的函数来更改内容——在 Vec<T> 的情况下,还可以更改集合的大小。我们建议仔细查看测试及其注释,以了解更多关于每种类型的信息。

我们已经介绍了 Rust 中序列的基础知识,所以让我们继续到下一个菜谱。

Rust 的调试

调试在 Rust 中一直是一个棘手的话题,但与 Visual Studio 调试或 IntelliJ IDEA 在 Java 世界中的能力相比,它仍然相形见绌。然而,调试能力现在已经超越了简单的 println! 语句。

准备工作

在 Visual Studio Code 中,可以通过额外的扩展来调试 Rust。通过在命令窗口中运行 ext install vadimcn.vscode-lldb 来安装它 (Ctrl + P/cmd + P).

在 Windows 上,由于其不完整的 LLVM 支持,调试受到限制。然而,扩展会提示您自动安装几个东西。此外,安装 Python 3.6 并将其添加到 %PATH%。在这些依赖项安装后,它在我们这里工作得很好(2019 年 3 月)。

github.com/vadimcn/vscode-lldb/wiki/Setup 阅读更多内容。

如何操作...

执行以下步骤来完成这个菜谱:

  1. 创建一个新的二进制项目进行调试:cargo new debug-me。使用新扩展打开此项目在 Visual Studio Code 中。

  2. 在任何事情发生之前,Visual Studio Code 需要一个启动配置来识别 Rust 的 LLVM 输出。首先,让我们创建这个启动配置;为此,在项目目录中添加一个包含launch.json文件的.vscode目录。这可以自动生成,所以请确保launch.json包含以下内容:

{
    "version": "0.2.0",
    "configurations": [
        {
            "type": "lldb",
            "request": "launch",
            "name": "Debug executable 'debug-me'",
            "cargo": {
                "args": [
                    "build",
                    "--bin=debug-me",
                    "--package=debug-me"
                ],
                "filter": {
                    "kind": "bin"
                }
            },
            "args": [],
            "cwd": "${workspaceFolder}"
        },
        {
            "type": "lldb",
            "request": "launch",
            "name": "Debug unit tests in executable 'debug-me'",
            "cargo": {
                "args": [
                    "test",
                    "--no-run",
                    "--bin=debug-me",
                    "--package=debug-me"
                ],
                "filter": {
                    "kind": "bin"
                }
            },
            "args": [],
            "cwd": "${workspaceFolder}"
        }
    ]
}
  1. 现在,让我们打开src/main.rs并添加一些代码进行调试:
struct MyStruct {
    prop: usize,
}

struct Point(f32, f32);

fn main() {
    let a = 42;
    let b = vec![0, 0, 0, 100];
    let c = [1, 2, 3, 4, 5];
    let d = 0x5ff;
    let e = MyStruct { prop: 10 };
    let p = Point(3.14, 3.14);

    println!("Hello, world!");
}
  1. 在 VS Code 的用户界面中保存并添加断点。点击行号左侧,应该会出现一个红色圆点。这是一个断点:

  1. 设置了断点后,我们期望程序在那里暂停,并给我们一些关于当前内存布局的见解,即在那个特定时间点的任何变量的状态。使用F5(或调试|开始调试)运行调试启动配置。窗口配置应该略有变化,窗口左侧的一个面板显示了局部变量(以及其他内容):

  1. 使用顶部的这个小控制面板,您可以控制执行流程,并观察左侧的堆栈和内存相应地变化。注意数组与(堆分配的)向量的区别!

现在,让我们深入了解代码,以更好地理解它。

它是如何工作的...

Rust 是建立在带有各种内置功能的 LLVM 编译器工具包之上的。当 Rust 程序编译时,它只被转换成中间语言,然后 LLVM 编译器创建原生字节码。

这也是为什么在这种情况下调试可以工作——它基于 LLVM 调试符号。虽然它显然缺乏现代 IDE 的便利性,但它是一个巨大的进步,并允许用户检查类型。希望工具的未来发展也能改善这种情况;目前,通用的调试器 GDB (www.gnu.org/software/gdb/) 处理了大多数将调试符号编译到程序中的情况。在 IDE 中连接调试器的配置可以在步骤 2中找到,通过在步骤 4中设置断点,它可以追踪代码行与输出之间的关系。使用默认设置编译为调试,调试器就可以在这一点上停止。虽然它不是完美的(在用户体验方面),但它的功能是惊人的。

即使是这种与(从用户体验角度)非常基础的调试器的简单连接,也能为开发者带来巨大的好处,并且是从println!()语句到检查变量当前值的巨大进步。

我们希望您能在这本书的剩余部分使用调试器的功能。有了这些知识,您现在可以继续到下一章。

第二章:进一步学习高级 Rust

对于 Rust 语言对热衷于学习的用户提出的困难,毫无疑问。然而,如果您正在阅读这篇文章,您已经走在了大多数人的前面,并投入了所需的时间来提高。这种语言及其强制您思考内存的方式将把新的概念引入到您的编程习惯中。Rust 并不一定提供新的工具来完成事情,但借用和所有权规则帮助我们更多地关注作用域、生命周期以及适当地释放内存,无论语言如何。因此,让我们深入探讨 Rust 中更高级的概念,以完成对语言的全面理解——何时、为什么以及如何应用以下概念:

  • 使用枚举创建有意义的数字

  • 没有空值

  • 复杂的条件与模式匹配

  • 实现自定义迭代器

  • 高效地过滤和转换序列

  • 以不安全的方式读取内存

  • 共享所有权

  • 共享可变所有权

  • 带有显式生命周期的引用

  • 使用特质界限强制行为

  • 与泛型数据类型一起工作

使用枚举创建有意义的数字

枚举,简称为枚举,是许多语言中常见的编程结构。这些类型的特殊情况允许将数字映射到名称。这可以用来将常量绑定到单个名称下,并允许我们声明值作为变体。例如,我们可以在枚举 MathConstants 中有 π,以及欧拉数作为变体。Rust 并无不同,但它可以走得更远。Rust 不仅仅依赖于 命名数字,它允许枚举具有与其他 Rust 类型相同的灵活性。让我们看看这在实践中意味着什么。

如何做到这一点...

按照以下步骤来探索枚举:

  1. 使用 cargo new enums --lib 创建一个新的项目,并在 Visual Studio Code 或您选择的任何 IDE 中打开此文件夹。

  2. 打开 src/lib.rs 并声明一个包含一些数据的枚举:

use std::io;

pub enum ApplicationError {
    Code { full: usize, short: u16 },
    Message(String),
    IOWrapper(io::Error),
    Unknown
}
  1. 除了声明之外,我们还实现了一个简单的函数:
impl ApplicationError {

    pub fn print_kind(&self, mut to: &mut impl io::Write) -> 
    io::Result<()> {
        let kind = match self {
            ApplicationError::Code { full: _, short: _ } => "Code",
            ApplicationError::Unknown => "Unknown",
            ApplicationError::IOWrapper(_) => "IOWrapper",
            ApplicationError::Message(_) => "Message"
        };
        write!(&mut to, "{}", kind)?; 
        Ok(())
    }
}
  1. 现在,我们还需要对枚举做一些处理,所以让我们实现一个名为 do_work 的虚拟函数:
pub fn do_work(choice: i32) -> Result<(), ApplicationError> {
    if choice < -100 {

            Err(ApplicationError::IOWrapper(io::Error::
             from(io::ErrorKind::Other
  )))
    } else if choice == 42 {
        Err(ApplicationError::Code { full: choice as usize, short: 
        (choice % u16::max_value() as i32) as u16 } )
    } else if choice > 42 {
        Err(ApplicationError::Message(
            format!("{} lead to a terrible error", choice)
        ))
    } else {
        Err(ApplicationError::Unknown)
    }
}
  1. 未经测试,一切都是假的!现在,添加一些测试来展示枚举强大的匹配功能,从 do_work() 函数开始:

#[cfg(test)]
mod tests {
    use super::{ApplicationError, do_work};
    use std::io;

    #[test]
    fn test_do_work() {
        let choice = 10;
        if let Err(error) = do_work(choice) {
            match error {
                ApplicationError::Code { full: code, short: _ } => 
                assert_eq!(choice as usize, code),
                // the following arm matches both variants (OR)
                ApplicationError::Unknown | 
                ApplicationError::IOWrapper(_) => assert!(choice < 
                42),
                ApplicationError::Message(msg) => 
                assert_eq!(format!
                ("{} lead to a terrible error", choice), msg)
            }
        }
    }

对于 get_kind() 函数,我们还需要一个测试:

    #[test]
    fn test_application_error_get_kind() {
        let mut target = vec![];
        let _ = ApplicationError::Code { full: 100, short: 100 
        }.print_kind(&mut target);
        assert_eq!(String::from_utf8(target).unwrap(), 
        "Code".to_string());

        let mut target = vec![];
        let _ = ApplicationError::Message("0".to_string()).
        print_kind(&mut target);
        assert_eq!(String::from_utf8(target).unwrap(), 
        "Message".to_string());

        let mut target = vec![];
        let _ = ApplicationError::Unknown.print_kind(&mut target);
        assert_eq!(String::from_utf8(target).unwrap(), 
        "Unknown".to_string());

        let mut target = vec![];
        let error = io::Error::from(io::ErrorKind::WriteZero);
        let _ = ApplicationError::IOWrapper(error).print_kind(&mut 
        target);
        assert_eq!(String::from_utf8(target).unwrap(), 
        "IOWrapper".to_string());

    }
}
  1. 在项目的根目录下调用 cargo test,我们可以观察到输出:
$ cargo test
   Compiling enums v0.1.0 (Rust-Cookbook/Chapter02/enums)
    Finished dev [unoptimized + debuginfo] target(s) in 0.61s
     Running target/debug/deps/enums-af52cbd5cd8d54cb

running 2 tests
test tests::test_do_work ... ok
test tests::test_application_error_get_kind ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

   Doc-tests enums

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

现在,让我们看看枚举在底层是如何工作的。

如何工作...

Rust 中的枚举封装了选择——就像在任何语言中一样。然而,它们在许多方面与常规结构的行为相似:

  • 它们可以为特性和函数拥有 impl 块。

  • 无名和命名的属性可以携带不同的值。

这些方面使它们成为所有选择的真正优秀候选人,无论是配置值、标志、常量还是像第 2 步中那样包装错误。其他语言中的典型枚举将名称映射到你选择的数值,但 Rust 更进一步。Rust 的枚举不仅可以有数值,甚至可以有命名属性。看看第 2 步中的定义:

pub enum ApplicationError {
    Code { full: usize, short: u16 },
    Message(String),
    IOWrapper(io::Error),
    Unknown
}

ApplicationError::Code有两个属性,一个叫做full,另一个叫做short——就像任何其他struct实例一样可赋值。第二个和第三个变体,MessageIOWrapper,封装了另一个类型实例,一个是String,另一个是std::io::Error,类似于元组。

能够在匹配子句中工作的附加能力使这些结构非常有用,尤其是在代码库很大且可读性很重要的情况下——例如在第 3 步中,我们在枚举类型中实现了一个函数。这个函数将显式的枚举实例映射到字符串,以便更容易打印。

第 4 步实现了一个辅助函数,为我们提供了不同类型的错误和值来处理,这是我们第 5 步中创建这些函数的两个广泛测试所必需的。在那里,我们使用match子句(这个子句将在本章后面的食谱中讨论)从错误中提取值,并在单个分支中匹配多个枚举变体。此外,我们还创建了一个测试来展示print_kind()函数通过使用Vec作为流(归功于它实现了Write特质)是如何工作的。

我们成功地学习了如何使用枚举创建有意义的数字。现在,让我们继续下一个食谱。

没有 null

函数式语言通常没有null的概念,简单的理由是它总是一个特殊情况。如果你严格遵循函数式原则,每个输入都必须有一个可工作的输出——但是 null 是什么?它是错误吗?还是正常操作参数内的一个负结果?

作为遗留特性,null 自从 C/C++时代就有,当时指针实际上可以指向(无效的)地址,0。然而,许多新的语言试图摆脱这一点。Rust 没有 null,也没有使用Option类型的正常返回值。错误的情况由Result类型覆盖,我们为此专门写了一章,第五章,处理错误和其他结果

如何做到这一点...

由于我们正在探索内置库功能,我们将创建几个测试来涵盖所有内容:

  1. 使用cargo new not-null --lib创建一个新的项目,并使用 Visual Studio code 打开项目文件夹。

  2. 首先,让我们看看unwrap()做了什么,并用以下代码替换src/lib.rs中的默认测试:

    #[test]
    #[should_panic]
    fn option_unwrap() {
        // Options to unwrap Options
        assert_eq!(Some(10).unwrap(), 10);
        assert_eq!(None.unwrap_or(10), 10);
        assert_eq!(None.unwrap_or_else(|| 5 * 2), 10);

        Option::<i32>::None.unwrap();
        Option::<i32>::None.expect("Better say something when 
        panicking");
    }
  1. Option也很好地封装了值,有时获取它们可能很复杂(或者简单地说,很冗长)。这里有几种获取值的方法:
    #[test]
    fn option_working_with_values() {
        let mut o = Some(42);

        let nr = o.take();
        assert!(o.is_none());
        assert_eq!(nr, Some(42));

        let mut o = Some(42);
        assert_eq!(o.replace(1535), Some(42));
        assert_eq!(o, Some(1535));

        let o = Some(1535);
        assert_eq!(o.map(|v| format!("{:#x}", v)), 
        Some("0x5ff".to_owned()));

        let o = Some(1535);
        match o.ok_or("Nope") {
            Ok(nr) => assert_eq!(nr, 1535),
            Err(_) => assert!(false)
        }
    }
  1. 由于它们的函数式起源,在单值或集合上工作通常并不重要,Option在某些方面也表现得像集合:
    #[test]
    fn option_sequentials() {
        let a = Some(42);
        let b = Some(1535);
        // boolean logic with options. Note the returned values
        assert_eq!(a.and(b), Some(1535));
        assert_eq!(a.and(Option::<i32>::None), None);
        assert_eq!(a.or(None), Some(42));
        assert_eq!(a.or(b), Some(42));
        assert_eq!(None.or(a), Some(42));
        let new_a = a.and_then(|v| Some(v + 100))
                     .filter(|&v| v != 42);

        assert_eq!(new_a, Some(142));
        let mut a_iter = new_a.iter();
        assert_eq!(a_iter.next(), Some(&142));
        assert_eq!(a_iter.next(), None);
    }
  1. 最后,在Option上使用match子句非常流行且通常是必要的:
    #[test]
    fn option_pattern_matching() {

        // Some trivial pattern matching since this is common

        match Some(100) {
            Some(v) => assert_eq!(v, 100),
            None => assert!(false) 
        };

        if let Some(v) = Some(42) {
            assert_eq!(v, 42);
        }
        else {
            assert!(false);
        }
    }
  1. 要看到它全部工作,我们也应该运行cargo test
$ cargo test
   Compiling not-null v0.1.0 (Rust-Cookbook/Chapter02/not-null)
    Finished dev [unoptimized + debuginfo] target(s) in 0.58s
     Running target/debug/deps/not_null-ed3a746487e7e3fc

running 4 tests
test tests::option_pattern_matching ... ok
test tests::option_sequentials ... ok
test tests::option_unwrap ... ok
test tests::option_working_with_values ... ok

test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

   Doc-tests not-null

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

现在,让我们深入了解代码,以更好地理解它。

它是如何工作的...

Options 在我们的初步惊讶中,是一个枚举类型。虽然这几乎保证了良好的match兼容性,但在其他方面枚举的行为与结构体非常相似。在步骤 2中,我们看到这不仅仅是一个普通的枚举,而是一个类型枚举——这迫使我们为None添加类型声明。步骤 2还展示了如何从Option类型中获取值,包括带和不带恐慌的情况。unwrap()是一个流行的选择,但它有一些变体,当遇到None时不会使线程停止。

unwrap()始终是一件危险的事情,并且只应在非生产代码中使用。它会引发恐慌,可能导致整个程序突然、意外地停止,甚至不会留下适当的错误消息。如果你希望程序停止,expect()是一个更好的选择,因为它允许你添加一个简单的消息。这就是为什么我们在单元测试中添加了#[should_panic]属性,这样我们就可以向你证明它实际上会引发恐慌(否则测试会失败)。

步骤 3展示了几种非侵入性的方法来展开Option的值。特别是由于unwrap()返回所有者值同时销毁Option本身,如果Option仍然是数据结构的一部分并且只是临时持有值,其他方法可能更有用。take()是为这些情况设计的,用None替换值,类似于replace(),它对替换值做同样的处理。此外,还有map(),它允许你直接处理值(如果存在),并忽略通常的if-then 或match结构,这些结构会增加大量的代码冗余(参考步骤 5)。

步骤 4中间有一个有趣的细节:Options可以像布尔值一样执行逻辑运算,类似于 Python,其中 AND/OR 运算返回特定的操作数(docs.python.org/3/reference/expressions.html#boolean-operations)。最后但同样重要的是,Options也可以使用迭代器像集合一样处理。

Rust 的选项非常灵活,通过查看文档(doc.rust-lang.org/std/option/index.html),你可以找到许多不同的方法来动态转换值,而不需要繁琐的ifletmatch保护子句。

现在我们已经成功地了解到 Rust 中没有空值,让我们继续学习下一个菜谱。

复杂的条件与模式匹配

如前一个示例所示,模式匹配与枚举一起非常有用。然而,还有更多!模式匹配是一种起源于函数式语言的构造,它减少了在struct中分配属性时在条件分支和赋值之间的许多选择。这些步骤同时进行,减少了屏幕上的代码量,并创建了一种类似于高阶switch-case语句的东西。

如何做到这一点...

只需遵循几个步骤,就可以了解更多关于模式匹配的信息:

  1. 使用cargo new pattern-matching创建一个新的二进制项目。这次,我们将运行一个实际的可执行文件!再次,使用 Visual Studio Code 或其他编辑器打开项目。

  2. 让我们看看字面量匹配。就像其他语言中的switch-case语句一样,每个匹配臂也可以匹配字面量:

fn literal_match(choice: usize) -> String {
    match choice {
        0 | 1 => "zero or one".to_owned(),
        2 ... 9 => "two to nine".to_owned(),
        10 => "ten".to_owned(),
        _ => "anything else".to_owned()
    }
}
  1. 然而,模式匹配比这要强大得多。例如,可以提取元组元素并选择性地匹配:
fn tuple_match(choices: (i32, i32, i32, i32)) -> String {
    match choices {
        (_, second, _, fourth) => format!("Numbers at positions 1 
        and 3 are {} and {} respectively", second, fourth)
    }
}
  1. 解构(将属性从struct移动到它们自己的变量中)是与结构和枚举结合使用的一个强大功能。首先,这有助于在单个匹配臂中将多个变量分配给分配给结构实例属性的值。现在,让我们定义一些结构和枚举:
enum Background {
    Color(u8, u8, u8),
    Image(&'static str),
}

enum UserType {
    Casual,
    Power
}

struct MyApp {
    theme: Background,
    user_type: UserType,
    secret_user_id: usize
}

然后,可以在解构匹配中匹配单个属性。枚举也工作得很好——然而,务必涵盖所有可能的变体;编译器会注意到(或使用特殊_来匹配所有)。匹配也是从上到下进行的,所以首先应用的规则将被执行。以下代码片段匹配了我们刚才定义的结构体的变体。如果检测到特定的用户类型和主题,它将匹配并分配变量:

fn destructuring_match(app: MyApp) -> String {
    match app {
        MyApp { user_type: UserType::Power, 
                secret_user_id: uid, 
                theme: Background::Color(b1, b2, b3) } => 
            format!("A power user with id >{}< and color background 
            (#{:02x}{:02x}{:02x})", uid, b1, b2, b3),
        MyApp { user_type: UserType::Power, 
                secret_user_id: uid, 
                theme: Background::Image(path) } => 
            format!("A power user with id >{}< and image background 
            (path: {})", uid, path),
        MyApp { user_type: _, secret_user_id: uid, .. } => format!
        ("A regular user with id >{}<, individual backgrounds not 
        supported", uid), 
    }
}
  1. 在强大的正则匹配之上,守卫也可以强制执行某些条件。类似于解构,我们可以添加更多约束:
fn guarded_match(app: MyApp) -> String { 
    match app {
        MyApp { secret_user_id: uid, .. } if uid <= 100 => "You are 
        an early bird!".to_owned(),
        MyApp { .. } => "Thank you for also joining".to_owned()
    }
}
  1. 到目前为止,借用和所有权并不是一个重要的问题。然而,到目前为止的所有match子句都采取了所有权并将其转移到匹配臂的作用域(=>之后的内容),除非你返回它,否则外部作用域无法对其进行任何其他操作。为了解决这个问题,也可以匹配引用:
fn reference_match(m: &Option<&str>) -> String {
    match m {
        Some(ref s) => s.to_string(),
        _ => "Nothing".to_string()
    }
}
  1. 为了形成一个完整的循环,我们还没有匹配特定的字面量类型:字符串字面量。由于它们的堆分配,它们在本质上与i32usize等类型不同。在语法上,它们看起来与其他任何匹配形式没有区别:
fn literal_str_match(choice: &str) -> String {
    match choice {
        "" => "Power lifting".to_owned(),
        "" => "Football".to_owned(),
        "" => "BJJ".to_owned(),
        _ => "Competitive BBQ".to_owned()
    }
}
  1. 现在,让我们将所有这些结合起来,构建一个main函数,该函数使用正确的参数调用各种函数。让我们先打印一些简单的匹配:
pub fn main() {
    let opt = Some(42);
    match opt {
        Some(nr) => println!("Got {}", nr),
        _ => println!("Found None") 
    }
    println!();
    println!("Literal match for 0: {}", literal_match(0));
    println!("Literal match for 10: {}", literal_match(10));
    println!("Literal match for 100: {}", literal_match(100));

    println!();
    println!("Literal match for 0: {}", tuple_match((0, 10, 0, 
    100)));

    println!();
    let mystr = Some("Hello");
    println!("Matching on a reference: {}", 
    reference_match(&mystr));
    println!("It's still owned here: {:?}", mystr);

接下来,我们还可以打印解构后的匹配:

    println!();
    let power = MyApp {
        secret_user_id: 99,
        theme: Background::Color(255, 255, 0),
        user_type: UserType::Power
    };
    println!("Destructuring a power user: {}", 
    destructuring_match(power));

    let casual = MyApp {
        secret_user_id: 10,
        theme: Background::Image("my/fav/image.png"),
        user_type: UserType::Casual
    };
    println!("Destructuring a casual user: {}", 
    destructuring_match(casual));

    let power2 = MyApp {
        secret_user_id: 150,
        theme: Background::Image("a/great/landscape.png"),
        user_type: UserType::Power
    };
    println!("Destructuring another power user: {}", 
    destructuring_match(power2));

最后,让我们看看关于守卫和 UTF 符号的文本字面量匹配:

    println!();
    let early = MyApp {
        secret_user_id: 4,
        theme: Background::Color(255, 255, 0),
        user_type: UserType::Power
    };
    println!("Guarded matching (early): {}", guarded_match(early));

     let not_so_early = MyApp {
        secret_user_id: 1003942,
        theme: Background::Color(255, 255, 0),
        user_type: UserType::Power
    };
    println!("Guarded matching (late): {}", 
    guarded_match(not_so_early));
    println!();

    println!("Literal match for : {}", literal_str_match(""));
    println!("Literal match for : {}", literal_str_match(""));
    println!("Literal match for : {}", literal_str_match(""));
    println!("Literal match for : {}", literal_str_match(""));
}
  1. 最后一步再次涉及到运行程序。由于这不是一个库项目,结果将在命令行上打印。您可以随意更改main函数中的任何变量,以查看它如何影响输出。以下是输出应该是的样子:
$ cargo run
   Compiling pattern-matching v0.1.0 (Rust-
   Cookbook/Chapter02/pattern-matching)
    Finished dev [unoptimized + debuginfo] target(s) in 0.43s
     Running `target/debug/pattern-matching`
Got 42

Literal match for 0: zero or one
Literal match for 10: ten
Literal match for 100: anything else

Literal match for 0: Numbers at positions 1 and 3 are 10 and 100 respectively

Matching on a reference: Hello
It's still owned here: Some("Hello")

Destructuring a power user: A power user with id >99< and color background (#ffff00)
Destructuring a casual user: A regular user with id >10<, individual backgrounds not supported
Destructuring another power user: A power user with id >150< and image background (path: a/great/landscape.png)

Guarded matching (early): You are an early bird!
Guarded matching (late): Thank you for also joining

Literal match for : BJJ
Literal match for : Football
Literal match for : Power lifting
Literal match for : Competitive BBQ

现在,让我们幕后看看,以更好地理解代码。

它是如何工作的...

自从我们在 Scala 编程语言中遇到模式匹配以来,我们就爱上了它的简单性。作为函数式编程的主要支柱,这项技术提供了一种快速转换值的方法,而不会牺牲 Rust 的类型安全。

步骤 2步骤 7中的字面匹配是一种节省if-else链的好方法。然而,最常见的情况可能是为了解包ResultOption类型以提取封装的值。虽然使用|符号可以实现多个匹配,但有一些特殊操作符可以匹配特定的变体:...表示一个范围,而..表示跳过结构体中剩余的成员。_几乎总是忽略特定事物的通配符,作为一个match子句,它是一个通配符,应该放在最后。在步骤 3中,我们进行了大量的元组解包;我们使用_代替变量名来跳过一些匹配。

以类似的方式,步骤 4设置了并使用 Rust 的match子句(也称为解构)来匹配类型内的属性。这个特性支持嵌套,并允许我们从复杂的结构体实例中提取值和子结构体。真不错!

然而,通常不是通过在类型上匹配然后仅在match分支中处理解包的值来完成的。相反,将匹配条件排列整齐是处理类型内允许值的更好方法。Rust 的match子句支持为此目的的守卫。步骤 5展示了它们的能力。

步骤 8步骤 9都展示了之前实现的match函数的使用。我们强烈建议您亲自进行一些实验,看看会发生什么变化。类型匹配允许在没有冗长的安全措施或解决方案的情况下实现复杂的架构,这正是我们想要的!

我们已经成功地学习了使用模式匹配的复杂条件。现在,让我们继续下一个菜谱。

实现自定义迭代器

优秀语言的真正力量在于它让程序员如何与标准库和通用生态系统中的类型进行集成。做到这一点的一种方法是通过迭代器模式:由四人帮在其《设计模式》一书中定义(Addison-Wesley Professional,1994 年),迭代器是通过一个指针在集合中移动的封装。Rust 在Iterator特质之上提供了一系列实现。让我们看看我们如何仅用几行代码利用这种力量。

准备工作

我们将为我们在早期菜谱中构建的链表构建一个迭代器。我们建议使用Chapter01/testing项目,或者跟随我们构建迭代器的过程。如果你太忙,无法这样做,完整的解决方案可以在Chapter02/custom-iterators中找到。这些路径指的是本书的 GitHub 仓库github.com/PacktPublishing/Rust-Programming-Cookbook

如何去做...

迭代器通常是它们自己的结构体,由于可能有不同类型(例如,用于返回引用而不是拥有值),因此在架构上也是一个很好的选择:

  1. 让我们为List<T>的迭代器创建一个结构体:
pub struct ConsumingListIterator<T>
where
    T: Clone + Sized,
{
    list: List<T>,
}

impl<T> ConsumingListIterator<T>
where
    T: Clone + Sized,
{
    fn new(list: List<T>) -> ConsumingListIterator<T> {
        ConsumingListIterator { list: list }
    }
}
  1. 到目前为止,这只是一个缺少迭代器应有的所有功能的普通struct。它们的定义性质是一个next()函数,它前进内部指针并返回它刚刚移动过的值。按照典型的 Rust 风格,返回的值被包裹在一个Option中,一旦集合中的项目用完,它就变为None。让我们实现Iterator特质以获得所有这些功能:
impl<T> Iterator for ConsumingListIterator<T>
where
    T: Clone + Sized,
{
    type Item = T;

    fn next(&mut self) -> Option<T> {
        self.list.pop_front()
    }
}
  1. 目前,我们可以实例化ConsumingListIterator并将我们的List实例传递给它,这样它就能很好地工作。然而,这还远非无缝集成!Rust 标准库提供了一个额外的特质来实现IntoIterator。通过实现这个特质的函数,即使是for循环也知道该怎么做,它看起来就像任何其他集合,并且可以轻松互换:
impl<T> IntoIterator for List<T>
where
    T: Clone + Sized,
{
    type Item = T;
    type IntoIter = ConsumingListIterator<Self::Item>;

    fn into_iter(self) -> Self::IntoIter {
        ConsumingListIterator::new(self)
    }
}
  1. 最后,我们需要编写一个测试来证明一切正常工作。让我们将其添加到现有的测试套件中:

    fn new_list(n: usize, value: Option<usize>) -> List<usize>{
        let mut list = List::new_empty();
        for i in 1..=n {
            if let Some(v) = value {
                list.append(v);
            } else {
                list.append(i);
            }
        }
        return list;
    }

    #[test]
    fn test_list_iterator() {
        let list = new_list(4, None);
        assert_eq!(list.length, 4);

        let mut iter = list.into_iter();
        assert_eq!(iter.next(), Some(1));
        assert_eq!(iter.next(), Some(2));
        assert_eq!(iter.next(), Some(3));
        assert_eq!(iter.next(), Some(4));
        assert_eq!(iter.next(), None);

        let list = new_list(4, Some(1));
        assert_eq!(list.length, 4);

        for item in list {
            assert_eq!(item, 1);
        } 

        let list = new_list(4, Some(1));
        assert_eq!(list.length, 4);
        assert_eq!(list.into_iter().fold(0, |s, e| s + e), 4);
    }
  1. 运行测试将展示这种集成工作得有多好。cargo test命令的输出展示了这一点:
$ cargo test
    Finished dev [unoptimized + debuginfo] target(s) in 0.02s
     Running target/debug/deps/custom_iterators-77e564edad00bd16

running 7 tests
test tests::bench_list_append ... ok
test tests::test_list_append ... ok
test tests::test_list_new_empty ... ok
test tests::test_list_split ... ok
test tests::test_list_iterator ... ok
test tests::test_list_split_panics ... ok
test tests::test_list_pop_front ... ok

test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

   Doc-tests custom-iterators

running 5 tests
test src/lib.rs - List (line 52) ... ignored
test src/lib.rs - List<T>::append (line 107) ... ok
test src/lib.rs - List<T>::new_empty (line 80) ... ok
test src/lib.rs - List<T>::pop_front (line 134) ... ok
test src/lib.rs - List<T>::split (line 173) ... ok

test result: ok. 4 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out

下一个部分将更深入地探讨幕后发生的事情!

它是如何工作的...

迭代器是向自定义数据结构提供高级功能的好方法。凭借它们简单统一的接口,集合类型也可以轻松切换,程序员不必为每个数据结构都适应新的 API。

通过在步骤 1步骤 2中实现Iterator特质,可以很容易地提供对集合元素的精确访问级别。在这个菜谱的案例中(类似于Vec<T>),它将完全消耗列表并逐个移除项目,从前面开始。

步骤 3 中,我们实现了 IntoIterator,这是一个使这种结构可用于 for 循环和其他调用 into_iter() 的用户的特例。并非每个集合都实现了这个特例以提供多个不同的迭代器;例如,Vec<T> 的第二个迭代器是基于引用的,并且只能通过类型上的 iter() 函数访问。顺便说一句,引用是一种数据类型,就像实际实例一样,所以在这种情况下,一切都关于类型定义。这些定义是在特例实现中通过 type Item 声明(所谓的关联类型doc.rust-lang.org/rust-by-example/generics/assoc_items/types.html)完成的。这些类型被称为关联类型,可以使用 Self::Item 来引用——就像泛型一样,但没有额外的语法冗余。

使用这些接口,你可以访问一个大型函数库,这些函数只假设存在一个正在工作的迭代器!查看 步骤 4步骤 5 以查看使用迭代器在新建列表类型上的实现和结果。

我们已经成功学习了如何实现自定义迭代器。现在,让我们继续下一个示例。

高效地过滤和转换序列

在上一个示例中,我们讨论了实现自定义迭代器,现在是时候利用它们提供的函数了。迭代器可以一次性转换、过滤、归约或简单地转换底层元素,从而使其成为一个非常高效的尝试。

准备工作

首先,使用 cargo new iteration --lib 创建一个新的项目,并将以下内容添加到项目目录中新建的 Cargo.toml 文件:

[dev-dependencies]
rand = "⁰.5"

这将在项目中添加对 rand (github.com/rust-random/rand) 包的依赖,首次运行 cargo test 时将安装。在 Visual Studio Code 中打开整个项目(或 src/lib.rs 文件)。

如何实现...

通过四个简单的步骤,我们就能在 Rust 中过滤和转换集合:

  1. 为了使用迭代器,你首先必须检索它!让我们这样做并实现一个测试,快速展示迭代器在常规 Rust Vec<T> 上的工作方式:
    #[test]
    fn getting_the_iterator() {
        let v = vec![10, 10, 10];
        let mut iter = v.iter();
        assert_eq!(iter.next(), Some(&10));
        assert_eq!(iter.next(), Some(&10));
        assert_eq!(iter.next(), Some(&10));
        assert_eq!(iter.next(), None);

        for i in v {
            assert_eq!(i, 10);
        }
    }
  1. 添加了一个测试后,让我们进一步探讨迭代器函数的概念。它们是可组合的,并允许你在单个迭代中执行多个步骤(想想在单个 for 循环中添加更多东西)。此外,结果类型可以与开始时完全不同!以下是添加到项目中以执行一些数据转换的另一个测试:
    fn count_files(path: &String) -> usize {
        path.len()
    }

    #[test]
    fn data_transformations() {
        let v = vec![10, 10, 10];
        let hexed = v.iter().map(|i| format!("{:x}", i));
        assert_eq!(
            hexed.collect::<Vec<String>>(),
            vec!["a".to_string(), "a".to_string(), "a".to_string()]
        );
        assert_eq!(v.iter().fold(0, |p, c| p + c), 30);
        let dirs = vec![
            "/home/alice".to_string(),
            "/home/bob".to_string(),
            "/home/carl".to_string(),
            "/home/debra".to_string(),
        ];

        let file_counter = dirs.iter().map(count_files);

        let dir_file_counts: Vec<(&String, usize)> = 
        dirs.iter().zip(file_counter).collect();

        assert_eq!(
            dir_file_counts,
            vec![
                (&"/home/alice".to_string(), 11),
                (&"/home/bob".to_string(), 9),
                (&"/home/carl".to_string(), 10),
                (&"/home/debra".to_string(), 11)
            ]
        )
    }
  1. 作为最后一步,让我们也看看一些过滤和拆分。在我们的个人经验中,这些证明是最有用的——它们大大减少了代码的冗余。以下是一些代码:
    #[test]
    fn data_filtering() {
        let data = vec![1, 2, 3, 4, 5, 6, 7, 8];
        assert!(data.iter().filter(|&n| n % 2 == 0).all(|&n| n % 2 
        == 0));

        assert_eq!(data.iter().find(|&&n| n == 5), Some(&5));
        assert_eq!(data.iter().find(|&&n| n == 0), None);
        assert_eq!(data.iter().position(|&n| n == 5), Some(4));

        assert_eq!(data.iter().skip(1).next(), Some(&2));
        let mut data_iter = data.iter().take(2);
        assert_eq!(data_iter.next(), Some(&1));
        assert_eq!(data_iter.next(), Some(&2));
        assert_eq!(data_iter.next(), None);

        let (validation, train): (Vec<i32>, Vec<i32>) = data
            .iter()
            .partition(|&_| (rand::random::<f32>() % 1.0) > 0.8);

        assert!(train.len() > validation.len());
    }
  1. 和往常一样,我们希望看到示例工作!运行 cargo test 来实现这一点:
$ cargo test
   Compiling libc v0.2.50
   Compiling rand_core v0.4.0
   Compiling iteration v0.1.0 (Rust-Cookbook/Chapter02/iteration)
   Compiling rand_core v0.3.1
   Compiling rand v0.5.6
    Finished dev [unoptimized + debuginfo] target(s) in 5.44s
     Running target/debug/deps/iteration-a23e5d58a97c9435

running 3 tests
test tests::data_transformations ... ok
test tests::getting_the_iterator ... ok
test tests::data_filtering ... ok

test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

   Doc-tests iteration

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

你想知道更多吗?让我们看看它是如何工作的。

它是如何工作的...

Rust 的迭代器受到了函数式编程语言的强烈启发,这使得它们非常便于使用。作为一个迭代器,每个操作都是逐个元素顺序应用的,但仅当迭代器被向前移动时。本食谱中展示了多种类型的操作。其中最重要的如下:

  • map()操作执行值或类型的转换,它们非常常见且易于使用。

  • filter()与许多类似操作一样,执行一个谓词(一个返回布尔值的函数),以确定一个元素是否应该包含在输出中。例如find()take_while()skip_while()any()

  • 聚合函数,如fold()sum()min()max(),用于将整个迭代器的所有内容缩减为一个单一的对象。这可能是一个数字(例如sum())或者一个哈希表(例如,通过使用fold())。

  • chain()zip()fuse()以及许多其他操作将迭代器组合起来,以便可以在单个循环中迭代。通常,我们使用这些操作是因为在其他情况下可能需要多次遍历。

这种更函数式的编程风格不仅减少了需要编写的代码量,还充当了一个通用词汇表:在条件适用时,不是通过遍历整个将项目推入先前定义列表的for循环,而是对filter()的函数调用告诉读者可以期待什么。步骤 2步骤 3展示了根据不同用例转换(步骤 2)或过滤(步骤 3)集合的不同函数调用。

此外,迭代器可以被链式连接起来,因此对iterator.filter().map().fold()的调用并不罕见,并且通常比执行相同操作的循环更容易理解。作为最后一步,大多数迭代器都被收集到它们的目标集合或变量类型中。collect()评估整个链,这意味着它的执行是昂贵的。由于整个主题非常具体于当前的任务,请查看我们编写的代码和结果/调用,以充分利用它。步骤 4只展示了运行测试,但真正的故事在代码中。

完成!我们已经成功学习了如何高效地过滤和转换序列。继续学习下一个食谱,了解更多内容!

以不安全的方式读取内存。

unsafe是 Rust 中的一个概念,其中一些编译器安全机制被关闭。这些超能力使 Rust 更接近 C 语言操纵(几乎)任意内存部分的能力。unsafe本身使一个作用域(或函数)能够使用这四种超能力(参见doc.rust-lang.org/book/ch19-01-unsafe-rust.html):

  • 取消原始指针的引用。

  • 调用一个unsafe函数或方法。

  • 访问或修改一个可变静态变量。

  • 实现一个不安全的特性。

在大多数项目中,unsafe 只需要用于使用 FFI(即 Foreign Function Interface),因为它超出了借用检查器的范围。无论如何,在这个菜谱中,我们将探索一些读取内存的 unsafe 方法。

如何做到这一点...

只需几个步骤,我们就会进入 unsafe 状态:

  1. 使用 cargo new unsafe-ways --lib 创建一个新的库项目。使用 Visual Studio Code 或其他编辑器打开该项目。

  2. 打开 src/libr.rs 在测试模块之前添加以下函数:

#![allow(dead_code)]
use std::slice;

fn split_into_equal_parts<T>(slice: &mut [T], parts: usize) -> Vec<&mut [T]> {
    let len = slice.len();
    assert!(parts <= len);
    let step = len / parts;
    unsafe {
        let ptr = slice.as_mut_ptr();

        (0..step + 1)
            .map(|i| {
                let offset = (i * step) as isize;
                let a = ptr.offset(offset);
                slice::from_raw_parts_mut(a, step)
            })
            .collect()
    }
}
  1. 准备就绪后,我们现在必须在 mod tests {} 中添加一些测试:

#[cfg(test)]
mod tests {
    use super::*;
    #[test]
    fn test_split_into_equal_parts() {
        let mut v = vec![1, 2, 3, 4, 5, 6];
        assert_eq!(
            split_into_equal_parts(&mut v, 3),
            &[&[1, 2], &[3, 4], &[5, 6]]
        );
    }
}
  1. 回忆一下 unsafe 的超级能力,我们可以尝试改变读取内存的方式。让我们添加这个测试来看看它是如何工作的:
#[test]
fn test_str_to_bytes_horribly_unsafe() {
    let bytes = unsafe { std::mem::transmute::<&str, &[u8]>("Going 
               off the menu") };
    assert_eq!(
        bytes,
            &[
                71, 111, 105, 110, 103, 32, 111, 102, 102, 32, 116, 
                104, 101, 32, 109, 101, 110, 117
            ]
        );
    }

  1. 最后一步是在运行 cargo test 后查看积极的测试结果:
$ cargo test
   Compiling unsafe-ways v0.1.0 (Rust-Cookbook/Chapter02/unsafe-ways)
    Finished dev [unoptimized + debuginfo] target(s) in 0.41s
     Running target/debug/deps/unsafe_ways-e7a1d3ffcc456d53

running 2 tests
test tests::test_str_to_bytes_horribly_unsafe ... ok
test tests::test_split_into_equal_parts ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

   Doc-tests unsafe-ways

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

安全性是 Rust 中的一个重要概念,让我们来看看使用 unsafe 我们会做出哪些权衡。

它是如何工作的...

虽然 unsafe 是一种在某种程度上使解决方案更简单的方法,但这本书 (rust-unofficial.github.io/too-many-lists/index.html) 通过像链表这样简单的东西完美地描述了安全编程的限制。

Rust 是一种安全的编程语言,这意味着编译器会确保所有内存都被计算在内。因此,程序不可能获得同一内存地址的多个可变引用,使用已释放的内存,或者出现类型安全问题等。这使 Rust 能够避免未定义的行为。然而,对于一些有限的使用场景,这些约束禁止了有效的使用案例,这就是为什么 unsafe 会放宽一些保证,以适应一些只有 C 语言才允许的操作。

步骤 1 中设置好项目后,我们在 步骤 2 中添加第一个函数。它的目的是类似于 chunks() (doc.rust-lang.org/std/primitive.slice.html#method.chunks_mut),但与迭代器不同,我们立即返回整个集合,这在示例中是可以的,但在实现生产版本时应该考虑。我们的函数将提供的(可变)切片分割成 parts 个大小相等的块,并返回对它们的可变引用。由于输入也是对整个内存部分的可变引用,因此我们将有 parts + 1 个对同一内存区域的可变引用;显然,这是违反了安全的 Rust!此外,这个函数还允许使用 ptr.offset() 调用超出分配的内存(这涉及到指针算术)。

步骤 3 中创建的测试中,我们展示了它编译和执行没有出现任何重大问题。步骤 4 提供了另一个不安全代码的例子:不进行类型转换更改数据类型。transmute (doc.rust-lang.org/std/mem/fn.transmute.html) 函数可以轻松地更改变量的数据类型,并带来所有随之而来的后果。如果我们把类型改为其他类型,比如 u64,我们最终会得到一个完全不同的结果,并读取不属于程序的内存。在 步骤 5 中,我们运行整个测试套件。

unsafe Rust 可以用来从数据结构中获取最后一点性能,进行一些神奇的二进制打包,或者实现 SendSync (doc.rust-lang.org/std/mem/fn.transmute.html)。无论你打算用 unsafe 做什么,查看 nomicon (doc.rust-lang.org/nightly/nomicon/) 以深入了解其深度。

拥有了这些知识,让我们继续下一个菜谱。

共享所有权

所有权和借用是 Rust 的基本概念;它们是无需运行时垃圾回收的原因。作为一个快速入门:它们是如何工作的?简而言之:作用域。Rust(以及许多其他语言)使用(嵌套)作用域来确定变量的有效性,因此它不能在作用域之外使用(如函数)。在 Rust 中,这些作用域 拥有 它们的变量,因此当作用域结束时它们将消失。为了使程序能够 移动 值,它可以将其所有权转移到嵌套作用域或返回给父作用域。

对于临时转移(和多用户查看),Rust 有 借用,它创建了一个指向拥有值的引用。然而,这些引用功能较弱,有时更复杂,难以维护(例如,引用能否比原始值存活得更久?),这可能是编译器抱怨的原因。

在这个菜谱中,我们通过使用引用计数器来共享所有权,只有当计数器达到零时才会丢弃变量,来解决这个问题。

准备工作

使用 new sharing-ownership --lib 创建一个新的库项目,并在你最喜欢的编辑器中打开该目录。我们还将使用 nightly 编译器进行基准测试,因此强烈建议运行 rustup default nightly

要启用基准测试,请将 #![feature(test)] 添加到 lib.rs 文件的顶部。

如何做到这一点...

理解共享所有权只需要八个步骤:

  1. 在相对年轻的 Rust 生态系统里,API 和函数签名并不总是最有效的,尤其是在它们需要一定的内存布局知识时。因此,考虑一个简单的 length 函数(将其添加到 mod tests 范围内):
    /// 
    /// A length function that takes ownership of the input 
    /// variable
    /// 
    fn length(s: String) -> usize {
        s.len()
    } 

虽然不是必需的,但该函数要求你将你的拥有变量传递到作用域内。

  1. 幸运的是,如果你在函数调用后仍然需要所有权,clone() 函数已经为你准备好了。顺便说一句,这类似于一个循环,在第一次迭代中所有权被移动,这意味着在第二次迭代时它就消失了——导致编译器错误。让我们添加一个简单的测试来展示这些移动:
    #[test]
    fn cloning() {
        let s = "abcdef".to_owned();
        assert_eq!(length(s), 6);
        // s is now "gone", we can't use it anymore
        // therefore we can't use it in a loop either!
        // ... unless we clone s - at a cost! (see benchmark)
        let s = "abcdef".to_owned();

        for _ in 0..10 {
            // clone is typically an expensive deep copy
            assert_eq!(length(s.clone()), 6);
        }
    }
  1. 这可行,但会创建大量字符串的副本,然后很快就会丢弃它们。这会导致资源浪费,并且对于足够大的字符串,会减慢程序的速度。为了建立一个基线,让我们通过添加基准测试来检查这一点:

    extern crate test;
    use std::rc::Rc;
    use test::{black_box, Bencher};

    #[bench]
    fn bench_string_clone(b: &mut Bencher) {
        let s: String = (0..100_000).map(|_| 'a').collect();
        b.iter(|| {
            black_box(length(s.clone()));
        });
    }
  1. 一些 API 需要输入变量的所有权,但没有语义意义。例如,步骤 1中的 length 函数假装需要变量所有权,但除非可变性也是必要的,否则 Rust 的 std::rc::Rc(简称引用计数)类型是一个很好的选择,可以避免重量级克隆或从调用范围中移除所有权。让我们通过创建一个更好的 length 函数来试试看:

    ///
    /// The same length function, taking ownership of a Rc
    /// 
    fn rc_length(s: Rc<String>) -> usize {
        s.len() // calls to the wrapped object require no additions 
    }
  1. 我们现在可以在将类型传递到函数之后继续使用 owned 类型:
     #[test]
    fn refcounting() {
        let s = Rc::new("abcdef".to_owned());
        // we can clone Rc (reference counters) with low cost
        assert_eq!(rc_length(s.clone()), 6);

        for _ in 0..10 {
            // clone is typically an expensive deep copy
            assert_eq!(rc_length(s.clone()), 6);
        }
    }
  1. 在我们创建了一个基线基准测试之后,我们当然想知道 Rc 版本的表现如何:
    #[bench]
    fn bench_string_rc(b: &mut Bencher) {
        let s: String = (0..100_000).map(|_| 'a').collect();
        let rc_s = Rc::new(s);
        b.iter(|| {
            black_box(rc_length(rc_s.clone()));
        });
    }
  1. 首先,我们应该通过运行 cargo test 来检查实现是否正确:
$ cargo test
   Compiling sharing-ownership v0.1.0 (Rust-
   Cookbook/Chapter02/sharing-ownership)
    Finished dev [unoptimized + debuginfo] target(s) in 0.81s
     Running target/debug/deps/sharing_ownership-f029377019c63d62

running 4 tests
test tests::cloning ... ok
test tests::refcounting ... ok
test tests::bench_string_rc ... ok
test tests::bench_string_clone ... ok

test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

   Doc-tests sharing-ownership

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
  1. 现在,我们可以检查哪种变体更快,以及它们之间的区别:
$ cargo bench
   Compiling sharing-ownership v0.1.0 (Rust-
   Cookbook/Chapter02/sharing-ownership)
    Finished release [optimized] target(s) in 0.54s
     Running target/release/deps/sharing_ownership-68bc8eb23caa9948

running 4 tests
test tests::cloning ... ignored
test tests::refcounting ... ignored
test tests::bench_string_clone ... bench: 2,703 ns/iter (+/- 289)
test tests::bench_string_rc ... bench: 1 ns/iter (+/- 0)

test result: ok. 0 passed; 0 failed; 2 ignored; 2 measured; 0 filtered out

在我们探索了 Rc 的共享所有权之后,让我们深入了解它们。

它是如何工作的...

令人印象深刻的基准测试结果并非偶然:Rc 对象是堆上位置的智能指针,尽管我们仍然调用 clone 来进行深拷贝,但 Rc 只会复制一个指针并增加对其的引用计数。而实际示例函数保持简单,这样我们就不必担心它,但它确实具有我们经常遇到的复杂函数的所有特性。我们在步骤 1中定义了第一个版本,它只适用于拥有内存(输入参数不是引用),在步骤 2步骤 3中展示了步骤 1中选择的 API 的后果:如果我们想保留(传递数据的)数据副本,我们需要调用 clone 函数。

步骤 4步骤 6中,我们使用 Rust 中的一个称为 Rc 的构造函数做等效操作。拥有其中之一意味着你拥有指针位置的所有权,但不是实际值,这使得整个构造非常轻量。实际上,一次为原始值分配内存并从多个位置指向它是提高需要大量移动字符串的应用程序性能的常见方法。这在步骤 7步骤 8中可以观察到,在那里我们执行测试和基准测试。

仍然有一个注意事项。Rc 构造函数不允许可变所有权,这个问题我们将在下一个菜谱中解决。

共享可变所有权

共享所有权对于只读数据来说很棒。然而,有时需要可变性,Rust 提供了一种实现这一点的绝佳方式。如果你还记得所有权和借用规则,如果有一个可变引用,它必须是唯一的引用,以避免异常。

这通常是借用检查器介入的地方:在编译时,它确保条件成立。这是 Rust 引入内部可变性模式的地方。通过将数据包装进RefCellCell类型的对象中,可以动态地分配不可变和可变访问。让我们看看这在实践中是如何工作的。

准备工作

使用cargo new --lib mut-shared-ownership创建一个新的库项目,并在你喜欢的编辑器中打开src/lib.rs。为了启用基准测试,请使用rustup default nightly切换到nightly Rust,并在lib.rs文件的顶部添加#![feature(test)](这有助于使用基准测试类型所需的类型)。

如何做...

让我们创建一个测试,以在几个步骤中确定共享可变所有权的最佳方式:

  1. 让我们在测试模块内部创建几个新函数:
    use std::cell::{Cell, RefCell};
    use std::borrow::Cow;
    use std::ptr::eq;

    fn min_sum_cow(min: i32, v: &mut Cow<[i32]>) {
        let sum: i32 = v.iter().sum();
        if sum < min {
            v.to_mut().push(min - sum);
        }
    }

    fn min_sum_refcell(min: i32, v: &RefCell<Vec<i32>>) {
        let sum: i32 = v.borrow().iter().sum();
        if sum < min {
            v.borrow_mut().push(min - sum);
        }
    }

    fn min_sum_cell(min: i32, v: &Cell<Vec<i32>>) {
        let mut vec = v.take();
        let sum: i32 = vec.iter().sum();
        if sum < min {
            vec.push(min - sum);
        }
        v.set(vec);
    }
  1. 这些函数根据传入的数据动态地(基于特定条件,如总和至少需要是X)修改整数列表,并依赖于三种共享可变所有权的方式。让我们探索这些在外部是如何表现的!Cell对象(以及RefCell对象)只是返回值的引用或所有权的包装器:
    #[test]
    fn about_cells() {
        // we allocate memory and use a RefCell to dynamically
        // manage ownership
        let ref_cell = RefCell::new(vec![10, 20, 30]);

        // mutable borrows are fine,
        min_sum_refcell(70, &ref_cell);

        // they are equal!
        assert!(ref_cell.borrow().eq(&vec![10, 20, 30, 10]));

        // cells are a bit different
        let cell = Cell::from(vec![10, 20, 30]);

        // pass the immutable cell into the function
        min_sum_cell(70, &cell);

        // unwrap
        let v = cell.into_inner();

        // check the contents, and they changed!
        assert_eq!(v, vec![10, 20, 30, 10]);
    }
  1. 由于这与其他编程语言非常相似,在这些语言中可以自由传递引用,我们也应该知道其注意事项。一个重要的方面是,如果借用检查失败,这些Cell线程会引发恐慌,这至少会使当前线程突然停止。在几行代码中,这看起来是这样的:
    #[test]
    #[should_panic]
    fn failing_cells() {
        let ref_cell = RefCell::new(vec![10, 20, 30]);

        // multiple borrows are fine
        let _v = ref_cell.borrow();
        min_sum_refcell(60, &ref_cell);

        // ... until they are mutable borrows
        min_sum_refcell(70, &ref_cell); // panics!
    }
  1. 直观地看,这些单元格应该会增加运行时开销,因此比常规的预编译借用检查要慢。为了确认这一点,让我们添加一个基准测试:
    extern crate test;
    use test::{ Bencher};

    #[bench]
    fn bench_regular_push(b: &mut Bencher) {
        let mut v = vec![];
        b.iter(|| {
            for _ in 0..1_000 {
                v.push(10);
            }
        });
    }

    #[bench]
    fn bench_refcell_push(b: &mut Bencher) {
        let v = RefCell::new(vec![]);
        b.iter(|| {
            for _ in 0..1_000 {
                v.borrow_mut().push(10);
            }
        });
    }

    #[bench]
    fn bench_cell_push(b: &mut Bencher) {
        let v = Cell::new(vec![]);
        b.iter(|| {
            for _ in 0..1_000 {
                let mut vec = v.take();
                vec.push(10);
                v.set(vec);
            }
        });
    }
  1. 然而,我们没有解决Cell中未预见的恐慌的危险,这在复杂的应用中可能是禁止性的。这就是Cow出现的地方。Cow是一种写时复制(Copy-on-Write)类型,如果请求可变访问,它会通过惰性克隆来替换它所包装的值。通过使用这个struct,我们可以确保避免使用此代码时的恐慌:
    #[test]
    fn handling_cows() {
        let v = vec![10, 20, 30];

        let mut cow = Cow::from(&v);
        assert!(eq(&v[..], &*cow));

        min_sum_cow(70, &mut cow);

        assert_eq!(v, vec![10, 20, 30]);
        assert_eq!(cow, vec![10, 20, 30, 10]);
        assert!(!eq(&v[..], &*cow));

        let v2 = cow.into_owned();

        let mut cow2 = Cow::from(&v2);
        min_sum_cow(70, &mut cow2);

        assert_eq!(cow2, v2);
        assert!(eq(&v2[..], &*cow2));
    }
  1. 最后,让我们通过运行cargo test来验证测试和基准是否成功:
$ cargo test
   Compiling mut-sharing-ownership v0.1.0 (Rust-
   Cookbook/Chapter02/mut-sharing-ownership)
    Finished dev [unoptimized + debuginfo] target(s) in 0.81s
     Running target/debug/deps/mut_sharing_ownership-
     d086077040f0bd34

running 6 tests
test tests::about_cells ... ok
test tests::bench_cell_push ... ok
test tests::bench_refcell_push ... ok
test tests::failing_cells ... ok
test tests::handling_cows ... ok
test tests::bench_regular_push ... ok

test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

   Doc-tests mut-sharing-ownership

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
  1. 让我们看看cargo bench输出的基准时间:
$ cargo bench
    Finished release [optimized] target(s) in 0.02s
     Running target/release/deps/mut_sharing_ownership-
     61f1f68a32def1a8

running 6 tests
test tests::about_cells ... ignored
test tests::failing_cells ... ignored
test tests::handling_cows ... ignored
test tests::bench_cell_push ... bench: 10,352 ns/iter (+/- 595)
test tests::bench_refcell_push ... bench: 3,141 ns/iter (+/- 6,389)
test tests::bench_regular_push ... bench: 3,341 ns/iter (+/- 124)

test result: ok. 0 passed; 0 failed; 3 ignored; 3 measured; 0 filtered out

以各种方式共享内存是复杂的,所以让我们更深入地了解它们是如何工作的。

它是如何工作的...

这个食谱就像一个大的基准或测试方案:在步骤 1中,我们定义要测试的函数,每个函数都有不同的输入参数,但具有相同的行为;它将Vec填充到最小总和。这些参数反映了不同的共享所有权方式,包括RefCellCellCow

步骤 2步骤 3创建了仅针对RefCellCell采用的不同方式处理和失败这些值的测试。步骤 5Cow类型类似;这些都是测试您自己理论的绝佳机会!

步骤 4步骤 6中,我们正在创建和运行我们在本菜谱中创建的函数的基准测试和测试。结果是令人惊讶的。事实上,我们尝试了不同的计算机和版本,并得出相同的结论:RefCell几乎与常规方式检索可变引用(运行时行为导致更高的方差)一样快。Cell参数的减速也是预期的;它们在每次迭代中都将整个数据移入和移出——这也是我们可以从Cow期望的,所以请随意尝试它。

Cell对象和RefCell对象将数据移动到堆内存,并使用引用(指针)来访问这些值,通常需要额外的跳跃。然而,它们提供了与 C#、Java 或其他类似语言相似的方式来移动对象引用,同时提供了舒适度。

我们希望您已经成功学习了关于共享可变所有权的知识。现在,让我们继续到下一个菜谱。

使用显式生命周期进行引用

生命周期在许多语言中都很常见,通常决定一个变量是否可以在作用域外使用。在 Rust 中,由于借用和所有权模型广泛使用生命周期和作用域来自动管理内存,所以情况要复杂一些。我们开发者想要避免保留内存并将内容克隆到其中的低效和潜在的减速,而不是使用引用。然而,这会导致一条棘手的路径,因为当原始值超出作用域时,引用会发生什么?

由于编译器无法从代码中推断出这些信息,您必须帮助它并注释代码,以便它可以检查正确的使用。让我们看看这会是什么样子。

如何做到这一点...

生命周期可以通过几个步骤来探索:

  1. 使用cargo new lifetimes --lib创建一个新的项目,并在您喜欢的编辑器中打开它。

  2. 让我们从一个非常简单的函数开始,该函数接受一个可能不会比函数长寿的引用!让我们确保函数和输入参数具有相同的生命周期:

// declaring a lifetime is optional here, since the compiler automates this

///
/// Compute the arithmetic mean
/// 
pub fn mean<'a>(numbers: &'a [f32]) -> Option<f32> {
    if numbers.len() > 0 {
        let sum: f32 = numbers.iter().sum();
        Some(sum / numbers.len() as f32)
    } else {
        None
    }
} 
  1. 在结构体中需要声明生命周期。因此,我们首先定义基础struct。它为包含的类型提供了生命周期注解:
///
/// Our almost generic statistics toolkit
/// 
pub struct StatisticsToolkit<'a> {
    base: &'a [f64],
}
  1. 接下来是实现,它继续了生命周期规范。首先,我们实现构造函数(new()):
impl<'a> StatisticsToolkit<'a> {

    pub fn new(base: &'a [f64]) -> 
     Option<StatisticsToolkit> {
        if base.len() < 3 {
            None
        } else {
            Some(StatisticsToolkit { base: base })
        }
    }

然后,我们希望实现方差计算以及标准差和平均值:

    pub fn var(&self) -> f64 {
        let mean = self.mean();

        let ssq: f64 = self.base.iter().map(|i| (i - 
        mean).powi(2)).sum();
        return ssq / self.base.len() as f64;
    }

    pub fn std(&self) -> f64 {
        self.var().sqrt()
    }

    pub fn mean(&self) -> f64 {
        let sum: f64 = self.base.iter().sum();

        sum / self.base.len() as f64
    }

作为最后的操作,我们添加了中位数计算:

    pub fn median(&self) -> f64 {
        let mut clone = self.base.to_vec();

        // .sort() is not implemented for floats
        clone.sort_by(|a, b| a.partial_cmp(b).unwrap()); 

        let m = clone.len() / 2;
        if clone.len() % 2 == 0 {
            clone[m]
        } else {
            (clone[m] + clone[m - 1]) / 2.0
        }
    }
}
  1. 就这样!需要一些测试以确保我们可以确信一切按预期工作。让我们从几个辅助函数和计算平均值的测试开始:
#[cfg(test)]
mod tests {

    use super::*;

    ///
    /// a normal distribution created with numpy, with mu = 
    /// 42 and 
    /// sigma = 3.14 
    /// 
    fn numpy_normal_distribution() -> Vec<f64> {
        vec![
            43.67221552, 46.40865622, 43.44603147, 
            43.16162571, 
            40.94815816, 44.585914 , 45.84833022, 
            37.77765835, 
            40.23715928, 48.08791899, 44.80964938, 
            42.13753315, 
            38.80713956, 39.16183586, 42.61511209, 
            42.25099062, 
            41.2240736 , 44.59644304, 41.27516889, 
            36.21238554
        ]
    }

    #[test]
    fn mean_tests() {
        // testing some aspects of the mean function
        assert_eq!(mean(&vec![1.0, 2.0, 3.0]), Some(2.0));
        assert_eq!(mean(&vec![]), None);
        assert_eq!(mean(&vec![0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 
        0.0]), 
        Some(0.0));
    }

然后,我们对新函数进行一些测试:

    #[test]
    fn statisticstoolkit_new() {
        // require >= 3 elements in an array for a 
        // plausible normal distribution
        assert!(StatisticsToolkit::new(&vec![]).is_none());
        assert!(StatisticsToolkit::new(&vec![2.0, 
         2.0]).is_none());

        // a working example
        assert!(StatisticsToolkit::new(&vec![1.0, 2.0, 
         1.0]).is_some());

        // not a normal distribution, but we don't mind
        assert!(StatisticsToolkit::new(&vec![2.0, 1.0, 
         2.0]).is_some());
    }

接下来,让我们测试实际的统计数据。在一个函数中,我们开始使用一些特殊的数据输入:

    #[test]
    fn statisticstoolkit_statistics() {
         // simple best case test
        let a_sample = vec![1.0, 2.0, 1.0];
        let nd = StatisticsToolkit::
         new(&a_sample).unwrap();
        assert_eq!(nd.var(), 0.2222222222222222);
        assert_eq!(nd.std(), 0.4714045207910317);
        assert_eq!(nd.mean(), 1.3333333333333333);
        assert_eq!(nd.median(), 1.0);

        // no variance
        let a_sample = vec![1.0, 1.0, 1.0];
        let nd = StatisticsToolkit::
         new(&a_sample).unwrap();
        assert_eq!(nd.var(), 0.0);
        assert_eq!(nd.std(), 0.0);
        assert_eq!(nd.mean(), 1.0);
        assert_eq!(nd.median(), 1.0);

为了检查更复杂的数据输入(例如,偏斜分布或边缘情况),让我们进一步扩展测试:

        // double check with a real library
        let a_sample = numpy_normal_distribution();
        let nd = 
         StatisticsToolkit::new(&a_sample).unwrap();
        assert_eq!(nd.var(), 8.580276516670548);
        assert_eq!(nd.std(), 2.9292109034124785);
        assert_eq!(nd.mean(), 42.36319998250001);
        assert_eq!(nd.median(), 42.61511209);

        // skewed distribution
        let a_sample = vec![1.0, 1.0, 5.0];
        let nd = 
         StatisticsToolkit::new(&a_sample).unwrap();
        assert_eq!(nd.var(), 3.555555555555556);
        assert_eq!(nd.std(), 1.8856180831641267);
        assert_eq!(nd.mean(), 2.3333333333333335);
        assert_eq!(nd.median(), 1.0);

        // median with even collection length
        let a_sample = vec![1.0, 2.0, 3.0, 4.0] ;
        let nd = 
         StatisticsToolkit::new(&a_sample).unwrap();
        assert_eq!(nd.var(), 1.25);
        assert_eq!(nd.std(), 1.118033988749895);
        assert_eq!(nd.mean(), 2.5);
        assert_eq!(nd.median(), 3.0);
    }
}
  1. 使用cargo test运行测试并验证它们是否成功:
$ cargo test
   Compiling lifetimes v0.1.0 (Rust-Cookbook/Chapter02/lifetimes)
    Finished dev [unoptimized + debuginfo] target(s) in 1.16s
     Running target/debug/deps/lifetimes-69291f4a8f0af715

running 3 tests
test tests::mean_tests ... ok
test tests::statisticstoolkit_new ... ok
test tests::statisticstoolkit_statistics ... ok

test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

   Doc-tests lifetimes

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

与生命周期一起工作很复杂,所以让我们深入了解代码以更好地理解。

它是如何工作的...

在这个食谱中,我们创建了一个简单的统计工具箱,它允许快速准确地分析正态分布样本。然而,这个例子只是为了说明生命周期的有用性和相对简单的方法。在步骤 2中,我们创建了一个计算给定集合平均值的函数。由于生命周期可以从使用函数/变量中推断出来,因此显式指定生命周期是可选的。尽管如此,该函数明确地将输入参数的生命周期绑定到函数的生命周期,要求任何传入的引用都要比mean()长命。

步骤 3步骤 4展示了如何在结构体及其实现中处理生命周期。由于类型实例可以很容易地比存储的引用长命(每个甚至可能需要不同的生命周期),因此显式指定生命周期变得必要。生命周期必须在每一步中声明;在结构体声明中,在impl块中,以及在使用它们的函数中。生命周期的名称将它们绑定在一起。从某种意义上说,它创建了一个与类型实例的生命周期相关的虚拟作用域。

生命周期注解很有用但很冗长,这使得处理引用有时很繁琐。然而,一旦注解到位,程序可以更加高效,接口可以更加方便,移除clone()方法和其他东西。

生命周期名称的选择('a')是常见的,但也是任意的。除了预定义的'static'之外,每个单词都同样适用,而且可读的选择绝对更好。

与显式生命周期一起工作并不太难,对吧?我们建议你继续实验,直到你准备好进入下一个食谱。

使用特性界限强制行为

当构建复杂架构时,先决条件行为非常常见。在 Rust 中,这意味着我们无法构建泛型或其他类型,除非它们符合某些先前的行为,换句话说,我们需要能够指定所需的特性。特性界限是实现这一目标的一种方式——即使你之前跳过了许多食谱,你也已经看到了多个这样的例子。

如何做到...

按照以下步骤学习更多关于特性的知识:

  1. 使用cargo new trait-bounds创建一个新的项目,并在你喜欢的编辑器中打开它。

  2. 编辑src/main.rs以添加以下代码,其中我们可以轻松地打印变量的调试格式,因为编译时需要实现该格式:

///
/// A simple print function for printing debug formatted variables
/// 
fn log_debug<T: Debug>(t: T) {
    println!("{:?}", t);
}
  1. 如果我们使用自定义类型,例如 struct AnotherType(usize) 来调用它,编译器会很快抱怨:
$ cargo run
   Compiling trait-bounds v0.1.0 (Rust-Cookbook/Chapter02/trait-bounds)
error[E0277]: `AnotherType` doesn't implement `std::fmt::Debug`
  --> src/main.rs:35:5
   |
35 | log_debug(b);
   | ^^^^^^^^^ `AnotherType` cannot be formatted using `{:?}`
   |
   = help: the trait `std::fmt::Debug` is not implemented for `AnotherType`
   = note: add `#[derive(Debug)]` or manually implement `std::fmt::Debug`
note: required by `log_debug`
  --> src/main.rs:11:1
   |
11 | fn log_debug<T: Debug>(t: T) {
   | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^

error: aborting due to previous error

For more information about this error, try `rustc --explain E0277`.
error: Could not compile `trait-bounds`.

To learn more, run the command again with --verbose.
  1. 为了解决这个问题,我们可以实现或推导 Debug 特质,正如错误信息中所述。对于标准类型的组合,推导实现是非常常见的。在特质中,特质的界限变得更有趣:
///
/// An interface that can be used for quick and easy logging
/// 
pub trait Loggable: Debug + Sized {
    fn log(self) {
        println!("{:?}", &self)
    }
}
  1. 然后,我们可以创建并实现一个合适的数据类型:
#[derive(Debug)]
struct ArbitraryType {
    v: Vec<i32>
}

impl ArbitraryType {
    pub fn new() -> ArbitraryType {
        ArbitraryType {
            v: vec![1,2,3,4]
        }
    }
}
impl Loggable for ArbitraryType {}
  1. 接下来,让我们在 main 函数中将代码串联起来:
fn main() {
    let a = ArbitraryType::new();
    a.log();
    let b = AnotherType(2);
    log_debug(b);
}
  1. 执行 cargo run 并确定输出是否符合你的预期:
$ cargo run
   Compiling trait-bounds v0.1.0 (Rust-Cookbook/Chapter02/trait-
   bounds)
    Finished dev [unoptimized + debuginfo] target(s) in 0.38s
     Running `target/debug/trait-bounds`
     ArbitraryType { v: [1, 2, 3, 4] }
     AnotherType(2)

在创建示例程序后,让我们探索特质界限的背景。

它是如何工作的...

特质界限指定了实现者实现时的要求。通过这种方式,我们可以在不了解其结构的情况下对泛型类型调用函数。

第 2 步 中,我们要求任何参数类型都必须实现 std::fmt::Debug 特质,以便能够使用调试格式化程序进行打印。然而,这并不很好地推广,我们还需要要求任何 其他 函数也实现该特性。这就是为什么在 第 4 步 中,我们要求任何实现 Loggable 特质的类型也实现 Debug

因此,我们可以在特质的函数中使用所有必需的特质,这使得扩展更加容易,并提供了所有类型实现特质以保持兼容性的能力。在 第 5 步 中,我们正在为创建的类型实现 Loggable 特质,并在后续步骤中使用它。

关于所需特质的决策对于公共 API 以及编写良好设计和可维护的代码同样重要。注意真正需要哪些类型以及如何提供它们,这将导致更好的接口和类型。注意两个类型界限之间的 + 符号;它要求在实现 Loggable 时存在(以及更多,如果添加了更多的 + 符号)这两个(以及更多)特质。

我们已经成功地学习了如何使用特质界限强制行为。现在,让我们继续下一个菜谱。

与泛型数据类型一起工作

Rust 的函数重载比其他语言更奇特。你不需要用不同的类型签名重新定义相同的函数,通过指定泛型实现的实际类型,你可以达到相同的结果。泛型是提供更通用接口的绝佳方式,由于编译器的有用消息,实现起来并不复杂。

在这个菜谱中,我们将以泛型方式实现动态数组(例如 Vec<T>)。

如何做到这一点...

只需几个步骤就能学会如何使用泛型:

  1. 首先,使用 cargo new generics --lib 创建一个新的库项目,并在 Visual Studio Code 中打开项目文件夹。

  2. 动态数组是一种你每天都会使用的数据结构。在 Rust 中,它的实现被称为 Vec<T>,而其他语言则称之为 ArrayListList。首先,让我们建立基本结构:

use std::boxed::Box;
use std::cmp;
use std::ops::Index;

const MIN_SIZE: usize = 10;

type Node<T> = Option<T>;

pub struct DynamicArray<T>
where
    T: Sized + Clone,
{
    buf: Box<[Node<T>]>,
    cap: usize,
    pub length: usize,
}
  1. struct定义所示,主要元素是一个类型为T的盒子,这是一个泛型类型。让我们看看实现看起来像什么:
impl<T> DynamicArray<T>
where
    T: Sized + Clone,
{
    pub fn new_empty() -> DynamicArray<T> {
        DynamicArray {
            buf: vec![None; MIN_SIZE].into_boxed_slice(),
            length: 0,
            cap: MIN_SIZE,
        }
    }

    fn grow(&mut self, min_cap: usize) {
        let old_cap = self.buf.len();
        let mut new_cap = old_cap + (old_cap >> 1);

        new_cap = cmp::max(new_cap, min_cap);
        new_cap = cmp::min(new_cap, usize::max_value());
        let current = self.buf.clone();
        self.cap = new_cap;

        self.buf = vec![None; new_cap].into_boxed_slice();
        self.buf[..current.len()].clone_from_slice(&current);
    }

    pub fn append(&mut self, value: T) {
        if self.length == self.cap {
            self.grow(self.length + 1);
        }
        self.buf[self.length] = Some(value);
        self.length += 1;
    }

    pub fn at(&mut self, index: usize) -> Node<T> {
        if self.length > index {
            self.buf[index].clone()
        } else {
            None
        }
    }
}
  1. 到目前为止,非常直接。我们不会使用类型名称,而是简单地使用T。如果我们想为泛型定义实现一个特定类型,会发生什么?让我们为usize类型实现Index操作(Rust 中的一个特质)。此外,一个clone操作在将来会非常有帮助,所以让我们也添加那个:
impl<T> Index<usize> for DynamicArray<T>
where
    T: Sized + Clone,
{
    type Output = Node<T>;

    fn index(&self, index: usize) -> &Self::Output {
        if self.length > index {
            &self.buf[index]
        } else {
            &None
        }
    }
}

impl<T> Clone for DynamicArray<T>
where
    T: Sized + Clone,
{
    fn clone(&self) -> Self {
        DynamicArray {
            buf: self.buf.clone(),
            cap: self.cap,
            length: self.length,
        }
    }
}
  1. 为了确保所有这些都能正常工作,我们没有犯任何错误,让我们为每个实现的功能进行一些测试:

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn dynamic_array_clone() {
        let mut list = DynamicArray::new_empty();
        list.append(3.14);
        let mut list2 = list.clone();
        list2.append(42.0);
        assert_eq!(list[0], Some(3.14));
        assert_eq!(list[1], None);

        assert_eq!(list2[0], Some(3.14));
        assert_eq!(list2[1], Some(42.0));
    }

    #[test]
    fn dynamic_array_index() {
        let mut list = DynamicArray::new_empty();
        list.append(3.14);

        assert_eq!(list[0], Some(3.14));
        let mut list = DynamicArray::new_empty();
        list.append("Hello");
        assert_eq!(list[0], Some("Hello"));
        assert_eq!(list[1], None);
    }

现在,让我们添加更多的测试:

    #[test]
    fn dynamic_array_2d_array() {
        let mut list = DynamicArray::new_empty();
        let mut sublist = DynamicArray::new_empty();
        sublist.append(3.14);
        list.append(sublist);

        assert_eq!(list.at(0).unwrap().at(0), Some(3.14));
        assert_eq!(list[0].as_ref().unwrap()[0], Some(3.14));

    }

    #[test]
    fn dynamic_array_append() {
        let mut list = DynamicArray::new_empty();
        let max: usize = 1_000;
        for i in 0..max {
            list.append(i as u64);
        }
        assert_eq!(list.length, max);
    }

    #[test]
    fn dynamic_array_at() {
        let mut list = DynamicArray::new_empty();
        let max: usize = 1_000;
        for i in 0..max {
            list.append(i as u64);
        }
        assert_eq!(list.length, max);
        for i in 0..max {
            assert_eq!(list.at(i), Some(i as u64));
        }
        assert_eq!(list.at(max + 1), None);
    }
}
  1. 一旦实现了测试,我们就可以使用cargo test成功运行测试:
$ cargo test
   Compiling generics v0.1.0 (Rust-Cookbook/Chapter02/generics)
    Finished dev [unoptimized + debuginfo] target(s) in 0.82s
     Running target/debug/deps/generics-0c9bbd42843c67d5

running 5 tests
test tests::dynamic_array_2d_array ... ok
test tests::dynamic_array_index ... ok
test tests::dynamic_array_append ... ok
test tests::dynamic_array_clone ... ok
test tests::dynamic_array_at ... ok

test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

   Doc-tests generics

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

现在,让我们看看从幕后使用泛型。

它是如何工作的...

泛型在 Rust 中工作得非常好,除了冗长的符号外,它们非常方便。实际上,你会发现它们无处不在,随着你在 Rust 中的进步,对更好、更通用接口的需求也会增加。

第 2 步中,我们正在创建一个修改后的动态数组(来自书籍《动手学数据结构与算法:Rust 语言实现》www.packtpub.com/application-development/hands-data-structures-and-algorithms-rust),它使用泛型类型。在代码中使用泛型类型就像使用任何其他类型一样,用T代替i32。然而,如前所述,编译器期望T类型具有某些行为,例如实现Clone,这在结构体和实现的where子句中指定。在更复杂的使用案例中,可能会有多个块,用于T实现Clone和未实现Clone的情况,但这将超出菜谱的范围。第 3 步显示了动态数组类型的泛型实现以及CloneSized特质如何发挥作用。

第 4 步实现Index特质时,某些事情变得更加明显。首先,我们为特质实现头指定usize类型。因此,只有当有人使用usize变量(或常量/文字)进行索引时,才会实现此特质,从而排除了任何负值。第二个方面是关联类型,它本身具有泛型类型。

泛型的一个重要方面是术语Sized。当大小在编译时已知时,Rust 中的变量是Sized的,因此编译器知道要分配多少内存。无尺寸类型在编译时具有未知的大小;也就是说,它们是动态分配的,可能在运行时增长。例如,str或类型为[T]的切片。它们的实际大小可以改变,这就是为什么它们总是位于一个固定大小的引用之后,即指针。如果需要Sized,则只能使用无尺寸类型的引用(&str&[T]),但还有?Sized来使这种行为可选。

然后,步骤 5步骤 6创建一些测试并运行它们。这些测试表明动态数组的主要功能仍然正常工作,我们鼓励你尝试解决其中关于代码的任何疑问。

如果你想了解更多关于动态数组以及它是如何增长(它的大小会翻倍,就像 Java 的ArrayList一样)的细节,请查看《Rust 编程中的动手实践数据结构与算法》,在那里可以更详细地解释这个动态数组以及其他数据结构。

第三章:使用 Cargo 管理项目

cargo 是 Rust 的独特卖点之一。它通过使创建、开发、打包、维护、测试和部署应用程序代码或工具到生产环境变得相当愉快,从而简化了开发者的生活。cargo 被设计成是跨多个阶段(如下所示)工作的任何类型 Rust 项目的单一首选工具:

  • 项目创建和管理

  • 配置和执行构建

  • 依赖安装和维护

  • 测试

  • 基准测试

  • 与其他工具接口

  • 打包和发布

尤其是在系统编程领域,像 cargo 这样的工具仍然很少见——这就是为什么许多大型用户开发了他们自己的版本。作为一种年轻的语言,Rust 从其他工具的正确方面汲取了灵感:npm(用于 Node.js)的通用性和中央仓库、pip(用于 Python)的易用性,以及许多其他方面。最终,cargo 提供了许多增强 Rust 体验的绝佳方式,并被引用为希望采用该语言的开发者的主要影响因素。

在本章中,我们将介绍一些配方,使开发者能够利用 cargo 的所有功能来创建生产级别的 Rust 项目。这些基本的配方作为参考依赖项、调整编译器行为、自定义工具以及许多在日常 Rust 开发中常见的其他事物的构建块。

在本章中,我们将介绍以下配方:

  • 使用工作区组织大型项目

  • 上传到 crates.io (crates.io)

  • 使用依赖项和外部 crate

  • 通过子命令扩展 cargo

  • 使用 cargo 测试你的项目

  • cargo 的持续集成

  • 自定义构建

使用工作区组织大型项目

创建单个项目很简单:运行 cargo new my-crate 即可完成。cargo 会轻松创建从文件夹结构到一个小源文件(或单元测试)的所有内容。然而,对于由多个较小的 crate 和一个可执行文件组成的大型项目呢?或者只是一系列相关的库?cargo 工具对此的答案是工作区

如何做到这一点...

按照以下步骤创建自己的工作区来管理多个项目:

  1. 在一个终端窗口(Windows PowerShell 或 macOS/Linux 上的终端),通过运行以下命令切换到将包含工作区的目录:
$ mkdir -p my-workspace
$ cd my-workspace
  1. 使用 cargo new 命令并跟其名称来创建一个项目:
$ cargo new a-project
     Created binary (application) `a-project` package
  1. 由于我们正在讨论多个项目,让我们添加另一个库项目,我们可以使用它:
$ cargo new a-lib --lib
     Created library `a-lib` package
  1. 编辑 a-project/src/main.rs 以包含以下代码:
use a_lib::stringify;
use rand::prelude::*;

fn main() {
    println!("{{ \"values\": {}, \"sensor\": {} }}", stringify(&vec![random::<f64>(); 6]), stringify(&"temperature"));
}
  1. 然后,向 a-lib/src/lib.rs 添加一些代码,该代码将使用 Debug 特性对传入的变量进行 stringify。显然,这也需要一些测试来显示该函数的功能。让我们添加一些测试来比较使用 stringify 的数字格式化和序列格式化的输出:
use std::fmt::Debug;

pub fn stringify<T: Debug>(v: &T) -> String {
    format!("{:#?}", v)
}

#[cfg(test)]
mod tests {
    use rand::prelude::*;
    use super::stringify;

    #[test]
    fn test_numbers() { 
        let a_nr: f64 = random();
        assert_eq!(stringify(&a_nr), format!("{:#?}", a_nr));
        assert_eq!(stringify(&1i32), "1");
        assert_eq!(stringify(&1usize), "1");
        assert_eq!(stringify(&1u32), "1");
        assert_eq!(stringify(&1i64), "1");
    }

    #[test]
    fn test_sequences() {
        assert_eq!(stringify(&vec![0, 1, 2]), "[\n 0,\n 1,\n 
        2,\n]");
        assert_eq!(
            stringify(&(1, 2, 3, 4)),
            "(\n 1,\n 2,\n 3,\n 4,\n)"
        );
    }
}
  1. 让我们在每个项目的Cargo.toml文件中添加一些配置来引用依赖项:
$ cat a-project/Cargo.toml 
[package]
name = "a-project"
version = "0.1.0"
authors = ["<git user email address>"]
edition = "2018"

[dependencies]
a-lib = { path = "../a-lib" }
rand = "0.5"

$ cat a-lib/Cargo.toml 
[package]
name = "a-lib"
version = "0.1.0"
authors = ["<git user email address>"]
edition = "2018"

[dev-dependencies]
rand = "*"

a-project现在使用了a-lib库,但如果我们同时开发这些项目,来回切换(例如,在更改后测试a-lib)将很快变得繁琐。这就是工作区发挥作用的地方。

  1. 要同时使用cargo处理两个项目,我们必须在a-liba-project的父目录my-workspace中创建Cargo.toml文件。它只包含两行:
[workspace]

members = [ "a-lib", "a-project" ]
  1. 有了这个文件,cargo可以同时执行两个项目的命令,从而简化处理。让我们编译cargo test并查看哪些测试被执行,以及它们的(测试)结果:
$ cargo test
   Compiling a-project v0.1.0 (my-workspace/a-project)
    Finished dev [unoptimized + debuginfo] target(s) in 0.30s
     Running target/debug/deps/a_lib-bfd9c3226a734f51

running 2 tests
test tests::test_sequences ... ok
test tests::test_numbers ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

     Running target/debug/deps/a_project-914dbee1e8606741

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

   Doc-tests a-lib

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
  1. 由于只有一个项目有测试(a-lib),它运行了这些测试。让我们编译cargo run来查看二进制可执行项目的输出:
$  cargo run
   Compiling a-project v0.1.0 (my-workspace/a-project)
    Finished dev [unoptimized + debuginfo] target(s) in 0.41s
     Running `target/debug/a-project`
{ "values": [
    0.6798204591148014,
    0.6798204591148014,
    0.6798204591148014,
    0.6798204591148014,
    0.6798204591148014,
    0.6798204591148014,
], "sensor": "temperature" }

现在,让我们深入了解幕后,更好地理解代码。

它是如何工作的...

在这个配方中,我们创建了一个简单的二进制项目(步骤 2 和步骤 4)以及一个依赖彼此的库项目(步骤 3 和步骤 5)。我们只需在步骤 6 中指定这些依赖项,并在步骤 7 中创建的工作区帮助我们将这些项目联合起来。现在,任何命令都在支持它们的项上运行。

通过构建此项目(使用cargo runcargo testcargo build),工具会创建一个包含当前依赖树(称为Cargo.lock)的文件。作为工作区,二进制文件的输出目录(target/)也位于工作区目录中,而不是单个项目的目录中。让我们检查目录内容,看看它看起来像什么,以及编译输出可以在哪里找到(代码中的重点已被添加):

$ ls -alh
total 28K
drwxr-xr-x. 5 cm cm 4.0K Apr 11 17:29 ./
drwx------. 63 cm cm 4.0K Apr 10 12:06 ../
drwxr-xr-x. 4 cm cm 4.0K Apr 10 00:42 a-lib/
drwxr-xr-x. 4 cm cm 4.0K Apr 11 17:28 a-project/
-rw-r--r--. 1 cm cm 187 Apr 11 00:05 Cargo.lock
-rw-r--r--. 1 cm cm 48 Apr 11 00:05 Cargo.toml
drwxr-xr-x. 3 cm cm 4.0K Apr 11 17:29 target/

$ ls -alh target/debug/
total 1.7M
drwxr-xr-x. 8 cm cm 4.0K Apr 11 17:31 ./
drwxr-xr-x. 3 cm cm 4.0K Apr 11 17:31 ../
-rwxr-xr-x. 2 cm cm 1.7M Apr 11 17:31 a-project*
-rw-r--r--. 1 cm cm 90 Apr 11 17:31 a-project.d
drwxr-xr-x. 2 cm cm 4.0K Apr 11 17:31 build/
-rw-r--r--. 1 cm cm 0 Apr 11 17:31 .cargo-lock
drwxr-xr-x. 2 cm cm 4.0K Apr 11 17:31 deps/
drwxr-xr-x. 2 cm cm 4.0K Apr 11 17:31 examples/
drwxr-xr-x. 4 cm cm 4.0K Apr 11 17:31 .fingerprint/
drwxr-xr-x. 4 cm cm 4.0K Apr 11 17:31 incremental/
-rw-r--r--. 1 cm cm 89 Apr 11 17:31 liba_lib.d
-rw-r--r--. 2 cm cm 3.9K Apr 11 17:31 liba_lib.rlib
drwxr-xr-x. 2 cm cm 4.0K Apr 11 17:31 native/

工作区的一个方面是其依赖项管理。cargo同步工作区内每个项目的Cargo.lock文件中的外部项目依赖项。因此,任何外部 crate 都将在可能的情况下在每个项目中具有相同的版本。当我们添加randcrate 作为依赖项时,它为两个项目选择了相同的版本(因为a-lib中的*版本)。以下是生成的Cargo.lock文件的部分内容:

# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
[[package]]
name = "a-lib"
version = "0.1.0"
dependencies = [
 "rand 0.5.6 (registry+https://github.com/rust-lang/crates.io-index)",
]

[[package]]
name = "a-project"
version = "0.1.0"
dependencies = [
 "a-lib 0.1.0",
 "rand 0.5.6 (registry+https://github.com/rust-lang/crates.io-index)",
]
[...]

cargo工作区是一种通过在更高层次捆绑一些操作来处理大型项目的方法,同时将大多数配置留给单个 crate 和应用程序。配置简单,结果产生可预测的行为,使用户可以围绕它构建构建过程(例如,从工作区的target/目录收集所有二进制文件)。

另一个有趣的方面是,cargo 在执行命令之前会向上查找最父级的 Cargo.toml 文件。因此,看起来像是在目录内运行特定项目的测试实际上是在运行该工作空间的所有测试。因此,现在命令必须更加具体,例如,使用 cargo test -p a-lib

我们已经成功学习了如何使用工作空间组织大型项目。现在,让我们继续下一个菜谱!

上传到 crates.io

crates.io (crates.io) 是 Rust 的公共仓库,用于社区 crate。它将依赖项链接在一起,启用发现,并允许用户搜索包。对于 crate 维护者,它提供使用统计信息和托管 readme 文件的地方。cargo 使得快速轻松地发布 crate 以及处理更新成为可能。让我们看看如何操作。

准备工作

对于这个菜谱,我们将发布一个具有最小功能性的 crate。如果您已经有了可以工作的源代码(即您自己的项目),请随意使用它。如果没有,请使用 cargo new public-crate --lib 创建一个新的库项目,并在 VS Code 中打开它:

图片

前往 crates.io 并登录您的账户(使用 github.com)。然后,转到账户设置页面创建一个新的令牌(按照页面上的说明操作)。使用您自己的令牌在命令行上登录:

图片

让我们看看我们需要执行哪些步骤才能上传到 crates.io (crates.io)。

如何操作...

cargo 登录并准备就绪后,按照以下步骤将库发布到仓库:

  1. 打开 src/lib.rs 并添加一些代码。我们菜谱中的 crate 只会发布臭名昭著的冒泡排序算法!

目前,crates.io 只使用名称作为标识符,这意味着您不能再使用 bubble-sort 这个名称了。然而,我们不是要求您选择一个新名称,而是要求您不要用不同的名称发布这个 crate 的副本,而是将精力集中在对社区有用的 crate 上。

这里是来自书籍《动手学习 Rust 数据结构和算法》(Hands-On Data Structures and Algorithms with Rust) (www.packtpub.com/application-development/hands-data-structures-and-algorithms-rust) 的一个实现:

//! This is a non-optimized implementation of the [bubble sort] algorithm for the book Rust Cookbook by Packt. This implementation also clones the input vector. 
//! 
//! # Examples
//!```

`!# use bubble_sort::bubble_sort;`

`!# let v = vec![2, 2, 10, 1, 5, 4, 3];`

`!# assert_eq!(bubble_sort(&v), vec![1, 2, 2, 3, 4, 5, 10]);`

`!#```rs

///
/// See module level documentation. 
/// 
pub fn bubble_sort<T: PartialOrd + Clone>(collection: &[T]) -> Vec<T> {
    let mut result: Vec<T> = collection.into();
    for _ in 0..result.len() {
        let mut swaps = 0;
        for i in 1..result.len() {
            if result[i - 1] > result[i] {
                result.swap(i - 1, i);
                swaps += 1;
            }
        }
        if swaps == 0 {
            break;
        }
    }
    result
}

此实现还附带测试:

#[cfg(test)]
mod tests {
    use super::bubble_sort;
     #[test]
    fn test_bubble_sort() {
        assert_eq!(bubble_sort(&vec![9, 8, 7, 6]), vec![6, 7, 8, 
         9]);
        assert_eq!(bubble_sort(&vec![9_f32, 8_f32, 7_f32, 6_f32]), 
         vec!
        [6_f32, 7_f32, 8_f32, 9_f32]);

        assert_eq!(bubble_sort(&vec!['c','f','a','x']), vec!['a', 
         'c', 'f', 'x']);

        assert_eq!(bubble_sort(&vec![6, 8, 7, 9]), vec![6, 7, 8, 
         9]);
        assert_eq!(bubble_sort(&vec![2, 1, 1, 1, 1]), vec![1, 1, 1, 
         1, 2]);
    }
}
  1. 此外,cargo 允许使用 Cargo.toml 中的各种字段来自定义 crates.io 上的着陆页。着陆页应告知 crate 的用户关于许可证(没有许可证意味着每个人都必须得到你的许可才能使用代码)、如何找到更多信息,甚至可能是一个示例。除此之外,(相当花哨)徽章提供了关于 crate 构建状态、测试覆盖率等信息。用以下片段替换 Cargo.toml 中的内容(并根据需要自定义):
[package]
name = "bubble-sort"
description = "A quick and non-optimized, cloning version of the bubble sort algorithm. Created as a showcase for publishing crates in the Rust Cookbook 2018"
version = "0.1.0"
authors = ["Claus Matzinger <claus.matzinger+kb@gmail.com>"]
edition = "2018"
homepage = "https://blog.x5ff.xyz"
repository = "https://github.com/PacktPublishing/Rust-
              Programming-Cookbook"
license = "MIT"
categories = [
    "Algorithms", 
    "Support"
]
keywords = [
    "cookbook",
    "packt",
    "x5ff", 
    "bubble",
    "sort",
]
readme = "README.md"
maintenance = { status = "experimental" }
  1. 现在所有元数据都已整理好,让我们运行 cargo package 来查看该包是否满足正式标准:
$ cargo package
error: 2 files in the working directory contain changes that were not yet committed into git:

Cargo.toml
README.md

to proceed despite this, pass the `--allow-dirty` flag
  1. 作为友好的提醒,cargo 确保只打包已提交的更改,因此仓库和 crates.io 保持同步。提交更改(如果你不知道如何使用 Git,请阅读相关资料:git-scm.com)并重新运行 cargo package
$ cargo package
   Packaging bubble-sort v0.1.0 (publish-crate)
   Verifying bubble-sort v0.1.0 (publish-crate)
   Compiling bubble-sort v0.1.0 (publish-
   crate/target/package/bubble-sort-0.1.0)
    Finished dev [unoptimized + debuginfo] target(s) in 0.68s
  1. 现在,使用授权的 cargo,让我们将我们的 crate 公开并运行 cargo publish
 $ cargo publish
    Updating crates.io index
    Packaging bubble-sort v0.2.0 (Rust-Cookbook/Chapter03/publish-
    crate)
   Verifying bubble-sort v0.2.0 (Rust-Cookbook/Chapter03/publish-
    crate)
   Compiling bubble-sort v0.2.0 (Rust-Cookbook/Chapter03/publish-
   crate/target/package/bubble-sort-0.2.0)
    Finished dev [unoptimized + debuginfo] target(s) in 6.09s
   Uploading bubble-sort v0.2.0 (Rust-Cookbook/Chapter03/publish-
    crate)
  1. 一旦成功,查看你的页面 crates.io/crates/bubble-sort

现在,让我们幕后了解代码,以便更好地理解。

它是如何工作的...

发布 crate 是让 Rust 社区认可你的好方法,并让你的作品对更广泛的受众开放。为了使社区能够快速适应你的 crate,请确保使用适当的关键词和类别,以及示例和测试来使其清晰易用,这是我们已经在 步骤 1步骤 2 中做到的。Cargo.toml 提供了比之前指定的更多选项,因此请查看文档,doc.rust-lang.org/cargo/reference/manifest.html#package-metadata,以获取更多信息。

该文件中最重要的属性是包名,它唯一地标识了 crate。虽然曾经发生过名称抢注和出售名称的事件,但这通常是不被赞同的,社区也在努力寻找解决方案。

一旦打包(步骤 3步骤 4),cargo 会创建一个 target/package 目录,其中包含将被上传到 crates.io 的所有内容。在目录中,不仅有源代码,还有一个名为 project_name-version.crate 的附加二进制文件。如果你不想上传所有内容——例如,省略视频或大型示例数据——Cargo.toml 允许使用排除过滤器。默认情况下,目录中的所有内容都被包含在内,但将大小保持在最小是良好的实践!

保持你的 API 令牌的秘密,并使其不在源控制中。如果你不确定一个令牌是否已被泄露,请撤销它!

步骤 5 中,我们正在上传新的 crate。然而,crates.io 并不接受任何上传;以下是一些你可能会遇到的错误示例(观察错误信息以修复它们):

error: api errors (status 200 OK): crate version `0.1.0` is already uploaded
error: api errors (status 200 OK): invalid upload request: invalid length 6, expected at most 5 keywords per crate at line 1 column 667
error: 1 files in the working directory contain changes that were not yet committed into git:
error: api errors (status 200 OK): A verified email address is required to publish crates to crates.io. Visit https://crates.io/me to set and verify your email address.

这些实际上是非常好的通知,因为这些障碍帮助程序员避免简单的错误,减少垃圾邮件,从而提高质量。如果你遵循这些条款,你将很容易看到你自己的项目步骤 6 页面的一个版本。

我们已经成功学会了如何上传到crates.io。现在,让我们继续下一个菜谱!

使用依赖和外部 crate

在软件工程中重用其他库是一个常见的任务,这就是为什么从开始就内置了cargo的简单依赖管理。第三方依赖(称为crate)存储在一个名为crates.iocrates.io)的注册表中,这是一个用户可以找到和发现 crate 的公共平台。从 Rust 1.34 开始,也提供了私有注册表。以Cargo.toml作为这个过程的核心点,让我们深入了解如何指定这些依赖。

如何操作...

让我们看看在这些步骤中依赖管理是如何工作的:

  1. 由于我们将在命令行上打印,让我们使用cargo new external-deps创建一个新的二进制应用程序,并在 VS Code 中打开它。

  2. 打开Cargo.toml文件以添加一些依赖:

[package]
name = "external-deps"
version = "0.1.0"
authors = ["Claus Matzinger <claus.matzinger+kb@gmail.com>"]
edition = "2018"

[dependencies]
regex = { git = "https://github.com/rust-lang/regex" } # bleeding edge libraries

# specifying crate features
serde = { version = "1", features = ["derive"] }
serde_json = "*" # pick whatever version

[dev-dependencies]
criterion = "0.2.11"

[[bench]]
name = "cooking_with_rust"
harness = false
  1. 添加了这些之后,我们还需要在src/main.rs文件中添加一些代码:
use regex::Regex;
use serde::Serialize;

#[derive(Serialize)]
struct Person {
    pub full_name: String,
    pub call_me: String,
    pub age: usize,
}

fn main() {
    let a_person = Person {
        full_name: "John Smith".to_owned(),
        call_me: "Smithy".to_owned(),
        age: 42,
    };
    let serialized = serde_json::to_string(&a_person).unwrap();
    println!("A serialized Person instance: {}", serialized);

    let re = Regex::new(r"(?x)(?P<year>\d{4})-(?P<month>\d{2})-(?
    P<day>\d{2})").unwrap();
    println!("Some regex parsing:");
    let d = "2019-01-31";
    println!(" Is {} valid? {}", d, re.captures(d).is_some());
    let d = "9999-99-00";
    println!(" Is {} valid? {}", d, re.captures(d).is_some());
    let d = "2019-1-10";
    println!(" Is {} valid? {}", d, re.captures(d).is_some());
}
  1. 然后,还有dev-dependency,我们可以用它来使用稳定的 Rust 编译器创建基准测试。为此,在src/同一级别创建一个新的文件夹,并在其中添加一个文件,cooking_with_rust.rs。在 VS Code 中打开它,并添加以下代码以运行基准测试:
#[macro_use]
extern crate criterion;

use criterion::black_box;
use criterion::Criterion;

pub fn bubble_sort<T: PartialOrd + Clone>(collection: &[T]) -> Vec<T> {
    let mut result: Vec<T> = collection.into();
    for _ in 0..result.len() {
        let mut swaps = 0;
        for i in 1..result.len() {
            if result[i - 1] > result[i] {
                result.swap(i - 1, i);
                swaps += 1;
            }
        }
        if swaps == 0 {
            break;
        }
    }
    result
}

fn bench_bubble_sort_1k_asc(c: &mut Criterion) {
    c.bench_function("Bubble sort 1k descending numbers", |b| {
        let items: Vec<i32> = (0..1_000).rev().collect();
        b.iter(|| black_box(bubble_sort(&items)))
    });
}

criterion_group!(benches, bench_bubble_sort_1k_asc);
criterion_main!(benches);
  1. 现在,让我们使用这些依赖并看看cargo是如何集成它们的。让我们首先执行cargo run
$ cargo run
   Compiling proc-macro2 v0.4.27
   Compiling unicode-xid v0.1.0
   Compiling syn v0.15.30
   Compiling libc v0.2.51
   Compiling memchr v2.2.0
   Compiling ryu v0.2.7
   Compiling serde v1.0.90
   Compiling ucd-util v0.1.3
   Compiling lazy_static v1.3.0
   Compiling regex v1.1.5 (https://github.com/rust-
    lang/regex#9687986d)
   Compiling utf8-ranges v1.0.2
   Compiling itoa v0.4.3
   Compiling regex-syntax v0.6.6 (https://github.com/rust-
    lang/regex#9687986d)
   Compiling thread_local v0.3.6
   Compiling quote v0.6.12
   Compiling aho-corasick v0.7.3
   Compiling serde_derive v1.0.90
   Compiling serde_json v1.0.39
   Compiling external-deps v0.1.0 (Rust-Cookbook
    /Chapter03/external-deps)
    Finished dev [unoptimized + debuginfo] target(s) in 24.56s
     Running `target/debug/external-deps`
A serialized Person instance: {"full_name":"John Smith","call_me":"Smithy","age":42}
Some regex parsing:
  Is 2019-01-31 valid? true
  Is 9999-99-00 valid? true
  Is 2019-1-10 valid? false
  1. 它下载并编译了各种 crate(下载部分被省略,因为它只做一次)——但你能否发现缺少了什么?是作为dev-dependency指定的criterion crate,它仅用于开发(test/bench/..)操作。让我们运行cargo bench来查看 crate 的基准测试结果,包括由criterion提供的一些基本趋势(输出已省略):
$ cargo bench
   Compiling proc-macro2 v0.4.27
   Compiling unicode-xid v0.1.0
   Compiling arrayvec v0.4.10
   [...]
   Compiling tinytemplate v1.0.1
   Compiling external-deps v0.1.0 (Rust-Cookbook
    /Chapter03/external-deps)
 Compiling criterion v0.2.11
    Finished release [optimized] target(s) in 1m 32s
     Running target/release/deps/external_deps-09d742c8de9a2cc7

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

     Running target/release/deps/cooking_with_rust-b879dc4675a42592
Gnuplot not found, disabling plotting
Bubble sort 1k descending numbers 
                        time: [921.90 us 924.39 us 927.17 us]
Found 12 outliers among 100 measurements (12.00%)
  6 (6.00%) high mild
  6 (6.00%) high severe

Gnuplot not found, disabling plotting

现在,让我们幕后了解代码的更好。

它是如何工作的...

通过在Cargo.toml中指定版本和名称,cargo可以下载和编译所需的 crate,并根据需要将它们链接到项目中。实际上,cargo维护了crates.io上的 crate 和原始git依赖的缓存(检查~/.cargo目录),其中放置了最近使用的 crate。这正是我们在第一步中通过向 crate 添加混合来源的依赖所做的事情。

这些来源之一是一个git仓库,但也可以是目录的本地路径。此外,通过传递一个对象(如在步骤 1中看到的regex crate),我们可以指定 crate 的功能(如在步骤 1中显示的serde依赖项)或使用名为dev-dependencies的整个部分来指定不包含在目标输出中的依赖项。结果是序列化在Cargo.lock中的依赖项树。dev-dependency准则的使用在步骤 6中显示。其余步骤显示了如何使用外部依赖项以及cargo下载和编译的各种版本。

Cargo.toml中的版本规范是其自己的迷你语言,并且它只会根据某些限制进行升级:

  • 一个单独的数字指定主版本(在 Rust 中,<major>.<minor>.<patch>模式是强制性的)但其他版本留给cargo决定(通常是最新版本)

  • 更精确的版本留下了更少的解释空间

  • *表示任何可用版本,最新版本优先

版本字符串中可以包含更多字符和符号,但通常这些就足够了。更多示例请查看doc.rust-lang.org/cargo/reference/specifying-dependencies.htmlcargo upgrade命令也会检查规范允许的最新版本,并相应地更新它们。如果你计划构建其他人使用的 crate,建议偶尔运行cargo upgrade以查看是否遗漏了任何安全/补丁更新。Rust 项目甚至建议将Cargo.lock文件放入源代码控制中,以避免意外破坏 crate。

最好尽量减少所需的 crate 数量,并尽可能保持它们是最新的。你的用户也会希望这样做。

相关内容...

Rust 1.34 版本也允许私有仓库。有关更多信息,请参阅以下博客文章:blog.rust-lang.org/2019/04/11/Rust-1.34.0.html#alternative-cargo-registries。我们现在已经成功学习了如何使用依赖项和外部 crate。现在,让我们继续下一个菜谱!

通过子命令扩展 cargo

这些天,一切都可以扩展。无论是称为插件、扩展、附加组件还是子命令——一切都是为了定制(开发者)体验。cargo提供了一个非常简单的方法来实现这一点:通过使用二进制文件名。这允许快速扩展cargo基础,包括针对你自己的用例或工作方式特定的功能。在这个菜谱中,我们将构建自己的扩展。

准备工作

对于这个菜谱,我们将停留在命令行,并使用一个简单的二进制示例代码,因此请打开终端/PowerShell(我们在 Windows 上使用 PowerShell 功能)以运行这个菜谱中的命令。

如何做到这一点...

扩展 cargo 意外地简单。要完成此操作,请执行以下步骤:

  1. 使用以下命令创建一个新的 Rust 二进制应用程序项目:cargo new cargo-hello

  2. 切换到 cargo-hello 目录,并使用 cargo build 构建它。

  3. 将当前项目位于您的 PATH 变量中的 target/debug 文件夹添加到您的 PATH 变量中。在 Linux 和 Mac(使用 bash)上,操作如下:

export PATH=$PATH:/path/to/cargo-hello/target/debug

在 Windows 上,您可以使用 PowerShell 通过以下代码脚本达到相同的目的:

$env:Path += ";C:/path/to/cargo-hello/target/debug"
  1. 在同一个窗口中,您现在应该能够在计算机上的任何目录中运行 cargo-hello(Windows 上的 cargo-hello.exe)。

  2. 此外,cargo 现在可以将 hello 作为子命令运行。尝试在计算机上的任何目录中运行 cargo hello。从这里,您将看到以下输出:

$ cargo hello
Hello, world!

现在,让我们深入了解代码,以更好地理解它。

它是如何工作的...

cargo 会拾取任何以 cargo- 开头且在 PATH 环境变量中可用的可执行文件。在 *nix 系统中,列出的目录用于发现命令行可执行文件。

为了使 cargo 无缝集成这些扩展,它们的名称必须满足一些条件:

  • 这些二进制文件必须在当前平台上可执行

  • 名称以 cargo- 开头

  • 包含的文件夹列在 PATH 变量中

在 Linux/macOS 上,这些可执行文件也可以是 shell 脚本——这对于提高开发者工作流程非常有用。然而,这些脚本必须看起来像二进制文件,因此没有文件扩展名。然后,而不是运行多个命令,例如 cargo publishgit taggit pushcargo shipit 可以显著提高速度和一致性。

此外,任何 cargo 子命令都可以接受命令行参数,这些参数是在命令之后传递的,并且默认情况下工作目录是命令运行的目录。有了这些知识,我们希望您现在可以向 cargo 功能添加更多内容!

我们已经成功学习了如何通过子命令扩展 cargo。现在,让我们继续下一个菜谱!

使用 cargo 测试项目

在之前的菜谱中,我们专注于编写测试,而这个菜谱则是关于运行它们。测试是软件工程的重要组成部分,因为它确保我们站在用户的角度,并再次检查我们所创建的内容是否正常工作。虽然许多其他语言需要单独的测试运行器,但 cargo 内置了这一功能!

让我们在这个菜谱中探索 cargo 如何帮助这个过程。

如何做...

要探索 cargo 测试功能,请按照以下步骤操作:

  1. 使用 cargo new test-commands --lib 在命令行中创建一个新的项目,并在 VS Code 中打开生成的文件夹。

  2. 接下来,将 src/lib.rs 中的内容替换为以下内容:

#[cfg(test)]
mod tests {

    use std::thread::sleep;
    use std::time::Duration;

    #[test]
    fn it_works() {
        assert_eq!(2 + 2, 4);
    }

    #[test]
    fn wait_10secs() {
        sleep(Duration::from_secs(10));
        println!("Waited for 10 seconds");
        assert_eq!(2 + 2, 4);
    }

    #[test]
    fn wait_5secs() {
        sleep(Duration::from_secs(5));
        println!("Waited for 5 seconds");
        assert_eq!(2 + 2, 4);
    }

        #[test]
    #[ignore]
    fn ignored() {
        assert_eq!(2 + 2, 4);
    }
}
  1. 正如我们在其他菜谱中所做的那样,我们可以使用 cargo test 命令执行所有测试:
$ cargo test
   Compiling test-commands v0.1.0 (Rust-Cookbook/Chapter03/test-
    commands)
    Finished dev [unoptimized + debuginfo] target(s) in 0.37s
     Running target/debug/deps/test_commands-06e02dadda81dfcd

running 4 tests
test tests::ignored ... ignored
test tests::it_works ... ok
test tests::wait_5secs ... ok
test tests::wait_10secs ... ok

test result: ok. 3 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out

   Doc-tests test-commands

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
  1. 为了快速迭代,cargo 允许我们通过使用 cargo test <test-name> 来执行特定的测试:
$ cargo test tests::it_works
    Finished dev [unoptimized + debuginfo] target(s) in 0.05s
     Running target/debug/deps/test_commands-06e02dadda81dfcd

running 1 test
test tests::it_works ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 3 filtered out
  1. 运行测试的另一种有用方法是不要捕获它们的输出。默认情况下,测试框架不会从测试内部打印任何内容。有时,有一些测试输出是有用的,所以让我们使用cargo test -- --nocapture来查看输出:
$ cargo test -- --nocapture
    Finished dev [unoptimized + debuginfo] target(s) in 0.01s
     Running target/debug/deps/test_commands-06e02dadda81dfcd

running 4 tests
test tests::ignored ... ignored
test tests::it_works ... ok
Waited for 5 seconds
test tests::wait_5secs ... ok
Waited for 10 seconds
test tests::wait_10secs ... ok

test result: ok. 3 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out

   Doc-tests test-commands

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
  1. 所有测试都是并行运行的,这有时会导致意外结果。为了调整这种行为,我们可以使用cargo test -- --test-threads <线程数>来控制线程数。让我们比较使用四个线程和一个线程的差异。我们将使用time程序来显示运行时间(如果你没有time,这是可选的)。让我们从四个线程开始:
$ time -f "%e" cargo test -- --test-threads 4
   Compiling test-commands v0.1.0 (/home/cm/workspace/Mine/Rust-
    Cookbook/Chapter03/test-commands)
    Finished dev [unoptimized + debuginfo] target(s) in 0.35s
     Running target/debug/deps/test_commands-06e02dadda81dfcd

running 4 tests
test tests::ignored ... ignored
test tests::it_works ... ok
test tests::wait_5secs ... ok
test tests::wait_10secs ... ok

test result: ok. 3 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out

   Doc-tests test-commands

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

10.53

与单线程相比,这要快得多:

$ time -f "%e" cargo test -- --test-threads 1
    Finished dev [unoptimized + debuginfo] target(s) in 0.03s
     Running target/debug/deps/test_commands-06e02dadda81dfcd

running 4 tests
test tests::ignored ... ignored
test tests::it_works ... ok
test tests::wait_10secs ... ok
test tests::wait_5secs ... ok

test result: ok. 3 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out

   Doc-tests test-commands

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

15.17
  1. 最后,我们还可以过滤多个测试,例如所有以wait开头的测试:
$ cargo test wait
    Finished dev [unoptimized + debuginfo] target(s) in 0.03s
     Running target/debug/deps/test_commands-06e02dadda81dfcd

running 2 tests
test tests::wait_5secs ... ok
test tests::wait_10secs ... ok

现在,让我们幕后了解代码以更好地理解它。

它是如何工作的...

Rust 的内置测试库称为 libtest,它是通过 cargo 调用的。无论创建的项目类型(二进制应用程序或库)如何,libtest都会运行相关的测试并输出结果。在这个菜谱中,我们正在检查运行之前创建的项目中的测试——然而,这些步骤显然适用于任何有测试的项目。

步骤 2中,我们创建了一个包含四个测试的小型库,其中两个测试在等待几秒钟(五秒和十秒)后向命令行打印内容。这使我们能够展示线程化测试运行,并且测试框架默认会捕获输出。

除了在项目中过滤可用的测试列表(我们在步骤 4步骤 7中这样做)之外,libtest还接受命令行参数来自定义输出、日志、线程以及许多其他事情。通过调用cargo test -- --help来了解更多信息。注意双横线(--),这告诉cargo将任何后续参数传递给libtest

如选项所表明的:除非另有说明,否则所有测试都是并行运行的,我们在步骤 6中更改了此选项,并取得了显著的效果(单线程时为 15 秒,多线程时为 10 秒——就像最长的睡眠时间一样)。使用此选项来调试竞态条件或其他运行时行为。

步骤 5使用一个选项来显示标准输出,其顺序与我们编写的测试函数不同。这是并发执行的结果,因此结合限制线程数的选项和输出捕获将线性执行测试。我们通过在步骤 7中过滤多个测试来结束步骤。

我们已经成功学习了如何使用cargo测试我们的项目。现在,让我们继续下一个菜谱!

与 cargo 的持续集成

自动化是当今软件工程的一个重要方面。无论是基础设施即代码还是函数即服务,许多事情都期望自动按预期工作。然而,基于某些规则的中心测试和部署基础设施的概念要古老得多(称为 ALM应用生命周期管理),并且使用现代工具,它变得极其简单。cargo 是为了支持这种无状态基础设施而构建的,它具有合理的默认值和易于定制的用户界面。

在这个菜谱中,我们将以 Microsoft 的 Azure DevOps 平台为例,看看如何构建 Rust 应用程序。

准备工作

虽然 Azure DevOps 仓库 (azure.microsoft.com/en-us/services/devops/?nav=min) 对任何人都是可访问的,但强烈建议创建一个 Microsoft 账户并利用免费层来重现示例。访问 azure.microsoft.com/en-us/services/devops/ 并按照说明开始操作。

为了有一个现成的项目来工作,我们正在重用本章前面提到的 Uploading to crates.io 菜单中的 bubble-sort crate,并将其上传到源代码托管服务,如 Azure DevOps 或 GitHub。

如何做到这一点...

打开浏览器窗口,导航到dev.azure.com,登录,并找到您创建的项目。然后,按照以下步骤操作:

  1. Azure DevOps 是项目管理的一站式解决方案,因此我们将源代码推送到可用的仓库。遵循仓库设置指南来完成此操作。

  2. 管道是 Azure DevOps 的持续集成部分。它们编排构建代理(运行构建的机器)并提供一个可视化界面来逐步构建过程。从空作业模板创建一个新的管道:

  1. 在每个管道内部,有几个作业——几个在同一代理上运行的步骤,但我们只需要一个。点击预定义代理作业 1 右侧的 + 符号,搜索名为 rust 的构建任务:

  1. 由于特定的构建任务在市场(归功于 Sylvain Pontoreau: github.com/spontoreau/rust-azure-devops)上可用,我们必须将其添加到我们的项目中。

  2. 购买(它是免费的)任务蓝图后,我们可以在管道中添加和配置它。有一个运行测试的构建非常有用,但 CI 系统非常灵活,你可以非常富有创意。现在,您的屏幕应该看起来像这样:

  1. 使用第一个任务(无需配置),因为它只是使用 rustup 安装工具。第二个任务简单地运行 cargo test

  1. 作为最后一步,排队构建并检查其进度。如果你遵循了配方,它将导致构建成功,你可以开始使用它来检查拉取请求,在 crates.io 上添加徽章,以及更多:

图片

现在,让我们深入了解代码,以便更好地理解它。

它是如何工作的...

Azure DevOps 是一个集成的项目管理、问题跟踪、源代码托管、构建和部署解决方案的解决方案。类似的产品还有 GitHub(也由微软拥有)、GitLab (about.gitlab.com) 或 Atlassian 的 Bitbucket (bitbucket.org)。与 CircleCI (circleci.com) 或 Travis CI (travis-ci.org) 一起,这些平台为团队提供了强大的工具,以确保每次新部署都能可靠地达到他们的目标,而无需大量管理开销。

基本思想很简单:通过使构建在中立平台上工作,大多数明显的错误(缺少依赖项或依赖于环境特定性)可以很容易地避免,同时运行必须在本地上运行的相同测试。除此之外,对于大型项目来说,运行每个测试可能要求很高,而专用基础设施则负责处理这一点。

由于计算机非常挑剔,测试运行的结果也是可见的,并且可以用来禁止某些操作,例如在测试失败的情况下部署到生产环境。从某种意义上说,持续集成系统使开发者对其自己的规则(测试)负责。

cargo 支持是通过在无状态系统中成为良好公民来隐式实现的。它不会因为某些条件未满足而失败,而是试图减轻晚期终止,并且一开始只需要很少的配置。它处理依赖关系轻松且良好的能力,加上对子命令的支持,使其成为跨平台构建的绝佳方式。

这里有一些关于除了运行 cargo test 之外你可以做的事情的想法:

  • 运行基准测试

  • 运行集成测试

  • 格式化代码

  • 仅在测试成功时接受 PR

  • 生成文档

  • 执行静态代码分析

Azure DevOps 还支持发布管道,这些管道应用于诸如发布到 crates.io(或其他软件包仓库)、更新托管文档等任务。有关如何操作的说明,请参阅 Azure DevOps 文档(docs.microsoft.com/en-us/azure/devops/?view=azure-devops)。对于更喜欢 YAML (yaml.org/) 文件来配置 CI 管道的用户,Azure DevOps 也支持这些文件。

感谢 Sylvain Pontoreau 在创建易于使用的任务模板方面的工作(twitter.com/bla),我们可以快速设置构建、测试或其他流水线。手动操作对于每个平台来说可能很棘手,对于大多数开发者来说,维护下载和 shell 脚本也是一件麻烦事。如果你在使用他的工作,他也很乐意听到这个消息——例如,在 Twitter(twitter.com/spontoreau)上。

我们已经成功地学习了如何与cargo进行持续集成。现在,让我们继续下一道菜谱!

自定义构建

cargo是多功能的——这是我们在这个章节的前几道菜谱中已经确立的。然而,我们没有触及配置cargo用于编译和运行 Rust 项目的工具。为此,有多种方法,因为它们适用于不同的领域。

在这个菜谱中,我们将通过自定义新项目的构建来探索两种方法。

如何做到这一点...

这就是如何自定义构建:

  1. 使用cargo new custom-build创建一个新的二进制项目,并使用 VS Code 打开项目文件夹。

  2. 打开src/main.rs并将 hello world 代码替换为以下内容:

fn main() {
    println!("Overflow! {}", 128u8 + 129u8);
}
  1. 我们二进制中的代码现在正在创建一个编译器可以轻松捕获的溢出情况。然而,默认的发布构建已经关闭了该功能。运行cargo run --release来查看其效果:
$ cargo run --release
 Finished release [optimized] target(s) in 0.02s
 Running `target/release/custom-build`
Overflow! 1
  1. 如果我们想要改变编译器在发布模式下在编译时验证溢出错误的事实(即使溢出在硬件驱动程序中可能是有用的),我们必须编辑Cargo.toml并自定义release配置文件(还有其他配置,例如devtest)。当我们到达那里时,我们可以更改一些其他选项以加快构建速度(这对于大型项目来说很重要):
# Let's modify the release build
[profile.release]
opt-level = 2
incremental = true # default is false
overflow-checks = true
  1. 现在我们运行cargo run --release,输出已经改变:
$ cargo run --release
   Compiling custom-build v0.1.0 (Rust-Cookbook/Chapter03/custom-build)
error: attempt to add with overflow
 --> src/main.rs:2:30
  |
2 | println!("Overflow! {}", 128u8 + 129u8);
  | ^^^^^^^^^^^^^
  |
  = note: #[deny(const_err)] on by default

error: aborting due to previous error

error: Could not compile `custom-build`.

To learn more, run the command again with --verbose.
  1. 这很简单——但还有更多!在项目的根目录下创建一个.cargo目录,并在其中添加一个config文件。由于文件(和目录)位于项目内部,这就是它的作用域。然而,通过将.cargo目录向上移动几个级别,可以使它对更多的项目有效。请注意,用户的家目录代表全局作用域,这意味着cargo配置适用于用户的所有项目。以下设置将默认构建目标切换为 WASM 输出(webassembly.org/)并将构建工件目录重命名为out(默认为target):
[build]
target = "wasm32-unknown-unknown" # the new default target
target-dir = "out"                # custom build output directory
  1. 现在,让我们从src/main.rs中移除溢出:
fn main() {
    println!("Overflow! {}", 128 + 129);
}
  1. 使用cargo buildcargo run进行编译以查看发生了什么:
$  cargo build
   Compiling custom-build v0.1.0 (Rust-Cookbook/Chapter03/custom-
    build)
    Finished dev [unoptimized + debuginfo] target(s) in 0.37s
$  cargo run
   Compiling custom-build v0.1.0 (Rust-Cookbook/Chapter03/custom-
    build)
    Finished dev [unoptimized + debuginfo] target(s) in 0.15s
     Running `out/wasm32-unknown-unknown/debug/custom-build.wasm`
     out/wasm32-unknown-unknown/debug/custom-build.wasm:
      out/wasm32-unknown-unknown/debug/custom-build.wasm: cannot 
       execute binary file

现在,让我们深入了解代码以更好地理解它。

它是如何工作的...

项目有许多方面可以进行配置,其中大多数对于较小的程序和库来说并不需要(除非是为了特殊架构)。这个配方只能展示一些——简单的——可能的例子,但在cargo手册中关于配置(doc.rust-lang.org/cargo/reference/config.html)和清单(doc.rust-lang.org/cargo/reference/manifest.html#the-profile-sections)有更多内容。

在第一步中,通过更改cargo配置中的一个标志来配置cargo以忽略溢出错误。虽然一开始这可能看起来是一个愚蠢的步骤,但有时为了允许驱动程序或其他低级电子设备运行,这是必要的。

许多其他选项定制了开发者的体验(例如,为新项目设置名称和电子邮件地址、别名等)或在非标准设置中很有用,例如,在创建设备驱动程序、操作系统或为专用硬件的实时软件时。我们可能在第九章,《系统编程变得简单》中稍后使用其中的一些。

然而,更改build部分(如在cargo build中)有严重的后果,因为它代表了项目的标准输出格式。将其更改为类似 WASM 的东西可能看起来很随意,但作为默认设置,它可以节省开发者设置开发环境的许多步骤——或者简单地使 CI 构建脚本不那么冗长。

无论如何,cargo非常灵活且易于配置,但它针对每个项目单独定制。查看清单和文档,了解它如何使你的项目(以及你的生活)变得更简单。

第四章:无畏并发

并发和并行是现代编程的重要组成部分,Rust 完美地配备了处理这些挑战的能力。借用和所有权模型对于防止数据竞争(在数据库世界中被称为异常)非常有效,因为变量默认是不可变的,如果需要可变性,则不能有其他任何对数据的引用。这使得 Rust 中的任何类型的并发都更加安全且复杂度更低(与其他许多语言相比)。

在本章中,我们将介绍几种使用并发来解决问题的方法,甚至还会探讨未来,这些在未来写作时还不是语言的一部分。如果你在将来阅读这篇文章(不是字面意思),这可能已经是核心语言的一部分了,你可以查看 使用 futures 进行异步编程 食谱以供历史参考。

在本章中,我们将介绍以下食谱:

  • 将数据移动到新线程中

  • 管理多个线程

  • 线程间的消息传递

  • 共享可变状态

  • 多进程

  • 将顺序代码并行化

  • 向量中的并发数据处理

  • 共享不可变状态

  • 演员 和 异步消息

  • 使用 futures 进行异步编程

将数据移动到新线程中

Rust 线程的工作方式与其他任何语言一样——在作用域中。任何其他作用域(如闭包)都可以轻松地从父作用域借用变量,因为很容易确定变量何时被丢弃。然而,在启动线程时,与父作用域的生命周期相比,其生命周期是无法知道的,因此引用可能在任何时候变得无效。

为了解决这个问题,线程作用域可以拥有其变量——内存被移动到线程的作用域中。让我们看看这是如何实现的!

如何做到这一点...

按照以下步骤查看如何在线程之间移动内存:

  1. 使用 cargo new simple-threads 创建一个新的应用程序项目,并在 Visual Studio Code 中打开该目录。

  2. 编辑 src/main.rs 并启动一个简单的线程,该线程不会将数据移动到其作用域中。由于这是线程的最简单形式,让我们打印一些内容到命令行并等待:

use std::thread;
use std::time::Duration;

fn start_no_shared_data_thread() -> thread::JoinHandle<()> {
    thread::spawn(|| {
        // since we are not using a parent scope variable in here
        // no move is required
        println!("Waiting for three seconds.");
        thread::sleep(Duration::from_secs(3)); 
        println!("Done")
    })
}
  1. 现在,让我们在 fn main() 中调用新函数。将 hello world 碎片替换为以下内容:
    let no_move_thread = start_no_shared_data_thread();

    for _ in 0..10 {
        print!(":");
    }

    println!("Waiting for the thread to finish ... {:?}", 
    no_move_thread.join());
  1. 让我们运行代码看看它是否工作:
$ cargo run
 Compiling simple-threads v0.1.0 (Rust-Cookbook/Chapter05/simple-
 threads)
    Finished dev [unoptimized + debuginfo] target(s) in 0.35s
     Running `target/debug/simple-threads`
::::::::::Waiting for three seconds.
Done
Waiting for the thread to finish ... Ok(())
  1. 现在,让我们将一些外部数据移动到线程中。向 src/main.rs 中添加另一个函数:
fn start_shared_data_thread(a_number: i32, a_vec: Vec<i32>) -> thread::JoinHandle<Vec<i32>> {
    // thread::spawn(move || {
    thread::spawn(|| {
        print!(" a_vec ---> [");
        for i in a_vec.iter() {
            print!(" {} ", i);
        }
        println!("]");
        println!(" A number from inside the thread: {}", a_number);
        a_vec // let's return ownership
    })
}
  1. 为了演示底层发生的事情,我们目前省略了 move 关键字。使用以下代码扩展 main 函数:
    let a_number = 42;
    let a_vec = vec![1,2,3,4,5];

    let move_thread = start_shared_data_thread(a_number, a_vec);

    println!("We can still use a Copy-enabled type: {}", a_number); 
    println!("Waiting for the thread to finish ... {:?}", 
    move_thread.join());
  1. 它是否工作?让我们尝试 cargo run
$ cargo run
Compiling simple-threads v0.1.0 (Rust-Cookbook/Chapter04/simple-threads)
error[E0373]: closure may outlive the current function, but it borrows `a_number`, which is owned by the current function
  --> src/main.rs:22:20
   |
22 | thread::spawn(|| {
   |               ^^ may outlive borrowed value `a_number`
...
29 |    println!(" A number from inside the thread: {}", a_number);
   |                                                     -------- `a_number` is borrowed here
   |
note: function requires argument type to outlive `'static`
  --> src/main.rs:22:6
   |
23 | /     thread::spawn(|| {
24 | |        print!(" a_vec ---> [");
25 | |        for i in a_vec.iter() {
...  |
30 | |        a_vec // let's return ownership
31 | |     })
   | |______^
help: to force the closure to take ownership of `a_number` (and any other referenced variables), use the `move` keyword
   |
23 | thread::spawn(move || {
   |               ^^^^^^^

error: aborting due to previous error

For more information about this error, try `rustc --explain E0373`.
error: Could not compile `simple-threads`.

To learn more, run the command again with --verbose.
  1. 我们已经看到了:要将任何类型的数据移动到线程作用域中,我们需要通过使用 move 关键字将值移动到作用域中来转移所有权。让我们遵循编译器的指示:
///
/// Starts a thread moving the function's input parameters
/// 
fn start_shared_data_thread(a_number: i32, a_vec: Vec<i32>) -> thread::JoinHandle<Vec<i32>> {
    thread::spawn(move || {
    // thread::spawn(|| {
        print!(" a_vec ---> [");
        for i in a_vec.iter() {
            print!(" {} ", i);
        }
        println!("]");
        println!(" A number from inside the thread: {}", a_number);
        a_vec // let's return ownership
    })
}
  1. 让我们再次尝试使用 cargo run
$ cargo run
   Compiling simple-threads v0.1.0 (Rust-Cookbook/Chapter04/simple-
   threads)
    Finished dev [unoptimized + debuginfo] target(s) in 0.38s
     Running `target/debug/simple-threads`
::::::::::Waiting for three seconds.
Done
Waiting for the thread to finish ... Ok(())
We can still use a Copy-enabled type: 42
   a_vec ---> [ 1 2 3 4 5 ]
   A number from inside the thread: 42
Waiting for the thread to finish ... Ok([1, 2, 3, 4, 5])

现在,让我们深入了解代码,以更好地理解它。

它是如何工作的...

Rust 中的线程行为与常规函数非常相似:它们可以拥有所有权,并且可以使用与闭包相同的语法(|| {} 是一个不带参数的空/noop 函数)。因此,我们必须像对待函数一样对待它们,并从所有权和借用(或更具体地说:生命周期)的角度来考虑。将引用(默认行为)传递到这个线程函数中,使得编译器无法跟踪引用的有效性,这对代码安全是一个问题。Rust 通过引入 move 关键字来解决此问题。

使用 move 关键字改变了默认的借用行为,将每个变量的所有权移动到作用域中。因此,除非这些值实现了 Copy 特性(如 i32),或者借用时具有比线程更长的生命周期(如 str 文本的 `'static' 生命周期),否则它们将无法在线程的父作用域中使用。

返回所有权也像在函数中一样工作——通过 return 语句。等待其他线程(使用 join())的线程可以解包 join() 的结果来检索返回值。

Rust 中的线程是操作系统的原生线程,每个线程都有自己的局部状态和执行栈。当它们崩溃时,只有线程停止,而不是整个程序。

我们已经成功地将数据移动到新的线程中。现在让我们继续到下一个菜谱。

管理多个线程

单线程很棒,但在现实中,许多用例需要大量的线程来并行执行大规模数据集。这已经被几年前发布的 map/reduce 模式所普及,并且仍然是一种处理多个文件、数据库结果中的行等不同事物的并行的好方法。无论来源如何,只要处理不是相互依赖的,就可以将其分块并 映射,这两者 Rust 都可以使其变得简单且无数据竞争条件。

如何实现...

在这个菜谱中,我们将添加更多线程来进行映射风格的数据处理。按照以下步骤操作:

  1. 运行 cargo new multiple-threads 来创建一个新的应用程序项目,并在 Visual Studio Code 中打开该目录。

  2. src/main.rs 中,在 main() 之上添加以下函数:

use std::thread;

///
/// Doubles each element in the provided chunks in parallel and returns the results.
/// 
fn parallel_map(data: Vec<Vec<i32>>) -> Vec<thread::JoinHandle<Vec<i32>>> {
    data.into_iter()
        .map(|chunk| thread::spawn(move ||
         chunk.into_iter().map(|c| 
         c * 2).collect()))
        .collect()
}
  1. 在这个函数中,我们为每个传入的块启动一个线程。这个线程只将数字加倍,因此函数为每个包含此转换结果的块返回 Vec<i32>。现在我们需要创建输入数据并调用该函数。让我们扩展 main 来实现这一点:
fn main() {

    // Prepare chunked data
    let data = vec![vec![1, 2, 3], vec![4, 4, 5], vec![6, 7, 7]];

    // work on the data in parallel
    let results: Vec<i32> = parallel_map(data.clone())
        .into_iter() // an owned iterator over the results
        .flat_map(|thread| thread.join().unwrap()) // join each 
         thread
        .collect(); // collect the results into a Vec

    // flatten the original data structure
    let data: Vec<i32> = data.into_iter().flat_map(|e| e)
     .collect();

    // print the results
    println!("{:?} -> {:?}", data, results);
}
  1. 使用 cargo run 我们现在可以看到结果:
$ cargo run
   Compiling multiple-threads v0.1.0 (Rust-
    Cookbook/Chapter04/multiple-threads)
    Finished dev [unoptimized + debuginfo] target(s) in 0.45s
     Running `target/debug/multiple-threads`
    [1, 2, 3, 4, 4, 5, 6, 7, 7] -> [2, 4, 6, 8, 8, 10, 12, 14, 14]

现在,让我们深入了解代码,以更好地理解它。

它是如何工作的...

承认,在 Rust 中处理多个线程就像我们在处理单线程一样,因为没有方便的方法来连接线程列表或类似的东西。相反,我们可以使用 Rust 的迭代器的力量以表达的方式做到这一点。有了这些功能结构,我们可以用一系列函数来替换for循环,这些函数可以懒惰地处理集合,这使得代码更容易处理且更高效。

在完成步骤 1的项目设置后,我们实现了一个多线程函数,用于对每个块应用一个操作。这些块仅仅是向量的部分,在这个例子中,一个操作——一个简单的将输入变量加倍的功能——可以对任何类型的任务进行。步骤 3展示了如何调用多线程的mapping函数,以及如何通过在未来的/承诺(dist-prog-book.com/chapter/2/futures.html)方式中使用JoinHandle来获取结果。步骤 4简单地展示了它按预期工作,通过输出加倍后的块作为扁平列表。

另一个有趣的是,我们不得不克隆数据的次数。由于将数据传递到线程中只能通过将值移动到每个线程的内存空间中,因此克隆通常是解决这些共享问题的唯一方法。然而,我们将在本章后面的食谱(共享不可变状态)中介绍一个类似多个Rc的方法,所以让我们继续下一个食谱。

使用通道在线程之间进行通信

在许多标准库和编程语言中,线程间的消息传递一直是一个问题,因为许多依赖于用户应用锁定。这导致了死锁,对于新手来说有些令人生畏,这也是为什么许多开发者对 Go 普及通道概念感到兴奋,我们也可以在 Rust 中找到这一点。Rust 的通道非常适合用几行代码设计一个安全、事件驱动的应用程序,而不需要任何显式的锁定。

如何做到这一点...

让我们创建一个简单的应用程序,该程序在命令行中可视化传入的值:

  1. 运行cargo new channels以创建一个新的应用程序项目,并在 Visual Studio Code 中打开该目录。

  2. 首先,让我们先处理基础知识。打开src/main.rs文件,并添加导入和一个enum结构:

use std::sync::mpsc::{Sender, Receiver};
use std::sync::mpsc;
use std::thread;

use rand::prelude::*;
use std::time::Duration;

enum ChartValue {
    Star(usize),
    Pipe(usize)
}
  1. 然后,在main函数内部,我们使用mpsc::channel()函数创建了一个通道,以及两个负责发送的线程。之后,我们将使用两个线程以可变延迟向主线程发送消息。以下是代码:
fn main() {
    let (tx, rx): (Sender<ChartValue>, Receiver<ChartValue>) = 
     mpsc::channel();

    let pipe_sender = tx.clone();

    thread::spawn(move || {
        loop {
            pipe_sender.send(ChartValue::Pipe(random::<usize>() % 
             80)).unwrap();
            thread::sleep(Duration::from_millis(random::<u64>() % 
             800));
        }
    });

    let star_sender = tx.clone();
    thread::spawn(move || {
        loop {
            star_sender.send(ChartValue::Star(random::<usize>() % 
             80)).unwrap();
            thread::sleep(Duration::from_millis(random::<u64>() % 
             800));
        }
    });
  1. 两个线程都在向通道发送数据,所以缺少的是通道的接收端来处理输入数据。接收器提供了两个函数,recv()recv_timeout(),这两个函数都会阻塞调用线程,直到接收到一个项目(或达到超时)。我们只是将要打印字符乘以传入的值:
    while let Ok(val) = rx.recv_timeout(Duration::from_secs(3)) {

        println!("{}", match val {
            ChartValue::Pipe(v) => "|".repeat(v + 1),
            ChartValue::Star(v) => "*".repeat(v + 1)
        });
    }
}
  1. 为了在最终运行程序时使用 rand,我们仍然需要将其添加到 Cargo.toml 中,如下所示:
[dependencies]
rand = "⁰.5"
  1. 最后,让我们看看程序是如何运行的——它将无限期地运行。要停止它,请按 Ctrl + C。用 cargo run 运行它:
$ cargo run
   Compiling channels v0.1.0 (Rust-Cookbook/Chapter04/channels)
    Finished dev [unoptimized + debuginfo] target(s) in 1.38s
     Running `target/debug/channels`
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
****************************
|||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|||||||||||||||||||||||||||||||||
*********************************************************
||||||||||||||||||||||||||||
************************************************************
*****************************
||||||||||||||||
***********
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
*******************************
|||||||||||||||||||||||||||||||||||||||||
*************************************************************
|||||||||||||||||||||||||||||||||||||||||||||||
*******************************
************************************************************************
*******************
******************************************************
|||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
||||||||||||||||||||||||||||||||
************************************************
*
||||||||||||||||||||||||||||||||||||||||
***********************************************
||||||
*************************
|||||||||||||||||||
|||||||||||||||||||||||||||||||||||
^C⏎ 

这是如何工作的?让我们深入了解代码,以更好地理解它。

它是如何工作的...

通道是 多生产者单消费者 数据结构,由许多发送者(带有轻量级克隆)组成,但只有一个接收者。在底层,通道不锁定,而是依赖于一个 unsafe 数据结构,该结构允许检测和管理流的状态。通道很好地处理了跨线程发送数据,并且可以用来创建演员风格的框架或反应式 map-reduce 风格的数据处理引擎。

这是 Rust 如何做到 无畏并发 的一个例子:进入的数据由通道拥有,直到接收者检索它,这时新的所有者接管。通道还充当队列,并持有元素直到它们被检索。这不仅使开发者免于实现交换,而且还免费为常规队列添加了并发性。

我们在这个菜谱的 第 3 步 中创建通道,并将发送者传递到不同的线程中,这些线程开始发送之前定义的(在 第 2 步enum 类型,以便接收者打印。这种打印是在 第 4 步 通过循环带有三秒超时的阻塞迭代器来完成的。第 5 步 展示了如何将依赖项添加到 Cargo.toml,而在 第 6 步 我们看到了输出:多行,包含随机数量的元素,这些元素可以是星号 (*) 或管道 (|)。

我们已经成功介绍了如何使用通道在线程之间轻松通信。现在让我们继续下一个菜谱。

共享可变状态

Rust 的所有权和借用模型大大简化了不可变数据的访问和传输——那么共享状态怎么办?有许多应用程序需要从多个线程对共享资源进行可变访问。让我们看看这是如何完成的!

如何去做...

在这个菜谱中,我们将创建一个非常简单的模拟:

  1. 运行 cargo new black-white 来创建一个新的应用程序项目,并在 Visual Studio Code 中打开该目录。

  2. 打开 src/main.rs 以添加一些代码。首先,我们需要一些导入和一个 enum 来使我们的模拟更有趣:

use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;

///
/// A simple enum with only two variations: black and white
/// 
#[derive(Debug)]
enum Shade {
    Black,
    White,
}
  1. 为了展示两个线程之间的共享状态,显然我们需要一个在某个东西上工作的线程。这将是一个着色任务,其中每个线程只有在黑色是前一个元素时才向向量添加白色,反之亦然。因此,每个线程都需要读取并根据输出写入共享向量。让我们看看执行此操作的代码:
fn new_painter_thread(data: Arc<Mutex<Vec<Shade>>>) -> thread::JoinHandle<()> {
    thread::spawn(move || loop {
        {
            // create a scope to release the mutex as quickly as    
            // possible
            let mut d = data.lock().unwrap();
            if d.len() > 0 {
                match d[d.len() - 1] {
                    Shade::Black => d.push(Shade::White),
                    Shade::White => d.push(Shade::Black),
                }
            } else {
                d.push(Shade::Black)
            }
            if d.len() > 5 {
                break;
            }
        }
        // slow things down a little
        thread::sleep(Duration::from_secs(1));
    })
}
  1. 在这个阶段,我们剩下的就是创建多个线程,并将数据的 Arc 实例传递给它们来处理:
fn main() {
    let data = Arc::new(Mutex::new(vec![]));
    let threads: Vec<thread::JoinHandle<()>> =
        (0..2)
        .map(|_| new_painter_thread(data.clone()))
        .collect();

    let _: Vec<()> = threads
        .into_iter()
        .map(|t| t.join().unwrap())
        .collect();

    println!("Result: {:?}", data);
}
  1. 让我们用 cargo run 运行代码:
$ cargo run
   Compiling black-white v0.1.0 (Rust-Cookbook/Chapter04/black-
   white)
    Finished dev [unoptimized + debuginfo] target(s) in 0.35s
     Running `target/debug/black-white`
     Result: Mutex { data: [Black, White, Black, White, Black, 
    White, Black] }

现在,让我们深入了解代码,以更好地理解它。

它是如何工作的...

Rust 的所有权原则是一把双刃剑:一方面,它保护免受意外后果的影响,并使编译时内存管理成为可能;另一方面,可变访问的获取要困难得多。虽然管理起来更复杂,但共享可变访问对于性能来说可以非常出色。

Arc代表原子引用计数器。这使得它们与常规引用计数器(Rc)非常相似,唯一的区别是Arc通过原子递增来完成其工作,这是线程安全的。因此,它们是跨线程引用计数的唯一选择。

在 Rust 中,这是以类似内部可变性的方式进行的(doc.rust-lang.org/book/ch15-05-interior-mutability.html),但使用ArcMutex类型(而不是RcRefCell),其中Mutex拥有它限制访问的实际内存部分(在步骤 3 的片段中,我们就是这样创建Vec的)。正如步骤 2所示,要获取值的可变引用,必须严格锁定Mutex实例,并且只有在返回的数据实例被丢弃(例如,当作用域结束时)之后,它才会返回。因此,保持Mutex的作用域尽可能小是很重要的(注意步骤 2中的附加{ ... })!

在许多用例中,基于通道的方法可以达到相同的目标,而无需处理Mutex和死锁(当多个Mutex锁相互等待解锁)的恐惧。

我们已经成功学习了如何使用通道来共享可变状态。现在让我们继续下一个菜谱。

Rust 中的多进程

线程对于进程内并发非常出色,当然也是将工作负载分散到多个核心的首选方法。每当需要调用其他程序,或者需要独立、重量级任务时,子进程就是最佳选择。随着最近编排型应用程序(如 Kubernetes、Docker Swarm、Mesos 等)的兴起,管理子进程也变得更为重要。在这个菜谱中,我们将与子进程进行通信和管理。

如何做到这一点...

按照以下步骤创建一个简单的应用程序,用于搜索文件系统:

  1. 使用cargo new child-processes创建一个新的项目,并在 Visual Studio Code 中打开它。

  2. 在 Windows 上,从 PowerShell 窗口中执行cargo run(最后一步),因为它包含所有必需的二进制文件。

  3. 在导入几个(标准库)依赖项之后,让我们编写基本的struct来保存结果数据。在main函数顶部添加以下内容:

use std::io::Write;
use std::process::{Command, Stdio};

#[derive(Debug)]
struct SearchResult {
    query: String,
    results: Vec<String>,
}
  1. 调用find二进制文件(实际执行搜索)的函数将结果转换为步骤 1中的struct。这个函数看起来是这样的:
fn search_file(name: String) -> SearchResult {
    let ps_child = Command::new("find")
        .args(&[".", "-iname", &format!("{}", name)])
        .stdout(Stdio::piped())
        .output()
        .expect("Could not spawn process");

    let results = String::from_utf8_lossy(&ps_child.stdout);
    let result_rows: Vec<String> = results
        .split("\n")
        .map(|e| e.to_string())
        .filter(|s| s.len() > 1)
        .collect();

    SearchResult {
        query: name,
        results: result_rows,
    }
}
  1. 太棒了!现在我们知道了如何调用外部二进制文件,传递参数,并将任何 stdout 输出转发到 Rust 程序。那么,如何写入外部程序的 stdin 呢?我们将添加以下函数来完成这个任务:
fn process_roundtrip() -> String {
    let mut cat_child = Command::new("cat")
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .spawn()
        .expect("Could not spawn process");

    let stdin = cat_child.stdin.as_mut().expect("Could 
     not attach to stdin");

    stdin
        .write_all(b"datadatadata")
        .expect("Could not write to child process");
    String::from_utf8(
        cat_child
            .wait_with_output()
            .expect("Something went wrong")
            .stdout
            .as_slice()
            .iter()
            .cloned()
            .collect(),
    )
    .unwrap()
}
  1. 为了看到它的实际效果,我们还需要在程序的 main() 部分调用这些函数。用以下内容替换默认的 main() 函数的内容:
fn main() {
    println!("Reading from /bin/cat > {:?}", process_roundtrip());
    println!(
        "Using 'find' to search for '*.rs': {:?}",
        search_file("*.rs".to_owned())
    )
}
  1. 现在我们可以通过发出 cargo run 来检查它是否工作:
$ cargo run
   Compiling child-processes v0.1.0 (Rust-Cookbook/Chapter04/child-
    processes)
    Finished dev [unoptimized + debuginfo] target(s) in 0.59s
    Running `target/debug/child-processes`
    Reading from /bin/cat > "datadatadata"
    Using 'find' to search for '*.rs': SearchResult { query: "
    *.rs", results: ["./src/main.rs"] }

现在,让我们深入了解代码,以更好地理解它。

它是如何工作的...

通过使用 Rust 的运行子进程和操作其输入输出的能力,将现有应用程序集成到新程序的流程中变得非常容易。在 步骤 1 中,我们正是通过使用带有参数的 find 程序并将输出解析到我们自己的数据结构中来实现这一点。

步骤 3 中,我们进一步发送数据到子进程,并恢复相同的文本(使用类似 catecho 风格)。你会注意到每个函数中都有字符串解析,这是必需的,因为 Windows 和 Linux/macOS 使用不同的字节大小来编码它们的字符(UTF-16UTF-8 分别)。同样,b"string" 将字面量转换为适合当前平台的字节数组字面量。

这些操作的关键成分是 管道,这是一个在命令行中使用 |管道)符号可用的操作。我们鼓励您也尝试其他 Stdio 结构的变体,看看它们能引导您到何处!

我们已经成功地学习了 Rust 中的多进程。现在让我们继续下一个配方。

将顺序代码并行化

从头开始创建高度并发的应用程序在许多技术和语言中相对简单。然而,当多个开发者必须基于某种预存在的工作(无论是遗留的还是其他类型)进行构建时,创建这些高度并发的应用程序就会变得复杂。多亏了不同语言之间的 API 差异、最佳实践或技术限制,现有的序列操作不能在没有深入分析的情况下并行运行。如果潜在的好处不显著,谁会去做呢?借助 Rust 的强大迭代器,我们能否在不进行重大代码更改的情况下并行运行操作?我们的答案是肯定的!

如何做到这一点...

这个配方展示了如何仅通过几个步骤使用 rayon-rs 简单地使应用程序并行运行,而不需要大量努力:

  1. 使用 cargo new use-rayon --lib 创建一个新的项目,并在 Visual Studio Code 中打开它。

  2. 打开 Cargo.toml 以向项目添加所需的依赖项。我们将基于 rayon 并使用 criterion 的基准测试功能:

# replace the default [dependencies] section...
[dependencies]
rayon = "1.0.3"

[dev-dependencies]
criterion = "0.2.11"
rand = "⁰.5"

[[bench]]
name = "seq_vs_par"
harness = false
  1. 作为示例算法,我们将使用归并排序,这是一种类似于快速排序的复杂、分而治之的算法(www.geeksforgeeks.org/quick-sort-vs-merge-sort/)。让我们从添加 merge_sort_seq() 函数到 src/lib.rs 的顺序版本开始:
///
/// Regular, sequential merge sort implementation
/// 
pub fn merge_sort_seq<T: PartialOrd + Clone + Default>(collection: &[T]) -> Vec<T> {
    if collection.len() > 1 {
        let (l, r) = collection.split_at(collection.len() / 2);
        let (sorted_l, sorted_r) = (merge_sort_seq(l), 
         merge_sort_seq(r));
        sorted_merge(sorted_l, sorted_r)
    } else {
        collection.to_vec()
    }
}
  1. 归并排序的高级视图很简单:将集合分成两半,直到不能再分,然后按顺序合并这些半部分。分割部分已经完成;缺少的是合并部分。将以下片段插入lib.rs
///
/// Merges two collections into one. 
/// 
fn sorted_merge<T: Default + Clone + PartialOrd>(sorted_l: Vec<T>, sorted_r: Vec<T>) -> Vec<T> {
    let mut result: Vec<T> = vec![Default::default(); sorted_l.len() 
     + sorted_r.len()];

    let (mut i, mut j) = (0, 0);
    let mut k = 0;
    while i < sorted_l.len() && j < sorted_r.len() {
        if sorted_l[i] <= sorted_r[j] {
            result[k] = sorted_l[i].clone();
            i += 1;
        } else {
            result[k] = sorted_r[j].clone();
            j += 1;
        }
        k += 1;
    }
    while i < sorted_l.len() {
        result[k] = sorted_l[i].clone();
        k += 1;
        i += 1;
    }

    while j < sorted_r.len() {
        result[k] = sorted_r[j].clone();
        k += 1;
        j += 1;
    }
    result
}
  1. 最后,我们还需要导入rayon,这是一个用于轻松创建并行应用程序的 crate,然后添加一个修改过的、并行化的归并排序版本:
use rayon;
  1. 接下来,我们添加一个修改过的归并排序版本:
///
/// Merge sort implementation using parallelism.
/// 
pub fn merge_sort_par<T>(collection: &[T]) -> Vec<T>
where
    T: PartialOrd + Clone + Default + Send + Sync,
{
    if collection.len() > 1 {
        let (l, r) = collection.split_at(collection.len() / 2);
        let (sorted_l, sorted_r) = rayon::join(|| merge_sort_par(l), 
        || merge_sort_par(r));
        sorted_merge(sorted_l, sorted_r)
    } else {
        collection.to_vec()
    }
}
  1. 很好——你能找到变化吗?为了确保两个版本都能提供相同的结果,让我们添加一些测试:
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_merge_sort_seq() {
        assert_eq!(merge_sort_seq(&vec![9, 8, 7, 6]), vec![6, 7, 8, 
         9]);
        assert_eq!(merge_sort_seq(&vec![6, 8, 7, 9]), vec![6, 7, 8, 
         9]);
        assert_eq!(merge_sort_seq(&vec![2, 1, 1, 1, 1]), vec![1, 1, 
         1, 1, 2]);
    }

    #[test]
    fn test_merge_sort_par() {
        assert_eq!(merge_sort_par(&vec![9, 8, 7, 6]), vec![6, 7, 8, 
         9]);
        assert_eq!(merge_sort_par(&vec![6, 8, 7, 9]), vec![6, 7, 8, 
         9]);
        assert_eq!(merge_sort_par(&vec![2, 1, 1, 1, 1]), vec![1, 1, 
         1, 1, 2]);
    }
}
  1. 运行cargo test,你应该看到成功的测试:
$ cargo test
   Compiling use-rayon v0.1.0 (Rust-Cookbook/Chapter04/use-rayon)
    Finished dev [unoptimized + debuginfo] target(s) in 0.67s
     Running target/debug/deps/use_rayon-1fb58536866a2b92

running 2 tests
test tests::test_merge_sort_seq ... ok
test tests::test_merge_sort_par ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

   Doc-tests use-rayon

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
  1. 然而,我们真正感兴趣的是基准测试——它是否会更快?为此,创建一个包含seq_vs_par.rs文件的benches文件夹。打开文件并添加以下代码:
#[macro_use]
extern crate criterion;
use criterion::black_box;
use criterion::Criterion;
use rand::prelude::*;
use std::cell::RefCell;
use use_rayon::{merge_sort_par, merge_sort_seq};

fn random_number_vec(size: usize) -> Vec<i64> {
    let mut v: Vec<i64> = (0..size as i64).collect();
    let mut rng = thread_rng();
    rng.shuffle(&mut v);
    v
}

thread_local!(static ITEMS: RefCell<Vec<i64>> = RefCell::new(random_number_vec(100_000)));

fn bench_seq(c: &mut Criterion) {
    c.bench_function("10k merge sort (sequential)", |b| {
        ITEMS.with(|item| b.iter(||         
        black_box(merge_sort_seq(&item.borrow()))));
    });
}

fn bench_par(c: &mut Criterion) {
    c.bench_function("10k merge sort (parallel)", |b| {
        ITEMS.with(|item| b.iter(|| 
        black_box(merge_sort_par(&item.borrow()))));
    });
}
criterion_group!(benches, bench_seq, bench_par);

criterion_main!(benches);
  1. 当我们运行cargo bench时,我们得到实际的数字来比较并行和顺序实现(变化指的是之前相同基准测试的运行):
$ cargo bench
   Compiling use-rayon v0.1.0 (Rust-Cookbook/Chapter04/use-rayon)
    Finished release [optimized] target(s) in 1.84s
     Running target/release/deps/use_rayon-eb085695289744ef

running 2 tests
test tests::test_merge_sort_par ... ignored
test tests::test_merge_sort_seq ... ignored

test result: ok. 0 passed; 0 failed; 2 ignored; 0 measured; 0 filtered out

Running target/release/deps/seq_vs_par-6383ba0d412acb2b
Gnuplot not found, disabling plotting
10k merge sort (sequential) 
                        time: [13.815 ms 13.860 ms 13.906 ms]
                        change: [-6.7401% -5.1611% -3.6593%] (p = 
                         0.00 < 0.05)
                        Performance has improved.
Found 5 outliers among 100 measurements (5.00%)
  3 (3.00%) high mild
  2 (2.00%) high severe

10k merge sort (parallel) 
                        time: [10.037 ms 10.067 ms 10.096 ms]
                        change: [-15.322% -13.276% -11.510%] (p = 
                        0.00 < 0.05)
                        Performance has improved.
Found 6 outliers among 100 measurements (6.00%)
  1 (1.00%) low severe
  1 (1.00%) high mild
  4 (4.00%) high severe

Gnuplot not found, disabling plotting

现在,让我们检查所有这些意味着什么,揭开代码的神秘面纱。

它是如何工作的...

rayon-rs (github.com/rayon-rs/rayon) 是一个流行的数据并行 crate,它只需要少量修改就可以将自动并发引入代码。在我们的例子中,我们使用rayon::join操作来创建流行的归并排序算法的并行版本。

步骤 1中,我们添加了基准测试的依赖项([dev-dependencies])以及实际构建库的依赖项([dependencies])。但在步骤 2步骤 3中,我们实现了一个常规的归并排序变体。一旦我们在步骤 4中添加了rayon依赖项,我们就可以在步骤 5中添加rayon::join来并行运行每个分支(到左右部分的排序),如果可能的话,在每个自己的闭包(|/*no params*/| {/* do work */},或简写为|/*no params*/| /*do work*/)中并行执行。join的文档可以在docs.rs/rayon/1.2.0/rayon/fn.join.html找到,其中详细说明了何时它能加速事情。

步骤 8中,我们正在创建一个符合 criterion 要求的基准测试。库在src/目录外编译一个文件,在基准测试框架中运行并输出数字(如步骤 9所示)——在这些数字中,我们可以看到仅通过添加一行代码就实现了轻微但一致的性能提升。在基准测试文件中,我们正在对相同随机向量(thread_local!()类似于static)的 10 万个随机数进行排序。

我们已经成功学习了如何将顺序代码并行化。现在让我们继续到下一个菜谱。

向量中的并发数据处理

Rust 的 Vec 是一个伟大的数据结构,它不仅用于存储数据,还作为某种管理工具。在本章早期的一个配方(管理多个线程)中,我们看到了当我们捕获 Vec 中多个线程的句柄,然后使用 map() 函数将它们连接起来时的情况。这次,我们将专注于并行处理常规 Vec 实例,而不增加额外的开销。在前一个配方中,我们看到了 rayon-rs 的强大功能,现在我们将使用它来并行化数据处理。

如何做到这一点...

在以下步骤中,让我们更多地使用 rayon-rs

  1. 使用 cargo new concurrent-processing --lib 创建一个新的项目,并在 Visual Studio Code 中打开它。

  2. 首先,我们必须通过在 Cargo.toml 中添加几行来添加 rayon 作为依赖项。此外,rand 包和用于基准测试的 criterion 将在稍后有用,因此让我们也将它们添加并适当配置:

[dependencies]
rayon = "1.0.3"

[dev-dependencies]
criterion = "0.2.11"
rand = "⁰.5"

[[bench]]
name = "seq_vs_par"
harness = false
  1. 由于我们将添加一个重要的统计误差度量,即平方误差之和,请打开 src/lib.rs。在其顺序版本中,我们简单地遍历预测及其原始值以找出差异,然后平方它,并汇总结果。让我们将此添加到文件中:
pub fn ssqe_sequential(y: &[f32], y_predicted: &[f32]) -> Option<f32> {
    if y.len() == y_predicted.len() {
        let y_iter = y.iter();
        let y_pred_iter = y_predicted.iter();

        Some(
            y_iter
                .zip(y_pred_iter)
                .map(|(y, y_pred)| (y - y_pred).powi(2))
                .sum()
        ) 
    } else {
        None
    }
}
  1. 这看起来很容易并行化,而 rayon 正好为我们提供了所需的工具。让我们使用并发来创建几乎相同的代码:
use rayon::prelude::*;

pub fn ssqe(y: &[f32], y_predicted: &[f32]) -> Option<f32> {
    if y.len() == y_predicted.len() {
        let y_iter = y.par_iter();
        let y_pred_iter = y_predicted.par_iter();

        Some(
            y_iter
                .zip(y_pred_iter)
                .map(|(y, y_pred)| (y - y_pred).powi(2))
                .reduce(|| 0.0, |a, b| a + b),
        ) // or sum()
    } else {
        None
    }
}
  1. 虽然与顺序代码的差异非常微妙,但这些变化对执行速度有重大影响!在我们继续之前,我们应该添加一些测试来查看实际调用函数的结果。让我们先从并行版本开始:
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_sum_of_sq_errors() {
        assert_eq!(
            ssqe(&[1.0, 1.0, 1.0, 1.0], &[2.0, 2.0, 2.0, 2.0]),
            Some(4.0)
        );
        assert_eq!(
            ssqe(&[-1.0, -1.0, -1.0, -1.0], &[-2.0, -2.0, -2.0, 
             -2.0]),
            Some(4.0)
        );
        assert_eq!(
            ssqe(&[-1.0, -1.0, -1.0, -1.0], &[2.0, 2.0, 2.0, 2.0]),
            Some(36.0)
        );
        assert_eq!(
            ssqe(&[1.0, 1.0, 1.0, 1.0], &[2.0, 2.0, 2.0, 2.0]),
            Some(4.0)
        );
        assert_eq!(
            ssqe(&[1.0, 1.0, 1.0, 1.0], &[2.0, 2.0, 2.0, 2.0]),
            Some(4.0)
        );
    }
  1. 顺序代码应该有相同的结果,因此让我们复制顺序代码版本的测试:
    #[test]
    fn test_sum_of_sq_errors_seq() {
        assert_eq!(
            ssqe_sequential(&[1.0, 1.0, 1.0, 1.0], &[2.0, 2.0, 2.0, 
             2.0]),
            Some(4.0)
        );
        assert_eq!(
            ssqe_sequential(&[-1.0, -1.0, -1.0, -1.0], &[-2.0,
             -2.0, -2.0, -2.0]),
            Some(4.0)
        );
        assert_eq!(
            ssqe_sequential(&[-1.0, -1.0, -1.0, -1.0], &[2.0, 2.0, 
             2.0, 2.0]),
            Some(36.0)
        );
        assert_eq!(
            ssqe_sequential(&[1.0, 1.0, 1.0, 1.0], &[2.0, 2.0, 2.0, 
             2.0]),
            Some(4.0)
        );
        assert_eq!(
            ssqe_sequential(&[1.0, 1.0, 1.0, 1.0], &[2.0, 2.0, 2.0, 
             2.0]),
            Some(4.0)
        );
    }
}
  1. 为了检查一切是否按预期工作,请在之间运行 cargo test
$ cargo test
   Compiling concurrent-processing v0.1.0 (Rust-
    Cookbook/Chapter04/concurrent-processing)
    Finished dev [unoptimized + debuginfo] target(s) in 0.84s
     Running target/debug/deps/concurrent_processing-
      250eef41459fd2af

running 2 tests
test tests::test_sum_of_sq_errors_seq ... ok
test tests::test_sum_of_sq_errors ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

   Doc-tests concurrent-processing

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
  1. 作为 rayon 的一个额外功能,让我们也在 src/lib.rs 中添加一些更多函数。这次,它们与在 str 中计数字母数字字符相关:
pub fn seq_count_alpha_nums(corpus: &str) -> usize {
    corpus.chars().filter(|c| c.is_alphanumeric()).count()
}

pub fn par_count_alpha_nums(corpus: &str) -> usize {
    corpus.par_chars().filter(|c| c.is_alphanumeric()).count()
}
  1. 现在,让我们看看哪个性能更好,并添加一个基准测试。为此,在 src/ 旁边创建一个 benches/ 目录,并添加一个 seq_vs_par.rs 文件。添加以下基准测试和辅助函数以查看速度提升。让我们从定义基准测试处理的基本数据的几个辅助函数开始:
#[macro_use]
extern crate criterion;
use concurrent_processing::{ssqe, ssqe_sequential, seq_count_alpha_nums, par_count_alpha_nums};
use criterion::{black_box, Criterion};
use std::cell::RefCell;
use rand::prelude::*;

const SEQ_LEN: usize = 1_000_000;
thread_local!(static ITEMS: RefCell<(Vec<f32>, Vec<f32>)> = {
    let y_values: (Vec<f32>, Vec<f32>) = (0..SEQ_LEN).map(|_| 
     (random::<f32>(), random::<f32>()) )
    .unzip();
    RefCell::new(y_values)
});

const MAX_CHARS: usize = 100_000;
thread_local!(static CHARS: RefCell<String> = {
    let items: String = (0..MAX_CHARS).map(|_| random::<char>
     ()).collect();
    RefCell::new(items)
});
  1. 接下来,我们将创建基准测试本身:
fn bench_count_seq(c: &mut Criterion) {
    c.bench_function("Counting in sequence", |b| {
        CHARS.with(|item| b.iter(|| 
         black_box(seq_count_alpha_nums(&item.borrow()))))
    });
}

fn bench_count_par(c: &mut Criterion) {
    c.bench_function("Counting in parallel", |b| {
        CHARS.with(|item| b.iter(|| 
         black_box(par_count_alpha_nums(&item.borrow()))))
    });
}
  1. 让我们创建另一个基准测试:
fn bench_seq(c: &mut Criterion) {
    c.bench_function("Sequential vector operation", |b| {
        ITEMS.with(|y_values| {
            let y_borrowed = y_values.borrow();
            b.iter(|| black_box(ssqe_sequential(&y_borrowed.0, 
             &y_borrowed.1)))
        })
    });
}

fn bench_par(c: &mut Criterion) {
    c.bench_function("Parallel vector operation", |b| {
        ITEMS.with(|y_values| {
            let y_borrowed = y_values.borrow();
            b.iter(|| black_box(ssqe(&y_borrowed.0, 
            &y_borrowed.1)))
        })
    });
}

criterion_group!(benches, bench_seq, bench_par,bench_count_par, bench_count_seq);

criterion_main!(benches);
  1. 有此可用,运行 cargo bench 并(经过一段时间后)检查输出以查看改进和计时(变化的部分指的是与上次运行相同基准测试的变化):
$ cargo bench
   Compiling concurrent-processing v0.1.0 (Rust-
    Cookbook/Chapter04/concurrent-processing)
    Finished release [optimized] target(s) in 2.37s
     Running target/release/deps/concurrent_processing-
      eedf0fd3b1e51fe0

running 2 tests
test tests::test_sum_of_sq_errors ... ignored
test tests::test_sum_of_sq_errors_seq ... ignored

test result: ok. 0 passed; 0 failed; 2 ignored; 0 measured; 0 filtered out

Running target/release/deps/seq_vs_par-ddd71082d4bd9dd6
Gnuplot not found, disabling plotting
Sequential vector operation 
                        time: [1.0631 ms 1.0681 ms 1.0756 ms]
                        change: [-4.8191% -3.4333% -2.3243%] (p = 
                        0.00 < 0.05)
                        Performance has improved.
Found 4 outliers among 100 measurements (4.00%)
  2 (2.00%) high mild
  2 (2.00%) high severe

Parallel vector operation 
                        time: [408.93 us 417.14 us 425.82 us]
                        change: [-9.5623% -6.0044% -2.2126%] (p = 
                        0.00 < 0.05)
                        Performance has improved.
Found 15 outliers among 100 measurements (15.00%)
  2 (2.00%) low mild
  7 (7.00%) high mild
  6 (6.00%) high severe

Counting in parallel time: [552.01 us 564.97 us 580.51 us] 
                        change: [+2.3072% +6.9101% +11.580%] (p = 
                        0.00 < 0.05)
                        Performance has regressed.
Found 4 outliers among 100 measurements (4.00%)
  3 (3.00%) high mild
  1 (1.00%) high severe

Counting in sequence time: [992.84 us 1.0137 ms 1.0396 ms] 
                        change: [+9.3014% +12.494% +15.338%] (p = 
                        0.00 < 0.05)
                        Performance has regressed.
Found 4 outliers among 100 measurements (4.00%)
  4 (4.00%) high mild

Gnuplot not found, disabling plotting

现在,让我们深入了解代码以更好地理解它。

它是如何工作的...

再次强调,rayon-rs——一个出色的库——通过更改单行代码实现了基准性能的大约 50% 的提升(并行与顺序)。这对于许多应用程序来说都是重要的,尤其是在机器学习中,算法的损失函数在训练周期中需要运行数百或数千次。将此时间减半将立即对生产力产生重大影响。

在设置好一切后的第一步(步骤 3步骤 4步骤 5),我们正在创建平方误差和的顺序和并行实现(hlab.stanford.edu/brian/error_sum_of_squares.html),唯一的区别是 par_iter() 与包括一些测试的 iter() 调用。然后我们添加一些——更常见的——计数函数到我们的基准测试套件中,我们将在 步骤 7步骤 8 中创建和调用它们。再次强调,顺序和并行算法每次都在完全相同的数据集上工作,以避免任何不幸的事件。

我们已经成功学习了如何在向量中并发处理数据。现在让我们继续到下一个菜谱。

共享不可变状态

有时,当程序在多个线程上运行时,当前版本设置以及更多内容都作为单一的真实点提供给线程。在 Rust 中,在变量不可变且类型被标记为安全共享的情况下,线程之间的状态共享是直接的。为了标记类型为线程安全,实现必须确保访问信息时不会发生任何不一致。

Rust 使用两个标记特质——SendSync——来管理这些选项。让我们看看如何。

如何做到这一点...

在几个步骤中,我们将探索不可变状态:

  1. 运行 cargo new immutable-states 以创建一个新的应用程序项目,并在 Visual Studio Code 中打开该目录。

  2. 首先,我们将添加导入和一个 noop 函数到我们的 src/main.rs 文件中:

use std::thread;
use std::rc::Rc;
use std::sync::Arc;
use std::sync::mpsc::channel;

fn noop<T>(_: T) {}
  1. 让我们探索不同类型如何在线程之间共享。mpsc::channel 类型提供了一个很好的现成示例,用于共享状态。让我们从一个按预期工作的基线开始:
fn main() {
    let (sender, receiver) = channel::<usize>();

    thread::spawn(move || {
        let thread_local_read_only_clone = sender.clone();
        noop(thread_local_read_only_clone);
    });
}
  1. 要查看其工作情况,请执行 cargo build。任何关于非法状态共享的错误都将由编译器找到:
$ cargo build
   Compiling immutable-states v0.1.0 (Rust-Cookbook/Chapter04
   /immutable-states)
warning: unused import: `std::rc::Rc`
 --> src/main.rs:2:5
  |
2 | use std::rc::Rc;
  | ^^^^^^^^^^^
  |
  = note: #[warn(unused_imports)] on by default

warning: unused import: `std::sync::Arc`
 --> src/main.rs:3:5
  |
3 | use std::sync::Arc;
  | ^^^^^^^^^^^^^^

warning: unused variable: `receiver`
  --> src/main.rs:10:18
   |
10 | let (sender, receiver) = channel::<usize>();
   | ^^^^^^^^ help: consider prefixing with an underscore: `_receiver`
   |
   = note: #[warn(unused_variables)] on by default

    Finished dev [unoptimized + debuginfo] target(s) in 0.58s
  1. 现在,我们将尝试用接收者做同样的事情。它会工作吗?将以下内容添加到 main 函数中:
    let c = Arc::new(receiver);
    thread::spawn(move || {
        noop(c.clone());
    });
  1. 运行 cargo build 以获取更详细的消息:
$ cargo build
   Compiling immutable-states v0.1.0 (Rust-Cookbook/Chapter04
    /immutable-states)
warning: unused import: `std::rc::Rc`
 --> src/main.rs:2:5
  |
2 | use std::rc::Rc;
  | ^^^^^^^^^^^
  |
  = note: #[warn(unused_imports)] on by default

error[E0277]: `std::sync::mpsc::Receiver<usize>` cannot be shared between threads safely
  --> src/main.rs:26:5
   |
26 | thread::spawn(move || {
   | ^^^^^^^^^^^^^ `std::sync::mpsc::Receiver<usize>` cannot be shared between threads safely
   |
   = help: the trait `std::marker::Sync` is not implemented for `std::sync::mpsc::Receiver<usize>`
   = note: required because of the requirements on the impl of `std::marker::Send` for `std::sync::Arc<std::sync::mpsc::Receiver<usize>>`
   = note: required because it appears within the type `[closure@src/main.rs:26:19: 28:6 c:std::sync::Arc<std::sync::mpsc::Receiver<usize>>]`
   = note: required by `std::thread::spawn`

error: aborting due to previous error

For more information about this error, try `rustc --explain E0277`.
error: Could not compile `immutable-states`.

To learn more, run the command again with --verbose.
  1. 由于接收者只为单个线程设计,用于从通道中获取数据,因此预期使用 Arc 无法避免这种情况。同样,也不可能简单地将 Rc 包装到 Arc 中,使其能够在线程之间可用。添加以下内容以查看错误:
   let b = Arc::new(Rc::new(vec![]));
    thread::spawn(move || {
        let thread_local_read_only_clone = b.clone();
        noop(thread_local_read_only_clone);
    });
  1. cargo build 再次揭示了后果——关于类型无法在线程之间发送的错误:
$ cargo build
   Compiling immutable-states v0.1.0 (Rust-Cookbook/Chapter04
   /immutable-states)
error[E0277]: `std::rc::Rc<std::vec::Vec<_>>` cannot be sent between threads safely
  --> src/main.rs:19:5
   |
19 | thread::spawn(move || {
   | ^^^^^^^^^^^^^ `std::rc::Rc<std::vec::Vec<_>>` cannot be sent between threads safely
   |
   = help: the trait `std::marker::Send` is not implemented for `std::rc::Rc<std::vec::Vec<_>>`
   = note: required because of the requirements on the impl of `std::marker::Send` for `std::sync::Arc<std::rc::Rc<std::vec::Vec<_>>>`
   = note: required because it appears within the type `[closure@src/main.rs:19:19: 22:6 b:std::sync::Arc<std::rc::Rc<std::vec::Vec<_>>>]`
   = note: required by `std::thread::spawn`

error[E0277]: `std::rc::Rc<std::vec::Vec<_>>` cannot be shared between threads safely
  --> src/main.rs:19:5
   |
19 | thread::spawn(move || {
   | ^^^^^^^^^^^^^ `std::rc::Rc<std::vec::Vec<_>>` cannot be shared between threads safely
   |
   = help: the trait `std::marker::Sync` is not implemented for `std::rc::Rc<std::vec::Vec<_>>`
   = note: required because of the requirements on the impl of `std::marker::Send` for `std::sync::Arc<std::rc::Rc<std::vec::Vec<_>>>`
   = note: required because it appears within the type `[closure@src/main.rs:19:19: 22:6 b:std::sync::Arc<std::rc::Rc<std::vec::Vec<_>>>]`
   = note: required by `std::thread::spawn`

error: aborting due to 2 previous errors

For more information about this error, try `rustc --explain E0277`.
error: Could not compile `immutable-states`.

To learn more, run the command again with --verbose. 

现在,让我们深入了解代码,以更好地理解它。

它是如何工作的...

由于这个菜谱实际上在最后一步未能构建,并指向了一个错误信息,发生了什么?我们学习了 SendSync。这些标记特性和错误类型将在最令人惊讶和关键的情况下出现在你的路径上。由于它们在存在时无缝工作,我们不得不创建一个失败的示例来向你展示它们所做的事情以及如何做到这一点。

在 Rust 中,标记特性(doc.rust-lang.org/std/marker/index.html)向编译器发出信号。在并发的情况下,这是跨线程共享的能力。Sync(多线程共享访问)和 Send(所有者可以安全地从一条线程转移到另一条线程)特性几乎为所有默认数据结构实现,但如果需要 unsafe 代码,则必须手动添加标记特性——这也是 unsafe

因此,大多数数据结构都将能够从其属性中继承 SendSync,这就是在 步骤 2步骤 3 中发生的事情。通常,你还会将实例包裹在 Arc 中以便更容易处理。然而,多个 Arc 实例需要其包含的类型实现 SendSync。在 步骤 4步骤 6 中,我们尝试将可用的类型放入 Arc 中——而不实现 SyncSend步骤 5步骤 7 展示了尝试时编译器的错误信息。如果你想了解更多,并了解如何将 marker 特性(doc.rust-lang.org/std/marker/index.html)添加到自定义类型中,请查看doc.rust-lang.org/nomicon/send-and-sync.html上的文档。

现在我们对 SendSync 了解更多,并发程序中的状态共享就不再是谜了。让我们继续到下一个菜谱。

使用演员处理异步消息

可扩展架构和异步编程导致了演员和基于演员的设计(mattferderer.com/what-is-the-actor-model-and-when-should-you-use-it)的兴起,这些设计得到了 Akka (akka.io/) 等框架的促进。尽管 Rust 拥有强大的并发功能,但 Rust 中的演员仍然难以正确使用,并且缺乏许多其他库所拥有的文档。在这个菜谱中,我们将探索 Rust 的演员框架 actix 的基础知识,该框架是在流行的 Akka 之后创建的。

如何做到这一点...

只需几个步骤即可实现基于演员的传感器数据读取器:

  1. 使用 cargo new actors 创建一个新的二进制应用程序,并在 Visual Studio Code 中打开该目录。

  2. Cargo.toml 配置文件中包含所需的依赖项:

[package]
name = "actors"
version = "0.1.0"
authors = ["Claus Matzinger <claus.matzinger+kb@gmail.com>"]
edition = "2018"

[dependencies]
actix = "⁰.8"
rand = "0.5"
  1. 打开 src/main.rs 以在 main 函数之前添加代码。让我们从导入开始:
use actix::prelude::*;
use std::thread;
use std::time::Duration;
use rand::prelude::*;
  1. 为了创建一个演员系统,我们必须考虑应用程序的结构。演员可以被视为一个消息接收器,其中有一个邮箱,消息堆积在那里直到被处理。为了简单起见,让我们模拟一些传感器数据作为消息,每个消息由一个u64时间戳和一个f32值组成:
///
/// A mock sensor function
/// 
fn read_sensordata() -> f32 {
     random::<f32>() * 10.0
}

#[derive(Debug, Message)]
struct Sensordata(pub u64, pub f32);
  1. 在一个典型系统中,我们会使用 I/O 循环以预定的时间间隔从传感器(s)中读取。由于actix(github.com/actix/actix/)建立在 Tokio(tokio.rs/)之上,这部分内容可以在这个菜谱之外探索。为了模拟快速读取和慢速处理步骤,我们将它实现为一个for循环:
fn main() -> std::io::Result<()> {
    System::run(|| {
        println!(">> Press Ctrl-C to stop the program");
        // start multi threaded actor host (arbiter) with 2 threads
        let sender = SyncArbiter::start(N_THREADS, || 
        DBWriter);

        // send messages to the actor 
        for n in 0..10_000 {
            let my_timestamp = n as u64;
            let data = read_sensordata();
            sender.do_send(Sensordata(my_timestamp, data));
        }
    })
}
  1. 让我们来处理最重要的部分:演员的消息处理。actix要求您实现Handler<T>特质。在main函数之前添加以下实现:
struct DBWriter;

impl Actor for DBWriter {
    type Context = SyncContext<Self>;
}

impl Handler<Sensordata> for DBWriter {
    type Result = ();

    fn handle(&mut self, msg: Sensordata, _: &mut Self::Context) -> 
     Self::Result {

        // send stuff somewhere and handle the results
        println!(" {:?}", msg);
        thread::sleep(Duration::from_millis(300));
    }
}
  1. 使用cargo run来运行程序,看看它是如何生成人工传感器数据的(如果您不想等待它完成,请按Ctrl + C):
$ cargo run
   Compiling actors v0.1.0 (Rust-Cookbook/Chapter04/actors)
    Finished dev [unoptimized + debuginfo] target(s) in 2.05s
     Running `target/debug/actors`
>> Press Ctrl-C to stop the program
  Sensordata(0, 2.2577233)
  Sensordata(1, 4.039347)
  Sensordata(2, 8.981095)
  Sensordata(3, 1.1506838)
  Sensordata(4, 7.5091066)
  Sensordata(5, 2.5614727)
  Sensordata(6, 3.6907816)
  Sensordata(7, 7.907603)
  ^C⏎    

现在,让我们幕后了解代码,以便更好地理解。

它是如何工作的...

演员模型通过面向对象的方法解决了在线程间传递数据的不足。通过利用演员之间消息的隐式队列,它可以防止昂贵的锁定和损坏状态。关于这个主题有大量的内容,例如,在 Akka 的文档中doc.akka.io/docs/akka/current/guide/actors-intro.html

在前两个步骤准备项目之后,步骤 3展示了使用宏([#derive()])实现Message特质的代码。有了这个,我们继续设置主系统——运行演员调度和幕后消息传递的主循环。

actix使用Arbiters来运行不同的演员和任务。一个常规的仲裁者基本上是一个单线程的事件循环,有助于在非并发环境中工作。另一方面,SyncArbiter是一个多线程版本,允许跨线程使用演员。在我们的例子中,我们使用了三个线程。

步骤 5中,我们看到了处理器的最小实现要求。使用SyncArbiter不允许通过返回值发送消息,这就是为什么结果现在是一个空元组。处理器也针对特定的消息类型,处理函数通过发出thread::sleep来模拟长时间运行的操作——这之所以有效,是因为它是唯一在该特定线程中运行的演员。

我们只是触及了actix能做什么的表面(省略了全能的 Tokio 任务和流)。查看他们关于这个主题的书(actix.rs/book/actix/)以及他们在 GitHub 存储库中的示例.

我们已经成功地学习了如何使用演员处理异步消息。现在让我们继续下一个菜谱。

使用 futures 进行异步编程

在 JavaScript、TypeScript、C# 和类似技术中,使用 futures 是一种常见的技巧——通过在它们的语法中添加 async/await 关键字而变得流行。简而言之,futures(或承诺)是一个函数的保证,在某个时刻,句柄将被解决,并将返回实际值。然而,并没有明确的时间表明这将会发生——但你可以安排一系列承诺,这些承诺依次解决。在 Rust 中这是如何工作的?让我们在这个食谱中找出答案。

在撰写本文时,async/await 正在经历重大开发。根据你阅读这本书的时间,示例可能已经停止工作。在这种情况下,我们要求你在配套的存储库中打开一个问题,这样我们就可以修复这些问题。有关更新,请查看 Rust async 工作组的存储库 github.com/rustasync/team

如何做到这一点...

在几个步骤中,我们将能够在 Rust 中使用 asyncawait 以实现无缝并发:

  1. 使用 cargo new async-await 创建一个新的二进制应用程序,并在 Visual Studio Code 中打开该目录。

  2. 如同往常,当我们集成库时,我们必须将依赖项添加到 Cargo.toml

[package]
name = "async-await"
version = "0.1.0"
authors = ["Claus Matzinger <claus.matzinger+kb@gmail.com>"]
edition = "2018"

[dependencies]
runtime = "0.3.0-alpha.6"
surf = "1.0"
  1. src/main.rs 中,我们必须导入依赖项。在文件顶部添加以下行:
use surf::Exception;
use surf::http::StatusCode;
  1. 经典的例子是等待一个网络请求完成。这通常很难判断,因为网络资源和/或中间的网络可能由其他人拥有,并且可能已经关闭。surf (github.com/rustasync/surf) 默认为 async,因此需要大量使用 .await 语法。让我们声明一个 async 函数来进行获取:
async fn response_code(url: &str) -> Result<StatusCode, Exception> {
    let res = surf::get(url).await?;
    Ok(res.status())
}
  1. 现在我们需要一个 async main 函数来调用 response_code() async 函数:
#[runtime::main]
async fn main() -> Result<(), Exception> {
    let url = "https://www.rust-lang.org";
    let status = response_code(url).await?;
    println!("{} responded with HTTP {}", url, status);
    Ok(())
}
  1. 让我们通过运行 cargo run 来看看代码是否工作(预期结果是 200 OK):
$ cargo +nightly run
 Compiling async-await v0.1.0 (Rust-Cookbook/Chapter04/async-await)
    Finished dev [unoptimized + debuginfo] target(s) in 1.81s
     Running `target/debug/async-await`
     https://www.rust-lang.org responded with HTTP 200 OK

asyncawait 在 Rust 社区中已经讨论了很长时间。让我们看看这个食谱是如何工作的。

它是如何工作的...

Futures(通常称为承诺)通常完全集成到语言中,并带有内置的运行时。在 Rust 中,团队选择了一种更雄心勃勃的方法,并将运行时留给社区来实现(目前是这样)。目前,两个项目 Tokio 和 Romio (github.com/withoutboats/romio) 以及 juliex (github.com/withoutboats/juliex) 为这些 futures 提供了最复杂的支持。随着 2018 版本中 Rust 语法中 async/await 的最近添加,各种实现成熟只是时间问题。

第 1 步 中设置好依赖项后,第 2 步 显示我们不需要启用 asyncawait 宏/语法就可以在代码中使用它们——这曾经是一个长期的要求。然后,我们导入所需的包。巧合的是,当我们在忙于这本书时,Rust 异步工作组构建了一个新的异步网络库——称为 surf。由于这个包是完全异步构建的,我们更倾向于使用它而不是更成熟的包,如 hyper (hyper.rs)。

第 3 步 中,我们声明了一个 async 函数,它自动返回一个 Future (doc.rust-lang.org/std/future/trait.Future.html) 类型,并且只能在其他 async 范围内调用。第 4 步 展示了如何使用 async main 函数创建这样的范围。这就结束了吗?不——#[runtime::main] 属性揭示了这一点:运行时无缝启动并分配执行任何异步操作。

虽然 runtime 包 (docs.rs/runtime/0.3.0-alpha.7/runtime/) 对实际实现不敏感,但默认情况下是基于 romiojuliex 的本地运行时(检查您的 Cargo.lock 文件),但您也可以启用功能更丰富的 tokio 运行时,以启用流、定时器等功能,用于异步操作之上。

async 函数内部,我们可以使用附加到 Future 实现者的 await 关键字 (doc.rust-lang.org/std/future/trait.Future.html),例如 surf 请求 (github.com/rustasync/surf/blob/master/src/request.rs#L563),其中运行时会调用 poll() 直到结果可用。这也可能导致错误,这意味着我们必须处理错误,这通常使用 ? 操作符来完成。surf 还提供了一个通用的 Exception 类型 (docs.rs/surf/1.0.2/surf/type.Exception.html) 别名来处理可能发生的一切。

尽管在 Rust 快速发展的生态系统中还有一些事情可能会发生变化,但使用 async/await 现在终于可以一起使用,而无需高度不稳定的包。拥有这一点对 Rust 的实用性是一个重大的提升。现在,让我们继续到另一个章节。

第五章:处理错误和其他结果

在每种编程语言中,处理错误始终是一个有趣的挑战。有许多风格可供选择:返回数值、异常(软件中断)、结果和选项类型等。每种方式都需要不同的架构,并对性能、可读性和可维护性有影响。Rust 的方法——就像许多函数式编程语言一样——基于将失败集成到常规工作流程中。这意味着无论返回值如何,错误都不是一个特殊情况,而是集成到处理中。OptionResult 是中心类型,允许返回结果以及错误。panic! 是一个额外的宏,在无法/不应继续时立即停止线程。

在本章中,我们将介绍一些基本的配方和架构,以有效地使用 Rust 的错误处理,使你的代码易于阅读、理解和维护。因此,在本章中,你可以期待学习以下配方:

  • 负责任地恐慌

  • 处理多个错误

  • 与异常结果一起工作

  • 无缝错误处理

  • 自定义错误

  • 弹性编程

  • 与外部 crate 进行错误处理

  • 在 Option 和 Result 之间移动

负责任地恐慌

有时,执行线程无法继续执行。这可能是由于无效的配置文件、无响应的同伴或服务器,或与操作系统相关的错误。Rust 有许多方法可以恐慌,无论是显式还是隐式。最普遍的一个可能是 unwrap() 对于多个 Option 类型及其相关类型,它在出错或 None 时会恐慌。然而,对于更复杂的程序,控制恐慌(例如,通过避免多个 unwrap() 调用和使用它的库)是至关重要的,而 panic! 宏支持这一点。

如何做到...

让我们看看我们如何可以控制多个 panic! 实例:

  1. 使用 cargo new panicking-responsibly --lib 创建一个新的项目,并用 VS Code 打开它。

  2. 打开 src/lib.rs 并将默认测试替换为常规、直接的恐慌实例:

#[cfg(test)]
mod tests {

    #[test]
    #[should_panic]
    fn test_regular_panic() {
        panic!();
    }
}
  1. 还有许多其他停止程序的方法。让我们添加另一个 test 实例:
    #[test]
    #[should_panic]
    fn test_unwrap() {
        // panics if "None"
        None::<i32>.unwrap();
    }
  1. 然而,这些恐慌都有一个通用的错误消息,这并不很有助于了解应用程序正在做什么。使用 expect() 可以让你提供一个错误消息来解释错误的起因:
    #[test]
    #[should_panic(expected = "Unwrap with a message")]
    fn test_expect() {
        None::<i32>.expect("Unwrap with a message");
    }
  1. panic! 宏提供了一种类似的方式来解释突然的停止:
    #[test]
    #[should_panic(expected = "Everything is lost!")]
    fn test_panic_message() {
        panic!("Everything is lost!");
    }

    #[test]
    #[should_panic(expected = "String formatting also works")]
    fn test_panic_format() {
        panic!("{} formatting also works.", "String");
    }
  1. 宏也可以返回数值,这对于可以检查这些值的 Unix 类操作系统来说非常重要。添加另一个测试以返回一个整数代码来指示特定的失败:
    #[test]
    #[should_panic]
    fn test_panic_return_value() {
        panic!(42);
    }
  1. 基于无效值停止程序的另一种很好的方法是使用 assert! 宏。它应该从编写测试中很熟悉,所以让我们添加一些来看看 Rust 的变体:
    #[test]
    #[should_panic]
    fn test_assert() {
        assert!(1 == 2);
    }

    #[test]
    #[should_panic]
    fn test_assert_eq() {
        assert_eq!(1, 2);
    }

    #[test]
    #[should_panic]
    fn test_assert_neq() {
        assert_ne!(1, 1);
    }
  1. 最后一步,像往常一样,使用cargo test编译并运行我们刚刚编写的代码。输出显示测试是否通过(它们应该通过):
$ cargo test
 Compiling panicking-responsibly v0.1.0 (Rust- Cookbook/Chapter05
 /panicking-responsibly)
Finished dev [unoptimized + debuginfo] target(s) in 0.29s
Running target/debug/deps/panicking_responsibly-6ec385e96e6ee9cd

running 9 tests
test tests::test_assert ... ok
test tests::test_assert_eq ... ok
test tests::test_assert_neq ... ok
test tests::test_panic_format ... ok
test tests::test_expect ... ok
test tests::test_panic_message ... ok
test tests::test_panic_return_value ... ok
test tests::test_regular_panic ... ok
test tests::test_unwrap ... ok

test result: ok. 9 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

   Doc-tests panicking-responsibly

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

但这如何让我们能够负责任地恐慌呢?让我们看看它是如何工作的。

它是如何工作的...

多亏了 Rust 检查 panic 结果的能力,我们可以验证消息和 panic 发生的事实。从步骤 2步骤 4,我们只是在使用各种(常见)方法恐慌,例如unwrap() (doc.rust-lang.org/std/option/enum.Option.html#method.unwrap)或panic!() (doc.rust-lang.org/std/macro.panic.html)。这些方法返回的消息,如'called Option::unwrap()on aNone value', src/libcore/option.rs:347:21panicked at 'explicit panic', src/lib.rs:64:9,并不容易调试。

然而,有一个名为unwrap()expect()变体,它接受一个&str参数作为用户用于进一步调试问题的简单消息。步骤 4步骤 6展示了如何结合消息和返回值。在步骤 7中,我们介绍了额外的assert!宏,它通常在测试中看到,但也进入生产系统以防止罕见且无法恢复的值。

停止线程或程序的执行应该是最后的手段,尤其是在你为他人创建库时。想想看——一些错误导致第三方库中出现意外的值,然后引发恐慌并使服务立即意外停止。想象一下,如果这种情况是由于调用unwrap()而不是使用更健壮的方法而发生的。

我们已经成功地学会了如何负责任地恐慌。现在,让我们继续下一个菜谱。

处理多个错误

当一个应用程序变得更加复杂并包含第三方框架时,需要一致地处理各种错误类型,而不需要对每个错误都设置条件。例如,一个网络服务的大量可能错误可能会冒泡到处理器,在那里它们需要被转换成带有信息性消息的 HTTP 代码。这些预期的错误可能从解析错误到无效的认证细节,失败的数据库连接,或者带有错误代码的应用程序特定错误。在这个菜谱中,我们将介绍如何使用包装器来处理这些各种错误。

如何做到这一点...

让我们分几个步骤创建一个错误包装器:

  1. 使用 VS Code 打开你用cargo new multiple-errors创建的项目。

  2. 打开src/main.rs并添加一些顶部的导入:

use std::fmt;
use std::io;
use std::error::Error;
  1. 在我们的应用程序中,我们将处理三种用户定义的错误。让我们在导入之后立即声明它们:
#[derive(Debug)]
pub struct InvalidDeviceIdError(usize);
#[derive(Debug)]
pub struct DeviceNotPresentError(usize);
#[derive(Debug)]
pub struct UnexpectedDeviceStateError {}
  1. 现在是包装器的时间:由于我们正在处理某种事物的多个变体,enum将完美地满足这个目的:

#[derive(Debug)]
pub enum ErrorWrapper {
    Io(io::Error),
    Db(InvalidDeviceIdError),
    Device(DeviceNotPresentError), 
    Agent(UnexpectedDeviceStateError)
}
  1. 然而,如果有一个与其他错误相同的接口那就更好了,所以让我们实现std::error::Error特质:
impl Error for ErrorWrapper {
    fn description(&self) -> &str {
        match *self {
            ErrorWrapper::Io(ref e) => e.description(),
            ErrorWrapper::Db(_) | ErrorWrapper::Device(_) => "No 
             device present with this id, check formatting.",
            _ => "Unexpected error. Sorry for the inconvenience."
        }
    }
}
  1. 特性使得必须实现std::fmt::Display,因此这将是我们下一个impl块:
impl fmt::Display for ErrorWrapper {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match *self {
            ErrorWrapper::Io(ref e) => write!(f, "{} [{}]", e, 
                self.description()), 
            ErrorWrapper::Db(ref e) => write!(f, "Device with id \"
             {}\" not found [{}]", e.0, self.description()),
            ErrorWrapper::Device(ref e) => write!(f, "Device with
             id\"{}\" is currently unavailable [{}]", e.0,         
             self.description()),
            ErrorWrapper::Agent(_) => write!(f, "Unexpected device 
             state [{}]", self.description())
        }
    }
}
  1. 现在,我们想看到我们劳动的结果。按照以下方式替换现有的main函数:
fn main() {
    println!("{}",     
    ErrorWrapper::Io(io::Error::from(io::ErrorKind::InvalidData)));
    println!("{}", ErrorWrapper::Db(InvalidDeviceIdError(42)));
    println!("{}", ErrorWrapper::Device
     (DeviceNotPresentError(42)));
    println!("{}", ErrorWrapper::Agent(UnexpectedDeviceStateError 
{}));
}
  1. 最后,我们执行cargo run以查看输出是否与我们之前预期的相符:
$ cargo run
 Compiling multiple-errors v0.1.0 (Rust-Cookbook/Chapter05
 /multiple-errors)
 Finished dev [unoptimized + debuginfo] target(s) in 0.34s
 Running `target/debug/multiple-errors`
invalid data [invalid data]
Device with id "42" not found [No device present with this id, check formatting.]
Device with id "42" is currently unavailable [No device present with this id, check formatting.]
Unexpected device state [Unexpected error. Sorry for the inconvenience.]

现在,让我们深入幕后,更好地理解代码。

它是如何工作的...

多个错误一开始可能看起来不是什么大问题,但对于一个清晰、易读的架构,有必要以某种方式解决它们。一个封装可能变体的枚举已被证明是最实用的解决方案,通过实现std::error::Error(以及std::fmt::Display要求),新错误类型的处理应该无缝。在步骤 36中,我们以简约的方式展示了所需特性的示例实现。步骤 7展示了如何使用封装枚举以及如何使用DisplayError实现来帮助匹配变体。

实现Error特性将在未来允许有趣的特点,包括递归嵌套。检查doc.rust-lang.org/std/error/trait.Error.html#method.source文档以了解更多信息。通常,如果我们能避免,我们不会创建这些错误变体,这就是为什么有支持性 crate 负责所有样板代码——我们将在本章的另一道菜中介绍这一点。

让我们继续到下一个菜谱,以补充我们在处理多个错误方面的新技能!

与异常结果一起工作

除了Option类型外,Result类型还可以有两个自定义类型,这意味着Result提供了关于错误原因的额外信息。这比返回单个类型实例或NoneOption更具有表达性。然而,这个None实例可以意味着从处理失败输入错误的任何东西。这样,Result类型可以被视为与其他语言中的异常类似系统,但它们是程序常规工作流程的一部分。一个例子是搜索,其中可能发生多种情况:

  • 找到了所需值。

  • 未找到所需值。

  • 集合无效。

  • 该值无效。

如何有效地使用Result类型?让我们在这个菜谱中找出答案!

如何做...

这里有一些使用ResultOption的步骤:

  1. 使用cargo new exceptional-results --lib创建一个新的项目,并用 VS Code 打开它。

  2. 打开src/lib.rs并在test模块之前添加一个函数:

/// 
/// Finds a needle in a haystack, returns -1 on error 
/// 
pub fn bad_practice_find(needle: &str, haystack: &str) -> i32 {
    haystack.find(needle).map(|p| p as i32).unwrap_or(-1)
}
  1. 如其名所示,这不是在 Rust 中传达失败的最佳方式。那么更好的方式是什么呢?一个答案是利用Option枚举。在第一个函数下面添加另一个函数:
/// 
/// Finds a needle in a haystack, returns None on error 
/// 
pub fn better_find(needle: &str, haystack: &str) -> Option<usize> {
    haystack.find(needle)
}
  1. 这使得推理预期的返回值成为可能,但 Rust 允许更丰富的变化——例如Result类型。将以下内容添加到当前函数集合中:
#[derive(Debug, PartialEq)]
pub enum FindError {
    EmptyNeedle,
    EmptyHaystack,
    NotFound,
}

/// 
/// Finds a needle in a haystack, returns a proper Result 
/// 
pub fn best_find(needle: &str, haystack: &str) -> Result<usize, FindError> {
    if needle.len() <= 0 {
        Err(FindError::EmptyNeedle)
    } else if haystack.len() <= 0 {
        Err(FindError::EmptyHaystack)
    } else {
        haystack.find(needle).map_or(Err(FindError::NotFound), |n| 
    Ok(n))
    }
}
  1. 现在我们实现了几种相同函数的变体,让我们来测试它们。对于第一个函数,将以下内容添加到 test 模块中,并替换现有的(默认)测试:
    use super::*;

    #[test]
    fn test_bad_practice() {
        assert_eq!(bad_practice_find("a", "hello world"), -1);
        assert_eq!(bad_practice_find("e", "hello world"), 1);
        assert_eq!(bad_practice_find("", "hello world"), 0);
        assert_eq!(bad_practice_find("a", ""), -1);
    }
  1. 其他测试函数看起来非常相似。为了保持一致的结果并展示返回类型之间的差异,将这些添加到 test 模块中:
    #[test]
    fn test_better_practice() {
        assert_eq!(better_find("a", "hello world"), None);
        assert_eq!(better_find("e", "hello world"), Some(1));
        assert_eq!(better_find("", "hello world"), Some(0)); 
        assert_eq!(better_find("a", ""), None); 
    }

    #[test]
    fn test_best_practice() {
        assert_eq!(best_find("a", "hello world"), 
        Err(FindError::NotFound));
        assert_eq!(best_find("e", "hello world"), Ok(1));
        assert_eq!(best_find("", "hello world"), 
        Err(FindError::EmptyNeedle));
        assert_eq!(best_find("e", ""), 
        Err(FindError::EmptyHaystack)); 
    }
  1. 让我们运行 cargo test 来查看测试结果:
$ cargo test
Compiling exceptional-results v0.1.0 (Rust-Cookbook/Chapter05
 /exceptional-results)
Finished dev [unoptimized + debuginfo] target(s) in 0.53s
Running target/debug/deps/exceptional_results-97ca0d7b67ae4b8b

running 3 tests
test tests::test_best_practice ... ok
test tests::test_bad_practice ... ok
test tests::test_better_practice ... ok

test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

   Doc-tests exceptional-results

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

现在,让我们看看幕后发生了什么,以便更好地理解代码。

它是如何工作的...

Rust 和许多其他编程语言使用 Result 类型来一次性传达多个函数结果。这样,函数可以像设计的那样返回,而不需要(意外的)跳跃,例如异常机制。

在这个菜谱的 第 3 步 中,我们展示了在其他语言中常见的错误通信方式(例如,Java)——然而,正如我们在测试 (第 6 步) 中所看到的,空字符串的结果是意外的(0 而不是 -1)。在第 3 步 中,我们定义了一个更好的返回类型,但这足够吗?不,还不够。在第 4 步 中,我们实现了函数的最佳版本,其中每个 Result 类型都易于解释和明确定义。

一个如何使用 Result 的更伟大的例子可以在标准库中找到。它是 slice 特性上的 quick_search 函数,它返回找到项的位置的 Ok() 和应该找到项的位置的 Err()。有关更多详细信息,请查看doc.rust-lang.org/std/primitive.slice.html#method.binary_search文档。

一旦你掌握了使用多个 ResultOption 类型来传达成功和失败之外的信息的技巧,其他人会喜欢你的表达性 API。继续学习,进入下一个菜谱。

无缝错误处理

异常在许多程序中是一个特殊情况:它们有自己的执行路径,程序可以随时跳入这个路径。但这理想吗?这取决于 try 块的大小(或 whatever 的名称);这可能会覆盖几个语句,调试运行时异常很快就会变得不有趣。实现安全错误处理的一种更好的方式可能是将错误集成到函数调用的结果中——这种做法已经在 C 函数中看到,其中参数执行数据传输,返回代码表示成功/失败。较新的、更函数式的方法建议类似于 Rust 中的 Result 类型——它带有用于优雅地处理各种结果的功能。这使得错误成为函数的预期结果,并使错误处理无需为每个调用添加额外的 if 条件。

在这个菜谱中,我们将介绍几种无缝处理错误的方法。

如何做到这一点...

让我们通过一些步骤来无缝处理错误:

  1. 使用 cargo new exceptional-results --lib 创建一个新的项目,并用 VS Code 打开它。

  2. 打开src/lib.rs并替换现有的测试为新测试:

    #[test]
    fn positive_results() {
       // code goes here
    }
  1. 如其名所示,我们将在函数体中添加一些正面的结果测试。让我们从声明和简单的内容开始。用以下内容替换前面的// code goes here部分:
        let ok: Result<i32, f32> = Ok(42);

        assert_eq!(ok.and_then(|r| Ok(r + 1)), Ok(43));
        assert_eq!(ok.map(|r| r + 1), Ok(43));
  1. 让我们添加一些更多的变化,因为多个Result类型可以表现得就像布尔值一样。在good_results测试中添加一些更多的代码:
        // Boolean operations with Results. Take a close look at 
        // what's returned
        assert_eq!(ok.and(Ok(43)), Ok(43));
        let err: Result<i32, f32> = Err(-42.0);
        assert_eq!(ok.and(err), err);
        assert_eq!(ok.or(err), ok);
  1. 然而,有好结果的地方,也可能会有坏结果!在Result类型的情况下,这关乎Err变体。添加另一个名为negative_results的空测试:
    #[test]
    fn negative_results() {
        // code goes here
    }
  1. 就像之前一样,我们将//code goes here注释替换为一些实际的测试:
        let err: Result<i32, f32> = Err(-42.0);
        let ok: Result<i32, f32> = Ok(-41);

        assert_eq!(err.or_else(|r| Ok(r as i32 + 1)), ok);
        assert_eq!(err.map(|r| r + 1), Err(-42.0));
        assert_eq!(err.map_err(|r| r + 1.0), Err(-41.0));
  1. 除了正面的结果外,负面的结果通常有自己的函数,例如map_err。与它相反,布尔函数表现一致,并将Err结果视为假。将以下内容添加到negative_results测试中:
        let err2: Result<i32, f32> = Err(43.0);
        let ok: Result<i32, f32> = Ok(42);
        assert_eq!(err.and(err2), err);
        assert_eq!(err.and(ok), err);
        assert_eq!(err.or(ok), ok);
  1. 作为最后一步,我们运行cargo test来查看测试结果:
$ cargo test
 Compiling seamless-errors v0.1.0 (Rust-Cookbook/Chapter05
  /seamless-errors)
 Finished dev [unoptimized + debuginfo] target(s) in 0.37s
 Running target/debug/deps/seamless_errors-7a2931598a808519

running 2 tests
test tests::positive_results ... ok
test tests::negative_results ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

   Doc-tests seamless-errors

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

你想知道更多吗?继续阅读以了解它是如何工作的。

它是如何工作的...

Result类型对于创建将所有可能函数结果集成到常规工作流程中的代码非常重要。这消除了对异常的特殊处理需求,使代码更加简洁,更容易推理。由于这些类型事先已知,库可以提供专门的函数,这正是我们在本菜谱中要探讨的。

在前几个步骤(步骤 2步骤 4),我们正在处理正面的结果,这意味着被Ok枚举变体包裹的值。首先,我们介绍了and_then函数,它提供了各种函数的链式调用,这些函数只有在初始ResultOk时才应该执行。如果链中的某个函数返回Err值,则Err结果会被传递下去,跳过正面的处理器(如and_thenmap)。同样,map()允许在Result类型内进行转换。mapand_then都只允许将Result<i32, i32>转换为Result<MyOwnType, i32>,但不能单独转换为MyOwnType。最后,测试覆盖了表中总结的多个Result类型的布尔运算:

A B A and B
Ok Ok Ok (B)
Ok Err Err
Err Ok Err
Err Err Err (A)
Ok Ok Ok (A)

剩余的步骤(步骤 5步骤 7)展示了与负面的结果类型Err相同的处理过程:map()只处理Ok结果,map_err()转换Err。其特殊情况是or_else()函数,它在返回Err时执行提供的闭包。测试的最后部分涵盖了多个Result类型的布尔函数,并展示了它们如何与各种Err参数一起工作。

现在我们已经看到了许多处理OkErr的不同变体,让我们继续下一个菜谱。

自定义错误

虽然Result类型对Err分支返回的类型不关心,但返回String实例作为错误信息也不是最佳选择。典型的错误有几个需要考虑的因素:

  • 是否有根本原因或错误?

  • 什么是错误信息?

  • 是否有更深入的消息要输出?

标准库的错误都遵循来自std::error::Error的通用特质——让我们看看它们是如何实现的。

如何做...

定义错误类型并不难——只需遵循以下步骤:

  1. 使用cargo new custom-errors创建一个新的项目,并用 VS Code 打开它。

  2. 使用 VS Code,打开src/main.rs并创建一个名为MyError的基本结构体:

use std::fmt;
use std::error::Error;

#[derive(Debug)]
pub struct MyError {
    code: usize,
}
  1. 我们可以实现一个Error特质,如下所示:
impl Error for MyError {
    fn description(&self) -> &str {
        "Occurs when someone makes a mistake"
 }
}
  1. 然而,特质还要求我们(除了我们推导出的Debug之外)实现std::fmt::Display
impl fmt::Display for MyError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
       write!(f, "Error code {:#X}", self.code) 
    }
}
  1. 最后,让我们看看这些特质是如何发挥作用的,并替换main函数:
fn main() {
    println!("Display: {}", MyError{ code: 1535 });
    println!("Debug: {:?}", MyError{ code: 42 });
    println!("Description: {:?}", (MyError{ code: 42 
    }).description());
}
  1. 然后,我们可以使用cargo run看到一切是如何协同工作的:
$ cargo run
Compiling custom-errors v0.1.0 (Rust-Cookbook/Chapter05/custom-errors)
 Finished dev [unoptimized + debuginfo] target(s) in 0.23s
 Running `target/debug/custom-errors`
Display: Error code 0x5FF
Debug: MyError { code: 42 }
Description: "Occurs when someone makes a mistake"

让我们看看是否可以深入了解这个简短的食谱。

它是如何工作的...

虽然任何类型在Result分支中都可以正常工作,但 Rust 提供了一个错误特质,可以用于更好地集成到其他 crate 中。一个例子是actix_web框架的错误处理(actix.rs/docs/errors/),它既与std::error::Error一起工作,也与其自己的类型一起工作(我们将在第八章 Chapter 8,Web 的安全编程)进行更深入的探讨)。

此外,Error特质还提供了嵌套,并且,通过动态分派,所有Errors都可以遵循一个通用的 API。在步骤 2中,我们声明类型并推导出(强制性的)Debug特质。在步骤 3步骤 4中,剩余的实现遵循。其余的食谱执行代码。

在这个简短而甜美的食谱中,我们可以创建自定义错误类型。现在,让我们继续下一个食谱。

弹性编程

返回ResultOption将始终遵循一个特定的模式,该模式生成大量的模板代码——特别是对于不确定的操作,如读取或创建文件和搜索值。特别是,该模式会产生大量使用早期返回的代码(记得goto吗?)或嵌套语句,这两种情况都会产生难以推理的代码。因此,Rust 库的早期版本实现了一个try!宏,它已被?运算符所取代,作为快速早期返回的选项。让我们看看这如何影响代码。

如何做...

按照以下步骤编写更健壮的程序:

  1. 使用cargo new resilient-programming创建一个新的项目,并用 VS Code 打开它。

  2. 打开src/main.rs以添加一个函数:

use std::fs;
use std::io;

fn print_file_contents_qm(filename: &str) -> Result<(), io::Error> {
    let contents = fs::read_to_string(filename)?;
    println!("File contents, external fn: {:?}", contents);
    Ok(())
}
  1. 前面的函数在找到文件时会打印文件内容;除此之外,我们还需要调用这个函数。为此,用以下内容替换现有的main函数:
fn main() -> Result<(), std::io::Error> {
    println!("Ok: {:?}", print_file_contents_qm("testfile.txt"));
    println!("Err: {:?}", print_file_contents_qm("not-a-file"));

    let contents = fs::read_to_string("testfile.txt")?;
    println!("File contents, main fn: {:?}", contents);
    Ok(())
}
  1. 就这样——运行cargo run以找出结果:
$ cargo run
 Compiling resilient-programming v0.1.0 (Rust-Cookbook/Chapter05
  /resilient-programming)
 Finished dev [unoptimized + debuginfo] target(s) in 0.21s
 Running `target/debug/resilient-programming`
File contents, external fn: "Hello World!"
Ok: Ok(())
Err: Err(Os { code: 2, kind: NotFound, message: "No such file or directory" })
File contents, main fn: "Hello World!"

现在,让我们深入了解代码,以更好地理解它。

它是如何工作的...

在这四个步骤中,我们看到了问号运算符的使用以及它是如何避免与守卫相关的典型模板代码。在步骤 3中,我们创建了一个函数,如果找到了文件(并且可读),它会打印文件内容;通过使用?运算符,我们可以跳过检查返回值并在必要时退出函数——所有这些操作都通过简单的?运算符完成。

步骤 4中,我们不仅调用了之前创建的函数,而且还打印了结果以展示其工作方式。此外,相同的模式也应用于(特殊的)main函数,它现在有返回值。因此,?不仅限于子函数,还可以应用于整个应用程序。

只需几个简单的步骤,我们就看到了如何安全地使用?运算符来解包Result。现在,让我们继续下一个菜谱。

使用外部 crate 进行错误处理

在现代程序中,创建和包装错误是一个常见的任务。然而,正如我们在本章的各种菜谱中所看到的,处理每一个可能的案例以及关心可能返回的每个可能的变体可能会相当繁琐。这个问题是众所周知的,Rust 社区已经找到了使这变得更容易的方法。我们将在下一章(第六章,使用宏表达自己)中涉及宏,但创建错误类型很大程度上依赖于使用宏。此外,这个菜谱与之前的菜谱(处理多个错误)相呼应,以展示代码中的差异。

如何做到这一点...

让我们通过几个步骤引入一些外部 crate 来更好地处理错误:

  1. 使用cargo new external-crates创建一个新的项目,并用 VS Code 打开它。

  2. 编辑Cargo.toml以添加quick-error依赖项:

[dependencies]
quick-error = "1.2"
  1. 要使用quick-error中提供的宏,我们需要显式地导入它们。将以下use语句添加到src/main.rs中:
#[macro_use] extern crate quick_error;

use std::convert::From;
use std::io;
  1. 然后,我们将一步添加所有我们想要在quick_error!宏中声明的错误:
quick_error! {
    #[derive(Debug)]
    pub enum ErrorWrapper {
        InvalidDeviceIdError(device_id: usize) {
            from(device_id: usize) -> (device_id)
            description("No device present with this id, check 
            formatting.")
        }

        DeviceNotPresentError(device_id: usize) {
            display("Device with id \"{}\" not found", device_id)
        }

        UnexpectedDeviceStateError {}

        Io(err: io::Error) {
            from(kind: io::ErrorKind) -> (io::Error::from(kind))
            description(err.description())
            display("I/O Error: {}", err)
        } 
    }
}
  1. 代码只有在添加了main函数后才是完整的:
fn main() {
    println!("(IOError) {}", 
    ErrorWrapper::from(io::ErrorKind::InvalidData));
    println!("(InvalidDeviceIdError) {}", 
    ErrorWrapper::InvalidDeviceIdError(42));
    println!("(DeviceNotPresentError) {}", 
    ErrorWrapper::DeviceNotPresentError(42));
    println!("(UnexpectedDeviceStateError) {}", 
    ErrorWrapper::UnexpectedDeviceStateError {});
}
  1. 使用cargo run来查找程序的输出:
$ cargo run
 Compiling external-crates v0.1.0 (Rust-Cookbook/Chapter05
  /external-crates)
 Finished dev [unoptimized + debuginfo] target(s) in 0.27s
  Running `target/debug/external-crates`
(IOError) I/O Error: invalid data
(InvalidDeviceIdError) No device present with this id, check formatting.
(DeviceNotPresentError) Device with id "42" not found
(UnexpectedDeviceStateError) UnexpectedDeviceStateError

你理解代码了吗?让我们找出它是如何工作的。

它是如何工作的...

与我们之前声明多个错误的菜谱相比,这个声明要短得多,并且有几个额外的优点。第一个优点是,每个错误类型都可以使用From特质创建(步骤 4中的第一个IOError)。其次,每个类型都会自动生成错误名称的描述和Display实现(参见步骤 3中的UnexpectedDeviceStateError,然后是步骤 5)。这并不完美,但作为第一步是不错的。

在底层,quick-error 生成一个处理所有可能情况的枚举,并在必要时生成实现。查看 main 宏——相当令人印象深刻(tailhook.github.io/quick-error/quick_error/macro.quick_error.html)!为了根据您的需求定制 quick-error 的使用,请查看他们的其余文档,网址为 tailhook.github.io/quick-error/quick_error/index.html。或者,还有 error-chain crate (github.com/rust-lang-nursery/error-chain),它采用不同的方法来创建这些错误类型。这两种选项中的任何一种都可以让您极大地提高错误的可读性和实现速度,同时移除所有样板代码。

我们已经成功地学习了如何通过使用外部 crate 来改进我们的错误处理。现在,让我们继续到下一个菜谱。

在 Option 和 Result 之间切换

当函数需要返回二进制结果时,选择使用 ResultOption。两者都可以传达函数调用失败的信息——但前者提供了过多的具体信息,而后者可能提供的信息过少。虽然这是一个针对特定情况做出的决定,但 Rust 的类型提供了在它们之间轻松转换的工具。让我们在这道菜谱中逐一了解它们。

如何做到这一点...

在几个快速步骤中,您将了解如何在不同之间切换 OptionResult

  1. 使用 cargo new options-results --lib 创建一个新的项目,并用 VS Code 打开它。

  2. 让我们编辑 src/lib.rs 并将现有的测试(在 mod tests 内部)替换为以下内容:

    #[derive(Debug, Eq, PartialEq, Copy, Clone)]
    struct MyError;

    #[test]
    fn transposing() {
        // code will follow
    }
  1. 我们需要将 // code will follow 替换为如何使用 transpose() 函数的示例:
        let this: Result<Option<i32>, MyError> = Ok(Some(42));
        let other: Option<Result<i32, MyError>> = Some(Ok(42));
        assert_eq!(this, other.transpose());
  1. 这也适用于 Err,为了证明这一点,将其添加到 transpose() 测试中:
        let this: Result<Option<i32>, MyError> = Err(MyError);
        let other: Option<Result<i32, MyError>> = Some(Err(MyError));
        assert_eq!(this, other.transpose());
  1. 剩下的特殊情况是 None。使用以下内容完成 transpose() 测试:
assert_eq!(None::<Result<i32, MyError>>.transpose(), Ok(None::
 <i32>));
  1. 在两种类型之间切换不仅涉及转置——还有更复杂的方法可以做到这一点。创建另一个 test
    #[test]
    fn conversion() {
        // more to follow
    }
  1. 作为第一次测试,让我们用可以替代 unwrap() 的内容替换 // more to follow
        let opt = Some(42);
        assert_eq!(opt.ok_or(MyError), Ok(42));

        let res: Result<i32, MyError> = Ok(42);
        assert_eq!(res.ok(), opt);
        assert_eq!(res.err(), None);
  1. 为了完成转换测试,也请将以下内容添加到 test 中。这些是转换,但来自 Err 方面:
        let opt: Option<i32> = None;
        assert_eq!(opt.ok_or(MyError), Err(MyError));

        let res: Result<i32, MyError> = Err(MyError);
        assert_eq!(res.ok(), None);
        assert_eq!(res.err(), Some(MyError));
  1. 最后,我们应该使用 cargo test 运行代码,并查看成功的测试结果:
$ cargo test
Compiling options-results v0.1.0 (Rust-Cookbook/Chapter05/options-results)
 Finished dev [unoptimized + debuginfo] target(s) in 0.44s
 Running target/debug/deps/options_results-111cad5a9a9f6792

running 2 tests
test tests::conversion ... ok
test tests::transposing ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

   Doc-tests options-results

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

现在,让我们深入幕后,更好地理解代码。

它是如何工作的...

虽然关于何时使用 Option 和何时使用 Result 的讨论发生在较高层面,但 Rust 通过几个函数支持这两种类型之间的转换。除了 map()and_then() 等函数(在本章的 无缝错误处理 部分讨论过)之外,这些函数还提供了处理各种错误的有效且优雅的方法。在 步骤 1步骤 4 中,我们逐渐构建了一个简单的测试,展示了转置函数的应用性。这使得通过一个函数调用就能从 Ok(Some(42)) 切换到 Some(Ok(42))(注意细微的区别)。同样,调用中的 Err 变体从常规的 Err(MyError) 函数变为 Some(Err(MyError))

剩余的步骤(步骤 6步骤 8)展示了在两种类型之间转换的更传统方法。这包括获取 OkErr 的值,以及为正结果提供错误实例。一般来说,这些函数足以替换大多数 unwrap()expect() 调用,并且程序中只有一个执行路径,无需求助于 ifmatch 条件语句。这增加了鲁棒性和可读性的额外优势,你的未来同事和用户会感谢你!

第六章:使用宏表达自己

在上一个世纪,许多语言都包含一个预处理器(最著名的是 C/C++),它通常执行简单的文本替换。虽然这对于表达常量(例如 #define MYCONST 1)很有用,但当替换变得复杂时(例如,#define MYCONST 1 + 1 并将其应用于 5 * MYCONST 得到 5 * 1 + 1 = 6 而不是预期的 10(来自 5 * (1 + 1)))时,可能会导致意想不到的结果。

然而,预处理器允许程序编程(元编程),因此使开发者的工作变得更简单。通过快速定义宏而不是复制粘贴表达式和过多的样板代码,可以减少代码库的大小,并实现可重用的调用——因此减少了错误。为了充分利用 Rust 的类型系统,宏不能简单地搜索和替换文本;它们必须在更高的层面上工作:抽象语法树。这不仅需要不同的调用语法(例如,在调用末尾使用感叹号;例如,println!),而且参数 类型 也不同。

在这个层面上,我们讨论的是表达式、语句、标识符、类型以及许多可以被传递给宏的内容。然而,最终,宏预处理器仍然在编译前将宏的主体插入到调用作用域中,因此编译器会捕获类型不匹配或借用违规。如果您想了解更多关于宏的信息,请查看博客文章blog.x5ff.xyz/blog/easy-programming-with-rust-macros/、“Rust 宏小书”(danielkeep.github.io/tlborm/book/index.html)和 Rust 书籍(doc.rust-lang.org/book/ch19-06-macros.html)。最好通过尝试来了解宏——我们将在本章中介绍以下内容:

  • 在 Rust 中构建自定义宏

  • 使用宏进行匹配

  • 使用预定义的 Rust 宏

  • 使用宏进行代码生成

  • 宏重载

  • 使用 repeat 为参数范围

  • 不要重复自己(DRY)

在 Rust 中构建自定义宏

之前,我们主要使用预定义的宏——现在是时候看看如何创建自定义宏了。Rust 中有几种类型的宏——基于 derive 的、函数式和属性,它们各自都有相应的用途。在本食谱中,我们将尝试函数式类型以开始。

如何实现...

您只需几个步骤就可以创建宏:

  1. 在终端(或在 Windows 上的 PowerShell)中运行 cargo new custom-macros 并使用 Visual Studio Code 打开该目录。

  2. 在编辑器中打开 src/main.rs 文件。让我们在文件顶部创建一个新的宏,命名为 one_plus_one

// A simple macro without arguments
macro_rules! one_plus_one {
    () => { 1 + 1 };
}
  1. 让我们称这个位于 main 函数内部的简单宏为:
fn main() {
    println!("1 + 1 = {}", one_plus_one!());
}
  1. 这是一个非常简单的宏,但宏可以做更多的事情!比如一个让我们决定操作的宏。在文件顶部添加一个非常简单的宏:
// A simple pattern matching argument
macro_rules! one_and_one {
 (plus) => { 1 + 1 };
 (minus) => { 1 - 1 };
 (mult) => { 1 * 1 };
}
  1. 由于宏的匹配器部分中的单词是必需的,我们必须像那样精确地调用宏。在main函数内部添加以下内容:
    println!("1 + 1 = {}", one_and_one!(plus));
    println!("1 - 1 = {}", one_and_one!(minus));
    println!("1 * 1 = {}", one_and_one!(mult));
  1. 作为最后一部分,我们应该考虑保持事物的有序;创建模块、结构体、文件等。将类似的行为分组是一种常见的组织方式,如果我们想在模块外部使用它,我们需要使其公开可用。就像pub关键字一样,宏必须显式导出——但是使用一个属性。将此模块添加到src/main.rs中,如下所示:
mod macros {
    #[macro_export]
    macro_rules! two_plus_two {
        () => { 2 + 2 };
    }
}
  1. 多亏了导出,我们现在也可以在main()中调用这个函数:
fn main() {
    println!("1 + 1 = {}", one_plus_one!());
    println!("1 + 1 = {}", one_and_one!(plus));
    println!("1 - 1 = {}", one_and_one!(minus));
    println!("1 * 1 = {}", one_and_one!(mult));
    println!("2 + 2 = {}", two_plus_two!());
}
  1. 通过在项目目录内的终端中发出cargo run命令,我们就可以知道它是否成功了:
$ cargo run
 Compiling custom-macros v0.1.0 (Rust-Cookbook/Chapter06/custom-
  macros)
 Finished dev [unoptimized + debuginfo] target(s) in 0.66s
 Running `target/debug/custom-macros`
1 + 1 = 2
1 + 1 = 2
1 - 1 = 0
1 * 1 = 1
2 + 2 = 4

为了更好地理解代码,让我们来解析这些步骤。

它是如何工作的...

根据需要,我们使用宏——macro_rules!——来创建自定义宏,就像我们在步骤 3中所做的那样。一个单独的宏匹配一个模式,并包含三个部分:

  • 一个名称(例如,one_plus_one)

  • 一个匹配器(例如,(plus) => ...)

  • 一个转录器(例如,... => { 1 + 1 })

调用一个宏始终是通过它的名称后跟一个感叹号(步骤 4)来完成的,对于特定的模式,使用所需的字符/单词(步骤 6)。请注意,plus和其他的不是变量、类型或以其他方式定义的——这给了您创建自己的领域特定语言DSL)的能力!关于这一点,本章的其他菜谱中将有更多介绍。

通过调用一个宏,编译器会注意抽象语法树(AST)中的位置,并且不是进行纯文本替换,而是在那里插入宏的转录子树。之后,编译器尝试完成编译,导致常规类型安全检查、借用规则执行等,但考虑到宏。这使得作为开发者的您更容易找到错误并将它们追溯到它们起源的宏。

步骤 6中,我们创建了一个模块来导出一个宏——这将会改善代码结构和可维护性,尤其是在较大的代码库中。然而,导出步骤是必需的,因为宏默认是私有的。尝试移除#[macro_export]属性来看看会发生什么。

步骤 8 展示了如何将项目中的每个宏变体作为比较来调用。有关更多信息,您还可以查看blog.rust-lang.org/2018/12/21/Procedural-Macros-in-Rust-2018.html上的博客文章,该文章更详细地介绍了在crates.io上提供宏 crate 的内容 (crates.io)。

现在我们知道了如何在 Rust 中构建自定义宏,我们可以继续到下一个菜谱。

使用宏实现匹配

当我们创建自定义宏时,我们已经看到了模式匹配在起作用:只有当特定的单词在编译前存在时,才会执行命令。换句话说,宏系统在它们成为表达式或类型之前将原始文本作为模式进行比较。因此,创建一个领域特定语言(DSL)非常容易。定义一个网络请求处理器?使用模式中的方法名称:GETPOSTHEAD。然而,种类繁多,所以让我们看看在这个菜谱中我们如何定义一些模式!

如何做...

通过遵循接下来的几个步骤,您将能够使用宏:

  1. 在终端(或在 Windows 上的 PowerShell)中运行cargo new matching --lib,然后用 Visual Studio Code 打开该目录。

  2. src/lib.rs中,我们添加一个宏来处理特定类型的输入。在文件顶部插入以下内容:

macro_rules! strange_patterns {
    (The pattern must match precisely) => { "Text" };
    (42) => { "Numeric" };
    (;<=,<=;) => { "Alpha" };
}
  1. 显然,应该对其进行测试以查看它是否工作。将it_works()测试替换为不同的测试函数:
    #[test]
    fn test_strange_patterns() {
        assert_eq!(strange_patterns!(The pattern must match 
        precisely), "Text");
        assert_eq!(strange_patterns!(42), "Numeric");
        assert_eq!(strange_patterns!(;<=,<=;), "Alpha");
    }
  1. 模式也可以包含实际的输入参数:
macro_rules! compare {
    ($x:literal => $y:block) => { $x == $y };
}
  1. 一个简单的测试来结束它如下:
    #[test]
    fn test_compare() {
        assert!(compare!(1 => { 1 }));
    }
  1. 处理 HTTP 请求始终是一个架构挑战,每个业务案例都需要添加额外的层和特殊路由。正如一些网络框架(github.com/seanmonstar/warp)所示,宏可以提供有用的支持,以使处理器能够组合在一起。向文件中添加另一个宏和支持函数——register_handler()函数,该函数模拟为我们的假设网络框架注册处理器函数:
#[derive(Debug)]
pub struct Response(usize);
pub fn register_handler(method: &str, path: &str, handler: &Fn() -> Response ) {}

macro_rules! web {
    (GET $path:literal => $b:block) => { 
     register_handler("GET", $path, &|| $b) };
    (POST $path:literal => $b:block) => { 
     register_handler("POST", $path, &|| $b) };
}
  1. 为了确保一切正常工作,我们还应该为web!宏添加一个测试。当函数为空时,不匹配其包含模式的宏会导致编译时错误:
    use super::*;

    #[test]
    fn test_web() {
        web!(GET "/" => { Response(200) });
        web!(POST "/" => { Response(403) });
    } 
  1. 作为最后一步,让我们运行cargo test(注意:在文件顶部添加#![allow(unused_variables, unused_macros)]以消除警告):
$ cargo test
   Compiling matching v0.1.0 (Rust-Cookbook/Chapter06/matching)
    Finished dev [unoptimized + debuginfo] target(s) in 0.31s
     Running target/debug/deps/matching-124bc24094676408

running 3 tests
test tests::test_compare ... ok
test tests::test_strange_patterns ... ok
test tests::test_web ... ok

test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

   Doc-tests matching

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

现在让我们看看代码做了什么。

它是如何工作的...

在这个菜谱的步骤 2中,我们定义了一个宏,该宏明确提供了引擎可以匹配的不同模式。具体来说,字母数字字符限制为,;=>。虽然这允许 Ruby 风格的映射初始化,但它也限制了 DSL 可以拥有的元素。然而,宏仍然非常适合创建处理情况的更表达性的方式。在步骤 6步骤 7中,我们展示了使用比常规链式函数调用更表达性的方式创建网络请求处理器的方法。步骤 4步骤 5展示了在宏中使用箭头(=>)的用法,而步骤 8通过运行测试将一切联系起来。

在这个菜谱中,我们为宏调用创建了匹配臂,这些臂使用字面量匹配(而不是在类型上匹配,这将在本章后面介绍)来决定替换项。这表明我们不仅可以在一个臂中使用参数和字面量,还可以在没有常规允许的名称约束的情况下自动化任务。

我们已经成功地学习了如何在宏中实现匹配。现在让我们继续下一个配方。

使用预定义的宏

正如我们在本章前面的配方中看到的,宏可以节省大量的编写工作,并提供便利函数,而无需重新思考整个应用程序架构。因此,Rust 标准库提供了许多宏,用于实现可能在其他情况下出人意料地复杂的各种功能。一个例子是跨平台打印——那将如何工作?是否为每个平台都有一个输出控制台文本的等效方式?关于颜色支持呢?默认编码是什么?有很多问题,这是需要可配置的东西很多的一个指标——然而,在典型的程序中,我们只调用print!("hello"),它就工作了。让我们看看还有其他什么。

如何做到这一点...

按照以下几个步骤来实现这个配方:

  1. 在终端(或在 Windows 上的 PowerShell)中运行cargo new std-macros,然后用 Visual Studio Code 打开该目录。然后,在项目的src目录中创建一个名为a.txt的文件,并包含以下内容:
Hello World!
  1. 首先,main()的默认实现(在src/main.rs中)已经为我们提供了一个调用println!的宏:
fn main() {
    println!("Hello, world!");
}
  1. 我们可以通过打印更多内容来扩展函数。在main()函数中println!宏调用之后插入以下内容:
    println!("a vec: {:?}", vec![1, 2, 3]);
    println!("concat: {}", concat!(0, 'x', "5ff"));
    println!("MyStruct stringified: {}", stringify!(MyStruct(10)));
    println!("some random word stringified: {}", stringify!
     (helloworld));

  1. MyStruct的定义也很简单,涉及标准库中附带的过程宏。在main()函数之前插入以下内容:
#[derive(Debug)]
struct MyStruct(usize);
  1. Rust 标准库还包括与外部世界交互的宏。让我们在main函数中添加更多调用:
    println!("Running on Windows? {}", cfg!(windows));
    println!("From a file: {}", include_str!("a.txt"));
    println!("$PATH: {:?}", option_env!("PATH")); 
  1. 作为最后一步,让我们向已知的println!assert!宏添加两个替代方案到main()函数中:
    eprintln!("Oh no!");
    debug_assert!(true);
  1. 如果你还没有这样做,我们必须使用cargo run运行整个项目,以查看一些输出:
$ cargo run
   Compiling std-macros v0.1.0 (Rust-Cookbook/Chapter06/std-macros)
    Finished dev [unoptimized + debuginfo] target(s) in 0.25s
     Running `target/debug/std-macros`
Hello, world!
a vec: [1, 2, 3]
concat: 0x5ff
MyStruct stringified: MyStruct ( 10 )
some random word stringified: helloworld
Running on Windows? false
From a file: Hello World!
$PATH: Some("/home/cm/.cargo/bin:/home/cm/.cargo/bin:/home/cm/.cargo/bin:/usr/local/bin:/usr/bin:/bin:/home/cm/.cargo/bin:/home/cm/Apps:/home/cm/.local/bin:/home/cm/.cargo/bin:/home/cm/Apps:/home/cm/.local/bin")
Oh no!

我们现在应该揭开面纱,更好地理解代码。

它是如何工作的...

main函数中,我们现在有几个或多或少为人所知的宏。每个宏都在做我们认为有用的操作。我们将跳过步骤 2,因为它只显示了println!宏——这是我们一直在使用的。然而,在步骤 3中,出现了一些更奇特的宏:

  • vec!创建并初始化一个向量,并且著名地使用[]来这样做。然而,虽然这样做在视觉上是有意义的,编译器同样接受vec!()vec!{}

  • concat!像静态字符串一样从左到右连接字面量。

  • stringify!从输入的标记中创建一个字符串字面量,无论这些标记是否存在(参见单词helloworld,它被转换成了字符串)。

第 4 步包括在 Rust 中使用过程宏。虽然单词derive和语法让人联想到经典 OOP 中的继承,但实际上它们并没有派生任何东西,而是提供了实际的实现。对我们来说,#[derive(Debug)]无疑是迄今为止最有用的,但还有PartialEqEqClone,它们紧随其后。

菜谱的第 5 步回到了函数式宏:

  • cfg!#[cfg]属性类似,这使得在编译时确定条件成为可能,这允许你——例如——包含特定平台的代码。

  • include_str!是一个非常有趣的宏。还有其他包含宏,但这个宏非常有用,因为它可以将提供的文件内容作为'static str(就像字面量一样)提供翻译。

  • option_env!编译时读取环境变量,以提供其值的Option结果。请注意,为了反映变量的更改,程序必须重新编译!

第 6 步的宏是其他已知流行宏的替代品:

  • debug_assert!assert!的一个变体,它不包括在--release构建中。

  • eprintln!将内容输出到标准错误而不是标准输出。

虽然这是一个相当稳定的选项,但 Rust 标准库的未来版本将包括更多宏,使使用 Rust 更加方便。在撰写本文时,最受欢迎的未完成宏示例是await!,由于对async/await的不同方法,它可能永远不会稳定。请查看文档中的完整列表:doc.rust-lang.org/std/#macros

现在我们已经了解了更多关于使用预定义宏的知识,我们可以继续到下一个菜谱。

使用宏进行代码生成

一些派生类型的宏已经向我们展示了我们可以使用宏来生成整个特质的实现。同样,我们也可以使用宏来生成整个结构体和函数,从而避免复制粘贴编程,以及繁琐的样板代码。由于宏是在编译前执行的,生成的代码将相应地进行检查,同时避免了严格类型语言的细节。让我们看看怎么做吧!

如何做到这一点……

代码生成可以像这些简单的步骤一样简单:

  1. 在终端(或在 Windows 上的 PowerShell)中运行cargo new code-generation --lib,然后使用 Visual Studio Code 打开该目录。

  2. 打开src/lib.rs并添加第一个简单的宏:

// Repeat the statement that was passed in n times
macro_rules! n_times {
    // `()` indicates that the macro takes no argument.
    ($n: expr, $f: block) => {
        for _ in 0..$n {
            $f()
        }
    }
}
  1. 让我们再来一个,这次稍微更具有生成性。将以下内容添加到测试模块外部(例如,在之前的宏下面):
// Declare a function in a macro!
macro_rules! make_fn {
    ($i: ident, $body: block) => {
        fn $i () $body
    } 
}
  1. 这两个宏也非常容易使用。让我们用相关的测试替换tests模块:
#[cfg(test)]
mod tests {
    #[test]
    fn test_n_times() {
        let mut i = 0;
        n_times!(5, {
            i += 1;
        });
        assert_eq!(i, 5);
    }

    #[test]
    #[should_panic]
    fn test_failing_make_fn() {
        make_fn!(fail, {assert!(false)});
        fail();
    }

    #[test]
    fn test_make_fn() {
        make_fn!(fail, {assert!(false)});
        // nothing happens if we don't call the function
    }
}
  1. 到目前为止,宏还没有进行复杂的代码生成。实际上,第一个宏只是重复一个块多次——这已经可以通过迭代器(doc.rust-lang.org/std/iter/fn.repeat_with.html)来实现。第二个宏创建了一个函数,但这同样可以通过闭包语法(doc.rust-lang.org/stable/rust-by-example/fn/closures.html)来实现。那么,让我们添加一些更有趣的东西,比如具有Default实现的enum
macro_rules! default_enum {
    ($name: ident, $($variant: ident => $val:expr),+) => {
        #[derive(Eq, PartialEq, Clone, Debug)]
        pub enum $name {
            Invalid,
            $($variant = $val),+
        }

        impl Default for $name {
            fn default() -> Self { $name::Invalid }
        }
    };
}
  1. 没有什么可以未经测试,所以这里有一个测试来查看它是否按预期工作。将此添加到前面的测试中:
    #[test]
    fn test_default_enum() {
        default_enum!(Colors, Red => 0xFF0000, Blue => 0x0000FF);
        let color: Colors = Default::default();
        assert_eq!(color, Colors::Invalid);
        assert_eq!(Colors::Red as i32, 0xFF0000);
        assert_eq!(Colors::Blue as i32, 0x0000FF);     
    }
  1. 如果我们在编写测试,我们也想看到它们在运行:
$ cargo test
Compiling custom-designators v0.1.0 (Rust-Cookbook/Chapter06/code-generation)
warning: function is never used: `fail`
  --> src/lib.rs:20:9
   |
20 | fn $i () $body
   | ^^^^^^^^^^^^^^
...
56 | make_fn!(fail, {assert!(false)});
   | --------------------------------- in this macro invocation
   |
   = note: #[warn(dead_code)] on by default

    Finished dev [unoptimized + debuginfo] target(s) in 0.30s
     Running target/debug/deps/custom_designators-ebc95554afc8c09a

running 4 tests
test tests::test_default_enum ... ok
test tests::test_make_fn ... ok
test tests::test_failing_make_fn ... ok
test tests::test_n_times ... ok

test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

   Doc-tests custom-designators

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

为了理解代码,让我们谈谈幕后发生的事情。

它是如何工作的...

由于编译器在真正编译之前执行宏,我们可以生成最终程序中将出现的代码,但实际上是通过宏调用创建的。这使得我们可以减少样板代码,强制执行默认值(例如实现某些特性、添加元数据等),或者为我们的 crate 的用户提供一个更友好的接口。

步骤 2中,我们创建了一个简单的宏来重复一个块(这些花括号{ }及其内容被称为)多次——使用for循环。在步骤 4中创建的测试显示了它的操作方式和它能做什么——它执行得就像我们在测试中直接写一个for循环一样。

步骤 3创建了一个更有趣的东西:一个函数。结合步骤 4中的测试,我们可以看到宏是如何操作的,并注意以下内容:

  • 提供的块是惰性评估的(只有当函数被调用时测试才会失败)。

  • 如果函数没有被调用,编译器会抱怨未使用的函数。

  • 以这种方式创建参数化函数会导致编译器错误(它找不到值)。

步骤 5创建了一个更复杂的宏,能够创建一个完整的enum。它允许用户定义变体(甚至可以使用箭头=>记法),并添加一个默认值。让我们看看宏期望的模式:($name: ident, $($variant: ident => $val:expr),+)。第一个参数($name)是一个标识符,它命名了某个东西(也就是说,标识符的规则被强制执行)。第二个参数是一个重复参数,它至少需要出现一次(由+表示),但如果提供了更多实例,它们必须用逗号分隔。这些重复的期望模式如下:标识符,=>,和表达式(例如,bla => 1 + 1Five => 5,或blog => 0x5ff,等等)。

宏内部的内容是enum的经典定义,重复参数的插入频率与输入中出现的频率相同。然后,我们可以在enum上添加 derive 属性,并实现std::default::Default特质(doc.rust-lang.org/std/default/trait.Default.html),以便在需要默认值时提供一些合理的东西。

让我们更多地了解宏和参数,然后继续下一个菜谱。

宏重载

方法/函数重载是一种技术,它允许有重复的方法/函数名,但每个都有不同的参数。许多静态类型语言,如 C#和 Java,支持这种技术,以便提供多种调用方法,而无需每次都想出一个新名字(或使用泛型)。然而,Rust 不支持函数重载——这是有充分理由的(blog.rust-lang.org/2015/05/11/traits.html)。Rust 支持重载的地方是宏模式:你可以创建一个宏,并拥有多个只有输入参数不同的分支。

如何做到这一点...

让我们通过几个简单的步骤实现一些重载宏:

  1. 在终端(或在 Windows 上的 PowerShell)中运行cargo new macro-overloading --lib,然后用 Visual Studio Code 打开该目录。

  2. src/lib.rs中,在mod tests模块声明之前添加以下内容:

#![allow(unused_macros)]

macro_rules! print_debug {
    (stdout, $($o:expr),*) => {
        $(print!("{:?}", $o));*;
        println!();
    };
    (error, $($o:expr),*) => {
        $(eprint!("{:?}", $o));*;
        eprintln!();
    };
    ($stream:expr, $($o:expr),*) => {
        $(let _ = write!($stream, "{:?}", $o));*;
        let _ = writeln!($stream);
    }
}
  1. 让我们看看我们如何应用这个宏。在tests模块内部,让我们通过添加以下单元测试来看看打印宏是否将字符串序列化到流中(替换现有的it_works测试):
    use std::io::Write;

    #[test]
    fn test_printer() {
        print_debug!(error, "hello std err");
        print_debug!(stdout, "hello std out");
        let mut v = vec![];
        print_debug!(&mut v, "a");
        assert_eq!(v, vec![34, 97, 34, 10]);

    }
  1. 为了便于未来的测试,我们应该在tests模块中添加另一个宏。这次,这个宏是对一个具有静态返回值的函数进行模拟(martinfowler.com/articles/mocksArentStubs.html)。在之前的测试之后编写这个:
    macro_rules! mock {
        ($type: ty, $name: ident, $ret_val: ty, $val: block) => {
            pub trait $name {
                fn $name(&self) -> $ret_val;
            }

            impl $name for $type {
                fn $name(&self) -> $ret_val $val
            }
        };
        ($name: ident, $($variant: ident => $type:ty),+) => {
            #[derive(PartialEq, Clone, Debug)]
            struct $name {
                $(pub $variant: $type),+
            }
        };
    }
  1. 然后,我们应该也测试一下mock!宏。在下面添加另一个测试:
    mock!(String, hello, &'static str, { "Hi!" });
    mock!(HelloWorld, greeting => String, when => u64);

    #[test]
    fn test_mock() {
        let mystr = "Hello".to_owned();
        assert_eq!(mystr.hello(), "Hi!");

        let g = HelloWorld { greeting: "Hello World".to_owned(), 
        when: 1560887098 };

        assert_eq!(g.greeting, "Hello World");
        assert_eq!(g.when, 1560887098);
    }
  1. 作为最后一步,我们运行cargo test来查看它是否工作。然而,这次,我们将--nocapture传递给测试工具,以查看打印了什么(对于步骤 3):
$ cargo test -- --nocapture
Compiling macro-overloading v0.1.0 (Rust-Cookbook/Chapter06
 /macro-overloading)
warning: trait `hello` should have an upper camel case name
  --> src/lib.rs:53:19
   |
53 | mock!(String, hello, &'static str, { "Hi!" });
   | ^^^^^ help: convert the identifier to upper camel case: `Hello`
   |
   = note: #[warn(non_camel_case_types)] on by default

    Finished dev [unoptimized + debuginfo] target(s) in 0.56s
     Running target/debug/deps/macro_overloading-bd8b38e609ddd77c

running 2 tests
"hello std err"
"hello std out"
test tests::test_mock ... ok
test tests::test_printer ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

   Doc-tests macro-overloading

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

现在,让我们幕后了解代码,以更好地理解它。

它是如何工作的...

重载是一个非常简单的概念——实际上简单到难以找到可用的示例,这些示例不能通过足够复杂的函数来完成。然而,在这个菜谱中,我们认为我们已经找到了一些有用的东西。

步骤 2中,我们创建了一个围绕println!和类似函数的包装器,允许通过仅用一个标记来写入标准流,如标准输出和标准错误,或者任何其他任意流类型。此外,这个实现还有一些有趣的细节:

  • 每次调用print!都会跟一个;——除了最后一个,这就是为什么在*后面还有一个额外的;的原因。

  • 该模式允许传入任意数量的表达式。

这个宏可以用来避免重复 println!("{:?}", "hello") 只是为了快速查看变量的当前值。此外,它还便于将输出重定向到标准错误。

步骤 3 中,我们为这个宏调用创建了一个测试。在快速检查中,我们向 errorstdoutvec!(这就是为什么我们导入 std::io::Write)打印。在那里,我们可以看到末尾的新行,并且它被写为字符串(数字是字节)。在任何调用中,它都找到了所需的宏模式并插入了其内容。

步骤 4 创建了一个宏,用于模拟结构体或整个结构体上的函数。这对于隔离测试,真正只测试目标实现而不冒通过尝试实现辅助函数而添加更多错误的风险非常有用。在这种情况下,宏的分支很容易区分。第一个分支创建了一个函数的模拟实现,并匹配它所需的参数:它附加到的类型、函数的标识符、返回类型以及返回该类型的代码块。第二个分支创建了一个结构体,因此只需要一个标识符来命名结构体及其属性及其数据类型。

模拟——或者创建一个模拟对象——是一种测试技术,它允许创建浅层结构来模拟所需的行为。这对于无法以其他方式实现的事物(外部硬件、第三方网络服务等等)或复杂的内部系统(数据库连接和逻辑)非常有用。

接下来,我们必须测试这些结果,这在 步骤 5 中完成。在那里,我们调用 mock! 宏并定义其行为,以及一个测试来证明它的工作。我们在 步骤 6 中运行测试,没有捕获控制台输出:它工作得很好!

我们确信宏的重载很容易学习。现在让我们继续下一个菜谱。

使用重复来指定参数范围

Rust 的 println! 宏有一个奇特的特点:你可以传入的参数数量没有上限。由于常规 Rust 不支持任意参数范围,它必须是一个宏特性——但是哪个呢?在这个菜谱中,找出如何处理和实现宏的参数范围。

如何做到...

经过这几步,你就会知道如何使用参数范围。

  1. 在终端(或在 Windows 上的 PowerShell)中运行 cargo new parameter-ranges --lib 并使用 Visual Studio Code 打开该目录。

  2. src/lib.rs 中,添加以下代码以在 vec! 风格中初始化一个集合:

#![allow(unused_macros)]

macro_rules! set {
 ( $( $item:expr ),* ) => {
        {
            let mut s = HashSet::new();
            $(
                s.insert($item);
            )*
            s
        }
    };
}
  1. 接下来,我们将添加一个简单的宏来创建一个 DTO——数据传输对象:
macro_rules! dto {
    ($name: ident, $($variant: ident => $type:ty),+) => {
        #[derive(PartialEq, Clone, Debug)]
        pub struct $name {
            $(pub $variant: $type),+
        }

        impl $name {
            pub fn new($($variant:$type),+) -> Self {
                $name {
                    $($variant: $variant),+
                }
             }
        }
    };
}
  1. 这也需要进行测试,所以让我们添加一个测试来使用新的宏创建一个集合:
#[cfg(test)]
mod tests {
    use std::collections::HashSet;

    #[test]
    fn test_set() {
        let actual = set!("a", "b", "c", "a");
        let mut desired = HashSet::new();
        desired.insert("a");
        desired.insert("b");
        desired.insert("c");
        assert_eq!(actual, desired); 
    }
}
  1. 在测试集合初始化后,让我们也测试创建一个 DTO。在之前的测试下添加以下内容:
    #[test]
    fn test_dto() {
        dto!(Sensordata, value => f32, timestamp => u64);
        let s = Sensordata::new(1.23f32, 123456);
        assert_eq!(s.value, 1.23f32); 
        assert_eq!(s.timestamp, 123456); 
    }
  1. 作为最后一步,我们也运行 cargo test 来展示它的工作情况:
$ cargo test
 Compiling parameter-ranges v0.1.0 (Rust-Cookbook/Chapter06
  /parameter-ranges)
  Finished dev [unoptimized + debuginfo] target(s) in 1.30s
  Running target/debug/deps/parameter_ranges-7dfb9718c7ca3bc4

running 2 tests
test tests::test_dto ... ok
test tests::test_set ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

   Doc-tests parameter-ranges

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

现在,让我们深入了解代码以更好地理解它。

它是如何工作的...

Rust 的宏系统中参数范围的工作方式有点像正则表达式。语法有几个部分:$()表示重复,其分隔符后面的字符(,;=>是允许的),最后是重复期望的限定符(+*——就像正则表达式一样,一个是或多个,另一个是零个或多个)。

第二步展示了类似于vec!的集合初始化宏的实现。在那里,我们期望一个表达式来填充std::collections::HashSet,并在子块中返回结果。这是必要的,以便允许诸如变量赋值(在 transcriber 块中不允许直接进行)之类的操作,但不要阻碍传递给宏的参数的展开。与声明类似,展开使用$()区域进行,但与分隔符不同,重复限定符直接跟随。其中包含的内容将根据参数的数量运行多次。

第二个宏在第三步中定义,并且更加复杂。名称dto!(数据传输对象)表示一个业务对象,如数据容器,它仅用于在程序内部传递数据,而不会被发送到程序外部。由于这些 DTO 包含大量的样板代码,它们可以像键值存储一样初始化。通过在参数范围指定中使用=>符号,我们可以创建用于在struct及其构造函数中创建属性的标识符/类型对。请注意,分隔属性的逗号位于+符号之前,因此它也会被重复。

第四步展示了调用在第二步中设计的宏以填充集合并测试是否正确填充。同样,第五步展示了创建和实例化 DTO 实例(称为Sensordata的结构体)以及测试以确认属性按预期创建。最后一步通过运行测试来确认这一点。

我们已经成功学习了如何使用 repeat 来处理参数范围。现在让我们继续下一个菜谱。

不要重复自己

在之前的菜谱中,我们使用宏来生成几乎任意的代码,从而减少了需要编写的代码量。让我们更深入地探讨这个话题,因为这是一个不仅能够减少错误而且能够实现代码一致性的好方法。每个人都应该做的重复性任务之一是测试(尤其是如果它是面向公众的 API),如果我们复制粘贴这些测试,我们就会暴露自己于错误之中。相反,让我们看看我们如何使用宏生成样板代码来停止重复。

如何做...

使用宏进行自动化测试只需几个步骤:

  1. 在终端(或在 Windows 上的 PowerShell)中运行cargo new dry-macros --lib,然后用 Visual Studio Code 打开该目录。

  2. src/lib.rs中,我们想要创建一个辅助宏并导入所需的库:

use std::ops::{Add, Mul, Sub};

macro_rules! assert_equal_len {
    // The `tt` (token tree) designator is used for
    // operators and tokens.
    ($a:ident, $b: ident, $func:ident) => (
        assert_eq!($a.len(), $b.len(),
                "{:?}: dimension mismatch: {:?} {:?}",
                stringify!($func),
                ($a.len(),),
                ($b.len(),));
    )
}
  1. 接下来,我们定义一个宏来自动实现一个操作符。让我们在 assert_equal_len 宏下面添加这个宏:
macro_rules! op {
    ($func:ident, $bound:ident, $method:ident) => (
        pub fn $func<T: $bound<T, Output=T> + Copy>(xs: &mut 
        Vec<T>, ys: &Vec<T>) {
            assert_equal_len!(xs, ys, $func);

            for (x, y) in xs.iter_mut().zip(ys.iter()) {
                *x = $bound::$method(*x, *y);
            }
        }
    )
}
  1. 现在,让我们调用这个宏并实际生成实现:
op!(add_assign, Add, add);
op!(mul_assign, Mul, mul);
op!(sub_assign, Sub, sub);
  1. 在这些函数到位之后,我们现在也可以生成测试用例了!用以下内容替换 test 模块:
#[cfg(test)]
mod test {

    use std::iter;
    macro_rules! test {
        ($func: ident, $x:expr, $y:expr, $z:expr) => {
            #[test]
            fn $func() {
                for size in 0usize..10 {
                    let mut x: Vec<_> = 
                    iter::repeat($x).take(size).collect();
                    let y: Vec<_> = 
                    iter::repeat($y).take(size).collect();
                    let z: Vec<_> = 
                    iter::repeat($z).take(size).collect();

                    super::$func(&mut x, &y);

                    assert_eq!(x, z);
                }
            }
        }
    }

    // Test `add_assign`, `mul_assign` and `sub_assign`
    test!(add_assign, 1u32, 2u32, 3u32);
    test!(mul_assign, 2u32, 3u32, 6u32);
    test!(sub_assign, 3u32, 2u32, 1u32);
}
  1. 作为最后一步,让我们通过运行 cargo test 来查看生成的代码的实际效果,以查看(积极的)测试结果:
$ cargo test
 Compiling dry-macros v0.1.0 (Rust-Cookbook/Chapter06/dry-macros)
  Finished dev [unoptimized + debuginfo] target(s) in 0.64s
  Running target/debug/deps/dry_macros-bed1682b386b41c3

running 3 tests
test test::add_assign ... ok
test test::mul_assign ... ok
test test::sub_assign ... ok

test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

   Doc-tests dry-macros

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

为了更好地理解代码,让我们分析这些步骤。

它是如何工作的...

虽然设计模式、if-else 构造和一般而言的 API 设计有助于代码重用,但当需要将标记(例如,某些名称)硬编码以保持松散耦合时,就会变得复杂。Rust 的宏可以帮助解决这个问题。作为一个例子,我们为这些函数生成函数和测试,以避免在文件之间复制和粘贴测试代码。

第 3 步 中,我们声明了一个宏,它围绕比较两个序列的长度并提供更好的错误信息。第 4 步 立即使用这个宏并创建一个具有提供名称的函数,但前提是多个输入 Vec 实例的长度匹配。

第 5 步 中,我们调用宏并提供它们所需的输入:一个名称(用于函数)和泛型绑定的类型。这使用提供的接口创建了函数,而不需要复制和粘贴代码。

第 6 步 通过声明 test 模块、一个用于生成测试的宏以及最终创建测试代码的调用来创建相关的测试。这允许你在编译之前即时生成测试,这显著减少了静态、重复代码的数量——这一直是测试中的一个问题。最后一步显示了这些测试实际上是在运行 cargo test 时创建和执行的。

第七章:将 Rust 与其他语言集成

在当今的应用程序领域,集成是关键。无论您是缓慢地现代化旧服务还是从头开始使用新语言,现在的程序很少是独立运行的。对于许多公司来说,Rust 仍然是一种异类技术——不幸的是,它通常不被考虑在典型的 SDK 中。这就是为什么 Rust 强调了与其他技术的良好协作,这也是为什么社区可以(并将)通过包装其他(本地)库来提供大量的驱动程序、服务集成等。

作为开发者,我们很少有机会从头开始(绿色项目),因此在本章中,我们将介绍 Rust 语言与其他语言和技术集成的各种方式。我们将重点关注撰写时的最流行和最有用的集成,但这些基础知识也应该为更大的互操作性提供基础,因为许多语言都提供了用于本地二进制的接口(例如 .NET (docs.microsoft.com/en-us/cpp/dotnet/calling-native-functions-from-managed-code?view=vs-2019)或 Java 的 JNI (docs.oracle.com/javase/7/docs/technotes/guides/jni/spec/intro.html#wp9502))。有了这些知识,添加 Rust 来增强您的 Web 应用程序应该和创建制造商代码的传感器驱动程序包装一样简单。

我们认为,良好的集成对于语言的成功至关重要。在本章中,我们将介绍以下食谱:

  • 包含旧 C 代码

  • 使用 FFI 从 Node.js 调用 Rust

  • 在浏览器中运行 Rust

  • 使用 Rust 和 Python

  • 为旧应用生成绑定

包含旧 C 代码

由于其多功能性、速度和简单性,C 仍然是最受欢迎的编程语言之一 (www.tiobe.com/tiobe-index/)。因此,许多应用程序——无论是旧应用还是新应用——都是使用 C 开发的,既有其优点也有其缺点。Rust 与 C 共享一个领域——系统编程,这就是为什么越来越多的公司用 Rust 替换其 C 代码,因为 Rust 作为一种现代编程语言具有安全性和吸引力。然而,变化并不总是以一次性的大爆炸(www.linkedin.com/pulse/big-bang-vs-iterative-dilemma-martijn-endenburg/)形式发生;通常是一个更加渐进的(迭代)方法,包括替换组件和替换应用程序的部分。

这里,我们使用 C 代码作为类比,因为它流行且广为人知。然而,这些技术适用于任何(本地)编译的技术,例如 Go、C++,甚至是 Fortran。所以,让我们开始吧!

准备工作

在这个菜谱中,我们不仅构建 Rust,还构建 C。为此,我们需要一个 C 编译器工具链——gcc (gcc.gnu.org/) 和 makewww.gnu.org/software/make/manual/make.html,这是一个基于规则的脚本引擎,用于执行构建。

检查工具是否已安装,方法是在终端窗口中打开(注意版本应该相似——至少是主要版本——以避免任何意外差异):

$ cc --version
cc (GCC) 9.1.1 20190503 (Red Hat 9.1.1-1)
Copyright (C) 2019 Free Software Foundation, Inc.
This is free software; see the source for copying conditions. There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
$ make --version
GNU Make 4.2.1
Built for x86_64-redhat-linux-gnu
Copyright (C) 1988-2016 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.

如果这些命令在你的机器上不可用,请检查如何在你的操作系统上安装它们。在任何Linux/Unix环境中(包括 WSL——Windows 子系统对于 Linux:docs.microsoft.com/en-us/windows/wsl/install-win10),它们可能需要通过默认的软件仓库安装gccmake。在某些发行版中(例如 Ubuntu),如build_essentialpackages.ubuntu.com/xenial/build-essential)这样的捆绑包也提供了这些工具。

在 macOS 上,检查 Homebrew,它提供类似的经验并提供gcc以及makebrew.sh/

Windows 用户可以选择使用 WSL(然后遵循 Linux 说明)或使用 Cygwin (www.cygwin.com)来找到gcc-coremake。我们建议将这些工具(默认为C:\cygwin64\bin)添加到 Windows 的PATH变量中(www.java.com/en/download/help/path.xml),这样常规的(PowerShell)终端就可以访问 Cygwin 的可执行文件。

准备就绪后,使用相同的 shell 创建一个legacy-c-code目录,并在其中运行cargo new rust-digest --lib,并在旁边创建一个名为C的目录:

$ ls legacy-c-code
C/ rust-digest/

C目录内,创建一个src文件夹以反映 Rust 项目。在 Visual Studio Code 或你的 Rust 开发环境中打开整个legacy-c-code

如何做到这一点...

按照以下步骤操作,以便能够在你的项目中包含旧代码:

  1. 让我们先实现 Rust 库。打开rust-digest/Cargo.toml来调整配置以输出动态库(*.so*.dll):
[lib]
name = "digest"
crate-type = ["cdylib"]
  1. 还需要添加的是依赖项。在这里,我们使用libc中的类型和一个名为ring的加密库,所以让我们添加这些依赖项:
[dependencies]
libc = "0.2"
ring = "0.14"
  1. 接下来,我们可以处理代码本身。让我们打开rust-digest/src/lib.rs并替换默认代码为以下片段。这个片段创建了一个从外部世界接受字符串(一个可变字符指针)并返回输入字符串摘要的接口:
use std::ffi::{CStr, CString};
use std::os::raw::{c_char, c_void};

use ring::digest;

extern "C" {
    fn pre_digest() -> c_void;
}

#[no_mangle]
pub extern "C" fn digest(data: *mut c_char) -> *mut c_char {
    unsafe {
        pre_digest();

        let data = CStr::from_ptr(data);
        let signature = digest::digest(&digest::SHA256, 
        data.to_bytes());

        let hex_digest = signature
            .as_ref()
            .iter()
            .map(|b| format!("{:X}", b))
            .collect::<String>();

        CString::new(hex_digest).unwrap().into_raw()
    }
}
  1. 现在应该是一个完整的 Rust 库了。让我们在rust-digest中运行cargo build来检查输出:
$ cd rust-digest; cargo build
   Compiling libc v0.2.58
   Compiling cc v1.0.37
   Compiling lazy_static v1.3.0
   Compiling untrusted v0.6.2
   Compiling spin v0.5.0
   Compiling ring v0.14.6
   Compiling rust-digest v0.1.0 (Rust-Cookbook/Chapter07/legacy-c-
    code/rust-digest)
    Finished dev [unoptimized + debuginfo] target(s) in 7.53s
  1. 应该有一个libdigest.so库(或在 Windows 上是digest.dll):
$  ls -al rust-digest/target/debug/
total 3756
drwxr-xr-x. 8 cm cm 4096 Jun 23 20:17 ./
drwxr-xr-x. 4 cm cm 4096 Jun 23 20:17 ../
drwxr-xr-x. 6 cm cm 4096 Jun 23 20:17 build/
-rw-r--r--. 1 cm cm 0 Jun 23 20:17 .cargo-lock
drwxr-xr-x. 2 cm cm 4096 Jun 23 20:17 deps/
drwxr-xr-x. 2 cm cm 4096 Jun 23 20:17 examples/
drwxr-xr-x. 13 cm cm 4096 Jun 23 20:17 .fingerprint/
drwxr-xr-x. 3 cm cm 4096 Jun 23 20:17 incremental/
-rw-r--r--. 1 cm cm 186 Jun 23 20:17 libdigest.d
-rwxr-xr-x. 2 cm cm 3807256 Jun 23 20:17 libdigest.so*
drwxr-xr-x. 2 cm cm 4096 Jun 23 20:17 native/
  1. 然而,我们也应该进行一次发布构建。在 rust-digest 目录下运行 cargo build --release
$ cargo build --release
   Compiling rust-digest v0.1.0 (Rust-Cookbook/Chapter07/legacy-c-
   code/rust-digest)
   Finished release [optimized] target(s) in 0.42s
  1. 要实现项目的 C 部分,创建并打开 C/src/main.c 以添加以下代码:
#include <stdio.h>

// A function with that name is expected to be linked to the project
extern char* digest(char *str);

// This function is exported under the name pre_digest
extern void pre_digest() {
    printf("pre_digest called\n");
}

int main() {
    char *result = digest("Hello World");
    printf("SHA digest of \"Hello World\": %s", result);
    return 0;
}
  1. make 是构建 C 代码的传统(也是最简单)的工具。make 通过运行一个名为 Makefile 的文件来遵循它定义的规则。创建并打开 C/Makefile 并添加以下内容:
# Include the Rust library
LIBS := -ldigest -L../rust-digest/target/release

ifeq ($(shell uname),Darwin)
    LDFLAGS := -Wl,-dead_strip $(LIBS)
else
    LDFLAGS := -Wl,--gc-sections $(LIBS)
endif

all: target/main

target:
  @mkdir -p $@

target/main: target/main.o 
  @echo "Linking ... "
  $(CC) -o $@ $^ $(LDFLAGS)

target/main.o: src/main.c | target
  @echo "Compiling ..."
  $(CC) -o $@ -c $<

clean:
  @echo "Removing target/"
  @rm -rf target
  1. 如果一切就绪,我们应该能够切换到 C 目录并运行 make all
$ make all
Compiling ...
cc -o target/main.o -c src/main.c
Linking ... 
cc -o target/main target/main.o -Wl,--gc-sections -ldigest -L../rust-digest/target/release

之后,会有一个 C/target 目录,其中包含两个文件:main.omain(Windows 上的 main.exe)。

  1. 为了能够运行可执行文件(.o 文件只是目标文件;不能运行),我们还需要告诉它我们的动态库所在的位置。为此,通常使用 LD_LIBRARY_PATH 环境变量。在 bash 中打开并运行以下命令,在 legacy-c-code 目录内临时覆盖变量为适当的路径:
$ cd rust-digest/target/release
$ LD_LIBRARY_PATH=$(pwd)
$ echo $LD_LIBRARY_PATH 
/tmp/Rust-Cookbook/Chapter07/legacy-c-code/rust-digest/target/release
  1. 现在是时候最终运行 C 程序并检查是否一切正常了。切换到 C/target 目录并运行以下命令:
$ ./main
pre_digest called
SHA digest of "Hello World": A591A6D4BF420404A11733CFB7B190D62C65BFBCDA32B57B277D9AD9F146E 

完成这些后,让我们看看背后的情况,了解它是如何完成的。

它是如何工作的...

用 Rust 替换旧 C 代码是一个逐步的过程,通常是为了提高开发者的生产力、安全性和潜在的创新。这已经在无数的应用程序中完成(例如,在微软的公共云服务 Azure 中:azure.microsoft.com/en-gb/),并且需要两种技术无缝协作。

多亏了基于 LLVM 编译器的 Rust 编译器,编译输出的是原生代码(例如,Linux 上的 ELF:zh.wikipedia.org/wiki/Executable_and_Linkable_Format),这使得它对 C/C++ 来说特别易于访问。在这个菜谱中,我们将探讨如何使用 Rust 中构建的动态库将这两个输出链接成一个单独的程序。

在 Rust 中创建动态库(*.so/*.dll)的先决条件出奇地简单:步骤 1 展示了 Cargo.toml 中对 rustc 输出所需格式的必要更改。还有其他格式,所以如果你在寻找特定内容,请查看 nomicon (doc.rust-lang.org/cargo/reference/manifest.html#building-dynamic-or-static-libraries) 和文档 doc.rust-lang.org/cargo/reference/manifest.html#building-dynamic-or-static-libraries

步骤 3 展示了创建传入字符串的 SHA256 (www.thesslstore.com/blog/difference-sha-1-sha-2-sha-256-hash-algorithms/) 摘要的代码,但只有在它调用了一个简单的回调函数 pre_digest() 来展示双向绑定之后。这里有几个需要注意的地方:

  • 从链接的库中导入函数是通过使用 extern "C" {} 声明来完成的("C" 实际上并不必要)。在声明了这样的结构之后,它可以像任何其他函数一样使用。

  • 为了导出与 ELF 格式兼容的函数,需要 #[no_mangle] 属性,因为编译器运行一个名称混淆方案,会更改函数名称。由于编译器没有通用的方案,no_mangle 确保它保持原样。要了解更多关于名称混淆的信息,请查看这个链接:doc.rust-lang.org/book/ch19-01-unsafe-rust.html#using-extern-functions-to-call-external-code

  • digest 函数内部使用 unsafe 是出于几个原因。首先,调用外部函数始终是不安全的(pre_digest())。其次,从 char 指针到 CStr 的转换是不安全的,需要作用域。

注意: ring (github.com/briansmith/ring) 是几个加密算法的纯 Rust 实现,因此没有 OpenSSL (www.openssl.org/) 或 LibreSSL (www.libressl.org) 的要求。由于这两个库都是基于各自的本地库构建的,它们总是给即使是经验丰富的 Rust 开发者带来头疼。然而,作为纯 Rust 实现,ring 避免了任何链接/编译问题。

步骤 4步骤 6,我们正在像以前一样构建 Rust 库,但结果是一个 .so.dll 文件,而不是 .rlib 文件。

步骤 7 展示了导入和调用动态链接函数所需的 C 代码。C 使用接口的 extern 声明来保持这一点的简单性,这使得你可以直接调用该函数。回调也是通过 extern 声明实现的,并且简单地打印出它已被调用。

Rust 的构建系统在到达 第 8 步 时真正大放异彩,在这一步中创建了 Makefile 的规则。创建规则很简单,但正如许多 C 语言开发者所知,它留下了很多复杂性的空间。然而,在我们的配方中,我们希望让它易于理解。每个规则都包含一个目标(例如,all)及其依赖项(例如,target/main),以及要运行的 bash 命令体(例如,@mkdir -p $@)。

这些依赖项可以是文件(例如 target/main.otarget/main)或其他规则。如果是文件,检查它们最后修改的时间,如果有变化,它们将运行规则及其依赖项。生成的依赖项树会自动解析。尽管这个高度有用、已有 30 年历史的工具可能非常吸引人,但仍有书籍专门讲述它是如何工作的。这当然是一次深入历史和 Linux 传统的探索。在这里查看一个简短的教程:www.cs.colby.edu/maxwell/courses/tutorials/maketutor/ 或者直接查看 make 手册 (www.gnu.org/software/make/manual/make.html)。

步骤 9C 代码编译成可执行文件,并将其链接到 libdigest.so,这是 rustc 创建的。我们还在 Makefile 中的 LDFLAGS 变量中指定了正确的链接路径。

只有在 步骤 10 中,才会明显看出静态库与动态库的不同。后者必须在运行时可用,因为它没有嵌入到可执行文件中,并且依赖于其他机制来查找。其中一种机制是 LD_LIBRARY_PATH 环境变量,它指向包含 libXXXX.so 文件的目录,以便程序通过名称查找其依赖项。对于这个菜谱,我们正在替换原始值,使其指向你的 rust-digest/target/release 目录所在位置($(pwd) 输出当前目录);然而,这仅适用于当前的终端会话,所以每次你关闭并重新打开窗口时,设置都会消失。如果路径设置不正确或目录/文件缺失,执行 main 将会得到类似以下的内容:

$ ./main
./main: error while loading shared libraries: libdigest.so: cannot open shared object file: No such file or directory

步骤 11 展示了正确的输出,因为已经调用了 pre_digest 函数,并且我们能够为 "Hello World"(不带引号)创建正确的 SHA256 摘要。

现在我们对将 Rust 集成到 C 类型应用程序中有了更多了解,我们可以继续到下一个菜谱。

使用 FFI 从 Node.js 调用 Rust

JavaScript 是一种学习曲线平缓且灵活的语言,这导致了在原始浏览器动画之外的各种领域的令人印象深刻的采用率。Node.js (nodejs.org/en/) 是基于 Google 的 V8 JavaScript 引擎的运行时,它允许 JavaScript 代码在操作系统(无需浏览器)上直接运行,包括访问各种低级 API,以启用物联网类型的应用程序和 Web 服务,甚至创建和显示虚拟/增强现实环境 (github.com/microsoft/HoloJS)。所有这一切都是因为 Node 运行时提供了对宿主操作系统的本地库的访问。让我们看看我们如何创建一个 Rust 库,以便从 JavaScript 中调用它。

准备工作

由于我们正在使用 Node.js,请按照官方网站上的说明安装npm和 Node.js 运行时:nodejs.org/en/download/。一旦准备就绪,您应该能够从终端(PowerShell 或 bash)运行以下命令:

$ node --version
v11.15.0
$ npm --version
6.7.0

实际版本可能更高。我们使用的 node 依赖项还需要 C/C++工具,以及安装 Python 2。按照 GitHub 上您操作系统的说明:github.com/nodejs/node-gyp#installation。然后,让我们设置一个类似于上一个菜谱的文件夹结构:

  1. 创建一个node-js-rust文件夹。

  2. 创建一个名为node的子文件夹,进入它,并运行npm init以生成package.json——基本上是 Node 的Cargo.toml

  3. node文件夹内,添加一个名为src的目录。

  4. node文件夹的同一级别创建一个新的 Rust 项目,命名为cargo new rust-digest --lib(或重用上一个菜谱中的项目)。

最后,您应该有一个如下设置的目录结构:

$ tree node-js-rust/
node-js-rust/
├── node
│   ├── package.json
│   └── src
│       └── index.js
└── rust-digest
    ├── Cargo.toml
    └── src
        └── lib.rs
4 directories, 4 files

在 Visual Studio Code 中打开整个目录以进行代码编写。

如何做到这一点...

让我们从上一个菜谱中的 SHA256 库重复几个步骤:

  1. 首先,让我们处理 Rust 部分。打开rust-digest/Cargo.toml以添加ring,这是用于哈希部分的依赖项,以及用于交叉编译的crate-type配置:
[lib]
name = "digest"
crate-type = ["cdylib"]

[dependencies]
libc = "0.2"
ring = "0.14"
  1. 接下来,让我们看看 Rust 代码。就像本章中的其他菜谱一样,我们正在创建一种通过 Rust 快速生成 SHA 摘要的方法,以便在 Node.js 中使用:
use std::ffi::{CStr, CString};
use std::os::raw::c_char;

use ring::digest;

#[no_mangle]
pub extern "C" fn digest(data: *mut c_char) -> *mut c_char {
    unsafe {

        let data = CStr::from_ptr(data);
        let signature = digest::digest(&digest::SHA256, 
        data.to_bytes());

        let hex_digest = signature
            .as_ref()
            .iter()
            .map(|b| format!("{:X}", b))
            .collect::<String>();

        CString::new(hex_digest).unwrap().into_raw()
    }
}

// No tests :(
  1. cargo build现在创建了一个本地库。您可以在 Rust 项目目录中的target/debug下找到这个库:
$ cargo build
   Compiling libc v0.2.58
   Compiling cc v1.0.37
   Compiling untrusted v0.6.2
   Compiling spin v0.5.0
   Compiling lazy_static v1.3.0
   Compiling ring v0.14.6
   Compiling rust-digest v0.1.0 (Rust-Cookbook/Chapter07/node-js-
    rust/rust-digest)
    Finished dev [unoptimized + debuginfo] target(s) in 5.88s
$ ls rust-digest/target/debug/
build/ deps/ examples/ incremental/ libdigest.d libdigest.so* native/

  1. 如果 JavaScript 部分调用了 Rust 二进制文件,需要做出一些声明以便使函数被识别。我们通过打印出对 Rust 库的调用结果来完成代码。将以下内容添加到node/src/index.js中:
const ffi = require('ffi');
const ref = require('ref');

const libPath = '../rust-digest/target/debug/libdigest';

const libDigest = ffi.Library(libPath, {
  'digest': [ "string", ["string"]],
});

const { digest } = libDigest;
console.log('Hello World SHA256', digest("Hello World"));
  1. require语句已经暗示了依赖关系,所以让我们也将其整合。打开node/package.json以添加以下内容:
{
  [...]
  "dependencies": {
    "ffi": "².3.0"
  }
}
  1. 一切准备就绪后,我们现在可以从node目录中运行npm install命令:
$ npm install

> ref@1.3.5 install Rust-Cookbook/Chapter07/node-js-rust/node/node_modules/ref
> node-gyp rebuild

make: Entering directory 'Rust-Cookbook/Chapter07/node-js-rust/node/node_modules/ref/build'
  CXX(target) Release/obj.target/binding/src/binding.o
In file included from ../src/binding.cc:7:
../../nan/nan.h: In function ‘void Nan::AsyncQueueWorker(Nan::AsyncWorker*)’:
../../nan/nan.h:2298:62: warning: cast between incompatible function types from ‘void (*)(uv_work_t*)’ {aka ‘void (*)(uv_work_s*)’} to ‘uv_after_work_cb’ {aka ‘void (*)(uv_work_s*, int)’} [-Wcast-function-type]
 2298 | , reinterpret_cast<uv_after_work_cb>(AsyncExecuteComplete)
[...]
  COPY Release/ffi_bindings.node
make: Leaving directory 'Rust-Cookbook/Chapter07/node-js-rust/node/node_modules/ffi/build'
npm WARN node@1.0.0 No description
npm WARN node@1.0.0 No repository field.

added 7 packages from 12 contributors and audited 18 packages in 4.596s
found 0 vulnerabilities

  1. 依赖项安装完成后,node应用程序就准备好运行了。运行node src/index.js来执行 JavaScript 文件:
$ node src/index.js
Hello World SHA256 A591A6D4BF420404A11733CFB7B190D62C65BFBCDA32B57B277D9AD9F146E

完成工作后,让我们看看为什么以及它是如何结合在一起的。

它是如何工作的...

Node.js,作为 JavaScript 的本地运行环境,提供了易于访问的本地库,这些库可以使用 Rust 构建。为了做到这一点,需要node-ffi(github.com/node-ffi/node-ffi)包来动态查找和加载所需的库。然而,首先,我们从 Rust 代码和项目开始:步骤 13 展示如何构建一个本地动态库,这是我们之前在本章的包含遗留 C 代码配方中,在它是如何工作的...部分讨论过的。

步骤 4 中,我们创建 JavaScript 代码。得益于 JavaScript 的动态特性,可以使用字符串和对象来定义函数签名,实际的调用看起来就像一个可以从模块中导入的常规函数。FFI 库还消除了数据类型转换,跨技术边界的调用是无缝的。另一个重要的注意事项是,使用node-ffi(github.com/node-ffi/node-ffi)时,需要实际的模块路径,这使得处理不同的工件变得容易得多(与在 C/C++互操作中使用环境变量相比)。

步骤 5步骤 6 中,我们使用著名的npm包管理器(www.npmjs.com/)添加和安装 Node.js 所需的依赖项,其中node-ffi(github.com/node-ffi/node-ffi)需要一些编译工具才能正常工作。

最后一步展示了程序是如何执行并创建与本章其他配方中相同的哈希值的。

我们已经学会了如何使用 FFI 从 Node.js 调用 Rust,现在让我们继续到下一个配方。

在浏览器中运行 Rust

在浏览器中运行 Rust 可能看起来与使用 Node.js 的 Rust 二进制文件类似。然而,现代浏览器环境要复杂得多。沙箱限制了本地资源的访问(这是好事!),浏览器提供少量脚本语言在网站内运行。虽然最成功的语言是 JavaScript,但它由于技术的脚本性质,在动画方面存在许多缺点。除此之外,还有垃圾回收、存在许多缺陷的类型系统以及缺乏一致的编程范式——所有这些都导致了实时应用程序(如游戏)不可预测和性能不佳。

然而,这些问题正在得到解决。一种名为 WebAssembly 的技术被引入,以便能够分发二进制文件(作为网络汇编语言),这些二进制文件可以在一个专门的执行环境中运行——就像 JavaScript 一样。事实上,JavaScript 能够无缝地与这些二进制文件交互,类似于 Node.js 应用程序中的本地库,这大大加快了速度。得益于 Rust 的 LLVM 基础,它可以编译成 WebAssembly,并且,凭借其内存管理,它对于运行这些实时应用是一个很好的选择。尽管这项技术仍处于起步阶段,但让我们看看它是如何工作的!

准备工作

对于这个项目,我们正在设置一个名为browser-rust的目录,其中包含一个web目录和一个名为rust-digestcargo库项目(cargo new rust-digest --lib)。对于编译,我们需要一个额外的编译目标wasm23-unknown-unknown,可以通过rustup安装。在终端中运行以下命令来安装目标:

$ rustup target add wasm32-unknown-unknown
info: downloading component 'rust-std' for 'wasm32-unknown-unknown'
 10.9 MiB / 10.9 MiB (100 %) 5.3 MiB/s in 2s ETA: 0s
info: installing component 'rust-std' for 'wasm32-unknown-unknown'

使用cargo安装一个名为wasm-bindgen-cli的工具(cargo install wasm-bindgen-cli),并在当前控制台窗口中调用wasm-bindgen来检查它是否工作。

web目录内,我们创建一个名为index.html的文件,该文件将托管并展示我们的 Rust 输出。为了能够渲染索引文件,还需要一个网络服务器。以下是一些选项:

  • Python(3.x)的标准库包含一个http.server模块,可以像这样调用:python3 -m http.server 8080

  • JavaScript 和 Node.js 的爱好者可以使用http-serverwww.npmjs.com/package/http-server),通过npmwww.npmjs.com/package/http-server)安装。

  • Ruby 的最新版本也自带了一个网络服务器:ruby -run -ehttpd . -p8080

  • 在 Windows 上,您可以使用 IIS Express(www.npmjs.com/package/http-server),也可以通过命令行:C:\> "C:\Program Files (x86)\IIS Express\iisexpress.exe" /path:C:\Rust-Cookbook\Chapter07\browser-rust\web /port:8080

任何提供静态文件的网络服务器都可以,并且它应该能够适当地提供文件。您最终应该得到如下目录结构:

$ tree browser-rust/
browser-rust/
├── rust-digest
│   ├── Cargo.lock
│   ├── Cargo.toml
│   ├── src
│       └── lib.rs
└── web
    └── index.html

3 directories, 4 files

您的项目现在应该已经设置好并准备就绪。让我们看看如何让 Rust 在浏览器中运行。

如何做到这一点...

下面是如何仅用几个步骤编写低延迟网络应用的步骤:

  1. 让我们从实现 Rust 部分开始。我们再次创建一个哈希库,因此我们首先创建 API 的基本功能。打开rust-digest/src/lib.rs,在测试上方插入以下内容:
use sha2::{Sha256, Digest};
use wasm_bindgen::prelude::*;

fn hex_digest(data: &str) -> String {
    let mut hasher = Sha256::new();
    hasher.input(data.as_bytes());
    let signature = hasher.result();
    signature
        .as_ref()
        .iter()
        .map(|b| format!("{:X}", b))
        .collect::<String>()
}
  1. 让我们将hex_digest()函数绑定到一个公开的 API,我们可以从模块外部调用它。这使得我们可以使用 WASM 类型调用代码,甚至可以自动生成大部分这些绑定。在上述代码下方添加一些内容:
#[wasm_bindgen]
pub extern "C" fn digest(data: String) -> String {
    hex_digest(&data)
}

#[wasm_bindgen]
pub extern "C" fn digest_attach(data: String, elem_id: String) -> Result<(), JsValue> {
    web_sys::window().map_or(Err("No window found".into()), |win| {
        if let Some(doc) = win.document() {
            doc.get_element_by_id(&elem_id).map_or(Err(format!("No 
            element with id {} found", elem_id).into()), |val|{
                let signature = hex_digest(&data);
                val.set_inner_html(&signature);
                Ok(())
            })
        }
        else {
            Err("No document found".into())
        }
    })
}
// No tests :( 
  1. 有时候,在模块实例化后有一个回调会很有用,所以让我们也添加一个:

#[wasm_bindgen(start)]
pub fn start() -> Result<(), JsValue> {
    // This function is getting called when initializing the WASM 
    // module
    Ok(())
}
  1. 我们使用了两个需要额外依赖的导入:wasm-bindgensha2(作为ring::digest的 Web 兼容版本)。此外,我们假装是一个用于外部链接的本地库,因此库的类型和名称应该进行调整。修改rust-digest/Cargo.toml以包含这些更改:
[lib]
name = "digest"
crate-type = ["cdylib"]

[dependencies]
sha2 = "0.8"
wasm-bindgen = "0.2.48"

[dependencies.web-sys]
version = "0.3.25"
features = [
  'Document',
  'Element',
  'HtmlElement',
  'Node',
  'Window',
]
  1. 现在,让我们编译库并检查输出。运行cargo build --target wasm32-unknown-unknown
$ cargo build --target wasm32-unknown-unknown
   Compiling proc-macro2 v0.4.30
   [...]
   Compiling js-sys v0.3.24
   Compiling rust-digest v0.1.0 (Rust-Cookbook/Chapter07/browser-
    rust/rust-digest)
    Finished dev [unoptimized + debuginfo] target(s) in 54.49s
$ ls target/wasm32-unknown-unknown/debug/
build/ deps/ digest.d digest.wasm* examples/ incremental/ native/
  1. 生成的digest.wasm文件是我们想在 Web 应用程序中使用 JavaScript 包含的文件。虽然可以直接这样做(developer.mozilla.org/en-US/docs/WebAssembly/Using_the_JavaScript_API),但数据类型转换可能会相当繁琐。这就是为什么有一个 CLI 工具来帮助。在browser-rust/rust-digest目录下运行wasm-bindgen target/wasm32-unknown-unknown/debug/digest.wasm --out-dir ../web/ --web以生成 Web 浏览器所需的必要 JavaScript 绑定:
$ wasm-bindgen target/wasm32-unknown-unknown/debug/digest.wasm --out-dir ../web/ --web
$ ls ../web/
digest_bg.d.ts digest_bg.wasm digest.d.ts digest.js index.html
  1. 这些绑定需要包含在我们的web/index.html文件中(目前是空的):
<!DOCTYPE html>
<html>
    <head>
        <meta content="text/html;charset=utf-8" http-equiv="Content-
         Type"/>
        <script type="module">
            import init, { digest, digest_attach } from 
             './digest.js';        
            async function run() {
                await init();
                const result = digest("Hello World");
                console.log(`Hello World SHA256 = ${result}`);
                digest_attach("Hello World", "sha_out")
            }
            run();
        </script>
    </head>
    <body>
        <h1>Hello World in SHA256 <span id="sha_out"></span></h1>
    </body>
</html>
  1. 保存并退出index.html文件,然后在网站目录内启动你之前准备好的网络服务器:
py -m http.server 8080
Serving HTTP on 0.0.0.0 port 8080 (http://0.0.0.0:8080/) ...
  1. 在浏览器中访问http://localhost:8080(确保允许服务器通过防火墙)并检查你的输出是否与以下内容匹配:

完成工作后,让我们看看为什么以及如何将所有这些内容结合起来。

它是如何工作的...

现代浏览器提供了一个 JavaScript 引擎和 Web 汇编虚拟机(webassembly.org/)。有了这个功能,Web 应用程序可以运行二进制代码,这些代码在其自己的运行时环境中安全执行,并且可以从外部轻松访问。主要好处包括以下内容:

  • 由于二进制编译,应用程序体积更小

  • 更快的端到端执行时间(没有编译步骤)

  • 没有垃圾回收——WASM 虚拟机是一个栈机器

此外,WASM 可以被转换成基于文本的格式,以便进行视觉检查和手动优化。Rust 是少数几种甚至可以编译成这些格式(文本和二进制)的语言之一,这主要归功于 LLVM 和 Rust 对内存管理的方法。

步骤 1步骤 2步骤 3中,我们创建 Rust 模块来完成这项工作。注意extern函数上方的#[wasm_bindgen]属性,它允许宏预处理器获取函数的输入和输出类型,并从该接口定义生成绑定。还有一个特殊的#[wasm_bindgen(start)]宏位于其中一个函数上方,它指定初始化函数在模块实例化时运行。这两个函数和digest_attach()都返回Result类型,这允许使用?运算符和通用的 rust 错误处理。

digest_attach()是特殊的(与digest()相比),因为它直接从 WASM 模块访问 DOM(www.w3.org/TR/WD-DOM/introduction.html),这是由web_sys包提供的。所有这些宏和函数都在wasm_bindgen::prelude::*语句中导入。

步骤 4相应地调整Cargo.toml,以确保编译工作顺利进行。注意,这里出现的任何错误(例如,针对不同的目标,例如默认目标)都表明 crate 与 WASM 不兼容。只有在步骤 5中,我们才执行针对 wasm32 目标的编译,这将生成 WASM 二进制文件。步骤 6使用wasm-bindgen CLI 运行绑定生成器,这将生成一些文件以简化集成。在这种情况下,它们是以下文件:

  • digest_bg.d.ts: 导出 WASM 函数的 TypeScript(www.typescriptlang.org/)定义

  • digest_bg.wasm: WASM 文件本身

  • digest.d.ts: 集成文件的 TypeScript 定义

  • digest.js: 加载和将导出的 WASM 函数转换为常规 JavaScript 调用的 JavaScript 实现

工具包括更多选项和示例(rustwasm.github.io/docs/wasm-bindgen/examples/without-a-bundler.html),用于其他集成,因此,请查看文档以获取详细信息(rustwasm.github.io/docs/wasm-bindgen/)。

并不是每个 crate 都可以编译为wasm32-unknown-unknown,特别是如果它们使用硬件访问或操作系统功能的话。一些 crate 实现了兼容层,通常指定为cargo功能。

第 7 步展示了如何将生成的 WASM 绑定包含到常规 HTML 页面中。在 ES6 语法(es6-features.org/#Constants)(可能对一些人来说不熟悉)之外,Rust 代码被整洁地封装在 JavaScript 函数中,因此不需要额外的转换。对于那些对它是如何工作的感兴趣的人,可以查看 digest.js 文件,该文件相当易于阅读,但展示了转换数据所涉及到的复杂性。就是这样——最后一步仅展示了如何提供文件,并且托管实际上是可以工作的。

现在我们已经学会了如何在浏览器中运行 Rust,让我们继续学习下一道食谱!

使用 Rust 和 Python

Python 已经成为许多应用的标配语言,从网络到数据科学。然而,Python 本身是一种解释型语言,并且以其相当慢而闻名——这就是为什么它与更快 的 C 代码集成得很好的原因。许多受欢迎的库都是用 C/C++ 和 Cython (cython.org/) 实现的,以实现所需性能(例如,numpypandaskeras 和 PyTorch 主要为原生代码)。由于 Rust 也能生成原生二进制文件,让我们看看如何为 Python 编写 Rust 模块。

准备工作

我们将再次创建 SHA256 摘要,并使用本章中每个食谱中相同的文件夹结构。创建一个 python-rust 目录,并使用 cargo new rust-digest --lib 在其中初始化一个新的 Rust 项目。

对于项目的 Python 部分,按照网站上的说明安装 Python(3.6/3.7)。然后,在 python-rust/python 内创建以下文件夹结构和文件(目前为空即可):

$ tree python
python
├── setup.py
└── src
    └── digest.py

1 directory, 2 files

在 VS Code 中打开整个 python-rust 文件夹,你就可以开始了。

如何操作...

Python 是一种非常适合集成的优秀语言——只需几个步骤就能了解其中的原因:

  1. 打开 rust-digest/src/lib.rs 以开始编写 Rust 代码。让我们添加所需的 use 语句以支持 FFI 和 ring,并声明一个要导出的 digest() 函数。请注意,此函数与本章中大多数其他食谱中的函数相同:
use std::ffi::{CStr, CString};
use std::os::raw::c_char;

use ring::digest;

#[no_mangle]
pub extern "C" fn digest(data: *mut c_char) -> *mut c_char {
    unsafe {

        let data = CStr::from_ptr(data);
        let signature = digest::digest(&digest::SHA256, 
         data.to_bytes());

        let hex_digest = signature
            .as_ref()
            .iter()
            .map(|b| format!("{:X}", b))
            .collect::<String>();

        CString::new(hex_digest).unwrap().into_raw()
    }
}

// No tests :( 
  1. 由于我们使用 ring 和第三方依赖项来创建哈希,因此让我们在 rust-digest/Cargo.toml 中声明它们(以及库类型):
[lib]
name = "digest"
crate-type = ["cdylib"]

[dependencies]
libc = "0.2"
ring = "0.14"
  1. 现在,让我们构建库以获得 libdigest.so(或 digest.dlllibdigest.dylib)。在 rust-digest 中运行 cargo build
$ cargo build
    Updating crates.io index
   Compiling cc v1.0.37
   Compiling libc v0.2.58
   Compiling untrusted v0.6.2
   Compiling spin v0.5.0
   Compiling lazy_static v1.3.0
   Compiling ring v0.14.6
   Compiling rust-digest v0.1.0 (Rust-Cookbook/Chapter07/python-
    rust/rust-digest)
    Finished dev [unoptimized + debuginfo] target(s) in 8.29s
$ ls target/debug/
build/ deps/ examples/ incremental/ libdigest.d libdigest.so* native/
  1. 为了在 Python 中加载此库,我们需要编写一些代码。打开 python/src/digest.py 并添加以下内容:
from ctypes import cdll, c_char_p
from sys import platform

def build_lib_name(name):
    prefix = "lib"
    ext = "so"

    if platform == 'darwin':
        ext = 'dylib'
    elif platform == 'win32':
        prefix = ""
        ext = 'dll'

    return "{prefix}{name}.{ext}".format(prefix=prefix, name=name, ext=ext)

def main():
    lib = cdll.LoadLibrary(build_lib_name("digest"))
    lib.digest.restype = c_char_p
    print("SHA256 of Hello World =", lib.digest(b"Hello World"))

if __name__ == "__main__":
    main()
  1. 虽然可以通过调用 python3 digest.py 来运行此文件,但这并不是大型项目的样子。Python 的 setuptools (setuptools.readthedocs.io/en/latest/) 提供了一种更好的结构化方法来创建甚至为当前操作系统安装可运行的脚本。常见的入口点是 setup.py 脚本,它声明了元数据以及依赖项和入口点。使用以下内容创建 python/setup.py
#!/usr/bin/env python
# -*- coding: utf-8 -*-

# Courtesy of https://github.com/kennethreitz/setup.py 

from setuptools import find_packages, setup, Command

# Package meta-data.
NAME = 'digest'
DESCRIPTION = 'A simple Python package that loads and executes a Rust function.'
URL = 'https://blog.x5ff.xyz'
AUTHOR = 'Claus Matzinger'
REQUIRES_PYTHON = '>=3.7.0'
VERSION = '0.1.0'
LICENSE = 'MIT'

文件继续将声明的变量输入到 setup() 方法中,该方法生成所需的元数据:

setup(
    # Meta stuff
    name=NAME,
    version=VERSION,
    description=DESCRIPTION,
    long_description=DESCRIPTION,
    long_description_content_type='text/markdown',
    # ---
    package_dir={'':'src'}, # Declare src as root folder
    packages=find_packages(exclude=["tests", "*.tests", "*.tests.*", 
     "tests.*"]), # Auto discover any Python packages
    python_requires=REQUIRES_PYTHON,
    # Scripts that will be generated invoke this method
    entry_points={
        'setuptools.installation': ['eggsecutable=digest:main'],
    },
    include_package_data=True,
    license=LICENSE,
    classifiers=[
        # Trove classifiers
        # Full list: https://pypi.python.org/pypi?
         %3Aaction=list_classifiers
        'License :: OSI Approved :: MIT License',
        'Programming Language :: Python',
        'Programming Language :: Python :: 3',
        'Programming Language :: Python :: 3.7',
        'Programming Language :: Python :: Implementation :: 
         CPython',
        'Programming Language :: Python :: Implementation :: PyPy'
    ],
)
  1. 步骤 6步骤 7步骤 8 仅适用于 Linux/macOS(或 WSL)。Windows 用户,请继续进行 步骤 9。Python 的独立模块被称为 eggs,因此让我们创建一个并运行 python3 setup.py bdist_egg
$ python3 setup.py bdist_egg
running bdist_egg
running egg_info
writing src/digest.egg-info/PKG-INFO
writing dependency_links to src/digest.egg-info/dependency_links.txt
writing entry points to src/digest.egg-info/entry_points.txt
writing top-level names to src/digest.egg-info/top_level.txt
reading manifest file 'src/digest.egg-info/SOURCES.txt'
writing manifest file 'src/digest.egg-info/SOURCES.txt'
installing library code to build/bdist.linux-x86_64/egg
running install_lib
warning: install_lib: 'build/lib' does not exist -- no Python modules to install

creating build/bdist.linux-x86_64/egg
creating build/bdist.linux-x86_64/egg/EGG-INFO
copying src/digest.egg-info/PKG-INFO -> build/bdist.linux-x86_64/egg/EGG-INFO
copying src/digest.egg-info/SOURCES.txt -> build/bdist.linux-x86_64/egg/EGG-INFO
copying src/digest.egg-info/dependency_links.txt -> build/bdist.linux-x86_64/egg/EGG-INFO
copying src/digest.egg-info/entry_points.txt -> build/bdist.linux-x86_64/egg/EGG-INFO
copying src/digest.egg-info/top_level.txt -> build/bdist.linux-x86_64/egg/EGG-INFO
zip_safe flag not set; analyzing archive contents...
creating 'dist/digest-0.1.0-py3.7.egg' and adding 'build/bdist.linux-x86_64/egg' to it
removing 'build/bdist.linux-x86_64/egg' (and everything under it)
  1. 这在 python/dist 中创建了一个 .egg 文件,它被构建为在调用时运行前面脚本中的 main() 函数。在 Mac/Linux 上,您必须运行 chmod +x python/dist/digest-0.1.0-py3.7.egg 才能运行它。让我们看看立即运行它会发生什么:
$ cd python/dist
$  ./digest-0.1.0-py3.7.egg
Traceback (most recent call last):
  File "<string>", line 1, in <module>
  File "Rust-Cookbook/Chapter07/python-rust/python/src/digest.py", line 17, in main
    lib = cdll.LoadLibrary(build_lib_name("digest"))
  File "/usr/lib64/python3.7/ctypes/__init__.py", line 429, in LoadLibrary
    return self._dlltype(name)
  File "/usr/lib64/python3.7/ctypes/__init__.py", line 351, in __init__
    self._handle = _dlopen(self._name, mode)
OSError: libdigest.so: cannot open shared object file: No such file or directory
  1. 对了,这个库仅是动态链接的!我们必须将我们的二进制文件指向库或将其移动到库可以找到它的位置。在 Mac/Linux 上,可以通过设置 LD_LIBRARY_PATH 环境变量到 Rust 构建输出所在的位置来实现。结果是调用编译后的 Rust 代码以获取字符串的 SHA256 摘要的 Python 程序:
$ LD_LIBRARY_PATH=$(pwd)/../../rust-digest/target/debug/ ./digest-0.1.0-py3.7.egg
SHA256 of Hello World = b'A591A6D4BF420404A11733CFB7B190D62C65BFBCDA32B57B277D9AD9F146E'
  1. 对于 Windows 用户,执行过程要简单一些。首先,使库对 Python 可用,然后直接运行脚本。在 python 目录中运行以下命令,以在 Python 中使用 Rust 生成 SHA256 摘要:
$ cp ../rust-digest/target/debug/digest.dll .
$ python.exe src/digest.py
SHA256 of Hello World = b'A591A6D4BF420404A11733CFB7B190D62C65BFBCDA32B57B277D9AD9F146E'

让我们看看它是如何以及为什么这样工作的。

它是如何工作的...

使用 Rust 来增强 Python 的功能是获得两者最佳结合的好方法:Python 以易于学习和使用而闻名;Rust 快速且安全(并且不容易在运行时失败)。

步骤 1步骤 3 中,我们再次创建了一个动态本地库,该库从提供的字符串参数创建 SHA256 哈希。在 Cargo.tomllib.rs 中的所需更改与创建用于 C/C++ 互操作性的库相同:#[no_mangle]。本章前面关于 包含旧版 C 代码 的配方详细描述了内部工作原理,因此请务必阅读那里的 它是如何工作的... 部分。

cdylib 库类型描述了 C 的动态库,其他类型适用于不同的目的。查看 nomicon (doc.rust-lang.org/nomicon/ffi.html) 和文档 (doc.rust-lang.org/cargo/reference/manifest.html#building-dynamic-or-static-libraries) 以获取更多详细信息。

我们的 Python 代码使用标准库中的ctypes (docs.python.org/3/library/ctypes.html)部分来加载 Rust 模块。在步骤 4中,我们展示了 Python 的动态调用能力可以无缝实例化和集成该类型。然而,数据类型需要相应地解释,这就是为什么返回类型被设置为字符指针,输入为字节类型,以实现与其他章节中其他菜谱相同的结果。由于平台和编程语言使用它们自己的方式将字节编码为字符串(UTF-8、UTF-16 等),我们必须将一个字节字面量(在 C 中转换为char*)传递给函数。

步骤 5步骤 6中,我们使用 Python 的 setuptools 创建一个.egg文件,这是 Python 模块的发行格式。在这种情况下,我们甚至创建了一个 eggsecutable (setuptools.readthedocs.io/en/latest/setuptools.html#eggsecutable-scripts),这使得通过执行.egg文件来运行函数成为可能。如步骤 7所示,仅仅运行它是不够的,因为我们还需要让库对执行环境可见。在步骤 8中,我们正在做这件事并检查结果(关于LD_LIBRARY_PATH的更多内容请参阅本章早期包含遗留 C 代码菜谱的如何做...部分)。

步骤 9中,我们在 Windows 上运行脚本。Windows 使用不同的机制来加载动态库,因此LD_LIBRARY_PATH方法不起作用。此外,Python eggsecutables 仅在 Linux/macOS 上可用,setuptools 提供了即时的部署机制,但不适用于本地开发(无需进一步安装/复杂性)。这就是为什么在 Windows 上,我们直接执行脚本——这也是if __name__ == "__main__"的原因。

现在我们已经学会了如何在 Python 中成功运行 Rust,让我们继续下一个菜谱。

为遗留应用程序生成绑定

正如我们在第一个菜谱中看到的,Rust 与其他本地语言之间的互操作性需要在一侧或两侧存在特定的结构来正确声明内存布局。使用rust-bindgen可以轻松自动化这项任务。让我们看看这如何使与本地代码的集成变得更加容易。

准备工作

就像本章的第一个菜谱包含遗留的 C 代码一样,这个菜谱有以下先决条件:

这些工具在任何 Linux/Unix 环境中都可用(在 Windows 上,您可以使用 WSL (docs.microsoft.com/en-us/windows/wsl/install-win10)),可能需要额外的安装。检查您的发行版的软件包仓库以获取列表中的软件包。

在 macOS 上,检查 Homebrew,这是一个 Mac 的包管理器:brew.sh/

Windows 用户最好使用 WSL 并遵循 Linux 指令,或者安装 MinGW (www.mingw.org/),以努力为 Windows 提供 GNU Linux 工具。

通过打开终端窗口并发出以下命令来检查工具是否正确安装:

$ cc --version
cc (GCC) 9.1.1 20190503 (Red Hat 9.1.1-1)
Copyright (C) 2019 Free Software Foundation, Inc.
This is free software; see the source for copying conditions. There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
$  ar --version
GNU ar version 2.31.1-29.fc30
Copyright (C) 2018 Free Software Foundation, Inc.
This program is free software; you may redistribute it under the terms of
the GNU General Public License version 3 or (at your option) any later version.
This program has absolutely no warranty.
$ git --version
git version 2.21.0

版本应该相似(至少是主要版本),以避免任何意外的差异。

一旦准备就绪,使用相同的 shell 创建一个 bindgen 目录,并在其中运行 cargo new rust-tinyexpr 并使用 git clone https://github.com/codeplea/tinyexpr 克隆 TinyExpr GitHub 仓库 (github.com/codeplea/tinyexpr)。

如何做到这一点...

让我们在几个步骤中创建一些绑定:

  1. 打开 rust-tinyexpr/Cargo.toml 并添加适当的构建依赖项:
[build-dependencies]
bindgen = "0.49"
  1. 创建一个新的 rust-tinyexpr/build.rs 文件,并添加以下内容以创建 C 库的定制构建:
use std::env;
use std::env::var;
use std::path::PathBuf;
const HEADER_FILE_NAME: &'static str = "../tinyexpr/tinyexpr.h";

fn main() {
    let project_dir = var("CARGO_MANIFEST_DIR").unwrap();
    println!("cargo:rustc-link-search={}/../tinyexpr/", 
     project_dir);
    println!("cargo:rustc-link-lib=static=tinyexpr");

    if cfg!(target_env = "msvc") {
        println!("cargo:rustc-link-
         lib=static=legacy_stdio_definitions");
    }

    let bindings = bindgen::Builder::default()
        .header(HEADER_FILE_NAME)
        .generate()
        .expect("Error generating bindings");

    let out_path = PathBuf::from(env::var("OUT_DIR").unwrap());
    bindings
        .write_to_file(out_path.join("bindings.rs"))
        .expect("Error writing bindings");
}
  1. 现在是实际的 Rust 代码。打开 rust-tinyexpr/src/main.rs 并添加一些代码以包含由 rust-bindgen 生成的文件(该文件由 build.rs 调用):
#![allow(non_upper_case_globals)]
#![allow(non_camel_case_types)]
#![allow(non_snake_case)]
use std::ffi::CString;

include!(concat!(env!("OUT_DIR"), "/bindings.rs"));

fn main() {
    let expr = "sqrt(5²+7²+11²+(8-2)²)".to_owned();
    let result = unsafe {
        te_interp(CString::new(expr.clone()).unwrap().into_raw(), 0 as *mut i32)
    };
    println!("{} = {}", expr, result);
}
  1. 如果我们现在在 rust-tinyexpr 内运行 cargo build,我们将看到以下结果:
$  cargo build
   Compiling libc v0.2.58
   Compiling cc v1.0.37
   Compiling autocfg v0.1.4
   Compiling memchr v2.2.0
   Compiling version_check v0.1.5
   Compiling rustc-demangle v0.1.15
   Compiling proc-macro2 v0.4.30
   Compiling bitflags v1.1.0
   Compiling ucd-util v0.1.3
   Compiling byteorder v1.3.2
   Compiling lazy_static v1.3.0
   Compiling regex v1.1.7
   Compiling glob v0.2.11
   Compiling cfg-if v0.1.9
   Compiling quick-error v1.2.2
   Compiling utf8-ranges v1.0.3
   Compiling unicode-xid v0.1.0
   Compiling unicode-width v0.1.5
   Compiling vec_map v0.8.1
   Compiling ansi_term v0.11.0
   Compiling termcolor v1.0.5
   Compiling strsim v0.8.0
   Compiling bindgen v0.49.3
   Compiling peeking_take_while v0.1.2
   Compiling shlex v0.1.1
   Compiling backtrace v0.3.31
   Compiling nom v4.2.3
   Compiling regex-syntax v0.6.7
   Compiling thread_local v0.3.6
   Compiling log v0.4.6
   Compiling humantime v1.2.0
   Compiling textwrap v0.11.0
   Compiling backtrace-sys v0.1.28
   Compiling libloading v0.5.1
   Compiling clang-sys v0.28.0
   Compiling atty v0.2.11
   Compiling aho-corasick v0.7.3
   Compiling fxhash v0.2.1
   Compiling clap v2.33.0
   Compiling quote v0.6.12
   Compiling cexpr v0.3.5
   Compiling failure v0.1.5
   Compiling which v2.0.1
   Compiling env_logger v0.6.1
   Compiling rust-tinyexpr v0.1.0 (Rust-Cookbook/Chapter07/bindgen/rust-tinyexpr)
error: linking with `cc` failed: exit code: 1
[...]
"-Wl,-Bdynamic" "-ldl" "-lrt" "-lpthread" "-lgcc_s" "-lc" "-lm" "-lrt" "-lpthread" "-lutil" "-lutil"
  = note: /usr/bin/ld: cannot find -ltinyexpr
          collect2: error: ld returned 1 exit status

error: aborting due to previous error

error: Could not compile `rust-tinyexpr`.

To learn more, run the command again with --verbose.
  1. 这是一个链接错误——链接器找不到库!这是因为我们实际上从未创建过它。切换到 tinyexpr 目录并运行以下命令在 Linux/macOS 上从源代码创建一个静态库:
$ cc -c -ansi -Wall -Wshadow -O2 tinyexpr.c -o tinyexpr.o -fPIC
$ ar rcs libtinyexpr.a tinyexpr.o

对于 Windows,过程略有不同:

$ gcc -c -ansi -Wall -Wshadow -O2 tinyexpr.c -o tinyexpr.lib -fPIC
  1. 回到 rust-tinyexpr 目录,我们可以再次运行 cargo build
$ cargo build
   Compiling rust-tinyexpr v0.1.0 (Rust-Cookbook/Chapter07/bindgen/rust-tinyexpr)
    Finished dev [unoptimized + debuginfo] target(s) in 0.31s
  1. 作为额外的好处,bindgen 还会生成测试,因此我们可以运行 cargo test 来确保二进制布局得到验证。然后,让我们使用 Rust 的 TinyExpr C 库解析一个表达式:
$ cargo test
   Compiling rust-tinyexpr v0.1.0 (Rust-
    Cookbook/Chapter07/bindgen/rust-tinyexpr)
    Finished dev [unoptimized + debuginfo] target(s) in 0.36s
     Running target/debug/deps/rust_tinyexpr-fbf606d893dc44c6

running 3 tests
test bindgen_test_layout_te_expr ... ok
test bindgen_test_layout_te_expr__bindgen_ty_1 ... ok
test bindgen_test_layout_te_variable ... ok

test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
$ cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.04s
     Running `target/debug/rust-tinyexpr`
    sqrt(5²+7²+11²+(8-2)²) = 15.198684153570664

让我们看看我们是如何得到这个结果的。

它是如何工作的...

bindgen 是一个惊人的工具,它可以从 C/C++ 头文件中实时生成 Rust 代码。在 步骤 1步骤 2 中,我们添加了依赖项并使用 bindgen API 加载头文件,生成并输出一个名为 bindings.rs 的文件到临时的 build 目录。OUT_DIR 变量仅在 cargo 的构建环境中可用,并指向包含多个构建工件目录。

此外,链接器需要知道已创建的库,以便可以将其链接。这是通过以特殊语法将所需参数打印到标准输出中完成的。在这种情况下,我们将库的名称(link-lib)和它应该检查的目录(link-search)传递给 rustc 链接器。cargo 可以使用这些输出做更多的事情。有关更多信息,请查看文档 (doc.rust-lang.org/cargo/reference/build-scripts.html)。

微软的 msvc 编译器通过移除标准 printf 函数,以更安全的变体取而代之,引入了一个破坏性的变化。为了最小化跨平台编译的复杂性,在 步骤 4 中引入了一个简单的编译器切换,以恢复 printf 旧版本。

步骤 3 通过包含文件创建 Rust 代码来调用链接的函数(同时忽略关于命名的几个警告)。虽然 bindgen 去除了接口的生成,但仍然需要使用与 C 兼容的类型来传递参数。这就是为什么在调用函数时我们必须创建指针的原因。

如果我们在这一步之后立即编译 Rust 代码,我们最终会得到一个巨大的错误信息,如 步骤 4 所示。为了解决这个问题,我们在 步骤 5 中从 C 代码创建静态库,使用一些 ccgcc C 编译器)的编译器标志,例如 -fPIC(代表 位置无关代码),它会在文件中创建一致的位置,因此可以作为库使用。cc 调用的输出是一个目标文件,然后使用 ar 工具将其存档到静态库中。

如果库正确可用,我们可以使用 cargo buildcargo run——如最后两步所示——来执行代码。

现在我们已经知道了如何将 Rust 与其他语言集成,让我们继续进入下一章,深入探讨另一个主题。

第八章:网络安全编程

自从 Ruby 编程语言的流行框架 Rails 以来,创建后端网络服务似乎成了动态类型语言的领域。这一趋势随着 Python 和 JavaScript 作为这些任务的主要语言的兴起而得到加强。毕竟,这些技术的本质使得创建这些服务特别快速,对服务的更改(例如,JSON 响应中的新字段)也简单易行。对我们许多人来说,回归静态类型进行网络服务感觉有些奇怪;毕竟,要启动“某种东西”需要更长的时间。

然而,这也有代价:如今,许多服务部署在云端,这意味着采用了按需付费的模式,并且(实际上)具有无限的扩展性。由于——最值得注意的是——Python 以其执行速度而闻名,我们现在可以在云服务提供商的账单上看到这种开销的成本。10% 的更快执行时间意味着在相同硬件和相同质量水平上服务 10% 的更多客户(例如,响应时间)。同样,较小的设备从较低的资源使用中受益,这转化为更快的软件,因此消耗更少的能源。Rust 作为一种系统编程语言,在设计时考虑了零开销,在速度或效率等方面与 C 语言非常接近。因此,对于大量使用的网络服务来说,在 Rust 中编写关键部分是合理的,Dropbox 就曾著名地采取这一举措来提高其服务质量并节省成本。

Rust 是一种非常适合网络的语言,在本章中,我们将探讨使用在许多不同应用程序中使用的框架来创建常规 RESTful API。您可以期待学习以下内容:

  • 设置网络服务器

  • 设计 RESTful API

  • 处理 JSON 有效载荷

  • 网络错误处理

  • 渲染 HTML 模板

  • 使用 ORM 将数据保存到数据库

  • 使用 ORM 运行高级查询

  • 网络上的身份验证

设置网络服务器

在过去几年中,网络服务器已经发生了变化。早期网络应用程序通常部署在某种类型的网络服务器应用程序之后,如 Apache Tomcat (tomcat.apache.org/)、IIS (www.iis.net/) 和 nginx (www.nginx.com/),但现在更常见的是将服务部分嵌入到应用程序中。这不仅对运维人员来说更容易,还允许开发者对整个应用程序有更紧密的控制。让我们看看如何开始并设置一个基本的静态网络服务器。

准备工作

让我们使用cargo new static-web设置一个 Rust 二进制项目。由于我们将在本地端口8081上提供服务,请确保该端口可访问。在新建的项目文件夹中,我们需要一个额外的文件夹static/,你可以在这里放置一个有趣的.jpg图像并提供服务。我们将假设这个图像叫做foxes.jpg

最后,使用 VS Code 打开整个目录。

如何做到这一点...

我们将在几个步骤中设置和运行我们自己的 Web 服务器:

  1. 首先打开src/main.rs,让我们添加一些代码。我们将从导入和简单的索引处理器开始,逐步向下到main函数:
#[macro_use]
extern crate actix_web;

use actix_web::{web, App, middleware, HttpServer, Responder, Result};
use std::{env};
use actix_files as fs;

fn index() -> Result<fs::NamedFile> {
    Ok(fs::NamedFile::open("static/index.html")?)
}
  1. 然而,这不会是唯一的请求处理器,所以让我们添加一些更多,看看请求处理是如何工作的:
fn path_parser(info: web::Path<(String, i32)>) -> impl Responder {
    format!("You tried to reach '{}/{}'", info.0, info.1)
}

fn rust_cookbook() -> impl Responder {
    format!("Welcome to the Rust Cookbook")
}

#[get("/foxes")]
fn foxes() -> Result<fs::NamedFile> {
    Ok(fs::NamedFile::open("static/foxes.jpg")?)
}
  1. 缺少的是main函数。这个main函数启动服务器并附加我们在上一步中创建的服务:
fn main() -> std::io::Result<()> {
    env::set_var("RUST_LOG", "actix_web=debug");
    env_logger::init();
    HttpServer::new(
        || App::new()
            .wrap(middleware::Logger::default())
            .service(foxes)
            .service(web::resource("/").to(index))
            .service(web::resource("/welcome").to(rust_cookbook))  
            .service(web::resource("/{path}/{id}").to(path_parser)))
        .bind("127.0.0.1:8081")?
        .run()
}
  1. 在第一个处理器中,我们提到了一个静态的index.html处理器,但我们还没有创建它。向一个新文件添加一个简单的marquee输出,并将其保存为static/index.html
<html>
    <body>
        <marquee><h1>Hello World</h1></marquee>
    </body>
</html>
  1. 我们还需要做的一件重要的事情是调整Cargo.toml。按照以下方式在Cargo.toml中声明依赖项:
[dependencies]
actix-web = "1"
env_logger = "0.6"
actix-files = "0"
  1. 使用终端执行cargo run并运行代码,然后在浏览器窗口中打开http://localhost:8081/http://localhost:8081/welcomehttp://localhost:8081/foxeshttp://localhost:8081/somethingarbitrary/10
$ cargo run
   Compiling autocfg v0.1.4
   Compiling semver-parser v0.7.0
   Compiling libc v0.2.59
[...]
   Compiling static-web v0.1.0 (Rust-Cookbook/Chapter08/static-web)
    Finished dev [unoptimized + debuginfo] target(s) in 1m 51s
     Running `target/debug/static-web`

这是http://localhost:8081的输出,由index函数处理:

图片

您也可以在http://localhost:8081/welcome调用welcome处理器:

图片

我们的静态处理器在http://localhost:8081/foxes返回柏林 Mozilla 办公室的照片:

图片

最后,我们添加了一个路径处理器,它解析路径中的字符串和整数,只是为了返回这些值:

图片

为了验证请求确实被我们的 Web 服务器处理,你应该查看在运行cargo run的终端中的日志输出中的单个请求:

[...]
    Finished dev [unoptimized + debuginfo] target(s) in 1m 51s
     Running `target/debug/static-web`
[2019-07-17T06:20:27Z INFO actix_web::middleware::logger] 127.0.0.1:35358 "GET / HTTP/1.1" 200 89 "-" "Mozilla/5.0 (X11; Fedora; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.86 Safari/537.36" 0.004907
[2019-07-17T06:21:58Z INFO actix_web::middleware::logger] 127.0.0.1:36154 "GET /welcome HTTP/1.1" 200 28 "-" "Mozilla/5.0 (X11; Fedora; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.86 Safari/537.36" 0.000844
^[[B[2019-07-17T06:22:34Z INFO actix_web::middleware::logger] 127.0.0.1:36476 "GET /somethingarbitrary/10 HTTP/1.1" 200 42 "-" "Mozilla/5.0 (X11; Fedora; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.86 Safari/537.36" 0.000804
[2019-07-17T06:24:22Z INFO actix_web::middleware::logger] 127.0.0.1:37424 "GET /foxes HTTP/1.1" 200 1416043 "-" "Mozilla/5.0 (X11; Fedora; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.86 Safari/537.36" 0.010263

现在,让我们幕后了解代码,以便更好地理解它。

它是如何工作的...

actix-web (actix.rs)是一个多功能的 Web 框架,它——在许多其他事情中——高效地服务静态文件。在本教程中,我们介绍了如何声明和注册请求处理器,以及一些提供响应的方法。在典型的 Web 框架中,有几种方法可以实现这些任务(声明处理器、创建响应),步骤 13展示了使用actix-web实现这两种方法的方式:

  • 使用属性(#[get("/foxes")]

  • 通过服务注册调用(.service(web::resource("/welcome").to(rust_cookbook))

无论我们以何种方式将处理器与路由关联,每个处理器都被包装在一个工厂中,该工厂按需创建新的处理器实例,这在编译器错误指向#[get(...)]属性而不是实际函数时非常明显。路径包括用于从路径传递数据到处理器函数的带类型的占位符——但更多内容将在下一个菜谱(设计 RESTful API)中介绍。

步骤 3中,我们还添加了记录中间件,记录用户代理、时间和 IP 地址,这样我们也可以在服务器端看到请求。所有这些操作都是使用actix-web方法链来完成的,它很好地结构化了调用。对run()的调用会阻塞应用程序并启动 actix 主循环。

步骤 6中的照片是在 2019 年 Rust All Hands 活动期间在 Mozilla 的柏林办公室拍摄的。是的,那些是 Firefox 枕头。

步骤 4添加了一个非常基本的index.html文件以供服务,而步骤 5则声明了Cargo.toml中的依赖项,就像我们之前做的那样。

在最后一步,我们运行代码并显示输出——在浏览器和日志中。

我们已经成功学习了设置 Web 服务器的基础知识。掌握了服务静态文件和图片以及解析路径参数的知识,我们可以继续学习下一个菜谱。

设计 RESTful API

几乎所有东西都依赖于 Web 资源——从通过 JavaScript 动态获取数据并在 HTML 中显示的单页应用程序,到特定服务的应用程序集成。Web 服务上的资源可以是任何东西,但它通常使用可读的 URI 来表示,这样信息就已经通过使用特定的路径来传输,然后只接受它需要处理的信息。这允许在内部和全局结构化代码,并利用所有HTTP方法创建一个开发者可以使用的表达性接口。RESTful API(www.codecademy.com/articles/what-is-rest)理想地捕捉了所有这些好处。

准备工作

使用cargo new api创建一个 Rust 二进制项目。由于我们将在本地端口8081上提供服务,请确保该端口可访问。在新建的项目文件夹中,我们需要一个额外的static/文件夹,你可以在这里放置一个有趣的.jpg图片以供服务。此外,请确保命令行上有一个像curl(curl.haxx.se/)这样的程序。或者,Postman(www.getpostman.com/)是一个具有图形界面的工具,可以完成相同的功能。

最后,使用 VS Code 打开整个目录。

如何操作...

让我们分几个步骤来构建一个 API:

  1. 打开src/main.rs以添加服务器和请求处理的主要代码。让我们一步一步来,从导入开始:
#[macro_use]
extern crate actix_web;

use actix_files as fs;
use actix_web::{ guard,
    http::header, http::Method, middleware, web, App, HttpRequest, HttpResponse, HttpServer,
    Responder, Result,
};
use std::env;
use std::path::PathBuf;
  1. 接下来,我们定义了一些处理器,它们接收请求并使用这些数据。将这些行添加到main.rs中:
#[get("by-id/{id}")]
fn bookmark_by_id(id: web::Path<(i32)>) -> impl Responder {
    format!("{{ \"id\": {}, \"url\": \"https://blog.x5ff.xyz\" }}", id)
}

fn echo_bookmark(req: HttpRequest) -> impl Responder {
    let id: i32 = req.match_info().query("id").parse().unwrap();
    format!("{:?}", id)
}

#[get("/captures/{tail:.*}")]
fn captures(req: HttpRequest) -> Result<fs::NamedFile> {
    let mut root = PathBuf::from("static/");
    let tail: PathBuf = req.match_info().query("tail").parse().unwrap();
    root.push(tail);

    Ok(fs::NamedFile::open(root)?)
}

#[get("from-bitly/{bitlyid}")]
fn bit_ly(req: HttpRequest) -> HttpResponse {
    let bitly_id = req.match_info().get("bitlyid").unwrap();
    let url = req.url_for("bitly", &[bitly_id]).unwrap();
    HttpResponse::Found()
        .header(header::LOCATION, url.into_string())
        .finish()
        .into_body()
}

#[get("/")]
fn bookmarks_index() -> impl Responder {
    format!("Welcome to your quick and easy bookmarking service!")
}
  1. 接下来,我们还应该将处理器注册到 web 服务器上。以下是 main.rsmain 函数:
fn main() -> std::io::Result<()> {
    env::set_var("RUST_LOG", "actix_web=debug");
    env_logger::init();
    HttpServer::new(|| {
        App::new()
            .wrap(middleware::Logger::default())
            .service(
                web::scope("/api")
                .service(
                    web::scope("/bookmarks")
                    .service(captures)
                    .service(bookmark_by_id)
                    .service(bit_ly)
                    .service(web::resource("add/{id}")
                        .name("add") 

                        .guard(guard::Any(guard::Put())
                        .or(guard::Post()))
                        .to(echo_bookmark))
            ))
            .service(
                web::scope("/bookmarks")
                    .service(bookmarks_index)
            )
            .external_resource("bitly", "https://bit.ly/{bitly}")

    })
    .bind("127.0.0.1:8081")?
    .run()
}
  1. 最后,我们应该将 Cargo.toml 适配以包含这些新的依赖项:
[dependencies]
actix-web = "1"
env_logger = "0.6"
actix-files = "0"
  1. 现在,我们可以使用 cargo run 构建并运行应用程序。然后,让我们看看是否可以使用 curl 或 Postman 来访问 API,这应该会产生以下类似的日志输出:
$ cargo run
 Finished dev [unoptimized + debuginfo] target(s) in 0.09s
 Running `target/debug/api`
[2019-07-17T15:38:14Z INFO actix_web::middleware::logger] 127.0.0.1:50426 "GET /bookmarks/ HTTP/1.1" 200 51 "-" "curl/7.64.0" 0.000655
[2019-07-17T15:40:07Z INFO actix_web::middleware::logger] 127.0.0.1:51386 "GET /api/bookmarks/by-id/10 HTTP/1.1" 200 44 "-" "curl/7.64.0" 0.001103
[2019-07-17T15:40:41Z INFO actix_web::middleware::logger] 127.0.0.1:51676 "GET /api/bookmarks/from-bitly/2NOMT6Q HTTP/1.1" 302 0 "-" "curl/7.64.0" 0.007269
[2019-07-17T15:42:26Z INFO actix_web::middleware::logger] 127.0.0.1:52566 "PUT /api/bookmarks/add/10 HTTP/1.1" 200 2 "-" "curl/7.64.0" 0.000704
[2019-07-17T15:42:33Z INFO actix_web::middleware::logger] 127.0.0.1:52626 "POST /api/bookmarks/add/10 HTTP/1.1" 200 2 "-" "curl/7.64.0" 0.001098
[2019-07-17T15:42:39Z INFO actix_web::middleware::logger] 127.0.0.1:52678 "DELETE /api/bookmarks/add/10 HTTP/1.1" 404 0 "-" "curl/7.64.0" 0.000630
[2019-07-17T15:43:30Z INFO actix_web::middleware::logger] 127.0.0.1:53094 "GET /api/bookmarks/captures/does-not/exist HTTP/1.1" 404 38 "-" "curl/7.64.0" 0.003554
[2019-07-17T15:43:39Z INFO actix_web::middleware::logger] 127.0.0.1:53170 "GET /api/bookmarks/captures/foxes.jpg HTTP/1.1" 200 59072 "-" "curl/7.64.0" 0.013600

以下是一些 curl 请求——它们应该很容易用 Postman 来复制:

$ curl localhost:8081/bookmarks/
Welcome to your quick and easy bookmarking service!⏎ 
$ curl localhost:8081/api/bookmarks/by-id/10
{ "id": 10, "url": "https://blog.x5ff.xyz" }⏎
$ curl -v localhost:8081/api/bookmarks/from-bitly/2NOMT6Q
* Trying ::1...
* TCP_NODELAY set
* connect to ::1 port 8081 failed: Connection refused
* Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 8081 (#0)
> GET /api/bookmarks/from-bitly/2NOMT6Q HTTP/1.1
> Host: localhost:8081
> User-Agent: curl/7.64.0
> Accept: */*
> 
< HTTP/1.1 302 Found
< content-length: 0
< location: https://bit.ly/2NOMT6Q
< date: Wed, 17 Jul 2019 15:40:45 GMT
<
$ curl -X PUT localhost:8081/api/bookmarks/add/10
10⏎ 
$ curl -X POST localhost:8081/api/bookmarks/add/10
10⏎
$ curl -v -X DELETE localhost:8081/api/bookmarks/add/10
* Trying ::1...
* TCP_NODELAY set
* connect to ::1 port 8081 failed: Connection refused
* Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 8081 (#0)
> DELETE /api/bookmarks/add/10 HTTP/1.1
> Host: localhost:8081
> User-Agent: curl/7.64.0
> Accept: */*
> 
< HTTP/1.1 404 Not Found
< content-length: 0
< date: Wed, 17 Jul 2019 15:42:51 GMT
< 
* Connection #0 to host localhost left intact
$ curl localhost:8081/api/bookmarks/captures/does-not/exist
No such file or directory (os error 2)⏎ 17:43:31
$ curl localhost:8081/api/bookmarks/captures/foxes.jpg
Warning: Binary output can mess up your terminal. Use "--output -" to tell 
Warning: curl to output it to your terminal anyway, or consider "--output 
Warning: <FILE>" to save to a file.

就这样了,但还有很多东西要解释,让我们看看为什么它会以这种方式工作。

它是如何工作的...

设计 良好 的 API 是困难的,并且需要很好地掌握可能发生的事情——特别是对于新的框架和语言。actix-web 已经证明了自己是一个多才多艺的工具,它有效地使用类型来取得很好的效果。步骤 1 通过导入一些类型和特性来设置这一点。

只有在 步骤 2步骤 3 中,事情才会变得更有趣。在这里,我们通过使用将函数包装成工厂的属性(在其下方都是异步演员;查看 第四章,无畏并发 中的 使用演员处理异步消息)或者让 web::resource() 类型来完成,几乎以 actix-web 允许的所有方式定义了各种处理器。无论哪种方式,每个处理器函数都有一个与之关联的路由,并且将并行调用。路由还包含可以使用 {} 语法指定的参数,该语法还允许使用正则表达式(例如,包含 "{tail:.*}" 的路由 - 这是一个简写,它接收 tail 键下的路径剩余部分)。

不要让用户直接访问你的文件系统中的文件,就像我们在这里做的那样。这在很多方面都是个坏主意,但最重要的是它提供了一种执行文件系统中任何文件的方法。更好的方法是提供一个抽象文件的白名单——例如,Base64 (developer.mozilla.org/en-US/docs/Web/API/WindowBase64/Base64_encoding_and_decoding) 编码的——使用一个独立的关键字:例如,UUID (tools.ietf.org/html/rfc4122)。

如果一个函数提供了一个 Path<T> 类型的输入参数,那么 T 就是相应路径变量中检查的内容。因此,如果函数头期望 i32,那么尝试传递字符串的人的请求将会失败。你可以通过 bookmarks/by-id/{id} 路径来验证这一点。作为 Path<T> 的替代方案,你也可以接收整个 HttpRequest (docs.rs/actix-web/1.0.3/actix_web/struct.HttpRequest.html) 作为参数,并通过 .query() 函数提取所需信息。echo_bookmarkbit_ly 函数展示了如何使用这些方法。

响应的行为类似。actix-web 提供了一个 Responder 特性,该特性为标准类型(如 String,以及我们看到的正确响应内容类型)实现了,这使得处理程序更易于阅读。同样,返回 HttpResponse 类型提供了更精细的控制。此外,还有一些结果和类似类型会自动转换为适当的响应,但展示所有这些内容将超出本书的范围。请查阅 actix-web 文档以获取更多信息。

属性的一个缺点是只有一个可以放在函数的顶部——那么我们如何重用函数以处理两种不同的 HTTP 方法?echo_bookmark 仅在 PUTPOST 上注册以响应输入 ID,而不是在 DELETEHEADGET 以及更多方法上。这是通过守卫实现的,只有当条件满足时才会转发请求。查看文档 (docs.rs/actix-web/1.0.3/actix_web/guard/index.html) 以获取更多信息。

第 4 步 展示了如何修改 Cargo.toml 以使其正常工作,而在 第 5 步 中,我们可以尝试使用 Web 服务。如果你花点时间观察 curl 的响应,我们会收到预期的结果。默认情况下,curl 不会跟随重定向,因此,带有位置头并指向我将去的地方的 HTTP 响应代码 302。这种重定向是由 actix-web 提供的外部资源提供的,这对于这些情况很有用。

现在我们已经了解了如何在 actix-web 中设计 API,让我们继续到下一个配方。

处理 JSON 负载

在学习如何创建 API 之后,我们需要传递数据。虽然路径提供了一种方法来做这件事,但任何稍微复杂一些的东西(例如,一个很长的项目列表)很快就会显示出这些方法的局限性。这就是为什么通常使用其他格式来结构化数据——JSON (json.org/) 是 Web 服务中最受欢迎的。在本章中,我们将使用之前的 API 并通过处理和返回 JSON 来增强它。

准备工作

让我们使用 cargo new json-handling 设置一个 Rust 二进制项目。由于我们将在本地端口 8081 上提供服务,请确保该端口可访问。此外,还需要一个如 curl 或 Postman 这样的程序来测试 Web 服务。

最后,使用 VS Code 打开整个目录。

如何做到这一点...

执行以下步骤以实现此配方:

  1. src/main.rs 中,我们首先添加导入:
#[macro_use]
extern crate actix_web;

use actix_web::{
    guard, http::Method, middleware, web, App, HttpResponse, HttpServer,
};
use serde_derive::{Deserialize, Serialize};
use std::env;
  1. 接下来,让我们创建一些处理函数以及一个可序列化的 JSON 类型。将以下代码添加到 src/main.rs 中:
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Bookmark {
    id: i32,
    url: String,
}

#[get("by-id/{id}")]
fn bookmarks_by_id(id: web::Path<(i32)>) -> HttpResponse {
    let bookmark = Bookmark {
        id: *id,
        url: "https://blog.x5ff.xyz".into(),
    };
    HttpResponse::Ok().json(bookmark)
}

fn echo_bookmark(bookmark: web::Json<Bookmark>) -> HttpResponse {
    HttpResponse::Ok().json(bookmark.clone())
}
  1. 最后,我们在 main 函数中将处理程序注册到 Web 服务器上:
fn main() -> std::io::Result<()> {
    env::set_var("RUST_LOG", "actix_web=debug");
    env_logger::init();
    HttpServer::new(|| {
        App::new().wrap(middleware::Logger::default()).service(
            web::scope("/api").service(
                web::scope("/bookmarks")
                    .service(bookmarks_by_id)
                    .service(
                        web::resource("add/{id}")
                            .name("add")
                            .guard(guard::Any(guard::Put()).
                             or(guard::Post()))
                            .to(echo_bookmark),
                    )
                    .default_service(web::route().method
                     (Method::GET)),
            ),
        )
    })
    .bind("127.0.0.1:8081")?
    .run()
}
  1. 我们还需要在 Cargo.toml 中指定依赖项。用以下依赖项替换现有的依赖项:
[dependencies]
actix-web = "1"
serde = "1"
serde_derive = "1"
env_logger = "0.6"
  1. 然后,我们可以通过运行 cargo run 并从不同的终端使用 curl 发送请求来测试它是否工作:命令及其响应应该如下所示:
$ curl -d "{\"id\":10,\"url\":\"https://blog.x5ff.xyz\"}" localhost:8081/api/bookmarks/add/10
Content type error⏎
$ curl -d "{\"id\":10,\"url\":\"https://blog.x5ff.xyz\"}" -H "Content-Type: application/json" localhost:8081/api/bookmarks/add/10
{"id":10,"url":"https://blog.x5ff.xyz"}⏎
$ curl localhost:8081/api/bookmarks/by-id/1
{"id":1,"url":"https://blog.x5ff.xyz"}⏎ 

同时,cargo run 的日志输出显示了来自服务器端的请求:

$ cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.08s
     Running `target/debug/json-handling`
[2019-07-13T17:06:22Z INFO actix_web::middleware::logger] 127.0.0.1:48880 "POST /api/bookmarks/add/10 HTTP/1.1" 400 63 "-" "curl/7.64.0" 0.001955
[2019-07-13T17:06:51Z INFO actix_web::middleware::logger] 127.0.0.1:49124 "POST /api/bookmarks/add/10 HTTP/1.1" 200 39 "-" "curl/7.64.0" 0.001290
[2019-07-18T06:34:18Z INFO actix_web::middleware::logger] 127.0.0.1:54900 "GET /api/bookmarks/by-id/1 HTTP/1.1" 200 39 "-" "curl/7.64.0" 0.001636

这很快也很简单,对吧?让我们看看它是如何工作的。

它是如何工作的...

将 JSON 处理添加到 actix-web 网络服务中很容易——归功于流行的 serde 包(crates.io/crates/serde)的深度集成。在 步骤 1 的一些导入之后,我们在 步骤 2 中声明一个 Bookmark 结构体作为 SerializeDeserialize,这使得 serde 能够为该数据类型生成和解析 JSON。

由于返回和消费 JSON 是一个非常常见的任务,处理函数的变化也是最小的。返回 JSON 有效载荷所需的函数附加到 HttpResponse 工厂方法上,该方法执行所有操作,包括设置适当的内容类型。在消费部分,有一个 web::Json<T> 类型来处理将转发到请求处理器的任何内容进行反序列化和检查。我们也可以依赖框架在这里做大部分的重活。

步骤 3 中注册处理器的操作与之前的菜谱没有不同;JSON 输入仅在处理函数中声明。actix-web 文档中有更多变体(actix.rs/docs/request/#json-request)及其示例(github.com/actix/examples/tree/master/json)。同样,步骤 4 包含了我们也在其他菜谱中使用过的必需依赖项。

步骤 5 中,我们运行整个项目并查看其工作情况:如果我们传递 JSON,输入 content-type 标头必须设置为适当的 MIME 类型(application/json);返回值也有这个头设置(以及 content-length 头),这样浏览器或其他程序可以轻松地处理结果。

让我们继续,看看另一个菜谱。

网络错误处理

网络服务的各个层次使得错误处理变得复杂,即使没有安全要求:应该传达什么以及何时传达?错误是否应该只在最后一刻才冒泡出来处理,还是更早一些?关于级联处理呢?在这个菜谱中,我们将揭示一些在 actix-web 中优雅处理这些问题的方法。

准备工作

让我们使用 cargo new web-errors 设置一个 Rust 二进制项目。由于我们将在本地端口 8081 上提供服务,请确保该端口可访问。此外,还需要一个程序,如 curl 或 Postman,来测试网络服务。

最后,使用 VS Code 打开整个目录。

如何操作...

你只需几个步骤就能理解 actix-web 的错误处理:

  1. src/main.rs 中,我们将添加基本的导入:
#[macro_use]
extern crate actix_web;
use failure::Fail;

use actix_web::{ http, middleware, web, App, HttpResponse, HttpServer, error
};
use serde_derive::{Deserialize, Serialize};
use std::env;
  1. 作为下一步,我们将定义我们的错误类型,并使用属性增强它们,以便框架知道这些类型:
#[derive(Fail, Debug)]
enum WebError {
    #[fail(display = "Invalid id '{}'", id)]
    InvalidIdError { id: i32 },
    #[fail(display = "Invalid request, please try again later")]
    RandomInternalError,
}

impl error::ResponseError for WebError {
    fn error_response(&self) -> HttpResponse {
        match *self {
            WebError::InvalidIdError { .. } => HttpResponse::new(http::StatusCode::BAD_REQUEST),
            WebError::RandomInternalError => HttpResponse::new(http::StatusCode::INTERNAL_SERVER_ERROR)
        }
    }
}
  1. 然后,我们将处理函数添加到 src/main.rs 中,并在 main() 中注册它:
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Bookmark {
    id: i32,
    url: String,
}

#[get("by-id/{id}")]
fn bookmarks_by_id(id: web::Path<(i32)>) -> Result<HttpResponse, WebError> {
    if *id < 10 {
        Ok(HttpResponse::Ok().json(Bookmark {
            id: *id,
            url: "https://blog.x5ff.xyz".into(),
        }))
    }
    else {
        Err(WebError::InvalidIdError { id: *id })
    }
}

fn main() -> std::io::Result<()> {
    env::set_var("RUST_LOG", "actix_web=debug");
    env_logger::init();
    HttpServer::new(|| {
        App::new()
            .wrap(middleware::Logger::default())
            .service(
                web::scope("/bookmarks")
                    .service(bookmarks_by_id)
            )
            .route(
                "/underconstruction",
                web::get().to(|| Result::<HttpResponse, 
                WebError>::Err(WebError::RandomInternalError)),
            )
    })
    .bind("127.0.0.1:8081")?
    .run()
}
  1. 为了导入依赖项,我们还需要调整 Cargo.toml
[dependencies]
actix-web = "1"
serde = "1"
serde_derive = "1"
env_logger = "0.6"
failure = "0"
  1. 为了完成这个配方,让我们看看如何使用 cargo runcurl 一起工作。以下是请求处理后的服务器输出:
$ cargo run
  Compiling web-errors v0.1.0 (Rust-Cookbook/Chapter08/web-errors)
    Finished dev [unoptimized + debuginfo] target(s) in 7.74s
     Running `target/debug/web-errors`
[2019-07-19T17:33:43Z INFO actix_web::middleware::logger] 127.0.0.1:46316 "GET /bookmarks/by-id/1 HTTP/1.1" 200 38 "-" "curl/7.64.0" 0.001529
[2019-07-19T17:33:47Z INFO actix_web::middleware::logger] 127.0.0.1:46352 "GET /bookmarks/by-id/100 HTTP/1.1" 400 16 "-" "curl/7.64.0" 0.000952
[2019-07-19T17:33:54Z INFO actix_web::middleware::logger] 127.0.0.1:46412 "GET /underconstruction HTTP/1.1" 500 39 "-" "curl/7.64.0" 0.000275

以下是用 curl 的详细模式显示的请求示例:

$ curl -v localhost:8081/bookmarks/by-id/1
* Trying ::1...
* TCP_NODELAY set
* connect to ::1 port 8081 failed: Connection refused
* Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 8081 (#0)
> GET /bookmarks/by-id/1 HTTP/1.1
> Host: localhost:8081
> User-Agent: curl/7.64.0
> Accept: */*
> 
< HTTP/1.1 200 OK
< content-length: 38
< content-type: application/json
< date: Fri, 19 Jul 2019 17:33:43 GMT
< 
* Connection #0 to host localhost left intact
{"id":1,"url":"https://blog.x5ff.xyz"}⏎ 

请求错误的 ID 会返回适当的 HTTP 状态代码:

$ curl -v localhost:8081/bookmarks/by-id/100
* Trying ::1...
* TCP_NODELAY set
* connect to ::1 port 8081 failed: Connection refused
* Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 8081 (#0)
> GET /bookmarks/by-id/100 HTTP/1.1
> Host: localhost:8081
> User-Agent: curl/7.64.0
> Accept: */*
> 
< HTTP/1.1 400 Bad Request
< content-length: 16
< content-type: text/plain
< date: Fri, 19 Jul 2019 17:33:47 GMT
< 
* Connection #0 to host localhost left intact
Invalid id '100'⏎

正如预期的那样,对 /underconstruction 的请求返回 HTTP 500 错误(内部服务器错误):

$ curl -v localhost:8081/underconstruction
* Trying ::1...
* TCP_NODELAY set
* connect to ::1 port 8081 failed: Connection refused
* Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 8081 (#0)
> GET /underconstruction HTTP/1.1
> Host: localhost:8081
> User-Agent: curl/7.64.0
> Accept: */*
> 
< HTTP/1.1 500 Internal Server Error
< content-length: 39
< content-type: text/plain
< date: Fri, 19 Jul 2019 17:33:54 GMT
< 
* Connection #0 to host localhost left intact
Invalid request, please try again later⏎ 

由于这工作得很好,让我们看看它是如何工作的。

它是如何工作的...

actix-web 使用一个错误特性和将 Rust 错误转换为 HttpResponses。这个特性和默认错误自动实现,但只响应默认的 内部服务器错误 消息。

步骤 1步骤 2 中,我们正在设置自定义错误,以便我们可以返回与用户当前正在执行的操作(或尝试执行的操作)相关的消息。与其他错误一样(见第五章,处理错误和其他结果),我们使用枚举来提供一个伞状结构,以便将错误变体匹配起来。每个变体都增加了一个属性,它提供了一个带有格式字符串的相应错误消息——这是由 failure crate(crates.io/crates/failure)提供的功能。这里的消息是针对响应代码 500(默认值)的最后一招类型消息。这个 HTTP 响应代码,连同错误体(如 HTML 页面)一起,可以通过实现 actix_web::error::ResponseError 特性进行自定义。使用 error_response() 函数提供的任何 HttpResponse 都将返回给客户端。

如果您自己调用该函数,则不会附加 #[fail(display="...")] 消息。始终使用 Rust 的 Result 枚举将错误传达给 actix_web

第 3 步 定义了 web 服务的处理函数,并且由于它使用 JSON 响应,因此需要一个用于序列化信息的结构体。在这个例子中,我们还在使用任意数字 10 作为返回错误的截止点——使用 Rust 的 Result 枚举。这提供了一个框架无关的方式来处理不良结果,就像我们使用纯 Rust 一样。第二个路由 /underconstruction 展示了 actix-web 路由的实现方式:作为一个闭包。由于它立即返回一个错误,我们必须明确告诉编译器返回类型以及它是一个可能返回 HttpResponseWebErrorResult 枚举。然后我们直接返回后者。第 4 步 展示了所需的依赖项,并告诉我们必须包含 failure crate。在最后一步,我们运行代码并通过发出 curl 请求来测试它,并检查服务器端的日志。这并不复杂,对吧?如果您想深入了解,也可以查看 actix-web 文档 (actix.rs/docs/errors/)。

让我们继续下一个步骤。

渲染 HTML 模板

虽然 JSON 是一个非常易于阅读且易于处理的格式,但许多人仍然更喜欢更互动的体验——例如网站。虽然这并不是 actix-web 的原生功能,但一些模板引擎提供了无缝集成,以最小化组装和输出 HTML 所需的调用。与简单地提供静态网站相比,主要区别在于模板引擎将变量输出和 Rust 代码渲染到增强的 HTML 页面中,以产生适应应用程序状态的任何内容。在本步骤中,我们将查看 另一个 Rust 模板引擎Yarte)(crates.io/crates/yarte) 与 actix-web 的集成。

准备工作

使用 cargo new html-templates 创建一个 Rust 二进制项目,并确保本地主机可以访问端口 8081。在创建项目目录后,您还需要创建一些额外的文件夹和文件。静态目录内的图像文件可以是任何图像,只要有一个 Base64 编码的版本作为文本文件即可。您可以使用在线服务或 Linux 上的 Base64 二进制文件(linux.die.net/man/1/base64)来创建自己的(您需要相应地更改代码中的名称)或使用我们仓库中的版本。.hbs 文件将在本步骤中填充(创建):

html-templates/
├── Cargo.toml
├── src
│   └── main.rs
├── static
│   ├── packtpub.com.b64
│   ├── packtpub.com.png
│   ├── placeholder.b64
│   ├── placeholder.png
│   ├── x5ff.xyz.b64
│   └── x5ff.xyz.png
└── templates
    ├── index.hbs
    └── partials
        └──bookmark.hbs

最后,使用 VS Code 打开整个目录。

如何操作...

只需几个步骤就可以创建模板化的网页:

  1. 首先,让我们向 src/main.rs 添加一些代码。用以下内容替换默认片段(注意:PLACEHOLDER_IMG 中的 Base64 编码字符串在此处进行了缩写。获取完整的 Base64 编码图像请访问 blog.x5ff.xyz/other/placeholder.b64):
#[macro_use]
extern crate actix_web;

use actix_web::{middleware, web, App, HttpServer, Responder};
use chrono::prelude::*;
use std::env;
use yarte::Template;

const PLACEHOLDER_IMG: &str =
    "iVBORw0KGgoAAAANS[...]s1NR+4AAAAASUVORK5CYII=";

#[derive(Template)]
#[template(path = "index.hbs")]
struct IndexViewModel {
    user: String,
    bookmarks: Vec<BookmarkViewModel>,
}

#[derive(Debug, Clone)]
struct BookmarkViewModel {
    timestamp: Date<Utc>,
    url: String,
    mime: String,
    base64_image: String,
}

在调整 src/main.rs 之后,将所需的依赖项添加到 Cargo.toml

[dependencies] 
actix-web = "1"
serde = "1"
serde_derive = "1"
env_logger = "0.6"
base64 = "0.10.1"
yarte = {version = "0", features=["with-actix-web"]}
chrono = "0.4"⏎ 

  1. 在声明模板之后,我们需要注册一个处理程序来提供服务:
#[get("/{name}")]
pub fn index(name: web::Path<(String)>) -> impl Responder {
    let user_name = name.as_str().into();

首先,让我们为已识别的用户添加书签数据:

    if &user_name == "Claus" {
        IndexViewModel {
            user: user_name,
            bookmarks: vec![
                BookmarkViewModel {
                    timestamp: Utc.ymd(2019, 7, 20),
                    url: "https://blog.x5ff.xyz".into(),
                    mime: "image/png".into(),
                    base64_image: std::fs::read_to_string
                    ("static/x5ff.xyz.b64")
                        .unwrap_or(PLACEHOLDER_IMG.into()),
                },
                BookmarkViewModel {
                    timestamp: Utc.ymd(2017, 9, 1),
                    url: "https://microsoft.com".into(),
                    mime: "image/png".into(),
                    base64_image: std::fs::read_to_string
                    ("static/microsoft.com.b64")
                        .unwrap_or(PLACEHOLDER_IMG.into()),
                },
                BookmarkViewModel {
                    timestamp: Utc.ymd(2019, 2, 2),
                    url: "https://www.packtpub.com/".into(),
                    mime: "image/png".into(),
                    base64_image: std::fs::read_to_string
                    ("static/packtpub.com.b64")
                        .unwrap_or(PLACEHOLDER_IMG.into()),
                },
            ],
        }

对于其他所有人(未识别的用户),我们可以简单地返回一个空向量:

    } else {
        IndexViewModel {
            user: user_name,
            bookmarks: vec![],
        }
    }
}

最后,让我们在 main 函数中启动服务器:

fn main() -> std::io::Result<()> {
    env::set_var("RUST_LOG", "actix_web=debug");
    env_logger::init();
    HttpServer::new(|| {
        App::new()
            .wrap(middleware::Logger::default())
            .service(web::scope("/bookmarks").service(index))
    })
    .bind("127.0.0.1:8081")?
    .run()
}
  1. 代码已经准备好了,但我们仍然缺少模板。这就是我们在 .hbs 文件中添加一些内容的地方。首先,让我们向 templates/index.hbs 添加代码:
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <link href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
</head>

在头部之后,我们需要一个 HTML 主体来标记数据:

<body>
    <div class="container">
        <div class="row">
            <div class="col-lg-12 pb-3">
                <h1>Welcome {{ user }}.</h1>
                <h2 class="text-muted">Your bookmarks:</h2>
            </div>
        </div>

        {{#if bookmarks.is_empty() }}
        <div class="row">
            <div class="col-lg-12">
            No bookmarks :(
            </div>
        </div>
        {{~/if}}

        {{#each bookmarks}}
            <div class="row {{# if index % 2 == 1 }} bg-light text-
            dark {{/if }} mt-2 mb-2">
            {{> partials/bookmark }}
            </div>
        {{~/each}}
    </div>
</body>
</html>
  1. 我们在这个最后的模板中调用了一个部分,所以让我们也向它添加一些代码。打开 templates/partials/bookmark.hbs 并插入以下内容:
<div class="col-lg-2">
    <img class="rounded img-fluid p-1" src="img/>    {{ base64_image }}"> </div>
<div class="col-lg-10">
    <a href="{{ url }}">
        <h3>{{ url.replace("https://", "") }}</h3>
    </a>
    <i class="text-muted">Added {{ timestamp.format("%Y-%m-
    %d").to_string() }}</i>
</div>
  1. 是时候尝试一下了!使用 cargo run 启动服务器并记录输出,同时打开浏览器窗口访问 localhost:8081/bookmarks/Hans 以及 localhost:8081/bookmarks/Claus,以查看是否正常工作。以下是浏览器窗口在打开这些 URL 后 cargo run 显示的内容:
$ cargo run
   Compiling html-templates v0.1.0 (Rust-Cookbook/Chapter08/html-templates)
 Finished dev [unoptimized + debuginfo] target(s) in 2m 38s
     Running `target/debug/html-templates`
[2019-07-20T16:36:06Z INFO actix_web::middleware::logger] 127.0.0.1:50060 "GET /bookmarks/Claus HTTP/1.1" 200 425706 "-" "Mozilla/5.0 (X11; Fedora; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.86 Safari/537.36" 0.013246
[2019-07-20T16:37:34Z INFO actix_web::middleware::logger] 127.0.0.1:50798 "GET /bookmarks/Hans HTTP/1.1" 200 821 "-" "Mozilla/5.0 (X11; Fedora; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.86 Safari/537.36" 0.000730

以下是一个未识别用户的查询结果:

对于已识别的用户,系统返回适当的内容:

让我们找出这是为什么。

它是如何工作的...

在许多语言中,创建模板引擎有点像入门教程——这可能是 Yarte 这个名字的由来。虽然选择很多,但 actix-web 提供了三个其他引擎的示例;我们建议您在他们的 GitHub 仓库中查看它们(github.com/actix/examples)。这个食谱的 第一步 已经涵盖了某些重要的工作:导入内容并声明视图模型(如 MVVM 模式:blogs.msdn.microsoft.com/msgulfcommunity/2013/03/13/understanding-the-basics-of-mvvm-design-pattern/)。Yarte 提供了宏属性,可以将特定的模型与模板文件关联起来——并且它会自动找到 templates 文件夹。如果这不适合您的项目,它们允许您相应地配置框架。更多详情请访问他们的网站(yarte.netlify.com/)。我们使用的是嵌套模型,其中内部结构不需要自己的关联模板。

第 2 步中,我们在/bookmarks作用域下注册了处理函数,并指定了/{name}路径,这导致 URL:/bookmarks/{name}actix-web在检查路由方面非常严格,所以/bookmarks/{name}**/**将会返回一个错误(404)。处理函数返回一个包含 Claus 名字的小书签列表,但不为其他人返回,这在更现实的场景中会从数据库中获取。无论如何,我们使用这个硬编码的版本,并添加了日志中间件,以便我们可以看到发生了什么。我们还使用了一个常量作为占位符图片,您可以在blog.x5ff.xyz/other/placeholder.b64下载它。

我们在第 3 步中定义的模板是引擎之间主要的不同之处。使用众所周知的{{ rust-code }}符号,我们可以增强常规 HTML 以生成更复杂的输出。有各种类型的循环、条件、变量和部分。部分之所以重要,是因为它们允许你将视图部分拆分为可重用的组件,这些组件甚至不一定是 HTML/Yarte 模板,可以是任何文本。

编译过程将这些模板拉入,将它们与我们之前声明的类型结合起来——有一个重要的后果。目前,更改模板需要重新编译main.rs文件,以反映更改,因此建议使用 touch 或类似的命令来设置src/main.rs的修改日期。之后,cargo的行为就像src/main.rs发生了变化一样。

第 4 步 实现了显示每个书签的部分,类似于第 3 步中的索引模板。只有在第 5 步中,我们才会运行并查看结果:一个简单的网站,显示了当用户被识别(即:有与该名称相关联的数据)和当用户未被识别时的相关书签列表。通过使用流行的 Bootstrap CSS 框架(getbootstrap.com),实现了最小化设计。

现在,让我们继续下一个菜谱。

使用 ORM 将数据保存到数据库

对于对象关系映射器的看法差异很大:当 SQL 数据库存储了世界上所有的数据时,它们的用途被强烈鼓励,但随后当实际上整个世界的数据都如此时,它们很快就失去了青睐。通常这些框架在易用性、语言集成和可扩展性之间提供了一种权衡。虽然查询 TB 级的数据确实需要一种根本不同的方法,但简单的 CRUD 型业务应用程序与为你做繁重工作的框架配合得很好,而且——最重要的是——它们与它们连接的实际数据库多少是独立的。Rust 的宏在这里非常有用——它们允许 ORM 框架在编译时做这些事情,因此它是内存安全的、类型安全的,并且速度快。让我们看看它是如何实现的。

准备工作

使用 cargo new orm 创建一个 Rust 二进制项目,并确保本地主机可以访问端口 8081。要访问服务,获取一个程序,例如 curl 或 Postman,来执行 POSTGET 以及更多类型的网络请求,以及一个用于创建和管理 SQLite (www.sqlite.org/index.html) 数据库的程序(例如,sqlitebrowser: github.com/sqlitebrowser/sqlitebrowser)。

使用 SQLite 数据库管理器,在 db 文件夹中创建一个新的数据库,bookmarks.sqlite。然后,添加一个遵循此模式的表:

CREATE TABLE bookmarks(id TEXT PRIMARY KEY, url TEXT);

接下来,我们将在项目中使用 libsqlite3 库和头文件。在 Linux、WSL 和 macOS 上,从软件包仓库安装适当的软件包。在 Ubuntu 和 WSL 上,您可以使用类似 apt-get install libsqlite3-dev 的命令。对于其他发行版和 macOS,请使用您首选的包管理器安装 libsqlite3 及其头文件。

原生 Windows 10 用户可能需要从 www.sqlite.org/download.html 下载 dll 二进制文件并将它们放入项目目录中。然而,强烈推荐使用 Linux/macOS。

最后,使用 VS Code 打开整个目录。

如何做到这一点...

只需几个步骤就可以运行数据库查询:

  1. src/main.rs 中,我们将添加基本的导入:
#[macro_use]
extern crate diesel;
mod models;
mod schema;

use actix_web::{middleware, web, App, Error, HttpResponse, HttpServer};

use std::env;

use diesel::prelude::*;
use diesel::sqlite::SqliteConnection;
use futures::Future;
use models::{Bookmark, NewBookmark};
use serde_derive::{Deserialize, Serialize};
  1. 让我们在 main.rs 中设置一些辅助类型和一个用于连接字符串的常量:
// Helpers
const SQLITE_DB_URL: &str = "db/bookmarks.sqlite";

#[derive(Debug, Serialize, Deserialize)]
struct WebBookmark {
    url: String,
}

fn connect(db_url: &str) -> SqliteConnection {
    SqliteConnection::establish(&SQLITE_DB_URL)
     .expect(&format!("Error connecting to {}", db_url))
}
  1. 我们还需要一些处理器,所以让我们将它们添加到文件中,从通过 ID 获取书签开始:
// Handlers
fn bookmarks_by_id(req_id: web::Path<(String)>) -> impl 
 Future<Item = HttpResponse, Error = Error> {
    web::block(move || {
        use self::schema::bookmarks::dsl::*;

        let conn = connect(&SQLITE_DB_URL);
        bookmarks
            .filter(id.eq(req_id.as_str()))
            .limit(1)
            .load::<Bookmark>(&conn)
    })
    .then(|res| match res {
        Ok(obj) => Ok(HttpResponse::Ok().json(obj)),
        Err(_) => Ok(HttpResponse::InternalServerError().into()),
    })
}

为了找出所有 ID,我们还想有一个处理器,它返回所有书签:

fn all_bookmarks() -> impl Future<Item = HttpResponse, Error = Error> {
    web::block(move || {
        use self::schema::bookmarks::dsl::*;

        let conn = connect(&SQLITE_DB_URL);
        bookmarks.load::<Bookmark>(&conn)
    })
    .then(|res| match res {
        Ok(obj) => Ok(HttpResponse::Ok().json(obj)),
        Err(_) => Ok(HttpResponse::InternalServerError().into()),
    })
}

接下来,让我们看看是否可以添加一些书签:

fn bookmarks_add(
    bookmark: web::Json<WebBookmark>,
     ) -> impl Future<Item = HttpResponse, Error = Error> {
    web::block(move || {
        use self::schema::bookmarks::dsl::*;

        let conn = connect(&SQLITE_DB_URL);
        let new_id = format!("{}", uuid::Uuid::new_v4());
        let new_bookmark = NewBookmark {
            id: &new_id,
            url: &bookmark.url,
        };
        diesel::insert_into(bookmarks)
            .values(&new_bookmark)
            .execute(&conn)
            .map(|_| new_id)
    })
    .then(|res| match res {
        Ok(obj) => Ok(HttpResponse::Ok().json(obj)),
        Err(_) => Ok(HttpResponse::InternalServerError().into()),
    })
}

几乎完整的 CRUD(创建、读取、更新、删除)操作还缺少 delete 函数:

fn bookmarks_delete(
    req_id: web::Path<(String)>,
     ) -> impl Future<Item = HttpResponse, Error = Error> {
    web::block(move || {
        use self::schema::bookmarks::dsl::*;

        let conn = connect(&SQLITE_DB_URL);
        diesel::delete(bookmarks.filter(id.eq(req_id.as_str())))
         .execute(&conn)
    })
    .then(|res| match res {
        Ok(obj) => Ok(HttpResponse::Ok().json(obj)),
        Err(_) => Ok(HttpResponse::InternalServerError().into()),
    })
}

最后,我们在 main 函数中将所有这些整合起来,该函数启动服务器并附加这些处理器:

fn main() -> std::io::Result<()> {
    env::set_var("RUST_LOG", "actix_web=debug");
    env_logger::init();
    HttpServer::new(move || {
        App::new().wrap(middleware::Logger::default()).service(
            web::scope("/api").service(
                web::scope("/bookmarks")
                    .service(web::resource("/all").route(web::get()
                    .to_async(all_bookmarks)))
                    .service(
                        web::resource("by-id/{id}").route(web
                        ::get().to_async(bookmarks_by_id)),
                    )
                    .service(
                        web::resource("/")
                            .data(web::JsonConfig::default())      
                            .route(web::post().to_async
                            (bookmarks_add)),
                    )
                    .service(
                        web::resource("by-
                        id/{id}").route(web::delete()
                        .to_async(bookmarks_delete)),
                    ),
            ),
        )
    })
    .bind("127.0.0.1:8081")?
    .run()
}
  1. 那么,模型在哪里?它们在自己的文件 src/models.rs 中。创建它并添加以下内容:
use crate::schema::bookmarks;
use serde_derive::Serialize;

#[derive(Debug, Clone, Insertable)]
#[table_name = "bookmarks"]
pub struct NewBookmark<'a> {
    pub id: &'a str,
    pub url: &'a str,
}

#[derive(Serialize, Queryable)]
pub struct Bookmark {
    pub id: String,
    pub url: String,
}
  1. 还有另一个尚未创建的导入:src/schema.rs。同样创建该文件,并添加以下代码:
table! {
    bookmarks (id) {
        id -> Text,
        url -> Text,
    }
}
  1. 如同往常,我们需要调整 Cargo.toml 以下载依赖项:
[dependencies]
actix-web = "1"
serde = "1"
serde_derive = "1"
env_logger = "0.6"
diesel = {version = "1.4", features = ["sqlite"] }
uuid = { version = "0.7", features = ["serde", "v4"] }
futures = "0.1"
  1. 这应该设置好一切,以便使用 cargo run 运行网络服务并观察日志输出(在请求之后):
$ cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.16s
     Running `target/debug/orm`
[2019-07-20T19:33:33Z INFO actix_web::middleware::logger] 127.0.0.1:54560 "GET /api/bookmarks/all HTTP/1.1" 200 2 "-" "curl/7.64.0" 0.004737
[2019-07-20T19:33:52Z INFO actix_web::middleware::logger] 127.0.0.1:54722 "POST /api/bookmarks/ HTTP/1.1" 200 1 "-" "curl/7.64.0" 0.017087
[2019-07-20T19:33:55Z INFO actix_web::middleware::logger] 127.0.0.1:54750 "GET /api/bookmarks/all HTTP/1.1" 200 77 "-" "curl/7.64.0" 0.002248
[2019-07-20T19:34:11Z INFO actix_web::middleware::logger] 127.0.0.1:54890 "GET /api/bookmarks/by-id/9b2a4264-3db6-4c50-88f1-807b20b5841e HTTP/1.1" 200 77 "-" "curl/7.64.0" 0.003298
[2019-07-20T19:34:23Z INFO actix_web::middleware::logger] 127.0.0.1:54992 "DELETE /api/bookmarks/by-id/9b2a4264-3db6-4c50-88f1-807b20b5841e HTTP/1.1" 200 1 "-" "curl/7.64.0" 0.017980
[2019-07-20T19:34:27Z INFO actix_web::middleware::logger] 127.0.0.1:55030 "GET /api/bookmarks/all HTTP/1.1" 200 2 "-" "curl/7.64.0" 0.000972

我们可以使用 curl 与网络服务交互,以下是一些预期的调用和输出:

$ curl localhost:8081/api/bookmarks/all
[]⏎
$ curl -d "{\"url\":\"https://blog.x5ff.xyz\"}" -H "Content-Type: application/json" localhost:8081/api/bookmarks/
"9b2a4264-3db6-4c50-88f1-807b20b5841e"⏎
$ curl localhost:8081/api/bookmarks/all
[{"id":"9b2a4264-3db6-4c50-88f1-807b20b5841e","url":"https://blog.x5ff.xyz"}]⏎
$ curl localhost:8081/api/bookmarks/by-id/9b2a4264-3db6-4c50-88f1-807b20b5841e
[{"id":"9b2a4264-3db6-4c50-88f1-807b20b5841e","url":"https://blog.x5ff.xyz"}]⏎
$ curl -X "DELETE" localhost:8081/api/bookmarks/by-id/9b2a4264-3db6-4c50-88f1-807b20b5841e
1⏎
$ curl localhost:8081/api/bookmarks/all
[]⏎

让我们看看这是如何工作的。

它是如何工作的...

diesel-rs 是 Rust 最著名的数据库连接框架,它提供了快速、类型安全和易于使用的体验,用于映射数据库表。这再次得益于宏的强大功能,它能够在编译时创建零成本的抽象。然而,为了实现这一点,我们需要权衡一些事情,并且了解如何使用这个框架是很重要的。

SQLite 没有非常严格的类型系统。这就是为什么我们可以使用一个名为 text 的泛型类型来使用字符串。其他数据库可能有更细微的类型。查看 SQLite3 类型(www.sqlite.org/datatype3.html)以获取更多信息。

步骤 1中,我们正在准备导入——没有什么特别有趣的内容,但你会注意到models.rsschema.rs的声明。再进一步,在步骤 2中,我们看到一个连接字符串(实际上只是一个文件路径)常量,我们将使用它在连接函数中连接到数据库。此外,我们正在创建一个 JSON 网络服务,因此我们创建了传输对象类型WebBookmark。我们在步骤 3中创建这些处理器,一个用于添加、检索(所有和按 ID)以及删除书签实体。

所有这些处理器都返回一个Future对象并异步运行。虽然处理器总是异步运行的(它们是演员),但它们明确返回类型,因为它们使用同步部分连接到数据库——diesel-rs目前不是线程安全的。这个同步部分是通过使用web::block语句实现的,它返回一个映射到Future和适当的HttpResponse类型的结果。在bookmarks_add处理器的例子中,它返回新创建的 ID 作为 JSON 字符串,而bookmarks_delete返回删除影响的行数。所有处理器在发生错误时都返回 500。

如果你想了解如何使用连接池并正确管理它们,请查看actix-web示例中的 diesel(github.com/actix/examples/tree/master/diesel)。它使用了 Rust 的r2d2包(github.com/sfackler/r2d2)。

步骤 3还注册了这些函数及其相应的路由。by-id路由接受两种不同的方法(GETDELETE),由于bookmarks_add函数的异步特性,数据必须显式声明JsonConfig以自动解析 JSON 输入。所有注册都使用to_async方法完成,这使得属性方法无法使用。

只有在 步骤 4步骤 5 中,我们才创建 diesel-rs 特定的代码。models.rs 是一个包含所有我们的模型文件的文件,它们都是表格中行的抽象,但 NewBookmark 类型负责插入新对象(table_nameInsertable 属性将其附加到 DSL),而 Bookmark 则返回给用户(diesel 的 Queryable 和 Serde 的 Serialize 使之成为可能)。schema.rs 包含一个宏调用,声明表名(bookmarks)、其主键(id)和列(idurl)及其由 diesel 理解的数据类型。还有许多其他类型;查看 diesel 对 table! 的深入解释 (diesel.rs/guides/schema-in-depth/)。

步骤 6 展示了 diesel-rs 与不同数据库的工作方式;所有这些都是必须声明的功能。此外,diesel 还有一个用于数据库迁移和其他有趣功能的 CLI,因此请查看其入门指南 (diesel.rs/guides/getting-started/) 以获取更多信息。在 步骤 7 中,我们最终可以运行网络服务并插入/查询一些数据。

然而,让我们继续使用 ORM 框架进行更高级的操作。

使用 ORM 运行高级查询

ORM 的一个主要缺点通常是执行非常规路径操作时的复杂性。SQL——关系数据库使用的语言——是标准化的,但其类型并不总是与应用程序所做的工作兼容。在本食谱中,我们将探讨在 Rust 的 diesel-rs 中运行更高级查询的几种方法。

准备工作

使用 cargo new advanced-orm 创建一个 Rust 二进制项目,并确保本地主机可以访问端口 8081。要访问服务,可以使用 curl 或 Postman 等程序执行 POSTGET 等类型的网络请求,以及用于创建和管理 SQLite (www.sqlite.org/index.html) 数据库的程序(例如,sqlitebrowser: github.com/sqlitebrowser/sqlitebrowser)。

如果您确保更新数据库表,则可以重用并扩展前一个食谱(使用 ORM 将数据保存到数据库中)中的代码。

使用 SQLite 数据库管理器,在文件夹 db 中创建一个新的数据库,名为 bookmarks.sqlite。然后,添加符合以下模式的表:

CREATE TABLE bookmarks(id TEXT PRIMARY KEY, url TEXT, added TEXT);
CREATE TABLE comments(id TEXT PRIMARY KEY, bookmark_id TEXT, comment TEXT);

接下来,我们将在项目中使用 libsqlite3 库和头文件。在 Linux、WSL 和 macOS 上,从软件包仓库安装适当的软件包。在 Ubuntu 和 WSL 上,可以使用类似 apt-get install libsqlite3-dev 的命令。

原生的 Windows 10 用户可能需要从 www.sqlite.org/download.html 下载 dll 二进制文件并将它们放置到项目目录中。然而,使用 Linux/macOS 被高度推荐。

最后,使用 VS Code 打开整个目录。

如何操作...

只需几个步骤就可以使用模板:

  1. src/main.rs将包含处理程序和主函数。让我们先添加一些辅助类型和函数:
#[macro_use]
extern crate diesel;
mod models;
mod schema;

use actix_web::{middleware, web, App, Error, HttpResponse, HttpServer};

use std::env;

use crate::schema::{date, julianday};
use chrono::prelude::*;
use diesel::prelude::*;
use diesel::sqlite::SqliteConnection;
use futures::Future;
use serde_derive::{Deserialize, Serialize};

在一些导入之后,让我们设置辅助工具:

// Helpers
const SQLITE_DB_URL: &str = "db/bookmarks.sqlite";

#[derive(Debug, Serialize, Deserialize)]
struct WebBookmark {
    url: String,
    comment: Option<String>,
}

#[derive(Debug, Serialize, Deserialize)]
struct WebBookmarkResponse {
    id: String,
    added: String,
    url: String,
    comment: Option<String>,
}

fn connect(db_url: &str) -> SqliteConnection {
    SqliteConnection::establish(&SQLITE_DB_URL).expect(&format!("Error connecting to {}", db_url))
}
  1. 一个新的处理程序将检索具有儒略日期的书签。让我们添加它以及一些其他众所周知的处理程序:
fn bookmarks_as_julian_by_date(
    at: web::Path<(String)>,
    ) -> impl Future<Item = HttpResponse, Error = Error> {
    web::block(move || {
        use self::schema::bookmarks::dsl::*;
        let conn = connect(&SQLITE_DB_URL);
        bookmarks
            .select((id, url, julianday(added)))
            .filter(date(added).eq(at.as_str()))
            .load::<models::JulianBookmark>(&conn)
    })
    .then(|res| match res {
        Ok(obj) => Ok(HttpResponse::Ok().json(obj)),
        Err(_) => Ok(HttpResponse::InternalServerError().into()),
    })
}

添加书签是这些众所周知的处理程序之一:

fn bookmarks_add(
    bookmark: web::Json<WebBookmark>,
    ) -> impl Future<Item = HttpResponse, 
    Error = Error> {
    web::block(move || {
        use self::schema::bookmarks::dsl::*;
        use self::schema::comments::dsl::*;

        let conn = connect(&SQLITE_DB_URL);
        let new_id = format!("{}", uuid::Uuid::new_v4());
        let now = Utc::now().to_rfc3339();
        let new_bookmark = models::NewBookmark {
            id: &new_id,
            url: &bookmark.url,
            added: &now,
        };

        if let Some(comment_) = &bookmark.comment {
            let new_comment_id = format!("{}", 
            uuid::Uuid::new_v4());
            let new_comment = models::NewComment {
                comment_id: &new_comment_id,
                bookmark_id: &new_id,
                comment: &comment_,
            };
            let _ = diesel::insert_into(comments)
                .values(&new_comment)
                .execute(&conn);
        }

        diesel::insert_into(bookmarks)
            .values(&new_bookmark)
            .execute(&conn)
            .map(|_| new_id)
    })
    .then(|res| match res {
        Ok(obj) => Ok(HttpResponse::Ok().json(obj)),
        Err(_) => Ok(HttpResponse::InternalServerError().into()),
    })
}

接下来,删除书签是一个重要的处理程序:

fn bookmarks_delete(
    req_id: web::Path<(String)>,
    ) -> impl Future<Item = HttpResponse, Error = Error> {
    web::block(move || {
        use self::schema::bookmarks::dsl::*;
        use self::schema::comments::dsl::*;

        let conn = connect(&SQLITE_DB_URL);
        diesel::delete(bookmarks.filter(id.eq(req_id.as_str())))
            .execute(&conn)
            .and_then(|_| {
                diesel::delete(comments.filter(bookmark_id.eq
                (req_id.as_str()))).execute(&conn)
            })
    })
    .then(|res| match res {
        Ok(obj) => Ok(HttpResponse::Ok().json(obj)),
        Err(_) => Ok(HttpResponse::InternalServerError().into()),
    })
}
  1. 现在我们能够添加和删除评论和书签,我们只需要一次性检索它们:
fn all_bookmarks() -> impl Future<Item = HttpResponse, Error = Error> {
    web::block(move || {
        use self::schema::bookmarks::dsl::*;
        use self::schema::comments::dsl::*;

        let conn = connect(&SQLITE_DB_URL);
        bookmarks
            .left_outer_join(comments)
            .load::<(models::Bookmark, Option<models::Comment>)>
            (&conn)
            .map(
                |bookmarks_: Vec<(models::Bookmark, 
                Option<models::Comment>)>| {
                    let responses: Vec<WebBookmarkResponse> = 
                    bookmarks_
                        .into_iter()
                        .map(|(b, c)| WebBookmarkResponse {
                            id: b.id,
                            url: b.url,
                            added: b.added,
                            comment: c.map(|c| c.comment),
                        })
                        .collect();
                    responses
                },
            )
    })
    .then(|res| match res {
        Ok(obj) => Ok(HttpResponse::Ok().json(obj)),
        Err(_) => Ok(HttpResponse::InternalServerError().into()),
    })
}

最后,我们在main()中连接一切:

fn main() -> std::io::Result<()> {
    env::set_var("RUST_LOG", "actix_web=debug");
    env_logger::init();
    HttpServer::new(move || {
        App::new().wrap(middleware::Logger::default()).service(
            web::scope("/api").service(
                web::scope("/bookmarks")
                    .service(web::resource("/all").route
                    (web::get().to_async(all_bookmarks)))
                    .service(
                        web::resource("added_on/{at}/julian")
                            .route(web::get().to_async
                            (bookmarks_as_julian_by_date)),
                    )
                    .service(
                        web::resource("/")
                            .data(web::JsonConfig::default())
                            .route(web::post().to_async
                            (bookmarks_add)),
                    )
                    .service(
                        web::resource("by-                    
                        id/{id}").route(web::delete().
                        to_async(bookmarks_delete)),
                    ),
            ),
        )
    })
    .bind("127.0.0.1:8081")?
    .run()
}
  1. 为了将评论与书签一起保存,我们不得不扩展模式和模型。使用以下内容创建(或编辑)src/schema.rs
use diesel::sql_types::Text;
joinable!(comments -> bookmarks (bookmark_id));
allow_tables_to_appear_in_same_query!(comments, bookmarks);

sql_function! {
    fn julianday(t: Text) -> Float;
}
sql_function! {
    fn date(t: Text) -> Text;
}

table! {
    bookmarks (id) {
        id -> Text,
        url -> Text,
        added -> Text,
    }
}

table! {
    comments (comment_id) {
        comment_id -> Text,
        bookmark_id -> Text,
        comment -> Text,
    }
}
  1. 接下来,创建或更新src/models.rs以创建这些类型的 Rust 表示:
use crate::schema::{bookmarks, comments};
use serde_derive::Serialize;

#[derive(Debug, Clone, Insertable)]
#[table_name = "bookmarks"]
pub struct NewBookmark<'a> {
    pub id: &'a str,
    pub url: &'a str,
    pub added: &'a str,
}

#[derive(Debug, Serialize, Queryable)]
pub struct Bookmark {
    pub id: String,
    pub url: String,
    pub added: String,
}

#[derive(Serialize, Queryable)]
pub struct JulianBookmark {
    pub id: String,
    pub url: String,
    pub julian: f32,
}

#[derive(Debug, Serialize, Queryable)]
pub struct Comment {
    pub bookmark_id: String,
    pub comment_id: String,
    pub comment: String,
}

#[derive(Debug, Clone, Insertable)]
#[table_name = "comments"]
pub struct NewComment<'a> {
    pub bookmark_id: &'a str,
    pub comment_id: &'a str,
    pub comment: &'a str,
}
  1. 为了导入依赖项,我们还需要调整Cargo.toml
[dependencies]
actix-web = "1"
serde = "1"
serde_derive = "1"
env_logger = "0.6"
diesel = {version = "1.4", features = ["sqlite"] }
uuid = { version = "0.7", features = ["serde", "v4"] }
futures = "0.1"
chrono = "0.4"
  1. 为了完成这个食谱,让我们看看如何使用cargo runcurl一起工作。请求应该按照以下日志输出做出响应:
$ curl http://localhost:8081/api/bookmarks/all
[]⏎
$ curl -d "{\"url\":\"https://blog.x5ff.xyz\"}" -H "Content-Type: application/json" localhost:8081/api/bookmarks/
"db5538f4-e2f9-4170-bc38-02af42e6ef59"⏎
$ curl -d "{\"url\":\"https://www.packtpub.com\", \"comment\": \"Great books\"}" -H "Content-Type:       
  application/json" localhost:8081/api/bookmarks/
"5648b8c3-635e-4d55-9592-d6dfab59b32d"⏎
$ curl http://localhost:8081/api/bookmarks/all
[{
    "id": "db5538f4-e2f9-4170-bc38-02af42e6ef59",
    "added": "2019-07-23T10:32:51.020749289+00:00",
    "url": "https://blog.x5ff.xyz",
    "comment": null
 },
 {
    "id": "5648b8c3-635e-4d55-9592-d6dfab59b32d",
    "added": "2019-07-23T10:32:59.899292263+00:00",
    "url": "https://www.packtpub.com",
    "comment": "Great books"
 }]⏎
$ curl http://localhost:8081/api/bookmarks/added_on/2019-07-23/julian
[{
    "id": "db5538f4-e2f9-4170-bc38-02af42e6ef59",
    "url": "https://blog.x5ff.xyz",
    "julian": 2458688.0
},
{
    "id": "5648b8c3-635e-4d55-9592-d6dfab59b32d",
    "url": "https://www.packtpub.com",
    "julian": 2458688.0
}]⏎ 

这里是请求生成的服务器日志,打印到cargo run运行的终端中:

$ cargo run
   Compiling advanced-orm v0.1.0 (Rust-Cookbook/Chapter08/advanced-orm)
 Finished dev [unoptimized + debuginfo] target(s) in 4.75s
 Running `target/debug/advanced-orm`
[2019-07-23T10:32:36Z INFO actix_web::middleware::logger] 127.0.0.1:39962 "GET /api/bookmarks/all HTTP/1.1" 200 2 "-" "curl/7.64.0" 0.004323
[2019-07-23T10:32:51Z INFO actix_web::middleware::logger] 127.0.0.1:40094 "POST /api/bookmarks/ HTTP/1.1" 200 38 "-" "curl/7.64.0" 0.018222
[2019-07-23T10:32:59Z INFO actix_web::middleware::logger] 127.0.0.1:40172 "POST /api/bookmarks/ HTTP/1.1" 200 38 "-" "curl/7.64.0" 0.025890
[2019-07-23T10:33:06Z INFO actix_web::middleware::logger] 127.0.0.1:40226 "GET /api/bookmarks/all HTTP/1.1" 200 287 "-" "curl/7.64.0" 0.001803
[2019-07-23T10:34:18Z INFO actix_web::middleware::logger] 127.0.0.1:40844 "GET /api/bookmarks/added_on/2019-07-23/julian HTTP/1.1" 200 194 "-" "curl/7.64.0" 0.001653

在幕后,有很多事情在进行。让我们找出发生了什么。

它是如何工作的...

使用diesel-rs需要很好地理解其内部工作原理,以实现预期的结果。查看之前的食谱(使用 ORM 将数据保存到数据库中)以了解基础知识的一些细节。在这个食谱中,我们直接深入更高级的内容。

步骤 1的一些基本设置之后,步骤 2创建了一个新的处理程序,该处理程序检索特定日期添加的所有书签,并返回儒略日期(en.wikipedia.org/wiki/Julian_day)。计算是使用 SQLite 的几个标量函数之一:juliandate()www.sqlite.org/lang_datefunc.html)。那么,我们是如何将这个函数引入 Rust 的呢?步骤 4展示了diesel-rs的方式:通过使用sql_function!宏(docs.diesel.rs/diesel/macro.sql_function.html)来适当地映射数据类型和输出。由于我们在这里映射的是一个预存在的函数,因此不需要进一步的操作(这应该与存储过程相同)。

步骤 2涵盖的另一个方面是向多个表插入和从多个表删除,这得益于 SQLite 禁用的引用完整性约束(www.w3resource.com/sql/joins/joining-tables-through-referential-integrity.php)。如果此约束被强制执行,请查看diesel-rs事务(docs.diesel.rs/diesel/connection/trait.Connection.html#method.transaction)。步骤 3继续展示如何检索这些数据——使用左外连接。左连接从左侧(如果连接看起来如下:bookmarks LEFT JOIN comments则为bookmarks)的每一行开始,并尝试与右侧表中的行匹配,这意味着无论是否有评论,我们都会得到每个书签。为了映射这个结果集,我们必须提供一个相应的数据类型来解析,diesel-rs期望这个类型是(Bookmark, Option<Comment>)。由于left_join()调用没有提到要连接哪些列,框架是如何知道的?再次,在步骤 4中,我们通过两个宏声明这两个表为joinablejoinabledocs.diesel.rs/diesel/macro.joinable.html)和allow_tables_to_appear_in_same_querydocs.diesel.rs/diesel/macro.allow_tables_to_appear_in_same_query.html)。结果检索后,我们将它们映射到一个Serializable组合类型,以隐藏从用户那里隐藏这个实现细节。

只有在步骤 4步骤 5中,我们才负责绘制柴油数据库表和行的映射——这里没有什么令人惊讶的。Queryable属性对于diesel-rs将元组映射到类型非常重要——无论实际表是什么。对于更具体的查询,我们也可以直接与元组一起工作。步骤 6负责处理依赖关系。

步骤 7运行服务器,细心的读者会注意到一件事:编译时间比平时长。我们怀疑diesel-rs在幕后做了很多工作,创建了类型安全的代码,以保持动态运行时开销低。然而,这可能会显著影响更大的项目,但一旦编译,类型有助于避免错误并使服务顺利运行。

我们格式化了curl输出,使其更易于阅读,并且输出工作正如预期。serde提供了 JSON 对象的持续序列化和反序列化;因此,输入时comment字段是可选的,但在输出时呈现为null

虽然 diesel-rs 尝试抽象许多数据库操作,但它使用 sql_query 接口(docs.diesel.rs/diesel/fn.sql_query.html)与其他 SQL 语句一起工作。然而,更复杂的分组聚合操作尚不支持——即使在原始 SQL 接口中——这是令人遗憾的。您可以在 GitHub 上跟踪进度(github.com/diesel-rs/diesel/issues/210)。

现在我们对使用 diesel-rs 运行查询有了更多了解,让我们继续下一个菜谱。

网络上的身份验证

在公共接口上安全地运行网络服务本身就是一项挑战,需要考虑很多事情。虽然许多细节属于安全工程师的工作描述,但开发人员应至少遵守一组最佳实践,以便他们能够赢得用户的信任。一开始,是传输加密(TLS),由于反向代理和负载均衡器提供了惊人的简单集成(以及 let's encrypt:letsencrypt.org/ 提供免费证书),我们在这个章节中没有包含任何菜谱。本章重点介绍使用 actix-web 中间件基础设施在应用层通过 JWT (jwt.io/) 验证请求。

准备工作

使用 cargo new authentication 创建一个 Rust 二进制项目,并确保端口 8081 可以从本地主机访问。要访问服务,获取一个程序,如 curl 或 Postman,以执行 POSTGET 和更多类型的网络请求。

最后,使用 VS Code 打开整个目录。

如何做到这一点...

只需几个步骤即可验证用户身份:

  1. src/main.rs 中,我们首先声明所需的导入:
#[macro_use]
extern crate actix_web;
mod middlewares;
use actix_web::{http, middleware, web, App, HttpResponse, HttpServer, Responder};
use jsonwebtoken::{encode, Header};
use middlewares::Claims;
use serde_derive::{Deserialize, Serialize};
use std::env;
  1. 在处理完这些之后,我们可以关注更相关的部分。让我们声明一些用于身份验证和处理程序的基本内容,我们想要访问:
const PASSWORD: &str = "swordfish";
pub const TOKEN_SECRET: &str = "0fd2af6f";

#[derive(Debug, Serialize, Deserialize)]
struct Login {
    password: String,
}

#[get("/secret")]
fn authed() -> impl Responder {
    format!("Congrats, you are authenticated")
}
  1. 接下来,我们需要一个处理程序来登录用户并在他们提供预期的密码时创建令牌,以及设置一切的 main() 函数:
fn login(login: web::Json<Login>) -> HttpResponse {
    // TODO: have a proper security concept
    if &login.password == PASSWORD {
        let claims = Claims {
            user_id: "1".into(),
        };
        encode(&Header::default(), &claims, TOKEN_SECRET.as_ref())
            .map(|token| {
                HttpResponse::Ok()
                    .header(http::header::AUTHORIZATION, format!
                    ("Bearer {}", token))
                    .finish()
            })
            .unwrap_or(HttpResponse::InternalServerError().into())
    } else {
        HttpResponse::Unauthorized().into()
    }
}

fn main() -> std::io::Result<()> {
    env::set_var("RUST_LOG", "actix_web=debug");
    env_logger::init();
    HttpServer::new(|| {
        App::new()
            .wrap(middleware::Logger::default())
            .wrap(middlewares::JwtLogin)
            .service(authed)
            .service(web::resource("/login").route(web::post().to(login)))
    })
    .bind("127.0.0.1:8081")?
    .run()
}
  1. main() 函数中的 wrap() 调用已经透露了一些细节——我们需要中间件来处理身份验证。让我们创建一个新文件,src/middlewares.rs,并包含以下代码:
use actix_service::{Service, Transform};
use actix_web::dev::{ServiceRequest, ServiceResponse};
use actix_web::{http, Error, HttpResponse};
use futures::future::{ok, Either, FutureResult};
use futures::Poll;
use jsonwebtoken::{decode, Validation};
use serde_derive::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize)]
pub struct Claims {
    pub user_id: String,
}

pub struct JwtLogin;

impl<S, B> Transform<S> for JwtLogin
where
    S: Service<Request = ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
    S::Future: 'static,
{
    type Request = ServiceRequest;
    type Response = ServiceResponse<B>;
    type Error = Error;
    type InitError = ();
    type Transform = JwtLoginMiddleware<S>;
    type Future = FutureResult<Self::Transform, Self::InitError>;

    fn new_transform(&self, service: S) -> Self::Future {
        ok(JwtLoginMiddleware { service })
    }
}
  1. 步骤 4 的代码中,我们看到另一个需要实现的 struct:JwtLoginMiddleware。让我们将其添加到 src/middlewares.rs
pub struct JwtLoginMiddleware<S> {
    service: S,
}

impl<S, B> Service for JwtLoginMiddleware<S>
where
    S: Service<Request = ServiceRequest, Response = 
    ServiceResponse<B>, Error = Error>,
    S::Future: 'static,
{
    type Request = ServiceRequest;
    type Response = ServiceResponse<B>;
    type Error = Error;
    type Future = Either<S::Future, FutureResult<Self::Response, 
    Self::Error>>;

    fn poll_ready(&mut self) -> Poll<(), Self::Error> {
        self.service.poll_ready()
    }

最重要的代码可以在调用函数实现中找到,其中请求通过应用中间件(并验证令牌)传递:

    fn call(&mut self, req: ServiceRequest) -> Self::Future {
        if req.path() == "/login" {
            Either::A(self.service.call(req))
        } else {
            if let Some(header_value) = 
            req.headers().get(http::header::AUTHORIZATION) {
                let token = header_value.to_str().unwrap().
                replace("Bearer", "");
                let mut validation = Validation::default();
                validation.validate_exp = false; // our logins don't 
                // expire
                if let Ok(_) =
                    decode::<Claims>(&token.trim(), 
                    crate::TOKEN_SECRET.as_ref(), &validation)
                {
                    Either::A(self.service.call(req))
                } else {
                    Either::B(ok(
                        req.into_response(HttpResponse::Unauthorized()
                        .finish().into_body())
                    ))
                }
            } else {
                Either::B(ok(
                    req.into_response(HttpResponse::Unauthorized().
                    finish().into_body())
                ))
            }
        }
    }
}
  1. 在我们可以运行服务器之前,我们还需要更新 Cargo.toml 以包含当前依赖项:
[dependencies]
actix-web = "1"
serde = "1"
serde_derive = "1"
env_logger = "0.6"
jsonwebtoken = "6"
futures = "0.1"
actix-service = "0.4"
  1. 激动人心的——让我们试试吧!使用 cargo run 启动服务器并发出一些 curl 请求:
$ cargo run
   Compiling authentication v0.1.0 (Rust-Cookbook/Chapter08/authentication)
    Finished dev [unoptimized + debuginfo] target(s) in 6.07s
     Running `target/debug/authentication`
[2019-07-22T21:28:07Z INFO actix_web::middleware::logger] 127.0.0.1:33280 "POST /login HTTP/1.1" 401 0 "-" "curl/7.64.0" 0.009627
[2019-07-22T21:28:13Z INFO actix_web::middleware::logger] 127.0.0.1:33334 "POST /login HTTP/1.1" 200 0 "-" "curl/7.64.0" 0.009191
[2019-07-22T21:28:21Z INFO actix_web::middleware::logger] 127.0.0.1:33404 "GET /secret HTTP/1.1" 200 31 "-" "curl/7.64.0" 0.000784

以下是对每个请求的 curl 输出。首先,未授权的请求:

$ curl -v localhost:8081/secret
* Trying ::1...
* TCP_NODELAY set
* connect to ::1 port 8081 failed: Connection refused
* Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 8081 (#0)
> GET /secret HTTP/1.1
> Host: localhost:8081
> User-Agent: curl/7.64.0
> Accept: */*
> 
< HTTP/1.1 401 Unauthorized
< content-length: 0
< date: Mon, 22 Jul 2019 21:27:48 GMT
< 
* Connection #0 to host localhost left intact

接下来,我们尝试使用无效的密码登录:

$ curl -d "{\"password\":\"a-good-guess\"}" -H "Content-Type: application/json" 
  http://localhost:8081/login -v
* Trying ::1...
* TCP_NODELAY set
* connect to ::1 port 8081 failed: Connection refused
* Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 8081 (#0)
> POST /login HTTP/1.1
> Host: localhost:8081
> User-Agent: curl/7.64.0
> Accept: */*
> Content-Type: application/json
> Content-Length: 27
> 
* upload completely sent off: 27 out of 27 bytes
< HTTP/1.1 401 Unauthorized
< content-length: 0
< date: Mon, 22 Jul 2019 21:28:07 GMT
< 
* Connection #0 to host localhost left intact

然后,我们使用真实密码并接收一个令牌回来:

$ curl -d "{\"password\":\"swordfish\"}" -H "Content-Type: application/json" 
  http://localhost:8081/login -v
* Trying ::1...
* TCP_NODELAY set
* connect to ::1 port 8081 failed: Connection refused
* Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 8081 (#0)
> POST /login HTTP/1.1
> Host: localhost:8081
> User-Agent: curl/7.64.0
> Accept: */*
> Content-Type: application/json
> Content-Length: 24
> 
* upload completely sent off: 24 out of 24 bytes
< HTTP/1.1 200 OK
< content-length: 0
< authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoiMSJ9.V_Po0UCGZqNmbXw0hYozeFLsNpjTZeSh8wcyELavx-c
< date: Mon, 22 Jul 2019 21:28:13 GMT
< 
* Connection #0 to host localhost left intact

authorization 标头 (developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Authorization) 中包含此令牌后,我们就可以访问秘密资源:


$ curl -H "authorization: Bearer 
  eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoiMSJ9.V_Po0UCGZqNmbXw0hYozeFLsNpjTZeSh8wcyELavx-  
  c" http://localhost:8081/secret -v
* Trying ::1...
* TCP_NODELAY set
* connect to ::1 port 8081 failed: Connection refused
* Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 8081 (#0)
> GET /secret HTTP/1.1
> Host: localhost:8081
> User-Agent: curl/7.64.0
> Accept: */*
> authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoiMSJ9.V_Po0UCGZqNmbXw0hYozeFLsNpjTZeSh8wcyELavx-c
> 
< HTTP/1.1 200 OK
< content-length: 31
< content-type: text/plain; charset=utf-8
< date: Mon, 22 Jul 2019 21:28:21 GMT
< 
* Connection #0 to host localhost left intact
Congrats, you are authenticated⏎

让我们揭开面纱,看看它是如何工作的。

它是如何工作的...

JWT 是在 Web 应用程序中提供身份验证和授权的绝佳方式。正如官方网站所示,JWT 由三部分组成:

  • 标题,提供关于标记的元信息

  • 其负载,即信息被发送的地方(JSON 序列化)

  • 一个签名来保证令牌在传输过程中未被更改

这些部分是 Base64 编码的,并用 . 连接成一个字符串。这个字符串被放入 HTTP 请求的 authorization 标头 (developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Authorization) 中。一个重要的注意事项是,对于这种身份验证,TLS 是强制性的,因为标头以及所有其他内容都是以明文发送的——每个人都能看到令牌。

负载数据可以包含您希望携带来回的用户信息。然而,也有一些特殊字段:isssubexpiss 提供了发行者的凭证(以任何方式),sub 是主题,而 exp 是过期时间戳。这是因为 JWT 可以用于通过联盟进行身份验证,即第三方服务。对于此实现,我们使用一个名为 jsonwebtoken 的 crate (github.com/Keats/jsonwebtoken)。

步骤 1 中,我们只是设置导入——这里没有什么特别之处。只有 步骤 2 提供了一些有趣的内容:一个硬编码的密码(不良的安全实践,但足以用于演示)以及一个硬编码的秘密(也是 不良)。真实的应用程序可以使用秘密存储库来存储秘密(例如,Azure Key Vault:azure.microsoft.com/en-in/services/key-vault/),并在数据库中存储密码的散列。在相同的步骤中,我们还声明了登录的输入数据结构——我们只关心密码——以及路径/秘密的处理程序,它应该在登录后才能工作。

以下步骤创建登录处理程序:如果密码匹配,处理程序将创建一个新的包含有效载荷数据(一个名为 Claims 的结构体)和用于签名令牌的 HMAC(默认为 HS256)算法的令牌,并将其返回。然后,处理程序与 App 实例一起注册,包括在以下步骤中实现的新的 JWT 认证中间件。

第 4 步第 5 步 负责创建用于验证 JWT 令牌的中间件。第 4 步 包含之前提到的 Claims 类型;然而,如果请求和响应类型保持默认,则其余代码主要是必需的样板代码。如果我们想检索用户信息传递给处理程序,我们将考虑定义自定义请求。只有在 第 5 步 中,我们才实现重要的部分:call() 函数。这个函数在每次请求处理之前被调用,并决定是否继续或停止传播它。显然,/login 路由是例外,总是会传递给处理程序。

每个其他路由都必须包含一个名为 authorization 的头字段和一个名为 Bearer 的类型,以及令牌,例如,(截断)authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJ[...]8wcyELavx-ccall() 函数提取令牌并尝试使用其密钥对其进行解码。如果成功,则将调用传递给处理程序;如果不成功,则用户显然无权访问资源——如果没有授权头也会发生这种情况。jsonwebtoken 默认验证 exp 字段(我们的 Claims 类型没有这个),这就是我们在这个示例中关闭的原因。为了简洁起见,我们在解析头字节的字符串时使用了 unwrap()。然而,如果遇到未知字节,这可能会使线程崩溃。

这里返回的类型是从 futures 库(docs.rs/futures/)导入的,并提供 Either 类型(docs.rs/futures/0.1.28/futures/future/enum.Either.html)以及 ok() 函数(docs.rs/futures/0.1.28/futures/future/fn.ok.html)。查看它们的文档以了解更多信息。

第 6 步 仅声明额外的依赖项,而在 第 7 步 中,我们可以运行服务器!首先检查 curl 请求——你能看到什么问题吗?在记录发生之前,阻止未经授权的请求。此外,我们已用粗体标记了重要部分。

这就结束了这一章。我们希望您喜欢网络编程食谱。下一章将涵盖更接近底层的内容:系统编程。

第九章:系统编程变得简单

Rust 最初被设想为与 C(以及可能 C++)一样的系统编程语言。尽管其吸引力导致了该领域之外的重大增长(有点像 C/C++),但仍有许多特性显著地简化了低级项目的工作。我们怀疑新颖性方面(以及强大的编译器、错误信息和社区)导致了该领域非常有趣的项目——例如操作系统。其中之一是 intermezzOS(intermezzos.github.io/),一个用于学习编程(使用 Rust)的操作系统;另一个是 Redox OS(www.redox-os.org/),一个纯 Rust 的微内核项目。然而,这并没有结束——Rust 嵌入式工作组已经编译了一份资源列表,并在他们的 GitHub 上突出显示了一些项目(github.com/rust-embedded/awesome-embedded-rust)。

Linux 是嵌入式设备最广泛采用的操作系统,但我们试图展示这些原则,而无需您运行 Linux。为了完全实现,例如,I2C 设备驱动程序,macOS 和 Windows 用户可以使用带有 Hyper-V(docs.microsoft.com/en-us/virtualization/hyper-v-on-windows/)、VirtualBox([www.virtualbox.org/](https://www.virtualbox.org/))或 Parallels([https://www.parallels.com/](https://www.parallels.com/))的虚拟机,或者租用云上的机器([https://azure.microsoft.com/en-us/](https://azure.microsoft.com/en-us/))。除了第一个菜谱外,本章中的菜谱可以在各种操作系统上运行。

这个列表真正令人惊叹,我们的目标是让您能够开始构建嵌入式驱动程序并将它们跨编译到各种 CPU 架构。考虑到这一点,本章涵盖了以下主题:

  • 跨平台编译 Rust

  • 实现设备驱动程序

  • 从这些驱动程序中读取

跨平台编译 Rust

意想不到的是,实现低级项目更具挑战性的方面之一是跨编译。多亏了其 LLVM 基础,rustc为不同的 CPU 架构提供了大量的工具链。然而,跨编译一个应用程序意味着它的(本地)依赖也必须适用于这个 CPU 架构。这对于小型项目来说是一个挑战,因为它需要在架构之间进行大量的版本管理,并且随着每个新要求的增加而变得越来越复杂。这就是为什么已经出现了几个与这个问题相关的工具。在本菜谱中,我们将探索一些工具并学习如何使用它们。

准备工作

这个菜谱非常特定于平台;在撰写本文时,在 Linux 之外的平台交叉编译 Rust 是一项棘手的工作。在 macOS 和 Windows 上,你可以使用带有 Hyper-V (https://docs.microsoft.com/en-us/virtualization/hyper-v-on-windows/)、VirtualBox (www.virtualbox.org/) 或 Parallels (www.parallels.com/) 的虚拟机,或者从你最喜欢的云服务提供商那里租用一台机器(azure.microsoft.com/en-us/)。

Windows 10 上的 Windows 子系统 for LinuxWSL)在撰写本文时不支持 Docker。可能存在绕过此限制的方法,但我们将把所需的调整留给读者。如果你找到一个解决方案,请确保在我们的 GitHub 仓库(github.com/PacktPublishing/Rust-Programming-Cookbook)上分享它。

然后,安装 Docker (docs.docker.com/install/) 并确保你可以不使用 sudo (docs.docker.com/install/linux/linux-postinstall/) 来继续。

如何做到这一点...

在 Docker 可用的情况下,按照以下步骤进行交叉编译到多个目标:

  1. 使用 cargo new cross-compile 创建一个用于二进制可执行文件的项目,并使用 VS Code 打开文件夹。

  2. 打开 src/main.rs 并将默认内容替换为以下内容:

#[cfg(target_arch = "x86")]
const ARCH: &str = "x86";

#[cfg(target_arch = "x86_64")]
const ARCH: &str = "x64";

#[cfg(target_arch = "mips")]
const ARCH: &str = "mips";

#[cfg(target_arch = "powerpc")]
const ARCH: &str = "powerpc";

#[cfg(target_arch = "powerpc64")]
const ARCH: &str = "powerpc64";

#[cfg(target_arch = "arm")]
const ARCH: &str = "ARM";

#[cfg(target_arch = "aarch64")]
const ARCH: &str = "ARM64";

fn main() {
    println!("Hello, world!");
    println!("Compiled for {}", ARCH);
}
  1. 使用 cargo run 来查看它是否工作以及你所在的架构:
$ cargo run
   Compiling cross-compile v0.1.0 (Rust-Cookbook/Chapter09/cross-
   compile)
   Finished dev [unoptimized + debuginfo] target(s) in 0.25s
   Running `target/debug/cross-compile`
   Hello, world!
   Compiled for x64
  1. 让我们进行一些交叉编译。首先,使用 cargo install cross 安装一个名为 cross 的工具:
$ cargo install cross 
   Updating crates.io index
   Installing cross v0.1.14
   Compiling libc v0.2.60
   Compiling cc v1.0.38
   Compiling cfg-if v0.1.9
   Compiling rustc-demangle v0.1.15
   Compiling semver-parser v0.7.0
   Compiling rustc-serialize v0.3.24
   Compiling cross v0.1.14
   Compiling lazy_static v0.2.11
   Compiling semver v0.9.0
   Compiling semver v0.6.0
   Compiling rustc_version v0.2.3
   Compiling backtrace-sys v0.1.31
   Compiling toml v0.2.1
   Compiling backtrace v0.3.33
   Compiling error-chain v0.7.2
    Finished release [optimized] target(s) in 15.64s
   Replacing ~/.cargo/bin/cross
    Replaced package `cross v0.1.14` with `cross v0.1.14` 
    (executable `cross`)
$ cross --version
cross 0.1.14
cargo 1.36.0 (c4fcfb725 2019-05-15)
  1. rust-cross (github.com/rust-embedded/cross) 仓库中所述,启动 Docker 守护进程以运行 ARMv7 的交叉构建:
$ sudo systemctl start docker
$ cross build --target armv7-unknown-linux-gnueabihf -v
+ "rustup" "target" "list"
+ "cargo" "fetch" "--manifest-path" "/home/cm/workspace/Mine/Rust-Cookbook/Chapter09/cross-compile/Cargo.toml"
+ "rustc" "--print" "sysroot"
+ "docker" "run" "--userns" "host" "--rm" "--user" "1000:1000" "-e" "CARGO_HOME=/cargo" "-e" "CARGO_TARGET_DIR=/target" "-e" "USER=cm" "-e" "XARGO_HOME=/xargo" "-v" "/home/cm/.xargo:/xargo" "-v" "/home/cm/.cargo:/cargo" "-v" "/home/cm/workspace/Mine/Rust-Cookbook/Chapter09/cross-compile:/project:ro" "-v" "/home/cm/.rustup/toolchains/stable-x86_64-unknown-linux-gnu:/rust:ro" "-v" "/home/cm/workspace/Mine/Rust-Cookbook/Chapter09/cross-compile/target:/target" "-w" "/project" "-it" "japaric/armv7-unknown-linux-gnueabihf:v0.1.14" "sh" "-c" "PATH=$PATH:/rust/bin \"cargo\" \"build\" \"--target\" \"armv7-unknown-linux-gnueabihf\" \"-v\""
   Compiling cross-compile v0.1.0 (/project)
     Running `rustc --edition=2018 --crate-name cross_compile src/main.rs --color always --crate-type bin --emit=dep-info,link -C debuginfo=2 -C metadata=a41129d8970184cc -C extra-filename=-a41129d8970184cc --out-dir /target/armv7-unknown-linux-gnueabihf/debug/deps --target armv7-unknown-linux-gnueabihf -C linker=arm-linux-gnueabihf-gcc -C incremental=/target/armv7-unknown-linux-gnueabihf/debug/incremental -L dependency=/target/armv7-unknown-linux-gnueabihf/debug/deps -L dependency=/target/debug/deps`
    Finished dev [unoptimized + debuginfo] target(s) in 0.25s
  1. 如果你有一台 Raspberry Pi 2(或更高版本),你可以在那里运行二进制文件:
$ scp target/armv7-unknown-linux-gnueabihf/debug/cross-compile alarm@10.0.0.171:~
cross-compile 100% 2410KB 10.5MB/s 00:00
$ ssh alarm@10.0.0.171
Welcome to Arch Linux ARM

     Website: http://archlinuxarm.org
       Forum: http://archlinuxarm.org/forum
         IRC: #archlinux-arm on irc.Freenode.net
Last login: Sun Jul 28 09:07:57 2019 from 10.0.0.46
$ ./cross-compile 
Hello, world!
Compiled for ARM

那么,rust-cross 是如何编译代码的?为什么使用 Docker?让我们看看它是如何工作的。

它是如何工作的...

在这个菜谱中,我们正在创建一个简单的二进制文件(步骤 1步骤 2),它具有条件编译以匹配目标架构,以便查看它是否工作。步骤 3 应该显示你的架构(通常是 x64x86_64);我们在 步骤 4 中安装交叉编译工具包,尝试在 Raspberry Pi 2 及以上版本上运行(步骤 5)。编译二进制文件后,我们将它传输到设备(ARM 二进制文件在 x86_64 指令集上无法运行)以执行(步骤 6)。

QEMU,一个流行的虚拟化框架,也支持模拟 ARM 指令,因此不需要严格的要求。查看他们的维基百科(wiki.qemu.org/Documentation/Platforms/ARM)以了解更多信息。

如果你感兴趣了解更多关于交叉编译应用程序的细节,请继续阅读。如果不感兴趣,请随意跳到下一个菜谱。

还有更多...

交叉编译是一个非常具体的过程,其中以下所有内容都必须匹配:

  • CPU 指令集,即汇编指令

  • 用于链接的兼容库(例如,标准库)

  • 二进制布局

  • 兼容的工具链(编译器、链接器)

多亏了 LLVM 的架构和 GNU 编译器集合,我们不必过多担心 CPU 指令集,因为它大部分默认提供,这也是为什么它在 Windows 上运行有些棘手的原因。正如我们在第七章中看到的许多配方中,将 Rust 与其他语言集成,Windows 和 macOS 使用不同的工具链,这使得为其他 CPU 指令集编译变得更加困难。我们的感觉是,如今在虚拟化环境中工作比在本地设置一切要容易得多。

如果你使用 Fedora 或任何其他启用了 SELinux 的分发版,交叉构建可能会因为权限错误而失败。目前,解决方案是禁用 SELinux(sudo setenforce 0),但修复正在进行中 (github.com/rust-embedded/cross/issues/112))。

考虑到目标工具链,rustup 允许我们快速安装其他目标(例如,rustup target add armv7-unknown-linux-gnueabihf),然而一些其他方面(例如,C 标准库 (www.gnu.org/software/libc/)))仍然需要本地安装。随着可用目标数量的增加,管理本地库的数量将变成一项全职工作(在这里我们完全不考虑各种库版本)。

为了控制这些依赖项、版本以及更多内容,rust-cross (github.com/rust-embedded/cross#usage)(以及其他 (github.com/dlecan/rust-crosscompiler-arm))),使用预先准备了一套基本库的 Docker 容器。通常,这些容器可以定制 (github.com/rust-embedded/cross#custom-docker-images),以添加任何你为你的用例所需的证书、配置、库等。

拥有这些知识后,我们可以继续下一个配方。

创建 I2C 设备驱动程序

在 Linux 中与设备通信发生在不同的层级。驱动程序最基本的一层是内核模块。这些模块除了其他功能外,对操作系统拥有无限制的访问权限,并在必要时通过如块设备等接口为用户提供访问权限。这就是 I2C (learn.sparkfun.com/tutorials/i2c/all) 驱动程序提供 as /dev/i2c-1 总线(例如)的地方,您可以向其写入和从中读取。使用 Rust,我们可以使用这个接口为连接到该总线的传感器设备创建一个驱动程序。让我们看看它是如何工作的。

如何做到这一点...

设备驱动程序可以通过几个步骤实现:

  1. 创建一个二进制项目:cargo new i2cdevice-drivers

  2. 在 VS Code 中打开文件夹,并将一些代码添加到 src/main.rs 文件中:

mod sensor;

use sensor::{Bmx42Device, RawI2CDeviceMock, Thermometer};
use std::thread::sleep;
use std::time::Duration;

fn main() {
    let mut device = Bmx42Device::new(RawI2CDeviceMock::
     new("/dev/i2c-1".into(), 0x5f)).unwrap();
    let pause = Duration::from_secs(1);
    loop {
        println!("Current temperature {} °C", 
         device.temp_celsius().unwrap());
        sleep(pause);
    }
}
  1. 接下来,我们将实现实际的传感器驱动程序。创建一个名为 src/sensor.rs 的文件来实现传感器驱动程序的各个方面。让我们先设置一些基本内容:
use std::io;
use rand::prelude::*;

pub trait Thermometer {
    fn temp_celsius(&mut self) -> Result<f32>;
}

type Result<T> = std::result::Result<T, io::Error>;
  1. 现在,我们添加一个模拟设备来表示总线系统:
#[allow(dead_code)]
pub struct RawI2CDeviceMock {
    path: String,
    device_id: u8,
}

impl RawI2CDeviceMock {
    pub fn new(path: String, device_id: u8) -> RawI2CDeviceMock {
        RawI2CDeviceMock {
            path: path,
            device_id: device_id,
        }
    }

    pub fn read(&self, register: u8) -> Result<u8> {
        let register = register as usize;
        if register == Register::Calib0 as usize {
            Ok(1_u8)
        } else { // register is the data register
            Ok(random::<u8>())
        }
    }
}
  1. 接下来,我们实现用户实际看到的传感器代码:
enum Register {
    Calib0 = 0x00,
    Data = 0x01,
}

pub struct Bmx42Device {
    raw: RawI2CDeviceMock,
    calibration: u8,
}

impl Bmx42Device {
    pub fn new(device: RawI2CDeviceMock) -> Result<Bmx42Device> {
        let calib = device.read(Register::Calib0 as u8)?;
        Ok(Bmx42Device {
            raw: device,
            calibration: calib
        })
    }
}
  1. 为了将传感器的行为封装到适当的函数中,让我们实现我们在 sensor.rs 顶部创建的 Thermometer 特性。将原始数据转换为可用的温度通常在手册或技术规范中说明:
impl Thermometer for Bmx42Device {
    fn temp_celsius(&mut self) -> Result<f32> {
        let raw_temp = self.raw.read(Register::Data as u8)?;
        Ok(((raw_temp as i8) << (self.calibration as i8)) as f32 / 
        10.0)
    }
}
  1. 我们还需要调整 Cargo.toml 配置以添加随机数生成器包:
[dependencies]
rand = "0.5"
  1. 如同往常,我们想看到程序的实际运行情况。使用 cargo run 来查看它打印出我们假装的温度(通过按 Ctrl + C 停止):
$  cargo run
   Compiling libc v0.2.60
   Compiling rand_core v0.4.0
   Compiling rand_core v0.3.1
   Compiling rand v0.5.6
   Compiling i2cdevice-drivers v0.1.0 (Rust-
   Cookbook/Chapter09/i2cdevice-drivers)
    Finished dev [unoptimized + debuginfo] target(s) in 2.95s
     Running `target/debug/i2cdevice-drivers`
Current temperature -9.4 °C
Current temperature 0.8 °C
Current temperature -1.2 °C
Current temperature 4 °C
Current temperature -3 °C
Current temperature 0.4 °C
Current temperature 5.4 °C
Current temperature 11.6 °C
Current temperature -5.8 °C
Current temperature 0.6 °C
^C⏎

实现之后,你可能想知道为什么以及它是如何工作的。让我们看看。

它是如何工作的...

在这个菜谱中,我们展示了如何实现一个在总线(如 I2C (learn.sparkfun.com/tutorials/i2c/all))上可用的非常简单的设备驱动程序。由于 I2C 是一个相对复杂的总线(这使得实现驱动程序变得简单),驱动程序实现了一个用于读取和写入操作的协议,并将它们封装在一个良好的 API 中。在这个菜谱中,我们没有实际使用 I2C 总线包来提供 struct 设备,因为这会影响操作系统兼容性。

步骤 2 中,我们创建主循环以非常简单的方式从传感器读取(检查 高效读取硬件传感器 菜谱),使用睡眠来控制读取速度。按照典型方式,我们通过创建使用 *nix 路径(/dev/i2c-1)和设备的硬件地址(由制造商定义)的块设备抽象来实例化驱动程序。

步骤 3 中,我们添加了一些构造来使我们的生活更轻松和更有结构:如果传感器上有更多设备或功能,Thermometer 特性是打包能力的良好实践。抽象 Result 是减少代码冗余的常见策略。

只有在第 4 步中,我们为总线创建了一个模拟,提供了对单个字节的读写功能。由于我们实际上并没有从总线读取或写入,这些函数读取随机数并将它们写入无处。为了了解这在现实生活中是如何完成的(例如,一次读取多个字节),请查看真实的i2cdev crate (github.com/rust-embedded/rust-i2cdev))。到目前为止,我们只让它在工作在 Linux 上,然而。

第 5 步创建抽象 API。每次我们从零开始实现驱动程序时,我们都是通过将特定的二进制命令写入预定义的寄存器来与设备通信。这可能是为了改变设备的状态,改变采样率,或者请求特定的测量(如果设备有多个传感器,并在实际设备上触发硬件过程)。在这项写入操作之后,我们可以读取指定的数据寄存器(所有地址和值都可以在设备的规范中找到),将值转换为可用的东西(如°C)。这涉及到像移位位、读取几个校准寄存器以及乘以溢出等事情。这样的过程因传感器而异。对于现实生活中的例子,请查看bmp085设备驱动程序(github.com/celaus/rust-bmp085),它展示了在 Rust 中的实际驱动程序实现,并观看以下 URL 上的驱动程序演讲:www.youtube.com/watch?v=VMaKQ8_y_6s

接下来的步骤展示了如何实现并从设备获取实际温度,并从原始设备模拟提供的随机数创建一个可用的数字。这应该是对通常如何使用原始值以将其转换为可用形式的简化。

在最后一步,我们看到它是如何工作的,并验证温度在现实值中通常分布得很好,尽管变化率令人恐惧。

让我们继续前进,看看我们如何能比使用纯循环更有效地读取这些传感器值。

高效读取硬件传感器

创建基于 I/O 的应用程序很棘手——它们必须尽可能快地、尽可能频繁地提供对资源的独占访问。这是一个资源调度问题。解决这类问题的基本方法是处理和排队请求,就像读取传感器值一样。

如何做到这一点...

您可以使用 I/O 循环在几个步骤中高效地读取事物:

  1. 创建一个二进制项目:cargo new reading-hardware

  2. 在 VS Code 中打开文件夹,创建一个src/sensor.rs文件来添加来自创建 I2C 设备驱动程序菜谱的代码:

use std::io;
use rand::prelude::*;

type Result<T> = std::result::Result<T, io::Error>;

pub trait Thermometer {
    fn temp_celsius(&mut self) -> Result<f32>;
}

enum Register {
    Calib0 = 0x00,
    Data = 0x01,
}
  1. 通常,原始设备抽象由硬件协议驱动程序提供。在我们的例子中,我们模拟了这样一个类型:
#[allow(dead_code)]
pub struct RawI2CDeviceMock {
    path: String,
    device_id: u8,
}

impl RawI2CDeviceMock {
    pub fn new(path: String, device_id: u8) -> RawI2CDeviceMock {
        RawI2CDeviceMock {
            path: path,
            device_id: device_id,
        }
    }

    pub fn read(&self, register: u8) -> Result<u8> {
        let register = register as usize;
        if register == Register::Calib0 as usize {
            Ok(1_u8)
        } else { // register is the data register
            Ok(random::<u8>())
        }
    }
}
  1. 为了适当的封装,创建一个struct来包装原始设备是个好主意:
pub struct Bmx42Device {
    raw: RawI2CDeviceMock,
    calibration: u8,
}

impl Bmx42Device {
    pub fn new(device: RawI2CDeviceMock) -> Result<Bmx42Device> {
        let calib = device.read(Register::Calib0 as u8)?;
        Ok(Bmx42Device {
            raw: device,
            calibration: calib
        })
    }
}
  1. 以下是对 Thermometer 特性的实现:
impl Thermometer for Bmx42Device {
    fn temp_celsius(&mut self) -> Result<f32> {
        let raw_temp = self.raw.read(Register::Data as u8)?;
        // converts the result into something usable; from the 
        // specification
        Ok(((raw_temp as i8) << (self.calibration as i8)) as f32 / 
         10.0)
    }
}
  1. 现在打开 src/main.rs 并将默认内容替换为更有趣的内容。让我们从导入和辅助函数开始:
mod sensor;
use tokio::prelude::*;
use tokio::timer::Interval;

use sensor::{Bmx42Device, RawI2CDeviceMock, Thermometer};
use std::time::{Duration, UNIX_EPOCH, SystemTime, Instant};
use std::thread;

use std::sync::mpsc::channel;

fn current_timestamp_ms() -> u64 {
    SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs()
}

#[derive(Debug)]
struct Reading {
    timestamp: u64,
    value: f32
}
  1. 接下来,我们将添加实际的事件循环和 main 函数:

fn main() {
    let mut device = Bmx42Device::new(RawI2CDeviceMock
     ::new("/dev/i2c-1".into(), 0x5f)).unwrap();

    let (sx, rx) = channel();

    thread::spawn(move || {
        while let Ok(reading) = rx.recv() {

            // or batch and save/send to somewhere
            println!("{:?}", reading);
        }
    });
    let task = Interval::new(Instant::now(), Duration
     ::from_secs(1))
        .take(5)
        .for_each(move |_instant| {
            let sx = sx.clone();
            let temp = device.temp_celsius().unwrap();
            let _ = sx.send(Reading {
                timestamp: current_timestamp_ms(),
                value: temp
            });
            Ok(())
        })
        .map_err(|e| panic!("interval errored; err={:?}", e));

    tokio::run(task);
}
  1. 为了使这成为可能,我们应该在 Cargo.toml 中添加一些依赖项:
[dependencies]
tokio = "0.1"
tokio-timer = "0.2"
rand = "0.5"
  1. 为了完成菜谱,我们还希望看到它运行并打印出一些模拟的读数:
$ cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.04s
     Running `target/debug/reading-hardware`
Reading { timestamp: 1564762734, value: -2.6 }
Reading { timestamp: 1564762735, value: 6.6 }
Reading { timestamp: 1564762736, value: -3.8 }
Reading { timestamp: 1564762737, value: 11.2 }
Reading { timestamp: 1564762738, value: 2.4 }

太好了!让我们看看这是如何工作的。

它是如何工作的...

与我们在 创建 I2C 设备驱动程序 菜谱中创建的简单忙等待循环不同,我们现在使用一个 tokio-rs 事件流(实际上是一个异步迭代器),我们可以在其上注册一个处理程序。让我们看看这个更有效的结构是如何实现的。

首先,在 步骤 2 中,我们重新创建了 创建 I2C 设备驱动程序 菜谱中的传感器代码,以便有一个可用的传感器。简而言之,该代码通过随机数生成器模拟了一个连接到 I2C 的温度传感器,以展示总线连接的设备驱动程序是如何工作的。

步骤 3 中,我们正在准备使用驱动程序读取一个值并将其通过通道发送到工作线程。因此,我们创建了一个 Reading 结构体,它保存了在某个时间戳的传感器读数。只有在 步骤 4 中,我们才创建 tokio-rs 任务运行器和流。这个流是一个表示需要处理的异步事件的迭代器的构造。每个事件对应于每秒一个定时间隔,从现在开始(Instant::now()),由于我们不想在这个菜谱中无限期运行,所以我们限制事件的数量为五个(.take(5))——就像我们处理任何其他迭代器一样。tokio::run() 接收这个流,并在其事件循环和线程池上开始执行事件,并在有事件要执行时阻塞。

在并发应用程序中,使用类似 std::thread::sleep 的做法被认为是一种反模式。为什么?因为它阻止了整个线程在睡眠期间做任何事。实际上,线程暂停,操作系统的 CPU 调度器会进行上下文切换以执行其他任务。只有在指定时间至少过去之后,调度器才会将线程转回活动模式以继续工作。驱动程序有时需要一些等待时间(几毫秒进行测量),这时通常会使用 sleep。由于设备只能从单个线程访问,因此在这里使用 sleep() 是合适的。

for_each闭包实现了每个事件的处理器,并接收一个Instant实例作为参数。在闭包内部,我们从传感器读取数据并通过通道(doc.rust-lang.org/std/sync/mpsc/)发送到我们之前创建的接收线程——这是我们在第四章中看到的模式,“无畏并发”。虽然我们可以在处理器中立即处理数据,但将其推入队列进行处理使我们能够创建批次并最小化流延迟。这在需要完成处理所需时间未知、非常大(即包括 Web 请求或其他移动部件)或需要大量错误处理(如指数退避(docs.microsoft.com/en-us/azure/architecture/patterns/retry))的情况下尤为重要。这不仅可以将关注点分离并简化维护,还允许我们更精确地执行读取操作。为了可视化这一点,让我们看看步骤 4的整体图景:

图片

步骤 5中,我们添加所需的依赖项,而步骤 6显示了输出——注意时间戳以确认它确实每秒触发一次,并且流按出现的顺序进行处理。

这标志着我们对设备驱动程序的深入探索的结束;如果这是你第一次涉足这个领域,你现在已经了解了如何解耦读取传感器数据与处理它;设备驱动程序最初是如何构建的;一旦准备就绪,如何将它们部署到目标设备上。在下一章中,我们将回到更高的抽象层次,并专注于更实用的食谱。

第十章:Rust 的实际应用

即使已经学习了 Rust 的九个章节,我们仍然缺少使应用程序易于使用的部分。Rust 生态系统中的许多 crate 在不同的领域提供了重要的功能,并且根据应用程序类型,您可能需要几个额外的 crate。在本章中,我们将查看 Rust 标准库和公共 crate 仓库中的各种部分,以使我们的应用程序开发更快、更简单,并且在一般情况下更高效。尽管本章重点在于命令行应用程序,但我们认为其中许多配方同样适用于其他类型,如 Web 服务器或共享实用库。您可以期待学习如何创建与操作系统良好集成的可使用 Rust 程序,并以用户熟悉和期望的方式运行。此外,我们还为希望使用 Rust 进行工作的机器学习爱好者添加了一个配方。

这是我们将涵盖的完整列表:

  • 随机数生成

  • 文件 I/O

  • 动态 JSON

  • 正则表达式

  • 文件系统访问

  • 命令行参数

  • 管道输入和输出

  • 网络请求

  • 使用最先进的机器学习库

  • 记录日志

  • 启动子进程

生成随机数

随机数生成是一种基本技术,我们每天都在使用——加密、模拟、近似、测试、数据选择等。每个应用都有其随机数生成器的特定要求(xkcd.com/221/)。虽然加密需要一个尽可能接近真实随机的生成器(www.random.org/),但模拟、测试和数据选择可能需要从某个分布中抽取可重复的样本。

由于打印限制,我们不得不将原始表情符号替换为字符和数字。查看此书的 GitHub 仓库以获取完整版本。

由于 Rust 标准库中没有随机生成器,对于许多项目来说,rand包是最佳选择。让我们看看如何使用它。

如何操作...

我们可以在几个步骤中获取随机性:

  1. 打开终端,使用cargo new random-numbers --lib创建一个新的项目。使用 VS Code 打开项目目录。

  2. 首先,我们需要在Cargo.toml中将rand包添加为依赖项。打开它并添加以下内容:

[dependencies]
rand = {version = "0.7", features = ["small_rng"]}
rand_distr = "0.2"
rand_pcg = "0.2"
  1. 由于我们正在探索如何使用rand库,我们将向测试模块添加并实现三个测试。让我们首先将src/lib.rs中的默认内容替换为一些必需的导入:
#[cfg(test)]
mod tests {
    use rand::prelude::*;
    use rand::SeedableRng;
    use rand_distr::{Bernoulli, Distribution, Normal, Uniform};
}
  1. 在导入下面(在mod tests作用域内),我们将添加第一个测试来检查随机数生成器RNGs)和伪随机数生成器PRNGs)的工作原理。为了获得可预测的随机数,我们使每个生成器基于第一个,它使用数组字面量进行初始化:
    #[test]
    fn test_rngs() {
        let mut rng: StdRng = SeedableRng::from_seed([42;32]);
        assert_eq!(rng.gen::<u8>(), 152);

        let mut small_rng = SmallRng::from_rng(&mut rng).unwrap();
        assert_eq!(small_rng.gen::<u8>(), 174);

        let mut pcg = rand_pcg::Pcg32::from_rng(&mut rng).unwrap();
        assert_eq!(pcg.gen::<u8>(), 135);
    }
  1. 在了解了常规(P)RNGs 之后,我们可以继续到更复杂的内容。我们是否可以使用这些 RNGs 来操作序列?让我们添加这个测试,它使用伪随机数生成器(PRNGs)来进行洗牌并选择结果:
    #[test]
    fn test_sequences() {
        let mut rng: StdRng = SeedableRng::from_seed([42;32]);

        let emoji = "ABCDEF".chars();
        let chosen_one = emoji.clone().choose(&mut rng).unwrap();
        assert_eq!(chosen_one, 'B');

        let chosen = emoji.choose_multiple(&mut rng, 3);
        assert_eq!(chosen, ['F', 'B', 'E']);

        let mut three_wise_monkeys = vec!['1', '2', '3'];
        three_wise_monkeys.shuffle(&mut rng);
        three_wise_monkeys.shuffle(&mut rng);
        assert_eq!(three_wise_monkeys, ['1', '3', '2']);

        let mut three_wise_monkeys = vec!['1', '2', '3'];
        let partial = three_wise_monkeys.partial_shuffle(&mut rng, 2); 
        assert_eq!(partial.0, ['3', '2']);
    }
  1. 如我们在本食谱的介绍中所述,随机数生成器(RNGs)可以遵循一个分布。现在,让我们向测试模块添加另一个测试,使用rand crate 来绘制遵循分布的随机数:
    const SAMPLES: usize = 10_000;

    #[test]
    fn test_distributions() {
        let mut rng: StdRng = SeedableRng::from_seed([42;32]);

        let uniform = Uniform::new_inclusive(1, 100);
        let total_uniform: u32 = uniform.sample_iter(&mut rng)
                                        .take(SAMPLES).sum();
        assert!((50.0 - (total_uniform as f32 / (
                 SAMPLES as f32)).round()).abs() <= 2.0);

        let bernoulli = Bernoulli::new(0.8).unwrap();
        let total_bernoulli: usize = bernoulli
            .sample_iter(&mut rng)
            .take(SAMPLES)
            .filter(|s| *s)
            .count();

        assert_eq!(
            ((total_bernoulli as f32 / SAMPLES as f32) * 10.0)
                .round()
                .trunc(),
            8.0
        );

        let normal = Normal::new(2.0, 0.5).unwrap();
        let total_normal: f32 = normal.sample_iter(&mut rng)
                                      .take(SAMPLES).sum();
        assert_eq!((total_normal / (SAMPLES as f32)).round(), 2.0);
    }
  1. 最后,我们可以运行测试以查看测试输出是否为正结果:
$ cargo test
 Compiling random-numbers v0.1.0 (Rust-Cookbook/Chapter10/random-numbers)
 Finished dev [unoptimized + debuginfo] target(s) in 0.56s
     Running target/debug/deps/random_numbers-df3e1bbb371b7353

running 3 tests
test tests::test_sequences ... ok
test tests::test_rngs ... ok
test tests::test_distributions ... ok

test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

   Doc-tests random-numbers

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

让我们看看幕后是如何操作的。

它是如何工作的……

自 2018 年以来,rand crate 经历了几个主要版本的修订,并且有几件事情发生了变化。特别是,crate 现在组织方式不同(rust-random.github.io/book/guide-gen.html),包含几个伴随 crate,这些 crate 包含对较少使用部分的实现。

这就是为什么在步骤 2中,我们不仅仅导入一个 crate,尽管它们都共享一个 GitHub 仓库(github.com/rust-random/rand)。这种分割的原因可能是为了与该领域的不同要求兼容。

简而言之,RNGs 代表的是一个基于其前驱动态确定的数字序列。那么第一个数字是什么呢?它被称为种子,可以是某个字面量(用于测试的可重复性)或者尽可能接近真正的随机性(当不进行测试时)。

流行的种子包括自 1970 年 1 月 1 日以来的秒数、操作系统的熵、用户输入等等。它越不可预测,就越好。

步骤 3中,我们使用一些立即在步骤 4中使用的导入设置剩余的代码。在那里,我们开始使用不同类型的 RNGs(rust-random.github.io/book/guide-rngs.html)。第一个是rand crate 的StdRng,它是对(截至本文写作时)ChaCha PRNG 的抽象,出于效率和加密安全性的考虑而选择。第二个算法是 SmallRng(docs.rs/rand/0.7.0/rand/rngs/struct.SmallRng.html),这是rand团队选择的一个 PRNG,具有很高的吞吐量和资源效率。然而,由于它很容易预测,必须仔细选择使用场景。最后一个算法(Pcg32)是从可用的 PRNG 列表中挑选的(rust-random.github.io/book/guide-rngs.html),它是作为不同 crate 的一部分提供的。

步骤5 中,我们处理序列并从中选择或打乱顺序。函数包括部分打乱(即,随机选择一个子集)和原地全打乱,以及从列表中选择一个或多个元素的随机选择。请注意,这些操作的特性是以一种不依赖于实际使用的随机数生成器的方式实现的。这提供了一个非常灵活且易于使用的 API。

只有在步骤 6中,我们才能得到遵循分布的随机数。这些对于进行更多科学工作(如初始化向量、模拟或游戏)可能非常重要。

大多数随机数生成器的默认值是均匀分布,其中每个数字出现的可能性相同。实际上从分布中抽取样本需要一个初始化的随机数生成器,它以种子的形式提供 StdRng。断言语句(经验上)显示它确实是一个均匀分布:在进行了 10,000 次抽取后,数字的平均值几乎正好在范围的中间(+/-2)。

以下分布是伯努利分布(mathworld.wolfram.com/BernoulliDistribution.html)。它可以初始化为成功的概率(例如,0.8)——但通常可以想象为一系列的抛硬币。实际上,这个分布用于生成布尔值(这就是为什么我们可以根据生成的值进行过滤)。

最后,在这个测试中,我们正在创建一个正态分布的生成器(mathworld.wolfram.com/NormalDistribution.html)。这是一种在中心点(均值)周围以定义的扩散(标准差)分布随机变量的已知形式。值越接近中心,其发生的可能性就越大。在这种情况下,我们使用均值为 2.0 和标准差为 0.5 进行初始化,这意味着在进行了大量抽取之后,我们应该得到我们提供的确切均值和标准差。assert_eq!确认了均值。

步骤 7展示了测试输出——并且它工作(在撰写本文时)。

如果rand包的一些实现细节发生变化(例如,小版本更新),则伴随的存储库中的代码可能会失败。

要了解更多关于rand包的信息,请阅读这本书中的更多内容(rust-random.github.io/book/)。然而,如果你对如何实现伪随机数生成器并了解更多相关信息感兴趣,请查看 Packt 出版的《使用 Rust 的数据结构和算法实践》(www.packtpub.com/application-development/hands-data-structures-and-algorithms-rust),在那里我们将深入了解。然而,由于我们已经成功地学会了使用rand包,我们可以继续到下一个菜谱。

写入和读取文件

处理文件是日常任务,有时——根据编程语言的不同——可能不合理地困难。Rust 项目团队已经解决了这个问题,并提供了易于使用的 API 来访问文件。让我们直接深入探讨。

准备工作

首先,使用 cargo new file-stuff 创建一个新的项目。现在,为了处理文件,我们需要一个用于读取和处理的文本文件。Lorem Ipsum([www.lipsum.com/](https://www.lipsum.com/))是一种流行的虚拟文本,可以大规模生成,因此为了继续这个配方,使用这个生成器生成几个(200)段落,并将文本保存为根目录下的 lorem.txt 文件。

通过在 VS Code 中打开项目目录来完成你的准备工作。

如何操作...

我们只需几个步骤就可以从磁盘读取文件:

  1. 由于 Rust 标准库包含了我们需要的所有基础知识,让我们直接进入 src/main.rs 并在那里添加导入:
use std::fs::{self, File};
use std::io::{self, BufRead, BufReader, BufWriter, Read, Seek, Write};
use std::path::Path;

const TEST_FILE_NAME: &str = "lorem.txt";
  1. 首先,让我们处理从文件中读取。为此,我们创建一个名为 read() 的函数,该函数读取并从导入下的准备文件 lorem.txt 中提取内容:
fn read() -> io::Result<()> {
    let path = Path::new(TEST_FILE_NAME);

    let input = File::open(path)?;
    let buffered = BufReader::new(input);

    let words: Vec<usize> = buffered
        .lines()
        .map(|line| line.unwrap().split_ascii_whitespace().count())
        .collect();
    let avg_word_count = words.iter().sum::<usize>() as f32 / 
     words.len() as f32;
    println!(
        "{}: Average words per line: {:.2}",
        path.to_string_lossy(),
        avg_word_count
    );

    let mut input = File::open(path)?;
    let mut input_buffer = String::new();
    input.read_to_string(&mut input_buffer)?;

    // ... or ...

    let lorem = fs::read_to_string(path)?;
    println!(
        "{}: Length in characters : {}",
        path.to_string_lossy(),
        lorem.len()
    );
    // reset file pointer to the beginning
    input.seek(io::SeekFrom::Start(0))?; 
    println!(
        "{}: Length in bytes: {}",
        path.to_string_lossy(),
        input.bytes().count()
    );
    Ok(())
}
  1. 接下来,我们将处理写入。在这种情况下,我们创建一个虚拟文件并以各种方式向其写入。你可以在 src/main.rs 中添加以下内容:
fn write() -> io::Result<()> {
    let mut path = Path::new(".").to_path_buf();

    path.push("hello.txt");

    let mut file = File::create(path)?;
    println!("Opened {:?}", file.metadata()?);

    file.write_all(b"Hello")?;

    let mut buffered = BufWriter::new(file);
    write!(buffered, " World!")?;
    write!(buffered, "\n{: >width$}", width=0x5ff)?;
    Ok(())
}
  1. 在最后一步,我们应该在 main 函数中将函数组合起来:
fn main() -> io::Result<()> {
    println!("===== READ =====");
    read()?;
    println!();
    println!("===== WRITE ====");
    write()?;
    Ok(())
}
  1. 使用 cargo run,我们现在可以从磁盘读取和写入以执行各种任务。在这里,我们可以观察一些关于 lorem.txt 文件以及我们写入的文件元数据的一般统计信息:
$ cargo run
   Compiling file-stuff v0.1.0 (Rust-Cookbook/Chapter10/file-stuff)
    Finished dev [unoptimized + debuginfo] target(s) in 0.84s
     Running `target/debug/file-stuff`
===== READ =====
lorem.txt: Average words per line: 42.33
lorem.txt: Length in characters : 57076
lorem.txt: Length in bytes: 57076

===== WRITE ====
Opened Metadata { file_type: FileType(FileType { mode: 33188 }), is_dir: false, is_file: true, permissions: Permissions(FilePermissions { mode: 33188 }), modified: Ok(SystemTime { tv_sec: 1567003873, tv_nsec: 941523976 }), accessed: Ok(SystemTime { tv_sec: 1566569294, tv_nsec: 260780071 }), created: Err(Custom { kind: Other, error: "creation time is not available on this platform currently" }) }

让我们看看我们在这里是如何处理文件的。

它是如何工作的...

在设置好项目后,我们直接进入 步骤 1 并提供与文件 API 一起工作的所需导入。请注意,与文件一起工作以及读取/写入文件位于两个不同的模块中:std::fs 用于访问,std::io 用于读取和写入。此外,std::path 模块提供了以平台无关的方式处理路径的强大且简单的方法。

步骤 2 提供了一个函数,展示了从我们在准备中创建的测试文件中读取数据的好几种方式。首先,我们打开文件并将 BufReaderdoc.rust-lang.org/std/io/struct.BufReader.html)的引用传递给它,这是一个缓冲读取器。虽然初始引用也允许读取数据,但 BufReader 以批量方式读取文件内容并从内存中提供它们。这减少了磁盘访问次数,同时显著提高了性能(与逐字节读取相比)。此外,这还允许使用 lines() 函数迭代行。

通过这种方式,我们可以遍历每一行,在空白处分割它,并计算结果迭代器(.split_ascii_whitespace().count())。将这些数字相加,然后除以找到的行数,我们可以确定每行的平均单词数。这展示了在 Rust 中一切都可以归结为迭代器,并且只需几行代码就能创建出强大的功能。

除了读取迭代器,Rust 标准库还支持直接读取到一个大字符串中。对于这个常见任务,fs::read_to_string()提供了一个方便的快捷方式。然而,如果你想保留文件指针以供以后使用,File结构体也提供了一个read_to_string()函数。

由于文件指针被设置为停止读取文件的位置(在这种情况下是末尾),在进一步使用之前,我们必须使用seek()函数重置文件指针。例如,如果我们想读取字节而不是字符,API 也提供了一个迭代器来处理这种情况(但还有更好的方法来获取文件大小)。

步骤 3深入探讨了写入文件。我们首先创建一个Path实例(它不能被更改),因此我们将其转换为可变的PathBuf实例并添加一个文件名。通过调用File::create(),我们快速创建(覆盖)并获取文件指针。metadata()函数提供了关于文件的一些元信息(格式化以提高可读性):

Metadata { 
  file_type: FileType(FileType { 
    mode: 33188 
  }), 
  is_dir: false, 
  is_file: true, 
  permissions: Permissions(FilePermissions { 
    mode: 33188 
  }), 
  modified: Ok(SystemTime { 
    tv_sec: 1567003873, 
    tv_nsec: 941523976 
  }), 
  accessed: Ok(SystemTime { 
    tv_sec: 1566569294, 
    tv_nsec: 260780071 
  }), 
  created: Err(Custom { 
    kind: Other, 
    error: "creation time is not available on this platform currently" 
  }) 
}

向文件写入与向控制台写入相同(例如,使用write!()宏),可以包含任何数据,只要它可以序列化为字节。b"Hello"字节字面量与&str切片一样有效。类似于缓冲读取,缓冲写入也通过一次只写入大块数据来提高性能。

步骤 4步骤 5main函数中将一切联系在一起,并通过运行来查看结果。

在处理文件时,没有什么令人惊讶的:API 预期是直观的,并得益于其在常见迭代器和标准化特质中的集成。我们可以愉快地继续到下一个菜谱。

解析无结构格式如 JSON

在我们开始之前,让我们定义当我们说结构化和无结构数据时我们指的是什么。前者,结构化数据,遵循某种模式——比如 SQL 数据库中的表模式。另一方面,无结构数据在它将包含的内容方面是不可预测的。在最极端的例子中,一篇文章的文本体可能是我们可能想到的最无结构的东西——每个句子可能根据其内容遵循不同的规则。

JSON 的可读性略好,但仍然是无结构的。一个对象可以具有各种数据类型的属性,并且两个对象不必相同。在本章中,我们将探讨一些处理 JSON(以及其他格式)的方法,当它不遵循我们可以在结构体中声明的模式时。

准备工作

此项目需要 Python 来运行一个小脚本。对于项目的 Python 部分,按照网站上的说明安装 Python(3.6 或 3.7,见www.python.org/)。python3 命令应在终端/PowerShell 中可用。

一旦可用,使用 cargo new dynamic-data --lib 创建一个新的项目。使用 VS Code 打开项目目录。

如何做到这一点...

解析是一个多步骤的过程(但很容易做):

  1. 首先,让我们将 serde 及其子 crate 添加到 Cargo.toml 文件中。打开文件并添加以下内容:
[dependencies]
serde = "1"
serde_json ="1"
toml = "0.5"
serde-pickle = "0.5"
serde_derive = "1"
  1. 现在,让我们使用这些 crate 并看看它们能做什么。我们通过创建测试来做到这一点,这些测试从各种格式解析相同的数据,从 JSON 开始。在 src/lib.rs 中,我们用以下内容替换默认的测试模块:
#[macro_use]
extern crate serde_json;

#[cfg(test)]
mod tests {
    use serde_json::Value;
    use serde_pickle as pickle;
    use std::fs::File;
    use toml;

    #[test]
    fn test_dynamic_json() {
        let j = r#"{
            "userid": 103609,
            "verified": true,
            "friendly_name": "Jason",
            "access_privileges": [
              "user",
              "admin"
            ]
        }"#;

        let parsed: Value = serde_json::from_str(j).unwrap();
        let expected = json!({
          "userid": 103609,
          "verified": true,
          "friendly_name": "Jason",
          "access_privileges": [
            "user",
            "admin"
          ]
        });
        assert_eq!(parsed, expected);

        assert_eq!(parsed["userid"], 103609);
        assert_eq!(parsed["verified"], true);
        assert_eq!(parsed["friendly_name"], "Jason");
        assert_eq!(parsed["access_privileges"][0], "user");
        assert_eq!(parsed["access_privileges"][1], "admin");
        assert_eq!(parsed["access_privileges"][2], Value::Null);
        assert_eq!(parsed["not-available"], Value::Null);
    }
}
  1. TOML 是一种基于文本的格式,与 JSON 和 YAML 竞争配置文件。让我们创建与之前相同的测试,但使用 TOML 而不是 JSON,并将以下代码添加到 tests 模块中:
    #[test]
    fn test_dynamic_toml() {
        let t = r#"
            [[user]]
            userid = 103609
            verified = true
            friendly_name = "Jason"
            access_privileges = [ "user", "admin" ]
        "#;

        let parsed: Value = toml::de::from_str(t).unwrap();

        let expected = json!({
            "user": [
                {
                    "userid": 103609,
                    "verified": true,
                    "friendly_name": "Jason",
                    "access_privileges": [
                        "user",
                        "admin"
                    ]
                }

            ]
        });
        assert_eq!(parsed, expected);

        let first_user = &parsed["user"][0];
        assert_eq!(first_user["userid"], 103609);
        assert_eq!(first_user["verified"], true);
        assert_eq!(first_user["friendly_name"], "Jason");
        assert_eq!(first_user["access_privileges"][0], "user");
        assert_eq!(first_user["access_privileges"][1], "admin");
        assert_eq!(first_user["access_privileges"][2], Value::Null);
        assert_eq!(first_user["not-available"], Value::Null);
    }
  1. 由于最后两个是基于文本的格式,让我们看看一个二进制格式。Python 的 pickle 格式常用于序列化数据以及机器学习模型。然而,在我们能够使用 Rust 读取它之前,让我们在项目根目录中创建一个名为 create_pickle.py 的小 Python 脚本来创建文件:
import pickle 
import json

def main():
    val = json.loads("""{
            "userid": 103609,
            "verified": true,
            "friendly_name": "Jason",
            "access_privileges": [
              "user",
              "admin"
            ]
        }""") # load the json string as dictionary

    # open "user.pkl" to write binary data (= wb)
    with open("user.pkl", "wb") as out:
        pickle.dump(val, out) # write the dictionary

if __name__ == '__main__':
    main()
  1. 运行 python3 create_pickle.py 在项目的根目录中创建一个 user.pkl 文件(脚本应该静默退出)。

  2. 将最后一个测试添加到 src/lib.rs 中的 tests 模块,该测试解析并比较 pickle 文件的内容与预期内容:

    #[test]
    fn test_dynamic_pickle() {
        let parsed: Value = { 
            let data = File::open("user.pkl")
                       .expect("Did you run create_pickle.py?");
            pickle::from_reader(&data).unwrap()
        };

        let expected = json!({
          "userid": 103609,
          "verified": true,
          "friendly_name": "Jason",
          "access_privileges": [
            "user",
            "admin"
          ]
        });
        assert_eq!(parsed, expected);

        assert_eq!(parsed["userid"], 103609);
        assert_eq!(parsed["verified"], true);
        assert_eq!(parsed["friendly_name"], "Jason");
        assert_eq!(parsed["access_privileges"][0], "user");
        assert_eq!(parsed["access_privileges"][1], "admin");
        assert_eq!(parsed["access_privileges"][2], Value::Null);
        assert_eq!(parsed["not-available"], Value::Null);
    }
  1. 最后,我们想看到测试运行的结果(成功)。让我们执行 cargo test 来查看测试结果以及我们如何能够读取来自各种来源的二进制和文本数据:
$ cargo test
 Compiling dynamic-json v0.1.0 (Rust-Cookbook/Chapter10/dynamic-data)
warning: unused `#[macro_use]` import
 --> src/lib.rs:1:1
 |
1 | #[macro_use]
 | ^^^^^^^^^^^^
 |
 = note: #[warn(unused_imports)] on by default

 Finished dev [unoptimized + debuginfo] target(s) in 1.40s
 Running target/debug/deps/dynamic_json-cf635db43dafddb0

running 3 tests
test tests::test_dynamic_json ... ok
test tests::test_dynamic_pickle ... ok
test tests::test_dynamic_toml ... ok

test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

 Doc-tests dynamic-json

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

让我们看看它是如何工作的。

它是如何工作的...

静态类型语言如 Rust,一旦确定了类型,编程就会变得非常舒适。然而,在一个不断变化的网络服务 API 的世界里,一个简单的附加属性可能导致解析错误,使得无法继续。因此,serde不仅支持完全自动化的解析,还能从其Value类型动态提取数据,包括类型解析。

步骤 1 中,我们添加了各种依赖项,所有这些依赖项都符合 serde 接口(位于 serde crate 中)——尽管它们来自不同的来源。它们的使用在 步骤 2 和之后进行了演示。

我们从创建一个包含serde_json要解析的 JSON 字符串的原始字符串开始。一旦创建了Value变量,我们就可以使用json!宏创建一个等效对象以进行比较。之后,我们调用Value API 来检索单个属性并检查它们的类型和内容。Value是一个枚举(docs.serde.rs/serde_json/value/enum.Value.html),它实现了一系列自动转换和检索函数,这些函数使得这些无缝的assert_eq!语句成为可能。如果属性或列表索引不存在,则返回ValueNull变体。

第 3 步 解析 TOML 格式(github.com/toml-lang/toml)并将其与 JSON 输出进行比较——多亏了统一的Value枚举,它与第 2 步非常相似。主要区别在于,TOML 中的用户属性是一个列表,以展示其他列表语法([[this-way-to-declare-a-list-item]])。

步骤 4步骤 5中,我们准备了一个包含字典对象的 Python pickle 文件——从与步骤 2相同的 JSON 对象解析而来。Pickle 是一种二进制格式,这意味着我们告诉 Python 的文件 API 写入原始字节而不是编码文本。相比之下,当我们读取文件时,Rust 默认读取字节,并要求程序员提供解释(codec)如果他们关心的话。File API(doc.rust-lang.org/std/fs/struct.File.html)自动返回一个(未缓冲的)Read对象以获取内容,我们可以直接将其传递到适当的 pickle 函数。代码的其余部分验证从 pickle 文件中读取的内容是否与其他对象相同。

我们在这里展示了读取三种类型,但serde支持更多。查看他们的文档以了解更多信息,但现在让我们继续下一个食谱。

使用正则表达式提取文本

正则表达式长期以来一直是编程的一部分,在 Rust 的上下文中,它以ripgrepgithub.com/BurntSushi/ripgrep)的形式获得了流行。ripgrep是 grep 的一个变体,用于搜索特定正则表达式的文件——它已被作为 VS Code 的主要部分采用,其中它为搜索引擎提供动力。原因很简单:速度(github.com/BurntSushi/ripgrep#quick-examples-comparing-tools)。

Rust 的正则表达式库已被重新实现,这可能是它优于早期实现的原因(以及因为 Rust 运行速度快)。让我们看看我们如何在 Rust 项目中利用正则表达式。

如何做到这一点...

让我们遵循几个步骤来探索 Rust 中的正则表达式:

  1. 打开终端,使用cargo new regex --lib创建一个新的项目。使用 VS Code 打开项目目录。

  2. 首先,我们将向Cargo.toml中的依赖项添加 regex crate:

[dependencies]
regex = "1"
  1. 接下来,让我们打开src/lib.rs来创建一些我们可以运行的测试。首先,我们创建一个测试模块,替换任何现有的代码:
#[cfg(test)]
mod tests {

    use regex::Regex;
    use std::cell::RefCell;
    use std::collections::HashMap;
}
  1. 正则表达式通常用于解析数据或验证数据是否符合表达式的规则。让我们在测试模块中添加一个测试来执行一些简单的解析:
    #[test]
    fn simple_parsing() {
        let re = Regex::new(r"(?P<y>\d{4})-(
                           ?P<m>\d{2})-(?P<d>\d{2})").unwrap();

        assert!(re.is_match("1999-12-01"));
        let date = re.captures("2019-02-27").unwrap();

        assert_eq!("2019", &date["y"]);
        assert_eq!("02", &date["m"]);
        assert_eq!("27", &date["d"]);

        let fun_dates: Vec<(i32, i32, i32)> = (1..12)
                  .map(|i| (2000 + i, i, i * 2)).collect();

        let multiple_dates: String = fun_dates
            .iter()
            .map(|d| format!("{}-{:02}-{:02} ", d.0, d.1, d.2))
            .collect();

        for (match_, expected) in re.captures_iter(
             &multiple_dates).zip(fun_dates.iter()) {
            assert_eq!(match_.get(1).unwrap().as_str(), 
                       expected.0.to_string());
            assert_eq!(
                match_.get(2).unwrap().as_str(),
                format!("{:02}", expected.1)
            );
            assert_eq!(
                match_.get(3).unwrap().as_str(),
                format!("{:02}", expected.2)
            );
        }
    }
  1. 然而,正则表达式可以通过它们的模式匹配做更多的事情。另一个任务可能是替换数据:
    #[test]
    fn reshuffle_groups() {
        let re = Regex::new(r"(?P<y>\d{4})-(
                 ?P<m>\d{2})-(?P<d>\d{2})").unwrap();

        let fun_dates: Vec<(i32, i32, i32)> = (1..12)
             .map(|i| (2000 + i, i, i * 2)).collect();

        let multiple_dates: String = fun_dates
            .iter()
            .map(|d| format!("{}-{:02}-{:02} ", d.0, d.1, d.2))
            .collect();

        let european_format = re.replace_all(
                              &multiple_dates, "$d.$m.$y");

        assert_eq!(european_format.trim(), "02.01.2001 04.02.2002 
                   06.03.2003 08.04.2004 10.05.2005 
                   12.06.2006 14.07.2007 16.08.2008 
                   18.09.2009 20.10.2010 22.11.2011");
    }
  1. 作为最后的测试,我们可以通过使用正则表达式分析数据来玩得更有趣,例如,统计电话号码的前缀:

    #[test]
    fn count_groups() {
        let counter: HashMap<String, i32> = HashMap::new();

        let phone_numbers = "+49 (1234) 45665
        +43(0)1234/45665 43
        +1 314-CALL-ME
        +44 1234 45665
        +49 (1234) 44444
        +44 12344 55538";

        let re = Regex::new(r"(\+[\d]{1,4})").unwrap();

        let prefixes = re
            .captures_iter(&phone_numbers)
            .map(|match_| match_.get(1))
            .filter(|m| m.is_some())
            .fold(RefCell::new(counter), |c, prefix| {
                {
                    let mut counter_dict = c.borrow_mut();
                    let prefix = prefix.unwrap().as_str().to_string();
                    let count = counter_dict.get(&prefix)
                                .unwrap_or(&0) + 1;
                    counter_dict.insert(prefix, count);
                }
                c
            });

        let prefixes = prefixes.into_inner();
        assert_eq!(prefixes.get("+49"), Some(&2));
        assert_eq!(prefixes.get("+1"), Some(&1));
        assert_eq!(prefixes.get("+44"), Some(&2));
        assert_eq!(prefixes.get("+43"), Some(&1));
    }
  1. 现在,让我们使用cargo test运行测试,我们可以看到正则表达式表现良好:
$ cargo test
 Finished dev [unoptimized + debuginfo] target(s) in 0.02s
 Running target/debug/deps/regex-46c0a096a2a4a140

running 3 tests
test tests::count_groups ... ok
test tests::simple_parsing ... ok
test tests::reshuffle_groups ... ok

test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

 Doc-tests regex

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

现在我们已经知道了如何使用正则表达式,让我们来看看它们是如何工作的。

它是如何工作的...

在步骤 1 和步骤 2 的初始设置之后,我们首先在步骤 3 中创建一个测试模块,以及所需的依赖项。然后,步骤 4 包含第一个测试,展示了 regex crate (docs.rs/regex/1.2.1/regex/)如何处理数据的简单解析。

通过使用原始字符串字面量语法,r"I am a raw string",我们编译一个新的Regex实例,并将其与日期字符串进行匹配。包含的字符类是跨操作系统和语言普遍使用的,包括对空白字符以及(字母)数字字符和原始字节的支撑。此外,可以使用(?flag)标记直接在表达式中放置标志。

第 4 步中的正则表达式由三个部分组成:(?P<y>\d{4})-(?P<m>\d{2})-(?P<d>\d{2})

第一部分命名为y?P<name>声明了一个名称)并寻找恰好四个({4})数字\d,它可以匹配。第二部分和第三部分各寻找两个数字,分别命名为md。这种命名在稍后当我们想要检索匹配时将变得很重要。在这些模式之间,我们看到一个-,这意味着最终的模式必须看起来像yyyy-mm-dd(或者更精确地说,像1234-12-12)才能匹配。

在测试中向下进行,这是我们要做的事情。通过准备一些正面的例子,我们可以验证一个日期(1999-12-01),以及通过名称提取各个部分(2019-02-27)。如果字符串有多个匹配,我们也可以遍历这些捕获以保持效率。在测试的情况下,我们还检查在遍历时提取的内容是否与预期值匹配。

编译正则表达式需要相当多的时间,尤其是当表达式非常大时。因此,尽可能预先编译并重用,避免在循环中编译!

步骤 5创建了一个类似的正则表达式,并复制了步骤 4测试中的fun_dates变量。然而,我们不仅想要提取内容,还想要替换模式,在这种情况下,将 ISO -表示法转换为欧洲风格的.表示法。由于我们在正则表达式中命名了组,我们现在也可以在替换字符串中引用这些名称。

步骤 6中,我们返回到匹配,但不是简单地验证,而是提取并处理提取的数据以创建信息。假设任务是要统计电话号码中的国家代码,我们可以应用正则表达式并使用HashMap来跟踪每个数字的出现次数。正则表达式匹配以+开头,后跟一到四个数字:(\+[\d]{1,4})

使用 Rust 的迭代器功能,我们提取匹配项并过滤掉任何非匹配项,然后将结果折叠到常见的HashMap中。RefCell帮助我们管理可变性,由于折叠函数必须返回累积的结果,我们必须将可变借用范围缩小以确保内存安全(编译器会告诉你)。一旦我们提取了单元格的内部值,我们就可以看到数字是什么。

这只是触及了正则表达式领域中可能任务的一些常见主题。我们强烈建议阅读文档以了解更多信息!

然而,现在我们已经尝试了一些正则表达式,我们可以继续到下一道菜谱。

递归搜索文件系统

ripgrep——正如我们在上一道菜谱(使用正则表达式提取文本)中提到的——是一个流行的 grep 引擎,它遍历文件以查找与提供的正则表达式规则匹配的任何内容。为此,不仅需要编译和匹配大量文本中的正则表达式,还需要找到这些文本。为了到达并打开这些文件,我们需要遍历文件系统的目录树。让我们来看看如何在 Rust 中实现这一点。

如何做到这一点...

我们可以通过以下步骤来理解递归搜索:

  1. 打开一个终端,使用cargo new filesystem创建一个新的项目。使用 VS Code 打开项目目录。

  2. 编辑Cargo.toml以添加一个名为glob的 crate 依赖项,用于遍历文件系统:

[dependencies]
glob = "0.3.0"
  1. src/main.rs中,我们可以开始实现遍历文件系统树的函数,但首先,让我们设置导入和一个类型别名以处理 boxed 错误:
use glob;
use std::error::Error;
use std::io;
use std::path::{Path, PathBuf};

type GenericError = Box<dyn Error + Send + Sync + 'static>;
  1. 接下来,我们将添加一个仅使用 Rust 标准库的递归walk函数。添加以下内容:
fn walk(dir: &Path, cb: &dyn Fn(&PathBuf), recurse: bool) -> io::Result<()> {
    for entry in dir.read_dir()? {
        let entry = entry?;
        let path = entry.path();
        if recurse && path.is_dir() {
            walk(&path, cb, true)?;
        }
        cb(&path);
    }
    Ok(())
}
  1. glob也是文件系统通配符样式的一种(例如,*.txtCargo*),在 Windows 和 Linux/Unix 上都能使用。在某些实现中,glob 也可以是递归的,这就是为什么我们可以使用同名 crate 来实现另一个walk函数:
fn walk_glob(pattern: &str, cb: &dyn Fn(&PathBuf)) -> Result<(), GenericError> {
    for entry in glob::glob(pattern)? {
        cb(&entry?);
    }
    Ok(())
}
  1. 现在缺少的是main函数,用于将所有这些内容串联起来并相应地调用函数。添加以下内容:
fn main() -> Result<(), GenericError> {
    let path = Path::new("./src");
    println!("Listing '{}'", path.display());
    println!("===");
    walk(path, &|d| println!(" {}", d.display()), true)?;
    println!();

    let glob_pattern = "../**/*.rs";
    println!("Listing by glob filter: {}", glob_pattern);
    println!("===");
    walk_glob(glob_pattern, &|d| println!(" {}", d.display()))?;
    println!();

    let glob_pattern = "Cargo.*";
    println!("Listing by glob filter: {}", glob_pattern);
    println!("===");
    walk_glob(glob_pattern, &|d| println!(" {}", d.display()))?;
    Ok(())
}
  1. 如同往常,我们希望看到它运行——使用cargo run递归地列出文件系统中的文件,使用我们在第 6 步中定义的过滤器。我们还鼓励你将路径更改为适合你系统的路径:
$ cargo run
   Compiling filesystem v0.1.0 (Rust-Cookbook/Chapter10/filesystem)
    Finished dev [unoptimized + debuginfo] target(s) in 0.25s
     Running `target/debug/filesystem`
Listing './src'
===
  ./src/main.rs

Listing by glob filter: ../**/*.rs
===
  ../command-line-args/src/main.rs
  ../dynamic-data/src/lib.rs
  ../file-stuff/src/main.rs
  ../filesystem/src/main.rs
  ../logging/src/main.rs
  ../pipes/src/main.rs
  ../random-numbers/src/lib.rs
  ../regex/src/lib.rs
  ../rusty-ml/src/main.rs
  ../sub-processes/src/main.rs
  ../web-requests/src/main.rs

Listing by glob filter: Cargo.*
===
  Cargo.lock
  Cargo.toml

让我们深入了解使用过滤器遍历文件系统的内部机制。

它是如何工作的...

遍历文件系统树并不是一个特别复杂的工作。然而,就像任何其他树遍历一样,递归地执行它要容易得多,尽管如果目录嵌套太深,总会有遇到栈溢出问题的风险。虽然迭代方法也是可能的,但实现起来要长得多,也更复杂。

在这个菜谱中,我们从第 1 步开始设置一切,在第 2 步中添加glob crate (docs.rs/glob/0.3.0/glob/)作为依赖项,并在第 3 步中最终导入所需的模块。在第 4 步中,我们编写了第一个walk函数,一个递归顺序遍历。这意味着我们在开始在该路径上执行提供的回调之前,尽可能递归地深入到第一个(按某种顺序)目录——因此我们是以节点出现的顺序处理节点的。

Rust 的DirEntry结构体非常强大,因为它允许通过属性访问其内容(而不是调用不同的函数)。io::Result<()>返回类型还允许使用?操作符,并在出现错误时提前结束。

第 5 步提供了一个类似的函数,使用glob迭代器。由于输入是一个模式(递归和非递归),这个模式会被解析,如果有效,则返回一个匹配的文件和文件夹路径的迭代器。然后我们可以使用这些条目调用回调函数。

第 6 步中,我们使用一系列路径调用函数。第一个路径深入到src目录,使用递归方法列出该目录下的所有文件。第二个模式首先进入项目目录的父目录,然后递归地匹配它找到的所有*.rs文件(及其子目录)。在本章的例子中,你应该能看到我们编写(以及将要编写)的所有代码文件。

最后,过滤器也可以是简单的东西,匹配最后的walk_glob()调用中的两个Cargo.*文件。

现在我们知道了如何遍历文件系统,让我们继续到另一个菜谱。

自定义命令行参数

使用命令行参数是配置程序以运行特定任务、使用特定输入数据集或简单地输出更多信息的好方法。然而,现在查看 Linux 程序的帮助文本输出,它提供了关于它可以处理的全部标志和参数的令人印象深刻的信息。除此之外,文本以某种标准化的格式打印出来,这也是为什么这通常需要强大的库支持。

Rust 中用于处理命令行参数最受欢迎的 crate 叫做clap(clap.rs/),在这个菜谱中,我们正在查看如何利用其优势来创建一个有用的命令行界面。

如何做到这一点...

一个使用命令行参数打印目录/文件的简单程序只需要几个步骤:

  1. 打开一个终端,使用cargo new command-line-args创建一个新的项目。使用 VS Code 打开项目目录。

  2. 首先,让我们将Cargo.toml适配以下载clap并有一个更好的二进制输出名称:

[package]
name = "list"
version = "1.0.0"
authors = ["Claus Matzinger <claus.matzinger+kb@gmail.com>"]
edition = "2018"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
clap = {version= "2.33", features = ["suggestions", "color"]}
  1. src/main.rs中,我们将从导入开始:
use clap::{App, Arg, SubCommand};
use std::fs::DirEntry;
use std::path::Path;

use std::io;
  1. 然后,我们定义一个walk函数,该函数递归地遍历文件系统,并在每个条目上执行回调。该函数支持排除某些路径,我们使用其自身类型来实现:
struct Exclusion(String);

impl Exclusion {
    pub fn is_excluded(&self, path: &Path) -> bool {
        path.file_name()
            .map_or(false, |f| f.to_string_lossy().find(&self.0).is_some())
    }
}

有了这个,我们可以定义walk函数:

fn walk(
    dir: &Path,
    exclusion: &Option<Exclusion>,
    cb: &dyn Fn(&DirEntry),
    recurse: bool,
) -> io::Result<()> {
    for entry in dir.read_dir()? {
        let entry = entry?;
        let path = entry.path();
        if !exclusion.as_ref().map_or(false, 
                 |e| e.is_excluded(&path)) {
            if recurse && path.is_dir() {
                walk(&path, exclusion, cb, true)?;
            }
            cb(&entry);
        }
    }
    Ok(())
}
  1. 接下来,几个辅助函数使我们的打印工作变得更简单:
fn print_if_file(entry: &DirEntry) {
    let path = entry.path();
    if !path.is_dir() {
        println!("{}", path.to_string_lossy())
    }
}
fn print_if_dir(entry: &DirEntry) {
    let path = entry.path();
    if path.is_dir() {
        println!("{}", path.to_string_lossy())
    }
}
  1. main函数中,我们第一次使用clap API。在这里,我们正在创建应用程序的参数/子命令结构:
fn main() -> io::Result<()> {
    let matches = App::new("list")
        .version("1.0")
        .author("Claus M - claus.matzinger+kb@gmail.com")
        .about("")
        .arg(
            Arg::with_name("exclude")
                .short("e")
                .long("exclude")
                .value_name("NAME")
                .help("Exclude directories/files with this name")
                .takes_value(true),
        )
        .arg(
            Arg::with_name("recursive")
                .short("r")
                .long("recursive")
                .help("Recursively descend into subdirectories"),
        )

在参数之后,我们以相同的方式添加子命令——遵循构建器模式:

        .subcommand(
            SubCommand::with_name("files")
                .about("Lists files only")
                .arg(
                    Arg::with_name("PATH")
                        .help("The path to start looking")
                        .required(true)
                        .index(1),
                ),
        )
        .subcommand(
            SubCommand::with_name("dirs")
                .about("Lists directories only")
                .arg(
                    Arg::with_name("PATH")
                        .help("The path to start looking")
                        .required(true)
                        .index(1),
                ),
        )
        .get_matches();

一旦我们检索到匹配项,我们必须获取传递给程序的实际值:

    let recurse = matches.is_present("recursive");
    let exclusions = matches.value_of("exclude")
                     .map(|e| Exclusion(e.into()));

然而,使用子命令,我们还可以匹配它们的特定标志和其他参数,这最好使用 Rust 的模式匹配来提取:

    match matches.subcommand() {
        ("files", Some(subcmd)) => {
            let path = Path::new(subcmd.value_of("PATH").unwrap());
            walk(path, &exclusions, &print_if_file, recurse)?;
        }
        ("dirs", Some(subcmd)) => {
            let path = Path::new(subcmd.value_of("PATH").unwrap());
            walk(path, &exclusions, &print_if_dir, recurse)?;
        }
        _ => {}
    }
    Ok(())
}
  1. 让我们看看这做了什么。运行cargo run来查看初始输出:
$ cargo run
 Compiling list v1.0.0 (Rust-Cookbook/Chapter10/command-line-args)
 Finished dev [unoptimized + debuginfo] target(s) in 0.68s
 Running `target/debug/list`

没有东西!确实,我们没有指定任何必需的命令或参数。让我们运行cargo run -- help(因为我们命名了程序为 list,直接调用编译后的可执行文件将是list help)来查看显示我们可以尝试的选项的帮助文本:

$ cargo run -- help
    Finished dev [unoptimized + debuginfo] target(s) in 0.03s
     Running `target/debug/list help`
list 1.0
Claus M - claus.matzinger+kb@gmail.com

USAGE:
    list [FLAGS] [OPTIONS] [SUBCOMMAND]

FLAGS:
    -h, --help Prints help information
    -r, --recursive Recursively descend into subdirectories
    -V, --version Prints version information

OPTIONS:
    -e, --exclude <NAME> Exclude directories/files with this name

SUBCOMMANDS:
    dirs Lists directories only
    files Lists files only
    help Prints this message or the help of the given subcommand(s)

我们应该首先查看dirs子命令,所以让我们运行cargo run -- dirs来查看它是否识别所需的PATH参数:

$ cargo run -- dirs 
 Finished dev [unoptimized + debuginfo] target(s) in 0.02s
 Running `target/debug/list dirs`
error: The following required arguments were not provided:
 <PATH>

USAGE:
 list dirs <PATH>

For more information try --help

让我们也尝试一个完全参数化的运行,其中我们列出项目目录下的所有子文件夹,排除所有名为src的文件夹(及其子文件夹):

$ cargo run -- -e "src" -r dirs "."
    Finished dev [unoptimized + debuginfo] target(s) in 0.03s
     Running `target/debug/list -e src -r dirs .`
./target/debug/native
./target/debug/deps
./target/debug/examples
./target/debug/build/libc-f4756c111c76f0ce/out
./target/debug/build/libc-f4756c111c76f0ce
./target/debug/build/libc-dd900fc422222982
./target/debug/build/bitflags-92aba5107334e3f1
./target/debug/build/bitflags-cc659c8d16362a89/out
./target/debug/build/bitflags-cc659c8d16362a89
./target/debug/build
./target/debug/.fingerprint/textwrap-a949503c1b2651be
./target/debug/.fingerprint/vec_map-bffb157312ad2f55
./target/debug/.fingerprint/bitflags-20c9ba1238fdf359
./target/debug/.fingerprint/strsim-13cb32b0738f6106
./target/debug/.fingerprint/libc-63efda3965f75b56
./target/debug/.fingerprint/clap-062d4c7aff8b8ade
./target/debug/.fingerprint/unicode-width-62c92f6253cf0187
./target/debug/.fingerprint/libc-f4756c111c76f0ce
./target/debug/.fingerprint/libc-dd900fc422222982
./target/debug/.fingerprint/list-701fd8634a8008ef
./target/debug/.fingerprint/ansi_term-bceb12a766693d6c
./target/debug/.fingerprint/bitflags-92aba5107334e3f1
./target/debug/.fingerprint/bitflags-cc659c8d16362a89
./target/debug/.fingerprint/command-line-args-0ef71f7e17d44dc7
./target/debug/.fingerprint/atty-585c8c7510af9f9a
./target/debug/.fingerprint
./target/debug/incremental/command_line_args-1s3xsytlc6x5x/s-ffbsjpqyuz-19aig85-4az1dq8f8e3e
./target/debug/incremental/command_line_args-1s3xsytlc6x5x
./target/debug/incremental/list-oieloyeggsml/s-ffjle2dbdm-1w5ez6c-13wi8atbsq2wt
./target/debug/incremental/list-oieloyeggsml
./target/debug/incremental
./target/debug
./target

试试看:几个组合展示了clap的强大功能。让我们看看它是如何工作的。

它是如何工作的...

clap(clap.rs/)自豪于是一个简单易用的 crate,用于在 Rust 中处理命令行参数——他们是对的。在最初的两个步骤中,我们设置了应用程序配置和依赖项。我们还重命名了二进制文件,因为listcommand-line-args更直接。

步骤 3中,我们首先导入必要的结构体(docs.rs/clap/2.33.0/clap/struct.App.html——App Arg SubCommand对于clap),然后在步骤 4中,我们正在创建一个函数,我们将使用命令行参数来参数化这个函数。该函数本身是一个简单的目录树遍历,能够在每个条目上执行回调,并有一种排除某些路径的方法。

这与我们在本章前面“递归搜索文件系统”菜谱中所做的是类似的。

步骤 5中定义了一些额外的辅助回调,用于仅打印目录和文件。闭包也可以工作,但不会达到相同的可读性。

步骤 6是我们与clap API 一起工作的地方。这个特定案例仅使用 Rust API;然而,clap也支持使用外部文件来配置参数。更多信息请参阅docs.rs/clap/2.33.0/clap/index.html。无论你如何定义参数,结构都非常相似:App结构体有几个元参数,用于告知用户作者、版本以及其他信息,以及它可以拥有的参数。

参数可以是标志(即设置某个值为true/false)或值(例如输入路径),这就是为什么我们使用Arg结构体来分别配置每个参数。典型的命令行标志有一个简写名称,用于较长的名称(例如 Linux/Unix 上的ls -als --all),以及一个简短的帮助文本来解释用法。最后一个设置是关于标志是否具有比布尔值更复杂的类型,我们将exclude标志设置为true,而将recursive标志保留为false。这些名称将稍后用于检索这些值。

现在许多命令行应用程序都具有子命令结构,这有助于更好地组织和提高可读性。子命令可以嵌套,并拥有自己的参数——就像App结构体一样。我们在这里定义的参数是位置参数,因此它们不是通过名称来引用,而必须出现在特定的位置。由于参数是必需的,参数解析器会接受任何传入的值。

通过调用get_matches(),我们执行解析(这也会在必要时触发帮助文本和早期退出)并检索一个ArgMatches实例。该类型管理键值对(参数名称及其获取的值),使用OptionResult类型,这允许我们使用 Rust 代码来设置默认值。

子命令在某种程度上就像子应用程序。它们带有自己的ArgMatches实例,用于访问它们的标志和更直接地操作。

步骤 6 展示了运行程序的一些可能调用。我们使用两个短横线,--,将任何参数传递给应用程序(而不是 cargo 解释它们),通过运行默认的帮助子命令,我们可以看到包含我们提供的所有文本和名称的整洁且标准化的帮助输出。

这些帮助文本也提供了一种情况,即解析失败(例如,当标志拼写错误时)以及每个子命令。然而,步骤 6 的最后一部分展示了当它成功时会发生什么,列出了 target/ 中的所有构建目录(因为我们排除了 src)。由于我们不希望您被各种参数组合所困扰,我们鼓励您尝试我们配置的其他参数,并查看不同的结果!

现在我们已经知道了如何处理命令行参数,让我们继续到下一个菜谱。

处理管道输入数据

从文件中读取数据是本章另一个菜谱中描述的非常常见的任务(写入和读取文件)。然而,这并不总是最佳选择。实际上,许多 Linux/Unix 程序可以使用管道(|)链接在一起来处理传入的流。这允许执行多项操作:

  • 输入源、静态文本、文件和网络流量的灵活性——无需更改程序

  • 运行多个进程,只将最终结果写回磁盘

  • 流的惰性评估

  • 上游/下游的灵活处理(例如,在写入磁盘之前对输出进行 gzip 压缩)

如果您不熟悉这是如何工作的,管道语法可能看起来很神秘。然而,它实际上源于函数式编程范式(www.geeksforgeeks.org/functional-programming-paradigm/),其中管道和流处理相当常见——与 Rust 的迭代器非常相似。让我们构建一个 CSV 到基于行的 JSON(每行是一个对象)转换器,看看我们如何与管道一起工作!

准备工作

打开终端,使用 cargo new pipes. 创建一个新的项目。使用 VS Code 打开项目目录,创建一个名为 cars.csv 的简单 CSV 文件,内容如下:

year,make,model
1997,Ford,E350
1926,Bugatti,Type 35
1971,Volkswagen,Beetle
1992,Gurgel,Supermini

我们现在将解析此文件并从中创建一系列 JSON 对象。

如何操作...

按照以下步骤实现 csv 到 JSON 转换器:

  1. 打开 Cargo.toml 以添加我们需要的几个依赖项,用于解析 CSV 和创建 JSON:
[dependencies]
csv = "1.1"
serde_json = "1"
  1. 现在,让我们添加一些代码。像往常一样,我们将导入 src/main.rs 中的几个东西,这样我们就可以在代码中使用它们:
use csv;
use serde_json as json;
use std::io;
  1. 下一步是添加一个将输入数据转换为 JSON 的函数。我们可以优雅地使用每个 csv::StringRecord 实例实现的 Iterator 特性来完成此操作:
fn to_json(headers: &csv::StringRecord, current_row: csv::StringRecord) -> io::Result<json::Value> {
    let row: json::Map<String, json::Value> = headers
        .into_iter()
        .zip(current_row.into_iter())
        .map(|(key, value)| (key.to_string(), json::Value::String(value.into())))
        .collect();
    Ok(json::Value::Object(row))
}
  1. 我们如何获取这些 csv::StringRecords 实例?通过从控制台读取!作为最后一部分代码,我们用以下代码替换默认的 main 函数:
fn main() -> io::Result<()> {
    let mut rdr = csv::ReaderBuilder::new()
        .trim(csv::Trim::All)
        .has_headers(false)
        .delimiter(b',')
        .from_reader(io::stdin());

    let header_rec = rdr
        .records()
        .take(1)
        .next()
        .expect("The first line does not seem to be a valid CSV")?;

    for result in rdr.records() {
        if let Ok(json_rec) = to_json(&header_rec, result?) {
            println!("{}", json_rec.to_string());
        }
    }
    Ok(())
}
  1. 最后,使用 PowerShell(在 Windows 上)或您喜欢的终端(Linux/macOS)通过管道输入数据来运行二进制文件:
$ cat cars.csv | cargo run
   Compiling pipes v0.1.0 (Rust-Cookbook/Chapter10/pipes)
    Finished dev [unoptimized + debuginfo] target(s) in 1.46s
     Running `target/debug/pipes`
{"make":"Ford","model":"E350","year":"1997"}
{"make":"Bugatti","model":"Type 35","year":"1926"}
{"make":"Volkswagen","model":"Beetle","year":"1971"}
{"make":"Gurgel","model":"Supermini","year":"1992"}

让我们深入了解我们如何通过多个程序流式传输数据。

它是如何工作的...

Linux 操作系统主要是基于文件的;许多重要的内核接口可以在模拟文件或文件夹结构的虚拟文件系统中找到。最好的例子是 /proc/ 文件系统,它允许用户访问硬件和其他内核/系统的当前信息。同样地,控制台输入和输出也被处理;实际上,它们是保留的文件句柄,编号为 0(标准输入)、1(标准输出)和 2(标准错误)。实际上,这些链接回 /proc/ 文件系统,其中 /proc/<process id>/fd/1 是该特定进程 ID 的标准输出。

有了这个概念,这些文件描述符可以像任何其他文件一样读取——这正是我们在本食谱中所做的。在 步骤 1 中设置基本依赖项并在 步骤 2 中导入模块之后,我们在 步骤 3 中创建一个处理函数。该函数接收 csv crate 的两个泛型 StringRecord(每个都包含一行数据)——一个用于标题行,另一个用于当前行。迭代器上的 zip() 函数允许我们有效地对齐索引,然后我们可以将结果转换为一个 Stringserde_json::Value::String 的元组。这允许我们将这些元组收集到 serde_json::Map 类型中,该类型随后被转换为 serde_json::Value::Object(表示一个 JSON 对象)。

迭代器的 collect() 函数依赖于特定类型的 FromIterator 特性的实现。serde_json::Map(String, serde_json::Value) 实现了这一特性。

第 4 步 然后调用这个 to_json() 函数——但在此之前,它必须构建一个自定义的 Reader 对象!默认情况下,csv::Reader 期望传入的行符合 Deserialize 结构——这在通用工具中是不可能的。因此,我们通过 ReaderBuilder 创建一个实例,并指定所需的选项:

  • trim(csv::Trim::All): 这使得清理变得更容易。

  • has_headers(false): 这允许我们首先读取标题;否则,它们将被忽略。

  • delimiter(b','): 这会将分隔符硬编码为逗号。

  • from_reader(io::stdin()): 这将连接到标准输入的 Read 接口。

在创建时,我们读取第一行并假设它是 CSV 的标题。因此,我们将其单独保存,以便在需要时借用给 to_json() 函数。随后,for 循环负责处理标准输入的 Read 接口的(无限)迭代器(通常直到接收到 EOF 信号,在 Linux/UNIX 操作系统上为 Ctrl + D)。每次迭代都会将结果再次打印到标准输出,以便其他程序可以通过管道读取。

就这样!我们强烈建议在继续下一个菜谱之前,查看 csv crate 的存储库,以了解更多它提供的功能(以及 serde_json (docs.serde.rs/serde_json/)))。

发送网络请求

近年来,网络请求已成为许多应用程序的重要组成部分。几乎所有东西都集成了某种类型的网络服务,即使只是诊断和用法统计。HTTP 的多功能性在更集中的计算世界中已被证明是一大资产。

本菜谱中的库之一(surf)是前沿的,依赖于 Rust 在编写此内容时的不稳定(async/await)功能。根据你阅读此内容的时间,库或 Rust 中的 async/await 可能已经改变——在这种情况下,请在相应的 GitHub 存储库中打开一个问题,这样我们就可以为其他读者提供一个可工作的示例。

在任何语言中,制作这些网络请求并不总是直截了当的,尤其是在发送和接收数据类型、变量等方面。由于 Rust 并不自带网络请求模块,因此我们可以使用几个库来连接到远程 HTTP 服务。让我们看看如何操作。

如何做到这一点...

我们可以仅用几个步骤就进行网络请求:

  1. 打开终端,使用 cargo new web-requests. 创建一个新的项目。使用 VS Code 打开项目目录。

  2. 首先,让我们编辑 Cargo.toml 以添加我们稍后将要使用的依赖项:

[dependencies]
surf = "1.0"
reqwest = "0.9"
serde = "1"
serde_json = "1"
runtime = "0.3.0-alpha.6"
  1. 让我们从导入这些外部依赖项并在 src/main.rs 中设置一些数据结构开始:
#[macro_use]
extern crate serde_json;

use surf::Exception;
use serde::Serialize;

#[derive(Serialize)]
struct MyGetParams {
    a: u64,
    b: String,
}
  1. surf (github.com/rustasync/surf) 是一个完全 async 开发的最新 crate。让我们创建一个测试函数来看看它的作用。首先,我们创建客户端并发出一个简单的 GET 请求:
async fn test_surf() -> Result<(), Exception> {
    println!("> surf ...");

    let client = surf::Client::new();
    let mut res = client
        .get("https://blog.x5ff.xyz/other/cookbook2018")
        .await?;

    assert_eq!(200, res.status());
    assert_eq!("Rust is awesome\n", res.body_string().await?);

然后,我们将升级到更复杂的内容,表单数据,我们也会确认它已经被很好地接收:

    let form_values = vec![
        ("custname", "Rusty Crabbington"),
        ("comments", "Thank you"),
        ("custemail", "rusty@nope.com"),
        ("custtel", "+1 234 33456"),
        ("delivery", "25th floor below ground, no elevator. sorry"),
    ];

    let res_forms: serde_json::Value = client
        .post("https://httpbin.org/post")
        .body_form(&form_values)?
        .recv_json()
        .await?;

    for (name, value) in form_values.iter() {
        assert_eq!(res_forms["form"][name], *value);
    }

接下来,对 JSON 有效负载重复相同的步骤:

    let json_payload = json!({
        "book": "Rust 2018 Cookbook",
        "blog": "https://blog.x5ff.xyz",
    });

    let res_json: serde_json::Value = client
        .put("https://httpbin.org/anything")
        .body_json(&json_payload)?
        .recv_json()
        .await?;

    assert_eq!(res_json["json"], json_payload);

最后,我们在 GET 请求中查询参数:

    let query_params = MyGetParams {
        a: 0x5ff,
        b: "https://blog.x5ff.xyz".into(),
    };
    let res_query: serde_json::Value = client
        .get("https://httpbin.org/get")
        .set_query(&query_params)?
        .recv_json()
        .await?;

    assert_eq!(res_query["args"]["a"], query_params.a.to_string());
    assert_eq!(res_query["args"]["b"], query_params.b);
    println!("> surf successful!");
    Ok(())
}
  1. 由于 surf 非常新,让我们也测试一个更成熟的(并且不是 async)crate,reqwest (github.com/seanmonstar/reqwest/))。就像之前的函数一样,它将通过几种不同的方式来完成不同类型的网络任务,从简单的 GET 请求开始:
fn test_reqwest() -> Result<(), Exception> {
    println!("> reqwest ...");

    let client = reqwest::Client::new();

    let mut res = client
        .get("https://blog.x5ff.xyz/other/cookbook2018")
        .send()?;

    assert_eq!(200, res.status());
    assert_eq!("Rust is awesome\n", res.text()?);

下一个请求具有 HTML 表单请求体:

    let form_values = vec![
        ("custname", "Rusty Crabbington"),
        ("comments", "Thank you"),
        ("custemail", "rusty@nope.com"),
        ("custtel", "+1 234 33456"),
        ("delivery", "25th floor below ground, no elevator. sorry"),
    ];

    let res_forms: serde_json::Value = client
        .post("https://httpbin.org/post")
        .form(&form_values)
        .send()?
        .json()?;

    for (name, value) in form_values.iter() {
        assert_eq!(res_forms["form"][name], *value);
    }

这之后是一个 JSON PUT 请求:

    let json_payload = json!({
        "book": "Rust 2018 Cookbook",
        "blog": "https://blog.x5ff.xyz",
    });

    let res_json: serde_json::Value = client
        .put("https://httpbin.org/anything")
        .json(&json_payload)
        .send()?
        .json()?;

    assert_eq!(res_json["json"], json_payload);

最终请求包含查询参数,由 serde 自动序列化:

    let query_params = MyGetParams {
        a: 0x5ff,
        b: "https://blog.x5ff.xyz".into(),
    };

    let res_query: serde_json::Value = client
        .get("https://httpbin.org/get")
        .query(&query_params)
        .send()?
        .json()?;

    assert_eq!(res_query["args"]["a"], query_params.a.to_string());
    assert_eq!(res_query["args"]["b"], query_params.b);

    println!("> reqwest successful!");
    Ok(())
}
  1. 还需要一个主要功能:main()。在这里,我们将调用前面的测试:
#[runtime::main]
async fn main() -> Result<(), Exception> {
    println!("Running some tests");
    test_reqwest()?;
    test_surf().await?;
    Ok(())
}
  1. 最重要的命令是 cargo +nightly run,这样我们就可以看到对两个 crate 都可以发出请求:
$ cargo +nightly run
 Finished dev [unoptimized + debuginfo] target(s) in 0.10s
 Running `target/debug/web-requests`
Running some tests
> reqwest ...
> reqwest successful!
> surf ...
> surf successful!

让我们幕后看看发生了什么。

它是如何工作的...

Rust 社区的 Web 框架是其他语言中学习到的经验如何影响更近期技术设计的绝佳例子。本章中讨论的两个 crate 都遵循一个类似的模式,这个模式可以在各种语言(例如 Python 的 requests)的众多库和框架中观察到,它们自身也发展到这一阶段。

这些框架的操作方式通常被称为构建器模式与装饰器模式(两者均在 设计模式,Gamma 等人,1994 年中描述)。对于 C# 程序员来说,该模式在 airbrake.io/blog/design-patterns/structural-design-patterns-decorator 中有解释。

在这个菜谱中,我们探讨了两个框架:reqwestsurf。在 Cargo.toml 中设置依赖项(步骤 2)之后,我们导入一些结构体来创建可序列化的数据类型(在 步骤 3 中传递给 serde_urlencoded (github.com/nox/serde_urlencoded)) 以用于 GET 参数。

步骤 4 中,我们创建了一个涵盖 surf 的函数。surf 是完全 async 的,这意味着为了使用 await,我们需要将函数声明为 async。然后,我们可以立即创建可重用的 surf::Client,它发出一个 GET 请求(到 blog.x5ff.xyz/other/cookbook2018)。与这个函数中的所有其他调用一样,我们使用 await 等待请求完成,并使用 ? 操作符在发生错误时失败。

在这个菜谱中,我们使用了极其有用的 httpbin.org/。这个网站将许多请求属性反射回发送者,使我们能够看到服务器接收到的 JSON 格式输出(以及其他内容)。

下一个请求是一个带有表单数据的 POST 请求,它可以表示为一个元组(键值对)的向量。使用之前的相同客户端(与其它框架不同,它不受特定域的限制),我们可以简单地将向量作为 POST 请求的表单体传递。由于我们已经知道端点将返回什么(JSON),我们可以要求框架立即将结果解析为 serde_json::Value(参见本章的 解析非结构化格式,如 JSON 菜谱)。同样,任何解析错误、超时等问题都由 ? 操作符处理,此时将返回错误。

返回的 JSON 包含请求中的表单值,确认请求中包含的数据符合预期的编码和格式。同样地,如果我们通过 PUT 请求发送 JSON 数据,期望返回的 JSON 应该与我们发送的相同。

在最后一个请求中,我们发送了带有从先前定义的 struct 自动构建的查询参数的 HTTP GET。在发送请求后,反射的 JSON 包含查询参数中的数据,这是我们发送的数据——如果我们(和库)一切都做得正确的话。

步骤 5reqwest 重复了相同的概念,只有少数 API 差异(除了功能之外):

  • futuresawait 不同,reqwest 使用 send() 来执行请求。

  • 声明接收数据格式(JSON、纯文本等)是在响应实例上完成的(即 send() 返回类型上)。

步骤 6 显示了每个测试函数都正常工作,没有报告任何恐慌或错误。

这两个库都提供了连接远程网络服务的优秀方法,其中 surf 在可移植性方面具有更多功能(例如,各种后端和 WASM 支持),而 reqwest 对于不需要 async 支持且需要 cookies 和代理的稳定应用来说非常出色。有关更多信息,请阅读它们各自的文档,以匹配您的项目和用例。现在,让我们继续下一个菜谱。

运行机器学习模型

机器学习和特别是深度学习自从 2012 年 AlexNet 胜利以来一直是一个热门话题 (papers.nips.cc/paper/4824-imagenet-classification-with-deep-convolutional-neural-networks.pdf),首选的语言通常是 Python,因为它的语法简单易用且灵活。然而,底层框架(TensorFlow、PyTorch 等)通常是用 C++ 构建的,这不仅是因为性能原因,也因为访问硬件(如 GPU)要容易得多。到目前为止,Rust 并不是实现底层框架的首选语言。即使在深度学习领域之外,Rust 在数据准备、传统机器学习和优化(进展跟踪在此:www.arewelearningyet.com/) 等多个领域缺乏库支持——那么,为什么要在任何机器学习任务中使用 Rust 呢?

Rust 社区为流行的深度学习框架提供了到 Rust API 的绑定,使用户可以进行一些(有限的)实验,以及使用已知架构的权重进行推理。虽然所有这些都高度实验性,但它代表了正确的方向,并且与它们一起工作非常有趣。

从长远来看,我们看到 Rust——作为一种底层语言——利用其低开销和高性能来促进机器学习模型的部署(即模型推理),针对资源有限的物联网设备(例如,github.com/snipsco/tract)。在此之前,我们可以享受让 Rust 的 torch 绑定工作起来的乐趣。一个使用 Rust 以高效方式处理非神经网络示例可以在 blog.x5ff.xyz/blog/azure-functions-wasm-rust-ai/ 找到。

准备工作

这个配方不能涵盖神经网络是如何以及为什么工作的细节,所以我们假设你已经对训练和测试数据集、卷积网络的作用以及损失函数与优化器如何一起实现模型收敛有了一定的了解。如果这句话对你来说没有意义,我们建议在实施此配方之前,参加许多在线课程之一,例如 www.fast.ai/ 的 MOOC (course.fast.ai/)、Coursera 的机器学习课程 (www.coursera.org/learn/machine-learning) 或 Microsoft AI 学院 (aischool.microsoft.com/en-us/machine-learning/learning-paths)。

要获取数据,切换到 rusty-ml 目录,并从 github.com/zalandoresearch/fashion-mnist 克隆(或下载并解压)Zalando Research 的时尚 MNIST (research.zalando.com/welcome/mission/research-projects/fashion-mnist/) 仓库。最终,你应该在 rusty-ml 项目目录中拥有三个目录,即 modelsfashion-mnistsrc

在伴随本书的 GitHub 仓库中,fashion-mnist 仓库被作为一个 Git 子模块(git-scm.com/book/en/v2/Git-Tools-Submodules)。如果你在你的本地仓库副本中运行 git submodule update --init,它将下载 fashion-mnist 仓库。

在我们继续之前,我们需要解压位于 fashion-mnist/data/fashion 的数据文件。在 Linux/macOS 上,你可以在该目录中运行 gunzip *.gz 以提取所有文件;在 Windows 上,使用你喜欢的工具进行相同的操作。

最终结果应该看起来像这样:

rusty-ml
├── Cargo.toml
├── fashion-mnist
│   ├── ...
│   ├── data
│   │   ├── fashion
│   │   │   ├── t10k-images-idx3-ubyte
│   │   │   ├── t10k-labels-idx1-ubyte
│   │   │   ├── train-images-idx3-ubyte
│   │   │   └── train-labels-idx1-ubyte
│   │   └── mnist
│   │   └── README.md
|   └── ...
├── models
└── src
    └── main.rs

原始的 MNIST([yann.lecun.com/exdb/mnist/](http://yann.lecun.com/exdb/mnist/))是一个由小图像(28 x 28 像素,灰度)组成的数据库,展示了手写的数字,目标是将它们分类到 0 到 9 的类别中——即识别数字。20 年后,现代算法以极高的准确率解决了这个任务,因此需要升级——这是德国柏林的时尚公司 Zalando 所承担的任务。fashion-mnist数据集是原始数据集的替代品,显示的是小件服装而不是数字。由于这些物品的复杂细节构成了十个类别中的每一个物品,对这些物品的分类要困难得多。任务是正确分类服装物品属于哪个类别(十个类别中的哪一个)。这些类别包括靴子、运动鞋、裤子、T 恤和其他。

在这个菜谱中,我们将使用 Rust 的 PyTorch 绑定tch-rs训练一个非常准确(约 90%)的模型来识别这些物品。

如何做到这一点...

训练和使用 Rust 中的神经网络只需要几个步骤:

  1. 打开Cargo.toml以添加tch-rs的依赖项:
[dependencies]
tch = "0.1"
failure ="0.1"
  1. 在深入研究之前,让我们先在src/main.rs中添加一些导入代码:
use std::io::{Error, ErrorKind};
use std::path::Path;
use std::time::Instant;
use tch::{nn, nn::ModuleT, nn::OptimizerConfig, Device, Tensor};
  1. PyTorch(以及因此,tch-rs)架构通常单独存储它们的层,因此我们可以在struct中单独的属性中存储它们:
#[derive(Debug)]
struct ConvNet {
    conv1: nn::Conv2D,
    conv2: nn::Conv2D,
    fc1: nn::Linear,
    fc2: nn::Linear,
}

impl ConvNet {
    fn new(vs: &nn::Path, labels: i64) -> ConvNet {
        ConvNet {
            conv1: nn::conv2d(vs, 1, 32, 5, Default::default()),
            conv2: nn::conv2d(vs, 32, 64, 5, Default::default()),
            fc1: nn::linear(vs, 1024, 512, Default::default()),
            fc2: nn::linear(vs, 512, labels, Default::default()),
        }
    }
}
  1. 为了使这些层作为一个神经网络协同工作,需要一个前向传递。tchnn模块提供了两个特性(ModuleModuleT),我们可以实现它们来完成这个任务。我们决定实现ModuleT
impl nn::ModuleT for ConvNet {
    fn forward_t(&self, xs: &Tensor, train: bool) -> Tensor {
        xs.view([-1, 1, 28, 28])
            .apply(&self.conv1)
            .relu()
            .max_pool2d_default(2)
            .apply(&self.conv2)
            .relu()
            .max_pool2d_default(2)
            .view([-1, 1024]) // flatten
            .apply(&self.fc1)
            .relu()
            .dropout_(0.5, train)
            .apply(&self.fc2)
    }
}
  1. 接下来,我们将实现训练循环。其他深度学习框架通常将这些部分隐藏起来,但 PyTorch 允许我们通过从头编写来更好地理解这些步骤。将以下函数添加到src/main.rs中,从一些数据加载开始:
fn train_from_scratch(learning_rate: f64, batch_size: i64, epochs: usize) -> failure::Fallible<()> {
    let data_path = Path::new("fashion-mnist/data/fashion");
    let model_path = Path::new("models/best.ot");

    if !data_path.exists() {
        println!(
            "Data not found at '{}'. Did you run '
             git submodule update --init'?",
            data_path.to_string_lossy()
        );
        return Err(Error::from(ErrorKind::NotFound).into());
    }

    println!("Loading data from '{}'", data_path.to_string_lossy());
    let m = tch::vision::mnist::load_dir(data_path)?;

然后,我们实例化两个重要的事情:VarStore,在tch中保存一切,以及ConvNet,这是我们之前声明的:

    let vs = nn::VarStore::new(Device::cuda_if_available());
    let net = ConvNet::new(&vs.root(), 10);
    let opt = nn::Adam::default().build(&vs, learning_rate)?;

    println!(
        "Starting training, saving model to '{}'",
        model_path.to_string_lossy()
    );

一旦我们有了这个,我们就可以使用循环来遍历训练数据(随机)批次,将它们输入到网络中,计算损失,并运行反向传播:

    let mut min_loss = ::std::f32::INFINITY;
    for epoch in 1..=epochs {
        let start = Instant::now();

        let mut losses = vec![];

        // Batched training, otherwise we would run out of memory
        for (image_batch, label_batch) in m.train_iter(
             batch_size).shuffle().to_device(vs.device())
        {
            let loss = net
                .forward_t(&image_batch, true)
                .cross_entropy_for_logits(&label_batch);
            opt.backward_step(&loss);

            losses.push(f32::from(loss));
        }
        let total_loss = losses.iter().sum::<f32>() / 
                         (losses.len() as f32);

在遍历整个训练集之后,我们然后在整个测试集上测试模型。由于这不应该影响模型性能,我们这次跳过了反向传播:

         // Predict the test set without using batches
        let test_accuracy = net
            .forward_t(&m.test_images, false)
            .accuracy_for_logits(&m.test_labels);

最后,我们打印一些统计数据,以便我们知道我们是否在正确的轨道上,但只有在保存当前最佳模型权重(即损失最低的地方)之后:

        // Checkpoint
        if total_loss <= min_loss {
            vs.save(model_path)?;
            min_loss = total_loss;
        }

        // Output for the user
        println!(
            "{:4} | train loss: {:7.4} | test acc: {:5.2}% 
             | duration: {}s",
            epoch,
            &total_loss,
            100\. * f64::from(&test_accuracy),
            start.elapsed().as_secs()
        );
    }
    println!(
        "Done! The best model was saved to '{}'",
        model_path.to_string_lossy()
    );
    Ok(())
}
  1. 在训练好模型之后,你通常还想要在其他图像上运行推理(即预测内容)。下一个函数接受最佳模型的权重并将其应用于ConvNet架构:
fn predict_from_best() -> failure::Fallible<()> {
    let data_path = Path::new("fashion-mnist/data/fashion");
    let model_weights_path = Path::new("models/best.ot");

    let m = tch::vision::mnist::load_dir(data_path)?;
    let mut vs = nn::VarStore::new(Device::cuda_if_available());
    let net = ConvNet::new(&vs.root(), 10);

    // restore weights
    println!(
        "Loading model weights from '{}'",
        model_weights_path.to_string_lossy()
    );
    vs.load(model_weights_path)?;

使用这个模型,我们可以然后取训练数据的一个随机子集并运行推理:

    println!("Probabilities and predictions 
              for 10 random images in the test set");
    for (image_batch, label_batch) in m.test_iter(1)
         .shuffle().to_device(vs.device()).take(10) {
        let raw_tensor = net
            .forward_t(&image_batch, false)
            .softmax(-1)
            .view(m.labels);
        let predicted_index: Vec<i64> = 
            raw_tensor.argmax(0, false).into();
        let probabilities: Vec<f64> = raw_tensor.into();

        print!("[ ");
        for p in probabilities {
            print!("{:.4} ", p);
        }
        let label: Vec<i64> = label_batch.into();
        println!("] predicted {}, was {}", 
                  predicted_index[0], label[0]);
    }
    Ok(())
}
  1. main函数将所有这些整合在一起,在调用推理函数之前训练一个模型:
 fn main() -> failure::Fallible<()> {
    train_from_scratch(1e-2, 1024, 5)?;
    predict_from_best()?;
    Ok(())
}
  1. 这很令人兴奋!让我们训练一个模型几个周期,看看损失逐渐减少,测试准确率分数逐渐提高:
$ cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.19s
     Running `target/debug/rusty-ml`
Loading data from 'fashion-mnist/data/fashion'
Starting training, saving model to 'models/best.ot'
   1 | train loss: 1.1559 | test acc: 82.87% | duration: 29s
   2 | train loss: 0.4132 | test acc: 86.70% | duration: 32s
   3 | train loss: 0.3383 | test acc: 88.41% | duration: 32s
   4 | train loss: 0.3072 | test acc: 89.16% | duration: 29s
   5 | train loss: 0.2869 | test acc: 89.36% | duration: 28s
Done! The best model was saved to 'models/best.ot'
Loading model weights from 'models/best.ot'
Probabilities and predictions for 10 random images in the test set
[ 0.0000 1.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 ] predicted 1, was 1
[ 0.5659 0.0001 0.0254 0.0013 0.0005 0.0000 0.4062 0.0000 0.0005 0.0000 ] predicted 0, was 0
[ 0.0003 0.0000 0.9699 0.0000 0.0005 0.0000 0.0292 0.0000 0.0000 0.0000 ] predicted 2, was 2
[ 0.0000 1.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 ] predicted 1, was 1
[ 0.6974 0.0000 0.0008 0.0001 0.0000 0.0000 0.3017 0.0000 0.0000 0.0000 ] predicted 0, was 0
[ 0.0333 0.0028 0.1053 0.7098 0.0420 0.0002 0.1021 0.0007 0.0038 0.0001 ] predicted 3, was 2
[ 0.0110 0.0146 0.0014 0.9669 0.0006 0.0000 0.0038 0.0003 0.0012 0.0000 ] predicted 3, was 3
[ 0.0003 0.0001 0.0355 0.0014 0.9487 0.0001 0.0136 0.0001 0.0004 0.0000 ] predicted 4, was 4
[ 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 1.0000 0.0000 0.0000 ] predicted 7, was 7
[ 0.0104 0.0091 0.0037 0.8320 0.0915 0.0001 0.0505 0.0002 0.0026 0.0000 ] predicted 3, was 3

这是一次非常有趣的机器学习世界之旅。让我们了解更多关于它。

它是如何工作的...

在 Rust 中进行深度学习是可行的——然而,它附带了许多条件。如果你已经了解一些 PyTorch,tch-rs (github.com/LaurentMazare/tch-rs) 是一个很好的框架,它让你可以立即开始使用。然而,对于任何刚开始接触机器学习的人来说,应该看看 Python(和 PyTorch),以熟悉所需的思维方式。tch-rs 使用 Python 版本的 C++ 基础,并对其创建的绑定提供了一个薄薄的包装。这意味着两件事:

  • Python 版本的多数想法应该适用于 tch-rs

  • 大量的 C++ 使用可能 非常 不安全。

通过使用绑定,由于抽象层和宿主语言中编程范式的改变,封装的代码更有可能因为添加的抽象层而留下一些类型的内存未释放。对于机器学习等应用,其中数十(甚至数百)GB 的内存使用并不罕见,内存泄漏的影响要大得多。然而,看到它已经如此出色地工作是非常令人兴奋的,我们预计这个项目将走得更远。

为了简洁,我们对模型训练过程做了一些简化。建议在进一步操作之前,研究如何正确评估模型并排除过拟合。

步骤 1 中,我们设置了 tch 依赖项以解决 步骤 2 中使用的导入。步骤 3 是事情变得有趣的地方(模型架构)。深度学习是一系列矩阵乘法,从技术上讲,输入和输出维度必须匹配才能工作。由于 PyTorch (pytorch.org/) 以其低级而闻名,我们必须手动设置各个层并匹配它们的维度。在这种情况下,我们使用两层二维卷积和两个密集层在最后来理解卷积发现的内容。当我们使用 new() 函数初始化网络时,我们将输入大小、神经元/滤波器数量和输出/层分配给实例化函数(nn::conv2dnn::linear)。如您所见,层之间的数字匹配,以便能够将它们连接起来,而最后一层正好输出我们寻找的类数量(10)。

张量是数学中向量的泛化版本。它们可以是单个数字(标量)到多维向量向量的多维度向量。更多信息请参阅 mathworld.wolfram.com/Tensor.html(警告:有很多数学内容)。

在第 4 步中,我们实现了由nn::ModuleT特性提供的正向过程。与nn::Module的区别在于train参数,它表示forward_t()函数中的这次运行是否旨在进行训练。该函数中的另一个参数是实际数据,表示为nn::Tensor引用。在我们能够使用它之前,我们必须为其分配一个结构,并且由于我们处理的是(灰度)图像,所以选择很简单:它是一个 4 维张量。维度分配如下:

  • 第一维度是批次,因此其中包含从 0 到batchsize数量的图像。

  • 第二维度表示图像中的通道数,对于灰度图像是 1,对于 RGB 图像是 3。

  • 在最后两个维度中,我们存储的是实际图像,因此它们是图像的宽度和高度。

因此,当我们对张量实例调用.view()函数时,我们正在将这些维度作为解释,其中-1 表示适合的任何值(对于批次大小来说是典型的)。从那时起,我们处理的是 28 x 28 x 1 的图像,我们将这些图像输入到第一个卷积层,并在结果上应用ReLUmachinelearningmastery.com/rectified-linear-activation-function-for-deep-learning-neural-networks/)函数。这后面跟着一个 2 维的最大池化层,之后对第二个卷积层重复此模式。这是常见的,用于控制卷积层的输出大小。在第二次最大池化之后,我们将输出向量展平(1,024 是一个计算值:medium.com/@iamvarman/how-to-calculate-the-number-of-parameters-in-the-cnn-5bd55364d7ca),然后依次应用带有 ReLU 函数的全连接层。最后层的原始输出随后作为张量返回。

在第 5 步的训练循环中,我们首先从磁盘读取数据,使用预定义的数据集函数。我们利用这一点,因为 MNIST 数据在机器学习示例中非常常见。最终,这是一个数据(在这种情况下,图像)的迭代器,附带一些实用的函数。实际上,由于数据已经分为训练集和测试集,所以有多个迭代器。

一旦加载,我们创建一个nn::VarStore,这是tch-rs概念,用于存储模型权重。这个VarStore实例被传递到我们的模型架构结构体ConvNet和优化器中,以便它可以进行反向传播(Adam arxiv.org/abs/1412.6980 是一个随机优化器,截至 2019 年初,被认为是最佳实践)。由于 PyTorch 允许在设备之间移动数据(即 CPU 与 GPU 之间),我们总是必须为权重和数据分配一个设备,以便框架知道写入哪个内存。

学习率参数表示优化器跳向最佳解决方案的步长。这个参数几乎总是非常小(例如,1e-2),因为选择更大的值可能会超过目标并恶化解决方案,而太小的值可能意味着它永远不会到达那里。更多信息请参阅www.jeremyjordan.me/nn-learning-rate/

接下来在训练循环中,我们必须实现实际的循环。这个循环运行几个时期,一般来说,数字越大意味着收敛性越好(例如,过度拟合:machinelearningmastery.com/overfitting-and-underfitting-with-machine-learning-algorithms/),但在这个配方中我们选择的数字(5)显然太低,是为了让训练快速完成并得到有形的结果。尝试一个更高的数字,看看模型是否有所改进!在每个时期内,我们可以运行通过打乱批次的操作(由数据集实现提供的便利函数),运行正向传递并计算每个批次的损失。损失函数——交叉熵(pytorch.org/docs/stable/nn.html#crossentropyloss)——会返回一个数字,告诉我们预测偏离了多少,这对于运行反向传播很重要。在这个例子中,我们选择了一次性处理 1,024 张图像的大批量,这意味着每个时期必须运行循环 59 次。这加快了过程,而对训练质量的影响不大——如果你能将所有内容都放入内存中。

将损失函数视为一个确定模型错误程度的函数。通常,我们会根据问题的类型(回归、二分类或多分类)选择一个预定义的损失函数。对于多分类,交叉熵是默认选项。

当我们遍历批次时,我们也想知道我们的表现如何,这就是为什么我们创建了一个简单的向量来存储每个批次的平均损失。绘制每个时期的损失,我们得到一个典型的形状,损失逐渐趋于零:

由于算法已经看到了训练数据,我们需要一些测试数据来查看它是否真正改进了,或者它只是学会了很好地识别训练数据。这就是为什么测试集不进行反向传播,直接计算准确率的原因。

通常建议将数据分为三个部分(machinelearningmastery.com/difference-test-validation-datasets/)。模型学习的训练集应占大多数,测试集应在每个 epoch 后显示进度和过拟合,最后,还有另一组网络之前从未见过的数据。最后一个目的是确保它在真实世界数据上的表现符合预期,并且不能用于训练中更改任何参数。令人困惑的是,这三个部分的命名有时是训练、验证、测试(分别)以及训练、测试、验证。

在一种称为检查点(checkpointing)的策略中,我们一旦发现模型产生的损失低于之前,就立即将最佳模型保存到磁盘。在训练 200 个 epoch 时,损失函数可能会出现几个峰值,因为模型学习到了错误特征,我们不希望丢失迄今为止的最佳模型。一旦完成一个 epoch 的训练,我们想要打印出一些信息来查看模型是否如预期那样收敛。

步骤 6中,我们重复了一些加载数据的设置过程,但不是训练架构,而是简单地从磁盘加载网络的权重。权重是我们上一步训练的部分,在仅推理的场景中,我们会在其他地方训练并将权重转移到我们分类真实世界数据的地方(或者使用类似 ONNX 的东西加载整个模型:onnx.ai/)。

为了说明预测过程,我们再次使用测试集(在实际情况中应避免这样做,因为模型必须像训练数据一样处理未见过的数据)。我们取 10 张随机图像(在 10 个大小为 1 的批次中),运行前向传播,然后使用一个名为 softmax 的函数从原始网络输出中推导出概率。在应用.view()以对齐数据到标签后,我们将概率打印到命令行供我们查看。由于这些是概率,取概率最高的索引就是网络的预测。由于我们使用了数据集实现,我们可以相信这些索引与输入标签相匹配。

步骤 7按顺序调用函数,我们在步骤 8的输出中看到一些训练和预测。正如步骤 5的解释所述,我们打印损失(对于这台机器,每行大约需要 30 秒出现)和训练准确率。训练完成后,我们知道最佳模型权重在哪里,并使用这些权重进行推理并打印出概率矩阵。

该矩阵中的每一行都代表每个类别的可能结果,并分配了相应的概率——虽然第一行是 100%确定的,但第二行则更接近(类别 0 为 57%,类别 6 为 40%)。第六个例子被错误地预测了,不幸的是,模型也相当自信(类别 3 为 71%,类别 2 为 11%),这让我们相信需要更多的训练。

我们鼓励您稍微调整一下参数,看看结果如何快速变化(无论是好是坏),或者如果您更有经验,可以构建更好的架构。无论您做什么,tch-rs都是使用 Rust 进行深度学习的一种有趣方式,我们希望它能进一步发展,以便我们可以在机器学习的各种任务中使用它。

现在我们对 Rust 中的机器学习有了更多的了解,让我们继续到下一个菜谱中更具体的内容。

配置和使用日志记录

虽然将调试和其他信息发送到控制台很流行且简单,但很可能会在一定的复杂性之后变得混乱。这包括缺乏标准化的日期/时间或来源类别或格式不一致,这使得难以通过系统追踪执行路径。此外,最近系统将日志视为信息的一个附加来源:我们每小时服务了多少用户?他们来自哪里?95^(th)百分位响应时间是多少?

由于打印限制,我们不得不将原始表情符号替换为它们的名称。查看这本书的 GitHub 仓库以获取完整版本。

这些问题可以通过使用提供一致和可配置输出的框架进行勤奋的日志记录来回答,这些输出可以轻松解析并发送到日志分析服务。让我们创建一个简单的 Rust 应用程序,以多种方式记录数据。

如何做到这一点...

按照以下步骤创建和使用自定义日志记录器:

  1. 打开终端,使用cargo new logging创建一个新的项目。使用 VS Code 打开项目目录。

  2. 作为第一步,我们将Cargo.toml适配以包含我们的新依赖项:

[dependencies]
log = "0.4"
log4rs = "0.8.3"
time = "0.1"
  1. 然后,在src/main.rs中,我们可以导入所需的宏:
use log::{debug, error, info, trace, warn};
  1. 在深入研究更复杂的内容之前,让我们添加一个函数,展示我们如何使用刚刚导入的宏:
fn log_some_stuff() {
    let a = 100;

    trace!("TRACE: Called log_some_stuff()");
    debug!("DEBUG: a = {} ", a);
    info!("INFO: The weather is fine");
    warn!("WARNING, stuff is breaking down");
    warn!(target: "special-target", "WARNING, stuff is breaking down");
    error!("ERROR: stopping ...");
}
  1. 这些宏之所以有效,是因为它们是由日志框架预先配置的。因此,如果我们正在配置日志,它必须全局进行——例如,在main函数中:
const USE_CUSTOM: bool = false;

fn main() {
    if USE_CUSTOM { 
    log::set_logger(&LOGGER)
        .map(|()| log::set_max_level(log::LevelFilter::Trace))
        .unwrap();
    } else {
        log4rs::init_file("log4rs.yml", Default::default()).unwrap();
    }
    log_some_stuff();
}
  1. 通过使用log4rs::init_file(),我们使用一个可以不重新编译程序即可更改的 YAML 配置。在继续src/main.rs之前,我们应该创建一个类似于下面的log4rs.yml(YAML 格式对缩进很挑剔):
refresh_rate: 30 seconds

appenders:
  stdout:
    kind: console

  outfile:
    kind: file
    path: "outfile.log"
    encoder:
      pattern: "{d} - {m}{n}"

root:
  level: trace
  appenders:
    - stdout

loggers:
  special-target:
    level: info
    appenders:
      - outfile
  1. 回到src/main.rs:我们看到了创建和使用完全自定义日志记录器的功能。为此,我们在src/main.rs中创建一个嵌套模块并在这里实现我们的日志记录器:
mod custom {
    pub use log::Level;
    use log::{Metadata, Record};

    pub struct EmojiLogger {
        pub level: Level,
    }

一旦我们定义了导入和基本 struct,我们就可以为我们的新 EmojiLogger 类型实现 log::Log 特性:

    impl log::Log for EmojiLogger {

        fn flush(&self) {}        

        fn enabled(&self, metadata: &Metadata) -> bool {
            metadata.level() <= self.level
        }

        fn log(&self, record: &Record) {
            if self.enabled(record.metadata()) {
                let level = match record.level() {
                    Level::Warn => "WARNING-SIGN",
                    Level::Info => "INFO-SIGN",
                    Level::Debug => "CATERPILLAR",
                    Level::Trace => "LIGHTBULB",
                    Level::Error => "NUCLEAR",
                };
                let utc = time::now_utc();
                println!("{} | [{}] | {:<5}", 
                         utc.rfc3339(), record.target(), level);
                println!("{:21} {}", "", record.args());
            }
        }
    }
}
  1. 为了避免任何生命周期冲突,我们希望日志器具有静态生命周期 (doc.rust-lang.org/reference/items/static-items.html),因此让我们使用 Rust 的 static 关键字实例化和声明变量:
static LOGGER: custom::EmojiLogger = custom::EmojiLogger {
    level: log::Level::Trace,
};
  1. 让我们执行 cargo run 命令,首先将 USE_CUSTOM 常量(在 步骤 5 中创建)设置为 false,这告诉程序读取并使用 log4rs.yaml 配置文件,而不是自定义模块:
$ cargo run
 Finished dev [unoptimized + debuginfo] target(s) in 0.04s
     Running `target/debug/logging`
2019-09-01T12:42:18.056681073+02:00 TRACE logging - TRACE: Called log_some_stuff()
2019-09-01T12:42:18.056764247+02:00 DEBUG logging - DEBUG: a = 100 
2019-09-01T12:42:18.056791639+02:00 INFO logging - INFO: The weather is fine
2019-09-01T12:42:18.056816420+02:00 WARN logging - WARNING, stuff is breaking down
2019-09-01T12:42:18.056881011+02:00 ERROR logging - ERROR: stopping ...

此外,我们还配置了它,如果将某些内容记录到 special-target,则将其追加到名为 outfile.log 的文件中。让我们也看看里面有什么:

2019-09-01T12:45:25.256922311+02:00 - WARNING, stuff is breaking down
  1. 既然我们已经使用了 log4rs 默认的日志记录器,让我们看看我们自己的日志类做了什么。将 USE_CUSTOM(来自 步骤 5)设置为 true 并使用 cargo run 生成以下输出:
$ cargo run
   Compiling logging v0.1.0 (Rust-Cookbook/Chapter10/logging)
    Finished dev [unoptimized + debuginfo] target(s) in 0.94s
     Running `target/debug/logging`
2019-09-01T10:46:43Z | [logging] | LIGHTBULB 
                      TRACE: Called log_some_stuff()
2019-09-01T10:46:43Z | [logging] | CATERPILLAR 
                      DEBUG: a = 100 
2019-09-01T10:46:43Z | [logging] | INFO-SIGN 
                      INFO: The weather is fine
2019-09-01T10:46:43Z | [logging] | WARNING-SIGN 
                      WARNING, stuff is breaking down
2019-09-01T10:46:43Z | [special-target] | WARNING-SIGN 
                      WARNING, stuff is breaking down
2019-09-01T10:46:43Z | [logging] | NUCLEAR 
                      ERROR: stopping ...

既然我们已经看到了它的工作情况,让我们深入探讨为什么会出现这种情况。

它是如何工作的...

在这个更复杂的例子中,我们使用 Rust 的日志基础设施,它由两个主要部分组成:

步骤 1步骤 2 的初始设置之后,我们只需导入在 步骤 3 中由 log crate 提供的宏——就这么多。当我们创建一个函数来写入所有可用的日志级别(将级别视为过滤的标签)以及在此行 步骤 4 中的附加目标时(如下所示),我们就涵盖了大多数日志记录的使用案例:

warn!(target: "special-target", "WARNING, stuff is breaking down");

步骤 5 设置了日志框架,log4rs,这是一个模仿 Java 世界事实标准的 crate:log4j (logging.apache.org/log4j/2.x/)。该 crate 在确定日志级别和格式的地方提供了出色的灵活性,并且可以在运行时进行更改。查看 步骤 6 配置文件以查看示例。在那里我们定义了 refresh_rate(何时重新扫描文件以查找更改)为 30 秒,这使得我们可以在不重新启动应用程序的情况下更改文件。接下来,我们定义了两个追加器,这意味着输出目标。第一个,stdout,是一个简单的控制台输出,而 outfile 生成 outfile.log,我们在 步骤 10 中展示了它。其编码属性也暗示了我们可以如何更改格式。

接下来,我们定义了一个root日志记录器,它代表默认设置。默认级别为trace会导致许多情况下日志记录过多;在warn通常就足够了,尤其是在生产环境中。在日志记录器属性中创建了额外的日志记录器,其中每个子(special-target)代表我们可以在日志宏中使用的目标(如前所述)。这些目标带有可配置的日志级别(在这种情况下为info)并可以使用一系列追加器来写入。这里还有许多其他选项可以使用——只需查看如何设置更复杂场景的文档即可。

步骤 7中,我们回到 Rust 代码并创建我们自己的日志记录器。这个日志记录器直接实现了 log crate 的Log特质,并将任何传入的log::Record转换为带有表情符号的终端输出,以供我们视觉娱乐。通过实现enabled(),我们可以过滤是否调用log(),因此我们的决策不仅仅基于简单的日志级别。我们在步骤 8中将EmojiLogger结构体实例化为一个静态变量(doc.rust-lang.org/reference/items/static-items.html),每次我们将USE_CUSTOM常量(步骤 5)设置为true时,都会将其传递给log::set_logger()函数。步骤 9步骤 10展示了这两个结果:

  • log4rs默认格式包括模块、日志级别、时间戳和消息,并创建了配置的outfile.log文件。

  • 我们的定制日志记录器创建了不寻常的格式,并附带一个显示日志级别的表情符号——正如我们想要的。

在 Rust 中,log crate 特别有用,因为它允许你将你自己的日志记录器附加到第三方 crate 上。本章中用于发出网络请求的 crate(在发送网络请求菜谱中)提供了基础设施(docs.rs/surf/1.0.2/surf/middleware/logger/index.html)来做这件事,就像许多其他 crate 一样(例如,在早期章节中的actix-web)。这意味着,只需添加一个依赖项和几行代码,你就可以创建一个完全带有日志记录的应用程序。

这就结束了我们对日志记录的偏离,让我们继续到另一个菜谱。

启动子进程

管道、容器编排和命令行工具都共享一个共同的任务:它们都必须启动和监控其他程序。这些系统调用在其他技术中以各种方式完成,所以让我们用 Rust 的Command接口调用几个标准程序。

如何做...

按照以下快速步骤调用外部程序:

  1. 打开终端,使用cargo new sub-processes.创建一个新的项目。使用 VS Code 打开项目目录。

  2. 打开src/main.rs。Rust 的标准库内置了一个外部命令接口,但首先,让我们导入它:

use std::error::Error;
use std::io::Write;
use std::process::{Command, Stdio};
  1. 一旦导入,我们就可以在main函数中做其余的事情。我们首先会在两个不同的目录中使用一些参数调用ls
fn main() -> Result<(), Box<dyn Error + Send + Sync + 'static>> {
    let mut ls_child = Command::new("ls");
    if !cfg!(target_os = "windows") {
        ls_child.args(&["-alh"]);
    }
    println!("{}", ls_child.status()?);
    ls_child.current_dir("src/");
    println!("{}", ls_child.status()?);

在下一步中,我们在子进程中设置环境变量,并通过抓取env程序的标准输出,我们可以检查它是否工作:

    let env_child = Command::new("env")
        .env("CANARY", "0x5ff")
        .stdout(Stdio::piped())
        .spawn()?;

    let env_output = &env_child.wait_with_output()?;
    let canary = String::from_utf8_lossy(&env_output.stdout)
    .split_ascii_whitespace()
    .filter(|line| *line == "CANARY=0x5ff")
    .count();

    // found it!
    assert_eq!(canary, 1);    

rev是一个程序,它反转通过标准输入传入的任何内容,它在 Windows 和 Linux/Unix 上可用。让我们用一些文本调用它并捕获输出:

    let mut rev_child = Command::new("rev")
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .spawn()?;

    {
        rev_child
            .stdin
            .as_mut()
            .expect("Could not open stdin")
            .write_all(b"0x5ff")?;
    }

    let output = rev_child.wait_with_output()?;
    assert_eq!(String::from_utf8_lossy(&output.stdout), "ff5x0");

    Ok(())
}

  1. 使用cargo run来查看程序打印ls输出(你的输出可能会有点不同):
$ cargo run
   Compiling sub-processes v0.1.0 (Rust-Cookbook/Chapter10/sub-processes)
    Finished dev [unoptimized + debuginfo] target(s) in 0.44s
     Running `target/debug/sub-processes`
total 24K
drwxr-xr-x. 4 cm cm 4.0K Aug 26 09:21 .
drwxr-xr-x. 13 cm cm 4.0K Aug 11 23:27 ..
-rw-r--r--. 1 cm cm 145 Aug 26 09:21 Cargo.lock
-rw-r--r--. 1 cm cm 243 Jul 26 10:23 Cargo.toml
drwxr-xr-x. 2 cm cm 4.0K Jul 26 10:23 src
drwxr-xr-x. 3 cm cm 4.0K Aug 26 09:21 target
exit code: 0
total 12K
drwxr-xr-x. 2 cm cm 4.0K Jul 26 10:23 .
drwxr-xr-x. 4 cm cm 4.0K Aug 26 09:21 ..
-rw-r--r--. 1 cm cm 1.1K Aug 31 11:49 main.rs
exit code: 0

Windows 用户必须在 PowerShell 中运行此程序,其中ls可用。

让我们看看它背后的工作原理。

它是如何工作的...

这个配方快速介绍了 Rust std::process::Command结构的一些功能。在步骤 1步骤 2中设置好一切后,我们在步骤 3中创建main函数。使用Result<(), Box<dyn Error + ...>>作为主函数的返回类型,允许我们使用?运算符而不是unwrap()expect()或其他构造——无论实际的错误类型如何。

我们首先使用ls命令,该命令列出目录内容。除了 Windows 外,程序接受参数以扩展输出:

  • -l添加额外的信息,如权限、日期和大小(也称为长列表)。

  • -a还包括隐藏文件(a 代表所有)。

  • -h使用人类友好的大小(例如,1,000 字节后的 KiB)。

对于ls,我们可以将这些标志作为一个大标志传递,-alh(顺序无关紧要),而args()函数允许我们将其作为字符串切片来完成。实际执行进程子代只有在检查实例的status()函数时才会进行,在这里,我们也在打印结果。状态码(在 Linux 上)分别代表特定程序的成功或失败,当它是zeronon-zero时。

下一个部分捕获程序的标准输出并为它设置一个环境变量。环境变量也可以是一种将数据或设置传输到子程序的好方法(例如,编译器标志用于构建和命令行 API 的键)。envlinux.die.net/man/1/env)是 Linux 上的一个程序(PowerShell 有等效版本),它打印可用的环境变量,因此当我们捕获标准输出时,我们可以尝试找到变量及其值。

下一个部分通过标准输入将数据传递给rev程序,同时捕获标准输出。rev简单地反转输入数据,因此我们预计输出将是输入的反转。有两个有趣的事情需要注意:

  • 获取标准输入句柄的范围是为了避免违反借用规则。

  • 从管道中写入和读取是以字节为单位的,这需要解析以将数据转换为/从字符串转换。String::from_utf8_lossy()函数执行这个操作,同时忽略无效数据。

之后,main函数返回一个正的空结果(Ok(()))。

在最后一步,像往常一样,我们运行代码来查看它是否工作,尽管在我们的源文件中只有两个println!()语句,并且只有ls命令的退出代码,但输出却很多。这是因为默认设置是通过控制台传递子进程的标准输出。因此,我们看到的是 Linux 上ls -alh命令的输出,这在你的机器上可能会有所不同。

在成功创建和运行了几个 Rust 命令之后,我们现在可以出去创建自己的应用程序了。我们希望这本书能在这方面帮助你。

posted @ 2025-09-06 13:42  绝不原创的飞龙  阅读(3)  评论(0)    收藏  举报