Rust-微服务实用指南-全-

Rust 微服务实用指南(全)

原文:annas-archive.org/md5/167ac8a249c0934121fef18f553bde1d

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

本书将介绍使用 Rust 开发微服务。我最近才开始使用 Rust,回溯到 2015 年。那时,1.0 版本发布才几个月,我当时并不认为这个工具会带来一场无声的革命,这场革命将颠覆与系统编程相关的传统,而那时,系统编程是乏味的,根本不是时尚。

也许我有点夸张,但我见证了公司如何停止使用传统工具,并开始用 Rust 重写他们产品或服务的一部分,他们对结果如此满意,以至于一次又一次地这样做。如今,Rust 是区块链倡议的重要组成部分,是 WebAssembly 的旗舰,是开发快速且可靠的微服务的强大工具,这些微服务利用了所有可用的服务器资源。因此,Rust 已经从好奇开发者的爱好工具转变为现代产品的坚实基础。

在本书中,我们将学习如何使用 Rust 创建微服务。我们从对微服务的简要介绍开始,讨论为什么 Rust 是编写它们的良好工具。然后,我们将使用hyper crate 创建我们的第一个微服务,并学习如何配置微服务和记录活动。之后,我们将探讨如何使用serde crate 支持不同格式的请求和响应。

本书面向的读者

这本书是为两类读者设计的——对于微服务新手的经验丰富的 Rust 开发者,以及对于 Rust 新手的先进微服务开发者。我试图涵盖今天 Rust 开发者可用的有用工具和 crate 的生态系统。本书描述了微服务的创建过程,从高级框架到构建低级异步组合器,这些组合器以最小的资源阻塞时间产生响应。本书旨在帮助你找到特定任务的解决方案。

为了能够理解本书涵盖的主题,你需要对 Rust 编程语言有扎实的背景知识(你应该能够使用cargo编写和编译应用程序,理解生命周期和借用概念,了解特质的工作原理,以及理解如何使用引用计数器、互斥锁、线程和通道)。如果你对 Rust 不熟悉,请在阅读本书之前花时间理解这些概念。

你还必须知道如何编写一个基于 HTTP 协议的最小后端。你必须理解 REST 是什么,以及如何将其用于应用程序。然而,你不需要理解 HTTP/2 是如何工作的,因为我们将使用提供对特定传输抽象无关的 crate。

本书涵盖的内容

第一章,微服务简介,介绍了微服务以及如何使用 Rust 创建它们。在这一章中,我们还讨论了使用 Rust 创建微服务的优势。

第二章,使用 Hyper Crate 开发微服务,描述了如何使用hyper crate 创建微服务,从而允许我们创建一个紧凑的异步 Web 服务器,并能够精确控制传入的请求(方法、路径、查询参数等)。

第三章,日志记录和配置微服务,包括有关使用命令行参数、环境变量和配置文件配置微服务的信息。您还将了解如何将日志添加到您的项目中,因为这是微服务在生产中维护最重要的功能。

第四章,使用 Serde Crate 进行数据序列化和反序列化,解释了除了常规的 HTTP 请求之外,您的微服务还必须支持特定格式的正式请求和响应,如 JSON 和 CBOR,这对于 API 实现以及在微服务之间组织相互交互非常重要。

第五章,使用 Futures Crate 理解异步操作,深入探讨了 Rust 的更深入的异步概念以及如何使用异步原语编写组合器来处理请求并为客户端准备响应。如果没有对这些概念有清晰的理解,您将无法编写有效的微服务来利用服务器所有可用的资源,并且避免阻塞执行异步活动的线程,这些线程需要特殊的执行运行时处理。

第六章,反应式微服务 – 提高容量和性能,向您介绍了一种不会立即响应传入请求的反应式微服务,并且在完成请求和响应处理时需要花费时间。您将熟悉 Rust 中的远程过程调用以及如何使用该语言,以便微服务之间可以相互调用。

第七章,与数据库的可靠集成,展示了如何使用 Rust 与数据库交互。您将了解提供数据库交互的 crates,包括 MySQL、PostgreSQL、Redis、MongoDB 和 DynamoDB。

第八章,使用对象关系映射(ORM)与数据库交互,解释了为了有效地与 SQL 数据库交互并将数据库记录映射到本机 Rust 结构体,您可以使用对象关系映射ORM)。本章演示了如何使用需要夜间编译器版本的 diesel crates,以及其能力用于生成与表绑定的绑定。

第九章,使用框架进行简单的 REST 定义和请求路由,解释了在某些情况下,你不需要编写严格的异步代码,并且使用简化微服务编写的框架就足够了。在本章中,你将熟悉四个这样的框架——rouille、nickel、rocket 和 gotham。

第十章,微服务中的后台任务和线程池,讨论了微服务中的多线程以及如何在需要高 CPU 负载且不能异步执行的情况下使用线程池来执行后台任务。

第十一章,使用 Actors 和 Actix Crate 处理并发,介绍了 Actix 框架,该框架使用 actor 模型为你提供易于与 Rust 兼容的抽象。这包括性能平衡、代码可读性和任务分离。

第十二章,可扩展的微服务架构,深入解释了如何设计松耦合的微服务,这些微服务不需要了解兄弟微服务,并且使用消息队列和代理来相互交互。我们将编写一个示例,说明如何使用 RabbitMQ 与其他微服务进行交互。

第十三章,Rust 微服务的测试与调试,解释了测试和调试在为微服务发布做准备方面是一个关键组成部分。你将学习如何从单元测试到覆盖整个应用程序的集成测试来测试微服务。之后,我们将讨论如何使用调试器和日志功能来调试应用程序。此外,我们还将创建一个使用基于 OpenTrace API 的分布式跟踪的示例——这是一个用于跟踪复杂应用程序活动的现代工具。

第十四章,微服务优化,阐述了如何优化微服务并提取最大可能的性能。

第十五章,将服务器打包到容器中,解释了当微服务准备发布时,应该关注将微服务打包到容器中,因为至少一些微服务需要额外的数据和环境才能工作,或者只是为了获得比裸二进制文件更快的容器交付的优势。

第十六章,Rust 微服务的 DevOps - 持续集成与交付,继续探讨如何构建微服务的主题,并解释如何使用持续集成来自动化产品的构建和交付流程。

第十七章,使用 AWS Lambda 的边界微服务,向您介绍了无服务器架构,这是一种编写服务的替代方法。您将熟悉 AWS Lambda,并可以使用 Rust 编写快速函数,作为无服务器应用程序的一部分。此外,我们将使用 Serverless Framework 以完全自动化的方式构建和部署示例应用程序到 AWS 基础设施。

要充分利用本书

您至少需要 Rust 的 1.31 版本。使用rustup工具安装它:rustup.rs/。为了编译某些章节的示例,您还需要安装编译器的夜间版本。您还需要安装 Docker 和 Docker Compose 来运行带有数据库和消息代理的容器,以简化本书中示例微服务的测试。

下载示例代码文件

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

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

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

  2. 选择 SUPPORT 标签。

  3. 点击代码下载与勘误。

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

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

  • Windows 版的 WinRAR/7-Zip

  • Mac 版的 Zipeg/iZip/UnRarX

  • Linux 版的 7-Zip/PeaZip

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

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

下载彩色图像

我们还提供了一份包含本书中使用的截图/图表的彩色图像的 PDF 文件。您可以从这里下载:www.packtpub.com/sites/default/files/downloads/9781789342758_ColorImages.pdf

约定使用

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

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

代码块设置如下:

let conn = Connection::connect("postgres://postgres@localhost:5432", TlsMode::None).unwrap();

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

#[derive(Deserialize, Debug)]
struct User {
 name: String,
 email: String,
}

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

cargo run -- add user-1 user-1@example.com
cargo run -- add user-2 user-2@example.com
cargo run -- add user-3 user-3@example.com

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

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

小贴士和技巧看起来像这样。

联系我们

欢迎读者反馈。

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

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

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

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

评论

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

如需更多关于 Packt 的信息,请访问packt.com

第一章:微服务简介

本章将向您介绍微服务的基础知识,包括微服务是什么以及如何将单体服务器拆分为微服务。如果您不熟悉微服务的概念,或者从未使用 Rust 编程语言实现过它们,这将很有用。

本章将涵盖以下主题:

  • 什么是微服务?

  • 如何将传统服务器架构转换为微服务

  • Rust 在微服务开发中的重要性

技术要求

本章没有特殊的技术要求,但现在安装或更新您的 Rust 编译器是个好时机。您可以从 Rust 的官方网站获取:www.rust-lang.org/。我建议您使用rustup工具,您可以从rustup.rs/下载。

如果您之前已安装编译器,您需要使用以下命令将其更新到最新版本:

rustup update

您可以从 GitHub 页面获取本书的示例:github.com/PacktPublishing/Hands-On-Microservices-with-Rust-2018/

什么是微服务?

现代用户每天都在与微服务互动;虽然不是直接互动,但通过使用 Web 应用程序。微服务是一种灵活的软件开发技术,有助于将应用程序作为一系列独立服务集合起来,这些服务之间关系较弱。

在本节中,我们将了解为什么微服务是个好东西,以及为什么我们需要它们。微服务遵循 REST 架构,该架构提供了关于使用一致 HTTP 方法的规则。我们还将探讨如何将微服务部署给用户,这是它们的主要优势之一。

我们为什么需要微服务

微服务是一种现代软件开发方法,指的是将软件拆分为一系列小型服务,这些服务更容易开发、调试、部署和维护。微服务是微型的、独立的服务器,充当单一业务功能。例如,如果您有一个作为单体工作的电子商务套件,您可以将其拆分为具有有限责任的小型服务器,执行相同的任务。一个微服务可以处理用户授权,另一个可以处理用户的购物车,其余的服务可以处理诸如搜索功能、社交媒体集成或推荐等功能。

微服务可以与数据库交互或连接到其他微服务。为了与数据库交互,微服务可以使用不同的协议。这可能包括 HTTP 或 REST、Thrift、ZMQ、AMQP 用于消息通信风格、WebSockets 用于流数据,甚至古老的简单对象访问协议SOAP)来与现有基础设施集成。本书我们将使用 HTTP 和 REST,因为这是提供和交互 Web API 最灵活的方式。我们将在后面解释这个选择。

与单体服务器相比,微服务有以下优势:

  • 你可以使用不同的编程语言

  • 单个服务器的代码库更小

  • 它们有独立的 DevOps 流程来构建和部署活动

  • 它们可以根据其实现进行扩展

  • 如果一个微服务失败,其余的将继续工作

  • 它们在容器内运行良好

  • 元素之间的隔离性提高导致安全性更好

  • 它们适合涉及物联网的项目

  • 它们与 DevOps 哲学一致

  • 它们可以被外包

  • 开发完成后可以进行编排

  • 它们是可重用的

然而,微服务也有一些缺点。以下是一些:

  • 过多的微服务会超负荷开发过程

  • 你必须设计交互协议

  • 对于小型团队来说,它们可能很昂贵

微服务架构是一种现代方法,可以帮助你实现拥有松耦合元素的目标。这就是服务器相互独立,帮助你比单体方法更快地发布和扩展应用程序,在单体方法中,你把所有的鸡蛋都放在一个篮子里。

如何部署微服务

由于微服务是一个小型但完整的 Web 服务器,你必须将其作为完整的服务器进行部署。但由于其功能范围较窄,配置起来也更简单。容器可以帮助你将二进制文件打包到包含必要依赖项的操作系统镜像中,从而简化部署过程。

这与单体架构的情况不同,在单体架构中,你有一个系统管理员负责安装和配置服务器。微服务需要一个新角色来执行此功能——DevOps。DevOps 不仅仅是一个工作角色,而是一种整个软件工程文化,其中开发人员成为系统管理员,反之亦然。DevOps 工程师负责打包和交付软件给最终用户或市场。与系统管理员不同,DevOps 工程师与云和集群一起工作,通常除了自己的笔记本电脑外不接触任何硬件。

DevOps 使用大量自动化,并将应用程序通过交付流程的各个阶段:构建、测试、打包、发布或部署,以及工作系统的监控。这有助于减少将特定软件推向市场以及发布其新版本所需的时间。对于单体服务器,很难使用大量自动化,因为它们过于复杂且脆弱。即使你想将单体打包到容器中,你也必须以大型捆绑包的形式交付它,并承担应用程序任何部分可能失败的风险。在本节中,我们将简要介绍容器和持续集成。我们将在第十五章“将服务器打包到容器中”和第十六章“Rust 微服务的 DevOps – 持续集成和交付”中详细介绍这些主题。

Docker

当我们提到容器时,我们几乎总是指 Docker 容器(www.docker.com/)。Docker 是运行容器(隔离环境)中程序的最受欢迎的软件工具。

容器化是一种虚拟化形式,其中应用程序资源的作用域受到限制。这意味着应用程序在其最大性能水平上运行。这与全虚拟化不同,全虚拟化需要运行完整的操作系统及其相应的开销,并在该隔离操作系统中运行你的应用程序。

Docker 因其多种原因而变得流行。其中一个原因是它有一个注册表——你可以在这里上传和下载带有应用程序的容器镜像。公共注册表是 Docker Hub(hub.docker.com/explore/),但你可以为私有软件拥有一个私有或受权限的注册表。

持续集成

持续集成CI)是指保持软件的主副本,并使用测试和合并过程来扩展应用程序的功能。CI 的过程与源代码管理SCM)过程集成。当源代码更新时(例如,在 Git 中),CI 工具会检查它并开始测试。如果所有测试都通过,开发者可以将更改合并到主分支。

CI 不能保证应用程序将正常工作,因为测试可能会出错,但它消除了在自动系统中由开发者运行测试的需求。这让你能够测试所有即将到来的更改,以检测更改之间的冲突。另一个优点是 CI 系统可以将你的解决方案打包到容器中,因此你唯一需要做的事情是将容器交付到生产云。容器的部署也易于自动化。

如何将传统服务器拆分为多个微服务

大约 10 年前,开发者们通常使用 Apache 网络服务器和脚本编程语言来创建 Web 应用程序,在服务器端渲染视图。这意味着没有必要将应用程序拆分成多个部分,并且将代码保持在一起更为简单。随着单页应用程序SPA)的出现,我们只需要在特殊情况下进行服务器端渲染,应用程序被分为两部分:前端和后端。另一个趋势是服务器改变了处理方法,从同步(每个客户端交互都存在于一个单独的线程中)变为异步(一个线程使用非阻塞的输入输出操作同时处理多个客户端)。这一趋势促进了单服务器单元性能的提升,意味着它们可以服务数千个客户端。这意味着我们不需要特殊的硬件、专有软件或特殊的工具链或编译器来编写具有出色性能的小型服务器。

当脚本编程语言变得流行时,微服务的入侵发生了。通过这一点,我们不仅指的是服务器端脚本的编程语言,还包括像 Python 或 Ruby 这样的通用高级编程语言。JavaScript 在后台需求中的采用,尤其是它以前一直是异步的,具有特别的影响力。

如果你编写自己的服务器已经足够困难,你可以为特殊情况创建一个单独的服务器,并直接从前端应用程序中使用它们。这不需要在服务器上进行渲染过程。本节简要描述了从单体服务器到微服务的演变。我们现在将探讨如何将单体服务器分解成小块。

避免单体服务的理由

如果你已经有一个包含所有后端功能的单个服务器,即使你启动了两个或更多个此服务的实例,你仍然有一个单体服务。单体服务有几个缺点——它无法垂直扩展,无法在不中断所有运行实例的情况下更新和部署一个功能,如果服务器故障,它会影响所有功能。让我们进一步讨论这些缺点。这可能会帮助你说服你的经理将你的服务分解成微服务。

无法垂直扩展

应用程序扩展的两种常见方法:

  • 水平扩展:指的是启动应用程序的新实例

  • 垂直扩展:指的是改进一个具有瓶颈的独立应用层

扩展后端最简单的方式是启动服务器的另一个实例。这将解决问题,但在许多情况下,这会浪费硬件资源。例如,假设你在收集或记录统计数据的应用程序中遇到瓶颈。这可能会只使用你 CPU 的 15%,因为记录可能包括多个 I/O 操作,但没有密集的 CPU 操作。然而,为了扩展这个辅助功能,你将不得不为整个实例付费。

无法仅更新和部署一个功能

如果你的后端作为单体运行,你无法仅更新其一小部分。每次你添加或更改一个功能,你都必须停止、更新并重新启动服务,这会导致中断。

当你有一个微服务并且发现一个错误时,你可以停止并仅更新这个微服务,而不会影响其他服务。正如我之前提到的,将产品拆分成单独的开发团队也是有用的。

一个服务器的故障会影响所有功能

避免单体应用程序的另一个原因是,每次服务器崩溃也会导致所有功能崩溃,即使不是每个功能都需要它工作,应用程序也会完全停止工作。如果你的应用程序无法加载新的用户界面主题,错误不是关键的,只要你不从事时尚或设计行业,你的应用程序仍然能够向用户提供关键功能。如果你将单体应用程序分解成独立的微服务,你将减少崩溃的影响。

将单体服务分解成各个部分

让我们看看一个提供以下功能的电子商务单体服务器的例子:

  • 用户注册

  • 产品目录

  • 购物车

  • 支付集成

  • 电子邮件通知

  • 统计收集

几年前开发的旧式服务器将包括所有这些功能。即使你将其拆分为单独的应用程序模块,它们仍然会在同一服务器上运行。你可以在以下位置看到单体服务的示例结构:

图片

在现实中,实际服务器包含的模块比这更多,但我们已经根据它们执行的任务将它们分成了逻辑组。这是一个将单体应用程序分解成多个松散耦合的微服务的好起点。在这个例子中,我们可以进一步将其分解成以下图中表示的各个部分:

图片

如你所见,我们使用均衡器将请求路由到微服务。你实际上可以直接从前端应用程序连接到微服务。

在前面的图中显示了服务之间可能发生的潜在通信。对于简单情况,您可以使用直接连接。如果交互更复杂,您可以使用消息队列。但是,您应该避免使用共享状态,如中央数据库,并通过记录进行交互,因为这可能会成为整个应用程序的瓶颈。我们将在第十二章 可扩展微服务架构中讨论如何扩展微服务。现在,我们将探索 REST API,它将在本书的一些示例中部分实现。我们还将讨论为什么 Rust 是实现微服务的绝佳选择。

REST API 的定义

让我们使用 REST 方法定义我们将在微服务基础设施中使用的 API。在这个例子中,为了演示目的,我们的微服务将具有最小的 API;实际的微服务可能不会如此“微小”。让我们探索我们应用程序微服务的 REST 规范。我们将从查看用户注册微服务开始,并查看应用程序的每个部分。

用户注册微服务

第一个服务负责用户的注册。它必须包含添加、更新或删除用户的方法。我们可以使用标准的 REST 方法来满足所有需求。我们将使用方法和路径的组合来提供此用户注册功能:

  • 发送到 /user/POST 请求创建一个新用户并返回其 id

  • 发送到 /user/idGET 请求返回与具有 id 的用户相关的信息

  • 发送到 /user/idPUT 请求将更改应用于具有 id 的用户

  • 发送到 /user/idDELETE 请求移除具有 id 的用户

此服务可以使用 电子邮件通知 微服务并调用其方法来通知用户注册。

电子邮件通知微服务

电子邮件通知 微服务可以非常简单,并且只包含一个方法:

  • 发送到 /send_email/POST 请求向任何地址发送电子邮件

此服务器还可以通过请求 用户注册 微服务来计算发送的电子邮件数量,以防止垃圾邮件或检查电子邮件是否存在于用户的数据库中。这是为了防止恶意使用。

产品目录微服务

产品目录 微服务跟踪可用的产品,并且只需要与其他微服务建立弱关系,除了 购物车。此微服务可以包含以下方法:

  • 发送到 /product/POST 请求创建一个新产品并返回其 id

  • 发送到 /product/idGET 请求返回具有 id 的产品信息

  • 发送到 /product/idPUT 请求更新具有 id 的产品信息

  • 发送到 /product/idDELETE 请求将 id 标识的产品标记为已删除

  • 发送到 /products/GET 请求返回所有产品的列表(可以通过额外参数进行分页)

购物车微服务

购物车微服务与用户注册产品目录微服务紧密集成。它持有待购商品并准备发票。它包含以下方法:

  • /user/uid/cart/ 发送的 POST 请求,将产品放入购物车,并返回具有 uid 的用户购物车中项目的 id

  • /user/uid/cart/id 发送的 GET 请求,返回有关 id 的项目信息

  • /user/uid/cart/id 发送的 PUT 请求,用于更新具有 id 的项目信息(更改项目数量)

  • /user/uid/cart/ 发送的 GET 请求,返回购物车中所有项目的列表

如您所见,我们没有在 /cart/ URL 中添加额外的 "s",并且我们使用相同的路径来创建项目和获取列表,因为第一个处理器对 POST 方法做出响应,第二个处理器处理使用 GET 方法的请求,依此类推。我们还在路径中使用用户的 ID。我们可以以两种方式实现嵌套的 REST 函数:

  • 使用会话信息获取用户的 id。在这种情况下,路径包含单个对象,例如 /cart/id。我们可以将用户的 id 保存在会话 cookie 中,但这并不可靠。

  • 我们可以明确地将用户的 id 添加到路径中。

支付集成微服务

在我们的示例中,此微服务将是一个第三方服务,它包含以下方法:

  • /invoices 发送的 POST 请求创建一个新的发票并返回其 id

  • /invoices/id/pay 发送的 POST 请求支付发票

统计收集微服务

此服务收集使用统计信息并将用户的操作记录下来以供以后改进应用程序。此服务导出 API 调用来收集数据,并包含一些内部 API 来读取数据:

  • /log 发送的 POST 请求记录用户的操作(用户的 id 设置在请求体中)

  • 仅从内部网络向 /log?from=?&to=? 发送的 GET 请求返回指定期间收集的数据

此微服务并不明显符合 REST 原则。它对提供完整方法集以添加、修改和删除数据的微服务很有用,但对于其他服务来说,它过于限制性。您不必为所有服务遵循清晰的 REST 结构,但对于一些期望它的工具来说可能很有用。

转换为微服务

如果您已经有一个运行中的应用程序,您可能将其转换为一组微服务,但您必须保持应用程序以最高的速率运行并防止任何中断。

要做到这一点,您可以逐步创建微服务,从最不重要的任务开始。在我们的示例中,从电子邮件活动和日志开始会更好。这种做法有助于您从头开始创建 DevOps 流程并将其与您的应用程序维护流程结合起来。

重新使用现有的微服务

如果你的应用程序是一个单体服务器,你不需要将所有模块都转换为微服务,因为你可以使用现有的第三方服务并缩减需要重写的代码量。这些服务可以帮助处理许多事情,包括存储、支付、日志记录以及交易通知,告诉你事件是否已送达。

我建议你自己创建和维护确定你竞争优势的服务,然后使用第三方服务处理其他任务。这可以显著减少你的开支和上市时间。

在任何情况下,记住你正在交付的产品,不要在应用程序的不必要单元上浪费时间。微服务方法帮助你简单地实现这一点,与单体服务的繁琐编码不同,单体服务需要你处理许多次要任务。希望你现在已经完全了解微服务可能有用的原因。在下一节中,我们将探讨为什么 Rust 是创建微服务的有希望的工具。

为什么 Rust 是创建微服务的优秀工具

如果你选择了阅读这本书,你可能已经知道 Rust 是一种最新、强大且可靠的编程语言。然而,选择它来实现微服务并不是一个明显的决定,因为 Rust 是一种系统编程语言,通常用于低级软件,如驱动程序或操作系统内核。这是因为你往往需要编写大量的粘合代码或深入研究涉及低级概念(如系统编程语言中的指针)的详细算法。但这并不是 Rust 的情况。作为一个 Rust 程序员,你肯定已经看到了它如何利用灵活的语言能力创建高级抽象。在本节中,我们将讨论 Rust 的优势:其严格和显式的本质、其高性能以及其出色的包管理系统。

显式与隐式

直到最近,还没有一个使用 Rust 编写异步网络应用程序的成熟方法。以前,开发者倾向于使用两种风格:要么使用显式控制结构来处理异步操作,要么使用隐式上下文切换。Rust 的显式性意味着第一种方法超越了第二种。隐式上下文切换在像 Go 这样的并发编程语言中使用,但这种模型不适合 Rust,有各种原因。首先,它有设计限制,并且很难或甚至不可能在线程之间共享隐式上下文。这是因为标准 Rust 库为某些函数使用线程局部数据,程序无法安全地更改线程环境。另一个原因是上下文切换的方法有开销,因此不符合零成本抽象哲学,因为你会有一个后台运行时。一些现代库,如 actix,提供了一个类似于自动上下文切换的高级方法,但实际上使用显式控制结构来处理异步操作。

Rust 中的网络编程随着时间的推移而发展。当 Rust 发布时,开发者只能使用标准库。这种方法特别冗长,不适合编写高性能服务器。这是因为标准库没有包含任何好的异步抽象。此外,hyper 事件,一个用于创建 HTTP 服务器和客户端的好包,在单独的线程中处理请求,因此只能有有限数量的并发连接。

mio 包的引入是为了提供一个清晰的异步方法来构建高性能服务器。它包含了与操作系统异步特性交互的函数,例如 epoll 或 kqueue,但它仍然很冗长,这使得编写模块化应用程序变得困难。

mio 之上的下一个抽象层是由 futurestokio 这对包组成的。futures 包包含了实现延迟操作的抽象(如果你熟悉 Python 中的 Twisted 的 defer 概念),它还包含了用于组装流处理器的类型,这些处理器是反应式的,并且像有限状态机一样工作。

使用 futures 包是实现高性能和高精度网络软件的有效方式。然而,它是一个中间件包,这使得解决日常任务变得困难。它是重写 hyper 等包的好基础,因为这些包可以使用具有完全控制的显式异步抽象。

当前的最高抽象级别是使用 futurestokiohyper crate 的 crate,例如 rocketactix-web。现在,rocket 包含了构建具有最少行数的 Web 服务器的元素。actix-web 在你的软件被分解成相互交互的小实体时,充当一组演员。还有很多其他有用的 crate,但我们将从 hyper 开始,作为从头开始开发 Web 服务器的基石。使用这个 crate,我们将在低级 crate(如 futures)和高级 crate(如 rocket)之间。这将使我们能够详细了解两者。

最小化运行时错误

有许多语言适合创建微服务,但并非每种语言都有可靠的设计来防止你犯错误。大多数解释型动态语言允许你编写灵活的代码,该代码可以即时决定获取对象的哪个字段以及调用哪个函数。你甚至可以通过向对象添加元信息来覆盖函数调用的规则。这在元编程或数据驱动运行时行为的情况下至关重要。

然而,动态方法对于需要可靠性而不是灵活性的软件来说具有显著的缺点。这是因为代码中的任何不准确都会导致应用程序崩溃。当你第一次尝试使用 Rust 时,你可能会觉得它缺乏灵活性。但这并不是真的;区别在于你用来实现灵活性的方法。在 Rust 中,你必须遵守所有规则。如果你创建了足够的抽象来覆盖应用程序可能遇到的所有情况,你将获得你想要的灵活性。

来自 JavaScript 或 Python 世界的 Rust 新手可能会注意到,他们必须声明数据序列化/反序列化的每一个案例,而在动态语言中,你可以简单地解包任何输入数据到自由形式的对象,并在之后探索其内容。实际上,你必须检查运行时期间的所有不一致性案例,并尝试确定如果你更改一个字段并删除另一个字段可能会产生什么后果。在 Rust 中,编译器会检查一切,包括类型、存在性和相应的格式。这里最重要的是类型,因为你不能编译使用不兼容类型的程序。在其他语言中,这有时会导致奇怪的编译错误,例如,你有两个相同 crate 的类型,但由于它们是在不同版本的同一 crate 中声明的,因此这些类型是不兼容的。只有 Rust 可以保护你免受这种自伤的方式。事实上,不同版本可以有不同的序列化/反序列化规则,即使两个声明具有相同的数据布局。

优秀的性能

Rust 是一种系统编程语言。这意味着你的代码会被编译成处理器的原生二进制指令,并且运行时没有不必要的开销,这与 JavaScript 或 Python 等解释器不同。

Rust 也不使用垃圾回收器,你可以控制所有内存分配和缓冲区大小,以防止溢出。

Rust 之所以在微服务中如此快速,另一个原因是它具有零成本抽象,这意味着语言中的大多数抽象在编译时没有任何开销。它们在编译期间转化为有效代码,没有任何运行时开销。对于网络编程来说,这意味着你的代码在编译后将是有效的,也就是说,一旦你在源代码中添加了有意义的构造。

最小化依赖项负担

Rust 程序编译成一个单一的二进制文件,不包含任何不想要的依赖。如果你想要使用 OpenSSL 或类似的不可替代的依赖项,则需要 libc 或另一个动态库,但所有 Rust 包都是静态编译到你的代码中的。

你可能会认为编译的二进制文件太大,不适合用作微服务。然而,微服务这个词指的是狭义的逻辑范围,而不是大小。即便如此,静态链接的程序对于现代计算机来说仍然非常小巧。

这能给你带来什么好处?你将无需担心依赖项。每个 Rust 微服务都使用自己的一套依赖项,这些依赖项被编译成一个单一的二进制文件。你甚至可以保留具有过时功能和依赖项的微服务,同时使用新的微服务。此外,与 Go 编程语言相比,Rust 对依赖项有严格的规则。这意味着即使有人强制更新包含所需依赖项的存储库,项目也能抵抗崩溃。

Rust 与 Java 相比如何?Java 有用于构建微服务的微框架,但你必须携带所有依赖项。你可以将这些依赖项放入一个胖Java ARchiveJAR)中,这是 Java 中的一种编译代码分发方式,但你仍然需要Java 虚拟机JVM)。别忘了,Java 还会用类加载器加载每个依赖项。此外,Java 字节码是解释执行的,即时编译JIT)完成需要相当长的时间来加速代码。在 Rust 中,依赖项的自举过程不需要很长时间,因为它们在编译期间就附加到了代码上,你的代码将从一开始就以最高速度运行,因为它已经被编译成原生代码。

摘要

在本章中,我们掌握了微服务的基础知识。简单来说,微服务是一个处理特定任务的紧凑型 Web 服务器。例如,微服务可以负责用户认证或电子邮件通知。它们使运行单元可重用。这意味着如果它们不需要任何更新,你不需要重新编译或重启单元。这种方法在部署和维护中更简单、更可靠。

我们还讨论了如何将包含所有业务逻辑的单个单元的垄断式 Web 服务器拆分成更小的部分,并通过通信将它们连接起来,这与松耦合的理念相符。为了拆分垄断式服务器,你应该将其分离成执行特定任务的域。

在本章的最后部分,我们探讨了为什么 Rust 是开发微服务的良好选择。我们提到了依赖管理、Rust 的性能、其显式性和其工具链。现在是时候深入编码,用 Rust 编写一个最小的微服务了。

在下一章中,我们将开始使用hyper crate 用 Rust 编写微服务,该 crate 提供了编写紧凑型异步 HTTP 服务器所需的所有功能。

进一步阅读

你在本章中学到了微服务的基础知识,这将作为你在本书中开始用 Rust 编写微服务的起点。如果你想了解更多关于本章讨论的主题,请参考以下列表:

第二章:使用 Hyper Crate 开发微服务

本章将简要介绍使用 Rust 和hyper crate 创建微服务。我们将探讨 HTTP 协议的基础和路由原则。我们还将描述一个完全使用 Rust 编写的最小 REST 服务,使用简单的方法。

在本章中,我们将介绍以下主题:

  • 使用hyper

  • 处理 HTTP 请求

  • 使用正则表达式进行路由

  • 从环境中获取参数

技术要求

因为我们在本章开始编写代码,所以你需要安装某些软件来编译和运行示例:

  • 我建议你使用rustup工具,这将保持你的 Rust 实例更新。如果你没有这个工具,你可以从rustup.rs/获取它。安装后,运行rustup update命令来更新当前安装。

  • Rust 编译器,至少版本 1.31。

  • 我们将使用的hyper crate,需要 OpenSSL (www.openssl.org/) 库。大多数流行的操作系统已经包含了 OpenSSL 包,你可以遵循你的包管理器的手册来安装它。

你可以从 GitHub 获取本章中显示的示例,网址为github.com/PacktPublishing/Hands-On-Microservices-with-Rust/tree/master/Chapter02

绑定微型服务器

在本节中,我们将从头开始创建一个 Tiny Server。我们将从必要的依赖项开始,声明一个主函数,然后尝试构建和运行它。

添加必要的依赖项

首先,我们需要创建一个新的文件夹,我们将在这个文件夹中添加创建第一个微服务所需的依赖项。使用cargo创建一个名为hyper-microservice的新项目:

> cargo new hyper-microservice

打开创建的文件夹,并将依赖项添加到你的Cargo.toml文件中:

[dependencies]
hyper = "0.12"

单一依赖项是hyper crate。这个 crate 的最新版本是异步的,并且建立在futures crate 之上。它还使用了tokio crate 作为运行时,包括调度器、反应器和异步套接字。tokio crate 的一些必要类型在hyper::rt模块中被重新导出。hyper的主要目的是操作 HTTP 协议,这意味着这个 crate 未来可以支持其他运行时。

服务器的主体函数

让我们从主函数开始,逐一添加必要的依赖项,并详细说明为什么我们需要每个依赖项。一个最小化的 HTTP 服务器需要以下内容:

  • 绑定的地址

  • 一个用于处理传入请求的server实例

  • 任何请求的默认处理器

  • 一个server实例将运行的反应器(运行时)

服务器的地址

我们首先需要一个地址。套接字地址由 IP 地址和端口号组成。在这本书中,我们将使用 IPv4,因为它得到了广泛的支持。在第六章,反应式微服务 - 增加容量和性能,我们将讨论扩展和微服务之间的交互,我会展示一些使用 IPv6 的示例。

标准 Rust 库包含一个IpAddr类型来表示 IP 地址。我们将使用包含IpAddr和端口号u16SocketAddr结构体。我们可以从类型为([u8; 4], u16)的元组中构造SocketAddr。将以下代码添加到我们的主函数中:

let addr = ([127, 0, 0, 1], 8080).into();

我们在这里使用了一个impl<I: Into<IpAddr>> From<(I, u16)> for SocketAddr特质的实现,它反过来使用impl From<[u8; 4]> for IpAddr。这使得我们可以使用.into()方法调用从元组中构造套接字地址。同样,我们可以使用构造函数创建新的SocketAddr实例。在生产应用程序中,我们将从外部字符串(命令行参数或环境变量)解析套接字地址,如果没有设置任何变体,我们将从具有默认值的元组中创建SocketAddr

服务器实例

现在我们可以创建一个server实例并将其绑定到这个地址:

let builder = Server::bind(&addr);

前一行创建了一个带有bind构造函数的hyper::server::Server实例,该构造函数实际上返回Builder,而不是Server实例。Server结构体实现了Future特质。它具有与Result类似的作用,但描述了一个不可立即获得的价值。你将在第五章,使用 Futures Crate 理解异步操作中了解更多关于Futurefuturescrate 的其他特质。

设置请求处理器

Builder结构体提供了调整创建的server参数的方法。例如,hyper 的server支持HTTP1HTTP2。你可以使用builder值选择一个或两个协议。在下面的示例中,我们使用builder通过serve方法附加一个处理传入 HTTP 请求的服务:

let server = builder.serve(|| {
    service_fn_ok(|_| {
        Response::new(Body::from("Almost microservice..."))
    })
});

在这里,我们使用构建实例来附加一个生成Service实例的函数。这个函数实现了hyper::service::NewService特质。生成的项随后必须实现hyper::service::Service特质。在hypercrate 中的服务是一个接收请求并返回响应的函数。我们没有在这个示例中实现这个特质;相反,我们将使用service_fn_ok函数,它将具有合适类型的函数转换为服务处理器。

有两个相应的结构体:hyper::Requesthyper::Response。在前面的代码中,我们忽略了一个请求参数并为每个请求构造了相同的响应。响应包含静态文本的正文。

将服务器实例添加到运行时

由于我们现在已经有了处理器,我们可以启动服务器。运行时期望一个 Future 实例,其类型为 Future<Item = (), Error = ()>,但 Server 结构体实现了一个带有 hyper::Error 错误类型的 Future。我们可以使用这个错误来通知用户问题,但在我们的例子中我们只会丢弃任何错误。如您所记得,drop 函数期望一个任何类型的单个参数,并返回一个 unit 空类型。Future 特性使用 map_err 方法。它通过一个函数改变错误类型,该函数期望原始错误类型并返回一个新的类型。使用以下方式从 server 中丢弃错误:

let server = server.map_err(drop);

现在我们已经拥有了所需的一切,可以使用特定的运行时启动 server。使用 hyper::rt::run 函数启动 server

hyper::rt::run(server);

还不要编译它,因为我们还没有导入类型。将其添加到源文件的开头:

use hyper::{Body, Response, Server};
use hyper::rt::Future;
use hyper::service::service_fn_ok;

我们需要导入我们正在使用的不同 hyper 类型:ServerResponseBody。在最后一行,我们使用 service_fn_ok 函数。Future 导入需要特别注意;它是 futures 包的重新导出特性,并在 hyper 包的每个地方使用。在下一章中,我们将详细检查这个特性。

构建和运行

您现在可以编译代码并使用以下命令启动服务器:

cargo run

使用您的浏览器连接到服务器。在浏览器的地址栏中输入 localhost:8080/,浏览器将连接到您的服务器并显示您在上一段代码中输入的文本:

重建更改

当您在开发网络服务器时,能够即时访问编译和运行的应用程序非常有用。每次更改代码时都必须手动重新启动 cargo run 是一件很麻烦的事情。我建议您在 cargo 上安装并使用 cargo-watch 子命令。这将监视您项目文件中的更改,并重新启动您选择的其它命令。

要安装 cargo-watch,执行以下步骤:

  1. 在控制台中输入以下命令:
cargo install cargo-watch
  1. 使用带有 watchrun 命令:
cargo watch -x "run"

您可以在引号之间添加额外的参数到 run 命令,或者添加额外的参数在 -- 字符之后。

处理传入请求

我们已经创建了一个服务器,但直到它能够响应真实请求之前,它并不是很有用。在本节中,我们将向请求添加处理器,并使用 REST 原则。

添加服务函数

在上一节中,我们基于 service_fn_ok 函数实现了简单的服务,这些函数期望服务函数不抛出任何错误。还有 service_fn 函数,可以用来创建可以返回错误的处理器。这些更适合异步 Future 结果。如我们之前所见,Future 特征有两个关联类型:一个用于成功结果,一个用于错误。service_fn 函数期望结果通过 IntoFuture 特征转换为 future。您可以在下一章中了解更多关于 futures crate 和其类型的信息。

让我们将之前的服务函数改为返回 Future 实例的服务函数:

let server = builder.serve(|| service_fn(microservice_handler));

然后添加此未实现的服务函数:

fn microservice_handler(req: Request<Body>)
    -> impl Future<Item=Response<Body>, Error=Error>
{
    unimplemented!();
}

与之前类似,这个函数期望一个 Request,但它不返回一个简单的 Response 实例。相反,它返回一个 future 结果。由于 Future 是一个特征(它没有大小),我们不能从函数中返回一个无大小实体,而必须将其包装在 Box 中。然而,在这种情况下,我们使用了全新的方法,即 impl 特征。这允许我们通过值返回特征的实现,而不是通过引用。我们的 future 可以解析为 hyper::Response<Body> 项目或 hyper::Error 错误类型。如果您是从头开始的项目并且没有使用本书中包含的代码示例,您应该导入必要的类型:

use futures::{future, Future};
use hyper::{Body, Error, Method, Request, Response, Server, StatusCode};
use hyper::service::service_fn;

我们还从 futures crate 中导入了 Future 特征。请确保您在 Cargo.toml 文件中使用 edition = "2018",或者将 crate 导入到 main.rs 中:

extern crate futures;
extern crate hyper;

我们首先将类型导入到代码中,但仍然需要在 Cargo.toml 文件中导入 crate。在您的 Cargo.toml 文件的依赖列表中添加以下 crate:

[dependencies]
futures = "0.1"
hyper = "0.12"

现在一切准备就绪,可以实现服务处理器。

我更喜欢按泛型到更具体的顺序排列依赖项。或者,您也可以使用字母顺序。

实现服务函数

我们的服务函数将支持两种类型的请求:

  • / 路径的 GET 请求带有索引页面响应

  • 其他带有 NOT_FOUND 响应的请求

要检测对应的方法和路径,我们可以使用 Request 对象的方法。请参见以下代码:

fn microservice_handler(req: Request<Body>)
    -> impl Future<Item=Response<Body>, Error=Error>
{
        match (req.method(), req.uri().path()) {
            (&Method::GET, "/") => {
                future::ok(Response::new(INDEX.into()))
            },
            _ => {
                let response = Response::builder()
                    .status(StatusCode::NOT_FOUND)
                    .body(Body::empty())
                    .unwrap();
                future::ok(response)
            },
        }
}

我使用了一个 match 表达式来检测从 req.method() 函数返回的对应方法,以及 req.uri().path() 方法链调用返回的 URI 路径。

method() 函数返回对 Method 实例的引用。Method 是一个枚举,包含所有支持的 HTTP 方法。与返回字符串方法的其它流行语言不同,Rust 使用来自有限枚举的严格方法集。这有助于在编译期间检测拼写错误。

使用future::ok函数创建的Future实例也会返回。这个函数立即将未来对象解析为对应类型的成功结果。这对于静态值很有用;我们不需要等待它们创建。

未来对象是一个长期操作,不会立即返回结果。运行时会轮询未来对象,直到它返回结果。在数据库上执行异步请求很有用。我们将在第七章,“与数据库的可靠集成”中这样做。

我们也可以返回流而不是整个结果。对于这些情况,futurescrate 包含一个Streamtrait。我们将在第五章,“使用 Futures Crate 理解异步操作”中进一步探讨这一点。

在我们的匹配表达式中,我们使用了Method::GET"/"路径来检测索引页面的请求。在这种情况下,我们将返回一个Response,它构建一个new函数和一个 HTML 字符串作为参数。

如果没有找到匹配_模式的页面,我们将从StateCode枚举返回一个带有NOT_FOUND状态码的响应。这包含了 HTTP 协议的所有状态码。

我们使用body方法来构建响应,并使用一个空的Body作为该函数的参数。为了检查我们之前没有使用它,我们使用unwrap来解包Result中的Response

索引页面

我们最后需要的是一个索引页面。当请求微服务时,返回一些关于微服务的相关信息被认为是好的做法,但出于安全原因,你可能隐藏它。

我们的索引页面是一个包含 HTML 内容的简单字符串:

const INDEX: &'static str = r#"
 <!doctype html>
 <html>
     <head>
         <title>Rust Microservice</title>
     </head>
     <body>
         <h3>Rust Microservice</h3>
     </body>
 </html>
 "#;

这是一个不能修改的常量值。如果你之前没有使用过,请注意字符串的开始r#"。这是一种 Rust 中的多行字符串,必须以"结尾。

现在,你可以编译代码,并用浏览器查看页面。我打开了开发者工具来显示请求的状态码:

图片

如果你尝试获取一个不存在的资源,你会得到一个404状态码,我们使用StatusCode::NOT_FOUND常量设置:

图片

实现 REST 原则

如果每个人都从头开始创建与微服务交互的规则,我们将有过多私有的通信标准。REST 不是一个严格的规则集,但它是一种旨在使与微服务交互简单的架构风格。它提供了一组建议的 HTTP 方法来创建、读取、更新和删除数据;以及执行操作。我们将向我们的服务添加方法,并使它们符合 REST 原则。

添加共享状态

你可能已经听说共享数据是坏事,如果它需要从不同的线程中更改,它可能是瓶颈的潜在原因。然而,如果我们想共享通道的地址或者我们不需要频繁访问它,共享数据是有用的。在本节中,我们需要一个用户数据库。在下面的示例中,我将向你展示如何向我们的生成函数添加共享状态。这种方法可以用于各种原因,例如保持与数据库的连接。

用户数据库显然会存储有关用户的数据。让我们添加一些类型来处理这个问题:

type UserId = u64;
struct UserData;

UserId代表用户的唯一标识符。UserData代表存储的数据,但在本例中我们使用一个空的 struct 进行序列化和解析流。

我们的数据库将如下所示:

type UserDb = Arc<Mutex<Slab<UserData>>>;

Arc是一个原子引用计数器,它为数据的一个实例提供多个引用(在我们的情况下,这是对数据 slab 的Mutex)。原子实体可以安全地在多个线程中使用。它使用原生原子操作来禁止引用的克隆。这是因为两个或多个线程可能会损坏引用计数器,并可能导致段错误,如果计数器大于代码中的引用,则可能导致数据丢失或内存泄漏。

Mutex是一个互斥包装器,用于控制对可变数据的访问。Mutex是一个原子标志,它检查只有一个线程可以访问数据,其他线程必须等待锁定Mutex的线程释放它。

你需要考虑到,如果你在一个线程中有一个锁定的Mutex,并且该线程崩溃,Mutex实例将中毒,如果你尝试从另一个线程锁定它,你会得到一个错误。

你可能想知道为什么我们回顾了这些类型,如果异步服务器可以在单个线程中工作。有两个原因。首先,你可能需要运行服务器在多个线程中进行扩展。其次,所有提供交互设施的类型,如发送对象(来自标准库、futures crate 或任何其他地方)或数据库连接,通常都会用这些类型包装,以使它们与多线程环境兼容。了解底层发生的事情可能是有用的。

你可能熟悉标准库中的类型,但Slab可能看起来有些不同。这种类型可以被视为网络服务器开发中的银弹。大多数池都使用这种设备。Slab 是一个分配器,可以存储和删除由有序数字标识的任何值。它还可以重用已删除项的槽位。它与Vec类型类似,如果你删除元素,它不会调整大小,但会自动重用空闲空间。对于服务器来说,保留连接或请求很有用,例如在 JSON-RPC 协议实现中。

在这种情况下,我们使用Slab为用户分配新的 ID,并保留与用户相关的数据。我们使用ArcMutex对来保护我们的数据库,以防止数据竞争,因为不同的响应可以在不同的线程中处理,这两个线程都可能尝试访问数据库。实际上,Rust 不会让你在没有这些包装器的情况下编译代码。

我们必须添加一个额外的依赖项,因为Slab类型在外部slabcrate 中可用。使用Cargo.toml添加此依赖项:

[dependencies]
slab = "0.4"
futures = "0.1"
hyper = "0.12"

main.rs文件中导入这些必要的类型:

use std::fmt;
use std::sync::{Arc, Mutex};
use slab::Slab;
use futures::{future, Future};
use hyper::{Body, Error, Method, Request, Response, Server, StatusCode};
use hyper::service::service_fn;

让我们在下一节中编写一个处理函数和一个main函数。

从服务函数访问共享状态

要访问共享状态,你需要提供共享数据的引用。这很简单,因为我们已经用Arc包装了我们的状态,它为我们提供了一个clone()函数来复制共享对象的引用。

由于我们的服务函数需要额外的参数,我们必须重新编写定义并调用我们的microservice_handler函数。现在它有一个额外的参数,即共享状态的引用:

fn microservice_handler(req: Request<Body>, user_db: &UserDb)
    -> impl Future<Item=Response<Body>, Error=Error>

我们还必须将此预期的引用发送到main函数:

fn main() {
     let addr = ([127, 0, 0, 1], 8080).into();
     let builder = Server::bind(&addr);
     let user_db = Arc::new(Mutex::new(Slab::new()));
     let server = builder.serve(move || {
         let user_db = user_db.clone();
         service_fn(move |req| microservice_handler(req, &user_db))
     });
     let server = server.map_err(drop);
     hyper::rt::run(server);
 }

如您所见,我们创建了一个Slab,并用MutexArc将其包装。之后,我们将对象user_db移动到使用move关键字的server构建器的serve函数调用中。当引用移动到闭包中时,我们可以将其发送到microservice_handler。这是一个由发送到service_fn调用的闭包调用的处理函数。我们必须克隆引用以将其移动到嵌套闭包中,因为该闭包可能被多次调用。然而,我们不应该完全移动对象,因为发送到serve函数的闭包可能被多次调用,因此运行时可能稍后还需要该对象。

换句话说,这两个闭包都可以被多次调用。service_fn的闭包将在与运行时相同的线程中被调用,我们可以使用其中的值引用。

在微服务中解析路径

在 Web 开发中,一个常见的任务是使用与持久存储一起工作的函数。这些函数通常被称为创建读取更新删除CRUD)函数。它们是与数据最常见操作。

我们可以为我们的服务实现一个 CRUD 集,但首先我们必须确定我们想要与之工作的实体。想象一下,我们需要三种类型的实体:用户、文章和评论。在这种情况下,我建议你分离微服务,因为用户微服务负责身份,文章微服务负责内容,评论微服务处理内容。然而,如果你能将这些实体用于多个上下文,你会得到更多的好处。

在我们实现所有处理函数之前,我们需要一个辅助函数,用于创建带有相应 HTTP 状态码的空响应:

fn response_with_code(status_code: StatusCode) -> Response<Body> {
    Response::builder()
        .status(status_code)
        .body(Body::empty())
        .unwrap()
}

此函数执行几个简单的操作——它期望一个状态码,创建一个新的响应构建器,设置该状态,并添加一个空体。

我们现在可以添加一个新的请求处理程序,该处理程序检查三个路径变体:

  • 索引页面(路径/

  • 与用户数据相关的操作(前缀/user/

  • 其他路径

我们可以使用match表达式来满足所有这些情况。将以下代码添加到microservices_handler函数中:

    let response = {
        match (req.method(), req.uri().path()) {
            (&Method::GET, "/") => {
                Response::new(INDEX.into())
            },
            (method, path) if path.starts_with(USER_PATH) => {
                unimplemented!();
            },
            _ => {
                response_with_code(StatusCode::NOT_FOUND)
            },
        }
    };
    future::ok(response)

正如你所见,我们在第二个分支中使用了if表达式来检测路径是否以/user/前缀开头。实际上,这个前缀存储在USER_PATH常量中:

const USER_PATH: &str = "/user/";

与前面的例子不同,在这种情况下,我们将使用我们全新的response_with_code函数来返回NOT_FOUND HTTP 响应。我们还分配了一个响应给response变量,并使用它来创建一个Future实例,使用future::ok函数。

实现 REST 方法

我们的服务微件已经可以区分不同的路径。剩下要做的只是实现用户数据的请求处理。所有传入的请求都必须在其路径中包含/user/前缀。

提取用户的标识符

要修改特定用户,我们需要他们的标识符。REST 规定您需要从路径中获取 ID,因为 REST 将数据实体映射到 URL。

我们可以使用路径的尾部来提取用户的标识符,这是我们已经拥有的。这就是为什么我们使用字符串的starts_with方法,而不是检查与USER_PATH路径尾部的强等价性。

我们之前声明了UserId类型,它等于u64无符号数。将以下代码添加到之前声明的match表达式的第二个分支中,使用(method, path)模式从路径中提取用户的标识符:

let user_id = path.trim_left_matches(USER_PATH)
        .parse::<UserId>()
        .ok()
        .map(|x| x as usize);

str::trim_left_matches方法会移除与提供的字符串匹配的字符串部分。之后,我们使用str::parse方法,该方法尝试将字符串(剩余的尾部)转换为实现了标准库中FromStr特性的类型。UserId已经实现了这一点,因为它等于u64类型,可以从字符串中解析出来。

解析方法返回Result。我们使用Result::ok函数将其转换为Option实例。我们不会尝试处理 ID 的错误。None值表示值的缺失或错误值。

我们还可以使用返回的Option实例的映射来将值转换为usize类型。这是因为Slab使用usize作为 ID,但usize类型的实际大小取决于平台架构,这可能是不同的。它可以是u32u64,这取决于你可以使用的最大内存地址。

为什么我们不能为UserId使用usize,因为它实现了FromStr特性?这是因为客户端期望与 HTTP 服务器相同的行为,而 HTTP 服务器不依赖于架构平台。在 HTTP 请求中使用不可预测的大小参数是不良的做法。

有时,选择一个类型来标识数据可能会有困难。我们使用 mapu64 值转换为 usize。然而,在 usize 等于 u32 的架构中,这不起作用,因为 UserId 可能大于内存限制。在微服务很小的案例中这是安全的,但对于你将在生产中使用的微服务来说,这是一个需要牢记的重要点。通常,这个问题很容易解决,因为你可以使用数据库的 ID 类型。

获取对共享数据的访问权限

在这个用户处理器中,我们需要访问包含用户的数据库。因为数据库是一个被 Mutex 实例包装的 Slab 实例,我们必须锁定互斥锁以获得对片段的独占访问。有一个 Mutex::lock 函数,它返回 Result<MutexGuard, PoisonError<MutexGuard>>MutexGuard 是一个作用域锁,这意味着它会在代码块或作用域内保持,并且它实现了 DerefDerefMut 特性,以提供对受保护对象下数据的透明访问。

在处理器中报告所有错误是一个好习惯。你可以记录错误并返回一个 500(内部错误)HTTP 状态码给客户端。为了保持简单,我们将使用 unwrap 方法并期望互斥锁能够正确锁定:

let mut users = user_db.lock().unwrap();

在这里,我们在生成请求的过程中锁定了 Mutex。在这种情况下,我们立即创建整个响应,这是正常的。在结果延迟或我们处理流的情况下,我们不应该一直锁定互斥锁。因为这将为所有请求创建瓶颈,因为如果所有请求都依赖于单个共享对象,那么 服务器 就不能并行处理请求。对于没有立即得到结果的情况,你可以克隆互斥锁的引用,并在需要访问数据时短暂地锁定它。

REST 方法

我们希望涵盖所有基本的 CRUD 操作。使用 REST 原则,有适合这些操作的合适的 HTTP 方法——POSTGETPUTDELETE。我们可以使用 match 表达式来检测相应的 HTTP 方法:

match (method, user_id) {
    // Put other branches here
    _ => {
        response_with_code(StatusCode::METHOD_NOT_ALLOWED)
    },
}

在这里,我们使用了一个包含两个值的元组——一个方法和一个用户标识符,它由 Option<UserId> 类型表示。如果客户端请求一个不支持的方法,有一个默认分支返回 METHOD_NOT_ALLOWED 消息(405 HTTP 状态码)。

让我们讨论每个操作的匹配表达式的每个分支。

POST – 创建数据

服务器 刚启动时,它不包含任何数据。为了支持数据创建,我们使用不带用户 ID 的 POST 方法。将以下分支添加到 match (method, user_id) 表达式中:

(&Method::POST, None) => {
    let id = users.insert(UserData);
    Response::new(id.to_string().into())
}

这段代码向用户数据库添加一个 UserData 实例,并在带有 OK 状态(HTTP 状态码 200)的响应中发送关联的用户 ID。默认情况下,这段代码是由 Response::new 函数设置的。

在这种情况下,UserData是一个空的 struct。然而,在实际应用中,它必须包含真实数据。我们使用一个空的 struct 来避免序列化,但你可以在第四章中了解更多关于基于serdecrate 的序列化和反序列化信息,使用 Serde Crate 进行数据序列化和反序列化

如果客户端使用POST请求设置 ID,你可以有两种解释方式——忽略它或尝试使用提供的 ID。在我们的例子中,我们将通知客户端请求是错误的。添加以下分支来处理这种情况:

(&Method::POST, Some(_)) => {
    response_with_code(StatusCode::BAD_REQUEST)
}

此代码返回一个带有BAD_REQUEST状态码(400 HTTP 状态码)的响应。

GET – 读取数据

当数据被创建时,我们需要能够读取它。在这种情况下,我们可以使用 HTTP 的GET方法。将以下分支添加到代码中:

(&Method::GET, Some(id)) => {
     if let Some(data) = users.get(id) {
         Response::new(data.to_string().into())
     } else {
         response_with_code(StatusCode::NOT_FOUND)
     }
 }

此代码使用用户数据库尝试通过路径中提供的 ID 查找用户。如果找到用户,我们将将其数据转换为String并转换为Body以发送响应。

如果找不到用户,处理分支将以NOT_FOUND状态码(经典的404错误)响应。

要使UserData可转换为String,我们必须为该类型实现ToStringtrait。然而,通常实现Displaytrait 更有用,因为对于每个实现了Displaytrait 的类型,ToString将被自动推导。将以下代码添加到main.rs源文件中的某个位置:

impl fmt::Display for UserData {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        f.write_str("{}")
    }
}

在此实现中,我们返回一个包含空 JSON 对象"{}"的字符串。真正的微服务必须使用serdetrait 进行此类转换。

PUT – 更新数据

一旦数据被保存,我们可能希望提供修改它的能力。这是PUT方法的任务。使用此方法来处理数据的更改:

(&Method::PUT, Some(id)) => {
    if let Some(user) = users.get_mut(id) {
        *user = UserData;
        response_with_code(StatusCode::OK)
    } else {
        response_with_code(StatusCode::NOT_FOUND)
    }
},

此代码尝试使用get_mut方法在用户数据库中查找user实例。这返回一个包含Some选项的可变引用,或者如果找不到相应的值,则返回None选项。我们可以使用解引用运算符*来替换存储中的数据。

如果找到并替换了用户数据,分支将返回OK状态码。如果没有找到请求的 ID 的用户,分支将返回NOT_FOUND

DELETE – 删除数据

当我们不再需要数据时,我们可以将其删除。这就是DELETE方法的目的。在分支中使用它如下:

(&Method::DELETE, Some(id)) => {
    if users.contains(id) {
        users.remove(id);
        response_with_code(StatusCode::OK)
    } else {
        response_with_code(StatusCode::NOT_FOUND)
    }
},

此代码检查Slab是否包含数据,并使用remove方法将其删除。我们不立即使用remove方法,因为它期望数据事先存在于存储中,如果数据不存在,则会引发 panic。

通常,网络服务并没有真正删除数据,而是只是将其标记为已删除。这样做是合理的,因为它允许你稍后探索数据并提高服务或公司的效率。然而,这是一种风险行为。用户应该能够完全删除他们的数据,因为敏感数据可能构成威胁。新的法律,如 GDPR 法律(en.wikipedia.org/wiki/General_Data_Protection_Regulation),保护用户对其数据的所有权,并规定数据保护的一些要求。违反这些法律可能会导致罚款。当你处理敏感数据时,记住这一点很重要。

路由高级请求

在前面的示例中,我们使用了模式匹配来检测请求的目的地。这不是一种灵活的技术,因为路径通常包含必须考虑的额外字符。例如,/user/1/路径包含尾随斜杠,/,在微服务的上一个版本中无法与用户 ID 一起解析。有一个灵活的工具可以解决这个问题:正则表达式。

使用正则表达式定义路径

正则表达式是一系列字符,用于表达在字符串中要搜索的图案。正则表达式提供给你创建小解析器的功能,这些解析器使用正式声明将文本分割成部分。Rust 有一个名为regex的 crate,这是正则表达式组合的流行缩写。你可以在这里了解更多关于这个 crate 的信息:crates.io/crates/regex

添加必要的依赖项

要在我们的server中使用正则表达式,我们需要两个 crate:regexlazy_static。第一个提供了Regex类型,用于创建和匹配字符串中的正则表达式。第二个帮助在静态上下文中存储Regex实例。我们可以将常量值赋给静态变量,因为它们在程序加载到内存时创建。为了使用复杂表达式,我们必须添加初始化代码并使用它来执行表达式,将结果赋给静态变量。lazy_static crate 包含一个lazy_static!宏来自动完成这项工作。这个宏创建一个静态变量,执行一个表达式,并将评估后的值赋给该变量。我们还可以使用局部变量而不是静态变量,在局部上下文中为每个请求创建一个正则表达式对象。然而,这会占用运行时开销,因此最好提前创建并重用它。

将这两个依赖项添加到Cargo.toml文件中:

[dependencies]
slab = "0.4"
futures = "0.1"
hyper = "0.12"
lazy_static = "1.0"
regex = "1.0"

除了上一个示例中的main.rs源文件中的导入之外,还需要添加两个导入:

use lazy_static::lazy_static;
use regex::Regex;

我们将使用lazy_static宏和Regex类型来构建一个正则表达式。

编写正则表达式

正则表达式包含一种特殊语言,用于编写模式以从字符串中提取数据。我们需要为我们的示例编写三个模式:

  • 索引页面路径

  • 用户管理路径

  • 用户列表路径(我们示例服务器的全新功能)

有一个 Regex::new 函数可以创建正则表达式。删除之前的 USER_PATH 常量,并在懒静态块中添加三个新的正则表达式常量:

lazy_static! {
    static ref INDEX_PATH: Regex = Regex::new("^/(index\\.html?)?$").unwrap();
    static ref USER_PATH: Regex = Regex::new("^/user/((?P<user_id>\\d+?)/?)?$").unwrap();
    static ref USERS_PATH: Regex = Regex::new("^/users/?$").unwrap();
}

如你所见,正则表达式看起来很复杂。为了更好地理解它们,让我们分析它们。

索引页面路径

INDEX_PATH 表达式匹配以下路径:

  • /

  • /index.htm

  • /index.html

匹配这些路径的表达式是 "^/(index\\.html?)?$"

^ 符号表示必须有字符串的开始,而 $ 符号表示必须有字符串的结束。当我们将这些符号放在两边时,我们防止路径中的所有前缀和后缀,并期望精确匹配。

( ) 括号表示必须有组。组中的表达式被视为一个不可分割的单位。

? 符号表示前面的字符是可选的。我们将它放在 l 字符后面,以允许路径中的文件具有 .htm.html 扩展名。正如你稍后看到的,我们没有索引文件可以读取。我们将其用作根路径处理器的别名。问号也用于整个组(带有文件名)之后,以适应空根路径,/

点符号 (.) 匹配任何字符,但我们需要一个真正的点符号。为了将点作为符号处理,我们必须在它前面添加一个反斜杠 (\)。然而,单个反斜杠将被解释为开始转义表达式,因此我们必须使用一对反斜杠 (\\) 来使反斜杠成为一个普通符号。

所有其他字符都按原样处理,包括 / 符号。

用户管理路径

USER_PATH 表达式可以匹配以下路径:

  • /user/

  • /user/<id>,其中 <id> 表示数字组

  • /user/<id>/,与上一个相同,但有一个尾随反斜杠

这些情况可以用 "^/user/((?P<user_id>\\d+?)/?)?$" 正则表达式来处理。这个表达式有点复杂。它包括两个组(一个是嵌套的)和一些其他奇怪的字符。让我们更仔细地看看。

?P<name> 是一个分组属性,用于设置捕获组的名称。每个括号中的组都可以通过 regex::Captures 对象访问。命名组可以通过名称访问。

\\d 是一个特殊表达式,用于匹配任何数字。为了指定我们有一个或多个数字,我们应该添加 + 符号,它告诉我们可能有多少重复。* 符号也可以添加,它告诉我们可以有零个或多个重复,但我们还没有在我们的正则表达式中使用它。

有两个组。第一个是嵌套的,名称为user_id。它必须只包含数字,才能解析为UserId类型。第二个是一个包含可选尾随斜杠的包围组。整个组是可选的,这意味着表达式可以包含没有标识符的/user/路径。

用户列表的路径

USERS_PATH是一个新的模式,我们在前面的例子中没有。我们将使用它来在server上返回用户的全列表。此模式仅适合路径的两个变体:

  • /users/ (带有尾随斜杠)

  • /users (不带尾随斜杠)

处理这些情况的正则表达式相当简单:"^/users/?$"。我们已经看到了这个模式中的所有符号。它期望一个以^符号和斜杠符号开始的字符串。之后,它期望以users结尾,尾部可以有可选的斜杠/?。最后,它期望字符串的结尾有$符号。

匹配表达式

我们必须重新组织microservice_handler的代码,因为我们不能在match表达式中使用正则表达式。我们必须提取以路径开头的的方法,因为我们需要它在大多数响应中:

let response = {
    let method = req.method();
    let path = req.uri().path();
    let mut users = user_db.lock().unwrap();

    // Put regular expressions here
};
futures::ok()

我们首先检查的是索引页面的请求。添加以下代码:

if INDEX_PATH.is_match(path) {
    if method == &Method::GET {
        Response::new(INDEX.into())
    } else {
        response_with_code(StatusCode::METHOD_NOT_ALLOWED)
    }

这使用INDEX_PATH正则表达式来检查请求的路径是否与索引页面请求匹配,使用Regex::is_match方法,该方法返回一个bool值。在这里,我们正在检查请求的方法,所以只允许GET

然后,我们将继续if子句,为用户列表请求提供一个替代条件:

} else if USERS_PATH.is_match(path) {
    if method == &Method::GET {
        let list = users.iter()
            .map(|(id, _)| id.to_string())
            .collect::<Vec<String>>()
            .join(",");
        Response::new(list.into())
    } else {
        response_with_code(StatusCode::METHOD_NOT_ALLOWED)
    }

此代码使用USERS_PATH模式来检查客户端是否请求了用户列表。这是一个新的路径路由。之后,我们遍历数据库中的所有用户,并将它们的 ID 连接成一个字符串。

以下代码用于处理 REST 请求:

} else if let Some(cap) = USER_PATH.captures(path) {
    let user_id = cap.name("user_id").and_then(|m| {
        m.as_str()
            .parse::<UserId>()
            .ok()
            .map(|x| x as usize)
    });
    // Put match expression with (method, user_id) tuple

此代码使用USER_PATHRegex::captures方法。它返回一个包含所有捕获组值的Captures对象。如果模式不匹配方法,它返回一个None值。如果模式匹配,我们得到存储在cap变量中的对象。Captures结构体有name方法,可以通过名称获取捕获值。我们使用user_id作为组的名称。此组可以是可选的,name方法返回一个Option。我们使用and_then方法将Option替换为解析后的UserId。最后,user_id变量采用Option<UserId>值,与我们的微服务的前一个版本相同。为了避免重复,我跳过了请求与(methoduser_id)元组相同的块——只需从本章前一部分的例子中复制这部分即可。

最后的部分是一个默认处理程序,它返回一个带有NOT_FOUND状态码的响应:

} else {
    response_with_code(StatusCode::NOT_FOUND)
}

服务现在已经完整,因此可以编译并运行。在第十三章《测试和调试 Rust 微服务》中,您将了解到如何调试微服务。然而,目前您可以使用curl命令发送一些POST请求,并在浏览器中检查结果。在 shell 中输入以下命令以添加三个用户并删除 ID 为**1**的第二个用户:

$ curl -X POST http://localhost:8080/user/
0
$ curl -X POST http://localhost:8080/user/
1
$ curl -X POST http://localhost:8080/user/
2
$ curl -X DELETE http://localhost:8080/user/1
$ curl http://localhost:8080/users
0,2

如果你从浏览器中获取用户列表,它应该显示以下内容:

图片

如您所见,我们使用curl时没有使用尾随斜杠的/users请求,而在浏览器中使用带有尾随斜杠的/users/。这个结果意味着正则表达式和请求路由都正常工作。

摘要

在本章中,我们使用hyper crate 创建了一个微服务。我们从一个仅响应*Rust Microservice*消息的最小示例开始。然后,我们创建了一个具有两个不同路径的微服务——第一个是索引页面请求,第二个是NOT_FOUND响应。

在学习了基础知识之后,我们开始使用match表达式使微服务符合 REST 规范。我们还添加了处理用户数据的四种基本操作——创建、读取、更新和删除。

为了扩展本章最后示例中的路由功能,我们实现了基于正则表达式的路由。正则表达式是紧凑的模式,用于检查和从文本中提取数据。

在本章中,我们遇到了各种 crate——hyperfuturesslabregexlazy_static。我们将在下一章详细讨论这些内容。

由于我们在下一章学习了如何创建最小化的 HTTP 微服务,我们将学习如何使其可配置,以及如何将其与日志关联,因为微服务在远程服务器上运行,我们需要一个无需重新编译即可配置它的能力,并且能够查看所有在微服务中发生的日志问题。

第三章:日志和配置微服务

微服务在现实世界中工作,这是一个动态的环境。为了有用,它们必须可配置,以便你可以更改地址或端口以绑定服务器的套接字。通常,你需要设置令牌、密钥和其他微服务的地址。即使你已经正确配置了它们,你的微服务也可能失败。在这种情况下,你需要能够使用服务器的日志。

在本章中,我们将学习以下技能:

  • 如何使用log crate 进行日志记录

  • 如何使用clap crate 读取命令行参数

  • 如何使用dotenv crate 读取环境变量

  • 如何声明和使用配置文件

技术要求

本章解释了如何将日志添加到服务中,并解析配置微服务所需的命令行参数或环境变量。除了 Rust 编译器(版本 1.31 或更高版本)之外,你不需要任何特殊的软件。使用 rustup 工具进行安装。

你可以在 GitHub 上找到本章示例的代码:github.com/PacktPublishing/Hands-On-Microservices-with-Rust-2018/tree/master/Chapter3

将日志添加到微服务中

如果微服务没有记录它执行的操作,我们就无法使用或调试微服务。在本节中,我们将开始使用我们的微服务进行日志记录,以了解它们内部发生了什么。我们将创建一个生成随机值的微服务,并将其附加到微服务上以记录其执行的操作。之后,我们将使用环境变量配置日志。

随机值生成微服务

要讨论这些更高级的话题,我们需要一个比生成hello消息更有用目的的微服务架构。我们将创建一个用于生成随机值的微服务应用程序。这足够简单,足以为我们提供足够的机会来使用日志和配置。

然而,我们不会完全从头开始;让我们以前一章的例子为基础,并添加一个依赖项:

[dependencies]
hyper = "0.12"
rand = "0.5"

rand crate 提供了在 Rust 中生成随机值所需的实用工具。在main.rs文件中导入必要的类型:

use hyper::{Body, Response, Server};
use hyper::rt::Future;
use hyper::service::service_fn_ok;

service_fn_ok函数中添加两行以处理传入的请求:

fn main() {
    let addr = ([127, 0, 0, 1], 8080).into();
    let builder = Server::bind(&addr);
    let server = builder.serve(|| {
        service_fn_ok(|_| {
            let random_byte = rand::random::<u8>();
            Response::new(Body::from(random_byte.to_string()))
        })
    });
    let server = server.map_err(drop);
    hyper::rt::run(server);
}

要了解更多关于前面代码的信息,请参阅前一章,其中我们探讨了hyper crate。

如你所见,我们在提供给service_fn_ok函数的闭包中添加了两行。第一行使用rand crate 的random函数生成随机字节。我们在rand::random::<u8>()调用中将生成的类型设置为类型参数。现在,u8是一个无符号字节整数。

在第二行,我们简单地将生成的字节转换为字符串,并将其作为ResponseBody返回。尝试运行代码以测试它:

图片

从前面的屏幕截图可以看出,该服务成功返回了生成的随机值。

日志 crate

日志记录是记录程序活动的过程。日志可以是特定格式的文本流,它打印到控制台或写入文件。Rust 有一个基于log crate 的优秀的日志生态系统。值得注意的是,log crate 包含宏但没有实际的日志实现。这给了你根据需要使用不同日志记录器的机会。在本节中,我们将开始在微服务中使用 log crate,以了解日志级别的工作原理以及如何设置您想要看到的日志级别。

日志记录器

实际上包含在某些 crate 中的日志实现如下:

  • env_logger

  • simple_logger

  • simplelog

  • pretty_env_logger

  • stderrlog

  • flexi_logger

  • log4rs

  • fern

在这些日志实现之间进行选择可能很困难。我建议您在crates.io上探索它们,以了解它们之间的差异。最受欢迎的是env_logger,这是我们将要使用的一个。env_logger读取RUST_LOG环境变量来配置日志并将日志打印到stderr。还有一个基于env_loggerpretty_env_logger crate,它以紧凑和彩色格式打印日志。两者都使用相同的环境变量进行配置。

stderr是三个标准流之一——stdin其中您的程序通过控制台读取输入数据; stdout程序发送输出数据;和 stderr具有显示错误或其他与应用程序相关信息的特殊用途。日志记录器通常使用stderr以避免影响输出数据。例如,假设您有一个解码输入流的工具。您希望工具只将解码后的数据发送到输出流。程序将如何通知您它遇到的问题?在这种情况下,我们可以使用stderr流,它作为一个输出流工作,但不会污染stdout?有一个stderr流,它作为一个输出流工作,但不会污染stdout

将日志记录器添加到您的Cargo.toml文件的依赖项列表中:

[dependencies]
log = "0.4"
pretty_env_logger = "0.2"
hyper = "0.12"
rand = "0.5"

然后将这些类型添加到您的main.rs文件中:

use hyper::{Body, Response, Server};
use hyper::rt::Future;
use hyper::service::service_fn_ok;
use log::{debug, info, trace};

日志级别

如我们之前讨论的,使用log crate,我们需要导入以下日志宏。我们可以使用以下:

  • trace!

  • debug!

  • info!

  • warn!

  • error!

这些按它们打印的信息的重要性排序,其中trace!是最不重要的,而error!是最重要的:

  • trace!: 用于打印关于任何关键活动的详细信息。它允许 Web 服务器跟踪任何传入的数据块。

  • debug!: 用于较少冗余的消息,例如传入的服务器请求。它对调试很有用。

  • info!: 用于重要信息,如运行时或服务器配置。在库 crate 中很少使用。

  • warn!:通知用户关于非关键错误,例如如果客户端使用了损坏的 cookie,或者必要的微服务暂时不可用,并且使用缓存数据作为响应。

  • error!:提供关于关键错误的警报。这用于数据库连接中断的情况。

我们直接从logcrate 中导入了必要的宏。

记录日志

没有代码的上下文数据,日志记录是没有用的。每个日志宏都期望一个可以包含位置参数的文本消息。例如,看看println!宏:

debug!("Trying to bind server to address: {}", addr);

上述代码将适用于实现了Display特质的类型。就像在println!宏中一样,你可以添加实现了Debug特质的类型,并使用{:?}格式化器。对于你的代码中的所有类型使用#[derive(Debug)]并设置整个 crate 的#![deny(missing_debug_implementations)]属性是有用的。

消息的自定义级别

级别在日志记录过程中起着重要的作用。它们用于根据优先级过滤记录。如果你为logger设置了info级别,它将跳过所有debugtrace记录。显然,在调试目的时需要更详细的日志记录,而在生产中使用服务器时则需要更简略的日志记录。

内部,logcrate 的每个宏都使用log!宏,该宏有一个参数用于设置级别:

log!(Level::Error, "Error information: {}", error);

它需要一个Level枚举的实例,该枚举有以下变体——TraceDebugInfoWarnError

检查日志是否启用

有时,日志记录可能需要很多资源。在这种情况下,你可以使用log_enabled!宏来检查是否已启用某个日志级别:

if log_enabled!(Debug) {
    let data = get_data_which_requires_resources();
    debug!("expensive data: {}", data);
}

自定义目标

每个日志记录都有一个目标。一个典型的日志记录看起来如下:

图片

日志记录包含日志级别、时间(在此输出中未显示)、目标和消息。你可以将目标视为一个命名空间。如果没有指定目标,logcrate 将使用module_path!宏来设置一个。我们可以使用目标来检测错误或警告发生的位置,或者用它来按名称过滤记录。我们将在下一节中看到如何通过环境变量设置过滤。

使用日志记录

我们现在可以为我们的微服务添加日志记录。在以下示例中,我们将打印关于套接字地址、传入请求和生成的随机值的信息:

fn main() {
     logger::init();
     info!("Rand Microservice - v0.1.0");
     trace!("Starting...");
     let addr = ([127, 0, 0, 1], 8080).into();
     debug!("Trying to bind server to address: {}", addr);
     let builder = Server::bind(&addr);
     trace!("Creating service handler...");
     let server = builder.serve(|| {
         service_fn_ok(|req| {
             trace!("Incoming request is: {:?}", req);
             let random_byte = rand::random::<u8>();
             debug!("Generated value is: {}", random_byte);
             Response::new(Body::from(random_byte.to_string()))
         })
     });
     info!("Used address: {}", server.local_addr());
     let server = server.map_err(drop);
     debug!("Run!");
     hyper::rt::run(server);
 }

使用日志记录相当简单。我们可以使用宏来打印套接字的地址以及请求和响应的信息。

使用变量配置记录器

有一些环境变量可以用来配置记录器。让我们看看每个变量。

RUST_LOG

编译此示例。要使用激活的日志记录器运行它,您必须设置RUST_LOG环境变量。env_logger包读取它,并使用此变量的过滤器配置日志记录器。必须使用相应的日志级别配置logger实例。

您可以全局设置RUST_LOG变量。如果您使用 Bash shell,您可以在.bashrc文件中设置它。

您可以在cargo run命令之前临时设置RUST_LOG

RUST_LOG=trace cargo run

然而,这也会打印大量的cargo工具和编译器记录,因为 Rust 编译器也使用log包进行日志记录。您可以通过名称过滤排除所有记录,除了您程序中的记录。您只需要使用目标名称的一部分,如下所示:

 RUST_LOG=random_service=trace,warn cargo run

RUST_LOG变量的值通过warn级别过滤所有记录,并为以random_service前缀开始的目标使用trace级别。

RUST_LOG_STYLE

RUST_LOG_STYLE变量设置打印记录的样式。它有三个变体:

  • auto:尝试使用样式字符

  • always:始终使用样式字符

  • never:关闭样式字符

以下是一个示例:

RUST_LOG_STYLE=auto cargo run

我建议您使用never值,如果您将stderr输出重定向到文件,或者您想使用grepawk提取具有特殊模式的值。

RUST_LOG变量更改为您自己的

如果您发布自己的产品,您可能需要更改RUST_LOGRUST_LOG_STYLE变量的名称。env_logger的新版本包含一个用于修复此问题的init_from_env特殊函数。它期望一个参数——一个Env对象的实例。请看以下代码:

let env = env_logger::Env::new()
    .filter("OWN_LOG_VAR")
    .write_style("OWN_LOG_STYLE_VAR");
env_logger::init_from_env(env);

它创建一个Env实例,并将OWN_LOG_VAR变量设置为配置日志,将OWN_LOG_STYLE_VAR变量设置为控制日志的样式。当创建env对象时,我们将将其用作env_logger包的init_from_env函数调用的参数。

读取环境变量

在上一个示例中,我们使用RUST_LOG环境变量的值来设置日志记录的过滤参数。我们还可以使用其他环境变量来设置服务器的参数。在以下示例中,我们将使用ADDRESS环境变量来设置我们想要绑定的套接字地址。

标准库

std::env标准模块中提供了足够多的函数来处理环境变量。它包含var函数来读取外部值。如果变量存在,此函数返回一个包含变量String值的Result,如果不存在,则返回一个VarError错误。将env模块的导入添加到您的main.rs文件中:

use std::env;

我们需要替换以下行:

let addr = ([127, 0, 0, 1], 8080).into();

替换为以下内容:

let addr = env::var("ADDRESS")
    .unwrap_or_else(|_| "127.0.0.1:8080".into())
    .parse()
    .expect("can't parse ADDRESS variable");

新代码读取ADDRESS值。如果此值不存在,我们不会让代码抛出 panic。相反,我们将使用unwrap_or_else方法调用将其替换为默认值"127.0.0.1:8080"。由于var函数返回一个String,我们还需要使用into方法调用将&'static str转换为String实例。

如果我们无法解析地址,我们将在except方法调用中抛出 panic。

您的服务器现在将使用addr变量,该变量从ADDRESS环境变量或默认值中获取值。

环境变量是配置应用程序的一种简单方式。它们也广泛得到托管或云平台和 Docker 容器的支持。

记住,所有敏感数据都对主机系统管理员可见。在 Linux 中,系统管理员可以通过使用cat /proc/pidof random-service-with-env/environ | tr '\0' '\n''`命令来读取这些数据。这意味着将比特币钱包的密钥设置为环境变量不是一个好主意。

使用.env文件

设置许多环境变量很耗时。我们可以通过使用配置文件来简化这一点,我们将在本章末尾进一步探讨。然而,在包或依赖项使用环境变量的情况下,不能使用配置文件。

为了使这个过程简单,我们可以使用dotenv包。这个包用于从文件中设置环境变量。这种做法作为十二要素应用方法的一部分出现(12factor.net/*)。

十二要素应用方法是一种构建软件即服务SaaS)应用程序的方法,旨在实现以下三个目标:

  • 声明式格式配置

  • 与操作系统和云的最大可移植性

  • 持续部署和扩展

这种方法鼓励你使用环境变量来配置应用程序。十二要素应用方法不需要配置磁盘空间,并且具有极高的可移植性,这意味着所有操作系统都支持环境变量。

使用dotenv

dotenv包允许你在名为.env的文件中设置环境变量,并将它们与以传统方式设置的环境变量连接起来。你不需要手动读取此文件。你只需要添加依赖项并调用包的初始化方法。

将此包添加到dependencies列表中:

dotenv = "0.13"

将以下导入添加到上一个示例的main.rs文件中,以使用dotenv包:

use dotenv::dotenv;
use std::env;

使用dotenv函数初始化它,该函数将尝试找到.env文件。它将返回一个包含此文件路径的Result。如果文件未找到,调用Resultok方法来忽略它。

.env文件添加变量

.env文件包含环境变量的名称和值对。对于我们的服务,我们将设置RUST_LOGRUST_BACKTRACEADDRESS变量:

RUST_LOG=debug
RUST_BACKTRACE=1
ADDRESS=0.0.0.0:1234

如你所见,我们将logger的所有目标都设置为debug级别,因为cargo不使用dotenv,因此跳过了这些设置。

RUST_BACKTRACE变量设置标志,在应用程序发生 panic 时打印应用程序的回溯。

将此文件存储在运行应用程序的工作目录中。你可以有多个文件,并使用它们进行不同的配置。此文件格式也与 Docker 兼容,可以用于设置容器的变量。

我建议你将.env文件添加到.gitignore中,以防止敏感或本地数据的泄露。这意味着每个与你的项目一起工作的用户或开发者都有自己的环境和需要他们自己的.env文件版本。

解析命令行参数

环境变量对于与容器一起使用很有用。如果你从控制台使用应用程序或想避免与其他变量名称冲突,你可以使用命令行参数。这是开发者设置程序参数的更传统方式。

你也可以使用env模块获取命令行参数。它包含args函数,该函数返回一个Args对象。此对象不是一个数组或向量,但它可迭代,你可以使用for循环处理所有命令行参数:

for arg in env::args() {
    // Interpret the arg here
}

在简单情况下,这个变体可能很有用。然而,对于具有复杂规则的参数解析,你必须使用命令行参数解析器。clapcrate 中包含了一个很好的实现。

使用clapcrate

要使用clapcrate 解析参数,你必须构建一个解析器并使用它来处理参数。要构建解析器,你首先创建一个App类型的实例。要使用它,添加所有必要的导入。

添加依赖项

Cargo.toml中添加依赖项:

clap = "2.32"

此 crate 提供了一些有用的宏,用于添加关于程序元信息。具体如下:

  • crate_name!:返回 crate 的名称

  • crate_version!:返回 crate 的版本

  • crate_authors!:返回作者列表

  • crate_description!:提供 crate 的描述

这些宏的所有信息都来自Cargo.toml文件。

导入必要的类型。我们需要两个类型,即AppArg,以及之前提到的宏:

use clap::{crate_authors, crate_description, crate_name, crate_version, Arg, App};

构建解析器

构建解析器的过程相当简单。你将创建一个App实例,并用Arg实例填充此类型。App还有可以用来设置应用程序信息的方法。将以下代码添加到我们服务器的main函数中:

let matches = App::new(crate_name!())
         .version(crate_version!())
         .author(crate_authors!())
         .about(crate_description!())
         .arg(Arg::with_name("address")
              .short("a")
              .long("address")
              .value_name("ADDRESS")
              .help("Sets an address")
              .takes_value(true))
         .arg(Arg::with_name("config")
              .short("c")
              .long("config")
              .value_name("FILE")
              .help("Sets a custom config file")
              .takes_value(true))
        .get_matches();

首先,我们使用new方法创建一个App实例,该方法期望接收 crate 的名称。我们使用crate_name!宏提供这个名称。之后,我们使用versionauthorabout方法通过相应的宏设置这些数据。我们可以链式调用这些方法,因为每个方法都会消耗并返回更新后的App对象。当我们设置应用程序的元信息时,我们必须使用arg方法声明支持的参数。

要添加一个参数,我们必须使用with_name方法创建一个Arg实例,提供名称,并使用链式方法调用设置额外的参数。我们可以使用short方法设置参数的简写形式,使用long方法设置长写形式。你可以使用value_name方法为生成的文档设置值的名称。你可以使用help方法提供参数的描述。takes_value方法用于指示此参数需要值。还有一个required方法用于指示选项是必需的,但在这里我们没有使用它。在我们的服务器中,所有选项都是可选的。

我们使用这些方法添加了--address参数,用于设置我们将用于绑定服务器的套接字地址。它还支持参数的简写形式a。我们将在稍后读取这个值。

服务器将支持--config参数来设置配置文件。我们已经将此参数添加到构建器中,但我们将在本章的下一节中使用它。

在我们创建构建器之后,我们调用get_matches方法。这个方法读取std::env::args_os中的参数,并返回一个ArgMatches实例,我们可以使用它来获取命令行参数的值。我们将它分配给matches局部变量。

我们应该在任何日志调用之前添加get_matches方法,因为它也会打印帮助信息。我们应该避免打印带有帮助描述的日志。

阅读参数

要读取参数,ArgMatches包含一个value_of方法,其中你可以添加一个参数的名称。在这种情况下,使用常量很方便,可以避免输入错误。提取--address参数,如果它不存在,则检查ADDRESS环境变量。这意味着命令行参数的优先级高于环境变量,并且你可以使用命令行参数覆盖.env文件中的参数:

let addr = matches.value_of("address")
    .map(|s| s.to_owned())
    .or(env::var("ADDRESS").ok())
    .unwrap_or_else(|| "127.0.0.1:8080".into())
    .parse()
    .expect("can't parse ADDRESS variable");

在这段代码中,我们将所有提供的字符串引用转换为&str类型的实体String对象。如果你想在代码的后续部分使用这个对象,或者需要将其移动到其他地方,这很有用。

用法

当你在你的应用程序中使用clapcrate 时,你可以使用命令行参数来调整它。clapcrate 添加了一个--help参数,用户可以使用它来打印有关所有参数的信息。这个描述是由 crate 自动生成的,如下面的示例所示:

$ ./target/debug/random-service-with-args --help
random-service-with-env 0.1.0
Your Name
Rust Microservice

USAGE:
 random-service-with-env [OPTIONS]

FLAGS:
 -h, --help       Prints help information
 -V, --version    Prints version information

OPTIONS:
 -a, --address <ADDRESS>    Sets an address
 -c, --config <FILE>        Sets a custom config file

我们的应用程序成功打印了用法信息:它提供了所有标志、选项和用法变体。如果你需要添加自己的帮助描述,可以使用 App 实例的 help 方法设置任何字符串作为帮助信息。

如果你使用 cargo run 命令,你还可以在 -- 参数之后设置命令行参数。这意味着它停止读取 run 命令,并将所有剩余的参数传递给正在运行的应用程序:

$ cargo run -- --help

现在,你可以使用 --address 参数并设置值为来启动服务器:

$ cargo run -- --address 0.0.0.0:2345

服务器已启动并向控制台打印:

    Finished dev [unoptimized + debuginfo] target(s) in 0.10s                                                                                             Running `target/debug/random-service-with-args --address '0.0.0.0:2345'`
 INFO 2018-07-26T04:23:52Z: random_service_with_env: Rand Microservice - v0.1.0
DEBUG 2018-07-26T04:23:52Z: random_service_with_env: Trying to bind server to address: 0.0.0.0:2345
 INFO 2018-07-26T04:23:52Z: random_service_with_env: Used address: 0.0.0.0:2345
DEBUG 2018-07-26T04:23:52Z: random_service_with_env: Run!
DEBUG 2018-07-26T04:23:52Z: tokio_reactor::background: starting background reactor

如何添加子命令

一些流行的应用程序,例如 cargodocker,使用子命令在单个二进制文件内提供多个命令。我们也可以使用 clap 包来支持子命令。一个微服务可能有两个命令:一个用于运行服务器,另一个用于生成 HTTP 甜点的密钥。看看下面的代码:

let matches = App::new("Server with keys")
    .setting(AppSettings::SubcommandRequiredElseHelp)
    .subcommand(SubCommand::with_name("run")
        .about("run the server")
        .arg(Arg::with_name("address")
            .short("a")
            .long("address")
            .takes_value(true)
            .help("address of the server"))
    .subcommand(SubCommand::with_name("key")
        .about("generates a secret key for cookies")))
    .get_matches();

在这里,我们使用了两种方法。setting 方法调整构建器,你可以使用 AppSettings 枚举的变体来设置它。SubcommandRequiredElseHelp 方法要求我们使用子命令,如果没有提供子命令,则打印帮助信息。要添加子命令,我们使用 subcommand 方法,并使用通过 with_name 方法创建的 SubCommand 实例。子命令实例也有设置子命令元信息的方法,就像我们对 App 实例所做的那样。子命令也可以接受参数。

在上面的示例中,我们添加了两个子命令——run 用于运行服务器,key 用于生成密钥。当你启动应用程序时,你可以使用这些命令:

$ cargo run -- run --address 0.0.0.0:2345

我们有两个 run 参数,因为 cargo 有一个同名的命令。

从文件中读取配置

环境变量和命令行参数对于添加单次运行的临时更改参数很有用。它们是配置服务器使用配置文件的更方便的方式。这种方法不符合 十二要素应用 方法论,但在需要设置长参数的情况下很有用。

可以用于配置文件的格式有很多。其中一些流行的包括 TOML、YAML 和 JSON。我们将使用 TOML,因为它在 Rust 编程语言中得到了广泛的应用。

添加 TOML 配置

TOML 文件格式在 toml 包中实现。它之前使用的是现在已废弃的 rustc-serialize 包,但最近几个版本已经使用 serde 包进行序列化和反序列化。我们将使用 tomlserde 包。

添加依赖项

我们实际上不仅需要 serde 包,还需要 serde_derive 包。这两个包都帮助在多种序列化格式中处理序列化结构体。将所有三个包添加到 Cargo.toml 文件的依赖列表中:

serde = "1.0"
serde_derive = "1.0"
toml = "0.4"

main.rs 文件中的完整导入列表包含以下内容:

use clap::{crate_authors, crate_description, crate_name, crate_version, Arg, App};
use dotenv::dotenv;
use hyper::{Body, Response, Server};
use hyper::rt::Future;
use hyper::service::service_fn_ok;
use log::{debug, info, trace, warn};
use serde_derive::Deserialize;
use std::env;
use std::io::{self, Read};
use std::fs::File;
use std::net::SocketAddr;

如您所见,我们在这里没有导入serde包。我们不会直接在代码中使用它,因为它需要使用serde_derive包。我们已经导入了serde_derive包中的所有宏,因为serde包包含SerializeDeserialize特性,而serde_derive帮助我们为我们的结构体推导这些特性。

微服务在与客户端交互时通常需要序列化和反序列化数据。我们将在下一章中介绍这个主题。

声明配置结构体

我们现在已经导入了所有必要的依赖项,可以声明我们的配置文件结构。将Config结构体添加到您的代码中:

#[derive(Deserialize)]
struct Config {
    address: SocketAddr,
}

此结构体仅包含一个带有地址的字段。您可以添加更多字段,但请记住,所有字段都必须实现Deserialize特性。serde包已经为标准库类型提供了实现。对于我们的类型,我们必须使用serde_derive包的宏推导Deserialize的实现。

一切准备就绪,我们可以从文件中读取配置。

读取配置文件

我们的服务器期望在当前工作目录中找到一个名为microservice.toml的配置文件。为了读取配置并将其转换为Config结构体,我们需要找到并读取此文件(如果存在)。将以下代码添加到服务器的main函数中:

let config = File::open("microservice.toml")
    .and_then(|mut file| {
        let mut buffer = String::new();
        file.read_to_string(&mut buffer)?;
        Ok(buffer)
    })
    .and_then(|buffer| {
        toml::from_str::<Config>(&buffer)
            .map_err(|err| io::Error::new(io::ErrorKind::Other, err))
    })
    .map_err(|err| {
        warn!("Can't read config file: {}", err);
    })
    .ok();

上述代码是一系列以File实例开始的调用链。我们使用open方法打开文件并提供名称microservice.toml。调用返回一个Result,我们将在调用链中处理它。在处理结束时,我们将使用ok方法将其转换为选项,并忽略在解析配置文件过程中发生的任何错误。这是因为我们的服务也支持环境变量和命令行参数,并为未设置的参数提供了默认值。

当文件准备就绪时,我们将尝试将其转换为String。我们创建了一个空字符串,称为缓冲区,并使用File实例的read_to_string方法将所有数据移动到缓冲区中。这是一个同步操作。它适合读取配置,但您不应该用它来读取发送给客户端的文件,因为它将锁定服务器的运行时直到文件读取完成。

在我们读取buffer变量之后,我们将尝试将其解析为 TOML 文件并将其转换为Config结构体。toml包在包的根命名空间中有一个from_str方法。它期望一个类型参数用于反序列化和一个输入字符串。我们使用Config结构体作为输出类型,使用buffer作为输入。但是有一个问题:File使用io::Error作为错误类型,但from_str使用toml::de:Error作为错误类型。我们可以将第二种类型转换为io::Error以使其与调用链兼容。

链表的倒数第二个部分是map_err方法调用。我们使用它将任何配置文件错误写入日志。正如你所见,我们使用了Warn级别。配置文件的问题不是关键的,但了解它们很重要,因为它们可能会影响配置。这使得microservices.toml文件成为可选的。

按优先级连接所有值

我们的服务器有四个地址设置的来源:

  • 配置文件

  • 环境变量

  • 命令行参数

  • 默认值

我们必须按此顺序连接它们。使用一组选项和or方法设置值,如果选项不包含任何内容,则实现这一点很简单。使用以下代码从所有来源获取地址值:

let addr = matches.value_of("address")
    .map(|s| s.to_owned())
    .or(env::var("ADDRESS").ok())
    .and_then(|addr| addr.parse().ok())
    .or(config.map(|config| config.address))
    .or_else(|| Some(([127, 0, 0, 1], 8080).into()))
    .unwrap();

首先,此代码从--address命令行参数获取一个值。如果它不包含任何值,代码将尝试从ADDRESS环境变量中获取一个值。之后,我们尝试将文本值解析为套接字地址。如果所有这些步骤都失败,我们可以尝试从我们从microservice.toml读取的Config实例中获取一个值。如果没有用户设置值,我们将使用默认地址值。在之前的地址解析代码中,我们也从字符串中解析了默认值。在此代码中,我们使用一个元组来构建SocketAddr实例。由于我们保证会得到一个值,所以我们使用unwrap来提取它。

创建和使用配置文件

我们现在可以创建一个配置文件并运行服务器。在项目的根目录中创建microservice.toml文件,并添加以下行到其中:

address = "0.0.0.0:9876"

编译并启动服务,你会看到它已经绑定到了该地址:

图片

概述

在本章中,我们向服务器添加了日志记录,并学习了如何激活logger并设置其过滤器。之后,我们将我们的不灵活服务器转换为一个可配置的微服务,可以从不同的来源读取设置——配置文件、环境变量和命令行参数。我们熟悉了十二要素应用方法,并使用了dotenv crate,它帮助我们从文件中读取环境变量。我们还使用了clap crate 来添加命令行解析器。最后,我们简要介绍了serde crate,它带我们进入了序列化的世界。

在下一章中,我们将学习如何使用serde crate 来满足微服务的需求:将请求反序列化并将响应序列化到特定的格式,如 JSON、CBOR、BSON、MessagePack 等。

第四章:使用 Serde Crate 进行数据序列化和反序列化

微服务可以与客户端或彼此交互。要实现交互,你必须选择一个协议和一个格式,以便从一个通信参与者向另一个通信参与者发送消息。有许多格式和 RPC 框架可以简化交互过程。在本章中,我们将发现 serde Crate 的功能,它可以帮助你使结构体可序列化和可反序列化,并兼容不同的格式,如 JSON、CBOR、MessagePack 和 BSON。

本章将涵盖以下主题:

  • 如何序列化和反序列化数据

  • 如何使自定义类型可序列化

  • 应选择哪些序列化格式以及应避免哪些格式

技术要求

在本章中,我们将探讨 serde Crate 家族中的一些可用功能。这个家族包括不可分割的一对——serdeserde_derive Crate。它还包括如 serde_jsonserde_yamltoml 这样的 Crate,它们为你提供了对特殊格式(如 JSON、YAML 和 TOML)的支持。所有这些 Crate 都是纯 Rust 编写的,并且不需要任何外部依赖。

您可以从 GitHub 获取本章示例的源代码:github.com/PacktPublishing/Hands-On-Microservices-with-Rust-2018/tree/master/Chapter04.

与微服务交互的数据格式

微服务可以与不同的参与者交互,例如客户端、其他微服务和第三方 API。通常,交互是通过网络使用特定格式的序列化数据消息来执行的。在本节中,我们将学习如何选择这些交互的格式。我们还将探索 serde Crate 的基本功能——如何使我们的结构体可序列化并使用特定格式。

Serde Crate

当我开始在我的项目中使用 Rust 时,我通常使用流行的 rustc_serialize Crate。这并不坏,但我发现它不够灵活。例如,我无法在我的结构体中使用泛型数据类型。serde Crate 的创建是为了消除 rustc_serialize Crate 的不足。自从 serde Crate 达到 1.0 版本分支以来,serde 一直是 Rust 中序列化和反序列化的主要 Crate。

我们在上一章中使用这个 Crate 来反序列化配置文件。现在我们将使用它来将请求数据和响应数据转换为文本或二进制数据。

为了探索序列化,我们将使用一个生成随机数的微服务。我们将重写一个非常简单的版本,不包含日志记录、读取参数、环境变量或配置文件。它将使用 HTTP 主体来指定随机值的范围和随机分布,以生成一个数字。

我们的服务将只处理 /random 路径的请求。它将期望请求和响应都使用 JSON 格式。如前所述,serde crate 为代码提供了序列化能力。我们还需要 serde_derive crate 来自动推导序列化方法。serde crate 只包含核心类型和特性,以使序列化过程通用和可重用,但特定格式是在其他 crate 中实现的。我们将使用 serde_json,它提供了一个 JSON 格式的 serializer

复制最小随机数生成服务的代码,并将以下依赖项添加到 Cargo.toml 中:

[dependencies]
futures = "0.1"
hyper = "0.12"
rand = "0.5"
serde = "1.0"
serde_derive = "1.0"
serde_json = "1.0"

将以下 crate 导入到 main.rs 源文件中:

extern crate futures;
extern crate hyper;
extern crate rand;
#[macro_use]
extern crate serde_derive;
extern crate serde_json;

如你所见,我们从 serde_deriveserde_json crate 中导入了一个宏来使用 JSON serializer。我们不需要导入 serde crate,因为我们不会直接使用它,但使用宏是必要的。现在我们可以查看代码的不同部分。首先,我们将检查请求和响应类型。之后,我们将实现一个处理器来使用它。

序列化响应

服务返回以 f64 类型表示的随机数。我们希望将其打包到一个 JSON 对象中,因为我们可能需要向对象中添加更多字段。在 JavaScript 中使用对象很简单。声明 RngResponse 结构体并添加 #[derive(Serialize)] 属性。这使得这个结构体可序列化,如下所示:

#[derive(Serialize)]
struct RngResponse {
    value: f64,
}

为了使这个结构体可反序列化,我们应该推导 Deserialize 特性。如果你想要使用相同的类型进行请求和响应,反序列化可能很有用。如果你想在服务器和客户端中使用相同的类型,那么推导 SerializeDeserialize 都是很重要的。

序列化的对象将被表示为以下字符串:

{ "value": 0.123456 }

反序列化请求

服务将支持复杂的请求,你可以指定分布和参数。让我们将这个枚举添加到源文件中:

#[derive(Deserialize)]
enum RngRequest {
    Uniform {
        range: Range<i32>,
    },
    Normal {
        mean: f64,
        std_dev: f64,
    },
    Bernoulli {
        p: f64,
    },
}

你可能想知道序列化值看起来像什么。serde_derive 提供了额外的属性,你可以使用这些属性来调整序列化格式。当前的 deserializer 期望一个 RngRequest 实例,如下所示:

RngRequest::Uniform {
        range: 1..10,
}

这将被表示为以下字符串:

{ "Uniform": { "range": { "start": 1, "end": 10 } } }

如果你从头开始创建自己的协议,由于你可以轻松地使其符合由 serde crate 自动生成的序列化器的任何限制,因此布局不会有任何问题。然而,如果你必须使用现有的协议,你可以尝试向声明中添加额外的属性。如果这不起作用,你可以手动实现 SerializeDeserialize 特性。例如,假设我们想使用以下请求格式:

{ "distribution": "uniform", "parameters": { "start": 1, "end": 10 } } }

serde 属性添加到 RngRequest 声明中,这将使 deserializer 支持前面的格式。代码将如下所示:

#[derive(Deserialize)]
#[serde(tag = "distribution", content = "parameters", rename_all = "lowercase")]
enum RngRequest {
    Uniform {
        #[serde(flatten)]
        range: Range<i32>,
    },
    Normal {
        mean: f64,
        std_dev: f64,
    },
    Bernoulli {
        p: f64,
    },
}

现在,枚举使用上述请求格式。serde_derive 包中有很多属性,探索它们非常重要。

调整序列化

serde_derive 支持许多属性,可以帮助你避免为结构体手动实现 serializerdeserializer。在本节中,我们将详细探讨有用的属性。我们将学习如何更改变体的字母大小写,如何移除嵌套级别,以及如何为枚举的标签和内容使用特定名称。

更改名称的大小写

serde_derive 将代码中字段的名称映射到数据字段。例如,结构体中的 title 字段将期望数据对象中的 title 字段。如果协议使用其他名称作为字段,你必须考虑到这一点以避免警告。例如,协议中的一个结构体可能包含 stdDev 字段,但如果你在 Rust 中使用这个字段的名称,你会得到以下警告:

warning: variable `stdDev` should have a snake case name such as `std_dev`

你可以通过添加 #![allow(non_snake_case)] 属性来修复这个问题,但这会使代码看起来不美观。更好的解决方案是使用 #[serde(rename="stdDev")] 属性,并仅对序列化和反序列化使用其他命名约定。

重命名属性有两种变体:

  • 更改所有变体的命名约定

  • 更改字段的名称

要更改枚举的所有变体,请添加 #[serde(rename_all="...")] 属性并使用以下值之一:"lowercase""PascalCase""camelCase""snake_case""SCREAMING_SNAKE_CASE""kebab-case"。为了更具有代表性,命名值将根据其自身约定的规则编写。

要更改字段的名称,请使用 #[serde(rename="...")] 属性并指定在序列化过程中使用的字段名称。你可以在 第十七章 中看到一个此属性使用示例,使用 AWS Lambda 的有界微服务

使用重命名的一个原因是当字段名称在 Rust 中是关键字时。例如,一个结构体不能包含名为 type 的字段,因为它是一个关键字。你可以在结构体中将它重命名为 typ 并添加 #[serde(rename="type")] 到它。

移除嵌套

自动派生的 serializer 使用与你的类型相同的嵌套结构。如果你需要减少嵌套级别,你可以设置 #[serde(flatten)] 属性以使用没有封装对象的字段。在之前的示例中,我们使用了标准库中的 Range 类型来设置生成随机值的范围,但我们还希望在序列化数据中看到实现细节。为此,我们需要 Rangestartend 字段。我们向该字段添加了此属性以删除结构中的 { "range": ... } 级别。

对于枚举,serde_derive 使用标签作为对象的名称。例如,以下 JSON-RPC 包含两个变体:

#[derive(Serialize, Deserialize)]
enum RpcRequest {
    Request { id: u32, method: String, params: Vec<Value> },
    Notification { id: u32, method: String, params: Vec<Value> },
}

params 字段包含一个由 serde_json::Value 类型表示的任何 JSON 值的数组,我们将在本章后面探讨此类型。如果你序列化此结构体的实例,它将包括变体的名称。例如,考虑以下内容:

{ "Request": { "id": 1, "method": "get_user", "params": [123] } }

此请求与 JSON-RPC 规范不兼容 (www.jsonrpc.org/specification#request_object)。我们可以使用 #[serde(untagged)] 属性删除封装对象,结构体将如下所示:

{ "id": 1, "method": "get_user", "params": [123] }

此更改后,此序列化数据可以发送为 JSON-RPC。但是,如果你仍然希望将变体值保留在序列化数据形式中,你必须使用另一种方法,这将在下一节中描述。

使用特定的名称为标签和内容命名

在我们的示例中,我们希望在序列化数据形式中有两个字段:distributionparameters。在第一个字段中,我们希望保存枚举的变体,但重命名使其为小写。在第二个字段中,我们将保留特定变体的参数。

为了实现这一点,你可以编写自己的 SerializerDeserializer,我们将在本章后面探讨这种方法。然而,在这种情况下,我们可以使用 #[serde(tag = "...")]#[serde(context = "...")] 属性。context 只能与 tag 配对使用。

我们已经将此添加到我们的 RngRequest 中:

#[serde(tag = "distribution", content = "parameters", rename_all = "lowercase")]

此属性指定了序列化对象的 distribution 键以保存枚举的变体。枚举的变体移动到序列化对象的 parameters 字段。最后一个属性 rename_all 改变了变体名称的大小写。如果没有重命名,我们将被迫使用标题大小写来表示分布,例如使用 "Uniform" 而不是更整洁的 "uniform"

有时,序列化的值必须包含具有动态结构的对象。例如,JSON 格式支持自由数据结构。我不喜欢未指定的结构,但为了创建与现有服务兼容的服务,我们可能需要它们。

任何值

如果你希望保持数据的一部分未序列化,但你不知道数据的结构,并且希望在运行时稍后探索它,你可以使用通用的 serde_json::Value,它表示任何类型的值。例如,serde_json 包含一个 Value 对象和一个从 Value 实例反序列化类型的方法。这可能在对表示进行重新排序之前完全反序列化困难的反序列化情况中很有用。

要使用通用的 Value,请将其添加到你的结构体中。例如,考虑以下内容:

#[derive(Deserialize)]
struct Response {
    id: u32,
    result: serde_json::Value,
}

在这里,我们使用了 serde_json 包的通用值。当你需要将其反序列化为 User 结构体,例如,你可以使用 serde_json::from_value 函数:

let u: User = serde_json::from_value(&response)?;

在本节中,我们学习了反序列化过程。现在是时候向我们的服务器添加一个处理器来处理请求了。这个处理器将反序列化数据,生成一个随机值,并以序列化的形式将数据返回给客户端。

如果我写一个代理服务,我应该反序列化和序列化请求以将它们不变地发送到另一个服务吗?这取决于服务的目的。序列化和反序列化会占用大量的 CPU 资源。如果服务用于平衡请求,你不需要请求的内部数据。特别是如果你只使用 HTTP 头选择请求的目的地。然而,你可能想使用反序列化数据的处理优势——例如,你可以在将其发送到其他微服务之前修补数据的一些值。

使用超

我们添加了RngRequestResponse类型,并实现了SerializeDeserialize特性。现在我们可以在处理器中使用它们与serde_json一起。在本节中,我们将探讨如何获取请求的全部内容,将其反序列化为特定类型的实例,以及序列化响应。

从流中读取主体

事实上,hyper包中请求的主体是一个流。你无法立即获取全部内容,但你可以读取传入的块,将它们写入一个向量,并使用结果数据集作为一个单一对象。

我们无法访问整个主体,因为它可能是一个巨大的数据块,我们无法将其保存在内存中。我们的服务可以用于上传多个太字节的数据文件或进行视频流传输。

由于我们使用的是异步方法,我们不能在读取流到末尾时阻塞当前线程。这是因为这将阻塞线程并导致程序停止工作,因为相同的线程用于轮询流。

serde包不支持连续数据流的反序列化,但你可以直接创建并使用一个Deserializer实例来处理无限流。

要从流中读取数据,你必须获取一个Stream实例并将其放入一个Future对象中,该对象将收集该流中的数据。我们将在下一章中更详细地探讨这个主题。让我们实现一个从Stream实例收集数据的Future。将以下代码添加到处理器中/random路径的match表达式的分支:

(&Method::POST, "/random") => {
    let body = req.into_body().concat2()
        .map(|chunks| {
            let res = serde_json::from_slice::<RngRequest>(chunks.as_ref())
                .map(handle_request)
                .and_then(|resp| serde_json::to_string(&resp));
            match res {
                Ok(body) => {
                    Response::new(body.into())
                },
                Err(err) => {
                    Response::builder()
                        .status(StatusCode::UNPROCESSABLE_ENTITY)
                        .body(err.to_string().into())
                        .unwrap()
                },
            }
        });
    Box::new(body)
}

Request实例有一个into_body方法,它返回请求的主体。我们使用了Body类型来表示我们的处理器的主体。Body类型是一个实现了Stream特性的块流。它有一个concat2方法,可以将所有块连接成一个单一对象。这是因为Chunk类型实现了Extend特性,并且可以与其他Chunk扩展。concat2方法将Stream转换为Future

如果你还不熟悉futures crate,你可以在下一章中了解更多关于它的信息。现在,你可以将Future对象视为一个稍后完成的Result。你可以将Stream视为一个没有引用任何项的Iterator,它必须从数据流中轮询下一个项。

在我们取完请求的全部正文后,我们可以使用反序列化。serde_derive派生一个通用的deserializer,因此我们必须使用一个 crate 来获取特定的序列化格式。在这种情况下,我们将使用 JSON 格式,所以我们将使用serde_json crate。这包括一个from_slice函数,它为我们的类型创建一个Deserializer,并使用它从缓冲区中读取一个实例。

from_slice方法返回一个Result<T, serde_json::Error>,我们将map这个结果到我们自己的handle_request函数,该函数读取请求并生成响应。我们将在本节稍后讨论这个函数。

当结果准备好时,我们使用serde_json::to_string函数将响应转换为 JSON 字符串。我们使用and_then,因为to_string返回一个 Result,我们必须处理任何错误。

我们现在有一个Result,它包含一个序列化的响应或者如果发生错误则包含serde_json::Error。我们将使用match表达式来返回一个成功的Response,如果响应被成功创建并序列化为一个String,或者返回一个带有UNPROCESSABLE_ENTITY状态和错误信息的响应体。

在我们的案例中,我们创建了一个没有结果的Future对象。我们必须将这个未来添加到一个 reactor 中以便执行它。

在讨论前面的代码时,我们提到了handle_request函数。让我们更仔细地看看这个函数的实现:

fn handle_request(request: RngRequest) -> RngResponse {
    let mut rng = rand::thread_rng();
    let value = {
        match request {
            RngRequest::Uniform { range } => {
                rng.sample(Uniform::from(range)) as f64
            },
            RngRequest::Normal { mean, std_dev } => {
                rng.sample(Normal::new(mean, std_dev)) as f64
            },
            RngRequest::Bernoulli { p } => {
                rng.sample(Bernoulli::new(p)) as i8 as f64
            },
        }
    };
    RngResponse { value }
}

函数接受一个RngRequest值。实现的第一行使用rand::thread_rng函数创建一个随机数生成器实例。我们将使用sample方法生成一个随机值。

我们的请求支持三种类型的分布:UniformNormalBernoulli。我们使用了解构模式来获取请求的参数以创建一个分布实例。之后,我们使用反序列化的请求进行采样,并将结果转换为f64类型以打包到RngResponse值中。

自定义类型

当你的微服务使用自定义数据结构时,它需要一个自定义的序列化格式。你可以通过实现SerializeDeserialize特质,或者通过添加特殊属性到你的结构体或结构体的字段中来自定义序列化。我们将在这里探讨这两种方法。

自定义序列化

我们将扩展我们的随机数生成服务,增加两个功能——生成随机颜色和打乱字节数组。对于第一个功能,我们需要添加一个Color结构体来保存颜色分量。创建一个新的文件,color.rs,并将以下代码添加到其中:

#[derive(Clone, PartialEq, Eq)]
pub struct Color {
    pub red: u8,
    pub green: u8,
    pub blue: u8,
}

添加两个我们稍后会用到的常量颜色:

pub const WHITE: Color = Color { red: 0xFF, green: 0xFF, blue: 0xFF };
pub const BLACK: Color = Color { red: 0x00, green: 0x00, blue: 0x00 };

结构体还实现了 PartialEqEq 来比较值与这些常量。

我们将使用与 CSS 兼容的文本表示颜色。我们将支持十六进制格式的 RGB 颜色以及两种文本颜色:blackwhite。要将颜色转换为字符串,为 Color 实现 Display 特性:

impl fmt::Display for Color {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            &WHITE => f.write_str("white"),
            &BLACK => f.write_str("black"),
            color => {
                write!(f, "#{:02X}{:02X}{:02X}", color.red, color.green, color.blue)
            },
        }
    }
}

此实现使用 '#' 前缀将三个颜色分量写入字符串。每个颜色分量字节都以十六进制格式写入,对于非重要数字使用 '0' 前缀,宽度为两个字符。

现在我们可以使用这个格式化程序来实现 Color 结构体的 Serialize 特性:

impl Serialize for Color {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: Serializer,
    {
        serializer.serialize_str(&self.to_string())
    }
}

Serialize 实现调用 Serializerserialize_str 方法来将颜色的十六进制表示存储到字符串中。在实现自定义反序列化之前,将所有必要的导入添加到 color.rs 文件中:

use std::fmt;
use std::str::FromStr;
use std::num::ParseIntError;
use serde::{de::{self, Visitor}, Deserialize, Deserializer, Serialize, Serializer};

自定义反序列化

我们的 Color 类型必须能够从字符串转换。我们可以通过实现 FromStr 特性来实现这一点,这使得我们可以调用 strparse 方法来从字符串解析结构体:

impl FromStr for Color {
    type Err = ColorError;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s {
            "white" => Ok(WHITE.to_owned()),
            "black" => Ok(BLACK.to_owned()),
            s if s.starts_with("#") && s.len() == 7 => {
                let red = u8::from_str_radix(&s[1..3], 16)?;
                let green = u8::from_str_radix(&s[3..5], 16)?;
                let blue = u8::from_str_radix(&s[5..7], 16)?;
                Ok(Color { red, green, blue })
            },
            other => {
                Err(ColorError::InvalidValue { value: other.to_owned() })
            },
        }
    }
}

在此实现中,我们使用具有四个分支的匹配表达式来检查情况。我们指示表达式应该具有 "white""black" 的文本值,或者它可以以 # 开头,并且应该包含恰好七个字符。否则,应返回错误以指示提供了不受支持的格式。

要实现 Deserialization 特性,我们需要添加 ColorVisitor 结构体,该结构体实现了 serde crate 的 Visitor 特性。Visitor 特性用于从不同的输入值中提取特定类型的值。例如,我们可以使用 u32str 输入类型来反序列化十进制值。以下示例中的 ColorVisitor 尝试将传入的字符串解析为颜色。它具有以下实现:

struct ColorVisitor;

impl<'de> Visitor<'de> for ColorVisitor {
    type Value = Color;

    fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
        formatter.write_str("a color value expected")
    }

    fn visit_str<E>(self, value: &str) -> Result<Self::Value, E> where E: de::Error {
        value.parse::<Color>().map_err(|err| de::Error::custom(err.to_string()))
    }

    fn visit_string<E>(self, value: String) -> Result<Self::Value, E> where E: de::Error {
        self.visit_str(value.as_ref())
    }
}

如您所见,我们使用了 strparse 方法,该方法与实现了 FromStr 特性的类型一起工作,将字符串转换为 Color 实例。我们实现了两个方法来从不同类型的字符串中提取值——第一个用于 String 实例,第二个用于 str 引用。现在我们可以将 Color 类型作为其他可反序列化结构体的字段添加。在我们更详细地查看处理二进制数据之前,让我们先仔细看看 ColorError 类型。

使用 failure crate 的自定义错误类型

之前的解析需要一个自己的错误类型来覆盖两种错误——数字解析错误和无效变体。让我们在本节中声明 ColorError 类型。

Rust 中的错误处理特别简单。有一个 Result 类型,它将成功和失败的结果包装在单个实体中。Result 将任何类型解释为错误类型,您可以使用 try! 宏或 ? 操作符将一个结果转换为另一个结果。但是,将不同的错误类型连接起来要复杂得多。有一个 std::error::Error 特性,它为所有错误提供了一个通用接口,但它有点笨拙。为了以更用户友好的方式创建错误,您可以使用 failure 包。

此包有助于错误处理,并包含广泛的 failure::Error 类型,这些类型与其他实现 std::Error::Error 特性的错误兼容。您可以将实现此特性的任何错误类型转换为通用的 failure::Error。此包还包括可以用于派生您自己的错误类型和 failure::Fail 特性的宏,例如 Backtrace,它提供了有关错误运行时主要原因的额外信息。

color.rs 文件中声明此类型:

#[derive(Debug, Fail)]
pub enum ColorError {
    #[fail(display = "parse color's component error: {}", _0)]
    InvalidComponent(#[cause] ParseIntError),
    #[fail(display = "invalid value: {}", value)]
    InvalidValue {
        value: String,
    },
}

ColorError 枚举有两个变体:InvalidComponent 用于解析问题,如果提供了错误值,则为 InvalidValue。为了实现此类型的必要错误特性,我们使用 #[derive(Debug, Fail)] 属性从 Fail 特性派生。Fail 派生的 Debug 特性实现也是必要的。

为了创建错误消息,我们添加了 fail 属性,它带有 display 参数,该参数期望一个包含参数以插入到格式字符串中的消息。对于字段,您可以使用名称,例如 value,以及带有下划线前缀的数字以指示它们的字段位置。例如,要插入第一个字段,请使用名称 0。要将字段标记为嵌套错误,请使用 #[cause] 属性。

Fail 特性派生不会为用作 ColorError 枚举变体的类型实现 From。您应该自己这样做:

impl From<ParseIntError> for ColorError {
    fn from(err: ParseIntError) -> Self {
        ColorError::InvalidComponent(err)
    }
}

ColorError 类型现在可以使用 ? 操作符,并且我们可以将随机颜色生成添加到我们的微服务中,同时进行二进制数组的洗牌。

二进制数据

在改进微服务之前,将所有必要的依赖项添加到 Cargo.toml 文件中:

failure = "0.1"
futures = "0.1"
hyper = "0.12"
rand = "0.5"
serde = "1.0"
serde_derive = "1.0"
serde_json = "1.0"
base64 = "0.9"
base64-serde = "0.3"

我们使用了很多与 Rust 和 crates 一起工作得很好的依赖项。如您所注意到的,我们已添加了 base64 包和 base64-serde 包。第一个是一个二进制到文本的转换器,第二个是 serde 序列化过程中的转换器所必需的。将这些全部导入到 main.rs 文件中:

#[macro_use]
extern crate failure;
extern crate futures;
extern crate hyper;
extern crate rand;
extern crate serde;
#[macro_use]
extern crate serde_derive;
extern crate serde_json;
extern crate base64;
#[macro_use]
extern crate base64_serde;

mod color;

use std::ops::Range;
use std::cmp::{max, min};
use futures::{future, Future, Stream};
use hyper::{Body, Error, Method, Request, Response, Server, StatusCode};
use hyper::service::service_fn;
use rand::Rng;
use rand::distributions::{Bernoulli, Normal, Uniform};
use base64::STANDARD;
use color::Color;

我们还添加了 color 模块,并使用该模块中的 color:Color。我们还从 failurebase64_serde 包中导入宏。

为颜色生成和数组洗牌向 RngRequest 枚举添加两个额外变体:

#[derive(Deserialize)]
#[serde(tag = "distribution", content = "parameters", rename_all = "lowercase")]
enum RngRequest {
    Uniform {
        #[serde(flatten)]
        range: Range<i32>,
    },
    Normal {
        mean: f64,
        std_dev: f64,
    },
    Bernoulli {
        p: f64,
    },
    Shuffle {
        #[serde(with = "Base64Standard")]
        data: Vec<u8>,
    },
    Color {
        from: Color,
        to: Color,
    },
}

Shuffle 变体的字段数据为 Vec<u8> 类型。由于 JSON 不支持二进制数据,我们必须将其转换为文本。我们添加了 #[serde(with = "Base64Standard")] 属性,这要求我们使用 Base64Standard 类型进行反序列化。您可以使用自己的序列化和反序列化函数自定义字段;现在,我们必须声明 Base64Standard

base64_serde_type!(Base64Standard, STANDARD);

我们必须声明它,因为 base64_serde 不包含预定义的反序列化器。这是因为 Base64 需要额外的参数,这些参数不能有通用值。

Color 变体包含两个字段,可以用来指定颜色生成的范围。

RngResponse 枚举添加一些新的响应变体:

#[derive(Serialize)]
#[serde(rename_all = "lowercase")]
enum RngResponse {
    Value(f64),
    #[serde(with = "Base64Standard")]
    Bytes(Vec<u8>),
    Color(Color),
}

现在,我们必须通过添加额外的变体来改进 handle_request 函数:

fn handle_request(request: RngRequest) -> RngResponse {
    let mut rng = rand::thread_rng();
    match request {
        RngRequest::Uniform { range } => {
            let value = rng.sample(Uniform::from(range)) as f64;
            RngResponse::Value(value)
        },
        RngRequest::Normal { mean, std_dev } => {
            let value = rng.sample(Normal::new(mean, std_dev)) as f64;
            RngResponse::Value(value)
        },
        RngRequest::Bernoulli { p } => {
            let value = rng.sample(Bernoulli::new(p)) as i8 as f64;
            RngResponse::Value(value)
        },
        RngRequest::Shuffle { mut data } => {
            rng.shuffle(&mut data);
            RngResponse::Bytes(data)
        },
        RngRequest::Color { from, to } => {
            let red = rng.sample(color_range(from.red, to.red));
            let green = rng.sample(color_range(from.green, to.green));
            let blue = rng.sample(color_range(from.blue, to.blue));
            RngResponse::Color(Color { red, green, blue})
        },
    }
}

我们在这里稍微重构了代码,并增加了两个额外的分支。第一个分支,对于 RngRequest::Shuffle 变体,使用 Rng 特性的 shuffle 方法来打乱传入的二进制数据,并将其作为转换为 Base64 文本返回。

第二个变体,RngRequest::Color,使用我们将声明的 color_range 函数。此分支在范围内生成三种颜色并返回生成的颜色。让我们来探索 color_range 函数:

fn color_range(from: u8, to: u8) -> Uniform<u8> {
    let (from, to) = (min(from, to), max(from, to));
    Uniform::new_inclusive(from, to)
}

此函数使用 fromto 值创建一个新的 Uniform 分布,包含的范围。我们现在准备编译和测试我们的微服务。

编译、运行和测试

使用 cargo run 命令编译此示例并运行它。使用 curl 向服务发送请求。在第一个请求中,我们将使用均匀分布生成一个随机数:

$ curl --header "Content-Type: application/json" --request POST \
 --data '{"distribution": "uniform", "parameters": {"start": -100, "end": 100}}' \
 http://localhost:8080/random

我们向 localhost:8080/random URL 发送了一个 POST 请求,带有 JSON 主体。这将返回 {"value":-55.0}

下一个命令请求对 "1234567890" 二进制字符串进行 Base64 打乱:

$ curl --header "Content-Type: application/json" --request POST \
 --data '{"distribution": "shuffle", "parameters": { "data": "MTIzNDU2Nzg5MA==" } }' \
 http://localhost:8080/random

预期的响应将是 {"bytes":"MDk3NjgxNDMyNQ=="}, 这等于字符串 "0976814325". 你将得到这个请求的另一个值。

下一个请求将采用一个随机颜色:

$ curl --header "Content-Type: application/json" --request POST \
 --data '{"distribution": "color", "parameters": { "from": "black", "to": "#EC670F" } }' \
 http://localhost:8080/random

这里,我们使用了颜色值的两种表示:字符串值 "black" 和十六进制值 "#EC670F"。响应将类似于 {"color":"#194A09"}

最后一个示例显示了如果我们尝试发送一个不支持值的请求会发生什么:

$ curl --header "Content-Type: application/json" --request POST \
 --data '{"distribution": "gamma", "parameters": { "shape": 2.0, "scale": 5.0 } }' \
 http://localhost:8080/random

由于服务不支持 "gamma" 分布,它将返回错误信息 "unknown variant gamma, expected one of uniform, normal, bernoulli, shuffle, color at line 1 column 24"

多种格式的微服务

有时微服务必须灵活并支持多种格式。例如,一些现代客户端使用 JSON,但有些需要 XML 或其他格式。在本节中,我们将通过添加 Concise Binary Object RepresentationCBOR)序列化格式来改进我们的微服务。

CBOR是一种基于 JSON 的二进制数据序列化格式。它更紧凑,支持二进制字符串,运行速度更快,并被定义为标准。您可以在tools.ietf.org/html/rfc7049上了解更多信息。

不同的格式

我们需要两个额外的包:queryst用于从查询字符串中解析参数,以及支持 CBOR 序列化格式的serde_cbor包。将这些添加到您的Cargo.toml中:

queryst = "2.0"
 serde_cbor = "0.8"

此外,在main.rs中导入它们:

extern crate queryst;
extern crate serde_cbor;

我们不会在处理程序中直接使用serde_json::to_string,而是将其移动到一个单独的函数中,该函数根据预期的格式序列化数据:

fn serialize(format: &str, resp: &RngResponse) -> Result<Vec<u8>, Error> {
    match format {
        "json" => {
            Ok(serde_json::to_vec(resp)?)
        },
        "cbor" => {
            Ok(serde_cbor::to_vec(resp)?)
        },
        _ => {
            Err(format_err!("unsupported format {}", format))
        },
    }
}

在这段代码中,我们使用了一个匹配表达式来检测格式。值得注意的是,我们将String结果更改为二进制类型Vec<u8>。我们还使用了failure::Error作为错误类型,因为serde_jsonserde_cbor都有自己的错误类型,我们可以使用?运算符将它们转换为通用错误。

如果提供的格式未知,我们可以使用failure包的format_err!宏构建一个Error。这个宏就像println!函数一样工作,但它基于字符串值创建一个通用错误。

我们还在导入部分更改了Error类型。之前它是来自hyper包的hyper::Error类型,但现在我们将使用failure::Error类型,并为错误使用包名前缀。

解析查询

HTTP URI 可以包含一个查询字符串,其中包含我们可以用来调整请求的参数。Request类型有一个uri方法,如果可用,它返回一个查询字符串。我们添加了queryst包,它将查询字符串解析为serde_json::Value。我们将使用这个值从查询字符串中提取"format"参数。如果没有提供格式,我们将使用默认值"json"。将格式提取块添加到处理/random路径请求的分支中,并使用我们之前声明的serialize函数:

(&Method::POST, "/random") => {
    let format = {
        let uri = req.uri().query().unwrap_or("");
        let query = queryst::parse(uri).unwrap_or(Value::Null);
        query["format"].as_str().unwrap_or("json").to_string()
    };
    let body = req.into_body().concat2()
        .map(move |chunks| {
            let res = serde_json::from_slice::<RngRequest>(chunks.as_ref())
                .map(handle_request)
                .map_err(Error::from)
                .and_then(move |resp| serialize(&format, &resp));
            match res {
                Ok(body) => {
                    Response::new(body.into())
                },
                Err(err) => {
                    Response::builder()
                        .status(StatusCode::UNPROCESSABLE_ENTITY)
                        .body(err.to_string().into())
                        .unwrap()
                },
            }
        });
    Box::new(body)
},

此代码从查询字符串中提取格式值,处理请求,并使用所选格式返回序列化值。

检查不同的格式

编译代码,运行它,并使用curl检查结果。首先,让我们检查传统的 JSON 格式:

$ curl --header "Content-Type: application/json" --request POST \
 --data '{"distribution": "uniform", "parameters": {"start": -100, "end": 100}}' \
 "http://localhost:8080/random?format=json"

这将返回我们之前看到的 JSON 响应:{"value":-19.0}。下一个请求将返回一个 CBOR 值:

$ curl --header "Content-Type: application/json" --request POST \
 --data '{"distribution": "uniform", "parameters": {"start": -100, "end": 100}}' \
 "http://localhost:8080/random?format=cbor"

这个命令不会在控制台打印响应,因为它是以二进制格式。您将看到以下警告消息:

Warning: Binary output can mess up your terminal. Use "--output -" to tell 
Warning: curl to output it to your terminal anyway, or consider "--output 
Warning: <FILE>" to save to a file.

让我们尝试以 XML 格式请求一个响应:

$ curl --header "Content-Type: application/json" --request POST \
 --data '{"distribution": "uniform", "parameters": {"start": -100, "end": 100}}' \
 "http://localhost:8080/random?format=xml"

这已经正确工作;它打印了unsupported format xml来指示它不支持 XML 格式。现在让我们继续讨论serde值的转码,并看看为什么 XML 不是serde广泛支持的格式。

转码

有时,你可能会遇到需要将一种格式转换为另一种格式的情况。在这种情况下,你可以使用serde_transcode crate,它通过标准的serde序列化过程将一种格式转换为另一种格式。该 crate 有一个transcode函数,它期望一个serializer和一个deserializer作为参数。你可以如下使用它:

let mut deserializer = serde_json::Deserializer::from_reader(reader);
let mut serializer = serde_cbor::Serializer::pretty(writer);
serde_transcode::transcode(&mut deserializer, &mut serializer).unwrap();

此代码将传入的 JSON 数据转换为 CBOR 数据。你可以在以下链接中了解更多关于此 crate 的信息:crates.io/crates/serde-transcode

XML 支持

serde对 XML 的支持并不很好。主要原因在于格式的复杂性。它有如此多的规则和例外,以至于你不能用简单形式描述预期的格式。然而,也有一些实现与serde不兼容。以下链接解释了流式传输、读取和写入 XML 数据:crates.io/crates/xml-rscrates.io/crates/quick-xml

另一种与serde基础设施不兼容的格式是 Protocol Buffers (developers.google.com/protocol-buffers/)。开发者经常出于性能原因和为了在不同应用程序中使用一个数据方案而选择此格式。要在 Rust 代码中使用此格式,请尝试使用protobuf crate:crates.io/crates/protobuf

在我看来,出于以下原因,最好使用与 Rust 中的serdecrate 兼容的格式:

  • 在代码中使用它更简单。

  • 结构体不需要方案,因为它们是严格的。

  • 你可以使用一个具有确定协议的独立 crate。

你不应该遵循serde方法的唯一情况是,如果你必须支持一个与serde不兼容的格式,但已在现有服务或客户端中使用。

摘要

在本章中,我们讨论了使用serdecrate 进行序列化和反序列化过程。我们探讨了serde如何支持多种格式,并可以自动为结构体或枚举派生SerializeDeserialize实现。我们实现了一个服务,该服务从以 JSON 格式序列化的传入参数生成随机数。

之后,你学习了如何自己实现这些特性,并为洗牌数组或生成随机颜色添加额外功能。最后,我们讨论了如何在单个处理器中支持多种格式。

在下一章中,你将学习如何使用异步代码的完整潜力,并用 Rust 编写可以同时处理数千个客户端的微服务。

第五章:使用 Futures Crate 理解异步操作

Rust 是一种现代语言,有许多我们可以用来实现微服务的途径和 crate。我们可以将这些分为两类——同步框架和异步框架。如果你想编写同步微服务,你可以将处理程序实现为一系列表达式和方法调用。但在 Rust 中编写异步代码很困难,因为它不使用垃圾回收器,你必须考虑所有对象的生命周期,包括回调。这不是一个简单的任务,因为你不能在任何代码行上停止执行。相反,你必须编写不会长时间阻塞执行的代码。这个挑战可以通过futures crate 优雅地解决。

在本章中,你将了解futures crate 的工作原理。我们将研究两种基本类型——FutureStream。我们还将探索多生产者单消费者MPSC)模块,它是std crate 中类似模块的替代品,但支持异步访问通道。在本章结束时,我们将创建一个使用FutureStream特质来处理传入数据并将处理结果返回给客户端的微服务。

futures crate 只包含异步原语。我们还将使用tokio crate,它为我们提供异步输入和输出能力,以读取和写入图像文件。

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

  • 基本异步类型

  • 创建图像服务

技术要求

本章需要安装 Rust。我们将使用futurestokio crate 来开发微服务。

你可以在 GitHub 上找到本章项目的源代码:github.com/PacktPublishing/Hands-On-Microservices-with-Rust/tree/master/Chapter04

基本异步类型

微服务可以以两种不同的方式实现——同步和异步。这种方法指的是下一个任务必须在当前任务完成时等待。为了使用代码并行运行任务,我们必须运行一个线程池并在池中的线程中运行任务。异步方法是指使用非阻塞操作,单线程执行多个任务。如果操作无法完成,它将返回一个标志,表示任务尚未完成,我们稍后必须尝试再次运行它。

在过去,Rust 开发者仅使用同步操作,这意味着如果我们想从套接字读取数据,我们必须阻塞一个正在执行的线程。现代操作系统有两种避免阻塞的方法——非阻塞输入/输出函数,以及可扩展的 I/O 事件通知系统,如epoll

异步活动指的是使用工作资源执行多个并发活动的能力。与同步处理程序相比,异步处理程序使用非阻塞操作。如果资源不可用以完成处理程序,它将被挂起,直到下一次尝试获取资源。有一个涉及反应器和承诺的成熟方法。反应器允许开发者在同一线程中运行多个活动,而承诺代表一个稍后可用的延迟结果。反应器保持一组承诺,并继续轮询,直到完成并返回结果。

由于标准 Rust 库不包含用于编写异步应用程序以及与反应器和承诺一起工作的有用模块,您需要一个第三方 crate。这类 crate 的一个例子是 futures crate,我们通过使用 hyper crate 间接使用了这个 crate。现在是时候详细探索这个 crate 了。在本节中,我们将讨论 futures crate 中可用的不同类型,如何使用通道在任务之间传递消息,以及如何使用反应器,这是运行任务所需的。

Future crate 的基本类型

futures crate 的创建是为了为 Rust 开发者提供异步编程的无成本抽象。这个 crate 适应 Rust 的借用系统,并有助于创建在资源可用时轮询资源并返回结果的类型。

对于日常使用,您只需要 futures crate 中的一小部分类型。三个基本类型是 FutureStreamSink。让我们详细探讨这些类型。

使用 Future 特质

Future 是一个特质,它会在未来返回一个结果,并代表一个不能立即完成的操作。与 Result 枚举类似,Future 有两个结果变体,分别由关联类型 ItemError 表示。这个特质有一个 poll 方法,用于检索结果。这个方法将由一个反应器调用,直到它返回 ErrorAsync::Ready 值。Async 是一个枚举,它有 ReadyPending 两种变体,用于表示异步操作的结果。Ready 表示值已准备好使用,而 Pending 表示值尚未可用,将在稍后准备好。

如您所见,Future 用于与 Result 类似的目的。然而,与 Result 不同,Future 是一个特质,这意味着实现未指定,许多类型都可以实现它。一个有用的特性是 FutureExt 特质,它可以应用于所有 Future 实例。这个特质提供了多种方法以延迟方式处理结果。例如,如果我们想将获取到的值转换为另一种类型,这个特质提供了一个 map 方法来完成这个目的。让我们看一下以下内容:

let fut = future::ok(10u64);
let mapped_fut = fut.map(|x| x as f64);

在这个例子中,我们从一个常量创建了一个FutureResult结构体。此类型实现了Future特质,并代表一个立即准备好的值。之后,我们为FutureResult调用了FutureExt中的map方法,它期望一个闭包并返回。

对于实现Future特质的类型,你必须使用反应器来获取结果。我们将在本节后面讨论反应器。记住,你不能立即获取结果并在下一个表达式中使用它;相反,你必须创建未来或流的链来获取适当的结果。继续阅读!我们现在将探讨Stream特质。

使用 Stream 特质

Stream是一个特质,它表示一系列延迟的项目。它的工作方式与Iterator特质类似,但它使用poll方法来获取下一个Item或在失败的情况下返回Error。流可以是来自套接字的数据或可以从文件中读取的数据。如果Future实例返回一个Stream,则可以将Stream转换为Future,反之亦然。

为了有效地使用流,你应该学习StreamExt特质的用法。这让你可以创建一个链来处理流中的每个项目,甚至可以将多个流合并为一个。例如,你可以使用带有谓词的filter方法从Stream中过滤一些元素:

let stream = stream::iter_ok::<_, ()>(vec![-1, 0, 1, 2, 3]);
let filtered_stream = stream.filter(|x| x > 0);

iter_ok方法从Iterator创建一个Stream。如果你想从一个Vec实例提供自己的值,这很有用。

一个有用的特性是将包含Stream结果的Future实例转换为仅Stream。例如,当你尝试使用tokio包中的TcpStream::connect方法通过 TCP 连接时,它将返回ConnectFuture,该实例实现了Future特质并返回一个TcpStream实例。

使用 Sink 发送数据

FutureStream对象从源提供数据,但如果你想向源发送数据,你必须使用Sink对象。Sink是一个与Stream类似的特质,但工作方向相反。它包含两个关联类型——SinkItemSinkError。第一个确定可以使用特定Sink发送的项目类型。第二个表示发送过程出错时的错误。要与Sink交互,你应该使用SinkExt特质的send方法,它包含将项目发送给接收者的send方法。send方法返回一个实现Future特质的Send结构体,这意味着你不能立即发送项目。send方法的调用返回一个必须由反应器执行的Future。如果你不关心发送过程的结果,你可以使用spawn方法在单独的任务中发送该Future

Sink 对象与 Stream 一起提供,您必须调用 StreamExt 的 split 方法来获取一个连接到流的 Sink 实例。这个调用返回一个包含 SplitSinkSplitStream 对象的元组。这些是让您能够同时读取输入和写入输出的必要条件。稍后,这些都可以通过这些对象中的任何一个的 reunite 方法重新组合。如果您正在编写复杂的交互,您可能需要多次使用 Sink 特性。每次都使用 split 来做这件事很难,但有两种替代方法可以使用。第一种是在单独实现的 Stream 特性中实现所有交互,并使用 poll 方法与 StreamSink 一起工作。第二种方法是将 Sink split 并与通道的 Receiver 对象 join。然后您可以使用这个通道的 Sender 发送项目,而无需每次都分割流。我们将在下一节中实现这种交互的示例,其中我们将讨论通道。

通道模块

并发活动通常需要相互交互。您可能已经熟悉标准库中的 mpsc 模块,它使用阻塞操作在通道中发送,但这不适合任何操作阻塞工作线程时完全阻塞的复杂反应器。然而,幸运的是,futures 包中有一个 channel 模块,它能够执行跨任务通信。channel 模块包含两个模块——mpsconeshot。让我们看看这两个模块。

发送多个消息的通道

通常,通道是一种单向交互原语。通道有一个发送消息的发送者和一个提取消息的接收者。内部,通道作为一个受原子标志或无锁数据类型保护免受数据竞争(当两个或多个线程尝试写入相同的内存单元格时)的数组或列表。通道实现了我们在以下章节中将要讨论的队列访问模式之一。

单生产者单消费者

这种方法意味着只有一个生产者可以发送消息,只有一个消费者可以读取它们。在 Rust 中,这意味着我们有一个单一的 Sender 和一个单一的 Receiver,它们都不能被克隆。标准库有一个内部实现的 单生产者单消费者SPSC)队列,但这种类型对用户不可用。如果您需要这种类型的队列,请尝试 bounded-spsc-queue 包。

多生产者单消费者

这是在 Rust 中最受欢迎的队列类型。标准库和 futures 包都提供了这种类型的通道。它之所以受欢迎,是因为通道通常用于为其他多个线程提供访问单个线程中存在的资源的权限。对于这种类型的队列,Sender 可以被克隆,但 Receiver 不能。

多生产者多消费者

这种类型的队列允许我们使用任何数量的线程的 SenderReceiverSenderReceiver 都可以被克隆并在多个线程中使用。如果有多个线程从 Receiver 读取消息,你无法预测哪个线程将获得特定的消息。你可以在 crossbeam-channel crate 中找到这个功能。

使用示例

要从一个线程向另一个线程发送消息,你可能会使用标准库中的 mpsc 模块。futures crate 中的 mpsc 模块以类似的方式工作,但当你调用 send 方法向消息流发送一个项目时,Sender 返回 Sink 实例。Receiver 实现了 Stream 特性,这意味着你必须使用一个反应器来轮询流以获取新消息:

fn multiple() {
    let (tx_sink, rx_stream) = mpsc::channel::<u8>(8);
    let receiver = rx_stream.fold(0, |acc, value| {
        future::ok(acc + value)
    }).map(|x| {
        println!("Calculated: {}", x);
    });
    let send_1 = tx_sink.clone().send(1);
    let send_2 = tx_sink.clone().send(2);
    let send_3 = tx_sink.clone().send(3);
    let execute_all = future::join_all(vec![
        to_box(receiver),
        to_box(send_1),
        to_box(send_2),
        to_box(send_3),
    ]).map(drop);
    drop(tx_sink);
    tokio::run(execute_all);
}

在这个例子中,我们创建了一个传递 u8 类型消息的通道。我们使用了 Receiverfold 方法来添加所有值,并在通道关闭时打印结果。我们使用 Sender 将值发送到 Receiver。最后,我们使用 future::join_all 方法将所有 futures 组合到一个单独的未来中,并将结果未来传递给 tokio crate 的执行器。join_all 函数期望一个实现了 Future 特性的特定类型的 Vec。我们添加了 to_box 函数,该函数将类型转换为具有 IntoFuture 特性的 Future,丢弃结果和错误,并将其装箱:

fn to_box<T>(fut :T) -> Box<dyn Future<Item=(), Error=()> + Send>
where
    T: IntoFuture,
    T::Future: Send + 'static,
    T::Item: 'static,
    T::Error: 'static,
{
    let fut = fut.into_future().map(drop).map_err(drop);
    Box::new(fut)
}

要关闭 Sender,我们只需要将其丢弃。如果我们不丢弃 Sender,通道将保持打开状态,tokio::run 将永远不会完成。

单次发送

oneshot 模块实现了一个单条消息的通道。它也有自己的 SenderReceiver 类型,但它们的工作方式不同。Sender 有一个 send 方法,它完成 oneshot 并完全消耗一个实例。Sender 不需要实现 Sink 特性,因为我们不能发送多个项目。它有一个预分配的单元格用于放置一个项目,该项目将立即放入单元格中,我们没有任何队列。

Receiver 实现了 Future 特性,这意味着你必须使用一个反应器来从它获取一个项目:

fn single() {
    let (tx_sender, rx_future) = oneshot::channel::<u8>();
    let receiver = rx_future.map(|x| {
        println!("Received: {}", x);
    });
    let sender = tx_sender.send(8);
    let execute_all = future::join_all(vec![
        to_box(receiver),
        to_box(sender),
    ]).map(drop);
    tokio::run(execute_all);
}

在这个例子中,我们为 oneshot 通道创建了一个 Sender 和一个 Receiver。发送者是一个将被 send 方法调用消耗的对象。Receiver 实现了 Future 特性,我们可以使用 map 方法来访问一个值。

之前,我们提到我们可以从多个来源向 Sink 发送消息。让我们使用通道实现这个示例。

在多个地方使用通道来使用 Sink

如前所述,你可以使用通道从不同的地方和任何时间发送带有 Sink 的数据。看看以下示例:

fn alt_udp_echo() -> Result<(), Error> {
    let from = "0.0.0.0:12345".parse()?;
    let socket = UdpSocket::bind(&from)?;
    let framed = UdpFramed::new(socket, LinesCodec::new());
    let (sink, stream) = framed.split();
    let (tx, rx) = mpsc::channel(16);
    let rx = rx.map_err(|_| other("can't take a message"))
        .fold(sink, |sink, frame| {
            sink.send(frame)
        });
    let process = stream.and_then(move |args| {
        tx.clone()
            .send(args)
            .map(drop)
            .map_err(other)
    }).collect();
    let execute_all = future::join_all(vec![
        to_box(rx),
        to_box(process),
    ]).map(drop);
    Ok(tokio::run(execute_all))
}

此示例创建了一个UdpSocket实例,它代表一个 UDP 套接字,并将其绑定到0.0.0.0:12345地址。之后,我们使用UdpFramed类型包装套接字,该类型实现了一个由提供的编解码器生成数据的Stream。我们将使用来自tokio::codec模块的LinesCodec。它读取输入并使用行分隔符将数据分割成代表文本行的片段。

我们将拆分封装的流并创建一个通道,以便从不同的地方发送 UDP 数据报。我们将在下一节熟悉通道模块,并学习任务如何通过SenderReceiver对象异步交互。

channel方法返回SenderReceiver对象。我们使用Receiver将所有传入的消息转发到 UDP 连接的Sink,并从流中读取所有数据,然后通过通道将其发送回去。这个回声服务器可以不使用通道更有效地实现,但我们在这里使用它们是为了演示目的。要发送消息,我们使用了创建的通道的Sender。这种方法的优势在于您可以在任何地方克隆并使用发送器实例,在任何时候向通道发送消息。

有时,FutureStream在它们的ItemError类型参数方面有所不同。为了解决这个问题,我们添加了一个other方法,该方法将任何错误实例包装为io::Error类型。我们使用此函数将一个错误类型转换为另一个类型:

fn other<E>(err: E) -> io::Error
where
    E: Into<Box<std::error::Error + Send + Sync>>,
{
    io::Error::new(io::ErrorKind::Other, err)
}

您可以编译这个回声服务器,并使用 netcat 实用程序检查其工作情况。如果您操作系统中尚未包含它,您应该安装它。使用带有--verbose(简称:-v)、--udp(简称:-u)和--no-dns(简称:-n)参数的nc命令并输入任何文本。例如,我们输入了"*Text Message*"

$ nc -vnu 0.0.0.0 12345
Ncat: Version 7.60 ( https://nmap.org/ncat )
Ncat: Connected to 0.0.0.0:12345.
Text Message
Text Message
^C

如您所见,服务器已将提供的字符串发送回我们。所有这些示例都使用执行器来并发运行任务。在我们开始实现服务器之前,让我们先了解执行器是如何工作的。

执行器

由于异步任务可以在单个线程中执行,我们需要一种方法来执行所有任务,即使某些任务在执行过程中生成新的任务。运行所有任务有两种方法:

  • 使用阻塞直接运行未来和收集流

  • 使用执行器运行未来和流

让我们在以下部分中探索它们。

使用阻塞运行未来和流

第一种方法是使用executor模块的block_onblock_on_stream函数。这两个函数都会阻塞当前线程以等待结果。这是一个简单但不太灵活的方法,但在以下情况下非常出色:

  • 如果您只有一个任务

  • 如果您的任务中没有读取或写入流

  • 如果您希望从可以阻塞的单独线程中完成任务

你应该记住,不要在异步代码中调用此函数,因为调用将阻塞执行器,你的程序将停止工作。

使用执行器

第二种方法是使用 Executor 实例执行所有任务。这允许你在单个线程中运行多个任务,即使某些任务不能立即完成。要使用 Executor,你必须创建并运行它,但它将阻塞当前线程,你应该在开始时添加所有要执行的任务。

例如,如果你想打开一个套接字并处理每个传入连接的流,你必须创建一个主要的 Future,它将读取传入连接的 Stream,并使用 tokio::spawn 方法为处理连接数据的 Stream 创建一个处理器。创建后,你必须使用执行器 spawn 整个处理 Future。请看以下示例:

fn send_spawn() {
    let (tx_sink, rx_stream) = mpsc::channel::<u8>(8);
    let receiver = rx_stream.fold(0, |acc, value| {
        println!("Received: {}", value);
        future::ok(acc + value)
    }).map(drop);
    let spawner = stream::iter_ok::<_, ()>(1u8..11u8).map(move |x| {
        let fut = tx_sink.clone().send(x).map(drop).map_err(drop);
        tokio::spawn(fut);
    }).collect();
    let execute_all = future::join_all(vec![
        to_box(spawner),
        to_box(receiver),
    ]).map(drop);
    tokio::run(execute_all);
}

在这个例子中,我们创建了一个通道。我们还使用 stream::iter_ok 方法从一个整数序列创建了一个流。我们将流的所有项目发送到通道,该通道读取所有传入的值并将它们打印到控制台。我们已处理了一个类似的例子。在当前版本中,我们使用 tokio::spawn 函数在当前线程的执行器中创建任务。

正如你所见,要使用 futures crate,你必须构建处理器的链。生成的代码难以维护和改进。为了简化异步代码,Rust 编译器已经开始支持 async/await 语法。

async/await 语法

一些编程语言,如 JavaScript 和 C#,有 asyncawait 操作符,可以帮助编写看起来像同步代码的异步代码。Rust 编译器的夜间版本支持一种新语法,并为语言添加了 asyncawait(实际上,这是一个宏)关键字,以简化异步应用程序的编写。新代码可能如下所示:

async fn http_get(addr: &str) -> Result<String, std::io::Error> {
    let mut conn = await!(NetwrokStream::connect(addr))?;
    let _ = await!(conn.write_all(b"GET / HTTP/1.0\r\n\r\n"))?;
    let mut buf = vec![0;1024];
    let len = await!(conn.read(&mut buf))?;
    let res = String::from_utf8_lossy(&buf[..len]).to_string();
    Ok(res)
}

这还不是稳定的,发布前可能会更改。async 是一个新关键字,它将标准函数转换为异步。await! 是一个在不稳定 Rust 版本中内置的宏。它暂停函数的执行,并等待作为 await! 参数提供的 Future 实例的结果。此宏使用生成器功能来中断执行,直到 await! 下的 Future 完成。

在本章的剩余部分,我们将查看一个使用流来处理传入和传出数据的代理。

创建图像服务

在本节中,我们将创建一个微服务,允许客户端上传图像,然后下载它们。首先,我们实现一个处理程序来异步上传图像并将它们保存到文件系统中,使用 tokio crate。之后,我们将实现一个下载处理程序,允许用户从之前上传的文件中下载原始图像。

上传图像

让我们开始实现一个微服务,该服务可以存储和提供具有上传文件功能的图像。为了获取传入的文件,我们必须读取 Request 的一个传入 StreamStream 可能非常大,所以我们不应该将整个文件保留在内存中。我们将分块读取传入的数据,并立即将它们写入文件。让我们创建微服务的 main 函数:

fn main() {
    let files = Path::new("./files");
    fs::create_dir(files).ok();
    let addr = ([127, 0, 0, 1], 8080).into();
    let builder = Server::bind(&addr);
    let server = builder.serve(move || {
        service_fn(move |req| microservice_handler(req, &files))
    });
    let server = server.map_err(drop);
    hyper::rt::run(server);
}

这看起来像我们创建的其他示例,但在这里我们将 std::path::Path 设置为一个将保留所有传入文件的目录。我们将使用 std::fs 模块的 create_dir 函数使用我们之前设置的路径创建目录。如果目录创建失败,我们将忽略它,但对于生产代码来说,最好停止创建服务器并返回一个 Error 或打印必要的信息。 这适用于演示目的,但不可靠,因为本地存储的文件可能会在服务器中丢失,并且您的服务可能会损坏。在现实中的微服务中,您可能更喜欢使用第三方服务,例如 AWS S3,来存储和向客户端提供文件。

在我们创建一个目录来存储文件后,我们将启动一个 Server,它将使用我们稍后定义的 microservice_handler。请注意,当我们传递一个 Path 的引用时。如果想要使用命令行参数设置另一个文件夹,将路径作为参数提供是有用的。

我们现在可以定义将处理四个情况的 microservice_handler 函数:

  • / 路径上返回索引页面

  • 将文件存储到 /upload 路径

  • 通过 /download 路径返回上传的文件

  • 对于其他请求返回 404 错误

函数的定义如下:

fn microservice_handler(req: Request<Body>, files: &Path)
    -> Box<Future<Item=Response<Body>, Error=std::io::Error> + Send>

这与我们在 第二章 中使用的处理程序定义相似,使用 Hyper Crate 开发微服务,以及 第三章 中使用的,记录和配置您的微服务,但我们使用 std::io::Error 而不是 hyper::Error。这是因为我们不仅与请求和响应一起工作,我们还使用一个可能会引起其他类型错误的文件系统。我们还期望一个 Path 类型的参数来确定我们将存储文件的目录。

让我们添加一个 match 表达式来匹配传入请求的参数。在这里,我们将考虑两个分支——第一个是当客户端向根路径发送 GET 请求时,第二个是对于所有其他请求。我们稍后会添加其他分支:

match (req.method(), req.uri().path().to_owned().as_ref()) {
    (&Method::GET, "/") => {
        Box::new(future::ok(Response::new(INDEX.into())))
    },
    _ => {
        response_with_code(StatusCode::NOT_FOUND)
    },
}

我们在 第二章 中使用了类似的模式匹配,使用 Hyper Crate 开发微服务。之前,我们有一个 match 表达式来检查传入请求的方法和路径。这次,我们需要 Uri::path 的一个副本,因为我们稍后需要在其他分支的正则表达式中使用路径副本。

response_with_code 函数现在返回一个 Future 实例,而不是 Request

fn response_with_code(status_code: StatusCode)
    -> Box<Future<Item=Response<Body>, Error=Error> + Send>
{
    let resp = Response::builder()
        .status(status_code)
        .body(Body::empty())
        .unwrap();
    Box::new(future::ok(resp))
}

让我们将剩余的分支添加到match表达式中。让我们添加一个来处理文件的上传:

(&Method::POST, "/upload") => {
    let name: String = thread_rng().sample_iter(&Alphanumeric).take(20).collect();
    let mut filepath = files.to_path_buf();
    filepath.push(&name);
    let create_file = File::create(filepath);
    let write = create_file.and_then(|file| {
        req.into_body()
            .map_err(other)
            .fold(file, |file, chunk| {
            tokio::io::write_all(file, chunk)
                .map(|(file, _)| file)
        })
    });
    let body = write.map(|_| {
        Response::new(name.into())
    });
    Box::new(body)
}

这个请求处理分支期望的是POST方法和"/upload"路径。我们不检查用户凭证,并允许每个人上传文件,但在实际的微服务中,你应该过滤传入流量以避免垃圾邮件或恶意使用。

在分支的第一行,我们为传入的文件生成一个随机名称。我们可以给客户端提供设置文件名称的机会,但这是一种危险的做法。如果你不检查传入请求的路径,客户端可以从服务器上的任何文件夹请求一个文件。我们使用rand crate 中的thread_rng函数调用获取一个实现了Rng特质的随机数生成器实例。之后,我们使用生成器通过Rng特质的sample_iter方法调用获取一个样本的Iterator,并向它提供一个生成随机字符和数字的Alphanumeric分布。我们从迭代器中取出 20 个项并将它们收集到一个String中。然后,我们使用to_path_buf方法将files变量转换为PathBuf,并将生成的文件名添加到路径中。

在下一行,我们使用生成的名称创建一个File。这里隐藏了异步应用程序最重要的区别——我们使用tokio::fs::File类型而不是std::fs::File类型,因此我们返回一个Future实例而不是文件引用。当文件创建完成后,这个Future将被完成。之后,我们使用创建的文件异步地向该文件写入一些数据。tokio::fs::File类型封装了std::fs::File,但实现了AsyncReadAsyncWrite特质。在任何时候,你都可以调用into_std方法来解包标准的File类型。然而,在我们这样做之前,我们将传入的流写入创建的文件。让我们更仔细地看看tokio crate 以及与文件异步读写相关的一些重要问题。

tokio crate

tokio crate 提供了以异步方式处理文件网络连接的功能。它包括 TCP 和 UDP 套接字的包装器——TcpStreamUdpSocket。它还包括通过FutureStream特质访问文件系统的类型。没有跨平台的异步文件处理方法,因为操作系统有自己的非阻塞 API 实现。然而,一些操作系统根本就没有好的异步 API。为了提供跨平台的文件系统异步访问,tokio使用tokio_threadpool crate,它有一个blocking方法,在单独的线程中运行任务。这有助于实现可以使用输入/输出操作阻塞线程的类型异步交互。这不是与文件系统交互最有效的方法,但它确实允许我们将同步 API 转换为异步。tokio crate 还包含一个Executor特质和一个Timer模块。我们之前已经考虑过执行器。timer模块包含TimeoutInterval类型,用于创建在指定时间间隔过去时产生值的FutureStream

文件的异步输入/输出

现在我们必须创建一个链来读取所有传入的块并将它们写入创建的文件。正如你可能记得的,File::create返回一个返回File实例的Future。我们不会立即获取结果,因为 I/O 操作需要一些时间,可能会阻塞当前运行的线程。我们必须使用Future::and_then方法将结果(当它准备好时)移动到其他Future实例,该实例将所有块发送到文件。为此,我们将使用通过req变量中存储的Requestinto_body方法调用获得的Body实例。Body实现了Chunk实例的Stream,但它可以产生一个hyper::Error。由于File::create可以产生一个io::Error,我们必须使用other函数调用将hyper::Error转换为io::Error,如下所示:

fn other<E>(err: E) -> Error
where
    E: Into<Box<std::error::Error + Send + Sync>>,
{
    Error::new(ErrorKind::Other, err)
}

之前的功能根据提供的单个参数中的任何Error创建一个带有ErrorKind::Otherio::Error。我们使用StreamExtmap_err函数与other函数一起将流的失败转换为io::Error。当BodyStream与错误类型兼容时,我们可以创建一个Future,它将传入的二进制数据移动到文件中。为此,我们可以使用StreamExt特质的fold方法。如果你熟悉函数式编程,你可能已经知道它是如何工作的。fold函数接受两个参数——一个初始值,它将在每次迭代中重复使用,以及一个函数,该函数使用初始值执行一些处理。处理函数必须在每次调用时返回一个Future实例,有一个条件——Future必须返回与初始值相同类型的类型。

我们将提供一个 File 实例作为初始值,并将调用 tokio::io::write_all 将请求体的一个数据块写入文件。write_all 函数期望一个输出流和一个二进制切片。它在成功时返回一个 Future,该 Future 返回一个包含输出流和提供的切片的元组。我们必须使用返回的 Futuremap 方法来丢弃切片并保留文件。结果链将 fold 整个 Stream 到一个 Future,当所有数据块都写入文件时,它将返回一个填充的 File 实例。我们将这个 Future 存储到一个变量中,并使用 FutureExtmap 方法来丢弃文件实例(带有写入数据的真实文件将保留在驱动器上),并返回一个带有存储文件名的 Response

我们现在已经成功实现了文件上传。现在我们应该讨论如何使用 HTML 表单上传文件,并为我们服务添加下载功能。

多部分表单请求

到目前为止,在本章中,我们使用了具有二进制体的请求。这对于微服务来说很合适,但如果你想要通过 HTML 表单发送文件,你应该使用具有 multipart/form-data 内容类型的请求。这允许客户端在单个请求中包含多个文件,但它也需要一个解析器来从请求体中分割文件。hyper 包不包含多部分请求的解析器,你可以使用其他包,例如 multipart 包来解析请求。然而,这并不支持异步操作,所以你应该使用与 hyper 包最新版本兼容的 multipart-async 包。你也可以自己实现多部分请求。要实现这一点,你可以创建一个实现 Stream 特性的结构体,并解析传入的数据块。多部分请求具有 multipart/form-data 内容类型,边界值例如 boundary=53164434ae464234f。其体包含一个分隔符和嵌入的文件:

--------------------------53164434ae464234f
Content-Disposition: form-data; name="first_file"; filename="file1.txt"
Content-Type: text/plain
Contents of the file1.txt
--------------------------53164434ae464234f
Content-Disposition: form-data; name="second_file"; filename="file2.txt"
Content-Type: text/plain
Contents of the file2.txt
--------------------------53164434ae464234f

您的流必须实现 Stream<Item=FileEntry>,它读取请求并使用提供的边界提取文件。

下载图片

让我们实现一个分支来下载图片。处理程序可以使用 /download/filename 路径下载文件。为了提取文件名,我们使用正则表达式:

lazy_static! {
    static ref DOWNLOAD_FILE: Regex = Regex::new("^/download/(?P<filename>\\w{20})?$").unwrap();
}

我们将使用 startwith 来检测路径中的 /download 部分。看看实现方式:

(&Method::GET, path) if path.starts_with("/download") => {
    if let Some(cap) = DOWNLOAD_FILE.captures(path) {
            let filename = cap.name("filename").unwrap().as_str();
            let mut filepath = files.to_path_buf();
            filepath.push(filename);
            let open_file = File::open(filepath);
            let body = open_file.map(|file| {
                let chunks = FileChunkStream::new(file);
                Response::new(Body::wrap_stream(chunks))
            });
            Box::new(body)
    } else {
        response_with_code(StatusCode::NOT_FOUND)
    }
}

在这个例子中,我们期望一个GET方法,并检查路径是否与DOWNLOAD_FILE正则表达式匹配。我们使用名称"filename"来提取文件名称的字符串。由于我们有一个包含文件夹路径的filepath变量,我们使用Path实例的to_path_buf方法将Path值转换为PathBuf类型,并将一个文件名推送到它。之后,我们使用tokio crate 的文件类型来打开一个文件,它具有异步读写能力以处理文件内容。文件的open方法返回一个OpenFuture实例,当成功时解析为一个File实例。

我们使用从hyper_staticfile crate 导入的FileChunkStream包装文件。这个流读取一个File并返回字节数据块。主体有一个wrap_stream方法,我们可以将整个流作为响应发送。当流被转发到客户端时,打开的File将在流被丢弃时关闭。

我们最后应该做的是返回一个Body实例。

用于发送文件的 sendfile

从一个文件转发文件到另一个文件并不有效,因为这种方法在发送之前会将每个数据块复制到内存中。流行的服务器如NGINX使用sendfile系统调用来从一个文件描述符发送文件到另一个。这有助于节省大量资源,因为sendfile允许零拷贝,这意味着我们可以直接将缓冲区写入必要的设备。要使用tokiosendfile,你必须为其实现一个包装器,但我不认为用微服务来提供静态文件是一个好主意。你可能更喜欢使用 NGINX 来完成这项任务,或者使用对象存储如AWS S3,它可以为客户端提供静态文件。

测试服务

图像服务现在已准备好进行测试。编译它,从互联网下载任何图片,并使用curl将其上传到我们的服务:

$ curl https://www.rust-lang.org/logos/rust-logo-128x128.png | curl -X POST --data-binary @- localhost:8080/upload

I4tcxkp9SnAjkbJwzy0m

这个请求下载 Rust 标志并将其上传到我们的微服务。它将以响应返回上传的图像名称。将其放在/download/路径之后,并尝试用你的浏览器下载它:

图片

摘要

在本章中,我们研究了futurestokio crate。futures crate 包含用于处理延迟结果和流的类型。我们比较了FutureResult类型以及StreamIterator类型。之后,我们实现了一个存储图像并将其发送回客户端的微服务。

我们将在第十章背景任务和线程池在微服务中中改进本章的微服务,使用线程和后台任务。但在下一章,我们将探讨反应式微服务和使用远程过程调用作为实现微服务的替代方法。

第六章:反应式微服务 - 提高容量和性能

如果您为您的应用程序采用微服务架构,您将获得松耦合的好处,这意味着每个微服务都足够独立,可以由不同的团队开发和维护。这是一种对业务任务的异步方法,但并非唯一的好处;还有其他好处。您可以通过仅扩展承担巨大负载的微服务来提高容量和性能。为了实现这一点,您的微服务必须是反应式的,并且必须是自维持的,通过消息传递与其他微服务进行交互。

在本章中,您将了解什么是反应式微服务,以及如何使用消息传递来确保微服务的连通性。此外,我们还将讨论反应式微服务是否可以是异步的。

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

  • 什么是反应式微服务?

  • JSON-RPC

  • gRPC

技术要求

本章将介绍在 Rust 中使用远程过程调用(RPCs)。您需要一个工作的 Rust 编译器,因为我们将会使用jsonrpc-http-servergrpc包创建两个示例。

如果您想测试 TLS 连接,您需要 OpenSSL 版本 0.9,因为grpc包目前还不支持 1.0 或更高版本。大多数现代操作系统已经切换到 1.0,但您可以将示例构建到支持版本 0.9 的 Docker 镜像中,或者等待grpc包更新到最新的 OpenSSL 版本。我们将构建不带 TLS 的测试示例。

您可以从本章的 GitHub 仓库中找到示例的来源,网址为github.com/PacktPublishing/Hands-On-Microservices-with-Rust/tree/master/Chapter06

什么是反应式微服务?

微服务架构意味着应用程序中存在多个部分。在过去,大多数应用程序都是单体,所有部分都包含在单个代码库中。微服务方法为我们提供了将代码库分割到多个团队和开发者的机会,为每个微服务提供一个单独的生命周期,以及部分通过一个通用协议进行交互。

这是否意味着您的应用程序将完全摆脱单体应用程序的所有缺陷?不。您可以编写相互之间如此紧密相关的微服务,以至于您甚至无法正确地更新它们。

这是如何实现的?想象一下,您有一个微服务需要等待另一个微服务的响应才能向客户端发送响应。反过来,另一个微服务也必须等待另一个微服务的响应,依此类推。如果您将应用程序的微服务紧密关联起来,您会发现与单体相同的缺点。

你必须编写微服务作为可以用于多个项目(而不仅仅是你的项目)的独立应用程序。你如何实现这一点?开发一个反应式微服务。让我们看看这是什么。

松散耦合

松散耦合是一种软件开发方法,意味着应用程序的每个部分应该对其他部分了解很少的信息。在传统应用程序中,如果你编写一个 GUI 组件,例如一个按钮,它必须可以在任何地方、任何应用程序中使用。作为另一个例子,如果你开发一个用于处理声音硬件的库,这也意味着你可以与每个应用程序一起使用;该库不限于在一种应用程序中使用。然而,在微服务中,松散耦合意味着一个微服务不知道其他微服务或它们的数量。

如果你开发一个应用程序,你应该将其部分——微服务——编写为独立的应用程序。例如,一个发送推送通知到移动平台的通告服务不会知道 CRM、会计或甚至用户。这是否可能?是的!你可以编写一个使用通用协议并通过消息传递与其他服务交互的微服务。这被称为消息驱动应用程序

消息驱动应用程序

传统的微服务有一个立即返回结果的 API,并且一个工作应用程序的每个参与者都必须了解其他部分。这种方法使微服务之间的关系保持紧密。这样的应用程序难以维护、更新和扩展。

如果你的应用程序通过由其他微服务处理的消息进行交互,那就更加方便了。当你使用消息作为交互单元时,这种方法被称为消息驱动。消息帮助你减少微服务的耦合,因为你可以同时为多个服务处理一条消息,或者为特定消息类型添加额外的处理消息。

要有完全解耦的微服务,你应该使用消息队列或消息代理服务。我们将在第十二章,可扩展微服务架构中详细了解这种方法,其中我们讨论了可扩展架构。

异步

由于反应式微服务使用消息传递,你必须异步处理消息。这并不意味着你必须使用异步 crate,如tokiofutures。但这意味着没有任何一条消息可以阻塞服务;每条消息都在短时间内被处理,如果服务必须执行长时间的任务,它应该将其作为后台任务执行,并通过发送包含该结果的消息来通知执行该任务的线程。为了实现这种行为,你可以使用多个线程而不需要异步代码。但对于反应式微服务来说,使用异步代码又是怎样的情况呢?

反应式微服务应该是异步的吗?

很常见的情况是,由于异步应用程序被错误地称为反应式,因为它们的事件循环等待外部事件,在等待时不会浪费服务器资源。反应式微服务不会为了保持传入连接以返回结果而浪费资源,因为当客户端短暂连接到反应式微服务时,它会提交任务并断开连接。在客户端等待微服务的异步响应后。反应式微服务不需要是异步的,但它们可以是。

当你选择消息传递进行交互时,你必须考虑到微服务必须是异步的,并且可以同时处理多个消息。传统的同步代码不能像异步代码那样处理那么多消息,因为同步代码必须等待 I/O 资源,而异步代码则对 I/O 事件做出反应,并尽可能利用资源。

更简单地说,如果你的微服务必须处理数十万条消息,你应该使用异步代码。如果你的微服务没有重负载,使用一个适度的同步算法就足够了。

基于未来和流的反应式微服务

如果你已经决定使用异步代码实现反应式微服务,你可以考虑使用未来库作为基础。这个库为你提供了构建反应式链的类型,以异步方式处理所有消息。但请记住,手动编写所有Future实例可能很困难。Rust 编译器即将推出一个新功能,提供了async/await运算符,通过编写具有Result返回类型的传统函数来简化Future特质的实现。这个特性是不稳定的,我们不会在本书中考虑它。

如果你不想编写低级代码,我推荐你使用actix库。这是一个非常好的框架,让你可以像编写同步代码一样编写异步代码。

如果你需要最低级别的控制,你可以使用作为futures库基础的mio库。它为你提供了对 I/O 事件的完全控制,你可以从服务器的资源中榨取最大速度。

消息代理

消息代理让你可以将所有消息发送到中央点,该点路由和将消息传递到必要的微服务。在某些情况下,这可能会成为瓶颈,因为全部负载将落在单个应用程序——消息代理上。但在大多数情况下,这是一个很好的方法,可以帮助你解耦微服务,并几乎无感知地更新任何微服务。

要使用消息代理,只需要支持 AMQP 协议即可。所有流行的消息代理都与该协议兼容。lapin-futures库提供了类型和方法,通过futures库的 API 使用 AMQP 协议。如果你想要使用mio库的低级控制,可以使用lapin-async库。

远程过程调用

如果你想要直接连接微服务,你可以使用 RPC 来允许一个服务的功能被另一个服务远程调用。有许多 RPC 框架,它们具有不同的格式和速度潜力。让我们看看一些流行的协议。

JSON-RPC

JSON-RPC 协议使用序列化为 JSON 格式的消息。它使用一种特殊的请求和响应格式,这里进行了描述:www.JSON-RPC.org/specification。该协议可以使用不同的传输方式,例如 HTTP、Unix 套接字,甚至是 stdio。在本章的后面,你会找到一个使用此协议的示例。

gRPC

gRPC 协议是由 Google 创建的,它使用 Protocol Buffer 序列化格式来处理消息。此外,该协议基于HTTP/2传输的优点,允许你实现卓越的性能。你可以在grpc.io/docs/找到更多关于该协议的信息。在本章的后面,你还会找到一个使用此协议的示例。

Thrift

Apache Thrift 是由 Facebook 开发的一种二进制通信协议。尽管该协议是二进制的,但它支持许多语言,如 C++、Go 和 Java。支持的传输方式包括文件、内存和套接字。

其他 RPC 框架

还有其他 RPC 协议,如 Cap'n Proto、XML-RPC,甚至是古老的 SOAP。有些为 Rust 提供了实现,但我建议在 JSON-RPC、gRPC 和 Thrift 之间进行选择,因为它们是最常用于微服务的。

RPC 和 REST

你可能会问,是否可以使用 REST API 或传统的 Web API 来实现反应式微服务。当然——可以!你可以有两种方式来做这件事:

  • 有网关可以将 REST 请求转换为 JSON-RPC 或其他协议。例如,gRPC 有一个现成的:github.com/grpc-ecosystem/grpc-gateway。你甚至可以编写自己的网关——对于简单或特定的情况来说并不难。

  • 你可以使用 Web API 从一个服务器向另一个服务器发送消息。一个微服务不必只有一个 API 路径,但你可以为 JSON 或其他格式的消息添加一个特殊处理程序。对于传输,你不仅可以使用 HTTP,还可以使用 WebSocket 协议。

反应式宣言

如果你将反应式架构视为一种标准化方法,你不会找到一个指南或规则来指导如何让你的微服务变得反应式,但有一个《反应式宣言》,你可以在这里找到:www.reactivemanifesto.org/。它包含了一系列原则,你可以使用这些原则来启发你改进应用程序的想法。

现在我们可以创建一个针对 JSON-RPC 协议的反应式微服务的示例。

理解 JSON-RPC

有一些 crate 提供了支持 JSON-RPC 协议的功能。大多数情况下,crate 只支持服务器或客户端的一侧,而不是两者都支持。有些 crate 甚至不支持异步计算。

JSON-RPC 的工作原理

JSON-RPC 协议使用以下格式的 JSON 消息来请求:

{"jsonrpc": "2.0", "method": "substring", "params": [2, 6, \"string\"], "id": 1}

前面的 JSON 消息调用了一个可以返回如下结果的服务器的substring远程方法:

{"jsonrpc": "2.0", "result": "ring", "id": 1}

值得注意的是,客户端确定请求的标识符并必须跟踪这些值。服务器对 ID 不敏感,它们使用连接来跟踪请求。

该协议有两个版本——1.0 和 2.0。它们很相似,但在第二个版本中,客户端和服务器是分离的。此外,它是传输无关的,因为第一个版本使用连接事件来确定行为。错误和参数也有改进。你应该为新项目使用版本 2.0。

为了支持 JSON-RPC,你的服务器必须响应这类 JSON 请求。该协议实现起来非常简单,但我们将使用jsonrpc-http-server crate,它使用 HTTP 传输并提供启动服务器所需的类型。

创建微服务

在本节中,我们将创建一个支持 JSON-RPC 协议并具有两个方法的微服务示例。该微服务将支持作为微服务环的一部分工作。我们将向一个微服务发送消息,该微服务将向环形中的下一个微服务发送消息,然后该微服务将消息进一步传递。

我们将创建一个环形示例,因为如果实现不正确,你的微服务将被阻塞,因为它们不能像反应式服务那样并行处理请求。

依赖项

首先,我们需要导入必要的依赖项:

failure = "0.1"
JSON-RPC = { git = "https://github.com/apoelstra/rust-JSON-RPC" }
jsonrpc-http-server = { git = "https://github.com/paritytech/JSON-RPC" }
log = "0.4"
env_logger = "0.6"
serde = "1.0"
serde_derive = "1.0"

很可能,你对大多数 crate 都很熟悉,除了jsonrpcjson-rpc-server。第一个是基于hyper crate 的 JSON-RPC 客户端。第二个也使用了hyper crate,并提供了 JSON-RPC 的服务器功能。

让我们导入必要的类型,并简要介绍一下它们:

use failure::Error;
use JSON-RPC::client::Client;
use JSON-RPC::error::Error as ClientError;
use JSON-RPC_http_server::ServerBuilder;
use JSON-RPC_http_server::JSON-RPC_core::{IoHandler, Error as ServerError, Value};
use log::{debug, error, trace};
use serde::Deserialize;
use std::env;
use std::fmt;
use std::net::SocketAddr;
use std::sync::Mutex;
use std::sync::mpsc::{channel, Sender};
use std::thread;

JSON-RPC crate 有一个Client类型,我们将使用它来调用其他服务的远程方法。我们还从该 crate 中导入Error作为ClientError以避免与来自failure crate 的Error名称冲突。

对于服务器端,我们将使用jsonrpc-http-server crate 中的ServerBuilder。此外,我们需要将Error从该 crate 重命名为ServerError。为了实现函数处理器,我们需要导入IoHandler,它可以用来将函数附加为 RPC 方法。此外,我们还需要一个Value(实际上,这个类型是从serde_json crate 重新导入的),它用作 RPC 方法的返回类型。

为了避免方法名称的错误,因为我们将在服务器实现中使用它们两次,然后在客户端中再次使用,我们将名称声明为字符串常量:

const START_ROLL_CALL: &str = "start_roll_call";
const MARK_ITSELF: &str = "mark_itself";

第一个方法将从微服务开始发送消息到下一个微服务。第二个方法用于停止这个点名过程。

客户端

为了与其他微服务实例交互并调用它们的远程方法,我们将创建一个单独的结构体,因为它比直接使用 JSON-RPC 的Client更方便。但在任何情况下,我们都在我们的结构体内部使用此类型:

struct Remote {
    client: Client,
}

我们将使用Remote结构体来调用远程服务。为了创建这个结构体,我们将使用以下构造函数:

impl Remote {
    fn new(addr: SocketAddr) -> Self {
        let url = format!("http://{}", addr);
        let client = Client::new(url, None, None);
        Self {
            client
        }
    }
}

Client结构体期望一个String URL 作为参数,但我们将使用SocketAddr来创建 URL。

此外,我们还需要一个泛型函数,它将使用Client实例来调用远程方法。向Remote结构体的实现中添加call_method方法:

fn call_method<T>(&self, meth: &str, args: &[Value]) -> Result<T, ClientError>
where
    T: for <'de> Deserialize<'de>,
{
    let request = self.client.build_request(meth, args);
    self.client.send_request(&request).and_then(|res| res.into_result::<T>())
}

使用 JSON-RPC crate 调用 JSON-RPC 方法很简单。使用Client实例的build_request方法创建一个Request,然后使用同一Clientsend_request方法发送它。有一个名为do_rpc的方法可以在单个调用中完成这个操作。我们将使用更详细的方法来展示你可以预先定义请求并使用它们来加速调用准备。此外,使用面向业务的 struct 方法而不是原始的Client更令人愉快。我们使用包装器隔离实现,以隐藏 RPC 调用的细节。如果你决定改为其他协议,比如 gRPC,会怎样呢?

Remote结构体的实现中添加特殊方法,使用call_method方法进行调用。首先,我们需要start_roll_call函数:

fn start_roll_call(&self) -> Result<bool, ClientError> {
    self.call_method(START_ROLL_CALL, &[])
}

调用时不会传递任何参数,但它期望结果是bool类型。我们使用一个常量作为远程方法的名字。

Remote结构体添加mark_itself方法:

fn mark_itself(&self) -> Result<bool, ClientError> {
    self.call_method("mark_itself", &[])
}

它也不发送任何参数,并返回bool值。

现在,我们可以添加一个工作者来将传入调用与传出调用分开。

工作者

由于我们有两种方法,我们将添加一个结构体以从工作线程执行这些方法的远程调用。向代码中添加Action枚举:

enum Action {
    StartRollCall,
    MarkItself,
}

它有两个变体:StartRollCall用于执行远程start_roll_call方法调用,以及MarkItself变体用于调用远程mark_itself方法。

现在,我们可以添加一个函数在单独的线程中创建一个工作者。如果我们将在传入方法处理程序中立即执行传出调用,我们可以阻塞执行,因为我们有一个微服务环,阻塞一个微服务将阻塞整个环的交互。

无阻塞是反应式微服务的一个重要属性。微服务必须并行或异步处理所有调用,但永远不应该长时间阻塞执行。它们应该像我们在讨论的演员模型中的演员一样工作。

看一下spawn_worker函数:

fn spawn_worker() -> Result<Sender<Action>, Error> {
    let (tx, rx) = channel();
    let next: SocketAddr = env::var("NEXT")?.parse()?;
    thread::spawn(move || {
        let remote = Remote::new(next);
        let mut in_roll_call = false;
        for action in rx.iter() {
            match action {
                Action::StartRollCall => {
                    if !in_roll_call {
                        if remote.start_roll_call().is_ok() {
                            debug!("ON");
                            in_roll_call = true;
                        }
                    } else {
                        if remote.mark_itself().is_ok() {
                            debug!("OFF");
                            in_roll_call = false;
                        }
                    }
                }
                Action::MarkItself => {
                    if in_roll_call {
                        if remote.mark_itself().is_ok() {
                            debug!("OFF");
                            in_roll_call = false;
                        }
                    } else {
                        debug!("SKIP");
                    }
                }
            }
        }
    });
    Ok(tx)
}

此函数创建一个通道并使用从 NEXT 环境变量中提取的地址启动一个新的线程,该线程使用一个例程处理从通道接收到的所有消息。我们使用从 NEXT 环境变量中提取的地址创建 Remote 实例。

有一个标志表示已调用 start_roll_call 方法。当接收到 StartRollCall 消息并调用远程服务器的 start_roll_call 方法时,我们将它设置为 true。如果标志已经设置为 true,并且常规收到 StartRollCall 消息,则线程将调用 mark_itself 远程方法。换句话说,我们将调用所有运行服务实例的 start_roll_call 方法。当所有服务都将标志设置为 true 时,我们将调用所有服务的 mark_itself 方法。

让我们启动一个服务器并运行一个服务环。

服务器

main 函数激活一个记录器并启动一个工作进程。然后,我们提取 ADDRESS 环境变量以使用此地址值来绑定服务器的套接字。看看以下代码:

fn main() -> Result<(), Error> {
    env_logger::init();
    let tx = spawn_worker()?;
    let addr: SocketAddr = env::var("ADDRESS")?.parse()?;
    let mut io = IoHandler::default();
    let sender = Mutex::new(tx.clone());
    io.add_method(START_ROLL_CALL, move |_| {
        trace!("START_ROLL_CALL");
        let tx = sender
            .lock()
            .map_err(to_internal)?;
        tx.send(Action::StartRollCall)
            .map_err(to_internal)
            .map(|_| Value::Bool(true))
    });
    let sender = Mutex::new(tx.clone());
    io.add_method(MARK_ITSELF, move |_| {
        trace!("MARK_ITSELF");
        let tx = sender
            .lock()
            .map_err(to_internal)?;
        tx.send(Action::MarkItself)
            .map_err(to_internal)
            .map(|_| Value::Bool(true))
    });
    let server = ServerBuilder::new(io).start_http(&addr)?;
    Ok(server.wait())
}

要实现 JSON-RPC 方法,我们使用 IoHandler 结构体。它有一个 add_method 方法,该方法期望方法名称,并需要一个包含此方法实现的闭包。

我们添加了两个方法,start_roll_callmark_itself,使用常量作为这些方法的名称。这些方法的实现很简单:我们只准备相应的 Action 消息并将它们发送到工作线程。

JSON-RPC 方法实现必须返回 Result<Value, ServerError> 值。要将任何其他错误转换为 ServerError,我们使用以下函数:

fn to_internal<E: fmt::Display>(err: E) -> ServerError {
    error!("Error: {}", err);
    ServerError::internal_error()
}

此函数仅打印当前错误消息,并使用 ServerError 类型的 internal_error 方法创建一个带有 InternalError 代码的错误。

在主函数的末尾,我们使用创建的 IoHandler 创建一个新的 ServerBuilder 实例,并启动 HTTP 服务器以监听带有 start_http 服务器的 JSON-RPC 请求。

现在我们可以启动一个服务环来测试它。

编译和运行

使用 cargo build 子命令编译此示例,并使用以下命令启动三个服务实例(在每个单独的终端窗口中运行每个命令以查看日志):

RUST_LOG=JSON-RPC_ring=trace ADDRESS=127.0.0.1:4444 NEXT=127.0.0.1:5555 target/debug/JSON-RPC-ring
RUST_LOG=JSON-RPC_ring=trace ADDRESS=127.0.0.1:5555 NEXT=127.0.0.1:6666 target/debug/JSON-RPC-ring
RUST_LOG=JSON-RPC_ring=trace ADDRESS=127.0.0.1:6666 NEXT=127.0.0.1:4444 target/debug/JSON-RPC-ring

当三个服务启动时,从另一个终端窗口使用 curl 准备并发送一个 JSON-RPC 调用请求:

curl -H "Content-Type: application/json" --data-binary '{"JSON-RPC":"2.0","id":"curl","method":"start_roll_call","params":[]}' http://127.0.0.1:4444

使用此命令,我们启动所有服务的交互,它们将在环中相互调用。您将看到每个服务的日志。第一个将打印如下内容:

[2019-01-14T10:45:29Z TRACE JSON-RPC_ring] START_ROLL_CALL
[2019-01-14T10:45:29Z DEBUG JSON-RPC_ring] ON
[2019-01-14T10:45:29Z TRACE JSON-RPC_ring] START_ROLL_CALL
[2019-01-14T10:45:29Z DEBUG JSON-RPC_ring] OFF
[2019-01-14T10:45:29Z TRACE JSON-RPC_ring] MARK_ITSELF
[2019-01-14T10:45:29Z DEBUG JSON-RPC_ring] SKIP

第二个将打印如下内容:

[2019-01-14T10:45:29Z TRACE JSON-RPC_ring] START_ROLL_CALL
[2019-01-14T10:45:29Z DEBUG JSON-RPC_ring] ON
[2019-01-14T10:45:29Z TRACE JSON-RPC_ring] MARK_ITSELF
[2019-01-14T10:45:29Z DEBUG JSON-RPC_ring] OFF

第三个将输出以下日志:

[2019-01-14T10:45:29Z TRACE JSON-RPC_ring] START_ROLL_CALL
[2019-01-14T10:45:29Z DEBUG JSON-RPC_ring] ON
[2019-01-14T10:45:29Z TRACE JSON-RPC_ring] MARK_ITSELF
[2019-01-14T10:45:29Z DEBUG JSON-RPC_ring] OFF

所有服务作为过程的独立参与者工作,对传入的消息做出反应,并在有消息要发送时向其他服务发送消息。

了解 gRPC

在本节中,我们将把 JSON-RPC 环形示例重写为 gRPC。这个协议与 JSON-RPC 不同,因为它需要一个协议声明——预定义的交互模式。这种限制对大型项目来说是有益的,因为你不能在消息布局中犯错误,但使用 Rust,JSON-RPC 也是可靠的,因为你必须精确地声明所有结构体,如果你接收到一个不正确的 JSON 消息,你会得到一个错误。使用 gRPC,你根本不必关心这一点。

gRPC 的工作原理

与 JSON-RPC 相比,gRPC 的优点在于速度。gRPC 可以更快地工作,因为它使用了一种快速的序列化格式——Protocol Buffers。gRPC 和 Protocol Buffers 都最初由 Google 开发,并在高性能案例中得到了验证。

gRPC 使用 HTTP/2 进行传输。它是一个非常快且优秀的传输协议。首先,它是二进制的:所有请求和响应都被压缩成一个紧凑的字节部分并压缩。它是多路复用的:你可以同时发送很多请求,但 HTTP/1 要求请求的顺序。

gRPC 需要一个方案,并使用 Protocol Buffers 作为 接口定义语言IDL)。在开始编写服务的实现之前,你必须编写包含所有类型和服务的声明的 proto 文件。之后,你需要将声明编译成源代码(在我们的案例中是 Rust 编程语言),并使用它们来编写实现。

protobuf crate 和常见的 gRPC crate 以该 crate 为基础。实际上,crate 并不多;只有两个:grpcio crate,它是对原始 gRPC 核心库的包装,以及 grpc crate,它是协议的纯 Rust 实现。

现在,我们可以将之前的示例从 JSON-RPC 协议重写为 gRPC。首先,我们必须添加所有必要的依赖项并编写我们服务的声明。

创建微服务

gRPC 示例非常复杂,因为我们必须声明一个交互协议。我们还需要添加 build.rs 文件,从协议描述生成 Rust 源代码。

由于从 curl 调用 gRPC 很困难,我们还将添加一个客户端,帮助我们测试服务。你也可以使用其他可用于调试 gRPC 应用程序的工具。

依赖项

创建一个新的 binary crate 并在编辑器中打开 Cargo.toml 文件。我们将探索这个文件的每一个部分,因为构建 gRPC 示例比使用灵活交互协议(如 JSON-RPC)的服务更复杂。我们将使用 Edition 2018,正如我们在本书中的大多数示例中所做的那样:

[package]
name = "grpc-ring"
version = "0.1.0"
authors = ["your email"]
edition = "2018"

在依赖项中,我们需要一组基本的 crate——failurelogenv_logger。此外,我们添加了 protobuf crate。我们不会直接使用它,但它在稍后本节中从协议描述生成的 Rust 源代码中会被使用。当前示例中最重要的 crate 是 grpc。我们将使用 GitHub 上的版本,因为该 crate 正在积极开发中:

[dependencies]
env_logger = "0.6"
failure = "0.1"
grpc = { git = "https://github.com/stepancheg/grpc-rust" }
log = "0.4"
protobuf = "2.2"

实际上,grpc crate 的 GitHub 仓库是一个工作区,并且还包含 protoc-rust-grpc crate,我们将使用它通过 build.rs 文件在 Rust 中生成协议声明。将此依赖项添加到 Cargo.toml[build-dependencies] 部分:

[build-dependencies]
protoc-rust-grpc = { git = "https://github.com/stepancheg/grpc-rust" }

我们创建的 example crate 将生成两个二进制文件——服务器和客户端。正如我所说的,我们需要一个客户端,因为它比手动准备调用更简单,并且使用 curl 调用 gRPC 方法。

第一个二进制文件是由 src/server.rs 文件构建的服务器:

[[bin]]
name = "grpc-ring"
path = "src/server.rs"
test = false

第二个二进制文件使用 src/client.rs 文件构建客户端:

[[bin]]
name = "grpc-ring-client"
path = "src/client.rs"
test = false

我们还有 src/lib.rs 用于通用部分,但我们必须描述一个协议并创建 build.rs 文件。

协议

gRPC 使用一种特殊的语言进行协议声明。该语言有两种版本——proto2proto3。我们将使用第二种,因为它更现代。创建一个 ring.proto 文件并添加以下声明:

syntax = "proto3";

option java_multiple_files = true;
option java_package = "rust.microservices.ring";
option java_outer_classname = "RingProto";
option objc_class_prefix = "RING";

package ringproto;

message Empty { }

service Ring {
  rpc StartRollCall (Empty) returns (Empty);
  rpc MarkItself (Empty) returns (Empty);
}

如您所见,我们指定了 proto3 语法。选项为您提供了设置不同语言源文件生成属性的能力,如果您将与其他应用程序或其他微服务交互服务,您可能需要设置这些选项。对于我们的示例,我们不需要设置这些选项,但如果您从其他开发者那里获取它,您可能需要在文件中包含这部分。

协议声明包含一个使用 package 修饰符设置的包名和我们将它设置为 ringproto 的包名。

此外,我们还添加了一个没有字段的 Empty 消息。我们将使用此类型作为所有方法的输入和输出参数,但对于实际的微服务来说,使用不同的类型会更好。首先,您不能没有输入和输出参数的方法。第二个原因是未来的服务改进。如果您想在以后添加额外的字段到协议中,您可以这样做。此外,协议可以轻松地处理协议的不同版本;通常,您可以同时使用新旧微服务,因为 Protocol Buffers 与额外字段配合良好,并且您可以在需要时扩展协议。

服务声明包含在 service 部分。您可以在协议声明文件中拥有多个服务的声明,并在实现中仅使用必要的声明服务。但我们的环形示例只需要一个服务声明。添加 Ring 服务并包含两个带有 rpc 修饰符的 RPC 方法。我们添加了 StartRollCall 方法和 MakeItself。与上一个示例相同。两者都接受 Empty 值作为输入参数,并返回 Empty

服务名称很重要,因为它将被用作生成 Rust 源代码中多个类型的名称前缀。您可以使用 protoc 工具创建源代码,但创建一个在编译期间生成带有协议类型的源代码的构建脚本会更方便。

生成接口

Rust 构建脚本允许你实现一个函数,该函数将为项目编译做一些额外的准备。在我们的例子中,我们有一个ring.proto文件,其中包含协议定义,我们希望使用protoc-rust-grpc包将其转换为 Rust 源代码。

在项目中创建build.rs文件并添加以下内容:

extern crate protoc_rust_grpc;

fn main() {
    protoc_rust_grpc::run(protoc_rust_grpc::Args {
        out_dir: "src",
        includes: &[],
        input: &["ring.proto"],
        rust_protobuf: true,
        ..Default::default()
    }).expect("protoc-rust-grpc");
}

构建脚本使用main函数作为入口点,在其中你可以实现任何想要的活动。我们使用了protoc-rust-grpc包的 run 函数与Args——我们在out_dir字段中设置了输出目录,将ring.proto文件作为输入声明设置在input字段中,激活了rust_protobuf布尔标志以生成rust**-**protobuf包的源代码(如果你已经使用protobuf包并使用它生成类型,则不需要它),然后设置includes字段为一个空数组。

然后,当你运行cargo build时,它将在src文件夹中生成两个模块:ring.rsring_grpc.rs。我不会在这里放置其源代码,因为生成的文件很大,但我们将使用它来创建一个 gRPC 客户端的包装器,就像我们在前面的例子中所做的那样。

共享客户端

打开lib.rs源文件并添加两个生成的模块:

mod ring;
mod ring_grpc;

导入我们需要创建 gRPC 客户端包装器的类型:

use crate::ring::Empty;
use crate::ring_grpc::{Ring, RingClient};
use grpc::{ClientConf, ClientStubExt, Error as GrpcError, RequestOptions};
use std::net::SocketAddr;

如你所见,生成的模块包含我们在ring.proto文件中声明的类型。ring模块包含Empty结构体,而ring_grpc模块包含Ring特质,它表示远程服务的接口。此外,protoc_rust_grpc在生成的构建脚本中创建了RingClient类型。此类型是一个可以用来调用远程方法的客户端。我们用我们自己的结构体包装它,因为RingClient生成Future实例,我们将使用Remote包装器来执行它们并获取结果。

我们还使用来自grpc包的类型。Error类型被导入为GrpcError

RequestOptions,它是准备方法调用请求所必需的;ClientConf,用于为HTTP/2连接添加额外的配置参数(我们将使用默认值);以及ClientStubExt,它为客户端提供连接方法。

在内部添加持有RingClient实例的Remote结构体:

pub struct Remote {
    client: RingClient,
}

我们使用这个结构体来创建客户端和服务器。添加一个新的方法从提供的SocketAddr构建新的Remote实例:

impl Remote {
    pub fn new(addr: SocketAddr) -> Result<Self, GrpcError> {
        let host = addr.ip().to_string();
        let port = addr.port();
        let conf = ClientConf::default();
        let client = RingClient::new_plain(&host, port, conf)?;
        Ok(Self {
            client
        })
    }
}

由于生成的客户端期望有单独的主机和端口值,我们从SocketAddr值中提取它们。此外,我们创建默认的ClientConf配置,并使用所有这些值来创建RingClient实例,并将其放入新的Remote实例中。

我们创建Remote结构体以拥有调用远程方法的基本方法。向Remote实现中添加start_roll_call方法以调用StartRollCall gRPC 方法:

pub fn start_roll_call(&self) -> Result<Empty, GrpcError> {
    self.client.start_roll_call(RequestOptions::new(), Empty::new())
        .wait()
        .map(|(_, value, _)| value)
}

RingClient已经具有此方法,但它期望我们想要隐藏的参数,并返回一个我们想要立即使用wait方法调用的Future实例。Future返回一个包含三个项目的元组,但我们只需要一个值,因为其他值包含我们不需要的元数据。

以类似的方式实现mark_itself方法来调用MarkItself gRPC 方法:

pub fn mark_itself(&self) -> Result<Empty, GrpcError> {
    self.client.mark_itself(RequestOptions::new(), Empty::new())
        .wait()
        .map(|(_, value, _)| value)
}

现在我们可以实现客户端和服务器,因为两者都需要Remote结构体来执行 RPC 调用。

客户端

添加src/client.rs文件并导入一些类型:

use failure::Error;
use grpc_ring::Remote;
use std::env;

我们需要从failure crate 中获取一个通用的Error类型,因为它是最常用的错误处理类型,并且导入我们之前创建的Remote结构体。

客户端是一个非常简单的工具。它使用在NEXT环境变量中提供的地址调用服务的StartRollCall远程 gRPC 方法:

fn main() -> Result<(), Error> {
    let next = env::var("NEXT")?.parse()?;
    let remote = Remote::new(next)?;
    remote.start_roll_call()?;
    Ok(())
}

使用解析的SocketAddr值创建Remote实例并执行调用。就是这样。服务器非常复杂。让我们来实现它。

服务器实现

添加src/server.rs源文件并将其添加到其中:

mod ring;
mod ring_grpc;

我们需要这些模块,因为我们将为我们的 RPC 处理程序实现Ring特质。看看我们将使用的类型:

use crate::ring::Empty;
use crate::ring_grpc::{Ring, RingServer};
use failure::Error;
use grpc::{Error as GrpcError, ServerBuilder, SingleResponse, RequestOptions};
use grpc_ring::Remote;
use log::{debug, trace};
use std::env;
use std::net::SocketAddr;
use std::sync::Mutex;
use std::sync::mpsc::{channel, Receiver, Sender};

你可能还不熟悉的一些类型是ServerBuilder,它用于创建服务器实例并填充服务实现,以及SingleResponse是处理程序调用的结果。其他类型你已经知道了。

服务实现

我们需要一个自己的类型来实现Ring特质以实现服务的 RPC 接口。但我们还必须保留一个Sender以供工作进程发送动作。添加带有ActionSenderMutex包装的RingImpl结构体,因为Ring特质还要求实现Sync特质:

struct RingImpl {
    sender: Mutex<Sender<Action>>,
}

我们将从Sender实例构建一个实例:

impl RingImpl {
    fn new(sender: Sender<Action>) -> Self {
        Self {
            sender: Mutex::new(sender),
        }
    }
}

对于每个传入的方法调用,我们需要向工作进程发送Action,我们可以在RingImpl实现中添加一个方法来在所有处理程序中重用它:

fn send_action(&self, action: Action) -> SingleResponse<Empty> {
    let tx = try_or_response!(self.sender.lock());
    try_or_response!(tx.send(action));
    let result = Empty::new();
    SingleResponse::completed(result)
}

send_action函数接收一个Action值,并锁定一个Mutex来使用Sender。最后,它创建一个Empty值并将其作为SingleResponse实例返回。如果你注意到了,我们使用了我们定义的try_or_response!宏,因为SingleResponse是一个Future实例,我们必须在任何成功或失败的情况下返回此类型。

此宏类似于标准库中的try!宏。它使用 match 来提取值或在没有错误值的情况下返回结果:

macro_rules! try_or_response {
    ($x:expr) => {{
        match $x {
            Ok(value) => {
                value
            }
            Err(err) => {
                let error = GrpcError::Panic(err.to_string());
                return SingleResponse::err(error);
            }
        }
    }};
}

前面的宏使用GrpcErrorPanic变体创建SingleResponse实例,但使用现有错误值的错误描述。

处理程序

现在,我们可以实现我们在ring.proto文件中声明的Ring服务的 gRPC 方法。我们有与方法相同名称的Ring特质。每个方法都期望一个Empty值并必须返回此类型,因为我们已经在声明中定义了它。此外,每个方法都必须返回作为结果的SingleResponse类型。我们已经定义了发送Action值到工作线程并返回带有Empty值的SingleResponse响应的send_action方法。让我们使用send_action方法来实现我们必须要实现的方法:

impl Ring for RingImpl {
    fn start_roll_call(&self, _: RequestOptions, _: Empty) -> SingleResponse<Empty> {
        trace!("START_ROLL_CALL");
        self.send_action(Action::StartRollCall)
    }

    fn mark_itself(&self, _: RequestOptions, _: Empty) -> SingleResponse<Empty> {
        trace!("MARK_ITSELF");
        self.send_action(Action::MarkItself)
    }
}

我们对 gRPC 方法处理器的实现相当简单,但你也可以添加更合理的实现,并从 Future 异步地生成SingleResponse

主函数

一切都为main函数的实现准备好了:

fn main() -> Result<(), Error> {
    env_logger::init();
    let (tx, rx) = channel();
    let addr: SocketAddr = env::var("ADDRESS")?.parse()?;
    let mut server = ServerBuilder::new_plain();
    server.http.set_addr(addr)?;
    let ring = RingImpl::new(tx);
    server.add_service(RingServer::new_service_def(ring));
    server.http.set_cpu_pool_threads(4);
    let _server = server.build()?;

    worker_loop(rx)
}

我们初始化了一个日志记录器,并创建了一个通道,我们将使用它从RingImpl向工作线程发送动作。我们从ADDRESS环境变量中提取了SocketAddr,并使用这个值将服务器绑定到提供的地址。

我们使用new_plain方法创建了一个ServerBuilder实例。它创建了一个没有 TLS 的服务器,因为 gRPC 支持安全连接,我们必须为ServerBuilder提供一个实现TlcAcceptor特质的类型参数。但是使用new_plain,我们使用来自tls_api_stub包的TlasAcceptor存根。ServerBuilder结构体包含了httpbis::ServerBuilder类型的http字段。我们可以使用这个文件来设置服务器套接字的绑定地址。

之后,我们创建了RingImpl实例,并使用ServiceBuilderadd_service方法附加一个服务实现,但我们必须提供服务的泛型grpc::rt::ServerServiceDefinition定义,并使用RingServer类型的new_service_def来为RingImpl实例创建它。

最后,我们设置处理传入请求的线程池中线程的数量,并调用ServiceBuilderbuild方法来启动服务器。但是等等——如果你不调用build方法,主线程将被终止,你必须添加一个循环或其他例程来保持主线程活跃。

幸运的是,我们需要一个工作线程,我们可以使用主线程来运行它。如果你只需要运行 gRPC 服务器,你可以使用一个带有thread::park方法调用的loop,这将阻塞线程,直到它被unpark方法调用解除阻塞。这种方法被异步运行时内部使用。

我们将使用worker_loop函数调用,但我们还没有实现这个函数。

工作线程

我们已经在 JSON-RPC 示例中实现了工作线程。在 gRPC 版本中,我们使用相同的代码,但期望一个Receiver值,并且不创建新的线程:

fn worker_loop(receiver: Receiver<Action>) -> Result<(), Error> {
    let next = env::var("NEXT")?.parse()?;
    let remote = Remote::new(next)?;
    let mut in_roll_call = false;
    for action in receiver.iter() {
        match action { /* Action variants here */ }
    }
    Ok(())
}

让我们编译并运行这个示例。

编译和运行

使用cargo build子命令构建服务器和客户端。

如果你想要指定二进制文件,请使用带有二进制文件名的--bin 参数。

此外,你可以使用cargo watch工具进行构建。

如果你使用cargo watch工具,那么build.rs脚本将生成带有 gRPC 类型的文件,并且watch将不断重启构建。为了防止这种情况,你可以设置命令的--ignore参数,使用文件名模式的忽略。在我们的例子中,我们必须运行cargo watch --ignore 'src/ring*'命令。

当两个二进制文件都构建完成后,在三个不同的终端中运行三个实例:

RUST_LOG=grpc_ring=trace ADDRESS=127.0.0.1:4444 NEXT=127.0.0.1:5555 target/debug/grpc-ring
RUST_LOG=grpc_ring=trace ADDRESS=127.0.0.1:5555 NEXT=127.0.0.1:6666 target/debug/grpc-ring
RUST_LOG=grpc_ring=trace ADDRESS=127.0.0.1:6666 NEXT=127.0.0.1:4444 target/debug/grpc-ring

当所有服务启动后,使用客户端向第一个服务发送请求:

NEXT=127.0.0.1:4444 target/debug/grpc-ring-client

此命令将调用远程方法start_roll_call,你将看到与前面 JSON-RPC 示例中类似的服务器日志。

摘要

本章介绍了创建响应式微服务架构的良好实践。我们从基本概念开始学习:什么是响应式方法,如何实现它,以及远程过程调用如何帮助实现消息驱动架构。此外,我们还讨论了你可以用 Rust 简单使用的现有 RPC 框架和 crate。

为了演示响应式应用程序的工作原理,我们创建了两个使用 RPC 方法相互交互的微服务示例。我们创建了一个应用程序,它使用一组正在运行的微服务,这些微服务在一个循环中相互发送请求,直到每个实例都了解一个事件。

我们还创建了一个示例,它使用 JSON-RPC 协议进行实例交互,并使用jsonrpc-http-servercrate 作为服务器端,使用 JSON-RPCcrate 作为客户端。

之后,我们创建了一个示例,它使用 gRPC 协议进行微服务交互,并使用了grpccrate,该 crate 涵盖了客户端和服务器端。

在下一章中,我们将开始将微服务与数据库集成,并探索可用于与以下数据库交互的 crate:PostgreSQL、MySQL、Redis、MongoDB、DynamoDB。

第七章:与数据库的可靠集成

持久化微服务需要存储和加载数据。如果您想保持这些数据组织有序且可靠,您应该使用数据库。Rust 有支持流行数据库的第三方 crate,在本章中,您将了解如何使用 Rust 与不同的数据库进行交互,包括以下内容:

  • PostgreSQL

  • MySQL

  • Redis

  • MongoDB

  • DynamoDB

我们将创建一些实用工具,允许您向数据库中插入或删除数据,以及查询数据库中存储的数据。

技术要求

在本章中,您需要数据库实例来运行我们的示例。最有效的方法是使用 Docker 进行数据库的运行和测试。您可以在本地安装数据库,但由于我们还需要在后续章节中使用 Docker,因此最好从本章开始安装并使用它。

我们将使用以下来自 Docker Hub 的官方镜像:

  • postgres:11

  • mysql:8

  • redis:5

  • mongo:4

  • amazon/dynamodb-local:latest

您可以在 Docker Hub 的仓库页面了解更多关于这些镜像的信息:hub.docker.com/.

我们还将使用作为 Amazon Web Services 一部分提供的DynamoDB数据库:aws.amazon.com/dynamodb/.

如果您想与数据库交互以检查我们的示例是否成功运行,您还必须为每个数据库安装相应的客户端。

您可以在 GitHub 上的Chapter07文件夹中找到本章的所有示例:github.com/PacktPublishing/Hands-On-Microservices-with-Rust-2018/.

PostgreSQL

PostgreSQL 是一个可靠且成熟的数据库。在本节中,我们将探讨如何在容器中启动该数据库的一个实例,以及如何使用第三方 crate 从 Rust 连接到它。我们将查看与该数据库的简单交互,以及使用连接池以获得额外性能的使用。我们将使用 Docker 启动数据库的一个实例,并创建一个工具,用于向表中添加记录,并在将它们打印到控制台之前查询已添加的记录列表。

设置测试数据库

要创建我们的数据库,您可以使用 Docker,它会自动拉取包含预安装 PostgreSQL 数据库的所有必要层。需要注意的是,PostgreSQL 在 Docker Hub 上有官方镜像,您应该选择使用这些镜像而不是非官方镜像,因为后者有更大的恶意更新风险。

我们需要启动一个包含 PostgreSQL 数据库实例的容器。您可以使用以下命令来完成此操作:

docker run -it --rm --name test-pg -p 5432:5432 postgres

这个命令做了什么?它从一个 postgres 镜像(最新版本)启动一个容器,并使用本地主机的 5432 端口将其转发到容器的内部端口 5432(即镜像暴露的端口)。我们还使用 --name 参数设置了一个名称。我们给容器命名为 test-pg。你可以稍后使用这个名称来停止容器。--rm 标志将在容器停止时删除与容器关联的匿名卷。为了能够从终端与数据库交互,我们添加了 -it 标志。

数据库实例将启动并在终端打印出类似以下内容:

creating subdirectories ... ok
selecting default max_connections ... 100
selecting default shared_buffers ... 128MB
selecting dynamic shared memory implementation ... posix
creating configuration files ... ok
running bootstrap script ... ok
performing post-bootstrap initialization ... ok
syncing data to disk ... ok
........

数据库现在已准备好使用。如果你本地有 psql 客户端,你可以使用它来检查。该镜像的默认参数如下:

psql --host localhost --port 5432 --username postgres

如果你不再需要数据库,可以使用以下命令来关闭它:

docker stop test-pg

但现在不要关闭它——让我们用 Rust 连接到它。

简单的数据库交互

与数据库交互的最简单方式是直接创建一个到数据库的单个连接。简单交互是一个直接的数据库连接,它不使用连接池或其他抽象来最大化性能。

要连接到 PostgreSQL 数据库,我们可以使用两个 crate:postgresr2d2_postgres。第一个是一个通用的连接驱动程序。第二个,r2d2_postgres,是 r2d2 连接池 crate 的一个 crate。我们将首先直接使用 postgres crate,而不使用 r2d2 crate 的池,然后创建一个简单的实用工具来创建一个表,在添加命令来操作该表中的数据之前。

添加依赖项

让我们创建一个新的项目,包含所有必要的依赖项。我们将创建一个用于管理数据库中用户的二进制实用工具。创建一个新的二进制 crate:

cargo new --bin users

接下来,添加依赖项:

cargo add clap postgres

但等等!货物中不包含 add 命令。我已经安装了用于管理依赖项的 cargo-edit 工具。你可以使用以下命令来完成此操作:

cargo install cargo-edit

上述命令安装了 cargo-edit 工具。如果你没有安装它,你的本地 cargo 将不会有 add 命令。安装 cargo-edit 工具并添加 postgres 依赖项。你也可以通过编辑 Cargo.toml 文件手动添加依赖项,但鉴于我们将要创建更复杂的项目,cargo-edit 工具可以帮助我们节省时间。

Cargo 工具可以在以下位置找到:github.com/killercup/cargo-edit。此工具包含三个有用的命令来管理依赖项:add用于添加依赖项,rm用于删除不必要的依赖项,upgrade用于将依赖项的版本升级到最新版本。此外,使用 Rust 的令人惊叹的 2018 版,你不需要使用extern crate ...声明。你可以简单地添加或删除任何 crate,并且它们将立即在所有模块中可用。但是,如果你添加了一个不需要的 crate,并且最终忘记了它怎么办?由于 Rust 编译器允许未使用的 crate,你可以在你的 crate 中添加以下 crate-wide 属性,#![deny(unused_extern_crates)],以防你意外地添加了一个不会使用的 crate。

此外,添加clap crate。我们需要它来解析我们的工具的参数。按照以下方式添加所有必要类型的用法:

extern crate clap;
extern crate postgres;

use clap::{
    crate_authors, crate_description, crate_name, crate_version,
    App, AppSettings, Arg, SubCommand,
};
use postgres::{Connection, Error, TlsMode};

所有必要的依赖都已安装,并且我们已经导入了我们的类型,因此我们可以创建到数据库的第一个连接。

创建连接

在你可以在数据库上执行任何查询之前,你必须与你在容器中启动的数据库建立连接。使用以下命令创建一个新的Connection实例:

let conn = Connection::connect("postgres://postgres@localhost:5432", TlsMode::None).unwrap();

创建的Connection实例有executequery方法。第一个方法用于执行 SQL 语句;第二个用于使用 SQL 查询数据。由于我们想要管理用户,让我们添加三个我们将与Connection实例一起使用的函数:create_tablecreate_userlist_users

第一个函数create_table为用户创建一个表:

fn create_table(conn: &Connection) -> Result<(), Error> {
    conn.execute("CREATE TABLE users (
                    id SERIAL PRIMARY KEY,
                    name VARCHAR NOT NULL,
                    email VARCHAR NOT NULL
                  )", &[])
        .map(drop)
}

此函数使用一个Connection实例来执行一个创建users表的语句。由于我们不需要结果,我们可以简单地使用Result上的map命令来drop它。正如你所见,我们使用了一个不可变引用来引用连接,因为Connection包含对共享结构的引用,所以我们不需要改变这个值来与数据库交互。

关于使用哪种方法的讨论有很多:使用运行时锁和 Mutex 的不可变引用,还是即使需要运行时锁也使用可变引用。一些 crate 使用第一种方法,而其他 crate 使用第二种。在我看来,将你的方法适应于它将被调用的环境是好的。在某些情况下,避免可变引用可能更方便,但在大多数情况下,要求接口对象(如postgres crate 中的Connection)的可变性更安全。crate 的开发者也有一个计划将引用改为可变引用。你可以在这里了解更多信息:github.com/sfackler/rust-postgres/issues/346

下一个函数是create_user

fn create_user(conn: &Connection, name: &str, email: &str) -> Result<(), Error> {
    conn.execute("INSERT INTO users (name, email) VALUES ($1, $2)",
                 &[&name, &email])
        .map(drop)
}

这个函数也使用了Connectionexecute方法来插入一个值,但它还向调用中添加了参数来填充提供的语句(create_table函数将这些参数留空)。执行的结果被丢弃,我们只保留Error。如果请求返回插入记录的标识符,你可能需要返回值。

最后一个函数list_users查询数据库以从users表中获取用户列表。

fn list_users(conn: &Connection) -> Result<Vec<(String, String)>, Error> {
    let res = conn.query("SELECT name, email FROM users", &[])?.into_iter()
        .map(|row| (row.get(0), row.get(1)))
        .collect();
    Ok(res)
}

这个函数list_users使用了Connectionquery方法。在这里,我们使用了一个简单的SELECT SQL 语句,将其转换为行的迭代器,并提取用户的名称和电子邮件地址对。

使用工具包装

我们已经准备好了所有查询,现在我们可以将它们在一个具有命令行界面的二进制工具中连接起来。在下面的代码中,我们将使用clap crate 解析一些参数,并运行函数来管理已建立的Connectionusers表中的用户。

我们的工具将支持三个命令。将它们的名称声明为常量:

const CMD_CREATE: &str = "create";
const CMD_ADD: &str = "add";
const CMD_LIST: &str = "list";

现在,我们可以使用clap crate 创建main函数来解析我们的命令行参数:

fn main() -> Result<(), Error> {

    let matches = App::new(crate_name!())
        .version(crate_version!())
        .author(crate_authors!())
        .about(crate_description!())
        .setting(AppSettings::SubcommandRequired)
        .arg(
            Arg::with_name("database")
            .short("d")
            .long("db")
            .value_name("ADDR")
            .help("Sets an address of db connection")
            .takes_value(true),
            )
        .subcommand(SubCommand::with_name(CMD_CREATE).about("create users table"))
        .subcommand(SubCommand::with_name(CMD_ADD).about("add user to the table")
                    .arg(Arg::with_name("NAME")
                         .help("Sets the name of a user")
                         .required(true)
                         .index(1))
                    .arg(Arg::with_name("EMAIL")
                         .help("Sets the email of a user")
                         .required(true)
                         .index(2)))
        .subcommand(SubCommand::with_name(CMD_LIST).about("print list of users"))
        .get_matches();
    // Add connection here
}

如果失败,main函数返回postgres::Error,因为我们将要进行的所有操作都与我们的 Postgres 数据库连接相关。在这里,我们创建了一个clap::App实例,并添加了一个--database参数,让用户更改连接地址。我们还添加了三个子命令createaddlist,以及add命令的额外参数,该参数需要用户的名称和电子邮件地址,以便我们可以将其插入数据库。

要创建一个Connection实例,我们使用数据库参数来提取用户通过--db命令行参数提供的连接 URL,如果没有提供,我们将使用默认 URL 值postgres://postgres@localhost:5432

let addr = matches.value_of("database")
    .unwrap_or("postgres://postgres@localhost:5432");
let conn = Connection::connect(addr, TlsMode::None)?;

我们使用了一个带有地址的Connection::connect方法,并将TlsMode参数设置为TlsMode::None,因为我们演示中不使用 TLS。我们创建了一个名为connConnection实例来调用我们的函数与数据库交互。

最后,我们可以为子命令添加分支:

match matches.subcommand() {
    (CMD_CREATE, _) => {
        create_table(&conn)?;
    }
    (CMD_ADD, Some(matches)) => {
        let name = matches.value_of("NAME").unwrap();
        let email = matches.value_of("EMAIL").unwrap();
        create_user(&conn, name, email)?;
    }
    (CMD_LIST, _) => {
        let list = list_users(&conn)?;
        for (name, email) in list {
            println!("Name: {:20}    Email: {:20}", name, email);
        }
    }
    _ => {
        matches.usage(); // but unreachable
    }
}
Ok(())

第一分支匹配crate子命令,通过调用create_table函数创建一个表。

第二分支是针对add子命令的。它提取用户名称和电子邮件地址所需的参数对,并调用create_user函数来创建一个包含提供值的用户记录。我们使用unwrap来提取它,因为这两个参数都是必需的。

倒数第二个分支处理list命令,通过调用list_users函数来获取用户列表。在取值之后,它在一个for循环中使用,将所有用户的记录打印到控制台。

最后一个分支不可达,因为我们将AppSettings::SubcommandRequired设置为clap::App,但我们保留它以保持一致性。如果你想在子命令值未设置时提供默认行为,这特别有用。

编译和运行

在本章的开头,我们启动了一个 PostgreSQL 数据库实例,我们将使用它来检查我们的工具。使用以下命令编译我们创建的示例并打印可用的子命令:

cargo run -- --helpYou will see the next output:
USAGE:
 users [OPTIONS] <SUBCOMMAND>
FLAGS:
 -h, --help       Prints help information
 -V, --version    Prints version information
OPTIONS:
 -d, --db <ADDR>    Sets an address of db connection
SUBCOMMANDS:
 add       add user to the table
 create    create users table
 help      Prints this message or the help of the given subcommand(s)
 list      print list of users

Cargo 看起来是一个管理应用程序数据库的可爱工具。让我们用它创建一个表格,如下所示:

cargo run -- create

此命令创建一个users表。如果你再次尝试运行它,你会得到一个错误:

Error: Error(Db(DbError { severity: "ERROR", parsed_severity: Some(Error), code: SqlState("42P07"), message: "relation \"users\" already exists", detail: None, hint: None, position: None, where_: None, schema: None, table: None, column: None, datatype: None, constraint: None, file: Some("heap.c"), line: Some(1084), routine: Some("heap_create_with_catalog") }))

如果你使用psql客户端检查你的表,你将看到我们数据库中的表:

postgres=# \dt
 List of relations
 Schema | Name  | Type  |  Owner 
--------+-------+-------+----------
 public | users | table | postgres
(1 row)

要添加新用户,使用以下参数调用add子命令:

cargo run -- add user-1 user-1@example.com
cargo run -- add user-2 user-2@example.com
cargo run -- add user-3 user-3@example.com

我们添加了三个用户,如果你输入list子命令,你可以在列表中看到他们:

cargo run -- list
Name: user-1   Email: user-1@example.com 
Name: user-2   Email: user-2@example.com 
Name: user-3   Email: user-3@example.com  

在以下示例中,我们将使用数据库连接池来并行添加多个用户。

连接池

我们创建的工具使用数据库的单个连接。对于少量查询来说,它工作得很好。如果你想并行运行多个查询,你必须使用连接池。在本节中,我们通过import命令改进了工具,该命令从 CSV 文件导入大量用户数据。我们将使用r2d2 crate 的Pool类型,添加一个读取用户文件的命令,并执行并行将用户添加到表中的语句。

创建连接池

要创建连接池,我们将使用可以保存多个连接并为我们从池中提供连接的r2d2 crate。这个 crate 是泛型的,所以你需要为每个要连接的数据库提供一个特定的实现。r2d2 crate 可以使用适配器 crate 连接以下数据库:

  • PostgreSQL

  • Redis

  • MySQL

  • SQLite

  • Neo4j

  • Diesel ORM

  • CouchDB

  • MongoDB

  • ODBC

在我们的例子中,我们需要r2d2-postgres适配器 crate 来连接到 PostgreSQL 数据库。使用r2d2 crate 将其添加到我们的依赖项中:

[dependencies]
clap = "2.32"
csv = "1.0"
failure = "0.1"
postgres = "0.15"
r2d2 = "0.8"
r2d2_postgres = "0.14"
rayon = "1.0"
serde = "1.0"
serde_derive = "1.0"

我们还保留了postgres依赖项,并添加了failure用于错误处理和rayon以并行执行 SQL 语句。我们还添加了一套serde crate 来从 CSV 文件反序列化User记录,以及csv crate 来读取该文件。

你将更习惯于使用 Rust 结构体来表示数据库中的数据记录。让我们添加一个User类型,它代表数据库中的用户记录,如下所示的结构体:

#[derive(Deserialize, Debug)]
struct User {
 name: String,
 email: String,
}

由于我们有我们的特殊User类型,我们可以改进create_userlist_users函数以使用这种新类型:

fn create_user(conn: &Connection, user: &User) -> Result<(), Error> {
    conn.execute("INSERT INTO users (name, email) VALUES ($1, $2)",
                 &[&user.name, &user.email])
        .map(drop)
}

fn list_users(conn: &Connection) -> Result<Vec<User>, Error> {
    let res = conn.query("SELECT name, email FROM users", &[])?.into_iter()
        .map(|row| {
            User {
                name: row.get(0),
                email: row.get(1),
            }
        })
        .collect();
    Ok(res)
}

它的变化并不大:我们仍然使用相同的Connection类型,但我们使用User结构体中的字段来填充我们的create语句并从我们的get list查询中提取值。create_table函数没有变化。

import命令添加一个常量:

const CMD_IMPORT: &str = "import";

然后,将其作为SubCommand添加到App中:

.subcommand(SubCommand::with_name(CMD_IMPORT).about("import users from csv"))

几乎所有分支都有所改变,我们应该探索这些变化。add命令创建一个User实例来调用create_user函数:

(CMD_ADD, Some(matches)) => {
    let name = matches.value_of("NAME").unwrap().to_owned();
    let email = matches.value_of("EMAIL").unwrap().to_owned();
    let user = User { name, email };
    create_user(&conn, &user)?;
}

list子命令返回一个User结构体实例的列表。我们必须注意这个变化:

(CMD_LIST, _) => {
    let list = list_users(&conn)?;
    for user in list {
        println!("Name: {:20}    Email: {:20}", user.name, user.email);
    }
}

import命令更复杂,所以让我们在下一节中更详细地讨论这个问题。

使用 rayon crate 进行并行导入

由于我们有连接池,我们可以并行运行对数据库的多个请求。我们将从 CSV 格式的标准输入流中读取用户。让我们为之前声明的match表达式添加一个分支,用于import子命令,并使用csv::Reader打开stdin。之后,我们将使用读取器的deserialize方法,它返回我们所需类型的反序列化实例的迭代器。在我们的情况下,我们将 CSV 数据反序列化为User结构体的列表并将它们推送到向量中:

(CMD_IMPORT, _) => {
    let mut rdr = csv::Reader::from_reader(io::stdin());
    let mut users = Vec::new();
    for user in rdr.deserialize() {
        users.push(user?);
    }
    // Put parallel statements execution here
}

Rayon

为了并行运行请求,我们将使用rayon crate,它提供了具有par_iter方法的并行迭代器。并行迭代器将列表分割成在线程池中运行的单独任务:

users.par_iter()
    .map(|user| -> Result<(), failure::Error> {
        let conn = pool.get()?;
        create_user(&conn, &user)?;
        Ok(())
    })
    .for_each(drop);

并行迭代器返回项目的方式与传统迭代器类似。我们可以使用Pool::get方法从连接池中获取一个连接,并使用连接的引用调用create_user函数。我们在这里也忽略结果,如果任何请求失败,它将被静默跳过,正如在演示中,我们无法处理尚未插入的值。由于我们使用多个连接,如果任何语句失败,我们无法使用事务来回滚更改。

rayon crate 看起来非常令人印象深刻且易于使用。您可能会问:您可以在微服务中使用这个 crate 吗? 答案是:是的! 但请记住,为了收集数据,您必须调用for_each方法,这将阻塞当前线程直到所有任务完成。如果您在异步Future的上下文中(我们在第五章,*使用 Futures Crate 理解异步操作)中调用它,它将阻塞反应器一段时间。

在下一节中,我们将重写针对 MySQL 数据库的此示例。

MySQL

MySQL 是最受欢迎的数据库之一,因此 Rust 自然有与之交互的 crate。我推荐您使用两个很好的 crate:mysql crate 及其异步版本mysql_async crate。

在本节中,我们将重写之前管理用户时支持 MySQL 数据库的示例。我们还将在一个容器中启动数据库的本地实例,并创建一个命令行实用程序,该实用程序连接到数据库实例,发送创建表的查询,并允许我们添加和删除用户。我们将使用最新的 PostgreSQL 示例,它使用r2d2连接池。

测试数据库

为了启动数据库,我们也将使用 Docker 镜像。您可以在本地安装 MySQL,但容器是一种更灵活的方法,它不会阻塞系统,并且您可以轻松地在几秒钟内为测试目的启动一个空数据库。

MySQL 数据库有一个官方镜像,mysql,您可以在以下位置找到:hub.docker.com/_/mysql。您可以使用以下命令使用这些镜像加载和运行容器:

docker run -it --rm --name test-mysql -e MYSQL_ROOT_PASSWORD=password -e MYSQL_DATABASE=test -p 3306:3306 mysql

您可以使用环境变量设置两个必要的参数。首先,MYSQL_ROOT_PASSWORD环境变量为 root 用户设置密码。其次,MYSQL_DATABASE环境变量设置了一个默认数据库的名称,该数据库将在容器首次启动时创建。我们命名我们的容器为test-mysql,并将本地端口3306映射到容器内的3306端口。

为了确保我们的容器已启动,您可以使用本地安装的mysql客户端:

mysql -h 127.0.0.1 -P 3306 -u root -p test

之前的命令连接到127.0.0.1(为了避免使用套接字)的3306端口,用户名为root-p参数请求连接的密码。我们为我们的测试容器设置了一个密码,因为数据库镜像需要它。

我们的数据库已准备好使用。您也可以使用以下命令停止它:

docker stop test-mysql

使用 r2d2 适配器连接

在上一节中,我们使用了来自r2d2 crate 的连接池与 PostgreSQL 数据库进行交互。在r2d2-mysql crate 中也有一个 MySQL 的连接管理器,允许您使用r2d2 crate 来使用 MySQL 连接。r2d2-mysql crate 基于mysql crate。使用连接池与 PostgreSQL 数据库类似简单,但在这里,我们使用MysqlConnectionManager作为r2d2::Pool的类型参数。让我们修改所有带有查询的函数,以使用我们 MySQL 数据库的连接池。

添加依赖项

首先,我们必须添加依赖项以建立到 MySQL 的连接。我们使用与上一个示例中相同的所有依赖项,但将postgres替换为mysql crate,将r2d2_postgres替换为r2d2_mysql crate:

mysql = "14.1"
r2d2_mysql = "9.0"

我们仍然需要csvrayonr2d2serde家族的 crate。

您还必须声明其他类型,以便在代码中使用,如下所示:

use mysql::{Conn, Error, Opts, OptsBuilder};
use r2d2_mysql::MysqlConnectionManager;

数据库交互函数

现在,我们可以用mysql crate 的Conn替换postgres crate 中的Connection实例,以提供我们的交互函数。第一个函数create_table使用对Conn实例的可变引用:

fn create_table(conn: &mut Conn) -> Result<(), Error> {
    conn.query("CREATE TABLE users (
                    id INT(6) UNSIGNED AUTO_INCREMENT PRIMARY KEY,
                    name VARCHAR(50) NOT NULL,
                    email VARCHAR(50) NOT NULL
                  )")
        .map(drop)
}

此外,我们使用了Conn连接对象的query方法来发送查询。此方法不期望参数。我们仍然忽略查询的成功结果,并用map将其drop

下一个函数create_user已转换为以下形式:

fn create_user(conn: &mut Conn, user: &User) -> Result<(), Error> {
     conn.prep_exec("INSERT INTO users (name, email) VALUES (?, ?)",
                  (&user.name, &user.email))
         .map(drop)
 }

我们使用Connprep_exec方法,它期望一个参数元组,这些参数是从User结构体字段中提取的。如您所见,我们使用了?字符来指定插入值的位置。

最后一个函数list_users从查询中收集用户。它比 PostgreSQL 版本更复杂。我们使用了返回实现Iterator特质的QueryResult类型的query方法。我们使用这个属性将结果转换为迭代器,并在Iterator实现的try_fold方法中尝试将值折叠到向量中:

fn list_users(conn: &mut Conn) -> Result<Vec<User>, Error> {
    conn.query("SELECT name, email FROM users")?
        .into_iter()
        .try_fold(Vec::new(), |mut vec, row| {
            let row = row?;
            let user = User {
                name: row.get_opt(0).unwrap()?,
                email: row.get_opt(1).unwrap()?,
            };
            vec.push(user);
            Ok(vec)
        })
}

对于try_fold方法调用,我们提供一个闭包,它期望两个参数:第一个是我们通过try_fold调用传递的向量,而第二个是一个Row实例。我们使用try_fold在行转换到用户失败时返回Error

我们使用Row对象的get_opt方法来获取相应类型的值,并使用?运算符从结果中提取它,或者使用try_fold返回Error。在每次迭代中,我们返回一个包含新附加值的向量。

创建连接池

我们将重用前一个示例中的参数解析器,但将重写建立连接的代码,因为我们现在使用的是 MySQL 而不是 PostgreSQL。首先,我们将数据库链接替换为mysql方案。我们将使用与启动 MySQL 服务器实例相同的参数来建立连接。

我们将地址字符串转换为Opts - 连接的选项,这是用于设置连接参数的 mysql crate 的类型。但是MysqlConnectionManager期望我们提供一个OptsBuilder对象。看看下面的代码:

let addr = matches.value_of("database")
    .unwrap_or("mysql://root:password@localhost:3306/test");
let opts = Opts::from_url(addr)?;
let builder = OptsBuilder::from_opts(opts);
let manager = MysqlConnectionManager::new(builder);
let pool = r2d2::Pool::new(manager)?;
let mut conn = pool.get()?;

现在,我们可以使用builder创建MysqlConnectionManager,并且我们可以使用manager实例创建r2d2::Pool。我们还得到一个可变的conn引用,以便为子命令提供它。

好消息是,这已经足够开始了。我们不需要在我们的分支中做任何改变,除了引用的类型。现在,我们必须传递一个可变的引用到连接:

(CMD_CRATE, _) => {
    create_table(&mut conn)?;
}

尝试启动并检查工具的工作情况。我们将提供一个具有以下格式的 CSV 文件作为输入:

name,email
user01,user01@example.com
user02,user02@example.com
user03,user03@example.com

如果你想检查数据库是否真的发生了变化,请尝试从我们的 CSV 文件导入用户数据:

cargo run -- import < users.csv

你可以使用mysql客户端打印users表:

mysql> SELECT * FROM users;
+----+--------+--------------------+
| id | name   | email              |
+----+--------+--------------------+
|  1 | user01 | user01@example.com |
|  2 | user03 | user03@example.com |
|  3 | user08 | user08@example.com |
|  4 | user06 | user06@example.com |
|  5 | user02 | user02@example.com |
|  6 | user07 | user07@example.com |
|  7 | user04 | user04@example.com |
|  8 | user09 | user09@example.com |
|  9 | user10 | user10@example.com |
| 10 | user05 | user05@example.com |
+----+--------+--------------------+
10 rows in set (0.00 sec)

它工作了!正如你所见,用户以不可预测的顺序被添加,因为我们使用了多个连接和真正的并发。现在你了解了如何使用 SQL 数据库。是时候看看如何通过r2d2crate 与 NoSQL 数据库交互了。

Redis

当编写微服务时,你可能有时需要一个可以按键存储值的存储库;例如,如果你想存储会话,你可以存储会话的保护标识符,并在持久缓存中保留有关用户的其他信息。如果会话数据丢失不是问题;相反,定期清理会话是一个最佳实践,以防用户的会话标识符被盗。

Redis 是一个流行的内存数据结构存储,适用于此用例。它可以用作数据库、消息代理或缓存。在下一节中,我们将使用 Docker 运行 Redis 实例并创建一个命令行工具,帮助管理 Redis 中的用户会话。

为测试设置数据库

Redis 在 Docker Hub 上有一个官方镜像,名为redis。要创建和运行容器,请使用以下命令:

docker run -it --rm --name test-redis -p 6379:6379 redis

此命令从redis镜像运行一个名为test-redis的容器,并将本地端口6379转发到容器的内部端口6379

关于 Redis 的一个有趣的事实是,它使用一个非常简单和直接的交互协议。您甚至可以使用 telnet 与 Redis 交互:

telnet 127.0.0.1 6379
Trying 127.0.0.1...
Connected to 127.0.0.1.
Escape character is '^]'.
SET session-1 "Rust"
+OK
GET session-1
$4
Rust
^]

原生客户端更易于使用,但它期望与原始协议相同的命令。

要关闭运行 Redis 的容器,请使用以下命令:

docker stop test-redis

让我们创建一个用于管理 Redis 会话的工具。

创建连接池

我们已经在 Docker 容器中启动了一个 Redis 实例,因此现在我们可以开始创建一个命令行工具,允许我们连接到该数据库实例并将一些信息放入其中。这个实用程序将不同于我们为 PostgreSQL 和 MySQL 创建的工具,因为 Redis 不使用 SQL 语言。我们将使用 Redis 中可用的特定 API 方法。

在本节中,我们将创建一个新的二进制 crate 并添加使用 r2d2::Pool 从 Redis 设置或获取数据的函数。之后,我们将根据用户指定的命令行参数作为子命令来调用它们。

依赖项

创建一个新的二进制 crate,并将所有必要的依赖项添加到该 crate 的Cargo.toml文件中:

[dependencies]
clap = "2.32"
failure = "0.1"
r2d2 = "0.8"
r2d2_redis = "0.8"
redis = "0.9"

我们添加了本章前面示例中使用的依赖项——clapfailurer2d2。此外,我们还需要redisr2d2_redis crate,它们包含 Redis 的连接管理器,以便我们可以使用 r2d2 crate 的 Pool

接下来,让我们导入创建工具所需的类型:

use clap::{
    crate_authors, crate_description, crate_name, crate_version,
    App, AppSettings, Arg, SubCommand,
};
use redis::{Commands, Connection, RedisError};
use r2d2_redis::RedisConnectionManager;
use std::collections::HashMap;

注意一些类型的用法。我们将 Connection 作为主要连接类型导入,我们将使用它来连接到 Redis 实例。我们还从 r2d2_redis crate 中导入了 RedisConnectionManager。此类型允许 Pool 创建新的连接。您应该注意的最后一件事情是 Command trait。这个 trait 包含反映 Redis 客户端 API 的方法。方法名称与 Redis 协议中相同(但全部小写),如前节中手动测试的那样。Command trait,由 Connection 结构体实现,允许您调用 Redis API 的方法。

Redis 支持许多命令。您可以在redis.io/commands找到完整的列表。redis crate 提供了其中大部分作为 Command trait 的方法。

添加命令和交互函数

我们正在为 Redis 创建的工具将支持三个命令:

  • add - 添加新的会话记录

  • remove - 通过键(即用户名)删除会话记录

  • list - 打印所有会话记录

我们需要为每个子命令的名称定义常量,以防止代码中字符串的错误:

const SESSIONS: &str = "sessions";
const CMD_ADD: &str = "add";
const CMD_REMOVE: &str = "remove";
const CMD_LIST: &str = "list";

此列表还包含 SESSION 常量,作为 Redis 中 HashMap 的名称。现在,我们可以声明用于操作会话数据的函数。

数据操作函数

我们的例子需要三个函数。第一个函数,add_session,在令牌和用户 ID 之间添加关联:

fn add_session(conn: &Connection, token: &str, uid: &str) -> Result<(), RedisError> {
    conn.hset(SESSIONS, token, uid)
}

此函数仅调用 Connectionhset 方法,并通过 token 键在 SESSIONS 映射中设置 uid 值。如果设置操作出现错误,则返回 RedisError

下一个函数,remove_session,也非常简单,它调用了 Connectionhdel 方法:

fn remove_session(conn: &Connection, token: &str) -> Result<(), RedisError> {
    conn.hdel(SESSIONS, token)
}

此函数从 SESSIONS 映射中删除具有 token 键的记录。

最后一个函数,list_sessions,从 SESSION 映射中返回所有令牌-uid 对,作为一个 HashMap 实例。它使用 Connectionhgetall 方法,该方法调用 Redis 中的 HGETALL 方法:

fn list_sessions(conn: &Connection) -> Result<HashMap<String, String>, RedisError> {
     conn.hgetall(SESSIONS)
}

如您所见,所有函数都映射到原始 Redis 命令,看起来非常简单。但所有函数在后台也做得很好,将值转换为相应的 Rust 类型。

现在,我们可以为会话工具创建一个参数解析器。

解析参数

由于我们的命令支持三个子命令,我们必须将它们添加到 clap::App 实例中:

let matches = App::new(crate_name!())
    .version(crate_version!())
    .author(crate_authors!())
    .about(crate_description!())
    .setting(AppSettings::SubcommandRequired)
    .arg(
        Arg::with_name("database")
        .short("d")
        .long("db")
        .value_name("ADDR")
        .help("Sets an address of db connection")
        .takes_value(true),
        )
    .subcommand(SubCommand::with_name(CMD_ADD).about("add a session")
                .arg(Arg::with_name("TOKEN")
                     .help("Sets the token of a user")
                     .required(true)
                     .index(1))
                .arg(Arg::with_name("UID")
                     .help("Sets the uid of a user")
                     .required(true)
                     .index(2)))
    .subcommand(SubCommand::with_name(CMD_REMOVE).about("remove a session")
                .arg(Arg::with_name("TOKEN")
                     .help("Sets the token of a user")
                     .required(true)
                     .index(1)))
    .subcommand(SubCommand::with_name(CMD_LIST).about("print list of sessions"))
    .get_matches();

如前例所示,这也可以使用带有 Redis 连接链接的 --database 参数。它支持两个子命令。add 子命令期望一个会话 TOKEN 和用户的 UIDremove 命令期望一个会话 TOKEN,仅用于从映射中删除它。list 命令不期望任何参数,并打印会话列表。

想象这个例子中的数据结构是一个会话缓存,它持有 tokenuid 之间的关联。在授权后,我们可以将令牌作为安全 cookie 发送,并为每个微服务提供的令牌提取用户的 uid,以实现微服务之间的松耦合。我们将在稍后详细探讨这个概念。

现在,我们准备好使用 r2d2::Pool 连接到 Redis。

连接到 Redis

r2d2 连接到 Redis 的方式与其他数据库类似:

let addr = matches.value_of("database")
    .unwrap_or("redis://127.0.0.1/");
let manager = RedisConnectionManager::new(addr)?;
let pool = r2d2::Pool::builder().build(manager)?;
let conn = pool.get()?;

我们从 --database 参数中获取地址,但如果它未设置,我们将使用默认值 redis://127.0.0.1/。之后,我们将创建一个新的 RedisConnectionManager 实例,并将其传递给 Pool::new 方法。

执行子命令

我们使用分支的结构来匹配我们之前示例中的子命令:

match matches.subcommand() {
    (CMD_ADD, Some(matches)) => {
        let token = matches.value_of("TOKEN").unwrap();
        let uid = matches.value_of("UID").unwrap();
        add_session(&conn, token, uid)?;
    }
    (CMD_REMOVE, Some(matches)) => {
        let token = matches.value_of("TOKEN").unwrap();
        remove_session(&conn, token)?;
    }
    (CMD_LIST, _) => {
        println!("LIST");
        let sessions = list_sessions(&conn)?;
        for (token, uid) in sessions {
            println!("Token: {:20}   Uid: {:20}", token, uid);
        }
    }
    _ => { matches.usage(); }
}

对于 add 子命令,我们从参数中提取 TOKENUID 值,并将它们与 Connector 的引用一起传递给 add_session 函数。对于 remove 子命令,我们仅提取 TOKEN 值,并使用相应的参数调用 remove_session 函数。对于 list 子命令,我们直接调用 list_session 函数,因为我们不需要任何额外的参数来从映射中获取所有值。这返回一个对向量。对中的第一个元素包含 token,第二个包含 uid。我们使用固定宽度指定符 {:20} 打印值。

测试我们的 Redis 示例

让我们编译并测试这个工具。我们将添加三个用户会话:

cargo run -- add 7vQ2MhnRcyYeTptp a73bbfe3-df6a-4dea-93a8-cb4ea3998a53
cargo run -- add pTySt8FI7TIqId4N 0f3688be-0efc-4744-829c-be5d177e0e1c
cargo run -- add zJx3mBRpJ9WTkwGU f985a744-6648-4d0a-af5c-0b71aecdbcba

要打印列表,请运行 list 命令:

cargo run -- list

使用此方法,您将看到您创建的所有会话:

LIST
Token: pTySt8FI7TIqId4N       Uid: 0f3688be-0efc-4744-829c-be5d177e0e1c
Token: zJx3mBRpJ9WTkwGU       Uid: f985a744-6648-4d0a-af5c-0b71aecdbcba
Token: 7vQ2MhnRcyYeTptp       Uid: a73bbfe3-df6a-4dea-93a8-cb4ea3998a53

我们已经学习了如何使用 Redis。它对于存储用于缓存的消息很有用。接下来,我们将查看最后一个 NoSQL 数据库:MongoDB。

MongoDB

MongoDB 是一个流行的 NoSQL 数据库,具有出色的功能和良好的性能。它非常适合结构快速变化的数据,如下所示:

  • 运营智能(日志和报告)

  • 产品数据管理(产品目录、层次结构和类别)

  • 内容管理系统(帖子、评论和其他记录)

我们将创建一个示例来存储用户的操作。

为测试启动数据库

我们将使用官方 Docker 镜像来启动 MongoDB 实例。您可以使用以下命令简单地完成此操作:

docker run -it --rm --name test-mongo -p 27017:27017 mongo

此命令从 mongo 镜像运行一个名为 test-mongo 的容器,并将本地端口 27017 转发到容器的相同内部端口。容器关闭后,容器产生的数据将被删除。

如果您有一个 mongo 客户端,您可以使用它连接到容器内的数据库实例:

mongo 127.0.0.1:27017/admin

当您需要关闭容器时,使用 dockerstop 子命令并指定容器的 name

docker stop test-mongo

如果您将容器附加到带有 -it 参数的终端,则可以使用 *Ctrl *+ C 终止它,就像我之前做的那样。

现在,我们可以看看如何使用 mongor2d2-mongo crate 连接到数据库。

使用 r2d2 池连接到数据库

按照惯例,我们将使用 r2d2 crate 中的 Pool,但在本例(以及 Redis 示例)中,我们不会同时使用多个连接。将所有必要的依赖项添加到新的二进制 crate:

[dependencies]
bson = "0.13"
chrono = { version = "0.4", features = ["serde"] }
clap = "2.32"
failure = "0.1"
mongodb = "0.3"
r2d2 = "0.8"
r2d2-mongodb = "0.1"
serde = "1.0"
serde_derive = "1.0"
url = "1.7"

列表并不短。除了您已经熟悉的 crates 之外,我们还添加了 bsonchronourl crates。第一个 crate 我们需要与数据库中的数据一起工作;第二个,用于使用 Utc 类型;最后一个用于将 URL 字符串拆分成片段。

按照以下方式导入所有必要的类型:

use chrono::offset::Utc;
use clap::{
    crate_authors, crate_description, crate_name, crate_version,
    App, AppSettings, Arg, SubCommand,
};
use mongodb::Error;
use mongodb::db::{Database, ThreadedDatabase};
use r2d2::Pool;
use r2d2_mongodb::{ConnectionOptionsBuilder, MongodbConnectionManager};
use url::Url;

此用户的日志工具将支持两个命令:add 用于添加记录,list 用于打印所有记录的列表。添加以下必要的常量:

const CMD_ADD: &str = "add";
const CMD_LIST: &str = "list";

要设置和获取结构化数据,我们需要声明一个 Activity 结构体,该结构体将用于创建 BSON 文档以及从 BSON 数据中恢复它,因为 MongoDB 使用此格式进行数据交互。Activity 结构体有三个字段,user_idactivitydatetime

#[derive(Deserialize, Debug)]
struct Activity {
    user_id: String,
    activity: String,
    datetime: String,
}

交互函数

由于我们已声明结构体,我们可以添加与数据库一起工作的函数。我们将添加的第一个函数是 add_activity,它将活动记录添加到数据库中:

fn add_activity(conn: &Database, activity: Activity) -> Result<(), Error> {
    let doc = doc! {
        "user_id": activity.user_id,
        "activity": activity.activity,
        "datetime": activity.datetime,
    };
    let coll = conn.collection("activities");
    coll.insert_one(doc, None).map(drop)
}

此函数仅将 Activity 结构体转换为 BSON 文档,通过从结构体中提取字段并使用相同字段构建 BSON 文档来实现。我们可以为结构体推导出 Serialize 特性以使用自动序列化,但为了演示目的,我使用了 doc! 宏来展示你可以添加一个可以即时构建的自由格式文档。

要添加 Activity,我们通过 collection() 方法从 Database 实例中获取一个名为 activities 的集合,并调用 Collectioninsert_one 方法来添加记录。

下一个方法是 list_activities。此方法使用 Database 实例来查找 activities 集合中的所有值。我们使用 Collectionfind() 方法来获取数据,但请确保将过滤器(第一个参数)设置为 None,将选项(第二个参数)设置为 None,以获取集合中的所有值。

你可以调整这些参数进行过滤,或者限制你检索的记录数量:

fn list_activities(conn: &Database) -> Result<Vec<Activity>, Error> {
    conn.collection("activities").find(None, None)?
        .try_fold(Vec::new(), |mut vec, doc| {
            let doc = doc?;
            let activity: Activity = bson::from_bson(bson::Bson::Document(doc))?;
            vec.push(activity);
            Ok(vec)
        })
}

要将 find 查询返回的每个记录转换为 BSON 文档,我们可以使用 bson::from_bson 方法,因为我们已经为 Activity 结构体推导出 Deserialize 特性。try_fold 方法允许我们在转换失败时中断折叠。我们将所有成功转换的值推送到我们提供给 try_fold 方法调用的第一个参数的向量中。现在,我们可以解析参数,以便我们可以准备一个池来调用声明的交互函数。

解析参数

我们的工具期望两个子命令:addlist。让我们将它们添加到 clap::App 实例中。像所有之前的示例一样,我们也添加了一个 --database 参数来设置连接 URL。请看以下代码:

let matches = App::new(crate_name!())
    .version(crate_version!())
    .author(crate_authors!())
    .about(crate_description!())
    .setting(AppSettings::SubcommandRequired)
    .arg(
        Arg::with_name("database")
        .short("d")
        .long("db")
        .value_name("ADDR")
        .help("Sets an address of db connection")
        .takes_value(true),
        )
    .subcommand(SubCommand::with_name(CMD_ADD).about("add user to the table")
                .arg(Arg::with_name("USER_ID")
                     .help("Sets the id of a user")
                     .required(true)
                     .index(1))
                .arg(Arg::with_name("ACTIVITY")
                     .help("Sets the activity of a user")
                     .required(true)
                     .index(2)))
    .subcommand(SubCommand::with_name(CMD_LIST).about("print activities list of users"))
    .get_matches();

add 子命令期望两个参数:USER_IDACTIVITY。在 Activity 结构体中,这两个参数都表示为 String 类型的值。我们将要求这些参数,但我们将获取任何提供的值,没有任何限制。list 子命令没有额外的参数。

创建连接池

要连接到数据库,我们从 --database 命令行参数中提取连接 URL。如果没有设置,我们使用默认值 mongodb://localhost:27017/admin

let addr = matches.value_of("database")
    .unwrap_or("mongodb://localhost:27017/admin");
let url = Url::parse(addr)?;

但我们也将它解析到 Url 结构体中。这是必要的,因为 MongoDB 连接期望选项集通过单独的值来收集:

let opts = ConnectionOptionsBuilder::new()
    .with_host(url.host_str().unwrap_or("localhost"))
    .with_port(url.port().unwrap_or(27017))
    .with_db(&url.path()[1..])
    .build();

let manager = MongodbConnectionManager::new(opts);

let pool = Pool::builder()
    .max_size(4)
    .build(manager)?;

let conn = pool.get()?;

在前面的代码中,我们创建了一个新的ConnectionOptionsBuilder实例,并用从解析的Url实例中获取的值填充它。我们设置了hostportdb名称。如您所见,我们跳过了路径的第一个字符,以便将其用作数据库的名称。调用build方法来构建ConnectionOptions结构体。现在,我们可以创建一个MongodbConnectionManager实例,并使用它来创建一个Pool实例。但是,在这个例子中,我们调用的是builder方法,而不是new,以向您展示如何设置Pool实例中的连接数。我们将此设置为4。之后,我们调用build方法来创建一个Pool实例。与之前的示例一样,我们调用Poolget方法来从一个池中获取Database连接对象。

实现子命令

子命令的实现很简单。对于add子命令,我们提取两个参数,USER_IDACTIVITY,并使用它们来创建一个Activity结构体实例。我们还使用Utc::now方法获取当前时间,并将其保存到Activitydatetime字段中。最后,我们调用add_activity方法将Activity实例添加到 MongoDB 数据库中:

match matches.subcommand() {
    (CMD_ADD, Some(matches)) => {
        let user_id = matches.value_of("USER_ID").unwrap().to_owned();
        let activity = matches.value_of("ACTIVITY").unwrap().to_owned();
        let activity = Activity {
            user_id,
            activity,
            datetime: Utc::now().to_string(),
        };
        add_activity(&conn, activity)?;
    }
    (CMD_LIST, _) => {
        let list = list_activities(&conn)?;
        for item in list {
            println!("User: {:20}    Activity: {:20}    DateTime: {:20}",
                     item.user_id, item.activity, item.datetime);
        }
    }
    _ => { matches.usage(); }
}

列表子命令调用list_activities函数,然后遍历所有记录并将它们打印到终端。日志工具已完成 - 我们现在可以测试它了。

测试

使用以下命令编译和运行工具:

cargo run -- add 43fb507d-4cee-431a-a7eb-af31a1eeed02 "Logged In"
cargo run -- add 43fb507d-4cee-431a-a7eb-af31a1eeed02 "Added contact information"
cargo run -- add 43fb507d-4cee-431a-a7eb-af31a1eeed02 "E-mail confirmed"

使用以下命令打印添加的记录列表:

cargo run -- list

这将打印以下输出:

User: 43fb507d-4cee-431a-a7eb-af31a1eeed02   DateTime: 2018-11-30 14:19:26.245957656 UTC    Activity: Logged In
User: 43fb507d-4cee-431a-a7eb-af31a1eeed02   DateTime: 2018-11-30 14:19:42.249548906 UTC   Activity: Added contact information
User: 43fb507d-4cee-431a-a7eb-af31a1eeed02   DateTime: 2018-11-30 14:19:59.035373758 UTC   Activity: E-mail confirmed

你也可以使用mongo客户端来检查结果:

mongo admin
> db.activities.find()
{ "_id" : ObjectId("5c0146ee6531339934e7090c"), "user_id" : "43fb507d-4cee-431a-a7eb-af31a1eeed02", "activity" : "Logged In", "datetime" : "2018-11-30 14:19:26.245957656 UTC" }
{ "_id" : ObjectId("5c0146fe653133b8345ed772"), "user_id" : "43fb507d-4cee-431a-a7eb-af31a1eeed02", "activity" : "Added contact information", "datetime" : "2018-11-30 14:19:42.249548906 UTC" }
{ "_id" : ObjectId("5c01470f653133cf34391c1f"), "user_id" : "43fb507d-4cee-431a-a7eb-af31a1eeed02", "activity" : "E-mail confirmed", "datetime" : "2018-11-30 14:19:59.035373758 UTC" }

你做到了!它运行得很好!现在,你知道如何使用 Rust 的所有流行数据库。在下一章中,我们将通过对象关系映射ORM)来提高这方面的知识,ORM 有助于简化数据库结构声明、交互和迁移。

DynamoDB

在本章中,我们使用了本地数据库实例。自己维护数据库的缺点是你还必须自己处理可伸缩性。有许多服务提供流行的数据库,这些数据库可以自动伸缩以满足你的需求。但并非每个数据库都可以无限制地增长:传统的 SQL 数据库在表变得很大时通常会经历速度性能问题。对于大型数据集,你应该选择使用设计时就提供可伸缩性的键值数据库(如 NoSQL)。在本节中,我们将探讨使用由 Amazon 创建的DynamoDB的用法,以提供作为一个服务的易于伸缩的数据库。

要使用 AWS 服务,你需要 AWS SDK,但 Rust 没有官方的 SDK,因此我们将使用rusoto包,它为 Rust 提供了 AWS API。让我们首先将本章中较早创建的工具移植到DynamoDB。首先,我们应该在DynamoDB实例中创建一个表。

为测试设置数据库

由于 AWS 服务是付费的,因此对于开发或测试你的应用程序,最好在本地启动DynamoDB数据库的一个实例。Docker Hub 上有DynamoDB的镜像。使用以下命令运行实例:

docker run -it --rm --name test-dynamodb -p 8000:8000 amazon/dynamodb-local

此命令创建一个数据库实例,并将容器的8000端口转发到相同数字的本地端口。

要与这个数据库实例一起工作,你需要 AWS CLI 工具。可以使用docs.aws.amazon.com/cli/latest/userguide/cli-chap-install.html中的说明进行安装。在 Linux 上,我使用以下命令:

pip install awscli --upgrade --user

此命令不需要安装管理权限。在我安装了工具之后,我创建了一个具有程序访问权限的用户,具体细节如下:console.aws.amazon.com/iam/home#/users$new?step=details。你可以在docs.aws.amazon.com/IAM/latest/UserGuide/getting-started_create-admin-group.html中了解更多关于创建用户账户以访问 AWS API 的信息。

当你有程序访问权限的用户时,你可以使用configure子命令配置 AWS CLI:

aws configure
AWS Access Key ID [None]: <your-access-key>
AWS Secret Access Key [None]: <your-secret-key>
Default region name [None]: us-east-1
Default output format [None]: json

子命令会要求你提供用户凭据、默认区域和所需的输出格式。根据适当的情况填写这些字段。

现在,我们可以使用 AWS CLI 工具创建一个表格。将以下命令输入到控制台:

aws dynamodb create-table --cli-input-json file://table.json --endpoint-url http://localhost:8000 --region custom

此命令从本地数据库中table.json文件的一个 JSON 格式的声明创建一个表格,端点为localhost:8000。这是我们已经启动的容器的地址。查看此表格声明文件的内容:

{
    "TableName" : "Locations",
    "KeySchema": [
        {
            "AttributeName": "Uid",
            "KeyType": "HASH"
        },
        {
            "AttributeName": "TimeStamp",
            "KeyType": "RANGE"
        }
    ],
    "AttributeDefinitions": [
        {
            "AttributeName": "Uid",
            "AttributeType": "S"
        },
        {
            "AttributeName": "TimeStamp",
            "AttributeType": "S"
        }
    ],
    "ProvisionedThroughput": {
        "ReadCapacityUnits": 1,
        "WriteCapacityUnits": 1
    }
}

此文件包含一个具有两个必需属性的表格声明:

  • Uid - 此属性存储用户标识符。此属性将用作分区键。

  • TimeStamp - 此属性存储当位置数据被生成时的戳记。此属性将用作排序键以对记录进行排序。

你可以使用以下命令检查数据库实例是否包含此新表格:

aws dynamodb list-tables --endpoint-url http://localhost:8000 --region custom

它打印出数据库实例包含的表格列表,但我们的列表相当短,因为我们只有一个表格:

{
    "TableNames": [
        "Locations"
    ]
}

数据库已准备就绪。现在,我们将创建一个工具,使用 Rust 向此表格添加记录。

连接到 DynamoDB

在本节中,我们将创建一个工具,用于向我们的DynamoDB数据库中的表格添加记录,并打印出表格中的所有记录。首先,我们需要添加所有必要的 crate。

添加依赖项

要与 AWS API 一起工作,我们将使用rusotocrate。实际上,它不是一个单独的 crate,而是一组 crate,其中每个 crate 都覆盖 AWS API 的一些功能。基本 crate 是rusoto_core,其中包含表示 AWS API 端点地址的Region结构体。Region通常对其他 crate 是必要的。此外,rusoto_corecrate 重新导出rusoto_credentialcrate,它包含用于加载和管理 AWS 凭证以访问 API 的类型。

要与DynamoDB数据库交互,我们需要添加rusoto_dynamodb依赖项。完整的列表如下:

chrono = "0.4"

clap = "2.32"

failure = "0.1"

rusoto_core = "0.36.0"

rusoto_dynamodb = "0.36.0"

我们还添加了chrono依赖项来生成时间戳并将它们转换为 ISO-8601 格式的字符串。我们使用clapcrate 来解析命令行参数,并使用failurecrate 从main函数返回一个通用的Error类型。

我们需要在我们的代码中以下类型:

use chrono::Utc;

use clap::{App, AppSettings, Arg, SubCommand,
    crate_authors, crate_description, crate_name, crate_version};

use failure::{Error, format_err};

use rusoto_core::Region;

use rusoto_dynamodb::{AttributeValue, DynamoDb, DynamoDbClient,
    QueryInput, UpdateItemInput};

use std::collections::HashMap;

值得注意的是从rusoto_corerusoto_dynamodbcrate 导入的类型。我们导入了Region结构体,它用于设置 AWS 端点的位置。DynamoDb特质和DynamoDbClient用于获取对数据库的访问权限。AttributeValue是一种用于表示存储在 DynamoDB 表中的值的类型。QueryInput是一个结构体,用于准备query,而UpdateItemInput是一个结构体,用于准备update_item请求。

让我们添加与DynamoDB数据库交互的函数。

交互函数

在本节中,我们将创建一个工具,该工具将位置记录存储到数据库中,并查询特定用户的位置点。为了在代码中表示位置,我们声明以下Location结构体:

#[derive(Debug)]
struct Location {
    user_id: String,
    timestamp: String,
    longitude: String,
    latitude: String,
}

这个结构体保存user_id,它代表分区键,以及timestamp,它代表排序键。

DynamoDB是一个键值存储,其中每个记录都有一个唯一的键。当你声明表时,你必须决定哪些属性将作为记录的键。你可以选择最多两个键。第一个是必需的,代表用于在数据库分区之间分配数据的分区键。第二个键是可选的,代表用于在表中排序项的属性。

rusoto_dynamodbcrate 包含一个AttributeValue结构体,它在查询和结果中用于插入或从表中提取数据。由于表中的每个记录(即每个项)都是属性名称到属性值的集合,我们将添加from_map方法将属性HashMap转换为我们的Location类型:

impl Location {
    fn from_map(map: HashMap<String, AttributeValue>) -> Result<Location, Error> {
        let user_id = map
            .get("Uid")
            .ok_or_else(|| format_err!("No Uid in record"))
            .and_then(attr_to_string)?;
        let timestamp = map
            .get("TimeStamp")
            .ok_or_else(|| format_err!("No TimeStamp in record"))
            .and_then(attr_to_string)?;
        let latitude = map
            .get("Latitude")
            .ok_or_else(|| format_err!("No Latitude in record"))
            .and_then(attr_to_string)?;
        let longitude = map
            .get("Longitude")
            .ok_or_else(|| format_err!("No Longitude in record"))
            .and_then(attr_to_string)?;
        let location = Location { user_id, timestamp, longitude, latitude };
        Ok(location)
    }
}

我们需要四个属性:UidTimeStampLongitudeLatitude。我们使用attr_to_string方法从映射中提取每个属性并将其转换为Location实例:

fn attr_to_string(attr: &AttributeValue) -> Result<String, Error> {
    if let Some(value) = &attr.s {
        Ok(value.to_owned())
    } else {
        Err(format_err!("no string value"))
    }
}

AttributeValue结构体包含多个字段,用于不同类型的值:

  • b - 一个由Vec<u8>表示的二进制值

  • bool - 一个具有bool类型的布尔值

  • bs - 一个二进制集,但表示为Vec<Vec<u8>>

  • l - 一个Vec<AttributeValue>类型的属性列表

  • m - 一个HashMap<String, AttributeValue>类型的属性映射

  • n - 一个以String类型存储的数字,以保持精确值而不丢失任何精度

  • ns - 一个作为Vec<String>的数字集合

  • null - 用于表示空值,并以bool存储,这意味着值是 null

  • s - 一个字符串,类型为String

  • ss - 一个字符串集合,类型为Vec<String>

你可能会注意到没有为时间戳指定数据类型。这是真的,因为DynamoDB为大多数数据类型使用字符串。

我们使用s字段来处理我们将通过add_location函数添加的字符串值:

fn add_location(conn: &DynamoDbClient, location: Location) -> Result<(), Error> {
    let mut key: HashMap<String, AttributeValue> = HashMap::new();
    key.insert("Uid".into(), s_attr(location.user_id));
    key.insert("TimeStamp".into(), s_attr(location.timestamp));
    let expression = format!("SET Latitude = :y, Longitude = :x");
    let mut values = HashMap::new();
    values.insert(":y".into(), s_attr(location.latitude));
    values.insert(":x".into(), s_attr(location.longitude));
    let update = UpdateItemInput {
        table_name: "Locations".into(),
        key,
        update_expression: Some(expression),
        expression_attribute_values: Some(values),
        ..Default::default()
    };
    conn.update_item(update)
        .sync()
        .map(drop)
        .map_err(Error::from)
}

这个函数期望两个参数:数据库客户端的引用,以及要存储的Location实例。我们必须手动准备数据,以便将其作为属性映射进行存储,因为DynamoDbClient只接受AttributeValue类型的值。键中包含的属性被插入到HashMap中,值从Location实例中提取,并使用具有以下声明的s_attr函数转换为AttributeValue

fn s_attr(s: String) -> AttributeValue {
    AttributeValue {
        s: Some(s),
        ..Default::default()
    }
}

在我们填充了key映射之后,我们可以用表达式设置其他属性。为了将属性设置到项目上,我们必须在DynamoDB语法中指定它们,例如SET Longitude = :x, Latitude = :y。这个表达式意味着我们添加了两个名为LongitudeLatitude的属性。在上面的表达式中,我们使用了占位符:x:y,这些将被我们从HashMap中传递的实际值所替换。

更多关于表达式的信息可以在这里找到:docs.aws.amazon.com/amazondynamodb/latest/developerguide/Expressions.html

当所有准备好的数据都准备好后,我们填充UpdateItemInput结构体,并将table_name设置为"Locations",因为update_item方法需要这个参数。

update_item方法返回RusotoFuture,它实现了我们在第五章中探讨的Future特质,即使用 Futures Crate 理解异步操作。你可以在异步应用程序中使用rusoto crate。由于我们在这个例子中没有使用 reactor 或异步操作,我们将调用RusotoFuturesync方法,这将阻塞当前线程并等待Result

我们已经实现了一个向表中创建新数据项的方法,现在我们需要一个函数来检索这个表中的数据。以下list_locations函数从Locations表中获取特定用户的Location列表:

fn list_locations(conn: &DynamoDbClient, user_id: String) -> Result<Vec<Location>, Error> {
    let expression = format!("Uid = :uid");
    let mut values = HashMap::new();
    values.insert(":uid".into(), s_attr(user_id));
    let query = QueryInput {
        table_name: "Locations".into(),
        key_condition_expression: Some(expression),
        expression_attribute_values: Some(values),
        ..Default::default()
    };
    let items = conn.query(query).sync()?
        .items
        .ok_or_else(|| format_err!("No Items"))?;
    let mut locations = Vec::new();
    for item in items {
        let location = Location::from_map(item)?;
        locations.push(location);
    }
    Ok(locations)
}

list_locations函数期望DynamoDbClient实例的引用和一个包含用户 ID 的字符串。如果表中存在请求用户的条目,它们将作为Vec类型的条目返回,并转换为Location类型。

在这个函数中,我们使用DynamoDbClientquery方法,它期望一个QueryInput结构体作为参数。我们用表的名称、键表达式的条件以及填充该表达式的值来填充它。我们使用一个简单的Uid = :uid表达式来查询具有相应Uid分区键值的项。我们使用一个:uid占位符并创建一个带有:uid键和user_id值的HashMap实例,该值通过s_attr函数调用转换为AttributeValue

现在,我们有两个函数来插入和查询数据。我们将使用它们来实现一个与DynamoDB交互的命令行工具。让我们从解析工具的参数开始。

解析命令行参数

AWS 被划分为区域,每个区域都有自己的端点来连接服务。我们的工具将支持两个参数来设置区域和端点:

.arg(
   Arg::with_name("region")
   .long("region")
   .value_name("REGION")
   .help("Sets a region")
   .takes_value(true),
   )
.arg(
   Arg::with_name("endpoint")
   .long("endpoint-url")
   .value_name("URL")
   .help("Sets an endpoint url")
   .takes_value(true),
   )

我们将这两个都添加到App实例中。该工具将支持两个命令:添加新项和打印所有项。第一个子命令是add,它期望三个参数:USER_IDLONGITUDELATITUDE

.subcommand(SubCommand::with_name(CMD_ADD).about("add geo record to the table")
           .arg(Arg::with_name("USER_ID")
                .help("Sets the id of a user")
                .required(true)
                .index(1))
           .arg(Arg::with_name("LATITUDE")
                .help("Sets a latitudelongitude of location")
                .required(true)
                .index(2))
           .arg(Arg::with_name("LONGITUDE")
                .help("Sets a longitude of location")
                .required(true)
                .index(3)))

list子命令只需要在参数中提供USER_ID

.subcommand(SubCommand::with_name(CMD_LIST).about("print all records for the user")
           .arg(Arg::with_name("USER_ID")
                .help("User if to filter records")
                .required(true)
                .index(1)))

将所有前面的代码添加到main函数中。我们可以使用这些参数来创建一个Region实例,我们可以使用它来与DynamoDB建立连接:

let region = matches.value_of("endpoint").map(|endpoint| {
     Region::Custom {
         name: "custom".into(),
         endpoint: endpoint.into(),
     }
 }).ok_or_else(|| format_err!("Region not set"))
 .or_else(|_| {
     matches.value_of("region")
         .unwrap_or("us-east-1")
         .parse()
 })?;

代码按照以下逻辑工作:如果用户设置了--endpoint-url参数,我们创建一个具有自定义名称的Region并提供一个endpoint值。如果没有设置endpoint,我们尝试将--region参数解析为Region实例,或者默认使用us-east-1值。

AWS 非常重视区域值,如果你在一个区域创建了一个表,你就无法从另一个区域访问那个表。我们为区域使用了一个自定义名称,但对于生产工具来说,最好使用~/.aws/config文件或提供自定义这些设置的灵活性。

现在,我们可以使用Region值来创建一个DynamoDbClient实例:

let client = DynamoDbClient::new(region);

DynamoDbClient结构体用于向我们的DynamoDB实例发送查询。我们将在命令的实现中使用这个实例。你还记得解析命令行参数的match表达式吗?首先为add子命令添加这个实现,它将新项放入表中,如下所示:

(CMD_ADD, Some(matches)) => {
     let user_id = matches.value_of("USER_ID").unwrap().to_owned();
     let timestamp = Utc::now().to_string();
     let latitude = matches.value_of("LATITUDE").unwrap().to_owned();
     let longitude = matches.value_of("LONGITUDE").unwrap().to_owned();
     let location = Location { user_id, timestamp, latitude, longitude };
     add_location(&client, location)?;
 }

实现很简单——我们提取所有提供的参数,使用Utc::now调用生成时间戳,并将其转换为 ISO-8601 格式的String类型。最后,我们填充Location实例并调用我们之前声明的add_location函数。

你是否曾经想过为什么数据库使用 ISO-8601 格式来表示日期,这些日期看起来像YEAR-MONTH-DATE HOUR:MINUTE:SECOND?这是因为以这种格式存储在字符串中的日期如果按字母顺序排序,则是按时间顺序排序的。这非常方便:你可以排序日期,将最早的放在顶部,最新的放在底部。

我们仍然需要实现list子命令:

(CMD_LIST, Some(matches)) => {
     let user_id = matches.value_of("USER_ID").unwrap().to_owned();
     let locations = list_locations(&client, user_id)?;
     for location in locations {
         println!("{:?}", location);
     }
 }

此命令提取 USER_ID 参数,并使用提供的 user_id 值调用 list_locations 函数。最后,我们遍历所有位置并将它们打印到终端。

实现已完成,我们现在可以尝试它了。

测试

为了测试该工具,使用 Docker 启动 DynamoDB 实例并创建一个表,就像我们在本章中之前所做的那样。让我们添加两个用户的四个位置:

cargo run -- --endpoint-url http://localhost:8000 add 651B4984-1252-4ECE-90E7-0C8B58541E7C 52.73169 41.44326
cargo run -- --endpoint-url http://localhost:8000 add 651B4984-1252-4ECE-90E7-0C8B58541E7C 52.73213 41.44443
cargo run -- --endpoint-url http://localhost:8000 add 651B4984-1252-4ECE-90E7-0C8B58541E7C 52.73124 41.44435
cargo run -- --endpoint-url http://localhost:8000 add 7E3E27D0-D002-43C4-A0DF-415B2F5FF94D 35.652832 139.839478

我们还将 --endpoint-url 参数设置为指向我们的客户端到本地的 DynamoDB 实例。当所有记录都已添加后,我们可以使用 list 子命令来打印指定用户的全部值:

cargo run -- --endpoint-url http://localhost:8000 list 651B4984-1252-4ECE-90E7-0C8B58541E7C

此命令将打印出类似以下内容:

Location { user_id: "651B4984-1252-4ECE-90E7-0C8B58541E7C", timestamp: "2019-01-04 19:58:26.278518362 UTC", latitude: "52.73169", longitude: "41.44326" }
Location { user_id: "651B4984-1252-4ECE-90E7-0C8B58541E7C", timestamp: "2019-01-04 19:58:42.559125438 UTC", latitude: "52.73213", longitude: "41.44443" }
Location { user_id: "651B4984-1252-4ECE-90E7-0C8B58541E7C", timestamp: "2019-01-04 19:58:55.730794942 UTC", latitude: "52.73124", longitude: "41.44435" }

如您所见,我们已按顺序检索了所有值,因为我们使用了 TimeStamp 属性作为表的排序键。现在,您已经具备了创建使用数据库的微服务的能力,但如果您使用 SQL 数据库,您还可以添加一个额外的抽象层,并以原生 Rust 结构体的形式与数据库记录一起工作,而无需编写粘合代码。在下一章中,我们将通过对象关系映射来检查这种方法。

概述

在本章中,我们涵盖了与数据库相关的大量内容。我们首先创建了一个到 PostgreSQL 的普通连接。之后,我们使用 r2d2 crate 添加了一个连接池,并利用 rayon crate 并行执行 SQL 语句。我们创建了一个用于管理我们的 users 数据库的工具,并为其 MySQL 数据库重新实现了它。

我们还掌握了一些与 NoSQL 数据库交互的方法,特别是 Redis 和 MongoDB。

我们最后探索的数据库是 DynamoDB,它是亚马逊网络服务的一部分,并且可以非常容易地进行扩展。

对于所有示例,我们都在容器中运行数据库实例,因为这是最简单的方式来测试与数据库的交互。我们尚未在微服务中使用数据库连接,因为这需要一个单独的线程来避免阻塞。我们将在第十章,微服务中的后台任务和线程池中学习如何使用异步代码来使用后台任务。

在下一章中,我们将探讨使用数据库的不同方法——使用 diesel crate 进行对象关系映射。

第八章:使用对象关系映射(ORM)与数据库交互

在本章中,我们将继续与数据库交互,但这次我们将使用 diesel crate 探索对象关系映射(ORM)。这个 crate 帮助生成代表 SQL 数据库中表和记录的 Rust 类型。ORM 允许您在代码中使用原生数据结构,并将记录和数据库表映射到它们。它很有用,因为编译器会负责匹配数据库中的数据列和源代码中的结构体的类型。

阅读本章后,您将熟悉以下内容:

  • 使用 diesel crate 与 r2d2

  • 生成和应用迁移

  • 使用 ORM 类型访问数据

技术要求

在本章中,我们将使用 SQLite 嵌入式数据库。您不需要安装和运行数据库,但您需要 PostgreSQL、MySQL 和 SQLite 数据库的开发包。在您的系统上安装它们。

您可以在 GitHub 上找到本章的示例:github.com/PacktPublishing/Hands-On-Microservices-with-Rust/tree/master/Chapter08.

diesel crate

在上一章中,我们学习了如何与不同的数据库交互。但我们讨论的方法存在潜在困难——您必须检查添加到应用程序中的原始请求。如果 Rust 编译器控制数据的结构并生成所有必要的请求,那么会更好。这种正式和严格的方法可以通过 diesel crate 实现。

Rust 有一个很棒的功能可以创建宏并生成代码。它允许 diesel crate 的创建者创建一个特定领域的语言来查询数据库中的数据。要开始使用这个 crate,我们需要将其添加到一个新项目中。

添加必要的依赖项

创建一个新的 crate 并添加以下依赖项:

[dependencies]
clap = "2.32"
diesel = { version = "¹.1.0", features = ["sqlite", "r2d2"] }
failure = "0.1"
r2d2 = "0.8"
serde = "1.0"
serde_derive = "1.0"
uuid = { version = "0.5", features = ["serde", "v4"] }

我们添加了 clapr2d2serde crate,以及 serde_derive crate。我们还需要 uuid crate 来生成用户 ID。我们还添加了具有以下功能的 diesel crate:

  • sqlite:使 crate 能够使用 SQLite 数据库

  • r2d2:使用池而不是普通连接

下一步您需要的是 diesel_cli 工具。

diesel_cli

diesel_cli 是创建迁移并应用它们所必需的。要安装此工具,请使用以下参数的 cargo

cargo install diesel_cli

然而,您需要 PostgreSQL、MySQL 和 SQLite 的开发包来构建这个工具。如果您没有或无法安装它们,您可以在 cargo install 中传递特殊参数。例如,如果您想在本章的示例中使用 diesel_cli,只需安装具有 sqlite 功能的工具即可:

cargo install diesel_cli --no-default-features --features "sqlite"

当您安装了 diesel-cli 工具后,运行它,使用 setup 命令准备应用程序以使用 diesel crate:

diesel setup

现在,我们必须为我们的示例准备所有必要的迁移。

创建迁移

此命令创建一个 migrations 文件夹,你可以使用以下命令存储迁移:

diesel migration generate <name>

此命令创建一个名为 <name> 的迁移并将其存储在 migrations 文件夹中。例如,如果你将创建的迁移的名称设置为 create_tables,你将在 migrations 文件夹中看到以下结构:

migrations/
└── 2018-11-22-192300_create_tables/
    ├── up.sql
    └── down.sql

对于每个迁移,generate 命令都会创建一个文件夹和一对文件:

  • up.sql:应用迁移的语句

  • down.sql:回滚迁移的语句

所有迁移都是手写的。你需要自己添加所有必要的迁移语句。以我们的例子为例,我们需要在 up.sql 文件中添加以下语句:

CREATE TABLE users (
   id TEXT PRIMARY KEY NOT NULL,
   name TEXT NOT NULL,
   email TEXT NOT NULL
 );

相反的语句在 down.sql 文件中:

DROP TABLE users;

应用 up.sql 脚本会创建与我们在上一章中使用的相同结构的 users 数据库。回滚脚本会删除用户表。

现在,我们可以使用此命令创建数据库并应用所有迁移:

DATABASE_URL=test.db diesel migration run

我们将 DATABASE_URL 设置为 test.db 以在当前文件夹中创建一个 SQLite 数据库。run 命令按顺序运行所有迁移。你可以有多个迁移,并且可以从一个结构级别移动到另一个,无论是向前还是向后。

小心!你可以有多个迁移,但你不能有来自不同项目到同一数据库的竞争迁移。自动迁移的问题是你不能从多个服务中进行,或者如果你在另一个微服务已经迁移数据库之后尝试迁移数据库,甚至无法启动一个微服务。

我们已经创建了迁移,现在我们必须在 Rust 源代码中声明数据结构。

声明数据结构

我们的工具将有两个包含数据结构的模块。第一个是 src/schema.rs 模块,它包含一个 table! 宏调用,用于声明每个表的字段。在我们的例子中,此模块包含以下声明:

table! {
    users (id) {
        id -> Text,
        name -> Text,
        email -> Text,
    }
}

此文件是由 diesel setup 命令自动生成的。当你运行设置时,它创建一个包含以下内容的 diesel.toml 配置文件:

# For documentation on how to configure this file,
# see diesel.rs/guides/configuring-diesel-cli
[print_schema]
file = "src/schema.rs"

如你所见,配置有一个 schema 模块引用。还会生成一个 schema.rs 文件,并且每次编译时都会更新。table! 宏创建了用于表的 DSL 所需的声明。

模型

架构声明仅定义表结构。为了将表映射到 Rust 类型,你必须添加一个包含模型的模块,该模型将用于将 users 表中的记录转换为原生 Rust 类型。让我们创建一个并命名为 models.rs。它将包含以下代码:

use serde_derive::Serialize;
use super::schema::users;

#[derive(Debug, Serialize, Queryable)]
pub struct User {
    pub id: String,
    pub name: String,
    pub email: String,
}

#[derive(Insertable)]
#[table_name = "users"]
pub struct NewUser<'a> {
    pub id: &'a str,
    pub name: &'a str,
    pub email: &'a str,
}

我们在这里声明了两个模型:User 用于表示数据库中的用户,NewUser 用于创建用户的新的记录。我们为 User 结构体推导出必要的特质。Queryable 特质被实现以允许你通过查询从数据库获取此类型。

有一个Insertable特质,它是从NewUser结构体派生出来的。这个特质允许结构体作为新行插入到表中。这种派生需要一个带有表名的注解。我们可以将其设置为users表,使用#[table_name = "users"]注解。

数据库结构已经准备就绪,我们可以从应用程序开始使用数据库。

连接到数据库

在我们的工具中,我们将实现两个子命令——add用于添加新用户,list用于从数据库检索所有可用用户。导入所有必要的依赖项并添加带有schemamodels的模块:

extern crate clap;
#[macro_use]
extern crate diesel;
extern crate failure;
extern crate serde_derive;

use clap::{
    crate_authors, crate_description, crate_name, crate_version,
    App, AppSettings, Arg, SubCommand,
};
use diesel::prelude::*;
use diesel::r2d2::ConnectionManager;
use failure::Error;

pub mod models;
pub mod schema;

由于我们使用的是r2d2 crate,我们还需要导入ConnectionManager以使用 diesel 对传统数据库连接的抽象。

使用pub修饰符声明的模块使它们在文档中可用。这对于由diesel crate 生成的模块很有用,这样你可以探索由生成的 DSL 提供的函数。

解析参数

与上一章中的示例类似,我们有一个参数解析器。它的声明如下:

let matches = App::new(crate_name!())
    .version(crate_version!())
    .author(crate_authors!())
    .about(crate_description!())
    .setting(AppSettings::SubcommandRequired)
    .arg(
        Arg::with_name("database")
        .short("d")
        .long("db")
        .value_name("FILE")
        .help("Sets a file name of a database")
        .takes_value(true),
        )
    .subcommand(SubCommand::with_name(CMD_ADD).about("add user to the table")
                .arg(Arg::with_name("NAME")
                     .help("Sets the name of a user")
                     .required(true)
                     .index(1))
                .arg(Arg::with_name("EMAIL")
                     .help("Sets the email of a user")
                     .required(true)
                     .index(2)))
    .subcommand(SubCommand::with_name(CMD_LIST).about("prints a list with users"))
    .get_matches();

我们可以使用带有数据库文件路径的--database参数。add子命令需要两个参数——带有用户名的NAME和带有电子邮件的EMAILlist子命令不需要额外的参数,并将打印用户列表。

创建连接

要创建连接,我们提取数据库的路径。由于我们使用的是 SQLite 数据库,与之前的示例不同,我们不需要 URL,而是数据库文件的路径。这就是为什么我们使用test.db文件名而不是 URL 的原因:

let path = matches.value_of("database")
     .unwrap_or("test.db");
 let manager = ConnectionManager::<SqliteConnection>::new(path);
 let pool = r2d2::Pool::new(manager)?;

r2d2::Pool需要一个ConnectionManager实例来与数据库建立连接,我们可以提供从命令行参数中提取的数据库路径作为关联类型来使用 SQLite 数据库。现在让我们看看如何使用生成的 DSL 与数据库交互。

使用 DSL 实现子命令

diesel crate 为我们生成一个 DSL,以简单的方式构造类型化查询。所有指令都作为 schema 的子模块生成,并对每个生成的表映射可用,模块路径如下:

use self::schema::users::dsl::*;

让我们使用生成的类型化关系实现两个命令。

添加用户子命令实现

我们用户管理工具的第一个子命令是add。这个命令从参数中提取用户的NAMEEMAIL,并使用uuid crate 生成一个新的用户标识符。我们将在这个所有微服务中使用这种类型。看看下面的代码:

(CMD_ADD, Some(matches)) => {
    let conn = pool.get()?;
    let name = matches.value_of("NAME").unwrap();
    let email = matches.value_of("EMAIL").unwrap();
    let uuid = format!("{}", uuid::Uuid::new_v4());
    let new_user = models::NewUser {
        id: &uuid,
        name: &name,
        email: &email,
    };
    diesel::insert_into(schema::users::table)
        .values(&new_user)
        .execute(&conn)?;
}

在我们提取所有参数后,我们从models模块创建一个NewUser实例。它需要引用值,我们不需要将所有权传递给值并在多个请求中重用它们。

最后一行使用了insert_into函数,它为提供的表生成一个INSERT INTO语句,但与表文本名称,如"users"不同,我们使用来自模式中用户moduletable类型。这有助于你在编译时看到所有错误。我们使用values函数调用来设置这个请求的值。作为值,我们使用对NewUser实例的引用,因为这个映射已经在结构声明中映射到***users***表。要执行一个语句,我们调用由values方法调用生成的InsertStatement实例的execute函数。

execute方法期望一个对我们已经从池中提取的连接的引用。

列出用户子命令实现

在之前的数据插入示例中,我们没有使用生成的users类型,而只使用了嵌套的table类型。为了在list子命令的实现中列出用户,我们将使用dsl子模块中的类型。

如果你构建一些文档并查看users::schema::users::dsl模块,你会看到以下项目:

pub use super::columns::id;
pub use super::columns::name;
pub use super::columns::email;
pub use super::table as users;

所有类型都非常复杂,你可以在文档中看到所有功能。由于users表类型实现了AsQuery特质,我们可以使用RunQueryDsl特质的load方法来处理users类型。我们将关联类型设置为model::Users以从表中提取此类型。我们也不需要像上一章那样进行任何手动提取。load方法期望一个Connection,我们可以从Pool实例中获取它:

(CMD_LIST, _) => {
    use self::schema::users::dsl::*;
    let conn = pool.get()?;
    let mut items = users
        .load::<models::User>(&conn)?;
    for user in items {
        println!("{:?}", user);
    }
}

现在,我们可以简单地遍历用户集合。这很简单。

如果你想要构造更复杂的请求,可以使用dieselcrate 在构建过程中生成的其他 DSL 函数。例如,你可以通过域名过滤用户,并使用以下 DSL 表达式限制列表中用户的数量:

let mut items = users
    .filter(email.like("%@example.com"))
    .limit(10)
    .load::<models::User>(&conn)?;

我们使用filter方法,并通过email列的like方法调用来创建参数,过滤了所有example.com域的用户。

测试

让我们测试我们的工具。使用以下命令编译并运行它:

cargo run -- add user1 user1@example.com
cargo run -- add user2 user2@example.com
cargo run -- add userx userx@xample.com

如果你添加过滤并调用list子命令,你会看到以下输出:

cargo run -- list
User { id: "a9ec3bae-c8c6-4580-97e1-db8f988f10f8", name: "user1", email: "user1@example.com" }
User { id: "7f710d18-aea5-46f9-913c-b60d4e4529c9", name: "user2", email: "user2@example.com" }

我们得到了一个将纯 Rust 类型映射到关系数据库类型的完美示例。

复杂的数据库结构

我们已经涵盖了一个单表的示例。在本节中,我们将创建一个具有复杂表结构的示例,以涵盖整体数据库交互。我们将开发一个用于数据库交互的独立 crate,该 crate 涵盖了复杂聊天应用程序的功能——与用户、频道和角色聊天。此外,我们将测试我们实现的功能,并展示如何测试 Rust 应用程序的数据库交互层。

示例应用程序的业务逻辑

在本节中,我们将学习如何将数据关系转换为 ORM 模型。我们将实现一个聊天应用的数据库交互包。想象一下,我们需要在 Rust 中表达这些数据关系:

图片

我们有四个表。第一个表包含用户。它是主表,并被其他所有表使用。每个用户都可以创建一个频道并成为频道的所有者。第二个表包含频道,每个频道都有一个所有者,由users表中的记录表示。

在我们的聊天应用中,每个用户都可以加入频道并向其发布消息。为了维护这种关系,我们将添加一个memberships表,其中包含两个引用的记录——一个用户是频道的成员,另一个是包含用户的频道记录。

此外,用户可以向频道发布消息。我们将保留所有消息在一个单独的messages表中。每条消息有两个关系:包含消息的频道和发布消息的用户。

API 方法

为了维护数据,我们需要提供以下方法:

  • register_user:向users表添加新用户

  • create_channel:创建一个新的频道,并使用提供的用户作为频道所有者

  • publish_channel:使频道公开

  • add_member:向频道添加成员

  • add_message:向频道添加用户的消息

  • delete_message:删除消息

你可能已经注意到我们没有删除频道的功能,但我们有一个删除消息的方法。这是因为用户可能会不小心发布一条消息,他们可能想要删除它。也许用户发布了一些他们想要从数据库中删除的私人信息。

我们不允许删除频道和用户,因为它们是业务逻辑的重要组成部分。如果用户删除了一个频道,那么其他用户的全部消息也会被删除。这不是其他用户希望的行为。

如果你需要一个删除功能,你可以在每个表中添加一个布尔列,表示记录已被删除。不要删除物理记录,但将其标记为已删除。你可以自己添加到这个例子中。在实际情况中,你还需要考虑用户所在国家的法律,因为它们可能要求物理删除记录。

现在,我们可以使用 Rust 的 ORM 通过diesel包来表示这些关系。

数据库结构和迁移

首先,我们需要创建数据库结构。我们需要四个表:

  • users:包含用户的账户

  • channels:包含用户创建的频道

  • memberships:属于频道的用户

  • messages:频道中用户的消息

要添加这些表,我们将向新项目添加四个迁移:

cargo new --lib chat
cd chat
diesel setup
diesel migration generate create_users
diesel migration generate create_channels
diesel migration generate create_memberships
diesel migration generate create_messages

前面的命令创建了这些迁移:

00000000000000_diesel_initial_setup
2019-01-06-192329_create_users
2019-01-06-192333_create_channels
2019-01-06-192338_create_memberships
2019-01-06-192344_create_messages

如你所记,每个迁移文件夹包含两个文件:up.sqldown.sql。现在,我们可以添加 SQL 语句来执行必要的迁移操作。

Diesel 初始设置

第一次迁移是diesel_initial_setup。它是由diesel CLI 工具自动创建的,包含一个设置触发器以更新表updated_at列的函数。我们将使用这个功能来处理频道表。像每次迁移一样,它由两个文件组成。

up.sql

这个 SQL 文件包含两个语句。第一个是diesel_manage_updated_at函数,它为表创建一个触发器,以便在每行更新时调用diesel_set_updated_at函数:

CREATE OR REPLACE FUNCTION diesel_manage_updated_at(_tbl regclass) RETURNS VOID AS $$
BEGIN
    EXECUTE format('CREATE TRIGGER set_updated_at BEFORE UPDATE ON %s
                    FOR EACH ROW EXECUTE PROCEDURE diesel_set_updated_at()', _tbl);
END;
$$ LANGUAGE plpgsql;

此函数仅对提供_tbl参数的表执行CREATE TRIGGER语句。

第二个函数是diesel_set_updated_at,它更新updated_at列,如果处理过的行已更改,则使用当前时间戳:

CREATE OR REPLACE FUNCTION diesel_set_updated_at() RETURNS trigger AS $$
BEGIN
    IF (
        NEW IS DISTINCT FROM OLD AND
        NEW.updated_at IS NOT DISTINCT FROM OLD.updated_at
    ) THEN
        NEW.updated_at := current_timestamp;
    END IF;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

为了使这个功能工作,你必须将updated_at列添加到你的表中,并在你的模型中添加一个同名字段。我们将在本节稍后对channel表做这件事。

down.sql

降级脚本会删除两个函数(如果存在):

DROP FUNCTION IF EXISTS diesel_manage_updated_at(_tbl regclass);
DROP FUNCTION IF EXISTS diesel_set_updated_at();

这是一个由diesel工具创建的默认迁移。正如你所见,它包含你可以删除或用你自己的替换的功能。现在,我们可以将users表添加到下一个迁移中。

用户表

第二次迁移是create_users。它创建一个users表,该表用于在数据库中保存所有用户的账户。为了创建这个表,我们创建了一个单独的迁移,包含两个脚本——一个用于创建users表,另一个用于删除它。

up.sql

将以下语句添加到up.sql脚本中:

CREATE TABLE users (
  id SERIAL PRIMARY KEY,
  email TEXT NOT NULL UNIQUE
);

如你所见,该表有两个列。id代表用户的唯一 ID,我们将在稍后的其他表中使用这个标识符。email列包含用户的唯一电子邮件。对于实际应用,users表还必须包含一个散列密码,以及两个列来存储用户创建和更新的时间。

down.sql

降级脚本删除users表:

DROP TABLE users;

现在,我们可以使用用户的 ID 来创建频道。

频道表

第三次迁移是create_channels。它创建一个channels表,包含所有由用户创建的频道。频道可以是私有的或公共的,每个频道都有一个标题。让我们看看创建数据库中频道表的脚本。

up.sql

升级脚本包含一个创建channels表的语句。列包括频道iduser_id,它引用users表中的用户。频道还有一个title列和一个is_public列,它包含一个表示频道可见性的标志。如果is_public等于TRUE,则表示该频道是公开的。看看以下语句:

CREATE TABLE channels (
  id SERIAL PRIMARY KEY,
  user_id INTEGER NOT NULL REFERENCES users,
  title TEXT NOT NULL,
  is_public BOOL NOT NULL,
  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

SELECT diesel_manage_updated_at('channels');

该表还有两个列——create_at,它在行创建时获取当前时间戳,以及updated_at,它包含行的最新更新时间戳。默认情况下,updated_at列在创建时使用当前时间戳作为默认值。

正如我们之前提到的,diesel 创建了一个 diesel_manage_updated_at 函数,该函数将触发器设置到表中,当行被更新时自动更新行的 updated_at 列。由于我们在表声明中已经有了 updated_at 列,我们可以在 SELECT 语句中调用此函数。

down.sql

down 脚本删除了 channels 表:

DROP TABLE channels;

up.sql 脚本中,我们创建了一个带有 diesel_manage_updated_at 调用的触发器,但我们不需要手动删除它,因为它会随着表的删除而自动移除。

会员表

第四次迁移是 create_memberships。它创建了一个必要的 memberships 表,用于管理可以读取消息并写入新消息的渠道参与者。这个表依赖于 userschannels 表。

up.sql

up 脚本简单,包含一个创建具有三个字段(会员的 id、在 channel_id 列中的渠道 id 以及具有 user_id 列的用户的 id)的会员表语句:

CREATE TABLE memberships (
  id SERIAL PRIMARY KEY,
  channel_id INTEGER NOT NULL REFERENCES channels,
  user_id INTEGER NOT NULL REFERENCES users
);

down.sql

down 脚本删除了表:

DROP TABLE memberships;

我们现在需要添加一个存储用户发布到渠道的消息的表。

消息表

第五次迁移是 create_messages。它创建了一个包含所有用户已写入消息的 messages 表。

up.sql

看看以下 up 脚本:

CREATE TABLE messages (
  id SERIAL PRIMARY KEY,
  timestamp TIMESTAMP NOT NULL,
  channel_id INTEGER NOT NULL REFERENCES channels,
  user_id INTEGER NOT NULL REFERENCES users,
  text TEXT NOT NULL
);

它创建了一个包含与渠道和用户通过 ID 关联的消息的表。此外,它还包含显示消息添加时间的戳以及每条消息的文本。

down.sql

down 脚本删除了表:

DROP TABLE messages;

我们已经完成了所有迁移,现在我们可以查看 diesel 工具生成的架构。

架构

diesel 创建了一个包含生成用于源代码中使用的 DSL 语言的宏调用的 schema 文件。具有相互关系的表架构需要额外的声明。让我们在 src/schema.rs 文件中查看生成的架构,看看它与我们在本章早期创建的简单架构有何不同。

第一张表是 users。它具有我们在 SQL 文件中声明的相同列:

table! {
    users (id) {
        id -> Int4,
        email -> Text,
    }
}

table! 宏在编译期间会被展开为一些类型和特质实现,您可以使用以下命令查看:

cargo rustc -- -Z unstable-options --pretty=expanded

此命令将所有展开的宏打印到终端。

diesel 工具还为 channels 表生成了一个 DSL 声明:

table! {
    channels (id) {
        id -> Int4,
        user_id -> Int4,
        title -> Text,
        is_public -> Bool,
        created_at -> Timestamp,
        updated_at -> Timestamp,
    }
}

对于 memberships 表,我们有以下声明:

table! {
    memberships (id) {
        id -> Int4,
        channel_id -> Int4,
        user_id -> Int4,
    }
}

对于 messages 表,我们有以下声明:

table! {
    messages (id) {
        id -> Int4,
        timestamp -> Timestamp,
        channel_id -> Int4,
        user_id -> Int4,
        text -> Text,
    }
}

但您可能已经注意到,表声明中没有包含任何关于关系的信息。由 joinable! 宏创建的关系期望一个表名和一个具有 ID 列名的父表:

joinable!(channels -> users (user_id));
joinable!(memberships -> channels (channel_id));
joinable!(memberships -> users (user_id));
joinable!(messages -> channels (channel_id));
joinable!(messages -> users (user_id));

所有关系都通过 joinable! 宏列出,但架构还包含一个 allow_tables_to_appear_in_same_query! 宏调用,表示哪些表可以在 JOIN 查询中使用:

allow_tables_to_appear_in_same_query!(
    channels,
    memberships,
    messages,
    users,
);

由于我们有了包含所有关系的完整模式声明,我们可以声明与原生 Rust 结构体具有相同关系的模型。

模型

现在,我们可以使用生成的模式来创建所有必要的模型,这些模型代表数据库记录到原生 Rust 结构体。首先,我们必须导入NaiveDateTime类型,因为我们有时间戳列。此外,我们必须导入所有表:userschannelsmembershipsmessages

use chrono::NaiveDateTime;
use crate::schema::{users, channels, memberships, messages};

我们将使用i32类型作为记录的标识符,但最好使用别名来使其意图更加明确:

pub type Id = i32;

让我们添加一个模型来表示users表中的记录。

用户

为了表示存储在users表中的用户,我们将添加一个具有以下声明的User结构体:

#[derive(Debug, Identifiable, Queryable, Serialize, Deserialize)]
#[table_name = "users"]
pub struct User {
    pub id: Id,
    pub email: String,
}

如您所见,我们使用具有SERIAL SQL 类型的Id类型作为 ID 列。对于电子邮件字段,我们使用 String 类型,它在 PostgreSQL 中映射到TEXT列类型。

此外,还有一个table_name属性来将此结构体与表绑定。我们还为此模型推导了一些特质——Debug用于将模型值打印到终端,以及SerializeDeserialize特质,使此模型可转换为任何序列化格式。这些是推荐实现的基本特质,尤其是如果你想在一个 REST API 中使用相同的类型。

Queryable特质代表可以转换为实现该特质的结构的 SQL 表达式的结果。这使得我们可以在数据库交互 API 中将元组转换为User结构体。

Identifiable特质意味着实现此特质的结构体代表表中的一个单个记录。此特质有一个关联的Id类型,它设置为 SQL 表中相应的类型。此特质还包含一个id方法,它返回表中的记录标识符。

通道

下一个模型是Channel,它代表channels表中的记录:

#[derive(Debug, Identifiable, Queryable, Associations, Serialize, Deserialize)]
#[belongs_to(User)]
#[table_name = "channels"]
pub struct Channel {
    pub id: Id,
    pub user_id: Id,
    pub title: String,
    pub is_public: bool,
    pub created_at: NaiveDateTime,
    pub updated_at: NaiveDateTime,
}

此模型使用table_name属性绑定到表,并包含映射到表相应列的所有字段。为了表示TIMESTAMP SQL 类型,我们使用来自chrono包的NaiveDateTime

该模型有一个user_id字段,它映射到users表中的记录。为了指示User模型是否属于users表,我们向此模型添加了belongs_to属性。该模型还必须实现Associations特质。如果模型做到了这一点,你就可以使用模型的belonging_to方法来获取与父记录相关联的其他记录。

会员资格

为了表示会员资格模型中的记录,我们添加了Membership模型:

#[derive(Debug, Identifiable, Queryable, Associations, Serialize, Deserialize)]
#[belongs_to(Channel)]
#[belongs_to(User)]
#[table_name = "memberships"]
pub struct Membership {
    pub id: Id,
    pub channel_id: Id,
    pub user_id: Id,
}

此模型与ChannelUser模型建立了关联关系。例如,如果你想获取一个用户的全部会员资格,你可以使用belonging_to方法:

let memberships = Membership::belonging_to(&user)
    .load::<Membership>(&conn);

消息

我们需要的最后一个模型是Message,它与messages表中的记录相关联:

#[derive(Debug, Identifiable, Queryable, Associations, Serialize, Deserialize)]
#[belongs_to(Channel)]
#[belongs_to(User)]
#[table_name = "messages"]
pub struct Message {
    pub id: Id,
    pub timestamp: NaiveDateTime,
    pub channel_id: Id,
    pub user_id: Id,
    pub text: String,
}

此模型还使用了我们在第一个示例中讨论的派生特性。现在,我们可以使用生成的模式和声明的模型来实现我们的数据库交互包。

数据库交互 API 包

让我们在 lib.rs 源文件中添加数据库交互 API 的实现。我们需要导入 diesel 包并声明模块:

#[macro_use]
extern crate diesel;

mod models;
mod schema;

你可以看到我们添加了两个模块:modelsschema。在实现中,我们需要以下类型:

use diesel::{Connection, ExpressionMethods, OptionalExtension, PgConnection, QueryDsl, RunQueryDsl, insert_into};
use chrono::Utc;
use failure::{Error, format_err};
use self::models::{Channel, Id, Membership, Message, User};
use self::schema::{channels, memberships, messages, users};
use std::env;

导入包括所有模型和所有表。我们还导入了 Connection 特性到 establish 连接,ExpressionMethods 特性来使用 DSL 的 eq 方法设置列的值相等,OptionalExtension 特性来使用 optional 方法尝试获取不在表中的记录,具有 filter 方法的 QueryDsl 特性,以及 RunQueryDsl 来使用尝试将记录转换为 Rust 类型的 get_result 方法。insert_into 方法让我们能够向表中插入新记录。现在,我们已经有了声明 Api 结构体所需的一切。

Api

我们将声明一个包含连接实例的结构体,并在这个连接上添加方法:

pub struct Api {
    conn: PgConnection,
}

Api 结构体可以通过 connect 方法创建,它使用 DATABASE_URL 环境变量来启动与 PostgreSQL 的连接:

impl Api {
    pub fn connect() -> Result<Self, Error> {
        let database_url = env::var("DATABASE_URL")
            .unwrap_or("postgres://postgres@localhost:5432".to_string());
        let conn = PgConnection::establish(&database_url)?;
        Ok(Self { conn })
    }
}

在这里我们使用直接连接而没有 r2d2 连接池,但你也可以使 Api 结构体兼容并发访问。让我们首先添加用于注册新用户的 API 方法。

注册用户

要使用电子邮件地址注册新用户,添加以下方法:

pub fn register_user(&self, email: &str) -> Result<User, Error> {
    insert_into(users::table)
        .values((
                users::email.eq(email),
                ))
        .returning((
                users::id,
                users::email
                ))
        .get_result(&self.conn)
        .map_err(Error::from)
}

register_user 函数期望一个包含用户电子邮件的字符串,向数据库中添加一条记录,并返回一个代表 users 表中记录的 User 实例。

我们使用 insert_into 方法与来自 users 范围的表类型,这是由 schema 模块中的 table! 宏自动创建的。此方法返回一个 IncompleteInsertStatement 实例,它提供了一个 values 方法来使用 INSERT 语句设置值。我们将 email 列设置为等于 email 变量。values 方法调用返回一个 InsertStatement 类型实例,它具有 returning 方法来设置与此语句一起返回的列。我们将返回值设置为 users 表的 idemail 列。returning 方法接收一个语句的所有权并返回一个新的 InsertStatement 实例,其中包含返回值。

最后,我们调用 InsertStatement 结构体的 get_result 方法来执行语句并将结果转换为 User 模型。因为我们有一个不同的 Result 错误类型,所以我们必须将 get_result 方法返回的 diesel::result::Error 类型转换为 failure::Error 类型。

创建频道

下一个方法是 create_channel,它为用户创建一个新的频道。看看实现方式:

pub fn create_channel(&self, user_id: Id, title: &str, is_public: bool)
    -> Result<Channel, Error>
{
    self.conn.transaction::<_, _, _>(|| {
        let channel: Channel = insert_into(channels::table)
            .values((
                    channels::user_id.eq(user_id),
                    channels::title.eq(title),
                    channels::is_public.eq(is_public),
                    ))
            .returning((
                    channels::id,
                    channels::user_id,
                    channels::title,
                    channels::is_public,
                    channels::created_at,
                    channels::updated_at,
                    ))
            .get_result(&self.conn)
            .map_err(Error::from)?;
        self.add_member(channel.id, user_id)?;
        Ok(channel)
    })
}

该函数期望 user_id、频道的 titleis_public 标志,这意味着频道是公开的。

由于我们必须将创建频道的用户作为创建频道的第一个成员添加,因此我们将两个语句合并为一个事务。要使用 diesel 创建事务,你可以使用 Connection 实例的 transaction 方法。此方法期望三个类型参数——成功值类型、错误值类型以及作为单个参数提供的闭包类型的函数调用。我们跳过所有类型,因为编译器可以检测它们。

在事务实现中,我们创建一个 Channel 模型实例,它代表数据库中的一个新记录。之后,我们使用 Api 结构的 add_member 方法。正如你所看到的,事务和连接实例都不需要一个可变引用,我们可以组合多个方法来获取一个不可变引用到 Connection 实例。你将在稍后看到 add_member 方法的实现,但现在我们将添加一个方法来更新表中频道的记录。

发布频道

我们将添加一个方法来将频道记录的 is_public 标志设置为 true。看看以下实现:

pub fn publish_channel(&self, channel_id: Id) -> Result<(), Error> {
    let channel = channels::table
        .filter(channels::id.eq(channel_id))
        .select((
                channels::id,
                channels::user_id,
                channels::title,
                channels::is_public,
                channels::created_at,
                channels::updated_at,
                ))
        .first::<Channel>(&self.conn)
        .optional()
        .map_err(Error::from)?;
    if let Some(channel) = channel {
        diesel::update(&channel)
            .set(channels::is_public.eq(true))
            .execute(&self.conn)?;
        Ok(())
    } else {
        Err(format_err!("channel not found"))
    }
}

该函数期望 channel_id,我们使用 table 值来创建一个语句。我们使用 QueryDsl 特性的 filter 方法获取具有提供 ID 的单个记录,并使用同一特性的 select 方法从表中提取转换为 Channel 模型实例所需的价值。然后,我们调用返回执行语句找到的第一个记录的 first 方法。如果没有找到记录,它将返回一个错误,但由于返回的是 Result 类型,我们可以通过使用 optional 方法调用来转换它,从而删除这个错误部分。它让我们可以在稍后决定如果未找到记录时该做什么。

如果找到记录,我们使用 update 方法和一个 Channel 模型的引用。此调用返回一个 UpdateStatement 值,它有一个 set 方法,我们使用它将 is_public 列设置为 true。最后,我们对连接实例执行此语句。此调用还自动更新记录的 updated_at 列,因为我们为 channels 表注册了一个触发器。现在,我们可以实现 add_member 函数。

添加成员

add_member 函数需要一个频道 ID 和用户 ID 来将会员记录添加到 memberships 表:

pub fn add_member(&self, channel_id: Id, user_id: Id)
    -> Result<Membership, Error>
{
    insert_into(memberships::table)
        .values((
                memberships::channel_id.eq(channel_id),
                memberships::user_id.eq(user_id),
                ))
        .returning((
                memberships::id,
                memberships::channel_id,
                memberships::user_id,
                ))
        .get_result(&self.conn)
        .map_err(Error::from)
}

实现很简单,它使用 insert_into 函数调用来准备 INSERT 语句,在表中插入一个新的 Membership 值。我们还需要一个函数来向频道添加新消息。

添加消息

add_message 方法将一条与频道和用户相关的消息添加到 messages 表:

pub fn add_message(&self, channel_id: Id, user_id: Id, text: &str)
    -> Result<Message, Error>
{
    let timestamp = Utc::now().naive_utc();
    insert_into(messages::table)
        .values((
                messages::timestamp.eq(timestamp),
                messages::channel_id.eq(channel_id),
                messages::user_id.eq(user_id),
                messages::text.eq(text),
                ))
        .returning((
                messages::id,
                messages::timestamp,
                messages::channel_id,
                messages::user_id,
                messages::text,
                ))
        .get_result(&self.conn)
        .map_err(Error::from)
}

实现还使用了insert_into函数,但我们还手动创建了时间戳。您可以通过在timestamp列中设置当前时间戳的默认值来避免手动设置此字段。

删除消息

如果您发布了消息并决定删除它,我们需要一个方法从messages表中删除消息。查看delete_message方法的实现:

pub fn delete_message(&self, message_id: Id) -> Result<(), Error> {
    diesel::delete(messages::table)
        .filter(messages::id.eq(message_id))
        .execute(&self.conn)?;
    Ok(())
}

此函数使用delete方法,该方法返回一个DeleteStatement实例,该实例也有一个filter方法。我们使用id列等于提供的message_id设置一个过滤器,并执行生成的DELETE SQL 语句。

测试包

就这些了,现在我们有一个可以用来与数据库交互的包。由于它不是二进制文件,我们需要保证代码的正确性。一个好的做法是用测试覆盖您的代码,我们现在就来做这件事。

将以下代码添加到lib.rs文件中:

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

    #[test]
    fn create_users() {
        let api = Api::connect().unwrap();
        let user_1 = api.register_user("user_1@example.com").unwrap();
        let user_2 = api.register_user("user_2@example.com").unwrap();
        let channel = api.create_channel(user_1.id, "My Channel", false).unwrap();
        api.publish_channel(channel.id).unwrap();
        api.add_member(channel.id, user_2.id).unwrap();
        let message = api.add_message(channel.id, user_1.id, "Welcome!").unwrap();
        api.add_message(channel.id, user_2.id, "Hi!").unwrap();
        api.delete_message(message.id).unwrap();
    }
}

我们添加了一个test模块和一个create_users测试函数。这个函数测试了我们实现的所有 API 方法。它使用带有连接方法调用的Api实例创建一个实例,并使用该实例注册两个用户,电子邮件地址为"user_1@example.com""user_2@example.com"。之后,它为第一个用户创建了一个频道,发布了它,并将第二个用户添加为成员。最后,它添加了两条消息并删除了第一条。让我们运行这个测试,但您必须使用 Docker 运行一个 PostgreSQL 数据库实例。您可以在上一章中阅读如何做到这一点。

应用所有迁移并运行测试:

DATABASE_URL=postgres://postgres@localhost:5432 diesel migration run && cargo test

当测试完成后,您将收到来自psql客户端的消息:

postgres=# select * from messages;
 id | timestamp | channel_id | user_id | text 
----+----------------------------+------------+---------+------
 2 | 2019-01-08 18:30:48.465001 | 1 | 2 | Hi!
(1 row)

如您所见,测试添加了两个记录并删除了第一个。在第十三章测试和调试 Rust 微服务中,我们将详细讨论微服务的测试。

摘要

在本章中,我们学习了如何使用对象关系映射将纯 Rust 类型存储和加载到数据库中。首先,我们使用diesel包中包含的diesel-cli工具创建了迁移。之后,我们添加了模型以将列映射到 Rust 类型,并使用r2d2包和diesel包的抽象创建了一个最小连接。

我们还提到了 DSL 构造,然而diesel包提供了很多功能,如果您想构建更复杂的查询,可以参考文档。

在下一章中,我们将学习一些简化编写微服务并让您更快实现想法的框架。

第九章:使用框架进行简单的 REST 定义和请求路由

在本章中,我们将探讨创建微服务的替代框架。在前面的章节中,我们使用了hypercrate 来处理 HTTP 交互,但它要求我们编写异步代码。如果你不需要低级控制,如果你创建的微服务在高负载下无法工作,或者你需要简单快速地编写代码,你可以尝试使用以下 crate 来创建微服务:

  • rouille

  • nickel

  • rocket

  • gotham

在本章中,我们将创建四个使用之前章节中数据库交互概念的微服务。

技术要求

本章向您介绍了新的 crate——rouillenickelrocketgotham。你不需要安装除cargo和 Rust 编译器之外的特殊软件,但你需要一个 nightly 版本,因为 Rocket 框架需要它。

为了使示例复杂,我们将使用 SQL 数据库和 SMTP 服务器。但你不需要在本地安装此软件。使用 Docker 启动带有 PostgreSQL 和 Postfix 服务器的容器就足够了。

你可以从 GitHub 上的相关项目获取本章的源代码:github.com/PacktPublishing/Hands-On-Microservices-with-Rust/tree/master/Chapter09

Rouille

rouillecrate 帮助你使用route!宏通过简单的路由声明创建微服务。这个框架提供了一个同步 API,每个请求都由池中的一个线程处理。

创建微服务

让我们使用 Rouille 框架编写一个用户注册的微服务。它允许用户创建账户并授权使用其他微服务。我们可以从创建服务器实例开始。

启动服务器

Rouille 框架非常易于使用。它包含start_server函数,这些函数期望一个函数来处理每个传入的请求。让我们创建一个main函数,使用带有r2d2池功能的dieselcrate,并调用一个处理请求的函数:

fn main() {
     env_logger::init();
     let manager = ConnectionManager::<SqliteConnection>::new("test.db");
     let pool = Pool::builder().build(manager).expect("Failed to create pool.");
     rouille::start_server("127.0.0.1:8001", move |request| {
         match handler(&request, &pool) {
             Ok(response) => { response },
             Err(err) => {
                 Response::text(err.to_string())
                     .with_status_code(500)
             }
         }
     })
 }

我们为本地test.db SQLite 数据库创建了一个ConnectionManager和一个带有此管理器的Pool实例。我们之前章节中讨论过这一点。我们感兴趣的是带有rouille::start_server函数调用的那一行。这个函数接受两个参数:一个监听地址和一个用于处理请求的闭包。我们将pool移动到闭包中,并调用位于其下方的handler函数,以生成一个以Pool作为参数的请求响应。

由于handler函数必须返回一个Response实例,如果一个handler函数返回错误,我们必须返回一个带有 500 状态码的响应。看起来很简单,不是吗?让我们看看handler函数的声明。

处理请求

Rouille 框架包含一个 router! 宏,它可以帮助你为每个路径和 HTTP 方法声明处理程序。如果我们添加一个从 start_server 函数调用中使用的闭包中调用的 handler 函数,router! 宏期望一个请求实例作为第一个参数和所需的请求处理程序数量。让我们按顺序分析四个处理程序函数。

根处理程序

以下是一个简单的处理程序,它期望一个 GET 方法并返回一个文本响应:

(GET) (/) => {
    Response::text("Users Microservice")
},

注册处理程序

要处理注册请求,我们需要为 /signup 路径提供一个 POST 方法处理程序。我们可以按以下方式声明它:

(POST) (/signup) => {
    let data = post_input!(request, {
        email: String,
        password: String,
    })?;
    let user_email = data.email.trim().to_lowercase();
    let user_password = pbkdf2_simple(&data.password, 12345)?;
    {
        use self::schema::users::dsl::*;
        let conn = pool.get()?;
        let user_exists: bool = select(exists(users.filter(email.eq(user_email.clone()))))
            .get_result(&conn)?;
        if !user_exists {
            let uuid = format!("{}", uuid::Uuid::new_v4());
            let new_user = models::NewUser {
                id: &uuid,
                email: &user_email,
                password: &user_password,
            };
            diesel::insert_into(schema::users::table).values(&new_user).execute(&conn)?;
            Response::json(&())
        } else {
            Response::text(format!("user {} exists", data.email))
                .with_status_code(400)
        }
    }
}

这个处理程序更复杂,也展示了如何解析请求的参数。我们需要解析一个包含两个参数——emailpassword 的 HTML 表单。为此,我们使用了 post_input! 宏,它期望一个请求实例和一个带有类型的表单声明。表单结构声明看起来像是一个没有名称但带有字段的简单结构声明。我们添加了两个必要的字段,post_input! 宏解析请求以填充具有相应字段的对象。

由于解析的参数只适合类型,我们还需要对其进行额外的处理。email 字段是 String 类型,我们使用了 trim 方法来删除不必要的空格,并使用 to_lowercase 方法将其转换为小写。我们没有对 password 字段进行任何更改,并将其作为参数传递给 rust-crypto crate 的 pbkdf2_simple 方法。

PBKDF2 是一种算法,它通过向加密值添加计算成本来防止暴力攻击。如果你的微服务遭到攻击并且你的密码被盗,攻击者将很难找到密码值以使用他人的账户访问服务。如果你使用散列,那么攻击者将能够快速找到匹配的密码。

在我们准备参数后,我们使用对象关系映射方法使用它们。首先,为了检查提供的电子邮件地址是否已存在用户,我们使用由 diesel crate 生成的 DSL,如果用户不存在,我们使用 uuid crate 为用户生成一个唯一的 ID。处理程序使用相应的值填充 NewUser 实例并将其插入数据库。成功后,它返回一个空的 JSON 响应。如果用户已经存在,处理程序返回一个包含 400 状态码(错误响应)的响应,并带有一条消息,说明提供的电子邮件地址的用户已存在。让我们看看如何使用存储的用户值进行登录。

登录处理程序

以下代码表示 /signin 请求路径的处理程序,并使用 post_input! 解析来自 HTML 表单的数据:

(POST) (/signin) => {
    let data = post_input!(request, {
        email: String,
        password: String,
    })?;
    let user_email = data.email;
    let user_password = data.password;
    {
        use self::schema::users::dsl::*;
        let conn = pool.get()?;
        let user = users.filter(email.eq(user_email))
            .first::<models::User>(&conn)?;
        let valid = pbkdf2_check(&user_password, &user.password)
            .map_err(|err| format_err!("pass check error: {}", err))?;
        if valid {
            let user_id = UserId {
                id: user.id,
            };
            Response::json(&user_id)
                .with_status_code(200)
        } else {
            Response::text("access denied")
                .with_status_code(403)
        }
    }
}

数据提取后,我们从连接池中获取一个连接,并使用 diesel 包生成的类型向数据库发送查询。代码从用户表中获取提供的电子邮件值对应的第一条记录。之后,我们使用 pbkdf2_check 函数检查密码是否与存储的密码匹配。如果用户有效,我们返回一个包含用户 ID 的 JSON 值。在接下来的章节中,我们不会直接提供这个服务,而是将从另一个微服务中使用它。如果密码不匹配,我们将返回一个带有 403 状态码的响应。

默认处理器

对于没有匹配路径或方法对的请求,我们可以添加一个默认处理器。我们的微服务对所有未知请求返回 404 错误。将其添加到 router! 宏调用中:

_ => {
    Response::empty_404()
}

编译和运行

使用以下命令准备数据库并运行服务器:

DATABASE_URL=test.db diesel migration run
cargo run

当服务器启动时,尝试发送登录和注册请求:

curl -d "email=user@example.com&password=password" -X POST http://localhost:8001/signup
curl -d "email=user@example.com&password=password" -X POST http://localhost:8001/signin

第二个请求将返回一个包含用户标识符的 JSON 格式响应,其外观如下:

{"id":"08a023d6-be15-46c1-a6d6-56f0e2a04aae"}

现在,我们可以尝试使用 nickel 包实现另一个服务。

Nickel

另一个帮助我们非常简单地创建微服务的框架是 nickel。在处理器的设计方面,它与 hyper 非常相似,但它支持同步操作。

创建微服务

让我们创建一个向任何地址发送电子邮件的服务。这个微服务还将从模板构建电子邮件的正文内容。首先,我们必须添加必要的依赖项以启动服务器实例。

启动服务器

要编写一个邮件微服务,我们需要两个依赖项:nickel 包和 lettre 包。第一个是一个受 Node.js 的 Express 框架启发的框架。第二个实现了 SMTP 协议,并允许我们与 Postfix 等邮件服务器交互。将这些依赖项添加到 Cargo.toml 文件中:

failure = "0.1"
lettre = { git = "https://github.com/lettre/lettre" }
lettre_email = { git = "https://github.com/lettre/lettre" }
nickel = "0.10"

对于 lettre 包,我们使用 GitHub 上的 0.9.0 版本,因为在编写时它不在 crates.io 上可用。我们需要从这些包中导入一些类型:

use lettre::{ClientSecurity, SendableEmail, EmailAddress, Envelope, SmtpClient, SmtpTransport, Transport};
use lettre::smtp::authentication::IntoCredentials;
use nickel::{Nickel, HttpRouter, FormBody, Request, Response, MiddlewareResult};
use nickel::status::StatusCode;
use nickel::template_cache::{ReloadPolicy, TemplateCache};

stdfailure 包中的类型在前面代码中没有展示。现在我们可以声明代表服务器共享状态的 Data 结构体:

struct Data {
    sender: Mutex<Sender<SendableEmail>>,
    cache: TemplateCache,
}

这个结构体包含两个字段——一个 Sender 用于向我们将要实现的邮件工作进程发送消息,以及 TemplateCache,它允许我们从本地目录加载和渲染模板。我们只直接使用它来生成电子邮件的正文,因为这个微服务不会渲染 HTML 响应。

以下代码启动了一个邮件发送工作进程,创建了一个 Data 结构体的实例,创建了一个 Nickel 服务器,并将其绑定到 127.0.0.1:8002 端口地址:

fn main() {
    let tx = spawn_sender();

    let data = Data {
        sender: Mutex::new(tx),
        cache: TemplateCache::with_policy(ReloadPolicy::Always),
    };

    let mut server = Nickel::with_data(data);
    server.get("/", middleware!("Mailer Microservice"));
    server.post("/send", send);
    server.listen("127.0.0.1:8002").unwrap();
}

Data 结构体的 cache 字段中,我们设置了一个需要 ReloadPolicy 参数的 TemplateCache 实例。ReloadPolicy 参数控制模板将重新加载的频率。我们使用 Always 变体,这意味着模板将在每次渲染时重新加载。它允许管理员更新模板而不会中断服务。

要启动服务器,我们需要创建一个 Nickel 实例,并使用 with_data 方法用 Data 实例初始化它。由于 Data 将在多个线程之间共享,我们必须将 Sender 包装在 Mutex 中。TemplateCache 已经实现了 SyncSend,并且可以安全地共享。

我们使用 getpost 方法向 Nickel 服务器实例添加两个方法。我们添加了两个处理器。第一个是针对根路径 /,它使用来自 nickel 包的 middleware! 宏附加一个返回文本响应的处理程序。第二个处理程序处理带有 /send 路径的请求,并调用位于其下的 send 函数。最后一个方法调用 listen 将服务器的套接字绑定到一个地址。现在我们可以继续前进并实现一个处理程序。

处理请求

Nickel 框架的处理程序接受两个参数:一个可变引用到 Request 结构体和一个拥有的 Response 实例,我们可以用数据填充它。处理程序必须返回 MiddlewareResult。每个输入和输出类型都有一个共享数据类型的类型参数。

nickel 包包含 try_with! 宏。它需要解包 Result 类型,但如果结果等于 Err,则返回 HTTP 错误。我创建了 send_impl 方法来使用常规的 ? 操作符;failure::Error 是错误类型。我发现这比使用像 try_with! 这样的特殊宏更常见:

fn send<'mw>(req: &mut Request<Data>, res: Response<'mw, Data>) -> MiddlewareResult<'mw, Data> {
    try_with!(res, send_impl(req).map_err(|_| StatusCode::BadRequest));
    res.send("true")
}

我们将结果映射到 BadRequest。如果方法返回 Ok,我们将发送一个 JSON true 值作为响应。对于这种最简单的 JSON 值类型,我们不需要使用序列化。

以下代码是 send_impl 函数的实现。让我们逐个分析:

fn send_impl(req: &mut Request<Data>) -> Result<(), Error> {
    let (to, code) = {
        let params = req.form_body().map_err(|_| format_err!(""))?;
        let to = params.get("to").ok_or(format_err!("to field not set"))?.to_owned();
        let code = params.get("code").ok_or(format_err!("code field not set"))?.to_owned();
        (to, code)
    };
    let data = req.server_data();
    let to = EmailAddress::new(to.to_owned())?;
    let envelope = Envelope::new(None, vec![to])?;
    let mut params: HashMap<&str, &str> = HashMap::new();
    params.insert("code", &code);
    let mut body: Vec<u8> = Vec::new();
    data.cache.render("templates/confirm.tpl", &mut body, &params)?;
    let email = SendableEmail::new(envelope, "Confirm email".to_string(), Vec::new());
    let sender = data.sender.lock().unwrap().clone();
    sender.send(email).map_err(|_| format_err!("can't send email"))?;
    Ok(())
}

Request 实例具有 from_body 方法,该方法返回一个 Params 结构体实例的查询参数。Paramsget 方法返回一个名为 Option 的参数。如果任何参数未提供,我们将返回一个 Err 值,因为该方法要求所有参数都必须设置。

要访问共享服务器的数据,有 Requestserver_data 方法,它返回一个 Data 实例,因为我们将其设置为 Request 的类型参数,并向服务器提供了一个 Data 实例。

当我们获取所有参数后,我们可以提取一个 Sender 实例(用于向工作者发送任务),使用缓存中的模板编写一封电子邮件,并将其发送给工作者。我们从查询的 to 参数创建一个 EmailAddress 实例。然后,我们用包含具有确认码值的 code 参数的模板参数填充 HashMap

参数已经准备就绪,我们使用Data实例的cache字段来访问TemplateCache。缓存中的render方法加载一个模板并用提供的参数填充它。render方法期望一个缓冲区来填充渲染的内容。在我们获取它之后,我们创建一个SendableEmail实例,克隆一个Sender,并使用克隆的实例向一个工作节点发送电子邮件。让我们看看电子邮件工作节点是如何实现的。

发送电子邮件的工作节点

我们使用一个单独的线程来接收SendableEmail值,并使用 SMTP 协议发送它们。以下代码创建了一个SmtpClient实例,并使用credentials方法设置连接的凭据:

fn spawn_sender() -> Sender<SendableEmail> {
    let (tx, rx) = channel();
    let smtp = SmtpClient::new("localhost:2525", ClientSecurity::None)
        .expect("can't start smtp client");
    let credentials = ("admin@example.com", "password").into_credentials();
    let client = smtp.credentials(credentials);
    thread::spawn(move || {
        let mut mailer = SmtpTransport::new(client);
        for email in rx.iter() {
            let result = mailer.send(email);
            if let Err(err) = result {
                println!("Can't send mail: {}", err);
            }
        }
        mailer.close();
    });
    tx
}

StmpClient已移动到新线程的上下文中。它被SmtpTransport包装,并用于发送每个接收到的SendableEmail实例。

工作节点实现了一个非事务性的电子邮件发送器。如果你想保证电子邮件的投递,你需要与邮件服务器实现更多样化的交互,或者你可以甚至嵌入一个邮件服务器,或者使用第三方服务。我建议你尽可能多地使用外部服务;虽然它们会花费你一些费用,但你在维护方面可以节省更多。我们仅为了演示目的实现了邮件服务,以展示如何将多个服务集成在一起。

编译和运行

在我们开始微服务之前,我们需要一个工作的 SMTP 服务器。让我们使用 Docker 创建一个。以下命令创建了一个包含 Postfix 服务器实例的容器:

docker run -it --rm --name test-smtp -p 2525:25  \
 -e SMTP_SERVER=smtp.example.com \
 -e SMTP_USERNAME=admin@example.com \
 -e SMTP_PASSWORD=password \
 -e SERVER_HOSTNAME=smtp.example.com \
 juanluisbaptiste/postfix

服务器公开端口*25*,我们将它重映射到本地端口*2525*。该命令使用环境变量设置所有必要的参数,现在邮件微服务已准备好编译和运行。使用cargo run命令执行此操作,启动后,使用以下命令进行检查:

curl -d "to=email@example.com&code=passcode" -X POST http://localhost:8002/send

当你调用这个命令时,微服务将构建并发送一封电子邮件到 Postfix 服务器。实际上,这封电子邮件不会被投递,因为我们的邮件服务器仅作为中继使用,许多邮件服务会拒绝来自这种邮件服务器的电子邮件。如果你想接收电子邮件,你需要相应地配置该服务。

火箭

我们将要探索的下一个框架是 Rocket。它是一个易于使用的框架,它使用夜间编译器的功能提供了一种将一组 Rust 函数转换为完整 Web 服务的工具。Rocket 框架与我们之前讨论的框架不同。它使用环境变量实现应用程序配置和日志记录。这种方法的不足之处在于调整和替换部分稍微复杂一些,但这种方法的好处是,你在编码微服务的日志和配置功能上几乎不花时间。

创建微服务

让我们创建一个实现我们应用程序评论功能的微服务。它将接受新的评论并将它们存储在数据库中。此外,客户端可以请求微服务中的任何和所有评论。首先,我们需要使用 Rocket 框架启动一个新的服务器。

启动服务器

要启动服务器实例,我们必须准备数据库交互。但是它并不能像使用diesel crate 那样直接工作。为了连接数据库,我们必须添加所需的 crates 并激活rocket_contrib crate 的必要功能:

rocket = "0.4.0-rc.1"
rocket_contrib = { version = "0.4.0-rc.1", features = ["diesel_sqlite_pool"] }
serde = "1.0"
serde_json = "1.0"
serde_derive = "1.0"
diesel = { version = "1.3", features = ["sqlite", "r2d2"] }
diesel_migrations = "1.3"
log = "0.4"

我们使用了rocket_contrib crate 的diesel_sqlite_pool功能和diesel crate 中的sqlite以及r2d2。以下代码行从我们需要的所有 crates 中导入宏,添加我们稍后将要创建的comment模块,并导入所有必要的类型:

#![feature(proc_macro_hygiene, decl_macro)]

#[macro_use]
extern crate rocket;
#[macro_use]
extern crate diesel;
#[macro_use]
extern crate diesel_migrations;
#[macro_use]
extern crate log;
#[macro_use]
extern crate serde_derive;
#[macro_use]
extern crate rocket_contrib;

mod comment;

use rocket::fairing::AdHoc;
use rocket::request::Form;
use rocket_contrib::json::Json;
use diesel::SqliteConnection;
use comment::{Comment, NewComment};

你还会看到我们使用了 nightly 发布版中的两个功能:proc_macro_hygienedecl_macro。没有这些功能,你无法声明处理器。

nightly Rust 编译器包含许多酷但不稳定的功能。不稳定并不意味着你不能在生产应用程序中使用它们;这意味着这些功能可能会更改或甚至被删除。它们的不稳定性意味着使用它们是有风险的,因为你可能需要稍后重写你的代码。Rocket 框架要求你使用一些不稳定的功能。你可以在不稳定手册中找到不稳定功能的完整列表:doc.rust-lang.org/stable/unstable-book/

现在,我们可以在代码中连接到 SQLite 数据库。为此,我们为SqliteConnection创建一个包装器,并将用户数据库属性设置为global.database.sqlite_database参数,以分配数据库连接:

#[database("sqlite_database")]
pub struct Db(SqliteConnection);

我们还使用了迁移嵌入功能,它将migrations文件夹中的所有 SQL 脚本包含在一个程序中:

embed_migrations!();

现在,我们可以创建并启动服务器实例。我们通过调用ignite方法创建一个Rocket实例,但在启动它之前,我们在 Rocket 框架中添加了两个称为 fairings 的中间件。第一个是为Db数据库包装器创建的,它为请求提供数据库池。第二个是AdHoc fairing,它尝试为数据库运行迁移。看看以下代码:

fn main() {
    rocket::ignite()
        .attach(Db::fairing())
        .attach(AdHoc::on_attach("Database Migrations", |rocket| {
            let conn = Db::get_one(&rocket).expect("no database connection");
            match embedded_migrations::run(&*conn) {
                Ok(_) => Ok(rocket),
                Err(err) => {
                    error!("Failed to run database migrations: {:?}", err);
                    Err(rocket)
                },
            }
        }))
        .mount("/", routes![list, add_new])
        .launch();
}

之后,我们调用mount方法将路由添加到根路径。路由是通过routes!宏创建的,其中我们包括本节稍后定义的所有路由。当Rocket实例构建完成后,我们通过调用launch方法来运行它。

处理请求

我们的微服务包含两个处理器。第一个处理器处理/list路径的请求,并从数据库返回所有评论:

#[get("/list")]
fn list(conn: Db) -> Json<Vec<Comment>> {
    Json(Comment::all(&conn))
}

如您所见,Rocket 框架中的处理器是一个函数,它接受**rocket**自动绑定的参数,并期望一个函数返回一个结果。我们的list函数返回 JSON 格式的评论列表。我们使用在comment模块中声明的Comment模型,通过函数参数提供的连接池提取所有评论。

要声明一个方法和路径,我们向具有所需路径的函数声明中添加get属性。get属性允许您使用GET方法调用处理器。此外,还有一个post属性,我们用它来添加评论处理器:

#[post("/new_comment", data = "<comment_form>")]
fn add_new(comment_form: Form<NewComment>, conn: Db) {
    let comment = comment_form.into_inner();
    Comment::insert(comment, &conn);
}

前面的函数期望两个参数:Form,它可以解析到NewComment对象,以及Db实例。Form包装器持有提供的类型内部值。为了提取它,我们调用into_inner方法,在我们的情况下返回NewComment结构体。如果表单不提供请求,该方法甚至不会被调用。我们在post属性的data绑定中设置一个参数,用于存储提供的数据。最后,我们使用Comment类型的插入方法,使用提供的ConnectionNewComment结构体插入到数据库中。

就这些!微服务已经声明。这很简单,不是吗?但我们最后需要的是模式声明。让我们添加它。

数据库模式和管理

评论将存储在具有三个字段的comments表中:评论的id、用户的uid和评论的text

mod schema {
    table! {
        comments {
            id -> Nullable<Integer>,
            uid -> Text,
            text -> Text,
        }
    }
}

Comment结构体的声明如下:

#[table_name="comments"]
#[derive(Serialize, Queryable, Insertable, Debug, Clone)]
pub struct Comment {
    pub id: Option<i32>,
    pub uid: String,
    pub text: String,
}

我们在Comment结构体中重复了相同的字段,并添加了没有idNewComment结构体:

#[derive(FromForm)]
pub struct NewComment {
    pub uid: String,
    pub text: String,
}

现在来点新的——我们为NewComment结构体推导出FormForm类型。这有助于 Rocket 将查询转换为Form实例。下一个Comment结构体实现添加了两个方法:

impl Comment {
    pub fn all(conn: &SqliteConnection) -> Vec<Comment> {
        all_comments.order(comments::id.desc()).load::<Comment>(conn).unwrap()
    }

    pub fn insert(comment: NewComment, conn: &SqliteConnection) -> bool {
        let t = Comment { id: None, uid: comment.uid, text: comment.text };
        diesel::insert_into(comments::table).values(&t).execute(conn).is_ok()
    }
}

我们使用由diesel包生成的函数与数据库交互,使用Connection实例。如果您想了解更多关于diesel包的信息,您可以在第八章中阅读更多,使用对象关系映射与数据库交互

编译和运行

要运行使用Rocket创建的微服务,您需要创建一个Rocket.toml配置文件。这允许您在启动之前配置微服务。查看以下Rocket.toml内容:

[global]
template_dir = "static"
address = "127.0.0.1"
port = 8003

[global.databases.sqlite_database]
url = "test.db"

在这个配置中,我们声明了全局参数,例如:包含模板的template_dir目录(如果使用它)、addressport,以及数据库连接的url

您可以使用环境变量覆盖任何参数。例如,如果我们需要将port参数设置为 80,我们可以使用以下命令运行微服务:

ROCKET_PORT=3721 cargo run

Rocket 框架也支持三种不同的环境类型:developmentstagingproduction。它允许你在同一个配置中拥有三个配置。在global部分之外添加一个额外的部分,并使用相应的模式运行微服务:

ROCKET_ENV=staging cargo run

要测试一个微服务,只需用简单的cargo run命令启动它,无需额外参数。当服务启动时,我们可以添加以下命令的注释并打印所有注释的列表:

curl -d 'uid=user_id&text="this is a comment"' -X POST http://localhost:8003/new_comment
curl http://localhost:8003/list

此命令以 JSON 格式打印所有注释。正如你所见,我们没有直接将任何结构体转换为 JSON。Rocket 会自动完成这项工作。

Gotham

我们已经学习了如何使用三个简化微服务编写的框架:Rouille、Nickel 和 Rocket。但所有这些框架都是同步的。如果你想编写异步微服务,你有三条路径可以选择:直接使用hyper依赖库,就像我们在第二章中做的那样,使用 Hyper 依赖库开发微服务;使用使用hypertokiogotham依赖库;或者使用actix-web框架。在本节中,我们将学习如何使用gotham依赖库和异步的tokio-postgres依赖库来异步地与 PostgreSQL 交互。我们将在第十一章中学习如何使用actix-web依赖库,使用 Actors 和 Actix 依赖库处理并发

作为使用gotham依赖库的示例,我们将创建一个微服务,该服务从请求中获取User-Agent header并将其存储在 PostgreSQL 数据库中。我们将创建一个完全异步的应用程序,并了解tokio-postgres依赖库。

创建一个微服务

创建一个新的二进制依赖库,并添加以下依赖:

failure = "0.1"
futures = "0.1"
gotham = "0.3"
gotham_derive = "0.3"
hyper = "0.12"
mime = "0.3"
tokio = "0.1"
tokio-postgres = { git = "https://github.com/sfackler/rust-postgres" }

正如你所见,我们添加了gothamgotham_derive依赖库。第一个是一个框架,第二个帮助我们为所需的共享连接推导出StateData特质的实现。gotham_derive依赖库也可以用来推导中间件的NewMiddleware特质,但我们的示例不需要特殊的中间件。

我们还添加了tokio-postgres依赖库。它包含了一个异步数据库连接器的实现,用于 PostgreSQL。

框架类型

我们需要为微服务准备很多类型。让我们简要谈谈我们这里导入的每个类型:

use failure::{Error, format_err};
use futures::{Future, Stream, future};
use gotham::handler::HandlerFuture;
use gotham::middleware::state::StateMiddleware;
use gotham::pipeline::single::single_pipeline;
use gotham::pipeline::single_middleware;
use gotham::router::Router;
use gotham::router::builder::{DefineSingleRoute, DrawRoutes, build_router};
use gotham::state::{FromState, State};
use gotham_derive::StateData;
use hyper::Response;
use hyper::header::{HeaderMap, USER_AGENT};
use std::sync::{Arc, Mutex};
use tokio::runtime::Runtime;
use tokio_postgres::{Client, NoTls};

很可能你已经熟悉failurefutures依赖库中的类型,因为我们在这本书的第一部分中大量使用了它们。最有趣的是gotham依赖库的类型。有多个模块涵盖了框架的不同部分;handler模块包含HandlerFuture,它是Future特质的别名,具有预定义的类型:

type HandlerFuture = dyn Future<
    Item = (State, Response<Body>),
    Error = (State, HandlerError)
    > + Send;

我们将在异步处理程序中使用这个Future别名。此外,此模块包含IntoHandlerFuture特质,该特质为可以转换为响应的元组实现。

middleware模块包含StateMiddleware,我们将使用它来将状态附加到我们的微服务上。

pipeline模块包含我们将要使用的两个函数:single_middlewaresingle_pipeline。第一个函数在内部创建一个包含单个提供的中间件的Pipeline。第二个函数是创建从单个管道实例创建管道链所必需的。

router模块包括我们构建微服务路由表的所需类型。Router结构是一个包含路由的类型,我们必须实例化并提供它给服务器。我们将通过调用build_router函数来完成这项工作。

对于DrawRoutes特质,我们需要有Router的方法来添加路径。它添加了getget_or_headputpost和其他方法来注册与相应 HTTP 方法对应的路径。调用这些方法返回SingleRouteBuilder实例,我们需要使用DefineSingleRoute特质为to方法,这允许我们将注册的路径映射到Handler

state模块为我们提供了使用通用的State并将其通过调用实现了StateData特质的类型所实现的FromState特质的borrow_from方法转换为所需类型的可能性。

gotham中的通用State是一个非常灵活的概念,它提供了获取环境不同部分引用的能力。你可以获取你自己的状态类型的引用或请求数据的引用。

我们需要从hypercrate 中获取一些类型,因为该 crate 在gotham实现中以及某些类型的hyper中使用。我们导入了Response类型来创建客户端的响应,以及HeaderMap来获取对请求头部的访问,因为我们需要获取USER_AGENT头部的值。

由于我们正在开发一个异步应用程序,我们必须使用相同的 reactor 在同一运行时中执行所有任务。为此,我们将使用从tokiocrate 中手动创建的Runtime

要连接到数据库,我们需要从tokio-postgrescrate 中导入Client类型和NoTls来配置连接。

现在我们已经导入了编写应用程序的main函数所需的所有内容。

主函数

main函数的实现中,我们创建一个Runtime实例,我们将使用它进行数据库查询和处理 HTTP 请求。查看以下代码:

pub fn main() -> Result<(), Error> {
    let mut runtime = Runtime::new()?;

    let handshake = tokio_postgres::connect("postgres://postgres@localhost:5432", NoTls);
    let (mut client, connection) = runtime.block_on(handshake)?;
    runtime.spawn(connection.map_err(drop));

    // ...
}

我们创建一个Runtime实例。之后,我们可以通过调用tokio-postgrescrate 的连接函数来创建一个新的数据库连接。它返回一个Future,我们必须立即执行它。要运行Future,我们将使用我们已创建的相同的RuntimeRuntime有我们已经在第五章中讨论过的block_on方法,使用 Futures Crate 理解异步操作。我们用Connect未来调用它,并获取一对结果:ClientConnection实例。

Client是一个提供创建语句方法的类型。我们将在这个部分稍后声明的ConnState中存储这个实例。

Connection类型是一个执行与数据库实际交互的任务。我们必须在Runtime中派发这个任务。如果你忘记这样做,你的数据库查询将被阻塞,永远不会发送到数据库服务器。

现在,我们可以使用Client实例来执行 SQL 语句。我们需要的第一个语句是创建一个用于记录User-Agent header值的表。Client结构体有batch_execute方法,它从字符串中执行多个语句。我们只使用了一个语句,但如果你想创建多个表,这个调用很有用:

let execute = client.batch_execute(
     "CREATE TABLE IF NOT EXISTS agents (
         agent TEXT NOT NULL,
         timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW()
     );");
 runtime.block_on(execute)?;

batch_execute返回一个Future实例,我们必须立即执行它以在插入记录之前初始化数据库。我们使用Runtime实例的block_on方法来执行语句。

在我们完成实现主函数之前,让我们看看ConnState结构体的实现:

#[derive(Clone, StateData)]
struct ConnState {
    client: Arc<Mutex<Client>>,
}

结构体非常简单,包含一个原子引用计数器Arc,它包装了一个带有Mutex的数据库Client。我们只需要一个方法来简化实例创建:

impl ConnState {
    fn new(client: Client) -> Self {
        Self {
            client: Arc::new(Mutex::new(client)),
        }
    }
}

但你也可以添加一个方法来获取这个状态的内值。如果你想在单独的模块中声明状态类型,这很有用。我们将直接使用client字段。

此外,你可能还会注意到ConnState继承了CloneStateData特性。结构体必须是可克隆的,因为 Gotham 会为每个请求克隆一个状态。StateData允许我们将此结构体的一个实例附加到StateMiddleware

现在,我们可以完成main函数的实现:

let state = ConnState::new(client);
let router = router(state);

let addr = "127.0.0.1:7878";
println!("Listening for requests at http://{}", addr);
gotham::start_on_executor(addr, router, runtime.executor());
runtime
    .shutdown_on_idle()
    .wait()
    .map_err(|()| format_err!("can't wait for the runtime"))

我们使用Client值创建了ConnState状态,并将结果存储在state变量中,用于router函数调用,我们将在稍后声明。

之后,我们可以通过调用start_on_executor函数来启动 Gotham 服务器。它期望三个参数:我们设置为"127.0.0.1:7878"地址,我们使用router函数调用创建的路由器值,以及我们从Runtime中提取的TaskExecutor实例。

实际上,start_on_executor函数调用会向异步反应器派发一个任务,我们必须启动我们的Runtime实例。我们可以通过调用shutdown_on_idle方法来实现,它返回一个Shutdown未来对象,我们使用wait方法调用在当前线程中运行它。当所有任务完成时,main函数结束。

让我们看看创建我们应用程序的Router实例的router函数实现:

fn router(state: ConnState) -> Router {
    let middleware = StateMiddleware::new(state);
    let pipeline = single_middleware(middleware);
    let (chain, pipelines) = single_pipeline(pipeline);
    build_router(chain, pipelines, |route| {
        route.get("/").to(register_user_agent);
    })
}

在函数实现中,我们创建一个StateMiddleware实例,并将ConnState提供给它。我们通过single_middleware调用将中间件添加到管道中,并通过调用single_pipeline函数调用创建一个链。它返回一个链和一组管道。

我们将这些值传递给build_router函数,该函数返回Router实例,但我们可以通过在将闭包作为第三个参数传递给build_router函数时调用RouterBuilder的方法来调整生成的Router

我们调用RouterBuilder的 get 方法,将register_user_agent函数中实现的处理器设置到根路径/。Gotham 框架支持路由的 scope 范围,这可以帮助你通过路径前缀来分组处理器,如下所示:

route.scope("/checkout", |route| {
    route.get("/view").to(checkout::view);
    route.post("/item").to(checkout::item::create);
    route.get("/item").to(checkout::item::read);
    route.put("/item").to(checkout::item::update);
    route.patch("/item").to(checkout::item::update);
    route.delete("/item").to(checkout::item::delete);
}

现在我们只需要实现一个处理器。

处理器实现

Gotham 中的每个处理器都必须返回一个可以转换为HandlerFuture的元组的HandlerFuture实现。此外,处理器必须接受一个State参数:

fn register_user_agent(state: State) -> Box<HandlerFuture> {
    // Implementation
}

如果你记得,我们需要从请求中提取User-Agent header。我们可以使用State值来完成此操作,因为我们可以通过borrow_from方法调用从State借用HeaderMap。它返回一个我们可以使用USER_AGENT键(从hyper crate 导入)来获取User-Agent HTTP header的映射:

let user_agent = HeaderMap::borrow_from(&state)
    .get(USER_AGENT)
    .map(|value| value.to_str().unwrap().to_string())
    .unwrap_or_else(|| "<undefined>".into());

HeaderMap返回HeaderValue作为header的值,我们必须使用to_str方法获取字符串值,并使用to_string方法将其转换为所有者字符串。如果未提供header,我们使用"<undefined>"值。

现在我们可以从State中借用ConnState值,并向数据库添加一条新记录:

let conn = ConnState::borrow_from(&state);
let client_1 = conn.client.clone();
let client_2 = conn.client.clone();

let res = future::ok(())
    .and_then(move |_| {
        let mut client = client_1.lock().unwrap();
        client.prepare("INSERT INTO agents (agent) VALUES ($1)
                        RETURNING agent")
    })
    .and_then(move |statement| {
        let mut client = client_2.lock().unwrap();
        client.query(&statement, &[&user_agent]).collect().map(|rows| {
            rows[0].get::<_, String>(0)
        })
    })
    .then(|res| {
        let mut builder = Response::builder();
        let body = {
            match res {
                Ok(value) => {
                    let value = format!("User-Agent: {}", value);
                    builder.status(StatusCode::OK);
                    value.into()
                }
                Err(err) => {
                    builder.status(StatusCode::INTERNAL_SERVER_ERROR);
                    err.to_string().into()
                }
            }
        };
        let response = builder.body(body).unwrap();
        Ok((state, response))
    });
Box::new(res)

我们需要两个Client的引用,因为我们必须解决两个未来:一个是准备查询,另一个是执行该查询。为了准备查询,我们将使用prepare方法,该方法期望一个包含 SQL 语句的字符串。方法调用返回一个返回Statement实例的Future实例,但我们不能在函数体中直接创建该Future,因为我们必须锁定Mutex以获取对Client的访问权限,并且它将在Future语句解决后阻塞。

要两次使用Client,我们需要两个Client的引用,并在未来的链中分别使用它们。我们通过调用future::ok方法来创建一个成功的Future,从而开始创建一个未来的链。我们使用and_then方法添加第一步:语句准备。然后,我们锁定Mutex以获取对Client的可变引用。接下来,我们调用prepare方法创建一个返回StatementFuture

除了这些,我们还可以将下一步添加到未来的链中,以填充Statement中的值。我们锁定第二个Mutex克隆以调用Client的查询方法。该方法期望一个语句作为第一个参数和一个引用数组,其中包含对值的引用。由于我们知道我们使用的语句插入一条新记录并返回一行,我们从第一行的第一个位置提取一个String值。

在链的末端,我们使用方法将查询执行的结果 Result 转换为 Response。我们为 Response 创建一个新的 Builder。如果查询返回成功的结果,我们将其返回给客户端。如果查询失败,我们使用 500 状态码打印一个错误。闭包返回一个包含一对的元组:StateResponse 实例。Gotham 使用这个结果将响应返回给客户端。

实现完成,现在我们可以使用数据库实例来检查它。

运行和测试

要运行此示例,我们需要一个 PostgreSQL 数据库实例。最简单的方法是启动一个 Docker 容器。我们已经在第七章,与数据库的可靠集成中做了这件事,在那里我们学习了如何使用 Rust 与数据库交互。你可以使用以下命令启动一个新的包含 PostgreSQL 数据库实例的容器:

docker run -it --rm --name test-pg -p 5432:5432 postgres

容器启动后,使用 cargo run 命令运行本节中编写的示例服务器。编译后它会打印出来,服务器准备好接受请求:

Listening for requests at http://127.0.0.1:7878

现在,你可以使用提供的链接从你的浏览器记录访问。如果配置成功,你将在浏览器中看到响应:

Gotham 处理了请求并返回了一个结果给你。如果你关闭数据库,服务器将返回一个带有 500 错误代码和 "connection closed" 字符串的响应。我们最后要做的就是验证服务器是否将记录添加到了数据库中,因为我们使用异步方法与数据库交互,并使用相同的 Runtime 来处理 HTTP 请求和执行 SQL 语句。为 postgres://postgres@localhost:5432 连接运行 **psql** 客户端并输入一个查询:

postgres=# SELECT * FROM agents;
                                                      agent                                      | timestamp           
-------------------------------------------+-------------------------------
 Mozilla/5.0 Gecko/20100101 Firefox/64.0   | 2019-01-10 19:43:59.064265+00
 Chrome/71.0.3578.98 Safari/537.36         | 2019-01-10 19:44:08.264106+00
(2 rows)

我们从两个不同的浏览器中发出了两个请求,现在我们在 agents 表中有两条记录。

摘要

本章向您介绍了几个简化微服务编写的舒适框架:Rouille、Nickel 和 Rocket。

Rouille 框架围绕 router! 宏构建,帮助你以简单的方式声明所需的全部路径和方法。路由声明看起来与我们使用 Hyper 的方式相似,但简单得多。

Nickel 框架也很简单易用,灵感来源于 JavaScript 的 Express 框架。

Rocket 框架非常酷,可以帮助你以直观、清晰的方式编写处理器,但它需要编译器的夜间版本。

Gotham 框架是一个基于 tokiohyper crate 的异步框架。它允许你使用异步应用程序的所有好处:并行处理数千个请求并充分利用所有资源。我们创建了一个示例,通过向它发送查询来使用异步的 tokio-postgres crate 与数据库一起工作。

但是有更多的框架,我们无法涵盖所有。大多数框架都是同步的,并且使用起来很简单。如果你想编写一个异步微服务,我推荐你大多数情况下使用actix-web crate,我们将在下一章中对其进行探讨。

第十章:微服务中的后台任务和线程池

在本章中,你将学习如何在微服务中使用后台任务。在第五章,使用 Futures Crate 理解异步操作中,我们创建了一个提供用户上传图像功能的微服务。现在,我们将创建这个服务的另一个版本,该版本加载一个图像并返回该图像的缩放版本。

为了充分利用可用资源,微服务必须使用异步代码实现,但微服务的每个部分并不都可以异步。例如,需要大量 CPU 负载的部分或必须使用共享资源的部分应该在一个单独的线程中实现,或者使用线程池来避免阻塞用于处理异步代码事件循环的主线程。

在本章中,我们将介绍以下主题:

  • 如何与生成的线程交互

  • 如何使用futures-cpupooltokio-threadpoolcrate

技术要求

在本章中,我们将通过添加图像缩放功能来改进第五章,使用 Futures Crate 理解异步操作中的图像微服务。要编译示例,你需要 Rust 编译器,版本 1.31 或更高。

你可以从 GitHub 上的项目获取本章示例的源代码:github.com/PacktPublishing/Hands-On-Microservices-with-Rust/tree/master/Chapter10

与线程交互

我们首先将使用一个单独的线程来实现这个功能,该线程将缩放所有传入的图像。之后,我们将使用线程池改进微服务。在本节中,我们将开始使用线程在后台执行任务。

同步还是异步?

在这本书中,我们更喜欢创建异步微服务,因为这些可以处理大量的并发请求。然而,并不是每个任务都可以通过异步方式处理。我们是否可以使用异步微服务取决于任务的类型和它需要的资源。让我们进一步探讨这种差异。

I/O 密集型任务与 CPU 密集型任务

有两种类型的任务。如果一个任务不进行很多计算,但进行大量的输入/输出操作,那么它被称为 I/O 密集型任务。由于 CPU 的速度远快于输入/输出总线,我们不得不等待很长时间才能使总线或设备可用进行读写。I/O 密集型任务可以通过异步方式很好地处理。

如果一个任务使用 CPU 进行大量操作,那么它被称为 CPU 密集型任务。例如,图像缩放是一种 CPU 密集型任务,因为它从原始图像重新计算像素,但只有在准备好时才保存结果。

I/O 密集型任务和 CPU 密集型任务之间的区别并不明显,并不是每个任务都可以严格归类为 I/O 或 CPU 领域。为了调整图像大小,你必须将整个图像保持在内存中,但如果你提供的服务转码视频流,它可能需要大量的 I/O 和 CPU 资源。

异步上下文中的同步任务

假设你知道任务属于哪个类别,无论是 I/O 还是 CPU。IO 任务可以在单个线程中处理,因为它们必须等待大量的 I/O 数据。然而,如果你的硬件具有多核 CPU 和大量的 I/O 设备,那么单个线程就不够了。你可能会决定使用单个异步上下文中的多个线程,但有一个问题——并不是每个异步任务都可以在线程之间传递。例如,SQLite-嵌入式数据库将服务数据存储在线程局部存储中,你不能在多个线程中使用相同的数据库句柄。

SQLite 不能异步地与数据库一起工作;它有异步方法可以与在单独线程中运行的实例交互,但你必须记住,并不是每个任务都可以在多线程上下文中运行。

如果我们拥有多核硬件,一个好的解决方案是使用线程池来处理连接。你可以将连接上下文从池中的任何线程传递,这样就可以异步地处理连接。

Rust 和编写良好的 crate 可以防止你犯错;在我看来,Rust 是编写快速和安全的软件的最佳工具。然而,重要的是要意识到某些难以通过编译器检测到的特定情况,这发生在你在异步上下文中调用阻塞操作时。异步应用程序使用一个反应器,当数据准备好读取或写入时,它会调用必要的代码,但如果你已经调用了阻塞方法,反应器就无法被调用,并且所有由被阻塞的线程处理的连接都将被阻塞。更糟糕的是,如果你调用与反应器相关的同步方法,应用程序可能会完全阻塞。例如,如果你尝试向由反应器处理的接收器发送消息,但通道已满,程序将被阻塞,因为反应器必须被调用以排空通道,但由于线程已被发送的消息阻塞,这无法完成。看看以下示例:

fn do_send(tx: mpsc::Sender<Msg>) -> impl Future<Item = (), Error = ()> {
    future::lazy(|| {
      tx.send(Msg::Event).wait(); // The program will be blocked here
    })
}

结论很简单——只应在异步上下文中使用异步操作。

在文件上使用 IO 操作的限制

如前所述,一些库,例如 SQLite,使用阻塞操作来对数据库进行查询并获取结果,但这取决于它们使用的 I/O 类型。在现代操作系统中,网络堆栈是完全异步的,但文件的输入/输出更难异步使用。操作系统包含执行异步读取或写入的函数,但很难实现跨平台的兼容性。使用单独的线程来处理硬盘 I/O 交互会更简单。tokio 包使用单独的线程来处理文件的 I/O。其他平台,如 Go 或 Erlang,也做同样的事情。你可以为特定操作系统使用异步 I/O 来处理文件,但这不是一个非常灵活的方法。

现在你已经了解了同步和异步任务之间的区别,我们准备创建一个使用单独线程来处理调整图像大小的 CPU 密集型任务的异步服务。

为图像处理启动一个线程

在我们的第一个例子中,我们将创建一个微服务,该服务期望接收一个包含图像的请求,将其完全加载到内存中,将其发送到线程进行调整大小,并等待结果。让我们首先创建一个期望图像数据和响应的线程。为了接收请求,我们将使用 mpsc::channel 模块和 oneshot::channel 用于响应,因为多个客户端不能发送请求,我们只期望每个图像有一个响应。对于请求,我们将使用以下结构体:

struct WorkerRequest {
    buffer: Vec<u8>,
    width: u16,
    height: u16,
    tx: oneshot::Sender<WorkerResponse>,
}

WorkerRequest 包含用于二进制图像数据的 buffer 字段,所需调整大小的图像的 widthheight,以及一个 oneshot::Sender 类型的 tx 发送器,用于发送 WorkerReponse 响应。

响应通过类型别名呈现为 Result 类型,该类型包含成功的结果,其中包含调整大小图像的二进制数据或错误:

type WorkerResponse = Result<Vec<u8>, Error>;

我们现在可以创建一个支持这些消息并执行调整大小的线程:

fn start_worker() -> mpsc::Sender<WorkerRequest> {
    let (tx, rx) = mpsc::channel::<WorkerRequest>(1);
    thread::spawn(move || {
        let requests = rx.wait();
        for req in requests {
            if let Ok(req) = req {
                let resp = convert(req.buffer, req.width, req.height).map_err(other);
                req.tx.send(resp).ok();
            }
        }
    });
    tx
}

由于我们为所有调整大小请求使用单个线程,我们可以使用 SenderReceiverwait 方法与客户端交互。前面的代码从 mpsc 模块创建了一个 channel,该 channel 可以在缓冲区中保持一条消息。我们不需要在缓冲区中为消息腾出更多空间,因为调整大小需要很长时间,而我们只需要在我们处理图像的同时将下一条消息发送给接收器。

我们使用thread::spawn方法来创建一个新的线程,并使用处理函数。Receiver::wait方法将Receiver转换为接收消息的阻塞迭代器。我们使用一个简单的循环来遍历所有请求。在这里不需要反应器。如果成功接收到消息,我们将处理请求。为了转换图像,我们使用以下代码片段中描述的convert方法。我们将结果发送到oneshot::Sender,它没有wait方法;我们只需要调用send方法,它返回一个Result。这个操作不会阻塞,也不需要反应器,因为它内部使用UnsafeCell来为实现了Future特质的Receiver提供一个值。

要调整图像大小,我们使用一个image包。这个包包含了一组丰富的图像转换方法,并支持多种图像格式。看看convert函数的实现:

fn convert(data: Vec<u8>, width: u16, height: u16) -> ImageResult<Vec<u8>> {
    let format = image::guess_format(&data)?;
    let img = image::load_from_memory(&data)?;
    let scaled = img.resize(width as u32, height as u32, FilterType::Lanczos3);
    let mut result = Vec::new();
    scaled.write_to(&mut result, format)?;
    Ok(result)
}

该函数期望接收图像的二进制数据,以及与其宽度和高度相关的数据。convert函数返回一个ImageResult,这是Result类型的一个别名,其错误类型为ImageError。我们使用这个错误类型,因为convert函数实现内部的一些方法可能会返回这种类型的错误。

实现的第一行尝试使用guess_format函数猜测传入数据的格式。我们可以在之后使用这个格式值来为输出图像使用相同的格式。之后,我们使用load_from_memory函数从数据向量中读取图像。这个调用读取数据并实际上将图像消耗的内存量加倍——如果你想要同时处理多个图像,请注意这一点。调整大小后,我们将缩放后的图像写入向量,并作为Result返回。缩放后的图像也消耗了一些内存,这意味着我们几乎将内存消耗量翻了两番。最好为传入消息的大小、宽度和高度添加限制,以防止内存溢出。

现在,我们可以实现main函数,它创建一个工作线程并启动服务器实例:

fn main() {
    let addr = ([127, 0, 0, 1], 8080).into();
    let builder = Server::bind(&addr);
    let tx = start_worker();
    let server = builder.serve(move || {
        let tx = tx.clone();
        service_fn(move |req| microservice_handler(tx.clone(), req))
    });
    let server = server.map_err(drop);
    hyper::rt::run(server);
}

与上一章的main函数相比,这里唯一的区别是我们调用start_worker函数,并使用返回的Sender作为处理函数的参数,同时附带一个请求。

让我们看看microservice_handler的实现,并了解它是如何与工作线程交互的。

以异步方式与线程交互

图像调整微服务的处理函数包含两个分支:一个用于索引页面,一个用于调整请求。看看以下代码:

fn microservice_handler(tx: mpsc::Sender<WorkerRequest>, req: Request<Body>)
    -> Box<Future<Item=Response<Body>, Error=Error> + Send>
{
    match (req.method(), req.uri().path().to_owned().as_ref()) {
        (&Method::GET, "/") => {
            Box::new(future::ok(Response::new(INDEX.into())))
        },
        (&Method::POST, "/resize") => {
            let (width, height) = {
                // Extracting parameters here
            };
            let body = ; // It's a Future for generating a body of the Response
            Box::new(body)
        },
        _ => {
            response_with_code(StatusCode::NOT_FOUND)
        },
    }
}

在处理函数的resize分支部分,我们必须执行各种操作:提取参数、从流中收集正文、向工作线程发送任务以及生成正文。由于我们使用异步代码,我们将创建一系列方法调用,以构建必要的Future对象。

要提取参数,我们使用以下代码:

let (width, height) = {
    let uri = req.uri().query().unwrap_or("");
    let query = queryst::parse(uri).unwrap_or(Value::Null);
    let w = to_number(&query["width"], 180);
    let h = to_number(&query["height"], 180);
    (w, h)
};

我们使用uriquery部分和queryst crate 的parse函数将参数解析为Json::Value。之后,我们可以通过索引提取必要的值,因为Value类型实现了std::ops::Index特质。通过索引获取值返回一个Value,如果值未设置,则返回Value::Nullto_number函数尝试将值表示为字符串并将其解析为u16值。或者,它返回一个默认值,这是作为第二个参数设置的:

fn to_number(value: &Value, default: u16) -> u16 {
    value.as_str()
        .and_then(|x| x.parse::<u16>().ok())
        .unwrap_or(default)
}

默认情况下,我们将使用 180 × 180 像素的图像大小。

处理分支的下一部分使用我们从查询字符串中提取的大小参数创建响应体。以下代码收集请求流到一个向量中,并使用工作者实例调整图像大小:

let body = req.into_body()
    .map_err(other)
    .concat2()
    .map(|chunk| chunk.to_vec())
    .and_then(move |buffer| {
        let (resp_tx, resp_rx) = oneshot::channel();
        let resp_rx = resp_rx.map_err(other);
        let request = WorkerRequest { buffer, width, height, tx: resp_tx };
        tx.send(request)
            .map_err(other)
            .and_then(move |_| resp_rx)
            .and_then(|x| x)
    })
    .map(|resp|  Response::new(resp.into()));

要与工作者交互,我们创建一个oneshot::channel实例和一个带有必要参数的WorkerRequest。之后,我们使用tx变量向工作者发送请求,tx是一个连接到工作者并提供了microservice_handler函数调用的Sender实例。send方法创建一个如果消息成功发送则成功的 future。我们使用and_then方法添加一个步骤到这个 future 中,该方法从实现了Future特质的oneshot::Recevier中读取一个值。

当缩放的消息准备好时,我们将其作为Future的结果,并将其map到响应。

使用curl发送图像来测试示例:

curl --request POST \
 --data-binary "@../../media/image.jpg" \
 --output "files/resized.jpg" \
 "http://localhost:8080/resize?width=100&height=100"

我们已从媒体文件夹发送了image.jpg并将其结果保存到files/resized.jpg文件中。

这个微服务的最大缺点是它只使用单个线程,这很快就会成为瓶颈。为了防止这种情况,我们可以使用多个线程来共享 CPU 资源以处理更多请求。现在让我们看看如何使用线程池。

使用线程池

要使用线程池,你不需要特殊的库。相反,你可以实现一个调度器,将请求发送到一组线程。你甚至可以检查工作者的响应来决定选择哪个线程进行处理,但有一些现成的 crate 可以帮助更优雅地解决这个问题。在本节中,我们将探讨futures-cpupooltokio-threadpoolcrate。

CpuPool

在这里,我们将重用现有的微服务并移除start_worker函数,以及WorkerRequestWorkerResult类型。保留convert函数并添加一个新的依赖到Cargo.toml

futures-cpupool = "0.1"

从该 crate 导入CpuPool类型:

use futures_cpupool::CpuPool;

现在这个池已经准备好在请求处理程序中使用。我们可以像在先前的例子中传递工作者线程的Sender一样传递它作为参数:

fn main() {
    let addr = ([127, 0, 0, 1], 8080).into();
    let pool = CpuPool::new(4);
    let builder = Server::bind(&addr);
    let server = builder.serve(move || {
        let pool = pool.clone();
        service_fn(move |req| microservice_handler(pool.clone(), req))
    });
    let server = server.map_err(drop);
    hyper::rt::run(server);
}

在前面的代码中,我们创建了一个包含四个线程的线程池,并将其传递给serve函数以clone它用于处理程序。处理程序函数将池作为第一个参数:

fn microservice_handler(pool: CpuPool, req: Request<Body>)
     -> Box<Future<Item=Response<Body>, Error=Error> + Send>

我们使用相同的分支和代码来提取宽度和高度参数。然而,我们改变了对图像的转换方式:

let body = req.into_body()
    .map_err(other)
    .concat2()
    .map(|chunk| chunk.to_vec())
    .and_then(move |buffer| {
        let task = future::lazy(move || convert(&buffer, width, height));
        pool.spawn(task).map_err(other)
    })
    .map(|resp| Response::new(resp.into()));

这个实现的代码变得更加紧凑和准确。在这个实现中,我们也将一个体收集到一个Vec二进制中,但是为了转换图像,我们使用一个在CpuPool中使用spawn方法产生的懒Future

我们使用future::lazy调用来推迟convert函数的执行。如果没有lazy调用,convert函数将立即被调用并阻塞所有 IO 活动。你还可以使用BuilderCpuPool设置特定的参数。这有助于设置池中线程的数量、堆栈大小以及在新线程启动后和停止前将被调用的钩子。

CpuPool不是使用池的唯一方法。让我们看看另一个例子。

阻塞部分

tokio-threadpool存储库包含一个声明如下所示的blocking函数:

pub fn blocking<F, T>(f: F) -> Poll<T, BlockingError> where F: FnOnce() -> T 

这个函数期望任何执行阻塞操作的功能,并在一个单独的线程中运行它,提供一个可以由反应器使用的Poll结果。这是一个稍微低级的方法,但它被tokio和其他存储库(在文件上执行 IO 操作)积极使用。

这种方法的优点是我们不需要手动创建线程池。我们可以使用简单的main函数,就像我们之前做的那样:

fn main() {
    let addr = ([127, 0, 0, 1], 8080).into();
    let builder = Server::bind(&addr);
    let server = builder.serve(|| service_fn(|req| microservice_handler(req)));
    let server = server.map_err(drop);
    hyper::rt::run(server);
}

要启动一个调用convert函数的任务,我们可以使用以下代码:

let body = req.into_body()
    .map_err(other)
    .concat2()
    .map(|chunk| chunk.to_vec())
        .and_then(move |buffer| {
            future::poll_fn(move || {
                let buffer = &buffer;
                blocking(move || {
                    convert(buffer, width, height).unwrap()
                })
            })
            .map_err(other)
        })
        .map(|resp| Response::new(resp.into()));

blocking函数调用将任务执行委托给另一个线程,并在每次调用时返回一个Poll,直到执行结果准备好。要调用返回Poll结果的原始函数,我们可以使用future::poll_fn函数调用将该函数包装起来,将任何轮询函数转换为Future实例。看起来很简单,不是吗?我们甚至没有手动创建线程池。

例如,tokio-fs存储库使用这种方法在文件上实现 IO 操作:

impl Write for File {
    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
        ::would_block(|| self.std().write(buf))
    }
    fn flush(&mut self) -> io::Result<()> {
        ::would_block(|| self.std().flush())
    }
}

would_block是阻塞函数的一个包装器:

fn would_block<F, T>(f: F) -> io::Result<T>
where F: FnOnce() -> io::Result<T>,
{
    match tokio_threadpool::blocking(f) {
        Ok(Ready(Ok(v))) => Ok(v),
        Ok(Ready(Err(err))) => {
            debug_assert_ne!(err.kind(), WouldBlock);
            Err(err)
        }
        Ok(NotReady) => Err(WouldBlock.into()),
        Err(_) => Err(blocking_err()),
    }
}

现在,你知道任何阻塞操作都可以与异步反应器结合使用。这种方法不仅用于与文件系统交互,还用于数据库和其他不支持futures存储库或需要大量 CPU 计算的存储库。

演员

线程和线程池是利用服务器更多资源的好方法,但编程风格很繁琐。你必须考虑很多细节:发送和接收消息、负载分配以及重新启动失败的线程。

另一种并行运行任务的方法是演员。演员模型是一种计算模型,它使用称为演员的计算原语。它们并行工作并通过传递消息相互交互。与使用线程或池相比,这是一种更灵活的方法,因为你可以将每个复杂任务委托给一个单独的演员,该演员接收消息并将结果返回给任何向演员发送请求的实体。你的代码结构良好,你甚至可以重用演员来处理不同的项目。

我们已经学习了futurestokio库,它们直接使用起来比较复杂,但它们是构建异步计算框架的良好基础,尤其是实现演员模型非常好。actix库已经实现了这一点:它基于这两个库将演员模型引入 Rust。让我们研究一下我们如何使用演员来执行后台任务。

我们将重新实现缩放微服务,但添加三个演员:一个缩放演员,一个计数演员,它计算请求数量,以及一个日志演员,它将计数值写入syslog

actix 框架的基础

actix库提供了一个组织良好的演员模型,使用起来很简单。有一些主要概念你应该记住。首先,actix库有System类型,这是维护演员系统的主要类型。在创建和启动任何演员之前,你必须创建System实例。实际上,System是一个控制整个过程的Actor,可以用来停止应用程序。

Actor是框架中最常用的特质。实现了Actor特质的每个类型都可以被启动。我们将在本章后面实现这个特质。此外,actix库还包含Arbiter类型,这是一个事件循环控制器,每个线程必须有一个。有SyncArbiter来运行 CPU 密集型任务,这个仲裁者使用线程池来执行演员。

每个Actor都必须在一个Context中工作,这是一个运行时环境,可以用来启动其他任务。此外,每个Actor实例都带有一个Address,你可以用它向演员发送消息并接收响应。在我们的示例中,我们将所有必要的演员的地址存储在共享状态中,以便从不同的处理程序并行使用它们。

Address提供了一个期望实现Message特质的类型的send方法。为了为Actor实现消息处理,你必须为演员的类型实现Handler特质。

让我们为我们的缩放微服务创建三个演员。

实现演员

首先,我们必须导入所有必要的依赖项。在本章中,我们将使用与之前示例相同的公共依赖项,但你还需要将以下依赖项添加到Cargo.toml中:

actix = "0.7"
failure = "0.1"
syslog = "4.0"

我们添加了actixcrate。它是当前示例的主要 crate。此外,我们还导入了failurecrate,因为我们将使用Fail特质来获取对compat方法的访问权限,该方法将实现Fail特质的任何错误类型转换为实现std::error::Error特质的Compat类型。

此外,我们将使用syslog,并添加了syslogcrate 来访问系统 API。syslog是系统日志的标准。我们将用它来演示 actor 如何执行整个过程中的单独任务。现在,我们可以将actors模块添加到我们的示例中,并添加三个 actor。

计数 actor

我们要实现的第一个 actor 是一个计数器。它接收一个包含字符串的消息,并计算相同字符串的数量。我们将用它来计算指定路径的请求数量。

类型

创建src/actors/count.rs模块并导入以下类型:

use actix::{Actor, Context, Handler, Message};
use std::collections::HashMap;

type Value = u64;

我们将使用Actor特质来实现 actor 的行为,该行为在Context中工作,接收Message并通过Handler特质的实现来处理它。此外,我们需要HashMap来存储所有计数。我们还添加了Value类型别名,并将其用作计数的类型。

Actor

Actor是一个实现了Actor特质的struct。我们将使用一个包含HashMapstruct来计数传入字符串的数量:

pub struct CountActor {
    counter: HashMap<String, Value>,
}

impl CountActor {
    pub fn new() -> Self {
        Self {
            counter: HashMap::new(),
        }
    }
}

我们添加了一个新方法来创建一个空的CountActor实例。

现在,我们可以为我们的struct实现Actor特质。实现很简单:

impl Actor for CountActor {
    type Context = Context<Self>;
}

我们指定一个上下文并将其设置为Context类型。actor 特质包含不同方法的默认实现,这些方法可以帮助你响应 actor 的生命周期事件:

  • started: 当Actor实例启动时调用此方法。

  • stopping: 当Actor实例切换到停止状态(例如,当调用Context::stop时)时调用此方法。

  • stopped: 当Actor实例停止时调用此方法。

现在,让我们添加一个 actor 将处理的消息类型。

Message

计数 actor 期望一个包含字符串的消息,我们将添加以下struct

pub struct Count(pub String);

impl Message for Count {
    type Result = Value;
}

Count``struct有一个字段,类型为String,并实现了 Actix 框架的Message特质。此实现允许我们使用 actor 的地址发送Count实例。

Message特质需要关联类型Result。在消息处理完毕后,将返回此值。我们将为提供的字符串返回一个计数器值。

Handler

为了支持传入的消息类型,我们必须为我们的 actor 实现Handler特质。让我们为我们的CountActor实现Count消息的Handler

impl Handler<Count> for CountActor {
    type Result = Value;

    fn handle(&mut self, Count(path): Count, _: &mut Context<Self>) -> Self::Result {
        let value = self.counter.entry(path).or_default();
        *value = *value + 1;
        *value
    }
}

我们还必须设置与结果相同类型的关联类型。

处理发生在Handler特质的handle方法体中。我们将使用Count消息获取提供的字符串条目,并提取HashMap中的条目。如果没有找到条目,我们将获取一个默认值,该值对于u64类型(Value别名)等于 0,并将1添加到该值中。

现在ConnActor已经准备好使用。我们可以实例化它,并使用演员的地址来计数 HTTP 请求的路径。让我们再添加两个演员。

日志演员

日志记录演员将向syslog添加记录。

类型

我们需要actixcrate 的基本类型,并从syslogcrate 导入一些类型:

use actix::{Actor, Context, Handler, Message};
use syslog::{Facility, Formatter3164, Logger, LoggerBackend};

我们不需要详细研究syslogcrate,但让我们讨论一些基本类型。

你也可以使用use actix::prelude::*;来导入actixcrate 中几乎所有常用的类型。

Logger是一个主要结构体,允许将方法写入syslog。它包括按级别从高到低记录日志的方法:emergalertcriterrwarningnoticeinfodebugLoggerBackend枚举指定了连接到日志记录器的类型。它可以是套接字或 UNIX 套接字。Facility枚举指定了写入日志的应用程序类型。Formatter3164指定了日志记录的格式。

在两个 RFC 中描述了两种Syslog协议:31645424。这就是为什么格式化程序有如此奇怪的名字。

现在,我们可以实现日志记录演员。

演员

主要类型是LogActor结构体,它包含一个位于writer字段的Logger实例:

pub struct LogActor {
    writer: Logger<LoggerBackend, String, Formatter3164>,
}

我们将在Handler特质实现中使用这个记录器来写入消息,但现在我们需要为我们结构体提供一个构造函数,因为我们必须在启动时配置Logger

impl LogActor {
     pub fn new() -> Self {
         let formatter = Formatter3164 {
             facility: Facility::LOG_USER,
             hostname: None,
             process: "rust-microservice".into(),
             pid: 0,
         };
         let writer = syslog::unix(formatter).unwrap();
         Self {
             writer,
         }
     }
 }

我们添加了一个新方法,该方法使用Facility值和进程名称填充Formatter3164结构体,并将其他字段设置为空白值。我们通过调用syslog::unix方法并提供一个格式化程序来创建一个Logger实例。我们将Logger存储在writer字段中,并返回一个LogActor结构体的实例。

要添加演员的行为,我们将为LogActor结构体实现Actor特质:

impl Actor for LogActor {
    type Context = Context<Self>;
}

由于这个演员将与服务器实例和计数演员在同一个线程中工作,我们将使用基本的Context类型。

消息

我们需要一个消息来发送消息以便将它们写入syslog。有一个简单的结构体,带有一个公共String字段就足够了:

pub struct Log(pub String);

impl Message for Log {
    type Result = ();
}

我们添加了Log结构体并为它实现了Message特质。对于这个消息,我们不需要返回值,因为日志记录将是一个单向过程,并且所有错误都将被忽略,因为它们对于微服务应用来说不是关键的。但是,如果你的微服务必须在一个严格的安全环境中工作,你也必须通知管理员有关日志记录的问题。

处理器

Log消息的Handler非常简单。我们使用提供的信息调用Logger的 info 方法,并通过将Result转换为Option来忽略错误:

impl Handler<Log> for LogActor {
     type Result = ();

     fn handle(&mut self, Log(mesg): Log, _: &mut Context<Self>) -> Self::Result {
         self.writer.info(mesg).ok();
     }
 }

我们必须实现的最后一个演员是调整大小的演员。

调整大小的演员

调整大小的演员调整传入的消息,并将调整大小后的消息返回给客户端。

类型

我们不需要任何特殊类型,将使用actixcrate 的基本类型,并从之前使用的imagecrate 导入类型:

use actix::{Actor, Handler, Message, SyncContext};
use image::{ImageResult, FilterType};

type Buffer = Vec<u8>;

我们将在处理器的实现中将之前示例中的函数主体转换为函数,这就是为什么我们导入了image包中的类型。我们为Vec<u8>类型添加了Buffer别名以方便使用。

演员

我们需要一个没有字段的struct,因为我们将在SyncArbiter中使用它,它将在多个线程中运行多个演员。添加ResizeActor结构体:

pub struct ResizeActor;

impl Actor for ResizeActor {
    type Context = SyncContext<Self>;
}

我们不需要特殊的构造函数,并且我们实现了带有SyncContext类型的Actor特质,用于关联的Context类型。我们将使用此上下文类型使此演员适合SyncArbiter的同步环境:

消息

在本例中我们不使用转换函数,但我们需要相同的参数,我们将从Resize结构体中获取它们:

pub struct Resize {
    pub buffer: Buffer,
    pub width: u16,
    pub height: u16,
}

impl Message for Resize {
    type Result = ImageResult<Buffer>;
}

我们提供了一个包含图像数据的buffer以及所需的widthheight。在Resize结构体的Message特质实现中,我们使用ImageResult<Buffer>类型。与convert函数返回的相同结果类型。我们将在 HTTP 处理器实现中稍后从演员获取此值。

处理器

我们为ResizeActor实现了Resize消息的Handler,但使用了传递的消息的字段来使用convert函数的主体:

impl Handler<Resize> for ResizeActor {
     type Result = ImageResult<Buffer>;

     fn handle(&mut self, data: Resize, _: &mut SyncContext<Self>) -> Self::Result {
         let format = image::guess_format(&data.buffer)?;
         let img = image::load_from_memory(&data.buffer)?;
         let scaled = img.resize(data.width as u32, data.height as u32, FilterType::Lanczos3);
         let mut result = Vec::new();
         scaled.write_to(&mut result, format)?;
         Ok(result)
     }
 }

我们还使用SyncContext而不是Context,就像我们之前为演员所做的那样。

所有演员都已准备就绪,您需要将所有模块添加到src/actors/mod.rs文件中:

pub mod count;
pub mod log;
pub mod resize;

现在,我们可以实现一个服务器,该服务器使用演员为每个请求执行调整大小和其他任务。

使用带有演员的服务器

导入服务器所需的全部必要类型。值得注意的是,只有那些你不熟悉的部分:

use actix::{Actor, Addr};
use actix::sync::SyncArbiter;

Addr是演员的地址。SyncArbiter是一个同步事件循环控制器,它以同步方式处理每条消息。我们需要它来处理调整大小的演员。还要添加actors模块并导入我们在子模块中声明的所有类型:

mod actors;

use self::actors::{
    count::{Count, CountActor},
    log::{Log, LogActor},
    resize::{Resize, ResizeActor},
};

我们需要一个共享状态来保存我们将用于处理请求的所有演员的地址:

#[derive(Clone)]
struct State {
    resize: Addr<ResizeActor>,
    count: Addr<CountActor>,
    log: Addr<LogActor>,
}

Addr类型是可克隆的,我们可以为我们的State结构体推导出Clone特质,因为我们必须为hyper的每个服务函数进行克隆。让我们实现一个带有新共享Statemain函数:

fn main() {
    actix::run(|| {
        let resize = SyncArbiter::start(2, || ResizeActor);
        let count = CountActor::new().start();
        let log = LogActor::new().start();

        let state = State { resize, count, log };

        let addr = ([127, 0, 0, 1], 8080).into();
        let builder = Server::bind(&addr);
        let server = builder.serve(move || {
            let state = state.clone();
            service_fn(move |req| microservice_handler(&state, req))
        });
        server.map_err(drop)
    });
}

首先,我们必须启动事件循环。这是通过调用actix::run方法来完成的。我们传递一个闭包来准备所有演员,并返回一个Future来运行。我们将使用hyperServer类型。

在闭包中,我们使用一个产生ResizeActor实例的函数来启动SyncArbiter。通过第一个参数,我们设置SyncArbiter将用于处理请求的线程数量。start方法返回一个仲裁器的地址,该仲裁器将消息路由到调整大小的演员。

要启动其他 actor,我们可以使用Actor特质的start方法,因为actix::run方法为我们创建了一个System实例和一个默认的Arbiter。我们就是这样创建了CountActorLogActor的。Actor特质的start方法还返回 actor 的地址。我们将它们全部放入一个新的State结构体中。

之后,我们创建了一个Server实例,就像之前的例子一样,但还传递了一个克隆的State引用。

请求处理器

在我们实现 HTTP 请求处理器之前,让我们添加一个函数,该函数使用StateCountActor发送消息,并使用返回的值通过LogActor打印它。看看下面的函数:

fn count_up(state: &State, path: &str) -> impl Future<Item=(), Error=Error> {
    let path = path.to_string();
    let log = state.log.clone();
    state.count.send(Count(path.clone()))
        .and_then(move |value| {
            let message = format!("total requests for '{}' is {}", path, value);
            log.send(Log(message))
        })
        .map_err(|err| other(err.compat()))
}

我们将路径转换成了String类型,因为我们需要这种类型来发送Count消息,并将其移动到发送Log消息给LogActorFuture中。此外,我们还需要将Addr克隆到LogActor中,因为当计数器的值可用后,我们还需要在闭包中使用它。现在让我们创建一个Future,依次发送Count消息和Log消息。

Addr结构体有一个send方法,该方法返回一个实现了Future特质的Request实例。Request将在可用时返回一个计数器值。我们使用Futureand_then方法将额外的Future添加到链中。我们需要准备一个消息用于syslog,并使用克隆的Addr将其发送到LogActor

我们还将错误转换为io::Error,但发送方法返回的MaiboxError是一个实现了Fail特质的错误类型,但没有实现标准库中的Error特质,因此我们必须使用compat方法将错误转换为failurecrate 中实现的Compat类型。

我们将在两个路径//resize上使用count_up方法,看看microservice_handler的实现:

fn microservice_handler(state: &State, req: Request<Body>)
    -> Box<Future<Item=Response<Body>, Error=Error> + Send>
{
    match (req.method(), req.uri().path().to_owned().as_ref()) {
        (&Method::GET, "/") => {
            let fut = count_up(state, "/").map(|_| Response::new(INDEX.into()));
            Box::new(fut)
        },
        (&Method::POST, "/resize") => {
            let (width, height) = {
                let uri = req.uri().query().unwrap_or("");
                let query = queryst::parse(uri).unwrap_or(Value::Null);
                let w = to_number(&query["width"], 180);
                let h = to_number(&query["height"], 180);
                (w, h)
            };
            // Add an implementation here
            Box::new(fut)
        },
        _ => {
            response_with_code(StatusCode::NOT_FOUND)
        },
    }
}

在某些部分保持不变,但现在它将State的引用作为第一个参数。由于这个处理函数必须返回一个Future实现,我们可以使用count_up函数调用的返回值,但将值替换为Response。我们已经在根路径上做了这件事。让我们使用ResizeActorAddr添加调整大小的功能。

要将图像缓冲区发送给 actor,我们必须使用collect2方法从RequestBody中收集它,就像我们之前做的那样:

let resize = state.resize.clone();
let body = req.into_body()
    .map_err(other)
    .concat2()
    .map(|chunk| {
        chunk.to_vec()
    })
    .and_then(move |buffer| {
        let msg = Resize {
            buffer,
            width,
            height,
        };
        resize.send(msg)
            .map_err(|err| other(err.compat()))
            .and_then(|x| x.map_err(other))
    })
    .map(|resp| {
        Response::new(resp.into())
    });
let fut = count_up(state, "/resize").and_then(move |_| body);

之后,我们创建了Resize消息,并使用该 actor 克隆的Addr将其发送给ResizeActor。我们将所有错误转换为io::Error。但是等等,我们还没有添加请求计数和日志记录。在创建用于调整图像大小的Future之前,使用and_then方法调用count_up函数,并将其放在前面。

就这些了!现在每个请求都会发送到CountActor,然后向LogActor发送一个信息消息,调整大小的请求也会连接所有数据并发送到ResizeActor进行调整。是时候测试它了。

构建和运行

使用 cargo run 子命令来构建和运行代码。当服务器启动时,使用 curl 命令发送带有图像的 POST 请求。你可以找到此前置命令的参数示例。

例如,我用浏览器请求了根路径五次,并发送了一次调整大小的请求。它将调整大小的消息存储到了 files 文件夹中。是的,它工作了!现在我们可以检查日志演员是否向 syslog 添加记录。使用以下命令来打印日志:

journalctl

你可以找到以下记录:

Jan 11 19:48:53 localhost.localdomain rust-microservice[21466]: total requests for '/' = 1
Jan 11 19:48:55 localhost.localdomain rust-microservice[21466]: total requests for '/' = 2
Jan 11 19:48:55 localhost.localdomain rust-microservice[21466]: total requests for '/' = 3
Jan 11 19:48:56 localhost.localdomain rust-microservice[21466]: total requests for '/' = 4
Jan 11 19:48:56 localhost.localdomain rust-microservice[21466]: total requests for '/' = 5
Jan 11 19:49:16 localhost.localdomain rust-microservice[21466]: total requests for '/resize' = 1

如你所见,我们对根路径有五个请求,对 /resize 路径有一个请求。

如果你没有 jounrnalctl 命令,你可以尝试使用 less /var/log/syslog 命令来打印日志。

此示例使用了演员来运行并发活动。实际上,只有 ResizeActor 使用了与 SyncArbiter 一起的单独线程。CountActorLogActor 使用与 hyper 服务器相同的线程。但这没关系,因为演员本身不会占用很多 CPU。

摘要

在本章中,我们探讨了如何在微服务中使用线程池。我们研究了三种方法:使用普通线程、使用 futures-cpupool 库和使用 tokio-threadpool 库。我们使用来自 futures 库的通道与异步代码中的线程进行交互。特殊的库会自动完成所有交互;你所需要做的只是调用一个将在单独线程中执行的功能。

此外,我们熟悉了 actix 库和演员模型,这有助于将任务分割成由智能运行时管理的独立单元来运行。

在下一章中,我们将学习如何使用 Rust 与不同的数据库交互,包括 PostgreSQL、MySQL、Redis、MongoDB 和 DynamoDB。

第十一章:使用 Actix Crate 和演员进行并发

本章将展示一种基于演员模型(如 Erlang 或 Akka)创建微服务的替代方法。这种方法允许您通过将微服务拆分为小型独立任务,并通过消息传递相互交互来编写清晰和有效的代码。

到本章结束时,您将能够做到以下事情:

  • 使用 Actix 框架和actix-web包创建微服务

  • 为 Actix Web 框架创建中间件

技术要求

要实现和运行本章的所有示例,您至少需要一个版本为 1.31 的 Rust 编译器。

您可以在 GitHub 上找到本章代码示例的来源:github.com/PacktPublishing/Hands-On-Microservices-with-Rust/tree/master/Chapter11

微服务中的演员并发

如果您熟悉 Erlang 或 Akka,您可能已经知道演员是什么以及如何使用它们。但无论如何,我们将在本节中回顾演员模型的知识。

理解演员

我们已经在第十章中熟悉了演员,微服务中的后台任务和线程池,但让我们谈谈使用演员进行微服务。

演员是进行并发计算的模式。我们应该了解以下模型:

  • 线程:在这个模型中,每个任务都在单独的线程中工作

  • 纤程或绿色线程:在这个模型中,每个任务都有由特殊运行时安排的工作

  • 异步代码:在这个模型中,每个任务都由一个反应器(实际上,这类似于纤程)运行

演员将这些方法结合成一个优雅的解决方案。要完成任何工作的一部分,您可以实现执行自己部分工作的演员,并通过消息与其他演员交互,以通知彼此整体进度。每个演员都有一个用于接收消息的邮箱,并且可以使用此地址向其他演员发送消息。

微服务中的演员

要使用演员开发微服务,您应该将您的服务拆分为解决不同类型工作的任务。例如,您可以为每个传入的连接或数据库交互使用单独的演员,甚至作为管理员来控制其他演员。每个演员都是一个在反应器中执行的非同步任务。

这种方法的优点如下:

  • 将单独的演员编写比编写大量函数要简单

  • 演员可能会失败并重新启动

  • 您可以重用演员

使用演员的一个重要好处是可靠性,因为每个演员都可以失败并重新启动,因此您不需要编写冗长的恢复代码来处理故障。这并不意味着您的代码可以在任何地方调用panic!宏,但这确实意味着您可以将演员视为短生命周期任务,它们在小型任务上并发工作。

如果你设计演员得当,你也会获得很好的性能,因为与消息的交互可以帮助你将工作分成短反应,这不会长时间阻塞反应器。此外,你的源代码结构也会更加清晰。

Actix 框架

Actix 框架为 Rust 提供了一个基于 futures crate 和一些异步代码的演员模型,允许演员以最小资源需求并发工作。

我认为这是用 Rust 创建网络应用和微服务的最佳工具之一。该框架包括两个优秀的 crate——包含核心结构的 actix crate 和实现 HTTP 和 WebSocket 协议的 actix-web crate。让我们创建一个将请求路由到其他微服务的微服务。

使用 actix-web 创建微服务

在本节中,我们将创建一个微服务,它看起来与我们之前在 第九章 中创建的其他微服务类似,即 使用框架进行简单的 REST 定义和请求路由,但内部使用演员模型来实现资源充分利用。

要使用 actix-web 创建一个微服务,你需要添加 actixactix-web 两个 crate。首先,我们需要启动管理其他演员运行时的 System 演员实例。让我们创建一个 System 实例,并使用必要的路由启动一个 actix-web 服务器。

启动 actix-web 服务器

启动一个 actix-web 服务器实例看起来与其他 Rust 网络框架类似,但需要 System 演员实例。我们不需要直接使用 System,但需要在一切准备就绪时通过调用 run 方法来运行它。这个调用启动了 System 演员并阻塞了当前线程。内部,它使用了我们在前几章讨论过的 block_on 方法。

启动服务器

考虑以下代码:

fn main() {
    env_logger::init();
    let sys = actix::System::new("router");
    server::new(|| {
      // Insert `App` declaration here
    }).workers(1)
        .bind("127.0.0.1:8080")
        .unwrap()
        .start();
    let _ = sys.run();
}

我们通过调用 server::new 方法创建一个新的服务器,该方法期望一个闭包来返回 App 实例。在我们创建 App 实例之前,我们必须完成服务器并运行它。workers 方法设置运行演员的线程数。

你可以选择不显式设置此值,默认情况下,它将等于系统上的 CPU 数量。在许多情况下,这是性能的最佳可能值。

下一个 bind 方法的调用将服务器的套接字绑定到一个地址。如果无法绑定到地址,该方法返回 Err,如果我们不能在期望的端口上启动服务器,我们将 unwrap 结果以停止服务器。最后,我们调用 start 方法来启动 Server 演员。它返回一个包含地址的 Addr 结构体,你可以使用该地址向 Server 演员实例发送消息。

实际上,Server 演员不会运行,直到我们调用 System 实例的 run 方法。添加这个方法调用,然后我们将详细查看创建 App 实例。

创建应用

将以下代码插入到 server::new 函数调用的闭包中:

let app = App::with_state(State::default())
    .middleware(middleware::Logger::default())
    .middleware(IdentityService::new(
            CookieIdentityPolicy::new(&[0; 32])
            .name("auth-example")
            .secure(false),
            ))
    .middleware(Counter);

App 结构体包含有关状态、中间件和路由作用域的信息。为了将共享状态设置到我们的应用程序中,我们使用 with_state 方法来构建 App 实例。我们创建 State 结构体的默认实例,其声明如下:

#[derive(Default)]
struct State(RefCell<i64>);

State 包含一个带有 i64 值的 cell,用于计算所有请求。默认情况下,它使用 0 值创建。

在此之后,我们使用 App 的中间件方法来设置以下三个中间件:

  • actix_web::middleware::Logger 是一个使用 log crate 记录请求和响应的日志器

  • actix_web::middleware::identity::IdentityService 通过实现 IdentityPolicy 特性的身份后端来帮助识别请求

  • Counter 是一个中间件,我们将在接下来的 中间件 部分中创建它,并使用 State 来计算请求的总数量

对于我们的 IdentityPolicy 后端,我们使用与 IdentityService 同一身份子模块中的 CookieIdentityPolicyCookieIdentityPolicy 期望一个至少有 32 字节的密钥。当创建了一个身份策略的 cookie 实例后,我们可以使用 pathnamedomain 等方法来设置特定的 cookie 参数。我们还通过使用 secure 方法并设置 false 值来允许通过不安全的连接发送 cookie。

你应该了解 cookie 的两个特殊参数:SecureHttpOnly。第一个要求使用安全的 HTTPS 连接来发送 cookie。如果你运行一个用于测试的服务并使用纯 HTTP 连接到它,那么 CookieIdentityPolicy 将不会工作。HttpOnly 参数不允许从 JavaScript 使用 cookie。CookieIdentityPolicy 将此参数设置为 true,并且你不能覆盖这种行为。

作用域和路由

我们必须添加到我们的 App 实例中的下一件事是路由。有一个 route 函数可以让你为任何路由设置处理器。但使用作用域来构建嵌套路径的结构会更周到。看看下面的代码:

app.scope("/api", |scope| {
    scope
        .route("/signup", http::Method::POST, signup)
        .route("/signin", http::Method::POST, signin)
        .route("/new_comment", http::Method::POST, new_comment)
        .route("/comments", http::Method::GET, comments)
})

我们 App 结构体的 scope 方法期望一个路径的前缀和一个以 scope 作为单一参数的闭包,并创建一个可以包含子路由的作用域。我们为 /api 路径前缀创建一个 scope,并使用 route 方法添加了四个路由:/signup/signin/new_comment/commentsroute 方法期望一个包括路径、方法和处理器的后缀。例如,如果服务器现在接收一个使用 POST 方法的 /api/singup 请求,它将调用 signup 函数。让我们为其他路径添加一个默认处理器。

我们的微服务还使用 Counter 中间件,我们将在稍后实现它,来计算请求的总数量。我们需要添加一个路由来渲染微服务的统计信息,如下所示:

.route("/counter", http::Method::GET, counter)

如您所见,我们这里不需要 scope,因为我们只有一个处理器,可以直接为 App 实例(而不是 scope)调用 route 方法。

静态文件处理器

对于之前 scope 中未列出的其他路径,我们将使用一个 handler,它将从文件夹中返回文件的 内容以提供静态资源。handler 方法期望一个路径的前缀,以及一个实现了 Handler 特质的类型。在我们的情况下,我们将使用一个现成的静态文件处理程序,actix_web::fs::StaticFiles。它需要一个指向本地文件夹的路径,我们还可以通过调用 index_file 方法设置索引文件:

app.handler(
    "/",
    fs::StaticFiles::new("./static/").unwrap().index_file("index.html")
)

现在,如果客户端向路径例如 /index.html/css/styles.css 发送 GET 请求,那么 StaticFiles 处理程序将发送来自 ./static/ 本地文件夹中相应文件的 内容。

HTTP 客户端

该微服务的处理程序作为代理工作,并将传入的请求重新发送到其他微服务,这些微服务将不会直接对用户可用。要向其他微服务发送请求,我们需要一个 HTTP 客户端。actix_web 包含一个。要使用客户端,我们需要添加两个函数:一个用于代理 GET 请求,另一个用于发送 POST 请求。

GET 请求

要发送 GET 请求,我们创建一个 get_request 函数,它期望一个 url 参数并返回一个包含二进制数据的 Future 实例:

fn get_request(url: &str) -> impl Future<Item = Vec<u8>, Error = Error> {
    client::ClientRequest::get(url)
        .finish().into_future()
        .and_then(|req| {
            req.send()
                .map_err(Error::from)
                .and_then(|resp| resp.body().from_err())
                .map(|bytes| bytes.to_vec())
        })
}

我们使用 ClientRequestBuilder 来创建 ClientRequest 实例。ClientRequest 结构体已经具有创建具有预设 HTTP 方法的构建器的快捷方式。我们调用 get 方法,该方法仅将 Method::GET 值设置到作为 ClientRequestBuilder 实例调用方法的请求中。您还可以使用构建器设置额外的头或 cookie。当您完成这些值后,您必须通过调用以下方法之一从构建器创建 ClientRequest 实例:

  • body 将体值设置为可以转换为 Into<Body> 的二进制数据

  • json 将体值设置为一个可以序列化为 JSON 值的任何类型

  • form 将体值设置为一个可以使用 serde_urlencoded: serializer 序列化的类型

  • streamingStream 实例中消耗体值

  • finish 创建一个不带体值的请求

我们使用 finish,因为 GET 请求不包含体值。所有这些方法都返回一个包含 ClientRequest 实例的成功值的 Result。我们不展开 Result,而是通过调用 into_future 方法将其转换为 Future 值,以便在处理程序甚至无法构建请求时向客户端返回一个错误值。

由于我们有一个 Future 值,我们可以使用 and_then 方法添加下一个处理步骤。我们调用 ClientRequestsend 方法来创建一个 SendRequest 实例,该实例实现了 Future 特质并向服务器发送请求。由于 send 调用可以返回 SendRequestError 错误类型,我们将其包装在 failure::Error 中。

如果请求成功发送,我们可以使用 body 方法调用获取 MessageBody 值。此方法是 HttpMessage 特质的一部分。MessageBody 还实现了具有 Bytes 值的 Future 特质,我们使用 and_then 方法扩展未来链并从 SendRequest 转换值。

最后,我们使用 Bytesto_vec 方法将其转换为 Vec<u8> 并将此值作为对客户端的响应。我们已经完成了将 GET 请求代理到另一个微服务的函数。让我们为 POST 请求创建一个类似的方法。

POST 请求

对于 POST 请求,我们需要输入参数,这些参数将被序列化到请求体中,以及输出参数,这些参数将从请求的响应体中反序列化。看看下面的函数:

fn post_request<T, O>(url: &str, params: T) -> impl Future<Item = O, Error = Error>
where
    T: Serialize,
    O: for <'de> Deserialize<'de> + 'static,
{
    client::ClientRequest::post(url)
        .form(params).into_future().and_then(|req| {
            req.send()
                .map_err(Error::from).and_then(|resp| {
                    if resp.status().is_success() {
                        let fut = resp.json::<O>().from_err();
                        boxed(fut)
                    } else {
                        error!("Microservice error: {}", resp.status());
                        let fut = Err(format_err!("microservice error"))
                            .into_future().from_err();
                        boxed(fut)
                    }
                })
        })
}

post_request 函数使用 ClientRequestpost 方法创建 ClientRequestBuilder,并用 params 变量的值填充一个表单。我们将 Result 转换为 Future 并向服务器发送一个请求。同样,在 GET 版本中,我们处理响应,但采用不同的方式。我们通过 HttpResponsestatus 方法调用获取响应的状态,并使用 is_success 方法调用检查它是否成功。

对于成功的响应,我们使用 HttpResponsejson 方法获取一个收集体并从 JSON 中反序列化的 Future。如果响应不成功,我们向客户端返回一个错误。现在,我们有了向其他微服务发送请求的方法,并且可以为每个路由实现处理器。

处理器

我们添加了代理传入请求并将它们重新发送到其他微服务的方法。现在,我们可以为我们的微服务支持的每个路径实现处理器。我们将为客户端提供一个整体 API,但实际上,我们将使用一组微服务向客户端提供所有必要的服务。让我们从实现 /signup 路径的处理器开始。

注册

路由微服务使用 /signup 路由将注册请求重新发送到绑定在 127.0.0.1:8001 地址的用户微服务。此请求使用 UserForm 填充并带有 Form 类型参数创建一个新的用户。看看下面的代码:

fn signup(params: Form<UserForm>) -> FutureResponse<HttpResponse> {
    let fut = post_request("http://127.0.0.1:8001/signup", params.into_inner())
        .map(|_: ()| {
            HttpResponse::Found()
            .header(header::LOCATION, "/login.html")
            .finish()
        });
    Box::new(fut)
}

我们调用之前声明的 post_request 函数,向用户微服务发送一个 POST 请求,如果它返回一个成功的响应,我们返回一个带有 302 状态码的响应。我们通过 HttpResponse::Found 函数调用创建一个带有相应状态码的 HttpResponseBuilder。之后,我们还通过 HttpResponseBuilderheader 方法调用设置 LOCATION 标头,将用户重定向到登录表单。最后,我们调用 finish() 从构建器创建一个 HttpResponse 并将其作为 boxed Future 对象返回。

函数具有 FutureResponse 返回类型,其实现如下:

type FutureResponse<I, E = Error> = Box<dyn Future<Item = I, Error = E>>;

如您所见,它是一个实现了 Future 特质的类型的 Box

此外,函数还期望Form<UserForm>作为参数。UserForm结构体声明如下:

#[derive(Deserialize, Serialize)]
pub struct UserForm {
    email: String,
    password: String,
}

如您所见,它期望两个参数:emailpassword。两者都将从请求的查询字符串中提取,格式为email=user@example.com&password=<secret>Form包装器有助于从响应体中提取数据。

actix_web 包通过大小限制请求和响应。如果您想发送或接收大量负载,您必须覆盖默认设置,这些设置通常不允许请求超过 256 KB。例如,如果您想增加限制,您可以使用Routewith_config方法调用提供的FormConfig结构体,并使用所需字节数调用配置的limit方法。HTTP 客户端也受到响应大小的限制。例如,如果您尝试从JsonBody实例中读取大型 JSON 对象,您可能需要在将其用作Future对象之前使用limit方法调用对其进行限制。

登录

其他方法允许用户使用提供的凭据登录到微服务。看看以下发送到/signin路径的signin函数,它处理发送的请求:

fn signin((req, params): (HttpRequest<State>, Form<UserForm>))
    -> FutureResponse<HttpResponse>
{
    let fut = post_request("http://127.0.0.1:8001/signin", params.into_inner())
        .map(move |id: UserId| {
            req.remember(id.id);
            HttpResponse::build_from(&req)
            .status(StatusCode::FOUND)
            .header(header::LOCATION, "/comments.html")
            .finish()
        });
    Box::new(fut)
}

函数有两个参数:HttpRequestForm。第一个我们需要获取对共享State对象的访问权限。第二个我们需要从请求体中提取UserForm结构体。我们也可以在这里使用post_request函数,但期望它在其响应中返回一个UserId值。UserId结构体声明如下:

#[derive(Deserialize)]
pub struct UserId {
    id: String,
}

由于HttpRequest实现了RequestIdentity特质,并且我们将IdentityService连接到了App,我们可以使用用户的 ID 调用remember方法,将当前会话与用户关联。

然后,我们创建一个带有302状态码的响应,就像我们在前面的处理器中所做的那样,并将用户重定向到/comments.html页面。但我们必须从HttpRequest构建一个HttpResponse实例,以保持remember函数调用时的更改。

新评论

创建新评论的处理程序使用用户的身份来检查是否有凭据添加新评论:

fn new_comment((req, params): (HttpRequest<State>, Form<AddComment>))
    -> FutureResponse<HttpResponse>
{
    let fut = req.identity()
        .ok_or(format_err!("not authorized").into())
        .into_future()
        .and_then(move |uid| {
            let params = NewComment {
                uid,
                text: params.into_inner().text,
            };
            post_request::<_, ()>("http://127.0.0.1:8003/new_comment", params)
        })
        .then(move |_| {
            let res = HttpResponse::build_from(&req)
                .status(StatusCode::FOUND)
                .header(header::LOCATION, "/comments.html")
                .finish();
            Ok(res)
        });
    Box::new(fut)
}

此处理器允许每个已签名的用户留下评论。让我们看看这个处理器是如何工作的。

首先,它调用RequestIdentity特质的identity方法,该方法返回用户的 ID。我们将它转换为Result,以便将其转换为Future,并在用户未识别时返回错误。

我们使用返回的用户 ID 值来准备对评论微服务的请求。我们从AddComment表单中提取text字段,并使用用户的 ID 和评论创建一个NewComment结构体。结构体声明如下:

#[derive(Deserialize)]
pub struct AddComment {
    pub text: String,
}

#[derive(Serialize)]
pub struct NewComment {
    pub uid: String,
    pub text: String,
}

我们也可以使用一个带有可选uid的单个结构体,但为了安全起见,最好为不同的需求使用单独的结构体,因为如果我们使用相同的结构体并且在没有验证的情况下将其重新发送到另一个微服务,我们可能会创建一个漏洞,允许任何用户以另一个用户的身份添加评论。通过使用精确、严格的类型而不是通用、灵活的类型来尽量避免这类错误。

最后,我们像之前一样创建一个重定向客户端,并将用户发送到/comments.html页面。

评论

要查看之前处理程序创建的所有评论,我们必须向评论微服务发送一个GET请求,使用我们之前创建的get_request函数,并将响应数据重新发送给客户端:

fn comments(_req: HttpRequest<State>) -> FutureResponse<HttpResponse> {
    let fut = get_request("http://127.0.0.1:8003/list")
        .map(|data| {
            HttpResponse::Ok().body(data)
        });
    Box::new(fut)
}

计数器

打印请求总量的处理程序也有相当简单的实现,但在这个案例中,我们能够访问共享状态:

fn counter(req: HttpRequest<State>) -> String {
    format!("{}", req.state().0.borrow())
}

我们使用HttpRequeststate方法来获取对State实例的引用。由于计数器值存储在RefCell中,我们使用borrow方法从单元格中获取值。我们实现了所有处理程序,现在我们必须添加一些中间件来计算对微服务的每个请求。

中间件

actix-web包支持可以附加到App实例上的中间件,以处理每个请求和响应。中间件有助于记录请求、转换它们,甚至可以使用正则表达式控制对一组路径的访问。将中间件视为所有传入请求和传出响应的处理程序。要创建中间件,我们首先必须为其实现Middleware特质。看看以下代码:

pub struct Counter;

impl Middleware<State> for Counter {
    fn start(&self, req: &HttpRequest<State>) -> Result<Started> {
        let value = *req.state().0.borrow();
        *req.state().0.borrow_mut() = value + 1;
        Ok(Started::Done)
    }

    fn response(&self, _req: &HttpRequest<State>, resp: HttpResponse) -> Result<Response> {
        Ok(Response::Done(resp))
    }

    fn finish(&self, _req: &HttpRequest<State>, _resp: &HttpResponse) -> Finished {
        Finished::Done
    }
}

我们声明一个空的Counter结构体,并为其实现Middleware特质。

Middleware特质有一个带有状态的类型参数。由于我们想要使用State结构体的计数器,我们将其设置为类型参数,但如果你想要创建与不同状态兼容的中间件,你需要添加类型参数到你的实现中,并添加必要的特质的实现,以便你可以将其导出到你的模块或包中。

Middleware特质包含三个方法。我们实现了所有这些方法:

  • 当请求准备就绪并将发送到处理程序时调用start

  • 在处理程序返回响应后调用response

  • 当数据已发送到客户端时调用finish

我们为responsefinish方法使用默认实现。

对于第一种方法,我们在Response::Done包装器中返回没有任何更改的响应。如果想要返回生成HttpResponseFutureResponse还有一个Future变体。

对于第二种方法,我们返回Finished枚举的Done变体。它还有一个可以包含 boxed Future对象的Future变体,该对象将在finish方法结束后运行。让我们来探讨一下在我们的实现中start方法是如何工作的。

Counter中间件的start方法实现中,我们将计算所有传入的请求。为此,我们从RefCell获取当前计数器的值,加1,并用新值替换单元格。最后,该方法返回一个Started::Done值,通知您当前请求将在处理链的下一个处理器/中间件中重用。Started枚举还有其他变体:

  • 如果你想立即返回响应,则应使用Response

  • 如果你想运行一个将返回响应的Future,则应使用Future

现在,微服务已经准备好构建和运行。

构建和运行

要运行微服务,请使用cargo run命令。由于我们没有其他微服务用于处理器,我们可以使用counter方法来检查服务器和Counter中间件是否正常工作。尝试在浏览器中打开http://127.0.0.1:8080/stats/counter。它将在空白页面上显示一个1值。如果你刷新页面,你会看到一个3值。那是因为浏览器在主请求之后也会发送一个请求来获取favicon.ico文件。

使用数据库

actix-webactix crate 结合的另一个好功能是使用数据库的能力。你还记得我们如何使用SyncArbyter来执行后台任务吗?这是一个实现数据库交互的好方法,因为异步数据库连接器不足,我们必须使用同步的。让我们为我们的上一个示例添加将响应缓存到 Redis 数据库的功能。

数据库交互 actor

我们首先实现一个与数据库交互的 actor。复制上一个示例并添加redis crate 到依赖项中:

redis = "0.9"

我们使用 Redis,因为它非常适合缓存,但我们也可以在内存中存储缓存的值。

创建一个src/cache.rs模块并添加以下依赖项:

use actix::prelude::*;
use failure::Error;
use futures::Future;
use redis::{Commands, Client, RedisError};

它添加了来自redis crate 的类型,我们已经在第七章与数据库的可靠集成中使用过,用于与 Redis 存储交互。

Actors

我们的 actor 必须保持一个Client实例。我们不使用连接池,因为我们将为处理数据库的并行请求使用多个 actor。看看下面的结构:

pub struct CacheActor {
    client: Client,
    expiration: usize,
}

结构中还包含一个expiration字段,该字段持有生存时间TTL)周期。这定义了 Redis 将保持值的时间。

在实现中添加一个new方法,该方法使用提供的地址字符串创建一个Client实例,并将clientexpiration值添加到CacheActor结构中,如下所示:

impl CacheActor {
    pub fn new(addr: &str, expiration: usize) -> Self {
        let client = Client::open(addr).unwrap();
        Self { client, expiration }
    }
}

此外,我们还需要为SyncContext实现一个Actor trait,就像我们在第十章背景任务和线程池在微服务中中调整工作者时做的那样:

impl Actor for CacheActor {
    type Context = SyncContext<Self>;
}

现在,我们可以添加支持设置和获取缓存值的消息。

Messages

要与CacheActor交互,我们必须添加两种类型的消息:设置值和获取值。

设置值消息

我们添加的第一个消息类型是SetValue,它提供了一对用于缓存的键和新的值。结构体有两个字段——path用作键,content持有值:

struct SetValue {
    pub path: String,
    pub content: Vec<u8>,
}

让我们为SetValue结构体实现一个Message特质,如果设置了值,则使用空单元类型,如果数据库连接有问题,则返回RedisError

impl Message for SetValue {
    type Result = Result<(), RedisError>;
}

CacheActor支持接收SetValue消息。让我们使用Handler特质来实现这一点:

impl Handler<SetValue> for CacheActor {
    type Result = Result<(), RedisError>;

    fn handle(&mut self, msg: SetValue, _: &mut Self::Context) -> Self::Result {
        self.client.set_ex(msg.path, msg.content, self.expiration)
    }
}

我们使用存储在CacheActor中的Client实例通过set_ex方法调用执行 Redis 的SETEX命令。这个命令以秒为单位设置一个带有过期时间的值。如您所见,实现接近第七章可靠与数据库集成的数据库交互函数,但作为特定消息的Handler实现。这种代码结构更简单、更直观。

获取值消息

GetValue结构体代表一个通过键(或路径,在我们的情况下)从 Redis 提取值的消息。它只包含一个带有path值的字段:

struct GetValue {
    pub path: String,
}

我们还必须实现Message特质,但我们希望它返回一个可选的Vec<u8>值,如果 Redis 包含提供的键的值:

impl Message for GetValue {
    type Result = Result<Option<Vec<u8>>, RedisError>;
}

CacheActor还实现了GetValue消息类型的Handler特质,并调用Clientget方法通过 Redis 存储的GET命令提取存储中的值:

impl Handler<GetValue> for CacheActor {
    type Result = Result<Option<Vec<u8>>, RedisError>;

    fn handle(&mut self, msg: GetValue, _: &mut Self::Context) -> Self::Result {
        self.client.get(&msg.path)
    }
}

如您所见,演员和消息足够简单,但我们必须使用Addr值来与他们交互。这不是一个简洁的方法。我们将添加一个特殊类型,允许方法与CacheActor实例交互。

链接到演员

下面的结构体包装了CacheActor的地址:

#[derive(Clone)]
pub struct CacheLink {
    addr: Addr<CacheActor>,
}

构造函数只填充这个addr字段以一个Addr值:

impl CacheLink {
    pub fn new(addr: Addr<CacheActor>) -> Self {
        Self { addr }
    }
}

我们需要一个CacheLink包装结构体来添加获取缓存功能的方法,但需要隐藏实现细节和消息交互。首先,我们需要一个获取缓存值的方法:

pub fn get_value(&self, path: &str) -> Box<Future<Item = Option<Vec<u8>>, Error = Error>> {
    let msg = GetValue {
        path: path.to_owned(),
    };
    let fut = self.addr.send(msg)
        .from_err::<Error>()
        .and_then(|x| x.map_err(Error::from));
    Box::new(fut)
}

前面的函数创建了一个带有path的新GetValue消息,并将此消息发送到CacheLink中包含的Addr。之后,它等待结果。函数返回这个交互序列作为一个 boxed Future

下一个方法以类似的方式实现——set_value方法通过向CacheActor发送SetValue消息来设置缓存中的新值:

pub fn set_value(&self, path: &str, value: &[u8]) -> Box<Future<Item = (), Error = Error>> {
    let msg = SetValue {
        path: path.to_owned(),
        content: value.to_owned(),
    };
    let fut = self.addr.send(msg)
        .from_err::<Error>()
        .and_then(|x| x.map_err(Error::from));
    Box::new(fut)
}

要组合一个消息,我们使用一个path和一个字节数组引用转换为Vec<u8>值。现在,我们可以在服务器实现中使用CacheActorCacheLink

使用数据库演员

在本章前面的示例中,我们使用了共享的State来提供对存储为i64的计数器的访问,并用RefCell包装。我们重用这个结构体,但添加一个CacheLink字段来使用与CacheActor的连接来获取或设置缓存值。添加此字段:

struct State {
    counter: RefCell<i64>,
    cache: CacheLink,
}

我们之前为State结构体推导了一个Default特质,但现在我们需要一个新的构造函数,因为我们必须提供一个带有缓存演员实际地址的CacheLink实例:

impl State {
    fn new(cache: CacheLink) -> Self {
        Self {
            counter: RefCell::default(),
            cache,
        }
    }
}

在大多数情况下,缓存是这样工作的——它试图从一个缓存中提取一个值;如果它存在且未过期,则将值返回给客户端。如果没有有效的值,我们需要获取一个新的值。在我们获取它之后,我们必须将其存储在缓存中以供将来使用。

在前面的例子中,我们经常使用一个接收来自另一个微服务的ResponseFuture实例。为了简化我们对缓存的用法,让我们向我们的State实现添加cache方法。此方法将任何提供的future包装在一个路径中,并尝试提取缓存的值。如果值不可用,它将获取一个新的值,之后,它接收存储复制的值以缓存,并将值返回给客户端。此方法将提供的Future值包装在另一个Future特质实现中。看看以下实现:

fn cache<F>(&self, path: &str, fut: F)
    -> impl Future<Item = Vec<u8>, Error = Error>
where
    F: Future<Item = Vec<u8>, Error = Error> + 'static,
{
    let link = self.cache.clone();
    let path = path.to_owned();
    link.get_value(&path)
        .from_err::<Error>()
        .and_then(move |opt| {
            if let Some(cached) = opt {
                debug!("Cached value used");
                boxed(future::ok(cached))
            } else {
                let res = fut.and_then(move |data| {
                    link.set_value(&path, &data)
                        .then(move |_| {
                            debug!("Cache updated");
                            future::ok::<_, Error>(data)
                        })
                        .from_err::<Error>()
                });
                boxed(res)
            }
        })
}

实现使用State实例来克隆CacheLink。我们必须使用克隆的link,因为我们必须将其移动到使用它的闭包中,以存储一个新值,如果我们需要获取它的话。

首先,我们调用CacheLinkget_value方法,并获取一个请求从缓存中获取值的Future。由于该方法返回Option,我们将使用and_then方法检查该值是否存在于缓存中,并将该值返回给客户端。如果值已过期或不可用,我们将通过执行提供的Future来获取它,如果返回新值,则使用链接调用set_value方法。

现在,我们可以使用cache方法来缓存前一个例子中comments处理程序返回的评论列表:

fn comments(req: HttpRequest<State>) -> FutureResponse<HttpResponse> {
    let fut = get_request("http://127.0.0.1:8003/list");
    let fut = req.state().cache("/list", fut)
        .map(|data| {
            HttpResponse::Ok().body(data)
        });
    Box::new(fut)
}

首先,我们创建一个Future,使用我们之前实现的get_request方法从另一个微服务获取值。之后,我们使用请求的state方法获取State的引用,通过传递/list路径调用cache方法,然后创建一个Future实例以获取新值。

我们已经实现了我们数据库演员的所有部分。我们仍然需要使用SyncArbiter启动一组缓存演员,并将返回的Addr值包装在CacheLink中:

let addr = SyncArbiter::start(3, || {
    CacheActor::new("redis://127.0.0.1:6379/", 10)
});
let cache = CacheLink::new(addr);
server::new(move || {
    let state = State::new(cache.clone());
    App::with_state(state)
    // remains part App building
})

现在,你可以构建服务器。它将每 10 秒返回/api/list请求的缓存值。

使用演员的另一个好处是 WebSocket。有了这个,我们可以通过作为演员实现的状态机添加有状态交互到我们的微服务中。让我们在下一节中看看这个。

WebSocket

WebSocket 是一种全双工通信协议,通过 HTTP 工作。WebSockets 通常作为主要 HTTP 交互的扩展使用,可用于实时更新或通知。

演员模型非常适合实现 WebSocket 处理器,因为你可以将代码组合和隔离在一个地方:在演员的实现中。actix-web支持 WebSocket 协议,在本节中,我们将向我们的微服务添加通知功能。也许我们用actix-web实现的所有功能使我们的示例变得有些复杂,但为了演示目的,保持所有功能以展示如何将服务器与多个演员和任务结合在一起是非常重要的。

重复演员

我们必须向所有连接的客户端发送关于新评论的通知。为此,我们必须保留所有连接客户端的列表以向他们发送通知。我们可以在每次连接时更新State实例以将每个新客户端添加到其中,但我们将创建一个更优雅的解决方案,使用一个路由器将消息重新发送给多个订阅者。在这种情况下,订阅者或监听器将是处理传入 WebSocket 连接的演员。

演员

我们将添加一个演员来重新发送消息给其他演员。我们需要从actixcrate 中获取一些基本类型,以及一个HashSet来保存演员的地址。导入NewComment结构体,我们将克隆并重新发送:

use actix::{Actor, Context, Handler, Message, Recipient};
use std::collections::HashSet;
use super::NewComment;

添加一个包含Recipient实例的HashSet类型的listeners字段的RepeaterActor结构体:

pub struct RepeaterActor {
    listeners: HashSet<Recipient<RepeaterUpdate>>,
}

你熟悉Addr类型,但我们之前还没有使用过Recipient。实际上,你可以使用recipient方法调用将任何Addr实例转换为RecipientRecipient类型是一个只支持一种Message类型的地址。

添加一个构造函数来创建一个空的HashSet

impl RepeaterActor {
    pub fn new() -> Self {
        Self {
            listeners: HashSet::new(),
        }
    }
}

接下来,为这个结构体实现Actor特质:

impl Actor for RepeaterActor {
    type Context = Context<Self>;
}

只需要一个标准的Context类型作为Actor的关联上下文类型就足够了,因为它可以异步工作。

现在,我们必须向这个演员类型添加消息。

消息

我们将支持两种类型的消息。第一种是更新消息,它将一个新评论从一个演员传输到另一个演员。第二种是控制消息,它向演员添加或删除监听器。

更新消息

我们将从更新消息开始。添加一个包装NewComment类型的RepeaterUpdate结构体:

#[derive(Clone)]
pub struct RepeaterUpdate(pub NewComment);

如你所见,我们还推导了Clone特质,因为我们需要克隆这条消息以将其重新发送给多个订阅者。NewComment现在也必须是可克隆的。

让我们为RepeaterUpdate结构体实现Message特质。我们将为Result关联类型使用一个空类型,因为我们不关心这些消息的投递:

impl Message for RepeaterUpdate {
    type Result = ();
}

现在,我们可以为RepeaterUpdate消息类型实现一个Handler

impl Handler<RepeaterUpdate> for RepeaterActor {
    type Result = ();

    fn handle(&mut self, msg: RepeaterUpdate, _: &mut Self::Context) -> Self::Result {
        for listener in &self.listeners {
            listener.do_send(msg.clone()).ok();
        }
    }
}

处理器的算法很简单:它遍历所有监听器(实际上,存储为Recipient实例的监听器地址)并向它们发送克隆的消息。换句话说,这个演员接收一条消息,然后立即将其发送给所有已知的监听器。

控制消息

以下消息类型是订阅或取消订阅RepeaterUpdate消息所必需的。添加以下枚举:

pub enum RepeaterControl {
    Subscribe(Recipient<RepeaterUpdate>),
    Unsubscribe(Recipient<RepeaterUpdate>),
}

它有两个变体,内部具有相同的Recipient<RepeaterUpdate>类型。actor 将发送它们自己的Recipient地址以开始监听更新或停止关于新评论的任何通知。

RepeaterControl结构实现Message特质,将其转换为message类型并使用一个空的Result关联类型:

impl Message for RepeaterControl {
    type Result = ();
}

现在,我们可以为RepeaterControl消息实现一个Handler特质:

impl Handler<RepeaterControl> for RepeaterActor {
    type Result = ();

    fn handle(&mut self, msg: RepeaterControl, _: &mut Self::Context) -> Self::Result {
        match msg {
            RepeaterControl::Subscribe(listener) => {
                self.listeners.insert(listener);
            }
            RepeaterControl::Unsubscribe(listener) => {
                self.listeners.remove(&listener);
            }
        }
    }
}

前一个处理器的实现也很简单:它在Subscribe消息变体上添加一个新的Recipient到监听器集合中,并在Unsubscribe消息中移除Recipient

重发NewComment值的 actor 已经准备好了,现在我们可以开始实现一个处理 WebSocket 连接的 actor。

通知 actor

通知 actor 实际上是 WebSocket 连接的处理程序,但它只执行一个功能——将NewComment值序列化为 JSON 并发送给客户端。

由于我们需要一个 JSON 序列化器,将serde_jsoncrate 添加到依赖项中:

serde_json = "1.0"

然后,添加src/notify.rs模块并开始实现 actor。

演员

通知 actor 更复杂,我们需要更多类型来实现它。让我们来看看它们:

use actix::{Actor, ActorContext, AsyncContext, Handler, Recipient, StreamHandler};
use actix_web::ws::{Message, ProtocolError, WebsocketContext};
use crate::repeater::{RepeaterControl, RepeaterUpdate};
use std::time::{Duration, Instant};
use super::State;

首先,我们开始使用actix_webcrate 的ws模块。它包含必要的WebsocketContext,我们将将其用作Actor特质实现中的上下文值。我们还需要MessageProtocolError类型来实现 WebSocket 流处理。我们还导入了ActorContext以停止Context实例的方法来断开与客户端的连接。我们导入了AsyncContext特质以获取上下文的地址并运行在时间间隔上执行的任务。我们还没有使用的一个新类型是StreamHandler。它是实现从Stream发送到Actor的值的处理所必需的。

你可以使用HandlerStreamHandler来处理相同类型的消息。哪个更可取?规则很简单:如果你的 actor 将处理大量消息,最好使用StreamHandler并将消息流连接为一个StreamActoractix运行时会进行检查,如果它调用相同的Handler,你可能会收到警告。

添加我们将用于向客户端发送ping消息的常量:

const PING_INTERVAL: Duration = Duration::from_secs(20);
const PING_TIMEOUT: Duration = Duration::from_secs(60);

常量包含间隔和超时值。

我们将向客户端发送 ping,因为我们必须保持连接活跃,因为服务器通常为 WebSocket 连接设置默认的超时时间。例如,如果没有任何活动,nginx将在 60 秒后关闭连接。如果你使用默认配置的nginx作为 WebSocket 连接的代理,那么你的连接可能会被中断。浏览器不会发送 ping,只会对传入的 ping 发送 pong。服务器负责向通过浏览器连接的客户端发送 ping,以防止因超时而断开连接。

将以下NotifyActor结构体添加到代码中:

pub struct NotifyActor {
     last_ping: Instant,
     repeater: Recipient<RepeaterControl>,
}

此 actor 有一个last_ping字段,用于保存最新 ping 的时间戳。此外,actor 还持有Recipient地址以发送RepeaterControl消息。我们将使用构造函数提供RepeaterActor的地址:

impl NotifyActor {
    pub fn new(repeater: Recipient<RepeaterControl>) -> Self {
        Self {
            last_ping: Instant::now(),
            repeater,
        }
    }
}

现在,我们必须为NotifyActor结构体实现Actor特质:

impl Actor for NotifyActor {
    type Context = WebsocketContext<Self, State>;

    fn started(&mut self, ctx: &mut Self::Context) {
        let msg = RepeaterControl::Subscribe(ctx.address().recipient());
        self.repeater.do_send(msg).ok();
        ctx.run_interval(PING_INTERVAL, |act, ctx| {
            if Instant::now().duration_since(act.last_ping) > PING_TIMEOUT {
                ctx.stop();
                return;
            }
            ctx.ping("ping");
        });
    }

    fn stopped(&mut self, ctx: &mut Self::Context) {
        let msg = RepeaterControl::Unsubscribe(ctx.address().recipient());
        self.repeater.do_send(msg).ok();
    }
}

这是我们第一次需要重写空的startedstopped方法。在started方法实现中,我们将创建一个Subscribe消息并通过Repeater发送它。此外,我们添加一个将在PING_INTERVAL上执行的任务,并使用WebsocketContextping方法发送 ping 消息。如果客户端从未向我们响应,则last_ping字段不会更新。如果间隔大于我们的PING_TIMEOUT值,我们将使用上下文的stop方法中断连接。

stopped方法实现要简单得多:它准备一个与 actor 相同地址的Unsubscribe事件,并将其发送给RepeaterActor

我们已经准备好了 actor 实现,现在我们必须添加消息和流的处理器。

处理器

首先,我们将实现ws::Message消息的StreamHandler实例:

impl StreamHandler<Message, ProtocolError> for NotifyActor {
    fn handle(&mut self, msg: Message, ctx: &mut Self::Context) {
        match msg {
            Message::Ping(msg) => {
                self.last_ping = Instant::now();
                ctx.pong(&msg);
            }
            Message::Pong(_) => {
                self.last_ping = Instant::now();
            }
            Message::Text(_) => { },
            Message::Binary(_) => { },
            Message::Close(_) => {
                ctx.stop();
            }
        }
    }
}

这是使用actix-web实现 WebSocket 协议交互的基本方法。我们将在稍后使用ws::start方法将 WebSocket 消息的Stream附加到此 actor。

Message类型有多个变体,反映了 RFC 6455(官方协议规范)中的 WebSocket 消息类型。我们使用PingPong来更新 actor 结构体的last_ping字段,并使用Close来根据用户的要求停止连接。

我们必须实现的最后一个Handler允许我们接收RepeaterUpdate消息并将NewComment值发送给客户端:

impl Handler<RepeaterUpdate> for NotifyActor {
    type Result = ();

    fn handle(&mut self, msg: RepeaterUpdate, ctx: &mut Self::Context) -> Self::Result {
        let RepeaterUpdate(comment) = msg;
        if let Ok(data) = serde_json::to_string(&comment) {
            ctx.text(data);
        }
    }
}

实现会解构一个RepeaterUpdate消息以获取NewComment值,使用serde_jsoncrate 将其序列化为 JSON,并通过WebsocketContexttext方法将其发送给客户端。

我们已经有了所有必要的 actor,所以让我们将它们与服务器连接起来。

为服务器添加 WebSocket 支持

由于我们将扩展上一节中的示例,我们将重用State结构体,但为稍后在main函数中创建的Repeateractor 添加一个Addr

pub struct State {
     counter: RefCell<i64>,
     cache: CacheLink,
     repeater: Addr<RepeaterActor>,
 }

更新构造函数以填充repeater字段:

fn new(cache: CacheLink, repeater: Addr<RepeaterActor>) -> Self {
     Self {
         counter: RefCell::default(),
         cache,
         repeater,
     }
 }

现在,我们可以创建一个 RepeaterActor,将演员的地址设置为 State,并将其用作我们 App 的状态:

let repeater = RepeaterActor::new().start();

 server::new(move || {
     let state = State::new(cache.clone(), repeater.clone());
     App::with_state(state)
         .resource("/ws", |r| r.method(http::Method::GET).f(ws_connect))
         // other
 })

此外,我们还添加了一个处理 HTTP 请求的处理程序,该处理程序具有 App 的资源方法调用,并将 ws_connect 函数传递给它。让我们看看这个函数的实现:

fn ws_connect(req: &HttpRequest<State>) -> Result<HttpResponse, Error> {
     let repeater = req.state().repeater.clone().recipient();
     ws::start(req, NotifyActor::new(repeater))
 }

这将 RepeaterActor 的地址克隆成一个 Recipient,然后用于创建一个 NotifyActor 实例。要启动该演员实例,你必须使用 ws::start 方法,该方法使用当前的 Request 并为该演员启动 WebsocketContext

剩下的工作是将一个 NewComment 发送到 RepeaterActor,它将重新发送到任何连接客户端的 NotifyActor 实例:

fn new_comment((req, params): (HttpRequest<State>, Form<AddComment>)) -> FutureResponse<HttpResponse> {
     let repeater = req.state().repeater.clone();
     let fut = req.identity()
         .ok_or(format_err!("not authorized").into())
         .into_future()
         .and_then(move |uid| {
             let new_comment = NewComment {
                 uid,
                 text: params.into_inner().text,
             };
             let update = RepeaterUpdate(new_comment.clone());
             repeater
                 .send(update)
                 .then(move |_| Ok(new_comment))
         })
         .and_then(move |params| {
             post_request::<_, ()>("http://127.0.0.1:8003/new_comment", params)
         })
         .then(move |_| {
             let res = HttpResponse::build_from(&req)
                 .status(StatusCode::FOUND)
                 .header(header::LOCATION, "/comments.html")
                 .finish();
             Ok(res)
         });
     Box::new(fut)
 }

我们扩展了当用户添加新评论时被调用的 new_comment 处理程序,并添加了一个额外的步骤来向重复器发送 NewComment 值。在任何情况下,我们都会忽略将此消息发送给演员的结果,并向另一个微服务发送 POST 请求。值得注意的是,即使没有发送到其他微服务,客户端也会收到关于新评论的通知,但你可以通过改变链中相应 Future 的顺序来改进它。

摘要

在本章中,我们介绍了使用 Actix 框架创建微服务。我们发现了如何创建和配置一个 App 实例,该实例描述了所有要使用的路由和中间件。之后,我们实现了所有返回 Future 实例的处理程序。在所有处理程序中,我们也使用 ClientRequest 向另一个微服务发送请求,并使用异步方法通过 futures 将响应返回给客户端。最后,我们探讨了如何为 actix-web 包创建自己的 Middleware

在下一章中,我们将检查可扩展的微服务架构,并探讨如何实现微服务之间的松耦合。我们还将考虑使用消息代理以灵活和可管理的方式在大型应用程序的部分之间交换消息。

第十二章:可扩展微服务架构

本章描述了微服务的可扩展性。在本章中,我们将学习如何创建使用消息与其他微服务进行交互的微服务。你将熟悉 RabbitMQ 消息代理以及如何在 Rust 中使用lapin包来使用它。

在本章中,我们将涵盖与微服务可扩展性相关的以下概念:

  • 可扩展微服务设计

  • 如何避免应用程序中的瓶颈

  • 消息代理是什么?

  • 如何使用 Rust 与 RabbitMQ

技术要求

要构建可扩展的微服务,你需要一个基础设施或资源来并行运行多个微服务。但为了演示目的,我们将使用 Docker,它提供了运行我们应用程序多个实例的能力。我们还需要 Docker 来启动一个 RabbitMQ 实例。

要构建所有示例,你需要 Rust 编译器的 1.31 版本。

你可以从 GitHub 上的项目获取本章示例的所有代码:github.com/PacktPublishing/Hands-On-Microservices-with-Rust/tree/master/Chapter12

可扩展架构

我们避免使用单体架构,如果你以正确的方式开发微服务,它们可以处理每秒更多的请求,但使用微服务并不意味着你可以毫不费力地拥有一个可扩展的应用程序。它使应用程序的任何部分都变得灵活,可以扩展,你必须编写松散耦合的微服务,这些服务可以在多个实例中运行。

基本思想

要使应用程序可扩展,你可以选择两种方法之一。

在第一种情况下,你可以启动整个应用程序的更多副本。你可能认为这是不可能的,但想象一下一个通过广告赚钱并提供将图像转换为 PDF 的服务。这种服务可以通过这种方式进行扩展,如果你有足够的硬件,你可以处理客户需要的任何数量的请求。

第二种方法是将应用程序拆分为处理相同类型任务的独立服务,你可以运行任意数量的服务实例。例如,如果你的应用程序是一个在线商店,你在服务器上处理图像或静态资源时遇到负载问题。这个问题很容易解决,因为你可以使用内容分发网络CDN)或购买额外的服务器来放置必要的静态文件,运行 NGINX,并将此服务器添加到你的域名 DNS 记录中。但对于微服务,你并不总是可以使用这种方法。这需要独创性。但食谱是清晰的。当你添加额外的微服务来处理特定任务或通过缓存或其他技巧加快某些流程时,你必须为你的微服务实现松散耦合。

为了有一个更抽象的服务交互层,你可以使用对您的应用程序一无所知的消息代理,但提供从微服务到另一个微服务发送消息的能力,并返回结果。

消息代理和队列

消息代理将消息从一种应用程序转换为另一种应用程序。消息代理的客户端使用 API 发送序列化为特定格式的消息,并订阅队列以接收所有新消息的通知。存在 AMQP(即 高级消息队列协议)协议,它提供了一个与不同产品兼容的通用 API。

消息代理的主要概念是队列。它是一种抽象,表示用于收集消息直到它们被客户端消费的实体。

为什么消息代理的概念很酷?因为它是最简单的方式来实现服务的松耦合并保持平滑更新的可能性。您可以使用通用的消息格式并编写微服务来读取特定类型的消息。这有助于重新路由所有消息路径。例如,您可以添加特定消息类型的特定处理程序,或者设置平衡规则以加载更强大的服务。

根据您的需求,有很多消息代理可以使用。以下章节中描述了一些流行的产品。

RabbitMQ

RabbitMQ 是最受欢迎的消息代理。这个消息代理支持 AMQP 协议。它速度快且可靠。它还便于创建短生命周期的队列,以实现基于消息的客户端-服务器交互。我们将使用这个消息代理来创建一个可扩展应用的示例。

Kafka

Apache Kafka 最初由 LinkedIn 创建,并捐赠给了 Apache 软件基金会。它使用 Scala 实现,就像一个日志,提交所有信息并提供对其的访问。它与传统的 AMQP 代理不同,因为它维护一个提交日志,有助于实现持久消息存储。

应用程序瓶颈

应用程序的任何部分都可能成为瓶颈。最初,您可能遇到微服务使用的底层基础设施问题,如数据库或消息代理。扩展这些部分是一个复杂的概念,但在这里我们只会触及微服务的瓶颈。

当您创建微服务时,您可能会遇到它可以处理的请求数量问题。这取决于多个因素。您可以使用如演员模型这样的概念来在线程和事件循环之间分配负载。

如果您遇到 CPU 性能问题,您可以创建一个单独的工人来处理 CPU 密集型任务,并通过消息代理安排任务以实现松耦合,因为您可以在任何时间添加更多工人来处理更多请求。

如果你有一些 I/O 密集型任务,你可以使用负载均衡器将负载定向到特定的服务,但你的微服务应该是可替换的,并且不应该保持持久状态,但可以从数据库中加载。这允许你使用 Kubernetes 等产品自动扩展你的应用程序。

你还应该通过逻辑领域将大型任务拆分成小而独立的微服务。例如,为处理账户创建一个单独的微服务,并为在线商店渲染和显示购物车创建另一个微服务。你还可以添加另一个处理支付并与其他微服务通过消息代理传递消息的微服务。

让我们创建一个可以通过运行其某些组件的额外实例来扩展的应用程序。

使用 Rust 和 RabbitMQ 构建可扩展的应用程序

在本节中,我们将编写一个应用程序,该程序可以从图像中解码 QR 码到文本字符串。我们将创建两个服务——一个用于处理传入请求和解码任务,另一个是工作器,它将接收任务并将图像解码为字符串,然后将结果返回到服务器。为了实现服务之间的交互,我们将使用 RabbitMQ。对于服务器和工作器的实现,我们将使用 Actix 框架。在我们开始编码之前,让我们使用 Docker 启动一个 RabbitMQ 实例。

为测试启动消息代理引导信息

要启动 RabbitMQ,我们将使用 DockerHub 上的官方 Docker 镜像,位于此处:hub.docker.com/_/rabbitmq/。我们已经使用 Docker 启动了数据库实例。启动 RabbitMQ 的过程类似:

docker run -it --rm --name test-rabbit -p 5672:5672 rabbitmq:3

我们启动了一个名为test-rabbit的容器,并将端口5672转发到容器的相同端口。RabbitMQ 镜像还公开了端口4369567125672。如果你想使用消息代理的高级功能,你也需要打开这些端口。

如果你想要启动一个 RabbitMQ 实例并从其他容器访问它,你可以为run命令设置--hostname参数,并使用其他容器提供的名称来连接到 RabbitMQ 实例。

当消息代理实例启动时,你可能需要从它那里获取一些统计信息。可以使用 Docker 的exec命令在容器内执行rabbitmqctl命令:

docker exec -it test-rabbit rabbitmqctl

它会打印出可用的命令。可以将其中任何一个添加到命令中,如下所示:

docker exec -it test-rabbit rabbitmqctl trace_on

前面的命令激活了跟踪所有推送到队列的消息,你可以使用以下命令查看:

docker exec -it test-rabbit rabbitmqctl list_exchanges

它会打印以下内容:

Listing exchanges for vhost / ...
amq.headers    headers
amq.direct    direct
amq.topic    topic
amq.rabbitmq.trace    topic
    direct
amq.fanout    fanout
amq.match    headers

现在,我们可以创建一个使用消息代理与工作器交互的微服务。

依赖项

创建一个新的库 crate(稍后我们将添加两个二进制文件)名为rabbit-actix

[package]
name = "rabbit-actix"
version = "0.1.0"
edition = "2018"

如你所见,我们正在使用 2018 版的 Rust。我们需要一大堆 crate:

[dependencies]
actix = "0.7"
actix-web = "0.7"
askama = "0.7"
chrono = "0.4"
env_logger = "0.6"
image = "0.21"
indexmap = "1.0"
failure = "0.1"
futures = "0.1"
log = "0.4"
queens-rock = "0.1"
rmp-serde = "0.13"
serde = "1.0"
serde_derive = "1.0"
serde_json = "1.0"
tokio = "0.1"
uuid = "0.7"

重要的是要注意,我们使用actix框架和actix-webcrate。如果您不熟悉这个 crate,您可以在第十一章中了解更多信息,使用 Actix Crate 和 Actor 处理并发。我们还使用imagecrate 来处理图像格式,因为这个 crate 被queens-rock使用,并实现了 QR 码的解码器。我们还使用askamacrate 来渲染带有已发布任务的 HTML 页面,并使用indexmapcrate 来获取一个保持元素插入顺序的有序哈希表。为了为任务创建唯一的名称,我们将使用 UUID4 格式,该格式在uuidcrate 中实现。

要与 RabbitMQ 交互,我们将使用lapin-futurescrate,但我们将其重命名为lapin,因为有两个此 crate 的实现。我们使用的是基于futurescrate 的实现,还有一个基于miocrate 的lapin-asynccrate 版本。我们将首先使用lapin-futurescrate,并将其命名为lapin

[dependencies.lapin]
version = "0.15"
package = "lapin-futures"

添加第一个二进制文件,其中包含指向src/server.rs文件的 server 实现:

[[bin]]
name = "rabbit-actix-server"
path = "src/server.rs"
test = false

添加第二个二进制文件,用于实现将在src/worker.rs文件中的 worker:

[[bin]]
name = "rabbit-actix-worker"
path = "src/worker.rs"
test = false

我们已经将askamacrate 作为主代码的依赖项使用,但我们还需要它作为build.rs脚本的依赖项。添加以下内容:

[build-dependencies]
askama = "0.7"

前面的依赖项需要重新构建模板以将其嵌入到代码中。将以下代码添加到新的build.rs脚本中:

fn main() {
    askama::rerun_if_templates_changed();
}

所有依赖项都已准备就绪,我们可以创建一个用于与消息代理中的队列交互的抽象类型。

抽象队列交互 actor

添加src/queue_actor.rs中的 actor,然后创建一个使用抽象处理器来处理传入消息并可以向队列发送新消息的 actor。它还必须创建所有必要的 RabbitMQ 队列并订阅相应队列中的新事件。

依赖项

要创建一个 actor,我们需要以下依赖项:

use super::{ensure_queue, spawn_client};
use actix::fut::wrap_future;
use actix::{Actor, Addr, AsyncContext, Context, Handler, Message, StreamHandler, SystemRunner};
use failure::{format_err, Error};
use futures::Future;
use lapin::channel::{BasicConsumeOptions, BasicProperties, BasicPublishOptions, Channel};
use lapin::error::Error as LapinError;
use lapin::message::Delivery;
use lapin::types::{FieldTable, ShortString};
use log::{debug, warn};
use serde::{Deserialize, Serialize};
use tokio::net::TcpStream;
use uuid::Uuid;

pub type TaskId = ShortString;

首先,我们使用来自 super 模块的ensure_queue函数,该函数创建一个新的队列,但我们将在此章的后面实现它。spawn_client允许我们创建一个连接到消息代理的新Client。我们将使用wrap_future函数,该函数将任何Future对象转换为ActorFuture,这可以在 Actix 框架的Context环境中启动。

让我们探索lapincrate 中的类型。Channel结构体表示与 RabbitMQ 实例的连接通道。BasicConsumeOptions表示用于Channel调用中basic_consume方法的选项,用于订阅队列中的新事件。BasicProperties类型用于Channel类型的basic_publish方法调用的参数,用于添加如关联 ID 等属性到消息的不同接收者,或设置交付所需的质量级别。BasicPublishOptions用于basic_publish调用,以设置消息发布活动的额外选项。

我们还需要从lapin包中获取Error类型,但我们将它重命名为LapinError,因为我们还使用了来自failure包的泛型ErrorDelivery结构体代表从队列中传递过来的入站消息。FieldTable类型被用作Channel类型的basic_consume方法调用的参数。ShortString类型是一个简单的别名,用于在lapin包中将String用作队列的名称。Uuid类型是从uuid包导入的,用于为消息生成唯一的关联 ID 以识别消息的来源。

现在,我们可以声明我们消息的抽象处理器。

抽象消息处理器

queue_actor.rs文件中创建QueueHandler结构体:

pub trait QueueHandler: 'static {
     type Incoming: for<'de> Deserialize<'de>;
     type Outgoing: Serialize;

     fn incoming(&self) -> &str;
     fn outgoing(&self) -> &str;
     fn handle(
         &self,
         id: &TaskId,
         incoming: Self::Incoming,
     ) -> Result<Option<Self::Outgoing>, Error>;
 }

QueueHandler是一个具有两个关联类型和三个方法的特质。它要求实现QueueHandler特质的类型具有static生命周期,因为此特质的实例将被用作具有静态生命周期的演员的字段。

此特质有一个Incoming关联类型,它代表入站消息类型,并要求类型实现Deserialize特质以便可反序列化,因为 RabbitMQ 只传输字节数组,你必须决定使用哪种格式进行序列化。Outgoing关联类型必须实现Serialize特质以便可序列化为字节数组,以便作为出站消息发送。

QueueHandler特质也有incomingoutgoing方法。第一个方法返回要消费入站消息的队列名称。第二个方法返回一个演员将要写入发送消息的队列名称。还有一个handle方法,它接受一个TaskId的引用和Self::Incoming关联类型的入站消息。该方法返回一个包含可选Self::Outgoing实例的Result。如果实现返回None,则不会将消息发送到出站通道。然而,你可以使用特殊的SendMessage类型稍后发送消息。我们将在添加演员的结构体之后声明此类型。

演员

添加一个新的结构体QueueActor,并添加一个实现QueueHandler特质的类型参数:

pub struct QueueActor<T: QueueHandler> {
     channel: Channel<TcpStream>,
     handler: T,
 }

该结构体有一个指向 RabbitMQ 连接Channel的引用。我们通过TcpStream构建它。结构体还有一个handler字段,其中包含一个实现QueueHandler的处理器的实例。

此结构体还必须实现Actor特质才能成为演员。我们还添加了一个started方法。它目前为空,但这是一个创建所有队列的好地方。例如,你可以创建一个消息类型,将消息的Stream附加到这个演员上。使用这种方法,你可以随时开始消费任何队列:

impl<T: QueueHandler> Actor for QueueActor<T> {
     type Context = Context<Self>;

     fn started(&mut self, _: &mut Self::Context) {}
 }

我们将在new方法中初始化所有队列,如果出现问题将中断演员的创建:

impl<T: QueueHandler> QueueActor<T> {
     pub fn new(handler: T, mut sys: &mut SystemRunner) -> Result<Addr<Self>, Error> {
         let channel = spawn_client(&mut sys)?;
         let chan = channel.clone();
         let fut = ensure_queue(&chan, handler.outgoing());
         sys.block_on(fut)?;
         let fut = ensure_queue(&chan, handler.incoming()).and_then(move |queue| {
             let opts = BasicConsumeOptions {
                 ..Default::default()
             };
             let table = FieldTable::new();
             let name = format!("{}-consumer", queue.name());
             chan.basic_consume(&queue, &name, opts, table)
         });
         let stream = sys.block_on(fut)?;
         let addr = QueueActor::create(move |ctx| {
             ctx.add_stream(stream);
             Self { channel, handler }
         });
         Ok(addr)
     }
 }

我们调用稍后将要实现的spawn_client函数,以创建一个连接到消息代理的Client。该函数返回一个Channel实例,该实例由连接的Client创建。我们使用Channel来确保所需的队列存在,或者使用ensure_queue创建它。此方法将在本章稍后实现。我们使用QueueHandler::outgoing方法的结果来获取要创建的队列的名称。

此方法期望SystemRunner通过调用block_on方法立即执行Future对象。它允许我们获取Result,如果方法调用失败,则中断其他活动。

之后,我们使用QueueHandler::incoming方法调用的名称创建一个队列。我们将从这个队列中消费消息,并使用Channelbasic_consume方法开始监听新消息。要调用basic_consume,我们还创建了BasicConsumeOptionsFieldTable类型的默认值。basic_consume返回一个将解析为Stream值的Future。我们使用SystemRunner实例的block_on方法调用执行此Future以获取一个Stream实例并将其附加到QueueActor。我们使用create方法调用创建QueueActor实例,该方法期望一个闭包,该闭包反过来又接受一个Context的引用。

处理传入的流

我们使用Channelbasic_consume方法创建了一个返回Delivery对象的Stream,这些对象来自一个队列。由于我们想将这个Stream附加到 actor 上,我们必须为QueueActor类型实现StreamHandler

impl<T: QueueHandler> StreamHandler<Delivery, LapinError> for QueueActor<T> {
     fn handle(&mut self, item: Delivery, ctx: &mut Context<Self>) {
         debug!("Message received!");
         let fut = self
             .channel
             .basic_ack(item.delivery_tag, false)
             .map_err(drop);
         ctx.spawn(wrap_future(fut));
         match self.process_message(item, ctx) {
             Ok(pair) => {
                 if let Some((corr_id, data)) = pair {
                     self.send_message(corr_id, data, ctx);
                 }
             }
             Err(err) => {
                 warn!("Message processing error: {}", err);
             }
         }
     }
 }

我们的StreamHandler实现期望一个Delivery实例。RabbitMQ 期望客户端在消费投递的消息时发送确认。我们通过将QueueActor的字段存储为Channel实例的basic_ack方法调用来完成此操作。此方法调用返回一个Future实例,我们将将其spawn在一个Context中以发送消息已被接收的确认。

RabbitMQ 要求消费者在处理每条消息时进行通知。如果消费者不这样做,则消息将悬挂在队列中。但你可以将BasicConsumeOptions结构体的no_ack字段设置为true,这样消息一旦被消费者读取就会被标记为已投递。但如果你的应用程序在处理消息之前失败,你将丢失该消息。这仅适用于非关键消息。

我们使用稍后将要实现的process_message方法,使用QueueHandler实例处理消息。如果此方法返回一个非None值,我们将将其用作响应消息,并使用send_message方法将其发送到输出队列,该方法我们将在本章稍后实现。但现在我们将添加一条消息以启动输出消息。

发送新消息

QueueActor必须发送一条新消息,因为我们将使用此 actor 将任务发送到 worker。添加相应的结构体:

pub struct SendMessage<T>(pub T);

为此类型实现 Message 特性:

impl<T> Message for SendMessage<T> {
     type Result = TaskId;
 }

我们需要将 Result 类型设置为 TaskId,因为我们将为处理响应的处理器生成一个新的任务 ID。如果你不熟悉 Actix 框架和消息,请回到 第十一章,使用 Actor 和 Actix Crate 涉及并发

此消息类型的 Handler 将生成一个新的 UUID 并将其转换为 String。然后,该方法将使用 send_message 方法向输出队列发送消息:

impl<T: QueueHandler> Handler<SendMessage<T::Outgoing>> for QueueActor<T> {
     type Result = TaskId;

     fn handle(&mut self, msg: SendMessage<T::Outgoing>, ctx: &mut Self::Context) -> Self::Result {
         let corr_id = Uuid::new_v4().to_simple().to_string();
         self.send_message(corr_id.clone(), msg.0, ctx);
         corr_id
     }
 }

现在,我们必须实现 QueueActorprocess_messagesend_message 方法。

工具方法

让我们添加 process_message 方法,它处理传入的 Delivery 项目:

impl<T: QueueHandler> QueueActor<T> {
     fn process_message(
         &self,
         item: Delivery,
         _: &mut Context<Self>,
     ) -> Result<Option<(ShortString, T::Outgoing)>, Error> {
         let corr_id = item
             .properties
             .correlation_id()
             .to_owned()
             .ok_or_else(|| format_err!("Message has no address for the response"))?;
         let incoming = serde_json::from_slice(&item.data)?;
         let outgoing = self.handler.handle(&corr_id, incoming)?;
         if let Some(outgoing) = outgoing {
             Ok(Some((corr_id, outgoing)))
         } else {
             Ok(None)
         }
     }
 }

首先,我们必须获取与消息相关联的唯一 ID。如果你还记得,我们在这个例子中使用了 UUID。我们将其存储在消息的关联 ID 字段中。

关联 ID 代表一个与消息相关联的值,作为响应的标签。此信息用于在 RabbitMQ 上实现 远程过程调用(RPC)。如果你跳过了 第六章,反应式微服务 - 提高容量和性能,你可以回到那里阅读更多关于 RPC 的内容。

我们使用 JSON 格式来处理消息,并使用 serde_json 解析以创建存储在 Delivery 实例的 data 字段中的传入数据。如果反序列化成功,我们取 Self::Incoming 类型的值。现在,我们拥有了调用 QueueHandler 实例的 handle 方法所需的所有信息——关联 ID 和反序列化的传入消息。处理程序返回一个 Self::Outgoing 消息实例,但我们不会立即对其进行序列化以发送,因为它将使用我们用于处理传入消息的 send_message 方法。让我们来实现它。

send_message 方法接收一个关联 ID 和一个输出值来准备并发送一条消息:

impl<T: QueueHandler> QueueActor<T> {
     fn send_message(&self, corr_id: ShortString, outgoing: T::Outgoing, ctx: &mut Context<Self>) {
         let data = serde_json::to_vec(&outgoing);
         match data {
             Ok(data) => {
                 let opts = BasicPublishOptions::default();
                 let props = BasicProperties::default().with_correlation_id(corr_id);
                 debug!("Sending to: {}", self.handler.outgoing());
                 let fut = self
                     .channel
                     .basic_publish("", self.handler.outgoing(), data, opts, props)
                     .map(drop)
                     .map_err(drop);
                 ctx.spawn(wrap_future(fut));
             }
             Err(err) => {
                 warn!("Can't encode an outgoing message: {}", err);
             }
         }
     }
 }

首先,该方法将值序列化为二进制数据。如果值成功序列化为 JSON,我们将准备选项和属性以调用 Channelbasic_publish 方法向输出队列发送消息。值得注意的是,我们将提供的关联 ID 与用于 basic_publish 调用的 BasicProperties 结构相关联。发布消息返回一个 Future 实例,我们必须在 Actor 的上下文中启动它。如果我们无法序列化值,我们将记录一个错误。

现在,我们可以通过添加 spawn_clientensure_queue 函数来完成 crate 的库部分实现。

Crate

将以下导入添加到 src/lib.rs 源文件中:

pub mod queue_actor;

use actix::{Message, SystemRunner};
use failure::Error;
use futures::Future;
use lapin::channel::{Channel, QueueDeclareOptions};
use lapin::client::{Client, ConnectionOptions};
use lapin::error::Error as LapinError;
use lapin::queue::Queue;
use lapin::types::FieldTable;
use serde_derive::{Deserialize, Serialize};
use tokio::net::TcpStream;

你熟悉一些类型。让我们讨论一些新的类型。Client 代表连接到 RabbitMQ 的客户端。Channel 类型将在连接的结果中创建,并由 Client 返回。QueueDeclareOptions 用作 Channelqueue_declare 方法调用的参数。ConnectionOptions 是建立连接所必需的,但我们将使用默认值。Queue 代表 RabbitMQ 中的队列。

我们需要两个队列:一个用于请求,一个用于响应。我们将使用关联 ID 指定消息的目的地。添加以下常量作为队列的名称:

pub const REQUESTS: &str = "requests";
pub const RESPONSES: &str = "responses";

要生成一个 Client 并创建一个 Channel,我们将添加 spawn_client 函数,该函数创建一个 Client 并从它生成一个 Channel

pub fn spawn_client(sys: &mut SystemRunner) -> Result<Channel<TcpStream>, Error> {
    let addr = "127.0.0.1:5672".parse().unwrap();
    let fut = TcpStream::connect(&addr)
        .map_err(Error::from)
        .and_then(|stream| {
            let options = ConnectionOptions::default();
            Client::connect(stream, options).from_err::<Error>()
        });
    let (client, heartbeat) = sys.block_on(fut)?;
    actix::spawn(heartbeat.map_err(drop));
    let channel = sys.block_on(client.create_channel())?;
    Ok(channel)
}

上述函数的实现足够简单。我们使用 connect 方法调用从一个常量地址创建一个 TcpStream。如果需要,你可以使地址参数可配置。connect 方法返回一个 Future,我们使用它来创建一个映射到连接到 RabbitMQ 的新 Client 的组合器。我们使用 SystemRunnerblock_on 来立即执行该 Future。它返回一个 Client 和一个 Heartbeat 实例。Client 实例用于创建 Channel 的实例。Heartbeat 实例是一个任务,它使用必须作为事件循环中的并发活动产生的连接来 ping RabbitMQ。我们使用 actix::spawn 来运行它,因为我们没有 ActorContext

最后,我们调用 Clientcreate_channel 方法来创建一个 Channel。但是该方法返回一个 Future,我们也使用 block_on 方法来执行它。现在,我们可以返回创建的 Channel 并实现 ensure_queue 方法,该方法期望该 Channel 实例作为参数。

ensure_queue 方法创建了一个调用 queue_declare 方法的选项,该方法在 RabbitMQ 内部创建一个队列:

pub fn ensure_queue(
    chan: &Channel<TcpStream>,
    name: &str,
) -> impl Future<Item = Queue, Error = LapinError> {
    let opts = QueueDeclareOptions {
        auto_delete: true,
        ..Default::default()
    };
    let table = FieldTable::new();
    chan.queue_declare(name, opts, table)
}

我们用默认参数填充 QueueDeclareOptions,但将 auto_delete 字段设置为 true,因为我们希望创建的队列在应用程序结束时被删除。这对于测试目的来说很合适。在这个方法中,我们不会立即执行由 queue_declare 方法返回的 Future。我们按原样返回它,以便调用环境可以使用返回的 Queue 值创建组合器。

我们已经实现了创建服务器和工人的所有必要部分。现在,我们需要声明请求和响应类型,以便在工人和服务器中使用它们。

请求和响应

被称为 QrRequest 的请求类型包含有关 QR 图像的数据:

#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct QrRequest {
    pub image: Vec<u8>,
}

它实现了来自 actixMessage 特性,该特性将被设置为 QueueHandler 的关联类型:

impl Message for QrRequest {
    type Result = ();
}

响应类型由 QrResponse 枚举表示:

#[derive(Clone, Debug, Deserialize, Serialize)]
pub enum QrResponse {
    Succeed(String),
    Failed(String),
}

它包含两个变体:Succeed用于成功结果,Failed用于错误。它与标准库中的Result类型类似,但我们决定添加我们自己的类型,以便在我们需要时有机会覆盖序列化行为。但我们可以通过实现From特质从Result实例构建此响应。这很有用,因为我们可以使用一个函数来构建返回Result类型的值。请看这里的实现:

impl From<Result<String, Error>> for QrResponse {
    fn from(res: Result<String, Error>) -> Self {
        match res {
            Ok(data) => QrResponse::Succeed(data),
            Err(err) => QrResponse::Failed(err.to_string()),
        }
    }
}

QrResponse还必须实现Message特质:

impl Message for QrResponse {
    type Result = ();
}

库 crate 已准备好用于创建一个工作器和服务器。让我们先实现一个工作器。

工作器

工作器将消费请求队列中的所有消息,并尝试将它们解码为 QR 图像字符串。

依赖项

我们需要以下类型来实现工作器的实现:

use actix::System;
use failure::{format_err, Error};
use image::GenericImageView;
use log::debug;
use queens_rock::Scanner;
use rabbit_actix::queue_actor::{QueueActor, QueueHandler, TaskId};
use rabbit_actix::{QrRequest, QrResponse, REQUESTS, RESPONSES};

我们在本章前面已经导入了所有必要的类型。我们还导入了用于解码 QR 图像的两个类型。GenericImageView提供了将图像转换为灰度的to_luma方法。Scanner方法是一个将灰度图像作为 QR 码提供的解码器。

处理程序

我们需要创建一个空的结构体,因为我们的工作器没有状态,并且只会转换传入的消息:

struct WokerHandler {}

我们使用WorkerHandler结构体作为队列的处理程序,并在稍后与QueueActor一起使用。实现QueueHandler特质,这是QueueActor所需的:

impl QueueHandler for WokerHandler {
    type Incoming = QrRequest;
    type Outgoing = QrResponse;

    fn incoming(&self) -> &str {
        REQUESTS
    }
    fn outgoing(&self) -> &str {
        RESPONSES
    }
    fn handle(
        &self,
        _: &TaskId,
        incoming: Self::Incoming,
    ) -> Result<Option<Self::Outgoing>, Error> {
        debug!("In: {:?}", incoming);
        let outgoing = self.scan(&incoming.image).into();
        debug!("Out: {:?}", outgoing);
        Ok(Some(outgoing))
    }
}

由于此处理程序接收请求并准备响应,我们将QrRequest设置为Incoming类型,将QrResponse设置为Outgoing类型。incoming方法返回REQUESTS常量的值,我们将用作传入队列的名称。outgoing方法返回RESPONSES常量,用作传出消息队列的名称。

QueueHandlerhandle方法接收一个请求并使用数据调用scan方法。然后,它将Result转换为QrResponse并返回它。让我们实现解码图像的scan方法:

impl WokerHandler {
    fn scan(&self, data: &[u8]) -> Result<String, Error> {
        let image = image::load_from_memory(data)?;
        let luma = image.to_luma().into_vec();
        let scanner = Scanner::new(
            luma.as_ref(),
            image.width() as usize,
            image.height() as usize,
        );
        scanner
            .scan()
            .extract(0)
            .ok_or_else(|| format_err!("can't extract"))
            .and_then(|code| code.decode().map_err(|_| format_err!("can't decode")))
            .and_then(|data| {
                data.try_string()
                    .map_err(|_| format_err!("can't convert to a string"))
            })
    }
}

从微服务角度来看,函数的实现并不重要,我将简要描述它——它从提供的字节中加载一个Image,使用to_luma方法将Image转换为灰度,并将返回值作为Scanner的参数提供。然后,它使用scan方法解码 QR 码并提取转换为String的第一个Code

主函数

现在,我们可以添加一个main函数来创建一个带有解码工作器的 actor:

fn main() -> Result<(), Error> {
    env_logger::init();
    let mut sys = System::new("rabbit-actix-worker");
    let _ = QueueActor::new(WokerHandler {}, &mut sys)?;
    let _ = sys.run();
    Ok(())
}

此方法启动一个System并创建一个QueueActor的实例,该实例包含一个WorkerHandler的实例。就是这样。这真的很简单——使用QueueActor,只需实现QueueHandler即可将处理器转换为队列。让我们以类似的方式创建一个服务器。

服务器

要实现服务器,我们不仅要实现QueueHandler,还要实现 HTTP 请求的处理程序。我们还将使用actixactix-webcrate。

依赖项

将以下类型添加到server.rs文件中:

use actix::{Addr, System};
use actix_web::dev::Payload;
use actix_web::error::MultipartError;
use actix_web::http::{self, header, StatusCode};
use actix_web::multipart::MultipartItem;
use actix_web::{
    middleware, server, App, Error as WebError, HttpMessage, HttpRequest, HttpResponse,
};
use askama::Template;
use chrono::{DateTime, Utc};
use failure::Error;
use futures::{future, Future, Stream};
use indexmap::IndexMap;
use log::debug;
use rabbit_actix::queue_actor::{QueueActor, QueueHandler, SendMessage, TaskId};
use rabbit_actix::{QrRequest, QrResponse, REQUESTS, RESPONSES};
use std::fmt;
use std::sync::{Arc, Mutex};

您应该熟悉所有这些类型,因为我们已经在之前的章节中使用了它们中的大多数,除了 MultipartItemMultipartError。这两种类型都用于从 POST 请求中提取上传的文件。

共享状态

我们还将添加 SharedTasks 类型别名,它表示由 MutexArc 包装的 IndexMap。我们将使用此类型来存储所有任务及其状态:

type SharedTasks = Arc<Mutex<IndexMap<String, Record>>>;

Record 是一个包含任务唯一标识符的结构体,用作消息的相关 ID。它还有一个 timestamp,表示任务被发布的时间,以及一个 Status,表示任务的状态:

#[derive(Clone)]
struct Record {
    task_id: TaskId,
    timestamp: DateTime<Utc>,
    status: Status,
}

Status 是一个枚举,有两个变体:InProgress,当任务被发送到工作者时;和 Done,当工作者以 QrResponse 值返回响应时:

#[derive(Clone)]
enum Status {
    InProgress,
    Done(QrResponse),
}

我们需要为 Status 实现 Display 特性,因为我们将会用它来渲染 HTML 模板。实现此特性:

impl fmt::Display for Status {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            Status::InProgress => write!(f, "in progress"),
            Status::Done(resp) => match resp {
                QrResponse::Succeed(data) => write!(f, "done: {}", data),
                QrResponse::Failed(err) => write!(f, "failed: {}", err),
            },
        }
    }
}

我们将展示三种状态:进行中、成功和失败。

我们的服务器需要一个共享状态。我们将使用带有任务映射和 QueueActorServerHandler 地址的 State 结构体:

#[derive(Clone)]
struct State {
    tasks: SharedTasks,
    addr: Addr<QueueActor<ServerHandler>>,
}

现在,我们可以实现 ServerHandler 并使用它创建一个 actor。

服务器处理器

为了从工作者那里消费响应,我们的服务器必须启动 QueueActor 并带有将更新服务器共享状态的处理器。创建一个包含 SharedTasks 引用副本的 ServerHandler 结构体:

struct ServerHandler {
    tasks: SharedTasks,
}

为此结构体实现 QueueHandler

impl QueueHandler for ServerHandler {
    type Incoming = QrResponse;
    type Outgoing = QrRequest;

    fn incoming(&self) -> &str {
        RESPONSES
    }
    fn outgoing(&self) -> &str {
        REQUESTS
    }
    fn handle(
        &self,
        id: &TaskId,
        incoming: Self::Incoming,
    ) -> Result<Option<Self::Outgoing>, Error> {
        self.tasks.lock().unwrap().get_mut(id).map(move |rec| {
            rec.status = Status::Done(incoming);
        });
        Ok(None)
    }
}

服务器处理器必须使用 RESPONSES 队列来消费响应,并将 REQUESTS 作为发送请求的输出队列。相应地,将 QrResponse 设置为 Incoming 类型,将 QrRequest 设置为 Outgoing 类型。

handle 方法锁定存储在 tasks 字段的 Mutex,以获取对 IndexMap 的访问权限,并在存在对应任务 ID 的记录时更新 Recordstatus 字段。任务 ID 将由 QueueActor 自动提取,并通过不可变引用提供给此方法。现在是实现所有必要的 HTTP 处理器的时候了。

请求处理器

我们需要为请求实现三个处理器:一个显示微服务名称的索引页面、渲染所有任务及其状态的处理器,以及用于上传带有二维码的新任务的处理器。

索引处理器

index_handler 返回包含此微服务名称的文本:

fn index_handler(_: &HttpRequest<State>) -> HttpResponse {
    HttpResponse::Ok().body("QR Parsing Microservice")
}

任务处理器

tasks_handler 使用 MutexIndexMap 锁定,以便迭代所有 Record 值并将它们作为 Tasks 结构体的一部分进行渲染:

fn tasks_handler(req: HttpRequest<State>) -> impl Future<Item = HttpResponse, Error = WebError> {
    let tasks: Vec<_> = req
        .state()
        .tasks
        .lock()
        .unwrap()
        .values()
        .cloned()
        .collect();
    let tmpl = Tasks { tasks };
    future::ok(HttpResponse::Ok().body(tmpl.render().unwrap()))
}

如果您还记得,我们添加了 askama crate 来渲染模板。在项目的根目录中创建一个 templates 文件夹,并添加一个 tasks.html 文件,其中包含一些 HTML 代码,至少包含以下渲染表格:

<table>
    <thead>
        <tr>
            <th>Task ID</th>
            <th>Timestamp</th>
            <th>Status</th>
        </tr>
    </thead>
    <tbody>
        {% for task in tasks %}
        <tr>
            <td>{{ task.task_id }}</td>
            <td>{{ task.timestamp }}</td>
            <td>{{ task.status }}</td>
        </tr>
        {% endfor %}
    </tbody>
</table>

这是您可以在本书示例文件夹中找到的完整模板的一部分,但前面的代码包含用于渲染从 Tasks 结构体中提取的所有任务的表格的代码,其实现如下:

#[derive(Template)]
#[template(path = "tasks.html")]
struct Tasks {
    tasks: Vec<Record>,
}

为此结构派生Template类型,并使用template属性附加模板。askama会将模板嵌入到你的代码中。这非常方便。

上传处理器

upload_handler稍微复杂一些,因为它接受包含上传图像的表单的 POST 请求:

fn upload_handler(req: HttpRequest<State>) -> impl Future<Item = HttpResponse, Error = WebError> {
    req.multipart()
        .map(handle_multipart_item)
        .flatten()
        .into_future()
        .and_then(|(bytes, stream)| {
            if let Some(bytes) = bytes {
                Ok(bytes)
            } else {
                Err((MultipartError::Incomplete, stream))
            }
        })
        .map_err(|(err, _)| WebError::from(err))
        .and_then(move |image| {
            debug!("Image: {:?}", image);
            let request = QrRequest { image };
            req.state()
                .addr
                .send(SendMessage(request))
                .from_err()
                .map(move |task_id| {
                    let record = Record {
                        task_id: task_id.clone(),
                        timestamp: Utc::now(),
                        status: Status::InProgress,
                    };
                    req.state().tasks.lock().unwrap().insert(task_id, record);
                    req
                })
        })
        .map(|req| {
            HttpResponse::build_from(&req)
                .status(StatusCode::FOUND)
                .header(header::LOCATION, "/tasks")
                .finish()
        })
}

实现获取一个MultipartItemStream,其值可以是FiledNested。我们使用以下函数来收集所有项目到一个单一的统一StreamVec<u8>对象中:

pub fn handle_multipart_item(
    item: MultipartItem<Payload>,
) -> Box<Stream<Item = Vec<u8>, Error = MultipartError>> {
    match item {
        MultipartItem::Field(field) => {
            Box::new(field.concat2().map(|bytes| bytes.to_vec()).into_stream())
        }
        MultipartItem::Nested(mp) => Box::new(mp.map(handle_multipart_item).flatten()),
    }
}

然后,我们使用into_future方法提取第一个项目。如果值存在,我们将其映射到QrRequest并使用QueueActor的地址向一个 worker 发送请求。

你可能会问,如果任务的 ID 没有设置,worker 返回结果是否可能。潜在地,这是可能的。如果你想有一个可靠的State变化,你应该实现一个仅与State实例一起工作的 actor。

最后,处理器构建一个HttpResponse值,将客户端重定向到/tasks路径。

现在,我们可以将所有部分连接到一个单独的函数中。

主函数

我们已经在第十一章,涉及并发与 Actors 和 Actix Crate中创建了创建actix应用程序实例的函数。如果你不记得如何将处理器和状态附加到应用程序,请回到该章节,查看以下main函数:

fn main() -> Result<(), Error> {
    env_logger::init();
    let mut sys = System::new("rabbit-actix-server");
    let tasks = Arc::new(Mutex::new(IndexMap::new()));
    let addr = QueueActor::new(
        ServerHandler {
            tasks: tasks.clone(),
        },
        &mut sys,
    )?;

    let state = State {
        tasks: tasks.clone(),
        addr,
    };
    server::new(move || {
        App::with_state(state.clone())
            .middleware(middleware::Logger::default())
            .resource("/", |r| r.f(index_handler))
            .resource("/task", |r| {
                r.method(http::Method::POST).with_async(upload_handler);
            })
            .resource("/tasks", |r| r.method(http::Method::GET).with_async(tasks_handler))
    })
    .bind("127.0.0.1:8080")
    .unwrap()
    .start();

    let _ = sys.run();
    Ok(())
}

此函数创建一个带有新方法的System实例,该方法返回一个SystemRunner实例,我们将使用它来启动包含ServerHandlerQueueActor。然后,它创建一个带有已孵化 actor 地址的State实例,并将所有必要的处理器填充到App对象中。

当我们启动一个SystemRunner时,它会创建一个 actor 并连接到 RabbitMQ 来创建所有必要的队列并开始消费响应。

一个好的做法是创建所有使用它的应用程序的相同队列,因为你无法知道哪个部分会先启动——服务器还是 worker。这就是为什么我们在QueueActionnew方法中实现了所有队列的创建。所有队列在任何代码使用它们之前都将可用。

我们准备好测试这个示例。

测试

让我们构建并运行应用程序的服务器和 worker。你应该启动一个包含 RabbitMQ 实例的容器,就像我们在本章的Bootstrap 消息代理测试部分所做的那样。然后,使用cargo build来构建所有部分。

当编译完成后,启动一个服务器实例:

RUST_LOG=rabbit_actix_server=debug ./target/debug/rabbit-actix-server

还可以使用以下命令启动一个 worker 实例:

RUST_LOG=rabbit_actix_worker=debug ./target/debug/rabbit-actix-worker

当两个部分都启动后,你可以使用rabbitmqctl命令探索 RabbitMQ 并查看你的队列:

docker exec -it test-rabbit rabbitmqctl list_queues

它打印出 actors 创建的所有队列:

Timeout: 60.0 seconds ...
Listing queues for vhost / ...
responses    0
requests    0

如果你想要查看所有已连接的消费者,你可以使用以下命令:

docker exec -it test-rabbit rabbitmqctl list_consumers

它打印出responses-consumer,代表一个服务器实例,以及requests-consumer,代表一个工作实例:

Listing consumers on vhost / ...
responses  <rabbit@f137a225a709.3.697.0>  responses-consumer   true    0    []
requests   <rabbit@f137a225a709.3.717.0>  requests-consumer    true    0    []

现在一切都已经连接到 RabbitMQ,我们可以在浏览器中打开http://localhost:8080/tasks。你会看到一个空表和一个上传 QR 码的表单。选择一个文件,并使用表单上传。页面会刷新,你会看到你的任务正在进行中:

如果工作者工作正常,并且你在短时间内刷新页面,你会看到解码后的 QR 码:

正如你所见,服务器和工作者通过 RabbitMQ 传递的消息进行交互。

让我们讨论这个解决方案的优缺点。

如何扩展这个应用程序

本章中我们创建的示例使用两个独立工作并使用 RabbitMQ 交换任务和结果的组件。

扩展这个应用程序非常简单——你只需要启动任意数量的工作者,他们不需要在同一个服务器上工作。你可以在分布式的服务器集群上运行工作者。如果你的工作者无法处理负载,你可以启动额外的工人,他们可以立即消费等待的任务并开始解码过程。

但这个系统有一个潜在的瓶颈——消息代理。对于这个示例,如果你启动额外的独立消息代理,你可以简单地处理它;例如,RabbitMQ 通过其集群功能支持多个实例。你的服务器可以与多个消息代理建立多个连接,但你不能启动任意数量的服务器,因为它们将具有不同的任务集。

是否可以共享任务列表?是的,你可以使用传统的数据库或存储,如 Redis,但它成为另一个瓶颈,因为很难为百万级客户使用相同的数据库实例。

我们如何处理数据库的瓶颈问题?你可以通过客户来分割任务列表,并将特定客户的列表保存在存储的一个实例上。如果你想提供一个功能,让客户之间可以共享任务,你可以创建共享列表并将它们存储在数据库中,在这种情况下,数据库的负载不会很重。

正如你所见,扩展是一个未定义的过程,你必须进行一些实验以达到预期的结果。但无论如何,你应该努力在微服务之间实现独立的任务,并使用消息或 RPC 来为你的应用程序提供松散耦合,以及良好的性能。

摘要

在本章中,我们讨论了可扩展微服务的重要主题。我们从基础知识开始,继续实现两个使用 RabbitMQ 作为消息代理相互交互的服务。我们创建的示例将 QR 码解码以便由单独的工作者处理。实现的应用程序的好处是你可以启动任意数量的工作者。

在下一章中,我们将学习如何使用单元测试、集成测试和调试器来测试和调试微服务。

第十三章:测试和调试 Rust 微服务

微服务,就像任何其他应用程序一样,都可能存在错误。你可能在编写新代码时犯错,或者通过向应用程序添加额外功能来犯错。在本章中,我们将探讨可用于测试和调试你的微服务的可用工具。

我们将首先为应用程序创建单元和集成测试。我们还将检查actix包的测试能力。

在此之后,我们将学习使用 LLDB 调试器进行调试,并检查日志作为调试技术,因为并非每个错误都能用传统的调试器捕获。此外,如果你在生产中使用产品,你不能将其附加到调试器上。

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

  • 微服务测试

  • 微服务调试

技术要求

对于本章的测试示例,你除了需要 Rust 编译器(当然,你也需要)之外,还需要一些额外的软件。你需要安装 Docker 和 Docker Compose 来从第十五章,“将服务器打包到容器中”启动应用程序。我们将使用这个应用程序来运行集成测试。

你还需要安装 Postman 工具,我们将使用它手动测试 API,以及 LLDB 调试器,我们将学习如何使用它来调试微服务。此外,还需要安装 Jaeger,但你可以使用在 Docker 中运行的单一镜像来完成这项工作。最后,我们将使用 OpenTracing API 进行分布式跟踪。

本章的示例代码可在 GitHub 上找到:github.com/PacktPublishing/Hands-On-Microservices-with-Rust/tree/master/Chapter13

微服务测试

Rust 是创建可靠应用程序的几乎完美的工具。编译器非常严格,永远不会错过任何潜在的内存访问错误或数据竞争,但仍然有许多方法可以在代码中犯错。换句话说,Rust 在很大程度上帮助你,但它并非万能。

单元测试

微服务也可能存在错误,因此你必须准备好处理所有可能的错误。第一道防线是单元测试。

单元测试涉及使用 HTTP 客户端向服务器或请求处理器发送隔离的请求。在单元测试中,你应该只检查一个函数。覆盖大多数有助于保持函数相同行为的代码,该函数可以通过测试重新实现或改进,这是必要的。

此外,你可以在编写任何代码之前编写一个测试。这被称为测试驱动开发TDD),但这种方法适合具有良好规格的项目,因为如果你还没有决定解决方案,你可能需要多次重写测试。换句话说,TDD 不适合错误不是关键,但需要高速开发的项目。

为传统 crate 编写单元测试很简单,但对于微服务,你会有很多问题,因为需要在生产环境中模拟微服务的工作环境。为了模拟环境,你可以使用创建具有预定义响应的 HTTP 服务器的模拟服务。你还记得在第十一章中,使用 Actix Crate 和 Actors 进行并发时,我们创建了一个无法测试的路由微服务吗?因为我们必须手动运行很多微服务?在本节中,我们将为该路由微服务创建一个单元测试。

模拟

让我们创建一个模拟服务器,模拟对路由微服务的三个路径的请求响应:/signin/signup/comments。有一个名为mockito的 crate 提供了模拟 HTTP 响应的服务器。我们将使用第十一章中的示例,使用 Actix Crate 和 Actors 进行并发。复制它并添加以下额外的依赖项:

mockito = "0.15"
reqwest = "0.9"

我们需要mockito crate 来启动带有模拟的服务器,以及reqwest crate 来向我们的 Actix 服务器实例发送 HTTP 请求。

创建一个带有#[cfg(test)]属性的tests模块,它只编译用于测试,并导入以下我们将用于测试的类型:

use crate::{start, Comment, LinksMap, UserForm, UserId};
use lazy_static::lazy_static;
use mockito::{mock, Mock};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::sync::Mutex;
use std::time::Duration;
use std::thread;

我们使用路由微服务的类型来准备请求;即,CommentUserFormUserId。此外,我们还添加了LinksMap结构体来配置 URL 到模拟:

#[derive(Clone)]
struct LinksMap {
    signup: String,
    signin: String,
    new_comment: String,
    comments: String,
}

将此结构体添加到State中,并使用处理程序获取微服务的 URL:

#[derive(Clone)]
struct State {
    counter: RefCell<i64>,
    links: LinksMap,
}

此外,我们还导入了lazy_static!宏,它用于初始化一个Mutex,我们将使用它来检查 Actix 服务器是否已启动一次。Rust 还有一个Once类型,也可以使用,但我们需要等待一定的时间间隔,然后让服务执行请求,而Once类型的is_completed方法是不稳定的。为了创建模拟,我们将使用mockito crate 的mock函数和一个表示特定请求处理器的Mock类型。

创建一个添加模拟的函数,如下所示:

fn add_mock<T>(method: &str, path: &str, result: T) -> Mock
where
    T: Serialize,
{
    mock(method, path)
        .with_status(200)
        .with_header("Content-Type", "application/json")
        .with_body(serde_json::to_string(&result).unwrap())
        .create()
}

add_mock函数期望一个 HTTP 方法和要模拟的资源路径。它还接受以 JSON 格式返回的响应值。

我们调用mock函数来创建一个Mock实例,并使用以下方法对其进行调整:

  • with_status设置响应的状态码

  • with_header设置特定头部的值

  • with_body设置响应体

最后,我们调用crate方法,它尝试启动一个模拟服务器并将我们创建的Mock附加到它。现在,我们可以启动一个路由微服务实例并准备所有必要的模拟来模拟路由器期望的其他微服务。

启动测试服务器

我们将在一个单独的线程中启动服务器实例,因为 Rust 在多个线程中运行测试,并且我们不会为每次测试运行创建具有唯一端口的服务器实例来展示如何使用共享实例,因为集成测试通常需要重用相同的应用程序实例。创建一个共享标志,我们将使用它来检测已经启动的路由器:

lazy_static! {
    static ref STARTED: Mutex<bool> = Mutex::new(false);
}

现在,我们将使用这个Mutex来创建一个启动服务器的函数。看看下面的setup函数实现:

fn setup() {
    let mut started = STARTED.lock().unwrap();
    if !*started {
        thread::spawn(|| {
            let url = mockito::server_url();
            let _signup = add_mock("POST", "/signup", ());
            let _signin = add_mock("POST", "/signin", UserId { id: "user-id".into() });
            let _new_comment = add_mock("POST", "/new_comment", ());
            let comment = Comment {
                id: None,
                text: "comment".into(),
                uid: "user-id".into(),
            };
            let _comments = add_mock("GET", "/comments", vec![comment]);
            let links = LinksMap {
                signup: mock_url(&url, "/signup"),
                signin: mock_url(&url, "/signin"),
                new_comment: mock_url(&url, "/new_comment"),
                comments: mock_url(&url, "/comments"),
            };
            start(links);
        });
        thread::sleep(Duration::from_secs(5));
        *started = true;
    }
}

之前的功能会锁定一个Mutex以获取标志的值。如果它等于false,我们将启动一个新的线程,并带有服务器实例和模拟,然后在将标志设置为true并释放Mutex之前等待 5 秒钟。

在启动的线程中,我们获取一个 URL 或mock服务器。如果它还没有启动,它将自动启动该服务器。之后,我们使用add_mock方法将所有模拟添加到模拟其他微服务。

mockito crate 要求你在mock服务器启动的同一个线程中添加所有模拟。

此外,我们将所有创建的模拟都保存在局部变量中。如果其中任何一个被丢弃,那么那个模拟处理程序就会丢失。你也可以使用std::mem::forget方法来确保模拟永远不会被丢弃,但更准确的做法是保留局部变量。

我们将使用LinksMap,通过mock服务器的 URL 和路径,这两个都通过以下函数连接:

fn mock_url(base: &str, path: &str) -> String {
    format!("{}{}", base, path)
}

最后,我们调用了start函数,这实际上是一个修改过的main函数:

fn start(links: LinksMap) {
    let sys = actix::System::new("router");
    let state = State {
        counter: RefCell::default(),
        links,
    };
    server::new(move || {
        App::with_state(state.clone())
            // App resources attached here
    }).workers(1).bind("127.0.0.1:8080").unwrap().start();
    sys.run();
}

这与第十一章涉及并发与 Actix Crate 中路由器微服务的main函数的区别在于,它期望将LinksMap值添加到State中。现在,我们可以创建方法来对重定向到模拟的服务器进行测试请求。

发送请求

要发送GET请求,我们将使用test_get函数,该函数创建一个reqwest crate 的Client,设置一个路径,执行一个send请求,并将响应从 JSON 反序列化:

fn test_get<T>(path: &str) -> T
where
    T: for <'de> Deserialize<'de>,
{
    let client =  Client::new();
    let data = client.get(&test_url(path))
        .send()
        .unwrap()
        .text()
        .unwrap();
    serde_json::from_str(&data).unwrap()
}

如果你熟悉reqwest crate,你可能想知道为什么我们得到文本值,因为Clientjson方法可以反序列化 JSON?如果我们这样做,在反序列化问题时我们无法看到原始值,但使用响应的原始文本,我们可以将其记录下来以供调查。

要生成 URL,我们使用以下函数:

fn test_url(path: &str) -> String {
    format!("http://127.0.0.1:8080/api{}", path)
}

这添加了我们将要绑定到的服务器地址,但对于大型项目,最好使用动态地址,特别是如果你想在每次测试中使用一个新的服务器实例。

对于POST请求,我们将使用类似的方法,但我们不会反序列化结果,因为我们不需要它,并且只会检查响应的状态:

fn test_post<T>(path: &str, data: &T)
where
    T: Serialize,
{
    setup();
    let client =  Client::new();
    let resp = client.post(&test_url(path))
        .form(data)
        .send()
        .unwrap();
    let status = resp.status();
    assert!(status.is_success());
}

我们已经有了所有必要的函数来实现每个处理器的单元测试。

实现测试

到目前为止,我们在这个章节中创建的实用工具使得单元测试看起来相当紧凑。为了测试期望 UserForm/signup 路径的处理程序,我们将添加一个带有 #[test] 属性的 test_signup_with_client 函数:

#[test]
fn test_signup_with_client() {
    let user = UserForm {
        email: "abc@example.com".into(),
        password: "abc".into(),
    };
    test_post("/signup", &user);
}

当我们运行 cargo test 命令时,这个函数将被调用,而 test_post 调用,反过来,将启动一个带有 mock 服务器的服务器。

要测试 /signin 路径的处理程序,我们将使用以下函数:

#[test]
fn test_signin_with_client() {
    let user = UserForm {
        email: "abc@example.com".into(),
        password: "abc".into(),
    };
    test_post("/signin", &user);
}

这个测试使用与 POST 请求相同的输入值。

要获取评论列表,只需调用带有 /comments 路径的 test_get 函数:

#[test]
fn test_list_with_client() {
    let _: Vec<Comment> = test_get("/comments");
}

现在,我们可以启动这些测试来检查将请求转发到 mock 服务器的路由微服务。

运行我们的测试

运行单元测试,请在本项目文件夹中运行 Cargo 的 test 命令。它将启动三个测试,您将在终端中看到命令的输出:

running 3 tests
test tests::test_list_with_client ... ok
test tests::test_signup_with_client ... ok
test tests::test_signin_with_client ... ok

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

所有测试都通过了,但让我们检查一下如果我们更改实现中的某些内容会发生什么。让我们移除 /signin 路径的 Mock。测试将打印出一个测试失败的信息:

running 3 tests
test tests::test_list_with_client ... ok
test tests::test_signin_with_client ... FAILED
test tests::test_signup_with_client ... ok

failures:
---- tests::test_signin_with_client stdout ----
thread 'tests::test_signin_with_client' panicked at 'assertion failed: status.is_success()', src/lib.rs:291:9
note: Run with `RUST_BACKTRACE=1` environment variable to display a backtrace.

failures:
    tests::test_signin_with_client

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

如预期的那样,tests::test_signin_with_client 测试失败了,因为它无法从 /signin 请求中获取响应。单元测试可以帮助您确保处理器的行为不会改变,即使您从头开始重写实现。

我们使用预定义的方法对微服务进行了单元测试,以简化服务器的启动并向其发送 HTTP 请求。如果您想测试与应用程序的复杂交互,您应该实现集成测试,我们现在将介绍。

集成测试

单元测试不能保证整个应用程序正常工作,因为它只测试了实现的一小部分。相比之下,集成测试更复杂,并且有助于确保您的整个应用程序正常工作。我们将这本书中创建的一些微服务组合成一个应用程序。让我们创建集成测试来检查应用程序。

启动应用程序实例

在我们编写测试代码的第一行之前,我们必须使用 Docker 启动一个应用程序。这很复杂,您将在第十五章 [80b8c3ec-d291-40df-a7a7-b9e9f0a64a99.xhtml] “将服务器打包到容器中” 中学习如何操作,但到目前为止,请打开该章节的代码示例文件夹,并从 Docker Compose 脚本中启动一个项目。然而,您还必须准备一个用于构建微服务的镜像。在终端中输入以下两个命令:

docker build -t rust:nightly nightly
docker-compose -f docker-compose.test.yml up

项目已经包含一个特殊的 Compose 文件,docker-compose.test.yml,它打开容器的端口,以便我们可以从本地的 Rust 应用程序连接到它们。

它需要一些时间来启动,但应用程序启动后,您将在终端窗口中看到日志。然后,我们可以编写一些集成测试。

依赖项

你可能会感到惊讶,但在集成测试中,我们不需要很多依赖项,因为我们将会使用 HTTP 客户端和serde系列 crate 来序列化请求和反序列化响应:

cookie = "0.11"
rand = "0.6"
reqwest = "0.9"
serde = "1.0"
serde_derive = "1.0"
serde_json = "1.0"
uuid = { version = "0.5", features = ["serde", "v4"] }

此外,我们需要uuidcrate 来生成唯一值,以及cookiecrate 来支持我们的 HTTP 请求中的 cookies,因为集成测试必须保持会话以发出一系列有意义的请求。

工具

就像我们对单元测试所做的那样,我们将添加一些实用函数以避免为每个测试创建 HTTP 客户端。我们将使用预定义的方法来执行健康检查,并发送POSTGET请求到应用程序中包含的微服务。创建一个utils.rs文件并导入必要的类型:

use cookie::{Cookie, CookieJar};
use rand::{Rng, thread_rng};
use rand::distributions::Alphanumeric;
pub use reqwest::{self, Client, Method, RedirectPolicy, StatusCode};
use reqwest::header::{COOKIE, SET_COOKIE};
use serde::Deserialize;
use std::collections::HashMap;
use std::iter;
use std::time::Duration;
use std::thread;

我们将使用reqwestcrate 中的Client实例,就像我们在单元测试中所做的那样,但我们需要导入额外的类型:Method用于精确设置不同的 HTTP 方法;RedirectPolicy用于控制重定向,因为路由微服务会重定向我们到其他页面;以及Client,它将执行这些重定向,但我们希望关闭这种行为。StatusCode用于检查返回的 HTTP 状态码。

我们导入了COOKIESET_COOKIE头,用于设置这些头的值以供请求使用,并从响应中获取它们的值。但这些头的值是正式的,我们需要解析它们。为了简化这一点,我们将使用 cookie crate 中的CookieCookieJar类型,因为reqwestcrate 目前不支持 cookies。

此外,我们使用randcrate 和从中导入的Alphanumeric分布来生成唯一的测试登录名,因为我们将与一个工作应用程序交互,现在根本无法重新启动它。

我们的应用程序包含四个微服务,以下地址,所有这些地址都可以从我们的应用程序的 Docker 容器中访问:

const USERS: &str = "http://localhost:8001";
const MAILER: &str = "http://localhost:8002";
const CONTENT: &str = "http://localhost:8003";
const ROUTER: &str = "http://localhost:8000";

我们将地址声明为常量,这样我们就有了一个单独的地方来更新它们(如果需要的话):

pub fn url(url: &str, path: &str) -> String {
    url.to_owned() + path
}

此外,我们需要一个函数来生成由字母数字字符组成的随机字符串:

pub fn rand_str() -> String {
    let mut rng = thread_rng();
    iter::repeat(())
            .map(|()| rng.sample(Alphanumeric))
            .take(7)
            .collect()
}

上述代码使用一个为当前线程初始化的随机数生成器和一个生成随机值的迭代器,取 7 个字符并将它们连接成String值。

由于集成测试与实时系统一起工作,我们需要一个函数来暂停当前线程:

pub fn wait(s: u64) {
    thread::sleep(Duration::from_secs(s));
}

这个函数是thread::sleep调用的简短别名。

但这不仅仅是关于工具——我们还需要一个通用客户端来向所有工作的微服务发送请求。

集成测试客户端

将以下结构体添加到您的utils.rs源文件中:

pub struct WebApi {
    client: Client,
    url: String,
    jar: CookieJar,
}

它有三个字段——一个 HTTP Client;一个基础url,用于使用附加的路径构建完整的 URL;以及一个CookieJar实例,用于在请求之间保持 cookie 值。

这个结构体的构造函数接受一个 URL 并构建一个禁用重定向的Client实例:

impl WebApi {
    fn new(url: &str) -> Self {
        let client = Client::builder()
            .redirect(RedirectPolicy::none())
            .build()
            .unwrap();
        Self {
            client,
            url: url.into(),
            jar: CookieJar::new(),
        }
    }
}

我们可以为应用程序的特定微服务创建WebApi实例的快捷方式:

pub fn users() -> Self { WebApi::new(USERS) }
pub fn mailer() -> Self { WebApi::new(MAILER) }
pub fn content() -> Self { WebApi::new(CONTENT) }
pub fn router() -> Self { WebApi::new(ROUTER) }

我们将检查每个微服务是否存活。为此,我们需要一个WebApi的方法,该方法向指定的路径发送GET请求并检查响应:

pub fn healthcheck(&mut self, path: &str, content: &str) {
    let url = url(&self.url, path);
    let mut resp = reqwest::get(&url).unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let text = resp.text().unwrap();
    assert_eq!(text, content);
}

我们的应用程序的每个微服务都有一个特殊的路径来获取微服务的名称,我们将使用它来进行健康检查。

要向微服务发送请求,我们将使用以下函数:

pub fn request<'a, I, J>(&mut self, method: Method, path: &'a str, values: I) -> J
where
    I: IntoIterator<Item = (&'a str, &'a str)>,
    J: for <'de> Deserialize<'de>,
{
    let url = url(&self.url, path);
    let params = values.into_iter().collect::<HashMap<_, _>>();
    let mut resp = self.client.request(method, &url)
        .form(&params)
        .send()
        .unwrap();

    let status = resp.status().to_owned();

    let text = resp
        .text()
        .unwrap();

    if status != StatusCode::OK {
        panic!("Bad response [{}] of '{}': {}", resp.status(), path, text);
    }

    let value = serde_json::from_str(&text);
    match value {
        Ok(value) => value,
        Err(err) => {
            panic!("Can't convert '{}': {}", text, err);
        },
    }
}

这是一个有用的函数,它以 JSON 格式发送请求并接收 JSON 格式的响应,然后将其反序列化为必要的本地结构。这个方法的实现并不疯狂。它期望一个 HTTP 方法、路径以及将用作请求表单参数的值。

如果微服务返回的不是OK的 HTTP 状态,我们使用文本响应来打印值。如果响应成功,我们将从 JSON 格式反序列化主体到必要的输出类型。

由于整个应用程序不会返回内部服务信息给我们,我们需要一个创建请求并检查响应状态码的方法,同时存储 cookie 以便有机会在我们的应用程序中注册和登录。为WebApi结构实现创建check_status方法:

pub fn check_status<'a, I>(&mut self, method: Method, path: &'a str, values: I, status: StatusCode)
where
    I: IntoIterator<Item = (&'a str, &'a str)>,
{
    let url = url(&self.url, path);
    let params = values.into_iter().collect::<HashMap<_, _>>();
    let cookies = self.jar.iter()
        .map(|kv| format!("{}={}", kv.name(), kv.value()))
        .collect::<Vec<_>>()
        .join(";");
    let resp = self.client.request(method, &url)
        .header(COOKIE, cookies)
        .form(&params)
        .send()
        .unwrap();
    if let Some(value) = resp.headers().get(SET_COOKIE) {
        let raw_cookie = value.to_str().unwrap().to_owned();
        let cookie = Cookie::parse(raw_cookie).unwrap();
        self.jar.add(cookie);
    }
    assert_eq!(status, resp.status());
}

上述实现还使用值以表单的形式发送请求,但它还准备 cookie 并在函数期望从服务器收到响应后,通过Cookie头将它们发送出去。如果响应包含SetCookie头,我们使用它来更新我们的CookieJar。通过这样的简单操作,我们使一个可以保持连接会话的方法。

类型

在我们开始实现测试之前,我们需要添加一些我们需要与微服务交互的类型。创建一个types.rs源文件并定义类型:

use serde_derive::Deserialize;
use uuid::Uuid;

现在,添加一个UserId结构体,它将被用来解析来自users微服务的原始响应(是的,我们也将直接测试它):

#[derive(Deserialize)]
pub struct UserId {
    id: Uuid,
}

此外,添加一个Comment结构体,我们将使用它来向我们的内容微服务发布新的评论:

#[derive(Deserialize)]
pub struct Comment {
    pub id: i32,
    pub uid: String,
    pub text: String,
}

现在,我们可以为每个微服务单独编写测试,然后创建一个测试复杂交互的测试。

用户

我们将开始users微服务的测试覆盖率。创建一个users.rs文件并将创建的模块导入其中,使用必要的类型:

mod types;
mod utils;

use self::types::UserId;
use self::utils::{Method, WebApi};

首先,我们必须检查微服务是否存活。添加users_healthcheck方法:

#[test]
fn users_healthcheck() {
    let mut api = WebApi::users();
    api.healthcheck("/", "Users Microservice");
}

它使用users方法创建WebApi结构体的实例,该方法已经配置好了与用户微服务的交互。我们使用healthcheck方法检查必须返回"Users Microservice"字符串的服务根路径。

users微服务的主要目的是新用户的注册以及已注册用户的授权。创建一个check_signup_and_signin函数,该函数将生成一个新用户,通过向/signup路径发送请求来注册它,然后尝试使用/signin路径进行登录:

#[test]
fn check_signup_and_signin() {
    let mut api = WebApi::users();
    let username = utils::rand_str() + "@example.com";
    let password = utils::rand_str();
    let params = vec![
        ("email", username.as_ref()),
        ("password", password.as_ref()),
    ];
    let _: () = api.request(Method::POST, "/signup", params);

    let params = vec![
        ("email", username.as_ref()),
        ("password", password.as_ref()),
    ];
    let _: UserId = api.request(Method::POST, "/signin", params);
}

我们创建了一个新的WebApi实例,该实例针对我们的users微服务。usernamepassword的值是由我们之前创建的utils模块中的rand_str函数调用生成的。之后,我们准备参数来模拟向服务器发送带有POST请求的 HTML 表单。第一个请求注册了一个新用户;第二个请求尝试使用相同的表单参数进行授权。

由于用户微服务被路由器微服务内部使用,它返回一个原始的UserId结构体。我们将解析它,但不会使用它,因为我们已经检查了微服务是否正常工作,因为它不会为无效凭据返回用户的 ID。

内容

我们需要测试的下一个微服务是一个允许用户发布评论的内容微服务。创建一个content.rs文件,并使用必要的类型导入typesutils模块:

mod types;
mod utils;

use self::utils::{Method, WebApi};
use self::types::Comment;

我们还将在content_healthcheck测试中检查该服务是否可用:

#[test]
fn content_healthcheck() {
    let mut api = WebApi::content();
    api.healthcheck("/", "Content Microservice");
}

此服务对于用户能够添加新评论是必要的,并且是松散耦合的(它不需要检查用户是否存在,因为它由路由器微服务保护,免受不存在用户的干扰)。我们将生成一个新的用户 ID 并发送一个请求来发布一条新评论:

#[test]
fn add_comment() {
    let mut api = WebApi::content();
    let uuid = uuid::Uuid::new_v4().to_string();
    let comment = utils::rand_str();
    let params = vec![
        ("uid", uuid.as_ref()),
        ("text", comment.as_ref()),
    ];
    let _: () = api.request(Method::POST, "/new_comment", params);

    let comments: Vec<Comment> = api.request(Method::GET, "/list", vec![]);
    assert!(comments.into_iter().any(|Comment { text, ..}| { text == comment }))
}

我们准备了一个创建新评论的表单,并向/new_comment路径发送了一个POST请求。之后,我们取出一组评论并检查列表中是否存在一个包含生成文本的评论。这意味着评论已被添加,并且内容微服务运行正常。

邮件发送器

我们的应用程序还有一个邮件发送器微服务,该服务向用户发送通知。它只需要utils模块进行测试:

mod utils;

use self::utils::{Method, WebApi};

将前面的代码放入一个新的mailer.rs文件,并添加一个healthcheck来测试微服务实例是否存活:

#[test]
fn mails_healthcheck() {
    let mut api = WebApi::mailer();
    api.healthcheck("/", "Mailer Microservice");
}

邮件发送器微服务也不需要知道用户来通知他们。它只需要一个电子邮件地址和一些内容。这个微服务将向用户发送确认码,所以让我们在我们的send_mail测试中模拟这种行为:

#[test]
fn send_mail() {
    let mut api = WebApi::mailer();
    let email = utils::rand_str() + "@example.com";
    let code = utils::rand_str();
    let params = vec![
        ("to", email.as_ref()),
        ("code", code.as_ref()),
    ];
    let sent: bool = api.request(Method::POST, "/send", params);
    assert!(sent);
}

我们使用mailer函数调用创建了一个WebApi实例,以将客户端指向邮件发送器微服务。之后,我们生成了一个新的电子邮件和代码,并将它们放入一个表单中。微服务返回一个布尔值,表示电子邮件已发送。我们使用asser!宏来检查它是否正确工作。

我们已经用测试覆盖了应用程序的所有微服务,现在我们可以添加一个完整的集成测试,以检查与应用程序的复杂交互。

路由器

创建一个router.rs文件并添加以下模块和类型:

mod types;
mod utils;

use self::utils::{Method, StatusCode, WebApi};
use self::types::Comment;

由于路由器微服务也通过我们用于其他微服务的根路径提供静态文件,为了检查它们是否存活,我们将使用一个特殊的/healthcheck路径,该路径返回该微服务的名称:

#[test]
fn router_healthcheck() {
    let mut api = WebApi::router();
    api.healthcheck("/healthcheck", "Router Microservice");
}

完整的测试是在check_router_full测试中实现的。查看以下代码:

#[test]
fn check_router_full() {
    let mut api = WebApi::router();
    let username = utils::rand_str() + "@example.com";
    let password = utils::rand_str();
    let params = vec![
        ("email", username.as_ref()),
        ("password", password.as_ref()),
    ];
    api.check_status(Method::POST, "/api/signup", params, StatusCode::FOUND);

    let params = vec![
        ("email", username.as_ref()),
        ("password", password.as_ref()),
    ];
    api.check_status(Method::POST, "/api/signin", params, StatusCode::FOUND);

    let comment = utils::rand_str();
    let params = vec![
        ("text", comment.as_ref()),
    ];
    api.check_status(Method::POST, "/api/new_comment", params, StatusCode::FOUND);

    let comments: Vec<Comment> = api.request(Method::GET, "/api/comments", vec![]);
    assert!(comments.into_iter().any(|Comment { text, ..}| { text == comment }))
}

它创建了一个针对路由微服务的 WebApi 实例。之后,它为用户创建随机凭据并在 /api 范围内调用路由的方法。但在这个案例中,我们使用 check_status 方法,因为路由微服务内部创建并保持会话 ID,并返回 cookie 来识别我们。

我们向 /api/signup/api/signin 发送请求以注册用户账户并对其进行授权。之后,我们调用应用程序 API 的 /api/new_comment 方法,通过当前会话的用户发布一条新评论。最后,我们在公开的 /api/comments 端点检查我们的评论是否存在。

我们使用这个集成测试覆盖了应用程序的基本功能,但对于大型应用程序,你也可以检查数据库中的记录、缓存的值和上传的文件,以确保应用程序按预期工作。如果你的微服务工作不正确,并且你找不到原因,你可以尝试使用我们在下一节中将要学习的工具来调试它。

调试微服务

如果你的程序出现错误,你需要调试工具来修复它,我们将在本节中探讨。调试不仅意味着使用调试器进行交互式调试——这是一个特殊的工具,可以帮助你逐步执行程序——你还可以使用日志来跟踪代码的所有活动。为了了解错误的起因,你可以使用以下工具:

  • curl:一个我们已使用的用于发送 HTTP 请求的命令行工具

  • Postman:一个用于测试 REST API 的图形界面工具

  • mitmproxy:一个用于跟踪通过它的所有请求的代理

  • LLDB:一个传统的命令行调试器

  • VS Code:一个具有良好 LLDB 集成的编辑器

让我们探索所有这些。

curl

执行 HTTP 请求最常用的工具是 curl。它是一个具有大量参数的命令行工具。其中一些最有用的如下:

  • --request <METHOD>(或 -X)设置要使用的 HTTP 方法

  • --header "Header: Value"(或 -H)为请求设置一个额外的头信息

  • --data <data>(或 -d)设置请求的主体,并使用 @filename 作为数据值来附加文件内容

  • --form "field=value"(或 -F)设置表单的一个字段

  • --cookie <file>(或 -b)设置一个包含 cookie 的文件以发送

  • --cookie-jar <file>(或 -c)设置一个包含 cookie 的文件以存储

例如,如果你想发送一个带有 JSON 文件的请求,请使用以下命令:

curl -X POST -H "Content-Type: application/json" -d @file.json http://localhost:8080/upload

或者,要发送表单,请使用以下命令:

curl -X POST -F login=user -F password=secret http://localhost:8080/register

如果你想在调用之间保持 cookie,请使用以下代码使用相同的文件读取和写入 cookie 值:-b session.file -c session.file

如果你更喜欢使用图形界面工具,你可以考虑使用 Postman。

Postman

Postman 是一个流行的浏览器扩展,它也作为桌面应用程序提供。你可以从这里获取它:www.getpostman.com/。Postman 最酷的功能之一是你可以分组请求并在请求中使用可配置的变量。

例如,让我们向使用 Docker Compose 启动的应用程序发送一个登录请求。安装 Postman 并创建一个名为 Rust Microservices 的新工作区。输入应用程序的 URL,将方法设置为 POST,并将正文设置为 x-www-form-unlencoded,包含两个参数,emailpassword(用户必须在使用 /signup 之前创建)。点击发送按钮:

图片

如您所见,微服务返回了一个包含名为 auth-example 的值的 cookie 的响应。

但如果我们想使用浏览器执行所有活动,但之后探索发送的请求和响应怎么办?我们可以启动一个跟踪代理。让我们试试。

mitmproxy

mitmproxy 是一个优秀的代理工具,它可以记录所有请求和响应,并且可以作为透明代理、SOCKS5 代理或反向代理工作。当你想通过浏览器与运行中的应用程序交互,但又想记录交互会话的所有请求和响应时,这个工具非常有用。你可以从这里获取这个工具:mitmproxy.org/.

安装一个代理并通过转发到服务器的端口启动它:

mitmweb --mode reverse:http://localhost:7000 --web-port 7777 --listen-port 7780

如您可能已经通过谈论参数所知,我们使用了反向代理模式。它使用 7777 端口提供对 mitmproxy UI 的访问,并使用端口 7780 通过代理连接到我们的应用程序。换句话说,代理将所有来自端口 7780 的请求重定向到我们应用程序的 7000 端口。

在浏览器中打开 127.0.0.1:7777,并在单独的标签页中打开 127.0.0.1:7780,然后尝试与应用程序交互。mitmproxy 网页应用将显示浏览器发出的请求流和响应:

图片

就像 Postman 一样,我们也可以探索 /signin 响应的头部,并查看设置了 auth-example 值的 cookie 头部。

有时,您可能会在应用程序中看到不正确的行为,但找不到代码中的错误。对于这些情况,您可能需要考虑尝试调试器。

LLDB

Rust 对两个调试器——GDB 和 LLDB——有很好的支持。我们在这里尝试第二个。LLDB 是一个现代的命令行调试器。它是 LLVM 项目的组成部分。

让我们尝试查看我们正在工作的路由微服务内部。移动到微服务的目录,并使用 cargo build 命令编译它。然而,你必须确保你没有设置 --release 标志,因为它会删除所有调试信息。如果你不使用 cargo,并想直接使用 rustc 添加调试信息,请添加 -g -C debuginfo=2 参数以保留输出文件中的调试符号。构建完成后,使用命令启动一个支持 Rust 编程语言的调试器:

rust-lldb ./target/debug/router-microservice

如果您使用了 rustup 安装工具安装 Rust,则此命令已经安装。您还将安装 LLDB 调试器到您的机器上。当调试器启动时,它将打印类似以下内容:

(lldb) command script import "/home/user/.rustup/toolchains/nightly-x86_64-unknown-linux-gnu/lib/rustlib/etc/lldb_rust_formatters.py"
(lldb) type summary add --no-value --python-function lldb_rust_formatters.print_val -x ".*" --category Rust
(lldb) type category enable Rust
(lldb) target create "./target/debug/router-microservice"
Current executable set to './target/debug/router-microservice' (x86_64).
(lldb) 

它将提示您输入。

让我们设置 comments 处理器的断点。您可以使用以下命令完成:

breakpoint set --name comments

它会打印出您已设置断点的信息:

Breakpoint 1: where = router-microservice`router_microservice::comments::h50a827d1180e4955 + 7 at main.rs:152, address = 0x00000000001d3c77

现在,我们可以使用此命令启动微服务:

run

它会通知您进程已启动:

Process 10143 launched: '/home/user/sources/Chapter15/deploy/microservices/router/target/debug/router-microservice'

现在,如果您在浏览器中尝试打开 http://localhost:8080/comments URL,那么调试器将中断您设置的断点处的处理器执行,调试器将显示它中断的代码行位置:

   151     fn comments(req: HttpRequest<State>) -> FutureResponse<HttpResponse> {
-> 152         debug!("/api/comments called");
   153         let url = format!("{}/list", req.state().content());
   154         let fut = get_req(&url)
   155             .map(|data| {

在这一点上,您可以探索正在运行的微服务。例如,您可以使用以下命令了解哪些活动线程存在:

thread list

它会显示 actix 的主线程和仲裁线程:

  thread #3: tid = 10147, 0x00007ffff7e939d7 libc.so.6`.annobin_epoll_wait.c + 87, name = 'actix-web accep'
  thread #4: tid = 10148, 0x00007ffff7f88b4d libpthread.so.0`__lll_lock_wait + 29, name = 'arbiter:77596ed'
  thread #5: tid = 10149, 0x00007ffff7f8573c libpthread.so.0`__pthread_cond_wait + 508, name = 'arbiter:77596ed'

要查看当前上下文中可用的变量,您可以使用 frame variable 命令。要将执行移动到下一行代码,使用 next 命令。要继续执行,使用 continue 命令。

使用这个工具,您可以逐步通过有问题的处理器并找到问题的原因。许多开发者更喜欢 GUI 调试器,我们也会尝试一个。

Visual Studio Code

Visual Studio Code 是一个方便的开发者编辑器,拥有许多扩展,包括对 Rust 语言和 LLDB 调试器的支持。让我们尝试使用它。

首先,您必须从这里下载并安装 Visual Studio Code:code.visualstudio.com/。之后,您需要安装两个扩展——rust-lang.rustvadimcn.vscode-lldb。第一个扩展添加 Rust 支持,而第二个将 VS Code 与 LLDB 集成。

与 Rust 的集成基于 Rust 语言服务器RLS)项目,该项目为 IDE 提供有关 Rust 代码的信息。

使用 文件 > 将文件夹添加到工作区... 打开路由微服务项目,并选择包含项目的文件夹。当它打开时,设置一个断点——将光标移动到所需的行并选择 调试 > 切换断点 命令。现在,我们可以使用 调试 | 开始调试 命令开始调试。在第一次运行时,需要一些时间来准备 LLDB,但调试器启动时,会在 输出 选项卡中打印一些信息。

打开浏览器并尝试打开激活我们 comments 处理器断点的 http://localhost:8080/comments URL:

使用顶部的栏移动执行指针到下一行。使用 GUI 调试器,您可以探索变量和调用栈。它简单且实用。但并非所有情况都可以通过调试器修复,因为存在一些类型的错误(称为 海森堡错误),在调试或研究它们时会消失。唯一能帮助这种情况的是日志。

结构化日志

我认为日志是调试的银弹,因为它无处不在——在测试中、在生产服务器上、在云基础设施中。此外,您不必重现产生错误的操作——您可以直接读取工作应用程序的日志来检测问题。有时,您会遇到无法重现的错误,日志可以帮助修复它们。

我们已经在第三章, 日志和配置微服务中学习了日志的基本知识。我们使用了简单的 env_loggerlog crate,但对于大型应用程序来说,可能还不够,因为您需要收集所有日志进行分析,并且从像 JSON 这样的正式格式解析日志更简单。为此存在结构化日志 crate。让我们通过 slog crate 探索使用结构化日志的一个微小示例。

示例

我们将创建一个微小的应用程序,该程序将日志写入文件和控制台。创建一个新的 crate 并添加以下依赖项:

slog = "2.4"
slog-async = "2.3"
slog-json = "2.3"
slog-term = "2.4"

我们需要 slog 作为应用程序的主要日志 crate。slog-async crate 有助于将日志处理移动到单独的线程。slog-json 提供了一个以 JSON 格式写入记录的日志记录器。slog-term 提供了将消息写入终端的格式。

我们将导入以下类型:

use slog::{crit, debug, error, Drain, Duplicate, Level, LevelFilter};
use slog_async::Async;
use slog_term::{CompactFormat, PlainDecorator};
use slog_json::Json;
use std::fs::OpenOptions;
use std::sync::Mutex;

从主 slog crate 中,我们将使用 critdebugerror 宏,这些宏是 log crate 中日志宏的替代品。Drain 是提供日志功能的主要 trait。最终,我们必须创建一个 Drain 实例来记录某些内容。Duplicate 是一种将记录复制到两个 Drain 实例的 DrainLevelLevelFilter 允许我们根据所需的级别过滤记录。

slog-async crate 中,我们将使用 Async 类型,它是一个将记录处理移动到单独线程的 Drain。从 slog-term crate 导入的 PlainDecorator 打印日志而不进行任何着色。我们还导入了 CompactFormat 类型,它是一个将记录以短格式写入的 Drain。从 slog-json crate 中,我们导入了 Json 格式的 Drain,它以 JSON 格式写入日志。

请注意,Drain trait 为包含已实现 Drain trait 的值的 Mutex 类型的值提供了一个默认实现。这允许我们将任何 Drain 包装在 Mutex 中,使其在多线程中使用时更安全。OpenOptions 类型被导入以打开一个文件进行写入并截断其内容。

现在,我们可以添加带有示例日志的 main 函数:

fn main() {
   let log_path = "app.log";
   let file = OpenOptions::new()
      .create(true)
      .write(true)
      .truncate(true)
      .open(log_path)
      .unwrap();

    let drain = Mutex::new(Json::default(file)).fuse();
    let file_drain = LevelFilter::new(drain, Level::Error);

    let decorator = PlainDecorator::new(std::io::stderr());
    let err_drain = CompactFormat::new(decorator).build().fuse();

    let drain_pair = Duplicate::new(file_drain, err_drain).fuse();
    let drain = Async::new(drain_pair).build().fuse();

    let log = slog::Logger::root(drain, slog::o!(
        "version" => env!("CARGO_PKG_VERSION"),
        "host" => "localhost",
        "port" => 8080,
    ));
    debug!(log, "started");
    debug!(log, "{} workers", 2;);
    debug!(log, "request"; "from" => "example.com");
    error!(log, "worker failed"; "worker_id" => 1);
    crit!(log, "server can't continue to work");
}

此函数打开一个文件并创建两个 Drain 实例。第一个是一个用 Mutex 包装的 Json,因为 Json 不是一个线程安全的类型。我们还用 LevelFilter 包装它,以过滤低于 Error 级别的消息。

之后,我们使用PlainDecorator将日志写入stderr流。它实现了Decorator特质,这可以用作创建Drain实例的流。我们用CompactFormat包装它,现在我们有两个Drain实例,我们将它们组合起来。

我们使用Duplicate将记录复制到两个创建的Drain实例,但我们还用Async包装它,以便将正在处理的日志移动到单独的线程。现在,我们可以创建一个Logger实例并填充它关于应用程序的基本信息。

我们使用root方法创建根日志记录器。此方法还可以获取一个值映射,这些值将被添加到记录中。我们使用了o!宏来创建一个映射。之后,我们添加了不同宏的调用,以展示如何使用结构化日志记录器。

任何日志宏都期望一些参数——一个指向Logger实例的引用、一条消息、可选的参数来填充消息,或者一个键值映射,其中包含可以稍后从日志中提取的额外参数。

演示应用程序已经准备好了,我们可以开始测试它。

构建和测试

使用cargo build构建此应用程序,或者如果你想玩代码,可以使用以下命令:

cargo watch --ignore *.log -x run

我们使用cargo-watch工具,但忽略日志文件,因为这些将在应用程序运行时创建。应用程序启动后,你将在终端看到以下记录:

version: 0.1.0
 host: localhost
  port: 8080
   Jan 20 18:13:53.061 DEBG started
   Jan 20 18:13:53.062 DEBG 2 workers
   Jan 20 18:13:53.062 DEBG request, from: example.com
   Jan 20 18:13:53.062 ERRO worker failed, worker_id: 1
   Jan 20 18:13:53.063 CRIT server can't continue to work

正如你所见,在终端输出中没有过滤掉任何带有Debug级别的记录。正如你可能记得的,我们将日志复制到两个Drain实例。第一个将错误写入app.log文件,如果你打开这个文件,你可以看到不包含带有Debug级别的记录的过滤记录:

{"msg":"worker failed","level":"ERRO","ts":"2019-01-20T18:13:53.061797633+03:00","port":8080,"host":"localhost","version":"0.1.0","worker_id":1}
{"msg":"server can't continue to work","level":"CRIT","ts":"2019-01-20T18:13:53.062762204+03:00","port":8080,"host":"localhost","version":"0.1.0"}

这里只有级别高于或等于Error的记录。

在第三章,“日志和配置微服务”,在env_logger crate 的示例中,我们使用了环境变量来配置日志记录器。slog也通过slog_envlogger crate 提供了这个功能。

日志是一个强大的工具,用于追踪单个微服务的每一个动作,但如果你的应用程序由多个微服务组成,理解某些错误发生的原因可能很困难,因为它是受多个因素影响的。为了找到并修复这种最困难的类型的问题,存在分布式追踪。

分布式追踪

分布式追踪有助于收集关于应用程序相关部分的分布式无环图(DAG)信息。你可以使用收集到的信息来分析分布式应用程序中任何活动的路径。

存在一个开放标准——OpenTracing,它被多个产品支持,包括 Jaeger。追踪的最小单元被称为span。Spans 可以与其他 span 建立关系,以构建带有追踪路径的报告。在本节中,我们将编写一个小应用程序,将一些 span 发送到 Jaeger 实例。

启动 Jaeger

首先,我们需要一个可工作的 Jaeger 实例,你可以从包含应用程序所有部分的官方 Docker 镜像中启动它,它甚至被称为 jaegertracing/all-in-one

$ docker run --rm --name jaeger \
 -e COLLECTOR_ZIPKIN_HTTP_PORT=9411 \
 -p 5775:5775/udp \
 -p 6831:6831/udp \
 -p 6832:6832/udp \
 -p 5778:5778 \
 -p 16686:16686 \
 -p 14268:14268 \
 -p 9411:9411 \
 jaegertracing/all-in-one:1.8

打开所有必要的端口,并通过 localhost:16686 访问 Jaeger 的 Web UI。

现在,我们可以编写一个应用程序来与该实例交互。

生成跨度

我们将使用两个 crate 来创建一个测试示例——rustracingrustracing_jaeger。创建一个新的 crate 并将其添加到 Cargo.toml[dependencies] 部分:

rustracing = "0.1"
rustracing_jaeger = "0.1"

将以下依赖项添加到 main.rs 源文件中:

use rustracing::sampler::AllSampler;
use rustracing::tag::Tag;
use rustracing_jaeger::Tracer;
use rustracing_jaeger::reporter::JaegerCompactReporter;
use std::time::Duration;
use std::thread;

AppSampler 实现了 Sampler 特性,该特性用于决定是否对每个新的跟踪进行采样。将采样器视为日志记录器的过滤器,但更智能,可以限制每秒的跟踪数量或使用其他条件。Tag 用于为跨度设置额外的数据。Tracer 是用于创建跨度的主要对象。JaegerCompactReporter 类型用于对跨度进行分组并将它们发送到 Jaeger 实例。

此外,我们还需要一个函数来让当前线程休眠毫秒数:

fn wait(ms: u64) {
    thread::sleep(Duration::from_millis(ms));
}

现在,你可以添加 main 函数,并将示例的第一部分添加到其中:

let (tracer1, span_rx1) = Tracer::new(AllSampler);
let (tracer2, span_rx2) = Tracer::new(AllSampler);
thread::spawn(move || {
    loop {
        {
            let req_span = tracer1
                .span("incoming request")
                .start();
            wait(50);
            {
                let db_span = tracer2
                    .span("database query")
                    .child_of(&req_span)
                    .tag(Tag::new("query", "SELECT column FROM table;"))
                    .start();
                wait(100);
                let _resp_span = tracer2
                    .span("generating response")
                    .follows_from(&db_span)
                    .tag(Tag::new("user_id", "1234"))
                    .start();
                wait(10);
            }
        }
        wait(150);
    }
});

在此代码中,我们完成了跟踪例程的大部分工作。首先,我们创建了两个 Tracer 实例,它们将通过 AllSampler 传递所有值。之后,我们使用 spawn 创建了一个新线程,并创建了一个生成跨度的循环。你必须记住,rustracing crate 使用 Drop 特性的实现将跨度值发送到与 Tracer::new 方法调用一起创建的 Receiver,我们必须丢弃值(我们使用了 Rust 的作用域规则来自动执行丢弃)。

我们使用了存储在 tracer1 变量中的 Tracer 实例,通过 span 方法调用创建了一个跨度。它期望一个跨度的名称,并创建了一个 StartSpanOptions 结构体,该结构体可以用来配置未来的 Span 值。为了配置,我们可以使用 child_of 方法设置一个父级,或者使用 follows_from 方法设置对先前 Span 的引用。我们还可以使用 tag 方法调用设置额外的信息,并提供一个名为 Tag 的键值对,就像我们在结构化日志中做的那样。配置完成后,我们必须调用 StartSpanOptions 实例的 start 方法来创建一个具有设置跨度的起始时间的 Span 实例。使用作用域和跟踪器,我们模拟了应用程序的两个部分:第一个处理请求,第二个执行数据库查询并生成响应,其中第一个是后者的父级。

现在,我们必须使用 SpanReceiver 实例来收集所有丢失的 Span 值。(它们实际上已经被发送到 Receiver。)此外,我们创建了两个带有名称的 JaegerCompactReporter 实例,并在循环中使用 report 方法调用将跨度添加到报告中:

let reporter1 = JaegerCompactReporter::new("router").unwrap();
let reporter2 = JaegerCompactReporter::new("dbaccess").unwrap();
loop {
    if let Ok(span) = span_rx1.try_recv() {
        reporter1.report(&[span]).unwrap();
    }
    if let Ok(span) = span_rx2.try_recv() {
        reporter2.report(&[span]).unwrap();
    }
    thread::yield_now();
}

现在,我们可以编译并运行这个跟踪示例。

编译并运行

要开始这个示例,你可以使用 cargo run 命令。当示例启动时,它将连续产生跨度并将它们发送到正在运行的 Jaeger 实例。你需要等待一小段时间并中断应用程序,否则它将生成过多的跨度。

在浏览器中打开 Jaeger 的 Web UI。在服务字段中选择 router,然后点击查找跟踪按钮以查找相应的跨度。你会看到最近的跟踪,如果你点击其中一个,你将看到跟踪的详细信息:

图片

正如你所见,分布式跟踪记录了我们应用程序的活动,我们可以将其用作日志记录我们应用程序中包含的微服务分布式活动的工具。

摘要

在本章中,我们讨论了许多关于测试和调试微服务的话题。首先,我们考虑了单元测试和集成测试,并看到了一些在 Rust 中使用这些测试的例子。之后,我们探讨了帮助我们调试微服务的工具,包括 curl 和 Postman,用于手动发送请求;mitmproxy 用于跟踪微服务的所有传入请求和传出响应;LLDB 用于探索微服务的运行代码;以及 Visual Studio Code 作为 LLDB 调试器的 GUI 前端。

最后,我们讨论了两种用于最大应用程序的技术,在这些应用程序中,你不能简单地调试:结构化日志和使用 OpenTracing API 的分布式跟踪。

在下一章中,你将了解一些可以用来优化 Rust 微服务的技巧。

第十四章:微服务优化

优化是微服务开发过程中的一个重要部分。通过优化,你可以提高微服务的性能,并降低基础设施和硬件的成本。在本章中,我们将探讨基准测试,以及一些优化技术,例如缓存、使用结构体重用共享数据而不获取所有权,以及如何使用编译器的选项来优化微服务。本章将帮助你通过优化技术来提高你的微服务的性能。

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

  • 性能测量工具

  • 优化技术

技术要求

要运行本章中的示例,你需要一个 Rust 编译器(版本 1.31 及以上)来构建用于测试的示例,以及构建你需要来测量性能的工具。

本章的示例源代码可以在 GitHub 上找到:github.com/PacktPublishing/Hands-On-Microservices-with-Rust/tree/master/Chapter14

性能测量工具

在你决定在微服务中优化某些内容之前,你必须测量其性能。你不应该一开始就编写最优和快速的微服务,因为并非每个微服务都需要良好的性能,如果你的微服务内部存在瓶颈,它将在高负载下遇到困难。

让我们探索一对基准测试工具。我们将探讨用 Rust 编写的工具,因为如果你需要测试具有极高负载的特殊情况,它们可以简单地用来构建你自己的测量工具。

Welle

Welle 是流行的Apache 基准测试工具ab)的替代品,该工具用于基准测试 HTTP 服务器。它可以生成一批请求到指定的 URL,并测量每个请求的响应时间。最后,它收集关于平均响应时间和失败请求数量的统计数据。

要安装此工具,请使用以下命令:

cargo install welle

使用此工具很简单:设置要测试的 URL 和要发送到服务器的请求数量:

welle --num-requests 10000 http://localhost:8080

默认情况下,该工具使用单个线程发送请求并等待响应以发送下一个请求。但你可以通过设置带有所需线程数的--concurrent-requests命令行参数来在更多线程之间分割请求。

当测量完成后,它将打印出类似以下报告:

Total Requests: 10000
Concurrency Count: 1
Total Completed Requests: 10000
Total Errored Requests: 0
Total 5XX Requests: 0

Total Time Taken: 6.170019816s
Avg Time Taken: 617.001µs
Total Time In Flight: 5.47647967s
Avg Time In Flight: 547.647µs

Percentage of the requests served within a certain time:
50%: 786.541µs
66%: 891.163µs
75%: 947.87µs
80%: 982.323µs
90%: 1.052751ms
95%: 1.107814ms
99%: 1.210104ms
100%: 2.676919ms

如果你想要使用除GET之外的 HTTP 方法,你可以使用--method命令行参数来设置它。

Drill

Drill 更为复杂,允许你对微服务进行负载测试。它不仅发送请求批次,还使用测试脚本来生成一系列活动。它帮助你执行一个可以用来测量整个应用程序性能的负载测试。

要安装drill,请使用以下命令:

cargo install drill

安装完成后,你必须配置将要执行的负载测试。创建一个benchmark.yml文件并添加以下负载测试脚本:

---

threads: 4
base: 'http://localhost:8080'
iterations: 5
rampup: 2

plan:
  - name: Index Page
    request:
      url: /

要使用此脚本开始测试,请运行以下命令:

drill --benchmark benchmark.yml --stats

它将通过发送使用脚本中的规则构建的 HTTP 请求来对你的微服务进行负载测试,并打印出如下报告:

Threads 4
Iterations 5
Rampup 2
Base URL http://localhost:8080

Index Page                http://localhost:8080/ 200 OK 7ms
Index Page                http://localhost:8080/ 200 OK 8ms
...
Index Page                http://localhost:8080/ 200 OK 1ms

Concurrency Level 4
Time taken for tests 0.2 seconds
Total requests 20
Successful requests 20
Failed requests 0
Requests per second 126.01 [#/sec]
Median time per request 1ms
Average time per request 3ms
Sample standard deviation 3ms

这两个工具都适合测试你的微服务的性能。如果你想要优化指定的处理器,Welle 适合测量单个请求类型的性能。Drill 适合生成复杂的负载,以测量应用程序可以服务多少用户。

让我们看看一个例子,我们添加一些优化,并使用 Welle 测试差异。

测量和优化性能

在本节中,我们将测量一个使用两种选项编译的示例微服务的性能:没有优化,以及由编译器进行的优化。微服务将向客户端发送渲染后的索引页面。我们将使用 Welle 工具来测量这个微服务的性能,以查看我们是否可以改进它。

基本示例

让我们在基于actix-webcrate 的新 crate 中创建一个微服务。

将以下依赖项添加到Cargo.toml

[dependencies]
actix = "0.7"
actix-web = "0.7"
askama = "0.6"
chrono = "0.4"
env_logger = "0.5"
futures = "0.1"

[build-dependencies]
askama = "0.6"

我们将构建一个微型的服务器,该服务器将以一分钟精度异步渲染当前时间索引页面。事实上,有一个快捷函数可以做到这一点:

fn now() -> String {
    Utc::now().to_string()
}

我们使用askamacrate 来渲染索引页面的模板,并从共享状态中插入当前时间。对于时间值,我们使用String而不是直接从chronocrate 中的类型,以便获得使用内存堆的值:

#[derive(Template)]
#[template(path = "index.html")]
struct IndexTemplate {
    time: String,
}

对于共享状态,我们将使用一个包含last_minute值的结构体,该值以Mutex包裹的String形式表示:

#[derive(Clone)]
struct State {
    last_minute: Arc<Mutex<String>>,
}

如你所记得,Mutex为多个线程提供了对任何类型的并发访问。它锁定用于读取和写入值。

对于索引页面,我们将使用以下处理器:

fn index(req: &HttpRequest<State>) -> HttpResponse {
    let last_minute = req.state().last_minute.lock().unwrap();
    let template = IndexTemplate { time: last_minute.to_owned() };
    let body = template.render().unwrap();
    HttpResponse::Ok().body(body)
}

上一段代码块中显示的处理程序锁定了一个在HttpRequest中可用的State实例的last_minute字段。然后我们使用这个值来填充IndexTemplate结构体并调用render方法来渲染模板,并将生成的值用作新的HttpResponse的正文。

我们使用main函数开始应用程序,该函数准备Server实例并启动一个单独的线程来更新共享的State

fn main() {
    let sys = actix::System::new("fast-service");

    let value = now();
    let last_minute = Arc::new(Mutex::new(value));

    let last_minute_ref = last_minute.clone();
    thread::spawn(move || {
        loop {
            {
                let mut last_minute = last_minute_ref.lock().unwrap();
                *last_minute = now();
            }
            thread::sleep(Duration::from_secs(3));
        }
    });

    let state = State {
        last_minute,
    };
    server::new(move || {
        App::with_state(state.clone())
            .middleware(middleware::Logger::default())
            .resource("/", |r| r.f(index))
    })
    .bind("127.0.0.1:8080")
    .unwrap()
    .start();
    let _ = sys.run();
}

我们在这个示例中使用了 Actix Web 框架。你可以在第十一章中了解更多关于这个框架的信息,使用 Actors 和 Actix Crate 处理并发。这个简单的示例已经准备好编译和启动。我们还将使用 Welle 工具检查这段代码的性能。

性能

首先,我们将使用标准命令不带任何标志来构建和运行代码:

cargo run

我们构建了一个包含大量调试信息的二进制文件,它可以像我们在第十三章中做的那样,与 LLDB 一起使用,进行测试和调试 Rust 微服务。调试符号会降低性能,但我们将检查它,以便与没有这些符号的版本进行比较。

让我们用100000个请求从10个并发活动加载运行中的服务器。由于我们的服务器绑定到了localhost8080端口,我们可以使用以下参数的welle命令来测量性能:

welle --concurrent-requests 10 --num-requests 100000 http://localhost:8080

这需要大约 30 秒(取决于你的系统)并且工具将打印报告:

Total Requests: 100000
Concurrency Count: 10
Total Completed Requests: 100000
Total Errored Requests: 0
Total 5XX Requests: 0

Total Time Taken: 29.883248121s
Avg Time Taken: 298.832µs
Total Time In Flight: 287.14008722s
Avg Time In Flight: 2.8714ms

Percentage of the requests served within a certain time:
50%: 3.347297ms
66%: 4.487828ms
75%: 5.456439ms
80%: 6.15643ms
90%: 8.40495ms
95%: 10.27307ms
99%: 14.99426ms
100%: 144.630208ms

在报告中,你可以看到平均响应时间为 300 毫秒。这是一个负担了调试的服务。让我们重新编译这个示例,并应用优化。在cargo run命令上设置--release标志:

cargo run --release

此命令将-C opt-level=3优化标志传递给rustc编译器。如果你使用不带--release标志的cargo,它将opt-level设置为2

在服务器重新编译并启动后,我们再次使用带有相同参数的 Welle 工具。它报告了其他值:

Total Requests: 100000
Concurrency Count: 10
Total Completed Requests: 100000
Total Errored Requests: 0
Total 5XX Requests: 0

Total Time Taken: 8.010280915s
Avg Time Taken: 80.102µs
Total Time In Flight: 63.961189338s
Avg Time In Flight: 639.611µs

Percentage of the requests served within a certain time:
50%: 806.717µs
66%: 983.35µs
75%: 1.118933ms
80%: 1.215726ms
90%: 1.557405ms
95%: 1.972497ms
99%: 3.500056ms
100%: 37.844721ms

如我们所见,请求的平均处理时间已经降低到超过 70%。结果已经相当不错。但我们能否再降低一些?让我们尝试通过一些优化来实现这一点。

优化

我们将尝试将三种优化应用到我们在上一节中创建的代码中:

  • 我们将尝试减少共享状态阻塞。

  • 我们将通过引用在状态中重用值。

  • 我们将添加响应的缓存。

在我们实现它们之后,我们将检查第一次两个改进后的性能,然后再次使用缓存进行检查。

在本节的代码中,我们将逐步实现所有优化,但如果你从本书的 GitHub 仓库中下载了示例,你将在本章项目的Cargo.toml文件中找到以下特性:

[features]
default = []
cache = []
rwlock = []
borrow = []
fast = ["cache", "rwlock", "borrow"]

这里的代码使用特性为你提供了一个单独激活或禁用任何优化的能力。我们看到以下内容:

  • cache:激活请求的缓存

  • rwlock:使用RwLock代替Mutex进行State

  • borrow:通过引用重用值

让我们实现所有这些优化,并将它们全部应用到性能差异的测量中。

无阻塞状态共享

在第一次优化中,我们将用RwLock替换Mutex,因为Mutex在读取和写入时都会锁定,但RwLock允许我们有一个单独的写入者或多个读取者。它允许我们在没有更新值的情况下避免读取值的阻塞。这适用于我们的示例,因为我们很少更新共享值,但必须从多个处理程序实例中读取它。

RwLockMutex的一个替代品,它将读取者和写入者分开,但RwLock的使用与Mutex一样简单。在State结构中将Mutex替换为RwLock

#[derive(Clone)]
struct State {
    // last_minute: Arc<Mutex<String>>,
    last_minute: Arc<RwLock<String>>,
}

此外,我们必须将创建 last_minute 引用计数器的类型替换为相应的类型:

// let last_minute = Arc::new(Mutex::new(value));
let last_minute = Arc::new(RwLock::new(value));

在工作者的代码中,我们将使用 RwLockwrite 方法锁定值以写入并设置新的时间值。它的独占锁将阻塞所有潜在的读取者和写入者,只有一个写入者可以更改值:

// let mut last_minute = last_minute_ref.lock().unwrap();
let mut last_minute = last_minute_ref.write().unwrap();

由于每个 worker 每三秒会获取一次独占锁,因此增加同时读取的数量是一个小的代价。

在处理器中,我们将使用 read 方法锁定 RwLock 以进行读取:

// let last_minute = req.state().last_minute.lock().unwrap();
let last_minute = req.state().last_minute.read().unwrap();

这段代码不会被其他处理器阻塞,除非 worker 更新值。它允许所有处理器同时工作。

现在我们可以实现第二个改进——避免值的克隆并通过引用使用它们。

通过引用重用值

为了渲染索引页面的模板,我们使用一个带有 String 字段的结构体,并且我们必须填充 IndexTemplate 结构体来调用其上的 render 方法。但是模板需要值的所有权,我们必须克隆它。克隆会消耗时间。为了避免这种 CPU 成本,我们可以使用值的引用,因为如果我们克隆一个使用内存堆的值,我们必须分配新的内存空间并将值的字节复制到新的位置。

这就是我们可以向一个值添加引用的方法:

struct IndexTemplate<'a> {
    // time: String,
    time: &'a str,
}

我们给一个结构体添加了 'a 生命周期,因为我们内部使用了一个引用,而这个结构体不能比我们引用的字符串值活得久。

对于 Future 实例的组合,使用引用并不总是可能的,因为我们必须构建一个生成 HttpResponseFuture,但它的生命周期比调用处理器要长。在这种情况下,如果你拥有它的所有权并使用如 fold 这样的方法将值传递到组合器链的所有步骤中,你可以重用这个值。对于可能消耗大量 CPU 时间的较大值来说,这是很有价值的。

现在我们可以使用对借用 last_minute 值的引用:

// let template = IndexTemplate { time: last_minute.to_owned() };
let template = IndexTemplate { time: &last_minute };

我们之前使用的 to_owned 方法克隆了我们放入 IndexTemplate 的值,但现在我们可以使用引用并完全避免克隆。

我们现在需要做的就是实现缓存,这有助于避免模板渲染。

缓存

我们将使用缓存来存储渲染的模板,并将其作为对后续请求的响应。理想情况下,缓存应该有一个生命周期,因为如果缓存没有更新,那么客户端将看不到页面上的任何更新。但为了我们的演示应用程序,我们不会重置缓存以确保它工作,因为我们的小型微服务渲染时间,我们可以看到它是否冻结。现在我们将向 State 结构体添加一个新字段以保留用于未来响应的渲染模板:

cached: Arc<RwLock<Option<String>>>

我们将使用RwLock,因为我们必须至少更新这个值一次,但对于那些不会更新且可以初始化的值,我们可以使用不带任何包装器的String类型来保护其免受并发访问,例如RwLockMutex。换句话说,如果你只读取它,可以直接使用String类型。

我们还必须使用None初始化值,因为我们需要渲染一次模板以获取缓存值。

State实例添加一个空值:

let cached = Arc::new(RwLock::new(None));
let state = State {
    last_minute,
    cached,
};

现在我们可以使用一个cached值来构建对用户请求的快速响应。但必须考虑到并非所有信息都可以展示给每个用户。缓存可以根据一些关于用户的信息来分隔值,例如,它可以使用位置信息为来自同一国家的用户获取相同的缓存值。以下代码改进了index处理器并接受一个cached值,如果缓存值存在,则使用它来生成一个新的HttpResponse

let cached = req.state().cached.read().unwrap();
if let Some(ref body) = *cached {
    return HttpResponse::Ok().body(body.to_owned());
}

我们立即返回一个cached值,因为已经存储了一个渲染的模板,我们不需要花费时间在渲染上。但如果不存在值,我们可以使用以下代码生成它,并设置cached值:

let mut cached = req.state().cached.write().unwrap();
*cached = Some(body.clone());

之后,我们将返回HttpResponse给客户端的原始代码保留下来。

现在我们已经实现了所有优化并编译了代码,可以测量优化后新版本的性能。

带优化编译

包含了三个优化。我们可以使用其中一些来检查性能差异。首先,我们将使用RwLock编译代码并借用状态值的特性。如果你使用书籍的 GitHub 仓库中的代码,你可以使用带有相应功能名称的--features参数运行必要的优化:

cargo run --release --features rwlock,borrow

一旦服务器准备就绪,我们可以使用 Welle 运行之前用来测量这个微服务性能的相同测试,以测量优化后的服务器版本可以处理多少个传入请求。

测试完成后,工具将打印出如下报告:

Total Requests: 100000
Concurrency Count: 10
Total Completed Requests: 100000
Total Errored Requests: 0
Total 5XX Requests: 0

Total Time Taken: 7.94342667s
Avg Time Taken: 79.434µs
Total Time In Flight: 64.120106299s
Avg Time In Flight: 641.201µs

Percentage of the requests served within a certain time:
50%: 791.554µs
66%: 976.074µs
75%: 1.120545ms
80%: 1.225029ms
90%: 1.585564ms
95%: 2.049917ms
99%: 3.749288ms
100%: 13.867011ms

如您所见,应用程序运行得更快——它只需要79.434微秒,而不是80.10微秒。差异不到 1%,但对于已经运行得很快的处理程序来说是个好成绩。

让我们尝试激活我们实现的全部优化,包括缓存。要使用 GitHub 上的示例这样做,请使用以下参数:

cargo run --release --features fast

服务器准备就绪后,让我们再次开始测试。使用相同的测试参数,我们得到了更好的报告:

Total Requests: 100000
Concurrency Count: 10
Total Completed Requests: 100000
Total Errored Requests: 0
Total 5XX Requests: 0

Total Time Taken: 7.820692644s
Avg Time Taken: 78.206µs
Total Time In Flight: 62.359549787s
Avg Time In Flight: 623.595µs

Percentage of the requests served within a certain time:
50%: 787.329µs
66%: 963.956µs
75%: 1.099572ms
80%: 1.199914ms
90%: 1.530326ms
95%: 1.939557ms
99%: 3.410659ms
100%: 10.272402ms

服务器对请求的响应时间为78.206微秒。这比没有优化的原始版本快了 2%以上,原始版本平均每个请求需要 80.10 微秒。

你可能认为差异不大,但事实上,差异很大。这是一个微小的例子,但试着想象一下优化一个处理程序,该处理程序向数据库发出三个请求,并渲染一个包含要插入的值数组的 200 KB 模板。对于重型处理程序,你可以通过 20%甚至更多的提升来提高性能。但请记住,你应该记住过度优化是一种极端措施,因为它使得代码更难开发,并添加更多功能而不影响已达到的性能。

最好不要将任何优化视为日常任务,因为你可能会花费大量时间优化短小的代码,以获得客户不需要的特性的 2%的性能提升。

优化技术

在上一节中,我们优化了源代码,但还有通过使用特殊的编译标志和第三方工具进行优化的替代技术。在本节中,我们将介绍一些这些优化技术。我们将简要讨论减少大小、基准测试和剖析 Rust 代码。

优化是一个富有创造性的主题。没有特殊的优化秘方,但在这个章节中,我们将创建一个小的微服务,该服务生成一个包含当前时间的索引页面,然后我们将尝试对其进行优化。通过这个例子,我希望向你展示一些适合你项目的优化思路。

链接时间优化

编译器在编译过程中自动执行许多优化,但我们也可以在源代码编译后激活一些优化。这种技术称为链接时间优化LTO),在代码链接和整个程序可用后应用。你可以通过在你的项目中的Cargo.toml文件中添加一个额外的部分来为 Rust 程序激活此优化:

[profile.release]
lto = true

如果你已经用这个优化编译了项目,强制重新构建你的项目。但这个选项也需要更多的时间进行编译。这种优化并不一定能提高性能,但可以帮助减小二进制文件的大小。

如果你激活了所有优化选项,并不意味着你已经制作了应用最快的版本。过多的优化可能会降低程序的性能,你应该将结果与原始版本进行比较。通常,只使用标准的--release选项就能帮助编译器生成一个在编译速度和性能之间达到最佳平衡的二进制文件。

正常的 Rust 程序使用 panic 宏来处理未处理的错误并打印回溯。对于优化,你可以考虑将其关闭。让我们在下一节中看看这个技术。

放弃恐慌

错误处理代码也需要空间,并可能影响性能。如果你尝试编写不会恐慌、会尝试解决问题并且只有在遇到无法解决的问题时才会失败的微服务,你可以考虑使用 abort(立即终止程序而不回绕堆栈),而不是 Rust 的panic

要激活它,请将以下内容添加到你的Cargo.toml文件中:

[profile.release]
panic = "abort"

现在,如果你的程序失败,它不会引发panic,并且会立即停止而不会打印回溯信息。

中断是危险的。如果你的程序被中断,它写日志或向分布式跟踪传递 span 的机会更小。对于微服务,你可以为跟踪创建一个单独的线程,即使主线程失败,也要等待所有可用的跟踪记录被存储。

有时候,你不仅需要提高性能,还必须减小二进制文件的大小。让我们看看如何做到这一点。

减小二进制文件的大小

你可能想要减小二进制文件的大小。通常这并不是必要的,但如果你的分布式应用程序使用了一些空间有限且需要小型二进制文件的硬件,那么这可能会很有用。要减小你编译的应用程序的大小,你可以使用strip命令,这是binutils包的一部分:

strip <path_to_your_binary>

例如,我尝试从本章基本示例部分创建的微服务的编译二进制文件中删除调试符号。使用cargo build命令编译的带有调试符号的二进制文件从 79 MB 减少到 12 MB。使用--release标志编译的版本从 8.5 MB 减少到 4.7 MB。

但请记住,你不能调试经过strip处理的二进制文件,因为工具会移除所有必要的调试信息。

有时候,你可能想要比较一些优化的想法,并测量哪一个更好。你可以使用基准测试来做到这一点。让我们看看cargo提供的基准测试功能。

独立的基准测试

Rust 支持开箱即用的基准测试。你可以用它来比较同一问题的不同解决方案的性能,或者了解应用程序某些部分的执行时间。

要使用基准测试,你必须添加一个带有#[bench]属性的函数。该函数期望有一个对Bencher实例的可变引用。例如,让我们比较克隆一个String与对其取引用:

#![feature(test)]
extern crate test;
use test::Bencher;

#[bench]
fn bench_clone(b: &mut Bencher) {
    let data = "data".to_string();
    b.iter(move || {
        let _data = data.clone();
    });
}

#[bench]
fn bench_ref(b: &mut Bencher) {
    let data = "data".to_string();
    b.iter(move || {
        let _data = &data;
    });
}

要进行基准测试,你必须向Bencher实例的iter方法提供一个你想要测量的代码闭包。你还需要在测试模块中添加带有#![feature(test)]test功能,并使用extern crate test来导入test包,从而从这个模块中导入Bencher类型。

bench_clone函数有一个String值,并且由Bencher在每次测量时克隆它。在bench_ref中,我们取一个String值的引用。

现在,你可以使用cargo启动基准测试:

cargo bench

它会为测试编译代码(带有#[cfg(test)]属性的代码项将被激活),然后运行基准测试。对于我们的示例,我们得到了以下结果:

running 2 tests
test bench_clone ... bench:          32 ns/iter (+/- 9)
test bench_ref   ... bench:           0 ns/iter (+/- 0)

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

如我们所预期,取String的引用不耗时,但String的克隆每次调用clone方法需要32纳秒。

记住,您可以对 CPU 密集型任务进行良好的基准测试,但不能对 I/O 密集型任务进行基准测试,因为 I/O 任务更多地依赖于硬件质量和操作系统性能。

如果您想基准测试运行应用程序中某些函数的操作,那么您必须使用分析器。让我们尝试使用分析器分析一些代码。

分析

基准测试对于检查代码的一部分很有用,但它们不适合检查运行应用程序的性能。如果您需要探索代码中某些函数的性能,您必须使用分析器。

分析器会导出有关代码和函数执行的信息,并记录代码工作的时间跨度。Rust 生态系统中有一种名为 flame 的分析器。让我们探索如何使用它。

分析需要时间,您应该将其作为一个功能来避免在生产安装中影响性能。将 flame 包添加到您的项目中,并作为可选使用。添加一个功能(例如来自 flamer 包仓库的官方示例;我将其命名为 flame_it),并将 flame 依赖项添加到其中:

[dependencies]
flame = { version = "0.2", optional = true }

[features]
default = []
flame_it = ["flame"]

现在,如果您想激活分析,您必须使用 flame_it 功能编译项目。

使用 flame 包非常简单,包括三个场景:

  • 直接使用 startend 方法。

  • 使用 start_guard 方法,它创建一个用于测量执行时间的 Span。当 Span 实例被丢弃时,自动结束测量。

  • 使用 span_of 来测量在闭包中隔离的代码。

我们将像在 第十三章 的 OpenTracing 示例中所做的那样使用跨度:测试和调试 Rust 微服务

use std::fs::File;

pub fn main() {
    {
        let _req_span = flame::start_guard("incoming request");
        {
            let _db_span = flame::start_guard("database query");
            let _resp_span = flame::start_guard("generating response");
        }
    }

    flame::dump_html(&mut File::create("out.html").unwrap()).unwrap();
    flame::dump_json(&mut File::create("out.json").unwrap()).unwrap();
    flame::dump_stdout();
}

您不需要收集跨度或将它们发送到 Receiver,就像我们为 Jaeger 所做的那样,但使用 flame 进行分析看起来像是跟踪。

在执行结束时,您必须以适当的格式导出报告,例如 HTML 或 JSON,将其打印到控制台,或写入 Writer 实例。我们使用了前三个。我们实现了主函数,并使用 start_quard 方法创建 Span 实例来测量一些代码片段的执行时间。之后,我们将编写报告。

使用已激活的分析功能的示例编译并运行此示例:

cargo run --features flame_it

前面的命令编译并打印报告到控制台:

THREAD: 140431102022912
| incoming request: 0.033606ms
  | database query: 0.016583ms
    | generating response: 0.008326ms
    + 0.008257ms
  + 0.017023ms

如您所见,我们创建了三个跨度。您也可以在文件中找到两个报告,out.jsonout.html。如果您在浏览器中打开 HTML 报告,它将呈现如下:

图片

在前面的屏幕截图中,您可以看到我们程序每个活动的相对执行时间。颜色较深的块表示执行时间较长。如您所见,分析对于找到可以优化其他技术的慢速代码部分很有用。

摘要

在本章中,我们讨论了优化。首先,我们探索了用于测量性能的工具——Welle,它是经典Apache 基准测试工具的替代品,以及 Drill,它使用脚本执行负载测试。

然后我们创建了一个微小的微服务并测量了其性能。专注于结果,我们对那个微服务进行了一些优化——我们避免了在读取共享状态时阻塞,我们通过引用重用了一个值而不是克隆它,我们还增加了渲染模板的缓存。然后我们测量了优化后的微服务的性能,并将其与原始版本进行了比较。

在本章的最后部分,我们了解了优化的一些替代技术——使用 LTO,在无需回溯的情况下终止执行而不是恐慌,减小编译二进制文件的大小,对代码的小片段进行基准测试,以及使用性能分析为你的项目服务。

在下一章中,我们将探讨使用 Docker 创建带有 Rust 微服务的镜像,以在预配置的环境中运行微服务,从而加快产品交付给客户的速度。

第十五章:将服务器打包到容器中

使用 Rust 创建的微服务部署起来相当简单:只需为你的服务器构建一个二进制文件,将该二进制文件上传到服务器,然后启动它即可。但这并不是真实应用中的灵活方法。首先,你的微服务可能需要文件、模板和配置。另一方面,你可能希望使用不同操作系统的服务器。在这种情况下,你将不得不为每个系统构建一个二进制文件。为了减少部署中出现的问题,现代微服务被打包到容器中,并使用虚拟化来启动。虚拟化有助于简化一组微服务的部署。此外,它还可以帮助扩展微服务,因为要运行微服务的额外实例,你只需启动另一个容器副本即可。

本章将带你沉浸于使用 Rust 微服务构建 Docker 镜像的过程。我们将探讨以下内容:

  • 使用 Docker 编译微服务。

  • 在容器中准备必要的 Rust 版本。

  • 减少使用 Rust 微服务构建镜像的时间。在我们准备好镜像后,我们将为多个微服务创建镜像。

  • 为 Docker Compose 工具创建一个 compose 文件,以引导一组微服务,展示如何运行由多个相互交互的微服务组成的复杂项目。

  • 配置一组微服务并添加一个数据库实例,以便这些微服务可以将持久状态存储到数据库中。

技术要求

本章需要完整安装 Docker 并使用 Docker Compose 工具。由于我们将使用 Docker 容器构建微服务,因此不需要 Rust 编译器,但如果你想在本地构建和测试任何微服务或在不修改 docker-compose.yml 文件的情况下调整配置参数,拥有 nightly Rust 编译器会更好。

要安装 Docker,请遵循此处针对您操作系统的说明:docs.docker.com/compose/install/

要安装 Docker Compose 工具,请查看以下文档:docs.docker.com/compose/install/

你可以在 GitHub 项目的 Chapter15 文件夹中找到本章的示例:github.com/PacktPublishing/Hands-On-Microservices-with-Rust-2018/

使用微服务构建 Docker 镜像

在本章的第一部分,我们将构建一个带有必要版本的 Rust 编译器的 Docker 镜像,并构建一个带有编译微服务的镜像。我们将使用来自其他章节的一组微服务来展示如何将使用不同框架创建的微服务连接起来。我们将使用来自 第九章 的 Simple REST Definition and Request Routing with Frameworksemailscontent 微服务,以及来自 第十一章 的 Involving Concurrency with Actors and Actix Craterouter 微服务,并且我们将调整它们以使其可配置。此外,我们还将添加一个 dbsync 微服务,它将对数据库执行所有必要的迁移,因为我们将会使用两个使用 diesel crate 的数据库的微服务,如果两个微服务都尝试为其自己的模式应用迁移,将会发生冲突。那是因为我们将使用单个数据库,但如果你为每个微服务使用单独的数据库(不一定是不同的数据库管理应用程序,但只是数据库文件),你可以为每个数据库使用单独的迁移集。现在是准备带有夜间 Rust 编译器的镜像的时候了。

使用 Rust 编译器创建镜像

Docker Hub 上有许多现成的镜像。你还可以在这里找到官方镜像:hub.docker.com/_/rust/。但我们将创建自己的镜像,因为官方镜像只包含稳定的编译器版本。如果你觉得足够用,使用官方镜像会更好,但如果你使用像 diesel 这样的需要 Rust 编译器夜间版本的 crate,你将不得不构建自己的镜像来构建微服务。

创建一个新的 Dockerfile 并向其中添加以下内容:

FROM buildpack-deps:stretch

ENV RUSTUP_HOME=/usr/local/rustup \
    CARGO_HOME=/usr/local/cargo \
    PATH=/usr/local/cargo/bin:$PATH

RUN set -eux; \
    url="https://static.rust-lang.org/rustup/dist/x86_64-unknown-linux-gnu/rustup-init"; \
    wget "$url"; \
    chmod +x rustup-init; \
    ./rustup-init -y --no-modify-path --default-toolchain nightly; \
    rm rustup-init; \
    chmod -R a+w $RUSTUP_HOME $CARGO_HOME; \
    rustup --version; \
    cargo --version; \
    rustc --version;

我从官方的 Rust Docker 镜像中借用了这个 Dockerfile,位于此处:github.com/rust-lang-nursery/docker-rust-nightly/blob/master/nightly/Dockerfile。这个文件是使用 Rust 编译器创建镜像时良好实践的起点。

我们的 Rust 镜像是基于 buildpack-deps 镜像的,它包含了开发者常用到的所有必要依赖。这个依赖在第一行通过 FROM 命令指示。

buildpack-deps 是基于 Ubuntu(基于 Debian 的免费开源 Linux 发行版)的官方 Docker 镜像。该镜像包含 OpenSSL 和 curl 等库的大量头文件,以及包含所有必要证书的软件包等。它作为 Docker 镜像的构建环境非常有用。

下一个包含 ENV 命令的行,在镜像中设置了三个环境变量:

  • RUSTUP_HOME:设置 rustup 工具的根文件夹,其中包含配置并安装工具链

  • CARGO_HOME:包含 cargo 工具使用的缓存文件

  • PATH:包含可执行二进制文件路径的系统环境变量

我们通过设置这些环境变量将所有实用程序目标文件夹设置为/usr/local

我们在这里使用rustup实用程序来初始化 Rust 环境。它是一个官方的 Rust 安装工具,可以帮助您维护和保持多个 Rust 安装的更新。在我看来,使用rustup是本地或容器中安装 Rust 的最佳方式。

最后一个Dockerfile命令,RUN,很复杂,我们将逐行分析这组命令。第一个 shell 命令如下:

set -eux

由于 Ubuntu 的默认 shell 是 Bash shell,我们可以设置三个有用的标志:

  • -e:此标志告诉 shell 仅在上一条命令成功完成后才运行下一条(命令)

  • -u:使用此标志,如果命令尝试展开未设置的变量,shell 将打印错误到stderr

  • -x:使用此标志,shell 将在运行之前将每个命令打印到stderr

接下来的三行下载rustup-init二进制文件,并将可执行标志设置为下载的文件:

url="https://static.rust-lang.org/rustup/dist/x86_64-unknown-linux-gnu/rustup-init"; \
wget "$url"; \
chmod +x rustup-init; \

下一个对运行rustup-init命令带有参数,并在运行后删除二进制文件的配对:

./rustup-init -y --no-modify-path --default-toolchain nightly; \
rm rustup-init; \

以下标志被使用:

  • -y:抑制任何确认提示

  • --no-modify-path:不会修改PATH环境变量(我们之前手动设置过,用于镜像)

  • --default-toolchain:默认工具链的类型(我们将使用nightly

剩余的行设置RUSTUP_HOMECARGO_HOME文件夹的写权限,并打印所有已安装工具的版本:

chmod -R a+w $RUSTUP_HOME $CARGO_HOME; \
rustup --version; \
cargo --version; \
rustc --version;

现在,您可以构建Dockerfile以获取包含预配置 Rust 编译器的镜像:

docker build -t rust:nightly  .

此命令需要一些时间才能完成,但完成后,您将拥有一个可以用于构建微服务镜像的镜像。如果您输入docker images命令,您将看到如下内容:

REPOSITORY   TAG       IMAGE ID       CREATED             SIZE
rust         nightly   91e52fb2cea5   About an hour ago   1.67GB

现在,我们将使用标记为rust:nightly的镜像,并从中创建微服务的镜像。让我们先为用户微服务创建一个镜像。

用户微服务镜像

用户微服务为用户提供注册功能。本章包含来自第九章的修改版用户微服务,使用框架进行简单的 REST 定义和请求路由。由于此服务需要数据库并使用diesel包与之交互,我们需要在构建镜像的过程中使用diesel.toml配置文件。

.dockerignore

由于 Docker 会复制构建文件夹中的所有文件,我们必须添加包含要避免复制的路径模式的.dockerignore文件。例如,跳过target构建文件夹是有用的,因为它可能包含大型项目数 GB 的数据,但无论如何,我们不需要所有这些,因为我们将会使用带有 Rust 编译器的镜像来构建微服务。添加.dockerignore文件:

target
 Cargo.lock
 **/*.rs.bk
 files
 *.db

在下一章中,我们将忽略所有 Rust 的构建工件(例如targetCargo.lockrustfmt工具产生的*.bk文件等),我们将探索持续集成工具。我们还包含了两个模式:files——如果尝试在本地运行,这个文件夹将由微服务创建来存储文件,*.db——对于 SQLite 数据库来说,这不是一个必要的模式,因为此版本使用的是 PostgreSQL 而不是 SQLite,但如果以后出于测试原因想要支持两种数据库,则很有用。

Dockerfile

现在一切准备就绪,可以构建并将微服务打包到镜像中。为此,将Dockerfile文件添加到包含微服务的文件夹中,并在其中添加以下行:

FROM rust:nightly

RUN USER=root cargo new --bin users-microservice
WORKDIR /users-microservice
COPY ./Cargo.toml ./Cargo.toml
RUN cargo build

RUN rm src/*.rs
COPY ./src ./src
COPY ./diesel.toml ./diesel.toml
RUN rm ./target/debug/deps/users_microservice*
RUN cargo build

CMD ["./target/debug/users-microservice"]

EXPOSE 8000

我们基于本章早期创建的rust:nightly镜像创建了该镜像。我们使用FROM命令设置了它。下一行创建了一个新的 crate:

RUN USER=root cargo new --bin users-microservice

你可能会问我们为什么这样做而没有使用现有的 crate。那是因为我们将在容器内部重现 crate 的创建过程,首先构建依赖项,以避免在添加任何微服务源代码的微小更改时重建它们的漫长过程。这种方法将为你节省大量时间。将Cargo.toml复制到镜像中,并构建所有依赖项,而不包括微服务的源代码(因为我们还没有复制它们):

WORKDIR /users-microservice
COPY ./Cargo.toml ./Cargo.toml
RUN cargo build

下一个命令集将源代码和diesel.toml文件添加到镜像中,删除之前的构建结果,并使用新源代码重新构建 crate:

RUN rm src/*.rs
COPY ./src ./src
COPY ./diesel.toml ./diesel.toml
RUN rm ./target/debug/deps/users_microservice*
RUN cargo build

在这个时刻,这个镜像包含了一个微服务的二进制文件,我们可以将其用作启动容器的起始命令:

CMD ["./target/debug/users-microservice"]

默认情况下,容器不会打开端口,你不能通过另一个容器连接到它,也不能将容器的端口转发到本地端口。由于我们的微服务从端口 8000 开始,我们必须使用以下命令来暴露它:

EXPOSE 8000

镜像已准备好构建和运行容器。让我们开始吧。

构建镜像

我们已经准备好了 Dockerfile 来构建一个镜像,该镜像首先构建我们微服务的所有依赖项,然后构建所有源代码。要启动这个过程,你必须使用 Docker 的build命令:

docker build -t users-microservice:latest 

当你运行这个命令时,你会看到 Docker 如何准备文件来构建镜像,并构建所有依赖项,但只针对没有微服务源代码的空 crate:

Sending build context to Docker daemon  13.82kB
Step 1/12 : FROM rust:nightly
 ---> 91e52fb2cea5
Step 2/12 : RUN USER=root cargo new --bin users-microservice
 ---> Running in 3ff6b18a9c72
     Created binary (application) `users-microservice` package
Removing intermediate container 3ff6b18a9c72
 ---> 85f700c4a567
Step 3/12 : WORKDIR /users-microservice
 ---> Running in eff894de0a40
Removing intermediate container eff894de0a40
 ---> 66366486b1e2
Step 4/12 : COPY ./Cargo.toml ./Cargo.toml
 ---> 8864ae055d16
Step 5/12 : RUN cargo build
 ---> Running in 1f1150ae4661
    Updating crates.io index
 Downloading crates ...
 Compiling crates ...
   Compiling users-microservice v0.1.0 (/users-microservice)
    Finished dev [unoptimized + debuginfo] target(s) in 2m 37s
Removing intermediate container 1f1150ae4661
 ---> 7868ea6bf9b3

我们的镜像总共需要 12 个步骤来构建微服务。正如你所见,依赖项的构建需要两分半钟。这并不快。但我们不需要重复这个步骤,直到Cargo.toml发生变化。接下来的步骤将微服务的源代码复制到容器中,并使用预构建的依赖项构建它们:

Step 6/12 : RUN rm src/*.rs
 ---> Running in 5b7d9a1f96cf
Removing intermediate container 5b7d9a1f96cf
 ---> b03e7d0b23cc
Step 7/12 : COPY ./src ./src
 ---> 2212e3db5223
Step 8/12 : COPY ./diesel.toml ./diesel.toml
 ---> 5d4c59d31614
Step 9/12 : RUN rm ./target/debug/deps/users_microservice*
 ---> Running in 6bc9df93ebc1
Removing intermediate container 6bc9df93ebc1
 ---> c2e3d67d3bf8
Step 10/12 : RUN cargo build
 ---> Running in b985b6c793d1
   Compiling users-microservice v0.1.0 (/users-microservice)
    Finished dev [unoptimized + debuginfo] target(s) in 4.98s
Removing intermediate container b985b6c793d1
 ---> 553156f97943
Step 11/12 : CMD ["./target/debug/users-microservice"]
 ---> Running in c36ff8e44db3
Removing intermediate container c36ff8e44db3
 ---> 56e7eb1144aa
Step 12/12 : EXPOSE 8000
 ---> Running in 5e76a47a0ded
Removing intermediate container 5e76a47a0ded
 ---> 4b6fc8aa6f1b
Successfully built 4b6fc8aa6f1b
Successfully tagged users-microservice:latest

如输出所示,构建微服务只需 5 秒钟。这足够快,你可以根据需要重新构建它多次。由于镜像已经构建,我们可以启动一个包含微服务的容器。

启动容器

我们构建的镜像已经存储在 Docker 中,我们可以使用docker images命令查看它:

REPOSITORY           TAG       IMAGE ID       CREATED         SIZE
users-microservice   latest    4b6fc8aa6f1b   7 minutes ago   2.3GB
rust                 nightly   91e52fb2cea5   3 hours ago     1.67GB

要从镜像中启动微服务,请使用以下命令:

docker run -it --rm -p 8080:8000 users-microservice

带有微服务实例的容器将启动,但它不会工作,因为我们还没有运行一个带有数据库实例的容器。我们不会手动连接容器,因为这属于 Docker 使用的细微之处,你可以在 Docker 的文档中了解更多信息;然而,我们将在本章后面的组合微服务集部分学习如何使用 Docker Compose 工具连接容器。

你可能也会问:为什么我们的微服务这么庞大?我们将在本章后面尝试减小它。但现在我们应该将其他微服务打包到镜像中。

内容微服务镜像

我们将要使用的第二个微服务是我们在第九章,使用框架进行简单的 REST 定义和请求路由中创建的内容微服务。我们还为使用 PostgreSQL 数据库准备了这项服务。我们从上一个示例中借用了dockerignore文件,并为此微服务修改了Dockerfile文件。请看以下代码:

FROM rust:nightly

RUN USER=root cargo new --bin content-microservice
WORKDIR /content-microservice
COPY ./Cargo.toml ./Cargo.toml
RUN cargo build

RUN rm src/*.rs
COPY ./src ./src
RUN rm ./target/debug/deps/content_microservice*
RUN cargo build

CMD ["./target/debug/content-microservice"]
EXPOSE 8000

如你所见,这个Dockerfile与上一个镜像的Dockerfile相同,但它有一个区别:它没有复制任何配置文件。我们将使用 Rocket 框架,但我们将使用 Docker Compose 文件中的环境变量设置所有参数。

你可以使用以下命令构建此镜像以检查其工作情况:

 docker build -t content-microservice:latest .

但构建这个镜像并不是必要的,因为我们不会手动启动容器——我们将使用 Docker Compose。让我们也将一个邮件微服务打包到镜像中。

邮件微服务镜像

邮件微服务不使用dieselcrate,我们可以使用官方的 Rust 镜像来构建微服务。此外,邮件微服务有模板,用于准备电子邮件的内容。我们将使用相同的.dockerignore文件,但会从上一个示例中复制Dockerfile并添加一些与邮件微服务相关的更改:

FROM rust:1.30.1

RUN USER=root cargo new --bin mails-microservice
WORKDIR /mails-microservice
COPY ./Cargo.toml ./Cargo.toml
RUN cargo build

RUN rm src/*.rs
COPY ./src ./src
COPY ./templates ./templates
RUN rm ./target/debug/deps/mails_microservice*
RUN cargo build

CMD ["./target/debug/mails-microservice"]

我们是从rust:1.30.1镜像创建了这个镜像。编译器的稳定版本适合编译这个简单的微服务。我们还添加了一个命令,将所有模板复制到镜像中:

COPY ./templates ./templates

现在,我们可以准备带有路由微服务的镜像。

路由微服务镜像

如果你记得,我们在第十一章,使用 Actors 和 Actix Crate 涉及并发中创建了路由微服务,我们探索了 Actix 框架的功能。我们将路由微服务修改为与其他微服务一起工作——我们添加了一个Config和一个State,它们与处理器共享配置值。此外,改进后的路由微服务还服务于静态文件夹中的资源。我们还需要将这个文件夹复制到镜像中。请看路由微服务的Dockerfile

FROM rust:1.30.1

RUN USER=root cargo new --bin router-microservice
WORKDIR /router-microservice
COPY ./Cargo.toml ./Cargo.toml
RUN cargo build

RUN rm src/*.rs
COPY ./src ./src
COPY ./static ./static
RUN rm ./target/debug/deps/router_microservice*
RUN cargo build

CMD ["./target/debug/router-microservice"]

EXPOSE 8000

我们还使用了官方的 Rust 镜像和稳定的编译器。与之前的例子相比,你将注意到的唯一区别是将static文件夹复制到镜像中。我们使用了与之前例子相同的.dockerignore文件。

我们已经为所有微服务构建了镜像,但我们还需要添加一个将迁移应用到数据库的工作者。我们稍后将会使用 Docker Compose 来自动应用所有迁移。让我们创建这个 Docker 镜像。

DBSync 工作者镜像

DBSync 工作者的唯一功能是等待与数据库的连接并应用所有迁移。我们也将这个工作者打包到 Docker 镜像中,以便在下一节中我们将创建的 compose 文件中使用。

依赖项

工作者需要以下依赖项:

clap = "2.32"
config = "0.9"
diesel = { version = "¹.1.0", features = ["postgres", "r2d2"] }
diesel_migrations = "1.3"
env_logger = "0.6"
failure = "0.1"
log = "0.4"
postgres = "0.15"
r2d2 = "0.8"
serde = "1.0"
serde_derive = "1.0"

我们需要diesel crate 的diesel_migrations来将所有迁移嵌入到代码中。这不是必需的,但很有用。我们需要configserde crate 来配置工作者。其他 crate 更常见,你可以在之前的章节中看到我们如何使用它们。

将这些依赖项添加到Cargo.toml并导入在main函数中将使用的类型:

use diesel::prelude::*;
use diesel::connection::Connection;
use failure::{format_err, Error};
use log::debug;
use serde_derive::Deserialize;

现在让我们创建一段代码,它将等待数据库连接并应用所有嵌入的迁移。

主函数

在创建主函数之前,我们必须使用embed_migrations!宏调用嵌入迁移:

embed_migrations!();

这个调用创建了一个embedded_migrations模块,其中包含一个run函数,该函数将所有迁移应用到数据库。但在我们使用它之前,让我们添加Config结构体来从配置文件或环境变量中读取数据库连接链接,使用config crate:

#[derive(Deserialize)]
struct Config {
    database: Option<String>,
}

这个结构体只包含一个参数——一个可选的String类型的数据库连接链接。我们稍后将会使用 Docker Compose 设置这个参数,通过DBSYNC_DATABASE环境变量。我们在main函数中添加了DBSYNC前缀。查看main函数的完整代码:

fn main() -> Result<(), Error> {
    env_logger::init();
    let mut config = config::Config::default();
    config.merge(config::Environment::with_prefix("DBSYNC"))?;
    let config: Config = config.try_into()?;
    let db_address = config.database.unwrap_or("postgres://localhost/".into());
    debug!("Waiting for database...");
    loop {
        let conn: Result<PgConnection, _> = Connection::establish(&db_address);
        if let Ok(conn) = conn {
            debug!("Database connected");
            embedded_migrations::run(&conn)?;
            break;
        }
    }
    debug!("Database migrated");
    Ok(())
}

在前面的代码中,我们初始化了env_logger以将信息打印到 stderr。之后,我们从一个config模块创建了一个通用的Config实例,并使用DBSYNC前缀合并环境变量。如果配置合并成功,我们尝试将其转换为之前声明的我们自己的Config类型的值。我们将使用配置来提取数据库连接的链接。如果没有提供值,我们将使用postgres://localhost/链接。

当连接链接就绪时,我们使用循环尝试连接到数据库。我们将尝试连接,直到成功为止,因为我们将会使用这个工作者与 Docker Compose 一起使用,尽管我们将启动一个包含数据库的容器,但在数据库实例启动时,它可能不可用。我们使用循环等待连接就绪。

当连接就绪时,我们使用它通过embedded_migrations模块的run方法应用内嵌迁移。在迁移应用后,我们打破循环并停止工作进程。

我们已经准备好了所有微服务以供启动,但它们的缺点是它们的源代码也保留在镜像中。如果我们想隐藏微服务的实现细节,这就不太好了。让我们探索一种使用镜像构建缓存隐藏微服务源的技术。

隐藏微服务源代码

在镜像内构建微服务的主要缺点是,所有源代码和构建工件都将对任何有权访问 Docker 镜像的人可用。如果你想删除源代码和其他构建工件,你可以使用两种方法之一。

  1. 第一种方法是通过带有 Rust 编译器的 Docker 镜像构建所有源代码,通过链接虚拟卷提供对源代码的访问。在 Docker 中,你可以使用docker run命令的-v参数将任何本地文件夹映射到容器内的一个卷。这种方法的不利之处在于,Docker 在容器内使用另一个 ID,这与你在本地会话中的 ID 不同。它可能会创建你无法删除的文件,除非更改用户 ID。此外,这种方法更难维护。但如果只需要编译结果,它是有用的。如果你计划在容器内运行微服务,最好在镜像内构建一切。

  2. 第二种方法涉及使用 Docker 构建一切,但使用构建缓存来获取编译结果并将其放入新创建的容器中。让我们探索实现此方法的Dockerfile

FROM rust:nightly as builder

RUN USER=root cargo new --bin dbsync-worker
WORKDIR /dbsync-worker
COPY ./Cargo.toml ./Cargo.toml
RUN cargo build

RUN rm src/*.rs
COPY ./src ./src
COPY ./migrations ./migrations
COPY ./diesel.toml ./diesel.toml
RUN rm ./target/debug/deps/dbsync_worker*
RUN cargo build

FROM buildpack-deps:stretch

COPY --from=builder /dbsync-worker/target/debug/dbsync-worker  /app/
ENV RUST_LOG=debug
EXPOSE 8000
ENTRYPOINT ["/app/dbsync-worker"]

我们使用了 dbsync 微服务的Dockerfile,文件的第一部分与原始文件相同,但有一个小的改进——我们将该名称设置为我们在第一行构建的镜像:

FROM rust:nightly as builder

现在,我们可以使用builder名称来使用镜像的缓存数据。

在本节之后,我们从一个原本用于构建前面的rust:nightly镜像的buildpack-deps镜像开始创建一个新的空镜像。我们使用带有--from参数的COPY命令从构建镜像中复制一个可执行二进制文件,其中我们设置了镜像的名称:

COPY --from=builder /dbsync-worker/target/debug/dbsync-worker  /app/

此命令将二进制文件复制到镜像内部的/app文件夹中,我们可以将其用作容器的入口点:

ENTRYPOINT ["/app/dbsync-worker"]

我们还设置了RUST_LOG环境变量并公开了端口。通过传递此Dockerfile的名称并使用 Docker 构建命令的-f参数来构建此镜像,你将得到一个包含微服务单个二进制文件的镜像。换句话说,这种方法允许我们构建微服务并重新使用编译的二进制文件来创建新镜像。你现在已经知道足够的信息来将微服务打包到镜像中,现在我们可以探索 Docker Compose 启动一组微服务并将所有启动的容器相互连接的能力。

组合微服务集

Docker Compose 是一个部署和运行一组可以相互连接的微服务的出色工具。它帮助你在可读的 YAML 文件中定义具有配置参数的多容器应用程序。你不仅限于本地部署,还可以在 Docker 守护进程也在运行的远程服务器上部署它。

在本章的这一节中,我们将所有包含数据库的微服务打包成一个单一的应用程序。你将学习如何为 Rust 框架和日志记录器设置变量,如何连接微服务,如何定义启动容器的顺序,如何读取运行中的应用程序的日志,以及如何为测试和生产使用不同的配置。

应用程序定义

Docker Compose 是一个与应用程序的 YAML 定义一起工作的工具。一个 YAML 文件可以包含容器、网络和卷的声明。我们将使用版本3.6。创建一个docker-compose.test.yml文件并添加以下部分:

version: "3.6"
services:
    # the place for containers definition

services部分,我们将添加所有我们的微服务。让我们看看每个容器配置。

数据库容器

我们的应用程序需要一个数据库实例。用户和内容微服务都使用 PostgreSQL 数据库,dbsync 工作器在必要时应用所有迁移。查看以下设置:

db:
  image: postgres:latest
  restart: always
  environment:
    - POSTGRES_USER=postgres
    - POSTGRES_PASSWORD=password
  ports:
    - 5432:5432

我们使用官方的 PostgreSQL 镜像。如果数据库失败,它也需要重启。我们将restart策略设置为always,这意味着如果容器失败,它将被重启。我们还使用环境变量设置了用户名和密码。

由于我们创建了用于测试目的的 compose 文件,我们将容器的一个端口转发到外部,以便使用本地客户端连接到数据库。

一个带有电子邮件服务器的容器

我们需要 SMTP 服务器来为我们的邮件服务。我们使用带有 Postfix 邮件服务器的juanluisbaptiste/postfix镜像。如果服务器失败,它也需要重启,我们将restart策略设置为always。查看以下代码:

smtp:
  image: juanluisbaptiste/postfix
  restart: always
  environment:
    - SMTP_SERVER=smtp.example.com
    - SMTP_USERNAME=admin@example.com
    - SMTP_PASSWORD=password
    - SERVER_HOSTNAME=smtp.example.com
  ports:
    - "2525:25"

我们还使用环境变量配置服务器,并设置了服务器名称、用户名、密码和主机名。为了测试邮件服务器,我们将邮件服务器的端口25转发到本地的2525端口。

DBSync 工作容器

现在我们可以添加应用到数据库实例的 dbsync 工作器。我们使用一个本地镜像,该镜像将使用./microservices/dbsync文件夹中的Dockerfile构建,我们将其用作build参数的值。这个工作器依赖于一个数据库容器(称为db),我们使用depends_on参数设置这个依赖关系。

依赖关系并不意味着当必要的应用程序准备好工作时,依赖的容器将被启动。它仅指容器启动的顺序;你的微服务需要的应用程序可能尚未准备好。你必须控制应用程序的可用性,就像我们对 dbsync 所做的那样,通过一个尝试连接到数据库直到其可用的循环。

此外,我们还设置了RUST_LOG变量,以过滤比debug级别低一级的消息,并且只打印与dbsync_worker模块相关的消息:

dbsync:
  build: ./microservices/dbsync
  depends_on:
    - db
  environment:
    - RUST_LOG=dbsync_worker=debug
    - RUST_BACKTRACE=1
    - DBSYNC_DATABASE=postgresql://postgres:password@db:5432

我们还通过设置RUST_BACKTRACE变量激活了回溯打印功能。

最后一个变量设置了一个连接到数据库的连接链接。正如你所见,我们使用主机的db名称,因为 Docker 配置容器以解析名称并匹配其他容器的名称,所以你不需要设置或记住容器的 IP 地址。你可以使用容器的名称作为主机名。

邮件微服务容器

向用户发送邮件的微服务是基于存储在./microservices/mails文件夹中的Dockerfile镜像构建的。此微服务依赖于smtp容器,但此微服务不会检查邮件服务是否已准备好工作。如果你想要检查邮件服务器是否已准备好,请在开始任何活动之前添加一段尝试连接到 SMTP 服务器的代码。查看以下设置:

mails:
  build: ./microservices/mails
  depends_on:
    - smtp
  environment:
    - RUST_LOG=mails_microservice=debug
    - RUST_BACKTRACE=1
    - MAILS_ADDRESS=0.0.0.0:8000
    - MAILS_SMTP_ADDRESS=smtp:2525
    - MAILS_SMTP_LOGIN=admin@example.com
    - MAILS_SMTP_PASSWORD=password
  ports:
    - 8002:8000

我们还通过环境变量配置了一个微服务,并将端口8002映射到容器的8000端口。你可以使用端口8002来检查微服务是否启动并正常工作。

用户微服务容器

用户微服务是由我们之前创建的Dockerfile构建的。此微服务依赖于两个其他容器——dbsync 和 mails。首先,我们需要在数据库中有一个用户表来保存用户记录;其次,我们需要有能力向用户发送电子邮件通知。我们还设置了USERS_ADDRESS变量中的套接字地址和USERS_DATABASE变量中的连接链接:

users:
  build: ./microservices/users
  environment:
    - RUST_LOG=users_microservice=debug
    - RUST_BACKTRACE=1
    - USERS_ADDRESS=0.0.0.0:8000
    - USERS_DATABASE=postgresql://postgres:password@db:5432
  depends_on:
    - dbsync
    - mails
  ports:
    - 8001:8000

此外,还有一个设置,将容器的端口8000映射到本地端口8001,你可以使用这个端口来测试访问微服务。

内容微服务容器

内容微服务是用./microservices/content文件夹中的Dockerfile文件构建的。我们也在本章的早期创建了此文件。由于内容微服务基于 Rocket 框架,我们可以使用带有ROCKET前缀的环境变量来配置微服务:

content:
  build: ./microservices/content
  depends_on:
    - dbsync
  ports:
    - 8888:8000
  environment:
    - RUST_LOG=content_microservice=debug
    - RUST_BACKTRACE=1
    - ROCKET_ADDRESS=0.0.0.0
    - ROCKET_PORT=8000
    - ROCKET_DATABASES={postgres_database={url="postgresql://postgres:password@db:5432"}}
  ports:
    - 8003:8000

此微服务使用数据库,并依赖于dbsync容器,而dbsync容器又依赖于包含数据库实例的db容器。我们打开端口8003以在 Docker 外部访问此微服务。

路由微服务容器

在我们启动整个应用程序之前,我们将配置最后一个服务,即路由微服务。此服务依赖于用户和内容微服务,因为路由代理请求这些微服务:

router:
  build: ./microservices/router
  depends_on:
    - users
    - content
  environment:
    - RUST_LOG=router_microservice=debug
    - RUST_BACKTRACE=1
    - ROUTER_ADDRESS=0.0.0.0:8000
    - ROUTER_USERS=http://users:8000
    - ROUTER_CONTENT=http://content:8000
  ports:
    - 8000:8000

我们还配置了router_microservice命名空间的debug级别日志,开启了回溯打印,设置了绑定此微服务的套接字地址,并使用配置支持的变量设置了用户和内容微服务的路径。我们使用容器名称作为主机名,因为 Docker Compose 配置容器通过名称相互访问。我们还转发端口8000到相同的系统端口。现在我们可以启动包含所有容器的应用程序。

运行应用程序

要运行应用程序,我们将使用 Docker Compose 工具,该工具必须已安装(你可以在本章的技术要求部分找到有用的链接)。如果实用工具安装成功,你将拥有docker-compose命令。将目录更改为名为docker-compose.test.yml的目录,并运行up子命令:

docker-compose -f docker-compose.test.yml up

因此,如果需要,它将构建所有镜像并启动应用程序:

Creating network "deploy_default" with the default driver
Creating deploy_smtp_1 ... done
Creating deploy_db_1    ... done
Creating deploy_mails_1  ... done
Creating deploy_dbsync_1 ... done
Creating deploy_users_1   ... done
Creating deploy_content_1 ... done
Creating deploy_router_1  ... done
Attaching to deploy_smtp_1, deploy_db_1, deploy_mails_1, deploy_dbsync_1, deploy_content_1, deploy_users_1, deploy_router_1

当所有容器启动后,你将在终端中看到所有容器的日志,日志前缀为容器名称:

smtp_1     | Setting configuration option smtp_sasl_password_maps with value: hash:\/etc\/postfix\/sasl_passwd
mails_1    | [2018-12-24T19:08:20Z DEBUG mails_microservice] Waiting for SMTP server
smtp_1     | Setting configuration option smtp_sasl_security_options with value: noanonymous
dbsync_1   | [2018-12-24T19:08:20Z DEBUG dbsync_worker] Waiting for database...
db_1       | 
db_1       | fixing permissions on existing directory /var/lib/postgresql/data ... ok
mails_1    | [2018-12-24T19:08:20Z DEBUG mails_microservice] SMTP connected
smtp_1     | Adding SASL authentication configuration
mails_1    | Listening on http://0.0.0.0:8000
mails_1    | Ctrl-C to shutdown server
content_1  | Configured for development.
router_1   | DEBUG 2018-12-24T19:08:22Z: router_microservice: Started http server: 0.0.0.0:8000
content_1  | Rocket has launched from http://0.0.0.0:8000
users_1    | [2018-12-24T19:08:24Z DEBUG users_microservice] Starting microservice...

现在应用程序已启动,你可以使用此链接通过浏览器连接到它:http://localhost:8000

要停止应用程序,使用Ctrl+C键组合。这将启动终止过程,你将在终端中看到它:

Gracefully stopping... (press Ctrl+C again to force)
Stopping deploy_router_1  ... done
Stopping deploy_users_1   ... done
Stopping deploy_content_1 ... done
Stopping deploy_mails_1   ... done
Stopping deploy_db_1      ... done
Stopping deploy_smtp_1    ... do

如果你重新启动应用程序,数据库将是空的。为什么?因为我们把数据库存储在容器的临时文件系统中。如果你需要持久性,你可以将本地文件夹附加到容器作为虚拟卷。让我们探索这个功能。

向应用程序添加持久状态

我们创建了一个由微服务组成的应用程序,并且没有持久状态——每次重启时应用程序都是空的。解决这个问题很简单:将持久卷映射到容器的文件夹。由于我们应用程序的没有任何微服务将数据保存在文件中,但 PostgreSQL 数据库是,我们只需要将一个文件夹附加到数据库容器。将docker-compose.test.yml复制到docker-compose.prod.yml,并添加以下更改:

services:
  db:
    image: postgres:latest
    restart: always
    environment:
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=password
    volumes:
      - database_data:/var/lib/postgresql/data
  # other containers definition
volumes:
  database_data:
    driver: local

我们将名为database_data的卷附加到数据库容器的/var/lib/postgresql/data路径。PostgreSQL 默认使用此路径来存储数据库文件。要声明持久卷,我们使用名为卷的volume部分。我们将driver参数设置为local以将数据保留在本地硬盘上。现在数据在重启之间保存。

我们还移除了所有微服务的端口转发,除了路由微服务,因为所有微服务都可通过 Docker 的内部虚拟网络访问,只有路由器需要在外部容器中可用。

在后台运行应用程序

我们通过将终端附加到容器的输出启动了应用程序,但如果您想将应用程序部署到远程服务器,这就不方便了。要分离终端,请在启动时使用-d参数:

docker-compose -f docker-compose.prod.yml up -d

这将启动具有持久状态的应用程序,并打印如下内容:

Starting deploy_db_1   ... done
Starting deploy_smtp_1   ... done
Starting deploy_dbsync_1 ... done
Starting deploy_mails_1   ... done
Starting deploy_content_1 ... done
Starting deploy_users_1   ... done
Starting deploy_router_1  ... done

它也会从终端分离。您可能会问:我如何使用env_loggerlogcrate 读取微服务打印的日志?请使用以下命令,并在末尾加上服务名称:

docker-compose -f docker-compose.prod.yml logs users

此命令将打印users_1容器的日志,它代表应用程序的用户服务。您可以使用grep命令过滤日志中的不必要记录。

由于应用程序已从终端分离,您应该使用向下命令来停止应用程序:

docker-compose -f docker-compose.test.yml stop

这将停止所有容器并输出以下内容:

Stopping deploy_router_1  ... done
Stopping deploy_users_1   ... done
Stopping deploy_content_1 ... done
Stopping deploy_mails_1   ... done
Stopping deploy_db_1      ... done
Stopping deploy_smtp_1    ... done

应用程序已停止,现在您知道如何使用 Docker Compose 工具运行多容器应用程序。如果您想了解更多关于在本地和远程机器上使用 Docker Compose 的信息,请阅读这本书。

摘要

本章向您介绍了如何使用 Docker 构建图像并运行自己的微服务容器。我们将我们在第九章,使用框架进行简单的 REST 定义和请求路由,和第十一章,使用 Actors 和 Actix Crate 处理并发中创建的所有微服务打包在一起,并学习了如何手动构建图像和启动容器。我们还添加了 dbsync 工作进程,它应用了所有必要的迁移,并为用户和内容微服务准备了数据库。

此外,我们还考虑了隐藏微服务源代码的方法,并使用容器的缓存将编译的二进制文件复制到空镜像中,而不构建工件。

在本章的后半部分,我们学习了如何一次性运行多个具有必要依赖项(如数据库和邮件服务器)的微服务。我们使用 Docker Compose 工具描述了具有运行顺序和端口转发的微服务集的配置。我们还学习了如何将卷附加到服务(容器)上,以存储持久数据,并允许您在没有任何数据丢失风险的情况下重新启动应用程序。

在下一章中,我们将学习如何使用持续集成工具自动化构建微服务,帮助您更快地交付产品的最新版本。

第十六章:Rust 微服务的 DevOps - 持续集成和交付

本章涵盖了广泛使用的持续集成(CI)和持续交付(CD)实践。当一个微服务正在开发时,您必须确保每个功能都经过测试并且能够正常工作,并考虑如何将应用程序部署到您的服务器或云中。

在本章中,我们将研究以下内容:

  • 如何使用工具检查代码

  • 如何使用 CI 工具自动构建代码

  • 如何将编译后的代码部署到服务器

技术要求

本章需要 Rust 编译器,并且您必须安装至少版本 1.31。此外,您还需要rustup工具来添加额外的组件,例如rustfmtclippy。如果您还没有它,您可以在以下链接找到:rustup.rs/

在本章中,我们将尝试启动一个 CI 系统,该系统将构建和测试一个微服务。由于手动安装这类工具既耗时又复杂,我们将使用 Docker 和 Docker Compose 来更快地启动和准备构建环境。但无论如何,您需要一个浏览器来连接到 TeamCity 工具的管理控制台 UI 并进行配置。

本章的示例可以在 GitHub 上找到:github.com/PacktPublishing/Hands-On-Microservices-with-Rust/tree/master/Chapter16.

持续集成和持续交付

在现代世界,速度是应用程序和产品成功的关键因素。竞争已经变得激烈,每个公司都必须尽可能快地发布新功能。

对于微服务开发者来说,这意味着我们需要一个持续的过程来及时且具有竞争力地构建和交付我们产品的版本。从软件的角度来看,这意味着您需要自动化这个过程——可能是一个特殊的产品或一系列脚本。幸运的是,这类产品已经存在;被称为 CI 工具。

持续集成

持续集成(CI)是将所有传入的功能和补丁合并到单个经过充分测试的应用程序的过程。重要的是要注意,这应该每天发生几次——您将获得一个新鲜出炉的版本,就像从传送带上下来的一样。

现在,许多产品提供给您一个测试、构建和部署产品的工具,但它们并不明确。在大多数情况下,CI 产品作为一个服务器,使用远程构建代理从存储库中提取代码进行构建。这个过程在以下图中大致描述:

图片

CI 服务器负责从源代码管理服务器(如 Git)获取更新,拉取代码的新版本,并使用已经连接并注册在服务器上的代理开始构建。一些软件可以使用 Docker 作为必要构建代理的运行时。在这种情况下,你甚至不需要手动运行代理。但这并不适合应用程序的每个部分,因为某些部分需要在不能作为 Docker 容器启动的环境中构建。

编译和测试成功的微服务可以被移动到也可以使用 CD 自动化的部署流程中。

持续交付

当应用程序构建完成并准备部署时,自动化部署的过程被称为 CD。通常,这种功能是通过 CI 产品提供的,使用称为配置管理和部署工具的特殊插件,如 Ansible、Chef、Puppet 和 Salt。

在过去,微服务是以包含如Web ARchivesWAR)等文件的存档形式交付的,在 Java 中,它们作为直接安装在服务器操作系统上的包,以及作为二进制文件。如今,公司更倾向于交付容器,而不是这些其他格式。容器的好处是无可否认的:它们紧凑且安全,使用共享注册表,而且你不需要一次又一次地准备环境。Docker 镜像已经包含了你需要的内容,如果你的微服务可以在不与其他相同微服务的实例冲突的情况下工作,你可以考虑将你的产品不仅作为部署到远程服务器的容器,还可以使用一个自动根据客户需求扩展应用程序的编排工具。

容器编排

通过自动化的构建和交付流程,你仍然可以将一个微服务部署到一个无法扩展的环境中。这意味着你失去了快速扩展应用程序的重要好处。一个大型互联网招聘服务的开发者告诉我一个有趣的故事,关于他们经历的峰值负载——他们服务器上最大的活动发生在周一早晨。那是在周末后每个员工访问办公室并决定“这结束了”的时候。应用程序维护的现实是,你无法预测服务的活动峰值,因此你应该能够快速运行更多微服务的实例。

有一些产品可以编排容器,其中最受欢迎的是 Kubernetes。你唯一需要做的就是将容器上传到注册表。Kubernetes 可以路由请求并运行无法处理所有传入请求的额外微服务实例。然而,你仍然需要为其提供硬件资源,并编写松散耦合的微服务,这样你就可以运行尽可能多的实例。

在任何情况下,为了自动化应用程序的交付流程,你必须从持续集成系统开始,并不断改进它。让我们看看我们可以用来为 Rust 项目编写 CI 脚本的工具。

Rust 工具

微服务质量控制的第一步是检查代码中不包含明显的错误。编译器会检查可变性、引用、所有权和生命周期的情况,如果你有未使用的代码,它还会打印警告,但还有一些更复杂的情况需要特殊工具来检测。

在本节中,我们将介绍在持续集成(CI)代理中常用的以下工具,用于准备合并前的代码。让我们一一探索,从代码格式化风格开始。

Rustfmt

Rustfmt 是一个帮助你使代码符合风格指南的工具。这并不意味着你必须使用一种通用的风格:该工具提供了多个配置参数,你可以使用它们来设置首选的样式。

这个工具已经成熟,但直到版本 1.31 之前,它都没有包含在 Rust 发行版中。自 2018 版发布以来,rustfmt 工具已经可用,并建议在项目中使用;然而,Rust 不会强制你的代码具有标准格式。让我们安装它并尝试使用它。

安装

如果你使用rustup工具,那么要安装rustfmt,你需要使用以下命令添加相应的组件:

rustup component add rustfmt

如果你想要安装最新版本,你可以直接从仓库中安装:

cargo install --git https://github.com/rust-lang/rustfmt

从源代码安装需要编译时间,可能不稳定,但你将拥有最新的功能。

使用

由于rustfmt被添加为一个命令,使用它就像调用一个特殊命令一样简单:

cargo fmt

此命令将所有源代码修复为默认风格指南。但它会静默运行,我们必须查看文件之间的差异来查看更改。

该工具会修补你的项目中的文件,在你尝试修复代码风格之前,你必须提交所有更改。

但我们将使用这个工具通过 CI 检查代码风格,如果代码格式不正确,则停止构建。为了检查代码并查看潜在的变化,你必须将--check参数传递给rustfmt

cargo fmt -- --check

正如你所见,我们使用了额外的--参数,因为没有它,我们会将参数传递给调用rustfmt的工具,但为了直接将参数发送到rustfmt,我们必须添加这个额外的破折号对。如果没有问题,检查将返回0代码,如果有错误,则返回非零代码,并打印出潜在的差异:

Diff in ./microservice/src/main.rs at line 1:
-use actix_web::{App, middleware, server, App};
+use actix_web::{middleware, server, App, App};

 fn index(_req: &HttpRequest) -> &'static str {
     "Microservice"
Diff in ./microservice/src/main.rs at line 8:
     env_logger::init();
     let sys = actix::System::new("microservice");
     server::new(|| {
-        App::new().middleware(middleware::Logger::default()).resource("/", |r| r.f(index))
+        App::new()
+            .middleware(middleware::Logger::default())
+            .resource("/", |r| r.f(index))
     })
     .bind("127.0.0.1:8080")
     .unwrap()

这正是我们在 CI 中需要用来中断构建,并查看中断原因以便修复的工具。

配置

你可以通过配置来更改rustfmt的行为,以设置你首选的样式。将rustfmt.toml配置文件添加到你的项目中。当前版本的默认值可以用以下配置文件内容描述:

max_width = 100
hard_tabs = false
tab_spaces = 4
newline_style = "Auto"
use_small_heuristics = "Default"
reorder_imports = true
reorder_modules = true
remove_nested_parens = true
edition = "2015"
merge_derives = true
use_try_shorthand = false
use_field_init_shorthand = false
force_explicit_abi = true

大多数参数都有描述性的名称,但如果你想阅读参数的描述,你可以通过向rustfmt传递--help=config参数来查看详细信息。你可以创建一个rustfmt.toml文件,并设置与默认值不同的参数。

通常,代码风格检查是 CI 脚本的第一步,因为它是最快的检查,最好在长时间编译过程之前完成。还有一个我们在编译之前应该做的代码检查——lint 检查。

Clippy

除了你代码格式的问题外,你还可能遇到更严重的问题,这些问题可以通过另一种工具——linters 来解决。linters 是一种程序,它可以找到可能影响未来性能的代码编写不良习惯,如果问题可以更简单地解决。Rust 有一个很好的 linters 叫做clippy,自 1.31 版本以来作为组件包含在内,当时它成为了 2018 版的一部分。它是在构建脚本时防止大量不良编码实践的好工具。让我们安装它并尝试使用它。

安装

你可以通过两种方式安装clippy,就像我们安装rustfmt工具一样:使用rustup命令添加组件或从项目的 GitHub 仓库安装最新版本。要将其添加为预构建组件,请使用以下命令:

rustup component add clippy

你也可以使用以下命令直接从项目的仓库中安装最新版本:

cargo install --git https://github.com/rust-lang/rust-clippy

但请记住,这个版本可能是不稳定的,它包含的 lints 可能在将来发生变化。

使用

要使用clippy,只需将其作为cargo的子命令启动即可:

cargo clippy

此子命令开始检查代码,并将通知你可能的改进。例如,假设你的代码中有一个这样的结构体:

struct State {
    vec: Box<Vec<u8>>,
}

然后,clippy会通知你Box是不必要的,因为Vec已经放置在内存堆中:

warning: you seem to be trying to use `Box<Vec<T>>`. Consider using just `Vec<T>`
 --> src/main.rs:4:10
  |
4 |     vec: Box<Vec<u8>>,
  |          ^^^^^^^^^^^^
  |
  = note: #[warn(clippy::box_vec)] on by default
  = help: `Vec<T>` is already on the heap, `Box<Vec<T>>` makes an extra allocation.
  = help: for further information visit https://rust-lang.github.io/rust-clippy/master/index.html#box_vec

但如果你真的想对向量进行装箱,你可以通过在字段或结构体上添加#[allow(clippy::box_vec)]属性来禁用此行代码的警告,并且此字段的警告将被抑制。

上述示例是一个警告,意味着代码将成功编译,构建不会被clippy中断。在 CI 脚本中,如果clippy从代码中获得警告,它必须中断执行,因为我们应该合并不包含任何警告以及模糊代码的代码。为了使clippy在出现警告时失败,我们可以设置额外的参数:

cargo clippy -- -D warnings

现在,clippy否认所有警告。但如果你的 crate 包含非默认功能:clippy不会检查所有这些。要进行完整检查,可以使用以下参数:

cargo clippy --all-targets --all-features -- -D warnings

当然,这会花费更多时间,但clippy知道的所有潜在问题都将被检查。

配置

clippy工具可能会非常烦人。为了收紧或放宽检查,你可以使用clippy.toml配置文件来配置工具。

例如,如果我们使用 -W clippy::pedantic 参数激活所有 lint,我们可以得到如下警告:

warning: you should put `MyCompany` between ticks in the documentation
 --> src/main.rs:8:29
  |
8 | /// This method connects to MyCompany API.
  |                             ^^^^^^^^^
  |
  = note: `-W clippy::doc-markdown` implied by `-W clippy::pedantic`
  = help: for further information visit https://rust-lang.github.io/rust-clippy/master/index.html#doc_markdown

这是因为 clippy 认为存在一个我们忘记包含在引号中的变量名称。为了避免这种行为,我们可以在 clippy.toml 配置文件中添加一个额外的单词来忽略 Markdown 注释:

doc-valid-idents = ["MyCompany"]

现在工具不会将 MyCompany 解释为变量的名称。

推荐的代码属性

正如你所见,允许或拒绝某些警告对于 lint 是可能的,但有一些属性可以用来自动化你的代码:

#![deny(
    bare_trait_objects,
    dead_code,
)]
#![warn(
    missing_debug_implementations,
    missing_docs,
    while_true,
    unreachable_pub,
)]
#![cfg_attr(
    feature = "cargo-clippy",
    deny(
        clippy::doc_markdown,
    ),
)]

这是代码更严格要求的示例。编译器拒绝未使用代码和缺少文档,将要求使用 loop 而不是 while true,并检查所有已发布类型都必须可达。我们还完全拒绝在 Markdown 文档中使用不带引号的变量名称。你可以将所需的要求添加到你的项目中。

此外,前面的要求迫使我们使用 dyn Trait 来表示 trait 对象,而不是裸露的 Trait 名称。如果你使用 2015 版本但想为 Edition 2018 准备项目,这可能很有用,但最好尽可能使用最新版本,并且有一个工具可以帮助你迁移到 Rust 的最新版本——rustfix

Rustfix

你可能想过,如果 Rust 可以在代码中找到问题并提出解决方案,为什么没有立即应用这些更改?这是一个合理的想法,未来可能实现,但现在这个功能正在通过 rustfix 工具积极开发中。

本项目旨在提供一个可以修复所有编译器警告的工具,今天你可以尝试使用它将你的项目从 Edition 2015 转换到 Edition 2018。我们不需要在 CI 流程中直接使用这个工具,但它可以帮助更快地满足 CI 检查。

安装

要安装 rustfix,请使用以下命令:

cargo install cargo-fix

安装后,你可以使用带有必要参数的 cargo fix 子命令。

使用方法

让我们考虑将项目从 Edition 2015 转换到 Edition 2018。你需要设置哪些参数来完成这个代码转换?首先,你可以使用以下命令来准备你的代码以进行转换:

cargo fix --edition

此命令将使你的代码与两个版本兼容,但如果你想使用某个版本的惯用用法,你必须将版本设置为在 Cargo.toml 文件的 [package] 部分的 edition 字段中使用的版本,并运行以下命令:

cargo fix --edition-idioms

运行此命令后,你的代码可能与所选版本兼容。你还可以使用 rustfix 做更多的事情,但如果你使用 IDE,一些问题可以得到修复;但这个主题超出了本书的范围;让我们探索其他 cargo 命令。

Cargo 测试

完全坦白地说,CI 中最重要的工具是测试工具。我们已经在 第十三章 中学习了如何编写测试,测试和调试 Rust 微服务,但在本节中,我们将探讨 cargo test 命令的一些有用参数。

CI 有一些有用的参数:

  • --no-run:编译但不运行测试,这对于检查不同目标的测试编译很有用,无需浪费额外运行的时间

  • --all-targets:为所有目标运行测试

  • --all-features:使用所有功能运行测试

  • --examples:测试所有示例

  • --jobs <N>:在多个作业中运行测试,如果测试只使用一个数据库实例,并且你想要顺序运行测试以避免一个测试影响另一个测试的结果,这很有用

现在我们已经准备好启动 CI 工具并为其构建微服务进行配置。

CI 和 CD 工具

在本节中,我们将讨论 CI 系统,并使用 Docker Compose 启动一个 CI 服务器实例,并使用构建代理。但首先,让我们看看一些流行的 CI 产品及其交付能力。

TravisCI

TravisCI 是开源项目最受欢迎的持续集成服务,因为它为这些项目提供免费计划,并且与 GitHub 集成良好。要使用它,你只需将 .travis.yml 文件添加到你的项目仓库的根目录。它默认支持 Rust。

使用 TravisCI,你可以在 Linux 或 macOS 环境中构建你的项目。让我们写一个简单的 .travis.yml 文件示例。该文件的第一部分是构建矩阵声明:

language: rust
cache: cargo
matrix:
  include:
    - os: linux
      rust: stable
      env: TARGET=x86_64-unknown-linux-gnu
    - os: linux
      rust: nightly
      env: TARGET=x86_64-unknown-linux-gnu
    - os: osx
      rust: stable
      env: TARGET=x86_64-apple-darwin
    - os: osx
      rust: nightly
      env: TARGET=x86_64-apple-darwin

我们选择了带有缓存的 Rust 语言,以加快构建更新的速度。此外,我们还声明了一个环境矩阵。TravisCI 自动为我们准备了 Rust 环境,有四种变体:带有 stable 编译器的 linux,带有 nightly 编译器的 linux,以及 osx 上的 stablenightly 编译器版本的一对。对于微服务,你通常只需要 linux 构建。此外,我们还指定了目标,但你可以使用 musl 而不是 gnu,例如。

以下代码安装额外的包:

addons:
  apt:
    packages:
      - build-essential
      - libcurl4-openssl-dev
      - libssl-dev

此外,你还可以添加在构建和测试运行中使用的环境变量:

env:
  global:
    - APPNAME="myapp"

最后,你必须添加一个用作 CI 脚本的脚本。你可以直接将 script 部分中的命令作为该部分的项放入 .travis.yml,或者添加一个 jobs 部分以包含可以并行运行的作业:

jobs:
  include:
    - name: rustfmt
      install:
        - rustup component add rustfmt
      script:
        - cargo fmt -- --check
    - name: clippy
      install:
        - rustup component add clippy
      script:
        - cargo clippy
    - name: test
      script:
        - cargo build --target $TARGET --verbose
        - cargo test --target $TARGET --verbose

jobs 部分也可以包含一个安装子部分,以提供为作业安装额外依赖项的命令列表。

现在,你可以将这个.travis.yml文件放入你项目仓库的根目录,以便允许 Travis CI 检查你的项目的 pull 请求。但请记住,你必须为 TravisCI 服务付费以使用私有仓库,而 GitHub 允许你免费拥有私有仓库。你可以测试你的 Rust 应用程序在 Linux 和 macOS 上,但还有一个提供另一组操作系统的服务。

AppVeyor

AppVeyor 是一个适用于 Linux 和 Windows 的 CI 服务。对于开源项目,它也是免费的,并且与 GitHub 有良好的集成。要开始使用这项服务,你必须在你的项目中添加一个appveyor.yml文件。让我们看看示例配置:

os: Visual Studio 2015
environment:
  matrix:
    - channel: stable
      target: x86_64-pc-windows-msvc
    - channel: nightly
      target: i686-pc-windows-msvc
    - channel: stable
      target: x86_64-pc-windows-gnu
    - channel: nightly
      target: i686-pc-windows-gnu

配置看起来与 TravisCI 的配置类似。我们还创建了一个构建矩阵,并将使用 MSVC 和 GNU 工具链为稳定和夜间编译器版本。在此之后,我们使用这些值通过rustup安装所需的工具:

install:
  - appveyor DownloadFile https://win.rustup.rs/ -FileName rustup-init.exe
  - rustup-init -yv --default-toolchain %channel% --default-host %target%
  - set PATH=%PATH%;%USERPROFILE%\.cargo\bin
  - rustup component add rustfmt
  - rustup component add clippy
  - rustc -vV
  - cargo -vV

在更新了PATH环境变量之后,我们也安装了rustfmtclippy工具。最后,我们可以按照以下方式构建项目:

build: false
test_script:
  - cargo fmt -- --check
  - cargo clippy
  - cargo build
  - cargo test

我们将build字段设置为false,以防止构建代理启动 MSBuild 工具,这对于 Rust crate 来说是不必要的。

Jenkins

这是一个最初为 Java 创建并设计的流行 CI 系统。Jenkins 是一个开源产品,对使用没有限制。这两个原因都是一些成长中的公司选择这个产品的原因,如果你想要定制构建过程并希望控制 CI 的成本,你也可能会选择这个产品。

Jenkins 为你提供了两种构建应用程序的方法。第一种是运行一个普通的脚本,第二种是使用流水线(pipeline),这是一个允许你在仓库根目录中包含 CI 脚本的特性,就像我们在 TravisCI 和 AppVeyor 中做的那样。

如果你想要自动从仓库中拉取此脚本并将其使用 SCM 更新,那么必须将以下pipeline配置存储在你项目根目录的Jenkinsfile配置文件中:

pipeline {
    agent { dockerfile true }
    stages {
        stage('Rustfmt') {
            steps {
                sh "cargo fmt -- --check"
            }
        }
        stage('Clippy') {
            steps {
                sh "cargo clippy"
            }
        }
        stage('Build') {
            steps {
                sh "cargo build"
            }
        }
        stage('Test') {
            steps {
                sh "cargo test"
            }
        }
    }
}

上述pipeline配置意味着 Jenkins 需要 Docker 来构建附加的Dockerfile,并运行所有阶段的全部命令。这个特性利用 Docker 容器而不是代理,但你也可以将传统的构建代理连接到 CI 服务器。

持续集成的演示

为了创建一个演示,我们将使用由 JetBrains 开发的 TeamCity CI。这个产品在某些特性上与 Jenkins 相似,但对我们演示来说,它更容易启动和部署。我们将启动自己的 CI 环境,并为其配置自己的构建任务。TeamCity 有一个免费计划,足以构建小型和中型项目。我们还将使用 Gogs Git 服务器来满足我们的构建需求。让我们开始吧。

Docker Compose

创建一个空的docker-compose.yml文件,并向其中添加一个services部分:

version: '3.1'
services:

SCM 服务器

services部分,首先添加 SCM 服务器 Gogs:

git-server:
    image: gogs/gogs
    ports:
        - '10022:22'
        - '10080:3000'
    volumes:
        - ./gogs:/data

我们将使用官方的 Docker 镜像。我们设置了一个持久卷来保持所有创建的仓库在启动之间的状态。此外,我们还转发了两个端口——SSH(从本地端口10022到容器中的端口22)和 HTTP(从本地端口10080到容器中的端口3000)。要使用 Git 客户端上传数据,我们将使用本地端口,但要从 TeamCity 服务器使用,我们必须使用容器的端口。

CI 服务器

我们需要的下一个服务是一个持续集成(CI)服务器。我们将使用 TeamCity 的官方镜像:

teamcity:
    image: jetbrains/teamcity-server
    ports:
        - '8111:8111'
    volumes:
        - ./teamcity/datadir:/data/teamcity_server/datadir
        - ./teamcity/logs:/opt/teamcity/logs

容器需要两个持久卷来存储数据和日志。我们还转发容器的8111端口到相同的本地端口,以便通过浏览器连接到用户界面。

CI 代理

要使用 TeamCity 服务器进行构建,我们需要至少一个代理。它在 Docker 中作为一个兄弟容器工作,我们还将它声明为一个服务,但提供指向我们之前创建的 CI 服务器的SERVER_URL环境变量:

agent:
    build: ./images/rust-slave
    environment:
        - SERVER_URL=http://teamcity:8111
    volumes:
        - ./teamcity/agent:/data/teamcity_agent/conf

有一个官方的代理镜像,但我们在这里没有直接使用它,因为我们需要添加 Rust 编译器和额外的工具,这就是为什么我们为这个服务构建了自己的镜像。此外,我们还需要为它提供一个持久卷来保持将要连接到服务器的运行代理的配置。

代理不需要打开端口,因为它们是非交互式的,并且没有任何用户界面。通常,代理也被称为奴隶。

镜像

从 TeamCity 最小代理的官方镜像创建的代理服务镜像如下:

FROM jetbrains/teamcity-minimal-agent:latest

RUN apt-get update
RUN apt-get install -y build-essential

ENV RUST_VERSION=1.32.0

RUN curl https://sh.rustup.rs -sSf \
 | sh -s -- -y --no-modify-path --default-toolchain $RUST_VERSION

ENV PATH=/root/.cargo/bin:$PATH

RUN rustup --version; \
 cargo --version; \
 rustc --version;

RUN rustup component add rustfmt
RUN rustup component add clippy

如您所见,我们安装了 Rust 编译器,并使用rustup添加了rustfmtclippy作为组件。

配置 Gogs

让我们配置源代码管理(SCM)服务器并将一个小型微服务推送到它:

  1. 使用以下命令从我们的 CI 服务包启动 Docker Compose:
docker-compose up
  1. 当所有服务启动后,在浏览器中打开http://localhost:10080来配置 Gogs 服务器。您将看到以下配置表单:

图片

  1. 在数据库类型字段中设置 SQLite3(因为我们不会花时间配置外部数据库),在其他字段中保留默认值,然后点击安装 Gogs 按钮。它将重定向到端口3000,但请记住,它仅在 Docker 内部可用,您必须再次打开之前的 URL。

  2. 点击注册链接并注册一个新账户:

图片

我设置了developer作为用户名,secret作为密码。我们需要这些凭证来上传我们的代码到创建的仓库,以及使用 CI 拉取代码。

  1. 使用+按钮创建一个新的私有仓库,并将其命名为microservice。现在您可以使用http://localhost:3000/developer/microservice.git将代码上传到这个仓库。如果您使用本书仓库中的本章代码,您可以使用该文件夹中的 microservice crate,但您必须初始化它并使用以下命令添加远程服务器:
git init
git add -A
git commit
git remote add origin http://localhost:10080/developer/microservice.git
git push origin master

这是一条简单的命令,但如果你忘记了,就需要记住。

  1. 输入我们之前设置的用户名和密码,你将获得一个空的源代码管理(SCM)仓库:

图片

现在我们可以配置 CI 以获取构建所需的代码。

配置 TeamCity

  1. 首先,打开 http://localhost:8111 URL,在那里我们在浏览器中绑定 CI 服务器并传递配置 TeamCity 实例的第一步。设置数据目录与作为持久卷附加的相同(默认值):

图片

  1. 点击“继续”按钮,在下一步中,创建一个 HSQLDB 类型的数据库。还有其他外部数据库的选项,对于生产环境来说,使用它们会更好,但对于测试来说,将所有数据保留在 TeamCity 的 data 目录中就足够了:

图片

  1. 创建一个管理员账户,你将使用它来访问 TeamCity 用户界面:

图片

我为用户名使用了 admin 值,为密码字段使用了 secret 值。现在初始化和启动需要一些时间,但初始化过程完成后,我们可以添加外部代理。

授权代理

由于我们有一个在兄弟容器中的代理,它已经尝试连接到服务器,但我们必须授权它,因为如果代理被授权,它可以窃取微服务的源代码。在“代理”页面的“未授权”选项卡中点击“授权”按钮:

图片

当代理被授权后,你可以在“已连接”选项卡中看到它,并可以控制它。你还可以在 Docker Compose 配置中添加更多的兄弟工作节点。

创建项目

现在我们已经连接了代理,可以创建一个构建我们的微服务的项目。在“项目”页面点击“创建项目”按钮,并填写我们想要构建的仓库参数:

图片

我们将仓库 URL 设置为 http://git-server:3000/developer/microservice.git,因为 CI 服务器实例在虚拟网络内部运行,可以通过 Docker 镜像暴露的名称和原始端口连接到其他服务。

当你点击“继续”按钮时,你可以指定项目的名称:

图片

再次点击“继续”,你将得到一个空的项目,我们可以通过步骤来配置它。

Rust 的构建步骤

在项目的页面上,点击配置构建步骤的链接并添加一个新的构建步骤到我们的项目中:

图片

第一步,称为“格式检查”,是一个运行自定义脚本的命令行,使用单个命令:cargo fmt -- --check。这个命令将使用 rustfmt 工具检查代码的风格。添加下一个构建步骤,称为“构建”(你可以使用自己的名称),使用 cargo build 命令:

图片

现在,如果你点击构建步骤菜单项,你会看到我们创建的步骤:

现在,你可以通过点击运行按钮开始这个构建过程,如前面的截图所示。它将立即与代理容器开始构建。

使用 CI 构建

如果你进入第一个出现的构建任务,你会看到构建正在进行中:

如你所见,第一步已经成功完成,cargo build 命令正在进行中。当它完成时,这个构建任务的状态将变为成功。它工作了!

此外,项目默认创建一个触发器,当你向仓库推送新更改时,它会运行构建过程:

我向一个仓库推送了一个额外的提交,构建过程随即开始。正如你在前面的截图中所见,出现了构建的预估时间。根据之前的构建,预估需要 15 分钟,但实际上只需要 40 秒,因为代理保持了构建缓存。

作为实验,你可以添加更多步骤来使用 clippy 测试和检查代码,还可以添加步骤将二进制文件上传到服务器。

你也可以配置 Jenkins 以类似的方式工作,但配置需要更多时间。

摘要

在本章中,我们了解了 Rust 微服务的 CI(持续集成)。如果你之前没有使用 Rust 创建过本地程序,这可能对你来说是一个新话题。首先,我们讨论了 CI 和 CD 的目的。此外,我们还探讨了容器编排工具的好处。

然后,我们学习了检查代码质量的一些工具——rustfmtclippyrustfix。然后我们了解了如何配置它们。

接下来,我们研究了使用一些流行的 CI 服务和服务器——TravisCI、AppVeyor 和 Jenkins 的示例。然后,我们使用 TeamCity CI 和其代理启动了一个示例,并使用私有 Git 服务器将我们的 Rust 项目推送到 CI 进行构建。最后,我们配置了微服务的构建过程,并通过 UI 进行了检查。

在下一章中,我们将探讨无服务器应用程序:它是怎么回事,以及如何使用 Rust 编写它们。亚马逊网络服务提供了 AWS Lambda 产品来托管无服务器应用程序,并开始正式支持 Rust。

第十七章:使用 AWS Lambda 的有界微服务

在上一章中,我们学习了如何使用 AWS Lambda 和官方的 lambda-runtime 包创建无服务器应用程序。这对于使用 Amazon Web Services (AWS) 的开发者尤其有用,尤其是那些特别想使用 AWS Lambda 的开发者。它与我们在创建独立网络服务器时的方法不同,因为 AWS Lambda 会自动存储、扩展和运行,而我们唯一需要提供的是微服务的编译代码。

本章将涵盖以下主题:

  • 处理 AWS Lambda Rust 运行时

  • 使用 Serverless Framework 将微服务部署到 AWS

技术要求

要使用本章中的技术,您需要一个配置好的 Docker 实例,因为 AWS 使用 Amazon Linux AMI 分发版来运行 Lambda,我们需要为该环境编译 Rust 代码的特殊环境。您还需要在 AWS 中有一个账户。如果您还没有,请创建一个。AWS 提供了一个名为 Free Tier 的免费试用期,为期一年,每月包含 100 万次 AWS Lambda 请求。您可以在以下链接中了解更多关于这个试用期的信息:aws.amazon.com/free/

您还应该知道如何使用 AWS 控制台。本章中将有使用它的示例,但为了生产环境,您必须了解其所有功能,包括使用访问控制来防止恶意渗透到您的微服务。您可以在一本名为《Learning AWS》的书中了解 AWS:www.packtpub.com/virtualization-and-cloud/learning-aws-second-edition

在本章中,我们将创建两个无服务器应用程序的示例。第一个示例需要 Rust 编译器 1.31 版本或更高版本以及 musl 库,而第二个示例则需要 npm 和 Docker。

您可以在 GitHub 上找到本章所有示例的源代码:github.com/PacktPublishing/Hands-On-Microservices-with-Rust/tree/master/Chapter17/

无服务器架构

在本书的大部分内容中,我们已将微服务创建为独立的服务器应用程序。要部署它们,您必须使用持续交付工具将二进制文件上传到远程服务器。如果您不想担心使二进制文件与操作系统兼容,您可以使用容器来交付和部署打包到镜像中的应用程序。这为您提供了使用容器编排服务(如 Kubernetes)的机会。容器编排软件简化了使用容器运行微服务的大型应用程序的扩展和配置。如果您进一步思考这种简化,您会发现使用预定义和预安装的容器池很有帮助,这些容器池具有通用的环境,可以运行带有请求处理功能的小型二进制文件,而不需要任何 HTTP 中间件。换句话说,您可以为事件编写处理器,而无需编写更多的 HTTP 代码。这种方法被称为无服务器。

在下一节中,我们将列出提供无服务器基础设施并可用于部署无服务器应用程序的平台。

AWS Lambda

AWS Lambda 是 Amazon 的一项产品,您可以在以下链接找到:aws.amazon.com/lambda/.

针对 Rust 编程语言提供了官方支持,使用 lambda-runtime 包:github.com/awslabs/aws-lambda-rust-runtime. 我们将在本章中使用此包来演示无服务器方法。

Azure Functions

Azure Functions 是微软的无服务器产品,它是 Azure 平台的一部分:azure.microsoft.com/en-us/services/functions/.

目前没有官方的 Rust 支持,但您可以使用 azure-functions 包,该包使用基于 GRPC 的 Azure Functions 内部工作者协议在主机和语言工作者之间进行交互。

Cloudflare Workers

Cloudflare 提供了自己的无服务器产品,称为 Cloudflare Workers:www.cloudflare.com/products/cloudflare-workers/.

此服务与 Rust 兼容,因为 Cloudflare Workers 实现了一个很棒的想法:将工作者编译为 WebAssemblyWASM)。由于 Rust 对 WASM 有良好的支持,您可以使用它轻松地为 Cloudflare 生成无服务器工作者。

IBM Cloud Functions

IBM 提供了自己的基于 Apache OpenWhisk 的无服务器产品:console.bluemix.net/openwhisk/.

您可以使用 Rust 编写无服务器函数,因为该平台支持以 Docker 镜像提供的函数,并且您可以使用 Rust 函数创建 Docker 镜像。

Google Cloud Functions

Google Cloud Functions 是 Google 提供的产品,作为 Google Cloud 的一部分:cloud.google.com/functions/.

Rust 没有支持。可能,你可以使用 Rust 为 Python 环境编写原生模块,并尝试使用 Python 代码启动它们,但我找不到确认这种方法是否可行的证据。无论如何,我确信将来会有机会运行 Rust 代码。

用于 AWS Lambda 的最小 Rust 微服务

在本节中,我们将创建一个在 AWS Lambda 无服务器环境中工作的微服务。我们将重新实现第四章 数据序列化和反序列化使用 Serde Crate 中 与微服务交互的数据格式 部分的随机数生成器。

依赖项

首先,我们需要创建一个新的 minimal-lambda crate 并向其中添加以下依赖项:

[dependencies]
lambda_runtime = { git = "https://github.com/awslabs/aws-lambda-rust-runtime" }
log = "0.4"
rand = "0.5"
serde = "1.0"
serde_derive = "1.0"
simple_logger = "1.0"

我们需要的首要依赖项是 lambda_runtime,这是一个用于使用 Rust 为 AWS Lambda 平台编写 lambda 函数的官方 crate。我们使用了 GitHub 上的版本,因为在编写本文时,这个 crate 正在积极开发。

AWS 将所有 lambda 函数的输出打印为日志,我们将使用 simple_logger crate,它将所有日志打印到 stdout

我们还需要用 lambda 重写二进制的名称,因为运行 AWS Lambda 的环境期望找到一个名为 bootstrap 的二进制文件,该文件实现了 lambda 函数。让我们将我们的示例生成的二进制文件重命名:

[[bin]]
name = "bootstrap"
path = "src/main.rs"

这就足够开始编写一个用于无服务器环境的最小微服务了。

开发一个微服务

我们需要在我们的代码中以下类型:

use serde_derive::{Serialize, Deserialize};
use lambda_runtime::{lambda, Context, error::HandlerError};
use rand::Rng;
use rand::distributions::{Bernoulli, Normal, Uniform};
use std::error::Error;
use std::ops::Range;

查看从 lambda_runtime crate 的导入是有意义的。lambda 宏用于从二进制文件导出处理程序,该处理程序将由 AWS Lambda 运行时使用。Context 是处理程序的必需参数,我们还有导入 HandlerError 以用于处理程序的返回 Result 值。

然后,我们可以编写一个主函数,初始化 simple_logger 并将 rng_handler(我们将在下面的代码中实现)包装起来,以导出 lambda 函数的处理程序:

fn main() -> Result<(), Box<dyn Error>> {
    simple_logger::init_with_level(log::Level::Debug).unwrap();
    lambda!(rng_handler);
    Ok(())
}

rng_handler 是一个函数,它期望一个请求并返回一个响应:

fn rng_handler(event: RngRequest, _ctx: Context) -> Result<RngResponse, HandlerError> {
    let mut rng = rand::thread_rng();
    let value = {
        match event {
            RngRequest::Uniform { range } => {
                rng.sample(Uniform::from(range)) as f64
            },
            RngRequest::Normal { mean, std_dev } => {
                rng.sample(Normal::new(mean, std_dev)) as f64
            },
            RngRequest::Bernoulli { p } => {
                rng.sample(Bernoulli::new(p)) as i8 as f64
            },
        }
    };
    Ok(RngResponse { value })
}

在实现中,我们使用了第四章 使用 Serde Crate 的数据序列化和反序列化 中的示例中的生成器,并在 与微服务交互的数据格式 部分借用了一个必须可序列化的请求类型:

#[derive(Deserialize)]
#[serde(tag = "distribution", content = "parameters", rename_all = "lowercase")]
enum RngRequest {
    Uniform {
        #[serde(flatten)]
        range: Range<i32>,
    },
    Normal {
        mean: f64,
        std_dev: f64,
    },
    Bernoulli {
        p: f64,
    },
}

前一个请求类型是一个枚举,有三个变体,允许客户端选择三个概率分布之一来生成随机值。我们还需要一个类型来返回带有随机值的响应。我们也将从前面的代码中借用它。看看我们将使用的响应结构体:

#[derive(Serialize)]
struct RngResponse {
    value: f64,
}

现在,这个 lambda 函数期望一个 JSON 格式的 RngRequest 值作为请求,它将被自动反序列化,以及一个 RngResponse 结果,它将被序列化为 JSON 并返回给客户端。让我们构建这段代码并检查它的工作情况。

构建

要构建 lambda 函数,我们需要生成一个与亚马逊 Linux 兼容的二进制文件。您可以使用三种方法来构建相应的二进制文件:

  • 使用与 x86_64 兼容的 Linux 发行版构建它。

  • 在亚马逊 Linux 的 Docker 容器中构建它。

  • 使用 musl 标准 C 库构建它。

我们将使用后一种方法,因为它最小化了生成的二进制文件的外部依赖。首先,您必须安装 musl 库,您可以从这里获取:www.musl-libc.org/

我使用以下命令完成了这项工作:

git clone git://git.musl-libc.org/musl
 cd musl
 ./configure
 make
 sudo make install

但如果您的操作系统有相应的包,您应该安装那个包。

要使用 musl 库构建代码,我们必须将 x86_64-unknown-linux-musl 作为目标值。但我们可以通过 cargo 的配置文件将此目标设置为项目的默认值。在项目的文件夹中添加一个 .cargo/config 文件,并添加以下配置:

[build]
target = "x86_64-unknown-linux-musl"

确保编译器支持 musl 或使用 rustup 添加它:

rustup target add x86_64-unknown-linux-musl

现在,您可以使用 cargo build 命令简单地构建 lambda。这将生成一个使用 musl 库编译的二进制文件,我们可以将其上传到 AWS。

部署

我们可以使用两个工具将 lambda 部署到 AWS:

  • AWS CLI 工具

  • Web AWS 控制台

第一个步骤可能有点繁琐,在本书的下一节中,您将看到如何使用 Serverless Framework 来部署由 lambda 函数组成的应用程序。对于这个例子,进入 AWS 控制台并转到 AWS Lambda 产品页面。点击 创建函数 按钮,在出现的表单中输入以下值:

  • 名称: minimal-lambda

  • 运行时: 在函数代码或层中选择使用自定义运行时

  • 角色: 选择从一个或多个模板创建新角色

  • 角色名称: minimal-lambda-role

这就是您完成表单后应该看起来像的样子:

点击创建函数按钮,在函数创建过程中,使用以下命令将二进制打包到 zip 文件中:

zip -j minimal-lambda.zip target/x86_64-unknown-linux-musl/debug/bootstrap

在出现的表单中,选择在 函数代码 部分的 代码输入类型上传一个 .zip 文件

选择文件并使用表单上传它。当带有 Rust 函数的存档上传后,函数就准备好被调用了。点击测试按钮,您将看到一个表单,您可以在其中输入 JSON 格式的测试请求:

在其中输入以下 JSON:

{
  "distribution": "uniform",
  "parameters": {
    "start": 0,
    "end": 100
  }
}

这是一个序列化的 RngRequest 值,它使用均匀分布生成 0-100 范围内的随机值。在事件名称字段中输入 uniform 并点击 创建 按钮,测试前提条件将被存储。现在您可以在 测试 按钮左侧的下拉列表中选择此请求。选择 uniform 值并点击 测试 按钮以查看响应结果:

我们生成的微服务产生了一个值。如果您再次点击 测试 按钮,它将生成下一个值。如您所见,在 日志输出 部分打印了由 simple_logger crate 生成的日志记录。并且此函数的执行大约需要 20 毫秒。

AWS Lambda 的主要好处是访问所有其他 AWS 服务。让我们创建一个更复杂的示例,利用更多服务来展示如何将 lambda 函数与其他 AWS 基础设施集成。

Serverless Framework

在本节中,我们将从 Wild Rydes Serverless Workshops 将无服务器应用程序移植到 Rust:github.com/aws-samples/aws-serverless-workshops。此示例的目的是提供一个模拟订购独角兽骑乘服务的服务。

我们将使用 Serverless Framework,它提供了一个有用的工具,可以简化使用资源声明及其关系的应用程序部署。本节灵感来源于 Andrei Maksimov 创建的 Serverless Framework 使用示例,位于此处:github.com/andreivmaksimov/serverless-framework-aws-lambda-amazon-api-gateway-s3-dynamodb-and-cognito。让我们准备环境,以便使用 Serverless Framework 编写和构建应用程序。

准备

首先,您需要使用 npm 安装 Serverless Framework,它随 Node.js 提供:

sudo npm install -g serverless

我全局安装了它,因为我想要使用它从具有多个 lambdas 的应用程序 Rust 模板创建新项目:

sls install --url https://github.com/softprops/serverless-aws-rust-multi --name rust-sls

此命令会自动下载模板,并使用提供的名称构建一个空白应用程序。它会在控制台打印以下内容:

Serverless: Downloading and installing "serverless-aws-rust-multi"...
Serverless: Successfully installed "rust-sls"

当项目初始化时,进入此项目的文件夹并添加 serverless-finch 插件,我们将使用它来上传我们应用程序的资产:

npm install --save serverless-finch

serverless-aws-rust-multi 模板是一个工作区,包含两个 crate:helloworld。让我们将它们重命名为 lambda_1lambda_2。我已经使用这个模板来向您展示一个应用程序如何包含多个 crate。在重命名文件夹后,我们还需要在项目的 Cargo.toml 配置中替换 workspacemembers

[workspace]
 members = [
     "lambda_1",
     "lambda_2",
 ]

现在我们可以不对 lambda_2 进行更改,并在 lambda_1 crate 中实现 Wild Rydes 示例的功能。

实现

模板的原始源代码包含一些与上一个示例类似的代码,但我们将从头编写代码,你必须删除原始的main.rs文件。

依赖项

lambda_1 crate 的文件夹中,将以下依赖项添加到Cargo.toml中:

[dependencies]
chrono = "0.4"
lambda_runtime = { git = "https://github.com/awslabs/aws-lambda-rust-runtime" }
log = "0.4"
rand = "0.6"
rusoto_core = {version = "0.35.0", default_features = false, features=["rustls"]}
rusoto_dynamodb = {version = "0.35.0", default_features = false, features=["rustls"]}
serde = "1.0"
serde_derive = "1.0"
serde_json = "1.0"
simple_logger = "1.0"
uuid = { version = "0.7", features = ["v4"] }

如果你阅读了前面的章节,你将熟悉列表中的所有 crate,包括我们在本章前面的部分中使用的lambda_runtime。让我们看看src/main.rs中我们将从该 crate 使用的类型:

use chrono::Utc;
use lambda_runtime::{error::HandlerError, lambda, Context};
use log::debug;
use rand::thread_rng;
use rand::seq::IteratorRandom;
use rusoto_core::Region;
use rusoto_dynamodb::{AttributeValue, DynamoDb, DynamoDbClient, PutItemError, PutItemInput, PutItemOutput};
use serde_derive::{Serialize, Deserialize};
use std::collections::HashMap;
use std::error::Error;
use uuid::Uuid;

我们使用前面的类型实现了以下一系列操作:

  • 解析请求

  • 找到(生成)稍后将声明的Unicorn实例

  • DynamoDb表添加记录

我们的main函数只调用一个执行这些步骤的处理器函数:

fn main() -> Result<(), Box<dyn Error>> {
     simple_logger::init_with_level(log::Level::Debug)?;
     debug!("Starting lambda with Rust...");
     lambda!(handler);
     Ok(())
 }

我们还初始化了日志记录器,以便使用 CloudWatch 服务读取它们。

处理器

处理器执行与原始示例相同的逻辑,但它完全用 Rust 和lambda_runtime crate 重写。看看handler函数的实现:

fn handler(event: Request, _: Context) -> Result<Response, HandlerError> {
     let region = Region::default();
     let client = DynamoDbClient::new(region);
     let username = event
         .request_context
         .authorizer
         .claims
         .get("cognito:username")
         .unwrap()
         .to_owned();
     debug!("USERNAME: {}", username);
     let ride_id = Uuid::new_v4().to_string();
     let request: RequestBody = serde_json::from_str(&event.body).unwrap();
     let unicorn = find_unicorn(&request.pickup_location);
     record_ride(&client, &ride_id, &username, &unicorn).unwrap();
     let body = ResponseBody {
         ride_id: ride_id.clone(),
         unicorn_name: unicorn.name.clone(),
         unicorn,
         eta: "30 seconds".into(),
         rider: username.clone(),
     };
     let mut headers = HashMap::new();
     headers.insert("Access-Control-Allow-Origin".into(), "*".into());
     let body = serde_json::to_string(&body).unwrap();
     let resp = Response {
         status_code: 201,
         body,
         headers,
     };
     Ok(resp)
 }

初始时,这个函数使用默认的Region值连接到 DynamoDB,它最初读取环境变量以获取实际值,如果没有找到区域,则使用us-east-1区域。然后,handler提取由Cognito提供的用户名,我们将使用它来授权用户,而不会手动实现用户注册。

然后,我们生成一次骑行的唯一 ID,并从提供的 JSON 字符串中提取请求的主体。你不能声明一个完整的Request结构体,你必须分两步解析它。第一步使用lambda!宏,第二步使用serde_json::from_str函数调用。然后,我们调用我们稍后将实现的find_unicorn函数,并使用record_ride函数调用将记录添加到数据库中,该函数我们将在本节稍后实现。

当记录被添加时,我们分两步构建响应。首先,我们创建响应的主体,然后将其包裹在额外的值中。我们必须这样做包裹,因为我们将会使用 API 网关通过S3共享的外部应用程序调用 lambda。

现在我们可以查看我们需要的结构体了。

请求和响应类型

主要的结构体是Unicorn,它包含我们将要骑乘的生物:

#[derive(Clone, Serialize)]
 #[serde(rename_all = "PascalCase")]
 struct Unicorn {
     name: String,
     color: String,
     gender: String,
 }

每个Unicorn都有一个namecolorgender。我们将把这些值作为 DynamoDB 记录中的条目存储。为了简化代码中实例的创建,我们将添加以下构造函数:

impl Unicorn {
     fn new(name: &str, color: &str, gender: &str) -> Self {
         Unicorn {
             name: name.to_owned(),
             color: color.to_owned(),
             gender: gender.to_owned(),
         }
     }
 }

你可能会问为什么我们不用枚举来表示颜色和性别。这是可能的,但你必须确保序列化的值正是你想要的。

Location结构体表示地图上的一个点,它将由应用程序的 UI 设置:

#[derive(Deserialize)]
 #[serde(rename_all = "PascalCase")]
 struct Location {
     latitude: f64,
     longitude: f64,
 }

现在我们可以声明一个包含bodyrequest_context字段的Request结构体,我们将使用它来获取由Cognito提供的用户名。您可能已经注意到Location结构体与其他结构体有不同的重命名规则。这是因为Request结构体是由 API 网关解析的,而LocationRequestBody将由前端应用创建,该应用使用其他标识符。Requestbody表示为一个String

#[derive(Deserialize)]
 #[serde(rename_all = "camelCase")]
 struct Request {
     body: String,
     request_context: RequestContext,
 }

RequestContext是一个由运行时填充的映射,我们将将其解析到一个结构体中:

#[derive(Deserialize)]
 #[serde(rename_all = "camelCase")]
 struct RequestContext {
     authorizer: Authorizer,
 }

我们需要一个只包含claims值的Authorizer字段:

#[derive(Deserialize)]
 #[serde(rename_all = "camelCase")]
 struct Authorizer {
     claims: HashMap<String, String>,
 }

我们在handler中使用claims来获取cognito:username值。

#[derive(Deserialize)]
 #[serde(rename_all = "PascalCase")]
 struct RequestBody {
     pickup_location: Location,
 }

现在我们可以声明一个Response。它也被 API 网关使用,必须包含status_codeheaders

#[derive(Serialize)]
 #[serde(rename_all = "camelCase")]
 struct Response {
     body: String,
     status_code: u16,
     headers: HashMap<String, String>,
 }

body字段由一个String类型表示,我们将将其单独反序列化到ResponseBody结构体中:

#[derive(Serialize)]
 #[serde(rename_all = "PascalCase")]
 struct ResponseBody {
     ride_id: String,
     unicorn: Unicorn,
     unicorn_name: String,
     eta: String,
     rider: String,
 }

前面的字段对于工作坊的前端应用是必要的。

现在我们可以添加生成Unicorn实例和向数据库添加记录的功能。

函数

find_unicorn函数从Unicorn的三个预定义值中选择一个:

fn find_unicorn(location: &Location) -> Unicorn {
     debug!("Finding unicorn for {}, {}", location.latitude, location.longitude);
     let unicorns = [
         Unicorn::new("Bucephalus", "Golden", "Male"),
         Unicorn::new("Shadowfax", "White", "Male"),
         Unicorn::new("Rocinante", "Yellow", "Female"),
     ];
     let mut rng = thread_rng();
     unicorns.iter().choose(&mut rng).cloned().unwrap()
 }

record_ride函数从 DynamoDB 构建 put 请求。要执行此类请求,我们需要填充一个只包含属性的HashMap。如果您想了解更多关于如何与 DynamoDB 交互的信息,您可以回到第七章,与数据库的可靠集成,其中我们详细探讨了与数据库的交互。

fn record_ride(
     conn: &DynamoDbClient,
     ride_id: &str,
     username: &str,
     unicorn: &Unicorn,
 ) -> Result<PutItemOutput, PutItemError> {
     let mut item: HashMap<String, AttributeValue> = HashMap::new();
     item.insert("RideId".into(), s_attr(ride_id));
     item.insert("User".into(), s_attr(username));
     item.insert("UnicornName".into(), s_attr(&unicorn.name));
     let timestamp = Utc::now().to_string();
     item.insert("RequestTime".into(), s_attr(&timestamp));
     item.insert("Unicorn".into(), unicorn_map(unicorn));
     let put = PutItemInput {
         table_name: "Rides".into(),
         item,
         ..Default::default()
     };
     conn.put_item(put).sync()
 }

我们还需要一个函数来准备由rusoto_dynamodb crate 使用的AttributeValues,这些类型可以表示为字符串值的引用:

fn s_attr<T: AsRef<str>>(s: T) -> AttributeValue {
     AttributeValue {
         s: Some(s.as_ref().to_owned()),
         ..Default::default()
     }
 }

我们需要的最后一个函数是将Unicorn的字段转换为映射:

fn unicorn_map(unicorn: &Unicorn) -> AttributeValue {
     let mut item = HashMap::new();
     item.insert("Name".into(), s_attr(&unicorn.name));
     item.insert("Color".into(), s_attr(&unicorn.color));
     item.insert("Gender".into(), s_attr(&unicorn.gender));
     AttributeValue {
         m: Some(item),
         ..Default::default()
     }
 }

在本章的后面,您将在 AWS 控制台中看到使用此布局的存储值。

配置

Serverless Framework 使用一个serverless.yml配置文件来部署 Lambda 到 AWS。由于我们安装了serverless-rust插件(它包含在 Rust 模板中),我们可以使用它来设置运行时。填写所描述服务的参数:

service: rust-sls
 provider:
   name: aws
   runtime: rust
   memorySize: 128

以下参数为配置函数提供了更多控制:

package:
   individually: true

我们还必须激活两个插件:一个用于构建 Rust Lambda,另一个用于将资产上传到S3

plugins:
   - serverless-rust
   - serverless-finch

现在我们可以声明我们的函数:

functions:
   lambda_1:
     handler: lambda_1
     role: RustSlsLambdaRole
     events:
       - http:
           path: ride
           method: post
           cors: true
           authorizer:
             type: COGNITO_USER_POOLS
             authorizerId:
               Ref: RustSlsApiGatewayAuthorizer
   lambda_2:
     handler: lambda_2
     events:
       - http:
           path: check
           method: get

第一个函数关联着我们将要声明的RustSlsLambdaRole角色。我们需要它来访问一些资源。Lambda 函数接收一个帖子并支持 CORS,可以从前端调用,这在浏览器中工作。我们还关联了一个授权者,并使用RustSlsApiGatewayAuthorizer,我们将在后面声明。

资源

添加一个包含ResourcesOutputs映射的资源部分,以声明必要的资源和输出变量。让我们添加Resources

resources:
   Resources:

添加一个S3存储桶声明,我们将所有资产放置在这里,并将WebsiteConfiguration设置为设置默认索引文件:

RustSlsBucket:
   Type: AWS::S3::Bucket
   Properties:
     BucketName: rust-sls-aws
     WebsiteConfiguration:
       IndexDocument: index.html

我们还必须添加一个策略,允许外部客户端(如浏览器)读取这些文件:

RustSlsBucketPolicy:
   Type: AWS::S3::BucketPolicy
   Properties:
     Bucket:
       Ref: "RustSlsBucket"
     PolicyDocument:
       Statement:
         -
           Effect: "Allow"
           Principal: "*"
           Action:
             - "s3:GetObject"
           Resource:
             Fn::Join:
               - ""
               -
                 - "arn:aws:s3:::"
                 -
                   Ref: "RustSlsBucket"
                 - "/*"

Wild Rydes 应用程序配置为使用Cognito客户端授权用户使用其账户。让我们使用以下声明进行配置并激活电子邮件确认:

RustSlsCognitoUserPool:
   Type: AWS::Cognito::UserPool
   Properties:
     UserPoolName: RustSls
     UsernameAttributes:
       - email
     AutoVerifiedAttributes:
       - email
RustSlsCognitoUserPoolClient:
   Type: AWS::Cognito::UserPoolClient
   Properties:
     ClientName: RustSlsWebApp
     GenerateSecret: false
     UserPoolId:
       Ref: "RustSlsCognitoUserPool"

在第七章,与数据库的可靠集成中,我们使用了表的 JSON 声明。您也可以使用 Serverless Framework 配置DynamoDB表:

RustSlsDynamoDBTable:
   Type: AWS::DynamoDB::Table
   Properties:
     TableName: Rides
     AttributeDefinitions:
       - AttributeName: RideId
         AttributeType: S
     KeySchema:
       - AttributeName: RideId
         KeyType: HASH
     ProvisionedThroughput:
       ReadCapacityUnits: 1
       WriteCapacityUnits: 1

为我们的lambda_1crate 添加一个角色:

RustSlsLambdaRole:
   Type: AWS::IAM::Role
   Properties:
     RoleName: RustSlsLambda
     AssumeRolePolicyDocument:
       Version: '2012-10-17'
       Statement:
         - Effect: Allow
           Principal:
             Service:
               - lambda.amazonaws.com
           Action: sts:AssumeRole

并将这些策略添加到该角色中:

Policies:
   - PolicyName: DynamoDBWriteAccess
     PolicyDocument:
       Version: '2012-10-17'
       Statement:
         - Effect: Allow
           Action:
             - logs:CreateLogGroup
             - logs:CreateLogStream
             - logs:PutLogEvents
           Resource:
             - 'Fn::Join':
               - ':'
               -
                 - 'arn:aws:logs'
                 - Ref: 'AWS::Region'
                 - Ref: 'AWS::AccountId'
                 - 'log-group:/aws/lambda/*:*:*'
         - Effect: Allow
           Action:
             - dynamodb:PutItem
           Resource:
             'Fn::GetAtt': [ RustSlsDynamoDBTable, Arn ]

我们必须为此角色提供对DynamoDB表的写访问权限。

创建一个authorizer

RustSlsApiGatewayAuthorizer:
   Type: AWS::ApiGateway::Authorizer
   Properties:
     Name: RustSls
     RestApiId:
       Ref: ApiGatewayRestApi
     Type: COGNITO_USER_POOLS
     ProviderARNs:
       - Fn::GetAtt: [ RustSlsCognitoUserPool, Arn ]
     IdentitySource: method.request.header.Authorization

声明输出变量:

Outputs:
   RustSlsBucketURL:
     Description: "RustSls Bucket Website URL"
     Value:
       "Fn::GetAtt": [ RustSlsBucket, WebsiteURL ]
   RustSlsCognitoUserPoolId:
     Description: "RustSls Cognito User Pool ID"
     Value:
       Ref: "RustSlsCognitoUserPool"
   RustSlsCognitoUserPoolClientId:
     Description: "RustSls Cognito User Pool Client ID"
     Value:
       Ref: "RustSlsCognitoUserPoolClient"
   RustSlsDynamoDbARN:
     Description: "RustSls DynamoDB ARN"
     Value:
       "Fn::GetAtt": [ RustSlsDynamoDBTable, Arn ]

此长配置的最后部分声明了serverless-finch插件将使用的文件夹来上传:

custom:
   client:
     bucketName: rust-sls-aws
     distributionFolder: assets

如您所见,我使用了rust-sls-aws作为存储桶名称,但每个S3存储桶都需要一个唯一的全局名称,并且您必须替换所有配置中的存储桶名称才能部署。

Deployment

部署准备工作已经完成。您需要一个有效的 AWS 账户来运行此应用程序。但让我们先创建一个用户,该用户具有使用 AWS CLI 部署应用程序所需的必要权限。

Permissions

要部署此应用程序,您需要配置 AWS CLI 工具和一个具有以下权限的用户:

  • AWSLambdaFullAccess

  • IAMFullAccess

  • AmazonDynamoDBFullAccess

  • AmazonAPIGatewayAdministrator

  • AmazonCognitoPowerUser

  • CloudFormationAdministrator

值得注意的是,后者是手动创建的,可以在配置用户时通过向策略中添加 JSON 定义来添加:

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "Stmt1449904348000",
            "Effect": "Allow",
            "Action": [
                "cloudformation:CreateStack",
                "cloudformation:CreateChangeSet",
                "cloudformation:ListStacks",
                "cloudformation:UpdateStack",
                "cloudformation:DeleteStack",
                "cloudformation:DescribeStacks",
                "cloudformation:DescribeStackResource",
                "cloudformation:DescribeStackEvents",
                "cloudformation:ValidateTemplate",
                "cloudformation:DescribeChangeSet",
                "cloudformation:ExecuteChangeSet"
            ],
            "Resource": [
                "*"
            ]
        }
    ]
}

当您创建了一个具有必要凭证的用户后,您可以使用 Serverless Framework 构建和部署应用程序,该框架会自动构建所有 lambdas。

Script

我们需要一些在部署前不知道的值。我们将使用sls info -v命令获取我们需要配置前端的实际值。创建一个 bash 脚本来添加必要的部署功能。首先,我们需要一个extract函数来获取sls info输出的第二列空格分隔的值:

extract() {
     echo "$DATA" | grep $1 | cut -d " " -f2
 }

要使用 Serverless Framework 部署应用程序,您必须调用sls deploy命令,但我们的应用程序更复杂,我们必须使用一系列命令:

deploy() {
     echo "ASSETS DOWNLOADING"
     curl -L https://api.github.com/repos/aws-samples/aws-serverless-workshops/tarball \
         | tar xz --directory assets --wildcards "*/WebApplication/1_StaticWebHosting/website" --strip-components=4
     echo "LAMBDAS BUILDING"
     sls deploy
     echo "ASSETS UPLOADING"
     sls client deploy
     echo "CONFIGURATION UPLOADING"
     DATA=`sls info -v`
     POOL_ID=`extract PoolId`
     POOL_CLIENT_ID=`extract PoolClientId`
     REGION=`extract region`
     ENDPOINT=`extract ServiceEndpoint`
     CONFIG="
     window._config = {
         cognito: {
             userPoolId: '$POOL_ID',
             userPoolClientId: '$POOL_CLIENT_ID',
             region: '$REGION'
         },
         api: {
             invokeUrl: '$ENDPOINT'
         }
     };
     "
     echo "$CONFIG" | aws s3 cp - s3://rust-sls-aws/js/config.js
     INDEX=`extract BucketURL`
     echo "INDEX: $INDEX"
 }

deploy 函数中,我们从 GitHub 下载 Wild Rydes 应用程序的前端部分,并将其所需文件夹提取到我们项目的 assets 文件夹中。然后我们调用 sls deploy 来部署应用程序的堆栈。然后我们调用 sls client deploy 将所有资产发布到 S3。当所有部分都部署完毕后,我们使用 extract 函数获取所有必要的值以填充 config.js 文件,这是连接已部署的前端与我们的 Rust 实现的 lambda 所必需的。我们使用嵌入式模板构建一个 config.js 文件,并使用 aws s3 cp 命令上传它。

让我们运行这个命令。

运行

如果你已经从 GitHub 下载了本章项目的源代码,你可以使用 deploy.sh 脚本来调用我们之前实现的函数。提供要调用的 deploy 函数的名称:

./deploy.sh deploy

它将使用 Serverless Framework 开始构建和部署过程,并打印出类似以下内容:

ASSETS DOWNLOADING
   % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                  Dload  Upload   Total   Spent    Left  Speed
   0     0    0     0    0     0      0      0 --:--:-- --:--:-- --:--:--     0
 100 65.7M    0 65.7M    0     0  7647k      0 --:--:--  0:00:08 --:--:-- 9968k
 LAMBDAS BUILDING
 Serverless: Building native Rust lambda_1 func...
     Finished release [optimized] target(s) in 0.56s                                                                                                                                                                
   adding: bootstrap (deflated 60%)
 Serverless: Building native Rust lambda_2 func...
     Finished release [optimized] target(s) in 0.32s                                                                                                                                                                
   adding: bootstrap (deflated 61%)
 Serverless: Packaging service...
 Serverless: Creating Stack...
 Serverless: Checking Stack create progress...
 .....
 Serverless: Stack create finished...
 Serverless: Uploading CloudFormation file to S3...
 Serverless: Uploading artifacts...
 Serverless: Uploading service .zip file to S3 (2.75 MB)...
 Serverless: Uploading service .zip file to S3 (1.12 MB)...
 Serverless: Validating template...
 Serverless: Updating Stack...
 Serverless: Checking Stack update progress...
 ........................................................................
 Serverless: Stack update finished...
 Service Information
 service: rust-sls
 stage: dev
 region: us-east-1
 stack: rust-sls-dev
 api keys:
   None
 endpoints:
   POST - https://48eggoi698.execute-api.us-east-1.amazonaws.com/dev/ride
   GET - https://48eggoi698.execute-api.us-east-1.amazonaws.com/dev/check
 functions:
   lambda_1: rust-sls-dev-lambda_1
   lambda_2: rust-sls-dev-lambda_2
 layers:
   None

部署需要时间,完成后,将调用第二个命令 sls client deploy,使用 serverless-finch 插件上传 assets 文件夹,并打印以下内容:


ASSETS UPLOADING
Serverless: This deployment will:
Serverless: - Upload all files from 'assets' to bucket 'rust-sls-aws'
Serverless: - Set (and overwrite) bucket 'rust-sls-aws' configuration
Serverless: - Set (and overwrite) bucket 'rust-sls-aws' bucket policy
Serverless: - Set (and overwrite) bucket 'rust-sls-aws' CORS policy
? Do you want to proceed? true
Serverless: Looking for bucket...
Serverless: Bucket found...
Serverless: Deleting all objects from bucket...
Serverless: Configuring bucket...
Serverless: Configuring policy for bucket...
Serverless: Configuring CORS for bucket...
Serverless: Uploading client files to bucket...
Serverless: Success! Your site should be available at http://rust-sls-aws.s3-website-us-east-1.amazonaws.com/
CONFIGURATION UPLOADING
INDEX: http://rust-sls-aws.s3-website-us-east-1.amazonaws.com

脚本打印出了我们可以用来连接和测试应用的链接。

测试

在浏览器中打开提供的 URL,你将看到 Wild Rydes 前端应用程序。

用户必须点击 GIDDY UP! 按钮,并使用 Cognito 注册账户,实际上这个服务是在后台使用的,用户不需要直接与该服务交互。

你将看到可爱的用户界面。点击地图并点击 设置取货 按钮,你将看到独角兽的头如何移动到你设置的位置:

独角兽的名称和颜色是由我们用 Rust 创建的 lambda 函数生成的。如果你打开 AWS 控制台的一些页面,你可以在 用户池 部分的 用户和组 页面上看到一个注册的用户:

我们部署了两个 lambda,但实际上应用程序只使用了第一个,称为 rust-sls-dev-lambda_1

如果你进入 lambda 的页面,点击 监控 标签,并打开 lambda 的 CloudWatch 日志,你可以看到 lambda 生成了一个用户名,并存储在我们设置的位置:

lambda 还在 DynamoDB 中存储了一个记录,你也可以在 DynamoDB 部分的 表格 页面上找到它:

你可以看到 lambda 添加的记录。如果你点击记录,你将看到我们之前使用 record_ride 函数填充的所有字段:

应用程序已成功移植到 Rust,并且按预期工作。让我们看看我们如何清理我们使用的资源。

更新和删除

如果你再次调用sls deploy,Serverless Framework 还提供了自动更新资源的能力。我们可以将此作为函数添加到部署 bash 脚本中:

update() {
     sls deploy
 }

如果你想要更新一些 Lambda 的代码,但又不想离开由Cognito维护的会话,这个命令很有用。

要移除我们部署的所有内容,我们可以使用以下函数:

remove() {
     echo "ASSETS REMOVING"
     sls client remove
     echo "LAMBDAS REMOVING"
     sls remove
     echo "ASSETS CLEANUP"
     rm -rf assets
     mkdir assets
     touch assets/.gitkeep
 }

它之所以有效,是因为 Serverless Framework 支持移除声明的资源。我建议你在实验后清理所有内容,因为即使你不使用这个演示,AWS 也会为服务产生账单。

摘要

在这一章中,我们研究了微服务实现的一种替代方法——无服务器架构。这种方法涉及直接使用处理传入请求的函数。有许多无服务器基础设施的提供商,我们使用了流行的 AWS 平台将无服务器应用程序移植到 Rust。

posted @ 2025-09-06 13:43  绝不原创的飞龙  阅读(33)  评论(0)    收藏  举报