Rust 从零到生产 简体中文版

本书为 《Zero to Production in Rust》的非官方翻译

欢迎贡献: cubewhy/zero2prod-zhcn

边学边翻译的, 可能有点慢/不准确...

只是自用翻译, 很多地方有个人成分... 如果有出错的地方请开issue告诉我哦

有的注解是我自己写的, 原作的话那些部分是不存在的

使用谷歌翻译+人工校对翻译

翻译版本的代码使用 Rust 2024, 过时代码已迁移到可用写法

序言

前言

当你读到这些文字时,Rust 已经实现了它最大的目标:向程序员们提供机会,让他们用另一种语言编写他们的生产系统。读完本书后,你仍然可以选择是否采用这种方式,但你已经具备了考虑这个机会所需的一切。我参与了两种截然不同的语言:Ruby 和 Rust 的成长历程——不仅参与编程,还举办活动、参与项目管理,并围绕它们开展业务。通过这些,我有幸与许多语言的创造者保持联系,并将其中一些人视为朋友。Rust 是我一生中唯一一次见证并帮助一门语言从实验阶段发展到被业界广泛接受的机会。

我要告诉你一个我一路走来学到的秘密:编程语言的采用并非因为功能清单。这是一个复杂的相互作用,它需要优秀的技术、谈论它的能力,以及找到足够多愿意长期投资的人。当我写下这些文字时,已有超过 5000 人利用业余时间为 Rust 项目做出了贡献,而且通常是免费的——因为他们相信这份赌注。

但你不必为编译器做出贡献,也不必被记录在 git log 中才能为 Rust 做出贡献。

Luca 的书就是这样一份贡献:它为新人提供了一个了解 Rust 的视角,并推广了众多优秀人士的优秀工作。

Rust 从未打算成为一个研究平台——它始终是一门编程语言,

用于解决大型代码库中实际存在的、切实可行的问题。毫不奇怪,它出自一个维护着庞大而复杂代码库的组织——Mozilla,Firefox 的创建者。我加入 Rust 时, 只是怀揣着雄心壮志——但这个雄心壮志是将研究成果产业化,让未来的软件变得更好。凭借其丰富的理论概念、线性类型、基于区域的内存管理,这门编程语言始终面向所有人。

这反映在其术语中:Rust 使用像“所有权”和“借用”这样通俗易懂的名称来指代我刚才提到的概念。Rust 是一门彻头彻尾的工业语言。

这也反映在其支持者身上:我认识 Luca 多年,他是 Rust 社区的成员,对 Rust 了如指掌。但他更深层次的兴趣在于通过满足人们的需求,让他们相信 Rust 值得一试。本书的标题和结构体现了 Rust 的核心价值之一: 在编写可靠且有效的生产软件中发现它的价值。

Rust 的优势在于,它倾注了人们的精力和知识,从而高效地编写出稳定的软件。这种体验最好通过指南来获得,而 Luca 就是你能找到的关于 Rust 的最佳指南之一。

Rust 并不能解决你所有的问题,但它努力消除各种错误。有一种观点认为,语言中的安全特性是因为程序员的无能。我不认同这种观点。 Emily Dunham 在 2017 年 RustConf 主题演讲中完美诠释了这一点:“安全代码让你能够更好地承担风险”。Rust 社区的魅力很大程度上在于其对用户的积极看法:无论你是新手还是经验丰富的开发者,我们都信任你的经验和决策。在这本书中,Luca 提供了许多即使在 Rust 之外也能应用的新知识,并在日常软件实践中进行了详尽的解释。祝你阅读、学习和思考愉快。

Florian Gilcher,

Ferrous Systems 管理总监兼

Rust 基金会联合创始人

本书讲述了什么

后端开发领域广阔无垠

你所处的环境对解决当前问题的最佳工具和实践有着巨大的影响。

例如,基于主干的开发方式非常适合编写在云环境中持续部署的软件。 同样的方法可能不太适合销售由客户托管和运行的软件的团队的业务模式和面临的挑战——他们更有可能从 Gitflow 方法中受益。 如果你是独自工作,可以直接推送到主干。 在软件开发领域,很少有绝对的事物,我认为在评估任何技术或方法的优缺点时,阐明你的观点是有益的。

“从零到生产”将重点关注由四到五名经验和熟练程度各异的工程师组成的团队编写云原生应用程序所面临的挑战。

云原生应用程序

定义 云原生应用 本身就足以写一本新书了[1]。与其规定云原生应用应该是什么样子,不如明确规定它们应该做什么。 借用 Cornelia Davis 的话来说,我们期望云原生应用:

  • 在易发生故障的环境中运行时实现高可用性
  • 在发布新版本的时候实现零停机
  • 处理动态工作负载(workload)

这些要求对我们软件架构的可行解决方案空间有着深远的影响。

高可用性意味着,即使我们的一台或多台机器突然出现故障(云环境中的常见情况[2]),我们的应用程序也应该能够在不停机的情况下处理请求。

这迫使我们的应用程序必须分布式运行——应该在多台机器上运行多个实例。

如果我们想要处理动态工作负载,情况也是如此——我们应该能够衡量系统是否负载过重,并通过启动新的应用程序实例来投入更多计算资源来解决问题。这还要求我们的基础设施具有弹性,以避免过度配置及其相关成本。

运行复制应用程序会影响我们的数据持久化方法——我们将避免使用本地文件系统作为主存储解决方案,而是依靠数据库来满足我们的持久化需求。

因此, 《从零到生产》将广泛涵盖那些看似与纯后端应用程序开发无关的主题。但云原生软件的核心在于彩虹 (Rainbow [3]) 和 DevOps,我们将花费大量时间讨论传统上与操作系统技术相关的主题。

我们将介绍如何对 Rust 应用程序进行检测,以收集日志、跟踪和指标,以便能够观察我们的系统。

我们将介绍如何通过迁移设置和改进数据库架构。

我们将涵盖使用 Rust 解决云原生 API 第一天和第二天问题所需的所有材料。

  • [1]: 就像 Cornelia Davis 的优秀云原生模式一样!
  • [2]: 例如,许多公司在 AWS Spot 实例上运行软件,以降低基础设施成本。Spot 实例的价格是通过持续竞价确定的,可能比按需实例的全价便宜得多(最高可便宜 90%!)。但有一个问题:AWS 可以随时停用您的 Spot 实例。您的软件必须具备容错能力才能利用这一机会。
  • [3]: 源书并没有这个注解, 关于什么是彩虹部署可以参考这个

和团队一起工作

这三个需求的影响超越了我们系统的技术特性:它影响着我们构建软件的方式。

为了能够快速向用户发布应用程序的新版本,我们需要确保我们的应用程序能够正常运行。

如果您正在独立开发一个项目,您可以依赖您对整个系统的透彻理解:您亲子编写了它,它可能很小,但是你随时可以回想起它的细节。[3] 如果您在团队中开发一个商业项目,您经常会遇到一些代码,这些代码既不是您编写的,也不是您审核的。原作者可能已经不在了。

如果您依赖于对代码功能的全面理解来防止代码崩溃,那么每次要引入更改时,您最终都会被恐惧所麻痹。(译者注: 俗称不敢动代码)

您需要写自动化测试。

在每次提交时自动运行。在每个分支上运行。保持 main 分支健康。

您需要利用类型系统使不良状态难以或无法表示。

您希望使用您掌握的所有工具,赋能团队中的每一位成员,让他们能够开发该软件。

即使他们可能不如您经验丰富,或者对代码库或您正在使用的技术不那么熟悉,他们也能全力投入开发过程。

因此,“从零到生产”项目从一开始就将重点放在测试驱动开发和持续集成上——我们甚至在启动和运行 Web 服务器之前就会设置 CI 流水线!

我们将介绍 API 的黑盒(black-box)测试和 HTTP mocking 等技术——这些技术在 Rust 社区中并不流行,文档也并不完善,但却非常强大。

我们还将借鉴领域驱动设计领域 (Domain Driven Design)的术语和技术,并将它们与类型驱动设计 (type-driven design)相结合,以确保系统的正确性。

我们的主要关注点是企业软件:写足以对领域进行建模的代码,并且支持长期演化。 (注: 就是说为未来考虑, 追求可维护性)

因此,我们会偏向于那些枯燥无味且正确的解决方案,即使它们会带来性能开销,而这些开销可以通过更谨慎、更精妙的方法进行优化。 先让它运行起来,然后再进行优化(如果需要)。

本书适合谁阅读

Rust 生态系统一直致力于通过面向初学者和新手的精彩资料打破采用障碍,从文档到编译器诊断的持续完善,我们付出了不懈的努力。 服务尽可能多的受众是有价值的。

同时,试图总是面向所有人讲解可能会产生有害的副作用:一些资料 可能与中级和高级用户相关,但对于初学者来说却太过繁琐和仓促, 最终会被忽视。

当我开始尝试使用 async/await 时,我亲身经历了这个问题。

我所需的高效知识与我通过阅读《Rust 之书》或在 Rust 数值生态系统中工作所积累的知识之间存在巨大差距。我想得到一个直截了当的问题的答案:

  • Rust 能成为一种高效的 API 开发语言吗?

答案是肯定的。

但弄清楚如何操作可能需要一些时间。 这就是我写这本书的原因。 我写这本书是为了那些读过《The Rust Book》并正在尝试移植一些简单系统的经验丰富的后端开发人员。

我写这本书是为了我团队的新工程师,帮助他们理解未来几周和几个月将要贡献的代码库。

我写这本书是为了一个利基市场,我认为目前 Rust 生态系统中的文章和资源无法满足他们的需求。

一年前,我为自己写了这本书。

分享在学习过程中获得的知识:如果你在 2022 年使用 Rust 进行后端开发,你的工具箱是什么样的?有哪些设计模式?有哪些陷阱?

如果你不符合这个描述,但正在努力实现它,我会尽力帮助你:虽然我们不会直接涵盖很多内容(例如大多数 Rust 语言特性),但我会尽量在需要的地方提供参考资料和链接,帮助你在学习过程中掌握/理解这些概念。

让我们开始吧

入门

安装Rust工具链

在您的系统上安装 Rust 的方法有很多种,但我们将重点介绍推荐的途径:通过 rustup。

有关如何安装 rustup 的说明,请访问 rustup.rs

rustup 不仅仅是一个 Rust 安装程序——它的是一个工具链管理工具

工具链是编译目标和发布渠道的组合。

编译目标

Rust 编译器的主要目的是将 Rust 代码转换为机器码——一组 CPU 和操作系统能够理解和执行的指令。

因此,您需要为每个编译目标(即您想要生成可运行可执行文件的每个平台(例如 64 位 Linux 或 64 位 OSX))配备不同的 Rust 编译器后端。

Rust 项目致力于支持各种编译目标,并提供不同级别的保证。目标分为不同等级,从“保证可用”的 Tier 1 到“尽力而为”的 Tier 3。

您可以在此处找到详尽的最新列表。

发布频道

Rust 编译器本身是一个动态的软件:它随着数百名志愿者的日常贡献而不断发展和改进。

Rust 项目追求稳定,而非停滞不前。以下是 Rust 文档 中的一段话:

您无需担心升级到新版 Rust 的稳定版本。每次升级都应轻松无痛,同时还能带来新功能、更少的 Bug 和更快的编译时间。

因此,对于应用程序开发,您通常应该依赖编译器的最新发布版本来运行、构建和测试您的软件——即所谓的稳定版本。

编译器每六周就会在稳定版本上发布一个新版本 ──本文撰写时的最新版本是 v1.43.1。

另外两个发布频道是

  • beta - 下一版本的候选版本
  • nightly - 每个晚上自动从 rust-lang/rust 自动构建, 这也是nightly名字的由来

使用 Beta 编译器测试软件是支持 Rust 项目的众多方法之一 —— 它有助于在发布日期之前发现错误。

Nightly 编译器则有不同的用途:它让早期采用者在未完成的功能7发布之前(甚至在稳定之前!)能够使用它们。

如果您计划在 Nightly 编译器上运行生产软件,我建议您三思:它被称为不稳定是有原因的。

我们需要什么工具链

安装 rustup 将为您提供最新的稳定编译器,并以您的主机平台作为目标平台。stable 是本书中用于构建、测试和运行代码的发布渠道。

您可以使用 rustup update 更新您的工具链,而 rustup toolchain list 则会为您提供系统上已安装内容的概览。

我们不需要(或执行)任何交叉编译——我们的生产工作负载将在容器中运行,因此我们不需要从开发机器交叉编译到生产环境中使用的目标平台

初始化我们的项目

通过 rustup 安装的工具链会将各种组件捆绑在一起。 其中之一就是 Rust 编译器 rustc。您可以使用以下命令进行检查:

rustc --version

您无需花费大量时间直接使用 Rustc——您构建和测试 Rust 应用程序的主要界面将是 Rust 的构建工具 Cargo。

您可以使用以下命令再次检查一切是否正常运行

cargo --version

我们接下来用 cargo 创建项目的骨架, 在整本书里, 我们都要在这个项目上工作

cargo new zero2prod

项目文件夹 zero2prod 的结构看起来应该是这样的

zero2prod/
  Cargo.toml
  .gitignore
  .git
  src/
    main.rs

该项目本身就是一个 Git 仓库,开箱即用。 (cargo 会自动创建git仓库) 如果您计划将项目托管在 GitHub 上,只需创建一个新的空仓库并运行

cd zero2prod
git add .
git commit -am "Project skeleton"
git remote add origin git@github.com:YourGitHubNickName/zero2prod.git
git push -u origin main

鉴于 GitHub 的受欢迎程度以及其最近发布的用于 CI 流水线的 GitHub Actions 功能,我们将以GitHub 为参考,但您可以自由选择任何其他 git 托管解决方案 (或根本不选择任何解决方案)

选择一个集成开发环境 (IDE)

项目框架已准备就绪,现在是时候启动你最喜欢的编辑器,开始摆弄它了。

每个人的偏好各不相同,但我认为,你至少应该拥有一个支持语法高亮、代码导航和代码补全的设置,尤其是在你刚开始学习一门新的编程语言时。

语法高亮可以立即反馈明显的语法错误,而代码导航和代码补全则支持“探索性”编程:你可以快速跳转至依赖项的源代码,快速访问从 crate 导入的结构体或枚举的可用方法,而无需在编辑器和 docs.rs 之间不断切换。

IDE 设置主要有两个选项:rust-analyzer 和 IntelliJ Rust (现在叫做RustRover)

译者注: 当然我本人喜欢用Neovim + rust-analyzer, VS Code看起来也很不错的说

Rust-analyzer

rust-analyzer 是 Rust LSP (Language Server Protocol) 的一个实现。

LSP 使得在各种编辑器中轻松使用 rust-analyzer 变得容易

这意味着你可以在 VS Code、Emacs、Vim/NeoVim 和 Sublime Text 3获得相同的Rust开发体验

特定编辑器的设置说明可在此处找到

IntelliJ Rust/RustRover

IntelliJ Rust 为 JetBrains 开发的编辑器套件提供 Rust 支持。

如果您没有 JetBrains 许可证,可以免费使用支持 In从telliJ Rust 的 IntelliJ IDEA 社区版本

如果您拥有 JetBrains 许可证,那么 CLion 是您首选的 Rust 编辑器。 (译者注: 现在用RustRover的话会更合适的)

我该用哪个IDE

译者注: 本段疑似过时, 现在 rust-analyzer 看起来很稳定, 如果你介意专有软件的话, 建议使用 rust-analyzer + 任何一个支持LSP的编辑器

截至 2022 年 3 月,IntelliJ Rust 应为首选。

尽管 rust-analyzer 前景光明,并且在过去一年中取得了令人难以置信的进步,但它距离提供与 IntelliJ Rust 目前提供的 IDE 体验相当的体验还相去甚远。

另一方面,IntelliJ Rust 会强制您使用 JetBrains 的 IDE,而您可能愿意,也可能不愿意。如果您想继续使用您选择的编辑器,请寻找其 rust-analyzer 集成/插件。

值得一提的是,rust-analyzer 是 Rust 编译器内部正在进行的更大规模库化工作的一部分:rust-analyzer 和 rustc 之间存在重叠,存在大量重复工作。

将编译器的代码库演化为一组可重用的模块,将使 rust-analyzer 能够利用编译器代码库中越来越大的子集,从而释放提供一流 IDE 体验所需的按需分析功能。

这是一个值得未来关注的有趣空间

内部开发循环

在项目开发过程中,我们会反复执行相同的步骤:

  • 进行修改
  • 编译应用程序
  • 运行测试
  • 运行应用程序

这也称为内部开发循环。

内部开发循环的速度是单位时间内可以完成的迭代次数的上限。

如果编译和运行应用程序需要 5 分钟,那么每小时最多可以完成 12 次迭代。如果将其缩短到 2 分钟,那么每小时就可以完成 30 次迭代!

Rust 在这方面帮不上忙——编译速度可能会成为大型项目的痛点。在继续下一步之前,让我们看看可以采取哪些措施来缓解这个问题。

让链接 (Linking) 更快点

在考察内部开发循环时,我们主要关注增量编译的性能——在对源代码进行微小更改后,Cargo 需要多长时间来构建二进制文件。

链接阶段 会花费相当多的时间——根据早期编译阶段的输出来组装实际的二进制文件。

默认链接器的性能不错,但根据您使用的操作系统,还有其他更快的替代方案:

  • Windows 和Linux上的 lld, LLVM 项目开发的链接器
  • MacOS上的 zld

为了加快链接阶段,您必须在计算机上安装备用链接器,并将此配置文件添加到项目中:(别忘了安装链接器!)

# .cargo/config.toml
# On Windows
# ```
# cargo install -f cargo-binutils
# rustup component add llvm-tools-preview
# ```
[target.x86_64-pc-windows-msvc]
rustflags = ["-C", "link-arg=-fuse-ld=lld"]
[target.x86_64-pc-windows-gnu]
rustflags = ["-C", "link-arg=-fuse-ld=lld"]
# On Linux:
# - Ubuntu, `sudo apt-get install lld clang`
# - Arch, `sudo pacman -S lld clang`
[target.x86_64-unknown-linux-gnu]
rustflags = ["-C", "linker=clang", "-C", "link-arg=-fuse-ld=lld"]
# On MacOS, `brew install michaeleisel/zld/zld`
[target.x86_64-apple-darwin]
rustflags = ["-C", "link-arg=-fuse-ld=/usr/local/bin/zld"]
[target.aarch64-apple-darwin]
rustflags = ["-C", "link-arg=-fuse-ld=/usr/local/bin/zld"]

Rust 编译器目前正在努力尽可能使用 lld 作为默认链接器——很快,这种自定义配置将不再是实现更高编译性能的必需品!

cargo-watch

我们还可以通过减少感知编译时间(即您花在终端上等待 cargo check 或 cargo run 完成的时间)来减轻对生产力的影响。

有工具可以帮我们! 我们可以用这个命令来安装 cargo-watch

cargo install cargo-watch

cargo-watch 会监控你的源代码,并在文件每次更改时自动执行命令。例如:

cargo watch -x check

这个命令将在每次源代码发生变化的时候自动运行 cargo check

这会减少你的编译时间

  • 您仍在 IDE 中,重新阅读刚刚所做的代码更改
  • 与此同时,cargo-watch 已经启动了编译过程
  • 切换到终端后,编译器已经运行了一半

cargo-watch 也支持命令链:

cargo watch -x check -x test -x run
  • 它会先运行 cargo check
  • 如果成功,它会启动 cargo test
  • 如果测试通过,它会使用 cargo run 启动应用程序。

我们的内部开发循环,就在这里!

持续集成

工具链已安装。 项目框架已完成。 IDE 已准备就绪。

在我们深入了解构建细节之前,最后要考虑的是我们的 持续集成 (CI) 流水线

在基于主干的开发中,我们应该能够在任何时间点部署主分支。

团队的每个成员都可以从主分支分支开发一个小功能或修复一个错误,然后合并回主分支并发布给用户。

持续集成使团队的每个成员能够每天多次将他们的更改集成到主分支中。

这会产生强大的连锁反应。

有些是显而易见且易于察觉的:它减少了由于长期分支而不得不处理混乱的合并冲突的机会。没有人喜欢合并冲突。

有些则更为微妙:持续集成可以缩短反馈循环。 你不太可能独自开发几天或几周,却发现你选择的方法并未得到团队其他成员的认可,或者无法与项目的其他部分很好地集成。

它迫使你尽早与同事沟通,并在必要时在仍然容易进行(并且不会冒犯任何人)的情况下进行纠正。

我们怎么让这些事情变得可能呢?

我们的 CI 流水线会在每次提交时运行一系列自动化检查。

如果其中一项检查失败,您将无法合并到主代码库 - 就这么简单。

CI 流水线通常不仅仅是确保代码健康:它们还是执行一系列其他重要检查的理想场所 - 例如,扫描依赖关系树以查找已知漏洞、进行 linting、格式化等等。

我们将逐一介绍您可能希望在 Rust 项目的 CI 流水线中运行的各种检查,并逐步介绍相关工具。 然后,我们将为一些主要的 CI 提供商提供一套现成的 CI 流水线。

CI 步骤

测试

如果您的 CI 流水线只有一个步骤,那么它应该是测试。

测试是 Rust 生态系统中的一流概念,您可以利用 cargo 来运行单元测试和集成测试:

cargo test

cargo test 还会在运行测试之前构建项目,因此您无需 事先运行 cargo build (尽管大多数流水线会在运行测试之前调用 cargo build 来缓存依赖项)。

代码覆盖率

关于测量代码覆盖率的利弊,已经有很多文章进行了探讨。

虽然使用代码覆盖率作为质量检查有一些缺点,但我确实认为它是一种快速收集信息并发现代码库中某些部分是否长期被忽视以及 测试是否不足的方法。 测量 Rust 项目代码覆盖率最简单的方法是使用 cargo tarpaulin,这是 xd009642 开发的 cargo 子命令。您可以使用以下命令安装 tarpaulin

# At the time of writing tarpaulin only supports
# x86_64 CPU architectures running Linux.
cargo install cargo-tarpaulin

当运行如下命令的时候, 将计算应用程序代码的代码覆盖率,忽略测试函数。 tarpaulin 可用于将代码覆盖率指标上传到 CodecovCoveralls 等热门服务。

请参阅 tarpaulin 的 README 文件,了解如何上传代码覆盖率指标。

cargo tarpaulin --ignore-tests

代码检查 (Linting)

用任何编程语言编写符合风格的代码都需要时间和练习。

在学习初期,很容易遇到一些可以用更简单方法解决的问题,最终却得到相当复杂的解决方案。

静态分析可以提供帮助:就像编译器单步执行代码以确保其符合语言规则和约束一样,linter 会尝试识别不符合风格的代码、过于复杂的结构以及常见的错误/低效之处。

Rust 团队维护着 Clippy, 官方的 Rust Linter

如果您使用默认配置文件,clippy 会包含在 rustup 安装的组件集中。

CI 环境通常使用 rustup 的最小配置文件,其中不包含 clippy。

但是您可以使用以下命令来安装它

rustup component add clippy

如果你已经安装过 clippy 了, 执行这个命令什么也不会发生

你可以执行如下的命令来为你的项目运行 clippy

cargo clippy

在我们的 CI 管道中,如果 clippy 发出任何警告,我们希望 Linter 检查失败。

我们可以通过以下方式实现:

cargo clippy -- -D warnings

静态分析并非万无一失:有时 clippy 可能会建议一些你认为不正确或不理想的更改。

你可以在受影响的代码块上使用 #[allow(clippy::lint_name)] 属性来关闭特定的警告,

或者在 clippy.toml 中使用一行配置语句,为整个项目禁用干扰性的 lint 检查。

或使用项目级 #![allow(clippy::lint_name)] 宏。

有关可用的 lint 以及如何根据您的特定目的进行调整的详细信息,请参阅 clippy 的 README 文件

代码格式化

大多数组织对主分支都有不止一道防线:

  • 一道是 持续集成 (CI) 流水线检查
  • 另一道通常是*拉取请求 (PR) 审查**

关于有价值的 PR 审查流程与枯燥乏味 PR 审查流程的区别,有很多说法——无需在此重新展开争论。

我确信好的 PR 审查不应该关注以下几点:格式上的小瑕疵——例如,"你能在这里加个换行符吗?"、"我觉得那里尾部有个空格!" 等等。

让机器处理格式,而审查人员则专注于架构、测试的完整性、可靠性和可观察性。自动格式化可以消除 PR 审查流程中复杂的干扰。你可能不喜欢这样或那样的格式选择,但彻底消除格式上的繁琐, 值得承受些许不适。

rustfmt 是 Rust 官方的格式化程序。

与 clippy 一样,rustfmt 也包含在 rustup 安装的默认组件中

您可以使用以下命令安装它

rustup component add rustfmt

你可以使用如下命令来为你的整个项目格式化代码

cargo fmt

在我们的 CI 流中,我们将添加格式化步骤

cargo fmt -- --check

如果提交包含未格式化的代码,它将失败,并将差异打印到控制台。

您可以使用配置文件 rustfmt.toml 针对项目调整 rustfmt。详细信息请参阅 rustfmt 的 README 文件

安全漏洞

cargo 使得利用生态系统中现有的 crate 来解决当前问题变得非常容易。

另一方面,每个 crate 都可能隐藏可利用的漏洞,从而危及软件的安全状况。

Rust 安全代码工作组 (Rust Secure Code working group) 维护着一个 数据库,其中包含 crates.io 上发布的 crate 的最新漏洞报告。

他们还提供了 cargo-audit,这是一个便捷的 cargo 子命令,用于检查项目依赖关系树中是否有任何 crate 存在漏洞。

您可以使用以下命令安装它

cargo install cargo-audit

当你安装之后, 你可以执行如下命令来扫描你的依赖树

cargo audit

我们将在每次提交时运行 cargo-audit,作为 CI 流水线的一部分。

我们还将每天运行它,以随时掌握那些我们目前可能没有积极开发但仍在生产环境中运行的项目依赖项的新漏洞!

开箱即用的 CI 流

授人以鱼,不如授人以渔

希望我教给你的知识足以让你为你的 Rust 项目构建一个可靠的 CI 流水线。

我们也应该坦诚地承认,学习如何使用 CI 提供商所使用的特定配置语言可能需要花费数小时的反复尝试,而且调试过程通常非常痛苦,反馈周期也很长。

因此,我决定编写一套适用于最常用现成配置文件, —— 就是我们刚才描述的那些步骤,可以直接放到你的项目仓库中:

调整现有设置以满足您的特定需求通常比从头开始编写新设置要容易得多

写一个电子邮件新闻程序

我们的驱动示例

前言指出

“从零到生产”将重点关注由四到五名经验和熟练程度各异的工程师组成的团队编写云原生应用程序所面临的挑战。

怎么做? 嗯,那就亲手建造一个吧!

基于问题的学习

选择一个你想解决的问题。

让问题驱动新概念和新技术的引入。

它颠覆了你习惯的层级结构:你正在学习的材料并非因为有人声称它相关,而是因为有助于更接近解决方案。

你学习新技术,并知道何时应该学习它们。

细节决定成败:基于问题的学习路径可能令人愉悦,但人们很容易误判旅程中每一步的挑战程度。

我们驱动的示例需要:

  • 足够小,以便我们能够在不偷工减料的情况下在一本书中解决;
  • 足够复杂,以便涵盖大型系统中出现的大多数关键主题;
  • 足够有趣,以便读者在学习过程中保持兴趣。

我们将采用电子邮件简报的形式——下一节将详细介绍我们计划涵盖的功能

纠正教程

基于问题的学习在互动环境中效果最佳:教师充当引导者,

根据参与者的行为线索和反应提供或多或少的支持。

在网站上出版的书籍无法给我同样的机会。

我非常感谢您对材料的反馈——请联系 contact@lpalmieri.com 或在推特上给我发送私信。

在现阶段,提供反馈是为“从零到生产”做出贡献的切实可行的方式。

我们的Newsletter应该做什么

有数十家公司提供的服务包含或主要围绕管理电子邮件地址列表这一理念。

虽然它们都拥有一些核心功能(例如发送电子邮件),但它们的服务都是针对特定用例量身定制的:面向管理数十万个地址且具有严格安全和合规要求的大公司的产品,与面向运营自有博客或小型在线商店的独立内容创作者的 SaaS 产品,在用户界面、营销策略和定价方面会有很大差异。

现在,我们并没有打造下一个 MailChimp 或 ConvertKit 的野心——它们的范围肯定太广,我们无法在一本书中全部涵盖。此外,一些功能需要反复应用相同的概念和技术——读久了会变得乏味。

如果您愿意在您的博客中添加一个电子邮件订阅页面——我们将尝试构建一个电子邮件通讯服务,以支持您起步所需的一切——不多不少,恰到好处。

捕捉需求: 用户故事

上面的产品简介留有一些解释空间——为了更好地界定我们的服务应该支持哪些内容,我们将利用用户故事。

格式相当简单:

  • 作为一名...
  • 我想...
  • 这样...

用户故事可以帮助我们了解我们为谁构建(作为用户)、他们想要执行的操作(想要执行的操作)以及他们的动机(以便执行操作)。 我们将实现三个用户故事:

  • 作为博客访问者, 我想订阅新闻简报, 以便在博客发布新内容时收到电子邮件更新
  • 作为博客作者, 我想向所有订阅者发送电子邮件, 以便在发布新内容时通知他们
  • 作为订阅者, 我想取消订阅新闻简报, 以便不再接收博客的电子邮件更新。

我们不会添加以下功能

  • 管理多份新闻通讯
  • 将订阅者细分为多个受众群体
  • 跟踪打开率和点击率

如上所述,它相当简陋——尽管如此,足以满足大多数博客作者的需求。

它肯定也能满足我“从零到生产”的需求。

迭代工作

让我们看其中一个用户故事:

作为博客作者, 我想向所有订阅者发送一封电子邮件, 以便在新内容发布时通知他们。

这在实践中意味着什么? 我们需要构建什么?

一旦你开始仔细研究这个问题, 就会出现大量问题——例如, 我们如何确保调用者确实是博客作者? 我们是否需要引入身份验证机制?

我们应该在电子邮件中支持 HTML 格式还是只支持纯文本? 那关于表情符号呢?

我们很容易花费数月时间来实现一个极其完善的电子邮件传递系统, 而甚至连基本的订阅/取消订阅功能都没有。

我们可能成为发送电子邮件领域的佼佼者, 但没有人会使用我们的电子邮件简报服务,因为它无法覆盖整个流程。

我们不会深入研究某个故事, 而是尝试在第一个版本中构建足够多的功能, 以在一定程度上满足所有故事的需求。

然后, 我们会回过头来改进: 为电子邮件发送添加容错和重试功能, 为新订阅者添加确认电子邮件等。

我们将以迭代的方式工作: 每次迭代都需要固定的时间, 并为我们带来略微更好的产品版本, 从而提升用户的体验。

值得强调的是, 我们迭代的是产品功能, 而不是工程质量:每次迭代生成的代码都会经过测试并妥善记录, 即使它只提供了一个微小但功能齐全的功能。

接下来

策略清晰, 我们终于可以开始了:下一章将重点介绍订阅功能。

起步阶段需要完成一些繁重的工作: 选择 Web 框架, 搭建用于管理数据库迁移的基础设施, 搭建应用程序脚手架, 以及设置集成测试。

预计以后会花更多时间与编译器进行结对编程!

注册新用户

我们的策略

我们将从头开始一个新项目——我们需要处理大量前期繁重的工作:

  • 选择一个 Web 框架并熟悉它;
  • 定义我们的测试策略;
  • 选择一个 crate 与我们的数据库交互(我们需要将这些电子邮件保存在某个地方!);
  • 定义我们希望如何管理数据库模式随时间的变化(也就是迁移);
  • 实际编写一些查询。

这项工作非常繁重,如果一头扎进去可能会让人不知所措。

我们将添加一个垫脚石,使整个过程更容易理解:在处理 /subscriptions 之前,我们将实现一个 /health_check 端点。虽然没有业务逻辑,但这是一个很好的机会,让我们熟悉我们的 Web 框架,并了解其所有不同的组成部分。

我们将依靠我们的持续集成流水线来在整个过程中保持控制——如果您尚未设置它,请快速浏览 第一章(或获取一个现成的模板)。

选择一个Web框架

我们应该使用哪个 Web 框架来编写 Rust API?

这部分原本应该讨论当前可用的 Rust Web 框架的优缺点。

但最终篇幅过长,实在没必要在这里写,所以我把它作为一篇衍生文章发布:请参阅 《选择 Rust Web 框架,2020 版》 (英语),深入了解 actix-webrockettidewarp

简而言之:截至 2022 年 3 月,actix-web 应该是您在生产环境中使用 Rust API 时的首选 Web 框架——它在过去几年中得到了广泛的使用,拥有庞大而健康的社区,并且运行在 tokio 上,因此最大限度地减少了处理不同异步运行时之间不兼容/互操作的可能性。

因此,它将成为我们从零到生产环境的首选。

尽管如此,tide、rocket和wrap都拥有巨大的潜力,我们最终可能会在2022年晚些时候做出不同的决定——如果你正在使用不同的框架进行“从零到生产”的实践,我很乐意看看你的代码!请给原作者发邮件至 contact@lpalmieri.com

在本章及以后的内容中,我建议您打开几个额外的浏览器标签页:actix-web 的网站actix-web 的文档actix-web 的示例集

我们的第一个Endpoint - 简单的可用性检测

让我们尝试通过实现一个健康检查端点来开始:当我们收到对 /health_check 的 GET 请求时,我们希望返回一个不带正文的 200 OK 响应。

我们可以使用 /health_check 来验证应用程序是否已启动并准备好接受传入请求。

将它与 pingdom.com 这样的 SaaS 服务结合使用,您可以在 API 出现故障时收到警报——这对于您正在运行的电子邮件简报来说是一个很好的基准。

如果您使用容器编排器 (例如 KubernetesNomad)来协调您的应用程序,那么健康检查端点也会非常方便:编排器可以调用 /health_check 来检测 API 是否无响应并触发重启。

编写 Actix-Web

我们的起点是 actix-web 主页上的 Hello World!示例:

use actix_web::{App, HttpRequest, HttpServer, Responder, web};

async fn greet(req: HttpRequest) -> impl Responder {
    let name = req.match_info().get("name").unwrap_or("World");
    format!("Hello {}!", &name)
}

#[tokio::main]
async fn main() -> std::io::Result<()> {
    HttpServer::new(|| {
        App::new()
            .route("/", web::get().to(greet))
            .route("/{name}", web::get().to(greet))
    })
    .bind("127.0.0.1:8000")?
    .run()
    .await
}

让我们把示例代码粘贴到 main.rs中

让我们运行 cargo check

error[E0432]: unresolved import `actix_web`
 --> src/main.rs:1:5
  |
1 | use actix_web::{App, HttpRequest, HttpServer, Responder, web};
  |     ^^^^^^^^^ use of unresolved module or unlinked crate `actix_web`
  |
  = help: if you wanted to use a crate named `actix_web`, use `cargo add actix_web` to add it to your `Cargo.toml`

error[E0433]: failed to resolve: use of unresolved module or unlinked crate `tokio`
 --> src/main.rs:8:3
  |
8 | #[tokio::main]
  |   ^^^^^ use of unresolved module or unlinked crate `tokio`

Some errors have detailed explanations: E0432, E0433.
For more information about an error, try `rustc --explain E0432`.

等..等等...为什么会这样

我们尚未将 actix-web 和 tokio 添加到依赖项列表中,因此编译器无法解析我们导入的内容。

我们可以手动修复此问题,方法是在 Cargo.toml 中添加

#! Cargo.toml

# [...]
[dependencies]
actix-web = "4"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }

或许我们可以执行 cargo add actix-web 来快速添加 actix-web 依赖

让我们再次运行 cargo check! 现在应该一切正常了!

让我们尝试运行一下应用程序!

cargo run

在你喜欢的终端尝试一下API吧

curl http://127.0.0.1:8000

太好了! 这可以用!

现在, 你可以按下 Ctrl+C来停止web应用程序

actix-web 应用程序的剖析

现在让我们回过头仔细看看我们刚刚在 main.rs 文件中复制粘贴的内容。

//! src/main.rs

// [...]

#[tokio::main]
async fn main() -> std::io::Result<()> {
    HttpServer::new(|| {
        App::new()
            .route("/", web::get().to(greet))
            .route("/{name}", web::get().to(greet))
    })
    .bind("127.0.0.1:8000")?
    .run()
    .await
}

服务器 - HttpServer

HttpServer 是支撑我们应用程序的骨干。它负责处理以下事项:

  • 应用程序应该在哪里监听传入的请求?TCP socket (例如 127.0.0.1:8000)? Unix 域套接字?
  • 我们应该允许的最大并发连接数是多少?单位时间内可以创建多少个新连接?
  • 我们应该启用传输层安全性 (TLS) 吗?
  • 等等

换句话说,HttpServer 处理所有传输层的问题。

之后会发生什么?当 HttpServer 与我们的 API 客户端建立了新的连接,而我们需要开始处理他们的请求时,它会做什么?

这时,App 就派上用场了!

应用程序 - App

App 是所有应用逻辑的存放地:路由、中间件、请求处理程序等。

App 组件的作用是接收传入的请求并返回响应。

让我们仔细地看一下如下代码片段:

App::new()
    .route("/", web::get().to(greet))
    .route("/{name}", web::get().to(greet))

App 是builder模式的一个实际示例: new() 为我们提供了一个干净的平台,我们可以使用流畅的 API(即链式调用)一点一点地添加新的行为。

我们将在整本书中根据需要了解 App 的大部分 API 接口: 读完本书后,您应该至少接触过一次它的大多数方法。

端点 - Route

如何向我们的应用添加新的端点?route 方法可能是最简单的方法 ——毕竟,我们已经在 Hello World! 示例中使用过了!

route 方法接受两个参数:

  • path - 一个字符串,可能为模板(例如/{name}), 用于容纳动态路径
  • route - Route 结构体的实例

Route 将处理程序与一组守卫组合在一起。

守卫指定请求必须满足的条件才能“匹配”并传递给处理程序。从实现的角度来看,守卫是 Guard 特性的实现者: Guard::check 是奇迹发生的地方。

在我们的代码片段中

.route("/", web::get().to(greet))

"/" 将匹配所有在基本路径后不带任何段的请求,例如 http://localhost:8000/web::get()Route::new().guard(guard::Get()) 的快捷方式,也就是说,当且仅当请求的 HTTP 方法是 GET 时,该请求才会传递给处理程序。

您可以想象一下,当一个新请求到来时会发生什么:应用会遍历所有已注册的端点,直到找到一个匹配的端点(路径模板和保护条件都满足),然后将请求对象传递给处理程序。

这并非 100% 准确,但目前来说,这是一个足够好的思维模型。

处理程序应该是什么样的?它的函数签名是什么?

目前我们只有一个示例,greet:

async fn greet(req: HttpRequest) -> impl Responder {
    // [...]
}

运行时 - Tokio

我们从整个 HttpServer 深入到 Route。让我们再看一下整个 main 函数:

#[tokio::main]
async fn main() -> std::io::Result<()> {
    // [...]
}

#[tokio::main] 有什么用? 当然, 让我们删掉它看看会发生什么!

很不幸的, cargo check 给出了如下的报错

error[E0752]: `main` function is not allowed to be `async`
 --> src/main.rs:9:1
  |
9 | async fn main() -> std::io::Result<()> {
  | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `main` function is not allowed to be `async`

For more information about this error, try `rustc --explain E0752`.
error: could not compile `zero2prod` (bin "zero2prod") due to 1 previous error

我们需要 main 函数是异步的,因为 HttpServer::run 是一个异步方法,但 main 函数(我们二进制文件的入口点)不能是异步函数。为什么呢?

Rust 中的异步编程建立在 Future trait 之上: Future 代表一个可能尚未到达的值。所有 Future 都公开一个 poll 方法,必须调用该方法才能使 Future 继续执行并最终解析出最终值。你可以将 Rust 的 Future 视为惰性的:除非进行轮询,否则无法保证它们会执行完成。与其他语言采用的推送模型相比,这通常被描述为一种拉模型。

Rust 的标准库在设计上不包含异步运行时:你应该将其作为依赖项引入你的项目,在 Cargo.toml 文件的 [dependencies] 下添加一个 crate。这种方法非常灵活:您可以自由地实现自己的运行时,并根据用例的特定需求进行优化(参见 Fuchsia 项目或 bastion 的 Actor 框架)。

这就解释了为什么 main 不能是异步函数:谁负责调用它的 poll 方法?

没有特殊的配置语法来告诉 Rust 编译器你的依赖项之一是异步运行时(例如,我们对分配器所做的配置),而且,公平地说,甚至没有一个关于运行时的标准化定义(例如,Executor trait)。 因此,你应该在 main 函数的顶部启动异步运行时,

然后用它来驱动你的 Future 完成。

你现在可能已经猜到 #[tokio::main] 的用途了,但仅仅猜测是不够的:

我们想看到它。

但是怎么做呢?

tokio::main 是一个过程宏,这是一个引入 cargo expand 的绝佳机会,它对于 Rust 开发来说是一个很棒的补充:

cargo install cargo-expand

Rust 宏在 token 级别运行:它们接收一个符号流(例如,在我们的例子中是整个 main 函数),并输出一个新符号流,然后将其传递给编译器。换句话说,Rust 宏的主要用途是代码生成

我们如何调试或检查特定宏的运行情况?您可以检查它输出的 token!

这正是 cargo expand 的亮点所在:它会扩展代码中的所有宏,而无需将输出传递给编译器,让您可以单步执行并了解正在发生的事情。

让我们使用 cargo expand 来揭开 #[tokio::main] 的神秘面纱:

cargo expand
fn main() -> std::io::Result<()> {
    let body = async { run().await };
    #[allow(
        clippy::expect_used,
        clippy::diverging_sub_expression,
        clippy::needless_return
    )]
    {
        return tokio::runtime::Builder::new_multi_thread()
            .enable_all()
            .build()
            .expect("Failed building the Runtime")
            .block_on(body);
    }
}

我们终于可以看看宏扩展后的代码了!

#[tokio::main] 扩展后传递给 Rust 编译器的 main 函数 确实是同步 (Sync) 的, 这也解释了为什么它编译时没有任何问题。

关键的一行是:tokio::runtime::Builder::new_multi_thread().enable_all().build().expect("[...]").block_on(/*[...]*/)

我们正在启动 tokio 的异步运行时,并使用它来驱动 HttpServer::run 返回的 Future 完成。

换句话说,#[tokio::main] 的作用是让我们产生能够定义异步主函数的错觉,而实际上,它只是获取我们的主要异步代码,并编写必要的样板代码,使其在 tokio 的运行时之上运行。

实现 Health Check Handler

我们已经回顾了 actix_web 的 Hello World! 示例中所有需要移动的部分:HttpServerApprouteactix_web::main

我们当然已经了解了足够多的内容,可以修改示例,使健康检查能够按预期工作:

/health_check 收到 GET 请求时,返回一个不带正文的 200 OK 响应。

让我们重新回顾一下我们的起点:

use actix_web::{App, HttpRequest, HttpServer, Responder, web};

async fn greet(req: HttpRequest) -> impl Responder {
    let name = req.match_info().get("name").unwrap_or("World");
    format!("Hello {}!", &name)
}

#[tokio::main]
async fn main() -> std::io::Result<()> {
    HttpServer::new(|| {
        App::new()
            .route("/", web::get().to(greet))
            .route("/{name}", web::get().to(greet))
    })
    .bind("127.0.0.1:8000")?
    .run()
    .await
}

首先,我们需要一个请求处理程序。模仿 greet 函数,我们可以从以下签名开始:

async fn health_check(req: HttpRequest) -> impl Responder {
    todo!()
}

我们说过,Responder 只不过是一个转换为 HttpResponse 的 trait。那么直接返回一个 HttpResponse 实例应该就行了!

查看它的文档,我们可以使用 HttpResponse::Ok 来获取一个已准备好 200 状态码的 HttpResponseBuilder。HttpResponseBuilder 提供了一个丰富的流畅 API,可以逐步构建 HttpResponse 响应,但我们在这里不需要它: 我们可以通过在构建器上调用 finish 来获取一个带有空主体的 HttpResponse。

将所有内容结合在一起:

use actix_web::{HttpRequest, HttpResponse, Responder};

// [...]

async fn health_check(req: HttpRequest) -> impl Responder {
    HttpResponse::Ok().finish()
}

快速运行一下 cargo check,确认我们的处理程序没有做任何奇怪的事情。

仔细查看一下 HttpResponseBuilder 的定义, 会发现它也实现了 Responder 接口——因此,我们可以省略对 finish 的调用,并将处理程序简化为:

// [...]
async fn health_check(req: HttpRequest) -> impl Responder {
    HttpResponse::Ok()
}

下一步是处理程序注册 - 我们需要通过 route 将其添加到我们的 App 中: (别忘了删除示例中的greet方法和相关route注册的代码)

//! src/main.rs

// [...]
#[tokio::main]
async fn main() -> std::io::Result<()> {
    // [...]
    App::new()
        // [...]
        .route("/health_check", web::get().to(health_check))
    // [...]
}

现在我们的代码看起来是这样

use actix_web::{web, App, HttpRequest, HttpResponse, HttpServer, Responder};

async fn health_check(req: HttpRequest) -> impl Responder {
    HttpResponse::Ok()
}

#[tokio::main]
async fn main() -> std::io::Result<()> {
    HttpServer::new(|| {
        App::new()
            .route("/health_check", web::get().to(health_check))
    })
    .bind("127.0.0.1:8000")?
    .run()
    .await
}

你可能看到 cargo check 在抱怨变量 req没有用到

我们的健康检查响应确实是静态的,并且不使用任何与传入 HTTP 请求捆绑的数据(路由除外)。我们可以遵循编译器的建议,在 req 前添加下划线...... 或者,我们可以从 health_check 中完全删除该输入参数:

async fn health_check() -> impl Responder {
    HttpResponse::Ok()
}

惊喜啊,它编译通过了!actix-web 在后台运行着一些相当高级的类型处理程序, 并且它接受各种各样的签名作为请求处理程序——稍后会详细介绍。

接下来做什么?

好吧,来个小测试!

curl -v http://127.0.0.1:8000/health_check
$ curl -v http://127.0.0.1:8000/health_check

*   Trying 127.0.0.1:8000...
* Connected to 127.0.0.1 (127.0.0.1) port 8000
* using HTTP/1.x
> GET /health_check HTTP/1.1
> Host: 127.0.0.1:8000
> User-Agent: curl/8.15.0
> Accept: */*
> 
* Request completely sent off
< HTTP/1.1 200 OK
< content-length: 0
< date: Wed, 20 Aug 2025 06:36:57 GMT
< 
* Connection #0 to host 127.0.0.1 left intact

你现在应该可以在响应中看到类似HTTP/1.1 200 OK的字样

这太棒了! 我们的Health Check正在工作!

恭喜你实现了第一个 actix-web 端点!

我们的第一个集成测试

/health_check 是我们的第一个端点,我们通过启动应用程序并通过 curl 手动测试来验证一切是否按预期运行。

然而,手动测试非常耗时: 随着应用程序规模的扩大,每次执行更改时,手动检查我们对其行为的所有假设是否仍然有效,成本会越来越高。

我们希望尽可能地实现自动化: 这些检查应该在每次提交更改时都在我们的持续集成 (CI) 流水线中运行,以防止出现回归问题。

虽然健康检查的行为在整个过程中可能不会有太大变化,但它是正确设置测试框架的良好起点。

我应该怎么测试一个API Endpoint

API 是达到目的的手段:一种暴露给外界执行某种任务的工具(例如,存储文档、发布电子邮件等)。

我们在 API 中暴露的端点定义了我们与客户端之间的契约:关于系统输入和输出(即接口)的共同协议。

契约可能会随着时间的推移而演变,我们可以粗略地设想两种情况:

  • 向后兼容的变更(例如,添加新的端点)
  • 重大变更(例如,移除端点或从其输出的架构中删除字段)。

在第一种情况下,现有的 API 客户端将保持原样运行。在第二种情况下,如果现有的集成依赖于契约中被违反的部分,则可能会中断。

虽然我们可能会故意部署对 API 契约的重大变更,但至关重要的是,我们不要意外地破坏它。

什么是最可靠的方法来检查我们没有引入用户可见的回归?

通过与用户完全一样的方式与 API 交互来测试 API:向 API 执行 HTTP 请求,并验证我们对收到的响应的假设。

这通常被称为黑盒测试:我们通过检查系统的输出来验证系统的行为,而不需要了解其内部实现的细节。

遵循这一原则,我们不会满足于直接调用处理函数的测试——例如:

#[cfg(test)]
mod tests {
    use crate::health_check;

    #[tokio::test]
    async fn health_check_succeeds() {
        let response = health_check().await;
        // This requires changing the return type of `health_check`
        // from `impl Responder` to `HttpResponse` to compile
        // You also need to import it with `use actix_web::HttpResponse`!
        assert!(response.status().is_success())
    }
}

这样做并不是最佳实践

  • 我们没有检查处理程序是否在 GET 请求时调用
  • 我们也没有检查处理程序是否以 /health_check 作为路径调用。

更改这两个属性中的任何一个都会破坏我们的 API 契约,但我们的测试仍然会通过—— 这还不够好。

actix-web 提供了一些便利,可以在不跳过路由逻辑的情况下与应用进行交互, 但这种方法存在严重的缺陷:

  • 迁移到另一个 Web 框架将迫使我们重写整个集成测试套件。 我们希望集成测试尽可能与 API 实现的底层技术高度分离(例如,在进行大规模重写或重构时,进行与框架无关的集成测试可以起到至关重要的作用!)
  • 由于 actix-web 的一些限制,我们无法在生产代码和测试代码之间共享应用启动逻辑,因此,由于存在随着时间的推移出现分歧的风险,我们对测试套件提供的保证的信任度会降低。

我们将选择一个完全黑盒解决方案:我们将在每次测试开始时启动我们的应用程序,并使用现成的 HTTP 客户端 (例如 reqwest) 与其交互。

我应该把测试放在哪里

Rust 在编写测试时提供了三种选择:

  • 在嵌入式测试模块中的代码旁边 (使用 mod tests )
// Some code I want to test
#[cfg(test)]
mod tests {
    // Import the code I want to test
    use super::*;
    // My tests
}
  • 在外部的 tests/ 文件夹
$ ls

src/
tests/
Cargo.toml
Cargo.lock
  • 公共文档的一部分 (doc tests)
/// Check if a number is even.
/// ```rust
/// use zero2prod::is_even;
///
/// assert!(is_even(2));
/// assert!(!is_even(1));
/// ```
pub fn is_even(x: u64) -> bool {
    x % 2 == 0
}

有什么区别?

嵌入式测试模块是项目的一部分,只是隐藏在配置条件检查 #[cfg(test)] 后面。而 tests 文件夹下的所有内容以及文档测试则被编译成各自独立的二进制文件。

这会影响可见性规则

嵌入式测试模块对其相邻的代码拥有特权访问权:它可以与未标记为公共的结构体、方法、字段和函数进行交互,而这些内容通常无法被我们代码的用户访问,即使他们将其作为自己项目的依赖项导入也是如此。

嵌入式测试模块对于我所说的“冰山项目”非常有用,即暴露的接口非常有限(例如几个公共函数),但底层机制却非常庞大且相当复杂(例如数十个例程)。通过公开的函数来测试所有可能的边缘情况可能并非易事——您可以利用嵌入式测试模块为私有子组件编写单元测试,从而增强对整个项目正确性的整体信心。

而外部测试文件夹和文档测试对代码的访问级别,与将 crate 添加为另一个项目依赖项时获得的访问级别完全相同。因此,它们主要用于集成测试,即通过与用户完全相同的方式调用代码来测试。

我们的电子邮件简报并非库,因此两者之间的界限有点模糊——我们不会将其作为 Rust crate 公开给世界,而是将其作为可通过网络访问的 API 公开。

尽管如此,我们将使用 tests 文件夹进行 API 集成测试——它更加清晰地划分,并且将测试助手作为外部测试二进制文件的子模块进行管理也更加容易。

改变我们的项目结构以便于测试

在真正开始在 /tests 下编写第一个测试之前,我们还有一些准备工作要做。

正如我们所说,任何测试代码最终都会被编译成它自己的二进制文件——我们所有测试代码

都以 crate 的形式导入。但目前我们的项目是一个二进制文件:它旨在执行,而不是

共享。因此,我们无法像现在这样在测试中导入 main 函数。

如果您不相信我的话,我们可以做一个快速实验:

# Create the tests folder
mkdir -p tests

创建 tests/health_check.rs 然后写入如下代码

//! tests/health_check.rs
use zero2prod::main;

#[test]
fn dummy_test() {
    main()
}

现在执行 cargo test, 欸? 好像报错了

error[E0432]: unresolved import `zero2prod`
 --> tests/health_check.rs:1:5
  |
1 | use zero2prod::main;
  |     ^^^^^^^^^ use of unresolved module or unlinked crate `zero2prod`
  |
  = help: if you wanted to use a crate named `zero2prod`, use `cargo add zero2prod` to add it to your `Cargo.toml`

For more information about this error, try `rustc --explain E0432`.
error: could not compile `zero2prod` (test "health_check") due to 1 previous error

译者注: 原作此处为修改 Cargo.toml来配置lib.rs, 但在最新的Rust中, lib.rs会自动识别为一个crate, 所以不必那么做了, 这里就没翻译

接下来我们创建文件 src/lib.rs

然后我们可以把 main.rs 中的逻辑搬过去了

//! main.rs
use zero2prod::run;

#[tokio::main]
async fn main() -> std::io::Result<()> {
    run().await
}
//! lib.rs
use actix_web::{App, HttpResponse, HttpServer, Responder, web};

async fn health_check() -> impl Responder {
    HttpResponse::Ok()
}

pub async fn run() -> std::io::Result<()> {
    HttpServer::new(|| App::new().route("/health_check", web::get().to(health_check)))
        .bind("127.0.0.1:8000")?
        .run()
        .await
}

好了,我们准备编写一些有趣的集成测试!

实现我们的第一个集成测试

我们对健康检查端点的规范是:当我们收到 /health_check 的 GET 请求时,我们会返回没有正文的 200 OK 响应。

这分为这几个部分

  • GET 请求
  • /health_check 端点
  • 响应码 200
  • 没有正文的响应

让我们将其转化为测试,并尽可能多地描述特征:

//! tests/health_check.rs
// `tokio::test` is the testing equivalent of `tokio::main`.
// It also spares you from having to specify the `#[test]` attribute.
//
// You can inspect what code gets generated using
// `cargo expand --test health_check` (<- name of the test file)
#[tokio::test]
async fn health_check_works() {
    // Arrange
    spawn_app().await.expect("Failed to spawn our app.");
    // We need to bring in `reqwest`
    // to perform HTTP requests against our application.
    let client = reqwest::Client::new();
    // Act
    let response = client
        .get("http://127.0.0.1:8000/health_check")
        .send()
        .await
        .expect("Failed to execute request.");

    // Assert
    assert!(response.status().is_success());
    assert_eq!(Some(0), response.content_length());
}

async fn spawn_app() -> std::io::Result<()> {
    todo!()
}

当然别忘了添加 reqwest 依赖

cargo add reqwest --dev

请花点时间仔细看看这个测试用例。

spawn_app 是唯一一个合理地依赖于我们应用程序代码的部分。

其他所有内容都与底层实现细节完全解耦——如果明天我们决定放弃 Rust,用 Ruby on Rails 重写应用程序,我们仍然可以使用相同的测试套件来检查新堆栈中的回归问题,只要将 spawn_app 替换为合适的触发器(例如,使用 bash 命令启动 Rails 应用程序)。

该测试还涵盖了我们感兴趣的所有属性:

  • 健康检查暴露在 /health_check;
  • 健康检查使用 GET 方法;
  • 健康检查始终返回 200;
  • 健康检查的响应没有正文。

如果这个测试通过了,这就完成了。

测试还没来得及做任何有用的事情就崩溃了:我们缺少了 spawn_app,这是集成测试的最后一块拼图。

为什么我们不直接在那里调用 run 呢? 也就是说

async fn spawn_app() -> std::io::Result<()> {
    zero2prod::run().await
}

让我们试试看!

cargo test

无论等待多久,测试执行都不会终止。这是怎么回事?

在 zero2prod::run 中,我们调用(并等待)HttpServer::run。HttpServer::run 返回一个 Server 实例 - 当我们调用 .await 时,它会无限期地监听我们指定的地址:它会处理传入的请求,但永远不会自行关闭或“完成”。

这意味着 spawn_app 永远不会返回,我们的测试逻辑也永远不会执行。

我们需要将应用程序作为后台任务运行。

tokio::spawn 在这里非常方便:tokio::spawn 接受一个 Future 并将其交给运行时进行轮询,

而无需等待其完成;因此,它与下游 Future 和任务(例如我们的测试逻辑)并发运行。

让我们重构 zero2prod::run,使其返回一个 Server 实例而不等待它:

//! src/lib.rs

// [...]

// Notice the different signature!
// We return `Server` on the happy path and we dropped the `async` keyword
// We have no .await call, so it is not needed anymore.
pub fn run() -> Result<Server, std::io::Error> {
    let server = HttpServer::new(|| App::new().route("/health_check", web::get().to(health_check)))
        .bind("127.0.0.1:8000")?
        .run();

    // No .await here!
    Ok(server)
}

我们需要相应地修改我们的 main.rs:

//! src/main.rs
use zero2prod::run;

#[tokio::main]
async fn main() -> std::io::Result<()> {
    run()?.await
}

运行一下 cargo check 应该能让我们确信一切正常。

现在我们可以实现 spawn_app 方法

// No .await call, therefore no need for `spawn_app` to be async now.
// We are also running tests, so it is not worth it to propagate errors:
// if we fail to perform the required setup we can just panic and crash
// all the things.
fn spawn_app() {
    let server = zero2prod::run().expect("Failed to bind address");
    // Launch the server as a background task
    // tokio::spawn returns a handle to the spawned future,
    // but we have no use for it here, hence the non-binding let
    let _ = tokio::spawn(server);
}

快速调整我们的测试以适应 spawn_app 方法签名的变化:

#[tokio::test]
async fn health_check_works() {
    // [...]
    spawn_app();
    // [...]

现在是时候运行 cargo test 了!

running 1 test
test health_check_works ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s

耶!我们的第一次集成测试通过了!

替我给自己鼓个掌,在一个章节内完成了第二个重要的里程碑。

Polishing

我们已经让它运行起来了,现在我们需要重新审视并改进它,如果需要或可能的话

清理

测试运行结束后,后台运行的应用会发生什么?

它会关闭吗? 它会像僵尸程序一样徘徊在某个地方吗?

嗯,连续多次运行 cargo test 总是会成功——这强烈暗示我们的 8000 端口会在每次运行结束时被释放,因此意味着应用已正确关闭。

再次查看 tokio::spawn 的文档,这支持了我们的假设:当一个 tokio 运行时关闭时,所有在其上生成的任务都会被丢弃。tokio::test 会在每个测试用例开始时启动一个新的运行时,并在每个测试用例结束时关闭。

换句话说,好消息是——无需实现任何清理逻辑来避免测试运行期间的资源泄漏。

选择随机端口

spawn_app 总是会尝试在 8000 端口上运行我们的应用——这并不理想:

  • 如果 8000 端口正在被我们机器上的其他程序(例如我们自己的应用!)占用,测试就会失败;
  • 如果我们尝试并行运行两个或多个测试,那么只有一个测试能够绑定端口,其他所有测试都会失败。

我们可以做得更好: 测试应该在随机可用的端口上运行它们的后台应用。

首先,我们需要修改 run 函数——它应该将应用地址作为参数,而不是依赖于硬编码的值:

让我们修改 lib.rs

//! src/lib.rs

// [...]
pub fn run(address: &str) -> Result<Server, std::io::Error> {
    let server = HttpServer::new(|| App::new().route("/health_check", web::get().to(health_check)))
        .bind(address)?
        .run();

    // No .await here!
    Ok(server)
}

然后,所有 zero2prod::run() 调用都必须更改为 zero2prod::run("127.0.0.1:8000") 才能保留相同的行为并使项目再次编译。 我们如何为测试找到一个随机可用的端口?

操作系统可以帮上忙:我们将使用端口 0。

端口 0 在操作系统层面是特殊情况:尝试绑定端口 0 将触发操作系统扫描可用端口,

然后该端口将被绑定到应用程序。

因此,只需将 spawn_app 更改为

//! tests/health_check.rs

fn spawn_app() {
    let server = zero2prod::run("127.0.0.1:0").expect("Failed to bind address");
    let _ = tokio::spawn(server);
}

注: 原作这里忘了提到要修改 main.rs 了, 请暂时main.rs 中的代码修改为 run("127.0.0.1:8000")?.await

这样就好了~ 现在,每次启动 Cargo 测试时,后台应用都会在随机端口上运行! 只有一个小问题...... 我们的测试失败了

running 1 test
test health_check_works ... FAILED

failures:

---- health_check_works stdout ----

thread 'health_check_works' panicked at tests/health_check.rs:19:10:
Failed to execute request.: reqwest::Error { kind: Request, url: "http://127.0.0.1:8000/health_check", source: hyper_util::client::legacy::Error(Connect, ConnectError("tcp connect error", 127.0.0.1:80
00, Os { code: 111, kind: ConnectionRefused, message: "Connection refused" })) }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    health_check_works

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s

我们的 HTTP 客户端仍在调用 127.0.0.1:8000,我们现在真的不知道该在那里放什么: 应用程序端口是在运行时确定的,我们无法在那里进行硬编码。

我们需要以某种方式找出操作系统分配给我们应用程序的端口,并将其从 spawn_app 返回。

有几种方法可以实现这一点——我们将使用 std::net::TcpListener

我们的 HttpServer 目前承担着双重任务:给定一个地址,它会绑定它,然后启动应用程序。我们可以接手第一步:我们自己用 TcpListener 绑定端口,然后使用 listen 将其交给 HttpServer。

这样做有什么好处呢?

TcpListener::local_addr 返回一个 SocketAddr 对象,它暴露了我们通过 .port() 绑定的实际端口。

让我们从 run 函数开始:

//! src/lib.rs

// [...]

pub fn run(listener: TcpListener) -> Result<Server, std::io::Error> {
    let server = HttpServer::new(|| App::new().route("/health_check", web::get().to(health_check)))
        .listen(listener)?
        .run();

    Ok(server)
}

这项更改破坏了我们的 main 函数和 spawn_app 函数。main 函数就留给你处理吧,我们来重点处理 spawn_app 函数:

//! tests/health_check.rs

// [...]

fn spawn_app() -> String {
    let listener = TcpListener::bind("127.0.0.1:0").expect("Faield to bind random port");
    // We retrieve the port assigned to us by the OS
    let port = listener.local_addr().unwrap().port();
    let server = zero2prod::run(listener).expect("Failed to bind address");
    let _ = tokio::spawn(server);

    // We return the application address to the caller!
    format!("http://127.0.0.1:{}", port)
}

现在我们可以在 reqwest::Client 中引用这个地址:

//! tests/health_check.rs

// [...]

#[tokio::test]
async fn health_check_works() {
    // Arrange
    let address = spawn_app();
    let client = reqwest::Client::new();

    // Act
    let response = client
        // Use the returned application address
        .get(format!("{address}/health_check"))
        .send()
        .await
        .expect("Failed to execute request.");

    // Assert
    assert!(response.status().is_success());
    assert_eq!(Some(0), response.content_length());
}

// [...]

回顾

让我们稍事休息一下,回顾一下,我们已经完成了相当多的内容!

我们着手实现一个 /health_check 端点,这让我们有机会进一步了解我们的 Web 框架 actix-web 的基础知识,以及 Rust API 的(集成)测试基础知识。

现在是时候利用我们学到的知识,最终完成我们电子邮件通讯项目的第一个用户故事了:

作为博客访问者, 我想订阅新闻简报, 以便在博客发布新内容时收到电子邮件更新

我们希望博客访问者在网页嵌入的表单中输入他们的电子邮件地址。

该表单将触发对我们后端 API 的 POST /subscriptions 调用,后端 API 将实际处理信息、存储信息并返回响应。

我们将深入研究:

  • 如何在 actix-web 中读取 HTML 表单中收集的数据(例如,如何解析 POST 请求体?);
  • 哪些库可以在 Rust 中使用 PostgreSQL 数据库(diesel、sqlx 和 tokio-postgres);
  • 如何设置和管理数据库迁移;
  • 如何在 API 请求处理程序中获取数据库连接;
  • 如何在集成测试中测试副作用(即存储数据);
  • 如何避免在使用数据库时测试之间出现奇怪的交互。

让我们开始吧!

使用HTML表单

完善我们的要求

为了将访客注册为我们的电子邮件简报,我们应该收集哪些信息?

嗯,我们当然需要他们的电子邮件地址(毕竟这是一封电子邮件简报)。 还有什么?

在典型的业务环境中,这通常会引发团队工程师和产品经理之间的对话。在这种情况下,我们既是技术主管,又是产品负责人,因此我们可以发号施令!

从个人经验来看,人们在订阅新闻通讯时通常会使用一次性或屏蔽电子邮件(或者,至少你们大多数人在订阅“从零到生产”时都是这样做的!)。

因此,收集一个名字会很不错,我们可以用它来作为电子邮件问候语(比如臭名昭著的 "Hey,{{subscriber.name}}!" ),也可以在订阅者列表中识别我们认识的人或共同订阅者。

我们不是警察,我们对姓名字段的真实性不感兴趣——我们会让人们在我们的新闻通讯系统中输入他们喜欢的任何身份信息: DenverCoder9, 我们欢迎你。

那么,事情就解决了:我们需要为所有新订阅者提供电子邮件地址和姓名。

鉴于数据是通过 HTML 表单收集的,它将通过 POST 请求的正文传递给我们的后端 API。正文将如何编码?

使用 HTML 表单时,有几种可用的编码方式: application/x-www-form-urlencoded

这最适合我们的用例。

引用 MDN Web 文档,使用 application/x-www-form-urlencoded

在我们的表单中,键和值被编码为键值元组,并以“&”分隔,键和值之间用“=”分隔。键和值中的非字母数字字符均采用百分号编码。

例如:如果名字是 Le Guin, 邮箱是 ursula_le_guin@gmail.com,则 POST 请求体应为 name=le%20guin&email=ursula_le_guin%40gmail.com(空格替换为 %20,而 @ 替换为 %40 - 可在此处找到参考转换表)。

总结:

  • 如果使用 application/x-www-form-urlencoded 格式提供了有效的姓名和邮箱地址组合,后端应返回 200 OK
  • 如果姓名或邮箱地址缺失,后端应返回 400 BAD REQUEST

将我们的需求作为测试

现在我们更好地理解了需要做什么,让我们将我们的期望编码到几个集成测试中。

让我们将新的测试添加到现有的 tests/health_check.rs 文件中——之后我们将重新组织测试套件的文件夹结构。

//! tests/health_check.rs

// [...]

#[tokio::test]
async fn subscribe_returns_a_200_for_valid_form_data() {
    // Arrange
    let app_address = spawn_app();
    let client = reqwest::Client::new();
    // Act
    let body = "name=le%20guin&email=ursula_le_guin%40gmail.com";
    let response = client
        .post(&format!("{}/subscriptions", &app_address))
        .header("Content-Type", "application/x-www-form-urlencoded")
        .body(body)
        .send()
        .await
        .expect("Failed to execute request.");

    // Assert
    assert_eq!(200, response.status().as_u16());
}

#[tokio::test]
async fn subscribe_returns_a_400_when_data_is_missing() {
    // Arrange
    let app_address = spawn_app();
    let client = reqwest::Client::new();
    let test_cases = vec![
        ("name=le%20guin", "missing the email"),
        ("email=ursula_le_guin%40gmail.com", "missing the name"),
        ("", "missing both name and email"),
    ];
    for (invalid_body, error_message) in test_cases {
        // Act
        let response = client
            .post(&format!("{}/subscriptions", &app_address))
            .header("Content-Type", "application/x-www-form-urlencoded")
            .body(invalid_body)
            .send()
            .await
            .expect("Failed to execute request.");

        // Assert
        assert_eq!(
            400,
            response.status().as_u16(),
            // Additional customised error message on test failure
            "The API did not fail with 400 Bad Request when the payload was {}.",
            error_message
        );
    }
}

subscribe_returns_a_400_when_data_is_missing 是一个表驱动测试的例子,也称为参数化测试。

它在处理错误输入时尤其有用——我们不必多次重复测试逻辑,只需对一组已知无效的主体运行相同的断言即可,这些主体我们预计会以相同的方式失败。

对于参数化测试,在失败时提供清晰的错误消息非常重要:如果您无法确定哪个特定的输入出错,那么在 XYZ 行的断言失败就不太理想!另一方面,该参数化测试涵盖的内容很广泛,因此花更多时间来生成清晰的失败消息是有意义的。

其他语言的测试框架有时原生支持这种测试风格 (例如 pytest 中的参数化测试,或 C# 的 xUnit 中的 InlineData)。Rust 生态系统中有一些 crate,它们扩展了基本测试框架,并提供了类似的功能,但遗憾的是,它们与 #[tokio::test] 宏的互操作性不佳,而我们需要使用宏来编写惯用的异步测试 (参见 rstesttest-case)。

现在让我们运行测试套件:

running 3 tests
test subscribe_returns_a_200_for_valid_form_data ... FAILED
test subscribe_returns_a_400_when_data_is_missing ... FAILED
test health_check_works ... ok

failures:

---- subscribe_returns_a_200_for_valid_form_data stdout ----

thread 'subscribe_returns_a_200_for_valid_form_data' panicked at tests/health_check.rs:39:5:
assertion `left == right` failed
  left: 200
 right: 404
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

---- subscribe_returns_a_400_when_data_is_missing stdout ----

thread 'subscribe_returns_a_400_when_data_is_missing' panicked at tests/health_check.rs:63:9:
assertion `left == right` failed: The API did not fail with 400 Bad Request when the payload was missing the email.
  left: 400
 right: 404


failures:
    subscribe_returns_a_200_for_valid_form_data
    subscribe_returns_a_400_when_data_is_missing

test result: FAILED. 1 passed; 2 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s

error: test failed, to rerun pass `--test health_check`

正如预期的那样,我们所有的新测试都失败了。

你很快就能发现“自行开发”参数化测试的一个局限性: 一旦一个测试用例失败,执行就会停止,我们也无法知道后续测试用例的结果。

让我们开始实现吧。

从 POST 请求解析表单数据

所有测试都失败了,因为应用程序在 POST 请求到达 /subscriptions 时返回了 404 NOT FOUND 错误。这是合理的:我们没有为该路径注册处理程序。

让我们通过在 src/lib.rs 中添加一个匹配的路由来解决这个问题:

//! src/lib.rs

use std::net::TcpListener;

use actix_web::{App, HttpResponse, HttpServer, Responder, dev::Server, web};

// We were returning `impl Responder` at the very beginning.
// We are now spelling out the type explicitly given that we have
// become more familiar with `actix-web`.
// There is no performance difference! Just a stylistic choice :)
async fn health_check() -> impl Responder {
    HttpResponse::Ok()
}

// Let's start simple: we always return a 200 OK
async fn subscribe() -> HttpResponse {
    HttpResponse::Ok().finish()
}

pub fn run(listener: TcpListener) -> Result<Server, std::io::Error> {
    let server = HttpServer::new(|| {
        App::new()
            .route("/health_check", web::get().to(health_check))
            // A new entry in our routing table for POST /subscriptions requests
            .route("/subscriptions", web::post().to(subscribe))
    })
    .listen(listener)?
    .run();

    Ok(server)
}

再次运行测试

running 3 tests
test health_check_works ... ok
test subscribe_returns_a_200_for_valid_form_data ... ok
test subscribe_returns_a_400_when_data_is_missing ... FAILED

failures:

---- subscribe_returns_a_400_when_data_is_missing stdout ----

thread 'subscribe_returns_a_400_when_data_is_missing' panicked at tests/health_check.rs:63:9:
assertion `left == right` failed: The API did not fail with 400 Bad Request when the payload was missing the email.
  left: 400
 right: 200
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    subscribe_returns_a_400_when_data_is_missing

test result: FAILED. 2 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s

subscribe_returns_a_200_for_valid_form_data 现在通过了: 好吧,我们的处理程序将所有传入的数据视为有效数据,这并不奇怪。

subscribe_returns_a_400_when_data_is_missing 仍然是红色 (未通过)。 是时候对该请求主体进行一些真正的解析了。actix-web 为我们提供了什么?

Extractors

actix-web 用户指南中, Extractors 部分尤为突出。 顾名思义,Extractors 用于指示框架从传入请求中提取特定信息。 actix-web 提供了几个开箱即用的提取器,以满足最常见的用例:

  • Path 用于从请求路径中获取动态路径段
  • Query 用于查询参数
  • Json 用于解析 JSON 编码的请求正文
  • 等等

幸运的是,有一个Extractor正好可以满足我们的用例: Form

阅读它的文档:

表单数据助手 (application/x-www-form-urlencoded)。 可用于从请求正文中提取 URL 编码数据,或将 URL 编码数据作为响应发送。

这真是太棒了!

我们该如何使用它?

查看 actix-web 的用户指南:

提取器可以作为处理函数的参数访问。Actix-web 每个处理函数最多支持 10 个提取器。参数位置无关紧要。

例子:

use actix_web::web;

#[derive(serde::Deserialize)]
struct FormData {
    username: String,
}

/// Extract form data using serde.
/// This handler get called only if content type is *x-www-form-urlencoded*
/// and content of the request could be deserialized to a `FormData` struct
fn index(form: web::Form<FormData>) -> String {
    format!("Welcome {}!", form.username)
}

所以,基本上……你只需将它作为处理程序和 actix-web 的参数添加到那里,当请求到达时,它就会以某种方式为你完成繁重的工作。我们现在先了解一下,稍后再回过头来了解底层发生了什么。

我们的 subscribe handler目前如下所示:

async fn subscribe() -> HttpResponse {
    HttpResponse::Ok().finish()
}

使用这个例子作为蓝图,我们可能想要一些类似这样的内容:

//! src/lib.rs
// [...]

#[derive(serde::Deserialize)]
struct FormData {
    email: String,
    name: String
}

async fn subscribe(_form: web::Form<FormData>) -> HttpResponse {
    HttpResponse::Ok().finish()
}

cargo check 并不是很开心

error[E0433]: failed to resolve: use of unresolved module or unlinked crate `serde`
 --> src/lib.rs:5:10
  |
5 | #[derive(serde::Deserialize)]
  |          ^^^^^ use of unresolved module or unlinked crate `serde`

好吧,我们需要将 serde 添加到依赖项中。让我们在 Cargo.toml 中添加一行:

[dependencies]
# We need the optional `derive` feature to use `serde`'s procedural macros:
# `#[derive(Serialize)]` and `#[derive(Deserialize)]`.
# The feature is not enabled by default to avoid pulling in
# unnecessary dependencies for projects that do not need it.
serde = { version = "1", features = ["derive"]}

cargo check 现在应该可以通过了, 那 cargo test

running 3 tests
test subscribe_returns_a_200_for_valid_form_data ... ok
test health_check_works ... ok
test subscribe_returns_a_400_when_data_is_missing ... ok

test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s

全部通过!

但是为什么?

Form 和 FromRequest

让我们直接进入源码:Form 是什么样子的?

你可以在这里找到它的源代码

这个定义看起来相当简单:

#[derive(PartialEq, Eq, PartialOrd, Ord, Debug)]
pub struct Form<T>(pub T);

它只不过是一个包装器:它对类型 T 进行泛型,然后用于填充 Form 的唯一字段。

这里没什么可看的。

提取魔法在哪里发生?

提取器是实现 FromRequest trait 的类型。

FromRequest 的定义有点杂乱,因为 Rust 尚不支持 trait 定义中的 async fn 。

稍微修改一下,它大致可以归结为这样的东西:

/// Trait implemented by types that can be extracted from request.
///
/// Types that implement this trait can be used with `Route` handlers.
pub trait FromRequest: Sized {
    /// The associated error which can be returned.
    type Error: Into<Error>;

    type Future: Future<Output = Result<Self, Self::Error>>;

    fn from_request(req: &HttpRequest, payload: &mut Payload) -> Self::Future;

    /// Omitting some ancillary methods that actix-web implements
    /// out of the box for you and supporting associated types
    /// [...]
}

from_request 将传入的 HTTP 请求的头部(即 HttpRequest)及其有效负载(即 Payload)的字节作为输入。如果提取成功,它将返回 Self;否则,它将返回一个可以转换为 actix_web::Error 的错误类型。

路由处理程序签名中的所有参数都必须实现 FromRequest trait:actix-web 将为每个参数调用 from_request,如果所有参数的提取都成功,它将运行实际的处理程序函数。

如果其中一个提取失败,则相应的错误将返回给调用者,并且处理程序永远不会被调用(actix_web::Error 可以转换为 HttpResponse)。

这非常方便: 您的处理程序无需处理原始的传入请求,而可以直接处理强类型信息,从而大大简化了处理请求所需的代码。

让我们来看看 Form 的 FromRequest 实现:它做了什么?

再次,我稍微修改了实际代码以突出关键元素并忽略具体的实现细节。

impl<T> FromRequest for Form<T>
where
    T: DeserializeOwned + 'static,
{
    type Error = actix_web::Error;
    
    async fn from_request(
        req: &HttpRequest,
        payload: &mut Payload
    ) -> Result<Self, Self::Error> {
        // Omitted stuff around extractor configuration (e.g. payload size limits)

        match UrlEncoded::new(req, payload).await {
            Ok(item) => Ok(Form(item)),
            // The error handler can be customised.
            // The default one will return a 400, which is what we want.
            Err(e) => Err(error_handler(e))
        }
    }
}

所有繁重的工作似乎都发生在 UrlEncoded struct中。

UrlEncoded 的功能非常丰富: 它透明地处理压缩和未压缩的有效负载,处理请求主体以字节流的形式一次到达一个块的情况,等等。

处理完所有这些事情之后, 关键的部分是:

serde_urlencoded::from_bytes::<T>(&body).map_err(|_| UrlencodedError::Parse)

serde_urlencodedapplication/x-www-form-urlencoded 数据格式提供(反)序列化支持。

from_bytes 接受一个连续的字节切片作为输入,并根据 URL 编码格式的规则将其反序列化为一个类型 T 的实例:键和值被编码为键值对元组,并以 & 分隔,键和值之间用 = 分隔;键和值中的非字母数字字符均采用百分号编码。

它是如何知道对于泛型类型 T 执行此操作的?

这是因为 T 实现了 serdeDeserializedOwned 特性:

impl<T> FromRequest for Form<T>
where
    T: DeserializeOwned + 'static,
{
    // [...]
}

要了解其内部实际发生的情况,我们需要仔细研究 serde 本身。

下一节关于 serde 的内容涉及一些 Rust 的高级主题。 如果您第一次阅读时没有完全理解,也没关系! 等您熟悉 Rust 之后再回来阅读,并多学习一些 serde,深入了解其中最难的部分。

Rust 中的序列化: serde

为什么我们需要 serde? serde 为我们做了什么?

引用来自它的教程的话:

Serde 是一个用于高效且通用地序列化和反序列化 Rust 数据结构的框架。

一般而言

serde 本身并不支持从/反序列化任何特定数据格式: serde 内部没有处理 JSON、Avro 或 MessagePack 细节的代码。如果您需要支持特定数据格式,则需要引入另一个 crate(例如,用于 JSON 的 serde_json 或用于 Avro 的 avro-rs)。serde 定义了一组接口,或者用它们自己的话说,一个数据模型

如果您想要实现一个库来支持新数据格式的序列化,则必须提供 Serializer trait 的实现。

Serializer trait 中的每个方法都对应于构成 serde 数据模型的 29 种类型之一——您的 Serializer 实现指定了每种类型如何映射到您的特定数据格式。

例如,如果您要添加对 JSON 序列化的支持,您的 serialize_seq 实现 将输出一个左方括号 [ 并返回一个可用于序列化序列元素的类型。

另一方面,您有 Serialize trait:您为 Rust 类型实现 Serialize::serialize 的目的是指定如何根据 serde 的数据模型,使用 Serializer trait 中提供的方法对其进行分解。

再次使用序列示例,以下是 Rust 中为 Vec 实现 Serialize 的方式:

use serde::ser::{Serialize, Serializer, SerializeSeq};

impl<T> Serialize for Vec<T>
where
    T: Serialize,
{
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: Serializer,
    {
        let mut seq = serializer.serialize_seq(Some(self.len()))?;
        for element in self {
            seq.serialize_element(element)?;
        }
        seq.end()
    }
}

这使得 serde 对数据格式保持不可知:一旦你的类型实现了 Serialize, 你就可以自由地使用任何具体的 Serializer 实现来实际执行序列化步骤 - 也就是说,你可以将你的类型序列化为 crates.io 上可用的 Serializer 实现的任何格式(剧透:几乎所有常用的数据格式)。

反序列化也是如此,通过 DeserializeDeserializer 进行,并在生命周期方面有一些额外的细节,以支持零拷贝反序列化。

效率

serde 的速度变慢是否是因为其对底层数据格式具有泛型性?

不是,这要归功于一个称为单态化的过程。

每次使用一组具体类型调用泛型函数时,Rust 编译器都会创建函数体的副本,将泛型类型参数替换为具体类型。这使得编译器能够针对所涉及的具体类型优化函数体的每个实例:其结果与我们不使用泛型或特征,为每种类型编写单独的函数所获得的结果并无二致。换句话说,我们不会因为使用泛型而付出任何运行时成本。

这个概念非常强大,通常被称为零成本抽象:使用高级语言结构可以生成与使用更丑陋/更“手工编写”的实现相同的机器码。因此,我们可以编写更易于人类阅读的代码(正如它所期望的那样!),而无需在最终成品的质量上做出妥协。

Serde 在内存使用方面也非常谨慎:我们之前提到的中间数据模型是通过 trait 方法隐式定义的,并没有真正的中间序列化结构体。

如果您想了解更多信息,Josh Mcguigan 写了一篇精彩的深度文章,题为《理解 Serde》

还值得指出的是,对特定类型进行(反)序列化所需的所有信息在编译时即可获得,没有任何运行时开销。

其他语言中的(反)序列化器通常利用运行时反射来获取要(反)序列化的类型的信息(例如,它们的字段名称列表)。Rust 不提供运行时反射, 因此所有信息都必须预先指定。

便捷

这就是 #[derive(Serialize)]#[derive(Deserialize)] 发挥作用的地方。

你肯定不想手动详细说明项目中定义的每个类型应该如何执行序列化。这很繁琐,容易出错,而且会浪费你本应专注于特定于应用程序的逻辑的时间。

这两个过程宏与 derive feature flag 后面的 serde 捆绑在一起,将解析类型的定义并自动为你生成正确的 Serialize/Deserialize 实现。

把所有东西放在一起

结合目前所学的知识,让我们再看一下我们的订阅处理程序:

#[derive(serde::Deserialize)]
struct FormData {
    email: String,
    name: String,
}

async fn subscribe(_form: web::Form<FormData>) -> HttpResponse {
    HttpResponse::Ok().finish()
}

现在,我们对正在发生的事情有了清晰的了解:

  • 在调用 subscribe 之前,actix-web 会为所有 subscribe 的输入参数调用 from_request 方法: 在我们的例子中,是 Form::from_request;
  • Form::from_request 会尝试根据 URL 编码规则,利用 serde_urlencodedFormData 的 Deserialize 实现(由 #[derive(serde::Deserialize)] 自动生成),将 body 反序列化为 FormData
  • 如果 Form::from_request 失败,则会向调用者返回 400 BAD REQUEST 错误码。如果成功,则会调用 subscribe 并返回 200 OK 错误码。

稍作思考,您会惊叹不已:它看起来如此简单,但其中却蕴含着如此丰富的内容 ——我们主要依靠 Rust 的强大功能以及其生态系统中一些最完善的 crate。

存储数据: 数据库

我们的 POST /订阅端点通过了测试,但其实用性相当有限:我们没有在任何地方存储有效的电子邮件和姓名。

我们从 HTML 表单收集的信息没有永久记录。

该如何解决这个问题?

在定义云原生时,我们列出了我们期望在系统中看到的一些新兴行为: 特别是,我们希望在易出错的环境中运行时实现高可用性。

因此,我们的应用程序被迫分布式——应该在多台机器上运行多个实例,以应对硬件故障。

这会对数据持久性产生影响:我们不能依赖主机的文件系统作为传入数据的存储层。

我们保存在磁盘上的任何内容都只能供应用程序的众多副本之一使用。

此外,如果宿主机崩溃,这些数据很可能会消失。

这解释了为什么云原生应用程序通常是无状态的: 它们的持久性需求被委托给专门的外部系统——数据库。

选择一个数据库

我们的newsletter项目应该使用什么数据库?

我先给出我的个人经验法则,听起来可能有点争议:

如果您不确定持久性需求,请使用关系数据库。

如果您不需要大规模部署,请使用 PostgreSQL。

在过去的二十年里,数据库产品种类繁多。

从数据模型的角度来看, NoSQL 运动为我们带来了文档存储 (例如 MongoDB) c、键值存储 (例如 AWS DynamoDB)、图形数据库(例如 Neo4J) 等。

我们有一些数据库使用 RAM 作为主存储(例如 Redis)。

我们有一些数据库通过列式存储针对分析查询进行了优化(例如 AWS RedShift)。

系统设计中存在着无限可能,您绝对应该充分利用这些可能性。

然而,如果您对应用程序所使用的数据访问模式还不甚了解,那么使用专门的数据存储解决方案很容易让您陷入困境。

关系数据库可以说是万能的:在构建应用程序的第一个版本时,它们通常是一个不错的选择,可以在您探索领域约束的过程中为您提供支持。

即使是关系数据库,也有很多选择。

除了 PostgreSQLMySQL 等经典数据库外,您还会发现一些令人兴奋的新数据库,例如 AWS AuroraGoogle SpannerCockroachDB

它们有什么共同点?

它们都是为了扩展而构建的。远远超出了传统 SQL 数据库的处理能力。

如果您担心扩展性问题,请务必考虑一下。如果不是,则无需考虑额外的复杂性。

这就是我们最终选择 PostgreSQL 的原因:它是一项久经考验的技术,如果您需要托管服务,它受到所有云提供商的广泛支持,开源,拥有详尽的文档,易于在本地运行以及通过 Docker 在 CI 中运行,并且在 Rust 生态系统中得到良好支持。

选择一个数据库 Crate

截至 2020 年 8 月,在 Rust 项目中与 PostgreSQL 交互时,有三个最常用的选项:

译者注: 在2025还有一个比较欢迎的选项叫做 sea-ql, 基于sqlx 的一个数据库crate

这三个项目都非常受欢迎,并且被广泛采用,在生产环境中也占有相当的份额。您该如何选择呢? 这取决于您对以下三个主题的看法:

  • 编译时安全性
  • SQL 优先 vs. DSL 用于查询构建
  • 异步 vs. 同步接口

编译期安全

与关系数据库交互时,很容易犯错——例如,我们可能会:

  • 查询中提到的列名或表名出现拼写错误;
  • 尝试执行数据库引擎拒绝的操作(例如,将字符串和数字相加,或在错误的列上连接两个表);
  • 期望返回的数据中包含实际上不存在的某个字段。

关键问题是:我们何时意识到自己犯了错误?

在大多数编程语言中,错误发生在运行时: 当我们尝试执行查询时,

数据库会拒绝它,然后我们会收到错误或异常。使用 tokio-postgres 时就会发生这种情况。

diesel 和 sqlx 试图通过在编译时检测大多数此类错误来加快反馈周期。

diesel 利用其 CLI 将数据库schema生成为 Rust 代码的表示,然后用于检查所有查询的假设。

相反, sqlx 使用过程宏在编译时连接到数据库,并检查提供的查询是否确实合理

查询接口

tokio-postgres 和 sqlx 都要求您直接使用 SQL 编写查询。

而 diesel 则提供了自己的查询构建器:查询以 Rust 类型表示,您可以通过调用其方法来添加过滤器、执行连接和类似的操作。这通常被称为领域特定语言 (DSL)。

哪一个更好?

一如既往,这取决于具体情况。

SQL 具有极高的可移植性——您可以在任何需要与关系数据库交互的项目中使用它, 无论应用程序使用哪种编程语言或框架编写。

而 diesel 的 DSL 仅在使用 diesel 时才有意义:您需要预先支付学习成本才能熟练掌握它,而且只有在您当前和未来的项目中坚持使用 diesel 时,这才值得。还值得指出的是,使用 diesel 的 DSL 表达复杂查询可能很困难, 您最终可能还是需要编写原始 SQL。

另一方面,Diesel 的 DSL 使得编写可重用组件变得更加容易:你可以将复杂的查询拆分成更小的单元,并在多个地方使用它们,就像使用普通的 Rust 函数一样。

异步支持

我记得在某处读过一篇关于异步IO的精彩解释, 大致如下:

线程用于并行工作,异步用于并行等待。

您的数据库并不与您的应用程序位于同一物理主机上:要运行查询,您必须执行网络调用。

异步数据库驱动程序不会减少处理单个查询所需的时间,但它可以让您的应用程序在等待数据库返回结果的同时,充分利用所有 CPU 核心来执行其他有意义的工作(例如,处理另一个 HTTP 请求)。

这是否足以让您接受异步代码带来的额外复杂性?

这取决于您应用程序的性能要求。

一般来说,对于大多数用例来说,在单独的线程池上运行查询应该已经足够了。同时,如果您的 Web 框架已经是异步的,那么使用异步数据库驱动程序实际上会减少您的麻烦28。

sqlx 和 tokio-postgres 都提供了异步接口,而 diesel 是同步的,并且 不打算在不久的将来推出异步支持。

还值得一提的是,tokio-postgres 是目前唯一支持查询流水线的 crate。该功能在 sqlx 中仍处于设计阶段,而我在 diesel 的文档或问题跟踪器中找不到任何提及。

总结

让我们总结一下比较矩阵中涵盖的所有内容:

Crate编译期安全查询接口异步
tokio-postgresNoSQLYes
sqlxYesSQLYes
dieselYesDSLNo

我们的选择: sqlx

对于“从零到生产”阶段,我们将使用 sqlx:它的异步支持简化了与 actix-web 的集成,而无需我们在编译时保证上做出妥协。

由于它使用原始 SQL 进行查询,因此它还限制了我们必须覆盖和精通的 API 范围。

有副作用的集成测试

我们想要实现什么目标?

让我们再回顾一下“满意案例”测试:

#[tokio::test]
async fn subscribe_returns_a_200_for_valid_form_data() {
    // Arrange
    let app_address = spawn_app();
    let client = reqwest::Client::new();
    // Act
    let body = "name=le%20guin&email=ursula_le_guin%40gmail.com";
    let response = client
        .post(&format!("{}/subscriptions", &app_address))
        .header("Content-Type", "application/x-www-form-urlencoded")
        .body(body)
        .send()
        .await
        .expect("Failed to execute request.");

    // Assert
    assert_eq!(200, response.status().as_u16());
}

我们现有的断言是不够的。

我们无法仅通过查看 API 响应来判断预期的业务结果是否已实现——我们想知道是否发生了副作用,例如数据存储。

我们想检查新订阅者的详细信息是否已实际保存。

该怎么做呢?

我们有两个选择:

  1. 利用公共 API 的另一个端点来检查应用程序状态;
  2. 在测试用例中直接查询数据库。

如果可能,您应该选择选项 1:您的测试不会考虑 API 的实现细节(例如底层数据库技术及其模式),因此不太可能受到未来重构的干扰。

遗憾的是,我们的 API 上没有任何公共端点可以让我们验证订阅者是否存在。

我们可以添加一个 GET /subscriptions 端点来获取现有订阅者列表,但这样一来,我们就必须担心它的安全性:我们不希望在没有任何形式的身份验证的情况下,将订阅者的姓名和电子邮件暴露在公共互联网上。

我们最终可能会编写一个 GET /subscriptions 端点(也就是说,我们不想登录生产数据库来查看订阅者列表),但我们不应该仅仅为了测试正在开发的功能而开始编写新功能。

让我们咬紧牙关,在测试中编写一个小查询。当出现更好的测试策略时,我们会将其删除。

设置数据库

为了在测试套件中运行查询,我们需要:

  • 一个正在运行的 Postgres 实例
  • 一个用于存储订阅者数据的表

Docker

我们将使用 Docker 来运行 Postgres。在启动测试套件之前,我们将使用 Postgres 的官方 Docker 镜像启动一个新的 Docker 容器。

您可以按照 Docker 网站上的说明将其安装到您的机器上。

让我们为它创建一个小型 Bash 脚本 scripts/init_db.sh,并添加一些自定义 Postgres 默认设置的功能:

#!/usr/bin/env bash
set -x
set -eo pipefail

# Check if a custom user has been set, otherwise default to 'postgres'
DB_USER=${POSTGRES_USER:=postgres}
# Check if a custom password has been set, otherwise default to 'password'
DB_PASSWORD="${POSTGRES_PASSWORD:=password}"
# Check if a custom database name has been set, otherwise default to 'newsletter'
DB_NAME="${POSTGRES_DB:=newsletter}"
# Check if a custom port has been set, otherwise default to '5432'
DB_PORT="${POSTGRES_PORT:=5432}"
# Launch postgres using Docker
docker run \
  -e POSTGRES_USER=${DB_USER} \
  -e POSTGRES_PASSWORD=${DB_PASSWORD} \
  -e POSTGRES_DB=${DB_NAME} \
  -p "${DB_PORT}":5432 \
  -d postgres \
  postgres -N 1000
# ^ Increased maximum number of connections for testing purposes

执行如下命令让这个文件可以被执行

chmod +x scripts/init_db.sh

然后我们可以启动 PostgreSQL

./scripts/init_db.sh

如果你运行 docker ps 你应该会看到类似这样的内容

CONTAINER ID   IMAGE      COMMAND                  CREATED         STATUS         PORTS                                         NAMES
06b8e8a7252d   postgres   "docker-entrypoint.s…"   5 seconds ago   Up 4 seconds   0.0.0.0:5432->5432/tcp, [::]:5432->5432/tcp   zen_herschel

注意 - 如果您没有在用 Linux,端口映射位可能会略有不同!

数据库迁移

为了存储订阅者的详细信息,我们需要创建第一张表。

要向数据库添加新表,我们需要更改其架构——这通常称为数据库迁移。

sqlx-cli

sqlx 提供了一个命令行界面 sqlx-cli 来管理数据库迁移。

我们可以使用以下命令安装 CLI:

cargo install --version=0.8.6 sqlx-cli --no-default-features --features postgres

运行 sqlx --help 检查一切是否按预期工作。

数据库创建

我们通常要运行的第一个命令是 sqlx database create。根据帮助文档:

Creates the database specified in your DATABASE_URL

Usage: sqlx database create [OPTIONS]

Options:
      --no-dotenv                          Do not automatically load `.env` files
  -D, --database-url <DATABASE_URL>        Location of the DB, by default will be read from the DATABASE_URL env var or `.env`
                                           files [env: DATABASE_URL=]
      --connect-timeout <CONNECT_TIMEOUT>  The maximum time, in seconds, to try connecting to the database server before
                                           returning an error [default: 10]
  -h, --help                               Print help

在我们的例子中,这并非绝对必要:我们的 Postgres Docker 实例已经自带了一个名为 newsletter 的默认数据库,这要归功于我们在启动它时使用环境变量指定的设置。尽管如此,您仍需要在 CI 管道和生产环境中执行创建步骤,所以无论如何都值得介绍一下。

正如帮助文档所示,sqlx database create 依赖于 DATABASE_URL 环境变量来 确定要执行的操作。

DATABASE_URL 应为有效的 Postgres 连接字符串 - 格式如下:

postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}

因此,我们可以在 scripts/init_db.sh 脚本中添加几行

# [...]
export DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@localhost:${DB_PORT}/${DB_NAME}
sqlx database create

您可能偶尔会遇到一个恼人的问题:当我们尝试运行 sqlx database create 命令时,Postgres 容器尚未准备好接受连接。

我经常遇到这种情况,因此想找个解决办法:我们需要等待 Postgres 恢复正常, 然后才能开始对其运行命令。让我们将脚本更新为:

#!/usr/bin/env bash
set -x
set -eo pipefail

# Check if a custom user has been set, otherwise default to 'postgres'
DB_USER=${POSTGRES_USER:=postgres}
# Check if a custom password has been set, otherwise default to 'password'
DB_PASSWORD="${POSTGRES_PASSWORD:=password}"
# Check if a custom database name has been set, otherwise default to 'newsletter'
DB_NAME="${POSTGRES_DB:=newsletter}"
# Check if a custom port has been set, otherwise default to '5432'
DB_PORT="${POSTGRES_PORT:=5432}"
# Launch postgres using Docker
docker run \
  -e POSTGRES_USER=${DB_USER} \
  -e POSTGRES_PASSWORD=${DB_PASSWORD} \
  -e POSTGRES_DB=${DB_NAME} \
  -p "${DB_PORT}":5432 \
  -d postgres \
  postgres -N 1000
# ^ Increased maximum number of connections for testing purposes

# Keep pinging Postgres until it's ready to accept commands
export PGPASSWORD="${DB_PASSWORD}"
until psql -h "localhost" -U "${DB_USER}" -p "${DB_PORT}" -d "postgres" -c '\q'; do
  >&2 echo "Postgres is still unavailable - sleeping"
  sleep 1
done

>&2 echo "Postgres is up and running on port ${DB_PORT}!"

export DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@localhost:${DB_PORT}/${DB_NAME}
sqlx database create

问题解决了!

健康检查使用 Postgres 的命令行客户端 psql。请查看以下说明,了解如何在您的操作系统上安装它。

脚本本身没有附带清单文件来声明其依赖项:很遗憾,在没有安装所有先决条件的情况下启动脚本的情况非常常见。这通常会导致脚本在执行过程中崩溃,有时会导致系统处于半崩溃状态。

我们可以在初始化脚本中做得更好:让我们在一开始就检查 psqlsqlx-cli 是否都已安装。

set -x
set -eo pipefail

if ! [ -x "$(command -v psql)" ]; then
  echo >&2 "Error: psql is not installed."
  exit 1
fi

if ! [ -x "$(command -v sqlx)" ]; then
  echo >&2 "Error: sqlx is not installed."
  echo >&2 "Use:"
  echo >&2 "
 cargo install --version=0.5.7 sqlx-cli --no-default-features --features postgres"
echo >&2 "to install it."
exit 1
fi

# [...]

添加迁移

现在让我们创建第一个迁移

# Assuming you used the default parameters to launch Postgres in Docker!
export DATABASE_URL=postgres://postgres:password@127.0.0.1:5432/newsletter
sqlx migrate add create_subscriptions_table

您的项目中现在应该会出现一个新的顶级目录 - migrations。sqlx 的 CLI 将把我们项目的所有迁移文件存储在这里。

在 migrations 下,您应该已经有一个名为 {timestamp}_create_subscriptions_table.sql 的文件。

我们需要在这里编写第一个迁移文件的 SQL 代码。

让我们快速勾勒出我们需要的查询:

-- migrations/{timestamp}_create_subscriptions_table.sql
-- Create Subscriptions Table
CREATE TABLE subscriptions(
  id uuid NOT NULL,
  PRIMARY KEY (id),
  email TEXT NOT NULL UNIQUE,
  name TEXT NOT NULL,
  subscribed_at timestamptz NOT NULL
);

关于主键争论一直存在:有些人喜欢使用具有业务含义的列(例如,电子邮件、自然键),而另一些人则觉得使用没有任何业务含义的合成键更安全(例如,ID、随机生成的 UUID、代理键)。

除非有非常充分的理由,否则我通常默认使用合成标识符——如果您对此有不同意见,请随意。

另外需要注意以下几点:

  • 我们使用 subscribed_at 来跟踪订阅的创建时间(timestamptz 是一种支持时区的日期和时间类型)
  • 我们使用 UNIQUE 约束在数据库级别强制确保电子邮件的唯一性
  • 我们强制所有字段的每一列都应使用 NOT NULL 约束进行填充
  • 我们使用 TEXT 格式来表示电子邮件和姓名,因为我们对它们的最大长度没有任何限制

数据库约束是防止应用程序错误的最后一道防线,但它是有代价的——数据库必须确保所有检查都通过后才能将新数据写入表。因此,约束会影响我们的写入吞吐量,即单位时间内我们可以在表中插入/更新的行数。

特别是 UNIQUE 约束,它会在我们的电子邮件列上引入一个额外的 BTree 索引:该索引必须在每次执行 INSERT/UPDATE/DELETE 查询时更新,而且会占用磁盘空间。

就我们的具体情况而言,我不会太担心: 我们的邮件列表必须非常受欢迎,才会遇到写入吞吐量的问题。如果真的遇到这个问题,那绝对是个好事。

运行迁移

我们可以使用以下方法对数据库进行迁移:

sqlx migrate run

它的行为与 sqlx database create 相同——它会查看 DATABASE_URL 环境变量,以了解需要迁移的数据库。 让我们将它添加到 scripts/init_db.sh 脚本中:

#!/usr/bin/env bash
set -x
set -eo pipefail

if ! [ -x "$(command -v psql)" ]; then
  echo >&2 "Error: psql is not installed."
  exit 1
fi

if ! [ -x "$(command -v sqlx)" ]; then
  echo >&2 "Error: sqlx is not installed."
  echo >&2 "Use:"
  echo >&2 "
 cargo install --version=0.5.7 sqlx-cli --no-default-features --features postgres"
echo >&2 "to install it."
exit 1
fi

# Check if a custom user has been set, otherwise default to 'postgres'
DB_USER=${POSTGRES_USER:=postgres}
# Check if a custom password has been set, otherwise default to 'password'
DB_PASSWORD="${POSTGRES_PASSWORD:=password}"
# Check if a custom database name has been set, otherwise default to 'newsletter'
DB_NAME="${POSTGRES_DB:=newsletter}"
# Check if a custom port has been set, otherwise default to '5432'
DB_PORT="${POSTGRES_PORT:=5432}"

if [[ -z "${SKIP_DOCKER}" ]]
then
  # Launuh postgrecs sing Docker
  docker run \
    -e POSTGRES_USER=${DB_USER} \
    -e POSTGRES_PASSWORD=${DB_PASSWORD} \
    -e POSTGRES_DB=${DB_NAME} \
    -p "${DB_PORT}":5432 \
    -d postgres \
    postgres -N 1000 
  # ^ Increased maximum number of connections for testing purposes
fi

# Keep pinging Postgres until it's ready to accept commands
export PGPASSWORD="${DB_PASSWORD}"
until psql -h "localhost" -U "${DB_USER}" -p "${DB_PORT}" -d "postgres" -c '\q'; do
  >&2 echo "Postgres is still unavailable - sleeping"
  sleep 1
done

>&2 echo "Postgres is up and running on port ${DB_PORT}!"

export DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@localhost:${DB_PORT}/${DB_NAME}
sqlx database create
sqlx migrate run

注意在这里我们进行了两处修改, 一处是文件末尾的 sqlx migrate run 另外一处是对 SKIP_DOCKER 环境变量的判断

我们将 docker run 命令置于 SKIP_DOCKER 标志之后,以便轻松针对现有 Postgres 实例运行迁移,而无需手动将其关闭并使用 scripts/init_db.sh 重新创建。如果我们的脚本未启动 Postgres,此命令在 CI 中也非常有用。

现在,我们可以使用以下命令迁移数据库

SKIP_DOCKER=true ./scripts/init_db.sh

您应该能够在输出中发现类似这样的内容

+ sqlx migrate run

如果您使用您最喜欢的 Postgres GUI 检查数据库,您现在会看到一个 subscriptions 表,旁边还有一个全新的 _sqlx_migrations 表: 这是 sqlx 跟踪针对您的数据库运行了哪些迁移的地方——现在它应该包含一行,用于记录我们的 create_subscriptions_table 迁移。

编写我们的第一个查询

我们已迁移并运行数据库。该如何与它通信?

Sqlx Feature Flags

我们安装了 sqlx-cli,但实际上还没有将 sqlx 本身添加为我们应用程序的依赖项。

我们在 Cargo.toml 中添加一行新代码:

[dependencies.sqlx]
version = "0.8.6"
default-features = false
features = [
    "tls-rustls",
    "macros",
    "postgres",
    "uuid",
    "chrono",
    "migrate",
    "runtime-tokio"
]

是的,有很多功能开关。让我们逐一介绍一下:

  • tls-rustls 指示 sqlx 使用 actix 运行时作为其 Future,并使用 rustls 作为 TLS 后端;
  • macros 允许我们访问 sqlx::query! 和 sqlx::query_as!,我们将会广泛使用它们;
  • postgres 解锁 Postgres 特有的功能(例如非标准 SQL 类型);
  • uuid 增加了将 SQL UUID 映射到 uuid crate 中的 Uuid 类型的支持。我们需要它来处理我们的 id 列;
  • chrono 增加了将 SQL timestamptz 映射到 chrono cratev 中的 DateTime<T> 类型的支持。我们需要它来处理我们的 subscribed_at 列;
  • migrate 允许我们访问 sqlx-cli 后台用来管理迁移的相同函数。事实证明,它对我们的测试套件很有用。 这些应该足够我们完成本章所需的工作了。

配置管理

连接到 Postgres 数据库最简单的入口点是 PgConnection。

PgConnection 实现了 Connection trait,它为我们提供了一个 connect 方法: 它接受连接字符串作为输入,并异步返回一个 Result<PostgresConnection,sqlx::Error>

我们从哪里获取连接字符串?

我们可以在应用程序中硬编码一个连接字符串,然后将其用于测试。

或者,我们可以选择立即引入一些基本的配置管理机制。

这比听起来简单,而且可以节省我们在整个应用程序中追踪一堆硬编码值的成本。

config crate 是 Rust 配置方面的“瑞士军刀”:它支持多种文件格式,并允许您分层组合不同的源(例如环境变量、配置文件等),从而轻松地根据每个部署环境定制应用程序的行为。

我们暂时不需要任何花哨的东西: 一个配置文件就可以了。

腾出空间

目前,我们所有的应用程序代码都位于一个文件 lib.rs 中。

为了避免在添加新功能时造成混乱,我们需要快速将其拆分成多个子模块。我们希望采用以下文件夹结构:

src/
├── configuration.rs
├── lib.rs
├── main.rs
├── routes
│   ├── health_check.rs
│   └── subscriptions.rs
├── routes.rs
└── startup.rs

lib.rs 看起来是这样

//! src/lib.rs
pub mod configuration;
pub mod routes;
pub mod startup;

startup.rs 将包含我们的运行函数, health_check 函数存放在 routes/health_check.rs 中, subscribeFormData 函数存放在 routes/subscriptions.rs 中, configuration.rs 初始为空。这两个处理程序都重新导出到 routes.rs 中:

//! /src/routes.rs
mod health_check;
mod subscriptions;

pub use health_check::*;
pub use subscriptions::*;

您可能需要添加一些 pub 可见性修饰符, 以及对 main.rstests/health_check.rs 中的 use 语句进行一些修正。

请确保 cargo test 通过, 然后再继续下一步。

读取配置文件

要使用 config 管理配置,我们必须将应用程序设置表示为实现 serdeDeserialize trait 的 Rust 类型。

让我们创建一个新的 Settings struct:

//! src/configuration.rs
#[derive(serde::Deserialize)]
pub struct Settings {}

目前我们有两组配置值:

  • 应用程序端口,actix-web 监听传入请求(目前在 main.rs 中硬编码为 8000)
  • 数据库连接参数

让我们在“设置”中为每个配置值添加一个字段:

我们需要在 DatabaseSettings 之上添加 #[derive(serde::Deserialize)], 否则编译器会报错

error[E0277]: the trait bound `DatabaseSettings: configuration::_::_serde::Deserialize<'_>` is not satisfied
    --> src/configuration.rs:3:19
     |
3    |     pub database: DatabaseSettings,
     |                   ^^^^^^^^^^^^^^^^ the trait `configuration::_::_serde::Deserialize<'_>` is not implemented for `DatabaseSettings`
     |

这是有道理的: 为了使整个类型可反序列化,类型中的所有字段都必须可反序列化。

我们有配置类型了,接下来做什么?

首先, 让我们使用以下命令将配置添加到依赖项中

cargo add config

我们想从名为 configuration 的配置文件中读取我们的应用程序设置:

pub fn get_configuration() -> Result<Settings, config::ConfigError> {
    let settings = config::Config::builder()
        // Add configuration values from a file named `configuration`.
        // It will look for any top-level file with an extension
        // that `config` knows how to parse: yaml, json, etc.
        .add_source(config::File::with_name("configuration"))
        .build()
        .unwrap();

    // Try to convert the configuration values it read into
    // our Settings type
    settings.try_deserialize()
}

让我们修改 main 方法 以读取配置作为第一步:

//! src/main.rs
use std::net::TcpListener;

use zero2prod::{configuration::get_configuration, run};

#[tokio::main]
async fn main() -> std::io::Result<()> {
    let configuration = get_configuration().expect("Failed to read config");
    let address = format!("0.0.0.0:{}", configuration.application_port);
    let listener = TcpListener::bind(address)?;

    run(listener)?.await
}

如果这时候尝试运行 cargo run 会崩溃

thread 'main' panicked at src/configuration.rs:20:10:
called `Result::unwrap()` on an `Err` value: configuration file "configuration" not found
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

让我们写一个配置文件来修复这个问题

我们可以使用任何文件格式,只要 config crate 知道如何处理它: 我们将选择 YAML。

# configuration.yaml
application_port: 8000

database:
  host: "127.0.0.1"
  port: 5432
  username: "postgres"
  password: "password"
  database_name: "newsletter"

cargo run 现在应该可以顺利执行了。

连接到 Postgres

PgConnection::connect 需要单个连接字符串作为输入, 而 DatabaseSettings 则提供了对所有连接参数的精细访问。 让我们添加一个便捷的 connection_string 方法来做到这一点:

//! src/configuration.rs
// [...]
impl DatabaseSettings {
    pub fn connection_string(&self) -> String {
        format!(
            "postgres://{}:{}@{}:{}/{}",
            self.username, self.password, self.host, self.port, self.database_name
        )
    }
}

我们终于可以连接了!

让我们调整一下正常情况下的测试:

use sqlx::{Connection, PgConnection};
use zero2prod::configuration::get_configuration;

#[tokio::test]
async fn subscribe_returns_a_200_for_valid_form_data() {
    // Arrange
    let app_address = spawn_app();
    let configuration = get_configuration().expect("Failed to read configuration");
    let connection_string = configuration.database.connection_string();
    // The `Connection` trait MUST be in scope for us to invoke
    // `PgConnection::connect` - it is not an inherent method of the struct!
    let connection = PgConnection::connect(&connection_string)
            .await
            .expect("Failed to connect to Postgres.");

    let client = reqwest::Client::new();
    // Act
    let body = "name=le%20guin&email=ursula_le_guin%40gmail.com";
    let response = client
        .post(&format!("{}/subscriptions", &app_address))
        .header("Content-Type", "application/x-www-form-urlencoded")
        .body(body)
        .send()
        .await
        .expect("Failed to execute request.");

    // Assert
    assert_eq!(200, response.status().as_u16());
}

而且... cargo test 通过了!

我们刚刚确认, 测试结果显示我们能够成功连接到 Postgres!

对于世界来说,这是一小步,而对于我们来说,却是一大步。

我们的测试断言

现在我们已经连接上了,终于可以编写测试断言了。

我们之前 10 页一直梦想着这些断言。

我们将使用 sqlx 的 query! 宏:

#[tokio::test]
async fn subscribe_returns_a_200_for_valid_form_data() {
    // [...]
    // The connection has to be marked as mutable!
    let mut connection = PgConnection::connect(&connection_string)
            .await
            .expect("Failed to connect to Postgres.");

    // [...]

    // Assert
    assert_eq!(200, response.status().as_u16());

    let saved = sqlx::query!("SELECT email, name FROM subscriptions",)
        .fetch_one(&mut connection)
        .await
        .expect("Failed to fetch saved subscription.");

    assert_eq!(saved.email, "ursula_le_guin@gmail.com");
    assert_eq!(saved.name, "le guin");
}

saved 的类型是什么? query! 宏返回一个匿名记录类型: 在编译时验证查询有效后,会生成一个结构体定义,其结果中的每个列都有一个成员 (例如, email 列对应的是 saved.email)。

如果我们尝试运行 cargo test, 将会报错:

error: set `DATABASE_URL` to use query macros online, or run `cargo sqlx prepare` to update the query cache
  --> tests/health_check.rs:52:17
   |
52 |     let saved = sqlx::query!("SELECT email, name FROM subscriptions",)
   |                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
   |
   = note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query` (in Nightly builds, run with -Z macro-backtrace for more inf
o)

error: could not compile `zero2prod` (test "health_check") due to 1 previous error

正如我们之前讨论过的,sqlx 在编译时会联系 Postgres 来检查查询是否格式正确。就像 sqlx-cli 命令一样,它依赖 DATABASE_URL 环境变量来获取数据库的地址。

我们可以手动导出 DATABASE_URL, 但每次启动机器并开始处理这个项目时,都会遇到同样的问题。我们不妨参考 sqlx 作者的建议——添加一个顶层 .env 文件。

DATABASE_URL="postgres://postgres:password@localhost:5432/newsletter"

sqlx 会从中读取 DATABASE_URL, 省去了我们每次都重新导出环境变量的麻烦。

数据库连接参数放在两个地方 (.envconfiguration.yaml) 感觉有点麻烦,但这不是什么大问题: configuration.yaml 可以用来 在应用程序编译后更改其运行时行为,而 .env 只与我们的开发流程、构建和测试步骤相关。

将 .env 文件提交到版本控制——我们很快就会在持续集成 (CI) 中用到它!

让我们再次尝试运行 cargo test:

running 3 tests
test health_check_works ... ok
test subscribe_returns_a_400_when_data_is_missing ... ok
test subscribe_returns_a_200_for_valid_form_data ... FAILED

failures:

---- subscribe_returns_a_200_for_valid_form_data stdout ----

thread 'subscribe_returns_a_200_for_valid_form_data' panicked at tests/health_check.rs:55:10:
Failed to fetch saved subscription.: RowNotFound

它失败了,这正是我们想要的!

现在我们可以专注于修补应用程序,让它恢复正常。

更新 CI 流

如果你检查一下,你会发现你的 CI 流现在无法执行我们在开始时引入的大多数检查。

我们的测试现在依赖于正在运行的 Postgres 数据库才能正确执行。由于 sqlx 的编译时检查,我们所有的构建命令 (cargo checkcargo lintcargo build )都需要一个正常运行的数据库!

我们不想再冒险使用一个损坏的 CI。

您可以在这里找到 GitHub Actions 设置的更新版本。只需更新 general.yml 文件即可。

持久化新订阅者

就像我们在测试中编写了 SELECT 查询来检查哪些订阅已持久保存到数据库中一样,现在我们需要编写 INSERT 查询,以便在收到有效的 POST /subscriptions 请求时实际存储新订阅者的详细信息。

让我们看看我们的handler:

use actix_web::{HttpResponse, web};

#[derive(serde::Deserialize)]
pub struct FormData {
    email: String,
    name: String,
}

pub async fn subscribe(_form: web::Form<FormData>) -> HttpResponse {
    HttpResponse::Ok().finish()
}

要在 subscribe 中执行查询,我们需要获取数据库connection。

让我们来看看如何获​​取。

actix-web 中的应用程序状态

到目前为止,我们的应用程序完全是无状态的:我们的处理程序仅处理来自传入请求的数据。

actix-web 让我们能够将与单个传入请求的生命周期无关的其他数据附加到应用程序 - 即所谓的应用程序状态。

您可以使用 App 上的 app_data 方法向应用程序状态添加信息。

让我们尝试使用 app_dataPgConnection 注册为应用程序状态的一部分。我们需要修改 run 方法,使其与 TcpListener 一起接受 PgConnection:

//! src/startup.rs
use std::net::TcpListener;

use actix_web::{dev::Server, web, App, HttpServer};
use sqlx::PgConnection;

use crate::routes::{health_check, subscribe};

pub fn run(
    listener: TcpListener,
    // New parameter!
    connection: PgConnection,
) -> Result<Server, std::io::Error> {
    let server = HttpServer::new(|| {
        App::new()
            .route("/health_check", web::get().to(health_check))
            .route("/subscriptions", web::post().to(subscribe))
            .app_data(connection)
    })
    .listen(listener)?
    .run();

    Ok(server)
}

这不能真正工作, cargo check 提示如下信息

error[E0277]: the trait bound `PgConnection: Clone` is not satisfied in `{closure@src/startup.rs:13
:34: 13:36}`
  --> src/startup.rs:13:18
   |
13 |     let server = HttpServer::new(|| {
   |                  ^^^^^^^^^^      -- within this `{closure@src/startup.rs:13:34: 13:36}`
   |                  |
   |                  unsatisfied trait bound
   |
   = help: within `{closure@src/startup.rs:13:34: 13:36}`, the trait `Clone` is not implemented for
 `PgConnection`
note: required because it's used within this closure
  --> src/startup.rs:13:34
   |
13 |     let server = HttpServer::new(|| {
   |                                  ^^
note: required by a bound in `HttpServer`
  --> /home/cubewhy/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/actix-web-4.11.0/src/serve
r.rs:72:27
   |
70 | pub struct HttpServer<F, I, S, B>
   |            ---------- required by a bound in this struct
71 | where
72 |     F: Fn() -> I + Send + Clone + 'static,
   |                           ^^^^^ required by this bound in `HttpServer`

For more information about this error, try `rustc --explain E0277`.

HttpServer 期望 PgConnection 可克隆, 但不幸的是, 事实并非如此。

为什么它首先需要实现 Clone trait呢?

actix-web Workers

让我们放大对 HttpServer::new 的调用:

let server = HttpServer::new(|| {
    App::new()
        .route("/health_check", web::get().to(health_check))
        .route("/subscriptions", web::post().to(subscribe))
})

HttpServer::new 不接受 App 作为参数——它需要一个返回 App 结构体的闭包。

这是为了支持 actix-web 的运行时模型:actix-web 会为您机器上的每个可用核心启动一个工作进程。

每个工作进程都运行由 HttpServer 构建的应用程序副本,并调用 HttpServer::new 作为参数的同一个闭包。 这就是为什么连接必须是可克隆的——我们需要为每个 App 副本创建一个连接。

但是,正如我们所说,PgConnection 没有实现 Clone 接口,因为它位于一个不可克隆的系统资源之上,即与 Postgres 的 TCP 连接。我们该怎么办?

我们可以使用另一个 actix-web 提取器 web::Data

web::Data 将我们的连接包装在一个原子引用计数指针 Arc 中:应用程序的每个实例都将获得一个指向 PgConnection 的指针,而不是获取 PgConnection 的原始副本。

Arc<T> 始终可克隆,无论 T 是什么:克隆 Arc 会增加活动引用的数量,并移交包装值内存地址的拷贝。

然后,处理程序可以使用相同的提取器访问应用程序状态。

让我们试一下:

//! src/startup.rs
use std::net::TcpListener;

use actix_web::{dev::Server, web, App, HttpServer};
use sqlx::PgConnection;

use crate::routes::{health_check, subscribe};

pub fn run(
    listener: TcpListener,
    // New parameter!
    connection: PgConnection,
) -> Result<Server, std::io::Error> {
    let connection = web::Data::new(connection);

    let server = HttpServer::new(move || {
        App::new()
            .route("/health_check", web::get().to(health_check))
            .route("/subscriptions", web::post().to(subscribe))
            .app_data(connection.clone())
    })
    .listen(listener)?
    .run();

    Ok(server)
}

它还不能编译,但我们只需要做一些整理工作:

error[E0061]: this function takes 2 arguments but 1 argument was supplied
  --> tests/health_check.rs:96:18
   |
96 |     let server = zero2prod::run(listener).expect("Failed to bind address");
   |                  ^^^^^^^^^^^^^^---------- argument #2 of type `PgConnection` is missing
   |

让我们快速修复这个问题:

//! src/main.rs
use std::net::TcpListener;

use sqlx::{Connection, PgConnection};
use zero2prod::{configuration::get_configuration, run};

#[tokio::main]
async fn main() -> std::io::Result<()> {
    let configuration = get_configuration().expect("Failed to read config");
    let connection = PgConnection::connect(&configuration.database.connection_string())
        .await
        .expect("Failed to connect to Postgres.");

    let address = format!("0.0.0.0:{}", configuration.application_port);
    let listener = TcpListener::bind(address)?;

    run(listener, connection)?.await
}

完美,编译通过。

Data Extractor

现在, 我们可以在请求处理程序中获取 Arc<PgConnection> 了,订阅时可以使用 web::Data Extractor:

//! src/routes/subscriptions.rs
use actix_web::{HttpResponse, web};
use sqlx::PgConnection;

// [...]

pub async fn subscribe(
    _form: web::Form<FormData>,
    _connection: web::Data<PgConnection>,
) -> HttpResponse {
    HttpResponse::Ok().finish()
}

我们将 Data 称为提取器,但它究竟是从哪里提取 PgConnection 的呢?

actix-web 使用类型映射来表示其应用程序状态:一个 HashMap, 存储任意数据 (使用 Any 类型) 及其唯一类型标识符 (通过 TypeId::of 获取)。

当新请求到来时, web::Data 会计算您在签名中指定的类型 (在我们的例子中是 PgConnection) 的 TypeId,并检查类型映射中是否存在与其对应的记录。如果有,它会将检索到的 Any 值转换为您指定的类型(TypeId 是唯一的, 无需担心), 并将其传递给您的处理程序。

这是一种有趣的技术,可以执行在其他语言生态系统中可能被称为依赖注入的操作。

INSERT 查询

我们终于在 subscribe 中建立了连接: 让我们尝试持久化新订阅者的详细信息。

我们将再次使用在快乐测试中用过的 query! 宏。

//! src/routes/subscriptions.rs
// [...]
use uuid::Uuid;
use chrono::Utc;

// [...]

pub async fn subscribe(
    form: web::Form<FormData>,
    connection: web::Data<PgConnection>,
) -> HttpResponse {
    sqlx::query!(
        r#"
        INSERT INTO subscriptions (id, email, name, subscribed_at)
        VALUES ($1, $2, $3, $4)
        "#,
        Uuid::new_v4(),
        form.email,
        form.name,
        Utc::now()
    )
    .execute(connection.get_ref())
    .await;
    HttpResponse::Ok().finish()
}

让我们来剖析一下发生了什么:

  • 我们将动态数据绑定到 INSERT 查询。$1 表示查询本身之后传递给 query! 的第一个参数, $2 表示第二个参数,依此类推。query! 在编译时会验证 提供的参数数量是否与查询预期相符,以及它们的 类型是否兼容 (例如,不能将数字作为 id 传递)
  • 我们为 id 生成一个随机的 Uuid
  • 我们为 subscribed_at 使用 UTC 时区的当前时间戳

我们还必须在 Cargo.toml 中添加两个新的依赖项来修复明显的编译器错误:

cargo add uuid --features=v4
cargo add chrono

如果我们尝试再次编译它会发生什么?

error[E0277]: the trait bound `&PgConnection: Executor<'_>` is not satisfied
   --> src/routes/subscriptions.rs:27:6
    |
27  |     .await;
    |      ^^^^^ the trait `Executor<'_>` is not implemented for `&PgConnection`
    |
    = help: the trait `Executor<'_>` is not implemented for `&PgConnection`
            but it is implemented for `&mut PgConnection`
    = note: `Executor<'_>` is implemented for `&mut PgConnection`, but not for `&PgConnection`

execute 需要一个实现 sqlxExecutor trait 的参数,事实证明, 正如我们在测试中编写的查询中所记得的那样, &PgConnection 并没有实现 Executor - 只有 &mut PgConnection 实现了。

为什么会这样? sqlx 有一个异步接口,但它不允许你在同一个数据库连接上并发运行多个查询。

要求可变引用允许他们在 API 中强制执行此保证。你可以将可变引用视为唯一引用:编译器保证执行时确实拥有对该 PgConnection 的独占访问权,因为在整个程序中不可能同时存在两个指向相同值的活跃可变引用。相当巧妙。 尽管如此,这看起来像是我们把自己设计成了一个死胡同: web::Data 永远不会给我们提供对应用程序状态的可变访问权限。

我们可以利用内部可变性——例如,将我们的 PgConnection 置于锁(例如 Mutex)之后, 这将允许我们同步对底层 TCP 套接字的访问,并在获取锁后获得对包装连接的可变引用。

我们可以让它工作,但这并不理想:我们被限制一次最多只能运行一个查询。这不太好。

让我们再看一下 sqlx 的 Executor trait的文档: 除了 &mut PgConnection 之外,还有什么实现了 Executor?

Bingo: 对 PgPool 的共享引用。

PgPool 是一个 Postgres 数据库连接池。它是如何绕过我们刚刚讨论过的 PgConnection 的并发问题的? 内部仍然有可变性,但类型不同:当你对 &PgPool 运行查询时,sqlx 会从池中借用一个 PgConnection 并用它来执行查询;如果没有可用的连接,它会创建一个新的或等待直到有连接释放。

这增加了我们的应用程序可以运行的并发查询数量,并提高了其弹性: 单个慢查询不会通过在连接锁上创建争用而影响所有传入请求的性能。

让我们重构运行、主函数和订阅函数来使用 PgPool 而不是单个 PgConnection:

//! src/main.rs
use std::net::TcpListener;

use sqlx::{Connection, PgPool};
use zero2prod::{configuration::get_configuration, run};

#[tokio::main]
async fn main() -> std::io::Result<()> {
    let configuration = get_configuration().expect("Failed to read config");
    // Renamed!
    let connection_pool = PgPool::connect(&configuration.database.connection_string())
        .await
        .expect("Failed to connect to Postgres.");

    let address = format!("0.0.0.0:{}", configuration.application_port);
    let listener = TcpListener::bind(address)?;

    run(listener, connection_pool)?.await
}
//! src/startup.rs
use std::net::TcpListener;

use actix_web::{dev::Server, web, App, HttpServer};
use sqlx::PgPool;

use crate::routes::{health_check, subscribe};

pub fn run(
    listener: TcpListener,
    db_pool: PgPool,
) -> Result<Server, std::io::Error> {
    let db_pool = web::Data::new(db_pool);

    let server = HttpServer::new(move || {
        App::new()
            .route("/health_check", web::get().to(health_check))
            .route("/subscriptions", web::post().to(subscribe))
            .app_data(db_pool.clone())
    })
    .listen(listener)?
    .run();

    Ok(server)
}
//! src/routes/subscriptions.rs
// No longer importing PgConnection!
use sqlx::PgPool;
// [...]

pub async fn subscribe(
    form: web::Form<FormData>,
    pool: web::Data<PgPool>, // Renamed!
) -> HttpResponse {
    sqlx::query!(
      /* [...] */
    )
    .execute(pool.get_ref())
    .await;
    HttpResponse::Ok().finish()
}

编译器几乎很高兴: cargo check 向我们发出了警告。

warning: unused `Result` that must be used
  --> src/routes/subscriptions.rs:16:5
   |
16 | /     sqlx::query!(
17 | |         r#"
18 | |         INSERT INTO subscriptions (id, email, name, subscribed_at)
19 | |         VALUES ($1, $2, $3, $4)
...  |
26 | |     .execute(pool.get_ref())
27 | |     .await;
   | |__________^
   |
   = note: this `Result` may be an `Err` variant, which should be handled
   = note: `#[warn(unused_must_use)]` on by default

sqlx::query 可能会失败——它返回一个 Result, 这是 Rust 对易错函数建模的方式。

编译器提醒我们处理错误情况——让我们遵循以下建议:

//! src/routes/subscriptions.rs
// [...]
pub async fn subscribe(
    form: web::Form<FormData>,
    pool: web::Data<PgPool>, // Renamed!
) -> HttpResponse {
    match sqlx::query!(
        r#"
        INSERT INTO subscriptions (id, email, name, subscribed_at)
        VALUES ($1, $2, $3, $4)
        "#,
        Uuid::new_v4(),
        form.email,
        form.name,
        Utc::now()
    )
    .execute(pool.get_ref())
    .await
    {
        Ok(_) => HttpResponse::Ok().finish(),
        Err(e) => {
            println!("Failed to execute query: {e}");
            HttpResponse::InternalServerError().finish()
        }
    }
}

cargo check 满足要求,但 cargo test 却不能满足要求:

error[E0061]: this function takes 2 arguments but 1 argument was supplied
  --> tests/health_check.rs:96:18
   |
96 |     let server = zero2prod::run(listener).expect("Failed to bind address");
   |                  ^^^^^^^^^^^^^^---------- argument #2 of type `Pool<Postgres>` is missing
   |

更新我们的测试

错误出在我们的 spawn_app 辅助函数中:

fn spawn_app() -> String {
    let listener = TcpListener::bind("127.0.0.1:0").expect("Faield to bind random port");
    // We retrieve the port assigned to us by the OS
    let port = listener.local_addr().unwrap().port();
    let server = zero2prod::run(listener).expect("Failed to bind address");
    let _ = tokio::spawn(server);

    // We return the application address to the caller!
    format!("http://127.0.0.1:{}", port)
}

我们需要传递一个连接池来运行。

考虑到我们接下来在 subscribe_returns_a_200_for_valid_form_data 中需要用到这个连接池来执行 SELECT 查询,因此可以对 spawn_app 进行泛化: 我们不会返回原始字符串,而是会给调用者一个结构体 TestAppTestApp 将保存测试应用程序实例的地址和连接池的句柄,从而简化测试用例的配置步骤。

//! tests/health_check.rs
use std::net::TcpListener;

use sqlx::{Connection, PgConnection, PgPool};
use zero2prod::configuration::get_configuration;

pub struct TestApp {
    pub address: String,
    pub db_pool: PgPool,
}

async fn spawn_app() -> TestApp {
    let listener = TcpListener::bind("127.0.0.1:0").expect("Faield to bind random port");
    // We retrieve the port assigned to us by the OS
    let port = listener.local_addr().unwrap().port();
    let address = format!("http://127.0.0.1:{}", port);

    let configuration = get_configuration().expect("");
    let connection_pool = PgPool::connect(&configuration.database.connection_string())
        .await
        .expect("Failed to connect to Postgres");
    let server = zero2prod::run(listener, connection_pool.clone()).expect("Failed to bind address");
    let _ = tokio::spawn(server);

    TestApp {
        address,
        db_pool: connection_pool,
    }
}

所有测试用例都必须进行相应的更新——这个屏幕外的练习,我留给你了,亲爱的读者。

让我们一起来看看 subscribe_returns_a_200_for_valid_form_data 在完成必要的更改后是什么样子的:

//! tests/health_check.rs
// [...]

#[tokio::test]
async fn subscribe_returns_a_200_for_valid_form_data() {
    // Arrange
    let app = spawn_app().await;
    let app_address = app.address.as_str();

    let client = reqwest::Client::new();
    // Act
    let body = "name=le%20guin&email=ursula_le_guin%40gmail.com";
    let response = client
        .post(&format!("{}/subscriptions", &app_address))
        .header("Content-Type", "application/x-www-form-urlencoded")
        .body(body)
        .send()
        .await
        .expect("Failed to execute request.");

    // Assert
    assert_eq!(200, response.status().as_u16());

    let saved = sqlx::query!("SELECT email, name FROM subscriptions",)
        .fetch_one(&app.db_pool)
        .await
        .expect("Failed to fetch saved subscription.");

    assert_eq!(saved.email, "ursula_le_guin@gmail.com");
    assert_eq!(saved.name, "le guin");
}

现在, 我们去掉了大部分与建立数据库连接相关的样板代码, 测试意图更加清晰了。

TestApp 是我们未来构建的基础, 我们将在此基础上开发出对大多数集成测试都有用的支持功能。

关键时刻终于到来了: 我们更新后的订阅实现是否足以让 subscribe_returns_a_200_for_valid_form_data 测试通过?

running 3 tests
test health_check_works ... ok
test subscribe_returns_a_400_when_data_is_missing ... ok
test subscribe_returns_a_200_for_valid_form_data ... ok

Yesssssssss!

成功了!

让我们再次奔跑,沐浴在这辉煌时刻的光芒之中!

cargo test
running 3 tests
test health_check_works ... ok
test subscribe_returns_a_400_when_data_is_missing ... ok
test subscribe_returns_a_200_for_valid_form_data ... FAILED

failures:

---- subscribe_returns_a_200_for_valid_form_data stdout ----
Failed to execute query: error returned from database: duplicate key value violates unique constrai
nt "subscriptions_email_key"

thread 'subscribe_returns_a_200_for_valid_form_data' panicked at tests/health_check.rs:45:5:
assertion `left == right` failed
  left: 200
 right: 500
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    subscribe_returns_a_200_for_valid_form_data

test result: FAILED. 2 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.07s

等等,不,搞什么鬼! 别这样对我们!

好吧,我撒谎了——我就知道会发生这种事。

对不起,我让你尝到了胜利的甜蜜滋味,然后又把你扔回了泥潭。

相信我,这里有一个重要的教训需要吸取。

测试隔离

您的数据库是一个巨大的全局变量:所有测试都与其交互,并且它们留下的任何数据都将对套件中的其他测试以及后续测试运行可用。

这正是我们刚才发生的事情:我们的第一次测试运行命令我们的应用程序注册一个邮箱地址为 ursula_le_guin@gmail.com 的新用户;应用程序执行了命令。

当我们重新运行测试套件时,我们再次尝试使用相同的邮箱地址执行另一个 INSERT 操作, 但是, 我们对邮箱列的 UNIQUE 约束引发了唯一键冲突并拒绝了查询, 导致应用程序返回 500 INTERNAL_SERVER_ERROR 错误。

您真的不希望测试之间有任何形式的交互:这会使您的测试运行变得不确定,并最终导致虚假的测试失败,而这些失败极难排查和修复。

据我所知,在测试中与关系数据库交互时,有两种技术可以确保测试隔离性:

  • 将整个测试包装在 SQL 事务中,并在事务结束时回滚;
  • 为每个集成测试启动一个全新的逻辑数据库。

第一种方法很巧妙,通常速度更快:回滚 SQL 事务比启动新的逻辑数据库所需的时间更少。在为查询编写单元测试时,这种方法效果很好,但在像我们这样的集成测试中,实现起来比较棘手:我们的应用程序将从 PgPool 借用一个 PgConnection,而我们无法在 SQL 事务上下文中“捕获”该连接。

这就引出了第二种方案:可能速度更慢,但实现起来更容易。

如何实现?

在每次测试运行之前,我们需要:

  • 创建一个具有唯一名称的新逻辑数据库;
  • 在其上运行数据库迁移。

执行此操作的最佳位置是 spawn_app,在启动我们的 actix-web 测试应用程序之前。

让我们再看一下:

// [...]
pub struct TestApp {
    pub address: String,
    pub db_pool: PgPool,
}

async fn spawn_app() -> TestApp {
    let listener = TcpListener::bind("127.0.0.1:0").expect("Faield to bind random port");

    let port = listener.local_addr().unwrap().port();
    let address = format!("http://127.0.0.1:{}", port);

    let configuration = get_configuration().expect("");
    let connection_pool = PgPool::connect(&configuration.database.connection_string())
        .await
        .expect("Failed to connect to Postgres");
    let server = zero2prod::run(listener, connection_pool.clone()).expect("Failed to bind address");
    let _ = tokio::spawn(server);

    TestApp {
        address,
        db_pool: connection_pool,
    }
}

configuration.database.connection_string() 使用我们在 configuration.yaml 文件中指定的数据库名称 - 所有测试都相同。

让我们将其随机化

let mut configuration = get_configuration().expect("");
configuration.database.database_name = Uuid::new_v4().to_string();

cargo test 将会失败: 没有数据库准备好使用我们生成的名称来接受连接。

让我们在 DatabaseSettings 中添加一个 connection_string_without_db 方法:

//! src/configuration.rs
// [...]
impl DatabaseSettings {
    pub fn connection_string(&self) -> String {
        format!(
            "postgres://{}:{}@{}:{}/{}",
            self.username, self.password, self.host, self.port, self.database_name
        )
    }

    pub fn connection_string_without_db(&self) -> String {
        format!(
            "postgres://{}:{}@{}:{}",
            self.username, self.password, self.host, self.port
        )
    }
}

省略数据库名称,我们连接到 Postgres 实例, 而不是特定的逻辑数据库。

现在我们可以使用该连接创建所需的数据库并在其上运行迁移:

//! tests/health_check.rs
// [...]
use sqlx::Executor;

// [...]
async fn spawn_app() -> TestApp {
    // [...]
    let mut configuration = get_configuration().expect("");
    configuration.database.database_name = Uuid::new_v4().to_string();

    let connection_pool = configure_database(&configuration.database).await;
    // [...]
}

pub async fn configure_database(config: &DatabaseSettings) -> PgPool {
    // Create database
    let mut connection = PgConnection::connect(&config.connection_string_without_db())
        .await
        .expect("Failed to connect to Postgres");

    connection
        .execute(format!(r#"CREATE DATABASE "{}";"#, config.database_name).as_str())
        .await
        .expect("Failed to create database.");

    // Migrate database
    let connection_pool = PgPool::connect(&config.connection_string())
        .await
        .expect("Failed to connect to Postgres");

    sqlx::migrate!("./migrations")
        .run(&connection_pool)
        .await
        .expect("Failed to migrate the database");

    connection_pool
}

sqlx::migrate!sqlx-cli 在执行 sqlx migration run 时使用的宏相同——无需 再添加 Bash 脚本即可达到相同的效果。 让我们再次尝试运行 cargo test:

running 3 tests
test subscribe_returns_a_400_when_data_is_missing ... ok
test health_check_works ... ok
test subscribe_returns_a_200_for_valid_form_data ... ok

test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.23s

这次成功了,而且是永久性的。

您可能已经注意到,我们在测试结束时没有执行任何清理步骤——我们创建的逻辑数据库不会被删除。这是有意为之: 我们可以添加清理步骤,但我们的 Postgres 实例仅用于测试目的,如果在数百次测试运行之后,由于大量残留(几乎为空)数据库而导致性能下降,重启它很容易。

小结

本章涵盖了大量主题: actix-web 提取器和 HTML 表单、 使用 serde 进行序列化/反序列化、Rust 生态系统中可用数据库 crate 的概述、sqlx 的基础知识以及在处理数据库时确保测试隔离的基本技术。

请花时间消化这些内容,并在必要时回头复习各个章节。

遥测

在第三章中,我们成功地完成了 POST /订阅的第一个实现,以完成我们电子邮件通讯项目的一个用户故事:

作为博客访问者, 我想订阅新闻简报, 以便在博客发布新内容时收到电子邮件更新

我们尚未创建包含 HTML 表单的网页来实际测试端到端流程,但我们已进行了一些黑盒集成测试,涵盖了现阶段我们关注的两种基本场景:

  • 如果提交了有效的表单数据(即同时提供了姓名和电子邮件),则数据将保存在我们的数据库中
  • 如果提交的表单不完整(例如,缺少电子邮件、姓名或两者兼而有之),则 API 将返回 400 错误。

我们应该满足于现状,急于将应用程序的第一个版本部署到最酷的云服务提供商上吗?

还不行——我们还没有能力在生产环境中正常运行我们的软件。 我们对此一无所知:应用程序尚未进行检测,我们也没有收集任何遥测数据,这让我们容易受到未知因素的影响。

如果您对上一句话的大部分内容感到困惑,请不要担心:彻底弄清楚这个问题将是本章的重点。

未知的未知

我们进行了一些测试。测试固然重要,它们能让我们对自己的软件及其正确性更有信心。

然而,测试套件并不能证明我们应用程序的正确性。我们必须探索截然不同的方法来证明某些东西是正确的(例如形式化方法)。

在运行时,我们会遇到一些在设计应用程序时从未测试过,甚至从未考虑过的场景。

根据我们迄今为止所做的工作以及我过去的经验,我可以指出一些盲点:

  • 如果数据库连接断开会发生什么?sqlx::PgPool 会尝试自动恢复吗?还是从那时起,所有数据库交互都会失败,直到我们重新启动应用程序?
  • 如果攻击者试图在 POST/subscriptions 请求的主体中传递恶意负载(例如,极大的负载、尝试执行 SQL 注入等),会发生什么?

这些通常被称为已知的未知问题:我们已知但尚未调查的缺陷,或者我们认为它们与实际无关,不值得花时间处理。

只要投入足够的时间和精力,我们就能摆脱大多数已知的未知问题。

不幸的是,有些问题我们以前从未见过,也没有预料到,它们就是未知的未知问题。

有时,经验足以将未知的未知问题转化为已知的未知问题:如果你以前从未使用过数据库,你可能从未想过连接中断时会发生什么;一旦你见过一次,它就变成了一种熟悉的故障模式,需要时刻注意。

通常情况下,未知的未知问题是我们正在开发的特定系统特有的故障模式。

它们存在于我们的软件组件、底层操作系统、我们正在使用的硬件、我们开发流程的特性以及被称为“外部世界”的巨大随机性来源之间的交汇处。

它们可能出现在以下情况:

  • 系统超出其正常运行条件(例如,异常的流量峰值)
  • 多个组件同时发生故障(例如,数据库进行主从故障转移时,SQL 事务被挂起)
  • 引入了改变系统平衡的变更(例如,调整重试策略)
  • 长时间未引入任何变更(例如,应用程序数周未重启,然后开始出现各种内存泄漏)
  • 等等

所有这些场景都有一个关键的相似之处: 它们通常无法在实际环境之外重现。

我们该如何做好准备,以应对由未知陌生人造成的中断或错误?

可观察性

我们必须假设,当未知问题出现时,我们可能不在现场:可能是深夜,也可能正在处理其他事情,等等。

即使我们在问题开始出现的那一刻注意到了问题,通常也不可能或不切实际地将调试器连接到生产环境中运行的进程(假设你一开始就知道应该查看哪个进程),而且性能下降可能会同时影响 多个系统。

我们唯一可以依赖的理解和调试未知问题的方法是遥测数据: 关于我们正在运行的应用程序的信息,这些信息会被自动收集,之后可以进行检查,以解答有关系统在某个时间点状态的问题。 什么问题?

好吧,如果这是一个未知的未知问题,我们事先并不知道需要提出哪些问题来隔离其根本原因——这就是关键所在。

我们的目标是拥有一个可观察的应用程序

引用自 Honeycomb 的可观察性指南:

可观察性是指能够针对你的环境提出任意问题,而无需 (——这是关键部分——) 提前知道你想问什么。

“任意”这个词用得有点过头了——就像所有绝对的表述一样,如果我们从字面上理解它,可能需要投入不合理的时间和金钱。

实际上,我们也会乐意选择一个具有足够可观察性的应用程序,以便我们能够提供我们向用户承诺的服务水平。

简而言之,要构建一个可观察的系统,我们需要:

  • 对我们的应用程序进行检测,以收集高质量的遥测数据;
  • 能够使用工具和系统,高效地对数据进行切片、切块和操作,以找到问题的答案。

我们将讨论一些可用于实现第二点的选项,但详尽的讨论超出了本书的范围。

在本章的其余部分,我们将重点讨论第一点。

日志记录

日志是最常见的遥测数据类型。

即使是从未听说过可观察性的开发人员,也能直观地理解日志的用处:当事情出错时,你会查看日志来了解正在发生的事情, 并祈祷自己能捕获足够的信息来有效地进行故障排除。

那么,日志是什么呢?

它的格式因时代、平台和你使用的技术而异。

如今,日志记录通常是一堆文本数据,并用换行符分隔当前记录和下一个记录。例如

The application is starting on port 8080
Handling a request to /index
Handling a request to /index
Returned a 200 OK

对于 Web 服务器来说,这四条日志记录完全有效。

Rust 生态系统在日志记录方面能为我们提供什么?

log crate

Rust 中用于日志记录的首选 crate 是 log

log 提供了五个宏:tracedebuginfowarnerror

它们的作用相同——发出一条日志记录——但正如其名称所暗示的那样,它们各自使用不同的日志级别。

trace 是最低级别: trace 级别的日志通常非常冗长,信噪比较低(例如,每次 Web 服务器收到 TCP 数据包时都会发出一条 trace 级别的日志记录)。

然后,我们依次按严重程度递增,依次为 debug、info、warn 和 error。

Error 级别的日志用于报告可能对用户造成影响的严重故障(例如,我们未能处理传入的请求或数据库查询超时)。

让我们看一个简单的使用示例:

fn fallible_operation() -> Result<String, String> { ... }

pub fn main() {
    match fallible_operation() {
        Ok(success) => {
            log::info!("Operation succeeded: {}", success);
        }
        Err(err) => {
            log::error!("Operation failed: {}", err);
        }
    }
}

我们正在尝试执行一个可能会失败的操作。

如果成功,我们将发出一条信息级别的日志记录。

如果失败,我们将发出一条错误级别的日志记录。

另请注意,log 的宏支持与标准库中 println/print 相同的插值语法。

我们可以使用 log 的宏来检测我们的代码库。

选择记录关于特定函数执行的信息通常是一个本地决策:只需查看函数本身即可决定哪些信息值得在日志记录中捕获。

这使得库能够被有效地检测,将遥测的范围扩展到我们亲自编写的代码之外。

actix-web 的日志中间件

actix_web 提供了一个 Logger 中间件。它会为每个传入的请求发出一条日志记录。

让我们将它添加到我们的应用程序中。

//! src/startup.rs
use std::net::TcpListener;

use actix_web::{dev::Server, middleware::Logger, web, App, HttpServer};
use sqlx::PgPool;

use crate::routes::{health_check, subscribe};

pub fn run(
    listener: TcpListener,
    db_pool: PgPool,
) -> Result<Server, std::io::Error> {
    let db_pool = web::Data::new(db_pool);

    let server = HttpServer::new(move || {
        App::new()
            // Middlewares are added using the `wrap` method on `App`
            .wrap(Logger::default())
            .route("/health_check", web::get().to(health_check))
            .route("/subscriptions", web::post().to(subscribe))
            .app_data(db_pool.clone())
    })
    .listen(listener)?
    .run();

    Ok(server)
}

现在,我们可以使用 cargo run 启动该应用,并使用 curl 快速发送一个请求:curl http://127.0.0.1:8000/health_check -v

请求返回 200,但是......我们用来启动应用的终端上没有任何反应。

没有日志。什么也没有。屏幕一片空白。

门面模式

我们说过,检测是一个局部决策。

相反,应用程序需要做出一个全局决策:我们应该如何处理所有这些日志记录?

应该将它们附加到文件中吗? 应该将它们打印到终端吗? 应该通过 HTTP 将它们发送到远程系统(例如 ElasticSearch)吗?

log crate 利用门面模式来处理这种二元性。

它为您提供了发出日志记录所需的工具,但没有规定应该如何处理这些日志记录。相反,它提供了一个 Log trait:

//! From `log`'s source code - src/lib.rs
/// A trait encapsulating the operations required of a logger.
pub trait Log: Sync + Send {
    /// Determines if a log message with the specified metadata would be
    /// logged.
    ///
    /// This is used by the `log_enabled!` macro to allow callers to avoid
    /// expensive computation of log message arguments if the message would be
    /// discarded anyway.
    fn enabled(&self, metadata: &Metadata) -> bool;
    /// Logs the `Record`.
    ///
    /// Note that `enabled` is *not* necessarily called before this method.
    /// Implementations of `log` should perform all necessary filtering
    /// internally.
    fn log(&self, record: &Record);
}

在主函数的开头, 您可以调用 set_logger 函数并传递 Log trait 的实现: 每次发出日志记录时,都会在您提供的记录器上调用 Log::log,从而可以执行您认为必要的任何形式的日志记录处理。

如果不调用 set_logger,所有日志记录都会被丢弃。这正是我们应用程序所发生的事情。

这次让我们初始化我们的日志记录器。

crates.io 上有一些日志实现 - 最常用的选项列在 log 本身的文档中。

我们将使用 env_logger - 如果像我们的例子一样,主要目标是将所有日志记录打印到终端,那么它就非常有效。

让我们将其添加为依赖项

cargo add env_logger

env_logger::Logger 将日志记录打印到终端,使用以下格式:

[<timestamp> <level> <module path>] <log message>

它会查看 RUST_LOG 环境变量来确定哪些日志应该打印,哪些日志应该被过滤掉。

例如, RUST_LOG=debug cargo run 会显示由我们的应用程序或我们正在使用的 crate 发出的所有调试级别或更高级别的日志。而 RUST_LOG=zero2prod 则会过滤掉由我们的依赖项发出的所有记录。

让我们根据需要修改 main.rs 文件:

//! src/main.rs
// [...]
use env_logger::Env;

#[tokio::main]
async fn main() -> std::io::Result<()> {
    // `init` does call `get_logger`, so this is all we need to do.
    // We are falling back to printing all logs at into-level or above
    // if the RUST_LOG envirionment variable has not been set.
    env_logger::Builder::from_env(Env::default().default_filter_or("info")).init();

    // [...]
}

让我们尝试使用 cargo run 再次启动该应用程序(根据我们的默认逻辑,这相当于 RUST_LOG=info cargo run)。终端上应该会显示两条日志记录(使用换行符,并缩进以使其适合页边距)。

[2025-08-23T01:23:50Z INFO  actix_server::builder] starting 20 workers
[2025-08-23T01:23:50Z INFO  actix_server::server] Tokio runtime found; starting in existing Tokio r
untime
[2025-08-23T01:23:50Z INFO  actix_server::server] starting service: "actix-web-service-0.0.0.0:8000
", workers: 20, listening on: 0.0.0.0:8000

如果我们使用 curl 发送 http://127.0.0.1:8000/health_check 请求,你应该会看到另一条日志记录, 这条记录是由我们在前几段中添加的 Logger 中间件发出的。

[2025-08-23T01:24:24Z INFO  actix_web::middleware::logger] 127.0.0.1 "GET /health_check HTTP/1.1" 200 0 "-" "curl/8.15.0" 0.000094

日志也是探索我们正在使用的软件如何工作的绝佳工具。

尝试将 RUST_LOG 设置为 trace 并重新启动应用程序。

您应该会看到一堆来自 docs.rs/mio(一个用于非阻塞 IO 的底层库)的轮询器注册日志记录,以及由 actix-web 生成的每个工作进程的几个启动日志记录(每个工作进程对应您机器上每个可用的物理核心!)。

通过研究 trace 级别的日志,您可以学到很多有深度的东西。

检测 POST /订阅

让我们利用之前学到的关于日志的知识,来设计一个 POST /subscriptions 请求的处理程序。

目前它看起来像这样:

pub async fn subscribe(
    form: web::Form<FormData>,
    pool: web::Data<PgPool>,
) -> HttpResponse {
    match sqlx::query!(/* [...] */)
    .execute(pool.get_ref())
    .await
    {
        Ok(_) => HttpResponse::Ok().finish(),
        Err(e) => {
            println!("Failed to execute query: {e}");
            HttpResponse::InternalServerError().finish()
        }
    }
}

让我们添加 log crate

cargo add log

我们应该在日志记录中捕获什么?

与外部系统的交互

让我们从一条久经考验的经验法则开始:任何通过网络与外部系统的交互都应受到密切监控。我们可能会遇到网络问题,数据库可能不可用,查询速度可能会随着订阅者表的变长而变慢,等等。

让我们添加两条日志记录: 一条在查询执行开始之前,一条在查询执行完成后立即记录。

//! src/routes/subscriptions.rs
// [...]
pub async fn subscribe(
    form: web::Form<FormData>,
    pool: web::Data<PgPool>,
) -> HttpResponse {
    log::info!("Saving new subscriber details in the database");
    match sqlx::query!(
        r#"
        INSERT INTO subscriptions (id, email, name, subscribed_at)
        VALUES ($1, $2, $3, $4)
        "#,
        Uuid::new_v4(),
        form.email,
        form.name,
        Utc::now()
    )
    .execute(pool.get_ref())
    .await
    {
        Ok(_) => {
            log::info!("New subscriber details have been saved");
            HttpResponse::Ok().finish()
        },
        Err(e) => {
            log::error!("Failed to execute query: {e:?}");
            HttpResponse::InternalServerError().finish()
        }
    }
}

目前,我们只会在查询成功时发出日志记录。为了捕获失败,我们需要将 println 语句转换为错误级别的日志:

log::error!("Failed to execute query: {e:?}");

好多了——我们现在已经基本覆盖了那个查询。

请注意一个小而关键的细节:我们使用了 std::fmt::Debug 格式的 {:?} 来捕获查询错误。

操作员是日志的主要受众——我们应该尽可能多地提取有关任何故障的信息,以便于故障排除。Debug 提供了原始视图,而 std::fmt::Display ({}) 会返回更美观的错误消息,更适合直接显示给我们的最终用户。

向用户一样思考

我们还应该捕捉什么?

之前我们说过

我们很乐意选择一个具有足够可观察性的应用程序,以便我们能够提供我们向用户承诺的服务水平。

这在实践中意味着什么?

我们需要改变我们的参考系统。

暂时忘记我们是这款软件的作者。

设身处地为你的一位用户着想,一个访问你网站、对你发布的内容感兴趣并想订阅你的新闻通讯的人。

对他们来说,失败意味着什么?

故事可能是这样的:

嘿! 我尝试用我的主邮箱地址 thomas_mann@hotmail.com 订阅你的新闻通讯,但网站出现了一个奇怪的错误。 你能帮我看看发生了什么吗? 祝好, 汤姆 附言:继续加油,你的博客太棒了!

Tom 访问了我们的网站,点击“提交”按钮时收到了“一个奇怪的错误”。

如果我们能根据他提供的信息(例如他输入的电子邮件地址)对问题进行分类,那么我们的申请就足够容易被观察到了。

我们能做到吗?

首先,让我们确认一下这个问题:Tom 是否注册为订阅者?

我们可以连接到数据库,并运行一个快速查询,再次确认我们的订阅者表中没有包含 thomas_mann@hotmail.com 邮箱地址的记录。

问题已确认。现在怎么办?

我们的日志中没有包含订阅者的邮箱地址,所以我们无法搜索。这完全是死路一条。 我们可以要求 Tom 提供更多信息:我们所有的日志记录都有时间戳,也许如果他记得自己尝试订阅的时间,我们就能找到一些线索?

这清楚地表明我们当前的日志质量不够好。

让我们改进它们:

//! src/routes/subscriptions.rs
// [...]
pub async fn subscribe(form: web::Form<FormData>, pool: web::Data<PgPool>) -> HttpResponse {
    log::info!(
        "Adding '{}' '{}' as a new subscriber.",
        form.email,
        form.name,
    );
    log::info!("Saving new subscriber details in the database");
    match sqlx::query!(/* [...] */)
    .execute(pool.get_ref())
    .await
    {
        Ok(_) => {
            log::info!("New subscriber details have been saved");
            HttpResponse::Ok().finish()
        }
        Err(e) => {
            println!("Failed to execute query: {e}");
            HttpResponse::InternalServerError().finish()
        }
    }
}

好多了——我们现在有一条日志行,可以同时捕获姓名和电子邮件。

这足以解决 Tom 的问题吗?

我们应该记录姓名和电子邮件吗?如果您在欧洲运营,这些信息通常属于个人身份信息 (PII),其处理必须遵守《通用数据保护条例》(GDPR) 中规定的原则和规则。我们应该严格控制谁可以访问这些信息、我们计划存储这些信息的时间、如果用户要求被遗忘,应该如何删除这些信息等等。一般来说,有很多类型的信息对于调试目的有用,但不能随意记录(例如密码)——您要么不得不放弃它们,要么依靠混淆技术(例如标记化/假名化)来在安全性、隐私性和实用性之间取得平衡。

日志必须易于关联

为了让示例简洁,我将在后续的终端输出中省略 sqlx 发出的日志。sqlx 的日志默认使用 INFO 级别——我们将在第 5 章中将其调低为 TRACE 级别。

如果我们的 Web 服务器只有一个副本在运行,并且该副本每次只能处理一个请求,那么我们可能会想象终端中显示的日志大致如下:

# First request
[.. INFO zero2prod] Adding 'thomas_mann@hotmail.com' 'Tom' as a new subscriber
[.. INFO zero2prod] Saving new subscriber details in the database
[.. INFO zero2prod] New subscriber details have been saved
[.. INFO actix_web] .. "POST /subscriptions HTTP/1.1" 200 ..
# Second request
[.. INFO zero2prod] Adding 's_erikson@malazan.io' 'Steven' as a new subscriber
[.. ERROR zero2prod] Failed to execute query: connection error with the database
[.. ERROR actix_web] .. "POST /subscriptions HTTP/1.1" 500 ..

您可以清楚地看到单个请求从哪里开始,在我们尝试执行该请求时发生了什么,我们返回了什么响应,下一个请求从哪里开始等等。

这很容易理解。

但当您同时处理多个请求时,情况并非如此:

[.. INFO zero2prod] Receiving request for POST /subscriptions
[.. INFO zero2prod] Receiving request for POST /subscriptions
[.. INFO zero2prod] Adding 'thomas_mann@hotmail.com' 'Tom' as a new subscriber
[.. INFO zero2prod] Adding 's_erikson@malazan.io' 'Steven' as a new subscriber
[.. INFO zero2prod] Saving new subscriber details in the database
[.. ERROR zero2prod] Failed to execute query: connection error with the database
[.. ERROR actix_web] .. "POST /subscriptions HTTP/1.1" 500 ..
[.. INFO zero2prod] Saving new subscriber details in the database
[.. INFO zero2prod] New subscriber details have been saved
[.. INFO actix_web] .. "POST /subscriptions HTTP/1.1" 200 ..

但是,我们哪些细节没有保存呢? thomas_mann@hotmail.com 还是 s_erikson@malazan.io?

从日志中无法判断。

我们需要一种方法来关联与同一请求相关的所有日志。

这通常使用请求 ID(也称为关联 ID)来实现:当我们开始处理传入请求时,我们会生成一个随机标识符(例如 UUID),然后将其与所有与该特定请求执行相关的日志关联起来。

让我们在处理程序中添加一个:

//! src/routes/subscriptions.rs
// [...]
pub async fn subscribe(form: web::Form<FormData>, pool: web::Data<PgPool>) -> HttpResponse {
    let request_id = Uuid::new_v4();
    log::info!(
        "request_id {} - Adding '{}' '{}' as a new subscriber.",
        request_id,
        form.email,
        form.name,
    );
    log::info!("request_id {request_id} - Saving new subscriber details in the database");
    match sqlx::query!(/* [...] */)
    .execute(pool.get_ref())
    .await
    {
        Ok(_) => {
            log::info!("request_id {request_id} - New subscriber details have been saved");
            HttpResponse::Ok().finish()
        }
        Err(e) => {
            println!("request_id {request_id} - Failed to execute query: {e}");
            HttpResponse::InternalServerError().finish()
        }
    }
}

传入请求的日志现在看起来像这样:

curl -i -X POST -d 'email=thomas_mann@hotmail.com&name=Tom' \
    http://127.0.0.1:8000/subscriptions
[.. INFO zero2prod] request_id 9ebde7e9-1efe-40b9-ab65-86ab422e6b87 - Adding
'thomas_mann@hotmail.com' 'Tom' as a new subscriber.
[.. INFO zero2prod] request_id 9ebde7e9-1efe-40b9-ab65-86ab422e6b87 - Saving
new subscriber details in the database
[.. INFO zero2prod] request_id 9ebde7e9-1efe-40b9-ab65-86ab422e6b87 - New
subscriber details have been saved
[.. INFO actix_web] .. "POST /subscriptions HTTP/1.1" 200 ..

现在,我们可以在日志中搜索 thomas_mann@hotmail.com,找到第一条记录,获取 request_id,然后拉取与该请求关联的所有其他日志记录。

几乎所有日志的 request_id 都是在我们的订阅处理程序中创建的,因此 actix_webLogger 中间件完全不知道它的存在。

这意味着,当用户尝试订阅我们的新闻通讯时,我们无法知道应用程序返回的状态码是什么。

我们该怎么办?

我们可以硬着头皮移除 actix_web 的 Logger,编写一个中间件为每个传入请求生成一个随机的请求标识符,然后编写我们自己的日志中间件,使其能够识别该标识符并将其包含在所有日志行中。

这样可以吗? 可以。

我们应该这样做吗? 可能不应该。

结构化日志

为了确保所有日志记录都包含 request_id, 我们必须:

  • 重写请求处理管道中的所有上游组件 (例如 actix-webLogger)
  • 更改我们从订阅处理程序调用的所有下游函数的签名;如果它们要发出日志语句,则需要包含 request_id, 因此需要将其作为参数传递下去。

那么我们导入到项目中的 crate 发出的日志记录呢?我们也应该重写这些 crate 吗?

显然,这种方法无法扩展

让我们退一步思考:我们的代码是什么样的?

我们有一个总体任务(HTTP 请求),它被分解为一系列子任务(例如,解析输入、进行查询等),这些子任务又可以递归地分解为更小的子例程。

每个工作单元都有一个持续时间(即开始和结束)。

每个工作单元都有一个与其关联的上下文(例如,新订阅者的姓名和电子邮件地址、request_id),这些上下文自然会被其所有子工作单元共享。

毫无疑问,我们正面临困境:日志语句是在特定时间点发生的孤立事件,而我们却固执地试图用它来表示树状处理流水线。 日志是一种错误的抽象。

那么,我们应该使用什么呢?

tracing Crate

tracing crate 可以帮助我们

tracing 扩展了日志式诊断,允许库和应用程序记录结构化事件,并附加有关时间性和因果关系的信息——与日志消息不同,跟踪中的跨度具有开始和结束时间,可以通过执行流进入和退出,并且可以存在于类似跨度的嵌套树中。

这真是天籁之音。

实际效果如何?

从 log 迁移到 tracing

只有一种方法可以找到答案——让我们将订阅处理程序转换为使用 tracing 而不是 log 进行检测。

让我们将 tracing 添加到依赖项中:

cargo add tracing --features=log

迁移的第一步非常简单:搜索函数主体中所有出现的 log:: 字符串,并将其替换为 tracing。

pub async fn subscribe(form: web::Form<FormData>, pool: web::Data<PgPool>) -> HttpResponse {
    let request_id = Uuid::new_v4();
    tracing::info!(
        "request_id {} - Adding '{}' '{}' as a new subscriber.",
        request_id,
        form.email,
        form.name,
    );
    tracing::info!("request_id {request_id} - Saving new subscriber details in the database");
    match sqlx::query!(/* [...] */)
    .execute(pool.get_ref())
    .await
    {
        Ok(_) => {
            tracing::info!("request_id {request_id} - New subscriber details have been saved");
            HttpResponse::Ok().finish()
        }
        Err(e) => {
            println!("request_id {request_id} - Failed to execute query: {e}");
            HttpResponse::InternalServerError().finish()
        }
    }
}

这样就好了。

如果您运行该应用程序并发出 POST /subscriptions 请求,

您将在控制台中看到完全相同的日志。完全相同。

很酷,不是吗?

这得益于我们在 Cargo.toml 中启用的 tracing log 功能标志。它确保每次使用 tracing 的宏创建事件或跨度时,都会发出相应的日志事件,

以便日志的记录器(在本例中为 env_logger)能够捕获它。

tracing 的 Span

现在,我们可以开始利用跟踪的 Span 来更好地捕获程序的结构。

我们需要创建一个代表整个 HTTP 请求的 span:

//! src/routes/subscriptions.rs
// [...]
pub async fn subscribe(form: web::Form<FormData>, pool: web::Data<PgPool>) -> HttpResponse {
    let request_id = Uuid::new_v4();
    // Spans, like logs, have an associated level
    // `into_span` creates a span at the into-level
    let request_span = tracing::info_span!(
        "Adding a new subscriber.",
        %request_id,
        subscriber_email = %form.email,
        subscriber_name = %form.name,
    );

    // Using `enter` in an async function is a recipe for disaster!
    // Bear with me for now, but don't do this at home.
    // See the floowing secion on `Instrumenting Futures`
    let _request_span_guard = request_span.enter();

    // [...]
    // `_request_span_guard` is dropped at the end of `subscribe`
    // That's when we "exit" the span
}

这里有很多事情要做——让我们分解一下。

我们使用 info_span! 宏创建一个新的 span,并将一些值附加到其上下文中: request_idform.emailform.name

我们不再使用字符串插值:跟踪允许我们将结构化信息关联到 span,作为键值对的集合32。我们可以显式命名它们(例如,将 form.email 命名为subscriber_email), 也可以隐式使用变量名作为键(例如,单独的 request_id 等同于 request_id = request_id)。

请注意,我们在所有 span 前面都添加了 % 符号: 我们告诉 tracing 使用它们的 Display 实现进行日志记录。您可以在它们的文档中找到有关其他可用选项的更多详细信息。

info_span 返回新创建的 span,但我们必须使用 .enter() 方法显式进入其中才能激活它。

.enter() 返回 Entered 的一个实例,它是一个守卫:只要守卫变量未被丢弃,所有下游 span 和日志事件都将被注册为已进入 span 的子级。这是一种典型的 Rust 模式,通常被称为资源获取即初始化 (RAII):编译器会跟踪所有变量的生命周期,当它们超出作用域时,会插入对其析构函数的调用,Drop::drop

Drop trait 的默认实现只负责释放该变量所拥有的资源。不过,我们可以指定一个自定义的 Drop 实现,以便在 drop 时执行其他清理操作——例如,当 Entered 守卫被 drop 时退出 span:

//! `tracing`'s source code
impl<'a> Drop for Entered<'a> {
    #[inline]
    fn drop(&mut self) {
        // Dropping the guard exits the span.
        //
        // Running this behaviour on drop rather than with an explicit function
        // call means that spans may still be exited when unwinding.
        if let Some(inner) = self.span.inner.as_ref() {
            inner.subscriber.exit(&inner.id);
        }
    }
}
if_log_enabled! {{
    if let Some(ref meta) = self.span.meta {
        self.span.log(
            ACTIVITY_LOG_TARGET,
            log::Level::Trace,
            format_args!("<- {}", meta.name())
        );
    }
}}

检查依赖项的源代码通常可以发现一些有用信息——我们刚刚发现,如果启用了日志功能标志,当 span 退出时,跟踪将会发出跟踪级别的日志。

让我们立即尝试一下:

RUST_LOG=trace cargo run
[.. INFO zero2prod] Adding a new subscriber.; request_id=f349b0fe..
subscriber_email=ursulale_guin@gmail.com subscriber_name=le guin
[.. TRACE zero2prod] -> Adding a new subscriber.
[.. INFO zero2prod] request_id f349b0fe.. - Saving new subscriber details
in the database
[.. INFO zero2prod] request_id f349b0fe.. - New subscriber details have
been saved
[.. TRACE zero2prod] <- Adding a new subscriber.
[.. TRACE zero2prod] -- Adding a new subscriber.
[.. INFO actix_web] .. "POST /subscriptions HTTP/1.1" 200 ..

注意,我们在 span 上下文中捕获的所有信息是如何在发出的日志行中报告的。

我们可以使用发出的日志密切跟踪 span 的生命周期:

  • 创建 span 时记录添加新订阅者的操作;
  • 进入 span (->);
  • 执行 INSERT 查询;
  • 退出 span (<-);
  • 最终关闭 span (--)。

等等,退出 span 和关闭 span 有什么区别?

很高兴你问这个问题!

你可以多次进入(和退出)一个 span。而关闭 span 是最终操作: 它发生在 span 本身被丢弃时。

当你有一个可以暂停然后恢复的工作单元时,这非常方便——例如一个异步任务!

检测 Futures

让我们以数据库查询为例。

执行器可能需要多次轮询其 Future 才能使其完成——当该 Future 处于空闲状态时,我们将处理其他 Future。 这显然会引发问题:我们如何确保不混淆它们各自的跨度?

最好的方法是紧密模拟 Future 的生命周期:每次执行器轮询 Future 时,我们都应该进入与其关联的跨度, 并在其每次暂停时退出。

这就是 Instrument 的用武之地。它是 Future 的一个扩展特性。Instrument::instrument 正是我们想要的:每次轮询 self(Future)时,进入我们作为参数传递的跨度;并在 Future 每次暂停时退出跨度。

让我们在查询中尝试一下:

//! src/routes/subscriptions.rs
use tracing::Instrument;
// [...]

pub async fn subscribe(form: web::Form<FormData>, pool: web::Data<PgPool>) -> HttpResponse {
    let request_id = Uuid::new_v4();
    let request_span = tracing::info_span!(
        "Adding a new subscriber.",
        %request_id,
        subscriber_email = %form.email,
        subscriber_name = %form.name,
    );
    let _request_span_guard = request_span.enter();

    // We do not call `.enter` on query_span!
    // `.instrument` takes care of it at the right moments
    // in the query future lifetime
    let query_span = tracing::info_span!("Saving new subscriber details in the database");

    match sqlx::query!(/* [...] */)
    .execute(pool.get_ref())
    .instrument(query_span)
    .await
    {
        Ok(_) => {
            tracing::info!("request_id {request_id} - New subscriber details have been saved");
            HttpResponse::Ok().finish()
        }
        Err(e) => {
            println!("request_id {request_id} - Failed to execute query: {e}");
            HttpResponse::InternalServerError().finish()
        }
    }
}

如果我们使用 RUST_LOG=trace 再次启动应用程序并尝试 POST /subscriptions 请求,我们将看到类似以下的日志:

[.. INFO zero2prod] Adding a new subscriber.; request_id=f349b0fe..
subscriber_email=ursulale_guin@gmail.com subscriber_name=le guin
[.. TRACE zero2prod] -> Adding a new subscriber.
[.. INFO zero2prod] Saving new subscriber details in the database
[.. TRACE zero2prod] -> Saving new subscriber details in the database
[.. TRACE zero2prod] <- Saving new subscriber details in the database
[.. TRACE zero2prod] -> Saving new subscriber details in the database
[.. TRACE zero2prod] <- Saving new subscriber details in the database
[.. TRACE zero2prod] -> Saving new subscriber details in the database
[.. TRACE zero2prod] <- Saving new subscriber details in the database
[.. TRACE zero2prod] -> Saving new subscriber details in the database
[.. TRACE zero2prod] -> Saving new subscriber details in the database
[.. TRACE zero2prod] <- Saving new subscriber details in the database
[.. TRACE zero2prod] -- Saving new subscriber details in the database
[.. TRACE zero2prod] <- Adding a new subscriber.
[.. TRACE zero2prod] -- Adding a new subscriber.
[.. INFO actix_web] .. "POST /subscriptions HTTP/1.1" 200 ..

我们可以清楚地看到查询 Future 在完成之前被执行器轮询了多少次。

太酷了!?

tracing 的 Subscriber

我们着手从日志迁移到跟踪,是因为我们需要一个更好的抽象来有效地检测我们的代码。我们特别希望将 request_id 附加到与同一传入 HTTP 请求相关的所有日志中。

虽然我保证跟踪会解决我们的问题,但看看那些日志:request_id 只打印在第一个日志语句中,我们把它明确地附加到 span 上下文中。

为什么呢?

嗯,我们还没有完成迁移

虽然我们已经将所有检测代码从 log 迁移到了 tracing,但我们仍然使用 env_logger 来处理所有事情!

//! src/main.rs
// [...]

#[tokio::main]
async fn main() -> std::io::Result<()> {
    env_logger::Builder::from_env(Env::default().default_filter_or("info")).init();

    // [...]
}

env_logger 的日志记录器实现了 log 的 Log 特性——它对 tracing 的 Span 所暴露的丰富结构一无所知! tracing 与 log 的兼容性非常出色,现在是时候用 tracing 原生解决方案替换 env_logger 了。

tracing crate 遵循 log 使用的相同外观模式——您可以自由地使用它的宏来 检测您的代码,但应用程序负责明确如何处理该 Span 遥测数据。

Subscriber 是 log 的 Log 的 tracing 对应物:Subscriber 特性的实现 暴露了各种方法来管理 Span 生命周期的每个阶段——创建、进入/退出、闭包等等。

//! `tracing`'s source code
pub trait Subscriber: 'static {
    fn new_span(&self, span: &span::Attributes<'_>) -> span::Id;
    fn event(&self, event: &Event<'_>);
    fn enter(&self, span: &span::Id);
    fn exit(&self, span: &span::Id);
    fn clone_span(&self, id: &span::Id) -> span::Id;
    // [...]
}

跟踪文档的质量令人叹为观止——我强烈建议您亲自查看 Subscriber 的文档,以正确理解每个方法的作用。

tracing-subscriber

tracing 不提供任何开箱即用的 subscriber。

我们需要研究 tracing-subscriber(这是 tracing 项目内部维护的另一个 crate), 以便找到一些基本的订阅器来启动它。让我们将它添加到我们的依赖项中:

cargo add tracing-subscriber --features=registry,env-filter

tracing-subscriber 的功能远不止提供一些便捷的订阅器。

它引入了另一个关键特性:Layer

Layer 使得构建跨度数据的处理管道成为可能:我们不必提供一个包罗万象的订阅器来完成我们想要的一切;相反,我们可以组合多个较小的层来获得所需的处理管道。

这大大减少了整个追踪生态系统中的重复工作:人们专注于通过大量创建新的层来添加新功能,而不是试图构建功能最齐全的订阅器。

分层方法的基石是 Registry

Registry 实现了 Subscriber 特性,并处理了所有棘手的问题:

Registry 实际上并不记录自身 trace: 相反,它收集并存储暴露给任何包裹它的层的 span 数据 [...]。Registry 负责存储 span 元数据,记录 span 之间的关系,并跟踪哪些 span 处于活动状态,哪些 span 已关闭。

下游层可以搭载 Registry 的功能并专注于其目的: 过滤需要处理的 span、格式化 span 数据、将 span 数据发送到远程系统等。

tracing-bunyan-formatter

我们希望创建一个与旧版 env_logger 功能相同的订阅器。

我们将通过组合三个层来实现此目标:

让我们将 tracing_bunyan_formatter 添加到我们的依赖项中:

cargo add tracing_bunyan_formatter

现在我们可以将所有内容整合到我们的 main 方法中:

//! src/main.rs
use tracing::subscriber::set_global_default;
use tracing_bunyan_formatter::{BunyanFormattingLayer, JsonStorageLayer};
use tracing_subscriber::{layer::SubscriberExt, EnvFilter, Registry};

// [...]

#[tokio::main]
async fn main() -> std::io::Result<()> {
    // We removed the `env_logger` line we had before!
    
    // We are falling back to printing all spans at into-level or above
    // if the RUST_LOG environment variable has not been set.
    let env_filter = EnvFilter::try_from_default_env()
        .unwrap_or_else(|_| EnvFilter::new("info"));
    let formatting_layer = BunyanFormattingLayer::new(
        "zero2prod".into(),
        std::io::stdout
    );

    // The `with` method is provided by `SubscriberExt`, an extension
    // trait for `Subscriber` exposed by `tracing_subscriber`
    let subscriber = Registry::default()
        .with(env_filter)
        .with(JsonStorageLayer)
        .with(formatting_layer);

    // `set_global_default` can be used by applications to specify
    // what subscriber should be used to process spans
    set_global_default(subscriber).expect("Failed to set subscriber");
    // [...]
}

如果你使用 cargo run 启动应用程序并发出请求,你会看到这些日志(为了更容易阅读,这里格式化后打印):

{
  "msg": "[ADDING A NEW SUBSCRIBER - START]",
  "subscriber_name": "le guin",
  "request_id": "30f8cce1-f587-4104-92f2-5448e1cc21f6",
  "subscriber_email": "ursula_le_guin@gmail.com"
  ...
}
{
  "msg": "[SAVING NEW SUBSCRIBER DETAILS IN THE DATABASE - START]",
  "subscriber_name": "le guin",
  "request_id": "30f8cce1-f587-4104-92f2-5448e1cc21f6",
  "subscriber_email": "ursula_le_guin@gmail.com"
  ...
}
{
  "msg": "[SAVING NEW SUBSCRIBER DETAILS IN THE DATABASE - END]",
  "elapsed_milliseconds": 4,
  "subscriber_name": "le guin",
  "request_id": "30f8cce1-f587-4104-92f2-5448e1cc21f6",
  "subscriber_email": "ursula_le_guin@gmail.com"
  ...
}
{
  "msg": "[ADDING A NEW SUBSCRIBER - END]",
  "elapsed_milliseconds": 5
  "subscriber_name": "le guin",
  "request_id": "30f8cce1-f587-4104-92f2-5448e1cc21f6",
  "subscriber_email": "ursula_le_guin@gmail.com",
  ...
}

我们成功了:所有附加到原始上下文的内容都已传播到其所有子跨度。

tracing-bunyan-formatter 还提供了开箱即用的持续时间:每次关闭跨度时,都会在控制台上打印一条 JSON 消息,并附加 elapsed_millisecond 属性。

JSON 格式在搜索方面非常友好:像 ElasticSearch 这样的引擎可以轻松提取所有这些记录,推断出模式并索引 request_id、name 和 email 字段。它释放了查询引擎的全部功能来筛选我们的日志!

这比我们以前的方法好得多:为了执行复杂的搜索,我们必须使用自定义的正则表达式,因此大大限制了我们可以轻松向日志提出的问题范围。

tracing-log

如果你仔细观察,就会发现我们遗漏了一些东西:我们的终端只显示由应用程序直接发出的日志。actix-web 的日志记录怎么了?

tracing 的日志功能标志确保每次发生跟踪事件时都会发出一条日志记录,从而允许 log 的记录器获取它们。 反之则不然:log 本身并不提供跟踪事件的发送功能,也没有提供启用此功能的功能标志。

如果需要,我们需要显式注册一个记录器实现,将日志重定向到我们的跟踪订阅者进行处理。

我们可以使用 tracing-log crate 提供的 LogTracer

cargo add tracing-log

让我们按需求修改 main.rs

//! src/main.rs
// [...]
#[tokio::main]
async fn main() -> std::io::Result<()> {
    // Redirect all `log`'s events to our subscriber
    LogTracer::init().expect("Failed to set logger");

    let env_filter = EnvFilter::try_from_default_env()
        .unwrap_or_else(|_| EnvFilter::new("info"));
    let formatting_layer = BunyanFormattingLayer::new(
        "zero2prod".into(),
        std::io::stdout
    );

    let subscriber = Registry::default()
        .with(env_filter)
        .with(JsonStorageLayer)
        .with(formatting_layer);

    set_global_default(subscriber).expect("Failed to set subscriber");
    // [...]
}

所有 actix-web 的日志应该再次在我们的控制台中输出。

删除没有用到的依赖

如果你快速浏览一下我们所有的文件,你会发现我们目前还没有在任何地方使用 log 或 env_logger。我们应该将它们从 Cargo.toml 文件中删除。

在大型项目中,重构后很难发现某个依赖项已不再使用。

幸运的是,工具再次派上用场——让我们安装 cargo-udeps (未使用的依赖项):

cargo install cargo-udeps

cargo-udeps 会扫描你的 Cargo.toml 文件,并检查 [dependencies] 下列出的所有 crate 是否已在项目中实际使用。查看 cargo-deps“战利品陈列柜”,了解一系列热门 Rust 项目,这些项目都曾使用 cargo-udeps 识别未使用的依赖项并缩短构建时间。

现在就在我们的项目上运行它吧!

# cargo-udeps requires the nightly compiler.
# We add +nightly to our cargo invocation
# to tell cargo explicitly what toolchain we want to use.
cargo +nightly udeps

输出应该是

zero2prod
  dependencies
    "env-logger"

不幸的是,它没有识别到 log。 让我们从 Cargo.toml 文件中删除这两项。

清理初始化

我们坚持不懈地努力改进应用程序的可观察性。

现在,让我们回顾一下我们编写的代码,看看是否有任何有意义的改进空间。

让我们从 main 函数开始:

#[tokio::main]
async fn main() -> std::io::Result<()> {
    LogTracer::init().expect("Failed to set logger");

    let env_filter = EnvFilter::try_from_default_env()
        .unwrap_or_else(|_| EnvFilter::new("info"));
    let formatting_layer = BunyanFormattingLayer::new(
        "zero2prod".into(),
        std::io::stdout
    );

    let subscriber = Registry::default()
        .with(env_filter)
        .with(JsonStorageLayer)
        .with(formatting_layer);

    set_global_default(subscriber).expect("Failed to set subscriber");

    let configuration = get_configuration().expect("Failed to read config");
    let connection_pool = PgPool::connect(&configuration.database.connection_string())
        .await
        .expect("Failed to connect to Postgres.");

    let address = format!("0.0.0.0:{}", configuration.application_port);
    let listener = TcpListener::bind(address)?;

    run(listener, connection_pool)?.await
}

现在主函数中有很多事情要做。

我们来分解一下:

//! src/main.rs
use std::net::TcpListener;

use sqlx::PgPool;
use tracing::{subscriber::set_global_default, Subscriber};
use tracing_bunyan_formatter::{BunyanFormattingLayer, JsonStorageLayer};
use tracing_log::LogTracer;
use tracing_subscriber::{layer::SubscriberExt, EnvFilter, Registry};
use zero2prod::{configuration::get_configuration, run};

/// Compose multiple layers into a `tracing`'s subscriber.
///
/// # Implementation Notes
///
/// We are using `impl Subscriber` as return type to avoid having to
/// spell out the actual type of the returned subscriber, which is
/// indeed quite complex.
/// We need to explicitly call out that the returned subscriber is
/// `Send` and `Sync` to make it possible to pass it to `init_subscriber`
/// later on.
pub fn get_subscriber(
    name: impl Into<String>,
    env_filter: impl Into<String>,
) -> impl Subscriber + Send + Sync {
    let env_filter = EnvFilter::try_from_default_env()
        .unwrap_or_else(|_| EnvFilter::new(env_filter.into()));
    let formatting_layer = BunyanFormattingLayer::new(
        name.into(),
        std::io::stdout
    );

    Registry::default()
        .with(env_filter)
        .with(JsonStorageLayer)
        .with(formatting_layer)
}

/// Register a subscriber as global default to process span data.
///
/// It should only be called once!
pub fn init_subscriber(subscriber: impl Subscriber + Send + Sync) {
    LogTracer::init().expect("Failed to set logger");

    set_global_default(subscriber).expect("Failed to set subscriber");
}

#[tokio::main]
async fn main() -> std::io::Result<()> {
    let subscriber = get_subscriber("zero2prod", "info");
    init_subscriber(subscriber);

    // [...]
}

我们现在可以将 get_subscriber 和 init_subscriber 移到 zero2prod 库中的一个模块中,叫做 telemetry

//! src/lib.rs
pub mod configuration;
pub mod routes;
pub mod startup;
pub mod telemetry;
//! src/telemetry.rs
use tracing::{subscriber::set_global_default, Subscriber};
use tracing_bunyan_formatter::{BunyanFormattingLayer, JsonStorageLayer};
use tracing_log::LogTracer;
use tracing_subscriber::{layer::SubscriberExt, EnvFilter, Registry};

pub fn get_subscriber(
    name: impl Into<String>,
    env_filter: impl Into<String>,
) -> impl Subscriber + Send + Sync {
    // [...]
}

pub fn init_subscriber(subscriber: impl Subscriber + Send + Sync) {
    // [...]
}

然后在 main.rs 中删除 get_subscriberinit_subscriber, 随后导入我们在 telemetry 模块的两个方法

//! src/main.rs
use zero2prod::telemetry::{get_subscriber, init_subscriber};

// [...]

太棒了!

集成测试的日志

我们不仅仅是为了美观/可读性而进行清理——我们还将这两个函数移到了 zero2prod 库中,以便我们的测试套件可以使用它们!

根据经验,我们在应用程序中使用的所有内容都应该反映在我们的集成测试中。

尤其是结构化日志记录,当集成测试失败时,它可以显著加快我们的调试速度: 我们可能不需要附加调试器,日志通常可以告诉我们哪里出了问题。这也是一个很好的基准:如果你无法从日志中调试,想象一下在生产环境中调试会有多么困难!

让我们修改我们的 spawn_app 辅助函数,让它负责初始化我们的 tracing 堆栈:

//! tests/health_check.rs
use zero2prod::telemetry::{get_subscriber, init_subscriber};

async fn spawn_app() -> TestApp {
  let subscriber = get_subscriber("test", "debug");
    init_subscriber(subscriber);
    // [...]
}

// [...]

如果您尝试运行 cargo test,您将会看到一次成功和一系列的测试失败:

test subscribe_returns_a_400_when_data_is_missing ... ok

failures:

---- subscribe_returns_a_200_for_valid_form_data stdout ----

thread 'subscribe_returns_a_200_for_valid_form_data' panicked at zero
2prod/src/telemetry.rs:27:23:
Failed to set logger: SetLoggerError(())
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

---- health_check_works stdout ----

thread 'health_check_works' panicked at zero2prod/src/telemetry.rs:27
:23:
Failed to set logger: SetLoggerError(())

failures:
    health_check_works
    subscribe_returns_a_200_for_valid_form_data

init_subscriber 应该只调用一次,但我们所有的测试都在调用它。

我们可以使用 once_cell 来解决这个问题

cargo add once_cell
use once_cell::sync::Lazy;

//! tests/health_check.rs
// Ensure that the `tracing` stack is only initialised once using `once_cell`
static TRACING: Lazy<()> = Lazy::new(|| {
    let subscriber = get_subscriber("test", "debug");
    init_subscriber(subscriber);
});


async fn spawn_app() -> TestApp {
    // The first time `initialize` is invoked the code in `TRACING` is executed.
    // All other invocations will instead skip execution.
    Lazy::force(&TRACING);
    
    // [...]
}

cargo test 现在通过了

然而,输出非常嘈杂:每个测试用例都会输出多行日志。

我们希望跟踪工具在每个测试中都能运行,但我们不想每次运行测试套件时都查看这些日志。

cargo test 解决了 println/print 语句的相同问题。默认情况下,它会吞掉所有打印到控制台的内容。您可以使用 cargo test ---nocapture 明确选择查看这些打印语句。

我们需要一个与跟踪工具等效的策略。

让我们为 get_subscriber 添加一个新参数,以便自定义日志应该写入到哪个接收器:

pub fn get_subscriber<Sink>(
    name: impl Into<String>,
    env_filter: impl Into<String>,
    sink: Sink
) -> impl Subscriber + Send + Sync 
    // This "weird" syntax is a higher-ranked trait bound (HRTB)
    // It basically means that Sink implements the `MakeWriter`
    // trait for all choices of the lifetime parameter `'a`
    // Check out https://doc.rust-lang.org/nomicon/hrtb.html
    // for more details.
    where Sink: for<'a> MakeWriter<'a> + Send + Sync + 'static
{
    let env_filter = EnvFilter::try_from_default_env()
        .unwrap_or_else(|_| EnvFilter::new(env_filter.into()));
    let formatting_layer = BunyanFormattingLayer::new(
        name.into(),
        sink
    );

    Registry::default()
        .with(env_filter)
        .with(JsonStorageLayer)
        .with(formatting_layer)
}

我们可以调整 main 方法使用 stdout:

//! src/main.rs
// [...]

#[tokio::main]
async fn main() {
    let subscriber = get_subscriber("zero2prod", "info", std::io::stdout);
    // [...]
}

在我们的测试套件中,我们将根据环境变量 TEST_LOG 动态选择接收器。

  • 如果设置了 TEST_LOG,我们将使用 std::io::stdout
  • 如果未设置 TEST_LOG,我们将使用 std::io::sink 将所有日志发送到 void

我们自己编写的 --nocapture flag 版本

//! tests/health_check.rs
// [...]
static TRACING: Lazy<()> = Lazy::new(|| {
    let default_filter_level = "info";
    let subscriber_name = "test";c

    // We cannot assign the output of `get_subscriber` to a variable based on the value of `TEST_LOG`
    // because the sink is part of the type returned by `get_subscriber`, therefore they are not the
    // same type. We could work around it, but this is the most straight-forward way of moving forward.
    if std::env::var("TEST_LOG").is_ok() {
        let subscriber = get_subscriber(subscriber_name, default_filter_level, std::io::stdout);
        init_subscriber(subscriber);
    } else {
        let subscriber = get_subscriber(subscriber_name, default_filter_level, std::io::sink);
        init_subscriber(subscriber);
    }
});

当你想查看某个测试用例的所有日志来调试它时,你可以运行

# We are using the `bunyan` CLI to prettify the outputted logs
# The original `bunyan` requires NPM, but you can install a Rust-port with
# `cargo install bunyan`
TEST_LOG=true cargo test health_check_works | bunyan

并仔细检查输出以了解发生了什么。

是不是很棒?

清理仪表代码 - tracing::instrument

我们重构了初始化逻辑。现在来看看我们的插桩代码。

是时候再次回归 subscribe 了。

//! src/routes/subscriptions.rs
// [...]
pub async fn subscribe(form: web::Form<FormData>, pool: web::Data<PgPool>) -> HttpResponse {
    let request_id = Uuid::new_v4();
    let request_span = tracing::info_span!(
        "Adding a new subscriber.",
        %request_id,
        subscriber_email = %form.email,
        subscriber_name = %form.name,
    );
    let _request_span_guard = request_span.enter();

    // We do not call `.enter` on query_span!
    // `.instrument` takes care of it at the right moments
    // in the query future lifetime
    let query_span = tracing::info_span!("Saving new subscriber details in the database");

    match sqlx::query!(
        r#"
        INSERT INTO subscriptions (id, email, name, subscribed_at)
        VALUES ($1, $2, $3, $4)
        "#,
        Uuid::new_v4(),
        form.email,
        form.name,
        Utc::now()
    )
    .execute(pool.get_ref())
    // First we attach the instrumentation, then we `.await` it
    .instrument(query_span)
    .await
    {
        Ok(_) => {
            tracing::info!("request_id {request_id} - New subscriber details have been saved");
            HttpResponse::Ok().finish()
        }
        Err(e) => {
            println!("request_id {request_id} - Failed to execute query: {e}");
            HttpResponse::InternalServerError().finish()
        }
    }
}

公平地说,日志记录给我们的订阅函数带来了一些噪音。

让我们看看能否稍微减少一下。

我们将从 request_span 开始: 我们希望订阅函数中的所有操作都在 request_span 的上下文中发生。 换句话说,我们希望将订阅函数包装在一个 span 中。

这种需求相当普遍: 将每个子任务提取到其各自的函数中是构建例程的常用方法,可以提高可读性并简化测试的编写;因此,我们经常会希望将 span 附加到函数声明中。

tracing 通过其 tracing::instrument 过程宏来满足这种特定的用例。让我们看看它的实际效果:

//! src/rotues/subscriptions.rs
// [...]
#[tracing::instrument(
    name = "Adding a new subscriber",
    skip(form, pool),
    fields(
        request_id = %Uuid::new_v4(),
        subscriber_email = %form.email,
        subscriber_name = %form.name,
    )
)]
pub async fn subscribe(form: web::Form<FormData>, pool: web::Data<PgPool>) -> HttpResponse {
    let query_span = tracing::info_span!("Saving new subscriber details in the database");

    match sqlx::query!(/* [...] */)
    .execute(pool.get_ref())
    .instrument(query_span)
    .await
    {
        Ok(_) => {
            HttpResponse::Ok().finish()
        }
        Err(e) => {
            tracing::error!("Failed to execute query: {e}");
            HttpResponse::InternalServerError().finish()
        }
    }
}

#[tracing::instrument] 在函数调用开始时创建一个 span,并自动将传递给函数的所有参数附加到 span 的上下文中——在我们的例子中是 formpool。函数参数通常不会显示在日志记录中(例如 pool),或者我们希望更明确地指定应该捕获哪些参数/如何捕获它们(例如,命名 form 的每个字段)——我们可以使用 skip 指令明确地告诉跟踪忽略它们。

name 可用于指定与函数 span 关联的消息 - 如果省略,则默认为函数名称。

我们还可以使用 fields 指令来丰富 span 的上下文。它利用了我们之前在 info_span! 宏中见过的相同语法。 结果相当不错:所有插桩关注点在视觉上都被执行关注点分隔开来,

前者由一个过程宏来处理,该宏“修饰”函数声明,而函数体则专注于实际的业务逻辑。

需要指出的是,如果将 tracing::instrument 应用于异步函数,它也会小心地使用 Instrument::instrument

让我们将查询提取到其自己的函数中,并使用 tracing::instrument 来摆脱 query_span 以及对 .instrument 方法的调用:

//! src/routes/subscription.rs
// [...]

#[tracing::instrument(
    name = "Adding a new subscriber",
    skip(form, pool),
    fields(
        request_id = %Uuid::new_v4(),
        subscriber_email = %form.email,
        subscriber_name = %form.name,
    )
)]
pub async fn subscribe(form: web::Form<FormData>, pool: web::Data<PgPool>) -> HttpResponse {
    match insert_subscriber(&pool, &form).await {
        Ok(_) => HttpResponse::Ok().finish(),
        Err(_) => HttpResponse::InternalServerError().finish(),
    }
}

#[tracing::instrument(
    name = "Saving new subscriber details in the database",
    skip(form, pool)
)]
pub async fn insert_subscriber(pool: &PgPool, form: &FormData) -> Result<(), sqlx::Error> {
    sqlx::query!(
        r#"
        INSERT INTO subscriptions (id, email, name, subscribed_at)
        VALUES ($1, $2, $3, $4)
        "#,
        Uuid::new_v4(),
        form.email,
        form.name,
        Utc::now()
    )
    .execute(pool)
    .await
    .inspect_err(|e| {
        tracing::error!("Failed to execute query: {:?}", e);
    })?;

    Ok(())
}

错误事件现在确实落在查询范围内,并且我们实现了更好的关注点分离:

  • insert_subscriber 负责数据库逻辑,它不感知周围的 Web 框架 - 也就是说,我们不会将 web::Formweb::Data 包装器作为输入类型传递
  • subscribe 通过调用所需的例程来协调要完成的工作,并根据 HTTP 协议的规则和约定将其结果转换为正确的响应

我必须承认我对 tracing::instrument 的无限热爱: 它显著降低了检测代码所需的工作量。

它会将你推向成功的深渊: 做正确的事是最容易的事。

保护你的秘密 - secrecy

#[tracing::instrument] 中其实有一个我不太喜欢的元素:它会自动将传递给函数的所有参数附加到 span 的上下文中——你必须选择不记录函数输入(通过 skip 选项),而不是选择加入。

你肯定不希望日志中包含机密信息(例如密码)或个人身份信息(例如最终用户的账单地址)。

选择退出是一个危险的默认设置——每次使用 #[tracing::instrument] 向函数添加新输入时,你都需要问自己: 记录这段输入安全吗? 我应该跳过它吗?

如果时间过长,别人就会忘记——你现在要处理一个安全事件。

你可以通过引入一个包装器类型来避免这种情况,该包装器类型明确标记哪些字段被视为敏感字段——secrecy::Secret

cargo add secrecy --features=serde

我们来看看它的定义:

/// Wrapper type for values that contains secrets, which attempts to limit
/// accidental exposure and ensure secrets are wiped from memory when dropped.
/// (e.g. passwords, cryptographic keys, access tokens or other credentials)
///
/// Access to the secret inner value occurs through the [...]
/// `expose_secret()` method [...]
pub struct Secret<S>
where
    S: Zeroize,
{
    /// Inner secret value
    inner_secret: S,
}

Zeroize trait 提供的内存擦除功能非常实用。

我们正在寻找的关键属性是 SecretBox 的屏蔽 Debug 实现: println!("{:?}", my_secret_string) 输出的是 Secret([REDACTED String]) 而不是实际的 secret 值。这正是我们防止敏感信息通过 #[tracing::instrument] 或其他日志语句意外泄露所需要的。

显式包装器类型还有一个额外的好处: 它可以作为新开发人员的文档,帮助他们熟悉代码库。它明确了在你的领域/根据相关法规,哪些内容被视为敏感信息。

现在我们唯一需要担心的秘密值是数据库密码。让我们写一下:

//! src/configuration.rs
use secrecy::SecretBox;
// [...]

#[derive(serde::Deserialize)]
pub struct DatabaseSettings {
    // [...]
    pub password: SecretBox<String>,
}

SecretBox 不会干扰反序列化 - SecretBox 通过委托给包装类型的反序列化逻辑来实现 serde::Deserialize(如果您像我们一样启用了 serde 功能标志)。

编译器不满意:

error[E0277]: `SecretBox<std::string::String>` doesn't implement `std::fmt::Display`
  --> src/configuration.rs:22:28
   |
21 |             "postgres://{}:{}@{}:{}/{}",
   |                            -- required by this formatting parameter
22 |             self.username, self.password, self.host, self.port, self.database_name
   |                            ^^^^^^^^^^^^^ `SecretBox<std::string::String>` cannot be formatted 
with the default formatter
   |
   = help: the trait `std::fmt::Display` is not implemented for `SecretBox<std::string::String>`
   = note: in format strings you may be able to use `{:?}` (or {:#?} for pretty-print) instead
   = note: this error originates in the macro `$crate::__export::format_args` which comes from the 
expansion of the macro `format` (in Nightly builds, run with -Z macro-backtrace for more info)

error[E0277]: `SecretBox<std::string::String>` doesn't implement `std::fmt::Display`
  --> src/configuration.rs:29:28
   |
28 |             "postgres://{}:{}@{}:{}",
   |                            -- required by this formatting parameter
29 |             self.username, self.password, self.host, self.port
   |                            ^^^^^^^^^^^^^ `SecretBox<std::string::String>` cannot be formatted 
with the default formatter
   |
   = help: the trait `std::fmt::Display` is not implemented for `SecretBox<std::string::String>`
   = note: in format strings you may be able to use `{:?}` (or {:#?} for pretty-print) instead
   = note: this error originates in the macro `$crate::__export::format_args` which comes from the 
expansion of the macro `format` (in Nightly builds, run with -Z macro-backtrace for more info)

For more information about this error, try `rustc --explain E0277`.
error: could not compile `zero2prod` (lib) due to 2 previous errors

这是一项功能,而非 bug —— secret::SecretBox 没有实现 Display 接口,因此我们需要 明确允许暴露已包装的 secret。编译器错误提示我们, 由于整个数据库连接字符串嵌入了数据库密码,因此也应该将其标记为 SecretBox:

//! src/configuration.rs
use secrecy::{ExposeSecret, SecretBox};
// [...]

impl DatabaseSettings {
    pub fn connection_string(&self) -> SecretBox<String> {
        SecretBox::new(Box::new(format!(
            "postgres://{}:{}@{}:{}/{}",
            self.username, self.password.expose_secret(), self.host, self.port, self.database_name
        )))
    }

    pub fn connection_string_without_db(&self) -> SecretBox<String> {
        SecretBox::new(Box::new(format!(
            "postgres://{}:{}@{}:{}",
            self.username, self.password.expose_secret(), self.host, self.port
        )))
    }
}
//! src/main.rs
use secrecy::ExposeSecret;
use sqlx::PgPool;
use zero2prod::{configuration::get_configuration, run, telemetry::{get_subscriber, init_subscriber}};

#[tokio::main]
async fn main() -> std::io::Result<()> {
    // [...]
    let connection_pool = PgPool::connect(&configuration.database.connection_string().expose_secret())
        .await
        .expect("Failed to connect to Postgres.");

    // [...]
}
//! tests/health_check.rs
use secrecy::ExposeSecret;
// [...]

pub async fn configure_database(config: &DatabaseSettings) -> PgPool {
    // Create database
    let mut connection = PgConnection::connect(&config.connection_string_without_db().expose_secret())
        .await
        .expect("Failed to connect to Postgres");

    connection
        .execute(format!(r#"CREATE DATABASE "{}";"#, config.database_name).as_str())
        .await
        .expect("Failed to create database.");

    // Migrate database
    let connection_pool = PgPool::connect(&config.connection_string().expose_secret())
        .await
        .expect("Failed to connect to Postgres");

    sqlx::migrate!("./migrations")
        .run(&connection_pool)
        .await
        .expect("Failed to migrate the database");

    connection_pool
}

暂时就是这样——以后,一旦引入敏感值,我们将确保将其包装到 SecretBox 中。

请求Id

我们还有最后一项工作要做:确保特定请求的所有日志,特别是包含返回状态码的记录,都添加了 request_id 属性。怎么做呢?

如果我们的目标是避免接触 actix_web::Logger,最简单的解决方案是添加另一个中间件,RequestIdMiddleware, 它负责:

  • 生成唯一的请求标识符
  • 创建一个新的 span,并将请求标识符作为上下文附加
  • 将其余的中间件链包装到新创建的 span 中

不过,这样会留下很多问题: actix_web::Logger 无法像其他日志那样以相同的结构化 JSON 格式让我们访问其丰富的信息(状态码、处理时间、调用者 IP 等)——我们必须从其消息字符串中解析出所有这些信息。

在这种情况下,我们最好引入一个支持跟踪的解决方案。

让我们将 tracing-actix-web 添加为依赖项之一

cargo add tracing-actix-web
//! src/startup.rs
use std::net::TcpListener;

use actix_web::{dev::Server, web, App, HttpServer};
use sqlx::PgPool;
use tracing_actix_web::TracingLogger;

use crate::routes::{health_check, subscribe};

pub fn run(
    listener: TcpListener,
    db_pool: PgPool,
) -> Result<Server, std::io::Error> {
    let db_pool = web::Data::new(db_pool);

    let server = HttpServer::new(move || {
        App::new()
            // Instead of `Logger::default`
            .wrap(TracingLogger::default())
            .route("/health_check", web::get().to(health_check))
            .route("/subscriptions", web::post().to(subscribe))
            .app_data(db_pool.clone())
    })
    .listen(listener)?
    .run();

    Ok(server)
}

如果您启动应用程序并发出请求,您应该会在所有日志中看到 request_id 以及 request_path 和其他一些有用的信息。

我们快完成了——还有一个未解决的问题需要解决。

让我们仔细看看 POST /subscriptions 请求发出的日志记录:

{
  "msg": "[REQUEST - START]",
  "request_id": "21fec996-ace2-4000-b301-263e319a04c5",
  ...
}
{
  "msg": "[ADDING A NEW SUBSCRIBER - START]",
  "request_id":"aaccef45-5a13-4693-9a69-5",
  ...
}

同一个请求却有两个不同的 request_id!

这个 bug 可以追溯到我们 subscribe 函数中的 #[tracing::instrument] 注解:

//! src/routes/subscriptions.rs
// [...]

#[tracing::instrument(
    name = "Adding a new subscriber",
    skip(form, pool),
    fields(
        request_id = %Uuid::new_v4(),
        subscriber_email = %form.email,
        subscriber_name = %form.name,
    )
)]
pub async fn subscribe(form: web::Form<FormData>, pool: web::Data<PgPool>) -> HttpResponse {
    // [...]
}

我们仍在函数级别生成一个 request_id,它会覆盖来自 TracingLogger 的 request_id。

让我们摆脱它来解决这个问题:

//! src/routes/subscriptions.rs
// [...]

#[tracing::instrument(
    name = "Adding a new subscriber",
    skip(form, pool),
    fields(
        subscriber_email = %form.email,
        subscriber_name = %form.name,
    )
)]
pub async fn subscribe(form: web::Form<FormData>, pool: web::Data<PgPool>) -> HttpResponse {
    // [...]
}

现在一切都很好 - 我们应用程序的每个端点都有一个一致的 request_id。

利用 tracing 生态系统

我们介绍了 tracing 的诸多功能——它显著提升了我们收集的遥测数据的质量,并提高了插桩代码的清晰度。

与此同时,我们几乎没有触及整个 tracing 生态系统在订阅层方面的丰富性。

以下列举一些现成的组件:

  • tracing-actix-web 与 OpenTelemetry 兼容。如果您插入 tracing-opentelemetry,则可以将 span 发送到与 OpenTelemetry 兼容的服务(例如 Jaeger 或 Honeycomb.io)进行进一步分析;
  • tracing-error 使用 SpanTrace 丰富了我们的错误类型,从而简化了故障排除。

毫不夸张地说,tracing 是 Rust 生态系统的基础 crate。虽然日志是最小公分母,但 tracing 现已成为整个诊断和插桩生态系统的现代支柱。

小结

我们从一个完全静默的 actix-web 应用程序开始,最终获得了高质量的遥测数据。现在是时候将这个新闻通讯 API 上线了!

在下一章中, 我们将为我们的 Rust 项目构建一个基本的部署流。

部署到生产环境

我们已经有了一个可工作的原型通讯API,是时候让我们把他部署到生产环境中了!

我们将会学习到如何去将我们的Rust程序打包成一个Docker镜像来部署到DigitalOcean的APP Platform

到最后的章节,我们会拥有一个自动部署(CD)的流程:每一个提交到main分支的代码都会自动触发最新版的生产环境应用给用户们使用。

我们必须谈谈部署

几乎所有人都喜欢强调:软件要尽可能频繁地部署到生产环境(我自己也不例外!)。

“要尽早获得客户反馈!”
“要快速发布,不断迭代产品!”

然而,真正告诉你如何去做的人却寥寥无几。

随便翻一本 Web 开发的书,或者某个框架的入门手册。

大多数书对“部署”一笔带过,顶多就是几句话。

有些书会安排一章来讲,但通常放在书的最后——那个往往没人翻到的章节。

只有极少数的书,会在尽可能靠前的部分,给予部署应有的篇幅。

为什么会这样?

因为部署至今仍然是一件棘手的事情。

市面上的服务商数不胜数,大多数并不好用;而所谓的“最佳实践”或者“前沿技术”也变化得极快。

因此,大多数作者都选择回避这个话题: 不仅需要花费大量篇幅,还可能在一两年后发现自己写下的内容已经完全过时。

但现实是,部署在软件工程师的日常工作中占据着非常重要的位置。

比如,当我们谈到数据库模式迁移、领域验证、API 演进时,如果不结合部署流程来讨论,就很难真正把问题讲清楚。

正因如此,在一本名为《从零到生产》的书里,我们绝对不能忽视“部署”这个主题。

选择我们的工具

本章的目的,是让你亲身体验一下:在每一次提交到主分支时,真正把应用部署出去意味着什么。

这也是为什么我们会在第五章就开始谈论部署:为了让你从现在开始,就能在后续章节不断练习这块“肌肉”,就像在一个真实的商业项目中会做的那样。

我们尤其关心的是:持续部署这一工程实践,会如何影响我们的设计决策与开发习惯。

当然,构建一个完美的持续部署流水线并不是本书的重点——那本身就足以写成一本书,甚至需要一整家公司来专注解决。

因此,我们必须务实,在“实用价值”(例如学习到业界广泛使用的工具)与“开发者体验”之间找到平衡。

即便我们现在花费大量精力去拼凑出一个所谓“最佳”的部署方案,等你真正落地到工作中,还是很可能会因为组织的具体限制而选择不同的工具和服务商。

真正重要的是其背后的理念,以及让你能够亲自实践“持续部署”这种方法论。

Docker 这一块

我们的本地开发环境和生产环境,其实承担着截然不同的角色。

在本地机器上,浏览器、IDE,甚至我们的音乐播放列表都能和平共处——它是一个多用途的工作站。 而生产环境则不同,它的关注点非常单一:运行我们的软件,并让用户能够访问。任何与这个目标无关的东西,轻则浪费资源,重则成为安全隐患。

这种差异,长期以来让“部署”变得非常棘手,也催生了一个经典的抱怨——“在我机器上能跑啊!”。

仅仅把源代码拷贝到生产服务器上是远远不够的。 我们的软件往往会依赖一些前提条件,比如:

操作系统的能力:一个原生的 Windows 应用无法在 Linux 上运行。

额外软件的可用性:比如特定版本的 Python 解释器。

系统配置:例如是否拥有 root 权限。

即便我们最初在本地和生产搭建了两套完全一致的环境,随着时间推移,版本的漂移和细微的不一致,依然会在某个深夜或周末“突然反噬”。

确保软件能够稳定运行的最简单方式,就是严格控制它所运行的环境。

这就是虚拟化技术的核心理念: 与其把代码“运”到生产环境,不如直接把包含应用的自给自足的环境一同打包送过去!

这样一来,双方都能获益:

对开发者来说,周五晚上少了许多意外。

对运维来说,得到的是一个稳定、一致的抽象层,可以在其上进一步构建。

如果这个环境本身还能用代码来定义,从而确保可复现性,那就更完美了。

好消息是:虚拟化早已不是什么新鲜事,它已经普及了将近十年。

像技术领域的多数情况一样,你会有多种选择:

  • 虚拟机

  • 容器(如 Docker)

  • 以及一些更轻量化的解决方案(如 Firecracker)

在本书中,我们会选择最主流、最普及的方案——Docker 容器。

Digital Ocean

AWS、Google Cloud、Azure、Digital Ocean、Clever Cloud、Heroku、Qovery…… 你可以选择的软件托管服务商名单还远不止这些。

如今,甚至有人专门靠“根据你的需求和场景推荐最合适的云服务”做起了生意——不过,这既不是我的工作(至少现在还不是),也不是这本书的目的。

我们要寻找的,是易于使用(良好的开发者体验、尽量少的无谓复杂性),同时又相对成熟可靠的解决方案。

在 2020 年 11 月,满足这两个条件的交集似乎就是 Digital Ocean,尤其是他们新推出的 App Platform 服务。

声明:Digital Ocean 没有付钱让我在这里推广他们的服务。

我们项目的Dockerfile

DigitalOcean 的 App Platform 原生支持容器化应用的部署。

我们的第一项任务,就是编写一个 Dockerfile,以便将应用打包并作为 Docker 容器来构建和运行。

Dockerfiles

Dockerfile是你项目环境的配方。

它们包含了这些东西:你的基础镜像(通常是一个OS包含了编程语言的工具链)和一个接一个的命令(COPY, RUN, 等)。这样就可以构建一个你需要的环境。

让我们看看一个最简单的可用的Rust项目的Dockerfile

# We use the latest Rust stable release as base image
FROM rust:1.89.0

# Let's switch our working directory to `app` (equivalent to `cd app`)
# The `app` folder will be created for us by Docker in case it does not
# exist already.
WORKDIR /app

# Install the required system dependencies for our linking configuration
RUN apt update && apt install lld clang -y

# Copy all files from our working environment to our Docker image
COPY . .

# Let's build our binary!
# We'll use the release profile to make it faaaast
RUN cargo build --release

# When `docker run` is executed, launch the binary!
ENTRYPOINT ["./target/release/zero2prod"]

将这个Dockerfile文件保存到项目的根目录:

zero2prod/
    .github/
    migrations/
    scripts/
    src/
    tests/
    .gitignore
    Cargo.lock
    Cargo.toml
    configuration.yaml
    Dockerfile

执行这些命令来生成镜像的过程叫做构建

使用Docker CLI来操作,我们需要输入以下指令。

docker build -t zero2prod --file Dockerfile .

命令最后的点是干什么的?

构建上下文

docker build的执行依赖两个要素:一个配方(即Dockerfile),以及一个构建上下文(build context)

你可以把正在构建的docker镜像想象成一个完全隔离的环境。

它和本地机器唯一的接触点就是诸如COPYADD这样的命令。

构建上下文则决定了:在执行COPY等命令时,镜像内部能够“看见”你主机上的那些文件。

举个例子,当我们使用.,就是告诉docker: “请把当前目录作为这次构建镜像时的上下文。”

因此,命令

COPY . /app

会将当前目录下的所有文件(包括源代码!)复制到镜像 /app 目录。

使用 . 作为构建上下文同时也意味着: Docker 不会允许你直接通过 COPY 去访问父目录,或任意指定主机上的其他路径。

根据需求,你也可以使用其他路径,甚至是一个 URL (!) 作为构建上下文。

Sqlx 离线模式

如果你很急,那么你可能已经运行了构建命令……但是你要意识到他是不能正常运行的。

# [...]
Step 4/5 : RUN cargo build --release
# [...]
error: error communicating with the server:
Cannot assign reguested address (os error 99)
    --> src/routes/subscriptions.rs:35:5
    |
35  | /     sqlx: :query!(
36  | |         r#"
37  | |     INSERT INTO subscriptions (id, email, name, subscribed_at)
38  | |     VALUES ($1,$2,$3,$4)
...   |
43  | |         Utc::now()
44  | |     )
    | |____-
    |
    = note: this error originates in a macro

发生了什么?

sqlx 在构建时访问了我们的数据库来确保所有的查询可以被成功的执行。

当我们在docker运行 cargo build 时,显然的,sqlx无法通过.env文件中确定的 DATABASE_URL 环境变量建立到数据库的连接。

如何解决?

我们可以通过--network来在构建镜像时允许镜像去和本地主机运行的数据库通信。这是我们在CI管道中遵循的策略,因为我们无论如何都需要数据库来运行集成测试。

不幸的是,由于在不同的操作系统(例如MACOS)上实施Docker网络会大大损害我们的构建可重现性,这对Docker构建有些麻烦。

更好的选择是sqlx的离线模式。

让我们在Cargo.toml加入sqlx的离线模式feature:

注: 在 0.8.6 版本的sqlx 中, offline mode 默认开启, 见 README

#! Cargo.toml
# [...]

[dependencies.sqlx]
version = "0.8.6"
default-features = false
features = [
    "tls-rustls",
    "macros",
    "postgres",
    "uuid",
    "chrono",
    "migrate",
    "runtime-tokio",
]

下一步依赖于 sqlx 的命令行界面 (CLI)。我们要找的命令是 sqlx prepare。我们来看看它的帮助信息:

sqlx prepare --help
Generate query metadata to support offline compile-time verification.

Saves metadata for all invocations of `query!` and related macros to a `.sqlx` directory in the current directory (or workspace root with `--workspace`), overwriting if needed.

During project compilation, the absence of the `DATABASE_URL` environment variable or the presence of `SQLX_OFFLINE` (with a value of `true` or `1`) will constrain the compile-time verification to
only read from the cached query metadata.

Usage: sqlx prepare [OPTIONS] [-- <ARGS>...]

Arguments:
  [ARGS]...
          Arguments to be passed to `cargo rustc ...`

Options:
      --check
          Run in 'check' mode. Exits with 0 if the query metadata is up-to-date. Exits with 1 if the query metadata needs updating

      --all
          Prepare query macros in dependencies that exist outside the current crate or workspace

      --workspace
          Generate a single workspace-level `.sqlx` folder.
          
          This option is intended for workspaces where multiple crates use SQLx. If there is only one, it is better to run `cargo sqlx prepare` without this option inside that crate.

      --no-dotenv
          Do not automatically load `.env` files

  -D, --database-url <DATABASE_URL>
          Location of the DB, by default will be read from the DATABASE_URL env var or `.env` files
          
          [env: DATABASE_URL=postgres://postgres:password@localhost:5432/newsletter]

      --connect-timeout <CONNECT_TIMEOUT>
          The maximum time, in seconds, to try connecting to the database server before returning an error
          
          [default: 10]

  -h, --help
          Print help (see a summary with '-h')

换句话说,prepare 执行的操作与调用 cargo build 时通常执行的操作相同, 但它会将这些查询的结果保存到元数据文件 (sqlx-data.json) 中,该文件稍后可以被 sqlx 自身检测到,并用于完全跳过查询并执行离线构建。

让我们调用它吧!

# It must be invoked as a cargo subcommand
# All options after `--` are passed to cargo itself
# We need to point it at our library since it contains
# all our SQL queries.
cargo sqlx prepare -- --lib
query data written to .sqlx in the current directory; please check this into version control

正如命令输出所示,我们确实会将文件提交到版本控制。.

让我们在 Dockerfile 中将 SQLX_OFFLINE 环境变量设置为 true, 以强制 sqlx 查看已保存的元数据,而不是尝试查询实时数据库:

FROM rust:1.89.0

WORKDIR /app
RUN apt update && apt install lld clang -y
COPY . .
ENV SQLX_OFFLINE=true
RUN cargo build --release
ENTRYPOINT ["./target/release/zero2prod"]

让我们试试构建我们的 docker image!

docker build --tag zero2prod --file Dockerfile .

这次应该不会出错了! 不过,我们有一个问题:如何确保 sqlx-data.json 不会不同步(例如,当数据库架构发生变化或添加新查询时)?

我们可以在 CI 流中使用 --check 标志来确保它保持最新状态——请参考本书 GitHub 仓库中更新的管道定义。

运行映像

在构建映像时,我们为其附加了一个标签 zero2prod:

docker build --tag zero2prod --file Dockerfile .

我们可以在其他命令中使用该标签来引用该图像。具体来说,运行以下命令:

docker run zero2prod

docker run 将触发我们在 ENTRYPOINT 语句中指定的命令的执行:

ENTRYPOINT ["./target/release/zero2prod"]

在我们的例子中,它将执行我们的二进制文件,从而启动我们的 API。

接下来,让我们启动我们的镜像!

你应该会立即看到一个错误:

Failed to connect to Postgres.: PoolTimedOut

这是来自我们 main 方法中的这一行:

//! src/main.rs
// [...]
#[tokio::main]
async fn main() -> std::io::Result<()> {
    // [...]
    let connection_pool = PgPool::connect(&configuration.database.connection_string().expose_secret())
        .await
        .expect("Failed to connect to Postgres.");

    // [...]
}

我们可以通过使用 connect_lazy 来放宽我们的要求——它只会在第一次使用池时尝试建立连接。

//! src/main.rs
// [...]

#[tokio::main]
async fn main() -> std::io::Result<()> {
    // [...]
    let connection_pool = PgPool::connect_lazy(&configuration.database.connection_string().expose_secret())
    .expect("Failed to connect to Postgres.");

    // [...]
}

现在我们可以重新构建 Docker 镜像并再次运行它: 你应该会立即看到几行日志!

让我们打开另一个终端,尝试向 health_check 端点发送请求:

curl http://127.0.0.1:8000/health_check
curl: (7) Failed to connect to 127.0.0.1 port 8000: Connection refused

不是很好...

Networking

默认情况下,Docker 镜像不会将其端口暴露给底层主机。我们需要使用 -p 标志显式地进行此操作。

让我们终止正在运行的镜像,然后使用以下命令重新启动它:

docker run -p 8000:8000 zero2prod

尝试访问健康检查端点将触发相同的错误消息。

我们需要深入研究 main.rs 文件来了解原因:

我们使用 127.0.0.1 作为主机地址 - 指示我们的应用程序仅接受来自同一台机器的连接。

然而,我们从主机向 /health_check 发出了一个 GET 请求,而我们的 Docker 镜像不会将其视为本地主机,因此触发了我们刚刚看到的“连接被拒绝”错误。

我们需要使用 0.0.0.0 作为主机地址,以指示我们的应用程序接受来自任何网络接口的连接,而不仅仅是本地接口。

不过,我们需要小心:使用 0.0.0.0 会显著增加我们应用程序的“受众”,并带来一些安全隐患

最好的方法是使地址的主机部分可配置 - 我们将继续使用 127.0.0.1 进行本地开发,并在 Docker 镜像中将其设置为 0.0.0.0

//! src/main.rs

#[tokio::main]
async fn main() -> std::io::Result<()> {
    // [...]
    let address = format!("0.0.0.0:{}", configuration.application_port);
    // [...]
}

分层配置

我们的设置结构目前如下所示:

//! src/configuration.rs
// [...]

#[derive(serde::Deserialize)]
pub struct Settings {
    pub database: DatabaseSettings,
    pub application_port: u16,
}

#[derive(serde::Deserialize)]
pub struct DatabaseSettings {
    pub username: String,
    pub password: SecretBox<String>,
    pub port: u16,
    pub host: String,
    pub database_name: String,
}

// [...]

让我们引入另一个结构体 ApplicationSettings, 将所有与应用程序地址相关的配置值组合在一起:

//! src/configuration.rs
#[derive(serde::Deserialize)]
pub struct Settings {
    pub database: DatabaseSettings,
    pub application: ApplicationSettings,
}

#[derive(serde::Deserialize)]
pub struct ApplicationSettings {
    pub port: u16,
    pub host: String,
}

// [...]

我们需要更新我们的 configuration.yml 文件以匹配新的结构:

#! configuration.yaml
application:
  port: 8000
  host: 127.0.0.1

database: ...

以及我们的 main.rs,我们将利用新的可配置 host 参数

//! src/main.rs
// [...]

#[tokio::main]
async fn main() {
    // [...]
    let address = format!("{}:{}", configuration.application.host, configuration.application.port);

    // [...]
}

现在可以从配置中读取主机名了,但是如何针对不同的环境使用不同的值呢?

我们需要将配置分层化。

我们来看看 get_configuration, 这个函数负责加载我们的 Settings 结构体:

//! src/configuration.rs
// [...]
pub fn get_configuration() -> Result<Settings, config::ConfigError> {
    let settings = config::Config::builder()
        .add_source(config::File::with_name("configuration"))
        .build()
        .unwrap();

    settings.try_deserialize()
}

我们正在从名为 configuration 的文件中读取数据,以填充“设置”的字段。configuration.yaml 中指定的值已无进一步调整的空间。

让我们采用更精细的方法。我们将拥有:

  • 一个基础配置文件,用于在本地和生产环境中共享的值(例如数据库名称);
  • 一组特定于环境的配置文件,用于指定需要根据每个环境进行自定义的字段的值(例如主机);
  • 一个环境变量 APP_ENVIRONMENT,用于确定运行环境(例如生产环境或本地环境)。

所有配置文件都将位于同一个顶级目录 configuration 中。 好消息是,我们正在使用的 crate config 开箱即用地支持上述所有功能?

让我们将它们组合起来:

//! src/configuration.rs
// [...]

pub fn get_configuration() -> Result<Settings, config::ConfigError> {
    let mut settings = config::Config::builder();

    let base_path = std::env::current_dir().expect("Failed to determine the current directory");
    let configuration_directory = base_path.join("configuration");

    // Read the "default" configuration file
    settings = settings
        .add_source(config::File::from(configuration_directory.join("base")).required(true));

    // Detect the running environment.
    // Default to `local` if unspecified.
    let environment: Environment = std::env::var("APP_ENVIRONMENT")
        .unwrap_or_else(|_| "local".into())
        .try_into()
        .expect("Failed to parse APP_ENVIRONMENT.");

    // Layer on the environment-specific values
    settings = settings.add_source(
        config::File::from(configuration_directory.join(environment.as_str())).required(true),
    );

    settings.build().unwrap().try_deserialize()
}

pub enum Environment {
    Local,
    Production,
}

impl Environment {
    pub fn as_str(&self) -> &'static str {
        match self {
            Environment::Local => "local",
            Environment::Production => "production",
        }
    }
}

impl TryFrom<String> for Environment {
    type Error = String;

    fn try_from(value: String) -> Result<Self, Self::Error> {
        match value.to_lowercase().as_str() {
            "local" => Ok(Self::Local),
            "production" => Ok(Self::Production),
            other => Err(format!(
                "{other} is not a supported environment. Use either `local` or `production`."
            )),
        }
    }
}

让我们重构配置文件以适应新的结构。

我们必须删除 configuration.yaml 文件,并创建一个新的配置目录,其中包含 base.yamllocal.yamlproduction.yaml 文件。

#! configuration/base.yaml
application:
  port: 8000

database:
  host: "127.0.0.1"
  port: 5432
  username: "postgres"
  password: "password"
  database_name: "newsletter"

#! configuration/local.yaml
application:
  host: 127.0.0.1
#! configuration/production.yaml
application:
  host: 0.0.0.0

现在,我们可以通过使用 ENV 指令设置 PP_ENVIRONMENT 环境变量来指示 Docker 镜像中的二进制文件使用生产配置:

FROM rust:1.89.0

WORKDIR /app

RUN apt update && apt install lld clang -y
COPY . .
ENV SQLX_OFFLINE=true
RUN cargo build --release
ENV APP_ENVIRONMENT=production
ENTRYPOINT ["./target/release/zero2prod"]

让我们重新构建映像并再次启动它:

docker build --tag zero2prod --file Dockerfile .
docker run -p 8000:8000 zero2prod

第一条日志行应该是这样的

{
  "name":"zero2prod",
  "msg":"Starting \"actix-web-service-0.0.0.0:8000\" service on 0.0.0.0:8000",
}

如果你的运行结果和这个差不多, 这是个好消息 - 配置文件正在按照我们期待的方式工作!

我们再试试请求 health check 端点

curl -v http://127.0.0.1:8080/health_check
*   Trying 127.0.0.1:8000...
* Connected to 127.0.0.1 (127.0.0.1) port 8000
* using HTTP/1.x
> GET /health_check HTTP/1.1
> Host: 127.0.0.1:8000
> User-Agent: curl/8.15.0
> Accept: */*
> 
* Request completely sent off
< HTTP/1.1 200 OK
< content-length: 0
< date: Sun, 24 Aug 2025 02:40:04 GMT
< 
* Connection #0 to host 127.0.0.1 left intact

太棒了, 这能用!

数据库连接

那么 POST /subscriptions 怎么样?

curl --request POST \
  --data 'name=le%20guin&email=ursula_le_guin%40gmail.com' \
  127.0.0.1:8000/subscriptions --verbose

经过长时间的等待, 服务端返回了 500!

我们看看应用程序日志 (很有用的, 难道不是吗?)

{
  "msg": "[SAVING NEW SUBSCRIBER DETAILS IN THE DATABASE - EVENT] \
Failed to execute query: PoolTimedOut",
}

这应该不会让你感到意外——我们将 connect 替换成了 connect_lazy,以避免立即与数据库打交道。

我们等了半分钟才看到返回 500 错误——这是因为 sqlx 中从连接池获取连接的默认超时时间为 30 秒。

让我们使用更短的超时时间,让失败速度更快一些:

//! src/main.rs
// [...]

#[tokio::main]
async fn main() -> std::io::Result<()> {
    // [...]
    let connection_pool = PgPoolOptions::new()
        .acquire_timeout(std::time::Duration::from_secs(2))
        .connect_lazy(&configuration.database.connection_string().expose_secret())
        .expect("Failed to connect to Postgres.");

    // [...]
}

使用 Docker 容器获取有效的本地设置有多种方法:

  • 使用 --network=host 运行应用程序容器,就像我们目前对 Postgres 容器所做的那样;
  • 使用 docker-compose;
  • 创建用户自定义网络。

有效的本地设置并不能让我们在部署到 Digital Ocean 时获得有效的数据库连接。因此,我们暂时先这样。

优化 Docker 映像

就我们的 Docker 镜像而言,它似乎按预期运行了——是时候部署它了!

嗯,还没有。

我们可以对 Dockerfile 进行两项优化,以简化后续工作:

  • 减小镜像大小,提高使用速度
  • 使用 Docker 层缓存,提高构建速度。

Docker 映像大小

我们不会在托管我们应用程序的机器上运行 docker build。它们将使用 docker pull 下载我们的 Docker 镜像,而无需经历从头构建的过程。

这非常方便:构建镜像可能需要很长时间(Rust 确实如此!),而我们只需支付一次费用。

要实际使用镜像,我们只需支付下载费用,该费用与镜像的大小直接相关。

我们的镜像有多大?

我们可以使用

docker images zero2prod
REPOSITORY   TAG       IMAGE ID       CREATED         SIZE
zero2prod    latest    01f54e7e0153   8 minutes ago   16.5GB

这是大还是小?

嗯,我们的最终镜像不能小于我们用作基础镜像的 rust。它有多大?

docker images rust:1.89.0

注: 译者这里懒得 docker pull 了, 反正这个映像很大就是了

好的,我们的最终镜像几乎是基础镜像的两倍大。 我们可以做得更好!

我们的第一步是通过排除构建镜像不需要的文件来减小 Docker 构建上下文的大小。

Docker 会在我们的项目中查找一个特定的文件 - .dockerignore 来确定哪些文件应该被忽略。

让我们在根目录中创建一个包含以下内容的文件:

#! .dockerignore
.env
target/
tests/
Dockerfile
scripts/
migrations/

所有符合 .dockerignore 中指定模式的文件都不会被 Docker 作为构建上下文的一部分发送到镜像,这意味着它们不在 COPY 指令的范围内。

如果我们能够忽略繁重的目录(例如 Rust 项目的目标文件夹),这将大大加快我们的构建速度(并减小最终镜像的大小)。

而下一个优化则利用了 Rust 的独特优势之一。

Rust 的二进制文件是静态链接的——我们不需要保留源代码或中间编译工件来运行二进制文件,它是完全独立的。

这与 Docker 的一项实用功能——多阶段构建完美兼容。我们可以将构建分为两个阶段:

  • builder 阶段,用于生成编译后的二进制文件
  • runtime 阶段,用于运行二进制文件

修改后的 Dockerfile 如下所示:

# Builder stage
FROM rust:1.89.0 AS builder

WORKDIR /app

RUN apt update && apt install lld clang -y
COPY . .
ENV SQLX_OFFLINE=true
RUN cargo build --release

# Runtime stage
FROM rust:1.89.0

WORKDIR /app

# Copy the compiled binary from the builder environment
# to your runtime env
COPY --from=builder /app/target/release/zero2prod zero2prod

# We need the configuration file at runtime!
COPY configuration configuration

ENV APP_ENVIRONMENT=production
ENTRYPOINT ["./target/release/zero2prod"]

runtime 是我们的最终镜像。

builder 阶段不会影响其大小——它是一个中间步骤,会在构建结束时被丢弃。最终工件中唯一存在的构建器阶段部分就是我们明确复制的内容——编译后的二进制文件!

使用上述 Dockerfile 生成的镜像大小是多少?

docker images zero2prod

只有1.3GB!

只比基础镜像大 20 MB,好太多了!

我们可以更进一步:在运行时阶段,我们不再使用 rust:1.89.0,而是改用 rust:1.589.0-slim,这是一个使用相同底层操作系统的较小镜像。

这比我们一开始的体积小了 4 倍——一点也不差!

我们可以通过减少整个 Rust 工具链和机器(例如 rustc、cargo 等)的重量来进一步缩小体积——运行我们的二进制文件时,这些都不再需要。

我们可以使用裸操作系统 (debian:bullseye-slim) 作为运行时阶段的基础镜像:

# [...]
# Runtime stage
FROM debian:bullseye-slim

WORKDIR /app

RUN apt-get update -y \
  && apt-get install -y
  --no-install-recommends openssl
  ca-certificates \
  # Clean up
  && apt-get autoremove -y \
  && apt-get clean -y \
  && rm -rf /var/lib/apt/lists/*

# Copy the compiled binary from the builder environment
# to your runtime env
COPY --from=builder /app/target/release/zero2prod zero2prod

# We need the configuration file at runtime!
COPY configuration configuration

ENV APP_ENVIRONMENT=production
ENTRYPOINT ["./target/release/zero2prod"]

不到 100 MB——比我们最初的尝试小了约 25 倍。

我们可以通过使用 rust:1.89.0-alpine 来进一步减小文件大小,但我们必须交叉编译到 linux-musl 目标——目前超出了我们的范围。如果您有兴趣生成微型 Docker 镜像,请查看 rust-musl-builder。

进一步减小二进制文件大小的另一种方法是从中剥离符号——您可以在不到 100 MB——比我们最初的尝试小了约 25 倍 42。 我们可以通过使用 rust:1.59.0-alpine 来进一步减小文件大小,但我们必须交叉编译到 linux-musl 目标——目前超出了我们的范围。如果您有兴趣生成微型 Docker 镜像,请查看 rust-musl-builder。 进一步减小二进制文件大小的另一种方法是从中剥离符号——您可以在此处找到 更多信息。找到更多信息。

为Rust Docker 构建缓存

Rust 在运行时表现出色,始终提供卓越的性能,但这也带来了一些代价:编译时间。在 Rust 年度调查中,Rust 项目面临的最大挑战或问题中,编译时间一直是热门话题。

优化构建(--release)尤其令人头疼——对于包含多个依赖项的中型项目,编译时间可能长达 15/20 分钟。这在我们这样的 Web 开发项目中相当常见,因为我们会从异步生态系统(tokio、actix-web、sqlx 等)中引入许多基础 crate。

不幸的是,我们在 Dockerfile 中使用 --release 选项来在生产环境中获得最佳性能。如何缓解这个问题? 我们可以利用 Docker 的另一个特性:层缓存。

Dockerfile 中的每个 RUN、COPY 和 ADD 指令都会创建一个层:执行指定命令后,先前状态(上一层)与当前状态之间的差异。

层会被缓存:如果操作的起始点(例如基础镜像)未发生变化,并且命令本身(例如 COPY 复制的文件的校验和)也未发生变化,Docker 不会执行任何计算,而是直接从本地缓存中获取结果副本。

Docker 层缓存速度很快,可以利用它来大幅提升 Docker 构建速度。

诀窍在于优化 Dockerfile 中的操作顺序:任何引用经常更改的文件(例如源代码)的操作都应尽可能晚地出现,从而最大限度地提高前一步保持不变的可能性,并允许 Docker 直接从缓存中获取结果。

开销最大的步骤通常是编译。

大多数编程语言都遵循相同的策略:首先复制某种类型的锁文件,构建依赖项,然后复制其余源代码,最后构建项目。 只要你的依赖关系树在两次构建之间没有发生变化,这就能保证大部分工作都被缓存。

例如,在一个 Python 项目中,你可能会有类似这样的代码:

FROM python:3
COPY requirements.txt
RUN pip install -r requirements.txt
COPY src/ /app
WORKDIR /app
ENTRYPOINT ["python", "app"]

遗憾的是, cargo 没有提供从其 Cargo.lock 文件开始构建项目依赖项的机制(例如 cargo build --only-deps)。

然而,我们可以依靠社区项目 cargo-chef 来扩展 cargo 的默认功能。

让我们按照 cargo-chef 的 README 中的建议修改 Dockerfile:

注: 不要在构建容器外运行 cargo chef, 防止损坏代码库。 (来自README)

FROM lukemathwalker/cargo-chef:latest-rust-1.89.0 AS chef

WORKDIR /app
RUN apt update && apt install lld clang -y

FROM chef AS planner
COPY . .

# Compute a lock-like file for our project
RUN cargo chef prepare --recipe-path recipe.json

FROM chef AS builder
COPY --from=planner /app/recipe.json recipe.json
# Build our project dependencies, not our application!
RUN cargo chef cook --release --recipe-path recipe.json

# Up to this point, if our dependency tree stays the same,
# all layers should be cached.
COPY . .

ENV SQLX_OFFLINE=true
RUN cargo build --release

# Runtime stage
FROM debian:bullseye-slim

WORKDIR /app

RUN apt-get update -y \
  && apt-get install -y --no-install-recommends openssl ca-certificates \
  # Clean up
  && apt-get autoremove -y \
  && apt-get clean -y \
  && rm -rf /var/lib/apt/lists/*

# Copy the compiled binary from the builder environment
# to your runtime env
COPY --from=builder /app/target/release/zero2prod zero2prod

# We need the configuration file at runtime!
COPY configuration configuration

ENV APP_ENVIRONMENT=production
ENTRYPOINT ["./target/release/zero2prod"]

我们使用三个阶段:第一阶段计算配方文件,第二阶段缓存依赖项,然后构建二进制文件,第三阶段是运行时环境。只要依赖项保持不变,recipe.json 文件就会保持不变,因此 cargo chef cook --release --recipe-path recipe.json 的结果会被缓存,从而大大加快构建速度。

我们利用了 Docker 层缓存与多阶段构建的交互方式:planner 阶段中的 COPY . . 语句会使 planner 容器的缓存失效,但只要 cargo chef prepare 返回的 recipe.json 的校验和保持不变, 它就不会使构建器容器的缓存失效。

您可以将每个阶段视为具有各自缓存的 Docker 镜像 - 它们仅在使用 COPY --from 语句时才会相互交互。 这将在下一节中为我们节省大量时间。

部署到 DigitalOcean Apps Platform

注: 本章翻译过程中没有核实内容, 因为译者本人不用这个 Digital Ocean (也没有兴趣为其付费), 他本人认为使用SaaS平台托管自己的服务是不好的. 不要完全跳过本章, 建议还是看一下

我们已经构建了一个(非常棒的)容器化应用程序版本。现在就部署它吧!

设置

您必须在 Digital Ocean 的网站上注册

注册账户后,请安装 doctl (Digital Ocean 的 CLI)——您可以在此处找到说明

在 Digital Ocean 的应用平台上托管并非免费——维护我们的应用及其相关数据库的正常运行大约需要每月 20 美元。

我建议您在每次会话结束时销毁该应用——这样可以将您的支出保持在 1 美元以下。我在撰写本章的过程中只花了 0.2 美元!

应用程序规范

Digital Ocean 的应用平台使用声明式配置文件来指定应用程序部署的样子——他们称之为 App Spec。 通过查看参考文档以及一些示例,我们可以拼凑出 App Spec 的初稿。 让我们将这个清单 spec.yaml 放在项目目录的根目录下。

#! spec.yaml
name: zero2prod
# See https://www.digitalocean.com/docs/app-platform/#regional-availability for the available options
# You can get region slugs from https://www.digitalocean.com/docs/platform/availability-matrix/
# `fra` stands for Frankfurt (Germany - EU)
region: fra
services:
  - name: zero2prod
    # Relative to the repository root
    dockerfile_path: Dockerfile
    source_dir: .
    github:
      branch: main
      deploy_on_push: true
      repo: LukeMathWalker/zero-to-production
    # Active probe used by DigitalOcean's to ensure our application is healthy
    health_check:
      # The path to our health check endpoint! It turned out to be useful in the end!
      http_path: /health_check
    # The port the application will be listening on for incoming requests
    # It should match what we specify in our configuration.yaml file!
    http_port: 8000
    # For production workloads we'd go for at least two!
    instance_count: 1
    # Let's keep the bill lean for now...
    instance_size_slug: basic-xxs
    # All incoming requests should be routed to our app
    routes:
      - path: /

请花点时间仔细检查所有指定的值,并了解它们的用途。

我们可以使用它们的命令行界面 (CLI) doctl 来首次创建应用程序:

doctl apps create --spec spec.yaml
Error: Unable to initialize DigitalOcean API client: access token is required.
(hint: run 'doctl auth init')

嗯,我们必须先进行身份验证。

我们按照他们的建议来吧:

doctl auth init
Please authenticate doctl for use with your DigitalOcean account.
You can generate a token in the control panel at
https://cloud.digitalocean.com/account/api/tokens

一旦您提供了令牌,我们可以再试一次:

doctl apps create --spec spec.yaml

好的,按照他们的指示关联你的 GitHub 帐户。

第三次就成功了,我们再试一次!

doctl apps create --spec spec.yaml

成功了!

您可以使用以下命令检查应用状态

doctl apps list

或者查看 DigitalOcean 的仪表盘

虽然应用已成功创建,但尚未运行!

查看他们仪表盘上的“部署”选项卡 - 它可能正在构建 Docker 镜像。

查看他们 bug 跟踪器上最近的一些问题,发现可能需要一段时间 - 不少人都报告了构建速度缓慢的问题。Digital Ocean 的支持工程师建议利用 Docker 层缓存来缓解这个问题 - 我们已经涵盖了所有基础内容!

如果您在 DigitalOcean 上构建 Docker 镜像时遇到内存不足错误,请查看此 GitHub issue

等待这些行显示在其仪表板构建日志中:

zero2prod | 00:00:20 => Uploaded the built image to the container registry
zero2prod | 00:00:20 => Build complete

部署成功!

您应该能够看到每十秒左右一次的健康检查日志,此时Digital Ocean 平台会 ping 我们的应用程序以确保其正常运行。

doctl apps list

你可以检索应用程序的公开 URI。类似于 https://zero2prod-aaaaa.ondigitalocean.app

现在尝试发送健康检查请求,它应该会返回 200 OK!

请注意,DigitalOcean 通过配置证书并将 HTTPS 流量重定向到我们在应用程序规范中指定的端口,帮我们设置了 HTTPS。这样就省了一件需要担心的事情。

POST /subscriptions 端点仍然失败,就像它在本地失败一样:我们的生产环境中没有支持我们应用程序的实时数据库。

让我们配置一个。

将此段添加到您的 spec.yaml 文件中:

#! spec.yaml
# [...]
databases:
  # PG = Postgres
  - engine: PG
    # Database name
    name: newsletter
    # Again, let's keep the bill lean
    num_nodes: 1
    size: db-s-dev-database
    # Postgres version - using the latest here
    version: "14"

然后更新您的应用规范:

# You can retrieve your app id using `doctl apps list`
doctl apps update YOUR-APP-ID --spec=spec.yaml

DigitalOcean 需要一些时间来配置 Postgres 实例。

与此同时,我们需要弄清楚如何在生产环境中将应用程序指向数据库。

如何使用环境变量注入 secret

连接字符串将包含我们不想提交到版本控制的值 - 例如,数据库根用户的用户名和密码。

我们最好的选择是使用环境变量在运行时将机密信息注入应用程序环境。例如,DigitalOcean 的应用程序可以引用 DATABASE_URL 环境变量(或其他一些更精细的视图)来在运行时获取数据库连接字符串。

我们需要(再次)升级 get_configuration 函数以满足我们的新需求。

//! src/configuration.rs
// [...]

pub fn get_configuration() -> Result<Settings, config::ConfigError> {
    let mut settings = config::Config::builder();

    let base_path = std::env::current_dir().expect("Failed to determine the current directory");
    let configuration_directory = base_path.join("configuration");

    // Read the "default" configuration file
    settings = settings
        .add_source(config::File::from(configuration_directory.join("base")).required(true));

    // Detect the running environment.
    // Default to `local` if unspecified.
    let environment: Environment = std::env::var("APP_ENVIRONMENT")
        .unwrap_or_else(|_| "local".into())
        .try_into()
        .expect("Failed to parse APP_ENVIRONMENT.");

    // Layer on the environment-specific values
    settings = settings.add_source(
        config::File::from(configuration_directory.join(environment.as_str())).required(true),
    );

    // Add in settings from environment variables (with a prefix of APP and '__' as separator)
    // E.g. `APP_APPLICATION__PORT=5001` would set `Settings.application.port`
    settings = settings.add_source(config::Environment::with_prefix("app").separator("__"));

    settings.build().unwrap().try_deserialize()
}

这使我们能够使用环境变量自定义 Settings 结构体中的任何值,从而覆盖配置文件中指定的值。

为什么这很方便? 它使得注入过于动态(即无法预先知道)或过于敏感而无法存储在版本控制中的值成为可能。

它还可以快速更改应用程序的行为: 如果我们想要调整其中一个值(例如数据库端口),则无需进行完全重建。

对于像 Rust 这样的语言来说,全新构建可能需要十分钟或更长时间,这可能会导致短暂的中断和对客户造成可见影响的严重服务质量下降。

在继续之前,我们先处理一个烦人的细节: 环境变量对于配置包来说,是字符串,如果使用 serde 的标准反序列化例程,它将无法获取整数。

幸运的是,我们可以指定一个自定义反序列化函数。

让我们添加一个新的依赖项,serde-aux(serde 辅助函数):

cargo add serde-aux

然后修改 ApplicationSettingsDatabaseSettings

//! src/configuration.rs
// [...]
use serde_aux::prelude::deserialize_number_from_string;

#[derive(serde::Deserialize)]
pub struct DatabaseSettings {
    #[serde(deserialize_with = "deserialize_number_from_string")]
    pub port: u16,
    // [...]
}

#[derive(serde::Deserialize)]
pub struct ApplicationSettings {
    #[serde(deserialize_with = "deserialize_number_from_string")]
    pub port: u16,
    // [...]
}

// [...]

连接到 Digital Ocean 的 Postgres 实例

让我们使用 DigitalOcean 仪表板(Compo- nents -> Database)查看数据库的连接字符串:

postgresql://newsletter:<PASSWORD>@<HOST>:<PORT>/newsletter?sslmode=require

我们当前的 DatabaseSettings 不支持 SSL 模式——这在本地开发中并不适用,但在生产环境中,为客户端/数据库通信提供传输级加密是非常必要的。

在尝试添加新功能之前, 让我们先重构 DatabaseSettings 来腾出空间。

当前版本如下所示:

#[derive(serde::Deserialize)]
pub struct DatabaseSettings {
    pub username: String,
    pub password: SecretBox<String>,
    #[serde(deserialize_with = "deserialize_number_from_string")]
    pub port: u16,
    pub host: String,
    pub database_name: String,
}

impl DatabaseSettings {
    pub fn connection_string(&self) -> SecretBox<String> {
        // [...]
    }

    pub fn connection_string_without_db(&self) -> SecretBox<String> {
        // [...]
    }
}

我们将修改它的两个方法, 使其返回 PgConnectOptions 而不是连接字符串:这将使管理所有这些参数变得更加容易。

//! src/configuration.rs
use sqlx::postgres::PgConnectOptions;

// [...]
impl DatabaseSettings {
    pub fn without_db(&self) -> PgConnectOptions {
        PgConnectOptions::new()
            .host(&self.host)
            .username(&self.username)
            .password(&self.password.expose_secret())
            .port(self.port)
    }

    pub fn with_db(&self) -> PgConnectOptions {
        self.without_db().database(&self.database_name)
    }
}

当然我们还得修改 src/main.rstests/health_check.rs

//! src/main.rs
// [...]

#[tokio::main]
async fn main() -> std::io::Result<()> {
    // [...]
    let connection_pool = PgPoolOptions::new()
        .acquire_timeout(std::time::Duration::from_secs(2))
        .connect_lazy_with(configuration.database.with_db());
   
    // [...]
}
//! tests/health_check.rs
// [...]
pub async fn configure_database(config: &DatabaseSettings) -> PgPool {
    // Create database
    let mut connection = PgConnection::connect_with(&config.without_db())
        .await
        .expect("Failed to connect to Postgres");

    connection
        .execute(format!(r#"CREATE DATABASE "{}";"#, config.database_name).as_str())
        .await
        .expect("Failed to create database.");

    // Migrate database
    let connection_pool = PgPool::connect_with(config.with_db())
        .await
        .expect("Failed to connect to Postgres");

    sqlx::migrate!("./migrations")
        .run(&connection_pool)
        .await
        .expect("Failed to migrate the database");

    connection_pool
}

运行 cargo test 以确保一切正常

我们现在需要在 DatabaseSettings 里增加字段 require_ssl:

//! src/configuration.rs
// [...]

#[derive(serde::Deserialize)]
pub struct DatabaseSettings {
    // [...]
    pub require_ssl: bool,
}

impl DatabaseSettings {
    pub fn without_db(&self) -> PgConnectOptions {
        let ssl_mode = if self.require_ssl {
            PgSslMode::Require
        } else {
            // Try an encrypted connection, fallback to unencrypted if it fails
            PgSslMode::Prefer
        };
        PgConnectOptions::new()
            .host(&self.host)
            .username(&self.username)
            .password(&self.password.expose_secret())
            .port(self.port)
            .ssl_mode(ssl_mode)
    }

    // [...]
}

我们希望在本地运行应用程序(以及测试套件)时, require_sslfalse, 但在生产环境中则为 true

让我们相应地修改配置文件:

#! configuration/local.yaml
application:
  host: 127.0.0.1
database:
  # New entry!
  require_ssl: false
#! configuration/production.yaml
application:
  host: 127.0.0.1
database:
  # New entry!
  require_ssl: true

注: 调整Sqlx log level 部分已过时, 暂时未找到替代方法

应用程序规范中的环境变量

最后一步:我们需要修改 spec.yaml 清单,以注入所需的环境变量。

#! spec.yaml
name: zero2prod
region: fra
services:
  - name: zero2prod
    # [...]
    envs:
      - key: APP_APPLICATION__BASE_URL
        scope: RUN_TIME
        value: ${APP_URL}
      - key: APP_DATABASE__USERNAME
        scope: RUN_TIME
        value: ${newsletter.USERNAME}
      - key: APP_DATABASE__PASSWORD
        scope: RUN_TIME
        value: ${newsletter.PASSWORD}
      - key: APP_DATABASE__HOST
        scope: RUN_TIME
        value: ${newsletter.HOSTNAME}
      - key: APP_DATABASE__PORT
        scope: RUN_TIME
        value: ${newsletter.PORT}
      - key: APP_DATABASE__DATABASE_NAME
        scope: RUN_TIME
        value: ${newsletter.DATABASE}
databases:
  # PG = Postgres
  - engine: PG
    # Database name
    name: newsletter
    # [...]

范围设置为 RUN_TIME, 以区分 Docker 构建过程中所需的环境变量和 Docker 镜像启动时所需的环境变量。 我们通过插入 Digital Ocean 平台公开的内容(例如 ${newsletter.PORT})来填充环境变量的值 - 更多详情请参阅其文档

最后一次 Push

让我们应用新规范

# You can retrieve your app id using `doctl apps list`
doctl apps update YOUR-APP-ID --spec=spec.yaml

并将我们的更改推送到 GitHub 以触发新的部署。

我们现在需要迁移数据库:

DATABASE_URL=YOUR-DIGITAL-OCEAN-DB-CONNECTION-STRING sqlx migrate run

一切准备就绪!

让我们向 /subscriptions 发送一个 POST 请求:

curl --request POST \
    --data 'name=le%20guin&email=ursula_le_guin%40gmail.com' \
    https://zero2prod-adqrw.ondigitalocean.app/subscriptions \
    --verbose

服务器应该会返回 200 OK。

恭喜,您刚刚部署了您的第一个 Rust 应用程序!

据说,Ursula Le Guin 刚刚订阅了您的电子邮件简报!

如果您已经看到这里,我很想获取一张您的 Digital Ocean 仪表板的屏幕截图,以展示正在运行的应用程序!

请将其发送至 rust@lpalmieri.com, 或在 X 上分享,并标记 "Zero To Production In Rust"帐户 zero2prod

拒绝无效订阅者 第一部分

我们的新闻通讯 API 已上线,托管在云服务提供商处。

我们拥有一套基本的工具来排查可能出现的问题。

我们有一个公开的端点 (POST /subscriptions) 来订阅我们的内容。

我们已经取得了很大进展!

但我们也走了一些弯路: POST /subscriptions 相当...宽松。

我们的输入验证极其有限:我们只需确保姓名和邮箱字段都已提供,其他什么都不用做。

我们可以添加一个新的集成测试,用一些“棘手”的输入来探测我们的 API:

//! tests/health_check.rs
// [...]
#[tokio::test]
async fn subscribe_returns_a_200_when_fields_are_present_but_empty() {
    // Arrange
    let app = spawn_app().await;
    let client = reqwest::Client::new();
    let test_cases = vec![
        ("name=&email=ursula_le_guin%40gmail.com", "empty name"),
        ("name=Ursula&email=", "empty email"),
        ("name=Ursula&email=definitely-not-an-email", "invalid email"),
    ];
    for (body, description) in test_cases {
        // Act
        let response = client
            .post(&format!("{}/subscriptions", &app.address))
            .header("Content-Type", "application/x-www-form-urlencoded")
            .body(body)
            .send()
            .await
            .expect("Failed to execute request.");

        // Assert
        assert_eq!(
            200,
            response.status().as_u16(),
            "The API did not return a 200 OK when the payload was {}.",
            description
        );
    }
}

不幸的是,新的测试通过了。

尽管所有这些有效载荷显然都是无效的,但我们的 API 还是欣然接受它们,并返回 200 OK。

这些麻烦的订阅者详细信息最终会直接进入我们的数据库,并在我们发送新闻通讯时给我们带来麻烦。

订阅新闻通讯时,我们需要提供两项信息:姓名和电子邮件地址。

本章将重点介绍名称验证: 我们应该注意什么?

需求

域约束

事实证明,姓名很复杂。

试图确定姓名的有效性是徒劳的。请记住,我们选择收集姓名是为了将其用于电子邮件的开头——我们不需要它与一个人的真实身份相匹配,无论这在其地理位置上意味着什么。完全没有必要给我们的用户带来错误或过于规范的验证带来的痛苦。

因此,我们可以简单地要求姓名字段非空(即,它必须至少包含一个非空格字符)。

安全约束

不幸的是,互联网上并非所有人都是好人。

如果有足够的时间,尤其是如果我们的简报获得关注并取得成功,我们必然会吸引恶意访问者的注意。

表单和用户输入是主要的攻击目标——如果它们没有得到适当的清理,它们可能会让攻击者破坏我们的数据库(SQL注入)、在我们的服务器上执行代码、导致我们的服务崩溃,以及执行其他恶意操作。

谢谢,但不用了。

在我们的情况下可能会发生什么?在各种可能的攻击中,我们应该做好哪些准备?

我们正在构建一份电子邮件简报,因此我们将重点关注以下方面:

  • 拒绝服务 - 例如,试图关闭我们的服务以阻止其他人注册。 这几乎是任何在线服务的常见威胁;
  • 数据盗窃 - 例如,窃取大量的电子邮件地址;
  • 网络钓鱼 - 例如,使用我们的服务向受害者发送看似合法的电子邮件,诱骗他们点击某些链接或执行其他操作。

我们是否应该尝试在验证逻辑中应对所有这些威胁?

绝对不是!

但采用分层安全方法是一种很好的做法:通过采取缓解措施来降低堆栈中多个层级的这些威胁的风险(例如,输入验证、避免SQL注入的参数化查询、电子邮件中参数化输入的转义等),即使任何一项检查失败或日后被移除,我们也能降低受到攻击的可能性。

我们应该始终牢记,软件是一个活生生的产物:对系统的整体理解是时间流逝的第一个受害者。

当你第一次写下整个系统时,你的脑海里已经有了它,但下一个接触它的开发人员却不会——至少从一开始就不会。因此,应用程序某个不起眼角落的负载检查可能会消失(例如HTML转义),从而使你暴露于一类攻击(例如网络钓鱼)。

冗余可降低风险。

让我们直奔主题——鉴于我们识别出的威胁类别,我们应该对名称进行哪些验证才能提升我们的安全态势?

我的建议是:

  • 强制设置最大长度。我们在 Postgres 中的电子邮件类型为 TEXT,这实际上不受限制——嗯,直到磁盘存储空间开始耗尽为止。名称的形式多种多样,但 256 个字符应该足以满足我们绝大多数用户的需求48——如果不够,我们会礼貌地要求他们输入昵称。
  • 拒绝包含麻烦字符的名称。/()"<>\{} 在 URL、SQL 查询和 HTML 片段中相当常见,但在名称49中则不那么常见。禁止使用它们会提高 SQL 注入和网络钓鱼攻击的复杂性。

第一个实现

让我们看一下现在的请求处理程序:

//! src/routes/subscriptions.rs
#[derive(serde::Deserialize)]
pub struct FormData {
    email: String,
    name: String,
}

#[tracing::instrument(
    name = "Adding a new subscriber",
    skip(form, pool),
    fields(
        subscriber_email = %form.email,
        subscriber_name = %form.name,
    )
)]
pub async fn subscribe(form: web::Form<FormData>, pool: web::Data<PgPool>) -> HttpResponse {
    match insert_subscriber(&pool, &form).await {
        Ok(_) => HttpResponse::Ok().finish(),
        Err(_) => HttpResponse::InternalServerError().finish(),
    }
}
// [...]

我们的新验证应该放在哪里?

第一个草图可能看起来像这样:

// An extension trait to provide the `graphemes` method
// on `String` and `&str`
use unicode_segmentation::UnicodeSegmentation;

pub async fn subscribe(form: web::Form<FormData>, pool: web::Data<PgPool>) -> HttpResponse {
    if !is_valid_name(&form.name) {
        return HttpResponse::BadRequest().finish();
    }
    match insert_subscriber(&pool, &form).await {
        Ok(_) => HttpResponse::Ok().finish(),
        Err(_) => HttpResponse::InternalServerError().finish(),
    }
}

/// Returns `true` if the input satisfies all our validation constrains
/// on subscriber names, `false` otherwise.
pub fn is_valid_name(s: &str) -> bool {
    // `.trim()` returns a view over the input `s` without trailing
    // whitespace-like characters.
    // `.is_empty` checks if the view contains any character.
    let is_empty_or_whitespace = s.trim().is_empty();
    
    // A grapheme is defined by the Unicode standard as a "user-perceived"
    // character: `å` is a single grapheme, but it is composed of two characters
    // (`a` and `̊`).
    //
    // `graphemes` returns an iterator over the graphemes in the input `s`.
    // `true` specifies that we want to use the extended grapheme definition set,
    // the recommended one.
    let is_too_long = s.graphemes(true).count() > 256;

    // Iterate over all characters in the input `s` to check if any of them matches
    // one of the characters in the forbidden array.
    let forbidden_characters = ['/', '(', ')', '"', '<', '>', '\\', '{', '}'];
    let contains_forbidden_characters = s.chars().any(|g| forbidden_characters.contains(&g));
   
    // Return `false` if any of our conditions have been violated
    !(is_empty_or_whitespace || is_too_long || contains_forbidden_characters)
}

为了成功编译新函数,我们必须将 unicode-segmentation crate 添加到我们的依赖项中:

cargo add unicode-segmentation

虽然它看起来是一个完美的解决方案(假设我们添加一堆测试),但像 is_valid_name 这样的函数会给我们一种虚假的安全感。

验证就像一口漏水的大锅

让我们把注意力转移到 insert_subscriber 函数上。 假设它要求 form.name 非空,否则就会发生一些可怕的事情(例如,引发panic!)。

insert_subscriber 函数能安全地假设 form.name 非空吗?

仅从它的类型来看,显然不行:form.name 是一个字符串。它的内容没有任何保证。

如果你完整地看一下我们的程序,你可能会说:我们在请求处理程序的底层检查它是否非空,因此我们可以安全地假设每次调用 insert_subscriber 函数时,form.name 都会非空。

但为了做出这样的断言,我们必须从局部方法(让我们看看这个函数的参数)转变为全局方法(让我们扫描整个代码库)。

虽然对于像我们这样的小型项目来说,这或许可行,但检查函数 (insert_subscriber) 的所有调用点以确保事先执行了某个验证步骤,这在大型项目中很快就会变得不可行。

如果我们坚持使用 is_valid_name,唯一可行的方法就是在 insert_subscriber 函数中再次验证 form.name,以及所有其他要求名称非空的函数。

这才是我们真正确保不变量在需要的地方存在的唯一方法。

如果 insert_subscriber 变得太大,我们不得不将其拆分成多个子函数,会发生什么情况?如果它们需要不变量,那么每个子函数都必须执行验证以确保其成立。

正如您所见,这种方法无法扩展。

这里的问题是 is_valid_name 是一个验证函数:它告诉我们,在程序执行流程的某个时刻,一组条件得到了验证。 但是,关于输入数据中附加结构的信息并没有存储在任何地方。它会立即丢失。

程序的其他部分无法有效地重用它——它们被迫执行另一次时间点检查,导致代码库拥挤,每一步都充满噪音(且浪费)的输入检查。

我们需要的是一个解析函数——一个接受非结构化输入的例程,如果一组条件成立,则返回一个更结构化的输出,这个输出在结构上保证我们关心的不变量从那一刻起一直成立。

怎么做?

使用类型!

类型驱动开发

让我们在项目中添加一个新模块,域,并在其中定义一个新的结构, SubscriberName:

//! src/lib.rs
// [...]
pub mod domain;
//! src/domain.rs

pub struct SubscriberName(String);

SubscriberName 是一个元组结构体,它是一种新类型,包含一个 String 类型的(未命名)字段。

SubscriberName 是一个真正的新类型,而不仅仅是一个别名——它不继承 String 上的任何方法,尝试将 String 赋值给 SubscriberName 类型的变量会触发编译器错误,例如:

let name: SubscriberName = "A string".to_string();
error[E0308]: mismatched types
  --> src/main.rs:10:32
   |
10 |     let name: SubscriberName = "A string".to_string();
   |               --------------   ^^^^^^^^^^^^^^^^^^^^^^ expected `SubscriberName`,
 found `String`
   |               |
   |               expected due to this

For more information about this error, try `rustc --explain E0308`.

根据我们当前的定义, SubscriberName 的内部字段是私有的:它只能根据 Rust 的可见性规则从域模块内的代码访问。

一如既往,信任但要验证:如果我们尝试在订阅请求处理程序中构建一个 SubscriberName 会发生什么?

//! src/routes/subscriptions.rs
// [...]
pub async fn subscribe(form: web::Form<FormData>, pool: web::Data<PgPool>) -> HttpResponse {
    let subscriber_name = crate::domain::SubscriberName(form.name.clone());
    // [...]
}

编译器会报错

error[E0603]: tuple struct constructor `SubscriberName` is private
  --> src/routes/subscriptions.rs:22:42
   |
22 |     let subscriber_name = crate::domain::SubscriberName(form.name.clone());
   |                                          ^^^^^^^^^^^^^^ private tuple struct con
structor
   |
  ::: src/domain.rs:1:27
   |
1  | pub struct SubscriberName(String);
   |                           ------ a constructor is private if any of the fields i
s private
   |
note: the tuple struct constructor `SubscriberName` is defined here
  --> src/domain.rs:1:1
   |
1  | pub struct SubscriberName(String);
   | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

因此,就目前情况而言,在域模块之外构建 SubscriberName 实例是不可能的。

让我们为 SubscriberName 添加一个新方法:

//! src/domain.rs
use unicode_segmentation::UnicodeSegmentation;

pub struct SubscriberName(String);

impl SubscriberName {
    /// Returns an instance of `SubscriberName` if the input satisfies all
    /// our validation constraints on subscriber names.
    /// It panics otherwise.
    pub fn parse(s: String) -> SubscriberName {
        // `.trim()` returns a view over the input `s` without trailing
        // whitespace-like characters.
        // `.is_empty` checks if the view contains any character.
        let is_empty_or_whitespace = s.trim().is_empty();
        // A grapheme is defined by the Unicode standard as a "user-perceived"
        // character: `å` is a single grapheme, but it is composed of two characters
        // (`a` and `̊`).
        //
        // `graphemes` returns an iterator over the graphemes in the input `s`.
        // `true` specifies that we want to use the extended grapheme definition set,
        // the recommended one.
        let is_too_long = s.graphemes(true).count() > 256;
        // Iterate over all characters in the input `s` to check if any of them matches
        // one of the characters in the forbidden array.
        let forbidden_characters = ['/', '(', ')', '"', '<', '>', '\\', '{', '}'];
        let contains_forbidden_characters = s.chars().any(|g| forbidden_characters.contains(&g));

        if is_empty_or_whitespace || is_too_long || contains_forbidden_characters {
            panic!("{s} is not a valid subscriber name");
        } else {
            Self(s)
        }
    }
}

是的,你说得对——这简直就是对 is_valid_name 的厚颜无耻的复制粘贴。

不过,有一个关键的区别:返回类型。

is_valid_name 返回一个布尔值,而 parse 方法如果所有检查都成功,则会返回一个 SubscriberName

还有更多!

parse 是在域模块之外构建 SubscriberName 实例的唯一方法——我们 在前面几段中已经验证过这一点。

因此,我们可以断言,任何 SubscriberName 实例都将满足我们所有的验证约束。

我们已经确保 SubscriberName 实例不可能违反这些约束。

让我们定义一个新的结构体,NewSubscriber:

//! src/domain.rs
// [...]
pub struct NewSubscriber {
    pub email: String,
    pub name: SubscriberName,
}

pub struct SubscriberName(String);

// [...]

如果我们将 insert_subscriber 改为接受 NewSubscriber 类型的参数而不是 FormData 类型,会发生什么情况?

pub async fn insert_subscriber(
    pool: &PgPool,
    new_subscriber: &NewSubscriber,
) -> Result<(), sqlx::Error> {
    // [...]
}

有了新的签名,我们可以确保 new_subscriber.name 非空——不可能通过传递空的订阅者名称来调用 insert_subscriber

我们只需查找函数参数的类型定义即可得出这个结论——我们可以再次进行本地判断,而无需去检查函数的所有调用点。

花点时间回顾一下刚刚发生的事情:我们从一组需求开始(所有订阅者名称都必须验证一些约束),我们发现了一个潜在的陷阱(我们可能会在调用 insert_subscriber 之前忘记验证输入),然后我们利用 Rust 的类型系统彻底消除了这个陷阱

我们通过构造使一个错误的使用模式变得不可表示——它将无法编译。

这种技术被称为类型驱动开发。

类型驱动开发是一种强大的方法,它可以将我们试图在类型系统内部建模的领域的约束进行编码,并依靠编译器来确保它们得到强制执行。

我们的编程语言的类型系统越具有表达力,我们就能越严格地限制我们的代码,使其只能表示在我们所工作的领域中有效的状态。

Rust 并没有发明类型驱动开发——它已经存在了一段时间,尤其是在函数式编程社区(Haskell、F#、OCaml 等)。Rust“只是”为您提供了一个具有足够表达力的类型系统,可以充分利用过去几十年来在这些语言中开创的许多设计模式。我们刚刚展示的特定模式在 Rust 社区中通常被称为“新型模式”。

在实现过程中,我们将逐步涉及类型驱动开发,但我强烈建议您查看本章脚注中提到的一些资源: 它们是任何开发人员的宝库。

所有权与不变量

我们修改了 insert_subscriber 的签名,但尚未修改主体以符合新的要求——现在就修改吧。

//! src/routes/subscriptions.rs

// [...]

#[tracing::instrument(/*[...]*/)]
pub async fn subscribe(form: web::Form<FormData>, pool: web::Data<PgPool>) -> HttpResponse {
    let new_subscriber = NewSubscriber {
        email: form.0.email,
        name: SubscriberName::parse(form.0.name),
    };
    match insert_subscriber(&pool, &new_subscriber).await {
        Ok(_) => HttpResponse::Ok().finish(),
        Err(_) => HttpResponse::InternalServerError().finish(),
    }
}


#[tracing::instrument(
    name = "Saving new subscriber details in the database",
    skip(new_subscriber, pool)
)]
pub async fn insert_subscriber(pool: &PgPool, new_subscriber: &NewSubscriber) -> Result<(), sqlx::Error> {
    sqlx::query!(
        r#"
        INSERT INTO subscriptions (id, email, name, subscribed_at)
        VALUES ($1, $2, $3, $4)
        "#,
        Uuid::new_v4(),
        new_subscriber.email,
        new_subscriber.name,
        Utc::now()
    )
    .execute(pool)
    .await
    .inspect_err(|e| {
        tracing::error!("Failed to execute query: {:?}", e);
    })?;

    Ok(())
}

我们很接近了, 但 cargo check 还没办法通过:

error[E0308]: mismatched types
  --> src/routes/subscriptions.rs:46:9
   |
46 |         new_subscriber.name,
   |         ^^^^^^^^^^^^^^
   |         |
   |         expected `&str`, found `SubscriberName`
   |         expected due to the type of this binding

error[E0277]: the trait bound `SubscriberName: sqlx::Encode<'_, Postgres>` is no
t satisfied

这里有一个问题: 我们没有任何方法可以真正访问封装在 SubscriberName 中的字符串值!

我们可以将 SubscriberName 的定义从 SubscriberName(String) 改为 SubscriberName(pubString),但这样一来,我们就会失去前两节中提到的所有好处:

  • 其他开发者可以绕过解析,使用任意字符串构建 SubscriberName
let liar = SubscriberName("".to_string());
  • 其他开发者可能仍然会选择使用 parse 来构建 SubscriberName,但他们随后可以选择将内部值更改为不再满足我们关心的约束的值
let mut started_well = SubscriberName::parse("A valid name".to_string());
started_well.0 = "".to_string();

我们可以做得更好——这正是 Rust 所有权系统的优势所在! 给定结构体中的某个字段,我们可以选择:

  • 通过值暴露它,使用消耗结构体本身:
impl SubscriberName {
    pub fn inner(self) -> String {
        // The caller gets the inner string,
        // but they do not have a SubscriberName anymore!
        // That's because `inner` takes `self` by value,
        // consuming it according to move semantics
        self.0
    }
}

- 暴露可变引用

```rs
impl SubscriberName {
    pub fn inner_mut(&mut self) -> &mut str {
        // The caller gets a mutable reference to the inner string.
        // This allows them to perform *arbitrary* changes to
        // value itself, potentially breaking our invariants!
        &mut self.0
    }
}

暴露引用

impl SubscriberName {
    pub fn inner_ref(&self) -> &str {
        // The caller gets a shared reference to the inner string.
        // This gives the caller **read-only** access,
        // they have no way to compromise our invariants!
        &self.0
    }
}

inner_mut 并非我们想要的效果——失去对不变量的控制,相当于使用 SubscriberName(pub String)。

inner 和 inner_ref 都适用,但 inner_ref 更好地传达了我们的意图:

让调用者有机会读取值,但无法对其进行修改。

让我们将 inner_ref 添加到 SubscriberName 中——然后我们可以修改 insert_subscriber 来使用它:

//! src/routes/subscriptions.rs
// [...]

#[tracing::instrument(
    name = "Saving new subscriber details in the database",
    skip(new_subscriber, pool)
)]
pub async fn insert_subscriber(pool: &PgPool, new_subscriber: &NewSubscriber) -> Result<(), sqlx::Error> {
    sqlx::query!(
        r#"
        INSERT INTO subscriptions (id, email, name, subscribed_at)
        VALUES ($1, $2, $3, $4)
        "#,
        Uuid::new_v4(),
        new_subscriber.email,
        // Using `inner_ref`!
        new_subscriber.name.inner_ref(),
        Utc::now()
    )
    .execute(pool)
    .await
    .inspect_err(|e| {
        tracing::error!("Failed to execute query: {:?}", e);
    })?;

    Ok(())
}

轰隆,编译成功了!

AsRef

虽然我们的 inner_ref 方法完成了任务,但我必须指出,Rust 的标准库公开了一个专为此类用法而设计的特性——AsRef

其定义非常简洁:

pub trait AsRef<T>
where
    T: ?Sized,
{
    // Required method
    fn as_ref(&self) -> &T;
}

什么时候应该为某个类型实现 AsRef<T>? 当该类型与 T 足够相似,以至于我们可以使用 &self 来获取 T 自身的引用时!

这听起来是不是太抽象了?再看看 inner_ref 的签名:它本质上就是为 SubscriberName 实现的 AsRef<str>!

AsRef 可以用来提升用户体验——让我们考虑一个具有以下签名的函数:

pub fn do_something_with_a_string_slice(s: &str) {
    // [...]
}

没什么太复杂的,但你可能需要花点时间弄清楚 SubscriberName 是否能提供 &str ,以及如何提供,尤其是当该类型来自第三方库时。

我们可以通过更改 do_something_with_a_string_slice 的签名来让体验更加无缝:

// We are constraining T to implement the AsRef<str> trait
// using a trait bound - `T: AsRef<str>`
pub fn do_something_with_a_string_slice<T: AsRef<str>>(s: T) {
    let s = s.as_ref();
    // [...]
}

我们现在可以写

let name = SubscriberName::parse("A valid name".to_string());
do_something_with_a_string_slice(name)

它会立即编译通过(假设 SubscriberName 实现了 AsRef<str> 接口)。

这种模式被广泛使用,例如,在 Rust 标准库 std::fs 中的文件系统模块中。像 create_dir 这样的函数接受一个 P 类型的参数,并强制要求实现 AsRef<Path> 接口,而不是强迫用户理解如何将 String 转换为 Path,或者如何将 PathBuf 转换为 Path,或者 OsString,等等...你懂的。

该标准库中还有其他一些像 AsRef 这样的小转换特性——它们为整个生态系统提供了一个共享的接口,以便围绕它们进行标准化。为你的类型实现这些特性,可以立即解锁大量通过现有的 crate 中的泛型类型公开的功能。

我们稍后会介绍其他一些转换特性(例如 From/Into、TryFrom/TryInto)。

让我们删除 inner_ref 并为 SubscriberName 实现 AsRef<str>:

//! src/domain.rs
// [...]
impl AsRef<str> for SubscriberName {
    fn as_ref(&self) -> &str {
        &self.0
    }
}

我们还需要修改 insert_subscriber:

//! src/routes/subscriptions.rs
// [...]
pub async fn insert_subscriber(pool: &PgPool, new_subscriber: &NewSubscriber) -> Result<(), sqlx::Error> {
    sqlx::query!(
        r#"
        INSERT INTO subscriptions (id, email, name, subscribed_at)
        VALUES ($1, $2, $3, $4)
        "#,
        Uuid::new_v4(),
        new_subscriber.email,
        // Using `as_ref` now!
        new_subscriber.name.as_ref(),
        Utc::now()
    )
    .execute(pool)
    .await
    .inspect_err(|e| {
        tracing::error!("Failed to execute query: {:?}", e);
    })?;

    Ok(())
}

该项目可以编译通过...

Panics

...但是我们的测试结果并不理想:

running 4 tests
test subscribe_returns_a_200_when_fields_are_present_but_empty ... FAILED
test subscribe_returns_a_200_for_valid_form_data ... ok
test subscribe_returns_a_400_when_data_is_missing ... ok
test health_check_works ... ok

failures:

---- subscribe_returns_a_200_when_fields_are_present_but_empty stdout ----

thread 'actix-server worker 0' panicked at src/domain.rs:33:13:
 is not a valid subscriber name
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

thread 'subscribe_returns_a_200_when_fields_are_present_but_empty' panicked at t
ests/health_check.rs:179:14:
Failed to execute request.: reqwest::Error { kind: Request, url: "http://127.0.0
.1:42035/subscriptions", source: hyper_util::client::legacy::Error(SendRequest, 
hyper::Error(IncompleteMessage)) }

好的一面是:我们不再为空名称返回 200 OK 错误。

不好的一面是: 我们的 API 会突然终止请求处理,导致客户端 观察到 IncompleteMessage 错误。这不太优雅。

让我们修改测试以反映我们的新期望:当有效负载包含无效数据时,我们希望看到 400 Bad Request 响应。

//! tests/health_check.rs
// [...]

async fn subscribe_returns_a_200_when_fields_are_present_but_invalid() {
    // Arrange
    let app = spawn_app().await;
    let client = reqwest::Client::new();
    let test_cases = vec![
        ("name=&email=ursula_le_guin%40gmail.com", "empty name"),
        ("name=Ursula&email=", "empty email"),
        ("name=Ursula&email=definitely-not-an-email", "invalid email"),
    ];
    for (body, description) in test_cases {
        // Act
        let response = client
            .post(&format!("{}/subscriptions", &app.address))
            .header("Content-Type", "application/x-www-form-urlencoded")
            .body(body)
            .send()
            .await
            .expect("Failed to execute request.");

        // Assert
        assert_eq!(
            // Not 200 anymore!
            400,
            response.status().as_u16(),
            "The API did not return a 400 OK when the payload was {}.",
            description
        );
    }
}

现在,让我们看看根本原因——当 SubscriberName::parse 中的验证检查失败时,我们选择 panic:

//! src/domain.rs
// [...]
pub fn parse(s: String) -> SubscriberName {
    // [...]

    if is_empty_or_whitespace || is_too_long || contains_forbidden_characters {
        panic!("{s} is not a valid subscriber name");
    } else {
        Self(s)
    }
}

Rust 中的 panic 用于处理不可恢复的错误: 意料之外的故障模式,或者我们无法有效恢复的故障模式。例如,主机内存不足或磁盘已满。Rust 的 panic 并不等同于 Python、C# 或 Java 等语言中的异常。虽然 Rust 提供了一些工具来捕获(某些) panic, 但这绝对不是推荐的方法,应该谨慎使用。Burntsushi 几年前在 Reddit 的一个帖子中就曾明确指出:

[...] 如果您的 Rust 应用程序在响应任何用户输入时出现 panic,则以下情况应该为真:您的应用程序存在错误,无论该错误存在于库中还是主应用程序代码中。

从这个角度来看,我们可以理解正在发生的事情: 当我们的请求处理程序发生恐慌时,actix-web 会认为发生了可怕的事情,并立即丢弃正在处理该 panic 请求的工作进程。

如果恐慌不是解决问题的办法,我们应该用什么来处理可恢复的错误?

将错误作为值 - Result

Rust 的主要错误处理机制建立在 Result 类型之上:

enum Result<T, E> {
   Ok(T),
   Err(E),
}

Result 用作易出错操作的返回类型: 如果操作成功,则返回 Ok(T); 如果失败,则返回 Err(E)

我们实际上已经使用过 Result 了,尽管当时我们并没有停下来讨论它的细微差别。

让我们再看一下 insert_subscriber 的签名:

//! src/routes/subscriptions.rs
// [...]

pub async fn insert_subscriber(
    pool: &PgPool,
    new_subscriber: &NewSubscriber,
) -> Result<(), sqlx::Error> {
    // [...]
}

它告诉我们,在数据库中插入订阅者是一个容易出错的操作——如果一切按计划进行,我们不会收到任何返回(() - 单位类型),如果出现问题,我们将收到一个 sqlx::Error,其中包含出错的详细信息(例如连接问题)。

将错误作为值,与 Rust 的枚举相结合,是构建健壮错误处理机制的绝佳基石。

如果你之前使用的语言支持基于异常的错误处理,那么这可能会带来翻天覆地的变化:我们需要了解的关于函数故障模式的一切都包含在它的签名中。

你无需深入研究依赖项的文档来了解某个函数可能抛出的异常(假设它事先已记录在案!)。

你不会在运行时对另一个未记录的异常类型感到惊讶。你不必“以防万一”地插入一个 catch-all 语句。

我们将在这里介绍基础知识,并将更详细的细节(Error trait)留到下一章。

parse 中加入 Result 返回值

让我们重构一下 SubscriberName::parse 函数,让它返回一个 Result,而不是因为输入无效而 panic。

我们先修改一下签名,但不修改函数体:

//! src/domain.rs
// [...]
impl SubscriberName {
    pub fn parse(s: String) -> Result<SubscriberName, ???> {
        // [...]
    }
}

我们应该使用什么类型作为 Result 的 Err 变量?

最简单的选择是字符串 - 失败时我们只会返回一条错误消息。

impl SubscriberName {
    pub fn parse(s: String) -> Result<SubscriberName, String> {
        // [...]
    }
}

运行 cargo check 会产生两个错误:

error[E0308]: mismatched types
  --> src/routes/subscriptions.rs:25:15
   |
25 |         name: SubscriberName::parse(form.0.name),
   |               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected `SubscriberName`,
 found `Result<SubscriberName, String>`
   |
   = note: expected struct `SubscriberName`
                found enum `Result<SubscriberName, std::string::String>`
help: consider using `Result::expect` to unwrap the `Result<SubscriberName, std:
:string::String>` value, panicking if the value is a `Result::Err`
   |
25 |         name: SubscriberName::parse(form.0.name).expect("REASON"),
   |                                                 +++++++++++++++++

error[E0308]: mismatched types
  --> src/domain.rs:20:13
   |
11 |     pub fn parse(s: String) -> Result<SubscriberName, String> {
   |                                ------------------------------ expected `Res
ult<SubscriberName, std::string::String>` because of return type
...
20 |             Self(s)
   |             ^^^^^^^ expected `Result<SubscriberName, String>`, found `Subsc
riberName`
   |
   = note: expected enum `Result<SubscriberName, std::string::String>`
            found struct `SubscriberName`
help: try wrapping the expression in `Ok`
   |
20 |             Ok(Self(s))
   |             +++       +

For more information about this error, try `rustc --explain E0308`.
error: could not compile `zero2prod` (lib) due to 2 previous errors

让我们关注第二个错误:在 parse 结束时,我们无法返回一个 SubscriberName 的裸实例——我们需要在两个 Result 变体中选择一个。

编译器理解了这个问题,并建议了正确的修改:使用 Ok(Self(s)) 而不是 Self(s)。

让我们遵循它的建议:

//! src/domain.rs
// [...]
impl SubscriberName {
    pub fn parse(s: String) -> Result<SubscriberName, String> {
        // [...]

        if is_empty_or_whitespace || is_too_long || contains_forbidden_characters {
            panic!("{s} is not a valid subscriber name");
        } else {
            Ok(Self(s))
        }
    }
}

cargo check 现在应该返回一个错误:

error[E0308]: mismatched types
  --> src/routes/subscriptions.rs:25:15
   |
25 |         name: SubscriberName::parse(form.0.name),
   |               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected `SubscriberName`,
 found `Result<SubscriberName, String>`
   |
   = note: expected struct `SubscriberName`
                found enum `Result<SubscriberName, std::string::String>`
help: consider using `Result::expect` to unwrap the `Result<SubscriberName, std:
:string::String>` value, panicking if the value is a `Result::Err`
   |
25 |         name: SubscriberName::parse(form.0.name).expect("REASON"),
   |                                                 +++++++++++++++++

For more information about this error, try `rustc --explain E0308`.
error: could not compile `zero2prod` (lib) due to 1 previous error

它抱怨我们在 subscribe 中调用 parse 方法: 当 parse 返回一个 SubscriberName 时,将其输出直接赋值给 Subscriber.name 是完全没问题的。

现在我们返回一个 Result —— Rust 的类型系统迫使我们处理这条不愉快的路径。我们不能假装它不会发生。

不过,我们先避免一下子讲太多——为了让项目尽快再次编译,我们暂时只在验证失败时触发 panic:

//! src/routes/subscriptions.rs
// [...]

pub async fn subscribe(form: web::Form<FormData>, pool: web::Data<PgPool>) -> HttpResponse {
    let new_subscriber = NewSubscriber {
        email: form.0.email,
        // Notice the usage of `expect` to specify a meaningful panic message
        name: SubscriberName::parcse(form.0.name).expect("Name validation failed."),
    };
    // [...]
}

cargo check 现在应该很顺利了。

该进行测试了!

深刻的断言错误: claim

我们的大多数断言都类似于 assert!(result.is_ok())assert!(result.is_err())

使用这些断言时,Cargo Test 失败时返回的错误消息非常糟糕。

到底有多糟糕?让我们来做个快速实验!

如果你在这个虚拟测试上运行 cargo test:

#[test]
fn dummy_fail() {
    let result: Result<&str, &str> = Err("The app crashed due to an IO error");
    assert!(result.is_ok());
}

将会输出

---- dummy_fail stdout ----
thread 'dummy_fail' panicked at 'assertion failed: result.is_ok()'

我们无法获得任何有关错误本身的详细信息——这会让调试过程变得相当痛苦。

我们将使用 claim crate 来获取更多信息丰富的错误消息:

cargo add claim

claim 提供了相当全面的断言,可以处理常见的 Rust 类型——特别是 Option 和 Result。

如果我们重写 dummy_fail 测试并使用 claim crate

#[test]
fn dummy_fail() {
    let result: Result<&str, &str> = Err("The app crashed due to an IO error");
    claim::assert_ok!(result);
}

将会输出

---- dummy_fail stdout ----
thread 'dummy_fail' panicked at 'assertion failed, expected Ok(..),
  got Err("The app crashed due to an IO error")'

好多了。

单元测试

一切准备就绪——让我们向域模块添加一些单元测试,以确保我们编写的所有代码都能按预期运行。

//! src/domain.rs
// [...]

#[cfg(test)]
mod tests {
    use claim::{assert_err, assert_ok};

    use crate::domain::SubscriberName;

    #[test]
    fn a_256_grapheme_log_name_is_valid() {
        let name = "a".repeat(256);
        assert_ok!(SubscriberName::parse(name));
    }

    #[test]
    fn a_name_longer_than_256_graphemes_is_rejected() {
        let name = "a".repeat(257);
        assert_err!(SubscriberName::parse(name));
    }

    #[test]
    fn whitespace_only_names_are_rejected() {
        let name = " ".to_string();
        assert_err!(SubscriberName::parse(name));
    }

    #[test]
    fn empty_string_is_rejected() {
        let name = "".to_string();
        assert_err!(SubscriberName::parse(name));
    }

    #[test]
    fn names_containing_an_invalid_character_are_rejected() {
        let name = "".to_string();
        assert_err!(SubscriberName::parse(name));
    }

    #[test]
    fn a_valid_name_is_parsed_successfully() {
        let name = "Zhangzhiyu".to_string();
        assert_ok!(SubscriberName::parse(name));
    }
}

不幸的是, 它不能编译 - cargo 突出显示了我们对 assert_ok/assert_err 的所有用法

error[E0277]: `SubscriberName` doesn't implement `std::fmt::Debug`
  --> src/domain.rs:64:9
   |
64 |         assert_err!(SubscriberName::parse(name));
   |         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
   |         |
   |         `SubscriberName` cannot be formatted using `{:?}` because it doesn'
t implement `std::fmt::Debug`
   |         required by this formatting parameter
   |

声明需要我们的类型实现 Debug trait,以便提供这些友好的错误信息。让我们在 SubscriberName 上添加一个 #[derive(Debug)] 属性:

//! src/domain.rs
// [...]

#[derive(Debug)]
pub struct SubscriberName(String);

编译器现在应该没问题了。测试怎么样?

cargo test
running 6 tests
test domain::tests::empty_string_is_rejected ... FAILED
test domain::tests::a_valid_name_is_parsed_successfully ... ok
test domain::tests::names_containing_an_invalid_character_are_rejected ... FAILE
D
test domain::tests::whitespace_only_names_are_rejected ... FAILED
test domain::tests::a_256_grapheme_log_name_is_valid ... ok
test domain::tests::a_name_longer_than_256_graphemes_is_rejected ... FAILED

failures:

---- domain::tests::empty_string_is_rejected stdout ----

thread 'domain::tests::empty_string_is_rejected' panicked at src/domain.rs:19:13
:
 is not a valid subscriber name
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

---- domain::tests::names_containing_an_invalid_character_are_rejected stdout --
--

thread 'domain::tests::names_containing_an_invalid_character_are_rejected' panic
ked at src/domain.rs:19:13:
 is not a valid subscriber name

---- domain::tests::whitespace_only_names_are_rejected stdout ----

thread 'domain::tests::whitespace_only_names_are_rejected' panicked at src/domai
n.rs:19:13:
  is not a valid subscriber name

---- domain::tests::a_name_longer_than_256_graphemes_is_rejected stdout ----

thread 'domain::tests::a_name_longer_than_256_graphemes_is_rejected' panicked at
 src/domain.rs:19:13:
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaa is not a valid subscriber name


failures:
    domain::tests::a_name_longer_than_256_graphemes_is_rejected
    domain::tests::empty_string_is_rejected
    domain::tests::names_containing_an_invalid_character_are_rejected
    domain::tests::whitespace_only_names_are_rejected

test result: FAILED. 2 passed; 4 failed; 0 ignored; 0 measured; 0 filtered out; 
finished in 0.00s

error: test failed, to rerun pass `--lib`

所有要求失败的路径测试都失败了,因为我们仍然在验证约束不满足的时候panic——让我们改变它:

//! src/domain.rs
// [...]
impl SubscriberName {
    pub fn parse(s: String) -> Result<SubscriberName, String> {
        // [...]

        if is_empty_or_whitespace || is_too_long || contains_forbidden_characters {
            // Replacing `panic!` with `Err(...)`
            Err(format!("{s} is not a valid subscriber name"))
        } else {
            Ok(Self(s))
        }
    }
}

现在,我们所有的领域单元测试都通过了——让我们最终解决本章开头编写的失败的集成测试。

处理 Result

SubscriberName::parse 现在返回的是 Result, 但 subscribe 调用了 expect 方法,因此 如果返回 Err 变量,就会触发 panic。 整个应用程序的行为没有任何变化。

我们如何修改 subscribe,使其在验证错误时返回 400 Bad Request 错误? 我们可以看看我们在 insert_subscriber 调用中已经做了什么!

match

我们如何处理调用方发生故障的可能性?

//! src/routes/subscriptions.rs
// [...]
pub async fn insert_subscriber(
    pool: &PgPool,
    new_subscriber: &NewSubscriber,
) -> Result<(), sqlx::Error> {
    // [...]
}
//! src/routes/subscriptions.rs
// [...]
pub async fn subscribe(
    form: web::Form<FormData>,
    pool: web::Data<PgPool>,
) -> HttpResponse {
    // [...]
    match insert_subscriber(&pool, &new_subscriber).await {
        Ok(_) => HttpResponse::Ok().finish(),
        Err(_) => HttpResponse::InternalServerError().finish(),
    }
}

insert_subscriber 返回 Result<(), sqlx::Error> , 而 subscribe 使用的是 REST API 的语言——它的输出必须是 HttpResponse 类型。为了在错误情况下向调用者返回 HttpResponse, 我们需要将 sqlx::Error 转换为 REST API 技术领域内合理的表示形式——在我们的例子中,是 500 Internal Server Error。

这时 match 就派上用场了:我们告诉编译器在 OkErr 两种情况下该做什么。

? 操作符

说到错误处理,让我们再看一下 insert_subscriber:

//! src/routes/subscriptions.rs
// [...]

pub async fn insert_subscriber(pool: &PgPool, new_subscriber: &NewSubscriber) -> Result<(), sqlx::Error> {
    sqlx::query!(/*[...]*/)
    .execute(pool)
    .await
    .inspect_err(|e| {
        tracing::error!("Failed to execute query: {:?}", e);
    })?;

    Ok(())
}

您是否注意到了 Ok(()) 之前的 ? ?

它是问号操作符 ?

? 在 Rust 1.13 中引入 - 它是语法糖。

当你使用易错函数并希望 “冒泡” 失败 (例如,类似于重新抛出已捕获的异常) 时, 它可以减少视觉噪音。

此代码块中的 ?

insert_subscriber(&pool, &new_subscriber)
.await
.map_err(|_| HttpResponse::InternalServerError().finish())?;

等于如下代码块中控制流

if let Err(error) = insert_subscriber(&pool, &new_subscriber)
    .await
    .map_err(|_| HttpResponse::InternalServerError().finish())
{
    return Err(error);
}

它允许我们在出现故障时使用单个字符(而不是多行代码块)提前返回。

鉴于 ? 会使用 Err 变量触发提前返回, 因此它只能在返回 Result 的函数中使用。subscribe (暂时) 不符合条件。

400 Bad Request

现在让我们处理 SubscriberName::parse 返回的错误:

//! src/routes/subscriptions.rs
// [...]
pub async fn subscribe(form: web::Form<FormData>, pool: web::Data<PgPool>) -> HttpResponse {
    let name = match SubscriberName::parse(form.0.name) {
        Ok(name) => name,
        Err(_) => return HttpResponse::BadRequest().finish(),
    };
    let new_subscriber = NewSubscriber {
        email: form.0.email,
        name,
    };
    match insert_subscriber(&pool, &new_subscriber).await {
        Ok(_) => HttpResponse::Ok().finish(),
        Err(_) => HttpResponse::InternalServerError().finish(),
    }
}

cargo test 尚未通过,但我们收到了不同的错误:

---- subscribe_returns_a_200_when_fields_are_present_but_invalid stdout ----

thread 'subscribe_returns_a_200_when_fields_are_present_but_invalid' panicked at
 tests/health_check.rs:182:9:
assertion `left == right` failed: The API did not return a 400 OK when the paylo
ad was empty email.
  left: 400
 right: 200
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    subscribe_returns_a_200_when_fields_are_present_but_invalid

test result: FAILED. 3 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; 
finished in 0.20s

使用空名称的测试用例现在可以通过了,但当提供空的电子邮件地址时,我们无法返回 400 Bad Request。

这并不出乎意料——我们还没有实现任何类型的电子邮件验证!

电子邮件格式

我们直观地熟悉电子邮件地址的常见结构 - XXX@YYY.ZZZ - 但如果你想严谨起见,避免退回实际上有效的电子邮件地址,这个问题很快就会变得更加复杂。

我们如何确定一个电子邮件地址是否“有效”?

互联网工程任务组 (IETF) 发布了几个征求意见稿 (RFC),概述了电子邮件地址的预期结构 - tools.ietf.org/html/rfc6854、RFC 5322RFC 2822。我们必须阅读这些文件,消化其中的内容,然后编写一个符合规范的 is_valid_email 函数。

除非你对理解电子邮件地址格式的细微差别有着浓厚的兴趣,否则我建议你先退一步:它相当混乱。混乱到甚至连 HTML 规范 都故意与我们刚刚链接的 RFC 不兼容

我们最好的办法是寻找一个长期深入研究过这个问题的现有库, 以便为我们提供即插即用的解决方案。幸运的是, Rust 生态系统中至少有一个这样的库 —— validator crate

基于属性的测试

我们可以使用另一种方法来测试我们的解析逻辑:与其验证特定输入集是否被正确解析,不如构建一个随机生成器来生成有效值,并检查我们的解析器是否不会拒绝这些值。

换句话说,我们验证我们的实现是否具有某个属性——“不会拒绝任何有效的电子邮件地址”。

这种方法通常被称为基于属性的测试。

例如,如果我们使用时间,我们可以反复采样三个随机整数:

  • H,介于 0 到 23 之间(含)
  • M,介于 0 到 59 之间(含)
  • S,介于 0 到 59 之间(含)

并验证 H:M:S 始终能够被正确解析。

基于属性的测试显著扩大了我们验证的输入范围,从而增强了我们对代码正确性的信心,但它并不能证明我们的解析器是正确的——它并没有彻底探索输入空间(除了微小的输入空间)。

让我们看看我们的 SubscriberEmail 的属性测试是什么样的。

如何使用 fake 生成随机测试数据

首先,我们需要一个有效邮箱地址的随机生成器。 我们可以自己写一个,但这是一个引入 fake crate 的好机会。

fake 提供了原始数据类型(整数、浮点数、字符串)和高级对象(IP 地址、国家/地区代码等)的生成逻辑,特别是电子邮件!让我们将 fake 添加为我们项目的开发依赖项:

cargo add fake

让我们在新的测试中使用它

//! src/domain/subscriber_email.rs
#[cfg(test)]
mod tests {
    // We are importing the `SafeEmail` faker!
    // We also need the `Fake` trait to get access to the
    // `.fake` method on `SafeEmail`
    use claim::{assert_err, assert_ok};
    use fake::{faker::internet::en::SafeEmail, Fake};

    use crate::domain::SubscriberEmail;
    
    // [...]

    #[test]
    fn valid_emails_are_parsed_successfully() {
        let email = SafeEmail().fake();
        assert_ok!(SubscriberEmail::parse(email));
    }
}

每次运行测试套件时,SafeEmail().fake() 都会生成一个新的随机有效电子邮件地址,然后我们用它来测试我们的解析逻辑。

与硬编码的有效电子邮件地址相比,这已经是一个重大改进,但为了捕捉到边缘情况的问题,我们不得不多次运行测试套件。一个快速而粗略的解决方案是在测试中添加一个 for 循环,但同样,我们可以借此机会深入研究并探索一个围绕基于属性的测试而设计的测试crate。

quickcheck Vs proptest

Rust 生态系统中有两种主流的基于属性的测试方案: quickcheckproptest。 它们的领域有所重叠,但各自在各自的领域中都独树一帜——请查看它们的 README 文件,了解所有细节。

对于我们的项目,我们将使用 quickcheck ——它入门相当简单,而且不使用太多宏,从而带来愉悦的 IDE 体验。

quickcheck 入门

让我们看一下其中一个例子来了解它的工作原理:

/// This function we want to test
fn reverse<T: Clone>(xs: &[T]) -> Vec<T> {
    let mut rev = vec![];
    for x in xs.iter() {
        rev.insert(0, x.clone());
    }
    rev
}

#[cfg(test)]
mod tests {
    #[quickcheck_macros::quickcheck]
    fn prop(xs: Vec<u32>) -> bool {
        /// A property that is always true, regardless
        /// of the vector we are applying the function to:
        /// reversing it twice should return the original input.
        xs == reverse(&reverse(&xs))
    }
}

quickcheck 在一个可配置迭代次数(默认为 100)的循环中调用 prop: 每次迭代时,它会生成一个新的 Vec<u32> 并检查 prop 是否返回 true

如果 prop 返回 false,它会尝试将生成的输入压缩为尽可能小的失败样本(最短的失败向量),以帮助我们调试哪里出了问题。

在我们的例子中,我们希望实现如下代码:

#[quickcheck_macros::quickcheck]
fn valid_emails_are_parsed_successfully(valid_email: String) -> bool {
    SubscriberEmail::parse(valid_email).is_ok()
}

不幸的是,如果我们要求输入字符串类型,我们将会得到各种各样的垃圾数据,从而导致验证失败。

我们如何定制生成程序?

实现 Arbitrary Trait

让我们回到前面的例子——quickcheck 是如何知道生成 Vec<u32> 的?一切都建立在 quickcheckArbitrary trait之上:

pub trait Arbitrary: Clone + 'static {
    // Required method
    fn arbitrary(g: &mut Gen) -> Self;

    // Provided method
    fn shrink(&self) -> Box<dyn Iterator<Item = Self>> { ... }
}

我们有两个方法:

  • arbitrary:给定一个随机源 (g),返回该类型的一个实例;
  • shrink:返回一个逐渐“缩小”的该类型实例序列,以帮助 quickcheck

找到最小的可能失败情况。

Vec<u32> 实现了 Arbitrary 接口,因此 quickcheck 知道如何生成随机的 u32 向量。

我们需要创建自己的类型,我们称之为 ValidEmailFixture,并为其实现 Arbitrary 接口。

如果你查看 Arbitrary 的特征定义,你会注意到 shrinking 是可选的:有一个默认的实现(使用 empty_shrinker),它会导致 quickcheck 输出遇到的第一个失败,而不会尝试使其变得更小或更优。因此,我们只需要为我们的 ValidEmailFixture 提供一个 Arbitrary::arbitrary 的实现。

让我们将 quickcheckquickcheck-macros 都添加为开发依赖项:

cargo add quickcheck quickcheck-macros --dev

然后

//! src/domain/subscriber_email.rs
// [...]
#[cfg(test)]
mod tests {
    use claim::assert_err;
    use fake::locales::{self, Data};

    use crate::domain::SubscriberEmail;

    // Both `Clone` and `Debug are required by `quickcheck`
    #[derive(Debug, Clone)]
    struct ValidEmailFixture(pub String);

    impl quickcheck::Arbitrary for ValidEmailFixture {
        fn arbitrary(g: &mut quickcheck::Gen) -> Self {
            let username = g
                .choose(locales::EN::NAME_FIRST_NAME)
                .unwrap()
                .to_lowercase();
            let domain = g.choose(&["com", "net", "org"]).unwrap();
            let email = format!("{username}@example.{domain}");
            Self(email)
        }
    }

    // ...
}

这是一个令人惊叹的例子,它展现了通过在 Rust 生态系统中共享关键特性而获得的互操作性。

我们如何让 fake 和 quickcheck 完美地协同工作?

在 Arbitrary::arbitrary 中,我们以 g 作为输入,它是一个 G 类型的参数。

G 受特性边界 G: quickcheck::Gen 的约束,因此它必须实现 quickcheck 中的 Gen 特性,

其中 Gen 代表“生成器”。

注: 等等...你注意到了吗? 在新的 quickcheck crate 中 Gen不再是trait, 而是一个 struct, 前面这一段代码是我自己(译者)的解决方案, 并非原作, 解决方案来自 这个 GitHub issue

两个 crate 的维护者可能彼此了解,也可能不了解,但 rand-core 中一套社区认可的 trait 为我们提供了轻松的互操作性。太棒了!

现在您可以运行 cargo test domain 了——结果应该会是绿色,这再次证明了我们的邮箱验证机制 并没有过于死板。

如果您想查看生成的随机输入,请在测试中添加 dbg!(&valid_email.0);

语句,然后运行 ​​cargo test valid_emails -- --nocapture ——数十个有效的邮箱地址 应该会弹出到您的终端中!

Payload 验证

如果您运行 cargo test,而不将运行的测试集限制在域内,您将看到我们的集成测试包含无效数据,仍然是红色的。

---- subscribe_returns_a_200_when_fields_are_present_but_invalid stdout ----

thread 'subscribe_returns_a_200_when_fields_are_present_but_invalid' panicked at
 tests/health_check.rs:182:9:
assertion `left == right` failed: The API did not return a 400 OK when the paylo
ad was empty email.
  left: 400
 right: 200
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    subscribe_returns_a_200_when_fields_are_present_but_invalid

让我们将我们出色的 SubscriberEmail 集成到应用程序中,以便从我们的 /subscriptions 端点中的验证中受益。 我们需要从 NewSubscriber 开始:

//! src/domain/new_subscriber.rs
use crate::domain::{SubscriberEmail, SubscriberName};

pub struct NewSubscriber {
    // We are not using `String` anymore!
    pub email: SubscriberEmail,
    pub name: SubscriberName,
}

如果你现在尝试编译这个项目,那可就糟了。

我们先从 cargo check 报告的第一个错误开始:

  --> src/routes/subscriptions.rs:28:16
   |
28 |         email: form.0.email,
   |                ^^^^^^^^^^^^ expected `SubscriberEmail`, found `String`
   |

它指的是我们的请求处理程序中的一行, subscribe:

//! src/routes/subscriptions.rs
// [...]

pub async fn subscribe(form: web::Form<FormData>, pool: web::Data<PgPool>) -> HttpResponse {
    let name = match SubscriberName::parse(form.0.name) {
        Ok(name) => name,
        Err(_) => return HttpResponse::BadRequest().finish(),
    };
    let new_subscriber = NewSubscriber {
        email: form.0.email,
        name,
    };
    match insert_subscriber(&pool, &new_subscriber).await {
        Ok(_) => HttpResponse::Ok().finish(),
        Err(_) => HttpResponse::InternalServerError().finish(),
    }
}

我们需要模仿我们已经对 name 字段所做的事情: 首先我们解析 form.0.email, 然后我们将结果(如果成功)赋值给 NewSubscriber.email

//! src/routes/subscriptions.rs

// We added `SubscriberEmail`!
use crate::domain::{NewSubscriber, SubscriberEmail, SubscriberName};
// [...]

pub async fn subscribe(form: web::Form<FormData>, pool: web::Data<PgPool>) -> HttpResponse {
    let name = match SubscriberName::parse(form.0.name) {
        Ok(name) => name,
        Err(_) => return HttpResponse::BadRequest().finish(),
    };
    let email = match SubscriberEmail::parse(form.0.email) {
        Ok(email) => email,
        Err(_) => return HttpResponse::BadRequest().finish(),
    };
    let new_subscriber = NewSubscriber {
        email,
        name,
    };
    // [...]
}

是时候处理第二个错误了

  --> src/routes/subscriptions.rs:53:9
   |
53 |         new_subscriber.email,
   |         ^^^^^^^^^^^^^^
   |         |
   |         expected `&str`, found `SubscriberEmail`
   |         expected due to the type of this binding

error[E0277]: the trait bound `SubscriberEmail: sqlx::Encode<'_, Postgres>` is not satisfied

这是在我们的 insert_subscriber 函数中,我们执行 SQL INSERT 查询来存储新订阅者的详细信息:

//! src/routes/subscriptions.rs

// [...]

pub async fn insert_subscriber(pool: &PgPool, new_subscriber: &NewSubscriber) -> Result<(), sqlx::Error> {
    sqlx::query!(
        r#"
        INSERT INTO subscriptions (id, email, name, subscribed_at)
        VALUES ($1, $2, $3, $4)
        "#,
        Uuid::new_v4(),
        new_subscriber.email,
        new_subscriber.name.as_ref(),
        Utc::now()
    )
    .execute(pool)
    .await
    .inspect_err(|e| {
        tracing::error!("Failed to execute query: {:?}", e);
    })?;

    Ok(())
}

解决方案就在下面这行——我们只需要使用 AsRef<str> 的实现, 借用 SubscriberEmail 的内部字段作为字符串切片。

#[tracing::instrument(
    name = "Saving new subscriber details in the database",
    skip(new_subscriber, pool)
)]
pub async fn insert_subscriber(pool: &PgPool, new_subscriber: &NewSubscriber) -> Result<(), sqlx::Error> {
    sqlx::query!(
        r#"[...]"#,
        Uuid::new_v4(),
        new_subscriber.email.as_ref(),
        new_subscriber.name.as_ref(),
        Utc::now()
    )
    .execute(pool)
    .await
    .inspect_err(|e| {
        tracing::error!("Failed to execute query: {:?}", e);
    })?;

    Ok(())
}

就这样了——现在可以编译了!

我们的集成测试怎么样?

cargo test
running 4 tests
test subscribe_returns_a_400_when_data_is_missing ... ok
test health_check_works ... ok
test subscribe_returns_a_200_when_fields_are_present_but_invalid ... ok
test subscribe_returns_a_200_for_valid_form_data ... ok

全部通过! 我们做到了!

使用 TryFrom 重构

在继续之前,我们先花几段时间来重构一下刚刚写的代码。我指的是我们的请求处理程序 subscribe:

//! src/routes/subscriptions.rs
// [...]
pub async fn subscribe(form: web::Form<FormData>, pool: web::Data<PgPool>) -> HttpResponse {
    let name = match SubscriberName::parse(form.0.name) {
        Ok(name) => name,
        Err(_) => return HttpResponse::BadRequest().finish(),
    };
    let email = match SubscriberEmail::parse(form.0.email) {
        Ok(email) => email,
        Err(_) => return HttpResponse::BadRequest().finish(),
    };
    let new_subscriber = NewSubscriber {
        email,
        name,
    };
    match insert_subscriber(&pool, &new_subscriber).await {
        Ok(_) => HttpResponse::Ok().finish(),
        Err(_) => HttpResponse::InternalServerError().finish(),
    }
}

我们可以在 parse_subscriber 函数中提取前两个语句:

pub fn parse_subscriber(form: FormData) -> Result<NewSubscriber, String> {
    let name = SubscriberName::parse(form.name)?;
    let email = SubscriberEmail::parse(form.email)?;

    Ok(NewSubscriber { email, name })
}

#[tracing::instrument(/*[...]*/)]
pub async fn subscribe(form: web::Form<FormData>, pool: web::Data<PgPool>) -> HttpResponse {
    let new_subscriber = match parse_subscriber(form.into_inner()) {
        Ok(subscriber) => subscriber,
        Err(_) => return HttpResponse::BadRequest().finish(),
    };
    // [...]
}

重构使我们更加清晰地分离了关注点:

  • parse_subscriber 负责将我们的数据格式(从 HTML 表单收集的 URL 解码数据)转换为我们的领域模型(NewSubscriber)
  • subscribe 仍然负责生成对传入 HTTP 请求的 HTTP 响应。

Rust 标准库在其 std::convert 子模块中提供了一些处理转换的特性。AsRef 就是从这里来的!

那里有没有什么特性可以捕捉到我们试图用 parse_subscriber 做的事情?

AsRef 不太适合我们这里要处理的问题: 两种类型之间容易出错的转换, 它会消耗输入值。

我们需要看看 TryFrom:

pub trait TryFrom<T>: Sized {
    type Error;

    // Required method
    fn try_from(value: T) -> Result<Self, Self::Error>;
}

T 替换为 FormData, 将 Self 替换为 NewSubscriber, 将 Self::Error 替换为 String ——这就是 parse_subscriber 函数的签名!

我们来试试看:

//! src/routes/subscriptions.rs
// [...]

impl TryFrom<FormData> for NewSubscriber {
    type Error = String;

    fn try_from(value: FormData) -> Result<Self, Self::Error> {
        let name = SubscriberName::parse(form.name)?;
        let email = SubscriberEmail::parse(form.email)?;

        Ok(NewSubscriber { email, name })
    }
}

#[tracing::instrument(/*[...]*/)]
pub async fn subscribe(form: web::Form<FormData>, pool: web::Data<PgPool>) -> HttpResponse {
    let new_subscriber = match form.into_inner().try_into() {
        Ok(subscriber) => subscriber,
        Err(_) => return HttpResponse::BadRequest().finish(),
    };
    match insert_subscriber(&pool, &new_subscriber).await {
        Ok(_) => HttpResponse::Ok().finish(),
        Err(_) => HttpResponse::InternalServerError().finish(),
    }
}

} 我们实现了 TryFrom,但却调用了 .try_into? 这到底是怎么回事?

标准库中还有另一个转换 trait,叫做 TryInto:

pub trait TryInto<T>: Sized {
    type Error;

    // Required method
    fn try_into(self) -> Result<T, Self::Error>;
}

它的签名与 TryFrom 的签名相同——转换方向相反! 如果您提供 TryFrom 实现,您的类型将自动获得相应的 TryInto 实现,无需额外工作。

try_intoself 作为第一个参数,这使得我们可以执行 form.0.try_into() 而不是 NewSubscriber::try_from(form.0) ——如果您愿意,这完全取决于您的个人喜好。

总的来说,实现 TryFrom/TryInto 能给我们带来什么好处?

没什么特别的,也没有什么新功能——我们“只是”让我们的意图更清晰。

我们明确地说明了“这是一个类型转换!”。

这为什么重要?因为它能帮助别人!

当另一位熟悉 Rust 的开发人员进入我们的代码库时,他们会立即发现转换模式,因为我们使用的是他们已经熟悉的特性。

小结

验证 POST /subscriptions 有效负载中的电子邮件地址是否符合预期格式是件好事,但这还不够。

我们现在有一封语法上有效的电子邮件,但我们仍然不确定它是否存在: 真的有人使用这个电子邮件地址吗?它能被访问到吗? 我们一无所知,而且只有一种方法可以找到答案:发送一封真实的电子邮件。

确认电子邮件(以及如何编写 HTTP 客户端!)将是下一章的主题。

拒绝无效订阅者 第二部分