Rust-Web-编程-全-
Rust Web 编程(全)
原文:
annas-archive.org/md5/aea44796c434395736f40ffa10fc61c1
译者:飞龙
前言
你是否希望将你的 Web 应用程序推向极限,以实现速度和低能耗,同时仍然保持内存安全?Rust 使你能够在没有垃圾回收和与 C 编程语言相似的能量消耗的情况下实现内存安全。这意味着你可以相对容易地创建高性能和安全的应用程序。
本书将带你经历 Web 开发的每个阶段,最终部署使用 Rust 构建并打包在 distroless Docker 中的高级 Web 应用程序,在 AWS 上使用我们创建的自动化构建和部署管道,生成的服务器镜像大小约为 50MB。
你将从 Rust 编程的介绍开始,这样你就可以避免在从传统动态编程语言迁移时遇到常见的问题。本书将向你展示如何为跨越多页和模块的项目结构化 Rust 代码。接下来,你将探索 Actix Web 框架,并搭建一个基本的 Web 服务器。随着你的进步,你将学习如何处理 JSON 请求,并通过 HTML、CSS 和 JavaScript 显示服务器数据,甚至为我们的数据构建一个基本的 React 应用程序。你还将学习如何在 Rust 中持久化数据并创建 RESTful 服务,其中我们在前端登录和验证用户,并缓存数据。稍后,你将在 AWS 上为应用程序构建自动化的构建和部署流程,使用两个 EC2 实例,我们将从注册域名平衡 HTTPS 流量到这些 EC2 实例上的应用程序,我们将使用 Terraform 构建这些实例。你还将通过在 Terraform 中配置安全组直接锁定到 EC2 实例的流量。然后,你将涵盖多层级构建以生成 distroless Rust 镜像。最后,你将涵盖高级 Web 概念,探索异步 Rust、Tokio、Hyper 和 TCP 帧。有了这些工具,你将实现 actor 模型,使你能够实现高级复杂的异步实时事件处理系统,通过构建一个基本的股票购买系统进行实践。你将通过在 Redis 上构建自己的队列机制来结束本书,其中你的 Rust 自建服务器和工作节点消费队列上的任务并处理这些任务。
这本书面向的对象
这本关于使用 Rust 进行 Web 编程的书籍是为那些在传统语言(如 Python、Ruby、JavaScript 和 Java)中编程的 Web 开发者所写,他们希望使用 Rust 开发高性能的 Web 应用程序。尽管不需要有 Rust 的先验经验,但如果你想要充分利用这本书,你需要对 Web 开发原则有扎实的理解,并对 HTML、CSS 和 JavaScript 有基本的知识。
本书涵盖的内容
第一章,Rust 快速入门,提供了 Rust 编程语言的基础知识。
第二章,在 Rust 中设计你的 Web 应用程序,涵盖了在 Rust 中构建和管理应用程序。
第三章, 处理 HTTP 请求,介绍了使用 Actix Web 框架构建一个基本的 Rust 服务器,该服务器可以处理 HTTP 请求。
第四章, 处理 HTTP 请求,介绍了从传入的 HTTP 请求中提取和处理数据。
第五章, 在浏览器中显示内容,介绍了使用 HTML、CSS 和 JavaScript 以及 React 从服务器显示数据并向服务器发送请求。
第六章, 使用 PostgreSQL 进行数据持久性,介绍了在 PostgreSQL 中管理和结构化数据,以及使用我们的 Rust Web 服务器与数据库交互。
第七章, 管理用户会话,介绍了在向 Web 服务器发送请求时进行身份验证和管理用户会话。
第八章, 构建 RESTful 服务,介绍了为 Rust Web 服务器实现 RESTful 概念。
第九章, 测试我们的应用程序端点和组件,介绍了端到端测试管道和在使用 Postman 对 Rust Web 服务器进行单元测试。
第十章, 在 AWS 上部署我们的应用程序,介绍了使用 Docker 构建自动化构建和部署管道,并在 AWS 上使用 Terraform 自动化基础设施构建。
第十一章, 在 AWS 上使用 NGINX 配置 HTTPS,介绍了在 AWS 上通过 NGINX 配置 HTTPS 和路由,并通过负载均衡将流量路由到不同的应用程序,这取决于 URL 中的端点。
第十二章, 在 Rocket 中重构我们的应用程序,介绍了将现有应用程序集成到 Rocket Web 框架中。
第十三章, 干净 Web 应用程序仓库的最佳实践,介绍了使用多阶段 Docker 构建来清理 Web 应用程序仓库,以生成更小的镜像,并初始化 Docker 容器以在部署时自动化数据库迁移。
第十四章, 探索 Tokio 框架,介绍了使用 Tokio 框架实现基本异步代码,以促进异步运行时。
第十五章, 使用 Tokio 接受 TCP 流量,涵盖了发送、接收和处理 TCP 流量的内容。
第十六章, 在 TCP 之上构建协议,介绍了使用结构和帧将 TCP 字节流处理成高级数据结构。
第十七章, 使用 Hyper 框架实现 Actors 和异步操作,介绍了如何使用 actor 框架构建一个异步系统,并通过 Hyper 框架接受 HTTP 请求。
第十八章,使用 Redis 排队任务,涵盖了接受 HTTP 请求并将它们打包成任务放入 Redis 队列以供工作池处理。
为了充分利用本书
你需要了解一些关于 HTML 和 CSS 的基本概念。你还需要对 JavaScript 有一些基本理解。然而,HTML、CSS 和 JavaScript 只需要用于在浏览器中显示数据。如果你只是阅读本书以构建 Rust 后端 API 服务器,那么不需要了解 HTML、CSS 和 JavaScript。
还需要一些关于编程概念(如函数和循环)的基本理解,因为这些内容本书不会涉及。
本书涵盖的软件/硬件 | 操作系统要求 |
---|---|
Rust | Windows、macOS 或 Linux(任何) |
节点(JavaScript) | Windows、macOS 或 Linux(任何) |
Python 3 | Windows、macOS 或 Linux(任何) |
Docker | Windows、macOS 或 Linux(任何) |
docker-compose | Windows、macOS 或 Linux(任何) |
Postman | Windows、macOS 或 Linux(任何) |
Terraform | Windows、macOS 或 Linux(任何) |
为了充分利用 AWS 部署章节,你需要一个 AWS 账户,如果你不符合免费层资格,这可能会产生费用。然而,构建是使用 Terraform 自动化的,因此启动和关闭构建将非常快速和简单,所以你在阅读本书时不需要保持基础设施运行,以将成本降至最低。
如果你使用的是本书的数字版,我们建议你亲自输入代码或从本书的 GitHub 仓库(下一节中有一个链接)获取代码。这样做将帮助你避免与代码复制和粘贴相关的任何潜在错误。
到这本书结束时,你将拥有构建和部署 Rust 服务器的基础知识。然而,必须注意的是,Rust 是一种强大的语言。由于本书侧重于网络编程和部署,在本书之后 Rust 编程还有改进的空间。建议在本书之后进一步阅读 Rust,以便你能够解决更复杂的问题。
下载示例代码文件
你可以从 GitHub 下载本书的示例代码文件:github.com/PacktPublishing/Rust-Web-Programming-2nd-Edition
。如果代码有更新,它将在 GitHub 仓库中更新。
我们还有其他来自我们丰富的图书和视频目录的代码包,可在 github.com/PacktPublishing/
获取。查看它们吧!
下载彩色图像
我们还提供了一份包含本书中使用的截图和图表彩色图像的 PDF 文件。你可以从这里下载:packt.link/Z1lgk
。
使用的约定
本书使用了多种文本约定。
文本中的代码
:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“这意味着我们必须修改待办事项表现有的模式,并在src/schema.rs
文件中添加用户模式。”
代码块设置如下:
table! {
to_do (id) {
id -> Int4,
title -> Varchar,
status -> Varchar,
date -> Timestamp,
user_id -> Int4,
}
}
table! {
users (id) {
id -> Int4,
username -> Varchar,
email -> Varchar,
password -> Varchar,
unique_id -> Varchar,
}
}
当我们希望将您的注意力引向代码块中的特定部分时,相关的行或项目将以粗体显示:
[default]
exten => s,1,Dial(Zap/1|30)
exten => s,2,Voicemail(u100)
exten => s,102,Voicemail(b100)
exten => i,1,Voicemail(s0)
任何命令行输入或输出应如下所示:
docker-compose up
粗体:表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“如果我们按下 Postman 中的发送按钮两次,在最初的 30 秒内,我们会得到以下输出:”
小贴士或重要注意事项
看起来是这样的。
联系我们
我们始终欢迎读者的反馈。
一般反馈:如果您对本书的任何方面有疑问,请通过电子邮件发送至 customercare@packtpub.com,并在邮件主题中提及书名。
勘误:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将不胜感激,如果您能向我们报告这一点。请访问www.packtpub.com/support/errata并填写表格。
盗版:如果您在互联网上以任何形式发现我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过电子邮件发送至 copyright@packt.com 并提供材料的链接。
如果您有兴趣成为作者:如果您在某个主题上具有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com。
分享您的想法
读完《Rust Web Programming - 第二版》后,我们很乐意听听您的想法!请点击此处直接进入此书的亚马逊评论页面并分享您的反馈。
您的评论对我们和科技社区都很重要,并将帮助我们确保我们提供高质量的内容。
下载本书的免费 PDF 副本
感谢您购买本书!
您喜欢在路上阅读,但无法携带您的印刷书籍到处走?您的电子书购买是否与您选择的设备不兼容?
别担心,现在,随着每本 Packt 书籍,您都可以免费获得该书的 DRM 免费 PDF 版本。
在任何地方、任何设备上阅读。将您最喜欢的技术书籍中的代码直接搜索、复制并粘贴到您的应用程序中。
优惠远不止于此,您还可以获得独家折扣、时事通讯和每日免费内容的每日电子邮件。
按照以下简单步骤获取好处:
- 扫描下面的二维码或访问以下链接
https://packt.link/free-ebook/9781803234694
-
提交您的购买证明
-
就这些!我们将直接将您的免费 PDF 和其他福利发送到您的电子邮件
第一部分:Rust Web 开发入门
在 Rust 中进行编码可能具有挑战性。在本部分中,我们涵盖了 Rust 编程的基础知识以及如何在项目中构建具有依赖关系的跨多个文件的程序。在本部分结束时,您将能够使用 Rust 构建应用程序,管理依赖项,导航 Rust 借用检查器,并管理数据集合、结构体、基本设计结构以及引用结构体。
本部分包括以下章节:
-
第一章, Rust 快速入门
-
第二章, 在 Rust 中设计您的 Web 应用程序
第一章:Rust 快速入门
Rust 正在变得越来越受欢迎,但它的学习曲线很陡峭。通过了解 Rust 的基本规则,以及学习如何操作各种数据类型和变量,我们将能够以与动态类型语言相似的方式,使用相同数量的代码编写简单的程序。
本章的目标是介绍 Rust 与通用动态语言之间的主要区别,并快速让你了解如何利用 Rust。
本章将涵盖以下主题:
-
为什么 Rust 是革命性的?
-
检查 Rust 中的数据类型和变量
-
控制变量所有权
-
构建 struct
-
使用宏进行元编程
一旦我们掌握了本章的主要概念,你将能够编写能在 Rust 中运行的简单程序。你还将能够调试你的程序并理解 Rust 编译器抛出的错误信息。因此,你将拥有在 Rust 中高效工作的基础。你还将能够将 Rust 代码组织到多个文件中。
技术要求
对于本章,我们只需要访问互联网,因为我们将在在线 Rust 操场中实现代码。提供的代码示例可以在在线 Rust 操场中运行。play.rust-lang.org/
对于详细说明,请参阅以下文件:github.com/PacktPublishing/Rust-Web-Programming-2nd-Edition/tree/main/chapter01
为什么 Rust 是革命性的?
在编程中,通常需要在速度和资源、开发速度和安全之间做出权衡。像 C/C++ 这样的低级语言可以给开发者提供对计算机的精细控制,代码执行速度快,资源消耗最小。然而,这并非没有代价。手动内存管理可能会引入错误和安全漏洞。一个简单的例子是缓冲区溢出攻击。这发生在程序员没有分配足够内存的情况下。例如,如果缓冲区只有 15 字节大,而发送了 20 字节,那么额外的 5 字节可能会超出边界。攻击者可以通过传递比缓冲区能处理的更多字节来利用这一点。这可能会覆盖包含可执行代码的区域,并用自己的代码覆盖它们。还有其他方法可以攻击没有正确管理内存的程序。除了增加了漏洞之外,解决低级语言中的问题需要更多的代码和时间。因此,C++ 网络框架并没有占据网络开发的大份额。相反,通常选择使用像 Python、Ruby 和 JavaScript 这样的高级语言更有意义。使用这些语言通常会导致开发者安全且快速地解决问题。
然而,必须指出的是,这种内存安全是有代价的。这些高级语言通常跟踪所有定义的变量及其对内存地址的引用。当没有更多的变量指向内存地址时,该内存地址中的数据就会被删除。这个过程称为垃圾回收,它消耗额外的资源和时间,因为程序必须停止以清理变量。
使用 Rust,内存安全无需昂贵的垃圾回收过程。Rust 通过在编译时使用借用检查器检查的一组所有权规则来确保内存安全。这些规则是下一节中提到的怪癖。正因为如此,Rust 能够通过真正高效的代码实现快速、安全的解决问题,从而打破了速度/安全性的权衡。
内存安全
内存安全是程序具有始终指向有效内存的内存指针的特性。
随着数据处理、交通和复杂任务被提升到网络堆栈中,Rust 凭借其不断增长的 Web 框架和库的数量,现在已成为 Web 开发的可行选择。这为 Rust 在 Web 领域带来了真正惊人的成果。在 2020 年,Shimul Chowdhury 对具有相同规格但不同语言和框架的服务器进行了一系列测试。结果可以在以下图表中看到:
图 1.1 – Shimul Chowdhury 对不同框架和语言的结果(可在 https://www.shimul.dev/posts/06-04-2020-benchmarking-flask-falcon-actix-web-rocket-nestjs/找到)
在前面的图表中,我们可以看到语言和框架之间存在一些差异。然而,我们必须注意,Rust 框架包括 Actix Web 和 Rocket。在处理总请求和数据传输方面,这些 Rust 服务器完全处于不同的水平。其他语言,如 Golang,已经进入市场,但 Rust 中缺乏垃圾回收功能却成功地超越了 Golang。这可以在 Jesse Howarth 的博客文章《Why Discord is switching from Go to Rust》中得到证明,其中发布了以下图表:
图 1.2 – Discord 的发现 => Golang 波动较大,Rust 则平滑(可在 https://discord.com/blog/why-discord-is-switching-from-go-to-rust 找到)
Golang 为了保持内存安全而实施的垃圾回收导致 2 分钟的峰值。这并不是说我们应该用 Rust 来做所有事情。最佳实践是使用适合工作的正确工具。所有这些语言都有不同的优点。我们在前面的图表中所做的是仅仅展示 Rust 的优点。
无需垃圾回收的需求是因为 Rust 使用强制规则通过借用检查器来确保内存安全。既然我们已经了解了为什么想要用 Rust 编码,我们就可以继续到下一节检查数据类型。
检查 Rust 中的数据类型和变量
如果你之前在其他语言中编码过,你将使用变量并处理不同的数据类型。然而,Rust 确实有一些可能会让开发者感到沮丧的特点。这尤其适用于那些来自动态语言的开发者,因为这些特点主要围绕内存管理和变量的引用。这些特点一开始可能会让人感到害怕,但当你理解它们时,你会学会欣赏它们。有些人可能会听说这些特点,并想知道为什么他们应该费心学习这门语言。这是可以理解的,但正是这些特点让 Rust 成为了一个范式转变的语言。与借用检查和与诸如生命周期和引用等概念作斗争,使我们能够获得像 Python 这样的动态语言的高级内存安全性。然而,我们也可以获得像 C 和 C++ 提供的低级内存安全资源。这意味着在 Rust 中编码时,我们不必担心悬垂指针、缓冲区溢出、空指针、段错误、数据竞争和其他问题。像空指针和数据竞争这样的问题可能很难调试。考虑到这一点,强制执行的规则是一个很好的权衡,因为我们必须了解 Rust 的特点,以获得非内存安全语言的速度和控制,但我们不会遇到这些非内存安全语言带来的头痛。
在我们进行任何网络开发之前,我们需要运行我们的第一个程序。我们可以在 Rust 操场中这样做,网址是 play.rust-lang.org/
。
如果你以前从未访问过 Rust 操场,当你到达那里时,你会看到以下布局:
fn main() {
println!("hello world");
}
当使用在线 Rust 操场时,前面的代码将看起来像以下截图:
图 1.3 – 在线 Rust 操场视图
在我们的 hello world
代码中,我们有一个 main
函数,这是我们的入口点。当运行我们的程序时,这个函数会被触发。所有程序都有入口点。如果你之前没有听说过这个概念,那可能是因为你来自动态语言,入口点是你指向解释器的脚本文件。对于 Python 来说,一个更接近的类比是当文件直接由解释器运行时运行的 main
块,如下所示:
if __name__ == "__main__":
print("Hello, World!")
如果你用 Python 编写代码,你可能会在 Flask 应用程序中看到这种用法。目前,我们并没有做任何新的东西。这是一个标准的Hello World示例,只是语法上有一点变化;然而,即使在这个例子中,我们打印的字符串也不像看起来那么简单。例如,让我们编写自己的函数,该函数接受一个字符串并使用以下代码打印它:
fn print(message: str) {
println!("{}", message);
}
fn main() {
let message = "hello world";
print(message);
}
这段代码应该可以正常工作。我们将其传递给我们的函数并打印它。然而,如果我们打印它,我们会得到以下输出:
10 | print(message);
| ^^^^^^^ doesn't have a size known at compile-time
|
= help: the trait `Sized` is not implemented for `str`
= note: all function arguments must have a statically known size
这不是很直接,但它带我们来到了我们必须理解的第一块领域,那就是字符串。别担心,字符串是编写功能性 Rust 代码时你需要弄清楚的最古怪变量。
在 Rust 中使用字符串
在我们探索上一节中的错误之前,让我们先纠正它,这样我们才知道我们要努力的方向。我们可以用以下代码让print
函数在没有错误的情况下工作:
fn print(message: String) {
println!("{}", message);
}
fn main() {
let message = String::from("hello world");
print(message);
}
我们所做的是从"hello world"
创建了一个String
,并将其传递给print
函数。这次编译器没有抛出错误,因为我们始终知道String
的大小,因此我们可以保留足够的内存空间。这听起来可能有些反直觉,因为字符串通常有不同的长度。如果我们只能使用相同长度的字母来编写代码中的每个字符串,那么这将不是一个非常灵活的编程语言。这是因为字符串本质上是由字节向量实现的指针,在 Rust 中用Vec<u8>
表示。它持有对堆内存中字符串内容(str
,也称为字符串切片)的引用,如下面的图所示:
图 1.4 – 字符串与 str“one”的关系
我们可以在图 1.4中看到,字符串是一个包含三个数字的向量。第一个数字是它所引用的str
的实际内存地址。第二个数字是分配的内存大小,第三个是字符串内容长度。因此,我们可以在代码中访问字符串字面量,而无需在代码中传递各种大小的变量。我们知道String
有一个固定的大小,因此可以在print
函数中分配这个大小。还必须注意的是,String
位于栈内存中,而我们的字符串字面量位于堆内存中。考虑到我们知道String
有一个固定的大小,而我们的字符串字面量是可变的,我们可以推断出栈内存用于可预测的内存大小,并且在程序运行时提前分配。我们的堆内存是动态的,因此当需要时才分配内存。现在我们知道了字符串的基本知识,我们可以使用它们创建的不同方式,如下面的代码所示:
let string_one = "hello world".to_owned();
let string_two = "hello world".to_string();
let string_three = string_two.clone();
我们必须注意,然而,创建string_three
是昂贵的,因为我们必须在堆中复制底层数据,而堆操作是昂贵的。这并不是 Rust 特有的怪癖。在我们的例子中,我们只是在体验底层发生了什么。例如,如果我们修改 Python 中的字符串,我们将会得到不同的结果:
# slower method
data = ["one", "two", "three", "four"]
string = ""
for i in data:
string += i
# faster method
"".join(data)
通过循环添加字符串会更慢,因为 Python 必须分配新的内存并将整个字符串复制到新的内存地址。join
方法更快,因为 Python 可以分配列表中所有数据的内存,然后复制数组中的字符串,这意味着字符串只需复制一次。这表明,尽管像 Python 这样的高级语言可能不会强迫你考虑字符串的内存分配,但如果你不承认这一点,你仍然会付出代价。
我们也可以通过借用,将字符串字面量传递给print
函数,如下面的代码所示:
fn print(message: &str) {
println!("{}", message);
}
fn main() {
print(&"hello world");
}
借用用&
表示。我们将在本章的后面讨论借用。现在,我们可以推断出借用只是一个固定大小的变量大小字符串切片的引用。如果借用是固定大小,我们就不能将其传递给print
函数,因为我们不知道大小。在这个时候,我们可以舒适地在 Rust 中使用字符串。在我们开始编写 Rust 程序之前,我们必须理解的概念是整数和浮点数。
使用整数和浮点数
在大多数高级 Web 编程语言中,我们只是将浮点数或整数赋给变量名,然后继续程序。然而,根据我们在字符串部分之前所接触到的内容,我们现在理解,在使用 Rust 中的字符串时,我们必须担心内存大小。整数和浮点数也是如此。我们知道整数和浮点数有大小范围。因此,我们必须告诉 Rust 我们在代码中传递了什么。Rust 支持有符号整数,用i
表示,和无符号整数,用u
表示。这些整数由 8、16、32、64 和 128 位组成。探索数字在二进制表示背后的数学并不适用于这本书;然而,我们需要了解使用几个位可以表示的数字范围,因为这将帮助我们理解 Rust 中不同类型的浮点数和整数表示什么。因为二进制要么是 0 要么是 1,我们可以通过将 2 的位数次幂来计算可以表示的整数范围。例如,如果我们有一个由 8 位表示的整数,2 的 8 次幂等于 256。我们必须记住 0 也是被表示的。考虑到这一点,8 位的整数范围是 0 到 255。我们可以用以下代码测试这个计算:
let number: u8 = 256;
这比我们计算的范围高一个。因此,我们不应该对看到以下溢出错误感到惊讶:
the literal `256` does not fit into the type
`u8` whose range is `0..=255`
因此,我们可以推断,如果我们把无符号整数降低到 255
,它将可以通过。但是,假设我们用以下代码将无符号整数转换为有符号整数:
let number: i8 = 255;
我们将看到我们得到以下有用的错误信息:
the literal `255` does not fit into the type
`i8` whose range is `-128..=127`
有助于的这条错误信息,我们可以看到有符号整数考虑了负数,因此有符号整数可以取的绝对值大约是二分之一。因此,我们可以通过以下代码将数字作为 16 位有符号整数来增加范围:
let number: i16 = 255;
这将有效。然而,让我们用以下代码将我们的 16 位整数与 8 位整数相加:
let number = 255i16;
let number_two = 5i8;
let result = number + number_two;
之前的代码可能看起来有些不同。在前面的代码中,我们只是用后缀定义了数据类型。所以,number
的值是 255
,类型是 i16
,而 number_two
的值是 5
,类型是 i8
。如果我们运行前面的代码,我们会得到以下错误:
11 | let result = number + number_two;
| ^ no implementation for `i16 + i8`
|
= help: the trait `Add<i8>` is not implemented for `i16`
我们将在本章后面讨论特质。现在,我们必须理解的是,我们不能将两个不同类型的整数相加。如果它们都是同一类型,那么我们可以。我们可以通过类型转换使用 as
来更改整数类型,如下面的代码行所示:
let result = number + number_two as i16;
这意味着 number_two
现在是一个 16 位整数,而 result
将是 260。然而,我们必须小心类型转换,因为如果我们以错误的方式执行,我们可能会遇到一个静默的错误,这在 Rust 中是不常见的。如果我们将 number
转换为 i8
类型而不是将 number_two
转换为 i16
类型,那么 result
将等于 4,这没有意义,因为 255 + 5 等于 260。这是因为 i8
类型小于 i16
类型。因此,如果我们把 i16
整数转换为 i8
整数,我们实际上是在截断一些数据,只取数字的低位,而忽略高位。因此,如果我们把 number
转换为 i8
整数,它最终会变成 -1。为了更安全,我们可以使用 i8::from
函数,如下面的代码所示:
let result = i8::from(number) + number_two;
运行此代码将给出以下错误:
let result = i8::from(number) + number_two;
| ^^^^^^^^ the trait `From<i16>` is not
implemented for `i8`
再次,我们将在本章后面讨论特质,但我们可以看到在前面的代码中,由于 From<i16>
特质没有为 i8
整数实现,我们不能将 i8
整数转换为 i16
整数。理解了这一点,我们可以自由地安全且高效地处理整数。关于 Rust 中整数大小的最后一个要点是,它们不是连续的。支持的大小如下表所示:
位 | 计算 | 大小 |
---|---|---|
8 | 2⁸ | 256 |
16 | 2¹⁶ | 65536 |
32 | 2³² | 4294967296 |
64 | 2⁶⁴ | 1.8446744e+19 |
128 | 2¹²⁸ | 3.4028237e+38 |
表 1.1 – 整数类型的大小
当涉及到浮点数时,Rust 支持浮点数 f32
和 f64
。这两种浮点类型都支持负数和正数。声明浮点变量需要与整数相同的语法,如下面的代码所示:
let float: f32 = 2.6;
通过这种方式,我们可以在 Rust 代码中舒适地处理整数和浮点数。然而,作为开发者,我们知道仅仅声明浮点数和整数并不是非常有用。我们希望能够包含并遍历它们。在下一节中,我们将使用向量和数组来实现这一点。
在向量和数组中存储数据
在 Rust 中,我们可以将我们的浮点数、整数和字符串存储在数组和向量中。首先,我们将专注于数组。数组存储在栈内存中。了解这一点,并记住我们学到的关于字符串的知识,我们可以推断出数组的大小是固定的。这是因为,正如我们记得的,如果变量存储在栈上,那么内存将在程序开始时分配并加载到栈中。我们可以定义一个整数数组,遍历它,打印每个整数,然后通过索引访问一个整数,以下代码可以做到这一点:
fn main() {
let int_array: [i32; 3] = [1, 2, 3];
for i in int_array {
println!("{}", i);
}
println!("{}", int_array[1]);
}
使用前面的代码,我们通过将它们括在方括号中来定义类型和大小。例如,如果我们打算创建一个长度为 4 的浮点数数组,我们会使用int_array: [f32; 4] = [1.1, 2.2, 3.3, 4.4]
。运行前面的代码将给出以下打印输出:
1
2
3
2
在前面的打印输出中,我们看到循环是有效的,并且我们可以通过方括号访问第二个整数。尽管数组的内存大小是固定的,我们仍然可以改变它。这就是可变性的作用所在。当我们定义一个变量为可变时,这意味着我们可以对其进行修改。换句话说,如果变量是可变的,我们可以在定义之后改变其值。如果你尝试更新本章中编写的代码中的任何变量,你会发现你无法这样做。这是因为 Rust 中的所有变量默认都是不可变的。我们可以在变量名前加上mut
标签来使任何变量在 Rust 中变为可变。回到固定数组,我们无法改变数组的大小,这意味着由于它存储在栈内存中,我们无法向其中追加/推送新的整数。然而,如果我们定义一个可变数组,我们可以使用相同内存大小的其他整数来更新其部分。以下代码是一个例子:
fn main() {
let mut mutable_array: [i32; 3] = [1, 2, 0];
mutable_array[2] = 3;
println!("{:?}", mutable_array);
println!("{}", mutable_array.len());
}
在前面的代码中,我们可以看到数组中的最后一个整数被更新为3
。然后我们打印出整个数组,然后打印出长度。你可能也注意到,前面代码的第一个打印语句现在使用了{:?}
。这调用了Debug
特质。如果我们为要打印的东西实现了Debug
,那么打印的东西的完整表示将在控制台显示。你还可以看到我们打印出了数组长度的结果。运行此代码将给出以下打印输出:
[1, 2, 3]
3
通过前面的打印输出,我们可以确认数组现在已更新。我们还可以使用数组访问切片。为了演示这一点,我们可以创建一个包含 100 个零的数组。然后我们可以从这个数组中取一个切片,并使用以下代码打印它:
fn main() {
let slice_array: [i32; 100] = [0; 100];
println!("length: {}", slice_array.len());
println!("slice: {:?}", &slice_array[5 .. 8]);
}
运行前面的代码将产生以下输出:
length: 100
slice: [0, 0, 0]
现在,我们能够使用数组进行生产。数组对于缓存很有用。例如,如果我们知道需要存储的量,那么我们可以有效地使用数组。然而,我们只成功地在数组中存储了一种类型的数据。如果我们试图在同一个数组中存储字符串和整数,我们会遇到问题。我们如何定义类型?这个问题适用于所有集合,如向量、HashMaps。有多种方法可以做到这一点,但最直接的方法是使用枚举。枚举就是枚举。在像 Python 这样的动态语言中,你可能不需要使用它们,因为你可以将任何类型传递到任何你想去的地方。然而,它们仍然可用。枚举是枚举类型的简称,基本上定义了一个具有可能变体的类型。在我们的情况下,我们希望我们的数组能够在同一个集合中存储字符串和整数。我们可以通过以下代码初始化我们的枚举来实现这一点:
enum SomeValue {
StringValue(String),
IntValue(i32)
}
在前面的代码中,我们可以看到我们定义了一个名为SomeValue
的枚举。然后我们表示StringValue
存储字符串值,而IntValue
存储整数值。然后我们可以定义一个长度为4
的数组,包含 2 个字符串和 2 个整数,以下代码所示:
let multi_array: [SomeValue; 4] = [
SomeValue::StringValue(String::from("one")),
SomeValue::IntValue(2),
SomeValue::StringValue(String::from("three")),
SomeValue::IntValue(4)
];
在前面的代码中,我们可以看到我们将字符串和整数包裹在我们的枚举中。现在,在循环中获取它们将是一项额外的任务。例如,我们可以对整数执行的操作,而对字符串则不能,反之亦然。考虑到这一点,在循环数组时,我们需要使用match
语句,如下面的代码所示:
for i in multi_array {
match i {
SomeValue::StringValue(data) => {
println!("The string is: {}", data);
},
SomeValue::IntValue(data) => {
println!("The int is: {}", data);
}
}
}
在前面的代码中,我们可以看到如果i
是SomeValue::StringValue
,我们就将包裹在SomeValue::StringValue
中的数据分配给名为data
的变量。然后我们将data
传递到内部作用域以进行打印。我们以相同的方式处理整数。尽管我们只是在打印以演示概念,但我们可以在这些内部作用域中对data
变量执行类型允许的任何操作。运行前面的代码将产生以下输出:
The string is: one
The int is: 2
The string is: three
The int is: 4
使用枚举包裹数据和match
语句处理数据的方法可以应用于 HashMaps 和向量。此外,我们用数组涵盖的内容也可以应用于向量。唯一的区别是我们不需要定义长度,并且如果需要,我们可以增加向量的大小。为了演示这一点,我们可以创建一个字符串向量,然后添加一个字符串到末尾,以下代码所示:
let mut string_vector: Vec<&str> = vec!["one", "two",
"three"];
println!("{:?}", string_vector);
string_vector.push("four");
println!("{:?}", string_vector);
在前面的代码中,我们可以看到我们使用vec!
宏来创建字符串向量。你可能已经注意到,像vec!
和println!
这样的宏可以改变输入的数量。我们将在本章后面介绍宏。运行前面的代码将产生以下输出:
["one", "two", "three"]
["one", "two", "three", "four"]
我们还可以使用 Vec
结构的 new
函数创建一个空向量,代码如下:let _empty_vector: Vec<&str> = Vec::new();
。你可能想知道何时使用向量,何时使用数组。向量更加灵活。你可能为了性能提升而倾向于使用数组。从表面上看,这似乎是合理的,因为它存储在栈上。访问栈将会更快,因为内存大小可以在编译时计算出来,这使得分配和释放比堆简单。然而,因为它在栈上,所以它不能超出其分配的作用域。移动向量只需要移动指针。然而,移动数组需要复制整个数组。因此,复制固定大小的数组比移动向量更昂贵。如果你只有少量数据,你只需要在小的作用域中使用这些数据,并且你知道数据的大小,那么使用数组是有意义的。然而,如果你将要移动数据,即使你知道数据的大小,使用向量也是一个更好的选择。现在我们可以使用基本的集合进行生产,我们可以继续到更高级的集合,即 HashMap。
使用 HashMap 映射数据
在某些其他语言中,HashMaps 被称为字典。它们有一个键和一个值。我们可以使用键来插入和获取值。现在我们已经学习了如何处理集合,我们可以在本节中稍微大胆一些。我们可以创建一个游戏角色的简单档案。在这个角色档案中,我们将有一个名字、年龄以及他们拥有的物品列表。这意味着我们需要一个枚举来容纳一个字符串、一个整数以及一个也容纳字符串的向量。我们希望打印出完整的 HashMap 来查看我们的代码是否正确。为此,我们将为我们的枚举实现 Debug
特性,如下面的代码所示:
#[derive(Debug)]
enum CharacterValue {
Name(String),
Age(i32),
Items(Vec<String>)
}
在前面的代码中,我们可以看到我们对枚举使用了 derive
属性。在这种情况下,属性是应用于 CharacterValue
枚举的元数据。derive
属性告诉编译器提供一个特性的基本实现。因此,在前面的代码中,我们告诉编译器将 Debug
特性的基本实现应用于 CharacterValue
枚举。有了这个,我们就可以创建一个新的 HashMap,其键指向我们定义的值,如下面的代码所示:
use std::collections::HashMap;
fn main() {
let mut profile: HashMap<&str, CharacterValue> =
HashMap::new();
}
我们说它是可变的,因为我们将要使用以下代码插入值:
profile.insert("name", CharacterValue::Name("Maxwell".to_string()));
profile.insert("age", CharacterValue::Age(32));
profile.insert("items", CharacterValue::Items(vec![
"laptop".to_string(),
"book".to_string(),
"coat".to_string()
]));
println!("{:?}", profile);
我们可以看到我们已经插入了所有需要的数据。运行此代码将给出以下输出:
{"items": Items(["laptop", "book", "coat"]), "age": Age(32),
"name": Name("Maxwell")}
在前面的输出中,我们可以看到我们的数据是正确的。插入它是另一回事;然而,我们现在必须再次获取它。我们可以使用一个get
函数来完成这个操作。get
函数返回一个Option
类型。Option
类型返回Some
或None
。所以,如果我们从我们的 HashMap 中获取name
,我们需要进行两次匹配,如下面的代码所示:
match profile.get("name") {
Some(value_data) => {
match value_data {
CharacterValue::Name(name) => {
println!("the name is: {}", name);
},
_ => panic!("name should be a string")
}
},
None => {
println!("name is not present");
}
}
在前面的代码中,我们可以检查键中是否有名称。如果没有,我们就打印出它不存在。如果name
键存在,我们就继续进行第二个检查,如果它是CharacterValue::Name
,就打印出名称。然而,如果name
键没有包含CharacterValue::Name
,那么就会有问题。所以,我们在match
中添加了一个额外的检查,即_
。这是一个捕获,意味着“任何其他东西”。我们只对CharacterValue::Name
以外的任何东西不感兴趣。因此,_
捕获映射到panic!
宏,这本质上会抛出一个错误。我们可以使这个更短。如果我们知道name
键将存在于 HashMap 中,我们可以使用以下代码来使用unwrap
函数:
match profile.get("name").unwrap() {
CharacterValue::Name(name) => {
println!("the name is: {}", name);
},
_ => panic!("name should be a string")
}
unwrap
函数直接暴露了结果。然而,如果结果是None
,那么它将直接导致程序终止的错误,如下面的打印输出所示:
thread 'main' panicked at 'called `Option::unwrap()` on a `None` value'
这可能看起来有些冒险,但在实践中,你最终会大量使用unwrap
函数,因为你需要直接访问结果,而且无论如何你都无法继续程序。一个典型的例子是连接到数据库。在许多 Web 编程中,如果数据库连接不成功,那么你无法继续进行 API 调用。因此,允许错误就像大多数其他 Web 语言一样是有意义的。既然我们已经了解了终止程序的错误,那么我们不妨在下一节学习如何处理错误。
处理结果和错误
在上一节中,我们了解到直接解包Option
并返回None
会导致线程恐慌。还有另一种情况,如果无法成功解包,它也会引发错误,那就是Result
。Result
类型可以返回Ok
或Err
。为了演示这一点,我们可以创建一个基本的函数,该函数返回一个基于我们传递给它的简单布尔值的Result
类型,如下面的代码所示:
fn error_check(check: bool) -> Result<i8, &'static str> {
if check {
Err("this is an error")
}
else {
Ok(1)
}
}
在前面的代码中,我们可以看到我们返回Result<i8, &'static str>
。这意味着如果Result
是Ok
,我们返回一个整数;如果Result
是Err
,我们也返回一个整数。&'static str
变量基本上是我们的错误字符串。我们可以通过&
知道它是一个引用。'static
部分意味着这个引用在整个程序的运行期间都是有效的。如果现在这还不清楚,不要担心,我们将在本章后面的部分介绍生命周期。现在我们已经创建了一个错误检查函数,我们可以使用以下代码来测试这些结果看起来像什么:
fn main() {
println!("{:?}", error_check(false));
println!("{:?}", error_check(false).is_err());
println!("{:?}", error_check(true));
println!("{:?}", error_check(true).is_err());
}
运行前面的代码会得到以下输出:
Ok(1)
false
Err("this is an error")
true
在前面的输出中,我们可以看到它确实返回了我们想要的结果。我们还可以注意到,我们可以在Result
变量上运行is_err()
函数,如果返回Ok
则结果为false
,如果返回Err
则结果为true
。我们还可以直接解包,但使用以下expect
函数添加额外的跟踪到堆栈跟踪中:
let result: i8 = error_check(true).expect("this has been caught");
前面的函数将产生以下输出:
thread 'main' panicked at 'this has been caught: "this is an error"'
通过前面的示例,我们可以看到我们首先收到expect
函数的消息,然后是Result
返回的错误消息。有了这个理解,我们可以抛出、处理并添加额外的跟踪到错误中。然而,随着我们继续前进,我们更多地暴露于生命周期和借用引用。现在是时候通过理解变量所有权来解决这个问题。
控制变量所有权
正如我们从本章的开头所记得的,Rust 没有垃圾回收器。然而,它具有内存安全。它是通过在变量所有权周围有严格的规则来实现的。这些规则在 Rust 编译时得到执行。如果你来自动态语言,那么这可能会最初导致挫败感。这被称为与借用检查器斗争。遗憾的是,这无端地给 Rust 带来了虚假的陡峭的学习曲线声誉,因为当你不知道发生了什么而与借用检查器斗争时,编写甚至最基本的程序似乎是一项不可能的任务。然而,如果我们花时间在学习规则之前尝试编写任何过于复杂的代码,那么对规则的了解和编译器的帮助将使在 Rust 中编写代码变得有趣且有益。再次提醒,Rust 已经连续 7 年是最受欢迎的语言。这并不是因为它不可能完成任何事情。在这些调查中为 Rust 投票的人理解所有权规则。Rust 的编译、检查和执行这些规则可以防止以下错误:
-
使用后释放:当内存被释放后再次访问时发生,这可能导致崩溃。它还可能允许黑客通过此内存地址执行代码。
-
悬垂指针:当引用指向不再包含引用的数据的内存地址时发生。本质上,这个指针现在指向了空或随机数据。
-
双重释放:当分配的内存被释放然后再次释放时发生。这可能导致程序崩溃,并增加敏感数据泄露的风险。这也使黑客能够执行任意代码。
-
段错误:当程序尝试访问它不允许访问的内存时发生。
-
缓冲区溢出:这种错误的例子是读取数组末尾之外的数据。这可能导致程序崩溃。
为了防止这些错误并实现内存安全,Rust 强制执行以下规则:
-
值由分配给它们的变量拥有
-
一旦变量超出其定义的作用域,它就会被从内存中释放
-
如果我们遵守复制、移动、不可变借用和可变借用的规则,值可以被引用和修改
了解规则是一回事,但要在 Rust 代码中实际应用这些规则,我们需要更详细地了解复制、移动和借用。
复制变量
复制发生在值被复制时。一旦复制完成,新变量拥有该值,而现有变量也拥有自己的值。
图 1.5 – 变量复制路径
在图 1**.5中,我们可以看到copy
特质的路径,然后它将自动被复制,如下面的代码所示:
let one: i8 = 10;
let two: i8 = one + 5;
println!("{}", one);
println!("{}", two);
运行前面的代码将给出以下打印输出:
10
15
在前面的例子中,我们欣赏到变量one
和two
可以被打印出来这一事实,表明one
已经被复制以供two
使用。为了测试这一点,我们可以使用以下代码测试我们的示例:
let one = "one".to_string();
let two = one;
println!("{}", one);
println!("{}", two);
运行此代码将导致以下错误:
move occurs because `one` has type `String`, which does not implement the `Copy` trait
由于字符串没有实现Copy
特性,所以代码无法运行,因为one
被移动到了two
。然而,如果我们删除println!("{}", one);
,代码将可以运行。这让我们来到了下一个我们必须理解的概念:移动。
移动变量
移动指的是值从一个变量移动到另一个变量。然而,与复制不同,原始变量不再拥有该值。
图 1.6 – 变量移动路径
从我们可以在图 1**.6中看到的情况来看,一旦one
被移动到two
,就无法再访问one
。为了真正了解这里发生的事情以及字符串是如何受影响的,我们可以设置一些旨在失败的代码,如下所示:
let one: String = String::from("one");
let two: String = one + " two";
println!("{}", two);
println!("{}", one);
运行前面的代码将给出以下错误:
let one: String = String::from("one");
--- move occurs because `one` has type
`String`, which does not implement the
`Copy` trait
let two: String = one + " two";
------------ `one` moved due to usage in operator
println!("{}", two);
println!("{}", one);
^^^ value borrowed here after move
如我们所见,编译器在这里很有帮助。它显示了字符串被移动到何处以及该字符串的值是从哪里借用的。因此,我们只需删除println!("{}", one);
这一行代码,就可以让代码立即运行。然而,我们希望能够在前面代码块的底部使用那个print
函数。我们不应该因为 Rust 实现的规则而限制代码的功能。我们可以通过使用to_owned
函数和以下代码来解决这个问题:
let two: String = one.to_owned() + " two";
to_owned
函数可用,因为字符串实现了ToOwned
特质。我们将在本章后面介绍特质,所以如果你现在还不知道这是什么意思,请不要停止阅读。我们本来可以在字符串上使用clone
。我们必须注意,to_owned
是clone
的通用实现。然而,我们使用哪种方法并不重要。人们可能会想知道为什么字符串没有Copy
特质。这是因为字符串是指向字符串字面量的指针。如果我们复制字符串,我们将有多个不受约束的指向同一字符串字面量数据的指针,这将是非常危险的。正因为如此,我们可以使用字符串来探索移动概念。如果我们用一个函数将字符串移出作用域,我们可以看到这如何影响我们的移动。这可以通过以下代码来完成:
fn print(value: String) {
println!("{}", value);
}
fn main() {
let one = "one".to_string();
print(one);
println!("{}", one);
}
如果我们运行前面的代码,我们将得到一个错误,指出print
函数移动了one
值。因此,println!("{}", one);
行在one
移动到print
函数中之后借用了one
。这条消息的关键部分是单词借用。为了理解正在发生的事情,我们需要探索不可变借用的概念。
不可变借用变量
当一个变量可以被另一个变量引用而不需要克隆或复制它时,就会发生不可变借用。这本质上解决了我们的问题。如果借用的变量超出作用域,则它不会被从内存中释放,并且仍然可以使用对值的原始引用。
图 1.7 – 不可变借用路径
我们可以在图 1.7中看到two
从one
借用了值。必须注意,当从one
借用时,one
被锁定,直到借用完成之前不能被访问。要执行借用操作,我们只需在前面加上前缀&
。这可以通过以下代码来证明:
fn print(value: &String) {
println!("{}", value);
}
fn main() {
let one = "one".to_string();
print(&one);
println!("{}", one);
}
在前面的代码中,我们可以看到我们的不可变借用使我们能够将字符串传递给print
函数,并在之后打印它。这可以通过以下打印结果来确认:
one
one
从我们的代码中我们看到,我们执行的不可变借用可以在图 1.8中演示。
图 1.8 – 与print
函数相关的不可变借用
在前面的图中,我们可以看到当print
函数正在运行时,one
是不可用的。我们可以用以下代码来证明这一点:
fn print(value: &String, value_two: String) {
println!("{}", value);
println!("{}", value_two);
}
fn main() {
let one = "one".to_string();
print(&one, one);
println!("{}", one);
}
如果我们运行前面的代码,我们将得到以下错误:
print(&one, one);
----- ---- ^^^ move out of `one` occurs here
| |
| borrow of `one` occurs here
borrow later used by call
我们可以看到,即使在print
函数中使用&one
之后,我们也不能使用one
。这是因为&one
的生存期贯穿整个print
函数的生存期。因此,我们可以得出结论,图 1.8是正确的。然而,我们可以进行另一个实验。我们可以将value_one
改为借用,看看会发生什么,以下代码如下:
fn print(value: &String, value_two: &String) {
println!("{}", value);
println!("{}", value_two);
}
fn main() {
let one = "one".to_string();
print(&one, &one);
println!("{}", one);
}
在前面的代码中,我们可以看到我们对one
进行了两次不可变借用,并且代码运行了。这突出了一个重要的事实:我们可以进行尽可能多的不可变借用。但是,如果借用是可变的会发生什么呢?为了理解这一点,我们必须探索可变借用。
变量的可变借用
可变借用本质上与不可变借用相同,只是借用是可变的。因此,我们可以更改借用值。为了演示这一点,我们可以创建一个print
语句,在打印之前改变借用值。然后我们在main
函数中打印它,以确认值已经被更改,以下代码:
fn print(value: &mut i8) {
value += 1;
println!("In function the value is: {}", value);
}
fn main() {
let mut one: i8 = 5;
print(&mut one);
println!("In main the value is: {}", one);
}
运行前面的代码将给出以下输出:
In function the value is: 6
In main the value is: 6
前面的输出证明了即使在print
函数中可变引用的生命周期结束后,one
仍然是6
。我们可以看到在print
函数中,我们使用*
运算符更新one
的值。这被称为解引用运算符。这个解引用运算符暴露了底层值以便进行操作。这一切看起来都很直接,但它是否与我们的不可变引用完全一样呢?如果我们记得,我们可能会有多个不可变引用。我们可以用以下代码来测试这一点:
fn print(value: &mut i8, value_two: &mut i8) {
value += 1;
println!("In function the value is: {}", value);
value_two += 1;
}
fn main() {
let mut one: i8 = 5;
print(&mut one, &mut one);
println!("In main the value is: {}", one);
}
在前面的代码中,我们可以看到我们创建了两个可变引用并将它们传递出去,就像在上一节中一样,但这次使用的是不可变引用。然而,运行它给出了以下错误:
error[E0499]: cannot borrow `one` as mutable more than once at a time
通过这个例子,我们可以确认我们一次不能有多个可变引用。这防止了数据竞争,并赋予了 Rust“无畏并发”的标签。在这里我们已经涵盖了,现在当编译器与借用检查器结合使用时,我们可以变得富有生产力。然而,我们已经触及了作用域和生命周期的概念。它们的使用是直观的,但就像借用规则一样,我们需要更详细地深入研究作用域和生命周期。
作用域
要理解作用域,让我们回顾一下我们如何声明变量。你会注意到,当我们声明一个新变量时,我们使用let
。当我们这样做时,那个变量是唯一拥有资源的变量。因此,如果值被移动或重新分配,那么原始变量就不再拥有该值。当一个变量被移动时,它实际上被移动到了另一个作用域。在外部作用域中声明的变量可以在内部作用域中引用,但一旦内部作用域过期,就不能在内部作用域中访问内部作用域中声明的变量。我们可以在以下图中将一些代码拆分为作用域:
图 1.9 – 将基本的 Rust 代码拆分为作用域
图 1.9 显示我们可以通过仅使用花括号来创建一个内部作用域。将我们刚刚学到的关于作用域的知识应用到 图 1.9 上,你能计算出它是否会崩溃吗?如果它会崩溃,会如何崩溃?
如果你猜到这会导致编译器错误,那么你是正确的。运行代码会导致以下错误:
println!("{}", two);
^^^ not found in this scope
因为one
是在内层作用域中定义的,所以我们无法在外层作用域中引用它。我们可以通过以下代码声明变量在外层作用域,但在内层作用域中赋值来解决这个问题:
fn main() {
let one = &"one";
let two: &str;
{
println!("{}", one);
two = &"two";
}
println!("{}", one);
println!("{}", two);
}
在前面的代码中,我们可以看到在赋值时我们没有使用let
,因为我们已经在外层作用域中声明了变量。运行前面的代码会得到以下输出:
one
one
two
我们还必须记住,如果我们将一个变量移动到函数中,那么当函数的作用域结束时,该变量就会被销毁。在函数执行之后,我们无法访问该变量,即使我们在函数执行之前声明了该变量。这是因为一旦变量被移动到函数中,它就不再处于原始作用域中。它已经被移动了。而且因为它被移动到那个作用域,它就绑定到了它被移动进入的作用域的生命周期。这把我们带到了下一个部分:生命周期。
遍历生命周期
理解生命周期将结束我们对借用规则和作用域的探索。我们可以通过以下代码来探索生命周期的效果:
fn main() {
let one: &i8;
{
let two: i8 = 2;
one = &two;
} // -----------------------> two lifetime stops here
println!("r: {}", one);
}
在前面的代码中,我们在内层作用域开始之前声明了one
。然而,我们将其赋值为two
的引用。two
只具有内层作用域的生命周期,因此在尝试打印它之前生命周期就已经结束。这一点可以通过以下错误来证明:
one = &two; } println!("r: {}", one);}
^^^^ - --- borrow later used here
| |
| `two` dropped here while still borrowed
borrowed value does not live long enough
当two
的生命周期结束时,two
会被丢弃。因此,我们可以断定one
和two
的生命周期并不相等。
虽然在编译时能够标记出来是件好事,但 Rust 并不会止步于此。这个概念也适用于函数。假设我们构建一个函数,它引用两个整数,比较它们,并返回最大的整数引用。这个函数是一段独立的代码。在这个函数中,我们可以表示两个整数的生命周期。这是通过使用'
前缀来完成的,这是一个生命周期表示法。表示法的名称可以是任何你想到的,但惯例是使用a
、b
、c
等。我们可以通过创建一个简单的函数来探索这一点,该函数接受两个整数并返回最大的一个,如下所示:
fn get_highest<'a>(first_number: &'a i8, second_number: &'a
i8) -> &'a i8 {
if first_number > second_number {
first_number
} else {
second_number
}
}
fn main() {
let one: i8 = 1;
let outcome: &i8;
{
let two: i8 = 2;
let outcome: &i8 = get_highest(&one, &two);
}
println!("{}", outcome);
}
如我们所见,第一个和第二个生命周期具有相同的符号a
。它们都必须在函数的整个持续时间内存在。我们还必须注意,该函数返回一个具有a
生命周期的i8
整数。如果我们试图在没有任何借用的情况下在函数参数上使用生命周期符号,我们将会遇到一些非常令人困惑的错误。简而言之,没有借用就无法使用生命周期符号。这是因为如果我们不使用借用,传递给函数的值会被移动到函数中。因此,它的生命周期是函数的生命周期。这似乎很简单;然而,当我们运行它时,我们会得到以下错误:
println!("{}", outcome);}
^^^^^^^ use of possibly-uninitialized `outcome`
错误发生是因为传递给函数的所有参数的生命周期以及返回的整数的生命周期都是相同的。因此,编译器不知道可以返回什么。结果,two
可能会被返回。如果two
被返回,那么函数的结果将不足以存活到被打印出来。然而,如果one
被返回,那么它将可以存活。因此,在内部作用域执行完毕后,可能没有值可以打印。然而,在动态语言中,我们能够运行存在引用尚未初始化的变量的风险的代码。然而,在 Rust 中,我们可以看到,如果存在这种错误的可能性,它将无法编译。短期内,这可能会让人觉得 Rust 的编码速度较慢,但随着项目的进展,这种严格性将通过防止静默错误来节省大量时间。关于我们的错误,我们无法以我们目前拥有的确切函数和主布局来解决我们的问题。我们或者必须将打印结果移动到内部作用域,或者克隆整数并将它们传递给函数。
我们可以创建另一个函数来探索具有不同生命周期参数的函数。这次我们将创建一个filter
函数。如果第一个数字小于第二个数字,我们将返回0
。否则,我们将返回第一个数字。这可以通过以下代码实现:
fn filter<'a, 'b>(first_number: &'a i8, second_number: &'b
i8) -> &'a i8 {
if first_number < second_number {
&0
} else {
first_number
}
}
fn main() {
let one: i8 = 1;
let outcome: &i8;
{
let two: i8 = 2;
outcome = filter(&one, &two);
}
println!("{}", outcome);
}
前面的代码之所以有效,是因为我们知道生命周期是不同的。第一个参数具有与返回的整数相同的生命周期。如果我们实现filter(&two, &one)
,我们会得到一个错误,指出结果的生命周期不足以被打印。我们现在已经涵盖了现在编写 Rust 代码所需了解的所有内容,而不会受到借用检查器的干扰。我们现在需要继续创建更大的构建块,以便我们可以专注于用代码解决我们想要解决的问题。我们将从这个程序的通用构建块开始:结构体。
构建结构体
在现代高级动态语言中,对象一直是构建大型应用程序和解决复杂问题的基石,这是有充分理由的。对象使我们能够封装数据、功能和行为。在 Rust 中,我们没有对象。然而,我们确实有可以存储数据的字段的结构体。然后我们可以管理这些结构体的功能并将它们通过特质组合在一起。这是一个强大的方法,它给我们带来了对象的好处,而没有高耦合,如下面的图所示:
图 1.10 – Rust 结构体和对象之间的区别
我们将从创建一个具有以下代码的 Human
结构体开始,做一些基本的工作:
#[derive(Debug)]
struct Human<'a> {
name: &'a str,
age: i8,
current_thought: &'a str
}
在前面的代码中,我们可以看到我们的字符串字面量字段与结构体本身的生存期相同。我们还已经将 Debug
特质应用于 Human
结构体,因此我们可以打印它并查看一切。然后我们可以创建 Human
结构体并使用以下代码打印结构体:
fn main() {
let developer = Human{
name: "Maxwell Flitton",
age: 32,
current_thought: "nothing"
};
println!("{:?}", developer);
println!("{}", developer.name);
}
运行前面的代码会给出以下输出:
Human { name: "Maxwell Flitton", age: 32, current_thought: "nothing" }
Maxwell Flitton
我们可以看到我们的字段正如我们所期望的那样。然而,我们可以将我们的字符串切片字段更改为字符串以消除生命周期参数。我们可能还想要添加另一个字段,在这个字段中我们可以通过一个 friend
字段引用另一个 Human
结构体。然而,我们可能也没有朋友。我们可以通过创建一个枚举来解决这个问题,这个枚举可以是朋友或不是朋友,并将其分配给 friend
字段,如下面的代码所示:
#[derive(Debug)]
enum Friend {
HUMAN(Human),
NIL
}
#[derive(Debug)]
struct Human {
name: String,
age: i8,
current_thought: String,
friend: Friend
}
我们可以定义一个初始时没有朋友的 Human
结构体,只是为了看看它是否工作,以下代码:
let developer = Human{
name: "Maxwell Flitton".to_string(),
age: 32,
current_thought: "nothing".to_string(),
friend: Friend::NIL
};
然而,当我们运行编译器时,它并不工作。我想这可能是由于编译器不相信我没有朋友。但遗憾的是,问题在于编译器不知道为这个声明分配多少内存。这可以通过以下错误代码来展示:
enum Friend { HUMAN(Human), NIL}#[derive(Debug)]
^^^^^^^^^^^ ----- recursive without indirection
|
recursive type has infinite size
由于枚举的存在,理论上,存储这个变量的内存可能无限。一个 Human
结构体可以作为 friend
字段引用另一个 Human
结构体,而这个 Human
结构体又可以引用另一个 Human
结构体,从而通过 friend
字段链接在一起,形成一个可能无限数量的 Human
结构体链。我们可以通过指针解决这个问题。我们不是在 friend
字段中存储 Human
结构体的所有数据,而是存储一个内存地址,我们知道它有一个最大值,因为它是一个标准整数。这个内存地址指向内存中存储另一个 Human
结构体的位置。因此,程序在跨越 Human
结构体时,无论该 Human
结构体是否有 friend
字段,都能准确地知道需要分配多少内存。这可以通过使用 Box
结构体来实现,它本质上是我们枚举的智能指针,以下代码:
#[derive(Debug)]
enum Friend {
HUMAN(Box<Human>),
NIL
}
因此,现在我们的枚举表示朋友是否存在,如果存在,它有一个内存地址,如果我们需要提取有关此朋友的信息。我们可以通过以下代码实现:
fn main() {
let another_developer = Human{
name: "Caroline Morton".to_string(),
age:30,
current_thought: "I need to code!!".to_string(),
friend: Friend::NIL
};
let developer = Human{
name: "Maxwell Flitton".to_string(),
age: 32,
current_thought: "nothing".to_string(),
friend: Friend::HUMAN(Box::new(another_developer))
};
match &developer.friend {
Friend::HUMAN(data) => {
println!("{}", data.name);
},
Friend::NIL => {}
}
}
在前面的代码中,我们可以看到我们创建了一个Human
结构体,然后创建了一个带有对第一个Human
结构体的引用作为friend
字段的另一个Human
结构体。然后我们通过friend
字段访问第二个Human
结构体的friend
。记住,我们必须处理两种可能性,因为它可能是一个 nil 值。
虽然能够建立朋友关系令人兴奋,但如果退一步思考,我们可以看到为每个创建的人类编写了大量的代码。如果我们必须在程序中创建大量的人类,这并不 helpful。我们可以通过为我们的结构体实现一些功能来减少这一点。我们将基本上为结构体创建一个构造函数,并添加额外的函数,这样我们就可以添加可选值。我们还将使thought
字段成为可选的。因此,一个基本的结构体,其构造函数只填充最基本字段,可以通过以下代码实现:
#[derive(Debug)]
struct Human {
name: String,
age: i8,
current_thought: Option<String>,
friend: Friend
}
impl Human {
fn new(name: &str, age: i8) -> Human {
return Human{
name: name.to_string(),
age: age,
current_thought: None,
friend: Friend::NIL
}
}
}
因此,现在创建一个新的人类只需要以下一行代码:
let developer = Human::new("Maxwell Flitton", 32);
这将具有以下字段值:
-
名称:
"Maxwell Flitton"
-
年龄:
32
-
当前想法:
None
-
朋友:
NIL
我们可以在实现块中添加更多函数,以添加朋友和当前的想法,如下面的代码所示:
fn with_thought(mut self, thought: &str) -> Human {
self.current_thought = Some(thought.to_string());
return self
}
fn with_friend(mut self, friend: Box<Human>) -> Human {
self.friend = Friend::HUMAN(friend);
return self
}
在前面的代码中,我们可以看到我们传递了一个调用这些函数的结构体的可变版本。由于这些函数返回调用它们的结构体,因此它们可以被链式调用。如果我们想创建一个有想法的开发者,我们可以用以下代码实现:
let developer = Human::new("Maxwell Flitton", 32)
.with_thought("I love Rust!");
我们必须注意,不需要self
作为参数的函数可以用::
调用,而需要self
作为参数的函数可以用简单的点(.
)调用。如果我们想创建一个有朋友的开发者,可以使用以下代码实现:
let developer_friend = Human::new("Caroline Morton", 30);
let developer = Human::new("Maxwell Flitton", 32)
.with_thought("I love Rust!")
.with_friend(Box::new(developer_friend));
Println!("{:?}", developer);
运行代码将导致developer
具有以下参数:
Name: "Maxwell Flitton"
Age: 32
Current Thought: Some("I love Rust!")
Friend: HUMAN(Human { name: "Caroline Morton", age: 30,
current_thought: None, friend: NIL })
我们可以看到,结合枚举和已经使用这些结构体实现的函数可以成为强大的构建块。如果我们定义了良好的结构体,我们只需少量代码就可以定义字段和功能。然而,为多个结构体编写相同的功能可能会很耗时,并导致大量重复的代码。如果你之前使用过对象,你可能已经使用了继承。Rust 做得更好。它有特性(traits),我们将在下一节中探讨。
使用特性验证
我们可以看到枚举可以赋予结构体处理多种类型的能力。这也可以翻译为任何类型的函数或数据结构。然而,这可能会导致很多重复。以User
结构体为例。用户有一组核心值,例如用户名和密码。然而,他们也可以根据角色有额外的功能。在使用用户时,我们必须在执行某些过程之前检查角色。我们可以通过以下步骤创建一个简单的玩具程序来封装结构体,定义用户及其角色:
-
我们可以使用以下代码定义我们的用户:
struct AdminUser {
username: String,
password: String
}
struct User {
username: String,
password: String
}
在前面的代码中,我们可以看到User
和AdminUser
结构体具有相同的字段。对于这个练习,我们只需要两个不同的结构体来展示特性能对它们产生的影响。现在我们的结构体已经定义好了,我们可以继续到下一步,也就是创建特型。
-
我们将在我们的结构体中实现这些特型。我们将拥有的总特型包括创建、编辑和删除。我们将使用它们来分配用户的权限。我们可以使用以下代码创建这三个特型:
trait CanEdit {
fn edit(&self) {
println!("admin is editing");
}
}
trait CanCreate {
fn create(&self) {
println!("admin is creating");
}
}
trait CanDelete {
fn delete(&self) {
println!("admin is deleting");
}
}
我们可以看到,特型的函数只接受self
作为参数。我们不能在函数中引用self
的字段,因为我们不知道将要实现哪些结构体。然而,如果我们需要,在将特型实现到结构体时可以重写函数。如果我们需要返回self
,我们需要将其封装在Box
结构体中,因为编译器不知道返回的结构体的大小。我们还必须注意,如果我们重写结构体的函数,函数的签名(输入参数和返回值)必须与原始函数相同。现在我们已经定义了特型,我们可以继续到下一步,即实现特型来定义用户的角色。
-
通过我们的角色,我们可以让管理员拥有所有权限,而用户只有编辑权限。这可以通过以下代码实现:
impl CanDelete for AdminUser {}
impl CanCreate for AdminUser {}
impl CanEdit for AdminUser {}
impl CanEdit for User {
fn edit(&self) {
println!("A standard user {} is editing",
self.username);
}
}
从我们之前的步骤中,我们可以记住所有函数已经为管理员工作,通过打印出管理员正在执行的操作。因此,我们不需要为管理员特型的实现做任何事情。我们还可以看到,我们可以为单个结构体实现多个特型。这增加了很大的灵活性。在我们的CanEdit
特型实现中,我们已经重写了edit
函数,以便可以打印出正确的语句。现在我们已经实现了特型,我们的user
结构体在代码中有了进入需要这些特型的作用域的权限。现在我们可以构建在下一步中使用这些特型的函数。
-
我们可以通过在实现了它们的结构体上直接在
main
函数中运行它们来利用特质的函数。然而,如果我们这样做,我们在这个练习中看不到它们的真正力量。我们可能还希望在将来的程序中,当我们跨越多个文件时,使用这种标准功能。以下代码展示了我们如何创建利用特质的函数:fn create<T: CanCreate>(user: &T) -> () {
user.create();
}
fn edit<T: CanEdit>(user: &T) -> () {
user.edit();
}
fn delete<T: CanDelete>(user: &T) -> () {
user.delete();
}
前面的符号相当类似于生命周期注解。我们在输入定义之前使用尖括号来定义我们想要在T
中接受的特质。然后我们声明我们将接受一个实现了该特质的借用结构体作为&T
。这意味着任何实现了该特定特质的结构体都可以通过该函数。因为我们知道特质能做什么,所以我们就可以使用特质函数。然而,因为我们不知道将要传递什么结构体,所以我们不能利用特定的字段。但请记住,当我们为结构体实现特质时,我们可以覆盖特质函数来利用结构体字段。这可能会显得有些僵化,但这个过程强制执行了良好、隔离、解耦的编码,这是安全的。例如,假设我们从一个特质中删除一个函数或从一个结构体中删除一个特质。编译器将拒绝编译,直到这个变化的全部影响都完成。因此,我们可以看到,特别是对于大型系统,Rust 是安全的,并且可以通过减少静默错误的风险来节省时间。现在我们已经定义了函数,我们可以在下一步的main
函数中使用它们。
-
我们可以通过以下代码测试所有特质是否工作:
fn main() {
let admin = AdminUser{
username: "admin".to_string(),
password: "password".to_string()
};
let user = User{
username: "user".to_string(),
password: "password".to_string()
};
create(&admin);
edit(&admin);
edit(&user);
delete(&admin);
}
我们可以看到,接受特质的函数就像任何其他函数一样被使用。
运行整个程序将给出以下输出:
admin is creating
admin is editing
A standard user user is editing
admin is deleting
在我们的输出中,我们可以看到User
结构体的edit
函数被覆盖是有效的。
我们现在已经学到了足够关于特质的知识,可以用于有效的 Web 开发。特质变得更加强大,我们将使用它们来处理我们 Web 编程的一些关键部分。例如,几个 Web 框架有在请求被视图/API 端点处理之前执行的特质。实现具有这些特质的结构体会自动加载view
函数和特质函数的结果。这可以是数据库连接、从头中提取令牌,或我们希望与之工作的任何其他东西。还有一个最后的概念我们需要在进入下一章之前解决,那就是宏。
使用宏进行元编程
元编程可以一般地描述为程序根据某些指令操纵自身的一种方式。考虑到 Rust 的强类型,我们可以通过泛型来实现元编程的最简单方式之一。一个展示泛型的经典例子是通过坐标,如下所示:
struct Coordinate <T> {
x: T,
y: T
}
fn main() {
let one = Coordinate{x: 50, y: 50};
let two = Coordinate{x: 500, y: 500};
let three = Coordinate{x: 5.6, y: 5.6};
}
在前面的代码片段中,我们可以看到Coordinate
结构体成功地处理了三种不同类型的数字。我们可以通过以下代码给Coordinate
结构体添加更多的变化,以便在一个结构体中拥有两种不同的数字类型:
struct Coordinate <T, X> {
x: T,
y: X
}
fn main() {
let one = Coordinate{x: 50, y: 500};
let two = Coordinate{x: 5.6, y: 500};
let three = Coordinate{x: 5.6, y: 50};
}
在前面的代码中,使用泛型所发生的情况是编译器正在寻找结构体被使用的所有实例,在编译运行时创建具有相应类型的结构体。现在我们已经涵盖了泛型,我们可以继续到元编程的主要机制:宏。
宏使我们能够抽象代码。我们已经在我们的打印函数中使用过宏了。函数末尾的!
符号表示这是一个正在被调用的宏。定义我们自己的宏是定义一个函数和使用函数中的match
语句内的生命周期符号的结合。为了演示这一点,我们将定义一个宏,以下代码将字符串大写:
macro_rules! capitalize {
($a: expr) => {
let mut v: Vec<char> = $a.chars().collect();
v[0] = v[0].to_uppercase().nth(0).unwrap();
$a = v.into_iter().collect();
}
}
fn main() {
let mut x = String::from("test");
capitalize!(x);
println!("{}", x);
}
我们不使用fn
这个词,而是使用macro_rules!
定义。然后我们说$a
是传递给宏的表达式。我们获取这个表达式,将其转换为字符向量,然后将第一个字符转换为大写,最后再将其转换回字符串。我们必须注意,在capitalize
宏中我们不返回任何内容,当我们调用宏时,我们也不给它赋值一个变量。然而,当我们最后打印x
变量时,我们可以看到它已经被大写了。这并不像普通函数那样表现。我们还必须注意,我们没有定义一个类型,而是只是说它是一个表达式;宏仍然通过特性行为进行检查。将整数传递给宏会创建以下错误:
| capitalize!(32);
| ---------------- in this macro invocation
|
= help: the trait `std::iter::FromIterator<char>` is not implemented for `{integer}`
生命周期、块、字面量、路径、元编程等也可以传递而不是表达式。虽然对基本宏的内部机制有一个简要的了解对于调试和进一步阅读很重要,但更深入地开发复杂宏并不会帮助我们开发 Web 应用。我们必须记住,宏是最后的手段,应该谨慎使用。宏中抛出的错误可能很难调试。在 Web 开发中,许多宏已经定义在第三方包中。正因为如此,我们不需要自己编写宏来启动 Web 应用。相反,我们将主要使用现成的derive
宏。
摘要
使用 Rust,我们发现从动态编程语言背景转换过来时存在一些陷阱。然而,只要稍微了解一些引用和基本内存管理知识,我们就可以避免常见的陷阱,并快速编写安全、高效的代码,以处理错误。通过利用结构体和特质,我们可以构建类似于标准动态编程语言中类的对象。在此基础上,特质还使我们能够构建类似混合功能的特性。这不仅使我们能够在需要时插入功能,而且可以通过类型检查对结构体进行验证,以确保容器或函数正在处理具有属于特质的特定属性的结构体,这些属性可以在代码中利用。
在我们完全功能化的结构体上,我们通过宏进一步增加了更多功能,并通过构建自己的 capitalize
函数来深入了解基本宏的内部机制,这为我们提供了进一步阅读和调试的指导。我们还看到了一个简短的演示,展示了宏与结构体结合在 Web 开发中进行 JSON 序列化时的强大功能。通过本章所学,我们现在可以编写基本的 Rust 程序。因为我们理解了借用检查器强制执行的概念,我们可以调试我们编写的应用程序。像其他语言一样,我们目前可以做的实际应用是有限的。然而,我们确实有了构建跨多个文件运行在我们自己的本地计算机上的实际应用的基础。
我们现在可以继续到下一章,并调查在我们自己的计算机上设置 Rust 环境,以结构化文件和代码,使我们能够构建可以解决实际问题的程序。
问题
-
str
和String
之间的区别是什么? -
为什么字符串切片不能传递给函数(字符串切片指的是
str
,而不是&str
)? -
我们如何访问 HashMap 中键所属的数据?
-
当一个函数导致错误时,我们能否处理其他过程,或者错误会立即崩溃程序?
-
为什么 Rust 只允许在某个时间点进行一次可变借用?
-
我们在函数中何时需要定义两个不同的生命周期?
-
结构体如何通过其字段之一链接到相同的结构体?
-
我们如何向结构体添加额外的功能,而这些功能也可以由其他结构体实现?
-
我们如何让容器或函数接受不同的数据结构?
-
添加一个特质,如
Copy
,到结构体的最快方式是什么?
答案
-
String
是一个固定大小的引用,存储在栈上,指向堆上的字符串类型数据。str
是存储在内存某处的不可变字节序列。由于str
的大小未知,它只能通过&str
指针来处理。 -
由于我们在编译时不知道字符串切片的大小,我们无法为其分配正确的内存量。另一方面,字符串有一个固定大小的引用存储在栈上,该引用指向堆上的字符串切片。因为我们知道这个字符串引用的固定大小,我们可以分配正确的内存量并将其传递给函数。
-
我们使用 HashMap 的
get
函数。然而,我们必须记住,get
函数仅仅返回一个Option
结构体。如果我们确信那里有东西,或者我们希望找不到东西时程序崩溃,我们可以直接解包它。然而,如果我们不希望这样,我们可以使用一个match
语句,并按我们的意愿处理Some
和None
输出。 -
不,在暴露错误之前,结果必须被解包。一个简单的
match
语句可以处理解包结果并按我们的意愿管理错误。 -
Rust 只允许一个可变借用,以防止内存不安全。在 Goregaokar 的博客中,使用枚举的例子来说明这一点。如果一个枚举支持两种不同的数据类型(
String
和i64
),如果对枚举的字符串变体的可变引用被创建,然后又创建另一个引用,可变引用可以更改数据,然后第二个引用仍然引用枚举的字符串变体。然后,第二个引用将尝试解引用枚举的字符串变体,这可能导致段错误。关于这个例子和其他例子的详细说明,请参阅进一步阅读部分。 -
当函数的结果依赖于某个生命周期,并且该结果需要在调用它的作用域之外使用时,我们需要定义两个不同的生命周期。
-
如果一个结构体在其字段中引用自身,其大小可能是无限的,因为它可以持续不断地引用自身。为了防止这种情况,我们可以在字段中对该结构体的引用用
Box
结构体包装。 -
通过使用特质,我们可以将额外的功能和解耦性嵌入到结构体中。实现一个特质将使结构体能够使用属于该特质的函数。特质的实现还允许结构体通过该特质的类型检查。
-
我们允许容器或函数通过在类型检查中声明枚举或特质,或者通过使用泛型(参见进一步阅读部分:精通 Rust或Rust 函数式编程实践(第一章))来接受不同的数据结构。
-
向结构体添加特质的最快方式是通过使用具有复制和克隆特质的 derive 宏来注释结构体。
进一步阅读
-
Hands-On Functional Programming in Rust(2018)由 Andrew Johnson 著,Packt Publishing 出版社
-
Mastering Rust(2019)由 Rahul Sharma 和 Vesa Kaihlavirta 著,Packt Publishing 出版社
-
Rust 编程语言(2018):
doc.rust-lang.org/stable/book/
-
单线程共享可变性问题(2015)由 Manish Goregaokar 撰写:
manishearth.github.io/blog/2015/05/17/the-problem-with-shared-mutability/
第二章:在 Rust 中设计你的 Web 应用
我们之前已经探讨了 Rust 的语法,使我们能够处理内存管理的怪癖并构建数据结构。然而,正如任何经验丰富的工程师会告诉你的,在多个文件和目录间结构化代码是构建软件的一个重要方面。
在本章中,我们将构建一个基本的命令行待办事项程序。我们使用 Rust 的Cargo来管理构建我们的命令行程序所需的依赖项。我们的程序将以可扩展的方式构建和管理我们的模块,这些模块将被导入到程序的其它区域并使用。我们将通过构建一个跨越多个文件、创建、编辑和删除待办事项应用的待办事项应用来学习这些概念。此应用将本地保存我们的多个待办事项应用文件,并且我们可以通过命令行界面与我们的应用进行交互。
在本章中,我们将涵盖以下主题:
-
使用 Cargo 管理软件项目
-
结构化代码
-
与环境交互
到本章结束时,你将能够构建可以在 Rust 中打包和使用的应用。你还将能够在你的代码中使用第三方包。结果,如果你理解你试图解决的问题并且能够将其分解为逻辑块,你将能够构建任何不需要服务器或图形用户界面的命令行应用。
技术要求
随着我们开始使用 Rust 构建 Web 应用,我们将不得不开始依赖第三方包来为我们做一些繁重的工作。Rust 通过一个名为 Cargo 的包管理器来管理依赖项。要使用 Cargo,我们必须从以下 URL 安装 Rust 到我们的计算机上:www.rust-lang.org/tools/install
。
此安装提供了编程语言 Rust 和依赖项管理器 Cargo。你可以在 GitHub 上找到所有代码文件:
github.com/PacktPublishing/Rust-Web-Programming-2nd-Edition/tree/main/chapter02
使用 Cargo 管理软件项目
在我们开始使用 Cargo 来结构化我们的程序之前,我们应该构建一个基本的单文件应用。为此,我们最初必须在本地目录中创建一个名为hello_world.rs
的文件。.rs
扩展名表示该文件是一个 Rust 文件。说实话,扩展名并不重要。如果在该文件中编写了有效的 Rust 代码,编译器将无任何问题地编译并运行它。然而,使用不同的扩展名可能会让其他开发者和代码编辑器感到困惑,并在从其他 Rust 文件导入代码时引起问题。因此,在命名你的 Rust 文件时最好使用.rs
。在我们的hello_world.rs
文件中,我们可以有以下的代码:
fn main() {
println!("hello world");
}
这与我们在上一章中的第一个代码块没有区别。现在我们已经在我们的hello_world.rs
文件中定义了入口点,我们可以使用以下命令来编译文件:
rustc hello_world.rs
一旦编译完成,同一目录下将会有一个二进制文件,可以运行。如果我们是在 Windows 上编译,我们可以使用以下命令来运行这个二进制文件:
.\hello_world.exe
如果我们在 Linux 或 macOS 上编译它,我们可以使用以下命令来运行它:
./hello_world
由于我们只构建了一个简单的hello world
示例,所以hello world
将直接打印出来。虽然当在一个文件中构建简单应用程序时这很有用,但不建议用于管理跨多个文件的程序。即使在依赖第三方模块时,也不推荐这样做。这就是 Cargo 发挥作用的地方。Cargo 通过一些简单的命令,开箱即用地管理一切,包括运行、测试、文档、构建/编译以及第三方模块依赖项。我们将在本章中介绍这些命令。从我们运行hello world
示例时看到的情况来看,我们必须在运行代码之前编译它,所以现在让我们继续到下一节,在那里我们将使用 Cargo 构建一个基本的应用程序。
使用 Cargo 进行构建
使用 Cargo 进行构建很简单。我们只需导航到我们想要构建项目的目录,并运行以下命令:
cargo new web_app
上述命令构建了一个基本的 Cargo Rust 项目。如果我们探索这个应用程序,我们会看到以下结构:
└── web_app
├── Cargo.toml
└── src
└── main.rs
我们可以看到只有一个 Rust 文件,这就是位于src
目录中的main.rs
文件。如果你打开main.rs
文件,你会看到这与我们在上一节中制作的文件相同。这是一个入口点,默认代码将hello world
打印到控制台。我们项目的依赖项和元数据定义在Cargo.toml
文件中。如果我们想要运行我们的程序,我们不需要导航到main.rs
文件并运行rustc
。相反,我们可以使用 Cargo 并使用以下命令来运行它:
cargo run
当你这样做时,你会看到项目按照以下打印输出进行编译和运行:
Compiling web_app v0.1.0 (/Users/maxwellflitton/Documents/
github/books/Rust-Web_Programming-two/chapter02/web_app)
Finished dev [unoptimized + debuginfo] target(s) in 0.15s
Running `target/debug/web_app`
hello world
您的打印输出将略有不同,因为基本目录将不同。在底部,您将看到hello world
,这是我们预期的。我们还可以看到打印输出表明编译未优化,并且它在target/debug/web_app
中运行。我们可以直接导航到target/debug/web_app
二进制文件并像上一节那样运行它,因为这是二进制文件存储的位置。target
目录是编译、运行和记录我们程序文件的存放位置。如果我们把代码附加到 GitHub 仓库,我们必须确保通过在.gitignore
文件中将其放置来让 GitHub 忽略target
目录。目前,我们正在运行未优化的版本。这意味着它运行较慢但编译更快。这在开发时是有意义的,因为我们将多次编译。然而,如果我们想运行优化版本,我们可以使用以下命令:
cargo run --release
前面的命令给出了以下打印输出:
Finished release [optimized] target(s) in 2.63s
Running `target/release/web_app`
hello world
在前面的输出中,我们可以看到我们的优化二进制文件位于target/release/web_app
路径。现在我们已经完成了基本构建,我们可以开始使用 Cargo 来利用第三方 crate。
使用 Cargo 打包 crate
第三方库被称为 crate。添加它们并使用 Cargo 管理它们是直接的。在本节中,我们将通过利用位于rust-random.github.io/rand/rand/index.html
的rand
crate 来探索这个过程。必须注意的是,这个 crate 的文档清晰且结构良好,包含对结构体、特性和模块的链接。这并不是对 rand crate 本身的反映。这是 Rust 的标准文档,我们将在下一节中介绍。为了在我们的项目中使用这个 crate,我们打开Cargo.toml
文件,并在[dependencies]
部分下添加rand
crate,如下所示:
[dependencies]
rand = "0.7.3"
现在我们已经定义了我们的依赖项,我们可以使用rand
crate 来构建一个随机数生成器:
use rand::prelude::*;
fn generate_float(generator: &mut ThreadRng) -> f64 {
let placeholder: f64 = generator.gen();
return placeholder * 10.0
}
fn main() {
let mut rng: ThreadRng = rand::thread_rng();
let random_number = generate_float(&mut rng);
println!("{}", random_number);
}
在前面的代码中,我们定义了一个名为generate_float
的函数,它使用 crate 生成并返回一个介于0
和10
之间的浮点数。一旦我们完成这个操作,我们就打印这个数字。rand
crate 的实现由 rand 文档处理。我们的use
语句导入了rand
crate。当使用rand
crate 生成浮点数时,文档告诉我们从rand::prelude
模块导入(*
),这简化了常见项的导入,如 crate 文档所示,请参阅rust-random.github.io/rand/rand/prelude/index.html
。
ThreadRng
结构体是一个随机数生成器,它生成一个介于0
和1
之间的f64
值,这在 rand crate 文档中有详细说明,请参阅rust-random.github.io/rand/rand/rngs/struct.ThreadRng.html
。
现在,我们看到了文档的强大功能。通过在 rand 文档的介绍页上点击几下,我们可以深入了解演示中使用的结构和函数的声明。现在我们的代码已经构建完成,我们可以使用 cargo run
命令运行我们的程序。当 Cargo 编译时,它会从 rand
crate 中提取代码并将其编译成二进制文件。我们还可以注意到现在有一个 cargo.lock
文件。正如我们所知,cargo.toml
是用来描述我们自己的依赖项的,而 cargo.lock
是由 Cargo 生成的,我们不应该自己编辑它,因为它包含有关我们依赖项的精确信息。这种无缝的功能结合易于使用的文档展示了 Rust 如何通过开发生态系统以及语言的质量通过边际收益来改善开发过程。然而,所有这些来自文档的收益并不完全依赖于第三方库;我们还可以自动生成自己的文档。
使用 Cargo 进行文档化
选择像 Rust 这样的新语言进行开发,速度和安全性并非唯一的益处。多年来,软件工程社区一直在学习和成长。像良好的文档这样的简单事情可以成就或毁掉一个项目。为了证明这一点,我们可以在 Rust 文件中使用以下代码定义 Markdown 语言:
/// This function generates a float number using a number
/// generator passed into the function.
///
/// # Arguments
/// * generator (&mut ThreadRng): the random number
/// generator to generate the random number
///
/// # Returns
/// (f64): random number between 0 -> 10
fn generate_float(generator: &mut ThreadRng) -> f64 {
let placeholder: f64 = generator.gen();
return placeholder * 10.0
}
在前面的代码中,我们使用 ///
标记表示 Markdown。这做了两件事:它告诉查看代码的其他开发者函数的作用,并在我们的自动生成中渲染 Markdown。在我们运行文档命令之前,我们可以定义和记录一个基本的用户结构和一个基本的用户特质,以展示这些是如何被记录的:
/// This trait defines the struct to be a user.
trait IsUser {
/// This function proclaims that the struct is a user.
///
/// # Arguments
/// None
///
/// # Returns
/// (bool) true if user, false if not
fn is_user() -> bool {
return true
}
}
/// This struct defines a user
///
/// # Attributes
/// * name (String): the name of the user
/// * age (i8): the age of the user
struct User {
name: String,
age: i8
}
现在我们已经记录了一系列不同的结构,我们可以使用以下命令运行自动文档化过程:
cargo doc --open
我们可以看到,文档的渲染方式与 rand crate 相同:
图 2.1 – 网络应用的文档视图
在前面的屏幕截图中,我们可以看到 web_app 是一个 crate。我们还可以看到 rand crate 的文档涉及其中(如果我们查看屏幕截图的左下角,我们可以看到位于我们的 web_app crate 文档之上的 rand crate 文档)。如果我们点击 User 结构,我们可以看到结构的声明、我们为属性编写的 Markdown 以及特质的影响,如下面的图所示:
图 2.2 – 结构的文档
必须注意,在本书的未来章节中,我们将不在代码片段中包含 Markdown 以保持可读性。然而,书中 GitHub 仓库提供了 Markdown 格式的代码。现在我们有一个文档齐全、运行良好的 Cargo 项目,我们需要能够向其中传递参数,以便根据上下文运行不同的配置。
与 Cargo 交互
现在我们程序正在运行并使用第三方模块,我们可以开始通过命令行输入与我们的 Rust 程序交互。为了使我们的程序能够根据上下文具有一些灵活性,我们需要能够向我们的程序传递参数并跟踪程序运行的参数。我们可以使用std
(标准库)标识符来完成这项任务:
use std::env;
fn main() {
let args: Vec<String> = env::args().collect();
println!("{:?}", args);
}
在前面的代码中,我们可以看到我们将传递给程序的参数收集到一个向量中,然后在调试模式下打印出这些参数。让我们运行以下命令:
cargo run one two three
运行前面的命令会得到以下输出:
["target/debug/interacting_with_cargo", "one", "two", "three"]
在这里,我们可以看到我们的args
向量包含了我们传递的参数。这并不奇怪,因为许多其他语言也接受通过命令行传递给程序的参数。我们还必须注意,二进制文件的路径也包括在内。在这里,我还必须强调,我正在使用一个名为interacting_with_cargo
的不同项目,因此路径是target/debug/interacting_with_cargo
。我们还可以从命令行参数中看到我们正在调试模式下运行。让我们尝试使用以下命令运行我们程序的发布版本:
cargo run --release one two three
我们会收到以下输出:
["target/release/interacting_with_cargo", "one", "two", "three"]
从前面的输出中,我们可以看到--release
不在我们的向量中。然而,这确实给我们提供了一些额外的功能来玩耍。例如,我们可能希望根据编译类型运行不同的进程。这可以通过以下代码轻松实现:
let args: Vec<String> = env::args().collect();
let path: &str = &args[0];
if path.contains("/debug/") {
println!("Debug is running");
}
else if path.contains("/release/") {
println!("release is running");
}
else {
panic!("The setting is neither debug or release");
}
然而,前面的简单解决方案是临时的。我们提取的path
只有在运行 Cargo 命令时才是一致的。虽然 Cargo 命令在构建、编译和文档化方面很出色,但在生产环境中携带所有这些文件是没有意义的。实际上,提取静态二进制文件,将其完全封装在 Docker 容器中,并直接运行二进制文件是有优势的,这样可以减少 Docker 镜像的大小,从 1.5 GB 减少到 200 MB。因此,虽然这看起来像是一个快速胜利,但它可能导致在部署我们的应用程序时破坏代码。因此,在最后放入panic
宏是至关重要的,以防止这种情况进入生产环境而你却不知道。
到目前为止,我们已经传递了一些基本的命令;然而,这并不有用或可扩展。还会有很多样板代码为我们编写,以实现为用户提供帮助指南。为了扩展我们的命令行界面,我们可以依赖 clap
crate 来处理传递给程序的参数,以下是一个依赖项:
[dependencies]
clap = "3.0.14"
为了完善我们对命令行界面的理解,我们可以开发一个玩具应用程序,它只接受一些命令并将它们打印出来。为了做到这一点,我们必须在 main.rs
文件中导入 clap
crate 所需的内容,以下代码所示:
use clap::{Arg, App};
现在,我们可以继续定义我们的应用程序:
-
我们的应用程序在
main
函数中包含有关应用程序的元数据,以下代码所示:fn main() {
let app = App::new("booking")
.version("1.0")
.about("Books in a user")
.author("Maxwell Flitton");
. . .
如果我们查看 clap
的文档,我们可以直接将参数绑定到 App
结构体;然而,这可能会变得很丑陋并且紧密耦合。相反,我们将在下一步中单独定义它们。
-
在我们的玩具应用程序中,我们正在接受一个名字、姓氏和年龄,可以定义如下:
let first_name = Arg::new("first name")
.long("f")
.takes_value(true)
.help("first name of user")
.required(true);
let last_name = Arg::new("last name")
.long("l")
.takes_value(true)
.help("first name of user")
.required(true);
let age = Arg::new("age")
.long("a")
.takes_value(true)
.help("age of the user")
.required(true);
我们可以看到我们可以继续堆叠参数。目前,它们还没有绑定到任何东西。现在,我们可以在下一步中将它们绑定到我们的应用程序并传递参数。
-
绑定、获取和解析输入可以通过以下代码实现:
let app = app.arg(first_name).arg(last_name).arg(age);
let matches = app.get_matches();
let name = matches.value_of("first name")
.expect("First name is required");
let surname = matches.value_of("last name")
.expect("Surname is required");
let age: i8 = matches.value_of("age")
.expect("Age is required").parse().unwrap();
println!("{:?}", name);
println!("{:?}", surname);
println!("{:?}", age);
}
现在我们有一个如何传递命令行参数的工作示例,我们可以通过运行以下命令与我们的应用程序交互,以查看它如何显示:
cargo run -- --help
在 --help
前面的中间 --
告诉 Cargo 将 --
后面的所有参数传递给 clap
而不是 cargo
。前面的命令将给出以下输出:
booking 1.0
Maxwell Flitton
Books in a user
USAGE:
interacting_with_cargo --f <first name> --l <last name>
--a <age>
OPTIONS:
--a <age> age of the user
--f <first name> first name of user
-h, --help Print help information
--l <last name> first name of user
-V, --version Print version information
在前面的输出中,我们可以看到如何直接与我们的编译后的二进制文件交互。我们还有一个很棒的帮助菜单。要与 Cargo 交互,我们需要运行以下命令:
cargo run -- --f first --l second --a 32
前面的命令将给出以下输出:
"first"
"second"
32
我们可以看到解析是正常工作的,因为我们有两个字符串和一个整数。crates 如 clap
有用的原因是它们本质上具有自文档性。开发者可以查看代码并了解正在接受哪些参数以及它们周围的元数据。用户可以通过仅传递 help
参数来获取输入的帮助。这种方法减少了文档过时的风险,因为它嵌入在执行它的代码中。如果你接受命令行参数,建议你使用像 clap
这样的 crate 来实现这个目的。现在我们已经探讨了如何结构化我们的命令行界面以便它可以扩展,我们可以在下一节中调查如何将代码结构化到多个文件中以扩展它。
代码结构
现在,我们可以开始构建我们的 Web 应用程序之旅了。在本章的其余部分,我们不会接触任何 Web 框架或构建 HTTP 监听器。这将在下一章发生。然而,我们将构建一个与 JSON 文件交互的待办事项模块。它将被构建成这样的结构,可以以最小的努力插入到我们构建的任何 Web 应用程序中。这个待办事项模块将使我们能够创建、更新和删除待办事项。然后我们将通过命令行与它交互。这里的流程是探索如何构建结构良好、可扩展和灵活的代码。为了理解这一点,我们将把这个模块的构建分解为以下部分:
-
构建待办和已完成的待办事项结构体。
-
构建一个工厂,使结构体能够在模块中以最小的清洁输入构建。
-
构建特性,使结构体能够删除、创建、编辑和获取待办事项。
-
构建一个读写文件的模块来存储待办事项(我们将在后面的章节中将这个替换为合适的数据库)。
-
构建一个
config
模块,该模块可以根据config
文件中的变量来改变应用程序的行为。
在我们开始处理这些步骤之前,我们需要让应用程序运行起来。我们可以通过导航到我们想要存放此应用程序的目录,并启动一个新的名为todo_app
的 Cargo 项目来实现这一点。一旦完成,我们将把处理待办事项管理的逻辑放入我们的to_do
模块中。这可以通过创建一个to_do
目录并在该目录的底部放置一个mod.rs
文件来实现,如下面的布局所示:
├── main.rs
└── to_do
├── mod.rs
使用这个结构,我们可以从结构体开始构建我们的to_do
模块。现在不必担心to_do
文件,因为这在第一步中已经涵盖了,即构建我们已完成和待完成的待办事项的结构体。
构建待办事项结构体
目前,我们只有两个用于待办事项的结构体:一个是等待完成的,另一个是已经完成的。然而,我们可能想要引入其他类别。例如,我们可以添加一个待办事项类别,或者为那些已经开始但出于某种原因受阻的任务添加一个暂停任务。为了避免错误和重复的代码,我们可以构建一个Base
结构体,并让其他结构体使用它。Base
结构体包含公共字段和函数。对Base
结构体的任何修改都将传播到所有其他待办事项结构体。我们还需要定义待办事项的类型。我们可以为待办和已完成硬编码字符串;然而,这不可扩展,并且容易出错。为了避免这种情况,我们将使用枚举来分类和定义待办事项类型的表示。为了实现这一点,我们需要为我们的模块创建以下文件结构:
├── main.rs
└── to_do
├── enums.rs
├── mod.rs
└── structs
├── base.rs
├── done.rs
├── mod.rs
└── pending.rs
在前面的代码中,我们可以注意到我们有两个 mod.rs
文件。这些文件基本上是我们声明文件的地方,以及我们在其中定义的内容,以便它们可以被同一目录中的其他文件访问。我们还可以在 mod.rs
文件中公开声明它们,以允许文件在目录外部被访问。在我们编写任何代码之前,我们可以在 图 2**.3 中看到我们的模块中的数据流:
图 2.3 – 我们待办模块中的数据流
我们可以看到我们的 Base
结构体被我们的其他待办结构体所使用。如果我们没有声明它,其他待办结构体将无法访问 Base
结构体。然而,to_do/structs
目录外部的任何文件都没有引用 Base
结构体,因此它不需要是公开声明。
现在我们已经理解了模块的数据流,我们需要回顾 图 2**.3 并确定我们首先需要做什么。我们可以看到我们的枚举没有依赖关系。事实上,我们的枚举提供了所有结构体。因此,我们将从 /to_do/enums.rs
文件中的枚举开始。我们的枚举定义了任务的状况,如下所示:
pub enum TaskStatus {
DONE,
PENDING
}
当定义任务的状况时,这将在代码中工作。然而,如果我们想要写入文件或数据库,我们将不得不构建一个方法来使我们的枚举能够以字符串格式表示。为此,我们可以为 TaskStatus
枚举实现一个 stringify
函数,如下所示:
impl TaskStatus {
pub fn stringify(&self) -> String {
match &self {
&Self::DONE => {"DONE".to_string()},
&Self::PENDING => {"PENDING".to_string()}
}
}
}
调用此函数将使我们能够在控制台打印待办任务的状况并将其写入我们的 JSON 文件。
注意
虽然 stringify
函数可以工作,但还有另一种将枚举值转换为字符串的方法。为了实现字符串转换,我们可以为 TaskStatus
实现一个 Display
特性。首先,我们必须使用以下代码导入格式模块:
use std::fmt;
然后,我们可以使用以下代码为 TaskStatus
结构体实现 Display
特性:
impl fmt::Display for TaskStatus {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match &self {
&Self::DONE => {write!(f, "DONE")},
&Self::PENDING => {write!(f, "PENDING")}
}
}
}
这个特性和实现具有与我们的 stringify
函数相同的逻辑。然而,我们的特性和需要时才会被使用。因此,我们有以下代码:
println!("{}", TaskStatus::DONE);
println!("{}", TaskStatus::PENDING);
let outcome = TaskStatus::DONE.to_string();
println!("{}", outcome);
这将导致以下输出:
DONE
PENDING
DONE
在这里,我们可以看到当我们把 TaskStatus
传递给 println!
时,Display
特性会自动被利用。
现在,我们可以在 /to_do/mod.rs
文件中使用以下代码使我们的枚举公开可用:
pub mod enums;
现在,我们可以参考 图 2**.3 来查看我们接下来可以构建什么,那就是 Base
结构体。我们可以在 /to_do/structs/base.rs
文件中使用以下代码定义 Base
结构体:
use super::super::enums::TaskStatus;
pub struct Base {
pub title: String,
pub status: TaskStatus
}
从文件顶部的导入中,我们可以使用super::super
访问TaskStatus
枚举。我们知道TaskStatus
枚举位于更高的目录中。由此,我们可以推断出super
为我们提供了访问当前目录mod.rs
文件中声明的内容的权限。因此,在/to_do/structs/
目录中的文件中使用super::super
可以让我们访问到/to_do/mod.rs
文件中定义的内容。
现在,我们可以在/to_do/structs/mod.rs
文件中声明我们的Base
结构体,以下代码如下:
mod base;
我们不需要将其声明为公共的,因为我们的Base
结构体不会在/to_do/structs/
目录外被访问。现在,回顾一下图 2**.3,我们可以构建我们的Pending
和Done
结构体。这就是我们在/to_do/structs/pending.rs
文件中使用组合来利用我们的Base
结构体的时刻,以下代码如下:
use super::base::Base;
use super::super::enums::TaskStatus;
pub struct Pending {
pub super_struct: Base
}
impl Pending {
pub fn new(input_title: &str) -> Self {
let base = Base{
title: input_title.to_string(),
status: TaskStatus::PENDING
};
return Pending{super_struct: base}
}
}
通过前面的代码,我们可以看到我们的super_struct
字段包含了我们的Base
结构体。我们利用我们的枚举并定义状态为挂起。这意味着我们只需将标题传递给构造函数,我们就有了一个具有标题和挂起状态的结构体。考虑到这一点,在我们的/to_do/structs/done.rs
文件中编写我们的Done
结构体应该是直截了当的,以下代码如下:
use super::base::Base;
use super::super::enums::TaskStatus;
pub struct Done {
pub super_struct: Base
}
impl Done {
pub fn new(input_title: &str) -> Self {
let base = Base {
title: input_title.to_string(),
status: TaskStatus::DONE
};
return Done{super_struct: base}
}
}
我们可以看到,除了TaskStatus
枚举有一个DONE
状态之外,与Pending
结构体定义没有太大区别。现在,我们可以通过以下代码在/to_do/structs/mod.rs
文件中使我们的结构体在目录外可用:
mod base;
pub mod done;
pub mod pending;
我们可以通过在/to_do/mod.rs
文件中声明以下代码,使我们的结构体在main.rs
文件中可访问:
pub mod structs;
pub mod enums;
我们现在已经创建了一个基本的模块并将其暴露给main.rs
文件。目前,我们可以编写一些基本的代码,使用我们的模块创建一个挂起的任务和另一个完成的任务。这可以通过以下代码完成:
mod to_do;
use to_do::structs::done::Done;
use to_do::structs::pending::Pending;
fn main() {
let done = Done::new("shopping");
println!("{}", done.super_struct.title);
println!("{}", done.super_struct.status.stringify());
let pending = Pending::new("laundry");
println!("{}", pending.super_struct.title);
println!("{}", pending.super_struct.status.stringify()
);
}
在前面的代码中,我们可以看到我们已声明了我们的to_do
模块。然后我们导入了我们的结构体并创建了一个pending
和done
结构体。运行我们的代码将给出以下输出:
shopping
DONE
laundry
PENDING
这阻止了main.rs
文件因过多的代码而变得臃肿。如果我们堆叠更多可以创建的项目类型,例如挂起或待办事项,main.rs
中的代码将会膨胀。这就是工厂模式发挥作用的地方,我们将在下一步进行探讨。
使用工厂管理结构体
工厂模式是指我们在模块的入口点抽象结构体的构建。我们可以通过以下方式了解我们的模块是如何工作的:
图 2.4 – to-do 工厂流程
工厂的作用是通过提供一个接口来抽象模块。虽然我们享受构建我们的模块,但如果其他开发者想要使用它,一个简单的工厂接口和良好的文档将为他们节省很多时间。他们只需要传递几个参数,然后从工厂中获取构建的结构体,这些结构体被封装在枚举中。如果我们更改模块的内部结构或它变得更加复杂,这不会产生影响。如果其他模块使用接口,如果我们保持接口的一致性,那么更改不会破坏其他代码。我们可以在 /to_do/mod.rs
文件中通过定义以下代码来构建我们的工厂:
pub mod structs;
pub mod enums;
use enums::TaskStatus;
use structs::done::Done;
use structs::pending::Pending;
pub enum ItemTypes {
Pending(Pending),
Done(Done)
}
pub fn to_do_factory(title: &str,
status: TaskStatus) -> ItemTypes {
match status {
TaskStatus::DONE => {
ItemTypes::Done(Done::new(title))
},
TaskStatus::PENDING => {
ItemTypes::Pending(Pending::new(title))
}
}
}
在前面的代码中,我们可以看到我们定义了一个名为 ItemTypes
的枚举,它封装了构造的任务结构体。我们的 factory
函数本质上接受我们输入的标题和状态。然后工厂匹配输入的状态。一旦我们确定了传入的状态类型,我们就构建一个与状态匹配的任务,并用 ItemTypes
枚举将其封装。这可能会变得越来越大和复杂,而我们的主文件将不会察觉到这一点。然后我们可以在 main.rs
文件中使用以下代码实现这个工厂:
mod to_do;
use to_do::to_do_factory;
use to_do::enums::TaskStatus;
use to_do::ItemTypes;
fn main() {
let to_do_item = to_do_factory("washing",
TaskStatus::DONE);
match to_do_item {
ItemTypes::Done(item) => {
println!("{}", item.super_struct.status
.stringify());
println!("{}", item.super_struct.title);
},
ItemTypes::Pending(item) => {
println!("{}", item.super_struct.status
.stringify());
println!("{}", item.super_struct.title);
}
}
}
在前面的代码中,我们可以看到我们将我们想要为待办事项创建的参数传递给工厂,然后匹配结果以打印出项的属性。现在 main.rs
文件中引入了更多的代码。然而,引入更多的代码是因为我们正在解包返回的枚举以打印出属性以供演示。我们通常将这个封装的枚举传递到其他模块中处理。创建结构体只需要一行代码,如下所示:
let to_do_item = to_do_factory("washing", TaskStatus::DONE);
这意味着我们可以创建一个待办事项并将其轻松传递,因为它被封装在枚举中。其他函数和模块只需接受枚举。我们可以看到这提供了灵活性。然而,根据我们当前的代码,我们可以去掉 Base
、Done
和 Pending
结构体,只保留一个接受状态和标题的结构体。这将意味着更少的代码。然而,这将也会降低灵活性。我们将在下一步中看到这一点,我们将向我们的结构体添加特性以锁定功能并确保安全性。
使用特性定义功能
目前,我们的结构体除了持有任务的状态和标题外,实际上并没有做什么。然而,我们的结构体可以有不同功能。因此,我们额外麻烦地定义了单独的结构体。例如,我们在这里构建一个待办事项应用程序。最终如何构建你的应用程序取决于你,但确保你不能创建一个已完成任务是有道理的;否则,你为什么要将它添加到你的待办事项列表中?这个例子可能看起来很微不足道。这本书使用待办事项列表来简化我们正在解决的问题。因此,我们可以专注于开发 Rust 网络应用程序的技术方面,而不必花费时间理解我们正在解决的问题。然而,我们必须承认,在更复杂的应用程序中,例如处理银行交易的系统,我们需要对逻辑的实现方式非常严格,并锁定任何不希望发生的过程的可能性。我们可以在我们的待办事项应用程序中通过为每个过程构建单独的特性并将它们分配给我们要的任务结构体来实现这一点。为此,我们需要在to_do
模块中创建一个traits
目录并为每个特性创建一个文件,其结构如下:
├── mod.rs
└── traits
├── create.rs
├── delete.rs
├── edit.rs
├── get.rs
└── mod.rs
然后,我们可以在to_do/traits/mod.rs
文件中公开定义所有特性,代码如下:
pub mod create;
pub mod delete;
pub mod edit;
pub mod get;
我们还必须在我们的to_do/mod.rs
文件中公开定义我们的特性,代码如下:
pub mod traits;
现在我们已经将所有特性文件连接到我们的模块中,我们可以开始构建我们的特性。我们可以从在to_do/traits/get.rs
文件中定义我们的Get
特性开始,代码如下:
pub trait Get {
fn get(&self, title: &str) {
println!("{} is being fetched", title);
}
}
这只是一个展示我们如何应用特性的示例;因此,我们现在只打印出正在发生的事情。我们必须记住,我们不能从传入的&self
参数中引用字段,因为我们可以将我们的特性应用到多个结构体上;然而,我们可以覆盖实现此特性的特性的get
函数。当涉及到Edit
特性时,我们可以在to_do/traits/edit.rs
文件中有两个函数来改变状态,代码如下:
pub trait Edit {
fn set_to_done(&self, title: &str) {
println!("{} is being set to done", title);
}
fn set_to_pending(&self, title: &str) {
println!("{} is being set to pending", title);
}
}
这里我们可以看到一个模式。因此,为了完整性,我们的Create
特性在to_do/traits/create.rs
文件中的形式如下:
pub trait Create {
fn create(&self, title: &str) {
println!("{} is being created", title);
}
}
我们的Delete
特性在to_do/traits/delete.rs
文件中定义,代码如下:
pub trait Delete {
fn delete(&self, title: &str) {
println!("{} is being deleted", title);
}
}
我们现在已经定义了我们需要的所有特性。因此,我们可以利用它们在待办事项项结构体中定义和锁定行为。对于我们的Done
结构体,我们可以将特性导入到to_do/structs/done.rs
文件中,代码如下:
use super::super::traits::get::Get;
use super::super::traits::delete::Delete;
use super::super::traits::edit::Edit;
然后,我们可以在定义了Done
结构体的同一文件中实现我们的Done
结构体,代码如下:
impl Get for Done {}
impl Delete for Done {}
impl Edit for Done {}
现在,我们的 Done
结构体可以获取、编辑和删除待办事项。在这里,我们可以真正看到特性(traits)的力量,正如在第一章《Rust 快速入门》中所强调的。我们可以轻松地堆叠或移除特性。例如,允许创建完成的待办事项可以通过简单的 impl Create for Done;
实现。现在我们已经为我们的 Done
结构体定义了所需的特性,我们可以继续到我们的 Pending
结构体,在 to_do/structs/pending.rs
文件中导入所需的代码:
use super::super::traits::get::Get;
use super::super::traits::edit::Edit;
use super::super::traits::create::Create;
然后,我们可以在定义我们的 Pending
结构体之后,使用以下代码实现这些特性:
impl Get for Pending {}
impl Edit for Pending {}
impl Create for Pending {}
在前面的代码中,我们可以看到我们的 Pending
结构体可以获取、编辑和创建,但不能删除。实现这些特性也将我们的 Pending
和 Done
结构体紧密地联系在一起,而无需编译它们。例如,如果我们接受实现了 Edit
特性的结构体,它将接受 Pending
和 Done
结构体。然而,如果我们创建一个接受实现了 Delete
特性的结构体的函数,它将接受 Done
结构体但拒绝 Pending
结构体。这为我们提供了一个充满活力的类型检查与灵活性的交响乐,这确实是 Rust 设计的证明。现在,我们的结构体已经拥有了所有我们想要的特性,我们可以完全重写我们的 main.rs
文件,利用它们。首先,我们使用以下代码导入所需的代码:
mod to_do;
use to_do::to_do_factory;
use to_do::enums::TaskStatus;
use to_do::ItemTypes;
use crate::to_do::traits::get::Get;
use crate::to_do::traits::delete::Delete;
use crate::to_do::traits::edit::Edit;
这次需要注意的是导入。尽管我们已经在我们想要的结构体上实现了特性,但我们必须将这些特性导入到使用它们的文件中。这可能会有些令人困惑。例如,在结构体初始化后调用 Get
特性的 get
函数将采取以下形式:item.get(&item.super_struct.title);
。get
函数与初始化的结构体绑定。直观上,不需要导入特性是有道理的。然而,如果你没有导入特性,编译器或 IDE 会给出无用的错误,指出名为 get
的函数在结构体中未找到。这很重要,因为我们将在未来使用数据库包和 Web 框架中的特性,我们需要导入这些特性以便包结构体可以使用。有了我们的导入,我们就可以在 main
函数中使用以下代码利用我们的特性和工厂:
fn main() {
let to_do_items = to_do_factory("washing",
TaskStatus::DONE);
match to_do_items {
ItemTypes::Done(item) => {
item.get(&item.super_struct.title);
item.delete(&item.super_struct.title);
},
ItemTypes::Pending(item) => {
item.get(&item.super_struct.title);
item.set_to_done(&item.super_struct.title);
}
}
}
运行前面的代码会得到以下输出:
washing is being fetched
washing is being deleted
我们在这里所做的是构建了自己的模块,它包含一个入口点。然后我们将其导入到 main
函数中并运行它。现在,基本结构已经构建并工作,但我们需要通过传递变量和写入文件来使模块与环境交互,使其变得有用。
与环境的交互
要与环境交互,我们必须管理两件事。首先,我们需要加载、保存和编辑待办事项的状态。其次,我们还必须接受用户输入以编辑和显示数据。我们的程序可以通过为每个过程运行以下步骤来实现这一点:
-
从用户那里收集参数。
-
定义一个命令(
get
、edit
、delete
和create
)并从传递到应用程序的命令中定义待办事项的标题。 -
加载一个存储程序之前运行中待办事项的 JSON 文件。
-
根据传递给程序的命令运行
get
、edit
、delete
或create
函数,并在最后将状态的结果保存到 JSON 文件中。
我们可以通过最初使用 serde
包加载我们的状态来使这个四步过程成为可能。
读取和写入 JSON 文件
我们现在处于将数据以 JSON 文件的形式持久化的阶段。我们将在 第六章 使用 PostgreSQL 进行数据持久化 中将其升级为数据库。但就目前而言,我们将介绍我们的第一个依赖项,即我们的 Web 应用程序中的 serde_json
。这个 serde_json
包处理 Rust 数据到 JSON 数据以及相反的转换。我们将在下一章中使用 serde_json
处理 HTTP 请求。我们可以在 Cargo.toml
文件中使用以下代码安装我们的包:
[dependencies]
serde_json="1.0.59"
由于我们将在未来升级我们的存储选项,因此将读取和写入我们的 JSON 文件的操作与应用程序的其余部分分开是有意义的。我们不希望在将数据库升级拉出来时进行大量的调试和重构。我们将保持简单,因为在读取和写入 JSON 文件时没有模式或迁移需要管理。考虑到这一点,我们只需要 read
和 write
函数。由于我们的模块小而简单,我们可以将模块放在 main.rs
文件旁边的文件中。首先,我们需要使用以下代码在我们的 src/state.rs
文件中导入所需的项:
use std::fs::File;
use std::fs;
use std::io::Read;
use serde_json::Map;
use serde_json::value::Value;
use serde_json::json;
如我们所见,我们需要标准库和一系列结构体来读取数据,如 图 2.5 中所示:
图 2.5 – 读取 JSON 文件的步骤
我们可以使用以下代码执行 图 2.5 中的步骤:
pub fn read_file(file_name: &str) -> Map<String, Value> {
let mut file = File::open(file_name.to_string()).unwrap();
let mut data = String::new();
file.read_to_string(&mut data).unwrap();
let json: Value = serde_json::from_str(&data).unwrap();
let state: Map<String, Value> = json.as_object()
.unwrap().clone();
return state
}
在前面的代码中,我们可以看到我们直接解包了文件的开头。这是因为如果我们无法读取文件,就没有继续程序的必要,因此我们直接解包了文件读取。我们还必须注意,字符串必须是可变的,因为我们将要在这个字符串中填充 JSON 数据。此外,我们使用 serde_json
包来处理 JSON 数据并将其配置为映射。现在,我们可以通过这个 Map
变量在整个程序中访问我们的待办事项。现在,我们需要写入我们的数据,这可以通过以下代码在同一文件中完成:
pub fn write_to_file(file_name: &str,
state: &mut Map<String, Value>) {
let new_data = json!(state);
fs::write(
file_name.to_string(),
new_data.to_string()
).expect("Unable to write file");
}
在前面的代码中,我们接受Map
变量和文件的路径。然后我们使用serde_json
crate 中的json!
宏将Map
变量转换为 JSON。然后我们将 JSON 数据转换为字符串,并将其写入我们的 JSON 文件。正因为如此,我们现在有了读取和写入待办事项到 JSON 文件的功能。我们现在可以升级我们的main.rs
文件,构建一个简单的命令行待办事项应用程序,该程序读取和写入待办事项到 JSON 文件。我们可以通过以下代码与它交互:
mod state;
use std::env;
use state::{write_to_file, read_file};
use serde_json::value::Value;
use serde_json::{Map, json};
fn main() {
let args: Vec<String> = env::args().collect();
let status: &String = &args[1];
let title: &String = &args[2];
let mut state: Map<String, Value> =
read_file("./state.json");
println!("Before operation: {:?}", state);
state.insert(title.to_string(), json!(status));
println!("After operation: {:?}", state);
write_to_file("./state.json", &mut state);
}
在前面的代码中,我们做了以下操作:
-
从传递给程序的参数中收集状态和标题
-
从 JSON 文件中读取待办事项
-
打印出从 JSON 文件中的待办事项
-
插入了新的待办事项
-
打印出从内存中的新待办事项集合
-
将我们的新待办事项列表写入我们的 JSON 文件
我们的根路径将是Cargo.toml
文件的位置,所以我们定义一个名为state.json
的空 JSON 文件,位于Cargo.toml
文件旁边。为了与之交互,我们可以传递以下命令:
cargo run pending washing
前面的命令将产生以下输出:
Before operation: {}
After operation: {"washing": String("pending")}
在前面的输出中,我们可以看到已经插入了washing
。检查我们的 JSON 文件也会显示washing
已经被写入文件。你可能已经注意到,我们已经删除了关于我们的to_do
模块的所有提及,包括我们构建的所有结构和特性。我们没有忘记它们。相反,我们只是在尝试将to_do
模块与state
模块融合之前,测试我们的 JSON 文件交互是否正常。我们将在下一节通过修改to_do
结构体中实现的特性来融合to_do
和state
模块。
回顾特性
现在我们已经定义了管理我们的待办事项在 JSON 文件中的状态的模块,我们有了关于我们的特性函数如何处理数据和与我们的 JSON 文件交互的想法。首先,我们可以更新我们最简单的特性,即src/to_do/traits/get.rs
文件中的Get
特性。在这里,我们只是从 JSON 映射中获取待办事项并打印出来。我们可以通过简单地将 JSON 映射传递到我们的get
函数中,使用待办事项标题从映射中获取待办事项状态,并将它打印到控制台来实现这一点,以下代码如下:
use serde_json::Map;
use serde_json::value::Value;
pub trait Get {
fn get(&self, title: &String, state: &Map<String, Value>) {
let item: Option<&Value> = state.get(title);
match item {
Some(result) => {
println!("\n\nItem: {}", title);
println!("Status: {}\n\n", result);
},
None => println!("item: {} was not found",
title)
}
}
}
在前面的代码中,我们可以看到我们对 JSON Map
执行了一个get
函数,匹配结果并打印出我们提取的内容。这意味着任何实现了Get
特性的待办事项现在都可以从我们的状态中提取待办事项并打印出来。
我们现在可以进入下一个更复杂的步骤,即src/to_do/traits/create.rs
文件中的Create
特性。这比我们的Get
特性稍微复杂一些,因为我们通过插入新的待办事项来编辑状态,然后将更新后的状态写入我们的 JSON。我们可以通过以下代码执行这些步骤:
use serde_json::Map;
use serde_json::value::Value;
use serde_json::json;
use crate::state::write_to_file;
pub trait Create {
fn create(&self, title: &String, status: &String,
state: &mut Map<String, Value>) {
state.insert(title.to_string(), json!(status));
write_to_file("./state.json", state);
println!("\n\n{} is being created\n\n", title);
}
}
在前面的代码中,我们可以看到我们使用了来自state
模块的write_to_file
函数来将我们的状态保存到 JSON 文件中。我们可以将create
函数作为模板,了解在删除待办事项时我们需要做什么。删除本质上是我们create
函数操作的逆操作。如果你想在继续之前尝试编写delete
函数,可以在src/to_do/traits/delete.rs
文件中完成。你的函数可能看起来不同;然而,它应该与以下代码运行在相同的方向上:
use serde_json::Map;
use serde_json::value::Value;
use crate::state::write_to_file;
pub trait Delete {
fn delete(&self, title: &String,
state: &mut Map<String, Value>) {
state.remove(title);
write_to_file("./state.json", state);
println!("\n\n{} is being deleted\n\n", title);
}
}
在前面的代码中,我们只是在我们的 JSON Map
上使用remove
函数,将更新的状态写入我们的 JSON 文件。我们接近本节的结尾。我们现在需要做的就是构建我们的Edit
特性和src/to_do/traits/edit.rs
文件中的edit
函数。我们有两个函数。一个将待办事项的状态设置为DONE
。另一个函数将待办事项的状态设置为PENDING
。这些将通过更新状态然后将更新的状态写入 JSON 文件来实现。在你继续阅读之前,你可以尝试自己编写这个函数。希望你的代码看起来像以下代码:
use serde_json::Map;
use serde_json::value::Value;
use serde_json::json;
use crate::state::write_to_file;
use super::super::enums::TaskStatus;
pub trait Edit {
fn set_to_done(&self, title: &String,
state: &mut Map<String, Value>) {
state.insert(title.to_string(),
json!(TaskStatus::DONE.stringify()));
write_to_file("./state.json", state);
println!("\n\n{} is being set to done\n\n", title);
}
fn set_to_pending(&self, title: &String,
state: &mut Map<String, Value>) {
state.insert(title.to_string(),
json!(TaskStatus::PENDING.stringify()));
write_to_file("./state.json", state);
println!("\n\n{} is being set to pending\n\n", title);
}
}
现在我们的特性可以不通过 JSON 文件进行交互,执行我们最初希望它们执行的过程。没有什么阻止我们直接在main.rs
文件中利用这些特性,就像我们最初定义这些特性时那样。然而,这并不具有可扩展性。这是因为我们实际上将构建一个具有多个视图和 API 端点的 Web 应用程序。因此,我们将与这些特性和存储过程在多个不同的文件中进行交互。因此,我们必须想出一个方法,以标准化的方式与这些特性进行交互,而无需重复代码,这将在下一节中完成。
处理特性和结构
为了使我们的代码能够与一个简单的接口进行交互,使我们能够以最小的痛苦进行更新,减少重复代码并因此减少错误,我们需要一个处理层,如图 2.6所示。6*:
图 2.6 – 通过处理模块的特性和结构流程
在图 2.6中,我们可以看到我们的结构体以一种松散的方式绑定到特质上,并且数据流向和从 JSON 流向特质。我们还可以看到我们有一个processes
模块的入口点,它将命令引导到正确的特质,然后特质将根据需要访问 JSON 文件。鉴于我们已经定义了我们的特质,我们只需要构建我们的processes
模块,将其连接到特质,然后连接到我们的main.rs
文件。我们将整个processes
模块构建在一个src/processes.rs
文件中。我们将其保持为单个文件,因为我们将在介绍数据库时将其删除。如果我们知道我们将来会删除它,就没有必要承担过多的技术债务。现在,我们可以通过以下代码最初导入我们需要的所有结构体和特质来开始构建我们的processes
模块:
use serde_json::Map;
use serde_json::value::Value;
use super::to_do::ItemTypes;
use super::to_do::structs::done::Done;
use super::to_do::structs::pending::Pending;
use super::to_do::traits::get::Get;
use super::to_do::traits::create::Create;
use super::to_do::traits::delete::Delete;
use super::to_do::traits::edit::Edit;
我们现在可以开始构建非公开函数。我们可以从处理我们的Pending
结构体开始。我们知道我们可以获取、创建或编辑待办事项,如下面的代码所示:
fn process_pending(item: Pending, command: String,
state: &Map<String, Value>) {
let mut state = state.clone();
match command.as_str() {
"get" => item.get(&item.super_struct.title, &state),
"create" => item.create(&item.super_struct.title,
&item.super_struct.status.stringify(), &mut state),
"edit" => item.set_to_done(&item.super_struct.title,
&mut state),
_ => println!("command: {} not supported", command)
}
}
在前面的代码中,我们可以看到我们已经处理了待办事项的Pending
结构体、命令和当前状态。然后我们匹配命令并执行与该命令关联的特质。如果传入的命令既不是get
、create
或edit
,我们不支持它,会抛出一个错误告诉用户不支持哪个命令。这是可扩展的。例如,如果我们允许Pending
结构体从 JSON 文件中删除待办事项,我们只需为Pending
结构体实现Delete
特质,然后将delete
命令添加到我们的process_pending
函数中。这总共只需要两行代码,并且这个更改将影响整个应用程序。如果我们删除一个命令,也会发生这种情况。现在,我们有了对Pending
结构体的灵活实现。考虑到这一点,你可以在继续阅读之前选择编写我们的process_done
函数。如果你选择了这样做,希望它看起来像以下代码:
fn process_done(item: Done, command: String,
state: &Map<String, Value>) {
let mut state = state.clone();
match command.as_str() {
"get" => item.get(&item.super_struct.title,
&state),
"delete" => item.delete(&item.super_struct.title,
&mut state),
"edit" =>
item.set_to_pending(&item.super_struct.title,
&mut state),
_ => println!("command: {} not supported", command)
}
}
我们现在可以处理我们的结构体。在设计我们的模块时,结构体的可扩展性就在这里。就像命令一样,我们希望像处理特质一样堆叠我们的结构体。这就是我们的入口点发挥作用的地方,如图 2.7所示:
图 2.7 – 流程的可扩展性
从图 2.7中我们可以看到,我们可以通过增加入口点的路由来扩展对结构体的访问。为了更好地理解这一点,我们应该定义我们的入口点,这次是一个公开函数,如下面的代码所示:
pub fn process_input(item: ItemTypes, command: String,
state: &Map<String, Value>) {
match item {
ItemTypes::Pending(item) => process_pending(item,
command, state),
ItemTypes::Done(item) => process_done(item,
command, state)
}
}
在前面的代码中,我们可以看到我们通过ItemTypes
枚举路由到正确的结构体。我们通过将新的结构体添加到ItemTypes
枚举中,编写一个在processes
模块中处理该结构体的新函数,并将所需的特性应用到结构体上,我们的模块可以处理更多的结构体。现在我们的processes
模块已经完全完成,我们可以重写我们的main.rs
文件来利用它。首先,我们使用以下代码导入所需的模块:
mod state;
mod to_do;
mod processes;
use std::env;
use serde_json::value::Value;
use serde_json::Map;
use state::read_file;
use to_do::to_do_factory;
use to_do::enums::TaskStatus;
use processes::process_input;
通过这些导入,我们可以看到我们将从 JSON 文件中读取数据,使用to_do_factory
从环境收集的输入创建结构体,并将其传递到我们的processes
模块以更新 JSON 文件。这是一个很好的时机停止阅读并尝试自己编写这个过程。记住,你必须从 JSON 文件中获取数据并检查待办事项的标题是否已经存储在 JSON 文件中。如果我们无法在 JSON 文件的数据中找到标题,那么我们知道它将处于待办状态,因为我们不能创建已完成任务。如果你选择这样做,你的代码可能看起来像以下这样:
fn main() {
let args: Vec<String> = env::args().collect();
let command: &String = &args[1];
let title: &String = &args[2];
let state: Map<String, Value> = read_file("./state.json");
let status: String;
match &state.get(*&title) {
Some(result) => {
status = result.to_string().replace('\"', "");
}
None=> {
status = "pending".to_owned();
}
}
let item = to_do_factory(title,
TaskStatus::from_string(
status.to_uppercase()));
process_input(item, command.to_string(), &state);
}
在我们运行任何东西之前,你可能已经意识到我们使用from_string
函数创建了一个TaskStatus
实例。我们还没有构建from_string
函数。在这个时候,你应该能够在impl TaskStatus
块中自己构建它。如果你尝试构建from_string
函数,它应该看起来像以下代码在src/to_do/enums.rs
文件中:
impl TaskStatus {
. . .
pub fn from_string(input_string: String) -> Self {
match input_string.as_str() {
"DONE" => TaskStatus::DONE,
"PENDING" => TaskStatus::PENDING,
_ => panic!("input {} not supported",
input_string)
}
}
}
如果你已经成功利用我们创建的接口使程序运行,那么做得好。现在我们可以轻松地在main
函数中编排一系列过程。我们可以使用以下命令与我们的程序交互:
cargo run create washing
上述命令在我们的 JSON 文件中创建了一个名为washing
的待办事项,状态为待办。我们支持所有其他特性,我们也可以在命令行中执行它们。我们现在已经构建了一个基本的命令行应用程序,该程序将待办事项存储在 JSON 文件中。然而,它不仅仅是一个基本的命令行应用程序。我们已经对模块进行了结构化,使它们具有可扩展性和灵活性。
摘要
在本章中,我们本质上构建了一个程序,该程序接受一些命令行输入,与文件交互,并根据命令和文件中的数据编辑它。数据很简单:标题和状态。我们本可以在main
函数中使用多个match
语句和if
、else if
和else
块来完成所有这些。然而,这并不具有可扩展性。相反,我们构建了继承其他结构体的结构体,然后实现了特性。然后我们将这些结构体的构建包装到一个工厂中,使得其他文件可以单行代码使用所有这些功能。
然后,我们构建了一个处理接口,以便可以处理命令输入、状态和结构体,使我们能够通过几行代码堆叠额外的功能并改变处理流程。我们的主函数必须只关注收集命令行参数和协调何时调用模块接口。我们现在已经探索并利用了 Rust 如何管理模块,为我们提供了构建解决现实世界问题的程序的构建块,这些程序可以添加功能而不会受到技术债务和膨胀的 main
函数的伤害。现在我们能够做到这一点,我们准备开始构建可扩展的 Web 应用程序,这些应用程序可以增长。在下一章中,我们将学习 Actix Web 框架 以启动一个基本的 Web 服务器。
问题
-
当将
--release
参数添加到构建和运行时,Cargo 中的--release
参数做什么? -
我们如何使一个文件在模块内部和外部都可用?
-
具有单作用域特性的优势是什么?
-
我们需要采取哪些步骤才能添加一个只允许
Get
和Edit
功能的OnHold
待办事项? -
工厂函数有哪些好处?
-
我们如何有效地根据某些过程映射一系列过程?
答案
-
在构建过程中,
--release
参数以优化方式编译程序,而不是调试编译。在运行过程中,--release
参数指向优化后的二进制文件,而不是调试二进制文件。优化后的二进制文件编译时间较长,但运行速度更快。 -
要使文件在模块中的其他文件中可用,我们必须在模块根目录下的
mod.rs
文件中将该文件定义为模块。我们在定义前添加mod
以使其在模块外部可用。 -
单作用域特性在定义结构体时提供了最大灵活性。一个很好的例子就是添加一个
OnHold
待办事项。有了这个项目,我们可能只允许它具有编辑特性,我们可以通过实现单作用域的Edit
特性来实现。如果我们有一个包含所有功能的特性,这将是不可能的。 -
在继承自基本结构体并实现
Get
和Edit
特性的结构体自己的文件中定义一个结构体。在工厂文件中为枚举添加一个hold
类型。在指向处理OnHold
项的新函数的处理流程中的入口点match
语句中添加另一行。 -
工厂函数标准化了结构体的构建。它还减少了在模块外部仅用一行代码构建一系列结构体的可能性。这阻止了其他文件膨胀,并且不需要开发者四处寻找以利用它。
-
我们使用导致其他
match
语句的match
语句。这使我们能够编写树状效果,并且没有任何阻止我们在链的后面连接分支。这已在 图 2**.7 中演示。
第二部分:处理数据和管理工作台
现在我们能够在 Rust 中构建应用程序,我们需要能够处理 HTTP 请求。在本部分的结尾,你将了解如何处理 HTTP 请求并将它们路由。你还将能够从请求体和头部中提取数据。你还将构建应用程序的结构,以便路由可以扩展,并在视图加载之前实现中间件来处理和路由 HTTP 请求。最后,你将了解如何通过直接从服务器提供 HTML、CSS 和 JavaScript 来在浏览器中显示内容。我们还将探索一个基本的 React 应用程序,并将其包裹在 Electron 中,以创建一个桌面应用程序来与我们的 Rust 服务器通信。到这一点,你将学会运行一个基本应用程序所需的一切,即使没有适当的数据库或身份验证。
本部分包括以下章节:
-
第三章, 处理 HTTP 请求
-
第四章, 处理 HTTP 请求
-
第五章, 在浏览器中显示内容
第三章:处理 HTTP 请求
到目前为止,我们已经以灵活、可扩展和可重用的方式构建了待办事项模块。然而,在网页编程方面,这只能带我们走这么远。我们希望待办事项模块能够快速地触达多人,而无需用户在自己的电脑上安装 Rust。我们可以通过一个网络框架来实现这一点。Rust 有很多可以提供的。最初,我们将使用 Actix Web 框架构建我们的主要服务器。
为了实现这一点,我们将以模块化的方式构建服务器的视图;我们可以轻松地将待办事项模块插入到我们的网络应用程序中。必须注意的是,Actix Web 框架使用 async
函数来定义视图。正因为如此,我们还将介绍异步编程,以便更好地理解 Actix Web 框架的工作原理。
在本章中,我们将涵盖以下主题:
-
介绍 Actix Web 框架
-
启动基本的 Actix Web 服务器
-
理解闭包
-
理解异步编程
-
通过网络编程理解
async
和await
-
使用 Actix Web 框架管理视图
技术要求
随着我们转向使用 Rust 构建网络应用,我们将不得不开始依赖第三方包来为我们做一些繁重的工作。Rust 通过一个名为 Cargo 的包管理器来管理依赖项。要使用 Cargo,我们不得不从以下网址安装 Rust 到我们的电脑上:www.rust-lang.org/tools/install
。
此安装提供了 Rust 编程语言和 Cargo。你可以在 GitHub 上找到所有代码文件,网址为 github.com/PacktPublishing/Rust-Web-Programming-2nd-Edition/tree/main/chapter03
。
介绍 Actix Web 框架
在撰写本文时,Actix Web 是 GitHub 页面上活动最频繁的 Rust 网络框架,如我们所见。你可能会想跳入另一个看起来更直观的框架,比如 Rocket,或者一个更快更轻量级的框架,比如 Hyper。我们将在本书的后续章节中覆盖这些框架;然而,我们必须记住,我们首先试图在 Rust 和网络编程中弄清楚我们的思路。考虑到我们是 Rust 和网络编程的新手,Actix Web 是一个很好的起点。它不是太底层,以至于我们只会陷入仅仅尝试让服务器处理一系列视图、数据库连接和认证。它也很受欢迎、稳定,并且有大量的文档。这将有助于在尝试超越本书并开发自己的网络应用程序时获得愉快的编程体验。建议你在转向其他网络框架之前先熟悉 Actix Web。这并不是说 Actix Web 是最好的,而其他所有框架都很糟糕;这只是为了方便一个平稳的学习和开发体验。考虑到这一点,我们现在可以继续到第一部分,在那里我们设置了一个基本的网络服务器。
启动一个基本的 Actix Web 服务器
使用 Cargo 构建很简单。我们只需要导航到我们想要构建项目的目录,并运行以下命令:
cargo new web_app
前面的命令构建了一个基本的 Cargo Rust 项目。当我们探索这个应用程序时,我们得到以下结构:
└── web_app
├── Cargo.toml
└── src
└── main.rs
现在,我们可以在 Cargo.toml
文件中使用以下代码定义我们的 Actix Web 依赖项:
[dependencies]
actix-web = "4.0.1"
由于前面的代码,我们现在可以继续构建网络应用程序。现在,我们将所有内容都放在我们的 src/main.rs
文件中,以下代码:
use actix_web::{web, App, HttpServer, Responder,
HttpRequest};
async fn greet(req: HttpRequest) -> impl Responder {
let name =
req.match_info().get("name").unwrap_or("World");
format!("Hello {}!", name)
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
HttpServer::new(|| {
App::new()
.route("/", web::get().to(greet))
.route("/{name}", web::get().to(greet))
.route("/say/hello", web::get().to(||
async { "Hello Again!" }))
})
.bind("127.0.0.1:8080")?
.run()
.await
}
在前面的代码中,我们可以看到我们导入了来自 actix_web
crate 所需的结构体和特性。我们可以看到我们使用了多种不同的方式来定义一个视图。我们通过构建一个函数来定义一个视图。这个函数接收一个 HttpRequest
结构体。然后从请求中获取 name
,然后返回一个可以实现来自 actix_web
crate 的 Responder
特性的变量。Responder
特性将我们的类型转换为 HTTP 响应。我们将我们为应用程序服务器创建的 greet
函数分配为路由视图,使用 .route("/", web::get().to(greet))
命令。我们还可以看到,我们可以使用 .route("/{name}", web::get().to(greet))
命令将 URL 中的名称传递给我们的 greet
函数。最后,我们将闭包传递给最终的路线。根据我们的配置,让我们运行以下命令:
cargo run
我们将得到以下打印输出:
Finished dev [unoptimized + debuginfo] target(s) in 0.21s
Running `target/debug/web_app`
在前面的输出中,我们可以看到,目前还没有日志记录。这是预期的,我们将在稍后配置日志。现在我们的服务器正在运行,对于以下每个 URL 输入,我们应该在浏览器中期望得到相应的输出:
-
http://127.0.0.1:8080/
-
Hello World!
-
http://127.0.0.1:8080/maxwell
-
Hello maxwell!
-
http://127.0.0.1:8080/say/hello
-
Hello Again!
在src/main.rs
文件中的前面代码中,我们可以看到一些我们之前没有遇到的新语法。我们用#[actix_web::main]
宏装饰了main
函数。这标志着我们的async
main
函数是 Actix Web 系统的入口点。有了这个,我们可以看到我们的函数是async
的,我们正在使用闭包来构建我们的服务器。在接下来的几个部分中,我们将探讨这两个概念。在下一个部分中,我们将调查闭包,以真正理解正在发生的事情。
理解闭包
闭包本质上来说是函数,但它们也是匿名的,这意味着它们没有名字。这意味着闭包可以被传递到函数和结构体中。然而,在我们深入探讨传递闭包之前,让我们通过在空白 Rust 程序(如果你更喜欢,可以使用 Rust playground)中定义一个基本的闭包来探索闭包,以下代码:
fn main() {
let test_closure = |string_input| {
println!("{}", string_input);
};
test_closure("test");
}
运行前面的代码将给出以下输出:
test
在前面的输出中,我们可以看到我们的闭包表现得像函数一样。然而,我们使用管道而不是花括号来定义输入,而不是使用花括号。
你可能已经注意到在前面的闭包中,我们没有为string_input
参数定义数据类型;然而,代码仍然可以运行。这与需要定义参数数据类型的函数不同。这是因为函数是暴露给用户的显式接口的一部分。如果代码可以访问函数,函数可以在代码的任何地方被调用。另一方面,闭包具有短暂的生存期,并且只与其所在的作用域相关。正因为如此,编译器可以从作用域中闭包的使用推断出传递给闭包的类型。因为我们调用闭包时传递了&str
,编译器知道string_input
的类型是&str
。虽然这很方便,但我们需要知道闭包不是泛型的。这意味着闭包有一个具体类型。例如,在定义我们的闭包之后,让我们尝试运行以下代码:
test_closure("test");
test_closure(23);
我们将得到以下错误:
7 | test_closure(23);
| ^^ expected `&str`, found integer
错误发生是因为我们对闭包的第一个调用告诉编译器我们期望&str
,所以第二个调用打破了编译过程。
作用域不仅影响闭包。闭包遵循与变量相同的范围规则。例如,假设我们打算尝试运行以下代码:
fn main() {
{
let test_closure = |string_input| {
println!("{}", string_input);
};
}
test_closure("test");
}
它将拒绝编译,因为当我们尝试调用闭包时,它不在调用的作用域内。考虑到这一点,你可能会正确地假设其他作用域规则也适用于闭包。例如,如果我们尝试运行以下代码,你认为会发生什么?
fn main() {
let another_str = "case";
let test_closure = |string_input| {
println!("{} {}", string_input, another_str);
};
test_closure("test");
}
如果你认为我们会得到以下输出,你是正确的:
test case
与函数不同,闭包可以访问它们自己的作用域中的变量。所以,为了以我们能够理解的方式简单描述闭包,它们有点像我们在作用域中调用以执行计算的动态变量。
我们可以通过使用move
来获取闭包中使用的外部变量的所有权,就像以下代码所示:
let test_closure = move |string_input| {
println!("{} {}", string_input, another_str);
};
由于在此处定义的闭包中使用了move
,因此another_str
变量在test_closure
声明后不能使用,因为test_closure
获取了another_str
的所有权。
我们还可以将闭包传递给函数;然而,必须注意的是,我们也可以将函数传递给其他函数。我们可以通过以下代码实现将函数传递给其他函数:
fn add_doubles(closure: fn(i32) -> i32,
one: i32, two: i32) -> i32 {
return closure(one) + closure(two)
}
fn main() {
let closure = |int_input| {
return int_input * 2
};
let outcome = add_doubles(closure, 2, 3);
println!("{}", outcome);
}
在前面的代码中,我们可以看到我们定义了一个闭包,该闭包接受一个整数并将其加倍,然后我们将其传递给add_doubles
函数,使用fn(i32)-> i32
的表示法,这被称为函数指针。当涉及到闭包时,我们可以实现以下特质之一:
-
Fn
:不可变借用变量 -
FnMut
:可变借用变量 -
FnOnce
:获取变量的所有权,因此只能调用一次
我们可以将实现了前面特质的闭包传递给我们的add_doubles
函数,如下所示:
fn add_doubles(closure: Box<dyn Fn(i32) -> i32>,
one: i32, two: i32) -> i32 {
return closure(one) + closure(two)
}
fn main() {
let one = 2;
let closure = move |int_input| {
return int_input * one
};
let outcome = add_doubles(Box::new(closure), 2, 3);
println!("{}", outcome);
}
在这里,我们可以看到closure
函数参数具有Box<dyn Fn(i32) -> i32>
签名。这意味着add_doubles
函数正在接受实现了Fn
特质且接受i32
作为输入并返回i32
的闭包。Box
结构体是一个智能指针,我们将闭包放在堆上,因为我们不知道闭包的大小在编译时。您还可以看到我们在定义闭包时使用了move
。这是因为我们正在使用one
变量,它位于闭包外部。one
变量可能存活时间不够长;因此,闭包通过我们在定义闭包时使用move
来获取其所有权。
考虑到我们已经讨论过的闭包,我们可以再次查看我们的服务器应用程序中的main
函数,如下所示:
#[actix_web::main]
async fn main() -> std::io::Result<()> {
HttpServer::new(|| {
App::new()
.route("/", web::get().to(greet))
.route("/{name}", web::get().to(greet))
.route("/say/hello", web::get().to(
|| async { "Hello Again!" }))
})
.bind("127.0.0.1:8080")?
.run()
.await
}
在前面的代码中,我们可以看到我们在使用HttpServer::new
函数构建HttpServer
之后运行它。考虑到我们现在所知道的内容,我们可以看到我们传递了一个返回App
结构的闭包。基于我们对闭包的了解,我们可以对我们所做的事情更有信心。如果我们返回App
结构,我们基本上可以在闭包内做我们想做的事情。有了这个想法,我们可以通过以下代码获取更多关于此过程的信息:
#[actix_web::main]
async fn main() -> std::io::Result<()> {
HttpServer::new(|| {
println!("http server factory is firing");
App::new()
.route("/", web::get().to(greet))
.route("/{name}", web::get().to(greet))
.route("/say/hello", web::get().to(
|| async { "Hello Again!" }))
})
.bind("127.0.0.1:8080")?
.workers(3)
.run()
.await
}
在前面的代码中,我们可以看到我们添加了一个print
语句来告诉我们闭包正在触发。我们还添加了另一个名为workers
的函数。这意味着我们可以定义用于创建我们的服务器的工作者数量。我们还打印出服务器工厂在我们的闭包中正在触发。运行前面的代码会给我们以下输出:
Finished dev [unoptimized + debuginfo] target(s) in
2.45s
Running `target/debug/web_app`
http server factory is firing
http server factory is firing
http server factory is firing
前面的结果告诉我们闭包被触发了三次。改变工作者的数量显示,这与闭包被触发的次数有直接关系。如果省略workers
函数,那么闭包的触发将与系统核心的数量成比例。我们将在下一节中探讨这些工作者如何适应服务器进程。
现在我们已经了解了构建App
结构体周围的细微差别,是时候看看程序结构的主要变化了,那就是异步编程。
理解异步编程
到目前为止,我们一直以顺序方式编写代码。这对于标准脚本来说已经足够好了。然而,在 Web 开发中,异步编程很重要,因为服务器有多个请求,API 调用引入了空闲时间。在其他一些语言中,如 Python,我们可以构建 Web 服务器而不需要接触任何异步概念。虽然在这些 Web 框架中使用了异步概念,但实现是在幕后定义的。这对于 Rust 框架 Rocket 也是正确的。然而,正如我们所看到的,它直接在 Actix Web 中实现。
当涉及到利用异步代码时,有两个主要概念我们必须理解:
-
进程:进程是一个正在执行的程序。它有自己的内存栈、变量的寄存器和代码。
-
main
程序。然而,线程不共享栈。
这在以下经典图中得到了体现:
图 3.1 – 线程和进程之间的关系 [来源:Cburnett (2007) (https://commons.wikimedia.org/wiki/File:Multithreaded_process.svg),CC BY-SA 3.0 (https://creativecommons.org/licenses/by-sa/3.0/deed.en)]
现在我们已经从高层次上理解了线程是什么以及它们与我们的代码有什么关系,我们可以通过一个玩具示例来了解如何在代码中利用线程,并亲眼看到这些线程的效果。一个经典的例子是构建一个简单的函数,它只是休眠,阻止时间流逝。这可以模拟一个耗时的函数,如网络请求。我们可以用以下代码按顺序运行它:
use std::{thread, time};
fn do_something(number: i8) -> i8 {
println!("number {} is running", number);
let two_seconds = time::Duration::new(2, 0);
thread::sleep(two_seconds);
return 2
}
fn main() {
let now = time::Instant::now();
let one: i8 = do_something(1);
let two: i8 = do_something(2);
let three: i8 = do_something(3);
println!("time elapsed {:?}", now.elapsed());
println!("result {}", one + two + three);
}
运行前面的代码会给我们以下输出:
number 1 is running
number 2 is running
number 3 is running
time elapsed 6.0109845s
result 6
在前面的输出中,我们可以看到我们的耗时函数按照我们预期的顺序运行。整个程序运行也正好超过 6 秒,这是有道理的,因为我们正在运行三个耗时的函数,每个函数休眠 2 秒。我们的耗时函数还返回值2
。当我们把所有三个耗时函数的结果相加时,我们将得到一个值为6
的结果,这正是我们所得到的。我们通过同时启动三个线程并将它们完成后再继续,将整个程序的速度加快到大约 2 秒。在继续之前等待线程完成的过程称为连接。因此,在我们开始启动线程之前,我们必须使用以下代码导入join
处理程序:
use std::thread::JoinHandle;
我们现在可以在main
函数中使用以下代码启动线程:
let now = time::Instant::now();
let thread_one: JoinHandle<i8> = thread::spawn(
|| do_something(1));
let thread_two: JoinHandle<i8> = thread::spawn(
|| do_something(2));
let thread_three: JoinHandle<i8> = thread::spawn(
|| do_something(3));
let result_one = thread_one.join();
let result_two = thread_two.join();
let result_three = thread_three.join();
println!("time elapsed {:?}", now.elapsed());
println!("result {}", result_one.unwrap() +
result_two.unwrap() + result_three.unwrap());
运行前面的代码给出了以下输出:
number 1 is running
number 3 is running
number 2 is running
time elapsed 2.002991041s
result 6
如我们所见,整个过程运行时间正好超过 2 秒。这是因为所有三个线程都是并发运行的。注意,线程三在线程二之前启动。如果你得到1
、2
和3
的序列,请不要担心。线程的完成顺序是不确定的。调度是确定的;然而,在底层有成千上万个事件需要 CPU 进行处理。因此,每个线程得到的精确时间片永远不会相同。这些微小的变化会累积起来。正因为如此,我们无法保证线程将以确定的顺序完成。
回顾我们启动线程的方式,我们可以看到我们向线程传递了一个闭包。如果我们尝试只通过线程传递do_something
函数,我们会得到一个错误,抱怨编译器期望一个FnOnce<()>
闭包,而找到的是i8
。这是因为标准闭包实现了FnOnce<()>
公共特质,而我们的do_something
函数只是简单地返回i8
。当FnOnce<()>
被实现时,闭包只能被调用一次。这意味着当我们创建一个线程时,我们可以确保闭包只能被调用一次,然后当它返回时,线程结束。由于我们的do_something
函数是闭包的最后一行,所以返回i8
。然而,必须注意的是,尽管实现了FnOnce<()>
特质,并不意味着我们不能多次调用它。这个特质只有在上下文需要时才会被调用。这意味着如果我们要在线程上下文之外调用闭包,我们可以多次调用它。
还要注意,我们直接解包了我们的结果。根据我们所知,我们可以推断出 JoinHandle
结构体上的 join
函数返回 Result
,我们也知道它可以返回 Err
或 Ok
。我们知道直接解包结果是安全的,因为我们只是在睡眠然后返回一个整数。我们还打印出了结果,这些结果确实是整数。然而,我们的错误并不是你所期望的。我们得到的完整 Result
类型是 Result<i8, Box<dyn Any + Send>>
。我们已经知道 Box
是什么;然而,dyn Any + Send
看起来很新。dyn
是一个关键字,我们用它来指示正在使用的特质的类型。Any
和 Send
是必须实现的两个特质。Any
特质用于动态类型,意味着数据类型可以是任何类型。Send
特质意味着它可以从一个线程安全地移动到另一个线程。Send
特质还意味着它可以从一个线程安全地复制到另一个线程。所以,我们要发送的内容已经实现了 Copy
特质,因为我们发送的内容可以在线程之间传递。现在我们理解了这一点,我们只需通过匹配 Result
的结果来处理线程的结果,然后将错误向下转换为字符串以获取错误信息,以下代码展示了如何操作:
match thread_result {
Ok(result) => {
println!("the result for {} is {}",
result, name);
}
Err(result) => {
if let Some(string) = result.downcast_ref::<String>() {
println!("the error for {} is: {}", name, string);
} else {
println!("there error for {} does not have a
message", name);
}
}
}
上述代码使我们能够优雅地管理线程的结果。现在,没有任何阻止你记录线程失败或根据先前线程的结果启动新线程的事情。因此,我们可以看到 Result
结构体是多么强大。我们还可以对线程做更多的事情,例如给它们命名或通过通道在它们之间传递数据。然而,本书的重点是网络编程,而不是高级并发设计模式和概念。不过,本章末尾提供了关于该主题的进一步阅读材料。
现在我们已经了解了如何在 Rust 中启动线程,它们返回什么,以及如何处理它们。有了这些信息,我们可以继续下一节,了解 async
和 await
语法,因为这是我们将在我们的 Actix Web 服务器中使用的内容。
理解 async
和 await
async
和 await
语法管理了上一节中涵盖的相同概念;然而,也有一些细微差别。我们不是简单地生成线程,而是创建未来对象,并在需要时对其进行操作。
在计算机科学中,期货是一个未处理的计算。这意味着结果尚未可用,但当我们调用或等待时,期货将被计算的结果填充。另一种描述方式是,期货是一种表达尚未准备好的值的方式。因此,期货并不完全等同于线程。实际上,线程可以使用期货来最大化其潜力。例如,假设我们有几个网络连接。我们可以为每个网络连接分配一个单独的线程。这比顺序处理所有连接要好,因为慢速网络连接会阻止其他更快连接的处理,直到它本身被处理,从而导致整体处理时间变慢。然而,为每个网络连接启动线程并不是免费的。相反,我们可以为每个网络连接创建一个期货。这些网络连接可以在期货准备好时由线程池中的线程进行处理。因此,我们可以看到为什么期货在 Web 编程中被使用,因为存在大量的并发连接。
期货也可以被称为承诺、延迟或延期。为了探索期货,我们将创建一个新的 Cargo 项目并利用在Cargo.toml
文件中创建的期货:
[dependencies]
futures = "0.3.21"
在安装了前面的 crate 之后,我们可以在main.rs
中使用以下代码导入所需的模块:
use futures::executor::block_on;
use std::{thread, time};
我们可以通过仅使用async
语法来定义期货。block_on
函数将阻塞程序,直到我们定义的期货被执行。现在我们可以使用以下代码定义do_something
函数:
async fn do_something(number: i8) -> i8 {
println!("number {} is running", number);
let two_seconds = time::Duration::new(2, 0);
thread::sleep(two_seconds);
return 2
}
do_something
函数基本上做的是代码所说的,即打印出它是哪个数字,休眠 2 秒,然后返回一个整数。然而,如果我们直接调用它,我们将不会得到i8
。相反,直接调用do_something
函数将给我们Future<Output = i8>
。我们可以在主函数中运行我们的期货并对其进行计时,以下代码:
fn main() {
let now = time::Instant::now();
let future_one = do_something(1);
let outcome = block_on(future_one);
println!("time elapsed {:?}", now.elapsed());
println!("Here is the outcome: {}", outcome);
}
运行前面的代码将给出以下输出:
number 1 is running
time elapsed 2.00018789s
Here is the outcome: 2
这是预期的结果。然而,让我们看看如果我们调用block_on
函数之前输入一个额外的sleep
函数会发生什么:
fn main() {
let now = time::Instant::now();
let future_one = do_something(1);
let two_seconds = time::Duration::new(2, 0);
thread::sleep(two_seconds);
let outcome = block_on(future_one);
println!("time elapsed {:?}", now.elapsed());
println!("Here is the outcome: {}", outcome);
}
我们将得到以下输出:
number 1 is running
time elapsed 4.000269667s
Here is the outcome: 2
因此,我们可以看到我们的期货只有在应用了block_on
函数并使用它来执行后才会执行。
这可能有点费时,因为我们可能只想在同一个函数中稍后执行一个期货。我们可以使用async
/await
语法来实现这一点。例如,我们可以在main
函数中使用await
语法调用do_something
函数并阻塞代码,直到它完成,以下代码:
let future_two = async {
return do_something(2).await
};
let future_two = block_on(future_two);
println!("Here is the outcome: {:?}", future_two);
async
块所做的就是返回一个期货。在这个块内部,我们调用do_something
函数,通过使用await
表达式阻塞async
块,直到do_something
函数解决。然后我们对future_two
期货应用block_on
函数。
看到我们前面的代码块,这可能会显得有些过度,因为它可以用调用do_something
函数并将其传递给block_on
函数的两行代码来完成。在这种情况下,它是过度的,但它可以给我们更多的灵活性来调用未来。例如,我们可以调用do_something
函数两次,并将它们作为返回值相加,如下所示:
let future_three = async {
let outcome_one = do_something(2).await;
let outcome_two = do_something(3).await;
return outcome_one + outcome_two
};
let future_outcome = block_on(future_three);
println!("Here is the outcome: {:?}", future_outcome);
将前面的代码添加到我们的main
函数中,将给出以下输出:
number 2 is running
number 3 is running
Here is the outcome: 4
尽管前面的输出是我们预期的结果,但我们知道这些未来将按顺序运行,而这块代码的总时间将略高于 4 秒。也许我们可以通过使用join
来加快这个速度。我们已经看到join
可以通过同时运行线程来加速线程。它确实有道理认为它也可以加速我们的未来。首先,我们必须使用以下代码导入join
宏:
use futures::join
现在,我们可以利用join
来处理我们的未来,并使用以下代码来计时实现:
let future_four = async {
let outcome_one = do_something(2);
let outcome_two = do_something(3);
let results = join!(outcome_one, outcome_two);
return results.0 + results.1
};
let now = time::Instant::now();
let result = block_on(future_four);
println!("time elapsed {:?}", now.elapsed());
println!("here is the result: {:?}", result);
在前面的代码中,我们可以看到join
宏返回一个包含结果的元组,并且我们解包这个元组以得到相同的结果。然而,如果我们实际运行这段代码,我们可以看到尽管我们得到了想要的结果,但我们的未来执行并没有加速,仍然停滞在 4 秒以上。这是因为未来并没有使用async
任务来运行。我们必须使用async
任务来加速我们未来的执行。我们可以通过以下步骤实现这一点:
-
创建所需的未来。
-
将它们放入一个向量中。
-
遍历向量,为向量中的每个未来启动任务。
-
连接
async
任务并求和向量。
这可以通过以下图示来直观地表示:
图 3.2 – 同时运行多个未来的步骤
为了同时连接所有我们的未来,我们必须使用另一个 crate 通过使用async_std
crate 创建我们自己的异步join
函数。我们在Cargo.toml
文件中使用以下代码定义这个 crate:
async-std = "1.11.0"
现在我们有了async_std
crate,我们可以通过在main.rs
文件顶部导入所需的内容来执行图 3.2中概述的方法,如下所示:
use std::vec::Vec;
use async_std;
use futures::future::join_all;
在main
函数中,我们现在可以使用以下代码定义我们的未来:
let async_outcome = async {
// 1.
let mut futures_vec = Vec::new();
let future_four = do_something(4);
let future_five = do_something(5);
// 2.
futures_vec.push(future_four);
futures_vec.push(future_five);
// 3\.
let handles = futures_vec.into_iter().map(
async_std::task::spawn).collect::<Vec<_>>();
// 4.
let results = join_all(handles).await;
return results.into_iter().sum::<i8>();
};
在这里,我们可以看到我们定义了我们的未来(1),然后我们将它们添加到我们的向量(2)。然后我们使用into_iter
函数在我们的向量中遍历我们的未来。然后我们使用async_std::task::spawn
在每个未来上启动一个线程。这类似于std::task::spawn
。那么,为什么要费这么多的额外麻烦呢?我们只需遍历向量并为每个任务启动一个线程。这里的区别在于async_std::task::spawn
函数在同一线程中生成一个async
任务。因此,我们在同一线程中并发运行这两个未来!然后我们连接所有句柄,await
等待这些任务完成,然后返回所有这些线程的总和。现在我们已经定义了我们的async_outcome
未来,我们可以用以下代码运行并计时:
let now = time::Instant::now();
let result = block_on(async_outcome);
println!("time elapsed for join vec {:?}", now.elapsed());
println!("Here is the result: {:?}", result);
运行我们的附加代码将给出以下附加打印输出:
number 4 is running
number 5 is running
time elapsed for join vec 2.007713458s
Here is the result: 4
它正在工作!我们已经成功地在同一线程中同时运行了两个async
任务,导致这两个未来在 2 秒多一点的时间内执行完成!
正如我们所看到的,在 Rust 中启动线程和async
任务很简单。然而,我们必须注意,将变量传递到线程和async
任务中并不是。Rust 的借用机制确保了内存安全。当我们向线程传递数据时,我们必须采取额外的步骤。关于线程间共享数据的一般概念的进一步讨论不利于我们的 Web 项目。然而,我们可以简要地指出哪些类型允许我们共享数据:
-
std::sync::Arc
:此类型使线程能够引用外部数据:use std::sync::Arc;
use std::thread;
let names = Arc::new(vec!["dave", "chloe", "simon"]);
let reference_data = Arc::clone(&names);
let new_thread = thread::spawn(move || {
println!("{}", reference_data[1]);
});
-
std::sync::Mutex
:此类型使线程能够修改外部数据:use std::sync::Mutex;
use std::thread;
let count = Mutex::new(0);
let new_thread = thread::spawn(move || {
count.lock().unwrap() += 1;
});
在这个线程内部,我们取消引用锁的结果,解包它,并修改它。必须注意的是,共享状态只能在持有锁的情况下访问。
我们已经了解了足够的异步编程知识,可以回到我们的网络编程。并发是一个可以涵盖整本书的主题,其中之一在进一步阅读部分有引用。现在,我们必须回到探索 Rust 在 Web 开发中的应用,看看我们对 Rust 异步编程的了解如何影响我们对 Actix Web 服务器的理解。
探索 Web 编程中的异步和 await
了解我们所了解的异步编程,我们现在可以以不同的方式看待我们的 Web 应用程序中的main
函数,如下所示:
#[actix_web::main]
async fn main() -> std::io::Result<()> {
HttpServer::new( || {
App::new()
.route("/", web::get().to(greet))
.route("/{name}", web::get().to(greet))
.route("/say/hello", web::get().to(||
async { "Hello Again!" }))
})
.bind("127.0.0.1:8080")?
.workers(3)
.run()
.await
}
我们知道我们的greet
函数是一个async
函数,因此是一个未来。我们还可以看到我们传递给/say/hello
视图的闭包也使用了async
语法。我们还可以看到HttpServer::new
函数在async fn main()
中使用了await
语法。因此,我们可以推断出我们的HttpServer::new
函数是一个执行器。然而,如果我们移除#[actix_web::main]
宏,我们会得到以下错误:
`main` function is not allowed to be `async`
这是因为我们的 main
函数,也就是我们的入口点,会返回一个 future 而不是运行我们的程序。#[actix_web::main]
是一个运行时实现,它使得所有内容都可以在当前线程上运行。#[actix_web::main]
宏标记了 async
函数(在这种情况下是 main
函数)由 Actix 系统执行。
注意
在这里冒险深入探讨一下,Actix crate 基于 actor 模型运行并发计算。在这里,actor 是一个计算。Actors 可以互相发送和接收消息。Actors 可以改变自己的状态,但它们只能通过消息影响其他 actor,这消除了基于锁的同步(我们之前提到的互斥锁是锁基础的)。对这个模型的进一步探索不会帮助我们开发基本的 Web 应用。然而,Actix crate 在actix.rs/book/actix
上对使用 Actix 进行并发系统编码有很好的文档。
我们在这里已经涵盖了大量的内容。如果你觉得自己没有完全记住所有内容,请不要感到有压力。我们简要地介绍了一系列关于异步编程的话题。我们不需要完全理解它就可以开始基于 Actix Web 框架构建应用程序。
你可能也会觉得我们涵盖的内容过多。例如,我们可以在需要时启动服务器并使用 async
语法来简单地生成视图,而不必真正了解发生了什么。在构建我们的玩具应用时,不了解发生了什么但知道在哪里放置 async
并不会减慢我们的速度。然而,这种快速浏览对于调试和设计应用来说是非常宝贵的。为了建立这一点,我们可以看看现实世界中的一个例子。我们可以看看这个聪明的 Stack Overflow 解决方案,它在一个文件中运行多个服务器:stackoverflow.com/questions/59642576/run-multiple-actix-app-on-different-ports
。
Stack Overflow 中的代码基本上是在一个运行时运行了两个服务器。首先,它们使用以下代码定义了视图:
use actix_web::{web, App, HttpServer, Responder};
use futures::future;
async fn utils_one() -> impl Responder {
"Utils one reached\n"
}
async fn health() -> impl Responder {
"All good\n"
}
视图定义完毕后,在 main
函数中定义了两个服务器:
#[actix_rt::main]
async fn main() -> std::io::Result<()> {
let s1 = HttpServer::new(move || {
App::new().service(web::scope("/utils").route(
"/one", web::get().to(utils_one)))
})
.bind("0.0.0.0:3006")?
.run();
let s2 = HttpServer::new(move || {
App::new().service(web::resource(
"/health").route(web::get().to(health)))
})
.bind("0.0.0.0:8080")?
.run();
future::try_join(s1, s2).await?;
Ok(())
}
我没有给这段代码添加任何注释,但它不应该让你感到害怕。我们可以自信地推断出 s1
和 s2
是 run
函数返回的 futures。然后我们将这两个 futures 合并,并 await
它们完成。我们的代码和 Stack Overflow 中的代码有一点细微的差别。我们的解决方案使用了 await?
并随后返回以下代码片段的 Ok
:
future::try_join(s1, s2).await?;
Ok(())
}
这是因为 ?
操作符本质上是一个 try
匹配。join(s1, s2).await?
大概等价于以下代码:
match join(s1, s2).await {
Ok(v) => v,
Err(e) => return Err(e.into()),
}
而 join(s1, s2).await.unwrap()
大概等价于以下代码:
match join(s1, s2).await {
Ok(v) => v,
Err(e) => panic!("unwrap resulted in {}", e),
}
由于?
操作符,提供解决方案的人必须在末尾插入Ok
,因为main
函数返回Result
,而这被实现?
操作符时移除了。
因此,在野外的解决方案中,Stack Overflow展示了涵盖异步编程的重要性。我们可以查看野外的代码,弄清楚发生了什么,以及Stack Overflow上的发帖者是如何实现他们的目标的。这也意味着我们可以自己发挥创意。没有什么阻止我们创建三个服务器并在main
函数中运行它们。这正是 Rust 真正闪耀的地方。花时间学习 Rust 让我们能够安全地深入到底层,并对我们所做的事情有更精细的控制。你会发现这在用 Rust 做的任何编程领域都是真实的。
在尝试构建我们的应用程序之前,还有一个概念我们应该调查,那就是将main
函数作为一个未来。如果我们看看Tokio库,我们可以看到它是一个通过提供编写网络应用程序所需的构建块来为 Rust 编程语言提供异步运行时的库。Tokio 的工作原理很复杂;然而,如果我们查看关于加快 Tokio 运行时的 Tokio 文档,我们可以添加如下所示的图表:
图 3.3 – 加快 Tokio 运行时 来源:Tokio 文档(2019)]
在前面的图中,我们可以看到有一些任务被排队,处理器正在处理它们。我们之前已经处理了我们的任务,所以这应该看起来很熟悉。考虑到这一点,我们可能不会对可以使用 Tokio 而不是 Actix Web 宏来运行我们的服务器感到太惊讶。为此,我们在Cargo.toml
文件中定义我们的 Tokio 依赖项,如下所示:
tokio = { version = "1.17.0", features = ["full"] }
使用前面的代码,我们现在可以在main.rs
文件中的宏切换到以下代码:
#[tokio::main]
async fn main() -> std::io::Result<()> {
HttpServer::new( || {
App::new()
.route("/", web::get().to(greet))
.route("/{name}", web::get().to(greet))
})
.bind("127.0.0.1:8080")?
.bind("127.0.0.1:8081")?
.workers(3)
.run()
.await
}
运行前面的代码将给我们与运行服务器相同的输出。当使用 Tokio 而不是我们的 Actix 运行时宏时,可能会有一些不一致。虽然这是一个有趣的结果,展示了我们可以如何自信地配置我们的服务器,但当我们谈到在 Actix 中开发待办事项应用程序时,我们将在这本书的其余部分使用 Actix 运行时宏。我们将在第十四章 探索 Tokio 框架中重新审视 Tokio。
我们现在已经涵盖了足够的服务器配置和服务器如何处理请求的内容,可以变得高效。现在我们可以继续定义我们的视图以及它们在下一节中的处理方式。
使用 Actix Web 框架管理视图
到目前为止,我们已经在main.rs
文件中定义了所有我们的视图。这对于小型项目来说是可以的;然而,随着我们的项目增长,这不会很好地扩展。找到正确的视图可能很困难,更新它们可能会导致错误。这也使得从你的 Web 应用程序中移除或插入模块变得更加困难。此外,如果我们把所有的视图定义在一个页面上,如果一个大团队正在开发应用程序,这可能会导致很多合并冲突,因为他们都会想要更改相同的文件,如果他们正在更改视图的定义。正因为如此,最好将一组视图的逻辑包含在一个模块中。我们可以通过构建一个处理认证的模块来探索这一点。我们不会在本章中构建围绕认证的逻辑,但它是一个很好的简单示例,用于探索如何管理视图模块的结构。在我们编写任何代码之前,我们的 Web 应用程序应该有以下文件布局:
├── main.rs
└── views
├── auth
│ ├── login.rs
│ ├── logout.rs
│ └── mod.rs
├── mod.rs
每个文件中的代码可以描述如下:
-
main.rs
: 服务器定义的入口点 -
views/auth/login.rs
: 定义登录视图的代码 -
views/auth/logout.rs
: 定义登出视图的代码 -
views/auth/mod.rs
: 定义auth
视图的工厂 -
views/mod.rs
: 定义整个应用程序所有视图的工厂
首先,让我们从main.rs
文件中的基本 Web 服务器开始,不添加任何额外的功能,以下代码:
use actix_web::{App, HttpServer};
mod views;
#[actix_web::main]
async fn main() -> std::io::Result<()> {
HttpServer::new(|| {
let app = App::new();
return app
})
.bind("127.0.0.1:8000")?
.run()
.await
}
上述代码简单明了,不应该有任何惊喜。我们稍后会修改代码,然后我们可以继续定义视图。对于本章,我们只想返回一个字符串,说明视图是什么。我们将知道我们的应用程序结构是可行的。我们可以在views/auth/login.rs
文件中使用以下代码定义我们的基本登录视图:
pub async fn login() -> String {
format!("Login view")
}
现在,在views/auth/logout.rs
文件中的登出视图是这样的,这并不会让人感到惊讶:
pub async fn logout() -> String {
format!("Logout view")
}
现在我们已经定义了视图,我们只需要在mod.rs
文件中定义工厂,以便我们的服务器能够提供它们。我们的工厂提供了我们应用程序的数据流,其形式如下:
图 3.4 – 我们应用程序的数据流
我们可以在图 3**.4中看到,链式工厂给我们带来了很多灵活性。如果我们想从我们的应用程序中移除所有的auth
视图,我们只需在我们的主视图工厂中删除一行代码就能做到这一点。我们还可以重用我们的模块。例如,如果我们想在多个服务器上使用auth
模块,我们只需为auth
视图模块创建一个 git 子模块,并在其他服务器上使用它。我们可以在views/auth/mod.rs
文件中使用以下代码构建我们的auth
模块工厂视图:
mod login;
mod logout;
use actix_web::web::{ServiceConfig, get, scope};
pub fn auth_views_factory(app: &mut ServiceConfig) {
app.service(scope("v1/auth").route("login",
get().to(login::login)).route("logout",
get().to(logout::logout))
);
}
在前面的代码中,我们可以看到我们传递了一个ServiceConfig
结构体的可变引用。这使得我们能够在服务器上的不同字段中定义诸如视图之类的事物。该结构体的文档说明,它是为了允许更大的应用程序将配置拆分到不同的文件中。然后我们将服务应用于ServiceConfig
结构体。该服务使我们能够定义一个视图块,所有这些视图都将填充在作用域中定义的前缀。我们还声明,目前我们使用get
方法,以便在浏览器中易于访问。现在,我们可以使用以下代码将auth
视图工厂插入到views/mod.rs
文件中的main
视图工厂:
mod auth;
use auth::auth_views_factory;
use actix_web::web::ServiceConfig;
pub fn views_factory(app: &mut ServiceConfig) {
auth_views_factory(app);
}
在前面的代码中,我们只需一行代码就能将整个视图模块切割。我们还可以按需链式调用模块。例如,如果我们想在auth
视图模块中添加子模块,我们可以这样做,只需将那些auth
子模块的工厂传递给auth
工厂即可。我们还可以在工厂中定义多个服务。我们的main.rs
文件在添加了一个configure
函数后基本保持不变,如下所示:
use actix_web::{App, HttpServer};
mod views;
#[actix_web::main]
async fn main() -> std::io::Result<()> {
HttpServer::new(|| {
let app =
App::new().configure(views::views_factory);
return app
})
.bind("127.0.0.1:8000")?
.run()
.await
}
当我们在App
结构体上调用configure
函数时,我们将视图工厂传递给configure
函数,它会自动将config
结构体传递给我们的工厂函数。由于configure
函数返回Self
,即App
结构体,我们可以在闭包的末尾返回结果。现在我们可以运行我们的服务器,得到以下结果:
图 3.5 – 登录视图
我们可以看到,我们的应用程序带有预期的前缀可以正常工作!通过这一点,我们已经涵盖了处理 HTTP 请求的所有基础知识。
摘要
在本章中,我们介绍了线程、未来和async
函数的基础知识。因此,我们能够自信地查看野外的多服务器解决方案并理解其工作原理。基于上一章学到的概念,我们构建了定义视图的模块。此外,我们通过链式调用工厂,使视图能够即时构建并添加到服务器中。通过这种链式工厂机制,我们可以在构建服务器时将整个视图模块插入或移除配置。
我们还构建了一个实用结构体,它定义了一个路径,标准化了一组视图的 URL 定义。在未来的章节中,我们将使用这种方法构建认证、JSON 序列化和前端模块。基于我们已经覆盖的内容,我们将在下一章中构建能够以多种不同方式从用户那里提取和返回数据的视图。有了这种模块化理解,我们有了强大的基础,使我们能够用 Rust 构建真实的网络项目,其中逻辑是隔离的,可以配置,代码可以以可管理的方式添加。
在下一章中,我们将处理请求和响应。我们将学习如何传递参数、主体、头和表单到视图中,并通过返回 JSON 来处理它们。我们将使用上一章中构建的待办事项模块,通过服务器视图来启用我们对待办事项的交互。
问题
-
HttpServer::new
函数传递了什么参数,该参数返回什么? -
闭包与函数的区别是什么?
-
进程和线程之间的区别是什么?
-
async
函数和普通函数之间的区别是什么? -
await
和join
之间的区别是什么? -
链接工厂的优势是什么?
答案
-
闭包被传递到
HttpServer::new
函数中。HttpServer::new
函数必须返回App
结构体,这样在HttpServer::new
函数执行后,bind
和run
函数才能对其执行操作。 -
闭包可以与其作用域之外的变量交互。
-
进程是一个具有自己的内存栈、寄存器和变量的正在执行的程序,而线程是一个轻量级的进程,它可以独立管理但与其他线程和主程序共享数据。
-
普通函数在调用时立即执行,而
async
函数是一个承诺,必须使用阻塞函数来执行。 -
await
会阻塞程序以等待未来执行;然而,join
函数可以并发运行多个线程或未来。await
也可以在join
函数上执行。 -
链接工厂使我们能够在构建和编排各个模块方面具有灵活性。模块内部的工厂专注于模块的构建方式,而模块外部的工厂专注于不同模块的编排方式。
进一步阅读
- 《Rust 并发实战》(2018),作者:布赖恩·特劳特温,Packt 出版社
第四章:处理 HTTP 请求
到目前为止,我们已经利用 Actix Web 框架来提供基本视图。然而,当涉及到从请求中提取数据并将数据返回给用户时,这只能让我们走这么远。在本章中,我们将融合来自 第二章**,在 Rust 中设计您的 Web 应用程序 和 第三章**,处理 HTTP 请求* 的代码,以构建处理待办事项的服务器视图。然后我们将探索 JSON 序列化 以提取数据并将其返回,使我们的视图更加用户友好。我们还将使用中间件在数据到达视图之前从头部提取数据。我们将通过构建待办应用程序的创建、编辑和删除待办事项端点来探索围绕数据序列化和从请求中提取数据的概念。
本章将涵盖以下主题:
-
了解融合代码的初始设置
-
将参数传递到视图中
-
使用宏进行 JSON 序列化
-
从视图中提取数据
一旦您完成本章,您将能够构建一个基本的 Rust 服务器,该服务器可以在 URL 中发送和接收数据,在 JSON 主体中,以及在 HTTP 请求的头部。这本质上是一个完全功能的 API Rust 服务器,没有用于数据存储的数据库,没有用户认证的能力,也没有在浏览器中显示内容的能力。然而,这些概念将在接下来的三个章节中介绍。您已经拥有一个完全工作的 Rust 服务器,它正在运行。让我们开始吧!
技术要求
对于本章,我们需要下载并安装 Postman。我们将需要 Postman 来向我们的服务器发送 API 请求。您可以从 www.postman.com/downloads/
下载它。
我们还将基于上一章中创建的服务器代码进行构建,该代码可在 github.com/PacktPublishing/Rust-Web-Programming-2nd-Edition/tree/main/chapter03/managing_views_using_the_actix_web_framework/web_app
找到。
您可以在此处找到本章将使用的完整源代码:github.com/PacktPublishing/Rust-Web-Programming-2nd-Edition/tree/main/chapter04
。
管理视图代码将是本章的基础,我们将向此代码库添加功能。我们将将其与我们在第二章,使用 Rust 设计您的 Web 应用程序中编写的 to-do 模块融合,该模块可以在github.com/PacktPublishing/Rust-Web-Programming-2nd-Edition/tree/main/chapter02/processing_traits_and_structs
找到。
了解融合代码的初始设置
在本节中,我们将介绍我们在第二章,使用 Rust 设计您的 Web 应用程序和第三章,处理 HTTP 请求中构建的两个融合代码片段的初始设置。这种融合将给我们以下结构:
图 4.1 – 我们的应用及其模块的结构
在这里,我们将注册主文件中的所有模块,然后将所有这些模块拉入要使用的视图。我们实际上是在将第二章,使用 Rust 设计您的 Web 应用程序中的命令行界面与网页视图相结合。结合这些模块,代码库中给出了以下文件:
├── main.rs
├── processes.rs
├── state.rs
然后,我们将to_do
模块绑定到与我们的main.rs
文件相同的目录中。如果你在阅读第二章,使用 Rust 设计您的 Web 应用程序时构建了to_do
模块,那么你的to_do
模块应该具有以下结构:
├── to_do
│ ├── enums.rs
│ ├── mod.rs
│ ├── structs
│ │ ├── base.rs
│ │ ├── done.rs
│ │ ├── mod.rs
│ │ └── pending.rs
│ └── traits
│ ├── create.rs
│ ├── delete.rs
│ ├── edit.rs
│ ├── get.rs
│ └── mod.rs
因此,现在,我们从上一章的views
模块中添加的 bolt 应该包含以下内容:
└── views
├── auth
│ ├── login.rs
│ ├── logout.rs
│ └── mod.rs
├── mod.rs
├── path.rs
所有代码的完整结构可以在以下 GitHub 仓库中找到:
)
现在我们已经将上一章的模块添加到我们的项目中,我们可以在程序中将它们绑定在一起。为此,我们必须创建一个新的src/main.rs
文件。首先,我们必须导入我们构建的模块,并使用以下代码定义一个基本的服务器:
use actix_web::{App, HttpServer};
mod views;
mod to_do;
mod state;
mod processes;
#[actix_web::main]
async fn main() -> std::io::Result<()> {
HttpServer::new(|| {
let app = App::new().configure(views::views_factory);
return app
})
.bind("127.0.0.1:8000")?
.run()
.await
}
在前面的代码中,我们定义了模块,然后是我们的服务器。因为服务器正在使用views_factory
,所以我们不需要在本章的其余部分修改此文件。相反,我们将链式调用在views_factory
函数中调用的工厂函数。
在这一点上,我们可以坐下来欣赏我们在前几章中所做所有艰苦工作的回报。原则的隔离和定义良好的模块使我们能够以最小的努力将我们的逻辑从命令行程序插入到我们的服务器接口中。现在,我们只需要将其连接到我们的 views
模块,并将参数传递到这些视图中。然而,在我们进入下一节之前,我们必须做一些小的整理工作,以确保我们的服务器可以运行。首先,我们的 Cargo.toml
文件中的依赖项有以下要求:
[dependencies]
actix-web = "4.0.1"
serde_json = "1.0.59"
然后,我们可以执行 cargo run
命令,这表明我们的登录和注销视图在我们的浏览器中工作。完成这些后,我们可以开始着手将参数传递到视图中。
将参数传递到视图中
在本节中,我们将介绍将两个模块融合以创建待办事项并通过视图存储的初始设置。为此,我们必须将待办事项的标题传递到创建待办事项的 create
视图中。我们可以使用以下路由将数据传递到视图中:
-
URL:数据和参数可以存储在请求的 URL 中。这通常用于简单情况,因为它易于实现。
-
body:数据可以存储在请求体中的不同字段下。这用于更复杂的数据结构和更大的有效载荷。
-
header:数据可以存储在请求头中的不同字段下。这用于存储正在发送的请求的元数据。我们还在头部存储了请求的认证数据。
我们将在整个项目中涵盖所有这些方法,但到目前为止,我们将使用 URL 方法传递我们的数据,因为这是最简单的介绍方法。首先,我们将使用以下布局创建我们的待办视图结构:
└── views
├── auth
│ ├── login.rs
│ ├── logout.rs
│ └── mod.rs
├── mod.rs
└── to_do
├── create.rs
└── mod.rs
我们可以看到,我们将 to-do
视图放在与 to_do
模块相邻的 views
模块中。我们将继续以这种方式堆叠我们的视图,以便我们可以将它们插入和从我们的服务器中取出,如果需要,还可以将它们插入到其他项目中。现在,创建待办事项将采取以下形式:
图 4.2 – 创建待办事项的过程
要执行 *图 4**.2 中展示的过程,我们需要执行以下步骤:
-
加载待办事项列表的当前状态。
-
从 URL 获取新待办事项的标题。
-
通过
to_do_factory
传递标题和状态pending
。 -
将上一步的结果、字符串
create
和状态传递到进程模块接口。 -
向用户返回一个字符串以表示过程已完成。
我们可以在 views/to_do/create.rs
文件中执行之前定义的这些步骤。首先,我们必须使用以下代码导入我们需要的:
use serde_json::value::Value;
use serde_json::Map;
use actix_web::HttpRequest;
use crate::to_do::{to_do_factory, enums::TaskStatus};
use crate::state::read_file;
use crate::processes::process_input;
我们将使用serde_json::value::Value
和serde_json::Map
来定义我们从state.json
文件中读取的数据类型,并使用HttpRequest
结构体从 URL 中提取标题。然后我们将从我们的其他模块中导入我们需要的内容,以便我们能够创建一个项目、读取状态文件和处理输入。我们的视图可以通过以下代码定义:
pub async fn create(req: HttpRequest) -> String {
let state: Map<String, Value> = read_file(
"./state.json"); // step 1
let title: String = req.match_info().get("title"
).unwrap().to_string(); // step 2
let item = to_do_factory(&title.as_str(),
TaskStatus::PENDING); // step 3
process_input(item, "create".to_string(), &state);
// step 4
return format!("{} created", title) // step 5
}
我们需要记住这是一个async
函数,因为它是我们服务器正在处理的视图。我们还可以看到我们的title
是通过使用match_info
函数从HttpRequest
中提取的。我们必须直接解包它,因为如果 URL 中没有标题,我们不想继续进行创建项目和将提取的标题转换为String
的过程。然后我们需要将这个引用传递给我们的to_do_factory
以创建一个ItemTypes
枚举。然后我们将我们的枚举与一个命令和当前应用程序状态的引用传递给我们的process_input
函数,正如我们记得的那样,它将经过一系列步骤来确定如何根据传入的命令和项目类型处理状态。这里有很多事情在进行,但必须注意的是,与如何处理项目相关的所有逻辑都不在这个视图中。这被称为代码正交性的关注点分离。代码正交性指的是以下数学概念:
图 4.3 – 正交性的数学概念
我们可以在*图 4**.3 中看到,如果一个向量与另一个向量正交,那么它就没有在另一个向量上的投影。在物理学中,如果这些向量是力,那么这些向量之间没有任何影响。现在,这在编程中不能完全正确:如果我们删除processes
模块中的代码,它将影响create
视图,因为我们必须引用它。然而,processes
的逻辑不应该定义在create
视图中。这部分的理由是我们必须在其他地方使用processes
,但这不是全部原因。当我们查看create
视图时,我们可以看到与创建挂起项目相关的逻辑与整个应用程序的其他部分。这使得开发者能够确切地知道正在发生什么。他们不会迷失在与此节中早些时候指定的创建待办事项的五步法无关的细节中。如果开发者想要探索保存项目周围的逻辑,他们可以调查定义此逻辑的文件。
我们现在必须使to_do
模块中的视图对外可用。我们可以通过在views/to_do/mod.rs
文件中创建一个待办视图工厂函数来实现,以下代码如下:
mod create;
use actix_web::web::{ServiceConfig, get, scope};
pub fn to_do_views_factory(app: &mut ServiceConfig) {
app.service(
scope("v1/item")
.route("create/{title}", get().to(create::create))
);
}
在前面的代码中,我们可以看到我们没有使create
视图公开,但我们确实在工厂中使用它来定义视图。我们还使用/{title}
标签定义传递到 URL 中的标题。现在我们的项目视图已经功能正常,我们需要在views/mod.rs
文件中将to_do_views_factory
连接到我们的应用程序,以下代码:
mod auth;
mod to_do; // define the module
use auth::auth_views_factory;
use to_do::to_do_views_factory; // import the factory
use actix_web::web::ServiceConfig;
pub fn views_factory(app: &mut ServiceConfig) {
auth_views_factory(app);
to_do_views_factory(app); // pass the ServiceConfig
}
在前面的代码中,我们可以看到我们已经定义了模块,导入了工厂,然后传递了应用程序配置。完成这些后,我们的应用程序就准备好运行并创建待办事项了。当我们的应用程序运行时,我们可以使用以下 URL 创建项目:
图 4.4 – 创建待办事项的视图
如果我们查看控制台,我们将看到以下输出:
learn to code rust is being created
如果我们查看根目录中的state.json
文件,我们将得到以下数据:
{"learn to code rust":"PENDING"}
我们可以看到,我们创建待办事项的过程是成功的!我们的应用程序从 URL 中获取标题,创建一个待办事项,并将其保存在我们的 JSON 文件中。虽然这是一个里程碑,但必须指出,JSON 文件并不是数据存储的最佳解决方案。然而,现在我们可以,因为我们将在第六章“使用 PostgreSQL 的数据持久性”中配置一个合适的数据库。我们还可以看到 URL 中的%20
,它表示一个空格。我们可以看到,这个空格在控制台输出和将数据保存到 JSON 文件中时都会出现,并且这个空格也在浏览器中显示的视图中。我们所做的是通过 URL 获取待办事项标题,将其打印到终端,在浏览器中显示,并将其保存到 JSON 文件中。我们实际上已经完成了构建一个 Web 应用程序的基础,因为我们可以向用户显示数据并将其存储在文件中。
GET
方法对我们来说适用,但它并不是创建待办事项的最合适方法。GET
方法可以被缓存、书签、保存在浏览器历史记录中,并且在其长度方面有限制。将它们书签、保存在浏览器历史记录中或缓存它们不仅会带来安全问题;它还增加了用户意外再次发出相同请求的风险。因此,使用GET
请求更改数据不是一个好主意。为了防止这种情况,我们可以使用POST
请求,它不会缓存,不会出现在浏览器历史记录中,也不能被书签。
由于我们列举的原因,我们现在将create
视图转换为POST
请求。记住我们对代码正交性的评论。定义我们的视图路由如何处理的是在我们的views/to_do/mod.rs
文件中的工厂。以下代码:
mod create;
use actix_web::web::{ServiceConfig, post, scope};
pub fn to_do_views_factory(app: &mut ServiceConfig) {
app.service(
scope("v1/item")
.route("create", post().to(create::create))
);
}
在前面的代码中,我们可以看到我们只是在导入和route
定义中将get
更改为post
。如果我们尝试使用之前的方法创建一个新的待办事项,我们会得到以下结果:
图 4.5 – 被阻止的方法
在图 4.5 中,我们可以看到页面无法找到。这可能会让人困惑,因为错误是一个 404 错误,表示页面找不到。URL 仍然定义,但不再允许对这个 URL 使用GET
方法。考虑到这一点,我们可以使用以下 Postman 配置进行POST
调用:
图 4.6 – 使用 Postman 的 POST 方法创建项目
在图 4.6 中,我们可以看到我们的 URL 仍然有效,只是使用了不同的方法——POST
方法。我们可以检查我们的状态文件,找到以下数据:
{"learn to code rust":"PENDING","washing":"PENDING"}
我们可以看到,更改create
视图允许的方法并没有影响我们创建或存储待办事项的方式。回顾图 4.6,我们还可以看到我们得到了状态码200
,这是OK
。这已经告诉我们创建已经发生。因此,我们不需要返回任何内容,因为状态是OK
。
回顾当我们尝试向我们的create
视图发送GET
请求时得到的结果,我们得到了一个包含以下代码的views/to_do/mod.rs
文件:
app.service(
scope("v1/item")
.route("create/{title}", post().to(create::create))
.route("create/{title}", get().to(create::create))
);
如果我们将 URL 放入浏览器,我们可以看到这会创建一个挂起的待办事项。如果需要,我们还可以在我们的get
路由中使用不同的函数与相同的 URL。这给了我们在如何使用和重用 URL 方面的灵活性。然而,考虑到我们之前提到的GET
和POST
方法之间的差异,只为我们的create
函数提供一个POST
方法是有意义的。
现在我们已经完成了创建待办事项所需的所有工作。然而,在其他视图中,我们必须返回结构化数据来展示当前待办事项的状态。
到目前为止,我们已经使用 URL 将数据传递到我们的应用程序中,这是我们可以传递数据的最基本方式。然而,我们无法使用 URL 传递结构化数据。例如,如果我们想发送一个哈希表或列表,URL 根本无法容纳这样的结构。这就是我们需要在请求体中使用 JSON 将数据传递给应用程序的地方,我们将在下一节中介绍。
使用宏进行 JSON 序列化
当涉及到序列化数据并将其返回给客户端时,我们可以使用Actix-web
crate 中的 JSON 快速实现这一点。我们可以通过在views/to_do/get.rs
文件中创建一个返回所有待办事项的GET
视图来演示这一点:
use actix_web::{web, Responder};
use serde_json::value::Value;
use serde_json::Map;
use crate::state::read_file;
pub async fn get() -> impl Responder {
let state: Map<String, Value> = read_file("./state.json");
return web::Json(state);
}
这里,我们可以看到我们只是从 JSON 文件中读取 JSON,然后使用web::Json
函数返回这些值。直接从 JSON 文件返回Map<String, Value>
可能更有意义,因为它是String
和Value
。然而,Map<String, Value>
的类型没有实现Responder
特质。我们可以通过以下代码更新函数,直接返回状态:
pub async fn get() -> Map<String, Value> {
let state: Map<String, Value> = read_file("./state.json");
return state;
}
然而,这不会工作,因为 views/to_do/mod.rs
文件中的 get().to()
函数需要接受一个实现了 Responder
特性的结构体。我们现在可以在 views/to_do/mod.rs
文件中插入我们的 get
视图,以下代码:
mod create;
mod get; // import the get file
use actix_web::web::{ServiceConfig, post, get, scope};
// import get
pub fn to_do_views_factory(app: &mut ServiceConfig) {
app.service(
scope("v1/item")
.route("create/{title}", post().to(create::create))
.route("get", get().to(get::get)) // define view and URL
);
}
运行 URL http://127.0.0.1:8000/item/get
会给我们响应体中的以下 JSON 数据:
{
"learn to code rust": "PENDING",
"washing": "PENDING"
}
我们现在有一些结构化的数据可以呈现给前端。虽然这本质上完成了工作,但并不太有帮助。例如,我们希望有两个不同的列表,一个是 pending,另一个是 done。我们还可以添加时间戳,告诉用户待办项是在何时创建或编辑的。仅仅返回待办项的标题和状态将无法使我们能够按需扩展复杂性。
构建我们的自定义序列化结构体
为了对将要返回给用户的类型数据有更多的控制,我们将不得不构建我们自己的序列化结构体。我们的序列化结构体将展示两个列表,一个用于已完成的项目,另一个用于待办项目。列表将被填充具有标题和状态的对象。如果我们回想一下 第二章,在 Rust 中设计您的 Web 应用程序,我们的 pending
和 Done
项目结构体是通过组合从 Base
结构体继承的。因此,我们必须从 Base
结构体中访问标题和状态。然而,我们的 Base
结构体对公众不可访问。我们必须使其可访问,以便我们可以序列化每个待办项的属性:
图 4.7 – 我们的待办结构体与接口之间的关系
查看 图 4.7,我们可以看到 TaskStatus
枚举是依赖关系的根。在我们能够序列化待办项之前,我们需要能够序列化这个 enum
。我们可以使用 serde
包来完成这个任务。为了做到这一点,我们必须更新 Cargo.toml
文件中的依赖项:
[dependencies]
actix-web = "4.0.1"
serde_json = "1.0.59"
serde = { version = "1.0.136", features = ["derive"] }
我们可以看到我们添加了 features = ["derive"]
。这将使我们能够用 serde
特性装饰我们的结构体。我们现在可以查看我们如何在 src/to_do/enums.rs
文件中定义我们的 enum
,以下代码:
pub enum TaskStatus {
DONE,
PENDING
}
impl TaskStatus {
pub fn stringify(&self) -> String {
match &self {
&Self::DONE => {return "DONE".to_string()},
&Self::PENDING =>
{return "PENDING".to_string()}
}
}
pub fn from_string(input_string: String) -> Self {
match input_string.as_str() {
"DONE" => TaskStatus::DONE,
"PENDING" => TaskStatus::PENDING,
_ => panic!("input {} not supported",
input_string)
}
}
}
在前面的代码中,我们可以看到我们有两个名为 DONE
和 PENDING
的字段;然而,它们本质上是其自身的类型。我们如何将其序列化为 JSON 值?在 stringify
函数中有一个线索。然而,这并不是全部。记住,我们服务器视图的返回值需要实现特性行为。我们可以通过在 src/to_do/enums.rs
文件中首先导入我们需要的特性行为来实现 serde
特性,以下代码:
use serde::ser::{Serialize, Serializer, SerializeStruct};
我们现在拥有了实现 Serialize
特性的所有必要条件,因此我们可以在下一节中自定义我们编写的结构体如何被序列化。
实现 Serialize 特性
Serialize
是我们将要实现的特质,Serializer
是一个数据格式化器,可以序列化serde
支持的任何数据格式。然后我们可以使用以下代码为我们的TaskStatus
enum
实现Serialize
特质:
impl Serialize for TaskStatus {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok,
S::Error>
where
S: Serializer,
{
Ok(serializer.serialize_str(&self.stringify()
.as_str())?)
}
}
这是一个在serde
文档中定义的标准方法。在前面的代码中,我们可以看到一个名为serialize
的函数已经被定义。当序列化我们的TaskStatus
enum
时,会调用serialize
函数。我们还注意到serializer
的类型表示法为S
。然后我们使用一个where
语句将S
定义为Serializer
。这看起来可能有些反直觉,因此我们可以从我们的应用程序中退一步来探索它。以下代码块不是完成我们的应用程序所必需的。
让我们定义一些基本的结构体如下:
#[derive(Debug)]
struct TwoDposition {
x: i32,
y: i32
}
#[derive(Debug)]
struct ThreeDposition {
x: i32,
y: i32,
z: i32
}
在前面的代码中,我们可以看到我们为TwoDposition
和ThreeDposition
结构体实现了Debug
特质。然后我们可以定义函数,使用以下代码为每个struct
打印调试语句:
fn print_two(s: &TwoDposition) {
println!("{:?}", s);
}
fn print_three(s: &ThreeDposition) {
println!("{:?}", s);
}
然而,我们可以看到这并不具有良好的可扩展性。我们将会为每个实现它的东西编写一个函数。相反,我们可以使用一个where
语句,这样我们就可以将两个结构体都传递给它,因为它们实现了Debug
特质。首先,我们必须使用以下代码导入特质:
use core::fmt::Debug;
然后,我们可以使用以下代码定义我们的灵活函数:
fn print_debug<S>(s: &S)
where
S: Debug {
println!("{:?}", s);
}
这里发生的事情是,我们的函数在传递给函数的变量的类型上是泛型的。然后我们获取类型S
的值的引用。这意味着如果S
实现了Debug
特质,它可以是任何类型。如果我们尝试传递一个没有实现Debug
特质的struct
,编译器将拒绝编译。那么,编译时发生了什么?运行以下代码:
fn main() {
let two = TwoDposition{x: 1, y: 2};
let three = ThreeDposition{x: 1, y: 2, z: 3};
print_debug(&two);
print_debug(&three);
}
我们将得到以下打印输出:
TwoDposition { x: 1, y: 2 }
ThreeDposition { x: 1, y: 2, z: 3 }
前面的输出是有意义的,因为这是调用 debug 特质时的打印结果。然而,它们是编译器编译时创建的两个不同的函数。我们的编译器编译了以下两个函数:
print_debug::<TwoDposition>(&two);
print_debug::<ThreeDposition>(&three);
这并没有打破我们对 Rust 工作方式的了解;然而,它确实使我们的代码更具可扩展性。使用where
语句还有更多优点;例如,我们可以使用以下代码指定迭代器中需要的特质:
fn debug_iter<I>(iter: I)
where
I: Iterator
I::Item: Debug
{
for item in iter {
println!("{:?}", iter);
}
}
在前面的代码中,我们可以看到我们接受了一个迭代器,并且迭代器中的项需要实现Debug
特质。然而,如果我们继续探索特质的实现,我们可能会失去本书的主要目标:使用 Rust 进行 Web 编程。
通过了解使用where
语句实现特质的用法,我们可以回顾一下在TaskStatus
enum
中实现Serialize
特质的代码:
impl Serialize for TaskStatus {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok,
S::Error>
where
S: Serializer,
{
Ok(serializer.serialize_str(&self.stringify()
.as_str())?)
}
}
我们可以看到,我们只是调用了stringify
函数,并将其包裹在Ok
结果中。我们只想将状态作为一个String
放入更大的数据体中。如果它是一个有字段的struct
,那么我们可以将serialize
函数编写如下:
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let mut s = serializer.serialize_struct("TaskStatus",
1)?;
s.serialize_field("status", &self.stringify())?;
s.end()
}
在前面的代码中,我们的序列化器是一个名为"TaskStatus"
的struct
,它有一个字段。然后我们将stringify
函数的结果分配给了status
字段。这样做本质上给我们以下结构:
#[derive(Serialize)]
struct TaskStatus {
status: String
}
然而,我们不会在我们的当前练习中使用serialize_struct
方法,因为我们需要将状态插入到一个更大的返回体中。
将序列化struct
集成到我们的应用程序代码中
现在我们已经使我们的TaskStatus
enum
能够被序列化,我们可以回顾图 4**.7,看到我们的Base
struct
是下一个要序列化的。我们还可以看到Base
struct
是 JSON 序列化的关键,但它目前不是公共的,所以我们需要将其设置为公共。这可以通过将to_do/structs/mod.rs
文件中的基础模块声明从mod base;
更改为pub mod base;
来实现。现在Base
struct
可以直接在模块外部使用,我们可以在src
目录中构建自己的json_serialization
模块,其结构如下:
├── main.rs
├── json_serialization
│ ├── mod.rs
│ └── to_do_items.rs
我们将在src/json_serialization/to_do_items.rs
文件中定义当调用get
视图时将返回给查看者的内容,以下代码:
use serde::Serialize;
use crate::to_do::ItemTypes;
use crate::to_do::structs::base::Base;
#[derive(Serialize)]
pub struct ToDoItems {
pub pending_items: Vec<Base>,
pub done_items: Vec<Base>,
pub pending_item_count: i8,
pub done_item_count: i8
}
在前面的代码中,我们所做的一切只是定义了一个标准的公共struct
参数。然后我们使用derive
宏实现了Serialize
特性。这使得struct
的属性可以以属性名为键序列化为 JSON。例如,如果ToDoItems``struct
有一个done_item_count
字段为 1,那么 JSON 体将表示为"done_item_count": 1
。我们可以看到,这比我们之前为TaskStatus
enum
所做的手动序列化要简单。这是因为我们字段的格式很简单。如果我们不需要在序列化过程中进行任何额外的逻辑,用Serialize
特性装饰ToDoItems
是最简单的方法,这将导致错误更少。
现在序列化已经被定义,我们必须考虑数据的处理。如果我们必须在调用struct
之前对数据进行排序和计数,这将无法扩展。这将会在处理序列化数据的视图中添加不必要的代码,而不是属于该视图逻辑的代码。这也会导致代码重复。我们只有一种方式来排序、计数和序列化数据。如果需要其他视图来返回项目列表,那么我们可能需要再次复制代码。
考虑到这一点,为struct
构建一个构造函数是有意义的,在这个构造函数中,我们接收一个待办事项的向量,将它们排序到正确的属性中,然后进行计数。我们可以用以下代码定义构造函数:
impl ToDoItems {
pub fn new(input_items: Vec<ItemTypes>) -> ToDoItems {
. . . // code to be filled in
}
}
在前面的代码中,我们可以看到我们的构造函数接受一个待办事项向量,这是我们已从 JSON 文件中加载的。在我们的构造函数内部,我们必须执行以下步骤:
- 将项目分类到两个向量中,一个用于待办事项,另一个用于已完成事项。
我们将简单地遍历项目向量,根据项目类型将它们追加到不同的向量中,以下代码展示了如何操作:
let mut pending_array_buffer = Vec::new();
let mut done_array_buffer = Vec::new();
for item in input_items {
match item {
ItemTypes::Pending(packed) => pending_array_buffer.
push(packed.super_struct),
ItemTypes::Done(packed) => done_array_buffer.push(
packed.super_struct)
}
}
- 计算待办和已完成事项的总数。
对于下一步,我们可以在每个向量上调用len
函数。len
函数返回usize
,它是一个指针大小的无符号整数类型。正因为如此,我们可以用以下代码将其转换为i8
:
let done_count: i8 = done_array_buffer.len() as i8;
let pending_count: i8 = pending_array_buffer.len() as i8;
-
现在我们已经拥有了构建和返回结构体所需的所有数据,这可以通过以下代码定义:
return ToDoItems{
pending_items: pending_array_buffer,
done_item_count: done_count,
pending_item_count: pending_count,
done_items: done_array_buffer
}
现在我们的构造函数已经完成。
我们现在可以使用这个函数构建我们的结构体。我们唯一需要做的就是将其插入到我们的应用程序中,以便我们可以将其传递到应用程序中。在json_serialization/mod.rs
文件中,我们可以用以下代码将其公开:
pub mod to_do_items;
我们现在可以在src/main.rs
文件中声明我们的模块,以下代码展示了如何操作:
mod json_serialization;
我们还必须确保我们的base
模块在src/to_do/structs/mod.rs
文件中是公开的。当我们返回数据时,我们将序列化|结构体,这可以在src/to_do/structs/base.rs
文件中通过以下代码实现:
pub mod to_do_items;
use super::super::enums::TaskStatus;
use serde::Serialize;
#[derive(Serialize)]
pub struct Base {
pub title: String,
pub status: TaskStatus
}
为了利用我们的结构体,我们必须在我们的views/to_do/get.rs
文件中的GET
视图中定义它,并使用以下代码返回它:
use actix_web::{web, Responder};
use serde_json::value::Value;
use serde_json::Map;
use crate::state::read_file;
use crate::to_do::{ItemTypes, to_do_factory, enums::TaskStatus};
use crate::json_serialization::to_do_items::ToDoItems;
pub async fn get() -> impl Responder {
let state: Map<String, Value> = read_file(
"./state.json");
let mut array_buffer = Vec::new();
for (key, value) in state {
let status = TaskStatus::from_string(
&value.as_str().unwrap())
.to_string();
let item: ItemTypes = to_do_factory(
&key, status);
array_buffer.push(item);
}
let return_package: ToDoItems = ToDoItems::new(
array_buffer);
return web::Json(return_package);
}
上述代码是另一个所有事情都突然变得清晰的例子。我们使用read_file
接口从 JSON 文件中获取状态。然后我们可以遍历映射,将项目类型转换为字符串,并将其输入到我们的to_do_factory
接口中。一旦我们从工厂中构建了项目,我们就将其追加到一个向量中,并将该向量输入到我们的 JSON 序列化结构体中。在点击get
视图后,我们收到以下 JSON 正文:
{
"pending_items": [
{
"title": "learn to code rust",
"status": "PENDING"
},
{
"title": "washing",
"status": "PENDING"
}
],
"done_items": [],
"pending_item_count": 2,
"done_item_count": 0
}
现在我们有一个结构良好的响应,我们可以在此基础上进行扩展和编辑。应用程序的开发永远不会停止,所以如果你打算继续维护这个应用程序,你将向这个返回的 JSON 体中添加功能。我们很快将转向其他视图。然而,在我们这样做之前,我们必须承认,每次我们进行 API 调用时,我们都会返回包含计数的完整项目列表。因此,我们必须在每个函数中包装这个响应;否则,我们将为每个视图重写我们在get
视图中编写的相同代码。在下一节中,我们将介绍如何包装我们的待办事项,以便它们可以在多个视图中返回。
将我们的自定义序列化结构体打包以返回给用户
现在,我们的 GET
视图返回 Responder
特质的实现。这意味着如果我们的 ToDoItems
结构体也实现了这个特质,它可以直接在视图中返回。我们可以在 json_serialization/to_do_items.rs
文件中这样做。首先,我们必须导入以下结构体和特质:
use serde::Serialize;
use std::vec::Vec;
use serde_json::value::Value;
use serde_json::Map;
use actix_web::{
body::BoxBody, http::header::ContentType,
HttpRequest, HttpResponse, Responder,
};
use crate::to_do::ItemTypes;
use crate::to_do::structs::base::Base;
use crate::state::read_file;
use crate::to_do::{to_do_factory, enums::TaskStatus};
从 actix_web
包中,我们可以看到我们导入了大量结构体和特质,这将使我们能够构建 HTTP 响应。现在,我们可以用以下代码在 ToDoItems
结构体的 get_state
函数中实现 get
视图代码:
impl ToDoItems {
pub fn new(input_items: Vec<ItemTypes>) -> ToDoItems {
. . .
}
pub fn get_state() -> ToDoItems {
let state: Map<String, Value> = read_file("./state. json");
let mut array_buffer = Vec::new();
for (key, value) in state {
let status = TaskStatus::from_string(&value
.as_str().unwrap().to_string());
let item = to_do_factory(&key, status);
array_buffer.push(item);
}
return ToDoItems::new(array_buffer)
}
}
上述代码使我们能够仅用一行代码从我们的 JSON 文件中获取所有待办事项。我们必须通过以下代码实现 Responder
特质来使我们的 ToDoItems
结构体能够在视图中返回:
impl Responder for ToDoItems {
type Body = BoxBody;
fn respond_to(self, _req: &HttpRequest)
-> HttpResponse<Self::Body> {
let body = serde_json::to_string(&self).unwrap();
HttpResponse::Ok()
.content_type(ContentType::json())
.body(body)
}
}
在前面的代码中,我们本质上使用 serde_json
包序列化了 ToDoItems
结构体,然后返回了一个包含 ToDoItems
结构体的 HTTP 响应。当我们的 ToDoItems
结构体在视图中返回时,将调用 respond_to
函数。现在,事情变得非常有趣。我们可以用以下代码重写我们的 views/to_do/get.rs
文件:
use actix_web::Responder;
use crate::json_serialization::to_do_items::ToDoItems;
pub async fn get() -> impl Responder {
return ToDoItems::get_state();
}
就这些了!如果我们现在运行我们的应用程序,我们将得到与之前相同的响应。有了这个,我们可以看到特质如何为我们的视图抽象代码。现在我们已经创建了 get
视图,我们必须着手构建其他创建、编辑和删除的视图。为此,我们将继续到下一个部分,即从我们的视图中提取数据。
从视图中提取数据
在本节中,我们将探索从我们的 HTTP 请求的头部和体中提取数据。然后,我们将使用这些方法来编辑、删除待办事项,并在请求完全加载之前通过中间件拦截请求。我们将一步一步来。现在,让我们从 HTTP 请求的体中提取数据来编辑待办事项。当涉及到接受 JSON 格式的数据时,我们应该像本书中一直做的那样,将此代码与视图分离。如果我们仔细想想,我们只需要发送我们正在编辑的项目。然而,我们也可以使用相同的模式来删除。我们可以在 json_serialization/to_do_item.rs
文件中用以下代码定义我们的模式:
use serde::Deserialize;
#[derive(Deserialize)]
pub struct ToDoItem {
pub title: String,
pub status: String
}
在前面的代码中,我们仅仅声明了每个字段所需的数据类型,因为我们不能通过 JSON 传递枚举;只能传递字符串。通过使用 Deserialize
特质宏装饰 ToDoItem
结构体,我们启用了从 JSON 的反序列化。我们必须记住使 ToDoItem
结构体对整个应用程序可用,所以我们的 json_serialization/mod.rs
文件应该看起来像以下这样:
pub mod to_do_items;
pub mod to_do_item;
现在我们已经完成了项目提取,我们可以继续到我们的 edit
视图。在我们的 views/to_do/edit.rs
文件中,我们可以用以下代码导入所需的内容:
use actix_web::{web, HttpResponse};
use serde_json::value::Value;
use serde_json::Map;
use crate::state::read_file;
use crate::to_do::{to_do_factory, enums::TaskStatus};
use crate::json_serialization::{to_do_item::ToDoItem,
to_do_items::ToDoItems};
use crate::processes::process_input;
在前面的代码中,我们可以看到,我们需要导入用于视图的标准序列化和 web 结构。我们还导入了ToDoItem
和ToDoItems
结构,用于摄取数据和返回应用程序的整个状态。然后我们可以导入我们的process_input
函数,该函数使用命令处理输入。在这个阶段,查看导入项,你能想到执行编辑所需的步骤吗?在继续之前先思考一下。路径就像我们处理get
视图时做的那样;然而,我们必须用新的更新项目更新状态。我们还必须记住,如果传递了edit
命令,我们的process_input
函数将编辑待办事项。
深思熟虑后,请记住,解决问题有许多方法。如果你的步骤解决了问题,那么即使它与设定的步骤不同,也不要感到难过。你可能还会提出一个更好的解决方案。我们的编辑
视图包括以下步骤:
-
获取整个应用程序中待办事项的状态。
-
检查项目是否存在,如果不存在则返回一个
未找到
响应。 -
将数据通过
to_do_factory
工厂传递,以从状态构建现有数据到一个我们可以操作的项目。 -
确认即将设置的状态与现有状态不同。
-
将现有项目传递给带有
edit
命令的process_input
函数,以便将其保存到 JSON 状态文件中。 -
获取应用程序的状态并返回它。
在这些步骤的指导下,我们可以具体化从请求体中提取 JSON 并用于编辑的知识。
从请求体中提取 JSON
现在我们已经完成了导入和概述的定义,我们可以使用以下代码定义视图的概述:
pub async fn edit(to_do_item: web::Json<ToDoItem>)
-> HttpResponse {
. . .
}
在前面的代码中,我们可以看到,我们的ToDoItem
结构被web::Json
结构包裹。这意味着参数to_do_item
将从请求体中提取,序列化,并构建为ToDoItem
结构。因此,在我们的视图中,我们的to_do_item
是一个ToDoItem
结构。因此,在我们的视图中,我们可以使用以下代码加载我们的状态:
let state: Map<String, Value> = read_file("./state.json");
然后,我们可以使用以下代码从我们的状态中提取项目数据:
let status: TaskStatus;
match &state.get(&to_do_item.title) {
Some(result) => {
status = TaskStatus::new(result.as_str().unwrap());
}
None=> {
return HttpResponse::NotFound().json(
format!("{} not in state", &to_do_item.title))
}
}
在前面的代码中,我们可以看到,我们可以从数据中构建状态,或者在未找到时返回一个未找到
的 HTTP 响应。然后我们需要使用以下代码使用现有数据构建项目结构:
let existing_item = to_do_factory(to_do_item.title.as_str(),
status.clone());
在前面的代码中,我们可以看到为什么我们的工厂派上用场。现在,我们需要比较项目和现有状态的新旧状态。如果期望的状态与以下代码相同,那么改变状态就没有意义:
if &status.stringify() == &TaskStatus::from_string(
&to_do_item.status.as_str()
.to_string()).stringify() {
return HttpResponse::Ok().json(ToDoItems::get_state())
}
因此,我们需要检查当前状态,如果它与期望的状态相同,我们只需返回一个Ok
HTTP 响应状态。我们这样做是因为前端客户端可能已经不同步。在下一章中,我们将编写前端代码,我们将看到项目将被缓存并渲染。如果我们假设另一个标签页中打开了我们的应用程序,或者我们在手机等其他设备上更新了我们的待办事项应用程序,那么发起这个请求的客户端可能已经不同步。我们不希望根据一个不同步的前端执行命令。然后我们需要通过编辑输入并返回以下代码的状态来处理输入:
process_input(existing_item, "edit".to_owned(), &state);
return HttpResponse::Ok().json(ToDoItems::get_state())
前面的代码应该能正常工作,但现在却不行。这是因为我们需要克隆我们的TaskStatus
枚举,而我们的TaskStatus
没有实现Clone
特质。这可以通过在src/to_do/enums.rs
文件中使用以下代码进行更新:
#[derive(Clone)]
pub enum TaskStatus {
DONE,
PENDING
}
我们必须确保edit
视图在to-do
视图工厂中可用且已定义。因此,在src/views/to_do/mod.rs
文件中,我们的工厂应该看起来像以下这样:
mod create;
mod get;
mod edit;
use actix_web::web::{ServiceConfig, post, get, scope};
pub fn to_do_views_factory(app: &mut ServiceConfig) {
app.service(
scope("v1/item")
.route("create/{title}", post().to(create::create))
.route("get", get().to(get::get))
.route("edit", post().to(edit::edit))
);
}
我们可以看到我们的视图工厂运行得很好。我们还可以退后一步,欣赏到所有我们的待办事项视图都定义在一个隔离的页面上,这意味着我们可以简单地查看前面的代码,并知道我们还需要一个delete
视图。我们现在可以运行我们的应用程序,并在 Postman 中使用以下配置发起请求:
图 4.8 – 使用 Postman 编辑请求
在图 4.8中,我们可以看到我们将洗衣任务切换到了"DONE"
状态。我们将这些数据以原始格式放入了正文,格式为 JSON。如果我们向edit
端点发起这个调用,我们将得到以下响应:
{
"pending_items": [
{
"title": "learn to code rust",
"status": "PENDING"
}
],
"done_items": [
{
"title": "washing",
"status": "DONE"
}
],
"pending_item_count": 1,
"done_item_count": 1
}
在前面的代码中,我们可以看到完成项列表现在已经被填充,计数已经被更改。如果我们继续发起相同的调用,我们将得到相同的响应,因为我们正在将washing
项编辑为已完成的done
状态,而它已经处于完成状态。我们将不得不将washing
的状态切换回pending
,或者更改我们调用中的标题以获取不同的更新状态。如果我们不在我们调用的正文中包含title
和status
,那么我们将立即得到一个错误的请求响应,因为ToDoItem
结构体期望这两个字段。
现在我们已经将接收和返回 URL 参数和正文中的 JSON 数据的过程锁定,我们几乎完成了。然而,我们还有一个重要的方法需要介绍,它是用于数据提取的 – 头部。头部用于存储元信息,例如安全凭证。
从请求中提取头部数据
如果我们需要授权一系列请求;将它们全部放入我们的 JSON 结构体中是不可扩展的。我们还必须承认请求体可能很大,特别是如果请求者是恶意的话。因此,在将请求传递到视图之前访问安全凭证是有意义的。这可以通过拦截请求,通常称为中间件来实现。一旦我们拦截了请求,我们就可以访问安全凭证,检查它们,然后处理视图。
在本书的前一版中,我们手动开发了我们的中间件用于认证。然而,这在代码管理方面不可扩展,并且不允许灵活性。然而,了解如何手动配置自己的中间件以更好地理解服务器构造函数的工作方式,并赋予我们处理请求的灵活性是很重要的。为了拦截我们的请求,我们需要添加 actix-service
包。通过这个安装,我们的 Cargo.toml
文件依赖项应该看起来像以下定义:
[dependencies]
actix-web = "4.0.1"
serde_json = "1.0.59"
serde = { version = "1.0.136", features = ["derive"] }
actix-service = "2.0.2"
现在,我们可以更新我们的 src/main.rs
文件。首先,我们的导入应该如下所示:
use actix_web::{App, HttpServer};
use actix_service::Service;
mod views;
mod to_do;
mod state;
mod processes;
mod json_serialization;
现在所有导入都已完成,我们可以定义我们的服务器构造函数,如下所示:
#[actix_web::main]
async fn main() -> std::io::Result<()> {
HttpServer::new(|| {
let app = App::new()
.wrap_fn(|req, srv|{
println!("{:?}", req);
let future = srv.call(req);
async {
let result = future.await?;
Ok(result)
}
}).configure(views::views_factory);
return app
})
.bind("127.0.0.1:8000")?
.run()
.await
}
在前面的代码中,我们可以看到 wrap_fn
允许我们与请求(req
)进行交互。当需要传递请求时,可以调用服务路由(srv
)。我们必须注意,调用路由服务是一个未来,我们随后在 async
代码块中等待其完成,并返回结果。这是中间件。我们可以操纵我们的请求,检查它,并在调用路由服务处理 HTTP 请求之前重新路由或返回它。对我们来说,我们只是打印出请求的调试信息,如下所示:
ServiceRequest HTTP/1.1 GET:/v1/item/get
headers:
"accept-language": "en-GB,en-US;q=0.9,en;q=0.8"
"accept": "text/html,application/xhtml+xml,application/xml;
q=0.9,image/avif,image/webp,image/ apng,*/*;q=0.8,application
signed-exchange;v=b3;q=0.9"
"sec-ch-ua-platform": "\"macOS\""
"sec-fetch-site": "none"
. . .
"host": "127.0.0.1:8000"
"connection": "keep-alive"
"sec-fetch-user": "?1"
我们可以看到我们有大量的数据可以处理。但这就是我们自制的中间件所能达到的。现在,我们将研究如何使用特质从头部提取数据。
使用特质简化头部提取
在我们这样做之前,我们必须安装 futures 包,将以下内容添加到 Cargo.toml
文件的依赖项部分:
futures = "0.3.21"
我们现在将创建一个名为 src/jwt.rs
的文件来存放我们的 src/jwt.rs
文件,并包含以下代码:
use actix_web::dev::Payload;
use actix_web::{Error, FromRequest, HttpRequest};
use futures::future::{Ready, ok};
Payload
结构体包含了请求的原始数据流。然后我们有 FromRequest
特质,这是我们将要实现以在数据到达视图之前提取数据的特质。然后我们使用 futures 的 Ready
和 ok
来包装数据提取的结果,创建一个立即准备好并带有成功值的未来,这个值就是我们的头部提取值。现在我们已经导入了所需的内容,我们可以使用以下代码定义我们的 JWT 结构体:
pub struct JwToken {
pub message: String
}
目前,我们只有一个消息,但在未来,我们将添加用户 ID 等字段。有了这个结构体,我们可以使用以下代码实现 FromRequest
特质:
impl FromRequest for JwToken {
type Error = Error;
type Future = Ready<Result<JwToken, Error>>;
fn from_request(req: &HttpRequest, _: &mut Payload)
-> Self::Future {
. . .
}
}
我们可以推断出,在视图加载之前,from_request
函数被调用。我们正在提取标题,这就是为什么我们对有效载荷不感兴趣。因此,我们用 _
标记该参数。我们需要定义 Future
类型,它是一个包含结果的就绪未来,这个结果可以是我们的 JwToken
结构体或一个错误。在 from_request
函数内部,我们可以使用以下代码从标题中提取数据:
match req.headers().get("token") {
Some(data) => {
let token = JwToken{
message: data.to_str().unwrap().to_string()
};
ok(token)
},
None => {
let token = JwToken{
message: String::from("nothing found")
};
ok(token)
}
}
在前面的代码中,我们可以看到,对于本章,我们只查找 token
键,如果它存在,我们就返回带有消息的 JwToken
结构体。如果没有,我们将返回不带任何内容的 JwToken
结构体。由于本章侧重于数据,所以我们在这里停止,但在 第七章 管理用户会话 中,我们将重新审视这个函数,并探讨诸如抛出错误和返回带有未经授权代码的请求等概念。现在,我们必须通过在 src/main.rs
文件中定义以下行代码来使我们的 JwToken
结构体可访问:
mod jwt;
现在我们已经费尽周折地实现了特质,使用它将会紧凑且简单。让我们回顾一下 views/to_do/edit.rs
文件中的 edit
视图,导入我们的 JwToken
结构体,将 JwToken
结构体添加到 edit
视图的参数中,并打印出以下代码中的消息:
use crate::jwt::JwToken;
pub async fn edit(to_do_item: web::Json<ToDoItem>,
token: JwToken) -> HttpResponse {
println!("here is the message in the token: {}",
token.message);
. . .
显然,我们不想编辑视图的其余部分,但正如我们可以从前面的代码中推断出的,token
参数是构造的 JwToken
结构体,它已经从 HTTP 请求中提取出来,并且可以像 ToDoItem
结构体一样使用。如果我们现在在运行服务器后进行相同的编辑 HTTP 调用,我们会看到 HTTP 请求被打印出来,但我们也会得到以下响应:
here is the message in the token: nothing found
看起来它正在正常工作,因为我们还没有在标题中添加任何内容。我们可以使用以下图中定义的 Postman 设置将令牌添加到我们的标题中:
图 4.9 – Postman 中的带有标题的编辑请求
如果我们再次发送请求,我们会得到以下输出:
here is the message in the token: "hello from header"
就这样!我们可以通过标题传递数据。更重要的是,在视图中添加和移除它们就像在参数中定义它们并移除它们一样简单。
摘要
在本章中,我们将之前章节中学到的所有知识都付诸实践。我们将待办事项工厂的逻辑融合进来,该工厂从 JSON 文件中加载和保存待办事项,并使用 Actix-web
的基本视图来查看待办事项的处理逻辑。通过这种方式,我们能够看到隔离的模块是如何协同工作的。在接下来的几章中,我们将继续从这个方法中获益,当我们移除加载和保存数据库的 JSON 文件时。
我们还成功地利用了 serde
包来序列化复杂的数据结构。这允许我们的用户在编辑时获得完整的州更新返回。我们还基于我们对未来、async
块和闭包的知识来拦截请求,在它们到达视图之前。现在,我们可以看到 Rust 的力量使我们能够对我们的服务器进行高度自定义,而无需深入框架。
因此,Rust 在 Web 开发中有着光明的未来。尽管它还处于起步阶段,但我们可以用很少或没有代码来启动和运行。通过几行代码和一个闭包,我们正在构建自己的中间件。我们的 JSON 序列化 struct
只需要一行代码就能实现,Actix
提供的特性能使我们只需在视图函数中定义参数,从而使得视图能够自动从体中提取数据并将其序列化到 struct
中。这种可扩展、强大且标准化的数据传递方式比许多高级语言更简洁。我们现在可以完全交互和检查 HTTP 请求的每个部分。
现在我们正在处理和向用户返回结构良好的数据,我们可以开始以交互式方式显示它,让用户在编辑、创建和删除待办事项时可以点击。
在下一章中,我们将从 Actix-web
服务器中提供 HTML、CSS 和 JavaScript。这将使我们能够通过图形用户界面查看和交互待办事项,JavaScript 将对我们在本章中定义的端点进行 API 调用。
问题
-
GET
请求和POST
请求之间的区别是什么? -
为什么我们在检查凭证时会有中间件?
-
你如何使自定义的
struct
能够在视图中直接返回? -
你如何为服务器实现中间件?
-
你如何使自定义
struct
将数据序列化到视图中?
答案
-
GET
请求可以被缓存,并且对可以发送的数据类型和数量有限制。POST
请求有一个体,这允许传输更多的数据。此外,它不能被缓存。 -
我们使用中间件在发送请求到目标视图之前打开头部并检查凭证。这给了我们一个机会,在加载视图之前返回一个
auth
错误,从而防止加载可能有害的体。 -
要使
struct
能够直接返回,我们必须实现Responder
特性。在实现过程中,我们必须定义responded_to
函数,该函数接受 HTTP 请求struct
。当struct
返回时,responded_to
将被触发。 -
为了实现中间件,我们在
App
结构体上实现wrap_fn
函数。在wrap_fn
函数中,我们传递一个闭包,该闭包接受服务请求和路由结构体。 -
我们使用
#[derive(Deserialize)]
宏来装饰结构体。一旦完成这个操作,我们定义要包裹在 JSON 结构体中的参数类型:parameter: web::Json<ToDoItem>
。
第五章:在浏览器中显示内容
我们现在已经到了可以构建能够管理不同方法和数据的 HTTP 请求的 Web 应用程序的阶段。这对于我们构建微服务服务器特别有用。然而,我们还想让非程序员能够与我们的应用程序交互以使用它。为了使非程序员能够使用我们的应用程序,我们必须创建一个图形用户界面。但是,必须注意的是,本章并不包含很多 Rust。这是因为其他语言存在用于渲染图形用户界面。我们将主要使用 HTML、JavaScript 和 CSS。这些工具成熟且广泛用于前端 Web 开发。虽然我个人非常喜欢 Rust(否则我不会写关于它的书),但我们必须根据需要使用合适的工具。在撰写本书时,我们可以使用 Yew 框架在 Rust 中构建前端应用程序。然而,能够将更成熟的工具融合到我们的 Rust 技术栈中是一种更有价值的技能。
本章将涵盖以下主题:
-
使用 Rust 提供 HTML、CSS 和 JavaScript
-
构建一个连接到 Rust 服务器的 React 应用程序
-
将我们的 React 应用程序转换为可在计算机上安装的桌面应用程序
在上一版(Rust Web Programming: A hands-on guide to developing fast and secure web apps with the Rust programming language)中,我们只是直接从 Rust 中提供前端资源。然而,由于反馈和修订,这种方法扩展性不佳,导致大量重复。直接由 Rust 提供的原始 HTML、CSS 和 JavaScript 也容易出错,因为这种方法使用的是非结构化方式,这就是为什么在第二版中,我们将涵盖 React,并提供使用 Rust 直接提供前端资源的简要介绍。到本章结束时,你将能够编写基本的图形用户界面,无需任何依赖项,并理解低依赖前端解决方案与 React 等完整前端框架之间的权衡。你不仅会了解何时使用它们,而且还能根据项目需要实施这两种方法。因此,你将能够根据需要选择合适的工具,并使用 Rust 作为后端和 JavaScript 作为前端构建端到端的产品。
技术要求
我们将在上一章中创建的服务器代码的基础上进行构建,该代码可以在github.com/PacktPublishing/Rust-Web-Programming-2nd-Edition/tree/main/chapter04/extracting_data_from_views/web_app
找到。
本章的代码可以在github.com/PacktPublishing/Rust-Web-Programming-2nd-Edition/tree/main/chapter05
找到。
我们还将使用 Node.js 来运行我们的 React 应用程序。可以通过执行 docs.npmjs.com/downloading-and-installing-node-js-and-npm
中概述的步骤来安装 Node 和 npm。
使用 Rust 服务 HTML、CSS 和 JavaScript
在上一章中,我们将所有数据以 JSON 格式返回。在本节中,我们将返回 HTML 数据供用户查看。在这份 HTML 数据中,我们将包含按钮和表单,使用户能够与我们在上一章中定义的 API 端点进行交互,以创建、编辑和删除待办事项。为此,我们需要构建自己的 app
视图模块,其结构如下:
views
├── app
│ ├── items.rs
│ └── mod.rs
服务基本 HTML
在我们的 items.rs
文件中,我们将定义显示待办事项的主要视图。然而,在我们这样做之前,我们应该探索在 items.rs
文件中返回 HTML 的最简单方法:
use actix_web::HttpResponse;
pub async fn items() -> HttpResponse {
HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body("<h1>Items</h1>")
}
在这里,我们只是返回一个具有 HTML 内容类型和 <h1>Items</h1>
体的 HttpResponse
结构。要将 HttpResponse
传递到应用中,我们必须在 app/views/mod.rs
文件中定义我们的工厂,如下所示:
use actix_web::web;
mod items;
pub fn app_views_factory(app: &mut web::ServiceConfig) {
app.route("/", web::get().to(items::items));
}
在这里,我们可以看到,我们不是构建一个服务,而是仅仅为我们的应用程序定义了一个 route
。这是因为这是着陆页。如果我们打算定义一个 service
而不是 route
,则无法在没有前缀的情况下定义服务的视图。
一旦我们定义了 app_views_factory
,我们就可以在我们的 views/mod.rs
文件中调用它。然而,首先,我们必须在 views/mod.rs
文件的顶部定义应用模块:
mod app;
一旦我们定义了 app
模块,我们就可以在同一个文件中的 views_factory
函数中调用应用工厂:
app::app_views_factory(app);
现在,我们的 HTML 服务视图已成为我们应用的一部分,我们可以运行它并在浏览器中调用主页 URL,得到以下输出:
图 5.1 – 首次渲染的 HTML 视图
我们可以看到我们的 HTML 已经渲染!通过我们在 图 5.1 中看到的内容,我们可以推断出我们可以在响应体中返回一个字符串,如下所示:
HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body("<h1>Items</h1>")
如果字符串是 HTML 格式,这将渲染 HTML。从这个发现中,你认为我们如何从由我们的 Rust 服务器提供的 HTML 文件中渲染 HTML?在继续之前,考虑这个问题 – 这将锻炼你的问题解决能力。
从文件中读取基本 HTML
如果我们有一个 HTML 文件,我们可以通过将那个 HTML 文件准备好作为字符串并将其插入到 HttpResponse
的体中来实现渲染。是的,就这么简单。为了实现这一点,我们将构建一个内容加载器。
要构建一个基本的内容加载器,首先在 views/app/content_loader.rs
文件中构建一个读取 HTML 文件的函数:
use std::fs;
pub fn read_file(file_path: &str) -> String {
let data: String = fs::read_to_string(
file_path).expect("Unable to read file");
return data
}
我们在这里需要做的只是返回一个字符串,因为这是我们需要的响应体。然后,我们必须在 views/app/mod.rs
文件中定义加载器,并在文件的顶部使用 mod content_loader;
行。
现在我们已经有了加载函数,我们需要一个 HTML 目录。这可以在名为 templates
的 src
目录旁边定义。在 templates
目录内部,我们可以添加一个名为 templates/main.html
的 HTML 文件,其内容如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charSet="UTF-8"/>
<meta name="viewport"
content="width=device-width, initial-
scale=1.0"/>
<meta httpEquiv="X-UA-Compatible"
content="ie=edge"/>
<meta name="description"
content="This is a simple to do app"/>
<title>To Do App</title>
</head>
<body>
<h1>To Do Items</h1>
</body>
</html>
在这里,我们可以看到我们的 body
标签具有与我们之前展示相同的内容 – 即 <h1>待办事项</h1>
。然后,我们有一个 head
标签,它定义了一系列元标签。我们可以看到我们定义了 viewport
。这告诉浏览器如何处理页面内容的尺寸和缩放。缩放很重要,因为我们的应用程序可能被各种不同的设备和屏幕尺寸访问。使用这个视口,我们可以将页面的宽度设置为与设备屏幕相同的宽度。然后,我们可以将我们访问的页面的初始缩放设置为 1.0
。接下来是 httpEquiv
标签,我们将其设置为 X-UA-Compatible
,这意味着我们支持旧版浏览器。最后一个标签只是对页面的描述,可以被搜索引擎使用。我们的 title
标签确保在浏览器标签上显示 to do app
。有了这个,我们在 body
中就有了标准的标题。
从文件中服务基本的 HTML
现在我们已经定义了我们的 HTML 文件,我们必须加载并服务它。回到我们的 src/views/app/items.rs
文件,我们必须加载 HTML 文件并使用以下代码来服务它:
use actix_web::HttpResponse;
use super::content_loader::read_file;
pub async fn items() -> HttpResponse {
let html_data = read_file(
"./templates/main.html");
HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(html_data)
}
如果我们运行我们的应用程序,我们将得到以下输出:
图 5.2 – 加载 HTML 页面的视图
在 图 5.2 中,我们可以看到我们得到了与之前相同的结果。这并不令人惊讶;然而,我们必须注意到 图 5.2 中的标签现在显示为 To Do App,这意味着我们 HTML 文件中的元数据正在被加载到视图中。没有任何东西阻止我们充分利用 HTML 文件。现在我们的 HTML 文件正在被服务,我们可以继续我们的下一个目标,即在页面上添加功能。
将 JavaScript 添加到 HTML 文件中
如果用户无法对我们的待办事项状态进行任何操作,这对前端用户来说将没有用处。在我们修改之前,我们需要通过查看以下图表来了解 HTML 文件的布局:
图 5.3 – HTML 文件的一般布局
在这里,在 图 5**.3 中,我们可以看到我们可以在头部定义元标签。然而,我们也可以在头部定义样式标签。在头部下面的样式标签中,我们可以将 CSS 插入到样式标签中。在主体下面,还有一个脚本部分,我们可以在这里注入 JavaScript。这个 JavaScript 在浏览器中运行并与主体中的元素交互。有了这个,我们可以看到,提供带有 CSS 和 JavaScript 的 HTML 文件可以提供一个完全功能的单页应用程序前端。有了这个,我们可以反思本章的介绍。虽然我喜欢 Rust 并强烈建议你用 Rust 编写一切,但这对于软件工程中的任何语言来说都不是一个好主意。我们现在可以轻松地使用 JavaScript 提供功能性的前端视图,这使得它成为你前端需求的最佳选择。
使用 JavaScript 与我们的服务器进行通信
现在我们知道了在哪里将 JavaScript 插入我们的 HTML 文件中,我们可以测试我们的定位。在本节的剩余部分,我们将在 HTML 主体中创建一个按钮,将其与一个 JavaScript 函数连接起来,然后当按下该按钮时,让浏览器打印出一个带有输入信息的警告。这对我们的后端应用程序没有任何影响,但它将证明我们对 HTML 文件的了解是正确的。我们可以在我们的 templates/main.html
文件中添加以下代码:
<body>
<h1>To Do Items</h1>
<input type="text" id="name" placeholder="create to do
item">
<button id="create-button" value="Send">Create</button>
</body>
<script>
let createButton = document.getElementById("create-
button");
createButton.addEventListener("click", postAlert);
function postAlert() {
let titleInput = document.getElementById("name");
alert(titleInput.value);
titleInput.value = null;
}
</script>
在我们的主体部分,我们可以看到我们定义了一个 input
和一个 button
。我们给 input
和 button
属性分配了唯一的 ID 名称。然后,我们使用 button
的 ID 添加一个事件监听器。之后,我们将我们的 postAlert
函数绑定到该事件监听器,以便在点击 button
时触发。当我们调用 postAlert
函数时,我们通过其 ID 获取 input
并在警告中打印出 input
的值。然后,我们将 input
的值设置为 null
,以便用户可以填写另一个要处理的值。在 input
中输入 testing
并点击按钮后,将产生以下输出:
图 5.4 – 当连接到 JavaScript 中的警告时点击按钮的效果
我们的 JavaScript 不必只限于在主体中让元素交互。我们还可以使用 JavaScript 对后端 Rust 应用程序执行 API 调用。然而,在我们匆忙编写整个应用程序到main.html
文件之前,我们必须停下来思考。如果我们那样做,main.html
文件会膨胀成一个巨大的文件。这将很难调试。此外,这可能导致代码重复。如果我们想在其他视图中使用相同的 JavaScript 呢?我们就必须将其复制粘贴到另一个 HTML 文件中。这不会很好地扩展,如果我们需要更新一个函数,我们可能会忘记更新一些重复的函数。这就是 JavaScript 框架(如 React)派上用场的地方。我们将在本章后面探索 React,但现在,我们将通过找到一种方法来分离我们的 JavaScript 和 HTML 文件,来完成我们的低依赖性前端。
必须警告的是,我们实际上是在实时手动重写 HTML,使用 JavaScript。人们可能会将这描述为一种“黑客式”解决方案。然而,在探索 React 之前,了解我们的方法非常重要,这样我们才能真正欣赏不同方法的好处。在我们进入下一部分之前,我们确实需要对src/views/to_do/create.rs
文件中的create
视图进行重构。这是一个回顾我们在前几章所开发内容的好机会。你必须基本上将create
视图转换为返回当前待办事项的状态,而不是一个字符串。一旦你尝试了这一点,解决方案应该如下所示:
use actix_web::HttpResponse;
use serde_json::Value;
use serde_json::Map;
use actix_web::HttpRequest;
use crate::to_do::{to_do_factory, enums::TaskStatus};
use crate::json_serialization::to_do_items::ToDoItems;
use crate::state::read_file;
use crate::processes::process_input;
pub async fn create(req: HttpRequest) -> HttpResponse {
let state: Map<String, Value> =
read_file("./state.json");
let title: String = req.match_info().get("title"
).unwrap().to_string();
let item = to_do_factory(&title.as_str(),
TaskStatus::PENDING);
process_input(item, "create".to_string(), &state);
return HttpResponse::Ok().json(ToDoItems::get_state())
}
现在,所有我们的待办事项都已更新并正常工作。我们现在可以进入下一部分,我们将让前端调用我们的后端。
将 JavaScript 注入 HTML
一旦我们完成这一部分,我们将拥有一个不太美观但功能齐全的主视图,我们可以使用 JavaScript 调用我们的 Rust 服务器来添加、编辑和删除待办事项。然而,如您所回忆的那样,我们没有添加delete
API 端点。为了将 JavaScript 注入我们的 HTML,我们必须执行以下步骤:
-
创建一个
delete
项目 API 端点。 -
添加一个
JavaScript 加载
函数,并用加载的 JavaScript 数据替换 HTML 数据中的 JavaScript 标签。 -
在 HTML 文件中添加一个 JavaScript 标签,并为 HTML 组件添加 ID,这样我们就可以在 JavaScript 中引用这些组件。
-
为我们的待办事项在 JavaScript 中构建一个
渲染
函数,并通过 ID 将其绑定到 HTML 上。 -
在 JavaScript 中构建一个
API 调用
函数,以便与后端通信。 -
为我们的按钮构建
get
、delete
、edit
和create
函数。
让我们详细看看这一点。
添加删除端点
添加delete
API 端点现在应该很简单。如果你想的话,建议你自己尝试实现这个视图,因为你现在应该对这个过程感到很舒服:
-
如果你感到困难,我们可以通过将以下第三方依赖项导入到
views/to_do/delete.rs
文件中来实现这一点:use actix_web::{web, HttpResponse};
use serde_json::value::Value;
use serde_json::Map;
这些都不是新的,你应该熟悉它们,并知道我们需要在哪里使用它们。
-
然后,我们必须使用以下代码导入我们的结构和函数:
use crate::to_do::{to_do_factory, enums::TaskStatus};
use crate::json_serialization::{to_do_item::ToDoItem,
to_do_items::ToDoItems};
use crate::processes::process_input;
use crate::jwt::JwToken;
use crate::state::read_file;
在这里,我们可以看到我们正在使用我们的 to_do
模块来构建我们的待办事项。通过我们的 json_serialization
模块,我们可以看到我们正在接受 ToDoItem
并返回 ToDoItems
。然后,我们使用 process_input
函数执行项目的删除。我们也不希望任何可以访问我们页面的用户删除我们的项目。因此,我们需要我们的 JwToken
结构体。最后,我们使用 read_file
函数读取我们项目的状态。
-
现在我们已经拥有了所有需要的东西,我们可以使用以下代码定义我们的
delete
视图:pub async fn delete(to_do_item: web::Json<ToDoItem>,
token: JwToken) -> HttpResponse {
. . .
}
在这里,我们可以看到我们接受了 ToDoItem
作为 JSON,并且我们为视图附加了 JwToken
,这样用户必须经过授权才能访问它。到目前为止,我们只有 JwToken
附加了一个消息;我们将在 第七章,管理用户会话 中管理 JwToken
的认证逻辑。
-
在我们的
delete
视图中,我们可以通过以下代码读取我们的 JSON 文件来获取我们的待办事项的状态:let state: Map<String, Value> = read_file("./state.json");
-
然后,我们可以检查具有此标题的项目是否在状态中。如果不是,则返回一个未找到的 HTTP 响应。如果是,我们则传递状态,因为我们需要标题和状态来构建项目。我们可以通过以下代码进行此检查和状态提取:
let status: TaskStatus;
match &state.get(&to_do_item.title) {
Some(result) => {
status = TaskStatus::from_string
(result.as_str().unwrap().to_string() );
}
None=> {
return HttpResponse::NotFound().json(
format!("{} not in state",
&to_do_item.title))
}
}
-
现在我们已经获得了待办事项的状态和标题,我们可以构建我们的项目并通过我们的
process_input
函数传递一个delete
命令。这将从 JSON 文件中删除我们的项目:let existing_item = to_do_factory(to_do_item.title.as_ str(),
status.clone());
process_input(existing_item, "delete". to_owned(),
&state);
-
记住,我们为我们的
ToDoItems
结构体实现了Responder
特性,并且我们的ToDoItems::get_state()
函数返回一个用 JSON 文件中的条目填充的ToDoItems
结构体。因此,我们可以从我们的delete
视图中得到以下返回语句:return HttpResponse::Ok().json(ToDoItems::get_state())
-
现在,我们的
delete
视图已经定义好了,我们可以将其添加到我们的src/views/to_do/mod.rs
文件中,从而得到如下所示的观点工厂:mod create;
mod get;
mod edit;
mod delete;
use actix_web::web::{ServiceConfig, post, get, scope};
pub fn to_do_views_factory(app: &mut ServiceConfig) {
app.service(
scope("v1/item")
.route("create/{title}",
post().to(create::create))
.route("get", get().to(get::get))
.route("edit", post().to(edit::edit))
.route("delete", post().to(delete::delete))
);
}
-
通过快速检查
to_do_views_factory
,我们可以看到我们拥有管理我们的待办事项所需的所有视图。如果我们将这个模块从我们的应用程序中移除并插入到另一个中,我们会立即看到我们正在删除和添加的内容。
我们的 delete
视图已经完全集成到我们的应用程序中,我们可以继续进行第二步,即构建我们的 JavaScript 加载功能。
添加 JavaScript 加载函数
现在我们所有的端点都已经准备好了,我们必须重新审视我们的主应用视图。在上一个部分中,我们确定了 <script>
部分的 JavaScript 即使它只是一个大字符串的一部分也能正常工作。为了使我们能够将我们的 JavaScript 放入一个单独的文件中,我们的视图将加载一个包含 <script>
部分中有 {{JAVASCRIPT}}
标签的 HTML 文件作为字符串。然后,我们将加载 JavaScript 文件作为字符串,并用 JavaScript 文件中的字符串替换 {{JAVASCRIPT}}
标签。最后,我们在 views/app/items.rs
文件中的 body 中返回完整的字符串:
pub async fn items() -> HttpResponse {
let mut html_data = read_file(
"./templates/main.html");
let javascript_data = read_file(
"./javascript/main.js");
html_data = html_data.replace("{{JAVASCRIPT}}",
&javascript_data);
HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(html_data)
}
在 HTML 中添加 JavaScript 标签
从我们上一步的 items
函数中,我们可以看到我们需要在根目录下构建一个新的目录,名为 JavaScript
。我们还必须在其中创建一个名为 main.js
的文件。随着我们对应用视图的此更改,我们还需要更改 templates/main.html
文件,并添加以下代码:
<body>
<h1>Done Items</h1>
<div id="doneItems"></div>
<h1>To Do Items</h1>
<div id="pendingItems"></div>
<input type="text" id="name" placeholder="create to do
item">
<button id="create-button" value="Send">Create</button>
</body>
<script>
{{JAVASCRIPT}}
</script>
请记住,我们的端点返回待办事项和已完成事项。因此,我们为这两个列表分别定义了各自的标题。ID 为 "doneItems"
的 div
是我们将从 API 调用中插入已完成待办事项的地方。
然后,我们将从 API 调用中插入我们的待办事项到 ID 为 "pendingItems"
的 div
中。之后,我们必须定义一个带有文本和按钮的输入。这将用于用户创建新的项目。
构建渲染 JavaScript 函数
现在我们已经定义了 HTML,我们将在 javascript/main.js
文件中定义逻辑:
-
我们将要构建的第一个函数将渲染我们主页上的所有待办事项。必须注意的是,这是
javascript/main.js
文件中代码最复杂的一部分。我们实际上正在编写 JavaScript 代码来编写 HTML 代码。稍后,在 创建 React 应用 部分,我们将使用 React 框架来替换这一需求。现在,我们将构建一个渲染函数来创建一个项目列表。每个项目在 HTML 中的形式如下:<div>
<div>
<p>learn to code rust</p>
<button id="edit-learn-to-code-rust">
edit
</button>
</div>
</div>
我们可以看到待办事项的标题嵌套在一个段落 HTML 标签中。然后,我们有一个按钮。请记住,HTML 标签的 id
属性必须是唯一的。因此,我们根据按钮将要执行的操作和待办事项的标题来构建这个 ID。这将使我们能够通过事件监听器将这些 id
属性绑定到执行 API 调用的函数上。
-
要构建我们的渲染函数,我们必须传递要渲染的项目、将要执行的处理类型(即
edit
或delete
)、在 HTML 中将要渲染这些项目的部分元素 ID,以及我们将绑定到每个待办事项按钮上的函数。此函数的轮廓在以下代码中定义:function renderItems(items, processType,
elementId, processFunction) {
. . .
}
-
在我们的
renderItems
函数内部,我们可以从构造 HTML 并使用以下代码遍历我们的待办事项开始:let itemsMeta = [];
let placeholder = "<div>"
for (let i = 0; i < items.length; i++) {
. . .
}
placeholder += "</div>"
document.getElementById(elementId).innerHTML =
placeholder;
在这里,我们定义了一个数组,它将收集我们为每个待办事项生成的待办事项 HTML 的元数据。这是在itemsMeta
变量下,将在renderItems
函数中稍后使用,以使用事件监听器将processFunction
绑定到每个待办事项按钮。然后,我们在placeholder
变量下定义了包含所有待办事项的 HTML。在这里,我们从一个div
标签开始。然后,我们遍历项目,将每个项目的数据转换为 HTML,然后用关闭的div
标签结束 HTML。之后,我们将构建的 HTML 字符串placeholder
插入到innerHTML
中。innerHTML
在页面上的位置就是我们想要看到我们构建的待办事项的地方。
-
在循环内部,我们必须使用以下代码构建单个待办事项 HTML:
let title = items[i]["title"];
let placeholderId = processType +
"-" + title.replaceAll(" ", "-");
placeholder += "<div>" + title +
"<button " + 'id="' + placeholderId + '">'
+ processType +
'</button>' + "</div>";
itemsMeta.push({"id": placeholderId, "title": title});
在这里,我们从正在遍历的项目中提取项目的标题。然后,我们定义我们将要用于绑定事件监听器的项目 ID。注意,我们用-
替换了所有空格。现在我们已经定义了标题和 ID,我们在placeholder
HTML 字符串中添加了一个带有标题的div
。我们还添加了一个带有placeholderId
的button
,然后用div
结束。我们可以看到我们的 HTML 字符串添加以;
结束。然后,我们将placeholderId
和title
添加到itemsMeta
数组中,以备后用。
-
接下来,我们遍历
itemsMeta
,使用以下代码创建事件监听器:. . .
placeholder += "</div>"
document.getElementById(elementId).innerHTML
= placeholder;
for (let i = 0; i < itemsMeta.length; i++) {
document.getElementById(
itemsMeta[i]["id"]).addEventListener(
"click", processFunction);
}
}
现在,如果点击了我们创建在待办事项旁边的按钮,processFunction
将被触发。我们的函数现在可以渲染项目,但我们需要通过 API 调用函数从我们的后端获取它们。我们现在将看看这一点。
构建 API 调用 JavaScript 函数
现在我们有了我们的渲染函数,我们可以看看我们的 API 调用函数:
-
首先,我们必须在
javascript/main.js
文件中定义我们的 API 调用函数。这个函数接受一个 URL,这是 API 调用的端点。它还接受一个方法,这是一个字符串,可以是POST
、GET
或PUT
。然后,我们必须定义我们的请求对象:function apiCall(url, method) {
let xhr = new XMLHttpRequest();
xhr.withCredentials = true;
-
然后,我们必须在
apiCall
函数内部定义事件监听器,该监听器在调用完成后渲染返回的 JSON 格式的待办事项:xhr.addEventListener('readystatechange', function() {
if (this.readyState === this.DONE) {
renderItems(JSON.parse(
this.responseText)["pending_items"],
"edit", "pendingItems", editItem);
renderItems(JSON.parse(this.responseText)
["done_items"],
"delete", "doneItems", deleteItem);
}
});
在这里,我们可以看到我们正在传递在templates/main.html
文件中定义的 ID。我们还传递了 API 调用的响应。我们还可以看到我们传递了editItem
函数,这意味着当点击一个悬而未决的项目旁边的按钮时,我们将触发一个edit
函数,将项目转换为已完成项目。考虑到这一点,如果一个已完成项目的按钮被点击,将触发deleteItem
函数。目前,我们将继续构建apiCall
函数。
- 在此之后,我们必须构建
editItem
和deleteItem
函数。我们还知道每次调用apiCall
函数时,项目都会被渲染。
现在我们已经定义了事件监听器,我们必须准备 API 调用对象的方法和 URL,定义头信息,然后返回请求对象,以便我们可以在需要时发送它:
xhr.open(method, url);
xhr.setRequestHeader('content-type',
'application/json');
xhr.setRequestHeader('user-token', 'token');
return xhr
}
现在,我们可以使用我们的apiCall
函数对应用程序的后端进行调用,并在 API 调用后重新渲染带有新项目状态的客户端。有了这个,我们可以继续到最后一步,在那里我们将定义执行创建、获取、删除和编辑待办事项功能的函数。
为按钮构建 JavaScript 函数
注意,标题只是硬编码在后端中的已接受令牌。我们将在第七章中介绍如何正确定义认证头,管理用户会话。现在,我们的 API 调用函数已经定义,我们可以继续到editItem
函数:
function editItem() {
let title = this.id.replaceAll("-", " ")
.replace("edit ", "");
let call = apiCall("/v1/item/edit", "POST");
let json = {
"title": title,
"status": "DONE"
};
call.send(JSON.stringify(json));
}
在这里,我们可以看到事件监听器所属的 HTML 部分可以通过this
来访问。我们知道,如果我们移除edit
这个词,并将-
替换为空格,它将把待办事项的 ID 转换为待办事项的标题。然后,我们利用apiCall
函数来定义我们的端点和方法。请注意,在replace
函数中的"edit "
字符串中有一个空格。我们之所以有这个空格,是因为我们必须移除编辑字符串后面的空格。如果我们不移除那个空格,它将被发送到后端,由于我们的应用程序后端在 JSON 文件中项目的标题旁边不会有空格,这将会导致错误。一旦我们的端点和 API 调用方法被定义,我们将标题传递到一个状态为完成的字典中。这是因为我们知道我们正在将待办事项从待办状态切换到完成状态。一旦完成这个操作,我们就发送带有 JSON 主体的 API 调用。
现在,我们可以使用相同的方法为deleteItem
函数:
function deleteItem() {
let title = this.id.replaceAll("-", " ")
.replace("delete ", "");
let call = apiCall("/v1/item/delete", "POST");
let json = {
"title": title,
"status": "DONE"
};
call.send(JSON.stringify(json));
}
再次强调,在replace
函数中的"delete "
字符串中有一个空格。有了这个,我们的渲染过程就完全处理完毕了。我们已经定义了编辑和删除函数以及渲染函数。现在,我们必须在页面初始加载时加载项目,而无需点击任何按钮。这可以通过一个简单的 API 调用来完成:
function getItems() {
let call = apiCall("/v1/item/get", 'GET');
call.send()
}
getItems();
在这里,我们可以看到我们只是使用GET
方法进行 API 调用并发送它。同时,请注意,我们的getItems
函数是在函数外部被调用的。这将在视图加载时触发一次。
编码已经持续了一段时间;然而,我们几乎完成了。我们只需要定义创建文本输入和按钮的功能。我们可以通过一个简单的事件监听器和create
端点的 API 调用来管理这个:
document.getElementById("create-button")
.addEventListener("click", createItem);
function createItem() {
let title = document.getElementById("name");
let call = apiCall("/v1/item/create/" +
title.value, "POST");
call.send();
document.getElementById("name").value = null;
}
我们还添加了将文本输入值设置为null
的细节。我们将input
设置为null
,这样用户就可以输入另一个要创建的项目,而无需删除刚刚创建的旧项目标题。点击应用程序的主视图会给我们以下输出:
图 5.5 – 渲染后的待办事项主页面
现在,为了检查我们的前端是否按预期工作,我们可以执行以下步骤:
-
按下 washing 已完成项目旁边的 删除 按钮。
-
输入
eat cereal for breakfast
并点击 创建。 -
输入
eat ramen for breakfast
并点击 创建。 -
点击
eat ramen for
breakfast
项目。
这些步骤应该产生以下结果:
图 5.6 – 完成上述步骤后的主页面
这样,我们就拥有了一个完全功能性的网络应用程序。所有按钮都正常工作,列表会立即更新。然而,它看起来并不美观。没有间距,一切都是黑白相间的。为了修正这个问题,我们需要将 CSS 集成到 HTML 文件中,我们将在下一节中这样做。
将 CSS 注入到 HTML 中
注入 CSS 与注入 JavaScript 的方法相同。我们将在 HTML 文件中有一个 CSS 标签,它将被文件中的 CSS 替换。为了实现这一点,我们必须执行以下步骤:
-
将 CSS 标签添加到我们的 HTML 文件中。
-
为整个应用程序创建一个基础 CSS 文件。
-
为我们的主视图创建一个 CSS 文件。
-
更新我们的 Rust 包以提供 CSS 和 JavaScript。
让我们更仔细地看看这个过程。
将 CSS 标签添加到 HTML 中
首先,让我们对我们的 templates/main.html
文件做一些更改:
<style>
{{BASE_CSS}}
{{CSS}}
</style>
<body>
<div class="mainContainer">
<h1>Done Items</h1>
<div id="doneItems"></div>
<h1>To Do Items</h1>
<div id="pendingItems"></div>
<div class="inputContainer">
<input type="text" id="name"
placeholder="create to do item">
<div class="actionButton"
id="create-button"
value="Send">Create</div>
</div>
</div>
</body>
<script>
{{JAVASCRIPT}}
</script>
在这里,我们可以看到我们有两个 CSS 标签。{{BASE_CSS}}
标签用于基础 CSS,它将在多个不同的视图中保持一致,例如背景颜色和列比例,这取决于屏幕大小。{{BASE_CSS}}
标签用于管理此视图的 CSS 类。尊敬地,css/base.css
和 css/main.css
文件是为我们的视图制作的。此外,请注意,我们将所有项目都放在了一个名为 mainContainer
的 div
中。这将使我们能够将所有项目居中显示在屏幕上。我们还添加了一些额外的类,以便 CSS 可以引用它们,并将创建项目的按钮从 button
HTML 标签更改为 div
HTML 标签。一旦完成这些,我们的 javascript/main.js
文件中的 renderItems
函数的循环将会有以下更改:
function renderItems(items, processType,
elementId, processFunction) {
. . .
for (i = 0; i < items.length; i++) {
. . .
placeholder += '<div class="itemContainer">' +
'<p>' + title + '</p>' +
'<div class="actionButton" ' +
'id="' + placeholderId + '">'
+ processType + '</div>' + "</div>";
itemsMeta.push({"id": placeholderId, "title": title});
}
. . .
}
考虑到这一点,我们现在可以在 css/base.css
文件中定义我们的基础 CSS。
创建基础 CSS
现在,我们必须定义页面及其组件的样式。一个不错的开始是在 css/base.css
文件中定义页面的主体。我们可以使用以下代码对主体进行基本配置:
body {
background-color: #92a8d1;
font-family: Arial, Helvetica, sans-serif;
height: 100vh;
}
背景颜色是对一种颜色的引用。仅从外观上看,这种引用可能似乎没有意义,但在线上有很多颜色选择器,您可以在其中查看并选择颜色,并且会提供参考代码。一些代码编辑器支持此功能,但为了快速参考,只需在 Google 上搜索HTML 颜色选择器
,您就会在可用的免费在线交互式工具数量上感到眼花缭乱。根据前面的配置,整个页面的背景将有一个代码为#92a8d1
,这是一种海军蓝的颜色。如果我们只有这个颜色,页面的大部分区域将会有白色背景。海军蓝背景只会出现在有内容的地方。我们将高度设置为100vh
。vh
是相对于视口高度的 1%。据此,我们可以推断出100vh
意味着在body
中定义的样式占据了视口的 100%。然后,我们定义所有文本的字体,除非被覆盖为Arial
、Helvetica
或sans-serif
。我们可以看到我们在font-family
中定义了多个字体。这并不意味着它们都被实现,或者不同级别的标题或 HTML 标签有不同的字体。相反,这是一个后备机制。首先,浏览器会尝试渲染Arial
;如果浏览器不支持,它将尝试渲染Helvetica
,如果这也失败了,它将尝试渲染sans-serif
。
这样,我们已经为我们的body
定义了通用样式,但关于不同屏幕尺寸呢?例如,如果我们打算在手机上访问我们的应用程序,它应该有不同的尺寸。我们可以在以下图中看到这一点:
图 5.7 – 手机和桌面显示器之间的边距差异
图 5**.7显示了边距与待办事项列表填充空间的比率。在手机上,屏幕空间不多,所以大部分屏幕需要被待办事项占据;否则,我们就无法阅读它。然而,如果我们使用宽屏桌面显示器,我们就不再需要大部分屏幕来显示待办事项。如果比率相同,待办事项在X轴上会被拉伸得很难阅读,而且坦白地说,看起来也不会很好。这就是媒体查询发挥作用的地方。我们可以根据窗口的宽度和高度等属性设置不同的样式条件。我们将从手机规格开始。因此,如果屏幕宽度不超过 500 像素,在我们的css/base.css
文件中,我们必须为我们的body
定义以下 CSS 配置:
@media(max-width: 500px) {
body {
padding: 1px;
display: grid;
grid-template-columns: 1fr;
}
}
在这里,我们可以看到页面边缘和每个元素的填充仅为一个像素。我们还有一个网格显示。这是我们可以定义列和行的位置。然而,我们没有充分利用它。我们只有一个列。这意味着我们的待办事项将占据大部分屏幕,如 图 5**.7 所示。尽管在这个上下文中我们没有使用网格,但我保留了它,以便你可以看到这与更大屏幕的其他配置之间的关系。如果我们的屏幕稍微大一点,我们可以将页面分成三个不同的垂直列;然而,中间列的宽度与两侧列的宽度之比为 5:1。这是因为我们的屏幕仍然不大,我们希望项目仍然占据大部分屏幕。我们可以通过添加另一个具有不同参数的媒体查询来调整这一点:
@media(min-width: 501px) and (max-width: 550px) {
body {
padding: 1px;
display: grid;
grid-template-columns: 1fr 5fr 1fr;
}
.mainContainer {
grid-column-start: 2;
}
}
我们还可以看到,对于我们的 mainContainer
CSS 类,其中包含我们的待办事项,我们将覆盖 grid-column-start
属性。如果我们不这样做,那么 mainContainer
将在 1fr
宽度的左侧边距中挤压。相反,我们是从中间开始和结束,在 5fr
。我们可以使用 grid-column-finish
属性使 mainContainer
横跨多个列。
如果我们的屏幕变大,我们希望调整比例,因为我们不希望项目宽度失控。为了实现这一点,我们必须定义中间列与两侧列的 3:1 比率,然后在屏幕宽度高于 1001px
时,定义 1:1 比率:
@media(min-width: 551px) and (max-width: 1000px) {
body {
padding: 1px;
display: grid;
grid-template-columns: 1fr 3fr 1fr;
}
.mainContainer {
grid-column-start: 2;
}
}
@media(min-width: 1001px) {
body {
padding: 1px;
display: grid;
grid-template-columns: 1fr 1fr 1fr;
}
.mainContainer {
grid-column-start: 2;
}
}
现在我们已经为所有视图定义了我们的通用 CSS,我们可以继续在 css/main.css
文件中定义我们的视图特定 CSS。
创建主页的 CSS
现在,我们必须分解我们的应用程序组件。我们有一个待办事项列表。列表中的每个项目都将是一个具有不同背景颜色的 div
:
.itemContainer {
background: #034f84;
margin: 0.3rem;
}
我们可以看到这个类有一个 0.3 的边距。我们使用 rem
是因为我们希望边距相对于根元素的字体大小进行缩放。我们还希望当我们的光标悬停在项目上时,项目颜色略有变化:
.itemContainer:hover {
background: #034f99;
}
在项目容器内部,我们使用段落标签表示项目标题。我们希望定义项目容器中所有段落的样式,但不是其他地方的样式。我们可以使用以下代码在容器中定义段落的样式:
.itemContainer p {
color: white;
display: inline-block;
margin: 0.5rem;
margin-right: 0.4rem;
margin-left: 0.4rem;
}
inline-block
允许标题与 div
并排显示,而 div
将作为项目的按钮。边距定义仅阻止标题紧挨着项目容器的边缘。我们还确保段落颜色为白色。
在项目标题样式化后,剩下的唯一项目样式就是操作按钮,它可以是 edit
或 delete
。这个操作按钮将向右浮动,并具有不同的背景颜色,以便我们知道点击的位置。为此,我们必须使用类定义我们的按钮样式,如下面的代码所示:
.actionButton {
display: inline-block;
float: right;
background: #f7786b;
border: none;
padding: 0.5rem;
padding-left: 2rem;
padding-right: 2rem;
color: white;
}
在这里,我们定义了显示方式,将其浮动到右边,并定义了背景颜色和填充。有了这个,我们可以通过运行以下代码来确保在悬停时颜色发生变化:
.actionButton:hover {
background: #f7686b;
color: black;
}
现在我们已经涵盖了所有概念,我们必须为输入容器定义样式。这可以通过运行以下代码来完成:
.inputContainer {
background: #034f84;
margin: 0.3rem;
margin-top: 2rem;
}
.inputContainer input {
display: inline-block;
margin: 0.4rem;
}
我们做到了!我们已经定义了所有的 CSS、JavaScript 和 HTML。在我们运行应用程序之前,我们需要在主视图中加载数据。
从 Rust 中提供 CSS 和 JavaScript
我们在 views/app/items.rs
文件中提供我们的 CSS。我们通过读取 HTML、JavaScript、基础 CSS 和主要 CSS 文件来实现这一点。然后,我们将 HTML 数据中的标签替换为其他文件中的数据:
pub async fn items() -> HttpResponse {
let mut html_data = read_file(
"./templates/main.html");
let javascript_data: String = read_file(
"./javascript/main.js");
let css_data: String = read_file(
"./css/main.css");
let base_css_data: String = read_file(
"./css/base.css");
html_data = html_data.replace("{{JAVASCRIPT}}",
&javascript_data);
html_data = html_data.replace("{{CSS}}",
&css_data);
html_data = html_data.replace("{{BASE_CSS}}",
&base_css_data);
HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(html_data)
}
现在,当我们启动我们的服务器时,我们将拥有一个完全运行的、具有直观前端的应用程序,其外观将如以下截图所示:
图 5.8 – CSS 后的主页
尽管我们的应用程序正在运行,并且我们已经配置了基础 CSS 和 HTML,但我们可能想要有可重用的独立 HTML 结构,这些结构有自己的 CSS。这些结构可以根据需要注入到视图中。这样做给我们提供了编写组件一次,然后将其导入到其他 HTML 文件中的能力。这反过来又使得维护更容易,并确保组件在多个视图中的一致性。例如,如果我们创建了一个视图顶部的信息栏,我们希望它在其他视图中也有相同的样式。因此,将信息栏作为组件一次性创建并插入到其他视图中是有意义的,正如下一节所涵盖的。
组件继承
有时,我们可能想要构建一个可以注入到视图中的组件。为此,我们必须加载 CSS 和 HTML,然后将它们插入到 HTML 的正确部分。
要做到这一点,我们可以创建一个 add_component
函数,该函数接受组件名称,从组件名称创建标签,并根据组件名称加载 HTML 和 CSS。我们将在 views/app/content_loader.rs
文件中定义这个函数:
pub fn add_component(component_tag: String,
html_data: String) -> String {
let css_tag: String = component_tag.to_uppercase() +
"_CSS";
let html_tag: String = component_tag.to_uppercase() +
"_HTML";
let css_path = String::from("./templates/components/")
+ &component_tag.to_lowercase() + ".css";
let css_loaded = read_file(&css_path);
let html_path = String::from("./templates/components/")
+ &component_tag.to_lowercase() + ".html";
let html_loaded = read_file(&html_path);
let html_data = html_data.replace(html_tag.as_str(),
&html_loaded);
let html_data = html_data.replace(css_tag.as_str(),
&css_loaded);
return html_data
}
在这里,我们使用在同一个文件中定义的 read_file
函数。然后,我们将组件 HTML 和 CSS 注入到视图数据中。注意,我们将组件嵌套在 templates/components/
目录中。对于这个实例,我们正在插入一个 header
组件,所以当我们将 header
传递给 add_component
函数时,我们的 add_component
函数将尝试加载 header.html
和 header.css
文件。在我们的 templates/components/header.html
文件中,我们必须定义以下 HTML:
<div class="header">
<p>complete tasks: </p><p id="completeNum"></p>
<p>pending tasks: </p><p id="pendingNum"></p>
</div>
在这里,我们只是显示已完成和待办事项的数量。在我们的 templates/components/header.css
文件中,我们必须定义以下 CSS:
.header {
background: #034f84;
margin-bottom: 0.3rem;
}
.header p {
color: white;
display: inline-block;
margin: 0.5rem;
margin-right: 0.4rem;
margin-left: 0.4rem;
}
为了使我们的 add_component
函数能够将 CSS 和 HTML 插入到正确的位置,我们必须将 HEADER
标签插入到 templates/main.html
文件的 <style>
部分中:
. . .
<style>
{{BASE_CSS}}
{{CSS}}
HEADER_CSS
</style>
<body>
<div class="mainContainer">
HEADER_HTML
<h1>Done Items</h1>
. . .
现在我们已经定义了所有的 HTML 和 CSS,我们需要在我们的 views/app/items.rs
文件中导入 add_component
函数:
use super::content_loader::add_component;
在同一文件中,我们必须在 items
视图函数中添加标题,如下所示:
html_data = add_component(String::from("header"),
html_data);
现在,我们必须修改 injecting_header/javascript/main.js
文件中的 apiCall
函数,以确保标题更新为待办事项项的数量:
document.getElementById("completeNum").innerHTML =
JSON.parse(this.responseText)["done_item_count"];
document.getElementById("pendingNum").innerHTML =
JSON.parse(this.responseText)["pending_item_count"];
现在我们已经插入了我们的组件,我们得到以下渲染视图:
图 5.9 – 带有标题的主页
如我们所见,我们的标题正确地显示了数据。如果我们将标题标签添加到视图 HTML 文件中,并在我们的视图中调用 add_component
,我们就会得到那个标题。
目前,我们有一个完全工作的单页应用程序。然而,这并非没有困难。我们可以看到,如果我们开始在前端添加更多功能,我们的前端将会开始失去控制。这就是像 React 这样的框架发挥作用的地方。使用 React,我们可以将我们的代码结构化为适当的组件,这样我们就可以在需要时使用它们。在下一节中,我们将创建一个基本的 React 应用程序。
创建一个 React 应用程序
React 是一个独立的应用程序。正因为如此,我们通常会将我们的 React 应用程序放在自己的 GitHub 仓库中。如果您想将 Rust 应用程序和 React 应用程序放在同一个 GitHub 仓库中,那也是可以的,但请确保它们在根目录下的不同目录中。一旦我们离开了 Rust 网络应用程序,我们就可以运行以下命令:
npx create-react-app front_end
这将在 front_end
目录中创建一个 React 应用程序。如果我们查看内部,我们会看到很多文件。请记住,这本书是关于 Rust 的网络编程。探索 React 的所有内容超出了本书的范围。然而,在 进一步阅读 部分建议了一本专门介绍 React 开发的书籍。现在,我们将专注于 front_end/package.json
文件。我们的 package.json
文件就像我们的 Cargo.toml
文件一样,我们在其中定义了依赖项、脚本以及围绕我们正在构建的应用程序的其他元数据。在我们的 package.json
文件中,我们有以下脚本:
. . .
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
. . .
如果我们想编辑它,我们可以这样做,但就目前而言,如果我们运行 npm start
命令在 package.json
文件所在的目录中,我们将运行 react-scripts start
命令。我们将很快运行我们的 React 应用程序,但在这样做之前,我们必须使用以下代码编辑我们的 front_end/src/App.js
文件:
import React, { Component } from 'react';
class App extends Component {
state = {
"message": "To Do"
}
render() {
return (
<div className="App">
<p>{this.state.message} application</p>
</div>
)
}
}
export default App;
在我们分解这段代码之前,我们必须澄清一些事情。如果你上网,可能会看到一些文章声称 JavaScript 不是一种基于类的面向对象语言。这本书不会深入探讨 JavaScript。相反,本章旨在让你获得足够的知识,以便能够启动并运行前端。希望这一章足以帮助你进一步阅读,并启动你为 Rust 网络应用添加前端之旅。为了本章的目的,我们将只关注可以支持继承的类和对象。
在前面的代码中,我们从react
包中导入了component
对象。然后,我们定义了一个继承自component
类的App
类。App
类是应用的主要部分,我们可以将front_end/src/App.js
文件视为前端应用的入口点。我们可以在App
类中定义其他路由(如果需要的话)。我们还可以看到App
类中有一个state
。这是应用的整体内存。我们必须将其称为state
;每次状态更新时,render
函数都会执行,更新组件渲染到前端的内容。这抽象了我们之前章节中在状态更新我们的自定义render
函数时所做的大部分工作。我们可以看到,我们的状态可以在返回时被引用。这被称为 JSX,它允许我们直接在 JavaScript 中写入 HTML 元素,而无需任何额外的方法。现在基本应用已经定义,我们可以将其导出以使其可用。
让我们导航到放置package.json
文件的目录,并运行以下命令:
npm start
React 服务器将启动,我们将在浏览器中获得以下视图:
图 5.10 – 我们 React 应用的第一个主要视图
在这里,我们可以看到我们的状态中的消息已经传递到我们的render
函数中,然后显示在我们的浏览器中。现在我们的 React 应用正在运行,我们可以开始使用 API 调用将数据加载到我们的 React 应用中。
在 React 中执行 API 调用
现在基本应用正在运行,我们可以开始对后端执行 API 调用。为此,我们将主要关注front_end/src/App.js
文件。我们可以构建我们的应用,使其能够用来自 Rust 应用的项目填充前端。首先,我们必须将以下内容添加到package.json
文件的依赖项中:
"axios": "⁰.26.1"
然后,我们可以运行以下命令:
npm install
这将安装我们的额外依赖项。现在,我们可以转向我们的front_end/src/App.js
文件,并使用以下代码导入我们需要的内容:
import React, { Component } from 'react';
import axios from 'axios';
我们将使用Component
为我们的App
类实现继承,并使用axios
执行对后端的 API 调用。现在,我们可以使用以下代码定义我们的App
类并更新我们的状态:
class App extends Component {
state = {
"pending_items": [],
"done_items": [],
"pending_items_count": 0,
"done_items_count": 0
}
}
export default App;
这里,我们有与我们的自制前端相同的结构。这也是我们从 Rust 服务器中的获取项目视图中返回的数据。现在我们知道我们将要处理什么数据,我们可以执行以下步骤:
-
在我们的
App
类内部创建一个函数,用于从 Rust 服务器获取函数。 -
确保这个函数在
App
类挂载时执行。 -
在我们的
App
类内部创建一个函数,用于将 Rust 服务器返回的项目处理成 HTML。 -
在我们完成所有这些步骤后,创建一个函数,将所有上述组件渲染到前端。
-
启用我们的 Rust 服务器以接收来自其他源的调用。
在我们开始这些步骤之前,我们应该注意,我们的App
类的轮廓将采取以下形式:
class App extends Component {
state = {
. . .
}
// makes the API call
getItems() {
. . .
}
// ensures the API call is updated when mounted
componentDidMount() {
. . .
}
// convert items from API to HTML
processItemValues(items) {
. . .
}
// returns the HTML to be rendered
render() {
return (
. . .
)
}
}
通过这种方式,我们可以开始编写执行 API 调用的函数:
-
在我们的
App
类内部,我们的getItems
函数具有以下布局:axios.get("http://127.0.0.1:8000/v1/item/get",
{headers: {"token": "some_token"}})
.then(response => {
let pending_items = response.data["pending_items"]
let done_items = response.data["done_items"]
this.setState({
. . .
})
});
这里,我们定义了 URL。然后,我们将我们的令牌添加到我们的头部。目前,我们将只硬编码一个简单的字符串,因为我们还没有在 Rust 服务器上设置用户会话;我们将在第七章,管理用户会话中更新这个;然后我们关闭它。因为axios.get
是一个承诺,我们必须使用.then
。.then
括号内的代码在数据返回时执行。在这些括号内,我们提取所需的数据,然后执行this.setState
函数。this.setState
函数更新App
类的状态。然而,执行this.setState
也会执行App
类的render
函数,这将更新浏览器。在这个this.setState
函数中,我们传递以下代码:
"pending_items": this.processItemValues(pending_items),
"done_items": this.processItemValues(done_items),
"pending_items_count": response.data["pending_item_count"],
"done_items_count": response.data["done_item_count"]
通过这种方式,我们已经完成了getItems
,并且可以从后端获取项目。现在我们已经定义了它,我们必须确保它被执行,我们将在下一步中这样做。
-
确保在
App
类加载时执行getItems
函数并更新状态,可以通过以下代码实现:componentDidMount() {
this.getItems();
}
这很简单。getItems
将在我们的App
组件挂载后立即执行。我们本质上是在componentDidMount
函数中调用this.setState
。这触发了在浏览器更新屏幕之前的额外渲染。即使render
被调用两次,用户也不会看到中间状态。这是我们继承自 React Component
类的许多函数之一。现在,既然我们在页面加载时立即加载数据,我们可以继续下一步:处理加载数据。
-
对于我们
App
类内部的processItemValues
函数,我们必须接收一个表示项目的 JSON 对象数组,并将它们转换为 HTML,这可以通过以下代码实现:processItemValues(items) {
let itemList = [];
items.forEach((item, index)=>{
itemList.push(
<li key={index}>{item.title} {item.status}</li>
)
})
return itemList
}
这里,我们只是遍历项目,将它们转换为li
HTML 元素,并将它们添加到一个空数组中,然后一旦填满就返回。记住,我们在getItems
函数中在数据进入状态之前使用processItemValue
函数来处理数据。现在我们已经有了所有 HTML 组件在我们的状态中,我们需要使用我们的render
函数将它们放置在页面上。
-
对于我们的
App
类,render
函数只返回 HTML 组件。我们在这里不使用任何额外的逻辑。我们可以返回以下内容:<div className="App">
<h1>Done Items</h1>
<p>done item count: {this.state.done_items_count}</p>
{this.state.done_items}
<h1>Pending Items</h1>
<p>pending item count:
{this.state.pending_items_count}</p>
{this.state.pending_items}
</div>
在这里,我们可以看到我们的状态被直接引用。这与我们在本章早期使用的手动字符串操作相比是一个美好的变化。使用 React 要干净得多,减少了出错的风险。在我们的前端,对后端的调用渲染过程应该可以工作。然而,我们的 Rust 服务器会阻止来自 React 应用的请求,因为它来自不同的应用。为了解决这个问题,我们需要进行下一步。
-
目前,我们的 Rust 服务器会阻止我们对服务器的请求。这归因于
Cargo.toml
文件中的以下代码:actix-cors = "0.6.1"
在我们的src/main.rs
文件中,我们必须使用以下代码导入 CORS:
use actix_cors::Cors;
现在,我们必须在服务器定义之前定义 CORS 策略,并在视图配置之后立即用以下代码包装 CORS 策略:
#[actix_web::main]
async fn main() -> std::io::Result<()> {
HttpServer::new(|| {
let cors = Cors::default().allow_any_origin()
.allow_any_method()
.allow_any_header();
let app = App::new()
.wrap_fn(|req, srv|{
println!("{}-{}", req.method(),
req.uri());
let future = srv.call(req);
async {
let result = future.await?;
Ok(result)
}
}).configure(views::views_factory).wrap(cors);
return app
})
.bind("127.0.0.1:8000")?
.run()
.await
}
使用这个,我们的服务器已经准备好接受来自我们的 React 应用的请求。
注意
当我们定义我们的 CORS 策略时,我们明确表示我们希望允许所有方法、头和来源。然而,我们可以用以下 CORS 定义来更加简洁:
let cors =
Cors::permissive();
现在,我们可以测试我们的应用以查看它是否工作。我们可以通过使用 Cargo 运行我们的 Rust 服务器并在不同的终端中运行我们的 React 应用来实现这一点。一旦启动并运行,我们的 React 应用在加载时应该看起来像这样:
图 5.11 – 当我们的 React 应用首次与 Rust 服务器通信时的视图
这样,我们可以看到现在对我们的 Rust 应用的调用正在按预期工作。然而,我们正在做的事情只是列出待办事项的名称和状态。React 的亮点在于构建自定义组件。这意味着我们可以为每个待办事项构建具有自己状态和函数的单独类。我们将在下一节中看到这一点。
在 React 中创建自定义组件
当我们查看 App
类时,我们可以看到有一个具有状态和可以用来管理如何以及何时将 HTML 渲染到浏览器的函数的类是非常有用的。当涉及到单个待办事项时,我们可以使用状态和函数。这是因为我们有一个从待办事项获取属性并调用 Rust 服务器以编辑或删除它的按钮。在本节中,我们将构建两个组件:在 src/components/ToDoItem.js
文件中的 ToDoItem
组件和在 src/components/CreateToDoItem.js
文件中的 CreateToDoItem
组件。一旦我们构建了这些组件,我们就可以将它们插入到 App
组件中,因为 App
组件将获取项目数据并遍历这些项目,创建多个 ToDoItem
组件。为了实现这一点,我们需要处理几个步骤,因此本节将分为以下子节:
-
创建我们的
ToDoItem
组件 -
创建我们的
CreateToDoItem
组件 -
在我们的
App
组件中构建和管理自定义组件
让我们开始吧。
创建我们的 ToDoItem 组件
我们将从 src/components/ToDoItem.js
文件中的更简单的 ToDoItem
组件开始。首先,我们必须导入以下内容:
import React, { Component } from 'react';
import axios from "axios";
这并不是什么新东西。现在我们已经导入了所需的模块,我们可以专注于如何使用以下代码定义 ToDoItem
:
class ToDoItem extends Component {
state = {
"title": this.props.title,
"status": this.props.status,
"button": this.processStatus(this.props.status)
}
processStatus(status) {
. . .
}
inverseStatus(status) {
. . .
}
sendRequest = () => {
. . .
}
render() {
return(
. . .
)
}
}
export default ToDoItem;
在这里,我们使用 this.props
填充状态,这是在组件构建时传递给组件的参数。然后,我们有以下 ToDoItem
组件的函数:
-
processStatus
: 这个函数将待办事项的状态(如PENDING
)转换为按钮上的消息(如edit
)。 -
inverseStatus
: 当我们有一个状态为PENDING
的待办事项并且编辑它时,我们希望将其转换为DONE
状态,以便可以发送到 Rust 服务器的edit
端点,这是相反的操作。因此,这个函数创建了一个传入状态的逆状态。 -
sendRequest
: 这个函数将请求发送到 Rust 服务器,用于编辑或删除待办事项。我们还可以看到我们的sendRequest
函数是一个箭头函数。箭头语法本质上将函数绑定到组件,这样我们就可以在我们的render
返回语句中引用它,允许当绑定到按钮被点击时执行sendRequest
函数。
既然我们知道我们的函数应该做什么,我们可以使用以下代码定义我们的状态函数:
processStatus(status) {
if (status === "PENDING") {
return "edit"
} else {
return "delete"
}
}
inverseStatus(status) {
if (status === "PENDING") {
return "DONE"
} else {
return "PENDING"
}
}
这很简单,不需要太多解释。现在我们的状态处理函数已经完成,我们可以使用以下代码定义我们的 sendRequest
函数:
sendRequest = () => {
axios.post("http://127.0.0.1:8000/v1/item/" +
this.state.button,
{
"title": this.state.title,
"status": this.inverseStatus(this.state.status)
},
{headers: {"token": "some_token"}})
.then(response => {
this.props.passBackResponse(response);
});
}
在这里,我们使用this.state.button
来定义 URL 的一部分作为端点根据我们按下的按钮而变化。我们还可以看到我们执行了this.props.passBackResponse
函数。这是一个我们传递给ToDoItem
组件的函数。这是因为我们在编辑或删除请求后从 Rust 服务器获取了待办事项的完整状态。我们需要使我们的App
组件能够处理已传递回的数据。在这里,我们提前看到了我们将在在 App 组件中构建和管理自定义组件小节中要做的事情。我们的App
组件将在passBackResponse
参数下有一个未执行的功能,它将传递给我们的ToDoItem
组件。这个在passBackResponse
参数下的函数将处理新的待办事项的状态,并在App
组件中渲染它。
通过这样,我们已经配置了所有我们的函数。剩下要做的就是定义render
函数的返回值,其形式如下:
<div>
<p>{this.state.title}</p>
<button onClick={this.sendRequest}>
{this.state.button}</button>
</div>
在这里,我们可以看到待办事项的标题被渲染在段落标签中,并且我们的按钮在点击时执行sendRequest
函数。我们现在已经完成了这个组件,它准备在我们的应用中显示。然而,在我们这样做之前,我们需要在下一节构建创建待办事项的组件。
在 React 中创建自定义组件
我们的反应应用在列出、编辑和删除待办事项方面运行良好。然而,我们无法创建任何待办事项。这包括一个输入和一个create
按钮,以便我们可以输入一个待办事项,然后通过点击按钮来创建它。在我们的src/components/CreateToDoItem.js
文件中,我们需要导入以下内容:
import React, { Component } from 'react';
import axios from "axios";
这些是我们构建组件的标准导入。一旦导入被定义,我们的CreateToDoItem
组件将具有以下形式:
class CreateToDoItem extends Component {
state = {
title: ""
}
createItem = () => {
. . .
}
handleTitleChange = (e) => {
. . .
}
render() {
return (
. . .
)
}
}
export default CreateToDoItem;
在前面的代码中,我们可以看到我们的CreateToDoItem
组件具有以下功能:
-
createItem
:这个函数向 Rust 服务器发送请求,以创建标题在状态中的待办事项 -
handleTitleChange
:这个函数在输入更新时更新状态
在我们探索这两个函数之前,我们将调整我们编写这些函数的顺序,并使用以下代码定义render
函数的返回值:
<div className="inputContainer">
<input type="text" id="name"
placeholder="create to do item"
value={this.state.title}
onChange={this.handleTitleChange}/>
<div className="actionButton"
id="create-button"
onClick={this.createItem}>Create</div>
</div>
在这里,我们可以看到输入的值为this.state.title
。同时,当输入改变时,我们执行this.handleTitleChange
函数。现在我们已经覆盖了render
函数,没有新的内容要介绍。这是一个很好的机会,让你再次查看我们的CreateToDoItem
组件的轮廓,并尝试自己定义createItem
和handleTitleChange
函数。它们与ToDoItem
组件中的函数形式相似。
您尝试定义的 createItem
和 handleTitleChange
函数应该类似于以下内容:
createItem = () => {
axios.post("http://127.0.0.1:8000/v1/item/create/" +
this.state.title,
{},
{headers: {"token": "some_token"}})
.then(response => {
this.setState({"title": ""});
this.props.passBackResponse(response);
});
}
handleTitleChange = (e) => {
this.setState({"title": e.target.value});
}
有了这些,我们已经定义了两个自定义组件。我们现在可以继续到下一个小节,在那里我们将管理我们的自定义组件。
在 App 组件中构建和管理自定义组件
虽然创建自定义组件很有趣,但如果我们不在应用程序中使用它们,它们就没有什么用处。在本小节中,我们将向 src/App.js
文件添加一些额外的代码,以使我们的自定义组件可以被使用。首先,我们必须使用以下代码导入我们的组件:
import ToDoItem from "./components/ToDoItem";
import CreateToDoItem from "./components/CreateToDoItem";
现在我们有了我们的组件,我们可以继续到我们的第一个修改。我们的 App
组件的 processItemValues
函数可以用以下代码定义:
processItemValues(items) {
let itemList = [];
items.forEach((item, _)=>{
itemList.push(
<ToDoItem key={item.title + item.status}
title={item.title}
status={item.status.status}
passBackResponse={
this.handleReturnedState}/>
)
})
return itemList
}
在这里,我们可以看到我们遍历从 Rust 服务器获取的数据,但不是将数据传递给一个通用的 HTML 标签,而是将待办事项数据的参数传递到我们自己的自定义组件中,这个组件被当作一个 HTML 标签来处理。当我们处理自己的响应并返回状态时,我们可以看到它是一个箭头函数,该函数处理数据并使用以下代码设置状态:
handleReturnedState = (response) => {
let pending_items = response.data["pending_items"]
let done_items = response.data["done_items"]
this.setState({
"pending_items":
this.processItemValues(pending_items),
"done_items": this.processItemValues(done_items),
"pending_items_count":
response.data["pending_item_count"],
"done_items_count": response.data["done_item_count"]
})
}
这与我们的 getItems
函数非常相似。如果您想减少重复代码的数量,这里可以进行一些重构。然而,为了使这可行,我们必须使用以下代码定义 render
函数的返回语句:
<div className="App">
<h1>Pending Items</h1>
<p>done item count:
{this.state.pending_items_count}</p>
{this.state.pending_items}
<h1>Done Items</h1>
<p>done item count: {this.state.done_items_count}</p>
{this.state.done_items}
<CreateToDoItem
passBackResponse={this.handleReturnedState} />
</div>
在这里,我们可以看到除了添加 createItem
组件之外,没有太多变化。运行我们的 Rust 服务器和 React 应用程序将给出以下视图:
图 5.12 – 带有自定义组件的 React 应用程序视图
图 5.12 显示我们的自定义组件正在渲染。我们可以点击按钮,结果我们会看到所有的 API 调用都正常工作,我们的自定义组件也按预期工作。现在,唯一阻碍我们的是让我们的前端看起来更美观,我们可以通过将 CSS 提升到 React 应用程序来实现这一点。
将 CSS 提升到 React
我们现在正处于使我们的 React 应用程序可用的最后阶段。我们可以将 CSS 分成多个不同的文件。然而,我们即将结束本章,再次检查所有的 CSS 将会无谓地在这个章节中填充大量的重复代码。虽然我们的 HTML 和 JavaScript 是不同的,但 CSS 是相同的。为了使其运行,我们可以从以下文件复制所有 CSS:
-
templates/components/header.css
-
css/base.css
-
css/main.css
将此处列出的 CSS 文件复制到 front_end/src/App.css
文件中。CSS 有一个改动,所有 .body
引用都应该替换为 .App
,如下代码片段所示:
.App {
background-color: #92a8d1;
font-family: Arial, Helvetica, sans-serif;
height: 100vh;
}
@media(min-width: 501px) and (max-width: 550px) {
.App {
padding: 1px;
display: grid;
grid-template-columns: 1fr 5fr 1fr;
}
.mainContainer {
grid-column-start: 2;
}
}
. . .
现在,我们可以导入我们的 CSS 并在应用程序和组件中使用它。我们还将不得不修改 render
函数中的返回 HTML。我们可以处理所有三个文件。对于 src/App.js
文件,我们必须使用以下代码导入 CSS:
import "./App.css";
然后,我们必须添加一个标题,并使用以下代码定义我们的 div
标签的正确类,以从我们的 render
函数的返回语句中获取代码:
<div className="App">
<div className="mainContainer">
<div className="header">
<p>complete tasks:
{this.state.done_items_count}</p>
<p>pending tasks:
{this.state.pending_items_count}</p>
</div>
<h1>Pending Items</h1>
{this.state.pending_items}
<h1>Done Items</h1>
{this.state.done_items}
<CreateToDoItem passBackResponse=
{this.handleReturnedState}/>
</div>
</div>
在我们的 src/components/ToDoItem.js
文件中,我们必须使用以下代码导入 CSS:
import "../App.css";
然后,我们必须将我们的 button
改为 div
,并使用以下代码定义我们的 render
函数的返回语句:
<div className="itemContainer">
<p>{this.state.title}</p>
<div className="actionButton" onClick=
{this.sendRequest}>
{this.state.button}</div>
</div>
在我们的 src/components/CreateToDoItem.js
文件中,我们必须使用以下代码导入 CSS:
import "../App.css";
然后,我们必须将我们的 button
改为 div
,并使用以下代码定义我们的 render
函数的返回语句:
<div className="inputContainer">
<input type="text" id="name"
placeholder="create to do item"
value={this.state.title}
onChange={this.handleTitleChange}/>
<div className="actionButton"
id="create-button"
onClick={this.createItem}>Create</div>
</div>
通过这种方式,我们已经将我们的 CSS 从 Rust 网络服务器提升到我们的 React 应用程序中。如果我们运行 Rust 服务器和 React 应用程序,我们将得到以下图所示的输出:
图 5.13 – 添加 CSS 后的我们的 React 应用程序视图
然后,我们就完成了!我们的 React 应用程序正在运行。让我们的 React 应用程序启动并运行需要更多时间,但我们能看到我们在 React 中有更多的灵活性。我们还可以看到,由于我们不需要手动操作字符串,我们的 React 应用程序更不容易出错。对于我们来说,构建在 React 中的另一个优势是现有的基础设施。在下一节和最后一节中,我们将把我们的 React 应用程序转换为通过将我们的 React 应用程序包裹在 Electron 中来运行的编译后的桌面应用程序。
将我们的 React 应用程序转换为桌面应用程序
将我们的 React 应用程序转换为桌面应用程序并不复杂。我们将使用 Electron 框架来完成这项工作。Electron 是一个强大的框架,可以将我们的 JavaScript、HTML 和 CSS 应用程序转换为跨平台编译的桌面应用程序,适用于 macOS、Linux 和 Windows。Electron 框架还可以通过 API(如加密存储、通知、电源监控、消息端口、进程、shell、系统首选项等)为我们提供访问计算机组件的能力。Slack、Visual Studio Code、Twitch、Microsoft Teams 等桌面应用程序都是内置在 Electron 中的。为了将我们的 React 应用程序转换为桌面应用程序,我们必须首先更新 package.json
文件。首先,我们必须使用以下代码更新 package.json
文件顶部的元数据:
{
"name": "front_end",
"version": "0.1.0",
"private": true,
"homepage": "./",
"main": "public/electron.js",
"description": "GUI Desktop Application for a simple To
Do App",
"author": "Maxwell Flitton",
"build": {
"appId": "Packt"
},
"dependencies": {
. . .
这大部分是通用元数据。然而,main
字段是必不可少的。这是我们编写定义 Electron 应用程序如何运行的文件的所在地。将 homepage
字段设置为 "./"
也确保了资源路径相对于 index.html
文件是相对的。现在我们的元数据已经定义,我们可以添加以下依赖项:
"webpack": "4.28.3",
"cross-env": "⁷.0.3",
"electron-is-dev": "².0.0"
这些依赖项有助于构建 Electron 应用程序。一旦它们被添加,我们就可以使用以下代码重新定义我们的脚本:
. . .
"scripts": {
"react-start": "react-scripts start",
"react-build": "react-scripts build",
"react-test": "react-scripts test",
"react-eject": "react-scripts eject",
"electron-build": "electron-builder",
"build": "npm run react-build && npm run electron-
build",
"start": "concurrently \"cross-env BROWSER=none npm run
react-start\" \"wait-on http://localhost:3000
&& electron .\""
},
在这里,我们已将所有 React 脚本前缀为 react
。这是为了将 React 进程与我们的 Electron 进程分开。如果我们现在只想以开发模式运行我们的 React 应用程序,我们必须运行以下命令:
npm run react-start
我们还定义了 Electron 的构建命令和开发启动命令。这些目前还不能工作,因为我们还没有定义我们的 Electron 文件。在 package.json
文件的底部,我们必须定义我们的开发者依赖项,以构建我们的 Electron 应用程序:
. . .
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"concurrently": "⁷.1.0",
"electron": "¹⁸.0.1",
"electron-builder": "²².14.13",
"wait-on": "⁶.0.1"
}
}
这样,我们在 package.json
文件中定义了我们所需的所有内容。我们需要使用以下命令安装新的依赖项:
npm install
现在,我们可以开始构建我们的 front_end/public/electron.js
文件,这样我们就可以构建我们的 Electron 文件。这基本上是样板代码,你可能会在其他教程中看到这个文件,因为这是在 Electron 中运行应用程序所需的最小代码。首先,我们必须使用以下代码导入我们需要的:
const { app, BrowserWindow } = require("electron");
const path = require("path");
const isDev = require("electron-is-dev");
然后,我们必须定义以下代码创建我们的桌面窗口的函数:
function createWindow() {
const mainWindow = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
nodeIntegration: true,
enableRemoteModule: true,
contextIsolation: false,
},
});
mainWindow.loadURL(
isDev
? "http://localhost:3000"
: `file://${path.join(__dirname,
"../build/index.html")}`
);
if (isDev) {
mainWindow.webContents.openDevTools();
}
}
在这里,我们实际上定义了窗口的宽度和高度。此外,请注意,nodeIntegration
和 enableRemoteModule
允许渲染器远程进程(浏览器窗口)在主进程中运行代码。然后,我们开始加载主窗口中的 URL。如果运行在开发者模式,我们只需加载 http://localhost:3000
,因为我们已经在本地主机上运行了 React 应用程序。如果我们构建我们的应用程序,那么我们编写的资源和文件将被编译,可以通过 ../build/index.html
文件加载。我们还声明,如果我们处于开发者模式,我们将打开开发者工具。当窗口准备好时,我们必须执行以下代码的 createWindow
函数:
app.whenReady().then(() => {
createWindow();
app.on("activate", function () {
if (BrowserWindow.getAllWindows().length === 0){
createWindow();
}
});
});
如果操作系统是 macOS,我们必须保持程序运行,即使我们关闭了窗口:
app.on("window-all-closed", function () {
if (process.platform !== "darwin") app.quit();
});
现在,我们必须运行以下命令:
npm start
这将运行 Electron 应用程序,给我们以下输出:
图 5.14 – 在 Electron 中运行的我们的 React 应用程序
在 图 5**.13 中,我们可以看到我们的应用程序正在我们的桌面上运行的一个窗口中。我们还可以看到,我们的应用程序可以通过我屏幕顶部的菜单栏访问。应用程序的标志显示在我的任务栏上:
图 5.15 – Electron 在我的任务栏上
以下命令将在 dist
文件夹中编译我们的应用,如果点击,将应用安装到您的计算机上:
npm build
以下是在我的 Mac 上的应用程序区域中,当我使用 Electron 测试我构建的开源包 Camel(用于 OasisLMF)的 GUI 时的一个例子:
图 5.16 – 我们的应用程序区域中的 Electron 应用
最终,我会想出一个标志。然而,这标志着关于在浏览器中显示内容的这一章的结束。
摘要
在本章中,我们最终使我们的应用能够被普通用户使用,而不是依赖于第三方应用,如 Postman。我们定义了自己的应用视图模块,其中包含读取文件和插入功能。这导致我们构建了一个加载 HTML 文件、从 JavaScript 和 CSS 文件中插入数据到视图数据中,然后提供这些数据的流程。
这为我们提供了一个动态视图,当我们编辑、删除或创建待办事项时,它会自动更新。我们还探索了一些关于 CSS 和 JavaScript 的基础知识,以便从前端发出 API 调用并动态编辑我们视图的某些部分的 HTML。我们还根据窗口大小管理整个视图的样式。请注意,我们没有依赖外部 crate。这是因为我们想要理解我们如何处理我们的 HTML 数据。
然后,我们使用 React 重新构建了前端。虽然这花费了更长的时间,并且有更多的组件需要移动,但由于我们不需要手动操作字符串来编写 HTML 组件,代码的可扩展性和安全性更高。我们也可以看到为什么我们选择了 React,因为它很好地与 Electron 结合,为我们提供了向用户交付应用的另一途径。
虽然我们的应用现在表面上可以工作,但在数据存储方面并不具备可扩展性。我们没有数据过滤过程。我们没有对存储的数据进行检查,并且我们没有多个表格。
在下一章中,我们将构建与运行在 Docker 中的本地 PostgreSQL 数据库交互的数据模型。
问题
-
返回 HTML 数据到用户浏览器的最简单方法是什么?
-
返回 HTML、CSS 和 JavaScript 数据到用户浏览器的最简单(不可扩展)方法是什么?
-
我们如何确保某些元素在应用的所有视图中背景色和样式标准的一致性?
-
我们如何在 API 调用后更新 HTML?
-
我们如何使按钮连接到我们的后端 API?
答案
-
我们可以通过仅定义一个 HTML 字符串并将其放入
HttpResponse
结构体的主体中,同时将内容类型定义为 HTML,来提供 HTML 数据。然后,将HttpResponse
结构体返回给用户的浏览器。 -
最简单的方法是将包含 CSS 硬编码在
<style>
部分和 JavaScript 硬编码在<script>
部分的完整 HTML 字符串硬编码,然后将该字符串放入HttpResponse
结构的主体中,并返回给用户的浏览器。 -
我们创建一个 CSS 文件,定义我们希望在应用程序中保持一致的组件。然后,我们在所有 HTML 文件的
<style>
部分中放置一个标签。然后,对于每个文件,我们加载基本 CSS 文件,并用 CSS 数据替换标签。 -
在 API 调用之后,我们必须等待状态就绪。然后,我们使用
getElementById
获取我们想要更新的 HTML 部分,序列化响应数据,然后将元素的内部 HTML 设置为响应数据。 -
我们给按钮分配一个唯一的 ID。然后,我们添加一个由唯一 ID 定义的事件监听器。在这个事件监听器中,我们将其绑定到一个函数,该函数使用
this
获取 ID。在这个函数中,我们向后端发起 API 调用,然后使用响应来更新我们视图中显示数据的其他部分的 HTML。
进一步阅读
要了解更多关于本章所涵盖的主题,请查看以下资源:
-
React 和 React Native:使用 React.js 的现代 Web 和移动开发的完整实战指南,作者:Adam Boduch 和 Roy Derks,Packt 出版社
-
掌握 React 测试驱动开发:使用 React、Redux 和 GraphQL 构建坚如磐石、经过良好测试的 Web 应用,作者:Daniel Irvine,Packt 出版社
-
使用 HTML5 和 CSS 进行响应式 Web 设计:使用最新的 HTML5 和 CSS 技术开发未来证明的响应式网站,作者:Ben Frain,Packt 出版社
-
电子文档:
www.electronjs.org/
第三部分:数据持久化
现在我们应用程序处理 HTTP 请求并在浏览器中显示内容,我们需要正确地存储和处理数据。在本部分,你将学习如何在开发中使用 Docker 本地管理数据库,以及如何使用 SQL 脚本来执行数据库迁移。你还将学习如何将数据库模式映射到 Rust 应用程序,从 Rust 查询和更新数据库。在本部分之后,你将能够管理数据库连接池、数据模型和迁移;使用中间件登录和验证请求;并在前端缓存数据,探索 RESTful 概念。
本部分包括以下章节:
-
第六章,使用 PostgreSQL 进行数据持久化
-
第七章,管理用户会话
-
第八章,构建 RESTful 服务
第六章:使用 PostgreSQL 实现数据持久化
到本书的这一阶段,我们应用程序的前端已经定义,我们的应用程序在表面上运行良好。然而,我们知道我们的应用程序是从一个 JSON 文件中读取和写入的。
在本章中,我们将放弃我们的 JSON 文件,并引入一个 create
、edit
和 delete
端点来与数据库而不是 JSON 文件进行交互。
在本章中,我们将涵盖以下主题:
-
构建我们的 PostgreSQL 数据库
-
使用 Diesel 连接到 PostgreSQL
-
将我们的应用程序连接到 PostgreSQL
-
配置我们的应用程序
-
管理数据库连接池
到本章结束时,你将能够管理一个应用程序,该应用程序使用数据模型在 PostgreSQL 数据库中执行读取、写入和删除数据。如果我们对数据模型进行更改,我们将能够通过迁移来管理它们。一旦完成这些操作,你将能够使用连接池优化你的数据库连接,并在无法建立数据库连接之前拒绝 HTTP 请求。
技术要求
在本章中,我们将使用 Docker 来定义、运行一个 PostgreSQL 数据库,并运行它。这将使我们的应用程序能够与本地机器上的数据库进行交互。可以通过遵循docs.docker.com/engine/install/
中的说明来安装 Docker。
我们还将在 Docker 之上使用 docker-compose
来编排我们的 Docker 容器。可以通过遵循docs.docker.com/compose/install/
中的说明来安装 docker-compose
。
本章的代码文件可以在github.com/PacktPublishing/Rust-Web-Programming-2nd-Edition/tree/main/chapter06
找到。
构建我们的 PostgreSQL 数据库
到本书的这一阶段,我们一直在使用 JSON 文件来存储我们的待办事项。到目前为止,这已经很好地为我们服务了。事实上,我们没有理由在整个书中使用 JSON 文件来完成任务。然而,如果你在生产项目中使用 JSON 文件,你将遇到一些缺点。
为什么我们应该使用合适的数据库
如果我们对 JSON 文件的读写操作增加,我们可能会遇到一些并发问题和数据损坏。此外,也没有对数据类型进行检查。因此,另一个开发者可以编写一个将不同数据写入 JSON 文件的功能,而没有任何阻碍。
迁移也存在一个问题。如果我们想给待办事项添加时间戳,这将只会影响我们插入到 JSON 文件中的新待办事项。因此,一些待办事项将具有时间戳,而另一些则不会,这将在我们的应用程序中引入错误。我们的 JSON 文件在过滤方面也存在限制。
目前,我们只是读取整个数据文件,修改整个数据集中的某个条目,并将整个数据集写入JSON文件。这并不有效,并且扩展性不好。它还阻碍了我们将这些待办事项与另一个数据模型(如用户)链接起来。此外,我们目前只能使用状态进行搜索。如果我们使用一个与待办事项数据库链接的用户表的 SQL 数据库,我们就能根据用户、状态或标题过滤待办事项。我们甚至可以使用它们的组合。当我们运行数据库时,我们将使用 Docker。那么,为什么我们应该使用 Docker 呢?
为什么使用 Docker?
要理解为什么我们会使用 Docker,我们首先需要了解 Docker 是什么。Docker 本质上具有像虚拟机一样工作的容器,但方式更加具体和细致。Docker 容器隔离单个应用程序及其所有依赖项。应用程序在 Docker 容器内运行。然后,Docker 容器可以相互通信。由于 Docker 容器共享单个公共操作系统(OS),它们彼此以及与整个操作系统隔离开来,这意味着 Docker 应用程序相比虚拟机使用更少的内存。由于 Docker 容器,我们可以使我们的应用程序更加便携。如果 Docker 容器在您的机器上运行,它也会在另一个安装了 Docker 的机器上运行。我们还可以打包我们的应用程序,这意味着不需要单独安装为我们的应用程序运行所必需的额外包,包括操作系统级别的依赖项。因此,Docker 在 Web 开发中为我们提供了极大的灵活性,因为我们可以在本地机器上模拟服务器和数据库。
如何使用 Docker 运行数据库
考虑到所有这些,通过额外的步骤来设置和运行一个 SQL 数据库是有意义的。为此,我们将使用 Docker:这是一个帮助我们创建和使用容器的工具。容器本身是 Linux 技术,它将应用程序及其整个运行时环境打包和隔离。容器在技术上是一个隔离的文件系统,但为了帮助可视化我们在本章中所做的工作,你可以把它们想象成迷你轻量级虚拟机。这些容器是由可以从Docker Hub下载的镜像构成的。在从这些镜像启动容器之前,我们可以将我们自己的代码插入到这些镜像中,如下面的图所示:
图 6.1 – Docker 镜像和容器之间的关系
使用 Docker,我们可以下载一个镜像,例如 PostgreSQL 数据库,并在我们的开发环境中运行它。由于 Docker,我们可以根据需要启动多个数据库和应用程序,然后关闭它们。首先,我们需要通过在终端运行以下命令来盘点我们的容器:
docker container ls -a
如果 Docker 是全新安装,我们将得到以下输出:
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
如我们所见,我们没有容器。我们还需要盘点我们的镜像。这可以通过运行以下终端命令来完成:
docker image ls
前面的命令给出了以下输出:
REPOSITORY TAG IMAGE ID CREATED SIZE
再次强调,如果 Docker 是全新安装,那么将没有容器。
我们还可以通过其他方式在 Docker 中创建数据库。例如,我们可以创建自己的已安装的docker-compose
。使用docker-compose
将使数据库定义变得简单。它还将使我们能够添加更多容器和服务。为了定义我们的 PostgreSQL 数据库,我们在根目录中编写以下docker-compose.yml
文件:
version: "3.7"
services:
postgres:
container_name: 'to-do-postgres'
image: 'postgres:11.2'
restart: always
ports:
- '5432:5432'
environment:
- 'POSTGRES_USER=username'
- 'POSTGRES_DB=to_do'
- 'POSTGRES_PASSWORD=password'
在前面的代码中,文件顶部我们定义了版本。较老的版本,如2或1,在文件布局上具有不同的风格。不同的版本也支持不同的参数。在撰写本书时,版本3是最新的版本。以下 URL 涵盖了每个docker-compose
版本之间的变化:docs.docker.com/compose/compose-file/compose-versioning/
.
然后我们定义嵌套在postgres
标签下的数据库服务。如postgres
和services
之类的标签表示字典,列表使用-
定义每个元素。如果我们把我们的docker-compose
文件转换为 JSON,它将具有以下结构:
{
"version": "3.7",
"services": {
"postgres": {
"container_name": "to-do-postgres",
"image": "postgres:11.2",
"restart": "always",
"ports": [
"5432:5432"
],
"environment": [
"POSTGRES_USER=username",
"POSTGRES_DB=to_do",
"POSTGRES_PASSWORD=password"
]
}
}
}
在前面的代码中,我们可以看到我们的服务是一个字典的字典,表示每个服务。因此,我们可以推断出我们不能有两个具有相同名称的标签,因为我们不能有两个相同的字典键。前面的代码还告诉我们,我们可以继续堆叠具有自己参数的服务标签。
在 Docker 中运行数据库
使用我们的数据库服务,我们有一个名称,因此当我们查看我们的容器时,我们知道每个容器相对于服务(如服务器或数据库)正在做什么。在配置数据库和构建它方面,我们幸运地拉取了官方的postgres
镜像。这个镜像为我们配置了一切,Docker 会从仓库中拉取它。这个镜像就像一个蓝图。我们可以从这个我们拉取的一个镜像中启动多个具有自己参数的容器。然后我们定义重启策略为总是。这意味着当容器退出时,容器的重启策略将被触发。我们还可以将其定义为仅在失败或停止时重启。
应该注意的是,Docker 容器有自己的端口,这些端口对机器是关闭的。然而,我们可以公开容器的端口,并将公开的端口映射到 Docker 容器内部的内部端口。考虑到这些特性,我们可以定义我们的端口。
然而,在我们的例子中,我们将保持我们的定义简单。我们将声明我们接受端口5432
上进入 Docker 容器的流量,并将其路由到内部端口5432
。然后我们将定义我们的环境变量,包括用户名、数据库名称和密码。虽然我们在这本书中使用的是通用、易于记忆的密码和用户名,但如果要推送到生产环境,建议您切换到更安全的密码和用户名。我们可以通过运行以下命令来构建和启动我们的系统,该命令将我们的docker-compose
文件所在的根目录:
docker-compose up
前面的命令将从存储库中拉取postgres
镜像并开始构建数据库。在一系列日志消息之后,终端应该显示以下输出:
LOG: listening on IPv4 address "0.0.0.0", port 5432
LOG: listening on IPv6 address "::", port 5432
LOG: listening on Unix socket "/var/run/postgresql/.s.PGSQL.5432"
LOG: database system was shut down at 2022-04-23 17:36:45 UTC
LOG: database system is ready to accept connections
如您所见,日期和时间会变化。然而,这里告诉我们的是我们的数据库已经准备好接受连接。是的,这真的很简单。因此,Docker 的采用是不可避免的。点击Ctrl + C将停止我们的docker-compose
;从而关闭我们的postgres
容器。
我们现在使用以下命令列出所有我们的容器:
docker container ls -a
前面的命令给出了以下输出:
CONTAINER ID IMAGE COMMAND
c99f3528690f postgres:11.2 "docker-entrypoint.s…"
CREATED STATUS PORTS
4 hours ago Exited (0) About a minute ago
NAMES
to-do-postgres
在前面的输出中,我们可以看到所有参数都在那里。然而,端口却是空的,因为我们已经停止了我们的服务。
探索 Docker 中的路由和端口
如果我们再次启动我们的服务并在另一个终端中列出我们的容器,端口5432
将位于PORTS
标签下。我们必须注意CONTAINER ID
对 Docker 容器的引用,因为它是唯一的,对于每个容器都是不同的/随机的。如果我们需要访问日志,我们需要引用这些。当我们运行docker-compose up
时,我们实际上使用以下结构:
图 6.2 – docker-compose 服务我们的数据库
在图 6.2中,我们可以看到我们的docker-compose
使用一个独特的项目名称来保持容器和网络在其命名空间中。必须注意的是,我们的容器正在本地主机上运行。因此,如果我们想调用由docker-compose
管理的容器,我们必须发起一个本地主机请求。然而,我们必须调用docker-compose
开放的端口,docker-compose
会将它路由到docker-compose
.yml
文件中定义的端口。例如,我们有两个数据库,以下是一个yml
文件:
version: "3.7"
services:
postgres:
container_name: 'to-do-postgres'
image: 'postgres:11.2'
restart: always
ports:
- '5432:5432'
environment:
- 'POSTGRES_USER=username'
- 'POSTGRES_DB=to_do'
- 'POSTGRES_PASSWORD=password'
postgres_two:
container_name: 'to-do-postgres_two'
image: 'postgres:11.2'
restart: always
ports:
- '5433:5432'
environment:
- 'POSTGRES_USER=username'
- 'POSTGRES_DB=to_do'
- 'POSTGRES_PASSWORD=password'
在前面的代码中,我们可以看到我们的两个数据库都通过端口5432
接受进入其容器的流量。然而,会有冲突,所以我们打开的端口之一是端口5433
,它被路由到第二个数据库容器的端口5432
,这给我们以下布局:
图 6.3 – docker-compose 服务多个数据库
这种路由在运行多个容器时给我们提供了灵活性。我们不会为我们的待办事项应用运行多个数据库,因此我们应该删除我们的postgres_two
服务。一旦我们删除了postgres_two
服务,我们就可以再次运行docker-compose
,然后使用以下命令列出我们的容器:
docker image ls
前面的命令现在将给出以下输出:
REPOSITORY TAG IMAGE ID
postgres 11.2 3eda284d1840
CREATED SIZE
17 months ago 312MB
在前面的输出中,我们可以看到我们的镜像是从postgres
仓库拉取的。我们还有一个为该镜像生成的唯一/随机 ID,以及该镜像创建的日期。
现在我们已经基本了解了如何让我们的数据库启动并运行,我们可以使用以下命令在后台运行docker-compose
:
docker-compose up -d
前面的命令只是告诉我们哪些容器已经被启动,以下为输出:
Starting to-do-postgres ... done
我们可以通过以下输出查看我们的状态:
STATUS PORTS NAMES
Up About a minute 0.0.0.0:5432->5432/tcp to-do-postgres
在前面的输出中,其他标签都是相同的,但我们还可以看到STATUS
标签告诉我们容器运行了多长时间,以及它占用了哪个端口。尽管docker-compose
在后台运行,但这并不意味着我们无法看到正在发生的事情。我们可以通过调用logs
命令并使用以下命令引用容器的 ID 来随时访问容器的日志:
docker logs c99f3528690f
前面的命令应该给出与我们的标准docker-compose up
命令相同的输出。要停止docker-compose
,我们可以运行stop
命令,如下所示:
docker-compose stop
前面的命令将停止我们的docker-compose
中的容器。必须注意的是,这与下面的down
命令不同:
docker-compose down
down
命令也会停止我们的容器。然而,down
命令会删除容器。如果我们的数据库容器被删除,我们也会丢失所有数据。
有一个名为volumes
的配置参数可以在容器删除时防止我们的数据被删除;然而,这对于我们在计算机上的本地开发并不是必需的。实际上,你可能会想要定期从你的笔记本电脑上删除容器和镜像。我曾在我的笔记本电脑上执行了一次删除不再使用的容器和镜像的操作,这释放了 23GB!
在我们的本地开发机器上的 Docker 容器应该被视为临时的。虽然 Docker 容器比标准虚拟机更轻量级,但它们并不是免费的。Docker 在我们本地机器上运行背后的想法是,我们可以模拟在服务器上运行我们的应用程序的情况。如果它在我们的笔记本电脑上的 Docker 中运行,我们可以确信它也会在我们的服务器上运行,特别是如果服务器是由一个生产就绪的 Docker 编排工具(如Kubernetes)管理的。
使用 Bash 脚本在后台运行 Docker
Docker 还可以帮助进行一致的测试和开发。我们希望每次运行测试时都能得到相同的结果。我们还希望能够轻松地让其他开发者入职,并使他们能够快速、轻松地关闭和启动容器以支持开发。我个人看到,如果不支持简单的关闭和启动程序,开发可能会被推迟。例如,当处理复杂的应用程序时,我们添加并测试的代码可能会损坏数据库。回滚可能不可行,删除数据库并重新开始将是一件痛苦的事情,因为重建这些数据可能需要很长时间。开发者甚至可能不记得最初是如何构建这些数据的。有多种方法可以防止这种情况发生,我们将在第九章中介绍,测试我们的应用程序端点和组件。
现在,我们将构建一个脚本,在后台启动我们的数据库,等待直到数据库连接就绪,然后关闭我们的数据库。这将为我们构建管道、测试和入职包以开始开发奠定基础。为此,我们将在 Rust Web 应用的根目录中创建一个名为scripts
的目录。然后我们可以创建一个scripts/wait_for_database.sh
文件,包含以下代码:
#!/bin/bash
cd ..
docker-compose up -d
until pg_isready -h localhost -p 5432 -U username
do
echo "Waiting for postgres"
sleep 2;
done
echo "docker is now running"
docker-compose down
使用前面的代码,我们将脚本的当前工作目录从scripts
目录移动到我们的根目录。然后我们在后台启动docker-compose
。接下来,我们循环,通过使用pq_isready
命令 ping 端口5432
,等待直到我们的数据库准备好接受连接。
重要注意事项
pg_isready
Bash 命令可能不在您的计算机上可用。pg_isready
命令通常与 PostgreSQL 客户端的安装一起提供。或者,您可以使用以下 Docker 命令代替pg_isready
:
until docker run -it postgres --add-host host.docker.internal:host-gateway docker.io/postgres:14-alpine -h localhost -U
username pg_isready
这里发生的情况是,我们正在使用postgres
Docker 镜像来运行我们的数据库检查,以确保我们的数据库已准备好接受连接。
当我们的数据库启动后,我们在控制台打印出我们的数据库正在运行,然后关闭我们的docker-compose
,销毁数据库容器。运行执行wait_for_database.sh
Bash 脚本的命令将给出以下输出:
❯ sh wait_for_database.sh
[+] Running 0/0
⠋ Network web_app_default Creating 0.2s
⠿ Container to-do-postgres Started 1.5s
localhost:5432 - no response
Waiting for postgres
localhost:5432 - no response
Waiting for postgres
localhost:5432 - accepting connections
docker is now running
[+] Running 1/1
⠿ Container to-do-postgres Removed 1.2s
⠿ Network web_app_default Removed
从前面的输出中,考虑到我们告诉循环在每次迭代中暂停 2 秒,我们可以推断出我们的新启动的数据库接受连接大约需要 4 秒钟。因此,我们可以说我们已经掌握了使用 Docker 管理本地数据库的基本技能。
在本节中,我们设置了我们的环境。我们也充分理解了 Docker 的基本知识,只需几个简单的命令就能构建、监控、关闭和删除我们的数据库。现在,我们可以继续到下一节,我们将使用 Rust 和diesel
crate 与我们的数据库进行交互。
使用 Diesel 连接到 PostgreSQL
现在我们的数据库正在运行,我们将在本节中构建与该数据库的连接。为此,我们将使用diesel
crate。diesel
crate 使我们能够使我们的 Rust 代码连接到数据库。我们使用diesel
crate 而不是其他 crate,因为diesel
crate 是最成熟的,拥有大量的支持和文档。为此,让我们按照以下步骤进行:
-
首先,我们将利用
diesel
crate。为此,我们可以在cargo.toml
文件中添加以下依赖项:diesel = { version = "1.4.8", features = ["postgres",
"chrono",
"r2d2"] }
dotenv = "0.15.0"
chrono = "0.4.19"
在前面的代码中,我们在diesel
crate 中包含了postgres
功能。diesel
crate 的定义还包括了chrono
和r2d2
功能。chrono
功能使我们的 Rust 代码能够利用 datetime 结构体。r2d2
功能使我们能够执行连接池。我们将在本章末尾介绍连接池。我们还包含了dotenv
crate。这个 crate 使我们能够在.env
文件中定义变量,这些变量随后将被传递到我们的程序中。我们将使用它来传递数据库凭据,然后传递到进程中。
-
现在我们需要安装
diesel
客户端,以便通过我们的终端而不是应用程序运行迁移到数据库。我们可以使用以下命令来完成:cargo install diesel_cli --no-default-features
--features postgres
-
我们现在需要定义环境变量
DATABASE_URL
URL。这将使我们的客户端命令能够使用以下命令连接到数据库:echo DATABASE_URL=postgres://username:password@localhost/to_do
> .env
在前面的 URL 中,我们的用户名表示为 username,密码表示为 password。我们的数据库运行在我们的计算机上,表示为localhost
,我们的数据库名为to_do
。这将在根目录中创建一个.env
文件,输出以下内容:
DATABASE_URL=postgres://username:password@localhost/to_do
-
现在我们已经定义了变量,我们可以开始设置我们的数据库。我们需要使用
docker-compose up
命令启动我们的数据库容器。然后,我们使用以下命令设置我们的数据库:diesel setup
前面的命令在根目录中创建了一个migrations
目录,其结构如下:
── migrations
│ └── 00000000000000_diesel_initial_setup
│ ├── down.sql
│ └── up.sql
当迁移升级时,会触发up.sql
文件,而当迁移降级时,会触发down.sql
文件。
-
现在,我们需要创建我们的迁移来创建我们的待办事项。这可以通过命令我们的客户端使用以下命令生成迁移来完成:
diesel migration generate create_to_do_items
一旦运行,我们应在控制台获得以下输出:
Creating migrations/2022-04-23-201747_create_to_do_items/up.sql
Creating migrations/2022-04-23-201747_create_to_do_items/down.sql
前面的命令在我们的迁移中给出了以下文件结构:
├── migrations
│ ├── 00000000000000_diesel_initial_setup
│ │ ├── down.sql
│ │ └── up.sql
│ └── 2022-04-23-201747_create_to_do_items
│ ├── down.sql
│ └── up.sql
从字面上看,使用diesel
包,我们可能觉得不得不创建自己的 SQL 文件可能是不幸的。然而,这迫使我们养成良好的习惯。虽然允许包自动编写 SQL 代码更容易,但这会将我们的应用程序与数据库耦合起来。例如,我曾经在重构一个微服务系统时工作。我遇到的问题是,我正在使用一个 Python 包来管理所有数据库迁移。然而,我想更改服务器的代码。你不会感到惊讶,我将服务器从 Python 切换到 Rust。然而,因为迁移是由 Python 库自动生成的,我不得不构建辅助 Docker 容器,这些容器至今仍在每次发布新版本时启动,并在构建过程中将数据库模式复制到 Rust 应用程序中。这是很混乱的。这也解释了为什么当我作为金融科技研发软件工程师时,我们必须手动编写所有 SQL 代码的原因。
数据库与我们的应用程序是分开的。正因为如此,我们应该将它们隔离,这样我们就不会把手动编写 SQL 代码看作是一种阻碍。在学习一项将为你未来节省头痛的好技能时,要接受它。我们必须记住不要强迫一个工具适用于所有事情,它必须是适合正确工作的正确工具。在我们的create to-do items
迁移文件夹中,我们在up.sql
文件中定义我们的to_do
表,使用以下 SQL 条目:
CREATE TABLE to_do (
id SERIAL PRIMARY KEY,
title VARCHAR NOT NULL,
status VARCHAR NOT NULL,
date timestamp NOT NULL DEFAULT NOW()
)
在前面的代码中,我们有一个唯一的id
字段,然后是title
和status
。我们还添加了一个date
字段,默认情况下,该字段包含将待办事项插入表中的当前时间。这些字段被包含在一个CREATE TABLE
命令中。在我们的down.sql
文件中,如果我们正在降级迁移,我们需要使用以下 SQL 命令来删除表:
DROP TABLE to_do
现在我们已经为我们的up.sql
和down.sql
文件编写了 SQL 代码,我们可以用以下图表来描述我们的代码做了什么:
图 6.4 – up.sql 和 down.sql 脚本的效果
-
现在我们已经准备好了迁移,我们可以使用以下终端命令来运行它:
diesel migration run
上述命令将运行迁移,创建to_do
表。有时,我们可能在 SQL 中引入不同的字段类型。为了纠正这一点,我们可以在up.sql
和down.sql
文件中更改 SQL,并运行以下redo
终端命令:
diesel migration redo
上述命令将运行down.sql
文件,然后运行up.sql
文件。
-
接下来,我们可以在我们的数据库 Docker 容器中运行命令来检查我们的数据库是否具有我们定义的正确字段的
to_do
表。我们可以通过在数据库 Docker 容器上直接运行命令来实现这一点。我们可以使用以下终端命令进入容器,使用username
用户名,同时指向to_do
数据库:docker exec -it 5fdeda6cfe43 psql -U username to_do
必须注意的是,在前面的命令中,我的容器 ID 是5fdeda6cfe43
,但你的容器 ID 将不同。如果你没有输入正确的容器 ID,你将不会在正确的数据库上运行正确的数据库命令。运行此命令后,我们得到一个具有以下提示的 shell 界面:
to_do=#
在前面的提示之后,当我们输入\c
时,我们将连接到数据库。这通常会用一条语句表示我们现在已连接到to_do
数据库并以username
用户身份连接。最后,输入\d
将列出关系,在终端中显示以下表:
Schema | Name | Type | Owner
--------+----------------------------+----------+----------
public | __diesel_schema_migrations | table | username
public | to_do | table | username
public | to_do_id_seq | sequence | username
从前面的表中,我们可以看到有一个迁移表来跟踪数据库的迁移版本。
-
我们还有一个
to_do
表格和to_do
项 ID 的序列。要检查模式,我们只需要输入\d+ to_do
,在终端中会得到以下模式:Table "public.to_do"
Column | Type | Collation |
--------+-----------------------------+-----------+
id | integer | |
title | character varying | |
status | character varying | |
date | timestamp without time zone | |
| Nullable | Default | Storage |
+----------+-----------------------------------+----------+
| not null | nextval('to_do_id_seq'::regclass) | plain |
| not null | | extended |
| not null || extended |
| not null | now() | plain |
Indexes:
"to_do_pkey" PRIMARY KEY, btree (id)
在前面的表中,我们可以看到我们的模式与我们设计的完全一致。我们在date
列中得到了更多一点的信息,明确指出我们没有得到待办事项创建的时间。鉴于我们的迁移已经成功,我们应该在下一步探索迁移如何在数据库中显示。
-
我们可以通过运行以下 SQL 命令来检查我们的迁移表:
SELECT * FROM __diesel_schema_migrations;
前面的命令给出了以下表作为输出:
version | run_on
----------------+----------------------------
00000000000000 | 2022-04-25 23:10:07.774008
20220424171018 | 2022-04-25 23:29:55.167845
如您所见,这些迁移对于调试很有用,因为有时我们可能会忘记在更新数据模型后运行迁移。为了进一步探索,我们可以在 Docker 容器外部使用以下命令回滚我们的最后一个迁移:
diesel migration revert
然后我们得到以下打印输出,告知我们已执行回滚操作:
Rolling back migration 2022-04-24-171018_create_to_do_items
现在我们已经回滚了迁移,我们的迁移表将如下所示:
version | run_on
----------------+----------------------------
00000000000000 | 2022-04-25 23:10:07.774008
在前面的打印输出中,我们可以看到我们的最后一个迁移已被移除。因此,我们可以推断迁移表不是一个日志。它只跟踪当前活动的迁移。迁移的跟踪将帮助diesel
客户端在运行迁移时执行正确的迁移。
在本节中,我们使用diesel
客户端连接到 Docker 容器中的数据库。然后我们在环境文件中定义了数据库 URL。接下来,我们初始化了一些迁移并在数据库中创建了一个表。更好的是,我们直接连接到了 Docker 容器,在那里我们可以运行一系列命令来探索我们的数据库。现在,我们的数据库通过终端中的客户端完全交互式,我们可以开始构建我们的to-do
项数据库模型,以便我们的 Rust 应用程序可以与数据库交互。
将我们的应用程序连接到 PostgreSQL
在上一节中,我们成功使用终端连接到 PostgreSQL 数据库。然而,我们现在需要我们的应用程序管理待办事项的数据库读写操作。在本节中,我们将我们的应用程序连接到在 Docker 中运行的数据库。为了连接,我们必须构建一个建立连接并返回它的函数。必须强调的是,管理数据库连接和配置有更好的方法,我们将在本章末尾介绍。现在,我们将实现最简单的数据库连接以使我们的应用程序运行。在 src/database.rs
文件中,我们使用以下代码定义函数:
use diesel::prelude::*;
use diesel::pg::PgConnection;
use dotenv::dotenv;
use std::env;
pub fn establish_connection() -> PgConnection {
dotenv().ok();
let database_url = env::var("DATABASE_URL")
.expect("DATABASE_URL must be set");
PgConnection::establish(&database_url)
.unwrap_or_else(
|_| panic!("Error connecting to {}",
database_url))
}
在前面的代码中,首先,你可能注意到 use diesel::prelude::*;
导入命令。此命令导入了连接、表达式、查询、序列化和结果结构体的一系列。一旦完成所需的导入,我们定义 connection
函数。首先,我们需要确保程序在无法使用 dotenv().();
命令加载环境时不会抛出错误。
完成此操作后,我们从环境变量中获取数据库 URL 并使用数据库 URL 的引用建立连接。然后我们 unwrap
结果,如果我们无法做到这一点,我们可能会恐慌地显示数据库 URL,因为我们想确保我们使用的是正确的 URL 和正确的参数。由于连接是函数中的最后一个语句,这就是返回的内容。
现在我们已经建立了自己的连接,我们需要定义模式。这将把 to-do
项的变量映射到数据类型。我们可以在 src/schema.rs
文件中使用以下代码定义我们的模式:
table! {
to_do (id) {
id -> Int4,
title -> Varchar,
status -> Varchar,
date -> Timestamp,
}
}
在前面的代码中,我们使用了 diesel
宏 table!
,它指定了一个表的存在。这个映射很简单,我们将在未来的数据库查询和插入中引用列和表使用此模式。
现在我们已经建立了数据库连接并定义了模式,我们必须在 src/main.rs
文件中声明它们,如下所示:
#[macro_use] extern crate diesel;
extern crate dotenv;
use actix_web::{App, HttpServer};
use actix_service::Service;
use actix_cors::Cors;
mod schema;
mod database;
mod views;
mod to_do;
mod state;
mod processes;
mod json_serialization;
mod jwt;
在前面的代码中,我们的第一个导入还启用了过程宏。如果我们不使用 #[macro_use]
标签,那么我们无法在其他文件中引用我们的模式。我们的模式定义也无法使用表宏。我们还导入了 dotenv
crate。我们保留了在 第五章 中创建的模块,在浏览器中显示内容。我们还定义了我们的模式和数据库模块。完成这些后,我们就需要开始构建我们的数据模型了。
创建我们的数据模型
我们将使用我们的数据模型在 Rust 中定义数据库数据的参数和行为。它们本质上充当数据库和 Rust 应用程序之间的桥梁,如下面的图所示:
图 6.5 – 模型、模式和数据库之间的关系
在本节中,我们将定义待办事项的数据模型。然而,我们需要使我们的应用程序能够根据需要添加更多数据模型。为此,我们执行以下步骤:
-
我们定义了一个新的待办事项数据模型结构体。
-
然后,我们为新的待办事项结构体定义了一个构造函数。
-
最后,我们定义了一个待办事项数据模型结构体。
在开始编写任何代码之前,我们在src
目录中定义了以下文件结构,如下所示:
├── models
│ ├── item
│ │ ├── item.rs
│ │ ├── mod.rs
│ │ └── new_item.rs
│ └── mod.rs
在前述结构中,每个数据模型在models
目录下都有一个目录。在该目录内,我们有两个文件定义了模型。一个用于新的插入,另一个用于管理数据库周围的数据。新的插入数据模型没有 ID 字段。
没有 ID 字段,因为数据库将为项目分配一个 ID;我们不会在之前定义它。然而,当我们与数据库中的项目交互时,我们将得到它们的 ID,我们可能想按 ID 进行筛选。因此,现有的数据项模型包含一个 ID 字段。我们可以在new_item.rs
文件中用以下代码定义我们的新项目数据模型:
use crate::schema::to_do;
use chrono::{NaiveDateTime, Utc};
#[derive(Insertable)]
#[table_name="to_do"]
pub struct NewItem {
pub title: String,
pub status: String,
pub date: NaiveDateTime
}
impl NewItem {
pub fn new(title: String) -> NewItem {
let now = Utc::now().naive_local();
return NewItem{
title,
status: String::from("PENDING"),
date: now
}
}
}
如前述代码所示,我们导入我们的表定义,因为我们将要引用它。然后我们使用title
和status
定义我们的新项目,它们应该是字符串类型。chrono
库用于定义我们的date
字段为NaiveDateTime
。然后我们使用diesel
宏定义表属于此结构体的"to_do"
表。不要被这个定义使用引号的事实所迷惑。
如果我们不导入我们的模式,应用程序将无法编译,因为它将无法理解引用。我们还添加了另一个diesel
宏,声明我们允许使用Insertable
标签将数据插入数据库。如前所述,我们不会向此宏添加任何更多标签,因为我们只想让这个结构体插入数据。
我们还添加了一个new
函数,使我们能够定义创建新结构的标准规则。例如,我们只将创建待处理状态的新项目。这减少了创建恶意状态的风险。如果我们想以后扩展,new
函数可以接受一个status
输入,并通过匹配语句运行它,如果状态不是我们愿意接受的状态之一,则抛出错误。我们还自动声明date
字段是创建项目的日期。
有了这个想法,我们可以在item.rs
文件中定义我们的项目数据模型,如下所示:
use crate::schema::to_do;
use chrono::NaiveDateTime;
#[derive(Queryable, Identifiable)]
#[table_name="to_do"]
pub struct Item {
pub id: i32,
pub title: String,
pub status: String,
pub date: NaiveDateTime
}
如前述代码所示,NewItem
和Item
结构体之间的唯一区别是我们没有构造函数;我们将Insertable
标签替换为Queryable
和Identifiable
,并在结构体中添加了一个id
字段。为了使这些对应用程序的其余部分可用,我们在models/item/mod.rs
文件中定义了以下代码:
pub mod new_item;
pub mod item;
然后,在models/mod.rs
文件中,我们用以下代码使item
模块对其他模块和main.rs
文件公开:
pub mod item;
接下来,我们在main.rs
文件中用以下行代码定义我们的模型模块:
mod models;
现在我们可以在整个应用程序中访问我们的数据模型。我们还将读取和写入数据库的行为锁定。接下来,我们可以继续导入这些数据模型并在我们的应用程序中使用它们。
从数据库获取数据
当与数据库交互时,可能需要一些时间来习惯我们这样做的方式。不同的对象关系映射器(ORMs)和语言有不同的怪癖。虽然基本原理相同,但这些 ORM 的语法可能会有很大差异。因此,仅仅使用 ORM 计时并不能使您更有信心,也不能解决更复杂的问题。我们可以从最简单的机制开始,即从表中获取所有数据。
为了探索这一点,我们可以从to_do
表获取所有项目,并在每个视图的末尾返回它们。我们在第四章,处理 HTTP 请求中定义了这种机制。就我们的应用程序而言,您会记得我们有一个get_state
函数,它为我们的前端打包待办事项。这个get_state
函数位于src/json_serialization/to_do_items.rs
文件中的ToDoItems
结构体中。最初,我们必须用以下代码导入所需的项:
use crate::diesel;
use diesel::prelude::*;
use crate::database::establish_connection;
use crate::models::item::item::Item;
use crate::schema::to_do;
在前面的代码中,我们导入了diesel
包和宏,这使我们能够构建数据库查询。然后我们导入establish_connection
函数以连接到数据库,然后导入模式和数据库模型以进行查询和处理数据。然后我们可以用以下代码重构我们的get_state
函数:
pub fn get_state() -> ToDoItems {
let connection = establish_connection();
let mut array_buffer = Vec::new();
let items = to_do::table
.order(to_do::columns::id.asc())
.load::<Item>(&connection).unwrap();
for item in items {
let status =
TaskStatus::new(&item.status.as_str());
let item = to_do_factory(&item.title, status);
array_buffer.push(item);
}
return ToDoItems::new(array_buffer)
}
在前面的代码中,我们首先建立连接。一旦建立了数据库连接,我们就从我们的表获取并基于它构建一个数据库查询。查询的第一部分定义了顺序。正如我们所见,我们的表也可以传递列的引用,这些列也有它们自己的函数。
我们首先定义将用于加载数据的结构体,并传入连接的引用。因为宏在load
中定义了结构体,如果我们把NewItem
结构体传递给load
函数,我们会得到一个错误,因为Queryable
宏没有为该结构体启用。
还可以注意到,load
函数在其函数括号之前有一个前缀,其中参数通过load::<Item>(&connection)
传递。这个前缀概念在第一章,Rust 快速入门,使用特性行验证部分,步骤 4中有所介绍,但到目前为止,您可以使用以下代码:
fn some_function<T: SomeType>(some_input: &T) -> () {
. . .
}
传递到前缀<Item>
的类型定义了接受的输入类型。这意味着当编译器运行时,为传递给我们的load
函数的每个类型编译一个单独的函数。
然后,我们直接解包 load
函数,得到数据库中的项目向量。有了数据库中的数据,我们通过构造项目结构体并将它们追加到我们的缓冲区中来进行循环。一旦完成,我们从缓冲区中构造 ToDoItems
JSON 架构并返回它。现在我们已经实施了这一变更,所有我们的视图都将直接从数据库返回数据。如果我们运行这个程序,将不会显示任何项目。如果我们尝试创建任何项目,它们将不会显示。然而,尽管它们没有被显示,我们所做的是从数据库中获取数据,并以我们想要的 JSON 结构进行序列化。这是从数据库返回数据并以标准方式将其返回给请求者的基础。这是在 Rust 中构建的 API 的骨架。因为我们不再依赖于从 JSON 状态文件中读取项目时读取,我们可以从 src/json_serialization/to_do_items.rs
文件中删除以下导入:
use crate::state::read_file;
use serde_json::value::Value;
use serde_json::Map;
我们移除了前面的导入,因为我们没有重构任何其他端点。因此,create
端点将正确触发;然而,该端点只是在 JSON 状态文件中创建项目,而 return_state
已经不再读取这些项目。为了再次启用创建功能,我们必须重构 create
端点以将新项目插入到数据库中。
插入到数据库中
在本节中,我们将构建并创建一个视图来创建待办事项。如果我们还记得创建待办事项的规则,我们不想创建重复的待办事项。这可以通过唯一约束来实现。然而,目前保持简单是好的。相反,我们将基于传递给视图的标题对数据库执行一个筛选。然后我们进行检查,如果没有返回结果,我们将向数据库中插入一个新的 to-do
事项。我们通过重构 views/to_do/create.rs
文件中的代码来完成这项工作。首先,我们重新配置导入,如下面的代码所示:
use crate::diesel;
use diesel::prelude::*;
use actix_web::HttpResponse;
use actix_web::HttpRequest;
use crate::json_serialization::to_do_items::ToDoItems;
use crate::database::establish_connection;
use crate::models::item::new_item::NewItem;
use crate::models::item::item::Item;
use crate::schema::to_do;
在前面的代码中,我们导入了必要的 diesel
导入以执行上一节中描述的查询。然后,我们导入了 actix-web
结构体,以便视图处理请求并定义结果。接着,我们导入了与数据库交互的数据库结构体和函数。现在我们已经拥有了所有这些,我们可以开始工作在 create
视图上了。在我们的 pub async fn create
函数内部,我们首先从请求中获取 to-do
项目的标题的两个引用:
pub async fn create(req: HttpRequest) -> HttpResponse {
let title: String = req.match_info().get("title"
).unwrap().to_string();
. . .
}
一旦我们从前面代码中的 URL 中提取了标题,我们就建立数据库连接,并使用该连接对我们的表进行数据库调用,如下面的代码所示:
let connection = establish_connection();
let items = to_do::table
.filter(to_do::columns::title.eq(&title.as_str()))
.order(to_do::columns::id.asc())
.load::<Item>(&connection)
.unwrap();
如前述代码所示,查询基本上与上一节的查询相同。然而,我们有一个filter
部分,它引用我们的title
列,必须等于我们的title
。如果正在创建的项目确实是新的,则不会创建任何项目;因此,结果的长度将为零。随后,如果长度为零,我们应该创建一个NewItem
数据模型,然后将其插入数据库,以便在函数结束时返回状态,如下面的代码所示:
if items.len() == 0 {
let new_post = NewItem::new(title);
let _ =
diesel::insert_into(to_do::table).values(&new_post)
.execute(&connection);
}
return HttpResponse::Ok().json(ToDoItems::get_state())
我们可以看到 Diesel 有一个insert
函数,它接受表和我们对构建的数据模型的引用。因为我们已经切换到数据库存储,所以我们可以删除以下导入,因为它们不再需要:
use serde_json::value::Value;
use serde_json::Map;
use crate::state::read_file;
use crate::processes::process_input;
use crate::to_do::{to_do_factory, enums::TaskStatus};
现在,使用我们的应用,我们将能够创建待办事项,然后看到这些事项在我们的应用前端弹出。因此,我们可以看到我们的create
和get state
函数正在工作;它们正在与我们的数据库交互。如果你遇到麻烦,一个常见的错误是忘记启动我们的docker-compose
。(注意:请记住做这件事,否则,应用将无法连接到数据库,因为它没有运行)。然而,我们无法将待办事项的状态编辑为完成
。我们将不得不在数据库中编辑我们的数据来完成这个操作。
编辑数据库
当我们编辑我们的数据时,我们将从数据库中获取数据模型,然后使用 Diesel 的数据库调用函数来编辑条目。为了使我们的edit
函数与数据库交互,我们可以在views/to_do/edit.rs
文件中编辑我们的视图。我们首先重构导入,如下面的代码所示:
use crate::diesel;
use diesel::prelude::*;
use actix_web::{web, HttpResponse};
use crate::json_serialization::{to_do_item::ToDoItem,
to_do_items::ToDoItems};
use crate::jwt::JwToken;
use crate::database::establish_connection;
use crate::schema::to_do;
如前述代码所示,出现了一个模式。这个模式是,我们导入依赖项来处理身份验证、数据库连接和模式,然后构建一个执行我们想要的操作的函数。我们已经覆盖了导入及其含义。在我们的edit
视图中,我们这次必须只获取一个对标题的引用,如下面的代码所示:
pub async fn edit(to_do_item: web::Json<ToDoItem>,
token: JwToken) -> HttpResponse {
let connection = establish_connection();
let results = to_do::table.filter(to_do::columns::title
.eq(&to_do_item.title));
let _ = diesel::update(results)
.set(to_do::columns::status.eq("DONE"))
.execute(&connection);
return HttpResponse::Ok().json(ToDoItems::get_state())
}
在前述代码中,我们可以看到我们在不加载数据的情况下对数据库执行了过滤。这意味着results
变量是一个UpdateStatement
结构体。然后我们使用这个UpdateStatement
结构体来在数据库中更新项目为完成
。鉴于我们不再使用 JSON 文件,我们可以在views/to_do/edit.rs
文件中删除以下导入:
use crate::processes::process_input;
use crate::to_do::{to_do_factory, enums::TaskStatus};
use serde_json::value::Value;
use serde_json::Map;
use crate::state::read_file;
在前述代码中,我们可以看到我们调用了update
函数,并用从数据库获取的结果填充它。然后我们将status
列设置为完成
,然后使用连接的引用来执行。现在我们可以使用这个来编辑我们的to-do
事项,使它们移动到完成列表。然而,我们无法删除它们。为了做到这一点,我们将不得不重构我们的最终端点,以完全重构我们的应用以连接到数据库。
删除数据
在删除数据时,我们将采取与上一节编辑时相同的方法。我们将从数据库中获取一个项目,通过 Diesel 的 delete
函数传递,然后返回状态。目前,我们应该对这种方法感到舒适,因此建议你在 views/to_do/delete.rs
文件中自己尝试实现它。以下是导入部分的代码:
use crate::diesel;
use diesel::prelude::*;
use actix_web::{web, HttpResponse};
use crate::database::establish_connection;
use crate::schema::to_do;
use crate::json_serialization::{to_do_item::ToDoItem,
to_do_items::ToDoItems};
use crate::jwt::JwToken;
use crate::models::item::item::Item;
在前面的代码中,我们依赖于 Diesel 包和预导入,这样我们就可以使用 Diesel 宏。没有 prelude
,我们就无法使用模式。然后我们导入返回数据给客户端所需的 Actix Web 结构体。然后导入我们构建来管理待办事项数据的包。对于 delete
函数,代码如下:
pub async fn delete(to_do_item: web::Json<ToDoItem>,
token: JwToken) -> HttpResponse {
let connection = establish_connection();
let items = to_do::table
.filter(to_do::columns::title.eq(
&to_do_item.title.as_str()))
.order(to_do::columns::id.asc())
.load::<Item>(&connection)
.unwrap();
let _ = diesel::delete(&items[0]).execute(&connection);
return HttpResponse::Ok().json(ToDoItems::get_state())
}
为了进行质量控制,让我们按照以下步骤进行:
-
在文本输入框中输入
buy canoe
并点击 创建 按钮。 -
在文本输入框中输入
go dragon boat racing
并点击 创建 按钮。 -
点击 buy canoe 项上的 编辑 按钮。完成此操作后,我们应该在前端看到以下输出:
图 6.6 – 预期输出
在前面的图中,我们已经购买了我们的独木舟,但还没有参加龙舟比赛。现在,我们的应用程序与我们的 PostgreSQL 数据库无缝工作。我们可以创建、编辑和删除我们的待办事项。由于在之前的章节中定义了结构,移除数据库的 JSON 文件机制并没有花费太多工作。数据处理和返回请求已经就绪。当我们现在运行应用程序时,你可能已经意识到在编译时会有以下输出:
warning: function is never used: `read_file`
--> src/state.rs:17:8
|
17 | pub fn read_file(file_name: &str) -> Map<String, Value> {
. . .
warning: function is never used: `process_pending`
--> src/processes.rs:18:4
|
18 | fn process_pending(item: Pending, command: String,
state: &Map<String, Value>) {
根据前面的输出,所发生的情况是我们不再使用 src/state.rs
文件或 src/processes.rs
文件。这些文件不再被使用,因为 src/database.rs
文件现在正在使用真实数据库来管理我们的存储、删除和编辑持久数据。我们还使用实现了 Diesel 的 Queryable
和 Identifiable
特性的数据库模型结构体将数据库中的数据处理到结构体中。因此,我们可以删除 src/state.rs
和 src/database.rs
文件,因为它们不再需要。我们也不再需要 src/to_do/traits
模块,因此也可以将其删除。接下来,我们可以删除所有对特质的引用。删除引用实际上意味着从 src/to_do/mod.rs
文件中删除特质,并从 to_do
模块的结构体中删除这些特质的导入和实现。例如,要从 src/to_do/structs/pending.rs
文件中删除特质,我们只需删除以下导入:
use super::super::traits::get::Get;
use super::super::traits::edit::Edit;
use super::super::traits::create::Create;
然后,我们可以删除以下特质的实现:
impl Get for Pending {}
impl Edit for Pending {}
impl Create for Pending {}
我们还必须在src/to_do/structs/done.rs
文件中移除特性。我们现在有机会欣赏我们结构良好、独立代码的回报。我们可以轻松地插入不同的持久化存储方法,因为我们只需删除几个文件和特性即可简单地移除现有的存储方法。然后我们只是移除了特性的实现。就是这样。我们不必深入函数和修改代码行。因为我们的读取功能在特性中,通过移除特性的实现,我们只需移除特性的实现就能完全切断应用程序。以这种方式结构代码在将来真的很划算。在过去,我不得不从一个服务器中提取代码到另一个服务器中,以重构微服务或升级或切换方法,例如存储选项。拥有独立的代码使重构变得更容易、更快、更不容易出错。
我们的应用程序现在与数据库完全兼容。然而,我们可以改进我们数据库的实现。在我们这样做之前,然而,我们应该在下一节重构我们的配置代码。
配置我们的应用程序
目前,我们正在将数据库 URL 存储在.env
文件中。这很好,但我们也可以使用yaml
配置文件。在开发网络服务器时,它们将在不同的环境中运行。例如,目前,我们正在在我们的本地机器上运行我们的 Rust 服务器。然而,稍后我们将把服务器打包成我们自己的 Docker 镜像并在云上部署它。由于微服务基础设施,我们可能会使用在一个集群中构建的服务器,并将其插入到连接到不同数据库的不同集群中。因此,定义所有内外部流量的配置文件变得至关重要。当我们部署我们的服务器时,我们可以向文件存储发出请求,例如 AWS S3,以获取服务器的正确配置文件。必须注意的是,环境变量可以优先用于部署,因为它们可以被传递到容器中。我们将在第十三章“干净 Web 应用程序仓库的最佳实践”中介绍如何使用环境变量配置 Web 应用程序。现在,我们将专注于使用配置文件。我们还需要使我们的服务器在加载配置文件方面具有灵活性。例如,我们不应该需要将配置文件放在特定的目录中,并具有特定的名称才能加载到我们的服务器中。我们应该为正确的上下文正确命名我们的配置文件,以降低错误配置文件被加载到服务器中的风险。我们的文件路径可以在启动服务器时传递。因为我们正在使用yaml
文件,所以我们需要在Cargo.toml
文件中定义serde_yaml
依赖项,如下所示:
serde_yaml = "0.8.23"
现在我们可以读取yaml
文件了,我们可以构建自己的配置模块,该模块将yaml
文件值加载到 HashMap 中。这可以通过一个结构体来完成;因此,我们应该将配置模块放入一个文件中,即src/config.rs
文件。首先,我们使用以下代码导入所需的模块:
use std::collections::HashMap;
use std::env;
use serde_yaml;
在前面的代码中,我们使用env
来捕获传递给程序的环境变量,使用HashMap
来存储配置文件中的数据,并使用serde_yaml
包来处理配置文件中的yaml
值。然后,我们使用以下代码定义包含我们配置数据的结构体:
pub struct Config {
pub map: HashMap<String, serde_yaml::Value>
}
在前面的代码中,我们可以看到我们的数据键的值是String
,而属于这些键的值是yaml
值。然后我们为我们的结构体构建一个构造函数,它接受传递给程序的最后一个参数,根据传递给程序的文件路径打开文件,并使用以下代码将文件中的数据加载到我们的map
字段中:
impl Config {
pub fn new() -> Config {
let args: Vec<String> = env::args().collect();
let file_path = &args[args.len() - 1];
let file = std::fs::File::open(file_path).unwrap();
let map: HashMap<String, serde_yaml::Value> =
serde_yaml::
from_reader(file).unwrap();
return Config{map}
}
}
现在已经定义了config
结构体,我们可以在src/main.rs
文件中使用以下代码行来定义我们的config
模块:
mod config;
然后,我们必须重构我们的src/database.rs
文件,以便从yaml
配置文件中加载。我们重构的导入形式如下:
use diesel::prelude::*;
use diesel::pg::PgConnection;
use crate::config::Config;
我们可以看到,所有对env
的引用都已移除,因为现在这已在我们的config
模块中处理。然后我们加载我们的文件,获取我们的DB_URL
键,并直接展开与DB_URL
键关联的变量的结果,将yaml
值转换为字符串并直接展开转换的结果。我们直接展开获取和转换函数,因为如果它们失败,我们无论如何都无法连接到数据库。如果我们无法连接,我们希望尽快知道错误,并显示一个清晰的错误消息,说明错误发生的位置。现在,我们可以将我们的数据库 URL 移入 Rust 应用程序根目录下的config.yml
文件中,内容如下:
DB_URL: postgres://username:password@localhost:5433/to_do
接下来,我们可以使用以下命令运行我们的应用程序:
cargo run config.yml
config.yml
文件是配置文件的路径。如果你运行docker-compose
和前端,你会看到我们的配置文件中的数据库 URL 正在被加载,并且我们的应用程序已连接到数据库。然而,这个数据库连接有一个问题。每次我们执行establish_connection
函数时,我们都会与我们的数据库建立连接。这可以工作;然而,这不是最优的。在下一节中,我们将通过数据库连接池来提高数据库连接的效率。
构建数据库连接池
在本节中,我们将创建一个数据库连接池。数据库连接池是一定数量的数据库连接。当我们的应用程序需要数据库连接时,它会从池中取出连接,当应用程序不再需要连接时,它会将连接放回池中。如果池中没有剩余的连接,应用程序将等待直到有可用的连接,如下面的图示所示:
图 6.7 – 限制为三个连接的数据库连接池
在我们重构数据库连接之前,我们需要在我们的Cargo.toml
文件中安装以下依赖项:
lazy_static = "1.4.0"
然后我们使用以下代码在src/database.rs
文件中定义我们的导入:
use actix_web::dev::Payload;
use actix_web::error::ErrorServiceUnavailable;
use actix_web::{Error, FromRequest, HttpRequest};
use futures::future::{Ready, ok, err};
use lazy_static::lazy_static;
use diesel::{
r2d2::{Pool, ConnectionManager, PooledConnection},
pg::PgConnection,
};
use crate::config::Config;
从前面代码中定义的导入中,你认为我们在编写新的数据库连接时会做什么?到目前为止,这是一个停下来思考你可以用这些导入做什么的好时机。
如果你还记得,第一个导入块将在请求击中视图之前建立数据库连接。第二个导入块使我们能够定义我们的数据库连接池。最后的Config
参数是为了获取连接的数据库 URL。现在我们的导入已经完成,我们可以使用以下代码定义连接池结构体:
type PgPool = Pool<ConnectionManager<PgConnection>>;
pub struct DbConnection {
pub db_connection: PgPool,
}
在前面的代码中,我们声明我们的PgPool
结构体是一个连接管理器,它管理池内的连接。然后我们使用以下代码构建我们的连接,这是一个静态引用:
lazy_static! {
pub static ref DBCONNECTION: DbConnection = {
let connection_string =
Config::new().map.get("DB_URL").unwrap()
.as_str().unwrap().to_string();
DbConnection {
db_connection: PgPool::builder().max_size(8)
.build(ConnectionManager::new(connection_string))
.expect("failed to create db connection_pool")
}
};
}
在前面的代码中,我们从配置文件中获取 URL 并构建一个返回并因此分配给DBCONNECTION
静态引用变量的连接池。因为这是一个静态引用,所以我们的DBCONNECTION
变量的生命周期与服务器匹配。现在我们可以重构establish_connection
函数,使其从数据库连接池中获取连接,以下是一段代码:
pub fn establish_connection() ->
PooledConnection<ConnectionManager<PgConnection>>{
return DBCONNECTION.db_connection.get().unwrap()
}
在前面的代码中,我们可以看到我们返回了一个PooledConnection
结构体。然而,我们不想每次需要时都调用establish_connection
函数。我们还想在 HTTP 请求击中视图之前拒绝请求,如果我们无法建立连接,因为我们不想加载无法处理的视图。就像我们的JWToken
结构体一样,我们可以创建一个结构体来创建数据库连接并将该数据库连接传递到视图中。我们的结构体有一个字段,即一个池连接,如下所示:
pub struct DB {
pub connection: PooledConnection<ConnectionManager<PgConnection>>
}
使用这个DB
结构体,我们可以像处理我们的JWToken
结构体一样实现FromRequest
特性,以下是一段代码:
impl FromRequest for DB {
type Error = Error;
type Future = Ready<Result<DB, Error>>;
fn from_request(_: &HttpRequest, _: &mut Payload) ->
Self::Future{
match DBCONNECTION.db_connection.get() {
Ok(connection) => {
return ok(DB{connection})
},
Err(_) => {
return err(ErrorServiceUnavailable(
"could not make connection to database"))
}
}
}
}
在这里,我们并没有直接解包获取数据库连接。相反,如果连接时出现错误,我们返回一个带有有用信息的错误。如果我们的连接成功,我们随后返回它。我们可以在我们的视图中实现这一点。为了避免重复代码,我们只需使用edit
视图,但我们应该将这种方法应用到所有我们的视图中。首先,我们定义以下导入:
use crate::diesel;
use diesel::prelude::*;
use actix_web::{web, HttpResponse};
use crate::json_serialization::{to_do_item::ToDoItem,
to_do_items::ToDoItems};
use crate::jwt::JwToken;
use crate::schema::to_do;
use crate::database::DB;
在前面的代码中,我们可以看到我们导入了DB
结构体。现在,我们的edit
视图应该如下所示:
pub async fn edit(to_do_item: web::Json<ToDoItem>,
token: JwToken, db: DB) -> HttpResponse {
let results = to_do::table.filter(to_do::columns::title
.eq(&to_do_item.title));
let _ = diesel::update(results)
.set(to_do::columns::status.eq("DONE"))
.execute(&db.connection);
return HttpResponse::Ok().json(ToDoItems::get_state())
}
在前面的代码中,我们可以看到我们直接引用了DB
结构体的connection
字段。事实上,我们可以通过将DB
结构体传递到视图中,就像我们的认证令牌一样,简单地获取一个池化连接到我们的视图中。
摘要
在本章中,我们构建了一个开发环境,我们的应用程序可以通过 Docker 与数据库进行交互。一旦完成这项工作,我们就探索了容器和镜像的列表,以检查我们的系统运行情况。然后,我们使用diesel
包创建了迁移。之后,我们安装了diesel
客户端,并将数据库 URL 定义为环境变量,以便我们的 Rust 应用程序和迁移可以直接连接到数据库容器。
然后,我们运行了迁移,并定义了在迁移运行时将触发的 SQL 脚本,然后运行了它们。一旦完成所有这些,我们再次检查数据库容器,以查看迁移是否实际上已执行。然后,我们在 Rust 中定义了数据模型,并重构了我们的 API 端点,以便它们在数据库上执行get
、edit
、create
和delete
操作,以跟踪待办事项。
我们在这里所做的是升级了我们的数据库存储系统。我们离拥有一个生产就绪的系统又近了一步,因为我们不再依赖于 JSON 文件来存储我们的数据。你现在拥有了执行数据库管理任务所需的技能,这些任务使你能够管理更改、凭证/访问和模式。我们还对数据库执行了所有必要的操作,以便运行一个创建、获取、更新和删除数据的应用程序。这些技能可以直接转移到你希望在 Rust Web 项目中进行的任何其他项目中。然后,我们通过使数据库连接受连接池限制来增强我们新开发的数据库技能。最后,通过实现FromRequest
特质,使实现数据库连接结构体变得简单,这样其他开发者只需通过将结构体传递到视图中作为参数即可实现我们的连接。如果无法建立数据库连接,视图也得到了保护。
在下一章中,我们将在此基础上构建用户身份验证系统,以便我们可以在访问应用程序时创建用户和检查凭证。我们将结合使用数据库、从头部提取数据、浏览器存储和路由,以确保用户必须登录才能访问待办事项。
问题
-
使用数据库而不是 JSON 文件有哪些优势?
-
你如何创建迁移?
-
我们如何检查迁移?
-
如果我们想在 Rust 中创建一个具有姓名和年龄的用户数据模型,我们应该怎么做?
-
什么是连接池,为什么我们应该使用它?
答案
-
数据库在同时进行多读和多写方面具有优势。数据库还会在插入之前检查数据,看它是否处于正确的格式,并且我们可以使用关联表进行高级查询。
-
我们安装
diesel
客户端并在.env
文件中定义数据库 URL。然后我们使用客户端创建迁移,并编写所需的模式以供迁移使用。接着我们运行迁移。 -
我们使用数据库的容器 ID 来访问容器。然后我们列出表;如果所需的表存在,那么这是一个迁移已运行的迹象。我们还可以检查数据库中的迁移表,以查看它最后一次运行的时间。
-
我们定义一个
NewUser
结构体,其中姓名是一个字符串,年龄是一个整数。然后我们创建一个User
结构体,具有相同的字段和一个额外的整数字段,即 ID。 -
连接池会收集一定数量的连接,这些连接连接到数据库。然后我们的应用程序将这些连接传递给需要它们的线程。这样可以保持连接到数据库的连接数量有限,以避免数据库过载。
第七章:管理用户会话
到目前为止,我们的应用程序通过在视图中点击按钮来操作数据库中的数据。然而,任何遇到我们的应用程序的人都可以编辑数据。虽然我们的应用程序不需要太多的安全性,但了解和实践这一概念在一般 Web 开发中非常重要。
在本章中,我们将构建一个创建用户的系统。此系统还将通过要求用户在通过前端应用程序修改任何待办事项之前登录来管理用户会话。
在本章中,我们将涵盖以下主题:
-
通过数据库迁移创建具有与其他表相关联的具有某些字段唯一约束的用户数据模型
-
认证我们的用户
-
管理用户会话
-
清理认证要求
-
配置认证令牌的过期时间
-
将认证添加到我们的前端
在阅读本章之后,您将能够理解在 Web 服务器上认证用户的基本知识。您还将能够在我们的 Rust 应用程序的服务器端实现此认证,并在前端 React 应用程序中存储凭证。对本章涵盖的概念和实践的理解也将使您能够通过 React Native 在手机应用程序中集成认证,并通过将我们的 React 应用程序包装在Electron中在 Rust 服务器和桌面应用程序中实现。
技术要求
在本章中,我们将在前一章构建的代码基础上进行构建。您可以在以下 URL 找到这些代码:github.com/PacktPublishing/Rust-Web-Programming-2nd-Edition/tree/main/chapter06/building_a_database_connection_pool
。
本章的代码可以在github.com/PacktPublishing/Rust-Web-Programming-2nd-Edition/tree/main/chapter07
找到。
创建我们的用户模型
由于我们在应用程序中管理用户会话,我们需要在允许创建、删除和编辑待办事项之前存储有关用户的信息以检查其凭证。我们将把我们的用户数据存储在PostgreSQL数据库中。虽然这不是必需的,但我们还将数据库中的用户与待办事项链接起来。这将使我们了解如何修改现有表并在表之间创建链接。为了创建我们的用户模型,我们将必须执行以下操作:
-
创建一个
User
数据模型。 -
创建一个
NewUser
数据模型。 -
修改待办事项数据模型,以便我们可以将其链接到用户模型。
-
更新模式文件,包含新的表和修改的字段。
-
在数据库上创建和运行迁移脚本。
在接下来的几节中,我们将详细查看前面的步骤。
创建用户数据模块
在我们开始之前,我们需要更新Cargo.toml
文件中的依赖项,如下所示:
[dependencies]
. . .
bcrypt = "0.13.0"
uuid = {version = "1.0.0", features = ["serde", "v4"]}
我们将使用bcrypt
包来散列和检查密码,以及使用uuid
包为我们用户数据模型生成唯一的 ID。正如我们在第六章中讨论的,使用 PostgreSQL 进行数据持久化,我们需要为我们的用户数据模型创建两个不同的结构体。
新用户将没有id
字段,因为这个字段在数据库中尚不存在。当新用户被插入到表中时,数据库将创建这个 ID。然后我们还有一个具有所有相同字段的结构体,包括我们添加的id
字段,因为我们可能需要在与数据库中现有用户交互时使用这个 ID。ID 数字可以用于引用其他表。它们很短,我们知道它们是唯一的。我们将使用用户 ID 将用户与待办事项链接起来。这些数据模型可以放在以下文件结构中,在src/models.rs
目录下:
└── user
├── mod.rs
├── new_user.rs
└── user.rs
我们将在new_user.rs
文件中定义数据模型。首先,我们必须定义导入,如下所示:
use uuid::Uuid;
use diesel::Insertable;
use bcrypt::{DEFAULT_COST, hash};
use crate::schema::users;
必须注意,我们尚未在模式中定义用户。在完成所有数据模型之后,我们将解决这个问题。在定义了我们的users
模式之前,我们将无法编译我们的代码。我们还将导入一个唯一的 ID 包,因为我们将在创建新用户时创建一个唯一的 ID,以及从diesel
包中导入的Insertable
特质,因为我们将在数据库中插入新用户。然后我们使用bcrypt
包中的hash
函数来散列我们为新用户定义的新密码。我们还可以看到我们导入了来自bcrypt
包的DEFAULT_COST
常量。DEFAULT_COST
仅仅是一个我们将传递给hash
函数的常量。我们将在下一节中探讨为什么这是这种情况,当我们介绍散列密码时。现在我们已经定义了用户数据模型模块并导入了我们需要的内容,我们可以继续到下一节创建NewUser
结构体。
创建一个 NewUser 数据模型
我们可以使用以下代码定义我们的数据模型:
#[derive(Insertable, Clone)]
#[table_name="users"]
pub struct NewUser {
pub username: String,
pub email: String,
pub password: String,
pub unique_id: String,
}
在这里,我们可以看到我们允许我们的数据模型可插入。然而,我们不允许它被查询。我们想要确保当从数据库中检索用户时,他们的 ID 是存在的。我们本可以继续定义用户的通用数据模型,但这并不安全。我们需要确保通过加密来保护我们的密码。如果你还记得第二章,在 Rust 中设计您的 Web 应用程序,我们利用特性允许某些待办事项结构执行操作。一些结构可以创建,而其他结构可以根据它们实现的特性进行删除。我们在这里通过仅实现Insertable
特性来锁定NewUser
结构的函数。然而,我们将通过为User
结构实现其他特性来启用查询,如图下所示:
图 7.1 – 使用特性锁定数据模型结构
现在我们已经创建了将新用户插入数据库的结构,我们可以探索如何在数据库中存储我们的用户密码。
你可能想知道为什么你不能恢复忘记的密码;你只能重置它们。这是因为密码被加密了。加密密码是存储密码时的常见做法。这就是我们使用算法来混淆密码,使其无法被读取的地方。一旦这样做,就无法逆转。
加密后的密码随后被存储在数据库中。为了验证密码,输入的密码会被加密并与数据库中存储的加密密码进行比较。这使我们能够查看输入的加密密码是否与数据库中存储的加密密码相匹配。这有几个优点。首先,它防止了有权访问您数据的员工知道您的密码。如果发生数据泄露,它也防止泄露的数据直接将您的密码暴露给拥有数据的人。
考虑到很多人使用相同的密码进行多项操作(尽管他们不应该这样做),如果你不散列密码并且发生数据泄露,你可以想象这将对使用你应用程序的人造成多大的损害。然而,散列比这更复杂。有一个叫做 盐值 的概念,确保当你散列相同的密码时,不会得到相同的散列结果。它是通过在散列之前向密码中添加额外的数据来实现的。这也是我们传递给 hash
函数的 DEFAULT_COST
常量所在的地方。假设我们获得了数据库中的数据,并想编写代码来猜测数据中的密码。如果我们有足够的计算能力,我们实际上可以猜测密码。因此,我们可以传递一个成本参数。随着成本参数的增加,无论是 CPU 时间还是内存的工作量都会呈指数增长。将成本因子增加一个单位将使计算散列所需的操作数量增加 10,000 或更多。
详细解释密码安全性超出了本书的范围。然而,必须强调的是,在存储密码时,密码散列始终是必须的。幸运的是,所有主流语言中都有一系列模块,只需几行代码即可实现密码的散列和检查。Rust 也不例外。
为了确保我们可以将新的用户以散列密码的形式插入到数据库中,请按照以下步骤操作:
-
首先,我们必须确保在
NewUser
构造函数中散列输入密码,它定义如下:impl NewUser {
pub fn new(username: String,
email: String, password: String) -> NewUser {
let hashed_password: String = hash(
password.as_str(), DEFAULT_COST
).unwrap();
let uuid = Uuid::new_v4().to_string();
return NewUser {
username,
email,
password: hashed_password,
unique_id: uuid
}
}
}
在这里,我们使用了 bcrypt
包中的 hash
函数来散列我们的密码,其中我们还传递了 DEFAULT_COST
常量。我们还使用 Uuid
包创建了一个唯一的 ID,然后使用这些属性构造了一个新的 NewUser
结构体实例。在我们的应用程序中,实际上并不需要一个唯一的 ID。然而,这些在多个服务器和数据库之间通信时可能会很有用。
-
现在我们已经定义了我们的
NewUser
数据模型,我们可以在user.rs
文件中使用以下代码定义我们的通用用户数据模型。首先,我们必须定义以下导入:extern crate bcrypt;
use diesel::{Queryable, Identifiable};
use bcrypt::verify;
use crate::schema::users;
在这里,我们可以看到我们正在使用 verify
函数,并且我们还允许通用用户数据模型结构体可查询和可识别。
-
在上一步定义的导入之后,我们可以构建我们的
User
结构体。记住,这是一个在我们进行数据库查询时将从数据库中加载的结构体。在你继续阅读之前,这是一个尝试自己构建User
结构体的好时机,因为它使用与NewUser
结构体相同的表,但有一个id
字段,并且是查询而不是插入。一旦你构建了你的User
结构体,它应该看起来像以下代码:#[derive(Queryable, Clone, Identifiable)]
#[table_name="users"]
pub struct User {
pub id: i32,
pub username: String,
pub email: String,
pub password: String,
pub unique_id: String
}
我们可以看到,我们只是添加了 id
字段,并推导了 Queryable
特性而不是 Insertable
特性。
-
现在我们已经定义了
User
结构体,我们可以构建一个函数来验证输入的密码是否与用户的密码匹配,以下代码所示:impl User {
pub fn verify(&self, password: String) -> bool {
verify(password.as_str(),
&self.password).unwrap()
}
}
-
现在我们已经定义了模型,我们必须记住在
models/user/mod.rs
文件中使用以下代码注册它们:pub mod new_user;
pub mod user;
-
此外,我们可以通过在
models/mod.rs
文件中添加以下行来使这些模块对应用程序可用:pub mod item;
pub mod user;
因此,我们已经定义了针对用户的数据模型。然而,我们仍然需要将它们与我们的待办事项链接起来。
修改待办事项数据模型
要将数据模型链接到我们的待办事项,我们必须修改我们的待办事项数据模型。我们可以通过多种方式来实现这一点。例如,我们可以在项目表中添加一个user_id
字段,它只是用户表中的unique_id
字段。当我们创建一个新的项目时,我们就会将用户的唯一 ID 传递给项目构造函数。这很容易实现;然而,它确实存在风险。仅仅将用户的唯一 ID 传递给项目并不能强制执行该 ID 是有效的并且在数据库中。没有任何东西阻止我们将已删除用户的 ID 插入到项目构造函数中,从而将孤立的项目插入到数据库中。这将使得以后很难提取,因为我们没有关于孤立项目关联的用户 ID 的引用。我们还可以创建一个新表,该表通过用户 ID 引用项目 ID,如下所示:
图 7.2 – 用于记录用户与项目关联的独立数据库表
这种方法的优点是,只需删除表就可以轻松地将用户与项目解耦。然而,它也没有在创建新条目时对有效用户 ID 或项目 ID 进行强制执行。我们还将不得不进行两次查询,一次是关联表,然后是项目表,以从用户那里获取项目。由于前两种方法(将用户 ID 列附加到项目表或创建包含项目 ID 和用户唯一 ID 的桥梁表)易于实现,我们不会探讨它们;你应该能够在这个阶段自己实现它们。在待办事项应用程序的上下文中,前两种方法将是不够好的,因为它们没有提供任何好处,却在将数据插入我们的数据库时引入了错误的风险。这并不意味着前两种方法永远不会被使用。每个项目的数据需求都是不同的。在我们的项目中,我们将创建一个外键来将我们的用户与项目链接起来,如下所示:
图 7.3 – 用户与项目之间的外键关联
这不允许我们通过一个数据库调用访问与用户关联的项目,但我们只能插入具有合法用户 ID 引用的项目。外键也可以触发级联事件,如果在删除用户时,这将自动删除与该用户关联的所有现有项目,以防止创建孤儿项目。我们通过声明与表的链接宏来创建外键。在 models/item/item.rs
中,我们可以通过以下方式实现:
use crate::schema::to_do;
use chrono::NaiveDateTime;
use super::super::user::user::User;
我们可以看到,我们必须导入 User
结构体,因为我们将在 belongs_to
宏中引用它,以声明我们的 Item
结构体属于 User
结构体,如下面的代码所示:
#[derive(Queryable, Identifiable, Associations)]
#[belongs_to(User)]
#[table_name="to_do"]
pub struct Item {
pub id: i32,
pub title: String,
pub status: String,
pub date: NaiveDateTime,
pub user_id: i32,
}
在这里,我们可以看到我们导入了用户数据模型结构体,使用 belongs_to
宏进行了定义,并添加了一个 user_id
字段来链接结构体。请注意,如果我们没有包含 Associations
宏,belongs_to
宏将无法调用。
我们最后需要做的是在 models/item/new_item.rs
文件中的字段和构造函数中添加 user_id
字段。我们需要这样做,以便可以将新的待办事项与创建该项目的用户链接起来。这可以通过以下代码实现:
use crate::schema::to_do;
use chrono::{NaiveDateTime, Utc};
#[derive(Insertable)]
#[table_name="to_do"]
pub struct NewItem {
pub title: String,
pub status: String,
pub date: NaiveDateTime,
pub user_id: i32,
}
impl NewItem {
pub fn new(title: String, user_id: i32) -> NewItem {
let now = Utc::now().naive_local();
NewItem{
title, status: String::from("PENDING"),
date: now,
user_id
}
}
}
因此,总结我们所做的,所有我们的数据模型结构体都已更改,并且我们能够在与数据库交互时,根据需要使用它们在应用程序中。然而,我们尚未更新数据库,也尚未更新连接应用程序和数据库的桥梁。我们将在下一步这样做。
更新模式文件
为了确保数据模型结构体到数据库的映射是最新的,我们必须使用这些更改更新我们的模式。这意味着我们必须更改待办事项项表的现有模式,并在 src/schema.rs
文件中添加用户模式。这由以下代码表示:
table! {
to_do (id) {
id -> Int4,
title -> Varchar,
status -> Varchar,
date -> Timestamp,
user_id -> Int4,
}
}
table! {
users (id) {
id -> Int4,
username -> Varchar,
email -> Varchar,
password -> Varchar,
unique_id -> Varchar,
}
}
必须注意,在模式文件中,我们的字段定义的顺序与 Rust 数据模型相同。这一点很重要,因为如果我们不这样做,当我们连接到数据库时,字段将不匹配。我们可能还会意识到,我们的模式仅仅只是定义了字段及其类型;它并没有涵盖待办事项表和用户表之间的关系。
我们不必担心这个问题,因为当我们创建和运行自己的迁移时,这个模式文件将随着关系的更新而更新。这导致我们创建自己的迁移来完成这个模式文件。
在数据库上创建和运行迁移脚本
运行迁移的过程与我们之前在 第六章 中讨论的类似,即 使用 PostgreSQL 进行数据持久化,该章节介绍了如何安装 Diesel 客户端并连接到数据库。首先,我们必须使用 docker-compose
命令运行我们的数据库:
docker-compose up
当我们运行迁移时,我们需要在后台运行这个程序。然后我们可以通过运行以下命令来创建迁移脚本:
diesel migration generate create_users
这在迁移目录中创建了一个目录,目录的名称包含create_users
。在这个目录内,我们有两个空的SQL文件。在这里,我们将手动编写自己的 SQL 脚本用于迁移。最初,你可能会觉得这没有必要,因为其他语言中有库可以自动生成这些迁移,但这样做有一些优点。
首先,它让我们保持对 SQL 的熟悉,SQL 是另一个实用的工具。这使得我们能够在解决日常问题时考虑利用 SQL 的解决方案。它还让我们能够更细致地控制迁移的流程。例如,在我们将要创建的迁移中,我们需要创建用户表和基础用户,这样当我们修改to_do
表中的列时,我们可以用占位符用户行的 ID 来填充它。我们通过以下表定义在我们的up.sql
文件中执行这一操作:
CREATE TABLE users (
id SERIAL PRIMARY KEY,
username VARCHAR NOT NULL UNIQUE,
email VARCHAR NOT NULL UNIQUE,
password VARCHAR NOT NULL,
unique_id VARCHAR NOT NULL
);
这很简单。注意,email
和username
字段是唯一的。这是因为我们不希望有重复用户名和电子邮件的用户。在这个级别上放置约束有多个原因。例如,我们可以通过数据库调用用户名和电子邮件来防止这种情况,如果存在重复,则拒绝插入新用户。
然而,代码中可能存在错误,或者有人可能在将来修改我们的代码。可能会引入一个新功能,这个功能没有这个检查,比如编辑功能。可能会有一个迁移修改行或插入新用户。如果你自己编写 SQL,通常最好的做法是确保你使用;
符号来表示操作已经完成。
这个 SQL 命令被触发,然后紧接着下一个命令被触发。在我们的up.sql
文件中,下一个命令插入了一个占位符用户行,使用以下命令:
INSERT INTO users (username, email, password, unique_id)
VALUES ('placeholder', 'placeholder email',
'placeholder password', 'placeholder unique id');
现在我们已经创建了用户,然后我们修改to_do
表。我们可以使用以下命令来完成这个操作,在刚刚写的命令相同的文件下:
ALTER TABLE to_do ADD user_id integer default 1
CONSTRAINT user_id REFERENCES users NOT NULL;
有了这些,我们的up.sql
迁移已经被定义。现在,我们必须定义我们的down.sql
迁移。在向下迁移中,我们基本上必须撤销向上迁移中做的操作。这意味着在to_do
表中删除user_id
列,然后完全删除用户表。这可以通过在down.sql
文件中的以下 SQL 代码来完成:
ALTER TABLE to_do DROP COLUMN user_id;
DROP TABLE users
我们必须记住,Docker 必须运行,以便迁移能够影响数据库。一旦运行了这个迁移,我们可以看到以下代码已经被添加到了src/schema.rs
文件中:
joinable!(to_do -> users (user_id));
allow_tables_to_appear_in_same_query!(
to_do,
users,
);
这使得我们的 Rust 数据模型能够查询用户和待办事项之间的关系。随着这次迁移的完成,我们可以再次运行我们的应用程序。然而,在我们这样做之前,我们只需要在src/views/to_do/create.rs
文件中进行一个小小的修改,在create
视图函数中的新项构造函数添加以下行代码:
let new_post = NewItem::new(title, 1);
现在运行我们的应用程序将导致与我们在第六章中描述的行为相同,即我们的应用程序正在运行我们已制作的迁移。然而,我们还需要查看我们的新用户构造函数是否正常工作,因为我们正在散列密码并生成唯一的 ID。
要做到这一点,我们需要构建一个创建用户端点。为此,我们必须定义模式,然后定义一个视图,将新用户插入到数据库中。我们可以在src/json_serialization/new_user.rs
文件中使用以下代码创建我们的模式:
use serde::Deserialize;
#[derive(Deserialize)]
pub struct NewUserSchema {
pub name: String,
pub email: String,
pub password: String
}
在此之后,我们可以在src/json_serialization/mod.rs
文件中声明新的用户模式pub mod new_user;
。一旦我们的模式被定义,我们可以创建自己的用户视图模块,其文件结构如下:
views
...
└── users
├── create.rs
└── mod.rs
在我们的users/create.rs
文件中,我们需要构建一个创建视图函数。首先,导入以下 crate:
use crate::diesel;
use diesel::prelude::*;
use actix_web::{web, HttpResponse, Responder};
use actix_web::HttpResponseBuilder;
use crate::database::DB;
use crate::json_serialization::new_user::NewUserSchema;
use crate::models::user::new_user::NewUser;
use crate::schema::users;
由于我们已经多次构建我们的视图,因此这些导入不应该令人惊讶。我们导入diesel
宏和 crate,以便我们能够调用数据库。然后我们导入actix_web
特性和结构体,以使数据能够流入和流出视图。接着我们导入我们的模式和结构体,以结构化我们接收和处理的数据。现在我们已经导入了正确的 crate,我们必须使用以下代码定义create
视图函数:
pub async fn create(new_user: web::Json<NewUserSchema>,
db: DB) -> impl Responder {
. . .
}
在这里,我们可以看到我们接受加载到NewUserSchema
结构体的 JSON 数据。我们还使用DB
结构体从连接池中建立数据库连接。在我们的create
视图函数内部,我们从NewUserSchema
结构体中提取所需的数据,使用以下代码创建一个NewUser
结构体:
let new_user = NewUser::new(
new_user.name.clone(),
new_user.email.clone(),
new_user.password.clone()
);
我们必须克隆要传递给NewUser
构造函数的字段,因为字符串没有实现Copy
特性,这意味着我们必须手动完成此操作。然后我们创建数据库的insert
命令并执行以下代码:
let insert_result = diesel::insert_into(users::table)
.values(&new_user)
.execute(&db.connection);
这返回一个Result
结构体。然而,我们并不直接unwrap
它。可能存在冲突。例如,我们可能正在尝试插入一个已经存在于数据库中的用户名或电子邮件的新用户。然而,我们不想让它仅仅出错。这是一个我们预期作为我们已经实现了唯一的用户名和电子邮件约束的边缘情况。如果在视图执行过程中发生了一个合法的错误,我们需要知道它。因此,我们必须给边缘情况提供响应代码。因此,我们匹配插入的结果,并使用以下代码返回适当的响应代码:
match insert_result {
Ok(_) => HttpResponse::Created(),
Err(_) => HttpResponse::Conflict()
}
在这里,我们已经建立了一个数据库连接,从 JSON 体中提取了字段,创建了一个新的NewUser
结构体,然后将其插入到数据库中。与其它视图相比,这里有一个细微的差别。在返回响应中,我们必须先await
然后unwrap
它。这是因为我们不返回 JSON 体。因此,HttpResponse::Ok()
仅仅是一个构建器结构体。
现在我们已经构建了创建视图,我们需要在views/users/mod.rs
文件中定义我们的视图工厂,如下所示:
mod create;
use actix_web::web::{ServiceConfig, post, scope};
pub fn user_views_factory(app: &mut ServiceConfig) {
app.service(
scope("v1/user")
.route("create", post().to(create::create))
);
}
再次,由于我们一直在定期构建视图,这些内容对你来说不应该感到意外。如果确实如此,建议你阅读第三章中“使用 Actix Web 框架管理视图”部分,处理 HTTP 请求,以获得清晰度。现在,我们的主视图工厂在views/mod.rs
文件中应该看起来如下:
mod auth;
mod to_do;
mod app;
mod users;
use auth::auth_views_factory;
use to_do::to_do_views_factory;
use app::app_views_factory;
use users::user_views_factory;
use actix_web::web::ServiceConfig;
pub fn views_factory(app: &mut ServiceConfig) {
auth_views_factory(app);
to_do_views_factory(app);
app_views_factory(app);
user_views_factory(app);
}
现在我们已经注册了我们的用户视图,我们可以运行我们的应用程序,并使用以下 Postman 调用创建我们的用户:
图 7.4 – 向我们的创建用户端点发送邮递员调用
因此,我们应该得到一个201
创建响应。如果我们再次调用完全相同的调用,我们应该得到一个409
冲突。因此,我们应该期待我们的新用户已经被创建。根据第六章中“使用 Diesel 连接 PostgreSQL”部分所涵盖的步骤,使用 PostgreSQL 进行数据持久化,我们可以在我们的 Docker 容器中检查数据库,以下是我们得到的结果:
id | name | email
----+-------------+-------------------
1 | placeholder | placeholder email
2 | maxwell | test@gmail.com
password
-------------------------------------------------------------
placeholder password
$2b$12$jlfLwu4AHjrvTpZrB311Y.W0JulQ71WVy2g771xl50e5nS1UfqwQ.
unique_id
--------------------------------------
placeholder unique id
543b7aa8-e563-43e0-8f62-55211960a604
在这里,我们可以看到在我们的迁移中创建的初始用户。然而,我们也可以看到我们通过视图创建的用户。这里有一个散列密码和唯一的 ID。从这一点我们可以看出,我们永远不应该直接创建我们的用户;我们只应该通过NewUser
结构体所属的构造函数创建用户。
在我们应用程序的上下文中,我们实际上并不需要一个唯一的 ID。然而,在更广泛的情况下,当使用多个服务器和数据库时,一个唯一的 ID 可能变得很有用。我们还必须注意,我们第二个冲突响应是正确的;第三个副本创建用户调用,并没有将副本用户插入到数据库中。
使用这个方法,我们的应用程序正在正常运行,因为现在有一个用户表,其中包含与待办事项关联的用户模型。因此,我们可以创建其他具有关系和结构迁移的数据表,以便它们可以无缝升级和降级。我们也已经涵盖了如何验证和创建密码。然而,我们实际上并没有编写任何检查用户是否传递正确凭证的代码。在下一节中,我们将致力于验证用户并拒绝不包含正确凭证的请求。
验证我们的用户
当涉及到验证我们的用户时,我们已经构建了一个结构,用于从 HTTP 请求的头部提取消息。我们现在已经到了可以真正利用这种提取,将有关用户的数据存储在头部的时候了。目前,没有任何东西阻止我们在每个 HTTP 请求的头部存储用户名、ID 和密码,以便我们可以验证每一个。然而,这是一个糟糕的做法。如果有人拦截请求或获取存储在浏览器中以方便此操作的数据,那么账户就会受到损害,黑客可以随心所欲地做任何事情。相反,我们将对数据进行混淆,如下面的图所示:
图 7.5 – 验证请求的步骤
在图 7.5中,我们可以看到我们使用一个密钥将有关用户的结构化数据序列化为字节形式的令牌。然后我们将令牌给用户,让他们存储在浏览器中。当用户想要发送授权请求时,用户必须在请求的头部发送令牌。然后我们的服务器使用密钥将令牌反序列化为有关用户的结构化数据。这个过程所使用的算法是任何人都可以获得的标准哈希算法。因此,我们有一个定义的密钥,以保持令牌在野外安全。为了我们的应用程序执行图 7.5 中概述的过程,我们可能需要重写大部分src/jwt.rs
文件,包括JwToken
结构体。在我们开始之前,我们需要使用以下代码更新我们的Cargo.toml
依赖项:
[dependencies]
. . .
chrono = {version = "0.4.19", features = ["serde"]}
. . .
jsonwebtoken = "8.1.0"
我们可以看到,我们已经将serde
功能添加到chrono
包中,并添加了jsonwebtoken
包。为了重建JwToken
结构体,我们需要在src/jwt.rs
文件中导入以下内容:
use actix_web::dev::Payload;
use actix_web::{Error, FromRequest, HttpRequest};
use actix_web::error::ErrorUnauthorized;
use futures::future::{Ready, ok, err};
use serde::{Deserialize, Serialize};
use jsonwebtoken::{encode, decode, Algorithm, Header,
EncodingKey, DecodingKey, Validation};
use chrono::{DateTime, Utc};
use chrono::serde::ts_seconds;
use crate::config::Config;
我们可以看到,我们导入了actix_web
特性和结构体,以启用请求和响应的处理。然后我们导入futures
,以便我们能够在 HTTP 请求击中视图之前拦截它。然后我们导入serde
和jsonwebtoken
,以启用数据到和从令牌的序列化和反序列化。然后我们导入chrono
crate,因为我们想记录这些令牌何时被铸造。我们还需要序列化的密钥,我们从这个配置文件中获取它,这就是为什么我们导入Config
结构体的原因。现在我们已经导入了所有需要的特性和结构体,我们可以用以下代码编写我们的令牌结构体:
#[derive(Debug, Serialize, Deserialize)]
pub struct JwToken {
pub user_id: i32,
#[serde(with = "ts_seconds")]
pub minted: DateTime<Utc>
}
在这里,我们可以看到我们有用户的 ID,我们还有令牌创建的日期和时间。我们还用serde
宏装饰我们的minted
字段,以说明我们将如何序列化datetime
字段。现在我们已经有了令牌所需的数据,我们可以继续定义序列化函数,以下代码:
impl JwToken {
pub fn get_key() -> String {
. . .
}
pub fn encode(self) -> String {
. . .
}
pub fn new(user_id: i32) -> Self {
. . .
}
pub fn from_token(token: String) -> Option<Self> {
. . .
}
}
我们可以用以下项目符号解释前面每个函数的作用:
-
get_key
:从config.yml
文件获取序列化和反序列化的密钥 -
encode
:将JwToken
结构体的数据编码为令牌 -
new
:创建一个新的JwToken
结构体 -
from_token
:从令牌创建一个JwToken
结构体。如果反序列化失败,它返回None
,因为反序列化可能会失败。
一旦我们构建了前面的函数,我们的JwToken
结构体将能够根据我们的需要处理令牌。我们用以下代码完善get_key
函数:
pub fn get_key() -> String {
let config = Config::new();
let key_str = config.map.get("SECRET_KEY")
.unwrap().as_str()
.unwrap();
return key_str.to_owned()
}
在这里,我们可以看到我们从配置文件中加载密钥。因此,我们需要将密钥添加到config.yml
文件中,这样我们的文件看起来就像这样:
DB_URL: postgres://username:password@localhost:5433/to_do
SECRET_KEY: secret
如果我们的服务器在生产环境中,我们应该有一个更好的密钥。然而,对于本地开发,这将足够好。现在我们已经从配置文件中提取了密钥,我们可以用以下代码定义我们的encode
函数:
pub fn encode(self) -> String {
let key = EncodingKey::
from_secret(JwToken::get_key().as_ref());
let token = encode(&Header::default(), &self,
&key).unwrap();
return token
}
在这里,我们可以看到我们使用配置文件中的密钥定义了一个编码密钥。然后我们使用这个密钥将JwToken
结构体的数据编码成令牌并返回它。现在我们能够编码我们的JwToken
结构体,当我们需要时,我们将需要创建新的JwToken
结构体,这可以通过以下new
函数实现:
pub fn new(user_id: i32) -> Self {
let timestamp = Utc::now();
return JwToken { user_id, minted: timestamp};
}
使用构造函数,我们知道我们的JwToken
何时被铸造。如果我们想的话,这可以帮助我们管理用户会话。例如,如果令牌的年龄超过我们认为合适的阈值,我们可以强制进行另一次登录。
现在,我们只有from_token
函数,其中我们使用以下代码从令牌中提取数据:
pub fn from_token(token: String) -> Option<Self> {
let key = DecodingKey::from_secret(
JwToken::get_key().as_ref()
);
let token_result = decode::<JwToken>(
&token, &key,
&Validation::new(Algorithm::HS256)
);
match token_result {
Ok(data) => {
Some(data.claims)
},
Err(_) => {
return None
}
}
}
在这里,我们定义了一个解码密钥,然后使用它来解码令牌。然后我们使用data.claims
返回JwToken
。现在,我们的JwToken
结构体可以创建、编码成令牌,并从令牌中提取。现在,我们只需要在视图加载之前从 HTTP 请求的头中提取它,以下是一个概要:
impl FromRequest for JwToken {
type Error = Error;
type Future = Ready<Result<JwToken, Error>>;
fn from_request(req: &HttpRequest,
_: &mut Payload) -> Self::Future {
. . .
}
}
我们已经多次实现了FromRequest
特质,用于数据库连接和之前为JwToken
结构体实现的实现。在from_request
函数内部,我们使用以下代码从头中提取令牌:
match req.headers().get("token") {
Some(data) => {
. . .
},
None => {
let error = ErrorUnauthorized(
"token not in header under key 'token'"
);
return err(error)
}
}
如果令牌不在头中,我们直接返回ErrorUnauthorized
,完全避免调用视图。如果我们能够从头中提取令牌,我们可以使用以下代码来处理它:
Some(data) => {
let raw_token = data.to_str()
.unwrap()
.to_string();
let token_result = JwToken::from_token(
raw_token
);
match token_result {
Some(token) => {
return ok(token)
},
None => {
let error = ErrorUnauthorized(
"token can't be decoded"
);
return err(error)
}
}
},
在这里,我们将从头中提取的原始令牌转换为字符串。然后我们反序列化令牌并将其加载到JwToken
结构体中。然而,如果由于提供了伪造的令牌而失败,我们返回一个ErrorUnauthorized
错误。现在我们的认证已经完全工作;然而,我们将无法做任何事情,因为我们没有有效的令牌,如下面的图所示:
图 7.6 – 认证阻止请求
在下一节中,我们将构建登录 API 端点,以便我们能够与受保护的端点交互。
管理用户会话
对于我们的用户,我们必须让他们能够登录。这意味着我们必须创建一个端点来验证他们的凭据,然后生成一个 JWT 并将其通过响应头返回给用户。我们的第一步是在src/json_serialization/login.rs
文件中定义一个登录模式,以下代码如下:
use serde::Deserialize;
#[derive(Deserialize)]
pub struct Login {
pub username: String,
pub password: String
}
我们必须记住在src/json_serialization/mod.rs
文件中使用pub mod login;
代码行来注册它。一旦我们这样做,我们就可以构建我们的登录端点了。我们可以通过编辑我们在第三章中创建的src/views/auth/login.rs
文件来实现,该文件位于“使用 Actix Web 框架管理视图”部分,它声明了我们的基本登录视图。这仅仅返回一个字符串。
现在,我们可以开始重构这个视图,通过定义所需的导入,如下面的代码所示:
use crate::diesel;
use diesel::prelude::*;
use actix_web::{web, HttpResponse, Responder};
use crate::database::DB;
use crate::models::user::user::User;
use crate::json_serialization::login::Login;
use crate::schema::users;
use crate::jwt::JwToken;
在这个阶段,我们可以浏览一下导入,了解我们将要做什么。我们将从请求体中提取用户名和密码。然后我们将连接到数据库以检查用户名和密码,接着使用JwToken
结构体创建一个将被返回给用户的令牌。我们可以在同一文件中使用以下代码来初步布局视图的轮廓:
. . .
Use std::collections::HashMap;
pub async fn login(credentials: web::Json<Login>,
db: DB) -> impl HttpResponse {
. . .
}
在这里,我们可以看到我们从传入请求的正文接受登录凭证,并为视图从连接池中准备一个数据库连接。然后我们可以从请求体中提取所需的详细信息,并使用以下代码进行数据库调用:
let password = credentials.password.clone();
let users = users::table
.filter(users::columns::username.eq(
credentials.username.clone())
).load::<User>(&db.connection).unwrap();
现在,我们必须检查我们是否从数据库调用中得到了预期的结果,如下面的代码所示:
if users.len() == 0 {
return HttpResponse::NotFound().await.unwrap()
} else if users.len() > 1 {
return HttpResponse::Conflict().await.unwrap()
}
在这里,我们已经进行了一些初步的返回。如果没有用户,我们将返回一个not found
响应代码。这是我们时不时都会期待的事情。然而,如果有多个用户使用相同的用户名,我们需要返回一个不同的代码。
由于显示的独特约束,有些事情非常不对劲。未来的迁移脚本可能会撤销这些唯一约束,或者用户查询可能会意外地被更改。如果发生这种情况,我们需要立即知道这一点,因为违反我们约束的损坏数据可能会导致我们的应用程序以难以调试的意外方式运行。
现在我们已经确认检索到了正确的用户数量,我们可以有信心地获取索引为零的唯一用户,并检查他们的密码是否可接受,如下所示:
match users[0].verify(password) {
true => {
let token = JwToken::new(users[0].id);
let raw_token = token.encode();
let mut body = HashMap::new();
body.insert("token", raw_token);
HttpResponse::Ok().json(body)
},
false => HttpResponse::Unauthorized()
}
在这里,我们可以看到我们使用了verify
函数。如果密码匹配,我们就使用 ID 生成一个 token,并将其返回给用户作为正文。如果密码不正确,我们则返回一个未授权代码。
在我们的注销方面,我们将采取一种更轻量级的方法。在我们的注销视图中,我们只需运行两行 JavaScript 代码。一行是将用户 token 从本地存储中移除,然后恢复用户到主视图。HTML 可以托管在打开时立即运行的 JavaScript。因此,我们可以通过在src/views/auth/logout.rs
文件中放置以下代码来实现这一点:
use actix_web::HttpResponse;
pub async fn logout() -> HttpResponse {
HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body("<html>\
<script>\
localStorage.removeItem('user-token'); \
window.location.replace(
document.location.origin);\
</script>\
</html>")
}
由于这个视图已经注册,我们可以运行应用程序并使用 Postman 进行调用:
Figure 7.7 – 使用 Postman 通过登录端点登录我们的应用程序
修改用户名将给我们一个404-response
代码,而修改密码将给我们一个401-response
代码。如果我们有正确的用户名和密码,我们将得到一个200-response
代码,并且响应头中会有一个token,如图7**.7所示。然而,如果我们想在响应头中使用我们的token,我们将收到一个token can't be decoded
消息。在下文中,我们将清理我们的认证要求。
清理认证要求
在本节中,我们在开始配置前端以处理这些认证过程之前,将清理我们的 Rust 服务器以实现认证。为了保持章节的连贯性,我们并没有定期进行“维护”。现在,我们将更新我们的to_do
视图。我们可以从更新带有认证要求的create
视图开始。为此,src/views/to_do/create.rs
文件中create
视图的函数签名应如下所示:
. . .
use crate::jwt::JwToken;
use crate::database::DB
pub async fn create(token: JwToken,
req: HttpRequest, db: DB) -> HttpResponse {
. . .
在创建新项目时,我们也必须使用来自令牌的 ID 更新用户 ID,代码如下:
if items.len() == 0 {
let new_post = NewItem::new(title, token.user_id);
let _ = diesel::
insert_into(to_do::table).values(&new_post)
.execute(&db.connection);
}
Return HttpResponse::Ok().json(
ToDoItems::get_state(token.user_id)
)
在我们的delete
视图中,我们必须确保我们正在删除请求用户所属的待办事项。如果我们不使用用户 ID 添加过滤器,待办事项的删除将是随机的。这个过滤器可以添加到我们的src/views/to_do/delete.rs
文件中,代码如下:
. . .
Use crate::database::DB;
. . .
pub async fn delete(to_do_item: web::Json<ToDoItem>,
token: JwToken, db: DB) -> HttpResponse {
let items = to_do::table
.filter(to_do::columns::title.eq(
&to_do_item.title.as_str())
)
.filter(to_do::columns::user_id.eq(&token.user_id))
.order(to_do::columns::id.asc())
.load::<Item>(&db.connection)
.unwrap();
let _ = diesel::delete(&items[0]).execute(&db.connection);
return HttpResponse::Ok().json(ToDoItems::get_state(
token.user_id
))
}
我们可以看到,在执行数据库查询时,filter
函数可以简单地串联。考虑到我们对delete
视图所做的工作,你认为我们将在src/views/to_do/edit.rs
文件中如何升级我们的认证要求?在这个阶段,我鼓励你自己尝试更新edit
视图,因为方法类似于我们的delete
视图升级。一旦你完成了这个,你的edit
视图应该如下所示:
pub async fn edit(to_do_item: web::Json<ToDoItem>,
token: JwToken, db: DB) -> HttpResponse {
let results = to_do::table.filter(to_do::columns::title
.eq(&to_do_item.title))
.filter(to_do::columns::user_
id
.eq(&token.user_id));
let _ = diesel::update(results)
.set(to_do::columns::status.eq("DONE"))
.execute(&db.connection);
return HttpResponse::Ok().json(ToDoItems::get_state(
token.user_id
))
}
现在我们已经更新了特定的视图,现在我们可以继续到get
视图,它也有应用于所有其他视图的get_state
函数。src/views/to_do/get.rs
文件中的get
视图现在如下所示:
use actix_web::Responder;
use crate::json_serialization::to_do_items::ToDoItems;
use crate::jwt::JwToken;
pub async fn get(token: JwToken) -> impl Responder {
ToDoItems::get_state(token.user_id)
}
现在,前述代码中的所有内容都不应该令人惊讶。我们可以看到,我们将用户 ID 传递给ToDoItems::get_state
函数。你必须记住在ToDoItems::get_state
函数实现的所有地方填写用户 ID,这包括所有待办事项视图。然后我们可以在src/json_serialization/to_do_items.rs
文件中重新定义我们的ToDoItems::get_state
函数,代码如下:
. . .
use crate::database::DBCONNECTION;
. . .
impl ToDoItems {
. . .
pub fn get_state(user_id: i32) -> ToDoItems {
let connection = DBCONNECTION.db_connection.get()
.unwrap();
let items = to_do::table
.filter(to_do::columns::user_id.eq
(&user_id))
.order(to_do::columns::id.asc())
.load::<Item>(&connection)
.unwrap();
let mut array_buffer = Vec::
with_capacity(items.len());
for item in items {
let status = TaskStatus::from_string(
&item.status.as_str().to_string());
let item = to_do_factory(&item.title, status);
array_buffer.push(item);
}
return ToDoItems::new(array_buffer)
}
}
在这里,我们可以看到我们已经更新了数据库连接和用户 ID 的过滤器。我们现在已经更新了我们的代码以适应不同的用户。还有一个变化我们必须做出。因为我们将在 React 应用程序中编写前端代码,我们将尽量使 React 编码尽可能简单,因为 React 开发本身就是一本大书。为了避免过度复杂化前端开发中的标题提取和GET
帖子使用 Axios,我们将在登录时添加一个Post
方法,并通过正文返回令牌。这又是一个尝试自己解决问题的好机会,因为我们已经涵盖了实现这一目标所需的所有概念。
如果你尝试自己解决这个问题,它应该如下所示。首先,我们在src/json_serialization/login_response.rs
文件中定义一个响应结构体,代码如下:
use serde::Serialize;
#[derive(Serialize)]
pub struct LoginResponse {
pub token: String
}
我们记得在src/json_serialization/mod.rs
文件中声明前面的结构体,通过添加pub mod login_response
。我们现在转到src/views/auth/login.rs
,在login
函数中有以下return
语句:
match users[0].clone().verify(credentials.password.clone()) {
true => {
let user_id = users[0].clone().id;
let token = JwToken::new(user_id);
let raw_token = token.encode();
let response = LoginResponse{token:
raw_token.clone()};
let body = serde_json::
to_string(&response).unwrap();
HttpResponse::Ok().append_header(("token",
raw_token)).json(&body)
},
false => HttpResponse::Unauthorized().finish()
}
注意
你可能已经注意到我们对未授权的部分做了一些小的改动:
HttpResponse::Unauthorized().finish()
这是因为我们将视图函数的return
类型从HttpResponse
结构体切换了,从而得到了以下函数签名:
(credentials: web::Json
我们必须进行切换,因为将json
函数添加到我们的响应中,将我们的响应从HttpResponseBuilder
转换为HttpResponse
。一旦调用了json
函数,就不能再使用HttpResponseBuilder
。回到未授权的响应构建器,我们可以推断出finish
函数将HttpResponseBuilder
转换为HttpResponse
。我们也可以通过使用await
将我们的HttpResponseBuilder
转换为HttpResponse
,如下所示:
HttpResponse::Unauthorized().await.unwrap()
在这里,我们可以看到我们在头部和体中返回了令牌。这将给我们编写前端代码时的灵活性和便捷性。然而,必须强调的是,这并不是最佳实践。我们正在实施将令牌返回到体和头部的做法,以使前端开发部分保持简单。我们可以在src/views/auth/mod.rs
文件中为我们的登录视图启用POST
方法,如下所示:
mod login;
mod logout;
use actix_web::web::{ServiceConfig, get, post, scope};
pub fn auth_views_factory(app: &mut ServiceConfig) {
app.service(
scope("v1/auth")
.route("login", get().to(login::login))
.route("login", post().to(login::login))
.route("logout", get().to(logout::logout))
);
}
我们可以看到我们只是在同一个login
视图中堆叠了一个get
函数。现在,POST
和GET
都可用于我们的登录视图。我们现在可以进入下一节,配置我们的认证令牌,以便它们可以过期。我们希望令牌过期以提高我们的安全性。如果一个令牌被泄露,恶意行为者获取了令牌,他们可以无限制地做他们想做的事情,而无需登录。然而,如果我们的令牌过期,那么恶意行为者只有有限的时间窗口,在令牌过期之前。
配置认证令牌的过期时间
如果我们尝试使用从登录令牌中获取的有效令牌在我们的现在受保护的端点上执行 API 调用,我们将得到一个未授权错误。如果我们插入一些print
语句,在解码令牌失败时,我们会得到以下错误:
missing required claim: exp
这意味着在我们的JwToken
结构体中没有名为exp
的字段。如果我们参考docs.rs/jsonwebtoken/latest/jsonwebtoken/fn.encode.html
上的jsonwebtoken
文档,我们可以看到encode
指令从未提到exp
:
use serde::{Deserialize, Serialize};
use jsonwebtoken::{encode, Algorithm, Header, EncodingKey};
#[derive(Debug, Serialize, Deserialize)]
struct Claims {
sub: String,
company: String
}
let my_claims = Claims {
sub: "b@b.com".to_owned(),
company: "ACME".to_owned()
};
// my_claims is a struct that implements Serialize
// This will create a JWT using HS256 as algorithm
let token = encode(&Header::default(), &my_claims,
&EncodingKey::from_secret("secret".as_ref())).unwrap();
在这里,我们可以看到没有提及任何声明。然而,实际上当我们尝试反序列化我们的令牌时,jsonwebtoken
crate 中的decode
函数会自动寻找exp
字段来确定令牌何时应该过期。我们正在探索这一点,因为官方文档和稍微有些令人困惑的错误信息可能会让你浪费数小时试图弄清楚发生了什么。考虑到这一点,我们必须回到我们的src/jwt.rs
文件进行一些修改,但这是最后一次,我保证,这并不是一个完整的重写。首先,我们确保以下内容与src/jwt.rs
文件中已有的内容一起导入:
. . .
use jsonwebtoken::{encode, decode, Header,
EncodingKey, DecodingKey,
Validation};
use chrono::Utc;
. . .
我们可以确保使用以下代码将exp
字段写入我们的JwToken
结构:
#[derive(Debug, Serialize, Deserialize)]
pub struct JwToken {
pub user_id: i32,
pub exp: usize,
}
我们现在必须重写我们的JwToken
结构的new
构造函数。在new
函数中,我们必须定义新铸造的JwToken
结构何时过期。这必须变化;作为一个开发者,你可能想要调整超时所需的时间。记住,每次我们更改 Rust 代码时都必须重新编译;因此,在配置文件中定义超时周期是有意义的。考虑到超时周期的变化,我们的new
函数具有以下形式:
pub fn new(user_id: i32) -> Self {
let config = Config::new();
let minutes = config.map.get("EXPIRE_MINUTES")
.unwrap().as_i64().unwrap();
let expiration = Utc::now()
.checked_add_signed(chrono::Duration::minutes(minutes))
.expect("valid timestamp")
.timestamp();
return JwToken { user_id, exp: expiration as usize };
}
我们可以看到我们定义了分钟数。然后我们将过期时间转换为usize
,然后构建我们的JwToken
结构。现在我们有了这个,我们需要更具体地返回错误类型,因为这可能是令牌解码错误,或者令牌可能已过期。我们使用以下代码处理解码令牌时的不同类型的错误:
pub fn from_token(token: String) -> Result<Self, String> {
let key = DecodingKey::
from_secret(JwToken::get_key().as_ref());
let token_result = decode::<JwToken>(&token.as_str(),
&key,&Validation::default());
match token_result {
Ok(data) => {
return Ok(data.claims)
},
Err(error) => {
let message = format!("{}", error);
return Err(message)
}
}
}
在这里,我们可以看到我们已经从返回Option
切换到了Result
。我们切换到Result
是因为我们在FromRequest
特质的实现中返回的消息可以被我们的from_request
函数消化和处理。from_request
函数中的其余代码保持不变。我们进行更改的地方是检查消息是否存在错误,并使用以下代码向前端返回不同的消息:
fn from_request(req: &HttpRequest,
_: &mut Payload) -> Self::Future {
match req.headers().get("token") {
Some(data) => {
let raw_token = data.to_str()
.unwrap()
.to_string();
let token_result = JwToken::
from_token(raw_token);
match token_result {
Ok(token) => {
return ok(token)
},
Err(message) => {
if message == "ExpiredSignature"
.to_owned() {
return err(
ErrorUnauthorized("token expired"))
}
return err(
ErrorUnauthorized("token can't be decoded"))
}
}
},
None => {
return err(
ErrorUnauthorized(
"token not in header under key 'token'"))
}
}
}
带有细微的错误信息,我们的前端代码可以处理和适应,因为我们可以在前端更具体地处理错误。在前端更具体地处理错误可以帮助用户,提示他们出错的地方。然而,当涉及到身份验证时,确保不要透露太多,因为这也可能帮助试图获取未经授权访问的恶意行为者。我们现在有我们的登录和注销端点正在运行;我们还有在需要视图上的令牌授权。然而,如果我们想要标准用户与我们的应用程序交互,这并不很有用,因为他们不太可能使用 Postman。因此,在下一节中,我们必须将我们的登录/注销端点纳入前端。
将身份验证添加到我们的前端
我们集成了我们的登录功能。我们必须从在src/components/LoginForm.js
文件中构建登录表单开始。首先,我们导入以下内容:
import React, {Component} from 'react';
import axios from 'axios';
import '../css/LoginForm.css';
本章的附录中提供了导入的 CSS 代码。我们在这里不会讲解它,因为它有很多重复的代码。您也可以从 GitHub 仓库下载 CSS 代码。有了这些导入,我们可以用以下代码构建我们的登录表单框架:
class LoginForm extends Component {
state = {
username: "",
password: "",
}
submitLogin = (e) => {
. . .
}
handlePasswordChange = (e) => {
this.setState({password: e.target.value})
}
handleUsernameChange = (e) => {
this.setState({username: e.target.value})
}
render() {
. . .
}
}
export default LoginForm;
在这里,我们可以看到我们跟踪username
和password
,它们会不断更新状态。记住,当状态更新时,我们执行render
函数。这很强大,因为我们可以改变我们想要的任何东西。例如,如果username
的长度超过一定长度,我们可以改变组件的颜色或移除按钮。我们不会自己做出剧烈的改变,因为这超出了本书的范围。现在我们已经定义了我们的框架,我们可以用以下代码来声明我们的render
函数返回的内容:
<form className="login" onSubmit={this.submitLogin}>
<h1 className="login-title">Login</h1>
<input type="text" className="login-input"
placeholder="Username"
autoFocus onChange={this.handleUsernameChange}
value={this.state.username} />
<input type="password" className="login-input"
placeholder="Password"
onChange={this.handlePasswordChange}
value={this.state.password} />
<input type="submit" value="Lets Go"
className="login-button" />
</form>
在这里,我们可以看到表单中有username
和password
字段,当有变化时,会执行handleUsernameChange
和handlePasswordChange
函数。当我们输入username
和password
时,我们需要通过submitLogin
函数将这些字段提交到后端,我们可以在下面定义这个函数:
submitLogin = (e) => {
e.preventDefault();
axios.post("http://localhost:8000/v1/auth/login",
{"username": this.state.username,
"password": this.state.password},
{headers: {"Access-Control-Allow-Origin": "*"}}
)
.then(response => {
this.setState({username: "", password: ""});
this.props.handleLogin(response.data["token"]);
})
.catch(error => {
alert(error);
this.setState({password: "", firstName: ""});
});
}
在这里,我们可以看到我们将登录 API 调用的响应传递给一个我们通过 props 传递的函数。我们将在src/app.js
文件中定义这个函数。如果有错误,我们将在一个 alert 中打印出来,告诉我们发生了什么。无论如何,我们将清空username
和password
字段。
现在我们已经定义了我们的登录表单,当我们需要用户登录时,我们将需要显示它。一旦用户登录,我们需要隐藏登录表单。在我们能够做到这一点之前,我们需要用以下代码将我们的登录表单导入到src/app.js
文件中:
import LoginForm from "./components/LoginForm";
我们现在需要跟踪登录状态。为此,我们的App
类的状态需要以下形式:
state = {
"pending_items": [],
"done_items": [],
"pending_items_count": 0,
"done_items_count": 0,
"login_status": false,
}
我们正在跟踪我们的项目,但如果login_status
是false
,我们可以显示登录表单。一旦用户登录,我们可以将login_status
设置为true
,因此我们可以隐藏登录表单。现在我们已经记录了登录状态,我们可以更新App
类的getItems
函数:
getItems() {
axios.get("http://127.0.0.1:8000/v1/item/get",
{headers: {"token": localStorage.getItem("user-token")}})
.then(response => {
let pending_items = response.data["pending_items"]
let done_items = response.data["done_items"]
this.setState({
"pending_items":
this.processItemValues(pending_items),
"done_items": this.processItemValues(done_items),
"pending_items_count":
response.data["pending_item_count"],
"done_items_count":
response.data["done_item_count"]
})
}).catch(error => {
if (error.response.status === 401) {
this.logout();
}
});
}
我们可以看到我们获取了令牌并将其放入头部。如果存在授权代码错误,我们将执行App
类的logout
函数。我们的logout
函数的形式如下:
logout() {
localStorage.removeItem("token");
this.setState({"login_status": false});
}
我们可以看到我们从本地存储中删除了令牌,并将我们的login_status
设置为false
。当尝试编辑待办事项时,如果出现错误,也需要执行这个logout
函数,因为我们必须记住我们的令牌可能会过期,所以它可能发生在任何地方,我们必须提示另一个登录。这意味着我们必须将logout
函数通过以下代码传递给ToDoItem
组件:
processItemValues(items) {
let itemList = [];
items.forEach((item, _)=>{
itemList.push(
<ToDoItem key={item.title + item.status}
title={item.title}
status={item.status}
passBackResponse={this.handleReturnedState}
logout={this.logout}/>
)
})
return itemList
}
一旦我们将logout
函数传递给ToDoItem
组件,我们就可以更新src/components/ToDoItem.js
文件中编辑待办事项的 API 调用,以下代码:
sendRequest = () => {
axios.post("http://127.0.0.1:8000/v1/item/" +
this.state.button,
{
"title": this.state.title,
"status": this.inverseStatus(this.state.status)
},
{headers: {"token": localStorage.getItem(
"user-token")}})
.then(response => {
this.props.passBackResponse(response);
}).catch(error => {
if (error.response.status === 401) {
this.props.logout();
}
});
}
在这里,我们可以看到我们通过头部传递令牌从本地存储到 API 调用。如果我们收到未授权的状态,我们就会执行通过 props 传入的logout
函数。
现在,我们回到src/app.js
文件,总结我们应用程序的功能。记住,我们的应用程序在首次访问时需要加载数据。当我们的应用程序最初加载时,我们必须考虑以下代码中的本地存储中的令牌:
componentDidMount() {
let token = localStorage.getItem("user-token");
if (token !== null) {
this.setState({login_status: true});
this.getItems();
}
}
现在我们的应用程序只有在有令牌的情况下才会从后端获取项目。我们必须在用render
函数封装我们的应用程序之前只处理登录。你已经看到了我们如何使用本地存储处理令牌。在这个时候,你应该能够为App
类构建handleLogin
函数。如果你尝试编写自己的函数,它应该看起来像以下代码:
handleLogin = (token) => {
localStorage.setItem("user-token", token);
this.setState({"login_status": true});
this.getItems();
}
我们现在处于为App
类定义render
函数的阶段。如果我们的登录状态是true
,我们可以使用以下代码显示应用程序提供的一切:
if (this.state.login_status === true) {
return (
<div className="App">
<div className="mainContainer">
<div className="header">
<p>complete tasks:
{this.state.done_items_count}</p>
<p>pending tasks:
{this.state.pending_items_count}</p>
</div>
<h1>Pending Items</h1>
{this.state.pending_items}
<h1>Done Items</h1>
{this.state.done_items}
<CreateToDoItem
passBackResponse={this.handleReturnedState}/>
</div>
</div>
)
}
这里没有太多新内容。然而,如果我们的登录状态不是true
,我们就可以使用以下代码显示登录表单:
else {
return (
<div className="App">
<div className="mainContainer">
<LoginForm handleLogin={this.handleLogin}
/>
</div>
</div>
)
}
正如我们所见,我们已经将handleLogin
函数传递给了LoginForm
组件。有了这个,我们就准备好运行应用程序了。我们的第一个视图看起来如下:
图 7.8 – 应用程序的登录和加载视图
一旦我们输入正确的凭证,我们就能访问应用程序并交互待办事项。我们的应用程序本质上已经可以工作了!
摘要
在本章中,我们构建了用户数据模型结构,并将它们绑定到我们的迁移中的待办事项数据模型。然后我们通过在 SQL 文件中执行多个步骤来进一步深入我们的迁移,以确保迁移顺利运行。我们还探讨了如何向某些字段添加唯一约束。
在数据库中定义了我们的数据模型之后,我们在将它们与存储的用户一起存储在数据库之前,对一些密码进行了散列。然后我们创建了一个 JWT 结构,以便我们的用户可以在他们的浏览器中存储 JWT,这样他们就可以在发起 API 调用时提交它们。然后我们探讨了如何在 JavaScript 和 HTML 存储中重定向 URL,以便前端可以确定用户是否有凭证,在考虑发送 API 调用到项目之前。
我们在这里所做的是通过迁移更改数据库,以便我们的应用程序可以管理处理更多复杂性的数据模型。然后我们利用前端存储来允许用户传递凭证。这对于你将开始的任何其他 Rust 网络项目都直接适用。大多数网络应用程序都需要某种形式的身份验证。
在下一章中,我们将探讨REST API实践,我们将标准化接口、缓存和日志。
问题
-
与服务器端代码相比,在 SQL 中定义唯一约束有什么优势?
-
用户拥有 JWT 而不是存储密码的主要优势是什么?
-
用户如何在前端存储 JWT?
-
一旦我们验证 JWT 是可用的,JWT 在视图中有什么用?
-
当用户点击端点时,更改前端数据并将其重定向到另一个视图的最小方法是什么?
-
为什么在用户登录时使用一系列不同的响应代码比仅仅表示登录成功或失败更有用?
答案
-
直接在数据库上添加唯一约束确保了无论数据操作是通过迁移还是服务器请求完成的,这一标准都得到执行。这也保护我们免受在另一个端点添加新功能时忘记执行这一标准或代码在端点的后续更改中更改所造成的数据损坏。
-
如果攻击者设法获取 JWT,并不意味着他们可以直接访问用户的密码。此外,如果令牌被刷新,那么攻击者对项目的访问时间有限。
-
JWT 可以存储在本地 HTML 存储或 cookies 中。
-
在散列令牌时,我们可以在其中存储多个数据点。因此,我们可以加密用户 ID。有了这个,我们可以提取用户 ID,用于与待办事项创建、删除或编辑相关的操作。
-
我们返回一个包含 HTML/text 主体的
HttpResponse
结构,该主体包含一个包含几个 HTML 标签的字符串。在这些标签之间是几个脚本标签。在脚本标签之间,我们可以通过分号分割我们的 JavaScript 命令。然后我们可以直接修改 HTML 存储和窗口位置。 -
数据库中数据损坏可能有多种原因,包括迁移中的更改。然而,可能存在一个错误,这不是用户的责任——例如,两个不同用户的重复用户名。这是一个违反了我们的唯一约束的错误。我们需要知道这种情况已经发生,以便我们可以纠正它。
进一步阅读
JWT 标准:tools.ietf.org/html/rfc7519
附录
登录表单所使用的 CSS:
body {
background: #2d343d;
}
.login {
margin: 20px auto;
width: 300px;
padding: 30px 25px;
background: white;
border: 1px solid #c4c4c4;
border-radius: 25px;
}
h1.login-title {
margin: -28px -25px 25px;
padding: 15px 25px;
line-height: 30px;
font-size: 25px;
font-weight: 300;
color: #ADADAD;
text-align:center;
background: #f7f7f7;
border-radius: 25px 25px 0px 0px;
}
.login-input {
width: 285px;
height: 50px;
margin-bottom: 25px;
padding-left:10px;
font-size: 15px;
background: #fff;
border: 1px solid #ccc;
border-radius: 4px;
}
.login-input:focus {
border-color:#6e8095;
outline: none;
}
.login-button {
width: 100%;
height: 50px;
padding: 0;
font-size: 20px;
color: #fff;
text-align: center;
background: #f0776c;
border: 0;
border-radius: 5px;
cursor: pointer;
outline:0;
}
.login-lost
{
text-align:center;
margin-bottom:0px;
}
.login-lost a
{
color:#666;
text-decoration:none;
font-size:13px;
}
.loggedInTitle {
font-family: "Helvetica Neue";
color: white;
}
第八章:构建 RESTful 服务
我们用 Rust 编写的待办事项应用在技术上是可以工作的。然而,我们还需要做一些改进。在本章中,我们将随着对RESTful API设计概念的探索来应用这些改进。
在本章中,我们将在请求击中视图之前最终拒绝未经授权的用户,通过评估我们系统的层次结构并重构我们处理请求的方式在整个请求生命周期中。然后,我们将使用这种身份验证来允许单个用户拥有他们自己的待办事项列表。最后,我们将记录我们的请求,以便我们可以调试我们的应用程序并深入了解我们的应用程序是如何运行的,在前端缓存数据以减少 API 调用。我们还将探索一些有用的概念,例如按命令执行代码和创建统一的接口来分割前端 URL 和后端 URL。
在本章中,我们将涵盖以下主题:
-
什么是 RESTful 服务?
-
映射我们的分层系统
-
构建统一接口
-
实现无状态
-
记录我们的服务器流量
-
缓存
-
按需代码
到本章结束时,我们将重构我们的 Rust 应用程序以支持 RESTful API 的原则。这意味着我们将绘制出 Rust 应用程序的层次结构,创建统一的 API 端点,记录应用程序中的请求,并在前端缓存结果。
技术要求
本章的代码可以在github.com/PacktPublishing/Rust-Web-Programming-2nd-Edition/tree/main/chapter08
找到
什么是 RESTful 服务?
GET
(获取)、PUT
(更新)、POST
(创建)和DELETE
(删除)我们的用户和待办事项。RESTful 方法的目的是通过重用可以管理和更新的组件来提高速度/性能、可靠性和可扩展性,这些组件不会影响系统。
你可能已经注意到,在 Rust 之前,慢速的高级语言似乎在 Web 开发中是一个明智的选择。这是因为它们编写起来更快、更安全。这是由于 Web 开发中数据处理速度瓶颈主要是网络连接速度。RESTful 设计旨在通过优化系统来提高速度,例如减少 API 调用,而不是仅仅关注算法速度。考虑到这一点,在本节中,我们将涵盖以下 RESTful 概念:
-
分层系统:这使我们能够添加额外的功能,例如授权,而无需更改接口。例如,如果我们必须在每个视图中检查JSON Web Token(JWT),那么这将是一大堆重复的代码,难以维护且容易出错。
-
统一系统:这简化并解耦了架构,使得应用的部分可以独立进化而不会相互冲突。
-
无状态性:这确保了我们的应用程序不会直接在服务器上保存任何东西。这对微服务和云计算有影响。
-
日志记录:这使我们能够窥视我们的应用程序并查看其运行情况,即使没有显示错误,也能暴露出不良行为。
-
缓存:这使我们能够在前端存储数据,以减少对后端 API 的 API 调用次数。
-
按需代码:这是我们的后端服务器在前端直接运行代码的地方。
我们将在下一节中探讨分层系统概念。
映射我们的分层系统
分层系统由具有不同功能单元的层组成。可以认为这些层是不同的服务器。这在微服务和大型系统中可能是正确的。当涉及到数据的不同层时,这也可能是正确的。在大型系统中,拥有经常被访问和更新的热数据和很少被访问的冷数据是有意义的。然而,虽然将层视为位于不同的服务器上很容易,但它们可以位于同一服务器上。我们可以用以下图表来映射我们的层:
图 8.1 – 我们应用程序的层
如您所见,我们的应用程序遵循以下流程:
-
首先,我们的HTTP 处理器通过监听我们在创建服务器时定义的端口来接受调用。
-
然后,它通过中间件,这是通过在我们的应用程序上使用
wrap_fn
函数定义的。 -
一旦完成,请求的 URL 就会被映射到正确的视图以及我们在
src/json_serialization/
目录中定义的模式。这些模式会被传递到在src/views
目录中定义的资源(我们的视图)。
如果我们想更新或从数据库获取数据,我们使用 Diesel ORM 将这些请求映射。在这个阶段,我们已经定义了所有层来有效地管理数据流,除了我们的中间件。正如前一章所指出的,第七章,管理用户会话,我们已经通过实现FromRequest
特质来为JwToken
结构体实现了我们的中间件以进行身份验证。有了这个,我们可以看到我们可以使用wrap_fn
或实现FromRequest
特质来实现我们的中间件。你认为我们应该在什么时候使用wrap_fn
或FromRequest
特质?两者都有优点和缺点。如果我们想为特定的单个视图实现中间件,那么实现FromRequest
特质是最好的选择。这是因为我们可以将实现FromRequest
特质的结构体插入我们想要的视图中。身份验证是实现FromRequest
特质的良好用例,因为我们想挑选和选择哪些端点需要身份验证。然而,如果我们想实施一个一揽子规则,我们最好在wrap_fn
函数中实现视图的认证选择。在wrap_fn
函数中实现我们的中间件意味着它适用于每个请求。
这的一个例子可能是我们不再支持所有端点的版本一。如果我们打算这样做,我们必须警告第三方用户我们不再支持 API 的版本一的决定。一旦我们的日期过去,我们必须提供一个有用的消息,说明我们不再支持版本一。在我们开始工作于我们的中间件层之前,我们必须在main.rs
文件顶部定义以下导入:
use actix_web::{App, HttpServer, HttpResponse};
use actix_service::Service;
use futures::future::{ok, Either};
use actix_cors::Cors;
为了确保我们知道进入的请求是针对一个v1
端点的,我们必须定义一个标志,我们可以在稍后决定是否处理请求或拒绝它时进行检查。我们可以通过在我们的main.rs
文件中使用以下代码来实现这一点:
.wrap_fn(|req, srv|{
let passed: bool;
if req.path().contains("/v1/") {
passed = false;
} else {
passed = true;
}
. . .
从前面的代码中,我们可以看到我们声明有一个名为passed
的布尔值。如果v1
不在 URL 中,则将其设置为true
。如果v1
存在于 URL 中,则passed
被设置为false
。
现在我们已经定义了一个标志,我们可以用它来决定请求会发生什么。在我们这样做之前,我们必须注意wrap_fn
的最后几行,如以下代码块所示:
let future = srv.call(req);
async {
let result = fut.await?;
Ok(result)
}
我们正在等待调用完成,然后返回名为result
的变量作为结果。在我们的v1
API 调用阻塞之后,我们必须检查请求是否通过。如果它通过了,我们就运行前面的代码。然而,如果请求失败,我们必须绕过这一点并定义另一个 future,它只是响应。
从字面上看,这似乎很简单。两者都将返回相同的结果,即响应。然而,Rust 不会编译。它将基于不兼容的类型抛出一个错误。这是因为async
块的行为类似于闭包。这意味着每个async
块都是其自己的类型。这可能会令人沮丧,并且由于这个细微的细节,它可能导致开发者花费数小时试图让两个未来相互配合。
幸运的是,在 futures crate 中有一个枚举可以为我们解决这个问题。Either
枚举将具有相同关联类型的两个不同的未来、流或汇合并入一个单一类型。这使得我们能够匹配passed
标志,并使用以下代码触发并返回适当的过程:
let end_result;
if passed == true {
end_result = Either::Left(srv.call(req))
}
else {
let resp = HttpResponse::NotImplemented().body(
"v1 API is no longer supported"
);
end_result = Either::Right(
ok(req.into_response(resp)
.map_into_boxed_body())
)
}
async move {
let result = end_result.await?;
Ok(result)
}
}).configure(views::views_factory).wrap(cors);
从前面的代码中,我们可以看到我们根据passed
标志将end_result
分配为视图调用,或者直接返回一个未授权的响应。然后我们在wrap_fn
的末尾返回这个结果。了解如何使用Either
枚举是一个很有用的技巧,当你需要代码在两个不同的未来之间进行选择时,这将为你节省数小时。
要检查我们是否正在阻止v1
,我们可以调用一个简单的get
请求,如下面的图所示:
图 8.2 – 阻塞 v1 的响应
我们可以看到我们的 API 调用被一个有用的消息阻止。如果我们通过 Postman 进行 API 调用,我们将看到我们得到一个501 Not Implemented
错误,如下面的图所示:
图 8.3 – 阻塞 v1 的 Postman 响应
我们可能希望在将来添加更多获取项的资源。这可能会引起潜在的问题,因为一些视图可能会与应用程序视图发生冲突。例如,我们的待办事项项 API 视图只有前缀item
。获取所有项需要v1/item/get
端点。
可以合理地开发一个视图,用于以后通过v1/item/get/{id}
端点详细查看待办事项并进行编辑。然而,这增加了前端应用程序视图和后端 API 调用之间发生冲突的风险。为了防止这种情况,我们必须确保我们的 API 具有统一的接口。
构建统一的接口
具有统一的接口意味着我们的资源可以通过 URL 唯一识别。这解耦了后端端点和前端视图,使得我们的应用可以扩展而不会在前端视图和后端端点之间发生冲突。我们使用版本标签解耦了后端和前端。当一个 URL 端点包含版本标签,如v1
或v2
,我们知道这个调用正在击中后端 Rust 服务器。当我们开发我们的 Rust 服务器时,我们可能想要在 API 调用的新版本上工作。然而,我们不想允许用户访问正在开发中的版本。为了使实时用户能够访问一个版本,同时我们在测试服务器上部署另一个版本,我们需要动态地为服务器定义 API 版本。根据你在本书中迄今为止获得的知识,你可以在定义服务器之前,在main.rs
文件的main
函数中简单地定义版本号并加载它。然而,我们必须为每个请求读取config.yml
配置文件。记住,当我们设置数据库连接池时,我们只从config.yml
文件中读取一次连接字符串,这意味着它在程序的整个生命周期中都是存在的。我们希望定义版本一次,然后在程序的整个生命周期中引用它。直观上,你可能会想在定义服务器之前,在main.rs
文件的main
函数中定义版本,然后在wrap_fn
内部访问版本的定义,如下面的示例所示:
let outcome = "test".to_owned();
HttpServer::new(|| {
. . .
let app = App::new()
.wrap_fn(|req, srv|{
println!("{}", outcome);
. . .
}
. . .
然而,如果我们尝试编译前面的代码,它将失败,因为outcome
变量的生命周期不够长。我们可以通过以下代码将outcome
变量转换为常量:
const OUTCOME: &str = "test";
HttpServer::new(|| {
. . .
let app = App::new()
.wrap_fn(|req, srv|{
println!("{}", outcome);
. . .
}
. . .
上述代码将无任何生命周期问题地运行。然而,如果我们加载我们的版本,我们必须从文件中读取它。在 Rust 中,如果我们从文件中读取,我们不知道从文件中读取的变量的大小。因此,我们从文件中读取的变量将是一个字符串。这里的问题是分配字符串不是在编译时可以计算的事情。因此,我们将不得不直接将版本写入我们的main.rs
文件。我们可以通过使用build
文件来实现这一点。
注意
我们在这个问题中利用build
文件来教授build
文件的概念,这样你如果需要的话就可以使用它们。没有任何阻止你在代码中硬编码常量的因素。
这是在 Rust 应用程序运行之前单个 Rust 文件运行的阶段。当我们在编译主 Rust 应用程序时,这个build
文件将自动运行。我们可以在Cargo.toml
文件的build dependencies
部分定义运行我们的build
文件所需的依赖项,以下是一段代码:
[package]
name = "web_app"
version = "0.1.0"
edition = "2021"
build = "build.rs"
[build-dependencies]
serde_yaml = "0.8.23"
serde = { version = "1.0.136", features = ["derive"] }
这意味着我们的 build
Rust 文件定义在应用程序的根目录中的 build.rs
文件中。然后,我们在 [build-dependencies]
部分中定义构建阶段所需的依赖项。现在,我们的依赖项已经定义,我们的 build.rs
文件可以采取以下形式:
use std::fs::File;
use std::io::Write;
use std::collections::HashMap;
use serde_yaml;
fn main() {
let file =
std::fs::File::open("./build_config.yml").unwrap();
let map: HashMap<String, serde_yaml::Value> =
serde_yaml::from_reader(file).unwrap();
let version =
map.get("ALLOWED_VERSION").unwrap().as_str()
.unwrap();
let mut f =
File::create("./src/output_data.txt").unwrap();
write!(f, "{}", version).unwrap();
}
在这里,我们可以看到我们需要从 YAML 文件中导入我们需要读取的内容,并将其写入标准文本文件。然后,我们将打开一个 build_config.yml
文件,该文件位于 Web 应用程序的根目录中,与 config.yml
文件相邻。然后,我们将从 build_config.yml
文件中提取 ALLOWED_VERSION
并将其写入文本文件。现在我们已经定义了构建过程以及从 build_config.yml
文件中需要的内容,我们的 build_config.yml
文件将必须采取以下形式:
ALLOWED_VERSION: v1
现在我们已经为我们的构建定义了一切,我们可以通过我们在 build.rs
文件中写入的文件引入一个 const
实例来表示我们的版本。为此,我们的 main.rs
文件需要一些更改。首先,我们使用以下代码定义 const
:
const ALLOWED_VERSION: &'static str = include_str!(
"./output_data.txt");
HttpServer::new(|| {
. . .
然后,我们使用以下代码认为请求通过,如果版本是被允许的:
HttpServer::new(|| {
. . .
let app = App::new()
.wrap_fn(|req, srv|{
let passed: bool;
if *&req.path().contains(&format!("/{}/",
ALLOWED_VERSION)) {
passed = true;
} else {
passed = false;
}
然后,我们使用以下代码定义错误响应和服务调用:
. . .
let end_result = match passed {
true => {
Either::Left(srv.call(req))
},
false => {
let resp = HttpResponse::NotImplemented()
.body(format!("only {} API is supported",
ALLOWED_VERSION));
Either::Right(
ok(req.into_response(resp).map_into_boxed_body())
)
}
};
. . .
我们现在可以构建并运行我们的应用程序,并支持特定版本。如果我们运行我们的应用程序并发出一个 v2
请求,我们会得到以下响应:
图 8.4 – 邮递员对被阻止 v2 的响应
我们可以看到我们的版本保护器现在正在工作。这也意味着我们必须使用 React 应用程序来访问前端视图,或者你可以在前端 API 端点中添加一个 v1
。
现在,如果我们运行我们的应用程序,我们可以看到我们的前端与新的端点一起工作。有了这个,我们离为我们的应用程序开发 RESTful API 又近了一步。然而,我们仍然有一些明显的不足。目前,我们可以创建另一个用户并使用该用户登录。在下一节中,我们将探讨如何以无状态的方式管理我们的用户状态。
实现无状态
无状态是指服务器不存储任何关于客户端会话的信息。这里的优势是显而易见的。它使我们的应用程序能够更容易地扩展,因为我们通过在客户端而不是服务器端存储会话信息来释放服务器端的资源。
它还使我们能够更灵活地选择计算方法。例如,假设我们的应用程序在受欢迎程度上爆炸式增长。结果,我们可能希望在我们的两个计算实例或服务器上启动我们的应用程序,并让负载均衡器以平衡的方式将流量引导到这两个实例。如果信息存储在服务器上,用户将会有不一致的体验。
他们可能在某个计算实例上更新会话状态,但当他们再次请求时,他们可能会遇到另一个具有过时数据的计算实例。考虑到这一点,仅仅通过在客户端存储所有内容来实现无状态是不够的。如果我们的数据库不依赖于我们应用程序的计算实例,我们也可以在这个数据库上存储我们的数据,如下面的图所示:
图 8.5 – 我们的无状态方法
如您所见,我们的应用程序已经是无状态的。我们在前端将用户 ID 存储在 JWT 中,并将我们的用户数据模型和待办事项存储在我们的 PostgreSQL 数据库中。然而,我们可能想在应用程序中存储 Rust 结构体。例如,我们可以构建一个结构体来计算击中服务器的请求数量。根据图 8**.5,我们不能仅仅在服务器上本地保存我们的结构体。相反,我们将我们的结构体存储在 Redis 中,按照以下图中的过程执行:
图 8.6 – 在 Redis 中保存结构体的步骤
PostgreSQL 和 Redis 之间的区别
Redis 是一个数据库,但它与 PostgreSQL 数据库不同。Redis 更接近于键值存储。由于数据存储在内存中,Redis 也很快。虽然 Redis 在管理表及其相互关系方面不如 PostgreSQL 完整,但它确实具有优势。Redis 支持有用的数据结构,如列表、集合、散列、队列和通道。您还可以为插入 Redis 的数据设置过期时间。您也不需要使用 Redis 处理数据迁移。这使得 Redis 成为缓存您需要快速访问但不太关心持久性的数据的理想数据库。对于通道和队列,Redis 也是促进订阅者和发布者之间通信的理想选择。
我们可以通过执行以下步骤来实现图 8**.6中的过程:
-
定义 Redis 服务的 Docker。
-
更新 Rust 依赖项。
-
更新 Redis 连接的配置文件。
-
构建一个可以保存和加载到 Redis 数据库中的计数器结构体。
-
为每个请求实现计数器。
让我们详细回顾每个步骤:
-
当涉及到启动 Redis Docker 服务时,我们需要使用标准 Redis 容器和标准端口。在我们实现了 Redis 服务之后,我们的
docker-compose.yml
文件应该处于以下状态:version: "3.7"
services:
postgres:
container_name: 'to-do-postgres'
image: 'postgres:11.2'
restart: always
ports:
- '5433:5432'
environment:
- 'POSTGRES_USER=username'
- 'POSTGRES_DB=to_do'
- 'POSTGRES_PASSWORD=password'
redis:
container_name: 'to-do-redis'
image: 'redis:5.0.5'
ports:
- '6379:6379'
我们可以看到,我们现在已经在本地机器上运行了 Redis 服务和数据库服务。现在 Redis 可以运行,我们需要在下一步更新我们的依赖项。
-
回顾图 8**.6,在将数据插入 Redis 之前,我们需要将 Rust 结构体序列化为字节。考虑到这些步骤,我们需要在
Cargo.toml
文件中添加以下依赖项:[dependencies]
. . .
redis = "0.21.5"
我们正在使用redis
crate 连接到 Redis 数据库。现在我们的依赖项已经定义,我们可以开始定义我们的配置文件。
-
当涉及到我们的
config.yml
文件时,我们必须添加 Redis 数据库连接的 URL。在本书的这个时间点,我们的config.yml
文件应该具有以下形式:DB_URL: postgres://username:password@localhost:5433/to_do
SECRET_KEY: secret
EXPIRE_MINUTES: 120
REDIS_URL: redis://127.0.0.1/
我们还没有为REDIS_URL
参数添加端口号。这是因为我们正在使用 Redis 服务的标准端口,即6379
,所以我们不需要定义端口。我们现在有了所有数据,可以定义一个可以连接到 Redis 的结构体,我们将在下一步中这样做。
-
我们将在
src/counter.rs
文件中定义我们的Counter
结构体。首先,我们必须导入以下内容:use serde::{Deserialize, Serialize};
use crate::config::Config;
-
我们将使用
Config
实例来获取 Redis URL,并使用Deserialize
和Serialize
特质来启用转换为字节。我们的Counter
结构体具有以下形式:#[derive(Serialize, Deserialize, Debug)]
pub struct Counter {
pub count: i32
}
-
现在我们已经定义了具有所有特质的
Counter
结构体,我们需要定义以下代码中所需的函数:impl Counter {
fn get_redis_url() -> String {
. . .
}
pub fn save(self) {
. . .
}
pub fn load() -> Counter {
. . .
}
}
-
在定义了前面的函数之后,我们可以将我们的
Counter
结构体加载和保存到 Redis 数据库中。当涉及到构建我们的get_redis_url
函数时,它应该具有以下形式:fn get_redis_url() -> String {
let config = Config::new();
config.map.get("REDIS_URL")
.unwrap().as_str()
.unwrap().to_owned()
}
-
现在我们有了 Redis URL,我们可以使用以下代码保存我们的
Counter
结构体:pub fn save(self) -> Result<(), redis::RedisError> {
let serialized = serde_yaml::to_vec(&self).unwrap();
let client = match redis::Client::open(
Counter::get_redis_url()) {
Ok(client) => client,
Err(error) => return Err(error)
};
let mut con = match client.get_connection() {
Ok(con) => con,
Err(error) => return Err(error)
};
match redis::cmd("SET").arg("COUNTER")
.arg(serialized)
.query::<Vec<u8>>(&mut con) {
Ok(_) => Ok(()),
Err(error) => Err(error)
}
}
-
在这里,我们可以看到我们可以将我们的
Counter
结构体序列化为Vec<u8>
。然后我们将定义 Redis 客户端,并在键"COUNTER"
下插入我们的序列化Counter
结构体。然而,Redis 还有更多功能,但你可以通过将 Redis 视为一个大型的可扩展内存哈希表来利用 Redis 本章。带着哈希表的概念,你认为我们如何从 Redis 数据库中获取Counter
结构体?你可能已经猜到了;我们使用带有"COUNTER"
键的GET
命令,然后使用以下代码反序列化:pub fn load() -> Result<Counter, redis::RedisError> {
let client = match redis::Client::open(
Counter::get_redis_url()){
Ok(client) => client,
Err(error) => return Err(error)
};
let mut con = match client.get_connection() {
Ok(con) => con,
Err(error) => return Err(error)
};
let byte_data: Vec<u8> = match redis::cmd("GET")
.arg("COUNTER")
.query(&mut con) {
Ok(data) => data,
Err(error) => return Err(error)
};
Ok(serde_yaml::from_slice(&byte_data).unwrap())
}
我们现在已经定义了Counter
结构体。我们已经在main.rs
文件中实现了所有内容。
-
当每次接收到请求时增加计数,我们需要在
main.rs
文件中执行以下代码:. . .
mod counter;
. . .
#[actix_web::main]
async fn main() -> std::io::Result<()> {
. . .
let site_counter = counter::Counter{count: 0};
site_counter.save();
HttpServer::new(|| {
. . .
let app = App::new()
.wrap_fn(|req, srv|{
let passed: bool;
let mut site_counter = counter::
Counter::load()
.unwrap();
site_counter.count += 1;
println!("{:?}", &site_counter);
site_counter.save();
. . .
-
在这里,我们可以看到我们定义了
counter
模块。在我们启动服务器之前,我们需要创建新的Counter
结构体并将其插入 Redis。然后我们从 Redis 获取Counter
,增加计数,然后为每个请求保存它。
现在我们运行服务器时,我们可以看到每次我们用请求击中我们的服务器时,计数器都在增加。我们的打印输出应该如下所示:
Counter { count: 1 }
Counter { count: 2 }
Counter { count: 3 }
Counter { count: 4 }
现在我们已经集成了另一个存储选项,我们的应用程序本质上按我们想要的方式运行。如果我们现在想要发布我们的应用程序,实际上没有什么能阻止我们使用 Docker 配置构建,并在带有数据库和NGINX的服务器上部署它。
并发问题
如果两个服务器同时请求计数器,可能会错过请求。计数器示例被探索以展示如何将序列化结构存储在 Redis 中。如果您需要在 Redis 数据库中实现一个简单的计数器并且并发是一个关注点,建议您使用INCR
命令。INCR
命令在 Redis 数据库中增加所选键下的数字,并返回新的增加后的数字。鉴于计数器在 Redis 数据库中增加,我们已经降低了并发问题的风险。
然而,我们总是可以添加一些内容。在下一节中,我们将研究日志请求。
记录我们的服务器流量
到目前为止,我们的应用程序没有记录任何内容。这不会直接影响应用程序的运行。然而,日志记录有一些优点。日志记录使我们能够调试我们的应用程序。
目前,由于我们正在本地开发,可能感觉日志记录并不是真的必要。然而,在生产环境中,有许多原因可能导致应用程序失败,包括 Docker 容器编排问题。记录已发生的过程的日志可以帮助我们定位错误。我们还可以使用日志来查看边缘情况和错误何时出现,以便监控应用程序的整体健康状况。在日志记录方面,我们可以构建四种类型的日志:
-
信息性(info): 这是一种通用日志记录。如果我们想跟踪一般过程及其进展,我们使用这种类型的日志。使用示例包括启动和停止服务器,以及记录我们想要监控的某些检查点,例如 HTTP 请求。
-
冗长: 这类信息类似于前一点中定义的类型。然而,它提供了更细粒度的信息,以告知我们过程的更详细流程。这种类型的日志主要用于调试目的,并且在生产设置中通常应避免使用。
-
警告: 我们在记录一个失败的过程且不应被忽略时使用此类型。然而,我们可以使用此代替引发错误,因为我们不希望服务中断或用户意识到具体的错误。日志本身是为了让我们意识到问题,以便我们可以采取行动。如调用另一个服务器失败等问题适合此类。
-
错误: 这是指由于错误而中断过程的地方,我们需要尽快解决这个问题。我们还需要通知用户交易没有完成。一个很好的例子是连接或向数据库插入数据失败。如果发生这种情况,交易发生的记录将不存在,并且无法事后解决。然而,应该注意的是,过程可以继续运行。
如果出现关于服务器无法发送电子邮件、连接到另一个服务器以调度产品进行发货等问题警告。一旦我们解决了问题,我们可以在这一时间段内回溯性地进行数据库调用,并使用正确的信息调用服务器。
在最坏的情况下,会有延迟。由于服务器在订单甚至进入数据库之前就被错误中断,我们将无法进行数据库调用。考虑到这一点,很明显为什么错误日志记录非常重要,因为用户需要被告知存在问题,并且他们的交易没有完成,这会促使他们稍后再次尝试。
我们可以考虑在错误日志中包含足够的信息,以便在问题解决后回溯性地更新数据库并完成剩余的过程,从而消除通知用户的需求。虽然这很有吸引力,但我们必须考虑两件事。日志数据通常是未结构化的。
进入日志的内容没有质量控制。因此,一旦我们最终成功将日志数据转换成正确的格式,仍然有可能损坏的数据会进入数据库。
第二个问题是日志不被认为是安全的。它们被复制并发送给其他开发人员在危机中,并且可以被插入到其他管道和网站上,例如 Bugsnag,以监控日志。考虑到日志的性质,在日志中包含任何可识别的信息都不是一个好的做法。
现在我们已经了解了日志记录的用途,我们可以开始配置我们自己的记录器。当涉及到日志记录时,我们将使用 Actix-web 记录器。这让我们在配置和良好地与我们的 Actix 服务器一起工作的情况下,有灵活性来记录什么。为了构建我们的记录器,我们必须在Cargo.toml
文件中定义一个新的 crate,以下代码:
[dependencies]
. . .
env_logger = "0.9.0"
这使我们能够使用环境变量来配置记录器。现在我们可以专注于main.rs
,因为这是我们的记录器被配置和使用的地方。首先,我们将使用以下代码导入我们的记录器:
use actix_web::{. . ., middleware::Logger};
使用这个导入,我们可以在main
函数中定义我们的记录器,以下代码:
. . .
#[actix_web::main]
async fn main() -> std::io::Result<()> {
. . .
env_logger::init_from_env(env_logger::Env::new()
.default_filter_or("info"));
. . .
在这里,我们声明我们的记录器将信息记录到 info 流中。记录器配置完成后,我们可以使用以下代码将记录器包装到我们的服务器上:
. . .
async move {
let result = end_result.await?;
Ok(result)
}
}).configure(views::views_factory).wrap(cors)
.wrap(Logger::new("%a %{User-Agent}i %r %s %D"));
return app
. . .
我们可以在我们的记录器中看到我们传递了"&a %{User-Agent}I %r %s %D"
字符串。这个字符串被记录器解释,告诉它们要记录什么。Actix 记录器可以接受以下输入:
-
%%
: 百分号 -
%a
: 远程 IP 地址(如果使用反向代理,则为代理的 IP 地址) -
%t
: 请求开始处理的时间 -
%P
: 服务请求的子进程的进程 ID -
%r
: 请求的第一行 -
%s
: 响应状态码 -
%b
: 响应的大小(包括 HTTP 头部的字节数) -
%T
:处理请求所需的时间,以秒为单位,带有小数点后六位的浮点格式 -
%D
:处理请求所需的时间,以毫秒为单位 -
%{``FOO}i
:request.headers['FOO']
-
%{``FOO}o
:response.headers['FOO']
-
%{``FOO}e
:os.environ['FOO']
基于这些输入,我们可以计算出我们将记录远程 IP 地址、用户代理、请求端点和处理请求所需的时间。我们将为我们的 Rust 服务器上的每个请求都这样做。以日志记录启动我们的 Rust 服务器会给我们以下输出:
[2022-05-25T17:22:32Z INFO actix_server::builder] Starting 8 workers
[2022-05-25T17:22:32Z INFO actix_server::server] Actix runtime found; starting in Actix runtime
在这里,我们可以看到服务器启动时自动记录了工作进程的数量。然后,如果我们启动我们的前端,我们应该被提示登录,因为令牌现在应该已经过期了。一个完整的标准请求日志应该看起来像以下输出:
[2022-05-25T17:14:56Z INFO actix_web::middleware::logger]
127.0.0.1 Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)
AppleWebKit/537.36 (KHTML, like Gecko)
Chrome/101.0.4951.64 Safari/537.36
GET /v1/item/get HTTP/1.1 401 9.466000
我们可以看到时间、这是一个INFO
级别的日志以及哪个记录器记录了它。我们还可以看到我的 IP 地址,因为我是在本地运行我的应用程序,我的电脑/浏览器详情以及带有401
响应代码的 API 调用。如果我们从请求日志中删除除方法、API 端点、响应代码和响应时间之外的所有内容,我们的登录提示将看起来像以下:
GET /v1/item/get HTTP/1.1 401 9.466000
OPTIONS /v1/auth/login HTTP/1.1 200 0.254000
POST /v1/auth/login HTTP/1.1 200 1405.585000
OPTIONS /v1/item/get HTTP/1.1 200 0.082000
GET /v1/item/get HTTP/1.1 200 15.470000
我们可以看到我在获取项目时失败了,得到了一个未授权的响应。然后我登录并从登录中得到了一个OK
响应。在这里,我们可以看到一个OPTIONS
和POST
方法。OPTIONS
方法是为了我们的 CORS,这就是为什么OPTIONS
调用处理时间只是其他 API 请求的一小部分。我们可以看到我们随后得到了我们的项目,然后渲染到页面上。然而,我们可以看到在刷新页面时以下日志中发生的事情:
OPTIONS /v1/item/get HTTP/1.1 200 0.251000
OPTIONS /v1/item/get HTTP/1.1 200 0.367000
GET /v1/item/get HTTP/1.1 200 88.317000
GET /v1/item/get HTTP/1.1 200 9.523000
我们可以看到有两个针对项目的GET
请求。然而,我们没有更改数据库中的待办事项。这不是错误,但这是浪费的。为了优化这一点,我们可以在下一节利用缓存的 REST 约束。
缓存
缓存是我们将数据存储在前端以便重复使用的地方。这使我们能够减少对后端 API 调用的次数并降低延迟。因为好处如此明显,所以可能会诱使我们缓存一切。然而,还有一些事情需要考虑。
并发是一个明显的问题。数据可能会过时,导致发送错误信息到后端时产生混淆和数据损坏。也存在安全方面的担忧。如果一个用户注销,另一个用户在同一台电脑上登录,那么第二个用户可能会访问第一个用户的物品。因此,必须有一些检查措施。正确的用户需要登录,数据需要带时间戳,这样如果缓存的数据在某个时间段后被访问,就会发起一个GET
请求来刷新数据。
我们的应用程序相当安全。除非我们登录,否则我们无法访问任何内容。我们可以在应用程序中缓存的主体过程是GET
项目调用。所有其他编辑后端项目列表状态的调用都会返回更新后的项目。考虑到这一点,我们的缓存机制看起来如下:
图 8.7 – 我们的缓存方法
图表中的循环可以在刷新页面时执行任意多次。然而,这可能不是一个好主意。如果一个用户在厨房使用手机登录我们的应用程序来更新列表,那么当用户回到电脑上工作并刷新电脑页面时,用户会遇到问题。这个缓存系统会暴露用户给过时的数据,这些数据将被发送到后端。我们可以通过引用时间戳来降低这种情况发生的风险。当时间戳超过截止时间时,我们将在用户刷新页面时发起另一个 API 调用以刷新数据。
当涉及到我们的缓存逻辑时,它将全部在front_end/src/App.js
文件下的getItems
函数中实现。我们的getItems
函数将具有以下布局:
getItems() {
. . .
if (difference <= 120) {
. . .
}
else {
axios.get("http://127.0.0.1:8000/v1/item/get",
{headers: {"token": localStorage
.getItem("token")}}).then(response => {
. . .
})
}).catch(error => {
if (error.response.status === 401) {
this.logout();
}
});
}
}
在这里,我们声明最后缓存的项和当前时间之间的时间差必须小于 120 秒,即 2 分钟。如果时间差低于 2 分钟,我们将从缓存中获取我们的数据。然而,如果时间差超过 2 分钟,我们将向我们的 API 后端发起请求。如果我们收到未授权的响应,我们将注销。首先,在这个getItems
函数中,我们获取项目缓存的日期,并使用以下代码计算那时和现在之间的差异:
let cachedData = Date.parse(localStorage
.getItem("item-cache-date"));
let now = new Date();
let difference = Math.round((now - cachedData) / (1000));
如果我们的时间差是 2 分钟,我们将从本地存储中获取我们的数据,并使用以下代码用这些数据更新我们的状态:
let pendingItems =
JSON.parse(localStorage.getItem("item-cache-data-pending"));
let doneItems =
JSON.parse(localStorage.getItem("item-cache-data-
done"));
let pendingItemsCount = pendingItems.length;
let doneItemsCount = doneItems.length;
this.setState({
"pending_items": this.processItemValues(pendingItems),
"done_items": this.processItemValues(doneItems),
"pending_items_count": pendingItemsCount,
"done_items_count": doneItemsCount
})
在这里,我们必须解析来自本地存储的数据,因为本地存储只处理字符串数据。由于本地存储只处理字符串,我们必须在用以下代码发起 API 请求时将我们插入本地存储的数据进行字符串化:
let pending_items = response.data["pending_items"]
let done_items = response.data["done_items"]
localStorage.setItem("item-cache-date", new Date());
localStorage.setItem("item-cache-data-pending",
JSON.stringify(pending_items));
localStorage.setItem("item-cache-data-done",
JSON.stringify(done_items));
this.setState({
"pending_items": this.processItemValues(pending_items),
"done_items": this.processItemValues(done_items),
"pending_items_count":
response.data["pending_item_count"],
"done_items_count": response.data["done_item_count"]
})
如果我们运行我们的应用程序,我们只会发起一次 API 调用。如果我们在大约 2 分钟内刷新我们的应用程序,我们会看到尽管我们的前端从缓存中渲染了所有项目,但并没有新的 API 调用。然而,如果我们创建、编辑或删除一个项目,然后在 2 分钟内刷新页面,我们会看到我们的视图会恢复到之前过时的状态。这是因为创建、编辑和删除的项目也会回到它们之前的状态,但它们并没有存储在本地存储中。这可以通过更新我们的handleReturnedState
函数以及以下代码来处理:
handleReturnedState = (response) => {
let pending_items = response.data["pending_items"]
let done_items = response.data["done_items"]
localStorage.setItem("item-cache-date", new Date());
localStorage.setItem("item-cache-data-pending",
JSON.stringify(pending_items));
localStorage.setItem("item-cache-data-done",
JSON.stringify(done_items));
this.setState({
"pending_items":this.processItemValues(pending_items),
"done_items": this.processItemValues(done_items),
"pending_items_count":response
.data["pending_item_count"],
"done_items_count": response.data["done_item_count"]
})
}
在这里,我们做到了。我们已经成功缓存了我们的数据,并重新使用它以防止我们的后端 API 被过度调用。这也可以应用于其他前端过程。例如,当用户结账时,可以缓存并使用客户购物车。
这使我们的简单网站更接近成为一个网络应用程序。然而,我们必须承认,随着我们越来越多地使用缓存,前端复杂性也在增加。对于我们的应用程序,这就是缓存停止的地方。目前,我们不需要在接下来的一个小时里对我们的应用程序进行任何更多的修改。然而,还有一个概念我们应该简要介绍,那就是按需代码。
按需代码
按需代码是在后端服务器直接在前端执行代码。这个限制是可选的,并且并不广泛使用。然而,它可能很有用,因为它赋予后端服务器决定何时以及如何在前端执行代码的权利。我们已经在做这件事了;在我们的登出视图中,我们通过简单地以字符串的形式返回它,直接在前端执行 JavaScript。这是在src/views/auth/logout.rs
文件中完成的。我们必须记住,我们现在已经将待办事项添加到了我们的本地存储中。如果我们不在登出时从本地存储中删除这些项目,那么如果有人设法在 2 分钟内登录到同一台电脑上的自己的账户,他们就能访问我们的待办事项。虽然这种情况非常不可能发生,但我们还是应该确保安全。记住,我们的登出视图在src/views/auth/logout.rs
文件中具有以下形式:
pub async fn logout() -> HttpResponse {
HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(. . .)
}
在我们的响应体中,我们有以下内容:
"<html>\
<script>\
localStorage.removeItem('user-token'); \
localStorage.removeItem('item-cache-date'); \
localStorage.removeItem('item-cache-data-pending'); \
localStorage.removeItem('item-cache-data-done'); \
window.location.replace(
document.location.origin);\
</script>\
</html>"
通过这种方式,我们不仅删除了用户令牌,还删除了所有项目和日期。这样,一旦我们登出,我们的数据就安全了。
摘要
在本章中,我们已经探讨了 RESTful 设计的各个方面,并将它们应用到我们的应用程序中。我们已经评估了应用程序的层,使我们能够重构中间件,以便根据结果处理两种不同的未来。这不仅仅停止在授权请求。根据请求的参数,我们可以实现中间件将请求重定向到其他服务器,或者直接以按需代码响应的形式做出一些更改,然后进行另一个 API 调用。这种方法为我们提供了另一个工具,在视图之前,在中间件中具有多个未来结果的定制逻辑。
然后,我们重构了我们的路径结构,以使接口统一,防止前端和后端视图之间的冲突。
然后,我们探讨了不同的日志级别,并记录了所有请求以突出显示沉默但不受欢迎的行为。在重构我们的前端以纠正这个问题后,我们随后使用日志来评估当将待办事项缓存到前端以防止过多的 API 调用时,我们的缓存机制是否工作正常。现在,我们的应用程序可以接受了。我们总是可以做出改进;然而,我们还没有达到如果我们将应用程序部署到服务器上,我们就能监控它、在出现问题时代码日志、管理具有各自待办事项的多用户以及在他们到达视图之前拒绝未经授权的请求的阶段。我们还有缓存,我们的应用程序是无状态的,在 PostgreSQL 和 Redis 数据库上访问和写入数据。
在下一章中,我们将为我们的 Rust 结构编写单元测试,为我们的 API 端点编写功能测试,以及清理代码以准备部署。
问题
-
为什么我们不能简单地将多个未来状态编码到中间件中,仅仅根据请求参数和授权结果调用并返回正确的一个,而必须用枚举来包装它们呢?
-
我们如何添加新版本的视图,同时仍然支持旧视图,以防我们的 API 为可能不会立即更新的移动应用和第三方提供服务?
-
为什么在弹性云计算时代,无状态约束变得越来越重要?
-
我们如何利用 JWT 的特性来集成另一个服务?
-
警告日志消息隐藏了错误已经发生的事实,但仍然提醒我们修复它。我们为什么要麻烦告诉用户发生了错误并尝试使用错误日志再次尝试?
-
记录所有请求的优点是什么?
-
为什么我们有时必须使用
async move
?
答案
-
Rust 的强类型系统会抱怨。这是因为
async
块的行为类似于闭包,意味着每个async
块都是其自己的类型。指向多个未来就像指向多个类型一样,因此它看起来像我们正在返回多个不同的类型。 -
我们在视图目录中添加了一个新模块,其中包含新的视图。这些视图具有相同的端点和视图,但需要新的参数。然后我们可以在工厂函数中添加一个版本参数。这些新视图将具有包含
v2
的相同端点。这使用户能够使用新旧 API 端点。然后我们通知用户旧版本将不再被支持,给他们时间来更新。在特定时间,我们将构建中的版本移动到v2
,切断所有执行v1
调用的请求,并返回一个有用的消息,指出v1
不再被支持。为了使这个过渡工作,我们必须更新build
配置中允许的版本列表以支持版本。 -
随着编排工具、微服务和按需弹性计算实例的普及,根据需求启动和关闭弹性计算实例已成为更常见的做法。如果我们将数据存储在实例本身上,当用户进行另一个 API 调用时,无法保证用户会访问到相同的实例,从而导致数据读取和写入的不一致性。
-
JWT 令牌使我们能够存储用户 ID。如果第二个服务有相同的密钥,我们只需将带有 JWT 的请求传递给其他服务即可。其他服务不需要有登录视图或访问用户数据库的权限,但仍能正常工作。
-
当发生错误,使我们无法事后回溯并解决问题时,我们必须引发错误而不是警告。一个经典的错误例子是无法写入数据库。一个良好的警告例子是另一个服务没有响应。当其他服务运行时,我们可以进行数据库调用并调用该服务以完成整个过程。
-
在生产环境中,当进行故障排除时,需要评估服务器的状态。例如,如果用户没有体验到更新,我们可以快速检查日志以查看服务器是否实际上接收到了请求,或者前端缓存是否存在错误。我们还可以用它来查看我们的应用程序是否按预期运行。
-
可能存在一种可能性,即我们在
async
块中引用的变量的生命周期可能不足以看到async
块的结束。为了解决这个问题,我们可以将变量的所有权转移到带有async
move
块的块中。
第四部分:测试和部署
当一个应用程序构建完成后,我们需要将其部署到服务器上以便其他人可以使用它。我们还需要测试它以确保它符合我们的预期,然后再部署它。在本部分中,我们将介绍使用 Postman 等工具进行的单元测试和端到端测试。我们将构建自己的构建和测试管道来自动化测试、构建和部署过程。我们将介绍 HTTP 请求如何路由到服务器以及 HTTPS 协议是什么,以便我们可以在 AWS 上实现它。我们还将使用 NGINX 将流量路由到我们的前端和后端,在 AWS 上的两个独立服务器之间平衡流量,并使用 AWS 安全组锁定这些服务器和负载均衡器的流量。我们将使用 Terraform 自动化 AWS 基础设施。
本部分包括以下章节:
-
第九章, 测试我们的应用程序端点和组件
-
第十章, 在 AWS 上部署我们的应用程序
-
第十一章, 在 AWS 上使用 NGINX 配置 HTTPS
第九章:测试我们的应用程序端点和组件
我们的任务 Rust 应用程序现在完全工作。我们对我们的第一个版本感到满意,因为它管理着身份验证、不同用户及其待办事项列表,并记录我们的流程以供检查。然而,一个网络开发者的工作永远不会结束。
虽然我们现在已经完成了为我们的应用程序添加功能的工作,但我们知道旅程并没有在这里结束。在未来这本书之外的迭代中,我们可能希望添加团队、新的状态、每个用户的多列表等。然而,当我们添加这些功能时,我们必须确保我们的旧应用程序的行为保持不变,除非我们主动改变它。这是通过构建测试来实现的。
在本章中,我们将构建检查我们现有行为的测试,设置陷阱,如果应用程序的行为在没有我们主动改变的情况下发生变化,则会抛出错误并报告给我们。这防止我们在添加新功能或更改代码后破坏应用程序并将其推送到服务器。
在本章中,我们将涵盖以下主题:
-
构建我们的单元测试
-
构建 JWT 单元测试
-
在 Postman 中编写功能 API 测试
-
使用 Newman 自动化 Postman 测试
-
构建整个自动化测试管道
到本章结束时,我们将了解如何在 Rust 中构建单元测试,通过一系列边缘情况详细检查我们的结构体。如果我们的结构体以我们预料之外的方式表现,我们的单元测试将向我们报告。
技术要求
在本章中,我们将基于第八章中构建的代码,即构建 RESTful 服务。这可以在github.com/PacktPublishing/Rust-Web-Programming-2nd-Edition/tree/main/chapter08/caching
找到。
Node 和 NPM 也需要用于安装和运行自动化 API 测试,可以在nodejs.org/en/download/
找到。
我们还将运行自动化测试管道的一部分,使用 Python。Python 可以从www.python.org/downloads/
下载并安装。
您可以在此处找到本章使用的完整源代码:github.com/PacktPublishing/Rust-Web-Programming-2nd-Edition/tree/main/chapter09
。
构建我们的单元测试
在本节中,我们将探讨单元测试的概念以及如何构建包含测试函数的单元测试模块。在这里,我们不会为我们的应用程序实现 100%的单元测试覆盖率。在我们的应用程序中,有些地方可以通过我们的功能测试来覆盖,例如 API 端点和 JSON 序列化。然而,单元测试在我们的应用程序的某些部分仍然很重要。
单元测试使我们能够更详细地查看我们的某些流程。正如我们在第八章“构建 RESTful 服务”中看到的那样,一个功能测试可能按预期的方式从端到端工作,但可能会有我们不希望出现的边缘情况和行为。这在上一章中有所体现,我们看到了应用程序在只需要一个 GET
请求的情况下进行了两次 GET
调用。
在我们的单元测试中,我们将逐一分解流程,模拟某些参数,并测试结果。这些测试是完全隔离的。这种优势在于我们可以快速测试一系列参数,而无需每次都运行整个流程。这也帮助我们精确地确定应用程序失败的位置和配置。单元测试对于测试驱动开发也很有用,在那里我们逐步构建一个功能的组件,运行单元测试,并根据测试结果修改组件。
在大型、复杂的系统中,这可以节省大量时间,因为你不需要启动应用程序并运行整个系统来查找错误或处理边缘情况。
然而,在我们过于兴奋之前,我们必须承认单元测试是一种工具,而不是一种生活方式,并且使用它有一些局限性。测试的质量取决于它们的模拟。如果我们不模拟真实的交互,那么单元测试可能会通过,但应用程序可能会失败。单元测试很重要,但它们也必须与功能测试相结合。
Rust 仍然是一门新兴语言,因此到目前为止,单元测试的支持并不像 Python 或 Java 等其他语言那样先进。例如,在使用 Python 时,我们可以在测试的任何阶段轻松地模拟任何文件中的任何对象。有了这些模拟,我们可以定义结果并监控交互。虽然 Rust 并没有这样现成的模拟,但这并不意味着我们不能进行单元测试。
拙匠常怪其工具。成功的单元测试背后的工艺是构建我们的代码,使各个代码块尽可能独立,这样各个部分就会尽可能具有自主性。正因为这种缺乏依赖性,测试可以很容易地进行,而无需复杂的模拟系统。
首先,我们可以测试我们的待办事项结构体。如您所记得,我们有 done
和 pending
结构体,它们继承了一个 base
结构体。我们可以从单元测试没有依赖的结构体开始,然后向下移动到有依赖的其他结构体。在我们的 src/to_do/structs/base.rs
文件中,我们可以在文件底部定义 base
结构体的单元测试,如下面的代码所示:
#[cfg(test)]
mod base_tests {
use super::Base;
use super::TaskStatus;
#[test]
fn new() {
let expected_title = String::from("test title");
let expected_status = TaskStatus::DONE;
let new_base_struct = Base{
title: expected_title.clone(),
status: TaskStatus::DONE
};
assert_eq!(expected_title,
new_base_struct.title);
assert_eq!(expected_status,
new_base_struct.status);
}
}
在前面的代码中,我们仅仅创建了一个结构体,并评估了该结构体的字段,确保它们是我们预期的。我们可以看到我们创建了一个带有#[cfg(test)]
属性的test
模块。#[cfg(test)]
属性是一个条件检查,只有当我们运行cargo test
时,代码才是活跃的。如果我们不运行cargo test
,则带有#[cfg(test)]
属性的代码不会被编译。
在模块内部,我们将从base_tests
模块外部的文件中导入Base
结构体,该模块仍然位于文件中。在 Rust 的世界里,使用super
导入我们正在测试的内容是典型的。有一个既定的标准,即测试代码应该位于同一文件中被测试代码的下方。然后,我们将通过在new
函数上添加#[test]
属性来测试Base::new
函数。
这是我们第一次介绍属性。属性简单来说就是应用于模块和函数的元数据。这些元数据通过提供信息来帮助编译器。在这种情况下,它是在告诉编译器这个模块是一个测试模块,而这个函数是一个单独的测试。
然而,如果我们运行前面的代码,它将不会工作。这是因为TaskStatus
枚举中没有实现Eq
特质,这意味着我们无法执行以下代码行:
assert_eq!(expected_status, new_base_struct.status);
这也意味着我们无法在两个TaskStatus
枚举之间使用==
运算符。因此,在我们尝试运行测试之前,我们必须在src/to_do/structs/enums.rs
文件中实现TaskStatus
枚举的Eq
特质,以下为相关代码:
#[derive(Clone, Eq, Debug)]
pub enum TaskStatus {
DONE,
PENDING
}
我们可以看到我们已经实现了Eq
和Debug
特质,这些特质对于assert_eq!
宏是必需的。然而,我们的测试仍然无法运行,因为我们还没有定义两个TaskStatus
枚举之间等价的规则。我们可以通过简单地将PartialEq
特质添加到我们的derive
注解中来实现PartialEq
特质。然而,我们应该探索如何编写我们自己的自定义逻辑。为了定义等价规则,我们在PartialEq
特质下实现eq
函数,以下为相关代码:
impl PartialEq for TaskStatus {
fn eq(&self, other: &Self) -> bool {
match self {
TaskStatus::DONE => {
match other {
&TaskStatus::DONE => return true,
&TaskStatus::PENDING => false
}
},
TaskStatus::PENDING => {
match other {
&TaskStatus::DONE => return false,
&TaskStatus::PENDING => true
}
}
}
}
}
在这里,我们可以看到我们通过两个match
语句成功确认了TaskStatus
枚举是否等于正在比较的其他TaskStatus
枚举。在eq
函数中使用==
运算符似乎更直观;然而,使用==
运算符会调用eq
函数,从而导致无限循环。如果你在eq
函数中使用==
运算符,代码仍然可以编译,但如果你运行它,你会得到以下无用的错误:
fatal runtime error: stack overflow
我们现在实际上创建了一个新的base
结构体,然后检查字段是否符合我们的预期。要运行此操作,请使用cargo test
功能,指向我们想要测试的文件,以下为相关命令:
cargo test to_do::structs::base
我们将得到以下输出:
running 1 test
test to_do::structs::base::base_tests::new ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0
filtered out; finished in 0.00s
我们可以看到我们的测试已经运行并通过了。现在,我们将继续编写模块其余部分的测试,这些部分是Done
和Pending
结构体。现在是时候看看你能否在src/to_do/structs/done.rs
文件中编写一个基本的单元测试了。如果你已经尝试在src/to_do/structs/done.rs
文件中为Done
结构体编写单元测试,你的代码应该看起来像以下代码:
#[cfg(test)]
mod done_tests {
use super::Done;
use super::TaskStatus;
#[test]
fn new() {
let new_base_struct = Done::new("test title");
assert_eq!(String::from("test title"),
new_base_struct.super_struct.title);
assert_eq!(TaskStatus::DONE,
new_base_struct.super_struct.status);
}
}
我们可以使用以下命令运行这两个测试:
cargo test
这给出了以下输出:
running 2 tests
test to_do::structs::base::base_tests::new ... ok
test to_do::structs::done::done_tests::new ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0
measured; 0 filtered out; finished in 0.00s
运行cargo test
将在所有 Rust 文件中运行所有测试。我们可以看到现在所有的测试都已经运行并通过了。
现在我们已经进行了一些基本的测试,让我们看看我们可以测试的其他模块。我们的 JSON 序列化和视图可以通过Postman在我们的功能测试中进行测试。我们的数据库模型没有我们故意定义的任何高级功能。
构建 JWT 单元测试
我们所有的模型所做的只是读取和写入数据库。这已经被证明是可行的。我们唯一剩下的要单元测试的模块是auth
模块。在这里,我们有一些基于输入有多个结果的逻辑。我们还必须做一些模拟,因为一些函数接受actix_web
结构体,这些结构体有特定的字段和函数。幸运的是,actix_web
有一个测试模块,它使我们能够模拟请求。
构建测试配置
在我们开始为 JWT 构建单元测试之前,我们必须记住,有一个对config
文件的依赖,以获取密钥。单元测试必须是隔离的。它们不应该需要传递正确的参数才能工作。它们应该每次都是隔离地工作。正因为如此,我们将在src/config.rs
文件中为我们的Config
结构体构建一个new
函数。编码测试的大纲将看起来像以下代码:
impl Config {
// existing function reading from file
#[cfg(not(test))]
pub fn new() -> Config {
. . .
}
// new function for testing
#[cfg(test)]
pub fn new() -> Config {
. . .
}
}
上述大纲显示有两个new
函数。我们的新new
函数在运行测试时被编译,如果服务器以正常方式运行,则旧new
函数被编译。我们的测试new
函数有标准值硬编码在以下代码中:
let mut map = HashMap::new();
map.insert(String::from("DB_URL"),
serde_yaml::from_str(
"postgres://username:password@localhost:5433/
to_do").unwrap());
map.insert(String::from("SECRET_KEY"),
serde_yaml::from_str("secret").unwrap());
map.insert(String::from("EXPIRE_MINUTES"),
serde_yaml::from_str("120").unwrap());
map.insert(String::from("REDIS_URL"),
serde_yaml::from_str("redis://127.0.0.1/")
.unwrap());
return Config {map}
这些默认函数与我们的开发config
文件相同;然而,我们知道这些变量将是一致的。我们在运行测试时不需要传递任何东西,我们也不存在读取另一个文件的风险。现在我们的测试已经配置好了,我们可以定义要求,包括 JWT 测试的配置。
定义 JWT 测试的要求
现在我们已经为测试安全地构建了Config
结构体,我们可以转到我们的src/jwt.rs
文件,并使用以下代码定义测试的导入:
#[cfg(test)]
mod jwt_tests {
use std::str::FromStr;
use super::{JwToken, Config};
use actix_web::{HttpRequest, HttpResponse,
test::TestRequest, web, App};
use actix_web::http::header::{HeaderValue,
HeaderName, ContentType};
use actix_web::test::{init_service, call_service};
use actix_web;
use serde_json::json;
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
pub struct ResponseFromTest {
pub user_id: i32,
pub exp_minutes: i32
}
. . .
}
通过前面的代码,我们可以导入一系列 actix_web
结构体和函数,使我们能够创建伪造的 HTTP 请求并将它们发送到伪造的应用程序以测试 JwToken
结构体在 HTTP 请求过程中的工作情况。我们还将定义一个 ResponseFromTest
结构体,它可以被处理为 JSON,以从 HTTP 请求中提取用户 ID,因为 JwToken
结构体包含了用户 ID。ResponseFromTest
结构体是我们期望得到的 HTTP 响应,因此我们正在紧密模拟响应对象。
现在我们已经导入了所有需要的,我们可以用以下代码定义测试的轮廓:
#[cfg(test)]
mod jwt_tests {
. . .
#[test]
fn get_key() {
. . .
}
#[test]
fn get_exp() {
. . .
}
#[test]
fn decode_incorrect_token() {
. . .
}
#[test]
fn encode_decode() {
. . .
}
async fn test_handler(token: JwToken,
_: HttpRequest) -> HttpResponse {
. . .
}
#[actix_web::test]
async fn test_no_token_request() {
. . .
}
#[actix_web::test]
async fn test_passing_token_request() {
. . .
}
#[actix_web::test]
async fn test_false_token_request() {
. . .
}
}
在这里,我们可以看到我们测试了获取密钥和令牌的编码和解码。它们是 JwToken
结构体的原生函数,并且根据我们之前所讨论的,你应该能够自己编写它们。其他函数被装饰为 #[actix_web::test]
。这意味着我们将创建伪造的 HTTP 请求来测试我们的 JwToken
如何实现 FromRequest
特性。现在,没有什么阻止我们编写测试,我们将在下一节中介绍。
为 JWT 构建基本功能测试
我们将从最基础的测试开始,获取密钥,其形式如下:
#[test]
fn get_key() {
assert_eq!(String::from("secret"), JwToken::get_key());
}
我们必须记住 "secret"
是在 Config::new
函数中硬编码的密钥,用于测试实现。如果 Config::new
测试函数工作正常,上述测试也将工作正常。获取过期时间也可能很重要。因为我们直接依赖于从 config
中提取的过期分钟数,所以以下测试将确保我们返回 120 分钟:
#[test]
fn get_exp() {
let config = Config::new();
let minutes = config.map.get("EXPIRE_MINUTES")
.unwrap().as_i64().unwrap();
assert_eq!(120, minutes);
}
我们现在可以继续测试如何处理无效令牌,以下是一个测试示例:
#[test]
fn decode_incorrect_token() {
let encoded_token: String =
String::from("invalid_token");
match JwToken::from_token(encoded_token) {
Err(message) => assert_eq!("InvalidToken",
message),
_ => panic!(
"Incorrect token should not be able to be
encoded"
)
}
}
在这里,我们传递一个 "invalid_token"
字符串,它应该会失败解码过程,因为它显然不是一个有效的令牌。然后我们将匹配结果。如果结果是错误,我们将断言错误信息是无效令牌的结果。如果有任何其他输出而不是错误,那么我们将抛出一个错误,使测试失败,因为我们期望解码会失败。
现在我们已经为我们的 JwToken
结构体函数编写了两个测试,这是一个很好的时机让你尝试编写编码和解码令牌的测试。如果你尝试编写了编码和解码测试,它应该看起来像以下代码:
#[test]
fn encode_decode() {
let test_token = JwToken::new(5);
let encoded_token = test_token.encode();
let new_token =
JwToken::from_token(encoded_token).unwrap();
assert_eq!(5, new_token.user_id);
}
前面的测试实际上将登录和认证请求过程简化为围绕令牌。我们创建一个新的带有用户 ID 的令牌,对令牌进行编码,然后解码令牌以测试我们传递到令牌中的数据是否与我们解码时得到的数据相同。如果不相同,则测试将失败。
现在我们已经完成了对JwToken
结构体函数的测试,我们可以继续测试JwToken
结构体如何实现FromRequest
特质。在我们这样做之前,我们必须定义一个基本的视图函数,该函数将仅处理JwToken
的认证,然后返回令牌中的用户 ID,以下代码所示:
async fn test_handler(token: JwToken,
_: HttpRequest) -> HttpResponse {
return HttpResponse::Ok().json(json!({"user_id":
token.user_id,
"exp_minutes":
60}))
}
这并不是什么新东西,事实上,这个大纲也是我们定义应用程序中视图的方式。有了我们的基本测试定义,我们可以继续构建针对 Web 请求的测试。
构建 Web 请求的测试
我们现在可以使用以下代码来测试我们的测试视图,看看它如何处理头部没有令牌的请求:
#[actix_web::test]
async fn test_no_token_request() {
let app = init_service(App::new().route("/", web::get()
.to(test_handler))).await;
let req = TestRequest::default()
.insert_header(ContentType::plaintext())
.to_request();
let resp = call_service(&app, req).await;
assert_eq!("401", resp.status().as_str());
}
在前面的代码中,我们可以看到我们可以创建一个假服务器并将我们的test_handler
测试视图附加到它上。然后我们可以创建一个没有令牌的假请求。然后我们将使用假请求调用服务器,并断言请求的响应代码为未授权。我们现在可以创建一个插入有效令牌的测试,以下代码所示:
#[actix_web::test]
async fn test_passing_token_request() {
let test_token = JwToken::new(5);
let encoded_token = test_token.encode();
let app = init_service(App::new().route("/", web::get()
.to(test_handler))).await;
let mut req = TestRequest::default()
.insert_header(ContentType::plaintext())
.to_request();
let header_name = HeaderName::from_str("token")
.unwrap();
let header_value = HeaderValue::from_str(encoded_token
.as_str())
.unwrap();
req.headers_mut().insert(header_name, header_value);
let resp: ResponseFromTest = actix_web::test::
call_and_read_body_json(&app, req).await;
assert_eq!(5, resp.user_id);
}
在这里,我们可以看到我们创建了一个有效的令牌。我们可以创建我们的假服务器并将我们的test_handler
函数附加到那个假服务器上。然后我们将创建一个可以变异的请求。然后,我们将令牌插入到头部,并使用call_and_read_body_json
函数调用假服务器,使用假请求。必须注意的是,当我们调用call_and_read_body_json
函数时,我们声明在resp
变量名下返回的类型是ResponseFromTest
。然后我们断言用户 ID 来自请求响应。
现在我们已经看到了如何创建带有头部的假 HTTP 请求,这是一个很好的机会让你尝试构建一个请求带有无法解码的假令牌的测试。如果你已经尝试过,它应该看起来像以下代码:
#[actix_web::test]
async fn test_false_token_request() {
let app = init_service(App::new().route("/", web::get()
.to(test_handler))).await;
let mut req = TestRequest::default()
.insert_header(ContentType::plaintext())
.to_request();
let header_name = HeaderName::from_str("token")
.unwrap();
let header_value = HeaderValue::from_str("test")
.unwrap();
req.headers_mut().insert(header_name, header_value);
let resp = call_service(&app, req).await;
assert_eq!("401", resp.status().as_str());
}
观察以下代码,我们可以看到我们使用在通过令牌请求测试中概述的方法将一个假令牌插入到头部,并在没有提供令牌的测试中使用未授权断言。如果我们现在运行所有测试,我们应该得到以下输出:
running 9 tests
test to_do::structs::base::base_tests::new ... ok
test to_do::structs::done::done_tests::new ... ok
test to_do::structs::pending::pending_tests::new ... ok
test jwt::jwt_tests::get_key ... ok
test jwt::jwt_tests::decode_incorrect_token ... ok
test jwt::jwt_tests::encode_decode ... ok
test jwt::jwt_tests::test_no_token_request ... ok
test jwt::jwt_tests::test_false_token_request ... ok
test jwt::jwt_tests::test_passing_token_request ... ok
test result: ok. 9 passed; 0 failed; 0 ignored;
0 measured; 0 filtered out; finished in 0.00s
从前面的输出中,我们的jwt
和to_do
模块现在已经完全进行了单元测试。考虑到 Rust 仍然是一种新的语言,我们设法无痛地单元测试了我们的代码,因为我们以模块化的方式结构了我们的代码。
actix_web
提供的tests
crate 使我们能够快速轻松地测试边缘情况。在本节中,我们测试了我们的函数如何处理缺少令牌、假令牌和正确令牌的请求。我们亲眼见证了 Rust 如何使我们能够在代码上运行单元测试。
所有的配置都是通过cargo
完成的。我们不需要设置路径,安装额外的模块,或配置环境变量。我们只需使用test
属性定义模块,并运行cargo test
命令。然而,我们必须记住,我们的视图和 JSON 序列化代码没有进行单元测试。这就是我们切换到 Postman 来测试我们的 API 端点的原因。
在 Postman 中编写测试
在本节中,我们将使用 Postman 实现功能集成测试,以测试我们的 API 端点。这将测试我们的 JSON 处理和数据库访问。为此,我们将遵循以下步骤:
-
我们将不得不为我们的 Postman 测试创建一个测试用户。我们可以使用以下 JSON 体来完成此操作:
{
"name": "maxwell",
"email": "maxwellflitton@gmail.com",
"password": "test"
}
-
我们需要向
http://127.0.0.1:8000/v1/user/create
URL 添加一个POST
请求。一旦完成,我们就可以使用我们的登录端点进行 Postman 测试。现在我们已经创建了测试用户,我们必须从http://127.0.0.1:8000/v1/auth/login
URL 的POST
请求响应头中获取令牌:{
"username": "maxwell",
"password": "test"
}
这给我们以下 Postman 布局:
图 9.1 – 创建新的使用 Postman 请求
使用此令牌,我们拥有创建我们的 Postman 集合所需的所有信息。Postman 是一组 API 请求。在这个集合中,我们可以使用用户令牌作为认证,将所有待办事项 API 调用组合在一起。调用结果如下:
图 9.2 – 创建新的使用 Postman 的响应
- 我们可以使用以下 Postman 按钮创建我们的集合,即+ 新建集合:
图 9.3 – 创建新的 Postman 集合
- 点击此按钮后,我们必须确保我们的用户令牌已为集合定义,因为所有待办事项 API 调用都需要令牌。这可以通过使用 API 调用的授权配置来完成,如下面的截图所示:
图 9.4 – 在新的 Postman 集合中定义 AUTH 凭据
我们可以看到,我们只是将令牌复制粘贴到以token为键的值中,这将插入到请求的头部。现在这个令牌应该被传递到集合中的所有请求中。这个集合现在存储在左侧导航栏的集合选项卡下。
- 我们现在已经配置了我们的集合,并且现在可以通过点击此截图所示的灰色添加请求按钮在集合下添加请求:
图 9.5 – 为我们的 Postman 集合创建新的请求
现在,我们必须考虑我们测试流程的方法,因为这必须是一个自包含的过程。
编写测试的有序请求
我们的要求将按以下顺序进行:
-
创建:创建一个待办事项,然后检查返回值以查看它是否已正确存储。
-
创建:创建另一个待办事项,检查返回值以查看前一个是否已存储,并且流程可以处理两个项目。
-
创建:创建另一个与另一个项目具有相同标题的待办事项,检查响应以确保我们的应用程序不会存储具有相同标题的重复待办事项。
-
编辑:编辑一个项目,检查响应以查看编辑后的项目是否已更改状态为完成,并且是否已存储在正确的列表中。
-
编辑:编辑第二个项目,以查看编辑效果是否是永久的,以及完成列表是否支持两个项目。
-
编辑:编辑应用程序中不存在的项目,以查看应用程序是否正确处理这种情况。
-
删除:删除一个待办事项以查看响应是否不再返回被删除的待办事项,这意味着它不再存储在数据库中。
-
删除:删除最后一个待办事项,检查响应以查看是否没有剩余项目,这表明删除操作是永久的。
我们需要运行前面的测试,因为它们依赖于前面的操作是正确的。当我们为集合创建请求时,我们必须清楚请求正在做什么,它处于哪个步骤,以及它是什么类型的请求。例如,创建我们的第一个创建测试将如下所示:
图 9.6 – 创建我们的第一个 Postman 创建请求
如我们所见,步骤通过下划线附加了类型。然后我们将测试列表中的测试描述放入请求描述(可选)字段。在定义请求时,你可能会意识到 API 密钥不在请求的标题中。
这是因为它位于请求的隐藏自动生成标题中。我们的第一个请求必须是一个POST
请求,带有http://127.0.0.1:8000/v1/item/create/washing
URL。
这创建了待办事项洗涤。然而,在我们点击发送按钮之前,我们必须切换到 Postman 请求中的测试选项卡,就在设置选项卡的左侧,以便编写以下截图所示的测试:
图 9.7 – 在 Postman 中访问测试脚本
我们必须用 JavaScript 编写测试。然而,我们可以通过在测试脚本中键入pm
来访问 Postman 的test
库。首先,在测试脚本顶部,我们需要处理请求,这是通过以下代码完成的:
var result = pm.response.json()
通过前面的行,我们可以在整个测试脚本中访问响应 JSON。为了全面测试我们的请求,我们需要遵循以下步骤:
-
首先,我们需要检查响应的基本内容。我们的第一个测试是检查响应是否为
200
。这可以通过以下代码完成:pm.test("response is ok", function () {
pm.response.to.have.status(200);
});
在这里,我们定义测试描述。然后,定义测试运行的函数。
-
然后,我们检查响应中的数据长度。在先前的测试之后,我们将定义以下代码来检查待办事项的长度是否为 1:
pm.test("returns one pending item", function(){
if (result["pending_items"].length !== 1){
throw new Error(
"returns the wrong number of pending items");
}
})
在前面的代码中,我们进行了一个简单的长度检查,如果长度不是一,则抛出错误,因为我们只期望pending_items
列表中有一个待办事项。
-
然后,我们在以下代码中检查待办事项的标题和状态:
pm.test("Pending item has the correct title", function(){
if (result["pending_items"][0]["title"] !==
"washing"){
throw new Error(
"title of the pending item is not 'washing'");
}
})
pm.test("Pending item has the correct status",
function()
{
if (result["pending_items"][0]["status"] !==
"PENDING"){
throw new Error(
"status of the pending item is not
'pending'");
}
})
在前面的代码中,如果状态或标题不符合我们的预期,我们将抛出错误。现在我们已经满足了待办事项的测试,我们可以继续对已完成事项进行测试。
-
由于我们的已完成事项应该是零,测试的定义如下:
pm.test("returns zero done items", function(){
if (result["done_items"].length !== 0){
throw new Error(
"returns the wrong number of done items");
}
})
在前面的代码中,我们只是确保done_items
数组长度为零。
-
现在,我们必须检查已完成和待办事项的数量。这可以通过以下代码完成:
pm.test("checking pending item count", function(){
if (result["pending_item_count"] !== 1){
throw new Error(
"pending_item_count needs to be one");
}
})
pm.test("checking done item count", function(){
if (result["done_item_count"] !== 0){
throw new Error(
"done_item_count needs to be zero");
}
})
现在我们已经构建了测试,我们可以通过点击 Postman 中的发送按钮来发出请求,以获取以下测试输出:
图 9.8 – Postman 测试输出
我们可以看到,我们的测试描述和测试状态被突出显示。如果你得到一个错误,状态将是红色,显示FAIL。现在我们的第一个创建测试已经完成,我们可以创建第二个创建测试。
创建一个 HTTP 请求的测试
然后,我们可以使用此 URL 创建2_create
测试:http://127.0.0.1:8000/v1/item/create/cooking
。这是一个尝试使用我们在上一步中探索的测试方法自己构建测试的好机会。如果你尝试构建测试,它们应该看起来像以下代码:
var result = pm.response.json()
pm.test("response is ok", function () {
pm.response.to.have.status(200);
});
pm.test("returns two pending item", function(){
if (result["pending_items"].length !== 2){
throw new Error(
"returns the wrong number of pending items");
}
})
pm.test("Pending item has the correct title", function(){
if (result["pending_items"][0]["title"] !== "washing"){
throw new Error(
"title of the pending item is not 'washing'");
}
})
pm.test("Pending item has the correct status", function(){
if (result["pending_items"][0]["status"] !==
"PENDING"){
throw new Error(
"status of the pending item is not 'pending'");
}
})
pm.test("Pending item has the correct title", function(){
if (result["pending_items"][1]["title"] !== "cooking"){
throw new Error(
"title of the pending item is not 'cooking'");
}
})
pm.test("Pending item has the correct status", function(){
if (result["pending_items"][1]["status"] !==
"PENDING"){
throw new Error(
"status of the pending item is not 'pending'");
}
})
pm.test("returns zero done items", function(){
if (result["done_items"].length !== 0){
throw new Error(
"returns the wrong number of done items");
}
})
pm.test("checking pending item count", function(){
if (result["pending_item_count"].length === 1){
throw new Error(
"pending_item_count needs to be one");
}
})
pm.test("checking done item count", function(){
if (result["done_item_count"].length === 0){
throw new Error(
"done_item_count needs to be zero");
}
})
我们可以看到,我们在第二个待办事项上添加了一些额外的测试。前面的测试也直接适用于3_create
测试,因为重复创建将与我们将使用与2_create
相同的 URL 相同。
前面的测试在这些测试中需要相当多的重复,稍微改变数组的长度、项目计数和数组内的属性。这是一个练习基本 Postman 测试的好机会。如果你需要将你的测试与我的测试进行交叉引用,你可以在以下 URL 的 JSON 文件中评估它们:github.com/PacktPublishing/Rust-Web-Programming-2nd-Edition/blob/main/chapter09/building_test_pipeline/web_app/scripts/to_do_items.postman_collection.json
。
在本节中,我们为 Postman 测试当 API 调用时执行了一系列步骤。这不仅对我们应用程序有用。Postman 可以访问互联网上的任何 API。因此,您可以使用 Postman 测试来监控实时服务器和第三方 API。
现在,如果必须手动每次运行所有这些测试,可能会很费力。我们可以使用新曼自动化运行和检查这个集合中的所有测试。如果我们自动化这些集合,我们可以在每天特定时间运行测试,以实时服务器和我们所依赖的第三方 API,并在我们的服务器或第三方 API 出现问题时提醒我们。
新曼将为我们在这个领域进一步开发提供一个良好的基础。在下一节中,我们将导出集合,并使用新曼按顺序运行导出集合中的所有 API 测试。
使用新曼自动化 Postman 测试
为了自动化一系列测试,在本节中,我们将按照正确的顺序导出我们的待办事项 Postman 集合。但首先,我们必须将集合导出为 JSON 文件。这可以通过在 Postman 的左侧导航栏中点击我们的集合,然后点击灰色显示的导出按钮来完成,如以下截图所示:
图 9.9 – 导出我们的 Postman 集合
现在我们已经导出了集合,我们可以快速检查它以查看文件是如何结构的。以下代码定义了测试套件的头部:
"info": {
"_postman_id": "bab28260-c096-49b9-81e6-b56fc5f60e9d",
"name": "to_do_items",
"schema": "https://schema.getpostman.com
/json/collection/v2.1.0/collection.json",
"_exporter_id": "3356974"
},
上述代码告诉 Postman 需要什么模式来运行测试。如果代码被导入到 Postman 中,ID 和名称将可见。然后文件继续通过以下代码定义单个测试:
"item": [
{
"name": "1_create",
"event": [
{
"listen": "test",
"script": {
"exec": [
"var result = pm.response.json()",
. . .
],
"type": "text/javascript"
}
}
],
"request": {
"method": "POST",
"header": [
{
"key": "token",
"value": "eyJhbGciOiJIUzI1NiJ9
.eyJ1c2VyX2lkIjo2fQ.
uVo7u877IT2GEMpB_gxVtxhMAYAJD8
W_XiUoNvR7_iM",
"type": "text",
"disabled": true
}
],
"url": {
"raw": "http://127.0.0.1:8000/
v1/item/create/washing",
"protocol": "http",
"host": ["127", "0", "0", "1"],
"port": "8000",
"path": ["v1", "item", "create", "washing"]
},
"description": "create a to-do item,
and then check the
return to see if it is stored correctly "
},
"response": []
},
从上述代码中,我们可以看到我们的测试、方法、URL、头部等都在一个数组中定义。快速检查item
数组将显示测试将按照我们想要的顺序执行。
现在,我们可以简单地使用新曼运行它。我们可以使用以下命令安装新曼:
npm install -g newman
注意
必须注意,上述命令是一个全局安装,有时可能会出现问题。为了避免这种情况,你可以设置一个包含以下内容的package.json
文件:
{
"name": "newman testing",
"description": "",
"version": "0.1.0",
"scripts": {
"test": "newman run to_do_items.
postman_collection.json"
},
"dependencies": {
"newman": "5.3.2"
}
}
使用这个package.json
,我们已经定义了测试命令和新曼依赖。我们可以使用以下命令在本地上安装我们的依赖项:
npm install
这将在node_modules
目录下安装我们所需的所有内容。我们不必直接运行新曼测试命令,可以使用package.json
中定义的测试命令,使用以下命令:
npm run test
现在我们已经安装了新曼,我们可以使用以下命令运行测试集合对导出的集合 JSON 文件进行测试:
newman run to_do_items.postman_collection.json
上述命令运行所有测试并给出状态报告。每个描述都打印出来,测试的状态也在测试旁边表示。以下是一个典型的 API 测试评估的打印输出:
→ 1_create
POST http://127.0.0.1:8000/v1/item/create/washing
[200 OK, 226B, 115ms]
✓ response is ok
✓ returns one pending item
✓ Pending item has the correct title
✓ Pending item has the correct status
✓ returns zero done items
✓ checking pending item count
✓ checking done item count
上述输出给出了名称、方法、URL 和响应。在这里,所有这些都通过了。如果其中任何一个没有通过,那么测试描述将显示一个 叉 而不是 勾。我们还得到了以下摘要:
图 9.10 – Newman 摘要
我们可以看到所有测试都通过了。通过这种方式,我们已经成功自动化了我们的功能测试,使我们能够以最小的努力测试完整的流程。然而,我们所做的是不可维护的。例如,我们的令牌将过期,这意味着如果我们在本月稍后运行测试,它们将失败。在下一节中,我们将构建一个完整的自动化流程,该流程将构建我们的服务器,更新我们的令牌,并运行我们的测试。
构建整个自动化测试流程
在开发和测试方面,我们需要一个可以轻松拆解和重建的环境。没有什么比在你的本地机器上构建数据库中的数据来进一步开发使用这些数据的功能更糟糕的了。然而,数据库容器可能会意外删除,或者你可能编写了一些损坏数据的代码。然后,你必须花费大量时间重新创建数据,才能回到之前的状态。如果系统复杂且缺少文档,你可能会忘记重新创建数据所需的步骤。如果你在开发和测试时对销毁本地数据库并重新开始感到不舒服,那么就有问题,而且你被抓住只是时间问题。在本节中,我们将创建一个单个 Bash 脚本,该脚本执行以下操作:
-
在后台启动数据库 Docker 容器。
-
编译 Rust 服务器。
-
运行单元测试。
-
启动 Rust 服务器运行。
-
将迁移运行在 Docker 中的数据库。
-
发送 HTTP 请求创建用户。
-
发送 HTTP 请求进行登录并获取令牌。
-
使用登录令牌更新 Newman JSON 文件。
-
运行 Newman 测试。
-
删除整个过程中产生的文件。
-
停止 Rust 服务器运行。
-
停止并销毁整个过程中运行的 Docker 容器。
上述列表中列出了许多步骤。浏览这个列表,直观地似乎应该将我们要探索的代码块分解成步骤;然而,我们将几乎在单个 Bash 脚本中运行所有这些步骤。许多前面概述的步骤可以用一行 Bash 代码实现。将代码分解成步骤将是多余的。现在我们已经拥有了所有需要的步骤,我们可以设置我们的测试基础设施。首先,我们需要在 web_app
根目录中 src
目录旁边设置一个 scripts
目录。然后在 scripts
目录中,我们需要一个 run_test_pipeline.sh
脚本,该脚本将运行主要的测试过程。我们还需要将我们的 Newman JSON config
文件放在 scripts
目录中。
我们将使用 bash
来编排整个测试流程,这是编排测试任务的最佳工具。在我们的 scripts/run_test_pipeline.sh
脚本中,我们将从以下代码开始:
#!/bin/bash
# move to directory of the project
SCRIPTPATH="$( cd "$(dirname "$0")" ; pwd -P )"
cd $SCRIPTPATH
cd ..
在前面的代码中,我们告诉计算机代码块是一个 Bash 脚本,使用 #!/bin/bash
shebang 行。Bash 脚本从调用它的 Bash 脚本的当前工作目录运行。我们可以从多个目录调用脚本,因此我们需要确保我们获取到脚本所在的目录,即 scripts
目录,将其分配给名为 SCRIPTPATH
的变量,移动到该目录,然后使用 cd..
命令向上移动一个目录,以便位于包含 Docker、配置和 Cargo 文件的父目录中。然后我们可以使用 -d
标志在后台启动 Docker 容器,并循环直到数据库接受连接,以下为相关代码:
# spin up docker and hold script until accepting connections
docker-compose up -d
until pg_isready -h localhost -p 5433 -U username
do
echo "Waiting for postgres"
sleep 2;
done
现在我们已经启动了 Docker 容器,我们可以继续构建我们的 Rust 服务器。首先,我们可以编译 Rust 服务器并使用以下代码运行我们的单元测试:
cargo build
cargo test
单元测试运行完毕后,我们可以使用以下代码在后台运行我们的服务器:
# run server in background
cargo run config.yml &
SERVER_PID=$!
sleep 5
命令末尾的 &
使得 cargo run config.yml
在后台运行。然后我们获取 cargo run config.yml
命令的进程 ID,并将其分配给变量 SERVER_PID
。然后我们等待 5 秒以确保服务器已准备好接受连接。在我们向服务器发送任何 API 调用之前,我们必须使用以下代码运行数据库迁移:
diesel migration run
然后我们回到 scripts
目录,向我们的服务器发送一个创建用户的 API 调用:
# create the user
curl --location --request POST 'http://localhost:8000/v1/user/create' \
--header 'Content-Type: application/json' \
--data-raw '{
"name": "maxwell",
"email": "maxwellflitton@gmail.com",
"password": "test"
}'
如果你想知道如何在 Bash 中使用 curl
发送 HTTP 请求,你可以使用 Postman 工具自动生成它们。在 Postman 工具的右侧,你可以看到一个 代码 按钮,如下面的截图所示:
图 9.11 – 代码生成工具
一旦你点击了代码标签,就会出现一个下拉菜单,你可以从中选择多种语言。一旦你选择了你想要的编程语言,你的 API 调用就会以代码片段的形式显示在你选择的编程语言中,然后你可以复制并粘贴。
现在我们已经创建了用户,我们可以登录并使用以下代码将令牌存储在 fresh_token.json
文件中;然而,需要注意的是,curl
首先需要被安装:
# login getting a fresh token
echo $(curl --location --request GET 'http://localhost:8000/v1/auth/login' \
--header 'Content-Type: application/json' \
--data-raw '{
"username": "maxwell",
"password": "test"
}') > ./fresh_token.json
这里发生的情况是我们可以将 API 调用的结果包装在一个变量 $(...)
中。然后我们通过 echo $(...) > ./fresh_token.json
将其输出并写入文件。然后我们可以将新的令牌插入到 Newman 数据中,并使用以下代码运行 Newman API 测试:
TOKEN=$(jq '.token' fresh_token.json)
jq '.auth.apikey[0].value = '"$TOKEN"''
to_do_items.postman_collection.json > test_newman.json
newman run test_newman.json
我们的测试现在完成了。我们可以清理测试运行时创建的文件,销毁 Docker 容器,并使用以下代码停止运行的服务器:
rm ./test_newman.json
rm ./fresh_token.json
# shut down rust server
kill $SERVER_PID
cd ..
docker-compose down
注意
在我们运行 Bash 脚本之前,curl
和jq
都需要安装。如果您使用 Linux,可能需要运行以下命令:
sudo chmod +x ./run_test_pipeline.sh
我们可以使用以下命令运行我们的测试脚本:
sh run_test_pipeline.sh
展示整个打印输出只会无谓地填满书籍。然而,我们可以在以下屏幕截图中看到测试打印输出的结尾:
图 9.12 – 测试管道输出
在这里,打印输出清楚地表明纽曼测试已经运行并通过。测试完成后,服务器被关闭,支持服务器的 Docker 容器也被停止并移除。如果您想将此日志写入txt
文件,可以使用以下命令:
sh run_test_pipeline.sh > full_log.txt
这就是了!一个完全工作的测试管道,它自动化了我们的服务器设置、测试和清理。因为我们将其编写为简单的 Bash 测试管道,所以我们可以将这些步骤集成到自动化管道中,如 Travis、Jenkins 或 GitHub Actions。这些管道工具在执行pull
请求和合并时自动触发。
摘要
在本章中,我们了解了我们应用程序的工作流程和组件,将它们分解以便我们可以为正确部分选择正确的工具。我们使用了单元测试,以便可以快速检查几个边缘情况,以了解每个函数和结构体如何与其他部分交互。
我们还直接使用单元测试检查了我们的自定义结构体。然后,我们使用actix_web
测试结构体来模拟请求,以查看使用结构体并处理请求的函数如何工作。然而,当我们来到主 API 视图模块时,我们转向了 Postman。
这是因为我们的 API 端点是简单的。它们创建、编辑和删除待办事项。我们可以通过直接进行 API 调用并检查响应来直接评估此过程。我们能够直接评估 JSON 处理以接受和返回数据。我们还能够使用这些 Postman 测试评估数据库中的数据查询、写入和更新。
Postman 使我们能够快速高效地测试一系列流程。我们甚至通过 Newman 自动化这个过程来加快测试速度。然而,必须注意的是,这种方法并不是万能的。如果 API 视图函数变得更加复杂,有更多的移动部件,例如与另一个 API 或服务通信,那么 Newman 方法将需要重新设计。需要考虑触发模拟此类过程的环境变量,以便我们可以快速测试一系列边缘情况。
如果系统随着我们结构体的依赖关系增长而增长,将需要模拟对象。这就是我们创建一个假的结构体或函数并定义测试输出的地方。为此,我们需要一个外部 crate,例如mockall
。关于这个 crate 的文档在本章的进一步阅读部分有所介绍。
我们的应用程序现在完全运行,并有一系列测试。现在,我们剩下的就是将我们的应用程序部署到服务器上。
在下一章中,我们将使用 Docker 在 Amazon Web Services (AWS) 上设置一个服务器,将我们的应用程序部署到服务器上。我们将介绍设置 AWS 配置、运行测试,并在测试通过的情况下将我们的应用程序部署到服务器上的过程。
问题
-
如果我们可以手动操作应用程序,为什么还要费心进行单元测试?
-
单元测试和功能测试有什么区别?
-
单元测试有哪些优点?
-
单元测试有哪些缺点?
-
功能测试有哪些优点?
-
功能测试有哪些缺点?
-
构建单元测试的合理方法是什么?
答案
-
当涉及到手动测试时,你可能会忘记运行某个特定程序。运行测试标准化了我们的标准,并使我们能够将它们集成到持续集成工具中,以确保新代码不会破坏服务器,因为如果代码失败,持续集成可以阻止新代码的合并。
-
单元测试将单个组件(如函数和结构体)隔离开来。然后,通过一系列模拟输入对这些函数和结构体进行评估,以了解组件如何与不同的输入交互。功能测试评估系统,调用 API 端点,并检查响应。
-
单元测试轻量级,不需要整个系统运行。它们可以快速测试一系列边缘情况。单元测试还可以精确地隔离错误发生的位置。
-
单元测试基本上是使用虚构输入的隔离测试。如果系统中的输入类型更改但单元测试未更新,那么这个测试在应该失败时实际上会通过。单元测试也不评估系统是如何运行的。
-
功能测试确保整个基础设施按预期协同工作。例如,我们配置和连接数据库的方式可能存在问题。使用单元测试,这些问题可能会被忽略。此外,尽管模拟确保了隔离的测试,但单元测试的模拟可能过时。这意味着模拟的函数可能会返回更新版本中没有的数据。因此,单元测试会通过,但功能测试不会,因为它们测试一切。
-
功能测试需要具有像数据库这样的基础设施来运行。还必须有设置和拆卸函数。例如,功能测试将影响数据库中存储的数据。在测试结束时,需要在再次运行测试之前清除数据库。这可能会增加复杂性,并可能需要在不同操作之间使用“粘合”代码。
-
我们首先测试那些没有依赖的 struct 和函数。一旦这些测试完成,我们就知道我们对它们很熟悉。然后我们转向那些具有我们之前测试过的依赖的函数和 struct。采用这种方法,我们知道我们正在编写的当前测试不会因为依赖而失败。
进一步阅读
-
Mockall 文档:
docs.rs/mockall/0.9.0/mockall/
-
Github Actions 文档:
github.com/features/actions
-
Travis 文档:
docs.travis-ci.com/user/for-beginners/
)
-
Circle CI 文档:
circleci.com/docs/
-
Jenkins 文档:
www.jenkins.io/doc/
第十章:在 AWS 上部署我们的应用程序
在许多教程和教育材料中,部署很少被涉及。这是因为有很多可变的部分,过程可能相当脆弱。在提及部署时,参考其他资源可能更为方便。
在本章中,我们将介绍如何在 Amazon Web Services (AWS) 上的服务器上自动化部署,并从那里构建和连接数据库。必须强调的是,部署和云计算是很大的主题——关于它们有整本书的论述。
在本章中,我们将达到可以部署和运行我们的应用程序以供他人使用的地方。学习如何在服务器上部署应用程序是最后一步。这就是您将您一直在开发的应用程序转变为一个实用现实的过程,可以供全世界的人们使用。
在本章中,我们将涵盖以下主题:
-
设置我们的构建环境
-
使用 Docker 管理我们的软件
-
在 AWS 上部署我们的应用程序
到本章结束时,您将能够将您的代码打包成 Docker 镜像,并在 AWS 上的服务器实例上部署,以便其他用户可以访问。您还将能够使用 Terraform 配置基础设施,这是一个用于将服务器和数据库等云计算基础设施定义为代码的工具。完成本章后,您将拥有几个构建脚本,这些脚本使用 Terraform 创建构建和部署服务器,将 Terraform 构建中的数据传递到配置文件中,SSH 到这些服务器,并运行一系列命令,导致数据库迁移并在我们的服务器上启动 Docker 容器。
技术要求
在本章中,我们将基于第 第九章 中构建的代码,测试我们的应用程序端点和组件 进行扩展。您可以在以下网址找到它:github.com/PacktPublishing/Rust-Web-Programming-2nd-Edition/tree/main/chapter09/building_test_pipeline
。
本章的代码可以在以下网址找到:github.com/PacktPublishing/Rust-Web-Programming-2nd-Edition/tree/main/chapter10
。
本章还有以下要求:
-
我们将使用 Terraform 自动化服务器的构建。因此,我们需要使用以下网址安装 Terraform:
learn.hashicorp.com/tutorials/terraform/install-cli
。 -
当我们使用 Terraform 时,它将对 AWS 基础设施进行调用。我们需要 AWS 认证,这将通过 AWS 客户端来完成。在本章中,我们将使用 AWS 客户端,可以使用以下网址安装:
docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html
。 -
你还需要一个 Docker Hub 账户,这样我们就可以打包和部署我们的应用程序。这可以在以下网址找到:
hub.docker.com/
。 -
由于你将在服务器上部署应用程序,你需要注册一个 AWS 账户。这可以在以下网址完成:
aws.amazon.com/
。
设置我们的构建环境
到目前为止,我们一直使用 cargo run
命令运行我们的应用程序。这一直运行得很好,但你可能已经注意到我们的应用程序速度并不快。事实上,当我们尝试登录到应用程序时,它相对较慢。这似乎与我们学习 Rust 来开发更快应用程序的目标相悖。
到目前为止,它看起来并不快。这是因为我们没有运行应用程序的优化版本。我们可以通过添加 --release
标签来实现这一点。结果,我们使用以下命令运行我们的优化应用程序:
cargo run --release config.yml
在这里,我们注意到编译需要更长的时间。每次我们更改代码,以及在开发过程中运行这个命令,都不是理想的;因此,我们一直使用 cargo run
命令在调试模式下构建和运行。然而,现在我们的优化应用程序正在运行,我们可以看到登录过程要快得多。虽然我们可以在本地运行服务器,但我们的目标是部署我们的应用程序到服务器上。为了在我们的应用程序上运行,我们必须在 Docker 镜像中构建我们的应用程序。为了确保我们的 Docker 镜像构建过程顺利,我们将在 AWS 上使用在线计算单元。在我们运行构建过程之前,我们需要执行以下步骤:
-
为 AWS 安全外壳(SSH)设置 AWS 弹性计算 云(EC2)密钥
-
为我们的本地计算机设置 AWS 客户端
-
编写一个 Terraform 脚本来构建我们的构建服务器
-
编写一个 Python 脚本来管理构建
-
编写一个 Bash 脚本来在服务器上编排构建过程
一旦我们完成了上述步骤,我们就能运行一个自动化的流水线,该流水线会在 AWS 上创建一个构建 EC2 服务器,然后构建我们的 Rust 应用程序。首先,让我们开始创建服务器的 SSH 密钥。
为 AWS EC2 实例设置 AWS SSH 密钥
如果我们想在服务器上运行命令,我们将不得不通过超文本传输协议(HTTP)使用 SSH 协议连接到服务器。然而,我们不能让任何人访问我们的服务器,因为它将不安全。为了阻止任何人连接到我们的服务器并运行他们想要的任何命令,我们只允许拥有 SSH 密钥的用户连接到我们的服务器。为了创建我们的密钥,我们需要登录到 AWS 控制台并导航到我们的 EC2 仪表板,这可以通过搜索框访问,如下面的屏幕截图所示:
图 10.1 – 使用搜索框导航到 EC2 仪表板
注意
必须注意,AWS 可能看起来与本章中的截图不同,因为 AWS 不断更改 UI。然而,基本概念将是相同的。
一旦我们导航到 EC2 仪表板,我们可以在视图左侧面板的网络与安全部分导航到密钥对,如下面的屏幕截图所示:
图 10.2 – 导航到密钥对
一旦我们导航到密钥对部分,将有一个您已拥有的密钥对列表。如果您之前构建过 EC2 实例,您可能已经看到一些列出了。如果您之前从未构建过 EC2 实例,那么列表将为空。在屏幕右上角,您可以通过点击以下屏幕截图所示的创建密钥对按钮来创建一个密钥:
图 10.3 – 允许创建密钥对的按钮
一旦我们点击了这个按钮,我们将看到以下表单:
图 10.4 – 创建密钥对表单
在前面的屏幕截图中,我们可以看到我们已将我们的remotebuild
密钥命名;我们还声明我们的密钥具有.pem
格式,并且位于.ssh
目录下。在.ssh
目录内,我们可以使用以下命令创建一个keys
目录:
mkdir "$HOME"/.ssh/keys/
"$HOME"
环境变量始终在 Bash shell 中可用,它表示用户的家目录。这意味着如果我们将这些 SSH 密钥存储在我们刚刚创建的目录中,其他用户在计算机上以不同的用户名登录时无法访问这些密钥。我们现在可以导航到我们的密钥已下载的位置,并使用以下命令将其复制到keys
目录:
cp ./remotebuild.pem "$HOME"/.ssh/keys/
然后,我们必须更改密钥的权限,以便只有文件的所有者才能使用 600 代码读取文件,这样我们才能使用密钥进行 SSH 操作,以下命令即可实现:
chmod 600 "$HOME"/.ssh/keys/remotebuild.pem
现在我们有一个 SSH 密钥存储在我们的 .ssh/keys
目录中,我们可以使用正确的权限访问这个密钥来访问我们创建的服务器。现在我们有了这个,我们需要通过设置我们的 AWS 客户端来获取对 AWS 服务的程序访问。
设置我们的 AWS 客户端
我们将使用 Terraform 自动化我们的服务器构建。为了做到这一点,Terraform 将调用 AWS 基础设施。这意味着我们必须在我们的本地机器上安装 AWS 客户端。在我们配置客户端之前,我们需要获取一些程序用户密钥。如果我们没有获取程序访问权限,我们的代码将不会被授权在我们的 AWS 账户上构建基础设施。获取用户密钥的第一步是通过搜索框导航到IAM部分,如下面的截图所示:
图 10.5 – 导航到 IAM 部分
一旦我们导航到IAM部分,我们将看到以下布局:
图 10.6 – IAM 部分视图
在前面的截图中,我们可以看到我的用户启用了多因素认证(MFA),并且我定期更换访问密钥。如果你是 AWS 的新手,这个清单可能不会令人满意,建议你遵循 AWS 提供的安全建议。我们现在必须访问视图左侧的用户选项,并通过点击屏幕右上角的添加用户按钮来创建新用户,如下面的截图所示:
图 10.7 – 创建用户
然后,我们可以创建一个用户。用户的名字不重要,但你必须确保他们有程序访问权限,通过检查访问密钥 - 程序访问选项,如下面的截图所示:
图 10.8 – 创建用户的第一个阶段
一旦我们突出显示了程序访问并定义了用户名,我们就可以继续到权限部分,如下面的截图所示:
图 10.9 – 定义权限
我们可以看到,我们已经赋予了用户 AdministratorAccess
权限,这将使我们能够创建和销毁服务器和数据库。用户创建过程中的其余步骤都很简单,您只需点击 下一步 即可完成。一旦用户创建成功,您将获得一个访问密钥和一个秘密访问密钥。请注意,将这些密钥记录在安全的位置,例如密码管理器中,因为您将无法再次在 AWS 网站上看到您的秘密访问密钥,并且在我们配置本地计算机上的 AWS 客户端时需要它们。现在我们已经有了用户密钥,我们可以使用以下命令配置我们的 AWS 客户端:
aws configure
然后 AWS 客户端将提示您在需要时输入用户密钥。完成此操作后,您的 AWS 客户端配置完成,我们就可以在本地计算机上以编程方式使用 AWS 功能。现在,我们已经准备好在下一节开始使用 Terraform 创建服务器。
设置 Terraform 构建
当涉及到在 AWS 上构建基础设施时,我们可以在 EC2 控制台中简单地点击和选择。然而,这并不是我们所希望的。如果您像我一样,当我点击一系列配置设置时,除非我记录下来,否则我会忘记我做了什么,而且说实话,记录您点击的内容并不是您期待的事情。即使您比我更好,您会记录下来,但当您更改配置时,您可能会忘记返回更新该更改的文档。点击和选择也很耗时。如果我们想创建一些基础设施,然后在一周后销毁它,然后在一个月后再重新创建它,如果我们必须点击和选择,我们可能会不愿意去操作它,而且我们的服务器账单会更高。这就是 基础设施即代码(IaC)发挥作用的地方。我们仍然需要像前几节那样进行一些点击和选择。如果我们不点击和选择来设置程序访问权限,我们就无法进行任何程序访问。
现在我们有了程序访问权限,我们可以构建 build
目录,该目录应位于我们的 web_app
和 front_end
目录旁边。在 build
目录中,我们可以在 build/main.tf
文件中定义构建服务器的基础设施。必须注意的是,.tf
扩展名是 Terraform 文件的标准扩展名。首先,我们使用以下代码定义正在使用哪个版本的 Terraform:
terraform {
required_version = ">= 1.1.3"
}
现在我们已经定义了 Terraform 版本,我们可以声明正在使用 AWS 模块。Terraform 注册表中包含各种平台的模块,包括 Google Cloud 和 Microsoft Azure。任何人都可以构建模块并抽象基础设施,以便在 Terraform 注册表中下载。我们的 AWS 模块使用声明采用以下形式:
provider "aws" {
version = ">= 2.28.1"
region = "eu-west-2"
}
如果另一个区域更适合您,您可能想要选择不同的区域。您可以通过以下链接找到 AWS 上可用于 EC2 的所有区域:docs.aws.amazon.com/AWSEC2/latest/UserGuide/using-regions-availability-zones.html
。
我只是用 "eu-west-2"
来进行演示。现在我们可以用以下代码构建我们的 EC2 实例:
resource "aws_instance" "build_server" {
ami = "ami-0fdbd8587b1cf431e"
instance_type = "t2.medium"
key_name = "remotebuild"
user_data = file("server_build.sh")
tags = {
Name = "to-do build server"
}
}
resource
声明我们正在定义一个要构建的资源,而 aws_instance
表明我们正在使用 AWS 模块中的 EC2 实例模板。可以通过以下链接找到可用的 AWS Terraform 模块及其文档:registry.terraform.io/providers/hashicorp/aws/latest/docs
。
build_server
是我们给它起的名字。我们可以在 Terraform 脚本的任何地方引用 build_server
,Terraform 将确定资源构建的顺序,以确保所有引用都被考虑在内。我们可以看到我们引用了上一节中定义的 "remotebuild"
键。如果我们想的话,我们可以创建多个可以通过一个密钥访问的 EC2 实例。我们还声明了名称,这样当我们查看我们的 EC2 实例时,我们就知道服务器是用来做什么的。我们还必须注意,user_data
是在新的 EC2 服务器构建完成后将运行的 Bash 脚本。ami
部分是对正在使用的操作系统类型和版本的引用。除非您使用的是相同的区域,否则请不要直接复制示例中的我的 Amazon Machine Image (AMI) ID,因为 AMI ID 可能会根据区域而变化。如果您想找到 AMI ID,请转到您的 EC2 仪表板并点击 启动实例,这将导致以下窗口:
图 10.10 – 启动实例
在这里,我们可以看到我们选择了 Amazon Linux。您必须选择这个选项;否则,您的构建脚本将无法工作。如果我们放大查看,我们可以看到 AMI ID 是可见的,如下所示:
图 10.11 – 获取服务器的 AMI ID
这将是您想要启动的区域的 Amazon Linux 操作系统的 AMI ID。您也可以看到没有任何阻止您在其他 Terraform 项目中使用其他操作系统的。如果您以前构建过 EC2 实例,您将知道除非我们将弹性 IP 绑定到 EC2 实例,否则 IP 地址是随机的。我们将不得不生成我们 EC2 实例的 IP 地址输出,这样我们就可以连接到它。我们的输出是用以下代码定义的:
output "ec2_global_ips" {
value = ["${aws_instance.build_server.*.public_ip}"]
}
在这里,我们可以看到我们使用aws_instance.build_server
引用我们的构建服务器。关于 Terraform 输出的进一步阅读可以在进一步阅读部分找到。到目前为止,我们的 Terraform 构建几乎完成了。我们必须记住,我们需要构建一个server_build.sh
脚本,这个脚本将在 EC2 实例构建完成后在 EC2 实例上运行。在我们的/build/server_build.sh
文件中,我们可以使用以下代码安装服务器所需的基本要求:
#!/bin/bash
sudo yum update -y
sudo yum install git -y
sudo yum install cmake -y
sudo yum install tree -y
sudo yum install vim -y
sudo yum install tmux -y
sudo yum install make automake gcc gcc-c++ kernel-devel -y
使用前面的包,我们将能够使用tree
在服务器上导航并查看文件树;我们还将能够执行git
操作,打开文件并使用vim
编辑它们,如果需要的话,通过一个终端打开多个面板。其他包使我们能够编译我们的 Rust 代码。我们还必须注意,我们在每个安装中都附加了一个-y
标签。这是告诉计算机跳过输入提示并输入默认答案。这意味着我们可以没有问题地在后台运行此脚本。现在我们必须使用以下代码安装 PostgreSQL 驱动程序:
sudo amazon-linux-extras install postgresql10 vim epel -y
sudo yum install -y postgresql-server postgresql-devel -y
我们几乎拥有了我们所需要的一切。在下一节中,我们将使用 Docker 构建和打包我们的应用程序。这可以通过以下代码完成:
sudo amazon-linux-extras install docker
sudo service docker start
sudo usermod -a -G docker ec2-user
在这里,我们可以看到我们安装了 Docker,启动了 Docker 服务,然后使用 Docker 服务注册我们的用户,这样我们就不必在每一个 Docker 命令中使用sudo
了。既然我们已经安装了 Docker,我们不妨为了完整性安装docker-compose
。这可以在我们脚本的末尾通过以下代码完成:
sudo curl -L "https://github.com/docker/compose/releases
/download/1.29.2/docker-compose-$(uname -s)-$(uname -m)"
-o /usr/local/bin/docker-compose
sudo chmod +x /usr/local/bin/docker-compose
curl
命令下载docker-compose
。然后我们使用chmod
命令使docker-compose
可执行。构建脚本几乎完成了。然而,运行构建脚本中定义的所有命令将需要一段时间。有可能在构建脚本完成之前我们能够通过 SSH 连接到我们的服务器。因此,我们应该将一个FINISHED
字符串写入一个文件,以通知其他进程服务器已经安装了所有已安装的包。我们可以用以下代码写入我们的标志:
echo "FINISHED" > home/ec2-user/output.txt
我们现在已经构建了将要为我们构建应用程序以进行部署的基础设施。在下一节中,我们将构建脚本,这些脚本将使用我们配置的构建服务器来编排我们的应用程序构建。
编写我们的 Python 应用程序构建脚本
从技术上讲,我们可以尝试将我们的应用程序构建脚本放入我们在上一节中编写的相同脚本中。然而,我们希望保持我们的脚本独立。例如,如果我们打算使用一个build/run_build.py
脚本,我们首先通过以下代码导入我们需要的所有内容:
from subprocess import Popen
from pathlib import Path
import json
import time
DIRECTORY_PATH = Path(__file__).resolve().parent
现在我们有了build
目录的绝对路径,我们可以通过Popen
类运行 Bash 命令,并且我们可以加载 JSON。现在我们已经导入了所有内容,我们可以使用以下代码运行我们的 Terraform 命令:
init_process = Popen(f"cd {DIRECTORY_PATH} && terraform init",
shell=True)
init_process.wait()
apply_process = Popen(f"cd {DIRECTORY_PATH} && terraform apply",
shell=True)
apply_process.wait()
在每个命令中,我们进入目录路径然后执行 Terraform 命令。然后我们等待命令完成再继续执行下一个命令。虽然 Python 是一个慢速、简单且非强类型的语言,但在这里它增加了很多功能。我们可以同时运行多个 Bash 命令,并在需要时稍后等待它们。我们还可以轻松地从进程提取数据,操作这些数据并将其输入到另一个命令中。前一个片段中执行的两个 Terraform 命令是 init
和 apply
。init
命令设置 Terraform 状态以记录正在发生的事情并下载我们需要的模块,在这个例子中是 AWS。apply
命令运行 Terraform 构建,这将构建我们的 EC2 服务器。
一旦我们的 EC2 实例构建完成,我们可以获取 Terraform 的输出并将其写入 JSON 文件,然后使用以下代码从该 JSON 文件加载数据:
produce_output = Popen(f"cd {DIRECTORY_PATH} && terraform
output -json > {DIRECTORY_PATH}/
output.json", shell=True)
produce_output.wait()
with open(f"{DIRECTORY_PATH}/output.json", "r") as file:
data = json.loads(file.read())
server_ip = data["ec2_global_ips"]["value"][0][0]
现在我们已经获得了服务器 IP,我们可以 SSH 连接到这台服务器并让它执行我们的构建。然而,可能会存在一些并发问题。在 Terraform 构建完成但服务器尚未准备好接受连接的短暂时间内,因此,我们只需要脚本等待一小段时间后再继续执行以下代码:
print("waiting for server to be built")
time.sleep(5)
print("attempting to enter server")
完成这些后,我们需要将我们的服务器 IP 传递给另一个管理构建的 Bash 脚本,然后在之后使用以下代码销毁服务器:
build_process = Popen(f"cd {DIRECTORY_PATH} &&
sh ./run_build.sh {server_ip}",
shell=True)
build_process.wait()
destroy_process = Popen(f"cd {DIRECTORY_PATH} &&
terraform destroy", shell=True)
destroy_process.wait()
我们现在已经构建了构建的编排和定义构建基础设施的 Terraform 脚本。现在我们必须构建最终的构建脚本,该脚本将在服务器上运行构建命令。
编写我们的 Bash 部署脚本
我们的 Bash 部署脚本必须接受构建服务器的 IP 地址,SSH 连接到服务器,并在服务器上运行一系列命令。它还必须将我们的代码复制到服务器上进行构建。我们可以从前面的代码中看到,我们可以在 /build/run_build.sh
文件中构建我们的构建 Bash 脚本。首先,我们从标准的样板代码开始:
#!/usr/bin/env bash
SCRIPTPATH="$( cd "$(dirname "$0")" ; pwd -P )"
cd $SCRIPTPATH
使用这个样板代码,我们声明这是一个 Bash 脚本,并且其余的 Bash 代码将在 build
目录中运行。然后我们使用以下代码上传 Rust 应用程序的代码:
rm -rf ../web_app/target/
scp -i "~/.ssh/keys/remotebuild.pem" -r
../web_app ec2-user@$1:/home/ec2-user/web_app
在这里,我们可以看到我们移除了 target
目录。正如我们所记得的,target
目录是在我们构建 Rust 应用程序时创建的;我们不需要从本地构建上传构建文件。然后我们使用 scp
命令复制我们的 Rust 代码。我们访问脚本传入的第一个参数,即 $1
。记住,我们传入的是 IP 地址,所以 $1
是 IP 地址。然后我们通过以下代码 SSH 连接到我们的服务器并在该服务器上运行命令:
ssh -i "~/.ssh/keys/remotebuild.pem" -t ec2-user@$1 << EOF
until [ -f ./output.txt ]
do
sleep 2
done
echo "File found"
curl https://sh.rustup.rs -sSf | bash -s -- -y
source ~/.cargo/env
cd web_app
cargo build --release
EOF
在这里,我们可以看到我们在每次迭代中循环休眠 2 秒钟,直到output.txt
文件出现。一旦output.txt
文件出现,我们就知道 Terraform 的构建脚本已经完成,我们可以开始我们的构建。我们通过在控制台回显"File found"
来发出这个信号。然后我们安装 Rust,使用source
命令将我们的cargo
命令加载到我们的 shell 中,移动到web_app
目录,并构建我们的 Rust 应用程序。
注意
如果需要,我们可以通过在run_build.sh
脚本中使用jq
命令并插入以下代码来去除 Python 依赖:
. . .
SCRIPTPATH="$( cd "$(dirname "$0")" ; pwd -P )"
cd $SCRIPTPATH
terraform init
terraform apply
terraform output -json > ./output.json
IP_ADDRESS=$(jq --raw-output '.ec2_global_ips.value[0][0]' output.json)
echo $IP_ADDRESS
echo "waiting for server to be built"
sleep 5
echo "attempting to enter server"
rm -rf ../web_app/target/
scp -i "~/.ssh/keys/remotebuild.pem" -r
../web_app ec2-user@$IP_ADDRESS:/home/ec2-user/web_app
. . .
需要注意的是,对$1
变量的引用会被替换为$IP_ADDRESS
。
要在没有 Python 依赖的情况下运行我们的管道,我们只需要运行以下命令:
Sh run_build.sh
然而,在接下来的章节中,我们将依赖于我们的 Python 脚本。
现在我们已经构建了构建管道,我们可以使用以下命令来运行它:
python3 run_build.py
警告
Terraform 有时可能会表现得有些情绪化;如果它失败了,你可能需要再次运行它。有时,我不得不运行 Terraform 三次以上才能完全正常工作。Terraform 会存储每一个动作,所以请不要担心——再次运行 Python 脚本不会导致服务器重复创建。
当运行此命令时,你将在过程中的三个不同点被提示输入yes
。第一次是为了批准使用 Terraform 构建服务器,第二次是为了将服务器的 IP 地址添加到已知主机列表以批准 SSH 连接,最后一次是为了批准服务器的销毁。通常来说,在这个书中展示打印输出是有意义的;然而,这里的打印输出很长,可能需要占用多页。此外,打印输出是显而易见的。Terraform 明确说明了正在构建的内容,复制和构建过程也是详尽的,使用 Terraform 销毁服务器的过程也会打印出来。当你运行这个构建管道时,会发生什么将非常清楚。你也可能注意到创建了一系列 Terraform 文件。这些文件跟踪我们在 AWS 平台上构建的资源的状态。如果你删除这些状态文件,你就无法知道构建了什么,并且会创建重复的服务器。它还会阻止 Terraform 进行清理。在我工作的地方,在撰写本文时,我们使用 Terraform 构建用于计算地理区域财务损失风险的大规模数据模型。正在处理的数据可以超过每块数太字节。我们使用 Terraform 启动一系列强大的计算机,将数据通过它们运行(这可能需要几天时间),然后在完成时关闭它们。需要多个人监控这个过程,因此我们的 Terraform 状态存储在一个state.tf
文件中:
terraform {
backend "s3" {
bucket = "some_bucket"
key = "some/ptaht/terraform.tfstate"
region = "eu-west-1"
}
}
需要注意的是,你的账户需要有权访问定义的存储桶。
现在,是时候构建我们的前端应用程序了。看看我们刚刚所做的工作,我们所需做的只是将上传我们的前端代码到构建服务器并构建前端应用程序的步骤添加到 /build/run_build.sh
文件中。在这个阶段,你应该能够自己编写这段代码。现在,停止阅读并尝试构建它将是一个很好的时间利用方式。如果你已经尝试过,它应该看起来像这里显示的代码:
rm -rf ../front_end/node_modules/
scp -i "~/.ssh/keys/remotebuild.pem" -r
../front_end ec2-user@$1:/home/ec2-user/front_end
ssh -i "~/.ssh/keys/remotebuild.pem" -t ec2-user@$1 << EOF
curl -o- https://raw.githubusercontent.com/nvm-sh
/nvm/v0.37.2/install.sh | bash
. ~/.nvm/nvm.sh
nvm install --lts
cd front_end
npm install
EOF
在这里,我们移除了 node 模块,将代码复制到服务器上,安装 node,然后运行我们应用程序的 install
命令。现在,我们的构建管道完全工作后,我们可以继续将我们的软件包装在 Docker 中,以便我们可以将软件打包并部署。
使用 Docker 管理我们的软件
到目前为止,我们一直在使用 Docker 来管理我们的 PostgreSQL 和 Redis 数据库。当涉及到运行我们的前端和 Rust 服务器时,我们只是在我们的本地计算机上直接运行它们。然而,当涉及到在远程服务器上运行我们的应用程序时,它更简单、更容易分发。在我们开始在服务器上部署我们的 Docker 镜像之前,我们需要在本地构建和运行它们,这从编写我们的 Docker 镜像文件开始。
编写 Docker 镜像文件
在我们继续之前,必须注意的是,这里执行的方法是构建 Rust 服务器 Docker 镜像的最简单、最不优化的方式,因为我们正在处理许多新概念。我们在 第十三章 中介绍了构建 Rust 服务器 Docker 镜像的优化方法,最佳实践:干净的 Web 应用程序仓库。当涉及到构建 Docker 镜像时,我们需要一个 Dockerfile。这是我们定义构建我们的镜像所需的步骤的地方。在我们的 web_app/Dockerfile
文件中,我们基本上借用了一个基础镜像,然后在上面运行我们的命令,以便我们的应用程序能够工作。我们可以使用以下代码定义基础镜像和运行我们的数据库交互的要求:
FROM rust:1.61
RUN apt-get update -yqq && apt-get install -yqq cmake g++
RUN cargo install diesel_cli --no-default-features --features postgres
在我们的 Docker 构建中,我们以官方的 rust
镜像开始。然后我们更新 apt
以便我们可以下载所有可用的包。然后我们安装 g++
和 diesel
客户端,以便我们的数据库操作能够工作。然后我们将 Rust 应用程序的代码和配置文件复制过来,并使用以下代码定义我们的工作目录:
COPY . .
WORKDIR .
现在,我们有以下代码来构建我们的 Rust 应用程序:
RUN cargo clean
RUN cargo build --release
现在我们已经完成了构建,我们将 target
目录中的静态二进制文件移动到我们的主目录,删除过量的代码,如 target
和 src
目录,并使用以下代码允许静态二进制文件可执行:
RUN cp ./target/release/web_app ./web_app
RUN rm -rf ./target
RUN rm -rf ./src
RUN chmod +x ./web_app
现在一切都已经完成,我们可以暴露运行 Web 服务器的端口,以便通过容器暴露服务器并执行以下代码启动 Docker 镜像时运行的命令:
EXPOSE 8000
CMD ["./web_app", "config.yml"]
现在我们已经编写了 Docker 镜像文件,我们可以继续构建 Docker 镜像。
构建 Docker 镜像
在我们可以运行这个之前,我们需要删除我们的构建脚本。我们的 Docker 镜像构建只剩下一件事。当我们把代码复制到 Docker 镜像中时,我们知道 target
目录中有许多我们不需要在镜像中的代码和文件。我们可以通过在 .dockerignore
文件中添加以下代码来避免复制 target
目录:
target
如果我们尝试使用构建脚本编译我们的应用程序,Docker 将将自己陷入无限文件循环,然后超时。这意味着我们的 main.rs
文件中的 ALLOWED_VERSION
变量采用以下形式:
#[actix_web::main]
async fn main() -> std::io::Result<()> {
const ALLOWED_VERSION: &'static str = "v1";
. . .
然后,我们必须在 Cargo.toml
文件中取消注释我们的 build
依赖项,并使用以下代码,并完全删除 build.rs
文件:
[package]
name = "web_app"
version = "0.1.0"
edition = "2021"
# build = "build.rs"
现在,我们已经准备好构建我们的镜像;我们导航到 Dockerfile 所在的位置,并运行以下命令:
docker build . -t rust_app
此命令执行当前目录中 Dockerfile 定义的构建。镜像被标记为 rust_app
。我们可以使用以下命令列出我们的镜像:
docker image ls
这将给出以下输出:
REPOSITORY TAG IMAGE ID CREATED SIZE
rust_app latest c2e465545e30 2 hours ago 2.69GB
. . .
然后,我们可以测试我们的应用程序是否正确构建;我们只需运行以下命令:
docker run rust_app
这直接运行我们的 Rust 应用程序。我们的应用程序应该立即崩溃,并显示以下错误:
thread 'main' panicked at 'called `Result::unwrap()`
on an `Err` value: Connection refused (os error 111)',
src/counter.rs:24:47 note: run with `RUST_BACKTRACE=1`
environment variable to display a backtrace
我们可以看到,错误不是来自我们的构建,而是来自计数器文件与 Redis 的连接问题。这令人放心,并且当我们在两个数据库上运行我们的 Rust 应用程序时,它将工作。
在 Docker 中有一个多层级构建的方法。这就是我们从 rust
基础镜像开始,构建我们的应用程序,然后将构建移动到另一个没有依赖项的 Docker 镜像中。结果是我们的服务器镜像通常只有 100 MB,而不是多个 GB。然而,我们的应用程序有很多依赖项,这种多层级构建方法会导致多个驱动错误。我们在 第十三章 中探讨了构建小型镜像的最佳实践,清洁 Web 应用程序仓库。
使用 Terraform 构建 EC2 构建服务器
我们现在已经在本地构建了我们的 Rust Docker 镜像。我们可以在构建服务器上构建它。在我们这样做之前,我们必须增加构建服务器硬盘的大小;否则,镜像将因为空间不足而拒绝构建。这可以通过在我们的 /build/main.tf
文件中添加一个根块设备来完成,如下面的代码片段所示:
resource "aws_instance" "build_server" {
ami = "ami-0fdbd8587b1cf431e"
instance_type = "t2.medium"
key_name = "remotebuild"
user_data = file("server_build.sh")
tags = {
Name = "to-do build server"
}
# root disk
root_block_device {
volume_size = "150"
volume_type = "gp2"
delete_on_termination = true
}
}
gp2
是 AWS 支持的 SSD 版本,我们正在使用。150
是我们连接到服务器的 GB 数。这将足够构建我们的 Docker 镜像,剩下的只是构建构建我们的 Docker 镜像的管道。
使用 Bash 进行构建编排
在这一点上,我们还将优化我们的 /build/run_build.sh
文件中的构建 Bash 脚本。首先,我们不删除 target
目录;相反,我们使用以下代码有选择性地上传到我们的服务器:
#!/usr/bin/env bash
SCRIPTPATH="$( cd "$(dirname "$0")" ; pwd -P )"
cd $SCRIPTPATH
ssh -i "~/.ssh/keys/remotebuild.pem" -t ec2-user@$1 "mkdir web_app"
scp -i "~/.ssh/keys/remotebuild.pem" -r
../web_app/src ec2-user@$1:/home/ec2-user/web_app/src
scp -i "~/.ssh/keys/remotebuild.pem"
../web_app/Cargo.toml ec2-user@$1:/home/ec2-user/web_app/Cargo.toml
scp -i "~/.ssh/keys/remotebuild.pem"
../web_app/config.yml ec2-user@$1:/home/ec2-user/web_app/config.yml
scp -i "~/.ssh/keys/remotebuild.pem"
../web_app/Dockerfile ec2-user@$1:/home/ec2-user/web_app/Dockerfile
在这里,我们可以看到我们创建了 web_app
目录,然后上传了构建 Rust Docker 镜像所需的文件和目录。然后我们需要使用以下代码连接到服务器来安装 Rust:
echo "installing Rust"
ssh -i "~/.ssh/keys/remotebuild.pem" -t ec2-user@$1 << EOF
curl https://sh.rustup.rs -sSf | bash -s -- -y
until [ -f ./output.txt ]
do
sleep 2
done
echo "File found"
EOF
echo "Rust has been installed"
在这里,我们可以看到我们在安装 Rust 之前阻止脚本直到服务器准备好所有安装。这意味着我们在构建服务器其余部分的同时运行 Rust 的安装,从而节省时间。然后我们退出与构建服务器的连接。最后,我们再次连接到服务器,并使用以下代码构建 Docker 镜像:
echo "building Rust Docker image"
ssh -i "~/.ssh/keys/remotebuild.pem" -t ec2-user@$1 << EOF
cd web_app
docker build . -t rust_app
EOF
echo "Docker image built"
我们已经在我们的构建服务器上构建了构建 Rust Docker 镜像的管道。这看起来像有很多步骤,你是对的。我们可以使用不同的目标操作系统和芯片架构在本地构建我们的镜像。在这里探索它将打断我们试图实现的目标的流程,但有关使用不同目标编译的更多信息将在 进一步阅读 部分提供。
为 React 前端编写 Docker 镜像文件
目前,我们将使用 Docker 打包我们的前端应用程序。在我们的 front_end/Dockerfile
文件中,我们继承 node 基础镜像,复制代码,并使用以下代码定义工作目录:
FROM node:17.0.0
WORKDIR .
COPY . ./
然后,我们安装一个 serve
包来提供 web 应用构建文件、构建应用程序所需的模块,并使用以下代码构建 React 应用程序:
RUN npm install -g serve
RUN npm install
RUN npm run react-build
然后,我们使用以下代码公开端口和服务器到我们的应用程序:
EXPOSE 4000
CMD ["serve", "-s", "build", "-l", "4000"]
然后,我们可以使用以下命令构建我们的 Docker 镜像:
docker build . -t front_end
然后,我们可以使用以下命令运行最近构建的镜像:
docker run -p 80:4000 front_end
这将容器的外部端口 (80
) 路由到本地暴露的端口 4000
。当我们的镜像运行时,我们得到以下输出:
INFO: Accepting connections at http://localhost:4000
这表明我们的镜像正在容器中运行。我们可以通过访问本地主机来访问我们的前端容器,本地主机端口为 80
,如下截图所示:
图 10.12 – 使用本地主机访问我们的前端容器
然而,我们无法对其进行任何操作,因为我们的 Rust 服务器尚未运行。现在,我们可以将构建前端所执行的步骤提升到我们的 /build/run_build.sh
脚本中,以便我们的构建管道构建前端镜像。这是一个尝试自己添加步骤的好机会。我们将在构建服务器上安装 node,然后执行前端构建步骤。
如果你尝试在我们的管道中集成我们的 React 构建,它应该看起来像以下代码:
echo "copying React app"
rm -rf ../front_end/node_modules/
scp -i "~/.ssh/keys/remotebuild.pem" -r
../front_end ec2-user@$1:/home/ec2-user/front_end
echo "React app copied"
echo "installing node on build server"
ssh -i "~/.ssh/keys/remotebuild.pem" -t ec2-user@$1 << EOF
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm
/v0.37.2/install.sh | bash
. ~/.nvm/nvm.sh
nvm install --lts
EOF
echo "node installed"
echo "building front-end on server"
ssh -i "~/.ssh/keys/remotebuild.pem" -t ec2-user@$1 << EOF
cd front_end
docker build . -t front_end
EOF
echo "front-end Docker image has been built"
我们可以在我们的实现中更加优化;然而,前面的代码是最简单的应用。首先,我们将需要的代码复制到构建服务器上。然后我们连接到构建服务器来安装 node。安装 node 后,我们再次连接到服务器,进入 React 应用程序目录并构建我们的 Docker 镜像。
我们的建设管道现在正在工作。想想我们在这里取得的成就——我们构建了一个管道,它构建了一个构建服务器;然后我们将我们的代码复制到构建服务器上,构建了 Docker 镜像,然后在构建完成后销毁了服务器。尽管这个流水线并不完美,但我们已经探索了一些强大的工具,这些工具将使您能够自动化任务,并减轻我们在本节中涵盖的其他 CI 流水线工具中的许多代码。然而,现在我们只是在构建 Docker 镜像,然后与服务器一起销毁它们。在下一节中,我们将部署我们的镜像到Docker Hub。
将镜像部署到 Docker Hub
在我们能够从 Docker Hub 拉取我们的镜像之前,我们必须先将我们的镜像推送到 Docker Hub。在我们能够将我们的镜像推送到 Docker Hub 之前,我们必须创建一个 Docker Hub 仓库。在 Docker Hub 上注册我们的镜像非常简单。登录后,我们点击右上角的创建仓库按钮,如图所示:
图 10.13 – 在 Docker Hub 上创建新的仓库
一旦我们点击了它,我们使用以下配置定义仓库:
图 10.14 – 定义新的 Docker 仓库
我们可以看到,有一个选项可以通过点击前面截图中的GitHub按钮将我们的仓库与 GitHub 连接起来。前面截图中的已连接GitHub 状态简单意味着我的 GitHub 已经连接到我的 Docker Hub 账户。这意味着每次成功的拉取请求完成后,镜像都会用代码重新构建,然后发送到仓库。如果你正在构建一个完全自动化的流水线,这可能会很有帮助。然而,对于这本书,我们不会连接我们的 GitHub 仓库。我们将将其推送到我们的构建服务器。如果你正在部署 React 应用程序,你还需要为我们的前端创建一个 Docker Hub 仓库。
现在我们已经定义了我们的 Docker Hub 仓库,我们需要在我们的构建管道中添加一个 Docker 登录。这意味着我们必须将我们的 Docker Hub 密码和用户名传递到我们的 Python 构建脚本中。然后我们的 Python 脚本可以将 Docker Hub 凭证传递到构建 Bash 脚本中。这个构建 Bash 脚本将登录到构建服务器上的 Docker,以便我们可以将我们的镜像推送到我们的 Docker Hub。在我们的/build/run_build.py
文件中,我们使用以下代码定义传递给 Python 脚本的参数:
. . .
import argparse
. . .
parser = argparse.ArgumentParser(
description='Run the build'
)
parser.add_argument('--u', action='store',
help='docker username',
type=str, required=True)
parser.add_argument('--p', action='store',
help='docker password',
type=str, required=True)
args = parser.parse_args()
我们可以看到,我们已经将required
设置为True
,这意味着除非提供这两个参数,否则 Python 脚本不会运行。如果我们向 Python 脚本提供一个-h
参数,那么在前面代码中定义的参数将带有帮助信息打印出来。现在我们已经摄入了 Docker 凭证,我们可以在/build/run_build.py
文件中将它们传递到我们的构建 Bash 脚本中,以下是对我们代码的以下修改:
. . .
print("attempting to enter server")
build_process = Popen(f"cd {DIRECTORY_PATH} && sh ./run_build.sh
{server_ip} {args.u} {args.p}", shell=True)
build_process.wait()
destroy_process = Popen(f"cd {DIRECTORY_PATH} && terraform destroy",
shell=True)
. . .
在这里,我们可以看到 Docker 用户名是通过args.u
属性访问的,而 Docker 密码是通过args.p
属性访问的。既然我们已经将 Docker 凭证传递到我们的构建 Bash 脚本中,我们需要使用这些凭证来推送我们的镜像。在我们的/build/run_build.sh
文件中,我们应该在 Docker 安装在我们的构建服务器上后,使用以下代码进行登录:
. . .
echo "Rust has been installed"
echo "logging in to Docker"
ssh -i "~/.ssh/keys/remotebuild.pem" -t ec2-user@$1 << EOF
echo $3 | docker login --username $2 --password-stdin
EOF
echo "logged in to Docker"
echo "building Rust Docker image"
. . .
在前面的代码中,我们使用–password-stdin
将密码管道输入到 Docker 登录中。stdin
确保密码不会存储在日志中,这使得它稍微安全一些。然后我们可以使用以下代码构建、标记并推送我们的 Rust 应用程序的更新:
echo "building Rust Docker image"
ssh -i "~/.ssh/keys/remotebuild.pem" -t ec2-user@$1 << EOF
cd web_app
docker build . -t rust_app
docker tag rust_app:latest maxwellflitton/to_do_actix:latest
docker push maxwellflitton/to_do_actix:latest
EOF
echo "Docker image built"
在这里,我们像往常一样构建 Rust 镜像。然后我们将 Rust 应用程序镜像标记为最新发布版本,并将其推送到 Docker 仓库。我们还必须将我们的前端应用程序推送到 Docker Hub。在这个时候,这是一个写代码推送前端镜像的好机会。如果你尝试推送前端镜像,它应该看起来像以下代码片段:
echo "building front-end on server"
ssh -i "~/.ssh/keys/remotebuild.pem" -t ec2-user@$1 << EOF
cd front_end
docker build . -t front_end
docker tag front_end:latest maxwellflitton/to_do_react:latest
docker push maxwellflitton/to_do_react:latest
EOF
echo "front-end Docker image has been built"
我们现在已经编写了所有必要的代码来构建两个镜像并将它们推送到我们的 Docker 仓库。我们可以使用以下命令运行我们的 Python 构建脚本:
python3 run_build.py --u some_username --p some_password
再次强调,打印输出很长,但我们可以检查我们的 Docker Hub 仓库以查看我们的镜像是否已推送。正如以下屏幕截图所示,Docker Hub 仓库会显示镜像何时被推送:
图 10.15 – 推送 Docker 仓库的视图
现在我们已经将镜像推送到我们的 Docker Hub 仓库!这意味着我们可以将它们拉到任何我们需要使用的计算机上,就像我们在docker-compose
文件中拉取 PostgreSQL 镜像时做的那样。我们现在应该将我们的镜像拉到服务器上,以便其他人可以访问和使用我们的应用程序。在下一节中,我们将部署我们的应用程序以供外部使用。
在 AWS 上部署我们的应用程序
尽管我们已经将 Rust 应用程序打包到 Docker 中,但我们还没有在 Docker 容器中运行我们的 Rust 应用程序。在我们将 Rust 应用程序运行在 AWS 服务器上之前,我们应该在本地运行我们的 Rust 应用程序。这将帮助我们了解简单的部署是如何工作的,而无需构建服务器。
在本地运行我们的应用程序
当涉及到在本地运行我们的应用程序时,我们将使用以下布局的docker-compose
:
图 10.16 – 本地部署结构
在这里,我们可以看到 NGINX 容器从docker-compose
网络外部接收流量,并将流量导向适当的容器。现在我们理解了我们的结构,我们可以定义我们的docker-compose
文件。首先,我们需要在build
、front_end
和web_app
目录旁边创建一个名为deployment
的目录。在我们的deployment
目录中,我们的docker-compose.yml
文件的一般布局如下所示:
services:
nginx:
. . .
postgres_production:
. . .
redis_production:
. . .
rust_app:
. . .
front_end:
. . .
我们可以从 NGINX 服务开始。既然我们知道外部端口是80
,那么我们的 NGINX 容器监听外部端口80
是有意义的,以下代码实现了这一点:
nginx:
container_name: 'nginx-rust'
image: "nginx:latest"
ports:
- "80:80"
links:
- rust_app
- front_end
volumes:
- ./nginx_config.conf:/etc/nginx/nginx.conf
我们可以看到我们获取了最新的 NGINX 镜像。我们还链接到front_end
和rust_app
容器,因为我们将会将这些 HTTP 请求转发到这些容器。还必须注意的是,我们有一个卷。这是在容器外部与容器内部的目录共享卷的地方。所以,这个卷定义意味着我们的deploy/nginx_config.conf
文件可以在 NGINX 容器的etc/nginx/nginx.conf
目录中访问。有了这个卷,我们可以在deploy/nginx_config.conf
文件中配置 NGINX 路由规则。
首先,我们将工作进程的数量设置为auto
。如果需要,我们可以手动定义工作进程的数量。auto
会检测可用的 CPU 核心数量,并将数量设置为该值,以下代码实现了这一点:
worker_processes auto;
error_log /var/log/nginx/error.log warn;
error_log
指令定义了日志记录到特定的文件。我们不仅定义了文件,还声明了写入日志文件所需的最小级别,即warning
。默认情况下,写入文件所需的日志级别是error
。现在我们可以继续在deploy/nginx_config.conf
文件中定义上下文。在events
上下文中,我们定义了工作进程一次可以处理的连接的最大数量。这是通过以下代码实现的:
events {
worker_connections 512;
}
我们定义的工作进程数量是 NGINX 设置的默认数量。现在这已经完成,我们可以继续到我们的http
上下文。在这里,我们定义了server
上下文。在这个上下文中,我们指示服务器监听端口80
,这是监听外部流量的端口,以下代码实现了这一点:
http {
server {
listen 80;
location /v1 {
proxy_pass http://rust_app:8000/v1;
}
location / {
proxy_pass http://front_end:4000/;
}
}
}
在这里,我们可以看到,如果端点的 URL 以/v1/
开头,我们就将其通过 Rust 服务器转发。必须注意的是,我们将v1
传递给 Rust 服务器。如果我们没有将v1
传递给 Rust 服务器,当它到达 Rust 服务器时,URL 中会缺少v1
。如果 URL 中不包含v1
,那么我们就将其转发到我们的front_end
容器。我们的 NGINX 容器现在已准备好管理我们的docker-compose
系统中的流量。在我们继续到前端和后端服务之前,我们需要定义 Redis 和 PostgreSQL 数据库。这里没有什么新的,所以在这个时候,你可以尝试自己定义它们。如果你已经定义了,那么你的代码应该看起来像这样:
postgres_production:
container_name: 'to-do-postgres-production'
image: 'postgres:latest'
restart: always
ports:
- '5433:5432'
environment:
- 'POSTGRES_USER=username'
- 'POSTGRES_DB=to_do'
- 'POSTGRES_PASSWORD=password'
expose:
- 5433
redis_production:
container_name: 'to-do-redis'
image: 'redis:5.0.5'
ports:
- '6379:6379'
volumes:
- ./data/redis:/tmp
之前在本地开发中使用的数据库定义与此相同。有了这些数据库,我们可以使用以下代码定义我们的 Rust 应用程序:
rust_app:
container_name: rust_app
build: "../web_app"
restart: always
ports:
- "8000:8000"
links:
- postgres_production
- redis_production
expose:
- 8000
volumes:
- ./rust_config.yml:/config.yml
在这里,我们可以看到我们没有定义一个镜像。而不是声明镜像,我们指向一个构建。build
标签是我们指向 deploy/rust_config.yml
文件的地方,其形式如下:
DB_URL: postgres://username:password@postgres_production/to_do
SECRET_KEY: secret
EXPIRE_MINUTES: 120
REDIS_URL: redis://redis_production/
在这里,我们可以看到我们引用的是 docker-compose
系统中定义的服务名称,而不是 URL。我们还必须在 web_app/src/main.rs
文件中将我们的 Rust 应用程序的地址更改为零,如下所示:
})
.bind("0.0.0.0:8000")?
.run()
然后,我们必须在我们的 web_app/Dockerfile
文件中删除 Docker 构建中的配置文件,如下所示:
RUN rm config.yml
如果我们不这样做,那么我们的 Rust 应用程序将无法与 NGINX 容器连接。现在,我们的 Rust 服务器已经定义完毕,我们可以继续在 docker-compose.yml
文件中定义前端应用程序,如下所示:
front_end:
container_name: front_end
image: "maxwellflitton/to_do_react:latest"
restart: always
ports:
- "4000:4000"
expose:
- 4000
在这里,我们看到我们引用了 Docker Hub 中的镜像并公开了端口。现在,我们的本地系统已经定义,我们可以通过运行以下命令来运行我们的系统并与之交互:
docker-compose up
所有的构建和运行都将自动完成。在我们能够与我们的系统交互之前,我们需要在我们的 Rust 应用程序构建中运行以下命令的 diesel
迁移:
diesel migration run
然后,我们需要使用以下 curl
命令创建一个用户:
curl --location --request POST 'http://localhost/v1/user/create' \
--header 'Content-Type: application/json' \
--data-raw '{
"name": "maxwell",
"email": "maxwellflitton@gmail.com",
"password": "test"
}'
我们现在已经准备好与我们的应用程序进行交互。我们可以看到,没有端口引用的 localhost 在以下屏幕截图中的工作情况:
图 10.17 – 通过浏览器访问我们的 docker-compose 系统
如果你的 NGINX 正在运行,你应该能够登录并像以前一样与待办事项应用程序进行交互。我们现在能够将我们的系统部署到 AWS,以便在下一节中其他人可以访问和使用我们的待办事项应用程序。
在 AWS 上运行我们的应用程序
我们可以通过执行以下步骤在 AWS 上部署我们的应用程序:
-
构建服务器
-
在那个服务器上运行我们的
docker-compose
系统 -
在那个服务器上运行数据库迁移
-
创建用户
一旦我们完成了这些步骤,我们就能访问远程服务器上的应用程序。然而,在我们这样做之前,我们必须修改 React 应用程序。目前,我们的 React 应用程序通过 127.0.0.1
向 localhost 发起 API 调用。当我们使用远程服务器时,这将不起作用,因为我们必须调用我们部署应用程序的服务器。为此,我们可以在 React 应用程序中提取 API 调用的位置,并使用以下代码更新 API 调用的 URL 根:
axios.get(window.location.href + "/v1/item/get",
这里发生的事情是 window.location.href
返回当前位置,这将是我们应用程序部署在的服务器 IP 地址,或者如果我们是在本地计算机上开发,则是 localhost。以下文件有需要更新的 API 调用:
-
src/components/LoginForm.js
-
src/components/CreateToDoitem.js
-
src/components/ToDoitem.js
-
src/App.js
更新这些文件后,我们可以在 build
目录下通过运行以下命令来运行另一个构建:
python3 run_build.py --u some_username --p some_password
一旦我们的构建完成,我们的两个镜像都将更新。现在我们可以移动到我们的 deployment
目录,并使用以下文件来完善它:
-
main.tf
:这应该与build
目录中的main.tf
文件相同,除了服务器有一个不同的标签 -
run_build.py
:这应该与build
目录中的run_build.py
文件相同,除了run_build.py
脚本末尾的 销毁服务器 过程 -
server_build.sh
:这应该与build
目录中的server_build.sh
脚本相同,因为我们希望我们的服务器拥有与构建镜像时相同的环境 -
deployment-compose.yml
:这应该与deployment
目录中的docker-compose.yml
文件相同,除了rust_app
服务有一个镜像标签而不是构建标签,并且镜像标签应该是maxwellflitton/to_do_actix:latest
-
.env
:这应该与web_app
目录中的.env
文件相同,我们需要它来执行数据库迁移
现在,我们已经准备好编写 run_build.sh
文件,这将使我们能够部署我们的应用程序,运行迁移,并创建一个用户。首先,我们从一些标准的样板代码开始,以确保我们处于正确的目录,如下所示:
#!/usr/bin/env bash
SCRIPTPATH="$( cd "$(dirname "$0")" ; pwd -P )"
cd $SCRIPTPATH
然后,我们复制启动我们的 docker-compose
系统所需的文件,并使用以下代码执行数据库迁移:
scp -i "~/.ssh/keys/remotebuild.pem"
./deployment-compose.yml ec2-user@$1:/home/ec2-user/docker-compose.yml
scp -i "~/.ssh/keys/remotebuild.pem"
./rust_config.yml ec2-user@$1:/home/ec2-user/rust_config.yml
scp -i "~/.ssh/keys/remotebuild.pem"
./.env ec2-user@$1:/home/ec2-user/.env
scp -i "~/.ssh/keys/remotebuild.pem"
./nginx_config.conf ec2-user@$1:/home/ec2-user/nginx_config.conf
scp -i "~/.ssh/keys/remotebuild.pem" -r
../web_app/migrations ec2-user@$1:/home/ec2-user/migrations
这些都不应该令人惊讶,因为我们需要所有前面的文件来运行我们的 docker-compose
系统。然后我们安装 Rust 并等待服务器构建完成,使用以下代码:
echo "installing Rust"
ssh -i "~/.ssh/keys/remotebuild.pem" -t ec2-user@$1 << EOF
curl https://sh.rustup.rs -sSf | bash -s -- -y
until [ -f ./output.txt ]
do
sleep 2
done
echo "File found"
EOF
echo "Rust has been installed"
再次,这并不是我们没有见过的新的东西。然后我们使用以下代码安装 diesel
客户端:
echo "installing diesel"
ssh -i "~/.ssh/keys/remotebuild.pem" -t ec2-user@$1 << EOF
cargo install diesel_cli --no-default-features --features postgres
EOF
echo "diesel has been installed"
然后,我们登录到 Docker,启动我们的 docker-compose
系统,运行我们的迁移,然后使用以下代码创建一个用户:
echo "building system"
ssh -i "~/.ssh/keys/remotebuild.pem" -t ec2-user@$1 << EOF
echo $3 | docker login --username $2 --password-stdin
docker-compose up -d
sleep 2
diesel migration run
curl --location --request POST 'http://localhost/v1/user/create' \
--header 'Content-Type: application/json' \
--data-raw '{
"name": "maxwell",
"email": "maxwellflitton@gmail.com",
"password": "test"
}'
EOF
echo "system has been built"
这样,我们的系统就部署完成了!我们可以通过将 output.json
文件中的服务器 IP 地址放入浏览器来访问我们的应用程序。我们可以登录并使用我们的待办事项应用程序,就像我们在本地计算机上运行系统时一样,如下面的截图所示:
图 10.18 – 我们在部署服务器上的应用程序
如我们所见,连接不安全,我们的浏览器正在给我们发出警告,因为我们没有实现 HTTPS 协议。这是因为我们的连接未加密。我们将在下一章中介绍如何加密我们的连接。
编写我们的应用程序构建脚本
目前,我们的应用程序在 EC2 实例上本地运行数据库。这有几个问题。首先,这意味着 EC2 是状态化的。如果我们拆毁实例,我们将丢失所有数据。
其次,如果我们擦除实例上的容器,我们可能会丢失所有数据。数据脆弱性并不是这里唯一的问题。假设我们的流量急剧增加,我们需要更多的计算实例来管理它。这可以通过使用 NGINX 作为两个实例之间的负载均衡器来实现,如下面的图所示:
图 10.19 – 为我们的系统加倍 EC2 实例
如您所见,这里的问题是访问随机数据。如果用户一创建了一个项目,并且这个请求击中了左侧的实例,它将存储在左侧的数据库中。然而,用户一可以随后发起一个GET
请求,这个请求击中了右侧的实例。第二个请求将无法访问第一个请求中创建的项目。用户将根据请求击中的实例访问随机状态。
这可以通过从我们的docker-compose
文件中删除数据库并在其外部创建数据库来解决,如下面的图所示:
图 10.20 – 我们的新改进系统
现在,我们有一个单一的数据真相,我们的 EC2 实例是无状态的,这意味着我们有自由根据需要创建和删除实例。
当涉及到将 AWS 数据库添加到我们的部署中时,我们必须在 Terraform 中构建我们的数据库,然后将有关构建的数据库的信息传递到我们的deployment/.env
文件以进行数据库迁移,以及传递到我们的deployment/rust_config.yml
文件以供我们的 Rust 服务器访问。首先,我们必须将数据库定义添加到我们的deployment/main.tf
文件中,如下面的代码所示:
resource "aws_db_instance" "main_db" {
instance_class = "db.t3.micro"
allocated_storage = 5
engine = "postgres"
username = var.db_username
password = var.db_password
db_name = "to_do"
publicly_accessible = true
skip_final_snapshot = true
tags = {
Name = "to-do production database"
}
}
在前面的代码中定义的字段很简单,除了allocated_storage
字段,这是分配给数据库的 GB 数。我们还可以看到我们使用了带有var
变量的变量。这意味着我们必须在运行 Terraform 构建时传递密码和用户名。我们需要在deployment/variables.tf
文件中定义我们的输入变量,如下面的代码所示:
variable "db_password" {
description = "The password for the database"
default = "password"
}
variable "db_username" {
description = "The username for the database"
default = "username"
}
这些变量有默认值,因此我们不需要传递变量。但是,如果我们想传递变量,我们可以使用以下布局:
-var"db_password=some_password" -var"db_username=some_username"
现在,我们必须将这些参数传递到所需的文件和 Terraform 构建中。这正是 Python 开始发光的地方。我们将读取和写入 YAML 文件,因此我们必须使用以下命令安装 YAML Python 包:
pip install pyyaml
然后,我们在脚本的顶部导入这个包到我们的deployment/run_build.py
文件中,使用以下代码:
import yaml
然后,我们从名为database.json
的 JSON 文件中加载数据库参数,并使用以下代码创建我们的vars
命令字符串:
with open("./database.json") as json_file:
db_data = json.load(json_file)
params = f' -var="db_password={db_data["password"]}"
-var="db_username={db_data["user"]}"'
您可以为您的deployment/database.json
文件制定任何所需的参数;我最近一直在尝试 GitHub Copilot,这是一个自动填充代码的 AI 配对程序员,这给了我以下参数:
{
"user": "Santiago",
"password": "1234567890",
"host": "localhost",
"port": "5432",
"database": "test"
}
我不知道谁是Santiago
,但 Copilot AI 显然认为Santiago
是正确的用户,所以我将使用它。回到我们的deployment/run_build.py
文件,我们必须通过更新以下代码将我们的参数传递给 Terraform 的apply
命令:
apply_process = Popen(f"cd {DIRECTORY_PATH} && terraform apply" + params, shell=True)
在运行 Terraform 构建的进程完成后,我们将该构建的输出存储在一个 JSON 文件中。然后我们创建自己的数据库 URL,并使用以下代码将此 URL 写入一个文本文件:
. . .
produce_output.wait()
with open(f"{DIRECTORY_PATH}/output.json", "r") as file:
data = json.loads(file.read())
database_url = f"postgresql://{db_data['user']}:{db_data['password']}
@{data['db_endpoint']['value'][0]}/to_do"
with open("./database.txt", "w") as text_file:
text_file.write("DATABASE_URL=" + database_url)
我们现在唯一需要做的事情是使用以下代码更新我们的 Rust 应用程序配置数据:
with open("./rust_config.yml") as yaml_file:
config = yaml.load(yaml_file, Loader=yaml.FullLoader)
config["DB_URL"] = database_url
with open("./rust_config.yml", "w") as yaml_file:
yaml.dump(config, yaml_file, default_flow_style=False)
在我们的管道中,只剩下一个更改,这是在我们的deployment/run_build.sh
文件中。我们不是将我们的本地.env
文件复制到部署服务器,而是使用以下代码复制我们的deployment/database.txt
文件:
scp -i "~/.ssh/keys/remotebuild.pem"
./database.txt ec2-user@$1:/home/ec2-user/.env
再次运行我们的部署将部署我们的服务器并将其连接到我们创建的 AWS 数据库。同样,这些构建脚本可能很脆弱。有时,在将其中一个文件复制到部署服务器时可能会被拒绝连接,这可能导致整个管道中断。因为我们自己编写了所有步骤并理解每个步骤,如果发生中断,手动解决问题或再次尝试运行 Python 构建脚本不会花费我们太多时间。
摘要
我们终于到达了旅程的终点。我们已经创建了自己的 Docker 镜像,打包了我们的 Rust 应用程序。然后我们在本地计算机上运行了这个镜像,并受到 NGINX 容器的保护。然后我们将其部署到 Docker Hub 账户,使我们能够将其部署到我们设置的 AWS 服务器上。
必须注意,我们已经完成了配置容器和通过 SSH 访问服务器的漫长步骤。这使得我们能够将此过程应用于其他平台,因为我们的通用方法并非仅针对 AWS。我们只是使用 AWS 来设置服务器。然而,如果我们使用其他提供商设置服务器,我们仍然能够在服务器上安装 Docker,将我们的镜像部署到上面,并使用 NGINX 和数据库连接来运行它。
然而,作为开发者,我们的工作永远不会结束,我们还能做更多的事情。不过,我们已经涵盖了构建 Rust 网络应用程序的核心基础知识,并实现了从零开始构建和自动化部署。
考虑到这一点,几乎没有阻碍开发者使用 Rust 构建 Web 应用程序。可以添加前端框架来提高前端功能,还可以添加额外的模块来增加我们的应用程序的功能和 API 端点。我们现在有一个坚实的基础来构建各种应用程序,并进一步阅读相关主题,以使我们能够开发 Rust Web 开发的技能和知识。
我们正处于 Rust 和 Web 开发的一个激动人心的时期,希望到达这个阶段后,你会有信心推动 Rust 在 Web 开发领域的进步。在下一章中,我们将使用 HTTPS 加密我们的 Web 流量到应用程序。
进一步阅读
-
Rust 编译到不同目标文档:
doc.rust-lang.org/rustc/targets/index.html
-
GitHub Actions 文档:
github.com/features/actions
-
Travis CI 文档:
docs.travis-ci.com/user/for-beginners/
-
CircleCI 文档:
circleci.com/docs/
-
Jenkins 文档:
www.jenkins.io/doc/
-
Terraform 输出文档:
developer.hashicorp.com/terraform/language/values/outputs
-
AWS 认证开发者 - 副总裁指南,第二版,V. Tankariya 和 B. Parmar (2019),Packt Publishing,第五章,开始使用弹性计算云 (EC2),第 165 页
-
AWS 认证开发者 - 副总裁指南,第二版,V. Tankariya 和 B. Parmar (2019),Packt Publishing,第十章,AWS 关系型数据库服务 (RDS),第 333 页
-
AWS 认证开发者 - 副总裁指南,第二版,V. Tankariya 和 B. Parmar (2019),Packt Publishing,第二十一章,开始使用 AWS CodeDeploy,第 657 页
-
精通 Kubernetes,G. Sayfan (2020),Packt Publishing
-
开始使用 Terraform,K. Shirinkin (2017),Packt Publishing
-
Nginx HTTP 服务器,第四版,M. Fjordvald 和 C. Nedelcu (2018),Packt Publishing
第十一章:在 AWS 上使用 NGINX 配置 HTTPS
当涉及到部署我们的应用程序时,许多教程和书籍都只涉及简单的部署,并忽略了使用 HTTPS 加密服务器与客户端之间流量这一概念。然而,HTTPS 是至关重要的,通常也是开发者必须克服的最大障碍,以便将他们的网站或 API 推向世界。尽管这本书的标题是《Rust Web Programming》,但为真正理解 HTTPS 的工作原理而专门奉献一章是至关重要的,这样你就可以在本地和Amazon Web Services(AWS)云上实施 HTTPS。本章将涵盖以下主题:
-
什么是 HTTPS?
-
使用
docker-compose
在本地实施 HTTPS -
将 URL 附加到我们在 AWS 上部署的应用程序
-
在 AWS 上强制实施 HTTPS
到本章结束时,你将能够使用 Terraform 代码构建基础设施,以加密流量并锁定对Elastic Compute Cloud(EC2)实例的不希望接收的流量,这样它们就只能接受来自负载均衡器的流量,这将在本章后面进行解释。最好的是,这一切大部分都是自动化的,鉴于我们使用 Docker 来部署应用程序,你将能够将这项技能转移到你未来想要部署的任何 Web 项目上。虽然这并不是最佳实现,因为还有整本书是关于云计算的,但你将能够实施一个稳固、安全的部署,即使 EC2 实例出现故障,负载均衡器也能路由到其他实例,继续为用户提供服务。如果流量需求增加,它也能进行扩展。
技术要求
在本章中,我们将基于在第十章中构建的代码,即在 AWS 上部署我们的应用程序。这可以在以下 URL 找到:github.com/PacktPublishing/Rust-Web-Programming-2nd-Edition/tree/main/chapter10/deploying_our_application_on_aws
。
本章的代码可以在github.com/PacktPublishing/Rust-Web-Programming-2nd-Edition/tree/main/chapter11
找到。
什么是 HTTPS?
到目前为止,我们的前端和后端应用程序都是通过 HTTP 运行的。然而,这并不安全,存在一些缺点。为了确保我们浏览器和 NGINX 服务器之间的流量安全,我们必须确保我们的应用程序正在使用 HTTP/2 协议。HTTP/2 协议与标准 HTTP/1 协议有以下不同之处:
-
二进制协议
-
压缩头
-
持久连接
-
多路复用流
我们可以讨论前面列出的要点之间的差异。
二进制协议
HTTP 使用基于文本的协议,而 HTTP/2 使用二进制协议。二进制协议使用字节来传输数据,而不是使用人类可读的字符,这些字符使用美国信息交换标准代码(ASCII)进行编码。使用字节可以减少可能的错误数量和传输数据所需的大小。它还将使我们能够加密我们的数据流,这是 HTTPS 的基础。
压缩头部
HTTP/2 在发送请求时压缩头部信息。压缩头部信息与二进制协议有类似的好处,这导致传输相同请求所需的数据量更小。HTTP/2 协议使用 HPACK 格式。
持久连接
当使用 HTTP 时,我们的浏览器每次需要资源时都必须发出一个请求。例如,我们可以让我们的 NGINX 服务器提供 HTML 文件。这将导致一个获取 HTML 文件的请求。在 HTML 文件中,可能有一个对 CSS 文件的引用,这将导致对 NGINX 服务器的另一个请求。在 HTML 文件中引用 JavaScript 文件也很常见。这将导致另一个请求。因此,为了加载一个标准的网页,我们的浏览器可能需要最多三个请求。当我们运行一个有多个用户的服务器时,这并不好。使用 HTTP/2,我们可以有持久连接。这意味着我们可以在一个连接中发出对 HTML、CSS 和 JavaScript 文件的三个请求。
多路复用流
使用 HTTP/1 发出请求意味着我们必须按顺序发送我们的请求。这意味着我们发出一个请求,等待这个请求被解决,然后发送另一个请求。使用 HTTP/2,我们使用多路复用流,这意味着我们可以同时发送多个请求,并在响应返回时解决它们。将多路复用流与持久连接相结合,可以缩短加载时间。任何在 1990 年代使用过互联网的读者都会记得,加载一个简单的页面需要等待很长时间。当然——那时的互联网连接速度没有现在快,但这也是由于通过多个不同连接的多个 HTTP 顺序请求来加载多个图片、HTML 和 CSS 的结果。
现在我们已经探讨了 HTTP 与 HTTP/2 之间的区别,我们可以探讨建立在 HTTP/2 之上的 HTTPS。然而,在继续前进之前,必须指出,安全是一个独立的领域。了解 HTTPS 周围的高级概念足以让我们理解我们所实施的重要性以及我们为何采取某些步骤。然而,这并不意味着我们成为了安全专家。
在我们探索 HTTPS 的步骤之前,我们需要了解什么是中间人攻击,因为这种攻击启发了 HTTPS 的步骤。中间人攻击正是其名称所暗示的那样:一个恶意窃听者可以拦截用户和服务器之间的通信数据包。这也意味着窃听者还可以在数据通过网络传输时获取加密信息。简单地搜索“中间人攻击”将会出现大量教程和可以下载以实施此类攻击的软件。本书范围之外还有更多关于安全性的注意事项,但为了总结,如果你正在托管一个你希望用户连接并登录的网站,没有理由不使用 HTTPS。
当涉及到 HTTPS 时,需要采取一系列步骤。首先,在向服务器发送任何请求之前,服务器和域的所有者必须从受信任的中心机构获取一个证书。可信赖的中心机构并不多。这些机构所做的是获取域名所有者的某些身份证明以及申请证书的人拥有该域的某些证据。这听起来可能像是一个头疼的问题,但许多 URL 提供商,如 AWS,通过使用诸如支付详情等信息简化了流程,在您点击购买域名时将这些信息发送到后端的后台受信任中心机构。我们可能需要填写一些额外的表格,但如果您有一个有效的 AWS 账户,这不会太费事。这些中心机构是有限的,因为任何拥有计算机的人都可以创建数字证书。例如,如果我们正在拦截服务器和用户之间的流量,我们可以使用密钥生成自己的数字证书并将其转发给用户。正因为如此,主流浏览器只认可由少数认可机构签发的证书。如果浏览器收到一个未认可的证书,这些浏览器将会给出警告,如下面的 Chrome 示例所示:
图 11.1 – 由于未识别的证书,Chrome 阻止访问
不建议点击高级以继续。
一旦域名和服务器所有者从中央机构获得证书,用户就可以发起请求。在交换任何有意义的数据到应用程序之前,服务器会将带有公钥的证书发送给用户。然后用户创建一个会话密钥,并加密这个会话密钥和证书的公钥,然后将它发送回服务器。服务器随后可以使用未通过网络发送的私钥解密这个密钥。因此,即使窃听者设法拦截消息并获取会话密钥,它也是加密的,所以他们无法使用它。服务器和客户端都可以检查相互加密消息的有效性。我们可以使用会话密钥向服务器和用户发送加密消息,如以下图所示:
图 11.2 – 建立 HTTPS 连接所需的步骤
不要担心——有各种软件包和工具可以帮助我们管理 HTTPS 过程;我们不需要实现自己的协议。您将了解为什么我们必须执行某些步骤,以及如何在出现问题时进行故障排除。在下一节中,我们将使用 NGINX 在本地实现基本的 HTTPS 协议。
使用 docker-compose 在本地实现 HTTPS
当涉及到实现 HTTPS 时,大部分工作将通过 NGINX 来完成。尽管我们与 NGINX 有过一些合作,但 NGINX 配置是一个强大的工具。您可以实现条件逻辑,从请求中提取变量和数据并对其操作,重定向流量,等等。在本章中,我们将足够地实现 HTTPS,但如果您有时间,建议您阅读有关 NGINX 配置基础的知识;阅读材料在进一步阅读部分提供。对于我们的deployment/nginx_config.yml
文件,我们需要以下布局:
worker_processes auto;
error_log /var/log/nginx/error.log warn;
events {
worker_connections 512;
}
http {
server {
. . .
}
server {
. . .
}
}
在这里,我们可以看到在我们的http
作用域中存在两个server
作用域。这是因为我们需要强制执行 HTTPS。我们必须记住我们的外部端口是80
。然而,如果我们想进行 HTTPS 连接,我们反而需要连接到端口443
,这是 HTTPS 的标准端口。在浏览器中输入https://
将针对端口443
,而在浏览器中输入http://
将针对端口80
。如果我们允许端口80
开放,大量的用户将以不安全的方式访问我们的网站,因为有些人会在浏览器中输入http://
。黑客也会传播 HTTP 链接,因为他们希望尽可能多的人不使用安全网络。然而,如果我们阻止端口80
,将http://
输入到浏览器中的人将无法访问网站。普通用户不太可能理解端口之间的差异,查看他们输入的内容,并更正它。相反,他们只会认为网站已关闭。因此,我们必须监听端口443
和80
。然而,当请求端口80
时,我们将请求重定向到端口443
。我们的第一个服务器作用域可以使用以下代码进行重定向:
server {
listen 80;
return 301 https://$host$request_uri;
}
在这里,我们可以看到我们监听端口80
,然后返回相同的请求,但使用 HTTPS 协议,这意味着它将击中我们的443
端口。我们还可以看到我们引用了$host
和$request_uri
变量。这些变量是 NGINX 中的标准变量,它们会自动填充。我们可以使用以下代码行定义自己的变量:
set $name 'Maxwell';
然而,我们希望我们的 NGINX 实例在我们的服务器和本地主机上运行,因此使用标准变量是这里最好的选择。现在我们已经定义了重定向规则,我们可以继续到下一个服务器作用域;我们使用以下代码监听端口443
:
server {
listen 443 ssl http2;
ssl_certificate /etc/nginx/ssl/self.crt;
ssl_certificate_key /etc/nginx/ssl/self.key;
location /v1 {
proxy_pass http://rust_app:8000/v1;
}
location / {
proxy_pass http://front_end:4000/;
}
}
观察前面的代码,必须注意我们正在同一个 NGINX 实例中处理和引导前端和后端应用的流量。在端口定义的同时,我们还声明我们正在使用ssl
和http2
NGINX 模块。这并不奇怪,因为 HTTPS 本质上是在 HTTP/2 之上的 SSL。然后我们定义服务器证书在 NGINX 容器中的位置。我们将在docker-compose
卷中稍后添加这些。我们还可以看到我们将 HTTPS 请求通过 HTTP 传递到适当的应用程序。如果我们尝试将这些代理更改为 HTTPS 协议,那么我们会得到一个不良网关错误。这是因为 NGINX 和我们的服务之间的握手将失败。这不是必需的,因为我们必须记住,前端和后端应用暴露的端口对于本地主机之外的人来说是不可用的。是的——在我们的本地机器上我们可以访问它们,这是因为它们运行在我们的本地机器上。如果我们部署我们的应用程序服务器,它将看起来像这样:
图 11.3 – 如果在服务器上部署,流量流向
我们的 NGINX 配置并非最佳。有一些设置可以在密码、缓存和管理超时方面进行调整。然而,这已经足够让 HTTPS 协议工作。如果你需要优化 NGINX 配置的缓存和加密方法,建议你寻找更多关于 DevOps 和 NGINX 的教育资源。
现在我们已经定义了我们的 NGINX 配置,我们必须定义我们的证书。
注意
要定义我们自己的证书,我们必须通过以下链接中的步骤安装 openssl
包:
Linux:
Windows:
Mac:
在 macOS Catalina 上安装 OpenSSL 库
这可以通过以下命令完成:
openssl req -x509 -days 10 -nodes -newkey rsa:2048
-keyout ./self.key -out ./self.crt
这创建了一个 x509
密钥,这是国际电信联盟的标准。我们声明证书将在 10 天后过期,密钥和证书的名称为 self
。它们可以命名为任何东西;然而,对我们来说,将证书命名为 self
是有意义的,因为它是一个自签发的证书。之前代码片段中显示的命令将推送几个提示。对于这些提示,你可以说什么并不重要,因为我们只是会用它们进行本地主机请求,这意味着它们永远不会超出我们的本地计算机之外。现在,如果你可以在 docker-compose.yml
文件中引用密钥和证书,我们就可以将密钥和证书存储在 deployment
目录中的任何位置。在我们的 docker-compose.yml
文件中,我们的 NGINX 服务现在具有以下形式:
nginx:
container_name: 'nginx-rust'
image: "nginx:latest"
ports:
- "80:80"
- 443:443
links:
- rust_app
- front_end
volumes:
- ./nginx_config.conf:/etc/nginx/nginx.conf
- ./nginx_configs/ssl/self.crt:/etc/nginx/ssl/self.crt
- ./nginx_configs/ssl/self.key:/etc/nginx/ssl/self.key
在这里,我们可以看到我选择将密钥和证书存储在一个名为 nginx_configs/ssl/
的目录中。这是因为我在 nginx_configs
目录下添加了几个简单的 NGINX 配置到 GitHub 仓库中,如果你想要一些关于处理变量、条件逻辑和直接从 NGINX 服务器上服务 HTML 文件的快速参考。虽然你从哪里获取密钥和证书可能不同,但将密钥和证书放在 NGINX 容器内的 etc/nginx/ssl/
目录中是很重要的。
现在,我们可以测试我们的应用程序以查看本地 HTTPS 是否工作。如果你启动你的 docker-compose
实例,然后在浏览器中访问 https://localhost
URL,你应该会收到一个警告,表明它不安全,你将无法立即连接到前端。这令人放心,因为我们不是中央权威机构,所以我们的浏览器将不会识别我们的证书。有众多浏览器,我们在这本书中描述如何绕过每个浏览器的限制会浪费很多空间。考虑到浏览器可以免费下载,我们可以通过访问 flags
URL 来绕过 Chrome 对我们应用程序的阻止,如下所示:
图 11.4 – 在 Chrome 中允许我们的应用程序证书通过
在这里,我们可以看到我已经允许来自 localhost 的无效证书。现在,在我们的浏览器中启用了无效证书后,我们可以访问我们的应用程序,如下所示:
图 11.5 – 通过 HTTPS 访问我们的应用程序
在这里,我们使用的是 HTTPS 协议;然而,正如我们在前面的截图中所见,Chrome 正在抱怨说它不安全。我们可以通过点击 不安全 提示来检查原因,如下所示:
图 11.6 – 解释连接不安全的原因
在这里,我们可以看到我们的证书是无效的。我们预期证书将无效,因为我们自己签发了它,这使得它没有得到官方认可。然而,我们的 HTTPS 连接正在工作!这很有趣,可以看到 HTTPS 的工作原理;然而,在我们的 localhost 上运行的自签名证书并没有什么用。如果我们想利用 HTTPS,我们必须将其应用到 AWS 上的应用程序中。在 AWS 上实现 HTTPS 之前,我们需要执行几个步骤。在下一节中,我们将为我们的应用程序分配一个 URL。
将 URL 附接到 AWS 上部署的应用程序
在上一章中,我们成功将待办事项应用程序部署到 AWS 服务器上,并通过将服务器的 IP 地址输入我们的浏览器来直接访问此应用程序。当我们注册我们的 URL 时,你会遇到多个缩写词。为了在 AWS 路由导航时感到舒适,熟悉以下图表中的 URL 缩写词是有意义的:
图 11.7 – URL 的结构
当我们将 URL 与我们的应用程序关联时,我们将配置一个 域名系统(DNS)。DNS 是一个将用户友好的 URL 转换为 IP 地址的系统。为了 DNS 系统能够工作,我们需要以下组件:
-
域名注册商:一个像 AWS、Google Cloud、Azure、GoDaddy 等组织,如果收到域名的付款和负责人的个人信息,就会注册一个域名。如果 URL 被用于非法活动,该组织也会处理滥用报告。
-
DNS 记录:一个已注册的 URL 可以有多个 DNS 记录。DNS 记录本质上定义了 URL 的路由规则。例如,一个简单的 DNS 记录会将 URL 转发到服务器的 IP 地址。
-
区域文件:DNS 记录的容器(在我们的情况下,区域文件将由 AWS 管理)。
DNS 记录和注册商对我们的 URL 工作至关重要。尽管我们可以直接连接到 IP 地址,但如果我们想连接到 URL,这里有几个中间人,如下所述:
图 11.8 – 连接 URL 到 IP 地址所需的步骤
如前图所示,如果我们想连接到服务器,我们将 URL 发送到本地 DNS 服务器。然后,该服务器从上到下按顺序进行三次调用。在三次请求结束时,本地 DNS 服务器将拥有与 URL 相关的 IP 地址。我们可以看到注册商负责部分映射。这就是我们配置 DNS 记录的地方。如果我们删除我们的 DNS 记录,那么 URL 将不再在互联网上可用。我们不必每次输入 URL 时都执行图11.8中列出的调用。我们的浏览器和本地 DNS 服务器会缓存 URL 到 IP 地址的映射,以减少对其他三个服务器的调用次数。然而,这里有一个问题;当我们构建我们的生产服务器时,你可能已经意识到每次我们拆解和启动生产服务器时,IP 地址都会改变。这里并没有发生什么问题;当我们创建 EC2 实例时,我们必须选择一个可用的服务器。像 AWS 这样的云提供商不能为了我们而保留服务器,除非我们愿意为此付费。在下一节中,我们将保持 IP 地址与弹性 IP 地址的一致性。
将弹性 IP 附加到我们的服务器
弹性 IP 地址本质上是我们保留的固定 IP 地址。我们可以在任何时候将这弹性 IP 地址附加到任何一个 EC2 实例上,只要我们觉得合适。这在路由方面非常有帮助。我们可以设置将 URL 路由到弹性 IP 的配置,然后切换弹性 IP 的分配到我们需要的服务器。这意味着我们可以将新应用程序部署到另一台服务器上,测试它,然后无需触及 URL 的路由即可将我们的弹性 IP 切换到新的部署服务器。
我们不会每次启动生产服务器时都创建一个弹性 IP。因此,在 AWS 控制台中点击创建和附加弹性 IP 地址是可以的。然而,在我们这样做之前,我们需要使用之前定义的没有 HTTPS 定义的 NGINX 配置文件部署我们的生产服务器,如下所示:
worker_processes auto;
error_log /var/log/nginx/error.log warn;
events {
worker_connections 512;
}
http {
server {
listen 80;
location /v1 {
proxy_pass http://rust_app:8000/v1;
}
location / {
proxy_pass http://front_end:4000/;
}
}
}
现在您应该已经明白了,因为 NGINX 配置只是通过外部端口80
监听 HTTP 请求,然后将它们传递给我们的应用程序。我们还必须删除我们对自己签名的证书的引用,因为我们不需要它们,也不会将这些证书上传到我们的服务器。考虑到我们没有引用我们的证书,我们deployment
目录中的docker-compose
实例应该有以下的 NGINX 定义:
nginx:
container_name: 'nginx-rust'
image: "nginx:latest"
ports:
- "80:80"
links:
- rust_app
- front_end
volumes:
- ./nginx_config.conf:/etc/nginx/nginx.conf
我们现在可以开始在生产服务器上部署我们的构建了。记住——我们可以使用之前章节中设置的deployment/run_build.py
Python 脚本来完成这项工作。一旦服务器构建完成,我们知道有一个带有"待办生产服务器"
标签的 EC2 实例正在运行。我们现在可以为我们的 EC2 实例分配一个弹性 IP 地址。
要分配弹性 IP,我们首先需要通过在 AWS 仪表板顶部的搜索栏中搜索 EC2 并点击服务来导航到 EC2 服务,这将导致以下视图:
图 11.9 – EC2 仪表板视图
我们可以看到,弹性 IP可以在资源面板的右侧和屏幕的左侧访问。一旦我们进入弹性 IP 地址仪表板,我们将有一个您拥有的弹性 IP 地址列表。在屏幕右上角,将有一个标有分配弹性 IP 地址的橙色按钮。如果您点击此按钮,您将获得以下创建表单:
图 11.10 – 分配(创建)弹性 IP 地址
我们在这里所做的是从您正在工作的区域的一个 IP 地址池中获取一个弹性 IP 地址。每个账户的弹性 IP 地址限制为五个。如果您认为这不足以满足您的需求,您将需要更具有创造性来设计您的网络基础设施。您还可以调查为主账户创建子账户。就像干净的代码一样,只针对一个项目进行清洁账户操作是有好处的。这将帮助您跟踪成本,并且关闭一个项目的所有基础设施将非常干净,因为您可以确信该项目的所有内容都已通过删除账户而被清除。
接下来,关于我们将弹性 IP 分配给 EC2 服务器,我们可以在弹性 IP 仪表板中突出显示所需的弹性 IP 地址,然后点击仪表板右上角的操作按钮,如下所示:
图 11.11 – 对我们的弹性 IP 执行操作
在 操作 下,我们必须点击 关联弹性 IP 地址 选项,这将显示以下内容:
图 11.12 – 关联弹性 IP 地址
我们在 Terraform 中定义的 "to-do 生产服务器"
标签。然而,如果我们没有标签,我们仍然可以从下拉菜单中选择一个实例。然后我们可以点击 关联 按钮。完成此操作后,我们可以在浏览器中访问我们的弹性 IP 地址,我们应该能够访问我们的待办事项应用程序,如图所示:
图 11.13 – 使用静态 IP 访问我们的待办事项应用程序
这里就是我们想要的——我们的应用程序可以通过弹性 IP 访问!!如果我们想的话,现在可以启动一个新的服务器,测试它,如果我们满意,可以将弹性 IP 重定向到新服务器,从而提供无缝更新,而用户不会察觉。然而,让用户输入原始 IP 地址并不理想。在下一节中,我们将注册一个域名并将其连接到我们的弹性 IP 地址。
注册域名
当涉及到注册域名时,所有这些都可以通过 AWS 的 Route 53 服务来处理。首先,我们导航到 Route 53,这是处理路由和 URL 注册的服务。在 Route 53 控制台网页的左侧,我们可以点击 已注册域名 部分,如下面的截图所示:
图 11.14 – 在 Route 53 控制台中导航到已注册域名
然后,我们将看到我们已拥有的已注册域名列表以及注册域名的选项,如下面的截图所示:
图 11.15 – 已注册域名视图
如果您点击 注册域名 按钮,您将进入一系列简单的表单以注册您的域名。截图这些步骤将是过度的。表单会要求您输入想要注册的域名。然后它们会告诉您该域名和其他类似域名是否可用。在撰写本书时,域名平均每年花费约 12 美元。一旦您选择了域名并点击结账,您将进入一系列个人信息表单。这些表单包括联系地址以及域名是用于个人使用还是公司。截图这些表单会导致页面过多,教育优势很小,因为这些表单是个人且容易填写。建议您选择通过 DNS 进行验证,因为这将是自动化的。
一旦你的域名注册完成,你可以进入 Route 53 主控制台中的主机区域部分。在这里,我们将看到你拥有的每个 URL 的主机区域列表。主机区域本质上是一组 DNS 记录。如果我们点击一个 URL 的主机区域,将会有两个 DNS 记录:NS 和 SOA(NS—名称服务器;SOA—权威开始)。这些记录不应被删除,如果它们被删除,知道这些记录的人可能会通过实施这些记录来劫持你的 URL。DNS 记录本质上是如何为域名路由流量的记录。每个 DNS 记录都有以下属性:
-
域名/子域名名称:记录所属的 URL 名称
-
记录类型:记录类型(A、AAAA、CNAME 或 NS)
-
值:目标 IP 地址
-
路由策略:Route 53 对查询的响应方式
-
TLL (Time to Live): 记录在客户端 DNS 解析器中缓存的时长,这样我们就不必频繁查询 Route 53 服务器,以减少 DNS 服务器流量与更新传播到客户端的时间之间的权衡
正如定义的属性一样,除了记录类型。我们可以在 Route 53 中构建高级记录类型。然而,以下记录类型是我们将域名路由到 IP 地址所必需的:
-
A:最简单的记录类型。类型 A 仅仅是将流量从 URL 路由到 IPv4 IP 地址。
-
AAAA:将流量从 URL 路由到 IPv6 地址。
-
CNAME:将主机名映射到另一个主机名(目标必须是 A 或 AAAA 记录类型)。
-
NS: 主机区域的名称服务器(控制流量路由方式)。
对于我们来说,我们将通过点击创建记录按钮创建一个 DNS A
记录,如以下截图所示:
图 11.16 – 创建 DNS 记录
点击后,我们得到以下布局:
图 11.17 – 创建 DNS 记录表单
我们可以看到记录类型 A
是默认的。我们还可以看到我们可以添加一个子域名。这给了我们一些灵活性。例如,如果我们想的话,api.freshcutswags.com
URL 可以指向与freshcutswags.com
不同的 IP 地址。现在,我们将保持子域名为空。然后,我们将把在前一节中设置的弹性 IP 地址放入www
中。一旦完成,我们应该有两个A
记录。然后我们可以使用www.digwebinterface.com网站检查我们的 URL 映射流量。在这里,我们可以输入 URL,网站会告诉我们 URL 被映射到了哪里。我们可以看到,我们的 URL 都映射到了正确的弹性 IP:
图 11.18 – 检查我们的 URL 映射
确认映射结果后,如前一个屏幕截图所示,我们可以访问我们的 URL 并期待看到以下内容:
图 11.19 – 通过注册的 URL 访问我们的应用程序
我们可以看到,我们的 URL 现在正在工作。然而,连接并不安全。在下一节中,我们将强制执行 HTTPS 协议以保护我们的应用程序,并对其进行锁定,因为现在,尽管我们可以通过 URL 访问我们的应用程序,但没有任何阻止我们直接访问服务器 IP 地址的措施。
在 AWS 应用程序上强制执行 HTTPS
目前,我们的应用程序在某种程度上是可行的,但在安全性方面却是个噩梦。在本节结束时,我们不会拥有最安全的应用程序,因为建议进一步阅读网络和 DevOps 教科书以实现金标准的安全性。然而,我们将已配置安全组,锁定我们的 EC2 实例,使其不能被外部人员直接访问,并通过负载均衡器强制执行加密流量,然后流量将被导向我们的 EC2 实例。我们努力的成果将是以下系统:
图 11.20 – 实现 HTTPS 的所需系统布局
要实现 图 11.20 中所示的系统,我们需要执行以下步骤:
-
为我们的 URL 和变体获取证书审批。
-
创建多个 EC2 实例以分发流量并确保服务在故障时仍能正常运行。
-
创建一个负载均衡器来处理传入流量。
-
创建安全组。
-
更新我们的 Python 构建脚本以支持多个 EC2 实例。
-
使用 Route 53 向导将我们的 URL 附接到负载均衡器。
在上一节关于将 URL 附接到我们 AWS 应用程序的部分,我们进行了大量的点击操作。正如前一章所述,如果可能的话,应尽量避免点击操作,因为它不可重复,而且我们人类会忘记我们做了什么。遗憾的是,在 URL 审批过程中,点击操作是最好的选择。在本节中,只有第一步和第六步需要点击操作。其余的将通过 Terraform 和 Python 完成。我们将对 Terraform 配置进行一些重大更改,因此建议在修改 Terraform 配置之前运行 terraform destroy
命令。然而,在我们进行任何编码之前,我们必须获取我们 URL 的证书。
获取我们 URL 的证书
由于我们通过 Route 53 带入了我们的 URL,这是由 AWS 处理的,并且我们的服务器在 AWS 上运行,因此证书的颁发和实施是一个简单的过程。我们需要通过在服务搜索栏中输入证书管理器
并点击它来导航到证书管理器。一旦到达那里,我们将看到一个只有一个小按钮的页面,该按钮标有请求证书。点击此按钮,我们将被带到以下页面:
图 11.21 – 证书管理器:旅程开始
我们希望我们的证书是公开的;因此,我们对默认选择感到满意,然后点击下一步。随后,我们将看到一个以下形式的表单:
图 11.22 – 定义证书请求
在这里,我们输入我们希望与证书关联的 URL。我们可以添加另一个,但我们将为我们的 URL 创建一个单独的证书,因为我们想探索如何在 Terraform 中附加多个证书。我们还可以看到 DNS 验证已经突出显示,这是推荐的,因为我们有服务器在 AWS 上,这意味着我们不需要采取任何更多行动来颁发证书。然后我们可以点击标有请求的按钮,我们将被重定向到一个包含证书列表的页面。我发现每次我这样做时,新的证书请求几乎都不在。我的猜测是存在延迟。不要担心——只需刷新页面,您将看到挂起的证书请求列在列表中。点击此条目,您将被引导到该证书请求的详细视图。在屏幕中间的右侧,您需要点击此处所示的在 Route 53 中创建记录按钮:
图 11.23 – 为 DNS 确认创建记录
点击按钮后,请按照提示操作,CNAME 记录将被创建。如果您不这样做,则票证的挂起状态将无限期持续,因为云服务提供商需要路由来颁发证书,考虑到我们选择了 DNS 验证。几分钟后,证书应该会被颁发。一旦完成,为前缀通配符执行相同的步骤。一旦完成,您的证书列表应该看起来像以下这样:
图 11.24 – 已颁发证书
在前面的屏幕截图中,我们可以看到我有两个证书:一个用于用户直接输入 URL 而没有前缀的情况,以及覆盖所有前缀的通配符。我们准备使用这些证书,但在这样做之前,我们必须执行一些其他步骤。在我们定义关于流量的规则之前,我们必须构建流量将流向的基础设施。在下一节中,我们将构建两个 EC2 实例。
创建多个 EC2 实例
我们将使用负载均衡器。因此,我们需要至少两个 EC2 实例。这意味着如果一个 EC2 实例宕机,我们仍然可以使用另一个 EC2 实例。我们还可以扩展我们的应用程序。例如,如果世界上所有人突然意识到他们需要一个待办事项应用来整理他们的生活,我们无法阻止我们增加 EC2 实例的数量以分配流量。我们可以通过进入我们的deployment/main.tf
文件并使用以下定义来增加我们的 EC2 实例到两个:
resource "aws_instance" "production_server" {
ami = "ami-0fdbd8587b1cf431e"
instance_type = "t2.medium"
count = 2
key_name = "remotebuild"
user_data = file("server_build.sh")
tags = {
Name = "to-do prod ${count.index}"
}
# root disk
root_block_device {
volume_size = "20"
volume_type = "gp2"
delete_on_termination = true
}
}
在这里,我们可以看到我们添加了一个count
参数并将其定义为2
。我们还更改了标签。我们还可以看到我们使用index
访问正在创建的 EC2 实例的数量。索引从零开始,每次创建资源时增加一。现在我们有两个实例,我们必须使用以下代码更新deployment/main.tf
文件底部的输出:
output "ec2_global_ips" {
value = ["${aws_instance.production_server.*.public_ip}"]
}
output "db_endpoint" {
value = "${aws_db_instance.main_db.*.endpoint}"
}
output "public_dns" {
value =
["${aws_instance.production_server.*.public_dns}"]
}
output "instance_id" {
value = ["${aws_instance.production_server.*.id}"]
}
在这里,我们可以看到,除了数据库端点外,所有其他输出都已更改为列表。这是因为它们都引用了我们的多个 EC2 实例。现在我们已经定义了我们的 EC2 实例,我们可以使用负载均衡器将流量路由到我们的实例。
为我们的流量创建负载均衡器
我们可以从一系列不同的负载均衡器中进行选择。我们已经讨论了 NGINX,它是一个流行的负载均衡器。对于本章,我们将使用应用程序负载均衡器来路由流量到我们的 EC2 实例并实现 HTTPS 协议。负载均衡器可以提供多种功能,并且它们可以保护deployment/load_balancer.tf
文件。首先,我们使用以下代码收集所需的数据:
data "aws_subnet_ids" "subnet" {
vpc_id = aws_default_vpc.default.id
}
data "aws_acm_certificate" "issued_certificate" {
domain = "*.freshcutswags.com"
statuses = ["ISSUED"]
}
data "aws_acm_certificate" "raw_cert" {
domain = "freshcutswags.com"
statuses = ["ISSUED"]
}
我们可以看到,我们使用的是data
声明而不是resource
,这是我们在 Terraform 脚本中查询 AWS 以获取特定类型数据的地方。我们获取了虚拟私有云(VPC)ID。在 Terraform 中,我们可以定义和构建一个 VPC,但在这本书的整个过程中,我们一直在使用默认的 VPC。我们可以获取用于我们的负载均衡器的默认 VPC ID。然后,我们使用前面的代码获取我们在上一节中定义的证书的数据。
现在,我们必须为我们的负载均衡器定义一个目标。这是通过以下代码形式的 target group 来完成的,我们可以将一组实例聚集在一起,以便负载均衡器可以针对这些实例:
resource "aws_lb_target_group" "target-group" {
health_check {
interval = 10
path = "/"
protocol = "HTTP"
timeout = 5
healthy_threshold = 5
unhealthy_threshold = 2
}
name = "ToDoAppLbTg"
port = 80
protocol = "HTTP"
target_type = "instance"
vpc_id = aws_default_vpc.default.id
}
在这里,我们可以看到我们定义了健康检查的参数。健康检查的参数是自解释的。健康检查将向一个针对目标组健康状态的服务发出警报。我们不希望将流量路由到已关闭的目标组。然后我们定义了流量的协议和端口,目标组中的资源类型,以及 VPC 的 ID。现在我们的目标组已经定义好了,我们可以用以下代码将其与我们的 EC2 实例关联起来:
resource "aws_lb_target_group_attachment" "ec2_attach" {
count = length(aws_instance.production_server)
target_group_arn = aws_lb_target_group.target-group.arn
target_id =
aws_instance.production_server[count.index].id
}
我们可以看到我们获取了目标 ID 的 EC2 服务器的 ID。有了这个,我们的 EC2 实例就可以被负载均衡器所针对。现在我们有了目标,我们可以用以下代码创建我们的负载均衡器:
resource "aws_lb" "application-lb" {
name = "ToDoApplicationLb"
internal = false
ip_address_type = "ipv4"
load_balancer_type = "application"
security_groups = ["${aws_security_group.
alb-security-group.id}"]
subnets = data.aws_subnet_ids.subnet.ids
tags = {
name = "todo load balancer"
}
}
我们可以看到负载均衡器定义中的参数非常直接。然而,你可能已经注意到了安全组定义。尽管我们没有定义任何安全组,但我们正在引用一个安全组。如果你不知道什么是安全组,不要担心——我们将在下一节中涵盖并构建我们需要的所有安全组。但在我们这样做之前,我们不妨先为负载均衡器定义监听和路由规则。首先,我们可以为端口80
定义 HTTP 监听器。如果你还记得本章的第一节,当我们在本地主机上使 HTTPS 工作的时候,你认为我们需要对 HTTP 流量做些什么?你不需要知道具体的 Terraform 代码,但我们希望促进的一般行为是什么?有了这个想法,我们可以用以下代码实现这种行为:
resource "aws_lb_listener" "http-listener" {
load_balancer_arn = aws_lb.application-lb.arn
port = 80
protocol = "HTTP"
default_action {
type = "redirect"
redirect {
port = "443"
protocol = "HTTPS"
status_code = "HTTP_301"
}
}
}
没错!我们从端口80
接收 HTTPS 流量,然后将其重定向到端口443
,使用 HTTPS 协议。我们可以看到我们使用我们创建的负载均衡器的 ARN 附加了这个监听器。我们现在可以用以下代码定义我们的 HTTPS 监听器:
resource "aws_lb_listener" "https-listener" {
load_balancer_arn = aws_lb.application-lb.arn
port = 443
protocol = "HTTPS"
certificate_arn = data.aws_acm_certificate.
issued_certificate.arn
default_action {
target_group_arn = aws_lb_target_group.target-
group.arn
type = "forward"
}
}
在这里,我们可以看到我们接受 HTTPS 流量,然后将 HTTP 流量转发到我们定义的目标组,使用目标组的 ARN。我们还可以看到我们已将一个证书附加到监听器上。然而,这并不涵盖我们所有的 URL 组合。记住——我们还有一个想要附加的证书。我们可以用以下代码附加我们的第二个证书:
resource "aws_lb_listener_certificate" "extra_certificate" {
listener_arn = "${aws_lb_listener.https-listener.arn}"
certificate_arn =
"${data.aws_acm_certificate.raw_cert.arn}"
}
这个连接应该很容易理解。在这里,我们仅仅引用了 HTTPS 监听器的 ARN 和我们要附加的证书的 ARN。我们现在已经为负载均衡器的资源定义了所有需要的东西。然而,关于流量呢?我们已经定义了负载均衡器、EC2 实例和负载均衡器的 HTTPS 路由。但是,有什么阻止某人直接连接到 EC2 实例,完全绕过负载均衡器和 HTTPS 呢?这就是安全组发挥作用的地方。在下一节中,我们将通过创建安全组来锁定流量,这样用户就不能绕过我们的负载均衡器。
创建安全组以锁定和确保流量
安全组本质上等同于防火墙。我们可以定义实现了安全组的资源之间的流量。流量的规则可以非常细致。一个安全组可以定义多个规则,以定义流量的来源(甚至如果需要,可以指定特定的 IP 地址)和协议。当我们谈到我们的安全组时,我们将需要两个。一个将接受来自世界各地的所有 IP 地址的 HTTP 和 HTTPS 流量。这将用于我们的负载均衡器,因为我们希望应用程序对每个人可用。另一个安全组将由我们的 EC2 实例实现;这个安全组除了第一个安全组之外,将阻止所有 HTTP 流量。我们还将启用 SSH 入站流量,因为我们需要 SSH 到服务器以部署应用程序,从而得到以下流量布局:
图 11.25 – 我们部署系统的安全组
这是你必须小心对待在线教程的地方。YouTube 视频和 Medium 文章中不乏一些通过点击和指向就能让负载均衡器启动运行的内容。然而,它们没有暴露 EC2 实例,也没有去探索安全组。即使在这个部分,我们也将数据库暴露在外。我这样做是因为这是一个在问题部分值得提出的好问题。然而,我在这里强调它,是因为你需要被警告它已经被暴露。锁定数据库的方法将在本章的答案部分中介绍。当我们谈到我们的安全组时,我们将在deployment/security_groups.tf
文件中定义它们。我们可以从以下代码开始定义负载均衡器的安全组:
resource "aws_security_group" "alb-security-group" {
name = "to-do-LB"
description = "the security group for the
application load balancer"
ingress {
. . .
}
ingress {
. . .
}
egress {
. . .
}
tags = {
name: "to-do-alb-sg"
}
}
在这里,我们在ingress
标签下有两个入站规则,在egress
标签下有一个出站规则。我们的第一个入站规则是允许来自任何地方的 HTTP 数据,以下代码:
ingress {
description = "http access"
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
所有零的cidr
块意味着来自任何地方。
我们的第二个入站规则是从任何地方来的 HTTPS 流量。你认为这将如何定义?它可以定义为以下代码:
ingress {
description = "https access"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
现在我们来定义我们的出站规则。我们必须允许所有流量和协议从负载均衡器流出,因为它们来自我们的资源。这可以通过以下代码实现:
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
必须注意,from_port
和to_port
为零,这意味着我们允许从所有端口流出流量。我们还设置了protocol
为-1
,这意味着我们允许所有协议作为流出流量。我们现在已经定义了负载均衡器的安全组。现在我们可以继续定义我们的 EC2 实例的安全组,以下代码:
resource "aws_security_group" "webserver-security-group" {
name = "to-do-App"
description = "the security group for the web server"
ingress {
. . .
}
ingress {
. . .
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
name: "to-do-webserver-sg"
}
}
出站规则将与负载均衡器相同,因为我们希望将数据返回到任何可以请求它的位置。当我们谈到我们的 HTTP 入站规则时,我们只想接受来自以下代码的负载均衡器的流量:
ingress {
description = "http access"
from_port = 80
to_port = 80
protocol = "tcp"
security_groups = ["${aws_security_group.
alb-security-group.id}"]
}
在这里,我们可以看到,我们不是定义 cidr
块,而是依赖于负载均衡器的安全组。现在所有用户流量都已定义,我们只需定义以下代码的 SSH 流量以进行部署:
ingress {
description = "SSH access"
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
在这里,我们通过端口 22
访问 SSH。这将使我们能够 SSH 进入我们的服务器并部署我们的应用程序。几乎所有的事情都完成了。我们只需将我们的 EC2 实例附加到我们的 EC2 安全组,这可以通过以下代码完成:
resource "aws_network_interface_sg_attachment"
"sg_attachment_webserver" {
count = length(aws_instance.production_server)
security_group_id = aws_security_group.
webserver-security-group.id
network_interface_id = aws_instance.
production_server[count.index].
primary_network_interface_id
}
我们的地毯脚本现在已完成,并且能够启动多个 EC2 实例、数据库和负载均衡器,同时锁定流量。如果您想以受限流量启动一个基本的带有 HTTPS 和数据库访问的 Web 应用程序,这是一个很好的模板。
现在我们有两个不同的 EC2 实例,我们将在下一节中更改我们的部署脚本,以便两个实例都安装了应用程序。
更新我们的 Python 部署脚本以支持多个 EC2 实例
我们可以通过同时运行多个进程等方式优化部署过程。这将随着我们拥有的 EC2 实例数量的增加而加快我们的部署。然而,我们必须记住,这是一本关于 Rust 和网络编程的书,其中包含了一些部署章节,以便您可以使用您所创建的内容。我们本可以写一本关于优化和改进我们的部署管道的整本书。当我们谈到在 deployment/run_build.py
文件中支持多个 EC2 实例时,在文件的末尾,我们只需通过以下代码遍历全局 IP 地址列表:
for server_ip in data["ec2_global_ips"]["value"][0]:
print("waiting for server to be built")
time.sleep(5)
print("attempting to enter server")
build_process = Popen(f"cd {DIRECTORY_PATH} && sh
./run_build.sh
{server_ip} {args.u} {args.p}",
shell=True)
build_process.wait()
就这样。现在支持了多个服务器。在这里,我们可以看到在 Python 文件中管理部署数据背后的逻辑与在单个服务器上部署应用程序的单独 Bash 脚本之间分离的有效性。保持事物隔离可以降低技术债务,使重构变得容易。现在,我们的代码基础设施已经完成!我们可以运行这个 Python 脚本并将我们的构建部署到 AWS。几乎所有的事情都完成了;我们接下来必须做的只是将我们的 URL 连接到负载均衡器。
将我们的 URL 附加到负载均衡器
这是本垒打。我们终于接近了尾声。感谢你一直陪伴我完成这一章,因为它并不像 Rust 编程那样令人兴奋。然而,如果你想要使用你的 Rust 服务器,它是非常重要的。为了将我们的 URL 连接到负载均衡器,我们必须导航到你的 URL 的托管区域。一旦到达那里,点击 创建记录 按钮。在 创建记录 显示中,如果你没有使用向导,请点击 创建记录 显示右上角的 切换到向导 链接以获取以下向导视图:
图 11.26 – 创建记录向导视图
在这里,我们可以看到一系列用于路由流量的花哨方式。然而,我们只是选择 简单路由,因为我们只需要将流量传递给负载均衡器,它负责在 EC2 实例之间分配流量。选择 简单路由 会给我们以下表单来填写:
图 11.27 – 定义简单记录向导视图
在这里,我们可以看到我已经选择了 ToDoApplicationLb
负载均衡器进行选择。当你点击 定义简单记录 按钮,你将被导航到一个要创建的记录列表。我们再次执行创建向导过程,以考虑所有带有通配符的前缀,然后确认我们创建的记录。有了这个,我们的 HTTPS 现在可以与我们的应用程序一起工作,如下所示:
图 11.28 – HTTPS 与互联网上的我们的应用程序一起工作
有了这个,我们的章节就完成了。如果你尝试直接通过 IP 地址访问我们的任何一个 EC2 实例,你将被阻止。因此,我们无法直接访问我们的 EC2 实例,但可以通过 HTTPS 通过我们的 URL 访问它们。如果你向用户提供任何你 URL 的变体,即使是到 URL 的 HTTP 链接,你的用户也会高兴地使用你的应用程序,并使用 HTTPS 协议。
摘要
目前为止,我们已经完成了部署在 AWS 上具有锁定流量和强制 HTTPS 的健壮且安全的应用程序所需的所有工作。我们覆盖了大量的内容才达到这里,你在本章中获得的技术技能可以应用于你想要在 AWS 上部署的几乎所有其他项目,只要你能够将其打包到 Docker 中。你现在理解了 HTTPS 的优势以及实现 HTTPS 协议所需的步骤,不仅能够实现 HTTPS 协议,还能将你的 URL 映射到服务器或负载均衡器的 IP 地址。更重要的是,我们使用 Terraform 提供的强大数据查询资源,自动化了使用证书管理器创建的证书附加到我们的负载均衡器。最后,当我们能够使用 HTTPS 和仅 HTTPS 访问我们的应用程序时,这一切都汇聚在一起。这不仅使我们获得了一些将在许多未来的项目中变得有用的实用技能,而且还探索了 HTTPS 和 DNS 的工作原理,这使我们更深入地理解和欣赏了当我们输入 URL 到浏览器时互联网通常是如何工作的。
在下一章中,我们将探讨 Rocket 框架。由于我们在 Actix web 应用程序中构建 Rust 模块的方式,我们能够直接从 Actix web 应用程序中提取模块,并将它们插入到 Rocket 应用程序中。考虑到我们在本章中所做的工作,我们还将能够通过仅更改部署docker-compose
文件中的一行代码,将我们的 Rocket 应用程序包装在 Docker 中,并将其插入到这里的构建管道中。在下一章中,您将亲身体验到,当一切结构良好且隔离时,更改功能和框架不会成为头痛的问题,实际上,这会相当愉快。
进一步阅读
-
应用程序负载均衡器文档:
docs.aws.amazon.com/AmazonECS/latest/developerguide/load-balancer-types.html
-
安全组文档:
docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html
问题
-
一旦我们记住我们的系统数据库仍然暴露给全世界,我们该如何将其锁定?
-
为什么在某些情况下我们应该使用弹性 IP?
-
我们如何自动化将现有的弹性 IP 关联到 EC2 实例上?
-
我们如何利用安全组来锁定 URL 和 EC2 实例之间的流量?
-
假设我们应用的流量大幅增加,我们的两个实例无法处理压力。我们该如何使我们的系统能够处理这种增加?
-
DNS 记录的基本前提是什么?
-
什么是 URL 托管区域?
答案
-
我们将创建一个只接受从 EC2 实例安全组到和从的数据库安全组。如果我们需要进行迁移,我们可以 SSH 到 EC2 实例并使用它作为代理连接到数据库。您也可以使用 DataGrip 等数据库查看软件这样做。有时您可能有一个仅用于用户将其用作代理以访问数据库的 EC2 实例。这被称为堡垒服务器。
-
当我们销毁和创建 EC2 实例时,EC2 实例的 IP 地址将改变。我们可以使用弹性 IP 来确保 IP 地址保持一致,这对于自动化管道可能很有帮助,因为我们可以继续指向该 IP 地址。然而,如果我们使用负载均衡器,我们不需要使用弹性 IP 地址,因为我们将我们的 URL 指向负载均衡器。
-
我们可以使用 Terraform 及其强大的数据资源来自动将弹性 IP 关联到 EC2 实例。这意味着我们可以通过数据查询获取现有弹性 IP 的数据,然后将此 IP 关联到我们正在创建的 EC2 实例。
-
确保负载均衡器附加的目标组中的 EC2 实例的安全组只能接受来自负载均衡器的 HTTP 流量。
-
考虑到我们系统中的所有内容都是自动化的,并且相互连接,我们只需将我们的 EC2 实例定义中的计数从 2 增加到 3 即可。这将把处理流量的 EC2 实例数量从 2 增加到 3。
-
DNS 记录本质上是一个路由规则,它将告诉我们如何将流量从 URL 路由到服务器 IP、URL 命名空间或 AWS 服务。
-
一个 URL 托管区域是一组 URL 的 DNS 记录。
第五部分:使我们的项目更灵活
现在我们已经在 AWS 上部署了一个完全工作的应用程序,具有 HTTPS 和数据库。然而,要到达那里有很多移动部件。在本部分中,我们将我们将书籍中构建的应用程序转移到 Rocket 应用程序中,以了解良好的代码结构如何在我们选择 Web 框架时提供灵活性。然后,我们将介绍如何通过构建/部署管道和自动迁移 Docker 容器(这些容器一次性应用数据库迁移然后死亡)来保持我们的 Web 应用程序存储库干净和灵活的实践。我们还介绍了多阶段构建以及如何构建大约 50MB 的 distroless 服务器 Docker 镜像。
本部分包括以下章节:
-
第十二章,在 Rocket 中重新创建我们的应用程序
-
第十三章,干净 Web 应用程序存储库的最佳实践
第十二章:在 Rocket 中重新创建我们的应用程序
到目前为止,我们已经使用Actix Web 框架构建了一个完全功能齐全的待办事项应用程序。在本章中,我们将探讨核心概念,这样如果我们决定在Rocket中完全重新创建待办事项应用程序,就不会有任何阻碍。这个框架可能对一些开发者有吸引力,因为它不需要太多的样板代码。
在本章中,我们将充分利用我们隔离的模块化代码,通过复制和插入现有的模块、视图、数据库连接配置和测试管道,在一章中完全重新创建我们的应用程序。即使你对在 Rocket 中构建 Web 应用程序不感兴趣,我仍然建议你完成这一章,因为你会体验到为什么执行良好的解耦测试和编写良好的代码是如此重要,因为良好的测试和结构将使你能够轻松切换 Web 框架。
在本章中,我们将涵盖以下主题:
-
什么是 Rocket?
-
设置我们的服务器
-
插入我们的现有模块
-
返回 JSON 状态
-
返回多个状态
-
使用 Rocket 注册我们的视图
-
插入我们的现有测试
到本章结束时,你将拥有一个功能齐全的待办事项应用程序,在 Rocket 中只需编写最少的代码。你不仅将了解配置和运行 Rocket 服务器的基础知识,而且还将能够将来自使用 Actix Web 的其他代码库的模块、视图和测试迁移到你的 Rocket 服务器,反之亦然。这不仅是一项宝贵的技能,而且也证实了高质量、隔离代码的需求。你将亲身体验为什么以及如何以我们的方式来结构化你的代码。
技术要求
在本章中,我们将基于在第十一章中构建的代码,在 AWS 上使用 NGINX 配置 HTTPS。您可以在以下 URL 找到它:github.com/PacktPublishing/Rust-Web-Programming-2nd-Edition/tree/main/chapter11/running_https_on_aws
。
本章的代码可以在github.com/PacktPublishing/Rust-Web-Programming-2nd-Edition/tree/main/chapter12
找到。
什么是 Rocket?
Rocket 是一个 Rust Web 框架,类似于 Actix Web。它比 Actix Web 更新,在撰写本文时用户基础较小。在本书的前一版中,Rocket 运行在 nightly Rust 上,这意味着发布并不稳定。然而,现在,Rocket 运行在稳定的 Rust 上。
该框架确实有一些优势,这取决于你的编码风格。Rocket 更容易编写,因为它自己实现了样板代码,因此开发者不必自己编写样板代码。Rocket 还支持开箱即用的 JSON 解析、表单和类型检查,所有这些都可以用几行代码实现。例如,日志记录功能在启动 Rocket 服务器时就已经实现。如果你只想轻松地启动一个应用程序,那么 Rocket 是一个很好的框架。然而,它不如 Actix Web 稳定,这意味着当你变得更加高级时,你可能会羡慕 Actix Web 的一些特性和实现。然而,在我的所有网络开发年中,我从未遇到过由于框架选择而严重受阻的问题。这主要取决于个人偏好。为了真正感受到差异,尝试一下 Rocket 是有意义的。在下一节中,我们将创建一个基本服务器。
设置我们的服务器
当涉及到在 Rocket 中设置基本服务器时,我们将从 main.rs
文件中定义的所有内容开始。首先,启动一个新的 Cargo 项目,然后在 Cargo.toml
文件中使用以下代码定义 Rocket 依赖项:
[dependencies]
rocket = "0.5.0-rc.2"
在依赖项方面,我们现在需要的就是这些。现在,我们可以转到 src/main.rs
文件来定义应用程序。最初,我们需要使用以下代码导入 Rocket crate 和与 Rocket crate 相关的宏:
#[macro_use] extern crate rocket;
现在,我们可以使用以下代码定义一个基本的 hello world 视图:
#[get("/")]
fn index() -> &'static str {
"Hello, world!"
}
通过前面的代码,我们可以推断出函数之前的宏定义了方法和 URL 端点。该函数是在调用视图时执行的逻辑,函数返回的内容就是返回给用户的内容。为了感受 URL 宏的强大功能,我们可以创建另外两个视图——一个显示 hello
,另一个显示 goodbye
:
#[get("/hello/<name>/<age>")]
fn hello(name: String, age: u8) -> String {
format!("Hello, {} year old named {}!", age, name)
}
#[get("/bye/<name>/<age>")]
fn bye(name: String, age: u8) -> String {
format!("Goodbye, {} year old named {}!", age, name)
}
在这里,我们可以看到我们可以将参数从 URL 传递到函数中。同样,这段代码清晰易懂。我们除了将这些视图附加到服务器并使用以下代码启动它之外,没有其他事情要做:
#[launch]
fn rocket() -> _ {
rocket::build().mount("/", routes![index, hello, bye])
}
在这里,我们可以看到我们必须用 Rocket 的宏装饰 main
函数,并且我们正在附加没有前缀的我们定义的视图。然后我们可以运行 cargo run
命令来启动服务器。一旦我们执行了 run
命令,我们就会得到以下输出:
Configured for debug.
>> address: 127.0.0.1
>> port: 8000
>> workers: 8
>> ident: Rocket
>> limits: bytes = 8KiB, data-form = 2MiB, file = 1MiB, form =
32KiB, json = 1MiB, msgpack = 1MiB, string = 8KiB
>> temp dir: /var/folders/l7/q2pdx7lj0l72s0lsf3kc34fh0000gn/T/
>> http/2: true
>> keep-alive: 5s
>> tls: disabled
>> shutdown: ctrlc = true, force = true, signals = [SIGTERM],
grace = 2s, mercy = 3s
>> log level: normal
>> cli colors: true
Routes:
>> (index) GET /
>> (bye) GET /bye/<name>/<age>
>> (hello) GET /hello/<name>/<age>
Fairings:
>> Shield (liftoff, response, singleton)
Shield:
>> X-Content-Type-Options: nosniff
>> X-Frame-Options: SAMEORIGIN
>> Permissions-Policy: interest-cohort=()
Rocket has launched from http://127.0.0.1:8000
在这里,我们可以看到日志是全面的。它定义了端口、地址和服务器配置。然后定义了附加的路线以及公平性。有了前面的日志,我们可以看到服务器是健康的,我们有预期的路线。在这里,我们可以看到日志是现成的。我们不需要定义任何东西,与 Actix Web 不同。我们还得到一条说明已挂载的视图和服务器正在监听的 URL 的注释。
现在,我们可以在浏览器中调用我们的hello视图,它给出了以下输出:
图 12.1 – 调用我们的 hello 视图的结果
调用此视图也给我们以下日志:
GET /hello/maxwell/33 text/html:
>> Matched: (hello) GET /hello/<name>/<age>
>> Outcome: Success
>> Response succeeded.
从查看日志来看,我们不能再对它提出更多要求了。我们现在已经有一个基本的服务器正在运行;然而,这并没有包含我们在之前的 Actix Web 应用程序中拥有的所有功能。重新编码我们所有的功能将导致章节过长。在下一节中,我们将利用我们的模块化代码,并将所有功能嵌入到我们的 Rocket 应用程序中。
插入现有模块
在整本书中,我们一直在各自的文件或目录中构建独立的模块,这些模块只关注一个过程。例如,数据库文件只专注于创建和管理数据库连接。待办事项模块只专注于构建待办事项,而 JSON 序列化模块完全专注于将数据结构序列化为 JSON。考虑到这些,我们将看到这些模块如何轻松地复制到我们的应用程序中并使用。一旦我们这样做,你就会亲身体会到为什么独立的模块很重要。
首先,我们必须在Cargo.toml
文件中用以下代码定义我们的依赖项:
[dependencies]
rocket = {version = "0.5.0-rc.2", features = ["json"]}
bcrypt = "0.13.0"
serde_json = "1.0.59"
serde_yaml = "0.8.23"
chrono = {version = "0.4.19", features = ["serde"]}
serde = { version = "1.0.136", features = ["derive"] }
uuid = {version = "1.0.0", features = ["serde", "v4"]}
diesel = { version = "1.4.8", features = ["postgres",
"chrono", "r2d2"] }
lazy_static = "1.4.0"
这些是我们之前模块中使用的 crates。现在,我们可以使用以下 Bash 命令将web_app
目录中的旧模块复制到我们的 Rocket 应用程序中:
cp -r ./web_app/src/json_serialization ./rocket_app/src/json_serialization
cp -r ./web_app/src/to_do ./rocket_app/src/to_do
cp -r ./web_app/src/models ./rocket_app/src/models
cp web_app/src/config.rs rocket_app/src/config.rs
cp web_app/config.yml rocket_app/config.yml
cp web_app/src/schema.rs rocket_app/src/schema.rs
cp ./web_app/src/database.rs ./rocket_app/src/database.rs
cp -r ./web_app/migrations ./rocket_app/migrations
cp ./web_app/docker-compose.yml ./rocket_app/docker-compose.yml
cp ./web_app/.env ./rocket_app/.env
事情几乎都正常;然而,我们确实有一些对 Actix web 框架的引用。这些可以通过删除特质的实现来删除。正如我们可以在以下图中看到的那样,独立的模块可以直接引用,而高级集成可以通过特质来实现:
图 12.2 – 我们的模块如何与不同的框架交互
一旦我们在src/database.rs
和src/json_serialization/to_do_items.rs
文件中删除了 Actix Web 特质的实现,我们就可以在main.rs
文件中定义和导入我们的模块。main.rs
文件的开头应该看起来像以下这样:
#[macro_use] extern crate rocket;
#[macro_use] extern crate diesel;
use diesel::prelude::*;
use rocket::serde::json::Json;
mod schema;
mod database;
mod json_serialization;
mod models;
mod to_do;
mod config;
use crate::models::item::item::Item;
use crate::json_serialization::to_do_items::ToDoItems;
use crate::models::item::new_item::NewItem;
use database::DBCONNECTION;
导入模块后,我们可以用以下代码重新创建create
视图:
#[post("/create/<title>")]
fn item_create(title: String) -> Json<ToDoItems> {
let db = DBCONNECTION.db_connection.get().unwrap();
let items = schema::to_do::table
.filter(schema::to_do::columns::title.eq(&title.as_str()))
.order(schema::to_do::columns::id.asc())
.load::<Item>(&db)
.unwrap();
if items.len() == 0 {
let new_post = NewItem::new(title, 1);
let _ = diesel::insert_into(schema::to_do::table)
.values(&new_post)
.execute(&db);
}
return Json(ToDoItems::get_state(1));
}
从前面的代码中我们可以看出,它类似于我们的 Actix Web 实现,因为我们正在使用现有的模块。唯一的区别是我们将ToDoItems
结构体传递给 Rocket crate 的Json
函数。我们还没有实现身份验证,所以现在我们只是将用户 ID 值1
传递到所有需要用户 ID 的操作中。
现在我们完成了create
视图,我们可以使用以下代码将其附加到我们的服务器上:
#[launch]
fn rocket() -> _ {
rocket::build().mount("/", routes![index, hello, bye])
.mount("/v1/item", routes![item_create])
}
我们可以看到,我们不必构建自己的配置函数。我们只需将视图按顺序排列在带有前缀的数组中,装饰视图函数的宏定义了其余的 URL。现在我们可以使用以下命令运行我们的 Rocket 服务器:
cargo run config.yml
我们必须记住启动我们的docker-compose
,以便数据库可访问,并使用diesel
客户端在数据库上运行迁移。然后我们可以使用以下 URL 通过post
请求创建我们的第一个待办事项:
http://127.0.0.1:8000/v1/item/create/coding
在发出post
请求后,我们将得到以下响应体:
{
"pending_items": [
{
"title": "coding",
"status": "PENDING"
}
],
"done_items": [],
"pending_item_count": 1,
"done_item_count": 0
}
就这样!我们的应用程序正在运行,我们不必重写整个代码库。我知道我在这本书中一直在重复这一点,但结构良好、独立的代码的重要性不容忽视。我们在这里所做的是在重构系统时很有用的。例如,我曾在微服务系统中工作,我们必须从一个服务器中移除功能,因为它的范围变得太大,然后创建另一个。正如你所看到的,独立的模块使这样的任务变得像梦一样,可以在最短的时间内以最小的努力完成。
现在我们已经以基本的方式集成了现有的模块,我们可以通过为我们的模块实现 Rocket 特性来继续进行高级集成。
实现 Rocket 特性
我们在模块中定义的大部分逻辑,我们复制到代码中,可以直接在我们的代码中引用。然而,我们确实需要利用数据库连接和具有 Actix Web 特性实现的 JWT 结构体。如果我们复制视图,我们必须为数据库连接和 JWT 身份验证实现 Rocket 特性,因为我们把它们传递到 Actix Web 应用程序的视图函数中。
在我们实现 Rocket 特性之前,我们必须使用以下命令复制 JWT 文件:
cp web_app/src/jwt.rs rocket_app/src/jwt.rs
然后,我们必须在Cargo.toml
文件中使用以下代码声明以下依赖项:
jsonwebtoken = "8.1.0"
我们现在可以继续到src/jwt.rs
文件,为我们的 Rocket 特性实现。首先,我们必须在文件顶部使用以下代码导入以下特性和结构体:
use rocket::http::Status;
use rocket::request::{self, Outcome, Request, FromRequest};
FromRequest
特性实现的核心理念将保持一致,因为我们关注的是令牌的解码和认证。然而,由于我们是从 Rocket 框架中实现一个特性,而不是从 Actix Web crate 中实现,所以会有一些细微的差异。主要区别是我们必须使用以下代码构建自己的 enum
,以定义可能的结果:
#[derive(Debug)]
pub enum JwTokenError {
Missing,
Invalid,
Expired
}
我们在这里选择了不同的可能性,因为令牌可能不在头部,所以可能会缺失。或者,令牌可能不是我们的,所以可能是无效的。记住,我们有一个时间戳来强制过期时间。如果令牌已过期,它将具有过期状态。
下一步仅仅是实现 FromRequest
特性。由于代码是隔离的,并且只关注令牌的编码和解码,我们不需要修改 JwToken
结构体。我们可以用以下代码定义特性的实现大纲:
#[rocket::async_trait]
impl<'r> FromRequest<'r> for JwToken {
type Error = JwTokenError;
async fn from_request(req: &'r Request<'_>)
-> Outcome<Self, Self::Error> {
. . .
}
}
在这里,我们可以看到,我们使用 async
特性宏装饰了特性的实现。这是因为请求是以异步方式发生的。我们还必须定义生命周期注解。这是因为我们必须声明请求的生命周期将与特性实现的寿命相同。我们可以通过 from_request
函数中的 request
参数看到这一点。现在,我们可以将旧 Actix Web 实现的逻辑提升到 from_request
函数中,只需对返回的类型进行一些更改。提升的代码最终应该看起来像以下这样:
match req.headers().get_one("token") {
Some(data) => {
let raw_token = data.to_string();
let token_result = JwToken::from_token(raw_token);
match token_result {
Ok(token) => {
return Outcome::Success(token)
},
Err(message) => {
if message == "ExpiredSignature".to_owned() {
return Outcome::Failure((Status::BadRequest,
JwTokenError::Expired))
}
return Outcome::Failure((Status::BadRequest,
JwTokenError::Invalid))
}
}
},
None => {
return Outcome::Failure((Status::BadRequest,
JwTokenError::Missing))
}
}
我们可以看到,我们已经将返回值包装在 Rocket 的 Outcome
中,这并不令人意外。当从头部解码或访问令牌失败时,我们也包括了我们的 enum
。
我们的 JwToken
结构体现在可以插入到我们的 Rocket 应用程序中,但我们必须记住移除旧的 Actix 实现以及所有对 Actix Web 框架的引用。我们还在 main.rs
文件中声明了 jwt
模块,代码如下:
mod jwt;
我们下一步是实现对数据库连接的 FromRequest
特性。在这个阶段,自己尝试实现数据库连接的 FromRequest
特性是个不错的主意。为了实现这一点,你不需要了解任何新的内容。
如果你尝试自己实现数据库连接的 FromRequest
特性,步骤应该如下。
首先,我们必须在 src/database.rs
文件中导入所需的 Rocket 结构体和特性,代码如下:
use rocket::http::Status;
use rocket::request::{self, Outcome, Request, FromRequest};
然后,我们必须定义结果。我们要么得到连接,要么得不到,所以我们的 enum
只有一个可能的错误,其形式如下:
#[derive(Debug)]
pub enum DBError {
Unavailable
}
然后,我们使用以下代码实现对数据库连接的 FromRequest
特性:
#[rocket::async_trait]
impl<'r> FromRequest<'r> for DB {
type Error = DBError;
async fn from_request(_: &'r Request<'_>)
-> Outcome<Self, Self::Error> {
match DBCONNECTION.db_connection.get() {
Ok(connection) => {
return Outcome::Success(DB{connection})
},
Err(_) => {
return Outcome::Failure((Status::BadRequest,
DBError::Unavailable))
}
}
}
}
前面的代码不应该令人惊讶太多;我们只是将获取数据库连接的现有逻辑与在JwToken
实现中定义的FromRequest
特性实现融合在一起。
注意
你可能已经注意到我们用[rocket::async_trait]
注解了我们的FromRequest
实现。我们使用这个注解是因为,在撰写本文时,Rust 中async
特性的稳定化不包括特性中的async
函数的支持。如果我们尝试在没有注解的情况下在特性中实现async
函数,我们将得到以下错误:
trait fns cannot be declared `async`
[rocket::async_trait]
注解使我们能够在特性实现中定义async
函数。我们不能简单地解构async
函数并拥有以下函数签名的原因有很多:
async fn from_request(_: &'r Request<'_>)
-> Pin<Box<dyn Future<Output
= Outcome<Self, Self::Error>>
+ Send + '_>> {
然而,这不会奏效,因为我们不能在特性函数中返回impl
特性,因为这不被支持。关于为什么特性中的async
函数难以实现的深入阅读,请访问以下博客文章:smallcultfollowing.com/babysteps/blog/2019/10/26/async-fn-in-traits-are-hard/
。
我们现在可以在main.rs
文件中的create
视图中实现我们的数据库连接。再次强调,这是一个很好的机会,让你尝试自己使用FromRequest
特性来实现数据库连接。
如果你尝试在create
视图中使用 Rocket 的FromRequest
特性,你的代码应该看起来如下:
#[post("/create/<title>")]
fn item_create(title: String, db: DB) -> Json<ToDoItems> {
let items = schema::to_do::table
.filter(schema::to_do::columns::title.eq(&title.as_ str()))
.order(schema::to_do::columns::id.asc())
.load::<Item>(&db.connection)
.unwrap();
if items.len() == 0 {
let new_post = NewItem::new(title, 1);
let _ = diesel::insert_into(schema::to_do::table)
.values(&new_post)
.execute(&db.connection);
}
return Json(ToDoItems::get_state(1));
}
如果我们再次运行我们的应用程序,然后点击create
端点,我们会看到我们的实现是有效的!这是一个启示,即我们的视图经过一些修改后可以复制粘贴到我们的 Rocket 应用程序中,从我们的 Actix Web 应用程序中。在下一节中,我们将把我们现有的视图集成到我们的 Rocket Web 应用程序中。
插入我们现有的视图
当涉及到我们的视图时,它们也是隔离的,我们可以通过一些小的修改将我们的视图复制到 Rocket 应用程序中,以回收我们在 Actix Web 应用程序中构建的视图。我们可以使用以下命令复制视图:
cp -r web_app/src/views rocket_app/src/views
通过这次复制,现在不用说我们也必须遍历并清除视图中的任何 Actix Web 框架的提及,因为我们没有使用它。一旦我们清除了视图中的任何 Actix Web 提及,我们就可以重构我们的现有代码,使其与 Rocket 框架兼容。我们将从我们的login
视图开始,因为这个视图接收 JSON 体并返回 JSON,在接下来的小节中讨论。
接受和返回 JSON
在我们更改视图之前,我们需要确保我们已经使用以下代码在src/views/auth/login.rs
文件中导入了所有需要的模块:
use crate::diesel;
use diesel::prelude::*;
use rocket::serde::json::Json;
use crate::database::DB;
use crate::models::user::user::User;
use crate::json_serialization::{login::Login,
login_response::LoginResponse};
use crate::schema::users;
use crate::jwt::JwToken;
我们可以看到,除了来自 Rocket 包的Json
结构体之外,没有太多变化。实现这些 Rocket 特性真的帮助我们切断了代码与 Actix 框架的联系,并连接到了 Rocket 框架,而无需更改实现这些特性的结构体的导入或使用方式。考虑到这一点,以下是我们登录视图的轮廓应该不会令人惊讶:
#[post("/login", data = "<credentials>", format = "json")]
pub async fn login<'a>(credentials: Json<Login>, db: DB) ->
Json<LoginResponse> {
. . .
}
我们可以看到,我们以与之前在 Actix 登录视图中相同的方式引用了我们传入的 JSON 体和数据库连接。主要的不同之处在于宏突出了数据是什么以及传入数据采取的格式。在login
视图中,我们有以下逻辑:
let username: String = credentials.username.clone();
let password: String = credentials.password.clone();
let users = users::table
.filter(users::columns::username.eq(username.as_str()))
.load::<User>(&db.connection).unwrap();
match users[0].clone().verify(password) {
true => {
let user_id = users[0].clone().id;
let token = JwToken::new(user_id);
let raw_token = token.encode();
let body = LoginResponse{token: raw_token.clone()};
return Json(body)
},
false => panic!("unauthorised")
}
我们可以看到,代码中唯一的区别是,我们不是返回多个不同的代码,而是简单地抛出一个错误。这种方法并不理想。在之前的构建中,Rocket 框架曾经使用一个简单的响应构建器,就像在 Actix 中一样。然而,在撰写本文时,Rocket 在其最近的版本中实施了很多破坏性更改。标准响应构建器现在根本不起作用,需要复杂的特性实现才能返回带有代码、正文和头值的响应。撰写本文时,文档和示例也有限。更多关于构建更高级响应的阅读材料可在进一步阅读部分找到。
现在我们已经定义了login
视图,我们可以继续到我们的logout
视图,该视图返回原始 HTML。
返回原始 HTML
如果你还记得,我们的注销机制返回的是原始 HTML,它在浏览器中运行 JavaScript 来移除我们的令牌。当涉及到 Rocket 时,返回原始 HTML 非常简单。在我们的src/views/auth/logout.rs
文件中,整个代码如下所示:
use rocket::response::content::RawHtml;
#[get("/logout")]
pub async fn logout() -> RawHtml<&'static str> {
return RawHtml("<html>\
<script>\
localStorage.removeItem('user-token'); \
window.location.replace(
document.location.origin);\
</script>\
</html>")
}
我们可以看到,它返回的是一个字符串,就像在之前的 Actix Web 视图中一样,但这个字符串被RawHtml
结构体包裹。现在我们可以开始更新我们的待办事项操作视图,以便我们的用户可以操作待办事项,如下一节所述。
返回带有 JSON 的状态
到目前为止,我们已经返回了 JSON 和原始 HTML。然而,请记住,我们的待办事项应用程序返回带有不同状态的 JSON。为了探索这个概念,我们可以回顾一下src/views/to_do/create.rs
文件中的create
视图,在那里我们必须返回一个带有 JSON 体的创建状态。首先,所有我们的导入都与之前相同,除了来自 Rocket 框架的状态和 JSON 结构体,如下所示:
use rocket::serde::json::Json;
use rocket::response::status::Created;
使用这些导入,我们可以用以下代码定义create
视图函数的轮廓:
#[post("/create/<title>")]
pub async fn create<'a>(token: JwToken, title: String, db: DB)
-> Created<Json<ToDoItems>> {
. . .
}
我们可以看到,我们的返回值是Created
结构体,其中包含Json
结构体,而Json
结构体又包含ToDoItems
结构体。我们还可以看到,我们的 JWT 身份验证也是以相同的方式在视图中实现的,因为,再次强调,我们正在实现 Rocket 特性。我们的数据库逻辑与之前的视图相同,如下面的代码所示:
let items = to_do::table
.filter(to_do::columns::title.eq(&title.as_str()))
.order(to_do::columns::id.asc())
.load::<Item>(&db.connection)
.unwrap();
if items.len() == 0 {
let new_post = NewItem::new(title, token.user_id);
let _ = diesel::insert_into(to_do::table).values(&new_post)
.execute(&db.connection);
}
如果任务已经在数据库中不存在,我们将插入我们的新待办事项。一旦我们这样做,我们就获取我们系统的状态,并使用以下代码返回它:
let body = Json(ToDoItems::get_state(token.user_id));
return Created::new("").body(body)
空字符串是位置。这可以留空,不会产生任何后果。然后我们使用状态的body
函数附加我们的主体。这就是我们想要运行create
视图所需的所有内容。
当涉及到我们的待办任务的其它视图时,它们都将是我们为create
视图所做内容的某种变体。所有待办事项视图都需要采取以下步骤:
-
使用 JWT 进行认证。
-
连接到数据库。
-
从 JSON 主体中获取数据以及/或从 JWT 获取用户数据。
-
对数据库中的数据进行一些操作(除了
GET
视图)。 -
返回用户的数据库状态。
在看到我们对create
视图所做的一切之后,你应该能够处理所有其他视图,使它们与 Rocket 框架兼容。我们已经涵盖了进行这些更改所需的所有内容。在书中详细说明这些更改会导致不必要的重复步骤被执行,过度膨胀它。这些更改可在书的 GitHub 仓库中找到。
一旦完成了待办事项视图,我们就可以继续到最后一个需要的视图,即创建用户,我们必须根据结果返回不同的状态。
返回多个状态
当涉及到创建用户时,我们只返回创建状态码或冲突状态码,不再返回其他内容。我们不需要返回数据,因为刚刚创建用户的个人已经知道用户详情。在 Rocket 中,我们可以不返回任何 body 而返回多个不同的状态码。我们可以在src/views/to_do/create.rs
文件中探索这个概念,但首先,我们必须确保以下内容被导入:
use crate::diesel;
use diesel::prelude::*;
use rocket::serde::json::Json;
use rocket::http::Status;
use crate::database::DB;
use crate::json_serialization::new_user::NewUserSchema;
use crate::models::user::new_user::NewUser;
use crate::schema::users;
现在我们已经拥有了所有需要的东西,我们可以使用以下代码定义视图的轮廓:
#[post("/create", data = "<new_user>", format = "json")]
pub async fn create_user(new_user: Json<NewUserSchema>, db: DB)
-> Status {
. . .
}
在这里,我们可以看到除了返回一个单一的Status
结构体之外,没有新的内容。我们的数据库逻辑如下所示:
let name: String = new_user.name.clone();
let email: String = new_user.email.clone();
let password: String = new_user.password.clone();
let new_user = NewUser::new(name, email, password);
let insert_result = diesel::insert_into(users::table)
.values(&new_user).execute(&db.connection);
我们使用以下代码从两种可能的状态中返回一个状态:
match insert_result {
Ok(_) => Status::Created,
Err(_) => Status::Conflict
}
我们的视图已经完整。现在我们可以继续到下一节,将我们的视图注册到 Rocket 应用程序中。
将我们的视图注册到 Rocket
在我们继续到src/main.rs
文件之前,我们必须确保我们的视图函数对src/main.rs
可用。这意味着要遍历每个视图模块中的所有mod.rs
文件,并将定义这些视图的函数声明为公共的。然后我们可以继续到src/main.rs
文件,并确保以下内容被导入:
#[macro_use] extern crate rocket;
#[macro_use] extern crate diesel;
use rocket::http::Header;
use rocket::{Request, Response};
use rocket::fairing::{Fairing, Info, Kind};
macro_use
声明不应该令人惊讶;然而,我们导入 Rocket 结构体来定义我们的 CORS 策略。有了这些 crate 导入,我们现在必须确保以下模块已被声明:
mod schema;
mod database;
mod json_serialization;
mod models;
mod to_do;
mod config;
mod jwt;
mod views;
这些模块对你来说都应该很熟悉。然后我们必须使用以下代码导入我们的视图:
use views::auth::{login::login, logout::logout};
use views::to_do::{create::create, delete::delete,
edit::edit, get::get};
use views::users::create::create_user;
我们现在已经导入了所有需要的内容。在声明服务器上的视图之前,我们需要定义我们的 CORS 策略。这是通过声明一个没有字段的 struct 来实现的。然后我们为这个 struct 实现Fairing
特质,允许流量。Fairings 本质上定义了中间件。关于 Fairings 的更多信息可以在进一步阅读部分找到。我们的 CORS 策略可以用以下代码定义:
pub struct CORS;
#[rocket::async_trait]
impl Fairing for CORS {
fn info(&self) -> Info {
Info {
name: "Add CORS headers to responses",
kind: Kind::Response
}
}
async fn on_response<'r>(&self, _request: &'r Request<'_>,
response: &mut Response<'r>) {
response.set_header(Header::new(
"Access-Control-Allow-Origin", "*"));
response.set_header(Header::new(
"Access-Control-Allow-Methods",
"POST, GET, PATCH, OPTIONS"));
response.set_header(Header::new(
"Access-Control-Allow-Headers", "*"));
response.set_header(Header::new(
"Access-Control-Allow-Credentials",
"true"));
}
}
到这本书的这一部分,我们现在已经熟悉了 CORS 的概念以及如何实现 Rocket 特质。前面的代码不需要详细说明。
我们现在有了将视图挂载到服务器所需的所有内容,以下代码所示:
#[launch]
fn rocket() -> _ {
rocket::build().mount("/", routes![index, hello, bye])
.mount("/v1/item/", routes![create, delete,
edit, get])
.mount("/v1/auth/", routes![login, logout])
.mount("/v1/user/", routes![create_user])
.attach(CORS)
.manage(CORS)
}
再次强调,这里不需要任何解释。你可能已经注意到,我们已经开始简单地展示代码,几乎不进行解释。这是好事,因为我们已经熟悉了我们正在使用的构建块。不用担心——我们已经完成了主要 Rocket 应用程序的构建,因为它将运行并完成我们所需要的一切。我们可以手动测试这个。然而,这将花费时间且容易出错。
记住,我们是用 Postman 在 Newman 中构建我们的测试的!在下一节中,我们将使用现有的测试流程,通过几个命令来测试所有我们的端点。
插入我们现有的测试
因为我们已经在测试流程中使用了 Newman,所以我们不必担心与我们的 Web 框架选择的高耦合。首先,我们需要使用以下命令将scripts
目录中的测试复制过来:
cp -r web_app/scripts rocket_app/scripts
然而,在运行之前,我们必须为我们的login
视图添加一个GET
方法,如下所示:
#[get("/login", data = "<credentials>", format = "json")]
pub async fn login_get<'a>(credentials: Json<Login>, db: DB)
-> Json<LoginResponse> {
// same logic as in the login view
}
然后,我们需要将这个视图导入到src/main.rs
文件中,并在我们的服务器auth
挂载中声明它。我们现在可以运行我们的完整测试,如下所示:
sh scripts/run_test_pipeline.sh
这将运行我们的完整流程,并给出以下结果:
图 12.3 – 我们完整测试流程的结果
我们可以看到,在 64 次检查中,只有 3 次失败了。如果我们继续向下滚动,我们可以看到错误仅发生因为我们为create
视图返回了不同的响应代码,如下所示:
# failure detail
1. AssertionError response is ok
expected response to have status
code 200 but got 201
at assertion:0 in test-script
inside "1_create"
2. AssertionError response is ok
expected response to have status
code 200 but got 201
at assertion:0 in test-script
inside "2_create"
3. AssertionError response is ok
expected response to have status
code 200 but got 201
at assertion:0 in test-script
inside "3_create"
在登录、认证、迁移以及每一步之间数据库中的数据状态方面,其他所有事情的表现都正如我们所预期的那样。
摘要
在本章中,我们介绍了复制我们的待办事项应用程序所需的主要概念。我们构建并运行了一个 Rocket 服务器。然后我们定义了路由并为我们的服务器建立了数据库连接。之后,我们探讨了中间件并构建了认证和数据处理,使用守卫来处理我们的视图。通过这些,我们创建了一个利用了本书中所有内容的视图。
我们在这里获得的是对我们在这本书中构建的模块代码的更深刻的欣赏。尽管我们回顾的一些概念自本书开始以来尚未触及,但这些模块是隔离的,只做一件事,并且做了它们标签所提议的事情。正因为如此,它们可以轻松地复制并用于完全不同的框架。我们的测试流程也派上了用场,立即确认我们的 Rocket 应用程序的行为与我们的 Actix Web 应用程序相同。考虑到这一点,我们的 Rocket 应用程序可以无缝集成到我们的构建和部署流程中,而不是我们的 Actix Web 应用程序。
在下一章中,我们将介绍构建 Web 应用程序的最佳实践,从而产生一个干净的 Web 应用程序存储库。在这里,你不仅将学习如何从测试和配置的角度来结构 Web 应用程序存储库,还将学习如何将 Web 应用程序打包到 Docker 中作为无依赖的发行版,从而产生大约 50 MB 的微型 Docker 镜像。
进一步阅读
-
火箭文档:
rocket.rs/
-
《使用 Rocket 进行 Rust 网络开发:使用 Rocket 框架开始 Rust 网络开发之旅的实用指南》,Karuna Murti(2022 年),Packt 出版
问题
-
我们亲身体验到了隔离过程的重要性,例如我们的测试流程。观察我们的测试流程,是否有可以移除的依赖关系,以进一步解耦流程,使其甚至不依赖于我们测试 Rust 服务器?
-
我们如何使用 Actix 和 Rocket 等框架将模块中的所有功能附加和分离?
-
我们如何在 AWS 上部署我们的 Rocket 服务器?
答案
-
目前,我们的测试流程依赖于 Diesel 进行迁移。我们可以简单地构建自己的 SQL 脚本,存放在定义我们数据库版本版本的目录中。这将完全解耦我们的测试流程与我们要测试的服务器。如果服务器具有相同的端点和访问 PostgreSQL 数据库的权限,它可以使用我们的流程进行测试,无论服务器是用什么语言编写的。
-
如果模块简单且接口良好,我们只需将其复制并导入我们想要使用它的地方。如果模块依赖于框架的高级功能,我们必须删除框架的特质实现,并为新的一个实现特质。
-
我们需要注意,我们的测试流程是在没有任何修改的情况下运行了 Rocket 服务器。这是因为我们使用了相同的配置,并使用 Cargo 来构建和运行应用程序。我们只需将构建指向 Rocket 应用程序,并将我们的 Dockerfile 复制到 Rocket 应用程序中。然后,我们的构建过程将在 Docker 中构建 Rocket 应用程序并将其部署到 Docker Hub。我们的部署过程将然后从 Docker Hub 拉取镜像并部署它。我们知道我们的端点是相同的,并且以相同的方式表现,所以集成应该不会痛苦。
第十三章:清洁 Web 应用仓库的最佳实践
在整本书中,我们一直在逐步构建我们的应用程序,并添加自动化脚本和工具来帮助我们测试和部署应用程序。然而,尽管这条路径对学习工具和概念很有用,但前几章中我们项目的结构并不适用于生产环境。
在本章中,我们将创建一个新的仓库,将我们的 Rust 代码提升到该仓库中,然后为我们的应用程序构建清洁的数据库迁移、测试和优化的 Docker 构建,以便可以顺利部署。
在本章中,我们将涵盖以下主题:
-
清洁仓库的一般布局
-
从环境变量获取我们的配置
-
设置本地开发数据库
-
在 Postman 测试中管理变量
-
构建 distroless 微型服务器 Docker 镜像
-
构建清洁的测试管道
-
使用 GitHub Actions 构建持续集成
到本章结束时,你将能够构建一个包含脚本、Docker 构建和测试的仓库结构,这将使开发变得顺畅,并易于添加新功能。你还将能够为应用程序构建distroless Docker 镜像,使它们更安全,并将我们的服务器镜像大小从 1.5 GB 降低到 45 MB!
技术要求
在本章中,我们将参考在第九章中定义的代码部分,测试我们的应用程序端点和组件。这可以在以下 URL 中找到:github.com/PacktPublishing/Rust-Web-Programming-2nd-Edition/tree/main/chapter09
。
本章的代码可以在github.com/PacktPublishing/Rust-Web-Programming-2nd-Edition/tree/main/chapter13
找到。
清洁仓库的一般布局
当谈到清洁布局时,我们必须在仓库中有单一焦点的目录,就像我们模块化的独立代码一样。在本章采用的清洁方法中,我们的仓库将具有以下布局:
├── Cargo.toml
├── README.md
├── .dockerignore
├── .gitignore
├── .github
│ . . .
├── builds
│ . . .
├── database
│ . . .
├── docker-compose.yml
├── scripts
│ . . .
├── src
│ . . .
└── tests
. . .
这些文件和目录有以下职责:
-
Cargo.toml
: 定义 Rust 构建的需求。 -
README.md
: 访问时在 GitHub 页面上渲染,告诉读者项目的内容以及如何与项目交互。 -
.dockerignore
: 告诉 Docker 构建在复制目录和文件到 Docker 镜像时忽略什么。 -
.gitignore
: 告诉 git 在提交代码到 git 仓库时忽略什么。 -
.github
: 一个存放 GitHub Actions 工作流程的目录。 -
builds
: 一个目录,根据芯片架构存放不同的 Docker 构建。 -
database
: 一个存放处理数据库迁移所需的所有脚本和 Docker 构建的目录。 -
docker-compose.yml
: 定义了运行开发构建所需的容器。 -
scripts
: 一个存放运行开发服务器或测试所需的 Bash 脚本的目录。 -
src
: 一个存放构建服务器所需所有 Rust 代码的目录。 -
tests
: 一个存放docker-compose
配置和 Postman 集合的目录,以实现完全集成的测试。我们必须记住,单元测试是在src
目录中编写的,并且在执行 Cargo 中的test
命令时条件编译。在标准构建和发布构建中,单元测试被排除。
现在我们已经知道了我们的仓库结构是什么样的,我们可以添加一些规则和文件来确保我们的构建和 git 提交以完全正确的方式进行。在项目开始时就做这件事是个好主意,以避免意外地将不想要的代码添加到 git 历史或 Docker 构建中。
首先,我们将从.gitignore
文件开始,该文件定义了以下规则:
/target/
Cargo.lock
# These are backup files generated by rustfmt
**/*.rs.bk
# jetbrains
.idea
# mac
.DS_Store
在这里,我们可以看到我们避开了target
目录中的任何内容,当执行 Rust 构建和测试时,这个目录会被填满很多文件。这些文件对项目的开发没有任何贡献,并且会迅速增加你项目的大小。如果你喜欢使用 JetBrains 或者使用 Mac,我已经添加了.idea
和.DS_Store
,因为这些文件可能会悄悄地进入仓库;它们不是运行任何 Web 应用程序代码所必需的。
现在,让我们看看我们的.dockerignore
文件,它有以下规则:
./tests
./target
./scripts
./database
.github
这些规则应该是有意义的。我们不希望将我们的构建文件、脚本、数据库迁移或 GitHub 工作流程添加到我们的 Docker 构建中。
我们现在已经定义了所有关于仓库的规则。在我们进入下一节之前,我们不妨定义一下我们应用程序的一般布局。在这里,我们可以使用以下命令将现有的待办事项应用程序的源 Rust 代码从现有仓库提升到我们的新仓库:
cp -r web_app/src ./clean_web_app/src
如果你已经在运行前面的命令之前在干净的 app 仓库中创建了一个src
目录,你必须删除干净 app 仓库中的src
目录;否则,你将有两个src
目录,其中复制的src
位于现有的src
内部。我们的Cargo.toml
文件与现有的 Web 应用程序有相同的依赖项;然而,我们可以使用以下代码更改其名称:
[package]
name = "clean_app"
version = "0.1.0"
edition = "2021"
让我们检查以下test
命令是否可以提升我们的代码:
cargo test
这应该给我们以下输出:
running 9 tests
test to_do::structs::base::base_tests::new ... ok
test to_do::structs::done::done_tests::new ... ok
test to_do::structs::pending::pending_tests::new ... ok
test jwt::jwt_tests::get_key ... ok
test jwt::jwt_tests::decode_incorrect_token ... ok
test jwt::jwt_tests::encode_decode ... ok
test jwt::jwt_tests::test_no_token_request ... ok
test jwt::jwt_tests::test_false_token_request ... ok
test jwt::jwt_tests::test_passing_token_request ... ok
这个输出显示我们的代码已编译,并且我们的Cargo.toml
文件定义正确。现在我们已经确认了我们的单元测试已经通过,这让我们有了一些保证,我们的代码正在工作。然而,我们如何定义我们的配置,在将应用程序部署到云时,会给我们带来一些障碍。在下一节中,我们将通过使用环境变量来配置我们的 Web 应用程序,来简化我们的部署。
从环境变量获取我们的配置
到目前为止,我们一直在从 YML 文件中加载配置变量。这有几个问题。首先,我们必须将这些文件移动到我们的部署位置。此外,文件与 Kubernetes 等编排工具不高效。Kubernetes 使用 ConfigMaps,这实际上为它们运行的每个容器定义了环境变量。环境变量也与 Secret Manager 和 AWS 凭证等工具很好地协同工作。我们还可以直接在docker-compose
中直接覆盖环境变量。考虑到所有这些优势,我们将把配置值从文件切换到环境变量。为了映射我们从文件中实现了配置变量的位置,我们只需要删除我们的src/config.rs
文件和main.rs
文件中该config
模块的模块声明。然后,我们可以再次运行cargo test
命令以获得以下输出:
--> src/database.rs:12:12
|
12 | use crate::config::Config;
| ^^^^^^ could not find `config` in the crate root
error[E0432]: unresolved import `crate::config`
--> src/jwt.rs:9:12
|
9 | use crate::config::Config;
| ^^^^^^ could not find `config` in the crate root
error[E0432]: unresolved import `crate::config`
--> src/counter.rs:4:12
|
4 | use crate::config::Config;
| ^^^^^^ could not find `config` in the crate root
在这里,我们在jwt
、database
和counter
模块中使用了config
。这是有意义的,因为我们使用这些模块时必须连接到外部结构。为了修复破坏性的导入,我们只需要将配置引用替换为环境变量引用。为了演示这一点,我们可以使用src/counter.rs
文件。首先,我们必须删除以下代码行:
...
use crate::config::Config;
...
let config = Config::new();
let redis_url = config.map.get("REDIS_URL")
.unwrap().as_str()
.unwrap().to_owned();
...
然后,我们必须用以下代码替换前面的代码行:
...
use std::env;
...
let redis_url = env::var("REDIS_URL").unwrap();
...
我们也可以按照这种格式为JWT
和database
模块进行操作。在JWT
模块中,有一个不是字符串的变量,必须将其转换为整数,即expire minutes
。这可以通过以下代码行完成:
let minutes = env::var("EXPIRE_MINUTES").unwrap()
.parse::<i64>()
.unwrap();
如果我们现在运行cargo test
命令,我们将得到以下输出:
running 9 tests
test jwt::jwt_tests::encode_decode ... FAILED
test jwt::jwt_tests::get_key ... FAILED
test jwt::jwt_tests::decode_incorrect_token ... FAILED
test to_do::structs::pending::pending_tests::new ... ok
test to_do::structs::base::base_tests::new ... ok
test to_do::structs::done::done_tests::new ... ok
test jwt::jwt_tests::test_passing_token_request ... FAILED
test jwt::jwt_tests::test_no_token_request ... ok
test jwt::jwt_tests::test_false_token_request ... FAILED
我们的测试运行了,所以我们知道编译代码是成功的。然而,一些 JWT 测试失败了。如果我们继续向下滚动日志,我们将看到以下错误:
---- jwt::jwt_tests::encode_decode stdout ----
thread 'jwt::jwt_tests::encode_decode' panicked at
'called `Result::unwrap()` on an `Err` value: NotPresent',
src/jwt.rs:52:50
这告诉我们我们的环境变量不存在。考虑到这个问题出现在JWT
模块中,我们可以确信它也会在database
和counter
模块中失败。因此,在我们运行或测试我们的应用程序之前,我们需要定义这些环境变量。我们可以通过构建一个包含以下代码的scripts/run_unit_tests.sh
脚本来为我们的应用程序构建一个带有环境变量的测试管道:
#!/usr/bin/env bash
# navigate to directory
SCRIPTPATH="$( cd "$(dirname "$0")" ; pwd -P )"
cd $SCRIPTPATH
cd ..
export SECRET_KEY="secret"
export EXPIRE_MINUTES=60
cargo test
在这里,我们导航到根目录,导出环境变量,然后运行test
命令。运行前面的脚本会导致所有单元测试通过。
注意
虽然我喜欢尽可能多地将内容放入 Bash 脚本中,因为它像文档一样让其他开发者看到所有移动的部分,但还有其他方法。例如,你可能会发现前面概述的方法很笨拙,因为这个方法与运行标准的cargo test
命令不同。其他方法包括以下:
-
手动将变量注入到测试中
-
使用
dotenv
crate 从文件中加载环境变量(https://github.com/dotenv-rs/dotenv) -
为环境变量设置合理的默认值
你会如何创建运行开发服务器的脚本?这可能是一个尝试自己编写脚本的绝佳时机。如果你已经尝试编写了脚本,你的 scripts/run_dev_server.sh
脚本应该看起来像以下代码:
#!/usr/bin/env bash
# navigate to directory
SCRIPTPATH="$( cd "$(dirname "$0")" ; pwd -P )"
cd $SCRIPTPATH
cd ..
export SECRET_KEY="secret"
export EXPIRE_MINUTES=60
export DB_URL="postgres://username:password@localhost:5433/to_do"
export REDIS_URL="redis://127.0.0.1/"
cargo run
然而,如果我们尝试运行前面的脚本,它将会崩溃,因为我们无法连接到 Redis 数据库。我们需要在 docker-compose.yml
文件中定义我们的开发服务,以下代码:
version: "3.7"
services:
postgres:
container_name: 'to-do-postgres'
image: 'postgres:11.2'
restart: always
ports:
- '5433:5432'
environment:
- 'POSTGRES_USER=username'
- 'POSTGRES_DB=to_do'
- 'POSTGRES_PASSWORD=password'
redis:
container_name: 'to-do-redis'
image: 'redis:5.0.5'
ports:
- '6379:6379'
现在我们已经定义了开发服务,我们可以启动 docker-compose
并运行 run_dev_server.sh
脚本,从而使开发服务器运行。然而,如果我们尝试执行任何请求,服务器将会崩溃。这是因为我们没有在数据库上执行迁移。在下一节中,我们将对开发数据库执行迁移。
设置本地开发数据库
当谈到迁移时,有一个优势是将我们使用的编程语言与迁移解耦。在过去,我不得不从一种语言切换到另一种语言,并希望迁移的实现没有与语言耦合。这也是一个部署问题。例如,在 Kubernetes 中,部署新的服务器或更新可能需要运行迁移。理想情况下,你希望通过我们所说的 init Pods 自动运行迁移。这是一个在主服务器部署之前启动并执行的容器。这个 init Pod 可以执行数据库迁移命令。然而,如果 init Pod 需要像 Rust 这样的东西来执行迁移,这可能会大大增加 init Pod 的大小。因此,我构建了一个仅依赖于 psql
和 wget
库的开源 Bash 工具。它可以创建新的迁移并使数据库升级或降级。然而,必须强调的是,这个工具并不适用于所有用途。引用我编写的迁移工具的文档(github.com/yellow-bird-consult/build_tools/tree/develop#use-yb-database-migrations-if-you-have-the-following
),如果你有以下情况,你应该选择在项目中使用迁移工具:
-
轻量级的迁移吞吐量:迁移没有时间戳;它们只是简单地编号。工具的设计简单,便于跟踪正在发生的事情。在微服务中的轻量级应用是一个理想的环境。
-
经过充分测试的代码:没有防护措施。如果你的 SQL 脚本中存在错误,你的数据库将会被部分运行的迁移所损害。在实施数据库生产环境的迁移之前,你应该在 Docker 数据库上设置测试环境。
-
你打算自己编写 SQL:因为这个工具完全与任何编程语言解耦,你必须为每个迁移编写自己的 SQL 脚本。这并不像你想象的那么可怕,并且给你更多的控制权。
-
你想要完全控制:SQL 迁移和简单的实现实际上是在一个 Bash 脚本中定义的。这种简单的实现给你 100%的控制权。没有任何东西阻止你打开你的数据库在 GUI 中并直接更改版本号或手动运行迁移的特定部分。
既然我们已经知道了我们将要面对什么,我们可以导航到database
目录并使用以下命令安装迁移工具:
wget -O - https://raw.githubusercontent.com/yellow-bird-consult
/build_tools/develop/scripts/install.sh | bash
这将在你的家目录中安装几个 Bash 脚本。你可能需要刷新你的终端来获取命令别名。并非所有操作系统都支持命令别名。如果你的命令别名有效,我们可以使用以下命令创建一组新的迁移:
yb db init
然而,如果别名不起作用,你可以通过 Bash 脚本来运行所有命令,因为每个 Bash 脚本都是 100%自包含的。我们只需使用以下命令传递相同的参数:
bash ~/yb_tools/database.sh db init
使用init
命令,我们得到以下结构:
├── database_management
│ └── 1
│ ├── down.sql
│ └── up.sql
这与柴油迁移工具相同,但只是用纯数字表示。我们有两个来自待办应用的迁移,因此我们可以使用以下命令来创建它们:
cp -r database_management/1 database_management/2
一旦我们完成这个操作,我们就可以创建我们的迁移文件。database_management/1/up.sql
文件使用以下代码创建to_do
表:
CREATE TABLE to_do (
id SERIAL PRIMARY KEY,
title VARCHAR NOT NULL,
status VARCHAR NOT NULL,
date timestamp NOT NULL DEFAULT NOW()
)
database_management/1/down.sql
文件使用以下代码删除to_do
表:
DROP TABLE to_do
database_management/2/up.sql
文件使用以下代码创建user
表并将所有现有项目链接到一个占位符用户:
CREATE TABLE users (
id SERIAL PRIMARY KEY,
username VARCHAR NOT NULL,
email VARCHAR NOT NULL,
password VARCHAR NOT NULL,
unique_id VARCHAR NOT NULL,
UNIQUE (email),
UNIQUE (username)
);
INSERT INTO users (username, email, password, unique_id)
VALUES ('placeholder', 'placeholder email',
'placeholder password', 'placeholder unique id');
ALTER TABLE to_do ADD user_id integer default 1
CONSTRAINT user_id REFERENCES users NOT NULL;
database_management/2/down.sql
文件使用以下代码删除users
表:
ALTER TABLE to_do DROP COLUMN user_id;
DROP TABLE users
我们现在已经准备好了迁移。然而,我们需要连接到我们的数据库以获取信息和执行迁移。我们可以启动我们的docker-compose
来启动开发数据库并使其运行。一旦完成,我们必须在环境变量中定义我们的数据库 URL。迁移工具会在环境变量中查找 URL。然而,如果当前工作目录中有一个.env
文件,迁移工具也会加载这个文件中的所有变量。在我们的database_management/.env
文件中,我们可以使用以下代码定义数据库 URL:
DB_URL="postgres://username:password@localhost:5433/to_do"
现在我们数据库正在运行,并且我们已经定义了 URL,我们可以使用以下命令获取数据库当前的迁移级别:
# with alias
yb db get
# without alias
bash ~/yb_tools/database.sh db get
目前,我们应该得到一个-1
。这意味着数据库上根本没有任何迁移版本表。如果有,但数据库上没有执行任何迁移,版本将是0
。如果有任何迁移,则响应将是当前所在的迁移编号。当使用构建工具执行数据库上的命令时,我们可以使用以下db
命令:
-
set
: 如果没有迁移版本表,则创建一个迁移版本表 -
up
: 通过应用up.sql
脚本向上迁移一个版本 -
down
: 通过应用down.sql
脚本向下迁移一个版本 -
new
: 如果你在最新版本上,则创建一个新的迁移文件夹 -
rollup
: 如果没有迁移版本表,则创建一个新的迁移版本表,然后从数据库的当前版本开始循环database_management
目录中的所有版本
我们将使用以下命令运行rollup
命令:
# with alias
yb db rollup
# without alias
bash ~/yb_tools/database.sh db rollup
这将在数据库上执行迁移。如果你运行get
命令,你会看到数据库的版本现在是2
。我们的数据库现在已准备好被我们的应用程序查询。
注意
迁移也可以使用sqlx-cli
crate 来实现,该 crate 可以在以下链接找到:https://crates.io/crates/sqlx-cli。
然而,需要 Cargo 来安装sqlx-cli
,这将使执行这些迁移的 init Pods 创建变得复杂。
不同于随机地发出请求,在下一节中,我们将细化我们的 Postman 测试,以便我们可以运行一系列请求并检查我们的应用程序是否以我们想要的方式运行。
在 Postman 测试中管理变量
在第九章中,测试我们的应用程序端点和组件,我们构建了一个 Postman 集合。然而,它有点粗糙,因为我们不得不依赖 Python 将新令牌加载到 Newman 集合中。虽然使用 Python 作为进程之间的粘合代码是一项有用的技能,但我们的旧版本使用 Python 准备 Newman 集合并不是最干净的方法。在我们的集合开始时,我们将添加两个新的请求。第一个请求将创建一个用户,以下是一些参数:
图 13.1 – 创建用户 Postman 请求
使用创建用户请求,我们在 Postman 的测试选项卡中得到了以下 JavaScript:
pm.test("response is created", function () {
pm.response.to.have.status(201);
});
通过这个,我们的集合的第一个请求将创建用户,如果请求未成功,将抛出一个错误。然后,我们可以为我们的集合创建第二个请求,该请求是登录,以下是一些参数:
图 13.2 – 登录 Postman 请求
使用这个请求,我们必须检查响应并将集合变量设置为令牌,这是通过在 Postman 的测试选项卡中运行以下 JavaScript 来完成的:
var result = pm.response.json()
pm.test("response is ok", function () {
pm.response.to.have.status(200);
});
pm.test("response returns token", function () {
pm.collectionVariables.set("login_token", result["token"]);
})
一旦我们设置了我们的收集变量,我们就能在整个收集过程中引用我们的令牌。为此,我们必须更新整个收集的授权,以确保我们的新令牌值能传播到所有我们的请求中。要访问授权设置,请点击一个create
请求的头部以获取以下内容:
图 13.3 – 请求头部
在上一张截图的右侧,我们可以看到一个转到授权按钮。如果我们点击这个按钮,我们会得到以下内容:
图 13.4 – 配置授权
我们可以看到值已经更改为{{login_token}}
。如果我们保存并然后导出收集 JSON 文件到我们仓库中的tests
目录,属于{{login_token}}
的值将被插入到收集 JSON 文件中。
现在我们有一个 Postman 收集,在登录请求后无需依赖 Python 将流程粘合在一起,它会自动更新一个新令牌。这要干净得多;然而,我们想要确保我们的测试管道的其余部分尽可能地模仿生产设置。在下一节中,我们将构建包含我们的应用程序的 Docker 镜像,其大小是之前章节中服务器镜像大小的几分之一。
构建无依赖的微型服务器 Docker 镜像
在前面的章节中,我们的服务器 Docker 镜像大约在 1.5 GB 左右。这相当大,当我们想要在服务器或其他开发者上分发我们的 Rust 镜像时并不理想。请注意,当镜像运行时,我们可以在 Docker 容器中访问一个 shell。这在开发中很有用,但在生产中并不理想,因为如果有人设法访问 Docker 容器,他们就能在其中四处查看并运行命令。如果服务器的权限没有锁定,黑客甚至可以在你的集群上开始运行命令。我见过通过这种方法发生的加密劫持,其中黑客利用 AWS 账户所有者的成本启动了大量挖矿 Pods。
我们将通过使用 distroless 镜像来解决这些问题。这些 distroless 镜像体积小巧,没有 shell。因此,如果有人设法访问我们的服务器,他们将无法做任何事情,因为没有 shell。我们将能够将我们的镜像大小从 1.5 GB 减少到 45 MB!这是我们想要的。然而,在我们开始构建我们的 distroless 镜像之前,我们必须知道 distroless 镜像上几乎没有东西。这意味着如果我们编译我们的应用程序并将其放入 distroless 镜像中,它将无法工作。例如,如果我们连接到数据库,我们需要在 distroless 镜像中包含libpq
库。由于 distroless 镜像不包含库,镜像将无法运行,因为我们的静态二进制文件将无法定位到libpq
库。
我们知道我们的 1.5 GB 镜像可以运行,因为它包含了所有东西,甚至厨房用具。我们可以使用我们的 1.5 GB 来检查静态二进制文件在镜像中包含的依赖项。我们可以通过移动到我们的deployment
目录来实现,我们在那里编写了代码来部署我们的应用程序到 AWS,并在那里启动docker-compose
。一旦这个运行起来,我们可以使用以下命令来检查我们的容器:
docker container ls
这将给出以下输出:
CONTAINER ID IMAGE . . .
0ae94ab0bbc5 nginx:latest . . .
6b49526250e3 deployment_rust_app. . . .
9f4dcdc8a455 redis:5.0.5 . . .
您的 ID 将不同,但我们将使用这些 ID 通过以下命令 SSH 进入我们的 Rust 应用程序:
docker exec -it 6b49526250e3 /bin/bash
这将打开一个交互式 shell,以便我们可以导航 Docker 容器。在这里,我们必须记住,静态二进制文件——即 Rust 服务器——被称为web_app
,并且它位于根目录中,所以我们不需要在容器内任何地方移动。我们可以使用以下命令列出依赖项:
ldd web_app
这将给出以下输出:
linux-vdso.so.1 (0x0000ffffb8a9d000)
libpq.so.5 => /usr/lib/aarch64-linux-gnu/libpq.so.5
libgcc_s.so.1 => /lib/aarch64-linux-gnu/libgcc_s.so.1
libpthread.so.0 => /lib/aarch64-linux-gnu/libpthread.so.0
libm.so.6 => /lib/aarch64-linux-gnu/libm.so.6
. . .
总共有 29 个依赖项。在列表的左侧是库的名称。在列表的右侧是库的路径。我们可以看到,数据库libpq
库需要与其他库一起使用。您的路径可能不同。这是因为我在运行这个镜像的 MacBook M1 上,它有一个 ARM 芯片架构。如果您没有这个,那么您的路径将是x86_64-linux-gnu
而不是aarch64-linux-gnu
。这是可以的——我们将在 GitHub 在线仓库中提供两个 Docker 文件。
在我们的 Docker 构建过程中,我们必须将这些库复制到我们的 distroless 镜像中。在我们的clean_web_app/builds
目录中,我们必须创建两个文件:aarch64_build
和x86_64_build
。这两个文件本质上都是相同的 Dockerfile,但库的引用不同。在撰写本文时,我希望有一种更智能的方法可以在一个 Dockerfile 中实现不同芯片的构建;然而,Docker 构建在传递变量方面非常糟糕,因为每个步骤都是隔离的,并且条件逻辑最多只能有限地使用。直接使用两个不同的文件会更简单。此外,如果构建在未来发生变化,那么两个不同芯片的构建将是解耦的。在我们的clean_web_app/builds/arch_build
文件中,我们必须获取 Rust 镜像,安装数据库库,复制要编译的应用程序代码,并定义我们正在进行的构建类型:
FROM rust:1.62.1 as build
RUN apt-get update
RUN apt-get install libpq5 -y
WORKDIR /app
COPY . .
ARG ENV="PRODUCTION"
RUN echo "$ENV"
我们可以看到,环境默认设置为"PRODUCTION"
。如果发生意外且环境未定义,则默认应为"PRODUCTION"
。在测试构建中意外花费更长的时间编译要好于意外将非生产服务器部署到生产环境中。然后,如果它是生产环境,我们使用发布标志进行编译,如果不是使用发布标志编译,则将静态二进制文件切换到发布目录:
RUN if [ "$ENV" = "PRODUCTION" ] ; then cargo build --release ; \
else cargo build ; fi
RUN if [ "$ENV" = "PRODUCTION" ] ; then echo "no need to copy" ; \
else mkdir /app/target/release/ && cp /app/target/debug/clean_app \
/app/target/release/clean_app ; fi
到目前为止,我们的应用程序已经编译完成。我们涵盖的所有内容都与我们所使用的芯片类型无关,因此x86_64_build
文件将包含我们在aarch64_build
文件中刚刚展示的相同代码。对于这两个构建文件,我们也可以使用以下代码获取我们的 distroless 镜像:
FROM gcr.io/distroless/cc-debian10
现在,这是构建脚本不同的地方。在 ARM 芯片构建中,我们必须从之前的 Rust 镜像中复制所需的库到我们的 distroless 镜像中,如下所示:
COPY --chown=1001:1001 --from=build \
/usr/lib/aarch64-linux-gnu/libpq.so.5 \
/lib/aarch64-linux-gnu/libpq.so.5
. . .
COPY --chown=1001:1001 --from=build \
/lib/aarch64-linux-gnu/libcom_err.so.2 \
/lib/aarch64-linux-gnu/libcom_err.so.2
将它们全部包含在内将仅仅为本书提供无用的膨胀,而且,这些文件都可在本书的 GitHub 仓库中找到。然而,我们必须注意,每个复制的第一部分的目录是我们探索我们大型应用程序 Docker 镜像时列出的目录。第二部分是相同的路径;然而,如果路径开头有/usr/lib/
,则缩短为/lib/
。distroless 镜像中没有 shell 或用户。
一旦所有库都已复制,我们必须将我们的 Web 应用程序的静态二进制文件复制到镜像的根目录,暴露端口,并使用以下代码定义入口点,即静态二进制文件:
COPY --from=build /app/target/release/clean_app \
/usr/local/bin/clean_app
EXPOSE 8000
ENTRYPOINT ["clean_app"]
使用这种方法,我们的 distroless 镜像就完成了。目前,两个构建都已存储起来,我们将根据芯片类型在构建的 bash 脚本中获取它们。
注意
我们不必手动构建我们的 distroless 应用程序。相反,我们可以通过以下链接使用 Apko:github.com/chainguard-dev/apko
。
你可以将你选择的构建复制到存储库根目录下的 Dockerfile
文件名下。然后运行以下命令:
docker build . -t clean_app
当你列出你的 Docker 镜像时,你会看到这个镜像大小为 46.5 MB!这比 1.5 GB 减少了大量。在下一节中,我们将将这些构建文件包含在测试管道中。
构建一个干净的测试管道
当涉及到测试我们的应用程序时,我们希望将其打包到我们希望部署到服务器上的 Docker 镜像中,就像在服务器上一样在数据库上运行迁移,并运行一系列 Postman 请求和测试来模拟用户发起一系列请求。这可以通过 scripts/run_full_release_test.sh
文件中的一个 Bash 脚本来编排。首先,我们必须使用以下代码找出我们正在运行的芯片:
#!/usr/bin/env bash
# navigate to directory
SCRIPTPATH="$( cd "$(dirname "$0")" ; pwd -P )"
cd $SCRIPTPATH
if [ "$(uname -m)" = "arm64" ]
then
cp ../builds/aarch64_build ../Dockerfile
else
cp ../builds/x86_64_build ../Dockerfile
fi
在这里,我们根据芯片类型拉取正确的构建。根据你使用的计算机,这可能会不同。我使用的是 Mac M1,所以当我在终端中调用 uname -m
命令时,我得到一个 arm64
输出。如果你不是使用 arch 或 ARM 芯片,你不需要条件逻辑。相反,你只需要拉取 x86_64_build
文件。然后,我们必须移动到 tests
目录,并使用以下代码构建我们的 docker-compose
:
cd ../tests
# build the images and network
docker-compose build --no-cache
docker-compose up -d
# wait until rust server is running
sleep 5
现在,我们可以运行我们的测试,并使用以下代码清理镜像:
# run the api tests
newman run to_do_items.postman_collection.json
# destroy the container and image
docker-compose down
docker image rm test_server
docker image rm init_test_db
docker image rm test_postgres
rm ../Dockerfile
在运行此代码之前,我们需要在我们的 tests
目录中构建我们的 docker-compose
。我们的 tests/docker-compose.yml
文件具有以下结构:
version: "3.7"
services:
test_server:
. . .
test_postgres:
. . .
test_redis:
. . .
init_test_db:
. . .
首先,我们将关注测试服务器。鉴于我们正在运行测试,我们需要指向构建,将 NOT PRODUCTION
参数传递给构建,定义服务器要使用的环境变量,然后在启动之前等待 Redis 数据库处于运行状态。我们可以用以下代码来完成:
test_server:
container_name: test_server
image: test_auth_server
build:
context: ../
args:
ENV: "NOT_PRODUCTION"
restart: always
environment:
- 'DB_URL=postgres://username:password@test_postgres:54
32/to_do'
- 'SECRET_KEY=secret'
- 'EXPIRE_MINUTES=60'
- 'REDIS_URL=redis://test_redis/'
depends_on:
test_redis:
condition: service_started
ports:
- "8000:8000"
expose:
- 8000
如我们所见,docker-compose
是一个强大的工具。几个标签就可以实现一些复杂的编排。然后,我们可以使用以下代码移动到我们的数据库和 Redis 容器:
test_postgres:
container_name: 'test_postgres'
image: 'postgres'
restart: always
ports:
- '5433:5432'
environment:
- 'POSTGRES_USER=username'
- 'POSTGRES_DB=to_do'
- 'POSTGRES_PASSWORD=password'
test_redis:
container_name: 'test_redis'
image: 'redis:5.0.5'
ports:
- '6379:6379'
这些数据库没有什么新意。然而,在最后一个服务中,我们创建了一个短暂的 init 容器,只是为了在服务器上运行迁移:
init_test_db:
container_name: init_test_db
image: init_test_db
build:
context: ../database
environment:
- 'DB_URL=postgres://username:password@test_postgres:
5432/to_do'
depends_on:
test_postgres:
condition: service_started
restart: on-failure
如我们所见,在我们的 init 容器进行数据库迁移并关闭之前,必须在 database
目录中进行 Docker 构建。这意味着我们的 init 容器必须安装 psql
,我们的迁移工具,以及 rollup
命令作为入口点。最初,我们在 database/Dockerfile
文件中使用以下代码安装我们需要的:
FROM postgres
RUN apt-get update \
&& apt-get install -y wget \
&& wget -O - https://raw.githubusercontent.com/\
yellow-bird-consult/build_tools/develop/scripts/\
install.sh | bash \
&& cp ~/yb_tools/database.sh ./database.sh
在这里,我们可以看到我们从 postgres
Docker 镜像中获取了 psql
库。然后,我们安装 wget
并使用它来安装我们的迁移构建工具。最后,我们将 database.sh
Bash 脚本从主目录复制到镜像的根目录,这样我们就不必担心别名问题。一旦我们配置了安装,我们必须将迁移 SQL 文件从当前目录复制到镜像的根目录,并将迁移命令定义为入口点:
WORKDIR .
ADD . .
CMD ["bash", "./database.sh", "db", "rollup"]
这将正常工作;然而,我们必须定义一个包含以下内容的 database/.dockerignore
文件,以避免环境变量被传递到镜像中:
.env
如果我们不阻止这个环境变量被复制到镜像中,那么我们通过 docker-compose
传递到初始化容器的任何变量都可能被覆盖。
我们现在已经准备好了所有需要的东西,所以我们只需要运行我们的 scripts/run_full_release.sh
脚本。这将产生一个关于构建镜像、启动 docker-compose
和通过 Newman 运行 API 测试的详细输出。最后的输出应该看起来像这样:
图 13.5 – 完整测试运行的结果
我们可以看到所有测试都已运行并通过。我们的 distroless 构建工作正常,我们的数据库初始化容器也进行了迁移。没有什么能阻止我们将这个基础设施部署到 AWS,区别在于指向 Docker Hub 上的镜像而不是本地构建。考虑到我们的 distroless 服务器很小,从 Docker Hub 拉取镜像并启动它将会非常快。
我们现在已经拥有了构建 GitHub 仓库持续集成的所有成分,以确保在创建拉取请求时运行测试。在下一节和最后一节中,我们将通过 GitHub Actions 配置持续集成。
使用 GitHub Actions 构建持续集成
当涉及到确保代码质量得到维护时,拥有一个每次拉取请求完成时都会运行的持续集成管道会很有用。我们可以通过 GitHub Actions 来实现这一点。必须注意的是,使用 GitHub Actions,你每个月可以获得几分钟的免费时间;然后,你必须为超出部分付费。所以,要小心,并留意你使用 GitHub Actions 的时间。
GitHub Actions 在执行任务时提供了灵活性。我们可以在合并或创建拉取请求以及创建问题时运行工作流程,还有更多。我们还可以选择使用分支的类型。在这个例子中,我们将仅关注任何分支上的拉取请求以运行单元测试和完整的集成测试。
要构建一个名为 tests
的工作流程,我们需要创建一个名为 .github/workflows/run-tests.yml
的文件。在这个文件中,我们将使用以下代码定义单元和集成测试的一般概述:
name: run tests
on: [pull_request]
jobs:
run-unit-tests:
. . .
run-integration-test:
. . .
在这里,我们定义了工作流程的名称和触发工作流程的条件,即所有分支的拉取请求。然后,我们定义了两个作业——一个用于运行单元测试,另一个用于运行集成测试。
每个作业都有步骤。我们也可以为我们的步骤定义依赖关系。我们可以使用以下代码定义我们的单元测试作业:
run-unit-tests:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v2
- uses: actions-rs/toolchain@v1
with:
toolchain: stable
- name: run the unit test
run: |
export SECRET_KEY="secret"
export EXPIRE_MINUTES=60
cargo test
在这里,我们使用了 checkout
动作。如果我们不使用 checkout
动作,我们将无法访问 GitHub 代码库中的任何文件。然后,我们导出单元测试运行所需的环境变量,然后使用 Cargo 运行单元测试。此外,请注意,我们定义了一个超时。定义超时很重要,以防万一某些操作陷入循环,你不会在一个作业中浪费所有时间。
现在,让我们继续到我们的集成测试作业:
run-integration-test:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v2
- name: create environment build and run newman
run: |
cd tests
cp ../builds/server_build ../Dockerfile
docker-compose build --no-cache
docker-compose up -d
sleep 5
- uses: actions/checkout@master
- uses: matt-ball/newman-action@master
with:
collection:
./tests/cerberus.postman_collection.json
在这里,我们进入 tests
目录,获取服务器构建 Docker 文件,启动 docker-compose
,然后使用 newman
动作运行 Newman 测试。如果我们发起一个拉取请求,动作将显示在拉取请求中。如果我们点击 GitHub Actions 按钮,我们可以访问状态和结果,如下面的截图所示:
图 13.6 – GitHub Actions 选项
然后,我们可以点击测试来查看作业的步骤,如下面的截图所示:
图 13.7 – GitHub Actions 作业视图
现在,如果我们点击作业中的某个步骤,它将展开。我们将看到我们的 Newman 测试是有效的:
图 13.8 – Newman 步骤结果
如我们所见,我们的持续集成工作正常!现在,随着我们的代码库既干净又实用,我们已经完成了这一章。
摘要
我们终于完成了在 Rust 中构建一个 Web 应用程序的结构,并围绕应用程序构建基础设施,以便于新功能的持续开发和轻松集成。我们已经将我们的代码库结构化成一个干净且易于使用的版本,其中目录具有各自的功能。就像在结构良好的代码中一样,我们的结构良好的代码库可以让我们轻松地将测试和脚本添加到代码库中,也可以轻松移除。然后,我们使用纯 bash 来管理数据库迁移,没有任何代码依赖,这样我们就可以在任何应用程序上使用我们的迁移,无论使用的是哪种语言。接着,我们构建了初始化容器来自动化数据库迁移,即使部署在服务器或集群上也能工作。我们还优化了服务器的 Docker 构建,使其更加安全,并将大小从 1.5 GB 减少到 45 MB。之后,我们将构建和测试集成到一个自动化的管道中,当新代码合并到 GitHub 代码库时,该管道会被触发。
这自然地结束了构建 Web 应用程序并将其部署到服务器上的过程。在接下来的章节中,我们将更深入地探讨使用 Rust 进行 Web 编程,查看底层框架,以便我们可以在 TCP 套接字上构建自定义协议。这将使您能够构建用于 Web 服务器或甚至本地进程的底层应用程序。在下一章中,我们将探讨 Tokio 框架,它是异步程序(如 TCP 服务器)的构建块。
进一步阅读
-
数据库迁移文档和仓库:
github.com/yellow-bird-consult/build_tools
-
GitHub Actions 文档:
docs.github.com/en/actions/guides
问题
-
bash 迁移工具使用增量单个数字整数来表示迁移。这种方法的重大缺点是什么?
-
为什么无发行版服务器更安全?
-
我们是如何在运行需要刷新令牌的新曼测试时移除对 Python 的需求的?
-
使用环境变量作为配置值的优点是什么?
答案
-
使用增量单个数字整数会使迁移容易发生冲突。因此,如果一个开发者在一个分支上编写迁移,而另一个开发者同时在另一个分支上编写迁移,当他们合并时,将会有迁移冲突。GitHub 应该能够处理这个问题,但重要的是要尽量减少迁移流量,合理规划数据库变更,并保持使用迁移的服务规模较小。然而,如果您对此有顾虑,请使用其他更重但具有更多安全措施的迁移工具。
-
无发行版服务器没有外壳。这意味着如果黑客设法访问我们的服务器容器,他们无法运行任何命令或检查容器的内容。
-
在登录请求中,我们在测试脚本中获取服务器返回的令牌,并将其分配给一个可以被其他请求访问的集合变量,从而消除了对 Python 的依赖。
-
当我们将应用程序部署到云端时,环境变量更容易实现。例如,Kubernetes 的 ConfigMaps 使用环境变量将变量传递到 Docker 容器中。通过使用环境变量,在 AWS 上实现 Secrets Manager 等服务也更为简便。
第六部分:使用底层网络应用程序探索协议编程和异步概念
网络编程已经发展到了不仅仅是与数据库交互的简单应用。在本部分,我们通过介绍异步 Rust 的基础知识、Tokio 和 Hyper,来涵盖更高级的概念。通过 Tokio 和 Hyper,我们利用异步 Rust 和 actor 模型来实现异步设计,例如在不同线程之间传递 actor 的消息、在 Redis 中排队任务以便多个工作者消费,以及使用 Tokio 的帧和 TCP 端口处理字节流。在本部分结束时,你将能够在你的服务器上实现更复杂的事件处理解决方案来处理更复杂的问题。你还将具备如何实现异步 Rust 的实际知识,这是一个新兴领域。
本部分包括以下章节:
-
第十四章,探索 Tokio 框架
-
第十五章,使用 Tokio 接受 TCP 流量
-
第十六章,在 TCP 之上构建协议
-
第十七章,使用 Hyper 框架实现 Actors 和异步
-
第十八章,使用 Redis 排队任务
第十四章:探索 Tokio 框架
到目前为止,在这本书中,我们一直在使用框架构建 Web 应用,并将它们打包在 Docker 中以部署到服务器上。构建标准服务器是有用的,这将使我们能够解决一系列问题。然而,在你的 Web 开发生涯中,某个时刻,标准的 REST API 服务器可能不再是最佳解决方案。寻找另一个工具来实现更定制的解决方案是有用的。
在本章中,我们将探索Tokio框架以实现异步编程。然后,我们将使用 Tokio 运行时通过使用 channels 向异步代码块发送消息来构建安全的自定义异步系统。这些消息甚至可以发送到不同的线程。然后,我们将通过实现 actor 模型来促进对复杂问题的异步解决方案。
在本章中,我们将涵盖以下主题:
-
探索异步编程的 Tokio 框架
-
使用 workers 进行工作
-
探索异步编程的 actor 模型
-
使用 channels 进行工作
-
在 Tokio 中使用 actors
到本章结束时,你将能够创建使用 actor 模型解决复杂问题的异步程序。你的异步程序不需要任何外部基础设施,例如数据库,并且我们的异步程序实现将是安全和隔离的,因为你将能够通过 channels 在你的系统和线程之间传递数据。你将能够理解和实现高度并发异步程序和网络应用的构建块。
技术要求
在本章中,不需要任何之前的代码。
本章的代码可以在github.com/PacktPublishing/Rust-Web-Programming-2nd-Edition/tree/main/chapter14
找到。
探索异步编程的 Tokio 框架
在我们探索 Tokio 是什么以及它是如何工作之前,我们应该尝试在正常的 Rust 中执行一些异步代码。在本章中,我们将构建一个基本的模拟使用 Tokio。因此,我们将要编写的 Tokio 代码位于simulation
目录中,作为一个独立的 Cargo 项目。鉴于我们在 Rust 服务器代码中运行async
函数来处理视图,我们可以尝试在main.rs
文件中的main
函数中执行一个基本的async
函数,以下代码如下:
async fn hello() {
println!("Hello, world!");
}
fn main() {
hello();
}
这看起来很简单;然而,如果我们尝试运行我们的main
函数,我们会得到以下输出:
warning: unused implementer of `Future` that must be used
--> src/main.rs:9:5
|
9 | hello();
| ^^^^^^^^
|
= note: `#[warn(unused_must_use)]` on by default
= note: futures do nothing unless you `.await` or poll them
这里,我们被提醒我们的async
函数是一个Hello, world!
消息,因为我们没有等待hello
函数执行。然而,如果我们对hello
函数实现await
,我们会得到以下错误:
8 | fn main() {
| ---- this is not `async`
9 | hello().await;
| ^^^^^^ only allowed inside `async`
functions and blocks
main
函数不是async
的。如果我们尝试将我们的main
函数转换为async
函数,我们会得到一个非常清晰的错误消息,指出main
函数不允许是async
的。
我们可以自己实现实现Future
特质的 struct,然后创建我们自己的 poll 方法。然而,从头开始创建自己的 future 对于本书的上下文来说过于繁琐,因为本书不是专门关于异步 Rust 的。幸运的是,Tokio 框架通过将我们的main
运行时函数转换为异步运行时函数来提供帮助。要运行我们的hello
函数,我们首先需要将 Tokio 添加到我们的Cargo.toml
文件中,以下代码:
[dependencies]
tokio = { version = "1", features = ["full"] }
然后,我们在main.rs
文件中导入以下 Tokio 宏和Error
结构体,以启用异步运行时:
use tokio::main;
use std::error::Error;
然后,我们可以应用我们的 Tokio 宏来使main
函数异步执行,并使用以下代码执行hello
函数:
#[main]
async fn main() -> Result<(), Box<dyn Error>> {
let outcome = hello().await;
Ok(outcome)
}
在这里,我们可以看到我们要么返回一个包含空元组的Result
,这在其他语言中与None
或Void
相同,要么返回一个错误。如果我们返回一个错误,我们返回一个实现Error
特质的 struct,它由于Box
表示法而位于堆内存中。现在我们运行我们的程序,我们将得到hello world
消息。从这个结果中,我们可以推断出我们的程序在hello
函数执行完毕之前是被阻塞的。然而,前面的代码要求我们返回某些内容,并且在定义中有一点点样板代码。如果我们删除所有导入,我们可以得到以下更简单的main
函数:
#[tokio::main]
async fn main() {
hello().await;
println!("program has run");
}
在这里,我们可以看到我们不需要烦恼于return
定义,我们可以在main
函数的最后一个语句中做任何我们想做的事情。我们仍然在await
上阻塞线程,正如以下打印输出所示:
Hello, world!
program has run
现在我们已经在 Tokio 中运行了基本的异步函数,我们可以进行一个实验。我们可以运行多个异步函数,并使用以下代码等待它们:
async fn hello(input_int: i32) -> i32 {
println!("Hello, world! {}", input_int);
return input_int
}
#[tokio::main]
async fn main() {
let one = hello(1);
let two = hello(2);
let three = hello(3);
let one = one.await;
let three = three.await;
let two = two.await;
println!("{} {} {}", one, two, three);
}
运行此代码将产生以下输出:
Hello, world! 1
Hello, world! 3
Hello, world! 2
1 2 3
在这里,我们可以看到操作是按照main
函数执行的顺序进行的。这意味着当我们使用await
等待一个 future 完成时,运行时会阻塞,直到 future 完成。即使two
future 在three
future 之前定义,three
future 也会在two
future 之前执行,因为three
future 在two
future 之前被 await。
那么问题是什么?这有什么大不了的?如果我们的异步函数阻塞了运行时,那么我们为什么不去定义普通的函数呢?我们可以进行最后一个实验来了解 Tokio:标准的睡眠测试。首先,我们需要导入以下内容:
use std::time::Instant;
use std::{thread, time};
然后,我们重新定义我们的hello
函数,使其在打印到终端之前暂停 5 秒钟,以下代码:
async fn hello(input_int: i32) -> i32 {
let five_seconds = time::Duration::from_secs(5);
tokio::time::sleep(five_seconds).await;
println!("Hello, world! {}", input_int);
input_int
}
我们在 hello 函数中等待未来执行时,会生成 Tokio 任务。我们使用tokio::spawn
生成一个 Tokio 任务。Tokio 任务是一个轻量级、非阻塞的执行单元。虽然 Tokio 任务类似于操作系统线程,但它们不是由操作系统调度器管理,而是由 Tokio 运行时管理。生成的 Tokio 任务在线程池上运行。生成的任务可能对应于一个线程,也可能不对应。这取决于 Tokio 运行时。我们使用以下代码生成任务:
#[tokio::main]
async fn main() {
let now = Instant::now();
let one = tokio::spawn({
hello(1)
});
let two = tokio::spawn({
hello(2)
});
let three = tokio::spawn({
hello(3)
});
one.await;
two.await;
three.await;
let elapsed = now.elapsed();
println!("Elapsed: {:.2?}", elapsed);
}
如果我们的未来阻塞了整个运行时间,那么经过的时间将是 15 秒。然而,运行我们的程序将给出以下输出:
Hello, world! 2
Hello, world! 3
Hello, world! 1
Elapsed: 5.00s
在这里,我们可以看到未来执行存在某种异步顺序。然而,总时间是 5 秒,因为它们是并发运行的。再次强调,从表面上看,这似乎并不太令人印象深刻。然而,这正是令人兴奋的地方。构建 Tokio 的超级聪明的人们通过轮询来跟踪线程。这就是 Tokio 运行时不断检查未来是否通过轮询一小部分内存来执行的地方。由于轮询,检查线程并不需要很多资源。因为检查线程不需要很多资源,Tokio 实际上可以保持数百万个任务开启。如果线程中有一个await
实例并且它阻塞了该线程的运行时,Tokio 将简单地切换到另一个线程并开始执行新线程。在后台运行的未来会偶尔轮询任务执行器以查询是否有结果。因此,Tokio 非常强大,这就是为什么我们在探索 Actix 时使用 Tokio 作为我们的 Actix Web 服务器的运行时,如第三章中所述,处理 HTTP 请求。了解 Tokio 将使我们能够以较低级别构建自己的网络应用程序。
虽然使用 Tokio 生成多个线程很令人兴奋,但我们必须探索与多个工作者协同工作的权衡。
与工作者协同工作
当定义工作者时,我们可以使用以下代码增强 Tokio 运行时宏:
#[tokio::main(flavor = "multi_thread", worker_threads = 4)]
async fn main() {
...
}
在这里,我们可以看到我们声明运行时是多线程的,并且我们有四个工作者线程。工作者本质上是在循环中运行的进程。每个工作者通过一个通道消耗任务,将它们放入队列中。然后工作者通过任务工作,按照接收的顺序执行它们。如果一个工作者完成了所有任务,它将搜索其他工作者的队列,如果可用,就会窃取任务,就像这里看到的那样:
图 14.1 – 工作者事件循环(工作窃取运行时。由卡尔·勒奇创作 – 许可证 MIT:https://tokio.rs/blog/2019-10-scheduler#the-next-generation-tokio-scheduler)
现在我们知道我们可以改变工作线程的数量,我们可以测试工作线程的数量如何影响我们的运行时间。首先,我们必须将我们的hello
函数改为每次只休眠 1 秒钟。然后,我们遍历一系列数字,为该范围的每次迭代生成一个任务,将生成任务的句柄推送到一个向量中。然后,我们使用以下代码等待向量中的所有 future:
#[tokio::main(flavor = "multi_thread", worker_threads = 4)]
async fn main() {
let now = Instant::now();
let mut buffer = Vec::new();
for i in 0..20 {
let handle = tokio::spawn(async move {
hello(i).await
});
buffer.push(handle);
}
for i in buffer {
i.await;
}
let elapsed = now.elapsed();
println!("Elapsed: {:.2?}", elapsed);
}
我们可以运行程序,使用不同数量的工作线程来查看线程数量如何影响所需时间。为了节省您的时间,这已经完成,提供了图 14.2所示的图表:
图 14.2 – 时间与工作线程数量的关系
我们可以看到,前四个工作线程对程序的整体时间有重大影响。然而,当我们增加工作线程数量超过四个时,回报急剧减少。这可能因人而异。我得到这张图表是因为运行我们程序的计算机有四个核心。建议我们有一个工作线程对应一个核心。
我们现在已经成功地使用 Tokio 工作线程加快了我们的程序。我们也更深入地理解了工作线程的权衡以及它们如何处理任务。然而,理解这一点有什么优势呢?我们完全可以使用像 Actix Web 或 Rocket 这样的高级框架来并发处理 API 端点。是的,高级框架很有用,确实解决了问题,但这些只是问题的一个解决方案。在下一节中,我们将介绍演员模型,因为这是一个异步解决方案,可以根据您的需求在程序或 Web 服务器中运行。
探索异步编程的演员模型
如果您之前以面向对象的方式编写过解决复杂问题的复杂代码,您将熟悉对象、属性和类继承。如果您不熟悉,请不要担心——我们不会在本章中实现它们。然而,建议您阅读面向对象编程的概念,以全面理解演员模型。
对于对象,我们有一系列进程和围绕这些进程的封装状态,这些状态可以是对象的属性。对象对于将逻辑和状态封装在概念或进程周围非常有用。有些人仅仅把对象看作是减少重复代码的工具;然而,对象可以用作模块之间的接口,或者可以用作编排进程。对象是许多复杂系统的基石。然而,当涉及到异步编程时,对象可能会变得复杂——例如,当我们有两个对象引用另一个对象的数据时,或者在一般情况下,当我们有对象之间的共享资源时,同时修改它们,如以下图表所示:
图 14.3 – 对象和异步
在这个图中,我们可以想象一个系统,其中对象 C正在跟踪投资。我们在对象 C中编码了一个规则,即我们不花费超过 50 英镑。然而,对象 A和对象 B都在执行不同的股票投资过程。由于各种原因,从网络延迟到可能的不同价格计算策略,决定投资股票的过程可能会有所不同。尽管对象 A和对象 B都通过从对象 C获取已投资总额获得批准,但由于对象 B的过程先完成,所以对象 B首先下单。因此,当对象 A完成其过程并下单时,它可能会使我们的总投资超过 50 英镑。这是一个数据竞争问题。我们可能会因为两个竞争的投资执行过于接近而超出投资预算。当我们考虑到我们可能需要跟踪数百个头寸时,数据竞争最终会破坏我们策略的规则。
为了解决数据竞争问题,我们可以实现一个数据库,或者尝试监控所有运行的线程以实现互斥锁、读写锁等形式。然而,数据库解决方案需要更多的内存、基础设施和代码来处理数据。当程序关闭时,数据也会被持久化,这增加了管理的复杂性。锁定系统和跟踪共享资源也可能引入潜在的错误和过度复杂性,这对于我们定义的简单问题来说是不必要的。这就是演员发挥作用的地方。演员本质上是有自己状态的计算单元。然而,必须注意的是,演员不是免费的;它们需要更多的 RAM 使用。此外,演员可能会死亡,这也会导致它们的状态消失,因此数据库备份可以在演员死亡时帮助持久化状态。演员只通过通道使用消息进行通信。这些消息被排队,然后按照消息发送的顺序进行处理。这给我们带来了以下系统动态:
图 14.4 – 演员和异步
在这里,我们可以看到,如果我们没有超过我们的投资预算,演员 C会接受消息并下单。我们可以让数百个演员向演员 C发送买卖消息,同时仍然有信心不会超过预算。随着我们添加更多的演员,我们的系统并不会变得越来越复杂。没有任何东西阻止我们让演员 C向其他演员发送消息,以根据我们投资预算的当前状态停止或增加买卖头寸的数量。
要构建更复杂的系统,你必须熟悉以下术语:
-
演员:一个可以包含状态并通过处理接收到的消息来修改该状态的作业单元。你永远不会直接引用演员状态或演员。
-
actor 引用:对 actor 的引用。这允许你发送消息给 actor,而无需知道其实现类型或网络上的位置。我所需要知道的是,如果我们向 actor 发送消息,我们会得到X。我们不需要知道 actor 是否在某个地方是本地的。
-
actor 系统:存在于单个进程内的 actor 集合,通过内存消息传递进行通信。
-
集群:网络化 actor 系统的集合,其 actor 通过 TCP 消息传递进行通信。
令人兴奋的是,我们可以实现异步操作,而无需依赖于数据库或跟踪线程。我们可以在 Tokio 运行时中运行所有操作,只需一个静态二进制文件!这非常强大。然而,没有任何阻止我们在微服务集群规模上以纳米服务的形式使用 actor 模型。在撰写本书时,纳米服务被轻描淡写地提及,并被一些公司如 Netflix 所使用。然而,通过我们在 Rust 和 distroless 容器中探索的内容,我们可以将 Rust 服务器部署到集群中,每个服务器只需 50 MB。根据本书到目前为止所涵盖的内容,没有任何阻止您推动边界并构建纳米服务,以及使用纳米服务实现 actor 模型。
actor 模型正在许多物联网设备和实时事件系统中使用。以下是一些使用 actor 模型的应用程序列表:
-
事件驱动应用程序(聊天、工作流、CRM)
-
金融(定价、欺诈检测、算法交易)
-
游戏(多人游戏)
-
分析和监控
-
营销自动化
-
系统集成
-
物联网(医疗保健、交通、安全)
好吧——我们可能永远无法真正理解好莱坞演员,但至少我们对计算 actor 很熟悉。在高级概念之后,我们可以继续构建 actor 系统的构建块:actor。在我们与 actor 一起工作之前,我们需要让 actor 相互通信。这可以通过 Tokio 中的通道来实现。
与通道一起工作
我们可以通过重写main.rs
文件来实验通道。首先,我们需要导入通道并实现main
特质为main
函数,以下代码:
use tokio::sync::mpsc;
#[tokio::main]
async fn main() {
...
}
在这里,我们可以看到我们导入了mpsc
模块。mpsc
。我们现在可以创建我们的通道并启动一个线程,该线程将多个消息发送到我们在main
函数中创建的通道的接收器。以下代码:
let (tx, mut rx) = mpsc::channel(1);
tokio::spawn(async move {
for i in 0..10 {
if let Err(e) = tx.send(i).await {
println!("send error: {:?}", e);
break;
}
println!("sent: {}", i);
}
});
在这里,我们可以看到通道的创建返回一个元组,我们解包以传输(tx
)和接收(rx
)。然后我们遍历从 0 到 10 的整数范围,将它们发送到我们创建的通道。如果有错误,我们将其打印出来并返回一个空元组,中断循环。在下一个循环中,我们打印出我们发送到通道的内容。然后我们可以使用以下代码接收消息:
while let Some(i) = rx.recv().await {
println!("got: {}", i);
}
必须注意,通道的分割 halves 实现了 Iterator
特性,使我们能够通过 while
循环从这些通道中读取消息。如果我们现在运行我们的代码,我们会得到以下输出:
sent: 0
got: 0
sent: 1
got: 1
sent: 2
got: 2
...
打印输出继续到 9
,但我们已经了解了正在发生的事情。我们不是发送所有消息然后处理它们。两个代码块是并行执行的,发送和接收消息。如果我们让其中一个代码块在迭代之间休眠,我们仍然会得到相同的打印输出。
虽然获取异步消息发送和接收的基础是一个正确的方向上的好步骤,但它并不实用。目前,我们只是在发送一个数字。如果我们想要能够实现相互通信的演员,我们需要能够在通道上发送更全面的消息。为了找出如何做到这一点,我们可以检查 channel
的源代码,它揭示了以下内容:
pub fn channel<T>(buffer: usize) -> (Sender<T>, Receiver<T>) {
assert!(buffer > 0, "mpsc bounded channel requires
buffer > 0");
let semaphore = (semaphore::Semaphore::new(buffer),
buffer);
let (tx, rx) = chan::channel(semaphore);
let tx = Sender::new(tx);
let rx = Receiver::new(rx);
(tx, rx)
}
我们可以看到,channel
函数正在实现泛型。发送者和接收者可以发送任何东西,只要它是一致的,正如在 (Sender<T>, Receiver<T>)
返回类型中所表示的。既然我们知道 channel
函数可以加载任何类型,我们就可以在 channel
函数外部创建一个消息结构体,以下代码所示:
#[derive(Debug, Clone)]
pub enum Order {
BUY,
SELL
}
#[derive(Debug, Clone)]
pub struct Message {
pub order: Order,
pub ticker: String,
pub amount: f32
}
在这里,我们有一个具有订单的结构体,该订单可以是 BUY
或 SELL
。我们的 Message
结构体还有一个股票的标记,用来表示正在处理的股票名称和处理股票的数量。在我们的 main
函数中,我们随后将我们的 Message
结构体传递给 channel
函数,并定义我们将要购买的股票,以下代码所示:
let (tx, mut rx) = mpsc::channel::<Message>(1);
let orders = [
Message { order: Order::BUY,
amount: 5.5, ticker: "BYND".to_owned()},
Message { order: Order::BUY,
amount: 5.5, ticker: "NET".to_owned()},
Message { order: Order::BUY,
amount: 5.5, ticker: "PLTR".to_owned()},
];
现在我们已经定义了订单,我们通过以下代码遍历订单,发送和接收它们:
tokio::spawn(async move {
for order in orders {
if let Err(e) = tx.send(order.clone()).await {
println!("send error: {:?}", e);
return;
}
println!("sent: {:?}", order);
}
});
while let Some(i) = rx.recv().await {
println!("got: {:?}", i);
}
现在运行我们的程序会给我们以下输出:
sent: Message { order: "BUY", ticker: "BYND", amount: 5.5 }
sent: Message { order: "BUY", ticker: "NET", amount: 5.5 }
got: Message { order: "BUY", ticker: "BYND", amount: 5.5 }
got: Message { order: "BUY", ticker: "NET", amount: 5.5 }
sent: Message { order: "BUY", ticker: "PLTR", amount: 5.5 }
got: Message { order: "BUY", ticker: "PLTR", amount: 5.5 }
我们可以看到,我们正在通过通道发送和接收消息。然而,在我们继续之前,必须注意,如果我们将传递给 channel
函数的缓冲区大小从 1 增加到 300,我们会得到以下打印输出:
sent: Message { order: "BUY", ticker: "BYND", amount: 5.5 }
sent: Message { order: "BUY", ticker: "NET", amount: 5.5 }
sent: Message { order: "BUY", ticker: "PLTR", amount: 5.5 }
got: Message { order: "BUY", ticker: "BYND", amount: 5.5 }
got: Message { order: "BUY", ticker: "NET", amount: 5.5 }
got: Message { order: "BUY", ticker: "PLTR", amount: 5.5 }
这是因为缓冲区太大,可以在不需要等待通道被读取(排空)直到有更多可用的新缓冲区的情况下发送多个消息。
在 Tokio 中与演员一起工作
这将是最后一次我们重写我们的 main.rs
文件。然而,一旦我们完成这一部分,我们将构建一个基本的演员模型系统。在我们的系统中,我们将创建一个演员来跟踪订单,这样我们就不超过我们的预算阈值。然后我们需要构建一个发送订单的演员,从而产生以下过程:
图 14.5 – 我们为演员的股票订单交互
我们可以看到,这次我们需要在订单消息中发送发出订单的演员的地址。这是一个设计选择,因为在我们系统中,订单演员在订单制作后迅速启动并死亡。我们无法在我们的订单簿演员中跟踪所有订单演员的地址。相反,订单簿演员可以从消息中获取地址以将响应发送给订单演员。首先,我们必须导入以下内容:
use tokio::sync::{mpsc, oneshot, mpsc::Sender};
在这里,我们导入mpsc
通道以向订单簿发送消息。然后我们导入oneshot
通道以促进结果被发送回订单。oneshot
通道是一个在发送消息之前保持打开状态的通道。这是因为oneshot
通道的容量/缓冲区大小为 1,因此它绑定在只发送一条消息之前关闭通道。
现在我们已经导入了所有需要的内容,我们可以继续定义以下代码的消息:
#[derive(Debug)]
pub struct Message {
pub order: Order,
pub ticker: String,
pub amount: f32,
pub respond_to: oneshot::Sender<u32>
}
在这里,我们可以看到我们已经附加了Sender
结构体,这使得接收者可以通过通道向发送者发送值。通过这条消息,我们可以使用以下代码构建我们的订单簿演员:
pub struct OrderBookActor {
pub receiver: mpsc::Receiver<Message>,
pub total_invested: f32,
pub investment_cap: f32
}
订单簿演员的状态将在整个程序的生命周期中持续存在。receiver
字段将用于接收来自所有订单演员的传入消息,并且将根据我们创建演员时定义的总投资和投资上限来做出处理订单的决定。我们现在必须实现创建订单簿演员、处理传入消息和按照以下大纲运行演员的函数:
impl OrderBookActor {
fn new(receiver: mpsc::Receiver<Message>,
investment_cap: f32) -> Self {
. . .
}
fn handle_message(&mut self, message: Message) {
. . .
}
async fn run(mut self) {
. . .
}
}
我们可以从构造函数(new
函数)开始,它具有以下形式:
fn new(receiver: mpsc::Receiver<Message>, investment_cap:
f32) -> Self {
return OrderBookActor {
receiver,
total_invested: 0.0,
investment_cap
}
}
构造函数的实现并不令人惊讶。我们只是传递一个接收者和投资上限,并自动将总投资设置为0
。现在订单演员可以被构建,我们可以使用以下代码处理订单演员接收到的消息:
fn handle_message(&mut self, message: Message) {
if message.amount + self.total_invested >=
self.investment_cap {
println!("rejecting purchase, total invested: {}",
self.total_invested);
let _ = message.respond_to.send(0);
} else {
self.total_invested += message.amount;
println!("processing purchase, total invested: {}",
self.total_invested);
let _ = message.respond_to.send(1);
}
}
在这里,我们保持了实现的基本性。如果新的订单使我们的投资资本超过我们的阈值,我们打印出我们已拒绝订单并返回0
。如果我们的新订单没有违反阈值,我们然后打印出我们正在处理订单并发送一个1
。在我们的例子中,订单演员不会对订单簿演员的响应采取行动;然而,如果您要构建一个系统,其中可以放置另一个订单(例如,卖出订单),建议您根据结果创建另一个基于订单的演员。
我们订单簿演员剩下的唯一函数是run
函数,它使用以下代码定义:
async fn run(mut self) {
println!("actor is running");
while let Some(msg) = self.receiver.recv().await {
self.handle_message(msg);
}
}
在这里,我们只是等待消息,直到我们通道中的所有发送者都被杀死。如果向我们的演员发送消息,我们使用我们的handle_message
函数处理它。
我们的订单簿演员现在可以通过三个单独的函数来构建、运行和接收通过通道返回的响应消息。我们需要构建一个订单演员结构体,以便我们可以向我们的订单簿演员发送消息。首先,我们使用以下代码定义我们的订单字段:
struct BuyOrder {
pub ticker: String,
pub amount: f32,
pub order: Order,
pub sender: Sender<Message>
}
在这里,我们需要定义我们正在购买的股票的股票代码、购买的数量和订单类型。然后我们有Sender
结构体,以便我们可以向订单簿发送消息。对于订单演员,我们只需要两个函数:构造函数和send
函数。这是因为我们的订单演员是发送初始消息的那个,所以订单演员不需要一个单独的函数来等待消息。我们为我们的订单演员的两个函数有以下大纲:
impl BuyOrder {
fn new(amount: f32, ticker: String,
sender: Sender<Message>) -> Self {
. . .
}
async fn send(self) {
. . .
}
}
在这个时间点,你应该能够自己编写订单演员的构造函数。如果你停下来尝试编写这个函数,你的代码应该看起来像这样:
fn new(amount: f32, ticker: String,
sender: Sender<Message>) -> Self {
return BuyOrder { ticker, amount,
order: Order::BUY, sender }
}
在这里,我们只是将参数传递给结构的创建,其中order
字段自动设置为BUY
。
构造函数构建完成后,唯一需要定义的函数是send
函数,其形式如下:
async fn send(self) {
let (send, recv) = oneshot::channel();
let message = Message { order: self.order,
amount: self.amount,
ticker: self.ticker,
respond_to: send};
let _ = self.sender.send(message).await;
match recv.await {
Err(e) => println!("{}", e),
Ok(outcome) => println!("here is the outcome: {}",
outcome)
}
}
在这里,我们设置了一个一次性通道,一旦从订单簿演员那里收到响应就会关闭。然后我们将我们的消息与订单演员结构体的字段打包,并在消息的respond_to
字段中包含订单演员的地址。然后订单演员发送消息并等待响应,我们只是简单地打印出来,因为这只是一个示例。
我们的系统组件现在已经构建完成。现在我们可以在main
函数中使用以下代码来编排我们的演员协同工作:
#[tokio::main]
async fn main() {
let (tx, rx) = mpsc::channel::<Message>(1);
let tx_one = tx.clone();
tokio::spawn(async move {
. . .
});
tokio::spawn(async move {
. . .
});
let actor = OrderBookActor::new(rx, 20.0);
actor.run().await;
}
在这里,我们定义了通道并将发送者克隆到通道中一次,这样我们就有两个相同的通道的发送者。这是因为我们有两个 Tokio 线程发送订单消息。然后我们创建我们的订单簿演员并运行它。如果我们克隆了另一个发送者但没有使用它,我们的程序将永远挂起,直到我们关闭它,因为订单簿演员会等待所有发送者被杀死后才会停止。
在我们的线程中,我们将简单地使用以下代码使用循环发送大量股票:
tokio::spawn(async move {
for _ in 0..5 {
let buy_actor = BuyOrder::new(5.5,
"BYND".to_owned(),
tx_one.clone());
buy_actor.send().await;
}
drop(tx_one);
});
tokio::spawn(async move {
for _ in 0..5 {
let buy_actor = BuyOrder::new(5.5,
"PLTR".to_owned(),
tx.clone());
buy_actor.send().await;
}
drop(tx);
});
需要注意的是,在我们发送完所有消息后,我们会丢弃tx_one
和tx
传输器,以提前发出任务完成的信号,而不是等待运行时结束来关闭通道。我们有两个线程在运行,因为我们想正确检查我们的订单簿是否可以安全地处理并发消息。如果我们运行我们的程序,我们会得到以下输出:
processing purchase, total invested: 5.5
here is the outcome: 1
processing purchase, total invested: 11
processing purchase, total invested: 16.5
here is the outcome: 1
here is the outcome: 1
rejecting purchase, total invested: 16.5
here is the outcome: 0
rejecting purchase, total invested: 16.5
here is the outcome: 0
. . .
在这里,我们可以看到消息正在以异步方式发送和处理到我们的订单簿演员。然而,无论你运行程序多少次,你都不会超过你的投资阈值。
我们终于得到了这个。Tokio 允许我们在 main
函数中运行异步代码,而演员模型允许我们构建无需依赖锁、线程处理、线程间状态传递或外部基础设施(如数据库)即可轻松理解的同步代码。这并不意味着我们应该将演员模型应用到所有事物上。然而,如果你想要编写安全、简单、可测试的异步代码,且无需外部基础设施并快速访问本地内存,那么结合 Tokio 的演员模型是非常强大的。
摘要
在本章中,我们成功地回顾了异步编程。最初,我们通过研究 Tokio 框架能够做什么以及实现基本的异步代码来探索 Tokio 框架,然后这些代码由 Tokio 框架实现。然后我们增加了工作线程的数量,以证明当我们仅仅增加工作线程的数量时,我们会得到递减的回报。然后我们使用通道来促进通过这些通道发送消息。这使得我们能够在代码的不同区域之间发送数据,即使代码在不同的线程中运行。
然而,尽管使用 Tokio 框架进行异步编程很有趣且令人愉快,但仅通过 Tokio 和通道的异步编程组合本身并不能直接导致实际应用。为了获得一些与 Tokio 和异步编程相关的实用技能,我们探索了演员框架。在这里,我们定义具有自己状态的 struct,然后通过通道在现在称为演员的 struct 之间进行通信。然后我们使用演员构建了一个基本系统,该系统在异步方式下放置订单,即使投资金额不超过定义的阈值。在 Tokio 中实现演员模型使我们能够构建无需任何外部基础设施(如数据库)的安全异步系统。在下一章中,我们将构建自定义异步系统的能力提升到新的水平,通过为我们的 Tokio 异步应用程序构建 TCP 监听器,这意味着我们可以监听来自其他应用程序和通过网络连接到我们的应用程序的客户端发送的命令。
进一步阅读
- Tokio 文档:
tokio.rs/tokio/tutorial
)
问题
-
演员模型是如何防止数据竞争问题的?
-
我们如何设计一种双向通信,使得一个演员可以向另一个演员发送消息并得到回应?
-
如果我们有两个演员各自向第三个演员发送一条消息,但我们为促进演员间通信的 MPSC 通道克隆了三个发送者的实例,那么我们的程序会发生什么?
-
使用通道和消息在代码块之间通信数据有什么优势?
答案
-
演员模型是演员之间发送消息的地方。每个演员都有一个队列来处理发送给该演员的传入消息。正因为如此,传入的消息按照接收的顺序进行处理。
-
接收初始消息的演员拥有 MPSC 通道的接收者。发送初始消息的演员是 MPSC 通道的发送者,并创建了一个单次使用的通道。发送初始消息的演员随后在初始消息中发送单次通道的发送者。发送初始消息的演员然后等待接收初始消息的演员处理数据,然后使用初始发送者中的发送者发送回响应。
-
我们的项目将按照预期运行;然而,由于我们只有两个发送消息的演员,因此只有两个用于 MSPC 通道的发送者会被终止。这意味着将有一个发送者剩余。由于有一个发送者未被使用完,通道不会被关闭,所以接收演员将继续无限期地保持程序运行。
-
通道和消息为我们提供了很多灵活性。我们可以在线程之间发送消息。我们也不必在代码的多个不同区域传递数据。如果一个代码块与一个通道有连接,该代码块可以接收/发送数据。我们还必须注意,我们可以实现不同的模式。例如,我们可以将消息发射到通道中并由多个订阅者接收。我们还可以强制交通方向或规则,如单次使用。这意味着我们对数据的流动有大量的控制。
第十五章:使用 Tokio 接受 TCP 流量
在上一章中,我们成功地在不同的线程中运行演员以相互发送消息。虽然编写异步编程的构建块令人兴奋,但我们留下了那个不太实用的应用。在本章中,我们将创建一个使用 Tokio 的服务器,该服务器监听端口上的 TCP 流量。如果发送了消息,我们的 TCP 服务器将处理传入的数据,通过一系列演员和线程执行操作,然后将更新后的数据返回给客户端。
本章将涵盖以下主题:
-
探索 TCP
-
接受 TCP
-
处理字节
-
将 TCP 传递给演员
-
使用演员跟踪订单
-
连接演员之间的通信
-
使用 TCP 进行响应
-
通过客户端发送不同的命令
到本章结束时,您将了解如何使用 TCP,以及如何使用字节打包和解包通过 TCP 发送的数据。有了这些知识,您将能够创建一个使用 Tokio 来监听传入消息的服务器,处理这些消息,然后通过一系列线程和演员根据传入的消息执行计算单元。
技术要求
在本章中,我们将基于第14 章的代码,探索 Tokio 框架进行构建。您可以在以下网址找到它:github.com/PacktPublishing/Rust-Web-Programming-2nd-Edition/tree/main/chapter14/working_with_actors
。
本章的代码可以在github.com/PacktPublishing/Rust-Web-Programming-2nd-Edition/tree/main/chapter15
找到。
探索 TCP
TCP 代表 传输控制协议。TCP 是互联网上使用最广泛的传输协议之一。TCP 实质上是一种协议,它通过套接字在程序或计算机之间传输字节。TCP 用于万维网、电子邮件、远程管理和文件传输。传输层安全性/安全套接字层(TLS/SSL)协议建立在 TCP 之上。这意味着 HTTP 和 HTTPS 是建立在 TCP 之上的。
TCP 是一种面向连接的协议。这意味着在传输任何数据之前,客户端和服务器之间会建立连接。这是通过三次握手实现的:
-
SYN:最初,客户端向服务器发送一个 SYN。SYN 是一个带有随机数的消息,以确保相同的客户端正在与服务器通信。
-
SYN-ACK:然后服务器向客户端响应初始序列号和一个额外的随机数,称为 ACK。
-
ACK:最后,客户端向服务器返回 ACK 以确认连接已被确认。
步骤 1和步骤 2建立并确认客户端和服务器之间的序列号。步骤 2和步骤 3建立并确认从服务器到客户端的序列号:
图 15.1 – TCP 握手
在本章中,我们将把上一章构建的 actor 模型转换为能够接受来自程序外部的 TCP 流量作为命令。首先,我们需要让我们的程序接受 TCP 连接。
接受 TCP
在我们编写任何 TCP 代码之前,我们必须承认我们的代码存在因所有代码都在一个文件中而变得臃肿的风险。为了防止src/main.rs
文件变得臃肿,我们必须将src/main.rs
文件中除主函数之外的所有代码复制到一个名为src/actors.rs
的文件中。现在,我们可以完全清除src/main.rs
文件,并用以下大纲填充它:
use tokio::net::TcpListener;
use std::{thread, time};
#[tokio::main]
async fn main() {
let addr = "127.0.0.1:8080".to_string();
let mut socket = TcpListener::bind(&addr).await.unwrap();
println!("Listening on: {}", addr);
while let Ok((mut stream, peer)) =
socket.accept().await {
println!("Incoming connection from: {}",
peer.to_string());
tokio::spawn(async move {
. . .
});
}
}
在这里,我们导入了一个 TCP 监听器来监听传入的流量。我们还导入了Tokio
crate 中的结构体,使我们能够执行睡眠函数并定义我们的main
运行时函数。在我们的main
函数中,我们定义了我们的地址并将其绑定到 TCP 监听器。我们直接解包,因为如果我们无法绑定地址,就没有继续程序的必要。你可以通过增加端口号 1 直到找到一个开放的端口号来处理地址绑定的结果,但在这个例子中,我们应该保持服务器实现的简单性。然后,我们有一个while
循环,在整个程序的生命周期内持续接受新的连接,如果程序没有被中断或套接字没有问题,这个循环可以是无限的。一旦我们得到一个连接,我们就创建一个新的线程并处理传入的消息。
目前,对于我们的传入消息,我们只需睡眠 5 秒钟,如下面的代码所示:
tokio::spawn(async move {
println!("thread starting {} starting",
peer.to_string());
let five_seconds = time::Duration::from_secs(5);
let begin = time::Instant::now();
tokio::time::sleep(five_seconds);
let end = begin.elapsed();
println!("thread {} finishing {}", peer.to_string(),
end.as_secs_f32());
});
在这里,我们打印线程开始时的时间,并在结束时打印持续时间。持续时间应该超过延迟。我们还让线程睡眠。打印语句和睡眠功能将使我们能够追踪当我们从不同的程序发送多个消息时发生了什么。
现在我们已经定义了接受 TCP 流量的程序,我们可以在不同的目录中创建一个新的 Rust cargo 项目作为客户端,该客户端将向服务器发送消息。在这个新项目中,Cargo.toml
文件将包含与 TCP 服务器相同的依赖项。在main.rs
文件中,我们有以下简单的程序:
use tokio::net::TcpStream;
use tokio::io::AsyncWriteExt;
use std::error::Error;
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
let mut stream =
TcpStream::connect("127.0.0.1:8080").await?;
println!("stream starting");
stream.write_all(b"hello world").await?;
println!("stream finished");
Ok(())
}
这个程序仅仅与 TCP 服务器建立连接,然后将hello world
以字节形式写入 TCP 流。一旦字节写入完成,我们就结束了。现在,我们可以测试我们的服务器。首先,我们需要运行我们的服务器,这将给出以下输出:
Listening on: 127.0.0.1:8080
终端现在应该处于挂起状态,这意味着我们可以尽可能快地连续在另一个终端运行我们的客户端三次,以便在第一个线程停止休眠之前获得三个请求。这将使我们能够看到休眠线程对我们应用程序的影响。我们的客户端输出应该如下所示:
Finished dev [unoptimized + debuginfo] target(s) in
0.87s
Running `target/debug/simulation_client`
stream starting
stream finished
Finished dev [unoptimized + debuginfo] target(s) in
0.02s
Running `target/debug/simulation_client`
stream starting
stream finished
Finished dev [unoptimized + debuginfo] target(s) in
0.01s
Running `target/debug/simulation_client`
stream starting
stream finished
如果我们等待 7 秒钟,我们可以检查我们的服务器终端,它应该有以下的输出:
Incoming connection from: 127.0.0.1:57716
thread starting 127.0.0.1:57716 starting
Incoming connection from: 127.0.0.1:57717
thread starting 127.0.0.1:57717 starting
Incoming connection from: 127.0.0.1:57718
thread starting 127.0.0.1:57718 starting
thread 127.0.0.1:57716 finishing
thread 127.0.0.1:57717 finishing
thread 127.0.0.1:57718 finishing
在这里,我们可以看到每个进程在本地主机上都有一个端口。正如预期的那样,通过创建线程来处理传入的消息,消息是以异步方式处理的。如果需要,我们可以处理更多的连接。
现在我们已经成功通过 TCP 接收到了字节,我们将在下一节中处理这些字节。
处理字节
为什么我们通过 TCP 通道发送字节而不是字符串本身呢?我们发送字节是因为它们有标准化的编码和解码方式。例如,在本章中,我们创建了一个用 Rust 编写的客户端。然而,客户端可能是用 JavaScript 或 Python 编写的。原始数据结构,如字符串字符,可以编码成字节,然后在 TCP 服务器接收到时解码。由于 UTF-8 标准,我们可以在任何地方使用这些字符串。我们的数据可以通过一个文本编辑器保存,并通过另一个文本编辑器加载,因为它们都使用相同的编码。
如果我们继续探索字节的概念,我们将得出结论,计算机能够存储的唯一数据是字节。MP3、WAV、JPEG、PNG 等都是编码的例子。如果你保存任何文件,你将把数据编码成字节。如果我们加载任何文件,我们将从字节中解码数据。现在,让我们解码通过 TCP 发送的字节字符串。
在 TCP 服务器项目的 main.rs
文件中,我们首先需要导入以下内容:
use tokio::io::{BufReader, AsyncBufReadExt, AsyncWriteExt};
BufReader
结构体本质上为任何读取器添加了一个缓冲区,这提高了从同一文件或套接字进行的小而频繁读取的速度。虽然这在我们期望通过同一 TCP 套接字发送多个小消息时非常有帮助,但如果我们要一次性从文件或套接字中读取大量数据,它将不会给我们带来任何速度提升。其他两个导入特性必须导入,才能使 BufReader
结构体能够读取或写入。
现在,我们必须擦除代码中线程创建部分的全部代码,以便重新开始,因为我们将会进行一系列不同的进程。首先,在我们的线程创建代码内部,我们必须打印出我们正在启动线程,通过将我们的流拆分为读取器和写入器,然后从读取器创建我们的缓冲区读取器,并使用一个空的向量来存储处理后的传入数据:
println!("thread starting {} starting", peer.to_string());
let (reader, mut writer) = stream.split();
let mut buf_reader = BufReader::new(reader);
let mut buf = vec![];
现在我们已经准备好读取一切,我们可以逐行连续读取,告诉读者一旦遇到流中的 EOF 条件('b\n'
),就停止读取:
loop {
match buf_reader.read_until(b'\n', &mut buf).await {
Ok(n) => {
. . .
},
Err(e) => println!("Error receiving message: {}", e)
}
}
我们可以看到,如果发生错误,我们会将其打印出来。然而,当我们处理字节时,我们关心的其余代码都在我们的Ok
代码块中。
在我们的Ok
代码块内部,我们最初需要检查流是否已关闭,方法是检查是否接收到了零字节:
if n == 0 {
println!("EOF received");
break;
}
EOF
代表文件结束。EOF 是声明我们已到达文件末尾或数据流已结束的标准方式。一旦我们越过前面的代码块,我们就知道我们有一些字节需要处理。我们必须使用 UTF-8 编码将我们的输入字节转换为字符串:
let buf_string = String::from_utf8_lossy(&buf);
前面代码中的丢失引用是指非标准字符被替换为占位符,因此非标准字符在翻译中会丢失。由于我们发送的是标准字符,所以这对我们来说不会是问题。在我们的字符串数据中,我们将使用;
分隔符将消息中的值分开。我们将使用以下代码将字符串拆分为字符串向量,并将所有新行替换掉:
let data: Vec<String> = buf_string.split(";")
.map(|x| x.to_string()
.replace("\n", ""))
.collect();
现在,我们可以打印出处理过的消息,然后清除缓冲区,这样我们正在处理的行就不会在下一个处理步骤中被捕获:
println!(
"Received message: {:?}",
data
);
buf.clear();
我们现在到达了循环的末尾。在循环外部,我们必须使用以下代码打印出线程已完成:
println!("thread {} finishing", peer.to_string());
这样,我们现在已经完成了服务器,因为我们能够处理字节。现在,我们可以转到客户端项目的main.rs
文件,并写入以下字节字符串:
println!("stream starting");
stream.write_all(b"one;two\nthree;four").await?;
println!("stream finished");
你认为我们会从这个字节字符串中得到什么结果?如果我们查看字节字符串,我们会看到有一个新行,因此我们通过 TCP 连接发送了一个数据包中的两个消息。由于;
分隔符,每个消息有两个值。当我们启动服务器并运行客户端时,我们将得到以下输出:
Incoming connection from: 127.0.0.1:59172
thread starting 127.0.0.1:59172 starting
Received message: ["one", "two"]
Received message: ["three", "four"]
EOF received
thread 127.0.0.1:59172 finishing
我们可以看到,在我们关闭线程之前,我们的服务器在同一线程中处理了两个消息。因此,我们可以看到,我们在 TCP 套接字方面有很多灵活性。我们现在已经准备好将 TCP 流量路由到我们的 actor。
将 TCP 传递给 actor
当涉及到将 TCP 数据路由到 actor 时,我们需要将我们的 actor 和 channel 导入服务器项目中的main.rs
文件,以下代码如下:
. . .
use tokio::sync::mpsc;
mod actors;
use actors::{OrderBookActor, BuyOrder, Message};
. . .
现在,我们必须构建我们的订单簿actor 并运行它。然而,如您所回忆的,我们只是在 Tokio 运行时的末尾运行了订单簿actor。然而,如果我们在这里应用这种策略,我们将阻塞循环的执行,这样我们就可以监听传入的流量。如果我们将订单簿actor 在循环之后运行,由于循环在while
循环中无限期运行,因此会阻塞任何后续代码的执行。在我们的情况下,还有一个更复杂的因素。这个复杂因素是 actor 运行函数进入了一个while
循环,这进一步解释了为什么需要将整个代码放在一个单独的 spawned Tokio 任务中。因此,我们必须在循环之前 spawn 一个线程:
#[tokio::main]
async fn main() {
let addr = "127.0.0.1:8080".to_string();
let mut socket =
TcpListener::bind(&addr).await.unwrap();
println!("Listening on: {}", addr);
let (tx, rx) = mpsc::channel::<Message>(1);
tokio::spawn(async move {
let order_book_actor = OrderBookActor::new(rx,
20.0);
order_book_actor.run().await;
});
println!("order book running now");
while let Ok((mut stream, peer)) =
socket.accept().await {
println!("Incoming connection from: {}",
peer.to_string());
let tx_one = tx.clone();
. . .
注意,我们直接将tx
接收器移动到 Tokio 任务中而不进行克隆,因为OrderBookActor
是唯一完全独占拥有接收器的演员。在这里,我们可以看到 TCP 监听器也是一样的。然后,我们创建mpsc
通道,我们使用它来在第一个我们创建的线程中创建和运行我们的订单簿演员。然后,我们进入while
循环来监听 TCP 流量。注意,订单簿演员的 mpsc 通道发送者的克隆立即进行。这是因为我们将在下一次迭代的读取循环中再次克隆它。
在循环中的Ok
块内,我们处理我们的字节字符串,创建新的购买订单演员,然后将消息发送到订单 簿演员:
. . .
let data: Vec<String> = buf_string.split(";")
.map(|x| x.to_string().replace("\n", "")).collect();
let amount = data[0].parse::<f32>().unwrap();
let order_actor = BuyOrder::new(amount, data[1].clone(),
tx_one.clone());
println!("{}: {}", order_actor.ticker, order_actor.amount);
order_actor.send().await;
buf.clear();
这就应该是全部了。我们可以看到,在标准 Tokio 运行时中定义的演员,无需监听流量就可以插入到我们的 TCP 网络应用程序中。只剩下最后一件事要做,那就是更新客户端main.rs
文件中发送的消息内容:
. . .
println!("stream starting");
stream.write_all(b"8.0;BYND;\n9.0;PLTR").await?;
println!("stream finished");
. . .
在这里,我们发送了两个购买订单(BYND
和PLTR
)。如果我们运行我们的服务器然后运行我们的客户端,我们将得到以下服务器打印输出:
Listening on: 127.0.0.1:8080
order book running now
actor is running
Incoming connection from: 127.0.0.1:59769
thread starting 127.0.0.1:59769 starting
BYND: 8
processing purchase, total invested: 8
here is the outcome: 1
PLTR: 9
processing purchase, total invested: 17
here is the outcome: 1
EOF received
thread 127.0.0.1:59769 finishing
通过这个打印输出,我们可以看到我们在处理传入流量之前运行了订单簿演员并监听了 TCP 流量。然后,我们接受我们的数据包,处理数据,并将数据发送到演员系统。总的来说,我们的应用程序流程如下所示:
图 15.2 – 我们的 TCP 应用程序流程
这样,我们现在有一个网络应用程序,它可以接受 TCP 流量并将处理后的数据从传入的字节传递到我们的演员系统中。当我们通过 TCP 接受新消息时,我们会即时创建购买订单演员,而我们的订单簿演员在整个程序的生命周期内持续运行。如果我们想添加另一个订单簿或不同类型的演员,我们只需在另一个线程中构建和运行演员即可。这没有限制,因此我们的系统可以扩展。
目前,我们的客户端不知道发生了什么。因此,我们的服务器必须向客户端回复发生了什么。然而,在我们能够这样做之前,我们必须跟踪我们的股票订单,以便在需要时返回订单的状态。
使用演员跟踪订单
当谈到跟踪我们的订单时,我们可以在订单簿中简单地添加一个 HashMap,并添加几个可以发送给订单簿actor 的其他消息。这是一种方法。我们正在进入一个没有明确正确方法的领域,社区内的人们在最佳解决方案上争论。在本章中,我们将通过创建两个新的 actor 来习惯在 Tokio 中创建 actor 和管理多个 actor。一个 actor 将仅跟踪我们的股票购买,而另一个 actor 将向订单跟踪器发送消息以获取我们订单的状态。
首先,我们需要在src/order_tracker.rs
中创建一个单独的文件。在这个文件中,我们最初需要导入处理股票集合和 actor 之间连接的通道所需的内容:
use tokio::sync::{mpsc, oneshot};
use std::collections::HashMap;
然后,我们需要为发送给我们的 tracker actor 的消息创建消息结构体:
#[derive(Debug, Clone)]
pub enum Order {
BUY(String, f32),
GET
}
pub struct TrackerMessage {
pub command: Order,
pub respond_to: oneshot::Sender<String>
}
在这里,我们需要传递一个命令。这个命令是必需的,因为 tracker actor 可以执行多个操作,例如BUY
和GET
。如果命令只是GET
,那么我们不需要其他任何东西,这就是为什么其余字段都是可选的。
定义了此消息后,我们可以构建最基础的 actor,它仅仅向 tracker actor 发送一个get
消息并返回我们订单的状态:
pub struct GetTrackerActor {
pub sender: mpsc::Sender<TrackerMessage>
}
在这里,我们可以看到GetTrackerActor
没有保留任何状态。我们完全可以把这个整个 actor 做成另一个 actor 中的函数。然而,正如之前所述,我们希望在本章中熟悉在异步系统中管理多个 actor。为了使我们的GetTrackerActor
能够获取数据,我们必须创建一个send
函数,该函数将发送GET
命令到 tracker actor,并将 tracker actor 的状态作为字符串返回:
impl GetTrackerActor {
pub async fn send(self) -> String {
println!("GET function firing");
let (send, recv) = oneshot::channel();
let message = TrackerMessage {
command: Order::GET,
respond_to: send
};
let _ = self.sender.send(message).await;
match recv.await {
Err(e) => panic!("{}", e),
Ok(outcome) => return outcome
}
}
}
现在您应该熟悉这个方法了。我们创建了一个单次通道,以便 tracker actor 可以向GetTrackerActor
actor 发送消息。然后,我们发送了GET
消息并等待响应。您可能也注意到了,我们在打印出send
函数正在触发。我们将在代码中穿插打印语句,以便我们可以跟踪async
代码在打印输出中的运行情况和顺序。
我们现在处于需要创建我们的订单跟踪器actor 的阶段。我们需要一个 HashMap 和一个接收消息的通道,我们可以用以下代码创建:
pub struct TrackerActor {
pub receiver: mpsc::Receiver<TrackerMessage>,
pub db: HashMap<String, f32>
}
这个 actor 更复杂,因为我们需要 actor 运行以接收消息、处理消息并发送订单状态,其结构如下:
impl TrackerActor {
pub fn new(receiver: mpsc::Receiver<TrackerMessage>) ->
Self {
. . .
}
fn send_state(&self, respond_to: oneshot::
Sender<String>) {
. . .
}
fn handle_message(&mut self, message: TrackerMessage) {
. . .
}
pub async fn run(mut self) {
. . .
}
}
如果您想测试对 actor 的处理,现在是尝试实现前面函数的好时机。
如果您尝试了这些函数,它们应该类似于以下代码所示,我们将在这里介绍。首先,我们的构造函数如下所示:
pub fn new(receiver: mpsc::Receiver<TrackerMessage>) ->
Self {
TrackerActor {
receiver,
db: HashMap::new(),
}
}
这个构造函数对任何人来说都不应该感到惊讶。我们需要一个 HashMap,其中字符串作为键来表示股票代码,浮点数表示我们拥有的该股票代码的股票数量。我们还接受一个通道接收器来接收消息。
我们接下来需要定义的过程是如何将我们的数据打包成一个字符串,以便我们可以通过 TCP 发送它。我们可以用以下代码来完成这个任务:
fn send_state(&self, respond_to: oneshot::Sender<String>) {
let mut buffer = Vec::new();
for key in self.db.keys() {
let amount = self.db.get(key).unwrap();
buffer.push(format!("{}:{ };", &key, amount));
}
buffer.push("\n".to_string());
println!("sending state: {}", buffer.join(""));
respond_to.send(buffer.join(""));
}
在这里,我们创建一个向量来存储我们的数据。然后,我们遍历我们的 HashMap,它记录着我们的股票持有情况。我们可以看到,我们用冒号:
将股票代码和数量分开,然后用分号;
将单独的股票代码和计数分开。此时,我们的响应应该是类似"BYND:8;PLTR:9;\n"
的字符串,这意味着我们持有 8 股 BYND 和 9 股 PLTR。一旦我们将整个状态存储在一个字符串向量中,我们就将向量连接成一个字符串,然后通过通道发送该字符串。
我们现在拥有了处理传入消息所需的一切,这可以通过以下代码来完成:
fn handle_message(&mut self, message: TrackerMessage) {
match message.command {
Order::GET => {
println!("getting state");
self.send_state(message.respond_to);
},
Order::BUY(ticker, amount) => {
match self.db.get(&ticker) {
Some(ticker_amount) => {
self.db.insert(ticker, ticker_amount +
amount);
},
None => {
self.db.insert(ticker, amount);
}
}
println!("db: {:?}", self.db);
}
}
}
在这里,我们匹配通过传入消息传递的命令。如果传递了一个GET
命令,我们只需返回带有响应地址的状态,该地址从传入的消息中提取出来。如果传递了一个BUY
命令,我们从消息中提取购买订单的参数并尝试从 HashMap 中获取股票代码。如果股票代码不存在,我们创建一个新的条目。如果股票代码存在,我们只需增加我们已购买的股票代码的数量。
我们现在已经处理了我们的消息和状态。只剩下一件事要做,那就是运行演员;这可以通过以下代码实现:
pub async fn run(mut self) {
println!("tracker actor is running");
while let Some(msg) = self.receiver.recv().await {
self.handle_message(msg);
}
}
使用这个方法,我们的跟踪器演员已经完全工作,现在是时候退后一步,看看我们的系统以及我们设想它如何工作,如下面的图所示:
图 15.3 – 演员之间的交互
在这里,我们可以看到,当执行购买订单时,订单簿演员和跟踪器演员之间必须存在交互。因此,我们需要重构我们的订单簿演员,以实现多个演员之间的链式通信。
连接演员之间的通信
如图 15.2所示,我们的订单簿演员正在运行并接受订单。一旦处理完BUY
订单,订单簿演员就会向跟踪器演员发送消息,更新状态。这意味着我们的演员需要管理两个通道。为了处理两个通道,在src/actors.rs
文件中,我们需要用以下代码导入跟踪器消息:
use tokio::sync::{mpsc, oneshot, mpsc::Sender};
use crate::order_tracker::TrackerMessage;
现在,我们必须持有两个通道,这导致我们的OrderBookActor
结构体具有以下字段:
pub struct OrderBookActor {
pub receiver: mpsc::Receiver<Message>,
pub sender: mpsc::Sender<TrackerMessage>,
pub total_invested: f32,
pub investment_cap: f32
}
这里,字段基本上是相同的,但我们保留了一个发送消息到跟踪器的 sender。我们可以看到不同的消息是多么有帮助。我们知道消息确切的目的地。有了这个额外字段,我们需要稍微修改OrderBookActor
的构造函数,使用以下代码:
pub fn new(receiver: mpsc::Receiver<Message>,
sender: mpsc::Sender<TrackerMessage>,
investment_cap: f32) -> Self {
OrderBookActor {
receiver, sender,
total_invested: 0.0,
investment_cap
}
}
我们必须添加的唯一其他行为是。记住我们在handle_message
函数中处理我们的传入消息。在这里,我们必须使用以下代码向跟踪器 actor 发送一个TrackerMessage
:
async fn handle_message(&mut self, message: Message) {
if message.amount + self.total_invested >=
self.investment_cap {
println!("rejecting purchase, total invested: {}",
self.total_invested);
let _ = message.respond_to.send(0);
}
else {
self.total_invested += message.amount;
println!("processing purchase, total invested: {}",
self.total_invested);
let _ = message.respond_to.send(1);
let (send, _) = oneshot::channel();
let tracker_message = TrackerMessage{
command: "BUY".to_string(),
ticker: Some(message.ticker),
amount: Some(message.amount),
respond_to: send
};
let _ = self.sender.send(tracker_message).await;
}
}
如我们所见,决定是否处理买入订单的逻辑是相同的,但如果买入订单被处理,我们只是构建一个TrackerMessage
并发送到*tracker*
actor。
现在我们已经构建和重构了 actors,我们的 actor 系统将像图 15**.2所示那样表现。我们现在可以实施我们的新 actor 系统,以便我们可以用 TCP 响应 TCP 流量。
使用 TCP 响应
当涉及到响应 TCP 时,我们必须在src/main.rs
文件中实现我们的 actor 系统。首先,我们需要使用以下代码导入我们的新 actors:
. . .
use order_tracker::{TrackerActor, GetTrackerActor,
TrackerMessage};
现在,我们必须在main
函数中使用以下代码构建我们的额外通道:
let addr = "127.0.0.1:8080".to_string();
let socket = TcpListener::bind(&addr).await.unwrap();
println!("Listening on: {}", addr);
let (tx, rx) = mpsc::channel::<Message>(1);
let (tracker_tx, tracker_rx) =
mpsc::channel::<TrackerMessage>(1);
let tracker_tx_one = tracker_tx.clone();
这里,我们有一个tracker
通道。使用tracker
和main
通道,我们可以使用以下代码启动两个不同的线程,分别运行*tracker*
actor 和*订单簿*
actor:
tokio::spawn( async {
TrackerActor::new(tracker_rx).run();
});
tokio::spawn(async move {
let order_book_actor = OrderBookActor::new(
rx, tracker_tx_one.clone(), 20.0);
order_book_actor.run().await;
});
这样,我们现在有两个 actor 正在运行,等待传入的消息。现在,我们必须管理我们的传入 TCP 流量,并根据传入的命令启动不同的 actor。作为一个设计选择,我们将通过 TCP 传入的第一个字符串作为我们的应用程序的命令:
let buf_string = String::from_utf8_lossy(&buf);
let data: Vec<String> = buf_string.split(";")
.map(|x| x.to_string().replace("\n", "")).collect();
println!("here is the data {:?}", data);
let command = data[0].clone();
然后,我们必须使用以下代码匹配我们的命令:
match command.as_str() {
"BUY" => {
. . .
},
"GET" => {
. . .
},
_ => {
panic!("{} command not supported", command);
}
}
buf.clear();
对于我们的买入订单,我们仍然简单地启动*买入订单*
actor 并将其发送到*订单簿*
actor:
println!("buy order command processed");
let amount = data[1].parse::<f32>().unwrap();
let order_actor = BuyOrder::new(amount, data[2].clone(),
tx_one.clone());
println!("{}: {}", order_actor.ticker, order_actor.amount);
order_actor.send().await;
这里的主要变化是我们如何管理传入的数据,这是因为我们引入了command
参数。对于get
命令,我们创建GetTrackerActor
,向跟踪器 actor 发送消息。然后,我们使用以下代码写入从*tracker*
actor 获取的状态:
println!("get order command processed");
let get_actor =
GetTrackerActor{sender: tracker_tx_two.clone()};
let state = get_actor.send().await;
println!("sending back: {:?}", state);
writer.write_all(state.as_bytes()).await.unwrap();
这样,我们的服务器现在可以接受不同的命令并跟踪我们所有的买入订单。
尽管我们的服务器现在完全功能化,但我们的客户端将不会工作。这是因为我们没有更新我们的客户端以包含命令。在下一节中,我们将更新我们的客户端,使其能够发送多个不同的命令。
通过客户端发送不同的命令
我们的客户端很简单,并且将保持简单。首先,我们必须确保我们的读取和写入特性被导入,因为这次我们将读取一个响应。我们的src/main.rs
文件中的导入应该看起来像这样:
use tokio::net::TcpStream;
use tokio::io::{BufReader, AsyncBufReadExt, AsyncWriteExt};
use std::error::Error;
然后,我们必须向我们的连接写入一系列消息,然后读取,直到我们得到一个新行:
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
let mut stream =
TcpStream::connect("127.0.0.1:8080").await?;
let (reader, mut writer) = stream.split();
println!("stream starting");
writer.write_all(b"BUY;8.0;BYND;\nBUY;9.0;PLTR\n
BUY;9.0;PLTR\nGET\n").await?;
println!("sent data");
let mut buf_reader = BufReader::new(reader);
let mut buf = vec![];
println!("reading data");
let _ = buf_reader.read_until(b'\n',
&mut buf).await.unwrap();
let state_string = String::from_utf8_lossy(&buf);
println!("{}", state_string);
Ok(())
}
通过这些,我们已经完成了。我们现在必须做的就是运行服务器,然后运行客户端。客户端有以下输出:
stream starting
sent data
reading data
PLTR:9;BYND:8;
在这里,我们在发送我们的订单后获得了我们的股票订单的状态。尽管这个状态是一个单独的字符串,但我们有分隔符,这样我们就可以将我们的数据分割成有用的东西。一旦我们的客户端运行完毕,我们的服务器将会有以下输出:
Incoming connection from: 127.0.0.1:61494
thread starting 127.0.0.1:61494 starting
here is the data ["BUY", "8.0", "BYND", ""]
buy order command processed
BYND: 8
processing purchase, total invested: 8
db: {"BYND": 8.0}
here is the outcome: 1
here is the data ["BUY", "9.0", "PLTR"]
buy order command processed
PLTR: 9
processing purchase, total invested: 17
db: {"PLTR": 9.0, "BYND": 8.0}
here is the outcome: 1
here is the data ["BUY", "9.0", "PLTR"]
buy order command processed
PLTR: 9
rejecting purchase, total invested: 17
here is the outcome: 0
here is the data ["GET"]
get order command processed
GET function firing
getting state
sending state: PLTR:9;BYND:8;
sending back: "PLTR:9;BYND:8;\n"
EOF received
thread 127.0.0.1:61494 finishing
这是一个很长的输出,但我们可以看到我们的订单消息是如何被转换成向量的。我们还可以看到我们的跟踪器状态是如何随时间变化的,最后,我们可以看到我们的get
命令是如何与*get 状态*
演员和*跟踪器*
演员一起处理的。
摘要
在这一章中,我们通过接受传入的 TCP 流量,将我们的 Tokio 异步程序提升到了下一个层次。然后,我们处理了我们的 TCP 流量,这些流量被封装在字节中,实际上创建了一个处理股票买入订单的协议。我们必须注意,我们在这方面有很大的灵活性。我们设法将多个买入订单和一个get
命令塞进一个消息中。如果我们保持服务器和客户端之间协议的一致性,我们可以在消息结构上变得更有创意,因为包装和服务器中的解包消息的方式几乎没有限制。
然后,我们在系统中添加了更多的线程和演员来处理传递给我们的服务器的多个命令。我们通过更新我们的客户端并返回我们的订单状态来完成这一章。结果是,一个高度异步安全的网络应用程序,通过 TCP 接受消息。这个网络应用程序不仅可以在我们的本地计算机上运行。我们可以将这个 TCP Tokio 网络应用程序包装在 Docker 中,并在服务器上部署它。你现在有了构建底层网络应用程序的工具,以帮助你的 Web 应用程序。考虑到我们的 distroless Rust 服务器大小约为 50 MB,这些网络应用程序将是你试图解决的问题的低成本辅助工具。
虽然拥有协议很有用,并给我们更多的自由度,但我们在下一章中会将 TCP 流量处理的协议处理提升到下一个层次,通过帧定界,使我们能够对通过 TCP 发送的消息的处理和包装有更多的控制。
进一步阅读
Tokio TCP 文档:docs.rs/tokio/latest/tokio/net/struct.TcpStream.html
。
问题
-
我们如何创建一个接受消息并将消息发送给其他演员的演员?
-
为什么我们需要在我们的线程中启动长时间运行的演员?
-
我们如何让多个演员处理相同类型的任务?
答案
-
我们创建了一个具有至少两个字段的演员。这两个字段持有我们发送消息的演员的通道发送者和我们接收消息的通道接收者。然后,我们需要一个
run
函数来使我们的演员能够运行并等待传入的消息。 -
如果我们不创建一个线程来运行我们的长时间运行 actor,我们的主运行时将被这个运行中的 actor 阻塞。如果服务器监听后只有一个 actor 在运行,这是可以的;然而,如果有多个 actor 或一个接受 TCP 流量的循环,那么我们将遇到问题,因为系统将基本上陷入僵局,我们的 actor 系统将无法工作。
-
我们可以构建一个本质上像路由器的 actor。它可以跟踪传入的消息,并将消息交替发送到多个执行相同类型工作的不同 actor。然而,如果多个 actor 依赖于内部状态,则不要这样做。
第十六章:在 TCP 之上构建协议
在上一章中,我们使用了 Tokio 框架来支持异步 actor 模型。我们的 Tokio 框架接受基本流量,并在消息处理完毕后将这些消息发送给 actor。然而,我们的 TCP 处理是基本的。如果你对 TCP 的唯一接触就是这本书,那么你不应该在这个基本的 TCP 流程上构建复杂的系统。在本章中,我们将完全专注于如何在 TCP 连接上打包、发送和读取数据。
在本章中,我们将涵盖以下主题:
-
设置 TCP 客户端和回声服务器
-
使用结构体在 TCP 上处理字节
-
在 TCP 上创建帧以分隔消息
-
在 TCP 之上构建 HTTP 帧
到本章结束时,你将能够使用多种不同的方法打包、发送和读取通过 TCP 发送的数据。你将能够理解如何将你的数据分割成可以处理为结构的帧。最后,你将能够构建一个包含 URL 和方法头的 HTTP 帧,以及包含数据的主体。这将使你能够在发送 TCP 数据时构建所需的数据结构。
技术要求
在本章中,我们将纯粹专注于如何在 TCP 连接上处理数据。因此,我们不会依赖任何之前的代码,因为我们正在构建自己的回声服务器。
本章的代码可以在github.com/PacktPublishing/Rust-Web-Programming-2nd-Edition/tree/main/chapter16
找到。
设置我们的 TCP 客户端和服务器
为了探索发送和处理 TCP 上的字节,我们将创建一个基本的回声服务器和客户端。我们将放弃上一章中构建的任何复杂逻辑,因为我们不需要在尝试探索发送、接收和处理字节的想法时被复杂逻辑分散注意力。
在一个新的目录中,我们应该有两个 cargo 项目——一个用于服务器,另一个用于客户端。它们可以采用以下文件结构:
├── client
│ ├── Cargo.toml
│ └── src
│ └── main.rs
└── server
├── Cargo.toml
└── src
└── main.rs
两个项目都将使用相同的 Tokio 依赖,因此两个项目应该在它们的Cargo.toml
文件中定义以下依赖:
[dependencies]
tokio = { version = "1", features = ["full"] }
我们现在需要构建回声服务器的基本机制。这是客户端向服务器发送消息的地方。服务器随后处理客户端发送的消息,重新打包消息,并将相同的消息发送回客户端。我们将从构建我们的服务器开始。
设置我们的 TCP 服务器
我们可以通过以下代码在server/src/main.rs
文件中定义服务器:首先导入我们需要的所有内容:
use tokio::net::TcpListener;
use tokio::io::{BufReader, AsyncBufReadExt, AsyncWriteExt};
这样做是为了我们可以监听传入的 TCP 流,从该流量中读取字节,并将其写回发送消息的客户。然后,我们需要利用 Tokio 运行时来监听传入的流量,如果收到消息则启动一个线程。如果你完成了上一章,这是一个很好的机会尝试自己完成这个任务,因为我们已经涵盖了创建监听传入流量的 TCP 服务器的概念。
如果你尝试编写 TCP 服务器的基础知识,你的代码应该看起来像这样:
#[tokio::main]
async fn main() {
let addr = "127.0.0.1:8080".to_string();
let socket = TcpListener::bind(&addr).await.unwrap();
println!("Listening on: {}", addr);
while let Ok((mut stream, peer)) =
socket.accept().await {
println!("Incoming connection from: {}",
peer.to_string());
tokio::spawn(async move {
. . .
});
}
}
在这里,我们应该熟悉以下概念:我们创建一个监听器,将其绑定到地址,然后等待传入的消息,当收到消息时创建一个线程。在线程内部,我们通过以下代码循环处理传入的消息,直到出现带有以下代码的新行:
println!("thread starting {} starting", peer.to_string());
let (reader, mut writer) = stream.split();
let mut buf_reader = BufReader::new(reader);
let mut buf = vec![];
loop {
match buf_reader.read_until(b'\n', &mut buf).await {
Ok(n) => {
if n == 0 {
println!("EOF received");
break;
}
let buf_string = String::from_utf8_lossy(&buf);
writer.write_all(buf_string.as_bytes())
.await.unwrap();
buf.clear();
},
Err(e) => println!("Error receiving message: {}", e)
}
}
println!("thread {} finishing", peer.to_string());
到现在为止,这段代码应该不会让你感到惊讶。如果你对前面代码中涵盖的任何概念不熟悉,建议你阅读上一章。
现在我们已经定义了一个基本的回声服务器,并且它已经准备好运行,这意味着我们可以将注意力转向在 client/src/main.rs
文件中创建我们的客户端代码。我们需要相同的 structs 和 traits 来使客户端工作。然后,我们需要向 TCP 服务器发送一个标准的文本消息。这是一个尝试自己实现客户端的好时机,而且之前已经多次涵盖了构建客户端所需的所有内容。
设置我们的 TCP 客户端
如果你尝试自己构建客户端,你应该已经导入了以下 structs 和 traits:
use tokio::net::TcpStream;
use tokio::io::{BufReader, AsyncBufReadExt, AsyncWriteExt};
use std::error::Error;
然后,我们必须建立 TCP 连接,发送一条消息,等待消息被发送回来,然后使用 Tokio 运行时将其打印出来:
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
let mut stream =
TcpStream::connect("127.0.0.1:8080").await?;
let (reader, mut writer) = stream.split();
println!("stream starting");
writer.write_all(b"this is a test\n").await?;
println!("sent data");
let mut buf_reader = BufReader::new(reader);
let mut buf = vec![];
println!("reading data");
let _ = buf_reader.read_until(b'\n', &mut
buf).await.unwrap();
let message = String::from_utf8_lossy(&buf);
println!("{}", message);
Ok(())
}
现在我们有一个运行中的客户端和服务器。为了测试客户端和服务器是否工作正常,我们必须在一个终端中启动服务器,然后在另一个终端中运行客户端,通过运行 cargo run
。服务器将显示以下输出:
stream starting
sent data
reading data
this is a test
我们服务器的输出将如下所示:
Listening on: 127.0.0.1:8080
Incoming connection from: 127.0.0.1:60545
thread starting 127.0.0.1:60545 starting
EOF received
thread 127.0.0.1:60545 finishing
这样,我们就有一个基本的回声服务器和客户端正在工作。现在我们可以专注于打包、解包和处理字节。在下一节中,我们将探讨使用 structs 标准化处理消息的基本方法。
使用 structs 处理字节
在上一章中,我们向服务器发送字符串。然而,结果是我们必须将单个值解析成我们需要的类型。由于字符串解析没有很好地结构化,其他开发者不清楚我们的消息结构。我们可以通过定义一个可以发送到 TCP 通道的 struct 来使我们的消息结构更清晰。通过在发送 struct 本身之前将其转换为二进制格式,我们可以实现通过 TCP 通道发送 struct。这也被称为序列化数据。
如果我们要将结构体转换为二进制格式,首先,我们需要利用serde
和bincode
包。有了我们新的包,客户端和服务器Cargo.toml
文件应该包含以下依赖项:
[dependencies]
serde = { version = "1.0.144", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
bincode = "1.3.3"
将会使用serde
包来序列化结构体,同时使用bincode
包将我们的消息结构体转换为二进制格式。现在,我们的依赖项已经定义好了,我们可以开始创建消息发送客户端。
创建消息发送客户端
我们可以构建client/src/main.rs
文件以通过 TCP 发送结构体。首先,我们必须导入我们需要的内容:
. . .
use serde::{Serialize, Deserialize};
use bincode;
在准备好导入之后,我们可以使用以下代码定义我们的Message
结构体:
#[derive(Serialize, Deserialize, Debug)]
struct Message {
pub ticker: String,
pub amount: f32
}
我们Message
结构体的定义形式与我们在 Actix 服务器上处理 HTTP 请求 JSON 主体的结构体类似。然而,这次我们不会使用 Actix Web 的结构体和特性来处理结构体。
我们的Message
结构体现在可以在main
函数中使用。记住,在我们的main
函数中,我们有一个由以下代码创建的 TCP 流:
let mut stream = TcpStream::connect("127.0.0.1:8080").await?;
let (reader, mut writer) = stream.split();
现在我们已经建立了连接,我们可以创建我们的Message
结构体,并将Message
结构体转换为二进制格式:
let message = Message{ticker: String::from("BYND"),
amount: 3.2};
let message_bin = bincode::serialize(&message).unwrap();
我们的Message
结构体现在已经是二进制格式。然后,我们必须使用以下代码将我们的消息通过 TCP 流发送出去:
println!("stream starting");
writer.write_all(&message_bin).await?;
writer.write_all(b"\n").await?;
println!("sent data");
注意,我们发送了消息然后换行。这是因为我们的服务器将会读取直到出现新行。如果我们不发送新行,那么程序将会挂起并且永远不会完成。
现在我们已经发送了消息,我们可以等待直到我们再次收到消息。然后,我们必须从二进制格式构造Message
结构体,并使用以下代码打印出构造的Message
结构体:
let mut buf_reader = BufReader::new(reader);
let mut buf = vec![];
println!("reading data");
let _ = buf_reader.read_until(b'\n',
&mut buf).await.unwrap();
println!("{:?}", bincode::deserialize::<Message>(&buf));
我们的客户现在已准备好向服务器发送消息。
在服务器中处理消息
当涉及到更新我们的服务器代码时,我们的目标是解包消息,打印消息,然后将消息转换为二进制格式以发送回客户端。在这个阶段,你应该能够自己实现这个更改。这是一个复习我们所覆盖内容的好机会。
如果你尝试首先在server/src/main.rs
文件中实现服务器上的消息处理,你应该已经导入了以下额外的需求:
use serde::{Serialize, Deserialize};
use bincode;
然后,你应该已经定义了Message
结构体,如下所示:
#[derive(Serialize, Deserialize, Debug)]
struct Message {
pub ticker: String,
pub amount: f32
}
现在,我们只需要处理消息,打印消息,然后将消息返回给客户端。我们可以通过在线程中的循环内管理消息来处理所有这些过程。
let message =
bincode::deserialize::<Message>(&buf).unwrap();
println!("{:?}", message);
let message_bin = bincode::serialize(&message).unwrap();
writer.write_all(&message_bin).await.unwrap();
writer.write_all(b"\n").await.unwrap();
buf.clear();
这里,我们使用了与客户端相同的方法,但方向相反——也就是说,我们首先将二进制格式转换为结构体,然后在最后将其转换回二进制格式。
如果我们运行服务器然后运行客户端,我们的服务器将给出以下输出:
Listening on: 127.0.0.1:8080
Incoming connection from: 127.0.0.1:50973
thread starting 127.0.0.1:50973 starting
Message { ticker: "BYND", amount: 3.2 }
EOF received
thread 127.0.0.1:50973 finishing
我们的客户端给出了以下输出:
stream starting
sent data
reading data
Ok(Message { ticker: "BYND", amount: 3.2 })
在这里,我们可以看到我们的Message
结构体可以被发送、接收,然后再次发送,而不会有任何妥协。这给我们的 TCP 流量带来了另一个层次的复杂性,因为我们可以为我们的消息创建更复杂的结构。例如,我们的消息中的一个字段可以是 HashMap,另一个字段可以是另一个结构体的向量,如果该结构体实现了serde
特性。我们可以根据需要更改Message
结构体的结构,而无需重写解包和打包消息的协议。其他开发者只需查看我们的Message
结构体,就可以知道通过 TCP 通道发送了什么。现在我们已经改进了通过 TCP 发送消息的方式,我们可以使用帧结构将流分割成帧。
利用帧结构
到目前为止,我们通过 TCP 发送结构体,并用换行符分隔这些消息。本质上,这是帧结构的最基本形式。然而,存在一些缺点。我们必须记住要添加一个分隔符,例如换行符;否则,我们的程序将无限期地挂起。我们还冒着在消息数据中包含分隔符的情况下,过早地将消息分割成两个消息的风险。例如,当我们使用换行符分隔消息时,在消息中包含换行符或任何特殊字符或字节以表示需要将流分割成可序列化的包的情况并非不可想象。为了防止这些问题,我们可以使用 Tokio 提供的内置帧结构支持。
在本节中,我们将重新编写客户端和服务器,因为消息的发送和接收将会改变。如果我们试图将我们的新方法插入到客户端现有的代码中,很容易导致混淆。在我们编写客户端和服务器之前,我们必须更新客户端和服务器中的Cargo.toml
文件中的依赖项:
[dependencies]
tokio-util = {version = "0.7.4", features = ["full"] }
serde = { version = "1.0.144", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
futures = "0.3.24"
bincode = "1.3.3"
bytes = "1.2.1"
在这里,我们使用了更多的 crate。我们将在本节剩余的代码中介绍它们的需求。为了掌握帧结构,我们将从一个简单的任务开始,即重新编写我们的客户端以支持帧结构。
重新编写我们的客户端以支持帧结构
记住,我们将在client/src/main.rs
文件中编写整个客户端。首先,我们必须使用以下代码从 Tokio 中导入所需的模块:
use tokio::net::TcpStream;
use tokio_util::codec::{BytesCodec, Decoder};
TcpStream
用于连接到我们的服务器。BytesCodec
结构体用于通过连接传输原始字节。我们将使用BytesCodec
结构体来配置分帧。Decoder
是一个特质,用于解码我们通过连接接受的字节。然而,当涉及到通过连接发送数据时,我们可以传递结构体、字符串或其他任何必须转换为字节的内容。因此,我们必须检查BytesCodec
结构体的实现,查看BytesCodec
的源代码。源代码可以通过查看文档或在编辑器中控制点击或悬停于BytesCodec
结构体上来检查。当我们检查BytesCodec
结构体的源代码时,我们将看到以下Encode
实现:
impl Encoder<Bytes> for BytesCodec {
. . .
}
impl Encoder<BytesMut> for BytesCodec {
. . .
}
在这里,我们只能通过BytesCodec
结构体使用Bytes
或BytesMut
通过连接发送数据。我们可以为BytesCodec
实现Encode
来发送其他类型的数据;然而,对于我们的用例来说,这过于复杂,而且直接通过我们的连接发送Bytes
更为合理。然而,在我们编写更多代码之前,我们不妨检查Bytes
的实现,以了解分帧是如何工作的。Bytes
的Encode
实现形式如下:
impl Encoder<Bytes> for BytesCodec {
type Error = io::Error;
fn encode(&mut self, data: Bytes, buf: &mut BytesMut)
-> Result<(), io::Error> {
buf.reserve(data.len());
buf.put(data);
Ok(())
}
}
在这里,我们可以看到正在传递的数据长度被保留在缓冲区中。然后,数据被放入缓冲区。
现在我们已经了解了我们将如何使用分帧来编码和解码我们的消息,我们需要从futures
和bytes
包中导入特质,以使我们能够处理我们的消息:
use futures::sink::SinkExt;
use futures::StreamExt;
use bytes::Bytes;
SinkExt
和StreamExt
特质基本上使我们能够异步地从流中接收消息。Bytes
结构体会将我们的序列化消息包装起来以便发送。然后,我们必须导入这些特质以启用消息的序列化,并定义我们的消息结构体:
use serde::{Serialize, Deserialize};
use bincode;
use std::error::Error;
#[derive(Serialize, Deserialize, Debug)]
struct Message {
pub ticker: String,
pub amount: f32
}
我们现在拥有开始工作所需的全部内容,来构建我们的运行时。请记住,我们的主要运行时具有以下大纲:
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
. . .
Ok(())
}
在我们的运行时内部,我们最初建立一个 TCP 连接,并使用以下代码定义分帧:
let stream = TcpStream::connect("127.0.0.1:8080").await?;
let mut framed = BytesCodec::new().framed(stream);
然后,我们定义我们的消息,序列化消息,并使用以下代码将消息包装在Bytes
中:
let message = Message{ticker: String::from("BYND"),
amount: 3.2};
let message_bin = bincode::serialize(&message).unwrap();
let sending_message = Bytes::from(message_bin);
然后,我们可以发送我们的消息,等待消息被发送回来,然后反序列化消息并使用以下代码将其打印出来:
framed.send(sending_message).await.unwrap();
let message = framed.next().await.unwrap().unwrap();
let message =
bincode::deserialize::<Message>(&message).unwrap();
println!("{:?}", message);
在所有这些之后,我们的客户端已经构建完成。我们可以看到,我们不必担心换行符或其他任何分隔符。在通过 TCP 发送和接收消息时,我们的代码既干净又直接。现在,我们的客户端已经构建完成,我们可以继续构建服务器,以便它能够处理分帧。
重新编写我们的服务器以支持分帧
当谈到构建支持帧处理的服务器时,它与我们在上一节中编写的代码有很多重叠。现在,尝试自己构建服务器是一个好时机。构建服务器需要将我们在上一节中编写的帧处理逻辑实现到现有的服务器代码中。
如果你尝试重写服务器,首先,你应该已经导入了以下结构体和特质:
use tokio::net::TcpListener;
use tokio_util::codec::{BytesCodec, Decoder};
use futures::StreamExt;
use futures::sink::SinkExt;
use bytes::Bytes;
use serde::{Serialize, Deserialize};
use bincode;
注意,我们导入的Decoder
特质允许我们在字节编解码器上调用.framed
。这里没有什么应该让你感到陌生的。一旦我们有了必要的导入,我们必须使用以下代码定义相同的Message
结构体:
#[derive(Serialize, Deserialize, Debug)]
struct Message {
pub ticker: String,
pub amount: f32
}
现在,我们必须使用以下代码定义服务器运行时的轮廓:
#[tokio::main]
async fn main() {
let addr = "127.0.0.1:8080".to_string();
let listener = TcpListener::bind(&addr).await.unwrap();
println!("Listening on: {}", addr);
loop {
let (socket, _) = listener.accept().await.unwrap();
tokio::spawn(async move {
. . .
});
}
}
在这里,我们可以看到监听器正在循环以接受流量,并在接收到消息时创建线程,就像之前的服务器实现一样。在我们的线程中,我们使用以下代码读取我们的帧化消息:
let mut framed = BytesCodec::new().framed(socket);
let message = framed.next().await.unwrap();
match message {
Ok(bytes) => {
. . .
},
Err(err) => println!("Socket closed with error:
{:?}", err),
}
println!("Socket received FIN packet and closed
connection");
如我们所见,我们不再有while
循环。这是因为我们的帧处理管理了消息之间的分割。
一旦我们从连接中提取出我们的字节,我们必须实现我们在客户端中做的相同逻辑,即处理我们的消息,打印它,再次处理它,然后将其发送回客户端:
let message =
bincode::deserialize::<Message>(&bytes).unwrap();
println!("{:?}", message);
let message_bin = bincode::serialize(&message).unwrap();
let sending_message = Bytes::from(message_bin);
framed.send(sending_message).await.unwrap();
现在我们有一个正在工作的客户端和服务器,它们利用了帧处理。如果我们启动服务器然后运行客户端,客户端将给出以下打印输出:
Message { ticker: "BYND", amount: 3.2 }
我们的服务器将给出以下打印输出:
Listening on: 127.0.0.1:8080
Message { ticker: "BYND", amount: 3.2 }
Socket received FIN packet and closed connection
我们的服务器和客户端现在支持帧处理。我们已经走了很长的路。现在,我们在这个章节中只剩下一个概念需要探索,那就是使用 TCP 构建 HTTP 帧。
在 TCP 之上构建 HTTP 帧
在我们探索这本书中的 Tokio 框架之前,我们使用 HTTP 在服务器之间发送和接收数据。HTTP 协议本质上是在 TCP 之上构建的。在本节中,虽然我们将创建一个 HTTP 帧,但我们不会完全模仿 HTTP 协议。相反,为了防止代码过多,我们将创建一个基本的 HTTP 帧来理解创建 HTTP 帧时使用的机制。还必须强调,这只是为了教育目的。TCP 对我们协议来说很好,但如果你想使用 HTTP 处理器,使用现成的 HTTP 处理器(如 Hyper)会更快速、更安全、更不容易出错。我们将在下一章中介绍如何使用 Hyper HTTP 处理器与 Tokio 一起使用。
当涉及到 HTTP 请求时,一个请求通常有一个头部和一个主体。当我们发送请求时,头部将告诉我们正在使用什么方法以及与请求关联的 URL。为了定义我们的 HTTP 帧,我们需要在服务器和客户端上定义相同的帧结构体。因此,我们必须为client/src/http_frame.rs
和server/src/http_frame.rs
文件编写相同的代码。首先,我们必须使用以下代码导入需要的序列化特质:
use serde::{Serialize, Deserialize};
然后,我们必须使用以下代码定义我们的 HTTP 帧:
#[derive(Serialize, Deserialize, Debug)]
pub struct HttpFrame {
pub header: Header,
pub body: Body
}
正如我们所见,我们在HttpFrame
结构体中定义了一个头和体。我们使用以下代码定义头和体结构体:
#[derive(Serialize, Deserialize, Debug)]
pub struct Header {
pub method: String,
pub uri: String,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct Body {
pub ticker: String,
pub amount: f32,
}
我们的基本 HTTP 帧现在已经完成,我们可以使用以下代码将 HTTP 帧导入客户端和服务器中的main.rs
文件:
mod http_frame;
use http_frame::{HttpFrame, Header, Body};
我们将首先在客户端的main.rs
文件中发送我们的 HTTP 帧,以下代码:
let stream = TcpStream::connect("127.0.0.1:8080").await?;
let mut framed = BytesCodec::new().framed(stream);
let message = HttpFrame{
header: Header{
method: "POST".to_string(),
uri: "www.freshcutswags.com/stock/purchase".to_string()
},
body: Body{
ticker: "BYND".to_string(),
amount: 3.2,
}
};
let message_bin = bincode::serialize(&message).unwrap();
let sending_message = Bytes::from(message_bin);
framed.send(sending_message).await.unwrap();
我们可以看到我们的 HTTP 帧开始看起来像我们在 Actix 服务器接收请求时将要处理的 HTTP 请求。对于我们的服务器中的main.rs
文件,几乎没有变化。我们只需要重新定义以下代码中正在反序列化的结构体:
let message = bincode::deserialize::<HttpFrame>(&bytes).unwrap();
println!("{:?}", message);
let message_bin = bincode::serialize(&message).unwrap();
let sending_message = Bytes::from(message_bin);
framed.send(sending_message).await.unwrap();
如果我们运行我们的服务器和客户端程序,我们会得到以下服务器输出:
Listening on: 127.0.0.1:8080
HttpFrame { header: Header {
method: "POST",
uri: "www.freshcutswags.com/stock/purchase"
},
body: Body {
ticker: "BYND",
amount: 3.2
}
}
Socket received FIN packet and closed connection
我们的客户端程序将给出以下输出:
HttpFrame { header: Header {
method: "POST",
uri: "www.freshcutswags.com/stock/purchase"
},
body: Body {
ticker: "BYND",
amount: 3.2
}
}
我们可以看到我们的数据没有损坏。我们现在已经涵盖了所有核心必要的方法和技巧,以便在 TCP 上打包、发送和读取数据时更加灵活。
摘要
在本章中,我们构建了一个基本的 TCP 客户端,向回声服务器发送和接收数据。我们首先发送基本的字符串数据,并用分隔符分隔消息。然后,我们通过序列化结构体增加了通过 TCP 连接发送的数据的复杂性。这使得我们能够拥有更复杂的数据结构。这种序列化还减少了获取所需格式的消息数据所需的处理。例如,在前一章中,我们在收到消息后会将字符串解析为浮点数。有了结构体,我们没有任何阻止我们将浮点数列表作为字段的理由,在消息序列化后,该字段将包含一个浮点数列表,而不需要任何额外的代码行。
结构体的序列化对于我们处理大多数问题已经足够,但我们探索了分帧技术,这样我们就不必依赖分隔符来区分我们通过 TCP 发送的消息。有了分帧,我们构建了一个基本的 HTTP 帧来可视化我们可以用帧做什么,以及 HTTP 是如何建立在 TCP 之上的。我们必须记住,实现 HTTP 协议比我们在本章中所做的工作要复杂得多,建议我们利用 crate 中已有的 HTTP 处理器来处理和 HTTP 流量。
在下一章中,我们将使用已建立的 Hyper crate 和 Tokio 运行时框架来处理 HTTP 流量。
进一步阅读
Tokio 分帧文档:tokio.rs/tokio/tutorial/framing
。
问题
-
使用分帧与使用分隔符相比有什么优势?
-
为什么我们将序列化的消息包装在
Bytes
结构体中? -
我们如何能够发送一个字符串作为帧?
答案
-
如果我们使用换行符等分隔符,通过 TCP 发送的数据中可能包含消息中的换行符。消息中存在换行符的问题意味着在接收到消息的末尾之前,消息已经被分割。帧结构解决了这个问题。
-
我们不得不将序列化的消息包装进一个
Bytes
结构体中,因为Encode
特性并未为任何其他数据类型实现。 -
实现字符串的
Encode
特性是最简单的方法。在实现Encode
特性时,我们序列化字符串,然后将字符串包装进一个Bytes
结构体中,在缓冲区中预留序列化字符串的长度,然后将序列化后的字符串放入缓冲区。
第十七章:使用 Hyper 框架实现演员和异步
演员模型向我们展示了我们可以构建既安全又易于维护的异步代码。在本章中,我们通过构建一个具有后台任务的缓存机制,该任务在我们使用Hyper框架接受传入的 HTTP 请求的同时持续运行,将演员模型推进了一步。必须注意的是,Hyper 框架是处理 HTTP 请求的低级方法。这使得我们能够构建具有细粒度控制 HTTP 服务器如何处理 HTTP 请求的 Web 应用程序。例如,如果我们没有编写如何处理统一资源标识符(URIs)和方法,Hyper 内置的 HTTP 服务器将处理所有请求,无论传入的方法或 URI 是什么。Hyper 对于构建自定义网络应用程序,如缓存机制非常有用。
在本章中,我们将涵盖以下主题:
-
分解演员异步项目和需求
-
定义通道消息
-
构建运行器演员
-
构建状态演员
-
使用 Hyper 处理 HTTP 请求
-
使用 Hyper 构建 HTTP 服务器
-
运行我们的 Hyper HTTP 服务器
到本章结束时,你将能够构建一个低级 HTTP 服务器,它在接受 HTTP 请求的同时在后台运行清理过程。然而,必须注意的是,在前面的章节中,我们已经使用像 Rocket 和 Actix 这样的框架构建了完整功能的应用程序。在 Actix 中构建一个完整功能的应用程序需要多个章节。在一个章节中不可能涵盖在像 Hyper 这样的低级框架中构建完整功能 Web 应用程序所需的所有内容。我们只是在 Hyper 的帮助下设置和接收 HTTP 请求。然而,通过本章所涵盖的内容,你应该能够使用在线 Hyper 文档中的示例构建一个完整功能的 Web 应用程序,因为我们涵盖了使 Hyper HTTP 服务器运行的核心概念。如果你需要一个支持大量视图和身份验证的完整功能 Web 应用程序,那么选择像 Actix 或 Rocket 这样的高级框架是有意义的。
技术要求
在本章中,我们将纯粹关注如何使用 Hyper 框架构建服务器。因此,我们不会依赖任何之前的代码,因为我们正在构建自己的新服务器。
本章的代码可以在github.com/PacktPublishing/Rust-Web-Programming-2nd-Edition/tree/main/chapter17
找到。
分解我们的项目
我们正在构建一个模拟平台,用户可以通过聊天机器人登录并与模拟人物互动,以查看他们对模拟人物说了什么。我们希望在模拟会话结束时看到用户对模拟人物说了什么。问题是,在短时间内会有很多消息发送给一个模拟人物。如果我们每次发送消息时都击中数据库,那么我们将使数据库承受很大的压力。让我们假设一个用户每 20 秒问一个问题;这意味着我们每分钟将击中数据库 6 次,因为每次交互都有一个问题和答案。如果我们有 800 个用户同时运行会话,那么我们每分钟可以有高达 4,800 次击中。这可能会对数据库造成压力。为了减轻数据库的压力,我们可以在 Hyper 中构建一个服务器来缓存聊天信息,并定期向数据库发送多个问题和答案。在继续前进之前,这是一个让你回顾我们已经涵盖的所有章节并思考构建此缓存机制的高级解决方案的机会。
建立缓存服务器有多种方法,但为了提高我们对异步编程的理解,我们将使用演员模型来解决问题。我们的方法将有一个演员接受聊天日志并将其缓存在一个 ID 下。然后我们还有一个演员定期从缓存演员获取消息数据并将其发送到另一个服务器。我们服务器中的数据流采用以下形式:
图 17.1 – 我们应用程序的层
使用这种方法,我们服务器需要以下文件布局:
├── Cargo.toml
└── src
├── actors
│ ├── messages.rs
│ ├── mod.rs
│ ├── runner.rs
│ └── state.rs
└── main.rs
我们将在actors
目录下的单独文件中定义我们的演员。处理 HTTP 请求的所有代码都将定义在main.rs
文件中。我们现在有了所有需要的文件。当我们谈到依赖项时,我们知道我们正在接受 HTTP 请求、发送 HTTP 请求、序列化数据和以异步方式运行我们的程序。鉴于我们在应用程序中所做的工作,我们不应该对Cargo.toml
文件中以下依赖项感到惊讶:
[dependencies]
tokio = { version = "1", features = ["full"] }
hyper = { version = "0.14.20", features = ["full"] }
reqwest = { version = "0.11.12", features = ["json"] }
serde_json = "1.0.86"
serde = { version = "1.0.136", features = ["derive"] }
在这个阶段,除了用于发送 HTTP 请求的reqwest
依赖项外,依赖项不应该令人惊讶。现在我们已经定义了项目的轮廓,我们可以继续构建系统的第一部分,即定义系统的消息。
定义通道消息
我们的运行演员需要定期向我们的状态演员发送消息,然后将一批聊天信息发送到服务器。考虑到运行演员的功能,我们可以看到它不需要状态,但需要发送消息。在构建运行演员之前,我们必须构建将被发送到演员和服务器上的消息。在src/actors/messages.rs
文件中,我们首先使用以下代码导入所需的模块:
use serde::Serialize;
use std::env;
我们将使用Serialize
特质来使我们能够处理来自 HTTP 请求的正文数据。现在我们已经导入了所需的模块,我们可以定义发送给演员的消息类型。如果我们考虑我们需要什么,我们将向状态演员发送消息以获取缓存数据或插入数据。状态演员可以返回数据,如果没有缓存的聊天记录,则返回空数据。考虑到我们有三种不同类型的消息,我们有以下枚举来定义发送的消息类型,以下代码:
#[derive(Debug, Serialize)]
pub enum MessageType {
INPUT,
OUTPUT,
EMPTY
}
然后,我们有用于发送和接收给演员的消息的结构体,其形式如下:
#[derive(Debug, Serialize)]
pub struct StateActorMessage {
pub message_type: MessageType,
pub chat_id: Option<i32>,
pub single_data: Option<String>,
pub block_data: Option<Vec<String>>
}
我们可以看到,我们必须定义正在发送的消息类型。其他一切都是可选的。例如,如果状态演员发现没有缓存的聊天记录,那么状态演员就不能填充除消息类型之外的其他字段,表示消息为空。
记住我们必须将缓存的聊天消息块发送到服务器。我们可以使用以下代码实现将数据发送到服务器的StateActorMessage
结构体的功能。我们必须注意,我们还没有创建PostBody
结构体,但我们将立即进行此操作:
impl StateActorMessage {
pub async fn send_to_server(&self) {
let lib_url = env::var("SERVER_URL").unwrap();
let joined =
self.block_data.clone().unwrap().join("$");
let body = PostBody {
chat_id: self.chat_id.unwrap(),
block_data: joined
};
let client = reqwest::Client::new();
let res = client.post(lib_url)
.json(&body)
.send()
.await.unwrap();
println!("{:?}", res);
}
}
我们可以看到,我们只是从环境中获取服务器 URL,创建一个由$
作为分隔符的大字符串,分隔问题和答案对,然后将其发送到另一个服务器。我们可以创建一个处理消息并将数据发送到服务器的演员。然而,将数据发送到服务器的操作与消息结构体耦合提供了更大的灵活性,因为任何处理消息的演员都可以将消息中的数据发送到服务器。
当涉及到PostBody
结构体时,它应该采取以下形式:
#[derive(Debug, Serialize)]
struct PostBody {
pub chat_id: i32,
pub block_data: String
}
我们整个应用程序的消息现在已定义。我们现在可以继续构建我们的运行演员。
构建我们的运行演员
我们的运行演员持续循环,向状态演员发送消息,请求批量数据,如果存在批量数据,则将其发送到服务器。这意味着我们的演员将在整个程序生命周期中发送和接收消息。考虑到运行演员的行为,我们不需要在src/actors/runner.rs
文件中需要以下导入:
use super::messages::{MessageType, StateActorMessage};
use tokio::sync::mpsc::{Sender, Receiver};
use std::time;
我们已经导入了所需的模块和用于暂停几秒钟的消息。我们还使用了类型别名来定义运行演员将支持的通道类型。我们现在可以定义运行演员,以下代码:
pub struct RunnerActor {
pub interval: i32,
pub receiver: Receiver<StateActorMessage>,
pub sender: Sender<StateActorMessage>,
}
在这里,我们仅仅定义了我们的演员在再次请求数据之前将等待的秒数。然后,我们有一个发送者和接收者用于发送和接收消息。定义了字段后,我们接下来需要使用以下代码定义我们的RunnerActor
结构体的构造函数和运行函数:
impl RunnerActor {
pub fn new(receiver: Receiver<StateActorMessage>,
sender: Sender<StateActorMessage>,
interval: i32) -> RunnerActor {
return RunnerActor { interval, receiver, sender }
}
pub async fn run(mut self) {
. . .
}
}
我们可以看到,我们的构造函数(new
)实际上并不是必需的。在我们的new
函数中,我们只是直接将参数传递到RunnerActor
结构体的字段中。然而,考虑到所付出的努力,它还是很有用的。例如,如果我们以后需要添加一些检查或者向状态演员发送消息,说明RunnerActor
结构体已经被构建,我们只需要更改new
函数中的行为,这种行为将会在整个程序中使用new
函数的地方传播。
对于我们的run
函数,我们运行一个无限循环。在这个循环内部,我们向状态演员发送消息。每次循环迭代都通过以下代码中的睡眠函数来中断:
pub async fn run(mut self) {
println!("runner actor is running");
let seconds = time::Duration::from_secs(self.interval
as u64);
loop {
tokio::time::sleep(seconds).await;
let message = StateActorMessage {
message_type: MessageType::OUTPUT,
chat_id: None,
single_data: None,
block_data: None
};
match self.sender.send(message).await {
. . .
};
}
}
一旦我们发送了消息,我们必须等待响应,如果响应不为空,则需要将批处理数据发送到服务器,这可以通过以下代码完成:
match self.sender.send(message).await {
Ok(_) => {
let message = self.receiver.recv().await.unwrap();
match message.message_type {
MessageType::OUTPUT => {
message.send_to_server().await;
},
_ => {
println!("state is empty");
}
}
},
Err(_) => {
println!("runner is failed to send message");
}
};
在这里,我们可以看到,到目前为止,逻辑并不太复杂。有了这个,我们将能够快速通过我们的状态演员,然后最终在 Hyper 中创建一个 HTTP 服务器。
构建我们的状态演员
当涉及到我们的状态演员时,我们必须发送和接收消息。我们的状态演员还将有一个状态,其中演员将存储要引用的聊天记录。考虑到状态演员的机制,我们不会对在src/actors/state.rs
文件中以下导入感到惊讶:
use std::collections::{HashMap, VecDeque};
use std::mem;
use tokio::sync::mpsc::{Sender, Receiver};
use super::messages::{MessageType, StateActorMessage};
导入中唯一的区别是mem
模块。mem
模块将使我们能够分配内存。当我们从演员的状态获取消息时,我们将介绍如何使用mem
模块。我们还可以看到,我们导入了HashMap
和VecDeque
来处理演员的状态。
现在我们已经导入了所需的模块,我们可以使用以下代码定义我们的演员结构体:
#[derive(Debug)]
pub struct StateActor {
pub chat_queue: VecDeque<i32>,
pub chat_logs: HashMap<i32, Vec<String>>,
pub receiver: Receiver<StateActorMessage>,
pub sender: Sender<StateActorMessage>,
}
chat_queue
是我们使用标准集合库中的VecDeque
结构体实现执行先进先出队列的地方。我们假设最老的聊天记录通常比后来的聊天记录包含更多的聊天内容,因此,我们应该缓存较老的聊天记录。我们之所以没有仅仅使用向量而不是队列,是因为从队列中弹出第一个元素不需要重新分配所有其他元素。如果我们从向量中移除第一个元素,我们就必须重新分配向量中的所有其他元素。当涉及到存储聊天记录时,我们使用聊天 ID 作为键,以及聊天 ID 下的问题和答案。
现在我们已经定义了我们的状态演员,我们可以定义演员所需的函数,以下代码展示了这些函数:
impl StateActor {
pub fn new(receiver: Receiver,
sender: Sender) -> StateActor {
. . .
}
pub fn get_message_data(&mut self,
chat_id: i32) -> Vec<String> {
. . .
}
pub fn insert_message(&mut self,
chat_id: i32, message_data:
String) {
. . .
}
async fn handle_message(&mut self,
message: StateActorMessage) {
. . .
}
pub async fn run(mut self) {
. . .
}
}
在这里,我们可以看到我们有一个构造函数、提取聊天数据、插入聊天记录、根据接收到的消息类型处理消息,以及一个run
函数来等待发送到状态演员的传入消息。
我们现在已经准备好通过函数实现的StateActor
结构体的逻辑。在本书的这个阶段,你应该能够自己实现这些函数,这将是一个很好的实践。
对于我们的构造函数,我们有以下代码:
pub fn new(receiver: Receiver, sender: Sender) -> StateActor {
let chat_queue: VecDeque<i32> = VecDeque::new();
let chat_logs: HashMap<i32, Vec<String>> =
HashMap::new();
return StateActor {chat_queue, chat_logs, receiver,
sender}
}
构造函数仅仅创建一个空的队列和 HashMap,并接受发送和接收消息的通道。对于我们的get_message
函数,我们有以下代码:
pub fn get_message_data(&mut self, chat_id: i32) ->
Vec<String> {
self.chat_logs.remove(&chat_id).unwrap()
}
我们可以看到,我们根据聊天 ID 从聊天记录中获取聊天数据。然后,我们将 HashMap 聊天记录的所有权从reference
变量中转移,删除聊天 ID 键,然后返回数据。这使得我们可以从我们的状态中删除并返回消息数据。
对于我们的insert_message
函数,我们使用以下代码:
pub fn insert_message(&mut self, chat_id: i32,
message_data: String) {
match self.chat_logs.get_mut(&chat_id) {
Some(patient_log) => {
patient_log.push(message_data);
},
None => {
self.chat_queue.push_back(chat_id);
self.chat_logs.insert(chat_id,
vec![message_data]);
}
}
}
在这里,我们可以看到我们仅仅将新的消息数据插入与聊天 ID 关联的向量中。如果聊天 ID 不存在,我们将聊天 ID 附加到队列中,并在聊天 ID 下创建一个新的向量。
我们现在可以继续到处理消息的函数,以下是其代码:
async fn handle_message(&mut self,
message: StateActorMessage) {
println!("state actor is receiving a message");
match message.message_type {
MessageType::INPUT => {
self.insert_message(message.chat_id.unwrap(),
message.single_data
.unwrap());
},
MessageType::OUTPUT => {
. . .
},
MessageType::EMPTY => {
panic!(
"empty messages should not be sent to the
state actor"
);
}
}
println!("{:?}", self.chat_logs);
println!("{:?}", self.chat_queue);
}
在这里,如果消息是输入,我们仅仅将消息插入到我们的状态中。如果消息为空,我们将使线程恐慌,因为不应该有任何空消息发送到状态演员。如果我们有一个输出消息,这意味着我们必须获取队列底部最老的聊天,其形式如下:
MessageType::OUTPUT => {
match self.chat_queue.pop_front() {
Some(chat_id) => {
let data = self.get_message_data(chat_id);
let message = StateActorMessage {
message_type: MessageType::OUTPUT,
chat_id: Some(chat_id),
single_data: None,
block_data: Some(data)
};
let _ =
self.sender.send(message).await.unwrap();
},
None => {
let message = StateActorMessage {
message_type: MessageType::EMPTY,
chat_id: None,
single_data: None,
block_data: None
};
let _ =
self.sender.send(message).await.unwrap();
}
}
},
在这里,我们可以看到如果没有队列中的内容,那么我们就没有聊天;因此我们返回一个空消息。如果队列中有聊天 ID,我们获取消息数据并通过消息发送给运行者。
最后,run
函数的形式如下:
pub async fn run(mut self) {
println!("state actor is running");
while let Some(msg) = self.receiver.recv().await {
self.handle_message(msg).await;
}
}
这个run
函数仅仅等待接收到的消息并处理这些接收到的消息。我们现在已经定义了所有的演员,现在可以继续构建我们的 HTTP 服务器。
使用 Hyper 处理 HTTP 请求
当涉及到构建我们的 HTTP 服务器时,我们将把所有的服务器逻辑实现到src/main.rs
文件中。首先,我们使用以下代码导入所需的模块:
use tokio::sync::{mpsc, mpsc::Sender};
use hyper::{Body, Request, Response, Server};
use hyper::body;
use hyper::service::{make_service_fn, service_fn};
use serde_json;
use serde::Deserialize;
use std::net::SocketAddr;
在本书的这个阶段,这些导入应该不会让你感到困惑。尽管我们从hyper
模块中导入了,但导入是自解释的。我们将从请求中提取数据,创建一个服务来处理我们的请求,并创建一个 HTTP 服务器来监听传入的请求。
我们还需要使用我们创建的演员。我们可以使用以下代码导入我们的演员:
mod actors;
use actors::state::StateActor;
use actors::runner::RunnerActor;
use actors::messages::StateActorMessage;
use actors::messages::MessageType;
导入所有这些演员后,我们可以着手接受请求。传入的 HTTP 请求将新的聊天记录插入到我们的状态演员中。为了实现插入聊天记录,我们需要一个聊天 ID、输入(一个问题)和输出(一个答案)。也最好有一个时间戳,但为了简化示例,我们将使用回合数来表示聊天记录创建的时间。为了从请求中提取我们刚刚列出的所有数据,我们需要以下结构体:
#[derive(Deserialize, Debug)]
struct IncomingBody {
pub chat_id: i32,
pub timestamp: i32,
pub input: String,
pub output: String
}
我们在处理传入请求正文时所采取的方法与使用 Rocket 和 Actix 构建服务器时相同。这是因为我们依赖于 serde
。处理传入请求正文实现相同的事实是对在 Rust 中实现特质的灵活性的证明。
由于我们正在接受传入的请求,我们必须处理它们。我们可以在一个函数中定义如何处理请求的逻辑,以下是一个概要:
async fn handle(req: Request<Body>, channel_sender:
Sender<StateActorMessage>) -> Result<Response<Body>,
&'static str> {
. . .
}
在这个 handle
函数中,我们接受一个带有正文和通道的请求,我们可以通过该通道向我们的演员发送消息。如果我们没有应用演员模型方法,我们只需接受请求即可。我们必须记住,我们通过一个函数来处理我们的请求,因此没有什么可以阻止我们传递数据库连接或其他任何东西。我们的 handle
函数本质上充当着中间件的角色。
对于这个例子,我们只支持一个视图。然而,为了了解我们可以用 Hyper 做什么,我们不妨使用以下代码打印出一些关于传入请求的基本信息:
println!("incoming message from the outside");
let method = req.method().clone();
println!("{}", method);
let uri = req.uri();
println!("{}", uri);
如果我们要支持多个视图,我们可以在传入请求的 URI 上实现一个 match
语句,将请求传递给另一个函数,该函数包含特定主题(如身份验证或聊天记录)的多个视图。然后另一个 match
语句可以将请求传递到正确的视图函数,该函数处理请求并返回 HTTP 响应,handle
函数将返回该响应。
同样,因为我们只支持一个视图,所以我们将直接在 handle
函数中处理请求。我们通过以下代码从请求中提取正文数据来完成这项工作:
let bytes = body::to_bytes(req.into_body()).await.unwrap();
let string_body = String::from_utf8(bytes.to_vec())
expect("response was not valid utf-8");
let value: IncomingBody = serde_json::from_str(
&string_body.as_str()).unwrap();
现在,我们有了发送聊天记录到我们的状态演员并返回一切正常的 HTTP 响应所需的所有数据,以下是一个代码示例:
let message = StateActorMessage {
message_type: MessageType::INPUT,
chat_id: Some(value.chat_id),
single_data: Some(format!("{}>>{}>>{}>>",
value.input,
value.output,
value.timestamp)),
block_data: None
};
channel_sender.send(message).await.unwrap();
Ok(Response::new(format!("{:?}", value).into()))
这样我们的 handle
函数就完成了。考虑到我们在本节中所做的工作,我们可以欣赏到 Hyper 在实现 HTTP 时的底层性。虽然我们已经打印出了 URI 和方法,但我们并没有对它们做任何事情。无论传入服务器的是什么方法或 URI,只要正文有正确的数据,我们的服务器就会工作。因此,让我们在下一节中让我们的服务器工作起来。
使用 Hyper 构建 HTTP 服务器
当涉及到使用 Hyper 运行 HTTP 服务器时,我们将使用 Tokio。我们有两个演员正在运行,以及两个通道来促进演员和请求之间的通信。首先,在 main
函数中,我们使用以下代码定义服务器的地址:
#[tokio::main]
async fn main() {
let addr = SocketAddr::from(([0, 0, 0, 0], 3000));
. . .
}
地址定义后,我们使用以下代码定义两个消息通道:
let (state_tx, state_rx) =
mpsc::channel::<StateActorMessage>(1);
let (runner_tx, runner_rx) =
mpsc::channel::<StateActorMessage>(1);
let channel_sender = state_tx.clone();
我们克隆了状态演员的发送者,因为我们将通过 handle
函数将发送者与传入的请求一起传递。现在我们有了我们的通道,我们可以使用以下代码启动两个线程来运行我们的演员:
tokio::spawn(async move {
let state_actor = StateActor::new(state_rx, runner_tx);
state_actor.run().await;
});
tokio::spawn(async move {
let lib_runner_actor = RunnerActor::new(runner_rx,
state_tx, 30);
lib_runner_actor.run().await;
});
在这里,我们必须小心地将另一个 actor 的发送者传递给我们要创建的 actor,因为 actors 互相发送消息;它们不是给自己发送消息。
我们缓存机制的 everything 现在都在运行。我们现在需要做的是接受传入的 HTTP 请求,我们可以通过以下代码实现:
let server = Server::bind(&addr).serve(make_service_fn( |_conn| {
let channel = channel_sender.clone();
async {
Ok::<_, hyper::Error>(service_fn(move |req| {
let channel = channel.clone();
async {handle(req, channel).await}
}))
}
}));
必须注意,我们正在返回一个使用异步语法的 future,这是 service_fn
函数所必需的。在这里,我们可以看到我们将我们的地址绑定到服务器,然后调用 serve
函数。在这个 serve
函数内部,我们传递 make_service_fn
函数,它将我们传递给 make_service_fn
函数的可执行程序包装在一个 MakeServiceFn
结构体中。我们传递给 make_service_fn
函数的可执行程序是一个闭包,它传递 _conn
,这是一个 AddrStream
结构体。使用 AddrStream
结构体,我们可以获取连接到服务器的对等方的地址。我们还可以消耗 AddrStream
结构体,并使用 AddrStream
结构体的 into_inner
方法提取底层的 TCP 流。在本章中,我们不会与 AddrStream
结构体玩耍,因为我们通过仅处理标准 HTTP 请求来保持简单。
在闭包内部,我们再次克隆发送者。我们需要在这里这样做,因为我们需要为每个进入的请求克隆通道,因为每个请求都需要向状态 actor 发送消息。然后我们使用 async
块创建一个 future,返回一个 Ok
枚举,它围绕 service_fn
函数,其中我们插入另一个闭包来处理传入的请求。这就是我们再次克隆通道并返回一个 future 的地方,我们的 handle
函数接受要处理的传入请求和通道以及要返回的 HTTP 响应。我们可以看到,与 Rocket 等其他框架相比,运行 HTTP 服务器需要更多的步骤。然而,我们也获得了更多的细粒度控制。
现在我们完成了服务器块,我们可以在 main
函数中实现最后一段逻辑,通过以下代码打印出服务器的问题错误:
if let Err(e) = server.await {
eprintln!("server error: {}", e);
}
这样,我们的简单 HTTP 服务器应该可以工作了。
运行我们的 Hyper HTTP 服务器
当我们运行我们的服务器时,我们首先需要使用以下命令导出我们将要发送批量聊天日志的 URL:
export SERVER_URL="https://httpbin.org/post"
这意味着我们只需要将我们的 HTTP 请求发送到 HTTPBin,并得到一个标准的响应。然后我们可以使用以下命令运行我们的服务器:
cargo run
我们的服务器现在正在以下打印输出中运行:
state actor is running
runner actor is running
我们的时间间隔是每 30 秒,runner actor 向状态 actor 发送一条消息。如果我们只是让我们的服务器运行,我们会得到以下打印输出:
state is empty
state actor is receiving a message
{}
[]
state is empty
state actor is receiving a message
{}
[]
state is empty
state actor is receiving a message
{}
[]
在这里,我们可以看到聊天状态和队列都是空的,并且我们的两个 actor 都在各自的线程中运行!然后我们可以获取我们的 Postman 应用程序,发送以下 HTTP 请求:
图 17.2 – 服务器 HTTP 请求
发送之前的请求将给出以下输出:
incoming message from the outside
POST
/test
state actor is receiving a message
{23: ["what is your name>>my name is maxwell>>1>>"]}
[23]
在这里我们可以看到状态和队列已经被填充。如果我们再在最初的 30 秒内按下 Postman 的发送按钮两次,我们会得到以下输出:
incoming message from the outside
POST
/test
state actor is receiving a message
{23: ["what is your name>>my name is maxwell>>1>>",
"what is your name>>my name is maxwell>>1>>"]}
[23]
incoming message from the outside
POST
/test
state actor is receiving a message
{23: ["what is your name>>my name is maxwell>>1>>",
"what is your name>>my name is maxwell>>1>>",
"what is your name>>my name is maxwell>>1>>"]}
[23]
我们可以看到,随着我们发送相同的聊天 ID,队列并没有增加。然后我们增加聊天日志的 ID,发送另一个请求,结果如下输出:
incoming message from the outside
POST
/test
state actor is receiving a message
{24: ["what is your name>>my name is maxwell>>1>>"],
23: ["what is your name>>my name is maxwell>>1>>",
"what is your name>>my name is maxwell>>1>>",
"what is your name>>my name is maxwell>>1>>"]}
[23, 24]
我们可以看到新的 ID 已经被插入到队列和状态中。我们设法在 30 秒内完成了所有这些请求,然后我们可以简单地等待,直到我们得到以下输出:
state actor is receiving a message
{23: ["what is your name>>my name is maxwell>>1>>"}
[23]
Response { url: Url { scheme: "https",
cannot_be_a_base: false,
username: "", password: None,
host: Some(Domain("httpbin.org")),
port: None, path: "/post",
. . .
"*", "access-control-allow-credentials": "true"} }
在这里发生的情况是,跑步者向获取最老聊天 ID 的状态发送了一条消息,然后将其发送到我们在环境变量中定义的服务器。我们可以看到状态已经更新,响应是 OK。为了防止页面打印输出膨胀,省略了一些 HTTP 响应。
我们可以得出结论,我们使用 Hyper HTTP 上的 actor 构建的缓存系统正如我们所期望的那样工作!有了这个,我们就结束了本章,对 Hyper 进行了足够的探索,以使用异步 actor 和通道启动服务器。
摘要
在本章中,我们构建了一个缓存聊天记录的网络应用程序。我们的网络应用程序还有一个后台任务持续运行,定期通过将缓存数据发送到服务器来清理缓存。我们将后台任务运行器分解为一个具有队列的 actor 系统,然后实现了它。这为您提供了一个全新的工具集来解决这些问题。后台运行的 actor 不仅适用于在服务器上运行以进行缓存。如果您在 Tokio 运行时上构建程序,您也可以为常规程序运行后台运行 actor。
在下一章中,我们将使用 Redis 持久存储来管理多个工作进程和网络应用程序,以在多个网络应用程序之间传输消息。
进一步阅读
超级文档:hyper.rs/guides
第十八章:使用 Redis 排队任务
接收请求,执行操作,然后向用户返回响应可以解决网络编程中的许多问题。然而,有时这种简单的方法根本无法满足需求。例如,当我还在 MonolithAi 工作时,我们有一个功能,用户可以输入数据和参数,然后点击按钮在数据上训练机器学习模型。然而,在向用户发送响应之前尝试训练机器学习模型会花费太长时间。连接可能会超时。为了解决这个问题,我们有一个 Redis 队列和一组工作进程消费任务。训练任务将被放入队列,当工作进程有空时,其中一个工作进程将开始训练模型。HTTP 服务器将接受用户的请求,将训练任务发布到队列中,并告知用户任务已发布。当模型训练完成后,用户将收到更新。另一个例子可能是一个食品订购应用程序,其中食品订单需要经过一系列步骤,如确认订单、处理订单然后交付订单。
考虑 MonolithAi 的例子,不难看出为什么学习如何在网络编程中实现排队不仅有用,而且为开发者提供了另一种解决方案,增加了他们可以解决的问题数量。
在本章中,我们将涵盖以下主题:
-
布局排队项目,描述所需的组件和方法
-
构建 HTTP 服务器
-
构建 polling 工作进程
-
使用 Redis 运行我们的应用程序
-
定义工作进程的任务
-
定义 Redis 队列的消息
-
在 HTTP 服务器中集成路由
-
在 Docker 中运行所有服务器和工作进程
在本章结束时,你将能够构建一个 Rust 程序,该程序可以根据传入的环境变量是作为工作进程还是服务器来运行。你还将能够将一系列任务以不同结构体的形式序列化,并将它们插入 Redis 队列中,使这些结构体能够排队并在不同的服务器之间传输。这不仅将赋予你实现队列的技能,还能利用 Redis 实现许多其他解决方案,例如多个服务器通过 Redis pub/sub 通道进行广播接收消息。
技术要求
在本章中,我们将专注于如何使用 Tokio 和 Hyper 在 Redis 队列上构建工作进程。因此,我们不会依赖任何之前的代码,因为我们正在构建自己的新服务器。
本章的代码可以在 github.com/PacktPublishing/Rust-Web-Programming-2nd-Edition/tree/main/chapter18
找到。
拆分我们的项目
在我们的系统中,有一系列需要执行的任务。然而,这些任务需要很长时间才能完成。如果我们只有一个普通的服务器来处理任务,服务器最终会变得拥堵,多个用户将收到延迟的服务体验。如果任务太长,那么用户的连接可能会超时。
为了避免在需要长时间任务时降低用户体验,我们利用排队系统。这就是 HTTP 服务器从用户那里接收请求的地方。与请求相关联的长时间任务随后被发送到先入先出队列,由一组工作者进行处理。因为任务在队列中,HTTP 服务器除了向用户响应任务已被发送并且他们的请求已被处理之外,别无他法。由于流量的起伏,当流量低时,我们不需要所有的工作者和 HTTP 服务器。然而,当流量增加时,我们需要创建和连接额外的 HTTP 服务器和工作者,如下面的图所示:
图 18.1 – 我们处理长时间任务的方法
考虑到前面的图,我们需要以下基础设施:
-
Redis 数据库:用于在队列中存储任务
-
HTTP 服务器:将任务发送到队列以进行处理
-
工作者:从队列中拉取/弹出/轮询/处理任务
我们可以为工作者和 HTTP 服务器构建单独的应用程序。然而,这样做会增加复杂性而没有获得任何收益。使用两个独立的应用程序,我们必须维护两个独立的 Docker 镜像。我们还会大量复制代码,因为 HTTP 服务器发送到 Redis 队列的任务必须与工作者拾取并处理的任务相同。对于特定任务,HTTP 服务器传递给工作者的字段可能存在不匹配。我们可以通过具有一系列字段的任务结构体和用于使用这些字段执行任务的运行函数来防止这种不匹配。这些任务结构体的序列化特性可以让我们在队列中传递字段并接收它们。
当涉及到构建 HTTP 服务器和工作者时,我们可以构建服务器,以便在程序启动后检查环境变量。如果环境变量表明应用程序是一个工作者,那么应用程序可以启动一个轮询队列的演员。如果环境变量表明应用程序是一个 HTTP 服务器,那么应用程序可以运行一个 HTTP 服务器并监听请求。
对于我们的任务队列项目,我们有以下概述:
├── Cargo.toml
├── docker-compose.yml
└── src
├── main.rs
└── tasks
├── add.rs
├── mod.rs
├── multiply.rs
└── subtract.rs
我们将在 src/main.rs
文件中定义服务器入口点。然后,我们在 src/tasks/
目录中定义我们的任务结构体。就我们 Cargo.toml
文件中的依赖项而言,我们有以下内容:
[dependencies]
bincode = "1.0"
bytes = "1.2.1"
redis = "0.22.1"
serde_json = "1.0.86"
tokio = { version = "1", features = ["full"] }
hyper = { version = "0.14.20", features = ["full"] }
serde = { version = "1.0.136", features = ["derive"] }
除了bytes
和bincode
包之外,这些依赖项对你来说都不应该陌生。我们将使用bytes
将我们的结构体转换为 HTTP 响应,并使用bincode
将结构体序列化为二进制,以便存储在 Redis 中。
通过本节中刚刚阐述的方法,我们将能够构建一个简单的任务处理队列,其中我们可以确保服务器和工作者之间的任务定义始终保持同步。定义了我们的方法后,我们可以继续进行任务旅程的第一部分,即 HTTP 服务器。
构建 HTTP 服务器
对于我们的 HTTP 服务器,我们需要执行以下步骤:
-
定义一个反序列化 HTTP 请求主体的结构体。
-
定义一个处理传入请求的函数。
-
根据环境变量定义程序的运行路径。
-
运行一个监听传入请求的服务器。
我们不会为每个步骤分别划分部分,因为我们已经在上一章中涵盖了所有这些步骤/过程。在我们执行所有步骤之前,我们必须将以下内容导入到src/main.rs
文件中:
use hyper::{Body, Request, Response, Server};
use hyper::body;
use hyper::service::{make_service_fn, service_fn};
use std::net::SocketAddr;
use std::env;
use serde::{Serialize, Deserialize};
use serde_json;
use bytes::{BufMut, BytesMut};
你应该熟悉所有这些导入,除了bytes
导入,我们将在定义 HTTP 处理函数时介绍它。首先,我们将定义一个简单的结构体,用于使用以下代码序列化传入的 HTTP 请求主体:
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IncomingBody {
pub one: String,
pub two: i32
}
这与我们的 Actix Web 应用程序的方法相同。我们将能够使用Serialize
和Deserialize
特性对任务结构体进行注解。
现在我们已经定义了IncomingBody
结构体,我们可以使用以下代码定义我们的handle
函数:
async fn handle(req: Request<Body>) ->
Result<Response<Body>, &'static str> {
let bytes = body::to_bytes(req.into_body()).await
.unwrap();
let response_body: IncomingBody =
serde_json::from_slice(&bytes).unwrap();
let mut buf = BytesMut::new().writer();
serde_json::to_writer(&mut buf,
&response_body).unwrap();
Ok(Response::new(Body::from(buf.into_inner().freeze())))
}
必须注意,我们在返回主体时调用了freeze
函数。这个freeze
函数将可变的字节转换为不可变的,防止任何缓冲区修改。在这里,我们可以看到我们正在接受一个通用的请求主体。然后我们可以使用serde
将主体和BytesMut
结构体(本质上只是一个连续的内存切片)序列化,然后将主体返回给用户,本质上创建了一个回声服务器。
现在我们可以定义main
函数,这是程序的入口点,以下是其代码:
#[tokio::main]
async fn main() {
let app_type = env::var("APP_TYPE").unwrap();
match app_type.as_str() {
"server" => {
. . .
},
"worker" => {
println!("worker not defined yet");
}
_ => {
panic!("{} app type not supported", app_type);
}
}
}
在这里,我们可以看到环境变量"APP_TYPE"
被提取。根据应用程序类型的不同,将执行不同的代码块。目前,我们只需打印出一条消息,说明如果应用程序类型是"worker"
,则工作者未定义。我们还声明,如果应用程序类型既不是"server"
也不是"worker"
类型,程序将发生恐慌。
在我们的服务器块中,我们使用以下代码定义了addr
和server
:
let addr = SocketAddr::from(([0, 0, 0, 0], 3000));
let server = Server::bind(&addr).serve(make_service_fn( |_conn| {
async {
Ok::<_, hyper::Error>(service_fn( move |req| {
async {handle(req).await}
}))
}
}));
if let Err(e) = server.await {
eprintln!("server error: {}", e);
}
这与上一章中的服务器代码非常相似。
然后我们使用以下命令运行服务器:
APP_TYPE=server cargo run
然后,我们可以发送以下请求:
图 18.2 – 向我们的 HTTP 服务器发送请求
在这里,我们可以看到我们的服务器正在工作,并回显发送到服务器的相同正文。我们现在可以继续构建我们的工作者应用程序。
构建轮询工作者
我们的工作者本质上是在 Redis 中循环和轮询队列。如果队列中有消息,工作者将会执行从队列中提取的任务。为了构建轮询工作者部分,工作者将会创建一个结构体,将结构体插入 Redis 队列,然后从队列中提取该插入的结构体以打印出来。这不是我们期望的行为,但这确实意味着我们可以快速测试我们的队列插入功能。到本章结束时,我们的 HTTP 服务器将插入任务,而我们的工作者将消费任务。
我们不希望工作者在没有休息的情况下不断轮询 Redis 队列。为了将轮询降低到合理的速率,我们需要让工作者在每次循环中休眠。因此,我们必须在 src/main.rs
文件中导入以下内容,以便我们能够使工作者休眠:
use std::{thread, time};
我们现在可以进入工作者运行的部分,在下面的 main
函数中定义我们的工作者代码:
match app_type.as_str() {
"server" => {
. . .
},
"worker" => {
// worker code is going to be inserted here
. . .
}
_ => {
panic!("{} app type not supported", app_type);
}
}
我们的工作者代码采用以下一般轮廓:
let client =
redis::Client::open("redis://127.0.0.1/").unwrap();
loop {
. . .
}
在这里,我们可以看到我们定义了 Redis 客户端,然后在一个无限循环中运行工作者。在这个循环中,我们将与 Redis 建立连接,轮询 Redis 中的队列,然后断开连接。由于任务可能需要很长时间,所以在任务执行期间保持 Redis 连接是没有意义的。
不幸的是,在撰写本书时,Rust Redis crate 没有简单的队列实现。然而,这不应该阻碍我们。如果我们知道获取 Redis 实现我们队列所需的原始命令,我们可以实现自己的队列。Redis 的性能类似于 SQL 数据库。如果你知道命令,你可以像在 SQL 中一样实现自己的逻辑。
在我们的无限循环中,我们将创建一个实现了 Serialize
和 Deserialize
特性的泛型结构体,然后使用以下代码将结构体序列化为二进制:
let body = IncomingBody{one: "one".to_owned(), two: 2};
let bytes = bincode::serialize(&body).unwrap();
我们的结构体现在是一个字节数组。然后我们将与 Redis 建立连接,使用 "LPUSH"
命令将 "some_queue"
推送到队列中,该命令将值插入队列的头部,以下代码所示:
let outcome: Option<Vec<u8>>;
{
let mut con = client.get_connection().unwrap();
let _ : () = redis::cmd("LPUSH").arg("some_queue")
.arg(bytes.clone())
.query(&mut con)
.unwrap();
// pop our task from the queue
outcome = redis::cmd("LPOP").arg("some_queue")
.query(&mut con)
.unwrap();
}
我们有 Option<Vec<u8>>
因为队列中可能没有任何内容。如果队列中没有内容,那么结果将是 none。目前,我们永远不会得到 none,因为我们是在从队列中提取任务之前直接将任务插入队列中的。然而,在低流量期间,我们的工作者将会轮询可能空置一段时间的队列。
现在我们有了结果,我们可以使用以下 match
语句来处理它:
match outcome {
Some(data) => {
. . .
},
None => {
. . .
}
}
如果我们有数据,我们只需反序列化二进制数据,并使用以下代码打印出结构体:
let deserialized_struct: IncomingBody =
bincode::deserialize(&data).unwrap();
println!("{:?}", deserialized_struct);
如果队列中没有内容,outcome
将是None
,我们可以在再次运行循环之前简单地休眠五秒钟,代码如下:
let five_seconds = time::Duration::from_secs(5);
tokio::time::sleep(five_seconds).await;
这样,我们的工作进程就准备好进行测试了。在构建这样的异步程序时,你总是可以做更多的事情。然而,为了避免使本章内容膨胀,我们将坚持使用我们的基本应用程序。如果你想进一步了解 Redis,你可以研究构建一个 pub/sub 系统,其中一个工作进程持续轮询队列,而其他工作进程通过监听通道上的消息的 actor 被关闭。当一个主要工作进程接收到一个新任务时,主要工作进程可以向通道发布一个消息,唤醒其他工作进程。如果你真的想挑战自己,你可以研究 Kubernetes 控制器,让主要工作进程启动和销毁工作进程 pod,这取决于流量。然而,这些项目将超出本书的范围。
为了在本章范围内使我们的应用程序工作,我们必须继续让我们的应用程序与 Redis 一起运行。
让我们的应用程序与 Redis 一起运行
在本地使用 Redis 运行我们的应用程序将需要我们使用带有 Docker 的 Redis,导出APP_TYPE
环境变量为"worker"
,然后使用 Cargo 运行我们的应用程序。对于我们的 Redis,我们的docker-compose.yml
文件如下所示:
version: "3.7"
services:
redis:
container_name: 'queue-redis'
image: 'redis'
ports:
- '6379:6379'
然后,我们可以使用以下命令导出我们的APP_TYPE
环境变量:
export APP_TYPE=worker
然后,我们可以使用以下命令运行我们的应用程序:
cargo run
当我们运行应用程序时,我们将得到以下输出:
IncomingBody { one: "one", two: 2 }
IncomingBody { one: "one", two: 2 }
IncomingBody { one: "one", two: 2 }
IncomingBody { one: "one", two: 2 }
. . .
IncomingBody
结构的输出将是无限的,因为我们正在运行一个无限循环。然而,这表明以下机制正在运行并正常工作:
图 18.3 – 我们从 Redis 队列中插入和提取数据的过程
虽然我们的工作进程正在与 Redis 队列交互,但它仅仅是在打印出放入 Redis 队列的结构体。在下一节中,我们将构建功能到我们插入 Redis 队列的结构体中,以便我们的工作进程可以执行任务。
定义工作进程的任务
当涉及到运行我们的任务时,我们需要字段以便我们可以将它们作为输入传递给正在运行的任务。我们的任务还需要一个run
函数,这样我们就可以选择何时运行任务,因为运行任务需要很长时间。我们可以在src/tasks/add.rs
文件中定义一个基本的加法任务,代码如下:
use std::{thread, time};
use serde::{Serialize, Deserialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AddTask {
pub one: i32,
pub two: i32
}
impl AddTask {
pub fn run(self) -> i32 {
let duration = time::Duration::from_secs(20);
thread::sleep(duration);
return self.one + self.two
}
}
这段代码不应该让你感到惊讶。我们将实现Serialize
和Deserialize
特质,以便我们可以将任务插入到 Redis 队列中。然后,我们可以使用sleep
函数模拟一个长时间的任务。最后,我们只是将两个数字相加。对于我们在src/tasks/multiply.rs
文件中的任务,run
函数的形式如下:
impl MultiplyTask {
pub fn run(self) -> i32 {
let duration = time::Duration::from_secs(20);
thread::sleep(duration);
return self.one * self.two
}
}
发现src/tasks/subtract.rs
文件中的run
函数具有以下结构体应该不会令人惊讶:
impl SubtractTask {
pub fn run(self) -> i32 {
let duration = time::Duration::from_secs(20);
thread::sleep(duration);
return self.one - self.two
}
}
现在,我们想要实现我们的一个任务,看看我们是否可以从 Redis 队列中拉出一个任务结构体并运行它。我们在src/tasks/mod.rs
文件中使用以下代码使任务在模块中可访问:
pub mod add;
pub mod multiply;
pub mod subtract;
在我们的src/main.rs
文件中,我们最初使用以下代码导入任务:
mod tasks;
use tasks::{
add::AddTask,
subtract::SubtractTask,
multiply::MultiplyTask
};
我们现在可以在我们的工作代码块中实现我们的一个任务。在这个工作代码块的开头,我们将使用以下代码将IncomingBody
结构体与AddTask
结构体交换:
let body = AddTask{one: 1, two: 2};
除了对outcome
match
语句中的Some
部分的处理外,其他什么都不需要改变,该语句现在具有以下形式:
let deserialized_struct: AddTask =
bincode::deserialize(&data).unwrap();
println!("{:?}", deserialized_struct.run());
在这里,我们可以看到我们已将二进制数据反序列化为AddTask
结构体,运行了run
函数,然后打印出了结果。在实际应用中,我们会将结果插入到数据库中或使用 HTTP 将结果发送到另一个服务器。然而,在本章中,我们只是想看看队列任务是如何执行的。我们在书中多次介绍了数据库插入和 HTTP 请求。
如果我们现在运行我们的工作应用程序,我们将得到 15 秒的延迟,然后出现以下打印输出:
3
如果我们再等待 15 秒钟,我们会得到相同的打印输出。这表明我们的任务正在从 Redis 队列中拉取,反序列化,并以我们期望的方式运行,即一个加二等于三。然而,这里有一个问题。我们只能发送和接收AddTask
结构体。这没有用,因为我们还有两个其他任务,我们希望支持所有这些任务。因此,我们必须继续定义可以支持一系列任务的消息。
定义 Redis 队列的消息
为了支持多个任务,我们必须采取两步方法来打包我们的任务以插入到 Redis 队列中。这意味着我们将任务结构体序列化为Vec<u8>
,然后将这个字节数组添加到另一个结构体中,该结构体有一个字段表示消息中的任务类型。我们可以通过首先在src/tasks/mod.rs
文件中使用以下代码导入Serialize
和Deserialize
特性来定义此过程:
use serde::{Serialize, Deserialize};
我们可以使用以下代码定义enum
任务类型和消息结构体:
#[derive(Debug, Clone, Serialize, Deserialize)]
use add::AddTask;
use multiply::MultiplyTask;
use subtract::SubtractTask;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum TaskType {
ADD(AddTask),
MULTIPLY(MultiplyTask),
SUBTRACT(SubtractTask)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TaskMessage {
pub task: TaskType
}
现在我们已经准备好将一系列任务打包,以便插入到 Redis 队列中。在我们的src/main.rs
文件中,我们可以使用以下代码导入TaskType
和TaskMessage
结构体:
mod tasks;
use tasks::{
add::AddTask,
TaskType,
TaskMessage
};
现在,我们已经准备好在工人代码块中重写我们的无限循环。我们最初创建AddTask
,序列化AddTask
,然后使用以下代码将序列化的任务打包到TaskMessage
中:
let body = AddTask{one: 1, two: 2};
let message = TaskMessage{task: TaskType::ADD(body)};
let serialized_message = bincode::serialize(&message).unwrap();
然后,我们将建立 Redis 连接,并使用以下代码将序列化的消息推送到 Redis 队列:
let mut con = client.get_connection().unwrap();
let _ : () = redis::cmd("LPUSH").arg("some_queue")
.arg(serialized_message
.clone())
.query(&mut con).unwrap();
然后,我们将使用以下代码从 Redis 队列中弹出任务并断开连接:
let outcome: Option<Vec<u8>> =
redis::cmd("RPOP").arg("some_queue").query(&mut con)
.unwrap();
std::mem::drop(con);
我们现在正在将TaskMessage
结构体在 Redis 队列中进进出出。如果有TaskMessage
,我们必须处理它。在outcome
语句的Some
块的match
块中,我们必须反序列化我们从 Redis 队列中获得的字节,然后使用以下代码匹配任务类型:
let deserialized_message: TaskMessage =
bincode::deserialize(&data).unwrap();
match deserialized_message.task {
TaskType::ADD(task) => {
println!("{:?}", task.run());
},
TaskType::MULTIPLY(task) => {
println!("{:?}", task.run());
},
TaskType::SUBTRACT(task) => {
println!("{:?}", task.run());
}
}
这现在使我们能够处理我们从 Redis 队列中提取并运行的单个任务。
我们的工作者现在支持我们所有的三个任务!然而,我们目前只是创建消息,然后直接在工作者中消费这些消息。我们需要启用 HTTP 服务器以接受一系列不同的请求,以便将一系列不同的任务发送到 Redis 队列供工作者消费。
在 HTTP 服务器中集成路由
我们现在处于使我们的 HTTP 服务器接受创建一系列任务的传入请求的阶段,这些任务取决于 URI 的内容。为了使我们的 HTTP 支持多个任务,我们本质上必须重写src/main.rs
文件中的handle
函数。在我们重写main
函数之前,我们必须使用以下代码导入我们需要的内容:
use hyper::body;
use hyper::http::StatusCode;
我们导入这些内容是因为如果我们传递了错误的 URI,我们将返回NOT_FOUND
状态码。我们还将从传入请求的正文提取数据。在我们重构handle
函数之前,我们需要将我们的IncomingBody
结构体更改为接受两个整数,其形式如下:
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IncomingBody {
pub one: i32,
pub two: i32
}
在我们的handle
函数内部,我们可以定义我们的 Redis 客户端,通过删除尾随斜杠来清理我们的 URI,并使用以下代码从传入的请求中提取数据:
let client =
redis::Client::open("redis://127.0.0.1/").unwrap();
let task_type = req.uri().to_string().replace("/", "")"");
let body_bytes =
body::to_bytes(req.into_body()).await.unwrap();
let body: IncomingBody =
_json::from_slice(&body_bytes).unwrap();
我们可以看到,我们可以从 URI 中提取任务类型。目前,我们将支持add
、subtract
和multiply
。我们现在已经从传入的请求中获得了所有需要的信息;我们可以根据以下代码使用 URI 构建适当的任务:
let message_type: TaskType;
match task_type.as_str() {
"add" => {
let body = AddTask{one: body.one,
two: body.two};
message_type = TaskType::ADD(body);
},
"multiply" => {
let body = MultiplyTask{one: body.one,
two: body.two};
message_type = TaskType::MULTIPLY(body);
},
"subtract" => {
let body = SubtractTask{one: body.one,
two: body.two};
message_type = TaskType::SUBTRACT(body);
},
_ => {
. . .
}
}
我们可以看到,无论任务是什么,我们都需要将任务结构体打包到我们的TaskType
枚举中,它可以序列化为二进制向量,以便将消息发送到 Redis 队列。对于match
语句的最后部分,它捕获所有与“add”、“multiply”或“subtract”不匹配的任务请求,我们只需返回以下代码的NOT_FOUND
HTTP 响应:
let response =
Response::builder().status(StatusCode::NOT_FOUND)
.body(Body::from("task not found"));
return Ok(response.unwrap())
现在我们已经拥有了创建可以插入 Redis 队列的通用任务消息所需的一切。有了这些信息,我们可以在刚刚覆盖的match
语句之后创建我们的TaskMessage
结构体并序列化TaskMessage
,以下代码如下:
let message = TaskMessage{task_type: message_type,
task: bytes};
let serialized_message =
bincode::serialize(&message).unwrap();
然后,我们将建立 Redis 连接,将序列化的消息推送到 Redis 队列,然后断开 Redis 连接,以下代码如下:
let mut con = client.get_connection().unwrap();
let _ : () = redis::cmd("LPUSH").arg("some_queue")
.arg(serialized_message
.clone())
.query(&mut con).unwrap();
最后,我们返回一个表示任务已发送的Ok
HTTP 响应,以下代码如下:
Ok(Response::new(Body::from("task sent")))
我们的handle
函数现在已经完成。我们现在需要做的就是从工作代码块中移除插入AddTask
结构到 Redis 队列的代码。我们之所以要从工作代码块中移除插入代码,是因为我们不再需要工作进程插入任务。移除插入代码的形式如下:
let client =
redis::Client::open("redis://127.0.0.1/").unwrap();
loop {
let outcome: Option<Vec<u8>> = {
let mut con = client.get_connection()
.unwrap();
redis::cmd("RPOP").arg("some_queue")
.query(&mut con)
.unwrap()
};
match outcome {
. . .
}
}
我们现在准备好将这些工作进程和 HTTP 服务器打包到 Docker 中,这样我们就可以运行我们想要那么多工作进程的应用程序。
在 Docker 中运行所有内容
我们现在处于可以运行整个应用程序在 Docker 中的阶段。这使得我们可以有多个工作进程从同一个 Redis 队列中拉取。首先,我们需要定义构建我们的工作进程/服务器镜像的Dockerfile
。我们将使用以下代码进行 distroless 构建:
FROM rust:1.62.1 as build
ENV PKG_CONFIG_ALLOW_CROSS=1
WORKDIR /app
COPY . .
cargo build --release
FROM gcr.io/distroless/cc-debian10
COPY --from=build /app/target/release/task_queue
/usr/local/bin/task_queue
EXPOSE 3000
ENTRYPOINT ["task_queue"]
到目前为止,这本书中的这个 distroless 构建不应该令人惊讶。我们只是在编译应用程序,然后将静态二进制文件复制到 distroless 镜像中。在我们以任何方式运行构建之前,我们必须确保不要将target
目录中的过多文件复制到我们的 Docker 构建中,以下是在.dockerignore
文件中的代码:
./target
.github
我们的项目构建现在已经就绪。我们可以定义以下轮廓的docker-compose.yml
:
version: "3.7"
services:
server_1:
. . .
worker_1:
. . .
worker_2:
. . .
worker_3:
. . .
redis:
container_name: 'queue-redis'
image: 'redis'
ports:
- '6379:6379'
在这里,我们可以看到我们有三个工作进程和一个服务器。我们的服务器形式如下:
server_1:
container_name: server_1
image: server_1
build:
context: .
environment:
- 'APP_TYPE=server'
- 'REDIS_URL=redis://redis:6379'
depends_on:
redis:
condition: service_started
restart: on-failure
ports:
- "3000:3000"
expose:
- 3000
在这里,我们可以看到我们可以暴露端口,指出构建上下文在当前目录,并且我们的容器应该在 Redis 启动后启动。
标准工作进程的形式如下:
worker_1:
container_name: worker_1
image: worker_1
build:
context: .
environment:
- 'APP_TYPE=worker'
- 'REDIS_URL=redis://redis:'
depends_on:
redis:
condition: service_started
restart: on-failure
我们可以想象其他工作进程具有与前面工作进程相同的结构,这是真的。如果我们想添加另一个工作进程,我们可以有与worker_1
完全相同的规范,只是我们只增加了附加到镜像和容器名称的数字,从而使得新的工作进程被称为worker_2
。你可能已经注意到我们添加了REDIS_URL
到环境变量中。这是因为工作进程和服务器必须在其容器外访问 Redis 数据库。将 localhost 传递给 Redis 客户端将导致无法连接到 Redis。因此,我们必须消除所有对 Redis 客户端的引用,并用以下代码替换这些引用:
let client =
redis::Client::open(env::var("REDIS_URL").unwrap())
.unwrap();
如果我们现在启动docker_compose
并向服务器发送一系列不同的 HTTP 请求,我们会得到以下输出:
. . .
queue-redis | 1:M 30 Oct 2022 18:42:52.334 *
RDB memory usage when created 0.85 Mb
queue-redis | 1:M 30 Oct 2022 18:42:52.334 *
Done loading RDB, keys loaded: 0, keys expired: 0.
queue-redis | 1:M 30 Oct 2022 18:42:52.334 *
DB loaded from disk: 0.002 seconds
queue-redis | 1:M 30 Oct 2022 18:42:52.334 *
Ready to accept connections
worker_1 | empty queue
worker_3 | empty queue
worker_1 | empty queue
worker_3 | empty queue
worker_2 | multiply: 9
worker_3 | multiply: 25
worker_1 | multiply: 8
worker_3 | empty queue
worker_3 | empty queue
worker_2 | multiply: 4
worker_2 | empty queue
. . .
这是一个很大的输出,但我们可以看到 Redis 启动了,并且有多个工作进程正在轮询 Redis 队列。我们还可以看到多个工作进程正在同时处理多个任务。如何向服务器发送请求的示例在这里展示:
图 18.4 – 向我们的服务器发送乘法请求的示例
图 18.5 – 向我们的服务器发送减法请求的示例
图 18.6 – 向我们的服务器发送添加请求的示例
这里就是它!我们有一个接受请求的服务器。根据 URI,我们的服务器构建一个任务,将其打包成一个消息,然后将其发送到 Redis 队列。然后我们有多个工作进程轮询 Redis 队列以处理长时间任务。
摘要
在本章中,我们构建了一个既可以作为工作进程也可以作为服务器的应用程序。然后我们构建了可以序列化并插入 Redis 队列的结构体。这使得我们的工作进程可以消费这些任务,并在自己的时间处理它们。现在,您有了构建无需阻塞 HTTP 服务器即可处理长时间任务的能力。序列化 Rust 结构体并将它们插入 Redis 的机制不仅限于处理大型任务。我们可以序列化 Rust 结构体并通过 Redis 的 pub/sub 通道将它们发送到其他 Rust 服务器,这在更大规模上本质上创建了一个演员模型的方法。借助我们的无元数据镜像,这些 Rust 服务器的大小仅约为 50 MB,这使得这个概念具有可扩展性。我们还探讨了将原始命令应用于 Redis,这为您提供了自由和信心,可以完全拥抱 Redis 提供的一切。所有可以用于 Redis 的命令的高级列表在 进一步阅读 部分给出。您将会对您能做什么感到震惊,我希望您在查看可用命令时,像我一样对使用 Redis 可以实现的所有解决方案感到兴奋。
我们已经到达了本书的结尾。我很感激您能走到这一步,当读者们联系我时,我总是很高兴。Rust 真正是一种革命性的编程语言。有了 Rust,我们能够构建和部署快速小巧的服务器。我们探索了异步编程和演员模型。我们构建了部署管道。您的旅程还没有结束;总有更多东西可以学习。然而,我希望我已经以这种方式向您介绍了基本概念,这样您可以继续前进,阅读更多文档,实践,并有一天推动网络编程的边界。
进一步阅读
-
Redis 推送至队列的文档:
redis.io/commands/lpush/
-
原始 Redis 命令的简洁列表:
www.tutorialspoint.com/redis/redis_lists.htm
-
Redis Rust crate 文档:
docs.rs/redis/latest/redis/