Rust-程序员的创造性项目-全-
Rust 程序员的创造性项目(全)
原文:
annas-archive.org/md5/623bec88fb2b2fd627860a706828e8f4译者:飞龙
前言
本书展示了 Rust 程序员可以免费使用的最有趣和最有用的库和框架,用于构建有趣和有用的项目,例如前端和后端 Web 应用程序、游戏、解释器、编译器、计算机模拟器和 Linux 可加载模块。
第一章:本书面向对象
本书面向已经学习过 Rust 编程语言并渴望将其应用于构建有用软件的开发者,无论是商业项目还是个人爱好项目。本书涵盖了多样化的需求,如构建 Web 应用程序、计算机游戏、解释器、编译器、模拟器或设备驱动程序。
阅读数据库章节需要一些 SQL 知识,而阅读 Linux 模块章节则需要 C 编程语言和 Linux 工具的知识。
本书涵盖内容
第一章,Rust 2018 – 生产力,介绍了 Rust 语言及其工具和库生态系统中的最新创新。特别是,它展示了如何使用一些广泛使用的实用库。
第二章,存储和检索数据,介绍了如何在 Rust 世界中读取和写入一些最流行的文本文件格式:TOML、JSON 和 XML。它还描述了如何访问 Rust 世界中一些最流行的数据库引擎,如 SQLite、PostgreSQL 和 Redis。
第三章,创建 REST Web 服务,介绍了如何使用 Actix 框架开发 REST 服务,该服务可以用作任何类型客户端应用程序的后端,尤其是 Web 应用程序。
第四章,创建完整的后端 Web 应用程序,介绍了如何使用 Tera 模板引擎替换文本文件中的占位符,以及如何使用 Actix 框架创建完整的后端 Web 应用程序。
第五章,使用 Yew 创建客户端 WebAssembly 应用程序,介绍了如何使用利用 WebAssembly 技术的 Yew 框架来创建 Web 应用程序的前端。
第六章,使用 Quicksilver 创建 WebAssembly 游戏,介绍了如何使用 Quicksilver 框架创建可在网页浏览器中运行的图形 2D 游戏,利用 WebAssembly 技术,或者作为桌面应用程序。
第七章,使用 ggez 创建桌面二维游戏,介绍了如何使用 ggez 框架创建桌面图形 2D 游戏,包括小部件的覆盖范围。
第八章,使用解析器组合进行解释和编译,描述了如何使用 Nom 解析器组合创建形式语言的解析器,然后构建语法检查器、解释器和编译器。
第九章,使用 Nom 创建计算机模拟器,描述了如何使用 Nom 库解析二进制数据并解释机器语言程序,这是构建计算机模拟器的第一步。
第十章,创建 Linux 内核模块,描述了如何使用 Rust 构建 Linux 可加载模块,重点关注 Mint 发行版;具体来说,将构建一个字符设备驱动程序。
第十一章,Rust 的未来,描述了未来几年 Rust 生态系统可能出现的创新。特别是,简要展示了新的异步编程技术。
要充分利用本书
| 本书涵盖的软件/硬件 | 操作系统要求 |
|---|---|
| 您需要在计算机上安装 Rust 1.31 版(自 2018 年 12 月起)或更高版本。本书的内容在 64 位 Linux Mint 和 32 位 Windows 10 系统上进行了测试。大多数示例应该适用于任何支持 Rust 的系统。第五章,使用 Yew 创建客户端 WebAssembly 应用程序,和第六章,使用 Quicksilver 创建 WebAssembly 游戏,需要支持 WebAssembly 的网络浏览器,如 Chrome 或 Firefox。第六章,使用 Quicksilver 创建 WebAssembly 游戏,和第七章,使用 ggez 创建桌面二维游戏,需要支持 OpenGL。第十章,创建 Linux 内核模块,仅在 Linux Mint 上运行。 |
如果您正在使用本书的数字版,我们建议您亲自输入代码或通过下一节中提供的 GitHub 仓库访问代码。这样做将帮助您避免与代码复制和粘贴相关的任何潜在错误。
下载示例代码文件
您可以从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/Creative-Projects-for-Rust-Programmers。如果代码有更新,它将在现有的 GitHub 仓库中更新。
我们还有其他来自我们丰富图书和视频目录的代码包,可在github.com/PacktPublishing/上找到。查看它们吧!
下载彩色图像
我们还提供了一份包含本书中使用的截图/图表彩色图像的 PDF 文件。您可以从这里下载:static.packt-cdn.com/downloads/9781789346220_ColorImages.pdf。
使用约定
本书使用了许多文本约定。
CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“pos变量是digits数组中当前数字的位置。”
代码块设置如下:
{
for pos in pos..5 {
print!("{}", digits[pos] as u8 as char);
}
任何命令行输入或输出都按如下方式编写:
curl -X GET http://localhost:8080/datafile.txt
粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词在文本中显示如下。以下是一个示例:“名称部分编辑框和其右侧的过滤器按钮用于过滤下方的表格,方式类似于list项目。”
警告或重要提示如下所示。
技巧和窍门如下所示。
联系我们
读者反馈始终欢迎。
一般反馈:如果您对本书的任何方面有疑问,请在邮件主题中提及书名,并通过customercare@packtpub.com给我们发送邮件。
勘误表:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,如果您能向我们报告,我们将不胜感激。请访问www.packtpub.com/support/errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。
盗版:如果您在互联网上以任何形式发现我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过copyright@packt.com与我们联系,并提供材料的链接。
如果您有兴趣成为作者:如果您在某个主题上具有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com。
评论
请留下您的评价。一旦您阅读并使用了这本书,为何不在购买它的网站上留下评价呢?潜在读者可以查看并使用您的客观意见来做出购买决定,我们 Packt 公司可以了解您对我们产品的看法,并且我们的作者可以查看他们对书籍的反馈。谢谢!
如需了解更多关于 Packt 的信息,请访问 packt.com。
Rust 2018:生产力
Rust 标准库和工具在过去几年中得到了很大的改进。自 2018 年 2 月以来,Rust 生态系统已经变得非常广泛和多样化。已经创建了四个领域工作小组,每个小组覆盖一个主要的应用领域。这些领域已经相当成熟,但这一发展使它们能够进一步改进。在未来几年,我们还将看到其他领域工作小组的引入。
即使作为一个开发者学习了语言,开发一个高质量且成本效益高的应用程序也不是一件容易的事情。为了避免重复造(可能是低质量的)轮子,作为开发者,你应该使用一个高质量的框架或一些高质量的库,这些库涵盖了你要开发的类型的应用程序。
这本书的目的是指导你作为开发者选择最适合开发软件的开源 Rust 库。本书涵盖了几个典型领域,每个领域使用不同的库。由于一些非标准库在许多不同领域都有用,如果将它们限制在单一领域,将会非常局限。
在本章中,你将学习以下主题:
-
理解 Rust 的不同版本
-
理解最近对 Rust 做出的最重要的改进
-
理解领域工作小组
-
理解本书中将涵盖的项目类型
-
一些有用的 Rust 库简介
第二章:技术要求
要跟随这本书,你需要访问一台安装了最新 Rust 系统的计算机。任何自 1.31 版本以来的版本都是可以的。稍后将为一些特定项目列出一些可选库。
任何引用的源代码和附加示例都可以(并且应该)从以下存储库下载:github.com/PacktPublishing/Creative-Projects-for-Rust-Programmers.
理解 Rust 的不同版本
在 2018 年 12 月 6 日,Rust 语言、其编译器和其标准库的一个非常重要的版本被发布:稳定版本 1.31。这个版本被定义为 2018 版本,这意味着它是一个里程碑,将作为未来年份的参考。
在此之前,还有一个版本,1.0,被定义为 2015 版本。这个版本的特点是 稳定性。直到版本 1.0,编译器的每个版本都对语言或标准库进行了破坏性更改,迫使开发者对其代码库进行大规模的更改。从版本 1.0 开始,已经做出了努力,以确保任何未来的编译器版本都可以正确编译为版本 1.0 或后续版本编写的任何代码。这被称为 向后兼容性。
然而,在 2018 版本发布之前,许多功能已经应用于语言和标准库。许多新库使用了这些新功能,这意味着这些库不能由较旧的编译器使用。因此,有必要将 Rust 的一个特定版本标记为旨在与较新库一起使用。这是 2018 版本的主要原因。
添加到语言的一些功能被标记为 2015 版本,而其他功能被标记为 2018 版本。2015 版本的功能只是小改进,而 2018 版本的功能是更深入的变化。开发者必须将他们的 crate 标记为 2018 版本,才能使用特定于 2018 版本的功能。
此外,尽管 2015 版本标志着语言和标准库的稳定里程碑,但命令行工具实际上并没有稳定;它们仍然相当不成熟。从 2015 年 5 月到 2018 年 12 月的三年半时间里,主要的官方命令行工具已经成熟,语言也得到了改进,以允许更高效的编码。2018 版本可以用“生产力”这个词来描述。
以下表格显示了语言、标准库和工具中稳定的功能的时间线:
| 2015 | 五月: 2015 版本 | 八月: 多核 CPU 上的并行编译 | |||||
|---|---|---|---|---|---|---|---|
| 2016 | 四月: 支持微软 C 编译器格式 | 五月: 捕获 panic 的能力 | 九月: 改进的编译器错误信息 | 十一月: ? 操作符 |
十二月: rustup 命令 |
||
| 2017 | 二月: 自定义 derive 属性 | 三月: cargo check 命令 | 七月: union 关键字 |
八月: 关联常量 | 十一月: 与 Option 一起的 ? 操作符 |
| 2018 | 二月:
-
四个领域工作组的形成。
-
rustfmt程序
| 五月:
-
Rust 编程语言第二版。
-
impl特性语言功能。 -
main可以返回一个 Result。 -
使用
..=的包含范围 -
本地类型 i128 和 u128。
-
match的改进模式
| 六月:
-
SIMD 库功能
-
dyn特性语言功能
| 八月: 自定义全局分配器 | 九月:
-
cargo fix命令 -
cargo clippy命令
| 十月:
-
过程宏
-
模块系统和
use语句的更改 -
原始标识符
-
no_std应用程序
| 十二月:
-
2018 版本
-
非词法生命周期
-
const fn语言功能 -
新的
www.rust-lang.org/网站 -
try、async和await是保留字
|
自 2015 年版以来,已经应用了许多改进。更多信息可以在官方文档中找到(blog.rust-lang.org/2018/12/06/Rust-1.31-and-rust-2018.html)。以下列出了最重要的改进:
-
一本新的官方教程书籍,免费在线提供(
doc.rust-lang.org/book/),或打印在纸上(由 Steve Klabnik 和 Carol Nichols 编写的 Rust 编程语言)。 -
一个全新的官方网站。
-
成立了四个领域工作组,这些是开放委员会,旨在设计四个关键领域的生态系统未来:
-
网络编程:围绕延迟计算的概念设计新的异步范式,称为 future,就像在其他语言中已经做的那样,例如 C++、C# 和 JavaScript(使用 promises)。
-
命令行应用程序:设计一些标准库来支持任何非图形、非嵌入式应用程序。
-
WebAssembly:设计工具和库来构建可在网页浏览器中运行的应用程序。
-
嵌入式软件:设计工具和库来构建在裸机系统或严格受限的硬件上运行的应用程序。
-
-
我们见证了语言的一些良好改进:
- 非词法生命周期;任何不再使用的绑定都被认为是 已死亡。例如,现在这个程序是被允许的:
fn main() {
let mut _a = 7;
let _ref_to_a = &_a;
_a = 9;
}
在此代码中,变量 _a 绑定的对象在第二个语句中被变量 _ref_to_a 借用。在非词法生命周期的引入之前,此类绑定会持续到作用域的末尾,因此最后一个语句将是非法的,因为它试图在 _ref_to_a 仍然借用该对象时通过绑定 _a 来更改该对象。现在,因为变量 _ref_to_a 不再使用,其生命周期在声明它的同一行结束,因此,在最后一个语句中,变量 _a 再次可以自由地更改其对象。
-
-
Impl Trait功能,允许函数返回未指定的类型,例如 闭包。 -
i128和u128本地类型。 -
一些其他保留关键字,例如
try、async和await。 -
?操作符,即使在main函数中也可以使用,因为它现在可以返回Result。以下是一个main函数返回Result的程序示例:
-
fn main() -> Result<(), String> {
Err("Hi".to_string())
}
它可以通过返回通常的空元组或通过返回你指定的类型来成功,在这种情况下是 String。以下是一个使用 ? 操作符的示例,该操作符在 main 函数中使用:
fn main() -> Result<(), usize> {
let array = [12, 19, 27];
let found = array.binary_search(&19)?;
println!("Found {}", found);
let found = array.binary_search(&20)?;
println!("Found {}", found);
Ok(())
}
这个程序将在标准输出流上打印 Found 1,这意味着数字 19 在位置 1 被找到,并且它将在标准错误流上打印 Error: 2,这意味着数字 20 没有被找到,但它应该被插入到位置 2。
-
-
过程宏,允许一种元编程,在编译时通过操作源代码生成 Rust 代码。
-
在
match表达式中实现更强大和更直观的模式匹配。
-
-
以及对标准工具的一些改进:
-
rustup程序,它允许用户轻松选择默认的编译器目标或更新工具链。 -
rustfix程序,它将 2015 版本的项目转换为 2018 版本的项目。 -
Clippy 程序,它检查非惯用语法,并为提高代码可维护性提出代码更改建议。
-
更快的编译速度,特别是如果只需要进行语法检查的话。
-
Rust 语言服务器(RLS)程序,目前仍然不稳定,但它允许 IDE 和可编程编辑器发现语法错误,并提出允许的操作。
-
Rust 语言像任何其他编程语言一样仍在不断发展。以下领域仍需改进:
-
IDE 工具,包括语言解释器(REPL)和图形调试器
-
支持裸机实时软件开发库和工具
-
主要应用程序领域的应用程序级框架和库
本书将主要关注列表上的第三点。
项目
当我们编写现实世界应用程序时,Rust 语言及其标准库是不够的。需要特定类型的应用程序框架,例如 GUI 应用程序、网络应用程序或游戏。
当然,如果你使用高质量和全面的库,你可以减少需要编写的代码行数。使用库还有以下两个优点:
-
整体设计得到改进,尤其是如果您使用框架(因为它强加了一个架构到您的应用程序上),它将由知识渊博的工程师创建,并由众多用户经过时间考验。
-
错误数量将减少,因为它们将经过比您可能能够应用的更彻底的测试。
实际上有很多 Rust 库,也称为 crates,但大多数质量较低或应用范围相当狭窄。本书将探讨 Rust 语言一些典型应用领域的最佳质量和最完整的库。
应用程序领域如下:
-
Web 应用程序:有各种流行的技术,包括以下:
-
REST 网络服务(仅后端)
-
一个事件驱动的网络客户端(仅前端)
-
一个完整的网络应用程序(全栈)
-
仅前端的一个网络游戏
-
-
游戏:当我说“游戏”时,我并不是指任何娱乐性的东西。我指的是一个显示连续动画的图形应用程序,与事件驱动的图形应用程序相反,后者在事件发生之前不做任何事情,例如用户按下键、移动鼠标或从连接中接收到一些数据。除了网页浏览器中的游戏,还有桌面和笔记本电脑游戏、游戏机游戏以及移动设备游戏。然而,游戏机和移动设备目前还没有得到 Rust 的良好支持,所以本书中我们将只探讨桌面和笔记本电脑游戏。
-
语言解释器:有两种可以解释的语言。这两种语言都在本书中进行了介绍:
-
文本:类似于编程语言、标记语言或机器命令语言
-
二进制:类似于要模拟的计算机的机器语言,或编程语言的中间字节码。
-
-
C 语言可调用的库:这是 Rust 的一个重要用例:开发一个可以被其他应用程序调用的库,通常是用高级语言编写的。Rust 不能假设其他语言可以调用 Rust 代码,但它可以假设它们可以调用 C 语言代码。我们将探讨如何构建一个可以像 C 语言编写的库一样调用的库。一个特别具有挑战性的案例是构建 Linux 操作系统的模块,它臭名昭著地必须用 C 语言编写。
大多数应用程序都会从文件、通信通道或数据库中读取和写入数据。在下一章中,我们将探讨各种不同的技术,这些技术将对所有其他项目都有用。
其他应用领域尚未在此列出,因为它们在 Rust 中要么使用不多,要么还不够成熟,或者它们仍然处于变动之中。这些不成熟领域的库在几年后将完全不同。这些领域包括微控制器软件、或其他实时或低资源系统软件,以及移动或可穿戴系统软件。
在本书中通过示例进行实践
要跟随书中的示例,您应该从在线仓库下载所有示例:github.com/PacktPublishing/Creative-Projects-for-Rust-Programmers。此仓库包含每个章节的子文件夹,以及章节中任何项目的子子文件夹。
例如,要运行本章中的use_rand项目,您应该前往Chapter01/use_rand文件夹,并输入cargo run。请注意,任何项目的最重要文件是cargo.toml和src/main.rs,因此您应该首先查看它们。
探索一些实用工具包
在继续查看如何使用最复杂的包之前,让我们先看看一些基本的 Rust 包。这些不是标准库的一部分,但它们在许多不同类型的项目中都很实用。所有 Rust 开发者都应该了解它们,因为它们具有通用适用性。
伪随机数生成器 – rand 包
生成伪随机数的能力对于多种应用都是必需的,特别是对于游戏。rand 包相当复杂,但它的基本用法在以下示例(命名为 use_rand)中展示:
// Declare basic functions for pseudo-random number generators.
use rand::prelude::*;
fn main() {
// Create a pseudo-Random Number Generator for the current thread
let mut rng = thread_rng();
// Print an integer number
// between 0 (included) and 20 (excluded).
println!("{}", rng.gen_range(0, 20));
// Print a floating-point number
// between 0 (included) and 1 (excluded).
println!("{}", rng.gen::<f64>());
// Generate a Boolean.
println!("{}", if rng.gen() { "Heads" } else { "Tails" });
}
首先,你创建一个伪随机数生成器对象。然后,你在这个对象上调用几个方法。任何生成器都必须是 可变的,因为任何生成都会修改生成器的状态。
gen_range 方法在右开区间内生成一个整数。gen 泛型方法生成指定类型的数字。有时,这种类型可以推断出来,就像在最后一个语句中,期望的是一个布尔值。如果生成的类型是浮点数,它在 0 和 1 之间,但不包括 1。
日志记录 – log 包
对于任何类型的软件,特别是对于服务器,发出日志消息的能力是必不可少的。日志架构有两个组件:
-
API:由
log包定义 -
实现:由几个可能的包定义
这里,展示了使用流行的 env_logger 包的示例。如果你想要从库中发出日志消息,你应该只添加 API 包作为依赖项,因为定义日志实现包是应用程序的责任。
在以下示例(命名为 use_env_logger)中,我们展示了一个应用程序(而不是库),因此我们需要这两个包:
#[macro_use]
extern crate log;
fn main() {
env_logger::init();
error!("Error message");
warn!("Warning message");
info!("Information message");
debug!("Debugging message");
}
在 Unix 类似控制台中,在运行 cargo build 之后,执行以下命令:
RUST_LOG=debug ./target/debug/use_env_logger
它将打印类似以下内容:
[2020-01-11T15:43:44Z ERROR logging] Error message
[2020-01-11T15:43:44Z WARN logging] Warning message
[2020-01-11T15:43:44Z INFO logging] Information message
[2020-01-11T15:43:44Z DEBUG logging] Debugging message
在命令开始处输入 RUST_LOG=debug,你定义了临时环境变量 RUST_LOG,其值为 debug。debug 级别是最高的,因此所有日志语句都会执行。相反,如果你执行以下命令,只有前三条线将被打印,因为 info 级别不够详细,无法打印调试信息:
RUST_LOG=info ./target/debug/use_env_logger
同样,如果你执行以下命令,只有前两行将被打印,因为 warn 级别不够详细,无法打印 debug 或 info 消息:
RUST_LOG=warn ./target/debug/use_env_logger
如果你执行以下命令之一,只有第一行将被打印,因为默认的日志级别是 error:
-
RUST_LOG=error ./target/debug/use_env_logger -
./target/debug/use_env_logger
在运行时初始化静态变量 – lazy_static 包
众所周知,Rust 不允许在安全代码中存在可变静态变量。在安全代码中允许存在不可变静态变量,但它们必须由常量表达式初始化,可能通过调用const fn函数来实现。然而,编译器必须能够评估任何静态变量的初始化表达式。
有时,然而,需要在使用时初始化静态变量,因为初始值取决于输入,如命令行参数或配置选项。此外,如果变量的初始化需要很长时间,而不是在程序开始时初始化它,那么可能最好只在变量第一次使用时初始化它。这种技术被称为延迟初始化。
有一个小 crate,名为lazy_static,它只包含一个与 crate 同名宏。这可以用来解决之前提到的问题。其使用方法如下(项目名为use_lazy_static):
use lazy_static::lazy_static;
use std::collections::HashMap;
lazy_static! {
static ref DICTIONARY: HashMap<u32, &'static str> = {
let mut m = HashMap::new();
m.insert(11, "foo");
m.insert(12, "bar");
println!("Initialized");
m
};
}
fn main() {
println!("Started");
println!("DICTIONARY contains {:?}", *DICTIONARY);
println!("DICTIONARY contains {:?}", *DICTIONARY);
}
这将打印以下输出:
Started
Initialized
DICTIONARY contains {12: "bar", 11: "foo"}
DICTIONARY contains {12: "bar", 11: "foo"}
如您所见,main函数首先开始执行。然后,它尝试访问DICTIONARY静态变量,这次访问导致变量的初始化。初始化的值,即一个引用,随后被解引用并打印出来。
最后一条语句,与之前的语句相同,不会再次执行初始化,正如您所看到的,Initialized文本没有再次打印出来。
解析命令行 – structopt crate
任何程序的命令行参数都很容易通过std::env::args()迭代器访问。然而,解析这些参数的代码实际上相当繁琐。为了获得更易于维护的代码,可以使用structopt crate,如下面的项目所示(项目名为use_structopt):
use std::path::PathBuf;
use structopt::StructOpt;
#[derive(StructOpt, Debug)]
struct Opt {
/// Activate verbose mode
#[structopt(short = "v", long = "verbose")]
verbose: bool,
/// File to generate
#[structopt(short = "r", long = "result", parse(from_os_str))]
result_file: PathBuf,
/// Files to process
#[structopt(name = "FILE", parse(from_os_str))]
files: Vec<PathBuf>,
}
fn main() {
println!("{:#?}", Opt::from_args());
}
如果你执行cargo run input1.txt input2.txt -v --result res.xyz命令,你应该得到以下输出:
Opt {
verbose: true,
result_file: "res.txt",
files: [
"input1.tx",
"input2.txt"
]
}
如您所见,文件名input1.txt和input2.txt已经被加载到结构的files字段中。--result res.xyz参数导致result_file字段被填充,而-v参数导致verbose字段被设置为true,而不是默认的false。
摘要
在本章中,我们介绍了新的 Rust 2018 版。我们学习了本书将要描述的项目类型。然后,我们快速浏览了四个有用的 crate,你可以在你的 Rust 代码中应用它们。
在下一章中,我们将学习如何将数据存储或检索到文件、数据库或其他应用程序中。
问题
-
有没有官方的 Rust 语言学习书籍?
-
2015 年最长的原始 Rust 整数有多长,到 2018 年底又是多长?
-
2018 年底有哪四个领域工作组?
-
Clippy 工具的目的何在?
-
rustfix工具的目的是什么? -
编写一个程序,生成 10 个介于 100 和 400 之间的伪随机
f32数字。 -
编写一个程序,生成 10 个介于 100 到 400 之间的伪随机
i32数字(不截断或四舍五入前一个练习中生成的数字)。 -
编写一个程序,创建一个包含 1 到 200 之间所有平方整数的静态向量。
-
编写一个程序,输出警告信息和信息消息,然后运行它,以便只显示警告信息。
-
尝试解析一个包含 1 到 20 之间值的命令行参数,如果值超出范围,则输出错误信息。短选项应为
-l,长选项应为--level。
存储和检索数据
任何软件应用的典型需求是通过读取/写入数据文件或数据流或通过查询/操作数据库来输入/输出数据。至于文件和流,非结构化数据,甚至二进制数据,很难操作,因此不建议使用。
此外,由于供应商锁定风险,不建议使用专有数据格式,因此应仅使用标准数据格式。幸运的是,在这些情况下,有免费的 Rust 库可以提供帮助。有 Rust crate 可以操作一些最受欢迎的文件格式,例如 TOML、JSON 和 XML。
在数据库方面,有 Rust crate 可以用来操作一些最受欢迎的数据库,例如 SQLite、PostgreSQL 和 Redis。
在本章中,你将学习以下内容:
-
如何从 TOML 文件中读取配置数据
-
如何读取或写入 JSON 数据文件
-
如何读取 XML 数据文件
-
如何查询或操作 SQLite 数据库中的数据
-
如何查询或操作 PostgreSQL 数据库中的数据
-
如何查询或操作 Redis 数据库中的数据
第三章:技术要求
当你运行 SQLite 代码时,需要安装 SQLite 运行时库。然而,安装 SQLite 交互式管理器也是有用的(尽管不是必需的)。你可以从www.sqlite.org/download.html下载 SQLite 工具的预编译二进制文件。然而,版本 3.11 或更高版本是理想的。
请注意,如果你使用的是基于 Debian 的 Linux 发行版,应该安装libsqlite3-dev包。
当你运行 PostgreSQL 代码时,还需要安装和运行 PostgreSQL 数据库管理系统(DBMS)。与 SQLite 类似,安装 PostgreSQL 交互式管理器是有用的,但不是必需的。你可以从www.postgresql.org/download/下载 PostgreSQL DBMS 的预编译二进制文件。然而,版本 7.4 或更高版本是可以接受的。
当你运行 Redis 代码时,安装和运行 Redis 服务器是必要的。你可以从redis.io/download下载它。
本章的完整源代码可以在github.com/PacktPublishing/Creative-Projects-for-Rust-Programmers存储库的Chapter02文件夹中找到。在这个文件夹中,每个项目都有一个子文件夹,还有一个名为data的文件夹,其中包含我们将用作项目输入的数据。
项目概述
在本章中,我们将探讨如何构建一个程序,该程序将 JSON 文件和 XML 文件加载到三个数据库中:SQLite 数据库、PostgreSQL 数据库和 Redis 键值存储。为了避免将文件名和位置以及数据库凭据硬编码到程序中,我们将从 TOML 配置文件中加载它们。
最后一个项目命名为 transformer,但我们将通过几个初步的小项目来解释这一点:
-
toml_dynamic和toml_static: 这两种方式以不同的方式读取一个 TOML 文件。 -
json_dynamic和json_static: 这两种方式以不同的方式读取一个 JSON 文件。 -
xml_example: 这将读取一个 XML 文件。 -
sqlite_example: 这将在 SQLite 数据库中创建两个表,向它们插入记录并查询它们。 -
postgresql_example: 这将在 PostgreSQL 数据库中创建两个表,向它们插入记录并查询它们。 -
redis_example: 这向一个键值存储添加一些数据并查询它。
读取一个 TOML 文件
在文件系统中存储信息的一个简单且易于维护的方法是使用文本文件。这对于数据量不超过 100 KB 的情况也非常高效。然而,在文本文件中存储信息存在几个相互竞争的标准,如 INI、CSV、JSON、XML、YAML 等。
Cargo 使用的格式是 TOML。这是一个非常强大的格式,被许多 Rust 开发者用来存储他们应用程序的配置数据。它设计为手动编写,使用文本编辑器,但它也可以很容易地由应用程序编写。
toml_dynamic 和 toml_static 项目(使用 toml 包)从 TOML 文件加载数据。在配置软件应用程序时读取 TOML 文件非常有用,这正是我们将要做的。我们将使用 data/config.toml 文件,它包含本章所有项目的所有参数。
您也可以通过使用代码来创建或修改一个 TOML 文件,但我们不会这么做。在某些场景下,能够修改 TOML 文件可能很有用,例如保存用户偏好设置。
重要的是要考虑,当 TOML 文件被程序修改时,它会经历重大的重构:
-
它获得了特定的格式,你可能不喜欢。
-
它失去了所有的注释。
-
它的项目按字母顺序排序。
因此,如果您想同时使用 TOML 格式手动编辑参数和程序保存的数据,您最好使用两个不同的文件:
-
一个仅由人类编辑的
-
主要由你的软件编辑,但偶尔也由人类编辑
本章描述了两个项目,在这些项目中使用不同的技术读取 TOML 文件。这些技术将在两种不同的情况下使用:
-
在我们不确定文件中包含哪些字段的情况下,我们想要探索它。在这种情况下,我们使用
toml_dynamic程序。 -
在另一种情况下,在我们的程序中,我们精确地描述了文件中应包含哪些字段,并且不接受不同的格式。在这种情况下,我们使用
toml_static程序。
使用 toml_dynamic
本节的目的在于在我们想要探索该文件内容时读取位于data文件夹中的config.toml文件。该文件的最初三行如下:
[input]
xml_file = "../data/sales.xml"
json_file = "../data/sales.json"
在这些行之后,文件包含其他部分。其中之一是[postgresql]部分,它包含以下行:
database = "Rust2018"
要运行此项目,请进入toml_dynamic文件夹,并输入cargo run ../data/config.toml。应该打印出长输出。它将从以下行开始:
Original: Table(
{
"input": Table(
{
"json_file": String(
"../data/sales.json",
),
"xml_file": String(
"../data/sales.xml",
),
},
),
注意,这仅仅是config.toml文件前三行的详细表示。此输出随后继续发出文件其余部分的类似表示。在打印出表示读取的文件的整个数据结构之后,输出中添加了以下行:
[Postgresql].Database: Rust2018
这是读取文件时加载数据结构的具体查询结果。
让我们看看toml_dynamic程序的代码:
- 声明一个变量,该变量将包含整个文件的描述。此变量在接下来的三个语句中初始化:
let config_const_values =
- 我们将命令行中的第一个参数中的文件路径添加到
config_path中。然后,我们将此文件的 内容加载到config_text字符串中,并将此字符串解析为toml::Value结构。这是一个递归结构,因为它可以在其字段中具有Value属性:
{
let config_path = std::env::args().nth(1).unwrap();
let config_text =
std::fs::read_to_string(&config_path).unwrap();
config_text.parse::<toml::Value>().unwrap()
};
- 然后使用调试结构化格式(
:#?)打印此结构,并从中检索一个值:
println!("Original: {:#?}", config_const_values);
println!("[Postgresql].Database: {}",
config_const_values.get("postgresql").unwrap()
.get("database").unwrap()
.as_str().unwrap());
注意,要获取包含在"postgresql"部分中的"database"项的值,需要编写大量的代码。get函数需要查找一个字符串,这可能会失败。这就是不确定性的代价。
使用 toml_static
另一方面,如果我们对 TOML 文件的组织结构非常有信心,我们应该使用项目中展示的另一种技术,即toml_static。
要运行它,请打开toml_static文件夹,并输入cargo run ../data/config.toml。程序将仅打印以下行:
[postgresql].database: Rust2018
此项目使用两个额外的 crate:
-
serde: 这启用了基本序列化/反序列化操作。 -
serde_derive: 这提供了一个名为custom-derive的强大附加功能,允许您使用结构体进行序列化/反序列化。
serde是标准的序列化/反序列化库。序列化是将程序中的数据结构转换为字符串(或流)的过程。反序列化是相反的过程;它是将字符串(或流)转换为程序中的某些数据结构的过程。
要读取 TOML 文件,我们需要使用反序列化。
在这两个项目中,我们不需要使用序列化,因为我们不打算编写 TOML 文件。
在代码中,首先,为data/config.toml文件中包含的任何部分定义了一个结构。该文件包含Input、Redis、Sqlite和Postgresql部分,因此我们声明了与我们要读取的文件部分一样多的 Rust 结构;然后,定义了Config结构来表示整个文件,这些部分作为其成员。
例如,这是Input部分的架构:
#[allow(unused)]
#[derive(Deserialize)]
struct Input {
xml_file: String,
json_file: String,
}
注意,前面的声明前面有两个属性。
使用allow(unused)属性来防止编译器警告我们关于以下结构中未使用的字段。这对于我们避免这些嘈杂的警告很方便。使用derive(Deserialize)属性来激活serde对以下结构的自动反序列化。
在这些声明之后,可以编写以下代码行:
toml::from_str(&config_text).unwrap()
这将调用from_str函数,该函数将文件的文本解析为结构。此表达式中未指定该结构的类型,但其值被分配给main函数第一行中声明的变量:
let config_const_values: Config =
因此,它的类型是Config。
如果文件内容与结构类型之间存在任何差异,将在此操作中视为错误。因此,如果此操作成功,对结构的任何其他操作都不会失败。
虽然前面的程序(toml_dynamic)具有类似于 Python 或 JavaScript 的动态类型,但此程序具有类似于 Rust 或 C++的静态类型。
静态类型的优势体现在最后一条语句中,通过简单地编写config_const_values.postgresql.database,就获得了与先前项目长语句相同的行为。
读取和写入 JSON 文件
对于存储比配置文件中存储的数据更复杂的数据,JSON 格式更为合适。这种格式相当流行,尤其是在使用 JavaScript 语言的人中。
我们将读取并解析data/sales.json文件。此文件包含一个单个匿名对象,该对象包含两个数组——"products"和"sales"。
"products"数组包含两个对象,每个对象都有三个字段:
"products": [
{
"id": 591,
"category": "fruit",
"name": "orange"
},
{
"id": 190,
"category": "furniture",
"name": "chair"
}
],
"sales"数组包含三个对象,每个对象包含五个字段:
"sales": [
{
"id": "2020-7110",
"product_id": 190,
"date": 1234527890,
"quantity": 2.0,
"unit": "u."
},
{
"id": "2020-2871",
"product_id": 591,
"date": 1234567590,
"quantity": 2.14,
"unit": "Kg"
},
{
"id": "2020-2583",
"product_id": 190,
"date": 1234563890,
"quantity": 4.0,
"unit": "u."
}
]
数组中的信息涉及一些要出售的产品以及与这些产品相关的销售交易。请注意,每个销售的第二字段("product_id")是对一个产品的引用,因此它应该在相应的产品对象创建后进行处理。
我们将看到具有相同行为的两个程序。它们读取 JSON 文件,将第二个销售对象的数量增加1.5,然后将整个更新后的结构保存到另一个 JSON 文件中。
类似于 TOML 格式的情况,对于 JSON 文件也可以使用动态解析技术,其中任何数据字段的存在的类型由应用程序代码检查,以及静态解析技术,其中它使用反序列化库来检查任何字段的存在的类型。
因此,我们有两个项目:json_dynamic 和 json_static。要运行每个项目,打开其文件夹,并输入 cargo run ../data/sales.json ../data/sales2.json。程序将不会打印任何内容,但它将读取命令行中指定的第一个文件,并创建指定的第二个文件。
创建的文件与读取的文件相似,但有以下不同之处:
-
由
json_dynamic创建的文件的字段按字母顺序排序,而由json_static创建的文件的字段按 Rust 数据结构的相同顺序排序。 -
第二次销售的量从
2.14增加到3.64。 -
在两个创建的文件中,最后的空行都被移除了。
现在,我们可以看到序列化和反序列化的两种技术的实现。
json_dynamic 项目
让我们看看这个项目的源代码:
-
此项目从命令行获取两个文件的路径名——现有的 JSON 文件(
"input_path"),将其读入内存结构中,以及一个要创建的 JSON 文件("output_path"),通过保存修改后的结构来创建。 -
然后,将输入文件加载到名为
sales_and_products_text的字符串中,并使用serde_json::from_str::<Value>函数将字符串解析为表示 JSON 文件的动态类型结构。此结构存储在sales_and_products本地变量中。
假设我们想要改变第二个销售交易的销量,增加 1.5 公斤:
- 首先,我们必须使用以下表达式获取此值:
sales_and_products["sales"][1]["quantity"]
-
这检索了通用对象的
"sales"子对象。它是一个包含三个对象的数组。 -
然后,这个表达式获取这个数组的第二个项目(从零开始计数
[1])。这是一个表示单个销售交易的对象。 -
然后,它获取销售交易对象的
"quantity"子对象。 -
我们达到的值具有动态类型,我们认为应该是
serde_json::Value::Number,因此我们与该类型进行模式匹配,指定if let Value::Number(n)子句。 -
如果一切顺利,匹配成功,我们将得到一个名为
n的变量——包含一个数字,或者可以通过使用as_f64函数将其转换为 Rust 浮点数的东西。最后,我们可以增加 Rust 数字,然后使用from_f64函数从它创建一个 JSON 数字。然后我们可以使用相同的表达式将此对象分配给 JSON 结构:
sales_and_products["sales"][1]["quantity"]
= Value::Number(Number::from_f64(
n.as_f64().unwrap() + 1.5).unwrap());
- 程序的最后一条语句将 JSON 结构保存到文件中。在这里,使用了
serde_json::to_string_pretty函数。正如其名所示,这个函数添加了格式化空白(空格和新行),使得生成的 JSON 文件更易于人类阅读。还有一个serde_json::to_string函数,它创建了一个更紧凑的相同信息版本。对于人类来说,阅读起来更困难,但对于计算机来说处理速度更快:
std::fs::write(
output_path,
serde_json::to_string_pretty(&sales_and_products).unwrap(),
).unwrap();
json_static 项目
如果我们确信我们的程序知道 JSON 文件的结构,可以使用静态类型技术,这是在 json_static 项目中展示的。这里的情形与处理 TOML 文件的项目类似。
静态版本源代码首先声明了三个结构体——每个结构体对应于我们将要处理的 JSON 文件中包含的对象类型。每个结构体前面都有以下属性:
#[derive(Deserialize, Serialize, Debug)]
让我们理解前面的代码片段:
-
Deserialize特性是必需的,用于将 JSON 字符串解析(即读取)到这个结构体中。 -
Serialize特性是必需的,用于将这个结构体格式化(即写入)为 JSON 字符串。 -
Debug特性非常适合在调试跟踪中打印这个结构体。
使用 serde_json::from_str::<SalesAndProducts> 函数解析 JSON 字符串。然后,增加已售橙子的数量的代码变得相当简单:
sales_and_products.sales[1].quantity += 1.5
程序的其余部分保持不变。
读取 XML 文件
另一个非常流行的文本格式是 XML。不幸的是,没有稳定的序列化/反序列化库来管理 XML 格式。然而,这并不一定是缺点。实际上,XML 格式通常用于存储大型数据集;实际上非常大,以至于在我们开始将数据转换为内部格式之前,加载所有这些数据可能是不高效的。在这些情况下,扫描文件或传入的流并读取时进行处理可能更有效。
xml_example 项目是一个相当复杂的程序,它扫描命令行上指定的 XML 文件,并以过程式的方式将文件中的信息加载到 Rust 数据结构中。它的目的是读取 ../data/sales.xml 文件。这个文件的结构与我们在上一节中寻找的 JSON 文件相对应。以下是一些该文件的摘录:
<?xml version="1.0" encoding="utf-8"?>
<sales-and-products>
<product>
<id>862</id>
</product>
<sale>
<id>2020-3987</id>
</sale>
</sales-and-products>
所有 XML 文件的第一行都有一个头部,然后是一个根元素;在这个例子中,根元素被命名为 sales-and-products。这个元素包含两种类型的元素——product 和 sale。这两种类型的元素都有特定的子元素,它们是对应数据的字段。在这个例子中,只显示了 id 字段。
要运行项目,打开其文件夹,输入 cargo run ../data/sales.xml。控制台将打印出一些行。前四行应该是这样的:
Got product.id: 862.
Got product.category: fruit.
Got product.name: cherry.
Exit product: Product { id: 862, category: "fruit", name: "cherry" }
这些描述了指定 XML 文件的内容。特别是,程序找到了一个 ID 为862的产品,然后检测到它是一种水果,然后是樱桃,最后,当整个产品被读取完毕后,整个表示产品的结构被打印出来。对于销售也会有类似的输出。
解析仅使用xml-rs crate 执行。这个 crate 提供了一个解析机制,如下面的代码片段所示:
let file = std::fs::File::open(pathname).unwrap();
let file = std::io::BufReader::new(file);
let parser = EventReader::new(file);
for event in parser {
match &location_item {
LocationItem::Other => ...
LocationItem::InProduct => ...
LocationItem::InSale => ...
}
}
EventReader类型的对象扫描缓冲文件,并在解析过程中执行每一步时生成一个事件。应用程序代码根据其需求处理这些类型的事件。
这个 crate 使用事件这个词,但转换这个词可能更准确地描述了解析器提取的数据。
一个复杂的语言难以解析,但对我们这样的简单数据语言,解析过程中的情况可以通过状态机来建模。为此,源代码中声明了三个enum变量:location_item,类型为LocationItem;location_product,类型为LocationProduct;以及location_sale,类型为LocationSale。
第一个表示解析的一般位置。我们可能处于一个产品内部(InProduct),处于一个销售内部(InSale),或者处于两者之外(Other)。如果我们处于产品内部,LocationProduct枚举表示当前产品内部解析的位置。这可以是在任何允许的字段内,或者在外部所有字段之外。对于销售也会有类似的状态。
迭代遇到几种类型的事件。主要的有以下几种:
-
XmlEvent::StartElement:表示一个 XML 元素开始。它被开始元素的名称和该元素的可能的属性所装饰。 -
XmlEvent::EndElement:表示一个 XML 元素结束。它被结束元素的名称所装饰。 -
XmlEvent::Characters:表示一个元素的文本内容可用。它被该可用文本所装饰。
程序声明了一个可变的product结构体,类型为Product,和一个可变的sale结构体,类型为Sale。它们使用默认值初始化。每当有字符可用时,它们被存储在当前结构体的相应字段中。
例如,考虑一个location_item的值为LocationItem::InProduct而location_product的值为LocationProduct::InCategory的情况——即,我们处于一个产品的类别中。在这种情况下,可能会有类别的名称或类别的结束。要获取类别的名称,代码中包含了一个match语句的模式:
Ok(XmlEvent::Characters(characters)) => {
product.category = characters.clone();
println!("Got product.category: {}.", characters);
}
在这个语句中,characters变量获取类别的名称,并将其克隆赋值给product.category字段。然后,名称被打印到控制台。
访问数据库
当文本文件很小且不需要经常更改时,文本文件是好的。实际上,更改文本文件的唯一方法是在其末尾追加内容或完全重写它。如果您想快速更改大型数据集中的信息,唯一的方法是使用数据库管理器。在本节中,我们将通过一个简单的示例学习如何操作 SQLite 数据库。
但首先,让我们看看三种流行的、广泛的数据库管理器类别:
-
单用户数据库:这些数据库将所有数据库存储在一个单独的文件中,该文件必须可通过应用程序代码访问。数据库代码被链接到应用程序中(它可能是一个静态链接库或动态链接库)。一次只允许一个用户访问它,并且所有用户都具有管理权限。要将数据库移动到任何地方,只需移动文件即可。在这个类别中最受欢迎的选择是 SQLite 和 Microsoft Access。
-
数据库管理系统(DBMS):这是一个必须作为服务启动的过程。多个客户端可以同时连接到它,并且它们可以同时应用更改而不会导致数据损坏。它需要更多的存储空间、更多的内存以及更长的启动时间(对于服务器)。在这个类别中有几个流行的选择,例如 Oracle、Microsoft SQL Server、IBM DB2、MySQL 和 PostgreSQL。
-
键值存储:这是一个必须作为服务启动的过程。多个客户端可以同时连接到它,并且可以同时应用更改。它本质上是一个大型的内存哈希表,可以被其他进程查询,并且可以选择将其数据存储在文件中,并在重启时重新加载。这个类别比其他两个类别不太受欢迎,但随着高性能网站后端的需求增加,它正在获得更多的关注。最受欢迎的选择之一是 Redis。
在接下来的几节中,我们将向您展示如何访问 SQLite 单用户数据库(在sqlite_example项目中)、PostgreSQL 数据库管理系统(在postgreSQL_example项目中)和 Redis 键值存储(在redis_example项目中)。然后,在transformer项目中,将使用这三种类型的数据库。
访问 SQLite 数据库
本节的源代码位于sqlite_example项目中。要运行它,打开其文件夹并输入cargo run。
这将在当前文件夹中创建sales.db文件。此文件包含一个 SQLite 数据库。然后,它将在该数据库中创建Products和Sales表,并在每个表中插入一行,然后对数据库执行查询。查询要求获取所有销售信息,并将每个销售与其相关联的产品结合起来。对于每个提取的行,将在控制台上打印一行,显示销售的日期时间、销售重量和关联产品的名称。由于数据库中只有一个销售记录,您将看到以下行被打印出来:
At instant 1234567890, 7.439 Kg of pears were sold.
此项目仅使用 rusqlite 包。其名称是 Rust SQLite 的缩写。要使用此包,Cargo.toml 文件必须包含以下行:
rusqlite = "0.23"
实现项目
让我们看看 sqlite_example 项目的代码是如何工作的。main 函数相当简单:
fn main() -> Result<()> {
let conn = create_db()?;
populate_db(&conn)?;
print_db(&conn)?;
Ok(())
}
它调用 create_db 来打开或创建一个包含空表的数据库,并打开并返回到此数据库的连接。
然后,它调用 populate_db 来将行插入由该连接引用的数据库的表中。
然后,它调用 print_db 来执行对这个数据库的查询,并打印出该查询提取的数据。
create_db 函数虽然长,但很容易理解:
fn create_db() -> Result<Connection> {
let database_file = "sales.db";
let conn = Connection::open(database_file)?;
let _ = conn.execute("DROP TABLE Sales", params![]);
let _ = conn.execute("DROP TABLE Products", params![]);
conn.execute(
"CREATE TABLE Products (
id INTEGER PRIMARY KEY,
category TEXT NOT NULL,
name TEXT NOT NULL UNIQUE)",
params![],
)?;
conn.execute(
"CREATE TABLE Sales (
id TEXT PRIMARY KEY,
product_id INTEGER NOT NULL REFERENCES Products,
sale_date BIGINT NOT NULL,
quantity DOUBLE PRECISION NOT NULL,
unit TEXT NOT NULL)",
params![],
)?;
Ok(conn)
}
Connection::open 函数简单地使用一个指向 SQLite 数据库文件的路径来打开一个连接。如果此文件不存在,它将被创建。正如你所见,创建的 sales.db 文件非常小。通常,DBMS 的空数据库比这大 1,000 倍。
要执行数据操作命令,调用连接的 execute 方法。它的第一个参数是一个 SQL 语句,可能包含一些参数,指定为 $1、$2、$3 等。函数的第二个参数是一个值切片的引用,用于替换这些参数。
当然,如果没有参数,参数值列表必须为空。第一个参数值(索引为 0)替换 $1 参数,第二个替换 $2 参数,依此类推。
注意,参数化 SQL 语句的参数可以是不同的数据类型(数值、字母数字、BLOBs 等),但 Rust 集合只能包含相同数据类型的对象。因此,使用 params! 宏来执行一些魔法操作。execute 方法的第二个参数的数据类型必须是可以迭代的集合,其项目实现了 ToSql 特性。实现此特性的对象,正如其名所示,可以用作 SQL 语句的参数。rusqlite 包含了许多 Rust 基本类型(如数字和字符串)的此特性的实现。
例如,params!(34, "abc") 表达式生成一个可以迭代的集合。这个迭代的第一个项目可以转换成一个包含数字 34 的对象,这个数字可以用来替换一个数值类型的 SQL 参数。第二个项目可以转换成一个包含 "abc" 字符串的对象,这个字符串可以用来替换一个字母数字类型的 SQL 参数。
现在,让我们看看 populate_db 函数。它包含将行插入数据库的语句。以下是其中之一:
conn.execute(
"INSERT INTO Products (
id, category, name
) VALUES ($1, $2, $3)",
params![1, "fruit", "pears"],
)?;
如前所述,此语句将执行以下 SQL 语句:
INSERT INTO Products (
id, category, name
) VALUES (1, 'fruit', 'pears')
最后,我们看到整个 print_db 函数,它比其他函数更复杂:
fn print_db(conn: &Connection) -> Result<()> {
let mut command = conn.prepare(
"SELECT p.name, s.unit, s.quantity, s.sale_date
FROM Sales s
LEFT JOIN Products p
ON p.id = s.product_id
ORDER BY s.sale_date",
)?;
for sale_with_product in command.query_map(params![], |row| {
Ok(SaleWithProduct {
category: "".to_string(),
name: row.get(0)?,
quantity: row.get(2)?,
unit: row.get(1)?,
date: row.get(3)?,
})
})? {
if let Ok(item) = sale_with_product {
println!(
"At instant {}, {} {} of {} were sold.",
item.date, item.quantity, item.unit, item.name
);
}
}
Ok(())
}
要执行 SQL 查询,首先必须通过调用连接的 prepare 方法来准备 SELECT SQL 语句,将其转换为高效的内部格式,使用 Statement 数据类型。此对象被分配给 command 变量。准备好的语句必须是可变的,以便允许以下参数的替换。然而,在这种情况下,我们没有任何参数。
一个查询可以生成多行,我们希望逐行处理,因此必须从这个命令创建一个迭代器。这是通过调用命令的 query_map 方法来完成的。此方法接收两个参数——一个参数值切片和一个闭包——并返回一个迭代器。query_map 函数执行两个任务——首先,它替换指定的参数,然后使用闭包将提取的每一行映射(或转换)为更方便的结构。但在我们的情况下,我们没有要替换的参数,所以我们只使用 SaleWithProduct 类型创建一个特定的结构。要从行中提取字段,使用 get 方法。它具有在 SELECT 查询中指定的字段上的零基索引。此结构是迭代器返回的对象,用于查询提取的任何行,并将其分配给名为 sale_with_product 的迭代变量。
现在我们已经学习了如何访问 SQLite 数据库,让我们来检查 PostgreSQL 数据库管理系统。
访问 PostgreSQL 数据库
我们在 SQLite 数据库中做的事情与我们将要在 PostgreSQL 数据库中做的事情相似。这是因为它们都基于 SQL 语言,但主要是因为 SQLite 被设计成与 PostgreSQL 类似。将应用程序从 PostgreSQL 转换为 SQLite 可能会更困难,因为前者有许多后者没有的先进功能。
在本节中,我们将转换上一节的示例,使其与 PostgreSQL 数据库而不是 SQLite 一起工作。因此,我们将解释其中的差异。
本节源代码位于 postgresql_example 文件夹中。要运行它,打开其文件夹并输入 cargo run。这将执行与 sqlite_example 中看到的基本相同的操作,因此创建并填充数据库后,将打印以下内容:
At instant 1234567890, 7.439 Kg of pears were sold.
项目的实现
此项目仅使用名为 postgres 的 crate。其名称是 postgresql 名称的流行缩写。
创建到 PostgreSQL 数据库的连接与创建到 SQLite 数据库的连接非常不同。因为后者只是一个文件,所以你以类似打开文件的方式执行,你应该写Connection::open(<db 文件路径>)。相反,要连接到 PostgreSQL 数据库,你需要访问一个运行服务器的计算机,然后访问该服务器监听的 TCP 端口,然后你需要在此服务器上指定你的凭据(你的用户名和密码)。可选地,然后你可以指定你想要使用此服务器管理的哪个数据库。
因此,调用的一般形式是Connection::connect(<URL>, <TlsMode>),其中 URL 可以是例如postgres://postgres:post@localhost:5432/Rust2018。URL 的一般形式是postgres://username[:password]@host[:port][/database],其中密码、端口和数据库部分是可选的。TlsMode参数指定连接是否必须加密。
端口是可选的,因为它默认值为 5432。另一个区别是,这个 crate 不使用params!宏。相反,它允许我们指定一个切片的引用。在这种情况下,它是一个空切片(&[]),因为我们不需要指定参数。
表创建和填充过程与sqlite_example中执行的方式相似。然而,查询是不同的。这是print_db函数的主体:
for row in &conn.query(
"SELECT p.name, s.unit, s.quantity, s.sale_date
FROM Sales s
LEFT JOIN Products p
ON p.id = s.product_id
ORDER BY s.sale_date",
&[],
)? {
let sale_with_product = SaleWithProduct {
category: "".to_string(),
name: row.get(0),
quantity: row.get(2),
unit: row.get(1),
date: row.get(3),
};
println!(
"At instant {}, {} {} of {} were sold.",
sale_with_product.date,
sale_with_product.quantity,
sale_with_product.unit,
sale_with_product.name
);
}
在 PostgreSQL 中,连接类的query方法执行参数替换,类似于execute方法,但它不将行映射到结构中。相反,它返回一个迭代器,可以立即在for语句中使用。然后,在循环体中,可以使用row变量(如示例中所示)来填充一个结构体。
既然我们已经知道如何访问 SQLite 和 PostgreSQL 数据库中的数据,让我们看看如何从 Redis 存储中存储和检索数据。
从 Redis 存储中存储和检索数据
一些应用程序需要某些类型的数据具有非常快的响应时间;比数据库管理系统(DBMS)能提供的还要快。通常,针对单个用户的 DBMS 会足够快,但对于某些应用程序(通常是大规模 Web 应用程序)来说,有数百个并发查询和许多并发更新。你可以使用多台计算机,但必须在它们之间保持数据的一致性,而保持一致性可能会造成性能瓶颈。
解决这个问题的方法是用一个键值存储,这是一个非常简单的数据库,可以在网络上进行复制。这可以将数据保存在内存中以最大化速度,但它也支持将数据保存到文件中的选项。这可以避免在服务器停止时丢失信息。
键值存储类似于 Rust 标准库中的HashMap集合,但它由一个服务器进程管理,该进程可能运行在不同的计算机上。查询是客户端和服务器之间交换的消息。Redis 是最常用的键值存储之一。
该项目的源代码位于redis_example文件夹中。要运行它,请打开文件夹并输入cargo run。这将打印以下内容:
a string, 4567, 12345, Err(Response was of incompatible type: "Response type not string compatible." (response was nil)), false.
这只是在当前计算机上创建一个数据存储,并在其中存储以下三个键值对:
-
"aKey",与"a string"相关联 -
"anotherKey",与4567相关联 -
45,与12345相关联
然后,它查询以下键:
-
"aKey",它获得一个"a string"值 -
"anotherKey",它获得一个4567值 -
45,它获得一个12345值 -
40,它获得一个错误
然后,它查询存储中是否存在40键,它返回false。
实施项目
在这个项目中只使用了rediscrate。
代码相当简短且简单。让我们看看它是如何工作的:
fn main() -> redis::RedisResult<()> {
let client = redis::Client::open("redis://localhost/")?;
let mut conn = client.get_connection()?;
首先,必须获取一个客户端。对redis::Client::open的调用接收一个 URL 并仅检查此 URL 是否有效。如果 URL 有效,则返回一个redis::Client对象,该对象没有打开的连接。然后,客户端的get_connection方法尝试连接,如果成功,则返回一个打开的连接。
任何连接本质上都有三个重要的方法:
-
set: 这尝试存储一个键值对。 -
get: 这尝试检索与指定键关联的值。 -
exists: 这尝试检测指定的键是否存在于存储中,而不检索其关联的值。
然后,set被调用了三次,键和值的类型不同:
conn.set("aKey", "a string")?;
conn.set("anotherKey", 4567)?;
conn.set(45, 12345)?;
最后,get被调用了四次,exists被调用了一次。前三次调用获取存储的值。第四次调用指定了一个不存在的值,因此返回了一个 null 值,它不能转换为所需的String,因此生成了一个错误:
conn.get::<_, String>("aKey")?,
conn.get::<_, u64>("anotherKey")?,
conn.get::<_, u16>(45)?,
conn.get::<_, String>(40),
conn.exists::<_, bool>(40)?);
你可以始终检查错误以找出你的键是否存在,但更干净的方法是调用exists方法,它返回一个布尔值,指定键是否存在。
通过这种方式,我们现在知道如何使用 Rust crates 通过最流行的数据库访问、存储和检索数据。
将所有这些放在一起
你现在应该知道足够的信息来构建一个示例,它执行了本章开头所描述的内容。我们学习了以下内容:
-
如何读取 TOML 文件以参数化程序
-
如何将有关产品和销售的数据加载到内存中,这些数据指定在 JSON 文件和 XML 文件中
-
如何将所有这些数据存储在三个地方:一个 SQLite 数据库文件、一个 PostgreSQL 数据库和一个 Redis 键值存储
完整示例的源代码可以在 transformer 项目中找到。要运行它,打开其文件夹并输入 cargo run ../data/config.toml。如果一切顺利,它将重新创建并填充 data/sales.db 文件中包含的 SQLite 数据库,PostgreSQL 数据库,该数据库可以通过 localhost 的 5432 端口访问,并命名为 Rust2018,以及 Redis 存储库,可以从 localhost 访问。然后,它将查询 SQLite 和 PostgreSQL 数据库中它们的表中的行数,并将打印以下内容:
SQLite #Products=4\.
SQLite #Sales=5\.
PostgreSQL #Products=4\.
PostgreSQL #Sales=5\.
因此,我们现在已经看到了一个相当广泛的数据操作示例。
摘要
在本章中,我们探讨了访问流行文本格式(TOML、JSON 和 XML)或由流行数据库管理器(SQLite、PostgreSQL 和 Redis)管理的数据的一些基本技术。当然,还存在许多其他文件格式和数据库管理器,关于这些格式和数据库管理器还有很多东西要学习。尽管如此,你现在应该已经掌握了它们的功能。这些技术对许多类型的应用程序都很有用。
在下一章中,我们将学习如何使用 REST 架构构建 Web 后端服务。为了使该章节独立,我们将仅使用框架来接收和响应 Web 请求,而不使用数据库。当然,这相当不切实际;但通过将这些 Web 技术与本章中介绍的技术相结合,你可以构建一个现实世界的 Web 服务。
问题
-
为什么用程序方式更改用户编辑的 TOML 文件不是一个好主意?
-
在什么情况下使用 TOML 或 JSON 文件的动态类型解析更好,而在什么情况下使用静态类型解析更好?
-
在什么情况下需要从
Serialize和Deserialize特性派生结构? -
什么是 JSON 字符串的漂亮生成?
-
为什么使用流解析器而不是单次调用解析器可能更好?
-
在什么情况下 SQLite 是更好的选择,而在什么情况下使用 PostgreSQL 更好?
-
将 SQL 命令传递给 SQLite 数据库管理器的参数类型是什么?
-
query方法在 PostgreSQL 数据库上做什么? -
读取和写入 Redis 键值存储中值的函数名称是什么?
-
你能尝试编写一个程序,从命令行获取一个 ID,查询 SQLite、PostgreSQL 或 Redis 数据库以获取该 ID,并打印有关找到的数据的一些信息吗?
创建 REST 网络服务
从历史上看,许多技术已被开发和用于创建客户端-服务器系统。然而,在最近几十年中,所有客户端-服务器架构都趋向于基于 Web——也就是说,基于 超文本传输协议 (HTTP)。HTTP 基于 传输控制协议 (TCP) 和 互联网协议 (IP)。特别是,两种基于 Web 的架构已经变得流行——简单对象访问协议 (SOAP) 和 表征状态转移 (REST)。
虽然 SOAP 是一个实际协议,但 REST 只是一系列 原则。遵循 REST 原则的网络服务被称为 RESTful。在本章中,我们将看到如何使用流行的 Actix 网络框架构建 RESTful 服务。
任何网络服务(包括 REST 网络服务)都可以被任何网络客户端使用——也就是说,任何可以发送 TCP/IP 网络上的 HTTP 请求的程序都可以作为网络客户端。最典型的网络客户端是在网络浏览器中运行的网页,并包含 JavaScript 代码。任何用任何编程语言编写并在实现 TCP/IP 协议的任何操作系统上运行的程序都可以作为网络客户端。
网络服务器也被称为 后端,而网络客户端被称为 前端。
本章将涵盖以下主题:
-
REST 架构
-
使用 Actix 网络框架构建网络服务的存根并实现 REST 原则
-
构建一个完整的网络服务,能够根据客户端请求上传文件、下载文件和删除文件
-
将内部状态作为内存数据库或数据库连接池来处理
-
使用 JavaScript 对象表示法 (JSON) 格式向客户端发送数据
第四章:技术要求
为了轻松理解本章内容,您应该具备 HTTP 的入门级知识。所需的概念如下:
-
统一资源标识符 (URIs)
-
方法(如
GET) -
标头
-
主体
-
内容类型(如
plain/text) -
状态码(如
Not Found=404)
在开始本章的项目之前,应在您的计算机上安装一个通用的 HTTP 客户端。示例中使用的工具是命令行工具 curl,在许多操作系统上免费提供。官方下载页面是 curl.haxx.se/download.html。特别是 Microsoft Windows 的页面是 curl.haxx.se/windows/。
或者,您可以使用几个免费的网络浏览器实用工具之一,例如 Chrome 的 Advanced REST Client 或 Firefox 的 RESTED 和 RESTer。
本章的完整源代码位于存储库的 Chapter03 文件夹中,该文件夹位于 github.com/PacktPublishing/Creative-Projects-for-Rust-Programmers。
REST 架构
REST 架构在 HTTP 协议的基础上构建得非常牢固,但它不要求任何特定的数据格式,因此它可以以多种格式传输数据,如纯文本、JSON、可扩展标记语言(XML)或二进制(编码为 Base64)。
许多网络资源描述了 REST 架构范式是什么。其中一个可以在en.wikipedia.org/wiki/Representational_state_transfer找到。
然而,REST 架构的概念非常简单。它是万维网(WWW)项目背后的思想的纯粹扩展。
万维网项目于 1989 年诞生,作为一个全球性的超文本图书馆。超文本是一种包含指向其他文档链接的文档,通过反复点击链接,你可以仅使用鼠标查看许多文档。这样的文档散布在互联网上,并由一个唯一的描述符,即统一资源定位符(URL)进行标识。共享此类文档的协议是 HTTP,文档是用超文本标记语言(HTML)编写的。文档可以嵌入图像,这些图像也通过 URL 地址进行引用。
HTTP 协议允许你将页面下载到你的文档查看器(网页浏览器)中,也可以上传新文档与他人共享。你还可以用新版本替换现有文档,或删除现有文档。
如果将文档或文件的概念替换为命名数据或资源的概念,你就得到了 REST 的概念。与 RESTful 服务器的任何交互都是对数据片段的操作,通过其名称进行引用。当然,这样的数据可以是磁盘文件,也可以是数据库中的一组记录,这些记录通过查询进行标识,甚至可以是内存中保留的变量。
RESTful 服务器的一个独特之处在于服务器端没有客户端会话。与任何超文本服务器一样,RESTful 服务器不会存储客户端已登录的事实。如果有与会话相关的数据,例如当前用户或之前访问的页面,这些数据仅属于客户端。因此,每当客户端需要访问受保护的服务或特定用户的数据时,请求必须包含用户的凭据。
为了提高性能,服务器可以将会话信息存储在缓存中,但这应该是透明的。服务器(除了性能之外)应该表现得好像它没有保留任何会话信息。
项目概述
我们将构建几个项目,每个项目都引入了新的功能。让我们依次看看每个项目:
-
第一个项目将构建一个服务的雏形,该服务应允许任何客户端上传、下载或从服务器删除文件。这个项目展示了如何创建 REST 应用程序编程接口(API),但它并不执行任何有用的操作。
-
第二个项目将实现前一个项目中描述的 API。它将构建一个服务,实际上允许任何客户端从服务器文件系统中上传、下载或删除文件。
-
第三个项目将构建一个服务,允许客户端向服务器进程中的内存数据库添加键值记录,并调用服务器中预定义的一些查询。这些查询的结果将以纯文本格式发送回客户端。
-
第四个项目将与第三个项目类似,但结果将以 JSON 格式编码。
我们的源代码很小,但它包括了 Actix web crate,而 Actix web crate 又包括了大约 200 个 crate,因此任何项目的第一次构建将需要大约 10 分钟。在应用代码的任何更改之后,构建将需要 12 到 30 秒。
选择 Actix web crate 是因为它是功能最全面、最可靠、高性能且文档良好的 Rust 后端 Web 应用程序框架。
这个框架不仅限于 RESTful 服务,因为它可以用来构建不同类型的后端 Web 软件。它是 Actix net 框架的扩展,这是一个旨在实现不同类型网络服务的框架。
重要的背景理论和上下文
之前我们提到,RESTful 服务基于 HTTP 协议。这是一个相当复杂的协议,但它的最重要的部分相当简单。下面是它的简化版本。
协议基于一对消息。首先,客户端向服务器发送请求,服务器在接收到这个请求后,通过向客户端发送响应来回复。这两个消息都是美国信息交换标准代码(ASCII)文本,因此它们很容易被操作。
HTTP 协议通常基于 TCP/IP 协议,这保证了这些消息到达指定的进程。
让我们看看一个典型的 HTTP 请求消息,如下所示:
GET /users/susan/index.html HTTP/1.1
Host: www.acme.com
Accept: image/png, image/jpeg, */*
Accept-Language: en-us
User-Agent: Mozilla/5.0
这条消息包含六行,因为结尾有一个空行。
第一行以单词GET开头。这个单词是方法,它指定了请求的操作。然后是一个 Unix 风格的路径,然后是协议的版本(这里,它是1.1)。
接下来是四行相对简单的属性。这些属性是头信息。有许多可能的可选头信息。
第一行空行之后的文本是主体。在这里,主体是空的。主体用于发送原始数据——甚至大量数据。
因此,任何 HTTP 协议的请求都会向特定的服务器发送一个命令名(方法),然后是一个资源标识符(路径)。然后是一系列属性(每行一个),然后是一个空行,最后是可能的原始数据(主体)。
最重要的方法如下详细说明:
-
GET:这请求从服务器下载资源(通常是 HTML 文件或图像文件,但也可能是任何数据)。路径指定了资源应读取的位置。 -
POST:这向服务器发送一些数据,服务器应将其视为新的。路径指定了添加这些数据的位置。如果路径标识了任何现有数据,服务器应返回错误代码。要发布的数据的内含在正文部分。 -
PUT:这与POST命令类似,但它的目的是替换现有数据。 -
DELETE:这请求根据路径指定的资源被移除。它有一个空体。
这里是一个典型的 HTTP 响应消息:
HTTP/1.1 200 OK
Date: Wed, 15 Apr 2020 14:03:39 GMT
Server: Apache/2.2.14
Accept-Ranges: bytes
Content-Length: 42
Connection: close
Content-Type: text/html
<html><body><p>Some text</p></body></html>
任何响应消息的第一行以协议版本开始,后跟文本格式和数字格式的状态码。成功表示为 200 OK。
然后,有几个标题——在这个例子中有六个——然后是一个空行,然后是正文,正文可能为空。在这种情况下,正文包含一些 HTML 代码。
您可以在以下位置找到有关 HTTP 协议的更多信息:en.wikipedia.org/wiki/Hypertext_Transfer_Protocol。
构建 REST 服务的存根
REST 服务的典型示例是为上传和下载文本文件而设计的网络服务。由于这可能会过于复杂而难以理解,我们首先将查看一个更简单的项目,即 file_transfer_stub 项目,该项目模拟此服务而不在文件系统中实际执行任何操作。
您将看到无状态 RESTful 网络服务的 API 结构,而不会被有关命令实现的细节所淹没。
在下一节中,此示例将通过所需实现来完成,以获得一个工作的文件管理网络应用。
运行和测试服务
要运行此服务,只需在控制台中键入命令 cargo run 即可。构建程序后,它将打印 Listening at address 127.0.0.1:8080 ...,并且它将保持监听传入的请求。
要测试它,我们需要一个网络客户端。如果您更喜欢,可以使用浏览器扩展,但在此章节中,我们将使用 curl 命令行工具。
file_transfer_stub 服务和 file_transfer 服务(我们将在下一节中看到)具有相同的 API,包含以下四个命令:
-
下载具有指定名称的文件。
-
上传具有指定名称和指定内容的文件。
-
上传具有指定名称前缀和指定内容的文件,作为响应获得完整名称。
-
删除指定名称的文件。
使用 GET 方法获取资源
在 REST 架构中下载资源时,应使用 GET 方法。对于这些命令,URL 应指定要下载的文件名。不应传递任何附加数据,响应应包含文件内容和状态码,可以是 200、404 或 500:
- 在控制台中输入以下命令:
curl -X GET http://localhost:8080/datafile.txt
- 在那个控制台中,应该打印以下模拟行,然后立即出现提示符:
Contents of the file.
- 同时,在另一个控制台中,应该打印以下行:
Downloading file "datafile.txt" ... Downloaded file "datafile.txt"
此命令模拟从服务器文件系统中下载 datafile.txt 文件的请求。
GET方法是 curl 的默认方法,因此你可以简单地输入以下命令:
curl http://localhost:8080/datafile.txt
- 此外,你可以通过输入以下命令将输出重定向到任何文件:
curl http://localhost:8080/datafile.txt >localfile.txt
因此,我们现在已经看到我们的网络服务如何通过 curl 下载远程文件,将其打印到控制台,或者将其保存到本地文件。
使用 PUT 方法将命名资源发送到服务器
在 REST 架构中上传资源时,应使用 PUT 或 POST 方法。PUT 方法用于客户端知道资源应存储的位置时,本质上,它将是其 标识键。如果已存在具有该键的资源,则该资源将被新上传的资源替换:
- 在控制台中输入以下命令:
curl -X PUT http://localhost:8080/datafile.txt -d "File contents."
- 在那个控制台中,提示符应立即出现。同时,在另一个控制台中,应该打印以下行:
Uploading file "datafile.txt" ... Uploaded file "datafile.txt"
此命令模拟向服务器发送文件的请求,客户端指定该资源的名称,因此如果已存在同名资源,则该资源将被覆盖。
- 你可以使用 curl 以以下方式发送指定本地文件中的数据:
curl -X PUT http://localhost:8080/datafile.txt -d @localfile.txt
在这里,curl 命令有一个额外的参数 -d,它允许我们指定要发送到服务器的数据。如果它后面跟着一个 @ 符号,则该符号后面的文本用作上传文件的路径。
对于这些命令,URI 应指定要上传的文件名称和文件内容,并且响应应只包含状态码,可以是 200、201(已创建)或 500。200 和 201 之间的区别在于,在第一种情况下,现有文件被覆盖,在第二种情况下,创建了一个新文件。
因此,我们现在已经学会了如何使用 curl 通过我们的网络服务上传字符串到远程文件,同时指定文件名。
使用 POST 方法将新资源发送到服务器
在 REST 架构中,POST 方法是在服务负责为新资源生成标识键时使用的方法。因此,请求不需要指定它。客户端可以指定标识符的模式或前缀。由于键是自动生成的且唯一,因此不可能有另一个具有相同键的资源。但是,应该将生成的键返回给客户端,否则,之后无法引用该资源:
- 要上传一个未知名称的文件,请在控制台中输入以下命令:
curl -X POST http://localhost:8080/data -d "File contents."
- 在那个控制台中,应打印文本
data17.txt,然后出现提示符。这是从服务器接收到的模拟文件名。同时,在另一个控制台中,应打印以下行:
Uploading file "data*.txt" ... Uploaded file "data17.txt"
此命令表示向服务器发送文件请求,服务器为该资源指定一个新唯一名称,以确保不会覆盖其他资源。
对于此命令,URI 不应指定要上传文件的完整名称,而只需指定前缀;当然,请求也应包含文件内容。响应应包含新创建文件的完整名称和状态码。在这种情况下,状态码只能是 201 或 500,因为已排除文件已存在的可能性。
现在我们已经学会了如何使用 curl 将字符串上传到新的远程文件,并将为该文件命名的工作留给服务器。我们还看到生成的文件名作为响应被发送回来。
使用 DELETE 方法删除资源
在 REST 架构中,要删除资源,应使用 DELETE 方法:
- 将以下命令输入到控制台(不用担心——不会删除任何文件!):
curl -X DELETE http://localhost:8080/datafile.txt
- 输入该命令后,提示符应立即出现。同时,在服务器控制台中,应打印以下行:
Deleting file "datafile.txt" ... Deleted file "datafile.txt"
此命令表示从服务器文件系统中删除文件请求。对于此类命令,URL 应指定要删除的文件名称。不需要传递其他数据,唯一的响应是状态码,可以是 200、404 或 500。因此,我们已经看到我们的网络服务如何使用 curl 删除远程文件。
作为总结,本服务的可能状态码如下:
-
200: OK -
201: 已创建 -
404: 未找到 -
500: 内部服务器错误
此外,我们的 API 的四个命令如下:
| 方法 | URI | 请求数据格式 | 响应数据格式 | 状态码 |
|---|---|---|---|---|
GET |
/{filename} |
--- | text/plain | 200, 404, 500 |
PUT |
/{filename} |
text/plain | --- | 200, 201, 500 |
POST |
/{filename prefix} |
text/plain | text/plain | 201, 500 |
DELETE |
/{filename} |
--- | --- | 200, 404, 500 |
发送无效命令
让我们看看服务器接收到无效命令时的行为:
- 将以下命令输入到控制台:
curl -X GET http://localhost:8080/a/b
- 在那个控制台中,提示符应立即出现。同时,在另一个控制台中,应打印以下行:
Invalid URI: "/a/b"
此命令表示从服务器获取 /a/b 资源请求,但,由于我们的 API 不允许这种指定资源的方法,服务拒绝该请求。
检查代码
main 函数包含以下语句:
HttpServer::new(|| ... )
.bind(server_address)?
.run()
第一行创建了一个 HTTP 服务器的实例。在这里,闭包的主体被省略了。
第二行将服务器绑定到一个 IP 端点,这是一个由 IP 地址和 IP 端口组成的对,如果绑定失败则返回错误。
第三行将当前线程置于该端点的监听模式。它阻塞线程,等待传入的 TCP 连接请求。
HttpServer::new调用的参数是一个闭包,如下所示:
App::new()
.service(
web::resource("/{filename}")
.route(web::delete().to(delete_file))
.route(web::get().to(download_file))
.route(web::put().to(upload_specified_file))
.route(web::post().to(upload_new_file)),
)
.default_service(web::route().to(invalid_resource))
在这个闭包中,创建了一个新的 Web 应用,然后对其应用了一个对service函数的调用。这样一个函数包含了对resource函数的调用,该函数返回一个对象,对其应用了四个对route函数的调用。最后,对应用对象应用了default_service函数的调用。
这个复杂的语句实现了一个基于 HTTP 请求的路径和方法来决定调用哪个函数的机制。在 Web 编程术语中,这种机制被称为路由。
请求路由首先在地址 URI 和一或多个模式之间执行模式匹配。在这种情况下,只有一个模式,/{filename},它描述了一个具有初始斜杠然后是一个单词的 URI。这个单词与filename名称相关联。
对route方法的四个调用基于 HTTP 方法(DELETE、GET、PUT、POST)进行路由。对于每个可能的 HTTP 方法都有一个特定的函数,然后调用一个to函数,该函数的参数是一个处理函数。
这样的route调用意味着以下内容:
-
如果当前 HTTP 命令的请求方法是
DELETE,则应该通过转到delete_file函数来处理这样的请求。 -
如果当前 HTTP 命令的请求方法是
GET,则应该通过转到download_file函数来处理这样的请求。 -
如果当前 HTTP 命令的请求方法是
PUT,则应该通过转到upload_specified_file函数来处理这样的请求。 -
如果当前 HTTP 命令的请求方法是
POST,则应该通过转到upload_new_file函数来处理这样的请求。
这四个名为处理程序的处理函数当然必须在当前作用域中实现。实际上,它们被定义了,尽管与TODO注释交织在一起,回忆起要有一个工作应用程序而不是存根所缺少的内容。尽管如此,这些处理程序包含了很多功能。
这样的路由机制可以用英语阅读,例如,对于一个DELETE命令:
创建一个service来管理名为/{filename}的web::resource,将delete命令路由到delete_file处理程序。
在所有模式之后,是对default_service函数的调用,它代表一个捕获所有模式,通常用于处理无效 URI,如前例中的/a/b。
捕获所有语句的参数——即web::route().to(invalid_resource)——导致路由到invalid_resource函数。你可以这样读:
对于这个 web 命令,将其路由到 invalid_resource 函数。
现在,让我们看看处理器,从最简单的一个开始,如下所示:
fn invalid_resource(req: HttpRequest) -> impl Responder {
println!("Invalid URI: \"{}\"", req.uri());
HttpResponse::NotFound()
}
此函数接收一个 HttpRequest 对象并返回实现了 Responder 特性的某个对象。这意味着它处理一个 HTTP 请求,并返回可以转换为 HTTP 响应的对象。
此函数相当简单,因为它所做的工作很少。它将 URI 打印到控制台,并返回一个 未找到 HTTP 状态码。
其他四个处理器接收不同的参数。它是这样的:info: Path<(String,)>。这样的参数包含之前匹配的路径的描述,其中 filename 参数被放入一个单值元组中,该元组位于 Path 对象内部。这是因为这样的处理器不需要整个 HTTP 请求,但它们需要解析的路径参数。
注意,我们有一个处理器接收 HttpRequest 类型的参数,而其他处理器接收 Path<(String,)> 类型的参数。这种语法是可能的,因为 main 函数中调用的 to 函数期望一个泛型函数作为参数,其参数可以是几种不同类型。
所有四个处理器都以以下语句开始:
let filename = &info.0;
这样的语句从与路径模式匹配产生的参数元组的第一个(也是唯一一个)字段中提取一个引用。只要路径恰好包含一个参数,这个操作就会成功。/a/b 路径无法与模式匹配,因为它有两个参数。同样,/ 路径也无法匹配,因为它没有参数。这些情况最终会落在 通配符 模式上。
现在,让我们专门检查 delete_file 函数。它继续以下几行:
print!("Deleting file \"{}\" ... ", filename);
flush_stdout();
// TODO: Delete the file.
println!("Deleted file \"{}\"", filename);
HttpResponse::Ok()
它有两个信息打印语句,并以返回成功值结束。在中间,实际删除文件的语句仍然缺失。调用 flush_stdout 函数是为了立即在控制台上输出文本。
download_file 函数与此类似,但它需要返回文件内容,因此响应更为复杂,如下面的代码片段所示:
HttpResponse::Ok().content_type("text/plain").body(contents)
Ok() 调用返回的对象首先通过调用 content_type 并将返回体的类型设置为 text/plain 来装饰,然后通过调用 body 并将文件内容设置为响应体。
upload_specified_file 函数相当简单,因为它的两个主要任务尚未完成:从请求体中获取要放入文件中的文本,并将该文本保存到文件中,如下面的代码块所示:
print!("Uploading file \"{}\" ... ", filename);
flush_stdout();
// TODO: Get from the client the contents to write into the file.
let _contents = "Contents of the file.\n".to_string();
// TODO: Create the file and write the contents into it.
println!("Uploaded file \"{}\"", filename);
HttpResponse::Ok()
upload_new_file 函数与此类似,但它还应有一个尚未实现的步骤:为要保存的文件生成一个唯一的文件名,如下面的代码块所示:
print!("Uploading file \"{}*.txt\" ... ", filename_prefix);
flush_stdout();
// TODO: Get from the client the contents to write into the file.
let _contents = "Contents of the file.\n".to_string();
// TODO: Generate new filename and create that file.
let file_id = 17;
let filename = format!("{}{}.txt", filename_prefix, file_id);
// TODO: Write the contents into the file.
println!("Uploaded file \"{}\"", filename);
HttpResponse::Ok().content_type("text/plain").body(filename)
因此,我们已经检查了网络服务存根的所有 Rust 代码。在下文中,我们将查看此服务的完整实现。
构建一个完整的网络服务
file_transfer项目通过填充缺失的功能来完成file_transfer_stub项目。
在上一个项目中省略了功能,原因如下:
-
要有一个非常简单的服务,实际上并不真正访问文件系统
-
只要有同步处理
-
忽略任何类型的失败,并保持代码简单
在这里,这些限制已经被移除。首先,让我们看看如果你编译并运行file_transfer项目会发生什么,然后使用与上一节相同的命令进行测试。
下载文件
让我们尝试以下步骤来下载文件:
- 在控制台中输入以下命令:
curl -X GET http://localhost:8080/datafile.txt
- 如果下载成功,服务器将在控制台打印以下行:
Downloading file "datafile.txt" ... Downloaded file "datafile.txt"
在客户端的控制台中,curl 打印出该文件的正文。
如果发生错误,服务将打印以下内容:
Downloading file "datafile.txt" ... Failed to read file "datafile.txt": No such file or directory (os error 2)
我们现在已经看到我们的网络服务如何通过 curl 下载文件。在下一节中,我们将学习我们的网络服务如何对远程文件执行其他操作。
将字符串上传到指定的文件
这是将字符串上传到具有指定名称的远程文件的命令:
curl -X PUT http://localhost:8080/datafile.txt -d "File contents."
如果上传成功,服务器将在控制台打印以下内容:
Uploading file "datafile.txt" ... Uploaded file "datafile.txt"
如果文件已经存在,它将被覆盖。如果它不存在,它将被创建。
如果发生错误,网络服务将打印以下行:
Uploading file "datafile.txt" ... Failed to create file "datafile.txt"
或者,它将打印以下行:
Uploading file "datafile.txt" ... Failed to write file "datafile.txt"
这就是我们的网络服务如何通过 curl 上传一个字符串到远程文件,同时指定文件名。
将字符串上传到新文件
这是将字符串上传到由服务器选择的名称的远程文件的命令:
curl -X POST http://localhost:8080/data -d "File contents."
如果上传成功,服务器将在控制台打印类似于以下的内容:
Uploading file "data*.txt" ... Uploaded file "data917.txt"
这个输出显示文件名包含一个伪随机数——在这个例子中,这是917,但你可能会看到其他一些数字。
在客户端的控制台中,curl 打印出该新文件的名称,因为服务器已经将其发送回客户端。
如果发生错误,服务器将打印以下行:
Uploading file "data*.txt" ... Failed to create new file with prefix "data", after 100 attempts.
或者,它将打印以下行:
Uploading file "data*.txt" ... Failed to write file "data917.txt"
这就是我们的网络服务如何通过 curl 上传一个字符串到新的远程文件,将创建新文件名的任务留给服务器。curl 工具将这个新名字作为响应接收。
删除文件
这是删除远程文件的命令:
curl -X DELETE http://localhost:8080/datafile.txt
如果删除成功,服务器将在控制台打印以下行:
Deleting file "datafile.txt" ... Deleted file "datafile.txt"
否则,它将打印这个:
Deleting file "datafile.txt" ... Failed to delete file "datafile.txt": No such file or directory (os error 2)
这就是我们的网络服务如何通过 curl 删除远程文件。
检查代码
让我们现在来检查这个程序和上一节中描述的程序之间的差异。Cargo.toml文件包含两个新的依赖项,如下面的代码片段所示:
futures = "0.1"
rand = "0.6"
futurescrate 用于异步操作,而randcrate 用于随机生成上传文件的唯一名称。
许多新的数据类型已从外部 crate 导入,如下面的代码块所示:
use actix_web::Error;
use futures::{
future::{ok, Future},
Stream,
};
use rand::prelude::*;
use std::fs::{File, OpenOptions};
主函数只有两个更改,如下所示:
.route(web::put().to_async(upload_specified_file))
.route(web::post().to_async(upload_new_file)),
在这里,两个对to函数的调用已被替换为对to_async函数的调用。虽然to函数是同步的(即,它保持当前线程忙碌,直到该函数完成),但to_async函数是异步的(即,它可以推迟到预期事件发生)。
这种更改是由上传请求的本质所要求的。此类请求可以发送大文件(几个兆字节),而 TCP/IP 协议将此类文件分割成小数据包。如果服务器在接收到第一个数据包后只是等待所有数据包的到来,它可能会浪费很多时间。即使有多个线程,如果许多用户同时上传文件,系统也会尽可能多地分配线程来处理此类上传,这相当低效。一个更高效的解决方案是异步处理。
然而,to_async函数不能接收一个同步处理程序作为参数。它必须接收一个返回具有impl Future<Item = HttpResponse, Error = Error>类型的值的函数,而不是由同步处理程序返回的impl Responder类型。实际上,这是两个上传处理程序upload_specified_file和upload_new_file返回的类型。
返回的对象是抽象类型,但必须实现Futuretrait。自 2011 年以来,C++中也使用了future的概念,类似于 JavaScript 的promises。它表示将来可用的值,同时,当前线程可以处理其他事件。
Futures 被实现为异步闭包,这意味着这些闭包被放入内部 futures 列表的队列中,而不是立即运行。当当前线程没有其他任务运行时,队列顶部的 future 被从队列中移除并执行。
如果两个 future 被链式调用,第一个链的失败会导致第二个 future 被销毁。否则,如果链的第一个 future 成功,第二个 future 有机会运行。
回到两个上传函数,它们签名的一个更改是它们现在接收两个参数。除了包含文件名的Path<(String,)>类型的参数外,还有一个Payload类型的参数。记住,内容可以分块到达,因此这样的Payload参数不包含文件的文本,但它是一个对象,用于异步获取上传文件的正文。
其使用相对复杂。
首先,对于两个上传处理程序,有以下的代码:
payload
.map_err(Error::from)
.fold(web::BytesMut::new(), move |mut body, chunk| {
body.extend_from_slice(&chunk);
Ok::<_, Error>(body)
})
.and_then(move |contents| {
需要调用map_err来转换错误类型。
fold 的调用每次从网络接收一块数据,并使用它来扩展 BytesMut 类型的对象。这种类型实现了一种可扩展的缓冲区。
and_then 的调用将另一个 future 链接到当前的一个。它接收一个闭包,当 fold 的处理完成时将被调用。这个闭包接收所有上传的内容作为参数。这是链式调用两个 future 的方法——以这种方式调用的任何闭包都是在前一个闭包完成后异步执行的。
闭包的内容只是将接收到的内容写入指定名称的文件。这个操作是同步的。
闭包的最后一行是 ok(HttpResponse::Ok().finish())。这是从 future 返回的方式。注意小写的 ok。
upload_new_file 函数在网页编程概念上与之前的函数相似。它更复杂,仅仅是因为以下原因:
-
而不是提供一个完整的文件名,只提供了一个前缀,其余部分必须生成一个伪随机数。
-
结果文件名必须发送到客户端。
生成唯一文件名的算法如下:
-
生成一个三位伪随机数,并将其连接到前缀。
-
获得的名称用于创建一个文件;这避免了覆盖具有该名称的现有文件。
-
如果发生冲突,将生成另一个数字,直到创建一个新文件,或者直到尝试了 100 次失败的尝试。
当然,这假设上传的文件数量始终远小于 1,000。
已经进行了其他更改,以考虑失败的可能性。
delete_file 函数的最后一部分现在看起来是这样的:
match std::fs::remove_file(&filename) {
Ok(_) => {
println!("Deleted file \"{}\"", filename);
HttpResponse::Ok()
}
Err(error) => {
println!("Failed to delete file \"{}\": {}", filename, error);
HttpResponse::NotFound()
}
}
此代码处理文件删除失败的情况。注意,在出现错误的情况下,不是返回表示数字 200 的成功状态码 HttpResponse::Ok(),而是返回表示数字 404 的 HttpResponse::NotFound() 失败代码。
download_file 函数现在包含一个局部函数,用于将整个文件内容读入一个字符串,如下所示:
fn read_file_contents(filename: &str) -> std::io::Result<String> {
use std::io::Read;
let mut contents = String::new();
File::open(filename)?.read_to_string(&mut contents)?;
Ok(contents)
}
函数以一些代码结束,以处理函数可能的失败,如下所示:
match read_file_contents(&filename) {
Ok(contents) => {
println!("Downloaded file \"{}\"", filename);
HttpResponse::Ok().content_type("text/plain").body(contents)
}
Err(error) => {
println!("Failed to read file \"{}\": {}", filename, error);
HttpResponse::NotFound().finish()
}
}
构建一个有状态的服务器
file_transfer_stub 项目的网页应用是完全无状态的,这意味着每个操作的行为独立于之前的操作。其他解释方式是,没有数据从一个命令保持到下一个命令,或者它只计算纯函数。
file_transfer 项目的网页应用有一个状态,但这个状态仅限于文件系统。这种状态是数据文件的内容。尽管如此,应用程序本身仍然是无状态的。没有变量从一个请求处理持续到另一个请求处理。
REST 原则通常被解释为规定任何 API 必须是无状态的。这是一个误解,因为 REST 服务 可以 有状态,但它们 必须表现得像无状态一样。无状态意味着,除了文件系统和数据库外,没有信息在服务器中从一次请求处理持续到另一次请求处理。表现得像无状态意味着任何请求序列都应该获得相同的结果,即使服务器在两次请求之间被终止并重新启动。
显然,如果服务器被终止,其状态就会丢失。因此,作为无状态的行为意味着即使状态被重置,行为也应该保持一致。那么,可能的服务器状态有什么作用呢?它是为了存储可以通过任何请求再次获取的信息,但这样做可能会很昂贵。这就是缓存的概念。
通常,任何 REST web 服务器都有一个内部状态。在这个状态中存储的典型信息是数据库连接池。池最初是空的,当第一个处理器必须连接到数据库时,它会搜索池以查找可用的连接。如果找到了,它就会使用它。否则,会创建一个新的连接并将其添加到池中。池是一个共享状态,必须传递给任何请求处理器。
在前几节的项目中,请求处理器是纯函数;它们没有共享公共状态的可能性。在 memory_db 项目中,我们将看到如何在 Actix web 框架中实现共享状态,并将其传递给任何请求处理器。
这个 web 应用程序代表了对一个非常简单的数据库的访问。而不是执行对数据库的实际访问,这需要在您的计算机上进行进一步的安装,它只是调用了在 src/data_access.rs 文件中定义的 data_access 模块导出的某些函数,这些函数将数据库保持在内存中。
内存数据库是所有请求处理器共享的状态。在一个更现实的应用中,状态将只包含一个或多个与外部数据库的连接。
如何拥有一个有状态的服务器
要在 Actix 服务中拥有状态,必须声明一个结构体,并且任何应该作为状态一部分的数据都应该是该结构体的字段。
在 main.rs 文件的开始处,有以下的代码:
struct AppState {
db: db_access::DbConnection,
}
在我们的 web 应用程序的状态中,我们只需要一个字段,但可以添加其他字段。
在 db_access 模块中声明的 DbConnection 类型代表了我们 web 应用程序的状态。在 main 函数中,在创建服务器之前,有以下的语句实例化了 AppState,然后适当地封装了它:
let db_conn = web::Data::new(Mutex::new(AppState {
db: db_access::DbConnection::new(),
}));
状态被所有请求共享,Actix web 框架使用多个线程来处理请求,因此状态必须是线程安全的。在 Rust 中声明线程安全对象的一种典型方式是将它封装在一个 Mutex 对象中。然后,这个对象被封装在一个 Data 对象中。
为了确保这种状态传递给任何处理程序,必须在调用service函数之前添加以下行:
.register_data(db_conn.clone())
这里,db_conn对象被克隆(由于它是一个智能指针,所以成本较低),并注册到应用程序中。
这种注册的效果是,现在可以向请求处理程序(同步和异步)添加另一种类型的参数,如下所示:
state: web::Data<Mutex<AppState>>
这种参数可以在如下语句中使用:
let db_conn = &mut state.lock().unwrap().db
这里,状态被锁定以防止其他请求的并发访问,并访问其db字段。
该服务的 API
此应用程序中的其余代码并不特别令人惊讶。API 从main函数中使用的名称中很清楚,如下面的代码块所示:
.service(
web::resource("/persons/ids")
.route(web::get().to(get_all_persons_ids)))
.service(
web::resource("/person/name_by_id/{id}")
.route(web::get().to(get_person_name_by_id)),
)
.service(
web::resource("/persons")
.route(web::get().to(get_persons)))
.service(
web::resource("/person/{name}")
.route(web::post().to(insert_person)))
.default_service(
web::route().to(invalid_resource))
注意,前三个模式使用GET方法,因此它们查询数据库。最后一个使用POST方法,因此它将新记录插入到数据库中。
注意以下词汇约定。
第一个和第三个模式的 URI 路径以复数名词persons开头,这意味着零个、一个或多个项目将由此请求管理,并且任何此类项目代表一个人。相反,第二个和第四个模式的 URI 路径以单数名词person开头,这意味着最多只能由一个项目管理此请求。
第一个模式以复数名词ids结尾,因此将处理与id相关的几个项目。它没有条件,因此请求所有 ID。第二个模式包含单词name_by_id,后面跟一个id参数,因此它是请求name数据库列的所有记录,其中id列的值指定。
即使在有任何疑问的情况下,处理函数或注释的名称也应该使服务的操作清晰,而无需阅读处理程序的代码。在查看处理程序的实现时,请注意它们要么根本不返回任何内容,要么只返回简单的文本。
测试服务
让我们通过一些 curl 操作来测试服务。
首先,我们应该填充最初为空的数据库。记住,由于它仅在内存中,每次启动服务时都是空的。
在启动程序后,输入以下命令:
curl -X POST http://localhost:8080/person/John
curl -X POST http://localhost:8080/person/Jonathan
curl -X POST http://localhost:8080/person/Mary%20Jane
在第一个命令之后,应在控制台打印数字1。在第二个命令之后,应打印2,在第三个命令之后,应打印3。它们是插入的人名的 ID。
现在,输入以下命令:
curl -X GET http://localhost:8080/persons/ids
应打印以下内容:1, 2, 3。这是数据库中所有 ID 的集合。
现在,输入以下命令:
curl -X GET http://localhost:8080/person/name_by_id/3
应打印以下内容:Mary Jane。这是id等于3的唯一人员的姓名。注意,输入序列%20已被解码为空格。
现在,输入以下命令:
curl -X GET http://localhost:8080/persons?partial_name=an
它应该打印以下内容:2: Jonathan; 3: Mary Jane。这是包含name列包含an子字符串的所有人员的集合。
数据库的实现
整个数据库实现都保存在db_access.rs源文件中。
数据库的实现相当简单。它是一个DbConnection类型,包含Vec<Person>,其中Person是一个包含两个字段的 struct——id和name。
DbConnection的方法描述如下:
-
new: 这将创建一个新的数据库。 -
get_all_persons_ids(&self) -> impl Iterator<Item = u32> + '_: 这返回一个迭代器,它提供了数据库中包含的所有 ID。此类迭代器的生命周期不能超过数据库本身的寿命。 -
get_person_name_by_id(&self, id: u32) -> Option<String>: 如果存在具有指定 ID 的唯一人员,则返回该人员的姓名,否则返回零。 -
get_persons_id_and_name_by_partial_name<'a>(&'a self, subname: &'a str) -> impl Iterator<Item = (u32, String)> + 'a: 这返回一个迭代器,它提供了所有姓名包含指定字符串的人员的 ID 和姓名。此类迭代器的生命周期不能超过数据库本身的寿命,也不能超过指定的字符串。 -
insert_person(&mut self, name: &str) -> u32: 这向数据库添加一条记录,包含一个生成的 ID 和指定的name。这返回生成的 ID。
处理查询
请求处理器,包含在main.rs文件中,获取几种类型的参数,如下所示:
-
web::Data<Mutex<AppState>>: 如前所述,这用于访问共享应用程序状态。 -
Path<(String,)>: 如前所述,这用于访问请求的路径。 -
HttpRequest: 如前所述,这用于访问一般请求信息。
但同时,请求处理器也获得了web::Query<Filter>参数来访问请求的可选参数。
get_persons处理器有一个查询参数——它是一个泛型参数,其参数是Filter类型。此类类型如下定义:
#[derive(Deserialize)]
pub struct Filter {
partial_name: Option<String>,
}
此定义允许请求,如http://localhost:8080/persons?partial_name=an。在这个请求中,路径只是/persons,而?partial_name=an是所谓的查询。在这种情况下,它只包含一个参数,其键为partial_name,其值为an。它是一个字符串,它是可选的。这正是Filter结构体所描述的。
此外,此类类型是可序列化的,因为此类对象必须通过序列化被请求读取。
get_persons函数通过以下表达式访问查询:
&query.partial_name.clone().unwrap_or_else(|| "".to_string()),
partial_name字段被克隆以获取一个字符串。如果它不存在,则将其视为空字符串。
返回 JSON 数据
上一节返回了纯文本数据。在 Web 服务中这是不寻常的,并且很少令人满意。通常,Web 服务以 JSON、XML 或其他结构化格式返回数据。json_db项目与memory_db项目相同,除了它以 JSON 格式返回数据。
首先,让我们看看当在它上面执行上一节中的相同 curl 命令时会发生什么,如下所示:
-
插入的行为相同,因为它们只是打印了一个数字。
-
第一个查询应该打印以下内容:
[1,2,3]。这三个数字在一个数组中,因此它们被括号包围。 -
第二个查询应该打印以下内容:
"Mary Jane"。名字是一个字符串,因此它被引号包围。 -
第三个查询应该打印以下内容:
[[2,"Jonathan"],[3,"Mary Jane"]]。人员序列是一个包含两个记录的数组,每个记录都是一个包含两个值的数组,一个是数字,一个是字符串。
现在,让我们看看这个项目与之前项目的代码差异。
在Cargo.toml文件中,增加了一个依赖项,如下所示:
serde_json = "1.0"
这是为了将数据序列化为 JSON 格式。
在main.rs文件中,get_all_persons_ids函数(而不是简单地返回一个字符串)有如下代码:
HttpResponse::Ok()
.content_type("application/json")
.body(
json!(db_conn.get_all_persons_ids().collect::<Vec<_>>())
.to_string())
首先,创建一个带有状态码Ok的响应;然后,将其内容类型设置为application/json,以便让客户端知道如何解释它将接收到的数据;最后,使用从serde_jsoncrate 中取出的json宏设置其主体。这个宏接受一个表达式——在这种情况下,类型为Vec<Person>——并返回一个serde_json::Value值。现在,我们需要一个字符串,因此调用to_string()。注意,json!宏要求其参数实现Serialize特质或可转换为字符串。
get_person_name_by_id、get_persons和insert_person函数有类似的变化。main函数没有变化。db_access.rs文件是相同的。
摘要
我们已经了解了一些 Actix Web 框架的特性。这是一个非常复杂的框架,涵盖了后端 Web 开发者的大多数需求,并且仍在积极开发中。
尤其是在file_transfer_stub项目中,我们学习了如何创建一个 RESTful 服务的 API。在file_transfer项目中,我们讨论了如何实现我们网络服务的操作。在memory_db项目中,我们了解了如何管理内部状态,特别是包含数据库连接的状态。在json_db项目中,我们看到了如何以 JSON 格式发送响应。
在下一章中,我们将学习如何创建一个完整的后端 Web 应用程序。
问题
-
根据 REST 原则,
GET、PUT、POST和DELETEHTTP 方法分别代表什么意思? -
哪个命令行工具可以用来测试一个网络服务?
-
请求处理器如何检索 URI 参数的值?
-
如何指定 HTTP 响应的内容类型?
-
如何生成一个唯一的文件名?
-
为什么无状态的 API 服务需要管理状态?
-
为什么服务的状态必须封装在
Data和Mutex对象中? -
为什么异步处理在 Web 服务中可能是有用的?
-
futures 的
and_then函数的目的是什么? -
哪些 crate 对于以 JSON 格式组合 HTTP 响应是有用的?
进一步阅读
要了解更多关于 Actix 框架的信息,请查看官方文档actix.rs/docs/,并查看官方示例github.com/actix/examples/。
创建一个完整的后端网页应用
在上一章中,我们看到了如何使用 Actix web 框架构建 RESTful 网络服务。为了对我们有用,RESTful 网络服务必须被客户端应用使用。
在本章中,我们将看到如何使用 Actix web 框架构建一个非常小但完整的网页应用。我们将使用 HTML 代码在网页浏览器中格式化,使用 JavaScript 代码在同一网页浏览器中执行,以及使用 Tera crate 进行 HTML 模板化。这对于在 HTML 页面中嵌入动态数据非常有用。
本章将涵盖以下主题:
-
理解经典网页应用及其 HTML 模板是什么
-
在 Rust 和 Actix web 中使用 Tera 模板引擎
-
使用 Actix web 处理网页请求
-
在网页中处理身份验证和授权
第五章:技术要求
为了最好地理解本章内容,你需要阅读上一章。此外,还假设你具备基本的 HTML 和 JavaScript 知识。
本章的完整源代码可以在github.com/PacktPublishing/Rust-2018-Projects存储库的Chapter04文件夹中找到。
网页应用的定义
每个人都知道什么是网页或网站,也知道有些网页相当静态,而有些则具有更多动态行为。然而,网页应用的定义却更为微妙且具有争议性。
我们将从网页应用的运行定义开始;也就是说,观察网页应用的外观和行为。
对于我们的目的,一个网页应用是一个具有以下行为的网站:
-
它在网页浏览器中表现为一个或多个网页。在这些页面上,用户可以通过按键盘上的键、用鼠标点击、触摸屏上的点击或使用其他输入设备与页面进行交互。对于某些用户交互,这些网页会向服务器发送请求,并从该网站接收作为响应的数据。
-
对于一个静态网页,接收到的数据对于相同的请求始终相同;但对于网页应用,接收到的数据取决于服务器当前的状态,这可能会随时间变化。在接收到数据后,网页会显示其他 HTML 代码,要么是新的完整页面,要么是当前页面的部分。
-
经典的网页应用只从服务器接收 HTML 代码,因此浏览器在收到 HTML 代码时只需显示它。现代应用更常从服务器接收原始数据,并在浏览器中使用 JavaScript 代码创建显示数据的 HTML 代码。
在这里,我们将开发一个相当经典的网页应用,因为我们的应用主要从服务器接收 HTML 代码。一些 JavaScript 代码将被用来改进应用的结构。
理解网页应用的行为
当用户通过浏览器的地址栏或点击页面中的链接来导航到网站时,浏览器会发送一个 HTTP GET请求,其中 URI 指定在地址字段或链接元素中,例如http://hostname.domainname:8080/dir/file?arg1=value1&arg2=value2。
这个地址通常被称为统一资源定位符(URL)或统一资源标识符(URI)。这两个缩写之间的区别在于,URI 是唯一标识资源的东西,不一定指定它可以在哪里找到;而 URL 则精确指定了资源可以找到的位置。在这个过程中,它也标识了资源,因为单个位置只能有一个资源。
因此,每个 URL 也是 URI,但一个地址可以是 URI 而不一定是 URL。例如,指定文件路径名的地址是 URL(也是 URI),因为它指定了文件的路径。然而,指定文件过滤条件的地址是 URI,但不是 URL,因为它没有明确指定哪个文件满足该条件。
地址的第一部分(如http://hostname.domainname:8080),直到(可选的)端口号,是必要的,以便将请求路由到应该处理它的服务器进程。这个服务器必须在主机计算机上运行,并且它必须等待针对该端口的传入请求;或者,通常的说法是,它必须在该端口上监听。
URI 的后续部分(如/dir/file)被称为路径,它始终以斜杠开头,以第一个问号字符或 URI 的结尾为结束。可能的后续部分(如?arg1=value1&arg2=value2)被称为查询,它由一个或多个用与号分隔的字段组成。查询的任何字段都有一个名称,后面跟着一个等号,然后是值。
当发起请求时,服务器应通过发送 HTTP 响应来回复,其中包含在浏览器中显示的 HTML 页面作为其主体。
在初始页面显示后,任何进一步的交互通常发生在用户通过键盘、鼠标或其他输入设备在页面上操作时。
注意,任何用户操作对页面产生的影响可以分为以下几种方式:
-
无代码:某些用户操作仅由浏览器处理,没有调用应用程序代码。例如,当鼠标悬停在控件上时,鼠标光标形状会改变;当在文本控件中输入时,该控件内的文本会改变;当点击复选框时,框会被选中或取消选中。通常,这种行为不受应用程序代码的控制。
-
仅前端:某些用户操作(如按键按下)会触发与这些操作关联的客户端 JavaScript 代码的执行,但不会执行客户端-服务器通信,因此不会调用服务器端代码。通常,任何按钮都与(使用按钮元素的
onclick属性)任何时间用户点击该按钮时执行的 JavaScript 代码相关联。此代码可以,例如,启用或禁用其他小部件或将数据从一个小部件复制到同一页面的另一个小部件。 -
仅后端:某些用户操作会触发客户端-服务器通信,而不使用任何 JavaScript 代码。这些操作的例子只有两个:
-
在 HTML
form元素内部点击一个submit输入元素 -
点击一个a HTML 元素,更广为人知的是链接
-
-
全栈:某些用户操作会触发与该操作关联的客户端 JavaScript 代码的执行。此 JavaScript 代码向后端进程发送一个或多个请求,并接收作为对这些请求的回复发送的响应。后端进程接收请求并适当地对它们做出响应。因此,客户端应用程序代码和服务器端应用程序代码都会运行。
现在,让我们来探讨这四种情况的优势和劣势。无代码的情况是默认的。如果浏览器的基本行为足够好,就没有必要对其进行自定义。可以使用 HTML 或 CSS 执行一些行为自定义。
仅前端和全栈的情况需要浏览器支持并启用 JavaScript。这曾经是一个问题,因为有些人或平台无法或不愿意支持它。如今,任何想要被称为Web 应用而不是仅仅是一个网页或网站的东西,如果没有使用某种形式的客户端处理,就无法做到。
仅前端的情况不与服务器交互,因此对于不需要将数据发送到当前计算机之外或不需要从另一台计算机接收数据的任何过程可能很有用,并建议使用。例如,可以使用 JavaScript 实现计算器,而不与服务器通信。然而,大多数 Web 应用都需要这种通信。
在 JavaScript 发明之前,只有后端的情况是可用的 Web 通信类型。尽管如此,它相当有限。
链接的概念对于旨在成为超文本的网站是有用的,而不是应用。记住,HTML 和 HTTP 中的HT代表超文本。这是网络的原始目的,但如今,Web 应用旨在成为通用应用程序,而不仅仅是超文本。
包含提交按钮的表单概念也限制了交互到一个固定的协议——一些字段被填写,然后按下一个按钮将所有数据发送到服务器。服务器处理请求并发送一个新的页面来替换当前页面。在许多情况下,这可以完成,但对于用户来说,这并不是一个愉快的体验。
第四种情况被称为全栈,因为这些应用程序既有前端应用代码,也有应用后端代码。由于前端代码需要后端代码才能正常工作,因此它被视为堆叠在其上。
注意,任何 Web 交互都必须在前端和后端运行一些机器代码。在前端,可能有网络浏览器、curl实用程序或其他类型的 HTTP 客户端。在后端,可能有 Web 服务器,如互联网信息服务(IIS)、Apache 或 NGINX,或者一个充当 HTTP 服务器的应用程序。
因此,对于任何 Web 应用程序,都存在使用 HTTP 协议的客户端-服务器通信。
术语全栈意味着,除了系统软件之外,还有一些应用软件在前端(作为 HTTP 客户端)运行,以及一些应用软件在后台(作为 HTTP 服务器)运行。
在一个典型的在浏览器上运行的完整栈应用程序中,没有链接或表单,只有 GUI 的典型小部件。通常,这些小部件是固定文本、可编辑字段、下拉列表、复选按钮和推送按钮。当用户按下任何推送按钮时,会向服务器发送一个请求,可能使用小部件中包含的值,当服务器发送回一个 HTML 页面时,该页面用于替换当前页面或其部分。
项目概述
我们将要构建的示例 Web 应用程序的目的是管理数据库中包含的人员列表。这是一个极其简单的数据库,因为它只有一个表,有两列——一列是数字 ID,另一列是名称。为了使项目简单,数据库实际上是一个存储在内存中的结构对象向量;但在现实世界的应用程序中,它当然会被存储在一个数据库管理系统(DBMS)中。
项目将分步骤构建,创建四个逐渐变得更加复杂的项目,这些项目可以从本章技术要求部分中链接的 GitHub 仓库下载:
-
templ项目是一系列代码片段,展示了如何为本章的项目使用 Tera 模板引擎。 -
list项目是一个关于人员的简单记录列表,可以根据名称进行筛选。这些记录实际上包含在数据库代码中,用户无法更改。 -
crud项目包含添加、更改和删除人员的功能。它们是所谓的创建、检索、更新和删除(CRUD)基本功能。 -
auth项目添加了一个登录页面,并确保只有授权用户可以读取或更改数据库。然而,用户列表及其权限不能更改。
templ项目,它不使用 Actix Web 框架,第一次编译需要 1 到 3 分钟,而在代码有任何更改后,只需几秒钟。
其他任何项目第一次编译大约需要 3 到 9 分钟,而在代码有任何更改后,大约需要 8 到 20 秒。
当你运行上述任何项目(除了第一个)时,你将在控制台上看到Listening at address 127.0.0.1:8080打印出来。要查看更多内容,你需要一个网页浏览器。
使用 Tera 模板引擎
在开始开发我们的 Web 应用之前,我们将检查模板引擎的概念——特别是 Tera crate,这是 Rust 可用的众多模板引擎之一。
模板引擎可以有几种应用,但它们主要用于网页开发。
网页开发中一个典型的问题是知道如何生成包含一些手动编写的常量部分和由应用程序代码生成的动态部分的 HTML 代码。一般来说,有两种方法可以获得这种效果:
-
你有一个包含大量打印字符串语句的编程语言源文件,以创建所需的 HTML 页面。这些
print语句混合了字符串字面量(即用引号括起来的字符串)和格式化为字符串的变量。如果你没有模板引擎,你会在 Rust 中这样做。 -
你编写一个包含所需常量 HTML 元素和所需常量文本的 HTML 文件,但它还包含一些用特定标记包围的语句。这些语句的评估生成了 HTML 文件的变量部分。这就是你在 PHP、JSP、ASP 和 ASP.NET 中会做的事情。
然而,也存在一种折衷方案,即编写包含评估语句的应用程序代码文件和 HTML 代码。然后你可以选择最适合这项工作的工具。这是模板引擎使用的范式。
假设你有一些 Rust 代码文件和一些必须相互协作的 HTML 文件。使这两个世界通信的工具是模板引擎。包含嵌入式语句的 HTML 文件被称为模板,Rust 应用程序代码调用模板引擎函数来操作这些模板。
现在,让我们看看templ示例项目中的代码。第一条语句创建了一个引擎实例:
let mut tera_engine = tera::Tera::default();
第二条语句通过调用add_raw_template函数将一个简单的模板加载到引擎中:
tera_engine.add_raw_template(
"id_template", "Identifier: {{id}}.").unwrap();
第一个参数是要用来引用此模板的名称,第二个参数是模板本身。这是一个对字符串切片的正常引用,但它包含{{id}}占位符。这个符号使其成为Tera 表达式。特别是,这个表达式只包含一个 Tera 变量,但它可以包含更复杂的表达式。
允许使用常量表达式,例如 {{3+5}},即使使用常量表达式没有意义。一个模板可以包含多个表达式,也可以一个都不包含。
注意,add_raw_template 函数是可能失败的,所以对其结果调用了 unwrap。在将作为参数接收的模板添加之前,此函数会分析它以查看其是否格式正确。例如,如果它读取 "Identifier: {{id}."(缺少大括号),它将生成一个错误,因此对 unwrap 的调用将导致恐慌。
当你有一个 Tera 模板时,你可以渲染它;也就是说,生成一个字符串,用一些指定的字符串替换表达式,这与宏处理器的做法类似。
为了评估一个表达式,Tera 引擎必须首先用其当前值替换其中使用的所有变量。为此,必须创建一个 Tera 变量的集合——每个变量都与它的当前值相关联——这个集合被称为上下文。上下文是通过以下两个语句创建和填充的:
let mut numeric_id = tera::Context::new();
numeric_id.insert("id", &7362);
第一个创建了一个可变上下文,第二个将键值对插入其中。在这里,值是一个数字的引用,但也可以作为值使用其他类型。
当然,在现实世界的例子中,值将是一个 Rust 变量,而不是一个常量。
现在,我们可以渲染它:
println!("id_template with numeric_id: [{}]",
tera_engine.render("id_template", &numeric_id).unwrap());
render 方法从 tera_engine 对象中获取一个名为 "id_template" 的模板,并应用由 numeric_id 上下文指定的替换。
如果指定的模板未找到,如果模板中的变量未替换,或者由于其他原因评估失败,这可能会失败。如果结果正常,unwrap 获取字符串。因此,它应该打印以下内容:
id_template with numeric_id: [Identifier: 7362.]
示例中的下一个三个 Rust 语句如下:
let mut textual_id = tera::Context::new();
textual_id.insert("id", &"ABCD");
println!(
"id_template with textual_id: [{}]",
tera_engine.render("id_template", &textual_id).unwrap()
);
它们做的是同一件事,但使用一个字面字符串,表明相同的模板变量可以用数字和字符串替换。打印的行应该是这样的:
id_template with textual_id: [Identifier: ABCD.]
下一个语句如下:
tera_engine
.add_raw_template("person_id_template", "Person id: {{person.id}}")
.unwrap();
它向引擎添加了一个包含 {{person.id}} 表达式的模板。这个 Tera 点符号与 Rust 点符号具有相同的功能——它允许我们访问结构体的一个字段。当然,它只在我们用具有 id 字段的对象替换 person 变量时才有效。
因此,Person 结构体定义如下:
#[derive(serde_derive::Serialize)]
struct Person {
id: u32,
name: String,
}
结构体有一个 id 字段,但也派生了 Serialize 特性。这是任何必须传递给 Tera 模板的对象的必要条件。
定义上下文中的 person 变量的语句如下:
one_person.insert(
"person",
&Person {
id: 534,
name: "Mary".to_string(),
},
);
因此,打印的字符串将是以下内容:
person_id_template with one_person: [Person id: 534]
现在,有一个更复杂的模板:
tera_engine
.add_raw_template(
"possible_person_id_template",
"{%if person%}Id: {{person.id}}\
{%else%}No person\
{%endif%}",
)
.unwrap();
模板是一行长的,但在 Rust 源代码中已被拆分为三行。
除了 {{person.id}} 表达式之外,还有三种其他类型的标记;它们是 Tera 语句。Tera 语句与 Tera 表达式不同,因为它们被 {% 和 %} 符号包围,而不是双大括号。虽然 Tera 表达式类似于 C 预处理器宏(即 #define),但 Tera 语句类似于 C 预处理器的条件编译指令(即 #if、#else 和 #endif)。
if 语句之后的表达式由 render 函数进行评估。如果该表达式未定义或其值为 false、0、空字符串或空集合,则该表达式被视为 false。然后,从 {%else%} 语句开始的部分文本将被丢弃。否则,从该语句之后到 {%endif%} 语句的部分将被丢弃。
此模板使用两种不同的上下文进行渲染——一种是在其中定义了 person 变量的上下文,另一种是没有定义变量的上下文。打印出的两行如下:
possible_person_id_template with one_person: [Id: 534]
possible_person_id_template with empty context: [No person]
在第一种情况下,打印出人的 id 值;在第二种情况下,打印出 No person 文本。
然后,创建了一个更复杂的模板:
tera_engine
.add_raw_template(
"multiple_person_id_template",
"{%for p in persons%}\
Id: {{p.id}};\n\
{%endfor%}",
)
.unwrap();
在这里,模板包含两种其他类型的语句——{%for p in persons%} 和 {%endfor%}。它们包含一个循环,新创建的 p 变量遍历 persons 集合,该集合必须属于 render 所使用的上下文。
然后,有以下的代码:
let mut three_persons = tera::Context::new();
three_persons.insert(
"persons",
&vec![
Person {
id: 534,
name: "Mary".to_string(),
},
Person {
id: 298,
name: "Joe".to_string(),
},
Person {
id: 820,
name: "Ann".to_string(),
},
],
);
这向 three_persons Tera 上下文添加了一个名为 persons 的 Tera 变量。该变量是一个包含三个人的向量。
因为 persons 变量可以遍历,所以可以评估模板,从而获得以下结果:
multiple_person_id_template with three_persons: [Id: 534;
Id: 298;
Id: 820;
]
注意,任何 Id 对象都位于单独的一行,因为模板包含一个换行符字符(通过 \n 转义序列);否则,它们将打印在同一行。
到目前为止,我们已经在字符串字面量中使用了模板。但是,对于长模板来说,这会变得很困难。因此,模板通常是从单独的文件中加载的。这是可取的,因为 集成开发环境(IDE)可以帮助开发者(如果它知道正在处理哪种语言)因此,最好将 HTML 代码保存在以 .html 后缀的文件中,CSS 代码保存在以 .css 后缀的文件中,等等。
下一个语句从文件中加载一个 Tera 模板:
tera_engine
.add_template_file("templates/templ_id.txt", Some("id_file_template"))
.unwrap();
add_template_file 函数的第一个参数是模板文件的路径,相对于项目的根目录。将所有模板文件放在单独的文件夹或其子文件夹中是一种良好的做法。
第二个参数允许我们指定新模板的名称。如果该参数的值为 None,则新模板的名称是第一个参数。
因此,该语句如下:
println!(
"id_file_template with numeric_id: [{}]",
tera_engine
.render("id_file_template", numeric_id.clone())
.unwrap()
);
这将打印以下内容:
id_file_template with numeric_id: [This file contains one id: 7362.]
以下代码将产生类似的结果:
tera_engine
.add_template_file("templates/templ_id.txt", None)
.unwrap();
println!(
"templates/templ_id.txt with numeric_id: [{}]",
tera_engine
.render("templates/templ_id.txt", numeric_id)
.unwrap()
);
最后,让我们谈谈一个方便的功能,它可以用来通过单个语句加载所有模板。
而不是逐个加载模板,在需要的地方加载,可以一次性加载所有模板并将它们存储在全局字典中。这使得它们在整个模块中可用。为此,可以使用在第一章 Rust 2018 – Productivity! 中描述的lazy_static宏,在函数外部编写:
lazy_static::lazy_static! {
pub static ref TERA: tera::Tera =
tera::Tera::new("templates/**/*").unwrap();
}
这条语句定义了TERA静态变量作为一个全局模板引擎。当你的应用中的某些 Rust 代码首次使用它时,它将自动初始化。这个初始化将在指定的文件夹子树中的所有文件中进行搜索,并将它们加载,给每个文件赋予文件名本身,并省略其文件夹名称。
本节最后要介绍的 Tera 引擎的功能是include语句。templ_names.txt文件的最后一行是以下内容:
{% include "footer.txt" %}
它将加载指定文件的内容并将其内联展开,替换掉原语句。这类似于 C 预处理器中的#include指令。
一个简单的个人列表
现在,我们可以检查list项目。如果你在控制台中运行服务器,并通过网页浏览器访问localhost:8080地址,你将在浏览器中看到以下页面:

这里有一个标题,一个标签,一个文本字段,一个推送按钮,以及一个包含三个人名单的表格。
在这个页面上,你可以做的唯一一件事是在文本字段中输入一些内容,然后点击按钮将输入的文本作为过滤器应用。例如,如果你输入l(即小写的L),只有哈姆雷特和奥赛罗的台词会出现,因为他们是唯一两个名字中包含这个字母的人。如果过滤器是x,结果将是“无人员”文本,因为这三个人中没有一个人的名字包含这个字母。页面看起来如下截图所示:

在解释这一切是如何工作的之前,让我们看看这个项目的依赖项;即它使用的外部 crate。它们如下所示:
-
actix-web:这是一个 Web 框架,也在第三章 创建 RESTful Web 服务 中使用。 -
tera:这是 Tera 模板引擎。 -
serde和serde_derive:这些是 Tera 引擎使用的序列化 crate,用于将整个结构体对象传递到模板上下文中。 -
lazy_static:这包含初始化 Tera 引擎的宏。
现在,让我们看一下源代码。对于这个项目,src文件夹包含以下文件:
-
main.rs:这是整个服务器端应用程序,不包括数据库。 -
db_access.rs:这是一个包含一些模拟数据的模拟数据库。 -
favicon.ico: 这是任何网站都应该有的图标,因为它会被浏览器自动下载并在浏览器标签中显示。
此外,还有一个 templates 文件夹,包含以下文件:
-
main.html: 这是整个网页的 Tera/HTML 模板,其中包含一个空白的主体。 -
persons.html: 这是部分网页的 Tera/HTML 模板,仅包含我们 Web 应用的主体。 -
main.js: 这是需要包含在 HTML 页面中的 JavaScript 代码。
现在,让我们来检查这个 Web 应用的机制。
当用户导航到 http://localhost:8080/ URI 时,浏览器向我们的进程发送一个 GET HTTP 请求(其路径只有一个斜杠),没有查询和空主体,并期望显示一个 HTML 页面。正如前一章所述,服务器——使用 Actix Web 框架——如果其 main 函数包含以下代码,则可以响应请求:
let server_address = "127.0.0.1:8080";
println!("Listening at address {}", server_address);
let db_conn = web::Data::new(Mutex::new(AppState {
db: db_access::DbConnection::new(),
}));
HttpServer::new(move || {
App::new()
.register_data(db_conn.clone())
.service(
web::resource("/")
.route(web::get().to(get_main)),
)
})
.bind(server_address)?
.run()
这里,我们有一个 Web 应用,其状态仅是一个对数据库连接(实际上是一个模拟数据库)的共享引用。这个应用只接受一种类型的请求——使用根路径 (/) 和 GET 方法的请求。这些请求被路由到 get_main 函数。该函数应返回一个包含要显示的初始 HTML 页面的 HTTP 响应。
下面是 get_main 函数的主体:
let context = tera::Context::new();
HttpResponse::Ok()
.content_type("text/html")
.body(TERA.render("main.html", context).unwrap())
这个函数根本不使用请求,因为它总是返回相同的结果。
要返回一个成功的响应(即状态码 200),调用 HttpResponse::Ok() 函数。要指定响应的主体是 HTML 代码,在响应上调用 content_type("text/html") 方法。要指定响应主体的内容,在响应上调用 body 方法。
body 函数的参数必须是一个包含要显示的 HTML 代码的字符串。可以像下面这样在这里编写所有代码:
.body("<!DOCTYPE html><html><body><p>Hello</p></body></html>")
然而,对于更复杂的页面,最好将所有 HTML 代码保存在一个单独的文件中,使用 .html 文件名扩展名,并将该文件的内容加载到一个字符串中,作为 body 函数的参数传递。这可以通过以下表达式完成:
.body(include_str!("main.html"))
如果 main.html 文件是静态的;也就是说,它不需要在运行时更改,这将工作得很好。然而,这个解决方案有两个原因过于限制:
-
我们希望我们的初始页面是一个 动态 页面。当页面打开时,它应该显示数据库中的人名单。
-
我们希望我们的初始页面,以及所有其他可能的页面,由几个部分组成:元数据元素、JavaScript 程序、样式、页面标题、页面主体部分和页面页脚。所有这些部分(除了主体部分)都应由所有页面共享,以避免在源代码中重复它们。因此,我们需要将这些部分保存在单独的文件中,然后在将 HTML 页面发送到浏览器之前将它们拼接在一起。此外,我们希望将 JavaScript 代码保存在具有
.js文件扩展名的单独文件中,将样式代码保存在具有.css文件扩展名的单独文件中,以便我们的 IDE 能够识别它们的语言。
解决这些问题的方法之一是使用 Tera 模板引擎,我们将在下一节中看到。
模板文件夹
最好将所有可交付的应用程序文本文件放在templates文件夹中(或其子文件夹中的某些文件夹)。因此,这个子树应该包含所有的 HTML、CSS 和 JS 文件,即使目前它们可能不包含任何 Tera 语句或表达式。
相反,非文本文件(如图片、音频、视频等)、用户上传的文件、需要明确下载的文档以及数据库应保存在其他地方。
所有模板文件的加载发生在运行时,但通常只在进程生命周期中发生一次。加载发生在运行时的这一事实意味着templates子树必须部署,并且要部署这些文件的新版本或更改版本,不需要重新构建程序。这一加载通常只在进程生命周期中发生一次的事实意味着模板引擎在第一次之后处理模板的速度相当快。
前面的 body 语句具有以下参数:
TERA.render("main.html", context).unwrap()
此表达式使用context Rust 变量中包含的 Tera 上下文来渲染main.html文件中包含的模板。此类变量已通过tera::Context::new()表达式初始化,因此它是一个空上下文。
HTML 文件非常小,它有两个值得注意的片段。第一个如下:
<script>
{% include "main.js" %}
</script>
这使用include Tera 语句将 JavaScript 代码合并到 HTML 页面中。这意味着将其合并到服务器意味着不需要进一步的 HTTP 请求来加载它。第二个片段如下:
<body id="body" onload="getPage('/page/persons')">
这导致页面加载时立即调用getPage JavaScript 函数。此函数定义在main.js文件中,正如其名称所暗示的,它会导致指定页面的加载。
因此,当用户导航到网站的根目录时,服务器准备了一个包含所有必需的 JavaScript 代码但几乎不含 HTML 代码的 HTML 页面,并将其发送到浏览器。一旦浏览器加载了空页面,它就会请求另一个页面,该页面将成为第一个页面的主体。
这可能听起来很复杂,但你可以把它看作页面被分成两部分——元数据、脚本、样式,以及可能还有页面头部和页脚是公共部分,这些在会话期间不会改变。中心部分(在这里是body元素,但也可能是一个内部元素)是变量部分,它会随着用户的任何点击而改变。
通过只重新加载页面的一部分,应用程序具有更好的性能和可用性。
让我们来看看main.js文件的内容:
function getPage(uri) {
var xhttp = new XMLHttpRequest();
xhttp.onreadystatechange = function() {
if (this.readyState == 4 && this.status == 200) {
document.getElementById('body')
.innerHTML = xhttp.responseText;
}
};
xhttp.open('GET', uri, true);
xhttp.send();
}
此代码创建了一个XMLHttpRequest对象,尽管它的名字叫 XML,但实际上并不使用 XML,而是用来发送 HTTP 请求。此对象被设置为在响应到达时通过将匿名函数分配给onreadystatechange字段来处理响应。然后,使用GET方法打开指定的 URI。
当响应到达时,代码会检查消息是否完整(readystate == 4)和有效(state == 200)。在这种情况下,假设为有效 HTML 的响应文本被分配为具有body唯一 ID 的元素的内容。
templates文件夹中的最后一个文件是persons.html文件。它是一个部分 HTML 文件——也就是说,一个包含 HTML 元素但不含<html>元素的文件——因此它的目的是被包含在另一个 HTML 页面中。这个小应用程序只有一个页面,所以它只有一个部分 HTML 文件。
让我们看看这个文件的一些有趣的部分。以下是一个允许用户输入文本的元素(所谓的编辑框):
<input id="name_portion" type="text" value="{{partial_name}}"/>
它的初始值——即在页面打开时显示给用户的文本——是一个Tera 变量。Rust 代码应该给这个变量赋值。
然后,这里有Filter按钮:
<button onclick="getPage('/page/persons?partial_name='
+ getElementById('name_portion').value)">Filter</button>
当用户点击它,并且前面的编辑框包含单词Ham时,'/page/persons?partial_name=Ham'参数会被传递给 JavaScript 的getPage函数。因此,该函数向后端发送GET请求,并将页面的主体替换为后端返回的任何内容,只要它是一个完整且有效的响应。
然后,这里有以下的 Tera 语句:
{% if persons %}
...
{% else %}
<p>No persons.</p>
{% endif %}
在这里,persons Tera 变量被评估。根据 Rust 程序,变量只能是一个集合。如果变量是一个非空集合,则会在 HTML 页面中插入一个表格;如果变量未定义或它是一个空集合,则显示No persons.文本。
在定义表格的 HTML 代码中,有如下内容:
{% for p in persons %}
<tr>
<td>{{p.id}}</td>
<td>{{p.name}}</td>
</tr>
{% endfor %}
这是一个对persons(我们知道它是非空的)中包含的项目进行迭代。
在每次迭代中,p变量将包含一个特定人的数据。这个变量在两个表达式中使用。第一个表达显示了变量的id字段的值。第二个表达显示了其name字段的值。
其他 Rust 处理器
我们只看到了网站根部的路由和处理——即/路径。这发生在用户打开页面时。
浏览器可以向该应用程序发送四种其他请求:
-
当访问根路径时,由该请求加载的页面会自动使用 JavaScript 代码发送另一个请求以加载页面主体。
-
当用户按下筛选按钮时,前端应将编辑框中包含的文本发送到后端,然后后端应通过发送满足此筛选条件的人员列表进行响应。
-
浏览器自动请求
favicon.ico应用图标。 -
任何其他请求都应被视为错误。
实际上,这些请求中的第一和第二条可以以相同的方式处理,因为初始状态可以通过指定空字符串的筛选生成。因此,剩下三种不同类型的请求。
为了路由这些请求,以下代码被插入到main函数中:
.service(
web::resource("/page/persons")
.route(web::get().to(get_page_persons)),
)
.service(
web::resource("/favicon.ico")
.route(web::get().to(get_favicon)),
)
.default_service(web::route().to(invalid_resource))
第一条路由将任何GET请求重定向到/page/persons路径的get_page_persons函数。这些请求发生在用户点击筛选按钮时,也间接发生在请求/路径时。
第二条路由将任何GET请求重定向到/favicon.ico路径的get_favicon函数。这些请求来自浏览器,当它收到完整的 HTML 页面而不是部分页面时。
对default_resource的调用将任何其他请求重定向到invalid_resource函数。这些请求不能通过正确使用应用程序进行,但在特定条件下或当用户在浏览器的地址栏中输入意外的路径时可能会发生。例如,当你输入http://127.0.0.1:8080/abc时,就会发生这种请求。
现在,让我们看看处理器的函数。
get_page_persons函数有两个参数:
-
使用
web::Query<Filter>传递可选的筛选条件。 -
使用
web::Data<Mutex<AppState>>传递数据库连接。
Query类型的参数定义如下:
#[derive(Deserialize)]
pub struct Filter {
partial_name: Option<String>,
}
这指定了查询的可能参数,即问号之后 URI 的部分。在这里,只有一个参数,它是可选的,因为这在 HTTP 查询中很典型。一个可能的查询是?partial_name=Jo,但在这种情况下,一个空字符串也是有效的查询。
为了能够从请求中接收Filter结构,它必须实现Deserialize特质。
get_page_persons函数的主体如下:
let partial_name = &query.partial_name.clone().unwrap_or_else(|| "".to_string());
let db_conn = &state.lock().unwrap().db;
let person_list = db_conn.get_persons_by_partial_name(&partial_name);
let mut context = tera::Context::new();
context.insert("partial_name", &partial_name);
context.insert("persons", &person_list.collect::<Vec<_>>());
HttpResponse::Ok()
.content_type("text/html")
.body(TERA.render("persons.html", context).unwrap())
第一个语句从请求中获取查询。如果定义了partial_name字段,则提取它;否则,生成一个空字符串。
第二个语句从共享状态中提取数据库连接。
第三条语句使用这个连接来获取满足条件的人员的迭代器。参见前一章“构建有状态服务器”部分中的实现数据库子节。参见前一章以了解这两行。
然后,创建一个空的 Tera 上下文,并向其中添加两个 Tera 变量:
-
partial_name用于在编辑框中保留在页面重新加载时通常会消失的输入字符。 -
persons是包含从数据库收集的人员的向量。为了实现这一点,Person类型必须实现Serialize特质。
最后,Tera 引擎可以使用上下文渲染persons.html模板,因为模板中使用的所有变量都已定义。渲染的结果作为成功 HTTP 响应的主体传递。当浏览器内的 JavaScript 代码接收到该 HTML 代码时,它将使用它来替换当前页面主体的内容。
现在,让我们看看get_favicon函数的主体:
HttpResponse::Ok()
.content_type("image/x-icon")
.body(include_bytes!("favicon.ico") as &[u8])
这是一个简单的成功 HTTP 响应,其内容是image HTTP 类型和x-icon子类型,其主体是包含图标的字节数据。这个二进制对象在编译时从favicon.ico文件中包含的字节构建。这个文件的内容嵌入到可执行程序中,因此不需要部署此文件。
最后,让我们看看invalid_resource函数的主体:
HttpResponse::NotFound()
.content_type("text/html")
.body("<h2>Invalid request.</h2>")
这是一个失败的响应(因为NotFound生成404状态码),它应该包含一个完整的 HTML 页面。为了简单起见,返回了一个直接的消息。
我们现在已经查看了一个非常简单的 Web 应用。本节中看到的大多数概念将在下一节中使用,其中数据库将通过用户操作进行修改。
一个 CRUD 应用
上一节中显示的 Web 应用允许我们在单个页面上查看筛选后的数据。如果您现在在crud文件夹中运行项目,您将看到一个更加丰富和有用的网页:

Id 编辑框和其右侧的查找按钮用于打开一个页面,允许您查看或编辑具有特定 ID 的人员数据。
名称部分编辑框和其右侧的筛选按钮用于筛选下方的表格,方式与list项目类似。
然后,有两个按钮——一个用于删除数据,一个用于添加数据。
最后,这里有人员的过滤表格。在这个应用中,数据库的初始状态是空的人员列表,因此不显示 HTML 表格。
让我们创建一些人。
点击“添加新人员”按钮。您将看到以下窗口:

这是用于创建人员并将其插入数据库的页面。Id 字段被禁用,因为其值将自动生成。要插入人员,为他们输入一个名字——例如,Juliet——然后点击插入按钮。主页将再次出现,但前面有一个包含只有 Juliet 的小表格,其 ID 为 1。
如果你重复这些步骤,插入 Romeo 和 Julius,你将看到以下图片中的结果:

任何列出的人员附近的按钮允许我们打开与该人员相关的页面。例如,如果点击 Julius 附近的按钮,将出现以下页面:

此页面与用于插入人员的页面非常相似,但有以下区别:
-
Id 字段现在包含一个值。
-
Name 字段现在包含一个初始值。
-
现在没有了插入按钮,而是有一个更新按钮。
如果你将 Julius 的值更改为 Julius Caesar 并点击更新,你将在主页上看到更新的列表。
打开与单个人员相关的页面的另一种方法是,在 Id 字段中输入该人员的 ID,然后点击查找按钮。如果你在字段为空或包含没有人员作为其 ID 的值时点击此按钮,页面上将出现红色错误消息:

此应用程序的最后一个功能允许我们删除记录。要这样做,点击要删除的人员所在行的左侧复选框,然后点击删除选定人员按钮。列表将立即更新。
注意,数据库存储在后端进程的内存中。如果你关闭浏览器并重新打开,或者打开另一个浏览器,你将看到相同的人员列表。你甚至可以从另一台计算机打开页面,只要你在后端进程运行的计算机上插入适当的名称或 IP 地址。然而,如果你通过按 Ctrl + C 键组合(或任何其他方式)终止后端进程,然后重新运行它,当页面重新加载时,所有浏览器都将显示没有人员。
JavaScript 代码
我们现在将探讨使此项目与上一节中描述的项目不同的因素。
首先,main.js 文件要大得多,因为它包含三个额外的函数:
-
sendCommand:这是一个相当通用的例程,用于向服务器发送 HTTP 请求,并异步处理接收到的响应。它接受五个参数:-
method是要使用的 HTTP 命令,例如GET、PUT、POST或DELETE。 -
uri是发送到服务器的路径和可能的查询。 -
body是请求的可能正文,用于发送大于 2 KB 的数据。 -
success是一个函数的引用,该函数将在接收到成功响应(status == 200)后被调用。 -
failure是一个在接收到任何失败响应(status != 200)后将被调用的函数的引用。这个函数用于访问 REST 服务,因为它允许任何 HTTP 方法,但它不会自动更改当前的 HTML 页面。相反,
getPage函数只能使用GET方法,但它会替换当前 HTML 页面为接收到的 HTML 代码。
-
-
delete_selected_persons:这个函数会扫描复选框被选中的项目,并使用/persons?id_list=URI 发送一个DELETE命令到服务器,后面跟着一个由逗号分隔的选定项目 ID 列表。服务器应该删除这些记录并返回一个成功状态。如果删除成功,这个 JavaScript 函数将不带过滤器重新加载主页面;否则,在消息框中显示错误消息,并且当前页面不会改变。应该在点击删除选定人员按钮时调用此函数。 -
savePerson:这个函数接收一个 HTTP 方法,可以是POST(用于插入)或PUT(用于更新)。它使用作为参数接收的方法和一个依赖于方法的 URI 向服务器发送命令。对于POST请求,URI 是/one_person?name=NAME,而对于PUT请求,URI 是/one_person?id=ID&name=NAME,其中ID和NAME实际上是创建或更新记录的id和name字段的值。当点击插入按钮时,应该使用POST参数调用此函数,当点击更新按钮时,应该使用PUT参数调用此函数。
现在,让我们检查应用的 HTML 代码。
HTML 代码
当然,已经向 persons.html 文件添加了许多 HTML 元素来创建额外的控件。
首先,有一个 <label class="error">{{id_error}}</label> 元素,用于显示由查找按钮引起的错误消息。为了正确处理此元素,需要在当前的 Tera 上下文中定义 id_error Tera 变量。
然后,还有以下元素:
<div>
<label>Id:</label>
<input id="person_id" type="number">
<button onclick="getPage(
'/page/edit_person/' + getElementById('person_id').value)"
>Find</button>
</div>
当点击查找按钮时,会请求位于 /page/edit_person/ URI 的页面,后面跟着输入的 ID。
然后,有两个按钮:
<div>
<button onclick="delete_selected_persons()">Delete Selected Persons</button>
<button onclick="getPage('/page/new_person')">Add New Person</button>
</div>
第一个简单地将所有工作委托给 delete_selected_persons 函数,而第二个则获取位于 /page/new_person URI 的页面。
最后,在包含人员列表的 HTML 表中添加了两列。它们位于表的左侧:
<td><input name="selector" id="{{p.id}}" type="checkbox"/></td>
<td><button onclick="getPage('/page/edit_person/{{p.id}}')">Edit</button></td>
第一列是用于选择要删除的记录的复选框,第二列是编辑按钮。复选框元素的 HTML id 属性的值是 {{p.id}} Tera 表达式,它将被当前行的记录 ID 所替换。因此,这个属性可以用来准备请求,将其发送到服务器以删除选定的项目。
编辑按钮将获取位于 /page/edit_person/ URI 的页面,后面跟着当前记录的 ID。
此外,还有一个另一个 HTML 部分文件,one_person.html。这个页面用于插入新的人和查看/编辑现有的人。其第一部分如下:
<h1>Person data</h1>
<div>
<label>Id:</label>
<input id="person_id" type="number" value="{{ person_id }}" disabled>
</div>
<div>
<label>Name:</label>
<input id="person_name" type="text" value="{{ person_name }}"/>
</div>
对于这两个input元素,value属性被设置为 Tera 表达式;对于第一个,它是person_id Tera 变量,对于第二个,它是person_name Tera 变量。当插入一个人时,这些变量将是空的,而当编辑一个人时,这些变量将包含数据库字段的当前值。
文件的最后部分如下:
{% if inserting %}
<button onclick="savePerson('POST')">Insert</button>
{% else %}
<button onclick="savePerson('PUT')">Update</button>
{% endif %}
<button onclick="getPage('/page/persons')">Cancel</button>
当页面用于插入一个人时,它必须显示插入按钮;当用于查看或编辑一个人时,它必须显示更新按钮。因此,使用inserting Tera 变量。当处于插入模式时,其值将为true,而在编辑模式时为false。
最后,取消按钮打开没有过滤的/page/persons页面。
关于templates文件夹,我们所需了解的就是这些。
Rust 代码
在src文件夹中,db_access.rs和main.rs文件都有很多更改。
db_access.rs文件更改
persons向量最初是空的,因为用户可以将其中的记录插入进去。
已添加以下函数:
-
get_person_by_id:这个函数在向量中搜索具有指定 ID 的人。如果找到,则返回该人,否则返回None。 -
delete_by_id:这个函数在向量中搜索具有指定 ID 的人;如果找到,则从向量中删除并返回true。否则,返回false。 -
insert_person:接收一个Person对象作为参数以将其插入数据库。然而,在将其插入向量之前,其id字段被一个唯一的 ID 值覆盖。如果向量不为空,则此值是向量中最大 ID 的整数加一,否则为1。 -
update_person:这个函数在向量中搜索具有指定 ID 的人;如果找到,则用指定的人替换这个人并返回true。否则,返回false。
这些函数中没有包含任何特定的网络内容。
main.rs文件更改
对于main函数,有各种对该路由的请求。新的路由如下:
.service(
web::resource("/persons")
.route(web::delete().to(delete_persons)),
)
.service(
web::resource("/page/new_person")
.route(web::get().to(get_page_new_person)),
)
.service(
web::resource("/page/edit_person/{id}")
.route(web::get().to(get_page_edit_person)),
)
.service(
web::resource("/one_person")
.route(web::post().to(insert_person))
.route(web::put().to(update_person)),
)
第一条路径用于删除选定的个人。
第二条路径用于获取页面,允许用户以插入模式插入一个新的人——即one_person.html页面。
第三条路径用于获取页面,允许用户查看或编辑一个新的人——即one_person.html页面——在编辑模式下。
对于第四个资源,有两条可能的路径。实际上,这个资源可以通过使用POST方法或PUT方法来访问。第一种方法用于将新记录插入数据库。第二种方法用于使用指定数据更新指定的记录。
现在,让我们看看处理程序。与之前的项目相比,其中一些是新的,一些是旧的但已更改,还有一些是未修改的。
新的处理程序如下:
-
delete_persons用于删除选定的人员。 -
get_page_new_person用于获取创建新人员的页面。 -
get_page_edit_person用于获取编辑现有人员的页面。 -
insert_person用于将新人员插入到数据库中。 -
update_person用于更新数据库中的现有人员。
改变的处理程序是get_page_persons和invalid_resource。未修改的处理程序是get_main和get_favicon。
这些处理程序可以分为三种逻辑类型:
-
那些负责生成 HTML 代码以替换网页部分的任务
-
那些负责返回非 HTML 数据的任务
-
那些执行一些工作然后返回关于已完成工作的状态信息的任务
返回 HTML 的函数有get_main、get_page_persons、get_page_new_person、get_page_edit_person和invalid_resource。get_favicon是唯一的数据返回函数;其他三个是数据处理函数。
从逻辑上讲,可以有一个单独的处理程序,首先执行一些工作,然后返回要显示的 HTML 页面。然而,最好将这些逻辑上不同的功能分离成两个不同的函数——首先,执行数据处理函数,然后运行返回 HTML 代码的函数。这种分离可以在后端或前端发生。
在这个项目中,前端负责分离。首先,JavaScript 代码发送请求来操作数据(例如,在数据库中插入记录),然后,如果操作成功,其他 JavaScript 代码请求 HTML 代码在浏览器中显示出来。
一种替代架构是以下调用序列:
-
用户在网页上执行一个操作。
-
该操作会导致执行一个 JavaScript 程序。
-
该程序从浏览器向服务器发送请求。
-
服务器将请求路由到后端处理程序函数。
-
后端处理程序首先调用一个程序来处理数据,然后等待其完成。
-
如果后端程序成功,后端将调用另一个程序来生成并返回下一个 HTML 页面给浏览器。如果后端程序失败,后端将生成并返回另一个 HTML 页面给浏览器,描述失败情况。
-
JavaScript 程序接收 HTML 页面并将其显示给用户。
现在,让我们逐个查看get_page_edit_person函数的主体。
记住,这个程序的目的生成网页的 HTML 代码以编辑一个人的名字。要编辑的人的当前名字应在数据库中找到,而常量 HTML 代码应在one_person.html模板中找到。
前五条语句定义并初始化了尽可能多的局部变量:
let id = &path.0;
let db_conn = &state.lock().unwrap().db;
let mut context = tera::Context::new();
if let Ok(id_n) = id.parse::<u32>() {
if let Some(person) = db_conn.get_person_by_id(id_n) {
第一条语句从路径中获取id变量作为字符串。对于这个函数,路由是/page/edit_person/{id},因此id变量可用于提取。
第二条语句获取并锁定数据库连接。
第三条语句创建一个空的 Tera 上下文。
第四条语句尝试将id Rust 变量解析为整数。如果转换成功,if语句的条件得到满足,因此执行下一条语句。
第五条语句通过调用get_person_by_id方法在数据库中搜索由该 ID 标识的人。
现在所需信息可用,Tera 上下文可以填充:
context.insert("person_id", &id);
context.insert("person_name", &person.name);
context.insert("inserting", &false);
让我们看看这些变量的目的是什么:
-
person_idTera 变量允许我们在页面上显示当前(禁用)的 ID。 -
person_nameTera 变量允许我们在页面上显示当前(可编辑)的人名。 -
insertingTera 变量允许我们(通过条件 Tera 语句)将页面设置为编辑页面,而不是插入页面。
然后,我们可以使用这个上下文调用render Tera 方法来获取 HTML 页面,并将生成的页面作为响应的 HTML 主体发送:
return HttpResponse::Ok()
.content_type("text/html")
.body(TERA.render("one_person.html", context).unwrap());
在这里,我们考虑了每个语句都成功的案例。在类型 ID 不是数字或它不在数据库中存在的情况下,函数执行以下代码。这发生在用户在主页的 ID 字段中输入错误数字然后点击查找时:
context.insert("id_error", &"Person id not found");
context.insert("partial_name", &"");
let person_list = db_conn.get_persons_by_partial_name(&"");
context.insert("persons", &person_list.collect::<Vec<_>>());
HttpResponse::Ok()
.content_type("text/html")
.body(TERA.render("persons.html", context).unwrap())
最后一行显示我们将使用的模板是persons.html,因此我们将转到主页。该模板的 Tera 变量是id_error、partial_name和persons。我们希望在第一个变量中有一个特定的错误消息,没有任何filter条件,以及所有人的列表。这可以通过过滤所有名字包含空字符串的人来实现。
当用户按下更新按钮时,调用update_person函数。
此函数有以下参数:
state: web::Data<Mutex<AppState>>,
query: web::Query<ToUpdate>,
第二个是使用以下结构定义的类型查询:
#[derive(Deserialize)]
struct ToUpdate {
id: Option<u32>,
name: Option<String>,
}
因此,此查询允许两个可选关键字:id和name。第一个关键字必须是一个整数。以下是一些有效的查询:
-
?id=35&name=Jo -
?id=-2 -
?name=Jo -
无查询
以下是对该结构的无效查询:
-
?id=x&name=Jo -
?id=2.4
这是函数体第一部分的内容:
let db_conn = &mut state.lock().unwrap().db;
let mut updated_count = 0;
let id = query.id.unwrap_or(0);
第一条语句获取并锁定数据库连接。
更新记录的计数由第二条语句定义。此例程只能更新一条记录,因此此计数只能是0或1。
然后,如果存在且有效,从查询中提取id变量,否则,将0视为替代。
注意,由于查询变量的类型定义了哪些字段被定义(它们是否为可选或必需以及它们的类型),Actix Web 框架可以对 URI 查询执行严格的解析。如果 URI 查询无效,则不会调用处理器,并将选择default_service例程。另一方面,在处理器中,我们可以确信查询是有效的。
函数体的最后一部分如下:
let name = query.name.clone().unwrap_or_else(|| "".to_string()).clone();
updated_count += if db_conn.update_person(Person { id, name }) {
1
} else {
0
};
updated_count.to_string()
首先,从查询中提取name变量,如果没有包含该变量,则考虑为空字符串。这个名称被克隆,因为数据库操作对其参数拥有所有权,我们不能放弃查询字段的拥有权。
然后,调用数据库连接的update_person方法。此方法接收一个使用刚刚提取的id和name值构造的新Person对象。如果此方法返回true,则将处理过的记录数设置为1。
最后,处理过的记录数作为响应返回。
其他例程在概念上与这里描述的类似。
处理具有身份验证的应用程序
之前所有应用程序的功能都对能够与我们的服务器建立 HTTP 连接的每个人开放。通常,一个 Web 应用程序应该根据当前使用它的人的行为不同。通常,一些用户被授权执行一些重要操作,例如添加或更新记录,而其他用户仅被授权读取这些记录。有时,必须记录特定用户的数据。
这打开了身份验证、授权和安全的广阔世界。
让我们想象一个简化的场景。有两个用户的配置文件连接到了模拟数据库:
-
joe,其密码为xjoe,只能读取人员数据库。 -
susan,其密码为xsusan,可以读取和写入人员数据库——也就是说,她可以执行前一个章节中应用程序允许的操作。
应用程序从登录页面开始。如果用户没有插入现有的用户名及其匹配的密码,他们将无法访问其他页面。即使用户名和密码有效,用户无权访问的小部件也会被禁用。
对于这些情况,一些应用程序创建一个服务器端用户会话。当用户数量有限时,这可能是一个合适的选择,但如果用户数量很多,可能会使服务器过载。在这里,我们将展示一个不使用服务器端会话的解决方案。
如果你运行auth项目并通过浏览器访问网站,你将看到以下页面:

这表明没有当前用户,并且有两个字段允许我们输入用户名和密码。如果您在用户名字段中输入foo然后点击登录,将会出现红色消息“用户foo未找到”。如果您输入susan然后点击登录,消息将是“用户susan的密码无效”。
相反,如果您输入了该用户的正确密码xsusan,将会出现以下页面:

这与crud项目的首页相同,增加了一行小部件:在蓝色中显示当前用户的名字和一个更改按钮。如果您点击更改用户按钮,您将返回到登录页面。此外,查看、编辑或添加人的页面在页面标题下方也有相同的小部件。
如果在登录页面您输入joe作为用户名,xjoe作为密码,将会出现以下页面:

这与susan出现的小部件相同,但删除选定人员按钮和添加新人员按钮现在被禁用。
要看到joe如何看待人们,首先,您需要以susan的身份登录,添加一些人,然后将用户更改为joe,因为joe不能添加人。如果您这样做,然后点击一个人的编辑按钮,您将看到以下页面,其中姓名字段为只读,更新按钮被禁用:

让我们从理解我们刚刚所做应用的细节开始。
实现
这个项目在crud项目的基础上添加了一些代码。
第一个区别在于Cargo.toml文件,其中添加了actix-web-httpauth = "0.1"依赖项。这个 crate 处理 HTTP 请求中用户名和密码的编码。
HTML 代码
main.html页面,而不是打开/page/persons URI,最初打开/page/login以显示登录页面。因此,这个项目为登录页面添加了一个新的 TERA 模板。这是以下所示的login.html部分 HTML 页面:
<h1>Login to Persons</h1>
<div>
<span>Current user:</span>
<span id="current_user" class="current-user"></span>
</div>
<hr/>
<label class="error">{{error_message}}</label>
<div>
<label>User name:</label>
<input id="username" type="text">
</div>
<div>
<label>Password:</label>
<input id="password" type="password">
</div>
<button onclick="login()">Log in</button>
其值得注意的点已用下划线标出:{{error_message}} Tera 变量,当点击登录按钮时调用login(),以及三个 ID 分别为current_user、username和password的元素。
persons.html和one_person.html模板在标题下方都有以下部分:
<div>
<span>Current user: </span>
<span id="current_user" class="current-user"></span>
<button onclick="getPage('/page/login')">Change User</button>
</div>
<hr/>
这将显示当前用户,或---,然后是更改用户按钮。点击此按钮将加载/page/login页面。
应用程序包含四个按钮,必须禁用未授权用户的按钮——两个在persons.html模板中,两个在one_person.html模板中。它们现在包含以下属性:
{% if not can_write %}disabled{% endif %}
它假设can_write Tera 变量定义为true,或任何非空值,如果——并且仅当——当前用户有修改数据库内容的授权。
在one_person.html模板中还有一个编辑框元素,必须将其设置为只读,以便未经授权更改数据的用户;因此,它包含以下属性:
{% if not can_write %}readonly{% endif %}
应该意识到,这些检查并不是最终的安全保障。前端软件中的授权检查总是可以被绕过的,因此最终的安全保障是由 DBMS 执行的那些。
然而,始终进行早期检查以使用户体验更加直观,错误信息更有帮助是很好的。
例如,如果实体的某个属性不应由当前用户修改,则可以使用 DBMS 以可靠的方式指定此约束。
然而,如果用户界面允许此类更改,用户可能会尝试更改此值,当他们发现这种更改不允许时,他们会感到失望。
此外,当尝试进行禁止的更改时,DBMS 会发出错误信息。该消息可能没有国际化,并引用了用户不熟悉的 DBMS 概念,例如表、列、行和对象名称。因此,此消息对用户来说可能是模糊的。
JavaScript 代码
main.js文件相对于crud项目有以下新增:
已添加并初始化为空字符串的全局变量username和password。
以下语句已添加到sendCommand函数和getPage函数中:
xhttp.setRequestHeader("Authorization",
"Basic " + btoa(username + ":" + password));
这为即将发送的 HTTP 请求设置了Authorization头。该头的格式是标准的 HTTP。
在getPage函数中,在将接收到的 HTML 代码分配给当前主体后的语句之后,插入以下三行:
var cur_user = document.getElementById('current_user');
if (cur_user)
cur_user.innerHTML = username ? username : '---';
如果当前页面包含具有current_user值的id属性元素,它们将设置该元素的内容。如果定义了username全局 JavaScript 变量且不为空,则该内容是该变量的值,否则为---。
另一个新增的是新login函数的定义。其主体如下:
username = document.getElementById('username').value;
password = document.getElementById('password').value;
getPage('/page/persons');
这将获取页面中username和password元素的价值,并将它们保存到具有相同名称的全局变量中,然后打开主页面。当然,这应该在login.html页面中调用,因为其他页面不太可能有password元素。
模拟数据库代码
模拟数据库中还有一个表:users。因此,其元素类型必须被定义:
#[derive(Serialize, Clone, Debug)]
pub struct User {
pub username: String,
pub password: String,
pub privileges: Vec<DbPrivilege>,
}
任何用户都有一个用户名、一个密码和一组权限。权限有一个自定义类型,该类型在同一个文件中定义:
#[derive(Serialize, Clone, Copy, PartialEq, Debug)]
pub enum DbPrivilege { CanRead, CanWrite }
在这里,只有两种可能的权限:能够读取数据库或能够写入数据库。现实世界的系统会有更多的粒度。
DbConnection结构现在也包含users字段,它是一个Users向量的集合。其内容(关于joe和susan的记录)在行内指定。
已添加以下函数:
pub fn get_user_by_username(&self, username: &str) -> Option<&User> {
if let Some(u) = self.users.iter().find(|u| u.username == username) {
Some(u)
}
else { None }
}
这将在users向量中搜索具有指定用户名的用户。如果找到,则返回;否则,返回None。
主函数
main函数只有两个小的更改。第一个更改是在App对象上调用data(Config::default().realm("PersonsApp"))。这个调用是为了从 HTTP 请求中获取认证上下文。它使用realm调用指定上下文。
第二个更改是添加以下路由规则:
.service(
web::resource("/page/login")
.route(web::get().to(get_page_login)),
)
此路径用于打开登录页面。它被主页用作应用的入口点,以及两个更改用户按钮。
get_page_login函数是唯一的新处理器。它只是调用get_page_login_with_message函数,该函数有一个字符串参数,用作错误消息显示。当此函数被get_page_login调用时,指定一个空字符串作为参数,因为在此页面上还没有发生错误。然而,此函数在其他六个地方被调用,其中指定了各种错误消息。此函数的目的是转到登录页面并显示作为参数接收到的消息。
登录页面显然对所有用户都是可访问的,就像 favicon 资源一样,但所有其他处理器都已修改,以确保只有授权用户才能访问这些资源。处理数据的处理器体具有以下结构:
match check_credentials(auth, &state, DbPrivilege::CanWrite) {
Ok(_) => {
... manipulate data ...
HttpResponse::Ok()
.content_type("text/plain")
.body(result)
},
Err(msg) => get_page_login_with_message(&msg)
}
首先,check_credentials函数检查由auth参数指定的凭据是否标识了一个具有CanWrite权限的用户。只有被允许写入的用户应该操作数据。对于他们,函数返回Ok,因此他们可以更改数据库,并以纯文本格式返回这些更改的结果。
不允许写入的用户将被重定向到登录页面,该页面显示check_credentials返回的错误消息。
相反,获取 HTML 页面的处理器体具有以下结构:
match check_credentials(auth, &state, DbPrivilege::CanRead) {
Ok(privileges) => {
... get path arguments, query arguments, body ...
... get data from the database ...
let mut context = tera::Context::new();
context.insert("can_write",
&privileges.contains(&DbPrivilege::CanWrite));
... insert some other variables in the context ...
return HttpResponse::Ok()
.content_type("text/html")
.body(TERA.render("<template_name>.html", context).unwrap());
},
Err(msg) => get_page_login_with_message(&msg)
}
在这里,就像典型情况一样,任何可以读取数据的用户也可以访问网页。在这种情况下,check_credentials函数成功并返回该用户的完整权限集。将这些结果与Ok(privileges)模式匹配会导致使用该用户的权限初始化privileges Rust 变量。
如果用户具有CanWrite权限,则该信息以true值传递给can_write Tera 变量,否则传递给false。这样,页面可以根据用户的权限启用或禁用 HTML 小部件。
最后,让我们看看check_credentials函数。
在其参数中,有 auth: BasicAuth。多亏了 actix_web_httpauth 包和主函数中对 data 的调用,此参数允许访问基本认证的授权 HTTP 头部。BasicAuth 类型的对象具有 user_id 和 password 方法,它们返回由 HTTP 客户端指定的可选凭据。
这些方法使用以下代码片段调用:
if let Some(user) = db_conn.get_user_by_username(auth.user_id()) {
if auth.password().is_some() && &user.password == auth.password().unwrap() {
此代码通过用户的用户名从数据库中获取用户信息,并检查存储的密码是否与来自浏览器的密码匹配。
这相当基础。现实世界的系统会存储加密的密码;它会使用相同的单向加密来加密指定的密码,并比较加密后的字符串。
然后,该程序会区分不同类型的错误:
-
HTTP 请求不包含凭据,或者凭据存在但指定的用户不在用户表中。
-
用户存在,但存储的密码与接收到的凭据中指定的密码不同。
-
凭据有效,但该用户没有所需的权限(例如,他们只有
CanRead访问权限,但需要CanWrite)。
因此,我们现在已经涵盖了一个简单的认证 Web 应用程序。
摘要
在本章中,我们看到了如何使用 Tera 模板引擎创建包含可变部分、条件部分、重复部分和从另一个文件中包含的部分的文本字符串或文件(而不仅仅是 HTML 格式)。
然后,我们看到了如何使用 Actix web——结合 HTML 代码、JavaScript 代码、CSS 样式和 Tera 模板引擎——创建一个具有 CRUD 功能、认证(以证明当前用户是谁)和授权(禁止对当前用户执行某些操作)的完整 Web 应用程序。
此项目向我们展示了如何创建一个执行客户端代码和服务器端代码的单个应用程序。
在下一章中,我们将看到如何使用 WebAssembly 技术和 Yew 框架创建客户端 Web 应用程序。
问题
-
创建包含可变部分的 HTML 代码的可能策略有哪些?
-
将 Tera 表达式嵌入到文本文件中的语法是什么?
-
将 Tera 语句嵌入到文本文件中的语法是什么?
-
在 Tera 渲染操作中,如何指定变量的值?
-
如何对发送到 Web 服务器的请求进行分类?
-
为什么将网页拆分成部分可能有用?
-
HTML 模板和 JavaScript 文件应该单独部署还是链接到可执行程序中?
-
哪个 JavaScript 对象可以用来发送 HTTP 请求?
-
当服务器不存储用户会话时,当前用户名应该存储在哪里?
-
如何从 HTTP 请求中提取凭据?
进一步阅读
-
关于 Tera 的更多信息可以在
tera.netlify.app/找到。 -
关于 Actix web 的更多信息可以在
actix.rs/docs/找到。 -
网页开发库和框架的状态可以在
www.arewewebyet.org/找到。
使用 Yew 创建客户端 WebAssembly 应用程序
本章中,你将看到如何使用 Rust 构建网页应用的前端,作为使用 HTML、CSS 和 JavaScript(通常使用 JavaScript 前端框架,如 React)或生成 JavaScript 代码的另一种语言(如 Elm 或 TypeScript)的替代方案。
要为网页浏览器构建 Rust 应用程序,必须将 Rust 代码转换为 WebAssembly 代码,这种代码可以被所有现代网页浏览器支持。现在,将 Rust 代码转换为 WebAssembly 代码的功能已包含在稳定的 Rust 编译器中。
开发大型项目需要网页前端框架。在本章中,将介绍 Yew 框架。这是一个支持使用 模型-视图-控制器(MVC)架构模式开发前端网页应用,并生成 WebAssembly 代码的框架。
本章将涵盖以下主题:
-
理解 MVC 架构模式及其在网页中的应用
-
使用 Yew 框架构建 WebAssembly 应用程序
-
如何使用 Yew 包创建采用 MVC 模式(
incr和adder)设计的网页 -
创建具有公共页眉和页脚(
login和yauth)的网页应用 -
创建具有前端和后端(在两个不同的项目中,
yclient和persons_db)的网页应用
前端使用 Yew 开发,后端是一个 HTTP RESTful 服务,使用 Actix web 开发。
第六章:技术要求
本章假设你已经阅读了前面的章节,此外,还需要具备 HTML 的相关知识。
要运行本章中的项目,只需安装 WebAssembly 代码生成器(简称 Wasm)即可。最简单的方法可能是输入以下命令:
cargo install cargo-web
13 分钟后,你的 Cargo 工具将增加几个命令。其中一些如下:
-
cargo web build(或cargo-web build):它构建旨在在网页浏览器中运行的 Rust 项目。它与cargo build命令类似,但用于 Wasm。 -
cargo web start(或cargo-web start):它执行cargo web build命令,然后启动一个网页服务器,每次被客户端访问时,都会向客户端发送一个完整的 Wasm 前端应用。它与cargo run命令类似,但用于服务 Wasm 应用程序。
本章的完整源代码位于存储库的 Chapter05 文件夹中:github.com/PacktPublishing/Creative-Projects-for-Rust-Programmers。
介绍 Wasm
Wasm 是一种强大的新技术,可以提供交互式应用程序。在网页出现之前,已经有许多开发者正在构建客户端/服务器应用程序,其中客户端应用程序在 PC 上运行(通常是 Microsoft Windows),而服务器应用程序在公司拥有的系统上运行(通常是 NetWare、OS/2、Windows NT 或 Unix)。在这样的系统中,开发者可以选择他们喜欢的客户端应用程序的语言。有些人使用 Visual Basic,其他人使用 FoxPro 或 Delphi,还有许多其他语言被广泛使用。
然而,对于这样的系统,部署更新是一种地狱般的体验,因为存在多个可能的问题,例如确保每个客户端 PC 都有适当的运行时系统,并且所有客户端都能同时获得更新。这些问题通过在网页浏览器中运行的 JavaScript 得到了解决,因为它是前端软件可以下载和执行的一个无处不在的平台。但这也有一些缺点:开发者被迫使用 HTML + CSS + JavaScript 来开发前端软件,而且有时这样的软件性能较差。
接下来是 Wasm,这是一种类似机器语言的编程语言,就像 Java 字节码或 Microsoft .NET CIL 代码,但它是由所有主流网页浏览器接受的标准化语言。其规范的第 1.0 版于 2017 年 10 月发布,到 2019 年,世界上已有超过 80%的网页浏览器支持它。这意味着它更高效,并且可以从多种编程语言中轻松生成,包括 Rust。
因此,如果将 Wasm 设置为 Rust 编译器的目标架构,用 Rust 编写的程序就可以在任何主流的现代网页浏览器上运行。
理解 MVC 架构模式
本章是关于创建网页应用程序的。所以,为了使事情更具体,让我们直接看看两个名为incr和adder的玩具网页应用程序。
实现两个玩具网页应用程序
要运行第一个玩具应用程序,请按照以下步骤操作:
-
进入
incr文件夹并输入cargo web start。 -
几分钟后,控制台上将出现一条消息,最后以以下行结束:
You can access the web server at `http://127.0.0.1:8000`.
- 现在,在网页浏览器的地址栏中输入:
127.0.0.1:8000或localhost:8000,你将立即看到以下内容:

- 点击两个按钮,或者选择以下文本框,然后按键盘上的+或0键。
-
如果你点击一次增加按钮,右侧框的内容将从 0 变为 1。
-
如果你再点击一次,它就会变成 2,以此类推。
-
如果你点击重置按钮,值会变为 0(零)。
-
如果你通过点击选择文本框,然后按+键,数值会增加,就像增加按钮一样。相反,如果你按0键,数值将被设置为零。
-
要停止服务器,请进入控制台并按Ctrl + C。
-
要运行
adder应用程序,请进入adder文件夹,并输入cargo web start。 -
类似地,对于其他应用程序,当服务器应用程序启动后,你可以刷新你的网页浏览器页面,你将看到以下页面:

- 在这里,你可以在第一个框中输入一个数字,在“加数 1”标签的右侧,在第二个框中输入另一个数字,然后按下“加”按钮。之后,你将在底部的文本框中看到这些数字的总和,该文本框已从黄色变为浅绿色,如下面的截图所示:

在加法操作后,“加”按钮已变为禁用状态。如果第一个两个框中的任何一个为空,则求和失败,不会发生任何操作。此外,如果你更改前两个框中任意一个的值,“加”按钮将变为启用状态,最后一个文本框变为空并变为黄色。
什么是 MVC 模式?
现在我们已经看到了一些非常简单的 Web 应用程序,我们可以使用这些应用程序作为例子来解释 MVC 架构模式是什么。MVC 模式是一种关于事件驱动的交互式程序的架构。
让我们看看什么是事件驱动的交互式程序。单词交互式是批处理的反义词。批处理程序是一种用户在开始时准备所有输入的程序,然后程序运行而不需要进一步输入。相反,交互式程序有以下步骤:
-
初始化。
-
等待用户采取某些行动。
-
当用户在输入设备上采取行动时,程序处理相关的输入,然后回到前面的步骤,等待进一步的输入。
例如,控制台命令解释器是交互式程序,所有的 Web 应用程序也都是交互式的。
术语事件驱动意味着在初始化后,应用程序不执行任何操作,直到用户在用户界面上执行某些操作。当用户在输入设备上采取行动时,应用程序处理这些输入,并且仅作为对用户输入的反应来更新屏幕。大多数 Web 应用程序都是事件驱动的。主要的例外是游戏和虚拟现实或增强现实环境,即使用户没有采取任何行动,动画也会继续。
本章中的所有示例都是事件驱动的交互式程序,因为初始化后,它们只有在用户用鼠标点击(或触摸触摸屏)或按下键盘上的任何键时才会执行某些操作。一些这样的点击和按键会在屏幕上引起变化。因此,MVC 架构可以应用于这些示例项目。
这个模式有几种方言。Yew 使用的方言源自 Elm 语言实现的方言,因此它被称为Elm 架构。
模型
在任何 MVC 程序中,都有一个名为model的数据结构,它包含表示用户界面所需的所有动态数据。
例如,在incr应用中,需要表示右侧框中包含的数字的值来表示该框,并且它可以随时改变。因此,这个数值必须在模型中。
在这里,浏览器窗口的宽度和高度通常不需要生成 HTML 代码,因此它们不应该成为模型的一部分。同样,按钮的大小和文本也不应该成为模型的一部分,但出于另一个原因:在这个应用中它们不能在运行时改变。尽管如此,如果这是一个国际化应用,所有文本也应该包含在模型中。
在adder应用中,模型应只包含三个文本框中的三个值。其中两个直接由用户输入,第三个是计算得出的,这并不重要。标签和文本框的背景颜色不应该成为模型的一部分。
视图
MVC 架构的下一部分是视图。它是指根据模型值如何表示(或渲染)屏幕图形内容的规定。它可以是声明性规范,如纯 HTML 代码,也可以是程序性规范,如一些 JavaScript 或 Rust 代码,或者它们的混合。
例如,在incr应用中,视图显示两个按钮和一个只读文本框,而在adder应用中,视图显示三个标签、三个文本框和一个按钮。
所有的按钮都有恒定的外观,但视图必须根据模型的变化更改数字的显示。
控制器
MVC 架构的最后一部分是控制器。它始终是一组由视图在用户使用输入设备与应用交互时调用的例程。当用户使用输入设备执行操作时,视图必须做的只是通知控制器用户已执行该操作,并指定操作(例如,哪个鼠标键被按下),以及位置(例如,屏幕的哪个位置)。
在incr应用中,三种可能的输入操作如下:
-
点击“增加”按钮
-
点击“重置”按钮
-
在文本框被选中时按下键盘上的键
通常,也可以使用键盘按下按钮,但这种动作可以被认为是相当于鼠标点击,因此每个按钮只通知一个输入动作类型。
在adder应用中,三种可能的输入操作如下:
-
改变“加数 1”文本框中的值
-
改变“加数 2”文本框中的值
-
点击“添加”按钮
有几种方式可以更改文本框的值:
-
在没有选择文本的情况下输入,插入额外的字符
-
在选择一些文本时输入,从而用字符替换所选文本
-
通过从剪贴板粘贴一些文本
-
通过从屏幕上的另一个元素拖放一些文本
-
通过使用鼠标上下旋转器
我们对此不感兴趣,因为它们由浏览器或框架处理。对于应用程序代码来说,重要的是当用户执行输入操作时,文本框会更改其值。
控制器的任务只是使用此类输入信息来更新模型。当模型完全更新后,框架通知视图需要刷新屏幕的外观,考虑到模型的新值。
在 incr 应用程序的情况下,当控制器被通知到按下 Increment 按钮,它会增加模型中的数字;当它被通知到按下 Reset 按钮,它会将模型中的该数字设置为零;当它被通知到在文本框上按下键时,它会检查按下的键是 +、0 还是其他,并将适当的更改应用于模型。在这些更改之后,视图被通知更新显示这样的数字。
在 adder 应用程序的情况下,当控制器被通知到 Addend 1 文本框的更改时,它会将编辑框中包含的新值更新到模型中。对于 Addend 2 文本框也有类似的行为;当控制器被通知到按下 Add 按钮,它会将模型中包含的两个加数相加并将结果存储在模型的第三个字段中。在这些更改之后,视图被通知更新显示这样的结果。
查看实现
关于网页,页面的表示通常由 HTML 代码组成,因此,使用 Yew 框架,视图函数必须生成 HTML 代码。这样的生成包含 HTML 代码的固定部分,但它们也访问模型以获取在运行时可能更改的信息。
在 incr 应用程序中,视图组合了定义两个按钮和一个只读数字 输入 元素的 HTML 代码,并将从模型中获取的值放入这样的 输入 元素中。视图包括通过将它们转发到控制器来处理两个按钮上的 HTML 点击 事件。
在 adder 应用程序中,视图组合了定义三个标签、三个数字输入元素和一个按钮的 HTML 代码,并将从模型中获取的值放入最后一个 输入 元素中。视图包括处理前两个文本框的 HTML 输入 事件和按钮上的 点击 事件,通过将它们转发到控制器。关于前两个文本框事件,框中的值被转发到控制器。
控制器实现
在使用 Yew 时,控制器通过一个 更新 程序实现,该程序处理来自视图的用户操作消息,并使用此类输入来更改模型。在控制器完成对模型的所有必要更改后,必须通知视图以将模型的更改应用于用户界面。
在某些框架中,例如在 Yew 中,这样的视图调用是自动的;该机制有以下步骤:
-
对于视图处理的任何用户操作,框架调用
update函数,即控制器。在这个调用中,框架将用户操作的详细信息传递给控制器;例如,在文本框中输入了哪个值。 -
通常,控制器会改变模型的状态。
-
如果控制器成功将一些更改应用到模型上,框架将调用视图函数,这是 MVC 架构的视图。
理解 MVC 架构
MVC 架构的控制流程在以下图中展示:

每个用户操作的迭代是这个操作序列:
-
用户看到屏幕上图形元素的静态表示。
-
用户使用输入设备对图形元素进行操作。
-
视图接收到用户操作并通知控制器。
-
控制器更新模型。
-
视图读取模型的新状态以更新屏幕的内容。
-
用户看到屏幕的新状态。
MVC 架构的主要概念如下:
-
所有需要正确构建显示的必要可变数据必须在一个名为模型的单个数据结构中。模型可能关联一些代码,但这些代码不直接接收用户输入,也不向用户输出。它可能访问文件、数据库或其他进程。由于模型不直接与用户界面交互,实现模型的代码在应用程序用户界面从文本模式移植到 GUI/web/mobile 时不应更改。
-
在屏幕上绘制并捕获用户输入的逻辑被称为视图。当然,视图必须了解屏幕渲染、输入设备和事件,以及模型。尽管视图只是读取模型,但它永远不会直接更改它。当发生有趣的事件时,视图会通知控制器该事件。
-
当控制器被视图通知有有趣的事件时,它会相应地更改模型,并在完成更改后,框架通知视图使用模型的新状态刷新自己。
项目概述
本章将介绍四个项目,这些项目的复杂度将逐渐增加。您已经看到了前两个项目的实际操作:incr和adder。第三个项目,命名为login,展示了如何在网站上创建登录页面进行身份验证。
第四个项目,命名为yauth,扩展了login项目,增加了对人员列表的 CRUD 处理。其行为几乎与第四章中“创建一个完整的后端 Web 应用”的auth项目相同。每个项目从零开始下载和编译大约需要 1 到 3 分钟。
入门
要启动所有设备,只需要一个非常简单的语句——main 函数的主体:
yew::start_app::<Model>();
它基于指定的 Model 创建一个网络应用程序,启动它,并等待默认的 TCP 端口。当然,TCP 端口可以更改。这是一个将应用程序提供给任何导航到它的浏览器的服务器。
incr 应用程序
在这里,我们将看到 incr 项目的实现,我们之前已经看到了如何构建和使用它。唯一的依赖项是 Yew 框架,因此,TOML 文件包含以下行:
yew = "0.6"
所有源代码都在 main.rs 文件中。模型通过以下简单声明实现:
struct Model {
value: u64,
}
它只需要是一个将被框架实例化的结构体,被视图读取,并被控制器读取和写入。它的名称及其字段名称是任意的。
然后必须将视图到控制器的可能通知声明为 enum 类型。以下是 incr 的示例:
enum Msg {
Increment,
Reset,
KeyDown(String),
}
此外,这里的名称也是任意的:
-
Msg是 message 的缩写,因为此类通知在某种程度上是视图到控制器的消息。 -
Increment消息通知点击了增量按钮。《Reset` 消息通知点击了重置按钮。 -
KeyDown消息通知键盘上任何键的按下;其参数传达了哪个键被按下。
要实现控制器,必须为我们的模型实现 yew::Component 特性。我们项目的代码如下:
impl Component for Model {
type Message = Msg;
type Properties = ();
fn create(_: Self::Properties, _: ComponentLink<Self>) -> Self {
Self { value: 0 }
}
fn update(&mut self, msg: Self::Message) -> ShouldRender { ... }
}
所需的实现如下:
-
Message:是之前定义的enum,描述了从视图到控制器的所有可能的通知。 -
Properties:在这个项目中没有使用。当未使用时,它必须是一个空元组。 -
create:框架调用它以让控制器初始化模型。它可以使用两个参数,但在这里我们对此不感兴趣,并且它必须返回一个具有初始值的模型实例。因为我们想在开始时显示数字零,我们将value设置为0。 -
update:框架在用户以某种方式在页面上进行操作时调用它,该操作由视图处理。两个参数是可变的模型本身(self)和来自视图的通知(msg)。此方法应返回ShouldRender类型的值,但bool值将很好。返回true表示模型已更改,因此需要刷新视图。返回false表示模型未更改,因此刷新视图将是浪费时间。
update 方法包含对消息类型的 match。前两种消息类型相当简单:
match msg {
Msg::Increment => {
self.value += 1;
true
}
Msg::Reset => {
self.value = 0;
true
}
如果收到 Increment 消息,则值会增加。如果收到 Reset 消息,则值归零。在这两种情况下,视图都必须刷新。
处理按键有点复杂:
Msg::KeyDown(s) => match s.as_ref() {
"+" => {
self.value += 1;
true
}
"0" => {
self.value = 0;
true
}
_ => false,
}
KeyDown匹配分支将按下的键分配给s变量。由于我们只对两个可能的键感兴趣,因此s变量上有嵌套的match语句。对于双键(+和0),更新模型并返回true以刷新视图。对于按下的任何其他键,不执行任何操作。
为了实现 MVC 的视图部分,必须为我们的模型实现yew::Renderable特质。唯一需要的方法是view,它获取对模型的不可变引用,并返回一个表示某些 HTML 代码的对象,但该对象能够读取模型并通知控制器:
impl Renderable<Model> for Model {
fn view(&self) -> Html<Self> {
html! { ... }
}
}
此方法的主体使用强大的yew::html宏构建。以下是此类宏调用的主体:
<div>
<button onclick=|_| Msg::Increment,>{"Increment"}</button>
<button onclick=|_| Msg::Reset,>{"Reset"}</button>
<input
readonly="true",
value={self.value},
onkeydown=|e| Msg::KeyDown(e.key()),
/>
</div>
它看起来非常类似于实际的 HTML 代码。它等同于以下 HTML 伪代码:
<div>
<button onclick="notify(Increment)">Increment</button>
<button onclick="notify(Reset)">Reset</button>
<input
readonly="true"
value="[value]"
onkeydown="notify(KeyDown, [key])"),
/>
</div>
注意,在任何 HTML 事件中,在 HTML 伪代码中,都会调用一个 JavaScript 函数(此处命名为notify)。相反,在 Rust 中,有一个返回给控制器消息的闭包。此类消息必须具有适当类型的参数。虽然onclick事件没有参数,但onkeydown事件有一个参数,在e变量中捕获,并通过调用该参数上的key方法,将按下的键传递给控制器。
还请注意在 HTML 伪代码中的[value]符号,在运行时将被实际值替换。
最后,请注意,宏的主体有三个与 HTML 代码不同的特性:
-
HTML 元素的所有参数都必须以逗号结尾。
-
任何 Rust 表达式都可以在 HTML 代码内部评估,只要它被括号包围。
-
在此 HTML 代码中不允许使用字面字符串,因此必须将其作为 Rust 字面量(通过在括号中包含它们)插入。
加法器应用
在这里,我们将看到adder项目的实现,我们已经看到了如何构建和使用它。我们将检查与incr项目不同的部分。
首先,存在一个与html宏展开递归级别的问题。它非常深,必须在程序开始时使用以下指令增加:
#![recursion_limit = "128"]
#[macro_use]
extern crate yew;
没有它们,将生成编译错误。对于更复杂的视图,需要更大的限制。模型包含以下字段:
addend1: String,
addend2: String,
sum: Option<f64>,
它们分别代表以下内容:
-
插入到第一个框中的文本(
addend1)。 -
插入到第二个框中的文本(
addend2)。 -
如果计算成功并在第三个框中显示计算出的数字,否则不显示。
处理的事件(即消息)如下:
ChangedAddend1(String),
ChangedAddend2(String),
ComputeSum,
它们分别代表以下内容:
-
对第一个框内容的任何更改,包含在框中的新值(
ChangedAddend1)。 -
对第二个框内容的任何更改,及其值(
ChangedAddend2)。 -
点击“添加”按钮。
create 函数初始化模型的三个字段:两个加数设置为空字符串,sum 字段设置为 None。使用这些初始值,在总和文本框中不显示任何数字。
update 函数处理三种可能的消息。对于 ComputeSum 消息,它执行以下操作:
self.sum = match (self.addend1.parse::<f64>(), self.addend2.parse::<f64>()) {
(Ok(a1), Ok(a2)) => Some(a1 + a2),
_ => None,
};
模型的 addend1 和 addend2 字段被解析以将它们转换为数字。如果两个转换都成功,第一个臂匹配,因此将 a1 和 a2 的值相加,并将它们的和分配给 sum 字段。如果某些转换失败,将 None 分配给 sum 字段。
关于第一个加数的臂如下所示:
Msg::ChangedAddend1(value) => {
self.addend1 = value;
self.sum = None;
}
当前文本框的值被分配给模型的 addend1 字段,并将 sum 字段设置为 None。对于其他加数的更改,执行类似的行为。
让我们看看 view 方法中最有趣的部分:
let numeric = "text-align: right;";
它将一段 CSS 代码片段分配给 Rust 变量。然后,使用以下代码创建第一个 addend 的文本框:
<input type="number", style=numeric,
oninput=|e| Msg::ChangedAddend1(e.value),/>
注意,将 numeric 变量的值分配给 style 属性。这些属性的值只是 Rust 表达式。
以下代码创建了 sum 文本框:
<input type="number",
style=numeric.to_string()
+ "background-color: "
+ if self.sum.is_some() { "lightgreen;" } else { "yellow;" },
readonly="true", value={
match self.sum { Some(n) => n.to_string(), None => "".to_string() }
},
/>
style 属性是通过连接之前看到的 numeric 字符串和背景颜色组成的。如果 sum 有数值,则颜色为浅绿色,如果它是 None,则颜色为黄色。此外,使用表达式分配 value 属性,如果 sum 是 None,则分配一个空字符串。
登录应用
到目前为止,我们已经看到,一个应用仅包含一个模型结构体、一个消息 enum、一个 create 函数、一个 update 方法和一个 view 方法。这对于非常简单的应用来说很好,但随着应用变得更加复杂,这种简单的架构变得难以管理。需要将应用的不同部分分离到不同的组件中,其中每个组件都按照 MVC 模式设计,因此它有自己的模型、控制器和视图。
通常,但不一定,有一个通用组件包含应用中所有部分都相同的部分:
-
一个包含标志、菜单和当前用户名的页眉
-
包含版权信息和联系信息的页脚
然后在页面中间,是内嵌部分(也称为 body,尽管它不是 body HTML 元素)。这个内嵌部分包含应用的真实信息,是许多可能的组件或表单(或页面)之一:
-
通过在其文件夹中键入
cargo web start来运行login应用。 -
当导航到
localhost:8000时,显示以下页面:

有两条水平线。第一行之上的部分是作为整个应用程序的头部,必须保持不变。第二行之下的部分是作为整个应用程序的尾部,也必须保持不变。中间部分是“登录”组件,仅在用户需要认证时出现。这部分将在用户认证后由其他组件替换。
首先,让我们看看一些认证失败的情况:
-
如果您直接点击“登录”,会出现一个消息框,显示:“用户未找到”。如果您在用户名框中输入一些随机字符,也会发生相同的情况。仅允许的用户名是
susan和joe。 -
如果您插入两个允许的用户名之一,然后点击登录,您将收到“指定用户无效密码”的消息。
-
如果您在密码框中输入一些随机字符,也会发生相同的情况。仅允许的用户密码是用户
susan的xsusan,以及用户joe的xjoe。如果您输入susan然后xsusan,在点击登录之前,您将看到以下内容:

接下来,您将看到以下内容:

以下三件事情发生了变化:
-
在标签“当前用户”的右侧,蓝色文本---已被替换为
susan。 -
在那个蓝色文本的右侧,出现了“更改用户”按钮。
-
在两条水平线之间,所有的 HTML 元素都被大号文本“待实现页面”所替换。当然,这种情况将代表用户已成功认证并正在使用应用程序的其余部分。
如果您点击“更改用户”按钮,您将看到以下页面:

它与第一个页面相似,但susan的名字既出现在“当前用户”处,也出现在“用户名”处。
项目组织结构
本项目的源代码已被拆分为三个文件(您可以在本书的 GitHub 仓库Chapter05/login/src/db_access.rs中找到):
-
db_access.rs:包含一个用于处理认证的用户目录的存根 -
main.rs:包含一行main函数,以及一个处理页面头部和尾部的 MVC 组件,并将内部部分委托给认证组件 -
login.rs:包含用于处理认证的 MVC 组件,用作主组件的内部部分
db_access.rs文件
db_access模块是前一章模块的一个子集。它声明了一个DbConnection结构体,用于模拟与数据库的连接。实际上,为了简单起见,它只包含Vec<User>,其中User是应用程序的账户:
#[derive(PartialEq, Clone)]
pub struct DbConnection {
users: Vec<User>,
}
User类型的定义如下:
pub enum DbPrivilege {
CanRead,
CanWrite,
}
pub struct User {
pub username: String,
pub password: String,
pub privileges: Vec<DbPrivilege>,
}
应用程序的任何用户都有一个名字、一个密码和一些权限。在这个简单的系统中,只有两种可能的权限:
-
CanRead,表示用户可以读取数据库中的所有内容 -
CanWrite,意味着用户可以更改数据库的所有内容(即插入、更新和删除记录)
连接了两个用户:
-
joe用户,密码为xjoe,只能从数据库中读取 -
susan用户,密码为xsusan,可以读取和写入数据
只有两个函数:
new,用于创建DbConnection:
pub fn new() -> DbConnection {
DbConnection {
users: vec![
User {
username: "joe".to_string(),
password: "xjoe".to_string(),
privileges: vec![DbPrivilege::CanRead],
},
User {
username: "susan".to_string(),
password: "xsusan".to_string(),
privileges: vec![DbPrivilege::CanRead,
DbPrivilege::CanWrite],
},
],
}
}
get_user_by_username,用于获取具有指定名称的用户引用,如果没有用户具有该名称,则为None:
pub fn get_user_by_username(&self, username: &str) -> Option<&User> {
if let Some(u) = self.users.iter().find(|u|
u.username == username) {
Some(u)
} else {
None
}
}
当然,首先,我们将使用 new 函数创建一个 DbConnection 对象,然后我们将使用 get_user_by_username 方法从该对象中获取一个 User。
main.rs 文件
main.rs 文件以以下声明开始:
mod login;
enum Page {
Login,
PersonsList,
}
第一个声明导入了 login 模块,该模块将被 main 模块引用。任何内部部分模块都必须在这里导入。
第二个声明声明了所有将用作内部部分的组件。在这里,我们只有认证组件(Login)和一个尚未实现的组件(PersonsList)。
然后,是主页 MVC 组件的模型:
struct MainModel {
page: Page,
current_user: Option<String>,
can_write: bool,
db_connection: std::rc::Rc<std::cell::RefCell<DbConnection>>,
}
按照惯例,任何模型的名称都以 Model 结尾:
-
模型的第一个字段是最重要的一个。它表示当前活动的是哪个内部部分(或页面)。
-
其他字段包含全局信息,即用于显示页眉、页脚或必须与内部组件共享的信息。
-
current_user字段包含已登录用户的名称,如果没有用户登录,则为None。 -
can_write标志是对用户权限的简单描述;在这里,两个用户都可以读取,但只有一个也可以写入,因此当它们登录时,此标志为true。 -
db_connection字段是对数据库占位符的引用。它必须与内部组件共享,因此它被实现为一个引用计数的智能指针到RefCell,其中包含实际的DbConnection。使用这种包装,任何对象都可以与其他组件共享,只要一次只有一个线程访问它们。
视图向控制器发送的可能通知如下:
enum MainMsg {
LoggedIn(User),
ChangeUserPressed,
}
记住,页脚没有可以获取输入的元素,而对于页眉,只有当它可见时,Change User 按钮可以获取输入。按下此类按钮时,将发送 ChangeUserPressed 消息。
因此,看起来没有发送 LoggedIn 消息的方法!实际上,Login 组件可以向主组件发送它。
控制器的更新函数具有以下主体:
match msg {
MainMsg::LoggedIn(user) => {
self.page = Page::PersonsList;
self.current_user = Some(user.username);
self.can_write = user.privileges.contains(&DbPrivilege::CanWrite);
}
MainMsg::ChangeUserPressed => self.page = Page::Login,
当 Login 组件通知主组件成功认证,从而指定了认证用户时,主控制器将 PersonsList 设置为要访问的页面,保存新认证用户的名称,并从该用户中提取权限。
当点击“更改用户”按钮时,要访问的页面 变为“登录”页面。view 方法只包含对 html 宏的调用。此类宏必须包含一个 HTML 元素,在这种情况下,它是一个 div 元素。
该 div 元素包含三个 HTML 元素:一个 style 元素、一个 header 元素和一个 footer 元素。但在标题和页脚之间,有一些 Rust 代码用于创建主页的内部部分。
要在 html 宏内插入 Rust 代码,有两种可能性:
-
HTML 元素的属性只是 Rust 代码。
-
在任何时刻,一对大括号包围着 Rust 代码。
在第一种情况下,此类 Rust 代码的评估必须返回一个可以通过 Display 特性转换为字符串的值。
在第二种情况下,大括号内 Rust 代码的评估必须返回一个 HTML 元素。那么如何从 Rust 代码中返回一个 HTML 元素呢?使用 html 宏!
因此,实现 view 方法的 Rust 代码包含一个 html 宏调用,该宏调用包含一个 Rust 代码块,该代码块又包含一个 html 宏调用,以此类推。这种递归是在编译时执行的,并且可以通过 recursion_limit Rust 属性来覆盖其限制。
注意,标题和内部部分都包含一个 match self.page 表达式。
在标题中,它只用于在当前页面不是登录页面时显示“更改用户”按钮,否则将毫无意义。
在内部部分,此类声明的主体如下:
Page::Login => html! {
<LoginModel:
current_username=&self.current_user,
when_logged_in=|u| MainMsg::LoggedIn(u),
db_connection=Some(self.db_connection.clone()),
/>
},
Page::PersonsList => html! {
<h2>{ "Page to be implemented" }</h2>
},
如果当前页面是“登录”,对 html 宏的调用包含 LoginModel: HTML 元素。实际上,HTML 语言没有这样的元素类型。这是在当前组件中嵌入另一个 Yew 组件的方式。LoginModel 组件在 login.rs 源文件中声明。其构造需要一些参数:
-
current_username是当前用户的名称。 -
when_logged_in是组件在成功完成身份验证时应调用的回调。 -
db_connection是数据库的(引用计数)副本。
关于回调,请注意它接收一个用户(u)作为参数,并返回由该用户装饰的消息 LoggedIn。将此消息发送到主组件的控制器是 Login 组件与刚刚登录的用户的主组件通信的方式。
login.rs 文件
login 模块首先定义 Login 组件的模型:
pub struct LoginModel {
dialog: DialogService,
username: String,
password: String,
when_logged_in: Option<Callback<User>>,
db_connection: std::rc::Rc<std::cell::RefCell<DbConnection>>,
}
此模型必须由主组件使用,因此它必须是公共的。
其字段如下:
-
dialog是对 Yew 服务的引用,这是请求框架执行比实现 MVC 架构更多操作的一种方式。对话框服务是向用户显示消息框的能力,通过浏览器的 JavaScript 引擎实现。 -
username和password是用户在两个文本框中输入的文本值。 -
when_logged_in是一个可能的回调函数,用于在成功认证完成后调用。 -
db_connection是数据库的引用。
可能的通知消息如下:
pub enum LoginMsg {
UsernameChanged(String),
PasswordChanged(String),
LoginPressed,
}
前两个消息表示相应的字段已更改值,第三个消息表示按钮已被按下。
到目前为止,我们已经看到这个组件有一个模型和一些消息,就像我们之前看到的组件一样;但现在我们将看到它还有一些我们从未见过的东西:
pub struct LoginProps {
pub current_username: Option<String>,
pub when_logged_in: Option<Callback<User>>,
pub db_connection:
Option<std::rc::Rc<std::cell::RefCell<DbConnection>>>,
}
这个结构表示每个父组件创建此组件必须传递的参数。在这个项目中,Login组件只有一个父组件,即主组件,该组件创建了一个具有LoginProps字段作为属性的LoginModel元素。请注意,所有字段都是Option的特殊化:即使你不将Option作为属性传递,Yew 框架也要求这样做。
这个LoginProps类型必须在四个地方使用:
- 首先,它必须实现
Default特质,以确保当框架需要此类型的对象时,其字段被正确初始化:
impl Default for LoginProps {
fn default() -> Self {
LoginProps {
current_username: None,
when_logged_in: None,
db_connection: None,
}
}
}
- 第二,我们已经看到,为模型实现
Component特质的实现必须定义一个Properties类型。在这种情况下,它必须是这样的:
impl Component for LoginModel {
type Message = LoginMsg;
type Properties = LoginProps;
即,这个类型被传递到LoginModel类型的Component特质的实现中。
- 第三,
create函数必须使用其第一个参数,该参数包含由父组件传递的值。以下是该函数:
fn create(props: Self::Properties, _link: ComponentLink<Self>)
-> Self {
LoginModel {
dialog: DialogService::new(),
username: props.current_username.unwrap_or(String::new()),
password: String::new(),
when_logged_in: props.when_logged_in,
db_connection: props.db_connection.unwrap(),
}
}
模型的所有字段都已初始化,但dialog和password字段接收默认值,而其他字段接收来自父组件接收的props对象中的值,即MainModel。因为我们确信props中的db_connection字段将是None,所以我们对其调用unwrap。相反,current_username字段可能是None,因此在这种情况下,使用空字符串。
然后是update函数,它是Login组件的控制器。
当用户按下登录按钮时,执行以下代码:
if let Some(user) = self.db_connection.borrow()
.get_user_by_username(&self.username)
{
if user.password == self.password {
if let Some(ref go_to_page) = self.when_logged_in {
go_to_page.emit(user.clone());
}
} else {
self.dialog.alert("Invalid password for the specified user.");
}
} else {
self.dialog.alert("User not found.");
}
使用borrow方法从RefCell中提取数据库连接,然后寻找当前名称的用户。如果找到用户,并且如果他们存储的密码与用户输入的密码相同,则从when_logged_in字段中提取回调,然后调用其emit方法,传递用户名的副本作为参数。因此,由父组件传递的例程,即|u| MainMsg::LoggedIn(u)闭包,被执行。
在用户缺失或密码不匹配的情况下,使用对话框服务的alert方法显示一个消息框。我们之前看到的控制器只有两个功能:create和update。但这个还有一个功能;它是change方法:
fn change(&mut self, props: Self::Properties) -> ShouldRender {
self.username = props.current_username.unwrap_or(String::new());
self.when_logged_in = props.when_logged_in;
self.db_connection = props.db_connection.unwrap();
true
}
此方法允许父组件使用 Properties 结构重新发送更新后的参数到该组件。create 方法只调用一次,而 change 方法在父组件需要更新传递给子组件的参数时会被调用。
通过阅读其代码,视图很容易理解,并且不需要解释。
yauth 应用
在上一节中展示的 login 应用程序展示了如何创建一个包含多个可能的子组件之一的父组件。然而,它只实现了一个子组件,即 Login 组件。因此,在本节中,将展示一个更完整的示例,包含三个不同的可能的子组件,对应于经典 Web 应用程序的三个不同的页面。
它被命名为 yauth,是 Yew Auth 的缩写,因为它的行为几乎与上一章中展示的 auth 项目相同,尽管如此,它完全基于 Yew 框架,而不是基于 Actix web 和 Tera。
理解应用的行为
本应用与上一节中的应用构建和启动方式相同,其第一页与 login 应用的第一页相同。尽管如此,如果你输入 susan 作为用户名,xsusan 作为密码,然后点击登录按钮,你会看到以下页面:

本页以及你将在本应用中看到的另一页,以及它们的行为,几乎与上一章中描述的 auth 应用程序的行为相同。唯一的区别如下:
-
任何错误信息都不会以红色文本的形式嵌入在页面中,而是以弹出消息框的形式显示。
-
头部和底部由主组件实现,它们的外观和行为如本章前述部分所述。
因此,我们只需要检查此应用的实现。
项目组织
本项目的源代码已被拆分为五个文件:
-
db_access.rs:它包含了一个连接到数据库的占位符,提供对用户目录的访问以处理身份验证以及人员列表;它实际上包含如向量这样的数据。它与上一章auth项目的同名文件几乎相同。唯一的区别是Serialize特性没有被实现,因为 Yew 框架不需要它。 -
main.rs:它包含了一行的main函数,以及处理页面头部和尾部的 MVC 组件,并将内嵌部分委托给应用的其他三个组件之一。 -
login.rs:它包含处理身份验证的 MVC 组件。它应作为主组件的内嵌部分使用。它与login项目的同名模块相同。 -
persons_list.rs:它包含处理人员列表的 MVC 组件。它应作为主组件的内嵌部分使用。 -
one_person.rs:它包含用于查看、编辑或插入单个人员的 MVC 组件;它将用作主组件的内嵌部分。
我们将只讨论yauth应用独有的文件,如下所述。
persons_list.rs文件
此文件包含组件的定义,以便用户管理人员列表,因此它定义以下结构作为模型:
pub struct PersonsListModel {
dialog: DialogService,
id_to_find: Option<u32>,
name_portion: String,
filtered_persons: Vec<Person>,
selected_ids: std::collections::HashSet<u32>,
can_write: bool,
go_to_one_person_page: Option<Callback<Option<Person>>>,
db_connection: std::rc::Rc<std::cell::RefCell<DbConnection>>,
}
让我们看看上一段代码中的每一行都说了什么:
-
dialog字段包含打开消息框的服务。 -
id_to_find字段包含用户在 Id 文本框中输入的值,如果框中包含数字,否则为None。 -
name_portion字段包含 Name 部分: 文本框中的值。特别是,如果该框为空,则模型中的该字段包含一个空字符串。filtered_persons字段包含使用指定过滤器从数据库中提取的人员列表。最初,过滤器指定提取所有名称包含空字符串的人员。当然,所有人员都满足该过滤器,因此数据库中的所有人员都被添加到该向量中,尽管数据库为空,因此该向量也为空。 -
selected_ids字段包含所有列出的、复选框被设置的人员的 ID,因此它们被选中以进行进一步操作。 -
can_write字段指定当前用户是否有修改数据的权限。 -
go_to_one_person_page字段包含要传递给查看/编辑/插入单个人员的页面的回调。此类回调函数接收一个参数,即要查看/编辑的人员,或None以打开插入新人员的页面。 -
db_connection字段包含对数据库连接的共享引用。
从视图到控制器的可能通知由该结构定义:
pub enum PersonsListMsg {
IdChanged(String),
FindPressed,
PartialNameChanged(String),
FilterPressed,
DeletePressed,
AddPressed,
SelectionToggled(u32),
EditPressed(u32),
}
让我们看看我们在上一段代码中做了什么:
-
当 Id: 文本框中的文本发生变化时,必须发送
IdChanged消息。其参数是字段的新文本值。 -
当点击查找按钮时,必须发送
FindPressed消息。 -
当 Name 部分: 文本框中的文本发生变化时,必须发送
PartialNameChanged消息。其参数是字段的新文本值。 -
当点击过滤器按钮时,必须发送
FilterPressed消息。 -
当点击删除选中人员按钮时,必须发送
DeletePressed消息。 -
当点击添加新人员按钮时,必须发送
AddPressed消息。 -
当列表中的人员复选框被切换(即选中或取消选中)时,必须发送
SelectionToggled消息。其参数是列表中该行指定的人员的 ID。 -
当点击列表中任何编辑按钮时,必须发送
EditPressed消息。其参数是列表中该行指定的人员的 ID。
然后,定义组件初始化参数的结构:
pub struct PersonsListProps {
pub can_write: bool,
pub go_to_one_person_page: Option<Callback<Option<Person>>>,
pub db_connection:
Option<std::rc::Rc<std::cell::RefCell<DbConnection>>>,
}
让我们看看这是如何工作的:
-
使用
can_write字段,主要组件指定了当前用户的权限的简单定义。更复杂的应用程序可能有更复杂的权限定义。 -
使用
go_to_one_person_page字段,主要组件传递一个函数的引用,必须调用该函数才能转到显示、编辑或插入单个人员的页面。 -
使用
db_connection字段,主要组件传递对数据库连接的共享引用。
通过实现 Default 特性初始化 PersonsListProps 结构体,以及通过实现 Component 特性初始化 PersonsListModel 结构体是微不足道的,除了 filtered_persons 字段。它不是将其保留为空向量,而是首先将其设置为空向量,然后通过以下语句进行修改:
model.filtered_persons = model.db_connection.borrow()
.get_persons_by_partial_name("");
为什么空集合对 filtered_persons 来说不是很好
每次从登录页面和 OnePerson 页面打开 PersonsList 页面时,模型都通过 create 函数初始化,并且使用该模型初始化页面的所有用户界面元素。
因此,如果您在 PersonsList 页面上输入某些内容,然后转到另一个页面,然后再返回到 PersonsList 页面,除非您在 create 函数中设置它,否则您输入的所有内容都会被清除。
可能,Id 文本框、名称部分文本框或所选人员被清除的事实并不非常令人烦恼,但人员列表被清除的事实意味着您将获得以下行为:
-
您过滤人员以查看一些列出的人员。
-
您点击一个人员的行中的编辑按钮,以更改该人员的名称,然后转到
OnePerson页面。 -
您更改名称并按下更新按钮,然后返回到
PersonsList页面。 -
您会看到“无人员”文本,而不是人员列表。
您在 OnePerson 页面上再也看不到您刚刚修改的人员了。这很不方便。
为了看到该人员被列出,您需要将 filtered_persons 设置为包含该人员的值。所选择的解决方案是显示数据库中存在的所有人员,这是通过调用 get_persons_by_partial_name("") 函数来执行的。
现在,让我们看看 update 方法如何处理视图的消息。
当接收到 IdChanged 消息时,执行以下语句:
self.id_to_find = id_str.parse::<u32>().ok(),
它试图在模型中存储文本框的值,如果没有可转换的数字,则为 None。
当接收到 FindPressed 消息时,执行以下语句:
match self.id_to_find {
Some(id) => { self.update(PersonsListMsg::EditPressed(id)); }
None => { self.dialog.alert("No id specified."); }
},
如果 Id 文本框包含一个有效的数字,则会递归地发送另一个消息:它是 EditPressed 消息。按下查找按钮必须与按下 Id 文本框中包含相同 ID 的行中的编辑按钮具有相同的行为,因此消息被转发到同一个函数。如果没有在文本字段中输入 ID,则显示一个消息框。
当接收到PartialNameChanged消息时,新部分名称仅保存在模型的name_portion字段中。当接收到FilterPressed消息时,执行以下语句:
self.filtered_persons = self
.db_connection
.borrow()
.get_persons_by_partial_name(&self.name_portion);
数据库连接封装在一个RefCell对象中,该对象进一步封装在一个Rc对象中。在Rc内部的访问是隐式的,但要访问RefCell内部的,则需要调用borrow方法。然后查询数据库以获取包含当前名称部分的所有人的列表。这个列表最终被分配给模型的filtered_persons字段。
当接收到DeletePressed消息时,执行以下语句:
if self
.dialog
.confirm("Do you confirm to delete the selected persons?") {
{
let mut db = self.db_connection.borrow_mut();
for id in &self.selected_ids {
db.delete_by_id(*id);
}
}
self.update(PersonsListMsg::FilterPressed);
self.dialog.alert("Deleted.");
}
显示以下弹出框以进行确认:

如果用户点击 OK 按钮(或按Enter键),则按以下方式执行删除操作:从共享数据库连接中借用一个可变引用,并且对于通过复选框选择的任何 ID,从数据库中删除相应的人员。
范围关闭释放了借用。然后,对update的递归调用触发FilterPressed消息,其目的是刷新显示的人员列表。最后,以下消息框传达了操作完成的信号:

当接收到AddPressed消息时,执行以下代码:
if let Some(ref go_to_page) = self.go_to_one_person_page {
go_to_page.emit(None);
}
在这里,获取go_to_one_person_page回调的引用,然后使用emit方法调用它。这种调用的效果是转到OnePerson页面。emit的参数指定页面上要编辑的人员。如果它是None,如本例中所示,页面将以插入模式打开。
当接收到SelectionToggled消息时,它指定了一个人员的 ID,但没有指定该人员是要选中还是取消选中。因此,执行以下代码:
if self.selected_ids.contains(&id) {
self.selected_ids.remove(&id);
} else {
self.selected_ids.insert(id);
}
我们想要反转用户点击的人员的状态,即如果未选中,则选中它,如果已选中,则取消选中它。模型的selected_ids字段包含所有选中人员的集合。因此,如果点击的 ID 包含在选中 ID 的集合中,则通过调用remove方法从该集合中删除;否则,通过调用insert方法将其添加到列表中。
最后,当接收到EditPressed消息(指定要查看/更改的人员的id)时,执行以下代码:
match self.db_connection.borrow().get_person_by_id(id) {
Some(person) => {
if let Some(ref go_to_page) = self.go_to_one_person_page {
go_to_page.emit(Some(person.clone()));
}
}
None => self.dialog.alert("No person found with the indicated id."),
}
数据库中搜索具有指定 ID 的人员。如果找到这样的人,则调用go_to_one_person_page回调,传递找到的人员的克隆。如果没有找到,则显示一个消息框解释错误。change方法在来自父组件的任何属性发生变化时保持模型字段更新。
然后是视图。当消息被展示时,描述了视图发送的消息。视图的其他有趣方面如下。
“删除所选人员”按钮和“添加新人员”按钮具有属性 disabled=!self.can_write。这仅在用户具有更改数据的权限时启用此类命令。
if !self.filtered_persons.is_empty() 条件语句导致只有在至少有一个人员被过滤时才会显示人员表。否则,将显示文本“无人员”。
表格的主体开始和结束于以下行:
for self.filtered_persons.iter().map(|p| {
let id = p.id;
let name = p.name.clone();
html! {
...
}
})
这是基于迭代器生成 HTML 元素序列所需的语法。
for 关键字紧随迭代器(在这种情况下,表达式 self.filtered_persons.iter()),然后是表达式 .map(|p|,其中 p 是循环变量。通过这种方式,可以将对 html 宏的调用插入到映射闭包中,该宏生成序列的元素。在这种情况下,这些元素是 HTML 表格的行。
最后一个值得注意的点是显示哪些人员被选中的方式。每个复选框都有属性 checked=self.selected_ids.contains(&id),。复选框属性期望一个 bool 值。该表达式将相对于包含在所选 ID 列表中的 id 的人员的复选框设置为选中状态。
one_person.rs 文件
此文件包含组件的定义,允许用户查看或编辑一个人的详细信息,或填写详细信息并插入一个新人员。当然,要查看现有记录的详细信息,必须将这些详细信息作为参数传递给组件;相反,要插入一个新人员,则不需要向组件传递任何数据。
此组件不会直接将其更改返回给创建它的父组件。如果用户请求这样做,则这些更改将保存到数据库中,父组件可以从数据库中检索它们。
因此,模型由以下结构定义:
pub struct OnePersonModel {
id: Option<u32>,
name: String,
can_write: bool,
is_inserting: bool,
go_to_persons_list_page: Option<Callback<()>>,
db_connection: std::rc::Rc<std::cell::RefCell<DbConnection>>,
}
通过前面的代码,我们理解了以下内容:
-
id字段包含 Id 文本框中包含的值,如果框中包含数字,否则为None。 -
name字段包含名称文本框中包含的值。特别是,如果框为空,则模型中的此字段包含一个空字符串。 -
can_write字段指定当前权限是否允许用户更改数据或仅查看数据。 -
is_inserting字段指定此组件是否已收到数据以将新人员插入数据库,或者是否已收到人员的资料以查看或编辑它们。 -
go_to_persons_list_page字段是一个无参数的回调,当用户关闭此页面以转到管理人员名单的页面时,必须由该组件调用。 -
db_connection字段是数据库的共享连接。
当然,在不允许用户更改值的情况下打开插入页面是没有意义的。所以,可能的组合如下:
-
插入模式:
id字段为None,can_write字段为true,is_inserting字段为true。 -
编辑模式:
id字段为Some,can_write字段为true,is_inserting字段为false。 -
只读模式:
id字段为Some,can_write字段为false,is_inserting字段为false。
视图到控制器可能的通告由以下enum定义:
pub enum OnePersonMsg {
NameChanged(String),
SavePressed,
CancelPressed,
}
让我们看看代码中发生了什么:
-
当用户更改名称文本框的内容时,会发送
NameChanged消息,该消息还指定了该文本框的当前内容。 -
当用户点击插入按钮或更新按钮时,会发送
SavePressed消息。为了区分这两个按钮,可以使用is_inserting字段。 -
当用户按下取消按钮时,会发送
CancelPressed消息。
Id 文本框的值在此组件的生命周期中永远不会改变,因此不需要消息。从父组件接收的数据由以下结构定义:
pub struct OnePersonProps {
pub id: Option<u32>,
pub name: String,
pub can_write: bool,
pub go_to_persons_list_page: Option<Callback<()>>,
pub db_connection:
Option<std::rc::Rc<std::cell::RefCell<DbConnection>>>,
}
在前面的代码中,我们需要检查以下内容:
-
如果父组件想要打开页面让用户插入新人员,则
id字段为None,如果页面用于查看或编辑该人员的资料,则包含该人员的 ID。 -
name字段是任何人员的唯一可更改数据。如果页面是为插入新人员而创建的,则为空字符串。否则,父组件传递人员的当前名称。 -
can_write字段指定用户是否允许更改显示的数据。如果id字段为None,则该字段应为true。 -
go_to_persons_list_page是激活父组件中PersonsList组件的回调函数。 -
db_connection字段是共享的数据库连接。
在本模块的其余部分,没有新的内容。唯一需要强调的是,基于模型can_write和is_inserting标志的条件表达式使用,允许只有一个具有突变视图的组件。
访问 RESTful 服务的网络应用
前一节描述了一个相当复杂的软件架构,但仍然只在用户的网页浏览器中运行,在它被安装的网站上提供服务之后。这相当不寻常,因为大多数网络应用实际上是与某些其他进程进行通信。通常,提供前端应用的同一家网站也提供后端服务,即一个网络服务,允许应用访问存储在服务器上的共享数据。
在本节中,我们将看到可以从存储库下载的一对项目:
-
yclient: 这是一个与yauth应用相当相似的应用。实际上,它是使用 Yew 和 Wasm 开发的,并且具有与yauth相同的界面和行为;尽管其数据,即授权用户和存储在模拟数据库中的人员,不再位于应用本身,而是在另一个应用中,通过 HTTP 连接访问。 -
persons_db: 这是为yclient应用提供数据访问的 RESTful 服务。它使用上一章中解释的 Actix web 框架开发。即使这个应用也不管理真实的数据库,而只是一个模拟的内存数据库。
要运行系统,需要两个命令:一个用于运行前端提供者yclient,另一个用于运行网络服务persons_db。
要运行前端提供者,进入yclient文件夹,并输入以下命令:
cargo web start
在下载和编译所有必需的 crate 之后,它将打印以下内容:
You can access the web server at `http://127.0.0.1:8000`.
要运行后端,在另一个控制台窗口中,进入db_persons文件夹,并输入以下命令:
cargo run
或者,我们可以使用以下命令:
cargo run --release
这两个命令都将结束并打印以下内容:
Listening at address 127.0.0.1:8080
现在,您可以使用您的网络浏览器并导航到localhost:8000。将打开的应用将与上一节中展示的yauth应用以及上一章中展示的auth应用相当相似。
让我们先看看persons_db是如何组织的。
persons_db应用
此应用使用上一两章中描述的 Actix web 框架。特别是,此项目从第三章中描述的json_db项目获取了一些功能,该章节是创建 RESTful 网络服务,以及从第四章中描述的auth项目获取的一些功能,该章节是创建完整的后端网络应用。
在这里,我们将只看到迄今为止尚未描述的新功能。Cargo.toml文件包含以下新行:
actix-cors = "0.1"
此 crate 允许处理跨源资源共享(CORS)检查,通常由浏览器执行。当一些在浏览器内部运行的代码试图使用网络连接访问外部资源时,出于安全原因,浏览器会检查所指向的主机是否正是提供执行请求代码的那个网站。这意味着前端和后端实际上是同一个网站。
如果检查失败,即前端应用试图与不同的网站通信,浏览器将使用OPTION方法发送 HTTP 请求,以检查该网站是否同意与该网络应用在此资源共享上合作。只有当OPTION请求的响应允许所需的访问类型时,原始请求才能被转发。
在我们的情况下,前端应用和网络服务都在本地主机上运行;尽管如此,它们使用不同的 TCP 端口:前端使用 8000,后端使用 8080。因此,它们被认为是不同的源,需要处理 CORS。actix-cors 库为使用 Actix web 开发的后端提供了允许此类跨源访问的功能。
其中一个功能在 main 函数中使用,如下代码片段所示:
.wrap(
actix_cors::Cors::new()
.allowed_methods(vec!["GET", "POST", "PUT", "DELETE"])
)
这段代码被称为 中间件,意味着它将为服务接收到的每个请求运行,因此它是一段位于客户端和服务器之间的软件。
wrap 方法是用来添加中间件的,这意味着以下代码必须放在每个处理器周围,可能过滤请求和响应。
这样的代码创建了一个类型为 Cors 的对象,并为其指定了哪些 HTTP 方法将被接受。
对于那些已经学习过 Actix web 框架描述的人来说,这个网络服务的其余部分应该是清晰的。它是一个 RESTful 网络服务,接受 URI 路径和查询作为请求,并以 JSON 主体作为响应返回,并且通过基本认证头在任何请求中提供认证。
API 为 GET 方法添加了新的路由和 /authenticate 路径,它调用 authenticate 处理器,用于获取包含其权限列表的整个用户对象。
现在让我们看看 yclient 是如何组织的。
yclient 应用
此应用从 yauth 应用停止的地方开始。yauth 应用包含自己的内存数据库,而这里描述的应用与 person_db 网络服务通信以访问其数据库。
在这里,我们将只看到与 yauth 项目相关的新功能。
导入的库
Cargo.toml 文件包含新行:
failure = "0.1"
serde = "1"
serde_derive = "1"
url = "1"
base64 = "0.10"
对于前面的代码,让我们看看以下这些内容:
-
failure库用于封装通信错误。 -
serde和serde_derive库用于通过反序列化从服务器传输整个对象到客户端。特别是,Person、User和DbPrivilege类型的整个对象在服务器响应中传输。 -
url库用于在 URL 中编码信息。在 URL 路径或 URL 查询中,你可以轻松地只放置标识符或整数,例如,比如/person/id/478或/persons?ids=1,3,39,但更复杂的数据,如人的名字,不允许直接使用。你不能有包含空白的 URL,如/persons?partial_name=John Doe,因为它包含空格。通常,你必须使用 URL 允许的编码来编码它,这由url::form_urlencoded::byte_serialize调用提供,它获取一个字节数组切片并返回一个生成字符的迭代器。如果你对这个迭代器调用collect::<String>(),你将得到一个可以安全放入网络 URI 的字符串。 -
使用
base64crate 执行将二进制数据编码为文本数据的类似操作,但用于 HTTP 请求的头部或主体。特别是,需要将用户名和密码编码在基本认证头部中。
源文件
源文件名与yauth项目相同,但db_access.rs文件已被重命名为common.rs。实际上,在这个项目中,没有需要访问数据库的代码,因为访问现在只由服务执行。common模块包含了一些组件需要的常量、两个结构体、一个枚举和一个函数的定义。
模型的更改
组件的模型有以下更改。
所有db_connection字段都已移除,因为应用现在不再直接访问数据库。这已成为服务器的责任。
添加了布尔fetching字段。当向服务器发送请求时将其设置为 true,在收到响应或请求失败时重置为 false。在这个应用中它并不是必需的,但在使用较慢的通信(与远程服务器通信)或一些更长的请求时可能很有用。它可以用来向用户显示请求正在进行,同时在此期间禁用其他请求。
添加了fetch_service字段以提供通信功能。ft字段被添加以在请求期间包含对当前FetchTask对象的引用,或者在没有发送请求时为Nothing。这个字段实际上并没有被使用;这只是一个保持当前请求活跃的技巧,因为否则在请求发送后update函数返回,局部变量就会被丢弃。
添加了link字段,用于将回调函数转发到当前模型,该回调函数将在收到响应时被调用。
添加了console字段,以提供一种将信息打印到浏览器控制台的方法,用于调试目的。在 Yew 中,print!和println!宏无效,因为没有系统控制台可以打印。但网络浏览器有一个控制台,可以通过console.log()JavaScript 函数调用访问。这个 Yew 服务提供了访问这种功能的方法。
添加了username和password字段,以便在所有请求中发送认证数据。
但让我们看看由于需要与服务器通信而需要进行的代码更改。
一个典型的客户端/服务器请求
对于在yauth项目中需要访问数据库的任何用户命令,已经移除了这种访问权限,并应用了以下更改。
现在这样的用户命令会向一个网络服务发送请求,然后必须处理来自该服务的响应。在我们的示例中,用户命令和收到服务响应之间的时间相当短——仅仅几毫秒,以下是一些原因:
-
客户端和服务器在相同的计算机上运行,因此 TCP/IP 数据包实际上没有离开计算机。
-
计算机没有其他事情要做。
-
数据库实际上是一个非常短的内存向量,因此其操作非常快。
然而,在实际系统中,处理导致通信的用户命令花费的时间要多得多。如果一切顺利,一个命令只需要半秒钟,但有时可能需要几秒钟。因此,同步通信是不可接受的。您的应用程序不能只是等待服务器的响应,因为这会显得它卡住了。
因此,Yew 框架的 FetchService 对象提供了一个异步通信模型。
由用户命令触发的控制器例程准备发送到服务器的请求,并准备一个回调例程来处理来自服务器的响应,然后发送请求,因此应用程序可以自由处理其他消息。
当来自服务器的响应到达时,响应会触发一个由控制器处理的消息。处理消息会调用预先准备的回调。
因此,除了表示用户命令的消息外,还添加了其他消息。其中一些报告了响应的接收,即请求的成功完成;而另一些报告了来自服务器的请求失败,即请求的不成功完成。例如,在 persons_list.rs 文件中实现的 PersonsListModel 组件中,以下用户操作需要通信:
-
按下查找按钮(触发
FindPressed消息) -
按下筛选按钮(触发
FilterPressed消息) -
按下删除选中人员按钮(触发
DeletePressed消息) -
按下其中一个编辑按钮(触发
EditPressed消息)
对于它们,添加了以下消息:
-
ReadyFilteredPersons(Result<Vec<Person>, Error>): 当从服务接收到筛选人员列表时,FetchService实例会触发此消息。这样的列表包含在Person的Vec中。这可能在处理FilterPressed消息之后发生。 -
ReadyDeletedPersons(Result<u32, Error>): 当服务完成删除一些人员的命令后,FetchService实例会触发此消息。删除的人员数量包含在u32中。这可能在处理DeletePressed消息之后发生。 -
ReadyPersonToEdit(Result<Person, Error>): 这是在从服务接收到请求的Person对象后由FetchService发送的,因此它可以被编辑(或简单地显示)。这可能在处理FindPressed消息或EditPressed消息之后发生。 -
Failure(String): 当服务返回失败响应时,FetchService发送此消息,表示前面的任何请求都失败了。
例如,让我们看看处理 EditPressed 消息的代码。其第一部分如下:
self.fetching = true;
self.console.log(&format!("EditPressed: {:?}.", id));
let callback =
self.link
.send_back(move |response: Response<Json<Result<Person, Error>>>| {
let (meta, Json(data)) = response.into_parts();
if meta.status.is_success() {
PersonsListMsg::ReadyPersonToEdit(data)
} else {
PersonsListMsg::Failure(
"No person found with the indicated id".to_string(),
)
}
});
让我们检查代码的工作原理:
-
首先,将
fetching状态设置为true,以记录通信正在进行中。 -
然后向浏览器的控制台打印一条调试信息。
-
然后,准备一个回调来处理响应。为了准备这样的回调,一个 move 闭包,即获取它所使用所有变量的所有权的闭包,被传递给
link对象的send_back函数。
记住,我们在这里是因为用户按下了按钮来编辑一个通过其 ID 指定的个人;因此,我们需要整个个人数据来向用户显示。
回调体是我们在收到服务器响应后想要执行的代码。这样的响应,如果成功,必须包含我们想要编辑的人的所有相关数据。因此,这个闭包从服务中获取一个 Response 对象。实际上,这个类型是由响应的可能内容参数化的。在这个项目中,我们始终期望一个 yew::format::Json 有效载荷,这样的有效载荷是一个 Result,它总是有 failure::Error 作为其错误类型。尽管如此,成功类型取决于请求类型。在这个特定的请求中,我们期望一个 Person 对象作为成功的结果。
闭包体调用响应上的 into_parts 方法,将响应解构为元数据和数据。元数据是 HTTP 特定的信息,而数据是 JSON 有效载荷。
使用元数据,可以检查响应是否成功(meta.status.is_success())。在这种情况下,将触发 Yew 消息 ReadyPersonToEdit(data);这样的消息将处理响应有效载荷。在发生错误的情况下,将触发 Failure 消息;这样的消息将显示指定的错误信息。
你可能会问:“为什么回调将有效载荷转发给 Yew 框架,指定另一个消息,而不是在收到响应时执行任何应该执行的操作?”
原因是,为了在框架外部执行,回调必须是其创建后访问的任何变量的所有者,即请求发送时,直到其销毁(收到响应时)。因此,它不能使用模型或任何其他外部变量。你甚至不能在这样的回调中在控制台打印或打开一个警告框。因此,你需要异步地将响应转发给消息处理器,该处理器将能够访问模型。
EditPressed 消息处理器的剩余部分如下:
let mut request = Request::get(format!("{}person/id/{}", BACKEND_SITE, id))
.body(Nothing)
.unwrap();
add_auth(&self.username, &self.password, &mut request);
self.ft = Some(self.fetch_service.fetch(request, callback));
首先,使用 get 方法准备一个网络请求,它使用 GET HTTP 方法,并可选地指定一个 body,在这种情况下是空的(Nothing)。
这样的请求通过调用 add_auth 公共函数来丰富认证信息,最后,调用 FetchService 对象的 fetch 方法。此方法使用请求和回调开始与服务器通信。它立即返回一个句柄,存储在模型的 ft 字段中。
然后,控制权返回到 Yew,它可以处理其他消息,直到从服务器收到响应。这样的响应将被转发到之前定义的回调函数。
现在,让我们看看当从服务器接收到人员结构作为响应,以请求通过 id 编辑人员时转发的 ReadyPersonToEdit(person) 消息的处理程序。其代码如下:
self.fetching = false;
let person = person.unwrap_or(Person {
id: 0,
name: "".to_string(),
});
if let Some(ref go_to_page) = self.go_to_one_person_page {
self.console
.log(&format!("ReadyPersonToEdit: {:?}.", person));
go_to_page.emit(Some(person.clone()));
}
首先,将 fetching 状态设置为 false,以记录当前通信已结束。
然后,如果接收到的人员是 None,则将这样一个值替换为具有零 id 和空字符串作为名称的人员。当然,这是一个无效的人员。
然后,获取模型中 go_to_one_person_page 字段的引用。这个字段可以是 None(实际上,仅在初始化阶段),因此,如果没有定义,则不执行任何操作。这个字段是一个 Yew 回调,用于跳转到另一个页面。
最后,打印一条调试信息,并使用 emit 方法调用回调。这个调用接收一个要显示在该页上的人员的副本。
现在,让我们看看当从服务器接收到错误时,转发的 Failure(msg) 消息的处理程序。这个处理程序被其他请求共享,因为它具有相同的行为。其代码如下:
self.fetching = false;
self.console.log(&format!("Failure: {:?}.", msg));
self.dialog.alert(&msg);
return false;
再次,由于通信已结束,将获取状态设置为 false。
打印一条调试信息,并打开一个消息框来显示用户错误信息。只要这样的消息框打开,组件就会冻结,因为无法处理其他消息。
最后,控制器返回 false 以表示不需要刷新任何视图。请注意,默认返回值是 true,因为通常控制器会更改模型,因此视图必须作为结果刷新。
摘要
我们已经看到如何使用 Rust、cargo-web 命令、Wasm 代码生成器和 Yew 框架来构建一个完整的 frontend web 应用。这样的应用是模块化和结构良好的,因为它们使用了 Elm 架构,这是 MVC 架构模式的一个变体。
我们创建了六个应用,并看到了它们的工作方式——incr、adder、login、yauth、persons_db 和 yclient。
尤其是你学习了如何构建和运行一个 Wasm 项目。我们探讨了用于构建交互式应用的 MVC 架构模式。我们介绍了 Yew 框架如何支持实现 MVC 模式的应用创建,特别是根据 Elm 架构。我们还看到了如何将应用结构化为多个组件,以及如何在应用的不同页面之间保持一个共同的头部和脚部,同时应用的主体从页面到页面发生变化。最后,我们学习了如何使用 Yew 与后端应用通信,该应用可能运行在不同的计算机上,并将数据打包成 JSON 格式。
在下一章中,我们将看到如何使用 Wasm 和 Quicksilver 框架构建一个网页游戏。
问题
-
WebAssembly 是什么,它有哪些优势?
-
MVC 模式是什么?
-
Elm 架构中的消息是什么?
-
Yew 框架中的组件是什么?
-
Yew 框架中的属性是什么?
-
你如何使用 Yew 框架构建一个具有固定头部和脚部的网页应用,并更改内部区域?
-
Yew 框架中的回调是什么?
-
你如何在 Yew 组件之间传递共享对象,例如数据库连接?
-
为什么在与服务器通信时,即使你不需要使用它,你也必须在模型中保留一个类型为 FetchTask 的字段?
-
你如何使用 Yew 框架打开 JavaScript 风格的警告框和确认框?
进一步阅读
-
你可以从这里下载 Yew 项目:
github.com/DenisKolodin/yew。仓库包含一个非常简短的教程和许多示例。 -
你可以在以下位置找到有关从 Rust 项目生成 Wasm 代码的其他信息:
github.com/koute/cargo-web。 -
网页开发库和框架的状态:
www.arewewebyet.org/ -
游戏开发库和框架的状态:
arewegameyet.com/ -
程序员编辑器和 IDE 的状态:
areweideyet.com/ -
异步编程库的状态:
areweasyncyet.rs/ -
GUI 开发库和框架的状态:
areweguiyet.com/
使用 Quicksilver 创建 WebAssembly 游戏
在本章中,您将了解如何使用 Rust 构建一个简单的 2D 游戏,该游戏可以编译为桌面应用程序或网络应用程序。要将其作为网络应用程序运行,我们将使用上一章中看到的工具生成一个 WebAssembly (Wasm) 应用程序。正如该章节所示,Wasm 是一种运行在浏览器内部的新技术,它可以将 Rust 源代码转换为伪机器语言,名为 Wasm,浏览器以最高速度加载和运行。
本章将描述并使用 Quicksilver 开源框架。它具有从单个源代码生成以下应用程序的强大功能:
-
一个独立的 图形用户界面 (GUI) 应用程序,可在 Windows、macOS 或 Linux 等桌面系统中运行
-
在启用 JavaScript 的网络浏览器中运行的 Wasm 应用程序
Quicksilver 面向游戏编程,因此,作为一个例子,我们将使用它开发一个交互式图形游戏:一项滑雪回转比赛,玩家必须沿着滑雪道驾驶滑雪板,进入沿途的障碍门。
本章将涵盖以下主题:
-
理解动画循环架构
-
使用 Quicksilver 框架构建一个动画应用程序(
ski) -
使用 Quicksilver 框架(
silent_slalom)构建一个简单的游戏 -
向游戏中添加文本和声音(
assets_slalom)
第七章:技术要求
您需要阅读上一章中关于 Wasm 的部分,但不需要其他知识。要运行本章中的项目,只需安装一个 Wasm 代码生成器即可。
本章的完整源代码位于存储库的 Chapter06 文件夹中,可在github.com/PacktPublishing/Creative-Projects-for-Rust-Programmers 找到。
对于 macOS 用户,您可能难以安装 coreaudio-sys。将 coreaudio-sys 的补丁版本升级到 0.2.3 可以解决这个问题。
项目概述
在本章中,我们将了解如何开发在现代网络浏览器或 GUI 窗口中运行的游戏。
为了这个目的,我们首先将描述基于动画循环概念的任何交互式游戏的典型架构。
然后,我们将介绍 Quicksilver crate。这是一个框架,允许我们基于动画循环创建图形应用程序。它允许我们生成可在网络浏览器中运行的 Wasm 可执行文件,或在桌面环境中运行的本地可执行文件。
第一个项目(ski)将非常简单:仅包含一个可以按箭头键旋转的滑雪板页面。这个项目将展示游戏的一般架构,如何在页面上绘制,以及如何处理输入。
第二个项目(silent_slalom)将为第一个项目添加功能,创建一个完整——尽管非常简单——的游戏。然而,它将不会使用可加载的资源,例如图像、字体或声音。
第三个项目(assets_slalom)将为第二个项目添加功能,加载字体和一些录音声音,并展示如何在页面上显示一些文本,以及如何播放加载的声音文件。
理解动画循环架构
如前一章所述,交互式软件的典型架构是事件驱动架构。在这种架构中,软件只是等待输入命令,当命令到达时,它会对这些命令做出响应。在收到任何命令之前,软件什么都不做。
这种架构对许多类型的应用程序来说既高效又响应迅速,但它对某些其他类型的应用程序来说并不理想,例如以下情况:
-
带有动画的游戏
-
持续模拟软件
-
多媒体软件
-
一些教育软件
-
机器监控软件(通常称为人机界面(HMI)软件)
-
系统监控软件(通常称为监督控制与数据采集(SCADA)软件)
在这样的系统中,软件总是有事情要做,如下面的例子所示:
-
在带有动画的游戏中,例如体育游戏或战斗游戏或赛车游戏,无论是与其他玩家对战还是与机器模拟玩家对战,即使用户没有操作,对手也会移动,时间会流逝;因此,屏幕必须不断更新以显示对手所做的一切,以及当前的时间。
-
在持续模拟软件中,例如汽车碰撞的图形模拟,物体即使在您没有按任何键的情况下也会继续移动;因此,屏幕必须随时显示物体的新位置。
-
在多媒体软件中,例如播放音频或视频剪辑的软件,数据会持续流动,直到您暂停或停止播放。
-
教育软件有很多种,但其中一些只是带有动画的游戏、持续模拟软件或多媒体软件。
-
大多数机械机器为了让用户监控它们,即使在用户没有请求更新时,也会在屏幕上显示其内部状态的持续更新表示。
-
许多复杂的系统,如工业设施、办公楼,以及最近也开始应用于住宅建筑,会在屏幕上显示系统内运行的设备状态的持续更新表示。
实际上,这类软件甚至可以使用事件驱动架构来开发。只需要使用一个称为计时器的特定小部件。计时器是一个软件组件,会在固定的时间间隔触发一个事件。
例如,在电子温度计中,有一个每分钟执行一次例程的计时器。这个例程从传感器读取温度,并在小屏幕上显示读取值。
对于某些类型的应用,使用事件驱动环境,可能包括一个或多个计时器,是合适的。例如,事件驱动编程对于会计应用等商业应用来说是最优的。在这些应用中,用户屏幕被分成几个输入小部件,如标签、按钮和文本框。在这样的软件中,直到用户点击鼠标或按下一个键,才运行应用程序代码。这些输入事件触发动作。
然而,事件驱动编程并不完全适合显示填充窗口的场景,这种软件没有小部件,并且即使在用户没有对输入设备进行操作的情况下,也始终有一些代码在运行。
对于这类软件,所谓的 动画循环架构 更为合适。其最简单的结构如下:
-
首先,绘制例程被定义为负责检查输入设备的状态并根据状态重新绘制屏幕的程序。
-
然后,一个屏幕区域被定义为场景,并为它定义一个更新率。
-
当程序启动时,它首先为场景打开一个窗口(或子窗口),然后使用内部计时器以固定间隔调用绘制例程。
-
这种周期性的绘制例程通常被称为 帧,其调用频率以每秒 帧数(FPS)来衡量。
动画循环有时被称为 游戏循环,因为它经常用于游戏。然而,这个名称并不准确,原因如下:
-
还有其他几种应用应该使用动画循环,例如连续模拟软件、工业机器监控软件或多媒体软件。因此,动画循环不仅限于游戏。
-
有些游戏不需要动画循环。例如,棋盘游戏、纸牌游戏或冒险游戏,只要它们不是基于动画的,就可以使用事件驱动架构完美实现。因此,游戏不一定基于动画循环。
注意,虽然在事件驱动架构中用户输入会触发动作,但在动画循环架构中,某些动作仍然会发生,但如果存在用户输入,这些动作会相应地改变。
考虑一个按下键盘键或鼠标按钮的用户。在事件驱动编程中,这种输入操作发送一条精确的命令。相反,在动画循环编程中,程序在任何一帧都会检查是否有任何键被按下。如果按键时间非常短,那么这种操作可能会被忽略,因为在检查键盘的一个周期中,那个键还没有被按下,而在下一个周期中,那个键已经被释放了。
这相当不寻常。典型的帧率是从 20 到 60 FPS,因此相应的间隔是从 50 到 16.7 毫秒。很难按下比这更短的时间。相反,按键时间通常比帧长得多,因此按键在几个连续帧中被看到按下。
如果你使用这样的按键来插入文本,你希望允许用户按下一个键来插入一个字母。如果你使用鼠标点击来在屏幕上按一个按钮,你希望那个屏幕按钮只按一次。为了避免这种多次点击,你必须在你第一次得到输入时暂时禁用输入。这相当麻烦,因此,对于典型的基于小部件的 GUI 应用程序,事件驱动编程更合适。
相反,当必须使按键的效果与按键持续时间成比例时,动画循环编程是合适的。例如,如果使用箭头键在屏幕上移动一个角色,并且如果你按住右箭头键 1 秒钟,那个角色会移动一段短距离;而如果你按住那个键 2 秒钟,那个角色会移动两倍的距离。一般来说,短按应该改变很少,而长按应该改变很多。
关于输出,当使用事件驱动编程时,操作的效果通常是通过改变小部件的一些属性(例如,在文本框中更改文本内容,或在图片框中加载位图)来显示的。在更改之后,小部件能够在其需要时使用其内部状态自行刷新。触发刷新的事件是小部件包含的屏幕部分的无效化。例如,如果另一个窗口覆盖了我们的窗口,然后它移动开去,我们窗口被发现的区域就无效了,因此它必须刷新。
这种图形被称为保留模式,因为有一个内部数据结构保留在需要刷新屏幕时所需的信息。相反,当使用动画循环编程时,必须在每一帧重新生成所有图像,因此不需要等待特定事件。这种图形被称为即时模式,因为绘图是在需要看到时立即由应用程序代码执行的。
在上一章中,我们看到了对于事件驱动应用程序,模型-视图-控制器(MVC)架构模式允许你给你的代码提供更好的结构。同样,对于动画循环应用程序,也存在一种 MVC 架构模式。
模型是包含所有必须在帧之间持续存在的变量的数据结构。
控制器是一个有输入但没有输出的函数。它检查输入设备的状态(哪些键盘键被按下;哪些鼠标键被按下;鼠标的位置;其他可能的输入通道的值),读取模型的字段,并更新它们。
视图是一个有输出但没有输入的功能。它读取模型的字段并根据读取的值在屏幕上绘制。
这是 Quicksilver 框架实现此模式的方式。
模型是任何数据类型,通常是结构体,必须实现State特质。这样的特质包含以下三个函数:
-
fn new() -> Result<Screen>: 这是创建模型的唯一方法。它将返回一个有效的模型(如果可能的话)或一个错误。 -
fn update(&mut self, window: &mut Window) -> Result<()>: 这是个控制器。它由框架定期调用。window参数允许你获取一些上下文信息。在这个框架中,它是可变的,但在 MVC 模式的正确实现中,它不应该被改变。相反,self——即模型——应该是可变的。 -
fn draw(&mut self, window: &mut Window) -> Result<()>: 这是个视图。它由框架定期调用。self参数允许从模型中获取信息。在这个框架中,它是可变的,但在 MVC 模式的正确实现中,它不应该被改变。相反,window参数——即输出设备——应该是可变的。
现在,让我们使用 Quicksilver 框架检查存储库中的第一个项目。
实施滑雪项目
我们将要看到的第一个项目相当简单。它只是在屏幕上显示一个几何形状,并允许用户使用箭头键旋转它:
- 要将其作为桌面应用程序运行,请进入
ski文件夹,并输入以下命令:
cargo run --release
推荐使用--release参数来优化生成的代码。对于这个简单的例子,这是没有意义的,但在更复杂的例子中,没有指定它生成的代码效率如此低,以至于生成的应用程序运行速度明显减慢。
- 经过几分钟的下载和编译后,以下桌面窗口将出现:

它只是一个 800 x 600 像素的白色矩形,上面有一个小紫色矩形和一个小靛蓝色三角形。它们代表一个尖端的单板滑雪板,位于雪坡上。
-
如果你按下键盘上的左箭头键(←)或右箭头键(→),你会看到滑雪板在其尖端旋转。
-
现在,请使用你的窗口环境中的适当命令关闭此窗口。通常,你会在标题栏中点击一个叉号图标,或者按Alt + F4键组合。
-
现在,让我们看看另一种启动此应用程序的方法。输入以下命令:
cargo web start --release
在上一章中,我们看到了这个命令如何帮助我们创建 Wasm 应用程序,并通过 HTTP 协议启动一个命令行程序来提供服务。
编译结束时,一个服务器程序启动并建议你可以访问应用的地址。在你的浏览器中,你可以输入此地址:localhost:8000。只有现代 64 位浏览器支持 WebGL2。如果在你这里不是这样,那么什么都不会发生;相反,如果你的浏览器支持这个标准,你将在浏览器中看到之前在桌面窗口中显示的相同图形。
这是可能的,因为我们的应用程序使用的 Quicksilver 框架具有多目标能力。当编译为 Wasm 目标时,它生成一个网页浏览器应用程序;当编译为 中央处理器(CPU)目标时,它生成一个桌面应用程序。
这种编译时便携性对于调试目的非常有用。实际上,调试 Wasm 应用程序并不容易;但如果你首先调试桌面应用程序,Wasm 版本中会剩下一些错误。
理解此代码背后的含义
现在,让我们看看创建此类项目所使用的代码。
在开始项目之前,需要对此进行说明。本章中的所有项目都展示了一个滑雪板在滑雪道上的场景。关于滑雪板和其他对象的坐标有一个约定:水平坐标,通常称为 X,实际上称为 across;而垂直坐标,通常称为 Y,实际上称为 along。
因此,横向速度 是从左到右(或反之,如果为负)移动的速度,而 纵向速度 是从底部到顶部(或反之,如果为负)移动的速度。
首先,Cargo.toml 文件必须包含 quicksilver = "0.3" 依赖项。然后,只有一个 main.rs 源文件。它包含一些常量,如下代码片段所示:
const SCREEN_WIDTH: f32 = 800.;
const SCREEN_HEIGHT: f32 = 600.;
const SKI_WIDTH: f32 = 10.;
const SKI_LENGTH: f32 = 50.;
const SKI_TIP_LEN: f32 = 20.;
const STEERING_SPEED: f32 = 3.5;
const MAX_ANGLE: f32 = 75.;
让我们看看这段代码中的术语意味着什么,如下所示:
-
SCREEN_WIDTH和SCREEN_HEIGHT是桌面窗口中的客户端区域或网页中画布的大小(以像素为单位)。 -
SKI_WIDTH、SKI_LENGTH和SKI_TIP_LEN是滑雪板的大小。 -
STEERING_SPEED是滑雪板每次步骤旋转的度数。步骤有一个频率(即每秒 25 次),因此这个常量代表角速度(每步 3.5 度 * 每秒 25 步 = 每秒 87.5 度)。 -
MAX_ANGLE是旋转能力的限制,无论是向右还是向左,以确保滑雪板始终向下。
然后,是我们的 MVC 架构模型,如下代码片段所示:
struct Screen {
ski_across_offset: f32,
direction: f32,
}
这些字段的意义如下:
-
ski_across_offset代表滑雪板尖端相对于屏幕中心的横向位移。实际上,在这个项目中,它始终为零,因为滑雪板的尖端从不移动。它是一个变量,因为在未来项目中,它将发生变化。 -
direction是滑雪板相对于下山方向的度数。它最初为零,但可以从 -75 到 +75 变化。它是我们模型中唯一可以改变的部分。
模型的构造函数相当简单,如下代码片段所示:
Ok(Screen {
ski_across_offset: 0.,
direction: 0.,
})
该模型简单地初始化了模型的两个字段为零。控制器的主体(update函数)是用以下代码创建的:
if window.keyboard()[Key::Right].is_down() {
self.steer(1.);
}
if window.keyboard()[Key::Left].is_down() {
self.steer(-1.);
}
Ok(())
此例程的目的是在按下右箭头键时将滑雪板稍微向右引导,如果按下左箭头键则稍微向左引导。
window.keyboard()表达式获取与当前窗口关联的键盘的引用,然后[Key::Right]表达式获取该键盘的右箭头键的引用。is_down函数在指定键在此瞬间处于按下状态时返回true。
导航是通过steer方法执行的,其主体由以下代码组成:
self.direction += STEERING_SPEED * side;
if self.direction > MAX_ANGLE {
self.direction = MAX_ANGLE;
}
else if self.direction < -MAX_ANGLE {
self.direction = -MAX_ANGLE;
}
首先,模型direction字段的值通过STEERING_SPEED常量递增或递减。然后,确保新值不超过设计限制。
视图更复杂。即使场景完全没有变化,也必须重新绘制整个场景。第一次绘制操作始终是绘制白色背景,如下所示:
window.clear(Color::WHITE)?;
然后,绘制矩形,如下所示:
window.draw_ex(&Rectangle::new((
SCREEN_WIDTH / 2\. + self.ski_across_offset - SKI_WIDTH / 2.,
SCREEN_HEIGHT * 15\. / 16\. - SKI_LENGTH / 2.),
(SKI_WIDTH, SKI_LENGTH)),
Background::Col(Color::PURPLE),
Transform::translate(Vector::new(0, - SKI_LENGTH / 2\. - SKI_TIP_LEN)) *
Transform::rotate(self.direction) *
Transform::translate(Vector::new(0, SKI_LENGTH / 2\.
+ SKI_TIP_LEN)),
0);
draw_ex方法用于绘制形状。它的第一个参数是要绘制的形状的引用;在这种情况下,它是Rectangle。它的第二个参数,在第五行,是形状的背景颜色;在这种情况下,它是紫色。它的第三个参数是一个平面仿射变换矩阵;在这种情况下,它是一个平移,然后是旋转,然后是平移。它的第四个参数,在最后一行,是一个Z高度;其目的是为形状提供重叠顺序。让我们更详细地检查这些参数。
Rectangle::new方法接收两个参数。第一个参数是由矩形左上角顶点的x和y坐标组成的元组。第二个参数是由矩形的宽度和高度组成的元组。坐标系的原点是窗口的左上角,x坐标向右增长,y坐标向下增长。
在这些公式中,唯一的变量是self.ski_across_offset,它表示当为正时,滑雪板相对于窗口中心的位移向右,当为负时向左。在这个项目中,它始终为零,因此滑雪板的x坐标始终位于窗口中心。垂直位置是使得矩形的中心接近窗口底部,大约是窗口高度的 15/16。
矩形总是与其边平行于窗口的边创建。为了有一个旋转角度,必须应用几何变换。有几种基本的变换可以通过乘法组合。要绘制一个在平移位置上的形状,使用 Transform::translate 方法创建一个变换,该方法接收一个 Vector(不是 Vec!)指定沿 x 和 y 方向的位移。要绘制一个在旋转位置上的形状,使用 Transform::rotate 方法创建一个变换,该方法接收一个角度(以度为单位),指定旋转形状的角度。
旋转是围绕形状的质心进行的,但我们想围绕滑雪板的尖端旋转。因此,我们需要首先平移矩形,使其质心位于滑雪板的尖端处,然后围绕其质心旋转,最后将其平移回原始质心。通过乘以这三个变换,我们得到了围绕滑雪板尖端的旋转。在矩形的例子中,质心就是矩形的中心。
draw_ex 的最后一个参数是一个 z 坐标。这是一个二维框架,因此不需要 z 坐标,但这个坐标允许我们指定形状出现的顺序。实际上,如果有两个形状重叠,并且它们具有相同的 z 坐标,Quicksilver(使用 WebGL)并不一定按照你绘制的顺序绘制它们。实际的顺序是未定义的。为了指定一个形状必须出现在另一个形状之上,它必须具有更大的 z 坐标。它的大小并不重要。
要在矩形上方绘制三角形尖端,执行一个类似的语句。Triangle::new 方法创建一个 Triangle 形状,使用三个 Vector 变量作为其顶点。为了使其围绕尖端旋转,我们需要知道三角形的质心。通过一点几何知识,你可以计算出该三角形的质心位于三角形底边中心上方,距离等于三角形高度的 1/3。
到程序结束时,有一个必须初始化应用的 main 函数。函数的主体包含以下内容:
run::<Screen>("Ski",
Vector::new(SCREEN_WIDTH, SCREEN_HEIGHT), Settings {
draw_rate: 40.,
update_rate: 40.,
..Settings::default()
}
);
这个语句只是运行模型,并带有一些参数。第一个参数是标题栏的标题,第二个参数是窗口的大小,第三个参数是一个包含一些可选设置的结构的实例。
下面两个设置在这里指定:
-
draw_rate:这是draw函数连续调用之间的时间间隔(以毫秒为单位) -
update_rate:这是update函数连续调用之间的时间间隔(以毫秒为单位)
这个项目相当简单,但它展示了将在本章其他项目中使用的许多概念。
实现 silent_slalom 项目
之前的项目只显示了一个滑雪场上的滑雪板。在本节中,我们将展示一个可能有趣的游戏,使用滑雪板进行障碍滑雪。为了简单起见,在这个项目中没有显示文本,也没有播放音效。它的源代码包含在 silent_slalom 文件夹中。
编译并运行其桌面版本后,将出现一个类似于这样的窗口:

除了滑雪板,还画了一些蓝色的小点。窗口中间有四个点,顶部边界上有两个半点。每一对蓝色点是一个障碍门的柱子。游戏的目的是通过每个障碍门。现在,你可以看到只有三个门,但赛道包含七个中间门,加上结束门。剩下的五个门将在滑雪板沿着斜坡前进时出现。
在你的情况下,柱子的实际位置将不同,因为它们的水平(横跨)位置是随机生成的。如果你停止并重新启动程序,你会看到其他柱子的位置。尽管如此,门的尺寸——即任何门的两柱之间的距离——保持不变;此外,任何门与其后门之间的 y 坐标距离也是恒定的。
要开始游戏,请按空格键。蓝色的小点将开始缓慢向下移动,给人一种滑雪板向前滑行的印象。通过旋转滑雪板,你可以改变它的方向,你应该尝试确保滑雪板的尖端通过每个门的柱子之间。
结束门通过绿色柱子而不是蓝色柱子来区分。如果你通过了它,游戏结束,显示一个类似于这样的窗口:

你可以通过按 R 键重新启动游戏。如果你未能正确通过一个门,游戏将停止并结束。你可以通过按 R 键重新启动它。
当然,这个项目与之前的项目有一些共同之处。让我们看看其中的不同之处。
第一个不同之处在于在 Cargo.toml 文件中插入了 rand = "0.6" 依赖项。门被定位在随机的 x 位置,因此需要这个包中包含的随机数生成器。
然后,定义以下常量:
const N_GATES_IN_SCREEN: usize = 3;
const GATE_POLE_RADIUS: f32 = 4.;
const GATE_WIDTH: f32 = 150.;
const SKI_MARGIN: f32 = 12.;
const ALONG_ACCELERATION: f32 = 0.06;
const DRAG_FACTOR: f32 = 0.02;
const TOTAL_N_GATES: usize = 8;
让我们详细看看这些常量,如下所示:
-
N_GATES_IN_SCREEN是一次在窗口中出现的门的数量。连续门之间的距离是窗口高度除以这个数字。因此,这个数字必须是正数。 -
GATE_POLE_RADIUS是用来表示柱子的每个圆的像素半径。 -
GATE_WIDTH是每个门中柱子中心之间的像素距离。这个数字必须是正数。 -
SKI_MARGIN是滑雪板尖端能够到达的最左侧位置到窗口左边的像素距离,以及滑雪板尖端能够到达的最右侧位置到窗口右边的像素距离。 -
ALONG_ACCELERATION是滑雪板由于斜坡而移动的加速度,以每帧像素为单位,对于每一帧,当滑雪板处于下山位置——即垂直时。例如,对于加速度值为 0.06 和更新率为 40 毫秒,或每秒 25 帧,一秒钟内速度将从零增加到 0.06 * 25 = 1.5 像素每帧——即速度为 1.5 * 25 = 37.5 像素每秒。如果滑雪板相对于斜坡有倾斜,实际加速度将更低。 -
DRAG_FACTOR代表由空气摩擦引起的减速。实际减速是此因子乘以速度的模量。 -
TOTAL_N_GATES是门的总数,包括终点门。
在上一个项目中,你一直只能做一件事——那就是旋转滑雪板——在这个项目中,你可以根据当前情况做不同的事情。因此,需要区分四种可能的状态,如下所示:
enum Mode {
Ready,
Running,
Finished,
Failed,
}
初始模式是 Ready,当你渴望开始滑行,位于斜坡顶部时。在发出 start 命令后,你处于 Running 模式,直到你正确完成滑行,结束于 Finished 模式,或者从门中出来,结束于 Failed 模式。
已经向应用程序的模型中添加了一些字段,以跟踪一些其他状态信息,如下面的代码块所示:
gates: Vec<(f32, f32)>,
forward_speed: f32,
gates_along_offset: f32,
mode: Mode,
entered_gate: bool,
disappeared_gates: usize,
这些字段的意义如下所述:
-
gates是杆的沿位置列表。对于它们,原点是窗口的中心。 -
forward_speed是每帧像素速度的模量。 -
gates_along_offset是所有显示的门的 Y 方向平移,这代表着滑雪板的前进。它是一个介于零和连续门之间沿间距之间的数字。 -
mode是之前描述的状态。 -
entered_gate指示滑雪板的尖端是否已经进入窗口中显示的最低门。这个标志被初始化为false;当滑雪板正确通过一个门时,它变为true,当那个门从底部退出窗口时再次变为false,因为现在它指的是下一个门。 -
disappeared_gates计算从窗口中退出的门。当然,它被初始化为零,每次门退出窗口时都会增加。
添加到 Screen 类的一个函数生成一个随机门,如下面的代码块所示:
fn get_random_gate(gate_is_at_right: bool) -> (f32, f32) {
let mut rng = thread_rng();
let pole_pos = rng.gen_range(-GATE_WIDTH / 2., SCREEN_WIDTH / 2\. -
GATE_WIDTH * 1.5);
if gate_is_at_right {
(pole_pos, pole_pos + GATE_WIDTH)
} else {
(-pole_pos - GATE_WIDTH, -pole_pos)
}
}
此函数接收gate_is_at_right标志,该标志指示生成的门将在斜率的哪个部分。如果此参数为true,则新门将位于窗口中心的右侧;否则,它将位于窗口中心的左侧。此函数创建一个随机数生成器,并使用它生成一个合理的杆位置。另一个杆位置是通过使用函数参数和固定的门大小(GATE_WIDTH)计算得出的。
另一个实用函数是deg_to_rad,它将角度从度转换为弧度。这是必需的,因为 Quicksilver 使用度,但三角函数使用弧度。new方法创建所有门,交替在右侧和左侧,并初始化模型。update函数所做的比之前项目中看到的名字相同的函数要多得多。让我们看看下面的代码片段:
match self.mode {
Mode::Ready => {
if window.keyboard()[Key::Space].is_down() {
self.mode = Mode::Running;
}
}
根据当前模式,执行不同的操作。如果模式是Ready,则检查空格键是否被按下,如果是这样,则将当前模式设置为Running。这意味着它开始比赛。如果模式是Running,则执行以下代码:
Mode::Running => {
let angle = deg_to_rad(self.direction);
self.forward_speed +=
ALONG_ACCELERATION * angle.cos() - DRAG_FACTOR
* self.forward_speed;
let along_speed = self.forward_speed * angle.cos();
self.ski_across_offset += self.forward_speed * angle.sin();
在此模式下,计算了很多东西。首先,将滑雪方向从度转换为弧度。
然后,由于斜率,前进速度增加,由于空气阻力(与速度本身成比例)而减少。净效应是速度将趋向于最大值。此外,滑雪方向相对于斜率的旋转越多,它就越慢。这种效果是通过使用cos余弦三角函数实现的。
然后,将前进速度分解为其分量:沿速度,它导致极点的向下移动,以及横速度,它增加横滑雪偏移量。它们分别通过应用cos和sin三角函数来计算前进速度,如下面的代码片段所示:
if self.ski_across_offset < -SCREEN_WIDTH / 2\. + SKI_MARGIN {
self.ski_across_offset = -SCREEN_WIDTH / 2\. + SKI_MARGIN;
}
if self.ski_across_offset > SCREEN_WIDTH / 2\. - SKI_MARGIN {
self.ski_across_offset = SCREEN_WIDTH / 2\. - SKI_MARGIN;
}
然后,它检查滑雪位置是否没有太偏向左边或右边,如果是这样,它将被保持在定义的边框内,如下面的代码片段所示:
self.gates_along_offset += along_speed;
let max_gates_along_offset = SCREEN_HEIGHT / N_GATES_IN_SCREEN as f32;
if self.gates_along_offset > max_gates_along_offset {
self.gates_along_offset -= max_gates_along_offset;
self.disappeared_gates += 1;
}
新的沿速度用于向下移动门,通过增加gates_along_offset字段。如果其新值大于连续门之间的距离,则从窗口底部移除一个门,所有门向后移动一步,消失的门数增加,如下面的代码片段所示:
let ski_tip_along = SCREEN_HEIGHT * 15\. / 16\. - SKI_LENGTH / 2\. - SKI_TIP_LEN;
let ski_tip_across = SCREEN_WIDTH / 2\. + self.ski_across_offset;
let n_next_gate = self.disappeared_gates;
let next_gate = &self.gates[n_next_gate];
let left_pole_offset = SCREEN_WIDTH / 2\. + next_gate.0 + GATE_POLE_RADIUS;
let right_pole_offset = SCREEN_WIDTH / 2\. + next_gate.1 - GATE_POLE_RADIUS;
let next_gate_along = self.gates_along_offset + SCREEN_HEIGHT
- SCREEN_HEIGHT / N_GATES_IN_SCREEN as f32;
然后,计算滑雪尖端的两个坐标:ski_tip_along是常数 y 坐标,从窗口顶部开始,而ski_tip_across是变量 x 坐标,从窗口中心开始。
然后,计算下一个门内的位置:left_pole_offset是左侧极点的x位置,而right_pole_offset是右侧极点的x位置。这些坐标是从窗口的左侧边框计算的。然后,next_gate_along是这些点的y位置,如下面的代码片段所示:
if ski_tip_along <= next_gate_along {
if !self.entered_gate {
if ski_tip_across < left_pole_offset ||
ski_tip_across > right_pole_offset {
self.mode = Mode::Failed;
} else if self.disappeared_gates == TOTAL_N_GATES - 1 {
self.mode = Mode::Finished;
}
self.entered_gate = true;
}
} else {
self.entered_gate = false;
}
如果滑雪板尖端(ski_tip_along)的y坐标小于门的坐标(next_gate_along),那么我们可以说滑雪板的尖端已经通过了下一个门。尽管如此,如果记录这种通过的entered_gate字段仍然是false,那么我们可以说明在上一帧滑雪板还没有通过门。因此,在这种情况下,我们处于滑雪板刚刚通过门的情形。所以,我们必须检查门是否正确或错误地通过了。
如果滑雪板尖端的x坐标不在两个极点坐标之间,那么我们就处于门外,因此进入Failed模式。否则,我们必须检查这个门是否是课程的最后一个门——即终点门。如果是这种情况,我们进入Finish模式;否则,我们记录我们已经进入了门,以避免在下一帧再次检查它,比赛继续进行。
如果y坐标表明我们还没有到达下一个门,我们记录entered_gate仍然是false。有了这个,我们就完成了Running情况下的计算。
还剩下两种模式需要考虑,如下面的代码片段所示:
Mode::Failed | Mode::Finished => {
if window.keyboard()[Key::R].is_down() {
*self = Screen::new().unwrap();
}
}
在Failed模式和Finished模式下,都会检查R键。如果按下,则重新初始化模型,回到游戏启动时的相同状态。
最后,检查转向键是否处于任何模式,就像在之前的项目中一样。关于draw函数,与之前的项目相比,本项目新增的功能是绘制极点。代码可以在以下代码片段中查看:
for i_gate in self.disappeared_gates..self.disappeared_gates + N_GATES_IN_SCREEN {
if i_gate >= TOTAL_N_GATES {
break;
}
一个循环扫描窗口中出现的门。门的索引从零到TOTAL_N_GATES,但我们必须跳过那些已经从底部消失的门,其数量为self.disappeared_gates。我们必须显示至少N_GATES_IN_SCREEN个门,并且必须停在最后一个门上。
为了向玩家显示哪个是终点门,它有不同的颜色,如下面的代码片段所示:
let pole_color = Background::Col(if i_gate == TOTAL_N_GATES - 1 {
Color::GREEN
} else {
Color::BLUE
});
最后一个门是绿色的。为了计算门的极点的y坐标,使用以下公式:
let gates_along_pos = self.gates_along_offset
+ SCREEN_HEIGHT / N_GATES_IN_SCREEN as f32
* (self.disappeared_gates + N_GATES_IN_SCREEN - 1 - i_gate) as f32;
它将两个连续门之间的滑雪板位置(gates_along_offset)添加到前三个门的初始位置。
然后,为每个门绘制两个小圆圈。左边的圆圈是通过执行以下语句绘制的:
window.draw(
&Circle::new(
(SCREEN_WIDTH / 2\. + gate.0, gates_along_pos),
GATE_POLE_RADIUS,
),
pole_color,
);
Circle构造函数的参数是一个由中心点的x和y坐标以及半径组成的元组。在这里,使用的是窗口对象的draw方法,而不是draw_ex方法。它更简单,因为它不需要变换也不需要z坐标。
因此,我们已经检查了这个项目的所有代码。在下一个项目中,我们将展示如何将文字和声音添加到我们的游戏中。
实现assets_slalom项目
前一个构建的项目是一个有效的回转滑雪比赛,但那个游戏没有声音或文字来解释正在发生的事情。这个项目包含在assets_slalom文件夹中,只是为前一个项目的游戏添加了声音和文字。
这里是比赛期间拍摄的一张截图:

在窗口的左上角,有如下信息:
-
已过时间:这告诉我们从当前比赛开始以来有多少秒或百秒已经过去。
-
速度:这告诉我们当前每秒向前移动多少像素。
-
剩余门数:这告诉我们还有多少门需要通过。
然后,一条帮助信息解释了哪些命令可用。
此外,还增加了四个声音,如下所示:
-
比赛任何开始时的滴答声
-
比赛任何转弯时的呼啸声
-
比赛任何失败时的碰撞声
-
比赛任何结束时的铃声
您必须运行游戏才能听到它们。请注意,并非所有网络浏览器都能同样有效地播放声音。
现在,让我们看看 Quicksilver 如何显示文字和播放声音。由于声音和文字需要文件,它们的使用并不简单;对于文字,需要一个或多个字体文件;对于声音,需要一个声音效果文件。这些文件必须存储在项目根目录下的名为static的文件夹中。如果您查看该文件夹,您将找到以下文件:
-
font.ttf:这是一个 TrueType 格式的字体。 -
click.ogg:这是一个短促的点击声音,将在比赛开始时播放。 -
whoosh.ogg:这是一个短促的摩擦声音,将在滑雪板在比赛中转弯时播放。 -
bump.ogg:这是一个表示不满的碰撞声音,将在滑雪板错过门时播放。 -
two_notes.ogg:这是一对音符,表示满意,将在滑雪板通过终点门时播放。
这样的static文件夹及其包含的文件必须与可执行程序一起部署,因为它们在程序运行时被程序加载。它们通常也被称为assets,因为它们只是数据,而不是可执行代码。
Quicksilver 选择以异步方式加载这些资源,使用future概念。要从文件中加载声音,使用Sound::load(«filename»)表达式。它接收一个实现路径引用的值,例如一个字符串,并返回一个实现Future特质的对象。
通过 Asset::new(«future value») 表达式创建了一个资产——即封装正在加载文件的未来的对象。它接收一个实现未来的值,并返回特定类型的 Asset 实例。例如,Asset::new(Sound::load("bump.ogg")) 表达式返回一个 Asset<Sound> 类型的值。这样的值是一个封装未来的资产——即从 bump.ogg 文件中读取声音。在这个项目中,声音是 .ogg 格式,但 Quicksilver 能够读取多种音频格式。
一旦你有一个封装未来加载文件的资产,你可以在表达式 sound_future.execute(|sound_resource| sound_resource.play()) 中访问这样的文件。在这里,sound_future 变量是我们的资产。由于它是一个未来,你必须等待它准备好。这是通过 Asset 类型的 execute 方法完成的。它调用作为参数接收的闭包,并将封装的资源传递给它,在这种情况下是 Sound 类型。
Sound 类型有 play 方法,它开始播放声音。在多媒体系统中,这种播放通常是异步的:你不必等待声音播放完毕就可以继续游戏。如果你在先前的声音仍在播放时调用 play,两个声音会重叠,如果你播放很多声音,结果音量通常会非常高。因此,你应该让你的声音非常短,或者很少播放。
类似地,Asset::new(Font::load("font.ttf")) 表达式返回一个 Asset<Font> 类型的值。这样的值是一个封装未来的资产——即从 font.ttf 文件中读取字体。你可以使用这个字体,如下所示 font_future.execute(|font_resource| image = font_resource.render(&"Hello", &style))。在这里,font_future 变量是我们的资产。由于它是一个未来,你必须使用 Asset 类型的 execute 方法等待它,该方法调用作为参数接收的闭包,并将封装的资源传递给它,在这种情况下是 Font 类型。
Font 类型有 render 方法,它接收一个字符串和一个 FontStyle 值的引用,并创建一个包含该文本的图像,使用该字体和字体样式打印。
分析代码
现在,让我们看看与上一个项目不同的所有项目代码。如下代码片段所示,有一个新的常量:
const MIN_TIME_DURATION: f64 = 0.1;
这是为了解决以下问题。如果游戏帧率为 50 FPS,窗口每秒重绘 50 次,并且每次使用变量的最新值。关于时间,它是一个变化如此之快的数字,以至于无法读取。因此,这个常量设置了显示时间的最大变化率。
模型有几个新的字段,如下代码片段所示:
elapsed_sec: f64,
elapsed_shown_sec: f64,
font_style: FontStyle,
font: Asset<Font>,
whoosh_sound: Asset<Sound>,
bump_sound: Asset<Sound>,
click_sound: Asset<Sound>,
two_notes_sound: Asset<Sound>,
这些字段的意义如下所述:
-
elapsed_sec是从当前比赛开始以来经过的秒数的分数,使用可用的最大分辨率。 -
elapsed_shown_sec是显示给用户的分数,表示从当前比赛开始以来经过的秒数。 -
font_style包含要打印的文本的大小和颜色。 -
font是用于打印屏幕文本的字体预期值。 -
whoosh_sound是在滑雪运行过程中要播放的声音的预期值。 -
bump_sound是在错过一个门时要播放的声音的预期值。 -
click_sound是在比赛开始时要播放的声音的预期值。 -
two_notes_sound是在通过终点门时要播放的声音的预期值。
已经定义了一个播放声音的例程,如下所示:
fn play_sound(sound: &mut Asset<Sound>, volume: f32) {
let _ = sound.execute(|sound| {
sound.set_volume(volume);
let _ = sound.play();
Ok(())
});
}
它接收一个声音的预期值和一个音量。它调用 execute 方法以确保声音已加载,然后设置指定的音量并播放该声音。请注意,execute 方法返回一个 Result,以允许可能的错误。由于在游戏中声音不是必需的,我们希望忽略有关声音的可能的错误,因此,我们总是返回 Ok(())。
在 steer 函数中,当执行转向操作且滑雪板尚未达到极端角度时,执行以下语句:
play_sound(&mut self.whoosh_sound, self.forward_speed * 0.1);
它播放“呼啸”声音和与滑雪速度成比例的音量。这样,如果你在未运行时旋转滑雪板,你将是安静的。
模型的新字段初始化如下:
elapsed_sec: 0.,
elapsed_shown_sec: 0.,
font_style: FontStyle::new(16.0, Color::BLACK),
font: Asset::new(Font::load("font.ttf")),
whoosh_sound: Asset::new(Sound::load("whoosh.ogg")),
bump_sound: Asset::new(Sound::load("bump.ogg")),
click_sound: Asset::new(Sound::load("click.ogg")),
two_notes_sound: Asset::new(Sound::load("two_notes.ogg")),
注意,font_style 设置了 16 点的大小和黑色颜色。我们已描述了其他类型的表达式。
在 update 函数中,当通过按空格键开始比赛时,执行以下语句:
play_sound(&mut self.click_sound, 1.)
它以正常音量播放点击声音。当运行时,经过时间的计算方式如下:
self.elapsed_sec += window.update_rate() / 1000.;
if self.elapsed_sec - self.elapsed_shown_sec >= MIN_TIME_DURATION {
self.elapsed_shown_sec = self.elapsed_sec;
}
update_rate 函数实际上返回帧之间的时间,以毫秒为单位。因此,如果你将其除以 1,000,你将得到每帧之间的秒数。
如果帧率很高,例如每秒 25 帧或更多,在任何帧显示不同的文本可能会令人困惑,因为人们无法阅读变化如此之快的文本。因此,前一个代码片段中的第二个语句展示了更新文本的较低速率的技术。elapsed_shown_sec 字段保持上次更新的时间,而 elapsed_sec 字段保持当前时间。
MIN_TIME_DURATION 常量用于保持文本在屏幕上必须保持不变的最小时长,以便更新。因此,如果从上次更新时间到当前时间的经过时间大于这个最小时长,文本就可以更新。在这个特定情况下,要更新的文本只是经过的秒数,所以,如果时间足够长,elapsed_shown_sec 字段就会被设置为当前时间。draw 例程将使用这个值在屏幕上打印经过的时间。
发出了另外两个声音。当mode变为Failed时,调用play_sound来播放碰撞声音。当mode变为Finished时,调用play_sound来播放铃声。
然后,绘制例程负责打印所有文本。首先,文本被格式化为一个新的多行字符串,如下所示:
let elapsed_shown_text = format!(
"Elapsed time: {:.2} s,\n\
Speed: {:.2} pixel/s,\n\
Remaining gates: {}\n\
Use Left and Right arrow keys to change direction.\n\
{}",
self.elapsed_shown_sec,
self.forward_speed * 1000f32 / window.update_rate() as f32,
TOTAL_N_GATES - self.disappeared_gates - if self.entered_gate { 1 } else { 0 },
match self.mode {
Mode::Ready => "Press Space to start.",
Mode::Running => "",
Mode::Finished => "Finished: Press R to reset.",
Mode::Failed => "Failed: Press R to reset.",
}
);
使用两位小数打印已过时间以及速度;剩余的关卡通过从总关卡数中减去消失的关卡来计算。此外,如果当前关卡已被进入,剩余关卡的数量将减一。然后,根据当前模式打印一些不同的单词。
准备好多行字符串后,该字符串被打印到一个新的图像上,并存储在image局部变量中,然后使用draw方法将图像绘制到窗口上,作为一个纹理背景。该方法接收的第一个参数是要打印的矩形区域,大小与整个位图相同,第二个参数是使用图像构建的Background类型的Img变体,如以下代码片段所示:
let style = self.font_style;
self.font.execute(|font| {
let image = font.render(&elapsed_shown_text, &style).unwrap();
window.draw(&image.area(), Img(&image));
Ok(())
})?;
因此,我们已经完成了对这个简单但有趣的框架的考察。
摘要
我们已经看到,一个完整的游戏,既可以在桌面运行,也可以在网络上运行,可以使用 Rust 和 Quicksilver 框架构建,网络版本使用cargo-web命令和 Wasm 代码生成器。这个游戏是根据动画循环架构和 MVC 架构模式构建的。我们创建了三个应用程序——ski、silent_slalom和assets_slalom——并理解了它们的实现。
在下一章中,我们将介绍另一个面向桌面应用的 2D 游戏框架,即ggez框架。
问题
-
动画循环是什么,它与事件驱动架构相比有哪些优势?
-
何时事件驱动架构比动画循环架构更好?
-
哪些类型的软件可以使用动画循环?
-
你如何使用 Quicksilver 绘制三角形、矩形和圆形?
-
你如何使用 Quicksilver 接收键盘输入?
-
MVC 中的控制器和视图是如何在 Quicksilver 中实现的?
-
你如何使用 Quicksilver 来调整动画的帧率?
-
你如何使用 Quicksilver 从文件中加载资源,并且这些资源应该保存在哪里?
-
你如何使用 Quicksilver 播放声音?
-
你如何使用 Quicksilver 在屏幕上绘制文本?
进一步阅读
你可以从这里下载 Quicksilver 项目:github.com/ryanisaacg/quicksilver。此存储库包含一个指向非常简短的教程和一些示例的链接。
你可以在github.com/koute/cargo-web找到有关从 Rust 项目生成 Wasm 代码的更多信息。
使用 ggez 创建桌面二维游戏
在前一章中,我们看到了如何使用 quicksilver 框架从单一源代码集构建基于动画循环架构的交互式软件(通常是桌面或网页浏览器中的动画游戏)。这种方法的一个缺点是,许多在桌面上可用的输入/输出函数在网页浏览器中不可用,因此网页浏览器框架不一定提供与桌面平台提供的桌面应用程序一样多的功能,例如文件存储。
此外,在使用动画循环架构时,获取离散输入(如鼠标点击、键入字母或数字)相当尴尬。为此,事件驱动架构更为合适。
在本章中,将介绍另一个应用程序框架——ggez 框架。该框架处理动画循环和离散事件,但在编写本书时,它仅支持二维桌面应用程序。
在前一章中,我们了解到为了计算各种图形对象的位置和方向,需要一些解析几何和三角学的知识。对于更复杂的应用,这些数学计算可能会变得令人难以承受。为了简化代码,将位置封装在点对象中,将平移封装在向量对象中是有用的,因此在本章中,我们将探讨如何执行这些封装。nalgebra 数学库帮助我们完成这项工作,也将在本章中介绍。
本章将涵盖以下主题:
-
理解线性代数
-
实现
gg_ski项目 -
实现
gg_silent_slalom项目 -
实现
gg_assets_slalom项目 -
实现
gg_whac项目
特别是,您将看到与上一章中查看的相同三个项目(gg_ski、gg_silent_slalom 和 gg_assets_slalom)的实现,以演示动画循环,以及一个 Whac-A-Mole 游戏(gg_whac)以演示处理离散事件。
第八章:技术要求
本章使用了前一章中实现的动画循环架构和回转滑雪游戏的引用。ggez 框架要求操作系统良好支持 OpenGL 3.2 API 以正确渲染图形对象。因此,旧操作系统如 Windows XP 不能使用。
本章的完整源代码位于github.com/PacktPublishing/Creative-Projects-for-Rust-Programmers存储库的 Chapter07 文件夹中。
macOS 用户可能难以安装 coreaudio-sys。将 coreaudio-sys 的补丁版本升级到 0.2.3 可以解决这个问题。
项目概述
在本章中,我们将首先探讨线性代数是什么以及为什么它对描述和操作任何图形游戏中的对象是有用的。然后,我们将探讨如何使用 nalgebra 库在我们的程序中执行线性代数运算。
之后,我们将重新创建前一章中使用的相同项目,但使用 nalgebra 库和 ggez 框架而不是 quicksilver 框架。gg_ski 是 ski 的重写,gg_silent_slalom 是 silent_slalom 的重写,而 gg_assets_slalom 是 assets_slalom 的重写。
在本章结束时,我们将通过 gg_whac 项目来查看一个完全不同的游戏的实现,以了解如何在将动画循环与事件驱动架构混合的架构中处理离散事件。这还将展示如何创建和将小部件(如按钮)添加到窗口中。
理解线性代数
线性代数是数学的一个分支,涉及一阶方程组系统,如下所示:

这个方程组系统对某些值有解(即,
)。除了在解方程组方面有用外,线性代数的概念和方法对于表示和操作几何实体也是很有用的。
特别地,平面上任何位置都可以用两个坐标表示,x 和 y,而空间中任何位置都可以用三个坐标表示,x,y 和 z。此外,平面上位置的任何平移都可以用两个坐标表示,Δx 和 Δy,而空间中位置的任何平移都可以用三个坐标表示,Δx,Δy 和 Δz。
例如,考虑平面上两个位置:
-
p[1]: 它的坐标是 x = 4, y = 7.
-
p[2]: 它的坐标是 x = 10, y = 16.
考虑平面上两个平移:
-
t[1]: 它的坐标是
. -
t[2]: 它的坐标是
.
你可以说,如果你通过 t[1] 平移将 p[1] 位置平移,你会到达 p[2] 位置。计算是通过添加相应的坐标来完成的:p[1x] + t[1x] = p[2x](或者,用数字来说,4 + 6 = 10)和 p[1y] + t[1y] = p[2y](或者,用数字来说,7 + 9 = 16)。
如果你依次对 p[1] 位置应用两个平移——t[1] 平移和 t[2] 平移——那么你将获得另一个位置(比如说,p[3])。如果你首先将两个平移(通过逐成员求和它们的分量)相加,然后将结果平移应用到 p[1] 上,你也会得到相同的结果。
因此,对于 x 坐标,我们有 (p[1x] + t[1x]) + t[2x] = p[1x] + (t[1x] + t[2x]),对于 y 坐标也有类似的等式。因此,可以添加变换。您可以通过将各自的坐标相加来向另一个变换添加变换,而不是将一个位置添加到另一个位置上。
您可以通过将计算应用于位置和变换实体本身来简化您的几何计算,使用以下公式:

在线性代数中,有两个概念可以应用于这类操作:
-
向量:代数向量是一组可以添加到另一个向量中的数字,得到另一个向量,这正是表示变换所需的。
-
点:代数点是一组不能添加到另一个点中的数字,但可以通过一个向量增加,从而得到另一个点,这正是表示位置所需的。
因此,线性代数的 N 维向量 适合表示 *N 维几何空间中的变换,而线性代数的 N 维点 适合表示 N 维几何空间中的位置。
nalgebra 库(发音为 en-algebra)是一系列代数算法的集合,它为这些类型的二维点和向量类型提供了实现,因此它将在所有以下项目中使用。
使用这个库,您可以编写以下程序,该程序显示了哪些操作是允许的,哪些是不允许的,使用向量和点:
use nalgebra::{Point2, Vector2};
fn main() {
let p1: Point2<f32> = Point2::new(4., 7.);
let p2: Point2<f32> = Point2::new(10., 16.);
let v: Vector2<f32> = Vector2::new(6., 9.);
assert!(p1.x == 4.);
assert!(p1.y == 7.);
assert!(v.x == 6.);
assert!(v.y == 9.);
assert!(p1 + v == p2);
assert!(p2 - p1 == v);
assert!(v + v - v == v);
assert!(v == (2\. * v) / 2.);
//let _ = p1 + p2;
let _ = 2\. * p1;
}
main 函数的前三个语句创建两个二维点和一个二维向量,其坐标为 f32 数字。这种内部数值类型通常可以推断出来,但在这里为了清晰起见进行了指定。
以下四个语句显示,Point2 和 Vector2 类型都包含 x 和 y 字段,这些字段由 new 函数的参数初始化。因此,Point2 和 Vector2 类型看起来非常相似,实际上许多库和许多开发者只使用一个类型来存储位置和变换。
然而,这些类型对于允许的操作是不同的。以下四个语句显示了哪些操作可以进行:
-
将一个向量加到一个点上(
p1 + v),得到另一个点。 -
从两个点中减去一个点(
p2 - p1),得到一个向量。 -
将两个向量相加或相减(
v + v - v),在两种情况下都得到一个向量。 -
将一个向量乘以一个数字或除以一个数字(
(2\. * v) / 2.),在两种情况下都得到一个向量。
在向量上允许执行一些操作,这些操作在点(因为它们对它们没有意义)上是不允许的,最后两个语句展示了这一点。你不能将两个点相加(p1 + p2),实际上,这个操作被注释掉以防止编译错误。你不应该将一个点乘以一个数字(2\. * p1),尽管,出于某种原因,nalgebra 库允许这样做。
如果你想了解更多关于 nalgebra 库的信息,你可以在 www.nalgebra.org/ 找到它的文档。
现在我们已经看到了使用 nalgebra 库处理几何坐标的好方法,让我们看看如何在游戏应用中使用它们。
实现 gg_ski 项目
本章的前三个项目只是对上一章中涵盖的三个项目的重写,但它们被转换成使用 ggez 框架和 nalgebra 库。它们如下所示:
-
ski项目已更名为gg_ski。 -
silent_slalom项目已更名为gg_silent_slalom。 -
assets_slalom项目已更名为gg_assets_slalom。
每个项目的行为与其在 第六章 中对应的项目的行为非常相似,即 使用 Quicksilver 创建 WebAssembly 游戏,因此你可以回到那一章查看每个项目的截图。对于所有三个项目,gg_ski、gg_silent_slalom 和 gg_assets_slalom,Cargo.toml 文件有以下更改。不再是 quicksilver 依赖项,而是以下依赖项:
ggez = "0.5"
nalgebra = "0.18"
术语 ggez(发音为 G. G. easy)是多人在线游戏玩家使用的俚语。
ggez 框架不可否认地受到了 LÖVE 游戏框架 的启发。它们之间的主要区别在于编程语言。LÖVE 使用 C++ 实现,可以用 Lua 编程,而 ggez 既可以实现也可以用 Rust 编程。
现在,让我们比较 ski 项目和 gg_ski 项目的 main.rs 源代码。
主函数
在文件末尾,有 main 函数,它为游戏准备上下文然后运行游戏:
fn main() -> GameResult {
let (context, animation_loop) = &mut ContextBuilder::new
("slalom", "ggez")
.window_setup(conf::WindowSetup::default().title("Slalom"))
.window_mode(conf::WindowMode::default().dimensions(SCREEN_WIDTH,
SCREEN_HEIGHT))
.add_resource_path("static")
.build()?;
let game = &mut Screen::new(context)?;
event::run(context, animation_loop, game)
}
在这个函数中,你可以看到,当你使用 ggez 框架时,你不仅仅运行模型。首先,你应该创建三个对象:
-
在我们的案例中,这是一个窗口。它被分配给
context变量。 -
一个动画循环,它使该上下文动画化。它被分配给
animation_loop变量。 -
在我们的案例中,模型是
Screen类型。它被分配给game变量。
创建这些对象后,你可以使用这三个对象作为参数调用 run 函数。
要创建上下文和动画循环,首先通过调用 ContextBuilder::new 函数创建一个 ContextBuilder 对象;然后,通过调用其方法——window_setup、window_mode 和 add_resource_path 来修改这个构建器。最后,调用 build 方法的调用返回一个上下文和一个动画循环。
然而,请注意以下事项:
-
对
new的调用指定了应用程序的名称("slalom"”)和创建者的名称("ggez"`)。 -
对
window_setup的调用指定了窗口标题栏中的文本("Slalom")。 -
对
window_mode的调用指定窗口的期望大小。 -
对
add_resource_path的调用指定了将包含在运行时加载的资源的文件夹名称("static"),即使我们在这个项目中不会使用资源。
关于Screen模型,请注意它是使用new方法创建的,因此我们将不得不提供此方法;然而,我们可以为这种类型的创建方法使用任何其他名称。
输入处理模式
quicksilver和ggez都采用基于动画循环的模型-视图-控制器(MVC)模式。这是通过要求模型实现一个具有两个必需方法的特质来完成的:
-
update是控制器。 -
draw是视图。
两个框架都运行一个隐式循环,该循环周期性地(每秒多次)调用以下操作:
-
控制器用于更新模型,使用可能的输入数据和模型的先前值
-
视图用于更新屏幕,使用模型的更新值
然而,这些框架在获取输入时使用的技巧有实质性的不同。quicksilver是一个完整的面向动画循环的框架。控制器(或update函数)通过访问输入设备的状态来获取输入——它可以检查鼠标的位置以及哪些鼠标按钮和键盘键被按下。
相反,ggez的输入处理是事件驱动的,因为它捕获输入设备的转换,而不是输入设备的状态。存在几种可能的输入设备转换:
-
鼠标的移动(鼠标移动)
-
鼠标按钮的按下(鼠标按钮按下)
-
按压鼠标按钮的释放(鼠标按钮抬起)
-
按键的按下(按键按下)
-
按压键盘键的释放(按键抬起)
在ggez中,对于这些可能的输入设备转换中的每一个,特质声明了一个可选的处理程序例程,该例程可以由应用程序代码为模型实现。这些例程被称为mouse_motion_event、mouse_button_down_event、mouse_button_up_event、key_down_event和key_up_event。
如果在动画循环时间框架内发生事件,则相关处理程序将在update函数被调用之前被调用。在这些事件处理程序中,应用程序代码应将(在模型中)从事件中收集的信息存储起来,例如哪个键被按下或鼠标移动到了哪个位置。然后,update函数可以处理这些输入数据,为视图准备所需的信息。
为了更好地理解这些技巧,以以下事件序列或时间线为例:
-
update函数每秒调用 10 次——也就是说,每十分之一秒调用一次——因此,每秒帧数=10。 -
用户在
0.020秒时按下 A 键,并在0.070秒后释放,然后他们在0.140秒时按下 B 键,并在0.380秒后释放。
对于 quicksilver,我们有以下的时间线:
| 在时间点 | 输入设备状态 | 更新函数中的输入处理 |
|---|---|---|
0.0 |
没有按键被按下。 | 没有操作。 |
0.1 |
没有按键被按下。 | 没有操作。 |
0.2 |
按下了 B 键。 | 处理了 B 键。 |
0.3 |
按下了 B 键。 | 处理了 B 键。 |
0.4 |
没有按键被按下。 | 没有操作。 |
0.5 |
没有按键被按下。 | 没有操作。 |
对于 ggez,我们有以下的时间线:
| 在时间点 | 输入事件 | 更新函数中的输入处理 |
|---|---|---|
0.0 |
没有输入事件。 | 模型中没有存储任何按键信息。 |
0.1 |
使用 A 键作为参数调用了 key_down_event 函数。它在模型中存储了 A 键。使用 A 键作为参数调用了 key_up_event 函数。它没有做任何事情。 |
从模型中读取 A 键。它被处理并重置。 |
0.2 |
使用 B 键作为参数调用了 key_down_event 函数。它在模型中存储了 B 键。 |
从模型中读取 B 键。它被处理并重置。 |
0.3 |
没有输入事件。 | 模型中没有存储任何按键信息。 |
0.4 |
使用 B 键作为参数调用了 key_up_event 函数。它没有做任何事情。 |
模型中没有存储任何按键信息。 |
0.5 |
没有输入事件。 | 模型中没有存储任何按键信息。 |
注意到对于 quicksilver,A 键从未被按下,而 B 键被按下了两次。这对于连续事件,如使用摇杆,可能是有益的,但不适用于离散事件,如点击命令按钮或向文本框中输入文本。
然而,quicksilver 有捕获所有同时发生事件的优点。例如,quicksilver 可以轻松处理和弦,即当几个键同时连续按下时。
相反,对于 ggez,只要在某个时间段内只按下一个键,所有按键都会被适当次数地处理。这对于按钮和文本框来说是预期的;然而,和弦没有被正确处理。ggez 处理的唯一键组合是涉及 Shift、Ctrl 和 Alt 特殊键的组合。
gg_ski 项目的输入处理
在 ggez 应用程序可以捕获的许多事件中,gg_ski 游戏只捕获了两个事件——右箭头键或左箭头键的按下和释放。处理这些事件会将相关的输入信息存储在模型中,以便 update 函数可以使用它。因此,模型必须包含一些额外的字段,与 quicksilver ski 项目中包含的字段相比。
因此,我们现在有一个模型,其中包含一些由事件函数更新的字段,供update函数使用,以及一些由update函数更新的其他字段,供draw函数使用。为了区分这些输入字段,最好将它们封装在以下定义的结构中:
struct InputState {
to_turn: f32,
started: bool,
}
to_turn字段表示用户按下了箭头键以改变滑雪的方向。如果只按下了左键,方向角度应该递减,因此此字段的值应该是-1.0。如果只按下了右键,方向角度应该递增,因此此字段的值应该是1.0。如果用户没有按下任何箭头键,方向应该保持不变,因此此字段的值应该是0.0。
started字段表示比赛已经开始。在这个项目中没有使用它。使用以下行将此结构的一个实例添加到模型中:
input: InputState,
键按下的捕获是通过以下代码完成的:
fn key_down_event(
&mut self,
_ctx: &mut Context,
keycode: KeyCode,
_keymod: KeyMods,
_repeat: bool,
) {
match keycode {
KeyCode::Left => { self.input.to_turn = -1.0; }
KeyCode::Right => { self.input.to_turn = 1.0; }
_ => (),
}
}
keycode参数指定了哪个键被按下。如果左键或右键被按下,to_turn字段被设置为-1.0或+1.0,分别。任何其他被按下的键都被忽略。
通过以下代码捕获键的释放:
fn key_up_event(&mut self, _ctx: &mut Context, keycode: KeyCode, _keymod: KeyMods) {
match keycode {
KeyCode::Left | KeyCode::Right => {
self.input.to_turn = 0.0;
}
_ => (),
}
}
如果左键或右键被释放,to_turn字段被设置为0.0以停止方向的变化。任何其他键的释放都被忽略。
与 quicksilver 的其他差异
在quicksilver和ggez之间,除了描述的概念差异之外,还有一些细微的差异,这些差异我在以下小节中进行了说明。
特质名称
模型需要实现的特质名称对于quicksilver是State,对于ggez是EventHandler。因此,对于quicksilver,我们有以下行:
impl State for Screen {
但在ggez中,我们有以下:
impl EventHandler for Screen {
上下文类型
使用quicksilver和ggez,你需要实现update方法和draw方法。这两个方法都接收一个参数,用于描述两个框架的输入/输出上下文。这个上下文是用于接收交互输入(通过update方法)和发出图形输出(通过draw方法)的对象。
然而,对于quicksilver,此上下文参数的类型是Window,如下所示:
fn update(&mut self, window: &mut Window) -> Result<()> {
...
fn draw(&mut self, window: &mut Window) -> Result<()> {
对于ggez,它是Context。因此,我们现在有以下签名:
fn update(&mut self, ctx: &mut Context) -> GameResult {
...
fn draw(&mut self, ctx: &mut Context) -> GameResult {
新方法
quicksilver的State特质需要实现new方法,这是框架用来创建模型实例的方法。ggez的EventHandler特质没有这样的方法,因为模型实例是在main函数中的应用代码中显式创建的,正如我们所看到的。
角度测量的单位
虽然quicksilver旋转角度必须以度为单位指定,而ggez旋转角度必须以弧度为单位指定,因此角度常量和变量以这些测量单位指定。因此,我们现在有以下几行:
const STEERING_SPEED: f32 = 110\. / 180\. * PI; // in radians/second
const MAX_ANGLE: f32 = 75\. / 180\. * PI; // in radians
如何指定 FPS 速率
要指定所需的每秒帧数(FPS)速率,在quicksilver中使用时,main函数中指定了两个参数,而ggez使用另一种技术。对于ggez,update函数每秒总是调用 60 次(如果可能的话),但应用程序代码可以通过编写以下update函数的主体来模拟不同的速率:
const DESIRED_FPS: u32 = 25;
while timer::check_update_time(ctx, DESIRED_FPS) {
...
}
这段代码的目的是确保while循环的主体以指定的速率执行,在这种情况下是每秒25帧。让我们看看这是如何实现的。
我们代码中指定的所需速率意味着主体应该每1000 / 25 = 40毫秒执行一次。当update函数执行时,如果自上次执行以来少于 40 毫秒已经过去,check_update_time函数将返回false,因此这次不会执行while循环的主体。很可能在下一个update调用时,时间仍然不足以满足要求,因此check_update_time函数将再次返回false。在随后的执行中,当自上次主体执行以来至少过去了 40 毫秒时,将返回true,因此主体将被执行。
这允许速率低于 60 FPS。然而,还有一个特性。如果由于某种原因,一帧比分配的时间长——比如说,130 毫秒——导致动画卡顿,那么check_update_time函数将连续多次返回true来弥补失去的时间。
当然,如果每一帧都太慢以至于花费太多时间,你将无法获得所需的速率。尽管如此,只要你的帧在所需的时间内处理完毕,这种技术就能确保平均帧率将是指定的速率。
要说实际平均速率接近所需速率,只要一帧的平均时间小于分配给帧的时间就足够了。相反,如果你的帧平均需要 100 毫秒,实际的帧率将是 10 FPS。
处理滑雪转向
在update循环的主体中,滑雪转向的处理方式不同。在ski项目中,只有在该时刻按下箭头键时才会调用steer函数。相反,在gg_sky项目中,以下语句总是被执行:
self.steer(self.input.to_turn);
steer函数在任何时间帧中都会被调用,传递之前通过输入处理方法设置的值。如果这个值是0,滑雪板不会转向。
新位置和速度的计算
此外,update函数的主体现在包含以下语句:
let now = timer::time_since_start(ctx);
self.period_in_sec = (now - self.previous_frame_time)
.as_millis() as f32 / 1000.;
self.previous_frame_time = now;
它们的目的是计算滑雪的正确运动学。在力学中,为了计算位置变化(
),你必须将当前速度(也称为速度,v)乘以前一帧以来经过的时间(
)。这导致以下方程:

要计算速度变化(
),你必须将当前加速度(a)乘以自前一帧以来经过的时间(
),这导致以下方程:

因此,为了计算位置变化和速度变化,我们需要知道自前一帧以来经过的时间。ggez 框架提供了 timer::time_since_start 函数,该函数返回自应用程序开始以来的持续时间。我们从前一帧的时间中减去持续时间,以获得两个帧之间的时间差。然后,将持续时间转换为秒。最后,保存当前时间,以便在下一帧的计算中使用。
绘制背景
由 draw 方法实现的 MVC 视图使用以下语句绘制白色背景:
graphics::clear(ctx, graphics::WHITE);
现在,让我们看看如何绘制组合形状。
绘制组合形状
要绘制一个组合形状,而不是单独绘制所有组件,首先创建一个 Mesh 对象,它是一个包含所有组件形状的组合形状,然后绘制 Mesh 对象。要创建 Mesh 对象,使用以下代码使用 MeshBuilder 类:
let ski = graphics::MeshBuilder::new()
.rectangle(
DrawMode::fill(),
Rect {
x: -SKI_WIDTH / 2.,
y: SKI_TIP_LEN,
w: SKI_WIDTH,
h: SKI_LENGTH,
},
[1., 0., 1., 1.].into(),
)
.polygon(
DrawMode::fill(),
&[
Point2::new(-SKI_WIDTH / 2., SKI_TIP_LEN),
Point2::new(SKI_WIDTH / 2., SKI_TIP_LEN),
Point2::new(0., 0.),
],
[0.5, 0., 1., 1.].into(),
)?
.build(ctx)?;
让我们来看看这段代码做了什么:
-
首先,
new函数创建一个MeshBuilder对象。 -
然后,这些方法指导这些网格构建器如何创建网格组件。
rectangle方法解释了如何创建一个矩形,它将成为滑雪板体,polygon方法解释了如何创建一个多边形,它将成为滑雪板尖。矩形的特点是其绘制模式(DrawMode::fill())、其位置和大小(x、y、w和h)以及其颜色(1., 0., 1., 1.)。多边形的特点是其绘制模式、其顶点列表和其颜色。它只有三个顶点,所以它是一个三角形。 -
然后,
build方法创建并返回指定的网格。请注意,以问号结尾的方法调用是可能失败的,并且颜色由红绿蓝 alpha 四元组指定,其中每个数字的范围是0到1。
要绘制一个网格,使用以下语句:
graphics::draw(
ctx,
&ski,
graphics::DrawParam::new()
.dest(Point2::new(
SCREEN_WIDTH / 2\. + self.ski_across_offset,
SCREEN_HEIGHT * 15\. / 16\. - SKI_LENGTH / 2.
- SKI_TIP_LEN,
))
.rotation(self.direction),
)?;
这个 draw 方法与定义 MVC 架构视图的 draw 方法不同。这可以在 ggez::graphics 模块中找到,而包含的方法(视图)是 ggez::event::EventHandler 特性的一个部分。
graphics::draw方法的第一个参数——ctx——是我们绘制上下文。第二个参数——&ski——是我们绘制的网格。第三个参数是一组参数,封装在DrawParam对象中。这种类型允许我们指定许多参数,其中两个如下所述:
-
使用
dest方法指定的绘制网格的点 -
网格必须旋转的角度(以弧度为单位),使用
rotation方法指定
因此,我们现在已经看到了如何在屏幕上绘制。然而,在调用这些语句之后,实际上并没有任何东西出现在屏幕上,因为这些语句只是为离屏输出做了准备。要获取输出,需要一个最终化语句,这将在下一节中描述。
结束绘制方法
视图(即draw方法)应以以下语句结束:
graphics::present(ctx)?;
timer::yield_now();
在 OpenGL 使用的典型双缓冲技术中,所有的ggez绘图操作不是直接输出到屏幕上,而是在一个隐藏的缓冲区中。present函数快速交换显示的屏幕缓冲区与隐藏的绘制缓冲区,从而立即显示场景,避免出现可能出现的闪烁。最后一条语句告诉操作系统停止使用 CPU 进行此过程,直到必须绘制下一帧。通过这样做,如果帧的处理速度比时间帧快,应用程序可以避免使用 100%的 CPU 周期。
因此,我们已经完成了对gg_ski项目的考察。在下一节中,我们将考察gg_silent_slalom项目是如何在此基础上构建的,以创建一个没有声音或文本的滑雪游戏。
实现gg_silent_slalom项目
在本节中,我们将考察gg_silent_slalom项目,这是前一章节中提出的gg_silent_slalom游戏的ggez框架的实现。在这里,我们只考察gg_ski项目和silent_slalom项目之间的差异。
正如我们在前一节中看到的,ggez将输入作为事件处理。在这个项目中,还处理了两个其他的关键事件——Space和R:
KeyCode::Space => {
self.input.started = true;
}
KeyCode::R => {
self.input.started = false;
}
空格键用于命令比赛的开始,因此它将started标志设置为true。R键用于将滑雪板重新定位在斜坡的起点,因此它将started标志设置为false。
此标志随后在update方法中使用,如下代码所示:
match self.mode {
Mode::Ready => {
if self.input.started {
self.mode = Mode::Running;
}
}
当处于Ready模式时,不是直接检查键盘状态,而是检查started标志。速度和加速度的计算考虑了自前一帧计算以来实际经过的时间:
self.forward_speed = (self.forward_speed
+ ALONG_ACCELERATION * self.period_in_sec * self.direction.cos())
* DRAG_FACTOR.powf(self.period_in_sec);
为了计算新的前进速度,沿着斜坡的加速度(ALONG_ACCELERATION)通过余弦函数(self.direction.cos())投影到滑雪板方向,然后将结果乘以经过的时间(self.period_in_sec)以获得速度增量。
然后将增加的速度乘以一个小于1的系数,以考虑摩擦。这个系数是 1 秒钟的DRAG_FACTOR常量。为了得到实际经过时间的减少系数,必须使用指数函数(powf)。
为了计算滑雪尖头的新水平位置,执行以下语句:
self.ski_across_offset +=
self.forward_speed * self.period_in_sec * self.direction.sin();
这将速度(self.forward_speed)乘以经过的时间(self.period_in_sec),以获得空间增量。这个增量通过正弦函数(self.direction.sin())投影到水平方向,以获得水平位置的变化。
为了计算沿斜面的移动,执行类似的计算,这实际上是滑雪板位置偏移,因为滑雪板总是绘制在相同的Y坐标上。
在draw方法中绘制标杆,首先,使用以下语句创建两个网格:
let normal_pole = graphics::Mesh::new_circle(
ctx,
DrawMode::fill(),
Point2::new(0., 0.),
GATE_POLE_RADIUS,
0.05,
[0., 0., 1., 1.].into(),
)?;
let finish_pole = graphics::Mesh::new_circle(
ctx,
DrawMode::fill(),
Point2::new(0., 0.),
GATE_POLE_RADIUS,
0.05,
[0., 1., 0., 1.].into(),
)?;
在这里,网格是直接创建的,没有使用MeshBuilder对象。new_circle方法需要上下文、填充模式、中心、半径、容差和颜色作为参数。容差是在性能和图形质量之间的权衡。前一个网格用于绘制所有标杆,除了终点标杆,后一个网格用于绘制终点标杆的标杆。
然后,使用如下语句等绘制这些网格,以显示所有标杆:
graphics::draw(
ctx,
pole,
(Point2::new(SCREEN_WIDTH / 2\. + gate.0, gates_along_pos),),
)?;
这里,第三个参数(具有DrawParam类型)以一种简单但有些晦涩的方式指定;它是一个只包含一个元素的元组。这个元素被解释为网格将被绘制的位置,对应于前一个章节中看到的dest方法调用。
因此,我们现在也已经看到了gg_silent_slalom项目的特殊性。在下一节中,我们将查看添加了声音和文本的gg_assets_slalom项目。
实现gg_assets_slalom项目
在本章中,我们将检查gg_assets_slalom项目,这是前一章中介绍的assets_slalom游戏ggez框架的实现。在这里,我们只检查gg_silent_slalom项目和assets_slalom项目之间的差异。
主要区别在于资产加载的方式。这些项目的资产分为两种——字体和声音。为了封装这些资产,ggez不是使用具有Asset<Font>和Asset<Sound>类型的对象,而是分别使用具有graphics::Font和audio::Source类型的对象。这些资产被加载到模型的构造函数中。例如,Screen对象的构造函数包含以下语句:
font: Font::new(ctx, "/font.ttf")?,
whoosh_sound: audio::Source::new(ctx, "/whoosh.ogg")?,
第一个语句加载一个包含TrueType字体的文件用于ctx上下文,并返回一个封装了这个字体的对象。第二个语句(对于ctx上下文)加载一个包含 OGG 声音的文件,并返回一个封装了这个声音的对象。这两个文件必须存在于main函数中通过add_resource_path方法指定的asset文件夹中,并且它们必须是支持的格式之一。
quicksilver和ggez在加载资源方面有一个重要的区别。quicksilver异步加载它们,创建需要确保资源已加载的future对象。另一方面,ggez是同步的;当它加载资源时,它会阻塞应用程序直到资源完全加载。创建的对象不是future对象,因此可以立即使用。
由于它使用future对象,quicksilver更为复杂,但这种复杂性在桌面应用程序中可能没有太大用处,因为如果你的应用程序的资源不超过几兆字节,从本地存储加载它们相当快,因此应用程序启动时的某些阻塞语句不会造成不便。当然,为了防止动画减速,资源必须在应用程序启动时、更改游戏级别或游戏结束时加载。一旦资源被加载,它就立即可用。
最容易使用的资源是声音。要播放声音,定义以下函数:
fn play_sound(sound: &mut audio::Source, volume: f32) {
sound.set_volume(volume);
let _ = sound.play_detached();
}
其第一个参数是sound资源,第二个参数是期望的volume级别。这个函数只是设置音量,然后使用play_detached方法播放声音。这个方法将新声音与任何其他正在播放的声音重叠。还有一个play方法,它在开始播放新声音之前会自动停止播放旧的声音。
要播放固定音量的声音,例如表示无法进入门的声音,可以使用以下语句:
play_sound(&mut self.bump_sound, 1.);
此外,为了使声音与速度成比例,可以使用以下语句:
play_sound(&mut self.whoosh_sound, self.forward_speed * 0.005);
字体也非常容易使用:
let text = graphics::Text::new((elapsed_shown_text, self.font, 16.0));
graphics::draw(ctx, &text, (Point2::new(4.0, 4.0), graphics::BLACK))?;
第一个语句通过调用new函数创建一个文本形状。它有一个包含三个字段的元组作为参数:
-
要打印的字符串(
elapsed_shown_text) -
用于此文本的可伸缩字体对象(
self.font) -
生成文本的期望大小(
16.0)
第二个语句在ctx上下文中绘制创建的文本形状。这个语句指定了一个元组,它将被转换为DrawParam值的第三个参数。指定的子参数是目标点(Point2::new(4.0, 4.0))和要使用的颜色(graphics::BLACK)。
因此,我们现在已经涵盖了整个游戏。在下一节中,我们将查看另一个游戏,它使用鼠标点击和其他类型的资源——图像。
实现 gg_whac 项目
在本节中,我们将检查gg_whac项目,这是在ggez框架中实现著名的打地鼠街机游戏的一个实现。首先,让我们尝试玩一玩。
在gg_whac文件夹中运行cargo run --release后,会出现以下窗口,显示一片草地:

对于那些不熟悉这款游戏的人来说,以下是一些规则。当你点击开始按钮时,以下事情会发生:
-
开始按钮消失。
-
倒计时从左上角的 40 秒开始,到 0 结束。
-
一只可爱的小鼹鼠随机出现在草地上。
-
鼠标光标变成一个禁止符号。
-
如果你将鼠标光标移至鼹鼠身上,它就会变成一个十字形,并出现一个大锤;只要你的鼠标光标保持在鼹鼠上方,你就可以用鼠标拖动这个大锤。
你的窗口应该看起来类似于以下:

如果你点击鼠标主按钮,当鼠标光标悬停在鼹鼠上方时,鼹鼠就会消失,并在另一个位置出现另一个鼹鼠。同时,一个计数器会告诉你你成功击打了多少只鼹鼠。当倒计时达到 0 时,你会看到你的得分。
资产
要了解这个应用程序的行为,首先,让我们看看assets文件夹的内容:
-
cry.ogg是鼹鼠从草地上跳出来时产生的声音。 -
click.ogg是锤子击中鼹鼠时的声音。 -
bump.ogg是锤子击中草地但未击中鼹鼠时的声音。 -
two_notes.ogg是游戏结束时由于倒计时结束而产生的铃声。 -
font.ttf是所有可见文本使用的字体。 -
mole.png是鼹鼠的图片。 -
mallet.png是锤子的图片。 -
lawn.jpg是用于填充背景的图片。 -
button.png是用于开始按钮的图片。
我们在前面一节中已经看到了如何加载和使用声音和字体。这里,有一种新的资产类型——图片。图片通过以下之类的语句声明:
lawn_image: graphics::Image,
它们在应用程序初始化时通过以下之类的语句被加载:
lawn_image: graphics::Image::new(ctx, "/lawn.jpg")?
它们通过以下之类的语句显示:
graphics::draw(ctx, &self.lawn_image, lawn_params)?;
这里,lawn_params参数,其类型为DrawParam,可以指定一个位置、一个缩放、一个旋转,甚至一个裁剪。
应用程序的一般结构和事件
现在,让我们检查源代码的结构。就像我们在本章前面看到的先前的项目一样,这个项目做了以下几件事:
-
定义了一些常量
-
定义了一个
struct Screen类型的模型 -
实现了
EventHandler特质及其所需的update和draw方法,以及可选的mouse_button_down_event和mouse_button_up_event方法 -
定义了
main函数
模型最重要的字段是mode,其类型由以下代码定义:
enum Mode {
Ready,
Raising,
Lowering,
}
初始模式是 Ready,其中倒计时停止,游戏准备开始。当游戏进行时,有以下状态:
-
没有鼹鼠出现。
-
一只鼹鼠从地面升起。
-
一只鼹鼠升起并等待被击中。
-
鼓槌即将击中鼹鼠。
-
被击中的鼹鼠会降入地面。
嗯,实际上,第一个状态不存在,因为游戏开始后,一只鼹鼠就会跳出来,而且,一旦你击中一只鼹鼠,另一只鼹鼠也会跳出来。第二和第三状态由 Mode::Raising 表示。简单来说,当鼹鼠达到其最大高度时,它不会被提升。
第四和第五状态由 Mode::Lowering 表示。简单来说,鼹鼠和鼓槌同时下降。
关于输入操作,应注意对于 EventHandler 特性,没有实现键处理方法,因为这款游戏不使用键盘。相反,它使用鼠标,因此有如下代码:
fn mouse_button_down_event(&mut self, _ctx: &mut Context,
button: MouseButton, x: f32, y: f32) {
if button == MouseButton::Left {
self.mouse_down_at = Some(Point2::new(x, y));
}
}
fn mouse_button_up_event(&mut self, _ctx: &mut Context,
button: MouseButton, x: f32, y: f32) {
if button == MouseButton::Left {
self.mouse_up_at = Some(Point2::new(x, y));
}
}
当鼠标按钮被按下时调用第一个方法,当鼠标按钮被释放时调用第二个方法。
这些方法的第三个参数(button)是一个枚举,表示哪个按钮已被按下;MouseButton::Left 实际上代表主鼠标按钮。
这些方法的第四和第五个参数(x 和 y)是鼠标按钮被按下时鼠标位置的坐标。它们的单位是像素,其坐标系的起点是上下文的最左上角顶点,在我们的案例中是窗口的客户区域。
只处理主鼠标按钮。当它被按下时,代表当前鼠标位置的点被存储在模型的 mouse_down_at 字段中,当它被释放时,它被存储在模型的 mouse_up_at 字段中。
这些字段在模型中以以下方式定义:
mouse_down_at: Option<Point2>,
mouse_up_at: Option<Point2>,
它们的值初始化为 None,并且只有前述代码将其设置为 Point2 值;一旦这些事件被 update 方法处理,它就会重置为 None。因此,每个鼠标事件只处理一次。
模型的其他字段
除了我们已描述的字段外,模型还有以下其他字段:
start_time: Option<Duration>,
active_mole_column: usize,
active_mole_row: usize,
active_mole_position: f32,
n_hit_moles: u32,
random_generator: ThreadRng,
start_button: Button,
start_time 字段用于显示游戏中的当前剩余时间,并在游戏结束时显示“游戏结束”文本。它初始化为 None,然后每次按下开始按钮时,当前时间都会存储在其中。
鼹鼠不会出现在完全随机的位置。草坪被秘密地组织成三行五列。一只鼹鼠会出现在这 15 个位置中的 1 个,随机选择。active_mole_column 和 active_mole_row 字段包含当前显示的鼹鼠的零基于列和行。
active_mole_position 字段包含当前蚂蚁出现的比例。0 值表示蚂蚁完全隐藏。1 值表示蚂蚁的图像(代表其身体的一部分)已经完全出现。n_hit_moles 字段统计被击中的蚂蚁数量。
random_generator 字段是一个伪随机数生成器,用于生成下一个要显示的蚂蚁的位置。最后,start_button 是表示开始按钮的字段。然而,它的类型在库中没有定义。它在这个应用程序中定义,正如我们将解释的那样。
定义一个小部件
商业应用程序有满屏的小型、交互式图形元素,如按钮和文本框。这些元素通常由 Microsoft Windows 文档命名为 控件,在类 Unix 环境中则称为 小部件(来自窗口对象)。使用图形原语定义小部件是一项相当复杂的任务,因此如果您想开发商业应用程序,您可能应该使用一个定义了一组小部件的库。
Rust 标准库和 ggez 框架都没有定义小部件。然而,如果您只需要一些非常简单的小部件,您可以自己开发它们,例如我们将为这个项目开发按钮。让我们看看这是如何实现的。
首先,有一个 Button 类型的定义,可以实例化以添加到窗口中的任何按钮:
struct Button {
base_image: Rc<graphics::Image>,
bounding_box: Rect,
drawable_text: graphics::Text,
}
我们的按钮只是一个按我们想要的尺寸调整过的图像,上面有居中的文本。这个图像对所有按钮都是相同的,因此应该在程序中共享以节省内存。这就是为什么 base_image 字段是一个指向图像的引用计数指针。
bounding_box 字段指示按钮的期望位置和大小。图像将被拉伸或缩小以适应此大小。drawable_text 字段是一个文本形状,它将在按钮图像上绘制作为其标题。Button 类型实现了几个方法:
-
使用
new创建一个新的按钮 -
使用
contains检查给定点是否在按钮内部 -
使用
draw在指定上下文中显示自身
new 方法有许多参数:
fn new(
ctx: &mut Context,
caption: &str,
center: Point2,
font: Font,
base_image: Rc<graphics::Image>,
) -> Self {
caption 参数是显示在按钮内部的文本。center 参数是按钮中心的期望位置。font 和 base_image 参数是使用的字体和图像。
要创建我们的按钮,使用以下表达式:
start_button: Button::new(
ctx,
"Start",
Point2::new(600., 40.),
font,
button_image.clone(),
),
它指定了 "Start" 作为标题,宽度为 600 像素,高度为 40 像素。
要绘制按钮,首先,我们使用以下表达式检查主鼠标按钮是否当前被按下:
mouse::button_pressed(ctx, MouseButton::Left)
通过这样做,可以使按钮看起来像被按下,从而提供按钮操作的视觉反馈。然后,我们使用以下表达式检查鼠标光标是否在按钮内部:
rect.contains(mouse::position(ctx))
当鼠标悬停在按钮上时,这会将按钮标题的颜色变为红色,以向用户显示按钮可以被点击。因此,我们现在已经看到了这个项目的最有趣的部分,这也结束了我们对ggez框架的探讨。
摘要
我们已经看到了如何使用ggez框架在桌面端构建二维游戏。这个框架不仅允许我们根据动画循环架构和 MVC 架构模式来结构化应用程序,还可以获取离散的输入事件。此外,我们还了解了为什么线性代数库对于图形应用程序来说可能很有用。
我们创建并查看了一个应用程序——gg_ski、gg_silent_slalom、gg_assets_slalom和gg_whac。
尤其是我们学习了如何使用ggez框架构建一个图形桌面应用程序,该框架根据 MVC 架构进行结构化,以及如何实现动画循环架构和事件驱动架构,可能是在同一个窗口中。此外,我们还学习了如何使用ggez在网页上绘制图形元素,以及如何使用ggez加载和使用静态资源。在章节的结尾,我们将二维点和向量封装在结构中,并看到了如何使用nalgebra库来操作它们。
在下一章中,我们将探讨一个完全不同的技术:解析。解析文本文件对于许多目的都很有用,特别是用于解释或编译源代码程序。我们将查看nom库,它使得解析任务变得更容易。
问题
-
线性代数中向量和点之间的区别是什么?
-
与代数向量和点相对应的几何概念是什么?
-
为什么即使在面向动画循环的应用程序中,捕获事件也可能很有用?
-
为什么在桌面游戏中加载同步资源可能是一个好主意?
-
ggez是如何从键盘和鼠标获取输入的? -
ggez框架中使用了哪些网格? -
你如何使用
ggez构建一个ggez网格? -
你如何使用
ggez获得期望的动画帧率? -
你如何使用
ggez在期望的位置绘制网格,并使用期望的缩放和旋转值? -
你如何使用
ggez播放声音?
进一步阅读
可以从github.com/ggez/ggez下载ggez项目。这个仓库包含许多示例项目,包括一个完整的彗星街机游戏。
使用解析器组合进行解释和编译
Rust 是一种系统编程语言。系统编程的典型任务之一是处理形式语言。形式语言是由良好定义的逻辑规则指定的语言,在计算机技术的各个方面都得到广泛应用。它们可以广泛地分为命令、编程和标记语言。
要处理形式语言,第一步是解析。解析意味着分析一段代码的语法结构,以检查它是否遵守其应使用的语法规则,然后,如果语法得到遵守,生成一个描述解析代码片段结构的数据库结构,以便进一步处理该代码。
在本章中,我们将看到如何处理用形式语言编写的文本,从解析步骤开始,并继续进行几个可能的输出——简单地检查语法、解释程序以及将程序翻译成 Rust 语言。
为了展示这些特性,将定义一种极其简单的编程语言,并围绕它构建四个工具(语法检查器、语义检查器、解释器和翻译器)。
在本章中,你将了解以下主题:
-
使用形式语法定义编程语言
-
将编程语言分为三类
-
学习构建解析器的两种流行技术——编译器编译器和解析器组合器
-
使用名为Nom的 Rust 解析器组合库
-
使用 Nom 库根据上下文无关语法处理源代码以检查其语法(
calc_parser) -
验证变量声明及其在某些源代码中的使用的一致性,同时为代码的最佳执行准备所需的结构(
calc_analyzer) -
执行预处理代码,在名为解释(
calc_interpreter)的过程中 -
将预处理代码翻译成另一种编程语言,在名为编译(
calc_compiler)的过程中;例如,将翻译成 Rust 代码
阅读本章后,你将能够编写简单形式语言的语法或理解现有形式语言的语法。你还将能够通过遵循其语法编写任何编程语言的解释器。此外,你还将能够编写将一种形式语言翻译成另一种形式语言的翻译器,遵循它们的语法。
第九章:技术要求
阅读本章内容,不需要了解前几章的知识。对形式语言理论和技术的了解有帮助但不是必需的,因为所需的知识将在本章中解释。将使用 Nom 库来构建此类工具,因此它将在本章中描述。
本章的完整源代码位于 GitHub 仓库的Chapter08文件夹中,网址为github.com/PacktPublishing/Creative-Projects-for-Rust-Programmers。
项目概述
在本章中,我们将构建四个递增复杂性的项目,如下所示:
-
第一个项目(
calc_parser)将仅是Calc语言的语法检查器。实际上,它只是一个解析器,随后是对解析结果的格式化调试打印。 -
第二个项目(
calc_analyzer)使用第一个项目的解析结果来验证变量声明的完整性和使用的一致性,然后以格式化的调试打印分析结果。 -
第三个项目(
calc_interpreter)使用分析结果在交互式解释器中执行预处理后的代码。 -
第四个项目(
calc_compiler)再次使用分析结果将预处理后的代码翻译成等效的 Rust 代码。
介绍 Calc
为了使以下解释更加清晰,我们首先定义一个玩具编程语言,我们将称之为Calc(来自计算器)。玩具编程语言是一种用于演示或证明某事的编程语言,不是用于开发现实世界软件的。以下是一个用Calc编写的简单程序示例:
@first
@second
> first
> second
@sum
sum := first + second
< sum
< first * second
以下程序要求用户输入两个数字,然后在控制台上打印这些数字的和与积。让我们逐个语句进行分析,如下所示:
-
前两个语句(
@first和@second)声明了两个变量。Calc中的任何变量都代表一个 64 位浮点数。 -
第三和第四个语句(
> first和> second)是输入语句。每个语句都会打印一个问号,等待用户输入一个数字并按Enter键。如果按Enter键之前没有输入数字或输入了无效的数字,则将值0赋给变量。 -
第五个语句声明了
sum变量。 -
第六个语句(
sum := first + second)是一个帕斯卡风格的赋值语句。它计算first和second变量的和,并将结果赋值给sum变量。 -
第七和第八个语句执行输出。第七个语句(
< sum)在控制台上打印sum变量的当前值。第八个语句(< first * second)计算first和second变量的当前值的乘积,然后在控制台上打印这种乘积的结果。
Calc语言有两个其他运算符——-(减号)和/(除号)——分别用于指定减法和除法。此外,以下代码显示操作可以在表达式中组合,因此这些是有效的赋值语句:
y := m * x + q
a := a + b - c / d
操作是从左到右执行的,但乘法和除法的优先级高于加法和减法。
除了变量之外,还允许使用数值字面量。因此,你可以编写以下代码:
a := 2.1 + 4 * 5
这个语句将22.1赋值给a,因为在执行加法之前会先执行乘法。为了强制不同的优先级,允许使用括号,如下面的代码片段所示:
a := (2.1 + 4) * 5
前面的代码片段将30.5赋值给a。
在前面的代码片段中,除了换行符之外,没有字符将一个语句与下一个语句分开。实际上,Calc语言没有用于分隔语句的符号,而且它也不需要这些符号。因此,第一个程序应该等同于以下内容:
@first@second>first>second@sum sum:=first+second<sum<first*second
在前面的代码片段中,没有歧义,因为@字符标记了声明的开始,>字符标记了输入操作的开始,<字符标记了输出操作的开始,而在当前语句不允许变量的位置上的变量标记了赋值的开始。
要理解这种语法,必须解释一些语法术语,如下所述:
-
整个文本是一个程序。
-
任何程序都是一系列语句。在第一个示例程序中,每一行恰好有一个语句。
-
在某些语句中,可能有一个可以评估的算术公式,例如
a * 3 + 2。这个公式是一个表达式。 -
任何表达式都可以包含更简单表达式的和或差。不包含和或差的简单表达式被称为项。因此,任何表达式都可以是一个项(如果它不包含和或差),或者它可以是表达式和项的和,或者它可以是表达式和项的差。
-
任何项都可以包含更简单表达式的乘法或除法。不包含乘法或除法的简单表达式被称为因子。因此,任何项都可以是一个因子(如果它不包含乘法或除法),或者它可以是项和因子的乘积,或者它可以是项和因子的除法。以下列出了三种可能的因子类型:
-
变量的名称,称为标识符
-
数值常数,由数字序列表示,称为字面量
-
括号内的完整表达式,以强制它们的优先级
-
在Calc语言中,为了简单起见,并且与大多数编程语言不同,标识符中不允许使用数字和下划线。因此,任何标识符都是一个非空字母序列。或者换句话说,任何标识符可以是一个字母,或者是一个字母后跟一个标识符。
形式语言的语法可以通过称为巴科斯-诺尔形式的符号来指定。使用这种符号,我们的Calc语言可以通过以下规则来指定:
<program> ::= "" | <program> <statement>
<statement> ::= "@" <identifier> | ">" <identifier> | "<" <expr> | <identifier> ":=" <expr>
<expr> ::= <term> | <expr> "+" <term> | <expr> "-" <term>
<term> ::= <factor> | <term> "*" <factor> | <term> "/" <factor>
<factor> ::= <identifier> | <literal> | "(" <expr> ")"
<identifier> := <letter> | <identifier> <letter>
上述代码片段中使用的所有规则的说明如下:
-
第一条规则规定,一个程序是一个空字符串或一个程序后跟一个语句。这相当于说,一个程序是一系列零个或多个语句。
-
第二条规则规定,一个语句要么是一个
@字符后跟一个标识符,要么是一个>字符后跟一个标识符,要么是一个<字符后跟一个表达式,或者是一个标识符后跟:=字符对,然后是一个表达式。 -
第三条规则规定,一个表达式要么是一个项,要么是一个项后跟一个
+字符和一个项,或者是一个表达式后跟一个-字符和一个项。这相当于说,一个表达式是一个项后跟零个或多个项项,其中项项是一个+或-操作符后跟一个项。 -
类似地,第四条规则规定,一个项要么是一个因子,要么是一个项后跟一个
*字符和一个因子,或者是一个项后跟一个/字符和一个因子。这相当于说,一个项是一个因子后跟零个或多个因子项,其中因子项是一个乘法或除法操作符后跟一个因子。 -
第五条规则规定,一个因子要么是一个标识符或一个文字面量,要么是一个括号内的表达式。只有当括号在代码中正确配对时,此规则才成立。
-
第六条规则规定,一个标识符是一个字母或一个标识符后跟一个字母。这相当于说,一个标识符是一系列一个或多个字母。此语法没有指定如何处理大小写敏感性,但我们将假设标识符是大小写敏感的。
此语法未定义 <letter> 符号和 <literal> 符号的意思,因此在这里进行解释:
-
<letter>符号表示任何is_alphabeticRust 标准库函数返回true的字符。 -
<literal>符号表示任何浮点数。实际上,由于我们将使用 Rust 代码来解析、存储和处理它,Calc对literal的定义与 Rust 对f64文字面量的定义相同。例如-4.56e300将被允许,但1_000和3f64将不被允许。
关于空白符,已经进行了简化。空白符、制表符和换行符在代码的所有位置都是允许的,除了在标识符内、在文字面量内和在 := 符号内。它们是可选的,但唯一需要空白符的位置是在语句的结束标识符和赋值的开始标识符之间,因为否则这两个标识符将合并成一个。
在本节中,我们定义了Calc语言的语法。这样的形式定义被称为语法。这是一个非常简单的语法,但它与现实世界编程语言的语法相似。为一种语言拥有形式语法对于构建处理用这种语言编写的代码的软件是有用的。
现在我们已经看到了我们的玩具语言,我们准备好处理用这种语言编写的代码了。第一个任务是构建一个语法检查器,用于验证该语言中任何程序的结构的有效性。
理解形式语言及其解析器
正如我们所见,系统编程的典型任务之一是处理形式语言。在这样的一些形式语言中,通常执行几种操作。这里列出了其中最典型的:
-
检查一行或文件的语法有效性
-
根据格式规则格式化文件
-
执行用命令语言编写的命令
-
解释用编程语言编写的文件——即立即执行它
-
将用编程语言编写的文件编译成另一种编程语言——即将其翻译成汇编语言或机器语言
-
将标记文件转换为另一种标记语言
-
在浏览器中渲染标记文件
所有这些操作都有共同的第一步——解析。检查一个字符串以根据语法提取其结构的过程称为解析。根据我们想要解析的形式语言的类别,至少有三种可能的解析技术。我们将在本节中看到这些类别:正则语言、上下文无关语言和上下文相关语言。
正则语言
最简单语言的类别是正则语言,它可以使用正则表达式来定义。
以最简单的方式,正则表达式是使用以下运算符在子字符串之间创建的模式:
-
连接 (或序列):这意味着一个子字符串必须跟随另一个子字符串;例如,
ab意味着b必须跟随a。 -
交替 (或选择):这意味着一个子字符串可以用另一个子字符串代替;例如,
a|b意味着a或b可以交替使用。 -
Kleene 星号 (或重复):这意味着一个子字符串可以使用零次或多次;例如,
a*意味着a可以使用零次、一次、两次或更多次。
使用这样的运算符时,可以使用括号。因此,以下是一个正则表达式:
a(bcd|(ef))g*
这意味着一个有效的字符串必须以一个a开头,后面跟着两个可能的子字符串——一个是字符串bcd,另一个是空字符串或字符串ef,或者字符串ef的任何多次重复,然后必须有g。以下是一些属于这种正则语言的字符串:
-
abcdg
-
ag
-
aefg
-
aefefg
-
aefefefg
-
aefefefefg
正则语言的一个优点是它们的解析所需的内存量仅取决于语法,而不取决于正在解析的文本;因此,通常它们即使解析巨大的文本也只需要很少的内存。
正则表达式 crate 是使用正则表达式解析正则语言的最受欢迎的方法。如果您有正则语言要解析,那么建议使用这样的库。例如,检测一个有效的标识符或一个有效的浮点数是正则语言解析器的任务。
上下文无关语言
由于编程语言不是简单的正则语言,因此不能使用正则表达式来解析它们。不属于正则语言的典型语言特性是括号的使用。大多数编程语言允许使用((5))字符串,但不允许使用((5)字符串,因为任何开括号都必须由一个闭括号匹配。这样的规则不能用正则表达式表示。
更一般(因此更强大)的语言类别是上下文无关语言。这些语言由语法定义,就像在前一节关于Calc语言的章节中看到的那样,包括某些元素必须匹配的事实(例如括号、方括号、花括号和引号)。
与正则语言不同,上下文无关语言所需的内存量取决于解析的文本。每次遇到一个开括号时,它必须被存储起来以匹配相应的闭括号。尽管这种内存使用通常相当小,并且以后进先出(LIFO)的方式访问(就像在堆栈数据结构中一样),但它非常高效,因为不需要堆分配。
然而,即使是上下文无关语言也足以用于现实世界的应用,因为现实世界的语言需要是上下文相关的,如下一节所述。
上下文相关语言
不幸的是,即使是上下文无关文法(CFGs)也不足以表示现实世界的编程语言。问题在于标识符的使用。
在许多编程语言中,在使用变量之前必须声明它。在任何代码位置,只能使用到该点定义的变量。这样一组可用的标识符被视为解析下一个语句的上下文。在许多编程语言中,这样的上下文不仅包含变量的名称,还包括其类型,以及它肯定已经接收了一个值或它可能仍然未初始化的事实。
为了捕捉这样的约束,可以定义上下文相关的语言,尽管这种形式主义相当难以操作,并且生成的语法解析效率不高。
因此,解析编程语言文本的通常方法是将解析分为几个概念上的遍历,如下所示:
-
第 1 步:在可能的情况下使用正则表达式——即解析标识符、字面量、运算符和分隔符。这一步生成一个标记流,其中每个标记代表一个解析项。例如,任何标识符都是不同的标记,而空白和注释则被跳过。这一步通常被称为词法分析或lexing。
-
第 2 步:使用上下文无关解析器,即应用语法规则到标记流中。这一步创建了一个表示程序的树形结构,这个结构被称为语法树。标记被存储为树的叶子(即终端节点)。这个树仍然可能包含上下文相关的错误,例如未声明的标识符的使用。这一步通常被称为语法分析。
-
第 3 步:处理语法树,将任何变量使用与该变量的声明关联起来,并可能检查其类型。这一步创建了一个新的数据结构,称为符号表,它描述了语法树中找到的所有标识符,并且用对这样的符号表的引用装饰了语法树。这一步通常被称为语义分析,因为它通常也涉及类型检查。
当我们有一个装饰的语法树及其相关的符号表时,解析操作就完成了。现在,开发者可以使用这些数据结构执行以下操作:
-
获取语法错误,如果代码无效
-
获取有关如何改进代码的建议
-
获取有关代码的一些度量
-
解释代码(如果语言是编程语言)
-
将代码翻译成另一种语言
在本章中,将执行以下操作:
-
词法分析步骤和语法分析步骤将被组合成一个单一的步骤,该步骤将处理源代码并生成语法树(在
calc_parser项目中)。 -
语义分析步骤将使用解析器生成的语法树来创建符号表和装饰的语法树(在
calc_analyser项目中)。 -
符号表和装饰的语法树将被用来执行用
Calc语言编写的程序(在calc_interpreter项目中)。 -
符号表和装饰的语法树还将被用来将程序翻译成 Rust 语言(在
calc_complier项目中)。
在本节中,我们看到了编程语言的有用分类。即使每种编程语言都属于上下文相关类别,其他类别仍然有用,因为解释器和编译器将正则语法和 CFG 作为它们操作的一部分。
但在看到完整的项目之前,让我们看看构建解析器所使用的技巧,特别是 Nom 库所使用的技巧。
使用 Nom 构建解析器
在开始编写Calc语言的解析器之前,让我们先看看用于构建解释器和编译器的最流行的解析技术。这是为了理解 Nom 库,它使用这些技术之一。
学习关于编译器-编译器和解析器组合器
要获得一个极其快速和灵活的解析器,你需要从头开始构建它。但是,几十年来,一种更简单的方法被用来构建解析器,即使用名为编译器-编译器或编译器生成器的工具:生成编译器的程序。这些程序接收输入为一个装饰过的语法规范,并为这种语法生成解析器的源代码。然后,这些生成的源代码必须与其他源文件一起编译,以获得可执行的编译器。
这种传统方法现在已有些过时,另一种名为解析器组合器的方法已经出现。解析器组合器是一组函数,允许将多个解析器组合起来以获得另一个解析器。
我们已经看到,任何Calc程序只是Calc语句的序列。如果我们有一个单行Calc语句的解析器,并且能够按顺序应用这样的解析器,那么我们就可以解析任何Calc程序。
我们应该知道,任何Calc语句要么是Calc声明,要么是Calc赋值,要么是Calc输入操作,要么是Calc输出操作。如果我们为每个这样的语句都有一个解析器,并且能够交替地应用这些解析器,我们就可以解析任何Calc语句。我们可以继续进行,直到我们得到单个字符(或者如果我们使用词法分析器的输出,则是到标记)。因此,一个程序的解析器可以通过组合其组成部分的解析器来获得。
但是,用 Rust 编写的解析器是什么?它是一个接收源代码字符串作为输入并返回结果的函数。结果可以是Err(如果该字符串无法解析)或Ok(包含表示解析项的数据结构)。
因此,虽然正常函数接收数据作为输入并返回数据作为输出,我们的解析器组合器接收一个或多个具有函数作为输入的解析器,并返回一个解析器作为输出。接收函数作为输入并返回函数作为输出的函数被称为二阶函数,因为它们处理函数而不是数据。在计算机科学中,二阶函数的概念起源于函数式语言,解析器组合器的概念也来自这样的语言。
在 Rust 的 2018 版本之前,二阶函数是不可行的,因为 Rust 函数不能返回函数而不分配闭包。因此,Nom 库(直到版本 4)使用宏而不是函数作为组合器以保持高性能。当 Rust 引入了impl Trait特性(包含在 2018 版本中)时,使用函数而不是宏实现解析器组合器的有效方法成为可能。因此,Nom 的第 5 版完全基于函数,仅保留宏以保持向后兼容性。
在下一节中,我们将看到 Nom 库的基本功能,我们将使用这些功能来构建解释器和编译器。
学习 Nom 的基础知识
Nom crate 实质上是一个函数集合。其中大部分是解析器组合器——也就是说,它们将一个或多个解析器作为参数,并以解析器作为返回值。你可以把它们看作是输入一个或多个解析器并输出组合解析器的机器。
一些 Nom 函数是解析器——也就是说,它们将一个char值的序列作为参数,如果解析失败则返回错误,或者在成功的情况下返回表示解析文本的数据结构。
现在,我们将通过非常简单的程序来查看 Nom 的最基本功能。特别是,我们将看到以下内容:
-
char解析器:解析单个固定字符 -
alt解析器组合器:接受替代解析器 -
tuple解析器组合器:接受一组固定的解析器 -
tag解析器:解析固定字符的字符串 -
map解析器组合器:转换解析器的输出值 -
Result::map函数:在解析器的输出上应用更复杂的转换 -
preceded、terminated和delimited解析器组合器:用于接受一组固定的解析器并从输出中丢弃其中的一些 -
take解析器:接受定义数量的字符 -
many1解析器组合器:接受一个或多个解析器的重复序列
解析字符的替代方案
作为解析器的例子,让我们看看如何解析固定字符的替代方案。我们想要解析一个非常简单的语言,这种语言只有三个单词——a、b和c。这样的解析器只有在它的输入是字符串a、字符串b或字符串c时才会成功。
如果解析成功,我们想要的结果是一对东西——剩余的输入(即在有效部分被处理后)和已处理文本的表示。由于我们的单词由单个字符组成,我们想要(作为这种表示)一个char类型的值,其中只包含解析的字符。
以下是我们使用 Nom 的第一个代码片段:
extern crate nom;
use nom::{branch::alt, character::complete::char, IResult};
fn parse_abc(input: &str) -> IResult<&str, char> {
alt((char('a'), char('b'), char('c')))(input)
}
fn main() {
println!("a: {:?}", parse_abc("a"));
println!("x: {:?}", parse_abc("x"));
println!("bjk: {:?}", parse_abc("bjk"));
}
如果你编译这个程序,包括 Nom crate 的依赖项,并运行它,它应该打印以下输出:
a: Ok(("", 'a'))
x: Err(Error(("x", Char)))
bjk: Ok(("jk", 'b'))
我们将我们的解析器命名为 parse_abc。它接受一个字符串切片作为输入,并返回 IResult<&str, char> 类型的值。这种返回值类型是一种 Result。这种 Result 类型的 Ok 情况是两个值的元组——包含剩余输入的字符串切片和一个字符,即通过解析文本获得的信息。这种 Result 类型的 Err 情况是由 Nom 包内部定义的。
如输出所示,parse_abc("a") 表达式返回 Ok(("", 'a'))。这意味着当解析 a 字符串时,解析成功;没有剩余的输入需要处理,并且提取的字符是 'a'。
相反,parse_abc("x") 表达式返回 Err(Error(("x", Char)))。这意味着当解析 x 字符串时,解析失败;x 字符串仍然需要处理,并且错误类型是 Char,意味着期望一个 Char 项目。请注意,Char 是由 Nom 定义的一种类型。
最后,parse_abc("bjk") 表达式返回 Ok(("jk", 'b'))。这意味着当解析字符串 bjk 时,解析成功;jk 输入仍然需要处理,并且提取的字符是 'b'。
现在,让我们看看我们的解析器是如何实现的。为 Nom 构建的任何解析器的签名都必须相似,并且它们的主体必须是一个函数调用,该函数的参数作为其参数(在这种情况下,(input))。
有趣的部分是 alt((char('a'), char('b'), char('c'))). 这个表达式意味着我们想要通过组合三个解析器,char('a')、char('b') 和 char('c') 来构建一个解析器。char 函数(不要与具有相同名称的 Rust 类型混淆)是一个内置的 Nom 解析器,它识别指定的字符并返回一个包含该字符的 char 类型的值。alt 函数(简称 alternative,即“选择”)是一个解析器组合器。它只有一个参数,即由几个解析器组成的元组。alt 解析器会选择一个与输入匹配的指定解析器。
确保对于任何给定的输入,最多只有一个解析器接受输入,这是你的责任。否则,语法是模糊的。以下是一个模糊解析器的示例——alt((char('a'), char('b'), char('a')))。char('a') 子解析器被重复,但 Rust 编译器不会注意到这一点。
在下一节中,我们将看到如何解析字符序列。
解析字符序列
现在,让我们看看另一个解析器,如下所示:
extern crate nom;
use nom::{character::complete::char, sequence::tuple, IResult};
fn parse_abc_sequence(input: &str)
-> IResult<&str, (char, char, char)> {
tuple((char('a'), char('b'), char('c')))(input)
}
fn main() {
println!("abc: {:?}", parse_abc_sequence("abc"));
println!("bca: {:?}", parse_abc_sequence("bca"));
println!("abcjk: {:?}", parse_abc_sequence("abcjk"));
}
运行后,它应该打印以下内容:
abc: Ok(("", ('a', 'b', 'c')))
bca: Err(Error(("bca", Char)))
abcjk: Ok(("jk", ('a', 'b', 'c')))
这次,字母 a、b 和 c 必须按照这个确切的顺序出现,并且 parse_abc_sequence 函数返回包含这些字符的元组。对于 abc 输入,没有剩余的输入,并且返回 ('a', 'b', 'c') 元组。bca 输入不被接受,因为它以 b 字符开头而不是 a。abcjk 输入被接受,就像第一种情况一样,但这次有一个剩余的输入。
解析器的组合是tuple((char('a'), char('b'), char('c')))。这与前面的程序类似,但通过使用tuple解析器组合器,获得了一个需要按顺序满足所有指定解析器的解析器。
在下一节中,我们将看到如何解析固定文本字符串。
解析固定字符串
在之前讨论的parse_abc_sequence函数中,为了识别abc序列,必须指定三次char解析器,结果是char值的元组。
对于较长的字符串(如语言的关键字),这不太方便,因为它们更容易被视为字符串而不是字符序列。Nom 库还包含一个用于固定字符串的解析器,名为tag。前面的程序可以使用此内置解析器重写,如下面的代码块所示:
extern crate nom;
use nom::{bytes::complete::tag, IResult};
fn parse_abc_string(input: &str) -> IResult<&str, &str> {
tag("abc")(input)
}
fn main() {
println!("abc: {:?}", parse_abc_string("abc"));
println!("bca: {:?}", parse_abc_string("bca"));
println!("abcjk: {:?}", parse_abc_string("abcjk"));
}
它将打印以下输出:
abc: Ok(("", "abc"))
bca: Err(Error(("bca", Tag)))
abcjk: Ok(("jk", "abc"))
而不是tuple((char('a'), char('b'), char('c')))表达式,现在有一个简单的调用tag("abc"),并且解析器返回一个字符串切片,而不是char值的元组。
在下一节中,我们将看到如何将解析器产生的值转换成另一个值,可能是另一种类型。
将解析后的项映射到其他对象
到目前为止,我们得到的结果只是我们在输入中找到的内容。但通常,我们在返回结果之前想要转换解析后的输入。
假设我们想要解析三个字母(a、b或c),但我们想要在解析的结果中,对于字母a返回数字5,对于字母b返回数字16,对于字母c返回数字8。
因此,我们想要一个解析器,它可以解析一个字母,但,如果解析成功,它返回一个数字,而不是返回那个字母。我们还想将字符a映射到数字5,字符b映射到数字16,字符c映射到数字8。原始的结果类型是char,而映射的结果类型是u8。以下代码块显示了执行这种转换的程序:
extern crate nom;
use nom::{branch::alt, character::complete::char, combinator::map, IResult};
fn parse_abc_as_numbers(input: &str)
-> IResult<&str, u8> {
alt((
map(char('a'), |_| 5),
map(char('b'), |_| 16),
map(char('c'), |_| 8),
))(input)
}
fn main() {
println!("a: {:?}", parse_abc_as_numbers("a"));
println!("x: {:?}", parse_abc_as_numbers("x"));
println!("bjk: {:?}", parse_abc_as_numbers("bjk"));
}
当它运行时,它应该打印以下输出:
a: Ok(("", 5))
x: Err(Error(("x", Char)))
bjk: Ok(("jk", 16))
对于a输入,提取出5。对于x输入,得到一个解析错误。对于bjk输入,提取出16,并且jk字符串作为待解析的输入保留。
对于这三个字符中的每一个,实现中包含类似map(char('a'), |_| 5)的内容。map函数是另一个解析器组合器,它接受一个解析器和闭包。如果解析器匹配,则生成一个值。闭包在这样一个值上被调用,并返回一个转换后的值。在这种情况下,闭包的参数是不需要的。
同一个解析器的另一种等效实现如下:
fn parse_abc_as_numbers(input: &str) -> IResult<&str, u8> {
fn transform_letter(ch: char) -> u8 {
match ch {
'a' => 5,
'b' => 16,
'c' => 8,
_ => 0,
}
}
alt((
map(char('a'), transform_letter),
map(char('b'), transform_letter),
map(char('c'), transform_letter),
))(input)
}
它定义了一个transform_letter内部函数,该函数应用转换并将该函数仅作为map组合器的第二个参数传递。
在下一节中,我们将看到如何以更复杂的方式操作解析器的输出,因为我们将会省略或交换结果元组中的某些字段。
创建自定义解析结果
到目前为止,解析的结果是由其中使用的解析器和组合器决定的——如果一个解析器使用三个项目的tuple组合器,结果就是一个包含三个项目的元组。这很少是我们想要的。例如,我们可能想要省略结果元组中的某些项目,或者添加一个固定项目,或者交换项目。
假设我们想要解析abc字符串,但在结果中我们想要省略b,只保留ac。为此,我们必须以下述方式后处理解析结果:
extern crate nom;
use nom::{character::complete::char, sequence::tuple, IResult};
fn parse_abc_to_ac(input: &str) -> IResult<&str, (char, char)> {
tuple((char('a'), char('b'), char('c')))(input)
.map(|(rest, result)| (rest, (result.0, result.2)))
}
fn main() {
println!("abc: {:?}", parse_abc_to_ac("abc"));
}
它将打印以下输出:
abc: Ok(("", ('a', 'c')))
当然,我们解析器的结果现在只包含一对——(char, char)。后处理在代码体的第二行中体现。它使用了一个map函数,这个函数与前面的例子中看到的不同;它属于Result类型。这种方法接收一个闭包,该闭包获取结果的Ok变体,并返回一个新的Ok变体,具有适当的数据类型。如果类型已经明确指定,那么代码将如下所示:
.map(|(rest, result): (&str, (char, char, char))|
-> (&str, (char, char)) {
(rest, (result.0, result.2))
}
从前面的代码中,tuple的调用返回一个结果,其Ok变体具有(&str, (char, char, char))类型。第一个元素是剩余的输入,分配给rest变量,第二个元素是解析的char值序列,分配给result变量。
然后,我们必须构造一个包含两个项目的对——即我们想要的剩余输入,以及我们想要的结果的字符对。作为剩余输入,我们指定由tuple提供的相同对,而作为结果,我们指定(result.0, result.2)——即第一个和第三个解析的字符,它们将是'a'和'c'。
以下的一些情况相当典型:
-
两个解析器的序列,需要保留第一个解析器的结果并丢弃第二个解析器的结果。
-
两个解析器的序列,需要丢弃第一个解析器的结果并保留第二个解析器的结果。
-
三个解析器的序列,需要保留第二个解析器的结果并丢弃第一个和第三个解析器的结果。这在括号表达式或引号文本中很典型。
对于这些先前的情况,映射技术同样适用,但 Nom 包含一些特定的组合器,具体如下:
-
preceded(a, b):这仅保留b的结果。 -
terminated(a, b):这仅保留a的结果。 -
delimited(a, b, c):这仅保留b的结果。
在下一节中,我们将看到如何解析指定数量的字符并返回解析的字符。
解析可变文本
我们迄今为止所进行的解析非常有局限性,因为我们只是检查了输入是否遵循了一种语言,而没有接受任意文本或数字的可能性。
假设我们想要解析一个以n字符开头,后跟两个其他任意字符的文本,并且我们只想处理后面的两个字符。这可以通过take内置解析器来完成,如下面的代码片段所示:
extern crate nom;
use nom::{bytes::complete::take, character::complete::char, sequence::tuple, IResult};
fn parse_variable_text(input: &str)
-> IResult<&str, (char, &str)> {
tuple((char('n'), take(2usize)))(input)
}
fn main() {
println!("nghj: {:?}", parse_variable_text("nghj"));
println!("xghj: {:?}", parse_variable_text("xghj"));
println!("ng: {:?}", parse_variable_text("ng"));
}
它将打印以下输出:
nghj: Ok(("j", ('n', "gh")))
xghj: Err(Error(("xghj", Char)))
ng: Err(Error(("g", Eof)))
第一次调用是成功的一次。n字符被char('n')跳过,然后通过take(2usize)读取另外两个字符。这个解析器读取由其参数指定的字符数(必须是无符号数),并将这个字节序列作为字符串切片返回。要读取单个字符,只需调用take(1usize),它将返回一个字符串切片。
第二次调用失败,因为缺少起始的n。第三次调用失败,因为起始的n之后字符少于两个,因此生成了Eof(代表文件结束)错误。
在下一节中,我们将看到如何通过重复应用给定的解析器来解析一个或多个模式序列。
重复解析器
需要解析由解析器识别的重复表达式序列的情况相当常见。因此,必须多次应用该解析器,直到它失败。这种重复是通过几个组合器完成的——即many0和many1。
前者即使没有解析到表达式的任何实例也会成功——即它是零或更多组合器。后者只有在至少解析到表达式的至少一个实例时才会成功——即它是一或更多组合器。让我们看看如何识别一个或多个abc字符串序列,如下所示:
extern crate nom;
use nom::{bytes::complete::take, multi::many1, IResult};
fn repeated_text(input: &str) -> IResult<&str, Vec<&str>> {
many1(take(3usize))(input)
}
fn main() {
println!(": {:?}", repeated_text(""));
println!("ab: {:?}", repeated_text("abc"));
println!("abcabcabc: {:?}", repeated_text("abcabcabc"));
}
它将打印以下输出:
: Err(Error(("", Eof)))
abc: Ok(("", ["abc"]))
abcabcabcx: Ok(("x", ["abc", "abc", "abc"]))
第一次调用失败,因为空字符串不包含任何abc的实例。如果使用了many0组合器,这次调用将成功。
另外两次调用仍然成功,并返回找到的实例的Vec。
在本节中,我们介绍了最流行的解析技术:编译器编译器和解析器组合器。它们在构建解释器和编译器时都很有用。然后,我们介绍了将在本章剩余部分以及下一章部分使用的 Nom 解析器组合器库。
现在,我们已经对 Nom 有了足够的了解,可以开始看到本章的第一个项目。
calc_parser 项目
这个项目是Calc语言的解析器。它是一个程序,可以检查一个字符串并检测它是否遵循Calc语言的语法,使用上下文无关解析器,并在这种情况下,根据语言的语法提取字符串的逻辑结构。这样的结构通常被称为语法树,因为它具有树形结构,并且它代表了解析文本的语法。
语法树是一个内部数据结构,因此通常用户看不到,也不需要导出。但是,出于调试目的,这个程序将把这个数据结构格式化打印到控制台。
由本项目构建的程序期望一个 Calc 语言文件作为命令行参数。在项目的 data 文件夹中,有两个示例程序——即 sum.calc 和 bad_sum.calc。
第一个是 sum.calc,如下所示:
@a
@b
>a
>b
<a+b
它声明了两个变量 a 和 b,然后要求用户为它们输入值,并打印它们的和。
另一个程序,bad_sum.calc,与前者相同,除了第二行——即 @d——表示一个错误,因为后来使用了未声明的 b 变量。
要在第一个示例 Calc 程序上运行项目,请进入 calc_parser 文件夹,并输入以下内容:
cargo run data/sum.calc
这样的命令应该在控制台上打印以下文本:
Parsed program: [
Declaration(
"a",
),
Declaration(
"b",
),
InputOperation(
"a",
),
InputOperation(
"b",
),
OutputOperation(
(
(
Identifier(
"a",
),
[],
),
[
(
Add,
(
Identifier(
"b",
),
[],
),
),
],
),
),
]
从前面的代码中,首先声明了 "a" 标识符,然后是 "b" 标识符,接着是对名为 "a" 的变量的输入操作,然后是对名为 "b" 的变量的输入操作,最后是一个带有许多括号的输出操作。
在 OutputOperation 下的第一个开括号代表表达式项的开始,根据之前提出的语法,它必须出现在任何输出操作语句中。这样的表达式包含两个项——一个项和一个操作符-项对列表。
第一个项包含两个项——一个因子和一个操作符-因子对列表。因子是 "a" 标识符,操作符-因子对列表为空。然后,让我们将这个传递给操作符-项对列表。它只包含一个项,其中操作符是 Add,项是一个因子后跟一个操作符-因子对列表。因子是 "b" 标识符,列表为空。
如果运行 cargo run data/bad_sum.calc 命令,不会检测到错误,因为这个程序只执行语法分析而不检查语义上下文。输出与之前相同,除了第六行——即 "d" 而不是 "b"。
现在,让我们检查 Rust 程序的源代码。唯一的第三方库是 Nom,这是一个仅用于词法和语法分析阶段的库(因此被本章的所有项目使用,因为它们都需要解析)。
有两个源文件——main.rs 和 parser.rs。让我们首先看看 main.rs 源文件。
理解 main.rs 源文件
main.rs 源文件只包含 main 函数和 process_file 函数。main 函数只是检查命令行是否包含参数,并将其传递给 process_file 函数,同时附带可执行 Rust 程序的路径。
process_file 函数检查命令行参数是否以 .calc 结尾——即唯一期望的文件类型,然后它将文件的 内容读取到 source_code 字符串中,并通过调用 parser::parse_program(&source_code)(位于 parser.rs 源文件中)来解析该字符串。
当然,这样一个文件是一个整个程序的解析器,因此它返回一个Result值。这种返回值的Ok变体是由剩余代码和语法树组成的对。然后,通过以下给出的语句将语法树格式化输出:
println!("Parsed program: {:#?}", parsed_program);
当处理只有五行和 17 个字符的小型sum.calc文件时,这个单独的println!语句会输出之前显示的长输出,共有 35 行和 604 字节。当然,对于更长的程序,输出会更长。
接下来,让我们看看parser.rs源文件。
了解parser.rs源文件
parser.rs源文件包含一个针对语言语法中每个语法元素的解析函数。这些函数的详细说明如下:
| 函数 | 描述 |
|---|---|
parse_program |
这解析整个Calc程序。 |
parse_declaration |
这解析一个Calc声明语句,例如@total。 |
parse_input_statement |
这解析一个Calc输入语句,例如>addend。 |
parse_output_statement |
这解析一个Calc输出语句,例如<total。 |
parse_assignment |
这解析一个Calc赋值语句,例如total := addend * 2。 |
parse_expr |
这解析一个Calc表达式,例如addend * 2 + val / (incr + 1)。 |
parse_term |
这解析一个Calc项,例如val / (incr + 1)。 |
parse_factor |
这解析一个Calc因子,例如incr,或4.56e12,或(incr + 1)。 |
parse_subexpr |
这解析一个Calc括号表达式,例如(incr + 1)。 |
parse_identifier |
这解析一个Calc标识符,例如addend。 |
skip_spaces |
这解析零个或多个空白字符序列。 |
关于先前声明的语法,有一些解释是必要的——parse_subexpr解析器的任务是解析( <expr> )序列,丢弃括号,并使用parse_expr解析<expr>初始表达式。skip_spaces函数是一个解析器,其任务是解析零个或多个空白字符(空格、制表符、换行符),目的是忽略它们。
在成功的情况下,所有其他前面的函数都返回一个表示解析代码的数据结构。
由于内置的double解析器将用于解析浮点数,因此没有为文字数字提供解析器。
理解解析器所需类型
在这个文件中,除了解析器之外,还定义了几个类型来表示解析程序的结构。最重要的类型定义如下:
type ParsedProgram<'a> = Vec<ParsedStatement<'a>>;
前面的代码片段只是说明解析后的程序是一个解析语句的向量。
注意生命周期指定。为了保持最佳性能,最小化内存分配。当然,会分配向量,但解析的字符串不会分配;它们是引用输入字符串的字符串切片。因此,语法树依赖于输入字符串,其生命周期必须短于输入字符串。
前面的声明使用了 ParsedStatement 类型,其声明方式如下:
enum ParsedStatement<'a> {
Declaration(&'a str),
InputOperation(&'a str),
OutputOperation(ParsedExpr<'a>),
Assignment(&'a str, ParsedExpr<'a>),
}
前面的代码片段说明一个解析语句可以是以下之一:
-
一个封装正在声明的变量名称的声明
-
一个封装将要接收输入值的变量名称的输入语句
-
一个封装将要打印的解析表达式值的输出操作
-
一个封装将要接收计算值的变量名称和解析表达式的赋值语句,其值将被分配给该变量
这个声明使用了 ParsedExpr 类型,它声明如下:
type ParsedExpr<'a> = (ParsedTerm<'a>, Vec<(ExprOperator, ParsedTerm<'a>)>);
从前面的代码片段中,一个解析表达式是一个由一个解析项和零个或多个对组成的对,其中每个对由一个表达式运算符和一个解析项组成。
表达式运算符定义为 enum ExprOperator { Add, Subtract },而解析项定义为以下内容:
type ParsedTerm<'a> = (ParsedFactor<'a>, Vec<(TermOperator, ParsedFactor<'a>)>);
我们可以看到,一个解析项是一个由一个解析因子和零个或多个对组成的对,其中每个对由一个项运算符和一个解析因子组成。项运算符定义为 enum TermOperator { Multiply, Divide },而解析因子定义为以下内容:
enum ParsedFactor<'a> {
Literal(f64),
Identifier(&'a str),
SubExpression(Box<ParsedExpr<'a>>),
}
这个声明说明一个解析因子可以是一个封装数字的文本,或者是一个封装变量名称的标识符,或者是一个封装解析表达式的子表达式。
注意到 Box 的使用。这是必需的,因为任何解析表达式都包含一个包含一个能够包含解析表达式的 enum 解析因子的解析项。因此,我们有一个包含的无限递归。如果我们使用一个 Box,我们就在主结构之外分配内存。
因此,我们已经看到了将要被解析器代码使用的所有类型的定义。现在,让我们以自顶向下的方式查看代码。
查看解析器代码
我们现在可以查看用于解析整个程序的代码。以下代码片段显示了解析器的入口点:
pub fn parse_program(input: &str) -> IResult<&str, ParsedProgram> {
many0(preceded(
skip_spaces,
alt((
parse_declaration,
parse_input_statement,
parse_output_statement,
parse_assignment,
)),
))(input)
}
注意到其结果类型是 ParsedProgram,它是一个解析语句的向量。
主体使用 many0 解析组合子来接受零个或多个语句(一个空程序被认为是有效的)。实际上,为了解析一个语句,使用 preceded 组合子来组合两个解析器并丢弃第一个解析器的输出。它的第一个参数是 skip_spaces 解析器,因此它简单地跳过语句之间的可能空格。第二个参数是 alt 组合子,用于接受四种可能语句中的任何一个。
many0 组合子生成一个对象向量,这些对象由组合子的参数生成。这些参数生成解析语句,因此我们有了所需的解析语句向量。
因此,总结来说,这个函数接受零个或多个语句,可能由空白字符分隔。在成功的情况下,函数返回的值是一个向量,其元素是解析语句的表示。
Calc声明的解析器如下所示:
fn parse_declaration(input: &str) -> IResult<&str, ParsedStatement> {
tuple((char('@'), skip_spaces, parse_identifier))(input)
.map(|(input, output)| (input, ParsedStatement::Declaration(output.2)))
}
从前面的代码片段中,一个声明必须是一个由@字符、可选的空白字符和一个标识符组成的序列;因此,使用tuple组合器来链式调用这样的解析器。然而,我们并不关心那个初始字符也不关心那些空白字符。我们只想得到标识符的文本,封装在ParsedStatement中。
因此,在应用tuple之后,结果是映射到一个Declaration对象,其参数是由tuple生成的第三个项。
以下代码片段显示了Calc输入语句的解析器:
fn parse_input_statement(input: &str) -> IResult<&str, ParsedStatement> {
tuple((char('>'), skip_spaces, parse_identifier))(input)
.map(|(input, output)| (input, ParsedStatement::InputOperation(output.2)))
}
Calc输入语句的解析器与前面的解析器非常相似。它只是查找>字符,并返回一个封装由parse_identifier返回的字符串的InputOperation变体。
以下代码片段显示了Calc输出语句的解析器:
fn parse_output_statement(input: &str) -> IResult<&str, ParsedStatement> {
tuple((char('<'), skip_spaces, parse_expr))(input)
.map(|(input, output)| (input, ParsedStatement::OutputOperation(output.2)))
}
此外,从前面代码的解析器与前面两个解析器相似。它只是查找<字符,解析一个表达式而不是标识符,并返回一个封装由parse_expr返回的解析表达式的OutputOperation。
最后一种Calc语句是赋值。它的解析器如下所示:
fn parse_assignment(input: &str) -> IResult<&str, ParsedStatement> {
tuple((
parse_identifier,
skip_spaces,
tag(":="),
skip_spaces,
parse_expr,
))(input)
.map(|(input, output)| (input, ParsedStatement::Assignment(output.0, output.4)))
}
这与前面的语句解析器有所不同。它链式调用五个解析器——一个标识符、一些可能的空白字符、字符串:=、一些可能的空白字符和一个表达式。结果是封装了元组中解析的第一个和最后一个项的Assignment变体——即标识符字符串和解析的表达式。
我们已经遇到了表达式解析器的使用,它定义如下:
fn parse_expr(input: &str) -> IResult<&str, ParsedExpr> {
tuple((
parse_term,
many0(tuple((
preceded(
skip_spaces,
alt((
map(char('+'), |_| ExprOperator::Add),
map(char('-'), |_| ExprOperator::Subtract),
)),
),
parse_term,
))),
))(input)
}
从前面的代码中,要解析一个表达式,首先必须解析一个项(parse_term),然后是零个或多个(many0)由一个运算符和一个项(parse_term)组成的对(tuple)。运算符可以由空白字符(skip_spaces)前导,这些空白字符必须被丢弃(preceded),并且它可以是加号字符(char('+')或减号字符(char('-')。但我们想用ExprOperator值替换这些字符。结果对象已经具有预期的类型,因此不需要其他map转换。
项的解析器与表达式的解析器类似。如下所示:
fn parse_term(input: &str) -> IResult<&str, ParsedTerm> {
tuple((
parse_factor,
many0(tuple((
preceded(
skip_spaces,
alt((
map(char('*'), |_| TermOperator::Multiply),
map(char('/'), |_| TermOperator::Divide),
)),
),
parse_factor,
))),
))(input)
}
parse_expr和parse_term之间的唯一区别如下:
-
当
parse_expr调用parse_term时,parse_term调用parse_factor。 -
其中
parse_expr将'+'字符映射到ExprOperator::Add值,将'-'字符映射到ExprOperator::Subtract值,parse_term将'*'字符映射到TermOperator::Multiply值,将'/'字符映射到TermOperator::Divide值。 -
其中
parse_expr在返回值类型中有ParsedExpr类型,parse_term有ParsedTerm类型。
因子的解析器再次遵循相对语法规则,其返回类型的定义ParsedFactor如下代码片段所示:
fn parse_factor(input: &str) -> IResult<&str, ParsedFactor> {
preceded(
skip_spaces,
alt((
map(parse_identifier, ParsedFactor::Identifier),
map(double, ParsedFactor::Literal),
map(parse_subexpr, |expr|
ParsedFactor::SubExpression(Box::new(expr))
),
)),
)(input)
}
此解析器会丢弃可能的初始空格,然后交替解析一个标识符、一个数字或一个子表达式。数字解析器是double,这是一个 Nom 内置函数,它根据 Rust f64字面量的语法解析数字。
所有这些解析的返回类型都是错误的,因此,使用map组合器来生成它们的返回值。对于标识符,只需要引用将自动使用parse_identifier函数返回的值作为参数构建的Identifier变体。一个等效但更冗长的代码将是map(parse_identifier, |id| ParsedFactor::Identifier(id))。
类似地,字面量从f64类型转换为ParsedFactor::Literal(f64)类型,子表达式被装箱并封装在SubExpression变体中。
子表达式的解析必须仅匹配并丢弃空格和括号,如下代码片段所示:
fn parse_subexpr(input: &str) -> IResult<&str, ParsedExpr> {
delimited(
preceded(skip_spaces, char('(')),
parse_expr,
preceded(skip_spaces, char(')')),
)(input)
内部的parse_expr解析器是唯一一个将其输出传递给结果的。为了解析一个标识符,使用了一个内置解析器,如下所示:
fn parse_identifier(input: &str) -> IResult<&str, &str> {
alpha1(input)
}
alpha1解析器返回一个由一个或多个字母组成的字符串。不允许数字和其他字符。通常,这不会命名为解析器,而是词法分析器、词法分析器、扫描器或标记器,但 Nom 没有区分。
最后,处理空格的小解析器(或词法分析器)如下所示:
fn skip_spaces(input: &str) -> IResult<&str, &str> {
let chars = " \t\r\n";
take_while(move |ch| chars.contains(ch))(input)
}
它使用我们尚未见过的组合器take_while。它接收一个返回布尔值(即谓词)的闭包作为参数。这样的闭包会在任何输入字符上被调用。如果闭包返回true,解析器将继续,否则停止。因此,它返回输入字符序列的最大序列,对于该序列,谓词值为true。
在我们的情况下,谓词检查字符是否包含在四个字符的切片中。
因此,我们已经看到了Calc语言的全部解析器。当然,现实世界的解析器要复杂得多,但概念是相同的。
在本节中,我们看到了如何使用 Nom 库通过 CFG 解析用Calc语言编写的程序。这是应用上下文相关语法(CSG)以及随后解释器或编译器的先决条件。
注意,这个程序解析器将任何字符序列都视为有效的标识符,而不检查变量在使用前是否已定义,或者变量是否被多次定义。对于此类检查,必须进行进一步的处理。这将在下一个项目中看到。
calc_analyzer 项目
前一个项目遵循 CFG 来构建解析器。这很好,但有一个大问题:Calc语言不是上下文无关的。事实上,有两个问题,如下所示:
-
在输入语句、输出语句和赋值语句中,任何变量的使用都必须先声明该变量。
-
任何变量都不应被声明多次。
这样的要求无法在上下文无关语言中表达。
此外,Calc只有一个数据类型——即浮点数——但考虑一下如果它也有字符串类型会怎样。你可以加减两个数字,但不能减去两个字符串。如果声明了一个名为a的变量为number类型,而另一个名为b的变量为string类型,你不能将a赋值给b,反之亦然。
通常情况下,对一个变量的操作取决于声明该变量时使用的类型。此外,这种约束无法在 CFG 中表达。
为了避免定义一个难以指定和解析的正式上下文相关语法(CDG),通常的做法是以非正式的方式定义这样的规则,称为语义规则,然后对语法树进行后处理以检查这些规则的有效性。
在这里,我们将执行此类语义检查的模块称为analyzer(使用一个语义检查器来验证变量的一些约束,例如变量在使用前必须被定义,以及变量不能被定义多次的事实),而calc_analyzer项目则是将此模块添加到解析器中的项目。
在下一节中,我们将看到analyzer模块的架构。
检查解析程序的变量
分析器从解析器结束的地方开始——一个包含标识符字符串、文字值和运算符的语法树。因此,它不再需要源代码。为了完成其任务,它遍历这样的树,每次遇到变量声明时,都必须确保它尚未被声明,而每次遇到变量使用时,都必须确保它已经被声明。
为了在不遍历语法树的情况下执行此类任务,还需要另一个数据结构。这样的数据结构是在遍历语法树时收集已声明的变量集合,当遇到变量声明时,分析器会在此集合中查找该变量的先前声明;如果找到,则是一个重复声明的错误;否则,将向集合中添加一个条目。
此外,当遇到变量使用时,分析器会在这样的集合中查找相同变量的先前声明,尽管这次,如果没有找到,则是一个缺少声明的错误。对于我们的简单语言,这样的集合只包含变量,但在更复杂的语言中,它将包含任何类型的标识符——常量、函数、命名空间等等。标识符的另一个名称是符号;因此,通常,这个集合被称为符号表。
为了对Calc程序进行变量检查,我们的符号表只需要存储变量的名称,尽管我们希望我们的分析器执行一些其他任务,这些任务在构建解释器时将非常有用。解释器在运行程序时必须在某处存储标识符的值,而不仅仅是它们的名称,因为我们已经有一个存储每个变量名称的变量集合,我们可以在变量的条目中为每个变量的值预留空间。当我们为Calc构建解释器时,这将非常有用。
但这超出了分析器在为解释器做准备时能做的事情。解释器必须扫描一种语法树来执行语句,当它遇到变量时,它必须查找其值。解析器生成的语法树包含变量的标识符,而不是它们的值,所以每当解释器找到一个变量时,它都应该在符号表中搜索那个字符串。
但我们想要一个快速的解释器,而字符串查找并不像使用指针或数组索引那样快。所以,为了准备快速解释,当分析器访问语法树时,它会将每个标识符替换为其在符号表中的位置索引。嗯,在 Rust 中,字符串不能被数字替换,所以一种可能的技术是在语法树中预留一个索引字段,并在变量在符号表中找到时填充该索引。
在这里,选择了另一种技术。分析器在访问语法树的同时,构建了一个并行分析的树,结构非常相似,但使用符号表中的索引而不是标识符。这样的树,加上为变量值预留空间的符号表,将非常适合解释程序。
因此,首先,让我们看看这个项目做了什么。打开calc_analyzer文件夹,并输入以下命令:cargo run data/sum.calc。
以下输出应出现在控制台上:
Symbol table: SymbolTable {
entries: [
(
"a",
0.0,
),
(
"b",
0.0,
),
],
}
Analyzed program: [
Declaration(
0,
),
Declaration(
1,
),
InputOperation(
0,
),
InputOperation(
1,
),
OutputOperation(
(
(
Identifier(
0,
),
[],
),
[
(
Add,
(
Identifier(
1,
),
[],
),
),
],
),
),
]
之前的代码程序,就像之前的那个一样,没有为用户输出。它将源代码解析成语法树,然后分析该语法树,构建符号表和分析程序。输出只是这些数据结构的格式化打印。
首先输出的结构是符号表。它有两个条目——a变量,其初始值为0.0,以及b变量,其初始值也为0.0。
然后,是分析后的程序,它与上一个项目中打印的解析程序非常相似。唯一的不同之处在于,所有"a"的出现都被替换为0,所有"b"的出现都被替换为1。这些数字是这些变量在符号表中的位置。
该项目扩展了前面的项目。parser.rs源文件是相同的,另外增加了两个文件——symbol_table.rs和analyzer.rs。但让我们首先从main.rs文件开始。
理解main.rs文件
此文件执行了前面项目中执行的所有操作,除了最后的格式化打印,它被以下行替换:
let analyzed_program;
let mut variables = symbol_table::SymbolTable::new();
match analyzer::analyze_program(&mut variables, &parsed_program) {
Ok(analyzed_tree) => {
analyzed_program = analyzed_tree;
}
Err(err) => {
eprintln!("Invalid code in '{}': {}", source_path, err);
return;
}
}
println!("Symbol table: {:#?}", variables);
println!("Analyzed program: {:#?}", analyzed_program);
从前面的代码片段中,分析器构建的两个数据结构首先声明——analyzed_program是带有变量索引的语法树,而variables是符号表。
所有的分析都是由analyze_program函数执行的。如果成功,它将返回分析后的程序,最后打印出这两个结构。
现在,让我们来检查符号表(symbol_table.rs)的实现。
查看 symbol_table.rs 文件
在symbol_table.rs文件中,有一个SymbolTable类型的实现,这是一个包含源代码中找到的标识符的集合。符号表的每个条目描述一个变量。这样的条目必须至少包含变量的名称。在类型语言中,它还必须包含该变量的数据类型的表示,尽管Calc不需要那个,因为它只有一个数据类型。
如果语言支持在块、函数、类或更大的结构(编译单元、模块、命名空间或包)中进行作用域,那么必须有多个符号表或一个指定这种作用域的符号表,尽管Calc不需要那个,因为它只有一个作用域。
符号表主要用于检查标识符和将代码翻译成另一种语言,尽管它也可以用于解释代码。当解释器评估一个表达式时,它需要获取该表达式中使用的变量的当前值。符号表可以用来存储任何变量的当前值,并提供这些值给解释器。因此,如果你想支持解释器,你的符号表也应该为定义的变量的当前值预留空间。
在下一个项目中,我们将创建一个解释器,因此,为了支持它,在这里,我们在符号表的每个条目中添加了一个字段,用于存储变量的当前值。我们符号表的每个条目的类型将是(String, f64),其中第一个字段是变量的名称,第二个字段是变量的当前值。这个值字段将在解释程序时被访问。
我们如何访问符号表中的条目?在分析程序时,我们必须搜索一个字符串,因此哈希表将提供最佳性能。然而,在解释代码时,我们可以用索引替换标识符,因此使用向量中的索引将提供最佳性能。在这里,为了简单起见,选择了一个向量,如果没有很多变量,这已经足够好了。因此,我们的定义如下所示:
struct SymbolTable {
entries: Vec<(String, f64)>,
}
对于 SymbolTable 类型,实现了三种方法,如下面的代码片段所示:
fn new() -> SymbolTable
fn insert_symbol(&mut self, identifier: &str) -> Result<usize, String>
fn find_symbol(&self, identifier: &str) -> Result<usize, String>
new 方法简单地创建一个空的符号表。
insert_symbol 方法试图将指定的标识符插入到符号表中。如果没有具有该名称的标识符,则为此名称添加一个条目,默认值为零,并且 Ok 结果是新条目的索引。否则,在 Err 结果中返回错误消息 Error: Identifier '{}' declared several times.。
find_symbol 方法试图在符号表中找到指定的标识符。如果找到,则 Ok 结果是找到条目的索引。否则,在 Err 结果中返回错误消息 Error: Identifier '{}' used before having been declared.。
现在,让我们看看分析器的源文件。
简单地查看 analyzer.rs 文件
如前所述,分析阶段读取解析阶段创建的分层结构,并构建另一个具有 AnalyzedProgram 类型的分层结构。因此,此模块必须声明此类以及它需要的所有类型,与 ParsedProgram 类型并行,如下所示:
type AnalyzedProgram = Vec<AnalyzedStatement>;
任何分析程序是一系列分析语句,如下面的代码片段所示:
enum AnalyzedStatement {
Declaration(usize),
InputOperation(usize),
OutputOperation(AnalyzedExpr),
Assignment(usize, AnalyzedExpr),
}
任何分析语句都是以下之一:
-
通过索引引用变量的声明
-
通过索引引用变量的输入操作
-
包含分析表达式的输出操作
-
通过索引引用变量并包含分析表达式的赋值操作
任何分析表达式是一个分析项和一个表达式运算符与一个分析项的零个或多个对的序列,如下面的代码片段所示:
type AnalyzedExpr = (AnalyzedTerm, Vec<(ExprOperator, AnalyzedTerm)>);
任何分析项是一个分析因子和一个项运算符与一个分析因子的零个或多个对的序列,如下面的代码片段所示:
type AnalyzedTerm = (AnalyzedFactor, Vec<(TermOperator, AnalyzedFactor)>);
任何分析因子是一个包含 64 位浮点数的字面量,或通过索引引用变量的标识符,或包含对堆分配的分析表达式的引用的子表达式,如下面的代码片段所示:
pub enum AnalyzedFactor {
Literal(f64),
Identifier(usize),
SubExpression(Box<AnalyzedExpr>),
}
分析器的入口点如下面的代码片段所示:
fn analyze_program(variables: &mut SymbolTable, parsed_program: &ParsedProgram)
-> Result<AnalyzedProgram, String> {
let mut analyzed_program = AnalyzedProgram::new();
for statement in parsed_program {
analyzed_program.push(analyze_statement(variables, statement)?);
}
Ok(analyzed_program)
}
analyze_program函数,如同此模块的所有函数一样,获取符号表的可变引用,因为它们都必须直接或间接地读取和写入符号。此外,它获取一个解析程序的引用。如果函数成功,它将更新符号表并返回一个分析程序;否则,它可能留下部分更新的符号表并返回一个错误消息。
主体简单地创建一个空的已分析程序并处理所有解析语句,通过调用analyze_statement。任何解析语句都会被分析,分析后的语句被添加到已分析程序中。对于任何语句分析失败,将立即返回此函数的错误。
因此,我们需要了解如何分析语句,如下所示:
fn analyze_statement(
variables: &mut SymbolTable,
parsed_statement: &ParsedStatement,
) -> Result<AnalyzedStatement, String> {
match parsed_statement {
ParsedStatement::Assignment(identifier, expr) => {
let handle = variables.find_symbol(identifier)?;
let analyzed_expr = analyze_expr(variables, expr)?;
Ok(AnalyzedStatement::Assignment(handle, analyzed_expr))
}
ParsedStatement::Declaration(identifier) => {
let handle = variables.insert_symbol(identifier)?;
Ok(AnalyzedStatement::Declaration(handle))
}
ParsedStatement::InputOperation(identifier) => {
let handle = variables.find_symbol(identifier)?;
Ok(AnalyzedStatement::InputOperation(handle))
}
ParsedStatement::OutputOperation(expr) => {
let analyzed_expr = analyze_expr(variables, expr)?;
Ok(AnalyzedStatement::OutputOperation(analyzed_expr))
}
}
}
analyze_statement函数将接收到的解析语句与四种语句类型进行匹配,提取相应变体的成员。
在声明中包含的标识符从未被定义过,因此它应该不存在于符号表中。因此,在处理这类语句时,此标识符将通过使用let handle = variables.insert_symbol(identifier)?Rust 语句插入到符号表中。如果插入失败,错误将传递出此函数。如果插入成功,符号的位置将存储在一个局部变量中。
在赋值和输入操作中包含的标识符应该已经被定义,因此它应该包含在符号表中。因此,在处理这类语句时,标识符将通过使用let handle = variables.find_symbol(identifier)?Rust 语句在符号表中查找。
在赋值和输出操作中包含的表达式通过let analyzed_expr = analyze_expr(variables, expr)?Rust 语句进行分析。如果分析失败,错误将传递出此函数。如果分析成功,分析后的结果表达式将存储在一个局部变量中。
对于四种Calc语句类型中的任何一种,如果没有遇到错误,函数将返回一个包含相应分析语句变体的成功结果。
因此,我们需要了解如何分析表达式,如下所示:
fn analyze_expr(
variables: &mut SymbolTable,
parsed_expr: &ParsedExpr,
) -> Result<AnalyzedExpr, String> {
let first_term = analyze_term(variables, &parsed_expr.0)?;
let mut other_terms = Vec::<(ExprOperator, AnalyzedTerm)>::new();
for term in &parsed_expr.1 {
other_terms.push((term.0, analyze_term(variables, &term.1)?));
}
Ok((first_term, other_terms))
}
接收到的解析表达式是一个对——&parsed_expr.0是一个解析项,&parsed_expr.1是一个包含表达式运算符和分析项对的向量。我们想要构建一个具有相同结构的分析表达式。
因此,首先分析第一个项。然后,创建一个空的包含表达式运算符和分析项对的列表;这就是分析向量。然后,对于解析向量的每个项目,构建一个项目并添加到分析向量中。最后,返回第一个分析项和其他分析项的向量对。
因此,我们需要了解如何通过以下代码分析项:
fn analyze_term(
variables: &mut SymbolTable,
parsed_term: &ParsedTerm,
) -> Result<AnalyzedTerm, String> {
let first_factor = analyze_factor(variables, &parsed_term.0)?;
let mut other_factors = Vec::<(TermOperator, AnalyzedFactor)>::new();
for factor in &parsed_term.1 {
other_factors.push((factor.0, analyze_factor(variables,
&factor.1)?));
}
Ok((first_factor, other_factors))
}
上述程序与之前的程序非常相似。首先解析的因子被分析以获得第一个分析因子,其他解析因子被分析以获得其他分析因子。
因此,我们需要了解如何分析因子。如下所示:
fn analyze_factor(
variables: &mut SymbolTable,
parsed_factor: &ParsedFactor,
) -> Result<AnalyzedFactor, String> {
match parsed_factor {
ParsedFactor::Literal(value) =>
Ok(AnalyzedFactor::Literal(*value)),
ParsedFactor::Identifier(name) => {
Ok(AnalyzedFactor::Identifier(variables.find_symbol(name)?))
}
ParsedFactor::SubExpression(expr) =>
Ok(AnalyzedFactor::SubExpression(
Box::<AnalyzedExpr>::new(analyze_expr(variables, expr)?),
)),
}
}
analyze_factor 函数的逻辑如下:
-
如果要分析解析的因子是一个字面量,则返回一个分析后的字面量,其中包含相同的值。
-
如果它是一个标识符,则返回一个分析后的标识符,其中包含解析到的标识符在符号表中的索引。如果未找到,则返回错误。
-
如果要分析解析的因子是一个子表达式,则返回一个子表达式,其中包含一个通过分析解析表达式获得的包装分析表达式,如果成功。如果失败,则返回错误。
因此,我们已经完成了分析器模块的审查。
在本节中,我们看到了如何分析上一节创建的解析器的结果,应用了 CSG(这是构建解释器或编译器所需的)。下一个项目将展示我们如何使用和执行分析程序。
calc_interpreter 项目
最后,我们到达了可以实际运行我们的 Calc 程序的项目。
要运行它,进入 calc_interpreter 文件夹,并输入 cargo run。编译后,以下文本将出现在控制台上:
* Calc interactive interpreter *
>
第一行是介绍信息,第二行是提示符。现在,我们输入以下内容作为示例:
@a >a @b b := a + 2 <b
在您按下 Enter 后,此 Calc 程序将被执行。声明了 a 变量,当执行输入语句时,控制台将出现一个问号。输入 5 并按 Enter。
程序继续声明 b 变量,将其赋值为 a + 2 表达式的值,然后打印 b 的值为 7。然后,程序结束,提示符重新出现。
因此,在屏幕上,将有以下内容:
* Calc interactive interpreter *
> @a >a @b b := a + 2 <b
? 5
7
>
解释器此外还有一些特定命令,以便能够运行 Calc 程序。如果您输入 v(代表 变量)然后按 Enter,您将看到以下内容:
> v
Variables:
a: 5
b: 7
>
此命令已转储符号表的内容,显示迄今为止声明的所有变量及其当前值。现在,您可以使用这些变量及其当前值输入其他 Calc 命令。
另一个解释器命令是 c(代表清除变量),它清空符号表。最后一个命令是 q(代表退出),它终止解释器。
那么 Calc 命令是如何执行的?如果您有一个分析程序树,以及包含变量值空间的关联符号表,这相当简单。只需将语义(即行为)应用于任何分析元素,程序就会自行运行。
此外,此项目扩展了先前的项目。parser.rs和analyzer.rs源文件是相同的;向symbol_table.rs文件中添加了一些行,并添加了一个其他文件——executor.rs。但让我们从main.rs文件开始。
了解 main.rs 文件
此文件包含两个函数,除了main函数外,还有run_interpreter和input_command。
main函数仅调用run_interpreter,因为这是解释器的目的。此函数具有以下结构:
fn run_interpreter() {
eprintln!("* Calc interactive interpreter *");
let mut variables = symbol_table::SymbolTable::new();
loop {
let command = input_command();
if command.len() == 0 {
break;
}
*<<process interpreter commands>>*
*<<parse, analyze, and execute the commands>>*
}
}
在打印介绍消息并创建符号表后,函数进入一个无限循环。
循环的第一个语句是调用input_command函数,该函数从控制台(或从文件或管道,如果标准输入被重定向)读取命令。然后,如果到达 EOF,则退出循环,整个程序也随之退出。
否则,处理解释器特定的命令,如果在输入文本中没有这样的命令,它将被解析并分析为一个Calc程序,然后执行分析后的程序。
以下代码块显示了解释器命令的实现方式:
match command.trim() {
"q" => break,
"c" => {
variables = symbol_table::SymbolTable::new();
eprintln!("Cleared variables.");
}
"v" => {
eprintln!("Variables:");
for v in variables.iter() {
eprintln!(" {}: {}", v.0, v.1);
}
}
一个q(退出)命令简单地跳出循环。一个c(清除)命令用一个新的符号表替换当前的符号表。一个v(变量)命令遍历符号表条目,并打印每个条目的名称和当前值。
如果输入文本不是这样的单字母命令之一,它将被以下代码处理:
trimmed_command => match parser::parse_program(&trimmed_command) {
Ok((rest, parsed_program)) => {
if rest.len() > 0 {
eprintln!("Unparsed input: `{}`.", rest)
} else {
match analyzer::analyze_program(&mut variables,
&parsed_program) {
Ok(analyzed_program) => {
executor::execute_program(&mut variables,
&analyzed_program)
}
Err(err) => eprintln!("Error: {}", err),
}
}
}
Err(err) => eprintln!("Error: {:?}", err),
},
parser::parse_program函数,如果成功,创建一个解析程序对象。在出错或输入中仍有待解析的内容的情况下,打印错误消息并丢弃命令。
否则,analyzer::analyze_program使用这样的解析程序创建(如果成功)一个分析程序对象。在出错的情况下,打印错误消息并丢弃命令。
最后,通过调用executor::execute_program执行分析程序。现在,让我们看看symbol_table.rs文件中有什么变化。
快速查看 symbol_table.rs 文件
已向SymbolTable类型的实现中添加了以下签名的三个函数:
pub fn get_value(&self, handle: usize) -> f64
pub fn set_value(&mut self, handle: usize, value: f64)
pub fn iter(&self) -> std::slice::Iter<(String, f64)>
get_value函数根据变量的索引获取变量的值。set_value函数根据索引和要分配的值设置变量的值。iter函数返回一个只读迭代器,用于遍历符号表中存储的变量。对于每个变量,返回一个包含名称和值的对。
接下来,我们看到实现解释器核心的模块。
理解 executor.rs 文件
此模块没有声明类型,因为它只使用其他模块中声明的类型。入口点是能够执行整个程序的功能,如下所示:
pub fn execute_program(variables: &mut SymbolTable, program: &AnalyzedProgram) {
for statement in program {
execute_statement(variables, statement);
}
}
它接收一个可变引用到符号表和一个引用到已分析程序,并简单地对该程序中的任何语句调用execute_statement函数。
以下代码块显示了最后一个函数(这更复杂):
fn execute_statement(variables: &mut SymbolTable, statement: &AnalyzedStatement) {
match statement {
AnalyzedStatement::Assignment(handle, expr) => {
variables.set_value(*handle, evaluate_expr(variables, expr));
}
AnalyzedStatement::Declaration(handle) => {}
AnalyzedStatement::InputOperation(handle) => {
let mut text = String::new();
eprint!("? ");
std::io::stdin()
.read_line(&mut text)
.expect("Cannot read line.");
let value = text.trim().parse::<f64>().unwrap_or(0.);
variables.set_value(*handle, value);
}
AnalyzedStatement::OutputOperation(expr) => {
println!("{}", evaluate_expr(variables, expr));
}
}
}
根据所使用的语句类型,执行不同的操作。对于赋值,它调用evaluate_expr函数以获取相关表达式的值,并使用set_value将该值赋给相关变量。
对于声明,不需要做任何事情,因为变量插入符号表及其初始化已经由分析器完成。
对于输入操作,打印一个问号作为提示,然后读取一行并将其解析为f64数字。如果转换失败,则使用零。然后将该值存储到符号表中作为变量的新值。
对于输出操作,评估表达式并打印结果值。以下代码显示了如何评估Calc表达式:
fn evaluate_expr(variables: &SymbolTable, expr: &AnalyzedExpr) -> f64 {
let mut result = evaluate_term(variables, &expr.0);
for term in &expr.1 {
match term.0 {
ExprOperator::Add => result += evaluate_term(variables,
&term.1),
ExprOperator::Subtract => result -= evaluate_term(variables,
&term.1),
}
}
result
}
首先,通过调用evaluate_term函数来评估第一个项,并将其值存储为一个临时结果。
然后,对于其他每个项,根据所使用的表达式运算符的类型,评估该项并获得的值加到或减去临时结果。
以下代码块显示了如何评估Calc项:
fn evaluate_term(variables: &SymbolTable, term: &AnalyzedTerm) -> f64 {
let mut result = evaluate_factor(variables, &term.0);
for factor in &term.1 {
match factor.0 {
TermOperator::Multiply => result *= evaluate_factor(
variables, &factor.1),
TermOperator::Divide => result /= evaluate_factor(
variables, &factor.1),
}
}
result
}
上述代码块显示了与之前类似的功能。它使用evaluate_factor函数评估当前项的所有因子,如下面的代码片段所示:
fn evaluate_factor(variables: &SymbolTable, factor: &AnalyzedFactor) -> f64 {
match factor {
AnalyzedFactor::Literal(value) => *value,
AnalyzedFactor::Identifier(handle) => variables.get_value(*handle),
AnalyzedFactor::SubExpression(expr) => evaluate_expr(variables, expr),
}
}
要评估一个因子,需要考虑因子的类型。literal的值只是包含的值。identifier的值通过调用get_value从符号表中获得。
通过评估它包含的表达式来获得SubExpression的值。因此,我们已经看到了执行一个Calc程序所需的所有内容。
在本节中,我们看到了如何使用上下文相关分析的结果来解释Calc程序。这种解释可以是交互式的,通过读取-评估-打印循环(REPL)或通过处理用Calc语言编写的文件。
在下一个项目中,我们将看到如何将Calc程序翻译成 Rust 程序。
calc_compiler 项目
拥有一个已分析程序(及其匹配的符号表),也容易创建一个将其翻译成另一种语言的程序。为了避免引入一种新语言,这里使用了 Rust 语言作为目标语言,但翻译到其他高级语言也不会更困难。
要运行它,进入calc_compiler文件夹并输入cargo run data/sum.calc。在编译项目后,程序将打印以下内容:
Compiled data/sum.calc to data/sum.rs
如果你进入data子文件夹,你会找到一个名为sum.rs的新文件,其中包含以下代码:
use std::io::Write;
#[allow(dead_code)]
fn input() -> f64 {
let mut text = String::new();
eprint!("? ");
std::io::stderr().flush().unwrap();
std::io::stdin()
.read_line(&mut text)
.expect("Cannot read line.");
text.trim().parse::<f64>().unwrap_or(0.)
}
fn main() {
let mut _a = 0.0;
let mut _b = 0.0;
_a = input();
_b = input();
println!("{}", _a + _b);
}
如果你喜欢,你可以使用rustc sum.rs命令来编译它,然后你可以运行生成的可执行文件。
对于任何编译的Calc程序,此文件始终相同,直到包含fn main() {的行。input例程是Calc运行时库。
Rust 生成的代码的其余部分对应于Calc语句。注意,所有变量都是可变的,并初始化为0.0,因此它们的类型是f64。变量的名称以前缀下划线开头,以防止与 Rust 关键字冲突。
实际上,这个项目还包含了前面项目中看到的解释器。如果你不带命令行参数运行项目,将启动一个交互式解释器。
让我们看看下一部分的源代码。此外,这个项目扩展了前面的项目。parser.rs、analyzer.rs和executor.rs源文件相同;向symbol_table.rs文件添加了一些行,并添加了一个其他文件——compiler.rs。
只向symbol_table.rs文件添加了一个小的函数。其签名如下所示:
pub fn get_name(&self, handle: usize) -> String
它允许根据索引获取标识符的名称。
但让我们从main.rs文件开始。
简要查看main.rs文件
main函数首先检查命令行参数。如果没有参数,则调用run_interpreter函数,与calc_interpreter项目中使用的相同。
相反,如果有单个参数,则会在其上调用process_file函数。这与calc_analyzer项目中使用的类似。只有两个区别。一个是插入的语句,如下面的代码片段所示:
let target_path = source_path[0..source_path.len() - CALC_SUFFIX.len()].to_string() + ".rs";
这生成了结果 Rust 文件的路径。另一个是替换两个结束语句,这些语句打印分析的结果,如下所示:
match std::fs::write(
&target_path,
compiler::translate_to_rust_program(&variables, &analyzed_program),
) {
Ok(_) => eprintln!("Compiled {} to {}.", source_path, target_path),
Err(err) => eprintln!("Failed to write to file {}: ({})", target_path, err),
}
这将翻译成 Rust 代码,获取一个多行字符串,并将该字符串写入目标文件。
因此,我们需要检查定义在compiler.rs源文件中的compiler模块。
理解compiler.rs文件
此模块不定义类型,因为它使用其他模块中定义的类型。与解析器、分析器和解释器一样,它为每种语言构造有一个函数,并通过遍历已分析程序树来执行翻译。
入口点从以下代码开始:
pub fn translate_to_rust_program(
variables: &SymbolTable,
analyzed_program: &AnalyzedProgram,
) -> String {
let mut rust_program = String::new();
rust_program += "use std::io::Write;\n";
...
这个函数,就像这个模块中的所有其他函数一样,获取对符号表和已分析程序的不可变引用。它返回一个包含 Rust 代码的String。首先创建一个空字符串,然后将所需的行附加到它。
此函数的最后部分如下所示:
...
for statement in analyzed_program {
rust_program += " ";
rust_program += &translate_to_rust_statement(&variables,
statement);
rust_program += ";\n";
}
rust_program += "}\n";
rust_program
}
对于任何Calc语句,都会调用translate_to_rust_statement函数,并将它返回的 Rust 代码附加到字符串上。
将Calc语句翻译成 Rust 代码的函数体如下所示:
match analyzed_statement {
AnalyzedStatement::Assignment(handle, expr) => format!(
"_{} = {}",
variables.get_name(*handle),
translate_to_rust_expr(&variables, expr)
),
AnalyzedStatement::Declaration(handle) => {
format!("let mut _{} = 0.0", variables.get_name(*handle))
}
AnalyzedStatement::InputOperation(handle) => {
format!("_{} = input()", variables.get_name(*handle))
}
AnalyzedStatement::OutputOperation(expr) => format!(
"println!(\"{}\", {})",
"{}",
translate_to_rust_expr(&variables, expr)
),
}
为了翻译一个赋值,通过调用get_name函数从符号表中获取变量的名称,并通过调用translate_to_rust_expr函数获取对应表达式的代码。对于其他语句也执行同样的操作。
为了翻译一个表达式,使用了以下函数:
fn translate_to_rust_expr(variables: &SymbolTable, analyzed_expr: &AnalyzedExpr) -> String {
let mut result = translate_to_rust_term(variables, &analyzed_expr.0);
for term in &analyzed_expr.1 {
match term.0 {
ExprOperator::Add => {
result += " + ";
result += &translate_to_rust_term(variables, &term.1);
}
ExprOperator::Subtract => {
result += " - ";
result += &translate_to_rust_term(variables, &term.1);
}
}
}
result
}
这些术语通过调用translate_to_rust_term函数进行翻译。加法和减法使用 Rust 字符串字面量" + "和" - "进行翻译。
项的翻译与表达式的翻译非常相似,但使用项运算符和调用translate_to_rust_factor函数。
此函数体定义如下:
match analyzed_factor {
AnalyzedFactor::Literal(value) => value.to_string() + "f64",
AnalyzedFactor::Identifier(handle) => "_".to_string()
+ &variables.get_name(*handle),
AnalyzedFactor::SubExpression(expr) => {
"(".to_string() + &translate_to_rust_expr(variables, expr) + ")"
}
}
对于字面量的翻译,它被转换成字符串,并附加"f64"来强制其类型。对于标识符的翻译,其名称从符号表中获取。对于子表达式的翻译,内部表达式被翻译,结果被括号包围。
在本节中,我们了解了如何在 Rust 中构建一个程序,该程序读取Calc程序并生成等效的 Rust 程序。这样的程序然后可以使用rustc命令进行编译。
摘要
在本章中,我们了解了一些编程语言的理论以及处理它们的算法。
尤其是我们可以看到,编程语言的语法可以用形式文法来表示。形式文法有一个有用的分类——正则语言、上下文无关语言和上下文相关语言。
编程语言属于第三类,但通常它们首先被词法分析器解析为正则语言。结果是作为上下文无关语言由解析器解析,然后分析以考虑上下文相关特性。
我们了解了处理形式语言(如编程语言或标记语言)文本的最流行技术——编译器-编译器和解析器组合器。特别是,我们看到了如何使用 Nom crate,这是一个解析器组合器库。
我们看到了许多内置的 Nom 解析器和解析器组合器,以及如何使用它们来创建我们自己的解析器,编写了许多使用 Nom 解析简单模式的 Rust 程序。我们定义了一个极其简单的编程语言的语法,我们称之为Calc,并使用它构建了一些小程序。我们为Calc构建了一个上下文无关解析器,该解析器在控制台上输出解析后的数据结构(calc_parser)。
我们为Calc构建了一个上下文相关分析器,该分析器在控制台上输出分析后的数据结构(calc_analyzer)。我们使用前面项目中描述的解析器和分析器为Calc构建了一个解释器(calc_interpreter)。我们还构建了一个编译器,可以将Calc程序翻译成等效的 Rust 程序(calc_compiler)。
在下一章中,我们将看到 Nom 和解析技术处理二进制数据的另一种用途。
问题
-
正则语言、上下文无关语言和上下文相关语言是什么?
-
巴科斯-诺尔范式是用来指定语言语法的什么?
-
编译器-编译器是什么?
-
解析器组合器是什么?
-
为什么 Nom 在 2018 年 Rust 版本之前必须只使用宏?
-
Nom 库中的
tuple、alt和map函数做什么? -
不通过中间语言,编程语言的解释器可能有哪些阶段?
-
编译器可能有哪些阶段?
-
当分析变量使用时,符号表有什么作用?
-
当解释程序时,符号表有什么作用?
进一步阅读
可以从github.com/Geal/nom下载 Nom 项目。此存储库还包含一些示例。
关于形式语言及其处理软件有许多教科书。特别是,你可以在维基百科上搜索以下术语:编译器-编译器、解析器组合器、巴科斯-诺尔范式、语法驱动翻译。
使用 Nom 创建计算机模拟器
在最后一章中,我们看到了如何解析文本文件——特别是如何用简单的编程语言编写源文件。文本文件并不是你需要解析的唯一东西——几种类型的系统软件需要解析二进制文件(如二进制可执行文件、多媒体文件和进程间通信消息)。
在本章中,我们将探讨如何应对解析二进制文件的需求以及如何使用nom库来简化这项任务。首先,我们将探讨如何在不使用外部库的情况下解析和解释一个非常简单的机器语言,然后我们将探讨如何使用nom库来简化这项任务。
为了做到这一点,我们将涵盖以下主题:
-
介绍一种仅使用 16 位字的最简单机器语言
-
用这种语言编写几个程序
-
为这种语言编写解析器和解释器,并在之前展示的程序上运行它
-
定义一个从之前版本派生出的字节寻址机器语言
-
解释当字节寻址的机器语言必须处理包含多个字节的单词时出现的寻址问题(字节序)
-
以新机器语言的形式展示之前展示的机器语言程序版本
-
使用
nom库为这种语言编写解析器和解释器,并在机器语言程序上运行它 -
编写一个 C 语言翻译器,将机器语言程序转换为等效的 C 语言程序
-
编写几个反汇编器——将机器语言程序转换为汇编语言的程序,并将它们应用于我们的机器语言程序
到本章结束时,你将学会 CPU 架构、解释和翻译机器语言的主要概念。
第十章:技术要求
对于本章中涉及nom库的部分,需要了解前一章的内容。
本章的完整源代码位于github.com/PacktPublishing/Creative-Projects-for-Rust-Programmers存储库的Chapter09文件夹中。
项目概述
在本章中,首先将介绍有关机器语言的一般概念。然后,将介绍一个非常简单的机器语言。当然,这在使用上相当不切实际,因为没有真实的硬件来运行它。它将仅用于演示如何处理它。
然后,将在机器语言中编写一个非常简单的算法——整数数字的格式化器。将编写一个 Rust 程序来解释这个程序,而无需使用外部库(word_machine_convert)。
然后,将用这种机器语言编写一个更复杂的程序——著名的埃拉托斯特尼发明来寻找素数的算法(称为埃拉托斯特尼筛法)。之前的 Rust 程序将用于解释这个机器语言程序(word_machine_sieve)。
之后,将定义一种更接近现实世界的机器语言,它能够处理单个字节而不是字。这种机器语言提出的问题将被解释。将用这种更新的机器语言编写一个新的埃拉托斯特尼筛法版本,并编写一个 Rust 解释器来运行它。此外,这个 Rust 程序将把机器语言程序翻译成 C 语言。这个解释器和编译器将使用在前一章中已介绍过的nom库来生成程序的中间版本。这个中间数据结构将被解释和编译成 C 语言(nom_byte_machine)。
最后,将为这种机器语言构建一个反汇编器(nom_disassembler)。它将再次使用nom库,并展示两种类型的反汇编——一种旨在帮助调试,另一种旨在为汇编器生成源代码;也就是说,一个将符号代码翻译成机器语言的程序。
介绍一个非常简单的机器语言
真实的机器语言和真实的计算机过于复杂,无法在一个章节中涵盖;因此,我们将使用一种更容易处理和理解的玩具机器语言。实际上,将使用两种机器语言:
-
我们将使用的第一种语言是更简单的一种。为了简单起见,它处理 16 位字,而不是内存字节。
-
第二种语言可以处理单个字节,就像大多数现代计算机一样。
因此,我们将使用的第一种语言只是一个 16 位字的序列,任何用其编写的程序只能操作 16 位字。
这两种机器语言都只使用一个包含机器代码和数据的内存段。在这里,代码和数据之间没有真正的区别;指令可以读取或写入代码和数据,数据可能错误地被执行,就像它是指令一样。通常,代码和一些数据(所谓的常量)是不打算改变的,但在这里,没有保证。
在大多数计算机架构中,任何进程使用的内存由几个部分组成,称为段。最常见的内存段是机器代码(通常称为文本)、静态数据、堆栈和堆。一些段可能是只读的,而其他段可能是可写的。一些段可能有固定的大小,而其他段可能可以调整大小。一些段可以与其他进程共享。
让我们看看为什么我们可能需要处理机器语言软件的一些原因:
-
当计算机不可用(因为购买成本太高或尚未建造)时运行计算机的二进制程序
-
当没有源代码且必须运行的计算机资源受限,以至于无法在其上运行调试器时,调试或分析二进制程序
-
拆解机器码——即将其翻译成汇编代码
-
将二进制程序翻译成另一种机器语言以在更快的速度本地运行,比通过解释它运行要快得多
-
将二进制程序翻译成高级编程语言以方便修改,然后再将其重新编译成任何机器语言
直接用机器码编写程序非常容易出错,所以没有人这样做。任何需要编写一些机器语言的人首先会用一种称为汇编语言的符号语言编写代码,然后将其翻译成机器语言。这种翻译可以手动完成或使用一个名为汇编器的特定程序完成。在这里,我们没有为我们的程序提供汇编器,所以我们将手动翻译汇编代码。然而,在描述我们的机器语言之前,让我们先看看一些与机器语言相关的概念。
与机器语言最相关的最重要的概念
在任何编程语言中,你需要一种方式来指定变量和语句。此外,为了记录代码,你需要一种方式在程序中插入注释。以下是一个非常简单的汇编语言程序,包含一些变量的声明、一些指令和一些注释:
// data
n
word 17
m
word 9
sum
word 0
// code
load n
add m
store sum
terminate 0
双反斜杠(//)开始注释。第一条注释声明(对人类而言)data部分开始的位置。第二条注释声明code部分开始的位置。
注意,除了注释外,一些行是缩进的,而另一些行则没有。实际的声明和指令必须缩进。第一列的行是标签,用于标记程序中的位置。
在前面的代码中,有一些数据,如第一行所示。每个数据项都是一个字,因此它使用word关键字声明。在位置n处有一个初始值为17的字。在位置m处有一个初始值为9的字,在sum位置处有一个初始值为0的字。
然后,有四条指令,每条指令都在不同的行上。每条指令有两个部分:
-
操作码(opcode):这是给处理器的命令。
-
操作数:这是操作码命令的参数——即命令所操作的数据。
所有机器语言都是为特定的计算机架构设计的。运行此程序的计算机只有两个 16 位 CPU 寄存器:
-
一个用于保存要操作的数据字,称为累加器
-
一个用于保存下一个要执行的指令地址,称为指令指针(或程序计数器)
程序的第一条指令是load n。这条指令等同于 Rust 语句accumulator = n;。它将地址为n的当前字值复制到累加器中。
第二条指令是 add m。这相当于 Rust 中的 accumulator += m; 语句。它将标签为 m 的地址处的字值加到累加器当前包含的值上,并将结果存储到累加器中。
第三条指令是 store sum。这相当于 Rust 中的 sum = accumulator; 语句。它将累加器的当前值复制到标签为 sum 的地址处的字。
最后一条指令是 terminate 0。这终止了程序的执行(如果有的话,返回到操作系统),并返回 0 值给启动此程序的进程(如果有的话)。
因此,如果我们跟随指令对数据的影响,我们发现这个程序以包含 17、9 和 0 的三个数据字开始,并以它们包含 17、9 和 26 结束。
然而,为了运行这个程序,我们需要将其翻译成机器语言。
这里,需要对 程序 和 进程 之间的区别进行区分。机器语言程序是在运行之前存在的机器代码。t 要么存储在存储设备或 ROM 中。相反,进程是在程序加载并运行的 RAM 区域中找到的。这种区别在多进程系统中尤为重要,在这种系统中,你可能有多个进程在同一个程序上运行,但在一次只运行一个进程的系统中也很重要。
让我们假设我们的机器要求任何程序都必须具有以下结构:
| 进程长度 |
|---|
| 第一条指令 |
| 第二条指令 |
| 第三条指令 |
| ... |
| 最后一条指令 |
| 数据的第一字 |
| 第二字数据 |
| 数据的第三字 |
| ... |
这个表显示程序的第一个字意味着整个进程的字长度。它后面的字意味着机器语言中的指令。程序的最后一条指令后面的字意味着数据。
在前面的程序中,我们有四个指令,每个指令使用一个字作为操作码和一个字作为操作数。因此,四个指令共占用八个字。如果我们把包含进程长度的初始字和三个变量占用的三个字(每个变量一个字)加起来,我们得到 1 + 8 + 3 = 12 个字。这是该程序使用的内存空间的大小,以字为单位。如果我们把这个数字设置为程序的初始字,这意味着我们需要在进程中恰好有那么多内存。
如果我们布置指令和数据,我们得到以下字数组用于我们的进程:
| 位置 | 内容 |
|---|---|
0 |
进程长度 |
1 |
load 指令的操作码 |
2 |
n 操作数 |
3 |
add 指令的操作码 |
4 |
m 操作数 |
5 |
store 指令的操作码 |
6 |
sum 操作数 |
7 |
terminate 指令的操作码 |
8 |
0 操作数 |
9 |
数据 17 |
10 |
数据 9 |
11 |
数据 0 |
任何单词的位置是其从程序开始处的距离,以单词为单位。任何位置都被称为单词的 地址,因为这个数字允许我们在过程中访问该单词。
机器语言不使用 标签;它只使用 地址。因此,为了将汇编代码翻译成机器语言,我们必须用内存地址替换标签的使用。第一个单词的地址,根据定义,是 0。第一个指令的地址是 1。任何指令都是两个单词长,因此第二个指令的地址是 1 + 2 = 3。最后一个指令之后的地址,即第一个数据单词的地址,标记为 n,是 9。第二个数据单词的地址,标记为 m,是 10。最后一个数据单词的地址,标记为 sum,是 11。
在添加初始长度、将指令移动到数据之前并替换标签之后,我们的程序变成了以下形式:
12
load 9
add 10
store 11
terminate 0
word 17
word 9
word 0
然后,我们必须将每个符号代码替换为其对应的机器语言操作码,这是一个唯一的数字。
让我们假设操作码和符号指令代码之间的以下对应关系:
0 = terminate
1 = load
2 = store
3 = add
word 关键字实际上并不生成指令。因此,我们的程序变成了以下形式:
12
1: 9
3: 10
2: 11
0: 0
17
9
0
当然,这些数字将作为二进制数字的向量存储。所以,在 Rust 中,它将是以下形式:
let mut program: Vec<u16> = vec![12, 1, 9, 3, 10, 2, 11, 0, 0, 17, 9, 0];
因此,我们已经能够手动将汇编语言程序翻译成机器语言程序。然而,我们使用了一个非常小的机器语言,只包含四种类型的指令——也就是说,只有四种不同的操作码。为了执行有用的工作,需要更多种类的指令。
扩展我们的机器语言
我们在前一节中看到的机器语言只能进行加法操作,并且没有输入/输出能力。这样的有限语言并不很有趣。因此,为了有一个可以用来构建有意义程序的编程语言,让我们向我们的机器语言添加一些 种类 的指令。
我们的汇编语言(及其对应的机器语言)由以下表格定义:
| 操作码 | 汇编语法 | 描述 |
|---|---|---|
0 |
terminate operand |
这将终止程序,将操作数返回给调用者。 |
1 |
set operand |
这会将操作数复制到累加器中。 |
2 |
load address |
这会将此地址的值复制到累加器中。 |
3 |
store address |
这会将累加器的值复制到这个地址。 |
4 |
间接加载地址 |
这会将指定在此地址的地址的值复制到累加器中。 |
5 |
间接存储地址 |
这会将累加器的值复制到指定在此地址的地址。 |
6 |
输入长度 |
这会要求用户在按下Enter键之前从控制台接收输入。然后,最多将输入行的length个字符复制到连续的内存字中。这个内存字的序列从累加器中包含的地址开始。每个内存字包含恰好一个字符。如果用户输入的字符少于length个,则剩余的字被设置为二进制零(0)。因此,无论如何,此指令都会设置length个内存字。 |
7 |
输出长度 |
将length个 ASCII 字符(其代码在连续的内存字中)输出到控制台。要输出的内存字序列从累加器中包含的地址开始。仅正确支持 7 位 ASCII 字符。 |
8 |
加地址 |
将该地址的值加到累加器的值上,并将结果保留在累加器中。它使用 16 位整数算术并带有环绕——即在整数溢出的情况下,获得 65,536 的模值。 |
9 |
减地址 |
使用环绕算术从累加器的值中减去该地址的值,并将结果保留在累加器中。 |
10 |
乘地址 |
使用环绕算术将累加器的值乘以该地址的值,并将结果保留在累加器中。 |
11 |
除地址 |
使用整数算术(截断)将累加器的值除以该地址的值,并将结果(商)保留在累加器中。 |
12 |
余数地址 |
这使用整数算术(截断)将累加器的值除以该地址的值,并将整数余数保留在累加器中。 |
13 |
跳转地址 |
这将继续执行address处的指令。 |
14 |
如果累加器为零跳转地址 |
只有当累加器的值等于0时,才继续执行address处的指令。否则,继续执行下一条指令。 |
15 |
如果累加器非零跳转地址 |
如果累加器的值不为0,则继续执行address处的指令。 |
16 |
如果累加器为正跳转地址 |
如果累加器的值是正数,则继续执行address处的指令。 |
17 |
如果累加器为负跳转地址 |
如果累加器的值是负数,则继续执行address处的指令。 |
18 |
如果累加器非正跳转地址 |
只有当累加器的值非正——即它是负数或等于0时,才继续执行address处的指令。 |
19 |
jump_if_nonnegative address |
如果累加器的值为非负值——也就是说,如果它是一个正数或者等于 0,则执行 address 处的指令。 |
| – | word value |
这为数据保留一个字。其初始内容由 value 指定。 |
| – | array length |
这为 length 个字保留一个数组。所有这些字都被初始化为 0。 |
注意到 set 指令类型(操作码 1)非常简单;它将操作数赋值给累加器。几乎所有其他赋值和算术指令类型都有一个间接级别——它们的操作数是必须操作的数据的内存地址。然而,有两个指令——indirect_load(操作码 4)和 indirect_store(操作码 5)——有两个间接级别。它们的操作数是一个字的内存地址,即必须操作的数据的内存地址。
现在我们有了足够强大的机器语言,我们可以用它编写一个有意义的程序。
编写一个非常简单的程序
为了向您展示如何使用这种语言,让我们用一些代码来写一些程序。我们将创建一个程序,当给定一个存储在内存字中的正整数(二进制格式)时,以十进制表示法打印它。
假设要打印的数字硬编码为 6710。当我们用 Rust 编写算法时,它如下所示:
fn main() {
let mut n: u16 = 6710;
let mut digits: [u16; 5] = [0; 5];
let mut pos: usize;
let number_base: u16 = 10;
let ascii_zero: u16 = 48;
pos = 5;
loop {
pos -= 1;
digits[pos] = ascii_zero + n % number_base;
n /= number_base;
if n == 0 { break; }
}
for pos in pos..5 {
print!("{}", digits[pos] as u8 as char);
}
}
在前面的代码中,n 变量是要转换和打印的无符号 16 位数字。digits 变量是一个缓冲区,将包含生成的数字的 ASCII 值。由于 16 位数字最多有五个十进制数字,五个数字的数组就足够了。pos 变量是 digits 数组中当前数字的位置。
number_base 变量是 10,因为我们使用十进制表示法。ascii_zero 变量包含零字符的 ASCII 码(即 48)。
第一个循环通过使用 % 运算符计算 n 除以 10 的余数,并将其添加到 ascii_zero 来计算任何 ASCII 十进制数字。然后,n 被除以 number_base 变量,以从其中移除最低位的十进制数字。第二个循环将生成的五个数字打印到控制台。
这个程序的问题在于它需要使用数组索引。实际上,pos 是 digits 数组的索引。机器语言使用地址,而不是索引;因此,为了模仿机器语言,我们必须将 pos 的类型替换为原始指针的类型,在 Rust 中,原始指针的解引用操作是不安全的。我们不是数到五,而是设置一个 end 指针。当 pos 达到这个指针时,它将完成数组。
因此,让我们将我们的 Rust 程序翻译成一种更接近使用原始指针转换成机器语言的格式:
fn main() {
let mut n: u16 = 6710;
let mut digits: [u16; 5] = [0; 5];
let mut pos: *mut u16;
let number_base: u16 = 10;
let ascii_zero: u16 = 48;
let end = unsafe {
(&mut digits[0] as *mut u16).offset(digits.len() as isize)
};
pos = end;
loop {
pos = unsafe { pos.offset(-1) };
unsafe { *pos = ascii_zero + n % number_base };
n /= number_base;
if n == 0 { break; }
}
while pos != end {
print!("{}", unsafe { *pos } as u8 as char);
pos = unsafe { pos.offset(1) };
}
}
在前面的程序中,使用了原始指针的不安全offset方法。当给定一个原始指针时,它通过在内存中前进指定的位置生成另一个原始指针。
要使程序更类似于机器语言程序,我们应该将所有 Rust 语句拆分为对应机器指令的基本语句。
然而,还有一个问题——我们的累加器寄存器有时会包含数字,有时会包含地址。在 Rust 中使用这是不方便的,因为数字和地址在 Rust 中有不同的类型。因此,在这里,我们将使用两个变量——acc(当它用于存储数字时代表累加器)和ptr_acc(当它用于存储地址时代表累加器,即内存指针)。
下面是获得的程序,它与机器语言程序非常相似:
fn main() {
let mut ptr_acc: *mut u16; // pointer accumulator
let mut acc: u16; // accumulator
let mut n: u16 = 6710;
let mut digits: [u16; 5] = [0; 5];
let mut pos: *mut u16;
let number_base: u16 = 10;
let ascii_zero: u16 = 48;
let one: u16 = 1;
ptr_acc = unsafe {
(&mut digits[0] as *mut u16).offset(digits.len() as isize)
};
pos = ptr_acc;
loop {
ptr_acc = pos;
ptr_acc = unsafe { ptr_acc.offset(-(one as isize)) };
pos = ptr_acc;
acc = n;
acc %= number_base;
acc += ascii_zero;
unsafe { *pos = acc };
acc = n;
acc /= number_base;
n = acc;
if n == 0 { break; }
}
for &digit in &digits {
print!("{}",
if digit == 0 { ' ' }
else { digit as u8 as char}
);
}
}
注意,现在,除了最后的for循环之外,空行之后的语句相当简单。它们只是赋值,可能还结合了一个操作,例如%=, +=, 或/=。此外,还有一个if语句用于在n变量为0时跳出循环。
这可以很容易地翻译成我们的汇编语言,如下所示:
n
word 6710
digits
array 5
pos
word 0
number_base
word 10
ascii_zero
word 48
one
word 1
set pos
store pos
before_generating_digits
load pos
subtract one
store pos
load n
remainder number_base
add ascii_zero
store_indirect pos
load n
divide number_base
store n
jump_if_nonzero before_generating_digits
set digits
output 5
terminate 0
这个汇编语言程序可以手动翻译成机器语言。
由于有 5 个数据字,1 个包含 5 个字的数据数组,16 条指令,每条指令占用两个字,以及初始字,我们总共有5 + 1 * 5 + 16 * 2 + 1 = 43个字。这个数字将是我们的程序第一个字的值。
然后,考虑到所需的布局(过程长度,随后是指令,然后是数据),我们可以计算跳转目标地址和数据地址,得到以下代码:
0: 43
1: set 39 // pos
3: store 39 // pos
5: before_generating_digits
5: load 39 // pos
7: subtract 42 // one
9: store 39 // pos
11: load 33 // n
13: remainder 40 // number_base
15: add 41 // ascii_zero
17: store_indirect 39 // pos
19: load 33 // n
21: divide 40 // number_base
23: store 33 // n
25: jump_if_nonzero 5 // before_generating_digits
27: set 34 // digits
29: output 5
31: terminate 0
33: n: 6710
34: digits: 0, 0, 0, 0, 0
39: pos: 0
40: number_base: 10
41: ascii_zero: 48
42: one: 1
在前面的代码中,注意地址的符号名称已被注释掉。
然后,通过用操作码替换符号代码,并移除注释和行地址,我们得到机器语言程序,作为以逗号分隔的十进制数字列表:
43,
1, 39,
3, 39,
2, 39,
9, 42,
3, 39,
2, 33,
12, 40,
8, 41,
5, 39,
2, 33,
11, 40,
3, 33,
15, 5,
1, 34,
7, 5,
0, 0,
6710,
0, 0, 0, 0, 0,
0,
10,
48,
1
例如,我们开始于以下行:
1: set 39 // pos
前一行变为以下内容:
1, 39,
因为1:行地址已经被移除,所以set符号代码被其操作码(1)所替换,// pos注释被移除,并且添加了两个逗号来分隔数字。
现在,我们可以构建一个 Rust 程序来解释这个程序。你可以在word_machine_convert项目中找到它。
如果你在这个项目上执行cargo run命令,由于没有依赖项,程序将很快编译。执行将简单地以空格开头打印6710。这个项目的名字意味着使用使用字寻址的机器语言来转换数字。
这个 Rust 程序的main函数只是将前面的数字列表传递给execute函数。
这个函数以以下代码开始:
fn execute(program: &[u16]) -> u16 {
let mut acc: u16 = 0;
let mut process = vec![0u16; program[0] as usize];
process[..program.len()].copy_from_slice(program);
let mut ip = 1;
loop {
let opcode = process[ip];
let operand = process[ip + 1];
//println!("ip: {} opcode: {} operand: {} acc: {}",
//ip, opcode, operand, acc);
ip += 2;
之前提到的函数(execute)模拟了一个极其简单的机器语言处理器,将内存视为 16 位字的一个片段。如果这个函数返回,它将返回可能执行的terminate指令的操作数。
acc变量表示累加器寄存器。process变量表示内存的实际运行时内容。其大小,以字为单位,是程序的第一字指定的数字。拥有比运行它的程序更短的过程是没有意义的,因为会丢失一些数据。
然而,拥有比运行它的程序更大的过程是有意义的,因为这样做,它分配了将被无声明需求的代码使用的内存。这样,你可以有一个只有几个字的程序,使用高达 65,536 字的内存空间,这是 128 Kibibytes (KiB)。
process变量的第一部分使用program的内容初始化,program是execute函数的参数。
ip变量是指令指针,初始化为1——也就是说,它指向第二个单词,那里有一个要执行的第一条指令。
然后,是处理循环。每条指令恰好有一个操作码和一个操作数,因此它们被加载到相应的变量中。然后,有一个被注释掉的调试语句;如果你的程序没有按预期工作,这可能很有用。
执行任何指令后,通常将执行其后的指令,因此指令指针立即增加两个字以跳过当前指令。例外的是jump指令和terminate指令。如果jump指令的条件得到满足,它将再次更改指令指针,而terminate指令将跳出处理循环,甚至跳出execute函数。
函数的其余部分是一个大的match语句,这是处理当前指令所需的。以下是它的前几行:
match opcode {
0 => // terminate
{ return operand }
1 => // set
{ acc = operand }
2 => // load
{ acc = process[operand as usize] }
这种match语句的每个分支的行为都非常简单,因为它旨在由硬件执行。例如,如果当前指令是terminate,函数返回操作数;如果是set,则操作数被分配给累加器;如果是load,则将地址为操作数的内存字分配给累加器;等等。
这里有一对算术指令:
9 => // subtract
{ acc = acc.wrapping_sub(process[operand as usize]) }
10 => // multiply
{ acc = acc.wrapping_mul(process[operand as usize]) }
在所有现代计算机中,整数数字以两种互补的格式存储,并且它们根据这些格式执行操作。这有几个优点:
-
如果操作数都被解释为有符号数或无符号数(但不能是一个有符号数和一个无符号数),则单个算术操作可以工作。
-
如果加法或减法导致整数溢出,然后另一个操作导致结果回到允许的范围内,结果仍然是有效的。
在高级语言中,如 Rust,默认情况下通常不允许算术溢出。在 Rust 中,基本运算符的溢出算术会在显示消息“尝试溢出加法”时引发恐慌。为了允许两种互补的算术,Rust 标准库为任何运算符提供了相应的包装方法,这通常是在机器语言中实现的。要使用它,而不是写 a + b,你写 a.wrapping_add(b);而不是写 a - b,你写 a.wrapping_sub(b),以及其他运算符也是如此。
jump 指令与其他指令略有不同,如下所示:
15 => // jump_if_nonzero
{ if acc != 0 { ip = operand as usize } }
16 => // jump_if_positive
{ if (acc as i16) > 0 { ip = operand as usize } }
在前面的代码中,jump_if_nonzero 指令检查累加器的值,并且只有当这个值不是 0 时,才将指令指针设置到指定的值。
jump_if_positive 指令检查累加器的值是否为正,将其解释为有符号数。如果没有 as i16 子句,检查将始终成功,因为 acc 变量是无符号的。
注意,在 Rust 中,一个无符号数可以被转换成有符号数,即使结果是负数;例如,表达式 40_000_u16 as i16 == -25_536_i16 是正确的。
input 和 output 指令非常复杂,它们甚至与操作系统交互。当然,它们不是真实世界的机器语言指令。它们被添加到这种伪机器语言中,只是为了能够以合理的努力编写一个完整的程序。在实践中,在真实世界的机器语言中,输入/输出是通过一系列复杂的指令或通过调用操作系统服务来执行的。
因此,我们已经看到了如何解释机器语言程序。然而,这是一个相当简单的程序;所以,在下一节中,我们将查看一个更有趣且更复杂的机器语言程序。
一个更复杂的程序——埃拉托斯特尼筛法
现在,让我们考虑一个更现实但具有挑战性的问题——实现一个算法来打印小于一个数 N 的所有素数,其中 N 是用户在运行时输入的。这被称为埃拉托斯特尼筛法算法。
下面是这个程序的 Rust 版本:
fn main() {
let limit;
loop {
let mut text = String::new();
std::io::stdin()
.read_line(&mut text)
.expect("Cannot read line.");
if let Ok(value) = text.trim().parse::<i16>() {
if value >= 2 {
limit = value as u16;
break;
}
}
println!("Invalid number (2..32767). Re-enter:")
}
let mut primes = vec![0u8; limit as usize];
for i in 2..limit {
if primes[i as usize] == 0 {
let mut j = i + i;
while j < limit {
primes[j as usize] = 1;
j += i;
}
}
}
for i in 2..limit {
if primes[i as usize] == 0 {
print!("{} ", i);
}
}
}
在前面的代码中,main 函数的前 14 行要求用户输入一个数字,直到输入的数字在 2 和 32767 之间。
接下来的一组语句分配一个字节数组来存储被检测为非素数的数字。最初,它包含所有零,这意味着在所需范围内每个数字都可能是一个素数。然后,按顺序扫描范围内的所有数字,对于每个数字,如果它仍然被认为是素数,则将其所有倍数标记为非素数。
最后的一组语句再次扫描所有数字,并只打印那些仍然标记为素数的数字。
这个程序的难度在于它需要为向量分配内存。我们的机器语言不允许内存分配。我们可以预先分配一个最大所需大小的数组,比如说,400 个单词。
为了预先分配这样的数组,只需指定进程大小等于程序大小加 400 个单词即可。这样做时,当进程开始执行时,它将分配所需的空间,并将其初始化为零的序列。
如您所想象,相应的汇编和机器语言程序相当复杂。它可以在word_machine_sieve项目中找到。
如果您运行它并输入一个不超过 400 的数字,所有小于输入数字的质数将被打印到控制台。解释器与前面项目中使用的解释器相同,但在main函数中还有一个机器语言程序。
这个机器语言程序比前一个项目的要大得多,并且通过注释进行解释。汇编语言在注释中的任何指令或数据项都是等效的。以下是初始部分,包含四个指令:
600, // 0:
// Let the user input the digits of the limit number.
1, 190, // 1: set digits
6, 5, // 3: input 5
// Initialize digit pointer.
1, 190, // 5: set digits
3, 195, // 7: store pos
进程大小600是 400 个单词,比程序大小多 200 个单词。
其中穿插了一些解释性注释,例如第二行和第五行。
第三行是一个set指令(操作码 1),操作数为190。注释解释说,这个指令从地址1开始,与set digits汇编指令相对应。
如您所想象,直接编写机器语言程序而不通过其汇编语言版本几乎是不可能的,手动将汇编语言翻译成机器语言是一项容易出错且繁琐的工作。幸运的是,编写一个为您完成这项工作的汇编器程序相当简单。您可以通过使用前一章中解释的编译技术来完成这项工作。
在下一节中,我们将探讨一个更现实的机器语言以及如何使用nom解析库来简化其解释。
定义字节寻址的机器语言
在前一个章节中,我们看到了不同类型的机器语言。然而,这种机器语言由于几个原因而相当不现实:
-
它按字寻址内存。这在计算机技术早期是很常见的,直到大约 1970 年。然后,拥有可以寻址单个内存字节的处理器的趋势越来越普遍。今天,可能每个正在生产的处理器都可以寻址单个内存字节。
-
它具有相同长度的指令。可能从未有过所有指令长度都相同的机器语言。一个非常简单的指令,例如一个无操作(NOP),可以占用一个字节,而有些处理器具有跨越多个字节的指令。
-
对于现实世界的处理器,任何类型的操作都在 16 位字上操作,例如加法。可能有一个指令操作单个字节,将一个 8 位字节加到另一个字节上,另一个指令执行相同操作但是在 16 位字上,将一个字加到另一个字上,另一个指令用于 32 位双字,甚至还有操作更大位序列的指令。
-
它只有一个处理器寄存器——累加器。现实世界的处理器有更多的处理器寄存器。
-
它提供的操作很少。现实世界的机器语言有更多可能的操作,例如逻辑操作、函数调用和函数返回指令、堆栈操作操作以及递增和递减运算符。
在这里,我们将改变我们的机器语言以引入以下缺失的功能:
-
字节寻址
-
可变长度指令
-
加载或存储单个字节的指令,除了加载或存储字节的指令之外
因此,我们对字节寻址的机器语言应用以下更改:
-
每个地址代表内存字节的位位置,而不是内存字的位位置。
-
每个操作码只占用一个字节,而不是像先前语言那样占用一个字。
-
虽然大多数指令类型仍然有一个字操作数,但三种指令类型有一个 1 字节操作数。它们是
终止操作数、输入长度和输出长度。 -
语言中增加了四种指令类型来操作单个字节。
要理解这种新的机器语言,重要的是要认识到每个 16 位字包含 2 个字节,一个包含数字的 8 个最低有效位,另一个包含数字的 8 个最高有效位。第一个字节被称为低字节,另一个被称为高字节。当操作一个字内的字节时,知道它是该字的低字节还是高字节是很重要的。
新的指令类型定义在以下表中:
| 操作码 | 汇编语法 | 描述 |
|---|---|---|
20 |
加载字节地址 |
这会将指定地址的字节值复制到累加器的低字节。累加器的高字节被设置为 0。 |
21 |
存储字节地址 |
这会将累加器值的低字节复制到该地址。累加器的高字节未使用。 |
22 |
间接加载字节地址 |
这会将指定地址的字节值复制到累加器的低字节。累加器的高字节被设置为 0。 |
23 |
间接存储字节地址 |
这会将累加器值的低字节复制到指定地址。累加器的高字节未使用。 |
需要这四个指令是因为加载、存储、间接加载和间接存储指令类型仍然传输整个字,而我们还需要读取或写入指定地址旁边的单个字节而不读取或写入该字节。
由于这些变化,在之前的机器语言中,每个指令占用四个字节。然而,在这种新的语言中,三种指令类型——终止、输入和输出——仅占用 2 个字节,而所有其他指令类型占用 3 个字节。
注意,所有其他指令类型保持不变,累加器和指令指针的大小仍然是 16 位。
虽然有了字节寻址能力,并且单词跨越多个字节,但这引发了一个问题。这就是所谓的字节序问题,将在下一节中描述。
应对字节序问题
考虑累加器中的一个值为 256 的字。这个字的低字节是 0,高字节是 1。这个字将被存储在 1000 内存地址。因为这个地址现在指的是一个字节,而不是一个双字节字,所以存储指令也必须访问另一个内存字节来存储一个字。对于每个计算机系统,所需的另一个字节是具有以下连续地址的字节,因此它在地址 1001。
因此,我们的累加器将存储在地址 1000 和 1001 的 2 个字节中。然而,数值为 256 的低字节,其值为 0,可能存储在地址 1000 或 1001。
在第一种情况下,当低字节存储在地址 1000 时,其值为 1 的高字节将被存储在地址 1001。以下是这种情况下的内存布局:
| 地址 | 内存内容 |
|---|---|
1000 |
00000000 |
1001 |
00000001 |
在第二种情况下,当低字节存储在地址 1001 时,高字节将被存储在地址 1000。以下是这种情况下的内存布局:
| 地址 | 内存内容 |
|---|---|
1000 |
00000001 |
1001 |
00000000 |
这只是一个约定问题。
不幸的是,一些重要的计算机供应商选择了一种约定,而另一些重要的计算机供应商选择了另一种约定。甚至有些计算机硬件可以被编程在运行时更改约定,因此选择约定取决于操作系统。
低字节具有较低内存地址的约定被称为小端序,这在前面两个表中的第一个表中显示。另一种约定,即高字节具有较低内存地址的约定,被称为大端序,它在前面两个表中的第二个表中显示。这个问题本身被称为字节序问题。
对于我们的机器语言,我们选择了小端序。
现在我们已经定义了新的字节寻址机器语言,并且我们选择了采用小端序约定,我们可以为这种机器语言编写一个解释器。
nom_byte_machine 项目
现在我们有了新的机器语言,我们可以用它编写一些程序,并尝试为这些程序构建一个解释器。此外,可以使用已在第八章中提到的nom库,使用解析器组合进行解释和编译,以简化此类解释器的构建。
然而,在我们开始编码之前,让我们考虑执行机器语言程序的可能技术。实际上,至少有三种可能的方式在没有真实硬件的情况下执行机器语言程序:
-
技术 1:就像硬件解释它一样解释它。这是在前面章节中用于解释
word_machine_sieve项目中埃拉托斯特尼筛法程序的技术。 -
技术 2:首先,解析整个程序并将其转换成高级数据结构,然后解释这个数据结构。
-
技术 3:将其翻译成另一种编程语言,然后使用该编程语言的解释器或编译器。
技术 1是三种技术中唯一能够为任何可能的程序获得正确结果的技术。其他两种技术仅在程序格式良好,遵循以下规则时才有效:
-
它以一个包含进程大小的字节的 little-endian 单词开始。
-
在初始单词之后,是一系列有效的机器语言指令,没有交错的空间或数据。
-
Terminate指令只出现一次,作为最后一个指令,以标记指令序列的结束。之后,只剩下数据。 -
没有语句会在指令上写入;只有数据可以被更改。因此,程序不是自我修改的;或者说,另一种说法是,程序指令与过程指令相同。
nom_byte_machine项目实现了所有三种技术,并将它们应用于一个格式良好的机器语言程序。这个程序是前面章节中看到的筛法算法的版本,为字节寻址的机器语言实现。
首先,让我们尝试通过在project文件夹中键入cargo run来构建和运行项目。构建将花费一些时间,因为它使用了nom库。执行开始于创建包含机器语言程序 C 语言版本的prog.c文件,并在控制台上打印以下内容:
Compiled to prog.c.
然后,程序使用前面描述的第一种技术解释程序。这导致它等待用户输入一个数字。你应该输入一个介于0和400之间的数字,然后按Enter键。
将使用技术 1打印一些素数,然后程序再次使用技术 2解释相同的程序,因此它再次等待用户输入一个数字。你应该再次输入一个数字并按Enter键。
例如,如果你第一次输入 100,第二次输入 40,那么控制台应该显示以下内容:
Compiled to prog.c.
100
2 3 5 7 11 13 17 19 23 29 31 37 41 43 47 53
59 61 67 71 73 79 83 89 97
Return code: 0
40
2 3 5 7 11 13 17 19 23 29 31 37
Return code: 0
执行后,prog.c 文件将存在于 project 文件夹中。在类 Unix 环境中,你可以使用以下命令编译它:
cc prog.c -o prog.exe
这将创建 prog.exe 文件。然后,你可以使用以下命令运行它:
./prog.exe
当然,这个程序与之前解释的程序具有相同的行为。它首先请求一个数字,例如,如果你输入 25,输出将是以下内容:
25
2 3 5 7 11 13 17 19 23
由于这个项目相对复杂,其源代码已被拆分为几个源文件。它们如下:
-
main.rs:这个文件包含机器语言程序和对其他源文件中包含的函数的调用。 -
instructions.rs:这个文件包含机器语言指令的定义和nom解析器以识别它们。 -
emulator.rs:这是机器码的低级解释器。每条指令首先被解析,然后执行。 -
parsing_interpreter.rs:首先解析所有机器码指令,构建一个数据结构,然后执行这个数据结构。 -
translator.rs:这个文件将所有机器码指令翻译成 C 语言代码,并添加一些 C 语言行以创建一个有效的 C 程序。
让我们看看以下各节中的每个文件。
理解 main.rs 源文件
main.rs 文件包含 main 函数,它以以下行开始:
let prog = vec![
187, 2, // 0: 699
// Let the user input the digits of the limit number.
1, 28, 1, // 2, 0: set digits
6, 5, // 5, 0: input 5
// Initialize digit pointer.
1, 28, 1, // 7, 0: set digits
3, 33, 1, // 10, 0: store pos
这个机器语言程序与 word_machine_sieve 项目中使用的类似。在这些程序中,数字代表单词 (u16),而现在它们代表字节 (u8)。
首先,阅读注释,除了那些单独占一行的描述性注释。这些注释包含当前指令或数据的地址,后面跟着一个冒号,然后是汇编语句。
第一行表示从地址 0 开始的内容。在这种情况下,这是数字 699,它是进程所需的长度。正如我们在上一节所说,我们采用了小端序约定来存储单词,因此这个数字被存储为字节对,187, 2,这意味着 2 x 256 + 187。
第二行是一个描述性注释。第三行表示从地址 2 开始的内容,以小端序表示为 2, 0。内容是 set 指令,其操作数是 digits 标签的地址。set 指令的操码是 1,digits 标签位于地址 284,以小端序表示为 28, 1。因此,这一行上有 1, 28, 1。
第四行表示从地址 5 开始的内容,这是一个汇编中为 input 5、机器码中为 6, 5 的指令。程序的其他部分类似。
程序的最后部分是数据部分。以下是其中的一段:
0, 0, 0, 0, 0, // 28, 1: digits: array 5
0, 0, // 33, 1: pos: word 0
10, 0, // 35, 1: number_base: word 10
第一行代表一个 5 字节的数组,所有这些都被初始化为0。它的标签是digits,其地址是284,由28, 1对表示。
第二行代表一个初始化为0的单词,其标签为pos,地址是33, 1对,这是在digits地址之后的 5 个字节。
第三行代表一个初始化为 10(由10, 0对表示)的单词,其标签为number_base,其地址是35, 1对,这是在pos地址之后的两个字节。
主函数以以下行结束:
let _ = translator::translate_program_to_c(&prog, "prog.c");
let return_code = emulator::execute_program(&prog).unwrap();
println!("\nReturn code: {}", return_code);
let mut parsed_program = parsing_interpreter::parse_program(&prog).unwrap();
let return_code = parsing_interpreter::execute_parsed_program(&mut parsed_program);
println!("\nReturn code: {}", return_code);
从前面的代码中,第一条语句调用一个函数,该函数将prog机器语言程序翻译成具有指定名称的 C 语言文件。
第二个语句逐条指令解释程序。
最后一段语句首先调用parse_program语句,该语句将程序翻译成数据结构并存储在parsed_program变量中,然后调用execute_parsed_program函数来执行这个数据结构。
Rust 程序的其余部分实现了这些功能,我们将使用nom库来完成这个目的。
使用 Nom 库
实现本节所描述功能的代码可以在instructions.rs源文件中找到。
在前面的章节中,我们看到了如何使用nom库来解析文本,即字符串切片。嗯,nom不仅限于文本;它还可以用于解析二进制数据,即字节切片。事实上,它就是为了这个目的而创建的,后来才添加了解析字符串的能力。
这里,我们将使用nom的二元解析能力来处理我们的机器语言。
解析二进制文件并不比解析文本文件更困难。它们之间的唯一区别在于,当解析文本文件时,解析的文本是字符串切片的引用,具有&str类型,而当解析二进制文件时,解析的文本是字节切片的引用,具有&[u8]类型。
例如,这是识别add指令的解析器的签名:
fn parse_add(input: &[u8]) -> IResult<&[u8], Instruction> {
parse_add函数接受一个字节切片的引用作为输入,当然,它的剩余序列仍然是一个字节切片的引用。我们希望它的返回值能够完全描述解析的指令,因此使用了自定义的Instruction类型。
这种类型可以按以下方式定义:
#[derive(Debug, Clone, Copy)]
enum Instruction {
Terminate(u8),
Set(u16),
Load(u16),
Store(u16),
IndirectLoad(u16),
IndirectStore(u16),
Input(u8),
Output(u8),
Add(u16),
Subtract(u16),
Multiply(u16),
Divide(u16),
Remainder(u16),
Jump(u16),
JumpIfZero(u16),
JumpIfNonZero(u16),
JumpIfPositive(u16),
JumpIfNegative(u16),
JumpIfNonPositive(u16),
JumpIfNonNegative(u16),
LoadByte(u16),
StoreByte(u16),
IndirectLoadByte(u16),
IndirectStoreByte(u16),
Byte(u8),
}
从前面的代码片段中,每个指令类型都是Instruction枚举的一个变体,这些变体有一个参数来存储操作符的值。Terminate、Input和Output变体有一个u8参数,而其他指令类型有一个u16参数。注意,最后一个变体不是一个指令;它是Byte(u8),表示在过程中包含的数据字节。
使用 Rust 枚举,很容易将指令的操作数封装在一个变体中,即使有多个,这在现实世界的机器语言中很典型。操作数总是相对较小的对象,因此为 Instruction 枚举派生 Copy 特性是高效的。
parse_add 函数的主体如下:
preceded(tag("\x08"), map(le_u16, Instruction::Add))(input)
在前面的章节中已经看到的 preceded 解析器组合器获取两个解析器,按顺序应用它们,丢弃第一个的结果,并返回第二个的结果。
它的第一个解析器是 tag("\x08")。在前面的章节中,我们已经看到了 tag 函数作为可以识别字面字符串切片的解析器。实际上,它还可以识别字面序列的字节,指定为字面字符串。要使用数字而不是 ASCII 字符来指定字节,十六进制转义序列是合适的。因此,这个解析器识别一个值为 8 的字节,这是 add 指令的操作码。
preceded 处理的第二个解析器必须识别小端 2 字节操作数。因此,使用 le_u16 解析器。它的名字意味着小端 u16。还有一个相应的 be_u16 解析器,用于识别使用大端字节顺序的词。
le_u16 解析器只返回一个 u16 值。然而,我们想要一个 Instruction::Add 对象来封装这个值。因此,使用 map 函数创建一个包含解析字的 Add 对象。
因此,parse_add 函数的主体首先检查是否有 8 个字节,然后丢弃它们;然后,它读取一对字节,根据小端字节顺序构建一个 16 位数字,然后返回一个包含此字的 Add 对象。
对于所有具有字操作数的指令,可以创建类似的解析器。然而,对于具有字节操作数的指令,必须使用不同的操作数解析器。在解析单个字节时,没有端序问题;然而,为了术语一致性,将使用 le_u8 解析器,即使 be_u8 解析器同样可以使用,因为它们是相同的。
因此,这里解析器被用来识别一个 terminate 指令,操作码为 0:
fn parse_terminate(input: &[u8]) -> IResult<&[u8], Instruction> {
preceded(tag("\x00"), map(le_u8, Instruction::Terminate))(input)
}
当我们想要识别 add 指令时,我们调用 parse_add,当我们想要识别 terminate 指令时,我们调用 parse_terminate;然而,当我们想要识别任何可能的指令时,我们必须使用 alt 解析器组合器将所有指令的解析器作为替代组合起来,如前所述章节中看到的。
这个解析器组合器有一个限制,然而——它不能组合超过 20 个解析器。实际上,我们有 24 种指令类型,因此需要组合 24 个解析器。这个问题可以通过嵌套使用 alt 来轻松解决。下面是生成的函数:
fn parse_instruction(input: &[u8]) -> IResult<&[u8], Instruction> {
alt((
alt((
parse_terminate,
parse_set,
parse_load,
parse_store,
parse_indirect_load,
parse_indirect_store,
parse_input,
parse_output,
parse_add,
parse_subtract,
parse_multiply,
parse_divide,
parse_remainder,
parse_jump,
parse_jump_if_zero,
parse_jump_if_nonzero,
parse_jump_if_positive,
parse_jump_if_negative,
parse_jump_if_nonpositive,
parse_jump_if_nonnegative,
)),
alt((
parse_load_byte,
parse_store_byte,
parse_indirect_load_byte,
parse_indirect_store_byte,
)),
))(input)
}
从前面的代码中,parse_instruction 函数使用 alt 来组合恰好两个解析器;第一个解析器使用 alt 来组合 20 个指令的解析器,而另一个解析器使用 alt 来组合剩余 4 个指令的解析器。当将字节切片传递给此函数时,它返回可以从其中解析出的唯一指令或错误,如果没有识别到指令。
Instruction 枚举实现了 len 方法,这对于找出指令的长度很有用。它如下所示:
impl Instruction {
pub fn len(self) -> usize {
use Instruction::*;
match self {
Byte(_) => 1,
Terminate(_) | Input(_) | Output(_) => 2,
_ => 3,
}
}
}
在前面的代码中,Byte 占用 1 个字节,Terminate、Input 和 Output 指令占用 2 个字节,其他指令占用 3 个字节。
get_process_size 函数用于从程序的第一个字节读取进程的长度。请注意,除了 parse_instruction 之外,本模块的所有解析器都是私有的,这样我们就可以解析机器代码指令。
现在我们有了指令的解析器,我们可以使用它构建一个低级解释器(即仿真器)。
emulator.rs 源文件
此仿真器在 emulator.rs 源文件中实现。解释器的入口点是以下函数:
pub fn execute_program(program: &[u8]) -> Result<u8, ()> {
let process_size_parsed: u16 = match get_process_size(program) {
Ok(ok) => ok,
Err(_) => return Err(()),
};
let mut process = vec![0u8; process_size_parsed as usize];
process[0..program.len()].copy_from_slice(&program);
let mut registers = RegisterSet { ip: 2, acc: 0 };
loop {
let instruction = match parse_instruction(&process[registers.ip as usize..]) {
Ok(instruction) => instruction.1,
Err(_) => return Err(()),
};
if let Some(return_code) = execute_instruction(&mut process, &mut registers, instruction) {
return Ok(return_code);
}
}
}
前面的函数接收一个程序作为参数,并通过逐条解析和执行指令来执行它。如果由于指令格式不正确而导致解析错误,则函数返回该解析错误。如果没有发生解析错误,程序将继续执行,直到遇到 Terminate 指令。然后,程序返回 Terminate 指令的操作数。
第一条语句获取进程所需的大小。然后,创建一个 process 变量,它是一个具有指定长度的字节向量。程序的内容被复制到进程的第一部分,然后进程的其余部分初始化为零。
然后,在前面代码的第八行,声明了 registers 变量,其类型为 RegisterSet,声明如下:
pub struct RegisterSet {
ip: u16,
acc: u16,
}
在这个简单的机器架构中,将指令指针和累加器封装在结构体中并没有带来太大的好处,但对于具有许多寄存器的更复杂处理器来说,这将很方便。
最后,是解释循环。它由两个步骤组成:
-
对
parse_instruction的调用从指令指针的当前位置解析进程并返回Instruction。 -
对
execute_instruction的调用执行了前面步骤生成的指令,考虑到整个进程和寄存器集。
execute_instruction 函数只是一个以以下内容开始的大的 match 语句:
match instruction {
Terminate(operand) => {
r.ip += 2;
return Some(operand);
}
Set(operand) => {
r.acc = operand;
r.ip += 3;
}
Load(address) => {
r.acc = get_le_word(process, address);
r.ip += 3;
}
Store(address) => {
set_le_word(process, address, r.acc);
r.ip += 3;
}
对于每种指令类型,都采取适当的操作。注意以下内容:
-
Terminate指令会导致函数返回Some,而对于任何其他指令,返回None。这允许调用者终止执行循环。 -
Set指令将累加器(r.acc)设置为操作数的值。 -
Load指令使用get_le_word函数从process的address位置读取一个 little-endian 格式的字,并将其赋值给累加器。 -
Store指令使用set_le_word函数将来自累加器的 little-endian 格式的字赋值到process的address位置。 -
所有指令都会将指令指针(
r.ip)增加指令本身的长度。
让我们看看每次指令需要读取或写入内存中的字时使用的辅助函数:
fn get_le_word(slice: &[u8], address: u16) -> u16 {
u16::from(slice[address as usize]) + (u16::from(slice[address as usize + 1]) << 8)
}
fn set_le_word(slice: &mut [u8], address: u16, value: u16) {
slice[address as usize] = value as u8;
slice[address as usize + 1] = (value >> 8) as u8;
}
在前面的代码中,get_le_word函数从address读取一个字节,并从下一个位置读取另一个字节。在 little-endian 表示法中,第二个字节是最重要的,因此它的值在添加到其他字节之前被左移 8 位。
set_le_word函数保存一个字节,连同地址位置,以及下一个位置的一个字节。第一个字节是通过将字转换为u8类型获得的,第二个字节是通过将字向右移动 8 位获得的。
当然,jump指令是不同的。例如,看看下面的代码片段:
JumpIfPositive(address) => {
if (r.acc as i16) > 0 {
r.ip = address;
} else {
r.ip += 3;
}
}
将JumpIfPositive指令的操作数视为一个有符号数。如果这个值是正的,指令指针被设置为操作数。否则,执行常规的增量操作。
作为另一个例子,让我们看看如何间接加载一个字节:
IndirectLoadByte(address) => {
r.acc = get_byte(process, get_le_word(process, address));
r.ip += 3;
}
使用get_le_word函数,从process的address位置读取 16 位值。这个值是一个字节的地址,因此使用get_byte函数读取这个字节并将其赋值给累加器。
因此,在本节中,我们看到了第一种执行技术——逐条解析和执行指令的技术。
parsing_interpreter.rs源文件
现在,我们可以看看其他的执行技术——首先解析整个程序,然后执行解析结果。
parsing_interpreter模块有两个入口点:
-
parse_program -
execute_parsed_program
第一个步骤是调用一次get_process_size函数来从前两个字节中获取进程大小,然后使用以下循环来解析程序指令:
let mut parsed_program = vec![Instruction::Byte(0); process_size_parsed];
let mut ip = 2;
loop {
match parse_instruction(&program[ip..]) {
Ok(instruction) => {
parsed_program[ip] = instruction.1;
ip += instruction.1.len();
if let Instruction::Terminate(_) = instruction.1 {
break;
}
}
Err(_) => return Err(()),
};
}
在下面的代码中,我们要构建的数据结构是parsed_program变量。这个变量是一个指令或字节数据的向量。它通过初始化为具有零值的单个数据字节来初始化,但随后一些字节被替换为指令。
从位置2开始,程序会重复使用parse_instruction函数进行解析。这个函数返回一个指令,并将其存储在程序位置对应的向量中。当解析到Terminate指令时,循环结束。
parse_instruction函数与我们在instructions模块中看到的相同。
在此循环之后,我们需要将数据值设置到向量中。这是通过以下循环完成的:
for ip in ip..program.len() {
parsed_program[ip] = Instruction::Byte(program[ip]);
}
这将用另一个字节替换向量的任何字节,其值来自程序。execute_parsed_program 函数具有以下结构:
let mut registers = ParsedRegisterSet { ip: 2, acc: 0 };
loop {
if let Some(return_code) = execute_parsed_instruction(parsed_program, &mut registers) {
return return_code;
};
}
上述代码定义了一个寄存器集,然后反复调用 execute_parsed_instruction 直到它返回 Some。此函数与 emulator 模块的 execute_instruction 函数非常相似。
主要区别在于使用 get_parsed_le_word、set_parsed_le_word、get_parsed_byte 和 set_parsed_byte 函数,而不是 get_le_word、set_le_word、get_byte 和 set_byte。
这些函数,而不是在 u8 对象的切片中获取或设置 u8 值,而是在 Instruction 对象的切片中获取或设置 Instruction::Byte 值。这个切片是解析后的程序。
我们现在将转向最后一种技术。
translator.rs 源文件
现在,我们可以看看最后一种执行技术——将程序翻译成 C 语言程序,以便可以使用任何 C 编译器进行编译。
translator.rs 模块只有一个入口点:
pub fn translate_program_to_c(program: &[u8], target_path: &str) -> Result<()> {
此函数获取要翻译的机器语言程序以及要创建的文件路径,并返回一个表示其成功或失败的结果。
其主体创建一个文本文件,并使用如下语句将其写入:
writeln!(file, "#include <stdio.h>")?;
它将字符串写入 file 流。请注意,与 println 宏类似,writeln 宏也支持通过成对的大括号进行字符串插值:
writeln!(file, " addr_{}: acc = {};", *ip, operand)?;
因此,任何实际的括号都必须成对出现:
writeln!(file, "unsigned char memory[] = {{")?;
翻译算法相当简单。首先,发出全局字节数组的声明:
unsigned char memory[];
然后,我们有两个实用函数的定义。它们的签名如下:
unsigned short bytes_to_u16_le(unsigned int address)
void u16_to_bytes_le(unsigned int address, unsigned short operand)
第一个读取 memory 数组中两个位置的两位字节——address 和 address + 1——并将它们解释为小端 16 位数字,然后返回该数字。第二个生成构成 operand 值的两个字节,并将它们作为小端 16 位数字写入内存的 address 和 address + 1 位置。
然后,发出 main C 函数。它首先声明 acc 变量,该变量将用作累加器寄存器。
可能令人惊讶的是,不需要包含指令指针的变量。这意味着在 C 程序执行期间,当前 C 语言语句对应于当前机器语言指令。
机器语言跳转是通过臭名昭著的 goto 语句实现的。为了能够跳转到任何指令,必须将跳转目标指令之前放置一个 C 语言唯一的标签。为了简单起见,在翻译任何指令时,生成一个不同的标签,即使其中大多数标签将永远不会被 goto 语句使用。
作为例子,让我们考虑store pos汇编语言指令,对应于3, 33, 1机器语言指令,其中3是store指令的操作码,33, 1代表小端表示法中的289。假设这个指令从程序的位置10开始。对于这个指令,将生成以下 C 语言语句:
addr_10: u16_to_bytes_le(289, acc);
首先,有一个标签作为可能的jump指令的目标。标签是通过将指令的位置与addr_常量连接来创建的。然后,有一个函数调用,它将acc变量的值复制到memory数组中位置289和230的字节,使用小端表示法。
为了创建这些语句,执行了一个循环,每次使用parse_instruction函数解析一条指令,然后使用translate_instruction_to_c函数生成相应的 C 语言语句。
这个函数包含一个大的match语句,为每种指令类型都有一个分支。例如,将Store指令翻译的分支如下:
Store(address) => {
writeln!(file, " addr_{}: u16_to_bytes_le({}, acc);", *ip, address)?;
*ip += 3;
}
在循环处理完Terminate语句之后,main C 函数被关闭,而刚刚声明的memory数组现在被定义为使用整个机器语言程序的内容进行初始化。
事实上,由于 C 语言代码没有使用机器语言指令,因此可以从这个数组中省略这些指令,但这样做更简单。
因此,我们已经看到了如何从一个机器语言程序生成一个等效的 C 语言程序,假设它是正确形成的。只要存在goto语句,这种技术可以用来生成其他编程语言中的程序。
既然我们已经看到了执行机器语言程序的好几种方法,我们可以看看机器语言解析器的另一种用途。
nom_disassembler项目
我们已经看到,通常机器语言程序是用汇编语言编写的,然后翻译成机器语言。所以,如果我们想理解或调试我们公司编写的机器语言程序,我们应该查看用于生成它的汇编语言程序。
然而,如果这个程序不是由我们公司编写的,我们没有它的汇编语言源代码可用,那么有一个工具尝试将其尽可能好地翻译成相应的汇编语言程序是有用的。这个工具被称为反汇编器,但由于以下原因,它不能创建一个优秀的汇编语言程序:
-
代码中不能插入任何有意义的注释。
-
数据变量没有符号名称来使其有意义。它们只是放置某些数据的位置的内存字节,因此它们通过地址来引用。
-
跳转的目标没有符号名称来使其有意义。它们只是某些指令开始的内存位置,因此它们通过地址来引用。
关于 16 位字,有时将它们视为单个数字是有用的,有时将它们视为字节的成对组合。如果你正在反汇编一个程序以对其进行更改并将其更改后的汇编程序提交给汇编器(以获得更改后的机器语言程序),最好为每个 16 位数字只生成一个数字(对于我们的处理器类型,以小端序表示)。
相反,如果你只是为了深入理解程序而反汇编程序,最好为每个 16 位数字生成一个数字表示法及其字节对的表示。
典型的反汇编器使用十六进制表示法。一个 16 位数字由四个十六进制数字表示,其中两个数字代表一个字节,另外两个数字代表另一个字节。
相反,为了继续使用十进制表示法,nom_disassembler项目从同一机器语言程序生成两个输出:
-
一个
FOR DEBUG输出,其中每个 16 位数字都显示为单个数字和字节对 -
一个
FOR ASSEMBLING输出,其中每个 16 位数字只显示为单个数字
我们现在将在下一小节学习如何运行项目。
运行项目
如果你为这个项目输入cargo run,你会看到一个以以下内容开始的冗长输出:
FOR DEBUG
Program size: 299
Process size: 699
2: Set(284: 28, 1)
5: Input(5)
7: Set(284: 28, 1)
10: Store(289: 33, 1)
13: IndirectLoadByte(289: 33, 1)
几行之后,你会找到以下内容:
297: Byte(2)
298: Byte(0)
FOR ASSEMBLING
process size 699
2: set 284
5: input 5
7: set 284
10: store 289
13: indirect load byte 289
最后,你会找到以下内容:
297: data byte 2
298: data byte 0
输出的第一部分是FOR DEBUG反汇编。在显示程序大小和进程后,开始显示反汇编指令。第一个是Set指令,其 16 位操作数是数字284,由28和1字节按小端序组成。第二个指令是Input,它有一个 8 位操作数。
任何指令都由指令的第一个字节的地址 precedes。因此,Set由2 precedes(它是程序的第三个字节),Input由5 precedes(它是程序的第六个字节)。
程序以一系列字节结束。由于机器语言没有字数据的概念,数据只是字节序列。
输出的第二部分是FOR ASSEMBLING反汇编。这与第一种反汇编技术有以下不同之处:
-
没有程序大小。任何汇编程序都可以计算相应的机器语言程序的大小。在汇编程序的源代码中不需要指定它。
-
指令的符号名称只包含小写字母,并且可以由多个单词组成,单词之间用空格分隔。这样,它们更容易阅读和编写。相反,
FOR DEBUG输出只使用指令枚举变体的名称。 -
操作数是一个数字。
我们现在将查看源代码以帮助我们进一步理解它。
检查源代码
现在,让我们通过检查main.rs文件中的源代码来查看这个项目是如何获得这种输出的。这个函数在定义了prog变量(如前一个项目所示)之后,只包含以下语句:
println!("FOR DEBUG");
let _ = disassembly_program_for_debug(&prog);
println!();
println!("FOR ASSEMBLING");
let _ = disassembly_program(&prog);
disassembly_program_for_debug函数产生第一种输出,而disassembly_program函数产生第二种输出。让我们看看这些函数做了什么。
生成对调试有用的反汇编代码
disassembly_program_for_debug函数的有趣部分是以下代码片段:
loop {
let instruction = parse_instruction(rest)?;
println!("{:5}: {:?}", offset, instruction.1);
offset += instruction.1.len();
rest = instruction.0;
if let Terminate(_) = instruction.1 {
break;
}
}
for byte in rest {
let instr = Byte(*byte);
println!("{:5}: {:?}", offset, instr);
offset += instr.len();
}
在前面的代码中,首先有一个循环使用parse_instruction函数解析每个指令,然后有一个循环扫描每个数据字节。对于每个解析的指令,通过println打印获得的指令,并将其大小添加到程序中的当前位置,即offset。
这个循环在找到Terminate指令时结束。对于数据字节,构建一个Byte变体,并以类似的方式打印。这引发了如何打印Instruction类型对象的问题。
要使用println的{:?}占位符打印,必须实现Debug特性。然而,如果你打印一个如前几章中定义的Instruction对象,我们不会得到我们想要的输出。例如,如果你执行print!("{:?}", Instruction::Set(284))语句,你会得到以下输出:
Set(284)
但我们希望得到以下输出:
Set(284: 28, 1)
为了获得期望的格式化,必须以下述方式定义一个新的类型:
#[derive(Copy, Clone)]
struct Word(u16);
Word类型以以下方式封装了Instruction变体的所有u16参数:
#[derive(Debug, Copy, Clone)]
enum Instruction {
Terminate(u8),
Set(Word),
Load(Word),
...
当然,这会导致任何Instruction对象构造时在其内部构造一个Word对象,并且Instruction实现的每个特性和Word也必须实现。Copy和Clone特性使用默认派生实现。
相反,Debug特性以以下方式实现:
impl std::fmt::Debug for Word {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(f, "{}: {}, {}", self.0, self.0 as u8, self.0 >> 8)
}
}
fmt函数的主体写入三个数字——整个参数(self.0)、其低字节(self.0 as u8)和其高字节(self.0 >> 8)。这样我们就得到了期望的格式化。
Instruction对象是由指令解析器创建的。因此,根据项目nom_byte_machine,这些解析器必须进行更改。在那个项目中,我们看到了一些解析器接受 16 位数字,例如这个:
fn parse_set(input: &[u8]) -> IResult<&[u8], Instruction> {
preceded(tag("\x01"), map(le_u16, Instruction::Set))(input)
}
对于所有这些解析器,必须将le_u16解析器的使用替换为le_word解析器的使用,从而得到以下结果:
fn parse_set(input: &[u8]) -> IResult<&[u8], Instruction> {
preceded(tag("\x01"), map(le_word, Instruction::Set))(input)
}
这个解析器定义如下:
fn le_word(input: &[u8]) -> IResult<&[u8], Word> {
le_u16(input).map(|(input, output)| (input, Word(output)))
}
它仍然调用le_u16解析器,但随后它获取生成的(input, output)对,并将output项封装在一个Word对象中,从而获得(input, Word(output))对。
我们已经看到了如何将机器语言程序转换为一种汇编代码。这种反汇编代码对于调试目的很有用,但不容易更改和重新组装以生成新的机器语言程序。在下一节中,我们将探讨另一种有用的反汇编代码,它可以再次进行组装。
生成可用于重新组装的反汇编代码
关于另一种输出类型,FOR ASSEMBLING,我们必须检查 disassembly_program 函数,它与 disassembly_program_for_debug 函数的相应部分非常相似。唯一的不同之处在于以下几点:
-
程序大小不会被输出。
-
两个
println语句的格式字符串为"{:5}: {}",而不是"{:5}: {:?}"。
对于这种格式占位符,Display 特性必须由 Instruction 类型实现:
impl std::fmt::Display for Instruction {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
use Instruction::*;
match self {
Terminate(byte) => write!(f, "terminate {}", byte),
Set(word) => write!(f, "set {}", word),
Load(word) => write!(f, "load {}", word),
...
Byte(byte) => write!(f, "data byte {}", byte),
}
}
}
对于任何变体,使用 write 宏来输出指令的符号名称,后跟字节或字的格式化值。这种格式化还需要为参数实现 Display 特性。字节是 u8 类型,它已经实现了 Display 特性。而对于字,需要以下声明:
impl std::fmt::Display for Word {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
这只是简单地产生封装在 Word 对象中的数值。因此,我们已经看到了如何将机器语言程序转换为两种可能的反汇编文本格式。
我们还看到了另一种反汇编方式。作为一个练习,你应该为这种机器语言编写一个汇编器,在由这个反汇编器生成的代码上运行它,并检查生成的机器代码是否与原始代码相同。
摘要
在本章中,我们首先定义了一种极其简单的玩具机器语言,然后定义了一种稍微复杂一点的机器语言来实验机器语言操作技术。
第一定义的机器语言假设内存是一系列 16 位字,任何指令都由两个部分组成,每个部分是一个字——操作码和操作数。第二种机器语言假设内存是一系列字节,某些指令可以操作单个字节,而其他指令可以操作整个字。
这引入了端序问题,它涉及到如何解释两个连续的字节作为一个字。例如,欧几里得筛法算法最初是用 Rust 编写的,然后被翻译成两种机器语言。
对于第一种机器语言,编写了一个不使用任何外部库的解释器。它首先用于解释一个小型数字转换程序(word_machine_convert),然后是更复杂的筛法算法(word_machine_sieve)。
对于第二种机器语言,在单个项目中(nom_byte_machine)编写了三个过程。所有这些过程都使用了 nom 解析库。第一个过程是逐条指令的解析器。第二个过程首先解析整个程序,然后解析并解释该程序。第三个过程将程序翻译成 C 语言。
对于第二种机器语言,使用了 nom 库(nom_disassembler)构建了两种反汇编器——一个反汇编器输出的内容对调试有用,另一个反汇编器输出的内容在编辑后重新组装有用。
因此,在阅读完这一章后,你现在应该理解机器语言是什么,它的对应汇编语言是什么,如何将汇编语言翻译成机器语言以及相反,如何将机器语言翻译成 C 语言,如何解释机器语言,以及如何使用 nom 解析库来完成这些任务。
在下一章中,我们将学习如何创建 Linux 内核模块。
问题
-
机器语言模拟器有什么用途?
-
处理器的累加器是什么?
-
处理器的指令指针是什么?
-
为什么直接用机器语言编写非常困难,因此最好使用汇编器?
-
Rust 枚举如何表示机器语言指令?
-
小端表示法和大端表示法是什么?
-
接受文本的
nom解析器和接受二进制数据的nom解析器之间有什么区别? -
机器语言程序必须遵守哪些规则才能被完全解析,或者能够将其翻译成另一种编程语言?
-
为什么在反汇编器中可能会偏好不同类型的输出,或者十六进制输出格式?
-
如何以不同的方式打印一个单一的数字?
创建 Linux 内核模块
任何合理的操作系统都可以通过可加载模块进行扩展。这是为了支持操作系统创建组织未特别支持的硬件,因此这些可加载模块通常被称为设备驱动程序。
然而,操作系统的这种可扩展性也可以被用于其他目的。例如,可以通过可加载模块在内核本身中支持特定的文件系统或网络协议,而无需更改和重新编译实际的内核。
在本章中,我们将探讨如何构建内核可加载模块,特别是针对 Linux 操作系统和 x86_64 CPU 架构。这里描述的概念和命令也适用于其他 CPU 架构。
本章将涵盖以下主题:
-
准备环境
-
创建模板模块
-
使用全局变量
-
分配内存
-
为字符设备创建驱动程序
到本章结束时,你将学习一些关于操作系统扩展模块的一般概念,特别是如何创建、管理和调试 Linux 内核模块。
第十一章:技术要求
要理解本章内容,应了解一些 Linux 操作系统的概念。特别是,你需要了解以下内容:
-
如何使用 Linux 命令解释器(即,shell)
-
如何理解 C 语言源代码
-
如何使用 GCC 编译器或 Clang 编译器
如果你没有这方面的知识,可以参考以下网络资源:
-
有许多教程教你如何使用 Linux 命令解释器。其中一个适合 Ubuntu Linux 分发初学者的教程可以在
ubuntu.com/tutorials/command-line-for-beginners#1-overview找到。一个更高级和完整的免费书籍可以在wiki.lib.sun.ac.za/images/c/ca/TLCL-13.07.pdf找到。 -
有许多教程教你关于 C 编程语言。其中之一是
www.tutorialspoint.com/cprogramming/index.htm。 -
Clang 编译器的参考可以在
clang.llvm.org/docs/ClangCommandLineReference.html找到。
本章中的代码示例仅在特定的 Linux 版本上开发和测试过——一个使用 4.15.0-72-generic 内核版本的 Linux Mint 分发——因此它们仅保证与这个版本兼容。Mint 分发源自 Debian 分发,因此它共享了 Debian 大多数命令。桌面环境无关紧要。
要运行本章中的示例,你应该有超级用户(root)权限访问基于 x86_64 架构的先前分布的系统。
要构建一个内核模块,需要编写大量的模板代码。这项工作已经在 GitHub 上的开源项目中为你完成了,GitHub 地址为github.com/lizhuohua/linux-kernel-module-rust。该 GitHub 项目的一部分已经被复制到一个框架中,用于编写 Linux 内核模块,这些模块将在本章中使用。这可以在与本章相关的存储库的linux-fw文件夹中找到。
此外,为了简化,不会进行交叉编译——也就是说,内核模块将在它将被使用的相同操作系统中构建。这有点不寻常,因为通常,可加载模块是为不适合软件开发的操作系统的或架构开发的;在某些情况下,目标系统过于受限,无法运行方便的开发环境,例如微控制器。
在其他情况下,情况正好相反——目标系统对于单个开发者来说成本过高,例如超级计算机。
本章的完整源代码可以在存储库github.com/PacktPublishing/Creative-Projects-for-Rust-Programmers的Chapter10文件夹中找到。
项目概述
在本章中,我们将探讨四个项目,展示如何构建越来越复杂的 Linux 内核模块:
-
boilerplate: 一个非常简单的内核模块,展示了构建你自己的模块所需的最小要求 -
state: 一个保持一些全局静态变量的模块——即一个静态状态 -
allocating: 一个分配堆内存的模块——即一个动态状态 -
dots: 一个实现只读字符设备的模块,它可以与文件系统路径名关联,然后可以像文件一样读取
理解内核模块
内核模块必须满足操作系统强加的某些要求,因此尝试用面向应用的编程语言(如 Java 或 JavaScript)编写内核模块是非常不合理的。通常,内核模块只使用汇编语言或 C 编写,有时也使用 C++。然而,Rust 被设计成一种系统编程语言,因此实际上可以使用 Rust 编写可加载的内核模块。
虽然 Rust 通常是一种可移植的编程语言——相同的源代码可以重新编译为不同的 CPU 架构和不同的操作系统——但对于内核模块来说并非如此。特定的内核模块必须为特定的操作系统设计和实现。此外,通常还需要针对特定的机器架构进行目标定位,尽管核心逻辑可以是架构无关的。因此,本章中的示例将仅针对 Linux 操作系统和 x86_64 CPU 架构。
准备环境
一些安装工作必须以超级用户权限执行。因此,您应该在安装系统范围包或更改内核中的任何命令之前,在命令前加上sudo。或者,您应该定期以超级用户身份工作。不用说,这是危险的,因为一个错误的命令可能会危及整个系统。要作为超级用户工作,请在终端中输入以下命令:
su root
然后,输入您的超级用户密码。
Linux 操作系统期望其模块只使用 C 语言编写。如果您想用 Rust 编写内核模块,必须使用粘合软件将您的 Rust 代码与 Linux 的 C 语言接口。
因此,必须使用 C 编译器来构建这个粘合软件。这里将使用clang编译器。这是低级虚拟机(LLVM)项目的一部分。
Rust 编译器还使用 LLVM 项目的库来生成机器代码。
您可以通过输入以下命令在您的 Linux 系统中安装clang编译器:
sudo apt update
sudo apt install llvm clang
注意,apt命令是 Debian 派生的发行版的典型命令,在许多 Linux 发行版和其他操作系统中不可用。
然后,您需要确保您的当前操作系统的 C 语言头文件已安装。您可以通过输入uname -r命令来发现您当前操作系统的版本。这将打印出类似4.15.0-72-generic的内容。您可以通过使用类似以下命令来安装特定版本的内核的头文件:
sudo apt install linux-headers-4.15.0-72-generic
您可以通过输入以下命令来组合这两个命令:
sudo apt install linux-headers-"$(uname -r)"
这将为您的系统生成正确的命令。
在撰写本文时,Linux 内核模块只能使用 Rust 编译器的nightly版本创建。要安装此编译器的最新版本,请输入以下命令:
rustup toolchain install nightly
此外,还需要 Rust 编译器的源代码和格式化 Rust 源代码的工具。您可以通过输入以下命令来确保它们已安装:
rustup component add --toolchain=nightly rust-src rustfmt
为了确保默认使用 Rust 的 x86_64 架构和 Linux 的nightly工具链,运行以下命令:
rustup default nightly-x86_64-unknown-linux-gnu
如果您的系统上没有安装其他目标平台,这可以缩短为rustup default nightly。
我们知道cargo实用程序有几个子命令,例如new、build和run。对于这个项目,还需要一个额外的cargo子命令——xbuild子命令。这个名字代表交叉构建,意味着为另一个平台编译。实际上,它用于为不同于编译器运行的平台生成机器代码。在这种情况下,这意味着虽然我们正在运行的编译器是一个在用户空间运行的标准可执行文件,但我们正在生成的代码将在内核空间运行,因此它需要一个不同的标准库。您可以通过输入以下行来安装该子命令:
cargo install cargo-xbuild
然后,在你从 GitHub 下载了与本章相关的源代码之后,你就可以运行示例了。
注意,在下载的源代码中,每个项目都有一个文件夹,还有一个名为linux-fw的文件夹。这个文件夹包含开发 Linux 内核模块的框架,示例假设它位于这个位置。
一个模板模块
第一个项目是最小、可加载的内核模块,因此被称为模板。当模块加载时,它将打印一条消息,当模块卸载时,它将打印另一条消息。
在boilerplate文件夹中,有以下源文件:
-
Cargo.toml: Rust 项目的构建指令 -
src/lib.rs: Rust 源代码 -
Makefile: 生成和编译 C 语言粘合代码以及将生成的目标代码链接到内核模块的构建指令 -
bd: 一个用于构建内核模块调试配置的 shell 脚本 -
br: 一个用于构建内核模块发布配置的 shell 脚本
让我们从构建内核模块开始。
构建和运行内核模块
要为调试目的构建内核模块,打开boilerplate文件夹并输入以下命令:
./bd
当然,这个文件必须具有可执行权限。然而,当它从 GitHub 仓库安装时,它应该已经具有这些权限。
第一次运行此脚本时,它将构建框架本身,因此需要相当长的时间。之后,它将在几分钟内构建boilerplate项目。
在build命令完成后,当前文件夹中应该会出现几个文件。其中一个是名为boilerplate.ko的文件,其中ko(代表内核对象)是我们想要安装的内核模块。它的大小非常大,因为它包含大量的调试信息。
一个提供 Linux 模块文件信息的 Linux 命令是modinfo。你可以通过输入以下命令来使用它:
modinfo boilerplate.ko
这应该会打印出有关指定文件的一些信息。要将模块加载到内核中,请输入以下命令:
sudo insmod boilerplate.ko
insmod(插入模块)命令从指定的文件加载 Linux 模块并将其添加到正在运行的内核中。当然,这是一个特权操作,可能会危及整个计算机系统的安全性和安全性,因此只有超级用户才能运行它。这就解释了为什么需要使用sudo命令。如果命令成功;终端上不会打印任何内容。
lsmod(列出模块)命令打印出所有当前已加载的模块的列表。要选择你感兴趣的模块,你可以使用grep实用程序过滤输出。因此,你可以输入以下命令:
lsmod | grep -w boilerplate
如果boilerplate已加载,你将得到类似以下的一行:

这行包含模块的名称、它使用的内存字节数以及这些模块的当前使用次数。
要卸载已加载的模块,你可以输入以下命令:
sudo rmmod boilerplate
rmmod(移除模块)命令从正在运行的 Linux 内核中卸载指定的模块。如果模块当前未加载,则此命令将打印错误消息并执行无操作。
现在,让我们看看这个模块的行为。Linux 有一个仅内存的日志区域,称为内核缓冲区。内核模块可以向该缓冲区追加文本行。当boilerplate模块被加载时,它将boilerplate: Loaded文本追加到内核缓冲区。当boilerplate模块被卸载时,它将boilerplate: Unloaded文本追加。只有内核及其模块可以写入它,但每个人都可以使用dmesg(即显示消息)实用程序来读取它。
如果你将dmesg输入到终端中,内核缓冲区的全部内容将被打印到终端。通常,内核缓冲区中有数千条消息,自系统上次重启以来由几个模块写入,但最后两行应该是boilerplate模块附加的。要查看仅最后 10 行并保持其颜色,请输入以下命令:
dmesg --color=always | tail
最后两行看起来应该如下所示:

任何一行的第一部分,括号内的内容,是内核写入的时间戳。这是自内核开始以来的秒和微秒数。该行的其余部分由模块代码写入。
现在,我们可以看到bd脚本是如何构建这个内核模块的。
构建命令
bd脚本的内容如下:
#!/bin/sh
cur_dir=$(pwd)
cd ../linux-fw
cargo build
cd $cur_dir
RUST_TARGET_PATH=$(pwd)/../linux-fw cargo xbuild --target x86_64-linux-kernel-module && make
让我们看看代码中发生了什么:
-
第一行声明这是一个 shell 脚本,因此将使用 Bourne shell 程序来运行它。
-
第二行将当前文件夹的路径保存到一个临时变量中。
-
第三、第四和第五行进入框架文件夹,为调试配置构建框架,然后返回到原始文件夹。
-
最后一行构建了该模块本身。注意,它以
&& make结束。这意味着在成功运行该行第一部分的命令后,必须运行该行第二部分的命令(make命令)。相反,如果第一部分的命令失败,则不会运行第二命令。该行以RUST_TARGET_PATH=$(pwd)/../linux-fw子句开始。它创建了一个名为RUST_TARGET_PATH的环境变量,该变量仅对命令行其余部分有效。它包含framework文件夹的绝对路径名。然后,调用cargo工具,并带有xbuild --target x86_64-linux-kernel-module参数。这是一个xbuild子命令,用于编译不同于当前平台的程序,其余的命令指定目标为x86_64-linux-kernel-module。这个目标是特定于我们使用的框架。为了解释如何使用此目标,有必要检查Cargo.toml文件,该文件包含以下代码:
[package]
name = "boilerplate"
version = "0.1.0"
authors = []
edition = "2018"
[lib]
crate-type = ["staticlib"]
[dependencies]
linux-kernel-module = { path = "../linux-fw" }
[profile.release]
panic = "abort"
lto = true
[profile.dev]
panic = "abort"
package 部分是常规的。lib 部分的 crate-type 项指定编译目标是静态链接库。
dependencies 部分的 linux-kernel-module 模块指定了包含框架的文件夹的相对路径。如果你希望将 framework 文件夹安装在这个项目的另一个相对位置或使用另一个名称,你应该更改此路径,以及 RUST_TARGET_PATH 环境变量。
多亏了这个指令,才可能使用 cargo 命令行中指定的目标。
剩余的部分指定,在发生恐慌时,应立即执行立即中止(无输出)操作,并且在发布配置中,链接时优化(LTO)应被激活。
完成这个 cargo 命令后,应该已经创建了 target/x86_64-linux-kernel-module/debug/libboilerplate.a 静态链接库。与其他任何 Linux 静态链接库一样,它的名称以 lib 开头,以 .a 结尾。
命令行的最后一部分运行 make 工具,这是一个主要用于 C 语言开发的 build 工具。正如 cargo 工具使用 Cargo.toml 文件来知道要做什么一样,make 工具使用 Makefile 文件来完成同样的目的。
在这里,我们不检查 Makefile,但只是说它读取由 cargo 生成的静态库,并用一些 C 语言粘合代码封装它,以生成 boilerplate.ko 文件,这是内核模块。
除了 bd 文件外,还有一个 br 文件,它与 bd 类似,但使用 release 选项同时运行 cargo 和 make,因此生成一个优化的内核模块。你可以通过输入以下命令来运行它:
./br
生成的模块将覆盖由 bd 创建的 boilerplate.ko 文件。你可以看到新文件在磁盘上要小得多,并且使用 lsmod 工具可以看到它在内存中也要小得多。
模板模块的源代码
现在,让我们来检查这个项目的 Rust 源代码。它包含在 src/lib.rs 文件中。第一行如下:
#![no_std]
这是一个指令,用于避免在这个项目中加载 Rust 标准库。实际上,标准库中的许多例程都假设作为应用程序代码运行——在用户空间,而不是内核内部——因此它们不能在这个项目中使用。当然,在这个指令之后,我们习惯使用的许多 Rust 函数将不再自动可用。
特别地,默认情况下不包含堆内存分配器,因此默认情况下不允许向量或字符串进行堆内存分配。如果你尝试使用 Vec 或 String 类型,你会得到一个 use of undeclared type or module 错误信息。
接下来的几行如下:
use linux_kernel_module::c_types;
use linux_kernel_module::println;
这些行将一些名称导入当前源文件。这些名称在框架中定义。
第一行导入了与 C 语言数据类型相对应的一些数据类型的声明。它们是必需的,以便与内核接口,内核期望模块是用 C 语言编写的。在此声明之后,您可以使用例如c_types::c_int表达式,它对应于 C 语言的int数据类型。
第二行导入了一个名为println的宏,就像标准库中的那样,现在已经不再可用。实际上,它可以以相同的方式使用,但不是打印到终端,而是将一行追加到内核缓冲区,前面带有时间戳。
然后,模块有两个入口点——init_module函数,当模块被加载时由内核调用,以及cleanup_module函数,当模块被卸载时由内核调用。它们由以下代码定义:
#[no_mangle]
pub extern "C" fn init_module() -> c_types::c_int {
println!("boilerplate: Loaded");
0
}
#[no_mangle]
pub extern "C" fn cleanup_module() {
println!("boilerplate: Unloaded");
}
它们的no_mangle属性是向链接器发出的指令,以保留这个确切函数名,以便内核可以通过其名称找到这个函数。它的extern "C"子句指定函数调用约定必须是 C 语言通常使用的约定。
这些函数不接受任何参数,但第一个函数返回一个值,表示初始化的结果。0表示成功,而1表示失败。Linux 规定这个值的类型是 C 语言的int变量,而框架中的c_types::c_int类型正好代表这种二进制类型。
这两个函数将我们在上一节中看到的消息打印到内核缓冲区。此外,这两个函数都是可选的,但如果init_module函数缺失,链接器将发出警告。
文件的最后两行如下:
#[link_section = ".modinfo"]
pub static MODINFO: [u8; 12] = *b"license=GPL\0";
他们为链接器定义了一个字符串资源,以便将其插入到生成的可执行文件中。该字符串资源的名称是.modinfo,其值是licence=GPL。该值必须是一个以空字符终止的 ASCII 字符串,因为这是 C 语言中通常使用的字符串类型。本节不是必需的,但如果它缺失,链接器将发出警告。
使用全局变量
之前项目的模块模板只是打印了一些静态文本。然而,对于模块来说,通常有一些变量必须在模块的生命周期内访问。通常,Rust 不使用可变的全局变量,因为它们不安全,只是在main函数中定义它们,并将它们作为参数传递给由main调用的函数。然而,内核模块没有main函数。它们有内核调用的入口点,因此,为了保持共享的可变变量,必须使用一些不安全的代码。
State项目展示了如何定义和使用共享的可变变量。要运行它,进入state文件夹并输入./bd。然后,输入以下四个命令:
sudo insmod state.ko
lsmod | grep -w state
sudo rmmod state
dmesg --color=always | tail
让我们看看我们做了什么:
-
最后一个命令将模块加载到内核中,不会向控制台输出任何内容。
-
第二个命令将显示通过检索所有已加载的模块并过滤名为
state的模块来加载模块。 -
第三个命令将从内核卸载模块,不会在控制台输出任何内容。
-
最后一个命令将显示此模块添加到内核缓冲区的两行。它们看起来像这样:
[123456.789012] state: Loaded
[123463.987654] state: Unloaded 1001
除了时间戳之外,它们与boilerplate示例的不同之处在于模块的名称以及第二行添加了数字1001。
让我们看看这个项目的源代码,展示它与boilerplate源代码相比的不同之处。lib.rs文件包含以下附加行:
struct GlobalData { n: u16 }
static mut GLOBAL: GlobalData = GlobalData { n: 1000 };
第一行定义了一个名为GlobalData的数据结构类型,它只包含一个 16 位的无符号数。第二行定义并初始化了这个类型的静态可变变量,命名为GLOBAL。
然后,init_module函数包含以下附加语句:
unsafe { GLOBAL.n += 1; }
这增加了全局变量。由于它被初始化为1000,在模块加载后,这个变量的值变为1001。
最后,cleanup_module函数中的语句被以下内容替换:
println!("state: Unloaded {}", unsafe { GLOBAL.n });
这将格式化和打印全局变量的值。请注意,读取和写入全局变量是一个不安全操作,因为它提供了对可变静态对象的访问。
bd和br文件与boilerplate项目中的文件相同。Cargo.toml和Makefile文件与boilerplate项目中的文件不同,因为将boilerplate字符串替换为state字符串。
分配内存
前一个项目定义了一个全局变量,但没有执行内存分配。即使在内核模块中,也可以分配内存,如allocating项目所示。
要运行此项目,打开allocating文件夹,输入./bd。然后,输入以下四个命令:
sudo insmod allocating.ko
lsmod | grep -w allocating
sudo rmmod allocating
dmesg --color=always | tail
这些命令的行为与上一个项目的相应命令非常相似,但最后一个命令会在时间戳之后打印一行文本:
allocating: Unloaded 1001 abcd 500000
让我们检查这个项目的源代码,看看它与boilerplate源代码相比有哪些不同。lib.rs文件包含以下附加行:
extern crate alloc;
use crate::alloc::string::String;
use crate::alloc::vec::Vec;
第一行明确声明需要一个内存分配器。否则,由于没有使用标准库,将不会将内存分配器链接到可执行模块。
第二行和第三行需要分别在源代码中包含String和Vec类型。否则,它们将不可用于源代码。然后,还有以下全局声明:
struct GlobalData {
n: u16,
msg: String,
values: Vec<i32>,
}
static mut GLOBAL: GlobalData = GlobalData {
n: 1000,
msg: String::new(),
values: Vec::new(),
};
现在,数据结构包含三个字段。其中两个字段msg和values在它们不为空时使用堆内存,而GLOBAL变量初始化了所有这些。在这里不允许内存分配,因此这些动态字段必须是空的。
在 init_module 函数中,与其他入口点一样,允许分配,因此以下代码是有效的:
unsafe {
GLOBAL.n += 1;
GLOBAL.msg += "abcd";
GLOBAL.values.push(500_000);
}
这将更改全局变量的所有字段,为 msg 字符串和 values 向量分配内存。最后,在 cleanup_module 函数中使用以下语句访问全局变量以打印其值:
unsafe {
println!("allocating: Unloaded {} {} {}",
GLOBAL.n,
GLOBAL.msg,
GLOBAL.values[0]
);
}
代码的其他部分保持不变。
字符设备
类 Unix 系统以其将 I/O 设备映射到文件系统的功能而闻名。除了预定义的 I/O 设备外,还可以将自定义设备定义为内核模块。内核设备可以连接到真实硬件,也可以是虚拟的。在本项目中,我们将构建一个虚拟设备。
在类 Unix 系统中,有两种类型的 I/O 设备:块设备和字符设备。前者在一次操作中处理字节数组(即它们是缓冲的),而后者一次只能处理一个字节,没有缓冲。
通常,设备可以读取、写入或两者兼而有之。我们的设备将是一个只读设备。因此,我们将构建一个文件系统映射的、虚拟的、只读字符设备。
构建字符设备
在这里,我们将构建一个字符设备驱动程序(或简称为字符设备)。字符设备是一种一次只能处理一个字节且没有缓冲的设备驱动程序。我们设备的操作将非常简单——对于从它读取的每个字节,它将返回一个点字符,但对于每 10 个字符,将返回一个星号而不是点。
要构建它,打开 dots 文件夹,并输入 ./bd。当前文件夹中将创建几个文件,包括我们的内核模块 dots.ko 文件。
要安装它并检查是否已加载,请输入以下命令:
sudo insmod dots.ko
lsmod | grep -w dots
现在,内核模块已作为字符设备加载,但尚未映射到特殊文件。然而,您可以使用以下命令在已加载的设备中找到它:
grep -w dots /proc/devices
/proc/devices 虚拟文件包含所有已加载设备模块的列表。其中,在 Character devices 部分,应该有一行如下所示:
236 dots
这意味着存在一个名为 dots 的加载字符设备驱动程序,其内部标识符为 236。这个内部标识符也被称为主设备号,因为它是一对数字中的第一个数字,实际上用于识别设备。另一个数字,称为次设备号,未使用,但可以设置为 0。
主设备号可能因系统而异,也可能因加载而异,因为它是内核在模块加载时分配的。无论如何,它是一个小的正整数。
现在,我们必须将这些设备驱动程序与一个特殊文件相关联,这是一个文件系统中的入口点,可以用作文件,但实际上是一个指向设备驱动程序的句柄。这个操作是通过以下命令完成的,你应该用你在 /proc/devices 文件中找到的主设备号替换 236:
sudo mknod /dev/dots1 c 236 0
mknod Linux 命令创建一个特殊设备文件。前面的命令在 dev 文件夹中创建了一个名为 dots1 的特殊文件。
这是一个具有特权的命令,原因有两个:
-
只有超级用户可以创建特殊文件。
-
只有超级用户可以在
dev文件夹中创建文件。
c 字符表示创建的设备将是一个字符设备。接下来的两个数字——236 和 0——是新虚拟设备的主设备号和次设备号。
注意,特殊文件(dots1)的名称可以与设备(dots)的名称不同,因为特殊文件与设备驱动程序之间的关联是通过主设备号来完成的。
创建特殊文件后,你可以从中读取一些字节。head 命令读取文本文件的第一行或字节。所以,输入以下命令:
head -c42 /dev/dots1
这将在控制台打印以下文本:
.........*.........*.........*.........*..
这个命令从指定的文件中读取前 42 个字节。
当询问第一个字节时,模块返回一个点。当询问第二个字节时,模块返回另一个点,以此类推,直到前九个字节。然而,当询问第 10 个字节时,模块返回一个星号。然后,这种行为会重复——在九个点之后,会不断地返回星号。实际上,只返回了 42 个字符,因为 head 命令从我们的设备请求了 42 个字符。
换句话说,如果模块生成的字符的序号是 10 的倍数,那么它就是一个星号;否则,它是一个点。
你可以根据 dots 模块创建其他特殊文件。例如,输入以下内容:
sudo mknod /dev/dots2 c 236 0
然后,输入以下命令:
head -c12 /dev/dots2
这将在控制台打印以下文本:
.......*....
注意,打印了 12 个字符,这是 head 命令要求的,但这次,星号在第 8 个字符处,而不是第 10 个。这是因为 dots1 和 dots2 这两个特殊文件都与同一个内核模块相关联,标识符为 (236, 0),名称为 dots。该模块记得它已经生成了 42 个字符,因此,在生成七个点之后,它必须生成它的第 50 个字符,这个字符必须是星号,因为它是一个 10 的倍数。
你可以尝试输入整个文件,但这些操作永远不会自发结束,因为模块会继续生成字符,就像它是一个无限文件。尝试输入以下命令,然后通过按下 Ctrl +* C* 来停止它:
cat /dev/dots1
将会打印出一串快速流动的字符,直到你停止它。
你可以通过输入以下命令删除特殊文件:
sudo rm /dev/dots1 /dev/dots2
你可以通过输入以下命令卸载模块:
sudo rmmod dots
如果你卸载模块而不删除特殊文件,它们将无效。如果你然后尝试使用其中一个,例如通过输入head -c4 /dev/dots1,你会得到以下错误信息:
head: cannot open '/dev/dots1' for reading: No such device or address
现在,让我们通过输入以下内容来查看附加到内核缓冲区的信息:
dmesg --color=always | tail
你会看到打印出的最后两行将与以下内容相似:
[123456.789012] dots: Loaded with major device number 236
[123463.987654] dots: Unloaded 54
第一行,在模块加载时打印,也显示了模块的主要编号。最后一行,在模块卸载时打印,也显示了模块生成的总字节数(如果你没有运行cat命令,则为42 + 12 = 54)。现在,让我们看看这个模块的实现。
dots 模块的源代码
你将在其他项目中找到的唯一相关差异是在src/lib.rs文件中。
首先,src/lib.rs文件声明了使用Box泛型类型,这与前一个项目中的String和Vec类似,默认情况下不包括。然后,它声明了一些其他与内核的绑定:
use linux_kernel_module::bindings::{
__register_chrdev, __unregister_chrdev, _copy_to_user, file, file_operations, loff_t,
};
它们的含义如下:
-
__register_chrdev:在内核中注册字符设备的函数。 -
__unregister_chrdev:从内核中注销字符设备的函数。 -
_copy_to_user:从内核空间到用户空间复制一系列字节的函数。 -
file:表示文件的数据类型。这个项目实际上并没有使用它。 -
file_operations:包含在文件上实现的操作的数据类型。只有此模块实现了read操作。这可以看作是用户代码的视角。当用户代码读取时,内核模块写入。 -
loff_t:表示长内存偏移量的数据类型,如内核所使用。这个项目实际上并没有使用它。
全局信息
全局信息保存在以下数据类型中:
struct CharDeviceGlobalData {
major: c_types::c_uint,
name: &'static str,
fops: Option<Box<file_operations>>,
count: u64,
}
让我们理解前面的代码:
-
第一个字段(
major)是设备的主要编号。 -
第二个字段(
name)是模块的名称。 -
第三个字段(
fops,简称文件操作)是实现所需文件操作的函数引用集合。这个引用集合将被分配到堆上,因此它被封装在一个Box对象中。任何Box对象必须从其创建时起封装一个有效的值,但fops字段引用的文件操作集合只能在内核初始化模块时创建;因此,这个字段被封装在一个Option对象中,它将被 Rust 初始化为None,并在内核初始化模块时接收一个Box对象。 -
最后一个字段(
count)是生成的字节数计数器。
如预期的那样,以下是全球对象的声明和初始化:
static mut GLOBAL: CharDeviceGlobalData = CharDeviceGlobalData {
major: 0,
name: "dots\0",
fops: None,
count: 0,
};
该模块只包含三个函数:init_module、cleanup_module 和 read_dot。前两个函数分别在模块加载和卸载时由内核调用。第三个函数在每次有用户代码尝试从这个模块读取字节时由内核调用。
虽然 init_module 和 cleanup_module 函数通过它们的名称(因此它们必须具有确切的这些名称)链接,并且必须先于 #[no_mangle] 指令以避免 Rust 改变它们的名称,但 read_dot 函数将通过其地址而不是其名称传递给内核。因此,它可以有您喜欢的任何名称,并且对于它不需要 #[no_mangle] 指令。
初始化调用
让我们看看 init_module 函数体的一部分:
let mut fops = Box::new(file_operations::default());
fops.read = Some(read_dot);
let major = unsafe {
__register_chrdev(
0,
0,
256,
GLOBAL.name.as_bytes().as_ptr() as *const i8,
&*fops,
)
};
在第一个语句中,创建了一个包含文件操作引用的 file_operations 结构体,并使用默认值放入一个 Box 对象中。
任何文件操作的默认值是 None,这意味着当需要此类操作时不会执行任何操作。我们将仅使用 read 文件操作,并且我们需要调用 read_dot 函数进行此操作。因此,在第二个语句中,此函数被分配给新创建的结构体的 read 字段。
第三个语句调用了 __register_chrdev 内核函数,该函数用于注册字符设备。此函数在网页上正式文档化,可在www.kernel.org/doc/html/latest/core-api/kernel-api.html?highlight=__register_chrdev#c.__register_chrdev找到。此函数的五个参数具有以下用途:
-
第一个参数是设备所需的主设备号。然而,如果它是
0,就像我们的情况一样,内核将生成主设备号并由函数返回。 -
第二个参数是从中生成次设备号的起始值。我们将从
0开始。 -
第三个参数是我们请求分配的次设备号的数量。我们将分配 256 个次设备号,从
0到255。 -
第四个参数是我们正在注册的设备范围的名称。内核期望一个以空字符终止的 ASCII 字符串。因此,
name字段已声明为以二进制0结尾,在这里,一个相当复杂的表达式只是改变了这个名称的数据类型。as_bytes()调用将字符串切片转换为字节切片。as_ptr()调用获取此切片的第一个字节的地址。as *const i8子句将此 Rust 指针转换为字节的原始指针。 -
第五个参数是文件操作结构体的地址。当执行读取操作时,内核将仅使用其
read字段。
现在,让我们看看 init_module 函数的其余部分:
if major < 0 {
return 1;
}
unsafe {
GLOBAL.major = major as c_types::c_uint;
}
println!("dots: Loaded with major device number {}", major);
unsafe {
GLOBAL.fops = Some(fops);
}
0
调用 __register_chrdev 返回的主要数字应该是由内核生成的一个非负数。只有在出错的情况下才会返回负数。由于我们希望在注册失败时失败模块的加载,因此我们返回 1——在这种情况下,表示模块加载失败。
在成功的情况下,主要数字存储在我们全局结构的 major 字段中。然后,将成功消息添加到内核缓冲区,包含生成的次要数字。
最后,将 fops 文件操作结构存储在全局结构中。
注意,在注册调用之后,内核保留 fops 结构的地址,因此在此函数注册期间不应更改此地址。然而,这成立,因为此结构是由 Box::new 调分配的,并且 fops 的赋值只是移动 Box 对象,即堆对象的指针,而不是堆对象本身。这解释了为什么使用 Box 对象。
清理调用
现在,让我们看看 cleanup_module 函数的主体:
unsafe {
println!("dots: Unloaded {}", GLOBAL.count);
__unregister_chrdev(
GLOBAL.major,
0,
256,
GLOBAL.name.as_bytes().as_ptr() as *const i8,
)
}
第一条语句将卸载消息打印到内核缓冲区,包括自模块加载以来从该模块读取的总字节数。
第二条语句调用 __unregister_chrdev 内核函数,该函数注销先前注册的字符设备。此函数在网页上正式文档化,可在www.kernel.org/doc/html/latest/core-api/kernel-api.html?highlight=__unregister_chrdev#c.__unregister_chrdev找到。
其参数与用于注册设备的函数的前四个参数相当相似。它们必须与相应的注册值相同。然而,在注册函数中,我们指定 0 作为主要数字,而在这里我们必须指定实际的主要数字。
读取函数
最后,让我们看看内核每次某些用户代码尝试从该模块读取一个字节时将调用的函数的定义:
extern "C" fn read_dot(
_arg1: *mut file,
arg2: *mut c_types::c_char,
_arg3: usize,
_arg4: *mut loff_t,
) -> isize {
unsafe {
GLOBAL.count += 1;
_copy_to_user(
arg2 as *mut c_types::c_void,
if GLOBAL.count % 10 == 0 { "*" } else { "." }.as_ptr() as *const c_types::c_void,
1,
);
1
}
}
此外,此函数必须由 extern "C" 子句装饰,以确保其调用约定与内核使用的相同,即系统 C 语言编译器使用的约定。
此函数有四个参数,但我们只会使用第二个。此参数是指向用户空间中结构的指针,其中必须写入生成的字符。函数的主体只包含三条语句。
第一条语句增加用户代码(由内核模块编写)读取的总字节数。
第二个语句是对内核函数_copy_to_user的调用。当你想要从一个由内核代码控制的内存区域复制一个或多个字节到由用户代码控制的内存区域时,可以使用此函数,因为此操作不允许简单的赋值。此函数在www.kernel.org/doc/htmldocs/kernel-api/API---copy-to-user.html有官方文档说明。
它的第一个参数是目标地址,这是我们想要写入字节的内存位置。在我们的例子中,这仅仅是read_dot函数的第二个参数,转换成适当的数据类型。
第二个参数是源地址,这是我们想要返回给用户的字节的内存位置。在我们的例子中,我们希望在九个点之后返回一个星号。因此,我们检查读取字符的总数是否是10的倍数。在这种情况下,我们使用只包含一个星号的静态字符串切片:否则,我们有一个包含点的字符串切片。对as_ptr()的调用获取字符串切片的第一个字节的地址,而as *const c_types::c_void子句将其转换为与 C 语言的const void *数据类型相对应的预期数据类型。
第三个参数是要复制的字节数。当然,在我们的例子中,这是1。
这就是生成点和星号所需的所有内容。
摘要
在本章中,我们探讨了可以使用 Rust 语言而不是典型的 C 编程语言来创建 Linux 操作系统内核的可加载模块的工具和技术。
特别是,我们看到了在 Mint 发行版和 x86_64 架构上可以使用的一系列命令,用于配置构建和测试可加载内核模块的适当环境。我们还研究了modinfo、lsmod、insmod、rmmod、dmesg和mknod命令行工具。
我们看到,为了创建内核模块,拥有一个针对 Rust 编译器的目标框架的代码框架是有用的。使用这个目标,Rust 源代码被编译成 Linux 静态库。然后,这个库与一些 C 语言的粘合代码链接,形成一个可加载的内核模块。
我们创建了四个逐渐增加复杂性的项目——boilerplate、state、allocating和dots。特别是,dots项目创建了一个模块,可以使用mknod命令将其映射到特殊文件;在此映射之后,当读取这个特殊文件时,会生成一系列点和星号。
在下一章和最后一章中,我们将考虑 Rust 生态系统在未来几年内的进步——语言、标准库、标准工具以及免费提供的库和工具。还包括对新增的异步编程的支持的描述。
问题
-
什么是 Linux 可加载内核模块?
-
Linux 内核期望使用哪种编程语言为其模块编写?
-
什么是内核缓冲区以及它每一行的第一部分是什么?
-
modinfo、lsmod、insmod和rmmodLinux 命令的目的是什么? -
为什么默认情况下,
String、Vec和Box数据类型在 Rust 代码构建内核模块时不可用? -
#[no_mangle]Rust 指令的目的是什么? -
extern "C"Rust 子句的目的是什么? -
init_module和cleanup_module函数的目的是什么? -
__register_chrdev和__unregister_chrdev函数的目的是什么? -
应该使用哪个函数将字节序列从内核空间内存复制到用户空间内存?
进一步阅读
本章项目中使用的框架是对开源仓库的修改,该仓库可以在github.com/lizhuohua/linux-kernel-module-rust找到。此仓库包含有关此主题的更多示例和文档。
Linux 内核的文档可以在www.kernel.org/doc/html/latest/找到。
Rust 的未来
2015 版 Rust 的热门词汇是稳定性,因为 1.0 版本承诺将与后续版本兼容。
2018 版 Rust 的热门词汇是生产力,因为 1.31 版本提供了一个成熟的工具生态系统,使得桌面操作系统(Linux、Windows、macOS)的命令行开发者可以更加高效。
有意向在未来几年推出一个新的 Rust 版本,但对于这个版本,其发布日期、功能以及热门词汇都尚未定义。
然而,在 2018 版发布后,全球 Rust 生态系统开发者正在针对 Rust 开发者的几个需求进行开发。很可能新的热门词汇将出自这些开发路线之一。
最有趣的发展方向如下:
-
集成开发环境(IDEs)和交互式编程
-
Crate 成熟度
-
异步编程
-
优化
-
嵌入式系统
到本章结束时,我们将看到 Rust 生态系统最可能的发展方向:语言、工具和可用库。你将了解未来几年可以期待什么。
Rust 语言中最激动人心的两个新特性是异步编程范式和const 泛型语言特性。到 2019 年底,前者已经被添加到语言中,而后者仍在开发中。本章将通过代码示例进行解释,因此你将获得关于它们的实际知识。
第十二章:IDEs 和交互式编程
许多开发者更喜欢在包含或协调所有开发工具的图形应用程序中工作,而不是使用终端命令行。这类图形应用程序通常被称为开发环境——或简称DEs。
目前,最受欢迎的 IDE 可能是以下这些:
-
Eclipse: 这主要用于 Java 语言的开发。
-
Visual Studio: 这主要用于 C#和 Visual Basic 语言的开发。
-
Visual Studio Code: 这主要用于 JavaScript 语言的开发。
在 20 世纪,为单一编程语言从头开始创建 IDE 是典型的做法。但这确实是一项重大任务。因此,在过去的几十年里,创建可定制的 IDE 变得更加典型,然后添加扩展(或插件)以支持特定的编程语言。对于大多数编程语言,至少有一个成熟的扩展用于流行的 IDE。然而,在 2018 年,Rust 的 IDE 支持非常有限,这意味着有一些扩展可以在一对 IDE 中使用 Rust,但它们提供的功能很少,性能不佳,而且也存在很多错误。
此外,许多程序员更喜欢交互式开发风格。在创建一个软件系统的新功能时,他们不喜欢写很多软件然后编译和测试所有这些。相反,他们更喜欢写一行或几行代码,并立即测试这些代码片段。在成功测试这些代码片段后,他们将它们集成到系统的其余部分。这对于使用 JavaScript 或 Python 等解释型语言的开发者来说是典型的。
能够运行代码片段的工具是语言解释器或快速内存编译器。这样的解释器从用户那里读取命令,评估它,打印结果,然后回到第一步。因此,它们通常被称为读取-评估-打印循环,或简称REPL。对于所有解释型编程语言,以及一些编译型语言,都有成熟的 REPL。在 2018 年,Rust 生态系统缺少一个成熟的 REPL。
在这里,IDE 问题和 REPL 问题一起提出,因为它们存在以下共同问题。现代 IDE 的主要功能是在编辑源代码时进行分析,以下目标是:
-
为了突出显示包含无效语法的代码,并在靠近无效代码的弹出窗口中显示编译错误消息
-
为了建议完成标识符,从已声明的标识符中选择
-
显示在编辑器中选定的标识符的概要文档
-
要从标识符的定义跳转到其使用,或反之亦然
-
在调试会话中,评估当前上下文中的表达式,或更改变量的内存内容
这些操作需要非常快速地解析 Rust 代码,这也是 Rust REPL 所需的功能。解决此类问题的尝试是一个名为Rust 语言服务器(github.com/rust-lang/rls)的项目,该项目由 Rust 语言团队开发。另一个尝试是由 Ferrous Systems 公司开发的名为Rust Analyzer(github.com/rust-analyzer/rust-analyzer)的项目,由几家合作伙伴支持。希望在下一次 Rust 版本发布之前,会有一个快速且强大的 Rust 语言分析器来支持智能程序员的编辑器、源级调试器和 REPL 工具,就像许多其他编程语言一样。
Crate 成熟度
当一个 crate 达到版本 1.0时,它就变得成熟。这个里程碑意味着后续的 1.x 版本将与它兼容。相反,对于 0.x 版本,没有这样的保证,任何版本都可能有一个与上一个版本相当不同的应用程序编程接口(API)。
拥有一个成熟的版本对于几个原因很重要,以下列出:
-
当您将依赖项升级到 crate 的新版本(以使用该库的新功能)时,您可以保证现有的代码不会损坏——也就是说,它将继续以先前的方式,或者以更好的方式运行。没有这样的保证,您通常需要审查使用该 crate 的所有代码,并修复所有不兼容性。
-
您在知识技能上的投资得到了保留。您无需重新培训自己或您的同事,甚至无需更新您的文档。
-
通常,软件质量会得到提高。如果一个 API 版本长时间保持不变,并且许多人使用它在不同的边缘情况下,未测试的 bug 和现实世界的性能问题可能会出现并被修复。相反,快速变化的版本通常在许多应用场景中充满 bug 且效率低下。
当然,迭代 API 的几个改进步骤有优势,几周内创建的 API 通常设计得很糟糕。尽管仍然有许多 crate 已经以 0.x 版本存在了几年,但稳定它们的时间即将到来。
这是对流行词“稳定性”的重新解释。在 2015 年,它意味着“语言和标准库的稳定性”。现在,成熟的生态系统其余部分必须稳定下来,才能被实际项目所接受。
异步编程
2019 年 11 月,在稳定的 Rust 中引入了一项重大创新——发布 1.39 版——它是async-await语法,以支持异步编程。
异步编程是一种在许多应用领域非常有用的编程范式,主要在多用户服务器中,因此许多编程语言——如 JavaScript、C#、Go 和 Erlang——都支持在语言中实现它。其他语言,如 C++和 Java,则通过标准库支持异步编程。
大约在 2016 年,在 Rust 中进行异步编程非常困难,因为既没有语言也没有可用的 crate 以简单和稳定的方式支持它。然后,一些支持异步编程的 crate 被开发出来,例如futures、mio和tokio,尽管它们并不容易使用,并且仍然停留在 1.0 版本之前,这意味着它们的 API 不稳定。
在看到仅使用库创建方便的异步编程支持有多困难之后,很明显需要一个语言扩展。
新的语法,类似于 C#的语法,包括新的async和await语言关键字。这种语法的稳定意味着之前的异步 crate 现在应该被认为是过时的,直到它们迁移到使用新的语法。
新的语法——在blog.rust-lang.org/2019/11/07/Async-await-stable.html网页上宣布——在rust-lang.github.io/async-book/网页上进行了描述。
对于那些从未感到异步编程必要的人来说,这里有一个快速示例。创建一个新的 Cargo 项目,并添加以下依赖项:
async-std = "1.5"
futures = "0.3"
在该项目的根目录下准备一个名为file.txt的文件,其中只包含五个Hello字符。使用类 Unix 的命令行,你可以使用以下命令来完成:
echo -n "Hello" >file.txt
将以下内容放入src/main.rs文件中:
use async_std::fs::File;
use async_std::prelude::*;
use futures::executor::block_on;
use futures::try_join;
fn main() {
block_on(parallel_read_file()).unwrap();
}
async fn parallel_read_file() -> std::io::Result<()> {
print_file(1).await?;
println!();
print_file(2).await?;
println!();
print_file(3).await?;
println!();
try_join!(print_file(1), print_file(2), print_file(3))?;
println!();
Ok(())
}
async fn print_file(instance: u32) -> std::io::Result<()> {
let mut file = File::open("file.txt").await?;
let mut byte = [0u8];
while file.read(&mut byte).await? > 0 {
print!("{}:{} ", instance, byte[0] as char);
}
Ok(())
}
如果你运行这个项目,输出并不完全确定。可能的输出如下:
1:H 1:e 1:l 1:l 1:o
2:H 2:e 2:l 2:l 2:o
3:H 3:e 3:l 3:l 3:o
1:H 2:H 3:H 1:e 2:e 3:e 1:l 1:l 3:l 1:o 2:l 3:l 2:l 3:o 2:o
前三条线是确定的。相反,最后一条线可以稍微打乱一下。
在第一次阅读时,假设它是同步代码,忽略async、await、block_on和join!等单词。通过这种简化,流程很容易理解。
main函数调用parallel_read_file函数。parallel_read_file函数的前六行分别三次调用print_file函数,参数为1、2和3,每行后面跟着一个println!调用。parallel_read_file函数的第七行再次三次调用print_file函数,使用相同的三个参数。
print_file函数使用File::open函数调用打开一个文件,然后使用file.read函数调用从该文件中一次读取一个字节。读取到的任何字节都会打印出来,前面跟着函数的参数(instance)。
因此,我们得到的信息是第一次调用print_file打印了1:H 1:e 1:l 1:l 1:o。它们是从文件中读取的五个字符,前面跟着数字1,作为函数的参数接收。
第四行打印了前三条线的相同内容,混合了字符。首先打印了三个H字符,然后是三个e字符,然后是三个l字符,然后发生了一些奇怪的事情:在所有l字符打印完毕之前,打印了一个o。
发生的事情是前三条线是通过print_file函数的三个连续调用打印的,而最后一条线是通过对同一函数的三个并行调用打印的。在任何并行调用中,一个调用打印的所有字母都是正确的顺序,但其他调用可能会交错它们的输出。
如果你认为这类似于多线程,你离真相不远了。尽管如此,有一个重要的区别。使用线程时,操作系统可以在任何时候中断线程并将控制权传递给另一个线程,这可能导致输出在不受欢迎的点中断。
为了避免这种中断,必须使用关键区域或其他同步机制。相反,在异步编程中,函数永远不会被中断,除非执行特定的异步操作。通常,这样的操作是调用外部服务,例如访问文件系统,这可能导致等待。而不是等待,另一个异步操作被激活。
现在,让我们从代码的开始部分看起,实现异步操作。它使用async_std库。它是标准库的异步版本。标准库仍然可用,但其函数是同步的。代码可以在以下片段中看到:
use async_std::fs::File;
use async_std::prelude::*;
要实现异步行为,必须使用此 crate 的函数。特别是,我们将使用File数据类型的函数。此外,还使用了尚未稳定的futurescrate 的一些功能。代码可以在以下片段中看到:
use futures::executor::block_on;
use futures::try_join;
然后,是main函数,其主体只包含以下一行:
block_on(parallel_read_file()).unwrap();
在这里,首先调用了parallel_read_file函数。
这是一个异步函数。当你使用正常的函数调用语法调用异步函数时,就像在parallel_read_file()表达式中一样,该函数的函数体实际上并没有被执行,就像一个正常和同步的函数那样。相反,这样的调用只是返回一个对象,称为future。future 类似于闭包,因为它封装了一个函数和用于调用该函数的参数。返回的 future 中封装的函数是我们调用函数的函数体。
要实际运行封装在future中的函数,需要一个特定的函数,称为执行器。block_on函数是一个执行器。当调用执行器并传递一个future给它时,该future封装的函数体将被执行,然后该函数返回的值由执行器本身返回。
因此,当调用block_on函数时,parallel_read_file的函数体将被执行,当它终止时,block_on也会终止,并返回parallel_read_file返回的相同值。由于这个最后的函数有一个Result值类型,应该将其展开。
然后,定义了一个签名如下所示的功能:
async fn parallel_read_file() -> std::io::Result<()>
async关键字标记该函数为异步的。它也是可能出错的,因此返回一个Result值。
异步函数只能由其他异步函数或执行器调用,例如block_on和try_join。main函数不是异步的,因此在那里我们需要一个执行器。
以下代码片段中添加了函数体的第一行。它是调用print_file函数的调用,传递值1给它。由于print_file函数也是异步的,因此要从异步函数内部调用它,必须使用.await子句。这样的函数是可能出错的,因此添加了一个?运算符,如下所示:
print_file(1).await?;
当使用 .await 调用一个异步函数时,该函数的主体立即开始执行,但一旦它因为执行一个阻塞函数(如操作系统调用)而交出控制权,另一个就绪的异步函数可能继续执行。然而,控制流不会超出 .await 子句,直到被调用函数的主体执行完毕。
函数主体中的第二行是同步函数的调用,因此不需要也不允许 .await,如下面的代码片段所示:
println!();
我们可以确信它是在前一条语句之后运行的,因为那条语句以 .await 子句结束。
这种模式重复三次,然后第七行包含一组与相同异步函数并行调用的三个调用,如下面的代码片段所示:
try_join!(print_file(1), print_file(2), print_file(3))?;
即使 try_join! 宏也是一个执行器。它运行由三次 print_file 调用生成的所有三个未来。异步编程只使用一个线程,因此实际上首先运行三个未来中的一个。如果它永远不需要等待,它会在其他未来有机会开始之前结束。
相反,由于这个函数将不得不等待,在任何等待时刻上下文都会切换到另一个正在运行的未来,从将其置于等待状态的语句开始。因此,三个未来的执行是交织在一起的。
现在,让我们看看这种被调用函数的定义。其签名如下面的代码片段所示:
async fn print_file(instance: u32) -> std::io::Result<()> {
它是一个异步函数,接收一个整数参数并返回一个空的 Result 值。
其主体中的第一行使用异步标准库中的 File 数据类型打开一个文件,如下面的代码片段所示:
let mut file = File::open("file.txt").await?;
因此,open 函数也是异步的,它必须跟随 .await,如下面的代码片段所示:
let mut byte = [0u8];
while file.read(&mut byte).await? > 0 {
print!("{}:{} ", instance, byte[0] as char);
}
异步的 read 函数用于读取字节以填充 byte 缓冲区。此缓冲区长度为 1,因此每次只读取一个字节。read 函数是可能出错的,如果成功,它返回读取的字节数。这意味着如果读取了一个字节,它返回 1,如果文件结束,它返回 0。如果调用读取了一个字节,循环继续。
循环的主体是一个同步输出语句。它打印当前文件流实例的标识符和刚刚读取的字节。
因此,步骤序列如下。
首先,启动print_file(1) future。当它执行阻塞的File::open调用时,这个 future 被挂起,并寻找一个准备就绪的 future。有两个准备就绪的 future:print_file(2)和print_file(3)。第一个被选中并启动。它也达到了File::open调用,因此被挂起,并启动第三个 future。当它达到File::open调用时,它被挂起并寻找一个准备就绪的 future。如果没有准备就绪的 future,线程本身将等待第一个准备就绪的 future。
首个完成File::open调用的 future 是第一个,它在调用之后立即恢复执行并开始从文件中读取一个字节。即使这个操作也是阻塞的,所以这个 future 被挂起,控制权转移到第二个 future,它开始读取一个字节。
总是有一个准备就绪的 future 队列。当一个 future 需要等待一个操作时,它将控制权交给执行器,执行器将控制权传递给准备就绪队列中的第一个 future。当阻塞操作完成时,等待的 future 被追加到准备就绪队列中,如果没有其他 future 正在运行,它可以获得控制权。
当读取完文件的所有字节后,print_file函数结束。当所有三个print_file调用都结束时,try_join!执行器结束,parallel_read_file函数可以继续执行。当它到达其末尾时,block_on执行器结束,随之整个程序结束。
由于阻塞操作需要可变的时间量,步骤的顺序是非确定性的。确实,之前看到的示例程序的最后一行输出在不同的运行中可能会有所不同,交换其中的一些部分。
正如我们所见,异步编程类似于多线程编程,但效率更高,可以节省上下文切换时间和内存使用。它主要适用于输入/输出(I/O)密集型任务,因为只使用一个线程,并且只有当执行 I/O 操作时才会中断控制流。相反,多线程可以在任何核心上分配不同的线程,因此更适合中央处理器(CPU)密集型操作。
在添加了async/await语法扩展之后,还需要开发和稳定使用并支持这种语法的 crate。
优化
通常,系统程序员对效率非常感兴趣。在这方面,Rust 以其高效性而闻名,尽管仍然存在一些性能问题,如下所述:
-
完整构建——特别是优化后的发布构建——相当慢,如果启用了链接时间优化,则更是如此。对于大型项目来说,这可能会相当麻烦。目前,Rust 编译器只是一个前端,它生成 低级虚拟机 (LLVM) 中间表示 (IR) 代码,并将此类代码传递给 LLVM 机器码生成器。然而,Rust 编译器生成的 LLVM IR 代码量不成比例,因此 LLVM 后端必须花费很长时间来优化它。一个改进的 Rust 编译器会将一个更紧凑的指令序列传递给 LLVM。编译器的重构正在进行中,这可能会导致编译器更快。
-
自 1.37 版本以来,Rust 编译器支持 性能分析指导优化 (PGO),这可以提高典型处理器工作流程的性能。然而,此功能使用起来相当繁琐。图形前端或 IDE 集成将使其更容易使用。
-
正在进行开发的是向语言添加 const 泛型 功能,这在下一节中描述。
-
在 LLVM IR 中,任何指针类型的函数参数都可以用
noalias属性标记,这意味着此指针引用的内存在此函数内部不会改变,除非通过此指针。利用此信息,LLVM 可以生成更快的机器码。此属性类似于 C 语言中的restrict关键字。然而,在 Rust 中,对于 每个 可变引用 (&mut),noalias属性由语言所有权规则保证。因此,可以获得更快的程序,这些程序始终为每个可变引用生成带有noalias属性的 LLVM IR 代码。这已经在 1.0 到 1.7 版本以及 1.28 和 1.29 版本中实现,尽管由于 LLVM 后端编译器中的错误,生成的代码存在缺陷。因此,直到发布正确的 LLVM 实现之前,noalias优化提示将不会使用。
const 泛型功能
目前,泛型数据类型只能由类型或生命周期参数化。能够通过常量表达式来参数化泛型数据类型也是有用的。从某种意义上说,此功能已经可用,但仅限于一种泛型类型:数组。你可以有 [u32; 7] 类型,这是一个由 u32 类型以及常量 7 参数化的数组,尽管你不能定义自己的由常量参数化的泛型类型。
此功能已在 C++ 语言中可用,正在夜间构建中进行开发。它将允许在泛型代码中将变量替换为常量,这无疑会提高性能。以下是一个使用 num 包作为依赖项的示例程序:
#![feature(const_generics)]
#![allow(incomplete_features)]
use num::Float;
struct Array2<T: Float, const WIDTH: usize, const HEIGHT: usize> {
data: [[T; WIDTH]; HEIGHT],
}
impl<T: Float, const WIDTH: usize, const HEIGHT: usize>
Array2<T, WIDTH, HEIGHT> {
fn new() -> Self {
Self { data: [[T::zero(); WIDTH]; HEIGHT] }
}
fn width(&self) -> usize { WIDTH }
fn height(&self) -> usize { HEIGHT }
}
fn main() {
let matrix = Array2::<f64, 4, 3>::new();
print!("{} {}", matrix.width(), matrix.height());
}
此程序仅使用编译器的夜间版本进行编译,创建了一个实现二维浮点数数组的类型。请注意,参数化如下:T: Float, const WIDTH: usize, const HEIGHT: usize。第一个参数是数组元素的类型。第二个和第三个参数是数组的大小。
使用常量值而不是变量允许重要的代码优化。
嵌入式系统
Rust 自 2009 年 Mozilla 开始赞助它以来一直在开发,有一个具体的目标:创建一个网络浏览器。即使到 2018 年,开发者的核心团队仍然为 Mozilla 基金会工作,其主营业务是构建客户端网络应用程序。此类软件是跨平台的,但专门针对以下要求:
-
随机存取存储器 (RAM): 至少 1 GB
-
支持的 CPU:最初仅支持 x86 和 x86_64;后来,也支持 ARM 和 ARM64。
-
支持的操作系统:Linux、Windows、macOS
这些要求排除了大多数微控制器,因为 Mozilla 基金会对此类平台不感兴趣,尽管 Rust 的特性似乎与许多具有更多约束要求的嵌入式系统的需求相匹配。因此,多亏了一个全球志愿者团队,2018 年成立了嵌入式工作组,以开发在嵌入式系统上使用 Rust 所需的生态系统——即在裸机或简化版的操作系统上,以及具有严重资源限制的系统。
在这个应用领域的发展相当缓慢,主要针对少数几种架构,但未来前景看好,至少对于 32 位或 64 位架构来说如此,因为任何由 LLVM 后端支持的架构都很容易被 Rust 编译器定位。
列出了以下针对语言的一些特定改进,这些改进简化了 Rust 在嵌入式系统中的使用:
-
标准库中的
Pin泛型类避免了在内存中移动对象。当某些外部设备访问内存位置时,这是必需的。 -
允许条件编译的
cfg和cfg_attr属性已被扩展。这个特性是必需的,因为尝试为错误平台编译代码可能会产生不可接受的代码膨胀,甚至导致编译错误。 -
allocatorAPI 已被做得更具可定制性。 -
const fn的适用性已经扩展。这个结构允许代码库在作为正常算法代码维护的同时,效率等同于硬编码的常量。
摘要
在本章中,我们看到了 Rust 生态系统在未来几年中最可能的发展方向——对 IDE 和交互式编程的支持;最受欢迎的 crate 的成熟;对新的异步编程范式及其关键字(async 和 await)的广泛支持;编译器和生成的机器代码的进一步优化;以及嵌入式系统编程的广泛支持。
我们已经学会了如何编写异步代码,以及定义和使用 const generics 的可能方法(在撰写本文时仍然不稳定)。
我们已经看到,有很多应用领域是 Rust 能够真正大放异彩的。当然,如果你只是想用它来娱乐,那么天空才是极限,但对于实际应用来说,库和工具的生态系统确实可以决定一个编程系统的可行性。现在,终于,高质量库和工具的临界质量即将达到。
评估
第一章
-
是的,这是由 Steve Klabnik 和 Carol Nichols 编著的 《Rust 编程语言》。
-
在 2015 年,它是长 64 位(或 8 字节)。到 2018 年底,它是长 128 位(或 16 字节)。
-
它们是网络、命令行应用程序、WebAssembly 和嵌入式软件。
-
它检查非惯用语法,并建议对代码进行更改以实现更好的可维护性。
-
它将 2015 版本的项目转换为 2018 版本的项目。
-
将此依赖项添加到
Cargo.toml文件中:
rand = "0.6"
然后,将此代码添加到 main.rs 文件中:
use rand::prelude::*;
fn main() {
let mut rng = thread_rng();
let mut numbers = vec![];
for _ in 0..10 {
numbers.push(rng.gen_range(100_f32, 400_f32));
}
println!("{:?} ", numbers)
}
- 使用前一个问题中使用的依赖项,将此代码添加到
main.rs文件中:
use rand::prelude::*;
fn main() {
let mut rng = thread_rng();
let mut numbers = vec![];
for _ in 0..10 {
numbers.push(rng.gen_range(100_i32, 401_i32));
}
println!("{:?} ", numbers)
}
- 将此依赖项添加到
Cargo.toml文件中:
lazy_static = "1.2"
然后,将此代码插入到 main.rs 文件中:
use lazy_static::lazy_static;
lazy_static! {
static ref SQUARES_FROM_1_TO_200: Vec<u32> = {
let mut v = vec![];
for i in 1.. {
let ii = i * i;
if ii > 200 { break; }
v.push(ii);
}
v
};
}
fn main() {
println!("{:?}", *SQUARES_FROM_1_TO_200);
}
- 首先,将此依赖项添加到
Cargo.toml文件中:
log = "0.4"
env_logger = "0.6"
然后,将此代码插入到 main.rs 文件中并执行 RUST_LOG=warn cargo run:
#[macro_use]
extern crate log;
fn main() {
env_logger::init();
warn!("Warning message");
info!("Information message");
}
- 将此依赖项添加到
Cargo.toml文件中:
structopt = "0.2"
然后,将此代码添加到 main.rs 文件中:
use structopt::StructOpt;
#[derive(StructOpt, Debug)]
struct Opt {
#[structopt(short = "l", long = "level")]
level: u32,
}
fn main() {
let options = Opt::from_args();
if options.level < 1 || options.level > 20 {
println!("Invalid level (1 to 20 is expected): {}", options.level);
} else {
println!("Level is {}", options.level);
}
}
第二章
-
因为软件应用的变化会丢失用户插入的所有注释,并按字母顺序排序项目。
-
当你不确定文件中哪些字段将存在,并希望允许一些缺失字段时,动态类型解析更好。当你想要丢弃不遵守预期格式的文件时,静态类型解析更好。
-
当你需要将数据结构从你的软件中发送(写入)时,需要一个从
Serialize派生的类型。当你想要接收(读取)数据结构时,需要一个从Deserialize派生的类型。 -
这是一个字段缩进以直观显示数据结构的格式。
-
因为它通过分批将数据加载到内存中来最小化内存的使用。
-
SQLite 在你想节省磁盘空间、内存空间、启动时间和有时也提高吞吐量时更好。PostgreSQL 在你有复杂的安全需求,或者你的数据必须同时供多个用户访问时更好。
-
这是一个指向可以转换为
ToSql的对象的引用的切片的引用。 -
这替换了 SQL
SELECT语句中的参数,然后创建并返回由该语句选择的行的迭代器。 -
get函数读取值;set函数写入值。 -
让我们使用一个包含关联 aKey => a string 的本地 Redis 实例。将此依赖项添加到
Cargo.toml文件中:
redis = "0.16"
然后,将此代码添加到 main.rs 文件中:
use redis::Commands;
fn main() -> redis::RedisResult<()> {
let id = std::env::args().nth(1).unwrap();
let client = redis::Client::open("redis://localhost/")?;
let mut conn = client.get_connection()?;
if let Ok(value) = conn.get::<_, String>(&id) {
println!("Value of '{}' is '{}'.", id, value);
} else {
println!("Id '{}' not found.", id);
}
Ok(())
}
第三章
-
GET请求下载资源;PUT发送数据以替换现有数据;POST发送一些服务器应视为新数据的数据;DELETE请求删除资源。 -
Curl 工具。
-
处理器声明一个参数,例如
info: Path<(String,)>,然后&info.0表达式的值是第一个 URI 参数的引用。 -
通过使用
HttpResponse类型的content_type方法——例如,HttpResponse::Ok().content_type("application/json")。 -
使用伪随机数生成器生成一个大整数,将其格式化为字符串,并将其附加到前缀。然后,你尝试创建一个具有该名称的新文件。如果创建失败,因为另一个文件已存在且具有该名称,你将尝试生成另一个文件名,直到找到一个未使用的组合。
-
为了缓存可以使用任何请求再次获取的信息,但这样做成本较高。
-
因为状态被所有请求共享,并且 Actix web 使用多个线程来处理请求,所以状态必须是线程安全的。在 Rust 中声明线程安全对象的典型方式是将它封装在 Mutex 对象中。
-
因为服务器可能需要等待从数据库、文件系统或另一个进程获取数据,在此等待期间,它可以处理其他请求。多线程是另一种可能的解决方案,但性能较差。
-
它将另一个 future 链接到当前 future 上。第一个 future 完成后,第二个闭包将异步执行。
-
serde用于序列化任何内容;serde_derive用于自动为某些数据类型实现序列化;以及serde_json用于自动为 JSON 数据实现序列化。
第四章
- 创建包含可变部分的 HTML 代码的可能策略如下:
-
仅代码:你有一个包含许多打印字符串以创建所需 HTML 页面的编程语言源文件。
-
带有标签的 HTML:你编写一个包含所需常量 HTML 元素和所需常量文本的 HTML 文件,但它还包含一些用特定标记括起来的语句。
-
HTML 模板:你编写包含标签和填充这些标签的应用代码的 HTML 模板。
-
使用双花括号,例如,
{{id}}。 -
这里使用
{%和%}标记,如下所示:
{%if person%}Id: {{person.id}}\
{%else%}No person\
{%endif%}
-
首先,创建一个
tera::Context类型的对象,然后使用其insert方法向该对象添加必要的名称-值关联。最后,将此上下文作为参数传递给 Tera 引擎的render方法。 -
在架构层面,可以将请求视为数据操作命令,或视为请求在浏览器中显示文档。传统上,这两种类型的请求合并为一个数据操作命令,其响应是当前页面的新内容。
-
因为一些部分(元数据、脚本、样式,以及可能还有页面头部和页脚)在会话期间不会改变或很少改变。其他部分(通常是中心部分或较小部分)会随着用户的任何点击而改变。通过只重新加载改变的部分,应用程序具有更好的性能和可用性。
-
所有模板文件都是在运行时加载的,因此必须部署模板的子树。
-
内置的 JavaScript
XMLHttpRequest类可以被实例化,并且这些实例有发送 HTTP 请求的方法。 -
它应该存储在当前网页中的全局 JavaScript 变量中。
-
处理器可以有一个
BasicAuth类型的参数,该参数封装了 HTTP 请求的授权头。这样的对象具有user_id和password方法。
第五章
-
它是一种类似于标准机器语言的编程语言,被所有主要网络浏览器接受。它可以比 JavaScript 更高效,但比其他类似机器语言的编程语言更便携。
-
它是一种交互式软件的架构模式。它使用模型的概念,即包含应用程序状态的数据库结构;视图,即使用模型的当前值来显示窗口或窗口一部分的代码;以及控制器,即由用户在窗口上的操作激活的代码,更新模型值并激活视图刷新。
-
Yew 和 Elm 语言使用的 MVC 实现的具体版本基于程序员定义的可能事件集合,称为消息。当视图检测到此类可能事件时,控制器通过与事件类型相关联的消息通知。
-
Yew 组件是 MVC 模式的一个实例。每个三联模型-视图-控制器都是一个组件。
-
Yew 属性是任何父组件在创建它们时传递给其子组件的数据。它们在组件层次结构中共享数据时是必需的。
-
你创建两个 Yew 组件——一个处理内部部分,另一个处理页眉和页脚——并且后者包含前者作为其子组件。
-
回调是可以作为属性传递给其子组件的可调用对象,以便它能够访问父组件的功能。
-
你将其作为属性传递,将其封装为
std::rc::Rc<std::cell::RefCell>类型的对象。 -
因为如果你只将其保留在局部变量中,它将在创建它的函数结束时被销毁。为了确保它能够在从服务器收到响应之前存活,这个对象必须保留在一个寿命较长的结构中。
-
在你的模型中,你声明一个
DialogService类型的对象,并使用其alert和confirm方法。 -
这留给读者。我在书的 GitHub 仓库中创建了一个示例。
第六章
-
它是一种交互式软件架构,主要用于游戏。在周期性间隔内,框架检查输入设备的状态,相应地修改模型,然后调用绘图例程。其优点是它更好地对应于输入设备具有连续输入的情况,例如某个键被按了一段时间,或者屏幕输出连续变化,即使用户没有进行任何操作。
-
当输入事件是离散的,例如在按钮上点击鼠标或输入框中输入文本时,并且当输出仅因为用户操作发生时。
-
连续仿真软件、工业机器监控软件或多媒体软件。
-
要绘制一个形状,你调用当前窗口的
draw_ex方法。该方法的第一参数描述了要绘制的形状;它可能是一个Triangle、Rectangle或Circle类型的实例。 -
在
update函数中,你可以检查键盘上任何键的状态。例如,window.keyboard()[Key::Right].is_down()表达式在右箭头键被按下时返回true。 -
模型必须实现
State特性。在该特性中,update方法是控制器,draw方法是视图。 -
Quicksilver 有两个速率,一个用于
update方法,一个用于draw方法。它们有默认值,但如果你想改变它们,设置传递给启动应用程序的run函数的Settings结构中的update_rate和draw_rate字段。 -
你可以通过调用
Font::load(filename)函数来开始加载字体,通过调用Sound::load(filename)函数来开始加载声音,等等。这样的调用会返回一个等待实际资源加载的 future。然后,你调用Asset::new函数,指定 future 作为其参数。第一次使用时,它将等待资源的完整加载。资源必须位于项目根目录下名为static的文件夹中。 -
在一个变量中加载了记录的声音资源之后,你可以调用
play_sound函数,并将该资源作为参数传递。 -
在一个变量中加载了字体资源之后,在
draw方法中,你可以调用该资源的execute方法,该方法等待字体完全加载,然后你调用已加载资源的render方法来在图像中绘制文本。然后,你可以通过调用窗口的draw方法在该窗口上绘制该图像。
第七章
-
向量是一个可以添加到另一个向量中并且可以乘以一个数的实体。将两个点相加或对一个点乘以一个数是没有意义的。
-
在几何学中,向量是一个平移或位移;点是一个位置。
-
因为某些事件是离散的。例如,当我点击一个按钮时,我对鼠标按下多少毫秒不感兴趣;我只想要得到一个点击事件。如果我输入一个单词,我想要在每次按键时得到一个字符输入。
-
因为资源通常只在应用程序启动时加载,或者当进入或退出关卡时。
-
可以为
EventHandler特性定义可选的key_down_event、key_up_event、mouse_button_down_event和mouse_button_up_event方法。这些方法在模型中注册它们已被调用(即,在时间范围内发生了相应的事件)。然后,update方法检查并重置模型中的这些设置。 -
它是一组要绘制的形状。要绘制一个形状,首先,你创建一个新的
Mesh实例,然后向其中添加形状(矩形、三角形等),然后你可以在屏幕上绘制该网格。 -
通用方法是使用
MeshBuilder::new()创建一个MeshBuilder实例;向该构建器添加形状,使用其方法(rectangle、polygon等);然后调用build方法,该方法返回一个Mesh实例。但还有更简短的方法,例如Mesh::new_circle函数,它返回一个包含单个圆的Mesh实例。 -
update方法总是以最高速度调用,但它会反复检查内部计时器,以确保其主体只执行所需的次数。 -
draw函数使用接收绘图上下文、要绘制的网格和DrawParam结构作为参数。这个结构可以包含在绘制网格时应用到网格上的几何变换。 -
audio::Source对象有几个方法,包括play和play_detached方法。第一个方法在播放指定声音之前会自动停止之前的音效;第二个方法会与现有音效重叠。
第八章
-
正则语言是可以由正则表达式定义的,它是由三个运算符的组合:连接、交替和重复。上下文无关语言是可以包含正则运算符,以及匹配符号(如括号)。上下文相关语言是其中任何表达式的有效性可能依赖于之前定义的任何其他表达式的语言。
-
它是一组规则,其中程序是一个符号,每个符号都被定义为符号或字符的连接或交替。
-
它是一个接收编程语言的正式定义作为输入并生成编译器作为输出的程序,编译器是一个解析(甚至编译成机器语言)指定语言编写的程序的程序。
-
它是一个接收一个或多个解析器作为输入并返回以某种方式组合输入解析器的解析器的函数。
-
因为在 Rust 语言的 2018 年版本之前,Rust 语言不允许返回函数而不将它们封装在分配的对象中。允许函数无分配返回的功能被称为
impl Trait。 -
tuple解析器组合器接收一系列解析器,并返回一个按顺序应用它们的解析器。alt解析器组合器接收一系列解析器,并返回一个交替应用它们的解析器。map解析器组合器接收一个解析器和闭包,并返回一个应用该解析器然后使用闭包转换其输出的解析器。 -
词法分析、语法分析、语义分析和解释。
-
词法分析、语法分析、语义分析、中间代码生成、中间代码优化、可重定位机器代码生成和链接。
-
当定义一个标识符时,需要符号表来检查在当前作用域中该名称尚未被定义,如果语言不允许标识符的遮蔽。当使用标识符时,需要符号表来检查该名称已经定义,并且它具有与使用兼容的类型。
-
当定义一个标识符时,需要符号表来存储标识符的初始值。当使用标识符时,需要符号表来获取或设置与该标识符关联的值。
第九章
- 可能的用途:
-
当计算机不可用时要运行二进制程序
-
当源代码不可用时,调试或分析二进制程序
-
反汇编机器代码
-
将二进制程序翻译成另一种机器语言
-
将二进制程序翻译成高级编程语言
-
它是主要的数据寄存器。它是任何指令的默认源和目标。
-
它是主要地址寄存器。它包含将要被检索和执行的下一个指令的地址。
-
一个原因是使用数字比使用名称更容易出错。另一个原因是当添加或删除指令或变量时,所有后续指令或变量的地址都会改变,因此代码中的许多地址必须增加或减少。
-
为每种指令类型定义一个变体。变体的名称是指令的符号名称,其参数是指令操作数的类型。
-
小端表示法是指一个字的低字节具有较低的内存地址,而大端表示法是指高字节具有较低的内存地址。
-
对于接受文本的解析器,输入是字符串切片的引用,具有
&str类型,而对于接受二进制数据的解析器,输入是字节切片的引用,具有&[u8]类型。 -
需要遵守的规则如下:
-
它以包含进程大小的字节数的小端字开始。
-
在初始单词之后,有一系列有效的机器语言指令,没有交错的空间或数据。
-
Terminate指令只出现一次,作为最后一个指令,以标记指令序列的结束。之后,只剩下数据。 -
没有语句写入指令;只有数据可以更改。因此,程序不是自修改的;换句话说,程序指令与过程指令相同。
-
因为有时一个 16 位数字可以有用地被视为一对字节,有时又是一个单独的数字。十六进制格式满足这两个要求,因为每对十六进制数字是一个字节,整个四位数序列是一个 16 位数字。
-
通过将其封装在一个新类型中,然后为该类型实现
Debug特质。
第十章
-
它是 Linux 操作系统内核的一个扩展,可以在运行时添加或删除。
-
C 编程语言,带有 GCC 扩展。
-
这是一个仅用于记录内存的区域,每个内核模块都可以向其中写入。当内核模块向其写入时,会在每行的开头添加一个括号包围的时间戳;这是自内核启动以来的秒数和微秒数。
-
ModInfo打印有关 Linux 模块文件的一些信息;LsMod打印当前所有已加载模块的列表;InsMod从指定的文件加载 Linux 模块并将其添加到正在运行的内核中;RmMod从正在运行的 Linux 内核卸载指定的模块。 -
因为
![no_std]指令阻止了标准堆分配器的使用以及所有使用它的标准类型。任何内核模块都需要一个自定义分配器,所以这个指令是必需的。 -
它是向链接器的一个指令,以保留以下函数的确切名称,以便内核可以通过名称找到该函数。
-
它指定函数调用约定必须是 C 语言通常使用的那个。
-
它是模块的两个入口点:当模块被加载时,内核会调用
init_module函数,当模块被卸载时,内核会调用cleanup_module函数。 -
__register_chrdev用于在内核中注册字符设备;__unregister_chrdev用于注销它。 -
_copy_to_user函数。


.
.
浙公网安备 33010602011771号