Rust-开发者的系统编程实践指南-全-
Rust 开发者的系统编程实践指南(全)
原文:
annas-archive.org/md5/c1119759384389da35e418769d631d23译者:飞龙
前言
现代软件堆栈在规模和复杂性上正在迅速演变。如云计算、网络、数据科学、机器学习、DevOps、容器、物联网、嵌入式系统、分布式账本、虚拟和增强现实以及人工智能等技术领域持续发展和专业化。这导致系统软件开发者严重短缺,他们能够构建系统基础设施组件。现代社会、企业和政府越来越依赖数字技术,这更加重视开发安全、可靠和高效的系统软件和软件基础设施,这些是现代网络和移动应用程序所依赖的。
系统编程语言如 C/C++ 在这个领域已经证明了几十年,提供了高度的控制力和性能,但这是以牺牲内存安全为代价的。
高级语言如 Java、C#、Python、Ruby 和 JavaScript 提供了内存安全,但提供了较少的内存布局控制,并且遭受垃圾回收暂停的困扰。
Rust 是一种现代的开源系统编程语言,它承诺提供三个世界的最佳之处:Java 的类型安全;C++ 的速度、表达性和效率;以及没有垃圾回收器的内存安全。
这本书采用了一种独特的三步法来教授 Rust 中的系统编程。本书的每一章都从 Unix-like 操作系统(Unix/Linux/macOS)中该主题的系统编程基础和内核系统调用概述开始。然后,你将学习如何使用 Rust 标准库以及在某些情况下使用外部 crate 执行常见系统调用,使用大量的代码片段。然后,通过一个你将构建的实际示例项目来巩固这些知识。最后,每一章都有问题来巩固学习。
在这本书的结尾,你将有一个坚实的理解,了解如何使用 Rust 来管理和控制操作系统资源,如内存、文件、进程、线程、系统环境、外围设备、网络接口、终端和外壳,并且你会了解如何通过 FFI 构建跨语言绑定。在这个过程中,你将学习如何使用行业工具,并深刻理解 Rust 为构建安全、高效、可靠和系统级软件带来的价值。
本书面向的对象
这本书的目标读者是对 Rust 有基本了解但几乎没有系统编程或经验的程序员。这本书也适合有系统编程背景并希望将 Rust 作为 C/C++ 替代方案的人。
读者应该对任何语言的编程概念有一个基本的理解,例如 C、C++、Java、Python、Ruby、JavaScript 或 Go。
本书涵盖的内容
第一章, 行业工具 – Rust 工具链和项目结构,介绍了 Rust 工具链用于构建和依赖管理、自动化测试和文档。
第二章, Rust 编程语言之旅,通过一个示例项目展示了 Rust 编程语言的关键概念,包括类型系统、数据结构和内存管理基础。
第三章, Rust 标准库简介,介绍了 Rust 标准库的关键模块,这些模块提供了系统编程的基础构建块和预定义功能。
第四章, 管理环境、命令行和时间,涵盖了围绕如何通过编程方式处理命令行参数、设置和操作进程环境以及与系统时间工作的几个基础主题。
第五章, Rust 中的内存管理,全面审视了 Rust 提供的内存管理功能。我们将回顾 Linux 内存管理基础知识、C/C++的传统缺点以及 Rust 如何克服这些缺点。
第六章, 在 Rust 中处理文件和目录,帮助你理解 Linux 文件系统的工作原理,以及如何掌握 Rust 标准库以应对文件和目录操作的各种场景。
第七章, 在 Rust 中实现终端 I/O,帮助你理解伪终端应用程序的工作原理以及如何创建一个。结果将是一个处理流的交互式应用程序。
第八章, 处理进程和信号,解释了进程是什么,如何在 Rust 中处理它们,如何创建和与子进程通信,以及如何处理信号和错误。
第九章, 管理并发,以 Rust 的惯用方式解释了并发的基础知识以及跨线程共享数据的各种机制,包括通道、互斥锁和引用计数器。
第十章, 处理设备 I/O,解释了 Linux I/O 概念,如缓冲、标准输入输出和设备 I/O,并展示了如何使用 Rust 控制 I/O 操作。
第十一章, 学习网络编程,解释了如何在 Rust 中与低级网络原语和协议一起工作,通过构建低级 TCP 和 UDP 服务器和客户端以及反向代理来展示。
第十二章,“编写不安全 Rust 和 FFI”,描述了与不安全 Rust 相关的关键动机和风险,并展示了如何使用 FFI 安全地将 Rust 与其他编程语言接口。
要充分利用本书
Rustup 必须在您的本地开发环境中安装。使用此链接进行安装:github.com/rust-lang/rustup。
请参阅以下链接以获取官方安装说明:www.rust-lang.org/tools/install。
安装后,使用以下命令检查rustc和cargo是否已正确安装:
rustc --version
cargo –version
您可以使用 Linux、macOS 或 Windows。
尽管 Rust 标准库在很大程度上是平台无关的,但本书的一般风格是面向基于 Linux/Unix 的系统。因此,在少数章节(或章节中的某些部分)中,建议使用本地 Linux 虚拟机,如 Virtual box,(或者如果您有云虚拟机,您也可以使用它)来运行章节中的代码。这可能是因为示例代码和项目中使用的命令、外部 crate 或共享库可能是 Linux/Unix 特定的。
有关在 Windows 上进行开发的说明
某些章节需要运行类似 Unix OS 的虚拟机或 Docker 镜像。
每一章中都有两种类型的代码,这些代码放置在本书的 Packt GitHub 仓库中:
-
与章节中命名的源文件中引用的示例项目对应的代码
-
独立的代码片段,放置在每个章节的
miscellaneous文件夹中(如果适用)
如果您使用的是本书的数字版,我们建议您亲自输入代码或通过 GitHub 仓库(下一节中提供链接)访问代码。这样做将有助于您避免与代码复制和粘贴相关的任何潜在错误。
当使用cargo run命令构建和运行 Rust 程序时,如果运行命令的用户 ID 没有足够的权限执行系统级操作(如读取或写入文件),您可能会遇到'权限被拒绝'的消息。在这种情况下,可以使用以下命令之一作为解决方案来运行程序:
sudo env "PATH=$PATH" cargo run
下载示例代码文件
您可以从www.packt.com的账户下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。
您可以通过以下步骤下载代码文件:
-
在 www.packt.com 登录或注册。
-
选择支持标签。
-
点击代码下载。
-
在搜索框中输入本书的名称,并遵循屏幕上的说明。
下载文件后,请确保使用最新版本的软件解压缩或提取文件夹:
-
Windows 上的 WinRAR/7-Zip
-
Mac 上的 Zipeg/iZip/UnRarX
-
Linux 上的 7-Zip/PeaZip
本书代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Practical-System-Programming-for-Rust-Developers。如果代码有更新,它将在现有的 GitHub 仓库中更新。
我们还有其他来自我们丰富图书和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们吧!
注意
本书中的代码片段是为了学习而设计的,并不旨在具有生产质量。因此,虽然代码示例是实用的,并使用了惯用的 Rust,但它们可能不会具有完整的功能,并且不会涵盖所有类型的边缘情况。这是有意为之,以免阻碍学习过程。
下载彩色图像
我们还提供了一份包含本书中使用的截图/图表彩色图像的 PDF 文件。您可以从这里下载:static.packt-cdn.com/downloads/9781800560963_ColorImages.pdf。
使用的约定
本书使用了多种文本约定。
文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“我们可以从Utc模块中访问now()函数来打印当前日期和时间。”
代码块是这样设置的:
fn main() {
println!("Hello, time now is {:?}", chrono::Utc::now());
}
当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:
fn main() {
println!("Hello, time now is {:?}", chrono::Utc::now());
}
任何命令行输入或输出都应如下编写:
rustup toolchain install nightly
粗体:表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词在文本中显示如下。以下是一个示例:“您将在控制台看到Hello, world!打印出来。”
小贴士或重要注意事项
看起来是这样的。
联系我们
我们欢迎读者的反馈。
一般反馈:如果您对本书的任何方面有疑问,请在邮件主题中提及书名,并通过 customercare@packtpub.com 给我们发邮件。
勘误:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,如果您能向我们报告,我们将不胜感激。请访问www.packtpub.com/support/errata,选择您的书,点击勘误提交表单链接,并输入详细信息。
盗版:如果您在互联网上遇到任何形式的我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过 mailto:copyright@packt.com 与我们联系,并提供材料的链接。
如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com。
评论
请留下评论。一旦您阅读并使用了这本书,为何不在您购买它的网站上留下评论呢?潜在读者可以查看并使用您的客观意见来做出购买决定,我们 Packt 可以了解您对我们产品的看法,我们的作者也可以看到他们对书籍的反馈。谢谢!
如需了解 Packt 的更多信息,请访问packt.com。
第一部分:Rust 系统编程入门
本节涵盖了 Rust 系统编程背后的基础概念。它包括对 Rust 特性的游览,Cargo 工具,Rust 标准库,用于管理环境变量、命令行参数以及处理时间的模块。示例项目包括一个用于评估算术表达式的解析器,编写 HTML 模板引擎的功能,以及构建用于图像处理的命令行工具。
本节包括以下章节:
-
第一章,行业工具 – Rust 工具链和项目结构
-
第二章,Rust 编程语言之旅
-
第三章,Rust 标准库简介
-
第四章,环境管理、命令行和时间处理
第一章:第一章:行业工具 – Rust 工具链和项目结构
Rust 作为一种现代的系统编程语言,具有许多固有的特性,使得编写安全、可靠和高效的代码变得更加容易。Rust 还有一个编译器,它使得随着项目规模和复杂性的增长,代码重构体验相对无忧。但任何编程语言本身如果没有支持软件开发生命周期的工具链都是不完整的。毕竟,如果没有工具,软件工程师将何去何从?
本章特别讨论了 Rust 工具链及其生态系统,以及如何在 Rust 项目中组织代码,以编写安全、可测试、高效、有文档和维护性好的代码,同时优化以在目标环境中运行。
本章节的关键学习成果如下:
-
为你的项目选择正确的 Rust 配置
-
Cargo 介绍和项目结构
-
Cargo 构建管理
-
Cargo 依赖项
-
编写测试脚本和进行自动单元和集成测试
-
自动生成技术文档
到本章结束时,你将学会如何选择正确的项目类型和工具链;高效组织项目代码;添加外部和内部库作为依赖项;为开发、测试和生产环境构建项目;自动化测试;并为你的 Rust 代码生成文档。
技术要求
Rustup 必须安装在本地的开发环境中。请使用此链接进行安装:github.com/rust-lang/rustup。
请参考以下链接获取官方安装说明:www.rust-lang.org/tools/install。
安装后,使用以下命令检查 rustc 和 cargo 是否已正确安装:
rustc --version
cargo --version
你必须能够访问你选择的任何代码编辑器。
本章中的一些代码和命令,特别是与共享库和设置路径相关的代码,需要在 Linux 系统环境中运行。建议安装一个本地虚拟机,如 VirtualBox 或等效产品,并安装 Linux 以便处理本章中的代码。安装 VirtualBox 的说明可以在以下链接找到:www.virtualbox.org。
本章示例的 Git 仓库可以在以下链接找到:github.com/PacktPublishing/Practical-System-Programming-for-Rust-Developers/tree/master/Chapter01。
为你的项目选择正确的 Rust 配置
当你开始学习 Rust 编程时,你首先需要选择一个 Rust 发布渠道和一个 Rust 项目类型。
本节讨论了 Rust 的 发布渠道 的细节,并提供了关于如何为你的项目选择它们的指导。
Rust 还允许你构建不同类型的二进制文件——独立可执行文件、静态库和动态库。如果你事先知道你将构建什么,你可以使用为你生成的脚手架代码创建正确的项目类型。
我们将在本节中介绍这些内容。
选择 Rust 发布通道
Rust 编程语言是持续开发的,在任何时候都有三个版本在同时开发,每个版本称为发布通道。每个通道都有其目的,具有不同的功能和稳定性特征。三个发布通道是稳定、beta和夜间。不稳定语言功能和库在夜间和beta通道中开发,而稳定通道提供稳定性保证。
Rustup是安装 Rust 编译器、Rust 标准库、Cargo 包管理器和用于代码格式化、测试、基准测试和文档等活动的其他核心工具的工具。所有这些工具都可用作多种称为工具链的版本。工具链是发布通道和宿主的组合,并且可选地还有一个相关的存档日期。
Rustup可以从发布通道或其他来源(如官方存档和本地构建)安装工具链。Rustup还根据宿主平台确定工具链。Rust 官方支持 Linux、Windows 和 macOS。因此,Rustup被称为工具多路复用器,因为它安装和管理多个工具链,从这个意义上讲,它与Ruby中的rbenv、Python中的pyenv或Node.js中的nvm类似。
Rustup 管理与工具链相关的复杂性,但由于它提供了合理的默认设置,安装过程相对简单。这些设置可以由开发者稍后修改。
注意
Rust 的稳定版本每 6 周发布一次;例如,Rust 1.42.0 于 2020 年 3 月 12 日发布,6 周后的同一天,Rust 1.43 于 2020 年 4 月 23 日发布。
每天都会发布一个新的 Rust 夜间版本。每 6 周,最新的夜间版本的主分支将成为 beta 版本。
大多数 Rust 开发者主要使用稳定通道。beta 通道的发布并不活跃,但仅用于测试 Rust 语言发布中的任何回归。
夜间通道用于活跃的语言开发,并且每晚发布。夜间通道允许 Rust 开发新的和实验性功能,并允许早期采用者在它们稳定之前测试它们。早期访问的代价是,在它们进入稳定发布之前,这些功能可能会有破坏性的变化。Rust 使用功能标志来确定给定夜间版本中启用了哪些功能。想要在夜间版本中使用前沿功能的使用者必须使用适当的功能标志对代码进行注释。
这里展示了功能标志的一个例子:
#![feature(try_trait)]
注意,beta 和稳定版本不能使用功能标志。
rustup默认配置为使用稳定通道。要使用其他通道,这里有一些命令。对于完整的列表,请参阅官方链接:github.com/rust-lang/rustup。
要安装夜间 Rust,请使用以下命令:
rustup toolchain install nightly
要全局激活夜间 Rust,请使用以下命令:
rustup default nightly
要在目录级别激活夜间 Rust,请使用以下命令:
rustup override set nightly
要获取夜间 Rust 中编译器的版本,请使用以下命令:
rustup run nightly rustc –-version
要将rustup重置为使用稳定通道,请使用以下命令:
rustup default stable
要显示已安装的工具链以及当前活动的是哪一个,请使用以下命令:
rustup show
要更新已安装的工具链到最新版本,请使用以下命令:
rustup update
注意,一旦设置了rustup default <channel-name>,其他相关工具,如 Cargo 和 Rustc,将使用默认通道设置。
对于你的项目,应该使用哪个 Rust 通道?对于任何面向生产的项目,建议只使用稳定发布通道。对于任何实验性项目,可以使用夜间或beta通道,但需谨慎,因为未来版本中可能会有破坏性更改。
选择 Rust 项目类型
Rust 中有两种基本的项目类型:库和二进制文件(或可执行文件)。
一个 库 是一个自包含的代码片段,旨在供其他程序使用。库的目的是通过利用其他开源开发者的辛勤工作来促进代码重用并加快开发周期。库也称为crates.io,可以被其他开发者发现和下载,用于他们自己的程序。库 crate 的程序执行从src/lib.rs文件开始。
一个 二进制文件 是一个独立的可执行文件,它可以下载并链接其他库到一个单独的二进制文件中。二进制项目类型也称为main()函数,该函数位于src/main.rs文件中。
在初始化项目时确定你想要在 Rust 中构建二进制或库程序是很重要的。我们将在本章后面看到这两种类型项目的示例。现在是时候介绍 Rust 生态系统中的明星工具和瑞士军刀——Cargo。
介绍 Cargo 和项目结构
Cargo 是 Rust 的官方构建和依赖管理工具。它具有许多其他流行工具的许多功能,如 Ant、Maven、Gradle、npm、CocoaPods、pip 和 yarn,但为编译代码、下载和编译依赖库(在 Rust 中称为crate)、链接库、构建开发和发布二进制文件提供了更加无缝和集成的开发者体验。它还执行代码的增量构建,以减少程序演变过程中的编译时间。此外,在创建新的 Rust 项目时,它还创建了一个惯用的项目结构。
简而言之,Cargo 作为一个集成工具链,在创建新项目、构建项目、管理外部依赖、调试、测试、生成文档和发布管理等日常任务中提供了无缝体验。
Cargo 是可以用来为新 Rust 项目设置基本项目脚手架结构的工具。在我们使用 Cargo 创建新的 Rust 项目之前,让我们首先了解在 Rust 项目中组织代码的选项:

图 1.1 – Cargo 项目结构和层次
图 1.1展示了如何在 Cargo 生成的 Rust 项目中组织代码。
在 Rust 项目中,代码组织的最小独立单元是main.rs源文件。
代码组织的下一个高级别是模块。模块内的代码有其自己的唯一命名空间。一个模块可以包含用户定义的数据类型(如结构体、特性和枚举)、常量、类型别名、其他模块导入和函数声明。模块可以嵌套在彼此内部。在较小的项目中,可以在单个源文件中定义多个模块定义,或者一个模块可以包含跨越多个源文件的代码,这在较大的项目中也很常见。这种组织方式也被称为模块系统。
多个模块可以组织到main.rs中,对于库 crate 则是lib.rs。
一个或多个 crate 可以组合成一个Cargo.toml文件,其中包含有关如何构建包的信息,包括下载和链接依赖的 crate。当使用 Cargo 创建新的 Rust 项目时,它创建一个package。一个package必须至少包含一个 crate – 要么是库 crate,要么是二进制 crate。一个 package 可以包含任意数量的二进制 crate,但它可以包含零个或仅有一个库 crate。
随着 Rust 项目规模的扩大,可能需要将一个包拆分成多个单元并独立管理。一组相关的包可以组织成一个Cargo.lock文件(包含工作区中所有包共享的依赖项特定版本的详细信息)和输出目录。
让我们通过几个例子来了解 Rust 中各种项目结构的类型。
使用 Cargo 自动化构建管理
当 Rust 代码编译和构建时,生成的二进制文件可以是独立的可执行二进制文件,也可以是其他项目可以使用的库。在本节中,我们将探讨如何使用 Cargo 创建 Rust 的二进制文件和库,以及如何配置Cargo.toml中的元数据以提供构建指令。
构建基本的二进制 crate
在本节中,我们将构建一个基本的二进制 crate。当构建二进制 crate 时,会产生一个可执行的二进制文件。这是 cargo 工具的默认 crate 类型。现在让我们看看创建二进制 crate 的命令。
-
第一步是使用
cargo new命令生成 Rust 源代码包。 -
在你的工作目录内的终端会话中运行以下命令以创建一个新的包:
--bin flag is to tell Cargo to generate a package that, when compiled, would produce a binary crate (executable).`first-program` is the name of the package given. You can specify a name of your choice. -
一旦命令执行,你将看到以下目录结构:
[package] name = "first-program" version = "0.1.0" authors = [<your email>] edition = "2018"并且
src目录中包含一个名为main.rs的文件:fn main() { println!("Hello, world!"); } -
要从这个包生成二进制 crate(或可执行文件),请运行以下命令:
target in the project root and creates a binary crate (executable) with the same name as the package name (first-program, in our case) in the location target/debug. -
从命令行执行以下操作:
cargo run你将在控制台看到以下输出:
Hello, world! [[bin]] name = "new-first-program" path = "src/main.rs" -
在命令行中运行以下命令:
new-first-program in the target/debug folder. You will see Hello, world! printed to your console. -
一个 cargo 包可以包含多个二进制的源代码。让我们学习如何向我们的项目中添加另一个二进制。在
Cargo.toml中,在第一个[[bin]]目标下方添加一个新的[[bin]]目标:[[bin]] name = "new-first-program" path = "src/main.rs" [[bin]] name = "new-second-program" path = "src/second.rs" -
接下来,创建一个新的文件,
src/second.rs,并添加以下代码:fn main() { println!("Hello, for the second time!"); } -
运行以下命令:
cargo run --bin new-second-program
你将看到名为 new-second-program 的 target/debug 目录中的声明。
恭喜!你已经学会了以下内容:
-
创建你的第一个 Rust 源包并将其编译成一个可执行二进制 crate
-
给二进制取一个新名字,不同于包名
-
向同一个 cargo 包添加第二个二进制
注意,一个 cargo 包可以包含一个或多个二进制 crate。
配置 Cargo
一个 cargo 包有一个关联的 Cargo.toml 文件,也称为 清单。
清单至少包含 [package] 部分,但可以包含许多其他部分。这里列出了部分部分:
指定包的输出目标:Cargo 包可以有五种类型的目标:
-
[[bin]]: 二进制目标是构建后可以运行的可执行程序。 -
[lib]: 库目标生成一个库,可以被其他库和可执行程序使用。 -
[[example]]: 这个目标对于库来说很有用,可以通过示例代码向用户展示外部 API 的使用。位于example目录中的示例源代码可以使用此目标构建成可执行二进制。 -
[[test]]: 位于tests目录中的文件代表集成测试,并且这些文件中的每一个都可以编译成一个单独的可执行二进制。 -
[[bench]]: 定义在库和二进制中的基准函数被编译成单独的可执行程序。
对于这些目标中的每一个,都可以指定配置,包括目标名称、目标源文件以及是否让 cargo 自动运行测试脚本并为目标生成文档。你可能还记得,在前一节中,我们更改了名称并设置了生成的二进制可执行文件的源文件。
指定包的依赖项:包中的源文件可能依赖于其他内部或外部库,这些库也被称为 依赖项。每个依赖项反过来可能依赖于其他库,依此类推。Cargo 下载本节中指定的依赖项列表,并将它们链接到最终输出目标。依赖项的多种类型包括以下:
-
[dependencies]: 包库或二进制依赖 -
[dev-dependencies]:用于示例、测试和基准测试的依赖项 -
[build-dependencies]:构建脚本(如果有指定)的依赖项 -
[target]:这是用于为各种目标架构交叉编译代码的。注意,这不要与包的输出目标混淆,包的输出可以是 lib、bin 等等。
指定构建配置文件:在构建 cargo 包时可以指定四种类型的配置文件:
-
dev:cargo build命令默认使用dev配置文件。使用此选项构建的包针对编译时速度进行了优化。 -
release:使用cargo build –-release命令启用发布配置文件,这适合用于生产发布,并且针对运行时速度进行了优化。 -
test:cargo test命令使用此配置文件。这用于构建测试可执行文件。 -
bench:cargo bench命令创建基准测试的可执行文件,它会自动运行所有带有#[bench]属性的函数。
[workspace]部分可以用来定义工作空间中包含的包列表。
构建静态库 crate
我们已经看到了如何创建二进制 crate。现在让我们学习如何创建库 crate:
cargo new --lib my-first-lib
新建 cargo 项目的默认目录结构如下:
├── Cargo.toml
├── src
│ └── lib.rs
在src/lib.rs中添加以下代码:
pub fn hello_from_lib(message: &str) {
println!("Printing Hello {} from library",message);
}
执行以下操作:
cargo build
你将看到在target/debug下构建的库,它将具有libmy_first_lib.rlib的名称。
要调用这个库中的函数,让我们构建一个小的二进制 crate。在src下创建一个bin目录,并创建一个新文件,src/bin/mymain.rs。
添加以下代码:
use my_first_lib::hello_from_lib;
fn main() {
println!("Going to call library function");
hello_from_lib("Rust system programmer");
}
use my_first_lib::hello_from_lib语句告诉编译器将库函数引入到本程序的范围内。
执行以下操作:
cargo run --bin mymain
你将在你的控制台中看到print语句。同时,二进制文件mymain将被放置在target/debug文件夹中,与我们之前编写的库文件一起。二进制 crate 会在同一文件夹中寻找库文件,在这种情况下它找到了。因此,它能够调用库中的函数。
如果你想要将mymain.rs文件放置在另一个位置(而不是在src/bin内),那么在Cargo.toml中添加一个目标,并像以下示例中那样指定二进制文件的名字和路径,然后将mymain.rs文件移动到指定位置:
[[bin]]
name = "mymain"
path = "src/mymain.rs"
执行cargo run --bin mymain,你将在你的控制台中看到println输出。
自动化依赖项管理
在上一节中,你学习了如何使用 Cargo 设置新项目的基目录结构和脚手架,以及如何构建各种类型的二进制和库 crate。在本节中,我们将探讨 Cargo 的依赖项管理功能。
Rust 附带一个内置的标准库,由语言原语和常用函数组成,但按设计来说很小(与其他语言相比)。大多数现实世界的 Rust 程序都依赖于额外的外部库来提高功能性和开发者生产力。任何此类外部代码都是程序的依赖项。Cargo 使得指定和管理依赖项变得容易。
在 Rust 生态系统中,crates.io是发现和下载库(称为crates.io作为默认包注册表)的中心公共包注册表。
依赖项在Cargo.toml的[dependencies]部分中指定。让我们看看一个例子。
使用以下命令开始一个新项目:
cargo new deps-example && cd deps-example
在Cargo.toml中,进行以下条目以包含外部库:
[dependencies]
chrono = "0.4.0"
Chrono是一个日期时间库。这被称为依赖项,因为我们的deps-examplecrate 依赖于这个外部库来实现其功能。
当你运行cargo build时,cargo 会查找crates.io上具有此名称和版本的 crate。如果找到,它将下载此 crate 及其所有依赖项,编译它们,并将下载的包的确切版本更新到名为Cargo.lock的文件中。Cargo.lock文件是一个生成文件,不应进行编辑。
Cargo.toml中的每个依赖项都在新的一行中指定,并采用格式<crate-name> = "<semantic-version-number>"。语义版本或 Semver的格式为 X.Y.Z,其中 X 是主版本号,Y 是次版本号,Z 是补丁版本号。
指定依赖项的位置
在Cargo.toml中指定依赖项的位置和版本有许多方法,其中一些在此处总结:
-
Crates.io 注册表:这是默认选项,我们只需指定包名和版本字符串,就像在本节中之前所做的那样。
-
crates.io是默认注册表,Cargo 提供了使用备用注册表选项。注册表名称必须在.cargo/config文件中进行配置,并在Cargo.toml中创建一个条目,如下例所示:[dependencies] cratename = { version = "2.1", registry = "alternate- registry-name" } -
Cargo.toml文件以获取其依赖项。 -
指定本地路径:Cargo 支持路径依赖项,这意味着库可以是主 cargo 包内的子 crate。在构建主 cargo 包时,也被指定为依赖项的子 crate 将被构建。但只有路径依赖项的依赖项不能上传到crates.io公共注册表。
-
多个位置:Cargo 支持指定注册表版本和 Git 或路径位置。对于本地构建,使用 Git 或路径版本,当包发布到crates.io时将使用注册表版本。
在源代码中使用依赖包
一旦在 Cargo.toml 文件中指定了依赖项,无论以何种格式,我们都可以在包代码中使用外部库,如下例所示。将以下代码添加到 src/main.rs:
use chrono::Utc;
fn main() {
println!("Hello, time now is {:?}", Utc::now());
}
use 语句告诉编译器将 chrono 包的 Utc 模块引入到本程序的范围内。然后我们可以从 Utc 模块访问 now() 函数来打印当前的日期和时间。use 语句不是强制的。打印日期时间的另一种方法如下:
fn main() {
println!("Hello, time now is {:?}", chrono::Utc::now());
}
这将给出相同的结果。但是,如果你需要在代码中多次使用 chrono 包中的函数,使用 use 语句一次性将 chrono 和所需模块引入范围会更方便,这会使输入变得更简单。
还可以使用 as 关键字重命名导入的包:
use chrono as time;
fn main() {
println!("Hello, time now is {:?}", time::Utc::now());
}
有关管理依赖项的更多详细信息,请参阅 Cargo 文档:doc.rust-lang.org/cargo/reference/specifying-dependencies.html。
在本节中,我们看到了如何向包中添加依赖项。可以在 Cargo.toml 中添加任意数量的依赖项并在程序中使用。Cargo 使依赖项管理过程变得相当愉快。
现在我们来看 Cargo 的另一个有用功能——运行自动化测试。
编写和运行自动化测试
Rust 编程语言内置了对编写自动化测试的支持。
Rust 测试基本上是 Rust 函数,用于验证包中编写的其他非测试函数是否按预期工作。它们基本上使用指定数据调用其他函数,并断言返回值符合预期。
Rust 有两种测试类型——单元测试和集成测试。
在 Rust 中编写单元测试
使用以下命令创建一个新的 Rust 包:
cargo new test-example && cd test-example
编写一个返回当前运行进程的进程 ID 的新函数。我们将在后面的章节中查看进程处理细节,所以你只需输入以下代码即可,因为这里的重点是编写单元测试:
use std::process;
fn main() {
println!("{}", get_process_id());
}
fn get_process_id() -> u32 {
process::id()
}
我们已经编写了一个简单的(愚蠢的)函数,使用标准库进程模块并检索当前运行进程的进程 ID。
使用 cargo check 运行代码以确认没有语法错误。
现在我们来编写一个单元测试。请注意,我们事先不知道进程 ID 会是多少,所以我们只能测试是否返回了一个数字:
#[test]
fn test_if_process_id_is_returned() {
assert!(get_process_id() > 0);
}
运行 cargo test。你会看到测试已经成功通过,因为函数返回了一个非零正整数。
注意,我们已经将单元测试写在了与代码相同的源文件中。为了告诉编译器这是一个测试函数,我们使用了 #[test] 注解。assert! 宏(在标准 Rust 库中可用)用于检查条件是否评估为真。还有两个其他宏可用,assert_eq! 和 assert_ne!,它们用于测试传递给这些宏的两个参数是否相等或不等。
也可以指定自定义错误消息:
#[test]
fn test_if_process_id_is_returned() {
assert_ne!(get_process_id(), 0, "There is error in code");
}
要编译但不运行测试,请使用 cargo test 命令的 --no-run 选项。
前面的例子只有一个简单的 test 函数,但随着测试数量的增加,以下问题会出现:
-
我们如何编写测试代码所需的任何辅助函数,并将其与其他包代码区分开来?
-
我们如何防止编译器将测试作为每个构建的一部分进行编译(以节省时间),并且不将测试代码作为正常构建的一部分(节省磁盘/内存空间)?
为了提供更多模块化和解决前面的问题,在 Rust 中将测试函数分组在 test 模块中是一种惯例:
#[cfg(test)]
mod tests {
use super::get_process_id;
#[test]
fn test_if_process_id_is_returned() {
assert_ne!(get_process_id(), 0, "There is
error in code");
}
}
这里是代码所做的更改:
-
我们已经将
test函数移动到tests模块下。 -
我们添加了
cfg属性,它告诉编译器只有在尝试运行测试时才编译测试代码(即,仅对cargo test,而不是对cargo build)。 -
有一个
use语句,它将get_process_id函数引入tests模块的范围内。请注意,tests是一个内部模块,因此我们使用super::前缀将正在测试的函数引入tests模块的范围内。
cargo test 现在将给出相同的结果。但我们实现的是更大的模块化,我们还允许条件编译测试代码。
在 Rust 中编写集成测试
在 Rust 中编写单元测试 部分,我们看到了如何定义 tests 模块来保存单元测试。这是用来测试细粒度代码片段的,如单个函数调用。单元测试很小,关注面很窄。
对于涉及更大范围代码的更广泛的测试场景,例如工作流程,需要集成测试。编写这两种类型的测试对于完全确保库按预期工作非常重要。
要编写集成测试,Rust 中的惯例是在包根目录下创建一个 tests 目录,并在该目录下创建一个或多个文件,每个文件包含一个集成测试。tests 目录下的每个文件都被视为一个单独的 crate。
但有一个问题。Rust 中的集成测试不适用于二进制 crate,仅适用于库 crate。因此,让我们创建一个新的库 crate:
cargo new --lib integ-test-example && cd integ-test-example
在 src/lib.rs 中,用以下代码替换现有代码。这是我们之前写的相同代码,但这次它在 lib.rs 中:
use std::process;
pub fn get_process_id() -> u32 {
process::id()
}
让我们创建一个 tests 文件夹并创建一个文件,tests/integration_test1.rs。在这个文件中添加以下代码:
use integ_test_example;
#[test]
fn test1() {
assert_ne!(integ_test_example::get_process_id(), 0, "Error
in code");
}
注意与单元测试相比,以下是对测试代码的以下更改:
-
集成测试位于库外部,因此我们必须将库引入集成测试的作用域。这是模拟外部用户如何从我们的库的公共接口调用函数。这是在单元测试中用
super::前缀将测试函数引入作用域的替代方案。 -
我们不需要在集成测试中指定
#[cfg(test)]注解,因为这些测试存储在单独的文件夹中,并且 cargo 只有在运行cargo test时才会编译此目录下的文件。 -
我们仍然必须为每个
test函数指定#[test]属性,以告诉编译器这些是测试函数(而不是辅助/实用代码)要执行的。
运行cargo test。你会看到这个集成测试已经成功运行。
控制测试执行
cargo test命令以测试模式编译源代码并运行生成的二进制文件。cargo test可以通过指定命令行选项以各种模式运行。以下是关键选项的摘要。
通过名称运行测试子集
如果一个包中有大量测试,cargo test默认每次都会运行所有测试。要按名称运行特定的测试用例,可以使用以下选项:
cargo test —- testfunction1, testfunction2
为了验证这一点,让我们将integration_test1.rs文件中的代码替换为以下内容:
use integ_test_example;
#[test]
fn files_test1() {
assert_ne!(integ_test_example::get_process_id(),0,"Error
in code");
}
#[test]
fn files_test2() {
assert_eq!(1+1, 2);
}
#[test]
fn process_test1() {
assert!(true);
}
这个最后的虚拟test函数是为了演示如何运行选择性的案例。
运行cargo test,你可以看到两个测试都执行了。
运行cargo test files_test1,你可以看到files_test1被执行。
运行cargo test files_test2,你可以看到files_test2被执行。
运行cargo test files,你会看到files_test1和files_test2测试被执行,但process_test1没有被执行。这是因为 cargo 寻找所有包含术语'files'的测试用例并执行它们。
忽略一些测试
在某些情况下,你希望每次执行大多数测试,但排除几个。这可以通过在test函数上标注#[ignore]属性来实现。
在上一个例子中,假设我们想要排除process_test1的常规执行,因为它计算密集,执行时间很长。以下代码片段展示了如何操作:
#[test]
#[ignore]
fn process_test1() {
assert!(true);
}
运行cargo test,你会看到process_test1被标记为忽略,因此没有执行。
仅在单独的迭代中运行被忽略的测试,请使用以下选项:
cargo test —- --ignored
第一个--是cargo命令和test二进制命令行选项之间的分隔符。在这种情况下,我们正在为测试二进制传递--ignored标志,因此需要这种看似令人困惑的语法。
顺序或并行运行测试
默认情况下,cargo test 在单独的线程中并行运行各种测试。为了支持这种执行模式,测试函数必须以没有测试案例之间共享公共数据的方式编写。然而,如果确实有这样的需求(例如,一个测试案例将一些数据写入某个位置,另一个测试案例读取它),那么我们可以按以下方式按顺序运行测试:
cargo test -- --test-threads=1
这个命令告诉 cargo 只使用一个线程来执行测试,这间接意味着测试必须按顺序执行。
总结来说,Rust 强大的内置类型系统和编译器强制执行的严格所有权规则,加上能够将单元和集成测试案例作为语言和工具的一部分进行脚本化和执行的能力,使得编写健壮、可靠的系统非常吸引人。
记录你的项目。
Rust 附带了一个名为Rustdoc的工具,它可以生成 Rust 项目的文档。Cargo 与Rustdoc集成,因此你可以使用任何工具来生成文档。
要了解为 Rust 项目生成文档的含义,请访问docs.rs。
这是一个为crates.io中所有 crate 提供的文档仓库。要查看生成的文档样本,选择一个 crate 并查看文档。例如,你可以访问docs.rs/serde来查看 Rust 中流行的序列化/反序列化库的文档。
要为你的 Rust 项目生成类似的文档,重要的是要考虑要记录什么,以及如何记录。
但你可以记录什么?以下是一些有用的 crate 记录方面:
-
对你的 Rust 库所做事情的整体简短描述。
-
库中模块和公共函数的列表。
-
一份其他项目的列表,例如
traits、macros、structs、enums和typedefs,公共用户需要熟悉这些内容才能使用各种功能。 -
对于二进制 crate,安装说明和命令行参数。
-
举例说明用户如何使用 crate。
-
可选地,crate 的设计细节。
现在我们知道了要记录什么,我们必须学习如何记录。记录你的 crate 有两种方法:
-
在 crate 中编写内联文档注释。
-
分离的 Markdown 文件。
你可以使用任何一种方法,rustdoc 工具会将它们转换为浏览器可以查看的 HTML、CSS 和 JavaScript 代码。
在 crate 中编写内联文档注释。
Rust 有两种类型的注释:代码注释(面向开发者)和文档注释(面向库/crate 的用户)。
代码注释使用以下方式编写:
-
使用
//进行单行注释和在 crate 中编写内联文档注释。 -
使用
/* */进行多行注释。
文档注释使用两种风格编写:
第一种风格是使用三个斜杠///来注释随后的单个项目。Markdown 标记可以用于注释的样式(例如,粗体或斜体)。这通常用于项目级文档。
第二种风格是使用//!。这用于为包含这些注释的项目添加文档(与第一种风格相反,第一种风格用于注释随后的项目)。这通常用于 crate 级文档。
在这两种情况下,rustdoc都会从 crate 的文档注释中提取文档。
在integ-test-example项目中的src/lib.rs文件中添加以下注释:
//! This is a library that contains functions related to
//! dealing with processes,
//! and makes these tasks more convenient.
use std::process;
/// This function gets the process ID of the current
/// executable. It returns a non-zero number
pub fn get_process_id() -> u32 {
process::id()
}
运行cargo doc –open以查看与文档注释对应的生成的 HTML 文档。
在 Markdown 文件中编写文档
在 crate 根目录下创建一个新的文件夹doc,并添加一个名为itest.md的新文件,包含以下 Markdown 内容:
# Docs for integ-test-example crate
This is a project to test `rustdoc`.
[Here is a link!](https://www.rust-lang.org)
// Function signature
pub fn get_process_id() -> u32 {}
此函数返回当前运行的可执行文件的过程 ID:
// Example
```rust
use integ_test_example;
fn get_id() -> i32 {
let my_pid = get_process_id();
println!("当前进程的进程 ID 为: {}", my_pid);
}
```rs
注意,前面的代码示例仅用于说明。
不幸的是,Cargo 在编写时并不直接支持从独立的 Markdown 文件生成 HTML(在此写作时),因此我们必须使用rustdoc如下:
rustdoc doc/itest.md
你将在同一文件夹中找到生成的 HTML 文档itest.html。在浏览器中查看它。
运行文档测试
如果文档中包含任何代码示例,rustdoc可以将代码示例作为测试执行。
让我们为我们的库编写一个代码示例。打开src/lib.rs,并将以下代码示例添加到现有代码中:
//! Integration-test-example crate
//!
//! This is a library that contains functions related to
//! dealing with processes
//! , and makes these tasks more convenient.
use std::process;
/// This function gets the process id of the current
/// executable. It returns a non-zero number
/// ```
/// fn get_id() {
/// let x = integ_test_example::get_process_id();
/// println!("{}",x);
/// }
/// ```rs
pub fn get_process_id() -> u32 {
process::id()
}
如果你运行cargo test --doc,它将运行此示例代码并提供执行状态。
或者,运行cargo test将运行tests目录中的所有测试用例(除了标记为忽略的),然后运行文档测试(即作为文档一部分提供的代码示例)。
摘要
理解 Cargo 生态系统中的工具链对于作为 Rust 程序员有效非常重要,本章已经提供了将在未来章节中使用的知识基础。
我们了解到 Rust 有三个发布渠道——稳定版、beta 版和 nightly 版。稳定版推荐用于生产使用,nightly 版用于实验性功能,beta 版是一个中间阶段,用于在它们被标记为稳定版之前验证 Rust 语言发布中是否存在任何回归。我们还学习了如何使用 rustup 配置项目使用的工具链。
我们看到了在 Rust 项目中组织代码的不同方式。我们还学习了如何构建可执行二进制文件和共享库。我们还探讨了如何使用 Cargo 指定和管理依赖项。
我们介绍了如何使用 Rust 的内置测试框架为 Rust 包编写单元测试和集成测试,如何使用 cargo 调用自动化测试,以及如何控制测试执行。我们学习了如何通过内联文档注释和使用独立的 Markdown 文件来记录包。
在下一章中,我们将通过一个动手项目快速浏览 Rust 编程语言。
进一步阅读
-
Cargo 书籍 (
doc.rust-lang.org/cargo) -
Rust 书籍 (
doc.rust-lang.org/book/) -
Rust Forge (
forge.rust-lang.org/) -
Rustup 书籍 (
rust-lang.github.io/rustup/index.html) -
Rust 风格指南 – Rust 风格指南包含了编写惯用 Rust 代码的约定、指南和最佳实践,可以在以下链接找到:
github.com/rust-dev-tools/fmt-rfcs/blob/master/guide/guide.md
第二章:第二章:Rust 编程语言之旅
在上一章中,我们探讨了 Rust 工具生态系统,包括构建和依赖管理、测试和文档。这些是关键且高度开发者友好的工具,为我们开始 Rust 项目提供了坚实的基础。在本章中,我们将构建一个工作示例,它将作为复习之用,并加强关键 Rust 编程概念。
本章的目标是提高对核心 Rust 概念的熟练程度。在深入 Rust 系统编程的细节之前,这是必不可少的。我们将通过设计和开发一个 命令行界面 (CLI) 来实现这一点。
我们将要构建的应用程序是一个 算术表达式评估器。由于这个名字有点长,让我们看看一个例子。
假设用户在命令行中输入以下算术表达式:
1+2*3.2+(4/2-3/2)-2.11+2⁴
工具将打印出结果 21.79。
对于用户来说,它看起来像是一个计算器,但实现它涉及很多内容。这个示例项目将向您介绍解析器和编译器设计中使用的核心计算机科学概念。这是一个非平凡的工程,它允许我们测试核心 Rust 编程的深度,但又不至于过于复杂,让您感到畏惧。
在继续阅读之前,我建议您克隆代码仓库,导航到 chapter2 文件夹,并执行 cargo run 命令。在命令行提示符下,输入几个算术表达式,并查看工具返回的结果。您可以使用 Ctrl + C 退出工具。这将使您更好地理解本章将要构建的内容。
以下是为本章定义的关键学习步骤,它们对应于构建我们项目的各个阶段:
-
分析问题域
-
建模系统行为
-
构建分词器
-
构建解析器
-
构建评估器
-
处理错误
-
构建命令行应用程序
技术要求
您应该在本地开发环境中安装 Rustup 和 Cargo。
本章代码的 GitHub 仓库可以在github.com/PacktPublishing/Practical-System-Programming-for-Rust-Developers/tree/master/Chapter02找到。
分析问题域
在本节中,我们将定义项目的范围和我们需要解决的技术挑战。
理解和分析问题域是构建任何系统的第一步。明确阐述我们试图解决的问题和系统的边界非常重要。这些可以以系统需求的形式捕捉。
让我们看看我们将要构建的 CLI 工具的要求。
工具应接受一个算术表达式作为输入,评估它,并以浮点数的形式提供数值输出。例如,表达式 1+23.2+(4/2-3/2)-2.11+2⁴* 应评估为 21.79。
范围内的算术运算包括加法(+)、减法(-)、乘法()、除法(/)、幂(^)、负号前缀(-)以及括号内的表达式()*。
三角函数和对数函数、绝对值、平方根等数学函数不在范围内。
对于这样的表达式,需要解决的挑战如下:
-
用户应该能够在命令行上以自由文本的形式输入一个算术表达式。数字、算术运算符和括号(如果有)应该分开,并使用不同的规则集进行处理。
-
操作符优先级规则必须被考虑(例如,乘法优先于加法)。
-
括号内的表达式必须给予更高的优先级。
-
用户可能在数字和操作符之间不输入空格,但程序必须能够解析带有或没有空格的字符输入。
-
如果数字包含小数点,继续读取数字的其余部分,直到遇到运算符或括号。
-
无效输入应该被处理,并且程序应该带合适的错误信息终止。以下是一些无效输入的例子:
无效输入 1:由于我们在这个程序中不处理变量,如果输入了一个字符,程序应带合适的错误信息退出(例如,*2 ** a 是无效输入)。
无效输入 2:如果只遇到一个括号(没有匹配的闭合括号),程序应带错误信息退出。
无效输入 3:如果算术运算符不被识别,程序应带错误信息退出。
显然还有其他类型的边缘情况可能导致错误。但我们只会关注这些。鼓励读者作为进一步练习实现其他错误条件。
既然我们已经知道了将要构建的范围,让我们来设计系统。
建模系统行为
在最后一节中,我们确认了系统需求。现在让我们设计处理算术表达式的逻辑。系统的组件在图 2.1中显示:

图 2.1 – 算术表达式评估器的设计
前面图中显示的组件如下协同工作:
-
用户在命令行输入中输入一个算术表达式并按下Enter键。
-
用户输入将被整体扫描并存储在局部变量中。
-
从用户那里扫描的算术表达式。数字存储为
Numeric类型的标记。每个算术运算符都存储为相应类型的标记。例如,+符号将表示为类型为Add的标记,而数字1将存储为类型为Num的标记,其值为1。这是通过Lexer(或Tokenizer)模块完成的。 -
对于表达式
1+2*3,必须先评估2和3的乘积,然后才是加法运算符。括号内包含的任何子表达式也必须按更高优先级进行评估。最终的 AST 将反映所有这些处理规则。这是通过Parser模块完成的。 -
从构建的 AST 中,最后一步是按正确顺序评估 AST 中的每个节点,并将它们聚合以得到完整表达式的最终值。这是通过
Evaluator模块完成的。 -
表达式的最终计算值将显示在命令行上,作为程序输出给用户。或者,任何处理错误都将显示为错误消息。
这是处理步骤的广泛序列。现在我们将看看如何将这个设计转换为 Rust 代码。
词法分析器、解析器和 AST 之间的区别
词法分析器和解析器是计算机科学中用于构建编译器和解释器的概念。词法分析器(也称为标记化器)将文本(源代码)分割成单词,并为其分配一个词法意义,例如关键字、表达式、运算符、函数调用等。词法分析器生成标记(因此得名标记化器)。
解析器接受词法分析器的输出,并将标记排列成树结构(树是一种数据结构)。这种树结构也称为AST。有了AST,编译器可以生成机器代码,解释器可以评估指令。本章的图 2.7展示了AST的示意图。
词法分析和解析阶段是编译过程中的两个不同步骤,但在某些情况下它们是合并的。请注意,诸如词法分析器、解析器和AST等概念的应用范围远不止编译器或解释器,例如用于渲染 HTML 网页或 SVG 图像。
我们已经看到了系统的整体设计。现在让我们了解代码是如何组织的。这里展示了项目结构的可视化表示:

图 2.2 – 项目的代码结构
让我们检查这些路径中的每一个:
-
src/parsemath: 包含核心处理逻辑的模块 -
src/parsemath/ast.rs: 包含 AST 代码 -
src/parsemath/parser.rs: 包含解析器的代码 -
src/parsemath/tokenizer.rs: 包含标记化器的代码 -
src/parsemath/token.rs: 包含标记和运算符优先级的数据结构 -
src/main.rs: 主命令行应用程序
现在让我们按照以下方式设置项目:
-
使用
cargo new chapter2 && cd chapter2创建一个新的项目。 -
在
src文件夹下创建一个名为parsemath的文件夹。 -
在
src/parsemath文件夹中创建以下文件:ast.rs、token.rs、tokenizer.rs、parser.rs和mod.rs。 -
将以下内容添加到
src/parsemath/mod.rs中:pub mod ast; pub mod parser; pub mod token; pub mod tokenizer;
注意,本项目使用了 Rust 模块系统进行结构化。所有与解析相关的功能都在parsemath文件夹中。该文件夹中的mod.rs文件表明这是一个 Rust 模块。mod.rs文件导出该文件夹中包含的各个文件中的函数,并将其提供给main()函数。然后,我们在main()函数中注册parsemath模块,以便 Rust 编译器构建模块树。总的来说,Rust 模块结构帮助我们以灵活和可维护的方式组织不同文件中的代码。
本章代码片段的重要注意事项
本章详细介绍了命令行工具的设计,并辅以图表进行说明。所有关键方法的代码片段也提供了相应的解释。然而,在某些地方,一些用于完善代码的元素,例如模块导入、测试脚本和impl块的定义,并未在此处包含,但可以直接在 GitHub 仓库中找到。如果您选择跟随代码编写,请记住这一点。否则,您可以结合本章的解释和代码仓库中的完整代码进行学习。
提示:您将在构建词法分析器、解析器和评估器的下一节中看到?操作符的使用。请记住,?是错误处理的快捷方式,以便自动从给定函数传播错误到其调用函数。这将在稍后的处理错误部分进行解释。
我们现在已经准备好了。让我们开始吧。
构建词法分析器
词法分析器是我们系统设计中读取一个或多个字符从算术表达式并将其转换为标记的模块。换句话说,输入是一组字符,输出是一组标记。如果您想知道,标记的例子包括加法、减法和Num(2.0)。
我们首先需要为两件事创建数据结构:
-
为了存储用户提供的输入算术表达式
-
为了表示输出标记
在下一节中,我们将深入探讨如何为tokenizer模块确定合适的数据结构。
词法分析器数据结构
为了存储输入的算术表达式,我们可以选择以下数据类型之一:
-
字符串切片
-
字符串
我们将选择&str类型,因为我们不需要拥有值或动态增加表达式的尺寸。这是因为用户将一次性提供算术表达式,在处理过程中表达式不会改变。
这里是Tokenizer数据结构的一种可能表示:
src/parsemath/tokenizer.rs
pub struct Tokenizer {
expr: &str
}
如果我们采取这种方法,我们可能会遇到问题。为了理解这个问题,让我们先了解分词是如何进行的。
对于表达式*1+21*3.2*,扫描到的单个字符将出现为八个单独的值,*1, +, 2, 1, , 3, ., 2。
从这个迭代器中,我们将提取以下五个标记:
Num(1.0), Add, Num(21.0), Multiply, Num(3.2)
为了完成这个任务,我们不仅需要读取一个字符将其转换为标记,还需要查看下一个字符。例如,给定输入表达式*1+21*3.2*,要将数字21标记为Num(21),我们需要读取字符2,然后是1,然后是*,以便得出第一个加法操作的第二操作数值为21。
为了完成这个任务,我们必须将字符串切片转换为迭代器,这不仅允许我们遍历字符串切片以读取每个字符,还允许我们peek查看下一个字符的值。
让我们看看如何实现字符串切片的迭代器。Rust 意外地有一个内置类型用于此。它是标准库中str模块的一部分,结构体称为Chars。
因此,我们的Tokenizer结构体的定义可能如下所示:
src/parsemath/tokenizer.rs
pub struct Tokenizer {
expr: std::str::Chars
}
注意,我们已经将expr字段的类型从字符串切片(&str)更改为迭代器类型(Chars)。Chars是字符串切片字符的迭代器。这将允许我们在expr上执行迭代,例如expr.next(),这将给出表达式中的下一个字符的值。但我们也需要查看输入表达式中下一个字符后面的字符,原因我们之前提到过。
对于这个,Rust 标准库有一个名为Peekable的结构体,它有一个peek()方法。peek()的使用可以通过一个例子来说明。让我们取算术表达式1+2:
let expression = '1+2';
因为我们将把这个表达式存储在Tokenizer的expr字段中,它是一个peekable iterator类型,所以我们可以按顺序对其执行next()和peek()方法,如下所示:
-
expression.next()返回1。迭代器现在指向字符1。 -
然后,
expression.peek()返回+但不消耗它,迭代器仍然指向字符1。 -
然后,
expression.next()返回+,迭代器现在指向字符+。 -
然后,
expression.next()返回2,迭代器现在指向字符2。
为了实现这样的迭代操作,我们将定义我们的Tokenizer结构体如下:
src/parsemath/tokenizer.rs
use std::iter::Peekable;
use std::str::Chars;
pub struct Tokenizer {
expr: Peekable<Chars>
}
我们还没有完成Tokenizer结构体的定义。早期的定义会引发编译器错误,要求添加生命周期参数。为什么是这样?你可能想知道。
Rust 中的结构体可以持有引用。但是,当与包含引用的结构体一起工作时,Rust 需要显式指定生命周期。这就是我们在Tokenizer结构体上得到编译器错误的原因。为了解决这个问题,让我们添加生命周期注解:
src/parsemath/tokenizer.rs
pub struct Tokenizer<'a> {
expr: Peekable<Chars<'a>>
}
您可以看到,Tokenizer结构体被赋予了生命周期注解'a。我们通过在结构体名称后面使用尖括号声明泛型生命周期参数名称'a来做到这一点。这告诉 Rust 编译器,任何对Tokenizer结构体的引用都不能比它包含的字符引用存在时间更长。
Rust 中的生命周期
在系统语言如 C/C++中,如果与引用相关的值在内存中已被释放,对引用的操作可能会导致不可预测的结果或失败。
在 Rust 中,每个引用都有一个生命周期,这是生命周期有效的范围。Rust 编译器(特别是借用检查器)会验证引用的生命周期不会比引用所指向的底层值的生命周期更长。
编译器是如何知道引用的生命周期的呢?大多数时候,编译器会尝试推断引用的生命周期(称为省略)。但是,当这不可能时,编译器期望程序员显式地注解引用的生命周期。编译器期望显式生命周期注解的常见情况包括函数签名中两个或多个参数是引用,以及在结构体中一个或多个成员是引用类型。
更多详细信息可以在 Rust 文档中找到,请参阅doc.rust-lang.org/1.9.0/book/lifetimes.html。
如解释所述,我们传递包含算术表达式的字符串引用给Tokenizer结构体。根据变量作用域的常规规则(大多数编程语言都适用),expr变量需要在Tokenizer对象存在期间有效。如果在Tokenizer对象存在期间,与expr引用对应的值被释放,那么就构成了一个悬挂(无效)引用场景。为了防止这种情况,我们通过<'a>的生命周期注解告诉编译器,Tokenizer对象不能比它持有的expr字段中的引用存在时间更长。
下面的截图显示了Tokenizer数据结构:

图 2.3 – Tokenizer 结构体
我们已经看到了如何定义包含输入算术表达式引用的Tokenizer结构体,接下来我们将看看如何表示从Tokenizer生成的输出标记。
为了能够表示可以生成的令牌列表,我们首先需要考虑这些令牌的数据类型。由于令牌可以是 Num 类型或运算符类型之一,我们必须选择一个可以容纳多种数据类型的数据结构。数据类型选项有元组、HashMaps、structs 和 enums。如果我们添加一个约束,即令牌中的数据类型可以是许多预定义 变体(允许的值)之一,那么我们只剩下一种选择——枚举。我们将使用 enum 数据结构来定义令牌。
以下截图显示了 enum 数据结构中令牌的表示:

图 2.4 – Token enum
以下是关于 Token enum 中存储的值的解释:
-
如果遇到
+字符,将生成Add令牌。 -
如果遇到
-字符,将生成Subtract令牌。 -
如果遇到
*字符,将生成Multiply令牌。 -
如果遇到
/字符,将生成Divide令牌。 -
如果遇到
^字符,将生成Caret令牌。 -
如果遇到
(字符,将生成LeftParen令牌。 -
如果遇到
)字符,将生成RightParen令牌。 -
如果遇到任何数字
x,将生成Num(x)令牌。 -
如果遇到
EOF(扫描整个表达式的末尾),将生成EOF令牌。
现在我们已经定义了数据结构来捕获 Tokenizer 模块的 输入(算术表达式)和 输出(令牌),我们现在可以编写实际处理的代码。
Tokenizer 数据处理
以下截图显示了 Tokenizer 及其数据元素和方法:

图 2.5 – Tokenizer 及其方法
Tokenizer 有两个公共方法:
-
new(): 使用用户提供的算术表达式创建一个新的分词器 -
next(): 读取表达式中的字符并返回下一个令牌
以下截图显示了 Tokenizer 模块的全貌:

图 2.6 – Tokenizer 模块设计
new() 方法的代码如下:
src/parsemath/tokenizer.rs
impl<'a> Tokenizer<'a> {
pub fn new(new_expr: &'a str) -> Self {
Tokenizer {
expr: new_expr.chars().peekable(),
}
}
}
你会注意到我们在 impl 行中声明了 Tokenizer 的生命周期。我们重复了两次 'a。Impl<'a> 声明生命周期 'a,而 Tokenizer<'a> 使用它。
关于生命周期的观察
你已经看到,对于 Tokenizer,我们在三个地方声明了其生命周期:
-
Tokenizer结构体的声明 -
为
Tokenizer结构体声明impl块 -
impl块内的方法签名
这可能看起来很冗长,但 Rust 期望我们关于生命周期要具体,因为这是我们避免内存安全问题,如 悬垂指针 或 使用后释放 错误的方法。
impl关键字允许我们向Tokenizer结构体添加功能。new()方法接受一个字符串切片作为参数,该参数包含用户输入的算术表达式的引用。它构建一个新的Tokenizer结构体,用提供的算术表达式初始化,并从函数中返回它。
注意,算术表达式不是以字符串切片的形式存储在结构体中,而是以字符串切片的可预览迭代器形式存储。
在此代码中,new_expr代表字符串切片,new_expr.chars()代表字符串切片的迭代器,而new_expr.chars().peekable()创建了一个字符串切片的可预览迭代器。
正规迭代器和可预览迭代器之间的区别在于,在前者中,我们可以使用next()方法消耗字符串切片中的下一个字符,而在后者中,我们还可以选择性地预览切片中的下一个字符而不消耗它。您将在我们编写Tokenizer的next()方法代码时看到这一点。
我们将通过在Tokenizer结构体上实现Iterator特质来编写Tokenizer上的next()方法代码。特质使我们能够向结构体(和枚举)添加行为。标准库中的Iterator特质(std::iter::Iterator)有一个必须按照以下签名实现的方法:
fn next(&mut self) -> Option<Self::Item>
方法签名指定,此方法可以在Tokenizer结构体的实例上调用,并返回Option<Token>。这意味着它要么返回Some(Token),要么返回None。
下面是实现Tokenizer结构体上的Iterator特质的代码:
src/parsemath/tokenizer.rs
impl<'a> Iterator for Tokenizer<'a> {
type Item = Token;
fn next(&mut self) -> Option<Token> {
let next_char = self.expr.next();
match next_char {
Some('0'..='9') => {
let mut number = next_char?.to_string();
while let Some(next_char) = self.expr.peek() {
if next_char.is_numeric() || next_char ==
&'.' {
number.push(self.expr.next()?);
} else if next_char == &'(' {
return None;
} else {
break;
}
}
Some(Token::Num(number.parse::<f64>(). unwrap()))
},
Some('+') => Some(Token::Add),
Some('-') => Some(Token::Subtract),
Some('*') => Some(Token::Multiply),
Some('/') => Some(Token::Divide),
Some('^') => Some(Token::Caret),
Some('(') => Some(Token::LeftParen),
Some(')') => Some(Token::RightParen),
None => Some(Token::EOF),
Some(_) => None,
}
}
}
注意这里有两个迭代器在起作用:
-
expr上的next()方法(它是Tokenizer结构体中的一个字段)返回下一个字符(我们通过将Peekable<Chars>类型分配给expr字段来实现这一点)。 -
Tokenizer结构体上的next()方法返回一个标记(我们通过在Tokenizer结构体上实现Iterator特质来实现这一点)。
让我们逐步了解当在Tokenizer上调用next()方法时会发生什么:
-
调用程序首先通过调用
new()方法实例化Tokenizer结构体,然后在该结构体上调用next()方法。Tokenizer结构体上的next()方法通过在expr字段上调用next()来读取存储的算术表达式中的下一个字符,从而返回表达式中的下一个字符。 -
然后使用
match语句评估返回的字符。模式匹配用于确定从expr字段中的字符串切片引用读取的字符后返回哪个标记。 -
如果从字符串切片返回的字符是算术运算符(
*+*, *-*, ***, */*, *^*)或者是一个括号,则返回相应的Token枚举值。在这里,字符和Token之间存在一对一的对应关系。 -
如果返回的字符是数字,则需要一些额外的处理。原因是,一个数字可能有多个数字。此外,一个数字可能是小数,在这种情况下,它可能是xxx.xxx的形式,其中小数点前后数字的数量是完全不可预测的。因此,对于数字,我们应该使用算术表达式的
peekable迭代器来消费下一个字符,并窥视下一个字符以确定是否继续读取数字。
Tokenizer的完整代码可以在 GitHub 上代码文件夹中的tokenizer.rs文件中找到。
构建解析器
解析器是我们项目中构建 AST 的模块,AST 是一个节点树,每个节点代表一个标记(一个数字或算术运算符)。AST 是标记节点的递归树结构,即根节点是一个标记,它包含也是标记的子节点。
解析器数据结构
parser与Tokenizer相比是一个更高级的实体。虽然Tokenizer将用户输入转换为细粒度的标记(例如,各种算术运算符),但解析器使用Tokenizer的输出构建一个整体的 AST,这是一个节点层次结构。从解析器构建的AST结构在以下图中展示:

图 2.7 – 我们的 AST
在前面的图中,以下每一项都是节点:
-
Number(2.0)
-
Number(3.0)
-
Multiply(Number(2.0),Number(3.0))
-
Number(6.0)
-
Add(Multiply(Number(2.0),Number(3.0)),Number(6.0))
这些节点中的每一个都作为Node枚举的一部分存储在Box变量中。
Parser结构体的整体设计如下:

图 2.8 – 解析器结构体设计
如前图所示,Parser将有两个数据元素:一个Tokenizer实例(我们在上一节中构建的),以及当前标记,表示我们已评估到算术表达式的哪个点。
解析器方法
Parser结构体将有两个公共方法:
-
new():创建解析器的新实例。这个new()方法将创建一个传递算术表达式的标记化器实例,并将从Tokenizer返回的第一个标记存储在其current_token字段中。 -
parse():从标记生成AST(节点树),这是解析器的主要输出。
这里是new()方法的代码。代码是自我解释的,它创建了一个新的Tokenizer实例,用算术表达式初始化它,然后尝试从表达式中检索第一个标记。如果成功,标记将被存储在current_token字段中。如果不成功,则返回ParseError:
src/parsemath/parser.rs
// Create a new instance of Parserpub fn new(expr: &'a str) -> Result<Self, ParseError> {
let mut lexer = Tokenizer::new(expr);
let cur_token = match lexer.next() {
Some(token) => token,
None => return Err(ParseError::InvalidOperator
("Invalid character".into())),
};
Ok(Parser {
tokenizer: lexer,
current_token: cur_token,
})
}
下面是公共 parse() 方法的代码。它调用一个私有 generate_ast() 方法,该方法递归地执行处理并返回一个 AST(节点树)。如果成功,则返回节点树;如果不成功,则传播接收到的错误:
src/parsemath/parser.rs
// Take an arithmetic expression as input and return an AST
pub fn parse(&mut self) -> Result<Node, ParseError> {
let ast = self.generate_ast(OperPrec::DefaultZero);
match ast {
Ok(ast) => Ok(ast),
Err(e) => Err(e),
}
}
以下图像列出了 Parser 结构体中的所有私有和公共方法:

图 2.9 – 解析器方法概述
现在我们来看 get_next_token() 方法的代码。此方法使用 Tokenizer 结构体从算术表达式中检索下一个标记,并更新 Parser 结构体的 current_token 字段。如果失败,则返回 ParseError:
src/parsemath/parser.rs
fn get_next_token(&mut self) -> Result<(), ParseError> {
let next_token = match self.tokenizer.next() {
Some(token) => token,
None => return Err(ParseError::InvalidOperator
("Invalid character".into())),
};
self.current_token = next_token;
Ok(())
}
注意在 Result<(), ParseError> 中返回的空元组 ()。这意味着如果没有出错,则不返回任何具体值。
下面是 check_paren() 方法的代码。这是一个辅助方法,用于检查表达式中是否存在匹配的括号对。如果没有,则返回错误:
src/parsemath/parser.rs
fn check_paren(&mut self, expected: Token) -> Result<(), ParseError> {
if expected == self.current_token {
self.get_next_token()?;
Ok(())
} else {
Err(ParseError::InvalidOperator(format!(
"Expected {:?}, got {:?}",
expected, self.current_token
)))
}
}
现在我们来看剩下的三个私有方法,它们负责大部分的解析器处理工作。
parse_number() 方法接受当前标记,并检查三件事:
-
是否是形式为 Num(i) 的数字。
-
是否有符号,如果是负数的话。例如,表达式 -2.2 + 3.4 被解析到 AST 中为 Add(Negative(Number(2.2)), Number(3.4))。
-
括号对:如果括号内找到一个表达式,它将其视为乘法操作。例如,1(2+3)* 被解析为 Multiply(Number(1.0), Add(Number(2.0), Number(3.0)))。
如果前述操作中的任何操作出现错误,则返回 ParseError。
下面是 parse_number() 方法的代码:
src/parsemath/parser.rs
// Construct AST node for numbers, taking into account
// negative prefixes while handling parenthesis
fn parse_number(&mut self) -> Result<Node, ParseError> {
let token = self.current_token.clone();
match token {
Token::Subtract => {
self.get_next_token()?;
let expr = self.generate_ast(OperPrec::Negative)?;
Ok(Node::Negative(Box::new(expr)))
}
Token::Num(i) => {
self.get_next_token()?;
Ok(Node::Number(i))
}
Token::LeftParen => {
self.get_next_token()?;
let expr = self.generate_ast
(OperPrec::DefaultZero)?;
self.check_paren(Token::RightParen)?;
if self.current_token == Token::LeftParen {
let right = self.generate_ast
(OperPrec::MulDiv)?;
return Ok(Node::Multiply(Box::new(expr),
Box::new(right)));
}
Ok(expr)
}
_ => Err(ParseError::UnableToParse("Unable to
parse".to_string())),
}
}
generate_ast() 方法是该模块的主要工作马,它递归调用。它按照以下顺序进行处理:
-
它使用
parse_number()方法处理数字标记、负数标记和括号内的表达式。 -
它在循环中按顺序解析算术表达式中的每个标记,以检查遇到的下一个两个运算符的优先级,并通过以这种方式调用
convert_token_to_node()方法来构建AST,使得具有较高优先级的运算符包含的表达式先于具有较低优先级的运算符包含的表达式执行。例如,表达式 1+23* 被评估为 Add(Number(1.0), Multiply(Number(2.0), Number(3.0))),而表达式 12+3* 被评估为 Add(Multiply(Number(1.0), Number(2.0)), Number(3.0))。
现在我们来看 generate_ast() 方法的代码:
src/parsemath/parser.rs
fn generate_ast(&mut self, oper_prec: OperPrec) -> Result<Node, ParseError> {
let mut left_expr = self.parse_number()?;
while oper_prec < self.current_token.get_oper_prec() {
if self.current_token == Token::EOF {
break;
}
let right_expr = self.convert_token_to_node
(left_expr.clone())?;
left_expr = right_expr;
}
Ok(left_expr)
}
我们已经看到了与解析器相关的各种方法。现在让我们看看处理算术运算符时的另一个关键方面——运算符优先级。
运算符优先级
操作符优先级的 enum 如下所示:

图 2.10 – 操作符优先级枚举
操作符优先级 enum 有以下值:
-
DefaultZero: 默认优先级(最低优先级) -
AddSub: 如果算术操作是加法或减法,则应用的优先级 -
MulDiv: 如果算术操作是乘法或除法,则应用的优先级 -
Power: 遇到 caret (^) 操作符时应用的优先级 -
Negative: 在数字前负号 (-) 前应用的优先级
优先级顺序从上到下增加,即 DefaultZero < AddSub < MulDiv < Power < Negative。
定义操作符优先级 enum 如下所示:
src/parsemath/token.rs
#[derive(Debug, PartialEq, PartialOrd)]
/// Defines all the OperPrec levels, from lowest to highest.
pub enum OperPrec {
DefaultZero,
AddSub,
MulDiv,
Power,
Negative,
}
使用 get_oper_prec() 方法来获取给定操作符的操作符优先级。以下代码展示了此方法的作用。在 Token 结构体的 impl 块中定义此方法:
src/parsemath/token.rs
impl Token {
pub fn get_oper_prec(&self) -> OperPrec {
use self::OperPrec::*;
use self::Token::*;
match *self {
Add | Subtract => AddSub,
Multiply | Divide => MulDiv,
Caret => Power,
_ => DefaultZero,
}
}
}
现在,让我们看看 convert_token_to_node() 的代码。此方法基本上通过检查标记是否为 Add、Subtract、Multiply、Divide 或 Caret 来构建操作符类型的 AST 节点。在出错的情况下,返回 ParseError:
src/parsemath/parser.rs
fn convert_token_to_node(&mut self, left_expr: Node) -> Result<Node, ParseError> {
match self.current_token {
Token::Add => {
self.get_next_token()?;
//Get right-side expression
let right_expr = self.generate_ast
(OperPrec::AddSub)?;
Ok(Node::Add(Box::new(left_expr),
Box::new(right_expr)))
}
Token::Subtract => {
self.get_next_token()?;
//Get right-side expression
let right_expr = self.generate_ast
(OperPrec::AddSub)?;
Ok(Node::Subtract(Box::new(left_expr),
Box::new(right_expr)))
}
Token::Multiply => {
self.get_next_token()?;
//Get right-side expression
let right_expr = self.generate_ast
(OperPrec::MulDiv)?;
Ok(Node::Multiply(Box::new(left_expr),
Box::new(right_expr)))
}
Token::Divide => {
self.get_next_token()?;
//Get right-side expression
let right_expr = self.generate_ast
(OperPrec::MulDiv)?;
Ok(Node::Divide(Box::new(left_expr),
Box::new(right_expr)))
}
Token::Caret => {
self.get_next_token()?;
//Get right-side expression
let right_expr = self.generate_ast
(OperPrec::Power)?;
Ok(Node::Caret(Box::new(left_expr),
Box::new(right_expr)))
}
_ => Err(ParseError::InvalidOperator(format!(
"Please enter valid operator {:?}",
self.current_token
))),
}
}
我们将在本章的 处理错误 部分详细讨论错误处理。Parser 的完整代码可以在 GitHub 章节文件夹中的 parser.rs 文件中找到。
构建评估器
一旦在解析器中构建了 AST(节点树),从 AST 中评估数值就是一个简单的操作。评估函数递归地解析 AST 树中的每个节点,并得到最终值。
例如,如果 AST 节点是 Add(Number(1.0),Number(2.0)),则计算结果为 3.0。
如果 AST 节点是 Add(Number(1.0),Multiply(Number(2.0),Number(3.0)):
-
它将 Number(1.0) 的值计算为 1.0。
-
然后,它将 Multiply(Number(2.0), Number(3.0)) 计算为 6.0。
-
然后,它将 1.0 和 6.0 相加,得到最终值 7.0。
让我们现在看看 eval() 函数的代码:
src/parsemath/ast.rs
pub fn eval(expr: Node) -> Result<f64, Box<dyn error::Error>> {
use self::Node::*;
match expr {
Number(i) => Ok(i),
Add(expr1, expr2) => Ok(eval(*expr1)? +
eval(*expr2)?),
Subtract(expr1, expr2) => Ok(eval(*expr1)? –
eval(*expr2)?),
Multiply(expr1, expr2) => Ok(eval(*expr1)? *
eval(*expr2)?),
Divide(expr1, expr2) => Ok(eval(*expr1)? /
eval(*expr2)?),
Negative(expr1) => Ok(-(eval(*expr1)?)),
Caret(expr1, expr2) => Ok(eval(*expr1)?
.powf(eval(*expr2)?)),
}
}
特性对象
在 eval() 方法中,你会注意到在出错的情况下,该方法返回 Box<dyn error::Error>。这是一个特性对象的例子。我们现在将解释这一点。
在 Rust 标准库中,error:Error 是一个特性。在这里,我们告诉编译器 eval() 方法应该返回实现 Error 特性的类型。我们不知道编译时返回的确切类型是什么;我们只知道返回的任何内容都将实现 Error 特性。底层错误类型仅在运行时才知道,不是静态确定的。在这里,dyn error::Error 是一个特性对象。使用 dyn 关键字表示它是一个特性对象。
当我们使用特质对象时,编译器在编译时不知道应该调用哪种类型的哪个方法。这只有在运行时才知道,因此被称为动态分发(当编译器在编译时知道应该调用哪个方法时,它被称为静态分发)。
注意,我们使用Box<dyn error::Error>来封装错误。这是因为我们在运行时不知道错误类型的尺寸,所以封装是一种绕过这个问题的方法(Box是一个在编译时具有已知尺寸的引用类型)。Rust 标准库通过让Box实现从任何实现了Error特质的类型到特质对象Box<Error>的转换,帮助我们封装错误。
更多详细信息可以在 Rust 文档中找到,请参阅doc.rust-lang.org/book/ch17-02-trait-objects.html。
处理错误
错误处理处理的问题是:我们如何将程序错误传达给用户?
在我们的项目中,错误可能由两个主要原因引起——可能是编程错误,或者可能是由于无效输入导致的错误。让我们首先讨论 Rust 的错误处理方法。
在 Rust 中,错误是一等公民,因为错误本身就是一个数据类型,就像整数、字符串或向量一样。因为error是一个数据类型,类型检查可以在编译时发生。Rust 标准库为 Rust 标准库中的所有错误实现了std::error::Error特质。Rust 不使用异常处理,而是一种独特的计算可以返回Result类型的方法:
enum Result<T, E> { Ok(T), Err(E),}
Result<T, E>是一个包含两个变体的enum,其中Ok(T)表示成功,而Err(E)表示返回的错误。通过模式匹配来处理函数返回的两种类型的结果。
为了更好地控制错误处理并提供更友好的错误信息给应用程序用户,建议使用实现了std::error::Error特质的自定义错误类型。这样,程序中不同模块的所有错误类型都可以转换为这种自定义错误类型,从而实现统一的错误处理。这是在 Rust 中处理错误的一种非常有效的方法。
一种轻量级的错误处理方法是将Option<T>用作函数的返回值,其中T是任何泛型类型:
pub enum Option<T> { None, Some(T),}
Option类型是一个包含两个变体的enum,Some(T)和None。如果处理成功,则返回Some(T)值,否则从函数返回None。
在我们的项目中,我们将使用Result和Option类型进行错误处理。
我们项目选择的错误处理方法如下:

图 2.11 – 错误处理方法
对于我们项目中的包含核心处理的四个模块,其方法如下:
-
new()和next()。new()方法相当简单,只是创建一个新的Tokenizer结构体实例并初始化它。此方法不会返回任何错误。然而,next()方法返回一个Token,如果算术表达式中存在任何无效字符,我们需要处理这种情况并将信息传达给调用代码。在这里,我们将使用轻量级的错误处理方法,next()方法的返回值将使用Option<Token>。如果可以从算术表达式中构造一个有效的Token,则返回Some(Token)。在无效输入的情况下,返回None。调用函数可以将None解释为错误条件,并处理必要的操作。 -
eval()函数,它根据节点树计算数值。如果在处理过程中出现错误,我们将返回一个普通的std::error::Error,但由于否则 Rust 编译器将不知道错误值的编译时大小,它将是一个Boxed值。此方法的返回类型是Result<f64, Box<dyn error::Error>>。如果处理成功,将返回一个数值(f64),否则返回一个Boxed错误。我们本可以为此模块定义一个自定义错误类型以避免复杂的Boxed错误签名,但选择这种方法是为了展示在 Rust 中进行错误处理的各种方式。 -
get_oper_prec(),它根据输入的算术运算符返回运算符优先级。由于我们在这个简单的方法中看不到任何错误的可能性,因此方法返回值中不会定义错误类型。 -
Parser模块包含大部分处理逻辑。在这里,将定义一个自定义错误类型ParseError,其结构如下:

图 2.12 – 自定义错误类型
我们的自定义错误类型有两个变体,UnableToParse(String)和InvalidOperator(String)。
第一个变体将是一个通用的错误,用于处理处理过程中出现的任何类型的错误,第二个变体将专门用于用户提供了无效的算术运算符的情况;例如,2=3。
让我们为解析器定义一个自定义错误类型:
src/parsemath/parser.rs
#[derive(Debug)]
pub enum ParseError {
UnableToParse(String),
InvalidOperator(String),
}
为了打印错误,我们还需要实现Display特质:
src/parsemath/parser.rs
impl fmt::Display for ParseError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match &self {
self::ParseError::UnableToParse(e) => write!(f,
"Error in evaluating {}", e),
self::ParseError::InvalidOperator(e) => write!(f,
"Error in evaluating {}", e),
}
}
}
由于ParseError将是处理过程中返回的主要错误类型,并且因为AST模块返回一个Boxed错误,我们可以编写代码将AST模块中的任何Boxed错误自动转换为由Parser返回的ParseError。代码如下:
src/parsemath/parser.rs
impl std::convert::From<std::boxed::Box<dyn std::error::Error>> for ParseError {
fn from(_evalerr: std::boxed::Box<dyn std::error::Error>)
-> Self {
return ParseError::UnableToParse("Unable to
parse".into());
}
}
这段代码允许我们编写如下代码:
let num_value = eval(ast)?
特别注意?运算符。它是以下内容的快捷方式:
-
如果
eval()处理成功,将返回值存储在num_value字段中。 -
如果处理失败,将
eval()方法返回的Boxed错误转换为ParseError,并将其进一步传播给调用者。
这部分讨论了算术表达式评估模块。在下一节中,我们将探讨如何从main()函数中调用此模块。
整合所有内容
在前面的章节中,我们看到了如何设计和编写我们项目各种处理模块的代码。现在,我们将把它们全部整合到一个main()函数中,该函数作为命令行应用程序。此main()函数将执行以下操作:
-
显示带有用户输入算术表达式的说明的提示。
-
从用户那里接受命令行输入的算术表达式。
-
实例化
Parser(返回一个Parser对象实例)。 -
解析表达式(返回表达式的 AST 表示)。
-
评估表达式(计算表达式的数学值)。
-
在命令行输出中向用户显示结果。
-
调用
Parser并评估数学表达式。
main()函数的代码如下:
src/main.rs
fn main() {
println!("Hello! Welcome to Arithmetic expression
evaluator.");
println!("You can calculate value for expression such as
2*3+(4-5)+2³/4\. ");
println!("Allowed numbers: positive, negative and
decimals.");
println!("Supported operations: Add, Subtract, Multiply,
Divide, PowerOf(^). ");
println!("Enter your arithmetic expression below:");
loop {
let mut input = String::new();
match io::stdin().read_line(&mut input) {
Ok(_) => {
match evaluate(input) {
Ok(val) => println!("The computed number
is {}\n", val),
Err(_) => {
println!("Error in evaluating
expression. Please enter valid
expression\n");
}
};
}
Err(error) => println!("error: {}", error),
}
}
}
main()函数向用户显示提示,从stdin(命令行)读取一行,并调用evaluate()函数。如果计算成功,它将显示计算出的 AST 和数值。如果失败,它将打印错误信息。
evaluate()函数的代码如下:
src/main.rs
fn evaluate(expr: String) -> Result<f64, ParseError> {
let expr = expr.split_whitespace().collect::<String>();
// remove whitespace chars
let mut math_parser = Parser::new(&expr)?;
let ast = math_parser.parse()?;
println!("The generated AST is {:?}", ast);
Ok(ast::eval(ast)?)
}
evaluate()函数使用提供的算术表达式实例化一个新的Parser,解析它,然后在AST模块上调用eval()方法。注意使用?运算符自动传播任何处理错误到main()函数,在那里它们通过println!语句进行处理。
运行以下命令以编译和运行程序:
cargo run
您可以尝试各种正数和负数、小数、算术运算符和可选括号内子表达式的组合。您还可以检查无效输入表达式将产生错误信息。
您可以扩展此项目以添加对平方根、三角函数、对数函数等数学函数的支持。您还可以添加边缘情况。
通过这种方式,我们结束了本书的第一个完整项目。我希望这个项目不仅让您了解了惯用的 Rust 代码是如何编写的,而且还让您了解了在设计程序时如何以 Rust 的方式思考。
完整的main()函数代码可以在 GitHub 文件夹中本章的main.rs文件中找到。
摘要
在本章中,我们从头开始使用 Rust 构建了一个命令行应用程序,没有使用任何第三方库,来计算算术表达式的值。我们涵盖了 Rust 的许多基本概念,包括数据类型、如何使用 Rust 数据结构来建模和设计应用程序领域、如何在模块间分割代码并集成它们、如何在模块内以函数的形式组织代码、如何将模块函数暴露给其他模块、如何进行模式匹配以编写优雅和安全的代码、如何向结构和枚举添加功能、如何实现特性和注解生命周期、如何设计和传播自定义错误类型、如何装箱类型以使数据大小对编译器可预测、如何构建递归节点树并导航它、如何编写递归评估表达式的代码,以及如何为结构指定生命周期参数。
恭喜你如果成功跟随着并得到了一些可工作的代码!如果你遇到了任何困难,你可以参考 GitHub 仓库中的最终代码。
这个示例项目为在接下来的章节中深入系统编程的细节提供了一个坚实的基础。如果你没有完全理解代码的每一个细节,没有必要担心。随着接下来的章节,我们将编写更多的代码,并逐步强化 Rust 惯用代码的概念。
在下一章中,我们将介绍 Rust 标准库,并了解它如何支持丰富的内置模块、类型、特性和函数,以执行系统编程。
第三章:第三章:Rust 标准库简介
在上一章中,我们使用各种 Rust 语言原语和 Rust 标准库中的模块构建了一个命令行工具。然而,为了充分利用 Rust 的力量,了解标准库中为系统编程任务提供的功能范围至关重要,而无需求助于第三方 crate。
在本章中,我们将深入探讨 Rust 标准库的结构。你将了解用于访问系统资源的标准模块的介绍,并学习如何通过编程方式管理它们。通过获得的知识,我们将用 Rust 实现一个模板引擎的小部分。到本章结束时,你将能够自信地导航 Rust 标准库并在你的项目中使用它。
本章的关键学习成果如下:
-
介绍 Rust 标准库
-
使用标准库模块编写模板引擎的一个特性
技术要求
Rustup 和 Cargo 必须安装在你的本地开发环境中。本章示例的 GitHub 仓库可以在github.com/PacktPublishing/Practical-System-Programming-for-Rust-Developers/tree/master/Chapter03找到。
Rust 标准库和系统编程
在我们深入研究标准库之前,让我们了解它如何适应系统编程的上下文。
在系统编程中,一个基本要求是管理系统资源,如内存、文件、网络 I/O、设备和进程。每个操作系统都有一个内核(或等效物),它是加载到内存中的中央软件模块,将系统硬件与应用程序进程连接起来。你可能想知道,Rust 标准库在这里有什么作用?我们是不是要用 Rust 编写一个内核?不,这不是本书的目的。最流行的操作系统,基本上是 Unix、Linux 和 Windows 变体,它们的内核主要是用 C 编写的,混合了一些汇编语言。尽管有几个实验性的努力朝这个方向前进,但 Rust 作为内核开发语言的补充还处于早期阶段。然而,Rust 标准库提供的是 API 接口,以便从 Rust 程序中发出系统调用,以管理和操作各种系统资源。以下图显示了这一上下文:

图 3.1 – Rust 标准库
让我们通过这个图来更好地理解每个组件:
-
read(),内核将代表编辑程序执行。这种限制的原因是现代处理器架构(如 x86-64)允许 CPU 在两种不同的特权级别下运行—内核模式和用户模式。用户模式的特权级别低于内核模式。CPU 只能在内核模式下执行某些操作。这种设计防止用户程序意外执行可能影响系统操作的任务。 -
系统调用(syscall)接口:内核还提供了一个系统调用 应用程序编程接口,作为进程请求内核执行各种任务的入口点。
-
libc(或glibc)。对于 Windows 操作系统,有等效的 API。 -
libc(或另一个特定平台的等效库)内部调用系统调用。Rust 标准库是跨平台的,这意味着系统调用的调用细节(或使用的包装库)被从 Rust 开发者那里抽象出来。从 Rust 代码中调用系统调用而不使用标准库的方法(例如,在嵌入式系统开发中)是存在的,但这超出了本书的范围。 -
用户空间程序:这些是你将使用标准库作为本书的一部分编写的程序。你在上一章中编写的 算术表达式评估器 就是这样一个例子。在本章中,你将学习如何使用标准库编写模板引擎的功能,这也是一个用户空间程序。
注意
Rust 标准库中的所有模块和函数并不都调用系统调用(例如,有字符串操作和处理错误的方法)。当我们遍历标准库时,记住这个区别是很重要的。
让我们现在开始我们的旅程,了解并开始使用 Rust 标准库。
探索 Rust 标准库
我们之前讨论了 Rust 标准库在启用用户程序调用内核操作中的作用。以下是一些标准库的显著特性,我们将简称为 std:
-
std是跨平台的。它提供了隐藏底层平台架构差异的功能。 -
std默认对所有 Rust 包可用。use语句提供了对相应模块及其组成部分(特质、方法、结构体等)的访问。例如,use std::fs语句提供了对提供文件操作操作的模块的访问。 -
std包含对标准 Rust 原始数据类型(如整数和浮点数)的操作。例如,std::i8::MAX是在标准库中实现的一个常量,指定了可以存储在类型为 i8 的变量中的最大值。 -
它实现了核心数据类型,如 向量、字符串 和 智能指针,如
Box、Rc和Arc。 -
它提供了数据操作、内存分配、错误处理、网络、I/O、并发、异步 I/O 原语和外部函数接口等功能。
下图展示了 Rust 标准库的高级视图:

图 3.2 – Rust 标准库 – 高级视图
Rust 标准库(std)的组织结构大致如下:
-
Rust 语言原语,包含基本类型,如有符号和无符号整数、布尔值、浮点数、字符、数组、元组、切片和字符串。原语由编译器实现。Rust 标准库包括原语,并在此基础上构建。
-
(
libc)或其他外部依赖。你可以指示编译器在不使用 Rust 标准库的情况下进行编译,并使用核心 crate(在 Rust 术语中,这种环境被称为no_std,并带有#![no_std]属性),这在嵌入式编程中很常见。 -
Box<T>)、引用计数指针(Rc<T>)、以及原子引用计数指针(Arc<T>)。它还包括集合,如Vec和String(注意,String在 Rust 中实现为 UTF-8 序列)。当使用标准库时,不需要直接使用此 crate,因为alloccrate 的内容被重新导出,并作为std库的一部分提供。唯一例外的情况是在no_std环境中开发时,此时可以直接使用此 crate 来访问其功能。 -
(
core或alloccrate)包括围绕并发、I/O、文件系统访问、网络、异步 I/O、错误和特定于操作系统的函数的丰富功能。
在本书中,我们将不会直接与core或alloccrate 进行工作,而是使用这些 crate 之上的高级抽象的 Rust 标准库模块。
我们现在将分析 Rust 标准库中的关键模块,重点关注系统编程。标准库被组织成模块。例如,允许用户程序在多个线程上运行的并发功能位于std::thread模块中,而处理同步 I/O 的 Rust 构造位于std::io模块中。理解标准库中功能如何在模块间组织是成为一名高效且富有成效的 Rust 程序员的关键部分。
图 3.3展示了标准库模块组织成组的布局:

图 3.3 – Rust 标准库模块
本图中的模块已按其主要关注领域进行分组。
然而,我们如何知道这些模块中哪些与管理系统资源相关呢?鉴于这可能对本书的用途感兴趣,让我们尝试将模块进一步分类到以下两个类别之一:
-
以系统调用为导向:这些模块要么直接管理系统硬件资源,要么需要内核执行其他特权操作。
-
以计算为导向:这些模块以数据表示、计算和向编译器发出的指令为导向。
图 3.4 展示了与 图 3.3 相同的模块分组,但被划分为 以系统调用为导向 或 以计算为导向。请注意,这可能不是一种完美的分类,因为并非所有标记为 以系统调用为导向 类别的模块都涉及实际系统调用。但这种分类可以作为在标准库中找到方向的指南:

图 3.4 – Rust 模块分类
让我们来了解每个模块的功能。
以计算为导向的模块
本节的标准库模块主要处理与数据处理、数据建模、错误处理和向编译器发出的指令相关的编程结构。一些模块可能具有与以系统调用为导向的类别重叠的功能,但这种分组是基于每个模块的主要关注点。
数据类型
本节提到了与 Rust 标准库中的数据类型和结构相关的模块。Rust 中的数据类型大致分为两类。第一组包括整数(有符号、无符号)、浮点数和 char 等原始类型,它们是语言、编译器和标准库的核心部分,标准库为这些类型添加了额外的功能。第二组包括向量、字符串等高级数据结构和特性,这些都是在标准库中实现的。这两个组中的模块在此列出:
-
any:当传递给函数的值的类型在编译时未知时可以使用。使用运行时反射来检查类型并执行适当的处理。使用此功能的示例可以是日志函数,其中我们希望根据数据类型自定义记录的内容。 -
array:它包含对原始数组类型实现的实用函数,例如比较数组。请注意,Rust 数组是值类型,即它们在栈上分配,并且具有固定长度(不可增长)。 -
char:它包含对char原始类型实现的实用函数,例如检查数字、转换为大写、编码为 UTF-8 等。 -
collections:这是 Rust 的标准集合库,其中包含编程中常用的常见集合数据结构的有效实现。该库中的集合包括Vectors、LinkedLists、HashMaps、HashSet、BTtreeMap、BTreeSet和BinaryHeap。 -
f32,f64: 本库提供了针对f32和f64原始类型实现的特定常量。常量的例子包括MAX和MIN,它们提供了f32和f64类型可以存储的最大和最小浮点数值。 -
i8,i16,i32,i64,i128: 各种大小的有符号整数类型。例如,i8表示长度为 8 位(1 字节)的有符号整数,而i128表示长度为 128 位(16 字节)的有符号整数。 -
u8,u16,u32,u64,u128: 各种大小的无符号整数类型。例如,u8表示长度为 8 位(1 字节)的无符号整数,而u128表示长度为 128 位(16 字节)的无符号整数。 -
isize,usize: Rust 有两种数据类型,isize和usize,分别对应有符号和无符号整数类型。这些类型的独特之处在于它们的大小取决于 CPU 是否使用 32 位或 64 位架构。例如,在 32 位系统上,isize和usize数据类型的大小为 32 位(4 字节),同样,对于 64 位系统,它们的大小为 64 位(8 字节)。 -
marker: 本模块描述了可以附加到类型(以 trait 的形式)上的基本属性。例如包括Copy(其值可以通过简单复制其位来复制的类型)和Send(线程安全的类型)。 -
slice: 包含用于在slice数据类型上执行iterate和split等操作的 struct 和方法。 -
string: 本模块包含String类型以及to_string等方法,允许将值转换为String。请注意,String在 Rust 中不是一个原始数据类型。Rust 中的原始类型在此列出:doc.rust-lang.org/std/。 -
str: 本模块包含与字符串切片相关的 struct 和方法,例如在str切片上执行iterate和split。 -
vec: 本模块包含Vector类型,它是一个具有堆分配内容的可增长数组,以及用于操作向量(如切片和迭代)的相关方法。vec模块是一个所有者引用和智能指针(如Box<T>)。请注意,vec最初是在alloccrate 中定义的,但现在作为std::vec和std::collections模块的一部分提供。
数据处理
这是一个各种模块的集合,提供了针对不同类型处理的辅助方法,例如处理 ASCII 字符、比较、排序、打印格式化值、算术运算和迭代器:
-
ascii: Rust 中的大多数字符串操作作用于 UTF-8 字符串和字符。但在某些情况下,可能需要仅对 ASCII 字符进行操作。本模块提供了对 ASCII 字符串和字符的操作。 -
cmp:此模块包含用于排序和比较值的函数以及相关宏。例如,实现此模块中包含的Eq特性允许使用==和!=运算符比较自定义结构体实例。 -
fmt:此模块包含用于格式化和打印字符串的实用工具。实现此特性使得可以使用format!宏打印任何自定义数据类型。 -
hash:此模块提供计算数据对象哈希的功能。 -
iter:此模块包含Iterator特性,它是 Rust 习惯性代码的一部分,也是 Rust 的一个流行特性。此特性可以通过自定义数据类型实现,以便迭代其值。 -
num:此模块提供用于数值操作的附加数据类型。 -
ops:此模块提供一组特性,允许您为自定义数据类型重载运算符。例如,可以为自定义结构体实现Add特性,并使用+运算符来添加该类型的两个结构体。
错误处理
此组包含具有 Rust 程序错误处理功能的模块。Error 特性是表示错误的基础结构。Result 处理函数返回值中的错误存在与否,而 Option 处理变量中的值的存在与否。后者防止了困扰许多编程语言的可怕 空值 错误:Panic 提供了一种退出程序的方式,如果无法处理错误:
-
error:此模块包含Error特性,它表示错误值的基本期望。所有错误都实现了Error特性,并且此模块用于实现自定义或特定于应用程序的错误类型。 -
option:此模块包含Option类型,它提供了将值初始化为Some值或None值的能力。Option类型可以被视为处理涉及值缺失的错误的一种非常基本的方式。空值在其他编程语言中以空指针异常或等效形式造成混乱。 -
panic:此模块提供处理 panic 的支持,包括捕获 panic 的原因和设置钩子以在 panic 时触发自定义逻辑。 -
result:此模块包含Result类型,它与Error特性和Option类型一起构成了 Rust 中错误处理的基础。Result表示为Result<T,E>,用于从函数返回值或错误。当预期错误时,函数返回Result类型,如果错误可恢复。
外部函数接口 (FFI)
FFI 由 ffi 模块提供。此模块提供用于在非 Rust 接口边界之间交换数据的实用工具,例如与其他编程语言一起工作或直接与底层操作系统/内核交互。
编译器
此组包含与 Rust 编译器相关的模块。
-
hint: 此模块包含向编译器提示代码应该如何发出或优化的函数。 -
prelude: 预言是 Rust 自动导入到每个 Rust 程序中的项目列表。这是一个便利功能。 -
primitive: 此模块重新导出 Rust 原始类型,通常用于宏代码中。
我们已经看到了 Rust 标准库的计算导向模块。现在让我们看看系统调用导向的模块。
系统调用导向模块
虽然前面的模块组与内存中的计算相关,但本节处理涉及管理硬件资源或其他通常需要内核干预的特权操作的操作。请注意,这些模块中的所有方法并不都涉及对内核的系统调用,但这有助于在模块级别构建心理模型。
内存管理
此分组包含来自标准库的一组模块,用于处理内存管理和智能指针。内存管理包括静态内存分配(在栈上)、动态内存分配(在堆上)、内存释放(当变量超出作用域时,其析构函数会被执行)、复制或复制值、管理原始指针和智能指针(它们是指向堆上数据的指针),以及固定对象的内存位置,以便它们不能被移动(这在特殊情况下是需要的)。模块如下:
-
alloc: 此模块包含内存分配和释放的 API,以及注册自定义或第三方内存分配器作为标准库的默认分配器。 -
borrow: 在 Rust 中,根据不同的使用场景,通常会对给定的类型使用不同的表示形式。例如,一个值可以存储和管理为Box<T>、Rc<T>或Arc<T>。同样,一个字符串值可以存储为String或str类型。Rust 提供了方法,允许通过实现Borrow特质中的borrow方法,将一种类型借用为另一种类型。所以基本上,一个类型可以自由地借用为许多不同的类型。此模块包含Borrow特质,它允许将拥有的值转换为借用值,或将任何类型的借用数据转换为拥有值。例如,String类型的值(它是一个拥有类型)可以借用为str。 -
cell: 在 Rust 中,内存安全基于规则:一个值可以有多个不可变引用或单个可变引用。但可能存在需要共享、可变引用的场景。此模块提供了可共享的可变容器,包括Cell和RefCell。这些类型提供了对共享类型的受控可变性。 -
clone: 在 Rust 中,原始类型如整数是可复制的,即它们实现了Copy特质。这意味着当将变量的值赋给另一个变量或传递参数给函数时,对象的值会被复制。但并非所有类型都可以复制,因为它们可能需要内存分配(例如,String或Vec类型,其中内存是在堆上分配,而不是在栈上)。在这种情况下,使用clone()方法来复制值。此模块提供了Clone特质,允许自定义数据类型的值被复制。 -
convert: 此模块包含便于数据类型之间转换的功能。例如,通过实现此模块中包含的AsRef特质,你可以编写一个接受类型为AsRef<str>的参数的函数,这意味着此函数可以接受任何可以转换为字符串引用的引用(&str)。由于str和String类型都实现了AsRef特质,你可以传递String引用(String)或字符串切片引用(&str)给此函数。 -
default: 此模块具有Default特质,用于为数据类型分配有意义的默认值。 -
mem: 此模块包含与内存相关的函数,包括查询内存大小、初始化、交换以及其他内存操作。 -
pin: Rust 中的类型默认是可移动的。例如,在Vec类型上,pop()操作会移动一个值出来,而push操作可能会导致内存重新分配。然而,在某些情况下,拥有固定内存位置且不移动的对象是有用的。例如,自引用数据结构,如链表。对于这种情况,Rust 提供了一个将数据固定在内存位置的数据类型。这是通过将类型包裹在固定指针Pin<P>中实现的,它将值P固定在内存中的位置。 -
ptr: 在 Rust 中,使用原始指针并不常见,并且仅在选择性用例中使用。Rust 允许在不可安全代码块中处理原始指针,编译器不负责内存安全,程序员负责内存安全的操作。此模块提供用于处理原始指针的函数。Rust 支持两种类型的原始指针——不可变(例如,*const i32)和可变(例如,*mut i32)。原始指针在使用上没有限制。它们是 Rust 中唯一可以设置为 null 的指针类型,并且原始指针没有自动解引用。 -
rc: 此模块提供单线程引用计数指针,其中rc代表引用计数。类型T的对象的引用计数指针可以表示为Rc<T>。Rc<T>提供了值T的共享所有权,该值在堆上分配。如果此类型的值被克隆,它将返回指向堆中相同内存位置的新的指针(不会在内存中复制值)。此值保留,直到最后一个引用此值的Rc指针存在,之后该值将被丢弃。
并发
此类别将有关同步并发处理的模块分组在一起。可以在 Rust 中通过启动进程、在进程内启动线程以及有方法在线程和进程之间同步和共享数据来设计并发程序。异步并发在 Async 组中介绍。
-
env: 此模块允许检查和操作进程的环境,包括环境变量、进程的参数和路径。此模块可以属于其自己的类别,因为它在并发之外被广泛使用,但它被归类在此处与process模块一起,因为此模块旨在与 进程 一起工作(例如,获取和设置进程的环境变量或获取启动进程使用的命令行参数)。 -
process: 此模块提供了处理进程的功能,包括启动新进程、处理 I/O 和终止进程。 -
sync: 在涉及并发的情况下,Rust 程序中执行的指令序列可能会有所不同。在这种情况下,可能存在多个并行执行的执行线程(例如,多核 CPU 中的多个线程),在这种情况下,需要同步原语来协调线程间的操作。此模块包括同步原语,如Arc、Mutex、RwLock和Condvar。 -
thread: Rust 的线程模型由原生操作系统线程组成。此模块提供了处理线程的功能,例如启动新线程、配置、命名和同步它们。
文件系统
这包含处理文件系统操作的两个模块。fs 模块处理与操作本地文件系统内容的方法。path 模块提供用于程序化导航和操作目录和文件系统路径的方法:
-
fs: 此模块包含用于处理和操作文件系统的操作。请注意,此模块中的操作可以在跨平台上使用。此模块中的结构体和方法处理文件、命名、文件类型、目录、文件元数据、权限以及遍历目录中的条目。 -
path: 此模块提供了用于处理和操作路径的类型PathBuf和Path。
输入输出
这包含了一个io模块,它提供了核心 I/O 功能。io模块包含在处理输入和输出时使用的常用函数。这包括对 I/O 类型(如文件或 TCP 流)的读写操作,以及用于提高性能的缓冲读写操作,以及与标准输入和输出一起工作。
网络通信
核心网络功能由net模块提供。此模块包含 TCP 和 UDP 通信的原语,以及处理端口和套接字的功能。
与操作系统相关的
与操作系统相关的函数由os模块提供。此模块包含 Linux、Unix 和 Windows 操作系统的平台特定定义和扩展。
时间
time模块提供了处理系统时间的函数。此模块包含处理系统时间和计算持续时间的结构体,通常用于系统超时。
异步
异步 I/O 功能由future和task模块提供:
-
future:这包含了一个Future特质,它是构建 Rust 中异步服务的基础。 -
task:此模块提供了处理异步任务所需的函数,包括Context、Waker和Poll。关于预置模块的说明
正如我们所见,Rust 在标准库中提供了很多功能。要使用它们,你必须将相应的模块导入到程序中。然而,有一组常用的特质、类型和函数,Rust 会自动导入到每个 Rust 程序中,因此 Rust 程序员不需要手动导入它们。这被称为将
use std::prelude::v1::*导入到 Rust 程序中。此模块重新导出常用 Rust 构造。prelude模块导出的项目包括特质、类型和函数,包括Box、Copy、Send、Sync、drop、Clone、Into、From、Iterator、Option、Result、String和Vec。重新导出模块的列表可以在doc.rust-lang.org/std/prelude/v1/index.html找到。
这完成了对 Rust 标准库模块的概述。Rust 标准库非常庞大,并且正在快速发展。强烈建议您使用本章中获得的理解,在doc.rust-lang.org/std/index.html上查看官方文档,以了解具体的方法、特质、数据结构和示例片段。
现在让我们继续到下一节,我们将通过编写一些代码来应用这些知识。
构建模板引擎
在本节中,我们将探讨 HTML 模板引擎的设计,并使用 Rust 标准库实现其中一个功能。让我们首先了解什么是模板引擎。
诸如 Web 和移动应用等应用程序使用存储在关系数据库、NoSQL 数据库和键值存储中的结构化数据。然而,网络上存在大量非结构化数据。一个特定的例子是所有网页都包含的文本数据。网页作为基于文本格式的 HTML 文件生成。
仔细观察后,我们可以看到 HTML 页面有两个部分:静态文本字面量 和 动态部分。HTML 页面作为一个模板编写,包含静态和动态部分,HTML 生成的上下文来自数据源。在生成网页时,生成器应将静态文本输出而不改变,同时应结合一些处理和提供的上下文来生成动态字符串结果。生成 HTML 页面涉及系统调用(创建、打开、读取和写入文件)和计算密集型的内存字符串操作。
一个 模板引擎 是一个可以用来以高效方式生成动态 HTML 页面的系统软件组件。它包含了解析器、标记化器、生成器和模板文件等软件组件的组合。
图 3.5 展示了使用模板引擎生成 HTML 所涉及的过程:

图 3.5 – 使用模板生成 HTML
为了更好地理解这一点,让我们以一个显示客户交易记录的网上银行页面为例。这可以通过使用 HTML 模板来实现,其中:
-
静态 HTML 包括银行名称、标志、其他品牌和所有用户都通用的内容。
-
网页的动态部分包含登录用户的实际历史交易列表。交易列表因用户而异。
这种方法的优点在于在 Web 开发生命周期中职责的分离:
-
一个 前端(Web)设计师 可以使用 Web 设计工具使用示例数据编写静态 HTML。
-
一个 模板设计师 会将静态 HTML 转换为嵌入页面动态部分元数据的 HTML 模板,使用特定的语法。
-
在运行时(当页面请求到达服务器时),模板引擎 从指定位置获取模板文件,应用登录用户的交易列表从数据库中,并生成最终的 HTML 页面。
流行的模板引擎示例包括 Jinja, Mustache, Handlebars, HAML, Apache Velocity, Twig 和 Django。不同的模板引擎在架构和语法上存在差异。
在这本书中,我们将编写一个类似 Django 模板 语法的简单模板引擎的结构。Django 是 Python 中流行的 Web 框架。像 Django 这样的商业模板引擎功能全面且复杂。我们不可能在本章中完全重新创建它们,但我们将构建代码结构和实现一个代表性功能。
HTML 模板引擎的类型
根据模板数据解析的时间,有两种类型的 HTML 模板引擎。
第一种类型的模板引擎在编译时解析 HTML 模板并将其转换为代码。然后,在运行时,动态数据被检索并加载到编译后的模板中。这些模板通常具有更好的运行时性能,因为其中一部分工作是在编译时完成的。
第二种类型的模板引擎在运行时同时解析模板和生成 HTML。我们将在我们的项目中使用这种类型,因为它相对简单易懂且易于实现。
让我们从设计一个 HTML 模板文件开始。
模板语法和设计
模板本质上是一个文本文件。以下列出了一些模板文件支持的常见特性:
-
字面量,例如,
<h1> hello world </h1> -
被包围在
{{和}}之间的模板变量,例如,<p> {{name}} </p> -
使用
if标签进行控制逻辑,例如,{% if amount > 100000 %} {% endif %} -
使用
for标签进行循环控制,例如,<ul>{% for customer in customer_list}<li>{{customer.name}}</li>{% endfor %}</ul> -
内容导入,例如,
{% include "footer.html" %} -
过滤器,例如,
{{name | upper}}
图 3.6 展示了一个示例模板和由模板引擎生成的 HTML:

图 3.6 – 模板引擎的概念模型
在 图 3.6 中,我们可以看到以下内容:
-
在左侧,显示了一个示例模板文件。模板文件是静态内容和动态内容的混合。静态内容的示例是
<h1> Welcome to XYZ Bank </h1>。动态内容的示例是<p> Welcome {{name}} </p>,因为name的值将在运行时被替换。模板文件中显示了三种类型的动态内容 – 一个if标签,一个for标签和一个模板变量。 -
在图中间,我们可以看到模板引擎有两个输入来源 – 模板文件和数据源。模板引擎接收这些输入并生成输出 HTML 文件。
图 3.7 使用示例解释了模板引擎的工作原理:

图 3.7 – 模板引擎的示例
从设计角度来看,模板引擎有两个部分:
-
解析器
-
HTML 生成器
让我们先了解使用模板引擎进行 HTML 生成所涉及的步骤。
模板文件包含一系列语句。其中一些是静态字面量,而其他则是使用特殊语法表示的动态内容的占位符。模板引擎从模板文件中读取每个语句。让我们称每个读取的行为一个模板字符串,从现在开始。这个过程从从模板文件中读取的模板字符串开始:
-
模板字符串被送入解析器。在我们的例子中,模板字符串是
<p> Welcome {{name}} </p>。 -
解析器首先确定模板字符串的类型,这被称为
if标签、for标签和模板变量。在这个例子中,生成了一个模板变量类型的标记(如果模板字符串包含静态字面量,则将其写入 HTML 输出而不做任何更改)。 -
然后将模板字符串解析为静态字面量,
Welcome,和一个模板变量{{name}}。 -
解析器的输出(步骤 2 和 3)传递给 HTML 生成器。
-
模板引擎通过上下文将数据源的数据传递给生成器。
-
解析的标记和字符串(步骤 2 和 3)与上下文数据(步骤 5)结合,生成结果字符串,并将其写入输出 HTML 文件。
上述步骤会针对从模板文件中读取的每个语句(模板字符串)重复进行。
我们不能使用在 第二章,“Rust 编程语言之旅”中为算术解析创建的解析器,因为在这个例子中,我们需要针对 HTML 模板语言语法特定的东西。我们可以使用通用解析库(例如,nom、pest 和 lalrpop 是 Rust 中几个流行的解析库),但为了这本书,我们将自定义构建一个模板解析器。这样做的原因是每个解析库都有自己的 API 和语法,我们需要熟悉它们。这样做会偏离本书的目标,即从第一原理学习如何用 Rust 编写惯用代码。
首先,让我们创建一个新的库项目,如下所示:
cargo new –-lib template-engine
src/lib.rs 文件(由 cargo 工具自动创建)将包含模板引擎的所有功能。
创建一个新的文件,src/main.rs。main() 函数将放置在这个文件中。
现在我们来设计模板引擎的代码结构。图 3.8 展示了详细的设计:

图 3.8:模板引擎的设计
让我们结合一些代码片段,来介绍模板引擎的关键数据结构和函数。我们将从数据结构开始。
数据结构
ContentType 是用于分类从 模板文件 中读取的 模板字符串 的主要数据结构。它以 enum 的形式表示,并包含可能的 ContentType 列表如下:
src/lib.rs
// Each line in input can be of one of following types
#[derive(PartialEq, Debug)]
pub enum ContentType {
Literal(String),
TemplateVariable(ExpressionData),
Tag(TagType),
Unrecognized,
}
请特别注意 PartialEq 和 Debug 这两个注释。前者用于允许内容类型进行比较,后者用于将内容值打印到控制台。
可推导特质
Rust 编译器可以自动为标准库中定义的一些特质提供默认实现。这些特质被称为可推导特质。为了指示编译器提供默认特质实现,使用#[derive]属性。请注意,这只能对您定义的自定义结构体和枚举类型执行,不能对您不拥有的其他库中定义的类型执行。
可以自动推导特质实现的类型包括比较特质,如Eq、PartialEq和Ord,以及其他如Copy、Clone、Hash、Default和Debug。
TagType是一个支持数据结构,用于指示模板字符串是否对应于for-tag(重复循环)或if-tag(显示控制):
src/lib.rs
#[derive(PartialEq, Debug)]
pub enum TagType {
ForTag,
IfTag,
}
我们将创建一个结构体来存储模板字符串分词的结果:
src/lib.rs
#[derive(PartialEq, Debug)]
pub struct ExpressionData {
pub head: Option<String>,
pub variable: String,
pub tail: Option<String>,
}
注意,head和tail的类型为Option<String>,以允许模板变量可能不包含静态字面文本在其前后。
总结来说,模板字符串首先被分词为ContentType::TemplateVariable(ExpressionData)类型,然后ExpressionData被解析为head="Hello"、variable="name"和tail =",welcome"。
关键函数
让我们看看实现模板引擎的关键函数:
-
程序:main():这是程序的起点。它首先调用函数来分词和解析模板字符串,接受上下文数据以输入到模板中,然后调用函数使用解析器的输出和上下文数据生成 HTML。 -
程序:get_content_type():这是解析器的入口点。它解析模板文件(我们称之为模板字符串)的每一行,并将其分类为以下标记类型之一:字面量、模板变量、标记或未识别。标记类型可以是for标记或if标记。如果标记是模板变量类型,它将解析模板字符串以提取头部、尾部和模板变量。这些类型被定义为
ContentType枚举的一部分。让我们编写一些测试用例,明确我们希望这个函数的输入和输出是什么,然后查看get_content_type()的实际代码。让我们在src/lib.rs中添加以下代码块来创建一个tests模块:#[cfg(test)] mod tests { use super::*; }
将单元测试放在这个tests模块中。每个测试都将以注释#[test]开始。
测试用例 1:检查内容类型是否为字面量:
src/lib.rs
#[test]
fn check_literal_test() {
let s = "<h1>Hello world</h1>";
assert_eq!(ContentType::Literal(s.to_string()),
get_content_type(s));
}
这个测试用例是为了检查存储在变量s中的字面量字符串是否被分词为ContentType::Literal(s)。
测试用例 2:检查内容类型是否为模板变量类型:
src/lib.rs
#[test]
fn check_template_var_test() {
let content = ExpressionData {
head: Some("Hi ".to_string()),
variable: "name".to_string(),
tail: Some(" ,welcome".to_string()),
};
assert_eq!(
ContentType::TemplateVariable(content),
get_content_type("Hi {{name}} ,welcome")
);
}
对于Template String标记类型,这个测试用例检查模板字符串中的表达式是否被解析为head、variable和tail组件,并成功返回为类型ContentType::TemplateVariable (ExpressionData)。
ForTag:
src/lib.rs
#[test]
fn check_for_tag_test() {
assert_eq!(
ContentType::Tag(TagType::ForTag),
get_content_type("{% for name in names %}
,welcome")
);
}
这个测试用例是为了检查包含for标签的语句是否成功被标记为ContentType::Tag(TagType::ForTag)。
IfTag:
src/lib.rs
#[test]
fn check_if_tag_test() {
assert_eq!(
ContentType::Tag(TagType::IfTag),
get_content_type("{% if name == 'Bob' %}")
);
}
这个测试用例是为了检查包含if标签的语句是否成功被标记为ContentType::Tag(TagType::IfTag)。
现在我们已经编写了单元测试用例,接下来让我们编写模板引擎的代码。
编写模板引擎
编写模板引擎有两个关键部分——解析器和 HTML 生成器。我们将从解析器开始。图 3.9显示了解析器的结构:

图 3.9:解析器设计
下面是解析器中各种方法的简要描述:
-
get_content_type(): 解析器的入口点。接受一个输入语句,并将其标记化为if标签、for标签或模板变量之一。 -
check_symbol_string(): 这是一个辅助方法,用于检查一个符号是否存在于另一个字符串中。例如,我们可以检查模式{%是否存在于模板文件的语句中,并据此确定它是一个标签语句还是模板变量。 -
check matching pair(): 这是一个辅助方法,用于验证模板文件中的语句是否在语法上正确。例如,我们可以检查是否存在匹配的成对{%和%}。否则,该语句将被标记为Unrecognized。 -
get_index_for_symbol(): 这个方法返回一个子字符串在另一个字符串中的起始索引。它用于字符串操作。 -
get_expression_data(): 这个方法将模板字符串解析为其组成部分,用于类型为TemplateString的标记。
编写解析器
让我们先看看get_content_type()方法。以下是程序逻辑的摘要:
-
for标签被{%和%}包围,并包含for关键字。 -
if标签被{%和%}包围,并包含if关键字。 -
模板变量被
{{和}}包围。
根据这些规则,语句被解析,并返回适当的标记——一个for标签、一个if标签或一个模板变量。
下面是get_content_type()函数的完整代码列表:
src/lib.rs
pub fn get_content_type(input_line: &str) -> ContentType {
let is_tag_expression = check_matching_pair
(&input_line, "{%", "%}");
let is_for_tag = (check_symbol_string(&input_line,
"for")
&& check_symbol_string(&input_line, "in"))
|| check_symbol_string(&input_line, "endfor");
let is_if_tag =
check_symbol_string(&input_line, "if") ||
check_symbol_string(&input_line, "endif");
let is_template_variable = check_matching_pair
(&input_line, "{{", "}}");
let return_val;
if is_tag_expression && is_for_tag {
return_val = ContentType::Tag(TagType::ForTag);
} else if is_tag_expression && is_if_tag {
return_val = ContentType::Tag(TagType::IfTag);
} else if is_template_variable {
let content = get_expression_data(&input_line);
return_val = ContentType::TemplateVariable
(content);
} else if !is_tag_expression && !is_template_variable {
return_val = ContentType::Literal
(input_line.to_string());
} else {
return_val = ContentType::Unrecognized;
}
return_val
}
支持函数
现在让我们谈谈支持函数。解析器使用这些支持函数执行诸如检查字符串中是否存在子字符串、检查花括号匹配对等操作。它们用于检查模板字符串是否语法正确,以及将模板字符串解析为其组成部分。在编写更多代码之前,让我们看看这些支持函数的测试用例,以了解它们将如何被使用,然后查看代码。请注意,这些函数被设计为可以在多个项目中重用。所有支持函数都放在 src/lib.rs 中:
-
check_symbol_string(): 检查一个符号字符串,例如'{%',是否包含在另一个字符串中。以下是这个函数的测试用例:#[test] fn check_symbol_string_test() { assert_eq!(true, check_symbol_string( "{{Hello}}", "{{")); }这里是该函数的代码:
pub fn check_symbol_string(input: &str, symbol: &str) -> bool { input.contains(symbol) }标准库提供了一个简单的方法来检查字符串切片中的子字符串。
-
check_matching_pair(): 这个函数检查匹配的符号字符串。以下是这个函数的测试用例:#[test] fn '{{' and '}}', to this function, and check if both are contained within another string expression, "{{Hello}}".Here is the code for the function:pub fn check_matching_pair(input: &str, symbol1: &str,
symbol2: &str) -> bool {
input.contains(symbol1) && input.contains(symbol2)
}
In this function, we are checking if the two matching tags are contained within the input string. -
get_expression_data(): 这个函数解析带有模板变量的表达式,将其解析为head、variable和tail组件,并返回结果。以下是这个函数的测试用例:#[test] fn check_get_expression_data_test() { let expression_data = ExpressionData { head: Some("Hi ".to_string()), variable: "name".to_string(), tail: Some(" ,welcome".to_string()), }; assert_eq!(expression_data, get_expression_data("Hi {{name}} ,welcome")); }这里是该函数的代码:
pub fn get_expression_data(input_line: &str) -> ExpressionData { let (_h, i) = get_index_for_symbol(input_line, '{'); let head = input_line[0..i].to_string(); let (_j, k) = get_index_for_symbol(input_line, '}'); let variable = input_line[i + 1 + 1..k] .to_string(); let tail = input_line[k + 1 + 1..].to_string(); ExpressionData { head: Some(head), variable: variable, tail: Some(tail), } } -
get_index_for_symbol: 这个函数接受两个参数,并返回第二个值在第一个值中找到的索引。这使得将模板字符串分成三部分——head、variable和tail变得容易。以下是这个函数的测试用例:#[test] fn char_indices() method on the slice available as part of the standard library, and converts the input string into an iterator that is capable of tracking indices. We then iterate over the input string and return the index of the symbol when found:pub fn get_index_for_symbol(input: &str, symbol: char)
-> (bool, usize) {
let mut characters = input.char_indices();
let mut does_exist = false;
let mut index = 0;
while let Some((c, d)) = characters.next() {
if d == symbol {
does_exist = true;
index = c;
break;
}
}
(does_exist, index)
}
这完成了 Parser 模块的代码。现在让我们看看将所有部分联系在一起的主要函数。
main() 函数
main() 函数是模板引擎的入口点。图 3.10 展示了 main() 函数的设计:

图 3.10:main() 函数
main() 函数执行协调角色,将所有部分联系在一起。它调用解析器,初始化上下文数据,然后调用生成器:
-
HashMap用于传递模板中提到的模板变量的值。我们将name和city的值添加到这个HashMap中。这个HashMap与解析后的模板输入一起传递给生成函数:let mut context: HashMap<String, String> = HashMap::new(); context.insert("name".to_string(), "Bob".to_string()); context.insert("city".to_string(), "Boston".to_string()); -
为从命令行(标准输入)读取的每一行输入的
get_context_data()函数。a) 如果行包含模板变量,它将调用 HTML 生成器
generate_html_template_var()来创建 HTML 输出。b) 如果一行包含一个字面字符串,它将简单地回显输入的 HTML 字面字符串。
c) 如果一行包含
for或if标签,目前我们只是打印出一个声明该功能尚未实现。我们将在未来的章节中实现它:for line in io::stdin().lock().lines() { match get_content_type(&line?.clone()) { ContentType::TemplateVariable(content) => { let html = generate_html_template_var (content, context.clone()); println!("{}", html); } ContentType::Literal(text) => println! ("{}", text), ContentType::Tag(TagType::ForTag) => println!("For Tag not implemented"), ContentType::Tag(TagType::IfTag) => println!("If Tag not implemented"), ContentType::Unrecognized => println!("Unrecognized input"), } } -
io::stdin()函数创建当前进程的标准输入的新句柄。标准输入使用以下for循环逐行读取,然后传递给解析器进行处理:for line in io::stdin().lock().lines() {..}
这是 main() 函数的完整代码列表:
src/main.rs
use std::collections::HashMap;
use std::io;
use std::io::BufRead;
use template_engine::*;
fn main() {
let mut context: HashMap<String, String> =
HashMap::new();
context.insert("name".to_string(), "Bob".to_string());
context.insert("city".to_string(),
"Boston".to_string());
for line in io::stdin().lock().lines() {
match get_content_type(&line.unwrap().clone()) {
ContentType::TemplateVariable(content) => {
let html = generate_html_template_var
(content, context.clone());
println!("{}", html);
}
ContentType::Literal(text) => println!("{}",
text),
ContentType::Tag(TagType::ForTag) =>
println!("For Tag not implemented"),
ContentType::Tag(TagType::IfTag) =>
println!("If Tag not implemented"),
ContentType::Unrecognized =>
println!("Unrecognized input"),
}
}
}
generate_html_template_var() 函数的实现如下所示:
src/lib.rs
use std::collections::HashMap;
pub fn generate_html_template_var(
content: ExpressionData,
context: HashMap<String, String>,
) -> String {
let mut html = String::new();
if let Some(h) = content.head {
html.push_str(&h);
}
if let Some(val) = context.get(&content.variable) {
html.push_str(&val);
}
if let Some(t) = content.tail {
html.push_str(&t);
}
html
}
此函数构建输出 html 语句,该语句由 head、文本内容 和 tail 组成。为了构建文本内容,模板变量被替换为上下文数据中的值。构建的 html 语句由函数返回。
本章的完整代码可以在 github.com/PacktPublishing/Practical-System-Programming-for-Rust-Developers/tree/master/Chapter03 找到。
执行模板引擎
目前,我们已经有了基本模板引擎的轮廓和基础,它可以处理两种类型的输入——静态字面量和模板变量。
让我们执行程序并运行一些测试:
-
使用以下命令构建和运行项目:
>cargo run -
<h2> 你好,欢迎来到我的页面 </h2>。您将看到相同的字符串被打印出来,因为没有需要进行转换。 -
<p> 我的名字是 {{name}} </p>或<p> 我住在 {{city}} </p>。您将看到<p> 我的名字是 Bob </p>或<p> 我住在波士顿 </p>被相应地打印出来。这是因为我们在main()程序中将变量name初始化为Bob,将city初始化为Boston。我们鼓励您增强此代码以支持在单个 HTML 语句中添加对两个模板变量的支持。 -
{%和%},并且包含字符串for或if。您将在终端看到以下消息之一被打印出来:For Tag not implemented或If Tag not implemented。
您被鼓励编写 for 标签和 if 标签的代码作为练习。确保检查符号的正确顺序。例如,无效的格式如 {% for }% 或 %} if {% 应该被拒绝。
尽管我们无法在此章节中实现模板引擎的更多功能,但我们已经看到了如何在实际用例中使用 Rust 标准库。我们主要使用了 Rust 标准库中的 io、collections、iter 和 str 模块来实现本章中的代码。随着我们进入未来的章节,我们将涵盖更多标准库的内容。
摘要
在本章中,我们回顾了 Rust 标准库的整体结构,并将标准库的模块按不同类别进行分类,以便更好地理解。您对并发、内存管理、文件系统操作、数据处理、数据类型、错误处理、与编译器相关、FFI、网络、I/O、特定于操作系统和与时间相关的功能等领域的模块进行了简要介绍。
我们探讨了模板引擎是什么,它是如何工作的,并定义了我们项目的范围和需求。我们根据 Rust 数据结构(枚举和结构体)和 Rust 函数设计了模板引擎。我们看到了如何编写解析模板的代码以及为涉及模板变量的语句生成 HTML 的代码。我们提供了输入数据并执行程序,在终端(命令行)中验证了生成的 HTML。
在下一章中,我们将更深入地探讨 Rust 标准库中处理管理进程环境、命令行参数和与时间相关的功能的模块。
进一步阅读
-
Django 模板语言:
docs.djangoproject.com/en/3.0/ref/templates/language/ -
Rust 标准库:
doc.rust-lang.org/std/index.html
第四章:第四章:管理环境、命令行和时间
在上一章中,我们探讨了 Rust 标准库的结构。我们还编写了一个基本模板引擎的部分代码,该引擎可以根据 HTML 模板和数据生成动态的 HTML 页面组件。从现在开始,我们将开始深入研究标准库的特定模块,这些模块按功能区域分组。
在本章中,我们将探讨与处理系统环境、命令行和时间相关函数相关的 Rust 标准库模块。本章的目标是使你能够更熟练地使用 命令行参数、路径操作、环境变量 和 时间测量。
了解这些有什么好处?
与 命令行参数 一起工作是一项编写任何接受命令行用户输入的程序所必需的技能。
想象一下,你会如何编写一个工具(例如 find 或 grep),它能够处理在文件夹和子文件夹中搜索文件和模式。这需要了解 路径操作,包括导航路径和读取及操作路径条目。
学习使用 环境变量 是将代码与配置分离的必要部分,这对于任何类型的程序来说都是一种良好的实践。
对于处理资源和时间戳的程序来说,学习如何与时间打交道是必要的。学习如何进行 时间测量 以记录事件之间的时间间隔,对于评估各种操作所需的时间是必要的。
在本章中,你将学习以下技能:
-
编写能够在 Linux、Unix 和 Windows 平台上发现和操作系统环境和文件系统的 Rust 程序
-
创建可以使用命令行参数接受配置参数和用户输入的程序
-
捕获事件之间的经过时间
这些是在 Rust 系统编程中需要的相关技能。我们将通过开发一个用于图像处理的命令行应用程序来以实际的方式学习这些主题。在这个过程中,我们将看到 Rust 标准库中 path、time、env 和 fs 模块的更多细节。
首先,让我们看看我们将要构建的内容。
想象一下,我们有一个批量图像缩放的工具——这个工具会遍历桌面或服务器上的文件系统目录,提取所有图像文件(例如,.png 和 .jpg),并将它们全部缩放到预定义的大小(例如,小、中或大)。
想象一下,这样的工具对于释放硬盘空间或上传图片到移动或网页应用中展示会有多有用。我们将构建这样的工具。请系好安全带。
我们将按照以下顺序介绍主题:
-
项目范围和设计概述
-
编写图像缩放库的代码
-
开发命令行应用程序
技术要求
本章代码的 GitHub 仓库可以在 github.com/PacktPublishing/Practical-System-Programming-for-Rust-Developers/tree/master/Chapter04 找到。
项目范围和设计概述
在本节中,我们将首先定义我们将要构建的内容,并查看技术设计。然后,我们将编写一个用于图像处理的 Rust 库。最后,我们将构建一个命令行应用程序,该应用程序通过命令行接受用户输入,并使用我们构建的图像调整库执行用户指定的命令。
我们将构建什么?
在本小节中,我们将描述我们正在构建的工具的功能需求、技术需求和项目结构。
功能需求
我们将构建一个执行以下两个操作的命令行工具:
-
图像调整大小:将源文件夹中的一个或多个图像调整到指定的大小
-
图像统计:提供有关源文件夹中现有图像文件的某些统计信息
让我们给这个工具命名为 ImageCLI。图 4.1 展示了该工具的两个主要功能:

图 4.1 – ImageCLI 工具的功能
用户将能够使用此工具调整图像大小。用户可以要求调整单个图像或多个图像。支持的 输入 图像格式为 JPG 和 PNG。支持的 输出 图像格式为 PNG。该工具将接受以下三个命令行参数:
-
size = small,输出图像的宽度将为 200 像素;对于size = medium,输出文件的宽度将为 400 像素;而对于size = large,输出宽度将为 800 像素。例如,如果输入图像是一个总大小为 8 MB 的 JPG 文件,通过指定size = medium,它可以被调整到大约 < 500 KB 的大小。 -
mode = single用于调整单个文件,或mode = all用于调整指定文件夹中的所有图像文件。 -
选择
mode = single或mode = all。对于mode = single,用户指定srcfolder的值为包含文件名的图像文件的完整路径。对于mode = all,用户指定srcfolder的值为(包含图像文件的)文件夹的完整路径,不包含任何图像文件名。例如,如果使用mode = single和srcfolder = /user/bob/images/image1.png,则工具将调整/user/bob/images文件夹中包含的image1.png单个图像文件的大小。如果使用mode = all和srcfolder = /user/bob/images,则工具将调整/user/bob/images源文件夹中包含的所有图像文件的大小。
对于我们的图像统计功能,用户还可以指定包含图像文件的srcfolder,并获取该文件夹中图像文件的数量以及所有这些图像文件的总大小。例如,如果使用srcfolder=/user/bob/images,则image stats选项将给出类似以下的结果:该文件夹包含 200 个图像文件,总大小为 2,234 MB。
非功能性需求
以下是该项目的非功能性(技术)需求列表:
-
工具将被打包并作为二进制文件分发,它应在三个平台上运行:Linux、Unix 和 Windows。
-
我们应该能够测量图像缩放所需的时间。
-
用户输入用于指定命令行标志时必须不区分大小写,以便于使用。
-
工具必须能够向用户显示有意义的错误消息。
-
图像缩放的核心功能必须与命令行界面(CLI)分离。这样,我们可以灵活地使用核心功能与桌面图形界面或作为 Web 应用程序的后端的一部分。
-
该项目将组织为一个包含图像处理功能的库和一个提供 CLI 以读取和解析用户输入、提供错误消息并向用户显示输出消息的二进制文件。该二进制文件将利用库进行核心图像处理。
项目结构
让我们创建项目骨架,以便更好地可视化项目结构。使用cargo创建一个新的lib项目。让我们使用以下命令将 CLI 工具命名为imagecli:
cargo new --lib imagecli && cd imagecli
这是项目结构:

图 4.2 – Cargo 项目结构
按照以下结构设置项目:
-
在
src文件夹下,创建一个名为imagix的子文件夹(用于图像魔法!)来存放库代码。在imagix子文件夹下,创建四个文件:mod.rs,它是进入imagix库的入口点,resize.rs用于存放与图像缩放相关的代码,stats.rs用于存放图像文件统计的代码,error.rs用于包含自定义错误类型和错误处理代码。 -
在
src文件夹下,创建一个名为imagecli.rs的新文件,其中将包含 CLI 的代码。
在本小节中,我们看到了工具的功能需求以及期望的项目结构。在下一个小节中,我们将探讨工具的设计。
技术设计
在本小节中,我们将探讨工具的高级设计,主要关注图像处理功能。我们将在开发命令行应用程序和测试部分设计 CLI 的具体细节。
我们的项目包括我们的可重用imagix库,其中包含图像缩放和统计的核心功能,以及一个带有 CLI 的二进制可执行文件imagecli。这如图图 4.3所示:

图 4.3 – 带有可重用库的 CLI 工具
如果库设计得当,它可以在未来用于其他类型的客户端;例如,应用程序可以提供一个图形用户界面(而不是 CLI)作为桌面应用程序,甚至可以使其通过基于浏览器的 HTML 客户端应用程序访问。
在我们开始设计之前,让我们尝试可视化一些我们必须克服和解决的关键技术挑战:
-
/tmp/子文件夹用于存储调整大小的图像?我们如何测量图像调整大小所需的时间?
-
调整多个图像的大小:
我们如何遍历用户提供的源文件夹以识别所有图像文件并为每个条目调用图像调整大小函数?
-
获取图像统计信息:
我们如何扫描用户提供的源文件夹,仅计算图像文件的数量,并获取该文件夹中所有图像文件的总大小?
-
tmp子文件夹?
前面的点可以根据设计目的分为三个广泛的关注点类别:
-
图像调整大小逻辑
-
路径操作和目录迭代逻辑
-
测量图像调整大小所需的时间
图像处理本身是一个高度专业化的领域,本书的范围不包括涉及的技术和算法。鉴于图像处理领域的复杂性和范围,我们将使用第三方库来实现所需的算法,并为我们提供一个良好的 API 进行调用。
为了这个目的,我们将使用用 Rust 编写的开源 crate image-rs/image。crate 文档可以在以下链接找到:docs.rs/image/
让我们看看如何使用image crate 设计imagix库。
image crate 功能齐全,具有许多图像处理功能。然而,我们将仅使用我们项目所需的一小部分功能。让我们回顾一下我们对图像处理的三个关键要求:能够打开图像文件并将其加载到内存中,能够将图像调整到所需的大小,以及能够将调整大小的图像从内存写入磁盘上的文件。image-rs/image crate 中的以下方法解决了我们的需求:
-
image::open(): 此函数在指定的路径打开一个图像。它自动从图像的文件扩展名检测图像的格式。图像数据从文件中读取,并转换为存储在内存中的DynamicImage类型。 -
DynamicImage::thumbnail(): 此函数将图像缩放到指定的大小(宽度和高度),并返回一个新的图像,同时保持宽高比。它使用快速整数算法,这是一种正弦变换技术。这是一个内存操作。 -
DynamicImage::write_to(): 这个函数将图像编码并写入实现了std::io::write特质的任何对象,在我们的情况下将是一个输出文件句柄。我们将使用这个方法将调整大小的图像写入文件。
这应该足以满足我们在这个项目中的图像处理需求。对于路径操作和时间测量的其他两个问题,我们将使用 Rust 标准库,这将在下一小节中描述。
使用 Rust 标准库
在开发图像调整大小工具时,我们将使用外部 crate 和 Rust 标准库。在前一节中,我们看到了我们计划如何使用imagecrate。
在本节中,我们将介绍我们将使用 Rust 标准库构建项目时将使用的功能。我们需要标准库的三个关键领域:
-
为了在目录中搜索、定位图像文件并创建新的子目录,我们需要路径操作和目录迭代功能。
-
我们需要从用户那里获取工具配置选项。我们将评估两种方法——通过环境变量获取这些信息,以及通过命令行参数获取这些信息。我们将选择其中一个选项。
-
我们希望测量图像调整大小任务所需的时间。
让我们详细看看这些领域的每个部分。
路径操作和目录迭代
对于路径操作,我们将使用 Rust 标准库中的std::path模块。对于目录迭代,我们将使用std::fs模块。
为什么我们需要操作路径?
调整大小图像的源图像文件存储在源文件夹中。调整大小图像文件的目标路径是tmp子文件夹(在源文件夹内)。在将每个调整大小的图像文件写入磁盘之前,我们必须构建文件存储的路径。例如,如果源文件的路径是/user/bob/images/image1.jpg,调整大小图像的目标路径将是/user/bob/images/tmp/image1.jpg。我们必须程序化地构建目标路径,然后调用imagecrate 上的方法以在目标路径上存储图像。
Rust 标准库通过两种数据类型支持路径操作功能:Path和PathBuf,它们都是std::path模块的一部分。有关如何使用标准库构建和操作paths的更多详细信息,请参阅侧边栏。
Rust 标准库的std::path模块
此模块提供了跨平台的路径操作函数。
路径通过遵循目录树来指向文件系统位置。Unix 系统中的一个路径示例是/home/bob/images/。Windows 操作系统上的一个路径示例可能是c:\bob\images\image1.png。
在std::path模块中,有两个常用的主要类型——Path和PathBuf。
对于解析路径及其组件(读取操作),使用 Path。在 Rust 术语中,它是一个 路径切片(类似于字符串切片,它是对字符串的引用)。
对于修改现有路径或构建新路径,使用 PathBuf。PathBuf 是一个 拥有者、可变的路径。
Path 用于路径的读取操作,而 PathBuf 用于路径的读取和写入操作。
下面是如何从一个字符串构造新路径的方法:
let path_name = Path::new("/home/alice/foo.txt");
在 path_name 中,/home/alice 代表父目录,foo 是文件名,txt 是文件扩展名。我们将使用 Path 类型上的 file_stem() 和 extension() 方法。
在 PathBuf 类型上的 pop() 和 push() 方法用于截断和向路径添加组件。
让我们使用以下代码创建一个新的 PathBuf 路径:
let mut path_for_editing = PathBuf::from("/home/bob/file1.png")
path_for_editing.pop() 将此路径截断到其父路径,即 "/home/bob"。
现在,push() 可以用来向 PathBuf 添加新组件。例如,从具有值 "/home/bob" 的 PathBuf 继续操作,push("tmp") 将 tmp 添加到 "/home/bob" 路径,并返回 "/home/bob/tmp"。
在我们的项目中,我们将使用 pop() 和 push() 方法来操作路径。
接下来,让我们看看如何执行我们项目所需的目录操作。
当用户指定 mode=all 时,我们的要求是遍历指定源文件夹中的所有文件,并过滤出图像文件进行处理。对于遍历目录路径,我们将使用 std::fs 模块中的 read_dir() 函数。
让我们看看如何使用此函数的示例:
use std::fs;
fn main() {
let entries = fs::read_dir("/tmp").unwrap();
for entry in entries {
if let Ok(entry) = entry {
println!("{:?}", entry.path());
}
}
}
以下是对前面代码的解释:
-
fs:read_dir()函数接收一个源文件夹路径,并返回std::fs::ReadDir,它是一个遍历目录条目的迭代器。 -
然后,我们使用
for循环提取每个目录条目(它被Result类型包裹),并打印其值。
这是我们将用来获取目录条目并进行进一步处理的代码。
除了读取目录内容外,我们还需要检查源文件夹下是否存在 tmp 子文件夹,如果不存在则创建它。我们将使用 std::fs 模块中的 create_dir() 方法来创建一个新的子目录。
我们将在后续章节中看到 std::fs 模块的更多细节。
时间测量
对于测量时间,我们可以使用 std::time 模块。
Rust 标准库中的 std::time 模块包含几个与时间相关的函数,包括获取 当前系统时间、创建表示时间跨度的 duration 以及测量两个特定时间点之间的 时间流逝。以下提供了使用 time 模块的一些示例。
要获取当前系统时间,我们可以编写以下代码:
use std::time::SystemTime;
fn main() {
let _now = SystemTime::now();
}
下面是如何从给定的时间点获取经过的时间的方法:
use std::thread::sleep;
use std::time::{Duration, Instant};
fn main() {
let now = Instant::now();
sleep(Duration::new(3, 0));
println!("{:?}", now.elapsed().as_secs());
}
Instant::now() 用于指示要测量的时间的起点。从这一点到调用 now.elapsed() 的点之间的时间间隔代表操作所花费的时间。在这里,我们使用 std::thread 模块中的 sleep() 函数来模拟延迟。
处理环境变量
在本小节中,我们将学习如何使用 Rust 标准库以及第三方辅助包来存储环境变量中的值并在程序中使用它们:
-
使用以下代码行创建一个新的项目:
.env file (instead of setting them in the console), so let's add a popular crate for this purpose, called dotenv, in Cargo.toml:[dependencies]
dotenv = "0.15.0"
Depending on when you are reading this book, you may have a later version of this tool available, which you may choose to use. -
在
main.rs中添加以下代码:use dotenv::dotenv; use std::env; fn main() { dotenv().ok(); for (key, value) in env::vars() { println!("{}:{}", key, value); } }在前面的代码中,我们导入了
std::env模块和dotenv::dotenv模块。以下语句从
.env文件中加载环境变量:dotenv().ok();上一段代码块中的
for循环会循环遍历环境变量并打印到控制台。env:vars()返回一个迭代器,包含当前进程所有环境变量的键值对。 -
为了测试这一点,让我们在项目根目录中创建一个新的
.env文件,并添加以下条目:size=small mode=single srcfolder=/home/bob/images/image1.jpg -
将
srcfolder值替换为你的值。使用以下命令运行程序:.env file printed out, along with the others associated with the process. -
要访问任何特定环境变量的值,可以使用
std::env::var()函数,它将变量的键作为参数。将以下语句添加到main()函数中,并查看打印出的size变量的值:println!("Value of size is {}", env::var("size").unwrap());
我们已经看到了如何使用 环境变量 接受图像处理中的用户输入。让我们看看如何使用 命令行参数 接受用户输入。
处理命令行参数
在本小节中,我们将学习如何使用 Rust 标准库的 std::env 模块读取命令行参数:
-
std::env模块通过std::env::args()支持命令行参数。创建一个新的 Cargo 项目。将以下行添加到src/main.rs中的main()函数:use std::env; fn main() { for argument in env::args() { println!("{}", argument); } } -
使用
cargo run small all /tmp执行代码。 -
将传递给程序的三个参数打印到控制台。要通过索引访问单个参数,请将以下代码添加到
main.rs:use std::env; fn main() { let args: Vec<String> = env::args().collect(); let size = &args[1]; let mode = &args[2]; let source_folder = &args[3]; println!( "Size:{},mode:{},source folder: {}", size, mode, source_folder ); } -
使用
cargo run small all /tmp运行程序。
size、mode 和 source_folder 的单个值将按以下方式打印出来:
Size:small,mode:all,source folder: /tmp
在我们看到的两种方法中——即使用 环境变量 和 命令行参数——后者更适合接受最终用户的输入,而环境变量方法更适合开发者配置工具。
然而,为了提供用户友好的界面,std::env::args 提供的裸骨功能是不够的。我们将使用名为 StructOpt 的第三方包来改善与 CLI 的用户交互。
这完成了对 Rust 标准库中路径操作、时间测量和读取环境和命令行参数模块的深入研究。
以下是关于 imagix 库的设计方法总结:
-
image-rs/image库。我们如何创建一个
/tmp/子文件夹来存储缩放后的图像?我们将使用
std::fs::create_dir()方法。 -
std::fs::read_dir()方法。我们如何操作路径,以便输出文件存储在
tmp子文件夹中?我们将使用
std::path::Path和std::path::PathBuf类型。 -
std::path::Path类型以及std::fs::read_dir()方法。 -
std::time::Duration和std::time::Instant模块。 -
StructOpt库。
通过这些,我们得出本节关于 imagix 库的项目范围和设计的结论。我们现在可以开始编写下一节中图像处理库的代码了。
编写 imagix 库
在本节中,我们将编写图像缩放和图像统计功能的代码。让我们首先看看代码结构。
imagix 库的模块结构总结在 图 4.4 中:

图 4.4 – imagix 库的模块
imagix 库将包括两个模块,分别是 resize.rs 和 stats.rs。有两个 enum,SizeOption 和 Mode,分别用于表示 大小选项 和 模式 的变体。用户将指定 SizeOption enum 的一个变体来指示所需的输出图像大小,以及指定 Mode enum 的一个变体来指示是否需要缩放一个或多个图像。还有一个 struct Elapsed 用于捕获图像缩放操作的耗时。
resize 模块有一个 process_resize_request() 公共函数,它是 imagix 库中图像缩放的主要入口点。
stats 模块有一个 get_stats() 公共函数。
项目整体代码组织的概述显示在 图 4.5 中:

图 4.5 – 代码组织
图 4.5 展示了以下内容:
-
在
Cargo.toml文件中需要的配置和依赖项 -
Cargo 项目的代码树结构
-
imagix库的源文件列表以及关键函数列表 -
imagecli.rs文件,它代表了imagix库的命令行包装,以及我们工具中的代码执行入口点
让我们先在 imagecli 项目的根目录中的 Cargo.toml 中添加两个外部库:
[dependencies]
image = "0.23.12"
structopt = "0.3.20"
在本节中,我们将遍历以下方法的代码片段:
-
get_image_files(),演示路径导航 -
resize_image(),它包含使用图像库进行图像缩放的核心逻辑,以及时间测量 -
get_stats(),它返回文件夹中图像文件的总数和总大小 -
自定义错误处理方法
代码的其余部分是标准的 Rust 代码(不特定于本章关注的主题),可以在本章的代码仓库中找到。
遍历目录条目
在本小节中,让我们审查get_image_files()方法的代码。这是检索源文件夹中包含的图片文件列表的方法。
此方法的逻辑描述如下:
-
我们首先检索源文件夹中的目录条目并将它们收集到一个向量中。
-
然后,我们遍历向量中的条目并筛选出仅包含图片文件。请注意,在这个项目中,我们只关注
PNG和JPG文件,但也可以扩展到其他类型的图片文件。 -
此方法从该方法返回一个图片文件列表。
代码列表如下所示:
src/imagix/resize.rs
pub fn get_image_files(src_folder: PathBuf) ->
Result<Vec<PathBuf>, ImagixError> {
let entries = fs::read_dir(src_folder)
.map_err(|e| ImagixError::UserInputError("Invalid
source folder".to_string()))?
.map(|res| res.map(|e| e.path()))
.collect::<Result<Vec<_>, io::Error>>()?
.into_iter()
.filter(|r| {
r.extension() == Some("JPG".as_ref())
|| r.extension() == Some("jpg".as_ref())
|| r.extension() == Some("PNG".as_ref())
|| r.extension() == Some("png".as_ref())
})
.collect();
Ok(entries)
}
代码使用read_dir()方法遍历目录条目并将结果收集到一个Vector中。然后,将Vector转换为迭代器,并筛选出仅包含图片文件的条目。这为我们提供了用于调整大小的图片文件集。在下一小节中,我们将审查执行实际图片调整大小的代码。
调整图片大小
在本小节中,我们将审查resize_image()方法的代码。此方法执行图片的调整大小。
此方法的逻辑如下:
-
此方法接受一个包含完整源文件夹路径的源图片文件名,将其调整大小为
.png文件,并将调整大小的文件存储在源文件夹下的/tmp子文件夹中。 -
首先,从完整路径中提取源文件名。文件扩展名更改为
.png。这是因为我们的工具将仅支持.png格式的输出文件。作为一个练习,你可以添加对其他图像格式类型的支持。 -
然后使用
/tmp前缀构建目标文件路径,因为调整大小的图片需要存储在源文件夹下的tmp子文件夹中。为了实现这一点,我们首先需要检查tmp文件夹是否已经存在。如果不存在,则需要创建它。构建带有tmp子文件夹的路径和创建tmp子文件夹的逻辑在之前的代码列表中已展示。 -
最后,我们需要调整图片的大小。为此,打开源文件,使用必要的参数调用调整大小函数,并将调整大小的图片写入输出文件。
-
使用
Instant::now()和Elapsed::from()函数计算图片调整大小所需的时间。
代码列表如下所示。为了解释说明,代码列表已被拆分为多个片段。
列出的代码接受三个输入参数——大小、源文件夹和一个类型为PathBuf的条目(它可以引用图片文件的完整路径)。文件扩展名更改为.png,因为这是工具支持的输出格式:
fn resize_image(size: u32, src_folder: &mut PathBuf) ->
Result<(), ImagixError> {
// Construct destination filename with .png extension
let new_file_name = src_folder
.file_stem()
.unwrap()
.to_str()
.ok_or(std::io::ErrorKind::InvalidInput)
.map(|f| format!("{}.png", f));
此代码片段将后缀/tmp追加到文件路径条目中,以创建目标文件夹路径。请注意,由于标准库的限制,首先将文件名构造为tmp.png,随后将其更改为反映最终调整大小的图片文件名:
// Construct path to destination folder i.e. create /tmp
// under source folder if not exists
let mut dest_folder = src_folder.clone();
dest_folder.pop();
dest_folder.push("tmp/");
if !dest_folder.exists() {
fs::create_dir(&dest_folder)?;
}
dest_folder.pop();
dest_folder.push("tmp/tmp.png");
dest_folder.set_file_name(new_file_name?.as_str());
这里的代码打开图像文件并将图像数据加载到内存中。在源文件夹下创建一个/tmp子文件夹。然后,将图像调整大小并写入目标文件夹的输出文件。记录并打印调整大小操作所需的时间:
let timer = Instant::now();
let img = image::open(&src_folder)?;
let scaled = img.thumbnail(size, size);
let mut output = fs::File::create(&dest_folder)?;
scaled.write_to(&mut output, ImageFormat::Png)?;
println!(
"Thumbnailed file: {:?} to size {}x{} in {}. Output
file
in {:?}",
src_folder,
size,
size,
Elapsed::from(&timer),
dest_folder
);
Ok(())
}
我们现在已经看到了调整图像的代码。接下来,我们将查看生成图像统计信息的代码。
图像统计
在前面的子节中,我们看到了调整图像的代码。在本节中,我们将看到生成图像统计信息的逻辑。此方法将计算指定源文件夹中的图像文件数量,并测量所有图像文件的总文件大小。
我们将要使用的get_stats()方法的逻辑描述如下:
-
get_stats()方法接受一个源文件夹作为其输入参数,并返回两个值:文件夹中的图像文件数量,以及文件夹中所有图像文件的总聚合大小。 -
通过调用
get_image_files()方法获取源文件夹中的图像文件列表。 -
std::path模块中的metadata()函数允许我们查询文件或目录的元数据信息。在我们的代码中,当我们遍历目录条目时,我们将所有文件的尺寸汇总到一个变量sum中。sum变量与图像文件条目的计数一起从函数返回。
代码列表如下所示:
src/imagix/stats.rs
pub fn get_stats(src_folder: PathBuf) -> Result<(usize,
f64), ImagixError> {
let image_files = get_image_files
(src_folder.to_path_buf())?;
let size = image_files
.iter()
.map(move |f| f.metadata().unwrap().len())
.sum::<u64>();
Ok((image_files.len(), (size / 1000000) as f64))
}
我们已经涵盖了图像处理功能的代码。现在,我们将介绍项目中自定义错误处理的细节。
错误处理
现在让我们看看我们的错误处理设计。
作为我们项目的一部分,我们可能需要处理许多故障条件。其中一些如下所示:
-
用户提供的源文件夹可能无效。
-
指定的文件可能不在源文件夹中。
-
我们程序可能没有权限读取和写入文件。
-
用户输入的大小或模式可能不正确。
-
在图像调整大小过程中可能会出现错误(例如,文件损坏)。
-
可能存在其他类型的内部处理错误。
让我们定义一个自定义错误类型,以统一方式处理所有这些不同类型的错误,并将错误作为库用户输出:
src/imagix/error.rs
pub enum ImagixError {
FileIOError(String),
UserInputError(String),
ImageResizingError(String),
FormatError(String),
}
错误的名称大多一目了然。FormatError是指在转换或打印参数值时遇到的任何错误。定义此自定义错误类型的目的是将处理过程中可能遇到的多种类型的错误,如用户输入错误、无法读取目录或写入文件、图像处理错误等,转换为我们的自定义错误类型。
仅定义一个自定义错误类型是不够的。我们还需要确保在程序运行过程中发生错误时,这些错误被转换为自定义错误类型。例如,读取图像文件时出现的错误会触发std::fs模块中定义的错误。这个错误应该被捕获并转换为我们的自定义错误类型。这样,无论文件操作或错误处理中是否存在错误,程序都会统一传播相同的自定义错误类型,以便由前端界面(在这个项目中,是命令行)处理用户。
对于将各种类型的错误转换为ImagixError,我们将实现From特性和Display特性,以便错误可以被打印到控制台。
在项目模块中的每个方法中,在失败点,你会注意到会抛出ImagixError并传播回调用函数。源代码可以在本章节的 Packt 代码仓库的源文件夹中找到。
这标志着代码中错误处理的子节结束。
这也标志着本节关于imagix库编码的结束。我们只走过了关键代码片段,因为在本章中直接打印整个代码列表并不实用。我敦促读者阅读整个源代码,以了解各种功能是如何转换为惯用的 Rust 代码的。
在下一节中,我们将构建一个命令行应用程序,它封装了这个库并提供用户界面。
开发命令行应用程序并进行测试
在上一节中,我们构建了图像调整大小的库。在本节中,我们将回顾主命令行应用程序的设计和关键代码部分。
让我们从一些自动化的单元测试开始,以测试resize.rs中的图像调整大小功能:这样我们可以确认图像调整大小库可以独立于任何调用函数工作。
在以下代码中展示了两个测试用例——一个用于调整单个图像的大小,另一个用于调整多个图像的大小。你可以将代码中的源文件夹和文件名替换为你自己的:
src/imagix/resize.rs
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_single_image_resize() {
let mut path = PathBuf::from("/tmp/images/
image1.jpg");
let destination_path = PathBuf::from(
"/tmp/images/tmp/image1.png");
match process_resize_request(SizeOption::Small,
Mode::Single, &mut path) {
Ok(_) => println!("Successful resize of single
image"),
Err(e) => println!("Error in single image:
{:?}", e),
}
assert_eq!(true, destination_path.exists());
}
#[test]
fn test_multiple_image_resize() {
let mut path = PathBuf::from("/tmp/images/");
let _res = process_resize_request(
SizeOption::Small, Mode::All, &mut path);
let destination_path1 = PathBuf::from(
"/tmp/images/tmp/image1.png");
let destination_path2 = PathBuf::from(
"/tmp/images/tmp/image2.png");
assert_eq!(true, destination_path1.exists());
assert_eq!(true, destination_path2.exists());
}
}
将image1.jpg和image2.jpg文件放在/tmp/images中,并使用以下命令执行测试:
cargo test
你可以看到测试成功通过。你还可以检查调整大小的图像。
作为练习,你也可以为图像统计功能添加测试用例。
现在我们可以得出结论,imagix库按预期工作。现在让我们继续设计命令行应用程序。
我们首先来看一下命令行界面的要求。
设计命令行界面
在本小节中,我们将探讨 CLI 的设计。设计意味着确定用户将使用的 CLI 的结构。CLI 应该对最终用户来说易于使用。CLI 还必须在其执行不同类型的操作时具有一定的灵活性。
imagecli CLI 将使用类似于 git 的命令-子命令模型。
CLI 命令结构如图 4.6 所示:

图 4.6 – CLI 命令设计
这里有一些用户可以指定的带有参数的命令示例:
-
对于调整图像,命令是
cargo run –-release resize,带有三个参数。 -
对于图像统计,命令是
cargo run –-release stats,带有一个参数。 -
对于调整单个图像,命令是
cargo run --release resize --size small --mode single --srcfolder <图像文件路径/文件名.extn>。 -
对于调整多个图像的大小,我们使用
cargo run --release resize --size medium --mode all --srcfolder <包含图像的文件夹路径>命令。 -
对于图像统计,使用
cargo run --release stats --srcfolder <包含图像的文件夹路径>命令。
imagecli 的 main() 函数解析命令行参数,通过向用户显示适当的错误消息来处理用户和加工错误,并调用 imagix 库中的相应函数。
让我们快速回顾一下。为了调整图像大小,我们需要从用户那里了解以下信息:
-
模式(单文件或多文件)
-
图像文件的大小输出(小/中/大)
-
存放图像文件(或文件)的源文件夹
在本节中,我们为工具设计了命令行界面(CLI)。在前几节中,我们构建了 imagix 库来调整图像大小。现在,我们将继续进行项目的最后一部分,即开发主要的命令行二进制应用程序,该应用程序将所有组件连接起来,并接受来自命令行的用户输入。
使用 structopt 编码命令行二进制
在前一节中,我们为命令行工具设计了界面。在本节中,我们将看到 main() 函数的代码,该函数接受来自命令行的用户输入并调用 imagix 库。这个 main() 函数将被编译并构建成命令行二进制工具。用户将调用这个可执行文件来调整图像并提供必要的命令行参数。
main() 函数将位于 src/imagecli.rs 中,因为我们希望命令行工具的二进制名称为 imagecli。
现在让我们回顾命令行应用程序的代码片段。main() 函数位于 src/imagecli.rs 文件中:
-
我们将从导入部分开始。注意我们编写的
imagix库的导入,以及用于命令行参数解析的structOpt:mod imagix; use ::imagix::error::ImagixError; use ::imagix::resize::{process_resize_request, Mode, SizeOption}; use ::imagix::stats::get_stats; use std::path::PathBuf; use std::str::FromStr; use structopt::StructOpt; // Define commandline arguments in a struct -
我们现在将看到工具的命令行参数的定义。为此,我们将使用
structopt语法。请参阅docs.rs/structopt中的文档。基本上,我们定义了一个名为Commandline的enum,并定义了两个子命令,Resize和Stats。Resize接受三个参数:size、mode和srcfolder(源文件夹)。Stats接受一个参数:srcfolder:#[derive(StructOpt, Debug)] #[structopt( name = "resize", about = "This is a tool for image resizing and stats", help = "Specify subcommand resize or stats. For help, type imagecli resize --help or imagecli stats --help" )] enum Commandline { #[structopt(help = " Specify size(small/medium/large), mode(single/all) and srcfolder")] Resize { #[structopt(long)] size: SizeOption, #[structopt(long)] mode: Mode, #[structopt(long, parse(from_os_str))] srcfolder: PathBuf, }, #[structopt(help = "Specify srcfolder")] Stats { #[structopt(long, parse(from_os_str))] srcfolder: PathBuf, }, } -
我们现在可以回顾
main()函数的代码。在这里,我们基本上接受命令行输入(由StructOpt验证)并调用我们的imagix库中的合适方法。如果用户指定了Resize命令,将调用imagix库中的process_resize_request()方法。如果用户指定了Stats命令,将调用imagix库中的get_stats()方法。任何错误都通过合适的消息来处理:fn main() { let args: Commandline = Commandline::from_args(); match args { Commandline::Resize { size, mode, mut srcfolder, } => { match process_resize_request(size, mode, &mut src_folder) { Ok(_) => println!("Image resized successfully"), Err(e) => match e { ImagixError::FileIOError(e) => println!("{}", e), ImagixError::UserInputError(e) => println!("{}", e), ImagixError::ImageResizingError(e) => println!("{}", e), _ => println!("Error in processing"), }, }; } Commandline::Stats { srcfolder } => match get_stats(srcfolder) { Ok((count, size)) => println!( "Found {:?} image files with aggregate size of {:?} MB", count, size ), Err(e) => match e { ImagixError::FileIOError(e) => println!("{}", e), ImagixError::UserInputError(e) => println!("{}", e), _ => println!("Error in processing"), }, }, } } -
使用以下命令构建应用程序:
cargo build --release
使用发布构建的原因是在调试和发布构建之间调整图像大小存在相当大的时间差异(后者要快得多)。
您可以在终端执行并测试以下场景。请确保在--srcfolder标志指定的文件夹中放置一个或多个.png或.jpg文件:
-
调整单个图像大小:
cargo run --release resize --size medium --mode single --srcfolder <path-to-image-file> -
调整多个文件大小:
cargo run --release resize --size small --mode all --srcfolder <path-to-image-file> -
生成图像统计:
cargo run --release stats --srcfolder <path-to-image-folder>
在本节中,我们构建了一个从命令行界面(CLI)工作的图像调整大小工具。作为一个练习,您可以尝试添加额外的功能,包括添加对更多图像格式的支持、更改输出文件的大小,甚至提供加密生成的图像文件以提供额外安全性的选项。
摘要
在本章中,我们学习了如何编写 Rust 程序,这些程序可以以跨平台的方式发现和操作系统环境、目录结构和文件系统元数据,使用std::env、std::path和std::fs模块。我们探讨了如何创建可以使用命令行参数或环境变量来接受配置参数和用户输入的程序。我们看到了两个第三方 crate 的使用:StructOptcrate 来改进工具的用户界面,以及image-rs/image来进行图像调整大小。
我们还学习了如何使用std:time模块来测量特定处理任务所需的时间。我们定义了一个自定义错误类型以统一库中的错误处理。在本章中,我们还介绍了文件处理操作。
在下一章中,我们将详细探讨使用标准库进行高级内存管理。
第二部分:在 Rust 中管理和控制系统资源
本节介绍了如何在 Rust 中与内核交互以管理内存、文件、目录、权限、终端 I/O、进程环境、进程控制和关系、处理信号、进程间通信和多线程。示例项目包括一个计算 Rust 源文件指标的工具、一个文本查看器、一个自定义 shell 以及 Rust 源文件指标工具的多线程版本。
本节包括以下章节:
-
第五章, Rust 中的内存管理
-
第六章, 与文件和目录一起工作
-
第七章, 在 Rust 中实现终端 I/O
-
第八章, 与进程和信号一起工作
-
第九章, 管理并发
第五章:第五章:Rust 中的内存管理
在第一部分,Rust 系统编程入门中,我们介绍了 Cargo(Rust 开发工具包),Rust 语言的概述,Rust 标准库的介绍,以及用于管理进程环境、命令行和时间相关函数的标准库模块。虽然第一部分,Rust 系统编程入门的重点是提供对系统编程领域的概述和 Rust 系统编程的基础,但第二部分,在 Rust 中管理和控制系统资源,将深入探讨如何在 Rust 中管理和控制系统资源,包括内存、文件、终端、进程和线程。
我们现在进入本书的第二部分,在 Rust 中管理和控制系统资源。图 5.1提供了本节的背景信息:

图 5.1 – 管理系统资源
在本章中,我们将重点关注内存管理。以下是本章的关键学习成果:
-
操作系统(OS)内存管理的基础
-
理解 Rust 程序的内存布局
-
Rust 内存管理生命周期
-
向模板引擎添加动态数据结构
我们将本章以概述(或对已经熟悉该主题的人进行复习)开始,概述操作系统中的内存管理一般原则,包括内存管理生命周期和进程在内存中的布局。然后,我们将介绍正在运行的 Rust 程序的内存布局。这包括 Rust 程序在内存中的布局以及堆、栈和静态数据段的特点。在第三部分,我们将学习 Rust 内存管理生命周期,它与其他编程语言的不同之处,以及如何在 Rust 程序中分配、操作和释放内存。最后,我们将增强我们在第三章,Rust 标准库和系统编程关键 crate 介绍中开始构建的模板引擎,添加一个动态数据结构。
技术要求
Rustup 和 Cargo 必须在本地开发环境中安装。
本章的完整代码可以在github.com/PacktPublishing/Practical-System-Programming-for-Rust-Developers/tree/master/Chapter05找到。
操作系统(OS)内存管理的基础
在本节中,我们将深入探讨现代操作系统中的内存管理基础。那些已经熟悉这个主题的人可以快速浏览本节以进行复习。
内存是运行程序(进程)可用的最基本和最重要的资源之一。内存管理涉及处理进程使用的内存的分配、使用、操作、所有权转移和最终释放。没有内存管理,无法执行程序。内存管理由内核、程序指令、内存分配器和垃圾收集器等组件的组合执行,但具体机制因编程语言和操作系统而异。
在本节中,我们将探讨内存管理生命周期,然后了解操作系统如何为进程布局内存的细节。
内存管理生命周期
在本节中,我们将介绍与内存管理相关的不同活动:
-
当一个可执行二进制文件运行时,内存管理生命周期开始。操作系统为程序分配一个虚拟内存地址空间,并根据二进制可执行文件中的指令初始化内存的各个部分。
-
当程序处理来自 I/O 设备(如文件、网络和标准输入(来自命令行))的各种输入时,内存管理活动继续。
-
当程序终止(或由于错误异常终止程序)时,内存管理生命周期结束。
图 5.2 展示了一个典型的内存管理周期:

图 5.2 – 内存生命周期
内存管理本质上涉及四个组件——分配、使用和操作、释放/释放和跟踪使用:
-
内存分配:这是在低级编程语言中由程序员显式完成的,但在高级语言中是透明执行的。分配的内存可以是固定大小(例如,数据类型的大小在编译时确定,如整数、布尔值或固定大小的数组)或动态大小(在运行时动态增加、减少或重新定位内存,例如可调整大小的数组)。
-
内存使用和操作:以下是在程序中执行的典型活动:
-
定义特定类型的命名内存区域(例如,声明一个类型为整数的新的变量 x)
-
初始化一个变量
-
修改变量的值
-
将值复制或移动到另一个变量
-
创建和操作值引用
-
-
内存释放:在低级语言中由程序员显式执行,但在 Java、Python、JavaScript 和 Ruby 等高级语言中,通过名为垃圾收集器的组件自动处理。
-
内存跟踪:这是在内核级别进行的。程序通过系统调用来分配和释放内存。系统调用由内核执行,它跟踪每个进程的内存分配和释放。
-
交换/分页:这也由内核完成。现代操作系统虚拟化物理内存资源。进程不会直接与实际的物理内存地址交互。内核为每个进程分配虚拟地址空间。系统中所有进程分配的虚拟地址空间总和可能超过系统中可用的物理内存量,但进程并不知道(或不在乎)这一点。操作系统通过虚拟内存管理来管理这一点,确保进程彼此隔离,并且程序在其生命周期内可以访问已提交的内存。交换和分页是虚拟内存管理中的技术。
分页和交换
操作系统如何将虚拟内存地址空间映射到物理内存?为了实现这一点,分配给程序的虚拟地址空间被分成固定大小的页面(例如 4 KB 或 8 KB 块)。页面是虚拟内存中固定长度的连续块。因此,分配给程序的虚拟内存被分成多个固定长度的页面。物理 RAM 上的对应单位是页面帧,它是一个固定长度的 RAM 块。多个页面帧加起来构成了系统上的总物理内存。
在任何时刻,程序的一些虚拟页面需要存在于物理页面帧中。其余的存储在磁盘上的交换区中,这是磁盘上预留的区域。内核维护一个页面表来跟踪程序分配的虚拟内存空间中每个页面的位置。当程序尝试访问页面上的内存位置,如果页面不在页面帧上,页面就在磁盘上,然后被交换到主内存中。同样,未使用的页面在 RAM 中也会被交换回磁盘(二级存储)以腾出空间供活动进程使用。这个过程称为分页。
如果在进程级别(而不是页面级别)应用相同的技巧,则称为交换,其中将一个进程的页面从内存交换到磁盘,为另一个进程加载到内存腾出空间。
这方面的内存管理涉及将物理 RAM 映射到虚拟地址空间,被称为虚拟内存管理。这确保了进程在需要时可以访问足够的内存,并且彼此以及与内核隔离。这样,程序就不能意外(或故意)写入内核或另一个进程的内存空间,从而防止内存损坏、未定义行为和安全问题。
我们已经了解了进程的内存管理生命周期。现在让我们了解操作系统如何布局程序在内存中的情况。
进程内存布局
我们现在将查看内核为单个进程分配的虚拟地址空间的结构。图 5.3显示了Linux上进程的内存布局,但类似的机制也存在于Unix和Windows操作系统变体中:

图 5.3 – 进程内存布局
进程是一个正在运行的程序。当程序启动时,操作系统将其加载到内存中,给它访问命令行参数和环境变量的权限,并开始执行程序指令。
操作系统为进程分配一定量的内存。这种分配的内存有一个与之相关的结构,称为进程的内存布局。进程的内存布局包含几个内存区域(也称为段),这些区域不过是内存页(在上一小节中已描述)的块。这些段在图 5.3中显示,并将在下面进行描述。
图 5.3中标记为A的部分显示,分配给进程的总虚拟内存空间被分割成内核空间和用户空间。内核空间是内存区域,其中加载了内核的一部分,帮助程序管理和与硬件资源通信。这包括内核代码、内核自己的内存区域和标记为保留的空间。在本章中,我们将仅关注用户空间,因为这是程序实际使用的区域。虚拟内存的内核空间对程序不可访问。
用户空间被分割成几个内存段,下面将进行描述:
-
文本段包含程序的代码和其他只读数据,如字符串字面量和常量参数。这部分直接从程序二进制(可执行文件或库)加载。
-
数据段存储初始化为非零值的全局和静态变量。
-
BSS 段包含未初始化的变量。
-
堆用于动态内存分配。随着在堆上分配内存,进程的地址空间继续增长。堆向上增长,这意味着新项目被添加到比前一项地址更高的地址。
-
栈用于局部变量,也用于函数参数(在某些平台架构中)。栈向下增长,这意味着较早放入栈中的项目占据较低的地址空间。
小贴士
注意,栈和堆在进程地址空间的相反端分配。随着栈大小的增加,它向下增长,而随着堆大小的增加,它向上增长。如果它们相遇,将发生栈溢出错误或堆上的内存分配调用将失败。
-
在栈和堆之间,还有一个区域,用于存放任何共享内存(跨进程共享的内存)、程序使用的共享库或内存映射区域(反映磁盘上文件的内存区域)。
-
在栈的上方,有一个区域用于存储传递给程序的命令行参数和为进程设置的环境变量。
内存管理是一个复杂的话题,为了使讨论集中于 Rust 中的内存管理,省略了很多细节。然而,前面描述的虚拟内存管理和虚拟内存地址的基本原理对于理解下一节中 Rust 如何执行内存管理是至关重要的。
理解 Rust 程序的内存布局
在上一节中,我们讨论了现代操作系统中的内存管理基础。在本节中,我们将讨论运行中的 Rust 程序是如何由操作系统在内存中布局的,以及 Rust 程序使用虚拟内存不同部分的特点。
Rust 程序内存布局
为了理解 Rust 如何实现低内存占用、内存安全和性能的结合,有必要了解 Rust 程序在内存中的布局以及它们如何被程序控制。
低内存占用依赖于内存分配、值复制和释放的高效管理。内存安全涉及确保对存储在内存中的值的访问是安全的。性能取决于理解将值存储在栈、堆或静态数据段中的影响。Rust 的亮点在于,所有这些任务并非完全留给程序员,如 C/C++中那样。Rust 编译器和它的所有权系统做了很多繁重的工作,防止了整个类别的内存错误。现在让我们详细探讨这个话题。
Rust 程序的内存布局如图 5.4 所示:

图 5.4 – Rust 程序内存布局
让我们通过这张图来了解 Rust 程序的内存布局:
-
(
cargo build)被内核读入系统内存并执行时,它成为一个进程。操作系统为每个进程分配其自己的私有用户空间,以确保不同的 Rust 进程不会意外地相互干扰。 -
文本段: Rust 程序的可执行指令放置于此。此部分位于栈和堆之下,以防止任何溢出覆盖它。此段是只读的,因此其内容不会意外被覆盖。然而,多个进程可以共享文本段。让我们以一个用 Rust 编写的文本编辑器为例,它在进程 1中运行。如果需要执行编辑器的第二个副本,那么系统将创建一个新的进程,并为其分配自己的私有内存空间(让我们称其为进程 2),但不会重新加载编辑器的程序指令。相反,它将创建对进程 1的文本指令的引用。但其余的内存(数据、栈等)不会在进程间共享。
-
Rc(单线程引用计数指针)和Arc(线程安全引用计数指针)。Rust 中具有动态大小的类型示例有
Vectors、Strings和其他集合类型,这些都是在堆上分配的。原始类型,如整数,默认情况下是栈分配的,但程序员可以使用
Box<T>类型(例如,let y =3在栈上为整数y分配内存并初始化为3,而let x: Box<i32> = Box::new(3)在堆上为整数x分配内存并初始化为3)在堆上分配内存。 -
栈段: 栈是进程内存中存储临时(局部)变量、函数参数和指令的返回地址(在函数调用结束后执行)的区域。默认情况下,Rust 中的所有内存分配都在栈上。每当调用一个函数时,其变量就会在栈上分配内存。内存分配在连续的内存位置中逐个发生,形成一个栈数据结构。
总结一下,以下是运行中的 Rust 程序分配的虚拟内存的视图:
-
Rust 程序的代码指令放入文本段区域。
-
原始数据类型在栈上分配。
-
静态变量位于数据段。
-
堆分配的值(在编译时不知道大小的值,如向量字符串)存储在数据段的堆区域。
-
未初始化的变量位于BSS 段。
在这些中,Rust 程序员对文本段和BSS段的控制不多,并且主要与内存的栈、堆和静态区域工作。在下文中,我们将深入探讨这三个内存区域的特点。
栈、堆和静态内存的特性
我们已经看到了在 Rust 程序中声明的不同类型的变量是如何分配到进程空间的不同区域的。在讨论的三个内存段——文本、数据和栈中,文本区域不受 Rust 程序员的控制,但程序员有灵活性来决定是否将一个值(即分配内存)放在栈上、堆上或作为静态变量。然而,这个决定有很强的含义,因为栈、静态变量和堆的管理方式截然不同,它们的生命周期也不同。理解这些权衡是编写任何 Rust 程序的重要部分。让我们更仔细地看看它们。
表 5.1 总结了栈分配、堆分配和静态段内存的特点。回想一下 图 5.4,栈分配的内存属于 栈段,而堆和静态变量属于虚拟内存地址空间的 数据段:


表 5.1 – 栈、堆和静态内存区域的特点
理解值的内存位置是否重要?
对于那些使用过其他高级编程语言的人来说,理解一个变量是存储在栈上、堆上还是静态数据段中,实际上并不是必需的,因为语言的编译器、运行时和垃圾回收器已经抽象了这些细节,使得程序员的工作变得简单。
但在 Rust 中,尤其是在编写面向系统的程序时,了解内存布局和内存模型对于选择适合和高效的数据结构来设计系统的各个部分是必要的。在很多情况下,这种知识甚至对于使 Rust 程序编译成功也是必要的!
在本节中,我们介绍了 Rust 程序的内存布局,并了解了栈和数据段内存区域的特点。在下一节中,我们将概述 Rust 的内存管理生命周期,并与其他编程语言进行比较。我们还将详细探讨 Rust 内存管理生命周期的三个步骤。
Rust 内存管理生命周期
计算机程序可以被建模为有限状态机。一个运行的程序接受不同形式的输入(例如,文件输入、命令行参数、网络调用、中断等)并从一个状态转换到另一个状态。以设备驱动程序为例。它可以处于以下状态之一:未初始化、活动或非活动。当设备驱动程序刚刚启动(加载到内存中)时,它处于未初始化状态。当设备寄存器初始化并准备好接受事件时,它进入活动状态。它可以被置于挂起模式,此时它不接受输入,在这种情况下,它进入非活动状态。你可以进一步扩展这个概念。对于像串行端口这样的通信设备,设备驱动程序可以处于发送或接收状态。中断可以触发从一个状态到另一个状态的转换。同样,任何类型的程序,无论是内核组件、命令行工具、网络服务器还是电子商务应用,都可以用状态和转换来建模。
为什么围绕状态讨论对内存管理很重要?因为,程序员在程序中以一组具有值的变量的形式表示状态,这些值存储在运行程序的虚拟内存中(进程)。由于程序会经历无数的状态转换(例如,顶级社交媒体网站程序每天处理数亿个状态转换),所有这些状态和转换都表示在内存中,然后持久化到磁盘。现代分层应用程序堆栈的每个组件(包括前端应用、后端服务器、网络堆栈、其他系统程序和操作系统内核工具)都需要能够高效地分配、使用和释放内存。因此,了解程序在其生命周期内内存布局如何变化,以及程序员可以做什么来使其高效,是很重要的。
在这个背景下,让我们继续概述 Rust 内存管理生命周期。
Rust 内存管理生命周期的概述
现在我们来比较其他编程语言与 Rust 的内存管理生命周期。让我们也看看图 5.5,它展示了与其他编程语言相比,Rust 中内存管理是如何工作的:

图 5.5 – 其他编程语言中的内存管理
为了欣赏 Rust 内存模型,了解其他编程语言中的内存管理方式是很重要的。图 5.5展示了两组编程语言——高级和低级——如何管理内存,并将其与 Rust 进行比较。
内存管理生命周期中有三个主要步骤:
-
内存分配
-
内存使用和操作
-
内存释放(解除分配)
这三个步骤的执行方式在不同编程语言中各不相同。
高级语言(如 Java、JavaScript 和 Python)从程序员(控制力有限)那里隐藏了内存管理的许多细节,使用垃圾回收组件自动执行内存释放,并且不向程序员提供对内存指针的直接访问。
低级(也称为系统)编程语言如 C/C++ 提供给程序员完全的控制权,但不提供任何安全网。高效地管理内存完全取决于开发人员的技能和细致程度。
Rust 结合了两者之长。Rust 程序员对内存分配拥有完全的控制权,能够操纵和移动内存中的值和引用,但受到严格的 Rust 所有权规则的约束。内存释放由编译器生成的代码自动化完成。
高级编程语言与低级编程语言
注意,术语高级和低级是用来根据提供给程序员的抽象级别对编程语言进行分类的。提供更高层次编程抽象的语言更容易编程,并从内存管理周围的许多困难责任中解脱出来,但以程序员控制力不足为代价。
另一方面,系统语言如 C 和 C++ 提供给程序员完全的控制权和责任来管理内存和其他系统资源。
我们已经看到了 Rust 与其他编程语言在内存管理方法上的概述。现在让我们在以下小节中更详细地了解它们。
内存分配
内存分配是将一个值(它可以是一个整数、字符串、向量或更高层次的数据结构,如网络端口、解析器或电子商务订单)存储到内存中的过程。作为内存分配的一部分,程序员实例化一个数据类型(原始或用户定义)并为其分配一个初始值。Rust 程序通过系统调用分配内存。
在高级语言中,程序员使用指定的语法声明变量。语言编译器(与语言运行时一起)处理各种数据类型在虚拟内存中的分配和确切位置。
在 C/C++ 中,程序员通过系统调用接口控制内存分配(和重新分配),语言(编译器、运行时)不会干预程序员的决策。
在 Rust 中,默认情况下,当程序员初始化一个数据类型并为其赋值时,操作系统会在栈上分配内存。这适用于所有原始类型(整数、浮点数、字符、布尔值、固定长度数组)、函数局部变量、函数参数以及其他固定长度数据类型(例如智能指针)。但是,程序员可以选择使用 Box<T> 智能指针显式地将原始数据类型放置在堆上。其次,所有动态值(例如,大小在运行时变化的字符串和向量)都存储在堆上,而指向这些堆数据的智能指针则存储在栈上。总结一下,对于固定长度变量,值存储在栈上,具有动态长度的变量在堆段上分配内存,而指向堆分配内存起始位置的指针存储在栈上。
现在我们来看一些关于内存分配的附加信息。
在 Rust 程序中声明的所有数据类型在编译时都会计算其大小;它们不是动态分配或释放的。那么,动态分配是什么呢?
当存在随时间变化而变化的值(例如,编译时不知道值的 String 或元素数量事先不知道的集合)时,这些值在运行时在堆上分配,但此类数据的引用作为指针(具有固定大小)存储在栈上。
例如,运行以下代码:
use std::mem;
fn main() {
println!("Size of string is {:?}",
mem::size_of::<String>());
}
当你在 64 位系统上运行此程序时,即使没有创建字符串变量或为其赋值,也会打印出 String 的大小?这是因为 Rust 不关心字符串有多长,为了计算其大小。听起来很奇怪?这就是它的工作方式。
在 Rust 中,String 是一个智能指针。这可以在 图 5.6 中体现出来。它有三个组成部分:一个 String 智能指针占用 64 位(或 8 字节),因此 String 类型变量的总大小为 24 字节。这不受字符串中实际值的影响,实际值存储在堆上,而智能指针(24 字节)存储在栈上。请注意,尽管 String 智能指针的大小是固定的,但堆上分配的实际内存大小可能会随着程序运行时字符串值的改变而变化。

图 5.6 – Rust 中 String 智能指针的结构
在本小节中,我们讨论了 Rust 程序中内存分配的各个方面。在下一小节中,我们将探讨内存管理生命周期的第二步,即 Rust 程序中的内存操作和使用。
内存使用和操作
内存使用和操作指的是程序指令,例如修改分配给变量的值、将值复制到另一个变量、将值的所有权从一个变量移动到另一个变量,以及创建对现有值的新的引用。在 Rust 中,copy、move和clone是三种基本的内存操作。move操作将数据的所有权从另一个变量转移到另一个变量。copy操作允许与变量关联的值通过位复制进行复制。在数据类型上实现clone特性允许复制值而不是移动语义。
所有原始数据类型(例如整数、布尔值和字符)默认实现copy特性。这意味着将原始数据类型的变量赋值给另一个同类型的变量时,会复制值(副本)。用户定义的数据类型,如结构体,如果它们的所有数据成员也实现了copy特性,则可以自己实现copy。
任何没有实现copy的东西默认都是移动的。例如,对于Vec数据类型,所有操作(例如,将Vec值作为函数参数传递、从函数返回Vec、赋值、模式匹配)都是移动操作。Rust 没有显式地有Move特性,因为它默认就是这样的。
对于非复制数据类型,move是默认行为。为了在非复制类型上实现任意的copy操作,可以在该类型上实现clone特性。
更多详细信息可以在 Rust 书籍中找到,请参阅doc.rust-lang.org/book/。在高级语言中,程序员可以初始化变量,将值赋给变量,并将值复制到其他变量。通常,高级语言没有显式的指针语义或算术,而是使用引用。区别在于指针指向值的精确内存地址,而引用是另一个变量的别名。当程序员使用引用语义时,语言内部实现指针操作。
在 C/C++中,程序员也可以初始化变量,分配和复制值。此外,还可能进行指针操作。指针允许你直接写入进程分配的任何内存。这种模型的问题在于,这会导致几种内存安全问题,例如使用后释放、双重释放和缓冲区溢出。
在 Rust 中,内存的使用和操作遵循某些规则:
-
首先,Rust 中的所有变量默认是不可变的。如果变量中的值需要更改,则必须显式地将变量声明为可变的(使用
mut关键字)。 -
其次,有一些适用于数据访问的所有权规则,这些规则将在后面的子节中列出。
-
第三,当涉及到与一个或多个变量共享值时,有一些引用(借用)规则适用,这些规则也将在后面进行说明。
-
第四,有生命周期,它向编译器提供有关两个或多个引用如何相互关联的信息。这有助于编译器通过检查引用是否有效来防止内存安全问题。
这些概念和规则使得在 Rust 中编程与其他编程语言非常不同(有时也更为困难)。但正是这些概念赋予了 Rust 在内存和线程安全性方面的超级能力。重要的是,Rust 提供了这些好处而不产生运行时成本。
现在我们来回顾一下 Rust 的拥有性以及接下来的子节中关于借用和引用的规则。
Rust 拥有性规则
拥有性可以说是 Rust 最独特的特性。它通过没有外部垃圾回收器或完全依赖程序员的技能集,为 Rust 程序提供了内存安全性。Rust 中有三条拥有性规则,这里列出了。更多详情可以在以下链接中找到:doc.rust-lang.org/book/ch04-01-what-is-ownership.html。
管理 Rust 拥有性的规则
在 Rust 中,每个值都有一个所有者。在任何时刻,对于给定的值,只能有一个所有者。当所有者的作用域结束时,值将被丢弃(与其相关的内存将被释放)。变量的作用域的例子包括函数、for循环、语句或match表达式的分支。更多关于作用域的详情可以在这里找到:doc.rust-lang.org/reference/destructors.html#drop-scopes。
Rust 真正有趣的一面是,这些拥有性规则并不是为了让程序员去记忆,而是 Rust 编译器强制执行这些规则。这些拥有性规则的另一个重要含义是,除了内存安全性外,相同的规则还确保了线程安全性。
Rust 的借用和引用
在 Rust 中,引用只是借用一个值,并由&符号表示。它们基本上允许你引用一个值而不拥有该值。这与拥有它们所指向的值的智能指针(如String、Vector、Box和Rc)不同。
对一个值的引用称为借用,它是对对象的临时引用,但必须返回,不能被借用者(只有所有者才能释放内存)销毁。如果一个值有多个借用,编译器将确保在对象被销毁之前所有借用都结束。这消除了如使用后释放和双重释放等内存错误。
更多关于 Rust 借用和引用的详情可以在以下链接中找到:doc.rust-lang.org/book/ch04-02-references-and-borrowing.html。
管理 Rust 引用的规则
存储在内存中的值可以有一个可变引用或任意数量的不可变引用(但不能同时两者都有)。
引用必须始终有效。Rust 编译器的借用检查部分如果在代码中发现无效引用,将停止编译。当情况不明确时,Rust 编译器也会要求程序员显式指定引用的生存期。
在本小节中,我们讨论了管理内存中变量和值以及它们的规则的几个规则。在下一个小节中,我们将探讨内存管理生命周期的最后一个方面,即使用后释放内存。
内存释放
内存释放处理的是如何从 Rust 程序中将内存释放回操作系统的问题。栈分配的值会自动释放,因为这是一个受管理的内存区域。静态变量有程序结束的生存期,因此当程序结束时它们会自动释放。真正的问题在于如何释放堆分配的内存。
其中一些值可能不需要保留在内存中直到程序结束,在这种情况下,它们可以被释放。但是,这种内存释放的机制在不同编程语言组之间差异很大:
-
高级语言在不再需要时不需要程序员显式释放内存。相反,它们使用一种称为垃圾回收的机制。在这个模型中,一个称为垃圾收集器的运行时组件分析进程的堆分配内存,使用专门的算法确定未使用的对象,并释放它们。这有助于提高内存安全性,防止内存泄漏,并使开发者的编程更容易。
-
在 C/C++中,内存释放是程序员的职责。忘记释放内存会导致内存泄漏。在内存已释放后访问值会导致内存安全问题。在大型、复杂的代码库中,或者由多人维护的代码中,这会导致严重问题。
-
Rust 在内存释放方面采取了非常不同的方法。Rust 没有为类型提供
Drop特质,它将由编译器生成的代码调用。这种方法的优点是它提供了细粒度的内存控制(类似于 C/C++),同时让 Rust 程序员免于手动释放内存(类似于高级语言),而没有垃圾收集器的缺点(延迟和不可预测的 GC 暂停)。 -
注意,在 Rust 中,只有值的拥有者才能释放与其相关的内存。引用不拥有它们指向的数据,因此不能释放内存。但是智能指针拥有它们指向的数据。当智能指针超出作用域时,编译器生成的代码会调用与智能指针关联的
Drop特质的drop方法。 -
此外,这些内存释放规则仅适用于堆分配内存,因为其他两种内存段(栈和静态)是由操作系统直接管理的。
到目前为止,我们已经看到了 Rust 程序中内存分配、操作和释放的规则。所有这些共同的目标是在没有外部垃圾收集器的情况下实现内存安全的主要目标,这是 Rust 编程语言的一个真正亮点。以下的小节描述了各种类型的内存漏洞以及 Rust 如何防止它们。
什么是内存安全?
内存安全简单来说,就是在一个程序的任何可能的执行路径中,都不会访问无效的内存。以下是一些突出的内存安全漏洞类别:
-
双重释放:尝试多次释放相同的内存位置。这可能导致未定义的行为或内存损坏。Rust 的所有权规则允许只有值的拥有者才能释放内存,并且在任何时候,堆中分配的值只能有一个所有者。因此,Rust 防止这类内存安全漏洞。
-
使用后释放:在程序释放内存之后访问内存位置。被访问的内存可能已被分配给另一个指针,因此原始指针可能会意外地破坏内存位置中的值,导致未定义的行为或通过任意代码执行引发安全问题。Rust 引用和生命周期规则由编译器中的借用检查器强制执行,始终确保在使用之前引用是有效的。Rust 借用检查器防止引用比它指向的值存活时间更长的情况发生。
-
缓冲区溢出:程序试图在分配范围之外存储值。这可能会破坏数据,导致程序崩溃,或导致恶意代码的执行。Rust 将容量与缓冲区关联,并在访问时执行边界检查。因此,在安全的 Rust 代码中,不可能溢出缓冲区。如果你尝试越界写入,Rust 将引发恐慌。
-
未初始化内存使用:程序从一个已分配但未初始化为值的缓冲区读取数据。这会导致未定义的行为,因为内存位置可以持有不确定的值。Rust 阻止从未初始化的内存中读取。
-
空指针解引用:程序使用空指针写入内存,导致段错误。在安全的 Rust 中不可能有空指针,因为 Rust 确保引用不会比它引用的值存活时间更长,并且 Rust 的生命周期规则要求操作引用的函数声明输入和输出引用之间的链接方式,使用生命周期注解。
我们已经看到了 Rust 如何通过其独特的默认不可变变量、所有权规则、生命周期、引用规则和借用检查器系统实现内存安全。
通过这些,我们总结了这个关于 Rust 内存管理生命周期的章节。在下一节中,我们将使用 Rust 实现一个动态数据结构。
实现动态数据结构
在本节中,我们将增强第三章,系统编程的 Rust 标准库和关键 crate 介绍中的模板引擎,以添加对单个语句中多个模板变量的支持。我们将通过将静态数据结构转换为动态数据结构来实现这一点。
我们将用图 5.7中显示的模板引擎模型来刷新我们的记忆:

图 5.7 – 模板引擎的概念模型(来自第三章,系统编程的 Rust 标准库和关键 crate 介绍)
你会记得我们在第三章,系统编程的 Rust 标准库和关键 crate 介绍中实现了一个模板引擎,用于解析带有模板变量的输入语句并将其转换为使用上下文数据提供的动态 HTML 语句。在本节中,我们将增强模板变量功能。我们首先将讨论设计变更,然后实现代码变更。
模板引擎设计的变化
在第三章,系统编程的 Rust 标准库和关键 crate 介绍中,我们实现了模板变量内容类型,其中在命令行中输入了以下内容:
<p> Hello {{name}} </p>
这将生成以下 HTML 语句:
<p> Hello Bob </p>
我们在main()程序中提供了name=Bob的值作为上下文数据。
让我们增强本章中template variable内容类型的特性。到目前为止,我们的实现如果只有一个模板变量是可行的。但如果有多于一个模板变量(如以下示例所示),则尚不可行。
我们的预期是以下代码应该可以工作,假设我们在main()程序中提供了city=Boston和name=Bob作为上下文数据:
<p> Hello {{name}}. Are you from {{city}}? </p>
这将生成以下 HTML 语句:
<p> Hello Bob. Are you from Boston? </p>
你会注意到在输入语句中这里有两个模板变量——name和city。我们将不得不增强我们的设计以支持这一点,从ExpressionData结构开始,它存储模板变量语句解析的结果。
让我们看看ExpressionData数据结构。我们可以从位于github.com/PacktPublishing/Practical-System-Programming-for-Rust-Developers/tree/master/Chapter03的Chapter03中的代码开始:
#[derive(PartialEq, Debug)]
pub struct ExpressionData {
pub head: Option<String>,
pub variable: String,
pub tail: Option<String>,
}
在我们的实现中,<p> Hello {{name}}. How are you? </p>的输入值将被标记为ExpressionData结构,如下所示:
Head = Hello
Variable = name
Tail = How are you?
在先前的设计中,我们允许以下格式:
<String literal> <template variable> <String literal>
在 template variable 之前的字符串字面量映射到 ExpressionData 中的 Head 字段,而 template variable 之后的字符串字面量映射到 ExpressionData 的 Tail 字段。
如您所见,我们在数据结构中仅提供了对一个 template variable 的支持(variable 字段为 String 类型)。为了在语句中容纳多个 template variable,我们必须修改结构,以允许 variable 字段存储多个 template variable 条目。
除了允许多个模板变量外,我们还需要适应输入语句的更灵活的结构。在我们的当前实现中,我们适应了在 template variable 前后各有一个字符串字面量。但在现实世界中,输入语句可以有任意数量的字符串字面量,如下例所示:
<p> Hello , Hello {{name}}. Can you tell me if you are living
in {{city}}? For how long? </p>
因此,我们需要对模板引擎进行以下更改:
-
允许每个语句解析超过一个模板变量
-
允许在输入语句中解析超过两个字符串字面量
为了允许这些更改,我们必须重新设计 ExpressionData 结构。我们还需要修改处理 ExpressionData 的方法,以实现这两个更改的解析功能。
让我们回顾一下需要设计变更的总结,如图 5.8 所示。此图来自 第三章,Rust 标准库和系统编程关键 crate 的介绍,但图中突出显示了需要更改的组件:

图 5.8 – 模板引擎设计变更
在本小节中,我们为书中所构建的模板引擎设计了一个动态数据结构。在下一小节中,我们将编写代码来实现这一功能。
编码动态数据结构
如 图 5.7 所示,在本章中,我们将修改以下模板引擎组件:
-
ExpressionData结构 -
get_expression_data()函数 -
generate_html_template_var()函数 -
main()函数
我们将从对 ExpressionData 结构的更改开始:
src/lib.rs
#[derive(PartialEq, Debug, Clone)]
pub struct ExpressionData {
pub expression: String,
pub var_map: Vec<String>,
pub gen_html: String,
}
我们已经完全重构了 ExpressionData 的结构。现在它有三个字段。字段的描述如下:
-
expression:用户输入的表达式存储在这里。 -
var_map:与之前的单个String字段不同,我们现在有一个字符串向量来存储语句中的 多个模板变量。我们使用向量而不是数组,因为我们不知道在编译时用户输入中会有多少模板变量。对于向量,内存是在堆上动态分配的。 -
gen_html:将对应于输入的生成的 HTML 语句存储在这里。什么是动态数据结构?
ExpressionData是一个动态数据结构的示例。它是动态的,因为var_map字段的内存分配在运行时动态变化,取决于输入中存在的模板变量的数量以及expression字段的总长度(基于输入语句中字符串字面量的数量和长度)。表达式数据是用户定义的数据结构的示例,它与智能指针相关联,因为其字段成员包含动态值。
由于对ExpressionData结构体的结构进行了更改,我们不得不修改以下两个函数:get_expression_data()和generate_html_template_var():
src/lib.rs
pub fn get_expression_data(input_line: &str) -> ExpressionData {
let expression_iter = input_line.split_whitespace();
let mut template_var_map: Vec<String> = vec![];
for word in expression_iter {
if check_symbol_string(word, "{{") &&
check_symbol_string(word, "}}") {
template_var_map.push(word.to_string());
}
}
ExpressionData {
expression: input_line.into(),
var_map: template_var_map,
gen_html: "".into(),
}
}
在前面的代码中,我们执行以下操作:
-
将输入语句分割成由空格分隔的单词(
expression_iter) -
遍历单词以解析仅模板变量
-
将模板变量添加到字符串向量
template_var_map.push(word.to_string()); -
构建结构体
ExpressionData并从函数返回动态内存分配
在前面的函数中,以下语句显示了动态内存分配:
template_var_map.push(word.to_string());此语句将输入语句中找到的每个模板变量添加到向量集合中,然后将其存储在
ExpressionData结构体中。向量上的每个push()语句都被 Rust 标准库转换为内存分配——ExpressionData是一个动态数据结构。同样,当ExpressionData类型的变量超出作用域时,结构体中所有元素(包括字符串向量)的内存都会被释放。
我们现在将修改生成 HTML 输出的函数:
src/lib.rs
pub fn generate_html_template_var(
content: &mut ExpressionData,
context: HashMap<String, String>,
) -> &mut ExpressionData {
content.gen_html = content.expression.clone();
for var in &content.var_map {
let (_h, i) = get_index_for_symbol(&var, '{');
let (_j, k) = get_index_for_symbol(&var, '}');
let var_without_braces = &var[i + 2..k];
let val = context.get(var_without_braces).unwrap();
content.gen_html = content.gen_html.replace(var, val);
}
content
}
此函数接受两个输入——ExpressionData类型和context HashMap。让我们通过一个示例来理解其逻辑。同时,假设以下输入值传递给该函数:
-
content的表达式字段为<p> {{name}} {{city}} </p>。 -
以下值包含在
content字段的var_map中:[{{name}},{{city}}] -
以下上下文数据作为
contentHashMap 传递给函数:name=Bob和city=Boston。
这里是我们函数中执行的处理:
-
我们遍历
content字段的var_map中包含的模板变量列表。 -
对于每次迭代,我们首先从存储在
content字段的var_map中的模板变量值中去除前导和尾随的花括号。因此{{name}}变为name,{{city}}变为city。然后我们在contextHashMap 中查找它们并检索值(得到Bob和Boston)。 -
最后一步是将输入字符串中的所有
{{name}}实例替换为Bob,将所有{{city}}实例替换为Boston。结果字符串存储在content结构体的gen_html字段中,该字段的数据类型为ExpressionData。
最后,我们将修改main()函数如下。与第三章中相比,main()函数的主要变化是传递给generate_hml_template_var()函数的参数变化:
src/main.rs
use std::collections::HashMap;
use std::io;
use std::io::BufRead;
use template_engine::*;
fn main() {
let mut context: HashMap<String, String> = HashMap::new();
context.insert("name".to_string(), "Bob".to_string());
context.insert("city".to_string(), "Boston".to_string());
for line in io::stdin().lock().lines() {
match get_content_type(&line.unwrap().clone()) {
ContentType::TemplateVariable(mut content) => {
let html = generate_html_template_var(&mut
content, context.clone());
println!("{}", html.gen_html);
}
ContentType::Literal(text) => println!("{}",
text),
ContentType::Tag(TagType::ForTag) => println!("For
Tag not implemented"),
ContentType::Tag(TagType::IfTag) => println!("If
Tag not implemented"),
ContentType::Unrecognized => println!(
"Unrecognized input"),
}
}
}
通过这些更改,我们可以使用cargo run运行程序,并在命令行中输入以下内容:
<p> Hello {{name}}. Are you from {{city}}? </p>
您将在终端上看到以下生成的 HTML 语句:
<p> Hello Bob. Are you from Boston? </p>
在本节中,我们将ExpressionData结构从静态数据结构转换为动态数据结构,并修改了相关函数,为模板引擎添加了以下功能:
-
允许每个语句解析多个模板变量
-
允许在输入语句中解析超过两个字符串字面量
现在,让我们以总结结束本章。
概述
在本章中,我们深入探讨了 Linux 环境中标准进程的内存布局,然后是 Rust 程序的内存布局。我们比较了不同编程语言中的内存管理生命周期,以及 Rust 在内存管理方面采取的不同方法。我们学习了在 Rust 程序中如何分配、操作和释放内存,并探讨了 Rust 内存管理的规则,包括所有权和引用规则。我们探讨了不同类型的内存安全问题以及 Rust 如何通过其所有权模型、生命周期、引用规则和借用检查器来防止这些问题。
然后,我们回到了Chapter03中的模板引擎实现示例,并为模板引擎添加了一些功能。我们通过将静态数据结构转换为动态数据结构来实现这一点,并学习了如何动态分配内存。动态数据结构在处理外部输入的程序中非常有用,例如,在接收来自网络套接字或文件描述符的传入数据的程序中,事先不知道传入数据的大小,这在您使用 Rust 编写的大多数现实世界复杂程序中很可能是这种情况。
这就结束了内存管理主题。在下一章中,我们将更深入地探讨处理文件和目录操作的 Rust 标准库模块。
进一步阅读
理解 Rust 中的所有权:doc.rust-lang.org/book/ch04-00-understanding-ownership.html
第六章:第六章:在 Rust 中处理文件和目录
在上一章中,我们探讨了 Rust 使用内存的细节,内存是关键的系统资源。
在本章中,我们将探讨 Rust 如何与另一类重要的系统资源——文件和目录——交互。Rust 标准库提供了一套丰富的抽象,使得平台无关的文件和目录操作成为可能。
对于本章,我们将回顾 Unix/Linux 管理文件的基本知识,并掌握 Rust 标准库提供的用于处理文件、路径、链接和目录的关键 API。
使用 Rust 标准库,我们将实现一个 shell 命令 rstat,该命令计算目录(及其子目录)中 Rust 代码的总行数,并提供一些额外的源代码度量指标。
我们将按以下顺序介绍主题:
-
理解 Linux 文件操作的系统调用
-
在 Rust 中进行文件 I/O 操作
-
学习目录和路径操作
-
设置硬链接、符号链接和执行查询
-
在 Rust 中编写 shell 命令(项目)
技术要求
使用以下命令验证 rustc 和 cargo 是否已正确安装:
rustc --version
cargo --version
本章代码的 Git 仓库可以在 github.com/PacktPublishing/Practical-System-Programming-for-Rust-Developers/tree/master/Chapter06 找到。
理解 Linux 文件操作的系统调用
在本节中,我们将探讨与操作系统级别管理文件系统资源相关的术语和基本机制。我们将以 Linux/Unix 为例,但类似的概念也适用于其他操作系统。
那么,你认为文件是什么?
文件只是一组字节。字节代表信息的一个单位——它可以是一个数字、文本、视频、音频、图像或其他类似的数字内容。字节组织在一个称为字节流的线性数组中。从操作系统的角度来看,文件的结构或内容没有其他期望。对文件的解释及其内容是由用户应用程序来完成的。
用户应用程序是一个不属于操作系统内核的程序。一个用户应用程序的例子是图像查看器,它将数据字节解释为图像。由于文件是由操作系统管理的资源,因此我们编写的任何用户程序都必须知道如何通过系统调用来与操作系统交互。文件可以被读取、写入或执行。一个可以执行的文件示例是由 Make 或 Cargo 等软件构建系统生成的二进制可执行(对象)文件。
Linux/Unix 独有的另一个方面是“一切皆文件”的哲学。在这里,“一切”指的是系统资源。Linux/Unix 上可以存在许多类型的文件:
-
常规文件,我们用它来存储文本或二进制数据
-
目录,包含名称列表和其他文件的引用
-
块设备文件,例如硬盘、磁带驱动器、USB 摄像头
-
字符设备文件,例如终端、键盘、打印机、声卡
-
命名管道,一种内存中的进程间通信机制
-
Unix 域套接字,也是一种进程间通信的形式
-
链接,如硬链接和符号链接
在本章中,我们将重点关注文件、目录和链接。然而,Unix I/O 模型的通用性意味着用于打开、读取、写入和关闭常规文件的同一系列系统调用也可以用于任何其他类型的文件,如设备文件。在 Linux/Unix 中,这是通过标准化系统调用实现的,然后由各种文件系统和设备驱动程序实现。
Linux/Unix 还提供了一个统一的/mnt/cdrom,它成为访问文件系统根目录的位置。文件系统的根目录可以在挂载点访问。
进程的挂载命名空间是它看到的所有挂载文件系统的集合。执行文件操作的系统调用进程在它视为其挂载命名空间一部分的文件和目录集合上操作。
Unix/Linux 系统调用(应用程序编程接口 - API)模型中的文件操作依赖于四个操作:打开、读取、写入和关闭,所有这些操作都与文件描述符的概念相关。什么是文件描述符?
文件描述符是文件的句柄。打开一个文件会返回一个文件描述符,而读取、写入和关闭等操作则使用文件描述符。
更多关于文件描述符的信息
读写等文件操作由进程执行。进程通过在内核上调用系统调用来执行这些操作。一旦进程打开一个文件,内核就会在文件表中记录它,其中每个条目都包含打开文件的详细信息,包括文件描述符(fd)和文件位置。每个 Linux 进程对其可以打开的文件数量都有一个限制。
对于内核来说,所有打开的文件都通过文件描述符来引用。当进程打开一个现有文件或创建一个新文件时,内核会返回一个文件描述符给进程。默认情况下,当进程从 shell 启动时,会自动创建三个文件描述符:open: 0 – 标准输入(stdin)、1- 标准输出(stdout)和2- 标准错误(stderr)。
内核维护一个所有打开文件描述符的表。如果进程打开或创建一个文件,内核从空闲文件描述符池中分配下一个空闲的文件描述符。当文件关闭时,文件描述符被释放回池中,并可用于重新分配。
现在我们来看一下与文件操作相关的常见 系统调用,操作系统将这些调用暴露出来:
-
open(): 这个系统调用打开一个现有文件。如果文件不存在,它还可以创建一个新文件。它接受一个路径名、文件打开的模式和标志。它返回一个文件描述符,可以在后续的系统调用中使用该文件描述符来访问文件:int open() system call. An example of a flag is O_CREAT, which tells the system call to create a file if the file does not exist, and returns the file descriptor.If there is an error in opening a file, `-1` is returned in place of the file descriptor, and the error number (`errno`) returned specifies the reason for the error. File open calls can fail for a variety of reasons including a *permissions error* and the *incorrect path* being specified in an argument to a system call. -
read(): 这个系统调用接受三个参数:一个 文件描述符、要读取的 字节数 以及数据读取后要放置的 缓冲区内存地址。它返回读取的字节数。在读取文件时发生错误时返回-1。 -
write(): 这个系统调用与read()类似,因为它也接受三个参数——一个 文件描述符、一个从其中读取数据的 缓冲区指针 以及从缓冲区中读取的 字节数。请注意,write()系统调用的成功完成并不保证字节立即写入磁盘,因为内核为了性能和效率原因对磁盘的 I/O 进行了缓冲。 -
close(): 这个系统调用接受一个 文件描述符 并释放它。如果对一个文件没有显式调用close(),那么当进程结束时,所有打开的文件都会被关闭。但是,为了重用内核,在不再需要时释放文件描述符(文件描述符)是一个好的做法。 -
lseek(): 对于每个打开的文件,内核跟踪一个文件偏移量,它表示下一次读取或写入操作将在文件中的位置。lseek()系统调用允许你将文件偏移量重新定位到文件中的任何位置。lseek()系统调用接受三个参数——文件描述符、偏移量和参考位置。参考位置可以取三个值——文件开头、当前光标位置或文件结尾。偏移量指定相对于参考位置的字节数,文件偏移量应指向该位置,以便进行下一次read()或write()操作。
这完成了对操作系统如何管理文件作为系统资源术语和关键概念的概述。我们已经看到了 Linux 中用于处理文件的 主要系统调用(syscalls)。在这本书中,我们不会直接使用这些 syscalls。但我们将通过 Rust 标准库模块间接地使用这些 syscalls。Rust 标准库提供了更高层次的 包装器,以便更容易地使用这些 syscalls。这些 包装器 还允许 Rust 程序在没有必要了解不同操作系统之间 syscalls 的所有差异的情况下工作。然而,了解操作系统如何管理文件,可以让我们窥见当我们使用 Rust 标准库进行文件和目录操作时,底层发生了什么。
在下一节中,我们将介绍如何在 Rust 中进行文件输入/输出操作。
在 Rust 中进行文件 I/O 操作
在本节中,我们将探讨 Rust 方法调用,这些调用使我们能够在 Rust 程序中处理文件。Rust 标准库让程序员免于直接处理系统调用,并提供了一组包装方法,这些方法公开了常见文件操作的 API。
Rust 标准库中用于处理文件的主要模块是 std::fs。std::fs 的官方文档可以在这里找到:doc.rust-lang.org/std/fs/index.html。该文档提供了一组方法、结构体、枚举和特质,这些方法共同提供了处理文件的功能。研究 std::fs 模块的结构有助于加深理解。然而,对于刚开始探索 Rust 系统编程的人来说,从程序员希望对文件执行的操作的心理模型开始,并将其映射回 Rust 标准库,可能更有用。这就是本节我们将要做的事情。文件的常见生命周期操作在 图 6.1 中展示。

图 6.1 – 常见的文件生命周期操作
程序员喜欢对文件执行的一些常见操作包括创建文件、打开和关闭文件、读取和写入文件、访问文件的元数据以及设置文件权限。这些操作在 图 6.1 中展示。这里提供了如何使用 Rust 标准库执行每个文件操作的描述:
-
std::fs模块中的File::create()允许你创建一个新文件并向其写入。可以使用std::fs::OpenOptions结构指定要创建的文件的自定义权限。下面是一个使用std::fs模块进行 创建 操作的代码片段示例:use std::fs::File; fn main() { let file = File::create("./stats.txt"); } -
std::fs::File::open()。默认情况下,它以只读模式打开文件。可以使用std::fs::OpenOptions结构来设置创建文件的自定义权限。以下展示了两种打开文件的方法。第一个函数返回一个Result类型,我们只是使用.expect()来处理它,如果文件未找到,则抛出带有消息的异常。第二个函数使用OpenOptions为要打开的文件设置额外的权限。在示例中,我们正在打开一个文件进行写操作,并且要求如果文件尚未存在则创建该文件:use std::fs::File; use std::fs::OpenOptions; fn main() { // Method 1 let _file1 = File::open("stats1.txt").expect("File not found"); // Method 2 let _file2 = OpenOptions::new() .write(true) .create(true) .open("stats2.txt"); } -
std::fs::copy()函数可用于将一个文件的内容复制到另一个文件,并覆盖后者。以下是一个示例:use std::fs; fn main() { fs::copy("stats1.txt", "stats2.txt").expect("Unable to copy"); } -
std::fs::rename()函数可用于此目的。如果目标文件已存在,则将其替换。需要注意的是,在进程的挂载命名空间中可以挂载多个文件系统(在各个位置),如前节所示。Rust 中的rename方法只有在源和目标文件路径位于同一文件系统中时才会工作。以下是一个rename()函数用法的示例:use std::fs; fn main() { fs::rename("stats1.txt", "stats3.txt").expect("Unable to rename"); } -
std::fs模块中提供了两个函数:fs::read()和fs::read_to_string()。前者将文件内容读取到一个bytes向量中。它根据文件大小(如果可用)预分配一个缓冲区。后者直接将文件内容读取到字符串中。以下是一些示例:use std::fs; fn main() { let byte_arr = fs::read(), we convert the byte_arr into a string for printing purposes, as printing out a byte array is not human-readable. -
std::fs模块中的fs::write()函数接受一个文件名和一个字节切片,并将字节切片作为文件的内容写入。以下是一个示例:use std::fs; fn main() { fs::write("stats3.txt", "Rust is exciting,isn't it?").expect("Unable to write to file"); } -
std::fs模块。函数is_dir()、is_file()和is_symlink()分别检查一个文件是否是常规文件、目录或符号链接。modified()、created()、accessed()、len()和metadata()函数用于检索文件元数据信息。permissions()函数用于检索文件上的权限列表。下面展示了查询操作的几个示例:
use std::fs; fn main() { let file_metadata = fs::metadata("stats.txt"). expect("Unable to get file metadata"); println!( "Len: {}, last accessed: {:?}, modified : {:?}, created: {:?}", file_metadata.len(), file_metadata.accessed(), file_metadata.modified(), file_metadata.created() ); println!( "Is file: {}, Is dir: {}, is Symlink: {}", file_metadata.is_file(), file_metadata.is_dir(), file_metadata.file_type().is_symlink() ); println!("File metadata: {:?}",fs::metadata ("stats.txt")); println!("Permissions of file are: {:?}", file_metadata.permissions()); } -
set_permissions()。以下是一个示例,其中在将文件权限设置为只读后,对文件的写操作失败:use std::fs; fn main() { let mut permissions = fs::metadata("stats.txt"). unwrap().permissions(); permissions.set_readonly(true); let _ = fs::set_permissions("stats.txt", permissions).expect("Unable to set permission"); fs::write("stats.txt", "Hello- Can you see me?"). expect("Unable to write to file"); } -
Rust 标准库中的
close()方法用于关闭文件。
在本节中,我们看到了 Rust 标准库中用于执行文件操作和查询操作的关键函数调用。在下一节中,我们将探讨如何使用 Rust 标准库进行目录和路径操作。
学习目录和路径操作
Linux(以及其他 Unix 变体)的内核维护一个对进程可见的单个目录树结构,它是分层的,包含该命名空间中的所有文件。这种分层组织包含单个文件、目录和链接(例如,符号链接)。
在前一个部分,我们探讨了 Rust 中的文件和文件操作。在本节中,我们将更详细地探讨目录和路径操作。在下一节中,我们将介绍链接。
目录是一个特殊的文件,其中包含一个文件名列表及其引用(/代表根目录,而/home和/etc将链接到/作为父目录。(注意,在某些操作系统,如 Microsoft Windows 变体中,每个磁盘设备都有自己的文件层次结构,并且没有单一的统一命名空间。)每个目录至少包含两个条目——一个指向自身的点条目和一个点点目录,它指向其父目录:

图 6.2 – 常见的目录和路径操作
在 Rust 标准库中,std::fs模块包含用于处理目录的方法,而std::path模块包含用于处理路径的方法。
正如前一个部分一样,我们将探讨涉及目录和路径操作的常见编程任务。这些在图 6.2中展示,并在此处详细说明:
-
std::fs模块中的std::fs::read_dir()函数可以用来遍历和检索目录中的条目。从检索到的目录条目中,可以使用path()、metadata()、file_name()和file_type()函数获取目录条目的元数据详情。这里展示了如何操作的示例:use std::fs; use std::path::Path; fn main() { let dir_entries = fs::read_dir(".").expect("Unable to read directory contents"); // Read directory contents for entry in dir_entries { //Get details of each directory entry let entry = entry.unwrap(); let entry_path = entry.path(); let entry_metadata = entry.metadata().unwrap(); let entry_file_type = entry.file_type().unwrap(); let entry_file_name = entry.file_name(); println!( "Path is {:?}.\n Metadata is {:?}\n File_type is {:?}.\n Entry name is{:?}.\n", entry_path, entry_metadata, entry_file_type, entry_file_name ); } // Get path components let new_path = Path::new("/usr/d1/d2/d3/bar.txt"); println!("Path parent is: {:?}", new_path.parent()); for component in new_path.components() { println!("Path component is: {:?}", component); } }接下来,我们将探讨如何程序化构建目录树。
-
std::fs模块。Rust 的std::fs::DirBuilder结构体提供了递归构建目录结构的方法。这里展示了递归创建目录结构的示例:use std::fs::DirBuilder; fn main() { let dir_structure = "/tmp/dir1/dir2/dir3"; DirBuilder::new() .recursive(true) .create(dir_structure) .unwrap(); }注意,还有另外两个函数也可以用来创建目录。
std::fs中的create_dir()和create_dir_all()可以用于此目的。同样,
std::fs模块中的remove_dir()和remove_dir_all()函数可以用来删除目录。接下来,我们将探讨如何动态构建路径字符串。
-
/usr/bob/a.txt,usr和bob代表目录,而a.txt代表文件。Rust 标准库提供了程序化构建路径字符串(表示文件或目录的完整路径)的功能。这在std::path::PathBuf中可用。这里展示了如何动态构建路径的示例:use std::path::PathBuf; fn main() { let mut f_path = PathBuf::new(); f_path.push(r"/tmp"); f_path.push("packt"); f_path.push("rust"); f_path.push("book"); f_path.set_extension("rs"); println!("Path constructed is {:?}", f_path); }
在展示的代码中,创建了一个新的PathBuf类型变量,并将各种路径组件动态添加以创建一个完全限定的路径。
这就完成了关于 Rust 标准库中目录和路径操作的子节。
在本节中,我们探讨了如何使用 Rust 标准库读取目录条目,获取它们的元数据,程序化构建目录结构,获取路径组件,并动态构建路径字符串。
在下一节中,我们将探讨如何使用 链接 和 查询。
设置硬链接、符号链接和执行查询
我们之前看到,在文件系统中,目录被处理得类似于常规文件。但它有不同的文件类型,并且包含一个包含文件名及其 Inode 的列表。ls –li 命令显示了与文件对应的 Inode 号码,如下所示:

图 6.3 – 文件列表中可见的 Inode 号码
由于目录包含一个将 文件名 与 inode 号码 映射的列表,因此可以有多个文件名映射到同一个 Inode 号码。这样的多个名称称为 ln shell 命令。并非所有非 UNIX 文件系统都支持这样的硬链接。
在文件系统中,可以有指向同一文件的多个 链接。它们本质上都是相同的,因为它们都指向同一个文件。大多数文件都有一个 链接计数 为 1(意味着该文件只有一个目录条目),但文件可以有 链接计数 > 1(例如,如果有两个链接指向同一个 inode 条目,那么该文件将有两个目录条目,并且 链接计数 将为 2)。内核维护这个 链接计数。
硬链接有一个限制,即它们只能引用同一文件系统内的文件,因为 Inode 号码只在文件系统内是唯一的。还有一种称为 ln –s 命令的另一种链接类型。由于符号链接引用的是文件名而不是 Inode 号码,因此它可以引用另一个文件系统中的文件。此外,与硬链接不同,符号链接可以在目录中创建。
在以下要点中,我们将看到 Rust 标准库中可用于创建和查询硬链接和符号链接(symlinks)的方法:
-
std::fs模块有一个名为fs::hard_link的函数,可以用于在文件系统中创建一个新的硬链接。以下是一个示例:use std::fs; fn main() -> std::io::Result<()> { fs::hard_link("stats.txt", "./statsa.txt")?; // Hard // link stats.txt to statsa.txt Ok(()) } -
使用 Rust 标准库中的
symlink方法在不同平台上会有所不同。在 Unix/Linux 上,可以使用std::os::unix::fs::symlink方法。在 Windows 上,有两个 API –os::windows::fs::symlink_file用于创建指向文件的 符号链接,或者os::windows::fs::symlink_dir用于创建指向目录的symlink。以下是在类 Unix 平台上创建symlink的示例:use std::fs; use std::os::unix::fs as fsunix; fn main() { fsunix::symlink("stats.txt", "sym_stats.txt"). expect("Cannot create symbolic link"); let sym_path = fs::read_link("sym_stats.txt"). expect("Cannot read link"); println!("Link is {:?}", sym_path); }
可以使用 fs::read_link 函数来读取符号链接,如下面的代码所示。
通过这一点,我们完成了关于在 Rust 标准库中处理链接的子节。到目前为止,我们已经看到了如何在 Rust 中处理文件、目录、路径和链接。在下一节中,我们将构建一个小型的 shell 命令,以展示 Rust 标准库在文件和目录操作中的实际应用。
在 Rust 中编写 shell 命令(项目)
在本节中,我们将利用我们在前几节中学到的 Rust 标准库中关于文件和目录操作的知识来实现一个 shell 命令。
这个 shell 命令会做什么?
该 shell 命令将被命名为rstat,即Rust 源统计。给定一个目录作为参数,它将生成 Rust 源文件的文件计数,以及目录结构内的源代码度量,如空白数、注释和实际代码行数。
下面是你将输入的内容:
cargo run --release -- -m src .
下面是从这个 shell 命令中看到的结果示例:
Summary stats: SrcStats { number_of_files: 7, loc: 187, comments: 8, blanks: 20 }
本节分为四个子节。在第一个子节中,我们将概述代码结构并总结构建此 shell 命令的步骤。然后,在三个不同的子节中,我们将回顾与错误处理、源度量计算和主程序对应的三个源文件的代码。
代码概述
在本子节中,我们将探讨 shell 命令的代码结构。我们还将回顾构建 shell 命令的步骤摘要。让我们开始吧。
代码结构如图6.4所示:

图 6.4 – Shell 命令代码结构
下面是构建 shell 命令的步骤摘要。源代码片段将在本节后面展示:
-
创建项目:使用以下命令创建一个新项目,并将目录更改为
rstat目录:cargo new rstat && cd rstat -
创建源文件:在
src文件夹下创建三个文件 –main.rs、srcstats.rs和errors.rs。 -
定义自定义错误处理:在
errors.rs中,创建一个结构体StatsError来表示我们的自定义错误类型。这将用于统一我们项目中的错误处理并向用户发送消息。在struct StatsError上实现以下四个特质:fmt::Display、From<&str>、From<io::Error>和From<std::num::TryFromIntError>。 -
定义计算源统计的逻辑:在
srcstats.rs中,创建一个结构体SrcStats来定义要计算的源度量。定义两个函数:get_src_stats_for_file()(接受一个文件名作为参数并计算该文件的源度量)和get_summary_src_stats()(接受一个目录名称作为参数并计算该目录根下所有文件的源度量)。 -
编写
main()函数以接受命令行参数:在
main.rs中,定义一个Opt结构来定义 shell 命令的命令行参数和标志。编写main()函数,该函数从命令行接受源目录名称并调用srcstats模块中的get_summary_src_stats()方法。确保在依赖项中包含Cargo.toml。 -
使用以下命令构建工具:
cargo build --release -
使用以下命令运行 shell 命令:
rstat binary to the path, and set LD_LIBRARY PATH to run the shell command like this:可以像下面这样设置 LD_LIBRARY_PATH(对于 Windows 可以使用等效命令):
export LD_LIBRARY_PATH=$(rustc --print sysroot)/lib:$LD_LIBRARY_PATH -
查看打印到终端的汇总源统计并确认生成的度量。
现在我们将查看之前列出的步骤的代码片段。我们将首先定义自定义错误处理。
错误处理
在执行我们的 shell 命令时,可能会出现几个问题。指定的源文件夹可能无效。查看目录条目的权限可能不足。还可能有其他类型的 I/O 错误,如以下所列:doc.rust-lang.org/std/io/enum.ErrorKind.html。为了向我们用户返回有意义的消息,我们将创建一个自定义错误类型。我们还将编写转换方法,通过实现各种 From 特性,将不同类型的 I/O 错误自动转换为我们的自定义错误类型。所有这些代码都存储在 errors.rs 文件中。让我们分两部分回顾这个文件中的代码片段:
-
第一部分涵盖了自定义错误类型和
Display特性的实现。 -
第二部分涵盖了自定义错误类型的各种
From特性实现。
errors.rs 代码的第一部分如下所示:
src/errors.rs (第一部分)
use std::fmt;
use std::io;
#[derive(Debug)]
pub struct StatsError {
pub message: String,
}
impl fmt::Display for StatsError {
fn fmt(&self, f: &mut fmt::Formatter) -> Result<(),
fmt::Error> {
write!(f, "{}", self)
}
}
在这里,定义了 StatsError 结构体,其中包含一个 message 字段,用于存储错误消息,在出现错误的情况下将传递给用户。我们还实现了 Display 特性,以便错误消息可以打印到控制台。
现在让我们看看 errors.rs 文件的第二部分。在这里,我们实现了各种 From 特性的实现,如下所示。代码注释编号,并在代码列表之后进行描述:
src/errors.rs (第二部分)
impl From<&str> for StatsError { <1>
fn from(s: &str) -> Self {
StatsError {
message: s.to_string(),
}
}
}
impl From<io::Error> for StatsError { <2>
fn from(e: io::Error) -> Self {
StatsError {
message: e.to_string(),
}
}
}
impl From<std::num::TryFromIntError> for StatsError { <3>
fn from(_e: std::num::TryFromIntError) -> Self {
StatsError {
message: "Number conversion error".to_string(),
}
}
}
源代码注释(用数字表示)的详细说明如下:
-
帮助从字符串构建
StatsError -
将
IO:Error转换为StatsError -
用于在将
usize转换为u32时检查错误
在本节中,我们回顾了 errors.js 文件的代码。在下一节中,我们将看到源代码度量的计算代码。
源代码度量计算
在本节中,我们将查看 srcstats.rs 文件的代码。该文件的代码片段按以下顺序分别展示:
-
第一部分: 模块导入
-
第二部分:
SrcStats结构体的定义 -
第三部分:
get_summary_src_stats()函数的定义 -
第四部分:
get_src_stats_for_file()函数的定义
让我们看看 第一部分。模块导入如下所示。与代码注释编号对应的描述在代码列表之后:
src/srcstats.rs (第一部分)
use std::convert::TryFrom; <1>
use std::ffi::OsStr; <2>
use std::fs; <3>
use std::fs::DirEntry; <4>
use std::path::{Path, PathBuf};<5>
use super::errors::StatsError; <6>
列出编号代码注释的描述如下:
-
使用
TryFrom来捕获将usize转换为u32时可能出现的任何错误。 -
OsStr用于检查具有.rs扩展名的文件。 -
std::fs是 Rust 标准库中用于文件和目录操作的主要模块。 -
DirEntry是 Rust 标准库中用于表示单个目录条目的结构体。 -
Path和PathBuf用于存储路径名。&Path类似于&str,而PathBuf类似于String。一个是引用,另一个是拥有对象。 -
读取文件或计算中出现的任何错误都被转换为自定义错误类型
StatsError。这一行中导入了它。
我们现在将查看 第二部分。存储计算出的指标的结构的定义在这里介绍。
结构体 SrcStats 包含以下源代码指标,这些指标将由我们的 shell 命令生成:
-
Rust 源代码文件的数量
-
代码行数(不包括注释和空白行)
-
空白行的数量
-
注释行的数量(以
//开头的单行注释;请注意,在这个工具的范围内我们不考虑多行注释)
用于存储计算出的源代码指标的 Rust 数据结构如下所示:
src/srcstats.rs (第-2 部分)
// Struct to hold the stats
#[derive(Debug)]
pub struct SrcStats {
pub number_of_files: u32,
pub loc: u32,
pub comments: u32,
pub blanks: u32,
}
让我们看看 第三部分,这是计算汇总统计信息的主要函数。由于这段代码有点长,我们将分三部分来查看:
-
代码片段的第 3a 部分显示了变量初始化。
-
代码片段的第 3b 部分显示了递归检索目录内 Rust 源代码文件的主要代码。
-
在第 3c 部分,我们遍历 Rust 文件列表,并对每个文件调用
get_src_stats_for_file()方法来计算源代码指标。结果被汇总。
get_summary_src_stats() 方法的第 3a 部分如下所示:
src/srcstats.rs (第 3a 部分)
pub fn get_summary_src_stats(in_dir: &Path) ->
Result<SrcStats, StatsError> {
let mut total_loc = 0;
let mut total_comments = 0;
let mut total_blanks = 0;
let mut dir_entries: Vec<PathBuf> =
vec![in_dir.to_path_buf()];
let mut file_entries: Vec<DirEntry> = vec![];
// Recursively iterate over directory entries to get flat
// list of .rs files
代码片段的第 3a 部分显示了变量的初始化。
get_summary_src_stats() 方法的第 3b 部分如下所示:
src/srcstats.rs (第-3b 部分)
while let Some(entry) = dir_entries.pop() {
for inner_entry in fs::read_dir(&entry)? {
if let Ok(entry) = inner_entry {
if entry.path().is_dir() {
dir_entries.push(entry.path());
} else {
if entry.path().extension() ==
Some(OsStr::new("rs")) {
file_entries.push(entry);
}
}
}
}
}
在代码的第 3b 部分,我们正在遍历指定文件夹内的条目,并将 目录 类型的条目与 文件 类型的条目分开,分别存储在单独的 vector 变量中。
get_summary_src_stats() 方法的第 3c 部分如下所示:
src/srcstats.rs (第 3c 部分)
let file_count = file_entries.len();
// Compute the stats
for entry in file_entries {
let stat = get_src_stats_for_file(&entry.path())?;
total_loc += stat.loc;
total_blanks += stat.blanks;
total_comments += stat.comments;
}
Ok(SrcStats {
number_of_files: u32::try_from(file_count)?,
loc: total_loc,
comments: total_comments,
blanks: total_blanks,
})
}
我们现在将查看 第四部分,这是计算单个 Rust 源代码文件的源代码指标的代码:
src/srcstats.rs (第-4 部分)
pub fn get_src_stats_for_file(file_name: &Path) ->
Result<SrcStats, StatsError> {
let file_contents = fs::read_to_string(file_name)?;
let mut loc = 0;
let mut blanks = 0;
let mut comments = 0;
for line in file_contents.lines() {
if line.len() == 0 {
blanks += 1;
} else if line.trim_start().starts_with("//") {
comments += 1;
} else {
loc += 1;
}
}
let source_stats = SrcStats {
number_of_files: u32::try_from(file_contents.lines()
.count())?,
loc: loc,
comments: comments,
blanks: blanks,
};
Ok(source_stats)
}
在第四部分,展示了 get_src_stats_for_file() 函数的代码。此函数逐行读取源文件,并确定该行是否对应于常规代码行、空白行或注释。根据这种分类,相应的计数器会增加。最终结果作为 SrcStats 结构体从函数返回。
这完成了 srcstats 模块的代码列表。在本小节中,我们回顾了计算源代码指标的代码。在下一节中,我们将回顾代码列表的最后部分,即 main() 函数。
main() 函数
在本小节中,我们现在将查看代码的最后一部分,即代表二进制程序入口点的 main() 函数。它执行四个任务:
-
从命令行接受用户输入。
-
调用适当的方法来计算源代码指标。
-
将结果显示给用户。
-
在出现错误的情况下,会向用户显示一个合适的错误信息。
main() 函数的代码分为两部分:
-
第一部分展示了 shell 命令的命令行界面结构。
-
第二部分展示了调用计算源指标并显示结果给用户的代码。
main.rs 的 第一部分 如下所示。我们将使用 structopt crate 来定义从用户那里接受的命令行输入的结构。
将以下内容添加到 Cargo.toml 文件中:
[dependencies]
structopt = "0.3.16"
第一部分 的代码列表如下所示:
src/main.rs (第一部分)
use std::path::PathBuf;
use structopt::StructOpt;
mod srcstats;
use srcstats::get_summary_src_stats;
mod errors;
use errors::StatsError;
#[derive(Debug, StructOpt)]
#[structopt(
name = "rstat",
about = "This is a tool to generate statistics on Rust
projects"
)]
struct Opt {
#[structopt(name = "source directory",
parse(from_os_str))]
in_dir: PathBuf,
#[structopt(name = "mode", short)]
mode: String,
}
在显示的代码第一部分中,定义了一个数据结构 Opt,它包含两个字段 – in_dir,表示输入文件夹的路径(要计算源指标),以及一个字段 mode。在我们的示例中,mode 的值是 src,表示我们想要计算源代码指标。在未来,可以添加额外的模式(例如,object 模式,用于计算可执行文件和库对象文件的大小等指标)。
在此代码的 第二部分 中,我们从用户的命令行参数中读取源文件夹,并从 srcstats 模块调用 get_summary_src_stats() 方法,我们在前面的子节中已讨论过。然后,该方法返回的指标将在终端中显示给用户。代码列表的 第二部分 如下所示:
src/main.rs
main 函数的代码如下:
fn main() -> Result<(), StatsError> {
let opt = Opt::from_args();
let mode = &opt.mode[..];
match mode {
"src" => {
let stats = get_summary_src_stats(&opt.in_dir)?;
println!("Summary stats: {:?}", stats);
}
_ => println!("Sorry, no stats"),
}
Ok(())
}
第二部分展示了 main() 函数,它是我们 shell 命令的入口点。该函数接受并解析命令行参数,并调用 get_summary_src_stats() 函数,将用户指定的 源文件夹 作为函数参数传递。包含综合源代码指标的成果将打印到控制台。
使用以下命令构建和运行工具:
cargo run --release -- -m src <src-folder>
<source-folder> 是 Rust 项目或源文件的位置,-m 是需要指定的命令行标志。它将是 src,表示我们想要源代码指标。
如果你想运行当前项目的统计信息,可以使用以下命令:
cargo run --release -- -m src .
注意命令中的点(.),它表示我们想要在当前项目文件夹中运行该命令。
你将在终端上看到源代码指标。
作为练习,你可以扩展这个 shell 命令以生成 Rust 项目生成的二进制文件的指标。要调用此选项,允许用户指定 –m 标志为 bin。
这就完成了关于开发 shell 命令的章节,它展示了在 Rust 中进行的文件和目录操作。
摘要
在本章中,我们回顾了操作系统级别的文件管理基础知识,以及与文件操作相关的主要系统调用。然后我们学习了如何使用 Rust 标准库打开和关闭文件,向文件读写数据,查询文件元数据,以及处理链接。在文件操作之后,我们学习了如何在 Rust 中进行目录和路径操作。在第三部分,我们看到了如何使用 Rust 创建硬链接和软(符号)链接,以及如何查询 symlinks。
然后,我们开发了一个 shell 命令,用于计算目录树中 Rust 源文件的源代码度量。这个项目展示了如何通过实际示例在 Rust 中执行各种文件和目录操作,并加强了 Rust 标准库在文件 I/O 操作中的概念。
继续探讨 I/O 主题,在下一章中,我们将学习终端 I/O 的基础知识以及 Rust 提供的用于处理伪终端的功能。
第七章:第七章: 在 Rust 中实现终端 I/O
在上一章中,我们探讨了如何处理文件和目录。我们还构建了一个 Rust 中的 shell 命令,用于生成项目目录中 Rust 源代码的汇总源代码度量。
在本章中,我们将探讨如何在 Rust 中构建基于终端的应用程序。终端应用程序是许多软件程序的重要组成部分,包括游戏、文本编辑器和终端模拟器。对于开发这些类型的程序,了解如何构建基于自定义终端界面的应用程序非常有帮助。这是本章的重点。
对于本章,我们将回顾终端的基本工作原理,然后探讨如何在终端上执行各种类型的操作,例如设置颜色和样式,执行光标操作(如清除和定位),以及处理键盘和鼠标输入。
我们将按照以下顺序介绍这些主题:
-
介绍终端 I/O 基础知识
-
与终端 UI(大小、颜色、样式)和光标一起工作
-
处理键盘输入和滚动
-
处理鼠标输入
本章的大部分内容将致力于通过一个实际示例来解释这些概念。我们将构建一个迷你文本查看器,以展示与终端一起工作的关键概念。该文本查看器将能够从磁盘加载文件并在终端界面上显示其内容。它还将允许用户使用键盘上的各种箭头键滚动内容,并在页眉和页脚栏上显示信息。
技术要求
本章中代码的 Git 仓库可以在github.com/PacktPublishing/Practical-System-Programming-for-Rust-Developers/tree/master/Chapter07/tui找到。
对于在 Windows 平台上工作的开发者,需要为本章安装一个虚拟机,因为用于终端管理的第三方 crate 不支持 Windows 平台(在撰写本书时)。建议安装一个如 VirtualBox 或等效的运行 Linux 的虚拟机来处理本章中的代码。安装 VirtualBox 的说明可以在www.virtualbox.org找到。
对于与终端一起工作,Rust 提供了几个功能来读取按键,并控制进程的标准输入和标准输出。当用户在命令行中键入字符时,当用户按下Enter键时,生成的字节对程序可用。这对于多种类型的程序都很有用。但对于某些类型的程序,如游戏或文本编辑器,它们需要更精细的控制,程序必须处理用户键入的每个字符,这也就是所谓的原始模式。有几个第三方库可以轻松处理原始模式。在本章中,我们将使用这样一个库,Termion。
介绍终端 I/O 基础
在本节中,我们将介绍终端的关键特性,概述 Termion 库,并定义本项目我们将要构建的范围。
让我们先看看终端的一些基础知识。
终端的特性
终端是用户可以与之交互的设备。使用终端,用户可以获取命令行访问权限以与计算机的操作系统交互。shell 通常充当控制程序,一方面驱动终端,另一方面与操作系统接口。
最初,UNIX 系统是通过连接到串行线的终端(也称为控制台)进行访问的。这些终端通常具有24 x 80行列的字符界面,或者在某些情况下具有基本的图形功能。为了在终端上执行操作,如清除屏幕或移动光标,使用了特定的转义序列。
终端可以以两种模式运行:
-
规范模式:在规范模式下,用户的输入按行处理,用户必须按下Enter键才能将字符发送到程序进行处理。
-
非规范或原始模式:在原始模式下,终端输入不会被收集成行,但程序可以读取用户键入的每个字符。
终端可以是物理设备或虚拟设备。如今的大多数终端都是伪终端,它们是一端连接到终端设备,另一端连接到驱动终端设备的程序的虚拟设备。伪终端帮助我们编写程序,使得一台主机上的用户可以通过网络通信在另一台主机上执行面向终端的程序。一个伪终端应用的例子是SSH,它允许用户通过网络登录到远程主机。
终端管理包括在终端屏幕上执行以下操作的能力:
-
颜色管理:在终端上设置各种前景和背景颜色,并将颜色重置为默认值。
-
样式管理:设置文本的样式为粗体、斜体、下划线等。
-
光标管理: 设置光标在特定位置,保存当前光标位置,显示和隐藏光标,以及其他特殊功能,如闪烁光标。
-
事件处理: 监听并响应键盘和鼠标事件。
-
屏幕处理: 从主屏幕切换到备用屏幕并清除屏幕。
-
原始模式: 将终端切换到原始模式。
在本章中,我们将结合使用 Rust 标准库和 Termion crate 来开发面向终端的应用程序。让我们在下一节中查看 Termion crate 的概述。
Termion crate
Termion crate 提供了上一节中列出的功能,同时也为用户提供易于使用的命令行界面。我们将在本章中使用许多这些功能。
为什么使用外部 crate 进行终端管理?
虽然在技术上可以使用 Rust 标准库在字节级别工作,但这很麻烦。像 Termion 这样的外部 crate 帮助我们将单个字节分组为按键,并实现许多常用的终端管理功能,这使得我们可以专注于更高层次的、面向用户的功能。
让我们讨论 Termion crate 的一些终端管理功能。该 crate 的官方文档可以在docs.rs/termion/找到。
Termion crate 具有以下关键模块:
-
cursor: 用于移动光标 -
event: 用于处理键盘和鼠标事件 -
raw: 用于将终端切换到原始模式 -
style: 用于设置文本的各种样式 -
clear: 用于清除整个屏幕或单独的行 -
color: 用于设置文本的各种颜色 -
input: 用于处理高级用户输入 -
scroll: 用于在屏幕上滚动
要包含 Termion crate,开始一个新的项目并在cargo.toml中添加以下条目:
[dependencies]
termion = "1.5.5"
这里通过代码片段展示了 Termion 的一些使用示例:
-
要获取终端大小,请使用以下命令:
termion::terminal_size() -
要设置前景颜色,请使用以下命令:
println!("{}", color::Fg(color::Blue)); -
要设置背景颜色然后重置背景颜色到原始状态,请使用以下命令:
println!( "{}Background{} ", color::Bg(color::Cyan), color::Bg(color::Reset) ); -
要设置粗体样式,请使用以下命令:
println!( "{}You can see me in bold?", style::Bold ); -
要将光标设置到特定位置,请使用以下命令:
termion::cursor::Goto(5, 10) -
要清除屏幕,请使用以下命令:
print!("{}", termion::clear::All);
我们将在接下来的章节中通过实际示例使用这些终端管理功能。现在,让我们定义本章将要构建的内容。
我们将构建什么?
我们将开发一个迷你文本查看器应用程序。此应用程序提供终端文本界面,从目录位置加载文档并查看文档。用户可以使用键盘键滚动文档。我们将逐步构建这个项目,通过多次代码迭代。
图 7.1 展示了我们将在本章中构建的屏幕布局:

图 7.1 – 文本查看器屏幕布局
文本查看器的终端界面中有三个组件:
-
标题栏:包含文本编辑器的标题。
-
文本区域:包含要显示的文本行。
-
页脚栏:显示光标位置、文件中的文本行数以及正在显示的文件名。
文本查看器将允许用户执行以下操作:
-
用户可以通过命令行参数提供一个文件名来显示。这应该是一个已存在的有效文件名。如果文件不存在,程序将显示错误消息并退出。
-
文本查看器将加载文件内容并在终端上显示它们。如果文件中的行数超过终端高度,程序将允许用户滚动文档,并重新绘制下一组行。
-
用户可以使用上、下、左、右键在终端中滚动。
-
用户可以按Ctrl + Q退出文本查看器。
一个流行的文本查看器会有更多功能,但这个核心范围为我们提供了足够的机会来学习如何在 Rust 中开发面向终端的应用程序。
在本节中,我们学习了终端是什么以及它们支持哪些功能。我们还概述了如何使用 Termion crate,并定义了在本章项目中我们将构建的内容。在下一节中,我们将开发文本查看器的第一个迭代版本。
与终端 UI(大小、颜色、样式)和光标一起工作
在本节中,我们将构建文本查看器的第一个迭代版本。在本节结束时,我们将有一个程序,它将从命令行接受文件名,显示其内容,并显示标题栏和页脚栏。我们将使用 Termion crate 来设置颜色和样式,获取终端大小,将光标定位在特定坐标,并清除屏幕。
本节中的代码组织如下:
-
编写数据结构和
main()函数 -
初始化文本查看器和获取终端大小
-
显示文档并设置终端颜色、样式和光标位置
-
退出文本查看器
让我们从数据结构和文本查看器的main()函数开始
编写数据结构和main()函数
在本节中,我们将定义在内存中表示文本查看器所需的数据结构。我们还将编写main()函数,该函数协调和调用各种其他函数:
-
创建一个新项目并使用以下命令切换到目录:
tui stands for text-viewer1.rs under src/bin. -
将以下内容添加到
cargo.toml中:[dependencies] termion = "1.5.5" -
让我们先从标准库和 Termion crate 中导入所需的模块:
use std::env::args; use std::fs; use std::io::{stdin, stdout, Write}; use termion::event::Key; use termion::input::TermRead; use termion::raw::IntoRawMode; use termion::{color, style}; -
让我们接下来定义表示文本查看器的数据结构:
struct Doc { lines: Vec<String>, } #[derive(Debug)] struct Coordinates { pub x: usize, pub y: usize, } struct TextViewer { doc: Doc, doc_length: usize, cur_pos: Coordinates, terminal_size: Coordinates, file_name: String, }此代码显示了为文本查看器定义的三个数据结构:
在查看器中显示的文档定义为
Doc结构体,它是一个字符串向量。为了存储光标位置 x 和 y 坐标以及记录当前终端的大小(字符的总行数和列数),我们定义了一个
Coordinates结构体。TextViewer结构体是表示文本查看器的主要数据结构。正在查看的文件中的行数被捕获在doc_length字段中。要在查看器中显示的文件名记录在file_name字段中。 -
现在我们来定义
main()函数,它是文本查看器应用程序的入口点:fn main() { //Get arguments from command line let args: Vec<String> = args().collect(); if args.len() < 2 { println!("Please provide file name as argument"); std::process::exit(0); } //Check if file exists. If not, print error // message and exit process if !std::path::Path::new(&args[1]).exists() { println!("File does not exist"); std::process::exit(0); } // Open file & load into struct println!("{}", termion::cursor::Show); // Initialize viewer let mut viewer = TextViewer::init(&args[1]); viewer.show_document(); viewer.run(); }main()函数接受一个文件名作为命令行参数,如果文件不存在则退出程序。此外,如果没有提供文件名作为命令行参数,它将显示错误消息并退出程序。 -
如果找到文件,
main()函数执行以下操作:它首先在
TextViewer结构体上调用init()方法来初始化变量。然后,它调用
show_document()方法来在终端屏幕上显示文件内容。最后,调用
run()方法,该方法等待用户输入到进程。如果用户按下 Ctrl + Q,程序将退出。 -
我们现在将编写三个方法签名 –
init()、show_document()和run()。这三个方法应该添加到TextViewer结构体的impl块中,如下所示:impl TextViewer { fn init(file_name: &str) -> Self { //... } fn show_document(&mut self) { // ... } fn run(&mut self) { // ... } }
到目前为止,我们已经定义了数据结构并编写了带有其他函数占位符的 main() 函数。在下一节中,我们将编写初始化文本查看器的函数。
初始化文本查看器和获取终端大小
当用户使用文档名启动文本查看器时,我们必须用一些信息初始化文本查看器并执行启动任务。这就是 init() 方法的用途。
下面是 init() 方法的完整代码:
fn init(file_name: &str) -> Self {
let mut doc_file = Doc { lines: vec![] }; <1>
let file_handle = fs::read_to_string(file_name)
.unwrap(); <2>
for doc_line in file_handle.lines() { <3>
doc_file.lines.push(doc_line.to_string());
}
let mut doc_length = file_handle.lines().count(); <4>
let size = termion::terminal_size().unwrap(); <5>
Self { <6>
doc: doc_file,
cur_pos: Coordinates {
x: 1,
y: doc_length,
},
doc_length: doc_length,
terminal_size: Coordinates {
x: size.0 as usize,
y: size.1 as usize,
},
file_name: file_name.into(),
}
}
init() 方法中的代码注释在此处描述:
-
初始化用于存储文件内容的缓冲区。
-
将文件内容作为字符串读取。
-
从文件中读取每一行并将其存储在
Doc缓冲区中。 -
使用文件中的行数初始化
doc_length变量。 -
使用
termion包来获取终端大小。 -
创建一个新的
TextViewer类型的结构体,并在init()方法中返回它。
我们已经编写了文本查看器的初始化代码。接下来,我们将编写在终端屏幕上显示文档内容以及显示页眉和页脚的代码。
显示文档并设置终端颜色、样式和光标位置
我们之前看到了我们想要构建的文本查看器布局。文本查看器屏幕布局有三个主要部分 – 页眉、文档区域和页脚。在本节中,我们将编写显示内容的主体函数和支持函数,按照定义的屏幕布局。
让我们看看 show_document() 方法:
src/bin/text-viewer1.rs
fn show_document(&mut self) {
let pos = &self.cur_pos;
let (old_x, old_y) = (pos.x, pos.y);
print!("{}{}", termion::clear::All, termion::cursor::Goto(1, 1));
println!(
"{}{}Welcome to Super text viewer\r{}",
color::Bg(color::Black),
color::Fg(color::White),
style::Reset
);
for line in 0..self.doc_length {
println!("{}\r", self.doc.lines[line as usize]);
}
println!(
"{}",
termion::cursor::Goto(0, (self.terminal_size.y - 2) as u16),
);
println!(
"{}{} line-count={} Filename: {}{}",
color::Fg(color::Red),
style::Bold,
self.doc_length,
self.file_name,
style::Reset
);
self.set_pos(old_x, old_y);
}
这里描述了show_document()方法的代码注释:
-
将光标当前的x和y坐标存储在临时变量中。这将在后续步骤中用于恢复光标位置。
-
使用 Termion crate,清除整个屏幕并将光标移动到屏幕的第 1 行第 1 列。
-
打印文本查看器的标题栏。使用黑色背景和白色前景色打印文本。
-
将内部文档缓冲区中的每一行显示到终端屏幕上。
-
将光标移动到屏幕底部(使用终端大小y坐标)以打印页脚。
-
以红色和粗体样式打印页脚文本。在页脚中打印文档的行数和文件名。
-
将光标重置到原始位置(在步骤 1中保存到临时变量中)。
让我们看看show_document()方法使用的set_pos()辅助方法:
src/bin/text-viewer1.rs
fn set_pos(&mut self, x: usize, y: usize) {
self.cur_pos.x = x;
self.cur_pos.y = y;
println!(
"{}",
termion::cursor::Goto(self.cur_pos.x as u16,
(self.cur_pos.y) as u16)
);
}
这个辅助方法同步内部光标跟踪字段(TextViewer结构体的cur_pos字段)和屏幕上的光标位置。
现在我们有了初始化文本查看器和在屏幕上显示文档的代码。有了这个,用户就可以在文本查看器中打开文档并查看其内容。但用户如何退出文本查看器?我们将在下一节中找到答案。
退出文本查看器
假设Ctrl + Q的键组合可以让用户退出文本查看器程序。我们如何实现这段代码?
要实现这一点,我们需要一种监听用户按键的方法,当按下特定的键组合时,程序应该退出。如前所述,我们需要将终端设置为原始操作模式,其中每个字符都可供程序评估,而不是等待用户按下Enter键。一旦我们获取了原始字符,其余的操作就相对简单。让我们在impl TextViewer块中的run()方法中编写代码来完成这个操作,如下所示:
src/bin/text-viewer1.rs
fn run(&mut self) {
let mut stdout = stdout().into_raw_mode().unwrap();
let stdin = stdin();
for c in stdin.keys() {
match c.unwrap() {
Key::Ctrl('q') => {
break;
}
_ => {}
}
stdout.flush().unwrap();
}
}
在下面的代码示例中,我们使用stdin.keys()方法在循环中监听用户输入。stdout()用于在终端显示文本。当按下Ctrl + Q时,程序退出。
现在我们可以使用以下命令运行程序:
cargo run --bin text-viewer1 <file-name-with-full-path>
由于我们尚未实现滚动,请将包含 24 行或更少内容的文件名传递给程序(这在行数方面通常是标准终端的默认高度)。您将看到文本查看器打开,并且标题栏、页脚栏和文件内容被打印到终端。按Ctrl + Q退出。请注意,您必须指定完整的文件路径作为命令行参数。
在本节中,我们学习了如何使用 Termion crate 获取终端大小、设置前景和背景颜色以及应用粗体样式。我们还学习了如何在指定坐标上定位屏幕上的光标,以及如何清除屏幕。
在下一节中,我们将探讨在文本编辑器中处理用户在文档内导航的按键以及如何实现滚动。
处理键盘输入和滚动
在上一节中,我们构建了我们面向文本的终端应用程序的第一个迭代版本。我们能够显示少于 24 行的文件,并看到包含一些信息的页眉和页脚栏。最后,我们能够通过Ctrl + Q退出程序。
在本节中,我们将向文本查看器添加以下功能:
-
提供显示任何大小文件的能力。
-
提供用户使用箭头键滚动文档的能力。
-
将光标位置坐标添加到页脚栏。
让我们从创建代码的新版本开始。
将原始代码复制到新文件中,如下所示:
cp src/bin/text-viewer1.rs src/bin/text-viewer2.rs
本节分为三个部分。首先,我们将实现响应用户以下按键的逻辑:上、下、左、右和退格。接下来,我们将实现更新内部数据结构中光标位置的功能,并同时更新屏幕上的光标位置。最后,我们将允许在多页文档中滚动。
我们将从处理用户按键开始。
监听用户的按键
让我们修改run()方法以响应用户输入并滚动文档。我们还想记录并显示当前光标位置在页脚栏中。代码如下所示:
src/bin/text-viewer2.rs
fn run(&mut self) {
let mut stdout = stdout().into_raw_mode().unwrap();
let stdin = stdin();
for c in stdin.keys() {
match c.unwrap() {
Key::Ctrl('q') => {
break;
}
Key::Left => {
self.dec_x();
self.show_document();
}
Key::Right => {
self.inc_x();
self.show_document();
}
Key::Up => {
self.dec_y();
self.show_document();
}
Key::Down => {
self.inc_y();
self.show_document();
}
Key::Backspace => {
self.dec_x();
}
_ => {}
}
stdout.flush().unwrap();
}
}
粗体行显示了从早期版本中run()方法的更改。在这段代码中,我们正在监听上、下、左、右和退格键。对于这些按键中的任何一种,我们都会使用以下方法之一:inc_x()、inc_y()、dec_x()或dec_y()适当地增加x或y坐标。例如,如果按下右箭头,光标位置的x坐标将使用inc_x()方法增加,如果按下下箭头,则仅使用inc_y()方法增加y坐标。坐标的更改记录在内部数据结构(TextViewer结构体的cur_pos字段)中。此外,光标在屏幕上重新定位。所有这些操作都是通过inc_x()、inc_y()、dec_x()和dec_y()方法实现的。
更新光标位置后,屏幕将完全刷新并重新绘制。
让我们看看实现四个更新光标坐标并重新定位屏幕上光标的方法。
定位终端光标
让我们编写inc_x()、inc_y()、dec_x()和dec_y()方法的代码。这些方法应作为impl TextViewer代码块的一部分添加,就像其他方法一样:
src/bin/text-viewer2.rs
fn inc_x(&mut self) {
if self.cur_pos.x < self.terminal_size.x {
self.cur_pos.x += 1;
}
println!(
"{}",
termion::cursor::Goto(self.cur_pos.x as u16,
self.cur_pos.y as u16)
);
}
fn dec_x(&mut self) {
if self.cur_pos.x > 1 {
self.cur_pos.x -= 1;
}
println!(
"{}",
termion::cursor::Goto(self.cur_pos.x as u16,
self.cur_pos.y as u16)
);
}
fn inc_y(&mut self) {
if self.cur_pos.y < self.doc_length {
self.cur_pos.y += 1;
}
println!(
"{}",
termion::cursor::Goto(self.cur_pos.x as u16,
self.cur_pos.y as u16)
);
}
fn dec_y(&mut self) {
if self.cur_pos.y > 1 {
self.cur_pos.y -= 1;
}
println!(
"{}",
termion::cursor::Goto(self.cur_pos.x as u16,
self.cur_pos.y as u16)
);
}
所有这四种方法的结构相似,每个方法只执行两个步骤:
-
根据按键,相应的坐标(x或y)将增加或减少,并记录在
cur_pos内部变量中。 -
光标被重新定位到屏幕上的新坐标。
我们现在有一个机制,当用户按下上、下、左、右或退格键时更新光标坐标。但这还不够。光标应该被重新定位到屏幕上的最新光标坐标。为此,我们将不得不更新show_document()方法,我们将在下一节中这样做。
在终端上启用滚动
到目前为止,我们已经实现了监听用户按键并重新定位光标在屏幕上的代码。现在,让我们将注意力转向代码中的另一个主要问题。如果我们加载一个行数少于终端高度的文档,代码运行良好。但考虑一种情况,即终端可以显示 24 行字符,而要显示在文本查看器上的文档有 50 行。我们的代码无法处理这种情况。我们将在本节中修复它。
要显示比屏幕尺寸允许的更多行,仅仅重新定位光标是不够的。我们必须重新绘制屏幕,以便根据光标位置在终端屏幕上显示文档的一部分。让我们看看需要修改show_document()方法以启用滚动的修改。在show_document()方法中查找以下代码行:
for line in 0..self.doc_length {
println!("{}\r", self.doc.lines[line as
usize]);
}
将前面的代码替换为以下代码:
src/bin/text-viewer2.rs
if self.doc_length < self.terminal_size.y { <1>
for line in 0..self.doc_length {
println!("{}\r", self.doc.lines[line as
usize]);
}
} else {
if pos.y <= self.terminal_size.y { <2>
for line in 0..self.terminal_size.y - 3 {
println!("{}\r", self.doc.lines[line as
usize]);
}
} else {
for line in pos.y - (self.terminal_size.y –
3)..pos.y {
println!("{}\r", self.doc.lines[line as
usize]);
}
}
}
show_document()方法代码片段中的代码注释在此处描述:
-
首先,检查输入文档中的行数是否少于终端高度。如果是这样,则在终端屏幕上显示输入文档中的所有行。
-
如果输入文档中的行数大于终端高度,我们必须分部分显示文档。最初,屏幕上显示的是与终端高度能容纳的行数相对应的文档的第一组行。例如,如果我们为文本显示区域分配 21 行,那么只要光标位于这些行内,就会显示原始的行集。如果用户继续向下滚动,则下一组行会在屏幕上显示。
让我们用以下命令运行程序:
cargo run –-bin text-viewer2 <file-name-with-full-path>
你可以尝试两种类型的文件输入:
-
一个行数少于终端高度的文件
-
一个行数多于终端高度的文件
你可以使用上、下、左和右箭头在文档中滚动并查看内容。你还会在页脚栏中看到当前光标位置(x和y坐标)。按Ctrl + Q退出。
这就完成了本章的文本查看器项目。你已经构建了一个功能性的文本查看器,可以显示任何大小的文件,并且可以使用箭头键滚动其内容。你还可以在页脚栏中查看光标当前位置、文件名和行数。
关于文本查看器的说明
注意,我们实现的是一个少于 200 行代码的迷你文本查看器版本。虽然它展示了关键功能,但你可以通过实现额外的功能和边缘情况来增强应用程序并提高其可用性。此外,这个查看器也可以转换成一个完整的文本编辑器。这些留给读者作为练习。
在本节中,我们已经完成了文本查看器项目的实现。文本查看器是一个经典的命令行应用程序,它没有需要鼠标输入的 GUI 界面。但是,学习如何处理鼠标事件对于开发基于 GUI 的终端界面非常重要。我们将在下一节中学习如何做到这一点。
处理鼠标输入
与键盘事件一样,Termion crate 也支持监听鼠标事件、跟踪鼠标光标位置并在代码中对其做出反应的能力。让我们看看如何在这里实现这一点。
在 src/bin 下创建一个名为 mouse-events.rs 的新源文件。
这里是代码逻辑:
-
导入所需的模块。
-
在终端中启用鼠标支持。
-
清除屏幕。
-
创建一个对传入事件进行迭代的迭代器。
-
监听鼠标按下、释放和保持事件,并在终端屏幕上显示鼠标光标位置。
代码在每个这些点的片段中进行解释。
让我们首先看看模块导入:
-
我们正在导入
termioncrate 模块以切换到原始模式、检测光标位置和监听鼠标事件:use std::io::{self, Write}; use termion::cursor::{self, DetectCursorPos}; use termion::event::*; use termion::input::{MouseTerminal, TermRead}; use termion::raw::IntoRawMode;在
main()函数中,让我们按照以下方式启用鼠标支持:fn main() { let stdin = io::stdin(); let mut stdout = MouseTerminal::from(io::stdout(). into_raw_mode().unwrap()); // ...Other code not shown }为了确保终端屏幕上的先前文本不会干扰此程序,让我们按照以下方式清除屏幕:
writeln!( stdout, "{}{} Type q to exit.", termion::clear::All, termion::cursor::Goto(1, 1) ) .unwrap(); -
接下来,让我们创建一个对传入事件进行迭代的迭代器,并监听鼠标事件。在终端上显示鼠标光标的位置:
for c in stdin.events() { let evt = c.unwrap(); match evt { Event::Key(Key::Char('q')) => break, Event::Mouse(m) => match m { MouseEvent::Press(_, a, b) | MouseEvent::Release(a, b) | MouseEvent::Hold(a, b) => { write!(stdout, "{}", cursor::Goto(a, b)) .unwrap(); let (x, y) = stdout.cursor_pos ().unwrap(); write!( stdout, "{}{}Cursor is at: ({},{}){}", cursor::Goto(5, 5), termion::clear:: UntilNewline, x, y, cursor::Goto(a, b) ) .unwrap(); } }, _ => {} } stdout.flush().unwrap(); }在下面的代码中,我们正在监听键盘事件和鼠标事件。在键盘事件中,我们特别寻找 Q 键,用于退出程序。我们也在监听鼠标事件——按下、释放和保持。在这种情况下,我们将光标定位在指定的坐标上,并在终端屏幕上打印出坐标。
-
使用以下命令运行程序:
cargo run --bin mouse-events -
使用鼠标在屏幕上点击,你将看到光标位置坐标在终端屏幕上显示。按
q退出。
通过这种方式,我们结束了关于在终端上处理鼠标事件的章节。这也结束了使用 Rust 进行终端 I/O 管理的章节。
概述
在本章中,我们通过编写一个迷你文本查看器学习了终端管理的基础知识。我们学习了如何使用 Termion 库获取终端大小、设置前景和背景颜色以及设置样式。之后,我们学习了如何在终端上处理光标,包括清除屏幕、将光标定位在特定的坐标集上以及跟踪当前光标位置。
我们学习了如何监听用户输入并跟踪键盘箭头键以进行滚动操作,包括左、右、上和下。我们编写了代码,在用户滚动文档时动态显示文档内容,同时考虑到终端大小的限制。作为练习,你可以改进文本查看器,还可以添加将文本查看器转换为完整编辑器的功能。
学习这些功能对于编写基于终端的游戏、编辑和查看应用程序以及终端图形界面,以及提供基于终端的仪表板非常重要。
在下一章中,我们将学习使用 Rust 进行进程管理的基础知识,包括启动和停止进程以及处理错误和信号。
第八章:第八章:与进程和信号一起工作
你知道当你将命令输入到电脑的终端界面时,这些命令是如何被执行的吗?这些命令是直接由操作系统执行的,还是有一个中间程序来处理它们?当你从命令行在前台运行一个程序并按下Ctrl + C时,谁在监听这个按键,程序又是如何被终止的?操作系统如何同时运行多个用户程序?程序和进程之间有什么区别?如果你对此感到好奇,那么请继续阅读。
在上一章中,我们学习了如何控制并改变用于与命令行应用程序中的用户交互的终端界面。
在本章中,我们将探讨进程,这是在系统编程中仅次于文件的第二大流行抽象。我们将学习进程是什么,它们与程序有何不同,它们是如何启动和终止的,以及如何控制进程环境。如果你想要编写如 shell 之类的系统程序,并希望对进程的生命周期有程序控制,这项技能是必要的。
我们还将通过使用Rust 标准库构建一个基本的 shell 程序作为迷你项目。这将让你对流行的 shell,如Bourne、Bash和zsh在底层的工作方式有一个实际的理解,并教你如何在 Rust 中构建你自己的定制 shell 环境的基础知识。
我们将按照以下顺序介绍这些主题:
-
理解 Linux 进程概念和系统调用
-
使用 Rust 启动新进程
-
处理子进程的 I/O 和环境变量
-
处理恐慌、错误和信号
-
在 Rust 中编写基本 shell 程序(项目)
在本章结束时,你将学会如何以独立进程的方式编程启动新程序,如何设置和调整环境变量,如何处理错误,响应外部信号,以及优雅地退出进程。你将学习如何使用 Rust 标准库与操作系统进行交互以执行这些任务。这让你作为系统程序员,对这一重要系统资源拥有极大的控制权;即,进程。
技术要求
使用以下命令验证rustc和cargo是否已正确安装:
rustc –version
cargo --version
本章中代码的 Git 仓库可以在github.com/PacktPublishing/Practical-System-Programming-for-Rust-Developers/tree/master/Chapter08找到。
注意
信号处理部分需要 Unix-like 的开发环境(Unix、Linux或macOS),因为 Microsoft Windows 没有直接的概念。如果您使用 Windows,请下载一个虚拟机,例如 Oracle VirtualBox(www.virtualbox.org/wiki/Downloads),或者使用Docker容器来启动一个Unix/Linux镜像以继续操作。
理解 Linux 进程概念和系统调用
在本节中,我们将介绍进程管理的 fundamentals,并了解为什么它在系统编程中很重要。我们将查看进程生命周期,包括创建新进程、设置其环境参数、处理其标准输入和输出以及终止进程。
本节首先理解程序和进程之间的区别。然后,我们将深入了解 Linux 进程的基本要素。最后,我们将概述如何使用 Rust 标准库封装的系统调用来管理进程生命周期。
程序如何变成进程?
进程是一个正在运行的程序。更准确地说,它是一个正在运行的程序的实例。您可以在同一时间运行单个程序的多个实例,例如从多个终端窗口启动文本编辑器。每个这样的运行程序实例都是一个进程。
即使一个进程是运行(或执行)程序的结果,这两个概念是不同的。程序以两种形式存在——源代码和机器可执行指令(目标代码或可执行文件)。通常使用编译器(和链接器)将程序的源代码转换为机器可执行指令。
机器可执行指令包含操作系统如何将程序加载到内存中、初始化它和运行它的信息。指令包括以下内容:
-
可执行文件格式(例如,ELF是 Unix 系统中流行的可执行文件格式)。
-
CPU 将要执行的程序逻辑。
-
程序入口点的内存地址。
-
初始化程序变量和常数的某些数据。
-
关于共享库、函数和变量位置的信息。
当程序从命令行、脚本或图形用户界面启动时,以下步骤会发生:
-
操作系统(内核)为程序分配虚拟内存(这也被称为程序的内存布局)。我们在第五章,“Rust 中的内存管理”中看到了这一点,它说明了程序在栈、堆、文本和数据段方面的虚拟内存布局。
-
内核随后将程序指令加载到虚拟内存的文本段中。
-
内核在数据段中初始化程序变量。
-
内核触发 CPU 开始执行程序指令。
-
内核还向运行中的程序提供访问所需资源的权限,例如文件或额外的内存。
进程(运行中的程序)的内存布局在第 第五章,“内存管理”中进行了讨论。它在此处以 图 8.1 的形式重现,以供参考:

图 8.1 – 程序内存布局
我们已经看到了程序的内存布局。那么,什么是进程呢?
对于内核来说,一个进程是一个由以下内容组成的抽象:
-
虚拟内存,其中加载了程序指令和数据,这在 图 8.1 的程序内存布局中表示。
-
一组关于运行程序的元数据,例如进程标识符、与程序关联的系统资源(例如打开文件列表)、虚拟内存表以及关于程序的其他此类信息。特别重要的是进程 ID,它唯一地标识了一个运行程序的实例。
注意
内核本身是
init,被分配了一个 进程 ID 为 1。init进程仅在系统关闭时终止,并且不能被杀死。所有未来的进程都是由init进程或其子进程创建的。因此,程序指的是程序员创建的指令(在源代码或机器可执行格式中),而进程是使用系统资源并由内核控制的程序的运行实例。作为程序员,如果我们想控制一个运行中的程序,我们将需要使用适当的系统调用到内核。Rust 标准库将这些系统调用封装成整洁的 API,以便在 Rust 程序中使用,正如在第 第三章,“Rust 标准库简介”中讨论的那样。
我们已经看到了程序与进程之间的关系。接下来,让我们在下一节中讨论一下进程的特性的更多细节。
深入探讨 Linux 进程基础
在第三章,“系统编程的 Rust 标准库和关键 crate 简介”中,我们看到了系统调用是如何作为用户程序(进程)和内核(操作系统)之间的接口的。使用系统调用,用户程序可以管理和控制各种系统资源,如文件、内存、设备等。
在本节中,我们将探讨一个运行中的程序(父进程)如何通过系统调用管理另一个程序(子进程)的生命周期。回想一下,在 Linux 中,进程也被视为系统资源,就像文件或内存一样。本节的重点是了解一个进程如何管理和与另一个进程通信。
图 8.2 展示了与进程管理相关的关键任务集:

图 8.2 – 在 Rust 中处理进程
让我们回顾一下前面图中显示的过程管理任务。我们将看到非 Rust 用户程序(例如,C/C++)如何在 Linux 上进行进程管理,以及它与 Rust 的不同之处。
创建新进程
在使用 Unix/Linux 时,任何需要创建新进程的用户程序都必须使用系统调用(系统调用)请求内核执行。一个程序(我们可以称之为 fork() 系统调用。内核复制父进程并创建一个具有唯一 ID 的 子进程。子进程获得父进程的内存空间(堆、栈等)的精确副本。子进程也获得了与父进程相同的程序指令副本。
创建后,子进程可以选择将其进程内存空间中的不同程序加载并执行。这是通过 exec() 系列的 系统调用 实现的。
因此,基本上,Unix/Linux 中用于 创建新子进程 的 系统调用 与用于将 新程序 加载到子进程中并执行它的系统调用是不同的。然而,Rust 标准库为我们简化了这一点,并提供了一个统一的接口,这两个步骤可以在创建新子进程时结合在一起。我们将在下一节中看到这方面的示例。
让我们回到章节开头的问题:当你在一个终端的命令行中键入某些内容时,究竟发生了什么?
当你在命令行中键入程序可执行文件名来运行程序时,会发生两件事:
-
首先,使用
fork()系统调用创建一个新的进程。 -
然后,新程序的图像(即,程序可执行文件)被加载到内存中,并使用
exec()系列调用执行。当你在终端中键入命令时会发生什么?
终端(正如我们在上一章中看到的)为用户提供了一个与系统交互的接口。但必须有一些东西来解释这些命令并执行它们。这就是
find * | grep debug | wc -l当这个命令被输入到终端时,shell 程序会启动三个进程来执行这个命令管道。正是这个 shell 命令使系统调用内核创建新进程、加载这些命令并按顺序执行它们。然后 shell 返回执行结果并将其打印到标准输出。
检查子进程的状态
一旦内核创建了一个子进程,它会返回一个子 进程 ID。wait() 和 waitpid() 系统调用可以通过传递 子进程 ID 到调用中来检查子进程是否正在运行。这些调用有助于同步子进程的执行与父进程。Rust 系统库提供了等待子进程完成并检查其状态的调用。
使用进程间通信进行通信
进程可以通过信号、管道、套接字、消息队列、信号量和共享内存等机制相互通信,并与内核(记住内核也是一个进程)协调其活动。在 Rust 中,两个进程也可以通过管道、进程和消息队列等多种方式通信。但父进程和子进程之间基本形式的进程间通信(IPC)涉及stdin/stdout 管道。父进程可以向标准输入写入,并从子进程的标准输出读取。我们将在后面的章节中看到一个例子。
设置环境变量
每个进程都有自己的关联环境变量集。fork()和exec()系统调用允许从父进程传递和设置环境变量到子进程。这些环境变量的值存储在进程的虚拟内存区域中。Rust 标准库还允许父进程显式设置或重置子进程的环境变量。
终止进程
进程可以通过使用exit()系统调用或被信号(如用户按下Ctrl + C)或使用kill()系统调用杀死来终止自己。Rust 也有一个exit()调用用于此目的。Rust 还提供了其他终止进程的方法,我们将在后面的章节中探讨。
处理信号
信号用于将异步事件(如键盘中断)与进程通信。除了 SIGSTOP 和 SIGKILL 这两个信号外,进程可以选择忽略信号或决定以自己的方式响应它们。直接使用 Rust 标准库处理信号对开发者来说并不友好,因此我们可以使用外部 crate。我们将在后面的章节中使用这样一个 crate。
在本节中,我们看到了程序和进程之间的区别,深入探讨了 Linux 进程的一些特性,并概述了在 Rust 中我们可以做些什么来与进程交互。
在下一节中,我们将通过编写一些代码来亲身体验如何使用 Rust 创建、交互和终止进程。请注意,在接下来的几个章节中,只提供代码片段。为了执行代码,您需要创建一个新的 cargo 项目,并将显示在src/main.rs文件中的代码添加到适当的模块导入中。
使用 Rust 创建进程
在 Rust 标准库中,std::process是用于处理进程的模块。在本节中,我们将探讨如何使用 Rust 标准库来创建新进程、与子进程交互和终止当前进程。Rust 标准库内部使用相应的 Unix/Linux 系统调用来调用内核操作以管理进程。
让我们从启动新的子进程开始。
创建新的子进程
std::process::Command用于在指定路径启动程序或运行标准 shell 命令。可以使用构建器模式构造新进程的配置参数。让我们看一个简单的例子:
use std::process::Command;
fn main() {
Command::new("ls")
.spawn()
.expect("ls command failed to start");
}
显示的代码使用Command::new()方法创建一个用于执行的新命令,该命令以要运行的程序名称作为参数。spawn()方法创建一个新的子进程。
如果你运行此程序,你将看到当前目录中文件的列表。
这是使用 Rust 标准库将标准 Unix shell 命令或用户程序作为子进程的最简单方法。
如果你想要向 shell 命令传递参数怎么办?以下代码片段显示了传递参数到命令的示例:
use std::process::Command;
fn main() {
Command::new("ls")
.arg("-l")
.arg("-h")
.spawn()
.expect("ls command failed to start");
}
可以使用arg()方法向程序传递一个参数。这里我们想要运行ls –lh命令以以长格式显示可读的文件大小。我们必须使用arg()方法两次来传递两个标志。
或者,可以使用如这里所示的方法使用args()。请注意,在未来的代码片段中已经删除了std::process导入和main()函数声明以避免重复,但你在运行程序之前必须添加它们:
Command::new("ls")
.args(&["-l", "-h"]).spawn().unwrap();
让我们修改代码以列出相对于当前目录上一级的目录内容。
代码显示了通过args()方法配置的ls命令的两个参数。
接下来,让我们将子进程的当前目录设置为非默认值:
Command::new("ls")
.current_dir("..")
.args(&["-l", "-h"])
.spawn()
.expect("ls command failed to start");
在前面的代码中,我们正在生成一个进程来在目录上一级运行ls命令。
使用以下命令运行程序:
cargo run
你将看到父目录的列表显示。
到目前为止,我们已经使用spawn()创建了一个新的子进程。此方法返回子进程的句柄。
使用output()也可以用另一种方式生成一个新的进程。区别在于output()生成子进程并等待其终止。让我们看一个例子:
let output = Command::new("cat").arg("a.txt").output().
unwrap();
if !output.status.success() {
println!("Command executed with failing error code");
}
println!("printing: {}", String::from_utf8(output.stdout).
unwrap());
我们使用output()方法生成一个新的进程来打印出名为a.txt的文件内容。让我们使用以下命令创建此文件:
echo "Hello World" > a.txt
如果你运行程序,你将看到a.txt文件的内容打印到终端。请注意,我们正在打印子进程的标准输出句柄的内容,因为默认情况下cat命令的输出被定向到那里。我们将在本章后面学习如何与子进程的stdin和stdout进行更多操作。
现在我们将看看如何终止一个进程。
终止进程
我们已经看到了如何生成新的进程。那么,如何终止它们呢?为此,Rust 标准库提供了两种方法——abort()和exit()。
以下代码片段显示了abort()方法的使用:
use std::process;
fn main() {
println!("Going to abort process");
process::abort();
// This statement will not get executed
println!("Process aborted");
}
此代码终止了当前进程,并且最后的语句将不会打印出来。
还有一个类似于abort()的exit()方法,但它允许我们指定一个可供调用进程使用的退出代码。
进程返回错误代码有什么好处?子进程可能会因为各种错误而失败。当程序失败且子进程退出时,对于调用程序或用户来说,知道表示失败原因的错误代码将是有用的。0表示成功退出。其他错误代码表示各种条件,如数据错误、系统文件错误、I/O 错误等。错误代码是平台特定的,但大多数类 Unix 平台使用 8 位错误代码,允许错误值在 0 到 255 之间。Unix BSD 的错误代码示例可以在www.freebsd.org/cgi/man.cgi?query=sysexits&apropos=0&sektion=0&manpath=FreeBSD+11.2-stable&arch=default&format=html找到。
以下是一个示例,展示了使用exit()方法从进程返回错误代码:
use std::process;
fn main() {
println!("Going to exit process with error code 64");
process::exit(64);
// execution never gets here
println!("Process exited");
}
在你的终端命令行上运行此程序。要在类 Unix 系统上知道最后一个执行进程的退出代码,你可以在命令行上输入$?。请注意,此命令可能因平台而异。
abort()与exit()的比较
注意,abort()和exit()都不会清理并调用任何析构函数,所以如果你想以干净的方式关闭进程,这些方法应该在所有析构函数运行之后才调用。然而,操作系统将确保在进程终止时,与该进程关联的所有资源,如内存和文件描述符,将自动可用于其他进程的重新分配。
在本节中,我们看到了如何创建和终止进程。接下来,让我们看看如何在子进程创建后检查其执行状态。
检查子进程执行状态
如前所述,当我们启动一个新的进程时,我们也会指定在进程内要执行的程序或命令。通常,我们还会关心这个程序或命令是否已成功执行,以便采取适当的行动。
Rust 标准库提供了一个status()方法,让我们可以找出进程是否成功完成执行。以下是一些示例用法:
use std::process::Command;
fn main() {
let status = Command::new("cat")
.arg("non-existent-file.txt")
.status()
.expect("failed to execute cat");
if status.success() {
println!("Successful operation");
} else {
println!("Unsuccessful operation");
}
}
运行此程序,你将在终端看到打印出操作失败的消息。使用有效的文件名重新运行程序,你将看到成功消息被打印出来。
这部分内容到此结束。你学习了在单独的子进程中运行命令的不同方法,如何终止它们,以及如何获取它们执行的状态。
在下一节中,我们将探讨如何设置环境变量以及与子进程的 I/O 操作。
处理 I/O 和环境变量
在本节中,我们将探讨如何处理与子进程的 I/O,并学习如何为子进程设置和清除环境变量。
我们为什么需要这样做?
以一个负载均衡器为例,该负载均衡器负责根据传入的请求启动新的工作进程(Unix 进程)。假设新的工作进程从环境变量中读取配置参数以执行其任务。那么,负载均衡器进程就需要启动工作进程并设置其环境变量。同样,可能还有另一种情况,父进程想要读取子进程的标准输出或标准错误并将其路由到日志文件。让我们了解如何在 Rust 中执行此类活动。我们将从处理子进程的 I/O 开始。
处理子进程的 I/O
标准输入(stdin)、标准输出(stdout)和标准错误(stderr)是允许进程与周围环境交互的抽象。
例如,当许多用户进程同时运行时,当用户在终端上输入按键时,内核会将按键传递给正确进程的标准输入。同样,一个 Rust 程序(作为在 shell 中运行的进程)可以向其标准输出打印字符,这些字符随后被 shell 程序读取并传递到终端屏幕供用户查看。让我们学习如何使用 Rust 标准库来处理标准输入和标准输出。
std::process::Stdio上的piped()方法允许子进程通过管道(这是类 Unix 系统中的 IPC 机制)与其父进程通信。
我们首先看看如何从父进程与子进程的标准输出句柄进行通信:
use std::io::prelude::*;
use std::process::{Command, Stdio};
fn main() {
// Spawn the `ps` command
let process = match Command::new("ps").
stdout(Stdio::piped()).spawn() {
Err(err) => panic!("couldn't spawn ps: {}", err),
Ok(process) => process,
};
let mut ps_output = String::new();
match process.stdout.unwrap().read_to_string(&mut
ps_output) {
Err(err) => panic!("couldn't read ps stdout: {}",
err),
Ok(_) => print!("ps output from child process
is:\n{}", ps_output),
}
}
在前面的代码片段中,我们首先创建一个新的子进程来运行ps命令以显示当前正在运行的所有进程。默认情况下,输出会被发送到子进程的stdout。
为了从父进程获取对子进程stdout的访问权限,我们使用stdio::piped()方法创建一个 Unix 管道。process变量是子进程的句柄,process.stdout是子进程标准输出的句柄。父进程可以从这个句柄读取,并将其内容打印到自己的stdout(即父进程的stdout)。这就是父进程如何读取子进程的输出。
现在我们来写一些代码,从父进程向子进程的标准输入发送一些字节:
let process = match Command::new("rev")
.stdin(Stdio::piped()) <1>
.stdout(Stdio::piped()) <2>
.spawn()
{
Err(err) => panic!("couldn't spawn rev: {}", err),
Ok(process) => process,
};
match process.stdin.unwrap().write_all
("palindrome".as_bytes()) {
Err(why) => panic!("couldn't write to stdin: {}",
why),
Ok(_) => println!("sent text to rev command"),
} <3>
let mut child_output = String::new();
match process.stdout.unwrap().read_to_string(&mut
child_output) {
Err(err) => panic!("couldn't read stdout: {}", err),
Ok(_) => print!("Output from child process is:\n{}",
child_output),
} <4>
以下代码中编号注释的描述在此提供:
-
在父进程和子进程的标准输入之间注册一个管道连接。
-
在父进程和子进程的标准输出之间注册一个管道连接。
-
向子进程的标准输入写入字节。
-
从子进程的标准输出读取并打印到终端屏幕。
子进程中还有一些其他方法可用。id()方法提供子进程的进程 ID,kill()方法终止子进程,stderr方法提供对子进程的标准错误的句柄,而wait()方法使父进程等待直到子进程完全退出。
我们已经看到了如何处理子进程的 I/O。现在让我们学习如何处理环境变量。
设置子进程的环境
让我们看看如何为子进程设置环境变量。以下示例展示了如何为子进程设置路径环境变量:
use std::process::Command;
fn main() {
Command::new("env")
.env("MY_PATH", "/tmp")
.spawn()
.expect("Command failed to execute");
}
std::process::Command上的env()方法允许父进程为正在创建的子进程设置环境变量。运行程序并使用以下命令进行测试:
cargo run | grep MY_PATH
你将看到程序中设置的MY_PATH环境变量的值。
要设置多个环境变量,可以使用envs()命令。
可以通过使用env_clear()方法清除子进程的环境变量,如下所示:
Command::new("env")
.env_clear()
.spawn()
.expect("Command failed to execute");
使用cargo run运行程序,你会看到对于env命令没有任何输出。通过注释掉.env_clear()语句重新运行程序,你将发现env值被打印到终端。
要删除特定的环境变量,可以使用env_remove()方法。
通过这种方式,我们结束了本节。我们看到了如何与子进程的标准输入和标准输出交互以及如何设置/重置环境变量。在下一节中,我们将学习如何处理子进程中的错误和信号。
处理恐慌、错误和信号
进程可能由于各种错误条件而失败。这些必须在受控的方式下处理。也可能存在我们想要根据外部输入(如用户按下Ctrl + C)终止进程的情况。我们如何处理这种情况是本节的重点。
注意
在进程由于错误而退出的情况下,操作系统本身会执行一些清理工作,例如释放内存、关闭网络连接以及释放与进程关联的任何文件句柄。但有时,你可能希望程序驱动的控制来处理这些情况。
进程执行失败可以大致分为两种类型 – 不可恢复的错误和可恢复的错误。当进程遇到不可恢复的错误时,有时别无选择,只能终止进程。让我们看看如何做到这一点。
终止当前进程
我们在“使用 Rust 创建进程”部分看到了如何终止和退出一个进程。process::Command上的abort()和exit()方法可以用于此目的。
在某些情况下,我们故意允许程序在某些条件下失败而不进行处理,主要是在不可恢复错误的情况下。std::panic宏允许我们使当前线程崩溃。这意味着程序会立即终止并向调用者提供反馈。但与exit()或abort()方法不同,它会回滚当前线程的堆栈并调用所有析构函数。以下是panic!宏使用的一个示例:
use std::process::{Command, Stdio};
fn main() {
let _child_process = match Command::new("invalid-command")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
{
Err(err) => panic!("Unable to spawn child process:
{}", err),
Ok(new_process_handle) => {
println!("Successfully spawned child process");
new_process_handle
}
};
}
使用cargo run运行程序,你将看到从panic!宏打印出的错误消息。还有一个可以在panic宏执行标准清理之前注册的自定义钩子。以下是相同的示例,这次带有自定义的panic钩子:
use std::panic;
use std::process::{Stdio,Command};
fn main() {
panic::set_hook(Box::new(|_| {
println!(" This is an example of custom panic
hook, which is invoked on thread panic, but
before the panic run-time is invoked")
}));
let _child_process = match Command::new("invalid-command")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
{
Err(err) => panic!("Normal panic message {}", err),
Ok(new_process_handle) => new_process_handle,
};
}
运行此程序时,你会看到显示自定义错误钩子消息,因为我们提供了一个无效的命令来作为子进程启动。
注意,panic!仅应用于不可恢复的错误。例如,如果一个子进程尝试打开一个不存在的文件,这可以通过使用可恢复错误机制(如Result枚举)来处理。使用Result的优势在于程序可以返回到其原始状态,并且失败的操作可以重试。如果使用panic!,程序会突然终止,并且无法恢复程序的原始状态。但有些情况下,panic!可能是合适的,例如当系统中的进程耗尽内存时。
接下来,让我们看看进程控制的另一个方面——信号处理。
信号处理
在类 Unix 系统中,操作系统可以向进程发送信号。请注意,Windows 操作系统没有信号。进程可以以它认为合适的方式处理信号,甚至可以忽略信号。操作系统为处理各种信号提供了默认设置。例如,当你从 shell 向一个进程发出 kill 命令时,会生成SIGTERM信号。默认情况下,程序在接收到这个信号时会终止,并且不需要在 Rust 中编写特殊代码来处理这个信号。同样,当用户按下Ctrl + C时,会接收到SIGINT信号。但 Rust 程序可以选择以自己的方式处理这些信号。
然而,由于各种原因,正确处理 Unix 信号是困难的。例如,信号可以在任何时候发生,并且处理信号的线程无法继续,直到信号处理程序完成执行。此外,信号可以在任何线程上发生,需要同步。因此,在 Rust 中,最好使用第三方 crate 进行信号处理。请注意,即使在使用外部 crate 的情况下,也应谨慎行事,因为这些 crate 并没有解决与信号处理相关的所有问题。
现在让我们看看使用signal-hookcrate 处理信号的示例。将其添加到Cargo.toml中的依赖项,如下所示:
[dependencies]
signal-hook = "0.1.16"
下面是一个示例代码片段:
use signal_hook::iterator::Signals;
use std::io::Error;
fn main() -> Result<(), Error> {
let signals = Signals::new(&[signal_hook::SIGTERM,
signal_hook::SIGINT])?;
'signal_loop: loop {
// Pick up signals that arrived since last time
for signal in signals.pending() {
match signal {
signal_hook::SIGINT => {
println!("Received signal SIGINT");
}
signal_hook::SIGTERM => {
println!("Received signal SIGTERM");
break 'signal_loop;
}
_ => unreachable!(),
}
}
}
println!("Terminating program");
Ok(())
}
在前面的代码中,我们在 match 子句中监听两个特定的信号,SIGTERM 和 SIGINT。SIGINT 可以通过按下 Ctrl + C 发送到程序。SIGTERM 信号可以通过在 Unix shell 的命令行中使用 kill 命令从 进程 ID 生成。
现在,运行程序并模拟两个信号。然后,按下 Ctrl + C 键组合,这将生成 SIGINT 信号。你会看到,与默认行为(即终止程序)不同,终端会打印出一条语句。
为了模拟 SIGTERM,在 Unix shell 的命令行中运行 ps 命令并检索 进程 ID。然后运行带有 进程 ID 的 kill 命令。你会看到进程终止,并且终端会打印出一条语句。
注意
如果你正在使用 tokio 进行异步代码,你可以使用信号钩子的 tokio-support 功能。
重要的是要记住,信号处理是一个复杂的话题,即使在编写自定义信号处理代码时,也需要谨慎行事。
在处理信号或处理错误时,记录信号或错误也是一个好的做法,以便将来供系统管理员参考和故障排除。然而,如果你想让程序读取这些日志,你可以使用外部包如 serde_json 将这些消息以 JSON 格式记录,而不是纯文本。
这就结束了关于在 Rust 中处理 panic、errors 和 signals 的本小节。现在,让我们编写一个 shell 程序来演示所讨论的一些概念。
在 Rust 中编写 shell 程序(项目)
我们在 深入 Linux 进程基础 部分学习了什么是 shell 程序。在本节中,我们将构建一个 shell 程序,并逐步添加功能。
在第一个迭代中,我们将编写基本的代码来从命令行读取 shell 命令并启动子进程来执行该命令。接下来,我们将添加将命令参数传递给子进程的功能。最后,我们将通过添加对用户以更自然语言语法输入命令的支持来个性化 shell。我们还将在这个最后迭代中引入错误处理。让我们开始吧:
-
让我们首先创建一个新的项目:
cargo new myshell && cd myshell -
创建三个文件:
src/iter1.rs、src/iter2.rs和src/iter3.rs。三个迭代的代码将放在这些文件中,以便可以单独构建和测试每个迭代。 -
在
Cargo.toml中添加以下内容:[[bin]] name = "iter1" path = "src/iter1.rs" [[bin]] name = "iter2" path = "src/iter2.rs" [[bin]] name = "iter3" path = "src/iter3.rs"在前面的代码中,我们指定给 Cargo 工具,我们想要为三个迭代构建单独的二进制文件。
现在,我们已经准备好开始 shell 程序的第一个迭代。
迭代 1 – 启动子进程执行命令
首先,让我们编写一个程序来从终端接收命令,然后启动一个新的子进程来执行这些用户命令。添加一个循环结构以在进程终止前循环接受用户命令。代码如下:
src/iter1.rs
use std::io::Write;
use std::io::{stdin, stdout};
use std::process::Command;
fn main() {
loop {
print!("$ "); <1>
stdout().flush().unwrap(); <2>
let mut user_input = String::new(); <3>
stdin()
.read_line(&mut user_input) <4>
.expect("Unable to read user input");
let command_to_execute = user_input.trim(); <5>
let mut child = Command::new(command_to_execute) <6>
.spawn()
.expect("Unable to execute command");
child.wait().unwrap(); <7>
}
}
上述代码中的编号注释描述如下:
-
显示
$提示符以提示用户输入命令。 -
清空
stdout句柄,以便$提示符立即在终端上显示。 -
创建一个缓冲区来保存用户输入的命令。
-
逐行读取用户命令。
-
从缓冲区中删除换行符(这是当用户按下Enter键提交命令时添加的)。
-
创建一个新的子进程并将用户命令传递给子进程以执行。
-
在接受更多用户输入之前,请等待子进程完成执行。
-
使用以下命令运行程序:
cargo run –-bin iter1
在$提示符下输入不带参数的任何命令,如ls或ps或du。你将在终端上看到命令执行的输出。你可以在下一个$提示符下继续输入更多此类命令。按Ctrl + C退出程序。
我们现在有了我们 shell 程序的第一版,但如果在命令后输入参数或标志,这个程序将会失败。例如,输入ls这样的命令是可行的,但输入ls –lah将会导致程序恐慌并退出。让我们在代码的下一个迭代中添加对命令参数的支持。
迭代 2 – 添加对命令参数的支持
让我们使用args()方法添加对命令参数的支持:
src/iter2.rs
// Module imports not shown here
fn main() {
loop {
print!("$ ");
stdout().flush().unwrap();
let mut user_input = String::new();
stdin()
.read_line(&mut user_input)
.expect("Unable to read user input");
let command_to_execute = user_input.trim();
let command_args: Vec<&str> =
command_to_execute.split_whitespace().
collect(); <1>
let mut child = Command::new(command_args[0])
<2>
.args(&command_args[1..]) <3>
.spawn()
.expect("Unable to execute command");
child.wait().unwrap();
}
}
显示的代码基本上与上一个代码片段相同,除了添加了三条注释编号的额外行。注释描述如下:
-
读取用户输入,按空格分割,并将结果存储在
Vec中。 -
Vec的第一个元素对应于命令。创建一个子进程来执行此命令。 -
将
Vec元素列表(从第二个元素开始)作为参数列表传递给子进程。 -
使用以下行运行程序:
cargo run -–bin iter2 -
在按Enter键之前输入一个命令并将其参数传递给它。例如,你可以输入以下命令之一:
ls –lah ps -ef cat a.txt
注意,在最后一个命令中,a.txt 是一个包含一些内容并位于项目根目录中的现有文件。
你将看到命令输出成功显示在终端上。到目前为止,shell 按预期工作。现在让我们在下一个迭代中稍微扩展一下。
迭代 3 – 支持自然语言命令
由于这是我们自己的 shell,让我们在这个迭代中实现一个用户友好的 shell 命令别名(为什么不呢?)。除了输入ls之外,如果用户可以像以下这样输入自然语言的命令会怎样:
show files
这是我们接下来要编写的代码。以下代码片段显示了代码。让我们首先看看模块导入:
use std::io::Write;
use std::io::{stdin, stdout};
use std::io::{Error, ErrorKind};
use std::process::Command;
从 std::io 模块导入用于向终端写入、从终端读取以及用于错误处理的模块。我们已经知道了导入 process 模块的目的。
现在我们来分部分查看 main() 程序。我们不会涵盖之前迭代中已经看到的代码。main() 函数的完整代码可以在 GitHub 仓库的 src/iter3.rs 文件中找到:
-
在显示
$提示符后,检查用户是否输入了任何命令。如果用户在提示符下只按了 Enter 键,则忽略并重新显示$提示符。以下代码检查用户是否至少输入了一个命令,然后处理用户输入:if command_args.len() > 0 {..} -
如果输入的命令是
show files,则在子进程中执行ls命令。如果命令是show process,则执行ps命令。如果没有参数输入show,或者show命令后跟无效的单词,则抛出错误:let child = match command_args[0] { "show" if command_args.len() > 1 => match command_args[1] { "files" => Command::new("ls"). args(&command_args[2..]).spawn(), "process" => Command::new("ps").args (&command_args[2..]).spawn(), _ => Err(Error::new( ErrorKind::InvalidInput, "please enter valid command", )), }, "show" if command_args.len() == 1 => Err(Error::new( ErrorKind::InvalidInput, "please enter valid command", )), "quit" => std::process::exit(0), _ => Command::new(command_args[0]) .args(&command_args[1..]) .spawn(), }; -
等待子进程完成。如果子进程未能成功执行,或者用户输入无效,则抛出错误:
match child { Ok(mut child) => { if child.wait().unwrap().success() { } else { println!("\n{}", "Child process failed") } } Err(e) => match e.kind() { ErrorKind::InvalidInput => eprintln!( "Sorry, show command only supports following options: files , process " ), _ => eprintln!("Please enter a valid command"), }, } -
使用
cargo run –-bin iter3运行程序,并在$提示符下尝试以下命令以进行测试:show files show process du
你会看到命令成功执行,并打印出表示成功的语句。
你会注意到我们在代码中添加了一些错误处理。让我们看看我们解决了哪些错误条件:
-
如果用户在输入命令前直接按 Enter 键
-
如果用户输入了没有参数的
show命令(无论是文件还是进程) -
如果用户输入了无效参数的
show命令 -
如果用户输入了有效的 Unix 命令,但该命令不受我们的程序支持(例如,
pipes或redirection)
让我们尝试以下无效输入:
show memory
show
invalid-command
你会看到错误信息被打印到终端。
也尝试在提示符下不输入命令直接按 Enter 键。你会看到这不会被处理。
在错误处理代码中,注意使用 ErrorKind 枚举,它是在 Rust 标准库中定义的一组预定义错误类型。预定义错误类型的列表可以在 doc.rust-lang.org/std/io/enum.ErrorKind.html 找到。
恭喜!你已经实现了一个基本的 shell 程序,它可以识别非技术用户的自然语言命令。你还实现了一些错误处理,使得程序在无效输入时具有一定的鲁棒性,不会崩溃。
作为练习,你可以做以下事情来增强这个 shell 程序:
-
添加对管道操作符分隔的命令链的支持,例如
ps | grep sys。 -
添加对重定向的支持,例如使用 > 操作符将进程执行的输出重定向到文件。
-
将命令行解析的逻辑移动到单独的标记器模块中。
在本节中,我们编写了一个具有现实世界 shell 程序(如zsh或bash)部分功能的 shell 程序。为了明确,现实世界的 shell 程序具有更多复杂的功能,但在这里我们已经涵盖了创建 shell 程序背后的基本概念。同样重要的是,我们学习了如何处理无效用户输入或子进程失败时的错误。为了内化你的学习,建议为建议的练习编写一些代码。
这就完成了 Rust 编写 shell 程序的部分。
摘要
在本章中,我们回顾了类 Unix 操作系统中进程的基本知识。我们学习了如何创建子进程,与其标准输入和标准输出进行交互,以及如何使用其参数执行命令。我们还看到了如何在错误条件下设置和清除环境变量。我们探讨了在错误条件下终止进程的各种方法,以及如何检测和处理外部信号。最后,我们用 Rust 编写了一个可以执行标准 Unix 命令的 shell 程序,还可以接受一些自然语言格式的命令。我们还处理了一系列错误,使程序更加健壮。
继续讨论管理系统资源的话题,在下一章中,我们将学习如何管理进程的线程,并在 Rust 中构建并发系统程序。
第九章:第九章:管理并发
并发系统无处不在。当你下载文件、收听流媒体音乐、与朋友开始文本聊天,以及在你的电脑后台打印某些东西时,同时进行,你正在体验并发编程的魔力。操作系统在后台为你管理所有这些,跨可用的处理器(CPU)调度任务。
但你知道如何编写一个可以同时做很多事情的程序吗?更重要的是,你知道如何以既内存安全又线程安全的方式做到这一点,同时确保系统资源的最佳使用吗?并发编程是实现这一目标的一种方式。但并发编程在大多数编程语言中都被认为是困难的,因为存在同步任务和在多个执行线程之间安全共享数据的挑战。在本章中,你将了解 Rust 中并发的基础,以及 Rust 如何使防止常见陷阱变得更容易,并使我们能够以安全的方式编写并发程序。本章的结构如下所示:
-
复习并发基础
-
创建和配置线程
-
线程中的错误处理
-
线程间的消息传递
-
通过共享状态实现并发
-
使用定时器暂停线程执行
在本章结束时,你将学会如何通过创建新线程、处理线程错误、在线程之间安全地传输和共享数据以同步任务、理解线程安全数据类型的基础以及暂停当前线程的执行以进行同步,来用 Rust 编写并发程序。
技术要求
使用以下命令验证rustup、rustc和cargo是否已正确安装:
rustup --version
rustc --version
cargo --version
本章中代码的 Git 仓库可以在以下位置找到:github.com/PacktPublishing/Practical-System-Programming-for-Rust-Developers/tree/master/Chapter09。
让我们从并发的一些基本概念开始。
复习并发基础
在本节中,我们将介绍多线程的基础,并阐明关于并发和并行的术语。
要欣赏并发编程的价值,我们必须理解当今程序需要快速做出决策或短时间内处理大量数据的必要性。如果我们严格依赖顺序执行,那么几个用例将无法实现。让我们考虑一些必须同时执行多项任务的系统示例。
一辆自动驾驶汽车需要同时执行许多任务,例如处理来自各种传感器的输入(以构建其周围环境的内部地图),规划车辆的路径,并向车辆的执行器发送指令(以控制刹车、加速和转向)。它需要持续处理不断到达的输入事件,并在十分之一秒内做出响应。
同时还有其他一些更为常见的例子。一个网页浏览器在接收新数据的同时,会处理用户输入并逐步渲染网页。一个网站会处理来自多个同时在线用户的请求。一个网络爬虫需要同时访问成千上万的网站以收集有关网站及其内容的信息。按顺序执行所有这些事情是不切实际的。
我们已经看到了一些需要同时执行多个任务的用例。但还有一个技术原因在推动编程中的并发,那就是单核 CPU 的时钟速度已经接近实际上限。因此,有必要增加更多的 CPU 核心和单台机器上的更多处理器。这反过来又推动了需要能够高效利用额外 CPU 核心的软件的需求。为了实现这一点,程序的一部分应该在不同的 CPU 核心上并发执行,而不是被限制在单核 CPU 上的指令顺序执行。
这些因素导致了在编程中多线程概念的广泛应用。这里有两个相关的术语需要理解——并发 和 并行。让我们更深入地了解一下。
并发与并行
在本节中,我们将回顾多线程的基本原理,并了解程序中 并发 和 并行 执行模型之间的区别。

图 9.1 – 并发基础
图 9.1 展示了 Unix/Linux 进程内的三个不同的计算场景:
-
顺序执行:假设一个进程有两个任务 A 和 B。任务 A 包含三个子任务 A1、A2 和 A3,它们是顺序执行的。同样,任务 B 包含两个任务,B1 和 B2,它们是依次执行的。总体来说,进程在执行进程 A 的所有任务之后,才会开始执行进程 B 的任务。这种模型存在一个挑战。假设任务 A2 需要等待外部网络或用户输入,或者等待系统资源可用。在这种情况下,所有排在任务 A2 后面的任务都将被阻塞,直到 A2 完成。这不是 CPU 的高效使用,并且会导致属于该进程的所有预定任务完成延迟。
-
并发执行:顺序程序有限制,因为它们没有处理多个同时输入的能力。这就是为什么许多现代应用程序是并发的,其中存在多个并发执行的执行线程。
在并发模型中,进程会交错执行任务,即交替执行任务 A和任务 B,直到它们都完成。在这里,即使A2被阻塞,它也允许其他子任务继续进行。每个子任务,A1、A2、A3、B1和B2,都可以在单独的执行线程上调度。这些线程可以在单个处理器上运行,也可以跨多个处理器核心调度。需要注意的是,并发是关于顺序无关的计算,而不是依赖于执行特定顺序以到达正确程序结果的顺序执行。编写适应顺序无关计算的程序比编写顺序程序更具挑战性。
-
并行执行:这是并发执行模型的一种变体。在这个模型中,进程真正地在不同的 CPU 处理器或核心上并行执行任务 A和任务 B。当然,这假设软件是以一种使得这种并行执行成为可能的方式编写的,并且任务 A和任务 B之间没有可能导致执行停滞或数据损坏的依赖关系。
并行计算是一个广泛的概念。并行性可以通过在单个机器内拥有多核或多处理器来实现,或者可以有不同的计算机集群,它们可以协作执行一系列任务。
何时使用并发与并行执行?
当程序或函数涉及大量计算(如图形、气象或基因组处理)时,它是计算密集型的。这类程序的大部分时间都用于使用 CPU 周期,并且将受益于拥有更好、更快的 CPU。
当大量处理涉及与输入/输出设备(如网络套接字、文件系统和其他设备)通信时,程序是I/O 密集型的。这类程序从拥有更快的 I/O 子系统(如磁盘或网络访问)中受益。
广义上讲,并行执行(真正的并行性)对于在计算密集型用例中增加程序的吞吐量更为相关,而并发处理(或伪并行性)可以在I/O 密集型用例中增加吞吐量和降低延迟。
在本节中,我们看到了编写并发程序的两种方法——并发和并行,以及这些方法如何与顺序执行模型不同。这两个模型都使用多线程作为基础概念。让我们在下一节中更多地讨论这一点。
多线程的概念
在本节中,我们将深入探讨在 Unix 中如何实现多线程。
Unix 支持线程作为进程执行多个任务并发的机制。一个 Unix 进程以单个线程启动,这是执行的主线程。但可以生成额外的线程,这些线程可以在单处理器系统中并发执行,或者在多处理器系统中并行执行。
每个线程都有自己的栈来存储自己的局部变量和函数参数。线程还维护自己的寄存器状态,包括栈指针和程序计数器。进程中的所有线程共享相同的内存地址空间,这意味着它们共享对数据段(初始化数据、未初始化数据和堆)的访问。线程还共享相同的程序代码(进程指令)。
在多线程进程中,多个线程并发执行相同的程序。它们可能正在执行程序的不同部分(例如不同的函数),或者它们可能在不同的线程中调用相同的函数(使用不同的数据集进行处理)。但请注意,为了使函数能够被多个线程同时调用,它需要是线程安全的。使函数线程安全的一些方法包括在函数中避免使用全局或静态变量,使用互斥锁来限制一次只有一个线程使用函数,或者使用互斥锁来同步对共享数据的访问。
但是,将并发程序建模为一组进程或同一进程内的线程组是一个设计选择。让我们比较这两种方法,以 Unix-like 系统为例。
由于线程位于同一进程空间中,因此跨线程共享数据要容易得多。线程还共享进程的公共资源,如文件描述符和用户/组 ID。线程创建比进程创建更快。由于它们共享相同的内存空间,因此线程之间的上下文切换对 CPU 来说也更快。但线程也带来了自己的复杂性。
如前所述,共享函数必须是线程安全的,对共享全局数据的访问应谨慎同步。此外,一个线程中的关键缺陷可能会影响其他线程,甚至可能导致整个进程崩溃。此外,没有保证不同线程中不同代码部分运行的顺序,这可能导致数据竞争、死锁或难以复现的 bug。由于 CPU 速度、线程数量和特定时间点运行的应用程序集等因素可能会改变并发程序的结果,因此与并发相关的 bug 难以调试。尽管存在这些缺点,如果决定采用基于线程的并发模型,代码结构、全局变量的使用和线程同步等方面应仔细设计。
图 9.2显示了进程内线程的内存布局:

图 9.2 – 进程中线程的内存布局
图表展示了在多线程模型下执行时,进程 P1 中的任务集在内存中的表示。我们在 第五章,Rust 中的内存管理 中详细看到了进程的内存布局。图 9.2 通过展示进程内各个线程内存分配的细节,扩展了进程内存布局。
如前所述,所有线程都在进程内存空间内分配内存。默认情况下,主线程创建时带有自己的栈。随着线程的创建,额外的线程也会分配自己的栈。我们之前在章节中讨论的共享并发模型是可能的,因为进程的全局和静态变量对所有线程都是可访问的,并且每个线程还可以将堆上创建的内存指针传递给其他线程。
然而,程序代码对线程来说是通用的。每个线程都可以从程序文本段的不同部分执行代码,并在各自的线程栈中存储局部变量和函数参数。当轮到线程执行时,其程序计数器(包含要执行指令的地址)被加载到 CPU 中,以便执行给定线程的指令集。
在图表所示的示例中,如果任务 A2 被阻塞等待 I/O,那么 CPU 将切换执行到另一个任务,例如 B1 或 A1。
通过这种方式,我们完成了关于并发和多线程基本知识的章节。我们现在可以开始使用 Rust 标准库编写并发程序了。
创建和配置线程
在上一节中,我们回顾了适用于 Unix 环境中所有用户进程的多线程基本原理。然而,线程的另一个方面取决于编程语言的实现 – 这就是 线程模型。
Rust 实现了一种 1:1 模型 的线程,其中每个操作系统线程映射到由 Rust 标准库创建的一个用户级线程。另一种模型是 M:N(也称为 绿色线程),其中存在 M 个绿色线程(由运行时管理的用户级线程),它们映射到 N 个内核级线程。
在本节中,我们将介绍使用 Rust 标准库创建 1:1 操作系统线程的基本知识。与线程相关的函数的 Rust 标准库模块是 std::thread。
使用 Rust 标准库创建新线程有两种方式。第一种方法使用 thread::spawn 函数,第二种方法使用 thread::Builder 结构体的构建模式。让我们首先看看 thread::spawn 函数的示例:
use std::thread;
fn main() {
for _ in 1..5 {
thread::spawn(|| {
println!("Hi from thread id {:?}",
thread::current().id());
});
}
}
在这个程序中使用了std::thread模块。thread::spawn()是用于创建新线程的函数。在显示的程序中,我们在主函数中(在进程的主线程中运行)创建了四个新的子线程。使用cargo run运行此程序。运行几次。你期望看到什么,实际上看到了什么?
你本应看到四行打印到终端,列出线程 ID。但你可能会注意到每次的结果都不同。有时你会看到一行打印,有时你会看到更多,有时则没有。这是为什么?
这种不一致的原因在于无法保证线程执行的顺序。此外,如果main()函数在子线程执行之前完成,你将不会在终端看到预期的输出。
为了解决这个问题,我们需要做的是将创建的子线程连接到主线程。然后main()线程等待直到所有子线程都执行完毕。为了看到这个效果,让我们按照以下方式修改程序:
use std::thread;
fn main() {
let mut child_threads = Vec::new();
for _ in 1..5 {
let handle = thread::spawn(|| {
println!("Hi from thread id {:?}",
thread::current().id());
});
child_threads.push(handle);
}
for i in child_threads {
i.join().unwrap();
}
}
与上一个程序相比,这些更改被突出显示。thread::spawn()返回一个线程句柄,我们将其存储在Vec集合数据类型中。在main()函数结束之前,我们将每个子线程连接到主线程。这确保了main()函数在退出之前等待所有子线程完成。
让我们再次运行程序。你会注意到打印了四行,每行对应一个线程。再次运行程序几次。每次都会打印四行。这是进步。这表明将子线程连接到主线程是有帮助的。然而,线程执行的顺序(如终端上打印输出的顺序所示)每次运行都会变化。这是因为,当我们创建多个子线程时,无法保证线程执行的顺序。这是多线程(如前所述)的一个特性,而不是一个错误。但这也是与线程一起工作的挑战之一,因为它给跨线程同步活动带来了困难。我们将在本章稍后学习如何解决这个问题。
我们已经看到了如何使用thread::spawn()函数创建新线程。现在让我们看看创建新线程的第二种方法。
thread::spawn()函数使用默认参数来设置线程名称和堆栈大小。如果你想明确设置它们,可以使用thread:Builder。这是一个线程工厂,它使用Builder模式来配置新线程的属性。以下示例已经使用Builder模式重写:
use std::thread;
fn main() {
let mut child_threads = Vec::new();
for i in 1..5 {
let builder = thread::Builder::new().name(format!(
"mythread{}", i));
let handle = builder
.spawn(|| {
println!("Hi from thread id {:?}", thread::
current().name().unwrap());
})
.unwrap();
child_threads.push(handle);
}
for i in child_threads {
i.join().unwrap();
}
}
代码中的更改已被突出显示。我们通过使用new()函数创建一个新的builder对象,然后使用name()方法配置线程的名称。然后我们在Builder模式的实例上使用spawn()方法。请注意,spawn()方法返回一个被io::Result<JoinHandle<T>>类型包裹的JoinHandle类型,因此我们必须解包方法的返回值以检索子进程句柄。
运行代码,你将在终端看到四个线程名称被打印出来。
我们已经看到了如何创建新的线程。现在让我们看看在处理线程时如何进行错误处理。
线程中的错误处理
Rust 标准库包含std::thread::Result类型,这是一个专门为线程设计的Result类型。以下代码展示了如何使用此类型的一个示例:
use std::fs;
use std::thread;
fn copy_file() -> thread::Result<()> {
thread::spawn(|| {
fs::copy("a.txt", "b.txt").expect("Error
occurred");
})
.join()
}
fn main() {
match copy_file() {
Ok(_) => println!("Ok. copied"),
Err(_) => println!("Error in copying file"),
}
}
我们有一个名为copy_file()的函数,该函数将源文件复制到目标文件。这个函数返回一个thread::Result<()>类型的值,我们在main()函数中使用match语句来解包它。如果copy_file()函数返回Result::Err变体,我们通过打印错误信息来处理它。
使用cargo run运行程序并传入一个无效的源文件名。你将看到错误信息:match子句的Ok()分支,成功信息将被打印出来。
这个例子展示了如何处理由线程传播到调用函数中的错误。如果我们想在错误传播到调用函数之前就识别出当前线程正在恐慌,该怎么办?Rust 标准库在std::thread模块中提供了一个名为thread::panicking()的函数,用于此目的。让我们通过修改前面的例子来学习如何使用它:
use std::fs;
use std::thread;
struct Filenames {
source: String,
destination: String,
}
impl Drop for Filenames {
fn drop(&mut self) {
if thread::panicking() {
println!("dropped due to panic");
} else {
println!("dropped without panic");
}
}
}
fn copy_file(file_struct: Filenames) -> thread::Result<()> {
thread::spawn(move || {
fs::copy(&file_struct.source,
&file_struct.destination).expect(
"Error occurred");
})
.join()
}
fn main() {
let foo = Filenames {
source: "a1.txt".into(),
destination: "b.txt".into(),
};
match copy_file(foo) {
Ok(_) => println!("Ok. copied"),
Err(_) => println!("Error in copying file"),
}
}
我们创建了一个名为Filenames的结构体,其中包含要复制的源文件名和目标文件名。我们使用一个无效值初始化源文件名。我们还为Filenames结构体实现了Drop特质,当结构体的实例超出作用域时会被调用。在这个Drop特质实现中,我们使用thread::panicking()函数来检查当前线程是否正在恐慌,并通过打印错误信息来处理它。然后错误被传播到主函数,主函数也处理线程错误并打印出另一个错误信息。
使用cargo run运行程序并传入一个无效的源文件名,你将在终端看到以下信息打印出来:
dropped due to panic
Error in copying file
此外,请注意在提供给spawn()函数的closure中使用move关键字。这对于线程将file_struct数据结构的所有权从主线程转移到新创建的线程是必需的。
我们已经看到了如何在调用函数中处理线程恐慌,以及如何检测当前线程是否正在恐慌。处理子线程中的错误对于确保错误被隔离并且不会使整个进程崩溃非常重要。因此,在设计多线程程序的错误处理时需要特别注意。
接下来,我们将继续讨论如何在线程之间同步计算的话题,这是编写并发程序的一个重要方面。
线程间的消息传递
并发是一个强大的功能,它使得编写新类型的应用程序成为可能。然而,并发程序的执行和调试是困难的,因为它们的执行是非确定性的。我们在上一节中的示例中看到了这一点,其中打印语句的顺序在每次程序运行时都不同。线程将执行的顺序在事先是未知的。并发程序的开发者必须确保程序将总体上正确执行,而不管个别线程执行的顺序如何。
在面对线程执行顺序不可预测的情况下,确保程序正确性的一个方法是通过引入跨线程同步活动的机制。这种并发编程模型之一是 消息传递并发。这是一种结构化并发程序组件的方法。在我们的案例中,并发组件是 线程(但它们也可以是进程)。Rust 标准库实现了一个名为 channels 的 消息传递并发 解决方案。通道 基本上就像一个管道,有两个部分 - 一个 生产者 和一个 消费者。生产者 将消息放入 通道,而 消费者 从 通道 中读取。
许多编程语言实现了线程间通信的概念。但 Rust 的 通道 实现有一个特殊的属性 - 多生产者单消费者 (mpsc)。这意味着可以有多个发送端,但只有一个消费端。将此翻译成线程的世界:我们可以有多个线程将值发送到通道,但只能有一个线程可以接收并消费这些值。让我们通过一个我们将逐步构建的示例来看看这是如何工作的。完整的代码列表也提供在 Git 仓库中该章节的 src/message-passing.rs:
-
首先声明模块导入 - 从标准库中导入的
mpsc和thread模块:use std::sync::mpsc; use std::thread; -
在
main()函数中,创建一个新的mpsc通道:let (transmitter1, receiver) = mpsc::channel(); -
复制通道,以便我们可以有两个传输线程:
let transmitter2 = mpsc::Sender::clone(&transmitter1); -
注意,我们现在有两个传输句柄 -
transmitter1和transmitter2,以及一个接收句柄 -receiver。 -
创建一个新的线程,将传输句柄
transmitter1移入线程闭包中。在这个线程内部,使用传输句柄向通道发送一系列值:thread::spawn(move || { let num_vec: Vec<String> = vec!["One".into(), "two".into(), "three".into(), "four".into()]; for num in num_vec { transmitter1.send(num).unwrap(); } }); -
启动一个第二线程,将传输句柄
transmitter2移动到线程闭包中。在这个线程内部,使用传输句柄将另一组值发送到通道中:thread::spawn(move || { let num_vec: Vec<String> = vec!["Five".into(), "Six".into(), "Seven".into(), "eight".into()]; for num in num_vec { transmitter2.send(num).unwrap(); } }); -
在程序的主线程中,使用通道的接收句柄来消费两个子线程写入通道的值:
for received_val in receiver { println!("Received from thread: {}", received_val); }完整的代码列表如下:
use std::sync::mpsc; use std::thread; fn main() { let (transmitter1, receiver) = mpsc::channel(); let transmitter2 = mpsc::Sender::clone( &transmitter1); thread::spawn(move || { let num_vec: Vec<String> = vec!["One".into(), "two".into(), "three".into(), "four".into()]; for num in num_vec { transmitter1.send(num).unwrap(); } }); thread::spawn(move || { let num_vec: Vec<String> = vec!["Five".into(), "Six".into(), "Seven".into(), "eight".into()]; for num in num_vec { transmitter2.send(num).unwrap(); } }); for received_val in receiver { println!("Received from thread: {}", received_val); } } -
使用
cargo run运行程序。(注意:如果你是从 Packt Git 仓库运行代码,请使用cargo run --bin message-passing)。你将在主程序线程中看到打印出来的值,这些值是从两个子线程发送过来的。每次运行程序时,你可能会收到不同的值接收顺序,因为线程执行的顺序是非确定性的。
mpsc通道提供了一个轻量级的线程间同步机制,可用于跨线程的消息传递通信。这种并发编程模型在你想要为不同类型的计算启动多个线程,并希望主线程汇总结果时非常有用。
在mpsc中需要注意的一个方面是,一旦一个值被发送到通道中,发送线程就不再拥有它。如果你想保留所有权或继续使用一个值,但仍然需要一种方式与其他线程共享这个值,那么 Rust 支持另一种并发模型,称为共享状态并发。我们将在下一节中探讨这一点。
实现共享状态并发
在本节中,我们将讨论 Rust 标准库支持的第二种并发编程模型——共享状态或共享内存并发模型。回想一下,进程中的所有线程都共享相同的进程内存空间,那么为什么不使用它作为线程间通信的方式,而不是消息传递呢?我们将探讨如何使用 Rust 实现这一点。
Mutex和Arc的组合构成了实现共享状态并发的主要方式。Mutex(互斥锁)是一种机制,它允许一次只有一个线程访问一块数据。首先,一个数据值被包裹在一个Mutex类型中,它充当一个锁。你可以将Mutex想象成一个带有外部锁的盒子,里面保护着一些有价值的东西。要访问盒子里的东西,首先我们必须请求某人打开锁并交出盒子。一旦我们完成,我们就把盒子交回去,然后其他人请求接管它。
同样,要访问或修改由Mutex保护的价值,我们必须首先获取锁。在Mutex对象上请求锁返回一个MutexGuard类型,它允许我们访问内部值。在这段时间内,没有其他线程可以访问这个由MutexGuard保护的价值。一旦我们使用完毕,我们必须释放MutexGuard(Rust 会为我们自动释放,因为MutexGuard超出了作用域,我们不需要调用单独的unlock()方法)。
但还有一个问题需要解决。使用锁来保护一个值只是解决方案的一部分。我们还需要将一个值的所有权赋予多个线程。为了支持一个值的多个所有权,Rust 使用了引用计数的智能指针 – Rc 和 Arc。Rc 通过其 clone() 方法允许一个值有多个所有者。但是 Rc 在线程间使用并不安全,而 Arc(代表原子引用计数)是 Rc 的线程安全版本。因此,我们需要用 Arc 引用计数的智能指针包装 Mutex,并在线程间传递值的所有权。一旦 Arc-保护的 Mutex 的所有权被转移到另一个线程,接收线程就可以在 Mutex 上调用 lock() 来获取对内部值的独占访问。Rust 的所有权模型有助于强制执行围绕此模型的规定。
Arc<T> 类型的工作方式是,它提供了类型为 T 的值的共享所有权,该值在堆上分配。通过在 Arc 实例上调用关联函数 clone(),创建了一个新的 Arc 引用计数指针实例,它指向与源 Arc 相同的堆分配,同时增加引用计数。每次调用 clone(),Arc 智能指针都会增加引用计数。当每个 cloned() 指针超出作用域时,引用计数器会减少。当最后一个克隆超出作用域时,Arc 指针及其指向的值(在堆上)都会被销毁。
总结来说,Mutex 确保最多只有一个线程能同时访问某些数据,而 Arc 使某些数据的共享所有权成为可能,并延长其生命周期,直到所有线程都完成使用它。
让我们通过一个逐步的示例来查看 Mutex 与 Arc 的用法,以演示共享状态并发。这次,我们将编写一个比仅仅在多个线程间增加共享计数器值更复杂的示例。我们将使用我们在第六章中写的示例,在 Rust 中处理文件和目录,来计算目录树中所有 Rust 文件的源文件统计信息,并将其修改为并发程序。我们将在下一节中定义程序的结构。本节的完整代码可以在 Git 仓库的 src/shared-state.rs 下找到。
定义程序结构
我们希望做的是将目录列表作为程序的输入,计算每个目录中每个文件的源文件统计信息,并打印出一系列源代码统计信息。
让我们首先在 cargo 项目的根目录中创建一个dirnames.txt文件,其中包含一个目录的完整路径列表,每行一个。我们将从该文件中读取每个条目,并为该目录树中的 Rust 文件启动一个单独的线程来计算源文件统计信息。因此,如果文件中有五个目录名称条目,主程序将创建五个线程,每个线程将递归地遍历条目的目录结构,并计算合并的 Rust 源文件统计信息。每个线程将增加共享数据结构中的计算值。我们将使用Mutex和Arc来保护线程间安全地访问和更新共享数据。
让我们开始编写代码:
-
我们将从这个程序的模块导入开始:
use std::ffi::OsStr; use std::fs; use std::fs::File; use std::io::{BufRead, BufReader}; use std::path::PathBuf; use std::sync::{Arc, Mutex}; use std::thread; -
定义一个结构体来存储源文件统计信息:
#[derive(Debug)] pub struct SrcStats { pub number_of_files: u32, pub loc: u32, pub comments: u32, pub blanks: u32, } -
在
main()函数中,创建一个新的SrcStats实例,使用Mutex锁来保护它,然后将其包装在Arc类型中:let src_stats = SrcStats { number_of_files: 0, loc: 0, comments: 0, blanks: 0, }; let stats_counter = Arc::new( Mutex::new(src_stats)); -
读取
dirnames.txt文件,并将单个条目存储在一个向量中:let mut dir_list = File::open( "./dirnames.txt").unwrap(); let reader = BufReader::new(&mut dir_list); let dir_lines: Vec<_> = reader.lines().collect(); -
遍历
dir_lines向量,并为每个条目启动一个新线程以执行以下两个步骤:a) 累积树中每个子目录的文件列表。
b) 然后打开每个文件并计算统计信息。更新由
Mutex和Arc保护的共享内存结构中的统计信息。
此步骤的代码整体结构看起来像这样:
let mut child_handles = vec![];
for dir in dir_lines {
let dir = dir.unwrap();
let src_stats = Arc::clone(&stats_counter);
let handle = thread::spawn(move || {
// Do processing: A)
// Do processing: B)
});
child_handles.push(handle);
}
在本节中,我们从文件中读取目录条目列表以计算源文件统计信息。然后我们遍历列表以启动一个线程来处理每个条目。在下一节中,我们将定义每个线程中要执行的处理。
在共享状态中聚合源文件统计信息
在本节中,我们将编写每个线程中计算源文件统计信息的代码,并将结果聚合到共享状态中。我们将分两部分查看代码 – 子步骤 A和子步骤 B:
-
在子步骤 A中,让我们遍历目录条目下的每个子目录,并将所有 Rust 源文件的合并列表累积到
file_entries向量中。子步骤 A的代码如下。在这里,我们首先创建两个向量来分别存储目录和文件名。然后我们遍历dirnames.txt文件中的每个项目目录条目,根据它是目录还是单个文件,将条目名称累积到dir_entries或file_entries向量中:let mut dir_entries = vec![PathBuf:: from(dir)]; let mut file_entries = vec![]; while let Some(entry) = dir_entries.pop() { for inner_entry in fs::read_dir( &entry).unwrap() { if let Ok(entry) = inner_entry { if entry.path().is_dir() { dir_entries.push( entry.path()); } else { if entry.path() .extension() == Some(OsStr:: new("rs")) { println!("File name processed is {:?}",entry); file_entries.push( entry); } } } } }在子步骤 A结束时,所有单个文件名都存储在
file_entries向量中,我们将在子步骤 B中使用它进行进一步处理。 -
在子步骤 B中,我们将从
file_entries向量中读取每个文件,计算每个文件的源统计信息,并将值保存到共享内存结构中。以下是子步骤 B的代码片段:for file in file_entries { let file_contents = fs::read_to_string( &file.path()).unwrap(); let mut stats_pointer = src_stats.lock().unwrap(); for line in file_contents.lines() { if line.len() == 0 { stats_pointer.blanks += 1; } else if line.starts_with("//") { stats_pointer.comments += 1; } else { stats_pointer.loc += 1; } } stats_pointer.number_of_files += 1; } -
让我们再次回顾接下来展示的程序框架。到目前为止,我们已经看到了线程中要执行的代码,这包括步骤 A 和 B 的处理:
let mut child_handles = vec![]; for dir in dir_lines { let dir = dir.unwrap(); let src_stats = Arc::clone(&stats_counter); let handle = thread::spawn(move || { // Do processing: step A) // Do processing: step B) }); child_handles vector. -
现在我们来看代码的最后部分。正如之前讨论的,为了确保主线程在子线程完成之前不完成,我们必须将子线程句柄与主线程连接起来。此外,让我们打印出线程安全的
stats_counter结构体的最终值,该结构体包含目录下所有 Rust 源文件(由各个线程更新)的聚合源代码统计信息:for handle in child_handles { handle.join().unwrap(); } println!( "Source stats: {:?}", stats_counter.lock().unwrap() );完整的代码列表可以在
src/shared-state.rs中找到的该章节的 Git 仓库中。在运行此程序之前,请确保在 cargo 项目的根目录中创建一个名为
dirnames.txt的文件,其中包含具有完整路径的目录条目列表,每个条目占一行。 -
使用
cargo run运行项目。(注意:如果您正在从 Packt Git 仓库运行代码,请使用cargo run --bin shared-state。)您将看到打印出的合并后的源代码统计信息。请注意,我们现在已经实现了我们在 第六章 中编写的项目的多线程版本,即 在 Rust 中处理文件和目录。作为一个练习,修改此示例以使用 消息传递并发 模型实现相同的项目。
在本节中,我们看到了多个线程如何安全地向存储在进程堆内存中的共享值(包装在 Mutex 和 Arc 中)写入,以线程安全的方式进行。在下一节中,我们将回顾另一种可用于控制线程执行的机制,即选择性地暂停当前线程的处理。
发送和同步特性
我们之前看到数据类型可以在线程之间共享,以及消息如何在线程之间传递。然而,Rust 中还有并发的一个方面。Rust 将数据类型定义为线程安全或不安全。
从并发角度来看,Rust 中的数据类型分为两类:那些实现了 Send 特性的(即,可以安全地从一条线程转移到另一条线程)。其余的都是 线程不安全 类型。一个相关的概念是 Sync,它与类型的引用相关联。如果一个类型的引用可以安全地传递到另一个线程,则认为该类型是 Sync。因此,Send 表示从一个线程安全地转移到另一个线程的所有权是安全的,而 Sync 表示数据类型可以通过引用(同时)安全地被多个线程共享。但请注意,在 Send 中,在值从发送线程转移到接收线程之后,发送线程就不再可以使用该值了。
Send和Sync也是自动推导的特性。这意味着如果一个类型由实现Send或Sync类型的成员组成,那么该类型本身会自动成为Send或Sync。Rust 的原始类型(几乎全部)都实现了Send和Sync,这意味着如果你从 Rust 原始类型创建一个自定义类型,你的自定义类型也会成为Send或Sync。我们已经在上一节中看到了一个例子,其中SrcStats(源统计)结构体在不需要我们显式地在结构体上实现Send或Sync的情况下,跨线程边界进行传输。
然而,如果需要手动为数据类型实现Send或Sync特性,这必须在unsafe Rust中进行。
总结来说,在 Rust 中,每个数据类型都被分类为线程安全或线程不安全,Rust 编译器强制执行线程安全类型的线程间安全传输或共享。
使用定时器暂停线程执行
有时,在处理线程的过程中,可能需要暂停执行,要么是为了等待另一个事件,要么是为了与其他线程同步执行。Rust 通过std::thread::sleep函数提供对此类操作的支持。此函数接受一个类型为time::Duration的时间持续时间,并暂停线程的执行指定的时间。在这段时间内,处理器时间可以提供给其他线程或计算机系统上运行的其他应用程序。让我们看看thread::sleep的使用示例:
use std::thread;
use std::time::Duration;
fn main() {
let duration = Duration::new(1,0);
println!("Going to sleep");
thread::sleep(duration);
println!("Woke up");
}
使用sleep()函数相当简单,但它会阻塞当前线程,在多线程程序中合理使用这一点非常重要。使用sleep()的替代方案是使用异步编程模型来实现具有非阻塞 I/O 的线程。
Rust 中的异步 I/O
在多线程模型中,如果任何线程中有阻塞 I/O 调用,它将阻塞程序的工作流程。异步模型依赖于非阻塞的系统调用进行 I/O,例如,访问文件系统或网络。在具有多个同时传入连接的 Web 服务器示例中,而不是为每个连接创建一个单独的线程以阻塞方式处理,异步 I/O 依赖于一个不会阻塞当前线程但会在等待 I/O 时安排其他任务的运行时。
虽然 Rust 内置了Async/Await语法,这使得编写异步代码变得更容易,但它不提供任何异步系统调用支持。为此,我们需要依赖外部库,如Tokio,它提供了异步运行时(执行器)以及 Rust 标准库中存在的 I/O 函数的异步版本。
那么,何时应该使用异步与多线程的并发方法呢?一个粗略的规则是,异步模型适合执行大量 I/O 的程序,而对于计算密集型(CPU-bound)任务,多线程并发是一个更好的方法。但请注意,这并不是一个二选一的选择,因为在实践中,看到同时利用异步和多线程的混合模型并不罕见。
想了解更多关于 Rust 中异步的信息,请参阅以下链接:rust-lang.github.io/async-book/。
摘要
在本章中,我们介绍了 Rust 中并发和多线程编程的基础知识。我们首先回顾了并发编程模型的需求。我们理解了程序并发执行和并行执行之间的区别。我们学习了如何使用两种不同的方法来创建新线程。我们使用线程模块中的特殊 Result 类型来处理错误,并学习了如何检查当前线程是否正在恐慌。我们探讨了线程在进程内存中的布局。我们讨论了两种跨线程同步处理的技术——消息传递并发和共享状态并发,并提供了实际示例。作为其中的一部分,我们学习了 Rust 中的通道、Mutex 和 Arc,以及它们在编写并发程序中的作用。然后,我们讨论了 Rust 如何将数据类型分类为线程安全或不安全,并展示了如何暂停当前线程的执行。
这就结束了 Rust 中管理并发的章节。这也结束了本书的第二部分,该节讨论了在 Rust 中管理和控制系统资源。
现在,我们将继续本书的最后一部分——第三部分,涵盖高级主题。在下一章中,我们将介绍如何在 Rust 中执行设备 I/O,并通过一个示例项目来内化学习。
第三部分:高级主题
本节涵盖高级主题,包括与外围设备协同工作、网络原语和 TCP/UDP 通信、不安全的 Rust 以及与其他编程语言交互。示例项目包括编写一个程序来检测连接的 USB 设备的详细信息、编写一个带有源服务器的 TCP 反向代理,以及 FFI 的示例。
本节包含以下章节:
-
第十章, 与设备 I/O 协同工作
-
第十一章, 学习网络编程
-
第十二章, 编写不安全的 Rust 和 FFI
第十章:第十章:使用设备 I/O
在 第六章 中,我们介绍了如何使用 Rust 标准库执行文件 I/O 操作(如读取和写入文件)的详细情况。在类 Unix 操作系统中,文件是一个抽象概念,它不仅用于处理常规磁盘文件(用于存储数据),还用于与连接到机器的多种类型的设备进行交互。在本章中,我们将探讨 Rust 标准库的功能,使我们能够在 Rust 中对任何类型的设备进行读取和写入(也称为设备 I/O)。设备 I/O 是系统编程的一个基本方面,用于监控和控制连接到计算机的各种类型的设备,如键盘、USB 摄像头、打印机和外设。你可能想知道 Rust 为系统程序员提供了哪些支持来处理所有这些不同类型的设备。我们将随着章节的进行回答这个问题。
在本章中,我们将使用 Rust 标准库回顾 Unix/Linux 中 I/O 管理的基础知识,包括错误处理,然后编写一个程序来检测并打印连接的 USB 设备的详细信息。
我们将按以下顺序介绍这些主题:
-
理解 Linux 中的设备 I/O 基础知识
-
执行缓冲读取和写入
-
使用标准输入和输出
-
I/O 的链式操作和迭代器
-
处理错误和返回值
-
获取连接的 USB 设备的详细信息(项目)
到本章结束时,你将学会如何使用标准读取器和写入器,这是任何 I/O 操作的基础。你还将学会如何通过使用缓冲读取和写入来优化系统调用。我们将涵盖对进程的标准 I/O 流的读取和写入以及处理 I/O 操作的错误,以及学习遍历 I/O 的方法。这些概念将通过一个示例项目得到加强。
技术要求
使用以下命令验证 rustup、rustc 和 cargo 是否已正确安装:
rustup --version
rustc --version
cargo --version
本章代码的 Git 仓库可以在 github.com/PacktPublishing/Practical-System-Programming-for-Rust-Developers/tree/master/Chapter10/usb 找到。
为了运行和测试本书中的项目,你必须安装本机 libusb 库,使其可以通过 pkg-config 找到。
本书中的项目已在 macOS Catalina 10.15.6 上进行测试。
有关在 Windows 上构建和测试的说明,请参阅:github.com/dcuddeback/libusb-rs/issues/20
有关 libusb crate 环境设置的通用说明,请参阅:github.com/dcuddeback/libusb-rs
理解 Linux 中的设备 I/O 基础知识
在前面的章节中,我们看到了如何使用进程和线程来在 CPU 上调度工作,以及如何通过控制程序的内存布局来管理内存。除了 CPU 和内存,操作系统还管理系统的硬件设备。硬件设备的例子包括键盘、鼠标、硬盘、视频适配器、音频卡、网络适配器、扫描仪、摄像头以及其他 USB 设备。但是,这些物理硬件设备的特殊性被操作系统通过称为设备驱动程序的软件模块隐藏起来。设备驱动程序是进行设备 I/O 不可或缺的软件组件。让我们更深入地了解一下它们。
什么是设备驱动程序?
设备驱动程序是加载到内核中的共享库,其中包含执行低级硬件控制的功能。它们通过连接到设备的计算机总线或通信子系统与设备进行通信。它们针对每种设备类型(例如,鼠标或网络适配器)或设备类(例如,IDE 或 SCSI 磁盘控制器)特定。它们也针对特定的操作系统(例如,Windows 的设备驱动程序在 Linux 上即使对于相同的设备类型也无法工作)。
设备驱动程序处理它们所编写的设备(或设备类)的特殊性。例如,用于控制硬盘的设备驱动程序接收读取或写入由块号标识的某些文件数据的请求。设备驱动程序将块号转换为磁盘上的磁道、扇区和柱面号。它还初始化设备,检查设备是否在使用中,验证其函数调用输入参数的有效性,确定要发出的命令,并将它们发送到设备。它处理来自设备的中断,并将它们传达给调用程序。设备驱动程序还实现了设备支持的特定硬件协议,例如用于磁盘访问的SCSI/ATA/SATA或用于串行端口通信的UART。因此,设备驱动程序抽象出了控制设备的大量硬件特定细节。
操作系统(特别是内核)接受用户程序对设备访问和控制的系统调用,然后使用相应的设备驱动程序物理访问和控制设备。图 10.1说明了用户空间程序(例如,使用标准库与操作系统内核通信的 Rust 程序)如何使用系统调用来管理和控制各种类型的设备:

图 10.1 – Linux 中的设备 I/O
在第六章“在 Rust 中处理文件和目录”,我们了解到 Linux/Unix 有“一切皆文件”的哲学,这体现在 I/O 的通用性上。相同的系统调用,如open()、close()、read()和write(),可以应用于所有类型的 I/O,无论是常规文件(用于存储文本或二进制数据)、目录、设备文件还是网络连接。这意味着用户空间程序的程序员可以编写代码与设备通信和控制设备,而无需担心设备的协议和硬件细节,这得益于内核(系统调用)和设备驱动程序提供的抽象层。此外,Rust 标准库添加了另一层抽象,以提供设备无关的软件层,Rust 程序可以使用它进行设备 I/O。这是本章的主要关注点。
设备类型
在 Unix/Linux 中,设备被广泛分为三种类型:
-
字符设备以字节序列的形式发送或接收数据。例如,终端、键盘、鼠标、打印机和声卡。与常规文件不同,数据不能随机访问,只能按顺序访问。
-
块设备以固定大小的块存储信息,并允许随机访问这些块。文件系统、硬盘、磁带驱动器和 USB 摄像头是块设备的例子。文件系统安装在块设备上。
-
网络设备与字符设备类似,因为数据是按顺序读取的,但也有一些区别。数据使用网络协议以可变长度的数据包发送,操作系统和用户程序必须处理这些协议。网络适配器通常是一个硬件设备(有些例外,如环回接口,它是一个软件接口),它连接到网络(如以太网或Wi-Fi)。
硬件设备通过其类型(块或字符)和设备号来识别。设备号反过来又分为主设备号和次设备号。
当连接新的硬件时,内核需要与设备兼容的设备驱动程序,并且可以操作设备控制器硬件。正如之前讨论的,设备驱动程序本质上是一个低级、硬件处理函数的共享库,可以作为内核的一部分以特权方式运行。没有设备驱动程序,内核不知道如何操作设备。当程序尝试连接到设备时,内核在其表中查找相关信息的条目,并将控制权转交给设备驱动程序。对于块和字符设备,有单独的表。设备驱动程序在设备上执行所需的任务,并将控制权返回给操作系统内核。
例如,让我们看看一个网页服务器向网页浏览器发送页面。数据结构为一个HTTP 响应消息,其中作为其数据负载一部分发送的网页(HTML)。数据本身存储在内核中的缓冲区(数据结构)中,然后传递给TCP 层,接着是IP 层,然后是以太网设备驱动程序,然后是以太网适配器,最后到达网络。以太网设备驱动程序不了解任何连接,只处理数据包。同样,当需要将数据存储到磁盘上的文件时,数据会存储在缓冲区中,然后传递给文件系统设备驱动程序,然后传递给磁盘控制器,然后将其保存到磁盘(例如,硬盘、SSD 等)。本质上,内核依赖于设备驱动程序与设备进行接口。
设备驱动程序通常是内核的一部分(内核设备驱动程序),但也有一些用户空间设备驱动程序,它们抽象化了内核访问的细节。在本章的后面部分,我们将使用这样一个用户空间设备驱动程序来检测 USB 设备。
在本节中,我们讨论了设备 I/O 的基本知识,包括 Unix-like 系统中的设备驱动程序和设备类型。从下一节开始,我们将关注如何使用 Rust 标准库中的功能进行设备无关的 I/O。
执行缓冲读取和写入
读取和写入是执行在文件和流等 I/O 类型上的基本操作,对于与许多类型的系统资源一起工作非常重要。在本节中,我们将讨论在 Rust 中进行不同方式的读取和写入。我们将首先介绍核心特质——Read和Write——这些特质允许 Rust 程序在实现这些特质的对象上执行读取和写入操作(这些也被称为读取器和写入器)。然后,我们将看到如何进行缓冲读取和缓冲写入,这对于某些类型的读取和写入操作更有效。
让我们从基本的Read和Write特质开始。
根据哲学“一切皆文件”,Rust 标准库提供了两个特质——Read和Write——它们提供了读取和写入输入和输出的通用接口。这个特质为不同类型的 I/O 实现了,例如文件、TcpStream、标准输入和标准输出流。
以下代码展示了使用Read特质的示例。在这里,我们使用std::fs::File模块中的open()函数(我们之前学过)打开一个records.txt文件。然后,我们将std::io模块中的Read特质引入作用域,并使用这个特质的read()方法从文件中读取字节。相同的read()方法也可以用于从实现Read特质的任何其他实体中读取,例如网络套接字或标准输入流:
use std::fs::File;
use std::io::Read;
fn main() {
// Open a file
let mut f = File::open("records.txt").unwrap();
//Create a memory buffer to read from file
let mut buffer = [0; 1024];
// read from file into buffer
let _ = f.read(&mut buffer[..]).unwrap();
}
在项目根目录中创建一个名为 records.txt 的文件,并使用 cargo run 运行程序。你可以选择打印缓冲区的值,这将显示原始字节。
Read 和 Write 是基于字节的接口,当它们涉及对操作系统的持续系统调用时可能会变得低效。为了克服这一点,Rust 还提供了两个结构体,以实现缓冲读取和写入 – BufReader 和 BufWriter,它们具有内置的缓冲区并减少了调用操作系统的次数。
之前的示例可以重写如下,以使用 BufReader:
use std::fs::File;
use std::io::{BufRead, BufReader};
fn main() {
// Open a file
let f = File::open("records.txt").unwrap();
// Create a BufReader, passing in the file handle
let mut buf_reader = BufReader::new(f);
//Create a memory buffer to read from file
let mut buffer = String::new();
// read a line into the buffer
buf_reader.read_line(&mut buffer).unwrap();
println!("Read the following: {}", buffer);
}
代码更改(与前一个版本相比)已被突出显示。BufReader 使用 BufRead 特性,该特性已被引入作用域。我们不是直接从文件句柄读取,而是创建一个 BufReader 实例并将一行读取到这个结构中。BufReader 方法内部优化了对操作系统的调用。运行程序并验证文件中的值是否正确打印。
BufWriter 类似地缓冲对磁盘的写入,从而最小化系统调用。它可以像以下代码所示那样使用:
use std::fs::File;
use std::io::{BufWriter, Write};
fn main() {
// Create a file
let f = File::create("file.txt").unwrap();
// Create a BufWriter, passing in the file handle
let mut buf_writer = BufWriter::new(f);
//Create a memory buffer
let buffer = String::from("Hello, testing");
// write into the buffer
buf_writer.write(buffer.as_bytes()).unwrap();
println!("wrote the following: {}", buffer);
}
在显示的代码中,我们正在创建一个新文件以写入,同时也创建了一个新的 BufWriter 实例。然后我们将缓冲区中的值写入 BufWriter 实例。运行程序并验证指定的字符串值是否已写入项目根目录下的 file.txt 文件。注意,在这里,除了 BufWriter 之外,我们还需要将 Write 特性引入作用域,因为其中包含 write() 方法。
注意何时使用和何时不使用 BufReader 和 BufWriter:
-
BufReader和BufWriter可以加速那些对磁盘进行小而频繁的读取或写入的程序。如果读取或写入仅偶尔涉及大量数据,它们不会提供任何好处。 -
BufReader和BufWriter在从内存数据结构中读取或写入时并不提供帮助。
在本节中,我们看到了如何进行无缓冲和缓冲的读取和写入。在下一节中,我们将学习如何处理进程的标准输入和输出。
处理标准输入和输出
在 Linux/Unix 中,流 是进程与其环境之间的通信通道。默认情况下,为每个运行进程创建三个标准流:标准输入、标准输出和标准错误。流是一个具有两端点的通信通道。一端连接到进程,另一端连接到另一个系统资源。例如,标准输入可以被进程用来从键盘或另一个进程读取字符或文本。同样,标准输出流可以被进程用来将一些字符发送到终端或文件。在许多现代程序中,进程的标准错误连接到一个日志文件,这使得分析和调试错误变得更容易。
Rust 标准库提供了与标准输入和输出流交互的方法。std::io模块中的Stdin结构体表示进程输入流的句柄。此句柄实现了我们在上一节中提到的Read特质。
以下代码示例展示了如何与进程的标准输入和标准输出流进行交互。在显示的代码中,我们从标准输入读取一行到缓冲区。然后,我们将缓冲区的内容写回进程的标准输出。请注意,在这里,单词process指的是您所编写的正在运行的程序。您本质上是从标准输入和标准输出分别读取和写入的:
use std::io::{self, Write};
fn main() {
//Create a memory buffer to read from file
let mut buffer = String::new();
// read a line into the buffer
let _ = io::stdin().read_line(&mut buffer).unwrap();
// Write the buffer to standard output
io::stdout().write(&mut buffer.as_bytes()).unwrap();
}
使用cargo run运行程序,输入一些文本,然后按Enter键。您将在终端上看到文本被回显。
Stdin,它是进程输入流的句柄,是对全局输入数据缓冲区的共享引用。同样,Stdout,它是进程的输出流,是对全局数据缓冲区的共享引用。由于Stdin和Stdout是共享数据的引用,为了确保这些数据缓冲区的独占使用,句柄可以被锁定。例如,std::io模块中的StdinLock结构体代表对Stdin句柄的锁定引用。同样,std::io模块中的StdoutLock结构体代表对Stdout句柄的锁定引用。以下代码示例展示了如何使用锁定引用的示例:
use std::io::{Read, Write};
fn main() {
//Create a memory buffer
let mut buffer = [8; 1024];
// Get handle to input stream
let stdin_handle = std::io::stdin();
// Lock the handle to input stream
let mut locked_stdin_handle = stdin_handle.lock();
// read a line into the buffer
locked_stdin_handle.read(&mut buffer).unwrap();
// Get handle to output stream
let stdout_handle = std::io::stdout();
// Lock the handle to output stream
let mut locked_stdout_handle = stdout_handle.lock();
// Write the buffer to standard output
locked_stdout_handle.write(&mut buffer).unwrap();
}
在显示的代码中,在读取和写入之前,将标准输入和输出流句柄锁定。
我们可以类似地向标准错误流写入。以下是一个代码示例:
use std::io::Write;
fn main() {
//Create a memory buffer
let buffer = b"Hello, this is error message from
standard
error stream\n";
// Get handle to output error stream
let stderr_handle = std::io::stderr();
// Lock the handle to output error stream
let mut locked_stderr_handle = stderr_handle.lock();
// write into error stream from buffer
locked_stderr_handle.write(buffer).unwrap();
}
在显示的代码中,我们使用stderr()函数构建标准错误流的句柄。然后,我们锁定此句柄并向其写入一些文本。
在本节中,我们看到了如何使用 Rust 标准库与进程的标准输入、标准输出和标准错误流进行交互。回想一下,在前一章关于管理并发的内容中,我们看到了如何从父进程读取和写入子进程的标准输入和输出流。
在下一节中,让我们看看一些可以在 Rust 中进行 I/O 的功能编程结构。
I/O 的链式操作和迭代器
在本节中,我们将探讨如何使用迭代器和链式操作与std::io模块一起使用。
std::io模块提供的许多数据结构都具有内置的while和for循环。以下是一个使用BufReader结构体和lines()迭代器的示例,BufReader是std::io模块的一部分。这个程序在循环中从标准输入流中读取行:
use std::io::{BufRead, BufReader};
fn main() {
// Create handle to standard input
let s = std::io::stdin();
//Create a BufReader instance to optimize sys calls
let file_reader = BufReader::new(s);
// Read from standard input line-by-line
for single_line in file_reader.lines() {
println!("You typed:{}", single_line.unwrap());
}
}
在下面的代码中,我们创建了一个指向标准输入流的句柄,并将其传递给一个BufReader结构体。这个结构体实现了BufRead特质,它有一个lines()方法,该方法返回一个遍历读取器行数的迭代器。这有助于我们在终端逐行输入文本,并让我们的运行程序读取它。在终端输入的文本会被回显到终端。执行cargo run,输入一些文本,然后按Enter键。根据需要重复此步骤。使用Ctrl + C退出程序。
同样,迭代器也可以用来逐行从文件中读取(而不是从我们在上一个示例中看到的标准输入中读取)。这里有一个代码片段的示例:
use std::fs::File;
use std::io::{BufRead, BufReader};
fn main() {
// Open a file for reading
let f = File::open("file.txt").unwrap();
//Create a BufReader instance to optimize sys calls
let file_reader = BufReader::new(f);
// Read from standard input line-by-line
for single_line in file_reader.lines() {
println!("Line read from file :{}",
single_line.unwrap());
}
}
在项目根目录下创建一个名为file.txt的文件。在这个文件中输入几行文本。然后,使用cargo run运行程序。你会看到文件内容被打印到终端。
我们已经看到了如何使用std::io模块中的迭代器。现在让我们看看另一个概念:链式操作。
std::io模块中的Read特质有一个chain()方法,它允许我们将多个BufReader链接在一起形成一个句柄。以下是一个如何创建一个将两个文件组合在一起的单一链式句柄的示例,以及如何从这个句柄中读取的示例:
use std::fs::File;
use std::io::Read;
fn main() {
// Open two file handles for reading
let f1 = File::open("file1.txt").unwrap();
let f2 = File::open("file2.txt").unwrap();
//Chain the two file handles
let mut chained_handle = f1.chain(f2);
// Create a buffer to read into
let mut buffer = String::new();
// Read from chained handle into buffer
chained_handle.read_to_string(&mut buffer).unwrap();
// Print out the value read into the buffer
println!("Read from chained handle:\n{}", buffer);
}
代码中使用chain()方法的语句已被突出显示。其余的代码相当直观,因为它与我们之前看到的示例类似。确保在项目根目录下创建两个文件,file1.txt和file2.txt,并在每个文件中输入几行文本。使用cargo run运行程序。你会看到两个文件的数据逐行打印出来。
在本节中,我们看到了如何使用迭代器以及如何将读取器链接在一起。在下一节中,让我们看看 I/O 操作的错误处理。
处理错误和返回值
在本节中,我们将了解std::io模块中内置的错误处理支持。以适当的方式处理可恢复的错误可以使 Rust 程序更加健壮。
在我们之前看到的代码示例中,我们使用了unwrap()函数从std::io模块的方法和相关函数(如Read、Write、BufReader和BufWriter)中提取返回值。然而,这并不是处理错误的方法。std::io模块有一个专门的Result类型,它由该模块中可能产生错误的任何函数或方法返回。
让我们重写之前的示例(链式读取器),使用io::Result类型作为函数的返回值。这允许我们使用?运算符直接从main()函数传递错误,而不是使用unwrap()函数:
use std::fs::File;
use std::io::Read;
fn main() -> std::io::Result<()> {
// Open two file handles for reading
let f1 = File::open("file1.txt")?;
let f2 = File::open("file3.txt")?;
//Chain the two file handles
let mut chained_handle = f1.chain(f2);
// Create a buffer to read into
let mut buffer = String::new();
// Read from chained handle into buffer
chained_handle.read_to_string(&mut buffer)?;
println!("Read from chained handle: {}", buffer);
Ok(())
}
与错误处理相关的代码已被突出显示。使用cargo run运行程序,这次确保项目根目录下既不存在file1.txt也不存在file3.txt。
你将看到错误信息打印到终端。
在我们刚才看到的代码中,我们只是在调用操作系统时传播接收到的错误。现在让我们尝试以更积极的方式处理错误。下面的代码示例显示了相同代码的自定义错误处理:
use std::fs::File;
use std::io::Read;
fn read_files(handle: &mut impl Read) ->
std::io::Result<String> {
// Create a buffer to read into
let mut buffer = String::new();
// Read from chained handle into buffer
handle.read_to_string(&mut buffer)?;
Ok(buffer)
}
fn main() {
let mut chained_handle;
// Open two file handles for reading
let file1 = "file1.txt";
let file2 = "file3.txt";
if let Ok(f1) = File::open(file1) {
if let Ok(f2) = File::open(file2) {
//Chain the two file handles
chained_handle = f1.chain(f2);
let content = read_files(&mut chained_handle);
match content {
Ok(text) => println!("Read from chained
handle:\n{}", text),
Err(e) => println!("Error occurred in
reading files: {}", e),
}
} else {
println!("Unable to read {}", file2);
}
} else {
println!("Unable to read {}", file1);
}
}
你会注意到我们创建了一个新的函数,该函数将std::io::Result返回给main()函数。我们在各种操作中处理错误,例如从文件读取和从链式读取器读取。
首先,使用cargo run运行程序,确保file1.txt和file2.txt都存在。你将看到两个文件的内容都打印到终端。通过移除其中一个文件来重新运行程序。你应该看到我们代码中的自定义错误消息。
通过这种方式,我们结束了关于错误处理的章节。现在让我们继续到本章的最后一节,我们将通过一个项目来检测和显示连接到计算机的 USB 设备的详细信息。
获取连接的 USB 设备的详细信息(项目)
在本节中,我们将演示在 Rust 中与设备一起工作的示例。所选的示例是显示计算机上所有连接的 USB 设备的详细信息。我们将使用libusb,这是一个 C 库,有助于与 USB 设备交互。Rust 中的libusb包是 C libusb库的安全包装。让我们首先看看设计。
设计项目
这将是如何工作的:
-
当 USB 设备插入到计算机中时,计算机总线上的电信号会触发计算机上的USB 控制器(硬件设备)。
-
USB 控制器在 CPU 上引发中断,然后执行内核中注册的该中断的处理程序。
-
当从 Rust 程序通过 Rust
libusb包装器包发起调用时,该调用被路由到libusbC 库,然后该库反过来在内核上执行系统调用来读取对应 USB 设备的设备文件。我们在本章前面已经看到 Unix/Linux 如何启用标准的read()和write()来进行 I/O。 -
当系统调用从内核返回时,
libusb库将 syscall 的值返回给我们的 Rust 程序。
我们使用libusb库,因为从头开始编写 USB 设备驱动程序需要实现 USB 协议规范,而编写设备驱动程序本身就是另一本书的主题。让我们看看我们程序的设计:

图 10.2 – USB 检测器项目设计
图 10.2 展示了程序中的结构和函数。以下是数据结构的描述:
-
USBList:检测到的 USB 设备列表。 -
USBDetails:这包含我们希望通过此程序检索的 USB 设备详细信息列表。 -
USBError:自定义错误处理。
这些是我们将要编写的函数:
-
get_device_information(): 用于根据设备引用和设备句柄检索所需设备详情的函数。 -
write_to_file(): 将设备详情写入输出文件的函数。 -
main(): 这是程序的入口点。它实例化一个新的libusb::Context,检索连接设备的列表,然后遍历列表以对每个设备调用get_device_information()。检索到的详情将被打印到终端,并使用我们之前看到的write_to_file()函数写入文件。
我们现在可以开始编写代码了。
编写数据结构和实用函数
在本节中,我们将编写用于存储 USB 设备列表和 USB 详情以及用于自定义错误处理的数据结构,我们还将编写一些实用函数:
-
让我们从创建一个新的项目开始:
cargo new usb && cd usb -
让我们将
libusb包添加到Cargo.toml中:[dependencies] libusb = "0.3.0" -
现在,我们将分部分查看代码。将此项目的所有代码添加到
usb/src/main.rs中。这里是模块导入:
use libusb::{Context, Device, DeviceHandle}; use std::fs::File; use std::io::Write; use std::time::Duration; use std::fmt;我们正在导入
libusb模块和 Rust 标准库中的几个模块。fs::File和io::Write用于写入输出文件,result::Result是函数的返回值,而time::Duration用于与libusb库一起工作。 -
让我们现在看看数据结构:
#[derive(Debug)] struct USBError { err: String, } struct USBList { list: Vec<USBDetails>, } #[derive(Debug)] struct USBDetails { manufacturer: String, product: String, serial_number: String, bus_number: u8, device_address: u8, vendor_id: u16, product_id: u16, maj_device_version: u8, min_device_version: u8, }USBError用于自定义错误处理,USBList用于存储检测到的 USB 设备列表,而USBDetails用于捕获每个 USB 设备的详情列表。 -
让我们为
USBList结构体实现Display特质,以便可以对结构体的内容进行自定义格式化以打印:impl fmt::Display for USBList { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { Ok(for usb in &self.list { writeln!(f, "\nUSB Device details")?; writeln!(f, "Manufacturer: {}", usb.manufacturer)?; writeln!(f, "Product: {}", usb.product)?; writeln!(f, "Serial number: {}", usb.serial_number)?; writeln!(f, "Bus number: {}", usb.bus_number)?; writeln!(f, "Device address: {}", usb.device_address)?; writeln!(f, "Vendor Id: {}", usb.vendor_id)?; writeln!(f, "Product Id: {}", usb.product_id)?; writeln!(f, "Major device version: {}", usb.maj_device_version)?; writeln!(f, "Minor device version: {}", usb.min_device_version)?; }) } } -
接下来,我们将为
USBError结构体实现From特质,以便在调用?操作符时,将来自libusb包和 Rust 标准库的错误自动转换为USBError类型:impl From<libusb::Error> for USBError { fn from(_e: libusb::Error) -> Self { USBError { err: "Error in accessing USB device".to_string(), } } } impl From<std::io::Error> for USBError { fn from(e: std::io::Error) -> Self { USBError { err: e.to_string() } } } -
让我们接下来看看将所有连接设备的详情写入输出文件的函数:
//Function to write details to output file fn write_to_file(usb: USBList) -> Result<(), USBError> { let mut file_handle = File::create ("usb_details.txt")?; write!(file_handle, "{}\n", usb)?; Ok(()) }
我们现在可以继续编写main()函数。
编写 main()函数
在本节中,我们将编写main()函数,该函数设置设备上下文,获取连接的 USB 设备列表,然后遍历设备列表以检索每个设备的详情。我们还将编写一个函数来打印设备详情:
-
我们将从
main()函数开始:fn main() -> Result<(), USBError> { // Get libusb context let context = Context::new()?; //Get list of devices let mut device_list = USBList { list: vec![] }; for device in context.devices()?.iter() { let device_desc = device.device_descriptor()?; let device_handle = context .open_device_with_vid_pid( device_desc.vendor_id(), device_desc.product_id()) .unwrap(); // For each USB device, get the information let usb_details = get_device_information( device, &device_handle)?; device_list.list.push(usb_details); } println!("\n{}", device_list); write_to_file(device_list)?; Ok(()) }在
main()函数中,我们首先创建一个新的libusb Context,它可以返回连接设备的列表。然后,我们遍历从Context结构体获得的设备列表,并对每个 USB 设备调用get_device_information()函数。最后,通过调用我们之前看到的write_to_file()函数,将详情打印到输出文件。 -
为了结束代码,让我们编写一个获取设备详情的函数:
// Function to print device information fn get_device_information(device: Device, handle: &DeviceHandle) -> Result<USBDetails, USBError> { let device_descriptor = device.device_descriptor()?; let timeout = Duration::from_secs(1); let languages = handle.read_languages(timeout)?; let language = languages[0]; // Get device manufacturer name let manufacturer = handle.read_manufacturer_string( language, &device_descriptor, timeout)?; // Get device USB product name let product = handle.read_product_string( language, &device_descriptor, timeout)?; //Get product serial number let product_serial_number = match handle.read_serial_number_string( language, &device_descriptor, timeout) { Ok(s) => s, Err(_) => "Not available".into(), }; // Populate the USBDetails struct Ok(USBDetails { manufacturer, product, serial_number: product_serial_number, bus_number: device.bus_number(), device_address: device.address(), vendor_id: device_descriptor.vendor_id(), product_id: device_descriptor.product_id(), maj_device_version: device_descriptor.device_version().0, min_device_version: device_descriptor.device_version().1, }) }
这就结束了代码部分。请确保将 USB 设备(如 U 盘)连接到计算机上。使用cargo run运行代码。你应该会在终端看到连接的 USB 设备列表,并且这些信息也会写入到输出文件usb_details.txt中。
注意,在这个例子中,我们展示了如何使用外部 crate(用于检索 USB 设备详情)和标准库(用于写入输出文件)进行文件 I/O。我们使用一个通用的错误处理结构体统一了错误处理,并自动将错误类型转换为这种自定义错误类型。
Rust 的 crate 生态系统(crates.io)有类似 crate 可以用来与其它类型的设备和文件系统交互。你可以尝试使用它们。
这就结束了关于编写程序获取 USB 详细信息的章节。
摘要
在本章中,我们回顾了 Unix/Linux 设备管理的基石概念。我们探讨了如何使用std::io模块进行缓冲读取和写入。然后我们学习了如何与进程的标准输入、标准输出和标准错误流进行交互。我们还看到了如何将读取器链在一起,并使用迭代器从设备中读取。接着我们研究了std::io模块的错误处理功能。最后,我们通过一个项目来检测连接的 USB 设备列表,并将每个 USB 设备的详细信息打印到终端和输出文件中。
Rust 标准库为在任意类型的设备上进行 I/O 操作提供了一个干净的抽象层。这鼓励 Rust 生态系统为任何类型的设备实现这些标准接口,使得 Rust 系统程序员能够以统一的方式与不同的设备进行交互。继续 I/O 主题,在下一章中,我们将学习如何使用 Rust 标准库进行网络 I/O 操作。
第十一章:第十一章:学习网络编程
在上一章中,我们学习了如何在 Rust 程序中与外围设备通信。在本章中,我们将关注另一个重要的系统编程主题——网络。
大多数现代操作系统,包括 Unix/Linux 和 Windows 变体,都原生支持使用 TCP/IP 进行网络。你知道如何使用 TCP/IP 将字节流或消息从一台计算机发送到另一台计算机吗?你想了解 Rust 为在不同机器上运行的两个进程之间的同步网络通信提供的语言支持吗?你对学习配置 TCP 和 UDP 套接字、在 Rust 中处理网络地址和监听器的基础感兴趣吗?那么,继续阅读。
我们将按以下顺序介绍这些主题:
-
在 Linux 中回顾网络基础知识
-
理解 Rust 标准库中的网络原语
-
在 Rust 中使用 TCP 和 UDP 进行编程
-
编写 TCP 反向代理(项目)
到本章结束时,你将学会如何处理网络地址、确定地址类型以及进行地址转换。你还将学习如何创建和配置套接字以及查询它们。你将使用 TCP 监听器、创建 TCP 套接字服务器并接收数据。最后,你将通过一个示例项目将这些概念付诸实践。
学习这些主题非常重要,因为基于套接字的编程使用 TCP 或 UDP 构成了编写分布式程序的基础。套接字帮助不同(甚至相同)机器上的两个进程相互建立通信并交换信息。它们构成了互联网上几乎所有 Web 和分布式应用的基础,包括互联网浏览器如何访问网页以及移动应用程序如何从 API 服务器检索数据。在本章中,你将了解 Rust 标准库为基于套接字的网络通信提供的支持类型。
技术要求
使用以下命令验证rustup、rustc和cargo是否已正确安装:
rustup --version
rustc --version
cargo --version
本章中代码的 Git 仓库可以在github.com/PacktPublishing/Practical-System-Programming-for-Rust-Developers/tree/master/Chapter11找到。
在 Linux 中回顾网络基础知识
互联网连接全球的多个不同网络,使网络中的机器能够以不同的方式相互通信,包括请求-响应模型(同步)、异步消息和发布-订阅风格的通告。图 11.1展示了两个网络之间连接的一个示例:

图 11.1 – 连接两个网络的互联网路由器
互联网还提供了网络协议和标准的抽象形式,以简化不同网络上的主机之间的通信。
标准的例子包括一个通用的主机寻址格式,一个由主机地址和端口号组合来定义网络端点。IPv4 地址的主机地址是一个32 位数字,IPv6 地址的主机地址是一个128 位数字。
网络协议的例子包括网络浏览器从网络服务器检索文档,域名系统(DNS)将域名映射到主机地址,IP 协议将数据包打包并在互联网上路由,以及 TCP 为 IP 数据包添加可靠性和错误处理。
特别是,网络协议在定义不同网络上的不同主机上运行的程序如何传输和解释信息方面非常重要。TCP/IP 协议栈是我们日常使用的互联网的基础,它使我们能够实现信息、交易和娱乐的数字世界。
图 11.2显示了分层 TCP/IP 协议栈:

图 11.2 – 连接两个网络的互联网路由器
在上一章中,我们讨论了设备驱动程序。在图 11.2中,显示的TCP/IP 协议栈的最低层——数据链路层——包括与用于主机之间通信的网络介质(例如,同轴电缆、光纤或无线)对应的设备驱动程序和网络接口卡。数据链路层将来自更高网络(IP)层的数据包组装成数据帧,并通过物理链路传输它们。
TCP/IP 协议栈的下一层是IP 层,这是 TCP/IP 堆栈中最重要的一层。它将数据组装成数据包并发送到数据链路层。IP 层还负责在互联网上路由数据。这是通过为每个传输的数据报(数据包)添加一个头部来实现的,该头部包括应将数据包传输到的远程主机的地址。从主机 A 发送到主机 B 的两个数据包可以通过互联网采取不同的路由。IP 是一种无连接协议,这意味着在两个主机之间没有创建用于多步通信的通信通道。这一层只是将数据包从一个主机的 IP 地址发送到另一个主机的 IP 地址,而不提供任何保证。
TCP/IP 协议栈的下一层是传输层。在这里,互联网上使用两种流行的协议——TCP 和 UDP。TCP代表传输控制协议,而UDP是用户数据报协议。虽然网络(IP)层关注在两个主机之间发送数据包,但传输层(TCP 和 UDP)关注在同一个主机或不同主机上运行的两个进程(应用程序或程序)之间的数据流发送。
如果有两个应用程序运行在单个主机的 IP 地址上,唯一标识每个应用程序的方法是通过使用一个端口号。每个参与网络通信的应用程序都会监听一个特定的端口,它是一个 16 位的数字。
流行端口的例子有80用于HTTP协议,443用于HTTPS协议,以及22用于SSH协议。IP 地址和端口号的组合称为套接字。我们将在本章中看到如何使用 Rust 标准库来处理套接字。UDP,像 IP 一样,是无连接的,并且不包含任何可靠性机制。但它的速度比 TCP 快,开销低。它用于高级服务,如 DNS,以获取与域名对应的宿主 IP 地址。
与 UDP 相比,TCP 为两个端点(应用程序/用户空间程序)提供了一个面向连接、可靠的通信通道,可以在其中交换字节流,同时保持数据序列。它包含诸如错误情况下的重传、接收到的数据包的确认和超时等特性。我们将在本章详细讨论基于 TCP 的通信,并在稍后构建一个使用 TCP 套接字通信的反向代理。
TCP/IP 协议栈的最高层是应用层。虽然 TCP 层是面向连接的,并且与字节流一起工作,但它对传输的消息的语义一无所知。这是由应用层提供的。例如,HTTP,这是互联网上最受欢迎的应用协议,使用 HTTP 请求和响应消息在HTTP 客户端(例如,互联网浏览器)和HTTP 服务器(例如,Web 服务器)之间进行通信。应用层读取从 TCP 层接收到的字节流,并将它们解释为 HTTP 消息,然后由我们用 Rust 或其他语言编写的应用程序程序处理。Rust 生态系统中有几个库(或 crate)实现了 HTTP 协议,因此 Rust 程序可以利用它们(或编写自己的)来发送和接收 HTTP 消息。在本章的示例项目中,我们将编写一些代码来解释传入的 HTTP 请求消息,并发送 HTTP 响应消息。
Rust 标准库中用于网络通信的主要模块是 std::net。它专注于编写使用 TCP 和 UDP 进行通信的代码。Rust 的 std::net 模块不直接处理 TCP/IP 协议套件的链路层或应用层。有了这个背景,我们就可以理解 Rust 标准库为 TCP 和 UDP 通信提供的网络原语。
理解 Rust 标准库中的网络原语
在本节中,我们将讨论 Rust 标准库中用于网络的基础数据结构。图 11.3 列出了常用的数据结构:

图 11.3 – Rust 标准库中的网络原语
让我们逐个查看数据结构:
-
Ipv4Addr:这是一个结构体,用于存储表示 IPv4 地址的 32 位整数,并提供相关函数和方法来设置和查询地址值。 -
Ipv6Addr:这是一个结构体,用于存储表示 IPv6 地址的 128 位整数,并提供相关函数和方法来查询和设置地址值。 -
SocketAddrV4:这是一个表示互联网域套接字的结构体。它存储一个 IPv4 地址和一个 16 位端口号,并提供相关函数和方法来设置和查询套接字值。 -
SocketAddrV6:这是一个表示互联网域套接字的结构体。它存储一个 IPv6 地址和一个 16 位端口号,并提供相关函数和方法来设置和查询套接字值。 -
IpAddr:这是一个枚举,有两个变体 –V4(Ipv4Addr)和V6(Ipv6Addr)。这意味着它可以存储 IPv4 主机地址或 IPv6 主机地址。 -
SocketAddr:这是一个枚举,有两个变体 –V4(SocketAddrV4)和V6(SocketAddrV6)。这意味着它可以存储 IPv4 套接字地址或 IPv6 套接字地址。注意
IPv6 地址的大小可能会根据目标操作系统架构而变化。
现在,让我们看看如何使用它们的几个示例。我们将从创建 IPv4 和 IPv6 地址开始。
在下面的示例中,我们使用 std::net 模块创建 IPv4 和 IPv6 地址,并使用内置方法查询创建的地址。is_loopback() 方法确认地址是否对应于 localhost,而 segments() 方法返回 IP 地址的各个段。注意,std::net 模块提供了一个特殊常量,Ipv4Addr::LOCALHOST,它可以用来使用 localhost(环回)地址初始化 IP 地址:
use std::net::{Ipv4Addr, Ipv6Addr};
fn main() {
// Create a new IPv4 address with four 8-bit integers
let ip_v4_addr1 = Ipv4Addr::new(106, 201, 34, 209);
// Use the built-in constant to create a new loopback
// (localhost) address
let ip_v4_addr2 = Ipv4Addr::LOCALHOST;
println!(
"Is ip_v4_addr1 a loopback address? {}",
ip_v4_addr1.is_loopback()
);
println!(
"Is ip_v4_addr2 a loopback address? {}",
ip_v4_addr2.is_loopback()
);
//Create a new IPv6 address with eight 16-bit
// integers, represented in hex
let ip_v6_addr = Ipv6Addr::new(2001, 0000, 3238,
0xDFE1, 0063, 0000, 0000, 0xFEFB);
println!("IPV6 segments {:?}", ip_v6_addr.segments());
}
以下示例展示了如何使用 IpAddr 枚举。在这个示例中,展示了如何使用 IpAddr 枚举创建 IPv4 和 IPv6 地址。IpAddr 枚举帮助我们以更通用的方式在我们的程序数据结构中定义 IP 地址,并允许我们在程序中灵活地处理 IPv4 和 IPv6 地址:
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
fn main() {
// Create an ipv4 address
let ip_v4_addr = IpAddr::V4(Ipv4Addr::new(106, 201, 34,
209));
// check if an address is ipv4 or ipv6 address
println!("Is ip_v4_addr an ipv4 address? {}",
ip_v4_addr.is_ipv4());
println!("Is ip_v4_addr an ipv6 address? {}",
ip_v4_addr.is_ipv6());
// Create an ipv6 address
let ip_v6_addr = IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0,
0, 0, 0, 1));
println!("Is ip_v6_addr an ipv6 address? {}",
ip_v6_addr.is_ipv6());
}
现在,让我们将注意力转向套接字。如前所述,套接字由一个 IP 地址和一个端口号组成。Rust 为 IPv4 和 IPv6 套接字都有单独的数据结构。接下来,让我们看一个示例。在这里,我们创建了一个新的 IPv4 套接字,并使用 ip() 和 port() 方法分别查询构建的套接字中的 IP 地址和端口号:
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
fn main() {
// Create an ipv4 socket
let socket = SocketAddr::new(IpAddr::V4(
Ipv4Addr::new(127,0,0,1)),8000);
println!("Socket address is {}, port is {}",
socket.ip(), socket.port());
println!("Is this IPv6 socket?{}",socket.is_ipv6());
}
IP 地址和套接字是使用 Rust 标准库进行网络编程的基础数据结构。在下一节中,我们将看到如何编写可以在 TCP 和 UDP 协议上通信的 Rust 程序。
使用 Rust 编程 TCP 和 UDP
如前所述,TCP 和 UDP 是互联网的基本传输层网络协议。在本节中,我们首先编写一个 UDP 服务器和客户端。然后,我们将看看如何使用 TCP 做同样的事情。
创建一个名为 tcpudp 的新项目,我们将在此项目中编写 TCP 和 UDP 服务器和客户端:
cargo new tcpudp && cd tcpudp
让我们首先看看使用 UDP 的网络通信。
编写 UDP 服务器和客户端
在本节中,我们将学习如何配置 UDP 套接字,以及如何发送和接收数据。我们将编写一个 UDP 服务器和一个 UDP 客户端。
从 UDP 服务器开始
在下面的示例中,我们通过使用 UdpSocket::bind 将其绑定到本地套接字来创建一个 UDP 服务器。然后,我们创建一个固定大小的缓冲区,并在循环中监听传入的数据流。如果收到数据,我们将通过将数据回显给发送者来创建一个新的线程以处理数据。由于我们已经介绍了如何在 第九章 中创建新线程,管理并发,这里不再需要解释:
tcpudp/src/bin/udp-server.rs
use std::str;
use std::thread;
fn main() {
let socket = UdpSocket::bind("127.0.0.1:3000").expect(
"Unable to bind to port");
let mut buffer = [0; 1024];
loop {
let socket_new = socket.try_clone().expect(
"Unable to clone socket");
match socket_new.recv_from(&mut buffer) {
Ok((num_bytes, src_addr)) => {
thread::spawn(move || {
let send_buffer = &mut
buffer[..num_bytes];
println!(
"Received from client:{}",
str::from_utf8(
send_buffer).unwrap()
);
let response_string =
format!("Received this: {}",
String::from_utf8_lossy(
send_buffer));
socket_new
.send_to(&response_string
.as_bytes(), &src_addr)
.expect("error in sending datagram
to remote socket");
});
}
Err(err) => {
println!("Error in receiving datagrams over
UDP: {}", err);
}
}
}
}
编写一个 UDP 客户端以向服务器发送数据包
在下面的代码中,我们首先要求标准库绑定到本地端口(通过提供一个地址端口组合 0.0.0.0:0,这允许操作系统选择一个临时的 IP 地址/端口来发送数据报)。然后,我们尝试连接到运行服务器的远程套接字,并在连接失败时显示错误。在成功连接的情况下,我们使用 peer_addr() 方法打印出对等方的套接字地址。最后,我们使用 send() 方法向远程套接字(服务器)发送消息:
tcpudp/src/bin/udp-client.rs
use std::net::UdpSocket;
fn main() {
// Create a local UDP socket
let socket = UdpSocket::bind("0.0.0.0:0").expect(
"Unable to bind to socket");
// Connect the socket to a remote socket
socket
.connect("127.0.0.1:3000")
.expect("Could not connect to UDP server");
println!("socket peer addr is {:?}",
socket.peer_addr());
// Send a datagram to the remote socket
socket
.send("Hello: sent using send() call".as_bytes())
.expect("Unable to send bytes");
}
使用以下命令运行 UDP 服务器:
cargo run --bin udp-server
在另一个终端中,运行以下 UDP 客户端:
cargo run --bin udp-client
你将看到服务器接收到的消息,该消息是从客户端发送的。
我们已经看到了如何编写 Rust 程序来进行 UDP 通信。现在,让我们看看 TCP 通信是如何进行的。
编写 TCP 服务器和客户端
在本节中,我们将学习如何配置 TCP 监听器,创建 TCP 套接字服务器,并在 TCP 上发送和接收数据。我们将编写一个 TCP 服务器和一个 TCP 客户端。
我们将从 TCP 服务器开始。在下面的代码中,我们使用 TcpListener::bind 创建一个监听套接字的 TCP 服务器。然后,我们使用 incoming() 方法,该方法返回一个传入连接的迭代器。每个连接返回一个可以通过 stream.read() 方法读取的 TCP 流。我们正在读取数据并打印出值。此外,我们使用 stream.write() 方法通过连接回显接收到的数据:
tcpudp/src/bin/tcp-server.rs
use std::io::{Read, Write};
use std::net::TcpListener;
fn main() {
let connection_listener = TcpListener::bind(
"127.0.0.1:3000").unwrap();
println!("Running on port 3000");
for stream in connection_listener.incoming() {
let mut stream = stream.unwrap();
println!("Connection established");
let mut buffer = [0; 100];
stream.read(&mut buffer).unwrap();
println!("Received from client: {}",
String::from_utf8_lossy(&buffer));
stream.write(&mut buffer).unwrap();
}
}
这就完成了 TCP 服务器的代码。现在让我们编写一个 TCP 客户端,向 TCP 服务器发送一些数据。
在下面的 TCP 客户端代码中,我们使用 TcpStream::connect 函数连接到服务器正在监听的远程套接字。此函数返回一个TCP 流,可以读取和写入(如前例所示)。在这里,我们首先将一些数据写入 TCP 流,然后读取从服务器收到的响应:
tcpudp/src/bin/tcp-client.rs
use std::io::{Read, Write};
use std::net::TcpStream;
use std::str;
fn main() {
let mut stream = TcpStream::connect(
"localhost:3000").unwrap();
let msg_to_send = "Hello from TCP client";
stream.write(msg_to_send.as_bytes()).unwrap();
let mut buffer = [0; 200];
stream.read(&mut buffer).unwrap();
println!(
"Got echo back from server:{:?}",
str::from_utf8(&buffer)
.unwrap()
.trim_end_matches(char::from(0))
);
}
使用以下命令运行 TCP 服务器:
cargo run --bin tcp-server
在另一个终端中,使用以下命令运行 TCP 客户端:
cargo run --bin tcp-client
你将看到客户端发送的消息在服务器上被接收并回显。
这就完成了关于使用 Rust 标准库进行 TCP 和 UDP 通信的章节。在下一节中,我们将使用到目前为止学到的概念来构建一个 TCP 反向代理。
编写 TCP 反向代理(项目)
在本节中,我们将仅使用 Rust 标准库演示 TCP 反向代理的基本功能,而不使用任何外部库或框架。
代理服务器是一种在互联网上跨越多个网络导航时使用的中间件软件服务。代理服务器有两种类型——正向代理和反向代理。正向代理作为向互联网发出请求的客户端的中介,而反向代理作为服务器的中介。图 11.4 展示了正向和反向代理服务器的作用:

图 11.4 – 代理服务器类型
正向代理作为一组客户端机器访问互联网的网关。它们帮助单个客户端机器在浏览互联网时隐藏其 IP 地址。它们还帮助在网络内的机器强制执行访问互联网的组织策略,例如限制访问的网站。
当正向代理代表客户端行动时,反向代理代表主机(例如,Web 服务器)行动。它们隐藏后端服务器的身份信息,让客户端无法得知。客户端只向反向代理服务器地址/域名发送请求,而反向代理服务器则知道如何将这个请求路由到后端服务器(有时也称为原始服务器),并将从原始服务器接收到的响应返回给请求的客户端。反向代理还可以用于执行其他功能,如负载均衡、缓存和压缩。然而,我们将只通过将客户端接收到的请求定向到后端原始服务器,并将响应路由回请求的客户端来演示反向代理的核心概念。
为了演示一个工作的反向代理,我们将构建两个服务器:
-
原始服务器:TCP 服务器(理解有限的 HTTP 语义)。
-
反向代理服务器:到达此服务器的客户端请求将被定向到原始服务器,原始服务器的响应将被路由回客户端。
创建一个新项目来编写原始和代理服务器:
cargo new tcpproxy && cd tcpproxy
创建两个文件:tcpproxy/src/bin/origin.rs 和 tcpproxy/src/bin/proxy.rs。
让我们从原始服务器的代码开始。这个服务器将执行以下操作:
-
接收传入的 HTTP 请求。
-
提取请求的第一行(称为HTTP 请求行)。
-
在特定的路由上接受
GET HTTP请求(例如,/order/status/1)。 -
返回订单的状态。我们将演示解析 HTTP 请求行以检索订单号,并仅发送一个响应,声明订单号 1 的状态为:已发货。
让我们看看原始服务器的代码。
编写原始服务器 – 结构体和方法
我们首先将看到模块导入、结构体定义和方法的代码。然后,我们将看到main()函数的代码。原始服务器的所有代码都可以在tcpproxy/src/bin/origin.rs中找到。
代码片段首先显示了模块导入。我们在这里从标准库中导入各种模块。std::io模块将用于读取和写入 TCP 流,std::net模块提供了 TCP 监听器、套接字和地址的原语。字符串模块(std::str和std::String)用于字符串操作和处理字符串解析错误:
tcpproxy/src/bin/origin.rs
use std::io::{Read, Write};
use std::net::TcpListener;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::str;
use std::str::FromStr;
use std::string::ParseError;
接下来,让我们声明一个结构体来保存传入的 HTTP 请求行(多行 HTTP 请求消息的第一行)。我们还将为这个结构体编写一些辅助方法。
在下面的代码中,我们将声明一个包含三个字段的RequestLine结构体——HTTP 方法、请求的资源路径以及发送请求的互联网浏览器或其他 HTTP 客户端支持的 HTTP 协议版本。我们还将编写一些方法来返回结构体成员的值。get_order_number()方法将实现自定义逻辑。如果我们收到一个请求/order/status/1路径的资源,我们将通过/分割这个字符串,并返回字符串的最后一部分,即顺序号1:
tcpproxy/src/bin/origin.rs
#[derive(Debug)]
struct RequestLine {
method: Option<String>,
path: Option<String>,
protocol: Option<String>,
}
impl RequestLine {
fn method(&self) -> String {
if let Some(method) = &self.method {
method.to_string()
} else {
String::from("")
}
}
fn path(&self) -> String {
if let Some(path) = &self.path {
path.to_string()
} else {
String::from("")
}
}
fn get_order_number(&self) -> String {
let path = self.path();
let path_tokens: Vec<String> = path.split("/").map(
|s| s.parse().unwrap()).collect();
path_tokens[path_tokens.len() - 1].clone()
}
}
让我们也为RequestLine结构体实现FromStr特质,这样我们就可以将传入的 HTTP 请求行(字符串)转换为我们的内部 Rust 数据结构——RequestLine。HTTP 请求行的结构如下所示:
<HTTP-method> <path> <protocol>
这三个值由空格分隔,并且都出现在 HTTP 请求消息的第一行中。在下面的程序中,我们将解析这三个值并将它们加载到RequestLine结构体中。稍后,我们将进一步解析路径成员并从中提取顺序号,以进行处理:
tcpproxy/src/bin/origin.rs
impl FromStr for RequestLine {
type Err = ParseError;
fn from_str(msg: &str) -> Result<Self, Self::Err> {
let mut msg_tokens = msg.split_ascii_whitespace();
let method = match msg_tokens.next() {
Some(token) => Some(String::from(token)),
None => None,
};
let path = match msg_tokens.next() {
Some(token) => Some(String::from(token)),
None => None,
};
let protocol = match msg_tokens.next() {
Some(token) => Some(String::from(token)),
None => None,
};
Ok(Self {
method: method,
path: path,
protocol: protocol,
})
}
}
我们已经看到了模块导入、结构体定义和RequestLine结构体的方法。现在让我们编写main()函数。
编写原始服务器——main函数
在原始服务器的main函数中,我们将执行以下操作:
-
启动 TCP 服务器。
-
监听传入的连接。
对于每个传入的连接,我们将执行以下操作:
-
读取传入的 HTTP 请求消息的第一行并将其转换为
RequestLine结构体。 -
构建 HTTP 响应消息并将其写入 TCP 流。
现在让我们分两部分看看main函数的代码——启动 TCP 服务器并监听连接,以及处理传入的 HTTP 请求。
启动 TCP 服务器并监听连接
要启动 TCP 服务器,我们将构建一个套接字地址,并使用TcpStream::bind将其绑定到套接字:
tcpproxy/src/bin/origin.rs
// Start the origin server
let port = 3000;
let socket_addr = SocketAddr::new(IpAddr::V4(
Ipv4Addr::new(127, 0, 0, 1)), port);
let connection_listener = TcpListener::bind(
socket_addr).unwrap();
println!("Running on port: {}", port);
然后,我们将监听传入的连接,并从每个连接的流中读取:
tcpproxy/src/bin/origin.rs
for stream in connection_listener.incoming() {
//processing of incoming HTTP requests
}
现在让我们看看传入请求的处理过程。
处理传入的 HTTP 请求
对于处理传入的请求,第一步是检索请求消息的第一行并将其转换为RequestLine结构体。在下面的代码中,我们使用lines()方法返回一个行迭代器。然后,我们使用lines().next()获取 HTTP 请求的第一行。我们使用RequestLine::from_str()将其转换为RequestLine结构体。这之所以可能,是因为我们已经为RequestLine结构体实现了FromStr特质:
tcpproxy/src/bin/origin.rs
// Read the first line of incoming HTTP request
// and convert it into RequestLine struct
let mut stream = stream.unwrap();
let mut buffer = [0; 200];
stream.read(&mut buffer).unwrap();
let req_line = "";
let string_request_line =
if let Some(line) = str::from_utf8(
&buffer).unwrap().lines().next() {
line
} else {
println!("Invalid request line received");
req_line
};
let req_line = RequestLine::from_str(
string_request_line).unwrap();
现在我们已经将所需数据解析到RequestLine结构体中,我们可以处理它并发送 HTTP 响应。让我们看看代码。如果接收到的消息不是GET请求,如果请求消息中的路径不以/order/status开头,或者如果未提供订单号,则使用404 Not found HTTP 状态码构造 HTTP 响应消息:
tcpproxy/src/bin/origin.rs
// Construct the HTTP response string
let html_response_string;
let order_status;
println!("len is {}", req_line.get_order_number()
.len());
if req_line.method() != "GET"
|| !req_line.path().starts_with(
"/order/status")
|| req_line.get_order_number().len() == 0
{
if req_line.get_order_number().len() == 0 {
order_status = format!("Please provide
valid order number");
} else {
order_status = format!("Sorry,this page is
not found");
}
html_response_string = format!(
"HTTP/1.1 404 Not Found\nContent-Type:
text/html\nContent-Length:{}\n\n{}",
order_status.len(),
order_status
);
}
如果请求格式正确,用于检索订单号的订单状态,我们应该构造一个带有200 OK HTTP 状态码的 HTML 响应消息,以将响应发送回客户端:
tcpproxy/src/bin/origin.rs
else {
order_status = format!(
"Order status for order number {} is:
Shipped\n",
req_line.get_order_number()
);
html_response_string = format!(
"HTTP/1.1 200 OK\nContent-Type:
text/html\nContent-Length:{}\n\n{}",
order_status.len(),
order_status
);
}
最后,让我们将构造的 HTTP 响应消息写入 TCP 流:
tcpproxy/src/bin/origin.rs
stream.write(html_response_string.as_bytes()).unwrap();
这完成了原始服务器的代码。完整的代码可以在 Packt GitHub 仓库的Chapter12目录下的tcpproxy/src/bin/origin.rs中找到。
使用以下命令运行程序:
cargo run --bin origin
你应该看到服务器启动时显示以下消息:
运行在端口:3000
在浏览器窗口中,输入以下 URL:
localhost:3000/order/status/2
你应该在浏览器屏幕上看到以下响应:
订单号 2 的订单状态是:已发货
尝试输入一个无效路径的 URL,如下所示:
localhost:3000/invalid/path
你应该看到以下消息显示:
抱歉,此页面未找到
此外,你可以提供一个有效的路径,但不包含订单号,如下所示:
localhost:3000/order/status/
你将看到以下错误消息显示:
请提供有效的订单号
这样,我们就完成了原始服务器部分的编写。现在让我们编写反向代理的代码。
编写反向代理服务器
让我们深入到反向代理的代码,从模块导入开始。这个反向代理服务器的所有代码都可以在tcpproxy/src/bin/proxy.rs中找到。
让我们先看看模块导入。
使用std::env模块来读取命令行参数。std::io用于读取和写入 TCP 流。std::net是我们看到的主要通信模块。std::process用于在无法恢复的错误情况下退出程序。std::thread用于为处理传入请求创建新线程:
tcpproxy/src/bin/proxy.rs
use std::env;
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use std::process::exit;
use std::thread;
让我们接下来编写main()函数。当我们启动反向代理服务器时,让我们接受两个命令行参数,分别对应于反向代理和原始服务器的套接字地址。如果用户没有提供两个命令行参数,则打印出错误消息并退出程序。然后,让我们解析命令行输入并使用TcpListener::bind启动服务器。在绑定到本地端口后,我们连接到原始服务器,并在连接失败时打印出错误消息。
将以下代码放在main()函数块中:
tcpproxy/src/bin/proxy.rs
// Accept command-line parameters for proxy_stream and
// origin_stream
let args: Vec<_> = env::args().collect();
if args.len() < 3 {
eprintln!("Please provide proxy-from and proxy-to
addresses");
exit(2);
}
let proxy_server = &args[1];
let origin_server = &args[2];
// Start a socket server on proxy_stream
let proxy_listener;
if let Ok(proxy) = TcpListener::bind(proxy_server) {
proxy_listener = proxy;
let addr = proxy_listener.local_addr()
.unwrap().ip();
let port = proxy_listener.local_addr().unwrap()
.port();
if let Err(_err) = TcpStream::connect(
origin_server) {
println!("Please re-start the origin server");
exit(1);
}
println!("Running on Addr:{}, Port:{}\n", addr,
port);
} else {
eprintln!("Unable to bind to specified proxy
port");
exit(1);
}
在启动服务器后,我们必须监听传入的连接。对于每个连接,创建一个单独的线程来处理连接。该线程随后调用handle_connection()函数,我们将在稍后描述。然后,将子线程处理与主线程连接起来,以确保在子线程完成之前main()函数不会退出:
tcpproxy/src/bin/proxy.rs
// Listen for incoming connections from proxy_server
// and read byte stream
let mut thread_handles = Vec::new();
for proxy_stream in proxy_listener.incoming() {
let mut proxy_stream = proxy_stream.expect("Error
in incoming TCP connection");
// Establish a new TCP connection to origin_stream
let mut origin_stream =
TcpStream::connect(origin_server).expect(
"Please re-start the origin server");
let handle =
thread::spawn(move || handle_connection(&mut
proxy_stream, &mut origin_stream));
thread_handles.push(handle);
}
for handle in thread_handles {
handle.join().expect("Unable to join child
thread");
}
这就完成了main()函数。现在让我们编写handle_function()的代码。这包含了代理到原始服务器的核心逻辑:
tcpproxy/src/bin/proxy.rs
fn handle_connection(proxy_stream: &mut TcpStream,
origin_stream: &mut TcpStream) {
let mut in_buffer: Vec<u8> = vec![0; 200];
let mut out_buffer: Vec<u8> = vec![0; 200];
// Read incoming request to proxy_stream
if let Err(err) = proxy_stream.read(&mut in_buffer) {
println!("Error in reading from incoming proxy
stream: {}", err);
} else {
println!(
"1: Incoming client request: {}",
String::from_utf8_lossy(&in_buffer)
);
}
// Write the byte stream to origin_stream
let _ = origin_stream.write(&mut in_buffer).unwrap();
println!("2: Forwarding request to origin server\n");
// Read response from the backend server
let _ = origin_stream.read(&mut out_buffer).unwrap();
println!(
"3: Received response from origin server: {}",
String::from_utf8_lossy(&out_buffer)
);
// Write response back to the proxy client
let _ = proxy_stream.write(&mut out_buffer).unwrap();
println!("4: Forwarding response back to client");
}
为了便于调试,涉及代理功能的四个关键步骤已在代码中标记,并打印到控制台:
-
在第一步,我们从传入的客户端连接读取数据。
-
在第二步,我们与原始服务器打开一个新的 TCP 流,并将我们从客户端收到的数据发送到原始服务器。
-
在第三步,我们正在读取我们从原始服务器收到的响应,并将数据存储在缓冲区中。
-
在最后一步,我们使用上一步收到的数据写入对应于发送原始请求的客户端的 TCP 流。
这就完成了反向代理的代码。我们保持了功能的简单性,并只处理了基本案例。作为额外的练习,你可以添加边缘情况以使服务器更健壮,还可以添加如负载均衡和缓存等附加功能。
这就完成了原始服务器的代码。完整的代码可以在 Packt GitHub 仓库的Chapter12目录下的tcpproxy/src/bin/proxy.rs中找到。
首先,使用以下命令启动原始服务器:
cargo run --bin origin
然后,使用以下命令运行代理服务器:
cargo run --bin proxy localhost:3001 localhost:3000
我们传递的第一个命令行参数被反向代理服务器用于绑定到指定的套接字地址。第二个命令行参数对应于原始服务器运行的套接字地址。这是我们必须代理传入请求的地址。
现在,让我们从浏览器运行与原始服务器相同的测试,但这次我们将请求发送到运行反向代理服务器的端口3001。你会注意到你会得到类似的消息响应。这表明由互联网浏览器客户端发送的请求正在由反向代理服务器代理到后端原始服务器,并且从原始服务器收到的响应正在被路由回浏览器客户端。
你应该看到服务器以以下消息启动:
运行在地址:127.0.0.1,端口:3001
在浏览器窗口中,输入以下 URL:
localhost:3001/order/status/2
你应该在浏览器屏幕上看到以下响应显示:
订单号 2 的订单状态为:已发货
尝试输入一个无效路径的 URL,例如以下内容:
localhost:3001/invalid/path
你应该会看到以下消息显示:
抱歉,此页面未找到
此外,你可以提供一个有效的路径,但不包括订单号,例如以下内容:
localhost:3001/order/status/
你将看到以下错误信息显示:
请提供有效的订单号
这就结束了这个示例项目,其中我们编写了两个服务器——一个 TCP 源服务器和一个简单的 TCP 反向代理服务器。
摘要
在本章中,我们回顾了 Linux/Unix 中的网络基础知识。我们学习了 Rust 标准库中的网络原语,包括 IPv4 和 IPv6 地址的数据结构,IPv4 和 IPv6 套接字以及相关方法。我们学习了如何创建地址,以及如何创建套接字并查询它们。
然后,我们学习了如何使用 UDP 套接字,并编写了 UDP 客户端和服务器。我们还回顾了 TCP 通信的基础知识,包括如何配置 TCP 监听器,如何创建 TCP 套接字服务器,以及如何发送和接收数据。最后,我们编写了一个由两个服务器组成的项目——一个源服务器和一个反向代理服务器,该服务器将请求路由到源服务器。
在本书的下一章和最后一章中,我们将涵盖系统编程的另一个重要主题——不安全 Rust 和 FFI。
第十二章:第十二章:编写 unsafe Rust 和 FFI
在上一章中,我们学习了内置在 Rust 标准库中的网络原语,并看到了如何编写通过 TCP 和 UDP 通信的程序。在本章中,我们将通过介绍一些与 unsafe Rust 和 外部函数接口(FFIs)相关的高级主题来结束本书。
我们已经看到 Rust 编译器如何强制执行内存和线程安全的所有权规则。虽然这大多数时候都是一种祝福,但可能存在您想要实现新的低级数据结构或调用用其他语言编写的外部程序的情况。或者,您可能想要执行 Rust 编译器禁止的其他操作,例如取消引用原始指针、修改静态变量或处理未初始化的内存。您是否想过 Rust 标准库本身是如何进行系统调用来管理资源的,当系统调用涉及处理原始指针时?答案在于理解 unsafe Rust 和 FFIs。
在本章中,我们首先将探讨为什么以及如何使用 unsafe Rust 代码。然后,我们将介绍 FFIs 的基础知识,并讨论在使用它们时的特殊注意事项。我们还将编写调用 C 函数的 Rust 代码,以及调用 Rust 函数的 C 程序。
我们将按以下顺序介绍这些主题:
-
介绍 unsafe Rust
-
介绍 FFIs
-
复习安全 FFIs 的指南
-
从 C 调用 Rust(项目)
-
理解 ABI
到本章结束时,您将学会何时以及如何使用 unsafe Rust。您将学习如何通过 FFIs 将 Rust 与其他编程语言接口,并学习如何与之交互。您还将了解一些高级主题的概述,例如 应用程序二进制接口(ABIs)、条件编译、数据布局约定以及向链接器提供指令。了解这些内容将有助于构建针对不同目标平台的 Rust 二进制文件,以及将 Rust 代码与用其他编程语言编写的代码链接。
技术要求
使用以下命令验证 rustup、rustc 和 cargo 是否已正确安装:
rustup --version
rustc --version
cargo --version
由于本章涉及编译 C 代码和生成二进制文件,您需要在您的开发机器上设置 C 开发环境。设置完成后,运行以下命令以验证安装是否成功:
gcc --version
如果此命令无法成功执行,请重新检查您的安装。
注意
建议在 Windows 平台上开发的人员使用 Linux 虚拟机来尝试本章中的代码。
本节中的代码已在 Ubuntu 20.04(LTS)x64 上进行测试,并应在任何其他 Linux 变体上工作。
本章代码的 Git 仓库可以在 github.com/PacktPublishing/Practical-System-Programming-for-Rust-Developers/tree/master/Chapter12 找到。
介绍不安全 Rust
到目前为止,在这本书中,我们看到了并使用了 Rust 语言,它在编译时强制执行内存和类型安全,并防止各种未定义的行为,例如内存溢出、空或无效指针构造以及数据竞争。这是 安全 Rust。实际上,Rust 标准库为我们提供了良好的工具和实用程序来编写安全、惯用的 Rust,并有助于保持程序安全(以及您的心情!)。
但在某些情况下,编译器可能会 妨碍。Rust 编译器对代码进行保守的静态分析(这意味着 Rust 编译器不介意生成一些假阳性并拒绝有效代码,只要它不让坏代码通过)。您作为程序员知道某段代码是安全的,但编译器认为它是危险的,因此拒绝此代码。这包括系统调用、类型转换和直接操作内存指针等操作,这些操作用于开发几类系统软件。
另一个例子是在嵌入式系统中,寄存器通过固定的内存地址访问并需要指针解引用。因此,为了启用此类操作,Rust 语言提供了 unsafe 关键字。对于 Rust 作为系统编程语言,它对于使程序员能够编写低级代码以直接与操作系统接口至关重要,如果需要,可以绕过 Rust 标准库。这是不安全 Rust。这是 Rust 语言中不遵循借用检查器规则的部分。
不安全 Rust 可以被视为安全 Rust 的超集。它是超集,因为它允许您做所有在标准 Rust 中可以做的事情,但您可以做更多被 Rust 编译器禁止的事情。实际上,Rust 的编译器和标准库都包含了精心编写的 unsafe Rust 代码。
您如何区分安全和不安全 Rust 代码?
Rust 提供了一种方便且直观的机制,可以使用 unsafe 关键字将代码块封装在 unsafe 块中。尝试以下代码:
fn main() {
let num = 23;
let borrowed_num = # // immutable reference to num
let raw_ptr = borrowed_num as *const i32; // cast the
// reference borrowed_num to raw pointer
assert!(*raw_ptr == 23);
}
使用 cargo check(或从 Rust playground IDE 运行)编译此代码。您将看到以下错误信息:
error[E0133]: dereference of raw pointer is unsafe and requires unsafe function or block
现在我们通过将原始指针的解引用封装在 unsafe 块中来修改代码:
fn main() {
let num = 23;
let borrowed_num = # // immutable reference to num
let raw_ptr = borrowed_num as *const i32; // cast
// reference borrowed_num to raw pointer
unsafe {
assert!(*raw_ptr == 23);
}
}
您将看到现在编译成功,尽管这段代码可能引发未定义的行为。这是因为一旦将某些代码封装在 unsafe 块中,编译器就期望程序员确保不安全代码的安全性。
现在我们来看看不安全 Rust 允许的操作类型。
不安全 Rust 中的操作
实际上,在unsafe类别中只有五个关键操作——解引用原始指针、处理可变静态变量、实现不安全特性和通过 FFI 接口调用外部函数,以及跨 FFI 边界共享联合结构体。
我们将在本节中查看前三个,在下一节中查看最后两个:
-
*const T是一个指针类型,对应于安全 Rust 中的&T(不可变引用类型),而*mut T是一个指针类型,对应于&mut T(安全 Rust 中的可变引用类型)。与 Rust 引用类型不同,这些原始指针可以同时具有对值的不可变和可变引用,或者同时有多个指针指向内存中的同一值。当这些指针超出作用域时,没有自动清理内存,这些指针也可以是 null 或指向无效的内存位置。Rust 提供的内存安全保证不适用于这些指针类型。下面将展示如何在unsafe块中定义和访问指针的示例:fn main() { let mut a_number = 5; // Create an immutable pointer to the value 5 let raw_ptr1 = &a_number as *const i32; // Create a mutable pointer to the value 5 let raw_ptr2 = &mut a_number as *mut i32; unsafe { println!("raw_ptr1 is: {}", *raw_ptr1); println!("raw_ptr2 is: {}", *raw_ptr2); } }您会注意到从这段代码中,我们通过从相应的不可变和可变引用类型进行转换,同时创建了同一值的不可变引用和可变引用。请注意,为了创建原始指针,我们不需要
unsafe块,但需要解引用它们。这是因为解引用原始指针可能会导致不可预测的行为,因为借用检查器不负责验证其有效性或生命周期。 -
unsafe块。在下面的示例中,我们声明了一个可变静态变量,它使用默认值初始化线程数量。然后,在main()函数中,我们检查环境变量,如果指定了该变量,它将覆盖默认值。在静态变量中的值覆盖必须包含在*unsafe*块中:static mut THREAD_COUNT: u32 = 4; use std::env::var; fn change_thread_count(count: u32) { unsafe { THREAD_COUNT = count; } } fn main() { if let Some(thread_count) = var("THREAD_COUNT").ok() { change_thread_count(thread_count.parse:: <u32>() .unwrap()); }; unsafe { println!("Thread count is: {}", THREAD_COUNT); } }以下代码片段展示了可变静态变量的声明,
THREAD_COUNT,初始化为4。当main()函数执行时,它会查找名为THREAD_COUNT的环境变量。如果找到env变量,它将调用change_thread_count()函数,在unsafe块中修改静态变量的值。然后main()函数在unsafe块中打印出该值。 -
Send或Sync特性。为了为原始指针实现这两个特性,我们必须使用不安全 Rust,如下所示:struct MyStruct(*mut u16); unsafe impl Send for MyStruct {} unsafe impl Sync for MyStruct {}unsafe关键字的原因是因为原始指针具有未跟踪的所有权,这随后成为程序员的职责来跟踪和管理。
与其他编程语言接口相关的不安全 Rust 有两个更多特性,我们将在下一节关于 FFI 的讨论中讨论。
介绍 FFI
在本节中,我们将了解 FFI 是什么,然后查看与 FFI 相关的两个不安全 Rust 特性。
为了理解 FFI,让我们看看以下两个示例:
-
有一个用于线性回归的 Rust 编写的快速机器学习算法。一个 Java 或 Python 开发者想要使用这个 Rust 库。这该如何实现?
-
你想在不用 Rust 标准库的情况下(这基本上意味着你想实现标准库中没有的功能或想改进现有功能)进行 Linux 系统调用。你将如何做?
尽管可能有其他方法可以解决这个问题,但一种流行的方法是使用 FFI。
在第一个例子中,你可以用 Java 或 Python 中定义的 FFI 包装 Rust 库。在第二个例子中,Rust 有一个关键字extern,可以用它来设置和调用 C 函数的 FFI。让我们看看第二个案例的例子:
use std::ffi::{CStr, CString};
use std::os::raw::c_char;
extern "C" {
fn getenv(s: *const c_char) -> *mut c_char;
}
fn main() {
let c1 = CString::new("MY_VAR").expect("Error");
unsafe {
println!("env got is {:?}", CStr::from_ptr(getenv(
c1.as_ptr())));
}
}
在这里,在main()函数中,我们正在调用外部 C 函数getenv()(而不是直接使用 Rust 标准库)来检索MY_VAR环境变量的值。getenv()函数接受一个*const c_char类型的参数作为输入。为了创建这种类型,我们首先实例化CString类型,传入环境变量的名称,然后使用as_ptr()方法将其转换为所需的函数输入参数类型。getenv()函数返回一个*mut c_char类型的值。为了将其转换为 Rust 兼容的类型,我们使用Cstr::from_ptr()函数。
注意这里的两个主要考虑因素:
-
我们在
extern "C"块中指定对 C 函数的调用。此块包含我们想要调用的函数的签名。请注意,函数中的数据类型不是 Rust 数据类型,而是属于 C 的数据类型。 -
我们从 Rust 标准库中导入了一些模块——
std::ffi和std::os::raw。ffi模块提供了与 FFI 绑定相关的实用函数和数据结构,这使得在非 Rust 接口之间进行数据映射变得更容易。我们使用ffi模块中的CString和CStr类型,在 C 之间传输 UTF-8 字符串。os::raw模块包含映射到 C 数据类型的平台特定类型,以便与 C 交互的 Rust 代码引用正确的类型。
现在,让我们使用以下命令运行程序:
MY_VAR="My custom value" cargo -v run --bin ffi
你将看到MY_VAR的值被打印到控制台。通过这种方式,我们已经成功使用外部 C 函数调用来检索环境变量的值。
回想一下,我们在前面的章节中学习了如何使用 Rust 标准库来获取和设置环境变量。现在我们已经做了类似的事情,但这次是使用 Rust FFI 接口调用 C 库函数。请注意,对 C 函数的调用被包含在一个unsafe块中。
到目前为止,我们已经看到了如何在 Rust 中调用 C 函数。稍后,在从 C 调用 Rust(项目)部分,我们将看到如何反过来操作,即从 C 调用 Rust 函数。
现在我们来看一下不安全的 Rust 的另一个特性,即定义和访问联合结构体的字段,以便通过 FFI 接口与 C 函数进行通信。
联合结构体是在 C 中使用的,并且不是内存安全的。这是因为在一个联合类型中,你可以将一个union的实例设置为其中一个不变量,并以另一个不变量的方式访问它。Rust 不直接在安全 Rust 中提供union作为类型。然而,Rust 在安全 Rust 中有一个称为enum的联合类型。让我们看看union的一个例子:
#[repr(C)]
union MyUnion {
f1: u32,
f2: f32,
}
fn main() {
let float_num = MyUnion {f2: 2.0};
let f = unsafe { float_num.f2 };
println!("f is {:.3}",f);
}
在显示的代码中,我们首先使用repr(C)注解,这告诉编译器MyUnion联合结构体中字段的顺序、大小和对齐方式与 C 语言中预期的一致(我们将在理解 ABI部分中讨论更多关于repr(C)的内容)。然后我们定义联合结构体的两个不变量:一个是u32类型的整数,另一个是f32类型的浮点数。对于这个联合结构体的任何给定实例,只有一个这些不变量是有效的。在代码中,我们创建了这个联合结构体的一个实例,使用float不变量初始化它,然后从unsafe块中访问其值。
使用以下命令运行程序:
cargo run
你将在终端上看到打印出f is 2.000的值。到目前为止,看起来是正确的。现在,让我们尝试将联合结构体作为整数而不是浮点类型来访问。为此,只需更改一行代码。定位以下行:
let f = unsafe { float_num.f2 };
改成以下内容:
let f = unsafe { float_num.f1 };
再次运行程序。这次,你不会得到错误,但你将看到像这样打印出无效值。原因是现在指向的内存位置中的值现在被解释为整数,尽管我们存储了一个浮点数值:
f is 1073741824
在 C 中使用联合结构体是危险的,除非做得非常小心,而 Rust 提供了在unsafe Rust中与联合结构体一起工作的能力。
到目前为止,你已经看到了unsafe Rust和 FFI 是什么。你也看到了调用unsafe和外部函数的例子。在下一节中,我们将讨论创建安全 FFI 接口的指南。
审查安全的 FFI 指南
在本节中,我们将探讨在使用 Rust 中的 FFI 与其他语言进行接口时需要记住的一些指南:
-
Rust 中的
extern关键字本身是不安全的,此类调用必须从unsafe块中进行。 -
#repr(C)注解对于保持内存安全非常重要。我们之前已经看到了如何使用这个注解的例子。另一个需要注意的事项是,外部函数的参数或返回值应仅使用与 C 兼容的类型。在 Rust 中,与 C 兼容的类型包括整数、浮点数、repr(C)注解的结构体和指针。与 C 不兼容的 Rust 类型包括特质对象、动态大小类型和具有字段的枚举。有一些工具,如rust-bindgen和cbindgen,可以帮助生成 Rust 和 C 之间兼容的类型(有一些注意事项)。 -
int和long,这意味着这些类型的长度根据平台架构而变化。当与使用这些类型的 C 函数交互时,可以使用 Rust 标准库std::raw模块,该模块提供跨平台的可移植类型别名。c_char和c_uint是我们在早期示例中使用的原始类型中的两个例子。除了标准库之外,libccrate 也为这些数据类型提供了这样的可移植类型别名。 -
引用和指针:由于 C 的指针类型和 Rust 的引用类型之间存在差异,Rust 代码在跨 FFI 边界工作时不应使用引用类型,而应使用指针类型。任何解引用指针类型的 Rust 代码必须在使用之前进行空检查。
-
为任何直接传递给外部代码的类型提供
Drop特性。仅使用Copy类型跨 FFI 边界使用则更为安全。 -
std::panic::catch_unwind或#[panic_handler](我们在 第九章,管理 并发性)中看到)。这将确保 Rust 代码不会在不稳定状态下终止或返回。 -
将 Rust 库暴露给外语:将 Rust 库及其函数暴露给外语(如 Java、Python 或 Ruby)应仅通过 C 兼容的 API 完成。
这就结束了关于编写安全 FFI 接口的章节。在下一节中,我们将看到一个从 C 代码中使用 Rust 库的示例。
从 C 调用 Rust(项目)
在本节中,我们将演示构建一个 Rust 共享库(在 Linux 上具有 .so 扩展名)所需的设置,该库包含 FFI 接口,并从 C 程序中调用它。该 C 程序将是一个简单的程序,仅打印问候消息。示例故意保持简单,以便您(由于您不需要熟悉复杂的 C 语法)可以专注于涉及到的步骤,并便于在各种操作系统环境中验证此第一个 FFI 程序。
这里是我们将采取的步骤,以开发并测试一个 C 程序的工作示例,该程序使用 FFI 接口从 Rust 库中调用函数:
-
创建一个新的 Cargo lib 项目。
-
修改
Cargo.toml以指定要构建共享库。 -
在 Rust 中编写 FFI(以 C 兼容的 API 形式):
-
构建 Rust 共享库。
-
验证 Rust 共享库是否已正确构建。
-
创建一个调用 Rust 共享库中函数的 C 程序。
-
指定 Rust 共享库路径构建 C 程序。
-
设置
LD_LIBRARY_PATH。 -
运行 C 程序。
让我们开始执行上述步骤:
-
创建一个新的 cargo 项目:
cargo new --lib ffi && cd ffi -
将以下内容添加到
Cargo.toml中:[lib] name = "ffitest" crate-type = ["dylib"] -
在
src/lib.rs中编写 FFI(以 C 兼容的 API 形式):#[no_mangle] pub extern "C" fn see_ffi_in_action() { println!("Congrats! You have successfully invoked Rust shared library from a C program"); }#[no_mangle]注解告诉 Rust 编译器,see_ffi_in_action()函数应该可以被具有相同名称的外部程序访问。否则,默认情况下,Rust 编译器会修改它。函数使用了
extern "C"关键字。如前所述,Rust 编译器使标记为extern的任何函数与 C 代码兼容。extern "C"中的"C"关键字表示目标平台上的标准 C 调用约定。在这个函数中,我们只是打印出问候语。 -
使用以下命令从
ffi文件夹构建 Rust 共享库:libffitest.so, created in the target/release directory. -
验证共享库是否已正确构建:
nm command-line utility is used to examine binary files (including libraries and executables) and view the symbols in these object files. Here, we are checking whether the function that we have written is included in the shared library. You should see a result similar to this:(在 Mac 平台上为
.dylib扩展名。) -
让我们创建一个 C 程序,调用我们构建的 Rust 共享库中的函数。在
ffi项目文件夹的根目录中创建一个rustffi.c文件,并添加以下代码:#include "rustffi.h" int main(void) { see_ffi_in_action(); }这是一个简单的 C 程序,它包含一个头文件,并有一个
main()函数,该函数反过来调用一个see_ffi_in_action()函数。在这个时候,C 程序不知道这个函数在哪里。当我们构建二进制文件时,我们将提供这个信息给 C 编译器。现在让我们编写这个程序中提到的头文件。在 C 源文件相同的文件夹中创建一个rustffi.h文件,并包含以下内容:void see_ffi_in_action();此头文件声明了函数签名,表示此函数不返回任何值也不接受任何输入参数。
-
使用以下命令从项目的根目录构建 C 二进制文件:
gcc: Invokes the GCC compiler.`-Ltarget/release`: The `–L` flag specifies to the compiler to look for the shared library in the folder target/release.`-lffitest`: The `–l` flag tells the compiler that the name of the shared library is `ffitest`. Note that the actual library built is called `libffitest.so`, but the compiler knows that the `lib` prefix and `.so` suffix are part of the standard shared library name, so it is sufficient to specify `ffitest` for the `–l` flag.`rustffi.c`: This is the source file to be compiled.`-o ffitest`: Tells the compiler to generate the output executable with the name `ffitest`. -
设置
LD_LIBRARY_PATH环境变量,在 Linux 中指定库将被搜索的路径:export LD_LIBRARY_PATH=$(rustc --print sysroot)/lib:target/release:$LD_ LIBRARY_PATH -
使用以下命令运行可执行文件:
./ffitest
你应该在终端上看到以下消息:
Congrats! You have successfully invoked Rust shared library from a C program
如果你已经走到这一步,恭喜你!
你已经使用 Rust 编写了一个包含具有 C 兼容 API 的函数的共享库。然后,你从 C 程序中调用了这个 Rust 库。这就是 FFI(Foreign Function Interface)的作用。
理解 ABI
本节简要介绍了 ABI 以及一些与 Rust 相关的(高级)特性,这些特性涉及条件编译选项、数据布局约定和链接选项。
ABI是一组约定和标准,编译器和链接器遵循这些约定和标准,用于函数调用约定以及指定数据布局(类型、对齐、偏移)。
要理解 ABI 的重要性,让我们通过 API 进行类比,API 是应用编程中众所周知的概念。当程序需要在源代码级别访问外部组件或库时,它会寻找外部组件暴露的 API 定义。外部组件可以是库或通过网络可访问的外部服务。API 指定了可以调用的函数名称,需要传递给函数调用的参数(包括它们的名称和数据类型),以及函数返回值的类型。
ABI 可以看作是 API 在二进制层面的等价物。编译器和链接器需要一种方式来指定调用程序如何在二进制对象文件中定位被调用函数,以及如何处理参数和返回值(参数的类型和顺序以及返回类型)。但与源代码不同,在生成的二进制文件的情况下,诸如整数长度、填充规则以及函数参数是存储在栈上还是寄存器中等细节会因平台架构(例如,x86、x64、AArch32)和操作系统(例如,Linux 和 Windows)而异。64 位操作系统可以为执行 32 位和 64 位二进制文件具有不同的 ABI。基于 Windows 的程序将不知道如何访问在 Linux 上构建的库,因为它们使用不同的 ABI。
虽然 ABIs 的研究本身就是一个专业化的主题,但了解 ABIs 的重要性以及 Rust 在编写代码时提供哪些功能来指定与 ABI 相关的参数就足够了。我们将涵盖以下内容 – 条件编译选项、数据布局约定和链接选项:
-
cfg宏。以下是一些cfg选项的示例:#[cfg(target_arch = "x86_64")] #[cfg(target_os = "linux")] #[cfg(target_family = "windows")] #[cfg(target_env = "gnu")] #[cfg(target_pointer_width = "32")]这些注释被附加到函数声明中,如下例所示:
// Only if target OS is Linux and architecture is x86, // include this function in build #[cfg(all(target_os = "linux", target_arch = "x86"))] // all conditions must be true fn do_something() { // ... }更多关于各种条件编译选项的详细信息可以在
doc.rust-lang.org/reference/conditional-compilation.html找到。 -
#[repr(Rust)]。但如果需要通过 FFI 边界传递数据,则接受的标准是使用 C 的数据布局(注释为#[repr(C)])。在这个布局中,字段顺序、大小和对齐方式与 C 程序中的方式相同。这对于确保数据在 FFI 边界上的兼容性非常重要。Rust 保证,如果将
#[repr(C)]属性应用于结构体,则该结构体的布局将与平台在 C 中的表示兼容。有一些自动化工具,如cbindgen,可以帮助从 Rust 程序生成 C 数据布局。 -
link注释。以下是一个示例:#[link(name = "my_library")] extern { static a_c_function() -> c_int; }#[link(...)]属性用于指示链接器链接到my_library以解析符号。它指导 Rust 编译器如何链接到本地库。此注释还可以用于指定要链接的库的类型(静态或动态)。以下注释告诉rustc链接到名为my_other_library的静态库:#[link(name = "my_other_library", kind = "static")]
在本节中,我们了解了 ABI 是什么以及它的意义。我们还探讨了如何通过代码中的各种注释来指定对编译器和链接器的指令,例如目标平台、操作系统、数据布局和链接指令。
这就结束了本节。本节的目的是仅介绍与 ABI、FFI 和相关指令有关的一些高级主题。更多详情,请参阅以下链接:doc.rust-lang.org/nomicon/。
摘要
在这一章中,我们回顾了不安全 Rust 的基础知识,并了解了安全 Rust 和不安全 Rust 之间的关键区别。我们看到了不安全 Rust 如何使我们能够执行在安全 Rust 中不被允许的操作,例如取消引用原始指针、访问或修改静态变量、与联合体一起工作、实现不安全特质以及调用外部函数。我们还探讨了什么是外函数接口,以及如何在 Rust 中编写一个。我们编写了一个从 Rust 调用 C 函数的示例。此外,在示例项目中,我们编写了一个 Rust 共享库,并从 C 程序中调用它。我们看到了如何在 Rust 中编写安全的 FFI 的指南。我们还查看了一些可以用来指定条件编译、数据布局和链接选项的 ABI 和注解。
有了这些,我们结束了这一章,也结束了这本书。
我感谢您与我一起踏上探索 Rust 系统编程世界的旅程,并祝愿您在进一步探索这个主题时一切顺利。
第十三章:您可能还会喜欢的其他书籍
如果你喜欢这本书,你可能会对 Packt 出版的以下其他书籍感兴趣:
](https://github.com/OpenDocCN/freelearn-rust-zh/raw/master/docs/prac-sys-prog-rs-dev/img/smaller1.png)](https://www.packtpub.com/product/creative-projects-for-rust-programmers/9781789346220)
Rust 程序员创意项目
卡洛·米拉内西
ISBN: 978-1-78934-622-0
-
访问 TOML、JSON 和 XML 文件以及 SQLite、PostgreSQL 和 Redis 数据库
-
使用 JSON 负载开发 RESTful 网络服务
-
使用 HTML 模板和 JavaScript 创建一个网络应用程序,或者使用 WebAssembly 创建一个前端网络应用程序或网络游戏
-
构建 2D 桌面游戏
-
为一种编程语言开发解释器和编译器
-
创建一个机器语言模拟器
-
使用可加载模块扩展 Linux 内核
Rust 编程食谱
克劳斯·马茨 inger
ISBN: 978-1-78953-066-7
-
了解 Rust 如何提供独特的解决方案来解决系统编程语言问题
-
掌握 Rust 的核心概念以开发快速且安全的应用程序
-
探索将 Rust 单元集成到现有应用程序中以提高效率的可能性
-
发现如何通过 Rust 实现更好的并行性和安全性
-
使用 Rust 编写 Python 扩展
-
编译外部汇编文件并使用外部函数接口 (FFI)
-
使用 Rust 构建 Web 应用程序和服务以实现高性能
留下评论 - 让其他读者了解您的看法
请通过在您购买书籍的网站上留下评论的方式与其他人分享您对这本书的看法。如果您从亚马逊购买了这本书,请在本书的亚马逊页面上留下一个诚实的评论。这对其他潜在读者来说至关重要,他们可以查看并使用您的客观意见来做出购买决定,我们也可以了解客户对我们产品的看法,我们的作者也可以看到他们对与 Packt 合作创建的标题的反馈。这只需要您几分钟的时间,但对其他潜在客户、我们的作者和 Packt 来说都很有价值。谢谢!


浙公网安备 33010602011771号