Rust-和-Rocket-Web-开发-全-

Rust 和 Rocket Web 开发(全)

原文:annas-archive.org/md5/aea44796c434395736f40ffa10fc61c1

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

Rocket 是 Rust 编程语言中第一个 Web 框架之一。Rocket 提供了构建 Web 应用程序的完整工具,例如请求路由工具、传入请求的强类型以及用于操作传入请求和传出响应的中间件。Rocket 还提供了模板支持和连接到各种数据库的功能。

Rocket 是用 Rust 编程语言编写的 Web 框架。作为较新的编程语言之一,Rust 被设计为性能和安全性高。它易于创建安全、多线程和异步的应用程序。Rust 在工具、文档和社区包方面也有坚实的基础。所有这些优势共同促成了 Rust 在受欢迎程度上的快速增长。

本书探讨了使用 Rocket Web 框架和 Rust 编程语言构建完整 Web 应用程序的过程。您将了解各种构建 Web 应用程序的技术,该应用程序可以处理传入的请求,将数据存储在关系型数据库管理系统(RDBMS)中,并向任何 HTTP 客户端生成适当的响应。

本书面向的对象

我们编写这本书是为了帮助那些想学习如何使用 Rocket Web 框架构建 Web 应用程序的软件工程师。虽然不是必需的,但基本了解 Rust 编程语言将有助于您轻松理解所涵盖的主题。

本书涵盖的内容

第一章介绍 Rust 语言,介绍了 Rust 语言以及构建 Rust 应用程序的工具。

第二章构建我们的第一个 Rocket Web 应用程序,指导您创建和配置 Rocket 应用程序。

第三章火箭请求与响应,介绍了 Rocket 的路由、请求和响应。

第四章构建、点燃和发射火箭,解释了 Rocket 的两个重要组件:状态和整流罩。状态提供可重用的对象,而整流罩充当 Rocket 应用程序的中间件部分。本章还解释了如何将数据库连接到 Rocket 应用程序。

第五章设计用户生成应用程序,探讨了设计应用程序的过程,并展示了如何使用 Rust 模块创建更易于管理的 Web 应用程序。

第六章实现用户 CRUD,指导您如何在 Rocket Web 应用程序及其背后的数据库中创建、读取、更新和删除CRUD)对象。

第七章在 Rust 和 Rocket 中处理错误,解释了如何在 Rust 中处理错误,以及我们如何在 Rocket 应用程序中应用错误处理。

第八章, 服务静态资源和模板,展示了如何使用 Rocket 网络应用来服务文件(如 CSS 文件和 JS 文件)。您还将学习如何使用模板为 Rocket 网络应用创建响应。

第九章, 显示用户帖子,指导你了解 Rust 泛型和如何使用泛型来显示不同类型的用户帖子。

第十章, 上传和处理帖子,解释了 Rust 应用程序中的异步编程和多线程,以及如何将这些技术应用于处理 Rocket 网络应用中的用户上传。

第十一章, 安全和添加 API 及 JSON,指导你如何在 Rocket 网络应用中创建身份验证和授权。本章还解释了如何创建 JSON API 端点以及如何使用 JWT 来保护 API 端点。

第十二章, 测试您的应用,介绍了测试 Rust 应用程序以及为 Rocket 网络应用创建端到端测试。

第十三章, 启动火箭应用,解释了如何配置生产服务器以使用 Rocket 网络应用来处理请求。本章还解释了如何使用 Docker 容器化 Rust 应用程序。

第十四章, 构建全栈应用,解释了如何使用 Rust 编程语言构建一个前端 WebAssembly 应用来补充 Rocket 网络应用。

第十五章, 改进火箭应用,解释了如何改进和扩展 Rocket 网络应用。本章还介绍了 Rocket 网络框架的可能的替代方案。

为了充分利用这本书

您需要在计算机上安装 Rust 编译器,通过安装 Rustup 和稳定工具链来实现。您可以使用 Linux、macOS 或 Windows。对于 macOS 用户,建议使用 Homebrew。对于 Windows 用户,建议使用 Windows Subsystem for Linux (WSL 或 WSL 2)来安装 Rustup。

本书中的所有代码都在 Arch Linux 和 macOS 上进行了测试,但它们也应该在其他 Linux 发行版或 Windows 操作系统上运行。

由于 Rust 是一种编译型语言,您可能需要在计算机上安装各种开发头文件,例如libssl-devlibpq-dev。在编译本书中的代码示例时,请注意错误信息,并在需要的情况下安装适用于您操作系统和开发环境的所需库。

在本书的进一步内容中,在第十章,我们将使用FFmpeg命令行处理视频。

如果您正在使用本书的数字版,我们建议您亲自输入代码或从本书的 GitHub 仓库(下一节中提供链接)获取代码。这样做将帮助您避免与代码的复制和粘贴相关的任何潜在错误。

下载示例代码文件

您可以从 GitHub 下载本书的示例代码文件github.com/PacktPublishing/Rust-Web-Development-with-Rocket。如果代码有更新,它将在 GitHub 仓库中更新。

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

下载彩色图像

我们还提供了一份包含本书中使用的截图和图表彩色图像的 PDF 文件。您可以从这里下载:packt.link/PUFPv

使用的约定

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

文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“内置数据类型,如OptionResult,以安全的方式处理类似 null 的行为。”

代码块应如下设置:

fn main() { 
    println!("Hello World!");
}

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

impl super::Encryptable for Rot13 {
    fn encrypt(&self) -> String {
    }
}

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

rustup default stable
rustup component add clippy

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

小贴士或重要提示

看起来是这样的。

联系我们

我们欢迎读者的反馈。

一般反馈:如果您对本书的任何方面有疑问,请通过电子邮件发送至 customercare@packtpub.com,并在邮件主题中提及书名。

勘误表:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,如果您能向我们报告,我们将不胜感激。请访问www.packtpub.com/support/errata并填写表格。

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

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

分享您的想法

一旦您阅读了《Rust Web Development with Rocket》,我们非常期待听到您的想法!请点击此处直接访问此书的亚马逊评论页面并分享您的反馈。

您的评论对我们和科技社区都非常重要,并将帮助我们确保我们提供高质量的内容。

第一部分:Rust 编程语言和 Rocket 网络框架简介

在这部分,你将学习关于 Rust 编程语言的知识,包括其基础知识、如何在你的操作系统上安装它,以及如何使用 Rust 工具和包注册表。你还将创建一个 Rocket 网络应用程序,并对其进行配置、编译和运行。我们将探讨为网络应用程序安装和包含其他包的方法。我们将使用 sqlx 来实现网络应用程序与关系型数据库的连接。

本部分包括以下章节:

  • 第一章**,介绍 Rust 语言

  • 第二章**,构建我们的第一个 Rocket 网络应用程序

  • 第三章**,Rocket 请求和响应

  • 第四章**,构建、点燃和发射 Rocket

  • 第五章**,设计用户生成应用程序

第一章:第一章:介绍 Rust 语言

几乎每个程序员都听说过 Rust 编程语言,甚至尝试或使用过它。每次都说“Rust 编程语言”有点繁琐,所以从现在起我们就叫它 Rust,或者 Rust 语言。

在本章中,我们将简要介绍 Rust,以帮助你是 Rust 语言的新手,或者如果你已经尝试过,作为复习。本章也可能对经验丰富的 Rust 语言程序员有所帮助。在章节的后面部分,我们将学习如何安装 Rust 工具链并创建一个简单的程序来介绍 Rust 语言的特性。然后,我们将使用第三方库来增强我们的一个程序,最后,我们将了解如何为 Rust 语言及其库获取帮助。

在本章中,我们将涵盖以下主要主题:

  • Rust 语言的概述

  • 安装 Rust 编译器和工具链

  • 编写 Hello World

  • 探索 Rust 包和 Cargo

  • 探索其他工具和获取帮助的地方

技术要求

要跟随本书的内容,你需要一台运行类似 Unix 的操作系统(如 Linux、macOS 或安装了 Windows Subsystem for Linux (WSLv1 或 WSLv2) 的 Windows)的计算机。不用担心 Rust 编译器和工具链;如果尚未安装,我们将在本章中安装它。

本章的代码可以在 github.com/PacktPublishing/Rust-Web-Development-with-Rocket/tree/main/Chapter01 找到。

Rust 语言的概述

要使用 Rocket 框架构建网络应用程序,我们首先需要了解一点 Rust 语言,因为 Rocket 是用那种语言构建的。根据 www.rust-lang.org,Rust 语言是 "一种赋予每个人构建可靠和高效软件能力的语言。" 它始于 2006 年左右程序员 Graydon Hoare 的一个个人项目,他是 Mozilla 的员工。Mozilla 基金会看到了这种语言对他们的产品的潜力;他们在 2009 年开始赞助这个项目,并在 2010 年向公众宣布。

自从诞生以来,Rust 的重点始终是性能和安全。构建一个网络浏览器并不是一件容易的事情;一种不安全的语言可以拥有非常快的性能,但如果没有适当的安全措施,使用系统语言的程序员可能会犯很多错误,例如丢失指针引用。Rust 被设计成一种系统语言,并从较老的语言中吸取了许多教训。在较老的语言中,你可以轻易地因为空指针而“自伤”,而且语言中没有任何东西可以阻止你编译这样的错误。相比之下,在 Rust 语言中,你不能编写出会导致空指针的代码,因为这将会在编译时被检测到,你必须修复实现以使其能够编译。

Rust 语言的设计在很大程度上借鉴了函数式编程范式以及面向对象编程范式。例如,它具有函数式语言的一些元素,如闭包和迭代器。你可以轻松地创建一个纯函数,并将该函数作为另一个函数的参数使用;有语法可以轻松创建闭包和数据类型,如OptionResult

另一方面,没有类定义,但你可以轻松地定义一个数据类型,例如,一个结构体。在定义了这种数据类型之后,你可以创建一个块来实现其方法。

尽管没有继承,但你可以通过使用MakeSound特质轻松地将对象分组。然后,你可以通过编写方法签名来确定该特质中应该有哪些方法。如果你定义一个数据类型,例如,一个名为Cow的结构体,你可以告诉编译器它实现了MakeSound特质。因为你说Cow结构体实现了MakeSound特质,所以你必须为Cow结构体实现特质中定义的方法。听起来像面向对象语言,对吧?

Rust 语言在发布稳定版本(Rust 1.0)之前经历了多次迭代(2015 年 5 月 15 日)。在发布稳定版本之前,一些早期的语言设计被废弃了。在某个时刻,Rust 有一个类特性,但在稳定发布之前被废弃了,因为 Rust 的设计被改为数据和行为分离。你写数据(例如,以structenum类型的形式),然后你分别写行为(例如,impl)。为了将这些impl归类到同一组中,我们可以创建一个特质。因此,你可以从面向对象语言中获得所有想要的功能。此外,Rust 曾经有过垃圾回收,但后来因为使用了另一种设计模式而被废弃。当对象超出作用域时,例如退出函数,它们会自动释放。这种类型的自动内存管理使得垃圾回收变得不必要。

在第一个稳定版本发布后,人们添加了更多功能,使 Rust 更加易用和可用。其中最大的变化是async/await,它在 1.39 版本中发布。这个特性对于开发处理 I/O 的应用程序非常有用,而 Web 应用程序编程处理大量的 I/O。Web 应用程序必须处理数据库和网络连接、从文件中读取等。人们普遍认为,async/await 是使语言适合 Web 编程的最需要的特性之一,因为在 async/await 中,程序不需要创建新的线程,但它也不像传统函数那样阻塞。

另一个重要特性是const fn,这是一个将在编译时而不是运行时评估的函数。

近年来,许多大型公司开始建立 Rust 开发者的人才库,这突出了它在商业中的重要性。

为什么使用 Rust 语言?

那么,为什么我们应该使用 Rust 语言进行 Web 应用程序开发?现有的成熟语言不是足够好吗?以下是一些人们想要使用 Rust 语言创建 Web 应用程序的原因:

  • 安全性

  • 无垃圾回收

  • 速度

  • 多线程和异步编程

  • 静态类型

安全性

虽然使用系统编程语言编写应用程序有其优势,因为它们功能强大(程序员可以访问程序的基本构建块,例如分配计算机内存以存储重要数据,然后在不使用时立即释放该内存),但很容易出错。

在传统的系统语言中,没有任何东西可以阻止程序在内存中存储数据,创建指向该数据的指针,释放内存中存储的数据,然后尝试通过该指针再次访问数据。数据已经消失,但指针仍然指向内存的该部分。

经验丰富的程序员很容易在简单的程序中找到这样的错误。一些公司强迫他们的程序员使用静态分析工具来检查代码中的此类错误。但是,随着编程技术的日益复杂,应用程序的复杂性也在增长,这些类型的错误仍然可以在许多应用程序中找到。近年来发现的一些高调的漏洞和黑客攻击,如 Heartbleed,如果我们使用内存安全语言,就可以防止。

Rust 是一种内存安全语言,因为它对程序员如何编写代码有一系列规则。例如,当代码编译时,它会检查变量的生命周期,如果另一个变量仍然尝试访问已经超出作用域的数据,编译器将显示错误。2020 年,博士后研究员 Ralf Jung 已经进行了第一次正式验证,证明 Rust 语言确实是一种安全语言。内置的数据类型,如 OptionResult,以安全的方式处理类似 null 的行为。

无垃圾回收

许多程序员由于安全问题,会创建和使用不同的内存管理技术。其中一种技术是垃圾回收。其理念很简单:内存管理在运行时自动完成,这样程序员就不必考虑内存管理。程序员只需创建一个变量,当变量不再使用时,运行时系统会自动将其从内存中移除。

垃圾回收是计算中的一个有趣且重要的部分。有许多技术,如引用计数和追踪。例如,Java 除了官方的垃圾回收器外,还有几个第三方垃圾回收器。

这种语言设计选择的问题在于垃圾回收通常需要大量的计算资源。例如,一部分内存因为垃圾收集器还没有回收这部分内存而暂时不可用。或者,更糟糕的是,垃圾收集器无法从堆中移除已使用的内存,因此它会积累,大多数计算机内存将变得不可用,或者我们通常所说的内存泄漏。在停止世界的垃圾回收机制中,整个程序执行被暂停,以便垃圾收集器回收内存,之后程序执行继续。因此,有些人发现很难用这种语言开发实时应用程序。

Rust 采取了一种不同的方法,称为资源获取即初始化RAII),这意味着一个对象一旦超出作用域就会自动释放。例如,如果你编写一个函数,在该函数中创建的对象将在函数退出时被释放。但显然,这使得 Rust 与手动释放内存的编程语言或具有垃圾回收的编程语言非常不同。

速度

如果你习惯于使用解释型语言或具有垃圾回收的语言进行 Web 开发,你可能会说我们不需要担心计算性能,因为 Web 开发是 I/O 密集型的;换句话说,瓶颈在于应用程序访问数据库、磁盘或另一个网络时,因为它们的速度比 CPU 或内存慢。

这个谚语可能主要是正确的,但一切都取决于应用程序的使用。如果你的应用程序处理大量的 JSON,处理是 CPU 密集型的,这意味着它受限于 CPU 的速度,而不是磁盘访问速度或网络连接速度。如果你关心应用程序的安全性,你可能需要处理散列和加密,这些都是 CPU 密集型的。如果你正在为在线流媒体服务编写后端应用程序,你希望应用程序尽可能优化。如果你正在编写服务于数百万用户的应用程序,你希望应用程序非常优化,并且尽可能快地返回响应。

Rust 语言是一种编译型语言,因此编译器会将程序转换为机器代码,计算机处理器可以执行。编译型语言通常比解释型语言运行得更快,因为在解释型语言中,当运行时二进制将程序解释为本地机器代码时,会有额外的开销。在现代解释器中,通过使用现代技术,如即时编译器JIT)来加速程序执行,速度差距已经缩小,但在像 Ruby 这样的动态语言中,它仍然比使用编译型语言慢。

多线程和异步编程

在传统编程中,同步编程意味着应用程序必须等待 CPU 处理完一个任务。在 Web 应用程序中,服务器必须等待 HTTP 请求被处理并响应;只有在此之后,它才会继续处理另一个 HTTP 请求。如果应用程序只是直接创建响应,如简单的文本,这并不是问题。但当 Web 应用程序需要花费一些时间来处理时,它必须等待数据库服务器响应,必须等待文件在服务器上完全写入,以及必须等待对第三方 API 服务的 API 调用成功完成,这时就会变成问题。

克服等待问题的方法之一是多线程。单个进程可以创建多个线程,这些线程共享一些资源。Rust 语言被设计成易于创建安全的多线程应用程序。它通过多个容器,如 Arc,来简化线程间数据传递。

多线程的问题在于,创建一个线程意味着分配大量的 CPU、内存和操作系统资源,或者如俗话所说,成本高昂。解决方案是使用一种称为异步编程的不同技术,其中单个线程被不同的任务重用,而不必等待第一个任务完成。人们可以很容易地在 Rust 中编写异步程序,因为自 2019 年 11 月 7 日起,它已被纳入语言中。

静态类型

在编程语言中,动态类型语言是指在运行时检查变量类型的语言,而静态类型语言则在编译时检查数据类型。

动态类型意味着编写代码更容易,但也更容易出错。通常,程序员需要在动态类型语言中编写更多的单元测试来补偿编译时没有检查类型。动态类型语言也被认为成本更高,因为每次函数被调用时,程序都需要检查传递的参数。因此,优化动态类型语言变得困难。

相反,Rust 是静态类型的,因此很难犯将字符串作为数字传递的错误。编译器可以在应用程序发布之前优化生成的机器代码,并显著减少编程错误。

现在我们已经概述了 Rust 语言及其与其他语言的比较优势,让我们学习如何安装 Rust 编译器工具链,该工具链将用于编译 Rust 程序。我们将在这本书中使用这个工具链。

安装 Rust 编译器工具链

让我们从安装 Rust 编译器工具链开始。Rust 有三个官方渠道:稳定版测试版夜间版。Rust 语言使用 Git 作为其版本控制系统。人们将新功能和错误修复添加到 master 分支。每晚,master 分支的源代码都会被编译并发布到夜间版渠道。六周后,代码将从 beta 分支分叉出来,编译并发布到 beta 渠道。然后,人们将在 beta 发布版中运行各种测试,通常是在他们的 CI(持续集成)安装中。如果发现错误,修复将被提交到 master 分支,然后回滚到 beta 分支。第一次 beta 分支六周后,稳定版将从 beta 分支创建。

我们将在整本书中使用稳定渠道的编译器,但如果您想尝试冒险,您也可以使用其他渠道。不过,如果您使用其他渠道,我们无法保证我们将要创建的程序会编译,因为人们会添加新功能,并且新版本中可能引入了回归。

在您的系统中安装 Rust 工具链有几种方法,例如从头开始引导和编译,或者使用您的操作系统包管理器。但是,在您的系统中安装 Rust 工具链的推荐方法是使用rustup

在其网站上的定义(rustup.rs)非常简单:"rustup 是系统编程语言 Rust 的安装程序。"现在,让我们尝试按照这些说明来安装rustup

在 Linux OS 或 macOS 上安装 rustup

如果您使用的是 Debian 10 Linux 发行版,则这些说明适用,但如果您已经使用其他 Linux 发行版,我们将假设您已经熟悉 Linux 操作系统,并且可以适应适合您 Linux 发行版的这些说明:

  1. 打开您选择的终端。

  2. 通过输入以下命令确保您已安装 cURL:

    curl
    
  3. 如果 cURL 尚未安装,让我们来安装它:

    apt install curl
    

如果您使用的是 macOS,您可能已经安装了 cURL。

  1. 之后,按照rustup.rs上的说明进行操作:

    curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
    
  2. 然后,它会显示问候和信息,您可以自定义;目前,我们只是使用默认设置:

    ...
    1) Proceed with installation (default)
    2) Customize installation
    3) Cancel installation
    >
    
  3. 输入1以使用默认安装。

  4. 之后,重新加载您的终端或在此当前终端中输入以下内容:

    source $HOME/.cargo/env
    
  5. 您可以通过在终端中输入rustup来确认安装是否成功,您应该会看到 rustup 的使用说明。

  6. 现在,让我们安装稳定的 Rust 工具链。在终端中输入以下内容:

    rustup toolchain install stable
    
  7. 在工具链安装到您的操作系统后,让我们确认我们是否可以运行 Rust 编译器。在终端中输入rustc,您应该会看到如何使用它的说明。

安装不同的工具链和组件

目前,我们已经安装了稳定工具链,但还有两个其他默认通道可以安装:nightlybeta

有时,你可能出于各种原因想要使用不同的工具链。也许你想尝试一个新功能,或者也许你想测试你的应用程序对即将到来的 Rust 版本的回归。你可以简单地使用rustup来安装它:

rustup toolchain install nightly

每个工具链都有组件,其中一些是工具链必需的,例如rustc,这是 Rust 编译器。其他组件默认不安装,例如clippy,它提供了rustc编译器未提供的更多检查,并给出代码风格建议。安装它也非常简单;你可以使用rustup component add <component>,如本例所示:

rustup default stable
rustup component add clippy

更新工具链、rustup 和组件

Rust 工具链有一个大约每三个月(六周加六周)的定期发布计划,但有时会有针对重大错误修复或安全问题的紧急发布。因此,有时你需要更新你的工具链。更新非常简单。此命令还将更新工具链中安装的组件:

rustup update

除了工具链之外,rustup本身也可能需要更新。你可以通过输入以下内容来更新它:

rustup self update

现在我们已经将 Rust 编译器工具链安装到我们的系统中,让我们编写我们的第一个 Rust 程序!

编写 Hello World!

在本节中,我们将编写一个非常基础的程序,Hello World!。在我们成功编译之后,我们将编写一个更复杂的程序,以查看 Rust 语言的基本功能。让我们按照以下指示进行操作:

  1. 让我们创建一个新的文件夹,例如,01HelloWorld

  2. 在文件夹内创建一个新的文件,并将其命名为main.rs

  3. 让我们在 Rust 中编写我们的第一个代码:

    fn main() { 
        println!("Hello World!");
    }
    
  4. 之后,保存你的文件,在同一文件夹中,打开你的终端,并使用rustc命令编译代码:

    rustc main.rs
    
  5. 你可以看到文件夹内有一个名为main的文件;从你的终端运行该文件:

    ./main
    
  6. 恭喜!你刚刚用 Rust 语言编写了你的第一个Hello World程序。

接下来,我们将提高我们的 Rust 语言水平;我们将展示带有控制流、模块和其他功能的 Rust 基本应用程序。

编写更复杂的程序

当然,在制作了Hello World程序之后,我们应该尝试编写一个更复杂的程序,看看我们能用这个语言做什么。我们想要编写一个程序,它可以捕获用户输入的内容,使用选定的算法对其进行加密,并将输出返回到终端:

  1. 让我们创建一个新的文件夹,例如,02ComplexProgram。之后,再次创建main.rs文件并再次添加main函数:

    fn main() {}
    
  2. 然后,使用std::io模块编写程序的这部分,提示用户输入他们想要加密的字符串:

    use std::io;
    fn main() {
        println!("Input the string you want to encrypt:");
        let mut user_input = String::new();
        io::stdin()
            .read_line(&mut user_input)
            .expect("Cannot read input");
        println!("Your encrypted string: {}", user_input);
    }
    

让我们逐行探索我们所写的:

  1. 第一行,use std::io;,是在告诉我们的程序,我们将在程序中使用 std::io 模块。除非我们明确表示不使用它,否则 std 应该默认包含在程序中。

  2. let... 行是一个变量声明。当我们定义一个变量在 Rust 中时,该变量默认是不可变的,因此我们必须添加 mut 关键字来使其可变。user_input 是变量名,而此语句的右侧是初始化一个新的空 String 实例。注意我们是如何直接初始化变量的。Rust 允许分离声明和初始化,但那种形式不是惯用的,因为程序员可能会尝试使用未初始化的变量,而 Rust 禁止使用未初始化的变量。因此,代码将无法编译。

  3. 下一段代码,即 stdin() 函数,初始化了 std::io::Stdin 结构体。它从终端读取输入并将其放入 user_input 变量中。注意 read_line() 的签名接受 &mut String。我们必须明确告诉编译器我们正在传递一个可变引用,因为 Rust 的借用检查器,我们将在稍后的 第九章**, 显示用户帖子 中讨论。read_line() 的输出是 std::result::Result,这是一个有两个变体的枚举,Ok(T)Err(E)Result 中的一个方法是 expect(),它返回一个泛型类型 T,如果它是 Err 变体,则它将结合传递的消息引发一个泛型错误 E 并导致程序崩溃。

  4. Rust 语言中有两个非常普遍且重要的枚举类型(std::result::Resultstd::option::Option),因此默认情况下,我们可以在程序中使用它们而不需要指定 use

接下来,我们想要能够加密输入,但到目前为止,我们还不知道我们想要使用哪种加密。我们想要做的第一件事是创建一个 特质,这是 Rust 语言中的一种特定代码,它告诉编译器一个类型可以有什么功能:

  1. 创建模块有两种方式:创建 module_name.rs 或创建一个名为 module_name 的文件夹并在其中添加一个 mod.rs 文件。让我们创建一个名为 encryptor 的文件夹并创建一个名为 mod.rs 的新文件。由于我们想要稍后添加类型和实现,让我们使用第二种方式。让我们在 mod.rs 中写下这些内容:

    pub trait Encryptable {
        fn encrypt(&self) -> String;
    }
    
  2. 默认情况下,类型或特质是私有的,但我们在 main.rs 中想要使用它,并在不同的文件中实现加密器,所以我们应该通过添加 pub 关键字来将特质标记为公共的。

  3. 该特质有一个名为 encrypt() 的函数,它以自引用作为参数并返回 String

  4. 现在,我们应该在 main.rs 中定义这个新模块。将此行放在 fn main 块之前:

    pub mod encryptor;
    
  5. 然后,让我们创建一个简单的类型,它实现了Encryptable特质。记得凯撒密码,其中密码替换一个字母为另一个字母吗?让我们实现最简单的一个叫做ROT13的,其中它将'a'转换为'n',将'n'转换为'a',将'b'转换为'o'和将'o'转换为'b',依此类推。在mod.rs文件中写下以下内容:

    pub mod rot13;
    
  6. 让我们在encryptor文件夹内创建另一个名为rot13.rs的文件。

  7. 我们想要定义一个简单的结构体,它只包含一个数据项,即一个字符串,并告诉编译器这个结构体正在实现Encryptable特质。将此代码放入rot13.rs文件中:

    pub struct Rot13(pub String);
    impl super::Encryptable for Rot13 {}
    

你可能会注意到我们在模块声明、特质声明、结构体声明和字段声明中到处都使用了pub

  1. 接下来,让我们尝试编译我们的程序:

    > rustc main.rs 
    error[E0046]: not all trait items implemented, missing: `encrypt`
     --> encryptor/rot13.rs:3:1
      |
    3 | impl super::Encryptable for Rot13 {}
    | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ missing 
      `encrypt` in implementation
    | 
     ::: encryptor/mod.rs:6:5
      |
    6 |     fn encrypt(&self) -> String;
      |     ----------------------------------------------
      ------ `encrypt` from trait
    error: aborting due to previous error
    For more information about this error, try `rustc --explain E0046`.
    

这里发生了什么?显然,编译器在我们的代码中找到了错误。Rust 的一个优点是它提供了有用的编译器消息。你可以看到错误发生的行,我们代码错误的原因,有时甚至建议修复我们的代码。我们知道我们必须为Rot13类型实现super::Encryptable特质。

如果你想看到更多信息,运行前面错误中显示的命令,rustc --explain E0046,编译器将显示关于那个特定错误的更多信息。

  1. 现在,我们可以继续实现我们的Rot13加密。首先,让我们将特质的签名放入我们的实现中:

    impl super::Encryptable for Rot13 {
        fn encrypt(&self) -> String {
        }
    }
    

这种加密的策略是对字符串中的每个字符进行迭代,如果它在'n''N'之前有一个字符,就将其字符值加 13,如果它在'n''N'或其后有字符,就减去 13。Rust 语言默认处理 Unicode 字符串,所以程序应该有一个限制,只能操作拉丁字母。

  1. 在我们的第一次迭代中,我们想要分配一个新的字符串,获取原始String的长度,从零索引开始,应用一个转换,将其推入一个新的字符串,并重复直到结束:

    fn encrypt(&self) -> String {
        let mut new_string = String::new();
        let len = self.0.len();
        for i in 0..len {
            if (self.0[i] >= 'a' && self.0[i] < 'n') || 
            (self.0[i] >= 'A' && self.0[i] < 'N') {
                new_string.push((self.0[i] as u8 + 13) as 
                char);
            } else if (self.0[i] >= 'n' && self.0[i] < 
            'z') || (self.0[i] >= 'N' && self.0[i] < 'Z') 
            {
                new_string.push((self.0[i] as u8 - 13) as 
                char);
            } else {
                new_string.push(self.0[i]);
            }
        } 
        new_string
    }
    
  2. 让我们尝试编译那个程序。你很快会发现它不起作用,所有的错误都是String不能通过usize索引。记住 Rust 默认处理 Unicode 吗?索引一个字符串将会产生各种复杂情况,因为 Unicode 字符有不同的尺寸:有些是 1 字节,但有些可以是 2、3 或 4 字节。关于索引,我们究竟在说什么?索引是指String中的字节位置、grapheme 还是 Unicode 标量值?

在 Rust 语言中,我们有原始类型,如 u8charfnstr 以及更多。除了这些原始类型之外,Rust 还在标准库中定义了许多模块,例如 stringioosfmtthread。这些模块包含了编程的许多构建块。例如,std::string::String 结构体处理 String。重要的编程概念,如比较和迭代,也定义在这些模块中,例如,std::cmp::Eq 用于比较类型的实例。Rust 语言还有 std::iter::Iterator 使类型可迭代。幸运的是,对于 String,我们已经有了一个进行迭代的方法。

  1. 让我们稍微修改一下我们的代码:

    fn encrypt(&self) -> String {
        let mut new_string = String::new();
        for ch in self.0.chars() {
            if (ch >= 'a' && ch < 'n') || (ch >= 'A' &&
            ch < 'N') {
                new_string.push((ch as u8 + 13) as char);
            } else if (ch >= 'n' && ch < 'z') || (ch >= 
            'N' && ch < 'Z') {
                new_string.push((ch as u8 - 13) as char);
            } else {
                new_string.push(ch);
            }
        }
        new_string
    }
    
  2. 有两种返回方式;第一种是使用 return 关键字,如 return new_string;,或者我们可以在函数的最后一行不写分号。你会看到第二种形式更常见。

  3. 上述代码运行正常,但我们可以让它更加符合 Rust 的风格。首先,让我们在不使用 for 循环的情况下处理迭代器。让我们移除新的字符串初始化,并使用 map() 方法。任何实现了 std::iter::Iterator 的类型都将有一个接受闭包作为参数并返回 std::iter::Mapmap() 方法。然后我们可以使用 collect() 方法将闭包的结果收集到它自己的 String 中:

    fn encrypt(&self) -> Result<String, Box<dyn Error>> {
        self.0
            .chars()
            .map(|ch| {
                if (ch >= 'a' && ch < 'n') || (ch >= 'A' 
                && ch < 'N') {
                    (ch as u8 + 13) as char
                } else if (ch >= 'n' && ch < 'z') || (
                ch >= 'N' && ch < 'Z') {
                    (ch as u8 - 13) as char
                } else {
                    ch
                }
            })
            .collect()
    }
    

map() 方法接受形式为 |x|... 的闭包。然后我们使用从 chars() 获取的捕获的单独项进行处理。

如果你看看闭包,你会发现我们也没有使用 return 关键字。如果我们不在分支中放置分号,并且它是最后一项,它将被视为返回值。

使用 if 块是好的,但我们可以让它更加符合 Rust 的风格。Rust 语言的一个优点是强大的 match 控制流。

  1. 让我们再次更改代码:

    fn encrypt(&self) -> String {
        self.0
            .chars()
            .map(|ch| match ch {
                'a'..='m' | 'A'..='M' => (ch as u8 + 13) 
                as char,
                'n'..='z' | 'N'..='Z' => (ch as u8 - 13) 
                as char,
                _ => ch,
            })
            .collect()
    }
    

这看起来更干净。管道 (|) 操作符是一个分隔符,用于匹配臂中的项。Rust 匹配器是详尽的,这意味着编译器会检查匹配器是否包含所有可能的匹配值。在这种情况下,这意味着 Unicode 中的所有字符。尝试移除最后一个臂并编译它,看看如果不包含集合中的项会发生什么。

你可以使用 ....= 来定义一个范围。前者表示我们排除了最后一个元素,而后者表示我们包含了最后一个元素。

  1. 现在我们已经实现了我们的简单加密器,让我们在主应用程序中使用它:

    fn main() {
        ...
        io::stdin()
        .read_line(&mut user_input)
        .expect("Cannot read input");
        println!(
            "Your encrypted string: {}",
            encryptor::rot13::Rot13(user_input).encrypt()
        );
    }
    

目前,当我们尝试编译它时,编译器会显示一个错误。基本上,编译器是在说,如果特性行不在作用域内,就不能使用特性行为,编译器提供的帮助信息显示了我们需要做什么。

  1. 将以下行放在 main() 函数上方,编译器应该生成一个没有错误的二进制文件:

    use encryptor::Encryptable;
    
  2. 让我们尝试运行可执行文件:

    > ./main
    Input the string you want to encrypt:
    asdf123
    Your encrypted string: nfqs123
    > ./main
    Input the string you want to encrypt:
    nfqs123
    Your encrypted string: asdf123
    

我们已经完成了我们的程序,并使用现实世界的加密对其进行了改进。在下一节中,我们将学习如何搜索和使用第三方库,并将它们集成到我们的应用程序中。

包和 Cargo

现在我们已经知道如何在 Rust 中创建一个简单的程序,让我们探索 Cargo,Rust 的包管理器。Cargo 是一个 命令行应用程序,用于管理您的应用程序依赖项并编译您的代码。

Rust 在 crates.io 有一个社区包注册处。您可以使用该网站搜索您可以在应用程序中使用的库。别忘了检查您想要使用的库或应用程序的许可证。如果您在该网站上注册,您可以使用 Cargo 公开分发您的库或二进制文件。

我们如何将 Cargo 安装到我们的系统中?好消息是,如果您使用 rustup 在稳定通道中安装 Rust 工具链,Cargo 已经安装好了。

Cargo 包布局

让我们在我们的应用程序中尝试使用 Cargo。首先,让我们复制我们之前编写的应用程序:

cp -r 02ComplexProgram  03Packages
cd 03Packages
cargo init . --name our_package

由于我们已经有了一个现有的应用程序,我们可以使用 cargo init 初始化我们的现有应用程序。注意我们添加了 --name 选项,因为我们正在将数字作为文件夹名称的前缀,而 Rust 包名称不能以数字开头。

如果我们正在创建一个新的应用程序,我们可以使用 cargo new package_name 命令。要创建一个仅包含库的包而不是二进制包,您可以将 --lib 选项传递给 cargo new

您将看到文件夹内有两个新文件,Cargo.tomlCargo.lock.toml 文件是一种常用作配置文件的文件格式。lock 文件是由 Cargo 自动生成的,我们通常不会手动更改其内容。通常也会将 Cargo.lock 添加到您的源代码版本控制应用程序的忽略列表中,例如 .gitignore

让我们检查 Cargo.toml 文件的内容:

[package]
name = "our_package"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at
https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
[[bin]]
name = "our_package"
path = "main.rs"

如您所见,我们可以为我们的应用程序定义基本内容,例如 nameversion。我们还可以添加重要信息,如作者、主页、仓库等。我们还可以添加在 Cargo 应用程序中想要使用的依赖项。

突出的一个特点是版本配置。Rust 版本是一个可选的标记,用于将具有相同兼容性的各种 Rust 语言版本分组。当 Rust 1.0 发布时,编译器没有能力知道 asyncawait 关键字。在添加了 asyncawait 之后,它为旧编译器带来了各种问题。解决这个问题的方法是引入 Rust 版本。已经定义了三个版本:2015、2018 和 2021。

目前,Rust 编译器可以完美地编译我们的包,但它并不非常符合惯例,因为 Cargo 项目在文件和文件夹名称及结构上有约定。让我们稍微改变一下文件和目录结构:

  1. 预期一个包位于src目录中。让我们将Cargo.toml文件中的[[bin]]路径从"main.rs"更改为"src/main.rs"

  2. 在我们的应用程序文件夹内创建src目录。然后,将main.rs文件和encryptor文件夹移动到src文件夹。

  3. Cargo.toml中的[[bin]]之后添加这些行:

    [lib]
    name = "our_package"
    path = "src/lib.rs"
    
  4. 让我们创建src/lib.rs文件,并将此行从src/main.rs移动到src/lib.rs

    pub mod encryptor;
    
  5. 然后,我们可以在main.rs文件中简化使用rot13Encryptable模块:

    use our_package::encryptor::{rot13, Encryptable};
    use std::io;
    fn main() {
        ...
        println!(
            "Your encrypted string: {}",
            rot13::Rot13(user_input).encrypt()
        );
    }
    
  6. 我们可以通过在命令行中输入cargo check来检查是否有错误阻止代码编译。它应该产生类似以下的内容:

    > cargo check
    Checking our_package v0.1.0 
        (/Users/karuna/Chapter01/03Packages)
    Finished dev [unoptimized + debuginfo] target(s) 
        in 1.01s
    
  7. 之后,我们可以使用cargo build命令构建二进制文件。由于我们没有在命令中指定任何选项,默认的二进制文件应该是未优化的,并包含调试符号。生成的二进制文件的默认位置是在工作区根目录下的target文件夹:

    $ cargo build
    Compiling our_package v0.1.0 
       (/Users/karuna/Chapter01/03Packages)
    Finished dev [unoptimized + debuginfo] target(s) 
        in 5.09s
    

然后,你可以按照以下方式在target文件夹中运行二进制文件:

./target/debug/our_package

debug默认由开发配置启用,our_package是我们指定在Cargo.toml中的名称。

如果你想要创建一个发布版本的二进制文件,你可以指定--release选项,使用cargo build --release。你可以在./target/release/our_package中找到发布版本的二进制文件。

你也可以输入cargo run,这将为你编译和运行应用程序。

现在我们已经安排好了应用程序结构,让我们通过使用第三方 crate 为我们的应用程序添加实际的加密功能。

使用第三方 crate

在我们使用第三方模块实现另一个加密器之前,让我们稍微修改一下我们的应用程序。将之前的03Packages文件夹复制到新的文件夹04Crates中,并使用该文件夹进行以下步骤:

  1. 我们将重命名我们的 Encryptor 特质为 Cipher 特质并修改函数。原因是我们只需要考虑类型的输出,而不是加密过程本身:

    • 让我们将src/lib.rs的内容更改为pub mod cipher;

    • 之后,将encryptor文件夹重命名为cipher

    • 然后,将 Encryptable 特质修改为以下内容:

      pub trait Cipher {
          fn original_string(&self) -> String;
          fn encrypted_string(&self) -> String;
      }
      

事实上,我们只需要函数来显示原始字符串和加密字符串。我们不需要在类型本身暴露加密。

  1. 之后,让我们也将src/cipher/rot13.rs修改为使用重命名的特质:

    impl super::Cipher for Rot13 {
        fn original_string(&self) -> String {
            String::from(&self.0)
        }
        fn encrypted_string(&self) -> String {
            self.0
                .chars()
                .map(|ch| match ch {
                    'a'..='m' | 'A'..='M' => (ch as u8 + 
                    13) as char,
                    'n'..='z' | 'N'..='Z' => (ch as u8 – 
                    13) as char,
                    _ => ch,
                })
                .collect()
        }
    }
    
  2. 让我们也修改main.rs以使用新的特性和函数:

    use our_package::cipher::{rot13, Cipher};
    …
    fn main() {
        …
        println!(
            "Your encrypted string: {}",
            rot13::Rot13(user_input).encrypted_string()
        );
    }
    

下一步是确定我们想要为我们新的类型使用什么加密和库。我们可以访问crates.io并搜索可用的包。在网站上搜索实际的加密算法后,我们找到了crates.io/crates/rsa。我们发现 RSA 算法是一个安全的算法,该包有良好的文档,并且已经由安全研究人员审核过,许可证与我们的需求兼容,并且下载量巨大。除了检查这个库的源代码外,所有迹象都表明这是一个很好的包来使用。幸运的是,在那个页面的右侧有一个安装部分。除了rsa包外,我们还将使用rand包,因为 RSA 算法需要一个随机数生成器。由于生成的加密是字节形式,我们必须以某种方式将其编码为string。常见的方法之一是使用base64

  1. 在我们的Cargo.toml文件中,在[dependencies]部分添加以下行:

    rsa = "0.5.0"
    rand = "0.8.4"
    base64 = "0.13.0"
    
  2. 下一步应该是添加一个新的模块并使用rsa包。但是,对于这个类型,我们想稍作修改。首先,我们想要创建一个关联函数,在其他语言中可能被称为构造函数。我们想要在这个函数中加密输入字符串并将加密后的字符串存储在一个字段中。有句话说是所有不在处理中的数据都应该默认加密,但事实是,作为程序员,我们很少这样做。

由于 RSA 加密涉及字节操作,存在错误的可能性,因此关联函数的返回值应该被包裹在Result类型中。虽然没有编译器规则,但如果一个函数不能失败,其返回值应该是直接的。无论函数是否可以产生结果,返回值应该是Option,但如果函数可以产生错误,使用Result会更好。

encrypted_string()方法应该返回存储的加密字符串,而original_string()方法应该解密存储的字符串并返回纯文本。

src/cipher/mod.rs中,将代码更改为以下内容:

pub trait Cipher {
    fn original_string(&self) -> Result<String, 
    Box<dyn Error>>;
    fn encrypted_string(&self) -> Result<String, 
    Box<dyn Error>>;
}
  1. 由于我们改变了特性的定义,我们不得不将src/cipher/rot13.rs中的代码也进行更改。将代码更改为以下内容:

    use std::error::Error;
    pub struct Rot13(pub String);
    impl super::Cipher for Rot13 {
        fn original_string(&self) -> Result<String, 
        Box<dyn Error>> {
            Ok(String::from(&self.0))
        }
    fn encrypted_string(&self) -> Result<String, 
        Box<dyn Error>> {
            Ok(self
                .0
                ...
                .collect())
        }
    }
    
  2. 让我们在src/cipher/mod.rs文件中添加以下行:

    pub mod rsa;
    
  3. 之后,在cipher文件夹内创建rsa.rs文件,并在其中创建Rsa结构体。请注意,我们使用Rsa而不是RSA作为类型名。按照惯例,类型名应使用CamelCase格式:

    use std::error::Error;
    pub struct Rsa {
        data: String,
    }
    impl Rsa {
        pub fn new(input: String) -> Result<Self, Box<
        dyn Error>> {
            unimplemented!();
        }
    }
    impl super::Cipher for Rsa {
        fn original_string(&self) -> Result<String, ()> {
           unimplemented!();
        }
        fn encrypted_string(&self) -> Result<String, ()> {
            Ok(String::from(&self.data))
        }
    }
    

我们可以观察到一些事情。首先,data字段没有pub关键字,因为我们想将其设置为私有。你可以看到我们有两个impl块:一个是用于定义Rsa类型的自身方法,另一个是用于实现Cipher特质。

此外,new()函数没有selfmut self&self&mut self作为第一个参数。在其他语言中,将其视为一个静态方法。此方法返回Result,要么是Ok(Self),要么是Box<dyn Error>Self实例是Rsa结构体的实例,但我们将稍后讨论Box<dyn Error>,当我们在第七章中讨论 Rust 和 Rocket 的错误处理时。现在,我们还没有实现此方法,因此使用了unimplemented!()宏。Rust 中的宏看起来像函数,但有一个额外的感叹号(!)。

  1. 现在,让我们实现关联函数。修改src/cipher/rsa.rs

    use rand::rngs::OsRng;
    use rsa::{PaddingScheme, PublicKey, RsaPrivateKey};
    use std::error::Error;
    const KEY_SIZE: usize = 2048;
    pub struct Rsa {
        data: String,
        private_key: RsaPrivateKey,
    }
    impl Rsa {
         pub fn new(input: String) -> Result<Self, Box<
        dyn Error>> {
            let mut rng = OsRng;
            let private_key = RsaPrivateKey::new(&mut rng, 
            KEY_SIZE)?;
            let public_key = private_key.to_public_key();
            let input_bytes = input.as_bytes();
            let encrypted_data =
                public_key.encrypt(&mut rng, PaddingScheme
                ::new_pkcs1v15_encrypt(), input_bytes)?;
            let encoded_data = 
            base64::encode(encrypted_data);
            Ok(Self {
                data: encoded_data,
                private_key,
            })
        }
    }
    

我们首先声明我们将要使用的各种类型。之后,我们定义一个常量来表示我们将使用什么大小的密钥。

如果你理解 RSA 算法,你已经知道它是一个非对称算法,这意味着我们有两个密钥:一个公钥和一个私钥。我们使用公钥加密数据,使用私钥解密数据。我们可以生成公钥并将其提供给另一方,但我们不希望将私钥提供给另一方。这意味着我们必须将私钥存储在结构体内部。

new()的实现相当直接。我们首先声明一个随机数生成器,rng。然后,我们生成 RSA 私钥。但请注意私钥初始化时的问号运算符(?)。如果一个函数返回Result,我们可以在调用其内部任何方法或函数后快速返回由该函数生成的错误,只需在该函数后使用(?)即可。

然后,我们从私钥生成 RSA 公钥,将输入字符串编码为字节,并加密数据。由于加密数据可能会导致错误,我们再次使用问号运算符。然后,我们将加密的字节编码为base64字符串,并初始化Self,这意味着Rsa结构体本身。

  1. 现在,让我们实现original_string()方法。我们应该做与我们创建结构体时相反的操作:

    fn original_string(&self) -> Result<String, Box<dyn Error>> {
        let decoded_data = base64::decode(&self.data)?;
        let decrypted_data = self
            .private_key
            .decrypt(PaddingScheme::
            new_pkcs1v15_encrypt(), &decoded_data)?;
        Ok(String::from_utf8(decrypted_data)?)
    }
    

首先,我们解码data字段中的base64编码字符串。然后,我们解密解码的字节并将它们转换回字符串。

  1. 现在我们已经完成了Rsa类型,让我们在main.rs文件中使用它:

    fn main() {
        ...
        println!(
            "Your encrypted string: {}",
            rot13::Rot13(user_input).encrypted_
            string().unwrap()
        );
        println!("Input the string you want to encrypt:");
        let mut user_input = String::new();
        io::stdin()
            .read_line(&mut user_input)
            .expect("Cannot read input");
        let encrypted_input = rsa::Rsa::new(
        user_input).expect("");
        let encrypted_string = encrypted_input.encrypted_
        string().expect("");
        println!("Your encrypted string: {}", 
        encrypted_string);
        let decrypted_string = encrypted_input
        .original_string().expect("");
        println!("Your original string: {}", 
        decrypted_string);
    }
    

一些读者可能会想知道为什么我们重新声明了user_input变量。简单的解释是 Rust 已经将资源移动到了新的Rot13类型,而 Rust 不允许重复使用已移动的值。你可以尝试注释掉第二个变量声明并编译应用程序以查看解释。我们将在第九章中更详细地讨论 Rust 的借用检查器和移动。

现在,尝试通过输入cargo run来运行程序:

$ cargo run
   Compiling cfg-if v1.0.0
   Compiling subtle v2.4.1
   Compiling const-oid v0.6.0
   Compiling ppv-lite86 v0.2.10
   ...
Compiling our_package v0.1.0 
   (/Users/karuna//Chapter01/04Crates)
Finished dev [unoptimized + debuginfo] target(s) 
    in 3.17s
     Running `target/debug/our_package`
Input the string you want to encrypt:
first
Your encrypted string: svefg
Input the string you want to encrypt:
second
Your encrypted string: lhhb9RvG9zI75U2VC3FxvfUujw0cVqqZFgPXhNixQTF7RoVBEJh2inn7sEefDB7eNlQcf09lD2nULfgc2mK55ZE+UUcYzbMDu45oTaPiDPog4L6FRVpbQR27bkOj9Bq1KS+QAvRtxtTbTa1L5/OigZbqBc2QOm2yHLCimMPeZKhLBtK2whhtzIDM8l5AYTBg+rA688ZfB7ZI4FSRm4/h22kNzSPo1DECI04ZBprAq4hWHxEKRwtn5TkRLhClGFLSYKkY7Ajjr3EOf4QfkUvFFhZ0qRDndPI5c9RecavofVLxECrYfv5ygYRmW3B1cJn4vcBhVKfQF0JQ+vs+FuTUpw==
Your original string: second

你会发现 Cargo 会自动下载依赖并逐个构建它们。此外,你可能注意到使用Rsa类型加密花费了一些时间。Rust 不是应该是一个快速的系统语言吗?RSA 算法本身就是一个慢速算法,但这并不是速度慢的真正原因。因为我们是在开发配置下运行程序,Rust 编译器生成一个包含所有调试信息的应用程序二进制文件,并且不对生成的二进制文件进行优化。另一方面,如果你使用--release标志构建应用程序,编译器会生成一个优化的应用程序二进制文件并删除调试符号。使用发布标志编译的结果二进制文件应该比调试二进制文件执行得更快。试着亲自做一下,这样你会记住如何构建发布二进制文件。

在本节中,我们学习了 Cargo 和第三方包,所以接下来,让我们了解一下如何找到我们使用的工具的帮助和文档。

工具和获取帮助

现在我们已经创建了一个相当简单的应用程序,你可能想知道我们可以使用哪些工具进行开发,以及如何了解更多关于 Rust 和获取帮助。

工具

除了 Cargo,我们还可以使用一些其他工具来开发 Rust 应用程序:

  • rustfmt

这个程序用于格式化你的源代码,使其遵循 Rust 风格指南。你可以通过使用rustuprustup component add rustfmt)来安装它。然后,你可以将其集成到你的 favorite text editor 或者在命令行中使用它。你可以在github.com/rust-lang/rustfmt了解更多关于rustfmt的信息。

  • clippy

这个名字让你想起了什么吗?clippy通过使用各种 lint 规则对你的 Cargo 应用程序进行 linting 非常有用。目前,你可以使用超过 450 个 lint 规则。你可以使用以下命令安装它:rustup component add clippy。之后,你可以通过运行cargo clippy在 Cargo 应用程序中使用它。你能在我们之前写的 Cargo 应用程序中尝试一下吗?你可以在github.com/rust-lang/rust-clippy了解更多关于clippy的信息。

文本编辑器

很可能,你选择的文本编辑器已经支持 Rust 语言,或者至少支持 Rust 的语法高亮。如果你想添加诸如跳转到定义、跳转到实现、符号搜索和代码补全等重要功能,你可以安装 Rust 语言服务器。大多数流行的文本编辑器已经支持语言服务器,所以你只需在你的文本编辑器中安装扩展或其他集成方法即可:

  • Rust 语言服务器

你可以使用rustup命令安装它:rustup component add rls rust-analysis rust-src。然后,你可以将其集成到你的文本编辑器中。例如,如果你正在使用rls

你可以在github.com/rust-lang/rls了解更多关于它的信息。

  • Rust analyzer

这个应用程序有望成为 Rust 语言服务器 2.0。截至本书编写时,它仍被视为处于 alpha 阶段,但根据我的经验,这个应用程序与常规更新配合得很好。你可以在github.com/rust-analyzer/rust-analyzer/releases找到这个应用程序的可执行文件,然后配置你的编辑器语言服务器以使用此应用程序。你可以在rust-analyzer.github.io了解更多关于它的信息。

获取帮助和文档

有几份重要的文档,你可能想要阅读以获取帮助或参考:

  • 《Rust 编程语言书籍》:如果你想了解更多关于 Rust 编程语言的信息,这本书是你想要阅读的。你可以在doc.rust-lang.org/book/在线找到它。

  • Rust 示例:这份文档是关于 Rust 语言及其标准库功能概念的示例集合。你可以在doc.rust-lang.org/rust-by-example/index.html在线阅读它。

  • 标准库文档:作为一名程序员,你将参考这份标准库文档。你可以了解更多关于标准库、它们的模块、函数签名、标准库函数的功能、阅读示例等内容。你可以在doc.rust-lang.org/std/index.html找到它。

  • Cargo.toml清单格式,你可以在doc.rust-lang.org/cargo/index.html了解更多关于它的信息。

  • Rust 风格指南:Rust 语言,像其他编程语言一样,有风格指南。这些指南告诉程序员命名约定是什么,关于空白,如何使用常量,以及 Rust 程序的其它惯用约定。你可以在doc.rust-lang.org/1.0.0/style/了解更多关于它的信息。

  • 我们之前使用的rsa包。要找到该库的文档,你可以访问crates.io,搜索该包的页面,然后转到右侧面板并进入文档部分。或者,你可以访问docs.rs,搜索包名并找到它的文档。

  • rustuprustup component add rust-docs)。然后,你可以使用rustup doc命令在离线状态下打开文档。如果你想离线打开标准库文档,你可以输入rustup doc --std。还有其他可以打开的文档;尝试使用rustup doc --help来查看它们是什么。

  • Rust 用户论坛:如果你想得到帮助或帮助其他 Rust 程序员,你可以在互联网上找到所有这些。在users.rust-lang.org/有一个专门的论坛来讨论与 Rust 相关的话题。

摘要

在本章中,我们对 Rust 语言进行了简要概述。我们学习了 Rust 工具链及其安装方法,以及 Rust 开发所需的工具。之后,我们创建了两个简单的程序,使用了 Cargo,并导入第三方模块以改进我们的程序。现在,你已经可以用 Rust 语言编写小程序了,去探索吧!尝试创建更多程序或对语言进行实验。你可以尝试使用 Rust by Example 来查看我们可以在程序中使用哪些特性。在随后的章节中,我们将学习更多关于 Rocket 的内容,这是一个用 Rust 语言编写的网络框架。

第二章:第二章: 构建我们的第一个 Rocket Web 应用程序

在本章中,我们将探讨 Rocket,这是一个使用 Rust 编程语言创建 Web 应用程序的框架。在我们使用 Rocket 框架创建第一个 Web 应用程序之前,我们将对 Rocket 有一些了解。之后,我们将学习如何配置我们的 Rocket Web 应用程序。最后,在本章末尾,我们将探讨如何获取这个 Web 框架的帮助。

在本章中,我们将涵盖以下主要主题:

  • 介绍 Rocket – 使用 Rust 语言编写的 Web 框架

  • 创建我们的第一个 Rocket Web 应用程序

  • 配置我们的 Rocket Web 应用程序

  • 获取帮助

技术要求

对于本章和随后的章节,你需要安装在 第一章 中提到的要求,即 介绍 Rust 语言 和 Rust 工具链。如果你还没有安装 Rust 编译器工具链,请遵循 第一章 中提到的安装指南,即 介绍 Rust 语言。此外,安装一个带有 Rust 扩展的文本编辑器以及 Rust 工具如 rustfmtclippy 会很有帮助。如果你还没有安装文本编辑器,可以使用带有 rust-analyzer 扩展的 Visual Studio Code 等开源软件。由于我们将向我们要创建的应用程序发出 HTTP 请求,你应该安装一个网络浏览器或其他 HTTP 客户端。

最后,Rocket 框架有几个版本发布,它们都略有不同。我们只会讨论 Rocket 0.5.0 版本。如果你计划使用 Rocket 的其他版本,请不要担心,因为术语和概念几乎相同。你可以使用本章 获取帮助 部分中提到的 API 文档来查看你使用的 Rocket 框架版本的正确文档。

本章的代码可以在 github.com/PacktPublishing/Rust-Web-Development-with-Rocket/tree/main/Chapter02 找到。

介绍 Rocket – 使用 Rust 语言编写的 Web 框架

Rocket Web 框架的开发始于 2016 年 Sergio Benitez 的一个项目。长期以来,它一直使用大量的 Rust 宏系统来简化开发过程;因此,直到最近 2021 年,才可以使用稳定的 Rust 编译器。在开发过程中,Rust 添加了 async/await 功能。Rocket 开始整合 async/await,直到 2021 年关闭了相关的问题跟踪器。

Rocket 是一个相当简单的 Web 框架,没有很多花哨的功能,例如数据库 对象关系映射(ORM)或邮件系统。程序员可以使用其他 Rust 包扩展 Rocket 的功能,例如,通过添加第三方日志记录或连接到内存存储应用程序。

Rocket 中的 HTTP 请求生命周期

处理 HTTP 请求是网络应用的一个基本组成部分。Rocket 网络框架将传入的 HTTP 请求视为一个生命周期。网络应用首先要做的是检查并确定哪个或哪些函数能够处理传入的请求。这部分称为路由。例如,如果有一个 GET/something 传入请求,Rocket 将检查所有已注册的路由以查找匹配项。

在路由之后,Rocket 将对传入请求进行验证,以检查与第一个函数中声明的类型和守卫是否匹配。如果结果不匹配且下一个处理传入请求的函数可用,Rocket 将继续对下一个函数进行验证,直到没有更多函数可以处理该传入请求。

验证后,Rocket 将根据程序员在函数体中编写的代码进行处理。例如,程序员使用请求中的数据创建一个 SQL 查询,将查询发送到数据库,从数据库检索结果,并使用结果创建 HTML。

Rocket 最后将返回一个响应,其中包含 HTTP 状态、头和主体。请求生命周期随后完成。

总结一下,Rocket 请求的生命周期是路由 → 验证 → 处理 → 响应。接下来,让我们讨论 Rocket 应用程序的启动方式。

Rocket 发射序列

就像现实生活中的火箭一样,我们首先构建 Rocket 应用程序。在构建过程中,我们将路由(处理传入请求的函数)挂载到 Rocket 应用程序上。

在构建过程中,Rocket 应用程序还管理各种状态。状态是一个可以在路由处理程序中访问的 Rust 对象。例如,假设我们想要一个将事件发送到日志服务器的记录器。我们在构建 Rocket 应用程序时初始化记录器对象,当有传入请求时,我们可以在请求处理程序中使用已管理的记录器对象。

在构建过程中,我们还可以附加整流罩。在许多网络框架中,通常有一个中间件组件,用于过滤、检查、授权或修改传入的 HTTP 请求或响应。在 Rocket 框架中,提供中间件功能的函数被称为整流罩。例如,如果我们想为每个 HTTP 请求创建一个用于审计的通用唯一标识符UUID),我们首先创建一个生成随机 UUID 并将其附加到请求 HTTP 头部的整流罩。我们还将整流罩附加到生成的 HTTP 响应中。接下来,我们将它附加到 Rocket 上。这个整流罩将拦截传入的请求和响应并进行修改。

在 Rocket 应用程序构建并准备就绪后,下一步当然是启动它。太好了,发射成功!现在,我们的 Rocket 应用程序已处于运行状态,准备好处理传入的请求。

现在我们对 Rocket 应用程序有了总体了解,让我们尝试创建一个简单的 Rocket 应用程序。

创建我们的第一个 Rocket Web 应用程序

在本节中,我们将创建一个非常简单的 Web 应用程序,它只处理一个 HTTP 路径。按照以下步骤创建我们的第一个 Rocket Web 应用程序:

  1. 使用 Cargo 创建一个新的 Rust 应用程序:

    cargo new 01hello_rocket --name hello_rocket
    

我们在一个名为 01hello_rocket 的文件夹中创建了一个名为 hello_rocket 的应用程序。

  1. 之后,让我们修改 Cargo.toml 文件。在 [dependencies] 之后添加以下行:

    rocket = "0.5.0-rc.1"
    
  2. src/main.rs 文件顶部追加以下行:

    #[macro_use]
    extern crate rocket;
    

这里,我们正在告诉 Rust 编译器通过使用 #[macro_use] 属性来使用 Rocket crate 中的宏。我们可以省略使用该属性,但那样就意味着我们必须为将要使用的每个宏指定 use

  1. 添加以下行以告知编译器我们正在使用来自 Rocket crate 的定义:

    use rocket::{Build, Rocket};
    
  2. 之后,让我们创建我们的第一个 HTTP 处理器。在前面几行之后添加以下行:

    #[get("/")]
    fn index() -> &'static str {
        "Hello, Rocket!"
    }
    

这里,我们定义了一个返回 str 引用的函数。Rust 语言中的 'a 表示一个变量具有 'a 生命周期。引用的生存期取决于许多因素。我们将在 第九章 显示用户帖子 中讨论这些内容,当我们更深入地讨论对象作用域和生命周期时。但是,'static 符号是特殊的,因为它意味着它将持续到应用程序仍然存活。我们还可以看到返回值是 "Hello, Rocket",因为它是在最后一行,我们没有在末尾放置分号。

但是,#[get("/")] 属性是什么?记得之前我们使用过 #[macro_use] 属性吗?rocket::get 属性是一个宏属性,它指定了函数处理的 HTTP 方法、路由、HTTP 路径和参数。我们可以使用七个特定方法的路由属性:getputpostdeleteheadoptionspatch。所有这些都与它们各自的 HTTP 方法名称相对应。

我们还可以使用替代宏来指定路由处理器,通过用以下内容替换属性宏:

#[route(GET, path = "/")]
  1. 接下来,删除 fn main() 函数并添加以下行:

    #[launch]
    fn rocket() -> Rocket<Build> {
        rocket::build().mount("/", routes![index])
    }
    

我们创建了一个函数来生成 main 函数,因为我们使用了 #[launch] 属性。在函数内部,我们构建了 Rocket 并将具有 index 函数的路由挂载到 "/" 路径上。

  1. 让我们尝试运行 hello_rocket 应用程序:

    > cargo run
       Updating crates.io index
       Compiling proc-macro2 v1.0.28
    ...
       Compiling hello_rocket v0.1.0 (/Users/karuna/
       Chapter02/01hello_rocket)
    Finished dev [unoptimized + debuginfo] target(s) 
        in 1m 39s
         Running `target/debug/hello_rocket`
    ![](https://github.com/OpenDocCN/freelearn-rust-zh/raw/master/docs/rs-webdev-rkt/img/01.png) Configured for debug.
       >> address: 127.0.0.1
       >> port: 8000
       >> workers: 8
       >> ident: Rocket
       >> keep-alive: 5s
    >> limits: bytes = 8KiB, data-form = 2MiB, file = 
    1MiB, form = 32KiB, json = 1MiB, msgpack = 1MiB, 
       string = 8KiB
       >> tls: disabled
       >> temp dir: /var/folders/gh/
       kgsn28fn3hvflpcfq70x6f1w0000gp/T/
       >> log level: normal
       >> cli colors: true
    >> shutdown: ctrlc = true, force = true, signals = 
       [SIGTERM], grace = 2s, mercy = 3s
    ![](https://github.com/OpenDocCN/freelearn-rust-zh/raw/master/docs/rs-webdev-rkt/img/04.png)  Routes:
       >> (index) GET /
    ![](https://github.com/OpenDocCN/freelearn-rust-zh/raw/master/docs/rs-webdev-rkt/img/02.png) Fairings:
       >> Shield (liftoff, response, singleton)
    ![](https://github.com/OpenDocCN/freelearn-rust-zh/raw/master/docs/rs-webdev-rkt/img/05.png) Shield:
       >> X-Content-Type-Options: nosniff
       >> X-Frame-Options: SAMEORIGIN
       >> Permissions-Policy: interest-cohort=()
    ![](https://github.com/OpenDocCN/freelearn-rust-zh/raw/master/docs/rs-webdev-rkt/img/03.png) Rocket has launched from http://127.0.0.1:8000
    

您可以看到应用程序打印了应用程序配置,例如保持活动超时时间、请求大小限制、日志级别、路由等,这些通常在 HTTP 服务器中看到,到终端。之后,应用程序打印了 Rocket 的各个部分。我们创建了一个单路由 index 函数,它处理 GET /

然后,还有默认的内置公平性(Fairing),Shield。Shield 通过默认向所有响应注入 HTTP 安全性和隐私头来实现。

我们还看到应用程序已成功启动,现在正在127.0.0.1地址和8000端口上接受请求。

  1. 现在,让我们测试应用程序是否真的在接收请求。您可以使用 Web 浏览器或任何 HTTP 客户端,因为这是一个非常简单的请求,但如果您使用命令行,请不要停止运行的应用程序;打开另一个终端:

    > curl http://127.0.0.1:8000
    Hello, Rocket!
    

您可以看到应用程序响应得非常完美。您还可以看到应用程序的日志:

GET /:
   >> Matched: (index) GET /
   >> Outcome: Success
   >> Response succeeded.
  1. 现在,让我们尝试请求我们应用程序中不存在的东西:

    > curl http://127.0.0.1:8000/somepath
    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="utf-8">
        <title>404 Not Found</title>
    </head>
    <body align="center">
        …
    </body>
    </html>
    

现在,让我们看看应用程序终端输出中发生了什么:

GET /somepath:
   >> No matching routes for GET /somepath.
   >> No 404 catcher registered. Using Rocket default.
   >> Response succeeded.

我们现在知道 Rocket 已经为404状态情况提供了默认的处理程序。

让我们回顾一下 Rocket 的生命周期,路由 → 验证 → 处理 → 响应。对 http://127.0.0.1:8000/的第一次请求是成功的,因为应用程序找到了GET /路由的处理程序。由于我们没有在应用程序中创建任何验证,该函数随后执行了一些非常简单的处理,返回了一个字符串。Rocket 框架已经为&str实现了Responder特质,因此它创建并返回了适当的 HTTP 响应。对/somepath的另一个请求没有通过路由部分,我们没有创建任何错误处理程序,因此 Rocket 应用程序为这个请求返回了一个默认的错误处理程序。

尝试在浏览器中打开它,并使用开发者工具检查响应,或者再次以详细模式运行curl命令以查看完整的 HTTP 响应,curl -v http://127.0.0.1:8000/curl -v http://127.0.0.1:8000/somepath

$ curl -v http://127.0.0.1:8000/somepath
*   Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to 127.0.0.1 (127.0.0.1) port 8000 (#0)
> GET /somepath HTTP/1.1
> Host: 127.0.0.1:8000
> User-Agent: curl/7.64.1
> Accept: */*
> 
< HTTP/1.1 404 Not Found
< content-type: text/html; charset=utf-8
< server: Rocket
< x-frame-options: SAMEORIGIN
< x-content-type-options: nosniff
< permissions-policy: interest-cohort=()
< content-length: 383
< date: Fri, 17 Aug 1945 03:00:00 GMT
< 
<!DOCTYPE html>
...
* Connection #0 to host 127.0.0.1 left intact
</html>* Closing connection 0

您可以看到对/的访问完美无缺,而对/somepath的访问返回了一个带有404状态和某些 HTML 内容的 HTTP 响应。还有一些默认的隐私和安全 HTTP 头,这些是由 Shield fairing 注入的。

恭喜!您刚刚创建了一个由 Rocket 驱动的第一个 Web 应用程序。您刚刚构建的是一个常规的 Rocket Web 应用程序。接下来,让我们将其修改为异步 Web 应用程序。

异步应用程序

什么是异步应用程序?让我们假设我们的 Web 应用程序正在向数据库发送查询。在等待响应的几毫秒内,我们的应用程序线程只是在做无用功。对于单个用户和单个请求来说,这并不是问题。异步应用程序是一种允许处理器在存在阻塞任务(如等待数据库响应)时执行其他任务的应用程序。我们将在稍后详细讨论这个问题;现在,我们只想将我们的应用程序转换为异步应用程序。

让我们修改之前创建的应用程序,使其变为异步。您可以在02hello_rocket_async文件夹中找到示例:

  1. 删除use rocket::{Build, Rocket};这一行,因为我们不会使用它。

  2. 然后,让我们在fn index()之前添加async关键字。

  3. #[launch]属性替换为#[rocket::main]。这是为了表示这个函数将成为我们应用程序中的主函数。

  4. 添加async关键字,并将fn launch()重命名为fn main()

  5. 我们也不希望主函数返回任何内容,所以使用remove -> Rocket<build>

  6. 在调用mount之后添加.launch().await;

最终的代码应该看起来像这样:

#[macro_use]
extern crate rocket;
#[get("/")]
async fn index() -> &'static str {
    "Hello, Rocket!"
}
#[rocket::main]
async fn main() {
    rocket::build().mount("/", routes![
    index]).launch().await;
}

使用Ctrl + C命令停止旧版本在服务器上的运行。你应该会看到类似以下的内容:

Warning: Received SIGINT. Requesting shutdown.
Received shutdown request. Waiting for pending I/O...

我们目前在"/"处理程序中没有任何阻塞任务,所以我们不会看到任何明显的收益。现在我们已经创建了我们的应用程序,让我们在下一节中对其进行配置。

配置我们的 Rocket 网络应用程序

让我们学习如何配置我们的 Rocket 网络应用程序,从为不同情况设置不同的配置文件开始。然后,我们将使用Rocket.toml文件进行配置。最后,我们将学习如何使用环境变量来配置我们的应用程序。

以不同配置文件启动 Rocket 应用程序

让我们运行我们的同步应用程序服务器,不使用发布标志,然后在另一个终端中,让我们看看我们是否可以对其进行基准测试:

  1. 首先,让我们使用cargo install benchrs安装应用程序。没错,你也可以使用 Cargo 来安装应用程序!在你的终端中,你可以使用非常优秀的 Rust 程序,例如ripgrep,这是在代码中搜索字符串最快的应用程序之一。

如果你想调用 Cargo 安装的应用程序,你可以使用完整路径,或者如果你使用的是基于 Unix 的终端,将其添加到你的终端路径中。将以下行追加到你的~/.profile或任何将被终端加载的其他配置文件中:

export PATH="$HOME/.cargo/bin:$PATH"

如果你使用 Windows,Rustup 应该已经将 Cargo 的bin文件夹添加到你的路径中了。

  1. 对运行中的应用程序进行基准测试:

    benchrs -c 30 -n 3000 -k http://127.0.0.1:8000/
    07:59:04.934 [INFO] benchrs:0.1.8
    07:59:04.934 [INFO] Spawning 8 threads
    07:59:05.699 [INFO] Ran in 0.7199552s 30 connections, 3000 requests with avg request time: 6.5126667ms, median: 6ms, 95th percentile: 11ms and 99th percentile: 14ms
    

你可以看到我们的应用程序在0.7199552秒内处理了大约 3,000 个请求。如果我们与其他重型框架进行比较,这是一个简单的应用程序的不错值。之后,现在暂时停止应用程序。

  1. 现在,让我们再次运行应用程序,但这次是在发布模式下。你还记得如何在前一章中这样做吗?

    > cargo run –release
    

然后,它应该编译我们的应用程序以发布并运行它。

  1. 当应用程序准备好再次接受请求后,在另一个终端中再次运行基准测试:

    $ benchrs -c 30 -n 3000 -k http://127.0.0.1:8000/
    08:12:51.388 [INFO] benchrs:0.1.8
    08:12:51.388 [INFO] Spawning 8 threads
    08:12:51.513 [INFO] Ran in 0.07942524s 30 connections, 3000 requests with avg request time: 0.021333333ms, median: 0ms, 
    5th percentile: 0ms and 99th percentile: 1ms
    

再次,结果非常令人印象深刻,但这里发生了什么?总的基准时间现在大约是 0.08 秒,比之前的总基准时间快了几乎 10 倍。

要了解速度增加的原因,我们需要了解 Rocket 配置文件。配置文件是为一组配置所赋予的名称。

Rocket 应用程序有两个元配置文件:defaultglobal。默认配置文件包含所有默认配置值。如果我们创建一个配置文件而没有设置其配置值,则将使用默认配置的值。至于全局配置文件,如果我们设置了配置值,则它将覆盖配置文件中设置的值。

除了这两个元配置文件之外,Rocket 框架还提供了两个配置。当以发布模式运行或编译应用程序时,Rocket 将使用release配置文件,而在调试模式运行或编译时,Rocket 将选择debug配置文件。以发布模式运行应用程序将显然生成一个优化的可执行二进制文件,但应用程序本身还有其他优化。例如,您将在应用程序输出中看到差异。默认情况下,调试配置文件会在终端上显示输出,但默认情况下,发布配置文件不会在终端输出中显示任何请求。

我们可以为配置文件创建任何名称,例如,developmentteststagingsandboxproduction。使用对您的开发过程有意义的任何名称。例如,在一个用于 QA 测试的机器上,您可能希望将配置文件命名为testing

要选择我们想要使用的配置文件,我们可以在环境变量中指定它。在命令行中使用ROCKET_PROFILE=profile_name cargo run。例如,您可以编写ROCKET_PROFILE=profile_name cargo run –release

现在我们知道了如何以特定配置启动应用程序,让我们学习如何创建配置文件并配置 Rocket 应用程序。

配置 Rocket Web 应用程序

Rocket 有一种配置 Web 应用程序的方法。Web 框架使用Provider特质。有人可以创建一个从文件中读取 JSON 的类型,并为该类型实现Provider特质。该类型然后可以被使用 figment crate 作为配置源的应用程序消费。

在 Rust 中,有一个约定,如果结构实现了标准库特质std::default::Default,则使用默认值初始化结构。该特质被编写如下:

pub trait Default {
    fn default() -> Self;
}

例如,一个名为StructName的结构,实现了Default特质,将被调用为StructName::default()。Rocket 有一个实现Default特质的rocket::Config结构。然后使用默认值来配置应用程序。

如果您查看rocket::Config结构的源代码,它被编写如下:

pub struct Config {
    pub profile: Profile,
    pub address: IpAddr,
    pub port: u16,
    pub workers: usize,
    pub keep_alive: u32,
    pub limits: Limits,
    pub tls: Option<TlsConfig>,
    pub ident: Ident,
    pub secret_key: SecretKey,
    pub temp_dir: PathBuf,
    pub log_level: LogLevel,
    pub shutdown: Shutdown,
    pub cli_colors: bool,
    // some fields omitted
}

如您所见,有诸如addressport之类的字段,这些字段显然将决定应用程序的行为。如果您进一步检查源代码,您可以看到Config结构的Default特质实现。

Rocket 还有一些 figment 提供者,当我们在应用程序中使用rocket::build()方法时,会覆盖默认的rocket::Config值。

第一个片段提供者从 Rocket.toml 文件中读取,或者如果我们使用 ROCKET_CONFIG 环境变量运行应用程序,则是我们指定的文件。如果我们指定 ROCKET_CONFIG(例如,ROCKET_CONFIG=our_config.toml),它将在应用程序根目录中搜索 our_config.toml。如果应用程序找不到该配置文件,则应用程序将在父文件夹中查找,直到达到文件系统的根。如果我们指定绝对路径,例如,ROCKET_CONFIG=/some/directory/our_config.toml,则应用程序将仅在指定位置搜索该文件。

第二个片段提供者从环境变量中读取值。我们将在稍后看到如何做到这一点,但首先,让我们尝试使用 Rocket.toml 文件来配置 Rocket 应用程序。

使用 Rocket.toml 配置 Rocket 应用程序

我们首先需要了解的是可以在配置文件中使用的键列表。这些是我们可以在配置文件中使用的键:

  • address: 应用程序将在该地址提供服务。

  • port: 应用程序将在该端口上提供服务。

  • workers: 应用程序将使用此数量的线程。

  • ident: 如果我们指定 false,则应用程序不会在服务器 HTTP 标头中放置身份;如果我们指定 string,则应用程序将使用它作为服务器 HTTP 标头的身份。

  • keep_alive: 以秒为单位保持连接的超时时间。使用 0 来禁用 keep_alive

  • log_level: 记录的最大级别(关闭/正常/调试/关键)。

  • temp_dir: 存储临时文件的目录路径。

  • cli_colors: 在日志中使用颜色和表情符号,或者不使用。这在禁用发布环境中的铃声和哨声很有用。

  • secret_key: Rocket 应用程序有一个类型来存储应用程序中的私有 cookies。私有 cookies 由此密钥加密。密钥长度为 256 位,您可以使用如 openssl rand -base64 32 这样的工具生成它。由于这是一个重要的密钥,您可能希望将其保存在安全的地方。

  • tls: 使用 tls.keytls.certs 进入您的 TLS(传输层安全性)密钥和证书文件的路径。

  • limits: 此配置是嵌套的,用于限制服务器读取大小。您可以将值写入多字节单位,例如 1 MB(兆字节)或 1 MiB(梅吉字节)。有几个默认选项:

    • limits.form – 32 KiB

    • limits.data-form – 2 MiB

    • limits.file – 1 MiB

    • limits.string – 8 KiB

    • limits.bytes – 8 KiB

    • limits.json – 1 MiB

    • limits.msgpack – 1 MiB

  • shutdown: 如果一个网络应用程序在处理某些内容时突然终止,正在处理的数据可能会意外损坏。例如,假设一个 Rocket 应用程序正在向数据库服务器发送更新数据的途中,但随后进程被突然终止。结果,数据不一致。此选项配置 Rocket 的平稳关闭行为。与 limits 类似,它有几个子配置:

    • shutdown.ctrlc – 应用程序是否忽略 Ctrl + C 键盘按键?

    • shutdown.signals – 触发关闭的 Unix 信号数组。仅在 Unix 或类 Unix 操作系统上有效。

    • shutdown.grace – 在停止服务器之前完成未完成的服务器 I/O 的秒数。

    • shutdown.mercy – 在停止之前完成未完成的连接 I/O 的秒数。

    • shutdown.force – 指定是否要杀死拒绝合作的进程。

现在我们知道了我们可以使用哪些密钥,让我们尝试配置我们的应用程序。记得我们的应用程序运行在哪个端口上吗?假设现在我们想在端口 3000 上运行应用程序。让我们在我们的应用程序根目录中创建一个 Rocket.toml 文件:

[default]
port = 3000

现在,再次尝试运行应用程序:

$ cargo run
...
 Rocket has launched from http://127.0.0.1:3000

你可以看到它在工作;我们正在端口 3000 上运行应用程序。但是,如果我们想为不同的配置文件运行不同的应用程序配置呢?让我们尝试在 Rocket.toml 文件中添加这些行,并以发布模式运行应用程序:

[release]
port = 9999
$ cargo run --release
...
 Rocket has launched from http://127.0.0.1:9999

没错,我们可以为不同的配置文件指定配置。如果我们的选项是嵌套的,我们该怎么办?因为这个文件是一个 .toml 文件,我们可以这样写:

[default.tls]
certs = "/some/directory/cert-chain.pem"
key = "/some/directory/key.pem"

或者,我们可以这样写:

[default]
tls = { certs = "/some/directory/cert-chain.pem", key = "/some/directory/key.pem" }

现在,让我们查看具有默认配置的整个文件:

[default]
address = "127.0.0.1"
port = 8000
workers = 16
keep_alive = 5
ident = "Rocket"
log_level = "normal"
temp_dir = "/tmp"
cli_colors = true
## Please do not use this key, but generate your own with `openssl rand -base64 32`
secret_key = " BCbkLMhRRtYMerGKCcboyD4Mhf6/XefvhW0Wr8Q0s1Q="
[default.limits]
form = "32KiB"
data-form = "2MiB"
file = "1MiB"
string = "8KiB"
bytes = "8KiB"
json = "1MiB"
msgpack = "1MiB"
[default.tls]
certs = "/some/directory/cert-chain.pem
key = "/some/directory/key.pem
[default.shutdown]
ctrlc = true
signals = ["term"]
grace = 5
mercy = 5
force = true

尽管我们可以创建包含完整配置的文件,但使用 Rocket.toml 的最佳实践是依赖默认值,并且只写入我们真正需要覆盖的内容。

用环境变量覆盖配置

在检查 Rocket.toml 后,应用程序再次使用环境变量覆盖 rocket::Config 值。应用程序将检查 ROCKET_* 环境变量的可用性。例如,我们可能定义 ROCKET_IDENT="Merpay"ROCKET_TLS={certs="abc.pem",key="def.pem"}。如果我们正在进行开发并且有多个团队成员,或者我们不希望在配置文件中存在某些内容并依赖于环境变量,例如,当我们在 Kubernetes Secrets 中存储 secret_key 时,这非常有用。在这种情况下,从环境变量中获取 secret 值比将其写入 Rocket.toml 并提交到源代码版本控制系统更安全。

让我们尝试通过运行应用程序并设置 ROCKET_PORT=4000 来覆盖配置:

$ ROCKET_PORT=4000 cargo run
...
 Rocket has launched from http://127.0.0.1:4000

环境变量覆盖是有效的;尽管我们在 Rocket.toml 文件中指定了端口 3000,但我们正在端口 4000 上运行应用程序。当我们配置应用程序连接到数据库时,我们将在 第四章 构建、点燃和发射火箭 中学习如何扩展默认的 rocket::Config 以使用自定义配置。现在我们已经学会了如何配置 Rocket 应用程序,让我们找出我们可以在哪里获得 Rocket 网络框架的文档和帮助。

获取帮助

使用网络框架获得帮助是至关重要的。在本部分中,我们将了解我们可以在哪里获得 Rocket 框架的帮助和文档。

您可以从 Rocket 框架本身的网站上获得帮助:rocket.rs/. 在该网站上,有一个如下指南:rocket.rs/v0.5-rc/guide/. 在该页面的左上角,有一个下拉菜单,您可以通过它选择 Rocket 网络框架的先前版本的文档。

api.rocket.rs上,您可以查看 API 的文档,但遗憾的是,这份文档是针对 Rocket 网络框架的 master 分支的。如果您想查看您框架版本的 API 文档,您必须手动搜索,例如https://api.rocket.rs/v0.3/rocket/https://api.rocket.rs/v0.4/rocket/

为 Rocket 框架生成离线文档的另一种方法是。从官方仓库github.com/SergioBenitez/Rocket下载 Rocket 的源代码。然后,在文件夹内输入./scripts/mk-docs.sh来运行 shell 脚本。生成的文档很有用,因为有时其中的一些内容与api.rocket.rs上的内容不同。例如,rocket::Config的定义及其在代码中的默认值与 API 文档中的略有不同。

摘要

在本章中,我们简要了解了 Rocket 的启动序列和请求生命周期。我们还创建了一个非常简单的应用程序,并将其转换为异步应用程序。之后,我们学习了 Rocket 的配置,使用Rocket.toml编写配置,并使用环境变量覆盖它。最后,我们学习了在哪里可以找到 Rocket 框架的文档。

现在我们已经使用 Rocket 网络框架创建了一个简单的应用程序,让我们在下一章中进一步讨论请求和响应。

第三章:第三章:Rocket 请求和响应

我们将在本章中更详细地讨论 Rocket 请求响应。第一部分将讨论 Rocket 如何以路由的形式处理传入请求。我们将了解路由的各个部分,包括 HTTP 方法、URI 和路径。然后,我们将创建一个使用路由各个部分的应用程序。我们还将讨论 Rust 特质并实现一个 Rust 特质来创建请求处理器。

我们还将讨论 Rocket 路由处理器中的响应,并实现返回响应。之后,我们将更多地讨论各种内置响应实现,并学习如何创建错误处理器以在路由处理器失败时创建自定义错误。最后,我们将实现一个泛型错误处理器来处理常见的 HTTP 状态代码,如404500

到本章结束时,您将能够创建 Rocket 框架最重要的部分:处理传入请求并返回响应的函数。

在本章中,我们将涵盖以下主要主题:

  • 理解 Rocket 路由

  • 实现路由处理器

  • 创建响应

  • 创建默认错误处理器

技术要求

我们对于本章仍然有与第二章构建我们的第一个火箭 Web 应用程序相同的技术要求。我们需要安装 Rust 编译器、文本编辑器和 HTTP 客户端。

您可以在此处找到本章的源代码:github.com/PacktPublishing/Rust-Web-Development-with-Rocket/tree/main/Chapter03

理解 Rocket 路由

我们本章从讨论 Rocket 如何以路由的形式处理传入请求开始。我们编写可以用来处理传入请求的函数,将这些函数的上方放置路由属性,并将路由处理函数附加到 Rocket 上。一个路由包含一个 HTTP 方法和一个src/main.rs文件:

#[macro_use]
extern crate rocket;
use rocket::{Build, Rocket};
#[derive(FromForm)]
struct Filters {
    age: u8,
    active: bool,
}
#[route(GET, uri = "/user/<uuid>", rank = 1, format = "text/plain")]
fn user(uuid: &str) { /* ... */ }
#[route(GET, uri = "/users/<grade>?<filters..>")]
fn users(grade: u8, filters: Filters) { /* ... */ }
#[launch]
fn rocket() -> Rocket<Build> {
    rocket::build().mount("/", routes![user, users])
}

突出的行是route属性。您只能在自由函数中放置route属性,而不能在Structimpl方法内部放置。现在,让我们详细讨论路由的各个部分。

HTTP 方法

在路由定义内部看到的第一个参数是 HTTP 方法。HTTP 方法在rocket::http::Method枚举中定义。该枚举有GETPUTPOSTDELETEOPTIONSHEADTRACECONNECTPATCH成员,它们都对应于在 RFCs(请求评论)中定义的有效 HTTP 方法。

除了使用#[route...]宏之外,我们还可以使用其他属性来表示路由。我们可以直接使用特定方法的路由属性,如#[get...]。有七个特定方法的路由属性:getputpostdeleteheadoptionspatch。我们可以将之前的路由属性重写为以下行:

#[get("/user/<uuid>", rank = 1, format = "text/plain")]
#[get("/users/<grade>?<filters..>")]

看起来很简单,对吧?不幸的是,如果我们想要处理HTTP CONNECTTRACE,我们仍然必须使用#[route...]属性,因为这些方法没有特定于方法的路由属性。

URI

在路由属性内部,我们可以看到?)是路径,问号之后的部分是查询。

路径和查询都可以被划分为/),例如/segment1/segment2。查询通过&符号进行分段,例如?segment1&segment2

一个段可以是/static?static。动态段定义在尖括号<>内,例如/<dynamic>?<dynamic>

如果你声明一个动态段,你必须使用该段作为路由属性之后的函数的参数。以下是我们如何通过编写一个新的应用程序并添加此路由和函数处理程序来使用动态段的示例:

#[get("/<id>")]
fn process(id: u8) {/* ... */}

路径

路径在处理函数中的参数类型必须实现rocket::request::FromParam特性。你可能想知道为什么在前面的例子中我们使用了u8作为函数参数。答案是 Rocket 已经为重要类型,如u8,实现了FromParam特性。以下是一个已经实现FromParam特性的所有类型的列表:

  • 原始类型,如f32f64isizei8i16i32i64i128usizeu8u16u32u64u128bool

  • Rust 标准库中的std::num模块的数值类型,例如NonZeroI8NonZeroI16NonZeroI32NonZeroI64NonZeroI128NonZeroIsizeNonZeroU8NonZeroU16NonZeroU32NonZeroU64NonZeroU128NonZeroUsize

  • Rust 标准库中的std::net模块中的net类型,例如IpAddrIpv4AddrIpv6AddrSocketAddrV4SocketAddrV6SocketAddr

  • &strString

  • Option<T>Result<T, T::Error>,其中T:FromParam。如果你是 Rust 的新手,这个语法是泛型类型的。T:FromParam意味着我们可以使用任何类型T,只要该类型实现了FromParam。例如,我们可以创建一个User结构体,为User实现FromParam,并将Option<User>用作函数处理器的参数。

如果你编写一个动态路径段,你必须使用处理函数中的参数,否则代码将无法编译。如果参数类型没有实现FromParam特性,代码同样会失败。

让我们看看如果我们从代码中移除id: u8参数,不使用处理函数中的参数会出现什么错误:

> cargo build
   ...
  Compiling route v0.1.0 (/Users/karuna/Chapter03/
  04UnusedParameter)
error: unused parameter
 --> src/main.rs:6:7
  |
6 | #[get("/<id>")]
  |       ^^^^^^^
error: [note] expected argument named `id` here
 --> src/main.rs:7:15
  |
7 | fn process_abc() { /* ... */ }
  |               ^^

然后,让我们编写一个动态段,它没有实现FromParam。定义一个空的struct,并将其用作处理函数中的参数:

struct S;
#[get("/<id>")]
fn process(id: S) { /* ... */ }

再次,代码将无法编译:

> cargo build
...
  Compiling route v0.1.0 (/home/karuna/workspace/
  rocketbook/Chapter03/05NotFromParam)
error[E0277]: the trait bound `S: FromParam<'_>` is not satisfied
--> src/main.rs:9:16
  | 
9 | fn process(id: S) { /* ... */ }
  |                ^ the trait `FromParam<'_>` is not implemented for `S` 
  | 
  = note: required by `from_param`
error: aborting due to previous error

我们可以从编译器的输出中看到,类型S必须实现FromParam特性。

在尖括号中还有一个动态形式,但后面跟着两个句点(..),例如/<dynamic..>。这种动态形式被称为多段

如果一个常规动态段必须实现FromParam特质,多个段必须实现rocket::request::FromSegments特质。Rocket 只为 Rust 标准库std::path::PathBuf类型提供FromSegments实现。PathBuf是一种在操作系统中表示文件路径的类型。这种实现对于从 Rocket 应用程序中提供静态文件非常有用。

你可能会认为从特定路径提供服务是危险的,因为任何人都可以尝试路径遍历,例如"../../../password.txt"。幸运的是,PathBufFromSegments实现已经考虑了安全问题。因此,对敏感路径的访问已被禁用,例如".."".""*"

另一种段类型是<_><_..>。如果你声明一个忽略段,它将不会显示在函数参数列表中。你必须将忽略的多个段作为路径中的最后一个参数声明,就像常规的多个段一样。

如果你想构建一个匹配很多内容但不想处理的 HTTP 路径,忽略段非常有用。例如,如果你有以下代码行,你可以拥有一个处理任何路径的网站。它将处理//some/1/2/3或任何其他路径:

#[get("/<_>")]
fn index() {}
#[launch]
fn rocket() -> Rocket<Build> {
    rocket::build().mount("/", routes![index])
}

查询

就像路径一样,查询段可以是静态段或动态段(例如"?<query1>&<query2>")或可以以多个"?<query..>"形式存在。多个查询形式称为"?<_>",或者忽略尾随参数,如"?<_..>"

动态查询和尾随参数都不应该实现FromParam,但两者都必须实现rocket::form::FromForm。我们将在第八章中更详细地讨论实现FromForm提供静态资源和模板

排名

URI 中的路径和查询段可以分为三种颜色静态部分通配符。如果路径的所有段都是静态的,则该路径称为静态路径。如果查询的所有段都是静态的,我们说查询具有静态颜色。如果路径或查询的所有段都是动态的,我们称路径或查询为通配符。部分颜色是当路径或查询既有静态段又有动态段时。

我们为什么需要这些颜色?它们是确定路由的下一个参数,即排名所必需的。如果我们有多个处理相同路径的路由,那么 Rocket 将根据排名对函数进行排序,并从排名最低的函数开始检查。让我们看一个例子:

#[get("/<rank>", rank = 1)]
fn first(rank: u8) -> String {
    let result = rank + 10;
    format!("Your rank is, {}!", result)
}
#[get("/<name>", rank = 2)]
fn second(name: &str) -> String {
    format!("Hello, {}!", name)
}
#[launch]
fn rocket() -> Rocket<Build> {
    rocket::build().mount("/", routes![first, second])
}

在这里,我们看到我们有两个函数处理相同的路径,但具有两个不同的函数签名。由于 Rust 不支持函数重载,我们创建了两个不同名称的函数。让我们尝试调用每个路由:

> curl http://127.0.0.1:8000/1
Your rank is, 11!
> curl http://127.0.0.1:8000/jane
Hello, jane!

当我们在另一个终端查看应用程序日志时,我们可以看到 Rocket 是如何选择路由的:

GET /1:
   >> Matched: (first) GET /<rank>
   >> Outcome: Success
   >> Response succeeded.
GET /jane:
   >> Matched: (first) GET /<rank>
   >> `rank: u8` param guard parsed forwarding with error 
      "jane"
   >> Outcome: Forward
   >> Matched: (second) GET /<name> [2]
   >> Outcome: Success
   >> Response succeeded.

尝试在源代码中反转优先级,并思考如果用u8作为参数调用会发生什么。之后,尝试请求端点以查看您的猜测是否正确。

让我们回顾一下 Rocket 的 URI 颜色。Rocket 将路径和查询的颜色优先级排序如下:

  • 静态路径,静态查询 = -12

  • 静态路径,部分查询 = -11

  • 静态路径,通配查询 = -10

  • 静态路径,无查询 = -9

  • 部分路径,静态查询 = -8

  • 部分路径,部分查询 = -7

  • 部分路径,通配查询 = -6

  • 部分路径,无查询 = -5

  • 通配路径,静态查询 = -4

  • 通配路径,部分查询 = -3

  • 通配路径,通配查询 = -2

  • 通配路径,无查询 = -1

您可以看到路径的优先级较低,静态优先级低于部分,最后,部分优先级低于通配颜色。在创建多个路由时请记住这一点,因为输出可能不是您预期的,因为您的路由可能具有较低的或较高的优先级。

格式

在路由中我们可以使用的另一个参数是format。在具有有效载荷的 HTTP 方法请求中,例如POSTPUTPATCHDELETE,HTTP 请求的Content-Type将与该参数的值进行比较。当处理没有有效载荷的 HTTP 请求,例如GETHEADOPTIONS时,Rocket 会检查并匹配路由的格式与 HTTP 请求的Accept头。

让我们为format参数创建一个示例。创建一个新的应用程序并添加以下行:

#[get("/get", format = "text/plain")]
fn get() -> &'static str {
    "GET Request"
}
#[post("/post", format = "form")]
fn post() -> &'static str {
    "POST Request"
}
#[launch]
fn rocket() -> Rocket<Build> {
    rocket::build().mount("/", routes![get, post])
}

如果您仔细观察,/get端点的格式使用的是"text/plain" IANA(互联网分配号码权威机构)媒体类型,但/post端点的格式不是正确的 IANA 媒体类型。这是因为 Rocket 接受以下缩写并将其转换为正确的 IANA 媒体类型:

  • "any""*/*"

  • "binary""application/octet-stream"

  • "bytes""application/octet-stream"

  • "html""text/html; charset=utf-8"

  • "plain""text/html; charset=utf-8"

  • "text""text/html; charset=utf-8"

  • "json""application/json"

  • "msgpack""application/msgpack"

  • "form""application/x-www-form-urlencoded"

  • "js""application/javascript"

  • "css""text/css; charset=utf-8"

  • "multipart""multipart/form-data"

  • "xml""text/xml; charset=utf-8"

  • "pdf""application/pdf"

现在,运行应用程序并调用这两个端点以查看它们的行为。首先,使用正确的和错误的Accept头调用/get端点:

> curl -H "Accept: text/plain" http://127.0.0.1:8000/get
GET Request
> curl -H "Accept: application/json" http://127.0.0.1:8000/get
{
  "error": {
    "code": 404,
    "reason": "Not Found",
    "description": "The requested resource could not be 
    found."
  }
}

具有正确Accept头的请求返回正确的响应,而具有不正确Accept头的请求返回404,但带有"Content-Type: application/json"响应头。现在,向/post端点发送POST请求以查看响应:

> curl -X POST -H "Content-Type: application/x-www-form-urlencoded" http://127.0.0.1:8000/post
POST Request
> curl -X POST -H "Content-Type: text/plain" http://127.0.0.1:8000/post
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <title>404 Not Found</title>
</head>
...
</html>

我们的应用程序输出了预期的响应,但响应的Content-Type不是我们预期的。我们将在本章后面学习如何创建默认的错误处理器。

数据

路由中的data参数用于处理请求体。数据必须以动态形式存在,例如动态的<something> URI 段。之后,声明的属性必须作为参数包含在路由属性之后的函数中。例如,看看以下几行:

#[derive(FromForm)]
struct Filters {
    age: u8,
    active: bool,
}
#[post("/post", data = "<data>")]
fn post(data: Form<Filters>) -> &'static str {
    "POST Request"
}
#[launch]
fn rocket() -> Rocket<Build> {
    rocket::build().mount("/", routes![post])
}

如果你没有在路由之后的函数中将data作为参数包含,Rust 将在编译时对此提出抱怨。尝试从函数签名中移除data参数,并尝试编译以查看编译器错误输出的实际效果。

当我们实现表单和上传文件到服务器时,我们将在后面了解更多关于数据的内容。现在我们已经学习了关于 Rocket 路由的知识,让我们创建一个应用程序来实现处理请求的路由。

实现路由处理器

在这里,我们将创建一个处理路由的应用程序。我们正在重用本章中编写的第一段代码。想法是我们有多个用户数据,我们希望发送请求以根据请求中发送的 ID 选择并返回选定的用户数据。在本部分中,我们将实现请求和选择路由处理器的部分。在下一节中,我们将学习如何创建自定义响应类型。在随后的章节中,我们将创建一个处理请求不匹配任何我们拥有的用户数据的处理器。最后,在最后一节中,我们将创建一个默认的错误处理器来处理无效请求。

让我们先复制第一段代码到一个新的文件夹中。之后,在src/main.rs中,在Filter定义之后添加一个User结构体:

struct Filters {
    ...
}
#[derive(Debug)]
struct User {
    uuid: String,
    name: String,
    age: u8,
    grade: u8,
    active: bool,
}

对于User结构体,我们使用uuid作为对象标识。原因是如果我们使用usize或其他数值类型作为 ID 而不进行任何认证,我们可能会陷入不安全的直接对象引用IDOR)安全漏洞,其中未经授权的用户可以轻易猜测任何数字作为 ID。作为标识符的 UUID 更难以猜测。

此外,在实际应用中,我们可能需要为姓名年龄创建透明加密,因为这些信息可能被视为个人可识别信息,但为了学习的目的,我们在这本书中将其省略。

我们还在结构体顶部添加了#[derive(Debug)]属性。该属性会自动为结构体创建一个用于打印的fmt::Debug实现。然后我们可以在代码中使用它,例如format!("{:?}", User)Debug属性的一个要求是所有类型成员都必须实现Debug;然而,在我们的情况下这并不是问题,因为所有 Rust 标准库类型已经实现了Debug特质。

关于下一步,我们希望在集合数据结构中存储几个 User 数据。我们可以将它们存储在一个 [User; 5] 数组或一个可增长的 std::vec::Vec 数组类型中。要找到数组中的用户数据,我们可以逐个迭代数组或 Vec,直到结束或找到匹配项,但这不是最佳方案,因为对于大型数组来说,这很耗时。

在计算机科学中,有一些更好的数据结构可以用来存储数据,并且可以通过索引轻松找到对象,例如哈希表。Rust 有许多库实现了各种数据结构,哈希表就是其中之一。在标准库中,我们可以在 std::collections::HashMap 中找到它。

除了使用标准库之外,我们还可以使用其他替代方案,因为 Rust 社区已经创建了许多与数据结构相关的库。尝试在 crates.iolib.rs 中搜索。例如,如果我们不使用标准库,我们可以使用替代 crate,如 hashbrown

让我们在 User 结构体声明之后在 src/main.rs 文件中实现它。不幸的是,HashMap 的创建需要堆分配,所以我们不能将 HashMap 赋值给静态变量。添加以下代码将不起作用:

use std::collections::HashMap;
...
static USERS: HashMap<&str, User> = {
    let map = HashMap::new();
    map.insert(
        "3e3dd4ae-3c37-40c6-aa64-7061f284ce28",
        User {
            uuid: String::from("3e3dd4ae-3c37-40c6-aa64-
            7061f284ce28"),
            name: String::from("John Doe"),
            age: 18,
            grade: 1,
            active: true,
        },
    );
    map
};

有几种方法可以将 HashMap 赋值给静态变量,但最好的建议是使用来自 lazy_static crate 的 lazy_static! 宏,它在运行时执行代码并执行堆分配。让我们将其添加到我们的代码中。首先,在 Cargo.toml 依赖项中添加 lazy_static

[dependencies]
lazy_static = "1.4.0"
rocket = "0.5.0-rc.1"

然后,按照以下方式在代码中使用和实现它。如果你想稍后测试,可以随意添加额外的用户:

use lazy_static::lazy_static;
use std::collections::HashMap;
...
lazy_static! {
    static ref USERS: HashMap<&'static str, User> = {
        let mut map = HashMap::new();
        map.insert(
            "3e3dd4ae-3c37-40c6-aa64-7061f284ce28",
            User {
                ...
            },
        );
        map
    };
}

让我们按照以下方式修改 fn user(...)

#[get("/user/<uuid>", rank = 1, format = "text/plain")]
fn user(uuid: &str) -> String {
    let user = USERS.get(uuid);
    match user {
        Some(u) => format!("Found user: {:?}", u),
        None => String::from("User not found"),
    }
}

我们希望函数在调用时返回一些内容,因此我们在函数签名中添加 -> String

HashMap 有许多方法,例如用于插入新键值对的 insert() 方法,或者 keys() 方法,它返回 HashMap 中键的迭代器。我们只是使用了 get() 方法,它返回 std::option::Option。记住,Option 只是一个枚举,可以是 None,或者如果它包含值,则是 Some(T)。最后,match 控制流运算符根据值是 None 还是 Some(u) 返回适当的字符串。

现在,如果我们尝试向 http://127.0.0.1:8000/user/3e3dd4ae-3c37-40c6-aa64-7061f284ce28 发送一个 GET 请求,我们可以看到它将返回正确的响应,如果我们向 http://127.0.0.1:8000/user/other 发送一个 GET 请求,它将返回 "User not found"

现在,让我们实现 users() 函数。让我们回顾一下原始签名:

#[route(GET, uri = "/users/<grade>?<filters..>")]
fn users(grade: u8, filters: Filters) {}

因为 u8 已经实现了 FromParam,所以我们可以直接使用它。但是,我们想看看如何为自定义类型实现 FromParam。让我们改变我们的用例,使其路径类似于 "/users/<name_grade>?<filters...>"

首先,创建一个自定义的 NameGrade 结构体。'r 注解意味着这个结构体应该只在其名称字段中引用的字符串存在期间存在:

struct NameGrade<'r> {
    name: &'r str,
    grade: u8,
}

如果我们要实现一个特性,我们必须查看该特性的签名。Rust 编译器要求类型实现特性中的所有方法和类型占位符。我们可以从 Rocket API 文档中找到 FromParam 的特性定义:

pub trait FromParam<'a>: Sized {
    type Error: Debug;
    fn from_param(param: &'a str) -> Result<Self, Self::Error>;
}

type Error: Debug; 被称为类型占位符。某些特性要求实现必须具有某种类型。任何实现了此特性的类型都应该使用一个具体的类型,该类型也具有调试特性。因为我们只想显示错误信息,所以我们可以使用 &'static str 作为此实现的 Error 类型。然后,按照如下方式编写 NameGrade 的特性实现签名:

use rocket::{request::FromParam, Build, Rocket};
...
impl<'r> FromParam<'r> for NameGrade<'r> {
    type Error = &'static str;
    fn from_param(param: &'r str) -> Result<Self, Self::
    Error> {}
}

在函数内部,将我们想要显示给应用程序用户的消息添加进去:

const ERROR_MESSAGE: Result<NameGrade, &'static str> = Err("Error parsing user parameter");

然后,让我们在 '_' 字符处拆分输入参数:

let name_grade_vec: Vec<&'r str> = param.split('_').collect();

name_grade_vec 的长度将是 2other,因此我们可以对它使用 match。由于 name_grade_vec[0] 是一个字符串,我们可以直接使用它,但对于第二个成员,我们必须对其进行解析。而且,由于结果可以是任何东西,我们必须使用一种特殊的语法,其形式如下 ::<Type>。这种语法被 Rust 社区亲切地称为 turbofish

就像 Option 一样,Result 只是一个枚举,可以是 Ok(T)Err(E)。如果程序成功解析 u8,该方法可以返回 Ok(NameGrade{...}),否则函数可以返回 Err("...")

match name_grade_vec.len() {
    2 => match name_grade_vec[1].parse::<u8>() {
        Ok(n) => Ok(Self {
            name: name_grade_vec[0],
            grade: n,
        }),
        Err(_) => ERROR_MESSAGE,
    },
    _ => ERROR_MESSAGE,
}

现在我们已经为 NameGrade 实现了 FromParam,我们可以在 users() 函数中使用 NameGrade 作为参数。我们还想将 String 作为函数的返回类型:

#[get("/users/<name_grade>?<filters..>")]
fn users(name_grade: NameGrade, filters: Filters) -> String {}

在函数内部,编写用 name_gradefilters 过滤 USERS 哈希表的例程:

let users: Vec<&User> = USERS
    .values()
    .filter(|user| user.name.contains(&name_grade.name) && 
    user.grade == name_grade.grade)
    .filter(|user| user.age == filters.age && user.active 
    == filters.active)
    .collect();

HashMap 有一个 values() 方法,它返回 std::collections::hash_map::ValuesValues 实现了 std::iter::Iterator,因此我们可以使用 filter() 方法对其进行过滤。filter() 方法接受一个 闭包,它返回 Rust 的 bool 类型。filter() 方法本身返回 std::iter::Filter,它实现了 Iterator 特性。Iterator 特性有一个 collect() 方法,可以用来将项目收集到集合中。有时,如果结果类型不能被编译器推断出来,你必须使用 ::<Type> turbofish 在 collect::<Type>() 中。

然后,我们可以将收集到的用户转换为 String

if users.len() > 0 {
    users
        .iter()
        .map(|u| u.name.to_string())
        .collect::<Vec<String>>()
        .join(",")
} else {
    String::from("No user found")
}

完成此操作后,运行应用程序并尝试调用 users() 函数:

curl -G -d age=18 -d active=true http://127.0.0.1:8000/users/John_1

它可以工作,但问题是查询参数很麻烦;我们希望 Filters 是可选的。让我们稍微修改一下代码。将 fn users 的签名更改为以下内容:

fn users(name_grade: NameGrade, filters: Option<Filters>) -> String {
...
        .filter(|user| {
            if let Some(fts) = &filters {
                user.age == fts.age && user.active == 
                fts.active
            } else {
                true
            }
        })
...

你可能会被这段代码弄糊涂:if let Some(fts) = &filters。这是 Rust 中的解构语法之一,就像这段代码一样:

match something {
    Ok(i) => /* use i here */ "",
    Err(err) => /* use err here */ "",
}

我们已经实现了这两个端点的请求部分,user()users(),但那些端点的返回类型是 Rust 标准库类型String。我们想要使用我们自己的自定义类型。所以,让我们在下一节中看看如何直接从User结构体创建响应。

创建响应

让我们为User类型实现一个自定义响应。在 Rocket 中,所有实现了rocket::response::Responder的类型都可以用作处理路由的函数的返回类型。

让我们看看Responder特质的签名。这个特质需要两个生命周期,'r'o'。结果生命周期'o'必须至少等于'r'生命周期:

pub trait Responder<'r, 'o: 'r> {
    fn respond_to(self, request: &'r Request<'_>) -> 
    Result<'o>;
}

首先,我们可以包含实现User结构Responder特质的所需模块:

use rocket::http::ContentType;
use rocket::response::{self, Responder, Response};
use std::io::Cursor;

然后,添加User结构的实现签名:

impl<'r> Responder<'r, 'r> for &'r User {
    fn respond_to(self, _: &'r Request<'_>) -> 
    response::Result<'r> {    }
}

为什么我们使用rocket::response::{self...}而不是rocket::response::{Result...}?如果我们返回-> Result,我们就不能使用在 Rust 中相当普遍的std::result::Result类型。在respond_to()方法体中写下以下行:

let user = format!("Found user: {:?}", self);
Response::build()
    .sized_body(user.len(), Cursor::new(user))
    .raw_header("X-USER-ID", self.uuid.to_string())
    .header(ContentType::Plain)
    .ok()

应用程序从User对象生成一个用户String,然后通过调用Response::build()生成rocket::response::Builder。我们可以为Builder实例设置各种有效载荷;例如,sized_body()方法添加响应体,raw_header()header()添加 HTTP 头,最后,我们使用finalize()方法生成response::Result()

sized_body()方法的第一个参数是Option,参数可以是None。因此,sized_body()方法需要第二个参数实现tokio::io::AsyncRead + tokio::io::AsyncSeek特质以自动确定大小。幸运的是,我们可以将主体包装在std::io::Cursor中,因为 Tokio 已经为Cursor实现了这些特质。

当我们实现std::iter::Iterator特质和rocket::response::Builder时,可以观察到的一个常见模式是通过链式命令调用Something实例,例如Something.new().func1().func2()

struct Something {}
impl Something {
    fn new() -> Something { ... }
    fn func1(&mut self) -> &mut Something { ... }
    fn func2(&mut self) -> &mut Something { ... }
}

让我们也修改users()函数以返回一个新的Responder。我们正在定义一个新的类型,这通常被称为newtype习语。如果我们想包装一个集合或绕过孤儿规则,这个习语很有用。

孤儿规则意味着typeimpl都不在我们的应用程序或 crate 中。例如,我们无法在我们的应用程序中实现impl Responder for Iterator。原因是Iterator是在标准库中定义的,而Responder是在 Rocket crate 中定义的。

我们可以使用以下行中的 newtype 习语:

struct NewUser<'a>(Vec<&'a User>);

注意,结构体有一个struct NewType(type1, type2, ...)

我们也可以调用一个无名称字段的struct(type1, type2, type3)。然后我们可以通过索引访问结构体的字段,例如self.0self.1等等。

在新类型定义之后,添加以下实现:

impl<'r> Responder<'r, 'r> for NewUser<'r> {
    fn respond_to(self, _: &'r Request<'_>) -> 
    response::Result<'r> {
        let user = self
            .0
            .iter()
            .map(|u| format!("{:?}", u))
            .collect::<Vec<String>>()
            .join(",");
        Response::build()
            .sized_body(user.len(), Cursor::new(user))
            .header(ContentType::Plain)
            .ok()
    }
}

与为User类型实现的Responder类似,在NewUserResponder实现中,我们基本上再次遍历用户集合,将它们收集为一个字符串,并再次构建response::Result

最后,让我们在user()users()函数中使用UserNewUser结构体作为响应类型:

#[get("/user/<uuid>", rank = 1, format = "text/plain")]
fn user(uuid: &str) -> Option<&User> {
    let user = USERS.get(uuid);
    match user {
        Some(u) => Some(u),
        None => None,
    }
}
#[get("/users/<name_grade>?<filters..>")]
fn users(name_grade: NameGrade, filters: Option<Filters>) -> Option<NewUser> {
    ...
    if users.len() > 0 {
        Some(NewUser(users))
    } else {
        None
    }
}

现在我们已经学会了如何为类型实现Responder特质,让我们在下一节中了解更多 Rocket 提供的包装器。

包装响应器

Rocket 有两个模块可以用来包装返回的Responder

第一个模块是rocket::response::status,它有以下结构体:AcceptedBadRequestConflictCreatedCustomForbiddenNoContentNotFoundUnauthorized。除了Custom之外的所有响应器都设置状态,就像它们对应的 HTTP 响应代码一样。例如,我们可以将之前的user()函数修改如下:

use rocket::response::status;
...
fn user(uuid: &str) -> status::Accepted<&User>  {
    ...
    status::Accepted(user)
}

Custom类型可以用来包装带有其他不在其他结构体中可用的 HTTP 代码的响应。例如,看看以下行:

use rocket::http::Status;
use rocket::response::status;
...
fn user(uuid: &str) -> status::Custom<&User>  {
    ...
    status::Custom(Status::PreconditionFailed, user)
}

另一个模块rocket::response::content有以下结构体:CssCustomHtmlJavaScriptJsonMsgPackPlainXml。与status模块类似,content模块用于设置响应的Content-Type。例如,我们可以将我们的代码修改为以下行:

use rocket::response::content;
use rocket::http::ContentType;
...
fn user(uuid: &str) -> content::Plain<&User> {
    ...
    content::Plain(user)
}
...
fn users(name_grade: NameGrade, filters: Option<Filters>) -> content::Custom<NewUser> {
    ...
    status::Custom(ContentType::Plain, NewUser(users));
}

我们也可以像以下示例那样结合这两个模块:

fn user(uuid: &str) -> status::Accepted<content::Plain<&User>> {
    ...
    status::Accepted(content::Plain(user))
}

我们可以使用rocket::http::Statusrocket::http::ContentType重写这一点:

fn user(uuid: &str) -> (Status, (ContentType, &User)) {
    ...
    (Status::Accepted, (ContentType::Plain, user))
}

现在,你可能想知道这些结构体如何创建 HTTP StatusContent-Type以及使用另一个Responder实现体体。答案是Response结构体有两个方法:join()merge()

假设有两个Response实例:originaloverrideoriginal.join(override)方法如果original中不存在,则合并override的体和状态。join()方法还会附加来自override的相同头。

同时,merge()方法用override的原始体和状态替换original,如果override中存在,则替换original的原始头。

让我们重写我们的应用程序以使用默认响应。这次我们想要添加一个新的 HTTP 头"X-CUSTOM-ID"。为此,实现以下函数:

fn default_response<'r>() -> response::Response<'r> {
    Response::build()
        .header(ContentType::Plain)
        .raw_header("X-CUSTOM-ID", "CUSTOM")
        .finalize()
}

然后,修改User结构的Responder实现:

fn respond_to(self, _: &'r Request<'_>) -> response::Result<'r> {
    let base_response = default_response();
    let user = format!("Found user: {:?}", self);
    Response::build()
        .sized_body(user.len(), Cursor::new(user))
        .raw_header("X-USER-ID", self.uuid.to_string())
        .merge(base_response)
        .ok()
}

最后,修改NewUserResponder实现。但这次,我们想要添加额外的值:"X-CUSTOM-ID"头。我们可以使用join()方法做到这一点:

fn respond_to(self, _: &'r Request<'_>) -> response::Result<'r> {
    let base_response = default_response();
    ...
    Response::build()
        .sized_body(user.len(), Cursor::new(user))
        .raw_header("X-CUSTOM-ID", "USERS")
        .join(base_response)
        .ok()
}

再次尝试打开userusers的 URL;你应该看到正确的Content-TypeX-CUSTOM-ID

< x-custom-id: CUSTOM 
< content-type: text/plain; charset=utf-8
< x-custom-id: USERS 
< x-custom-id: CUSTOM 
< content-type: text/plain; charset=utf-8

内置实现

除了contentstatus包装器外,Rocket 已经为几种类型实现了Responder特质,以使开发者更容易使用。以下是一个已经实现Responder特质的类型列表:

  • std::option::Option – 对于任何已经实现了 ResponderT 类型,我们都可以返回 Option<T>。如果返回的变体是 Some(T),则将 T 返回给客户端。我们已经在 user()users() 函数中看到了这种返回类型的示例。

  • std::result::ResultResult<T, E> 中的两种变体 TE 都应该实现 Responder。例如,我们可以将我们的 user() 实现更改为返回 status::NotFound,如下所示:

    use rocket::response::status::NotFound;
    ...
    fn user(uuid: &str) -> Result<&User, NotFound<&str>> {
        let user = USERS.get(uuid);
        user.ok_or(NotFound("User not found"))
    }
    
  • &strString – 这些类型以文本内容作为响应体返回,并且 Content-Type 为 "text/plain"。

  • rocket::fs::NamedFile – 这个 Responder 特性会根据文件内容自动返回一个指定了 Content-Type 的文件。例如,我们有 "static/favicon.png" 文件,我们想在应用程序中提供它。请看以下示例:

    use rocket::fs::{NamedFile, relative};
    use std::path::Path;
    #[get("/favicon.png")]
    async fn favicon() -> NamedFile {
        NamedFile::open(Path::new(relative!(
        "static")).join("favicon.png")).await.unwrap()
    }
    
  • rocket::response::RedirectRedirect 用于向客户端返回一个重定向响应。我们将在 第八章提供静态资源和模板 中更详细地讨论 Redirect

  • rocket_dyn_templates::Template – 这个响应者返回一个动态模板。我们将在 第八章提供静态资源和模板 中更详细地讨论模板。

  • rocket::serde::json::Json – 这个类型使得返回 JSON 类型变得容易。要使用这个响应者实现,你必须在 Cargo.toml 中启用 "json" 功能,如下所示:rocket = {version = "0.5.0-rc.1", features = ["json"]}。我们将在 第十一章安全和添加 API 及 JSON 中更详细地讨论 JSON。

  • rocket::response::FlashFlash 是一种在客户端访问后会被删除的 cookie 类型。我们将在 第十一章安全和添加 API 及 JSON 中学习如何使用这种类型。

  • rocket::serde::msgpack::MsgPackCargo.toml 中的 "msgpack" 功能。

  • rocket::response::stream 模块中的各种 stream 响应者 – 我们将在 第九章显示用户帖子第十章上传和处理帖子 中了解更多关于这些响应者的信息。

我们已经实现了一些路由,派生自 FromParam,并创建了实现了 Responder 特性的类型。在下一节中,我们将学习如何为相同的 HTTP 状态码创建默认的错误处理器。

创建默认错误处理器

应用程序应该能够在处理过程中处理可能随时发生的错误。在 Web 应用程序中,将错误返回给客户端的标准方式是使用 HTTP 状态码。Rocket 提供了一种以 rocket::Catcher 的形式处理向客户端返回错误的方法。

捕获处理器的工作方式与路由处理器类似,但有几点例外。让我们修改我们的最后一个应用程序来看看它是如何工作的。让我们回顾一下我们是如何实现 user() 函数的:

fn user(uuid: &str) -> Result<&User, NotFound<&str>> {
    let user = USERS.get(uuid);
    user.ok_or(NotFound("User not found"))
}

如果我们请求 GET /user/wrongid,应用程序将返回一个带有代码 404"text/plain" 内容类型和 "User not found" 体的 HTTP 响应。让我们将函数改回返回 Option

fn user(uuid: &str) -> Option<&User> {
    USERS.get(uuid) 
}

返回 Option 的函数,其中变体为 None,将使用默认的 404 错误处理器。之后,我们可以按以下方式实现默认的 404 处理器:

#[catch(404)]
fn not_found(req: &Request) -> String {
    format!("We cannot find this page {}.", req.uri())
}

注意函数上方的 #[catch(404)] 属性。它看起来像是一个路由指令。我们可以使用 200599default 之间的任何有效 HTTP 状态码。如果我们放置 default,它将用于代码中未声明的任何 HTTP 状态码。

route 类似,catch 属性必须放在一个自由函数上方。我们无法在 impl 块内的方法上方放置 catch 属性。同样,与路由处理函数一样,捕获函数必须返回一个实现了 Responder 的类型。

处理错误的函数可以有零个、一个或两个参数。如果函数有一个参数,参数类型必须是 &rocket::Request。如果函数有两个参数,第一个参数类型必须是 rocket::http::Status,第二个参数必须是 &Request

捕获函数连接到火箭的方式略有不同。在我们使用 mount()routes! 宏处理路由函数时,我们使用 register()catchers! 宏处理捕获函数:

#[launch]
fn rocket() -> Rocket<Build> {
    rocket::build().mount("/", routes![user, users, 
    favicon]).register("/", catchers![not_found])
}

我们如何告诉路由处理函数使用捕获器?假设一个捕获器已经被定义并注册,如下所示:

#[catch(403)]
fn forbidden(req: &Request) -> String {
    format!("Access forbidden {}.", req.uri())
}
fn rocket() -> Rocket<Build> {
    rocket::build().mount("/", routes![user, users, 
    favicon]).register("/", catchers![not_found, 
    forbidden])
}

然后,我们可以在路由处理函数中直接返回 rocket::http::Status。状态随后将被转发到任何已注册的捕获器或火箭内置的捕获器:

use rocket::http::{Status, ContentType};
...
fn users(name_grade: NameGrade, filters: Option<Filters>) -> Result<NewUser, Status> {
    ...
    if users.is_empty() {
        Err(Status::Forbidden)
    } else {
        Ok(NewUser(users))
    }
}

尝试调用此端点的 GET 请求,看看会发生什么:

curl -v http://127.0.0.1:8000/users/John_2
...
< HTTP/1.1 403 Forbidden
< content-type: text/plain; charset=utf-8
...
< 
* Connection #0 to host 127.0.0.1 left intact
Access Forbidden /users/John_2.* Closing connection 0

应用程序返回 403 默认处理器中的字符串,并且也返回正确的 HTTP 状态。

摘要

本章探讨了 Rocket 框架最重要的部分之一。我们学习了路由及其组成部分,如 HTTP 方法、URI、路径、查询、排名和数据。我们还实现了一些路由以及与应用程序相关的各种路由类型。之后,我们探讨了创建响应类型的方法,并学习了 Responder 特性中已实现的各个包装器和类型。最后,我们学习了如何创建捕获器并将其连接到 Rocket 应用程序。

在下一章中,我们将学习其他火箭组件,例如状态和整流罩。我们将学习火箭应用程序的初始化过程,以及我们如何使用这些状态和整流罩来创建更现代和复杂的应用程序。

第四章:第四章:构建、点燃和发射 Rocket

许多网络应用程序需要某种可以反复使用的对象管理,无论是数据库服务器的连接池、内存存储的连接、第三方服务器的 HTTP 客户端,还是任何其他对象。网络应用程序中的另一个常见功能是中间件

本章中,我们将讨论两个 Rocket 功能(状态和火箭头),它们作为 Rocket 的可重用对象管理和中间件部分。我们还将学习如何创建和使用数据库服务器的连接,这在几乎所有网络应用程序中都非常重要。

完成本章后,我们希望您能够使用和实现 Rocket 网络框架的可重用对象管理和中间件部分。我们还希望您能够连接到您自己选择的数据库。

本章我们将涵盖以下主要主题:

  • 管理状态

  • 与数据库一起工作

  • 安装 Rocket 火箭头

技术要求

除了 Rust 编译器、文本编辑器和 HTTP 客户端等常规要求外,从本章开始,我们将与数据库一起工作。本书中我们将使用的数据库是 PostgreSQL,您可以从www.postgresql.org/下载它,通过您的操作系统包管理器安装它,或使用第三方服务器,如亚马逊网络服务AWS)、Microsoft Azure 或谷歌云平台GCP)。

我们将看到如何连接到其他关系数据库管理系统RDBMSs)如 SQLite、MySQL 或 Microsoft SQL Server,您可以将课程代码调整为适合这些 RDBMS,但使用 PostgreSQL 更容易理解。

您可以在github.com/PacktPublishing/Rust-Web-Development-with-Rocket/tree/main/Chapter04找到本章的源代码。

管理状态

在网络应用程序中,通常程序员需要创建一个在请求/响应生命周期中可以重复使用的对象。在 Rocket 网络框架中,这个对象被称为状态。状态可以是任何东西,例如数据库连接池、存储各种客户统计信息的对象、存储到内存存储的连接的对象、用于发送简单邮件传输协议SMTP)电子邮件的客户端,等等。

我们可以告诉 Rocket 维护状态,这被称为管理状态。创建管理状态的过程相当简单。我们需要初始化一个对象,告诉 Rocket 管理它,并在路由中使用它。一个注意事项是,我们可以从不同类型管理多个状态,但 Rocket 只能管理 Rust 类型的单个实例。

让我们直接尝试一下。我们将有一个访问计数器状态,并告诉 Rocket 管理它,并为每个传入的请求增加计数器。我们可以重用前一章中的应用程序,将 Chapter03/15ErrorCatcher 中的程序复制到 Chapter04/01State 中,并在 Cargo.toml 中将应用程序重命名为 chapter4

src/main.rs 中,定义一个结构体来保存访问计数器的值。为了状态能够工作,要求是 T: Send + Sync + 'static

use std::sync::atomic::AtomicU64;
...
struct VisitorCounter {
    visitor: AtomicU64,
}

我们已经知道 'static 是一个生命周期标记,但 Send + Sync 是什么?

在现代计算中,由于其复杂性,程序可能会以非预期的方式执行。例如,多线程使得很难知道变量的值是否在另一个线程上被更改。现代 CPU 也执行分支预测,并同时执行多个指令。复杂的编译器也会重新排列生成的二进制代码执行流程以优化结果。为了克服这些问题,在 Rust 语言中需要某种同步机制。

Rust 语言有特性和内存容器来解决同步问题,这取决于程序员如何期望应用程序工作。我们可能想在堆中创建一个对象,并在多个其他对象中共享对该对象的引用。例如,我们创建对象 x,并在其他对象的字段 yz 中使用 x 的引用 &x。这会创建另一个问题,因为程序可以在其他例程中删除 x,从而使程序变得不稳定。解决方案是为不同的用例创建不同的容器。这些包括 std::cell::Rcstd::box::Box 等。

std::marker::Send 是这些特性之一。Send 特性确保任何 T 类型在转移到另一个线程时是安全的。几乎 std 库中的所有类型都是 Send,但有少数例外,例如 std::rc::Rcstd::cell::UnsafeCell。Rc 是一个单线程的引用计数指针。

同时,std::marker::Sync 表示 T 类型可以在多个线程之间安全共享。只有当 &T 引用可以安全地发送到另一个线程时,这才会成立。并非所有 Send 类型都是 Sync。例如,std::cell::Cellstd::cell::RefCellSend 但不是 Sync

Both Send + Sync 都是 Send + Sync 吗?这些类型自动成为 Send 类型。除了原始指针、RcCellRefCell 之外,std 库中的几乎所有类型都是 Send + Sync

AtomicU64是什么?使用常规的u64类型,尽管它是Send + Sync,但线程之间没有同步,因此可能会发生数据竞争条件。例如,两个线程同时访问相同的变量x(其值为64),并且它们将值增加一。我们期望结果是66(因为有两条线程),但由于线程之间没有同步,最终结果是不可预测的。它可以是6566

std::sync模块中的类型提供了一些在多个线程之间共享更新的方法,包括std::sync::Mutexstd::sync::RwLockstd::sync::atomic类型。我们还可以使用可能比标准库提供更好速度的其他库,例如parking_lotcrate。

现在我们已经定义了VisitorCounter,让我们初始化它并告诉 Rocket 将其作为状态管理。在rocket()函数中写入以下代码:

fn rocket() -> Rocket<Build> {
    let visitor_counter = VisitorCounter {
        visitor: AtomicU64::new(0),
    };
    rocket::build()
        .manage(visitor_counter)
        .mount("/", routes![user, users, favicon])
        .register("/", catchers![not_found, forbidden])
}

在我们告诉 Rocket 管理状态之后,我们可以在路线处理函数中使用它。在前一章中,我们学习了必须在使用function参数时使用的动态段。在路线处理函数中,我们还可以使用其他参数,我们称之为请求守卫。它们被称为守卫,因为如果请求没有通过守卫内的验证,请求将被拒绝,并返回错误响应。

任何实现了rocket::request::FromRequest的类型都可以被视为请求守卫。传入的请求将与从左到右的每个请求守卫进行验证,如果请求无效,将短路并返回错误。

假设我们有一个如下所示的路线处理函数:

#[get("/<param1>")]
fn handler1(param1: u8, type1: Guard1, type2: Guard2) {}

Guard1Guard2类型是请求守卫。然后,传入的请求将与Guard1方法进行验证,如果发生错误,将立即返回适当的错误响应。

我们将在整本书中学习并实现请求守卫,但在这个章节中,我们将只使用请求守卫而不进行实现。FromRequest已经为rocket::State<T>实现,因此我们可以在路线处理函数中使用它。

现在我们已经学习了为什么在路线处理函数中使用State,让我们在我们的函数中使用它。我们想要设置访问者计数器,以便每个请求都应该增加计数器:

use rocket::{Build, Rocket, State};
#[get("/user/<uuid>", rank = 1, format = "text/plain")]
fn user<'a>(counter: &State<VisitorCounter>, uuid: &'a str) -> Option<&'a User> {
    counter.visitor.fetch_add(1, Ordering::Relaxed);
    println!("The number of visitor is: {}", counter.
    visitor.load(Ordering::Relaxed));
    USERS.get(uuid)
}

为什么我们添加'a生命周期?我们正在添加一个新的引用参数,而 Rust 无法推断返回的&User应该遵循哪个生命周期。在这种情况下,我们表示User引用的生命周期应该与uuid相同。

在函数内部,我们使用 AtomicU64 的fetch_add()方法来增加访问者的值,并使用 AtomicU64 的load()方法打印该值。

让我们在users()函数中也添加相同的处理,但由于我们与user()函数有完全相同的流程,所以让我们创建另一个函数:

impl VisitorCounter {
    fn increment_counter(&self) {
        self.visitor.fetch_add(1, Ordering::Relaxed);
        println!(
            "The number of visitor is: {}",
            self.visitor.load(Ordering::Relaxed)
        );
    }
}
...
fn user<'a>(counter: &State<VisitorCounter>, uuid: &'a str) -> Option<&'a User> {
    counter.increment_counter();
    ...
}
...
fn users<'a>(
    counter: &State<VisitorCounter>,
    name_grade: NameGrade,
    filters: Option<Filters>,
) -> Result<NewUser<'a>, Status> {
    counter.increment_counter();
    ...
}

此示例与Atomic类型配合良好,但如果您需要处理更复杂的数据类型,例如StringVecStruct,请尝试使用标准库或第三方 crate(如parking_lot)中的MutexRwLock

现在我们已经知道了在 Rocket 中State是什么,让我们通过结合数据库服务器来扩展我们的应用程序。我们将使用State来存储数据库连接。

与数据库一起工作

目前,在我们的应用程序中,我们正在使用静态变量存储用户数据。这非常麻烦,因为它不够灵活,我们无法轻松更新数据。大多数现代处理数据的应用程序将使用某种持久存储,无论是基于文件系统的存储、面向文档的数据库还是传统的 RDBMS。

Rust 有许多库可以连接到各种数据库或类似存储。有postgrescrate,它是 Rust 的 PostgreSQL 客户端。还有其他客户端,如mongodbredis。对于diesel,它可以连接到各种数据库系统。对于连接池管理,有deadpoolr2d2crate。所有 crate 都有其优势和局限性,例如没有异步应用程序。

在本书中,我们将使用sqlx连接到 RDBMS。sqlx声称是一个 Rust 的 SQL 工具包。它为客户端提供了连接到各种 RDBMS 的抽象,它有一个连接池特质,并且还可以用于将类型转换为查询以及将查询响应转换为 Rust 类型。

如本章技术要求部分所述,我们将使用 PostgreSQL 作为我们的 RDBMS,因此请准备 PostgreSQL 的连接信息。

之后,按照以下步骤将我们的应用程序转换为使用数据库:

  1. 我们将再次使用我们的应用程序。我们首先想做的事情是在终端中输入以下命令来安装sqlx-cli

    cargo install sqlx-cli
    

sqlx-cli是一个有用的命令行应用程序,用于创建数据库、创建迁移和运行迁移。它不如其他成熟框架中的迁移工具复杂,但它非常有效地完成了工作。

  1. 准备连接信息,并在您的终端中设置DATABASE_URL环境变量。DATABASE_URL的格式应如下所示,具体取决于您使用的 RDBMS:

    postgres://username:password@localhost:port/db_name?connect_options
    

对于connect_options,它以查询形式存在,相关信息可以在www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING找到。其他 RDBMS 的DATABASE_URL格式可能如下所示:

mysql://username:password@localhost:port/db_name

或者sqlite::memory:sqlite://path/to/file.db?connect_optionssqlite:///path/to/file.db?connect_options。SQLite 的连接选项可以在www.sqlite.org/uri.html找到。

  1. 使用以下命令创建一个新的数据库:

    sqlx database create
    
  2. 我们可以使用以下命令创建一个名为create_users的迁移:

    sqlx migrate add create_users
    
  3. sqlx CLI 将在我们的应用程序根目录内创建一个名为 migrations 的新文件夹,在该文件夹内,将有一个符合 timestamp_migration_name.sql 模式的文件。在我们的示例中,文件名将看起来像 migrations/20210923055406_create_users.sql。在文件中,我们可以编写 SQL 查询来创建或修改 User 结构体,所以让我们将以下代码写入 SQL 文件:

    CREATE TABLE IF NOT EXISTS users
    (
        uuid   UUID PRIMARY KEY,
        name   VARCHAR NOT NULL,
        age    SMALLINT NOT NULL DEFAULT 0,
        grade  SMALLINT NOT NULL DEFAULT 0,
        active BOOL NOT NULL DEFAULT TRUE
    );
    CREATE INDEX name_active_idx ON users(name, active);
    

我们如何知道数据库列类型和 Rust 类型之间的映射关系?sqlx 提供了自己的映射;我们可以在 docs.rs/sqlx 找到相关文档。该包为支持的数据库提供了优秀的模块。我们可以在顶部的搜索栏中搜索它;例如,我们可以在 docs.rs/sqlx/0.5.7/sqlx/postgres/index.html 找到 PostgreSQL 的文档。在该页面上,我们可以看到有 types 模块,我们可以查看它们。

  1. 在我们编写迁移文件的内容后,我们可以使用以下命令运行迁移:

    sqlx migrate run
    
  2. 迁移后,检查生成的数据库表,看看表模式是否正确。让我们插入上一章的数据。也可以随意用您选择的数据填充表格。

  3. 迁移后,将 sqlx 包包含在我们的 Cargo.toml 文件中。由于我们将使用 PostgreSQL 的 uuid 类型,我们还应该包含 uuid 包。如果您想启用其他 RDBMS,请查看该包的 API 文档:

    sqlx = {version = "0.5.7", features = ["postgres", "uuid", "runtime-tokio-rustls"]}
    uuid = "0.8.2"
    
  4. 我们可以从 Cargo.toml 中删除 lazy_static,并从 src/main.rs 文件中移除 lazy_static!USERSHashMap 的引用。我们不需要这些,我们只将从我们之前插入的数据库中检索 User 数据。使用以下 SQL INSERT 语法插入之前用户的数据:

    INSERT INTO public.users
    (uuid, name, age, grade, active)
    VALUES('3e3dd4ae-3c37-40c6-aa64-7061f284ce28'::uuid, 'John Doe', 18, 1, true);
    
  5. 修改 User 结构体以符合我们创建的数据库:

    use sqlx::FromRow;
    use uuid::Uuid;
    ...
    #[derive(Debug, FromRow)]
    struct User {
        uuid: Uuid,
        name: String,
        age: i16,
        grade: i16,
        active: bool,
    }
    

sqlx 从数据库检索结果时,它将被存储在 sqlx::Database::Row 中。这种类型可以转换为任何实现了 sqlx::FromRow 的类型。幸运的是,只要所有成员实现了 sqlx::Decode,我们就可以推导出 FromRow。有一些异常情况,我们可以用来覆盖 FromRow。以下是一个示例:

#[derive(Debug, FromRow)]
#[sqlx(rename_all = "camelCase")]
struct User {
    uuid: Uuid,
    name: String,
    age: i16,
    grade: i16,
    #[sqlx(rename = "active")]
    present: bool,
    #[sqlx(default)]
    not_in_database: String,
}

对于 rename_all,我们可以使用以下选项:snake_caselowercaseUPPERCASEcamelCasePascalCaseSCREAMING_SNAKE_CASEkebab-case

当我们具有不同的列名和类型成员名时,使用 rename。如果一个成员在数据库中没有列,并且该类型实现了 std::default::Default 特性,我们可以使用 default 指令。

为什么我们使用 i16?答案是 PostgreSQL 类型没有映射到 Rust 的 u8 类型。我们可以使用 i8,使用更大的 i16 类型,或者尝试为 u8 实现 Decode。在这种情况下,我们选择使用 i16 类型。

我们希望程序从环境变量中读取连接信息 (DATABASE_URL)。在 第二章构建我们的第一个 Rocket Web 应用程序,我们学习了如何使用标准配置来配置 Rocket,但这次,我们想添加额外的配置。我们可以从在 Cargo.toml 中将 serde 添加到应用程序依赖项开始。

serde 是 Rust 中使用最广泛且最重要的库之一。其名称来源于 序列化和反序列化。它用于涉及序列化和反序列化的任何事物。它可以用于将 Rust 类型实例转换为字节表示,反之亦然,转换为 JSON 和反之亦然,转换为 YAML,以及任何其他类型,只要它们实现了 serde 特性。它还可以用于将实现 serde 特性的一个类型转换为另一个实现 serde 特性的类型。

如果你想查看 serde 的文档,你可以在他们的网站上找到它,网址为 serde.rs

serde 的文档中提到了许多对多种数据格式的原生或第三方支持,例如 JSON、Bincode、CBOR、YAML、MessagePack、TOML、Pickle、RON、BSON、Avro、JSON5、Postcard、URL 查询字符串、Envy、Envy Store、S-expressions、D-Bus 的二进制线格式和 FlexBuffers。

让我们在 Cargo.toml 中添加以下行以将 serde 包含到我们的应用程序中:

[dependencies]
...
serde = "1.0.130"
  1. 之后,创建一个将用于包含我们自定义配置的结构体:

    use serde::Deserialize;
    ...
    #[derive(Deserialize)]
    struct Config {
        database_url: String,
    }
    

serde 已经提供了可以在 derive 属性中使用的 Deserialize 宏。到目前为止,我们已经使用了大量提供库的宏,这些库可以在 derive 属性中使用,例如 DebugFromRowDeserialize。宏系统是 Rust 的重要特性之一。

  1. 实现读取配置并将其映射到 rocket() 函数的例程:

    fn rocket() -> Rocket<Build> {
        let our_rocket = rocket::build();
        let config: Config = our_rocket
            .figment()
            .extract()
            .expect("Incorrect Rocket.toml 
             configuration");
        ...
        our_rocket
            .manage(visitor_counter)
        ...
    }
    
  2. 现在应用程序可以从环境变量中获取 DATABASE_URL 信息,是时候初始化数据库连接池并告诉 Rocket 管理它了。编写以下行:

    use sqlx::postgres::PgPoolOptions;
    ...
    async fn rocket() -> Rocket<Build> {
        ...
        let config: Config = rocket_frame
            .figment()
            .extract()
            .expect("Incorrect Rocket.toml 
            configuration");
        let pool = PgPoolOptions::new()
            .max_connections(5)
            .connect(&config.database_url)
            .await
            .expect("Failed to connect to database");
        ...
        rocket_frame
            .manage(visitor_counter)
            .manage(pool)
        ...
    }
    

我们使用 PgPoolOptions 初始化连接池。其他数据库可以使用它们对应的类型,例如 sqlx::mysql::MySqlPoolOptionssqlx::sqlite::SqlitePoolOptionsconnect() 方法是一个 async 方法,因此我们必须使 rocket() 也异步,以便能够使用结果。

之后,在 rocket() 函数内部,我们告诉 Rocket 管理连接池。

  1. 在使用数据库连接之前,我们使用了 lazy_static 并创建了 user 对象作为 USERS 哈希表的引用。现在,我们将使用数据库中的数据,因此我们需要使用具体对象而不是引用。从 UserNewUser 结构体的 Responder 实现中移除 ampersand (&):

    impl<'r> Responder<'r, 'r> for User { ... }
    struct NewUser(Vec<User>);
    impl<'r> Responder<'r, 'r> for NewUser { ... }
    
  2. 现在,是时候实现 user() 函数以使用数据库连接池从数据库中查询了。按照以下方式修改 user() 函数:

    async fn user(
        counter: &State<VisitorCounter>,
        pool: &rocket::State<PgPool>,
        uuid: &str,
    ) -> Result<User, Status> {
        ...
        let parsed_uuid = Uuid::parse_str(uuid)
        .map_err(|_| Status::BadRequest)?;
        let user = sqlx::query_as!(
            User,
            "SELECT * FROM users WHERE uuid = $1",
            parsed_uuid
        )
        .fetch_one(pool.inner())
        .await;
        user.map_err(|_| Status::NotFound)
    }
    

我们在函数参数中包含了连接池管理状态。之后,我们将 UUID &str 参数解析为 Uuid 实例。如果解析 uuid 参数时发生错误,我们将错误更改为 Status::BadRequest 并返回错误。

然后,我们使用 query_as! 宏向数据库服务器发送查询并将结果转换为 User 实例。我们可以使用许多 sqlx 宏,例如 query!query_file!query_as_unchecked!query_file_as!。您可以在我们之前提到的 sqlx API 文档中找到这些宏的文档。

使用此宏的格式如下:query_as!(RustType, "prepared statement", bind parameter1, ...)。如果您不需要将结果作为 Rust 类型获取,可以使用 query! 宏代替。

然后,我们使用 fetch_one() 方法。如果您想执行而不是查询,例如更新或删除行,可以使用 execute() 方法。如果您想获取所有结果,可以使用 fetch_all() 方法。您可以在 sqlx::query::Query 结构的文档中找到其他可用的方法和它们的文档。

我们可以选择保留 user() 函数返回的 Option<User> 并使用 user.ok(),或者我们将返回值更改为 Status::SomeStatus。由于我们将返回类型更改为 Ok(user)Err(some_error),我们可以直接返回 Ok(user) 变体,但我们需要使用 map_err(|_| Status::NotFound) 将错误更改为 Status 类型。

您可能正在想,如果我们向服务器发送原始 SQL 查询,是否可能进行 SQL 注入攻击?是否可能错误地获取任何用户输入并执行 sqlx::query_as::<_, User>("SELECT * FROM users WHERE name = ?").bind``).fetch_one(pool.inner())

答案是否定的。sqlx 预编译并缓存了每个语句。由于使用预编译语句的结果,它比常规 SQL 查询更安全,并且返回的类型也是我们从 RDBMS 服务器期望的类型。

  1. 让我们再修改一下 users() 函数。像 user() 函数一样,我们希望该函数是 async 并从 Rocket 管理状态中获取连接池。我们还想从 NewUser 中移除生命周期,因为我们不再引用 USERS

    async fn users(
        counter: &State<VisitorCounter>,
        pool: &rocket::State<PgPool>,
        name_grade: NameGrade<'_>,
        filters: Option<Filters>,
    ) -> Result<NewUser, Status> {…}
    
  2. 之后,我们可以准备预编译语句。如果客户端发送 filters 请求,我们将更多条件追加到 WHERE 子句中。对于 PostgreSQL,预编译语句使用 $1$2 等等,但对于其他 RDBMS,您可以使用 ? 作为预编译语句:

    ...
    let mut query_str = String::from("SELECT * FROM users WHERE name LIKE $1 AND grade = $2");
    if filters.is_some() {
        query_str.push_str(" AND age = $3 AND active = 
        $4");
    }
    
  3. 接下来,编写执行查询的代码,但绑定参数的数量可能会根据是否存在过滤器而改变;我们使用query_as函数,这样我们就可以使用if分支。我们还添加了%name%作为名称绑定参数,因为我们使用了 SQL 语句中的LIKE运算符。我们还需要将u8类型转换为i16类型。最后,我们使用fetch_all方法检索所有结果。query_as!宏和查询函数的不错之处在于,它们都根据fetch_onefetch_all返回Vec<T>或不是:

    ...
    let mut query = sqlx::query_as::<_, User>(&query_str)
        .bind(format!("%{}%", &name_grade.name))
        .bind(name_grade.grade as i16);
    if let Some(fts) = &filters {
        query = query.bind(fts.age as i16).bind(fts.
        active);
    }
    let unwrapped_users = query.fetch_all(pool.inner()).await;
    let users: Vec<User> = unwrapped_users.map_err(|_| Status::InternalServerError)?;
    
  4. 我们可以像往常一样返回结果:

    ...
    if users.is_empty() {
        Err(Status::NotFound)
    } else {
        Ok(NewUser(users))
    }
    

现在,让我们再次尝试调用user()users()端点。它应该像我们使用HashMap时一样工作。由于我们在连接池上写下connect()之后没有修改连接选项,SQL 输出被写入终端:

/* SQLx ping */; rows: 0, elapsed: 944.711µs
SELECT * FROM users …; rows: 1, elapsed: 11.187ms
SELECT
  *
FROM
  users
WHERE
  uuid = $1

这里有一些更多的输出:

/* SQLx ping */; rows: 0, elapsed: 524.114µs
SELECT * FROM users …; rows: 1, elapsed: 2.435ms
SELECT
  *
FROM
  users
WHERE
  name LIKE $1
  AND grade = $2

在这本书中,我们不会使用 ORM;相反,我们将只使用sqlx,因为这对于本书的范围已经足够了。如果你想在应用程序中使用 ORM,你可以使用来自github.com/NyxCode/ormxwww.sea-ql.org/SeaORM/的 ORM 和查询构建器。

现在我们已经了解了State以及如何使用State来使用数据库,现在是时候学习 Rocket 的另一个中间件功能,即附加整流罩。

附加 Rocket 整流罩

在现实生活中,火箭整流罩是一种用于保护火箭有效载荷的头部锥体。在 Rocket 框架中,整流罩不是用来保护有效载荷的,而是用来钩入请求生命周期的任何部分并重写有效载荷。整流罩在其他 Web 框架中类似于中间件,但有一些不同之处。

其他框架的中间件可能能够注入任何任意数据。在 Rocket 中,整流罩可以用来修改请求,但不能用来添加不属于请求的信息。例如,我们可以使用整流罩在请求或响应中添加新的 HTTP 头。

一些 Web 框架可能能够终止并直接响应传入的请求,但在 Rocket 中,整流罩不能直接停止传入的请求;请求必须通过路由处理函数,然后路由可以创建适当的响应。

我们可以通过为类型实现rocket::fairing::Fairing来创建一个整流罩。让我们首先看看特质的签名:

 #[crate::async_trait]
pub trait Fairing: Send + Sync + Any + 'static {
    fn info(&self) -> Info;
    async fn on_ignite(&self, rocket: Rocket<Build>) ->
    Result { Ok(rocket) }
    async fn on_liftoff(&self, _rocket: &Rocket<Orbit>) { }
    async fn on_request(&self, _req: &mut Request<'_>, 
    _data: &mut Data<'_>) {}
    async fn on_response<'r>(&self, _req: &'r Request<'_>, 
    _res: &mut Response<'r>) {}
}

有一些类型我们不太熟悉,例如BuildOrbit。这些类型与 Rocket 中的阶段相关。

Rocket 阶段

我们想要讨论的类型是BuildOrbit,它们的完整模块路径是rocket::Orbitrocket::Build。这些类型是什么?Rocket 实例的签名是Rocket<P: Phase>,这意味着任何实现了rocket::PhaseP类型。

Phase是一个pub trait SomeTrait: private::Sealed {}

Phase 特征被密封,因为 Rocket 作者只打算在 Rocket 应用程序中有三个阶段:rocket::Buildrocket::Igniterocket::Orbit

我们通过 rocket::build() 初始化一个 Rocket 实例,它使用 Config::figment() 默认配置,或者使用 rocket::custom<T: Provider>(provider: T),它使用自定义配置提供者。在这个阶段,我们还可以使用 configure<T: Provider>(self, provider: T) 将生成的实例与自定义配置链式连接。然后我们可以使用 mount() 添加路由,使用 register() 注册捕获器,使用 manage() 管理状态,并使用 attach() 附加防热罩。

之后,我们可以通过 ignite() 方法将 Rocket 阶段更改为 Ignite。在这个阶段,我们有一个具有最终配置的 Rocket 实例。然后我们可以通过 launch() 方法将 Rocket 发送到 Orbit 阶段,或者返回 Rocket<Build> 并使用 #[launch] 属性。我们还可以跳过 Ignite 阶段,并在 build() 之后直接使用 launch()

让我们回顾一下到目前为止我们创建的代码:

#[launch]
async fn rocket() -> Rocket<Build> {
    let our_rocket = rocket::build();
    …
    our_rocket
        .manage(visitor_counter)
        .manage(pool)
        .mount("/", routes![user, users, favicon])
        .register("/", catchers![not_found, forbidden])
}

这个函数生成 Rocket<Build>,而 #[launch] 属性生成使用 launch() 的代码。

这个小节的结论是,Rocket 阶段从 BuildIgniteLaunch。这些阶段与防热罩有何关系?让我们在下一个小节中讨论这个问题。

防热罩回调

实现防热罩的任何类型都必须实现一个强制函数,info(),它返回 rocket::fairing::InfoInfo 结构体被定义为如下:

pub struct Info {
    pub name: &'static str,
    pub kind: Kind,
}

rocket::fairing::Kind 被定义为只是一个空的 struct,pub struct Kind(_);,但 Kind 包含了 Kind::IgniteKind::LiftoffKind::RequestKind::ResponseKind::Singleton

关联常量是什么?在 Rust 中,我们可以声明关联项,这些是在特征中声明的或在实现中定义的项。例如,我们有这样一段代码:

struct Something {
    item: u8
}
impl Something {
    fn new() -> Something {
        Something {
            item: 8,
        }
    }
}

我们可以使用 Something::new() self 作为第一个参数。我们已经实现了一个关联方法几次。

我们也可以定义一个关联类型如下:

trait SuperTrait {
    type Super;
}
struct Something;
struct Some;
impl SuperTrait for Something {
    type Super = Some;
}

最后,我们可以有一个 rocket::fairing::Kind

pub struct Kind(usize);
impl Kind {
    pub const Ignite: Kind = Kind(1 << 0);
    pub const Liftoff: Kind = Kind(1 << 1);
    pub const Request: Kind = Kind(1 << 2);
    pub const Response: Kind = Kind(1 << 3);
    pub const Singleton: Kind = Kind(1 << 4);
    ...
}

让我们回到 Info。我们可以创建一个 Info 实例如下:

Info {
    name: "Request Response Tracker",
    kind: Kind::Request | Kind::Response,
}

我们说的是 kind 的值是 kind 关联常量之间 OR 位运算的结果。Kind::Request1<<2,这意味着二进制中的 100 或十进制中的 4Kind::Response1<<3,这意味着二进制中的 1000 或十进制中的 80100 | 1000 的结果是二进制中的 1100 或十进制中的 12。有了这些知识,我们可以将 Info 实例的 kind 值从 00000 设置到 11111

使用位运算设置配置是一个非常常见的将多个值打包到一个变量中的设计模式。一些其他语言甚至将这个设计模式变成自己的类型,并称之为bitset

在实现Fairing特质的类型中,必须实现的方法是info(),它返回Info实例。我们还需要根据我们定义的kind实例实现on_ignite()on_liftoff()on_request()on_response()。在我们的情况下,这意味着我们必须实现on_request()on_response()

Rocket 在不同的场合执行我们的公平器方法。如果我们有on_ignite(),它将在发射前执行。这种类型的公平器是特殊的,因为on_ignite()返回Result,如果返回的变体是Err,则可以中止发射。

对于on_liftoff(),此方法将在发射后执行,这意味着当 Rocket 处于Orbit阶段时。

如果我们有on_request(),它将在 Rocket 获取请求但请求尚未路由之前执行。此方法将可以访问RequestData,这意味着我们可以修改这两个项目。

当路由处理程序创建响应但尚未将其发送到 HTTP 客户端时,将执行on_response()。此回调可以访问RequestResponse实例。

Kind::Singleton是特殊的。我们可以创建同一类型的多个公平器实例并将它们附加到 Rocket 上。但是,我们可能只想允许添加一个Fairing实现类型的实例。我们可以使用Kind::Singleton,这将确保只有最后附加的此类型的实例将被添加。

现在我们对 Rocket 阶段和Fairing回调有了更多的了解,让我们在下一小节中实现Fairing特质。

实现并附加公平器

目前,我们的 Rocket 应用程序管理VisitorCounter,但我们没有将State<VisitorCounter>添加到favicon()函数中。我们可能还想添加新的路由处理函数,但将State<VisitorCounter>作为每个路由处理函数的参数参数是很繁琐的。

我们可以将VisitorCounter从受管理状态改为公平器。同时,让我们假设我们的应用程序中还有另一个要求。我们希望为请求和响应添加自定义头,用于内部日志记录。我们可以通过添加另一个公平器来更改传入的请求和响应来实现它。

首先,让我们稍微组织一下我们的模块使用。我们需要添加与公平器相关的模块,rocket::http::Headerrocket::Buildrocket::Orbit,这样我们就可以为我们的VisitorCounter公平器和另一个修改请求和响应的公平器使用它们:

use rocket::fairing::{self, Fairing, Info, Kind};
use rocket::fs::{relative, NamedFile};
use rocket::http::{ContentType, Header, Status};
use rocket::request::{FromParam, Request};
use rocket::response::{self, Responder, Response};
use rocket::{Build, Data, Orbit, Rocket, State};

VisitorCounter添加Fairing特质实现。由于此特质是一个async特质,我们需要用#[rocket::async_trait]装饰impl

#[rocket::async_trait]
impl Fairing for VisitorCounter {
    fn info(&self) -> Info {
        Info {
            name: "Visitor Counter",
            kind: Kind::Ignite | Kind::Liftoff | Kind::
            Request,
        }
    }
}

我们添加了强制性的 info() 方法,它返回 Info 实例。在 Info 实例内部,我们实际上只需要 Kind::Request,因为我们只需要为每个传入请求增加访问者计数器。但这次,我们还添加了 Kind::IgniteKind::Liftoff,因为我们想看到回调何时执行。

然后,我们可以在 impl Fairing 块内添加这些回调:

async fn on_ignite(&self, rocket: Rocket<Build>) -> fairing::Result {
    println!("Setting up visitor counter");
    Ok(rocket)
}
async fn on_liftoff(&self, _: &Rocket<Orbit>) {
    println!("Finish setting up visitor counter");
}
async fn on_request(&self, _: &mut Request<'_>, _: &mut Data<'_>) {
    self.increment_counter();
}

on_ignite() 方法的返回类型是什么?rocket::fairing::Result 被定义为 Result<T = Rocket<Build>, E = Rocket<Build>> = Result<T, E>。此方法用于控制程序是否继续。例如,我们可以检查与第三方服务器的连接以确保其就绪。如果第三方服务器准备好接受连接,我们可以返回 Ok(rocket)。但是,如果第三方服务器不可用,我们可以返回 Err(rocket) 以停止 Rocket 的启动。注意,on_liftoff()on_request()on_response() 没有返回类型,因为 Fairing 被设计为仅在构建 Rocket 时失败。

对于 on_liftoff(),我们只想将一些信息打印到应用程序输出中。对于 on_request(),我们承担这个舱盖的真实目的:为每个请求增加计数器。

在实现 Fairing 特性之后,我们可以从 user()users() 函数参数中移除 counter: &State<VisitorCounter>。我们还需要从这些函数的主体中移除 counter.increment_counter();

在我们修改了 user()users() 函数之后,我们可以将舱盖附加到 Rocket 应用程序上。在 Rocket 初始化代码中将 manage(visitor_counter) 改为 attach(visitor_counter)

是时候看看舱盖的实际效果了!首先,看看初始化序列。你可以看到 on_ignite() 在开始时执行,而 on_liftoff() 在一切准备就绪后执行:

> cargo run
...
Setting up visitor counter
 Configured for debug.
...
 Fairings:
   >> Visitor Counter (ignite, liftoff, request)
...
Finish setting up visitor counter
 Rocket has launched from http://127.0.0.1:8000

之后,再次尝试调用我们的路由处理函数,以查看计数器再次增加:

> curl http://127.0.0.1:8000/user/3e3dd4ae-3c37-40c6-aa64-7061f284ce28

此外,在 Rocket 输出中,我们可以看到当我们将其用作状态时,它会增加:

The number of visitor is: 2

现在,让我们实现第二个用例,将跟踪 ID 注入到我们的请求和响应中。

首先,修改 Cargo.toml 以确保 uuid 包可以生成一个随机的 UUID:

uuid = {version = "0.8.2", features = ["v4"]}

之后,在 src/main.rs 中,我们可以定义我们想要注入的头部名称以及作为舱盖工作的类型:

const X_TRACE_ID: &str = "X-TRACE-ID";
struct XTraceId {}

之后,我们可以为 XtraceId 实现一个 Fairing 特性。这次,我们希望有 on_request()on_response() 回调:

#[rocket::async_trait]
impl Fairing for XTraceId {
    fn info(&self) -> Info {
        Info {
            name: "X-TRACE-ID Injector",
            kind: Kind::Request | Kind::Response,
        }
    }
}

现在,在 impl Fairing 块内编写 on_request()on_response() 的实现:

async fn on_request(&self, req: &mut Request<'_>, _: &mut Data<'_>) {
    let header = Header::new(X_TRACE_ID, 
    Uuid::new_v4().to_hyphenated().to_string());
    req.add_header(header);
}
async fn on_response<'r>(&self, req: &'r Request<'_>, res: &mut Response<'r>) {
    let header = req.headers().get_one(X_TRACE_ID).
    unwrap();
    res.set_header(Header::new(X_TRACE_ID, header));
}

on_request() 中,我们生成一个随机 UUID,并将生成的字符串作为请求头部之一注入。在 on_response() 中,我们将具有相同头部的响应注入到请求中。

不要忘记初始化并将这个新的舱盖附加到 Rocket 构建和启动过程中:

let x_trace_id = XTraceId {};
our_rocket
    ...
    .attach(visitor_counter)
    .attach(x_trace_id)
    ...

重新运行应用程序。我们应该在应用程序输出中看到一个新舱盖,并在 HTTP 响应中包含 "x-trace-id"

...
 Fairings:
   >> X-TRACE-ID Injector (request, response)
   >> Visitor Counter (ignite, liftoff, request)
...

这里是另一个例子:

curl -v http://127.0.0.1:8000/user/3e3dd4ae-3c37-40c6-aa64-7061f284ce28
...
< x-trace-id: 28c0d523-13cc-4132-ab0a-3bb9ae6153a9
...

请注意,我们可以在应用程序中使用 StateFairing。只有在我们需要为每个请求调用它时才使用 Fairing

之前,我们创建了一个连接池,并告诉 Rocket 使用管理状态来管理它,但 Rocket 已经有通过其内置数据库连接 rocket_db_pools 连接到数据库的方法,rocket_db_pools 是一种公平的连接方式。让我们看看在下一部分如何实现它。

使用 rocket_db_pools 连接到数据库

Rocket 通过使用 rocket_db_pools 提供了一种连接到某些 RDBMS 的官方方法。该 crate 为 Rocket 提供了数据库驱动程序的集成。我们将学习如何使用这个 crate 来连接到数据库。让我们将之前创建的连接池从使用状态改为使用公平连接:

  1. 我们不需要 serde,因为 rocket_db_pools 已经有自己的配置。从 Cargo.toml 中删除 serde 并添加 rocket_db_pools 作为依赖项:

    [dependencies]
    rocket = "0.5.0-rc.1"
    rocket_db_pools = {version = "0.5.0-rc.1", features = ["sqlx_postgres"]}
    ...
    

你也可以使用不同的功能,如 sqlx_mysqlsqlx_sqlitesqlx_mssqldeadpool_postgresdeadpool_redismongodb

  1. Rocket.toml 中,删除包含 database_url 配置的行,并替换为以下行:

    [debug.databases.main_connection]
    url = "postgres://username:password@localhost/rocket"
    

如果你喜欢,可以使用 default.databases.main_connection,你也可以将 main_connection 改成你喜欢的任何名称。

  1. 在 Cargo 库项目中,我们可以使用 pub use something; 语法在 our_library 中重新导出某些内容,然后另一个库可以通过 our_library::something 使用它。删除这些 use sqlx...use serde... 行,因为 rocket_db_pools 已经重新导出了 sqlx,我们也不再需要 serde

    use serde::Deserialize;
    ...
    use sqlx::postgres::{PgPool, PgPoolOptions};
    use sqlx::FromRow;
    
  2. 添加以下行来使用 rocket_db_pools。注意,我们可以在代码中多行声明 use

    use rocket_db_pools::{
        sqlx,
        sqlx::{FromRow, PgPool},
        Connection, Database,
    };
    
  3. 删除 Config 结构体的声明,并添加以下行来声明数据库连接类型:

    #[derive(Database)]
    #[database("main_connection")]
    struct DBConnection(PgPool);
    

数据库为 DBConnection 类型自动生成 rocket_db_pools::Database 实现。注意,我们写的是连接名称 "main_connection",就像我们在 Rocket.toml 中设置的那样。

  1. rocket() 函数中移除配置和连接池初始化:

    let config: Config = our_rocket
        .figment()
        .extract()
        .expect("Incorrect Rocket.toml configuration");
    let pool = PgPoolOptions::new()
        .max_connections(5)
        .connect(&config.database_url)
        .await
        .expect("Failed to connect to database");
    
  2. rocket() 函数中添加 DBConnection::init() 并将其附加到 Rocket:

    async fn rocket() -> Rocket<Build> {
        ...
        rocket::build()
            .attach(DBConnection::init())
            .attach(visitor_counter)
            ...
    }
    
  3. user()users() 函数修改为使用 rocket_db_pools::Connection 请求保护:

    async fn user(mut db: Connection<DBConnection>, uuid: &str) -> Result<User, Status> {
        ...
        let user = sqlx::query_as!(User, "SELECT * FROM 
        users WHERE uuid = $1", parsed_uuid)
            .fetch_one(&mut *db)
            .await;
        ...
    }
    ...
    #[get("/users/<name_grade>?<filters..>")]
    async fn users(
        mut db: Connection<DBConnection>,
        ...
    ) -> Result<NewUser, Status> {
        ...
        let unwrapped_users = query.fetch_all(&mut 
        *db).await;
        ...
    }
    

应用程序应该像我们使用状态管理连接池时一样工作,但有一些细微的差别。以下是我们的输出:

 Fairings:
   ...
   >> 'main_connection' Database Pool (ignite)

我们可以看到应用程序输出中有一个新的公平连接,但应用程序输出中没有准备好的 SQL 语句。

摘要

在这一章中,我们学习了两个 Rocket 组件,StateFairing。我们可以在构建火箭时管理状态对象和附加公平连接,在路由处理函数中使用 state 对象,并使用 fairing 函数在构建后、启动后、请求时和响应时执行回调。

我们还创建了计数器状态并在路由处理函数中使用它们。我们还学习了如何使用 sqlx,进行了数据库迁移,创建了数据库连接池状态,并使用 state 查询数据库。

此后,我们更多地了解了 Rocket 初始化过程以及构建、点燃和发射阶段。

最后,我们将计数器状态改为公平器,并创建了一个新的公平器来向传入的请求和传出的响应中注入自定义的 HTTP 头部。

拥有了这些知识,你可以在路由处理函数之间创建可重用的对象,并创建一个可以在请求和响应之间全局执行的方法。

我们的 src/main.rs 文件正在变得越来越大和复杂;我们将在下一章学习如何以模块的方式管理我们的 Rust 代码,并规划一个更复杂的应用程序。

第五章:第五章:设计用户生成型应用程序

我们将编写一个 Rocket 应用程序,以便更多地了解 Rocket 网络框架。在本章中,我们将设计应用程序并创建应用程序骨架。然后,我们将把应用程序骨架拆分成更小的可管理模块。

阅读本章后,您将能够设计和创建应用程序骨架,并将您的应用程序模块化到您喜欢的程度。

在本章中,我们将涵盖以下主要主题:

  • 设计用户生成型网络应用程序

  • 规划用户结构

  • 创建应用程序路由

  • 模块化 Rocket 应用程序

技术要求

对于本章,我们与上一章有相同的技术要求。我们需要一个 Rust 编译器、一个文本编辑器、一个 HTTP 客户端和一个 PostgreSQL 数据库服务器。

您可以在github.com/PacktPublishing/Rust-Web-Development-with-Rocket/tree/main/Chapter05找到本章的源代码。

设计用户生成型网络应用程序

到目前为止,我们已经获得了一些关于 Rocket 框架的基本知识,例如路由、请求、响应、状态和公平性。让我们在此基础上扩展知识,并通过创建一个完整的应用程序来学习 Rocket 框架的其他功能,如请求守卫、cookies 系统、表单、上传和模板。

我们应用程序的想法是处理用户的各种操作,并且每个用户都可以创建和删除用户生成的内容,例如文本、照片或视频。

我们可以先创建我们想要执行的要求。在各种开发方法中,有许多形式和名称用于定义要求,例如用户故事、用例、软件要求或软件需求规范。

指定要求后,我们通常可以创建应用程序骨架。然后,我们可以实现应用程序并测试实现。

在我们的情况下,因为我们希望实用并理解代码层面的情况,我们将指定要求并在同一步骤中创建应用程序骨架。

让我们从创建一个新的应用程序开始。然后,将应用程序命名为"our_application",并在Cargo.toml中包含rocketrocket_db_poolscrate:

[package]
edition = "2018"
name = "our_application"
version = "0.1.0"
[dependencies]
rocket = {path = "../../../rocket/core/lib/", features = ["uuid"]}
rocket_db_pools = {path = "../../../rocket/contrib/db_pools/lib/", features =[ "sqlx_postgres"]}

修改src/main.rs文件以删除main()函数,并确保我们拥有最基本 Rocket 应用程序:

#[macro_use]
extern crate rocket;
use rocket::{Build, Rocket};
#[launch]
async fn rocket() -> Rocket<Build> {
    rocket::build()
}

让我们通过规划我们希望在应用程序中拥有的用户数据来进入下一步。

规划用户结构

让我们在应用程序中编写用户结构。在最基本层面上,我们希望有一个uuid字段,其类型为Uuid,作为唯一标识符,以及一个username字段,其类型为String,作为人类可记忆的标识符。然后,我们可以添加额外的列,例如emaildescription,其类型为String,以存储有关我们用户的一些更多信息。

我们还希望用户数据中包含 password,但是拥有明文 password 字段不是一个选择。有几个哈希选项,但是显然,我们不能使用不安全的旧哈希函数,如 md5sha1。然而,我们可以使用更安全的哈希加密,如 bcryptscryptargon2。在这本书中,我们将使用 argon2id 函数,因为它对 String 作为 password_hash 类型具有更强的抵抗力。

我们还希望为我们的用户添加一个 status 列。状态可以是 activeinactive,因此我们可以使用 bool 类型。但是,在未来,我们可能希望它具有可扩展性,并具有其他状态,例如,如果要求用户在可以使用我们的应用程序之前包含电子邮件信息并确认他们的电子邮件,则可以使用 confirmed。我们必须使用另一种类型。

在 Rust 中,我们有 enum,这是一个具有多个变体的类型。我们可以有一个具有隐式区分符或显式区分符的枚举。

隐式区分符枚举是一个成员没有被赋予区分符的枚举;它自动从 0 开始,例如,enum Status {Active, Inactive}。使用隐式区分符枚举意味着我们必须在 PostgreSQL 中使用 CREATE TYPE SQL 语句添加一个新的数据类型,例如,CREATE TYPE status AS ENUM ('active', 'inactive');

如果我们使用显式区分符枚举,即成员被赋予区分符的枚举,我们可以使用 PostgreSQL 的 INTEGER 类型并将其映射到 rust i32。一个显式区分符枚举看起来如下所示:

enum Status {
    Inactive = 0,
    Active = 1,
}

因为使用显式区分符枚举更简单,所以我们将选择这种类型作为用户状态列。

我们还希望跟踪用户数据何时创建以及何时更新。Rust 标准库提供了 std::time 用于时间量化类型,但这个模块非常原始,不适合日常操作。有几个尝试为 Rust 创建好的日期和时间库,例如 timechrono 依赖项,幸运的是,sqlx 已经支持这两个依赖项。我们选择使用 chrono 作为这本书。

根据这些要求,让我们编写 User 类型的结构定义和 sqlx 迁移:

  1. Cargo.toml 中,添加 sqlxchronouuid 依赖项:

    sqlx = {version = "0.5.9", features = ["postgres", "uuid", "runtime-tokio-rustls", "chrono"]}
    chrono = "0.4"
    uuid = {version = "0.8.2", features = ["v4"]}
    
  2. src/main.rs 中,添加 UserStatus 枚举和 User 结构体:

    use chrono::{offset::Utc, DateTime};
    use rocket_db_pools::sqlx::FromRow;
    use uuid::Uuid;
    #[derive(sqlx::Type, Debug)]
    #[repr(i32)]
    enum UserStatus {
        Inactive = 0,
        Active = 1,
    }
    #[derive(Debug, FromRow)]
    struct User {
        uuid: Uuid,
        username: String,
        email: String,
        password_hash: String,
        description: String,
        status: UserStatus,
        created_at: DateTime<Utc>,
        updated_at: DateTime<Utc>,
    }
    

注意,我们设置了具有显式区分符的 UserStatus 枚举,并在 User 结构体中使用了 UserStatus 作为状态类型。

  1. 之后,让我们在 Rocket.toml 文件中设置数据库 URL 配置:

    [default]
    [default.databases.main_connection]
    url = "postgres://username:password@localhost/rocket"
    
  2. 然后,再次使用 sqlx migrate add 命令创建数据库迁移,并按如下方式修改生成的迁移文件:

    CREATE TABLE IF NOT EXISTS users
    (
        uuid          UUID PRIMARY KEY,
        username      VARCHAR NOT NULL UNIQUE,
        email         VARCHAR NOT NULL UNIQUE,
        password_hash VARCHAR NOT NULL,
        description   TEXT,
        status        INTEGER NOT NULL DEFAULT 0,
        created_at    TIMESTAMPTZ NOT NULL DEFAULT 
        CURRENT_TIMESTAMP,
        updated_at    TIMESTAMPTZ NOT NULL DEFAULT 
        CURRENT_TIMESTAMP
    );
    

注意,我们设置了 INTEGER,它在 Rust 中对应于 i32 作为 status 列类型。还有一点需要注意,因为 PostgreSQL 中的 UNIQUE 约束已经自动为 usernameemail 创建了索引,所以我们不需要为这两个列添加自定义索引。

不要忘记再次运行 sqlx migrate run 命令行以运行此迁移。

  1. 让我们在 src/main.rs 中添加这些行来初始化数据库连接池公平处理:

    use rocket_db_pools::{sqlx::{FromRow, PgPool}, Database};
    ...
    #[derive(Database)]
    #[database("main_connection")]
    struct DBConnection(PgPool);
    async fn rocket() -> Rocket<Build> {
        rocket::build().attach(DBConnection::init())
    }
    

在我们的 User 结构体准备好之后,接下来我们可以编写与用户相关的路由的代码框架,例如创建或删除用户。

创建用户路由

在先前的应用程序中,我们主要处理获取用户数据,但在现实世界的应用程序中,我们还想进行其他操作,如插入、更新和删除数据。我们可以将获取用户数据(用户和用户列表)的两个函数扩展为 创建、读取、更新和删除 (CRUD) 函数。这四个基本函数可以被认为是持久数据存储的基本操作。

在一个 Web 应用程序中,存在一种基于 HTTP 方法的架构风格来执行操作。如果我们想获取一个实体或一组实体,我们使用 HTTP GET 方法。如果我们想创建一个实体,我们使用 HTTP POST 方法。如果我们想更新一个实体,我们使用 HTTP PUTPATCH 方法。最后,如果我们想删除一个实体,我们使用 HTTP DELETE 方法。使用这些 HTTP 方法来统一传递数据被称为 表征状态转移 (REST),遵循该约束的应用程序被称为 RESTful

在我们为应用程序创建 RESTful 用户路由之前,让我们考虑我们想要处理哪些传入参数以及我们想要返回哪些响应。在前面的章节中,我们创建了返回 String 的路由,但大多数 Web 都是由 HTML 组成的。

对于用户路由响应,我们希望得到 HTML,所以我们可以使用 rocket::response::content::RawHtml。我们可以将其包裹在 Result 中,以 Status 作为错误类型。让我们创建一个类型别名以避免每次使用它作为路由函数返回类型时都写 Result<RawHtml<String>, Status>。在 src/main.rs 中添加以下内容:

type HtmlResponse = Result<RawHtml<String>, Status>;

对于用户路由请求,请求的有效负载将根据请求的内容而有所不同。对于使用 GET 获取特定用户信息的函数,我们需要知道用户的标识符,在我们的例子中,它将是 uuid 类型中的 &str。我们只需要引用(&str),因为我们不会处理 uuid,所以不需要 String 类型:

#[get("/users/<_uuid>", format = "text/html")]
async fn get_user(mut _db: Connection<DBConnection>, _uuid: &str) -> HtmlResponse {
    todo!("will implement later")
}

如果我们定义了一个变量或传递了一个参数但没有使用它,编译器会发出警告,所以我们现在在函数参数的变量名前使用下划线(_)来抑制编译器警告。在我们稍后实现函数时,我们会将变量改为前面没有下划线的变量。

就像 unimplemented! 宏一样,todo! 宏对于原型设计很有用。语义上的区别是,如果我们使用 todo!,我们是在说代码将会被实现,而如果我们使用 unimplemented!,我们并没有做出任何承诺。

挂载路由并尝试现在运行应用程序,并对该端点进行 HTTP 请求。你可以看到应用程序将如何引发恐慌,但幸运的是,Rocket 使用std::panic::catch_unwind函数在服务器中处理捕获恐慌。

对于用户列表,我们必须考虑我们应用程序的可扩展性。如果我们有很多用户,如果我们尝试查询所有用户,这不会非常高效。我们需要在我们的应用程序中引入某种分页。

使用Uuid作为实体 ID 的一个弱点是我们不能按其 ID 对实体进行排序和排序。我们必须使用另一个有序字段。幸运的是,我们已经定义了具有 1 微秒分辨率的created_at字段,它可以进行排序。

但是,请注意,如果你的应用程序正在处理高流量或在分布式系统中,微秒级的分辨率可能不够。你可以使用一个公式来计算TIMESTAMPZ的碰撞概率,该公式用于计算生日悖论。你可以通过使用单调 ID 或支持纳秒级分辨率的硬件和数据库来解决这个问题,但高度可扩展的应用程序超出了本书关于 Rocket Web 框架的范围。

现在让我们先定义Pagination结构体,然后我们将在稍后实现这个结构体。因为我们想在用户列表中使用Pagination,并将其用作request参数,所以我们可以自动使用#[derive(FromForm)]来自动生成rocket::form::FromForm的实现。但是,我们必须创建一个新的类型,OurDateTime,因为孤儿规则意味着我们无法为DateTime<Utc>实现rocket::form::FromForField

use rocket::form::{self, DataField, FromFormField, ValueField};
...
#[derive(Debug, FromRow)]
struct OurDateTime(DateTime<Utc>);
#[rocket::async_trait]
impl<'r> FromFormField<'r> for OurDateTime {
fn from_value(_: ValueField<'r>) -> form::Result<'r, 
    Self> {
        todo!("will implement later")
    }
    async fn from_data(_: DataField<'r, '_>) -> form::
    Result<'r, Self> {
        todo!("will implement later")
    }
}
#[derive(FromForm)]
struct Pagination {
    cursor: OurDateTime,
    limit: usize,
}
#[derive(sqlx::Type, Debug, FromFormField)]
#[repr(i32)]
enum UserStatus {
    ...
}
#[derive(Debug, FromRow, FromForm)]
struct User {
    ...
    created_at: OurDateTime,
    updated_at: OurDateTime,
}

现在,我们可以为用户列表创建一个未实现的功能:

#[get("/users?<_pagination>", format = "text/html")]
async fn get_users(mut _db: Connection<DBConnection>, _pagination: Option<Pagination>) -> HtmlResponse {
    todo!("will implement later")
}

我们需要一个页面来填写输入新用户数据的表单:

#[get("/users/new", format = "text/html")]
async fn new_user(mut _db: Connection<DBConnection>) -> HtmlResponse {
    todo!("will implement later")
}

之后,我们可以创建一个处理创建用户数据的函数:

use rocket::form::{self, DataField, Form, FromFormField, ValueField};
...
#[post("/users", format = "text/html", data = "<_user>")]
async fn create_user(mut _db: Connection<DBConnection>, _user: Form<User>) -> HtmlResponse {
    todo!("will implement later")
}

我们需要一个页面来修改现有的用户数据:

#[get("/users/edit/<_uuid>", format = "text/html")]
async fn edit_user(mut _db: Connection<DBConnection>, _uuid: &str) -> HtmlResponse {
    todo!("will implement later")
}

我们需要处理更新用户数据的函数:

#[put("/users/<_uuid>", format = "text/html", data = "<_user>")]
async fn put_user(mut _db: Connection<DBConnection>, _uuid: &str, _user: Form<User>) -> HtmlResponse {
    todo!("will implement later")
}
#[patch("/users/<_uuid>", format = "text/html", data = "<_user>")]
async fn patch_user(
    mut _db: Connection<DBConnection>,
    _uuid: &str,
    _user: Form<User>,
) -> HtmlResponse {
    todo!("will implement later")
}

PUTPATCH之间的区别是什么?简单来说,在 REST 中,如果我们想完全替换资源,则使用PUT请求,而PATCH用于部分更新数据。

最后一个与用户相关的函数是执行HTTP DELETE的函数:

#[delete("/users/<_uuid>", format = "text/html")]
async fn delete_user(mut _db: Connection<DBConnection>, _uuid: &str) -> HtmlResponse {
    todo!("will implement later")
}

在创建与用户相关的路由处理函数之后,我们可以扩展我们的需求。

制作用户生成内容

只处理用户数据的应用程序并不有趣,所以我们将添加我们的用户上传和删除帖子的能力。每个帖子可以是文本帖子、照片帖子或视频帖子。让我们看看步骤:

  1. 定义Post的结构:

    #[derive(sqlx::Type, Debug, FromFormField)]
    #[repr(i32)]
    enum PostType {
        Text = 0,
        Photo = 1,
        Video = 2,
    }
    #[derive(FromForm)]
    struct Post {
        uuid: Uuid,
        user_uuid: Uuid,
        post_type: PostType,
        content: String,
        created_at: OurDateTime,
    }
    

我们希望区分类型,因此我们添加了 post_type 列。我们还想在用户和帖子之间建立关系。因为我们希望用户能够创建许多帖子,我们可以在结构体中创建一个 user_uuid 字段。内容将用于存储文本内容或存储上传文件的文件路径。我们将在应用程序实现时处理数据迁移。

  1. 每个帖子在 HTML 上的呈现方式可能不同,但它们将在网页上占据相同的位置,所以让我们创建一个 DisplayPostContent 特性和三个 DisplayPostContent 用于每个新类型:

    trait DisplayPostContent {
        fn raw_html() -> String;
    }
    struct TextPost(Post);
    impl DisplayPostContent for TextPost {
        fn raw_html() -> String {
            todo!("will implement later")
        }
    }
    struct PhotoPost(Post);
    impl DisplayPostContent for PhotoPost {
        fn raw_html() -> String {
            todo!("will implement later")
        }
    }
    struct VideoPost(Post);
    impl DisplayPostContent for VideoPost {
        fn raw_html() -> String {
            todo!("will implement later")
        }
    }
    
  2. 最后,我们可以添加处理 Post 的路由。我们可以创建 get_postget_postscreate_postdelete_post。我们还希望这些路由位于用户下:

    #[get("/users/<_user_uuid>/posts/<_uuid>", format = "text/html")]
    async fn get_post(mut _db: Connection<DBConnection>, _user_uuid: &str, _uuid: &str) -> HtmlResponse {
        todo!("will implement later")
    }
    #[get("/users/<_user_uuid>/posts?<_pagination>", format = "text/html")]
    async fn get_posts(
        mut _db: Connection<DBConnection>,
        _user_uuid: &str,
        _pagination: Option<Pagination>,
    ) -> HtmlResponse {
        todo!("will implement later")
    }
    #[post("/users/<_user_uuid>/posts", format = "text/html", data = "<_upload>")]
    async fn create_post(
        mut _db: Connection<DBConnection>,
        _user_uuid: &str,
        _upload: Form<Post>,
    ) -> HtmlResponse {
        todo!("will implement later")
    }
    #[delete("/users/<_user_uuid>/posts/<_uuid>", format = "text/html")]
    async fn delete_post(
        mut _db: Connection<DBConnection>,
        _user_uuid: &str,
        _uuid: &str,
    ) -> HtmlResponse {
        todo!("will implement later")
    }
    

在添加与帖子相关的类型和函数后,我们可以在下一小节中最终创建应用程序骨架。

完成应用程序

不要忘记将这些路由添加到 Rocket 初始化过程中:

async fn rocket() -> Rocket<Build> {
   rocket::build().attach(DBConnection::init()).mount(
        "/",
        routes![
            get_user,
            get_users,
            new_user,
            create_user,
            edit_user,
            put_user,
            patch_user,
            delete_user,
            get_post,
            get_posts,
            create_post,
            delete_post,
        ],
    )
}

我们还希望通过路由提供上传的文件:

use rocket::fs::{NamedFile, TempFile};
...
#[get("/<_filename>")]
async fn assets(_filename: &str) -> NamedFile {
    todo!("will implement later")
}
async fn rocket() -> Rocket<Build> {
    rocket::build()
        ...
        .mount("/assets", routes![assets])
}

是时候添加我们的默认错误处理了!其他框架通常为 HTTP 状态码 404422500 提供默认错误处理器。让我们为这些代码创建一个处理器:

use rocket::request::Request;
...
#[catch(404)]
fn not_found(_: &Request) -> RawHtml<String> {
    todo!("will implement later")
}
#[catch(422)]
fn unprocessable_entity(_: &Request) -> RawHtml<String> {
    todo!("will implement later")
}
#[catch(500)]
fn internal_server_error(_: &Request) -> RawHtml<String> {
    todo!("will implement later")
}
async fn rocket() -> Rocket<Build> {
    rocket::build()
        ...
        .register(
            "/",
catchers![not_found, unprocessable_entity, 
            internal_server_error],
        )
}

当我们使用 Cargo 的 run 命令运行应用程序时,应用程序应该能够正确启动。但是,当我们查看 src/main.rs 文件时,该文件包含许多函数和类型定义。我们将在下一节中将我们的应用程序模块化。

模块化 Rocket 应用程序

记得在 第一章介绍 Rust 语言,当我们使用模块创建应用程序时?应用程序源代码的一个功能是将其用作应用程序开发人员的文档。易于阅读的代码可以轻松进一步开发并与团队中的其他人共享。

编译器不关心程序是在一个文件中还是多个文件中;生成的应用程序二进制文件是相同的。然而,在单个长文件上工作的程序员很容易感到困惑。

我们将把应用程序源代码拆分成更小的文件,并将文件分类到不同的模块中。程序员来自不同的背景,他们可能有自己关于如何拆分应用程序源代码的范式。例如,习惯于编写 Java 程序的程序员可能更喜欢根据逻辑实体或类组织代码。习惯于模型-视图-控制器(MVC)框架的人可能更喜欢将文件放在模型、视图和控制器文件夹中。习惯于清洁架构的人可能会尝试将代码组织成层。但最终,真正重要的是你组织代码的方式被你合作的人接受,并且他们都能舒适且容易地使用相同的源代码。

Rocket 没有关于如何组织代码的具体指南,但我们可以观察到两个可以用来模块化应用程序的东西。第一个是Cargo项目包布局约定,第二个是 Rocket 组件本身。

根据Cargo文档(doc.rust-lang.org/cargo/guide/project-layout.html),包布局应该是这样的:

┌── Cargo.lock
├── Cargo.toml
├── src/
│   ├── lib.rs
│   ├── main.rs
│   └── bin/
│       ├── named-executable.rs
│       ├── another-executable.rs
│       └── multi-file-executable/
│           ├── main.rs
│           └── some_module.rs
├── benches/
│   ├── large-input.rs
│   └── multi-file-bench/
│       ├── main.rs
│       └── bench_module.rs
├── examples/
│   ├── simple.rs
│   └── multi-file-example/
│       ├── main.rs
│       └── ex_module.rs
└── tests/
    ├── some-integration-tests.rs
    └── multi-file-test/
        ├── main.rs
        └── test_module.rs

由于我们还没有基准测试、示例或测试,让我们专注于src文件夹。我们可以将应用程序拆分为src/main.rs中的可执行文件和src/lib.rs中的库。在可执行项目中,制作一个只调用库的小型可执行代码是非常常见的。

我们已经知道 Rocket 有不同部分,所以将 Rocket 组件拆分到它们自己的模块中是个好主意。让我们将源代码组织到这些文件和文件夹中:

┌── Cargo.lock
├── Cargo.toml
└── src/
    ├── lib.rs
    ├── main.rs
    ├── catchers
    │   └── put catchers modules here
    ├── fairings
    │   └── put fairings modules here
    ├── models
    │   └── put requests, responses, and database related modules here
    ├── routes
    │   └── put route handling functions and modules here
    ├── states
    │   └── put states modules here
    ├── traits
    │   └── put our traits here
    └── views
        └── put our templates here
  1. 首先,编辑Cargo.toml文件:

    [package]
    ...
    [[bin]]
    name = "our_application"
    path = "src/main.rs"
    [lib]
    name = "our_application"
    path = "src/lib.rs"
    [dependencies]
    ...
    
  2. 创建src/lib.rs文件以及以下文件夹:src/catcherssrc/fairingssrc/modelssrc/routessrc/statessrc/traitssrc/views

  3. 之后,在每个文件夹内创建一个mod.rs文件:src/catchers/mod.rssrc/fairings/mod.rssrc/models/mod.rssrc/routes/mod.rssrc/states/mod.rssrc/traits/mod.rssrc/views/mod.rs

  4. 然后,编辑src/lib.rs

    #[macro_use]
    extern crate rocket;
    pub mod catchers;
    pub mod fairings;
    pub mod models;
    pub mod routes;
    pub mod states;
    pub mod traits;
    
  5. 首先编写数据库连接。编辑src/fairings/mod.rs

    pub mod db;
    
  6. 创建一个新文件,src/fairings/db.rs,并像在src/main.rs中之前定义的连接一样编写文件:

    use rocket_db_pools::{sqlx::PgPool, Database};
    #[derive(Database)]
    #[database("main_connection")]
    pub struct DBConnection(PgPool);
    

注意,我们只使用了比src/main.rs更少的模块。我们还添加了pub关键字,以便从其他模块或src/main.rs中访问结构体。

  1. 因为特质将要被结构体使用,我们需要先定义特质。在src/traits/mod.rs中,从src/main.rs复制特质:

    pub trait DisplayPostContent {
        fn raw_html() -> String;
    }
    
  2. 之后,让我们将所有用于请求和响应的结构体移动到src/models文件夹中。按照以下方式编辑src/models/mod.rs

    pub mod our_date_time;
    pub mod pagination;
    pub mod photo_post;
    pub mod post;
    pub mod post_type;
    pub mod text_post;
    pub mod user;
    pub mod user_status;
    pub mod video_post;
    
  3. 然后,创建文件并将src/main.rs中的定义复制到这些文件中。第一个是src/models/our_date_time.rs

    use chrono::{offset::Utc, DateTime};
    use rocket::form::{self, DataField, FromFormField, ValueField};
    #[derive(Debug)]
    pub struct OurDateTime(DateTime<Utc>);
    #[rocket::async_trait]
    impl<'r> FromFormField<'r> for OurDateTime {
        fn from_value(_: ValueField<'r>) -> form::
        Result<'r, Self> {
            todo!("will implement later")
        }
        async fn from_data(_: DataField<'r, '_>) -> 
        form::Result<'r, Self> {
            todo!("will implement later")
        }
    }
    
  4. 接下来是src/models/pagination.rs

    use super::our_date_time::OurDateTime;
    #[derive(FromForm)]
    pub struct Pagination {
        pub next: OurDateTime,
        pub limit: usize,
    }
    

注意use声明使用了super关键字。Rust 模块按照层次结构组织,一个模块包含其他模块。super关键字用于访问包含当前模块的模块。super关键字可以链式使用,例如,use super::super::SomeModule;

  1. 之后,编写src/models/post_type.rs

    use rocket::form::FromFormField;
    use rocket_db_pools::sqlx;
    #[derive(sqlx::Type, Debug, FromFormField)]
    #[repr(i32)]
    pub enum PostType {
        Text = 0,
        Photo = 1,
        Video = 2,
    }
    
  2. 还要编写src/models/post.rs

    use super::our_date_time::OurDateTime;
    use super::post_type::PostType;
    use rocket::form::FromForm;
    use uuid::Uuid;
    #[derive(FromForm)]
    pub struct Post {
        pub uuid: Uuid,
        pub user_uuid: Uuid,
        pub post_type: PostType,
        pub content: String,
        pub created_at: OurDateTime,
    }
    

然后,编写src/models/user_status.rs

use rocket::form::FromFormField;
use rocket_db_pools::sqlx;
#[derive(sqlx::Type, Debug, FromFormField)]
#[repr(i32)]
pub enum UserStatus {
    Inactive = 0,
    Active = 1,
}
  1. 编写src/models/user.rs

    use super::our_date_time::OurDateTime;
    use super::user_status::UserStatus;
    use rocket::form::FromForm;
    use rocket_db_pools::sqlx::FromRow;
    use uuid::Uuid;
    #[derive(Debug, FromRow, FromForm)]
    pub struct User {
        pub uuid: Uuid,
        pub username: String,
        pub email: String,
        pub password_hash: String,
        pub description: Option<String>,
        pub status: UserStatus,
        pub created_at: OurDateTime,
        pub updated_at: OurDateTime,
    }
    

然后,编写三个post新类型,src/models/photo_post.rssrc/models/text_post.rssrc/models/video_post.rs

use super::post::Post;
use crate::traits::DisplayPostContent;
pub struct PhotoPost(Post);
impl DisplayPostContent for PhotoPost {
    fn raw_html() -> String {
        todo!("will implement later")
    }
}
use super::post::Post;
use crate::traits::DisplayPostContent;
pub struct TextPost(Post);
impl DisplayPostContent for TextPost {
    fn raw_html() -> String {
        todo!("will implement later")
    }
}
use super::post::Post;
use crate::traits::DisplayPostContent;
pub struct VideoPost(Post);
impl DisplayPostContent for VideoPost {
    fn raw_html() -> String {
        todo!("will implement later")
    }
}

在所有三个文件中,我们在use声明中使用crate关键字。我们之前已经讨论过super关键字;crate关键字是指我们正在工作的当前库,即our_application库。在 Rust 2015 版本中,它写作双分号(::),但自从 Rust 2018 版本以来,::变为了crate。现在,::表示外部 crate 的根路径,例如,::rocket::fs::NamedFile;

除了super::crate之外,还有一些其他的use声明:selfSelf。我们可以使用self来避免在代码中引用项目时的歧义,如下面的例子所示:

use super::haha;
mod a {
    fn haha() {}
    fn other_func() {
        self::haha();
    }
}

Self用于在特性中引用关联类型,如下面的例子所示:

trait A {
  type Any;
  fn any(&self) -> Self::Any;
}
struct B;
impl A for B {
  type Any = usize;
  fn any(&self) -> self::Any {
    100
  }
}
  1. 现在,让我们回到应用程序骨架。在所有结构体之后,是时候为应用程序编写路由了。修改src/routes/mod.rs

    use rocket::fs::NamedFile;
    use rocket::http::Status;
    use rocket::response::content::RawHtml;
    pub mod post;
    pub mod user;
    type HtmlResponse = Result<RawHtml<String>, Status>;
    #[get("/<_filename>")]
    pub async fn assets(_filename: &str) -> NamedFile {
        todo!("will implement later")
    }
    

我们可以将处理资产的函数放在它们自己的 Rust 文件中,但由于只有一个函数且非常简单,我们只需将函数放在mod.rs文件中。

  1. 接下来,创建并编写src/routes/post.rs

    use super::HtmlResponse;
    use crate::fairings::db::DBConnection;
    use crate::models::{pagination::Pagination, post::Post};
    use rocket::form::Form;
    use rocket_db_pools::Connection;
    #[get("/users/<_user_uuid>/posts/<_uuid>", format = "text/html")]
    pub async fn get_post(
        mut _db: Connection<DBConnection>,
        _user_uuid: &str,
        _uuid: &str,
    ) -> HtmlResponse {
        todo!("will implement later")
    }
    #[get("/users/<_user_uuid>/posts?<_pagination>", format = "text/html")]
    pub async fn get_posts(
        mut _db: Connection<DBConnection>,
        _user_uuid: &str,
        _pagination: Option<Pagination>,
    ) -> HtmlResponse {
        todo!("will implement later")
    }
    #[post("/users/<_user_uuid>/posts", format = "text/html", data = "<_upload>")]
    pub async fn create_post(
        mut _db: Connection<DBConnection>,
        _user_uuid: &str,
        _upload: Form<Post>,
    ) -> HtmlResponse {
        todo!("will implement later")
    }
    #[delete("/users/<_user_uuid>/posts/<_uuid>", format = "text/html")]
    pub async fn delete_post(
        mut _db: Connection<DBConnection>,
        _user_uuid: &str,
        _uuid: &str,
    ) -> HtmlResponse {
        todo!("will implement later")
    }
    
  2. 创建并编写src/routes/user.rs

    use super::HtmlResponse;
    use crate::fairings::db::DBConnection;
    use crate::models::{pagination::Pagination, user::User};
    use rocket::form::Form;
    use rocket_db_pools::Connection;
    #[get("/users/<_uuid>", format = "text/html")]
    pub async fn get_user(mut _db: Connection<DBConnection>, _uuid: &str) -> HtmlResponse {
        todo!("will implement later")
    }
    #[get("/users?<_pagination>", format = "text/html")]
    pub async fn get_users(
        mut _db: Connection<DBConnection>,
        _pagination: Option<Pagination>,
    ) -> HtmlResponse {
        todo!("will implement later")
    }
    #[get("/users/new", format = "text/html")]
    pub async fn new_user(mut _db: Connection<DBConnection>) -> HtmlResponse {
        todo!("will implement later")
    }
    #[post("/users", format = "text/html", data = "<_user>")]
    pub async fn create_user(mut _db: Connection<DBConnection>, _user: Form<User>) -> HtmlResponse {
        todo!("will implement later")
    }
    #[get("/users/edit/<_uuid>", format = "text/html")]
    pub async fn edit_user(mut _db: Connection<DBConnection>, _uuid: &str) -> HtmlResponse {
        todo!("will implement later")
    }
    #[put("/users/<_uuid>", format = "text/html", data = "<_user>")]
    pub async fn put_user(
        mut _db: Connection<DBConnection>,
        _uuid: &str,
        _user: Form<User>,
    ) -> HtmlResponse {
        todo!("will implement later")
    }
    #[patch("/users/<_uuid>", format = "text/html", data = "<_user>")]
    pub async fn patch_user(
        mut _db: Connection<DBConnection>,
        _uuid: &str,
        _user: Form<User>,
    ) -> HtmlResponse {
        todo!("will implement later")
    }
    #[delete("/users/<_uuid>", format = "text/html")]
    pub async fn delete_user(mut _db: Connection<DBConnection>, _uuid: &str) -> HtmlResponse {
        todo!("will implement later")
    }
    
  3. 为了最终完成库,请在src/catchers/mod.rs中添加捕获器:

    use rocket::request::Request;
    use rocket::response::content::RawHtml;
    #[catch(404)]
    pub fn not_found(_: &Request) -> RawHtml<String> {
        todo!("will implement later")
    }
    #[catch(422)]
    pub fn unprocessable_entity(_: &Request) -> RawHtml<String> {
        todo!("will implement later")
    }
    #[catch(500)]
    pub fn internal_server_error(_: &Request) -> RawHtml<String> {
        todo!("will implement later")
    }
    
  4. 当库准备就绪时,我们可以修改src/main.rs本身:

    #[macro_use]
    extern crate rocket;
    use our_application::catchers;
    use our_application::fairings::db::DBConnection;
    use our_application::routes::{self, post, user};
    use rocket::{Build, Rocket};
    use rocket_db_pools::Database;
    #[launch]
    async fn rocket() -> Rocket<Build> {
        rocket::build()
            .attach(DBConnection::init())
            .mount(
                "/",
                routes![
                    user::get_user,
                    user::get_users,
                    user::new_user,
                    user::create_user,
                    user::edit_user,
                    user::put_user,
                    user::patch_user,
                    user::delete_user,
                    post::get_post,
                    post::get_posts,
                    post::create_post,
                    post::delete_post,
                ],
            )
            .mount("/assets", routes![routes::assets])
            .register(
                "/",
                catchers![
                    catchers::not_found,
                    catchers::unprocessable_entity,
                    catchers::internal_server_error
                ],
            )
    }
    

我们的src/main.rs文件变得更加简洁。

现在,如果我们想添加更多的结构体或路由,我们可以在相应的文件夹中轻松地添加新的模块。我们还可以添加更多的状态或公平性,并轻松找到这些项目的文件位置。

摘要

在本章中,我们学习了如何设计应用程序、创建 Rocket 应用程序骨架以及将 Rust 应用程序组织成更小的可管理模块。

我们还学习了诸如 CRUD 和 RESTful 应用程序、Rust enum区分符以及 Rust 路径限定符等概念。

希望在阅读本章之后,您可以将这些概念应用到帮助您更好地组织代码中。

我们将在接下来的章节中开始实现这个应用程序,并学习更多关于 Rust 和 Rocket 概念,如模板、请求守卫、cookies 和 JSON。

第二部分:深入探讨 Rocket Web 应用程序开发

在本部分,你将学习 Rust 语言的中间概念,例如 Rust 向量、错误处理、Rust 的 Option 和 Result、枚举、循环、匹配、闭包和异步。你将通过使用 serde crate 来了解更多关于 Rocket 内置守卫、创建自定义请求守卫、表单、cookies、上传文件、模板和 JSON 的知识。

本部分包括以下章节:

  • 第六章**,实现用户 CRUD

  • 第七章**,处理 Rust 和 Rocket 中的错误

  • 第八章**,服务静态资源和模板

  • 第九章**,显示用户的帖子

  • 第十章**,上传和处理帖子

  • 第十一章**,安全和添加 API 及 JSON

第六章:第六章:实现用户 CRUD

在上一章中,我们为应用程序创建了一个大致的轮廓。在本章中,我们将实现管理用户的端点。通过在本章中实现端点,你将了解实体的 HTTP 基本操作,即创建、读取、更新和删除实体。

此外,你还将学习如何构建 HTML 和 HTML 表单,将表单有效负载发送到服务器,验证和清理表单有效负载,散列密码有效负载,并通过将消息重定向到另一个端点来处理失败。

在实现端点的同时,你还将学习如何从数据库中查询单行和多行,以及如何从数据库中插入、更新和删除一行。

在本章中,我们将涵盖以下主要主题:

  • 实现 GET 用户

  • 实现 GET 用户

  • 实现 POST 用户

  • 实现 PUT 和 PATCH 用户

  • 实现 DELETE 用户

技术要求

对于本章,我们与上一章有相同的技术要求。我们需要一个 Rust 编译器、一个文本编辑器、一个 HTTP 客户端和一个 PostgreSQL 数据库服务器。

你可以在这个章节中找到源代码:github.com/PacktPublishing/Rust-Web-Development-with-Rocket/tree/main/Chapter06

实现 GET 用户

让我们看看实现这一步骤:

  1. 我们将从实现src/routes/user.rs中的get_user()函数的基本知识开始:

    #[get("/users/<_uuid>", format = "text/html")]
    pub async fn get_user(mut _db: Connection<DBConnection>, _uuid: &str) -> HtmlResponse {
        todo!("will implement later")
    }
    

在我们实现get_user()之前,我们想要准备我们将要使用的其他例程。例如,我们想要返回 HTML,因此我们需要在同一个src/routes/user.rs文件中创建一个const&'static str,作为我们的 HTML 模板。

  1. 我们将创建两个独立的const实例,这样我们就可以在 HTML 前缀和后缀之间插入不同的内容:

    const USER_HTML_PREFIX: &str = r#"<!DOCTYPE html>
    <html lang="en">
    <head>
    <meta charset="utf-8" />
    <title>Our Application User</title>
    </head>
    <body>"#;
    const USER_HTML_SUFFIX: &str = r#"</body>
    </html>"#;
    

之后,我们将为User结构体创建两个方法。第一个是查找数据库服务器中的条目,第二个是为User实例创建 HTML 字符串。

  1. src/models/user.rs文件中,向use指令中添加以下行:

    use rocket_db_pools::sqlx::{FromRow, PgConnection};
    use std::error::Error;
    
  2. 然后,为User创建一个impl块:

    impl User{}
    
  3. 在块内部,添加find()方法:

    pub async fn find(connection: &mut PgConnection, uuid: &str) -> Result<Self, Box<dyn Error>> {
        let parsed_uuid = Uuid::parse_str(uuid)?;
        let query_str = "SELECT * FROM users WHERE uuid = 
        $1";
        Ok(sqlx::query_as::<_, Self>(query_str)
            .bind(parsed_uuid)
            .fetch_one(connection)
            .await?)
    }
    

我们之前创建了一个类似的方法。我们首先做的事情是将 UUID(通用唯一标识符)&str解析为Uuid实例,并使用问号运算符(?)快速返回Box<dyn Error>。之后,我们定义 SQL 查询字符串query_str,最后,我们返回User实例。

这里有一点不同,我们传递的是PgConnection本身的可变引用,而不是Connection<DBConnection>的可变引用。

记得之前,我们使用的是Connection<DBConnection>,如下所示:

pub async fn find(db: &mut Connection<DBConnection>, uuid: &str) -> ... {
    ...
        .fetch_one(&mut *db)
        .await?)
}

我们首先使用星号 (*) 操作符解引用 db。连接实现了 std::ops::Deref,其实现公开了新类型 DBConnection,这是一个 sqlx::PgPool 的包装器,是 sqlx::Pool<sqlx::Postgres> 的别名。

sqlx::Executor 特性为 sqlx::PgConnection 实现,这是一个实现了 sqlx::Connection 特性的结构体,代表对数据库的单个连接。sqlx::Executor 特性也为 &sqlx::Pool 实现,这是一个异步的 SQLx 数据库连接池。

由于各种 sqlx 方法(如 fetch_allfetch_onefetch_manyfetchexecuteexecute_many)接受泛型类型 E,该类型由 sqlx::Executor 特性绑定,因此我们可以使用连接池本身的引用或从池中获取的连接来使用这些方法。

由于 find() 方法中的 OurDateTime 类型是 sqlx 所未知的,因此存在问题。

  1. src/models/our_date_time.rs 中添加以下指令:

    #[derive(Debug, sqlx::Type)]
    #[sqlx(transparent)]
    pub struct OurDateTime(DateTime<Utc>);
    

transparent 指令自动生成引用内部类型实现的实现,在我们的例子中是 DateTime<Utc>

  1. 除了 find() 方法之外,让我们再实现另一个方法,将 User 转换为 HTML String

    pub fn to_html_string(&self) -> String {
        format!(
            r#"<div><span class="label">UUID: 
            </span>{uuid}</div>
    <div><span class="label">Username: </span>{username}</div>
    <div><span class="label">Email: </span>{email}</div>
    <div><span class="label">Description: </span>{description}</div>
    <div><span class="label">Status: </span>{status}</div>
    <div><span class="label">Created At: </span>{created_at}</div>
    <div><span class="label">Updated At: </span>{updated_at}</div>"#,
            uuid = self.uuid,
            username = self.username,
            email = self.email,
            description = self.description.as_ref().
            unwrap_or(&String::from("")),
            status = self.status.to_string(),
            created_at = self.created_at.0.to_rfc3339(),
            updated_at = self.updated_at.0.to_rfc3339(),
        )
    }
    
  2. 由于 OurDateTime 成员是私有的,但我们像 self.created_at.``.to_rfc3339() 这样访问它,因此在编译时将会产生错误。为了解决这个问题,将 src/models/our_date_time.rs 中的 OurDateTime 成员转换为公共:

    pub struct OurDateTime(pub DateTime<Utc>);
    

我们还需要为 UserStatus 实现 to_string() 方法。我们可以选择实现 to_string(),或者我们可以实现 std::fmt::Display,这会自动提供 to_string()。作为奖励,使用 Display 特性,我们还可以在 format!("{}", something) 宏中使用它。

  1. 按照以下方式修改 src/models/user_status.rs

    use std::fmt;
    ...
    impl fmt::Display for UserStatus {
        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> 
        fmt::Result {
            match *self {
                UserStatus::Inactive => write!(f, 
               "Inactive"),
                UserStatus::Active => write!(f, "Active"),
            }
        }
    }
    
  2. 是时候在 src/routes/user.rs 中实现 get_user() 函数了:

    use rocket::http::Status;
    use rocket::response::content::RawHtml;
    use rocket_db_pools::{sqlx::Acquire, Connection};
    #[get("/users/<uuid>", format = "text/html")]
    pub async fn get_user(mut db: Connection<DBConnection>, uuid: &str) -> HtmlResponse {
        let connection = db
            .acquire()
            .await
            .map_err(|_| Status::InternalServerError)?;
    }
    

首先,我们添加所需的 use 指令,然后从 _db_uuid 中移除下划线以标记它们为已使用的变量。然后,我们从数据库连接池中获取单个连接,如果出现问题则返回 InternalServerError

  1. 在我们设置连接变量之后,我们可以执行之前定义的 find() 方法:

    ...
        let user = User::find(connection, uuid)
            .await
            .map_err(|_| Status::NotFound)?;
    

我们只为这种情况公开了一个简单的错误 NotFound,但更复杂的应用程序应该适当地处理错误,例如记录错误并返回适当的错误状态和错误消息。

  1. 最后,我们可以构建 HTML 字符串并返回它:

    ...
        let mut html_string = String::from(USER_HTML_PREFIX);
        html_string.push_str(&user.to_html_string());
        html_string.push_str(format!(r#"<a href="
        /users/edit/{}">Edit User</a>"#, 
        user.uuid).as_ref());
        html_string.push_str(r#"<a href="/users">User 
        List</a>"#);
        html_string.push_str(USER_HTML_SUFFIX);
        Ok(RawHtml(html_string))
    

注意我们添加了两个链接,用于编辑此用户和转到 /users

在下一节中,我们将实现 get_users() 函数,以便应用程序可以处理 /users 端点。

实现 GET 用户

现在,让我们来实现 get_users()。以下是对上一章中该函数外观的快速回顾:

#[get("/users?<_pagination>", format = "text/html")]
pub async fn get_users(
    mut _db: Connection<DBConnection>,
    _pagination: Option<Pagination>,
) -> HtmlResponse {
    todo!("will implement later")
}

如前所述,我们应该准备我们将要使用的例程:

  1. src/models/user.rs 中创建一个名为 find_all 的方法,如下所示:

    use super::pagination::{Pagination};
    use crate::fairings::db::DBConnection;
    use rocket_db_pools::Connection;
    use rocket_db_pools::sqlx::{Acquire, FromRow, PgConnection};
    ...
    impl User {
    ...
        pub async fn find_all(
            db: &mut Connection<DBConnection>,
            pagination: Option<Pagination>,
    ) -> Result<(Vec<Self>, Option<Pagination>), 
        Box<dyn Error>> {
            if pagination.is_some() {
    return Self::find_all_with_pagination(db, 
                &(pagination.unwrap())).await;
            } else {
                return Self::find_all_without_
                pagination(db).await;
            }
        }
    }
    

find_all 函数的参数是 Connection,它包含连接池和可选的 Pagination

如果函数执行成功,我们希望返回一个包含 UserPagination 的向量。我们可以将其作为括号 () 中的 元组 包装,但是有可能数据库中没有更多的行,所以我们用 Option 包装返回的 Pagination。然后将其拆分为两个方法以使其更容易阅读:find_all_without_paginationfind_all_with_pagination

  1. 让我们稍微修改一下 src/models/pagination.rs,并添加 DEFAULT_LIMIT 以限制我们一次想要获取的用户数量:

    pub const DEFAULT_LIMIT: usize = 10;
    
  2. 我们可以在 src/models/user.rs 中创建并实现基例函数 find_all_without_pagination

    use super::pagination::{Pagination, DEFAULT_LIMIT};
    ...
    async fn find_all_without_pagination(db: &mut Connection<DBConnection>) -> Result<(Vec<Self>, Option<Pagination>), Box<dyn Error>> {
        let query_str = "SELECT * FROM users ORDER BY 
        created_at DESC LIMIT $1";
        let connection = db.acquire().await?;
        let users = sqlx::query_as::<_, Self>(query_str)
            .bind(DEFAULT_LIMIT as i32)
            .fetch_all(connection)
            .await?;
    }
    

类似于 find() 方法,我们定义 query_str 并执行查询,然后将 Vec<User> 结果绑定到 users 变量。但是,为什么这次我们传递的是 &mut Connection<DBConnection> 数据库连接池而不是 &mut PgConnection?我们先继续函数:

{
    ...
    let mut new_pagination: Option<Pagination> = None;
    if users.len() == DEFAULT_LIMIT {
        let query_str = "SELECT EXISTS(SELECT 1 FROM 
        users WHERE created_at < $1 ORDER BY 
        created_at DESC LIMIT 1)";
        let connection = db.acquire().await?;
        let exists = sqlx::query_as::<_,
        BoolWrapper>(query_str)
            .bind(&users.last().unwrap().created_at)
            .fetch_one(connection)
            .await?;
        if exists.0 {
            new_pagination = Some(Pagination {
                next: users.last().unwrap().
                created_at.to_owned(),
                limit: DEFAULT_LIMIT,
            });
        }
    }
    Ok((users, new_pagination))
}

我们然后准备返回的分页,首先将其设置为 None。如果获取的用户等于 DEFAULT_LIMIT,则可能存在下一行,所以我们向数据库执行第二次查询。由于我们不能重用单个连接,我们必须再次从数据库池中获取新的连接。这就是为什么我们向 find_allfind_all_without_pagination 传递 &mut Connection<DBConnection> 而不是 &mut PgConnection 的原因。如果存在下一行,我们可以返回包装在 Some() 中的分页。但是,什么是 BoolWrapper?我们需要设置一个类型来放置 "SELECT EXISTS..." 查询的结果。

  1. src/models/mod.rs 中添加 pub mod bool_wrapper; 并创建一个新文件,src/models/bool_wrapper.rs,内容如下:

    use rocket_db_pools::sqlx::FromRow;
    #[derive(FromRow)]
    pub struct BoolWrapper(pub bool);
    

不要忘记在 src/models/user.rs 中添加 use super::bool_wrapper::BoolWrapper;

  1. 现在,是时候实现 find_all_with_pagination 了:

    async fn find_all_with_pagination(db: &mut Connection<DBConnection>, pagination: &Pagination) -> Result<(Vec<Self>, Option<Pagination>), Box<dyn Error>> {
        let query_str =
            "SELECT * FROM users WHERE created_at < $1 
             ORDER BY created_at DESC LIMIT 
    2";
        let connection = db.acquire().await?;
        let users = sqlx::query_as::<_, Self>(query_str)
            .bind(&pagination.next)
            .bind(DEFAULT_LIMIT as i32)
            .fetch_all(connection)
            .await?;
        let mut new_pagination: Option<Pagination> = None;
        if users.len() == DEFAULT_LIMIT {
            let query_str = "SELECT EXISTS(SELECT 1 FROM 
            users WHERE created_at < $1 ORDER BY 
            created_at DESC LIMIT 1)";
            let connection = db.acquire().await?;
            let exists = sqlx::query_as::<_, 
            BoolWrapper>(query_str)
                .bind(&users.last().unwrap().created_at)
                .fetch_one(connection)
                .await?;
            if exists.0 {
                new_pagination = Some(Pagination {
                    next: users.last().unwrap().
                    created_at.to_owned(),
                    limit: DEFAULT_LIMIT,
                });
            }
        }
        Ok((users, new_pagination))
    }
    

私有方法与 find_all_without_pagination 的工作方式相同,但我们添加了一个 WHERE 条件以从某个点开始查询。

  1. 现在,是时候实现 get_users() 函数了:

    #[get("/users?<pagination>", format = "text/html")]
    pub async fn get_users(mut db: Connection<DBConnection>,
        pagination: Option<Pagination>) -> HtmlResponse {
        let (users, new_pagination) = User::find_all(&mut 
        db, pagination)
            .await
            .map_err(|_| Status::NotFound)?;
    }
    
  2. 在我们获取 usersnew_pagination 之后,我们可以构建返回值的 HTML:

    ...
    let mut html_string = String::from(USER_HTML_PREFIX);
    for user in users.iter() {
        html_string.push_str(&user.to_html_string());
        html_string
            .push_str(format!(r#"<a href="/users/{}">See 
             User</a><br/>"#, user.uuid).as_ref());
        html_string.push_str(
            format!(r#"<a href="/users/edit/{}">Edit 
            User</a><br/>"#, user.uuid).as_ref(),
        );
    }
    
  3. 如果我们有 new_pagination,则添加指向下一页的链接:

    if let Some(pg) = new_pagination {
        html_string.push_str(
            format!(
                r#"<a href="/users?pagination.next={}&
                pagination.limit={}">Next</a><br/>"#,
                &(pg.next.0).timestamp_nanos(),
                &pg.limit,
            )
            .as_ref(),
        );
    }
    

注意我们使用 timestamp_nanos() 将时间转换为 i64 以使其在 HTML 中更容易传输。

  1. 为了最终化函数,添加以下行:

    html_string.push_str(r#"<a href="/users/new">New user</a>"#);
    html_string.push_str(USER_HTML_SUFFIX);
    Ok(RawHtml(html_string))
    
  2. 现在,我们必须为 OurDateTime 实现 FromFormField,因为我们正在分页中使用 OurDateTime。在 src/models/our_date_time.rs 中添加所需的 use 指令:

    use chrono::{offset::Utc, DateTime, TimeZone};
    use rocket::data::ToByteUnit;
    
  3. 因为我们在 User 实现内部克隆 OurDateTime (users.last().unwrap().created_at 克隆 OurDateTime

    #[derive(Debug, sqlx::Type, Clone)]
    
  4. 对于 from_value 实现,我们只是从请求参数中解析 i64 并将其转换为 OurDateTime 对象:

     impl<'r> FromFormField<'r> for OurDateTime {
        fn from_value(field: ValueField<'r>) -> form::
        Result<'r, Self> {
            let timestamp = field.value.parse::<i64>()?;
            Ok(OurDateTime(
            Utc.timestamp_nanos(timestamp)))
        }
        ...
    }
    
  5. 但是,对于from_data,我们不得不更加参与其中,因为我们必须将请求转换为bytes,然后再将其转换回&str,最后转换为i64。首先,我们获取表单的 Rocket 限制:

    async fn from_data(field: DataField<'r, '_>) -> form::Result<'r, Self> {
        let limit = field
            .request
            .limits()
            .get("form")
            .unwrap_or_else(|| 8.kibibytes());
        ...
    }
    
  6. 然后,从请求中获取bytes

    let bytes = field.data.open(limit).into_bytes().await?;
    if !bytes.is_complete() {
        return Err((None, Some(limit)).into());
    }
    let bytes = bytes.into_inner();
    
  7. 最后,我们将bytes转换为&str,将其解析为i64,并转换为OurDateTime

    let time_string = std::str::from_utf8(&bytes)?;
    let timestamp = time_string.parse::<i64>()?;
    Ok(OurDateTime(Utc.timestamp_nanos(timestamp)))
    

现在,get_userget_users已经准备好了,但我们还没有任何数据。在下一节中,我们将实现new_usercreate_user函数,这样我们就可以通过 HTML 表单插入用户数据。

实现 POST 用户

要创建用户,我们将使用new_usercreate_user函数。new_user()函数相对容易实现;我们只需要提供一个带有用户填写表单的 HTML 页面。

让我们看看步骤:

  1. src/routes/user.rs中实现new_user()函数:

    #[get("/users/new", format = "text/html")]
    pub async fn new_user() -> HtmlResponse {
        let mut html_string = String::from(USER_HTML_
        PREFIX);
        html_string.push_str(
            r#"<form accept-charset="UTF-8" action="/
            users" autocomplete="off" method="POST">
        <div>
            <label for="username">Username:</label>
            <input name="username" type="text"/>
        </div>
        <div>
            <label for="email">Email:</label>
            <input name="email" type="email"/>
        </div>
        <div>
            <label for="password">Password:</label>
            <input name="password" type="password"/>
        </div>
        <div>
            <label for="password_confirmation">Password 
            Confirmation:</label>
            <input name="password_confirmation" 
            type="password"/>
        </div>
        <div>
            <label for="description">Tell us a little bit 
            more about yourself:</label>
            <textarea name="description"></textarea>
        </div>
        <button type="submit" value="Submit">Submit</
         button>
    </form>"#,
        );
        html_string.push_str(USER_HTML_SUFFIX);
        Ok(RawHtml(html_string))
    }
    

在 HTML 中,我们将form标签的action属性设置为"/users",将method属性设置为"POST"。这对应于我们应用程序中的create_user路由。在 HTML 页面上,我们有usernameemailpasswordpassword_confirmationdescription字段。然后我们插入提交按钮,并将html_string服务给客户端应用程序。

  1. 现在尝试运行应用程序,并在网页浏览器中打开http://127.0.0.1:8000/users/new。最后,我们有了可以在浏览器中渲染的内容:

Figure 6.1 - 新用户页面

图 6.1 - 新用户页面

  1. 如前所述,在实现create_user()函数之前,我们首先想要创建其他例程。由于 HTML 表单与User结构体没有一对一的映射,我们创建另一个结构体。将此结构体放在src/models/user.rs中:

    #[derive(Debug, FromForm)]
    pub struct NewUser<'r> {
        #[field(validate = len(5..20).or_else(msg!("name 
        cannot be empty")))]
        pub username: &'r str,
        pub email: &'r str,
        pub password: &'r str,
        #[field(validate = eq(self.password).or_
        else(msg!("password confirmation mismatch")))]
        pub password_confirmation: &'r str,
        #[field(default = "")]
        pub description: Option<&'r str>,
    }
    

我们为NewUser设置derive FromForm特质,因此我们可以在结构体字段之上使用field指令。此指令可以用来匹配请求负载字段名称与结构体字段名称,设置默认值,并验证字段内容。

如果 HTML 表单字段与结构体字段名称不同,我们可以使用字段指令进行重命名,如下所示:

#[field(name = uncased("html-field-name"))]

它也可以这样做:

#[field(name = "some-other-name")]

如果你使用 uncased 宏,那么包含任何大小写(例如HTML-FIELD-NAME)的负载 HTML 字段名称将与结构体字段名称匹配。

对于设置默认值,语法如下:

#[field(default = "default value")]

对于验证,语法如下:

#[field(validate = validation_function())]

rocket::form::validate模块中,有几个内置的验证函数:

  • contains:当字段作为字符串包含此子字符串,或者字段作为Vec包含此项目,或者OptionSome(value),或者rocket::form::ResultOk(value)时,此函数成功——例如,contains("foo")

  • eq:当字段值等于函数参数时,此函数成功。在 Rust 中,如果类型实现了std::cmp::PartialEq,则类型可以进行比较。你可以在NewUser结构体中看到示例,eq(self.password)

  • ext: 如果字段类型是 rocket::fs::TempFile 并且内容类型与函数参数匹配(例如,ext(rocket::http::ContentType::JavaScript)),则此函数成功。

  • len: 此函数在字段值的长度在参数范围内时成功。您可以在我们的 NewUser 结构体中看到示例,len(5..20)。在 Rust 中,我们定义范围为 from..to,但我们可以省略 to 部分。

  • ne: 如果字段值不等于提供的参数(!=),则此函数成功。实现 std::cmp::PartialEq 特性的类型也可以使用不等式运算符。

  • omits: 此函数是 contains 的反函数。

  • one_of: 如果值包含提供的参数中的任何一个项目,则此函数成功。参数必须是一个迭代器。

  • range: 此函数类似于 len,但它匹配字段值而不是字段长度的值。

  • with: 我们可以传递一个具有布尔返回类型的函数或闭包,并且当传递的函数或闭包返回 true 时,函数成功。

除了这些函数外,还有三个更多函数我们可以使用。这些函数几乎以相同的方式工作,但消息不同:

  • dbg_contains: 此函数也在错误消息中返回字段值。

  • dbg_eq: 此函数也在错误消息中返回项目值。

  • dbg_omits: 此函数也在错误消息中返回项目值。

NewUser 结构体中,我们可以看到我们还可以通过将验证函数与 .or_else("other message") 结合来设置自定义错误消息,如下例所示:

#[field(validate = len(5..20).or_else(msg!("name cannot be empty")))]

除了提供的函数外,我们还可以创建一个自定义验证函数。该函数应返回 form::Result<'_, ()>。我们希望实现自定义验证来检查密码强度和电子邮件的正确性。

第一项验证是密码验证。我们将使用一个名为 zxcvbn 的 crate。这个 crate 是 Dropbox 创建的同名 npm 模块的 Rust 版本。zxcvbn 库的灵感基于一个 "CorrectHorseBatteryStaple",与一些规则(如“必须包含至少八个字符,其中一个是大写字母,一个是小写字母,一个是数字”)相比,更容易记住且更难破解。

  1. zxcvbn = "2" 添加到 Cargo.toml 依赖项中,然后在 src/models/user.rs 中创建以下函数:

    use rocket::form::{self, Error as FormError, FromForm};
    use zxcvbn::zxcvbn;
    ...
    fn validate_password(password: &str) -> form::Result<'_, ()> {
        let entropy = zxcvbn(password, &[]);
        if entropy.is_err() || entropy.unwrap().score()
        < 3 {
            return Err(FormError::validation("weak 
            password").into());
        }
        Ok(())
    }
    

您可以将评分强度设置为最高四分,但这意味着我们无法将弱密码发送到服务器。目前,我们只是将密码评分阈设置为二。

  1. 之后,我们可以实现电子邮件正确性的验证。首先,将 regex = "1.5.4" 添加到 Cargo.toml 并在 src/models/user.rs 中添加此函数:

    use regex::Regex;
    ...
    fn validate_email(email: &str) -> form::Result<'_, ()> {
        const EMAIL_REGEX: &str = r#"(?:[a-z0-9!#$%&
        '*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]
        +)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\
        x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\
        x0e-\x7f])*")@(?:(?:a-z0-9?\.)+a-z0-9?|\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][
        0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][
        0-9]?|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08
        \x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01
        -\x09\x0b\x0c\x0e-\x7f])+)\])"#;
        let email_regex = Regex::new(EMAIL_REGEX).
        unwrap();
        if !email_regex.is_match(email) {
            return Err(FormError::validation("invalid 
            email").into());
        }
        Ok(())
    }
    
  2. 如果 NewUser 结构体的电子邮件:

    pub struct NewUser<'r> {
        ...
        #[field(validate = validate_email().
        or_else(msg!("invalid email")))]
        pub email: &'r str,
        #[field(validate = validate_password()
        .or_else(msg!("weak password")))]
        pub password: &'r str,
        ...
    }
    
  3. 接下来,我们可以为User结构体实现create()方法。出于安全考虑,我们将使用安全的密码散列函数来散列密码。在 2021 年,人们认为md5是一个非常不安全的散列函数,而sha1sha3也被认为是不安全的散列函数,所以我们不会使用这些函数。人们通常考虑使用bcryptscryptargon2。现在,argon2有一个版本,argon2id,它对旁路攻击和 GPU 破解攻击具有抵抗力,所以我们将使用argon2作为密码散列实现。

create()方法还存在另一种可能的攻击:将"<script>console.log("hack")</script>"作为描述。我们可以通过使用名为ammonia的 HTML 清理库来解决这个问题。

要为create()方法添加argon2ammonia,请在Cargo.toml中添加以下行:

ammonia = "3.1.2"
argon2 = "0.3"
rand_core = {version = "0.6", features = ["std"]}
  1. 我们可以在src/models/mod.rs中创建一个用于清理 HTML 的函数:

    use ammonia::Builder;
    use std::collections::hash_set::HashSet;
    pub fn clean_html(src: &str) -> String {
        Builder::default()
            .tags(HashSet::new())
            .clean(src)
            .to_string()
    }
    

来自ammonia::Builder::default的默认清理器允许许多 HTML 标签,人们仍然可以破坏网站。为了解决这个问题,我们传递一个空的HashSet来禁止任何 HTML 标签。

  1. 在密码散列和 HTML 清理就绪后,是时候为User结构体实现create()方法了。在src/models/user.rs中添加所需的use指令:

    use super::clean_html;
    use argon2::{password_hash::{rand_core::OsRng, PasswordHasher, SaltString},Argon2};
    
  2. 将以下行放入impl User块中:

    pub async fn create<'r>(
        connection: &mut PgConnection,
        new_user: &'r NewUser<'r>,
    ) -> Result<Self, Box<dyn Error>> {
        let uuid = Uuid::new_v4();
        let username = &(clean_html(new_user.username));
        let description = &(new_user.description.map(
        |desc| clean_html(desc)));
    }
    

我们为新的User实例生成一个新的 UUID。之后,我们清理用户名值和描述值。我们不清理电子邮件和密码,因为我们已经使用正则表达式验证了电子邮件的内容,并且我们不会在 HTML 中显示任何密码。

  1. 接下来,添加以下行以散列密码:

    let salt = SaltString::generate(&mut OsRng);
    let argon2 = Argon2::default();
    let password_hash = argon2.hash_password(new_user.password.as_bytes(), &salt);
    if password_hash.is_err() {
        return Err("cannot create password hash".into());
    }
    
  2. 接下来,我们向我们的数据库服务器发送INSERT语句并返回插入的行。添加以下行:

    let query_str = r#"INSERT INTO users
    (uuid, username, email, password_hash, description, status)
    VALUES
    ($1, $2, $3, $4, $5, $6)
    RETURNING *"#;
    Ok(sqlx::query_as::<_, Self>(query_str)
        .bind(uuid)
        .bind(username)
        .bind(new_user.email)
        .bind(password_hash.unwrap().to_string())
        .bind(description)
        .bind(UserStatus::Inactive)
        .fetch_one(connection)
        .await?)
    
  3. User::create()方法就绪时,我们可以实现create_user()函数。当应用程序成功创建用户时,最好通过重定向到get_user来显示结果。为此,我们可以使用rocket::response::Redirect类型而不是RawHtml

此外,如果存在错误,最好重定向到new_user()并显示错误,以便用户可以修复输入错误。我们可以通过获取NewUser验证的错误或任何其他错误,并将带有嵌入式错误信息的重定向到new_user()函数来实现这一点。

我们可以使用rocket::form::Contextual来获取请求表单值的错误,它是包含错误信息的表单类型的代理。我们还将使用rocket::response::Flash向网络浏览器发送一次性 cookie,并使用rocket::request::FlashMessage在路由上检索消息。将这些行添加到src/routes/user.rs中:

use crate::models::{pagination::Pagination, user::{NewUser, User}};
use rocket::form::{Contextual, Form};
use rocket::request::FlashMessage;
use rocket::response::{content::RawHtml, Flash, Redirect};
  1. create_user()函数的签名更改为以下内容:

    #[post("/users", format = "application/x-www-form-urlencoded", data = "<user_context>")]
    pub async fn create_user<'r>(
        mut db: Connection<DBConnection>,
        user_context: Form<Contextual<'r, NewUser<'r>>>,
    ) -> Result<Flash<Redirect>, Flash<Redirect>> {}
    

由于我们正在发送POST数据,浏览器将发送Content-Type"application/x-www-form-urlencoded",因此我们必须相应地更改格式。

此外,查看请求参数;我们不是插入Form<NewUser<'r>>,而是在参数中间插入Contextual类型。我们还更改了返回值,将其更改为Result<Flash<Redirect>, Flash<Redirect>>

  1. 现在,让我们实现函数体。将以下行添加到函数体中:

    if user_context.value.is_none() {
        let error_message = format!(
            "<div>{}</div>",
            user_context
                .context
                .errors()
                .map(|e| e.to_string())
                .collect::<Vec<_>>()
                .join("<br/>")
        );
        return Err(Flash::error(Redirect::to("/
        users/new"), error_message));
    }
    

如果user_contextvalue,这意味着 Rocket 成功转换了请求有效负载并将其放入value属性中。我们分支并返回带有Flash消息和Redirect指令的Error"/users/new"

  1. 下一个实现是如果 Rocket 成功解析NewUser。在true分支中添加以下行:

    let new_user = user_context.value.as_ref().unwrap();
    let connection = db.acquire().await.map_err(|_| {
        Flash::error(
            Redirect::to("/users/new"),
            "<div>Something went wrong when creating 
             user</div>",
        )
    })?;
    let user = User::create(connection, new_user).await.map_err(|_| {
        Flash::error(
            Redirect::to("/users/new"),
            "<div>Something went wrong when creating 
            user</div>",
        )
    })?;
    Ok(Flash::success(
        Redirect::to(format!("/users/{}", user.uuid)),
        "<div>Successfully created user</div>",
    ))
    

就像get_user()函数一样,我们创建一个例程来获取数据库连接,对数据库服务器执行INSERT操作,并生成成功的Redirect响应。但是,当发生错误时,我们不是返回 HTML,而是生成带有适当路径和消息的Redirect指令。

  1. 我们现在需要更改new_user()get_user()函数,以便能够处理传入的FlashMessage请求守卫。首先,对于new_user()函数,将签名更改为以下形式:

    get_user(
        mut db: Connection<DBConnection>,
        uuid: &str,
        flash: Option<FlashMessage<'_>>,
    )
    
  2. 因为闪存消息可能并不总是存在,所以我们将其包裹在Option中。在let mut html_string = String::from(USER_HTML_PREFIX);之后,在函数体中添加以下行:

    if flash.is_some() {
        html_string.push_str(flash.unwrap().message());
    }
    
  3. 我们几乎以相同的方式修改了new_user()函数。将函数签名更改为以下形式:

    new_user(flash: Option<FlashMessage<'_>>)
    

然后,在USER_HTML_PREFIX之后添加以下行:

if flash.is_some() {
    html_string.push_str(flash.unwrap().message());
}
  1. 现在,是时候尝试创建用户数据了。如果一切正确,你应该看到以下屏幕。错误消息如下:

图 6.2 - 失败时的错误消息

![img/Figure_6.2_B16825.jpg]

图 6.2 - 失败时的错误消息

成功消息如下所示:

图 6.3 - 成功消息

图 6.3 - 成功消息

图 6.3 - 成功消息

在接下来的章节中,我们将继续更新用户和删除用户。

实现 PUT 和 PATCH 用户

要更新用户,我们需要一个像new_user()这样的页面,但我们希望表单预先填充现有数据。我们还想为用户添加一个字段来确认旧密码。让我们看看步骤:

  1. edit_user()函数签名更改为以下形式:

    #[get("/users/edit/<uuid>", format = "text/html")]
    pub async fn edit_user(mut db: Connection<DBConnection>,    uuid: &str, flash: Option<FlashMessage<'_>>) -> HtmlResponse {}
    
  2. 要获取现有用户,在函数体块内部添加以下行:

    let connection = db
        .acquire()
        .await
        .map_err(|_| Status::InternalServerError)?;
    let user = User::find(connection, uuid)
        .await
        .map_err(|_| Status::NotFound)?;
    
  3. 之后,我们可以添加 HTML,例如new_user(),但这次,我们还包含了用户现有的数据。在edit_user()函数体内部添加以下行:

    let mut html_string = String::from(USER_HTML_PREFIX);
    if flash.is_some() {
        html_string.push_str(flash.unwrap().message());
    }
    html_string.push_str(
        format!(
            r#"<form accept-charset="UTF-8" action="/
            users/{}" autocomplete="off" method="POST">
    <input type="hidden" name="_METHOD" value="PUT"/>
    <div>
        <label for="username">Username:</label>
        <input name="username" type="text" value="{}"/>
    </div>
    <div>
        <label for="email">Email:</label>
        <input name="email" type="email" value="{}"/>
    </div>
    <div>
        <label for="old_password">Old password:</label>
        <input name="old_password" type="password"/>
    </div>
    <div>
        <label for="password">New password:</label>
        <input name="password" type="password"/>
    </div>
    <div>
        <label for="password_confirmation">Password 
        Confirmation:</label>
        <input name="password_confirmation" type=
        "password"/>
    </div>
    <div>
        <label for="description">Tell us a little bit more 
        about yourself:</label>
        <textarea name="description">{}</textarea>
    </div>
    <button type="submit" value="Submit">Submit</button>
    </form>"#,
            &user.uuid,
            &user.username,
            &user.email,
            &user.description.unwrap_or_else(|| 
            "".to_string()),
        )
        .as_ref(),
    );
    html_string.push_str(USER_HTML_SUFFIX);
    Ok(RawHtml(html_string))
    

在此之后,我们之前在之前的页面中实现的指向"/users/edit/{}"的所有先前链接都应该正常工作。

如果你查看代码,我们会看到表单的method属性具有"POST"值。原因是 HTML 标准表示表单方法只能是GETPOST。大多数网络浏览器会将无效的方法,如PUTPATCH,更改为POST

一些 Web 框架通过发送一个包含隐藏值在请求有效负载中的POST请求来绕过这个限制。我们将使用第二种方式通过添加一个新字段name="_METHOD"并赋予其"PUT"值来实现更新用户的实现。

就像create_user()一样,我们希望在出现错误时执行update_function()以重定向到edit_user(),同样在成功更新用户后,我们也想执行update_function()以重定向到用户页面。

由于我们正在添加新的有效负载,_METHODold_password,我们需要一个与NewUser不同的新类型:

  1. src/models/user.rs中创建一个名为EditedUser的新结构体:

    #[derive(Debug, FromForm)]
    pub struct EditedUser<'r> {
        #[field(name = "_METHOD")]
        pub method: &'r str,
        #[field(validate = len(5..20).or_else(msg!("name 
        cannot be empty")))]
        pub username: &'r str,
        #[field(validate = validate_email()
        .or_else(msg!("invalid email")))]
        pub email: &'r str,
        pub old_password: &'r str,
        pub password: &'r str,
        pub password_confirmation: &'r str,
        #[field(default = "")]
        pub description: Option<&'r str>,
    }
    
  2. 如果old_password中没有值,我们希望跳过更新密码,但如果old_password中有值,我们想确保密码强度足够且password_confirmation的内容与密码相同。在src/models/user.rs中创建一个函数:

    fn skip_validate_password<'v>(password: &'v str, old_password: &'v str, password_confirmation: &'v str) -> form::Result<'v, ()> {
        if old_password.is_empty() {
            return Ok(());
        }
        validate_password(password)?;
        if password.ne(password_confirmation) {
            return Err(FormError::validation("password 
            confirmation mismatch").into());
        }
        Ok(())
    }
    
  3. 然后,使用密码字段上方的指令中的验证函数:

    #[field(validate = skip_validate_password(self.old_password, self.password_confirmation))]
    pub password: &'r str,
    

我们需要一个方法让User根据EditedUser的内容更新数据库行。此方法还将验证old_password的哈希值以确保EditedUser有效。

  1. src/models/user.rs中添加use指令:

    use argon2::{password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, Argon2};
    use chrono::offset::Utc;
    
  2. src/models/user.rs中的impl User块内部创建一个新方法:

    pub async fn update<'r>(db: &mut Connection<DBConnection>, uuid: &'r str, user: &'r EditedUser<'r>) -> Result<Self, Box<dyn Error>> {}
    
  3. 在方法内部,从数据库中获取旧用户数据:

    let connection = db.acquire().await?;
    let old_user = Self::find(connection, uuid).await?;
    
  4. 准备更新数据:

    let now = OurDateTime(Utc::now());
    let username = &(clean_html(user.username));
    let description = &(user.description.map(|desc| clean_html(desc)));
    
  5. 由于我们可以根据是否有old_password来更改密码或跳过更改密码,准备查询项:

    let mut set_strings = vec![
        "username = $1",
        "email = $2",
        "description = $3",
        "updated_at = $4",
    ];
    let mut where_string = "$5";
    let mut password_string = String::new();
    let is_with_password = !user.old_password.is_empty();
    
  6. 如果我们正在更新密码,我们需要验证old_password与现有密码是否匹配。我们还想对新的密码进行哈希处理,并将密码添加到set_strings中。追加以下行:

    if is_with_password {
        let old_password_hash = PasswordHash::
        new(&old_user.password_hash)
            .map_err(|_| "cannot read password hash")?;
        let argon2 = Argon2::default();
        argon2
            .verify_password(user.password.as_bytes(), 
            &old_password_hash)
            .map_err(|_| "cannot confirm old password")?;
        let salt = SaltString::generate(&mut OsRng);
        let new_hash = argon2
            .hash_password(user.password.as_bytes(), 
            &salt)
            .map_err(|_| "cannot create password hash")?;
        password_string.push_str(
        new_hash.to_string().as_ref());
        set_strings.push("password_hash = $5");
        where_string = "$6";
    }
    
  7. 然后,构建更新用户的UPDATE语句,执行该语句,并返回User实例:

    let query_str = format!(
        r#"UPDATE users SET {} WHERE uuid = {} RETURNING 
        *"#,
        set_strings.join(", "),
        where_string,
    );
    let connection = db.acquire().await?;
    let mut binded = sqlx::query_as::<_, Self>(&query_str)
        .bind(username)
        .bind(user.email)
        .bind(description)
        .bind(&now);
    if is_with_password {
        binded = binded.bind(&password_string);
    }
    let parsed_uuid = Uuid::parse_str(uuid)?;
    Ok(binded.bind(parsed_uuid).fetch_one(connection).await?)
    
  8. 现在,是时候使用EditedUser并实现update_user()了。在use指令中追加EditedUser

    use crate::models::{pagination::Pagination, user::{EditedUser, NewUser, User}};
    
  9. src/routes/user.rs中创建update_user()函数:

    #[post("/users/<uuid>", format = "application/x-www-form-urlencoded", data = "<user_context>")]
    pub async fn update_user<'r>(db: Connection<DBConnection>, uuid: &str, user_context: Form<Contextual<'r, EditedUser<'r>>>) -> Result<Flash<Redirect>, Flash<Redirect>> {}
    
  10. 在该函数中,我们需要检查表单是否正确。追加以下行:

    if user_context.value.is_none() {
        let error_message = format!(
            "<div>{}</div>",
            user_context
                .context
                .errors()
                .map(|e| e.to_string())
                .collect::<Vec<_>>()
                .join("<br/>")
        );
        return Err(Flash::error(
            Redirect::to(format!("/users/edit/{}", uuid)),
            error_message,
        ));
    }
    
  11. 我们可以告诉应用程序根据"_METHOD"来处理。追加以下行:

    let user_value = user_context.value.as_ref().unwrap();
    match user_value.method {
        "PUT" => put_user(db, uuid, user_context).await,
        "PATCH" => patch_user(db, uuid, user_
        context).await,
        _ => Err(Flash::error(
            Redirect::to(format!("/users/edit/{}", uuid)),
            "<div>Something went wrong when updating 
            user</div>",
        )),
    }
    

我们不会浪费之前定义的函数。我们正在使用put_user()patch_user()函数。

  1. 现在,是时候实现put_user()函数了。更改put_user()函数的签名:

    #[put("/users/<uuid>", format = "application/x-www-form-urlencoded", data = "<user_context>")]
    pub async fn put_user<'r>(mut db: Connection<DBConnection>, uuid: &str, user_context: Form<Contextual<'r, EditedUser<'r>>>) -> Result<Flash<Redirect>, Flash<Redirect>> {}
    

然后,按照以下方式实现该函数:

let user_value = user_context.value.as_ref().unwrap();
let user = User::update(&mut db, uuid, user_value).await.map_err(|_| {
    Flash::error(
        Redirect::to(format!("/users/edit/{}", uuid)),
        "<div>Something went wrong when updating 
        user</div>",
    )
})?;
Ok(Flash::success(
    Redirect::to(format!("/users/{}", user.uuid)),
    "<div>Successfully updated user</div>",
))
  1. 对于patch_user()函数,我们可以直接重用put_user()函数。编写patch_user()的代码:

    #[patch("/users/<uuid>", format = "application/x-www-form-urlencoded", data = "<user_context>")]
    pub async fn patch_user<'r>(db: Connection<DBConnection>, uuid: &str, user_context: Form<Contextual<'r, EditedUser<'r>>>) -> Result<Flash<Redirect>, Flash<Redirect>> {
        put_user(db, uuid, user_context).await
    }
    
  2. 最后,在src/main.rs中追加新的路由:

    user::edit_user,
    user::update_user,
    user::put_user,
    

剩下的唯一端点是用于删除用户。让我们在下一节继续讨论。

实现 DELETE 用户

删除用户的第一件事是创建一个针对User结构体的方法。让我们看看步骤:

  1. src/models/user.rsimpl User块中编写删除用户的函数:

    pub async fn destroy(connection: &mut PgConnection, uuid: &str) -> Result<(), Box<dyn Error>> {
        let parsed_uuid = Uuid::parse_str(uuid)?;
        let query_str = "DELETE FROM users WHERE uuid = 
        $1";
        sqlx::query(query_str)
            .bind(parsed_uuid)
            .execute(connection)
            .await?;
        Ok(())
    }
    

然后,我们可以在src/routes/user.rs中实现delete_user()函数:

#[delete("/users/<uuid>", format = "application/x-www-form-urlencoded")]
pub async fn delete_user(
    mut db: Connection<DBConnection>,
    uuid: &str,
) -> Result<Flash<Redirect>, Flash<Redirect>> {
    let connection = db.acquire().await.map_err(|_| {
        Flash::error(
            Redirect::to("/users"),
            "<div>Something went wrong when deleting 
            user</div>",
        )
    })?;
    User::destroy(connection, uuid).await.map_err(|_| {
        Flash::error(
            Redirect::to("/users"),
            "<div>Something went wrong when deleting 
            user</div>",
        )
    })?;
    Ok(Flash::success(
        Redirect::to("/users"),
        "<div>Successfully deleted user</div>",
    ))
}
  1. 问题在于 HTML 中的链接和表单都不允许使用DELETE方法。我们不能使用链接,因为任何看到它的机器人都会在上面爬行,可能会意外执行资源删除。就像更新用户一样,我们可以使用表单并向新的端点发送POST请求。在src/routes/user.rs中添加一个新函数:

    #[post("/users/delete/<uuid>", format = "application/x-www-form-urlencoded")]
    pub async fn delete_user_entry_point(
        db: Connection<DBConnection>,
        uuid: &str,
    ) -> Result<Flash<Redirect>, Flash<Redirect>> {
        delete_user(db, uuid).await
    }
    
  2. 不要忘记在src/main.rs中添加路由:

    user::delete_user,
    user::delete_user_entry_point,
    
  3. 现在,我们可以在哪里创建一个用于删除用户的表单?让我们在get_user()页面上做这件事。按照以下方式添加表单的 HTML:

    html_string
        .push_str(format!(r#"<a href="/users/edit/{}">Edit 
        User</a><br/>"#, user.uuid).as_ref());
    html_string.push_str(
        format!(
            r#"<form accept-charset="UTF-8" action="/
            users/delete/{}" autocomplete="off" 
            method="POST"><button type="submit" 
            value="Submit">Delete</button></form>"#,
            user.uuid
        )
        .as_ref(),
    );
    

我们现在已经完成了所有用于管理用户的端点。尝试添加用户,看看分页是如何工作的,或者尝试改进 HTML。你还可以尝试激活用户以进行挑战!

摘要

在本章中,我们通过实现创建、读取、更新和删除用户路由,学习了用户实体的基本操作。

我们还了解了 Rocket 框架的各个模块,例如 RawHtml、Redirect、Contextual、Flash、Form 和 FlashMessage。

在实现端点的同时,我们还了解了数据库操作,如查询、插入、更新和删除数据库服务器上的对象。

在下一章中,我们将学习更多关于错误处理和创建我们自己的错误类型的内容。

第七章:第七章:Rust 和 Rocket 中的错误处理

在上一章中,我们学习了如何创建端点和 SQL 查询来处理User实体的管理。在本章中,我们将学习更多关于 Rust 和 Rocket 中的错误处理。学习本章的概念后,你将能够实现 Rocket 应用程序中的错误处理。

我们还将讨论更多在 Rust 和 Rocket 中处理错误的方法,包括使用panic!宏来指示不可恢复的错误,以及使用OptionResult、创建自定义Error类型和记录生成的错误来捕获panic!宏。

在本章中,我们将涵盖以下主要主题:

  • 使用 panic!

  • 使用 Option

  • 返回 Result

  • 创建自定义错误类型

  • 记录错误

技术要求

对于本章,我们与上一章有相同的技术要求。我们需要一个 Rust 编译器、一个文本编辑器、一个 HTTP 客户端和一个 PostgreSQL 数据库服务器。

你可以在github.com/PacktPublishing/Rust-Web-Development-with-Rocket/tree/main/Chapter07找到本章的源代码。

使用 panic!

要理解 Rust 中的错误处理,我们需要从panic!宏开始。当应用程序遇到不可恢复的错误且继续应用程序没有意义时,我们可以使用panic!宏。如果应用程序遇到panic!,应用程序将发出后迹并终止。

让我们尝试在上一章创建的程序中使用panic!。假设我们希望在初始化 Rocket 之前让应用程序读取一个秘密文件。如果应用程序找不到这个秘密文件,它将不会继续。

让我们开始吧:

  1. src/main.rs中添加以下行:

    use std::env;
    
  2. rocket()函数中的同一文件,添加以下行:

    let secret_file_path = env::current_dir().unwrap().join("secret_file");
    if !secret_file_path.exists() {
        panic!("secret does not exists");
    }
    
  3. 之后,尝试在当前工作目录下不创建名为secret_file的空文件的情况下执行cargo run。你应该看到以下输出:

    thread 'main' panicked at 'secret does not exists', src/main.rs:15:9
    note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
    
  4. 现在,尝试使用RUST_BACKTRACE=1 cargo run再次运行应用程序。你应该在终端中看到类似于以下的后迹输出:

    RUST_BACKTRACE=1 cargo run 
    Finished dev [unoptimized + debuginfo] target(s) 
        in 0.18s
         Running `target/debug/our_application`
    thread 'main' panicked at 'secret does not exists', src/main.rs:15:9
    stack backtrace:
    ...
      14: our_application::main
                 at ./src/main.rs:12:36
      15: core::ops::function::FnOnce::call_once
                 at /rustc/59eed8a2aac0230a8b5
                 3e89d4e99d55912ba6b35/library/core/
                 src/ops/function.rs:227:5
    note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.
    
  5. 有时候,我们不想在panic!宏中恐慌后进行资源释放,因为我们希望应用程序尽快退出。我们可以通过在Cargo.toml中设置panic = "abort"来跳过释放,在使用的配置文件下。设置此配置将使我们的二进制文件更小,退出更快,操作系统将在稍后清理它。让我们尝试这样做。在Cargo.toml中设置以下行并再次运行应用程序:

    [profile.dev]
    panic = "abort"
    

现在我们知道了如何使用panic!,让我们看看如何在下一节中捕获它。

捕获 panic!

除了使用 panic!,我们还可以在 Rust 代码中使用 todo!unimplemented! 宏。这些宏在原型设计时非常有用,因为它们会在调用 panic! 的同时允许代码在编译时进行类型检查。

但是,为什么当我们使用 todo! 调用一个路由时,Rocket 不会关闭呢?如果我们检查 Rocket 的源代码,会发现 src::panic 中有一个 catch_unwind 函数,它可以用来捕获抛出异常的函数。让我们看看 Rocket 源代码中的这段代码,core/lib/src/server.rs:

let fut = std::panic::catch_unwind(move || run())
         .map_err(|e| panic_info!(name, e))
         .ok()?;

在这里,run() 是一个路由处理函数。每次我们调用一个抛出异常的路由时,前面的程序会将异常转换为结果的 Err 变体。尝试移除我们之前添加的 secret_file_path 程序并运行应用程序。现在,创建一个用户并尝试进入用户帖子。例如,创建一个具有 95a54c16-e830-45c9-ba1d-5242c0e4c18f UUID 的用户。尝试打开 http://127.0.0.1/users/95a54c16-e830-45c9-ba1d-5242c0e4c18f/posts。由于我们只在函数体中放置了 todo!("will implement later"),应用程序将抛出异常,但前面的 catch_unwind 函数将捕获这个异常并将其转换为错误。请注意,如果我们在 Cargo.toml 中设置 panic = "abort",则 catch_unwind 将不会工作。

在常规的工作流程中,我们通常不希望使用 panic!,因为抛出异常会中断一切,程序将无法继续。如果 Rocket 框架没有捕获 panic!,并且其中一个路由处理函数抛出异常,那么这个单独的错误将关闭应用程序,并且将没有东西来处理其他请求。但是,如果我们想在遇到不可恢复的错误时终止 Rocket 应用程序,我们应该如何做呢?让我们看看下一节如何实现。

使用关闭

为了在路由处理函数遇到不可恢复的错误时平稳关闭,我们可以使用 rocket::Shutdown 请求守卫。记住,请求守卫是我们提供给路由处理函数的参数。

要查看 Shutdown 请求守卫的实际效果,让我们尝试在我们的应用程序中实现它。使用之前的应用程序,在 src/routes/mod.rs 中添加一个新的路由 /shutdown:

use rocket::Shutdown;
...
#[get("/shutdown")]
pub async fn shutdown(shutdown: Shutdown) -> &'static str {
    // suppose this variable is from function which 
    // produces irrecoverable error
    let result: Result<&str, &str> = Err("err");
    if result.is_err() {
        shutdown.notify();
        return "Shutting down the application.";
    }
    return "Not doing anything.";
}

尝试在 src/main.rs 中添加 shutdown() 函数。之后,重新运行应用程序,并向 /shutdown 发送 HTTP 请求,同时监控终端上的应用程序输出。应用程序应该能够平稳关闭。

在接下来的两个部分中,我们将看看如何使用 OptionResult 作为处理错误的替代方法。

使用 Option

在编程中,一个例程可能会产生正确的结果或遇到问题。一个经典的例子是除以零。在数学上,除以零是未定义的。如果一个应用程序有一个除以某物的例程,并且该例程遇到零作为输入,则应用程序不能返回任何数字。我们希望应用程序返回另一种类型而不是数字。我们需要一种可以持有多种数据变体的类型。

在 Rust 中,我们可以定义一个 enum 类型,这是一种可以有不同的数据变体的类型。一个 enum 类型可能如下所示:

enum Shapes {
    None,
    Point(i8),
    Line(i8, i8),
    Rectangle {
        top: (i8, i8),
        length: u8,
        height: u8,
    },
}

PointLine 被说成有 Rectangle,而 Rectangle 也可以称为 类似结构体的枚举 变体。

如果 enum 的所有成员都没有数据,我们可以在成员上添加一个区分符。以下是一个例子:

enum Color {
    Red,         // 0
    Green = 127, // 127
    Blue,        // 128
}

我们可以将 enum 赋值给一个变量,并在函数中使用该变量,如下所示:

fn do_something(color: Color) -> Shapes {
    let rectangle = Shapes::Rectangle {
        top: (0, 2),
        length: 10,
        height: 8,
    };
    match color {
        Color::Red => Shapes::None,
        Color::Green => Shapes::Point(10),
        _ => rectangle,
    }
}

回到错误处理,我们可以使用 enum 来传达我们的代码中存在错误。回到除以零的情况,这里有一个例子:

enum Maybe {
    WeCannotDoIt,
    WeCanDoIt(i8),
}
fn check_divisible(input: i8) -> Maybe {
    if input == 0 {
        return Maybe::WeCannotDoIt;
    }
    Maybe::WeCanDoIt(input)
}

之前模式返回或不返回内容的情况非常常见,因此 Rust 在标准库中有一个自己的枚举来表示我们是否有内容,称为 std::option::Option

pub enum Option<T> {
    None,
    Some(T),
}

Some(T) 用于传达我们拥有 T,而 None 显然用于传达我们没有 T。我们在之前的代码中使用了 Option。例如,我们在 User 结构体中使用了它:

struct User {
    ...
    description: Option<String>,
    ...
}

我们还使用了 Option 作为函数参数或返回类型:

find_all(..., pagination: Option<Pagination>) -> (..., Option<Pagination>), ... {}

我们可以使用 Option 做很多事情。假设我们有两个变量,we_have_itwe_do_not_have_it

let we_have_it: Option<usize> = Some(1);
let we_do_not_have_it: Option<usize> = None;
  • 我们可以做的事情之一是模式匹配并使用其内容:

    match we_have_it {
        Some(t) => println!("The value = {}", t),
        None => println!("We don't have it"),
    };
    
  • 如果我们关心 we_have_it 的内容,我们可以以更方便的方式处理它:

    if let Some(t) = we_have_it {
        println!("The value = {}", t);
    }
    
  • 如果内部类型实现了 std::cmp::Eqstd::cmp::Ord,则 Option 可以进行比较,即内部类型可以使用 ==!=> 等比较运算符进行比较。注意我们使用了 assert!,这是一个用于测试的宏:

    assert!(we_have_it != we_do_not_have_it);
    
  • 我们可以检查一个变量是 Some 还是 None

    assert!(we_have_it.is_some());
    assert!(we_do_not_have_it.is_none());
    
  • 我们也可以通过展开 Option 来获取内容。但是,有一个注意事项;展开 None 将会引发恐慌,所以在展开 Option 时要小心。注意我们使用了 assert_eq!,这是一个用于测试的宏,用于确保相等性:

    assert_eq!(we_have_it.unwrap(), 1);
    // assert_eq!(we_do_not_have_it.unwrap(), 1); 
    // will panic
    
  • 我们还可以使用 expect() 方法。此方法与 unwrap() 的行为相同,但我们可以使用自定义消息:

    assert_eq!(we_have_it.expect("Oh no!"), 1);
    // assert_eq!(we_do_not_have_it.expect("Oh no!"), 1); // will panic
    
  • 我们可以展开并设置默认值,这样在展开 None 时就不会引发恐慌:

    assert_eq!(we_have_it.unwrap_or(42), 1);
    assert_eq!(we_do_not_have_it.unwrap_or(42), 42);
    
  • 我们可以使用闭包展开并设置默认值:

    let x = 42;
    assert_eq!(we_have_it.unwrap_or_else(|| x), 1);
    assert_eq!(we_do_not_have_it.unwrap_or_else(|| x), 42);
    
  • 我们可以使用 map()map_or()map_or_else() 将包含的值转换为其他类型:

    assert_eq!(we_have_it.map(|v| format!("The value = {}", v)), Some("The value = 1".to_string()));
    assert_eq!(we_do_not_have_it.map(|v| format!("The value = {}", v)), None);
    assert_eq!(we_have_it.map_or("Oh no!".to_string(), |v| format!("The value = {}", v)), "The value = 1".to_string());
    assert_eq!(we_do_not_have_it.map_or("Oh no!".to_string(), |v| format!("The value = {}", v)), "Oh no!".to_string());
    assert_eq!(we_have_it.map_or_else(|| "Oh no!".to_string(), |v| format!("The value = {}", v)), "The value = 1".to_string());
    assert_eq!(we_do_not_have_it.map_or_else(|| "Oh no!".to_string(), |v| format!("The value = {}", v)), "Oh no!".to_string());
    

还有其他重要的方法,你可以在 std::option::Option 的文档中查看。尽管我们可以使用 Option 来处理有或没有某种情况的情况,但它并不能传达“出了问题”的消息。我们可以在下一部分使用与 Option 类似的另一个类型来实现这一点。

返回 Result

在 Rust 中,我们有 std::result::Result 枚举,它的工作方式类似于 Option,但 Result 类型更多的是说“我们有它”或“我们有这个错误”。就像 Option 一样,Result 是可能的 T 类型或可能的 E 错误的枚举类型:

enum Result<T, E> {
   Ok(T),
   Err(E),
}

回到除以零的问题,看看以下简单的例子:

fn division(a: usize, b: usize) -> Result<f64, String> {
    if b == 0 {
        return Err(String::from("division by zero"));
    }
    return Ok(a as f64 / b as f64);
}

我们不希望除以 0,所以我们在前面的函数中返回一个错误。

Option 类似,Result 有许多方便的特性我们可以使用。假设我们有 we_have_itwe_have_error 变量:

let we_have_it: Result<usize, &'static str> = Ok(1);
let we_have_error: Result<usize, &'static str> = Err("Oh no!");
  • 我们可以使用模式匹配来获取值或错误:

    match we_have_it {
        Ok(v) => println!("The value = {}", v),
        Err(e) => println!("The error = {}", e),
    };
    
  • 或者,我们可以使用 if let 来解构并获取值或错误:

    if let Ok(v) = we_have_it {
        println!("The value = {}", v);
    }
    if let Err(e) = we_have_error {
        println!("The error = {}", e);
    }
    
  • 我们可以比较 Ok 变体和 Err 变体:

    assert!(we_have_it != we_have_error);
    
  • 我们可以检查一个变量是 Ok 变体还是 Err 变体:

    assert!(we_have_it.is_ok());
    assert!(we_have_error.is_err());
    
  • 我们可以将 Result 转换为 Option

    assert_eq!(we_have_it.ok(), Some(1));
    assert_eq!(we_have_error.ok(), None);
    assert_eq!(we_have_it.err(), None);
    assert_eq!(we_have_error.err(), Some("Oh no!"));
    
  • 就像 Option 一样,我们可以使用 unwrap()unwrap_or()unwrap_or_else()

    assert_eq!(we_have_it.unwrap(), 1);
    // assert_eq!(we_have_error.unwrap(), 1); 
    // panic
    assert_eq!(we_have_it.expect("Oh no!"), 1);
    // assert_eq!(we_have_error.expect("Oh no!"), 1);
    // panic
    assert_eq!(we_have_it.unwrap_or(0), 1);
    assert_eq!(we_have_error.unwrap_or(0), 0);
    assert_eq!(we_have_it.unwrap_or_else(|_| 0), 1);
    assert_eq!(we_have_error.unwrap_or_else(|_| 0), 0);
    
  • 此外,我们可以使用 map()map_err()map_or()map_or_else()

    assert_eq!(we_have_it.map(|v| format!("The value = {}", v)), Ok("The value = 1".to_string()));
    assert_eq!(
        we_have_error.map(|v| format!("The error = {}", 
        v)),
        Err("Oh no!")
    );
    assert_eq!(we_have_it.map_err(|s| s.len()), Ok(1));
    assert_eq!(we_have_error.map_err(|s| s.len()), Err(6));
    assert_eq!(we_have_it.map_or("Default value".to_string(), |v| format!("The value = {}", v)), "The value = 1".to_string());
    assert_eq!(we_have_error.map_or("Default value".to_string(), |v| format!("The value = {}", v)), "Default value".to_string());
    assert_eq!(we_have_it.map_or_else(|_| "Default value".to_string(), |v| format!("The value = {}", v)), "The value = 1".to_string());
    assert_eq!(we_have_error.map_or_else(|_| "Default value".to_string(), |v| format!("The value = {}", v)), "Default value".to_string());
    

除了 std::result::Result 文档中的这些方法之外,还有其他重要的方法。请务必查看它们,因为 OptionResult 在 Rust 和 Rocket 中非常重要。

将字符串或数字作为错误返回在某些情况下可能是可接受的,但大多数情况下,我们希望有一个真实的错误类型,包括错误信息和可能的回溯,这样我们就可以进一步处理。在下一节中,我们将学习(并使用)Error特质,并在我们的应用程序中返回动态错误类型。

创建自定义错误类型

Rust 有一个特质来统一通过提供 std::error::Error 特质来传播错误。由于 Error 特质被定义为 pub trait Error: Debug + Display,任何实现了 Error 的类型也应该实现 DebugDisplay 特质。

让我们看看如何通过创建一个新的模块来创建自定义错误类型:

  1. src/lib.rs 中,添加新的 errors 模块:

    pub mod errors;
    
  2. 然后,创建一个新的文件夹,src/errors,并添加 src/errors/mod.rssrc/errors/our_error.rs 文件。在 src/errors/mod.rs 中,添加以下行:

    pub mod our_error;
    
  3. src/errors/our_error.rs 中,为 error 添加自定义类型:

    use rocket::http::Status;
    use std::error::Error;
    use std::fmt;
    #[derive(Debug)]
    pub struct OurError {
        pub status: Status,
        pub message: String,
        debug: Option<Box<dyn Error>>,
    }
    impl fmt::Display for OurError {
        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> 
        fmt::Result {
            write!(f, "{}", &self.message)
        }
    }
    
  4. 然后,我们可以为 OurError 实现 Error 特质。在 src/errors/our_error.rs 中,添加以下行:

    impl Error for OurError {
        fn source(&self) -> Option<&(dyn Error + 'static)> {
            if self.debug.is_some() {
                self.debug.as_ref().unwrap().source();
            }
            None
        }
    }
    

目前,对于 User 模块,我们为每个方法返回一个 Result<..., Box<dyn Error>> 动态错误。这是一个使用任何实现了 Error 的类型来返回错误,然后使用 Box 将实例放入堆中的常见模式。

这种方法的缺点是我们只能使用 Error 特性提供的方法,即 source()。我们希望能够使用 OurError 的状态、消息和调试信息。

  1. 因此,让我们给 OurError 添加几个构建方法。在 src/errors/our_error.rs 文件中添加以下行:

    impl OurError {
        fn new_error_with_status(status: Status, message: 
        String, debug: Option<Box<dyn Error>>) -> Self {
            OurError {
                status,
                message,
                debug,
            }
        }
        pub fn new_bad_request_error(message: String, 
        debug: Option<Box<dyn Error>>) -> Self {
            Self::new_error_with_status(Status::
            BadRequest, message, debug)
        }
        pub fn new_not_found_error(message: String,
        debug: Option<Box<dyn Error>>) -> Self {
            Self::new_error_with_status(Status::NotFound, 
            message, debug)
        }
        pub fn new_internal_server_error(
            message: String,
            debug: Option<Box<dyn Error>>,
        ) -> Self {
            Self::new_error_with_status(Status::
            InternalServerError, message, debug)
        }
    }
    
  2. 如果我们查看 src/models/user.rs 文件,有三个错误来源:sqlx::Erroruuid::Errorargon2。让我们为 sqlx::Erroruuid::Error 创建到 OurError 的转换。在 src/errors/our_error.rs 文件中添加以下 use 指令:

    use sqlx::Error as sqlxError;
    use uuid::Error as uuidError;
    
  3. 在同一文件 src/errors/our_error.rs 中,添加以下行:

    impl OurError {
        ...
        pub fn from_uuid_error(e: uuidError) -> Self {
            OurError::new_bad_request_error(
                String::from("Something went wrong"),
                Some(Box::new(e)))
        }
    }
    
  4. 对于 sqlx::Error,我们希望将 not_found 错误转换为 HTTP 状态 404,并将重复索引错误转换为 HTTP 状态 400bad request。在 src/errors/our_error.rs 文件中添加以下行:

    use std::borrow::Cow;
    ....
    impl OurError {
        ....
        pub fn from_sqlx_error(e: sqlxError) -> Self {
            match e {
                sqlxError::RowNotFound => {
                    OurError::new_not_found_error(
                        String::from("Not found"),
                        Some(Box::new(e)))
                }
                sqlxError::Database(db) => {
                    if db.code().unwrap_or(Cow::
                    Borrowed("2300")).starts_with("23") {
                        return OurError::new_bad_
                        request_error(
                            String::from("Cannot create or 
                            update resource"),
                            Some(Box::new(db)),
                        );
                    }
                    OurError::new_internal_server_error(
                        String::from("Something went 
                        wrong"),
                        Some(Box::new(db)),
                    )
                }
                _ => OurError::new_internal_server_error(
                    String::from("Something went wrong"),
                    Some(Box::new(e)),
                ),
            }
        }
    }
    
  5. 在修改我们的 User 实体之前,我们需要做一件事。Rust 中的某些 crate 默认不编译 std 库,以使生成的二进制文件更小,并嵌入到物联网(IoT)设备或 WebAssembly 中。例如,argon2 crate 默认不包含 Error 特性的实现,因此我们需要启用 std 功能。在 Cargo.toml 中,修改 argon2 依赖项以启用 std 库功能:

    argon2 = {version = "0.3", features = ["std"]}
    
  6. src/models/user.rs 文件中,删除 use std::error::Error; 并将其替换为 use crate::errors::our_error::OurError;。然后,我们可以将 User 的方法替换为使用 OurError。以下是一个示例:

    pub async fn find(connection: &mut PgConnection, uuid: &str) -> Result<Self, OurError> {
        let parsed_uuid = Uuid::parse_str(
        uuid).map_err(OurError::from_uuid_error)?;
        let query_str = "SELECT * FROM users WHERE uuid = 
        $1";
        Ok(sqlx::query_as::<_, Self>(query_str)
            .bind(parsed_uuid)
            .fetch_one(connection)
            .await
            .map_err(OurError::from_sqlx_error)?)
    }
    
  7. 对于 argon2 错误,我们可以创建一个函数或方法,或者手动转换。例如,在 src/models/user.rs 文件中,我们可以这样做:

    let password_hash = argon2
        .hash_password(new_user.password.as_bytes(), 
         &salt)
        .map_err(|e| {
            OurError::new_internal_server_error(
                String::from("Something went wrong"),
                Some(Box::new(e)),
            )
        })?;
    

将所有方法更改为使用 OurError。提醒一下:你可以在 GitHub 仓库中找到 src/models/user.rs 的完整源代码。github.com/PacktPublishing/Rust-Web-Development-with-Rocket/tree/main/Chapter07

  1. 然后,在 src/routes/user.rs 文件中,我们将使用 OurError 的状态和消息。因为 Error 类型已经实现了 Display 特性,我们可以在 format!() 中直接使用 e。以下是一个示例:

    pub async fn get_user(...) -> HtmlResponse {
    ...
        let user = User::find(connection, 
        uuid).await.map_err(|e| e.status)?;
    ...
    }
    ...
    pub async fn delete_user(...) -> Result<Flash<Redirect>, Flash<Redirect>> {
    ...
        User::destroy(connection, uuid)
            .await
            .map_err(|e| Flash::error(Redirect::to("/
             users"), format!("<div>{}</div>", e)))?;
    ...
    }
    

你可以在 GitHub 仓库中找到 src/routes/user.rs 的完整源代码。github.com/PacktPublishing/Rust-Web-Development-with-Rocket/tree/main/Chapter07。现在我们已经实现了错误处理,可能是一个尝试实现之前在 src/catchers/mod.rs 中定义的捕获器的良好时机,以显示默认错误给用户。你还可以在源代码中查看默认捕获器的示例。

在一个应用程序中,跟踪和记录错误是维护应用程序的重要部分。由于我们实现了 Error 特性,我们可以在应用程序中记录错误的 source()。让我们在下一节中看看如何做到这一点。

记录错误

在 Rust 中,有一个日志 crate 提供了应用程序日志的接口。日志提供了五个宏:error!warn!info!debug!trace!。应用程序可以根据严重性创建日志并过滤需要记录的内容,同样也基于严重性。例如,如果我们基于 warn 过滤,那么我们只记录 error!warn! 并忽略其余内容。由于日志 crate 并没有实现日志记录本身,人们通常使用另一个 crate 来进行实际的实现。在日志 crate 的文档中,我们可以找到其他可用的日志 crate 的示例:env_loggersimple_loggersimplelogpretty_env_loggerstderrlogflexi_loggerlog4rsfernsyslogslog-stdlog

让我们在应用程序中实现自定义日志记录。我们将使用 fern crate 进行日志记录,并将其包装在 async_log 中以实现异步日志记录:

  1. 首先,在 Cargo.toml 中添加以下这些 crate:

    async-log = "2.0.0"
    fern = "0.6"
    log = "0.4"
    
  2. Rocket.toml 中添加 log_level 的配置:

    log_level = "normal"
    
  3. 然后,我们可以在应用程序中创建一个初始化全局日志记录器的函数。在 src/main.rs 中,创建一个名为 setup_logger 的新函数:

    fn setup_logger() {}
    
  4. 在函数内部,让我们初始化日志记录器:

    use log::LevelFilter;
    ...
    let (level, logger) = fern::Dispatch::new()
        .format(move |out, message, record| {
            out.finish(format_args!(
                "[{date}] [{level}][{target}] [{
                 message}]",
                date = chrono::Local::now().format("[
                %Y-%m-%d][%H:%M:%S%.3f]"),
                target = record.target(),
                level = record.level(),
                message = message
            ))
        })
        .level(LevelFilter::Info)
        .chain(std::io::stdout())
        .chain(
            fern::log_file("logs/application.log")
                .unwrap_or_else(|_| panic!("Cannot open 
                logs/application.log")),
        )
        .into_log();
    

首先,我们创建一个 fern::Dispatch 的新实例。之后,我们使用 format() 方法配置输出格式。设置输出格式后,我们使用 level() 方法设置日志级别。

对于日志记录器,我们不仅希望将日志输出到操作系统的 stdout,还希望写入日志文件。我们可以使用 chain() 方法来实现。为了避免恐慌,别忘了在应用程序目录中创建一个 logs 文件夹。

  1. 在设置好日志级别和日志记录器后,我们将其包装在 async_log 中:

    async_log::Logger::wrap(logger, || 0).start(level).unwrap();
    
  2. OurError 被创建时,我们将记录它。在 src/errors/our_error.rs 中添加以下行:

    impl OurError {
        fn new_error_with_status(...) ... {
            if debug.is_some() {
                log::error!("Error: {:?}", &debug);
            }
            ...
        }
    }
    
  3. setup_logger() 函数添加到 src/main.rs 中:

    async fn rocket() -> Rocket<Build> {
        setup_logger();
    ...
    }
    
  4. 现在,让我们尝试在应用程序日志中查看 OurError。尝试创建具有相同用户名的用户;应用程序应该在终端和 logs/application.log 中发出类似以下的重名用户错误:

    [[2021-11-21][17:50:49.366]] [ERROR][our_application::errors::our_error]
    [Error: Some(PgDatabaseError { severity: Error, code: "23505", message:
    "duplicate key value violates unique constraint \"users_username_key\""
    , detail: Some("Key (username)=(karuna) already exists."), hint: None, p
    osition: None, where: None, schema: Some("public"), table: Some("users")
    , column: None, data_type: None, constraint: Some("users_username_key"),
    file: Some("nbtinsert.c"), line: Some(649), routine: Some("_bt_check_un
    ique") })]
    

现在我们已经学会了如何记录错误,我们可以实现日志功能来改进应用程序。例如,我们可能想要创建服务器端分析,或者我们可以将日志与第三方监控服务结合,以改进操作并创建商业智能。

概述

在本章中,我们学习了一些在 Rust 和 Rocket 应用程序中处理错误的方法。我们可以使用 panic!OptionResult 来传播错误并创建错误处理。

我们还学习了如何创建一个实现 Error 特质的自定义类型。该类型可以存储另一个错误,创建一个错误链。

最后,我们学习了在应用程序中记录错误的方法。我们还可以使用日志功能来改进应用程序本身。

我们的用户页面看起来不错,但到处使用String显得有些繁琐,所以在下章中,我们将学习如何使用 CSS、JavaScript 以及应用中的其他资源进行模板化。

第八章:第八章:服务静态资源和模板

网络应用程序的常见功能之一是服务静态文件,例如 层叠样式表CSS)或 JavaScriptJS)文件。在本章中,我们将学习如何从 Rocket 应用程序中服务静态资源。

对于网络框架的一个常见任务是渲染模板到 HTML 文件。我们将学习如何使用 Tera 模板从 Rocket 应用程序中渲染 HTML。

在本章中,我们将涵盖以下主要主题:

  • 服务静态资源

  • 介绍 Tera 模板

  • 展示用户

  • 处理表单

  • 保护 HTML 表单免受 CSRF 攻击

技术要求

对于本章,我们与上一章有相同的技术要求。我们需要一个 Rust 编译器、一个文本编辑器、一个 HTTP 客户端和一个 PostgreSQL 数据库服务器。

对于文本编辑器,您可以尝试添加一个支持 Tera 模板的扩展。如果没有 Tera 扩展,请尝试添加一个支持 Jinja2 或 Django 模板的扩展,并将文件关联设置为包含 *.tera 文件。

我们将向我们的应用程序添加 CSS,并使用来自 minicss.org/ 的样式表,因为它体积小且开源。请随意使用并修改示例 HTML 文件以使用其他样式表。

您可以在 github.com/PacktPublishing/Rust-Web-Development-with-Rocket/tree/main/Chapter08 找到本章的源代码。

服务静态资源

服务静态资源(如 HTML 文件、JS 文件或 CSS 文件)是网络应用程序的一个非常常见的任务。我们也可以让 Rocket 服务文件。让我们创建第一个服务 favicon 的函数。之前您可能已经注意到,一些网络浏览器从我们的服务器请求 favicon 文件,尽管我们在提供的 HTML 页面上没有明确提及它。让我们看看步骤:

  1. 在应用程序根目录中,创建一个名为 static 的文件夹。在 static 文件夹内,添加一个名为 favicon.png 的文件。您可以在互联网上找到样本 favicon.png 文件,或者使用本章示例源代码中的文件。

  2. src/main.rs 中添加一个新的路由:

    routes![
        ...
        routes::favicon,
    ],
    
  3. src/routes/mod.rs 中添加一个新的路由处理函数来服务 favicon.png

    use rocket::fs::{relative, NamedFile};
    use std::path::Path;
    ...
    #[get("/favicon.png")]
    pub async fn favicon() -> NamedFile {
        NamedFile::open(Path::new(relative!("static/
        favicon.png")))
            .await
            .ok()
            .unwrap()
    }
    

在这里,relative! 是一个宏,它生成一个相对于 crate 的路径版本。这意味着该宏指的是源文件或生成的二进制文件的文件夹。例如,我们在这个应用程序中的源文件位于 /some/source,通过说 relative!("static/favicon.png"),这意味着路径是 /some/source/static/favicon.png

每次我们想要服务特定的文件时,我们都可以创建一个路由处理函数,返回 NamedFile,并将路由挂载到 Rocket 上。但显然,这种方法并不好;我们可以创建一个函数来动态返回静态文件。

  1. 让我们重用我们在创建应用程序骨架时创建的assets函数。之前,在第三章Rocket 请求和响应中,我们了解到我们可以在路由中使用多个段。我们可以利用这一点,为具有与请求多个段相同的文件名的文件提供服务。

删除我们之前创建的 favicon 功能,并从src/main.rs中移除对该功能的引用。在src/routes/mod.rs中,修改use声明:

use rocket::fs::{relative, NamedFile};
use std::path::{Path, PathBuf};
  1. 如果应用程序找不到请求的文件,则应返回 HTTP 404状态码。我们可以通过将NamedFile包裹在Option中轻松地返回404状态码。如果NamedFileNone,则响应将自动具有404状态。修改src/routes/mod.rs中的assets函数签名:

    #[get("/<filename..>")]
    pub async fn assets(filename: PathBuf) -> Option<NamedFile> {}
    
  2. 然后,我们可以实现assets函数:

    let mut filename = Path::new(relative!("static")).join(filename);
    NamedFile::open(filename).await.ok()
    
  3. 不幸的是,Rocket 如果文件名是目录,会返回 HTTP 200状态码,因此攻击者可以尝试攻击并映射static文件夹内的文件夹结构。让我们通过添加以下这些行来处理这种情况:

    let mut filename = Path::new(relative!("static")).join(filename);
    if filename.is_dir() {
        filename.push("index.html");
    }
    NamedFile::open(filename).await.ok()
    

如果攻击者尝试系统地检查静态文件内的路径,攻击者将收到 HTTP 404状态码,并且无法推断出static文件夹内的文件夹结构。

  1. 还有另一种提供静态文件的方法:使用内置的rocket::fs::FileServer结构。在src/routes/mod.rs中删除处理静态资源的函数,并在src/main.rs中追加以下行:

    use rocket::fs::relative;
    use rocket::fs::FileServer;
    ...
    .mount("/assets", FileServer::from(
     relative!("static")))
    

尽管像 Rocket 这样的 Web 框架可以提供静态文件,但在 Web 服务器(如 Apache Web 服务器或 NGINX)后面提供静态文件更为常见。在更高级的设置中,人们还利用云存储,如 Amazon Web Services S3 或 Google Cloud Storage,与内容分发网络CDN)结合使用。

在下一节中,我们将对第六章实现用户 CRUD中创建的 HTML 进行细化。

介绍 Tera 模板

在 Web 应用程序中,通常有一个作为 Web 模板系统的部分。Web 设计师和 Web 开发者可以创建 Web 模板,Web 应用程序从模板生成 HTML 页面。

有不同类型的 Web 模板:服务器端 Web 模板(其中模板在服务器端渲染),客户端 Web 模板(客户端应用程序渲染模板),或混合 Web 模板。

Rust 中有几个模板引擎。我们可以在crates.io或 https:/lib.rs 找到用于 Web 开发的模板引擎(例如HandlebarsTeraAskamaLiquid)。

Rocket web 框架以rocket_dyn_templatescrate 的形式内置了对模板的支持。目前,crate 只支持两个引擎:HandlebarsTera。在这本书中,我们将使用 Tera 作为模板引擎以简化开发,但也欢迎尝试 Handlebars 引擎。

Tera 是一个受Jinja2Django模板启发的模板引擎。您可以在 https://tera.netlify.app/docs/找到 Tera 的文档。Tera 模板是一个包含表达式、语句和注释的文本文件。当模板被渲染时,表达式、语句和注释被替换为变量和表达式。

例如,假设我们有一个名为hello.txt.tera的文件,其内容如下:

Hello {{ name }}!

如果我们的程序有一个名为name的变量,其值为"Robert",我们可以创建一个包含以下内容的hello.txt文件:

Hello Robert!

如您所见,我们可以轻松地使用 Tera 模板创建 HTML 页面。在 Tera 中,我们可以使用三个分隔符:

  • {{ }}用于表达式

  • {% %}用于语句

  • {# #}用于注释

假设我们有一个名为hello.html.tera的模板,其内容如下:

<div>
    {# we are setting a variable 'name' with the value 
    "Robert" #}
    {% set name = "Robert" %}
    Hello {{ name }}!
</div>

我们可以用以下内容的模板将其渲染成hello.html文件:

<div>
    Hello Robert!
</div>

Tera 还具有其他功能,如在一个模板中嵌入其他模板、基本数据操作、控制结构和函数。基本数据操作包括基本的数学运算、基本的比较函数和字符串连接。控制结构包括if分支、for循环和其他模板。函数是返回一些用于模板的文本的定义过程。

我们将通过将OurApplication响应更改为使用 Tera 模板引擎来学习一些这些功能。让我们设置OurApplication以使用 Tera 模板引擎:

  1. Cargo.toml文件中,添加依赖项。我们需要rocket_dyn_templatescrate 和serdecrate 来序列化实例:

    chrono = {version = "0.4", features = ["serde"]}
    rocket_dyn_templates = {path = "../../../rocket/contrib/dyn_templates/", features = ["tera"]}
    serde = "1.0.130"
    
  2. 接下来,在Rocket.toml中添加一个新的配置以指定放置模板文件的文件夹:

    [default]
    ...
    template_dir = "src/views"
    
  3. src/main.rs中,添加以下行以将rocket_dyn_templates::Templatefairing 附加到应用程序:

    use rocket_dyn_templates::Template;
    ...
    #[launch]
    async fn rocket() -> Rocket<Build> {
    ...
    rocket::build()
    ...
        .attach(Template::fairing())
    ...
    }
    

添加Templatefairing 很简单。我们将在下一节通过将RawHtml替换为Template来学习Template

展示用户

我们将修改/users/<uuid>/users/的路线,采取以下步骤:

  1. 我们需要做的第一件事是创建一个模板。我们已经在/src/views中配置了模板文件夹,所以请在src文件夹中创建一个名为views的文件夹,然后在views文件夹内部创建一个名为template.html.tera的模板文件。

  2. 我们将使用该文件作为所有 HTML 文件的基 HTML 模板。在src/views/template.html.tera内部添加以下 HTML 标签:

    <!DOCTYPE html>
    <html lang="en">
    <head>
      <meta charset="utf-8" />
      <title>Our Application User</title>
      <link href="/assets/mini-default.css" 
      rel="stylesheet">
      <link rel="icon" type="image/png" href="/assets/
      favicon.png">
      <meta name="viewport" content="width=device-width, 
      initial-scale=1">
    </head>
    <body>
      <div class="container"></div>
    </body>
    </html>
    

注意我们在 HTML 文件中包含了一个 CSS 文件。您可以从 minicss.org/ 下载开源 CSS 文件并将其放入 static 文件夹中。由于我们已经在 /assets/<filename..> 路径上创建了一个用于服务静态文件的路由,我们可以在 HTML 文件中直接使用该路由。

  1. 对于下一步,我们需要包含一个可以放置我们想要渲染的 HTML 文本的部分,以及一个可以插入 flash 消息的部分。修改 src/views/template.html.tera 如下:

    <div class="container">
      {% if flash %}
        <div class="toast" onclick="this.remove()">
          {{ flash | safe }}
        </div>
      {% endif %}
      {% block body %}{% endblock body %}
    </div>
    

默认情况下,所有变量都会被转义渲染以避免 XSS(跨站脚本)攻击。我们添加了条件,如果存在 flash 变量,我们将变量放在 {{ flash }} 表达式内。为了使 HTML 标签以原始形式渲染而不被转义,我们可以使用 | safe 过滤器。

Tera 还有其他内置过滤器,如 loweruppercapitalize 等。对于内容,我们使用 block 语句。block 语句意味着我们将在语句中包含另一个模板。您也可以看到 blockendblock 语句结束。

  1. Tera 可以渲染实现 Serde 的 Serializable 特性的任何类型。让我们修改 User 和相关类型以实现 Serializable 特性。在 src/models/user.rs 文件中,按如下修改文件:

    use rocket::serde::Serialize;
    ...
    #[derive(Debug, FromRow, FromForm, Serialize)]
    pub struct User {
    ...
    
  2. 修改 src/models/user_status.rs 如下:

    use rocket::serde::Serialize;
    ...
    #[derive(sqlx::Type, Debug, FromFormField, Serialize)]
    #[repr(i32)]
    pub enum UserStatus {
    ...
    
  3. 此外,修改 src/models/our_date_time.rs 如下:

    use rocket::serde::Serialize;
    #[derive(Debug, sqlx::Type, Clone, Serialize)]
    #[sqlx(transparent)]
    pub struct OurDateTime(pub DateTime<Utc>);
    ...
    
  4. 对于 Pagination,我们可以直接派生 Serialize,但使用 URL 中的时间戳看起来不太好,例如,/users?pagination.next=``&pagination.limit=2。我们可以通过将 OurDateTime 转换为 i64 和反之亦然来使分页 URL 看起来更好。在 src/models/pagination.rs 文件中,追加以下行:

    use rocket::serde::Serialize;
    ...
    #[derive(Serialize)]
    pub struct PaginationContext {
        pub next: i64,
        pub limit: usize,
    }
    impl Pagination {
        pub fn to_context(&self) -> PaginationContext {
            PaginationContext {
                next: self.next.0.timestamp_nanos(),
                limit: self.limit,
            }
        }
    }
    
  5. 由于我们不再使用 RawHtml,请从 src/routes/mod.rs 文件中删除 use rocket::response::content::RawHtml; 指令,并按如下修改文件:

    use rocket_dyn_templates::Template;
    ...
    type HtmlResponse = Result<Template, Status>;
    
  6. src/routes/user.rs 文件中,删除 use rocket::response::content::RawHtml 指令。我们将添加 Template 指令以使响应返回 Template,但我们还需要 Serde 的 Serializecontext! 宏的帮助来将对象转换为 Tera 变量。追加以下行:

    use rocket::serde::Serialize;
    ...
    use rocket_dyn_templates::{context, Template};
    
  7. 然后,我们可以修改 get_user 函数。在 src/routes/user.rs 文件中的 get_user 函数内,删除所有与 RawHtml 相关的内容。从 let mut html_string = String::from(USER_HTML_PREFIX);Ok(RawHtml(html_string)) 的所有行都应删除。

  8. 然后,将删除的行内容替换为以下行:

    #[derive(Serialize)]
    struct GetUser {
        user: User,
        flash: Option<String>,
    }
    let flash_message = flash.map(|fm| String::from(fm.message()));
    let context = GetUser {
        user,
        flash: flash_message,
    };
    Ok(Template::render("users/show", &context))
    

记住 Tera 模板可以使用实现 Serializable 特性的任何 Rust 类型。我们定义了派生 Serializable 特性的 GetUser 结构体。由于 User 结构体已经实现了 Serializable,我们可以在 GetUser 结构体中使用它作为字段。创建 GetUser 的新实例后,我们告诉应用程序渲染 "users/show" 模板文件。

  1. 由于我们已经告诉应用程序模板名称是"users/show",所以在src/views内部创建一个名为users的新文件夹。在src/views/users文件夹内部,创建一个新文件,src/views/users/show.html.tera。之后,在文件中添加以下行:

    {% extends "template" %}
    {% block body %}
      {% include "users/_user" %}
      <a href="/users/edit/{{user.uuid}}" class="
      button">Edit User</a>
      <form accept-charset="UTF-8" action="/users/
      delete/{{user.uuid}}" autocomplete="off" 
      method="POST" id="deleteUser"
          class="hidden"></form>
      <button type="submit" value="Submit" 
      form="deleteUser">Delete</button>
      <a href="/users" class="button">User List</a>
    {% endblock body %}
    

第一条语句{% extends "template" %}表示我们正在扩展我们之前创建的src/views/template.html.tera。父src/views/template.html.tera有一个语句{% block body %}{% endblock body %},我们告诉 Tera 引擎用src/views/users/show.html.tera中相同块的内容覆盖该块。

  1. 在这段代码中,我们还可以看到{% include "users/_user" %},所以让我们创建一个src/views/users/_user.html.tera文件,并添加以下行:

    <div class="row">
      <div class="col-sm-3"><mark>UUID:</mark></div>
      <div class="col-sm-9"> {{ user.uuid }}</div>
    </div>
    <div class="row">
      <div class="col-sm-3"><mark>Username:</mark></div>
      <div class="col-sm-9"> {{ user.username }}</div>
    </div>
    <div class="row">
      <div class="col-sm-3"><mark>Email:</mark></div>
      <div class="col-sm-9"> {{ user.email }}</div>
    </div>
    <div class="row">
      <div class="col-sm-3"><mark>
      Description:</mark></div>
      <div class="col-sm-9"> {{ user.description }}</div>
    </div>
    <div class="row">
      <div class="col-sm-3"><mark>Status:</mark></div>
      <div class="col-sm-9"> {{ user.status }}</div>
    </div>
    <div class="row">
      <div class="col-sm-3"><mark>Created At:</mark></div>
      <div class="col-sm-9"> {{ user.created_at }}</div>
    </div>
    <div class="row">
      <div class="col-sm-3"><mark>Updated At:</mark></div>
      <div class="col-sm-9"> {{ user.updated_at }}</div>
    </div>
    

在这两个文件中,您将看到许多表达式,例如{{ user.username }}。这些表达式使用我们之前定义的变量:let context = GetUser { user, flash: flash_message,};。然后,我们告诉应用程序渲染模板:Ok(Template::render("users/show", &context))

你可能想知道为什么我们要将show.html.tera_user.html.tera分开。使用模板系统的一个好处是我们可以重用模板。我们希望在get_users函数中重用相同的用户 HTML。

  1. 让我们修改src/routes/user.rs文件中的get_users函数。删除从let mut html_string = String::from(USER_HTML_PREFIX);Ok(RawHtml(html_string))的行。将这些行替换为以下行:

    let context = context! {users: users, pagination: new_pagination.map(|pg|pg.to_context())};
    Ok(Template::render("users/index", context))
    
  2. 我们不是定义一个新的结构体,例如GetUser,而是使用context!宏。通过使用context!宏,我们不需要创建一个新的类型传递给模板。现在,创建一个名为src/views/users/index.html.tera的新文件,并将以下行添加到文件中:

    {% extends "template" %}
    {% block body %}
      {% for user in users %}
        <div class="container">
          <div><mark class="tag">{{loop.
          index}}</mark></div>
          {% include "users/_user" %}
          <a href="/users/{{ user.uuid }}" class="
          button">See User</a>
          <a href="/users/edit/{{ user.uuid }}" 
          class="button">Edit User</a>
        </div>
      {% endfor %}
      {% if pagination %}
        <a href="/users?pagination.next={{
        pagination.next}}&pagination.limit={{
        pagination.limit}}" class="button">
          Next
        </a>
      {% endif %}
      <a href="/users/new" class="button">New user</a>
    {% endblock %}
    

在这里我们看到了两个新事物:一个{% for user in users %}...{% endfor %}语句,它可以用来迭代数组,以及{{loop.index}},用于获取for循环中的当前迭代。

  1. 我们还想修改new_useredit_user函数,但在那之前,我们想看看get_userget_users的实际应用。由于我们已经将HtmlResponse别名改为Result<Template, Status>,我们需要在new_useredit_user中将Ok(RawHtml(html_string))转换为使用模板。将new_useredit_user函数中的Ok(RawHtml(html_string))改为Ok(Template::render("users/tmp", context!())),并创建一个空的src/views/users/tmp.html.tera文件。

  2. 现在,我们可以运行应用程序并检查我们用 CSS 改进的页面:

![图 8.1 – get_user()渲染

![图 8.1 – get_user()渲染

图 8.1 – get_user()渲染

我们可以看到模板正在与应用程序提供的正确 CSS 文件一起工作。在下一节中,我们还将修改表单以使用模板。

使用表单

如果我们查看new_useredit_user表单的结构,我们可以看到这两个表单几乎相同,只有一些细微的差别。例如,表单的action端点不同,因为edit_user有两个额外的字段:_METHODold_password。为了简化,我们可以制作一个模板供这两个函数使用。让我们看看步骤:

  1. 创建一个名为src/views/users/form.html.tera的模板,并插入以下行:

    {% extends "template" %}
    {% block body %}
      <form accept-charset="UTF-8" action="{{ form_url }}" 
      autocomplete="off" method="POST">
        <fieldset>
        </fieldset>
      </form>
    {% endblock %}
    
  2. 接下来,让我们通过添加一个legend标签来给表单添加标题。将此放在fieldset标签内:

    <legend>{{ legend }}</legend>
    
  3. legend标签下,如果我们正在编辑用户,可以添加一个额外的字段:

    {% if edit %}
      <input type="hidden" name="_METHOD" value="PUT" />
    {% endif %}
    
  4. 继续添加字段,按照以下方式添加usernameemail字段:

    <div class="row">
      <div class="col-sm-12 col-md-3">
        <label for="username">Username:</label>
      </div>
      <div class="col-sm-12 col-md">
        <input name="username" type="text" value="{{ 
        user.username }}"/>
      </div>
    </div>
    <div class="row">
      <div class="col-sm-12 col-md-3">
        <label for="email">Email:</label>
      </div>
      <div class="col-sm-12 col-md">
        <input name="email" type="email" value="{{ 
        user.email }}"/>
      </div>
    </div>
    
  5. email字段之后添加一个条件old_password字段:

    {% if edit %}
      <div class="row">
        <div class="col-sm-12 col-md-3">
          <label for="old_password">Old password:</label>
        </div>
        <div class="col-sm-12 col-md">
          <input name="old_password" type="password" />
        </div>
      </div>
    {% endif %}
    
  6. 添加其余的字段:

    <div class="row">
      <div class="col-sm-12 col-md-3">
        <label for="password">Password:</label>
      </div>
      <div class="col-sm-12 col-md">
        <input name="password" type="password" />
      </div>
    </div>
    <div class="row">
      <div class="col-sm-12 col-md-3">
        <label for="password_confirmation">Password 
        Confirmation:</label>
      </div>
      <div class="col-sm-12 col-md">
        <input name="password_confirmation" type=
        "password" />
      </div>
    </div>
    <div class="row">
      <div class="col-sm-12 col-md-3">
        <label for="description">Tell us a little bit more 
        about yourself:</label>
      </div>
      <div class="col-sm-12 col-md">
        <textarea name="description">{{ user.description 
        }}</textarea>
      </div>
    </div>
    <button type="submit" value="Submit">Submit</button>
    
  7. 然后,将标签和字段更改为显示值(如果有值):

    <input name="username" type="text" {% if user %}value="{{ user.username }}"{% endif %} />
    ...
    <input name="email" type="email" {% if user %}value="{{ user.email }}"{% endif %} />
    ...
    <label for="password">{% if edit %}New Password:{% else %}Password:{% endif %}</label>
    ...
    <textarea name="description">{% if user %}{{ user.description }}{% endif %}</textarea>
    
  8. 在我们创建了表单模板之后,我们可以修改new_useredit_user函数。在new_user中,从let mut html_string = String::from(USER_HTML_PREFIX);Ok(Template::render("users/tmp", context!()))删除这些行以创建RawHtml

form.html.tera中,我们添加了这些变量:form_urleditlegend。我们还需要将Option<FlashMessage<'_>>转换为String,因为FlashMessageSerializable特质的默认实现不是人类可读的。在new_user函数中添加这些变量并渲染模板,如下所示:

let flash_string = flash
    .map(|fl| format!("{}", fl.message()))
    .unwrap_or_else(|| "".to_string());
let context = context! {
    edit: false,
    form_url: "/users",
    legend: "New User",
    flash: flash_string,
};
Ok(Template::render("users/form", context))
  1. 对于edit_user,我们可以创建相同的变量,但这次我们知道user的数据,因此我们可以将user包含在上下文中。在src/routes/user.rsedit_user函数中删除从let mut html_string = String::from(USER_HTML_PREFIX);Ok(Template::render("users/tmp", context!()))的行。

将这些行替换为以下代码:

let flash_string = flash
    .map(|fl| format!("{}", fl.message()))
    .unwrap_or_else(|| "".to_string());
let context = context! {
    form_url: format!("/users/{}",&user.uuid ),
    edit: true,
    legend: "Edit User",
    flash: flash_string,
    user,
};
Ok(Template::render("users/form", context))
  1. 作为最后的润色,我们可以从src/routes/user.rs中删除USER_HTML_PREFIXUSER_HTML_SUFFIX常量。我们还应该删除src/views/users/tmp.html.tera文件,因为再也没有函数使用该文件了。而且,因为我们已经在模板中用<div></div>标签包围了闪存消息,所以我们可以从闪存消息中删除div的使用。例如,在src/routes/user.rs中,我们可以修改以下行:

    Ok(Flash::success(
        Redirect::to(format!("/users/{}", user.uuid)),
        "<div>Successfully created user</div>",
    ))
    

我们可以将它们修改为以下行:

Ok(Flash::success(
    Redirect::to(format!("/users/{}", user.uuid)),
    "Successfully created user",
))

我们还可以改进表单的一个方面,那就是添加一个令牌来保护应用程序免受跨站请求伪造CSRF)攻击。我们将在下一节学习如何保护我们的表单。

保护 HTML 表单免受 CSRF 攻击

最常见的安全攻击之一是 CSRF,恶意第三方诱使用户发送一个具有与预期不同的值的 Web 表单。减轻这种攻击的一种方法是在表单内容中发送一个一次性令牌。然后,Web 服务器检查令牌的有效性,以确保请求来自正确的 Web 浏览器。

我们可以在 Rocket 应用程序中通过创建一个将生成令牌并检查发送回的表单值的公平性来创建这样的令牌。让我们看看步骤:

  1. 首先,我们需要添加这个依赖项。我们将需要一个base64crate 来将二进制值编码和解码为字符串。我们还需要 Rocket 的secrets功能来存储和检索私有 cookie。私有 cookie 就像常规 cookie 一样,但它们被我们在Rocket.toml文件中配置的secret_key加密。

对于依赖项,我们还需要添加time作为依赖项。在Cargo.toml中添加以下行:

base64 = {version = "0.13.0"}
...
rocket = {path = "../../../rocket/core/lib/", features = ["uuid", "json", "secrets"]}
...
time = {version = "0.3", features = ["std"]}

防止 CSRF 的步骤是生成随机字节,将随机字节存储在私有 cookie 中,将随机字节作为字符串进行哈希处理,并渲染带有令牌字符串的表单模板。当用户发送令牌回来时,我们可以从 cookie 中检索令牌并比较两者。

  1. 要创建一个 CSRF 公平性,添加一个新的模块。在src/fairings/mod.rs中添加新的模块:

    pub mod csrf;
    
  2. 之后,创建一个名为src/fairings/csrf.rs的文件,并添加存储随机字节的 cookie 默认值的依赖项和常量:

    use argon2::{
        password_hash::{
            rand_core::{OsRng, RngCore},
            PasswordHash, PasswordHasher, 
            PasswordVerifier, SaltString,
        },
        Argon2,
    };
    use rocket::fairing::{self, Fairing, Info, Kind};
    use rocket::http::{Cookie, Status};
    use rocket::request::{FromRequest, Outcome, Request};
    use rocket::serde::Serialize;
    use rocket::{Build, Data, Rocket};
    use time::{Duration, OffsetDateTime};
    const CSRF_NAME: &str = "csrf_cookie";
    const CSRF_LENGTH: usize = 32;
    const CSRF_DURATION: Duration = Duration::hours(1);
    

然后,我们可以扩展 Rocket 的Request以添加一个新的方法来检索 CSRF 令牌。因为Request是一个外部 crate,我们无法添加另一个方法,但我们可以通过添加一个 trait 并使外部 crate 类型扩展此 trait 来克服这一点。我们不能使用外部 trait 扩展外部 crate,但使用内部 trait 扩展外部 crate 是允许的。

  1. 我们想要创建一个方法来从私有 cookie 中检索 CSRF 令牌。继续在src/fairings/csrf.rs中添加以下行:

    trait RequestCsrf {
        fn get_csrf_token(&self) -> Option<Vec<u8>>;
    }
    impl RequestCsrf for Request<'_> {
        fn get_csrf_token(&self) -> Option<Vec<u8>> {
            self.cookies()
                .get_private(CSRF_NAME)
                .and_then(|cookie| base64::
                decode(cookie.value()).ok())
                .and_then(|raw| {
                    if raw.len() >= CSRF_LENGTH {
                        Some(raw)
                    } else {
                        None
                    }
                })
        }
    }
    
  2. 之后,我们想要添加一个检索或生成并存储随机字节(如果 cookie 不存在)的公平性。添加一个新的结构体来作为公平性管理:

    #[derive(Debug, Clone)]
    pub struct Csrf {}
    impl Csrf {
        pub fn new() -> Self {
            Self {}
        }
    }
    #[rocket::async_trait]
    impl Fairing for Csrf {
        fn info(&self) -> Info {
            Info {
                name: "CSRF Fairing",
                kind: Kind::Ignite | Kind::Request,
            }
        }
        async fn on_ignite(&self, rocket: Rocket<Build>) –
        > fairing::Result {
            Ok(rocket.manage(self.clone()))
        }
    }
    
  3. 我们想要首先检索令牌,如果令牌不存在,则生成随机字节并将字节添加到私有令牌中。在impl Fairing块中,添加on_request函数:

    async fn on_request(&self, request: &mut Request<'_>, _: &mut Data<'_>) {
        if let Some(_) = request.get_csrf_token() {
            return;
        }
        let mut key = vec![0; CSRF_LENGTH];
        OsRng.fill_bytes(&mut key);
        let encoded = base64::encode(&key[..]);
        let expires = OffsetDateTime::now_utc() + CSRF_
        DURATION;
        let mut csrf_cookie = Cookie::new(
        String::from(CSRF_NAME), encoded);
        csrf_cookie.set_expires(expires);
        request.cookies().add_private(csrf_cookie);
    }
    
  4. 我们需要一个请求保护者来从请求中检索令牌字符串。添加以下行:

    #[derive(Debug, Serialize)]
    pub struct Token(String);
    #[rocket::async_trait]
    impl<'r> FromRequest<'r> for Token {
        type Error = ();
        async fn from_request(request: &'r Request<'_>) -> 
        Outcome<Self, Self::Error> {
            match request.get_csrf_token() {
                None => Outcome::Failure((Status::
                Forbidden, ())),
                Some(token) => Outcome::
                Success(Self(base64::encode(token))),
            }
        }
    }
    
  5. 如果找不到令牌,我们返回 HTTP 403状态码。我们还需要两个额外的函数:生成哈希和比较令牌哈希与其他字符串。由于我们已经在密码哈希中使用argon2,我们可以重用argon2crate 来执行这些函数。添加以下行:

    impl Token {
        pub fn generate_hash(&self) -> Result<String, 
        String> {
            let salt = SaltString::generate(&mut OsRng);
            Argon2::default()
                .hash_password(self.0.as_bytes(), &salt)
                .map(|hp| hp.to_string())
                .map_err(|_| String::from("cannot hash 
                authenticity token"))
        }
        pub fn verify(&self, form_authenticity_token: 
        &str) -> Result<(), String> {
            let old_password_hash = self.generate_hash()?;
            let parsed_hash = PasswordHash::new(&old_
            password_hash)
                .map_err(|_| String::from("cannot verify 
                authenticity token"))?;
            Ok(Argon2::default()
                .verify_password(form_authenticity_
                token.as_bytes(), &parsed_hash)
                .map_err(|_| String::from("cannot verify 
                authenticity token"))?)
        }
    }
    
  6. 在我们设置了Csrf公平性之后,我们可以在应用程序中使用它。在src/main.rs中,将公平性附加到 Rocket 应用程序:

    use our_application::fairings::{csrf::Csrf, db::DBConnection};
    ...
    async fn rocket() -> Rocket<Build> {
    ...
            .attach(Csrf::new())
    ...
    }
    
  7. src/models/user.rs中添加一个新字段来包含从表单发送的令牌:

    pub struct NewUser<'r> {
    ...
        pub authenticity_token: &'r str,
    }
    ...
    pub struct EditedUser<'r> {
    ...
        pub authenticity_token: &'r str,
    }
    
  8. src/views/users/form.html.tera中添加一个字段来存储令牌字符串:

    <form accept-charset="UTF-8" action="{{ form_url }}" autocomplete="off" method="POST">
      <input type="hidden" name="authenticity_token" 
      value="{{ csrf_token }}"/>
    ...
    
  9. 最后,我们可以修改src/routes/user.rs。添加Token依赖项:

    use crate::fairings::csrf::Token as CsrfToken;
    
  10. 我们可以将CsrfToken用作请求保护者,将令牌传递到模板中,并将模板作为 HTML 渲染:

    pub async fn new_user(flash: Option<FlashMessage<'_>>, csrf_token: CsrfToken) -> HtmlResponse {
    ...
        let context = context! {
            ...
            csrf_token: csrf_token,
        };
        ...
    }
    ...
    pub async fn edit_user(
        mut db: Connection<DBConnection>, uuid: &str, 
        flash: Option<FlashMessage<'_>>, csrf_token: 
        CsrfToken) -> HtmlResponse {
    ...
        let context = context! {
            ...
            csrf_token: csrf_token,
        };
        ...
    }
    
  11. 修改 create_user 函数以验证令牌,如果哈希值不匹配则返回:

    pub async fn create_user<'r>(
        ...
        csrf_token: CsrfToken,
    ) -> Result<Flash<Redirect>, Flash<Redirect>> {
        …
        let new_user = user_context.value.as_ref().
        unwrap();
        csrf_token
            .verify(&new_user.authenticity_token)
            .map_err(|_| {
                Flash::error(
                    Redirect::to("/users/new"),
                    "Something went wrong when creating 
                    user",
                )
            })?;
        ...
    }
    
  12. 同样对 update_userput_userpatch_user 函数进行操作:

    pub async fn update_user<'r>(
        ...
        csrf_token: CsrfToken,
    ) -> Result<Flash<Redirect>, Flash<Redirect>> {
        ...
        match user_value.method {
            "PUT" => put_user(db, uuid, user_context, 
            csrf_token).await,
            "PATCH" => patch_user(db, uuid, user_context, 
            csrf_token).await,
            ...
        }
    }
    ...
    pub async fn put_user<'r>(
        ...
        csrf_token: CsrfToken,
    ) -> Result<Flash<Redirect>, Flash<Redirect>> {
        let user_value = user_context.value.as_ref().
        unwrap();
        csrf_token
            .verify(&user_value.authenticity_token)
            .map_err(|_| {
                Flash::error(
                    Redirect::to(format!("/users/edit/{}",
                    uuid)),
                    "Something went wrong when updating 
                    user",
                )
            })?;
        …
    }
    …
    pub async fn patch_user<'r>(
        ...
        csrf_token: CsrfToken,
    ) -> Result<Flash<Redirect>, Flash<Redirect>> {
        put_user(db, uuid, user_context, csrf_token).await
    }
    

然后,尝试重新启动应用程序并发送不带令牌的表单。我们应该看到应用程序返回 HTTP 403 状态码。CSRF 是最常见的网络攻击之一,但我们已经学习了如何通过使用 Rocket 功能来减轻这种攻击。

摘要

在本章中,我们学习了三个对于网络应用程序来说是常见的事情。第一点是学习如何通过使用 PathBufFileServer 结构来让 Rocket 应用程序服务静态文件。

另一件事是我们已经学到的,那就是如何使用 rocket_dyn_templates 将模板转换为客户端的响应。我们还了解了模板引擎 Tera 以及 Tera 模板引擎的各种功能。

通过利用静态资源和模板,我们可以轻松地创建现代网络应用程序。在下一章中,我们将学习关于用户帖子:文本、图片和视频的内容。

第九章:第九章:显示用户帖子

在本章中,我们将实现显示用户帖子。除了显示用户帖子外,我们还将学习 泛型数据类型特质边界,以将行为相似的类型分组,从而减少相似代码的创建。我们还将学习 Rust 编程语言最重要的部分:内存模型及其术语。我们将学习更多关于 所有权移动复制克隆借用生命周期 的知识,以及如何在我们的代码中实现这些。

完成本章后,你将理解并在 Rust 编程中实现这些概念。泛型数据类型和特质边界有助于减少重复,而 Rust 的内存模型和概念可以说是 Rust 语言最独特的特性,使其不仅速度快,而且是最安全的编程语言之一。这些概念也使得人们说 Rust 有一个陡峭的学习曲线。

在本章中,我们将涵盖以下主要主题:

  • 显示帖子 – 文本、照片和视频

  • 使用泛型数据类型和特质边界

  • 学习所有权和移动

  • 借用和生命周期

技术要求

对于本章,我们与上一章有相同的技术要求。我们需要一个 Rust 编译器、一个文本编辑器、一个 HTTP 客户端和一个 PostgreSQL 数据库服务器。

你可以在 github.com/PacktPublishing/Rust-Web-Development-with-Rocket/tree/main/Chapter09 找到本章的源代码。

显示帖子 – 文本、照片和视频

在前面的章节中,我们实现了用户管理,包括列出、显示、创建、更新和删除用户实体。现在,我们想要对帖子做同样的事情。为了刷新你的记忆,我们计划有 User 帖子。帖子可以是文本、照片或视频。

当我们实现了应用程序骨架时,我们在 src/models/post.rs 中创建了一个 Post 结构体,其内容如下:

pub struct Post {
    pub uuid: Uuid,
    pub user_uuid: Uuid,
    pub post_type: PostType,
    pub content: String,
    pub created_at: OurDateTime,
}

计划使用 post_type 来区分帖子类型,并使用 content 字段来存储帖子的内容。

现在我们已经重新梳理了我们想要做的事情,让我们来实现显示帖子:

  1. 我们想要做的第一件事是创建一个迁移文件来更改数据库模式。我们想要创建一个表来存储帖子。在应用程序根目录下,运行以下命令:

    sqlx migrate add create_posts
    
  2. 我们应该会在 migrations 文件夹中看到一个名为 YYYYMMDDHHMMSS_create_posts.sql 的新文件(取决于当前日期时间)。使用以下行编辑文件:

    CREATE TABLE IF NOT EXISTS posts
    (
        uuid       UUID PRIMARY KEY,
        user_uuid  UUID NOT NULL,
        post_type  INTEGER NOT NULL DEFAULT 0,
        content    VARCHAR NOT NULL UNIQUE,
        created_at TIMESTAMPTZ NOT NULL DEFAULT CUR-
        RENT_TIMESTAMP,
        FOREIGN KEY (user_uuid) REFERENCES "users" (uuid)
    );
    
  3. 编辑文件后,在命令行中运行迁移以创建数据库表:

    sqlx migrate run
    
  4. 我们还在 src/traits/mod.rs 中创建了一个名为 DisplayPostContent 的特质,它有一个 raw_html() 方法。我们希望通过将内容转换为 HTML 片段并在 Tera 模板中渲染这些片段来显示 Post 中的内容。更改 raw_html() 的签名,以便我们可以使用 Post 作为 HTML 片段的来源:

    fn raw_html(&self) -> String;
    
  5. 现在,我们可以实现 src/models/text_post.rssrc/models/photo_post.rssrc/models/video_post.rs 中的每个类型。从修改 src/models/text_post.rs 开始:

    pub struct TextPost(pub Post);
    impl DisplayPostContent for TextPost {
        fn raw_html(&self) -> String {
            format!("<p>{}</p>", self.0.content)
        }
    }
    

实现非常简单,我们只是在 Post 内容周围包裹了一个 p HTML 标签。

  1. 接下来,修改 src/models/photo_post.rs

    pub struct PhotoPost(pub Post);
    impl DisplayPostContent for PhotoPost {
        fn raw_html(&self) -> String {
            format!(
    r#"<figure><img src="img/{}" class="section 
                media"/></figure>"#,
                self.0.content
            )
        }
    }
    

对于 PhotoPost,我们使用 Post 内容作为 img HTML 标签的来源。

  1. 最后一个我们修改的类型是 src/models/video_post.rs

    pub struct VideoPost(pub Post);
    impl DisplayPostContent for VideoPost {
        fn raw_html(&self) -> String {
            format!(
                r#"<video width="320" height="240" con-
                trols>
        <source src="img/{}" type="video/mp4">
        Your browser does not support the video tag.
        </video>"#,
                self.0.content
            )
        }
    }
    

对于 VideoPost,我们使用 Post 内容作为 video HTML 标签的来源。

我们需要为帖子创建模板。让我们从一个将用于单个帖子或多个帖子的模板开始。

  1. src/views 文件夹中创建一个 posts 文件夹。然后,在 src/views/posts 文件夹中创建一个 _post.html.tera 文件。将该文件中的以下行添加到文件中:

    <div class="card fluid">
      {{ post.post_html | safe }}
    </div>
    

我们在 div 中包裹了一些内容,并将内容过滤为安全的 HTML。

  1. src/views/posts 文件夹中,创建一个 show.html.tera 文件作为模板来显示单个帖子。将该文件中的以下行添加到文件中:

    {% extends "template" %}
    {% block body %}
      {% include "posts/_post" %}
      <button type="submit" value="Submit" form="delete-
      Post">Delete</button>
      <a href="/users/{{user.uuid}}/posts" class="but-
      ton">Post List</a>
    {% endblock %}
    
  2. src/views/posts 文件夹中创建一个 index.html.tera 文件来显示用户帖子。将该文件中的以下行添加到文件中:

    {% extends "template" %}
    {% block body %}
      {% for post in posts %}
        <div class="container">
          <div><mark class="tag">{{ loop.index 
          }}</mark></div>
          {% include "posts/_post" %}
          <a href="/users/{{ user.uuid }}/posts/{{ 
          post.uuid }}" class="button">See Post</a>
        </div>
      {% endfor %}
      {% if pagination %}
        <a href="/users/{{ user.uuid }}/posts?pagina
        tion.next={{ pagination.next }}&paginat-
        ion.limit={{ pagination.limit }}" class="button">
          Next
        </a>
      {% endif %}
      <a href="/users/{{ user.uuid }}/posts/new" 
      class="button">Upload Post</a>
    {% endblock %}
    
  3. 创建视图后,我们可以实现 Post 结构体的方法来从数据库获取数据。修改 src/models/post.rs 文件以包含 use 声明:

    use super::bool_wrapper::BoolWrapper;
    use super::pagination::{Pagination, DEFAULT_LIMIT};
    use super::photo_post::PhotoPost;
    use super::post_type::PostType;
    use super::text_post::TextPost;
    use super::video_post::VideoPost;
    use crate::errors::our_error::OurError;
    use crate::fairings::db::DBConnection;
    use crate::traits::DisplayPostContent;
    use rocket::form::FromForm;
    use rocket_db_pools::sqlx::{FromRow, PgConnection};
    use rocket_db_pools::{sqlx::Acquire, Connection};
    
  4. 我们需要为 Post 结构体派生 FromRow 以将数据库行转换为 Post 实例:

    #[derive(FromRow, FromForm)]
    pub struct Post {
        ...
    }
    
  5. Post 创建一个 impl 块:

    impl Post {}
    
  6. impl Post 块内部,我们可以添加查询数据库并返回 Post 数据的函数。由于这些函数与 User 函数类似,你可以从 Chapter09/01DisplayingPost 源代码文件夹中的 步骤 1417 复制代码。首先,我们添加 find() 方法来获取单个帖子:

    pub async fn find(connection: &mut PgConnection, uuid: &str) -> Result<Post, OurError> {
        let parsed_uuid = 
        Uuid::parse_str(uuid).map_err(Our
        Error::from_uuid_error)?;
        let query_str = "SELECT * FROM posts WHERE uuid = 
        $1";
        Ok(sqlx::query_as::<_, Self>(query_str)
            .bind(parsed_uuid)
            .fetch_one(connection)
            .await
            .map_err(OurError::from_sqlx_error)?)
    }
    
  7. 添加 find_all() 方法:

    pub async fn find_all(
        db: &mut Connection<DBConnection>,
        user_uuid: &str,
        pagination: Option<Pagination>,
    ) -> Result<(Vec<Self>, Option<Pagination>), OurError> {
        if pagination.is_some() {
            return Self::find_all_with_pagination(db, 
            user_uuid, &pagination.unwrap()).await;
        } else {
            return Self::find_all_without_pagination(db, user_uuid).await;
        }
    }
    
  8. 添加 find_all_without_pagination() 方法:

    async fn find_all_without_pagination(
        db: &mut Connection<DBConnection>,
        user_uuid: &str,
    ) -> Result<(Vec<Self>, Option<Pagination>), OurError> {
        let parsed_uuid = 
        Uuid::parse_str(user_uuid).map_err(Our-
        Error::from_uuid_error)?;
        let query_str = r#"SELECT *
    FROM posts
    WHERE user_uuid = $1
    ORDER BY created_at DESC
    LIMIT $2"#;
        let connection = db.acquire().await.map_err(Our-
        Error::from_sqlx_error)?;
        let posts = sqlx::query_as::<_, Self>(query_str)
            .bind(parsed_uuid)
            .bind(DEFAULT_LIMIT as i32)
            .fetch_all(connection)
            .await
            .map_err(OurError::from_sqlx_error)?;
        let mut new_pagination: Option<Pagination> = None;
        if posts.len() == DEFAULT_LIMIT {
            let query_str = "SELECT EXISTS(SELECT 1 FROM 
            posts WHERE created_at < $1 ORDER BY 
            created_at DESC LIMIT 1)";
            let connection = db.acquire().
            await.map_err(OurError::from_sqlx_error)?;
            let exists = sqlx::query_as::<_,
            BoolWrapper>(query_str)
                .bind(&posts.last().unwrap().created_at)
                .fetch_one(connection)
                .await
                .map_err(OurError::from_sqlx_error)?;
            if exists.0 {
                new_pagination = Some(Pagination {
                    next: posts.last().unwrap()
                    .created_at.to_owned(),
                    limit: DEFAULT_LIMIT,
                });
            }
        }
        Ok((posts, new_pagination))
    }
    
  9. 添加 find_all_with_pagination() 方法:

    async fn find_all_with_pagination(
        db: &mut Connection<DBConnection>,
        user_uuid: &str,
        pagination: &Pagination,
    ) -> Result<(Vec<Self>, Option<Pagination>), OurError> {
        let parsed_uuid = 
        Uuid::parse_str(user_uuid).map_err(
        OurError::from_uuid_error)?;
        let query_str = r#"SELECT *
    FROM posts
    WHERE user_uuid = $1 AND☐created_at < $2
    ORDER BY created_at☐DESC
    LIMIT $3"#;
        let connection = db.acquire().await.map_err(
        OurError::from_sqlx_error)?;
        let posts = sqlx::query_as::<_, Self>(query_str)
            .bind(&parsed_uuid)
            .bind(&pagination.next)
            .bind(DEFAULT_LIMIT as i32)
            .fetch_all(connection)
            .await
            .map_err(OurError::from_sqlx_error)?;
        let mut new_pagination: Option<Pagination> = None;
        if posts.len() == DEFAULT_LIMIT {
            let query_str = "SELECT EXISTS(SELECT 1 FROM 
            posts WHERE created_at < $1 ORDER BY 
            created_at DESC LIMIT 1)";
            let connection = db.
            acquire().await.map_err(
            OurError::from_sqlx_error)?;
            let exists = sqlx::query_as::<_,
            BoolWrapper>(query_str)
                .bind(&posts.last().unwrap().created_at)
                .fetch_one(connection)
                .await
                .map_err(OurError::from_sqlx_error)?;
            if exists.0 {
                new_pagination = Some(Pagination {
                    next: posts.last().unwrap().
                    created_at.to_owned(),
                    limit: DEFAULT_LIMIT,
                });
            }
        }
        Ok((posts, new_pagination))
    }
    
  10. 我们需要添加将 Post 实例转换为 TextPostPhotoPostVideoPost 的方法。在 impl Post 块内部添加以下行:

    pub fn to_text(self) -> TextPost {
        TextPost(self)
    }
    pub fn to_photo(self) -> PhotoPost {
        PhotoPost(self)
    }
    pub fn to_video(self) -> VideoPost {
        VideoPost(self)
    }
    
  11. 当视图和模型实现就绪后,我们可以实现显示用户帖子的函数。在 src/routes/post.rs 中添加所需的 use 声明:

    use crate::models::{pagination::Pagination, post::Post, post_type::PostType, user::User};
    use crate::traits::DisplayPostContent;
    use rocket::http::Status;
    use rocket::serde::Serialize;
    use rocket_db_pools::{sqlx::Acquire, Connection};
    use rocket_dyn_templates::{context, Template};
    
  12. 修改 src/routes/post.rs 中的 get_post() 函数:

    #[get("/users/<user_uuid>/posts/<uuid>", format = "text/html")]
    pub async fn get_post(
        mut db: Connection<DBConnection>,
        user_uuid: &str,
        uuid: &str,
    ) -> HtmlResponse {}
    
  13. get_post() 函数内部,从数据库查询 user 信息和 post 信息。写入以下行:

    let connection = db
        .acquire()
        .await
        .map_err(|_| Status::InternalServerError)?;
    let user = User::find(connection, user_uuid)
        .await
        .map_err(|e| e.status)?;
    let connection = db
        .acquire()
        .await
        .map_err(|_| Status::InternalServerError)?;
    let post = Post::find(connection, uuid).await.map_err(|e| e.status)?;
    if post.user_uuid != user.uuid {
        return Err(Status::InternalServerError);
    }
    
  14. src/views/posts/show.html.terasrc/views/posts/_post.html.tera 文件中,我们设置了两个变量:userpost。我们必须将这两个变量添加到传递给模板的上下文中。添加两个将要传递给模板的结构体:

    #[derive(Serialize)]
    struct ShowPost {
        post_html: String,
    }
    #[derive(Serialize)]
    struct Context {
        user: User,
        post: ShowPost,
    }
    
  15. 最后,我们可以将userpost变量传递到context中,与context一起渲染模板,并从函数中返回。添加以下行:

    let mut post_html = String::new();
        match post.post_type {
            PostType::Text => post_html = 
            post.to_text().raw_html(),
            PostType::Photo => post_html = 
            post.to_photo().raw_html(),
            PostType::Video => post_html = 
            post.to_video().raw_html(),
        }
        let context = Context {
            user,
            post: ShowPost { post_html },
        };
        Ok(Template::render("posts/show", context))
    
  16. 对于src/routes/post.rs中的get_posts()函数,我们希望从数据库中获取posts数据。将函数修改为以下行:

    #[get("/users/<user_uuid>/posts?<pagination>", format = "text/html")]
    pub async fn get_posts(
        mut db: Connection<DBConnection>,
        user_uuid: &str,
        pagination: Option<Pagination>,
    ) -> HtmlResponse {
        let user = User::find(&mut db, 
        user_uuid).await.map_err(|e| e.status)?;
    let (posts, new_pagination) = Post::find_all(&mut 
        db, user_uuid, pagination)
            .await
            .map_err(|e| e.status)?;
    }
    
  17. 现在我们已经实现了获取posts数据的功能,是时候将这些帖子渲染出来了。在get_posts()函数内部,添加以下行:

    #[derive(Serialize)]
    struct ShowPost {
     uuid: String,
     post_html: String,
    }
    let show_posts: Vec<ShowPost> = posts
        .into_iter()
        .map(|post| {
            let uuid = post.uuid.to_string();
            let mut post_html = String::new();
            match post.post_type {
                PostType::Text => post_html = 
                post.to_text().raw_html(),
                PostType::Photo => post_html = 
                post.to_photo().raw_html(),
                PostType::Video => post_html = 
                post.to_video().raw_html(),
            };
            ShowPost { uuid, post_html }
        })
        .collect();
    let context =
        context! {user, posts: show_posts, pagination: 
        new_pagination.map(|pg|pg.to_context())};
    Ok(Template::render("posts/index", context))
    

现在我们已经完成了get_post()get_posts()的代码,是时候测试这两个端点了。尝试向静态文件夹中添加图片和视频,并在数据库中添加条目。你可以在 GitHub 仓库中找到本章源代码中的静态文件夹中的示例图片和视频。以下是一个示例:

图 9.1 – 测试端点

图 9.1 – 测试端点

当我们打开网络浏览器并导航到用户帖子页面时,我们应该能够看到类似于以下截图的内容:

图 9.2 – 示例用户帖子页面

图 9.2 – 示例用户帖子页面

我们已经实现了显示帖子的函数,但如果我们回顾一下代码,我们可以看到这三种类型(文本、照片和视频)都有相同的方法,因为它们都在实现相同的接口。

在下一节中,让我们将这些内容转换为泛型数据类型和特质界限。

使用泛型数据类型和特质界限

泛型数据类型泛型类型,或者简单地称为泛型,是编程语言能够将相同的程序应用于不同数据类型的一种方式。

例如,我们希望为不同的数据类型创建一个multiplication(a, b) -> c {}函数,例如u8f64。如果一个语言没有泛型,程序员可能不得不实现两个不同的函数,例如,multiplication_u8(a: u8, b: u8) -> u8multiplication_f64(a: f64, b: f64) -> f64。创建两个不同的函数看起来可能很简单,但随着应用程序的复杂性增加,分支和确定使用哪个函数将会更加复杂。如果一个语言有泛型,那么可以通过使用单个可以接受u8f64的函数来解决多个函数的问题。

在 Rust 语言中,我们可以通过在函数名后面声明泛型来创建一个泛型函数,如下所示:

fn multiplication<T>(a: T, b: T) -> T {}

我们还可以在structenum定义中使用泛型。以下是一个示例:

struct Something<T>{
    a: T,
    b: T,
}
enum Shapes<T, U> {
    Rectangle(T, U),
    Circle(T),
}

我们还可以在方法定义中使用泛型。在Something<T>之后,我们可以实现方法如下:

impl<T, U> Something<T, U> {
    fn add(&self, T, U) -> T {}
}

在编译时,编译器通过使用具体的类型(在我们的乘法示例中为 u8f64)来识别和将泛型代码转换为特定代码,具体取决于使用了哪种类型。这个过程称为单态化。由于单态化,使用泛型编写的代码生成的二进制文件将具有与使用特定代码生成的二进制文件相同的执行速度。

现在我们已经了解了泛型简介,让我们在我们的现有应用程序中使用泛型:

  1. src/models/post.rs 文件中,添加另一个方法将 Post 实例转换为 media

    pub fn to_media(self) -> Box<dyn DisplayPostContent> {
        match self.post_type {
            PostType::Text => Box::new(self.to_text()),
            PostType::Photo => Box::new(self.to_photo()),
            PostType::Video => Box::new(self.to_video()),
        }
    }
    

我们正在告诉 to_media() 方法返回实现了 DisplayPostContent 的类型,并将 TextPostPhotoPostVideoPost 放入堆中。

  1. src/routes/post.rs 文件中,get_post() 函数内部,Context 结构体声明之后,添加以下行:

    struct Context {
        …
    }
    fn create_context<T>(user: User, media: T) -> Context {
        Context {
            user,
            post: ShowPost {
                post_html: media.raw_html(),
            },
        }
    }
    

是的,我们可以在另一个函数内部创建一个函数。内部函数将具有局部作用域,并且不能在 get_post() 函数外部使用。

  1. 我们需要将 context 变量从直接初始化结构体更改为如下所示:

    let context = Context {...};
    

我们需要将其更改为使用 create_context() 函数:

let media = post.to_media();
let context = create_context(user, media);

到目前为止,我们可以看到 create_context() 可以使用任何类型,例如 Stringu8,但 Stringu8 类型没有 raw_html() 函数。当编译代码时,Rust 编译器将显示错误。让我们通过使用特性界限来解决这个问题。

我们已经定义和实现了多个特性,并且我们已经知道特性为不同数据类型提供了一致的行为。我们在 src/traits/mod.rs 中定义了 DisplayPostContent 特性,并且实现了 DisplayPostContent 的每个类型都有相同的方法,即 raw_html(&self) -> String

我们可以通过在泛型声明后添加一个特性来限制泛型类型。将 create_context() 函数更改为使用特性界限:

fn create_context<T: DisplayPostContent>(user: User, media: T) -> Context {...}

不幸的是,仅使用 DisplayPostContent 是不够的,因为 T 的大小不是固定的。我们可以将函数参数从 media: T 改为 media: &T 引用,因为引用具有固定的大小。我们还有另一个问题,因为 DisplayPostContent 的大小在编译时是未知的,所以我们需要添加另一个界限。每个 T 类型在编译时都隐式地期望具有一个常量大小,隐式特性界限为 std::marker::Sized。我们可以通过使用特殊的 ?Size 语法来移除隐式界限。

我们可以使用多个特性绑定,并通过使用加号(+)将它们组合起来。create_context() 函数的结果签名将如下所示:

fn create_context<T: DisplayPostContent + ?Sized>(user: User, media: &T) -> Context {...}

在尖括号(<>)内编写多个特性界限会使函数签名难以阅读,因此有一个用于定义特性界限的替代语法:

fn create_context<T>(user: User, media: &T) -> Context
where T: DisplayPostContent + ?Sized {...}

由于我们更改了函数签名以使用引用,因此我们必须更改函数的使用方式:

let context = create_context(user, &*media);

我们通过使用解引用符号(*)获取 media 对象,并通过使用引用符号(&)再次引用 media

现在,Rust 编译器应该能够再次编译代码。我们将在下一两个部分中学习更多关于引用的内容,但在那之前,我们必须学习 Rust 的内存模型,称为所有权和移动。

学习所有权和移动

当我们实例化一个结构体时,我们创建一个实例。想象一下结构体就像一个模板;实例是基于模板在内存中创建的,并填充了适当的数据。

Rust 中的实例有一个作用域;它在函数中创建并返回。以下是一个例子:

fn something() -> User {
    let user = User::find(...).unwrap();
    user
}
let user = something()

如果一个实例没有被返回,那么它将从内存中移除,因为它不再被使用。在这个例子中,user 实例将在函数结束时被移除:

fn something() {
    let user = User::find(...).unwrap();
    ...
}

我们可以说实例有一个作用域,如前所述。在作用域内创建的资源将在作用域结束时以它们创建的相反顺序被销毁。

我们还可以通过使用花括号 {} 在例程中创建一个局部作用域。在作用域内创建的任何实例将在作用域结束时被销毁。例如,user 作用域位于花括号内:

...
{
    let user = User::find(...).unwrap();
}
...

实例拥有资源,不仅是在栈内存中,也在堆内存中。当实例超出作用域时,无论是由于函数退出还是花括号作用域退出,附加到实例的资源将自动以创建的相反顺序清理。这个过程被称为资源获取即初始化RAII)。

想象一下计算机内存由栈和堆组成:

Stack: ☐☐☐☐☐☐☐☐☐☐☐☐
Heap:  ☐☐☐☐☐☐☐☐☐☐☐☐

实例拥有栈内存中的内存:

Stack: ☐☒☒☒☐☐☐☐☐☐☐☐
Heap:  ☐☐☐☐☐☐☐☐☐☐☐☐

另一个实例可能从栈和堆中拥有内存。例如,一个字符串可以是一个单词或几段文字。我们无法确定 String 实例的大小,因此我们无法将所有信息存储在栈内存中;相反,我们可以将一些信息存储在栈内存中,将一些信息存储在堆内存中。这是一个简化的表示:

Stack: ☐☒☐☐☐☐☐☐☐☐☐☐
         ↓
Heap:  ☐☒☒☒☒☐☐☐☐☐☐☐

在其他编程语言中,有一个名为 std::ops::Drop 的函数。但是,大多数类型不需要实现 Drop 特性,并且当它们超出作用域时将自动从内存中移除。

在 Rust 中,如果我们创建一个实例并将其设置为另一个实例,它被称为 src/routes/post.rs 文件中的 get_posts() 函数,修改如下:

let show_posts: Vec<ShowPost> = posts
    .into_iter()
    .map(|post| ShowPost {
        post_html: post.to_media().raw_html(),
        uuid: post.uuid.to_string(),
    })
    .collect();
let context = ...

如果我们编译程序,我们应该看到类似于以下错误的错误:

error[E0382]: borrow of moved value: `post`
  --> src/routes/post.rs:78:19
   |
76 |         .map(|post| ShowPost {
   |               ---- move occurs because `post` has type `models::post::Post`, which does not implement the `Copy` trait
77 |             post_html: post.to_media().raw_html(),
   |                             ---------- `post` moved due to this method call
78 |             uuid: post.uuid.to_string(),
   |                   ^^^^^^^^^^^^^^^^^^^^^ value borrowed here after move

什么是移动?让我们回到内存简化的例子。当一个实例被分配给另一个实例时,第二个实例的一部分将在栈内存中分配:

Stack: ☐☒☐☐☒☐☐☐☐☐☐☐
         ↓
Heap:  ☐☒☒☒☒☐☐☐☐☐☐☐

然后,一些新的实例指向堆中的旧数据:

Stack: ☐☒☐☐☒☐☐☐☐☐☐☐
              ↓
Heap:  ☐☒☒☒☒☐☐☐☐☐☐☐

如果两个实例都指向相同的堆内存,那么第一个实例被丢弃时会发生什么?由于无效数据的可能性,在 Rust 中,只有一个实例可以拥有自己的资源。Rust 编译器将拒绝编译使用已移动实例的代码。

如果我们查看我们的代码,Post中的to_media()方法移动了post实例,并将其放入TextPostPhotoPostVideoPost中。因此,我们不能在post.uuid.to_string()中使用post实例,因为它已经被移动了。现在,我们可以通过改变行顺序来修复代码:

let show_posts: Vec<ShowPost> = posts
    .into_iter()
    .map(|post| ShowPost {
        uuid: post.uuid.to_string(),
        post_html: post.to_media().raw_html(),
    })
    .collect();

当我们使用post.uuid.to_string()时没有移动,所以代码应该可以编译。

但是,我们如何创建一个std::marker::Copy trait,这样当我们从一个实例赋值给另一个实例时,它会在栈上创建一个副本。这就是为什么像u8这样的简单类型,它们不需要太多内存或具有已知大小,实现了Copy trait。让我们看看这个代码是如何工作的说明:

let x: u8 = 8;
let y = x;
Stack: ☐☒☐☐☒☐☐☐☐☐☐☐
Heap:  ☐☐☐☐☐☐☐☐☐☐☐☐

如果一个类型的所有成员都实现了Copy trait,那么这个类型可以自动推导出Copy trait。我们还需要推导Clone,因为Copy trait 在其定义中由Clone trait 所约束:pub trait Copy: Clone { })。以下是一个推导Copy trait 的例子:

#[derive(Copy, Clone)]
struct Circle {
    r: u8,
}

然而,这个例子将不会工作,因为String没有实现Copy

#[derive(Copy, Clone)]
pub struct Sheep {
    ...
    pub name: String,
    ...
}

这个例子会工作:

#[derive(Clone)]
pub struct Sheep {
    ...
    pub name: String,
    ...
}

克隆是通过复制堆内存的内容来工作的。例如,假设我们有前面的代码和以下代码:

let dolly = Sheep::new(...);

我们可以将dolly可视化如下:

Stack: ☐☒☐☐☐☐☐☐☐☐☐☐
         ↓
Heap:  ☐☒☒☒☒☐☐☐☐☐☐☐

假设我们像这样从dolly赋值另一个实例:

let debbie = dolly;

这就是内存使用的情况:

Stack: ☐☒☐☐☐☐☒☐☐☐☐☐
         ↓                ↓
Heap:  ☐☒☒☒☒☐☒☒☒☒☐☐

由于分配堆内存很昂贵,我们可以使用另一种方式来查看实例的值:借用

借用和生命周期

我们已经在我们的代码中使用了引用。引用是栈中的一个实例,它指向另一个实例。让我们回顾一下实例内存使用的情况:

Stack: ☐☒☐☐☐☐☐☐☐☐☐☐
         ↓
Heap:  ☐☒☒☒☒☐☐☐☐☐☐☐

引用是在栈内存中分配的,指向另一个实例:

Stack: ☐☒←☒☐☐☐☐☐☐☐☐
         ↓
Heap:  ☐☒☒☒☒☐☐☐☐☐☐☐

在栈上分配比在堆上分配更便宜。正因为如此,大多数时候使用引用比克隆更高效。创建引用的过程被称为借用,因为引用借用了另一个实例的内容。

假设我们有一个名为airwolf的实例:

#[derive(Debug)]
struct Helicopter {
    height: u8,
    cargo: Vec<u8>,    
}
let mut airwolf = Helicopter {
    height: 0,
    cargo: Vec::new(),
};
airwolf.height = 10;

我们可以通过使用连字符(&)操作符来创建对airwolf的引用:

let camera_monitor_a = &airwolf;

借用一个实例就像是一个摄像头监视器;一个引用可以看到被引用实例的值,但不能修改这个值。我们可以有多个引用,就像这个例子中看到的那样:

let camera_monitor_a = &airwolf;
let camera_monitor_b = &airwolf;
...
let camera_monitor_z = &airwolf;

如果我们想要一个可以修改其所引用实例值的引用呢?我们可以创建一个&mut操作符:

let remote_control = &mut airwolf;
remote_control.height = 15;

现在,如果我们有两个遥控器会发生什么呢?嗯,直升机不能同时上升和下降。同样地,Rust 限制了可变引用,并且一次只允许一个可变引用。

Rust 还禁止同时使用可变引用和不可变引用,因为可能会发生数据不一致。例如,添加以下行将不会工作:

let last_load = camera_monitor_a.cargo.last(); // None
remote_control.cargo.push(100);

last_load的值是多少?我们期望last_loadNone,但遥控器已经将某些内容推送到货物中。由于数据不一致问题,如果我们尝试编译代码,Rust 编译器将发出错误。

实现借用和生命周期

现在我们已经学习了所有权、移动和借用,让我们修改我们的代码以使用引用。

  1. 如果我们查看TextPostPhotoPostVideoPost的当前定义,我们可以看到我们正在获取post的所有权并将post实例移动到新的TextPostPhotoPostVideoPost实例中。在src/models/text_post.rs中添加以下结构体:

    pub struct TextPost(pub Post);
    
  2. src/models/post.rs中,添加以下函数:

    pub fn to_text(self) -> TextPost { // self is post instance
        TextPost(self) // post is moved into TextPost instance 
    }
    
  3. 我们可以将TextPost字段转换为对Post实例的引用。将src/models/text_post.rs修改为以下内容:

    pub struct TextPost(&Post);
    
  4. 由于我们将未命名的字段转换为私有未命名的字段,我们还需要一个初始化器。追加以下行:

    impl TextPost {
        pub fn new(post: &Post) -> Self {
            TextPost(post)
        }
    }
    

由于我们改变了TextPost的初始化,我们还需要更改to_text()to_media()的实现。在src/models/post.rs中,将to_text()方法修改为以下内容:

pub fn to_text(&self) -> TextPost {
    TextPost::new(self)
}

to_media()方法修改为以下内容:

pub fn to_media(self) -> Box<dyn DisplayPostContent> {
    match self.post_type {
        PostType::Text => Box::new((&self).to_text()),
        ...
    }
}
  1. 让我们尝试编译代码。我们应该看到一个错误,如下所示:

    error[E0106]: missing lifetime specifier
     --> src/models/text_post.rs:4:21
      |
    4 | pub struct TextPost(&Post);
      |                     ^ expected named lifetime parameter
    

出现这个错误的原因是代码需要一个生命周期指定符。生命周期指定符是什么?让我们看看一个非常简单的程序的例子:

fn main() {
    let x;
    {
        let y = 5;
        x = &y;
    } // y is out of scope
    println!("{}", *x);
}
  1. 记住,在 Rust 中,任何实例在达到作用域的末尾后都会自动移除。在前面的代码中,y是在由花括号{}表示的作用域内创建的。当代码到达作用域的末尾}时,y实例将从内存中清除。那么x会发生什么?前面的代码将无法编译,因为x不再有效。我们可以按照以下方式修复代码:

    fn main() {
        let x;
        {
            let y = 5;
            x = &y;
            println!("{}", *x);
        }
    }
    
  2. 现在,让我们看看src/models/text_post.rs中的代码:

    pub struct TextPost(&Post);
    

由于 Rust 是多线程的,并且有很多分支,我们无法保证对Post实例的引用&Post可以像TextPost实例一样长命。如果&PostTextPost实例未销毁的情况下已经被销毁,会发生什么?解决方案是在以下位置放置一个标记,称为TextPost

pub struct TextPost<'a>(&'a Post);

我们在告诉编译器,任何TextPost实例都应该与引用的&Post一样长命,这由生命周期指示符'a表示。如果编译器发现&Post没有像TextPost实例一样长命,它将不会编译程序。

生命周期指定符的约定是使用小写单字母,如'a,但还有一个特殊的生命周期指定符,'static'static生命周期指定符意味着引用的数据与应用程序一样长命。例如,我们说pi引用的数据将与应用程序一样长命:

let pi: &'static f64 = &3.14;
  1. 让我们修改应用程序的其余部分。我们已经看到了如何在类型定义中使用生命周期指定符;现在让我们在 impl 块和方法中也使用它。将 src/models/text_post.rs 的其余部分修改为以下内容:

    impl<'a> TextPost<'a> {
        pub fn new(post: &'a Post) -> Self {...}
    }
    impl<'a> DisplayPostContent for TextPost<'a> {...}
    
  2. 让我们将 src/models/photo_post.rs 中的 PhotoPost 修改为也使用生命周期:

    pub struct PhotoPost<'a>(&'a Post);
    impl<'a> PhotoPost<'a> {
        pub fn new(post: &'a Post) -> Self {
            PhotoPost(post)
        }
    }
    impl<'a> DisplayPostContent for PhotoPost<'a> {...}
    
  3. 让我们也将 VideoPostsrc/models/video_post.rs 中进行更改:

    pub struct VideoPost<'a>(&'a Post);
    impl<'a> VideoPost<'a> {
        pub fn new(post: &'a Post) -> Self {
            VideoPost(post)
        }
    }
    impl<'a> DisplayPostContent for VideoPost<'a> {...}
    
  4. src/models/post.rs 文件中,按照以下方式修改代码:

    impl Post {
        pub fn to_text(&self) -> TextPost {
            TextPost::new(self)
        }
        pub fn to_photo(&self) -> PhotoPost {
            PhotoPost::new(self)
        }
        pub fn to_video(&self) -> VideoPost {
            VideoPost::new(self)
        }
        pub fn to_media<'a>(&'a self) -> Box<dyn 
        DisplayPostContent + 'a> {
            match self.post_type {
                PostType::Photo => Box::new(self.to_photo()),
                PostType::Text => Box::new(self.to_text()),
                PostType::Video => Box::new(self.to_video()),
            }
        }
        ...
    }
    

现在,我们正在使用借用的 Post 实例来处理 TextPostPhotoPostVideoPost 实例。但在结束本章之前,让我们根据以下说明稍微重构一下代码:

  1. 我们可以看到 ShowPost 结构体在 get_post()get_posts() 中被重复。在 src/models/post.rs 中添加一个新的结构体:

    use rocket::serde::Serialize;
    ...
    #[derive(Serialize)]
    pub struct ShowPost {
        pub uuid: String,
        pub post_html: String,
    }
    
  2. 添加一个将 Post 转换为 ShowPost 的方法:

    impl Post {
        ...
        pub fn to_show_post<'a>(&'a self) -> ShowPost {
            ShowPost {
                uuid: self.uuid.to_string(),
                post_html: self.to_media().raw_html(),
            }
        }
        ...
    }
    
  3. src/routes/post.rs 中,将 ShowPost 添加到 use 声明中:

    use crate::models::{
        pagination::Pagination,
        post::{Post, ShowPost},
        user::User,
    };
    
  4. 通过删除以下行来修改 get_post() 函数,以删除不必要的结构体声明和函数:

    #[derive(Serialize)]
    struct ShowPost {
        post_html: String,
    }
    #[derive(Serialize)]
    struct Context {
        user: User,
        post: ShowPost,
    }
    fn create_context<T: DisplayPostContent + ?Sized>(user: User, media: &T) -> Context {
        Context {
            user,
            post: ShowPost {
                post_html: media.raw_html(),
            },
        }
    }
    let media = post.to_media();
    let context = create_context(user, &*media);
    
  5. 将这些行替换为 context! 宏:

    let context = context! { user, post: &(post.to_show_post())};
    
  6. get_posts() 函数中,删除以下行:

    #[derive(Serialize)]
    struct ShowPost {
        uuid: String,
        post_html: String,
    }
    let show_posts: Vec<ShowPost> = posts
        .into_iter()
        .map(|post| ShowPost {
            uuid: post.uuid.to_string(),
            post_html: post.to_media().raw_html(),
        })
        .collect();
    

将这些行替换为这一行:

let show_posts: Vec<ShowPost> = posts.into_iter().map(|post| post.to_show_post()).collect();
  1. 此外,更改 context 实例化:

    let context = context! {user, posts: &show_posts, pagination: new_pagination.map(|pg|pg.to_context())};
    
  2. 最后,删除不必要的 use 声明。删除以下行:

    use crate::traits::DisplayPostContent;
    use rocket::serde::Serialize;
    

现在使用借用的 Post 实例显示帖子,实现应该更简洁。应用程序的速度应该没有差异,因为我们只是使用单个实例的引用。

事实上,有时使用所有者属性而不是引用更好,因为没有显著的性能提升。在复杂的应用程序、高内存使用应用程序或高性能应用程序(如游戏或高速交易,数据量很大)中,使用引用可能是有用的,但这要以开发时间为代价。

摘要

在本章中,我们实现了 get_post()get_posts() 来在网页中显示 Post 信息。随着这些实现,我们还学习了通过泛型和特质界限来减少代码重复。

我们还学习了 Rust 最独特和最重要的特性:其内存模型。我们现在知道一个实例拥有一个内存块,要么在栈上,要么同时在栈和堆上。我们还了解到将另一个实例赋值给实例意味着移动所有权,除非它是一个实现了 Copy 和/或 Clone 特性的简单类型。我们还学习了借用、借用的规则以及使用生命周期指定符来补充移动、复制和借用。

这些规则是 Rust 中最令人困惑的部分之一,但正是这些规则使得 Rust 在保持与其他系统语言(如 C 或 C++)相同性能的同时,成为一个非常安全的语言。现在我们已经实现了显示帖子,让我们在下一章学习如何上传数据。

第十章:第十章:上传和处理帖子

在本章中,我们将学习如何上传用户帖子。我们将从多部分上传的基本知识开始,然后继续使用TempFile来存储上传的文件。上传文件后,我们将实现图像处理。

接下来,我们将学习如何通过并发编程技术、异步编程和多线程来改进处理。

在本章中,我们将涵盖以下主要主题:

  • 上传文本帖子

  • 上传照片帖子

  • 异步处理文件

  • 使用工作进程上传视频帖子并处理

技术要求

对于本章,我们有通常的要求:Rust 编译器、文本编辑器、网络浏览器和 PostgreSQL 数据库服务器。除了这些要求之外,我们还将处理上传的视频文件。从www.ffmpeg.org/download.html下载FFmpeg命令行。FFmpeg 是一个用于处理媒体文件的媒体框架。确保您可以在操作系统的终端上运行 FFmpeg。

您可以在此章节的源代码github.com/PacktPublishing/Rust-Web-Development-with-Rocket/tree/main/Chapter10中找到。

上传文本帖子

我们首先想要上传的是文本帖子,因为它是最简单的类型。当我们提交 HTML 表单时,我们可以指定form标签的enctype属性为text/plainapplication/x-www-form-urlencodedmultipart/form-data。我们已经学习了如何在创建用户时处理application/x-www-form-urlencoded的 Rocket 应用程序。我们为该结构体创建一个结构体并为其推导FromForm。稍后,在路由处理函数中,我们设置一个路由属性,如getpost,并将结构体分配到data注释中。

对于Content-Type="application/x-www-form-urlencoded"的请求体是简单的:表单键和值通过&分隔成键值对,键和值之间用等号(=)连接。如果发送的字符不是字母数字,则进行百分号编码(%)。以下是一个表单请求体的示例:

name=John%20Doe&age=18

对于上传文件,Content-Typemultipart/form-data,其正文不同。假设我们有以下 HTTP 头:

Content-Type: multipart/form-data; boundary=---------------------------charactersforboundary123

HTTP 正文可以是以下内容:

Content-Type: multipart/form-data; boundary=---------------------------charactersforboundary123
Content-Disposition: form-data; name="name"
John Doe
-----------------------------charactersforboundary123
Content-Disposition: form-data; name="upload"; filename="file1.txt"
Content-Type: text/plain
Lorem ipsum dolor sit amet 
-----------------------------charactersforboundary123
Content-Disposition: form-data; name="other_field"
Other field

在 Rocket 中,我们可以通过使用multer crate 来处理multipart/form-data。让我们按照以下说明尝试使用该 crate 实现上传:

  1. 通过将这些 crate 添加到Cargo.toml依赖项中修改我们的应用程序:

    multer = "2.0.2"
    tokio-util = "0.6.9"
    
  2. Rocket.toml中添加以下配置以处理文件上传限制并添加一个临时目录来存储上传的文件:

    limits = {"file/avif" = "1Mib", "file/gif" = "1Mib", "file/jpg" = "1Mib", "file/jpeg" = "1Mib", "file/png" = "1Mib", "file/svg" = "1Mib", "file/webp" = "1Mib", "file/webm" = "64Mib", "file/mp4" = "64Mib", "file/mpeg4" = "64Mib", "file/mpg" = "64Mib", "file/mpeg" = "64Mib", "file/mov" = "64Mib"}
    temp_dir = "/tmp"
    
  3. 修改src/views/posts/index.html.tera以添加一个用户可以上传文件的表单。在分页块之后添加以下行:

    <form action="/users/{{ user.uuid }}/posts" enctype="multipart/form-data" method="POST">
      <fieldset>
        <legend>New Post</legend>
        <div class="row">
          <div class="col-sm-12 col-md-3">
            <label for="upload">Upload file:</label>
          </div>
          <div class="col-sm-12 col-md">
            <input type="file" name="file" accept=" 
            text/plain">
          </div>
        </div>
        <button type="submit" value="Submit">Submit</
        button>
      </fieldset>
    </form>
    
  4. src/models/post.rs文件中为Post添加create()方法。我们希望有一个方法将Post数据保存到数据库中。在impl Post {}块内添加以下行:

    pub async fn create(
        connection: &mut PgConnection,
        user_uuid: &str,
        post_type: PostType,
        content: &str,
    ) -> Result<Self, OurError> {
        let parsed_uuid = Uuid::parse_str(
        user_uuid).map_err(OurError::from_uuid_error)?;
        let uuid = Uuid::new_v4();
        let query_str = r#"INSERT INTO posts
    (uuid, user_uuid, post_type, content)
    VALUES
    ($1, $2, $3, $4)
    RETURNING *"#;
        Ok(sqlx::query_as::<_, Self>(query_str)
            .bind(uuid)
            .bind(parsed_uuid)
            .bind(post_type)
            .bind(content)
            .fetch_one(connection)
            .await
            .map_err(OurError::from_sqlx_error)?)
    }
    
  5. 我们可以移除FromForm,因为我们不再使用占位符了。从src/models/post.rs中移除以下行:

    use rocket::form::FromForm;
    ...
    #[derive(FromRow, FromForm)]
    pub struct Post {...}
    
  6. 我们需要从请求的Content-Type中获取多部分边界,但 Rocket 没有可以执行此操作的请求保护器。让我们创建一个可以处理原始 HTTP Content-Type头部的类型。在src/lib.rs中添加以下行:

    pub mod guards;
    

src文件夹中,创建另一个名为guards的文件夹,然后创建一个src/guards/mod.rs文件。在文件中,添加一个处理原始 HTTP 请求体的结构体:

use rocket::request::{FromRequest, Outcome};
pub struct RawContentType<'r>(pub &'r str);
  1. 实现FromRequestRawContent以创建请求保护器:

    #[rocket::async_trait]
    impl<'r> FromRequest<'r> for RawContentType<'r> {
        type Error = ();
        async fn from_request(req: &'r rocket::
        Request<'_>) -> Outcome<Self, Self::Error> {
            let header = req.headers().get_one("
            Content-Type").or(Some("")).unwrap();
            Outcome::Success(RawContentType(header))
        }
    }
    
  2. Rocket 会将"/users/delete/<uuid>"路由视为与"/users/<user_uuid>/posts"路由冲突。为了避免这个问题,我们可以在路由宏中添加rank。在src/routes/user.rs中,编辑delete_user_entry_point()函数上面的路由宏:

    #[post("/users/delete/<uuid>", format = "application/x-www-form-urlencoded", rank = 2)]
    pub async fn delete_user_entry_point(...) -> ... {...}
    
  3. src/routes/post.rs中添加所需的use声明以实现 HTTP 多部分请求的处理:

    use crate::guards::RawContentType;
    use crate::models::post_type::PostType;
    use multer::Multipart;
    use rocket::request::FlashMessage;
    use rocket::response::{Flash, Redirect};
    use rocket::data::{ByteUnit, Data};
    
  4. 添加一个常量以限制上传文件的大小:

    const TEXT_LIMIT: ByteUnit = ByteUnit::Kibibyte(64); 
    
  5. 让我们修改get_posts()函数,以便在上传失败或成功时添加一个flash消息:

    pub async fn get_posts(
        ...
        flash: Option<FlashMessage<'_>>,
    ) -> HtmlResponse {
        let flash_message = flash.map(|fm| 
        String::from(fm.message()));
        ...
        let context = context! {flash: flash_message,...};
        Ok(Template::render("posts/index", context))
    }
    
  6. 现在是时候实现create_post()函数了。我们首先需要做的是修改post路由宏和函数签名:

    #[post("/users/<user_uuid>/posts", format = "multipart/form-data", data = "<upload>", rank = 1)]
    pub async fn create_post(
        mut db: Connection<DBConnection>,
        user_uuid: &str,
        content_type: RawContentType<'_>,
        upload: Data<'_>,
    ) -> Result<Flash<Redirect>, Flash<Redirect>> {...}
    
  7. create_post()函数内部,添加一个返回错误的闭包。我们添加闭包以避免重复。添加以下行:

    let create_err = || {
        Flash::error(
            Redirect::to(format!("/users/{}/posts", 
            user_uuid)),
            "Something went wrong when uploading file",
        )
    };
    
  8. create_err定义下,继续从content_type请求保护器中获取边界:

    let boundary = multer::parse_boundary(content_type.0).map_err(|_| create_err())?;
    
  9. 对于TextPost,我们只需将文本文件的内容存储在帖子的content字段中。让我们打开请求体,将其作为多部分处理,并定义一个新变量来存储正文内容。添加以下行:

    let upload_stream = upload.open(TEXT_LIMIT);
    let mut multipart = Multipart::new(tokio_util::io::ReaderStream::new(upload_stream), boundary);
    let mut text_post = String::new();
    
  10. 下一步我们需要做的是迭代多部分字段。我们可以这样迭代多部分字段:

    while let Some(mut field) = multipart.next_field().await.map_err(|_| create_err())? {
        let field_name = field.name();
        let file_name = field.file_name();
        let content_type = field.content_type();
        println!(
            "Field name: {:?}, File name: {:?}, 
            Content-Type: {:?}",
            field_name, file_name, content_type
        );
    }
    

由于我们表单中只有一个字段,我们只需获取第一个字段的内容并将其值放入text_post变量中。添加以下行:

while let Some(mut field) = multipart.next_field().await.map_err(|_| create_err())? {
    while let Some(field_chunk) = 
    field.chunk().await.map_err(|_| create_err())? {
        text_post.push_str(std::str::from_utf8(
        field_chunk.as_ref()).unwrap());
    }
}
  1. 最后,在我们获取请求体内容并将其分配给text_post之后,是时候将其存储到数据库中并返回到posts列表页面。添加以下行:

    let connection = db.acquire().await.map_err(|_| create_err())?;
    Post::create(connection, user_uuid, PostType::Text, &text_post)
        .await
        .map_err(|_| create_err())?;
    Ok(Flash::success(
        Redirect::to(format!("/users/{}/posts", 
        user_uuid)),
        "Successfully created post",
    ))
    

现在,尝试重新启动应用程序并上传文本文件。你应该能在posts列表页面上看到文本文件的内容:

![图 10.1 – 上传的文本帖子img/Figure_10.1_B16825.jpg

图 10.1 – 上传的文本帖子

现在我们已经实现了上传和处理文本文件,是时候继续上传和处理图片文件了。

上传图片帖子

在 Rocket 0.5之前,上传多部分文件必须手动实现,如前节所述。从 Rocket 0.5开始,有一个rocket::fs::TempFile类型可以直接用来处理上传文件。

为了处理图像文件,我们可以使用image crate。该 crate 可以处理打开和保存各种图像文件格式。image crate 还提供了操作图像的方法。

网站出于各种原因处理上传的媒体文件,如图像,包括减少磁盘使用。一些网站会降低图像质量,并将上传的图像编码成默认更小的文件格式。在这个例子中,我们将所有上传的图像转换为 75%质量的 JPEG 文件。

让我们按照以下步骤使用image crate 和TempFile结构体实现上传图像文件:

  1. Cargo.toml中移除multertokio-util,然后添加image crate 到Cargo.toml

    image = "0.24.0"
    
  2. src/lib.rs中移除pub mod guards;,然后移除src/guards文件夹。

  3. src/models/post.rs中添加一个结构体来处理上传文件:

    use rocket::fs::TempFile;
    ...
    #[derive(Debug, FromForm)]
    pub struct NewPost<'r> {
        pub file: TempFile<'r>,
    }
    
  4. 修改src/views/posts/index.html.tera以包括作为接受文件的图像:

    ...
    <input type="file" name="file" accept="text/plain,image/*">
    ...
    
  5. 从边界变量声明到多部分迭代块中移除未使用的use声明、TEXT_LIMIT常量和create_post()函数的一部分:

    use crate::guards::RawContentType;
    use multer::Multipart;
    use rocket::data::{ByteUnit, Data};
    ...
    const TEXT_LIMIT: ByteUnit = ByteUnit::Kibibyte(64);
    ...
    let boundary = multer::parse_boundary(content_type.0).map_err(|_| create_err())?;
    ...until
    while let Some(mut field) = multipart.next_field().await.map_err(|_| create_err())? {
    ...
    }
    
  6. 添加所需的use声明:

    use crate::models::post::{NewPost, Post, ShowPost};
    use image::codecs::jpeg::JpegEncoder;
    use image::io::Reader as ImageReader;
    use image::{DynamicImage, ImageEncoder};
    use rocket::form::Form;
    use std::fs::File;
    use std::io::{BufReader, Read};
    use std::ops::Deref;
    use std::path::Path;
    
  7. 我们可以使用我们之前创建的NewPost结构体作为常规的FromForm派生结构体。修改create_post()函数签名:

    pub async fn create_post<'r>(
        mut db: Connection<DBConnection>,
        user_uuid: &str,
        mut upload: Form<NewPost<'r>>,
    ) -> Result<Flash<Redirect>, Flash<Redirect>> {...}
    
  8. create_err闭包声明下,为上传文件的新名称生成一个随机的uuid名称:

    let file_uuid = uuid::Uuid::new_v4().to_string();
    
  9. 检查上传文件的Content-Type,如果 Temp 文件无法确定它,则返回一个错误:

    if upload.file.content_type().is_none() {
        return Err(create_err());
    }
    
  10. 找到上传文件的扩展名并创建一个新的文件名:

    let ext = upload.file.content_type().unwrap().extension().unwrap();
    let tmp_filename = format!("/tmp/{}.{}", &file_uuid, &ext);
    
  11. 在临时位置持久化上传的文件:

    upload
        .file
        .persist_to(tmp_filename)
        .await
        .map_err(|_| create_err())?;
    
  12. 定义contentpost_type以供以后保存:

    let mut content = String::new();
    let mut post_type = PostType::Text;
    
  13. 检查文件的媒体类型。我们可以将媒体类型分为位图和svg文件。目前,我们将只处理文本和图像。视频将在下一节中处理。添加以下行:

    let mt = upload.file.content_type().unwrap().deref();
    if mt.is_text() {
    } else if mt.is_bmp() || mt.is_jpeg() || mt.is_png() || mt.is_gif() {
    } else if mt.is_svg() {
    } else {
        return Err(create_err());
    }
    
  14. 我们首先处理文本。创建一个字节向量(u8),打开并读取文件到向量中,然后将向量推入我们之前定义的内容字符串中。在mt.is_text()块内添加这些行:

    let orig_path = upload.file.path().unwrap().to_string_lossy().to_string();
    let mut text_content = vec![];
    let _ = File::open(orig_path)
        .map_err(|_| create_err())?
        .read(&mut text_content)
        .map_err(|_| create_err())?;
    content.push_str(std::str::from_utf8(&text_content).unwrap());
    
  15. 接下来,我们想要处理svg文件。对于这个文件,我们不能将其转换为 JPEG 文件;我们只想将文件复制到static文件夹中,并创建一个图像路径/assets/random_uuid_filename.svg。在mt.is_svg()块内添加以下行:

    post_type = PostType::Photo;
    let dest_filename = format!("{}.svg", file_uuid);
    content.push_str("/assets/");
    content.push_str(&dest_filename);
    let dest_path = Path::new(rocket::fs::relative!("static")).join(&dest_filename);
    upload
        .file
        .move_copy_to(&dest_path)
        .await
        .map_err(|_| create_err())?;
    
  16. 对于位图文件,我们希望将它们转换为 JPEG 文件。首先,我们想要定义目标文件名。在mt.is_bmp() || mt.is_jpeg() || mt.is_png() || mt.is_gif()块内添加以下行:

    post_type = PostType::Photo;
    let orig_path = upload.file.path().unwrap().to_string_lossy().to_string();
    let dest_filename = format!("{}.jpg", file_uuid);
    content.push_str("/assets/");
    content.push_str(&dest_filename);
    
  17. 继续处理位图,将文件打开到缓冲区中,并将缓冲区解码为image crate 理解的二进制格式:

    let orig_file = File::open(orig_path).map_err(|_| create_err())?;
    let file_reader = BufReader::new(orig_file);
    let image: DynamicImage = ImageReader::new(file_reader)
        .with_guessed_format()
        .map_err(|_| create_err())?
        .decode()
        .map_err(|_| create_err())?;
    
  18. 在目标文件想要生成的 JPEG 结果的位置创建一个路径,并在该路径下创建一个文件。追加以下行:

    let dest_path = Path::new(rocket::fs::relative!("static")).join(&dest_filename);
    let mut file_writer = File::create(dest_path).map_err(|_| create_err())?;
    
  19. 我们然后创建一个 JPEG 解码器,指定 JPEG 质量和图像属性,并将二进制格式写入目标文件。追加以下行:

    let encoder = JpegEncoder::new_with_quality(&mut file_writer, 75);
    encoder
        .write_image(
            image.as_bytes(),
            image.width(),
            image.height(),
            image.color(),
        )
        .map_err(|_| create_err())?;
    
  20. 最后,我们可以像上一节那样保存帖子。将Post::create()方法更改为以下内容:

    Post::create(connection, user_uuid, post_type, &content)
    ...
    

我们现在已经完成了使用TempFileimagecrate 上传和处理文本和图像文件的例程的创建。不幸的是,这个过程使用了一种更传统的编程范式,可以改进。让我们在下一节学习如何异步处理文件。

异步处理文件

在计算机发展的早期,可用的资源通常在某种程度上有限。例如,老一代 CPU 一次只能执行一个任务。这使得计算变得困难,因为计算机资源必须按顺序等待任务的执行。例如,当 CPU 正在计算一个数字时,用户无法使用键盘输入任何内容。

然后,人们发明了具有调度器的操作系统,它将资源分配给运行任务。调度器的发明导致了线程的概念。线程,或操作系统线程,是可以由调度器独立执行的程序指令的最小序列。

一些现代编程语言可以生成同时生成多个线程的应用程序,因此被称为多线程应用程序。

创建多线程应用程序可能是一个缺点,因为创建一个线程会分配各种资源,例如内存栈。在某些应用程序中,例如桌面应用程序,创建多个线程是合适的。但是,在创建多个线程可能成为问题的其他应用程序中,例如快速请求和响应的 Web 应用程序,可能会出现问题。

有多种技术可以以多种方式克服这个问题。一些语言选择使用绿色线程,或虚拟线程,其中语言运行时管理单个操作系统线程,并使程序表现得像多线程一样。其他一些语言,如 JavaScript 和 Rust,选择使用async/await,这是一种语法特性,允许执行部分被挂起和恢复。

在上一节中,我们使用了 Rust 标准库来打开和写入文件进行图像处理。该库本身被称为阻塞,因为它等待所有文件都已加载或写入。这并不高效,因为 I/O 操作比 CPU 操作慢,而线程可以用来执行其他操作。我们可以通过使用异步编程来改进程序。

在 Rust 中,我们可以如下声明一个async函数:

async fn async_task1() {...}
async fn async_task2() {...}

任何async函数都返回std::future::Future特质。默认情况下,运行函数不会做任何事情。我们可以使用async_task1和一个执行器,例如futures包,来运行async函数。以下代码将表现得像常规编程:

use futures::executor::block_on;
async fn async_task1() {...}
fn main() {
    let wait = async_task1();
    block_on(wait); // wait until async_task1 finish
}

我们可以在函数使用后使用.await来不阻塞线程,如下所示:

async fn combine() {
    async_task1().await;
    async_task2().await;
}
fn main() {
    block_on(combine());
}

或者,我们可以等待两个函数都完成,如下所示:

async fn combine2() {
  let t1 = async_task1();
  let t2 = async_task2();
  futures::join!(t1, t2);
}
fn main() {
  block_on(combine2());
}

futures包非常基础;我们可以使用提供执行器、调度器和其他许多功能的其他运行时。在 Rust 生态系统中,有几个竞争的运行时,例如tokiosmolasync-std。我们可以一起使用这些不同的运行时,但这并不高效,因此建议坚持使用单个运行时。Rocket 本身使用tokio作为async/await的运行时。

我们之前在代码中使用过async函数,现在让我们更深入地使用async函数。让我们按照以下步骤将之前的图像处理转换为使用async编程技术:

  1. Cargo.toml中添加 crate 依赖项:

    tokio = {version = "1.16", features = ["fs", "rt"]}
    
  2. 如果我们查看处理上传的代码,我们可以看到文件相关的操作使用的是标准库,因此是阻塞的。我们希望用 Tokio 等效的async库替换这些库。从src/routes/post.rs中移除use声明:

    use std::fs::File;
    use std::io::{BufReader, Read};
    

然后,添加以下use声明:

use image::error::ImageError;
use std::io::Cursor;
use tokio::fs::File;
use tokio::io::AsyncReadExt;
  1. 将标准库中的mt.is_text()块的内容替换为 Tokio 等效库。找到以下行:

    let _ = File::open(orig_path)
        .map_err(|_| create_err())?
        .read(&mut text_content)
        .map_err(|_| create_err())?;
    

将这些行替换为以下内容:

let _ = File::open(orig_path)
    .await
    .map_err(|_| create_err())?
    .read_to_end(&mut text_content)
    .await
    .map_err(|_| create_err())?;
  1. 接下来,替换mt.is_bmp() || mt.is_jpeg() || mt.is_png() || mt.is_gif()块中的文件读取。将文件的同步读取替换为 Tokio 等效的文件读取功能。我们希望将结果包裹在std::io::Cursor中,因为ImageReader方法需要std::io::Read + std::io:Seek特质,而Cursor是一个实现了这些特质的类型。

找到以下行:

let orig_file = File::open(orig_path).map_err(|_| create_err())?;
let file_reader = BufReader::new(orig_file);

将这些行替换为以下内容:

let orig_file = tokio::fs::read(orig_path).await.map_err(|_| create_err())?;
let read_buffer = Cursor::new(orig_file);
  1. 将图像解码代码包裹在tokio::task::spawn_blocking中。这个函数允许同步代码在 Tokio 执行器中运行。找到以下行:

    let image: DynamicImage = ImageReader::new(file_reader)
        .with_guessed_format()
        .map_err(|_| create_err())?
        .decode()
        .map_err(|_| create_err())?
    

将它们替换为以下行:

let encoded_result: Result<DynamicImage, ()> = tokio::task::spawn_blocking(|| {
    Ok(ImageReader::new(read_buffer)
        .with_guessed_format()
        .map_err(|_| ())?
        .decode()
        .map_err(|_| ())?)
})
.await
.map_err(|_| create_err())?;
let image = encoded_result.map_err(|_| create_err())?;
  1. 接下来,我们希望将 JPEG 编码也包裹在spawn_blocking中。我们还想将文件写入改为 Tokio 的async函数。找到以下行:

    let dest_path = Path::new(rocket::fs::relative!("static")).join(&dest_filename);
    let mut file_writer = File::create(dest_path).map_err(|_| create_err())?;
    JpegEncoder::new_with_quality(&mut file_writer, 75)
        .write_image(
            image.as_bytes(),
            image.width(),
            image.height(),
            image.color(),
        )
        .map_err(|_| create_err())?;
    

将它们替换为以下行:

let write_result: Result<Vec<u8>, ImageError> = tokio::task::spawn_blocking(move || {
    let mut write_buffer: Vec<u8> = vec![];
    let mut write_cursor = Cursor::new(&mut 
    write_buffer);
    let _ = JpegEncoder::new_with_quality(&mut 
    write_cursor, 75).write_image(
        image.as_bytes(),
        image.width(),
        image.height(),
        image.color(),
    )?;
    Ok(write_buffer)
})
.await
.map_err(|_| create_err())?;
let write_bytes = write_result.map_err(|_| create_err())?;
let dest_path = Path::new(rocket::fs::relative!("static")).join(&dest_filename);
tokio::fs::write(dest_path, &write_bytes)
    .await
    .map_err(|_| create_err())?;

现在,我们可以运行应用程序并再次尝试上传功能。应该没有差异,除了现在它使用了async函数。如果有大量请求,异步应用程序应该表现得更好,因为应用程序可以在处理长 I/O(例如从数据库读取和写入、处理网络连接和处理文件等)的同时使用线程来执行其他任务。

还有一个例子,其中应用程序使用 tokio::sync::channel 创建另一个异步通道,以及 rayon(一个用于数据并行的 crate)。您可以在 Chapter10/04UploadingPhotoRayon 文件夹中找到本章源代码中的此示例。

在下一节中,我们将创建用于上传视频和处理视频的 worker 的句柄。

使用 worker 上传视频帖子并处理

在本节中,我们将处理一个上传的视频。处理上传的视频不是一个简单任务,因为它可能需要很长时间,所以即使使用 async 编程技术,生成的响应也会花费很长时间。

编程中解决长时间处理问题的另一种技术是使用消息传递。我们将创建另一个线程来处理视频。当用户上传视频时,我们将执行以下操作:

  1. 生成临时文件的路径。

  2. 将路径标记为未处理。

  3. 将文件的路径存储在数据库中。

  4. 从主 Rocket 线程向处理视频的线程发送消息。

  5. 返回上传视频的响应。

如果处理视频的线程收到消息,它将从数据库中找到数据,处理文件,并将帖子标记为完成。

如果用户在处理过程中请求 posts 列表或帖子,用户将看到加载图像。如果用户在处理完成后请求 posts 列表或帖子,用户将在网络浏览器中看到正确的视频。

Rust 的视频处理库还不够成熟。有几个库可以用来封装 ffmpeg 库,但使用 ffmpeg 库很复杂,即使是在其自己的语言中,也就是 C 语言。一个解决方案是使用 ffmpeg-cli crate,它是 ffmpeg 二进制的封装。

按照以下说明处理上传的视频文件:

  1. 我们希望添加 ffmpeg-cli crate 和 flume crate 作为依赖项。flume crate 通过生成通道、生产者和消费者来工作。还有类似的库,如 std::sync::mpsccrossbeam-channel,可以根据不同的性能和质量使用。将依赖项添加到 Cargo.toml

    flume = "0.10.10"
    ffmpeg-cli = "0.1"
    
  2. 修改表单以允许上传视频文件。编辑 src/views/posts/index.html.tera

    <input type="file" name="file" accept="text/plain,image/*,video/*">
    
  3. 找到一个占位图来显示视频仍在处理中。在 Chapter10/05ProcessingVideo/static/loading.gif 文件夹中可以找到本节源代码中的 loading.gif 示例文件。

  4. 修改 src/models/video_post.rsVideoPostraw_html() 方法,以显示如果视频尚未处理,则显示 loading.gif 图像:

    fn raw_html(&self) -> String {
        if self.0.content.starts_with("loading") {
            return String::from(
    "<figure><img src=\"/assets/loading.gif\" 
                class=\"section media\"/></figure>",
            );
        }
        ...
    }
    
  5. 我们希望有一个方法来更新 Post 的内容并将其标记为永久。在 src/models/post.rsimpl Post{} 块中添加以下方法:

    pub async fn make_permanent(
        connection: &mut PgConnection,
        uuid: &str,
        content: &str,
    ) -> Result<Post, OurError> {
        let parsed_uuid = Uuid::parse_str(uuid).map_err(
        OurError::from_uuid_error)?;
        let query_str = String::from("UPDATE posts SET 
        content = $1 WHERE uuid = $2 RETURNING *");
        Ok(sqlx::query_as::<_, Self>(&query_str)
            .bind(content)
            .bind(&parsed_uuid)
            .fetch_one(connection)
            .await
            .map_err(OurError::from_sqlx_error))?
    }
    
  6. 我们希望创建一个要发送到通道的消息。在 src/models/mod.rs 中添加一个新的模块:

    pub mod worker;
    
  7. 然后,创建一个新文件,src/models/worker.rs。在文件中创建一个新的 Message 结构体如下:

    pub struct Message {
        pub uuid: String,
        pub orig_filename: String,
        pub dest_filename: String,
    }
    impl Message {
        pub fn new() -> Self {
            Message {
                uuid: String::new(),
                orig_filename: String::new(),
                dest_filename: String::new(),
            }
        }
    }
    
  8. 创建一个当通道接收到消息时将被执行的工作线程。在 src/lib.rs 中添加一个名为 worker 的新模块:

    pub mod workers;
    
  9. 创建一个名为 workers 的文件夹。然后,创建一个新文件,src/workers/mod.rs,并添加一个新的视频模块:

    pub mod video;
    
  10. 创建一个新文件,src/workers/video.rs,并添加所需的 use 声明:

    use crate::models::post::Post;
    use crate::models::worker::Message;
    use ffmpeg_cli::{FfmpegBuilder, File, Parameter};
    use sqlx::pool::PoolConnection;
    use sqlx::Postgres;
    use std::process::Stdio;
    use tokio::runtime::Handle;
    
  11. 添加以下函数签名来处理视频:

    pub fn process_video(connection: &mut PoolConnection<Postgres>, wm: Message) -> Result<(), ()> {...}
    
  12. process_video() 函数内部,添加以下行来准备目标文件:

    let mut dest = String::from("static/");
    dest.push_str(&wm.dest_filename);
    
  13. 我们希望将所有文件重新编码成 MP4 文件,并使用 x265 编解码器作为视频文件的目标。将这些行添加到构建 ffmpeg 二进制文件的参数:

    let builder = FfmpegBuilder::new()
        .stderr(Stdio::piped())
        .option(Parameter::Single("nostdin"))
        .option(Parameter::Single("y"))
        .input(File::new(&wm.orig_filename))
        .output(
            File::new(&dest)
                .option(Parameter::KeyValue("vcodec", 
                "libx265"))
                .option(Parameter::KeyValue("crf", "28")),
        );
    
  14. 对于工作线程来说,接下来的最后一步是执行构建器。我们也可以将其设置为 async。添加以下行:

    let make_permanent = async {
        let ffmpeg = builder.run().await.unwrap();
        let _ = ffmpeg.process.wait_with_output().
        unwrap();
        let mut display_path = String::from("/assets/");
        display_path.push_str(&wm.dest_filename);
        Post::make_permanent(connection, &wm.uuid, 
        &display_path).await
    };
    let handle = Handle::current();
    Ok(handle
        .block_on(make_permanent)
        .map(|_| ())
        .map_err(|_| ())?)
    
  15. 我们接下来想要做的是创建一个线程来接收和处理消息。我们可以在初始化 src/main.rs 中的 Rocket 之后添加一个新的线程。我们想要做几件事情:

    • 初始化一个 worker 线程。

    • 初始化一个生产者(消息发送者)和一个消费者(消息接收者)。

    • 初始化一个数据库连接池。

    • worker 线程中,消费者将从数据库连接池中获取一个连接并处理消息。

让我们从在 src/main.rs 中添加 use 声明开始:

use our_application::models::worker::Message;
use our_application::workers::video::process_video;
use rocket::serde::Deserialize;
use sqlx::postgres::PgPoolOptions;
use tokio::runtime::Handle;
  1. src/main.rs 中的 use 声明之后添加结构体以从 Rocket.toml 获取数据库配置:

    #[derive(Deserialize)]
    struct Config {
        databases: Databases,
    }
    #[derive(Deserialize)]
    struct Databases {
        main_connection: MainConnection,
    }
    #[derive(Deserialize)]
    struct MainConnection {
        url: String,
    }
    
  2. rocket() 函数中的 setup_logger() 之后,初始化 flume 生产者和消费者如下:

    let (tx, rx) = flume::bounded::<Message>(5);
    
  3. 让 Rocket 管理 tx 变量。我们还想将生成的 Rocket 对象赋值给一个变量,因为我们想获取数据库配置。找到以下行:

    rocket::build()
        .attach(DBConnection::init())
        .attach(Template::fairing())
        .attach(Csrf::new())
        .mount(...)
    

将它们替换为以下行:

let our_rocket = rocket::build()
    .attach(DBConnection::init())
    .attach(Template::fairing())
    .attach(Csrf::new())
    .manage(tx)
    .mount(...);
  1. 在我们获取 our_rocket 之后,我们想要获取数据库配置并为工作线程初始化一个新的数据库连接池。添加以下行:

    let config: Config = our_rocket
        .figment()
        .extract()
        .expect("Incorrect Rocket.toml configuration");
    let pool = PgPoolOptions::new()
        .max_connections(5)
        .connect(&config.databases.main_connection.url)
        .await
        .expect("Failed to connect to database");
    
  2. 创建一个线程来接收和处理消息。同时,别忘了为了返回 our_rocket 作为 rocket() 签名,我们需要 Rocket<Build> 返回值。添加以下行:

    tokio::task::spawn_blocking(move || loop {
        let wm = rx.recv().unwrap();
        let handle = Handle::current();
        let get_connection = async { (&pool).
        acquire().await.unwrap() };
        let mut connection = handle.block_on(get_
        connection);
        let _ = process_video(&mut connection, wm);
    });
    our_rocket
    
  3. 现在,在创建视频后,在 create_post() 路由处理函数中使用管理的 tx 变量发送消息。在 src/routes/post.rs 中添加所需的 use 声明:

    use crate::errors::our_error::OurError;
    use crate::models::worker::Message;
    use flume::Sender;
    use rocket::State;
    
  4. create_post() 函数中,检索由 Rocket 管理的 Sender<Message> 实例。将 Sender<Message> 实例添加到函数参数中:

    pub async fn create_post<'r>(
        ...
        tx: &State<Sender<Message>>,
    )
    
  5. if mt.is_text() 块之前,添加以下变量:

    let mut wm = Message::new();
    let mut is_video = false;
    
  6. if mt.is_svg() {} 块之后,添加一个新的块来初始化一个临时视频值并将其赋值给我们已经初始化的 wm 变量:

    else if mt.is_mp4() || mt.is_mpeg() || mt.is_ogg() || mt.is_mov() || mt.is_webm() {
        post_type = PostType::Video;
        let dest_filename = format!("{}.mp4", file_uuid);
        content.push_str("loading/assets/");
        content.push_str(&dest_filename);
        is_video = true;
        wm.orig_filename = upload
            .file
            .path()
            .unwrap()
            .to_string_lossy()
            .to_string()
            .clone();
        wm.dest_filename = dest_filename.clone();
    }
    
  7. 在以下行中找到帖子创建和返回值:

    Post::create(connection, user_uuid, post_type, &content)
        .await
        .map_err(|_| create_err())?;
    Ok(Flash::success(
        Redirect::to(format!("/users/{}/posts", 
        user_uuid)),
        "Successfully created post",
    ))
    

将其修改为以下行:

Ok(Post::create(connection, user_uuid, post_type, &content)
    .await
    .and_then(move |post| {
        if is_video {
            wm.uuid = post.uuid.to_string();
            let _ = tx.send(wm).map_err(|_| {
                OurError::new_internal_server_error(
                    String::from("Cannot process 
                    message"),
                    None,
                )
            })?;
        }
        Ok(Flash::success(
            Redirect::to(format!("/users/{}/posts", 
            user_uuid)),
            "Successfully created post",
        ))
    })
    .map_err(|_| create_err())?)

现在尝试重新启动应用程序并上传视频文件;注意加载页面。如果视频已经被处理,视频应该会显示:

图 10.2 – 上传的视频帖子

图 10.2 – 上传的视频帖子

消息传递是一种非常实用的技术,用于处理长时间运行的任务。如果你的应用程序需要大量处理但需要快速返回响应,请尝试使用这项技术。

一些应用程序使用更高级的应用程序,称为消息代理,它可以重试发送消息、安排发送消息、将消息发送到多个应用程序等。一些知名的消息代理应用程序包括 RabbitMQ、ZeroMQ 和 Redis。许多云服务也提供消息代理服务,例如 Google Cloud Pub/Sub。

在我们完成本章之前,我们还可以做一件事:删除用户帖子。尝试编写 delete_post() 函数。你可以在 GitHub 的 Chapter10/06DeletingPost 文件夹中找到示例代码。

摘要

在本章中,我们学到了很多东西。

我们首先学习的是如何在 Rocket 中处理多部分表单。之后,我们学习了如何使用 TempFile 上传文件。在上传照片和视频的同时,我们还学习了如何处理图像文件和视频文件。

我们通过 async/await 和多线程学习了更多关于并发编程的知识。我们还介绍了如何创建线程并将消息传递给不同的线程。

在下一章中,我们将重点关注如何在 Rocket 应用程序中实现身份验证、授权以及提供 API 服务。

第十一章:第十一章:安全性和添加 API 及 JSON

网络应用的两个最重要的方面是认证和授权。在本章中,我们将学习如何实现简单的认证和授权系统。在创建了这些系统之后,我们将学习如何创建一个简单的应用程序编程接口API)以及如何使用JSON Web TokenJWT)保护 API 端点。

在本章结束时,你将能够创建一个认证系统,包括登录和登出以及为已登录用户设置访问权限的功能。你还将能够创建一个 API 服务器,并了解如何保护 API 端点。

在本章中,我们将涵盖以下主要主题:

  • 用户认证

  • 用户授权

  • 处理 JSON

  • 使用 JWT 保护 API

技术要求

对于本章,我们有通常的要求:一个 Rust 编译器、一个文本编辑器、一个网络浏览器和一个 PostgreSQL 数据库服务器,以及 FFmpeg 命令行。在本章中,我们将学习关于 JSON 和 API 的知识。安装 cURL 或其他任何 HTTP 测试客户端。

你可以在github.com/PacktPublishing/Rust-Web-Development-with-Rocket/tree/main/Chapter11找到本章的源代码。

用户认证

网络应用中最常见的任务之一是处理注册和登录。通过登录,用户可以向网络服务器表明他们确实是他们所说的那个人。

当我们为用户模型实现 CRUD 时,我们已经创建了一个注册系统。现在,让我们使用现有的用户模型来实现登录系统。

登录的想法很简单:用户可以填写他们的用户名和密码。然后,应用程序会验证用户名和密码是否有效。之后,应用程序可以生成包含用户信息的 cookie 并将其返回给网络浏览器。每当浏览器有请求时,cookie 都会从浏览器发送回服务器,我们验证 cookie 的内容。

为了确保我们不必为每个请求实现 cookie,如果我们使用请求保护器在路由处理函数中自动验证 cookie,我们可以创建一个请求保护器。

让我们按照以下步骤开始实现用户登录系统:

  1. 创建一个请求保护器来处理用户认证 cookie。如果我们想在路由处理函数中添加新的请求保护器,我们可以将请求保护器组织在同一个地方,这样会更方便。在src/lib.rs中添加一个新的模块:

    pub mod guards;
    
  2. 然后,创建一个名为src/guards的文件夹。在src/guards内部,添加一个名为src/guards/mod.rs的文件。在这个新文件中添加一个新的模块:

    pub mod auth;
    
  3. 然后,创建一个名为src/guards/auth.rs的新文件。

  4. 创建一个结构体来处理用户认证 cookie。让我们将这个结构体命名为CurrentUser。在src/guards/auth.rs中,添加一个结构体来存储User信息:

    use crate::fairings::db::DBConnection;
    use crate::models::user::User;
    use rocket::http::Status;
    use rocket::request::{FromRequest, Outcome, Request};
    use rocket::serde::Serialize;
    use rocket_db_pools::{sqlx::Acquire, Connection};
    #[derive(Serialize)]
    pub struct CurrentUser {
        pub user: User,
    }
    
  5. 定义一个常量,该常量将用作 cookie 的键来存储用户的全局唯一标识符UUID):

    pub const LOGIN_COOKIE_NAME: &str = "user_uuid";
    
  6. CurrentUser实现FromRequest特质,使结构体成为一个请求守卫。添加以下实现框架:

    #[rocket::async_trait]
    impl<'r> FromRequest<'r> for CurrentUser {
        type Error = ();
        async fn from_request(req: &'r Request<'_>) -> 
        Outcome<Self, Self::Error> {
        }
    }
    
  7. from_request函数内部,定义一个错误,如果发生错误则返回:

    let error = Outcome::Failure((Status::Unauthorized, ()));
    
  8. 从请求中获取 cookie,并按如下方式提取 cookie 中的 UUID:

    let parsed_cookie = req.cookies().get_private(LOGIN_COOKIE_NAME);
    if parsed_cookie.is_none() {
        return error;
    }
    let cookie = parsed_cookie.unwrap();
    let uuid = cookie.value();
    
  9. 我们想要获取数据库的连接以查找用户信息。我们可以在请求守卫实现内部获取另一个请求守卫(例如Connection<DBConnection>)。添加以下行:

    let parsed_db = req.guard::<Connection<DBConnection>>().await;
    if !parsed_db.is_success() {
        return error;
    }
    let mut db = parsed_db.unwrap();
    let parsed_connection = db.acquire().await;
    if parsed_connection.is_err() {
        return error;
    }
    let connection = parsed_connection.unwrap();
    
  10. 查找并返回用户。添加以下行:

    let found_user = User::find(connection, uuid).await;
    if found_user.is_err() {
        return error;
    }
    let user = found_user.unwrap();
    Outcome::Success(CurrentUser { user })
    
  11. 接下来,我们想要实现登录本身。我们将创建一个like sessions/new路由来获取登录页面,一个sessions/create路由来发送登录的用户名和密码,以及一个sessions/delete路由用于登出。在实现这些路由之前,让我们为登录创建一个模板。在src/views中添加一个名为sessions的新文件夹。然后,创建一个名为src/views/sessions/new.html.tera的文件。将以下行追加到该文件中:

    {% extends "template" %}
    {% block body %}
      <form accept-charset="UTF-8" action="login" 
      autocomplete="off" method="POST">
        <input type="hidden" name="authenticity_token" 
        value="{{ csrf_token }}"/>
        <fieldset>
          <legend>Login</legend>
          <div class="row">
            <div class="col-sm-12 col-md-3">
              <label for="username">Username:</label>
            </div>
            <div class="col-sm-12 col-md">
              <input name="username" type="text" value="" 
              />
            </div>
          </div>
          <div class="row">
            <div class="col-sm-12 col-md-3">
              <label for="password">Password:</label>
            </div>
            <div class="col-sm-12 col-md">
              <input name="password" type="password" />
            </div>
          </div>
          <button type="submit" value="Submit">
          Submit</button>
        </fieldset>
      </form>
    {% endblock %}
    
  12. src/models/user.rs中,添加一个用于登录信息的结构体:

    #[derive(FromForm)]
    pub struct Login<'r> {
        pub username: &'r str,
        pub password: &'r str,
        pub authenticity_token: &'r str,
    }
    
  13. 在同一文件中,我们想要为User结构体创建一个方法,以便能够根据登录用户名信息从数据库中查找用户,并验证登录密码是否正确。在通过update方法验证密码正确后,是时候重构了。创建一个新的函数来验证密码:

    fn verify_password(ag: &Argon2, reference: &str, password: &str) -> Result<(), OurError> {
        let reference_hash = PasswordHash::new(
        reference).map_err(|e| {
            OurError::new_internal_server_error(
            String::from("Input error"), Some(
            Box::new(e)))
        })?;
        Ok(ag
            .verify_password(password.as_bytes(), 
            &reference_hash)
            .map_err(|e| {
                OurError::new_internal_server_error(
                    String::from("Cannot verify 
                    password"),
                    Some(Box::new(e)),
                )
            })?)
    }
    
  14. update方法从以下行更改:

    let old_password_hash = PasswordHash::new(&old_user.password_hash).map_err(|e| {
        OurError::new_internal_server_error(
        String::from("Input error"), Some(Box::new(e)))
    })?;
    let argon2 = Argon2::default();
    argon2
        .verify_password(user.old_password.as_bytes(), 
        &old_password_hash)
        .map_err(|e| {
            OurError::new_internal_server_error(
                String::from("Cannot confirm old 
                password"),
                Some(Box::new(e)),
            )
        })?;
    

然后,将其更改为以下行:

let argon2 = Argon2::default();
verify_password(&argon2, &old_user.password_hash, user.old_password)?;
  1. 创建一个基于登录用户名查找用户的方法。在impl User块中,添加以下方法:

    pub async fn find_by_login<'r>(
        connection: &mut PgConnection,
        login: &'r Login<'r>,
    ) -> Result<Self, OurError> {
        let query_str = "SELECT * FROM users WHERE 
        username = $1";
        let user = sqlx::query_as::<_, Self>(query_str)
            .bind(&login.username)
            .fetch_one(connection)
            .await
            .map_err(OurError::from_sqlx_error)?;
        let argon2 = Argon2::default();
        verify_password(&argon2, &user.password_hash, 
        &login.password)?;
        Ok(user)
    }
    
  2. 现在,实现处理登录的路由。在src/routes/mod.rs中创建一个新的mod

    pub mod session;
    

然后,创建一个名为src/routes/session.rs的新文件。

  1. src/routes/session.rs中,创建一个名为new的路由处理函数。我们希望该函数为我们之前创建的登录模板提供渲染后的模板。添加以下行:

    use super::HtmlResponse;
    use crate::fairings::csrf::Token as CsrfToken;
    use rocket::request::FlashMessage;
    use rocket_dyn_templates::{context, Template};
    #[get("/login", format = "text/html")]
    pub async fn new<'r>(flash: Option<FlashMessage<'_>>, csrf_token: CsrfToken) -> HtmlResponse {
        let flash_string = flash
            .map(|fl| format!("{}", fl.message()))
            .unwrap_or_else(|| "".to_string());
        let context = context! {
            flash: flash_string,
            csrf_token: csrf_token,
        };
        Ok(Template::render("sessions/new", context))
    }
    
  2. 然后,创建一个名为create的新函数。在这个函数中,我们想要找到用户并验证与数据库中的密码散列匹配的密码。如果一切顺利,设置包含用户信息的 cookie。追加以下行:

    use crate::fairings::db::DBConnection;
    use crate::guards::auth::LOGIN_COOKIE_NAME;
    use crate::models::user::{Login, User};
    use rocket::form::{Contextual, Form};
    use rocket::http::{Cookie, CookieJar};
    use rocket::response::{Flash, Redirect};
    use rocket_db_pools::{sqlx::Acquire, Connection};
    ...
    #[post("/login", format = "application/x-www-form-urlencoded", data = "<login_context>")]
    pub async fn create<'r>(
        mut db: Connection<DBConnection>,
        login_context: Form<Contextual<'r, Login<'r>>>,
        csrf_token: CsrfToken,
        cookies: &CookieJar<'_>,
    ) -> Result<Flash<Redirect>, Flash<Redirect>> {
        let login_error = || Flash::error(
        Redirect::to("/login"), "Cannot login");
        if login_context.value.is_none() {
            return Err(login_error());
        }
        let login = login_context.value.as_ref().unwrap();
        csrf_token
            .verify(&login.authenticity_token)
            .map_err(|_| login_error())?;
        let connection = db.acquire().await.map_err(|_| 
        login_error())?;
        let user = User::find_by_login(connection, login)
            .await
            .map_err(|_| login_error())?;
        cookies.add_private(Cookie::new(LOGIN_COOKIE_NAME, 
        user.uuid.to_string()));
        Ok(Flash::success(Redirect::to("/users"), "Login 
        successfully"))
    }
    
  3. 最后,创建一个名为delete的函数。我们将使用此函数作为登出路由。追加以下行:

    #[post("/logout", format = "application/x-www-form-urlencoded")]
    pub async fn delete(cookies: &CookieJar<'_>) -> Flash<Redirect> {
        cookies.remove_private(
        Cookie::named(LOGIN_COOKIE_NAME));
        Flash::success(Redirect::to("/users"), "Logout 
        successfully")
    }
    
  4. session::newsession::createsession::delete添加到src/main.rs中:

    use our_application::routes::{self, post, session, user};
    ...
    async fn rocket() -> Rocket<Build> {
        ...
        routes![
        ...
            session::new,
            session::create,
            session::delete,
        ]
        ...
    }
    
  5. 现在,我们可以使用CurrentUser来确保只有登录用户才能访问我们应用程序中的一些端点。在src/routes/user.rs中,删除edit端点中查找用户的例程。删除以下行:

    pub async fn edit_user(
        mut db: Connection<DBConnection>,
        ...
    ) -> HtmlResponse {
        let connection = db
            .acquire()
            .await
            .map_err(|_| Status::InternalServerError)?;
    let user = User::find(connection, 
        uuid).await.map_err(|e| e.status)?;
        ...
    }
    
  6. 然后,将CurrentUser添加到需要登录用户的路由中,如下所示:

    use crate::guards::auth::CurrentUser;
    ...
    pub async fn edit_user(...
        current_user: CurrentUser,
    ) -> HtmlResponse {
        ...
        let context = context! {
            form_url: format!("/users/{}", uuid),
            ...
            user: &current_user.user,
            current_user: &current_user,
            ...
        };
        ...
    }
    ...
    pub async fn update_user<'r>(...
        current_user: CurrentUser,
    ) -> Result<Flash<Redirect>, Flash<Redirect>> {
        ...
        match user_value.method {
            "PUT" => put_user(db, uuid, user_context, 
            csrf_token, current_user).await,
            "PATCH" => patch_user(db, uuid, user_context, 
            csrf_token, current_user).await,
            ...
        }
    }
    ...
    pub async fn put_user<'r>(...
        _current_user: CurrentUser,
    ) -> Result<Flash<Redirect>, Flash<Redirect>> {...}
    ...
    pub async fn patch_user<'r>(...
        current_user: CurrentUser,
    ) -> Result<Flash<Redirect>, Flash<Redirect>> {
        put_user(db, uuid, user_context, csrf_token, 
        current_user).await
    }
    ...
    pub async fn delete_user_entry_point(...
        current_user: CurrentUser,
    ) -> Result<Flash<Redirect>, Flash<Redirect>> {
        delete_user(db, uuid, current_user).await
    }
    ...
    pub async fn delete_user(...
        _current_user: CurrentUser,
    ) -> Result<Flash<Redirect>, Flash<Redirect>> {...}
    
  7. 最后,在src/routes/post.rs的端点上也进行保护。只有已登录用户可以上传和删除帖子,因此将代码修改为以下内容:

    crate::guards::auth::CurrentUser;
    ...
    pub async fn create_post<'r>(...
        _current_user: CurrentUser,
    ) -> Result<Flash<Redirect>, Flash<Redirect>> {...}
    ...
    pub async fn delete_post(...
        _current_user: CurrentUser,
    ) -> Result<Flash<Redirect>, Flash<Redirect>> {...}
    

在我们实现认证之前,我们可以编辑和删除任何用户或帖子。现在尝试在不登录的情况下编辑或删除某些内容。然后,尝试登录并删除和编辑。

仍然存在一个问题:登录后,用户可以编辑和删除其他用户的资料。我们将在下一节中通过实现授权来学习如何防止这个问题。

授权用户

认证和授权是信息安全中的两个主要概念。如果认证是一种证明实体是其所称实体的方式,那么授权就是一种赋予实体权利的方式。一个实体可能能够修改某些资源,一个实体可能能够修改所有资源,一个实体可能只能查看有限资源,等等。

在上一节中,我们实现了登录和CurrentUser等认证概念;现在是时候实现授权了。想法是确保已登录用户只能修改他们自己的信息和帖子。

请记住,这个例子非常简单。在更高级的信息安全中,有更高级的概念,例如基于角色的访问控制。例如,我们可以创建一个名为admin的角色,我们可以将某个用户设置为admin,而admin可以无限制地做任何事情。

让我们按照以下步骤尝试实现简单的授权:

  1. CurrentUser添加一个简单的方法来比较其实例与 UUID。在src/guards/auth.rs中追加以下行:

    impl CurrentUser {
        pub fn is(&self, uuid: &str) -> bool {
            self.user.uuid.to_string() == uuid
        }
        pub fn is_not(&self, uuid: &str) -> bool {
            !self.is(uuid)
        }
    }
    
  2. 添加一种新的错误类型。在src/errors/our_error.rs文件中的impl OurError {}块中添加一个new方法:

    pub fn new_unauthorized_error(debug: Option<Box<dyn Error>>) -> Self {
        Self::new_error_with_status(Status::Unauthorized, 
        String::from("unauthorized"), debug)
    }
    
  3. 我们可以在模板中检查CurrentUser实例来控制应用程序的流程。例如,如果没有CurrentUser实例,我们显示注册和登录的链接。如果有CurrentUser实例,我们显示注销的链接。让我们修改 Tera 模板。编辑src/views/template.html.tera文件并追加以下行:

    <body>
      <header>
        <a href="/users" class="button">Home</a>
        {% if current_user %}
    <form accept-charset="UTF-8" action="/logout" 
          autocomplete="off" method="POST" id="logout"  
          class="hidden"></form>
          <button type="submit" value="Submit" form="
          logout">Logout</button>
        {% else %}
          <a href="/login" class="button">Login</a>
          <a href="/users/new" class="button">Signup</a>
        {% endif %}
      </header>
      <div class="container">
    
  4. 编辑src/views/users/index.html.tera文件并删除以下行:

    <a href="/users/new" class="button">New user</a>
    

找到这一行:

<a href="/users/edit/{{ user.uuid }}" class="button">Edit User</a>

将其修改为以下行:

{% if current_user and current_user.user.uuid == user.uuid %}
    <a href="/users/edit/{{user.uuid}}" class="
    button">Edit User</a>
{% endif %}
  1. 编辑src/views/users/show.html.tera文件并找到这些行:

    <a href="/users/edit/{{user.uuid}}" class="button">Edit User</a>
    <form accept-charset="UTF-8" action="/users/delete/{{user.uuid}}" autocomplete="off" method="POST" id="deleteUser" class="hidden"></form>
    <button type="submit" value="Submit" form="deleteUser">Delete</button>
    

然后,用以下条件检查包围这些行:

{% if current_user and current_user.user.uuid == user.uuid %}
     <a href... 
    ... 
    </button>
{% endif %}
  1. 接下来,我们希望只允许已登录用户上传。在src/views/posts/index.html.tera文件中找到表单行:

    <form action="/users/{{ user.uuid }}/posts" enctype="multipart/form-data" method="POST">
    ...
    </form>
    

用以下条件包围表单行:

{% if current_user %}
     <form action="/users/{{ user.uuid }}/posts" enctype="multipart/form-data" method="POST"> 
    ... 
    </form>
{% endif %}
  1. 现在对模板进行最后的修改。我们希望只有帖子的所有者才能删除帖子。在src/views/posts/show.html.tera文件中找到这些行:

    <form accept-charset="UTF-8" action="/users/{{user.uuid}}/posts/delete/{{post.uuid}}" autocomplete="off" method="POST" id="deletePost" class="hidden"></form>
    <button type="submit" value="Submit" form="deletePost">Delete</button>
    

用以下行包围它们:

{% if current_user and current_user.user.uuid == user.uuid %}
    <form... 
    ... 
    </button>
{% endif %}
  1. 修改路由处理函数以获取 current_user 的值。记住,我们可以将请求保护器包装在 Option 中,例如 Option<CurrentUser>。当一个路由处理函数无法获取 CurrentUser 实例(例如,没有已登录的用户)时,它将生成 OptionNone 变体。然后我们可以将实例传递给模板。

让我们从 src/routes/post.rs 开始转换路由处理函数。按照以下方式修改 get_post() 函数:

pub async fn get_post(...
    current_user: Option<CurrentUser>,
) -> HtmlResponse {
    ...
    let context = context! {user, current_user, post: 
    &(post.to_show_post())};
    Ok(Template::render("posts/show", context))
}
  1. 让我们对 get_posts() 函数做同样的事情。按照以下方式修改函数:

    pub async fn get_posts(...
        current_user: Option<CurrentUser>,
    ) -> HtmlResponse {
        let context = context! {
            ...
            current_user,
        };
        Ok(Template::render("posts/index", context))
    }
    
  2. 为了确保 create_post() 函数的安全性,我们可以检查上传文件的用户的 UUID 是否与 URL 上的 user_uuid 相同。这个检查是为了防止已登录的攻击者篡改请求并发送虚假请求。在我们进行文件操作之前,将检查放入 create_post() 函数中,如下所示:

    pub async fn create_post<'r>(...
        current_user: CurrentUser,
    ) -> Result<Flash<Redirect>, Flash<Redirect>> {
        ...
        if current_user.is_not(user_uuid) {
            return Err(create_err());
        }
        ...
    }
    
  3. 我们可以在 src/routes/post.rs 中的 delete_post() 函数进行相同的检查。我们希望阻止未经授权的用户发送篡改的请求并删除他人的帖子。按照以下方式修改 delete_post() 函数:

    pub async fn delete_post(...
        current_user: CurrentUser,
    ) -> Result<Flash<Redirect>, Flash<Redirect>> {
        ...
        if current_user.is_not(user_uuid) {
            return Err(delete_err());
        }
        ...
    }
    
  4. 尝试重新启动应用程序,登录,并查看您是否可以删除他人的帖子。还尝试通过应用相同的原理修改 src/routes/user.rs:获取 CurrentUser 实例并应用必要的检查,或将 CurrentUser 实例传递给模板。您可以在 github.com/PacktPublishing/Rust-Web-Development-with-Rocket/tree/main/Chapter11/02Authorization 找到完整的代码,包括保护用户相关路由。

  5. 网络服务器最常见的任务之一是提供 API,并且一些 API 必须防止不受欢迎的使用。在下一节中,我们将学习如何提供 API 并保护 API 端点。

处理 JSON

网络应用程序的常见任务之一是处理 API。API 可以返回很多不同的格式,但现代 API 已经收敛为两种常见的格式:JSON 和 XML。

在 Rocket 网络框架中,构建返回 JSON 的端点相当简单。对于处理 JSON 格式的请求体,我们可以使用 rocket::serde::json::Json<T> 作为数据保护器。泛型 T 类型必须实现 serde::Deserialize 特性,否则 Rust 编译器将拒绝编译。

对于响应,我们可以通过返回 rocket::serde::json::Json<T> 来做同样的事情。泛型 T 类型在用作响应时必须仅实现 serde::Serialize 特性。

让我们看看如何处理 JSON 请求和响应的示例。我们希望创建一个单独的 API 端点 /api/users。此端点可以接收类似于 our_application::models::pagination::Pagination 结构的 JSON 主体,如下所示:

{"next":"2022-02-22T22:22:22.222222Z","limit":10}

按照以下步骤实现 API 端点:

  1. OurError 实现 serde::Serialize。将这些行添加到 src/errors/our_error.rs

    use rocket::serde::{Serialize, Serializer};
    use serde::ser::SerializeStruct;
    ...
    impl Serialize for OurError {
        fn serialize<S>(&self, serializer: S) -> 
        Result<S::Ok, S::Error>
        where
            S: Serializer,
        {
            let mut state = serializer.
            serialize_struct("OurError", 2)?;
            state.serialize_field("status", &self
            .status.code)?;
            state.serialize_field("message", &self
            .message)?;
            state.end()
        }
    }
    
  2. 我们希望 Pagination 继承 Deserialize 并自动实现 Deserialize 特性,因为 Pagination 将会在 JSON 数据保护器 Json<Pagination> 中使用。由于 Pagination 包含 OurDateTime 成员,因此 OurDateTime 也必须实现 Deserialize 特性。修改 src/models/our_date_time.rs 并添加 Deserialize derive 宏:

    use rocket::serde::{Deserialize, Serialize};
    ...
    #[derive(Debug, sqlx::Type, Clone, Serialize, Deserialize)]
    #[sqlx(transparent)]
    pub struct OurDateTime(pub DateTime<Utc>);
    
  3. Pagination 实现 SerializeDeserialize。我们还想实现 Serialize,因为我们想将 Pagination 作为 /api/users 端点的响应的一部分使用。按照以下方式修改 src/models/pagination.rs

    use rocket::serde::{Deserialize, Serialize};
    ...
    #[derive(FromForm, Serialize, Deserialize)]
    pub struct Pagination {...}
    
  4. 对于 User 结构体,它已经自动继承了 Serialize,因此我们可以在 User 的向量中使用它。需要修复的一个问题是,我们不希望密码包含在生成的 JSON 中。Serde 有许多宏可以控制如何从结构体生成序列化数据。添加一个宏来跳过 password_hash 字段。修改 src/models/user.rs

    pub struct User {
        ...
        #[serde(skip_serializing)]
        pub password_hash: String,
        ...
    }
    
  5. 我们希望返回 UserPagination 的向量作为生成的 JSON。我们可以创建一个新的结构体来将这些封装在一个字段中。在 src/models/user.rs 中添加以下行:

    #[derive(Serialize)]
    pub struct UsersWrapper {
        pub users: Vec<User>,
        #[serde(skip_serializing_if = "Option::is_none")]
        #[serde(default)]
        pub pagination: Option<Pagination>,
    }
    

注意,如果 pagination 字段为 None,我们将跳过它。

  1. src/routes/mod.rs 中添加一个新的模块:

    pub mod api;
    

然后,在 src/routes/api.rs 中创建一个新文件。

  1. src/routes/api.rs 中添加通常的 use 声明、模型、错误和数据库连接:

    use crate::errors::our_error::OurError;
    use crate::fairings::db::DBConnection;
    use crate::models::{
        pagination::Pagination,
        user::{User, UsersWrapper},
    };
    use rocket_db_pools::Connection;
    
  2. 添加 use 声明 rocket::serde::json::Json

    use rocket::serde::json::Json;
    
  3. 添加一个处理函数定义以获取用户:

    #[get("/users", format = "json", data = "<pagination>")]
    pub async fn users(
        mut db: Connection<DBConnection>,
        pagination: Option<Json<Pagination>>,
    ) -> Result<Json<UsersWrapper>, Json<OurError>> {}
    
  4. 实现函数。在函数中,我们可以使用 into_inner() 方法获取 JSON 的内容,如下所示:

    let parsed_pagination = pagination.map(|p| p.into_inner());
    
  5. 查找用户。添加以下行:

    let (users, new_pagination) = User::find_all(&mut db, parsed_pagination)
        .await
        .map_err(|_| OurError::new_internal_server_
        error(String::from("Internal Error"), None))?;
    

由于我们已经为 OurError 实现了 Serialize 特性,我们可以自动返回该类型。

  1. 现在,是时候返回 UsersWrapper。添加以下行:

    Ok(Json(UsersWrapper {
        users,
        pagination: new_pagination,
    }))
    
  2. 最后要做的事情是将路由添加到 src/main.rs

    use our_application::routes::{self, api, post, session, user};
    ...
    .mount("/", ...)
    .mount("/assets", FileServer::from(relative!("static")))
    .mount("/api", routes![api::users])
    
  3. 尝试运行应用程序并向 http://127.0.0.1:8000/api/users 发送请求。我们可以使用任何 HTTP 客户端,但如果使用 cURL,它将如下所示:

    curl -X GET -H "Content-Type: application/json" -d "{\"next\":\"2022-02-22T22:22:22.222222Z\",\"limit\":1}" http://127.0.0.1:8000/api/users
    

应用程序应该返回类似以下输出的内容:

{"users":[{"uuid":"8faa59d6-1079-424a-8eb9-09ceef1969c8","username":"example","email":"example@example.com","description":"example","status":"Inactive","created_at":"2021-11-06T06:09:09.534864Z","updated_at":"2021-11-06T06:09:09.534864Z"}],"pagination":{"next":"2021-11-06T06:09:09.534864Z","limit":1}}

现在我们已经完成了 API 端点的创建,接下来让我们在下一节尝试保护这个端点。

使用 JWT 保护 API

我们想要执行的一个常见任务是保护 API 端点免受未经授权的访问。有许多原因需要保护 API 端点,例如保护敏感数据、进行金融服务或提供订阅服务。

在网络浏览器中,我们可以通过创建会话、分配一个 cookie 给会话并将会话返回给网络浏览器来保护服务器端点,但 API 客户端不总是网络浏览器。API 客户端可以是移动应用程序、其他 Web 应用程序、硬件监控器等等。这引发了一个问题,我们如何保护 API 端点?

保护 API 端点有很多方法,但一个行业标准是使用 JWT。根据IETF RFC7519,JWT 是一种紧凑、URL 安全的表示声明的方式,用于在双方之间传输。JWT 中的声明可以是 JSON 对象或这些 JSON 对象的特殊纯文本表示。

使用 JWT 的一个流程如下:

  1. 客户端向服务器发送认证请求。

  2. 服务器响应 JWT。

  3. 客户端存储 JWT。

  4. 客户端使用存储的 JWT 发送 API 请求。

  5. 服务器验证 JWT 并根据情况响应。

让我们尝试按照以下步骤实现 API 端点保护:

  1. Cargo.toml依赖项部分添加所需的库:

    hmac = "0.12.1"
    jwt = "0.16.0"
    sha2 = "0.10.2"
    
  2. 我们想要使用一个秘密令牌来签名 JWT 令牌。在Rocket.toml中添加一个新的条目如下:

    jwt_secret = "fill with your own secret"
    
  3. 添加一个新的状态来存储令牌的秘密。当应用程序创建或验证 JWT 时,我们想要检索这个秘密。在src/states/mod.rs中添加以下行:

    pub struct JWToken {
        pub secret: String,
    }
    
  4. 修改src/main.rs以使应用程序从配置中检索秘密并管理状态:

    use our_application::states::JWToken;
    ...
    struct Config {...
        jwt_secret: String,
    }
    ...
    async fn rocket() -> Rocket<Build> {
        ...
        let config: Config = our_rocket...
        let jwt_secret = JWToken {
            secret: String::from(config.jwt_
            secret.clone()),
        };
        let final_rocket = our_rocket.manage(jwt_secret);
        ...
        final_rocket
    }
    
  5. 创建一个结构体来存储用于认证发送的 JSON 数据,另一个结构体来存储包含要返回给客户端的令牌的 JSON 数据。在src/models/user.rs中添加以下use声明:

    use rocket::serde::{Deserialize, Serialize};
    

添加以下结构体:

#[derive(Deserialize)]
pub struct JWTLogin<'r> {
    pub username: &'r str,
    pub password: &'r str,
}
#[derive(Serialize)]
pub struct Auth {
    pub token: String,
}
  1. JWTLogin实现一个验证用户名和密码的方法。添加impl块和方法:

    impl<'r> JWTLogin<'r> {
        pub async fn authenticate(
            &self,
            connection: &mut PgConnection,
            secret: &'r str,
        ) -> Result<Auth, OurError> {}
    }
    
  2. authenticate()方法内部,添加error闭包:

    let auth_error =
        || OurError::new_bad_request_error(
        String::from("Cannot verify password"), None);
    
  3. 然后,根据用户名查找用户并验证密码:

    let user = User::find_by_login(
        connection,
        &Login {
            username: self.username,
            password: self.password,
            authenticity_token: "",
        },
    )
    .await
    .map_err(|_| auth_error())?;
    verify_password(&Argon2::default(), &user.password_hash, self.password)?;
    
  4. 添加以下use声明:

    use hmac::{Hmac, Mac};
    use jwt::{SignWithKey};
    use sha2::Sha256;
    use std::collections::BTreeMap; 
    

authenticate中继续以下操作以从用户的 UUID 生成令牌并返回令牌:

let user_uuid = &user.uuid.to_string();
let key: Hmac<Sha256> =
    Hmac::new_from_slice(secret.as_bytes()
    ).map_err(|_| auth_error())?;
let mut claims = BTreeMap::new();
claims.insert("user_uuid", user_uuid);
let token = claims.sign_with_key(&key).map_err(|_| auth_error())?;
Ok(Auth {
    token: token.as_str().to_string(),
})
  1. 创建一个用于认证的函数。让我们称这个函数为login()。在src/routes/api.rs中添加所需的use声明:

    use crate::models::user::{Auth, JWTLogin, User, UsersWrapper};
    use crate::states::JWToken;
    use rocket::State;
    use rocket_db_pools::{sqlx::Acquire, Connection};
    
  2. 然后,按照以下方式添加login()函数:

    #[post("/login", format = "json", data = "<jwt_login>")]
    pub async fn login<'r>(
        mut db: Connection<DBConnection>,
        jwt_login: Option<Json<JWTLogin<'r>>>,
        jwt_secret: &State<JWToken>,
    ) -> Result<Json<Auth>, Json<OurError>> {
        let connection = db
            .acquire()
            .await
            .map_err(|_| OurError::new_internal_server_
            error(String::from("Cannot login"), None))?;
        let parsed_jwt_login = jwt_login
            .map(|p| p.into_inner())
            .ok_or_else(|| OurError::new_bad_request_
            error(String::from("Cannot login"), None))?;
        Ok(Json(
            parsed_jwt_login
                .authenticate(connection, &jwt_secret
                .secret)
                .await
                .map_err(|_| OurError::new_internal_
                server_error(String::from("Cannot login"), 
                None))?,
        ))
    }
    
  3. 现在我们已经创建了登录功能,下一步是创建一个请求保护器来处理请求头中的授权令牌。在src/guards/auth.rs中添加以下use声明:

    use crate::states::JWToken;
    use hmac::{Hmac, Mac};
    use jwt::{Header, Token, VerifyWithKey};
    use sha2::Sha256;
    use std::collections::BTreeMap;
    
  4. 为请求保护器添加一个新的结构体APIUser

    pub struct APIUser {
        pub user: User,
    }
    
  5. APIUser实现FromRequest。添加以下代码块:

    #[rocket::async_trait]
    impl<'r> FromRequest<'r> for APIUser {
        type Error = ();
        async fn from_request(req: &'r Request<'_>) -> 
        Outcome<Self, Self::Error> {}
    }
    
  6. from_request()中,添加返回错误的闭包:

    let error = || Outcome::Failure ((Status::Unauthorized, ()));
    
  7. 从请求头中获取令牌:

    let parsed_header = req.headers().get_one("Authorization");
    if parsed_header.is_none() {
        return error();
    }
    let token_str = parsed_header.unwrap();
    
  8. 从状态中获取秘密:

    let parsed_secret = req.rocket().state::<JWToken>();
    if parsed_secret.is_none() {
        return error();
    }
    let secret = &parsed_secret.unwrap().secret;
    
  9. 验证令牌并获取用户的 UUID:

    let parsed_key: Result<Hmac<Sha256>, _> = Hmac::new_from_slice(secret.as_bytes());
    if parsed_key.is_err() {
        return error();
    }
    let key = parsed_key.unwrap();
    let parsed_token: Result<Token<Header, BTreeMap<String, String>, _>, _> = token_str.verify_with_key(&key);
    if parsed_token.is_err() {
        return error();
    }
    let token = parsed_token.unwrap();
    let claims = token.claims();
    let parsed_user_uuid = claims.get("user_uuid");
    if parsed_user_uuid.is_none() {
        return error();
    }
    let user_uuid = parsed_user_uuid.unwrap();
    
  10. 查找用户并返回用户数据:

    let parsed_db = req.guard::<Connection<DBConnection>>().await;
    if !parsed_db.is_success() {
        return error();
    }
    let mut db = parsed_db.unwrap();
    let parsed_connection = db.acquire().await;
    if parsed_connection.is_err() {
        return error();
    }
    let connection = parsed_connection.unwrap();
    let found_user = User::find(connection, &user_uuid).await;
    if found_user.is_err() {
        return error();
    }
    let user = found_user.unwrap();
    Outcome::Success(APIUser { user })
    
  11. 最后,在src/routes/api.rs中添加一个新的受保护 API 端点:

    use crate::guards::auth::APIUser;
    ...
    #[get("/protected_users", format = "json", data = "<pagination>")]
    pub async fn authenticated_users(
        db: Connection<DBConnection>,
        pagination: Option<Json<Pagination>>,
        _authorized_user: APIUser,
    ) -> Result<Json<UsersWrapper>, Json<OurError>> {
        users(db, pagination).await
    }
    
  12. src/main.rs中添加到 Rocket 的路由:

    ...
    .mount("/api", routes![api::users, api::login, 
     api::authenticated_users])
    ...
    

现在,尝试访问新的端点。以下是一个使用 cURL 命令行的示例:

curl -X GET -H "Content-Type: application/json" \
 http://127.0.0.1:8000/api/protected_users

响应将会是一个错误。现在尝试发送一个请求来获取访问令牌。以下是一个示例:

curl -X POST -H "Content-Type: application/json" \
  -d "{\"username\":\"example\", \"password\": \"password\"}" \
 http://127.0.0.1:8000/api/login

如此示例所示,返回了一个令牌:

{"token":"eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX3V1aWQiOiJmMGMyZDM4Yy0zNjQ5LTRkOWQtYWQ4My0wZGE4ZmZlY2 E2MDgifQ.XJIaKlIfrBEUw_Ho2HTxd7hQkowTzHkx2q_xKy8HMKA"}

使用令牌发送请求,如本示例所示:

curl -X GET -H "Content-Type: application/json" \T -H "Content-Type: application/json" \
 -H "Authorization: eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX3V1aWQiOiJmMGMyZDM4Yy0zNjQ5LTRkOWQtYWQ4My0wZGE4ZmZlY2 E2MDgifQ.XJIaKlIfrBEUw_Ho2HTxd7hQkowTzHkx2q_xKy8HMKA" \
 http://127.0.0.1:8000/api/protected_users

然后,将返回正确的响应。JWT 是保护 API 端点的好方法,所以当需要时使用我们学到的技术。

摘要

在本章中,我们学习了如何验证用户并创建一个 cookie 来存储已登录用户的信息。我们还介绍了CurrentUser作为一个请求守卫,它在应用程序的某些部分充当授权。

在创建认证和授权系统之后,我们还学习了关于 API 端点的内容。我们将传入的请求体解析为 API 中的请求守卫,然后创建了一个 API 响应。

最后,我们了解了一些关于 JWT 以及如何用它来保护 API 端点的内容。

在下一章中,我们将学习如何测试我们创建的代码。

第三部分:完成 Rust Web 应用程序开发

在这部分,你将学习哪些部分不是 Rocket Web 应用程序的主要部分,但对于 Rust 和 Rocket 相关的 Web 开发来说,拥有这些部分是很有帮助的。

本部分包括以下章节:

  • 第十二章**,测试你的应用程序

  • 第十三章**,启动 Rocket 应用程序

  • 第十四章**,构建全栈应用程序

  • 第十五章**,改进 Rocket 应用程序

第十二章:第十二章: 测试您的应用程序

确保程序正确运行是编程的一个重要部分。在本章中,我们将学习如何测试 Rust 应用程序。我们将为函数实现一个简单的单元测试,并为创建用户实现一个功能测试。

我们将学习一种简单的技术来调试并找到代码中问题发生的位置。

在学习本章信息后,您将能够为 Rust 和 Rocket 应用程序创建单元测试和功能测试,以确保应用程序按预期工作。您还将学习如何使用gdblldb等调试器调试 Rust 程序。

在本章中,我们将涵盖以下主要主题:

  • 测试 Rust 程序

  • 测试 Rocket 应用程序

  • 调试 Rust 应用程序

技术要求

在本章中,我们将进行测试和调试,因此我们需要一个调试器。请为您的操作系统安装gdb,GNU 调试器(www.sourceware.org/gdb/download/))。

您可以在此章节的源代码中找到github.com/PacktPublishing/Rust-Web-Development-with-Rocket/tree/main/Chapter12

测试 Rust 程序

编程的一个重要部分是测试应用程序。有许多种测试,例如单元测试(用于测试单个函数或方法)、功能测试(用于测试应用程序的功能)和集成测试(用于测试各种单元和函数作为一个单一的组合实体)。为了使应用程序尽可能正确,应进行各种测试。

在 Rust 标准库中,有三个宏用于测试:assert!assert_eq!assert_ne!assert!宏接受一个或多个参数。第一个参数是任何评估为布尔值的语句,其余的是如果结果不是预期的调试信息。

assert_eq!宏比较第一个参数和第二个参数之间的相等性,其余的是如果结果不是预期的调试信息。assert_ne!宏是assert_eq!的对立面;此宏测试第一个和第二个参数之间的不等性。

让我们看看那些宏在实际中的应用。我们想要测试src/models/text_post.rsTextPost模型的raw_html()方法。我们想要确保该方法的结果是我们想要的字符串。按照以下步骤测试该方法:

  1. src/models/text_post.rs中添加以下use声明:

    use crate::models::our_date_time::OurDateTime;
    use crate::models::post_type::PostType;
    use chrono::{offset::Utc, TimeZone};
    use uuid::Uuid;
    
  2. 在相同的src/models/text_post.rs文件中,我们想要有一个测试函数。为了使函数成为测试函数,使用#[test]属性注释该函数。添加函数声明:

    #[test]
    fn test_raw_html() {
    }
    
  3. 在函数内部,初始化一个TextPost实例如下:

    let created_at = OurDateTime(Utc.timestamp_nanos(1431648000000000));
    let post = Post {
        uuid: Uuid::new_v4(),
        user_uuid: Uuid::new_v4(),
        post_type: PostType::Text,
        content: String::from("hello"),
        created_at: created_at,
    };
    let text_post = TextPost::new(&post);
    
  4. assert!宏添加到确保结果字符串是我们想要的:

    assert!(
        text_post.raw_html() == 
        String::from("<p>hel1lo</p>"),
        "String is not equal, {}, {}",
        text_post.raw_html(),
        String::from("<p>hello</p>")
    );
    
  5. 保存文件,通过在终端上运行 cargo test 来运行测试。由于我们在测试代码中犯了一个错误 "<p>hel1lo</p>",测试应该失败,如下面的示例所示:

    $ cargo test
       Compiling our_application v0.1.0 (/workspace/
       rocketbook/Chapter12/01RustTesting)
    …
    running 1 test
    test models::text_post::test_raw_html ... FAILED
    failures:
    ---- models::text_post::test_raw_html stdout ----
    thread 'models::text_post::test_raw_html' panicked at 'String is not equal, <p>hello</p>, <p>hello</p>', src/models/text_post.rs:33:5
    note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
    failures:
        models::text_post::test_raw_html
    test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
    error: test failed, to rerun pass '--lib' 
    
  6. 通过将 "<p>hel1lo</p>" 替换为 "<p>hello</p>" 来修复 "<p>hel1lo</p>"。保存文件并再次运行测试。现在测试应该可以正常工作:

    $ cargo test
       Compiling our_application v0.1.0 (/workspace/
       rocketbook/Chapter12/01RustTesting)
    …
    running 1 test
    test models::text_post::test_raw_html ... ok
    test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
         Running unittests (target/debug/deps/our_
         application-43a2db5b02032f30)
    running 0 tests
    test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
       Doc-tests our_application
    running 0 tests
    test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
    
  7. 我们想使用 assert_eq!assert_ne! 宏。assert_eq! 宏用于检查第一个参数是否等于第二个参数。assert_ne! 宏用于确保第一个参数不等于第二个参数。在 test_raw_html() 函数中添加这些宏以查看它们的作用:

    assert_eq!(text_post.raw_html(), String::from("<p>hello</p>"));
    assert_ne!(text_post.raw_html(), String::from("<img>hello</img>"));
    
  8. 再次运行测试;它应该通过。但是,如果我们查看测试输出,会有警告,如下所示:

    warning: unused import: `crate::models::our_date_time::OurDateTime`
     --> src/models/text_post.rs:1:5
      |
    1 | use crate::models::our_date_time::OurDateTime;
      |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
      |
      = note: `#[warn(unused_imports)]` on by default
    
  9. 单元测试的一个约定是创建一个测试模块并将模块标记为测试,这样它就不会被编译。在 src/models/text_post.rs 中添加一个新的模块:

    #[cfg(test)]
    mod tests {
    }
    
  10. src/models/text_post.rs 文件中,删除这些未使用的 use 声明:

    use crate::models::our_date_time::OurDateTime;
    use crate::models::post_type::PostType;
    use chrono::{offset::Utc, TimeZone};
    use uuid::Uuid;
    
  11. 反之,在 tests 模块中的 src/models/text_post.rs 中添加所需的 use 声明:

    use super::TextPost;
    use crate::models::our_date_time::OurDateTime;
    use crate::models::post::Post;
    use crate::models::post_type::PostType;
    use crate::traits::DisplayPostContent;
    use chrono::{offset::Utc, TimeZone};
    use uuid::Uuid;
    
  12. test_raw_html() 函数移动到 tests 模块中。在终端中再次运行 cargo test。测试应该没有警告通过,如下面的示例所示:

    $ cargo test
    Finished test [unoptimized + debuginfo] target(s) 
        in 0.34s
         Running unittests (target/debug/deps/our_
         application-40cf18b02419edd7)
    running 1 test
    test models::text_post::tests::test_raw_html ... ok
    test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
         Running unittests (target/debug/deps/our_
         application-77e614e023a036bf)
    running 0 tests
    test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
       Doc-tests our_application
    running 0 tests
    test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
    

现在我们已经学会了如何在 Rust 中执行单元测试,我们可以继续在下一节中通过功能测试来测试应用程序。

测试 Rocket 应用程序

除了在 src 目录中放置测试外,我们还可以在应用程序根目录下的 tests 目录中的 Rust 文件中创建测试。当我们运行 cargo test 时,命令行会查找 tests 目录并运行那里找到的任何测试。人们通常在 src 目录中使用测试进行单元测试,并在 tests 目录中编写功能测试。

Rocket 框架提供了一个 rocket::local 模块,它包含用于向本地 Rocket 应用程序发送请求的模块、结构和方法。向本地 Rocket 应用程序发送非网络请求的主要目的是检查响应并确保响应是我们预期的,主要是为了测试。

让我们尝试通过以下步骤实现应用程序的集成测试:

  1. 在应用程序的根目录下,添加一个名为 tests 的新目录。在 tests 目录内,创建一个名为 functional_tests.rs 的文件。

  2. tests/functional_tests.rs 内部,添加一个新的测试函数,如下所示:

    #[test]
    fn some_test() {
        assert_eq!(2, 1 + 1);
    }
    
  3. 之后,从命令行保存并运行 cargo test。测试应该通过,cargo test 的输出应该显示它在 tests 目录内运行测试,如下所示:

    ...
    Running tests/functional_tests.rs
    ...
    
  4. 让我们继续测试 Rocket 应用程序。创建一个名为 test_rocket 的测试函数,但由于应用程序是 async 的,我们需要不同的测试注解,如下所示:

    #[rocket::async_test]
    async fn test_rocket() {
    }
    
  5. 我们将把 Rocket 实例放入rocket::local::asynchronous::Client实例中。稍后,我们可以使用Client实例发送请求并验证响应。但,一个问题在于 Rocket 初始化在src/main.rs中,而不是在our_application库中。我们可以通过将 Rocket 初始化从src/main.rs移动到src/lib.rs来解决这个问题。将src/main.rs中的代码移动到src/lib.rs下的pub mod声明下,然后更改任何use our_application::use crate::

之后,将rocket()函数重命名为setup_rocket()。此外,在setup_rocket()函数前面添加pub并从setup_rocket()函数的顶部移除#[launch]

我们需要一个方法来获取数据库 URL,因此实现Configsrc/lib.rs中的get_database_url方法:

impl Config {
    pub fn get_database_url(&self) -> String {
        self.databases.main_connection.url.clone()
    }
}
  1. src/main.rs中,将应用程序更改为使用setup_rocket(),如下所示:

    use our_application::setup_rocket;
    use rocket::{Build, Rocket};
    #[launch]
    async fn rocket() -> Rocket<Build> {
        setup_rocket().await
    }
    
  2. 返回到tests/functional_test.rs,添加our_application库:

    use our_application;
    

然后,在test_rocket()函数中初始化一个 Rocket 实例,如下所示:

let rocket = our_application::setup_rocket().await;
  1. 我们希望获取数据库连接以截断数据库表,确保测试的干净状态。添加所需的use声明:

    use our_application::Config;
    use rocket_db_pools::sqlx::PgConnection;
    use sqlx::postgres::PgPoolOptions;
    

然后,在test_rocket()函数内部添加以下行:

let config_wrapper = rocket.figment().extract();
assert!(config_wrapper.is_ok());
let config: Config = config_wrapper.unwrap();
let db_url = config.get_database_url();
let db_wrapper = PgPoolOptions::new()
    .max_connections(5)
    .connect(&db_url)
    .await;
assert!(db_wrapper.is_ok());
let db = db_wrapper.unwrap();
  1. 我们希望截断users表的内容。我们希望为User创建一个删除所有数据的方法,但该方法应仅适用于测试。让我们添加一个 trait 来扩展User模型。添加use声明:

    use our_application::models::user::User;
    

添加ModelCleaner trait 并为User实现ModelCleaner,如下所示:

#[rocket::async_trait]
trait ModelCleaner {
    async fn clear_all(connection: &mut PgConnection) 
    -> Result<(), String>;
}
#[rocket::async_trait]
impl ModelCleaner for User {
    async fn clear_all(connection: &mut PgConnection) 
    -> Result<(), String> {
        let _ = sqlx::query("TRUNCATE users RESTART 
        IDENTITY CASCADE")
            .execute(connection)
            .await
            .map_err(|_| String::from("error 
            truncating databasse"))?;
        Ok(())
    }
}
  1. test_rocket()函数中继续,添加以下行:

    let pg_connection_wrapper = db.acquire().await;
    assert!(pg_connection_wrapper.is_ok());
    let mut pg_connection = pg_connection_wrapper.unwrap();
    let clear_result_wrapper = User::clear_all(&mut pg_connection).await;
    assert!(clear_result_wrapper.is_ok());
    
  2. tests/functional_tests.rs文件中添加use声明:

    use rocket::local::asynchronous::Client;
    

然后,在test_rocket()函数内部创建一个Client实例:

let client_wrapper = Client::tracked(rocket).await;
assert!(client_wrapper.is_ok());
let client = client_wrapper.unwrap();
  1. 目前,数据库中的用户数量是0。我们希望通过获取"/users"并解析 HTML 来进行测试。一个用于解析 HTML 的 crate 是scraper。因为我们只想在测试中使用scraper crate,所以请在Cargo.toml中添加一个新的部分,称为[dev-dependencies],如下所示:

    [dev-dependencies]
    scraper = "0.12.0"
    
  2. 返回到tests/functional_test.rs,我们想要获取"/users"响应。添加use声明:

    use rocket::http::Status;
    

然后,在test_rocket()函数内部追加以下行:

let req = client.get("/users");
let resp = req.dispatch().await;
assert_eq!(resp.status(), Status::Ok);
  1. 我们希望验证响应体不包含任何用户。如果我们查看src/views/users/index.html.tera模板,我们会看到每个用户都有一个mark HTML 标签。让我们使用scraper通过添加use声明来验证响应:

    use scraper::{Html, Selector}; 
    

然后,在test_rocket()函数内部追加以下行:

let body_wrapper = resp.into_string().await;
assert!(body_wrapper.is_some());
let body = Html::parse_document(&body_wrapper.unwrap());
let selector = Selector::parse(r#"mark.tag"#).unwrap();
let containers = body.select(&selector);
let num_of_elements = containers.count();
assert_eq!(num_of_elements, 0);
  1. 我们希望创建一个post请求来创建新用户,但一个问题在于应用程序将执行 token 真实性检查,因此我们需要首先从"/users/new"页面获取值。将以下行追加以从响应体中获取 token:

    let req = client.get("/users/new");
    let resp = req.dispatch().await;
    assert_eq!(resp.status(), Status::Ok);
    let body_wrapper = resp.into_string().await;
    assert!(body_wrapper.is_some());
    let body = Html::parse_document(&body_wrapper.unwrap());
    let authenticity_token_selector = Selector::parse(r#"input[name="authenticity_token"]"#).unwrap();
    let element_wrapper = body.select(&authenticity_token_selector).next();
    assert!(element_wrapper.is_some());
    let element = element_wrapper.unwrap();
    let value_wrapper = element.value().attr("value");
    assert!(value_wrapper.is_some());
    let authenticity_token = value_wrapper.unwrap();
    
  2. 使用authenticity_token发送post请求。添加use声明:

    use rocket::http::ContentType;
    
  3. 然后,将以下行追加到test_rocket()函数中:

    let username = "testing123";
    let password = "lkjKLAJ09231478mlasdfkjsdkj";
    let req = client.post("/users")
        .header(ContentType::Form)
        .body(
            format!("authenticity_token={
            }&username={}&email={}@{}
            .com&password={}&password_confirmation={}
            &description=",
        authenticity_token, username, username, username, 
        password, password,
    ));
    let resp = req.dispatch().await;
    assert_eq!(resp.status(), Status::SeeOther);
    
  4. 最后再次检查 "/users" 页面;您应该看到一个用户。追加以下行:

    let req = client.get("/users");
    let resp = req.dispatch().await;
    assert_eq!(resp.status(), Status::Ok);
    let body_wrapper = resp.into_string().await;
    assert!(body_wrapper.is_some());
    let body = Html::parse_document(&body_wrapper.unwrap());
    let selector = Selector::parse(r#"mark.tag"#).unwrap();
    let containers = body.select(&selector);
    let num_of_elements = containers.count();
    assert_eq!(num_of_elements, 1);
    

再次尝试运行测试。有时测试会成功:

$ cargo test
...
     Running tests/functional_tests.rs (target/debug/deps/functional_tests-625b16e4b25b72de)
running 2 tests
test some_test ... ok
...
test test_rocket ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.43s

但是,有时测试不会成功:

$ cargo test
...
     Running tests/functional_tests.rs (target/
     debug/deps/functional_tests-625b16e4b25b72de)
running 2 tests
test some_test ... ok
...
test test_rocket ... FAILED
failures:
---- test_rocket stdout ----
thread 'test_rocket' panicked at 'assertion failed: `(left == right)`
  left: `0`,
 right: `1`', tests/functional_tests.rs:115:5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
thread 'rocket-worker-test-thread' panicked at 'called `Result::unwrap()` on an `Err` value: Disconnected',
/workspace/rocketbook/Chapter12/03RocketTesting/src/lib.rs:137:28
failures:
    test_rocket
test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.09s

测试为什么失败?我们将在下一节中学习如何调试 Rust 程序。

调试 Rust 应用程序

在上一节中,我们学习了如何编写功能测试,但有时测试会失败。我们想知道为什么测试失败了。错误可能发生的两个可能位置是用户创建过程和创建用户后的用户查找过程。

一种调试方法是记录错误可能发生的位置。如果我们记录用户创建过程中所有可能发生的错误(例如,在 src/routes/user.rscreate_user() 函数中),我们会发现身份验证令牌验证有时会产生错误。记录错误的示例如下:

csrf_token
    .verify(&new_user.authenticity_token)
    .map_err(|err| {
        log::error!("Verify authenticity_token error: {}", 
        err);
        Flash::error(
            Redirect::to("/users/new"),
            "Something went wrong when creating user",
        )
    })?;

如果我们继续记录 verify() 方法并继续追踪问题的来源,我们最终会发现令牌的 from_request() 方法没有产生正确的结果。我们可以通过更改 src/fairings/csrf.rs 中的 from_request() 方法来修复问题,如下所示:

async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
    match request.get_csrf_token() {
        None => Outcome::Failure((Status::Forbidden, ())),
        Some(token) => Outcome::Success(Self(base64::
        encode_config(token, base64::URL_SAFE))),
    }
}

显然,记录代码并查找问题并不高效。我们还可以使用调试器,如 gdb(GNU 调试器)或 lldb 来调试 Rust 程序。gdb 可用于 Linux 操作系统,而 lldb(LLVM 项目的调试器)可用于 macOS 和 Linux 操作系统。如果您想为 Rust 编程语言使用调试器,请安装这些调试器之一。

Rust 提供了 rust-gdbgdb 的包装器)和 rust-lldblldb 的包装器)。这些程序应与 Rust 编译器一起安装。让我们通过以下步骤看看如何使用 rust-gdb 的示例:

  1. 首先,使用终端上的 cargo build 命令构建应用程序。由于我们不是构建发布版本,调试符号应该包含在生成的二进制文件中。

  2. 检查生成的二进制文件在源目录目标目录中的位置;例如,如果应用程序的源代码在 /workspace/rocketbook/Chapter12/03RocketTesting/,我们可以在 /workspace/rocketbook/Chapter12/03RocketTesting/target/debug/our_application 中找到生成的二进制文件。

  3. 在终端上像运行 gdb 一样运行 rust-gdb。以下是一个示例:

    rust-gdb -q target/debug/our_application
    
  4. 您将看到一个 gdb 提示符,如下所示:

    Reading symbols from target/debug/our_application...
    Reading symbols from /workspace/rocketbook/Chapter12/03RocketTesting/target/debug/our_application/Contents/Resources/DWARF/our_application...
    (gdb)
    
  5. 设置应用程序的断点,例如:

    b /workspace/rocketbook/Chapter12/03RocketTesting/src/lib.rs:143
    
  6. 您将看到一个提示设置 our_application 库的断点,如下所示:

    No source file named /workspace/rocketbook/Chapter12/03RocketTesting/src/lib.rs.
    Make breakpoint pending on future shared library load? (y or [n])
    
  7. 回复 y 并注意以下设置的 Breakpoint 1

    (y or [n]) y
    Breakpoint 1 (/workspace/rocketbook/Chapter12/03RocketTesting/src/lib.rs:143) pending.
    
  8. gdb 提示符下编写 r 命令并按 Enter 键运行应用程序:

    (gdb) r
    
  9. 应用程序应该运行,因为它触发了断点,执行停止。我们可以再次使用 gdb 提示符来检查 final_rocket,如下例所示:

    Starting program: /workspace/rocketbook/Chapter12/03RocketTesting/target/debug/our_application 
      [Thread debugging using libthread_db enabled]
    Using host libthread_db library 
      "/usr/lib/libthread_db.so.1".
      [New Thread 0x7ffff7c71640 (LWP 50269)]
      ...
      [New Thread 0x7ffff746d640 (LWP 50273)]
      Thread 1 "our_application" hit Breakpoint 1, our_
    application::main::{generator#0} () at 
      src/lib.rs:143
      143         final_rocket
    
  10. 在调试器提示符中尝试打印一些变量:

    (gdb) p config
    

我们可以看到以下打印的结果:

$1 = our_application::Config {databases: our_application::Databases {main_connection: our_application::MainConnection {url: "postgres://username:password@localhost/rocket"}}, jwt_secret: "+/xbAZJs+e1BA4
gbv2zPrtkkkOhrYmHUGnJIoaL9Qsk="}
  1. 要退出gdb,只需在提示符下输入quit并确认退出调试器,如下所示:

    (gdb) quit
    A debugging session is active.
            Inferior 1 [process 50265] will be killed.
    Quit anyway? (y or n) y
    

这些调试器有许多更多功能,例如设置多个断点和通过断点逐步执行。您可以在www.sourceware.org/gdb/找到有关gdb的更多信息,以及lldb.llvm.org/有关lldb的更多信息。

此外,还有适用于 IDE 或代码编辑器的调试器,例如,Visual Studio Code 的用户可以使用 CodeLLDB (marketplace.visualstudio.com/items?itemName=vadimcn.vscode-lldb) 从编辑器方便地单击行并标记断点,并通过专用面板检查变量:

图 12.1 – CodeLLDB 检查我们的应用程序

图 12.1 – CodeLLDB 检查我们的应用程序

在任何情况下,使用调试器是编程不可或缺的工具。学会正确使用调试器可以帮助我们更好地使用 Rust 编程语言。

摘要

在本章中,我们学习了测试 Rust 程序和 Rocket 应用程序。我们学习了使用如assert!这样的宏进行测试。我们还学习了单元测试和功能测试之间的区别。

我们创建了一个功能测试,并了解了在 Rocket 应用程序上进行功能测试的模块。最后,我们学习了一些调试 Rust 应用程序的技术,以帮助修复它。

测试和调试是编程的重要组成部分,因为这些技术可以提高应用程序的正确性。

在所有开发完成后,在下一章中,我们将学习几种将 Rocket 应用程序设置为可供现实世界用户使用的方法。

第十三章:第十三章:启动 Rocket 应用程序

在开发和测试之后,开发的一个重要部分是准备应用程序以服务于其目标用户。在本章中,我们将学习一些生成生产就绪二进制文件的技术。在我们生成二进制文件之后,我们将学习配置位于通用 Web 服务器后面的应用程序。最后,我们将学习如何为 Rocket 应用程序生成 Docker 镜像。

在学习本章中的信息后,您将能够使用 Rust 编译器标志和 Cargo 配置来优化二进制文件。您还将学习准备您的应用程序以服务于其目标用户的技术。

在本章中,我们将涵盖以下主要主题:

  • 优化生产二进制文件

  • 使用 Rocket 应用程序设置 Apache HTTP 服务器

  • 为 Rocket 应用程序生成 Docker 镜像

技术要求

在本章中,我们将使用 Apache HTTP 服务器(httpd.apache.org/)来处理 HTTP 请求。如果您有基于 Unix 的操作系统,您通常可以在操作系统的软件包管理器中找到 Apache HTTP 服务器。如果您有 Windows 操作系统,以下链接中有推荐的下载:httpd.apache.org/docs/2.4/platform/windows.html

您还需要使用 OpenSSL 生成一个 TLS(传输层安全性)证书。如果您有基于 Unix 的操作系统,您通常可以使用发行版的软件包管理器找到 OpenSSL 二进制文件。如果您有 Windows 操作系统,您可以在以下链接中找到推荐的二进制文件:wiki.openssl.org/index.php/Binaries

对于生成 Docker 镜像,您可以使用以下链接中的 Docker Desktop:www.docker.com/products/docker-desktop/

您可以在以下位置找到本章的源代码:github.com/PacktPublishing/Rust-Web-Development-with-Rocket/tree/main/Chapter13

优化生产二进制文件

在我们创建应用程序之后,我们希望准备应用程序以接受真实连接。在软件开发中,有一个生产环境,也称为发布环境或部署环境。生产环境包含系统的配置和软件,使其能够提供给目标客户。在第二章,“构建我们的第一个 Rocket Web 应用程序”,我们了解到我们可以告诉 Rust 编译器在编译 Rust 应用程序时构建发布二进制文件。我们可以使用带有额外--release标志的cargo buildcargo run

为了刷新,Cargo 将读取 [profile.release] 部分中的 Cargo.toml 配置。我们可以进行一些编译优化来提高生成的镜像:

  1. 第一个设置是编译的 codegen-units 数量。Rust 编译可能需要很长时间,为了解决这个问题,编译器可能会尝试将其分割成多个部分并行编译。但是,并行编译二进制文件或库可能会遗漏一些优化。默认情况下,codegen-units 的数量是 3。我们可以牺牲编译速度,将 codegen-units 设置为 1 以进一步优化生成的二进制文件。例如,在 Cargo.toml 中,我们可以有如下设置:

    [profile.release]
    codegen-units = 1
    

codegen 后端,LLVM,可以执行各种 LTO(链接时优化)以生成优化后的代码输出。要启用 LTO,我们可以设置 lto = yeslto = "fat"。以下是在 Cargo.tomllto 的一个示例:

[profile.release]
lto = "fat"

设置优化级别。我们可以设置从 0123 的优化级别,默认值是 0(无优化)到 3(所有优化),如下所示:

[profile.release]
opt-level = 3

除了优化级别 03,我们还可以设置 "s""z",其中 "s" 用于二进制大小优化,而 "z" 用于二进制大小优化并关闭循环向量化。

禁用 panic 回溯。我们可以设置 panic 不显示堆栈跟踪。结果是更优化的二进制文件。在 Cargo.toml 中设置以下内容以禁用堆栈回溯:

[profile.release]
panic = "abort"
  1. 第二个优化是编译正确的架构。CPU 生产商总是会创建一个新的 CPU,具有更好的优化或指令集,这可以提高应用程序的性能。例如,SSE(Streaming SIMD Extensions)指令集是由英特尔在发布英特尔奔腾 III 时引入的。

默认情况下,Rust 编译器会生成具有合理 CPU 支持的二进制文件。但这意味着在编译库或二进制文件时不会使用新的指令集或优化。我们可以告诉 Rust 编译器生成支持目标机器新优化或指令集的二进制文件。

要查看 Rust 编译器支持的目标架构列表,我们可以使用以下命令:

rustc --print target-cpus

例如,如果我们知道目标机器是支持 znver3 架构的 AMD Ryzen,我们可以按照以下方式编译 Rust 程序:

RUSTFLAGS='-C target-cpu=znver -C codegen-units=1' cargo build –release

我们使用 RUSTFLAGS 环境变量的原因是 target-cpu 选项在 Cargo.toml 中不被认可。Cargo 还会使用在 RUSTFLAGS 环境变量中设置的任何其他 rustc 选项。

现在我们知道了如何为生产环境编译 Rust 应用程序,让我们学习如何在 Web 服务器后面部署 Rocket 应用程序。

配置 Apache HTTP 服务器与 Rocket 应用程序

我们知道 Rocket 在其配置中支持 TLS,因此我们可以将 TCP 端口设置为443,这是默认的 HTTPS 连接端口。在某些情况下,直接运行 Web 应用程序可能是可接受的,例如,当我们想要为微服务提供服务内容时。

我们不想直接运行 Rocket 应用程序的一个原因是因为 Rocket 指南中的这个警告:

Rocket 内置的 TLS 仅实现了 TLS 1.2 和 1.3。它可能不适合生产使用。

Rocket 框架使用的 TLS 库可能由于各种原因(如安全原因或尚未经过审计)不适合生产使用。

除了 TLS 库问题之外,还有其他原因我们不希望直接从 Rocket 服务内容。一个例子是我们想要从同一台计算机上服务多个应用程序。我们也可能想要在同一台机器上服务 PHP 应用程序。

当人们服务 Rust 应用程序时,会使用的一种技术是将它放在一个通用的 Web 服务器后面,该服务器可以执行反向代理:

![图 13.1 - 执行 Rocket 应用程序反向代理的通用 Web 服务器]

img/Figure_13.1_B16825.jpg

图 13.1 - 执行 Rocket 应用程序反向代理的通用 Web 服务器

最常用的反向代理应用程序之一是 Apache HTTP 服务器。Apache HTTP 服务器除了反向代理之外,还有其他功能,包括服务静态文件和压缩文件以更快地处理请求。

让我们尝试使用 Apache HTTP 服务器来服务我们的应用程序,并按照以下步骤配置服务器以作为反向代理:

  1. 下载适用于您操作系统的 Apache HTTP 服务器或从httpd.apache.org/下载。

  2. 尝试使用以下命令行启动应用程序:

    sudo apachectl -k start
    
  3. Apache HTTP 服务器的默认端口是8080。使用 cURL 命令检查 Apache 是否正在运行:

    curl http://127.0.0.1:8080/
    
  4. Apache HTTP 服务器的功能可以通过模块进行扩展,并且安装了几个模块。我们想要启用几个模块,以便通过反向代理使用 HTTP 请求来访问我们的应用程序。找到httpd.conf,这是您操作系统的配置文件。在某些 Linux 发行版中,配置可能位于/etc/httpd/httpd.conf。在其他发行版或操作系统上,文件位置可能位于/usr/local/etc/httpd/httpd.conf

编辑httpd.conf文件并取消注释以启用所需的模块:

LoadModule log_config_module libexec/apache2/mod_log_config.so
LoadModule vhost_alias_module libexec/apache2/mod_vhost_alias.so
LoadModule socache_shmcb_module libexec/apache2/mod_socache_shmcb.so
LoadModule ssl_module libexec/apache2/mod_ssl.so
LoadModule xml2enc_module libexec/apache2/mod_xml2enc.so
LoadModule proxy_html_module libexec/apache2/mod_proxy_html.so
LoadModule proxy_module libexec/apache2/mod_proxy.so
LoadModule proxy_connect_module libexec/apache2/mod_proxy_connect.so
LoadModule proxy_http_module libexec/apache2/mod_proxy_http.so
  1. 在相同的httpd.conf文件中,找到这些行并取消注释这些行:

    Include /usr/local/etc/httpd/extra/httpd-vhosts.conf
    Include /usr/local/etc/httpd/extra/httpd-ssl.conf
    Include /usr/local/etc/httpd/extra/proxy-html.conf
    
  2. 我们需要一个服务器名称。在真实服务器中,我们可以通过从域名注册商购买域名权利并将ourapplication.example.net指向它来获取一个域名。按照以下方式编辑/etc/hosts和一些测试域名:

    127.0.0.1 ourapplication.example.net
    
  3. 为您的操作系统安装openssl。之后,使用openssl命令行生成ourapplication.example.net的证书,如下所示:

    openssl req -x509 -out ourapplication.example.com.crt -keyout ourapplication.example.com.key \
      -newkey rsa:2048 -nodes -sha256 \
    -subj '/CN=ourapplication.example.com' -extensions 
      EXT -config <( \
       printf "[dn]\nCN=ourapplication.example
      .com\n[req]\ndistinguished_name = dn\n[EXT]\
      nsubjectAltName=DNS:ourapplication.
      example.com\nkeyUsage=digitalSignature\
      nextendedKeyUsage=serverAuth")
    

命令行将生成两个文件,ourapplication.example.com.crtourapplication.example.com.key

  1. 生成一个 PEM 文件,该文件格式包含以下证书:

    openssl rsa -in ourapplication.example.com.key -text > ourapplication.example.com.private.pem
    openssl x509 -inform PEM -in ourapplication.example.com.crt > ourapplication.example.com.public.pem 
    
  2. 编辑 httpd-vhosts.conf 文件。该文件可能位于 /usr/local/etc/httpd/extra/,具体取决于您的操作系统配置。添加一个新的虚拟主机。我们希望虚拟主机指向 http://127.0.0.1:8000 的我们的 Rocket 应用程序。添加以下行:

    <VirtualHost *:443>
        ServerName ourapplication.example.com
        SSLEngine On
        SSLCertificateFile /usr/local/etc/httpd/
        ourapplication.example.com.public.pem
        SSLCertificateKeyFile /usr/local/etc/httpd/
        ourapplication.example.com.private.pem
        SSLProxyEngine On
        ProxyRequests Off
        ProxyVia Off
        <Proxy *>
             Require all granted
        </Proxy>
        ProxyPass "/" "http://127.0.0.1:8000/"
        ProxyPassReverse "/" "http://127.0.0.1:8000/"
    </VirtualHost>
    
  3. 通过运行以下命令来检查配置是否正确:

    sudo apachectl configtest
    
  4. 在您的网络浏览器中重新启动并打开 ourapplication.example.com。网络浏览器可能会抱怨根证书未知。我们可以添加我们生成的证书,以便它在我们的浏览器中被接受。例如,在 Firefox 中,我们可以转到 首选项 | 隐私和安全 | 查看证书。之后,选择 服务器标签 并点击 添加异常。然后,确保 永久存储此异常 被选中。最后,点击 确认安全异常 以存储安全异常。如果一切顺利,我们可以在浏览器中使用示例域名,如图所示:

![Figure 13.2 – 使用域名和 TLS 证书img/Figure_13.2_B16825.jpg

![Figure 13.2 – 使用域名和 TLS 证书

现在我们已经将 Rocket 应用程序部署在反向代理后面,我们可以使用同样的原理与真实服务器一起使用。设置 Apache HTTP 服务器或 NGINX 作为反向代理,并在反向代理后面运行 Rocket 应用程序。

要在操作系统启动时自动运行 Rocket 应用程序,我们可以为操作系统设置某种类型的服务。如果我们运行的是以 systemd 作为服务管理器的 Linux 发行版,例如,我们可以创建一个 systemd 服务文件并自动运行应用程序。

在下一节中,我们将学习一种不同的部署应用程序的方法。我们将使用 Docker 打包并创建我们的 Rocket 应用程序的 Docker 容器。

为 Rocket 应用程序生成 Docker 镜像

容器化已经是一段时间内用于打包生产应用程序的热门选择。容器化最流行的应用程序之一是 Docker。在本节中,我们将学习如何设置 Docker 以运行我们的 Rocket 应用程序。要使用 docker 命令行,请从 www.docker.com/products/docker-desktop/ 安装 Docker Desktop。

按照以下步骤创建和运行 Rocket 应用程序的 Docker 镜像:

  1. 在应用程序的根目录中创建一个 Dockerfile。

  2. 我们可以使用一些基础镜像来构建和运行应用程序。我们将使用来自 hub.docker.com/_/rust 的 Rust 官方 Docker 镜像。对于 Linux 发行版,我们将使用 Alpine base,因为它是最小的 Docker 基础镜像之一。

在 Dockerfile 中添加第一行:

FROM rust:alpine as prepare-stage
  1. 设置工作目录。将此行追加到 Dockerfile 中:

    WORKDIR /app
    
  2. 我们可以使用 Cargo 安装依赖项,但还有一种快速编译应用程序的方法。我们可以供应商化依赖项并使用供应商依赖项来构建应用程序。在应用程序源代码的根目录下运行此命令:

    cargo vendor
    
  3. 我们希望覆盖从互联网到供应商文件夹的依赖项来源。在根应用程序文件夹中创建一个 .cargo 文件夹,并在 .cargo 文件夹内创建 config.toml

将这些行追加到 .cargo/config.toml 文件中:

[source.crates-io]
replace-with = "vendored-sources"
[source.vendored-sources]
directory = "vendor"
  1. 我们希望添加构建应用程序为 Docker 镜像所需的文件。我们不需要 Rocket.toml、模板或静态文件来构建应用程序。将这些行追加到 Dockerfile 中:

    COPY src src
    COPY Cargo.toml Cargo.toml
    COPY .cargo .cargo
    COPY vendor vendor
    
  2. 添加构建镜像的指令。我们希望使用另一个阶段并安装依赖项来构建镜像。添加以下行:

    FROM prepare-stage as build-stage
    RUN apk add --no-cache musl-dev
    RUN cargo build --release
    
  3. 通过运行以下命令尝试构建应用程序:

    docker build .
    
  4. 在测试之后,向 Dockerfile 中添加一个新的部分以运行应用程序。我们希望打开端口 8000。我们还希望添加默认时区并配置用户以运行应用程序。追加以下行:

    FROM rust:alpine
    EXPOSE 8000
    ENV TZ=Asia/Tokyo \
        USER=staff
    RUN addgroup -S $USER \
        && adduser -S -g $USER $USER
    
  5. 我们希望镜像包含最新的库。将以下行追加到 Dockerfile 中:

    RUN apk update \
        && apk add --no-cache ca-certificates tzdata \
        && rm -rf /var/cache/apk/*
    
  6. 设置工作目录。将此行追加到 Dockerfile 中:

    WORKDIR /app
    
  7. Rocket.toml 设置为从 0.0.0.0 运行。我们希望告诉应用程序使用主机运行的数据库。在 Docker 中,我们可以使用特殊域名 host.docker.internal 来引用主机机器。按照以下方式编辑 Rocket.toml

    [default.databases.main_connection]
    url = "postgres://username:passwordR@host.docker.internal:5432/rocket"
    [release]
    address = "0.0.0.0"
    
  8. 将生成的二进制文件、Rocket.toml、资产和模板复制到最终镜像中。将这些行追加到 Dockerfile 中:

    COPY --from=build-stage /app/target/release/our_application our_application
    COPY Rocket.toml Rocket.toml
    COPY static static
    COPY src/views src/views
    
  9. 添加存储日志文件的文件夹:

    RUN mkdir logs
    
  10. 添加更改权限到 $USER,如下所示:

    RUN chown -R $USER:$USER /app
    
  11. 最后,将应用程序的入口点运行到 Dockerfile 中:

    USER $USER
    CMD ["./our_application"]
    
  12. 使用此命令构建镜像并为其创建标签:

    docker build -t our_application .
    
  13. 在构建了 Docker 镜像之后,是时候运行它了。使用以下命令行:

    docker run --add-host host.docker.internal:host-gateway -dp 8000:8000 our_application
    

一切完成后,我们应该看到 Docker 容器正在运行并显示 our_application 输出:

图 13.3 - Docker Desktop 显示正在运行的容器和我们的应用程序

图 13.3 - Docker Desktop 显示正在运行的容器和我们的应用程序

使用 Docker 部署 Rocket 应用程序就像部署其他应用程序一样。我们需要复制源代码、构建并运行生成的镜像。有一些操作可以执行以确保正确部署,例如供应商库和打开正确的端口以确保可以向正在运行的容器和容器内运行的应用程序发送请求。

摘要

在本章中,我们学习了生产就绪的编译选项。我们可以使用它们来确保生成的二进制文件尽可能优化。我们还学习了如何设置一个通用目的的 HTTP 服务器与 Rocket 应用程序协同工作。最后,我们学习了如何为 Rocket 应用程序创建和运行 Docker 镜像。

在学习这些技术之后,我们将它们扩展到设置 Rocket 应用程序以服务于其目标用户。

在下一章中,我们将学习如何使用 Rust 与 Rocket 应用程序结合创建前端 WebAssembly 应用程序。

第十四章:第十四章:构建全栈应用程序

在本章中,我们将学习如何构建一个简单的 WebAssembly 应用程序,并使用 Rocket 来提供 WebAssembly 应用程序。我们将使 WebAssembly 从我们之前创建的一个端点中加载用户信息。学习本章中的信息后,您将能够使用 Rust 编写和构建 WebAssembly 应用程序。您将学习如何使用 Rocket 网络框架提供 WebAssembly。

在本章中,我们将涵盖以下主要主题:

  • 介绍 WebAssembly

  • 设置 Cargo 工作区

  • 设置 WebAssembly 构建目标

  • 使用 Yew 编写 WebAssembly 应用程序

  • 使用 Rocket 提供 WebAssembly 应用程序

技术要求

本章的技术要求非常简单:Rust 编译器、Cargo 命令行和一个网页浏览器。

您可以在github.com/PacktPublishing/Rust-Web-Development-with-Rocket/tree/main/Chapter14找到本章的代码。

介绍 WebAssembly

在过去,几乎所有的网页浏览器应用程序都是使用 JavaScript 语言制作的。也有尝试在网页浏览器中使用不同语言的情况,例如 Java Applet、Adobe Flash 和 Silverlight。但是,所有这些不同的尝试都不是网络标准,因此这些尝试的采用并没有像 JavaScript 那样普遍。

然而,有一种方法可以在网页浏览器中使用其他编程语言:通过使用WebAssembly。WebAssembly 既是一种二进制可执行格式,也是对应于基于栈的虚拟机的文本格式。支持 WebAssembly 的网页浏览器可以执行二进制可执行格式。任何可以编译成 WebAssembly 的编程语言都可以由网页浏览器执行。

2015 年,WebAssembly 被宣布,并于 2017 年 3 月首次发布。所有主要的网页浏览器供应商都在 2017 年 9 月完成了对至少支持 WebAssembly 的浏览器的发布,然后万维网联盟于 2019 年 12 月 5 日推荐了 WebAssembly。

类似于 C++或 Rust 这样的编译型语言可以被编译成.wasm文件,然后浏览器中的虚拟机可以运行 WebAssembly 文件。要运行解释型语言,首先,语言运行时可以被编译成.wasm文件,然后运行时可以运行运行时脚本。

图 14.1 - WebAssembly 中的解释型语言和编译型语言

图 14.1 - WebAssembly 中的解释型语言和编译型语言

Rust 编程语言支持 WebAssembly,而且我们已经学习了 Rust 并使用 Rust 和 Rocket 创建了一个后端应用程序,我们可以利用这个机会学习一点使用 Rust 开发前端应用程序。旧的网络标准和网络技术,如 HTML、CSS 和 JavaScript,是改变了人类历史进程的技术。了解新的网络标准,如 WebAssembly,是成为未来开发一部分的好机会。

让我们在应用程序中实现一个页面,我们将渲染一个空模板。模板将从服务器加载 WebAssembly 二进制文件。WebAssembly 将调用我们之前创建的用户 API 端点。然后,它将使用自定义组件渲染用户。

对于实现,我们将使用 Yew (yew.rs),这是一个前端 Rust 框架。

设置 Cargo 工作区

由于我们即将创建一个新的应用程序,如果我们能让 our_application Rocket 应用程序的代码与这个新应用程序一起工作那就很好了。Cargo 有一个名为 Cargo 工作区 的功能。Cargo 工作区是在单个目录中包含多个 Cargo 包的集合。

让我们按照以下步骤设置一个 Cargo 工作区,以便在单个目录中拥有多个应用程序:

  1. 创建一个目录,例如,01Wasm

  2. our_application 目录移动到 01Wasm 目录内,并在 01Wasm 目录内创建一个新的 Cargo.toml 文件。

  3. 按照以下方式编辑 Cargo.toml 文件:

    [workspace]
    members = [
      "our_application",
    ]
    
  4. 01Wasm 内使用以下命令创建一个新的 Rust 应用程序:

    cargo new our_application_wasm
    
  5. 然后,将新应用程序添加到 01Wasm/Cargo.toml 中的工作区成员,如下所示:

    members = [
      "our_application",
      "our_application_wasm",
    ]
    
  6. 使用以下命令尝试构建这两个应用程序:

    cargo build
    
  7. 要构建或运行其中一个应用程序,请使用二进制包名称添加 --bin,或使用库包名称添加 --lib。要运行应用程序,请考虑运行 Rocket 应用程序所需的目录位置。例如,如果没有日志目录,应用程序可能无法运行。此外,如果没有静态目录,应用程序可能无法找到资产文件。

  8. 在终端中运行此命令尝试构建其中一个应用程序:

    cargo build --bin our_application
    

现在我们已经设置了 Cargo 工作区,我们可以学习如何为不同的目标构建应用程序,特别是针对 WebAssembly 的应用程序。

设置 WebAssembly 构建目标

Rust 编译器可以设置为编译到不同的架构。这些架构也称为 x86_64-unknown-linux_gnux86_64-apple-darwin

目标可以分为三个级别,一级、二级和三级:

  • 一级意味着目标保证能够正常工作。

  • 二级意味着目标保证能够构建,但有时为这些目标构建的二进制文件的自动化测试可能不会通过。此级别的宿主工具和完整标准库也得到支持。

  • Tier 3表示 Rust 代码库支持目标的一些功能。为这些目标构建可能存在也可能不存在,并且工具可能不完整。

记住,WebAssembly 是一个虚拟机的二进制格式。Rust 编译器有针对虚拟机规范的 target,例如asmjs-unknown-emscriptenwasm32-unknown-emscriptenwasm32-unknown-unknown。社区主要支持围绕wasm32-unknown-unknown的工具。

要查看 Rust 编译器的可用目标列表,请在终端中运行以下命令:

rustup target list

要为 Rust 编译器添加 WebAssembly 目标支持,请在终端中运行以下命令:

rustup target add wasm32-unknown-unknown

添加目标后,通过运行此命令尝试构建our_application_wasm

cargo build --target wasm32-unknown-unknown --bin our_application_wasm

在下一节中,我们将使用wasm32-unknown-unknown来构建 WebAssembly 应用程序。

使用 Yew 编写 WebAssembly 应用程序

在应用程序中,我们将使用 Yew(https://yew.rs)。在网站上,它说 Yew 是一个用于创建多线程前端 Web 应用程序的现代 Rust 框架。

Cargo 可以编译 WebAssembly 二进制文件,但如果没有其他步骤,WebAssembly 二进制文件本身是不可用的。我们必须在 Web 浏览器的虚拟机引擎中加载 WebAssembly 二进制文件。有一些提议,例如使用<script type="module"></script>标签,但不幸的是,这些提议还没有成为标准。我们必须告诉 JavaScript 使用 WebAssembly Web API 来加载模块。为了使开发更容易,我们可以使用来自 Rust WebAssembly 工作组(https://rustwasm.github.io/)的wasm-pack。Yew 使用一个名为trunkhttps://trunkrs.dev)的应用程序,它封装了wasm-pack并提供其他便利。使用以下命令安装trunk

cargo install --locked trunk

现在编译 WebAssembly 的准备已经完成,我们可以为 WebAssembly 应用程序编写代码。按照以下步骤创建应用程序:

  1. our_application_wasm目录内创建一个名为index.html的 HTML 文件。我们将使用此 HTML 文件来模拟our_application上的模板,但有细微差别。我们希望为 HTML 标签添加一个 ID,使其成为 WebAssembly 应用程序的主要标签。让我们称这个 ID 为main_container。将以下行追加到our_application_wasm/index.html

    <!DOCTYPE html>
    <html lang="en">
    <head>
    </head>
    <body>
      <header>
        <a href="/" class="button">Home</a>
      </header>
      <div class="container" id="main_container"></div>
    </body>
    </html>
    
  2. yew作为依赖项添加到our_application_wasm中。我们还想访问浏览器 DOM,因此需要另一个依赖项。Gloo (gloo-rs.web.app/)提供了对 Web API 的绑定,我们希望将gloo_utils作为我们的 WebAssembly 应用程序的依赖项来访问 DOM。将以下依赖项添加到our_application_wasm/Cargo.toml中:

    gloo-utils = "0.1.3"
    yew = "0.19"
    getrandom = { version = "0.2", features = ["js"] }
    
  3. our_application_wasm/src/main.rs中添加所需的use声明:

    use gloo_utils::document;
    use yew::prelude::*;
    
  4. our_application_wasm/src/main.rs中创建一个最小的组件,创建一个空的 HTML:

    #[function_component(App)]
    fn app() -> Html {
        html! {
            <>{"Hello WebAssembly!"}</>
        }
    }
    
  5. our_application_wasm/src/main.rsmain()函数中使用gloo_utils选择具有main_container ID 的div标签。在main()函数中追加以下行:

    let document = document();
    let main_container = document.query_selector("#main_container").unwrap().unwrap();
    
  6. 通过将此行添加到main()函数来初始化 Yew 应用程序:

    yew::start_app_in_element::<App>(main_container);
    
  7. 我们可以使用trunk创建一个小的 Web 服务器,构建构建 WebAssembly 和相关 JavaScript 所需的一切,以加载 WebAssembly 并服务 HTML。在our_application_wasm目录内的终端中运行以下命令:

    trunk serve
    

终端中应该有如下输出:

Apr 27 20:35:44.122  INFO fetching cargo artifacts
Apr 27 20:35:44.747  INFO processing WASM
Apr 27 20:35:44.782  INFO using system installed binary app="wasm-bindgen" version="0.2.80"
Apr 27 20:35:44.782  INFO calling wasm-bindgen
Apr 27 20:35:45.065  INFO copying generated wasm-bindgen artifacts
Apr 27 20:35:45.072  INFO applying new distribution
Apr 27 20:35:45.074  INFO ✅ success
Apr 27 20:35:45.074  INFO 📡 serving static assets at -> /
Apr 27 20:35:45.075  INFO 📡 server listening at 0.0.0.0:8080
Apr 27 20:53:10.796  INFO 📦 starting build
Apr 27 20:53:10.797  INFO spawning asset pipelines
Apr 27 20:53:11.430  INFO building our_application_wasm
  1. 尝试打开一个网络浏览器到http://127.0.0.1:8080;你会看到它加载并运行 Yew WebAssembly 应用程序:

图 14.2 - Hello WebAssembly!

图 14.2 - Hello WebAssembly!

  1. 我们将使用一个 API 端点来获取用户信息,该端点返回我们在our_application中之前创建的 JSON,从http://127.0.0.1:8000/api/users。要将 JSON 转换为 Rust 类型,让我们定义与our_application中相似的类型。这些类型应该派生自 SerDes 的deserialize。在our_application_wasm/Cargo.toml中,添加 WebAssembly 代码的依赖项:

    chrono = {version = "0.4", features = ["serde"]}
    serde = {version = "1.0.130", features = ["derive"]}
    uuid = {version = "0.8.2", features = ["v4", "serde"]}
    
  2. 然后,在our_application_wasm/src/main.rs中添加所需的use声明:

    use chrono::{offset::Utc, DateTime};
    use serde::Deserialize;
    use std::fmt::{self, Display, Formatter};
    use uuid::Uuid;
    
  3. 最后,添加用于反序列化 JSON 的类型:

    #[derive(Deserialize, Clone, PartialEq)]
    enum UserStatus {
        Inactive = 0,
        Active = 1,
    }
    impl fmt::Display for UserStatus {
        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> 
        fmt::Result {
            match *self {
                UserStatus::Inactive => write!(f, 
                "Inactive"),
                UserStatus::Active => write!(f, "Active"),
            }
        }
    }
    #[derive(Copy, Clone, Deserialize, PartialEq)]
    struct OurDateTime(DateTime<Utc>);
    impl fmt::Display for OurDateTime {
        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> 
        fmt::Result {
            write!(f, "{}", self.0)
        }
    }
    #[derive(Deserialize, Clone, PartialEq)]
    struct User {
        uuid: Uuid,
        username: String,
        email: String,
        description: Option<String>,
        status: UserStatus,
        created_at: OurDateTime,
        updated_at: OurDateTime,
    }
    #[derive(Clone, Copy, Deserialize, PartialEq)]
    struct Pagination {
        next: OurDateTime,
        limit: usize,
    }
    #[derive(Deserialize, Default, Properties, PartialEq)]
    struct UsersWrapper {
        users: Vec<User>,
        #[serde(skip_serializing_if = "Option::is_none")]
        #[serde(default)]
        pagination: Option<Pagination>,
    }
    

    注意

    为了改进重新定义类型,我们可以创建一个库,该库定义的类型可以由两个应用程序使用。

  4. 如果我们查看User结构体,我们可以看到描述字段是一个Option。创建一个便利函数,如果值是None,则返回一个空String,如果值是Some,则返回String的内容。将以下函数添加到our_application_wasm/src/main.rs

    struct DisplayOption<T>(pub Option<T>);
    impl<T: Display> Display for DisplayOption<T> {
        fn fmt(&self, f: &mut Formatter) -> fmt::Result {
            match self.0 {
                Some(ref v) => write!(f, "{}", v),
                None => write!(f, ""),
            }
        }
    }
    
  5. 现在是时候实现一个将渲染User的组件了。我们将该组件命名为UsersList。将以下函数添加到our_application_wasm/src/main.rs

    #[function_component(UsersList)]
    fn users_list(UsersWrapper { users, .. }: &UsersWrapper) -> Html {
        users.iter()
            .enumerate().map(|user| html! {
            <div class="container">
                <div><mark class="tag">{ format!("{}", 
                user.0) }</mark></div>
                <div class="row">
                    <div class="col-sm-3"><mark>{ "UUID:" 
                    }</mark></div>
                    <div class="col-sm-9"> { format!("{}", 
                    user.1.uuid) }</div>
                </div>
                <div class="row">
                    <div class="col-sm-3"><mark>{ 
                    "Username:" }</mark></div>
                    <div class="col-sm-9">{ format!("{}", 
                    user.1.username) }</div>
                </div>
                <div class="row">
                    <div class="col-sm-3"><mark>{ "Email:" 
                    }</mark></div>
                    <div class="col-sm-9"> { format!("{}", 
                    user.1.email) }</div>
                </div>
                <div class="row">
                    <div class="col-sm-3"><mark>{ 
                    "Description:" }</mark></div>
                    <div class="col-sm-9"> { format!("{}", 
                    DisplayOption(user.1.description.
                    as_ref())) }</div>
                </div>
                <div class="row">
                    <div class="col-sm-3"><mark>{ 
                    "Status:" }</mark></div>
                    <div class="col-sm-9"> { format!("{}", 
                    user.1.status) }</div>
                </div>
                <div class="row">
                    <div class="col-sm-3"><mark>{ "Created 
                    At:" }</mark></div>
                    <div class="col-sm-9"> { format!("{}", 
                    user.1.created_at) }</div>
                </div>
                <div class="row">
                    <div class="col-sm-3"><mark>{ "Updated 
                    At:" }</mark></div>
                    <div class="col-sm-9"> { format!("{}", 
                    user.1.updated_at) }</div>
                </div>
                <a href={format!("/users/{}", 
                user.1.uuid)} class="button">{ "See user" 
                }</a>
            </div>
        }).collect()
    }
    

注意,html!宏的内容看起来像our_application/src/views/users/_user.html.tera的内容。

  1. 我们希望从 API 端点加载User数据。我们可以通过使用两个库来实现这一点,reqwasm(它提供 HTTP 请求功能),以及wasm-bindgen-futures(它将 Rust futures转换为 JavaScript promise和反之亦然)。将以下依赖项添加到our_application_wasm/Cargo.toml

    reqwasm = "0.2"
    wasm-bindgen-futures = "0.4"
    
  2. our_application_wasm/src/main.rs中,为我们的 API 端点添加一个const。添加以下行:

    const USERS_URL: &str = "http://127.0.0.1:8000/api/users";
    
  3. 实现获取User数据的例程。添加所需的use声明:

    use reqwasm::http::Request;
    

然后,在our_application_wasm/src/main.rs中的app()函数内追加以下行:

fn app() -> Html {
    let users_wrapper = use_state(|| UsersWrapper::
    default());
    {
        let users_wrapper = users_wrapper.clone();
        use_effect_with_deps(
            move |_| {
let users_wrapper = 
                users_wrapper.clone();
                wasm_bindgen_futures::spawn_
                local(async move {
let fetched_users_wrapper: 
                    UsersWrapper = Request::get(
                    USERS_URL)
                        .send()
                        .await
                        .unwrap()
                        .json()
                        .await
                        .unwrap();
                    users_wrapper.set(fetched_
                    users_wrapper);
                });
                || ()
            },
            (),
        );
    }
}
  1. users_wrapper获取下的{}块中,设置nextlimit的值。追加以下行:

    let users_wrapper = use_state(|| UsersWrapper::default());
    {
        ...
    }
    let (next, limit): (Option<OurDateTime>, Option<usize>) = if users_wrapper.pagination.is_some()
    {
        let pagination = users_wrapper.
        pagination.as_ref().unwrap();
        (Some(pagination.next), Some(pagination.limit))
    } else {
        (None, None)
    };
    
  2. 将 HTML 从Hello WebAssembly!更改为显示正确的User信息。我们希望使用我们之前创建的UsersList组件。将html!宏内容更改为以下内容:

    html! {
        <>
            <UsersList users = {users_wrapper.
            users.clone()}/>
            if next.is_some() {
                <a href={ format!("/users?
                pagination.next={}&pagination.limit={}", 
                DisplayOption(next), DisplayOption(limit)) 
                } class="button">
                    { "Next" }
                </a>
            }
        </>
    }
    
  3. 通过在终端运行此命令来构建 our_application_wasm WebAssembly 和 JavaScript:

    trunk build
    

命令应在 dist 目录中生成三个文件:index.html、一个具有随机名称的 WebAssembly 文件和一个具有随机名称的 JavaScript 文件。dist 目录中随机 WebAssembly 和 JavaScript 文件的示例是 index-9eb0724334955a2a_bg.wasmindex-9eb0724334955a2a.js

到目前为止,我们已经成功编写并构建了一个 WebAssembly 应用程序。在下一节中,我们将学习如何使用 Rocket 服务 WebAssembly 应用程序。

使用 Rocket 服务 WebAssembly 应用程序

在本节中,我们将按照以下步骤使用以下步骤来服务 WebAssembly 网络应用程序:

  1. 要在 our_application 中运行 WebAssembly 文件,我们需要对 our_application 进行一点修改。首先,将 WebAssembly 和 JavaScript 从 our_application_wasm/dist 复制到 our_application/static 目录。

  2. 编辑模板以能够选择性地在 our_application/src/views/template.html.tera 中使用 WebAssembly,如下所示:

    <head>
      ...
      {% block wasm %}{% endblock wasm %}
      <meta...> 
    </head>
    <body>
      ...
      {% block wasmscript %}{% endblock wasmscript %}
    </body>
    
  3. 添加一个名为 our_application/src/views/users/wasm.html.tera 的新模板文件。编辑文件以确保 HTML 文件加载必要的 WebAssembly 和 JavaScript 文件,并在正确的 DOM 上运行 WebAssembly。添加以下行:

    {% extends "template" %}
    {% block wasm %}
    <link rel="preload" href="/assets/index-9eb0724334955a2a_bg.wasm" as="fetch" type="application/wasm" crossorigin="">
    <link rel="modulepreload" href="/assets/index-9eb0724334955a2a.js">
    {% endblock wasm %}
    {% block body %}
    <div id="main_container"></div>
    {% endblock body %}
    {% block wasmscript %}
    <script type="module">import init from '/assets/index-9eb0724334955a2a.js';init('/assets/index-9eb0724334955a2a_bg.wasm');</script>
    {% endblock wasmscript %}
    
  4. 添加一个新的路由处理函数来加载生成的 HTML。在 our_application/src/routes/user.rs 中添加以下函数:

    #[get("/users/wasm", format = "text/html")]
    pub async fn wasm() -> HtmlResponse {
        let context = context! {};
        Ok(Template::render("users/wasm", context))
    }
    
  5. 最后,别忘了加载路由。在 our_application/src/lib.rs 中添加新路由:

    user::delete_user_entry_point,
    user::wasm,
    post::get_post,
    
  6. 通过在 our_application 目录中运行 cargo run 来运行 our_application 网络服务器,然后在网页浏览器中打开 http://127.0.0.1:8000/users/wasm。如果我们检查网页浏览器开发者工具,我们可以看到网页浏览器运行了 JavaScript 和 WebAssembly,如下面的截图所示:

图 14.3 - 网页浏览器加载和运行 our_application_wasm

图 14.3 - 网页浏览器加载和运行 our_application_wasm

通过修改带有 main_container 标签的标签,然后从 http://127.0.0.1:8000/api/users 加载 JSON 并在网页浏览器中正确渲染 HTML,WebAssembly 应该可以正常运行。

摘要

互联网技术已经发展到允许网络浏览器运行虚拟机的通用二进制格式。现在,网络浏览器可以运行由 Rust 编译器生成的二进制文件。

在本章中,我们概述了 WebAssembly,以及如何准备 Rust 编译器以编译到 WebAssembly。我们还学习了如何设置 Cargo 工作区,以便在单个目录中拥有多个应用程序。

然后,我们学习了如何编写一个简单的前端应用程序,该应用程序使用 Yew 和其他 Rust 库从我们之前创建的 our_application API 端点加载 User 数据。

最后,我们完成了如何在 our_application 网络服务器中服务生成的 WebAssembly 和 JavaScript。

下一章是最后一章,我们将探讨如何扩展 Rocket 应用并寻找其替代方案。

第十五章:第十五章:改进 Rocket 应用程序

现在我们已经完成了简单的应用程序,在本章的最后一节,我们将探讨我们可以对 Rocket 应用程序进行的改进。

在本章中,我们将了解如何添加各种技术,如日志、跟踪和监控,以使 Rocket 应用程序达到现代 Web 开发的标准。我们将探讨缩放 Rocket 应用程序的技术。

我们还将探索其他用于 Rust 语言的 Web 框架。一个 Web 框架可能不是适合所有事情的最好工具,因此通过了解其他 Web 框架,我们可以拓宽我们对 Rust Web 生态系统的知识。

在本章中,我们将涵盖以下主要主题:

  • 扩展 Rocket 应用程序

  • 缩放 Rocket 应用程序

  • 探索替代的 Rust Web 框架

技术要求

本章的技术要求非常简单:Rust 编译器、Cargo 命令行和一个 Web 浏览器。

您可以在github.com/PacktPublishing/Rust-Web-Development-with-Rocket/tree/main/Chapter15找到本章的代码。

扩展 Rocket 应用程序

我们已经成功从头开始创建了一个简单的 Rocket 应用程序,从基本的 Rocket 概念如路由开始。有很多事情可以做来改进应用程序。在本节中,我们将讨论我们可以使用的库,以添加功能和改进系统。

添加日志

在现代设置中,一个好的 Web 应用程序通常需要日志和监控系统来获取有关系统本身的信息。之前,我们学习了如何向 Rocket 应用程序添加日志。日志系统将信息写入 stdout 和文件。我们可以通过使用分布式日志系统来改进日志系统,其中应用程序将日志发送到另一个服务器以创建应用程序事件的持续记录。

我们可以创建一个 Rocket 防火墙,将日志事件发送到第三方日志服务器,如 Logstash、Fluentd 或 Datadog。然后,日志可以被提取、转换、聚合、过滤,并用于进一步分析。

一个可以将日志发送到 Fluentd 的 crate 示例是github.com/tkrs/poston。使用 poston crate,我们可以创建一个工作池,定期将数据发送到 Fluentd 服务器。

将日志扩展到跟踪

在为 Rocket 应用程序设置日志之后,我们可以通过跟踪概念进一步改进日志功能。通常,日志关注记录单个事件,而跟踪关注应用程序的工作流程。有几个术语是常用的,包括 日志事件跨度跟踪

log是程序员用来捕获数据的单个信息片段,而event是日志的结构化形式。例如,假设我们有一个使用通用日志格式en.wikipedia.org/wiki/Common_Log_Format)的日志,如下所示:

127.0.0.1 user-identifier frank [10/Oct/2000:13:55:36 -0700] "GET /apache_pb.gif HTTP/1.0" 200 2326

我们可以将日志转换为事件,如下所示:

{
    "request.host": "127.0.0.1",
    "request.ident": "user-identifier",
    "request.authuser": "frank",
    "request.date": "2000-10-10 13:55:36-07",
    "request.request": "GET /apache_pb.gif HTTP/1.0",
    "request.status": 200,
    "request.bytes": 2326,
}

span是一种日志类型,但它覆盖的是一段时间,而不是单一时间点的信息。最后,trace是一系列 span 的集合,可以用来创建应用程序各部分的流程。

假设我们有一个名为Trace的公平性 Rocket 应用程序,我们可以通过使用Trace公平性和以下步骤来实现跟踪:

  1. 创建一个实现 Rocket 请求保护的 struct,例如,RequestID

  2. 当请求到达时,Trace公平性将request_idRequestID的一个实例)分配给Request实例。

  3. 然后,Trace公平性创建一个包含request_idstart_time信息的日志。

  4. 一个路由处理函数随后检索request_id作为参数,因为结构实现了 Rocket 请求保护。

  5. 在路由处理函数内部,我们希望应用程序首先做的是创建一个包含request_idfunction_start_time信息的日志。

  6. 我们可以在函数内部添加各种日志来记录时间;例如,在我们向数据库发送查询之前,我们创建一个包含request_id和时间信息的日志。稍后,当我们从数据库接收到响应时,我们再次创建一个日志。

  7. 然后,我们可以在函数返回之前再次添加一个日志,包含request_id和时间,以标记函数的结束。

  8. 最后,在Trace公平性中,我们再次使用request_idend_time创建一个日志。

通过转换和分析日志,我们可以将具有相同request_id的日志构建成 span。最后,我们可以将 span 的树状结构构建成一个 trace,记录 Rocket 请求-响应生命周期中每个事件的计时。通过使用跟踪信息,我们可以确定哪些应用程序部分可以进一步改进。

有几个 crate 我们可以用来进行跟踪,例如,docs.rs/tracing/latest/tracing/docs.rs/tracing-log/latest/tracing_log/,它们将 Rust 日志功能与跟踪功能桥接。

设置监控

在 Rocket 应用程序中使用日志和跟踪来获取信息时,监控是获取系统信息以评估系统自身能力的过程。例如,我们收集 Rocket 应用程序的服务器 CPU 使用情况。

对于监控,我们可以使用 Prometheus 与 Grafana 作为可视化工具,Datadog 或其他第三方应用程序。我们通常安装一个代理,这是一个收集并发送各种系统信息到分布式监控服务器的应用程序。

尽管没有直接连接到 Rocket 应用程序,通常监控系统也会收集有关应用程序本身的信息。例如,在容器化环境中,有活性和就绪概念确保容器准备好接收其预期功能。

我们可以在 Rocket 应用程序中设置一个返回200 HTTP 状态码的路由,或者一个 ping 数据库并返回200 HTTP 状态码的路由。然后我们可以告诉监控系统定期检查 Rocket 应用程序的响应。如果有响应,这意味着应用程序仍然运行正确,但没有响应则意味着 Rocket 应用程序存在问题。

设置邮件和警报系统

有时,我们需要在 Web 应用程序中实现邮件功能。例如,当用户在网站上注册时,系统随后会发送一封验证邮件。有几个库可以用于 Rust 发送邮件。例如,有一个名为 Lette 的 crate (crates.io/crates/lettre)。让我们看看发送邮件的示例代码。

Cargo.toml中添加以下依赖项:

[dependencies]
lettre = "0.9"
lettre_email = "0.9"

在应用程序中,例如在src/lib.rs中,我们可以添加以下函数来发送邮件:

use lettre::{SmtpClient, Transport};
use lettre_email::EmailBuilder;
fn send_email(email: &str, name: &str) -> Result<String, String> {
    let email = EmailBuilder::new()
        .to((email, name))
        .from("admin@our_application.com")
        .subject("Hi, welcome to our_application")
        .text("Hello, thank you for joining our_
        application.")
        .build()
        .unwrap();
    let mut mailer = SmtpClient::new_unencrypted_
    localhost().unwrap().transport();
    mailer
        .send(email.into())
        .map(|_| String::from("Successfuly sent email"))
        .map_err(|_| String::from("Couldn't send email"))
}

我们还可以向应用程序添加一个警报系统,用于在出现问题时发出警报。我们可以使用第三方通知系统或使用邮件系统在出现问题时发送通知。

现在我们已经探讨了多种改进 Rocket 应用程序的方法,让我们扩展我们的应用程序。

扩展 Rocket 应用程序

在开发 Rocket 应用程序并将其部署到生产环境之后,由于使用量的增加,应用程序可能需要扩展。有几种扩展 Web 应用程序的方法,它们可以分为两类:垂直扩展和水平扩展。

垂直扩展意味着为单个节点增加资源。例如,我们用速度更快的 CPU 替换运行 Rocket 应用程序的计算机的 CPU。垂直扩展的另一个例子是在运行 Rocket 应用程序的计算机中增加 RAM 的数量。

水平扩展是通过添加更多节点或更多计算机来处理工作负载来扩展应用程序。水平扩展的一个例子是在两台服务器上运行并设置 Rocket Web 服务器。

假设我们有以下系统:

![图 15.1 – 简单的 Rocket 应用程序图片 15.1_B16825.jpg

图 15.1 – 简单的 Rocket 应用程序

我们首先可以将数据库移动到另一台服务器,如下所示:

![图 15.2 – 分离数据库图片

图 15.2 – 分离数据库

然后,我们可以添加负载均衡器,如下所示:

![图 15.3 – 添加负载均衡器图片

图 15.3 – 添加负载均衡器

负载均衡器可以是硬件负载均衡器,IaaS(基础设施即服务)负载均衡器,如 AWS Load Balancer,Kubernetes 负载均衡器,或者软件负载均衡器,如 HAProxy 或 NGINX。

在添加负载均衡器后,我们还可以添加其他机器,每台机器都有自己的 Rocket 服务器实例,如下所示:

![图 15.4 – 水平扩展 Rocket 应用程序图片

图 15.4 – 水平扩展 Rocket 应用程序

如果我们想要负载均衡 Rocket 服务器,有一些事情我们需要注意,例如,确保 Rocket.toml 中的 "secret_key" 在所有 Rocket 服务器实例中相同。我们还可以确保我们的会话库和 cookie 不在每个实例的内存中存储内容,而是在共享存储中,例如数据库。

另一个提高 Rocket 应用程序扩展性的想法是将静态文件或资源托管在它们自己的服务器上。静态文件服务器可以是通用的 Web 服务器,如 Apache HTTP 服务器或 NGINX,或者像 AWS S3 或 Azure Storage 这样的服务。我们需要注意的一件事是在生成 Rocket 响应时,我们需要将静态资源设置到正确的服务器上。例如,我们不必将 HTML CSS 设置为 "./mini-default.css",而必须设置为 "static.example.com/mini-default.css".

在以下图中可以看到静态服务器与负载均衡器的示意图:

![图 15.5 – 添加静态文件服务器图片

图 15.5 – 添加静态文件服务器

我们还可以添加一个 内容分发网络 (CDN) 来在系统中分配负载,如下所示:

![图 15.6 – 添加 CDN图片

图 15.6 – 添加 CDN

CDN 可以来自 IaaS,例如 AWS CloudFront 或 GCP Cloud CDN,或者第三方 CDN 提供商,如 Fastly、Akamai 或 Cloudflare。这些 CDN 在各种地理位置提供服务器,并可以提供缓存和更快的网络连接,使我们的应用程序运行更快。

在基本扩展后,系统可以进一步扩展,例如通过添加数据库复制或集群,或者添加缓存系统,如 Redis 或 Redis 缓存集群。以下是一个此类系统的示例:

![图 15.7 – 添加数据库集群和缓存集群图片

图 15.7 – 添加数据库集群和缓存集群

系统扩展的一个重要部分是确定哪些部分的规格可以改进,或者哪些部分可以隔离到自己的服务器中,例如,增加运行 Rocket 服务器的计算机的 CPU,或者将数据库移动到自己的服务器,然后稍后从单个服务器扩展数据库到数据库集群。

现在我们已经学习了扩展 Rocket 应用程序的基本技术,让我们在下一节讨论一些与 Rocket Web 框架类似的软件。

探索 Rust Web 框架的替代方案

Rocket 是 Rust 编程语言的优秀 Web 框架,但有时我们可能需要其他工具来构建 Web 应用程序。在本节中,我们将探讨一些 Rocket Web 框架的替代方案。这些替代框架包括 Actix Web、Tide 和 Warp。让我们逐一检查这些 Web 框架。

Actix Web

Rocket 的一个很好的替代方案是 Actix Web (actix.rs/)。就像 Rocket 一样,Actix Web 是一个 Web 框架。最初,它是在 Actix crate(一个演员框架)之上创建的。如今,Actix 的功能不再使用,因为 Rust 的 futures 和 async/await 生态系统正在成熟。

就像 Rocket 一样,Actix Web 包含诸如路由、请求提取器、表单处理器、响应处理器和中间件系统等概念。Actix Web 还提供了诸如静态文件处理器、数据库连接、模板化等功能。

让我们看看一个 Actix Web 的代码示例,以了解它与 Rocket 的相似之处。

Cargo.toml 中,添加以下内容:

[dependencies]
actix-web = "4.0.1"

src/main.rs 中,添加以下内容:

use actix_web::{get, web, App, HttpServer, Responder};
#[get("/users/{name}")]
async fn user(name: web::Path<String>) -> impl Responder {
    format!("Hello {name}!")
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
    HttpServer::new(|| {
        App::new()
            .route("/hello_world", web::get().to(|| async { 
            "Hello World!" }))
            .service(user)
    })
    .bind(("127.0.0.1", 8080))?
    .run()
    .await
}

尝试运行应用程序并打开 http://127.0.0.1:8080/hello_worldhttp://127.0.0.1:8080/users/world 来查看结果。

Tide

另一个 Rust Web 框架的替代方案是 Tide (github.com/http-rs/tide)。与 Rocket 或 Actix Web 不同,这个框架只提供基本功能,如请求类型、结果类型、会话和中间件。

让我们看看一个 Tide 的代码示例,以了解它与 Rocket 的相似之处。

Cargo.toml 中,添加以下内容:

[dependencies]
tide = "0.16.0"
async-std = { version = "1.8.0", features = ["attributes"] }

src/main.rs 中,添加以下内容:

use tide::Request;
async fn hello_world(_: Request<()>) -> tide::Result {
    Ok(String::from("Hello World!").into())
}
#[async_std::main]
async fn main() -> tide::Result<()> {
    let mut app = tide::new();
    app.at("/hello_world").get(hello_world);
    app.listen("127.0.0.1:8080").await?;
    Ok(())
}

通过在命令行中运行 cargo run 并在浏览器中打开 http://127.0.0.1:8080/hello_world 来运行应用程序。

Warp

另一个 Rust Web 框架的替代方案是 Warp (github.com/seanmonstar/warp)。这个框架在其过滤器功能之上提供了各种功能。通过使用过滤器,它可以执行路径路由、提取参数和头部、反序列化查询字符串,并解析各种请求体,如表单、多部分表单数据和 JSON。Warp 还支持服务静态文件、目录、WebSocket、日志、中间件和基本的压缩系统。

让我们看看一个使用 Warp 的示例应用程序。在 Cargo.toml 文件中,添加以下内容:

[dependencies]
tokio = {version = "1", features = ["full"]}
warp = "0.3"

src/main.rs文件中,添加以下内容:

use warp::Filter;
#[tokio::main]
async fn main() {
    let hello = warp::path!("hello_world")
        .and(warp::path::end())
        .map(|| format!("Hello world!"));
    warp::serve(hello).run(([127, 0, 0, 1], 8080)).await;
}

再次,像 Tide 和 Warp 示例一样,尝试在浏览器中打开http://127.0.0.1:8080/hello_world

摘要

在本章的最后,我们学习了如何改进和扩展 Rocket 应用程序。我们可以使用各种工具来改进 Rocket 应用程序,例如添加日志记录、跟踪、监控和邮件发送器。我们还了解了一些关于扩展 Rocket 应用程序的原则。

最后,我们学习了其他 Rust Web 框架,如 Actix Web、Tide、13 和 Warp。

我们从学习如何创建和构建 Rust 应用程序以及与 Rust 一起工作的工具,如 Cargo 开始这本书。然后,我们学习了 Rocket 应用程序的基础知识,例如请求的生命周期以及如何配置 Rocket 应用程序。

然后,我们继续学习更多概念,例如 Rocket 路由和路由部分,如 HTTP 方法、路径、格式和数据。为了处理路由,我们必须创建一个函数,该函数接收请求对象并返回响应对象。

在继续学习 Rocket 的基础知识之后,我们更深入地了解了 Rocket 组件,例如状态、将数据库与 Rocket 连接以及防热罩。

之后,我们学习了如何组织 Rust 模块以创建更复杂的应用程序。然后,我们设计了一个应用程序并实现了路由来管理用户和帖子等实体。为了管理实体,我们学习了如何编写查询到数据库以添加、获取、修改或删除项目。

然后,我们讨论了更高级的话题,如 Rust 错误处理及其在 Rocket 应用程序中的实现。在继续讨论更高级的话题时,我们还学习了 Rocket 的功能,例如提供静态资产和使用模板生成响应。我们还讨论了如何使用表单,以及如何使用 CSRF 保护表单免受恶意攻击者攻击。

在学习如何处理表单数据后,我们学习了 Rust 泛型和如何在 Rocket 应用程序中应用 Rust 泛型以渲染具有相同特质的Post。为了处理Post的变体,我们学习了更多关于高级 Rust 编程的知识,包括生命周期和内存安全。我们还学习了在实现Post变体的处理时关于async编程和多线程的知识。

为了将 Rocket 作为一个现代 Web 框架使用,我们还学习了如何允许 Rocket 应用程序处理 API 和 JSON,使用身份验证和授权来保护应用程序,并学习了如何使用 JWT 来保护 API。

为了确保我们的 Rocket 应用程序按预期工作,我们接着学习了如何测试 Rust 和 Rocket 应用程序。在确认应用程序按预期工作后,我们学习了如何以不同的方式部署应用程序,例如将 Rocket 应用程序放在通用 Web 服务器后面,并使用 Docker 构建和提供 Rocket 应用程序。

为了补充后端应用,我们学习了如何在前端使用 Rust 创建 WebAssembly 应用程序。最后,我们还学习了如何扩展 Rocket 应用程序,以及如何寻找 Rocket 网络框架的替代方案。

现在我们已经掌握了构建 Rust 和 Rocket 应用程序的所有基础知识,我们可以在生产级别的网络应用程序中实现 Rust 和 Rocket 网络框架的技能。为了扩展这本书中的知识,你可以从 Rust 或 Rocket 网站和论坛中学习更多。不要犹豫,尝试使用 Rust 语言和 Rocket 网络框架制作出优秀的应用程序。

Packt_Logo_Orange

Packt.com

订阅我们的在线数字图书馆,全面访问超过 7,000 本书籍和视频,以及领先的行业工具,帮助你规划个人发展并推进职业生涯。如需更多信息,请访问我们的网站。

第十六章:为什么订阅?

  • 使用来自 4,000 多名行业专业人士的实用电子书和视频,节省学习时间,多花时间编码

  • 通过为你量身定制的 Skill Plans 提高学习效果

  • 每月免费获得一本电子书或视频

  • 完全可搜索,便于轻松访问关键信息

  • 复制粘贴、打印和收藏内容

你知道吗,Packt 为每本书都提供电子书版本,包括 PDF 和 ePub 文件?你可以在packt.com升级到电子书版本,作为印刷书客户,你有权获得电子书副本的折扣。如需了解更多详情,请联系我们 customercare@packtpub.com。

www.packt.com,你还可以阅读一系列免费的技术文章,订阅各种免费通讯,并享受 Packt 书籍和电子书的独家折扣和优惠。

你可能还会喜欢的其他书籍

如果你喜欢这本书,你可能对 Packt 的以下其他书籍也感兴趣:

Mastering Adobe Photoshop Elements

Rust Web Programming

Rust Web Programming

Maxwell Flitton

ISBN: 978-1-80056-081-9

  • 在 Rocket、Actix Web 和 Warp 中构建可扩展的 Web 应用

  • 使用 PostgreSQL 为你的 Web 应用应用数据持久性

  • 为你的 Web 应用构建登录、JWT 和配置模块

  • 从 Actix Web 服务器提供 HTML、CSS 和 JavaScript

  • 在 Postman 和 Newman 中构建单元测试和功能 API 测试

Mastering Adobe Captivate 2019 - Fifth Edition

Game Development with Rust and WebAssembly

Eric Smith

ISBN: 978-1-80107-097-3

  • 使用 WebAssembly 将 Rust 应用程序构建和部署到 Web 上

  • 使用 wasm-bindgen 和 Canvas API 绘制实时图形

  • 编写游戏循环并获取键盘输入以进行动态操作

  • 探索碰撞检测并创建一个可以在平台上跳上跳下并掉入洞中的动态角色

  • 使用状态机管理动画

Packt 正在寻找像你这样的作者

如果你对成为 Packt 的作者感兴趣,请访问authors.packtpub.com并今天申请。我们已与成千上万的开发者和技术专业人士合作,就像你一样,帮助他们将见解分享给全球技术社区。你可以提交一般申请,申请我们正在招募作者的特定热门话题,或者提交你自己的想法。

分享你的想法

现在你已经完成了《Rust Web Development with Rocket》的学习,我们非常想听听你的想法!如果你是从亚马逊购买了这本书,请点击此处直接跳转到亚马逊评论页面并分享你的反馈或在该购买网站上留下评论。

你的评论对我们和科技社区都非常重要,它将帮助我们确保我们提供的是高质量的内容。

posted @ 2025-09-06 13:42  绝不原创的飞龙  阅读(21)  评论(0)    收藏  举报