企业就绪的-Web-应用蓝图-全-

企业就绪的 Web 应用蓝图(全)

原文:zh.annas-archive.org/md5/7307753f02dcea646873ecfc8d055a9e

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

本书的目标是向您展示如何使用行业最佳实践开发一个网络应用程序,并将其放置在运行的生产环境中。我们将通过创建一个可工作的待办事项应用程序来实现这一点。这个应用程序在tozo.dev上运行,所有代码都可在github.com/pgjones/tozo下以 MIT 许可证获取。

本书中的开发蓝图是基于我在之前成功构建应用程序时使用的一个蓝图,包括几年前我自己的初创公司。这里使用的技术之所以被选择,是因为其在行业中的流行,Python、NodeJS 和 Terraform 是全栈开发中流行的工具,而 AWS 是流行的云基础设施提供商。

我开始写这本书是为了成为我希望在我开始全栈工程师职业生涯时拥有的指南。我试图回答我在开始时的大部分问题,并介绍我所缺少的大部分词汇。在过去的一年里,我一直在完善和使用这个蓝图来指导和发展初级工程师在他们的第一份工业工作。我希望它也能帮助你构建出色的应用程序!

本书面向的对象

本书面向的是已经知道如何编程的软件工程师(例如,计算机科学或训练营的毕业生),他们想学习如何按照行业流程构建应用程序(例如,使用持续集成和部署)。

你需要掌握 TypeScript/JavaScript、Python、HTML、CSS 和 SQL 的实际知识。除此之外,你被期望对 Quart、React、AWS 以及书中介绍的所有其他特定技术和流程都是新手。

本书涵盖的内容

第一章《设置我们的开发系统》中,我们将设置开发应用程序所需的一切。这包括安装 Python、Node.js、Terraform 和 Git,以及每个相关工具。

第二章《使用 Quart 创建可重用后端》中,我们将构建一个可用于任何应用程序的后端,介绍诸如身份验证、保护、数据库连接和电子邮件等元素。

第三章《构建 API》中,我们将构建一个包含成员和会话管理的待办事项跟踪 RESTful CRUD API。

第四章《使用 React 创建可重用前端》中,我们将构建一个可用于任何应用程序的前端,同时讨论路由、样式化数据输入(表单)、状态管理和吐司反馈。

第五章《构建单页应用程序》中,我们将通过创建允许用户注册和登录到我们的应用程序、更改和管理他们密码的页面来构建一个待办事项跟踪用户界面。

第六章“部署和监控您的应用程序”中,我们将部署应用程序到在 Docker 中运行的 AWS。在这里,我们将讨论如何设置域名、使用 HTTPS 以及监控应用程序的错误。

第七章“保护和应用打包”中,我们将采用行业最佳实践来保护应用程序并将其打包到应用商店,包括添加多因素认证以及满足成为渐进式 Web 应用程序的要求。

为了充分利用本书

您需要能够阅读和理解基本的 Python、TypeScript、HTML 和 SQL。其他所有内容将在书中介绍。

图片

所有安装说明都包含在书中。

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

下载示例代码文件

您可以从 GitHub 下载本书的示例代码文件github.com/pgjones/tozo。如果代码有更新,它将在 GitHub 仓库中更新。

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

下载彩色图像

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

访问 Code in Action 视频

您可以在此处找到本书的 CiA 视频:bit.ly/3PBCd6r

使用的约定

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

文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“reminderName字符串是一个字符串,它唯一地标识了在上下文粒度范围内的提醒。”

代码块应如下设置:

class APIError(Exception):
    def __init__(self, status_code: int, code: str) -> None:
        self.status_code = status_code
        self.code = code

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

public interface IHotelGrain : IGrainWithStringKey
    {
        <<Code removed for brevity>>
        public Task Subscribe(IObserver observer);
        public Task UnSubscribe(IObserver observer);

任何命令行输入或输出都应如下编写:

az monitor app-insights component create --app ai-distel-prod --location westus  --resource-group rg-distel-prod

粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“客户端系统将消息批次发送到分发谷物,它遍历消息批次以将消息分发给每个目标谷物。”

小贴士或重要提示

看起来像这样。

联系我们

我们始终欢迎读者的反馈。

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

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

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

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

分享您的想法

一旦您阅读了《生产就绪型 Web 应用程序蓝图》,我们非常乐意听取您的想法!请点击此处直接访问亚马逊评论页面并分享您的反馈。

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

第一部分 设置我们的系统

在我们构建我们的应用程序之前,我们需要一个为快速开发而设置的系统。这意味着我们需要安装工具来自动格式化、检查和测试我们的代码,同时使用 Git 进行版本控制和 Terraform 来管理基础设施。

本部分包括以下章节:

  • 第一章**,设置我们的开发系统

第一章:为开发设置我们的系统

本书的目标是提供一个在生产环境中运行的 Web 应用程序的蓝图,并尽可能多地利用工业最佳实践。为此,我们将构建一个可工作的待办事项应用程序,代号为 Tozo,允许用户跟踪任务列表。您可以在图 1.1中看到完成的应用程序:

图 1.1:本书中将构建的待办事项应用

图 1.1:本书中将构建的待办事项应用

虽然目标是构建一个可工作的待办事项应用程序,但我们将关注对任何应用程序都实用的功能,其中许多功能和许多技术都与这里构建的应用程序相同。例如,用户需要登录、更改密码等。因此,我的希望是您能够使用这个蓝图,移除少量特定的待办事项代码,并构建您自己的应用程序。

在本章中,我们将从一个没有任何工具的新机器开始,为开发设置它。我们还将设置系统来自动开发和测试应用程序。具体来说,我们将安装一个系统包管理器,并使用它来安装各种语言运行时和工具,然后在设置远程仓库和激活持续集成之前。到本章结束时,您将拥有开发应用程序所需的一切,这意味着您将能够专注于开发应用程序。这意味着您将能够快速构建和测试您应用程序中用户所需的功能。

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

  • 旨在快速开发

  • 设置我们的系统

  • 为后端开发安装 Python

  • 为前端开发安装 NodeJS

  • 为基础设施开发安装 Terraform

  • 为数据库开发安装 PostgreSQL

  • 采用基于 GitHub 的协作开发流程

技术要求

我已经构建了本书中描述的应用程序,您可以通过以下链接访问它:tozo.dev。代码也可在github.com/pgjones/tozo找到(请随意使用该代码或本书中的代码,MIT 许可证下使用)。

我将假设您对 TypeScript 和 Python 有一定的了解,因为我们将使用这些语言来编写应用程序。然而,我们将避免使用任何晦涩难懂的语言特性,并希望代码易于理解。我还将假设您乐于使用命令行,而不是专注于图形用户界面指令,因为大多数工具都针对命令行使用进行了优化,这应该是有利的。

要跟随本章的开发,请使用配套仓库github.com/pgjones/tozo,并查看r1-ch1-startr1-ch1-end标签之间的提交。

旨在快速开发

在我们开始设置构建待办事项应用程序的系统之前,了解我们在构建任何应用程序时的目标是很重要的,即通过尽可能快地交付解决方案来解决客户的需求。这意味着我们必须了解他们的需求,将它们转化为可工作的代码,并且至关重要,有信心地部署解决方案。

当我们在开发一个应用程序时,代码更改与能够运行并看到更改效果之间的时间越短,越好。这就是为什么我们将所有代码本地运行,并启用自动重新加载;这意味着我们做出的任何更改都可以在几秒钟内通过本地浏览器进行测试。

热重载/自动重新加载

在开发过程中,我们理想情况下希望我们对代码所做的任何更改都能立即生效,以便我们可以检查这些更改是否产生了预期的效果。这个功能被称为热重载或自动重新加载,并且在我们这本书中使用的 React 和 Quart 开发服务器中是激活的。

我还喜欢使用工具来帮助加快开发速度并增强代码按预期工作的信心。这些工具应该尽可能频繁地运行,理想情况下作为自动化过程的一部分。我已经将这些工具分为自动格式化、代码检查和测试类别。

代码自动格式化

代码的格式和风格很重要,因为与您习惯的风格不同,您需要更长的时间来理解。这意味着您将花费更多的时间来理解风格而不是逻辑,这将导致更多的错误。此外,虽然您可以保持一致,但几乎每个人都有不同的首选风格,我发现这些偏好会随着时间的推移而改变。

在过去,我使用工具来检查样式并报告任何不一致性。这很有帮助,但也很浪费,因为每个不一致性都必须手动修复。幸运的是,现在大多数语言都有官方的或占主导地位的自动格式化工具,它既定义了风格,又将所有代码更改为匹配该风格。使用最流行的自动格式化工具意味着大多数开发人员都会认识你的代码。

我们的目标是设置我们的工具,以便尽可能多的代码都有自动格式化工具。

代码检查

我认为代码检查可以分为两部分:类型检查和静态分析。类型检查要求我们在编写代码时包含类型。我尽可能使用类型提示或类型化语言,因为这可以捕捉到我通常犯的大量错误。类型化还有助于记录代码,这意味着它清楚地说明了预期的对象(类型)。虽然类型化在编写时需要更多的努力,但我认为它很容易在避免错误方面得到回报。因此,检查类型应该是我们代码检查的第一个目标。

第二部分,静态分析,允许代码检查器查找命名、函数使用、可能的错误、安全问题以及未使用代码中的潜在问题,并标记过于复杂或构建不良的代码。这些代码检查器是一种非常低成本的理智检查,因为它们运行快速且简单,并且很少出现误报(假阳性)。

测试代码

虽然代码检查器可以识别代码中的错误和问题,但它无法检测到逻辑问题,即正确编写的代码却做了错误的事情。为了识别这些问题,我们需要编写测试来检查代码的执行结果是否产生预期的输出。因此,我们在编写代码时编写测试非常重要,尤其是在我们发现错误时。我们将专注于编写提供简单测试应用程序是否按预期工作的测试。

测试覆盖率

测试覆盖率用于衡量测试套件测试了多少代码。这通常是通过测量测试执行的代码行数与总代码行数的比率来完成的。我发现这个指标没有帮助,因为它关注的是执行的行数,而不是对用户有意义的用例。因此,我鼓励您关注测试您认为用户需要的用例。然而,如果您想以这种方式测量覆盖率,您可以使用 pdm 安装 pytest-cov

使用自动格式化工具、代码检查器和测试套件,我们可以更有信心地开发,从而提高开发速度,这反过来意味着为我们的用户提供更好的体验。然而,为了使用这些工具,我们首先需要有效地设置我们的系统。

设置我们的系统

为了有效地开发我们的应用程序,我们需要能够开发和运行它。这意味着我们需要工具来管理代码更改、测试和检查应用程序的错误以及运行它。这些工具可以通过系统包管理器安装,具体取决于您的操作系统。我建议您在 Linux (https://linuxbrew.sh) 和 macOS (https://brew.sh) 上安装 Homebrew,或在 Windows 上安装 Scoop (scoop.sh)。在这本书中,我将展示 brewscoop 命令,但您应该只使用适用于您操作系统的命令。

您还需要一个代码编辑器来编写代码,以及一个浏览器来运行应用程序。我建议您按照他们网站上的说明安装 VS Code (code.visualstudio.com) 和 Chrome (www.google.com/chrome)。安装了这些工具后,我们现在可以考虑如何管理代码。

管理代码

在我们开发应用程序的过程中,我们不可避免地会犯错误并希望回到之前的工作版本。你也可能希望与他人共享代码,或者只是为自己保留一个备份。这就是为什么我们需要通过版本控制系统来管理代码。虽然有许多不同的版本控制系统,但这个行业的大多数都使用 git (git-scm.com)。可以通过以下方式通过系统包管理器安装:

brew install git
scoop install git

使用 git

这本书可以通过使用git add将文件添加到仓库,git commit创建提交,以及git push更新远程仓库来完成。我认为这些是基本的 git 命令。然而,git 的使用仍然可能非常复杂,你可能会发现你的仓库处于混乱状态。随着实践的增加,这会变得更容易,而且网上有大量的帮助。你总是可以删除你的本地仓库,并从远程版本重新开始(就像我以前多次做的那样)。

现在我们已经安装了 git,让我们设置以下作者信息:

git config --global user.name "Phil Jones"
git config --global user.email "pgjones@tozo.dev"

应该将高亮显示的值更改为你的姓名和电子邮件地址。

接下来,我们可以在名为tozo的目录中创建一个代码仓库,并在其中运行以下命令:

git init .

这将创建一个可以安全忽略的.git目录。这导致以下项目结构:

tozo
└── .git

在开发过程中,我们可能希望 git 忽略某些文件和路径。我们将通过创建列出我们不希望包含在仓库中的文件名和文件路径的.gitignore文件来实现这一点。

编写好的提交信息

Git 存储的变更历史如果使用得当,可以成为你代码的优秀伴侣文档。这可能在开始时看起来没有优势,但经过一年的开发后,如果你没有从一开始就做这件事,你会非常怀念它。因此,我强烈建议你编写良好的提交信息。

一个好的提交包含对代码的单个原子更改。这意味着它是集中的(不会将不同的更改组合到一个提交中)并且是完整的(每个提交都使代码处于工作状态)。

一个好的提交信息也是经过良好描述和推理的。这意味着提交信息解释了为什么进行更改。这种上下文信息非常有价值,因为它很快就会被遗忘,并且通常需要理解代码。

在安装了 git 之后,我们可以开始提交更改;然而,我们应该确定我们打算如何合并更改,在我看来,这应该通过变基来完成。

相比合并,使用变基

由于我非常重视 git 提交历史,我建议在合并更改时使用变基而不是合并。前者会将本地新提交放在任何远程更改之上,重写并留下一个线性清晰的历史记录,而后者将引入一个合并提交。要执行此更改,请运行以下代码:

git config --global pull.rebase true

我们现在已经使用包管理器和版本控制设置了我们的系统。接下来,我们可以安装应用程序各个方面的特定工具。

安装 Python 用于后端开发

有许多适合后端开发的编程语言,任何一种都是你应用程序的一个很好的选择。在这本书中,我选择使用 Python,因为我发现与其他语言相比,Python 的代码更易于理解和跟踪。

由于我们将使用 Python 编写应用程序的后端,因此我们需要在本地安装它。虽然你可能已经安装了 Python 版本,但我建议你使用系统包管理器安装的版本,如下所示:

brew install python
scoop install python

我们之前使用的包管理器不知道如何安装和管理 Python 包,因此我们还需要另一个包管理器。Python 中有很多选择,我认为 PDM 是最好的。PDM 可以在 Linux 和 macOS 系统上使用系统包管理器进行安装,如下所示:

brew install pdm

对于 Windows 系统,可以通过运行以下命令进行安装:

scoop bucket add frostming https://github.com/frostming/scoop-frostming.git
scoop install pdm

我们将把后端代码保存在一个单独的后端文件夹中,所以请在项目的顶层创建一个 backend 文件夹,并按照以下文件夹结构进行组织:

tozo
└── backend
    ├── src
    │   └── backend
    └── tests

接下来,我们需要通知 git 有一些我们不希望在存储库中跟踪的文件,因此它应该通过添加以下内容到 backend/.gitignore 来忽略它们:

/__pypackages__
/.mypy_cache
.pdm.toml
.pytest_cache
.venv
*.pyc

为了让 PDM 管理我们的项目,我们需要在 backend 目录中运行以下命令:

pdm init

当提示时,你应该选择之前使用系统包管理器安装的 Python 版本。

我们现在可以专注于特定于 Python 的快速开发工具。

格式化代码

Python 没有官方的格式或格式化器;然而,black 是代码的事实上的格式化器,isort 是导入的事实上的格式化器。我们可以通过在 backend 目录中运行以下命令将两者添加到我们的项目中:

pdm add --dev black isort

开发标志

我们在这里使用 --dev 标志,因为这些工具仅用于后端开发,因此不需要在生产环境中安装。

blackisort 需要以下配置才能良好地协同工作。这应该添加到 backend/pyproject.toml 文件末尾(如果你使用的是除 3.10 之外版本的 Python,你可能需要更改 target-version),如下所示:

[tool.black]  
target-version = ["py310"] 
[tool.isort]
profile = "black"

以下命令将在 srctests 文件夹中的我们的代码上运行 blackisort

pdm run black src tests
pdm run isort src tests

我们将使用 Jinja 模板来处理我们应用程序发送的电子邮件。虽然这些模板是代码,但它们不是 Python,因此需要不同的格式化器。幸运的是,djhtml 可以用来格式化模板,并且可以通过在 backend 文件夹中运行以下命令来添加:

pdm add --dev djhtml

以下命令将在我们的模板代码上运行 djhtml

djhtml src/backend/templates --tabwidth 2 --check

我们现在已经安装了格式化后端代码所需的工具。接下来,我们可以安装用于代码检查所需的工具。

对代码进行代码检查

Python 支持类型提示,用于描述变量、函数等的预期类型。我们将使用类型提示和工具来检查我们没有引入任何类型相关的错误。Python 最受欢迎的类型检查工具是mypy。可以在*backend*目录下运行以下命令来安装:

pdm add --dev mypy

以下命令将在后端代码上运行mypy

pdm run mypy src/backend/ tests/

mypy帮助我们查找类型错误的同时,我们可以添加Flake8来帮助我们查找其他错误。Flake8可以通过以下方式使用pdm安装:

pdm add --dev flake8

Flake8必须配置为与blackmypy一起工作,通过在*backend/setup.cfg*中添加以下内容:

[flake8] 
max-line-length = 88
extend-ignore = E203

Flake8可以通过运行以下命令来使用:

pdm run flake8 src/ tests/

我们可以使用工具来帮助我们发现另一种类型的错误,这些错误与安全相关。一个很好的例子是检查 SQL 注入漏洞。Bandit 是另一个可以帮助识别这些错误的 linter,并且可以在*backend*目录下运行以下命令来安装:

pdm add --dev bandit

Bandit 只需要对src代码进行 lint,因为在生产期间不会运行测试代码。要运行 Bandit 对src代码进行 lint,可以使用以下命令:

pdm run bandit -r src/

Bandit 模块未找到错误

如果 Bandit 运行失败并出现错误ModuleNotFoundError: No module named ‘pkg_resources’,则运行pdm add --dev setuptools以添加缺失的模块。

现在我们有了查找错误的工具,但我们也可以添加工具来查找未使用的代码。这很有帮助,因为在重构过程中,代码往往会被遗忘,导致文件比应有的复杂得多,难以阅读和理解。我喜欢使用vulture来查找未使用的代码,并且可以在*backend*目录下运行以下命令来安装:

pdm add --dev vulture

不幸的是,vulture可能会报告误报,所以我喜欢通过在*backend/pyproject.toml*中添加以下配置来配置它,以确保报告问题时 100%有信心:

[tool.vulture]
min_confidence = 100

与 Bandit 一样,最好只通过以下命令在src代码上运行vulture(而不是测试):

pdm run vulture src/

现在,让我们看看我们需要测试代码的内容。

测试代码

Python 的unittest是其标准库的一部分,然而,我认为使用pytest更好。pytest功能丰富,允许编写非常简单和清晰的测试,例如以下小型示例,它测试简单的加法是否正确:

def test_addition():
    assert 1 + 1 == 2

pytest需要pytest-asyncio插件来测试异步代码,并且它们都可以通过以下方式使用pdm安装:

pdm add --dev pytest pytest-asyncio 

pytest最好配置为在测试失败时显示局部变量,因为这使理解测试失败的原因变得容易得多。此外,asyncio模式应设置为auto,以便更容易编写异步测试。以下配置应放置在*backend/pyproject.toml*中:

[tool.pytest.ini_options]
addopts = "--showlocals"
asyncio_mode = "auto"
pythonpath = ["src"]

要运行测试,可以使用以下命令调用pytest,指定tests路径:

pdm run pytest tests

现在我们已经安装了所有工具,我们需要一些简单的命令来运行它们。

脚本命令

我们在我们的项目中添加了许多有用的工具;然而,每个工具都有不同的独特命令,我们不得不记住。我们可以通过利用 PDM 的脚本功能来简化这一点,因为它可以将 PDM 命令映射到所需的命令。我们将添加以下三个 PDM 脚本命令:

  • pdm run format来运行格式化工具并格式化代码

  • pdm run lint来运行 linting 工具并检查代码

  • pdm run test来运行测试

PDM 的脚本需要将这些脚本命令添加到backend/pyproject.toml文件中,如下所示:

[tool.pdm.scripts]
format-black = "black src/ tests/"
format-djhtml = "djhtml src/backend/templates -t 2 --in-place"
format-isort = "isort src tests"
format = {composite = ["format-black", "format-djhtml", "format-isort"]}
lint-bandit = "bandit -r src/"
lint-black = "black --check --diff src/ tests/"
lint-djhtml = "djhtml src/backend/templates -t 2 --check"
lint-flake8 = "flake8 src/ tests/"
lint-isort = "isort --check --diff src tests"
lint-mypy = "mypy src/backend tests"
lint-vulture = "vulture src"
lint = {composite = ["lint-bandit", "lint-black", "lint-djhtml", "lint-flake8", "lint-isort", "lint-mypy", "lint-vulture"]}
test = "pytest tests/"

在后端工具就绪且可通过易于记忆的命令访问后,我们现在可以为前端做同样的事情。

安装 NodeJS 进行前端开发

由于我们希望我们的应用程序在浏览器中运行,我们需要用 JavaScript 或编译成它的语言编写前端。有很多好的选择,但我选择使用TypeScript,因为它是在 JavaScript 的基础上增加了类型(即,它是同一种基本语言)。这意味着它与所需的运行时语言非常接近,并且具有来自类型的安全性和文档。

由于我们将使用 TypeScript 编写前端,我们需要安装NodeJS来编译 TypeScript 到在浏览器中运行的 JavaScript。NodeJS 最好通过系统包管理器安装,如下所示:

brew install node
scoop install nodejs

与 Python 不同,我们安装了特定的包管理器,NodeJS 自带了一个名为npm的包管理器。我们将使用npm来管理前端依赖和工具。npm还包括npx工具,我们将用它来运行一次性脚本。

与后端一样,我们将前端代码分离到一个前端文件夹中。然后,我们将在这个新文件夹中使用create-react-app工具,通过在项目目录中运行以下命令来设置一切:

npx create-react-app frontend --template typescript

它应该给出以下文件夹结构:

tozo
└── frontend
    ├── node_modules
    ├── public
    └── src

在安装的文件中,目前只有frontend/package.jsonfrontend/package-lock.jsonfrontend/tsconfig.jsonfrontend/.gitignorefrontend/src/react-app-env.d.tsfrontend/public/index.xhtml文件是重要的,因此您可以删除或根据需要修改其他文件。

现在,我们可以专注于特定的 NodeJS 工具,以实现快速开发。

格式化代码

TypeScript 没有官方的格式/格式化工具;然而,Prettier 是事实上的格式化工具。我们应该通过在frontend目录中运行以下命令将 Prettier 添加到项目中作为开发依赖项:

npm install --save-dev prettier

--save-dev 标志

我们在这里使用--save-dev标志,因为这些工具仅用于开发前端,因此不需要在生产环境中安装。

默认情况下,Prettier 不会添加尾随逗号,这与 Python 中使用的样式不同。为了保持一致,因此不必考虑这一点,可以在frontend/package.json中添加以下部分进行配置:

"prettier": {
  "trailingComma": "all" 
}

然后以下命令将运行 Prettier 来处理我们的代码:

npx prettier --parser typescript --write "src/**/*.{ts,tsx}"

我们现在已经安装了格式化代码的工具,我们可以专注于安装检查代码的工具。

检查代码

在前面的章节中,我们需要一个代码检查器来检查我们的 Python 代码,然而,由于我们使用 TypeScript,我们不需要安装任何额外的工具来进行类型检查。但是,我们可以安装代码检查器来检查其他错误;TypeScript 和 JavaScript 的事实上的代码检查器是eslint,我们可以在前端目录中运行以下命令来安装它:

npm install --save-dev eslint 

默认情况下,eslint与 Prettier 不兼容;幸运的是,eslint-config-prettier包配置了eslint以实现兼容。我们可以在前端目录中运行以下命令来安装它:

npm install --save-dev eslint-config-prettier

与后端一样,我们应该使用eslint-plugin-import来排序我们的导入,该插件是通过以下方式与npm一起安装的:

npm install --save-dev eslint-plugin-import

这些代码检查器通过在frontend/package.json中用以下内容替换现有的eslintConfig部分来进行配置:

"eslintConfig": {
  "extends": [
    "react-app",
    "react-app/jest",
    "plugin:import/errors",
    "plugin:import/warnings",
    "plugin:import/typescript",
    "prettier"
  ]
}

突出的行将已经存在。

可以通过以下命令在我们的代码上运行eslint

npx eslint "src/**/*.{ts,tsx}"

eslint也可以通过使用--fix标志来修复它识别的一些问题,如下所示:

npx eslint --fix "src/**/*.{ts,tsx}"

我们现在已经安装了代码检查工具,我们可以专注于测试工具。

测试代码

之前使用的create-react-app工具还安装了一个名为 Jest 的测试运行器,我们可以通过运行以下命令来调用它:

npm run test

Jest 允许使用expect语法编写测试,如下面的示例所示:

test('addition', () => {
  expect(1 + 1).toBe(2);
});

在测试工具存在的情况下,我们可以专注于分析构建的包。

分析包

前端代码将以包(块)的形式交付给用户。这些包,尤其是主包,应该尽可能小,这样用户就不会等待太长时间下载代码。为了检查包大小并分析每个包中包含的内容,我在前端目录中运行以下命令使用source-map-explorer

npm install --save-dev source-map-explorer

在我们能够分析包大小之前,我们首先需要通过运行以下命令来构建它们:

npm run build

然后,我们可以通过以下命令来分析它们:

npx source-map-explorer build/static/js/*.js

前面命令的输出显示在图 1.2中:

图 1.2:source-map-explorer 的输出显示主包为 141 KB

图 1.2:source-map-explorer 的输出显示主包为 141 KB

每个包都应该尽可能小,一个很好的经验法则是当包超过 1 MB 时应该进行拆分。当我们将在第四章中添加密码复杂度分析器到前端时,我们会发现我们需要这样做,使用 React 创建可重用前端

编写命令脚本

为了与后端匹配,我们想要添加以下命令:

  • 使用npm run analyze来运行包分析器

  • 使用npm run format来运行格式化工具并格式化代码

  • 使用npm run lint来运行代码检查工具

  • 使用npm run test来运行测试

由于 npm run test 已经存在,我们只需要添加其他三个。这可以通过在 frontend/package.json 文件的 scripts 部分添加以下内容来完成:

"scripts": {
  "analyze": "npm run build && source-map-explorer \"build/static/js/*.js\"",
  "format": "eslint --fix \"src/**/*.{ts,tsx}\" && prettier --parser typescript --write \"src/**/*.{ts,tsx}\"",
  "lint": " eslint \"src/**/*.{ts,tsx}\" && prettier --parser typescript --list-different  \"src/**/*.{ts,tsx}\"",
  "start": "react-scripts start",
  "build": "react-scripts build",
  "test": "react-scripts test",
  "eject": "react-scripts eject"
}

突出的行将已经存在于该部分中。

在前端工具就绪并可通过易于记忆的命令访问后,我们现在可以为基础设施做同样的事情。

安装 Terraform 进行基础设施开发

我们需要创建和管理远程基础设施,从我们将用于与其他开发者一起开发应用程序或简单地备份我们的代码的远程仓库开始。这个远程基础设施可以通过手动创建,例如使用 GitHub 的 Web 界面。然而,通过使用基础设施即代码工具,我们可以记录我们所做的所有更改,然后如果出现问题,我们可以重新运行我们的代码并将一切恢复到已知状态。

我认为 Terraform 是管理基础设施的最佳工具,我们可以按照以下方式安装:

brew install terraform
scoop install terraform

在安装了 Terraform 后,我们可以在我们的仓库中创建一个用于基础设施代码的文件夹,如下所示:

mkdir infrastructure

我们的项目仓库现在应该具有以下结构:

tozo
├── backend
├── frontend
└── infrastructure

与后端和前端一样,我们需要安装工具来帮助开发。此外,对于基础设施,我们还需要工具来管理机密。

管理机密

要允许 Terraform 管理我们的基础设施,我们需要提供密码、密钥和其他机密信息。这些机密信息需要以安全的方式存储(并使用)——简单地在仓库中以纯文本形式存储密码是常见的被黑客攻击的方式。我们将加密这些机密信息并将加密文件存储在仓库中。这意味着我们必须保密加密密钥,我建议您使用像 BitWarden 这样的密码管理器来做到这一点。

要加密机密信息,我们可以使用 ansible-vault,它是通过 Python 包管理器 pip 安装的,如下所示:

pip install ansible-vault

pip 或 PDM

pip 是安装包的工具,而 PDM 是项目管理工具。由于我们没有基础设施项目要管理,使用 pip 安装 ansible-vault 更有意义。然而,这将是唯一一次我们直接使用 pip。

要配置 ansible-vault,我们需要提供加密密钥。为此,将您的加密密钥添加到 infrastructure/.ansible-vault 文件中,并通过在 infrastructure/ansible.cfg 文件中添加以下内容来通知 Ansible 它已存储在那里:

[defaults]
vault_password_file = .ansible-vault

我们需要加密两个文件:Terraform 的状态文件 terraform.tfstate 和我们的机密变量集合 secrets.auto.tfvars。执行此操作的命令如下:

ansible-vault encrypt secrets.auto.tfvars --output=secrets.auto.tfvars.vault 
ansible-vault encrypt terraform.tfstate --output=terraform.tfstate.vault

我们还需要解密这些文件,可以通过以下命令完成:

ansible-vault decrypt secrets.auto.tfvars.vault --output=secrets.auto.tfvars 
ansible-vault decrypt terraform.tfstate.vault --output=terraform.tfstate

为了确保密码文件、加密文件以及 Terraform 自动生成的通用文件不被视为仓库的一部分,应该在 infrastructure/.gitignore 文件中添加以下内容:

.ansible-vault 
secrets.auto.tfvars
terraform.tfstate 
*.backup 
.terraform.lock.hcl 
.terraform/

Terraform 现在已设置好并准备好使用,这意味着我们可以专注于开发工具。

格式化、代码检查和测试代码

Terraform 内置了一个格式化工具,可以通过以下命令调用:

terraform fmt

此格式化工具还支持在代码检查时使用的检查模式,如下所示:

terraform fmt --check=true

Terraform 还提供了一个用于代码检查的工具,如下所示:

terraform validate

测试 Terraform 代码比较困难,因为几乎所有代码都依赖于与第三方服务的交互。相反,我发现运行并检查输出是否有意义是测试代码将做什么的唯一方法。Terraform 将通过运行以下命令提供它计划执行的操作的输出:

terraform plan

这是我们需要安装和设置来管理本书中将要安装的所有基础设施的全部内容。我们现在可以专注于数据库。

安装 PostgreSQL 用于数据库开发

我们的应用程序需要以结构化形式存储数据(待办事项),这使得数据库成为一个理想的选择。此数据库需要在本地上运行,以便我们可以与之一起开发,因此我们需要安装它。我首选的数据库是 PostgreSQL,这是一个基于 SQL 的关系型数据库。我之所以喜欢它,是因为它非常广泛地受到支持,并且非常强大。

PostgreSQL 是通过以下方式使用系统包管理器安装的:

brew install postgres
scoop install postgresql

如果使用 brew,你可能需要将 postgresql 作为在后台运行的服务启动,如下所示:

brew services start postgresql

此外,当使用 brew 时,我们需要创建一个超级用户,按照惯例称为 postgres。此用户是通过以下命令创建的:

createuser -s postgres

然而,使用 scoop,你将需要使用以下命令在需要使用 PostgreSQL 时启动数据库:

pg_ctl start

随着数据库工具的添加,我们拥有了所有本地工具来开发我们的应用程序。这意味着我们可以专注于远程工具,即 GitHub 仓库。

使用 GitHub 采用协作开发流程

虽然你可能正在独立工作,但采用允许他人协作并确保代码始终准备好部署到生产环境的发展流程是一种良好的做法。我们将通过使用远程仓库和持续集成CI)来实现这两个目标。

远程仓库充当所有代码的备份,并使得设置 CI(测试、代码检查等)变得容易得多。我们将使用 GitHub,因为它拥有我需要的所有功能,尽管其他平台,如 GitLab,也是有效的,并且在行业中广泛使用。

我们不是通过 GitHub 的 UI 创建存储库,而是使用之前设置的 Terraform。为此,我们首先需要一个来自 GitHub 的个人访问令牌,如docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token中所述。此令牌需要 repoworkflowdelete_repo 权限。此令牌是秘密的,因此最好放在 infrastructure/secrets.auto.tfvars 中,并按照之前在 管理秘密 部分中描述的方式加密。代码应按以下方式放置到 infrastructure/secrets.auto.tfvars 中(将 abc1234 替换为您的令牌):

github_token = "abc1234"

Terraform 本身不知道如何与 GitHub 交互,这意味着我们需要安装 GitHub 提供程序来完成此操作。这是通过将以下代码添加到 infrastructure/main.tf 来完成的:

terraform {
  required_providers {
    github = {
      source  = "integrations/github"
      version = "~> 4.0"
    }
  }
  required_version = ">=1.0"
}

提供程序存在后,我们可以通过添加以下代码到 infrastructure/github.tf 来描述我们希望存在的存储库:

variable "github_token" {
  sensitive = true
}

provider "github" {
  token = var.github_token
}

resource "github_repository" "tozo" {
  name       = "tozo"
  visibility = "private"
}

最后,为了实际创建存储库,我们需要初始化并应用 Terraform,如下所示:

terraform init 
terraform apply

我们现在应该设置 git,使其了解远程存储库。为此,我们需要正确的路径,这取决于您的 GitHub 账户名称和项目名称。由于我的 GitHub 账户名称是 pgjones 且项目名称为 tozo,路径是 pgjones/tozo,因此以下命令:

git remote add origin git@github.com:pgjones/tozo.git

要使我们的本地分支跟踪远程 origin main 分支,请运行以下命令:

git push --set-upstream origin main 

要将我们的 main 分支上的本地更改推送到远程 feature 分支,请运行以下命令:

git push origin main:feature

要将远程main分支拉取以更新我们的本地分支,请运行以下命令:

git pull origin main

这个行业中的大多数都运行基于合并(拉取)请求的开发工作流程,我们也将采用。此工作流程包括以下步骤:

  1. 在本地开发一个由尽可能少的提交组成的功能(小的更改)。

  2. 将功能推送到远程 feature 分支。

  3. 基于该分支打开合并请求。

  4. 审查合并请求,只有 CI 通过时才将其合并到 main 分支。

  5. 拉取最新的main分支并重复。

存储库创建后,我们现在可以查看添加 CI。

添加持续集成

GitHub 提供了一个名为 Actions 的 CI 系统,它有一个免费层,我们将使用它。首先,我们需要创建以下文件夹结构:

tozo
└── .github
    └── workflows

现在我们可以配置一个工作流程,在 main 分支的每次更改和每个合并请求上运行作业,通过将以下代码添加到 .github/workflows/ci.yml

name: CI

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]
  workflow_dispatch:

jobs:

这使我们能够为基础设施、后端和前端添加工作。

为基础设施代码添加 CI

我们之前设置了以下命令来格式化和检查基础设施代码:

terraform fmt --check=true
terraform validate

要使这些操作作为 CI 的一部分运行,我们需要将以下作业添加到 .github/workflows/ci.yml 文件中的 jobs 部分:

  infrastructure:
    runs-on: ubuntu-latest

    steps:
      - name: Install Terraform
        run: |
          sudo apt-get update && sudo apt-get install -y gnupg             software-properties-common curl
          curl -fsSL https://apt.releases.hashicorp.com/gpg |             sudo apt-key add -
          sudo apt-add-repository "deb [arch=amd64] https://            apt.releases.hashicorp.com $(lsb_release -cs) main"
          sudo apt-get update && sudo apt-get install terraform
      - uses: actions/checkout@v3

      - name: Initialise Terraform
        run: terraform init

      - name: Check the formatting
        run: terraform fmt --check=true --recursive

      - name: Validate the code
        run: terraform validate

现在我们可以为后端代码添加一个任务。

为后端代码添加持续集成

我们之前已经设置了以下命令来格式化、检查和测试后端代码:

pdm run format
pdm run lint
pdm run test

要使这些作为持续集成的一部分运行,我们还需要运行一个数据库服务,因为测试是针对数据库运行的。幸运的是,GitHub 通过在持续集成作业旁边运行 PostgreSQL 数据库来支持 PostgreSQL 数据库服务。我们可以利用这个数据库服务,并通过在.github/workflows/ci.yml中的jobs部分添加以下作业来运行命令:

  backend:
    runs-on: ubuntu-latest

    container: python:3.10.1-slim-bullseye

    services:
      postgres:
        image: postgres
        env:
          POSTGRES_DB: tozo_test
          POSTGRES_USER: tozo
          POSTGRES_PASSWORD: tozo
          POSTGRES_HOST_AUTH_METHOD: "trust"
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
    defaults:
      run:
        working-directory: backend

    env:
      TOZO_QUART_DB_DATABASE_URL: "postgresql://tozo:tozo@        postgres:5432/tozo_test"

    steps:
      - uses: actions/checkout@v3

      - name: Install system dependencies
        run: apt-get update && apt-get install -y postgresql           postgresql-contrib

      - name: Initialise dependencies
        run: |
          pip install pdm
          pdm install

      - name: Linting
        run: pdm run lint

      - name: Testing
        run: pdm run test

现在我们可以为前端代码添加一个任务。

为前端代码添加持续集成

我们之前已经设置了以下命令来格式化、检查、测试和构建前端代码:

npm run format
npm run lint
npm run test
npm run build

我们可以通过在.github/workflows/ci.ymljobs部分添加以下作业来利用该服务并运行命令:

  frontend:
    runs-on: ubuntu-latest

    defaults:
      run:
        working-directory: frontend

    steps:
      - name: Use Node.js
        uses: actions/setup-node@v2
        with:
          node-version: '18'

      - uses: actions/checkout@v3

      - name: Initialise dependencies
        run: npm ci --cache .npm --prefer-offline
      - name: Check formatting
        run: npm run format

      - name: Linting
        run: npm run lint

      - name: Testing
        run: npm run test

      - name: Build
        run: npm run build 

现在我们已经准备好开始开发我们的应用。在这个阶段,文件夹结构如下:

tozo
├── .github
│   └── workflows
├── backend
│   ├── src
│   │   └── backend
│   └── tests
├── frontend
│   ├── public
│   └── src
└── infrastructure

现在我们所有的检查都在每次更改main分支和每个拉取请求时运行。这应该确保我们的代码保持高质量,并提醒我们可能被忽视的任何问题。

摘要

在本章中,我们设置了开发我们应用所需的所有工具。我们首先安装了一个系统包管理器,然后使用它来安装和设置 git。使用 git,我们创建了我们的本地仓库并开始提交代码。我们安装了 Python、NodeJS、Terraform 以及格式化、检查和测试代码所需的工具。最后,我们使用 Terraform 创建并设置了一个带有工作持续集成的远程 GitHub 仓库,确保我们的代码在每次更改时都会自动检查。

本章中安装的工具是开发以下章节中描述的应用所必需的。它还将使您能够快速完成这些工作,因为工具将帮助您快速识别代码中的问题和错误。

在下一章中,我们将开始开发我们应用的后端,重点是设置应用框架和扩展,以支持我们想要的功能,例如身份验证。

进一步阅读

在升级应用之前,切换 Python 和 NodeJS 的版本以测试应用通常很有用。为此,我推荐使用pyenvhttps://github.com/pyenv/pyenv)和nhttps://github.com/tj/n)来分别测试 Python 和 NodeJS。

第二部分 构建待办事项应用

现在,我们将使用 Quart 和 React 构建一个功能齐全的待办事项跟踪应用。该应用将包括许多常见功能,如身份验证、用户管理、样式化页面和表单。

本部分包括以下章节:

  • 第二章使用 Quart 创建可重用后端

  • 第三章构建 API

  • 第四章使用 React 创建可重用前端

  • 第五章构建单页应用

第二章:使用 Quart 创建可重用的后端

在上一章中,我们安装了开发应用程序所需的工具,这意味着我们可以开始构建后端。后端在服务器上运行,而前端在客户端的网页浏览器中运行。在我们的设置中,后端需要成为数据库和前端之间的接口,提供 API 以访问和编辑待办事项(见图 2.1):

图 2.1:所需设置的示意图

图 2.1:所需设置的示意图

除了提供 API,后端还需要连接到数据库,管理用户会话,保护自身免受重负载和不正确使用的影响,并向用户发送电子邮件。在本章中,我们将构建具有这些功能的后端。本章结束时,我们将构建一个可重用的后端,任何 API 都可以使用它。或者,可以将这些功能分部分添加到您自己的应用程序中。

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

  • 创建基本的 Quart 应用程序

  • 包含用户账户

  • 保护应用程序

  • 连接到数据库

  • 发送电子邮件

技术要求

本章需要以下附加文件夹,并应创建它们:

tozo
└── backend
    ├── src
    │   └── backend
    │       ├── blueprints
    │       ├── lib
    │       └── templates
    └── tests
        ├── blueprints
        └── lib

应该创建以下空文件:backend/src/backend/init.pybackend/src/backend/blueprints/init.pybackend/src/backend/lib/init.pybackend/tests/init.pybackend/tests/blueprints/init.py,和 backend/tests/lib/init.py

要跟踪本章的开发过程,请使用配套仓库github.com/pgjones/tozo,并查看标签 r1-ch2-startr1-ch2-end 之间的提交。

创建基本的 Quart 应用程序

首先,我们可以创建一个基本的 API,它对请求做出简单的响应。我喜欢将此称为乒乓路由,因为请求是乒乓,响应是 pong。为此,我选择使用 Quart 框架。Quart 是一个带有扩展生态系统的小型 Web 框架,我们将使用它来添加额外的功能。

使用 Flask 作为替代方案

Quart 是非常流行的 Flask 框架的异步版本,它允许我们使用现代异步库。然而,如果您已经熟悉 Flask,您可以在本书中轻松地调整代码;有关更多信息,请参阅quart.palletsprojects.com/en/latest/how_to_guides/flask_migration.xhtml

要使用 Quart,我们首先需要通过在 backend 目录中运行以下命令来使用 pdm 添加它:

pdm add quart

现在,我们可以在 backend/src/backend/run.py 文件中添加以下代码来创建一个 Quart 应用程序:

from quart import Quart
app = Quart(__name__)

这允许我们添加函数,称为路由处理程序,当请求与给定的 HTTP 方法和路径匹配时,会调用这些函数并返回响应。对于我们的基本应用,我们希望对GET /control/ping/的请求做出响应。这是通过将以下代码添加到backend/src/backend/run.py中实现的:

from quart import ResponseReturnValue
@app.get("/control/ping/")
async def ping() -> ResponseReturnValue:
    return {"ping": "pong"}

现在我们有了创建带有 ping 路由的应用的代码,我们应该设置工具,以便服务器在本地启动并处理请求。与后端工具一样,我们需要在backend/pyproject.toml中添加一个新的脚本名称,如下所示:

[tool.pdm.scripts]
start = "quart --app src/backend/run.py run --port 5050"

上述代码允许在backend目录下运行时启动后端应用,如下所示:

pdm run start

命令运行后,我们可以在任何目录中运行以下命令来检查 ping 路由是否工作:

curl localhost:5050/control/ping/

或者,你可以在浏览器中输入localhost:5050/control/ping/,如图 2.2 所示:

图 2.2:在浏览器中访问时的控制 ping 路由

图 2.2:在浏览器中访问时的控制 ping 路由

使用 curl

curl (curl.se/docs/manpage.xhtml) 是一个优秀的命令行工具,用于发送 HTTP 请求。curl默认安装在大多数系统上,但如果你发现你没有它,你可以使用系统包管理器来安装它(brew install curlscoop install curl)。

不带任何选项,curl执行一个GET请求,你可以使用-X POST选项切换到POST请求,或者你可以使用--json ‘{“tool”: curl}’选项发送 JSON 数据。

对于一个基本的后端来说,这已经足够了;然而,我们需要更多的功能,以及代码能够正常工作的更多确定性。我们将通过添加测试、使用蓝图、添加配置和确保一致的 JSON 错误响应来实现这一点。

测试 ping 路由

测试路由是否按预期工作是一种良好的实践。为此,我们可以在backend/tests/test_run.py中添加以下测试:

from backend.run import app
async def test_control() -> None:
    test_client = app.test_client()
    response = await test_client.get("/control/ping/")
    assert (await response.get_json())["ping"] == "pong"

在测试代码就绪后,我们可以运行pdm run test来查看它是否运行并通过。

关于常见 await 错误的警告

我发现 Python 中错误地等待错误的事情很常见,而且似乎其他人也有这个问题。这个问题通常在如下代码中看到:

await response.get_json()[“ping”]

这将因couroutine cannot be indexed错误而失败,因为response.get_json()返回的协程必须在索引之前等待。这个问题通过在正确的位置添加括号来解决,在这个例子中如下所示:

(await response.get_json())[“ping”]

现在我们有一个工作的 ping-pong 路由,我们需要考虑如何添加更多的路由,为了清晰起见,最好使用蓝图来完成。

使用蓝图使代码更清晰

我们将 ping 路由处理程序添加到与应用程序相同的文件中(backend/src/backend/run.py),因为这是最简单的方法来启动;然而,随着我们添加更多的路由处理程序,文件将很快变得不清楚且难以更新。Quart 提供了蓝图来帮助随着应用程序变大而结构化代码。由于我们将添加更多的路由处理程序,我们将把到目前为止的内容转换为蓝图。

我们现在可以将 ping 路由处理程序移动到控制蓝图,通过向backend/src/backend/blueprints/control.py添加以下代码:

from quart import Blueprint, ResponseReturnValue

blueprint = Blueprint("control", __name__)
@blueprint.get("/control/ping/")
async def ping() -> ResponseReturnValue:
    return {"ping": "pong"}

然后,我们可以通过将backend/src/backend/run.py更改为以下内容来将其注册到应用程序中:

from quart import Quart
from backend.blueprints.control import blueprint as control_blueprint

app = Quart(__name__)
app.register_blueprint(control_blueprint)

现有的测试将继续工作;然而,我认为测试的位置应该覆盖它所测试的代码的位置。这使得理解测试在哪里以及测试应该测试什么变得更加容易。因此,我们需要将backend/tests/test_run.py移动到backend/tests/blueprints/test_control.py

您现在应该有以下后端文件和结构:

tozo
└── backend
    ├── pdm.lock
    ├── pyproject.toml
    ├── setup.cfg
    ├── src
    │   └── backend
    │       ├── blueprints
    │       │   ├── __init__.py
    │       │   └── control.py
    │       ├── __init__.py
    │       └── run.py
    └── tests
        ├── blueprints
        │   ├── __init__.py
        │   └── test_control.py
        └── __init__.py

我们将为应用程序中每个逻辑功能集合使用一个蓝图,并在整个结构中遵循此结构。我们现在可以专注于配置应用程序以在各种环境中运行。

配置应用程序

我们需要在多个环境中运行我们的应用程序,特别是开发、测试、CI 和生成环境。为此,我们需要在每个环境中更改一些设置;例如,数据库连接。配置允许我们更改这些设置而不必修改代码。它还允许将秘密与代码分开管理,从而更加安全。

我认为环境变量是提供配置的最佳方式,每个环境都有相同变量的不同值。我们可以指示 Quart 从带前缀的环境变量中加载配置。前缀确保只考虑相关的环境变量;默认前缀是QUART_,但我们将将其更改为TOZO_。为此,我们需要将以下更改添加到backend/src/backend/run.py,以便在创建应用程序后立即加载配置:

app = Quart(__name__)
app.config.from_prefixed_env(prefix="TOZO")

应该已经存在高亮显示的代码行。

在生产中,我们将使用 Terraform 脚本来定义环境变量,而在本地,我们将从文件中加载环境变量。首先,对于开发,我们需要将以下内容添加到backend/development.env

TOZO_BASE_URL="localhost:5050" 
TOZO_DEBUG=true
TOZO_SECRET_KEY="secret key"

其次,对于测试,我们需要将以下内容添加到backend/testing.env

TOZO_BASE_URL="localhost:5050" 
TOZO_DEBUG=true
TOZO_SECRET_KEY="secret key" 
TOZO_TESTING=true

现在文件已经存在,我们可以通过将以下更改应用到backend/pyproject.toml来调整 PDM 脚本,以便在启动应用程序或运行测试时加载它们:

[tool.pdm.scripts]
start = {cmd = "quart --app src/backend/run.py run --port   5050", env_file = "development.env"}
test = {cmd = "pytest tests/", env_file = "testing.env"}

这些对脚本的微小修改将确保在执行pdm run startpdm run test命令时自动加载环境。现在,我们将查看一个经常被忽视的功能,即一致的错误响应。

确保错误响应是 JSON 格式

由于我们正在编写一个服务于 JSON 的后端 API,因此所有响应都应使用 JSON,包括错误响应。因此,我们不会使用 Quart 内置的错误响应,而是将使用我们自己的,这些响应会明确地产生 JSON 响应。

错误响应通常由 400-500 范围内的状态码表示。然而,仅状态码本身并不能总是传达足够的信息。例如,在注册新成员时,对于无效电子邮件地址的请求和密码强度不足的请求,都期望返回 400 状态码。因此,需要返回一个额外的代码来区分这些情况。我们可以通过在 backend/src/backend/lib/api_error.py 中添加以下代码来实现:

class APIError(Exception):
    def __init__(self, status_code: int, code: str) -> None:
        self.status_code = status_code
        self.code = code

随着 APIError 的可用,我们现在可以通过在 backend/src/backend/run.py 中添加以下代码来告知 Quart 如何处理它:

from quart import ResponseReturnValue
from backend.lib.api_error import APIError

@app.errorhandler(APIError)  # type: ignore
async def handle_api_error(error: APIError) -> ResponseReturnValue:
    return {"code": error.code}, error.status_code

我们还应该告知 Quart 如何处理任何其他意外错误,例如会导致 500 “内部服务器错误”响应的错误,如下所示:

@app.errorhandler(500)
async def handle_generic_error(
    error: Exception
) -> ResponseReturnValue:
    return {"code": "INTERNAL_SERVER_ERROR"}, 500

我们现在已经设置了一个基本的 Quart 应用程序,以便我们能够添加我们实际应用程序所需的所有功能,首先是管理用户账户的能力。

包含用户账户

由于我们希望用户能够登录我们的应用程序,我们需要验证客户端是否是他们声称的那个人。之后,我们需要确保每个用户只能看到他们自己的待办事项。这通常是通过用户输入用户名和密码来实现的,然后这些密码会被与存储的版本进行核对。

我们需要验证用户对后端发出的每个请求;然而,理想情况下我们只希望用户登录一次(直到他们注销)。我们可以通过在用户登录时将信息保存到 cookie 中来实现这一点,因为浏览器会随后将 cookie 与每个请求一起发送。

当用户登录并开始会话时,我们需要将一些识别信息保存到 cookie 中;例如,他们的用户 ID。然后,我们可以在每次请求时读取 cookie 并识别是哪个用户。然而,客户端可以编辑或伪造 cookie,因此我们需要确保 cookie 中的信息没有被篡改。

我们可以通过在 cookie 中签名信息来防止篡改。签名是指使用一个密钥对数据进行加密函数处理以创建签名。然后,这个签名会与数据一起存储,允许存储的签名与重新计算版本进行核对。

Quart-Auth 是一个 Quart 扩展,它会为我们管理 cookie 和存储在其中的数据。在 backend 目录中运行以下命令即可安装 Quart-Auth:

pdm add quart-auth

然后,在创建应用程序时,需要在 backend/src/backend/run.py 中激活 AuthManager,如下所示:

from quart_auth import AuthManager
auth_manager = AuthManager(app)

虽然 Quart-Auth 为保护 cookie 提供了一套合理的默认设置,但我们的使用方式使我们能够更加安全。具体来说,我们可以利用严格的 SameSite 设置,而不是 Quart-Auth 默认的宽松设置。这是因为我们只需要对 API 路由的非导航请求进行身份验证。

SameSite

SameSite 设置确保 cookie 数据仅在来自指定域的请求中发送。这防止了其他网站使用 cookie 数据发起请求。要了解更多关于 SameSite 的信息,您可以点击以下链接:developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite

要添加严格的 SameSite 设置,请将以下内容添加到 backend/development.envbackend/testing.env 文件中:

TOZO_QUART_AUTH_COOKIE_SAMESITE="Strict"

然而,由于我们未使用 HTTPS,我们需要在开发环境中禁用安全 cookie 标志。这可以通过将以下内容添加到 backend/development.envbackend/testing.env 文件中来实现:

TOZO_QUART_AUTH_COOKIE_SECURE=false

由于 Quart-Auth 管理会话,我们现在需要存储密码,确保它们足够强大,并允许无密码身份验证。

安全地存储密码

虽然我们现在可以管理用户的会话,但为了开始会话,用户需要通过提供电子邮件和密码来登录。虽然电子邮件可以直接存储在数据库中,但密码需要特别注意。这是因为用户经常在许多网站/服务中使用相同的密码,如果我们的应用程序发生泄露,我们可能会泄露对许多其他网站的访问权限。因此,我们不会直接存储密码,而是将密码哈希并存储。

密码哈希是将哈希操作应用于明文密码的结果。一个好的哈希操作应该确保生成的哈希值不能被转换回明文密码,并且每个不同的明文密码都会产生不同的哈希结果。

我喜欢使用bcrypt作为哈希操作,因为它同时满足这两个要求并且易于使用。在 backend 目录中运行以下命令即可安装bcrypt

pdm add bcrypt

安装了bcrypt之后,我们可以使用为每个密码生成的盐值来哈希密码,如下所示:

import bcrypt
hashed = bcrypt.hashpw(password, bcrypt.gensalt(rounds=14))

检查提供的密码是否与哈希密码匹配是通过以下代码完成的:

match = bcrypt.checkpw(password, hashed)

我们将在第三章**,构建 API中添加的登录和注册功能中使用bcrypt。接下来,我们需要检查密码是否足够强大。

对密码进行加盐处理

在散列密码时,最佳实践是在计算哈希之前向密码中添加salt。由于盐的目的是为每个存储的密码不同,因此它确保了在两个不同的实现中散列的相同密码具有不同的哈希。因此,添加盐是一个额外的安全措施,我们通过bcrypt.gensalt函数来实现。

确保密码强度

用户经常选择弱密码,这使得他们的账户容易受到攻击。为了防止这种情况,我们应该确保我们的用户选择强密码。为此,我喜欢使用zxcvbn,因为它给出了一个表示密码强度的分数。这是在后端目录中运行以下命令安装的:

pdm add zxcvbn

然后,它被用来给出分数,如下所示:

from zxcvbn import zxcvbn
score = zxcvbn(password).score

分数是介于 0 到 4 之间的值,我通常认为 3 或 4 的分数是好的。因此,我们将阻止使用分数较低的密码。

在下一章中添加注册和更改密码功能时,我们将使用zxcvbn。接下来,我们需要考虑用户在没有密码的情况下如何进行身份验证;例如,当他们忘记密码时。

允许无密码身份验证

有几种情况下,用户无法提供密码,但可以证明他们有权访问账户的电子邮件地址。一个典型的例子是当用户忘记他们的密码并希望重置它时。在这些情况下,我们需要向用户发送一个令牌,他们可以将其提供给我们,从而验证他们是电子邮件负责的用户。为此,令牌必须能够识别用户,恶意用户必须不能篡改令牌或创建自己的令牌。

要创建令牌,我们可以使用加密的itsdangerous对用户的 ID 进行签名,这也是 Quart-Auth 用于 cookie 的方法。itsdangerous是在后端目录中运行以下命令安装的:

pdm add itsdangerous

由于这种方法没有加密签名数据,因此重要的是要记住,用户将能够阅读我们放在令牌中的任何内容。因此,我们不应在令牌中放置任何敏感信息(用户 ID 不是敏感信息)。

我们还将为我们的令牌添加时间戳;这样我们可以确保它们在特定时间段后过期。此外,由于我们希望能够在链接中使用令牌,我们需要使用URLSafeTimedSerializer。我们可以按照以下方式创建带有用户 ID 的令牌:

from itsdangerous import URLSafeTimedSerializer
from quart import current_app
serializer = URLSafeTimedSerializer(
    current_app.secret_key, salt="salt"
)
token = serializer.dumps(user_id)

令牌可以按照以下方式读取和检查:

from itsdangerous import BadSignature, SignatureExpired
from backend.lib.api_error import APIError
signer = URLSafeTimedSerializer(
    current_app.secret_key, salt="salt"
)
try:
    user_id = signer.loads(token, max_age=ONE_DAY)
except (SignatureExpired):
    raise APIError(403, "TOKEN_EXPIRED")
except (BadSignature):
    raise APIError(400, "TOKEN_INVALID")
else:
    # Use the user_id safely

由于我们使用的是定时令牌,因此在测试时我们需要控制时间。例如,如果我们想测试一个已过期的令牌,我们需要在令牌检查时创建它,使其过期。为此,我们可以使用freezegun,这是在后端目录中运行以下命令安装的:

pdm add --dev freezegun

然后,我们可以在我们的测试中使用以下代码创建一个旧令牌:

from freezegun import freeze_time
with freeze_time("2020-01-01"):
    signer = URLSafeTimedSerializer(        app.secret_key, salt="salt"    )
    token = signer.dumps(1)

此令牌然后可以用来测试路由处理程序对过期令牌的响应。

在下一章中,我们将添加忘记密码功能时,将使用itsdangerousfreezegun

接下来,由于有恶意用户会尝试攻击我们的应用,我们需要保护它。

保护应用

在您将应用部署到生产环境后不久,用户最多会误用它,最坏的情况会攻击它。因此,从一开始就添加速率限制和请求验证进行防御是值得的。

速率限制限制了远程客户端向应用发送请求的速率。这防止了用户通过他们的请求过载应用,从而防止其他用户使用应用。

验证确保接收到的(或回复的)JSON 数据与预期的结构匹配。这很有帮助,因为它意味着如果 JSON 数据结构不正确,将显示错误消息。它还可以减轻用户发送导致错误或应用问题的结构的可能性。

添加速率限制

我们将使用名为 Quart-Rate-Limiter 的 Quart 扩展来强制执行速率限制,该扩展通过在*backend*目录中运行以下命令来安装:

pdm add quart-rate-limiter

我们现在可以通过在*backend/src/backend/run.py*中添加以下代码来激活RateLimiter

from quart_rate_limiter import RateLimiter
rate_limiter = RateLimiter(app)

当激活RateLimiter后,应用中的任何路由都可以获得速率限制保护,例如,限制每分钟最多六次请求,如下所示:

from datetime import timedelta

from quart_rate_limiter import rate_limit

@app.get("/")
@rate_limit(6, timedelta(minutes=1))
async def handler():
    ...

与其他错误一样,如果客户端超过速率限制,提供 JSON 响应很重要;我们可以在*backend/src/backend/run.py*中添加以下代码来实现:

from quart_rate_limiter import RateLimitExceeded

@app.errorhandler(RateLimitExceeded)  # type: ignore
async def handle_rate_limit_exceeded_error(
    error: RateLimitExceeded,
) -> ResponseReturnValue:
    return {}, error.get_headers(), 429

现在我们能够添加速率限制,最佳实践是将它们添加到所有路由。为了确保我们这样做,让我们添加一个检查的测试。

确保所有路由都有速率限制

恶意攻击者通常会寻找缺少速率限制的路径作为他们可以攻击的弱点。为了减轻这种风险,我喜欢检查所有路由都有速率限制或被标记为豁免,使用rate_exempt装饰器。为此,我在*tests/test_rate_limits.py*中添加了以下代码:

from quart_rate_limiter import (
    QUART_RATE_LIMITER_EXEMPT_ATTRIBUTE,
    QUART_RATE_LIMITER_LIMITS_ATTRIBUTE,
)
from backend.run import app
IGNORED_ENDPOINTS = {"static"}

def test_routes_have_rate_limits() -> None:
    for rule in app.url_map.iter_rules():
        endpoint = rule.endpoint

        exempt = getattr(
            app.view_functions[endpoint],
            QUART_RATE_LIMITER_EXEMPT_ATTRIBUTE,
            False,
        )
        if not exempt and endpoint not in IGNORED_ENDPOINTS:
            rate_limits = getattr(
                app.view_functions[endpoint],
                QUART_RATE_LIMITER_LIMITS_ATTRIBUTE,
                [],
            )
            assert rate_limits != []

在 Quart 应用中,规则是应用将响应的方法-路径组合。每个规则都有一个端点,指示哪个函数应该处理请求。静态端点是 Quart 添加的,因此我们在本次测试中忽略它。

此测试将检查应用中的所有路由都有速率限制或被豁免。这意味着我们还需要将rate_exempt装饰器添加到我们在设置基本应用时添加的控制 ping 端点。这通过在*backend/src/backend/blueprints/control.py*中的 ping 路由处理程序中添加突出显示的装饰器来完成,如下所示:

from quart_rate_limiter import rate_exempt
@blueprint.get("/control/ping/")
@rate_exempt
async def ping() -> ResponseReturnValue:
    return {"ping": "pong"}

除了速率限制路由外,我们还可以通过验证请求和响应数据来保护路由。

添加请求和响应验证

恶意用户通常会尝试发送格式错误和无效的数据,以寻找我们代码中的错误。为了减轻这种影响,我们将使用名为 Quart-Schema 的 Quart 扩展来验证请求和响应。在 backend 目录中运行以下命令即可安装:

pdm add "pydantic[email]" 
pdm add quart-schema

按照惯例,JSON(JavaScript/TypeScript)和 Python 使用不同的命名约定,前者使用 camelCase,后者 snake_case。这意味着在接收或回复时,我们需要在这两种命名约定之间进行转换。幸运的是,Quart-Schema 可以通过 convert_casing 选项自动为我们完成这项工作,无需额外思考。

我们可以通过向 backend/src/backend/run.py 添加以下代码来激活 QuartSchema,包括设置 convert_casing 选项:

from quart_schema import QuartSchema
schema = QuartSchema(app, convert_casing=True) 

使用这种设置,我们可以使用 dataclass 来定义和验证路由期望接收的数据,以及验证它是否发送了正确的数据,如下所示:

from quart_schema import validate_request, validate_response
@dataclass
class Todo:
    task: str
    due: Optional[datetime]
@app.post("/")
@validate_request(Todo)
@validate_response(Todo)
async def create_todo(data: Todo) -> Todo:
    ... 
    return data

与其他错误一样,如果客户端发送错误数据,提供包含有用信息的 JSON 响应给客户端非常重要。我们可以通过向 backend/src/backend/run.py 添加以下错误处理器来完成此操作:

from quart_schema import RequestSchemaValidationError
@app.errorhandler(RequestSchemaValidationError)  # type: ignore
async def handle_request_validation_error(
    error: RequestSchemaValidationError,
) -> ResponseReturnValue:
    if isinstance(error.validation_error, TypeError):
        return {"errors": str(error.validation_error)}, 400
    else:
        return {"errors": error.validation_error.json()}, 400

检查 validation_error 的类型可以在响应中返回有用的信息,从而帮助纠正问题。

由于 Quart-Schema 为我们的应用添加了不受速率限制的路由,因此我们需要更改 backend/tests/test_rate_limits.py 中的 IGNORED_ENDPOINTS 行,如下所示:

IGNORED_ENDPOINTS = {"static", "openapi", "redoc_ui", "swagger_ui"}

由于我们可以验证后端发送和接收的数据的结构,我们现在可以转向如何将数据存储在数据库中。为此,我们需要能够连接到它并执行查询。

连接到数据库

我们选择将应用所需的数据存储在 PostgreSQL 数据库中,我们需要连接到该数据库。为此,我喜欢使用名为 Quart-DB 的 Quart 扩展,它是一个围绕快速低级 PostgreSQL 驱动程序的优秀包装器。在 backend 目录中运行以下命令即可安装:

pdm add quart-db

我们可以通过向 backend/src/backend/run.py 添加以下代码来激活 QuartDB

from quart_db import QuartDB
quart_db = QuartDB(app)

我们还需要配置 QuartDB 应连接到的数据库。这是通过添加一个 TOZO_QUART_DB_DATABASE_URL 环境变量来实现的,其值如下所示,其中高亮部分是可配置的:

postgresql://username:password@0.0.0.0:5432/db_name

在开发中,我们将使用 tozo 作为用户名、密码和数据库名,因为它们非常明显且易于记忆。为此,请向 backend/development.env 添加以下内容:

TOZO_QUART_DB_DATABASE_URL="postgresql://tozo:tozo@0.0.0.0:5432/tozo"

在测试时,我们将使用 tozo_test 作为用户名、密码和数据库名,以确保测试和开发数据保持分离。为此,请向 backend/testing.env 添加以下内容:

TOZO_QUART_DB_DATABASE_URL="postgresql://tozo_test:tozo_test@0.0.0.0:5432/tozo_test"

在开发和测试更改后,我们需要将数据库重置到已知状态。在运行测试之前,我们也希望重置数据库以确保测试不会因为数据库处于不同状态而失败。为此,我们首先在*backend/src/backend/run.py*中添加以下代码以添加一个 Quart CLI 命令来重新创建数据库:

import os
from subprocess import call  # nosec
from urllib.parse import urlparse 
@app.cli.command("recreate_db")
def recreate_db() -> None:
    db_url = urlparse(os.environ["TOZO_QUART_DB_DATABASE_URL"])
    call(  # nosec
        ["psql", "-U", "postgres", "-c", f"DROP DATABASE IF           EXISTS {db_url.path.removeprefix('/')}"],
    )
    call(  # nosec
        ["psql", "-U", "postgres", "-c", f"DROP USER IF EXISTS           {db_url.username}"],
    )
    call(  # nosec
        ["psql", "-U", "postgres", "-c", f"CREATE USER {db_url.       username} LOGIN PASSWORD '{db_url.password}' CREATEDB"],
    )
    call(  # nosec
        ["psql", "-U", "postgres", "-c", f"CREATE DATABASE {db_          url.path.removeprefix('/')}"],
    )

此命令通过call函数调用psql。前两个调用将使用DROP DATABASEDROP USER SQL 命令删除已存在的数据库和用户。删除后,接下来的调用将使用CREATE USERCREATE DATABASE SQL 命令创建用户和数据库。

我们现在可以利用此命令在pdm run test脚本中,并添加一个新的pdm run recreate-db脚本,以便按需重置数据库,通过在backend/pyproject.toml中做出以下更改:

[tool.pdm.scripts]
recreate-db-base = "quart --app src/backend/run.py recreate_db"
recreate-db = {composite = ["recreate-db-base"], env_file =  "development.env"}
test = {composite = ["recreate-db-base", "pytest tests/"], env_  file = "testing.env"}

突出的行表示test脚本已被更改,而recreate-dbrecreate-db-base脚本已被添加。

为了检查它是否工作,我们现在可以在backend目录中运行以下命令来创建开发数据库:

pdm run recreate-db

然后,为了检查它是否工作,我们可以使用以下命令打开数据库的psql shell:

psql –U tozo

前面的命令应该给出类似于图 2.3的输出:

图 2.3:运行\dt 命令描述空数据库时 psql 的输出

图 2.3:运行\dt 命令描述空数据库时 psql 的输出

PSQL

PSQL 是一个命令行工具,可以连接到 PostgreSQL 数据库,并允许运行查询和其他命令。这意味着您可以从命令行测试 SQL 查询并检查数据库的结构。我建议您尝试\dt命令,该命令列出数据库中的所有表,以及\d tbl命令,该命令描述名为tbl的表的结构。

在测试时,我们需要在 Quart 测试应用上下文中运行我们的测试,以确保建立了数据库连接。为此,我们需要在*backend/tests/conftest.py*中添加以下代码:

from typing import AsyncGenerator

import pytest
from quart import Quart

from backend.run import app

@pytest.fixture(name="app", scope="function")
async def _app() -> AsyncGenerator[Quart, None]:
    async with app.test_app():
        yield app

pytest固定文件可以被注入到测试中,这意味着我们可以通过将其声明为参数来在测试中使用此固定文件。这意味着必须将*backend/tests/blueprints/test_control.py*重写如下:

from quart import Quart
async def test_control(app: Quart) -> None:
    test_client = app.test_client()
    response = await test_client.get("/control/ping/")
    assert (await response.get_json())["ping"] == "pong"

另一个有用的功能是直接连接到数据库以在测试中使用。通过在*backend/conftest.py*中添加以下代码提供此固定文件:

from quart_db import Connection
from backend.run import quart_db
@pytest.fixture(name="connection", scope="function")
async def _connection(app: Quart) -> AsyncGenerator[Connection, None]:
    async with quart_db.connection() as connection:
        async with connection.transaction():
            yield connection

在此设置完成后,我们所有的测试都可以使用应用固定文件,并对测试数据库运行测试。

除了连接到数据库外,我们还需要后端连接到邮件服务器以向用户发送邮件。

发送邮件

我们将向我们的应用程序用户发送电子邮件,首先是他们在注册时收到的确认邮件。如果用户忘记了密码,我们也会发送一封密码重置邮件。这些有针对性的电子邮件是事务性的,而不是营销性的,这是一个重要的区别,因为营销邮件很少通过应用程序代码发送。

对于事务性电子邮件,通常的目标是尽可能清晰地传达任务给用户。因此,这些电子邮件通常是基于文本的,图像最少。然而,我们应该确保电子邮件有品牌特色,并留有空间放置任何必需的法律文本。这意味着我们需要渲染电子邮件,使得事务性文本清晰,并围绕相关的品牌和文本。

渲染电子邮件

我们将把一封电子邮件视为由一个头部组成,我们将在这里放置品牌信息(例如,一个标志),内容部分将放置电子邮件的具体信息(例如,指向我们应用程序密码重置页面的链接),以及一个底部,其中放置任何法律信息。由于电子邮件之间只有内容会变化,我们可以考虑将头部和底部与内容分开渲染。

由于大多数电子邮件客户端支持 HTML,我们可以设计我们的电子邮件使其更具吸引力,更易于阅读。这意味着我们需要一个 HTML 头部/底部,我们可以将特定电子邮件的内容渲染到其中。这最好使用 Quart 内置的render_template函数来完成,该函数利用 Jinja2 来渲染模板文档。

要开始设置头部和底部,我们需要在backend/src/backend/templates/email.xhtml中放置以下代码:

<!DOCTYPE html>
<html>
  <head>
    <title>Tozo - email</title>
    <meta http-equiv="Content-Type" content="text/html;      charset=UTF-8">
    <meta name="viewport" content="width=device-width, initial-      scale=1.0">
  </head>
  <body style="font-family: Arial, 'Helvetica Neue', Helvetica,    sans-serif; font-size: 14px; font-style: normal; margin: 0">
    <table width="100%" height="100%" cellpadding="0"       cellspacing="0" border="0">
      <tr>
        <td align="center">
          <table height="100%" cellpadding="20" cellspacing="0"            border="0" style="max-width: 540px;">
            <tr>
              <td align="left" width="540">
                {% block welcome %}
                  Hello,
                {% endblock %}
              </td>
            </tr>
            <tr>
              <td align="left" width="540">
                {% block content %}
                  Example content
                {% endblock %}
              </td>
            </tr>
            <tr>
              <td align="center" width="540">
                The Tozo team
              </td>
            </tr>
          </table>
        </td>
      </tr>
    </table>
  </body>
</html>

由于电子邮件客户端只支持 HTML 和 CSS 的有限部分,我们使用表格来布局电子邮件。我们追求的布局是内容保持在视口中央的 540 像素宽度内。这应该支持大多数电子邮件客户端,同时仍然看起来不错。

突出的block指令在渲染时仅显示其内的内容,如图 2.4 所示。它允许任何扩展此基本电子邮件的模板替换块的内容,因此我们将以此作为所有电子邮件的基础。

图 2.4:在浏览器中查看时渲染的电子邮件

图 2.4:在浏览器中查看时渲染的电子邮件

caniemail.com

caniemail.com网站是一个非常有价值的资源,用于检查现有的各种电子邮件客户端支持哪些 HTML 和 CSS 功能。我建议检查这个网站以了解添加到 HTML 电子邮件中的任何新功能。

现在我们有了漂亮的电子邮件,我们可以添加代码将它们发送到用户的电子邮件地址。

发送电子邮件

虽然直接从应用程序使用 SMTP 服务器发送电子邮件是可能的,但我发现使用像 Postmark (postmarkapp.com) 这样的第三方服务是更好的实践。这是因为 Postmark 将确保我们的电子邮件从有助于确保低垃圾邮件评分的设置中可靠地发送,这是从新的 SMTP 服务器难以实现的。

在开发和测试中,我更喜欢不发送电子邮件,而是将它们记录下来。我发现这使开发更容易、更快(无需检查任何电子邮件收件箱)。我们可以通过从 send_email 函数开始,该函数通过将以下代码添加到 backend/src/backend/lib/email.py 中将电子邮件记录到控制台来实现:

import logging
from typing import Any
from quart import render_template

log = logging.getLogger(__name__)
async def send_email(
    to: str, 
    subject: str, 
    template: str, 
    ctx: dict[str, Any], 
) -> None:
    content = await render_template(template, **ctx)
    log.info("Sending %s to %s\n%s", template, to, content)

我们还需要配置日志记录,这可以通过在 backend/src/backend/run.py 中添加以下代码通过基本设置来完成:

import logging

logging.basicConfig(level=logging.INFO)

要使用第三方 Postmark 发送电子邮件,我们需要向他们的 API 发送 HTTP 请求。为此,我们可以在 backend 目录中运行以下命令来使用 httpx

pdm add httpx

然后,我们可以调整 send_email 函数,如果配置中可用令牌,通过更改 backend/src/backend/lib/email.py 中的代码通过 Postmark 发送邮件,如下所示:

import logging
from typing import Any, cast

import httpx
from quart import current_app, render_template
log = logging.getLogger(__name__)

class PostmarkError(Exception):
    def __init__(self, error_code: int, message: str) -> None:
        self.error_code = error_code
        self.message = message
async def send_email(
    to: str, 
    subject: str, 
    template: str, 
    ctx: dict[str, Any], 
) -> None:
    content = await render_template(template, **ctx)
    log.info("Sending %s to %s\n%s", template, to, content)
    token = current_app.config.get("POSTMARK_TOKEN")
    if token is not None:
        async with httpx.AsyncClient() as client:
            response = await client.post(
                "https://api.postmarkapp.com/email",
                json={
                    "From": "Tozo <help@tozo.dev>",
                    "To": to,
                    "Subject": subject,
                    "Tag": template,
                    "HtmlBody": content,
                },
                headers={"X-Postmark-Server-Token": token},
            )
        data = cast(dict, response.json())
        if response.status_code != 200:
            raise PostmarkError(                data["ErrorCode"], data["Message"]            )

send_email 函数现在使用 httpx 发送 POST 请求到 Postmark,包括作为头部的必需令牌和请求 JSON 主体中的电子邮件内容。Postmark 返回的任何错误都作为易于识别的 PostmarkError 抛出。我们现在可以专注于如何在测试中使用电子邮件。

测试电子邮件是否已发送

在测试后端的功能时,我们通常会想要检查是否已发送电子邮件。我们可以通过将以下代码添加到 backend/tests/lib/test_email.py 中来测试 send_email 函数:

from pytest import LogCaptureFixture
from quart import Quart
from backend.lib.email import send_email
async def test_send_email(
    app: Quart, caplog: LogCaptureFixture
) -> None:
    async with app.app_context():
        await send_email(
            "member@tozo.dev", "Welcome", "email.xhtml", {}
        )  
    assert "Sending email.xhtml to member@tozo.dev" in caplog.text

caplog 是一个 pytest 修复程序,它捕获测试期间记录的所有内容。这允许我们通过查找特定文本来检查我们的电子邮件是否已记录。

现在已经设置了后端,我们已经拥有了开始开发应用程序 API 所需的一切。此阶段的文件夹结构如下:

tozo
├── .github
│   └── workflows
├── backend
│   ├── src
│   │   └── backend
│   │       └── blueprints
│   │       └── lib
│   │       └── templates
│   └── tests
│       └── backend
│           └── blueprints
│           └── lib
├── frontend
│   ├── public
│   └── src
└── infrastructure

摘要

在本章中,我们使用 Quart 构建了一个后端应用程序,我们可以在其上构建特定的 API。它可以连接到数据库,管理用户会话,保护自己免受重和错误的使用,并向用户发送电子邮件。

本章中我们构建的功能在许多应用中都很常见,因此它们将对你正在尝试构建的应用程序很有用。此外,本章中构建的后端是通用的,可以适应你的特定用途。

在下一章中,我们将添加一个 API 来管理用户,允许用户登录,并支持我们在本书中构建的任务功能。

进一步阅读

在本书中,为了简化,我们选择只发送 HTML 电子邮件;然而,发送包含 HTML 和纯文本部分的复合电子邮件是更好的实践。你可以在 useplaintext.email 阅读对此的倡导。

第三章:构建 API

在上一章中,我们构建了一个连接到数据库的后端,管理用户会话并发送电子邮件。现在,我们将向后端添加一个特定的 API 来跟踪成员的待办事项。这需要一个允许管理成员、会话和待办事项的 API。

在本章中,你将学习如何构建一个RESTful API,这是一种非常流行的 API 风格,你可能会在职业生涯中使用并遇到。你还将构建一个用于管理成员并验证其操作的 API,这可以在其他应用中通过最小修改使用。最后,我们还将构建一个用于跟踪待办事项的 API,这同样可以适应其他用途。

我们将使用 RESTful 风格来构建 API,因为它非常适合 Web 应用,并且可以用 Quart 轻松表达。RESTful API 是功能按资源分组的地方,每个函数都是一个作用于资源的操作。例如,登录功能描述为创建会话,注销描述为删除会话。对于 RESTful Web 应用,操作由 HTTP 动词表示,资源由 HTTP 路径表示。此外,响应状态码用于指示功能的效果,2XX 代码表示成功,4XX 代码表示不同类型的错误。

RESTful API 的替代方案

虽然 RESTful API 使用许多 HTTP 动词和路径来描述功能,但更基本的方式是有一个单一的POST路由。然后,这个路由被用于所有功能,请求体描述了功能和数据。一个很好的例子是 GraphQL,它通常只使用POST /graphql和一个定义的消息结构。如果你希望使用 GraphQL,请查看strawberry.rocks

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

  • 创建数据库模式和模型

  • 构建会话 API

  • 构建成员 API

  • 构建待办事项 API

技术要求

本章需要以下额外的文件夹,并应创建:

tozo
└── backend
    ├── src
    │   └── backend
    │       ├── migrations
    │       └── models
    └── tests
        └── models

应创建空的backend/src/backend/models/__init__.pybackend/tests/models/__init__.py文件。

要跟踪本章的开发进度,请使用配套的仓库github.com/pgjones/tozo,并查看标签r1-ch3-startr1-ch3-end之间的提交。

创建数据库模式和模型

在这本书中,我们正在构建一个待办事项跟踪应用,这意味着我们需要存储关于成员及其待办事项的数据。我们将通过将数据放入数据库来实现这一点,这意味着我们需要定义数据的结构。这个结构被称为模式,它描述了数据库中的表。

当前的id属性。

ORMs

模式和模型通常被混为一谈,尤其是在使用 对象关系模型 (ORM) 时。虽然一开始使用 ORM 简单,但我发现它隐藏了重要的细节,并在一段时间后使开发变得更加困难。这就是为什么在这本书中,模型和模式是相关的但不同的。这也意味着我们将为与数据库的所有交互编写 SQL 查询。

我们将首先在迁移中定义成员数据和待办数据作为模型和模式,然后再添加一些初始测试和开发数据。

创建成员模式及模型

我们需要为每个成员存储信息,以便我们可以通过外键引用将他们的待办事项与他们关联起来。此外,我们还需要存储足够的信息,以便成员可以登录并证明他们的身份(进行身份验证),这意味着我们需要存储他们的电子邮件和密码散列。最后,我们还将存储他们的账户创建时间和验证电子邮件的时间 - 后者如果我们要向他们发送电子邮件则非常重要。

此数据的模式由以下 SQL 提供给出,仅供参考,并将用于 运行第一次迁移 部分:

CREATE TABLE members (
    id INT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
    created TIMESTAMP NOT NULL DEFAULT now(),
    email TEXT NOT NULL,
    email_verified TIMESTAMP,
    password_hash TEXT NOT NULL
);

CREATE UNIQUE INDEX members_unique_email_idx on members (LOWER(email));

突出的唯一索引确保每个电子邮件地址只有一个成员账户,忽略电子邮件的大小写。

SQL 格式化

第一章 设置我们的开发系统 中,我提到了代码格式化和自动格式化工具的重要性。遗憾的是,我还没有找到一个适用于嵌入 Python 代码中的 SQL 的自动格式化工具。然而,我建议您遵循 sqlstyle.guide/ 上给出的风格指南,就像我在这本书中做的那样。

我们可以用 Python dataclass 来表示数据库表,其中每个列都作为属性,并具有相关的 Python 类型。这是以下代码中显示的模型,应该添加到 backend/src/backend/models/member.py

from dataclasses import dataclass
from datetime import datetime

@dataclass
class Member:
    id: int
    email: str
    password_hash: str
    created: datetime
    email_verified: datetime | None

除了模型外,我们还可以将以下函数添加到 backend/src/backend/models/member.py 中,以便在后端模型和从数据库中读取的 SQL 之间进行转换:

from quart_db import Connection

async def select_member_by_email(
    db: Connection, email: str
) -> Member | None:
    result = await db.fetch_one(
        """SELECT id, email, password_hash, created,                  email_verified
             FROM members
            WHERE LOWER(email) = LOWER(:email)""",
        {"email": email},
    )
    return None if result is None else Member(**result)

async def select_member_by_id(
    db: Connection, id: int
) -> Member | None:
    result = await db.fetch_one(
        """SELECT id, email, password_hash, created,                  email_verified
             FROM members
            WHERE id = :id""",
        {"id": id},
    )
    return None if result is None else Member(**result)

这些函数允许从数据库中读取成员信息。突出显示的行确保如果小写的电子邮件匹配,则认为电子邮件是一致的。

电子邮件大小写敏感性

在我们的应用程序中,我们存储用户给出的电子邮件的大小写,同时比较小写的电子邮件。这是最用户友好且最安全的解决方案,因为电子邮件可以有大小写敏感的本地部分(在 @ 之前),但很少这样,并且对于域部分(在 @ 之后)必须是大小写不敏感的。因此,通过存储给定的大小写,我们确保电子邮件被正确投递,同时确保每个电子邮件地址只有一个账户。更多信息请参阅 stackoverflow.com/questions/9807909/are-email-addresses-case-sensitive

接下来,我们需要添加可以更改数据库中数据的函数,通过在 backend/src/models/member.py 中添加以下内容:

async def insert_member(
    db: Connection, email: str, password_hash: str
) -> Member:
    result = await db.fetch_one(
        """INSERT INTO members (email, password_hash)
                VALUES (:email, :password_hash)
             RETURNING id, email, password_hash, created,
                       email_verified""",
        {"email": email, "password_hash": password_hash},
    )
    return Member(**result)

async def update_member_password(
    db: Connection, id: int, password_hash: str
) -> None:
    await db.execute(
        """UPDATE members 
              SET password_hash = :password_hash 
            WHERE id = :id""",
        {"id": id, "password_hash": password_hash},
    )

async def update_member_email_verified(
    db: Connection, id: int
) -> None:
    await db.execute(
        "UPDATE members SET email_verified = now() WHERE id = :id",
        {"id": id},
    )

这些函数与我们将很快添加到 API 中的功能相匹配。

我们应该通过添加以下内容到 backend/tests/models/test_member.py 来测试大小写敏感性:

import pytest
from asyncpg.exceptions import UniqueViolationError  # type: ignore
from quart_db import Connection
from backend.models.member import insert_member
async def test_insert_member(connection: Connection) -> None:
    await insert_member(connection, "casing@tozo.dev", "")
    with pytest.raises(UniqueViolationError):
        await insert_member(connection, "Casing@tozo.dev", "")

首先,我们想要一个测试来确保 insert_member 正确地拒绝了一个具有不同大小写的第二个成员。高亮行确保在执行时,其内的行会引发 UniqueViolationError,从而防止成员再次被插入。

我们还需要测试 select_member_by_email 函数是否不区分大小写,通过在 backend/tests/models/test_member.py 中添加以下内容:

from backend.models.member import select_member_by_email
async def test_select_member_by_email (connection: Connection) -> None:
    await insert_member(connection, "casing@tozo.dev", "")
    member = await select_member_by_email(
        connection, "Casing@tozo.dev"
    )
    assert member is not None

以这种方式设置模型代码后,我们将在后端代码的任何需要的地方直接使用这些函数和类实例。

创建待办事项模式和模型

我们还希望为每个待办事项存储信息,特别是待办事项任务作为文本,待办事项应完成的日期(尽管这应该是可选的),以及待办事项是否完成。此外,每个待办事项都应该与其所属成员相关联。

此数据模式由以下 SQL 给出,供参考,将在 运行第一次迁移 部分中使用:

CREATE TABLE todos (
    id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
    complete BOOLEAN NOT NULL DEFAULT FALSE,
    due TIMESTAMPTZ,
    member_id INT NOT NULL REFERENCES members(id),
    task TEXT NOT NULL
);

对应的后端模型由以下代码给出,该代码应添加到 backend/src/backend/models/todo.py 中:

from dataclasses import dataclass
from datetime import datetime
from pydantic import constr

@dataclass
class Todo:
    complete: bool
    due: datetime | None
    id: int
    task: constr(strip_whitespace=True, min_length=1)  # type: ignore

在这里,constr 用于代替 str,以确保空字符串不被视为有效。除了模型外,我们还可以在 backend/src/backend/models/todo.py 中添加以下函数,以便在后端模型和从数据库中读取的 SQL 之间进行转换:

from quart_db import Connection
async def select_todos(
    connection: Connection, 
    member_id: int, 
    complete: bool | None = None,
) -> list[Todo]:
    if complete is None:
        query = """SELECT id, complete, due, task
                     FROM todos
                    WHERE member_id = :member_id"""
        values = {"member_id": member_id}
    else:
        query = """SELECT id, complete, due, task
                     FROM todos
                    WHERE member_id = :member_id 
                          AND complete = :complete"""
        values = {"member_id": member_id, "complete": complete}
    return [
        Todo(**row) 
        async for row in connection.iterate(query, values)
    ]
async def select_todo(
    connection: Connection, id: int, member_id: int,
) -> Todo | None:
    result = await connection.fetch_one(
        """SELECT id, complete, due, task
             FROM todos
            WHERE id = :id AND member_id = :member_id""",
        {"id": id, "member_id": member_id},
    )
    return None if result is None else Todo(**result)

这些函数允许从数据库中读取待办事项,但只会返回属于给定 member_id 的待办事项。使用这些函数应确保我们不会将待办事项返回给错误成员。

接下来,我们需要添加可以更改数据库中数据的函数,通过在 backend/src/models/todo.py 中添加以下内容:

async def insert_todo(
    connection: Connection, 
    member_id: int,
    task: str,
    complete: bool,
    due: datetime | None, 
) -> Todo:
    result = await connection.fetch_one(
        """INSERT INTO todos (complete, due, member_id, task)
                VALUES (:complete, :due, :member_id, :task)
             RETURNING id, complete, due, task""",
        {
            "member_id": member_id, 
            "task": task, 
            "complete": complete, 
            "due": due,
        },
    )
    return Todo(**result)
async def update_todo(
    connection: Connection, 
    id: int, 
    member_id: int,
    task: str,
    complete: bool,
    due: datetime | None,
) -> Todo | None:
    result = await connection.fetch_one(
        """UPDATE todos
              SET complete = :complete, due = :due, 
                  task = :task
            WHERE id = :id AND member_id = :member_id
        RETURNING id, complete, due, task""",
        {
            "id": id,
            "member_id": member_id, 
            "task": task, 
            "complete": complete, 
            "due": due,
        },
    )
    return None if result is None else Todo(**result)

async def delete_todo(
    connection: Connection, id: int, member_id: int,
) -> None:
    await connection.execute(
        "DELETE FROM todos WHERE id = :id AND member_id = :member_id",
        {"id": id, "member_id": member_id},
    )

注意,所有这些函数也接受一个 member_id 参数,并且只影响属于给定 member_id 的待办事项。这将帮助我们避免授权错误,即我们编写的代码错误地允许成员访问或修改其他成员的待办事项。

这是我们应该测试的,通过在 backend/tests/models/test_todo.py 中添加以下内容。首先,我们想要一个测试来确保 delete_todo 正确地删除了待办事项:

import pytest
from quart_db import Connection
from backend.models.todo import (
    delete_todo, insert_todo, select_todo, update_todo
)
@pytest.mark.parametrize(
    "member_id, deleted",
    [(1, True), (2, False)],
)
async def test_delete_todo(
    connection: Connection, member_id: int, deleted: bool
) -> None:
    todo = await insert_todo(        connection, 1, "Task", False, None     )
    await delete_todo(connection, todo.id, member_id)
    new_todo = await select_todo(connection, todo.id, 1)
    assert (new_todo is None) is deleted

突出的参数化提供了两个测试。第一个测试确保 member_id 1 可以删除他们的待办事项,第二个测试确保 member_id 2 不能删除另一个用户的待办事项。

我们还应该添加一个类似的测试来确保更新按预期工作:

@pytest.mark.parametrize(
    "member_id, complete",
    [(1, True), (2, False)],
)
async def test_update_todo(
    connection: Connection, member_id: int, complete: bool
) -> None:
    todo = await insert_todo(        connection, 1, "Task", False, None     )
    await update_todo(
        connection, todo.id, member_id, "Task", True, None
    )
    new_todo = await select_todo(connection, todo.id, 1)
    assert new_todo is not None
    assert new_todo.complete is complete

参数化提供了两个测试。第一个测试确保 member_id 1 的成员可以更新他们的待办事项,第二个测试确保 member_id 2 的成员不能更新其他用户的待办事项。

虽然我们已经设置了这些重要的测试,但我们不能运行它们,直到通过迁移创建数据库表。

运行第一个迁移

尽管我们已经编写了创建数据库模式的 SQL 查询,但它们还没有在数据库上运行。为了运行这些查询,Quart-DB 提供了一个迁移系统,允许我们在后端启动时运行查询,但前提是它们尚未运行。为了利用这个功能,我们可以在 backend/src/backend/migrations/0.py 文件中添加以下代码:

from quart_db import Connection

async def migrate(connection: Connection) -> None:
    await connection.execute(
        """CREATE TABLE members (
               id INT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
               created TIMESTAMP NOT NULL DEFAULT now(),
               email TEXT NOT NULL,
               email_verified TIMESTAMP,
               password_hash TEXT NOT NULL
           )""",
    )
    await connection.execute(
        """CREATE UNIQUE INDEX members_unique_email_idx 
                            ON members (LOWER(email)
        )"""
    )
    await connection.execute(
        """CREATE TABLE todos (
               id BIGINT PRIMARY KEY GENERATED ALWAYS AS                  IDENTITY,
               complete BOOLEAN NOT NULL DEFAULT FALSE,
               due TIMESTAMPTZ,
               member_id INT NOT NULL REFERENCES members(id),
               task TEXT NOT NULL
           )""",
    )
async def valid_migration(connection: Connection) -> bool:
    return True

要看到此迁移生效,您可以运行 pdm run recreate-db 然后启动后端(因为迁移将在后端启动时运行)。然后您可以使用 psql –U tozo 检查数据库,并看到如图 3.1 所示的两个新表:

图 3.1:迁移后的数据库模式。

图 3.1:迁移后的数据库模式。

members 表和 todos 表之间存在一对一的关系,即一个成员可以有多个待办事项。此外,请注意,schema_migration 表是由 Quart-DB 创建和管理的,用于跟踪迁移。

添加测试和开发数据

在开发和运行测试时,在数据库中拥有一些标准化的初始数据很有帮助;例如,我们可以添加一个具有已知凭证的标准成员以登录,而不是每次数据库重建时都必须创建一个新成员。为此,我们可以利用 Quart-DB 的数据路径功能。

为了方便使用,我们将在 backend/src/backend/migrations/data.py 文件中添加单个成员到数据库,如下所示:

from quart_db import Connection 

async def execute(connection: Connection) -> None:
    await connection.execute(
        """INSERT INTO members (email, password_hash)
                VALUES ('member@tozo.dev', '$2b$14$6yXjNza30kPCg3LhzZJfqeCWOLM.zyTiQFD4rdWlFHBTfYzzKJMJe'
           )"""
    )
    await connection.execute(
        """INSERT INTO todos (member_id, task)
                VALUES (1, 'Test Task')"""
    )

密码散列值对应于 password 的值,这意味着登录将使用 member@tozo.devpassword 的电子邮件和密码组合。

要指示 Quart-DB 加载并运行此文件,我们需要在 backend/development.envbackend/testing.env 文件中添加以下配置变量:

TOZO_QUART_DB_DATA_PATH="migrations/data.py"

我们现在可以在 backend 目录中运行以下命令来运行测试并检查它们是否通过:

pdm run test

现在我们已经定义了后端存储的数据,我们可以专注于 API,从会话管理开始。

构建 session API

为了管理用户会话,我们需要一个提供登录和登出(即创建和删除会话)路由的 会话(身份验证)API。登录应设置一个 cookie,而登出则删除 cookie。根据身份验证设置,登录应需要电子邮件和匹配的密码。我们将通过包含登录、登出和状态功能的会话蓝图添加此 API。

创建蓝图

蓝图是一组路由处理程序,用于关联相关的会话功能。它可以通过在 backend/src/backend/blueprints/sessions.py 文件中的以下代码创建:

from quart import Blueprint
blueprint = Blueprint("sessions", __name__)

然后,该蓝图需要通过在backend/src/backend/run.py中添加以下内容来注册到应用中:

from backend.blueprints.sessions import blueprint as sessions_blueprint
app.register_blueprint(sessions_blueprint)

在创建了蓝图之后,我们现在可以添加特定功能作为路由。

添加登录功能

登录功能被描述为 RESTful 风格中的创建会话,因此该路由应该是POST,期望一个电子邮件、一个密码和一个记住标志,在成功时返回200,在凭证无效时返回401。这是通过以下内容完成的,应添加到backend/src/backend/blueprints/sessions.py

from dataclasses import dataclass
from datetime import timedelta

import bcrypt
from pydantic import EmailStr
from quart import g, ResponseReturnValue
from quart_auth import AuthUser, login_user
from quart_rate_limiter import rate_limit
from quart_schema import validate_request

from backend.lib.api_error import APIError
from backend.models.member import select_member_by_email

@dataclass
class LoginData:
    email: EmailStr
    password: str
    remember: bool = False

@blueprint.post("/sessions/")
@rate_limit(5, timedelta(minutes=1))
@validate_request(LoginData)
async def login(data: LoginData) -> ResponseReturnValue:
    """Login to the app.

    By providing credentials and then saving the     returned cookie.
    """
    result = await select_member_by_email(        g.connection, data.email     )
    if result is None:
        raise APIError(401, "INVALID_CREDENTIALS")
    passwords_match = bcrypt.checkpw(
        data.password.encode("utf-8"),
        result.password_hash.encode("utf-8"),
    )
    if passwords_match:
        login_user(AuthUser(str(result.id)), data.remember)
        return {}, 200
    else:
        raise APIError(401, "INVALID_CREDENTIALS")

此路由的速率限制低于其他路由(每分钟五次请求)以防止恶意行为者暴力破解登录。这就是恶意行为者不断尝试不同密码,希望最终能够正确并允许登录的地方。

该路由还验证请求数据是否具有正确的LoginData结构,这确保了用户正确使用此路由,并防止无效数据在路由处理程序代码中引起错误。

根据请求数据中提供的电子邮件,路由本身尝试从数据库中获取成员的详细信息。如果没有数据,则返回401响应。然后,将请求数据中提供的密码与数据库中的密码散列进行比对,匹配成功则成员通过200响应登录。如果密码不匹配,则返回401响应。

跟随斜杠

对于此路由以及应用中的所有其他路由,我已添加了跟随斜杠,以便路径为/sessions/而不是/sessions。这是一个有用的约定,因为对/sessions的请求将被自动重定向到/sessions/,因此即使缺少斜杠也能正常工作,而如果路由没有跟随斜杠定义,对/sessions/的请求则不会被重定向到/session

登录会导致 cookie 存储在成员的浏览器中,然后在每个后续请求中发送。此 cookie 的存在和值用于确定成员是否已登录,以及哪个成员发出了请求。

账户枚举

此实现将允许攻击者列出数据库中存在的电子邮件,这可以被视为一个安全问题。请参阅第七章**,关于如何减轻此问题的影响

添加注销功能

注销路由被描述为 RESTful 风格中的会话删除,因此该路由应该是DELETE,返回200。以下内容应添加到backend/src/backend/blueprints/sessions.py

from quart_auth import logout_user
from quart_rate_limiter import rate_exempt

@blueprint.delete("/sessions/")
@rate_exempt
async def logout() -> ResponseReturnValue:
    """Logout from the app.

    Deletes the session cookie.
    """
    logout_user()
    return {}

此路由不受速率限制,因为不应该有任何东西阻止成员注销 – 确保注销功能正常工作,以便成员在想要注销时能够注销。然后,该路由只需要调用logout_user,这将导致 cookie 被删除。

幂等路由

幂等性是路由的一个属性,即无论该路由被调用多少次,最终状态都是相同的,也就是说,调用该路由一次或十次具有相同的效果。这是一个有用的属性,因为它意味着如果请求失败,可以安全地重试路由。对于 RESTful 和 HTTP API,使用 GETPUTDELETE 动词的路由预期是幂等的。在本书中,使用 GETPUTDELETE 动词的路由是幂等的。

添加状态功能

有一个返回当前会话(状态)的路由很有用,因为我们将用它进行调试和测试。对于 RESTful API,这应该是一个 GET 路由,以下内容应添加到 backend/src/backend/blueprints/sessions.py

from quart_auth import current_user, login_required
from quart_schema import validate_response

@dataclass
class Status:
    member_id: int

@blueprint.get("/sessions/")
@rate_limit(10, timedelta(minutes=1))
@login_required
@validate_response(Status)
async def status() -> ResponseReturnValue:
    assert current_user.auth_id is not None  # nosec
    return Status(member_id=int(current_user.auth_id))

突出的断言用于通知类型检查器在此函数中 current_user.auth_id 不能为 None,从而防止类型检查器将后续行视为错误。# nosec 注释通知 bandit 安全检查器这种 assert 的使用不是安全风险。

为了保护,路由被速率限制,并且只有在请求中存在从登录处获取的正确 cookie 时才会运行。该路由根据 cookie 中的值返回成员 ID,因为这同样非常有用。

测试路由

我们应该测试这些路由是否按用户预期工作,首先测试用户可以登录,获取其状态,然后注销,作为一个完整的流程。这是通过在 backend/tests/blueprints/test_sessions.py 中添加以下内容来测试的:

from quart import Quart

async def test_session_flow(app: Quart) -> None:
    test_client = app.test_client()
    await test_client.post(
        "/sessions/",
        json={
            "email": "member@tozo.dev", "password": "password"
        },
    )
    response = await test_client.get("/sessions/")
    assert (await response.get_json())["memberId"] == 1
    await test_client.delete("/sessions/")
    response = await test_client.get("/sessions/")
    assert response.status_code == 401

此测试确保成员可以登录并访问需要他们登录的路由。然后它注销成员并检查他们不能再访问该路由。

我们还应该测试如果提供了错误的凭据,登录路由是否返回正确的响应,通过在 backend/tests/blueprints/test_sessions.py 中添加以下测试:

async def test_login_invalid_password(app: Quart) -> None:
    test_client = app.test_client()
    await test_client.post(
        "/sessions/",
        json={
            "email": "member@tozo.dev", "password": "incorrect"
        },
    )
    response = await test_client.get("/sessions/")
    assert response.status_code == 401

这就是我们允许成员登录和注销所需的所有内容。接下来,我们可以专注于管理成员。

构建成员 API

为了管理成员,我们需要一个 API,它提供创建成员(注册)、确认电子邮件地址、更改密码、请求密码重置和重置密码的路由。

我们将通过为成员创建一个蓝图来添加此 API,包含注册、电子邮件确认、更改密码和密码重置功能。

创建成员蓝图

首先,我们应该为所有成员路由创建一个蓝图,它是在 backend/src/backend/blueprints/members.py 中的以下代码创建的:

from quart import Blueprint
blueprint = Blueprint("members", __name__)

随后需要将蓝图注册到应用中,通过在 backend/src/backend/run.py 中添加以下内容:

from backend.blueprints.members import blueprint as members_blueprint
app.register_blueprint(members_blueprint)

在蓝图创建后,我们现在可以添加特定的功能作为路由。

创建成员

在我们的应用中,我们希望用户能够注册成为会员。这需要一个接受电子邮件和密码的路由。然后,该路由应检查密码是否足够复杂,创建一个新的会员,并发送欢迎邮件。当路由创建会员时,它应使用POST方法以符合 RESTful 风格。

我们将在欢迎电子邮件中添加一个链接,收件人可以访问以证明他们使用我们的应用进行了注册。这样,我们就验证了电子邮件地址的所有者与注册的用户是同一人。该链接将通过在路径中包含一个认证令牌来工作,该令牌的作用如第二章中所述,即使用 Quart 创建可重用的后端。

我们可以通过首先创建一个电子邮件模板来实现这一点,将以下内容添加到backend/src/backend/templates/welcome.xhtml

{% extends "email.xhtml" %}
{% block welcome %}
  Hello and welcome to tozo!
{% endblock %}
{% block content %}
  Please confirm you signed up by following this 
  <a href="{{ config['BASE_URL'] }}/confirm-email/{{ token }}/">
    link
  </a>.
{% endblock %}

该路由本身应在成功时返回201,因为此状态码表示成功创建。所有这些都可以通过将以下内容添加到backend/src/backend/blueprints/members.py来实现:

from dataclasses import dataclass
from datetime import timedelta

import asyncpg  # type: ignore
import bcrypt
from itsdangerous import URLSafeTimedSerializer
from quart import current_app, g, ResponseReturnValue
from quart_schema import validate_request
from quart_rate_limiter import rate_limit
from zxcvbn import zxcvbn  # type: ignore

from backend.lib.api_error import APIError
from backend.lib.email import send_email
from backend.models.member import insert_member

MINIMUM_STRENGTH = 3
EMAIL_VERIFICATION_SALT = "email verify"

@dataclass
class MemberData:
    email: str
    password: str

@blueprint.post("/members/")
@rate_limit(10, timedelta(seconds=10))
@validate_request(MemberData)
async def register(data: MemberData) -> ResponseReturnValue:
    """Create a new Member.

    This allows a Member to be created.
    """
    strength = zxcvbn(data.password)
    if strength["score"] < MINIMUM_STRENGTH:
        raise APIError(400, "WEAK_PASSWORD")

    hashed_password = bcrypt.hashpw(
        data.password.encode("utf-8"), 
        bcrypt.gensalt(14),
    )
    try:
        member = await insert_member(
            g.connection, 
            data.email, 
            hashed_password.decode(),
        )
    except asyncpg.exceptions.UniqueViolationError:
        pass
    else:
        serializer = URLSafeTimedSerializer(
            current_app.secret_key,             salt=EMAIL_VERIFICATION_SALT,
        )
        token = serializer.dumps(member.id)
        await send_email(
            member.email, 
            "Welcome", 
            "welcome.xhtml", 
            {"token": token},
        )
    return {}, 201

如所见,首先使用zxcvbn检查密码强度,弱密码将导致返回400响应。然后,将密码进行散列,并与电子邮件一起插入会员。接着,使用新会员的 ID 创建一个电子邮件验证令牌,在发送到指定的电子邮件地址之前将其渲染到电子邮件正文中。

当用户点击链接时,他们将通过电子邮件确认路由的令牌返回到我们的应用进行验证。

确认电子邮件地址

当用户注册为会员时,他们会被发送回我们的应用的一个链接,该链接包含一个电子邮件验证令牌。该令牌识别会员,从而确认电子邮件地址是正确的。因此,我们需要一个接受令牌的路由,如果有效,则确认电子邮件地址。这将在 RESTful 意义上更新会员的电子邮件属性,因此可以通过将以下内容添加到backend/src/backend/blueprints/members.py来实现:

from itsdangerous import BadSignature, SignatureExpired 
from backend.models.member import update_member_email_verified
ONE_MONTH = int(timedelta(days=30).total_seconds()) 

@dataclass
class TokenData:
    token: str

@blueprint.put("/members/email/")
@rate_limit(5, timedelta(minutes=1))
@validate_request(TokenData)
async def verify_email(data: TokenData) -> ResponseReturnValue:
    """Call to verify an email.

    This requires the user to supply a valid token.
    """
    serializer = URLSafeTimedSerializer(
        current_app.secret_key, salt=EMAIL_VERIFICATION_SALT
    )
    try:
        member_id = serializer.loads(            data.token, max_age=ONE_MONTH         )
    except SignatureExpired:
        raise APIError(403, "TOKEN_EXPIRED")
    except BadSignature:
        raise APIError(400, "TOKEN_INVALID")
    else:
        await update_member_email_verified(g.connection,          member_id)
    return {} 

通过loads方法检查令牌,如果已过期则返回403响应,如果无效则返回400响应。如果令牌有效,则在数据库中将会员的电子邮件标记为已验证,并返回200响应。

一旦用户注册,并且希望验证了他们的电子邮件,他们希望能够更改他们的密码。

更改密码

用户可能想要更改他们的密码,这需要一个接受他们新密码和旧密码的路由。检查旧密码是为了使会员的账户更加安全,因为恶意用户通过无人看管的电脑获取访问权限时无法更改会员的密码(除非他们也知道会员的密码)。该路由还需要检查新密码的复杂度,与注册路由相同。

路径还应通知用户密码已通过电子邮件更改。这样做可以使成员的账户更加安全,因为如果成员被告知有未经授权的密码更改,他们可以采取纠正措施。此电子邮件通过在 backend/src/backend/templates/password_changed.xhtml 中添加以下内容来定义:

{% extends "email.xhtml" %}

{% block content %}
  Your Tozo password has been successfully changed.
{% endblock %}

此路由将更新密码,在 RESTful 风格中意味着在成员密码资源上的PUT路由,在成功时返回200。如果密码不够复杂,则应返回400响应,如果旧密码不正确,则返回401响应。以下内容应添加到 backend/src/backend/blueprints/members.py

from typing import cast
from quart_auth import current_user, login_required
from backend.models.member import select_member_by_id, update_member_password

@dataclass
class PasswordData:
    current_password: str
    new_password: str

@blueprint.put("/members/password/")
@rate_limit(5, timedelta(minutes=1))
@login_required
@validate_request(PasswordData)
async def change_password(data: PasswordData) -> ResponseReturnValue:
    """Update the members password.

    This allows the user to update their password.
    """
    strength = zxcvbn(data.new_password)
    if strength["score"] < MINIMUM_STRENGTH:
        raise APIError(400, "WEAK_PASSWORD")

    member_id = int(cast(str, current_user.auth_id))
    member = await select_member_by_id(
        g.connection, member_id
    )
    assert member is not None  # nosec
    passwords_match = bcrypt.checkpw(
        data.current_password.encode("utf-8"),
        member.password_hash.encode("utf-8"),
    )
    if not passwords_match:
        raise APIError(401, "INVALID_PASSWORD")

    hashed_password = bcrypt.hashpw(
        data.new_password.encode("utf-8"),
        bcrypt.gensalt(14),
    )
    await update_member_password(
        g.connection, member_id, hashed_password.decode()
    )
    await send_email(
        member.email, 
        "Password changed", 
        "password_changed.xhtml", 
        {},
    )
    return {}

与登录路由一样,此路由有一个较低的速率限制,以减轻暴力攻击的风险。然后,代码检查密码强度,然后再检查旧密码是否与数据库中存储的哈希值匹配。如果这些检查通过,数据库中的密码哈希值将被更新,并向成员发送电子邮件。

此功能故意对忘记密码的成员没有用。在这种情况下,他们首先需要请求密码重置。

请求密码重置

如果成员忘记了他们的密码,他们希望有一种方法来重置它。这通常是通过向成员发送一个链接来实现的,他们可以点击该链接访问密码重置页面,链接中包含一个用于授权重置的令牌——就像电子邮件验证一样。为了使这成为可能,我们首先需要一个接受用户电子邮件地址并发送链接的路由。首先,让我们将以下电子邮件内容添加到 backend/src/backend/templates/forgotten_password.xhtml

{% extends "email.xhtml" %}
{% block content %}
  You can use this 
  <a href="{{ config['BASE_URL'] }}/reset-password/{{ token     }}/">
    link
  </a> 
  to reset your password.
{% endblock %}

路径本身应接受一个电子邮件地址,并且按照 RESTful 风格,应是对成员电子邮件资源的PUT操作。以下内容应添加到 backend/src/backend/blueprints/members.py

from pydantic import EmailStr
from backend.models.member import select_member_by_email

FORGOTTEN_PASSWORD_SALT = "forgotten password"  # nosec

@dataclass
class ForgottenPasswordData:
    email: EmailStr

@blueprint.put("/members/forgotten-password/")
@rate_limit(5, timedelta(minutes=1))
@validate_request(ForgottenPasswordData)
async def forgotten_password(data: ForgottenPasswordData) -> ResponseReturnValue:
    """Call to trigger a forgotten password email.

    This requires a valid member email.
    """
    member = await select_member_by_email(        g.connection, data.email     )
    if member is not None:
        serializer = URLSafeTimedSerializer(
            current_app.secret_key,             salt=FORGOTTEN_PASSWORD_SALT,
        )
        token = serializer.dumps(member.id)
        await send_email(
            member.email, 
            "Forgotten password", 
            "forgotten_password.xhtml", 
            {"token": token},
        )
    return {}

此路由使用忘记密码的盐创建一个令牌。确保盐不同很重要,以确保这些令牌不能用来代替电子邮件验证令牌,反之亦然。然后,将令牌渲染到电子邮件中并发送给成员。

重置密码

如果成员跟随之前路由发出的电子邮件中的链接,他们将访问一个允许他们输入新密码的页面。因此,我们需要一个接受新密码和令牌的路由。这是通过在 backend/src/backend/blueprints/members.py 中添加以下内容来实现的:

ONE_DAY = int(timedelta(hours=24).total_seconds())
@dataclass
class ResetPasswordData:
    password: str
    token: str

@blueprint.put("/members/reset-password/")
@rate_limit(5, timedelta(minutes=1))
@validate_request(ResetPasswordData)
async def reset_password(data: ResetPasswordData) -> ResponseReturnValue:
    """Call to reset a password using a token.

    This requires the user to supply a valid token and a
    new password.
    """
    serializer = URLSafeTimedSerializer(
        current_app.secret_key, salt=FORGOTTEN_PASSWORD_SALT
    )
    try:
        member_id = serializer.loads(data.token, max_age=ONE_          DAY)
    except SignatureExpired:
        raise APIError(403, "TOKEN_EXPIRED")
    except BadSignature:
        raise APIError(400, "TOKEN_INVALID")
    else:
        strength = zxcvbn(data.password)
        if strength["score"] < MINIMUM_STRENGTH:
            raise APIError(400, "WEAK_PASSWORD")

        hashed_password = bcrypt.hashpw(
            data.password.encode("utf-8"), 
            bcrypt.gensalt(14),
        )
        await update_member_password(
            g.connection, member_id, hashed_password.decode()
        )
        member = await select_member_by_id(
            g.connection, int(cast(str, current_user.auth_id))
        )
        assert member is not None  # nosec
        await send_email(
            member.email, 
            "Password changed", 
            "password_changed.xhtml", 
            {},
        )
    return {}

此路由检查令牌是否有效,如果无效则返回400,如果已过期则返回403。过期很重要,因为它可以防止成员的电子邮件在未来被泄露(因为令牌已过期,因此无用)。然后,如果新密码足够强大,新的哈希值将被放入数据库。

管理成员

我们已经添加了创建成员和管理成员密码的功能。然而,我们还没有添加管理成员账户本身的功能,例如关闭和删除它。这个功能将取决于您应用的监管规则,例如,您可能需要保留数据一定的时间。

通过这个路由,我们拥有了所有需要的成员账户功能,现在可以专注于测试这些功能。

测试路由

我们应该测试这些路由是否按用户预期的方式工作。首先,让我们通过在*backend/tests/blueprints/test_members.py*中添加以下内容来测试新成员可以注册并登录:

import pytest
from quart import Quart

async def test_register(
    app: Quart, caplog: pytest.LogCaptureFixture 
) -> None:
    test_client = app.test_client()
    data = {
        "email": "new@tozo.dev", 
        "password": "testPassword2$",
    }
    await test_client.post("/members/", json=data)
    response = await test_client.post("/sessions/", json=data)
    assert response.status_code == 200
    assert "Sending welcome.xhtml to new@tozo.dev" in caplog.text

此测试使用电子邮件new@tozo.dev注册新成员,然后检查是否向此地址发送了欢迎邮件。接下来,我们需要检查用户是否可以通过添加以下内容到*backend/tests/blueprints/test_members.py*来确认他们的电子邮件地址:

from itsdangerous import URLSafeTimedSerializer
from freezegun import freeze_time
from backend.blueprints.members import EMAIL_VERIFICATION_SALT
@pytest.mark.parametrize(
    "time, expected",
    [("2022-01-01", 403), (None, 200)],
)
async def test_verify_email(
    app: Quart, time: str | None, expected: int
) -> None:
    with freeze_time(time):
        signer = URLSafeTimedSerializer(
            app.secret_key, salt= EMAIL_VERIFICATION_SALT
        )
        token = signer.dumps(1)
    test_client = app.test_client()
    response = await test_client.put(
        "/members/email/", json={"token": token}
    )
    assert response.status_code == expected
async def test_verify_email_invalid_token(app: Quart) -> None:
    test_client = app.test_client()
    response = await test_client.put( 
        "/members/email/", json={"token": "invalid"} 
    ) 
    assert response.status_code == 400

突出的行使我们能够确保过期的令牌导致返回403响应,而当前令牌则成功。第二个测试确保无效的令牌导致返回400响应。

接下来,我们将测试成员是否可以通过添加以下内容到*backend/tests/blueprints/test_members.py*来更改他们的密码:

async def test_change_password(
    app: Quart, caplog: pytest.LogCaptureFixture 
) -> None:
    test_client = app.test_client()
    data = {
        "email": "new_password@tozo.dev", 
        "password": "testPassword2$",
    }
    response = await test_client.post("/members/", json=data)
    async with test_client.authenticated("2"):  # type: ignore
        response = await test_client.put(
            "/members/password/", 
            json={
                "currentPassword": data["password"], 
                "newPassword": "testPassword3$",
            }
        )
        assert response.status_code == 200
    assert "Sending password_changed.xhtml to new@tozo.dev" in caplog.text

此测试注册新成员,然后,在以该成员身份认证的情况下更改密码。

然后,我们可以测试忘记密码的用户是否可以通过添加以下内容到*backend/tests/blueprints/test_members.py*来请求重置链接:

async def test_forgotten_password(
    app: Quart, caplog: pytest.LogCaptureFixture 
) -> None:
    test_client = app.test_client()
    data = {"email": "member@tozo.dev"}
    response = await test_client.put(
        "/members/forgotten-password/", json=data
    )
    assert response.status_code == 200
    assert "Sending forgotten_password.xhtml to member@tozo.dev" in caplog.text

现在我们已经有了这些简单的测试,我们可以专注于待办事项 API。

构建待办事项 API

为了管理待办事项,我们需要一个提供创建新待办事项、检索待办事项或待办事项列表、更新待办事项和删除待办事项(即具有 CRUD 功能的 API)。我们将通过创建一个包含每个 CRUD 功能的路由的待办事项蓝图来实现这一点。

CRUD 功能

创建读取更新删除,用于描述一组功能。它通常用于描述 RESTful API 的功能。通常,对于 RESTful API,创建路由使用POST HTTP 方法,读取使用GET,更新使用PUT,删除使用DELETE

创建蓝图

可以使用以下代码在*backend/src/backend/blueprints/todos.py*中创建蓝图:

from quart import Blueprint
blueprint = Blueprint("todos", __name__)

然后需要将蓝图注册到应用中,通过在*backend/src/backend/run.py*中添加以下内容:

from backend.blueprints.todos import blueprint as todos_blueprint
app.register_blueprint(todos_blueprint)

创建蓝图后,我们现在可以添加特定的功能作为路由。

创建待办事项

我们首先需要的功能是创建一个待办事项。该路由应期望待办事项数据,并在成功时返回包含201状态码的完整待办事项。返回完整的待办事项是有用的,因为它包含了待办事项的 ID,并确认数据已添加。一个 RESTful 的待办事项创建路由应使用 POST 动词,并具有/todos/路径。以下内容应添加到*backend/src/backend/blueprints/todos.py*

from dataclasses import dataclass 
from datetime import datetime, timedelta
from typing import cast

from quart import g
from quart_auth import current_user, login_required
from quart_schema import validate_request, validate_response
from quart_rate_limiter import rate_limit

from backend.models.todo import insert_todo, Todo
@dataclass
class TodoData:
    complete: bool
    due: datetime | None
    task: str

@blueprint.post("/todos/")
@rate_limit(10, timedelta(seconds=10))
@login_required
@validate_request(TodoData)
@validate_response(Todo, 201)
async def post_todo(data: TodoData) -> tuple[Todo, int]:
    """Create a new Todo.

    This allows todos to be created and stored.
    """
    todo = await insert_todo(
        g.connection, 
        int(cast(str, current_user.auth_id)),
        data.task,
        data.complete,
        data.due,
    )
    return todo, 201

该路由被速率限制以防止恶意使用,假设普通用户在 10 秒内不太可能创建超过 10 个待办事项(平均每秒 1 个)。它也是一个需要用户登录的路由。最后的两个装饰器确保请求和响应数据代表待办事项数据和完整的待办事项。

路由函数只是将数据插入数据库并返回完整的待办事项。接下来,用户需要从后端读取一个待办事项。

读取待办事项

用户需要根据其 ID 读取一个待办事项。这将作为一个带有路径中指定 ID 的GET路由来实现。该路由应返回待办事项或如果待办事项不存在则返回404响应。以下内容应添加到*backend/src/backend/blueprints/todos.py*

from backend.lib.api_error import APIError
from backend.models.todo import select_todo

@blueprint.get("/todos/<int:id>/")
@rate_limit(10, timedelta(seconds=10))
@login_required
@validate_response(Todo)
async def get_todo(id: int) -> Todo:
    """Get a todo.

    Fetch a Todo by its ID.
    """
    todo = await select_todo(
        g.connection, id, int(cast(str, current_user.auth_id))
    )
    if todo is None:
        raise APIError(404, "NOT_FOUND")
    else:
        return todo

与创建路由一样,此路由包括速率限制保护、需要用户登录,并验证响应数据。然后根据路径中给出的 ID 从数据库中选择待办事项,并返回它或如果不存在待办事项则返回404响应。请注意,select_todo函数需要成员的 ID,确保成员不能读取其他成员的待办事项。

虽然读取单个待办事项很有用,但用户还需要在一次调用中读取他们所有的待办事项,我们将在下面添加。

读取待办事项

用户需要读取他们所有的待办事项,对于 RESTFul API,应使用GET动词并在成功时返回待办事项列表。我们还将允许用户根据complete属性过滤待办事项,这应该是可选的,因此在 RESTful API 中,它通过querystring提供。querystring通过请求路径工作,例如,/todos/?complete=true/todos/?complete=false。以下内容应添加到*backend/src/backend/blueprints/todos.py*

from quart_schema import validate_querystring

from backend.models.todo import select_todos

@dataclass
class Todos:
    todos: list[Todo]

@dataclass
class TodoFilter:
    complete: bool | None = None

@blueprint.get("/todos/")
@rate_limit(10, timedelta(seconds=10))
@login_required
@validate_response(Todos)
@validate_querystring(TodoFilter)
async def get_todos(query_args: TodoFilter) -> Todos:
    """Get the todos.

    Fetch all the Todos optionally based on the     complete status.
    """
    todos = await select_todos(
        g.connection, 
        int(cast(str, current_user.auth_id)), 
        query_args.complete,
    )
    return Todos(todos=todos)

此路由包括速率限制保护、需要登录使用、验证响应数据,并包括验证querystring参数。现在我们可以继续允许更新待办事项。

更新待办事项

我们需要提供成员更新构成待办事项的数据的功能。对于 RESTFul API,此路由应使用PUT动词,期望待办事项数据,并在成功时返回完整的待办事项或如果待办事项不存在则返回404。以下内容应添加到*backend/src/backend/blueprints/todos.py*

from backend.models.todo import update_todo

@blueprint.put("/todos/<int:id>/")
@rate_limit(10, timedelta(seconds=10))
@login_required
@validate_request(TodoData)
@validate_response(Todo)
async def put_todo(id: int, data: TodoData) -> Todo:
    """Update the identified todo

    This allows the todo to be replaced with the request data.
    """
    todo = await update_todo(
        g.connection, 
        id,  
        int(cast(str, current_user.auth_id)),
        data.task,
        data.complete,
        data.due,
    )
    if todo is None:
        raise APIError(404, "NOT_FOUND")
    else:
        return todo

此路由包括速率限制保护、需要登录使用,并验证请求和响应数据。然后更新待办事项并返回更新后的待办事项或对于提供的 ID 没有待办事项时的404响应。接下来,我们将允许用户删除待办事项。

删除待办事项

对于 RESTFul API,待办事项删除路由应使用DELETE动词,无论待办事项是否存在都返回202。以下内容应添加到*backend/src/backend/blueprints/todos.py*

from quart import ResponseReturnValue

from backend.models.todo import delete_todo

@blueprint.delete("/todos/<int:id>/")
@rate_limit(10, timedelta(seconds=10))
@login_required
async def todo_delete(id: int) -> ResponseReturnValue:
    """Delete the identified todo

    This will delete the todo.
    """
    await delete_todo(
        g.connection, id, int(cast(str, current_user.auth_id))
    )
    return "", 202

此路由包括速率限制保护,要求登录使用,并且只要待办事项属于登录成员,就会删除具有给定 ID 的待办事项。

在所有待办事项的功能都到位之后,我们现在可以专注于测试它是否正确工作。

测试路由

我们应该测试这些路由是否按用户预期的方式工作。首先,我们需要确保我们可以在backend/tests/blueprints/test_todos.py中添加以下内容来创建新的待办事项:

from quart import Quart

async def test_post_todo(app: Quart) -> None:
    test_client = app.test_client()
    async with test_client.authenticated("1"):  # type: ignore
        response = await test_client.post(
            "/todos/", 
            json={
                "complete": False, "due": None, "task": "Test                    task"
            },
        )
        assert response.status_code == 201
        assert (await response.get_json())["id"] > 0

接下来,我们可以确保我们可以在backend/tests/blueprints/test_todos.py中添加以下内容来读取待办事项:

async def test_get_todo(app: Quart) -> None:
    test_client = app.test_client()
    async with test_client.authenticated("1"):  # type: ignore
        response = await test_client.get("/todos/1/")
        assert response.status_code == 200
        assert (await response.get_json())["task"] == "Test           Task"

继续 CRUD 功能,我们可以确保可以通过在backend/tests/blueprints/test_todos.py中添加以下内容来更新待办事项:

async def test_put_todo(app: Quart) -> None: 
    test_client = app.test_client() 
    async with test_client.authenticated("1"):  # type: ignore    
        response = await test_client.post( 
            "/todos/",  
            json={ 
                "complete": False, "due": None, "task": "Test                    task"
            }, 
        )
        todo_id = (await response.get_json())["id"]
        response = await test_client.put(
            f"/todos/{todo_id}/",
            json={
                "complete": False, "due": None, "task":                   "Updated"
            },  
        )
        assert (await response.get_json())["task"] == "Updated"
        response = await test_client.get(f"/todos/{todo_id}/")
        assert (await response.get_json())["task"] == "Updated"

最后,我们可以确保可以通过在backend/tests/blueprints/test_todos.py中添加以下内容来删除待办事项:

async def test_delete_todo(app: Quart) -> None:  
    test_client = app.test_client()  
    async with test_client.authenticated("1"):  # type: ignore     
        response = await test_client.post(  
            "/todos/",   
            json={  
                "complete": False, "due": None, "task": "Test                   task"
            },  
        ) 
        todo_id = (await response.get_json())["id"]
        await test_client.delete(f"/todos/{todo_id}/")
        response = await test_client.get(f"/todos/{todo_id}/")
        assert response.status_code == 404

通过这些测试,我们拥有了管理待办事项所需的所有功能。

摘要

在本章中,我们定义了如何在数据库中存储数据,然后构建了一个 API 来管理会话、成员和待办事项。这包括我们应用将通过易于理解的 RESTful API 所需的所有功能。

虽然待办事项功能可能对你的应用没有直接的帮助,但 CRUD 模式是你应该使用的模式。此外,成员和会话 API 可以直接在你的应用中使用。最后,你也许已经理解了什么是一个好的 RESTful API,并且可以在其他地方应用和使用。

在下一章中,我们将创建一个带样式的前端,包括在 React 中的验证数据输入,这样我们就可以使用这个 API 或任何其他 API。

进一步阅读

在本章中,我们构建了一个相当简单的 RESTful API。随着你的 API 复杂性的增加,我建议遵循www.vinaysahni.com/best-practices-for-a-pragmatic-restful-api中的最佳实践。

第四章:使用 React 创建可重用的前端

在上一章中,我们构建了一个用于管理会话、成员和待办事项的 API。在本章中,我们将创建一个前端,使其能够连接到该 API 或您可能希望使用的任何其他 API。此外,我们还将添加样式、路由、验证数据输入和通过 toast 提供反馈。

样式、路由、数据输入和反馈都是您应用中非常有用的功能,并不特定于待办事项。因此,在本章结束时,我们将创建一个前端,我们可以向其中添加具有任何特定功能的用户界面。

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

  • 提升基本的 React 应用

  • 添加路由

  • 启用数据输入

  • 管理应用状态

  • 支持 toast 反馈

技术要求

在本章中需要以下额外的文件夹,并应创建它们:

tozo
└── frontend
    └── src
        └── components

要使用配套仓库 github.com/pgjones/tozo 跟进本章的开发,请查看 r1-ch4-startr1-ch4-end 标签之间的提交。

提升基本的 React 应用

第一章* 的 安装 NodeJS 用于前端开发 部分,我们在 设置开发系统 中使用了 create-react-app 工具来创建一个标准的 React 应用,我们现在可以为此用途进行配置。

首先,由于我们正在使用前端开发服务器,我们需要通过在 frontend/package.json 中添加以下内容来代理 API 请求到我们的后端:

{
  ...,
  "proxy": "http://localhost:5050"
}

突出的省略号表示现有的代码;请注意,已经添加了额外的尾随逗号。

接下来,我们将配置导入系统,以便我们可以使用以 src 为根的全路径(即,src/components/Component),而不是例如 ../components/Component。这使得导入的文件更容易找到,因为我们总能将路径与 src 目录联系起来。这也与我们已经在后端使用的导入路径类型相匹配。为此,我们需要将以下内容添加到 frontend/tsconfig.json

{
  "compilerOptions": {
    "baseUrl": "./",
    ...
  }
}

compilerOptions部分应该已经存在,我们需要在其中添加baseUrl条目(突出显示的省略号表示现有的代码)。此外,我们需要安装eslint-import-resolver-typescript来通知eslint使用相同的baseUrl,在frontend目录中运行以下命令:

npm install --save-dev eslint-import-resolver-typescript

这可以通过在 frontend/package.json 中的 eslintConfig 部分添加以下内容来配置:

"eslintConfig": {
  "extends": [...],
  "settings": {
    "import/resolver": {
      "typescript": {}
    }
  }
}

突出的行表示现有 eslintConfig 部分内的现有代码。

通过这些小的配置更改,我们现在可以专注于设计应用样式、添加页面标题和添加认证上下文。

设计应用样式

构建一个设计系统并一致地使用它来设计应用程序需要付出很多努力。幸运的是,MUI (mui.com) 是一个现有的 React 组件库,可以用来创建使用 Google 领先的 Material Design 系统的应用程序。在 frontend 目录中运行以下命令来安装 MUI

npm install @mui/material @mui/icons-material @mui/lab @emotion/react @emotion/styled 

由于材料设计和 MUI 基于 Roboto 字体,我们需要在 frontend 目录中运行以下命令来安装它:

npm install @fontsource/roboto

此字体也需要包含在包中,因此以下导入应添加到 frontend/src/App.tsx 中:

import "@fontsource/roboto/300.css";
import "@fontsource/roboto/400.css";
import "@fontsource/roboto/500.css";
import "@fontsource/roboto/700.css";

数字代表字体粗细(粗度);默认情况下,MUI 只使用 300400500700 的粗细,因此我们只需要这些。

语义化 HTML

MUI 非常擅长使用最描述性的 HTML 标签来表示元素;例如,MUI 按钮使用 Button 标签而不是对 div 标签进行样式化。这被称为 语义化 HTML,因为它使用 HTML 来加强内容的语义。这是一件重要的事情,因为它有助于可访问性并提高用户体验。

到目前为止,我们的应用程序将看起来与默认的 MUI 应用程序完全一样,但我们可以通过为主题化应用程序来改变这一点。为此,让我们在 frontend/src/ThemeProvider.tsx 中创建一个 ThemeProvider 元素,如下所示:

import { useMemo } from "react";
import { PaletteMode } from "@mui/material";
import CssBaseline from "@mui/material/CssBaseline";
import useMediaQuery from "@mui/material/useMediaQuery";
import { createTheme, ThemeProvider as MuiThemeProvider } from "@mui/material/styles";
interface IProps {
  children: React.ReactNode;
}
const ThemeProvider = ({ children }: IProps) => {
  const prefersDarkMode = useMediaQuery("(prefers-color-scheme:     dark)"); 
  const theme = useMemo(
    () => {
      const palette = { 
        mode: (prefersDarkMode ? "dark" : "light") as           PaletteMode,
      }; 
      return createTheme({ palette }); 
    }, 
    [prefersDarkMode] 
  );
  return (
    <MuiThemeProvider theme={theme}>
      <CssBaseline enableColorScheme />
      { children }
    </MuiThemeProvider>
  );
};
export default ThemeProvider;

在这里,我们使用了 CssBaseline 组件来重置和标准化浏览器的样式,从而确保我们的应用程序在所有浏览器中看起来都一样。我们还使用了 prefers-color-scheme 系统首选项来根据用户的系统偏好将应用程序切换到深色模式。

ThemeProvider 应该在 App 组件中以任何样式化组件的父组件的形式渲染,即 frontend/src/App.tsx 应该如下所示:

import ThemeProvider from "src/ThemeProvider";
const App = () => {
  return (
    <ThemeProvider>
    </ThemeProvider>
  );
}

注意,我已经将 App 函数定义的语法更改为使用箭头函数语法,而不是 create-react-app 为我们创建的函数定义语法。

函数式风格

TypeScript 允许使用 function 关键字或通过箭头 => 语法来定义函数。虽然这两种风格之间存在差异,但对于 React 组件来说,使用哪种风格实际上没有区别。在这本书中,我将根据我的偏好使用箭头语法。

由于我们已经更改了 App 组件,我们还需要通过将 frontend/src/App.test.tsx 替换为以下内容来更新测试:

import React from "react";
import { render } from "@testing-library/react";
import App from "./App";
test("renders the app", () => {
  render(<App />);
});

我们的目标是使应用程序能够在小手机屏幕、大桌面屏幕以及两者之间的所有屏幕上使用。我们可以通过为小手机屏幕构建应用程序并允许其根据屏幕大小调整大小来实现这一点。然而,正如你在 图 4.1 中可以看到的,当水平宽度很大时,这开始看起来很奇怪:

图 4.1:没有容器时应用程序的外观

图 4.1:没有容器时应用程序的外观

通过在 frontend/src/App.tsx 中添加一个 Container 来解决这个问题:

import Container from "@mui/material/Container";
const App = () => {
  return (
    <ThemeProvider>
      <Container maxWidth="md">
      </Container>
    </ThemeProvider>
  );
}

突出的行显示了添加的内容,结果如图 4.2 所示:

图 4.2:带有容器的应用外观

图 4.2:带有容器的应用外观

在设置样式后,我们现在可以为每个页面添加标题。

添加页面标题

我们可以通过配置页面标题,使其在用户浏览器中显示,从而为用户提供更好的体验,如图 4.3 所示:

图 4.3:标题(如 Chrome 所显示)

图 4.3:标题(如 Chrome 所显示)

要设置标题,我们可以使用 react-helmet-async,这需要在 frontend 目录下运行以下命令:

npm install react-helmet-async

要使用 react-helmet-async,我们需要通过在 frontend/src/App.tsx 中添加以下内容,将 HelmetProvider 添加为组件的祖先:

import { Helmet, HelmetProvider } from "react-helmet-async";
const App = () => {
  return (
    <HelmetProvider>
      <Helmet>
        <title>Tozo</title>
      </Helmet>
      <ThemeProvider>
        <Container maxWidth="md">
        </Container>
      </ThemeProvider> 
    </HelmetProvider>
  );
}

突出显示的行将默认页面标题设置为 Tozo,并应添加到现有代码中。

我们现在可以创建一个 Title 组件,它既设置浏览器显示的标题,又在页面上显示清晰的标题文本,通过在 frontend/src/components/Title.tsx 中添加以下内容:

import Typography from "@mui/material/Typography";
import { Helmet } from "react-helmet-async";
interface IProps {
  title: string;
}
const Title = ({ title }: IProps) => (
  <>
    <Helmet>
      <title>Tozo | {title}</title>
    </Helmet>
    <Typography component="h1" variant="h5">{title}    </Typography>
  </>
);
export default Title;

通过这个小的添加,我们现在可以思考应用如何知道用户是否已经认证。

添加认证上下文

前端应用需要跟踪用户当前是否已认证(登录),如果他们没有登录,则显示登录或注册页面。这是在整个应用中都会用到的东西,因此我们将通过在 frontend/src/AuthContext.tsx 中添加以下内容来使用一个 React 上下文,具体称为 AuthContext

import { createContext, useState } from "react";

interface IAuth {
  authenticated: boolean;
  setAuthenticated: (value: boolean) => void;
}

export const AuthContext = createContext<IAuth>({
  authenticated: true,
  setAuthenticated: (value: boolean) => {},
});

interface IProps {
  children?: React.ReactNode;
}

export const AuthContextProvider = ({ children }: IProps) => {
  const [authenticated, setAuthenticated] = useState(true);

  return (
    <AuthContext.Provider 
      value={{ authenticated, setAuthenticated }}
    >
      {children}
    </AuthContext.Provider>
  );
};

React 上下文和属性钻取

React 上下文最适合在 React 组件树中全局共享东西。这是因为提供者的任何子组件都将能够访问上下文。我们也可以通过通过树传递上下文来达到这个效果,这被称为属性钻取。然而,当有大量组件需要传递时,属性钻取很快就会变得繁琐。

要使这个上下文在整个应用中可用,我们可以在 frontend/src/App.tsx 中添加提供者:

import { AuthContextProvider } from "src/AuthContext";

const App = () => {
  return (
    <AuthContextProvider>
      <HelmetProvider>
        <Helmet>
          <title>Tozo</title>
        </Helmet>
        <ThemeProvider>
          <Container maxWidth="md">
          </Container>
        </ThemeProvider> 
      </HelmetProvider>
    </AuthContextProvider>
  );
}

应该将突出显示的行添加到现有代码中。

这样就可以通过 useContext 钩子在任意组件中访问认证状态:

import { AuthContext } from "src/AuthContext";
const { authenticated } = React.useContext(AuthContext);

我们将在设置路由时使用这个上下文。

添加路由

前端应用通常由多个页面组成,就像我们的待办事项应用一样。我们将通过路由来实现这一点,路由允许根据应用的路径渲染不同的页面组件。由于我们正在构建单页应用,这个路由将在前端代码中完成,而不是在多页应用中通常的后端完成。

我们将使用 React Router (reactrouter.com) 来处理应用中的路由。这需要在 frontend 目录下运行以下命令:

npm install react-router-dom

单页应用

单页应用,通常称为SPA,是指从后端服务器只获取单个页面的 Web 应用。这个单页能够渲染应用内的所有页面。这是一个优点,因为从一页导航到另一页通常在 SPA 中更快;然而,这也带来了更大的初始下载成本。

我发现将所有路由放入一个名为Router的单个组件中更清晰,其中每个页面都是一个单独的RouteRouter是通过在*frontend/src/Router.tsx*中添加以下内容来定义的:

import { BrowserRouter, Routes } from "react-router-dom";
const Router = () => (
  <BrowserRouter>
    <Routes>
      {/* Place routes here */}
    </Routes>
  </BrowserRouter>
); 
export default Router;

然后,Router组件应该被渲染在Container组件中,如下所示,这应该添加到*frontend/src/App.tsx*中:

import Router from "src/Router";

const App = () => {
  return (
    <AuthContextProvider>
      <HelmetProvider>
        <Helmet>
          <title>Tozo</title>
        </Helmet>
        <ThemeProvider>
          <Container maxWidth="md">
            <Router />
          </Container>
        </ThemeProvider> 
      </HelmetProvider>
    </AuthContextProvider>
  );
}

应该将突出显示的行添加到现有代码中。

我们现在可以为我们的路由添加认证,以确保某些页面只对已登录用户显示。

需要认证

应用程序中的大部分路由应该只对已登录的用户可用。因此,我们需要一个组件来检查用户是否已认证,并显示页面。或者,如果用户未认证,应用将重定向用户到登录页面。这是通过在*frontend/src/components/RequireAuth.tsx*中创建以下组件来实现的:

import { useContext } from "react";
import { Navigate, useLocation } from "react-router-dom";
import { AuthContext } from "src/AuthContext";
interface IProps {
  children: React.ReactNode;
}
const RequireAuth = ({ children }: IProps) => {
  const { authenticated } = useContext(AuthContext);
  const location = useLocation();
  if (authenticated) {
    return <>{children}</>;
  } else {
    return <Navigate state={{ from: location }} to="/login/" />;
  }
};
export default RequireAuth;

导航状态被设置为包括当前位置,以便用户在成功认证后可以返回到页面。

然后,我们可以在Route组件内将RequireAuth用作Page组件的包装器,因为这确保了只有当用户认证后,Page才会显示,例如(这不应该添加到我们的应用中):

<Route 
  element={<RequireAuth><Page /></RequireAuth>} 
  path= "/private/" 
/>

路由设置的最后一点是控制导航时的滚动。

在导航时重置滚动

当用户在应用中导航并更改页面时,他们期望从页面顶部开始查看新页面(即页面的顶部)。由于视图或滚动位置将在 React Router 导航中保持固定,我们需要自己将其重置到顶部。我们可以通过以下组件来实现,该组件放置在*frontend/src/components/ScrollToTop.tsx*中:

import { useEffect } from "react";
import { useLocation } from "react-router";

const ScrollToTop = () => {
  const { pathname } = useLocation();

  useEffect(() => {
    window.scrollTo(0, 0);
  }, [pathname]);

  return null;
};

export default ScrollToTop;

useEffect仅在它的pathname依赖项更改时触发,因此滚动仅在导航时发生。

应该通过在*frontend/src/Router.tsx*中添加以下内容,在Router组件中渲染此组件:

import ScrollToTop from "src/components/ScrollToTop";

const Router = () => (
  <BrowserRouter>
    <ScrollToTop />
    <Routes>
      {/* Place routes here */}
    </Routes>
  </BrowserRouter>
);

应该将突出显示的行添加到现有代码中。

为了使前端测试通过,我们需要通过在*frontend/src/setupTests.ts*中添加以下内容来定义window.scrollTo函数:

window.scrollTo = (x, y) => {
  document.documentElement.scrollTop = y;
}

这就是我们通过路由在我们的应用中启用页面所需的所有内容;现在,我们可以专注于用户如何输入数据。

启用数据输入

我们的应用程序用户需要输入他们的电子邮件和密码来登录,然后是待办任务的描述、截止日期和完成情况。这些字段需要分组到表单中;构建具有良好用户体验的表单需要大量努力,因为必须对每个字段和表单本身进行验证,并管理触摸状态、错误状态和焦点状态。

表单输入状态

表单输入框需要显示各种不同的状态,以帮助用户理解其使用方式和何时出现问题。首先,输入将处于空状态,没有值和错误。这很重要,因为输入不应该在用户触摸/交互之前显示错误。然后,当用户与其交互时,输入应显示它处于焦点状态。最后,在输入被触摸后,如果值不验证,则需要显示错误状态。

我们将使用 Formik (formik.org) 来管理表单和字段状态,并使用 Yup (github.com/jquense/yup) 来验证输入数据。这些是通过在 前端 目录中运行以下命令来安装的:

npm install formik yup

由于我们将使用 MUI 进行样式化和 Formik 来管理状态,因此我们需要创建结合两者的字段组件。虽然这会因每个字段而异,但以下函数对所有组件都很有用,应该放置在 前端/src/utils.tsx

import { FieldMetaProps } from "formik";
import React from "react";
export const combineHelperText = <T, >(
    helperText: React.ReactNode | string | undefined, 
    meta: FieldMetaProps<T>,
) => {
  if (Boolean(meta.error) && meta.touched) {
    if (typeof helperText === "string") {
      return `${meta.error}. ${helperText ?? ""}`;
    } else {
      return (<>{meta.error}. {helperText}</>);
    }
  } else {
    return helperText;
  }
}

这个通用函数通过从 Formik 元数据属性中提取错误,并在有错误且用户触摸了输入时与辅助文本一起显示来工作。在 <T, > 中的逗号是必需的,用于区分我们想要的通用用法和 <T> JSX 元素单独所暗示的内容。

在安装了 Formik 并准备好使用辅助函数后,我们可以开始创建字段组件,从复选框字段开始。

实现一个样式化的复选框字段

在我们的应用程序中,我们需要一个复选框字段来指示待办事项是否完成,或者指示用户在登录时是否希望被记住。以下内容应添加到 前端/src/components/CheckboxField.tsx

import Checkbox from "@mui/material/Checkbox";
import FormControl from "@mui/material/FormControl";
import FormControlLabel from "@mui/material/FormControlLabel";
import FormHelperText from "@mui/material/FormHelperText";
import { FieldHookConfig, useField } from "formik";
import { combineHelperText } from "src/utils";

type IProps = FieldHookConfig<boolean> & {
  fullWidth?: boolean;
  helperText?: string;
  label: string;
  required?: boolean;
};

const CheckboxField = (props: IProps) => {
  const [field, meta] = useField<boolean>(props);

  return (
    <FormControl
      component="fieldset"
      error={Boolean(meta.error) && meta.touched}
      fullWidth={props.fullWidth}
      margin="normal"
      required={props.required}
    >
      <FormControlLabel
        control={<Checkbox {...field} checked={field.value} />}
        label={props.label}
      />
      <FormHelperText>
        {combineHelperText(props.helperText, meta)}
      </FormHelperText>
    </FormControl>
  );
};

export default CheckboxField;

尽管大部分代码是为了按照材料设计系统指定的样式化复选框,但关键方面是使用 useField 钩子来提取 Formik 状态,以便在 MUI 组件中使用。

我们现在可以继续到下一个字段,即日期输入字段。

实现一个样式化的日期字段

我们需要一个日期字段,让用户指定待办事项的截止日期,它将类似于 图 4.4

图 4.4:移动屏幕上的日期选择器

图 4.4:移动屏幕上的日期选择器

要做到这一点,我们将使用 MUI-X (mui.com/x) 组件而不是内置的浏览器日期选择器,因为 MUI-X 选择器对用户来说更容易使用。MUI-X 是一套高级 MUI 组件,因此它与 MUI 一起工作,并遵循相同的样式。除了 MUI-X 之外,我们还需要 date-fns 来将字符串解析为 Date 实例。

这两个组件都是通过在 前端 文件夹中运行以下命令来安装的:

npm install @mui/x-date-pickers date-fns

安装了这些库后,我们可以在 frontend/src/components/DateField.tsx 中添加以下内容,以创建一个提供日期选择器的 DateField 组件,如图 4.4 所示:

import TextField, { TextFieldProps } from "@mui/material/TextField";
import { AdapterDateFns } from "@mui/x-date-pickers/AdapterDateFns";
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
import { FieldHookConfig, useField } from "formik";
import { combineHelperText } from "src/utils";

const DateField = (
  props: FieldHookConfig<Date | null> & TextFieldProps
) => {
  const [field, meta, helpers] = useField<Date | null>(props);
  return (
    <LocalizationProvider dateAdapter={AdapterDateFns}>
      <DatePicker
        label={props.label}
        value={field.value}
        onChange={(newValue) => helpers.setValue(newValue)}
        renderInput={(params) => (
          <TextField 
            fullWidth={props.fullWidth}
            {...params} 
            helperText={combineHelperText(props.helperText, meta)} 
          />
        )}
      />
    </LocalizationProvider>
  );
}; 
export default DateField;

接下来,我们可以在输入电子邮件的表单中添加一个字段组件。

实现一个带样式的电子邮件字段

我们需要一个电子邮件字段,让用户登录和注册。为此,以下内容应添加到 frontend/src/components/EmailField.tsx

import TextField, { TextFieldProps } from "@mui/material/TextField";
import { FieldHookConfig, useField } from "formik";
import { combineHelperText } from "src/utils";

const EmailField = (props: FieldHookConfig<string> & TextFieldProps) => {
  const [field, meta] = useField<string>(props);
  return (
    <TextField
      {...props}
      autoComplete="email"
      error={Boolean(meta.error) && meta.touched}
      helperText={combineHelperText(props.helperText, meta)}
      margin="normal"
      type="email"
      {...field}
    />
  );
};

export default EmailField;

接下来,我们可以添加一个简单的文本字段。

实现一个带样式的文本字段

我们需要一个文本字段,让用户输入待办任务信息。为此,以下内容应添加到 frontend/src/components/TextField.tsx

import MUITextField, { TextFieldProps } from "@mui/material/TextField";
import { FieldHookConfig, useField } from "formik";
import { combineHelperText } from "src/utils";

const TextField = (props: FieldHookConfig<string> & TextFieldProps) => {
  const [field, meta] = useField<string>(props);
  return (
    <MUITextField
      {...props}
      error={Boolean(meta.error) && meta.touched}
      helperText={combineHelperText(props.helperText, meta)}
      margin="normal"
      type="text"
      {...field}
    />
  );
};

export default TextField;

最后,我们可以添加一个密码输入字段。

实现一个带样式的密码字段

我们需要一个密码字段,让用户在登录或更改密码时输入他们的现有密码。这个字段应该有一个可见性切换按钮,使得密码可见,因为这有助于用户正确地输入密码。

要做到这一点,以下内容应添加到 frontend/src/components/PasswordField.tsx

import IconButton from "@mui/material/IconButton";
import InputAdornment from "@mui/material/InputAdornment";
import TextField, { TextFieldProps } from "@mui/material/TextField";
import Visibility from "@mui/icons-material/Visibility";
import VisibilityOff from "@mui/icons-material/VisibilityOff";
import { FieldHookConfig, useField } from "formik";
import { useState } from "react";
import { combineHelperText } from "src/utils";

const PasswordField = (props: FieldHookConfig<string> & TextFieldProps) => {
  const [field, meta] = useField<string>(props);
  const [showPassword, setShowPassword] = useState(false);

  return (
    <TextField
      {...props}
      InputProps={{
        endAdornment: (
          <InputAdornment position="end">
            <IconButton
              onClick={() => setShowPassword((value) =>                 !value)}
              tabIndex={-1}
            >
              {showPassword ? <Visibility /> :                 <VisibilityOff />}
            </IconButton>
          </InputAdornment>
        ),
      }}
      error={Boolean(meta.error) && meta.touched}
      helperText={combineHelperText(props.helperText, meta)}
      margin="normal"
      type={showPassword ? "text" : "password"}
      {...field}
    />
  );
};

export default PasswordField;

可见性按钮被赋予 tabIndex 值为 -1,以将其从标签流中移除,这样在输入密码后按下 Tab 键会将焦点移至下一个字段而不是可见性按钮,从而符合用户的期望。

实现一个带样式的密码强度字段

现有的密码字段允许用户输入密码,但不会给出密码强度指示。当用户注册或更改密码时,这将非常有用。在过去,应用程序会强制要求密码中包含特殊的大写和小写字母字符,以使密码强度更高。然而,这很遗憾地导致了更弱的密码。因此,我们将要求密码足够强大,通过计算其熵来实现(这是我们在 第二章,*使用 Quart 创建可重用后端)中已经做到的)。

仅在后端 API 调用中检查强度会导致用户体验不佳,因为用户需要很长时间才能收到关于密码强度的反馈。幸运的是,有一个 zxcvbn 版本,我们可以在前端使用它来为用户提供关于密码强度的即时反馈。

首先,我们应该在 前端 目录中运行以下命令来安装它:

npm install zxcvbn 
npm install --save-dev @types/zxcvbn

我们希望这个字段能够立即向用户提供关于他们密码强度的视觉反馈,无论是通过颜色(随着密码强度的提高而变得更绿),还是通过显示的文本来表示。因此,让我们向*frontend/src/components/PasswordWithStrengthField.tsx*添加以下函数:

const scoreToDisplay = (score: number) => {
  let progressColor = "other.red";
  let helperText = "Weak";
  switch (score) {
    case 25:
      progressColor = "other.pink";
      break;
    case 50:
      progressColor = "other.orange";
      break;
    case 75:
      progressColor = "other.yellow";
      helperText = "Good";
      break;
    case 100:
      progressColor = "other.green";
      helperText = "Strong";
      break;
  }
  return [progressColor, helperText];
};

然后,我们可以在字段本身中使用此函数,通过向*frontend/src/components/PasswordWithStrengthField.tsx*添加以下内容:

import LinearProgress from "@mui/material/LinearProgress";
import { TextFieldProps } from "@mui/material/TextField";
import { FieldHookConfig, useField } from "formik";
import zxcvbn from "zxcvbn";
import PasswordField from "src/components/PasswordField";
const PasswordWithStrengthField = (
  props: FieldHookConfig<string> & TextFieldProps,
) => {
  const [field] = useField<string>(props);
  const result = zxcvbn(field.value ?? "");
  const score = (result.score * 100) / 4;
  const [progressColor, helperText] = scoreToDisplay(score);
  return (
    <>
      <PasswordField {...props} helperText={helperText} />
      <LinearProgress
        sx={{
          "& .MuiLinearProgress-barColorPrimary": {
            backgroundColor: progressColor,
          },
          backgroundColor: "action.selected",
          margin: "0 4px 24px 4px",
        }}
        value={score}
        variant="determinate"
      />
    </>
  );
};
export default PasswordWithStrengthField;

此代码在现有的PasswordField下方渲染一个LinearProgress组件,并根据已添加的scoreToDisplay函数着色。

PasswordWithStrengthField使用zxcvbn来确定密码强度。这意味着任何直接导入PasswordWithStrengthField的组件都会将zxcvbn添加到其包中,这是一个问题,因为zxcvbn非常大。因此,为了仅在需要时加载zxcvbn,我们可以通过向*frontend/src/components/LazyPasswordWithStrengthField.tsx*添加以下内容来使用 React 的懒加载和 suspense 系统:

import { TextFieldProps } from "@mui/material/TextField"; 
import { lazy, Suspense } from "react";
import { FieldHookConfig } from "formik";
import PasswordField from "src/components/PasswordField";
const PasswordWithStrengthField = lazy( 
  () => import("src/components/PasswordWithStrengthField"), 
);
const LazyPasswordWithStrengthField = (
  props: FieldHookConfig<string> & TextFieldProps, 
) => (
  <Suspense fallback={<PasswordField {...props} />}>
    <PasswordWithStrengthField {...props} />
  </Suspense>
);
export default LazyPasswordWithStrengthField;

现在,PasswordField将显示给用户,直到zxcvbn被下载,从而通过确保只有在用户需要时才下载来提高用户体验。

这些都是我们待办事项应用所需的定制字段;接下来,我们需要一组样式化的操作按钮。

实现样式化表单操作

我们实现的字段将包含在需要提交的表单中。因此,让我们添加一个有用的辅助FormActions组件,允许用户将表单作为主要操作提交,或者作为次要操作导航到其他地方。以下代码应添加到*frontend/src/components/FormActions.tsx*

import Button from "@mui/material/Button"; 
import LoadingButton from "@mui/lab/LoadingButton"; 
import Stack from "@mui/material/Stack";
import { Link } from "react-router-dom";
interface ILink {
  label: string;
  to: string;
  state?: any;
}
interface IProps {
  disabled: boolean;
  isSubmitting: boolean;
  label: string;
  links?: ILink[];
}
const FormActions = ({ disabled, isSubmitting, label, links }: IProps) => (
  <Stack direction="row" spacing={1} sx={{ marginTop: 2 }}>
    <LoadingButton
      disabled={disabled}
      loading={isSubmitting}
      type="submit"
      variant="contained"
    > 
      {label}
    </LoadingButton> 
    {(links ?? []).map(({ label, to, state }) => (
      <Button 
        component={Link}
        key={to}
        state={state}
        to={to}
        variant="outlined" 
      >
        {label}
      </Button>
    ))}
  </Stack>
);
export default FormActions;

主要操作通过使用LoadingButton组件来显示,因为它允许我们通过旋转的圆圈向用户指示表单提交正在进行中。如果没有这种反馈,用户可能会认为应用已冻结或忽略了他们的点击。

现在我们已经拥有了用户输入数据所需的所有字段和辅助组件。这意味着我们可以专注于如何管理应用的状态,特别是如何从后端获取数据并将其存储在应用的状态中。

管理应用状态

与后端一样,拥有代表应用中使用的数据的模型很有帮助。这些模型将验证数据,帮助 linters 确保我们正确地使用数据,并确保使用正确的类型。我们还将使用模型来正确地将数据转换为与后端 API 通信时使用的 JSON 表示形式。

待办事项模型需要根据从后端接收到的内容或用户输入的数据构建。然后,模型需要以 JSON 格式输出,以便可以将此输出发送到后端。此外,模型还应验证构建它的数据是否具有正确的结构,并转换类型(即,将表示 JSON 中日期的字符串转换为 Date 实例)。

我们只需要在前端为待办事项提供一个模型,因此我们需要在 frontend/src/models.ts 中添加以下内容:

import { formatISO } from "date-fns";
import * as yup from "yup";
const todoSchema = yup.object({
  complete: yup.boolean().required(),
  due: yup.date().nullable(),
  id: yup.number().required().positive().integer(),
  task: yup.string().trim().min(1).defined().strict(true), 
});
export class Todo {
  complete: boolean;
  due: Date | null;
  id: number;
  task: string;

  constructor(data: any) {
    const validatedData = todoSchema.validateSync(data);  
    this.complete = validatedData.complete;
    this.due = validatedData.due ?? null;
    this.id = validatedData.id;
    this.task = validatedData.task;
  }

  toJSON(): any {
    return {
      complete: this.complete,
      due:
        this.due !== null
          ? formatISO(this.due, { representation: "date" })
          : null,
      id: this.id,
      task: this.task,
    };
  }
}

todoSchema 在构造函数中使用,以确认数据具有正确的结构并转换类型。toJSON 方法是一个标准的 JavaScript 方法,用于将对象转换为 JSON 兼容的结构,这是通过将到期日期转换为 ISO 8601 格式的字符串来完成的。

虽然这个模型是针对我们的应用程序特定的,但使用带有 yup 验证的类是任何应用程序数据的良好模式。

在建立模型之后,我们现在可以与后端通信了,这是我们接下来要关注的重点。

与后端通信

我们需要从后端 API 发送和接收数据,并将其存储在本地状态中,以便在渲染各种组件时使用。首先,让我们安装 axios,因为它在发送和接收 JSON 方面比内置的 fetch 函数有更友好的 API。在 frontend 文件夹中运行以下命令即可安装:

npm install axios

我们需要以允许跨多个组件使用的方式存储接收到的数据。为了管理这个状态,我们将使用 React-Query (tanstack.com/query/v4),因为它易于使用且令人愉悦。首先,让我们在 frontend 目录中运行以下命令来安装它:

npm install @tanstack/react-query

要使用 React-Query,必须通过 React-Query 的 QueryClientProvider 提供一个 QueryClient。这是通过在 frontend/src/App.tsx 中添加以下内容来实现的:

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const queryClient = new QueryClient();

const App => {
  return (
    <QueryClientProvider client={queryClient}>
      <AuthContextProvider>
        <HelmetProvider>
          <Helmet>
            <title>Tozo</title>
          </Helmet>
          <ThemeProvider>
            <Container maxWidth="md">
              <Router />
            </Container>
          </ThemeProvider> 
        </HelmetProvider>
      </AuthContextProvider>
    </QueryClientProvider>
  );
};

应该将突出显示的行添加到现有代码中。

我们需要调整 React-Query,使得未认证的请求会导致 AuthContext 发生变化。这是为了处理用户在未先登录的情况下访问页面的情况。我们还将只允许在服务器没有响应或响应状态码为 5XX 时重试。

状态管理

在 React 中,渲染的输出必须是当前状态的函数。因此,当从后端获取数据时,我们需要管理各种获取状态。这些状态从获取加载开始,根据结果进展到成功或错误状态。假设获取成功,那么数据在需要再次获取之前的有效时长就是一个问题。所有这些状态都由 React-Query 帮助我们管理。

为了做到这一点,我们首先需要在 frontend/src/query.ts 文件中围绕 React-Query 的 useQuery 写一个包装器,该包装器用于从后端 API 获取数据,通过添加以下内容:

import axios, { AxiosError } from "axios";
import { useContext } from "react";
import {
  QueryFunction,
  QueryFunctionContext,
  QueryKey,
  useQuery as useReactQuery,
  UseQueryOptions,
  UseQueryResult,
} from "@tanstack/react-query";

import { AuthContext } from "src/AuthContext";

const MAX_FAILURES = 2;

export function useQuery<
  TQueryFnData = unknown,
  TData = TQueryFnData,
  TQueryKey extends QueryKey = QueryKey,
>(
  queryKey: TQueryKey,
  queryFn: QueryFunction<TQueryFnData, TQueryKey>,
  options?: UseQueryOptions<TQueryFnData, AxiosError, TData, TQueryKey>,
): UseQueryResult<TData, AxiosError> {
  const { setAuthenticated } = useContext(AuthContext);

  return useReactQuery<TQueryFnData, AxiosError, TData, TQueryKey>(
    queryKey,
    async (context: QueryFunctionContext<TQueryKey>) => {
      try {
        return await queryFn(context);
      } catch (error) {
        if (axios.isAxiosError(error) && error.response?.status === 401) {
          setAuthenticated(false);
        }
        throw error;
      }
    },
    {
      retry: (failureCount: number, error: AxiosError) =>
        failureCount < MAX_FAILURES &&
        (!error.response || error.response.status >= 500),
      ...options,
    },
  );
}

这段代码通过检查 401 响应状态码的错误来包装标准的useQuery钩子,如图中第一个高亮块所示。由于 401 响应表示用户未认证,因此随后更新了本地认证状态。

代码还提供了逻辑来决定何时重试请求,如图中第二个高亮块所示。如果出现网络错误(无响应)或服务器错误(由5XX响应代码指示),逻辑将重试请求,最多重试两次。注意,因此,在出现网络故障的情况下,查询将在所有三次尝试都失败之前处于加载状态。

现在,我们将向 React-Query 的useMutation添加相同的逻辑,该钩子通过以下方式添加到frontend/src/query.ts

import {
  MutationFunction,
  useMutation as useReactMutation,
  UseMutationOptions,
  UseMutationResult,
} from "@tanstack/react-query";
export function useMutation<
  TData = unknown,
  TVariables = void,
  TContext = unknown,
>(
  mutationFn: MutationFunction<TData, TVariables>,
  options?: UseMutationOptions<TData, AxiosError, TVariables, TContext>,
): UseMutationResult<TData, AxiosError, TVariables, TContext> {
  const { setAuthenticated } = useContext(AuthContext);

  return useReactMutation<TData, AxiosError, TVariables, TContext>(
    async (variables: TVariables) => {
      try {
        return await mutationFn(variables);
      } catch (error) {
        if (axios.isAxiosError(error) && error.response?.status === 401) {
          setAuthenticated(false);
        }
        throw error;
      }
    },
    {
      retry: (failureCount: number, error: AxiosError) =>
        failureCount < MAX_FAILURES &&
        (!error.response || error.response.status >= 500),
      ...options,
    },
  );
}

这个useMutation钩子具有与useQuery钩子相同的认证包装和重试逻辑。

这两个新的钩子可以在应用的任何部分以与标准 React-Query 钩子相同的方式使用。例如,useQuery钩子可以这样使用:

import { useQuery } from "src/queries";
const Component = () => {
  const { data } = useQuery(
    ["key"], 
    async () => {
      const response = await axios.get<any>("/");
      return response.data;
    },
  );
  return (<>{ data }</>);
};

现在我们可以完全与后端交互,并存储适当的状态,这使我们能够专注于向用户提供反馈。

支持吐司反馈

吐司(在 MUI 中称为Snackbar)可以用来向用户展示与页面上的直接元素无关的反馈。良好的吐司使用方式是在请求后端失败时显示错误消息,如图4.5所示,或者在用户更改密码后显示成功消息,因为没有直接通过页面内容进行确认。不良的使用方式是报告输入的电子邮件无效,在这种情况下,电子邮件字段应该显示错误。

图 4.5:吐司错误示例

图 4.5:吐司错误示例

为了支持吐司,我们需要能够从应用的任何组件中添加吐司,并显示该吐司。关键的是,如果有多个吐司,它们应该一个接一个地显示,以确保不会同时显示多个吐司。这是另一个 React 上下文用例,类似于之前添加的认证上下文。因此,让我们首先将以下吐司上下文添加到frontend/src/ToastContext.tsx

import { AlertColor } from "@mui/material/Alert";
import React, { createContext, useState } from "react";

export interface IToast {
  category?: AlertColor;
  key: number;
  message: string;
}

interface IToastContext {
  addToast: (message: string, category: AlertColor | undefined) => void;
  setToasts: React.Dispatch<React.SetStateAction<IToast[]>>;
  toasts: IToast[];
}

export const ToastContext = createContext<IToastContext>({
  addToast: () => {},
  setToasts: () => {},
  toasts: [],
});

interface IProps {
  children?: React.ReactNode;
}

export const ToastContextProvider = ({ children }: IProps) => {
  const [toasts, setToasts] = useState<IToast[]>([]);

  const addToast = (
    message: string,
    category: AlertColor | undefined = undefined,
  ) => {
    setToasts((prev) => [
      ...prev,
      {
        category,
        key: new Date().getTime(),
        message,
      },
    ]);
  };

  return (
    <ToastContext.Provider value={{ addToast, setToasts, toasts }}>
      {children}
    </ToastContext.Provider>
  );
};

由于ToastContextProvider需要是任何使用吐司的组件的祖先,我们可以将其添加到frontend/src/App.tsx

import { ToastContextProvider } from "src/ToastContext";

const App = () => {
  return (
    <QueryClientProvider client={queryClient}>
      <AuthContextProvider>
        <HelmetProvider>
          <Helmet>
            <title>Tozo</title>
          </Helmet>
          <ThemeProvider>
            <ToastContextProvider>
              <Container maxWidth="md">
                <Router />
              </Container>
            </ToastContextProvider>
          </ThemeProvider> 
        </HelmetProvider>
      </AuthContextProvider>
    </QueryClientProvider>
  );
}

应该将高亮行添加到现有代码中。

最后,我们需要显示吐司。我们可以通过添加以下内容到frontend/src/components/Toasts.tsx来实现:

import Alert from "@mui/material/Alert"; 
import Snackbar from "@mui/material/Snackbar";
import React, { useContext, useEffect, useState } from "react";

import { ToastContext, IToast } from "src/ToastContext";

const Toasts = () => {
  const { toasts, setToasts } = useContext(ToastContext);
  const [open, setOpen] = useState(false);
  const [currentToast, setCurrentToast] = useState<IToast | undefined>();

  useEffect(() => {
    if (!open && toasts.length) {
      setCurrentToast(toasts[0]);
      setToasts((prev) => prev.slice(1));
      setOpen(true);
    }
  }, [open, setCurrentToast, setOpen, setToasts, toasts]);

  const onClose = (
    event?: React.SyntheticEvent | Event, reason?: string
  ) => {
    if (reason !== "clickaway") {
      setOpen(false);
    }
  };

  return (
    <Snackbar
      anchorOrigin={{
        horizontal: "center",
        vertical: "top",
      }}
      autoHideDuration={6000}
      key={currentToast?.key}
      onClose={onClose}
      open={open}
      TransitionProps={{
        onExited: () => setCurrentToast(undefined),
      }}
    >
      <Alert
        onClose={onClose}
        severity={currentToast?.category}
      >
        {currentToast?.message}
      </Alert>
    </Snackbar>
  );
};

export default Toasts;

这段代码的关键方面是useEffect,它将从吐司列表中取出一个吐司,并在有吐司要显示且没有打开的吐司时将其设置为当前吐司。吐司也会自动在 6 秒后关闭,给用户足够的时间来注册它。

我们现在需要在App组件中渲染Toasts组件,最终版本的前端代码*frontend/src/App.tsx*如下所示:

import "@fontsource/roboto/300.css";
import "@fontsource/roboto/400.css";
import "@fontsource/roboto/500.css";
import "@fontsource/roboto/700.css";
import Container from "@mui/material/Container";
import { HelmetProvider } from "react-helmet-async";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { AuthContextProvider } from "src/AuthContext";
import Toasts from "src/components/Toasts";
import Router from "src/Router";
import ThemeProvider from "src/ThemeProvider";
import { ToastContextProvider } from "src/ToastContext";
const queryClient = new QueryClient();
const App = () => (
  <QueryClientProvider client={queryClient}>
    <AuthContextProvider>
      <HelmetProvider>
        <ThemeProvider>
          <ToastContextProvider>
            <Container maxWidth="md">
              <Toasts />
              <Router />
            </Container>
          </ToastContextProvider>
        </ThemeProvider>
      </HelmetProvider>
    </AuthContextProvider>
  </QueryClientProvider>
);
export default App;

突出的行是为了添加 toast。

现在,当任何组件添加了一个 toast 时,它将在屏幕顶部中央作为一个 alert snackbar 显示 6 秒钟。

摘要

在本章中,我们创建了一个包含路由、验证数据输入和 toast 反馈的样式化前端,并且可以连接到我们在上一章中构建的后端 API。这将使我们能够添加我们待办应用所需的特定页面和功能。

本章添加的功能可以作为任何应用的基石,而不仅仅是这本书中开发的特定待办事项应用。你可以在此基础上添加任何你需要的功能的用户界面。

在下一章中,我们将构建页面并添加构成待办事项应用功能的部分。

进一步阅读

如果你发现你无法使用 React-Query 来表示你应用的状态,那么可能就是时候使用像 Redux 这样的完整状态管理工具了,redux.js.org

第五章:构建单页应用

在上一章中,我们通过工具和设置扩展了一个基本的 React 应用,这些工具和设置是我们构建用户界面所需的。这意味着在本章中,我们可以专注于将构成我们的单页应用的功能。具体来说,我们将添加允许用户进行身份验证、管理他们的密码和管理他们的待办事项的功能。

用户界面和功能,用于管理用户身份验证和密码,对任何应用都很有用,可以直接用于您的应用。虽然待办事项用户界面可能不符合您自己应用的需求,但技术将是可应用的。

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

  • 添加导航

  • 添加用户身份验证页面

  • 添加密码管理页面

  • 添加待办事项页面

技术要求

本章需要以下额外的文件夹,并应创建:

tozo
└── frontend
    └── src
        └── pages

要使用配套仓库 github.com/pgjones/tozo 跟进本章的开发,请查看标签 r1-ch5-startr1-ch5-end 之间的提交。

添加导航

我们正在构建的应用需要允许已登录用户导航到完整的待办事项列表、允许他们更改密码的页面,并允许他们注销。对于未登录用户,他们需要在登录、注册和重置密码页面之间进行导航。

专注于已登录用户的需求,Material Design 系统包括一个位于页面顶部的应用栏。这将允许链接到完整的待办事项列表(主页)以及一个账户菜单,用于更改他们的密码和注销。

更复杂的导航

您的应用可能比本书中构建的页面更多。这意味着导航系统需要能够链接到更多页面。虽然账户菜单可以通过与用户相关的更多链接进行扩展,但这并不是放置其他链接的好位置。相反,抽屉是最好的解决方案。抽屉可以从左侧滑入,并且可以包含所需数量的链接。

账户菜单需要允许用户注销,这意味着它需要通过突变查询后端,然后更新应用的本地身份验证上下文(状态)。为此,应在 frontend/src/components/AccountMenu.tsx 中添加以下代码:

import axios from "axios"; 
import { useContext } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { AuthContext } from "src/AuthContext";
import { useMutation } from "src/query";
const useLogout = () => {
  const { setAuthenticated } = useContext(AuthContext);
  const queryClient = useQueryClient();
  const { mutate: logout } = useMutation(
    async () => await axios.delete("/sessions/"),
    { 
      onSuccess: () => {
        setAuthenticated(false);
        queryClient.clear();
      },
    },
  );
  return logout;
};

此代码提供了一个 logout 函数,当调用时,会触发突变,从而发送 DELETE /sessions/ 请求。如果此请求成功,用户将被注销,本地身份验证上下文设置为 false,并且由 React-Query 存储的数据将被清除。如果请求失败,则不会发生任何变化,提示用户再次尝试。

在此功能到位后,我们现在需要添加样式化菜单。我们可以通过向现有的 frontend/src/components/AccountMenu.tsx 代码中添加以下内容来实现:

import Divider from "@mui/material/Divider";
import IconButton from "@mui/material/IconButton";
import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
import AccountCircle from "@mui/icons-material/AccountCircle";
import React, { useState } from "react";
import { Link } from "react-router-dom";
const AccountMenu = () => {
  const logout = useLogout();
  const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
  const onMenuOpen = (event: React.MouseEvent<HTMLElement>) => 
    setAnchorEl(event.currentTarget);
  const onMenuClose = () => setAnchorEl(null);
  return (
    <>
      <IconButton
        color="inherit"
        onClick={onMenuOpen}
      >
        <AccountCircle />
      </IconButton>
      <Menu
        anchorEl={anchorEl}
        anchorOrigin={{ horizontal: "right", vertical: "top" }}
        keepMounted
        onClose={onMenuClose}
        open={Boolean(anchorEl)}
        transformOrigin={{           horizontal: "right", vertical: "top"         }}
      >
        <MenuItem 
          component={Link} 
          onClick={onMenuClose} 
          to="/change-password/"
        >
          Change password
        </MenuItem>
        <Divider />
        <MenuItem onClick={() => {logout(); onMenuClose();}}>
          Logout
        </MenuItem>
      </Menu>
    </>
  );
};
export default AccountMenu;

这是标准 MUI 代码,用于在点击 IconButton 组件时打开的菜单。

我们现在可以添加应用栏本身,包括指向主页的链接,如果用户已登录(认证),则添加账户菜单,通过在 frontend/src/components/TopBar.tsx 中添加以下内容:

import AppBar from "@mui/material/AppBar";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Toolbar from "@mui/material/Toolbar";
import React, { useContext } from "react";
import { Link } from "react-router-dom";
import { AuthContext } from "src/AuthContext";
import AccountMenu from "src/components/AccountMenu";
const sxToolbar = {
  paddingLeft: "env(safe-area-inset-left)",
  paddingRight: "env(safe-area-inset-right)",
  paddingTop: "env(safe-area-inset-top)",
}
const TopBar = () => {
  const { authenticated } = useContext(AuthContext);
  return (
    <>
      <AppBar position="fixed">
        <Toolbar sx={sxToolbar}>
          <Box sx={{ flexGrow: 1 }}>
            <Button color="inherit" component={Link} to="/">
              Tozo
            </Button>
          </Box>
          {authenticated ? <AccountMenu /> : null}
        </Toolbar>
      </AppBar>
      <Toolbar sx={{ ...sxToolbar, marginBottom: 2 }} />
    </>
  );
};
export default TopBar;

为了在具有刘海的设备(如 iPhone X)上正确显示应用栏,需要使用 safe-area-inset 的额外填充样式(已高亮)。

TopBar 应在 BrowserRouter 中的 Router 内渲染,通过在 frontend/src/Router.tsx 中添加以下内容:

import TopBar from "src/components/TopBar";

const Router = () => (
  <BrowserRouter> 
    <ScrollToTop /> 
    <TopBar />
    <Routes> 
      {/* Place routes here */} 
    </Routes> 
  </BrowserRouter>
);

应将高亮行添加到现有代码中。

渲染后,应用栏应看起来像 图 5.1

图 5.1:在移动浏览器中显示的应用栏

图 5.1:在移动浏览器中显示的应用栏

导航完成后,我们可以开始添加页面;我们将首先允许用户注册和登录。

添加用户认证页面

在首次访问我们的应用时,用户需要注册、确认他们的电子邮件并登录。而后续访问时,他们只需登录即可。这些操作中的每一个都需要在我们的应用中成为一个页面。

注册

当新用户访问我们的应用时,他们需要做的第一件事是注册,所以我们将首先添加一个注册页面。为了注册,用户需要输入他们的电子邮件和密码。一旦用户提供了这些信息,我们将使用成员 API 创建用户,然后重定向用户到登录页面,或者如果 API 调用失败,显示相关错误。

我们将首先将此逻辑作为一个自定义的 useRegister 钩子添加到 frontend/src/pages/Register.tsx

import axios from "axios"; 
import { FormikHelpers } from "formik";
import { useContext } from "react";
import { useNavigate } from "react-router";
import { ToastContext } from "src/ToastContext";
import { useMutation } from "src/query";
interface IForm {
  email: string;
  password: string;
}
const useRegister = () => {
  const navigate = useNavigate();
  const { addToast } = useContext(ToastContext);
  const { mutateAsync: register } = useMutation(
    async (data: IForm) => await axios.post("/members/", data),
  );
  return async (
    data: IForm,
    { setFieldError }: FormikHelpers<IForm>,
  ) => {
    try {
      await register(data);
      addToast("Registered", "success");
      navigate("/login/", { state: { email: data.email } });
    } catch (error: any) {
      if (
        error.response?.status === 400 &&
        error.response?.data.code === "WEAK_PASSWORD"
      ) {
        setFieldError("password", "Password is too weak");
      } else {
        addToast("Try again", "error");
      }
    }
  };
};

useRegister 钩子返回的函数设计为用作 FormikonSubmit 属性。这允许函数在后端响应表明密码太弱时(如高亮所示)向密码字段添加特定错误。否则,如果注册成功,应用将导航到登录页面。

在注册时自动登录

我们已实现的流程将用户引导到登录页面,他们在注册后登录,而不是自动登录。虽然这不是最佳的用户体验,但这是为了减轻账户枚举,因此是一个安全的默认设置。然而,您可能认为对于您的应用来说用户体验更重要。如果是这样,后端路由需要登录用户,并且此页面应在注册后引导用户到主页。

我们现在需要提供用户输入他们的电子邮件和强密码的输入字段,我们可以通过显示密码强度来确保这一点。字段将被验证,以通知用户任何错误并使用正确的自动完成值。自动完成值应鼓励浏览器为用户做大部分工作(例如,填写他们的电子邮件地址)。

因此,注册页面通过在 frontend/src/pages/Register.tsx 中添加以下代码到现有代码中而扩展:

import { Form, Formik } from "formik";
import { useLocation } from "react-router-dom"; 
import * as yup from "yup";

import EmailField from "src/components/EmailField";
import FormActions from "src/components/FormActions";
import LazyPasswordWithStrengthField from "src/components/LazyPasswordWithStrengthField";
import Title from "src/components/Title";
const validationSchema = yup.object({
  email: yup.string().email("Email invalid").required("Required"),
  password: yup.string().required("Required"),
});

const Register = () => {
  const location = useLocation();
  const onSubmit = useRegister();
  return (
    <>
      <Title title="Register" />
      <Formik<IForm>
        initialValues={{
          email: (location.state as any)?.email ?? "",
          password: "",
        }}
        onSubmit={onSubmit}
        validationSchema={validationSchema}
      >
        {({ dirty, isSubmitting, values }) => (
          <Form>
          <EmailField 
            fullWidth label="Email" name="email" required 
          />
            <LazyPasswordWithStrengthField
              autoComplete="new-password"
              fullWidth
              label="Password"
              name="password"
              required
            />
            <FormActions
              disabled={!dirty}
              isSubmitting={isSubmitting}
              label="Register"
              links={[
                {label: "Login", to: "/login/", state: { email:                   values.email }},
                {label: "Reset password", to: "/forgotten-                  password/", state: { email: values.email }},
              ]}
            />
          </Form>
        )}
      </Formik>
    </>
  );
};
export default Register;

由于用户经常忘记他们是否已经注册,我们已通过 FormActions 链接使导航到登录和重置密码页面变得更加容易。此外,当用户在这些页面之间导航时,电子邮件字段中的任何值都会被保留。这避免了用户需要再次输入它,从而提高了用户体验。这是通过 location.state 实现的,useLocation 钩子获取任何当前值,而 FormActions 组件的 links 属性的 state 部分设置它。

然后,我们可以通过在 frontend/src/Router.tsx 文件中添加以下内容来添加页面到路由中:

import { Route } from "react-router-dom";
import Register from "src/pages/Register";
const Router = () => (
  <BrowserRouter>  
    <ScrollToTop />  
    <TopBar /> 
    <Routes>  
      <Route path="/register/" element={<Register />} /> 
    </Routes>  
  </BrowserRouter>
);

应该将高亮行添加到现有代码中。

完成的 注册 页面应类似于 图 5.2

图 5.2:注册页面

图 5.2:注册页面

现在用户能够注册后,他们接下来需要确认他们的电子邮件。

电子邮件确认

在注册时,用户会收到一封包含返回我们应用的链接的电子邮件。链接中有一个标识用户的令牌。通过跟随链接,用户将令牌传递给我们,并证明他们控制着电子邮件地址。因此,我们需要一个页面,当访问时,将令牌发送到后端并显示结果。

链接的形式为 /confirm-email/:token/,其中 :token 是实际用户的令牌(例如,/confirm-email/abcd/)。因此,我们可以通过添加以下内容到 frontend/src/Router.tsx 文件中,使用路由参数提取令牌:

import ConfirmEmail from "src/pages/ConfirmEmail";
const Router = () => (
  <BrowserRouter>  
    <ScrollToTop />  
    <TopBar /> 
    <Routes>  
      <Route path="/register/" element={<Register />} />
<Route 
path="/confirm-email/:token/" element={<ConfirmEmail />} 
/> 
    </Routes>  
  </BrowserRouter>
);

应该将高亮行添加到现有代码中。

现在,我们可以构建 ConfirmEmail 页面并利用 useParam 钩子从路径中提取令牌。为此,需要在 frontend/src/pages/ConfirmEmail.tsx 文件中添加以下代码:

import LinearProgress from "@mui/material/LinearProgress";
import axios from "axios";
import { useContext } from "react";
import { useParams } from "react-router";
import { Navigate } from "react-router-dom";

import { useQuery } from "src/query";
import { ToastContext } from "src/ToastContext";

interface IParams {
  token?: string;
}

const ConfirmEmail = () => {
  const { addToast } = useContext(ToastContext);
  const params = useParams() as IParams;
  const token = params.token ?? "";
  const { isLoading } = useQuery(
    ["Email"],
    async () => await axios.put("/members/email/", { token }),
    {
      onError: (error: any) => {
        if (error.response?.status === 400) {
          if (error.response?.data.code === "TOKEN_INVALID") {
            addToast("Invalid token", "error");
          } else if (error.response?.data.code === "TOKEN_            EXPIRED"){
            addToast("Token expired", "error");
          }
        } else {
          addToast("Try again", "error");
        }
      },
      onSuccess: () => addToast("Thanks", "success"),
    },
  );

  if (isLoading) {
    return  <LinearProgress />;
  } else {
    return <Navigate to="/" />;
  }
};

export default ConfirmEmail;

高亮行显示了从路径中提取的令牌参数。

为了确保用户知道应用正在运行,当前端等待后端响应时,会显示 LinearProgress 进度条;我们可以在 图 5.3 中看到这一点:

图 5.3:显示正在进行的处理的 LinearProgress 进度条的确认电子邮件页面

图 5.3:显示正在进行的处理的 LinearProgress 进度条的确认电子邮件页面

最后,在注册并确认他们的电子邮件后,用户将需要登录。

登录

用户需要登录才能查看和交互他们的待办事项。为此,用户需要输入他们的电子邮件和密码。一旦用户提供了这些信息,我们将使用会话 API 创建会话。如果登录成功,用户应随后被重定向到主页或 from 状态指定的页面(如果存在)。from 状态将用户重定向到他们尝试登录但未登录时查看的页面。

要完成这个任务,我们首先需要在 frontend/src/pages/Login.tsx 文件中添加以下逻辑:

import axios from "axios"; 
import { FormikHelpers } from "formik";
import { useContext } from "react";
import { useLocation, useNavigate } from "react-router";
import { AuthContext } from "src/AuthContext";
import { ToastContext } from "src/ToastContext";
import { useMutation } from "src/query";
interface IForm {
  email: string;
  password: string;
}
const useLogin = () => {
  const location = useLocation();
  const navigate = useNavigate();
  const { addToast } = useContext(ToastContext);
  const { setAuthenticated } = useContext(AuthContext);
  const { mutateAsync: login } = useMutation(
    async (data: IForm) => await axios.post("/sessions/",      data),
  );
  return async (
    data: IForm,
    { setFieldError }: FormikHelpers<IForm>,
  ) => {
    try {
      await login(data);
      setAuthenticated(true);
      navigate((location.state as any)?.from ?? "/");
    } catch (error: any) {
      if (error.response?.status === 401) {
        setFieldError("email", "Invalid credentials");
        setFieldError("password", "Invalid credentials");
      } else {
        addToast("Try again", "error");
      }
    }
  };
};

在登录逻辑定义后,我们现在可以添加 UI 元素。这需要一个包含电子邮件和密码输入的表单,应将其添加到 frontend/src/pages/Login.tsx 中的现有代码中:

import { Form, Formik } from "formik"; 
import * as yup from "yup";

import EmailField from "src/components/EmailField";
import FormActions from "src/components/FormActions";
import PasswordField from "src/components/PasswordField";
import Title from "src/components/Title";
const validationSchema = yup.object({
  email: yup.string().email("Email invalid").required("Required"),
  password: yup.string().required("Required"),
});

const Login = () => {
  const onSubmit= useLogin();
  const location = useLocation();
  return (
    <>
      <Title title="Login" />
      <Formik<IForm>
        initialValues={{
          email: (location.state as any)?.email ?? "",
          password: "",
        }}
        onSubmit={onSubmit}
        validationSchema={validationSchema}
      >
        {({ dirty, isSubmitting, values }) => (
          <Form>
            <EmailField
              fullWidth label="Email" name="email" required
            />
            <PasswordField
              autoComplete="password"
              fullWidth
              label="Password"
              name="password"
              required
            />
            <FormActions
              disabled={!dirty}
              isSubmitting={isSubmitting}
              label="Login"
              links={[
                {label: "Reset password", to: "/forgotten-                  password/", state: { email: values.email }},
                {label: "Register", to: "/register/", state: {                   email: values.email }},
              ]}
            />
          </Form>
        )}
      </Formik>
    </>
  );
};
export default Login;

突出的代码显示,表单提交在表单变脏之前是禁用的。这有助于用户,因为它确保他们只能在更改表单后提交表单。这是我们将在所有表单上使用的模式。

我们现在可以通过在 frontend/src/Router.tsx 中添加以下内容来将页面添加到路由中:

import Login from "src/pages/Login";
const Router = () => ( 
  <BrowserRouter>   
    <ScrollToTop />   
    <TopBar />  
    <Routes>   
      <Route path="/register/" element={<Register />} /> 
      <Route  
        path="/confirm-email/:token/"        element={<ConfirmEmail />}  
      />  
      <Route path="/login/" element={<Login />} />
    </Routes>   
  </BrowserRouter> 
);

应将突出显示的行添加到现有代码中。

完成的 登录 页面应类似于 图 5.4

图 5.4:登录页面

图 5.4:登录页面

用户现在可以注册并登录到我们的应用。尽管如此,他们无法管理他们的密码,这是我们接下来要关注的。

添加密码管理页面

我们需要允许用户管理他们的密码。这相当复杂,因为用户经常忘记他们的密码,因此还需要一个安全的机制来重置密码。

修改密码

为了让用户更改他们的密码,他们必须提供现有的密码和一个强大的替代密码。因此,前端需要将两者都发送到后端,并在当前密码不正确或新密码太弱时显示相关错误。此逻辑包含在以下代码中,应添加到 frontend/src/pages/ChangePassword.tsx

import axios from "axios"; 
import { FormikHelpers } from "formik";
import { useContext } from "react";
import { useNavigate } from "react-router-dom";
import { ToastContext } from "src/ToastContext";
import { useMutation } from "src/query";
interface IForm {
  currentPassword: string;
  newPassword: string;
}

const useChangePassword = () => {
  const { addToast } = useContext(ToastContext);
  const { mutateAsync: changePassword } = useMutation(
    async (data: IForm) => 
      await axios.put("/members/password/", data),
  );
  const navigate = useNavigate();

  return async (
    data: IForm,
    { setFieldError }: FormikHelpers<IForm>,
  ) => {
    try {
      await changePassword(data);
      addToast("Changed", "success");
      navigate("/");
    } catch (error: any) {
      if (axios.isAxiosError(error)) {
        if (error.response?.status === 400) { 
          setFieldError("newPassword", "Password is too weak"); 
        } else if (error.response?.status === 401) {
          setFieldError("currentPassword", "Incorrect             password"); 
        }
      } else { 
        addToast("Try again", "error"); 
      } 
    }
  };
}

逻辑定义后,我们现在可以添加 UI 元素。这需要一个包含普通密码字段和密码强度字段的表单,如图所示,应将其添加到 frontend/src/pages/ChangePassword.tsx 中的现有代码中:

import { Form, Formik } from "formik";
import * as yup from "yup";
import FormActions from "src/components/FormActions";
import LazyPasswordWithStrengthField from "src/components/LazyPasswordWithStrengthField";
import PasswordField from "src/components/PasswordField";
import Title from "src/components/Title";

const validationSchema = yup.object({
  currentPassword: yup.string().required("Required"),
  newPassword: yup.string().required("Required"),
});

const ChangePassword = () => {
  const onSubmit = useChangePassword();
  return (
    <>
      <Title title="Change Password" />
      <Formik<IForm>
        initialValues={{ currentPassword: "", newPassword: "" }}
        onSubmit={onSubmit}
        validationSchema={validationSchema}
      >
        {({ dirty, isSubmitting }) => (
          <Form>
            <PasswordField
              autoComplete="current-password"
              fullWidth
              label="Current password"
              name="currentPassword"
              required
            />
            <LazyPasswordWithStrengthField 
              autoComplete="new-password" 
              fullWidth 
              label="New password" 
              name="newPassword" 
              required 
            />
            <FormActions 
              disabled={!dirty}
              isSubmitting={isSubmitting} 
              label="Change" 
              links={[{ label: "Back", to: "/" }]} 
            />
          </Form>
        )}
      </Formik>
    </>
  );
};
export default ChangePassword;

然后,我们可以通过在 frontend/src/Router.tsx 中添加以下内容来将页面添加到路由中:

import RequireAuth from "src/components/RequireAuth"; 
import ChangePassword from "src/pages/ChangePassword";
const Router = () => (
  <BrowserRouter>
    ...
    <Routes>
      ...
      <Route 
        path="/change-password/" 
        element={<RequireAuth><ChangePassword /></RequireAuth>} 
      />
    </Routes>
  </BrowserRouter>
);

在代码块中,... 代表为了简洁而省略的代码。

完成的 修改密码 页面应类似于 图 5.5

图 5.5:修改密码页面

图 5.5:修改密码页面

用户现在可以在登录状态下更改他们的密码。接下来,我们将允许用户在忘记密码时请求密码重置链接。

忘记密码

当用户忘记他们的密码时,他们需要通过请求重置链接来重置密码。为此,用户需要输入他们的电子邮件,然后我们将使用成员 API 向他们发送密码重置电子邮件,如果失败,则显示通用错误。

执行此操作的以下代码应放置在 frontend/src/pages/ForgottenPassword.tsx

import axios from "axios";
import { useContext } from "react";
import { useNavigate } from "react-router";

import { useMutation } from "src/query";
import { ToastContext } from "src/ToastContext";
interface IForm {
  email: string;
}

const useForgottenPassword = () => {
  const navigate = useNavigate();
  const { addToast } = useContext(ToastContext);

  const { mutateAsync: forgottenPassword } = useMutation(
    async (data: IForm) => 
      await axios.post("/members/forgotten-password/", data),
  ); 
  return async (data: IForm) => {
    try {
      await forgottenPassword(data);
      addToast("Reset link sent to your email", "success");
      navigate("/login/");
    } catch {
      addToast("Try again", "error");
    }
  };
};

逻辑定义后,我们现在可以添加 UI 元素。这需要一个包含电子邮件字段的表单,如图所示,应将其添加到 frontend/src/pages/ForgottenPassword.tsx 中的现有代码中:

import { Form, Formik } from "formik";
import { useLocation } from "react-router";
import * as yup from "yup";

import EmailField from "src/components/EmailField";
import FormActions from "src/components/FormActions";
import Title from "src/components/Title";

const validationSchema = yup.object({ 
  email: yup.string().email("Email invalid").required("Required"), 
});

const ForgottenPassword = () => {
  const onSubmit = useForgottenPassword();
  const location = useLocation();

  return (
    <>
      <Title title="Forgotten password" />
      <Formik<IForm>
        initialValues={{ 
          email: (location.state as any)?.email ?? "" 
        }}
        onSubmit={onSubmit}
        validationSchema={validationSchema}
      >
        {({ dirty, isSubmitting, values }) => (
          <Form>
            <EmailField
              fullWidth label="Email" name="email" required
            />
            <FormActions 
              disabled={!dirty}
              isSubmitting={isSubmitting} 
              label="Send email" 
              links={[ 
                {label: "Login", to: "/login/", state: { email:                   values.email }}, 
                {label: "Register", to: "/register/", state: {                   email: values.email }}, 
              ]} 
            />
          </Form>
        )}
      </Formik>
    </>
  );
};

export default ForgottenPassword;

然后,我们可以通过在 frontend/src/Router.tsx 中添加以下内容来将页面添加到路由中:

import ForgottenPassword from "src/pages/ForgottenPassword";
const Router = () => (
  <BrowserRouter>
    ...
    <Routes>
      ...
      <Route 
        path="/forgotten-password/" 
        element={<ForgottenPassword />} 
      />
    </Routes>
  </BrowserRouter>
);

在代码块中,... 代表为了简洁而省略的代码。

完成的 忘记密码 页面应类似于 图 5.6

图 5.6:忘记密码页面

图 5.6:忘记密码页面

接下来,我们需要添加一个页面,让用户访问以实际重置他们的密码。

重置密码

发送给用户通过忘记密码页面的电子邮件将包含一个链接到重置密码页面。此链接将包含一个标识用户的令牌,这与前面描述的电子邮件确认过程相同。此页面需要允许用户输入一个新的强密码,并通过链接的令牌将其发送到后端。执行此操作的逻辑如下所示,应将其放置在 frontend/src/pages/ResetPassword.tsx 中:

import axios from "axios";
import { FormikHelpers } from "formik";
import { useContext } from "react";
import { useNavigate, useParams } from "react-router";

import { useMutation } from "src/query";
import { ToastContext } from "src/ToastContext";
interface IForm {
  password: string;
}
interface IParams {
  token?: string;
}

const useResetPassword = () => {
  const navigate = useNavigate();
  const params = useParams() as IParams;
  const token = params.token ?? "";
  const { addToast } = useContext(ToastContext);

  const { mutateAsync: reset } = useMutation(
    async (password: string) => 
      await axios.put(
        "/members/reset-password/", { password, token },
      ),
  ); 
  return async (
    data: IForm, 
    { setFieldError }: FormikHelpers<IForm>, 
  ) => {
    try {
      await reset(data.password);
      addToast("Success", "success");
      navigate("/login/");
    } catch (error: any) {
      if (error.response?.status === 400) { 
        if (error.response?.data.code === "WEAK_PASSWORD") { 
          setFieldError("newPassword", "Password is too weak");  
        } else if (error.response?.data.code === "TOKEN_           INVALID") {
          addToast("Invalid token", "error"); 
        } else if (error.response?.data.code === "TOKEN_           EXPIRED") { 
          addToast("Token expired", "error"); 
        } 
      } else {
        addToast("Try again", "error");
      }
    }
  }
};

逻辑定义完毕后,我们现在可以添加 UI 元素。这需要一个包含显示密码强度字段的表单。我们可以通过向 frontend/src/pages/ResetPassword.tsx 中的现有代码添加以下代码来实现:

import { Form, Formik } from "formik";
import * as yup from "yup";

import LazyPasswordWithStrengthField from "src/components/LazyPasswordWithStrengthField"
import FormActions from "src/components/FormActions";
import Title from "src/components/Title";

const validationSchema = yup.object({ 
  email: yup.string().email("Email invalid").required("Required"), 
});

const ResetPassword = () => { 
  const onSubmit = useResetPassword();
  return (
    <>
      <Title title="Reset password" />
      <Formik<IForm>
        initialValues={{ password: "" }}
        onSubmit={onSubmit}
        validationSchema={validationSchema}
      >
        {({ dirty, isSubmitting, values }) => (
          <Form>
            <LazyPasswordWithStrengthField
              autoComplete="new-password"
              fullWidth
              label="Password"
              name="password"
              required
            />
            <FormActions 
              disabled={!dirty}
              isSubmitting={isSubmitting} 
              label="Reset password"
              links={[{label: "Login", to: "/login/"}]}
            />
          </Form>
        )}
      </Formik>
    </>
  );
};

export default ResetPassword;

然后,我们可以通过向 frontend/src/Router.tsx 添加以下内容来添加页面到路由中:

import ResetPassword from "src/pages/ResetPassword";
const Router = () => (
  <BrowserRouter>
    ...
    <Routes>
      ...
      <Route 
        path="/reset-password/:token/" 
        element={<ResetPassword />} 
      />
    </Routes>
  </BrowserRouter>
);

在代码块中,... 代表为了简洁而省略的代码。

完成的 重置密码 页面应类似于 图 5.7

图 5.7:重置密码页面

图 5.7:重置密码页面

用户现在可以管理他们的密码,这意味着我们可以专注于管理他们的待办事项的页面。

添加待办事项页面

用户将需要通过应用程序管理他们的待办事项,包括创建、编辑和查看他们的待办事项。这些对应于不同的页面,我们将添加。

首先,让我们创建我们将需要从后端获取待办事项的特定 React-Query 查询。我们可以通过向 frontend/src/queries.ts 添加以下代码来实现:

import axios from "axios";
import { useQueryClient } from "@tanstack/react-query";
import { Todo } from "src/models";
import { useQuery } from "src/query";
export const STALE_TIME = 1000 * 60 * 5;  // 5 mins
export const useTodosQuery = () => 
  useQuery<Todo[]>(
    ["todos"], 
    async () => {
      const response = await axios.get("/todos/");
      return response.data.todos.map(
        (json: any) => new Todo(json)
      );
    },
    { staleTime: STALE_TIME },
  );
export const useTodoQuery = (id: number) => {
  const queryClient = useQueryClient();
  return useQuery<Todo>(
    ["todos", id.toString()],
    async () => {
      const response = await axios.get(`/todos/${id}/`);
      return new Todo(response.data);
    },
    {
      initialData: () => {
        return queryClient
          .getQueryData<Todo[]>(["todos"])
          ?.filter((todo: Todo) => todo.id === id)[0];
      },
      staleTime: STALE_TIME,
    },
  );
};

staleTime 选项(突出显示)的更改确保 react-query 不会不断重新获取待办事项数据,而是将其视为有效 5 分钟。这通过减少用户的互联网数据使用来提高用户体验。此外,如果可用,useTodoQuery 将有助于使用缓存的待办事项数据作为 initialData,从而节省对后端的请求并提高用户体验。

哪个用户的待办事项?

之前定义的 useTodosQuery 可能不清楚只会返回当前认证用户的待办事项。这是因为我们已经设置了后端只返回属于当前认证用户的待办事项。在用户能够更改前端代码并可能绕过检查的情况下,在后台做出认证决策至关重要。

接下来,我们需要通过向 frontend/src/queries.ts 添加以下内容来添加更新后端待办事项数据的突变:

import { useMutation } from "src/query";
export interface ItodoData {
  complete: boolean;
  due: Date | null;
  task: string;
} 
export const useCreateTodoMutation = () => {
  const queryClient = useQueryClient();
  return useMutation(
    async (data: ItodoData) => await axios.post("/todos/",       data),
    {
      onSuccess: () => queryClient.invalidateQueries(["todos"]),
    },
  );
};
export const useEditTodoMutation = (id: number) => {
  const queryClient = useQueryClient();
  return useMutation(
    async (data: ItodoData) => 
      await axios.put(`/todos/${id}/`, data),
    {
      onSuccess: () => queryClient.        invalidateQueries(["todos"]), 
    },
  );
};
export const useDeleteTodoMutation = () => {
  const queryClient = useQueryClient();
  return useMutation(
    async (id: number) => 
      await axios.delete(`/todos/${id}/`),
    {
      onSuccess: () => queryClient.        invalidateQueries(["todos"]), 
    },
  );
};

这三个突变都将使 [“todos”] 查询数据无效,因此需要之前定义的待办事项查询来获取新数据,而不是返回过时的数据。

在这些查询可用的情况下,我们现在可以创建实际的视觉元素(即用户可以与之交互的页面)。

展示待办事项

我们需要的第一页是展示所有用户待办事项的页面,这实际上是用户的首页。除了展示待办事项外,它还需要提供创建待办事项以及编辑和删除现有待办事项的操作。

编辑或删除待办事项的操作可以通过点击待办事项或与其关联的删除按钮直接链接到待办事项。然而,创建待办事项是页面的主要操作,因此最适合使用浮动操作按钮。因此,待办事项页面应类似于 图 5.8

图 5.8:显示待办事项的首页,以及浮动操作按钮

图 5.8:显示待办事项的首页,以及浮动操作按钮

首先,让我们通过在 frontend/src/components/Todo.tsx 中添加以下内容来创建一个显示单个待办事项的组件:

import Checkbox from "@mui/material/Checkbox";
import IconButton from "@mui/material/IconButton";
import ListItem from "@mui/material/ListItem";
import ListItemButton from "@mui/material/ListItemButton";
import ListItemIcon from "@mui/material/ListItemIcon";
import ListItemText from "@mui/material/ListItemText";
import Skeleton from "@mui/material/Skeleton";
import DeleteIcon from "@mui/icons-material/Delete";
import { format } from "date-fns";
import { Link } from "react-router-dom";
import { Todo as TodoModel } from "src/models";
import { useDeleteTodoMutation } from "src/queries";
interface IProps { todo?: TodoModel }
const Todo = ({ todo }: IProps) => {
  const { mutateAsync: deleteTodo } = useDeleteTodoMutation();
  let secondary; 
  if (todo === undefined) {
    secondary = <Skeleton width="200px" />;
  } else if (todo.due !== null) {
    secondary = format(todo.due, "P");
  }
  return (
    <ListItem
      secondaryAction={
        <IconButton
          disabled={todo === undefined} edge="end"
          onClick={() => deleteTodo(todo?.id!)}
        >
          <DeleteIcon />
        </IconButton>
      }
    >
      <ListItemButton
        component={Link} disabled={todo === undefined}
        to={`/todos/${todo?.id}/`}
      >
        <ListItemIcon>
          <Checkbox
            checked={todo?.complete ?? false}
            disabled disableRipple edge="start" tabIndex={-1}
          />
        </ListItemIcon>
        <ListItemText 
          primary={todo?.task ?? <Skeleton />}           secondary={secondary}
        />
      </ListItemButton>
    </ListItem>
  );
}
export default Todo;

todo 属性未定义时,此 Todo 组件将渲染骨架。我们可以利用这一点来改善用户体验,因为待办事项是从后端获取的。

骨架加载

从后端获取数据将花费一定的时间,在这段时间内,用户会想知道应用在做什么。因此,最好向用户展示应用正在工作(加载数据)。我们将通过使用与完成页面布局相同的灰色动画块(即骨架)来实现这一点。灰色的布局看起来像骨架,因此得名。

通过在 frontend/src/pages/Todos.tsx 中添加以下内容,完成显示用户所有待办事项的完整首页:

import Fab from "@mui/material/Fab";
import List from "@mui/material/List";
import AddIcon from "@mui/icons-material/Add";
import { Link, Navigate } from "react-router-dom";
import Todo from "src/components/Todo";
import { useTodosQuery } from "src/queries";
const Todos = () => {
  const { data: todos } = useTodosQuery();
  if (todos?.length === 0) {
    return <Navigate to="/todos/new/" />;
  } else {
    return (
      <>
        <List>
          {todos !== undefined ?
            todos.map((todo) => <Todo key={todo.id} todo={todo} />)
            : [1, 2, 3].map((id) => <Todo key={-id} />)  
          }
        </List>
        <Fab 
          component={Link} 
          sx={{ 
            bottom: (theme) => theme.spacing(2), 
            position: "fixed", 
            right: (theme) => theme.spacing(2), 
          }} 
          to="/todos/new/"
        >
          <AddIcon />
        </Fab>
      </>
    );
  }
};
export default Todos;

然后,我们可以通过在 frontend/src/Router.tsx 中添加以下内容来将页面添加到路由中:

import Todos from "src/pages/Todos";
const Router = () => (
  <BrowserRouter>
    ...
    <Routes>
      ...
      <Route 
        path="/" 
        element={<RequireAuth><Todos /></RequireAuth>} 
      />
    </Routes>
  </BrowserRouter>
);

在代码块中,... 代表为了简洁而省略的代码。

现在我们能够显示待办事项,我们需要能够创建和编辑它们。

创建待办事项

我们将需要为用户提供创建新待办事项和编辑现有待办事项的页面。这两个页面都需要一个表单来输入和编辑待办事项数据。为了避免在每个页面上重复表单代码,我们将创建一个 TodoForm 组件,首先通过在 frontend/src/components/TodoForm.tsx 中添加以下内容来定义表单验证:

import * as yup from "yup";
const validationSchema = yup.object({
  complete: yup.boolean(),
  due: yup.date().nullable(),
  task: yup.string().required("Required"),
});

在定义了验证模式和表单结构后,我们可以添加组件本身。该组件只需要在 Formik 表单中渲染相关字段。以下代码应添加到 frontend/src/components/TodoForm.tsx

import { Form, Formik } from "formik";

import CheckboxField from "src/components/CheckboxField";
import DateField from "src/components/DateField";
import FormActions from "src/components/FormActions";
import TextField from "src/components/TextField";
import type { ITodoData } from "src/queries";

interface IProps {
  initialValues: ITodoData;
  label: string;
  onSubmit: (data: ITodoData) => Promise<any>;
}

const TodoForm = ({ initialValues, label, onSubmit }: IProps) => (
  <Formik< ITodoData>
    initialValues={initialValues}
    onSubmit={onSubmit}
    validationSchema={validationSchema}
  >
    {({ dirty, isSubmitting }) => (
      <Form>
        <TextField
          fullWidth label="Task" name="task" required
        />
        <DateField fullWidth label="Due" name="due" />
        <CheckboxField
          fullWidth label="Complete" name="complete"
        />
        <FormActions
          disabled={!dirty}
          isSubmitting={isSubmitting}
          label={label}
          links={[{ label: "Back", to: "/" }]}
        />
      </Form>
    )}
  </Formik>
);

export default TodoForm;

然后,我们可以在页面中使用 TodoForm 来创建待办任务,通过在 frontend/src/pages/CreateTodo.tsx 中添加以下内容:

import { useContext } from "react";
import { useNavigate } from "react-router-dom";

import TodoForm from "src/components/TodoForm";
import Title from "src/components/Title";
import type { ITodoData } from "src/queries";
import { useCreateTodoMutation } from "src/queries";
import { ToastContext } from "src/ToastContext";

const CreateTodo = () => {
  const navigate = useNavigate();
  const { addToast } = useContext(ToastContext);
  const { mutateAsync: createTodo } = useCreateTodoMutation();
  const onSubmit = async (data: ITodoData) => {
    try {
      await createTodo(data);
      navigate("/");
    } catch {
      addToast("Try Again", "error");
    }
  };

  return (
    <>
      <Title title="Create a Todo" />
      <TodoForm
        initialValues={{           complete: false, due: null, task: ""         }}
        label="Create"
        onSubmit={onSubmit}
      />
    </>
  );
};

export default CreateTodo;

然后,我们可以通过在 frontend/src/Router.tsx 中添加以下内容来将页面添加到路由中:

import CreateTodo from "src/pages/CreateTodo";
const Router = () => (
  <BrowserRouter>
    ...
    <Routes>
      ...
      <Route 
        path="/todos/new/" 
        element={<RequireAuth><CreateTodo /></RequireAuth>} 
      />
    </Routes>
  </BrowserRouter>
);

在代码块中,... 代表为了简洁而省略的代码。

完成的创建待办事项页面应类似于 图 5.9

图 5.9:创建待办事项页面显示待办事项表单

图 5.9:创建待办事项页面显示待办事项表单

用户在创建待办事项后希望能够编辑它们,这是我们接下来要添加的。

编辑待办事项

最后,对于待办事项页面,我们需要允许用户编辑他们的待办事项;您可以通过以下代码来实现,该代码应添加到 frontend/src/pages/EditTodo.tsx

import Skeleton from "@mui/material/Skeleton";
import { useContext } from "react";
import { useNavigate, useParams } from "react-router";

import TodoForm from "src/components/TodoForm";
import Title from "src/components/Title";
import type { ITodoData } from "src/queries";
import { useEditTodoMutation, useTodoQuery } from "src/queries";
import { ToastContext } from "src/ToastContext";

interface Iparams {
  id: string;
}

const EditTodo = () => {
  const navigate = useNavigate();
  const params = useParams<keyof Iparams>() as Iparams;
  const todoId = parseInt(params.id, 10);
  const { addToast } = useContext(ToastContext);
  const { data: todo } = useTodoQuery(todoId); 
  const { mutateAsync: editTodo } = useEditTodoMutation(todoId);

  const onSubmit = async (data: ITodoData) => {
    try {
      await editTodo(data);
      navigate("/");
    } catch {
      addToast("Try again", "error");
    }
  };

  return (
    <>
      <Title title="Edit todo" />
      {todo === undefined ? (
        <Skeleton height="80px" />
      ) : (
        <TodoForm
          initialValues={{
            complete: todo.complete,
            due: todo.due,
            task: todo.task,
          }}
          label="Edit"
          onSubmit={onSubmit}
        />
      )}
    </>
  );
};

export default EditTodo;

然后,我们可以通过在 frontend/src/Router.tsx 中添加以下内容来添加页面到路由:

import EditTodo from "src/pages/EditTodo";
const Router = () => (
  <BrowserRouter>
    ...
    <Routes>
      ...
      <Route 
        path="/todos/:id/" 
        element={<RequireAuth><EditTodo /></RequireAuth>} 
      />
    </Routes>
  </BrowserRouter>
);

在代码块中,... 表示为了简洁而省略的代码。

完成的编辑待办事项页面应类似于 图 5.10

图 5.10:编辑待办事项页面

图 5.10:编辑待办事项页面

这完成了我们待办应用所需的前端功能。

摘要

在本章中,我们创建了一个用户界面,允许用户进行身份验证、管理他们的密码以及管理他们的待办事项。这完成了应用的开发版本,我们现在可以在本地使用它来管理待办事项。

用户身份验证和密码管理用户界面对任何应用都很有用,可以直接用于您的应用中,而待办事项用户界面可以进行调整或作为其他功能的参考。

在下一章中,我们将把这个应用部署到生产环境,使用户能够访问和使用它。

进一步阅读

为了进一步增强您的应用,我建议您阅读更多关于良好 UX 实践的内容,例如,通过 builtformars.com。此外,为了提高您的前端样式技能,我建议 css-tricks.com

第三部分 发布一个生产就绪的应用

构建一个可工作的应用只是第一步;它需要部署到运行在 AWS 上的公共域名,确保安全,然后打包到移动应用商店。我们将通过尽可能多地融入行业最佳实践来完成所有这些工作。

本部分包括以下章节:

  • 第六章部署和监控您的应用

  • 第七章保护和应用打包

第六章:部署和监控您的应用程序

在上一章中,我们构建了应用程序的前端,从而使其成为一个可用的工具。然而,尽管我们可以在本地使用它,但其他用户将无法使用。因此,在本章中,我们将部署我们的应用程序,并通过一个公开的域名tozo.dev使其可用。我们还将确保我们正在监控应用程序,以便我们可以快速修复任何问题。

因此,在本章中,你将学习如何为任何需要数据库的 Docker 容器化应用程序在 AWS 中构建基础设施;该基础设施将能够扩展到非常高的负载而无需进行重大更改。你还将学习如何为你的域名设置域名系统DNS)和 HTTPS,这两者都适用于任何网站或应用程序。最后,你将了解监控的重要性以及如何轻松地进行监控。

为了使我们的应用程序可以通过公开域名访问,它需要在始终可以通过互联网访问的系统上运行。这可能可以是任何系统,包括我们的本地计算机。然而,该系统需要持续运行并通过稳定的 IP 地址访问。因此,支付由 AWS 管理的专用系统费用会更好。

AWS 费用

本章构建的 AWS 基础设施在无免费层的情况下运行成本约为每月 20 美元。如果你能够使用免费层,这将更便宜(但不是免费的)。另外,AWS 有几个创业信用计划,你可能符合资格。

如果你想停止付费,你需要移除基础设施,这可以通过删除resource定义并运行terraform apply来完成。

一旦我们为远程系统付费,我们可以将其配置为直接运行我们的应用程序,就像我们的本地系统一样。然而,我们将使用容器化基础设施,因为配置容器以运行我们的应用程序比配置远程系统更容易。

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

  • 使应用程序准备就绪以投入生产

  • 部署到 AWS

  • 在域名上提供服务

  • 发送生产电子邮件

  • 监控生产

技术要求

要使用配套仓库跟踪本章的发展,请访问github.com/pgjones/tozo,查看r1-ch6-startr1-ch6-end标签之间的提交。

使应用程序准备就绪以投入生产

由于我们的生产基础设施将运行容器,我们需要将我们的应用程序容器化。为此,我们需要决定如何提供前端和后端服务,以及如何构建容器镜像。

前端服务

到目前为止,在开发过程中,我们使用 npm run start 来运行一个提供前端代码的服务器。这被称为 服务器端渲染SSR),我们可以在生产环境中继续这样做。然而,利用 客户端渲染CSR)要容易得多,因为它不需要一个专门的前端服务器。CSR 通过构建一个可以由任何服务器(而不是专门的前端服务器)提供的文件包来实现,我们将使用后端服务器。

要构建前端包,我们可以使用 npm run build 命令。此命令创建一个单独的 HTML 文件 (frontend/build/index.xhtml) 和多个静态文件(cssjsmedia)在以下结构中:

tozo
└── frontend
    └── build
        └── static
            ├── css
            ├── js
            └── media

静态文件,包括 frontend/build/static 文件夹内的文件,可以通过将文件和结构移动到 backend/src/backend/static 文件夹来提供服务。然后,我们的后端将自动使用与文件夹结构匹配的路径提供这些文件。

包的剩余部分(即 HTML 文件)需要为任何匹配应用中页面的请求提供服务。为此,我们首先需要一个服务蓝图,通过在 backend/src/backend/blueprints/serving.py 中添加以下内容来创建它:

from quart import Blueprint
blueprint = Blueprint("serving", __name__)

随后需要将蓝图注册到应用中,通过在 backend/src/backend/run.py 中添加以下内容:

from backend.blueprints.serving import blueprint as serving_blueprint
app.register_blueprint(serving_blueprint)

由于后端没有关于哪些路径匹配前端页面的知识,它将为任何不匹配后端 API 路径的路径提供前端服务。这是通过 Quart 使用 <path:path> URL 变量完成的;因此,将以下内容添加到 backend/src/backend/blueprints/serving.py 中:

from quart import render_template, ResponseReturnValue
from quart_rate_limiter import rate_exempt

@blueprint.get("/")
@blueprint.get("/<path:path>")
@rate_exempt
async def index(path: str | None = None) -> ResponseReturnValue:
    return await render_template("index.xhtml")

最后,需要将 frontend/build/index.xhtml 复制到 backend/src/backend/templates/index.xhtml 以用于生产应用,就像我们在容器化应用时做的那样。

由于现在可以从后端服务器提供前端服务,我们现在可以专注于使用一个生产就绪的后端服务器。

后端服务

到目前为止,在开发过程中,我们使用 pdm run start 来运行和提供后端服务。然而,这不适合生产环境,因为它启动了一个为开发配置的 Hypercorn 服务器(例如,它配置服务器输出调试信息)。

Hypercorn

Quart 是一个需要服务器才能工作的框架。到目前为止,在开发过程中,我们一直使用为开发配置的 Hypercorn。Hypercorn 是一个支持 HTTP/1、HTTP/2 和 HTTP/3 的 Python 服务器,以高效的方式运行,并由 Quart 推荐使用。

我们将使用以下配置来为生产环境配置 Hypercorn,放置在 hypercorn.toml 中:

accesslog = "-"
access_log_format = "%(t)s %(h)s %(f)s - %(S)s '%(r)s' %(s)s %(b)s %(D)s"
bind = "0.0.0.0:8080"
errorlog = "-"

accesslogerrorlog 配置确保 Hypercorn 在运行时记录每个请求和错误,这将帮助我们了解服务器正在做什么。bind 配置 Hypercorn 监听 8080 端口,当我们设置下一节的生产基础设施时,我们将网络流量导向此端口。

然后,可以通过以下命令在生产环境中启动服务器:

pdm run hypercorn --config hypercorn.toml backend.run:app

现在我们知道了如何在生产环境中提供后端,我们需要关注如何安装我们为此所需的一切。

容器化应用程序

要在生产环境中运行应用程序,我们需要在容器中安装应用程序的所有依赖项和代码。我们将通过构建一个包含已安装依赖项和包含代码的容器镜像来实现这一点。

要构建镜像,我们将使用 Dockerfile,因为这是构建镜像最清晰的方式。具体来说,我们将使用多阶段 Dockerfile,第一阶段构建前端,最终阶段安装和运行后端服务器。

Docker 术语

Dockerfile 与 Docker 一起用于构建容器镜像。Dockerfile 是一个有序的命令列表,每个命令生成最终镜像的一层,每一层都是基于前一层构建的。最终的镜像需要包含运行其中代码所需的一切。运行实例的镜像被称为容器

构建前端阶段

要构建前端,我们需要一个已安装 NodeJS 的系统。由于这是一个常见的要求,我们可以使用 NodeJS 基础镜像。因此,我们可以在Dockerfile中添加以下内容,以创建一个名为frontend的基于 NodeJS 的阶段:

FROM node:18-bullseye-slim as frontend 

接下来,我们需要创建一个工作目录并在其中安装前端依赖项:

WORKDIR /frontend/
COPY frontend/package.json frontend/package-lock.json /frontend/
RUN npm install

这最好在将代码复制到镜像之前完成,因为依赖项的变化频率低于代码。

Dockerfile 缓存

Dockerfile 是一系列命令,每个命令在最终镜像中形成一个层。这些层按照 Dockerfile 中给出的顺序构建,对任何层的更改都需要重建所有后续层,并且早期层将被缓存。因此,最好将很少改变的层放在经常改变的层之前。

最后,我们可以将为我们应用程序编写的客户端代码复制到镜像中,并使用以下代码构建它:

COPY frontend /frontend/
RUN npm run build

现在我们有一个包含构建好的前端的完整前端阶段。我们将在生产镜像中使用它。

构建生产镜像

生产镜像将在Dockerfile的第二个阶段构建。此阶段也可以从一个现有的基础镜像开始,因为安装了 Python 的系统也是一个常见的要求。为此,以下内容应添加到Dockerfile中:

FROM python:3.10.1-slim-bullseye

接下来,我们需要添加一个init系统,以确保在 Docker 容器中运行时,正确地向我们的后端服务器发送信号。dumb-init是一个流行的解决方案,我之前多次使用过。dumb-init通过以下添加进行安装和配置:

RUN apt-get update && apt install dumb-init 
ENTRYPOINT ["/usr/bin/dumb-init", "--"]

然后,我们可以配置 Hypercorn 在镜像运行时启动:

EXPOSE 8080
RUN mkdir -p /app
WORKDIR /app
COPY hypercorn.toml /app/
CMD ["pdm", "run", "hypercorn", "--config", "hypercorn.toml", "backend.run:app"]

接下来,我们需要安装后端依赖项,这首先需要我们安装pdm并配置 Python 以与之协同工作:

RUN python -m venv /ve
ENV PATH=/ve/bin:${PATH}
RUN pip install --no-cache-dir pdm

这允许我们使用pdm安装后端依赖项:

COPY backend/pdm.lock backend/pyproject.toml /app/
RUN pdm install --prod --no-lock --no-editable 

现在,我们可以从前端阶段包含构建好的前端:

COPY --from=frontend /frontend/build/index.xhtml \
    /app/backend/templates/ 
COPY --from=frontend /frontend/build/static/. /app/backend/static/

最后,我们可以将后端代码复制到镜像中:

COPY backend/src/ /app/

这为我们提供了一个完整的镜像,可用于生产环境。

为了使镜像更安全,我们可以更改将运行服务器的用户。默认情况下,这是带有管理权限和访问权限的root用户,而将用户更改为nobody则移除了这些权限。我们可以通过添加以下内容来完成此操作:

USER nobody

既然我们已经定义了如何构建 Docker 镜像,我们现在可以专注于构建和部署它。

部署到 AWS

要部署我们的应用程序,我们需要构建一个运行容器和数据库的基础设施。容器必须可以从公共互联网访问,数据库必须可以从容器访问。这个基础设施很容易用AWS构建,我们将使用它。然而,在这本书中,我们将使用 AWS 服务,这些服务在其他云提供商上也有等效服务,如果您希望使用不同的提供商。

首先,我们需要使用电子邮件、密码和您的卡详情创建一个 AWS 账户(通过此链接:aws.amazon.com)。这个账户将是根账户或超级用户账户;因此,我们将为 Terraform 创建一个额外的身份和访问管理IAM)子账户。IAM 用户是通过 IAM 用户仪表板上的添加用户按钮创建的,如图 6.1 所示:

图 6.1:IAM 仪表板(带有添加用户按钮)

图 6.1:IAM 仪表板(带有添加用户按钮)

我将把这个用户命名为terraform,以表明它的用途。它应该只有程序性访问权限,并附加AdministratorAccess策略。一旦创建,将显示访问密钥 ID 和秘密访问密钥;两者都需要按照以下方式添加到*infrastructure/secrets.auto.tfvars*中:

aws_access_key = "abcd"
aws_secret_key = "abcd"

我正在使用abcd作为示例,您需要将其替换为您自己的值。

在设置好凭证后,我们可以开始配置 Terraform 以与 AWS 协同工作。首先,通过在*infrastructure/main.tf*中现有的 Terraform required_providers部分添加以下内容,将 AWS 提供者添加到 Terraform 中:

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = ">=3.35.0"
    }
  }
}

进行此更改后,需要运行terraform init以使更改生效。

然后,我们可以配置提供者,这需要选择一个要使用的区域。由于我位于英国伦敦,我将使用eu-west-2,然而,我建议您使用离您的客户最近的区域。这可以通过在*infrastructure/aws.tf*中添加以下内容来完成:

variable "aws_access_key" {
  sensitive = true
}

variable "aws_secret_key" {
  sensitive = true
}

provider "aws" {
  access_key = var.aws_access_key
  secret_key = var.aws_secret_key
  region     = "eu-west-2"
}

现在,我们可以使用 Terraform 来管理 AWS 基础设施,这意味着我们可以专注于我们希望该基础设施成为什么样子。

设计生产系统

第二章,“使用 Quart 创建可重用后端”,我们决定构建一个三层架构,其中有一个后端 API 与前端和数据库通信。这意味着在 AWS 中,我们需要运行数据库、后端在容器中,以及一个负载均衡器来监听来自前端的外部请求。为此,我们可以使用图 6.2中显示的服务和设置:

图 6.2:预期的 AWS 架构

图 6.2:预期的 AWS 架构

此架构使用以下 AWS 服务:

  • 关系数据库服务RDS)运行 PostgreSQL 数据库

  • 弹性容器服务ECS)运行应用容器

  • 应用程序负载均衡器ALB)接受来自互联网(前端)的连接

此外,我们将使用 ECS 的Fargate变体,这意味着我们不需要管理运行容器的系统。

通过使用这些托管服务,我们可以支付 AWS 来管理服务器的大部分工作,使我们能够专注于我们的应用。现在我们可以设置网络以支持这种架构。

设置网络

要构建我们的架构,我们必须从基础开始,即网络。我们需要定义系统之间如何相互通信。在图 6.3中,你可以看到我们正在努力实现一个单一虚拟私有云VPC),包含公共和私有子网。

图 6.3:预期的网络设置

图 6.3:预期的网络设置

关键的是,私有子网只能与公共子网通信,但不能直接与互联网通信。这意味着我们可以将数据库放在私有子网中,将应用和 ALB 放在公共子网中,从而增加一个额外的安全层,防止未经授权的数据库访问。

VPC

VPC 是一个包含资源的虚拟网络。我们将为所有资源使用单个 VPC。

要构建网络,我们首先需要在infrastructure/aws_network.tf中添加以下内容来为我们的系统创建一个 AWS VPC:

resource "aws_vpc" "vpc" {
  cidr_block         = "10.0.0.0/16"
  enable_dns_support = true
}

CIDR 表示法

AWS 使用/). IPv4 地址由 4 个字节组成(每个字节是 8 位),每个字节以数字形式书写,并用点(.)分隔。子网掩码数字表示试地址的前导位数必须与给定地址匹配才能被认为是给定范围内的地址。以下是一些 CIDR 范围的示例:

  • 10.0.0.0/16 表示在此范围内前 16 位(或前两个字节)必须匹配(即,任何以10.0开头的地址都在此范围内)

  • 10.0.0.64/26 表示前 26 位或前 3 个字节以及最终字节的第一个 2 位必须匹配(即,任何在10.0.0.6410.0.0.128(不包括10.0.0.128)之间的地址)

  • 0.0.0.0/0 表示任何 IP 地址都匹配

使用这种 VPC 设置,我们将使用的所有 IP 地址都将位于 10.0.0.0/16 CIDR 块中,因此将以 10.0 开头。这个块是 AWS VPC 的传统选择。

我们现在可以将 VPC 划分为子网或子网络,因为这允许我们限制哪些子网可以相互通信以及与公共互联网通信。首先,我们将 VPC 划分为 CIDR 块 10.0.0.0/24 中的公共子网和 10.0.1.0/24 中的私有子网。我选择这些块,因为它们非常清楚地表明以 10.0.0 开头的任何 IP 地址将是公共的,而 10.0.1 将是私有的。

由于 AWS 区域被划分为可用区,我们将为每个区域创建一个公共子网和一个私有子网,总共可达四个子网。四个是最佳选择,因为它由 2 位表示,因此使得 CIDR 范围更容易表达。因此,这些子网的子网掩码为 26,因为它等于 24 加上所需的 2 位。这是通过在 infrastructure/aws_network.tf 中添加以下内容来完成的:

data "aws_availability_zones" "available" {}

resource "aws_subnet" "public" {
  availability_zone = data.aws_availability_zones.available.names[count.index]
  cidr_block        = "10.0.0.${64 * count.index}/26"
  count             = min(4, length(data.aws_availability_zones.available.names))
  vpc_id            = aws_vpc.vpc.id
}

resource "aws_subnet" "private" {
  availability_zone = data.aws_availability_zones.available.names[count.index]
  cidr_block        = "10.0.1.${64 * count.index}/26"
  count             = min(4, length(data.aws_availability_zones.available.names))
  vpc_id            = aws_vpc.vpc.id
}

可用区

AWS 区域被划分为多个(通常为三个)可用区(通常称为 AZs)。每个区域都是一个与其他区域物理隔离的数据中心,这样如果某个区域发生故障(例如,火灾),就不会影响到其他区域。因此,将我们的系统放置在多个区域中,可以提供更强的容错能力。

如其 公共 名称所暗示的,我们希望公共子网中的系统能够与互联网进行通信。这意味着我们需要向 VPC 添加一个互联网网关,并允许网络流量在它和公共子网之间路由。这通过在 infrastructure/aws_network.tf 中添加以下内容来完成:

resource "aws_internet_gateway" "internet_gateway" {
  vpc_id = aws_vpc.vpc.id
}
resource "aws_route_table" "public" {
  vpc_id = aws_vpc.vpc.id

  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.internet_gateway.id
  }
}

resource "aws_route_table_association" "public_gateway" {
  count          = length(aws_subnet.public)
  subnet_id      = aws_subnet.public[count.index].id
  route_table_id = aws_route_table.public.id
}

最后,在网络的方面,我们需要一个负载均衡器来接受来自互联网的连接并将它们路由到应用容器。首先,让我们为负载均衡器添加一个安全组,允许在端口 80443 上进行入站(入站)连接以及任何出站(出站)连接;我们在 infrastructure/aws_network.tf 中这样做:

resource "aws_security_group" "lb" {
  vpc_id = aws_vpc.vpc.id

  ingress {
    protocol    = "tcp"
    from_port   = 80
    to_port     = 80
    cidr_blocks = ["0.0.0.0/0"]
  }

  ingress {
    protocol    = "tcp"
    from_port   = 443
    to_port     = 443
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    protocol    = "-1"
    from_port   = 0
    to_port     = 0
    cidr_blocks = ["0.0.0.0/0"]
  }
}

协议和端口

默认情况下,网站使用 TCP(协议)在端口 80 上为 HTTP 请求提供服务,在端口 443 上为 HTTPS 请求提供服务。端口可以更改,但这样做并不推荐,因为大多数用户不会理解如何在他们的浏览器中进行匹配更改。

HTTP 的下一个版本,HTTP/3,将使用 UDP 作为协议,服务器定义的任何端口都可能被使用。然而,这项技术目前还处于起步阶段,因此不会在这本书中使用。

现在可以通过在 infrastructure/aws_network.tf 中添加以下内容来添加负载均衡器本身:

resource "aws_lb" "tozo" {
  name               = "alb"
  subnets            = aws_subnet.public.*.id
  load_balancer_type = "application"
  security_groups    = [aws_security_group.lb.id]
}

resource "aws_lb_target_group" "tozo" {
  port        = 8080
  protocol    = "HTTP"
  vpc_id      = aws_vpc.vpc.id
  target_type = "ip"

  health_check {
    path = "/control/ping/"
  }
  lifecycle {
    create_before_destroy = true
  }

  stickiness {
    enabled = true
    type    = "lb_cookie"
  }
}

负载均衡

负载均衡器将尝试在目标组中分配请求,以平衡目标组中每个目标所承受的负载。因此,可以使用多台机器来服务单个负载均衡器后面的请求。

在负载均衡器就位并准备就绪后,我们现在可以开始向网络中添加系统,从数据库开始。

添加数据库

现在我们可以将 PostgreSQL 数据库添加到私有子网中,然后通过安全组,我们可以确保数据库只能与公共子网中的系统通信。这使得攻击者更难直接访问数据库。因此,为了做到这一点,以下内容应添加到 infrastructure/aws_network.tf

resource "aws_db_subnet_group" "default" {
  subnet_ids = aws_subnet.private.*.id
}

resource "aws_security_group" "database" {
  vpc_id = aws_vpc.vpc.id

  ingress {
    from_port   = 5432
    to_port     = 5432
    protocol    = "TCP"
    cidr_blocks = aws_subnet.public.*.cidr_block
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = aws_subnet.public.*.cidr_block
  }
}

数据库本身是通过 aws_db_instance Terraform 资源创建的,这需要定义相当多的配置变量。以下代码给出的是一组安全的变量,用于在 AWS 免费层上运行数据库。以下内容应添加到 infrastructure/aws.tf

variable "db_password" {
  sensitive = true
} 
resource "aws_db_instance" "tozo" {
  apply_immediately       = true
  allocated_storage       = 20
  backup_retention_period = 5
  db_subnet_group_name    = aws_db_subnet_group.default.name
  deletion_protection     = true
  engine                  = "postgres"
  engine_version          = "14"
  instance_class          = "db.t3.micro"
  db_name                 = "tozo"
  username                = "tozo"
  password                = var.db_password
  vpc_security_group_ids  = [aws_security_group.database.id]
}

db_password 应该添加到 infrastructure/secrets.auto.tfvars 中,其值最好由密码生成器在非常强的设置下创建(这个密码永远不需要记住或输入)。

随着你的应用程序使用量的增长,我建议你将 instance_class 的值更改为更大的机器,启用 multi_az 以确保在可用区故障情况下的鲁棒性,并启用 storage_encrypted

AWS 网络界面

在这本书中,我们故意将所有基础设施定义为代码,并忽略 AWS 网络界面。这样做最好,因为它确保我们可以始终通过运行 terraform apply 来恢复基础设施到已知的工作状态,并且这意味着我们有一个可审计的变更历史。然而,使用网络界面检查基础设施并检查一切是否如预期仍然非常有用。

在运行 terraform apply 之后,你应该看到 RDS 中正在运行数据库,这意味着我们可以创建一个集群来运行应用程序。

运行集群

我们将使用 ECS 集群来运行我们的 Docker 镜像,并且我们还将使用 Fargate 运行 ECS 集群,这意味着我们不需要管理服务器或集群本身。虽然 Fargate 不属于 AWS 免费层并且会花费更多,但避免自己管理这些事情是值得的。

在我们可以设置 ECS 之前,我们首先需要一个存储 Docker 镜像的仓库,以及 ECS 从中拉取和运行镜像的地方。我们可以通过在 infrastructure/aws_cluster.tf 中添加以下内容来使用 弹性容器注册ECR):

resource "aws_ecr_repository" "tozo" {
  name = "tozo"
}

resource "aws_ecr_lifecycle_policy" "tozo" {
  repository = aws_ecr_repository.tozo.name

  policy = jsonencode({
    rules = [
      {
        rulePriority = 1
        description  = "Keep prod and latest tagged images"
        selection = {
          tagStatus     = "tagged"
          tagPrefixList = ["prod", "latest"]
          countType     = "imageCountMoreThan"
          countNumber   = 9999
        }
        action = {
          type = "expire"
        }
      },
      {
        rulePriority = 2
        description  = "Expire images older than 7 days"
        selection = {
          tagStatus   = "any"
          countType   = "sinceImagePushed"
          countUnit   = "days"
          countNumber = 7
        }
        action = {
          type = "expire"
        }
      }
    ]
  })
}

除了创建仓库本身,这还确保了旧镜像被删除,这对于随着时间的推移降低存储成本至关重要。标记为 prod 的镜像被保留,因为这些镜像应用于应该运行的镜像(Docker 会将 latest 添加到最近构建的镜像)。

Docker 镜像标记

当构建 Docker 镜像时,可以给它添加标签以识别它。默认情况下,它将被标记为 latest,直到构建了新的镜像并取走该标签。因此,最好以有用的方式标记镜像,以便知道它们代表什么。

我们现在可以创建 ECS 集群,这需要一个任务定义,然后是一个在集群中运行任务的服务。从任务开始,我们需要一个 IAM 角色来执行,我们将称之为 ecs_task_execution,以及一个 IAM 角色让任务存在,我们将称之为 ecs_task。这些是通过在 infrastructure/aws_cluster.tf 中添加以下内容来创建的:

resource "aws_iam_role" "ecs_task_execution" {
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = "sts:AssumeRole"
        Principal = {
          Service = "ecs-tasks.amazonaws.com"
        }
        Effect = "Allow"
        Sid    = ""
      }
    ]
  })
}
resource "aws_iam_role_policy_attachment" "ecs-task" { 
  role       = aws_iam_role.ecs_task_execution.name 
  policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy" 
}
resource "aws_iam_role" "ecs_task" {
  assume_role_policy = jsonencode({ 
    Version = "2012-10-17"
    Statement = [
      {
        Action = "sts:AssumeRole"
        Principal = {
          Service = "ecs-tasks.amazonaws.com"
        }
        Effect = "Allow"
        Sid    = ""
      }
    ]
  })
} 

策略附加用于将现有的执行策略附加到 IAM 角色上。

在创建了角色之后,我们现在可以定义 ECS 任务本身。这需要包括所有在生产环境中正确运行代码所需的环境变量。因此,应该以与 db_password 相同的方式创建一个 app_secret_key 变量,并将其首先添加到 infrastructure/secrets.auto.tfvars 文件中。然后,以下内容可以添加到 infrastructure/aws_cluster.tf

variable "app_secret_key" { 
  sensitive = true 
} 
resource "aws_ecs_task_definition" "tozo" {
  family                   = "app"
  network_mode             = "awsvpc"
  requires_compatibilities = ["FARGATE"]
  cpu                      = 256
  memory                   = 512
  execution_role_arn       = aws_iam_role.ecs_task_execution.arn
  task_role_arn            = aws_iam_role.ecs_task.arn
  container_definitions = jsonencode([{
    name      = "tozo"
    image     = "${aws_ecr_repository.tozo.repository_      url}:latest"
    essential = true
    environment = [
      {
        name  = "TOZO_BASE_URL"
        value = "https://tozo.dev"
      },
      {
        name  = "TOZO_SECRET_KEY"
        value = var.app_secret_key
      },
      {
        name  = "TOZO_QUART_DB_DATABASE_URL"
        value = "postgresql://tozo:${var.db_password}@${aws_db_          instance.tozo.endpoint}/tozo"
      },
      {
        name  = "TOZO_QUART_AUTH_COOKIE_SECURE"
        value = "true"
      },
      {
        name  = "TOZO_QUART_AUTH_COOKIE_SAMESITE"
        value = "Strict"
      }
    ]
    portMappings = [{
      protocol      = "tcp"
      containerPort = 8080
      hostPort      = 8080
    }]
  }])
}

就像数据库一样,随着客户数量的增加和应用的扩展,cpumemory 的值可以增加以满足需求。

我们现在已经创建了服务将运行的任务;然而,在我们可以创建服务之前,我们需要通过在 infrastructure/aws_network.tf 中添加以下内容来允许负载均衡器和运行中的容器(这些容器正在暴露端口 8080)之间的连接:

resource "aws_security_group" "ecs_task" {
  vpc_id = aws_vpc.vpc.id

  ingress {
    protocol        = "tcp"
    from_port       = 8080
    to_port         = 8080
    security_groups = [aws_security_group.lb.id]
  }

  egress {
    protocol    = "-1"
    from_port   = 0
    to_port     = 0
    cidr_blocks = ["0.0.0.0/0"]
  }
}

这最终允许通过在 infrastructure/aws_cluster.tf 中使用以下代码来定义服务和集群:

resource "aws_ecs_cluster" "production" {
  name = "production"
}
resource "aws_ecs_service" "tozo" {
  name            = "tozo"
  cluster         = aws_ecs_cluster.production.id
  task_definition = aws_ecs_task_definition.tozo.arn
  desired_count   = 1
  launch_type     = "FARGATE"

  network_configuration {
    security_groups  = [aws_security_group.ecs_task.id]
    subnets          = aws_subnet.public.*.id
    assign_public_ip = true
  } 
  load_balancer {
    target_group_arn = aws_lb_target_group.tozo.arn
    container_name   = "tozo"
    container_port   = 8080
  } 
  lifecycle {
    ignore_changes = [task_definition, desired_count]
  }
}

desired_count 指的是运行中的容器数量,应该随着你的应用处理更多请求而增加;至少三个意味着有容器在不同的可用区运行,因此更健壮。

自动扩展

随着你的应用流量增长,你可以通过分配更大的机器和增加 desired_count 来扩展基础设施。你应该能够通过这种方式扩展到非常高的流量(并且当你这样做的时候,恭喜你)。然而,如果你的流量是周期性的(例如,白天比晚上有更多的流量),那么使用自动扩展可以节省成本。自动扩展是指随着流量的增加自动分配更多资源。

现在我们有了准备就绪的集群;我们现在需要的只是将 Docker 镜像构建并放置到仓库中。

添加持续部署

一切准备就绪后,我们现在可以通过构建容器镜像,将其上传到 ECR 仓库,并通知 ECS 部署新镜像来部署更改。这是在 GitHub 仓库的主要分支发生更改时最好执行的操作。我们可以使用 GitHub action 来完成这项工作,就像在 第一章使用 GitHub 采纳协作开发流程 部分中一样,设置我们的开发系统。

首先,我们需要创建一个 IAM 用户,该用户有权将 Docker 镜像推送到 ECR 仓库,并通知 ECS 部署新镜像。此用户还需要一个访问密钥,因为我们将会使用它来验证 pushdeploy 命令。以下代码创建此用户,并应放置在 infrastructure/aws.tf 文件中:

resource "aws_iam_user" "cd_bot" {
  name = "cd-bot"
  path = "/"
}

resource "aws_iam_user_policy" "cd_bot" {
  name = "cd-bot-policy"
  user = aws_iam_user.cd_bot.name

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action   = "ecr:*"
        Effect   = "Allow"
        Resource = aws_ecr_repository.tozo.arn
      },
      {
        Action   = "ecr:GetAuthorizationToken"
        Effect   = "Allow"
        Resource = "*"
      },
      {
        Action   = "ecs:UpdateService"
        Effect   = "Allow"
        Resource = aws_ecs_service.tozo.id
      }
    ]
  })
}

resource "aws_iam_access_key" "cd_bot" {
  user = aws_iam_user.cd_bot.name
}

由于持续部署将以 GitHub 动作的形式运行,我们需要将此访问密钥和仓库 URL 作为 github_actions_secret 可用;这通过在 infrastructure/github.tf 文件中添加以下内容来完成:

resource "github_actions_secret" "debt_aws_access_key" {
  repository      = github_repository.tozo.name
  secret_name     = "AWS_ACCESS_KEY_ID"
  plaintext_value = aws_iam_access_key.cd_bot.id
}

resource "github_actions_secret" "debt_aws_secret_key" {
  repository      = github_repository.tozo.name
  secret_name     = "AWS_SECRET_ACCESS_KEY"
  plaintext_value = aws_iam_access_key.cd_bot.secret
}

resource "github_actions_secret" "debt_aws_repository_url" {
  repository      = github_repository.tozo.name
  secret_name     = "AWS_REPOSITORY_URL"
  plaintext_value = aws_ecr_repository.tozo.repository_url
}

这些秘密现在可以在持续部署操作中使用。此操作由两个作业组成:

  • 第一个作业构建 Docker 镜像并将其推送到 ECR 仓库

  • 第二个作业指示 ECS 部署它(通过替换当前运行的镜像)

从第一个作业开始,以下内容应添加到 .github/workflows/cd.yml 文件中:

name: CD

on:
  push:
    branches: [ main ]
  workflow_dispatch:

jobs:
  push:
    runs-on: ubuntu-latest
    env:
      AWS_REPOSITORY_URL: ${{ secrets.AWS_REPOSITORY_URL }}

    steps:
      - uses: actions/checkout@v3

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v1
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{secrets.AWS_SECRET_ACCESS_            KEY}}
          aws-region: eu-west-2

      - name: Login to Amazon ECR
        uses: aws-actions/amazon-ecr-login@v1

      - name: Fetch a cached image
        continue-on-error: true
        run: docker pull $AWS_REPOSITORY_URL:latest
      - name: Build the image
        run: | 
          docker build \
            --cache-from $AWS_REPOSITORY_URL:latest \ 
            -t $AWS_REPOSITORY_URL:latest \
            -t $AWS_REPOSITORY_URL:$GITHUB_SHA .
      - name: Push the images
        run: docker push --all-tags $AWS_REPOSITORY_URL

为了节省构建时间,最后构建的镜像,标记为 latest,会被拉取并用作缓存。构建的镜像随后通过标记提交哈希来识别。

现在,我们可以添加一个 deploy 作业,该作业应指示 ECS 部署为此提交构建的镜像。这是通过向已标记提交哈希的镜像添加 prod 标签,然后通知 ECS 运行它来完成的。这通过在 .github/workflows/cd.yml 文件中添加以下内容来实现:

  deploy:
    needs: push
    runs-on: ubuntu-latest
    env:
      AWS_REPOSITORY_URL: ${{ secrets.AWS_REPOSITORY_URL }} 
    steps:
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v1
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{secrets.AWS_SECRET_ACCESS_KEY}}
          aws-region: eu-west-2

      - name: Inform ECS to deploy a new image
        run: |
          MANIFEST=$(aws ecr batch-get-image --region eu-west-2 --repository-name tozo --image-ids imageTag=$GITHUB_SHA --query 'images[].imageManifest' --output text)
          aws ecr put-image --region eu-west-2 --repository-name tozo --image-tag prod --image-manifest "$MANIFEST" || true
          aws ecs update-service --cluster production --service tozo --region eu-west-2 --force-new-deployment

此作业是幂等的,重新运行它将部署与之关联的特定提交。这意味着可以根据需要重新运行它来回滚部署。

部署问题和回滚

并非每次部署都会顺利,部署失败可能发生在部署过程中或部署之后。如果部署本身失败,ECS 将自动保持之前部署的运行。如果失败发生在部署之后,你可以通过重新运行旧的 deploy 作业回滚到安全的前一个版本。

现在,每当主分支发生更改时,你应该看到该更改会自动在生产环境中生效。此外,如果正在运行的作业存在错误或问题,你可以重新运行旧的 deploy 作业。这是一种非常高效的应用程序开发方式。

尽管我们可以通过 ALB URL 访问应用程序,但我们的用户期望使用一个漂亮的域名,这是我们接下来要关注的。

在域名上提供服务

我们希望有一个易于记忆的域名,以便用户可以找到并识别我们的应用程序,这意味着我们需要从域名注册商那里购买一个。我喜欢使用 Gandi (gandi.net) 或 AWS,因为它们值得信赖,然而,我喜欢将域名与托管提供商分开,以防万一出现问题;因此,我将在这本书中使用 Gandi,并已用它注册了 tozo.dev 在接下来的几年里,如图 6.4 所示:

图 6.4:注册域名的 Gandi 主页

图 6.4:注册域名的 Gandi 主页

域名注册商将允许指定域名相关的 DNS 记录;要在 Gandi 中这样做,我们需要通过在infrastructure/main.tf中添加以下高亮代码将gandi提供程序添加到terraform

terraform {
  required_providers {
    gandi = {
      source = "go-gandi/gandi"
      version = "~> 2.0.0"
    }
  }
}

DNS

虽然域名对人类来说容易记忆,但浏览器需要相应的 IP 地址才能发出请求。这就是 DNS 的目的,它将域名解析为正确的 IP 地址。这是浏览器自动完成的,但如果你想手动尝试,可以使用dig工具(例如,dig tozo.dev)。单个域名将具有多个 DNS 记录。到目前为止,我们已经讨论了包含域名 IPv4 地址的A记录,还有一个用于 IPv6 地址的AAA记录,一个指向另一个域名的AAAA记录的ALIAS记录,一个用于邮件服务器信息的MX记录(我们将在本章的发送生产电子邮件部分使用),一个将子域名别名为另一个域名的CNAME记录,以及其他一些记录。

通过terraform init初始化后,我们可以开始使用terraform apply来执行这些更改。首先,我们需要从 Gandi 获取一个生产 API 密钥,它位于安全部分,如图 6.5 所示:

图 6.5:Gandi 安全部分;注意生产 API 密钥部分

图 6.5:Gandi 安全部分;注意生产 API 密钥部分

API 密钥需要按照以下方式添加到infrastructure/secrets.auto.tfvars(你的密钥将不同于我的abcd示例):

gandi_api_key = "abcd"

然后,使用以下内容在infrastructure/dns.tf中配置gandi提供程序:

variable "gandi_api_key" {
  sensitive = true
}

provider "gandi" {
  key = var.gandi_api_key
}

gandi提供程序现在已设置好,可以用来设置 DNS 记录。我们需要两个记录:一个用于域名的ALIAS记录,一个用于www.tozo.dev子域名的CNAME记录。以下内容应添加到infrastructure/dns.tf

data "gandi_domain" "tozo_dev" {
  name = "tozo.dev"
}

resource "gandi_livedns_record" "tozo_dev_ALIAS" {
  zone   = data.gandi_domain.tozo_dev.id
  name   = "@"
  type   = "ALIAS"
  ttl    = 3600
  values = ["${aws_lb.tozo.dns_name}."]
}
resource "gandi_livedns_record" "tozo_dev_www" {
  zone   = data.gandi_domain.tozo_dev.id
  name   = "www"
  type   = "CNAME"
  ttl    = 3600
  values = ["tozo.dev."]
}

在 DNS 记录就绪后,我们现在可以专注于添加 HTTPS(SSL)。

保护连接

确保用户与应用之间的通信加密是最佳实践;然而,当通信包含敏感信息,如用户的密码时,这变得至关重要。因此,我们将只为我们的应用使用加密通信。

为了保护这个连接,我们可以利用 HTTPS 通过 SSL(或 TLS)来保护,这是广泛支持的并且易于使用。为此,我们需要获得浏览器将识别的加密证书。幸运的是,Let’s Encrypt 将免费为我们颁发证书。Let’s Encrypt 可以通过acme提供程序与 Terraform 一起使用,通过在infrastructure/main.tf中添加以下高亮代码并运行terraform init来激活:

terraform {
  required_providers {
    acme = {
      source  = "vancluever/acme"
      version = "~> 2.0"
    }
  }
}

证书颁发机构

要启用 HTTPS,我们可以创建自己的自签名证书;这会起作用,但浏览器会显示警告。此警告将表明浏览器不信任所提供的证书属于该域名。为了避免此警告,我们需要一个公认的证书授权机构来签署我们的证书。为此,证书授权机构必须确认域名的所有者就是请求证书的人。有许多其他证书授权机构会为此服务收费,但 Let’s Encrypt 是免费的!

要为域名获取证书,我们需要向 Let’s Encrypt 证明我们控制该域名。我们可以通过添加以下内容到infrastructure/certs.tf中的acme提供程序来完成此操作:

provider "acme" {
  server_url = "https://acme-v02.api.letsencrypt.org/directory"
}

resource "tls_private_key" "private_key" {
  algorithm = "RSA"
}

resource "acme_registration" "me" {
  account_key_pem = tls_private_key.private_key.private_key_pem
  email_address   = "pgjones@tozo.dev"
}

resource "acme_certificate" "tozo_dev" {
  account_key_pem = acme_registration.me.account_key_pem
  common_name     = "tozo.dev"

  dns_challenge {
    provider = "gandiv5"

    config = {
      GANDIV5_API_KEY = var.gandi_api_key
    }
  }
}
resource "aws_acm_certificate" "tozo_dev" {
  private_key       = acme_certificate.tozo_dev.private_key_pem
  certificate_body  = acme_certificate.tozo_dev.certificate_pem
  certificate_chain = "${acme_certificate.tozo_dev.certificate_pem}${acme_certificate.tozo_dev.issuer_pem}"

  lifecycle {
    create_before_destroy = true
  }
}

记得更改电子邮件地址,以便来自 Let’s Encrypt 的提醒和更新发送到您而不是我的电子邮件!

我们刚刚创建的证书现在可以添加到 ALB,这样做将使用户能够通过 HTTPS 连接到 ALB,从而访问我们的应用。为确保仅使用 HTTPS,让我们通过添加以下内容到infrastructure/aws_network.tf来将任何通过 HTTP(端口80)连接的访客重定向到 HTTPS(端口443):

resource "aws_lb_listener" "http" {
  load_balancer_arn = aws_lb.tozo.arn
  port              = "80"
  protocol          = "HTTP"

  default_action {
    type = "redirect"

    redirect {
      port        = "443"
      protocol    = "HTTPS"
      status_code = "HTTP_301"
    }
  }
}

然后,我们可以通过在infrastruture/aws_network.tf中添加以下代码来接受 HTTPS 连接并将它们转发到包含我们运行中的应用程序的目标组:

resource "aws_lb_listener" "https" {
  load_balancer_arn = aws_lb.tozo.arn
  port              = 443
  protocol          = "HTTPS"
  ssl_policy        = "ELBSecurityPolicy-2016-08"
  certificate_arn   = aws_acm_certificate.tozo_dev.arn

  default_action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.tozo.arn
  }
}

这些更改完成后,您可以运行以下命令:

terraform init
terraform apply

这应该会创建所有基础设施。然后,您需要将本地代码推送到 GitHub 仓库,以便 CD 作业运行并部署应用。一旦完成,您应该能够访问tozo.dev(或您的域名)并看到运行中的应用程序。现在,我们可以专注于如何向应用的用户发送邮件,例如欢迎邮件。

发送生产邮件

第二章**“使用 Quart 创建可重用后端”发送邮件部分中,我们配置了我们的应用,如果存在POSTMARK_TOKEN配置值,则通过 Postmark 发送邮件。现在我们可以设置生产环境,以便在应用的配置中存在POSTMARK_TOKEN

要这样做,我们首先需要 Postmark 的批准;这是为了确保我们无意滥用他们的服务。由于我们使用 Postmark 进行事务性邮件(例如,密码重置令牌),我们应该获得许可。这是通过请求批准按钮或直接与他们支持团队交谈来获得的。

在获得许可后,我们可以添加相关的 DNS 记录以证明我们控制tozo.dev域名。这些记录可以从您的 Postmark 账户获取,并应按照以下方式添加到infrastructure/dns.tf

resource "gandi_livedns_record" "tozo_dev_DKIM" {
  zone   = data.gandi_domain.tozo_dev.id
  name   = "20210807103031pm._domainkey"
  type   = "TXT"
  ttl    = 10800
  values = ["k=rsa;p=abcd"]
}

resource "gandi_livedns_record" "tozo_dev_CNAME" {
  zone   = data.gandi_domain.tozo_dev.id
  name   = "pm-bounces"
  type   = "CNAME"
  ttl    = 10800
  values = ["pm.mtasv.net."]
}

注意突出显示的abcd DKIM 值是一个占位符,应替换为您自己的值。

我们需要的 Postmark 令牌也存在于您的账户中,并应添加到infrastructure/secrets.auto.tfvars(您的密钥将不同于我的abcd示例):

postmark_token = "abcd"

为了使此令牌可用于我们的应用程序,我们需要将其作为运行容器中的环境变量。这是通过在现有的 infrastructure/aws_cluster.tf 中的 aws_ecs_task_definition 部分添加以下内容来实现的:

variable "postmark_token" {
  sensitive = true
}
resource "aws_ecs_task_definition" "tozo" {
  container_definitions = jsonencode([{
    environment = [
      {
        name  = "TOZO_POSTMARK_TOKEN"
        value = var.postmark_token
      }
    ]
  }])
}

应该将突出显示的行添加到文件中。请注意,环境变量名称是 TOZO_POSTMARK_TOKEN,因为只有以 TOZO_ 前缀的环境变量被加载到应用程序的配置中。请参阅 第二章**,使用 Quart 创建可重用的后端

我们的应用程序现在应该使用 Postmark 发送欢迎、重置密码和其他电子邮件。我们可以通过登录 Postmark 并检查活动来监控这一点。接下来,我们可以专注于监控应用程序本身。

监控生产环境

现在我们的应用程序正在生产环境中运行,我们需要确保它正常运行。这意味着我们需要监控问题,特别是错误和缓慢的性能,因为它们都会导致糟糕的用户体验。为了做到这一点,我发现使用 Sentry (sentry.io) 最容易,它可以监控前端和后端代码中的错误和性能。

监控后端

为了监控后端,我们应该在 Sentry 中创建一个新的项目并命名为 backend。在这里,我们将看到任何错误并可以监控性能。该项目将有自己的 数据源名称DSN)值,我们需要在生产环境中将其提供给应用程序。DSN 可以在项目的配置页面上找到,网址为 sentry.io

为了使 DSN 可用于我们的应用程序,我们需要将其作为运行容器中的环境变量。这是通过在现有的 infrastructure/aws_cluster.tf 中的 aws_ecs_task_definition 部分添加以下内容来实现的:

resource "aws_ecs_task_definition" "tozo" {
  container_definitions = jsonencode([{
    environment = [
      {
        name  = "SENTRY_DSN"
        value = "https://examplePublicKey@o0.ingest.sentry.io/0"
      }
    ]
  }])
}

突出的值将因您的设置而异,因为这里使用的值是 Sentry 的示例 DSN。

我们接下来需要在 backend 文件夹中运行以下命令来安装 sentry-sdk

pdm add sentry-sdk

这允许我们通过 Sentry 的 QuartIntegration 激活 Sentry 对 Quart 的监控;我们可以通过将以下内容添加到 backend/src/backend/run.py 来实现这一点:

import sentry_sdk
from sentry_sdk.integrations.quart import QuartIntegration
if "SENTRY_DSN" in os.environ:
    sentry_sdk.init(
        dsn=os.environ["SENTRY_DSN"],
        integrations=[QuartIntegration()],
        traces_sample_rate=0.2,
    )
app = Quart(__name__)

如前代码所示,sentry_sdk.init 应该在 app = Quart(__name__) 之前。

预期性能

按照惯例,如果一个动作需要超过 100 毫秒来返回响应,用户会注意到速度变慢,并有一个糟糕的体验。因此,我的目标是让路由在 40 毫秒内完成,因为这为网络传输和任何 UI 更新提供了时间,以确保在 100 毫秒的目标内完成。不过,有一个例外,那就是任何对密码进行散列的路由应该超过 100 毫秒——否则,散列太弱,容易破解。

这就是我们监控后端所需的所有内容,因此现在我们可以对前端进行相同的操作。

监控前端

要监控前端,我们首先需要在 Sentry 中创建一个 frontend 项目。接下来,我们需要在 frontend 文件夹中运行以下命令来安装 Sentry SDK:

npm install @sentry/react @sentry/tracing

这允许我们通过在 frontend/src/index.tsx 中添加以下代码来激活 Sentry 的浏览器集成:

import * as Sentry from "@sentry/react";
import { BrowserTracing } from "@sentry/tracing";
if (process.env.NODE_ENV === "production") {
  Sentry.init({
    dsn: "https://examplePublicKey@o0.ingest.sentry.io/0",
    integrations: [new BrowserTracing()],
    tracesSampleRate: 0.2,
  });
}

提供的突出显示的 DSN 值是一个示例,您的 DSN 值可在 sentry.io 的项目设置中找到。由于此值不敏感,我们可以将其直接放置在前端代码中。

为了正确工作,Sentry.init 必须在以下内容之前:

const root = ReactDOM.createRoot(
  document.getElementById("root") as HTMLElement,
);

这样,我们就可以监控前端了。接下来,当发生错误时,我们可以向用户展示一个友好的错误页面。

显示错误页面

尽管我们尽了最大努力,但用户在使用应用时很可能会遇到错误和 bug。当这种情况发生时,我们应该向用户展示一个有用的错误页面,承认问题并鼓励用户再次尝试,如图 图 6.6 所示:

图 6.6:错误页面

图 6.6:错误页面

此页面是通过在 frontend/src/pages/Error.tsx 中添加以下代码实现的:

import Alert from "@mui/material/Alert";
import AlertTitle from "@mui/material/AlertTitle";
import Container from "@mui/material/Container";
import Link from "@mui/material/Link";

const Error = () => (
  <Container maxWidth="sm">
    <Alert severity="error" sx={{ marginTop: 2 }}>
      <AlertTitle>Error</AlertTitle>
        Sorry, something has gone wrong. 
        Please try reloading the page or click{" "}      
        <Link href="/">here</Link>.
    </Alert>
  </Container>
);
export default Error;

错误容忍

根据我的经验,用户对被承认并迅速修复的 bug 非常宽容,不便之处很快就会被忘记。然而,那些未被承认或多次影响用户的 bug 则不会被原谅,并导致用户使用其他应用。这就是为什么监控应用以查找错误并在添加任何新功能之前先修复它们至关重要。

要在发生错误时显示此错误页面,我们可以使用 Sentry 的 ErrorBoundary,通过在 frontend/src/index.tsx 中进行以下更改:

import Error from "src/pages/Error";
root.render(
  <React.StrictMode>
    <Sentry.ErrorBoundary fallback={<Error />}>
      <App />
    </Sentry.ErrorBoundary>
  </React.StrictMode>,
);

为了检查一切设置是否正确并正常工作,我们可以在 frontend/src/Router.tsx 中添加以下代码来创建一个在访问时发生错误的路由:

const ThrowError = () => {throw new Error("Test Error")};

const Router = () => (
  <BrowserRouter>
    ...
    <Routes>
      ...
      <Route
        element={<ThrowError />}
        path="/test-error/"
      />
    </Routes>
  </BrowserRouter>
);

在代码块中,... 代表为了简洁而省略的代码。

现在,任何访问 /test-error/ 的请求都将导致错误并显示错误页面。

在安装了友好的错误页面和 Sentry 之后,我们能够监控错误和性能问题。

摘要

在本章中,我们已经将我们的应用部署到云端,并使用我们自己的易于记忆的域名提供服务,从而允许任何用户使用我们的应用。我们还学习了如何监控它以发现任何问题,因此我们准备好尽快修复 bug。

本章中构建的基础设施可以用于任何需要数据库的容器化应用,并且可以扩展到非常高的负载。

在下一章中,我们将向我们的应用添加一些高级功能,并将其转变为一个渐进式 Web 应用。

第七章:保护并打包应用程序

在上一章中,我们将应用程序部署到tozo.dev,允许用户通过任何设备的浏览器使用我们的 Web 应用程序,并添加了监控,以便我们知道何时出现问题。

在本章中,我们将关注如何保持我们的应用程序安全,无论是从我们使用的代码还是用户使用的认证方法来看。我们还将打包我们的应用程序,以便用户可以通过应用商店使用我们的应用程序。

将应用程序的安全性视为一个持续的过程非常重要,在这个过程中,实践和包必须不断更新和改进。在本章中,我将演示我的包更新管理流程,您可以采用并在此基础上进行改进。我们还将采用当前的最佳实践来保护应用程序。

我们还将进行一项重大更改以支持多因素认证。虽然这将使用户能够选择更高的安全性,但它也将展示如何对应用程序进行大规模的更改;具体来说,它将展示如何通过迁移来更改数据库。

最后,通过打包我们的应用程序,我们可以让我们的用户在应用商店中找到我们的应用程序,并像使用他们手机上的任何其他应用程序一样使用它。

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

  • 保护应用程序

  • 更新包

  • 添加多因素认证

  • 转换为渐进式 Web 应用程序

技术要求

要使用配套仓库github.com/pgjones/tozo跟踪本章的开发,请查看r1-ch7-startr1-ch7-end标签之间的提交。

保护应用程序

我们迄今为止所做的大部分工作都使用了安全默认设置(例如,在第二章**,使用 Quart 创建可重用后端)中使用的 Strict SameSite 设置),然而,总有更多可以做的事情来保护应用程序。具体来说,我们可以利用安全头来限制浏览器允许页面执行的操作,进一步防止账户枚举,并限制可以注册的账户以减少垃圾邮件。现在让我们来看看这些安全选项。

添加安全头信息

为了进一步保护我们的应用程序,我们可以利用额外的安全头来限制浏览器允许应用程序执行的操作。这些头应该添加到应用程序发送的每个响应中;我们可以通过在backend/src/backend/run.py中添加以下内容来实现:

from quart import Response
from werkzeug.http import COOP 
@app.after_request
async def add_headers(response: Response) -> Response:
    response.content_security_policy.default_src = "'self'"
    response.content_security_policy.connect_src = "'self' *.sentry.io"
    response.content_security_policy.frame_ancestors = "'none'"
    response.content_security_policy.report_uri = "https://ingest.sentry.io"        
    response.content_security_policy.style_src = "'self' 'unsafe-inline'"
    response.cross_origin_opener_policy = COOP.SAME_ORIGIN
    response.headers["Referrer-Policy"] = "no-referrer, strict-origin-when-cross-origin"
    response.headers["X-Content-Type-Options"] = "nosniff"
    response.headers["X-Frame-Options"] = "SAMEORIGIN"
    response.headers[
        "Strict-Transport-Security"
    ] = "max-age=63072000; includeSubDomains; preload"
    return response

高亮显示的report_uri“https://ingest.sentry.io”是一个占位符,您使用的正确值可以在后端项目的 Sentry 仪表板的 CSP 部分找到。

添加的安全头信息如下:

  • Content-Security-Policy (CSP): 这用于限制内容如何与页面和其他域名交互。如使用所示,它限制了内容,使其必须由我们的域名(称为self)提供,除了任何可以内联添加的样式内容(称为unsafe-inline),这是为了正确工作所需的 MUI。设置还允许连接到sentry.io,以便我们的监控可以工作。最后,它有一个报告 URI,以便我们可以监控 CSP 本身的任何错误。

  • Cross-Origin-Opener-Policy (COOP): 这将我们的应用与其他域名(来源)隔离开。

  • Referrer-Policy: 这限制了浏览器在跟随链接时如何填充Referer头,并用于保护用户的隐私。

  • X-Content-Type-Options: 这确保了浏览器尊重我们从服务器返回的content类型。

  • X-Frame-Options: 这提高了对点击劫持的保护,并确保我们的应用仅在我们自己的域名上显示。

  • Strict-Transport-Security: 这通知浏览器所有后续连接到我们的应用都必须通过 HTTPS 进行。

OWASP

网络应用程序安全最佳实践的权威来源是OWASP基金会,您可以在owasp.org找到它。本书中的头部推荐基于他们的建议。

在安全头设置到位后,我们可以更详细地了解我们如何登录用户,同时保护免受账户枚举攻击。

保护免受账户枚举攻击

账户枚举是指攻击者试图了解哪些电子邮件地址被用作注册账户。通过这样做,攻击者可以了解谁使用敏感的应用程序(例如,约会应用),并且可以了解他们可以尝试强制访问哪些账户。保护免受这种攻击需要妥协用户体验,正如我们在第五章“添加用户认证页面”部分中讨论的那样,在第三章“构建单页应用程序”中,关于注册时的自动登录。

在这本书中,我们将采用最安全的做法,这意味着我们需要重新审视在第三章“构建 API”部分中实现的登录功能,因为它容易受到账户枚举攻击。

登录功能中的弱点在于代码仅当提供的电子邮件属于注册成员时才检查密码散列。这意味着对于属于注册成员的电子邮件,该路由响应时间明显更长,而对于不属于的电子邮件则更快;这允许攻击者通过响应时间来判断电子邮件是否已注册。因此,缓解措施是始终检查密码散列,通过将backend/src/backend/blueprints/sessions.py中的路由更改为以下内容:

REFERENCE_HASH = "$2b$12$A.BRD7hCbGciBiqNRTqxZ.odBxGo.XmRmgN4u9Jq7VUkW9xRmPxK."
@blueprint.post("/sessions/")
@rate_limit(5, timedelta(minutes=1))
@validate_request(LoginData)
async def login(data: LoginData) -> ResponseReturnValue:
    """Login to the app.

    By providing credentials and then saving the     returned cookie.
    """
    result = await select_member_by_email(        g.connection, data.email     )
    password_hash = REFERENCE_HASH
    if result is not None:
        password_hash = result.password_hash 
    passwords_match = bcrypt.checkpw(
        data.password.encode("utf-8"),
        password_hash.encode("utf-8"),
    )
    if passwords_match and result is not None:
        login_user(AuthUser(str(result.id)), data.remember)
        return {}, 200
    else:
        raise APIError(401, "INVALID_CREDENTIALS")

REFERENCE_HASH被设置为一个非常长的随机字符字符串,几乎不可能偶然匹配。

通过增加对账户枚举的保护,我们可以通过添加对垃圾邮件账户的保护来专注于账户本身。

保护免受垃圾邮件账户的侵害

如果您允许用户注册并与应用程序交互,那么不可避免的是,您将会有一些用户会利用它来向您或其他用户发送垃圾邮件。对此的一个简单初始缓解措施是防止用户使用一次性电子邮件地址(这些是免费的短期电子邮件地址,非常适合垃圾邮件发送者)注册到您的应用程序。幸运的是,disposable-email-domains项目跟踪这些域,并在backend目录中运行以下命令进行安装:

pdm add disposable-email-domains

然后,可以将以下内容添加到*backend/src/backend/blueprints/members.py*register路由的开头:

from disposable_email_domains import blocklist  # type: ignore
async def register(data: MemberData) -> ResponseReturnValue:
    email_domain = data.email.split("@", 1)[1]
    if email_domain in blocklist:
        raise APIError(400, "INVALID_DOMAIN")
    ...

在之前的代码块中,...代表现有的register代码。这将通过返回适当的错误代码来阻止来自被阻止电子邮件域的注册。

现在,我们需要处理在*frontend/src/pages/Register.tsx*中找到的useRegister钩子中的这个错误:

const useRegister = () => { 
      ...
      if (
        error.response?.status === 400 &&
        error.response?.data.code === "WEAK_PASSWORD"
      ) {
        setFieldError("password", "Password is too weak");
      } else if (
        error.response?.status === 400 &&
        error.response?.data.code === "INVALID_DOMAIN"
      ) {
        setFieldError("email", "Invalid email domain");
      } else {
        addToast("Try again", "error");
      }
      ...
}

突出的行是useRegister钩子中的现有代码。将检查添加到现有的if子句作为else if子句(如本片段所示)是很重要的,否则用户可能会收到多个令人困惑的错误信息。

在实践中,保持应用程序的安全就像是一场与攻击者的军备竞赛,我建议您继续遵循 OWASP 并采用最新的指导方针。同样,我们还需要不断更新我们的包,这是我们接下来要关注的重点。

更新包

在 Web 应用程序中,一个非常常见的漏洞来源是有漏洞的依赖包。如果应用程序正在使用一个包的较旧版本,而新版本提供了更高的安全性,这种情况尤其如此。为了减轻这种风险,我们可以定期检查已知的漏洞,并且关键的是,尽可能频繁地更新包。

锁文件的重要性

通过使用 npm 和 PDM,我们正在使用锁文件;这意味着在更改锁文件之前,我们将在任何系统上始终安装相同的包版本。如果没有锁文件,我们很快就会处于不同系统运行不同包版本甚至不同包的状态,这会使诊断错误变得困难,因为它可能依赖于我们未测试的版本。然而,关键的是,这会使我们的应用程序的安全性降低,因为我们无法控制安装的内容。

定期检查漏洞

在我们的应用程序中,我们使用了许多第三方依赖项,每个依赖项都可能使用额外的第三方依赖项。这意味着我们需要检查大量库是否存在漏洞——太多以至于我们无法自己完成!幸运的是,当其他人发现漏洞时,它们会被发布,并且存在工具可以检查安装的版本与已发布的漏洞列表之间的版本。

我们将使用这些工具来检查我们的代码,如果它们发现任何问题,我们可以切换到固定版本。我建议定期自动执行此操作,具体来说,每周通过 GitHub 工作流执行一次。

为了开始,我们可以创建一个工作流,该工作流计划在 UTC 时间周二上午 9 点运行,通过在.github/workflows/audit.yml中添加以下内容来实现:

name: Audit

on: 
  schedule:
    - cron: "0 9 * * 2"
jobs:

周二补丁

周二通常是应用补丁的日子,因为它在一周的开始,几乎总是工作日(假期期间周一可能不是工作日),并且最重要的是,它为周一提供了时间来响应周末的问题,使得周二可以自由处理补丁问题。

为了检查前端代码,我们可以在npm包管理器中内置的npm audit工具。此工具将检查已安装的前端依赖项,并在发现任何不安全的包版本时发出警报。为了按计划运行它,应在.github/workflows/audit.yml中添加以下作业:

  frontend-audit:
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: frontend

    steps:
      - name: Use Node.js
        uses: actions/setup-node@v2
        with:
          node-version: '18'

      - uses: actions/checkout@v3  
      - name: Initialise dependencies
        run: npm ci --cache .npm --prefer-offline
      - name: Audit the dependencies
        run: npm audit

现在,为了检查后端代码,我们可以在后端目录中运行以下命令来使用pip-audit

pdm add --dev pip-audit

我们将添加一个pdm脚本来使用pdm run audit来审计代码,就像我们在第一章**,设置我们的开发系统;中的安装后端开发 Python部分所做的那样;因此,将以下内容添加到后端/pyproject.toml中:

[tool.pdm.scripts]
audit = "pip-audit"

在此基础上,我们可以在.github/workflows/audit.yml中添加以下作业:

  backend-audit:
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: backend
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-python@v4
        with:
          python-version: '3.10'

      - name: Initialise dependencies
        run: |
          pip install pdm
          pdm install 
      - name: Audit the dependencies
        run: pdm run audit

如果frontend-auditbackend-audit作业发现存在安全问题的包,此工作流将失败,并发出警报。然而,最好是主动地保持我们的依赖项更新。

一个月度更新系统

为了确保依赖项是最新的,我建议每个月更新所有包。这确保了应用程序永远不会使用超过一个月的依赖项,并且更容易利用依赖项的最新功能。这看起来可能是一项繁重的工作,然而,根据我的经验,一次性完成所有升级比分批进行需要更多的努力。

为了使这个过程更容易,我们必须在前端/package.json后端/pyproject.toml文件中取消固定依赖项。但这并不意味着我们已经取消固定依赖项,因为前端/package-lock.json后端/pdm.lock将完全定义要安装的确切版本。这反而意味着如果需要,我们将允许我们的应用程序与任何库版本一起工作——并且我们将指导它始终使用最新版本。

前端/package.json文件应如下所示:

  "dependencies": {
    "@emotion/react": "*",
    "@emotion/styled": "*",
     ...
  },
  "devDependencies": {
    "@types/zxcvbn": "*",
    "eslint": "*",
    ...
  }

注意,现在每个依赖项都没有被固定,*表示允许任何版本。

在做出这些更改后,我们可以在前端目录中运行以下命令来更新前端依赖项:

npm update 

我们也可以在后端目录中运行类似的命令来更新后端依赖项:

pdm update 

最后,为了升级基础设施依赖项,应在基础设施目录中运行以下命令:

terraform init –upgrade

这些更新可能需要做出一些小的更改以支持最新版本。很可能会出现 CI 检查警告,特别是我们一直在使用的类型检查,如果这些更改没有进行。

现在我们有一个系统来保持我们的应用程序更新,我们可以添加多因素认证来帮助我们的用户保护他们的账户。

添加多因素认证

我们的应用程序允许用户通过提供电子邮件和密码来登录。这意味着我们允许他们通过他们知道的东西(即密码)进行身份验证。我们还可以允许他们使用其他身份验证因素,例如使用他们的指纹(即他们是什么),或特定的移动设备(即他们有什么)。要求用户使用多个因素进行身份验证会使攻击者更难访问他们的账户,但这也使得用户进行身份验证变得更加困难。因此,最好允许用户选择加入多因素认证。

用户最熟悉的是将手机作为额外因素使用,我们将使用基于共享密钥的基于时间的单次密码(TOTP)令牌来实现这一点。在用户的手机上,共享密钥是一个额外的因素。使用发送到用户手机的短信消息也很常见;然而,这种方法越来越容易受到攻击,不应被视为安全。

TOTP

TOTP 算法利用共享密钥和当前时间生成一个在一定时间内有效的代码(通常约为 60 秒)。任何两个系统都应该为相同的时间和共享密钥计算出相同的代码,因此用户可以提供一个代码,我们的应用程序应该匹配。

基于 TOTP 的多因素认证(MFA)的工作原理是首先与用户共享一个密钥。这通常是通过在我们的应用程序中显示一个二维码来完成的,用户使用身份验证应用程序扫描该二维码。然后,用户的身份验证应用程序将显示一个用户可以在我们的应用程序中输入的代码,以确认已设置 MFA。然后,在随后的任何登录中,用户都需要输入由他们的身份验证应用程序显示的当前代码。

要在我们的应用程序中支持 MFA,我们需要更新数据库和相关模型,添加在后端和前端激活它的功能,然后,最后,在登录时利用 MFA。

更新数据库和模型

要支持 MFA,我们需要为每个成员存储两块信息:

  • 第一项是共享密钥,如果用户尚未激活多因素认证(MFA),则该密钥可以是NULL

  • 第二项是他们最后使用的代码,也可以是NULL。最后使用的代码是必需的,以防止重放攻击,攻击者只需重新发送之前的 MFA 代码。

要添加此信息,我们需要创建一个新的数据库迁移,通过将以下代码添加到backend/src/backend/migrations/1.py

from quart_db import Connection
async def migrate(connection: Connection) -> None:
    await connection.execute(
        "ALTER TABLE members ADD COLUMN totp_secret TEXT"
    )
    await connection.execute(
        "ALTER TABLE members ADD COLUMN last_totp TEXT"
    )
async def valid_migration(connection: Connection) -> bool:
    return True

良好的迁移

数据库迁移必须谨慎编写,因为迁移将在代码访问数据库状态时更改数据库状态。因此,最好编写在允许旧代码继续运行的同时添加功能的迁移。例如,最好不要在一个迁移中删除或重命名列;相反,应该添加一个新列,然后使用一段时间后再删除旧列。

我们还需要更新后端模型以考虑这两个新列,通过将 backend/src/backend/models/member.py 中的 Member 模型更改为以下内容(变更已高亮显示):

@dataclass
class Member:
    id: int
    email: str
    password_hash: str
    created: datetime
    email_verified: datetime | None 
    last_totp: str | None
    totp_secret: str | None

现在,我们还需要更新以下模型函数在 backend/src/backend/models/member.py

async def select_member_by_email(
    db: Connection, email: str
) -> Member | None:
    result = await db.fetch_one(
        """SELECT id, email, password_hash, created,
                  email_verified, last_totp, totp_secret
             FROM members
            WHERE LOWER(email) = LOWER(:email)""",
        {"email": email},
    )
    return None if result is None else Member(**result)
async def select_member_by_id(
    db: Connection, id: int
) -> Member | None:
    result = await db.fetch_one(
        """SELECT id, email, password_hash, created,
                  email_verified, last_totp, totp_secret
             FROM members
            WHERE id = :id""",
        {"id": id},
    )
    return None if result is None else Member(**result)
async def insert_member(
    db: Connection, email: str, password_hash: str
) -> Member:
    result = await db.fetch_one(
        """INSERT INTO members (email, password_hash)
                VALUES (:email, :password_hash)
             RETURNING id, email, password_hash, created,
                       email_verified, last_totp,                       totp_secret""",
        {"email": email, "password_hash": password_hash},
    )
    return Member(**result)

注意,唯一的变更(如高亮所示)是在 SQL 查询中添加新列。

为了能够更改 last_totptotp_secret 列的值,我们需要在 backend/src/backend/models/member.py 中添加以下函数:

async def update_totp_secret(
    db: Connection, id: int, totp_secret: str | None
) -> None:
    await db.execute(
        """UPDATE members
              SET totp_secret = :totp_secret
            WHERE id = :id""",
        {"id": id, "totp_secret": totp_secret},
    )
async def update_last_totp(
    db: Connection, id: int, last_totp: str | None
) -> None:
    await db.execute(
        """UPDATE members
              SET last_totp = :last_totp
            WHERE id = :id""",
        {"id": id, "last_totp": last_totp},
    )

在数据库和后端模型更新后,我们可以添加激活 MFA 的功能。

激活 MFA

要激活 MFA,我们需要一个页面,我们的应用程序中的页面应遵循 图 7.1 中显示的过程:

图 7.1:MFA 激活过程

图 7.1:MFA 激活过程

密钥本身需要在后端生成和管理,我们可以使用 pyotp 库来完成;在 backend 目录中运行以下命令来安装库:

pdm add pyotp

我们现在可以开始添加后端路由,从返回成员 MFA 状态的路由开始。这将要么是 active(MFA 正在使用中),要么是 inactive(MFA 未使用),要么是 partial(成员正在激活 MFA 的过程中);它还需要返回共享密钥。我们将以 URI 的形式返回密钥,我们可以从中生成 QR 码。

此路由的代码如下,并应添加到 backend/src/backend/blueprints/members.py

from typing import Literal
from pyotp.totp import TOTP
from quart_schema import validate_response
@dataclass
class TOTPData:
    state: Literal["ACTIVE", "PARTIAL", "INACTIVE"]
    totp_uri: str | None
@blueprint.get("/members/mfa/")
@rate_limit(10, timedelta(seconds=10))
@login_required
@validate_response(TOTPData)
async def get_mfa_status() -> TOTPData:
    member_id = int(cast(str, current_user.auth_id))
    member = await select_member_by_id(g.connection, member_id)
    assert member is not None  # nosec
    totp_uri = None
    state: Literal["ACTIVE", "PARTIAL", "INACTIVE"]
    if member.totp_secret is None:
        state = "INACTIVE"
    elif (
        member.totp_secret is not None and         member.last_totp is None
    ):
        totp_uri = TOTP(member.totp_secret).provisioning_uri(
            member.email, issuer_name="Tozo"
        )
        state = "PARTIAL"
    else:
        state = "ACTIVE"
    return TOTPData(state=state, totp_uri=totp_uri)

注意,totp_uri 仅在部分状态下返回(已高亮),因为它包含的秘密只有在需要时才应共享。

下一个我们需要的路由是允许成员通过创建共享密钥来启动 MFA。这应该添加到 backend/src/backend/blueprints/members.py

from pyotp import random_base32
from backend.models.member import update_totp_secret
@blueprint.post("/members/mfa/")
@rate_limit(10, timedelta(seconds=10))
@login_required
@validate_response(TOTPData)
async def initiate_mfa() -> TOTPData:
    member_id = int(cast(str, current_user.auth_id))
    member = await select_member_by_id(g.connection, member_id)
    assert member is not None  # nosec
    if member.totp_secret is not None:
        raise APIError(409, "ALREADY_ACTIVE")
    totp_secret = random_base32()
    totp_uri = TOTP(totp_secret).provisioning_uri(
        member.email, issuer_name="Tozo"
    )
    await update_totp_secret(g.connection, member_id, totp_      secret)
    return TOTPData(state="PARTIAL", totp_uri=totp_uri) 

我们需要的最后一个路由是允许用户输入 TOTP 代码以确认设置,这应该添加到 backend/src/backend/blueprints/members.py

from backend.models.member import update_last_totp
@dataclass 
class TOTPToken: 
    token: str
@blueprint.put("/members/mfa/")
@rate_limit(10, timedelta(seconds=10))
@login_required
@validate_request(TOTPToken)
async def confirm_mfa(data: TOTPToken) -> ResponseReturnValue:
    member_id = int(cast(str, current_user.auth_id))
    member = await select_member_by_id(g.connection, member_id)
    assert member is not None  # nosec
    if member.totp_secret is None:
        raise APIError(409, "NOT_ACTIVE")
    totp = TOTP(member.totp_secret)
    if totp.verify(data.token):
        await update_last_totp(g.connection, member_id, data.          token)
        return {}
    else:
        raise APIError(400, "INVALID_TOKEN")

我们现在可以构建前端页面来处理界面,该界面需要显示 QR 码。我们可以通过在 frontend 目录中运行以下命令来安装 qrcode.react 来实现这一点:

npm install qrcode.react

我们需要构建的页面应该看起来像 图 7.2

图 7.2:MFA 设置页面

图 7.2:MFA 设置页面

要构建 MFA 页面,我们首先需要在 frontend/src/components/TotpField.tsx 中添加一个特定字段,让用户输入一次性密码,具体操作如下:

import TextField, { TextFieldProps } from "@mui/material/TextField";
import { FieldHookConfig, useField } from "formik";
import { combineHelperText } from "src/utils";
const TotpField = (props: FieldHookConfig<string> & TextFieldProps) => {
  const [field, meta] = useField<string>(props);
  return (
    <TextField
      {...props}
      autoComplete="one-time-code"
      error={Boolean(meta.error) && meta.touched}
      helperText={combineHelperText(props.helperText, meta)}
      inputProps={{ inputMode: "numeric", maxLength: 6,        pattern: "[0-9]*" }}
      margin="normal"
      type="text"
      {...field}
    />
  );
};
export default TotpField;

在使用TotpField之前,我们需要将激活 MFA 所需的功能添加到 frontend/src/pages/MFA.tsx

import axios from "axios"; 
import { useQueryClient } from "@tanstack/react-query";
import { useMutation } from "src/query";
const useActivateMFA = (): [() => Promise<void>, boolean] => {
  const queryClient = useQueryClient();
  const { mutateAsync: activate, isLoading } = useMutation(
    async () => await axios.post("/members/mfa/"),
    {
      onSuccess: () => queryClient.invalidateQueries(["mfa"]),
    },
  );
  return [
    async () => {
      await activate();
    },
    isLoading,
  ];
};

该突变使mfa查询无效,因为这是我们用于确定用户 MFA 状态的查询的关键。

我们还需要添加一个功能来确认 MFA 激活,这可以添加到 frontend/src/pages/MFA.tsx

import { FormikHelpers } from "formik";
import { useContext } from "react";
import { ToastContext } from "src/ToastContext";
interface IForm {
  token: string;
}
const useConfirmMFA = () => {
  const { addToast } = useContext(ToastContext);
  const queryClient = useQueryClient();
  const { mutateAsync: confirm } = useMutation(
    async (data: IForm) => await axios.put("/members/mfa/", data),
    {
      onSuccess: () => queryClient.invalidateQueries(["mfa"]),
    },
  );
  return async (
    data: IForm, { setFieldError }: FormikHelpers<IForm>
  ) => {
    try {
      await confirm(data);
    } catch (error: any) {
      if (axios.isAxiosError(error) && 
          error.response?.status === 400) {
        setFieldError("token", "Invalid code");
      } else {
        addToast("Try again", "error");
      }
    }
  };
};

在功能到位后,我们可以添加以下 UI 元素,这些元素应添加到 frontend/src/pages/MFA.tsx

import LoadingButton from "@mui/lab/LoadingButton";
import Skeleton from "@mui/material/Skeleton";
import Typography from "@mui/material/Typography";
import { Form, Formik } from "formik";
import { QRCodeSVG } from "qrcode.react";
import * as yup from "yup";
import FormActions from "src/components/FormActions";
import Title from "src/components/Title";
import TotpField from "src/components/TotpField";
import { useQuery } from "src/query";
const validationSchema = yup.object({
  token: yup.string().required("Required"),
}); 
const MFA = () => {
  const { data } = useQuery(["mfa"], async () => {
    const response = await axios.get("/members/mfa/");
    return response.data;
  });
  const [activate, isLoading] = useActivateMFA();
  const onSubmit = useConfirmMFA();
  let content = <Skeleton />;
  if (data?.state === "ACTIVE") {
    content = <Typography variant="body1">MFA Active</Typography>;
  } else if (data?.state === "INACTIVE") {
    content = (
      <LoadingButton loading={isLoading} onClick={activate}>
        Activate
      </LoadingButton>
    );
  } else if (data !== undefined) {
    content = (
      <>
        <QRCodeSVG value={data.totpUri} />
        <Formik<IForm>
          initialValues={{ token: "" }}
          onSubmit={onSubmit}
          validationSchema={validationSchema}
        >
          {({ dirty, isSubmitting }) => (
            <Form>
              <TotpField
                fullWidth={true}
                label="One time code"
                name="token"
                required={true}
              />
              <FormActions
                disabled={!dirty}
                isSubmitting={isSubmitting}
                label="Confirm"
                links={[{ label: "Back", to: "/" }]}
              />
            </Form>
          )}
        </Formik>
      </>
    );
  }
  return (
    <>
      <Title title="Multi-Factor Authentication" />
      {content}
    </>
  );
};
export default MFA; 

显示的 UI 代码取决于 MFA 状态,包括最初在从后端获取 MFA 状态时显示Skeleton的情况。然后显示LoadingButton以激活 MFA,一个二维码和TotpField以确认 MFA 激活,如果 MFA 处于激活状态,则最终显示确认文本。

接下来,需要通过在 frontend/src/Router.tsx 中添加以下内容来将 MFA 页面添加到路由中:

import MFA from "src/pages/MFA";
const Router = () => (
  <BrowserRouter>
   ...
    <Routes>
      ...
      <Route
        path="/mfa/"
        element={<RequireAuth><MFA /></RequireAuth>}
      />
    </Routes>
  </BrowserRouter>
);

在代码块中,...代表为了简洁而省略的代码。

为了使用户能够找到 MFA 页面,我们可以在 frontend/src/components/AccountMenu.tsx 组件中添加以下MenuItem

<MenuItem  
  component={Link}  
  onClick={onMenuClose}  
  to="/mfa/" 
> 
  MFA
</MenuItem>

现在用户可以激活 MFA,我们可以在登录过程中使用它。

使用 MFA 登录

登录过程还必须更改,如果用户已激活 MFA,则要求用户提供一次性密码。为此,后端必须向前端指示,对于已激活 MFA 的用户,需要额外的令牌。以下代码应替换 backend/src/backend/blueprints/sessions.py 中的登录路由:

from pyotp.totp import TOTP
from backend.models.member import update_last_totp
@dataclass
class LoginData:
    email: EmailStr
    password: str
    remember: bool = False
    token: str | None = None
@blueprint.post("/sessions/")
@rate_limit(5, timedelta(minutes=1))
@validate_request(LoginData)
async def login(data: LoginData) -> ResponseReturnValue:
    member = await select_member_by_email(g.connection, data.email)
    password_hash = REFERENCE_HASH
    if member is not None:
        password_hash = member.password_hash 
    passwords_match = bcrypt.checkpw(
        data.password.encode("utf-8"),
        password_hash.encode("utf-8"),
    )
    if passwords_match:
        assert member is not None  # nosec
        if (
            member.totp_secret is not None and 
            member.last_totp is not None
        ):
            if data.token is None:
                raise APIError(400, "TOKEN_REQUIRED")
            totp = TOTP(member.totp_secret)
            if (
                not totp.verify(data.token) or 
                data.token == member.last_totp
            ):
                raise APIError(401, "INVALID_CREDENTIALS")
            await update_last_totp(
                g.connection, member.id, data.token
            )
        login_user(AuthUser(str(member.id)), data.remember)
        return {}, 200
    else:
        raise APIError(401, "INVALID_CREDENTIALS")

如果用户已激活 MFA,但登录数据不包括一次性密码(token),则此代码将返回400错误响应;这允许前端登录页面随后要求用户提供一次性密码并重新尝试登录。此外,如果一次性密码无效,代码将返回401无效凭据消息——注意它检查之前使用的代码以防止重放攻击。

我们现在可以修改现有的登录页面,使其看起来像 图 7.3,适用于已激活 MFA 的账户:

图 7.3:带有额外一次性密码字段的登录页面

图 7.3:带有额外一次性密码字段的登录页面

首先,我们需要修改 frontend/src/pages/Login.tsx 中的useLogin逻辑,如下所示:

import { useState } from "react";
interface IForm {
  email: string;
  password: string;
  token: string;
}
const useLogin = (): [(data: IForm, helpers: FormikHelpers<IForm>) => Promise<void>, boolean] => {
  const [requiresMFA, setRequiresMFA] = useState(false);
  const location = useLocation();
  const navigate = useNavigate();
  const { addToast } = useContext(ToastContext);
  const { setAuthenticated } = useContext(AuthContext);
  const { mutateAsync: login } = useMutation(
    async (data: IForm) => await axios.post("/sessions/",       data),
  );
  return [
    async (data: IForm, { setFieldError }:       FormikHelpers<IForm>)=>{
      const loginData: any = {
        email: data.email,
        password: data.password,
      };
      if (requiresMFA) {
        loginData["token"] = data.token;
      }
      try {
        await login(loginData);
        setAuthenticated(true);
        navigate((location.state as any)?.from ?? "/");
      } catch (error: any) {
        if (error.response?.status === 400) {
          setRequiresMFA(true);
        } else if (error.response?.status === 401) {
          setFieldError("email", "Invalid credentials");
          setFieldError("password", "Invalid credentials");
          setFieldError("token", "Invalid credentials");
        } else {
          addToast("Try again", "error");
        }
      }
    },
    requiresMFA,
  ];
};

useLogin钩子返回登录功能和一个标志,指示是否需要一次性密码。当尝试登录时,此标志被设置,并且后端返回400响应。

我们可以使用useLogin钩子中的标志来在登录表单中显示TotpField,通过在 frontend/src/pages/Login.tsx 中进行以下突出显示的更改:

import TotpField from "src/components/TotpField"; 
const Login = () => {
  const [onSubmit, requiresMFA] = useLogin();
  ...
  return (
    <>
      <Formik<IForm> 
        initialValues={{
          email: (location.state as any)?.email ?? "",
          password: "",
          token: "",
        }}
        onSubmit={onSubmit}
        validationSchema={validationSchema}
      >
        {({ isSubmitting, values }) => (
          <Form>
            {requiresMFA ? (
              <TotpField
                fullWidth={true}
                label="One time code"
                name="token"
                required={true}
              />
            ) : null}
          </Form>
        )}
      </Formik>
    </>
  );
};

这将允许用户输入一次性密码并完成登录。我们现在可以考虑如何处理用户丢失共享密钥的情况。

恢复和停用 MFA

用户不可避免地会丢失共享密钥并需要恢复对账户的访问。这通常是用户在激活多因素认证时获得的恢复代码所完成的。这些恢复代码是存储在后端中的额外一次性密钥,可以一次性使用以恢复访问。虽然这可行,但任何恢复系统都需要考虑您的客户服务将如何以及以何种形式进行,因为用户通常会寻求帮助。

OWASP 为此提供了额外的指导,您可以在以下链接中查看:cheatsheetseries.owasp.org/cheatsheets/Multifactor_Authentication_Cheat_Sheet.xhtml#resetting-mfa

我们已经对我们的应用进行了重大更改,您可以用它作为模板来进一步为您的应用进行重大更改。接下来,我们将通过将其转换为渐进式 Web 应用来打包我们的应用以供应用商店使用。

转换为渐进式 Web 应用

我们可以通过将其转换为渐进式 Web 应用PWA)来使我们的应用更加用户友好,尤其是在移动设备上。PWA 可以像所有其他应用一样安装在手机上,无论是通过应用商店还是直接从浏览器中的提示进行安装。PWA 还可以离线工作并使用其他高级功能,如推送通知。然而,PWA 的开发更为复杂,服务工作者(一个关键特性)可能非常难以正确实现。

服务工作者

服务工作者是作为网页和服务器之间代理的定制 JavaScript 脚本。这允许服务工作者添加离线优先功能,例如缓存页面以提高性能或接受推送通知。

一个 PWA 必须有一个服务工作者和一个清单文件才能工作;这些文件可以通过我们用于第一章、“设置我们的开发系统”的create-react-app工具获得。为此,让我们在临时目录中使用 PWA 模板创建一个新的react应用:

npx create-react-app temp --template cra-template-pwa-typescript

然后,我们可以通过复制以下文件将服务工作者代码从临时项目复制到我们的项目中:

  • temp/src/service-worker.ts复制到frontend/src/service-worker.ts

  • temp/src/serviceWorkerRegistration.ts复制到frontend/src/serviceWorkerRegistration.ts

现在,您可以选择删除temp目录或保留以供参考。

要激活服务工作者,以下内容应添加到frontend/src/index.tsx中以注册服务工作者:

import * as serviceWorkerRegistration from "src/serviceWorkerRegistration"; 
serviceWorkerRegistration.register();

此服务工作者需要从 workbox 工具包([web.dev/workbox/](http://web.dev/workbox/))中依赖大量依赖项才能工作;这些依赖项由 Google 开发,以使服务工作者更容易使用。这些依赖项可以通过在前端目录中运行以下命令来安装:

npm install workbox-background-sync workbox-background-sync workbox-cacheable-response workbox-core workbox-expiration workbox-navigation-preload workbox-precaching workbox-range-requests workbox-routing workbox-strategies workbox-streams

当我们使用 npm run start 前端开发服务器开发应用时,服务工作者不会处于活动状态,因此为了测试它,我们需要通过后端开发服务器本地提供服务。首先,我们必须在 frontend 目录中运行以下命令来构建前端:

npm run build

这将在 frontend/build 目录中创建文件,我们需要将这些文件复制到后端。这需要以下文件移动:

  • 将整个 frontend/build/static 目录复制到 backend/src/backend/static

  • frontend/build/index.xhtml 文件复制到 backend/src/backend/templates/index.xhtml

  • frontend/build 中的剩余文件复制到 backend/src/backend/static 中(例如,将 frontend/build/service-worker.js 复制到 backend/src/backend/static/service-worker.js

剩余的文件也需要包含在 Dockerfile 中,并在现有的 COPY --from=frontend 命令旁边添加以下内容:

COPY --from=frontend /frontend/build/*.js* /app/backend/static/

后端运行时(通过 pdm run start),服务工作者启用的应用可通过 localhost:5050 访问。你可以通过查看 图 7.4 中的开发者工具控制台来检查服务工作者是否工作正常:

图 7.4:浏览器开发者工具中的服务工作者输出

图 7.4:浏览器开发者工具中的服务工作者输出

服务工作者现在将开始缓存内容,并且当后端不运行时,你应该能够刷新应用。

应用图标

浏览器中的网页都有一个与其关联的图标,通常显示在标签页的标题旁边。这个图标被称为 favicon。PWA(渐进式 Web 应用)有额外的图标,用于移动主页屏幕(以及其他地方)上的应用;这些图标在清单文件中定义。

我们现在可以将注意力转向清单文件,它描述了应用及其应关联的标志。一旦你设计好了一个标志,我建议将其保存为 SVG 格式的 favicon,并放置在 frontend/public/favicon.svg 中。由于我们使用 SVG 格式而不是 ICO 格式,以下代码应替换 frontend/public/index.xhtml 中的现有代码(注意文件扩展名):

<link rel="icon" href="%PUBLIC_URL%/favicon.svg" />

然后需要将相同的标志保存为 PNG 格式,作为 192x192 像素的方形文件在 frontend/public/logo192.png 中,以及作为 512x512 像素的方形文件在 frontend/public/logo512.png 中。清单文件应包括以下内容,并将其放置在 frontend/public/manifest.json 中:

{
  "short_name": "Tozo",
  "name": "Tozo todo app",
  "icons": [
    {
      "src": "favicon.svg",
      "sizes": "64x64 32x32 24x24 16x16",
      "type": " image/svg+xml"
    },
    {
      "src": "logo192.png",
      "type": "image/png",
      "sizes": "192x192"
    },
    {
      "src": "logo512.png",
      "type": "image/png",
      "sizes": "512x512"
    }
  ],
  "start_url": ".",
  "display": "standalone",
  "theme_color": "#1976d2",
  "background_color": "#ffffff"
}

与服务工作者一样,我们还需要将标志复制到后端。对于开发,将所有标志复制到 backend/src/backend/static/ 文件夹。对于生产,以下内容应添加到 Dockerfile 中:

COPY --from=frontend /frontend/build/*.png /frontend/build/*.svg /app/backend/static/

我们现在需要从后端提供这些新文件,这可以通过向 backend/src/backend/blueprints/serving.py 中添加以下代码来实现:

from quart import current_app, send_from_directory
@blueprint.get(
    "/<any('service-worker.js', 'service-worker.js.map', 'manifest.json', 'asset-manifest.json', 'favicon.svg', 'logo192.png', 'logo512.png'):path>"  # noqa: E501
)
@rate_exempt
async def resources(path: str) -> ResponseReturnValue:
    assert current_app.static_folder is not None  # nosec
    return await send_from_directory(
        current_app.static_folder, path
    )

在做出这些更改后,我们的应用已成为 PWA,这意味着我们可以为应用商店打包它。这样做最简单的方法是使用pwabuilder.com,它将创建 iOS 和 Android 包。要这样做,请访问pwabuilder.com并输入你的应用域名。然后,它将展示可以上传到 Google Play 商店和 iOS 应用商店的包。

PWA Builder

PWA Builder 是一个由微软指导的项目,旨在通过简化流程来提高 PWA 的采用率。PWA 在 Windows 和 Android 上是第一类应用。

这种方法存在局限性;首先,虽然 PWA 在 Windows 和 Android 系统上是第一类应用,但在苹果的 iOS 系统上支持有限。可能的情况是,你用 PWA Builder 打包的应用在应用商店中不被接受——几乎没有解释为什么。此外,iOS 不支持 PWA 的所有功能;最显著的是,直到 2023 年将不支持推送通知。

在完成 PWA 转换后,我们可以将 PWA 包上传到各个应用商店,使用户能够从商店安装它。有关如何操作的进一步说明请参阅 Android(https://docs.pwabuilder.com/#/builder/android)和 iOS(docs.pwabuilder.com/#/builder/app-store)。

摘要

在本章中,我们已经确保了我们的应用安全,并采用了一个持续更新的流程来保持其安全性。我们还添加了一个主要功能,MFA,它将作为未来对应用进行重大更改的指南。最后,我们已经打包了我们的应用,准备添加到应用商店。

这是一个很好的地方,因为你现在有一个正在生产中运行的 Web 应用的蓝图,它采用了许多行业最佳实践。这是一个你可以根据自己的需求进行调整的蓝图,其中待办事项的具体方面作为指南,我希望我介绍给你的最佳实践和工具能为你带来帮助。

进一步阅读

这还不是终点;你现在可以并且应该做更多的事情来改进你的应用,使其对你的用户更有价值。我建议你添加更多测试以减少错误,特别是通过添加端到端测试。我还建议你使用 Lighthouse、pagespeed.web.dev等工具来识别常见的性能、可访问性和一般 PWA 问题。

posted @ 2025-09-24 13:51  绝不原创的飞龙  阅读(20)  评论(0)    收藏  举报