FastAPI-React-和-MongoDB-全栈开发-全-

FastAPI、React 和 MongoDB 全栈开发(全)

原文:zh.annas-archive.org/md5/bd742fc2af9d0b64a6f94d7b9da9035f

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

全栈 FastAPI、React 和 MongoDB,第二版是一本快速、简洁、实用的入门指南,旨在提升 Web 开发者的潜力,并帮助他们利用 FARM 栈的灵活性、适应性和稳健性,在快速发展的 Web 开发和 AI 领域中保持领先。本书介绍了栈的每个元素,然后解释了如何使它们协同工作以构建中型 Web 应用程序。

本书通过实际操作示例和真实世界的用例,展示了如何使用 MongoDB 设置文档存储,使用 FastAPI 构建简单的 API,以及使用 React 创建应用程序。此外,它深入探讨了使用 Next.js,确保 MongoDB 中的数据完整性和安全性,以及将第三方服务与应用程序集成。

这本书将如何帮助您

这本书采用实际操作的方法,通过使用 FARM 栈的真实世界示例来展示 Web 应用程序开发。到本书结束时,您将能够自信地使用 FARM 栈以快速的速度开发功能齐全的 Web 应用程序。

这本书面向的对象是谁

这本书适合具有基本 JavaScript 和 Python 知识的初级 Web 开发者,他们希望提高自己的开发技能,掌握一个强大且灵活的栈,并更快地编写更好的应用程序。

这本书涵盖了哪些内容

第一章Web 开发与 FARM 栈,通过快速浏览广泛使用的各种技术,为您提供了对 Web 开发领域的深入理解。它介绍了最受欢迎的选项——FARM 栈。它突出了 FARM 栈组件的优势,它们之间的关系,以及为什么这一组特定技术非常适合 Web 应用程序。

第二章使用 MongoDB 设置数据库,提供了 MongoDB 的概述,然后展示了如何为 FARM 应用程序设置数据存储层。它帮助您了解创建、更新和删除文档的基本知识。此外,本章详细介绍了聚合管道框架——一个强大的分析工具。

第三章Python 类型提示和 Pydantic,包括一些示例,教您更多关于 FastAPI 的 Web 特定方面以及如何无缝地在 MongoDB、Python 数据结构和 JSON 之间混合数据。

第四章FastAPI 入门,专注于介绍 FastAPI 框架,以及标准的 REST API 实践及其在 FastAPI 中的实现方式。它涵盖了 FastAPI 实现最常见 REST API 任务的一些非常简单的示例,以及它如何通过利用现代 Python 功能和库(如 Pydantic)来帮助您。

第五章设置 React 工作流程,展示了如何使用 React 框架设计一个由几个组件组成的应用程序。它讨论了探索 React 及其各种功能所需的工具。

第六章身份验证和授权,详细介绍了基于 JSON Web TokensJWTs)的简单、健壮且可扩展的 FastAPI 后端配置。它展示了如何将基于 JWT 的身份验证方法集成到 React 中,利用 React 的强大功能——特别是 Hooks、Context 和 React Router。

第七章使用 FastAPI 构建 Backend,帮助您处理一个简单的业务需求并将其转化为一个完全功能、部署在互联网上的 API。它展示了如何定义 Pydantic 模型、执行 CRUD 操作、构建 FastAPI 后端并连接到 MongoDB。

第八章构建应用程序的前端,说明了构建全栈 FARM 应用程序前端的步骤。它展示了如何使用现代 Vite 设置创建 React 应用程序并实现基本功能。

第九章使用 FastAPI 和 Beanie 集成第三方服务,介绍了 Beanie,这是一个基于 Motor 和 Pydantic 的流行 ODM 库,用于 MongoDB。它展示了如何定义模型和映射到 MongoDB 集合的 Beanie 文档。您将看到如何构建另一个 FastAPI 应用程序,并使用后台任务集成第三方服务。

第十章使用 Next.js 14 进行 Web 开发,对重要的 Next.js 概念进行了概述,例如服务器操作、表单处理和 Cookie,以帮助创建新的 Next.js 项目。您还将学习如何在 Netlify 上部署您的 Next.js 应用程序。

第十一章有用的资源和项目想法,在工作与 FARM 栈时提供了一些实用建议,以及 FARM 栈或非常相似的栈可能适用且有帮助的项目想法。

为了充分利用本书

您需要了解 JavaScript 和 Python 的基础知识。对 MongoDB 的先验知识更佳,但不是必需的。您将需要以下软件:

本书涵盖的软件/硬件 操作系统要求
MongoDB 版本 7.0 或更高 Windows、macOS 或 Linux
MongoDB Atlas Search Windows、macOS 或 Linux
MongoDB Shell 2.2.15 或更高版本 Windows、macOS 或 Linux
Node.js 版本 18.17 或更高 Windows、macOS 或 Linux
Python 3.11.7 或更高版本 Windows、macOS 或 Linux
Next.js 14 或更高版本 Windows、macOS 或 Linux
FastAPI 0.111.1 Windows、macOS 或 Linux
React 18 或更高版本 Windows、macOS 或 Linux

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

下载示例代码文件

您可以从 GitHub(github.com/PacktPublishing/Full-Stack-FastAPI-React-and-MongoDB-2nd-Edition)下载本书的示例代码文件。如果代码有更新,它将在 GitHub 仓库中更新。

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

下载彩色图像

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

使用的约定

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

文本中的代码: 表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“可选地,您可以创建一个middleware.js函数,该函数将包含将在每个(或仅选定的)请求上应用的中间件。”

代码块设置如下:

const Cars = () => {
    return (
        <div>Cars</div>
    )
}
export default Cars

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

      <body>
        <Navbar />
        {children}
      </body>

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

git push -u origin main

粗体: 表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“选择 Windows 版本,然后点击下载。”

小贴士或重要注意事项

看起来是这样的。

联系我们

我们欢迎读者的反馈。

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

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

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

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

下载此书的免费 PDF 副本

感谢您购买此书!

您喜欢在路上阅读,但无法随身携带您的印刷书籍吗?

您的电子书购买是否与您选择的设备不兼容?

别担心,现在,每购买一本 Packt 书籍,您都可以免费获得该书的 DRM 免费 PDF 版本。

在任何地方、任何设备上阅读。直接从您最喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。

优惠远不止于此,您还可以获得独家折扣、新闻通讯以及每天收件箱中的优质免费内容。

按照以下简单步骤获取这些好处:

  1. 扫描二维码或访问以下链接

packt.link/free-ebook/9781835886762

  1. 提交您的购买证明

  2. 就这样!我们将直接将免费 PDF 和其他优惠发送到您的电子邮件。

第一章:Web 开发与 FARM 栈

网站是使用一组被称为的技术构建的。栈的每个组件都负责应用的一层。虽然在理论上,你可以将任何类型的 frontend 技术与任何类型的 backend 技术结合,从而最终得到一个自定义栈,但一些栈在敏捷性和减少开发时间方面已经证明了自己的价值。如果你是一位需要不时将一些数据上线到网上的 Web 开发者或分析师,或者你只是想拓宽你的开发者视野,那么这一章应该会给你一些关于这组工具的视角,以及它们与替代技术的比较。

本章从可用技术和需求的角度概述了当今的 Web 开发格局,并在本章末尾,我们将论证使用FARM栈的必要性——这是一个结合了FastAPI用于 REST API 层、React用于前端和MongoDB作为数据库的栈。

本书专注于构成 FARM 栈的技术的高层次概念。通过学习这些概念,你将能够以快速的速度和现代的能力开发你的下一个 Web 开发项目。目前,我们不会深入细节或具体示例,而是将选定的栈组件(MongoDB、FastAPI 和 React)与它们的可能对应物进行比较。

到本章结束时,你将很好地理解 FARM 栈各个组件为开发项目带来的好处,它们之间的关系,以及为什么这一套技术非常适合具有灵活规格的 Web 应用——无论是从处理的数据还是期望的功能性来看。

本章将涵盖以下主题:

  • FARM 栈是什么以及组件是如何相互配合的?

  • 为什么使用 MongoDB 进行数据存储?

  • 什么是 FastAPI?

  • 前端——React

技术要求

对于这本书,你需要一些东西来帮助你完成你的旅程。以下是一些建议:

让我们从对 FARM 栈的基本理解开始。

FARM 栈是什么?

栈是一组覆盖现代 Web 应用不同部分的技术,混合并良好集成。正确的栈将使你在构建 Web 应用时能够满足某些标准,而所需的工作量和时间将比从头开始构建要少得多。

首先,让我们看看你需要构建一个功能性的 Web 应用需要什么:

  • 操作系统:通常,这是基于 Unix/Linux 的。

  • 存储层:一个 SQL 或 NoSQL 数据库。在这本书中,我们将使用 MongoDB。

  • Web 服务器:Apache 和 NGINX 相当受欢迎,但我们将讨论 FastAPI 的 Python 解决方案,如 Uvicorn 或 Hypercorn。

  • 开发环境:Node.js/JavaScript、.NET、Java 或 Python。

可选的,并且通常是,你还可以添加一个前端库或框架(例如 Vue.js、Angular、React 或 Svelte),因为绝大多数的 Web 开发公司从采用一个框架中受益匪浅,无论是在一致性、开发速度还是符合标准方面。此外,用户期望随着时间的推移而改变。对于登录、按钮、菜单和其他网站元素应该是什么样子,以及它们应该如何工作,存在一些未言明的标准。使用框架将使你的应用程序与现代 Web 更加一致,并且对用户满意度大有裨益。

最著名的堆栈如下:

  • MERNMongoDB + Express.js + React + Node.jsMERN)可能是当今最受欢迎的堆栈之一。开发者可以舒适地使用 JavaScript,除非他们需要编写一些样式表。随着 React Native 用于移动应用和 Electron.js 用于桌面应用,一个产品几乎可以涵盖每一个平台,同时仅依赖于 JavaScript。

  • MEANMongoDB + Express.js + Angular.js + Node.jsMEAN)与之前提到的 MERN 类似,Angular.js 以前端以更结构化的模型-视图-控制器MVC)方式管理。

  • LAMPLinux + Apache + MySQL + PHPLAMP)可能是第一个流行起来的堆栈缩写,也是过去 20 年中使用最广泛的之一。它至今仍然非常受欢迎。

前两个堆栈运行在 Node.js 平台上(一个服务器端运行的 JavaScript V8 引擎),并且有一个共同的 Web 框架。尽管 Express.js 是最受欢迎的,但在 Node.js 宇宙中还有许多优秀的替代品,例如 Koa.js、Fastify.js,或者一些更结构化的,如 Nest.js。

由于这是一本 Python 书,我们还将介绍一些重要的 Python 框架。对于 Python 开发者来说,最受欢迎的前三个框架是DjangoFlaskFastAPI。使用 Django Web 框架和优秀的Django REST FrameworkDRF)以现代和逻辑的方式构建 REST API 非常流行。Django 本身在 Python 开发者中非常成熟且广为人知。它还包含一个管理站点,可以自定义和序列化 REST 响应,可以选择功能性和基于类的视图,等等。

另一方面,FastAPI 是一个相对较新的框架。首次发布于 2018 年 12 月,这个替代的轻量级框架迅速获得了支持者。几乎立即,这些支持者就在技术堆栈中为 FastAPI 创造了一个新的缩写——FARM

让我们了解 FARM 代表什么:

  • FA代表 FastAPI——在技术年数中,一个全新的 Python Web 框架

  • R 代表 React,这是最受欢迎的 UI 库

  • M 代表数据层——MongoDB,这是目前最流行的 NoSQL 数据库

图 1.1 提供了 FARM 栈中各个组成部分之间集成的高级概述:

图 1.1 – FARM 栈及其组件的示意图

图 1.1:FARM 栈及其组件

如前图所示,FARM 栈由三层组成:

  1. 用户通过客户端执行操作,在我们的案例中,这将是基于 React 的——这最终创建了一个包含 HTML、层叠样式表(CSS)和 JavaScript 的包。

  2. 此用户操作(如鼠标点击、表单提交或其他事件)随后触发一个 HTTP 请求(如 GETPOSTPUT 或带有有效负载的其他 HTTP 动词)。

  3. 最后,此请求由 REST API 服务(FastAPI)处理。

Python 部分以 FastAPI 和可选依赖为中心,并由 findOnefindcreateupdate 等操作以及 MongoDB 聚合框架提供服务。从数据库中获得的结果通过 FastAPI 的 Python 驱动(Motor)进行解释,从 BSON 转换为适当的 Python 数据结构,并最终以纯 JSON 的形式从 REST API 服务器输出。如果你使用 Motor,这是一个 MongoDB 的异步 Python 驱动程序,这些调用将以异步方式处理。

最后,回到 图 1.1 中的图和标有 JSON 的箭头,数据被输入到 UI 中,由 React 处理,并用于更新界面、渲染必要的组件以及将 UI 与 React 的虚拟 DOM 树同步。

接下来的几节将讨论 FARM 栈诞生的动机。为什么选择这些技术,更重要的是,为什么选择这些技术组合?你将详细了解每个组件及其使其成为现代 Web 开发工作流程良好匹配的功能。在简要介绍整个栈的好处之后,这些章节将提供每个选择的概述,并强调它可以为现代 Web 开发工作流程提供的优势。

为什么选择 FARM 栈?

栈的灵活性和简单性,以及其组件,在开发速度、可扩展性和可维护性方面提供了真正的提升,同时允许未来的可扩展性(由于 MongoDB 的分布式特性以及 FastAPI 的异步特性),如果您的产品需要发展并变得比最初预期的更大,这可能至关重要。理想的情况可能是一个可以实验的小到中型规模的 Web 应用程序。

开发人员和分析师都可以从 Python 的生态系统和扩展性中受益,这个生态系统包含几乎涵盖所有包含某种类型计算的人类活动的丰富模块。

为什么使用 MongoDB?

MongoDB 是一个免费、快速且可扩展的数据库,具有 JSON 格式和简单语法。它支持灵活的模式,从而实现迭代和快速开发。MongoDB 能够适应各种复杂性的数据结构。此外,其查询和聚合方法使其成为像 FastAPI 这样的灵活 REST API 框架的绝佳选择,结合官方 Python 驱动程序如 Motor。它具有高度的采用率和成熟度,并且是十年前席卷 Web 开发世界的 NoSQL 数据存储运动支柱之一。

以下是一些将在本书中详细说明的其他功能:

  • 复杂的嵌套结构:MongoDB 文档允许嵌入其他文档和文档数组,这自然地转化为现代数据网络应用程序的数据流(例如,可以将所有评论嵌入到它们所响应的博客文章中)。鼓励去规范化。

  • 简单直观的语法:执行基本 创建读取更新删除CRUD)操作的方法,结合强大的聚合框架和投影,通过使用驱动程序,几乎可以轻松实现所有数据读取。对于有 SQL 经验的人来说,这些命令应该是直观的。

  • 社区和文档:MongoDB 由一家成熟的公司和一个强大的社区支持,并提供各种工具以促进开发和原型设计过程。例如,Compass 是一个桌面应用程序,它允许用户管理和维护数据库。无服务器函数的框架正在不断更新和升级,并且几乎为每种编程语言都提供了优秀的驱动程序。

当然,MongoDB 不是一个万能的解决方案,一些挑战在开始时就值得注意。一方面,无模式设计和将任何类型的数据插入数据库的能力可能会引起一些恐慌,但这转化为后端需要更强的数据完整性验证。你将看到 Pydantic——一个优秀的 Python 验证和类型强制库——如何帮助你实现更强的数据完整性。在 SQL 世界中存在的复杂连接的缺失,可能是一些应用程序的致命缺陷。

现在你已经了解了 MongoDB 在可扩展性和灵活性方面的优势,以及其无模式的方法,那么请看看你选择的 REST API 框架 FastAPI,并学习它是如何帮助你实现无模式方法并简化与数据的交互的。

为什么使用 FastAPI?

FastAPI 是一个现代且性能卓越的 Web 框架,用于构建 API。由 Sebastian Ramirez 构建,它使用了 Python 编程语言的新特性,如类型提示和注解、async – await 语法、Pydantic 模型、WebSocket 支持,等等。

如果你不太熟悉 API,让我们深入了解,通过了解 API 是什么来开始。应用程序编程接口API)用于实现不同软件组件之间的某种交互,它们通过请求和响应的周期使用超文本传输协议HTTP)进行通信。因此,API 如其名所示,是一个接口。通过这个接口,人类或机器与应用程序或服务进行交互。每个 API 提供商都应该有一个适合他们提供的数据类型的接口;例如,一个天气预报站提供的 API 会列出某个地点的温度和湿度水平。体育网站提供正在进行的比赛的统计数据。一个比萨饼配送 API 会提供所选配料、价格和预计送达时间。

API 涉及到你生活的方方面面,例如,传输医疗数据、实现应用程序之间的快速通信,甚至在田野中的拖拉机上使用。API 是使今天的网络运行的原因,简单来说,是信息交换的最佳形式。

本章不会详细讲解 REST API 的严格定义,而是列出它们的一些最重要的特性:

  • 无状态:据说 REST API 是无状态的,这意味着客户端和服务器之间不存储任何状态。所有请求和响应都由 API 服务器独立处理,且不涉及会话本身的信息。

  • 分层结构:为了保持 API 可扩展性和可理解性,RESTful 架构意味着一个分层结构。不同的层形成一个层次结构,相互通信但不与每个组件通信,从而提高了整体安全性。

  • 客户端-服务器架构:API 应该能够连接不同的系统/软件组件,而不限制它们自身的功能——服务器和客户端必须保持相互独立。

虽然与其他 Python 框架相比较新,但 MongoDB 选择 FastAPI 作为他们的 REST API 层有许多原因。以下是其中的一些原因:

  • 高性能:FastAPI 可以实现非常高的性能,尤其是与其他基于 Python 的解决方案相比。通过底层使用 Starlette,FastAPI 的性能达到了通常只属于 Node.js 和 Go 的水平。

  • 数据验证和简洁性:由于 Pydantic 极度依赖 Python 类型,这带来了许多好处。由于 Pydantic 结构只是开发者定义的类的实例,你可以使用复杂的数据验证、深度嵌套的 JSON 对象和分层模型(使用 Python 列表和字典),这与 MongoDB 的本质非常契合。

  • 快速开发:有了强大的集成开发环境IDE)支持,开发变得更加直观,这导致开发时间更短,错误更少。

  • 标准兼容性:FastAPI 基于标准,完全兼容用于构建 API 的开放标准——如 OpenAPI 和 JSON 模式。

  • 应用逻辑结构化:该框架允许将 API 和应用程序结构化为多个路由器,允许对请求和响应进行细粒度定制,并轻松访问 HTTP 周期的每个部分。

  • asyncio模块集成到 Python 中。

  • 依赖注入:FastAPI 中的依赖注入系统是其最大的卖点之一。它使得创建复杂的功能变得容易重用,这些功能可以在你的 API 中轻松使用。这是一件大事,可能是使 FastAPI 成为混合 Web 应用理想的特性——它为开发者提供了将不同功能轻松附加到 REST 端点的机会。

  • 优秀的文档:该框架本身的文档非常出色,无与伦比。它既易于遵循,又内容丰富。

  • 自动文档生成:基于 OpenAPI,FastAPI 能够实现自动文档的创建,这本质上意味着你可以免费使用 Swagger 来获取你的 API 文档。

此外,入门相对简单:

pip install fastapi

为了至少对使用 FastAPI 进行编码有一个基本的概念,让我们看看一个最小化的 API:

# main.py
from fastapi import FastAPI
app = FastAPI()
@app.get(“/”)
async def root():
    return {“message”: “Hello World”}

前几行代码定义了一个具有单个端点(/)的最小 API,该端点对GET请求返回消息Hello world。你可以实例化一个 FastAPI 类,并使用装饰器告诉服务器哪些 HTTP 方法应该触发哪个函数以进行响应。

Python 和 REST API

Python 已经用于构建 REST API 很长时间了。尽管有许多选项和解决方案,DRFFlask似乎是最受欢迎的,至少直到最近。如果你喜欢冒险,你可以通过 Google 搜索不那么流行或较旧的框架,例如bottle.pyCherryPy

DRF 是 Django Web 框架的插件系统,它使 Django 系统能够创建高度定制的 REST API 响应,并基于定义的模型生成端点。DRF 是一个非常成熟且经过实战检验的系统。它定期更新,其文档非常详细。

Flask,Python 的轻量级微框架,是网络构建 Python 工具中的瑰宝,并且可以用多种方式创建 REST API。你可以使用纯 Flask 并输出适当的格式(即,JSON 而不是 HTML),或者使用一些开发出来的扩展,使创建 REST API 尽可能简单。这两种解决方案在本质上都是同步的,尽管似乎有积极的发展方向,旨在启用异步支持。

此外,还有一些非常强大和成熟的工具,例如 Tornado,它是一个异步网络库(和服务器),能够扩展到数万个开放连接。最后,在过去的几年里,已经创建了几个基于 Python 的新解决方案。

这些解决方案中的一种,并且可以说是最快的,是 Starlette。被称为轻量级的 ASGI 框架/工具包,它非常适合构建高性能的异步服务。

塞巴斯蒂安·拉米雷斯(Sebastian Ramirez)在 Starlette 和 Pydantic 的基础上构建了 FastAPI,同时通过使用最新的 Python 特性(如类型提示和异步支持)添加了众多功能和优点。根据一些最近的开发者调查 1,FastAPI 正迅速成为最受欢迎和最受欢迎的 Web 框架之一。

1 www.jetbrains.com/lp/devecosystem-2023/python/#python_web_libs_two_years

在本书的后续章节中,您将了解 FastAPI 最重要的功能,但在此阶段,我们将强调拥有一个真正异步的 Python 框架作为系统最多样化组件的粘合剂的重要性。实际上,除了执行通常的 Web 框架任务,如与数据库通信、向前端输出数据、管理身份验证和授权之外,这个 Python 管道还使您能够通过依赖注入系统快速集成并轻松执行频繁需要的任务,如后台作业、头部和正文操作、响应和请求验证等。

本书将尝试涵盖您构建简单 FastAPI 系统所需的绝对最小必要条件,但在过程中,它将考虑各种网络服务器解决方案和部署选项(如 Deta、Heroku 和 DigitalOcean)为您基于 FastAPI 的 Python 后端,同时尝试选择免费解决方案。

因此,简而言之,您应该考虑选择 FastAPI,因为您理想上希望拥有异步处理请求的能力和速度,就像使用 Node.js 服务器一样,同时又能访问 Python 生态系统。此外,您还希望拥有一个框架的简单性和开发速度,该框架可以自动为您生成文档。

在审查了后端组件之后,现在是时候确定你的技术栈并着手前端工作了。下一节将为您简要介绍 React,并讨论它与其他(同样有效)解决方案的区别。

前端——React

当谈到前端——即面向用户的网站部分时,网络世界的变化最为明显。蒂姆·伯纳斯-李(Tim Berners-Lee)于 1991 年首次公开了第一个 HTML 规范,它由文本和不到 20 个标签组成。1994 年,CSS 被引入,网络开始看起来更加美观。传说中,名为 Mocha 的新浏览器脚本语言在短短 10 天内被创造出来——那是在 1995 年。后来,这种语言经历了多次变化,成为了我们今天所熟知的 JavaScript——一种强大且快速的编程语言,随着 Node.js 的出现,它也能够征服服务器。

2013 年 5 月,React 在美国推出,整个 Web 开发界得以见证虚拟 DOM、单向数据流、Flux 模式等。

这只是一点历史,旨在尝试提供一些背景和连贯性,因为 Web 开发,就像任何其他创造性的人类活动一样,很少会跳跃式发展。通常,它是通过一系列步骤发展的,使用户能够解决他们面临的问题。不提 Vue.js 就是不公平的,它是一个构建前端的好选择,同时也拥有一个完整的库生态系统,而 Svelte.js 则在构建 UI 方面提供了一个根本性的转变,即 UI 是编译的,捆绑的大小显著减小。

为什么使用 React?

对于任何面向公众的 Web 应用程序来说,交互式、吸引人、快速且直观的 UI 是必需的。虽然非常困难,但仅使用纯 JavaScript 就可以实现大多数甚至所有简单 Web 应用程序预期提供的功能。FastAPI 能够使用任何兼容的模板引擎(在 Python 世界中,最广泛使用的大概是 Jinja2)来服务 HTML(以及静态文件,如 JavaScript 或 CSS),但我们和用户想要的更多。

与其他框架相比,React 较小。它甚至不被视为框架,而是一个库——实际上,是几个库。尽管如此,它是一个经过超过 10 年开发、为 Facebook 的需求而创建、并由像 Uber、X(前身为 Twitter)和 Airbnb 这样的最大公司使用的成熟产品。

本书没有深入探讨 React,因为我们想专注于 FARM 栈的所有不同部分是如何连接并融入更大图景中的。此外,81% 的开发者已经使用 React2 并且熟悉其功能,因此我们假设我们的读者已经对这一框架有一定程度的了解。

2 2022.stateofjs.com/en-US/libraries/front-end-frameworks/

大多数开发者希望有一个简化和结构化的方式来构建 UI。React 通过依赖 JSX——JavaScript 和 XML 的混合体,具有直观的基于标签的语法,并为开发者提供了一种将应用程序视为组件的方式,这些组件进而形成其他更复杂的组件,从而将构建复杂 UI 和交互的过程分解为更小、更易于管理的步骤,使开发者能够以更简单的方式创建动态应用程序。

使用 React 作为前端解决方案的主要好处可以总结如下:

  • 性能:通过使用在内存中运行的 React 虚拟 DOM,React 应用提供了平滑且快速的性能。

  • 可复用性:由于该应用是通过使用具有自身属性和逻辑的组件构建的,因此您可以一次性编写组件,然后根据需要多次复用它们,从而减少开发时间和复杂性。

  • 易用性:这始终有点主观,但 React 入门很容易。高级概念和模式需要一定程度的熟练度,但即使是新手开发者也能从将应用程序前端拆分为组件并像乐高积木一样使用它们的可能性中立即获得好处。

React 和基于 React 的框架使你作为开发者能够创建具有桌面级外观和感觉的单页应用程序,同时还有对搜索引擎优化有益的服务器端渲染。了解 React 的使用方法使你能够从今天最强大的前端 Web 框架中受益,例如 Next.js、静态站点生成器(如 Gatsby.js)或令人兴奋且充满希望的新来者(如 React Remix)。

版本 16.8中,React 库引入了钩子,使开发者能够在不使用类的情况下使用和操作组件的状态,以及一些 React 的其他功能。这是一个重大的变化,成功地解决了不同的问题——它使得在组件之间重用状态逻辑成为可能,并简化了复杂组件的理解和管理。

最简单的 React 钩子可能是useState钩子。这个钩子使你能够在组件的生命周期内拥有并维护一个状态值(如对象、数组或变量),而无需求助于老式的基于类的组件。

例如,一个可能用于用户尝试找到合适的汽车时过滤搜索结果的非常简单的组件可能包含所需的品牌、型号和生产年份范围。这种功能非常适合作为单独的组件——一个需要维护不同输入控件状态(可能实现为一系列下拉菜单)的搜索组件。让我们看看这种实现的 simplest 可能版本。

以下代码块创建了一个简单的函数组件,它具有单个状态字符串值——一个 HTML select元素,它将更新名为brand的状态变量:

import { useState } from “react”;
const Search = () => {
const [brand, setBrand] = useState(“”);
return (
<div>
<div>Selected brand: {brand}</div>
<select onChange={(ev) => setBrand(ev.target.value)}>
<option value=””>All brands</option>
<option value=”Fiat”>Fiat</option>
<option value=”Ford”>Ford</option>
<option value=”Renault”>Renault</option>
<option value=”Opel”>Opel</option>
</select>
</div>
);
};
export default Search;

粗体行是钩子魔法发生的地方,它必须位于函数体内部。该语句仅仅创建了一个新的状态变量,称为brand,并为你提供了一个 setter 函数,可以在组件内部使用它来设置所需的值。

有许多钩子可以解决不同的问题,本书将介绍以下基本钩子:

  • 声明式视图:在 React 中,你不必担心 DOM 的过渡或突变。React 处理一切,你唯一需要做的就是声明视图的外观和反应。

  • 无模板语言:React 实际上使用 JavaScript 作为模板语言(通过 JSX),因此为了能够有效地使用它,你只需要了解一些 JavaScript,例如数组操作和迭代。

  • 丰富的生态系统:有众多优秀的库可以补充 React 的基本功能——从路由器到自定义钩子、外部库集成、CSS 框架适配等等。

最终,钩子为 React 提供了一种新的方式,在组件之间添加和共享状态逻辑,甚至可以在简单情况下取代 Redux 或其他外部状态管理库的需求。本书中展示的大部分示例都使用了上下文 API——这是一个 React 特性,它允许在不通过不需要它的组件传递 props 的情况下将对象和函数传递到组件树中。结合钩子——useContext钩子——它提供了一种简单直接的方式,在应用的每个部分传递和维护状态值。

React 使用(尽管不是强制性的)功能 JavaScript 的最新特性,ES6 和 ES7,尤其是在数组方面。使用 React 可以提高对 JavaScript 的理解,类似的情况也可以说关于 FastAPI 和现代 Python。

最后一部分将是选择 CSS 库或框架。截至 2024 年,有数十个 CSS 库与 React 兼容,包括 Bootstrap、Material UI、Bulma 等等。许多这些库与 React 合并,成为预构建的自定义和参数化组件的有意义框架。我们将使用 Tailwind CSS,因为它易于设置——并且一旦你掌握了它,它就非常直观。

将 React 部分保持到最基本,应该能让你更多地关注故事中的真正主角——FastAPI 和 MongoDB。如果你愿意,可以轻松地替换 React,无论是 Svelte.js、Vue.js 还是纯手工打造的 ECMAScript。然而,通过学习 React(及其钩子)的基础知识,你将踏上一次美妙的网络开发之旅,这将使你能够使用和理解建立在 React 之上的许多工具和框架。

争议性地,Next.js 是功能最丰富的服务器端渲染 React 框架,它支持快速开发、基于文件系统的路由等等。

摘要

本章为 FARM 堆栈奠定了基础,从描述每个组件的角色到它们的优点。现在,你将自信地选择 FARM 堆栈,并且知道如何在灵活和流动的网络开发项目中实现它。既然你在阅读,我会假设我的案例是有说服力的——你对它仍然感兴趣,并准备好探索 FARM 堆栈。

下一章将提供一个快速、简洁、可操作的 MongoDB 概述,然后为你的 FARM 应用程序设置数据存储层。随着你的进展,我们相信你会发现 FastAPI、React 和 MongoDB 的组合是你下一个网络应用程序的最佳选择。

第二章:使用 MongoDB 设置数据库

在本章中,您将通过几个简单而具有说明性的示例来探索 MongoDB 的一些主要功能。您将了解 MongoDB 查询 API 的基本命令,以开始与存储在 MongoDB 数据库中的数据进行交互。您将学习到必要的命令和方法,使您能够插入、管理、查询和更新您的数据。

本章的目的是帮助您了解在本地机器或云上设置 MongoDB 数据库是多么容易,以及如何在快速发展的 Web 开发过程中执行可能需要的操作。

通过 MongoDB 方法和聚合进行查询,最佳的学习方式是通过实验数据。本章利用 MongoDB Atlas 提供的真实世界样本数据集,这些数据集已加载到您的云数据库中。您将学习如何对这些数据集执行 CRUD 和聚合查询。

本章将涵盖以下主题:

  • MongoDB 数据库的结构

  • 安装 MongoDB 社区服务器和工具

  • 创建 Atlas 集群

  • MongoDB 查询和 CRUD 操作

  • 聚合框架

技术要求

对于本章,您需要 MongoDB 版本 7.0.7 和 Windows 11(以及 Ubuntu 22.04 LTS)。

MongoDB 版本 7.0 与以下兼容:

  • Windows 11、Windows Server 2019 或 Windows Server 2022(64 位版本)

  • Ubuntu 20.04 LTS(Focal)和 Ubuntu 22.04 LTS(Jammy)Linux(64 位版本)

以下是一些推荐的系统配置:

  • 至少配备 8 GB RAM 的台式机或笔记本电脑。

  • 没有指定 CPU 要求,但请确保它是现代的(多核处理器),以确保高效性能。

MongoDB 数据库的结构

MongoDB 在流行度和使用方面被广泛认为是领先的 NoSQL 数据库——其强大的功能、易用性和多功能性使其成为大型和小型项目的绝佳选择。其可扩展性和性能使得您的应用程序的数据层拥有非常坚实的基础。

在以下章节中,您将更深入地了解 MongoDB 的基本概念和构建块:文档、集合和数据库。由于本书采用自下而上的方法,您将从最底层开始,了解 MongoDB 中可用的最简单数据结构的概述,然后从这里开始,进入文档、集合等。

文档

MongoDB 是一个面向文档的数据库。但这实际上意味着什么呢?

在 MongoDB 中,文档类似于传统关系数据库中的行。MongoDB 中的每个文档都是一个由键值对组成的数据结构,代表一条记录。存储在 MongoDB 中的数据为应用程序开发者提供了极大的灵活性,使他们能够根据需要建模数据,并允许他们随着应用程序需求的变化在未来轻松地演进模式。MongoDB 具有灵活的模式模型,这基本上意味着你可以在集合中的不同文档中拥有不同的字段。根据需要,你还可以为文档中的字段使用不同的数据类型。

然而,如果你的应用程序需要在集合中的文档中保持数据的一致结构,你可以使用 MongoDB 中的模式验证规则来强制一致性。MongoDB 使你能够以对应用程序需求最有意义的方式存储数据。

MongoDB 中的文档只是一个有序的键值对集合。在这本书中,术语字段可以互换使用,因为它们代表同一事物。这种结构,正如你稍后将要探索的,与每种编程语言中的数据结构相对应;在 Python 中,你会发现这种结构是一个字典,非常适合 Web 应用程序或桌面应用程序的数据流。

创建文档的规则相当简单:键/字段名称必须是字符串,有一些例外,你可以在文档中了解更多信息,并且一个文档不能包含重复的键名。请记住,MongoDB 是区分大小写的。

在本章中,你将把一个名为sample_mflix的示例数据集加载到你的 MongoDB Atlas 集群中。该数据集包含许多集合,但本章中对我们感兴趣的是movies集合,它包含描述电影的文档。以下文档可能存在于这个集合中:

{
  _id: ObjectId("573a1390f29313caabcd42e8"),
  plot: 'A group of bandits stage a brazen train hold-up, only to find a determined posse hot on their heels.',
  genres: [ 'Short', 'Western' ],
  runtime: 11,
  cast: [
    'A.C. Abadie',
    "Gilbert M. 'Broncho Billy' Anderson",
    'George Barnes',
    'Justus D. Barnes'
  ],
  poster: 'https://m.media-amazon.com/images/M/MV5BMTU3NjE5NzYtYTYyNS00MDVmL WIwYjgtMmYwYWIxZDYyNzU2XkEyXkFqcGdeQXVyNzQzNzQxNzI@._V1_SY1000_SX677_AL_.jpg',
  title: 'The Great Train Robbery',
  fullplot: "Among the earliest existing films in American cinema - notable as the first film that presented a narrative story to tell - it depicts a group of cowboy outlaws who hold up a train and rob the passengers. They are then pursued by a Sheriff's posse. Several scenes have color included - all hand tinted.",
  languages: [ 'English' ],
  released: ISODate("1903-12-01T00:00:00.000Z"),
  directors: [ 'Edwin S. Porter' ],
  rated: 'TV-G',
  awards: { wins: 1, nominations: 0, text: '1 win.' },
  lastupdated: '2015-08-13 00:27:59.177000000',
  year: 1903,
  imdb: { rating: 7.4, votes: 9847, id: 439 },
  countries: [ 'USA' ],
  type: 'movie',
  tomatoes: {
    viewer: { rating: 3.7, numReviews: 2559, meter: 75 },
    fresh: 6,
    critic: { rating: 7.6, numReviews: 6, meter: 100 },
    rotten: 0,
    lastUpdated: ISODate("2015-08-08T19:16:10.000Z")
  },
  num_mflix_comments: 0
}

注意

当涉及到文档中的文档嵌套时,MongoDB 支持 100 层嵌套,这在大多数应用程序中你可能不会达到这个限制。

MongoDB 支持的数据类型

MongoDB 允许你将任何 BSON 数据类型作为字段值存储。BSON 与 JSON 非常相似,它代表“二进制 JSON”。BSON 的二元结构使其更快,并且比 JSON 支持更多的数据类型。在设计任何类型的应用程序时,最重要的决定之一是数据类型的选择。作为一个开发者,你永远不会想为当前的工作使用错误工具。

注意

MongoDB 支持的所有数据类型的完整列表可以在官方文档中找到:www.mongodb.com/docs/mongodb-shell/reference/data-types/

MongoDB 支持的一些最重要的数据类型包括:

  • 字符串:这些可能是 MongoDB 中最基本和最通用的数据类型,它们用于表示文档中的所有文本字段。

  • 数字:MongoDB 支持不同类型的数字,包括:

    • int:32 位整数

    • long:64 位整数

    • double:64 位浮点数

    • decimal:基于 128 位的十进制浮点数

  • truefalse值;它们不使用引号书写,因为你不希望它们被解释为字符串。

  • 对象或内嵌文档:在 MongoDB 中,文档内的字段可以包含内嵌文档,允许在单个文档中进行复杂的数据结构化。这种能力支持类似 JSON 的结构深层嵌套,便于灵活和分层的数据建模。

  • 数组:数组可以包含零个或多个值,具有类似列表的结构。数组的元素可以是任何 MongoDB 数据类型,包括其他文档和数组。它们是从零开始的,特别适合创建内嵌关系。例如,你可以在博客文章文档本身中存储所有评论,包括时间戳和发表评论的用户。数组可以利用标准的 JavaScript 数组方法进行快速编辑、推送和其他操作。

  • _id作为主键。如果插入的文档省略了_id字段,MongoDB 会自动为_id字段生成一个 ObjectId,用于在集合中唯一标识文档。ObjectId 的长度为 12 字节。它们体积小、可能唯一、生成速度快、有序。这些 ObjectId 广泛用作传统关系的键——ObjectId 会自动索引。

  • 日期:尽管 JSON 不支持日期类型并将它们存储为普通字符串,但 MongoDB 的 BSON 格式明确支持日期类型。它们表示自 Unix 纪元(1970 年 1 月 1 日)以来的 64 位毫秒数。所有日期都存储为 UTC,没有时区关联。BSON 日期类型是有符号的。负值表示 1970 年之前的日期。

  • 二进制数据:二进制数据字段可以存储任意二进制数据,是保存非 UTF-8 字符串到数据库的唯一方式。这些字段可以与 MongoDB 的 GridFS 文件系统结合使用,例如存储图像。

  • Null:这可以表示 null 值或不存在的字段,我们甚至可以将 JavaScript 函数作为不同的数据类型存储。

现在你已经了解了 MongoDB 中可用的字段类型以及如何将你的业务逻辑映射到(灵活的)模式中,是时候介绍集合了——文档的组,在关系数据库世界中与表相对应。

集合和数据库

尽管你可以在同一个集合中存储多个模式,但有许多理由将你的数据存储在多个数据库和多个集合中:

  • 数据分离:集合允许你逻辑上分离不同类型的数据。例如,你可以有一个用于用户数据的集合,另一个用于产品数据的集合,还有一个用于订单数据的集合。这种分离使得管理和查询特定类型的数据变得更加容易。

  • 性能优化:通过将数据分离到不同的集合中,你可以通过更有效地索引和查询特定集合来优化性能。这可以提高查询性能并减少需要扫描的数据量。

  • 数据局部性:在集合中将相同类型的文档分组将需要更少的磁盘查找时间,考虑到索引是由集合定义的,查询效率会更高。

虽然单个 MongoDB 实例可以同时托管多个数据库,但将应用程序中使用的所有文档集合都保存在单个数据库中是一种良好的做法。

注意

当你安装 MongoDB 时,将创建三个数据库,它们的名称不能用于你的应用程序数据库:adminlocalconfig。它们是内置数据库,不应被替换,因此请避免意外地将你的数据库命名为相同的方式或对这些数据库进行任何更改。

安装 MongoDB 数据库的选项

在回顾了 MongoDB 数据库的基本术语、概念和结构之后,现在是时候学习如何在本地和云端设置 MongoDB 数据库服务器了。

本地数据库设置方便快速原型设计,甚至不需要互联网连接。然而,我们建议你在设置数据库作为未来章节中用作后端时,使用 MongoDB Atlas 提供的云托管数据库。

MongoDB Atlas 相比本地安装提供了许多优势。首先,它易于设置,正如你将看到的,你可以在几分钟内将其设置好并运行起来,一个慷慨的免费层数据库已经准备好工作。MongoDB 处理数据库的所有操作方面,如配置、扩展、备份和监控。

Atlas 取代了大部分手动设置并保证了可用性。其他好处包括 MongoDB 团队的参与(他们试图实施最佳实践),默认情况下具有访问控制、防火墙和细粒度访问控制、自动备份(取决于层级),以及立即开始生产力的可能性。

安装 MongoDB 和相关工具

MongoDB 不仅仅是一个数据库服务提供商,而是一个完整的开发者数据平台,它围绕核心数据库构建了一系列技术,以满足你所有的数据需求并提高你的开发效率。让我们检查以下组件,你将在接下来的章节中安装或使用它们:

  • MongoDB 社区版:MongoDB 的免费版本,可在所有主要操作系统上运行。这是你将在本地玩转数据时使用的版本。

  • MongoDB Compass:一个用于在可视化环境中管理、查询、聚合和分析 MongoDB 数据的图形用户界面(GUI)。Compass 是一个成熟且实用的工具,您将在初始查询和聚合探索过程中使用它。

  • MongoDB Atlas:MongoDB 的数据库即服务解决方案。这一服务是 MongoDB 成为 FARM 堆栈核心部分的主要原因之一。它相对容易设置,并且可以减轻您手动管理数据库的负担。

  • (mongosh):一个命令行外壳,不仅可以在您的数据库上执行简单的创建读取更新删除CRUD)操作,还允许执行管理任务,如创建和删除数据库、启动和停止服务以及类似的工作。

  • MongoDB 数据库工具:几个命令行实用程序,允许管理员和开发者将数据从数据库导出或导入,提供诊断功能,或允许操作存储在 MongoDB 的 GridFS 系统中的大文件。

本章将重点介绍实现完全功能安装的流程。请检查与您的操作系统对应的安装说明。本章包括 Windows、Linux 和 macOS 的安装说明。

在 Windows 上安装 MongoDB 和 Compass

在本节中,您将学习如何安装 MongoDB 社区版最新版本,撰写本文时为 7.0。MongoDB 社区版仅支持 x86_64 架构的 64 位 Windows 版本。支持的 Windows 版本包括 Windows 11、Windows Server 2019 和 Windows Server 2022。要安装 MongoDB 和 Compass,您可以参考以下步骤。

注意

我们强烈建议您检查 MongoDB 网站上的说明(www.mongodb.com/docs/manual/tutorial/install-mongodb-on-windows/),以确保您能够获取最新信息,因为它们可能会有所变化。

  1. 要下载安装程序,请访问 MongoDB 下载中心www.mongodb.com/try/download/community,选择 Windows 版本,然后点击下载,如下所示:

图片

图 2.1:MongoDB 下载页面

  1. 接下来,执行它。如果出现安全提示“打开可执行文件”,请选择,然后继续进入 MongoDB 设置向导。

  2. 阅读许可协议,勾选复选框,然后点击下一步

  3. 这是一个重要的屏幕。当被问及选择哪种设置类型时,请选择完整,如下所示:

图片

图 2.2:完整安装

  1. 下一个向导将询问您是否希望 MongoDB 作为 Windows 网络服务(您应该选择这种方式)或本地和域服务运行。保留默认值,不要做任何更改,直接进入下一步。

  2. 另一个向导将出现,提示你是否想安装 Compass,MongoDB 的数据库管理 GUI 工具。选择复选框并继续安装:

    • img/B22406_02_03.jpg

图 2.3:安装 Compass

  1. 最后,Windows 的 用户账户控制UAC)警告屏幕将弹出,你应该选择

现在你已经在本地机器上安装了 MongoDB Community Server,下一节将展示如何安装你将在本书中使用的其他必要工具。

安装 MongoDB Shell (mongosh)

在您的计算机上安装 MongoDB Community Server 和 Compass 后,接下来将安装 mongosh,MongoDB Shell。

注意

关于其他操作系统的说明,请访问 MongoDB 文档:www.mongodb.com/docs/mongodb-shell/install/.

这里是如何在 Windows 上操作的:

  1. 导航到 MongoDB 下载中心 (www.mongodb.com/try/download/shell),在 工具 部分选择 MongoDB Shell

  2. 从下拉菜单中选择 Windows 版本和 msi 包,然后点击 下载

img/B22406_02_04.jpg

图 2.4:下载 MongoDB Shell

  1. 接下来,在您的计算机上找到 msi 包并执行它。如果安全提示要求 打开可执行文件,选择 并继续到 MongoDB 设置向导。向导将打开以下页面。点击 下一步

img/B22406_02_05.jpg

图 2.5:MongoDB Shell 设置向导

  1. 在提示中,选择安装 mongosh 的目标文件夹,或者如果你觉得默认选项看起来不错,就保持默认,然后完成安装。

  2. 到目前为止,你应该能够测试 MongoDB 是否正在运行(作为服务)。在你的选择命令提示符中输入以下命令(最好是使用 cmder,可在 cmder.app 获取):

    mongosh
    
  3. 你应该会看到各种通知和一个标记为 > 的小提示。尝试输入以下内容:

    Show dbs
    

    如果你看到自动生成的 adminconfiglocal 数据库,你应该可以继续了。

  4. 现在,检查 Compass 的安装情况。在 Windows 上,你应该能在开始菜单下的 MongoDBCompass(无空格)中找到它。

  5. 如果你只是点击 27017,你应该能看到当你使用 MongoDB 命令行时看到的全部数据库:adminconfiglocal

MongoDB 数据库工具

MongoDB 数据库工具是一组用于与 MongoDB 部署一起使用的命令行实用程序。以下是一些常见的数据库工具:

  • mongoimport:从扩展的 JSON、CSV 或 TSV 导出文件导入内容

  • mongoexport:从 mongod 实例中生成 JSON 或 CSV 导出

  • mongodump:创建 mongod 数据库内容的二进制导出

有一些其他工具,例如 mongorestorebsondumpmongostatmongotopmongofiles。MongoDB 数据库工具可以使用 MSI 安装程序安装(或作为 ZIP 存档下载)。

注意

msi 软件包可以从 MongoDB 下载中心下载(www.mongodb.com/try/download/database-tools)。

下载后,您可以按照 MongoDB 文档中提供的安装说明进行操作(www.mongodb.com/docs/database-tools/installation/installation-windows/)。

下一节将介绍在标准 Linux 发行版上安装 MongoDB 的过程。

在 Linux 上安装 MongoDB 和 Compass:Ubuntu

Linux 为本地服务器的开发和管理工作提供了许多好处,但最重要的是,如果您决定不再使用 MongoDB 的数据库即服务,您可能希望在一个基于 Linux 的服务器上工作。

在本书中,我们将介绍在 Ubuntu 22.04 LTS(Jammy)版本上的安装过程,同时 MongoDB 版本也支持 x86_64 架构的 Ubuntu 20.04 LTS(Focal)。安装 MongoDB Ubuntu 所需的步骤将在此列出,但您应始终检查 MongoDB Ubuntu 安装页面(www.mongodb.com/docs/manual/tutorial/install-mongodb-on-ubuntu/)以了解最近的变化。然而,过程本身不应发生变化。

以下操作需要在 Bash shell 中执行。下载允许您安装 MongoDB 的公钥,然后您将创建一个列表文件并重新加载包管理器。对于其他 Linux 发行版,也需要执行类似的步骤,因此请确保检查您选择发行版的网站上的文档。最后,您将通过包管理器执行 MongoDB 的实际安装并启动服务。

总是最好跳过 Linux 发行版提供的软件包,因为它们通常没有更新到最新版本。按照以下步骤在 Ubuntu 上安装 MongoDB:

  1. 按照以下方式导入软件包管理系统中使用的公钥。

    您的系统上需要安装 gnupgcurl。如果您还没有安装它们,可以通过运行以下命令进行安装:

    sudo apt-get install gnupg curl
    

    要导入 MongoDB 公共 GPG 密钥,请运行以下命令:

    curl -fsSL https://www.mongodb.org/static/pgp/server-7.0.asc | \
       sudo gpg -o /usr/share/keyrings/mongodb-server-7.0.gpg \
       --dearmor
    

    通过运行以下命令为 Ubuntu 22.04(Jammy)创建 /etc/apt/sources.list.d/mongodb-org-7.0.list 文件:

    echo "deb [ arch=amd64,arm64 signed-by=/usr/share/keyrings/mongodb-server-7.0.gpg ] https://repo.mongodb.org/apt/ubuntu jammy/mongodb-org/7.0 multiverse" | sudo tee /etc/apt/sources.list.d/mongodb-org-7.0.list
    
  2. 使用以下命令重新加载本地包数据库:

    sudo apt-get update
    
  3. 安装 MongoDB 软件包。要安装最新稳定版本,请执行以下命令:

    sudo apt-get install -y mongodb-org
    
  4. 运行 MongoDB 社区版。如果您遵循这些说明并通过包管理器安装 MongoDB,则在安装过程中将创建/var/lib/mongodb数据目录和/var/log/mongodb日志目录。

  5. 您可以使用以下命令启动mongod进程:

    sudo systemctl start mongod
    Failed to start mongod.service: Unit mongod.service not found.
    

    首先运行以下命令:

    sudo systemctl daemon-reload
    

    然后再次运行start命令(如步骤 5所示)。

  6. 您只需简单地输入以下命令即可开始使用 MongoDB Shell (mongosh):

    mongosh
    

当涉及到安装和进程管理时,MongoDB 与其他 Linux 软件并没有特别不同。然而,如果您在安装过程中遇到任何问题,第一个建议是访问 MongoDB Linux 安装页面。

设置 Atlas

MongoDB Atlas——由 MongoDB 提供的云数据库服务——是 MongoDB 最强大的卖点之一。

MongoDB Atlas 是一项完全托管的数据服务,这意味着 MongoDB 为您处理基础设施管理、数据库设置、配置和维护任务。这使您能够专注于开发应用程序,而不是管理底层基础设施。

注意

www.mongodb.com/docs/atlas/getting-started/上详细记录了注册和设置 MongoDB Atlas 实例的过程。

您可以通过两种方式设置您的 Atlas 账户:

  • Atlas UI(网站)

  • Atlas CLI(命令行)

Atlas CLI 为 MongoDB Atlas 提供了一个专门的命令行界面,允许您从终端直接管理您的 Atlas 数据库部署和 Atlas Search。在本书中,您将看到如何从 UI 进行操作。

如果您还没有 Atlas 账户,请前往www.mongodb.com/cloud/atlas/register创建一个 Atlas 账户。您可以使用 Google 账户、GitHub 账户或电子邮件账户注册此服务。

注意

随着更多功能的引入,Atlas UI 和集群创建步骤可能会发生变化。强烈建议您在设置集群时参考最新说明(www.mongodb.com/docs/atlas/getting-started/)。

在设置账户(这里使用的是 Gmail 地址,因此您可以使用 Google 账户登录以实现更快的访问)之后,系统会提示您创建一个集群。您将创建一个免费的M0集群,并且应该选择一个尽可能接近您物理位置的云提供商和区域选项,以最小化延迟。

创建 Atlas 集群

要设置一个 Atlas 集群,请执行以下步骤:

  1. 在您的 Atlas 仪表板上,您将看到创建部署选项。点击创建以开始创建您的第一个 Atlas 集群的过程。

图 2.6:Atlas 仪表板

在这一步,您需要做几件事情:

  1. 选择免费的M0 沙盒选项。

  2. 给您的集群起一个有意义的名字,例如farm-stack。您可以选择任何其他名字。

  3. 确保已勾选自动安全设置添加示例数据集选项。这将在以后非常有用。

  4. 选择您偏好的云服务提供商(默认为 AWS)

  5. 选择离您位置最近的区域以最小化延迟,然后点击创建部署

注意

创建 Atlas 用户和设置 IP 是一个重要的步骤,您必须在开始使用 Atlas 集群之前完成。

  1. 在下一屏,您将被要求创建一个数据库用户,该用户将有一个用户名和密码。这两个字段都是自动填充的,以简化流程。您可以根据自己的偏好更改用户名和密码。请确保将密码保存在某个地方,因为您稍后连接到 **您的集群时需要它****。

  2. 默认情况下,您的当前 IP 地址被添加以启用本地连接。MongoDB Atlas 提供了许多安全层,受限 IP 地址访问是其中之一。如果您打算从任何其他 IP 地址使用您的集群,您可以稍后添加该 IP,或者您也有选择启用从任何地方访问(0.0.0.0/0),这将允许您从任何地方连接,但出于安全原因,这不是推荐选项。

完成这些步骤后,您已成功创建了第一个 Atlas 集群!

获取 Atlas 集群的连接字符串

接下来,您将查看为您自动加载的示例数据集。在本节中,您将使用 Compass 将数据集连接到您的 Atlas 集群,并使用它来探索相同的数据集:

  1. 在 Atlas 仪表板上,点击如图图 2.7所示的浏览集合按钮。

图 2.7:Atlas 仪表板

  1. 您可以看到sample_mflix数据集已经加载到您的集群中。您将拥有一个名为sample_mflix的数据库,并在其下创建六个集合:commentsembedded_moviesmoviessessionstheatresusers

  2. 现在,前往您的 Atlas 仪表板,获取从 Compass 连接到 Atlas 集群的连接字符串。

  3. 在 Atlas 仪表板上,点击绿色的连接按钮。

图 2.8:连接到您的集群

  1. 然后,选择Compass

图 2.9:连接到您的集群

  1. 在下一个向导中,复制框中显示的连接字符串:

图 2.10:获取连接字符串

太好了!现在,您已经有了 Atlas 集群的连接字符串。您可以去 Compass 并使用此连接字符串从 Compass 连接到您的 Atlas 集群。连接到集群之前,别忘了将<password>替换为您的 Atlas 用户密码。

从 Compass 连接到 Atlas 集群

执行以下步骤以从 Compass 连接到您的 Atlas 集群:

  1. 如果 Compass 尚未在您的计算机上运行,请启动它。在URI框中,粘贴您从上一步复制的连接字符串,并添加您的密码。接下来,单击连接

图片

图 2.11:MongoDB Compass

  1. 成功连接到您的 Atlas 集群后,您将看到类似于图 2.12的内容:

图片

图 2.12:MongoDB Compass 中的“我的查询”选项卡

  1. 您可以在左侧面板中看到您集群中的数据库列表。单击sample_mflix以展开下拉菜单并显示集合列表。然后,单击movies以查看该集合中存储的文档:

图片

图 2.13:集合中的文档列表

图 2.13 显示,你的sample_mflix.movies集合中有 21.4k 个文档。

现在,您应该在您的机器上拥有一个功能齐全的全球最受欢迎的 NoSQL 数据库实例。您还创建了一个在线账户,并成功创建了您自己的集群,准备好应对大多数数据挑战并为您的 Web 应用提供动力。

MongoDB 查询和 CRUD 操作

让我们看看 MongoDB 的实际应用,亲身体验全球最受欢迎的 NoSQL 数据库的力量。本节将通过简单的示例向您展示最关键的 MongoDB 命令。这些方法将使您作为开发者能够控制您的数据,创建新的文档,使用不同的标准和条件查询文档,执行简单和更复杂的聚合,并以各种形式输出数据。

虽然您将通过 Python 驱动程序(Motor 和 PyMongo)与 MongoDB 进行通信,但首先学习如何直接编写查询是有帮助的。您将从查询在集群创建时导入的sample_mflix.movies数据集开始,然后您将经历创建新数据的过程——插入、更新等。

让我们先定义执行 MongoDB 命令的两种选项,如下所示:

  • Compass 图形用户界面

  • MongoDB Shell (mongosh)

mongosh连接到您的 MongoDB Atlas 集群并执行数据上的 CRUD 操作:

  1. 要从mongosh(MongoDB Shell)连接到您的 Atlas 集群,请导航到您的 Atlas 集群仪表板并获取mongosh的连接字符串。步骤将与 Compass 相同,只是连接工具不同。为此,您需要 MongoDB Shell 而不是 Compass。

    图 2.14 显示了mongosh的连接字符串:

图片

图 2.14:连接到 mongosh(MongoDB Shell)

  1. 复制连接字符串并导航到您计算机上的 CLI。

  2. 现在,为了在 Atlas 中的云数据库上设置与执行命令的选项,请执行以下步骤:

    1. 在 shell 会话(Windows 上的命令提示符或 Linux 上的 Bash)中,将连接字符串粘贴到提示符中,然后按Enter键。然后,输入密码并按Enter键。

    您也可以通过使用--password选项后跟您的密码来显式地在连接字符串中传递密码。为了避免在输入密码时出现任何拼写错误或错误,您可以使用此选项。

    1. 成功连接到您的 Atlas 集群后,您应该看到如下内容:

图 2.15:成功连接到 MongoDB 数据库

  1. 接下来,使用show dbs命令列出集群中所有存在的数据库:
show dbs

此命令应列出所有可用的数据库:adminlocalsample_mflix(您的数据库)。

  1. 为了使用您的数据库,请输入以下代码:
use sample_mflix

控制台将响应switched to db sample_mflix,这意味着现在您可以查询并操作您的数据库。

  1. 要查看sample_mflix中的可用集合,请尝试以下代码:
show collections

您应该能够查看在 Atlas UI 和 Compass 中看到的六个集合,即commentsembedded_moviesmoviessessionstheatresusers。现在您已经有了可用的数据库和集合,您可以继续使用一些查询选项。

MongoDB 中的查询

本节将通过使用sample_mflix.movies集合作为示例来展示find()的使用。使用具有预期查询结果的真实数据有助于巩固所学知识,并使理解底层过程更加容易和全面。

本章将涵盖的最常见的 MongoDB 查询语言命令如下:

  • find(): 根据简单或复杂的标准查找和选择文档

  • insertOne(): 将新文档插入到集合中

  • insertMany(): 将一个文档数组插入到集合中

  • updateOne()updateMany(): 根据某些标准更新一个或多个文档

  • deleteOne()deleteMany(): 从集合中删除一个或多个文档

sample_mflix.movies集合中有 21,349 个文档。要查询所有文档,请在 MongoDB Shell 中输入以下命令:

db.movies.find()

上述命令将打印出几个文档,如下所示:

图 2.16:find()查询输出

控制台将打印出消息“输入“it”以获取更多信息”,因为控制台一次只打印出 20 个文档。这个语句在 SQL 世界中可以理解为经典的SELECT * FROM TABLE

注意

find()方法返回一个游标而不是实际的结果。游标允许对返回的文档执行一些标准数据库操作,例如限制结果数量、按一个或多个键(升序或降序)排序以及跳过记录。

您还可以应用一些过滤器,只返回满足指定条件的文档。movies集合有一个years字段,它表示电影发布的年份。例如,您可以编写一个查询来只返回在1969年发布的电影。

在命令提示符中,输入以下命令:

db.movies.find({"year": 1969}).limit(5)

在这里,你使用了游标上的 limit() 方法来指定游标应返回的最大文档数,在这种情况下为 5

上述命令将返回搜索结果:

图 2.17:带有过滤条件的 find() 操作

结果现在应仅包含满足 year 键等于 1969 的条件的文档。查看结果,似乎有很多文档。你还可以通过使用 db.collection.countDocuments() 方法在查询上执行计数操作。例如:

db.movies.countDocuments({"year": 1969})

上述命令返回 107,这意味着你的集合中有 107 个文档符合你的搜索条件;也就是说,有 107 部电影是在 1969 年发布的。

你在之前的查询中使用的 JSON 语法是一个 过滤器,它可以有多个键值对,用于定义你的查询方法。MongoDB 有许多操作符,允许你查询具有比简单相等更复杂条件的字段,并且它们的最新文档可在 MongoDB 网站上找到,网址为 docs.mongodb.com/manual/reference/operator/query/

你可以访问该页面并查看一些操作符,因为它们可以给你一个关于如何构建你的查询的想法。

例如,假设你想找到所有在 USA 发布且年份在 1945 之后的 Comedy (类型) 电影。以下查询将完成这项工作:

db.movies.find({"year": {$gt: 1945}, "countries": "USA", "genres": "Comedy"})

执行查询后,你应该会看到游标返回的一堆文档。

你也可以使用 countDocuments 方法来找出匹配过滤条件的文档的确切数量:

db.movies.countDocuments({"year": {$gt: 1945}, "countries": "USA", "genres": "Comedy"})

你会发现集合中有 3521 个文档符合你的搜索条件。

$gt 操作符用于指定年份应大于 1945,确保所选电影是在此年之后发布的。国家和类型的条件很简单,需要 countries 数组包含 USA,而 genres 数组包含 Comedy

记住,find() 方法意味着一个 AND 操作,因此只有满足所有三个条件的文档才会被返回。

一些最广泛使用的查询操作符如下:

  • $gt:大于

  • $lt:小于

  • $in:提供值列表

然而,你可以在 MongoDB 文档中看到更多——逻辑上的 ;用于在地图上查找最近点的 地理空间 操作符等等。现在是时候探索其他允许你执行查询和操作的方法了。

findOne()find() 类似;它也接受一个可选的过滤参数,但只返回满足条件的第一个文档。

在你深入研究创建、删除和更新现有文档的过程之前,重要的是要提到一个非常有用的功能,称为投影

投影

投影允许你指定在查询结果中应包含或排除哪些字段。这是通过向find()findOne()方法提供额外的参数来实现的。此参数是一个对象,它指定了要包含或排除的字段,从而有效地定制查询结果,只包含所需的信息。

构建投影很简单;一个投影查询只是一个 JSON 对象,其中键是字段的名称,而值是0(如果你想从输出中排除一个字段)或1(如果你想包含它)。ObjectId类型默认包含,所以如果你想从输出中移除它,你必须明确将其设置为0。此外,如果你没有在投影中包含任何字段的名称,它假定具有0值,并且不会被投影。

假设在你之前的查询中,你只想投影电影标题、上映国家和年份。为此,执行以下命令:

db.movies.find({"year": {$gt: 1945}, "countries": "USA", "genres": "Comedy"}, {"_id":0, "title": 1, "countries": 1, "year": 1}).sort({"year": 1}).limit(5)

排序和限制操作首先按year字段升序排序返回的文档,然后根据limit参数限制结果为五份文档。在投影部分,通过将其设置为0来抑制_id字段,并通过将其设置为1来包含titlecountriesyear字段。由于在投影中省略了genres字段和所有其他字段,它们自动被排除在返回的文档之外。

创建新文档

在 MongoDB 中创建新文档的方法是insertOne()。你可以尝试将以下虚构电影插入到你的数据库中:

db.movies.insertOne({"title": "Once upon a time on Moon", "genres":["Test"], year: 2024})

上述命令将打印以下消息:

{
  acknowledged: true,
  insertedId: ObjectId("66b25f48b959c3fb3a4e56ed")
}

第一部分表示 MongoDB 已确认插入操作,而第二部分则打印出ObjectId键,这是 MongoDB 使用的并自动分配给新插入文档的主键,除非手动提供。

自然地,MongoDB 也支持使用insertMany()方法一次性插入多个文档。该方法接受一个文档数组,而不是单个文档。例如,你可以按如下方式插入另外几部样本电影:

db.movies.insertMany([{"title": "Once upon a time on Moon", "genres":["Test"], year: 2024}, {"title": "Once upon a time on Mars", "genres":["Test"], year: 2023}, {"title": "Tiger Force in Paradise", "genres":["Test"], year: 2019, rating: "G"}])

在这里,你插入了三部虚构的电影,第三部有一个新的属性,评分(设置为G),这在任何其他电影中都不存在,只是为了突出 MongoDB 的架构灵活性。Shell 也确认了这一点,并打印出新插入文档的ObjectId键。

更新文档

在 MongoDB 中更新文档可以通过几种不同的方法来实现,这些方法适合于可能出现在你的业务逻辑中的不同场景。

updateOne() 方法使用在字段中提供的数据更新遇到的第一个文档。例如,让我们更新第一个 genres 字段包含 Test 的电影,并将其设置为 PlaceHolder 类型,如下所示:

db.movies.updateOne({genres: "Test"}, {$set: {"genres.$": "PlaceHolder"}})

只要使用 $set 操作符,你也可以更新文档的现有属性。假设你想要更新你收藏中所有匹配过滤条件且 genres 字段值设置为 placeHolder 类型的文档,并将年份值增加 1。你可以尝试以下命令:

db.movies.updateMany( { "genres": "Test" }, { $set: { "genres.$": "PlaceHolder" }, $inc: { "year": 1 } } )

上述命令更新了许多文档,即所有 genres 字段包含 Test 的电影。

更新文档是一个原子操作——如果同时发出两个或多个更新,则首先到达服务器的更新将被应用。

mongosh 还提供了一个 replaceOne() 方法,它接受一个过滤器,就像你之前的方法一样,但还期望一个完整的文档来替换前面的文档。你可以在以下文档中获取有关集合方法的更多信息:www.mongodb.com/docs/manual/reference/method/db.collection.updateOne/

删除文档

删除文档的方式与 find 方法类似——你可以提供一个过滤器来指定要删除的文档,并使用 deleteOnedeleteMany 方法来执行操作。

使用以下命令删除你收藏中插入的所有假电影:

db.movies.deleteMany({genres: "PlaceHolder"})

壳会通过一个 deletedCount 变量来确认此操作,其值等于 4——被删除的文档数量。deleteOne 方法以非常相似的方式通过删除第一个匹配过滤条件的文档来操作。

要在 MongoDB 中删除整个集合,你可以使用 db.collection.drop() 命令。然而,不建议在不加考虑的情况下删除整个集合,因为它将删除所有数据和相关的索引。建议不要为电影数据集运行此命令,因为我们还需要它来完成本章的其余部分。

注意

如果你删除了所有文档,请确保在 Atlas 中再次导入数据(你应在 Atlas 仪表板上看到一个选项)。

聚合框架

MongoDB 聚合框架是一个极其有用的工具,它可以将一些(或大多数)计算和不同复杂度的聚合负担卸载到 MongoDB 服务器,从而减轻你的客户端以及(基于 Python 的)后端的工作量。

围绕一个 find 方法展开,你已经广泛使用了这个方法,但额外的好处是在不同的阶段或步骤中进行数据处理。

如果你想要熟悉所有可能性,MongoDB 文档网站([www.mongodb.com/docs/manual/reference/method/db.collection.aggregate/](https://www.mongodb.com/docs/manual/reference/method/db.collection.aggregate/))是最佳起点。然而,我们将从几个简单的示例开始。

聚合的语法与其他你之前使用的方法类似,例如 find()findOne()。我们使用 aggregate() 方法,它接受一个阶段列表作为参数。

可能最好的聚合开始方式是模仿 find 方法。

编写一个聚合查询以选择所有 genres 字段包含 Comedy 的电影:

db.movies.aggregate([{$match: {"genres": "Comedy"}}])

这可能是最简单的聚合,它只包含一个阶段,即 $match 阶段,它告诉 MongoDB 你只想获取喜剧电影,因此第一个阶段的输出正好是这些。

在你的集合中,你既有 series 数据也有 movies 数据。让我们编写一个聚合管道来过滤出类型为电影且类型为 Comedy 的电影。然后,将它们分组在一起以找出喜剧电影的平均时长:

db.movies.aggregate([ {$match: {type: "movie", genres: "Comedy" } }, {$group: {_id: null, averageRuntime: { $avg: "$runtime" } } } ])

上述代码将返回以下输出:

[ { _id: null, averageRuntime: 98.86438291881745 } ]

这里是对前面聚合查询的更详细解释:

  • $match 定义了过滤文档的标准。在这种情况下,{type: "movie", genres: "Comedy"} 指定文档必须具有类型等于 movie,并且它们的 genres 数组中必须包含 Comedy 才能通过。

  • $group 阶段接受定义如何分组文档以及要对分组数据执行哪些计算的参数。

  • $group 阶段,_id 指定分组标准。将 _id 设置为 null 意味着所有从前一个阶段传递过来的文档将被聚合到一个单独的组中,而不是根据不同的字段值分成多个组。

  • $avg 是一个累加器运算符,在这里用于计算平均值。$runtime 指定每个文档的运行时字段应用于计算。

一旦数据按照你的要求分组和聚合,你就可以应用其他更简单的操作,例如排序、排序和限制。

摘要

本章详细介绍了定义 MongoDB 及其结构的基石。你已经看到了如何使用 MongoDB Atlas 在云端设置数据库,以及探索了创建、更新和删除文档的基础。此外,本章详细介绍了聚合管道框架——一个强大的分析工具。

下一章将展示如何使用 FastAPI 创建 API——这是一个令人兴奋且全新的 Python 框架。我们将提供一个最小化但完整的指南,介绍主要的概念和功能,希望这能让你相信构建 API 可以快速、高效且有趣。

第三章:Python 类型提示和 Pydantic

在探索 FastAPI 之前,了解一些将在 FastAPI 旅程中大量使用的 Python 概念是有用的。

Python 类型提示是语言中非常重要且相对较新的特性,它有助于开发者提高工作效率,为开发流程带来更大的健壮性和可维护性。类型使你的代码更易于阅读和理解,最重要的是,它们促进了良好的编程实践。

FastAPI 高度基于 Python 类型提示。因此,在深入研究框架之前,回顾类型提示的基本概念、它们是什么、如何实现以及它们的目的是有用的。这些基础知识将帮助你使用 FastAPI 创建健壮、可维护和可扩展的 API。

到本章结束时,你将对 Python 中类型注解在 FastAPI 和 Pydantic 中的作用有深入的理解。Pydantic 是一个现代 Python 库,它在运行时强制执行类型提示,当数据无效时提供可定制且用户友好的错误,并允许使用 Python 类型注解定义数据结构。

你将能够精确地建模你的数据,利用 Pydantic 的高级功能,使你成为一个更好的、更高效的 FastAPI 开发者。

本章将涵盖以下主题:

  • Python 类型提示及其用法

  • Pydantic 的概述及其主要功能,包括解析和验证数据

  • 数据反序列化和序列化,包括高级和特殊情况

  • 验证和数据转换、别名以及字段和模型级别的验证

  • 高级 Pydantic 使用,例如嵌套模型、字段和模型设置

技术要求

要运行本章中的示例应用程序,你应在本地计算机上安装 Python 版本 3.11.7(https://www.python.org/downloads/)或更高版本,一个虚拟环境,以及一些包。由于本章的示例不会使用 FastAPI,如果你愿意,你可以创建一个干净的虚拟环境,并使用以下命令安装 Pydantic:

pip install pydantic==2.7.1 pydantic_settings==2.2.1

在本章中,你将使用 Pydantic 以及一些与 Pydantic 相关的包,例如pydantic_settings

Python 类型

编程语言中存在的不同类型定义了语言本身——它们定义了其边界,并为可能实现的方式设定了一些基本规则,更重要的是,它们推荐了实现某种功能的方法。不同类型的变量有完全不同的方法和属性集合。例如,将字符串大写是有意义的,但将浮点数或整数列表大写则没有意义。

如果你已经使用 Python 一段时间了,即使是完成最平凡的任务,你也已经知道,就像每一种编程语言一样,它支持不同类型的数据——字符串和不同的数值类型,如整数和浮点数。它还拥有一个相当丰富的数据结构库:从字典到列表,从集合到元组,等等。

Python 是一种动态类型语言。这意味着变量的类型不是在编译时确定的,而是在运行时确定的。这个特性使得语言本身具有很大的灵活性,并允许你将一个变量声明为字符串,使用它,然后稍后将其重新赋值为列表。然而,改变变量类型的便捷性可能会使得更大、更复杂的代码库更容易出错。动态类型意味着变量的类型与其本身嵌入,并且易于修改。

在另一端的是所谓的静态类型语言:C、C++、Java、Rust、Go 等等。在这些语言中,变量的类型是在编译时已知的,并且不能随时间改变。类型检查是在编译时(即在运行时之前)进行的,错误是在运行时之前捕获的,因为编译器会阻止程序编译。

编程语言根据另一个不同的轴划分为不同的类别:强类型语言和弱类型语言。这个特性告诉我们语言对其类型限制到多大程度,以及从一个类型强制转换为另一个类型有多容易。例如,与 JavaScript 不同,Python 被认为是在这个光谱的较强一侧,当你在 Python 解释器中尝试执行非法操作时,解释器会发送强烈的消息,例如在 Python 解释器中输入以下内容以将dict类型添加到数字中:

>>>{}+3
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: unsupported operand type(s) for +: 'dict' and 'int'

因此,虽然 Python 会在你尝试执行不支持的操作时抱怨,但它只会在运行时这样做,而不是在执行代码之前。实际上,对于开发者来说,没有任何提示表明你正在编写的代码违反了 Python 的类型系统。

类型提示

正如你在上一节中看到的,Python 是一种动态类型语言,类型是在运行时才知道的。由于变量类型嵌入在变量的值中,作为一个开发者,仅通过查看它或使用你选择的 IDE 检查它,你无法知道代码库中遇到的变量的类型。幸运的是,Python 从 3.5 版本开始引入了一个非常受欢迎的特性——类型注解(https://peps.python.org/pep-0484/)。

类型注解或提示在 Python 中是额外的语法,它通知你,开发者,变量的预期类型。它们在运行时不被 Python 语言使用,并且以任何方式修改或影响你的程序的行为。你可能想知道,如果 Python 解释器甚至看不到它们,这些提示有什么用。

结果表明,几个重要的好处将使几乎任何代码库都更加健壮、更易于维护和面向未来:

  • 更快的代码开发:任何阅读你代码的开发者都会确切知道任何注释变量的类型——无论是整数还是浮点数,列表还是集合,这有助于加快开发速度。

  • 方法和属性知识:你将确切知道任何给定变量可用的哪些方法和属性。在大型代码库中意外更改变量的类型将被立即检测到。

  • 简化代码开发:代码编辑器和 IDE(如 Visual Studio Code)将提供出色的支持和自动完成(IntelliSense),进一步简化开发并减少开发者的认知负荷。

  • 自动代码生成:FastAPI 提供基于 Python 类型提示的自动和交互式(如完全功能的 REST API)文档,完全基于 Python 类型提示。

  • 类型检查器:这是最重要的好处。这些是在后台运行的程序,对你的代码进行静态分析,发现潜在问题并立即通知你。

  • 更易于阅读和更小的认知负荷:注释的代码更容易阅读,并且当你作为开发者需要处理代码并试图弄清楚它应该做什么时,它对你的认知负荷要小得多。

  • 强类型且灵活:保留了语言强类型和动态类型灵活性的特点,同时允许施加必要的安全要求和约束。虽然推荐用于大型代码库,但 Python 类型提示已深入 FastAPI 和 Pydantic,因此即使是小型项目,也至少需要了解类型以及如何使用它们。

类型提示是 FastAPI 的基础。结合 MongoDB 的灵活文档模式,它是 FARM 栈开发的支柱。类型提示确保你的应用程序数据流在系统中的每个时刻都保持正确的数据类型。虽然对于简单的端点来说这可能看起来微不足道——数量应该是整数,名称应该是字符串等——但是当你的数据结构变得更加复杂时,调试类型错误可能会变得非常繁琐。

类型提示也可以被定义为一种形式化——一种在运行时之前(静态)向类型检查器(在你的情况下是 Mypy)指示值类型的正式解决方案,这将确保当 Python 运行时遇到你的程序时,类型不会成为问题。

下一个部分将详细说明类型提示的语法、如何注释函数以及如何使用 Mypy 检查代码。

实现类型提示

让我们看看如何实现类型提示。创建一个名为 Chapter3 的目录,并在其中创建一个虚拟环境,如前所述。在此目录内,如果你想要能够精确地重现章节中的示例,请添加一个包含以下内容的 requirements.txt 文件:

mypy==1.10.0
pydantic==2.7.4

使用 requirements.txt 安装包:

pip install -r requirements.txt

现在,你已经准备好探索 Python 类型提示的世界了。

虽然有许多 Python 类型检查器——基本上是执行源代码静态分析而不运行它的工具——但我们将使用 mypy,因为它易于安装。稍后,你将拥有 Black 或 Ruff 等工具,这些工具会对你的源代码执行不同的操作,包括类型检查。

为了展示 Python 类型注解语法,一个简单的函数,如下所示,就足够了:

  1. 创建一个名为 chapter3_01.py 的文件并定义一个简单的函数:

    def print_name_x_times(name: str, times: int) -> None:
        for _ in range(times):
            print(name)
    

    之前的函数接受两个参数,name(一个字符串)和 times(一个整数),并返回 None,同时该函数会在控制台打印给定名称指定次数。如果你尝试在代码中调用该函数并开始输入参数,Visual Studio Code(或任何具有 Python 类型检查支持的 IDE)会立即建议第一个位置参数为字符串,第二个位置参数为整数。

  2. 你可以尝试输入错误的参数类型,例如,先输入一个整数,然后输入一个字符串,保存文件,并在命令行上运行 mypy

    mypy chapter3_01.py
    
  3. Mypy 将会通知你存在两个错误:

    types_testing.py:8: error: Argument 1 to "print_name_x_times" has incompatible type "int"; expected "str"  [arg-type]
    types_testing.py:8: error: Argument 2 to "print_name_x_times" has incompatible type "str"; expected "int"  [arg-type]
    Found 2 errors in 1 file (checked 1 source file)
    

这个例子足够简单,但再次看看 Python 增强提案 8PEP 8)在另一个例子中对类型提示语法的建议:

  1. 插入一个具有值的简单变量:

    text: str = "John"
    

    冒号紧接在变量后面(没有空格),冒号后有一个空格,并且在提供值的情况下,等号周围有空格。

  2. 当注释函数的输出时,由破折号和大于号组成的“箭头”(->)应该被一个空格包围,如下所示:

    def count_users(users: list[str]) -> int:
        return len(users)
    

    到目前为止,你已经看到了简单的注解,这些注解将变量限制为一些 Python 原始类型,包括整数和字符串。类型注解可以更加灵活:你可能希望允许变量接受几种不同的变量类型,例如整数和字符串。

  3. 你可以使用 typing 模块的 Union 包来实现这一点:

    from typing import Union
    x: Union(str, int)
    
  4. 之前定义的 x 变量可以接受字符串或整数值。实现相同功能的一种更现代和简洁的方式如下:

    x: str | int
    

这些注解意味着变量 x 可以是整数,也可以接受 string 类型的值,这与整数的类型不同。

typing 模块包含几种所谓的泛型,包括以下几种:

  • List:用于应该为列表类型的变量

  • Dict:用于字典

  • Sequence:用于任何类型的值序列

  • Callable:用于可调用对象,例如函数

  • Iterator:表示一个函数或变量接受一个迭代器对象(一个实现迭代器协议并可用于 for 循环的对象)

注意

鼓励你探索 typing 模块,但请记住,该模块中的类型正在逐渐被导入到 Python 的代码功能中。

例如,List 类型在处理 FastAPI 时非常有用,因为它允许你快速高效地将项目或资源的列表序列化为 JSON 输出。

List 类型的例子如下,在一个名为 chapter3_02.py 的新文件中:

from typing import List
def square_numbers(numbers: List[int]) -> List[int]:
    return [number ** 2 for number in numbers]
# Example usage
input_numbers = [1, 2, 3, 4, 5]
squared_numbers = square_numbers(input_numbers)
print(squared_numbers)  # Output: [1, 4, 9, 16, 25]

另一个有用的类型是 Literal,它将变量的可能值限制为几个可接受的状态:

from typing import Literal
account_type: Literal["personal", "business"]
account_type = "name"

前面的几行展示了类型提示的力量。将 account_type 变量分配给字符串本身并没有什么错误,但这个字符串不是可接受状态集的一部分,因此 Mypy 会抱怨并返回一个 Incompatible types in assignment 错误。

现在,看看一个包含 datetime 参数的例子。创建一个名为 chapter3_03.py 的新文件:

from datetime import datetime
def format_datetime(dt: datetime) -> str:
     return dt.strftime("%Y-%m-%d %H:%M:%S")
now = datetime.now()
print(format_datetime(now))

之前定义的函数接受一个参数——一个 datetime 对象,并输出一个字符串:一个格式良好的日期和时间,适用于在网站上显示。如果你在 Visual Studio Code 编辑器中尝试输入 dt 然后一个点,你将收到自动完成系统的提示,提供与 datetime 对象相关的所有方法和属性。

要声明一个结构为字典列表(对任何使用基于 JSON 的 API 的人来说都非常熟悉),你可以使用如下方式,在一个名为 chapter3_04.py 的文件中:

def get_users(id: int) -> list[dict]:
    return [
        {"id": 1, "name": "Alice"},
        {"id": 2, "name": "Bob"},
        {"id": 3, "name": "Charlie"},
    ]

在介绍了 Python 中的基本注解类型之后,接下来的几节将探讨一些更高级的类型,这些类型在处理 FastAPI 和 Pydantic 时非常有用。

高级注解

你迄今为止看到的注解非常简单,仅传达与变量、函数、类参数或输出相关的特定所需类型的基本信息。Python 的类型系统功能更强大,它可以用来进一步限制允许的变量状态,并防止你,作为开发者,在代码中创建不可能或非法的状态。

最常用的类型如下:

  • Optional 类型用于以明确和开发者友好的方式处理可选值和 None 值。

  • Union 类型允许你定义可能类型的联合,例如整数和字符串。现代 Python 使用管道运算符(|),如前例所示。

  • self 类型用于表示值将是某个类的实例,这在 Pydantic 模型验证器中非常有用,正如我们稍后将要看到的。

  • New 类型允许开发者基于现有类型定义全新的类型。

本节详细介绍了 Python 类型提示、它们的目的以及它们的实现方式。下一节将更深入地探讨 Pydantic,FastAPI 数据验证的得力助手。

Pydantic

Pydantic 是一个数据验证库,在其网站上被标记为 Python 最广泛使用的验证库。它允许您以细粒度的方式对数据进行建模,并在 Python 类型提示系统中牢固地扎根,同时执行各种类型的验证。实际版本 V2 将代码的关键部分重写为Rust以提高速度,并提供了出色的开发者体验。以下列表描述了使用 Pydantic 的一些好处:

  • 基于标准库中的类型提示:您无需学习虚构的新系统或术语,只需学习纯 Python 类型即可。

  • 卓越的速度:FastAPI 和 MongoDB 的各个方面都围绕着速度——以创纪录的时间交付快速且响应迅速的应用程序——因此拥有一个快速的验证和解析库是强制性的。Pydantic 的核心是用 Rust 编写的,这确保了数据操作的高速运行。

  • 庞大的社区支持和广泛采用:当与 Django Ninja、SQLModel、LangChain 等流行包一起工作时,学习 Pydantic 将非常有用。

  • JSON schema 的发射可能性:它有助于与其他系统集成。

  • 更多灵活性:Pydantic 支持不同的模式(在强制转换方面严格和宽松)以及几乎无限定制的选项和灵活性。

  • 深受开发者喜爱:它已被下载超过 7000 万次,PyPI 上有超过 8000 个包依赖于 Pydantic(截至 2024 年 7 月)。

注意

您可以在其文档中详细了解 Pydantic:docs.pydantic.dev/latest/

从广义上讲,Pydantic 在现代 Web 开发工作流程中解决了许多重要问题。它确保输入到您的应用程序中的数据是正确形成和格式化的,位于期望的范围内,具有适当类型和尺寸,并且安全且无错误地到达文档存储库。

Pydantic 还确保您的应用程序输出的数据与预期和规范完全一致,省略了不应公开的字段(如用户密码),甚至包括与不兼容系统交互等更复杂的任务。

FastAPI 站在两个强大的 Python 库——Starlette 和 Pydantic 的肩膀上。虽然 Starlette 负责框架的 Web 相关方面,通常通过 FastAPI 提供的薄包装、实用函数和类来实现,但 Pydantic 负责 FastAPI 的非凡开发者体验。Pydantic 是 FastAPI 的基础,利用其强大的功能为所有 FARM 堆栈开发者打开了竞技场。

虽然类型检查是在静态(不运行代码)的情况下执行的,但 Pydantic 在运行时的作用很明显,并扮演着输入数据的守护者角色。你的 FastAPI 应用将从用户那里接收数据,从灵活的 MongoDB 数据库模式中接收数据,以及通过 API 从其他系统接收数据——Pydantic 将简化解析和数据验证。你不需要为每个可能的无效情况编写复杂的验证逻辑,只需创建与你的应用程序需求尽可能匹配的 Pydantic 模型即可。

在接下来的部分中,你将通过具有递增复杂性和要求的示例来探索 Pydantic 的大部分功能,因为我们认为这是熟悉库的最佳和最有效的方式。

Pydantic 基础知识

与一些提供类似功能的其他库(如dataclasses)不同,Pydantic 提供了一个基类(恰当地命名为BaseModel),通过继承实现了解析和验证功能。由于你将在接下来的部分中构建用户模型,你可以先列出需要与你的用户关联的最基本数据。至少,你需要以下内容:

  • 用户名

  • 电子邮件地址

  • 一个 ID(目前保持为整数)

  • 出生日期

在 Pydantic 中,一个与该规范相关联的用户模型可能如下所示,在一个名为chapter3_05.py的文件中:

from datetime import datetime
from pydantic import BaseModel
class User(BaseModel):
    id: int
    username: str
    email: str
    dob: datetime

User类已经为你处理了很多工作——在类实例化时立即执行验证和解析,因此不需要执行验证检查。

构建类的过程相当直接:每个字段都有一个类型声明,Pydantic 准备好通知你任何可能遇到的错误类型。

如果你尝试创建一个用户,你不应该看到任何错误:

Pu = User(id=1, username="freethrow", email="email@gmail.com", dob=datetime(1975, 5, 13))

然而,如果你创建了一个包含错误数据的用户,并且方便地导入了 Pydantic 的ValidationError

from pydantic import BaseModel, ValidationError
try:
    u = User(
        id="one",
        username="freethrow",
        email="email@gmail.com",
        dob=datetime(1975, 5, 13),
    )
    print(u)
except ValidationError as e:
    print(e)

当你运行程序时,Pydantic 会通知你数据无法验证:

1 validation error for User
id
  Input should be a valid integer, unable to parse string as an integer [type=int_parsing, input_value='one', input_type=str]

Pydantic 的错误信息,源自ValidationError,是故意设计得信息丰富且精确的。出现错误的字段被称作id,错误类型也会被描述。首先想到的有用之处是,如果有多个错误——例如,你可能提供了一个无效的datetime——Pydantic 不会在第一个错误处停止。它会继续解析整个实例,并输出一个错误列表,这个列表可以很容易地以 JSON 格式输出。这实际上是在处理 API 时的期望行为;你希望能够列出所有错误,例如,向发送了错误数据的后端用户。异常包含了一个遇到的所有错误的列表。

模型保证实例在验证通过后,将包含所需的字段,并且它们的类型是正确的。

你还可以根据类型提示约定提供默认值和可空类型:

class User(BaseModel):
    id: int = 2
    username: str
    email: str
    dob: datetime
    fav_colors: list[str] | None = ["red", "blue"]

之前的模型有一个默认的 id 值(这在实践中可能不是你想要做的)以及一个作为字符串的喜欢的颜色列表,这些也可以是 None

当你创建并打印一个模型(或者更准确地说,当你通过 print 函数调用它的表示时),你会得到一个漂亮的输出:

id=2 username='marko' email='email@gmail.com' dob=datetime.datetime(1975, 5, 13, 0, 0) fav_colors=None

Pydantic 默认以宽松模式运行,这意味着它会尝试将提供的类型强制转换为模型中声明的类型。例如,如果你将用户 ID 作为字符串 "2" 传递给模型,将不会出现任何错误,因为 Pydantic 会自动将 ID 转换为整数。

虽然字段可以通过点符号(user.id)访问并且可以轻松修改,但这不建议这样做,因为验证规则将不会应用。你可以创建一个具有 id 值为 5 的用户实例,访问 user.id,并将其设置为字符串 "five",但这可能不是你想要的。

除了纯粹的数据验证之外,Pydantic 还为你的应用程序提供了其他重要的功能。Pydantic 模型中最广泛使用的操作包括以下内容:

  • 数据反序列化:将数据摄入模型

  • 数据序列化:将验证后的数据从模型输出到 Python 数据结构或 JSON

  • 数据修改:实时清理或修改数据

接下来的几节将更详细地查看这些操作的每一个。

反序列化

反序列化是指向模型提供数据的过程,这是输入阶段,与序列化过程相对,序列化意味着以期望的形式输出模型数据。反序列化与验证紧密相关,因为验证和解析过程是在实例化模型时执行的,尽管这可以被覆盖。

在 Pydantic 中,ValidationError 是 Pydantic 类型,当数据无法成功解析为模型实例时会被抛出。

虽然你已经通过实例化基于 Pydantic 的用户模型执行了一些验证,但待验证的数据通常以字典的形式传递。以下是一个将数据作为字典传递的示例,文件名为 chapter3_06.py

创建你的用户模型的另一个版本,并传递一个包含数据的字典:

class User(BaseModel):
    id: int
    username: str
    email: str
    password: str
user = User.model_validate(
    {
        "id": 1,
        "username": "freethrow",
        "email": "email@gmail.com",
        "password": "somesecret",
    }
)
print(user)

.model_validate() 方法是一个辅助方法,它接受一个 Python 字典并执行类实例化和验证。这个方法在一步中创建你的 user 实例并验证数据类型。

类似地,model_validate_json() 接受一个 JSON 字符串(当与 API 一起工作时很有用)。

也可以使用 model_construct() 方法不进行验证来构建模型实例,但这有非常特定的用户场景,并且在大多数情况下不推荐使用。

你已经学会了如何将数据传递给你的简单 Pydantic 模型。下一个部分将更详细地查看模型字段及其属性。

模型字段

Pydantic 字段基于 Python 类型,设置它们为必需或可空并提供默认值是直观的。例如,要为字段创建默认值,只需在模型中提供它作为值即可,而可空字段遵循你在 Python 类型 部分中看到的相同约定——通过使用 typing 模块的旧联合语法,或使用带有管道操作符的新语法。

以下是一个名为 chapter3_07.py 的文件中另一个用户模型的示例:

  1. 插入一些默认值:

    from pydantic import BaseModel
    from typing import Literal
    class UserModel(BaseModel):
        id: int
        username: str
        email: str
        account: Literal["personal", "business"] | None = None
        nickname: str | None = None
    

    之前定义的 UserModel 类定义了一些标准的字符串类型字段:一个账户可以有两个确切值或等于 None,以及一个昵称可以是字符串或 None

  2. 你可以使用 model_fields 属性如下检查模型:

    print(UserModel.model_fields)
    

    你将获得一个方便的列表,其中包含属于该模型的所有字段及其信息,包括它们的类型和是否为必需项:

    {'id': FieldInfo(annotation=int, required=True), 'username': FieldInfo(annotation=str, required=True), 'email': FieldInfo(annotation=str, required=True), 'account': FieldInfo(annotation=Union[Literal['personal', 'business'], NoneType], required=False, default=None), 'nickname': FieldInfo(annotation=Union[str, NoneType], required=False, default=None)}
    

下一个部分将详细介绍 Pydantic 特定的类型,这些类型使得使用库更加容易和快速。

Pydantic 类型

虽然 Pydantic 基于标准 Python 类型,如字符串、整数、字典和集合,这使得它对于初学者来说非常直观和简单,但该库还提供了一系列针对常见情况的定制和解决方案。在本节中,你将了解其中最有用的。

严格的类型,如 StrictBoolStrictIntStrictStr 和其他 Pydantic 特定类型,是只有当验证的值属于这些类型时才会通过验证的类型,没有任何强制转换:例如,StrictInt 必须是 Integer 类型,而不是 "1"1.0

限制类型为现有类型提供额外的约束。例如,condate() 是一个具有大于、大于等于、小于和小于等于约束的日期类型。conlist() 包装列表类型并添加长度验证,或可以强制规则,即包含的项必须是唯一的。

Pydantic 不仅限于验证原始类型,如字符串和整数。许多额外的验证器涵盖了你在建模业务逻辑时可能遇到的大多数使用情况。例如,email 验证器验证电子邮件地址,由于它不是 Pydantic 核心包的一部分,因此需要使用以下命令单独安装:

pip install pydantic[email]

Pydantic 网站(https://docs.pydantic.dev/latest/api/types/)提供了一个全面的附加验证类型列表,这些类型扩展了功能——列表可以有最小和最大长度,唯一性可以是必需的,整数可以是正数或负数,等等,例如 CSS 颜色代码。

Pydantic 字段

虽然简单的 Python 类型注解在许多情况下可能足够,但 Pydantic 的真正力量开始在你开始使用 Field 类为字段定制模型并添加元数据到模型字段时显现出来。

让我们看看如何使用上一节中探讨的 UserModelField 类。创建一个文件,并将其命名为 chapter3_08.py

首先,使用 Field 类重写你之前的 UserModel

from typing import Literal
from pydantic import BaseModel, Field
class UserModelFields(BaseModel):
    id: int = Field(…)
    username: str = Field(…)
    email: str = Field(…)
    account: Literal["personal", "business"] | None = Field(default=None)
    nickname: str | None = Field(default=None)

此模型与之前定义的没有字段的模型等效。第一个语法差异可以在提供默认值的方式中看到——Field 类接受一个显式定义的默认值。

字段还通过使用别名提供了额外的模型灵活性,正如你将在下一节中看到的。

字段别名

字段允许你创建和使用别名,这在处理需要与你的基于 Pydantic 的数据定义兼容的不同系统时非常有用。创建一个名为 chapter3_09.py 的文件。假设你的应用程序使用 UserModelFields 模型来处理用户,但也需要能够从另一个系统接收数据,可能通过基于 JSON 的 API,而这个其他系统发送的数据格式如下:

external_api_data = {
    "user_id": 234,
    "name": "Marko",
    "email": "email@gmail.com",
    "account_type": "personal",
    "nick": "freethrow",
}

这种格式明显不符合你的 UserModelFields 模型,而别名提供了一种优雅地处理这种不兼容性的方法:

class UserModelFields(BaseModel):
    id: int = Field(alias="user_id")
    username: str = Field(alias="name")
    email: str = Field()
    account: Literal["personal", "business"] | None = Field(
        default=None, alias="account_type"
    )
    nickname: str | None = Field(default=None, alias="nick")

此更新后的模型为所有具有不同名称的字段提供了别名,因此可以验证你的外部数据:

user = UserModelFields.model_validate(external_api_data)

在这种情况下,你已经使用了简单的 alias 参数,但还有其他选项用于别名,用于序列化或仅用于验证。

此外,Field 类允许以不同的方式约束数值,这是 FastAPI 中广泛使用的一个特性。创建一个名为 chapter3_10.py 的文件并开始填充它。

假设你需要模拟一个具有以下字段的棋类活动:

from datetime import datetime
from uuid import uuid4
from pydantic import BaseModel, Field
class ChessTournament(BaseModel):
    id: int = Field(strict=True)
    dt: datetime = Field(default_factory=datetime.now)
    name: str = Field(min_length=10, max_length=30)
    num_players: int = Field(ge=4, le=16, multiple_of=2)
    code: str = Field(default_factory=uuid4)

在这个相对简单的课程中,Pydantic 字段引入了一些复杂的验证规则,否则这些规则将非常冗长且难以编写:

  • dt:锦标赛的 datetime 对象使用 default_factory 参数,这是一个在实例化时调用的函数,它提供了默认值。在这种情况下,值等于 datetime.now

  • name:此字段有一些长度约束,例如最小和最大长度。

  • 注册球员的数量受到限制:它必须大于或等于 4,小于或等于 16,并且还必须是偶数——2 的倍数,以便所有球员都能在每一轮比赛中进行比赛。

  • uuid 库。

  • id:此字段是一个整数,但这次你应用了 strict 标志,这意味着你覆盖了 Pydantic 的默认行为,不允许像 "3" 这样的字符串通过验证,即使它们可以被转换为整数。

注意

Pydantic 文档中的一个有用页面专门介绍了字段:https://docs.pydantic.dev/latest/concepts/fields/。Field 类提供了许多验证选项,建议在开始建模过程之前浏览一下。

下一节将详细介绍如何通过反序列化过程从模型中获取数据。

序列化

任何解析和验证库最重要的任务是数据序列化(或数据导出)。这是将模型实例转换为 Python 字典或 JSON 编码字符串的过程。生成 Python 字典的方法是 model_dump(),如下面的用户模型示例所示,在一个名为 chapter3_11.py 的新文件中。

要在 Pydantic 中使用电子邮件验证,请将以下行添加到 requirements.txt 文件中:

email_validator==2.1.1

然后,重新运行用户模型:

pip install -r requirements.txt
class UserModel(BaseModel):
    id: int = Field()
    username: str = Field(min_length=5, max_length=20)
    email: EmailStr = Field()
    password: str = Field(min_length=5, max_length=20, pattern="^[a-zA-Z0-9]+$")

您正在使用的用户模型是一个相当标准的模型,并且,凭借您对 Pydantic 字段的了解,您已经可以理解它。有几个新的验证,但它们是直观的:从 Pydantic 导入的 EmailStr 对象是一个验证电子邮件地址的字符串,而 password 字段包含一个额外的正则表达式,以确保该字段只包含字母数字字符,没有空格。以下是一个例子:

  1. 创建模型的一个实例并将其序列化为 Python 字典:

    u = UserModel(
        id=1,
        username="freethrow",
        email="email@gmail.com",
        password="password123",
    )
    print(u.model_dump())
    

    结果是一个简单的 Python 字典:

    {'id': 1, 'username': 'freethrow', 'email': 'email@gmail.com', 'password': 'password123'}
    
  2. 尝试将模型导出为 JSON 表示形式并出于安全原因省略密码:

    print(u.model_dump_json(exclude=set("password"))
    

    结果是一个省略密码的 JSON 字符串:

    {"id":1,"username":"freethrow","email":"email@gmail.com"}
    

序列化默认使用字段名而不是别名,但这是可以通过将 by_alias 标志设置为 True 来轻松覆盖的另一个设置。

在使用 FastAPI 和 MongoDB 时,一个使用的别名示例是 MongoDB 的 ObjectId 字段,它通常序列化为字符串。另一个有用的方法是 model_json_schema(),它为模型生成 JSON 模式。

模型可以通过 ConfigDict 对象进行额外配置,以及一个名为 model_config 的特殊字段——该名称是保留的且必须的。在以下名为 chapter3_12.py 的文件中,您使用 model_config 字段允许通过名称填充模型并防止向模型传递额外的数据:

from pydantic import BaseModel, Field, ConfigDict, EmailStr
class UserModel(BaseModel):
    id: int = Field()
    username: str = Field(min_length=5, max_length=20, alias="name")
    email: EmailStr = Field()
    password: str = Field(min_length=5, max_length=20, pattern="^[a-zA-Z0-9]+$")
    model_config = ConfigDict(extra="forbid", populate_by_name=True)

model_config 字段允许对模型进行额外配置。例如,extra 关键字指的是传递给反序列化过程的数据字段:默认行为是简单地忽略这些数据。

在此示例中,我们将 extra 设置为 forbid,因此任何传递的额外数据(未在模型中声明)将引发验证错误。"populate_by_name" 是另一个有用的设置,因为它允许我们使用字段名而不是仅使用别名来填充模型,实际上是将两者混合使用。您将看到,当构建需要与不同系统通信的 API 时,此功能非常方便。

自定义序列化器

当涉及到序列化时,Pydantic 几乎可以提供无限的能力,并且还提供了不同的序列化方法,用于 Python 和 JSON 输出,这通过使用@field_serializer装饰器来实现。

注意

Python 装饰器是一种强大而优雅的特性,允许您在不更改实际代码的情况下修改或扩展函数或方法的行为。

装饰器是高阶函数,它接受一个函数作为输入,添加一些功能,并返回一个新的、装饰过的函数。这种方法促进了 Python 程序的可重用性、模块化和关注点的分离。

在以下示例中,您将创建一个非常简单的银行账户模型,并使用不同类型的序列化器。您的需求是将余额精确四舍五入到两位小数,并且在序列化为 JSON 时,将updated字段格式化为 ISO 格式:

  1. 创建一个名为chapter3_13.py的新文件,并添加一个简单的银行账户模型,该模型只包含两个字段:余额和最后账户更新时间:

    from datetime import datetime
    from pydantic import BaseModel, field_serializer
    class Account(BaseModel):
        balance: float
        updated: datetime
        @field_serializer("balance", when_used="always")
        def serialize_balance(self, value: float) -> float:
            return round(value, 2)
        @field_serializer("updated", when_used="json")
        def serialize_updated(self, value: datetime) -> str:
           return value.isoformat()
    

    您已添加了两个自定义序列化器。第一个是余额序列化器(如字符串"balance"所示),它将始终被使用。这个序列化器简单地将余额四舍五入到两位小数。第二个序列化器仅用于 JSON 序列化,并将日期返回为 ISO 格式的日期时间字符串。

  2. 如果您尝试填充模型并检查序列化,您将看到序列化器如何修改了初始默认输出:

    account_data = {
        "balance": 123.45545,
        "updated": datetime.now(),
    }
    account = Account.model_validate(account_data)
    print("Python dictionary:", account.model_dump())
    print("JSON:", account.model_dump_json())
    

    您将得到类似的输出:

    Python dictionary: {'balance': 123.46, 'updated': datetime.datetime(2024, 5, 2, 21, 34, 11, 917378)}
    JSON: {"balance":123.46,"updated":"2024-05-02T21:34:11.917378"}
    

在本章的早期部分,您已经看到了通过仅实例化模型类所提供的 Pydantic 基本验证。下一节将讨论 Pydantic 的各种自定义验证方法,以及如何借助 Pydantic 装饰器来利用这些方法,从而超越序列化并提供强大的自定义验证功能。

自定义数据验证

与自定义字段序列化器类似,自定义字段验证器作为装饰器实现,使用@field_validator装饰器。

字段验证器是类方法,因此它们必须接收整个类作为第一个参数,而不是实例,第二个值是要验证的字段名称(或字段列表,或*符号表示所有字段)。

字段验证器应返回解析后的值或一个ValueError响应(或AssertionError),如果传递给验证器的数据不符合验证规则。与其他 Pydantic 功能一样,从示例开始要容易得多。创建一个名为chapter3_14.py的新文件,并插入以下代码:

from pydantic import BaseModel,  field_validator
class Article(BaseModel):
    id: int
    title: str
    content: str
    published: bool
    @field_validator("title")
    @classmethod
    def check_title(cls, v: str) -> str:
        if "FARM stack" not in v:
            raise ValueError('Title must contain "FARM stack"')
        return v.title()

验证器在类实例化之前运行,并接受类和验证的字段名称作为参数。check_title验证器检查标题是否包含字符串"FARM stack",如果不包含,则抛出ValueError。此外,验证器返回标题大写的字符串,因此我们可以在字段级别执行数据转换。

虽然字段验证器提供了很大的灵活性,但它们并没有考虑字段之间的交互和字段值的组合。这就是模型验证器发挥作用的地方,下一节将详细说明。

模型验证器

在执行与网络相关数据的验证时,另一个有用的功能是模型验证——在模型级别编写验证函数的可能性,允许各种字段之间进行复杂的交互。

模型验证器可以在实例化模型类之前或之后运行。我们再次将关注一个相当简单的例子:

  1. 首先,创建一个新文件,并将其命名为chapter3_15.py

  2. 假设你有一个具有以下结构的用户模型:

    from pydantic import BaseModel, EmailStr, ValidationError, model_validator
    from typing import Any, Self
    class UserModelV(BaseModel):
        id: int
        username: str
        email: EmailStr
        password1: str
        password2: str
    

    该模型与之前的模型一样简单,它包含两个密码字段,这两个字段必须匹配才能注册新用户。此外,你还想施加另一个验证——通过反序列化进入模型的 数据不得包含私有数据(如社会保险号码或卡号)。模型验证器允许你执行此类灵活的验证。

  3. 继续上一个模型,你可以在类定义下编写以下模型验证器:

    @model_validator(mode='after')
    def check_passwords_match(self) -> Self:
        pw1 = self.password1
        pw2 = self.password2
        if pw1 is not None and pw2 is not None and pw1 != pw2:
            raise ValueError('passwords do not match')
        return self
    @model_validator(mode='before')
    @classmethod
    def check_private_data(cls, data: Any) -> Any:
        if isinstance(data, dict):
            assert (
                'private_data' not in data
            ), 'Private data should not be included'
        return data
    
  4. 现在,尝试验证以下数据:

    usr_data = {
        "id": 1,
        "username": "freethrow",
        "email": "email@gmail.com",
        "password1": "password123",
        "password2": "password456",
        "private_data": "some private data",
    }
    try:
        user = UserModelV.model_validate(usr_data)
        print(user)
    except ValidationError as e:
        print(e)
    

    你只会被告知一个错误——与before模式相关的错误,指出不应包含私有数据。

  5. 如果你取消注释或删除设置private_data字段的行并重新运行示例,错误将变为以下内容:

    Value error, passwords do not match [type=value_error, input_value={'id': 1, 'username': 'fr...ssword2': 'password456'}, input_type=dict]
    

在上一个例子中涉及了一些新概念;你正在使用 Python 的Self类型,它是为了表示包装类的实例而引入的,因此你实际上期望输出是UserModelV类的实例。

check_private_data函数中,还有一个新概念,它检查传递给类的数据是否是字典的实例,然后继续验证字典中是否包含不希望的private_data字段——这只是 Pydantic 检查传递数据的途径,因为它存储在字典内部。

下一节将详细介绍如何使用 Pydantic 组合嵌套模型以验证越来越复杂的模型。

嵌套模型

如果你来自基本的 MongoDB 背景,那么通过组合在 Pydantic 中对嵌套模型的处理非常简单直观。要了解如何实现嵌套模型,最简单的方法是从需要验证的现有数据结构开始,并通过 Pydantic 进行操作:

  1. 从返回汽车品牌和型号(或模型)的 JSON 文档结构开始。创建一个名为 chapter3_16.py 的新文件,并添加以下代码行:

    car_data = {
        "brand": "Ford",
        "models": [
            {"model": "Mustang", "year": 1964},
            {"model": "Focus", "year": 1975},
            {"model": "Explorer", "year": 1999},
        ],
        "country": "USA",
    }
    

    您可以从数据结构内部开始,识别最小的单元或最深层嵌套的结构——在这个例子中,最小的单元是 1964 年的福特野马车型。

  2. 这可以是第一个 Pydantic 模型:

    class CarModel(BaseModel):
        model: str
        year: int
    
  3. 一旦完成这个初步的抽象,创建品牌模型就变得容易了:

    class CarBrand(BaseModel):
        brand: str
        models: List[CarModel]
        country: str
    

汽车品牌型号有独特的名称和产地,并包含一系列车型。

模型字段可以是其他模型(或列表、集合或其他序列)并且这个特性使得将 Pydantic 数据结构映射到数据,尤其是 MongoDB 文档,变得非常愉快和直观。

虽然 MongoDB 可以支持多达 100 层的嵌套,但在您的数据建模过程中,您可能不会达到这个限制。然而,值得注意的是,Pydantic 将在您深入数据结构时支持您。从 Python 端嵌入数据也变得更加容易管理,因为您可以确信进入您集合的数据是按照预期存储的。

下一节和最后一节将详细介绍 Pydantic 提供的另一个有用工具——在处理环境变量和设置时提供一些帮助,这是每个与网络相关的项目都会遇到的问题。

Pydantic Settings

Pydantic Settings 是一个外部包,需要单独安装。它提供了从环境变量或秘密文件中加载设置或配置类的 Pydantic 功能。

这基本上是 Pydantic 网站上的定义(docs.pydantic.dev/latest/concepts/pydantic_settings/),整个概念围绕着 BaseSettings 类展开。

尝试从此类继承的模型会尝试通过扫描环境来读取任何作为关键字参数传递的字段值。

这种简单的功能允许您从环境变量中定义清晰和直接的配置类。Pydantic 设置也可以自动获取环境修改,并在需要时手动覆盖测试、开发或生产中的设置。

在接下来的练习中,您将创建一个简单的 pydantic_settings 设置,这将允许您读取环境变量,并在必要时轻松覆盖它们:

  1. 使用 pip 安装 Pydantic settings:

    pip install pydantic-settings
    
  2. 在与项目文件同一级别的位置创建一个 .env 文件:

    API_URL=https://api.com/v2
    SECRET_KEY=s3cretstr1n6
    
  3. 现在,您可以设置一个简单的 Settings 配置(chapter3_17.py 文件):

    from pydantic import Field
    from pydantic_settings import BaseSettings
    class Settings(BaseSettings):
        api_url: str = Field(default="")
        secret_key: str = Field(default="")
        class Config:
            env_file = ".env"
    print(Settings().model_dump())
    
  4. 如果您运行此代码,Python 和 .env 文件位于同一路径,您将看到 Pydantic 能够从 .env 文件中读取环境变量:

    {'api_url': 'https://api.com/v2', 'secret_key': 's3cretstr1n6'}
    

    然而,如果您设置了环境变量,它将优先于 .env 文件。

  5. 你可以通过在 Settings() 调用之前添加此行来测试它,并观察程序的输出:

    os.environ["API_URL"] = 'http://localhost:8000'
    

Pydantic 设置使得管理配置,如 Atlas 和 MongoDB 的 URL、密码散列的秘密以及其他配置,变得更加结构化和有序。

摘要

本章详细介绍了 Python 的一些方面,这些方面要么是新的且仍在发展中,要么通常被简单地忽视,例如类型提示,以及它们的使用可能对你的项目产生的影响。

FastAPI 基于 Pydantic 和类型提示。与这些稳固的原则和约定一起工作,将使你的代码更加健壮、可维护和面向未来,即使在与其他框架一起工作时也是如此。你已经拥有坚实的 Python 类型基础,并学习了 Pydantic 提供的基本功能——验证、序列化和反序列化。

你已经学会了如何通过 Pydantic 反序列化、序列化和验证数据,甚至在过程中添加一些转换,创建更复杂的结构。

本章已为你提供了学习更多 FastAPI 的网络特定方面的能力,以及如何在 MongoDB、Python 数据结构和 JSON 之间无缝混合数据。

下一章将探讨 FastAPI 及其 Pythonic 基础。

第四章:快速入门 FastAPI

应用程序编程接口API)是您的 FARM 堆栈的基石,作为系统的“大脑”。它实现了业务逻辑,决定了数据如何进出系统,但更重要的是,它如何与系统内的业务需求相关联。

如同 FastAPI 这样的框架,通过示例更容易展示。在本章中,您将探索一些简单的端点,这些端点构成了一个最小、自包含的 REST API。这些示例将帮助您了解 FastAPI 如何处理请求和响应。

本章重点介绍该框架,以及标准 REST API 实践及其在 FastAPI 中的实现。您将学习如何发送请求并根据您的需求修改它们,以及如何从 HTTP 请求中检索所有数据,包括参数和请求体。您还将了解如何处理响应,以及您如何可以使用 FastAPI 轻松设置 cookies、headers 和其他标准网络相关主题。

本章将涵盖以下主题:

  • FastAPI 框架概述

  • 简单 FastAPI 应用的设置和需求

  • FastAPI 中的 Python 特性,例如类型提示、注解和async/await语法

  • FastAPI 如何处理典型的 REST API 任务

  • 处理表单数据

  • FastAPI 项目的结构和路由

技术要求

对于本章,您需要以下内容:

  • Python 设置

  • 虚拟环境

  • 代码编辑器和插件

  • REST 客户端

以下部分将更详细地介绍这些要求。

Python 设置

如果您还没有安装 Python,请访问 Python 下载网站([www.python.org/downloads/](https://www.python.org/downloads/))以获取您操作系统的安装程序。在本书中,您将使用版本 3.11.7或更高版本。

FastAPI 严重依赖于 Python 提示和注解,Python 3.6 之后的版本以类似现代的方式处理类型提示;因此,虽然理论上任何高于 3.6 的版本都应该可以工作,但本书中的代码使用 Python 版本 3.11.7,出于兼容性的原因。

确保您的 Python 安装已升级到最新的 Python 版本之一——如前所述,至少为版本 3.11.7——并且是可访问的且是默认版本。您可以通过以下方式进行检查:

  • 在您选择的终端中键入python

  • 使用pyenv,一个方便的工具,可以在同一台机器上管理多个 Python 版本。

虚拟环境

如果您之前曾经参与过 Python 项目,那么您可能需要包含一些,如果不是几十个,Python 第三方包。毕竟,Python 的主要优势之一在于其庞大的生态系统,这也是它被选为 FARM 堆栈的主要原因之一。

不深入探讨 Python 如何管理第三方包的安装细节,让我们先概述一下,如果你决定为所有项目仅使用一个 Python 安装,或者更糟糕的是,如果这个安装是默认操作系统的 Python 安装,可能会出现的主要问题。

下面是一些挑战:

  • 操作系统在 Python 版本方面通常滞后,所以最新的几个版本可能不可用。

  • 包将安装到相同的命名空间或相同的包文件夹中,这会在任何依赖于该包的应用程序或包中造成混乱。

  • Python 包依赖于其他包,这些包也有版本。假设你正在使用包 A,它依赖于包 B 和 C,并且由于某种原因,你需要将包 B 保持在一个特定的版本(即 1.2.3)。你可能需要包 B 用于完全不同的项目,而这个项目可能需要不同的版本。

  • 减少或无法复现:没有单独的 Python 虚拟环境,将很难快速复制所有必需的包所需的功能。

Python 虚拟环境是解决上述问题的解决方案,因为它们允许你在一个纯净的 Python 开发环境中工作,只包含你需要的包和包版本。在我们的例子中,虚拟环境将肯定包括核心包:FastAPI 和 Uvicorn。另一方面,FastAPI 依赖于 Starlette、Pydantic 等,因此控制包版本非常重要。

Python 开发的最佳实践指出,无论项目大小如何,每个项目都应该有自己的虚拟环境。虽然有多种创建虚拟环境的方法,它是一个分离和独立的 Python 环境,但你将使用virtualenv

使用virtualenv创建新虚拟环境的基本语法如下所示。一旦你处于项目文件夹中,将你的文件夹命名为FARMchapter4,打开一个终端,并输入以下命令:

python – m venv venv

此命令将为你的项目创建一个新的虚拟环境,Python 解释器的副本(或者在 macOS 上,一个全新的 Python 解释器),必要的文件夹结构,以及一些激活和停用环境的命令,以及pip安装程序的副本(pip 用于安装包)。

为了激活你的新虚拟环境,你将根据你的操作系统选择以下命令之一。对于 Windows 系统,在 shell 中输入以下内容:

venv/Scripts/activate

在 Linux 或 macOS 系统上,使用以下命令:

source venv/bin/activate

在这两种情况下,你的 shell 现在应该以你为环境所取的名字作为前缀。在创建新虚拟环境的命令中,最后一个参数是环境名称,所以在这个例子中是venv

在使用虚拟环境时,以下是一些需要考虑的事项:

  • 在虚拟环境放置方面,存在不同的观点。目前,如果你像之前那样将它们保存在项目文件夹内就足够了。

  • activate 命令类似,还有一个 deactivate 命令可以退出你的虚拟环境。

  • requirements.txt 文件中保存确切的包版本并固定依赖项不仅有用,而且在部署时通常是必需的。

Python 社区中有许多 virtualenv 的替代方案,以及许多互补的包。Poetry 是一个同时管理虚拟环境和依赖项的工具,virtualenvwrapper 是一组进一步简化环境管理过程的实用工具。pyenv 稍微复杂一些——它管理 Python 版本,并允许你根据不同的 Python 版本拥有不同的虚拟环境。

代码编辑器

虽然有许多优秀的 Python 代码编辑器和 集成开发环境IDE),但一个常见的选择是微软的 Visual Studio CodeVS Code)。2015 年发布,它是跨平台的,提供了许多集成工具,例如用于运行开发服务器的集成终端。它轻量级,提供了数百个插件,几乎可以满足你任何编程任务的需求。由于你将使用 JavaScript、Python、React 和 CSS 进行样式设计,以及运行命令行进程,因此使用 VS Code 是最简单的方法。

也有一个名为 MongoDB for VS Code 的优秀 MongoDB 插件,它允许你连接到 MongoDB 或 Atlas 集群,浏览数据库和集合,快速查看模式索引,以及查看集合中的文档。这在全栈场景中非常有用,当你发现自己正在处理 Python 的后端代码、JavaScript 和 React 或 Next.js 的前端代码、运行外壳,并需要快速查看 MongoDB 数据库的状态时。扩展程序可在以下链接找到:https://marketplace.visualstudio.com/items?itemName=mongodb.mongodb-vscode。你还可以在 Visual Studio Code 的 扩展 选项卡中通过搜索 MongoDB 来安装它。

终端

除了 Python 和 Git 之外,你还需要一个外壳程序。Linux 和 Mac 用户通常已经预装了一个。对于 Windows,你可以使用 Windows PowerShell 或像 Cmder (cmder.app) 这样的控制台模拟器,它提供了额外的功能。

REST 客户端

为了有效地测试您的 REST API,您需要一个 REST 客户端。虽然Postman(www.postman.com/)功能强大且可定制,但还有其他可行的替代方案。Insomnia()和 REST GUI 提供了一个更简单的界面,而HTTPie(),一个命令行 REST API 客户端,允许在不离开 shell 的情况下快速测试。它提供了诸如表达性语法、表单和上传处理以及会话等功能。

HTTPie 可能是安装最简单的 REST 客户端,因为它可以使用pip或其他包管理器,如 Chocolatey、apt(用于 Linux)或 Homebrew。

安装 HTTPie 的最简单方法是激活您的虚拟环境并使用pip,如下面的命令所示:

pip install httpie

安装完成后,您可以使用以下命令测试 HTTPie:

(venv) http GET "http://jsonplaceholder.typicode.com/todos/1"

输出应该以HTTP/1.1 200 OK响应开始。

venv表示虚拟环境已激活。HTTPie 通过简单地添加POST来简化 HTTP 请求,包括有效载荷、表单值等。

安装必要的包

在设置虚拟环境之后,您应该激活它并安装运行第一个简单应用程序所需的 Python 库:FastAPI 和 Uvicorn。

为了使 FastAPI 运行,它需要一个服务器。在这种情况下,服务器是一种用于提供 Web 应用程序(或 REST API)的软件。FastAPI 依赖于异步服务器网关接口ASGI),它使异步非阻塞应用程序成为可能,这是您可以完全利用 FastAPI 功能的地方。您可以在以下文档中了解更多关于 ASGI 的信息:asgi.readthedocs.io/

目前,FastAPI 文档列出了三个兼容 Python ASGI 的服务器:UvicornHypercornDaphne。本书将重点介绍 Uvicorn,这是与 FastAPI 一起使用最广泛和推荐的选择。Uvicorn 提供高性能,如果您遇到困难,网上有大量的文档可供参考。

要安装前两个依赖项,请确保您位于工作目录中,并激活了所需的虚拟环境,然后执行以下命令:

pip install fastapi uvicorn

现在,您拥有了一个包含 shell、一个或两个 REST 客户端、一个优秀的编辑器和优秀的 REST 框架的 Python 编码环境。如果您之前开发过DjangoFlask应用程序,这些都应该很熟悉。

最后,选择一个文件夹或克隆这本书的 GitHub 仓库,并激活一个虚拟环境。通常,在工作目录中创建一个名为venv的文件夹来创建环境,但请随意根据您的喜好来组织您的目录和代码。

在此之后,本章将简要讨论一些结构化您的 FastAPI 代码的选项。现在,请确保您在一个已激活新创建的虚拟环境的文件夹中。

快速了解 FastAPI

第一章Web 开发和 FARM 栈中,提到了为什么 FastAPI 是 FARM 栈中首选的 REST 框架。使 FastAPI 独特的是其编码速度和由此产生的干净代码,这使得你可以快速发现并修复错误。该框架的作者 Sebastian Ramirez 经常谦逊地强调,FastAPI 只是 Starlette 和 Pydantic 的混合,同时大量依赖现代 Python 特性,特别是类型提示。

在深入示例和构建 FastAPI 应用程序之前,快速回顾 FastAPI 所基于的框架是有用的。

Starlette

Starlette 是一个以高性能和众多特性著称的 ASGI 框架,这些特性在 FastAPI 中也有提供。这些包括 WebSocket 支持、启动和关闭时的事件、会话和 Cookie 支持、后台任务、中间件实现和模板。虽然你不会直接在 Starlette 中编码,但了解 FastAPI 内部的工作原理及其起源是很重要的。

如果你对其功能感兴趣,请访问 Starlette 优秀的文档(https://www.starlette.io/)。

异步编程

你可能在学习使用 Node.js 开发应用程序时已经接触过异步编程范式。这涉及到执行慢速操作,例如网络调用和文件读取,使得系统可以在不阻塞的情况下响应其他请求。这是通过使用事件循环,一个异步任务管理器来实现的,它允许系统将请求移动到下一个,即使前一个请求尚未完成并返回响应。

Python 在 3.4 版本中增加了对异步 I/O 编程的支持,并在 3.6 版本中引入了 async/await 关键字。ASGI 在 Python 世界中随后出现,概述了应用程序应该如何构建和调用,并定义了可以发送和接收的事件。FastAPI 依赖于 ASGI 并返回一个 ASGI 兼容的应用程序。

在这本书中,所有端点函数都带有 async 关键字前缀,甚至在它们成为必要之前,因为你会使用异步的 Motor Python MongoDB 驱动程序。

注意

如果你正在开发一个不需要高压力的简单应用程序,你可以使用简单的同步代码和官方的 PyMongo 驱动程序。

带有 async 关键字的函数是协程;它们在事件循环上运行。虽然本章中的简单示例可能不需要 async 就能工作,但当你通过一个异步驱动程序,如 Motor (https://motor.readthedocs.io/en/stable/),连接到你的 MongoDB 服务器时,FastAPI 中异步编程的真正力量将变得明显。

标准的 REST API 操作

本节将讨论 API 开发中的一些常见术语。通常,通信通过 HTTP 协议进行,通过 HTTP 请求和响应。你将探索 FastAPI 如何处理这些方面,并利用 Pydantic 和类型提示等额外库来提高效率。在示例中,你将使用 Uvicorn 作为服务器。

任何 REST API 通信的基础是一个 URL 和路径的系统。你的本地 Web 开发服务器的 URL 将是http://localhost:8000,因为8000是 Uvicorn 使用的默认端口。端点的路径部分(可选)可以是/cars,而http是方案。你将看到 FastAPI 如何处理路径、查询字符串、请求和响应正文,定义端点函数的特定顺序的重要性,以及如何有效地从动态路径段中提取变量。

在每个路径或地址中,URL 和路径的组合,都有一组可以执行的操作—HTTP 动词。例如,一个页面或 URL 可能列出所有待售的汽车,但你不能发出POST请求,因为这不被允许。

在 FastAPI 中,这些动词作为 Python装饰器实现。换句话说,它们被公开为装饰器,并且只有当你,即开发者,实现它们时,它们才会被实现。

FastAPI 鼓励正确和语义化地使用 HTTP 动词进行数据资源操作。例如,在创建新资源时,你应该始终使用POST(或@post装饰器),对于读取数据(单个或项目列表),使用GET,对于更新使用PATCH等等。

HTTP 消息由请求/状态行、头部和可选的正文数据组成。FastAPI 提供了工具,可以轻松创建和修改头部、设置响应代码以及以干净直观的方式操作请求和响应正文。

本节描述了支撑 FastAPI 性能的编程概念和特定的 Python 特性,使代码易于维护。在下一节中,你将了解标准的 REST API 操作,并了解它们是如何通过 FastAPI 实现的。

FastAPI 是如何表达 REST 的?

观察一个最小的 FastAPI 应用程序,例如经典的Hello World示例,你可以开始检查 FastAPI 如何构建端点。在这个上下文中,端点指定以下详细信息:

  • 一个独特的 URL 组合:这将在你的开发服务器中保持一致—localhost:8000

  • 路径:斜杠后面的部分。

  • HTTP 方法。

例如,在名为Chapter4的新文件夹中,使用 Visual Studio Code 创建一个名为chapter4_01.py的新 Python 文件:

from fastapi import FastAPI
app = FastAPI()
@app.get("/")
async def root():
    return {"message": "Hello FastAPI"}

使用这段代码,你可以完成几件事情。以下是每个部分的作用分解:

  • chapter4_01.py的第一行中,你从fastapi包中导入了 FastAPI 类。

  • 接下来,你实例化了一个应用程序对象。这只是一个具有所有 API 功能并暴露一个 ASGI 兼容应用程序的 Python 类,这个应用程序必须传递给 Uvicorn。

现在,应用程序已经准备就绪并实例化。但没有端点,它无法做或说很多。它有一个端点,即根端点,你可以在 http://127.0.0.1:8000/ 上查看。FastAPI 提供了用于 HTTP 方法的装饰器,以告诉应用程序如何以及是否响应。然而,你必须实现它们。

之后,你使用了 @get 装饰器,它对应于 GET 方法,并传递了一个 URL——在这种情况下,使用了根路径 /

装饰的函数,命名为 root,负责响应请求。它接受任何参数(在这种情况下,没有参数)。函数返回的值,通常是 Python 字典,将由 ASGI 服务器转换为 JavaScript 对象表示法JSON)响应,并作为 HTTP 响应返回。这看起来可能很显然,但将其分解以了解基础知识是有用的。

上述代码定义了一个具有单个端点的完整功能应用程序。要测试它,你需要一个 Uvicorn 服务器。现在,你必须使用 Uvicorn 在你的命令行中运行实时服务器:

uvicorn chapter4_01:app --reload

当你使用 FastAPI 进行开发时,你将非常频繁地使用此代码片段,所以以下说明将对其进行分解。

注意

Uvicorn 是你的 ASGI 兼容的 Web 服务器。你可以通过传递可执行 Python 文件(不带扩展名)和实例化应用(FastAPI 实例)的组合(由冒号 : 分隔)来直接调用它。--reload 标志指示 Uvicorn 在你保存代码时每次重新加载服务器,类似于 Node.js 中的 Nodemon。除非指定其他方式,否则你可以使用此语法运行本书中包含 FastAPI 应用的所有示例。

这是使用 HTTPie 测试唯一端点时的输出。记住,当你省略方法的关键字时,它默认为 GET 请求:

(venv) http http://localhost:8000/
HTTP/1.1 200 OK
content-length: 27
content-type: application/json date: Fri, 01 Apr 2022 17:35:48 GMT
server: uvicorn
{
  "message": "Hello FastAPI"
}

HTTPie 通知你你的简单端点正在运行。你将获得 200 OK 状态码,content-type 设置正确为 application/json,并且响应是一个包含所需消息的 JSON 对象。

每个 REST API 指南都以类似的 hello world 示例开始,但使用 FastAPI,这尤其有用。只需几行代码,你就可以看到简单端点的结构。这个端点仅覆盖针对根 URL (/) 的 GET 方法。因此,如果你尝试使用 POST 请求测试此应用,你应该会收到 405 Method Not Allowed 错误(或任何非 GET 方法)。

如果你想要创建一个对 POST 请求返回相同消息的端点,你只需更改装饰器。将以下代码添加到文件末尾(chapter4_01.py):

@app.post("/")
async def post_root():
    return {"message": "Post request success!"}

HTTPie 将在终端中相应地响应:

(venv) http POST http://localhost:8000 HTTP/1.1 200 OK
content-length: 35
content-type: application/json date: Sat, 26 Mar 2022 12:49:25 GMT
server: uvicorn
{
    "message": "Post request success!"
}

现在你已经创建了一些端点,请转到 http://localhost:8000/docs,看看 FastAPI 为你生成了什么。

自动文档

在开发 REST API 时,你会发现你需要不断执行 API 调用——GETPOST 请求——分析响应,设置有效载荷和头信息,等等。选择一个可行的 REST 客户端在很大程度上是一个个人喜好问题,这是一件应该仔细考虑的事情。虽然市场上有很多客户端——从功能齐全的 API IDE,如 Postman (www.postman.com/),到稍微轻量级的 Insomnia (insomnia.rest/) 或 Visual Studio Code 的 REST 客户端 (marketplace.visualstudio.com/items?itemName=humao.rest-client)——本书主要使用非常简单的基于命令行的 HTTPie 客户端,它提供了一个简约的命令行界面。

然而,这正是介绍 FastAPI 最受欢迎的另一个特性的正确时机——交互式文档——这是一个有助于在 FastAPI 中开发 REST API 的工具。

随着你开发的每个端点或路由器,FastAPI 会自动生成文档。它是交互式的,允许你在开发过程中测试你的 API。FastAPI 列出你定义的所有端点,并提供有关预期输入和响应的信息。该文档基于 OpenAPI 规范,并大量依赖于 Python 提示和 Pydantic 库。它允许设置要发送到端点的 JSON 或表单数据,显示响应或错误,与 Pydantic 紧密耦合,并且能够处理简单的授权程序,例如将在 第六章**,认证和授权 中实现的携带令牌流。你无需使用 REST 客户端,只需打开文档,选择要测试的端点,方便地将测试数据输入到标准网页中,然后点击 提交 按钮!

在本节中,你创建了一个最小化但功能齐全的 API,具有单个端点,让你了解了应用程序的语法和结构。在下一节中,你将了解 REST API 请求-响应周期的基本元素以及如何控制过程的每个方面。标准 REST 客户端提供了一种更可移植的体验,并允许你比较不同的 API,即使它们不是基于 Python 的。

构建展示 API

REST API 围绕 HTTP 请求和响应展开,这些是网络的动力,并且在每个使用 HTTP 协议的 Web 框架中实现。为了展示 FastAPI 的功能,你现在将创建简单的端点,专注于实现所需功能的特定代码部分。而不是常规的 CRUD 操作,接下来的部分将专注于检索和设置请求和响应元素的过程。

获取路径和查询参数

第一个端点将用于通过其唯一的 ID 获取一个虚构的汽车。

  1. 创建一个名为 chapter4_02.py 的文件,并插入以下代码:

    from fastapi import FastAPI
    app = FastAPI()
    @app.get("/car/{id}")
    async def root(id):
        return {"car_id": id}
    car/:id, while {id} is a standard Python string-formatted dynamic parameter in the sense that it can be anything—a string or a number since you haven’t used any hinting.
    
  2. 尝试一下,并使用等于 1 的 ID 来测试端点:

    (venv) http "http://localhost:8000/car/1"
    HTTP/1.1 200 OK
    content-length: 14
    content-type: application/json date: Mon, 28 Mar 2022 20:31:58 GMT
    server: uvicorn
    {
        "car_id": "1"
    }
    
  3. 你收到了你的 JSON 响应,但在这里,响应中的 1 是一个字符串(提示:引号)。你可以尝试用等于字符串的 ID 来执行相同的路由:

    (venv) http http://localhost:8000/car/billy HTTP/1.1 200 OK
    {
        "car_id": "billy"
    }
    

    FastAPI 返回你提供的字符串,它是作为动态参数的一部分提供的。然而,Python 的新特性,如类型提示,也派上用场。

  4. 回到你的 FastAPI 路径(或端点),使汽车 ID 成为整数,只需对变量参数的类型进行提示即可。端点将看起来像这样:

    @app.get("/carh/{id}")
    async def hinted_car_id(id: int):
        return {"car_id": id}
    

你已经给它指定了一个新的路径:/carh/{id}car 后面的 h 表示提示)。除了函数名(hinted_car_id)外,唯一的区别在于参数:紧跟在 int 后面的分号表示你可以期望一个整数,但 FastAPI 对此非常认真,你已经在框架中看到了如何很好地使用提示系统。

如果你查看 http://localhost:8000/docs 上的交互式文档,并尝试在 /carh/ 端点的 id 字段中插入一个字符串,你会得到一个错误。

现在,在你的 REST 客户端中尝试运行它,并通过传递一个字符串来测试 /carh/ 路径。首先,FastAPI 为你正确地设置了状态码——即 422 Unprocessable Entity——并在响应体中指出问题所在——值不是一个有效的整数。它还告知你错误发生的确切位置:在 id 路径中。

这是一个简单的例子,但想象一下你正在发送一个复杂的请求,路径复杂,有多个查询字符串,也许还有头部中的附加信息。使用类型提示可以快速解决这些问题。

如果你尝试访问端点而不指定任何 ID,你将得到另一个错误:

(venv) http http://localhost:8000/carh/ HTTP/1.1 404 Not Found
{
    "detail": "Not Found"
}

FastAPI 再次正确地设置了状态码,给你一个 404 Not Found 错误,并在响应体中重复了此消息。你访问的端点不存在;你必须在斜杠后指定一个值。

可能会出现你拥有类似路径的情况:既有动态路径也有静态路径。一个典型的情况是拥有众多用户的应用程序。将 API 定向到由 /users/id 定义的 URL 将会给你一些关于选定 ID 的用户信息,而 /users/me 通常是一个显示你的信息并允许你以某种方式修改它的端点。

在这种情况下,重要的是要记住,与其他 Web 框架一样,顺序很重要。由于路径处理程序声明的顺序,以下代码将不会产生预期的结果,因为应用程序会尝试将 /me 路径与它遇到的第一个端点匹配——需要 ID 的那个端点——由于 /me 部分不是一个有效的 ID,你会得到一个错误。

创建一个名为 chapter4_03.py 的新文件,并将以下代码粘贴进去:

from fastapi import FastAPI
app = FastAPI()
@app.get("/user/{id}")
async def user(id: int):
    return {"User_id": id}
@app.get("/user/me")
async def me_user():
    return {"User_id": "This is me!"}

当你运行应用程序并测试 /user/me 端点时,你将得到一个与之前相同的 422 Unprocessable Entity 错误。一旦你记住顺序很重要——FastAPI 会找到第一个匹配的 URL,检查类型,并抛出错误。如果第一个匹配的是具有固定路径的那个,那么一切都会按预期工作。只需更改两个路由的顺序,一切就会按预期工作。

FastAPI 对路径处理的一个强大功能是它如何限制路径到一组特定的值和一个从 FastAPI 导入的路径函数,这使你能够在路径上执行额外的验证。

假设你想要一个 URL 路径,它接受两个值并允许以下操作:

  • account_type:可以是 freepro

  • months:这必须是一个介于 3 和 12 之间的整数。

FastAPI 通过让你创建一个基于 Enum 的类来解决这个问题,用于账户类型。这个类定义了账户变量所有可能的值。在这种情况下,只有两个——freepro。创建一个新的文件,并将其命名为 chapter4_04.py,然后编辑它:

from enum import Enum
from fastapi import FastAPI, Path
app = FastAPI()
class AccountType(str, Enum):
    FREE = "free"
    PRO = "pro"

最后,在实际的端点中,你可以将这个类与 Path 函数的实用工具结合起来(不要忘记与 FastAPI 一起从 fastapi 导入它)。将以下代码粘贴到文件的末尾:

@app.get("/account/{acc_type}/{months}")
async def account(acc_type: AccountType, months: int = Path(..., ge=3, le=12)):
    return {"message": "Account created", "account_type": acc_type, "months": months}

在前面的代码中,FastAPI 将路径的 acc_type 部分的类型设置为之前定义的类,并确保只能传递 freepro 值。然而,months 变量是由 Path 实用函数处理的。当你尝试访问这个端点时,account_type 将显示只有两个值可用,而实际的枚举值可以通过 .value 语法访问。

FastAPI 允许你使用标准的 Python 类型声明路径参数。如果没有声明类型,FastAPI 将假设你正在使用字符串。

关于这些主题的更多详细信息,你可以访问优秀的文档网站,看看其他可用的选项(https://fastapi.tiangolo.com/tutorial/path-params/)。在这种情况下,Path 函数接收了三个参数。三个点表示该值是必需的,并且没有提供默认值,ge=3 表示该值可以大于或等于 3,而 le=12 表示它可以小于或等于 12。这种语法允许你在路径函数中快速定义验证。

查询参数

现在你已经学会了如何验证、限制和正确排序你的路径参数和端点,是时候看看查询参数了。这些参数是通过 URL 将数据传递给服务器的简单机制,它们以键值对的形式表示,由等号(=)分隔。你可以有多个键值对,由与号(&)分隔。

查询参数通过在 URL 的末尾使用问号/等号记法添加:?min_price=2000&max_price=4000

问号(?)是一个分隔符,它告诉您查询字符串从哪里开始,而与号(&)允许您添加多个(等号=)赋值。

查询参数通常用于应用过滤器、排序、排序或限制查询集、分页长列表的结果以及类似任务。FastAPI 将它们处理得与路径参数非常相似,因为它会自动提取它们并在您的端点函数中使它们可用于处理。

  1. 创建一个简单的端点,接受两个查询参数,用于汽车的最低价和最高价,并将其命名为chapter4_05.py

    from fastapi import FastAPI
    app = FastAPI()
    @app.get("/cars/price")
    async def cars_by_price(min_price: int = 0, max_price: int = 100000):
        return {"Message": f"Listing cars with prices between {min_price} and {max_price}"}
    
  2. 使用 HTTPie 测试此端点:

    (venv) http "http://localhost:8000/cars/price?min_price=2000&max_price=4000"
    HTTP/1.1 200 OK
    content-length: 60
    content-type: application/json date: Mon, 28 Mar 2022 21:20:24 GMT
    server: uvicorn
    {
    "Message": "Listing cars with prices between 2000 and 4000"
    }
    

在这个解决方案中,您无法确保基本条件,即最低价格应低于最高价格。这是由 Pydantic 的对象级验证处理的。

FastAPI 选择您的查询参数,并执行与之前相同的解析和验证检查。它提供了Query函数,就像Path函数一样。您可以使用大于小于等于条件,以及设置默认值。它们也可以设置为默认为None。根据需要,查询参数将被转换为布尔值。您可以编写相当复杂的路径和查询参数的组合,因为 FastAPI 可以区分它们并在函数内部处理它们。

通过这样,您已经看到了 FastAPI 如何使您能够处理通过路径和查询参数传递的数据,以及它使用的工具在幕后尽快进行解析和验证。现在,您将检查 REST API 的主要数据载体:请求体

请求体——数据的大部分

REST API 允许客户端(一个网页浏览器或移动应用程序)与 API 服务器之间进行双向通信。大部分数据都通过请求和响应体传输。请求体包含从客户端发送到您的 API 的数据,而响应体是从 API 服务器发送到客户端(们)的数据。

这些数据可以用各种方式编码,但许多用户更喜欢使用 JSON 编码数据,因为它与我们的数据库解决方案 MongoDB 非常出色——MongoDB 使用 BSON,与 JSON 非常相似。

当在服务器上修改数据时,您应该始终使用:

  • POST请求:用于创建新资源

  • PUTPATCH:用于更新资源

  • DELETE:用于删除资源

由于请求体将包含原始数据——在这种情况下,MongoDB 文档或文档数组——您可以使用 Pydantic 模型。但首先,看看这个机制是如何工作的,没有任何验证或建模。在 HTTP 术语中,GET方法应该是幂等的,这意味着它应该总是为同一组参数返回相同的值。

在以下用于将新车插入未来数据库的假设端点的代码中,你可以将通用的请求体作为数据传递。它可以是字典,无需进入该字典应该如何构建的细节。创建一个名为 chapter4_06.py 的新文件,并将以下代码粘贴进去:

from typing import Dict
from fastapi import FastAPI, Body
app = FastAPI()
@app.post("/cars")
async def new_car(data: Dict = Body(...)):
    print(data)
    return {"message": data}

直观来看,Body 函数与之前介绍的 PathQuery 函数类似。然而,区别在于,当处理请求体时,此函数是强制性的。

三个点表示请求体是必需的(你必须发送一些内容),但这仅是唯一的要求。尝试插入一辆车(2015 年制造的菲亚特 500):

(venv) http POST "http://localhost:8000/cars" brand="FIAT" model="500" year=2015
HTTP/1.1 200 OK
content-length: 56
content-type: application/json date: Mon, 28 Mar 2022 21:27:31 GMT
server: uvicorn
{
  "message": {
  "brand": "FIAT",
  "model": "500",
  "year": "2015"
}

FastAPI 会做繁重的工作。你可以检索传递给请求体的所有数据,并将其提供给函数以进行进一步处理——数据库插入、可选预处理等。

另一方面,你可以向请求体传递任何键值对。当然,这只是一个说明一般机制的例子——在现实中,Pydantic 将成为你的数据守护者,确保你只让正确的数据进入。

虽然一切顺利,但 FastAPI 仍然会发送一个 200 响应状态,尽管 201 Resource Created 错误更为合适和准确。例如,你可以在函数末尾将一些文档插入 MongoDB,并使用 201 CREATED 状态消息。你将看到修改响应体是多么容易,但就目前而言,你将能够看到 Pydantic 在处理请求体时的优势。

要创建新的汽车条目,你只需要 brandmodel 和生产 year 字段。

因此,在 chapter4_07.py 文件中创建一个简单的 Pydantic 模型,其中包含所需的数据类型:

from fastapi import FastAPI, Body
from pydantic import BaseModel
class InsertCar(BaseModel):
    brand: str
    model: str
    year: int
app = FastAPI()
@app.post("/cars")
async def new_car(data: InsertCar):
    print(data)
    return {"message": data}

到现在为止,你知道前两个参数应该是字符串,而年份必须是整数;它们都是必需的。

现在,如果你尝试发送之前相同的数据,但带有额外的字段,你将只会收到这三个字段。此外,这些字段将经过 Pydantic 解析和验证,如果某些内容不符合数据规范,将抛出有意义的错误信息。

Pydantic 模型验证和 Body 函数的组合,在处理请求数据时提供了所有必要的灵活性。这是因为你可以将它们结合起来,并通过相同的请求总线传递不同的信息片段。

如果你想要传递与用户关联的促销代码以及新车数据,你可以尝试定义一个用于用户的 Pydantic 模型,并使用 Body 函数提取促销代码。首先,在新的文件中定义一个最小的用户模型,并将其命名为 chapter4_08.py

class UserModel(BaseModel):
    username: str
    name: str

现在,创建一个更复杂的函数,该函数将处理两个 Pydantic 模型和可选的用户促销代码——将默认值设置为 None

@app.post("/car/user")
async def new_car_model(car: InsertCar, user: UserModel, code: int = Body(None)):
    return {"car": car, "user": user, "code": code}

对于这个请求,它包含一个完整的 JSON 对象,其中有两个嵌套对象和一些代码,你可能选择使用 Insomnia 或类似的图形用户界面客户端,因为这样做比在命令提示符中输入 JSON 或使用管道要容易。虽然这主要是一个个人偏好的问题,但在开发和测试 REST API 时,拥有一个如 Insomnia 或 Postman 之类的图形用户界面工具以及一个命令行客户端(如 cURL 或 HTTPie)是非常有用的。

Body类构造函数的参数与PathQuery构造函数非常相似,并且由于它们通常会更加复杂,因此尝试使用 Pydantic 来驯服它们是有用的。解析、验证和有意义的错误消息——Pydantic 在允许请求数据到达真实数据处理功能之前为我们提供了整个包。POST请求几乎总是以适当的 Pydantic 模型作为参数传入。

在尝试了请求体和 Pydantic 模型的组合之后,你已经看到你可以控制数据的流入,并且可以确信提供给你的 API 端点的数据将是你想要和期望的数据。然而,有时你可能想要直接与裸金属打交道,并处理原始请求对象。FastAPI 也覆盖了这种情况,如下一节所述。

请求对象

FastAPI 建立在 Starlette 网络框架之上。FastAPI 中的原始请求对象是 Starlette 的请求对象,一旦从 FastAPI 直接导入,你就可以在你的函数中访问它。通过直接使用请求对象,你错过了 FastAPI 最重要的功能:Pydantic 的解析和验证以及自文档化!然而,可能存在你需要拥有原始请求的情况。

看看chapter4_09.py文件中的以下示例:

from fastapi import FastAPI, Request
app = FastAPI()
@app.get("/cars")
async def raw_request(request: Request):
    return {"message": request.base_url, "all": dir(request)}

在前面的代码中,你创建了一个最小的 FastAPI 应用程序,导入了Request类,并在端点中使用它。如果你使用REST客户端测试此端点,你将只得到基础 URL 作为消息,而all部分列出了Request对象的全部方法和属性,以便你了解可用的内容。

所有这些方法和属性都可供你在你的应用程序中使用。

有了这些,你已经看到了 FastAPI 如何帮助你与主要的 HTTP 传输机制——请求体、查询字符串和路径——一起工作。接下来,你将探索任何网络框架解决方案同样重要的方面——cookies、headers、表单数据和文件。

Cookies 和 headers,表单数据,和文件

说到网络框架如何摄取数据,处理表单数据、处理文件以及操作 Cookies 和 headers 等主题必须包括在内。本节将提供 FastAPI 如何处理这些任务的简单示例。

Headers

标头参数的处理方式与查询和路径参数类似,正如你稍后将会看到的,还有 cookie。你可以通过使用Header函数来收集它们,可以说。在诸如身份验证和授权等主题中,标头是必不可少的,因为它们经常携带JSON Web Tokens(JWTs),这些用于识别用户及其权限。

尝试使用新文件chapter4_10.py中的Header函数读取用户代理:

from typing import Annotated
from fastapi import FastAPI, Header
app = FastAPI()
@app.get("/headers")
async def read_headers(user_agent: Annotated[str | None, Header()] = None):
    return {"User-Agent": user_agent}

根据你使用的软件来执行端点的测试,你将得到不同的结果。以下是一个使用 HTTPie 的示例:

(venv) http GET "http://localhost:8000/headers"
HTTP/1.1 200 OK
content-length: 29
content-type: application/json date: Sun, 27 Mar 2022 09:26:49 GMT
server: uvicorn
{
"User-Agent": "HTTPie/3.2.2"
}

你可以用这种方式提取所有标头,FastAPI 将提供进一步的帮助——它将名称转换为小写,将键转换为蛇形命名法,等等。

Cookie 的工作方式类似,尽管它们可以从Cookies标头中手动提取。该框架提供了一个名为Cookie的实用函数,它以类似于QueryPathHeader的方式完成所有工作。

表单(和文件)

到目前为止,你只处理了 JSON 数据。它是网络上的通用语言,也是你在数据往返中的主要载体。然而,有些情况需要不同的数据编码——表单可能直接由你的 API 处理,数据编码为multipart/form-dataform-urlencoded。随着现代 React Server Actions 的出现,表单数据在前端开发中也变得更加流行。

注意

尽管你可以在路径操作中声明多个表单参数,但你不能声明 JSON 中预期的Body字段。HTTP 请求将使用仅application/x-www-form-urlencoded而不是application/json进行编码。这种限制是 HTTP 协议的一部分,并不特定于 FastAPI。

覆盖这两种表单情况——包括和不包括上传文件的最简单方法是首先安装python-multipart,这是一个 Python 的流式多部分解析器。为此,你必须停止你的服务器并使用pip来安装它:

pip install python-multipart==0.0.9

Form函数与之前检查的实用函数类似,但不同之处在于它寻找表单编码的参数。对于简单字段,数据通常使用媒体类型(application/x-www-form-urlencoded)进行编码,而如果包含文件,编码对应于multipart/form-data

看一个简单的例子,你希望上传一张图片和一些表单字段,比如品牌和型号。

你将使用一张可以在 Pexels 上找到的照片(www.pexels.com/photo/white-),重命名为car.jpeg并保存在当前目录中。

创建一个名为chapter4_11.py的文件,并将以下代码粘贴进去:

from fastapi import FastAPI, Form, File, UploadFile
app = FastAPI()
@app.post("/upload")
async def upload(
    file: UploadFile = File(...), brand: str = Form(...), model: str = Form(...)
):
    return {"brand": brand, "model": model, "file_name": file.filename}

上一段代码通过Form函数处理表单参数,并通过使用UploadFile实用类上传文件。

然而,照片并没有保存在磁盘上——它的存在只是被确认,并返回文件名。在 HTTPie 中测试具有文件上传的端点如下所示:

http -f POST localhost:8000/upload  brand='Ferrari' model='Testarossa'  file@car.jpeg

前面的 HTTPie 调用返回以下输出:

HTTP/1.1 200 OK
content-length: 63
content-type: application/json
date: Fri, 22 Mar 2024 11:01:38 GMT
server: uvicorn
{
    "brand": "Ferrari",
    "file_name": "car.jpeg",
    "model": "Testarossa"
}

要将图像保存到磁盘,你必须将缓冲区复制到磁盘上的实际文件中。以下代码实现了这一点(chapter4_12.py):

import shutil
from fastapi import FastAPI, Form, File, UploadFile
app = FastAPI()
@app.post("/upload")
async def upload(
    picture: UploadFile = File(...),
    brand: str = Form(...),
    model: str = Form(...)
):
    with open("saved_file.png", "wb") as buffer:
        shutil.copyfileobj(picture.file, buffer)
    return {"brand": brand, "model": model, "file_name": picture.filename}

open块使用指定的文件名在磁盘上打开一个文件,并复制通过表单发送的 FastAPI 文件。你将硬编码文件名,因此任何新的上传将简单地覆盖现有文件,但你可以使用通用唯一识别码UUID)库等随机生成文件名。

文件上传可以通过不同的方式实现——文件上传也可以由 Python 的async文件库aiofiles或作为后台任务处理,这是 FastAPI 的另一个特性,将在第五章中展示,设置 React 工作流程

FastAPI 响应自定义

前几节讨论了 FastAPI 请求的许多示例,说明了你可以如何触及请求的每一个角落——路径、查询字符串、请求体、头部和 Cookies,以及如何处理表单编码的请求。

现在,让我们更仔细地看看 FastAPI 的响应对象。在所有之前的案例中,你返回了一个由 FastAPI 序列化为 JSON 的 Python 字典。该框架允许对响应进行自定义。

在 HTTP 响应中,你可能首先想要更改的是状态码,例如,在事情没有按计划进行时提供一些有意义的错误。当存在 HTTP 错误时,FastAPI 方便地引发经典的 Python 异常。它还使用符合标准的、有意义的响应代码,以最大限度地减少创建自定义有效负载消息的需求。例如,你不希望为所有内容发送200 OK状态码,然后通过有效负载通知用户错误——FastAPI 鼓励良好的实践。

设置状态码

HTTP 状态码表示操作是否成功或存在错误。这些代码还提供了关于操作类型的信息,并且可以根据几个组来划分:信息性、成功、客户端错误、服务器错误等。虽然不需要记住状态码,但你可能知道404500代码的含义。

FastAPI 使设置状态码变得非常简单——只需将所需的status_code变量传递给装饰器即可。在这里,你正在使用208 status代码为一个简单的端点(chapter4_13.py):

from fastapi import FastAPI, status
app = FastAPI()
@app.get("/", status_code=status.HTTP_208_ALREADY_REPORTED)
async def raw_fa_response():
    return {"message": "fastapi response"}

在 HTTPie 中测试根路由产生以下输出:

(venv) http GET "http://localhost:8000"
HTTP/1.1 208 Already Reported content-length: 30
content-type: application/json date: Sun, 27 Mar 2022 20:14:25 GMT
server: uvicorn
{
    "message": "fastapi response"
}

类似地,你可以为deleteupdatecreate操作设置状态码。

FastAPI 默认设置200 状态码,如果没有遇到异常,因此设置各种 API 操作的正确代码取决于你,例如删除时使用204 No Content,创建时使用201。这是一个特别值得鼓励的良好实践。

Pydantic 可用于响应建模。您可以使用response_model参数限制或修改应在响应中出现的字段,并执行与请求体类似的检查。

FastAPI 不启用自定义响应,但修改和设置头和 cookie 与从 HTTP 请求和框架中读取它们一样简单。

虽然这超出了本书的范围,但值得注意的是,JSON 绝不是 FastAPI 可以提供的唯一响应:您可以输出HTMLResponse并使用经典的 Flask-like Jinja 模板,StreamingResponseFileResponseRedirectResponse等等。

HTTP 错误

错误是不可避免的。例如,用户可能以某种方式向查询发送了错误的参数,前端发送了错误的请求体,或者数据库离线(尽管在 MongoDB 中这种情况不太可能)——任何情况都可能发生。尽快检测这些错误(这是 FastAPI 的一个主题)并向前端以及用户发送清晰完整的消息,通过抛出异常至关重要。

FastAPI 依赖于网络标准,并在开发过程的各个方面强制执行良好实践,因此它非常重视使用 HTTP 状态码。这些代码提供了对出现问题的清晰指示,而有效载荷可以用来进一步阐明问题的原因。

FastAPI 使用一个称为HTTPException的 Python 异常来引发 HTTP 错误。这个类允许您设置状态码并设置错误消息。

回到将新汽车插入数据库的例子,您可以设置一个自定义异常,如下所示(chapter4_14.py):

from pydantic import BaseModel
from fastapi import Fastapi, HTTPException, status
app = FastAPI()
class InsertCar(BaseModel):
    brand: str
    model: str
    year: int
@app.post("/carsmodel")
async def new_car_model(car: InsertCar):
    if car.year > 2022:
        raise HTTPException(
            status.HTTP_406_NOT_ACCEPTABLE, detail="The car doesn't exist yet!"
        )
    return {"message": car}

当尝试插入尚未建造的汽车时,响应如下:

(venv) λ http POST http://localhost:8000/carsmodel brand="fiat" mode3
l="500L" year=2023
HTTP/1.1 406 Not Acceptable content-length: 39
content-type: application/json date: Tue, 29 Mar 2022 18:37:42 GMT
server: uvicorn
{
    "detail": "The car doesn't exist yet!"
}

这是一个相当牵强的例子,用于为可能出现的潜在问题创建自定义异常。然而,这很好地说明了可能实现的内容以及 FastAPI 提供的灵活性。

依赖注入

为了简要但自包含地介绍 FastAPI,必须提到依赖注入系统。从广义上讲,依赖注入DI)是在适当的时间向路径操作函数提供必要功能(类、函数、数据库连接、授权状态等)的一种方式。FastAPI 的 DI 系统对于在端点之间共享逻辑、共享数据库连接等非常有用,正如您在连接到您的 MongoDB Atlas 实例时将看到的——执行安全性和身份验证检查等。

依赖项并不特殊;它们只是可以接受与路径操作相同参数的正常函数。实际上,官方文档将它们与未使用装饰器的路径操作进行比较。尽管如此,依赖项的使用方式略有不同。它们被赋予一个单一参数(通常是可调用的),并且不是直接调用;它们只是作为参数传递给 Depends()

一个受官方 FastAPI 文档启发的示例如下;你可以使用分页依赖并在不同的资源中使用它(chapter4_15.py):

from typing import Annotated
from fastapi import Depends, FastAPI
app = FastAPI()
async def pagination(q: str | None = None, skip: int = 0, limit: int = 100):
    return {"q": q, "skip": skip, "limit": limit}
@app.get("/cars/")
async def read_items(commons: Annotated[dict, Depends(pagination)]):
    return commons
@app.get("/users/")
async def read_users(commons: Annotated[dict, Depends(pagination)]):
    return commons

在全栈 FastAPI 项目中,DI(依赖注入)最常见的情况之一是身份验证;你可以使用相同的身份验证逻辑,即检查头部的授权令牌并将其应用于所有需要身份验证的路由或路由器,正如你将在第六章**,身份验证 和授权 *中看到的那样。

使用路由器结构化 FastAPI 应用程序

虽然将所有我们的 request/response 逻辑放在一个大文件中是可能的,但随着你开始构建一个中等规模的项目,你很快就会看到这并不可行,不可维护,也不便于工作。FastAPI,就像 Node.js 世界的 Express.js 或 Flask 的蓝图,在 /cars 路径上提供了 cars,另一个用于处理 /users 路径上的用户创建和管理,等等。FastAPI 提出了一种简单直观的项目结构,足以容纳最常见的情况。

API 路由器

FastAPI 提供了一个名为 APIRouter 的类,用于分组路由,通常与同一类型的资源(用户、购物项目等)相关。这个概念在 Flask 中被称为 Blueprints,并且在每个现代 Web 框架中都存在,它允许代码更加模块化和分散在更小的单元中,每个路由器只管理一种类型的资源。这些 APIRouter 最终包含在主要的 FastAPI 实例中,并提供非常相似的功能。

而不是直接在主应用程序实例(通常称为 app)上应用路径装饰器(@get@post 等),它们被应用于 APIRouter 实例。下面是一个简单的示例,将应用程序拆分为两个 APIRouter:

  1. 首先,创建一个名为 chapter4_16.py 的文件,该文件将托管主要的 FastAPI 实例:

    from fastapi import FastAPI
    from routers.cars import router as cars_router
    from routers.user import router as users_router
    app = FastAPI()
    app.include_router(cars_router, prefix="/cars", tags=["cars"])
    app.include_router(users_router, prefix="/users", tags=["users"])
    
  2. 现在,创建一个名为 /routers 的新文件夹,并在该文件夹中创建一个名为 users.py 的文件,用于创建 APIRouter:

    from fastapi import APIRouter
    router = APIRouter()
    @router.get("/")
    async def get_users():
        return {"message": "All users here"}
    
  3. 在同一 /routers 目录中创建另一个文件,命名为 cars.py

    from fastapi import APIRouter
    router = APIRouter()
    @router.get("/")
    async def get_cars():
        return {"message": "All cars here"}
    

当在 chapter4_17.py 文件中将路由器连接到主应用程序时,你可以向 APIRouter 提供不同的可选参数——标签和一组依赖项,例如身份验证要求。然而,前缀是强制性的,因为应用程序需要知道在哪个 URL 上挂载 APIRouter。

如果你使用以下命令使用 Uvicorn 测试此应用程序:

uvicorn chapter4_17:app

然后,前往自动生成的文档,您会看到两个 APIRouter 被挂载,就像您定义了两个单独的端点一样。然而,它们被分别归类在各自的标签下,以便于导航和测试。

如果您现在导航到文档,您确实应该找到在/cars上定义的一个路由,并且只响应GET请求。直观地,这个程序可以让您在短时间内构建并行或同一级别的路由,但使用 APIRouters 的最大好处之一是它们支持嵌套,这使得管理端点的复杂层次结构变得轻而易举!

路由是应用程序的子系统,并不打算独立使用,尽管您可以在特定路径下自由挂载整个独立的 FastAPI 应用程序,但这超出了本书的范围。

中间件

FastAPI 实现了请求/响应周期的概念,拦截请求,以某种期望的方式对其进行操作,然后在将其发送到浏览器或客户端之前获取响应,如果需要,执行额外的操作,最后返回最终的响应。

中间件基于 ASGI 规范,并在 Starlette 中实现,因此 FastAPI 允许您在所有路由中使用它,并且可以选择将其绑定到应用程序的一部分(通过 APIRouter)或整个应用程序。

与提到的框架类似,FastAPI 的中间件只是一个接收请求和call_next函数的函数。创建一个名为chapter4_17.py的新文件:

from fastapi import FastAPI, Request
from random import randint
app = FastAPI()
@app.middleware("http")
async def add_random_header(request: Request, call_next):
    number = randint(1,10)
    response = await call_next(request)
    response.headers["X-Random-Integer "] = str(number)
    return response
@app.get("/")
async def root():
    return {"message": "Hello World"}

如果您现在启动这个小型应用程序,并测试唯一的路由,即http://127.0.0.1:8000/上的路由,您会注意到返回的头部包含一个介于 1 到 10 之间的整数,并且每次请求这个整数都会不同。

中间件在跨源资源共享CORS)认证中扮演着重要角色,这是您在开发全栈应用程序时必然会遇到的问题,同时也用于重定向、管理代理等。这是一个非常强大的概念,可以极大地简化并提高您的应用程序效率。

概述

本章介绍了 FastAPI 如何通过利用现代 Python 功能和库(如 Pydantic)实现最常用的 REST API 任务的一些简单示例,以及它如何帮助您。

本章还详细介绍了 FastAPI 如何使您能够通过 HTTP 执行请求和响应,以及您如何在任何时候利用它,自定义和访问请求以及响应的元素。最后,它还详细介绍了如何将 API 拆分为路由,以及如何将应用程序组织成基于资源的逻辑单元。

下一章将为您快速介绍 React——FARM 堆栈中首选的用户界面库。

第五章:设置 React 工作流程

本章重点介绍 React 库,并讨论了您应该了解的重要主题和功能,以便您能够创建一个非常简单的 React 应用,实际上只是一个前端。在本章中,您将了解 React 的主要功能和最显著的概念。

您将从先决条件和工具开始,例如 Node.js、一些 Visual Studio Code 扩展等。您还将学习如何使用名为Vite的新标准和推荐构建工具。与Create React App相比,Vite 更高效,允许快速热模块替换HMR)和按需文件服务,无需捆绑。捆绑是将多个 JavaScript 文件组合并连接成一个文件的过程,减少了加载页面所需的 HTTP 请求数量。另一方面,HMR允许在应用程序运行时实时更新单个模块。

您将设计一个包含几个组件的简单应用,并了解解耦如何帮助您编写模块化和可维护的代码。本章涵盖了两个最重要的 React 钩子以及它们如何解决一些常见的 Web 开发问题。然而,本章的主要目标是讨论探索 React 及其各种功能所需的工具。

到本章结束时,您将拥有一个简单但功能齐全的 React 网络应用。本章中的概念将使您准备好成为一个重视相对简单工具以实现复杂功能,而不受严格框架限制的前端开发者。

本章将涵盖以下主题:

  • React 简介以及如何使用 Vite 创建 React 应用

  • 使用 Tailwind CSS 进行样式技术

  • 函数组件和 JSX,React 的语言

  • 如何使用useStateuseEffect钩子进行状态管理和 API 通信

  • React Router 和其他 React 生态系统内包的功能

技术要求

创建基于 React 的应用涉及多个步骤,包括设置构建系统和转换器、创建目录结构等。在您开始开发应用程序之前,您必须安装以下工具:

  • Vite:Vite 需要 Node.js 版本 18+或 20+才能运行,但您始终可以查看vitejs.dev上的文档以获取更新。

  • Node.js:您可以从nodejs.org/en/download/下载适用于您操作系统的 Node.js。在安装时,请勾选所有选项——如果您使用的是 Windows 机器,您希望安装npmNode.js 的包管理器)以及可选的额外命令行工具。

  • Visual Studio Code:安装一个名为ES7+ React/Redux/React-Native snippets的 React 扩展,以帮助加快 React 应用组件的创建。

  • React 开发者工具:安装 React 开发者工具浏览器扩展(react.dev/learn/react-developer-tools)。这使你能够更快地调试你的 React 应用程序并轻松发现潜在问题。

使用 Vite 创建 React 应用程序

React 是一个用于构建 用户界面 (UI) 的 JavaScript 库,尤其是用于 单页应用程序 (SPAs),但也适用于传统的服务器端渲染应用程序。它提供了可重用的 UI 组件,能够管理自己的状态,允许以简单和高可扩展性的方式创建复杂和动态的 Web 应用程序。

React 是基于虚拟 文档对象模型 (DOM) 的。它最小化了实际 DOM 的操作,从而提高了性能。正如介绍中所述,React 的强大生态系统包括 Next.jsRemix.js、以移动为中心的 React Native 以及众多 Hooks。这些功能使开发者能够构建灵活且高性能的应用程序。

Vite 是一个现代构建工具,旨在简化并加快使用 React(以及 Vue.js、Svelte 和其他框架和库)开发 Web 应用程序的速度。它提供了一个快速的开发服务器,支持热模块替换等特性,确保快速更新而不会丢失应用程序的当前状态。与传统设置不同,Vite 将应用程序模块分离为依赖项和源代码,使用 esbuild 进行快速依赖项捆绑,并使用原生 ECMAScript 模块 (ESMs) 提供源代码。这种方法导致服务器启动和更新时间更快,从而提高了开发过程中的生产力。

注意

Vite 支持多种类型项目的脚手架搭建,如 Svelte、Preact、Solid.js 等。

让我们从创建一个简单的应用程序开始,你将在本介绍中在此基础上构建:

  1. 选择一个你喜欢的文件夹,例如 chapter5。使用 cd 将其设置为工作目录,然后从你选择的终端运行以下命令以创建一个 React 模板:

    npm create vite@latest frontend -- --template react
    
  2. 与 Create React App 工具不同,Vite 需要手动安装所有 Node.js 依赖项。将工作目录更改为你的 /frontend 目录:

    cd frontend
    
  3. 接下来,你可以通过运行以下命令来安装依赖项:

    npm install
    

    一旦这个过程完成,你将拥有一个正确初始化的 React 项目,准备进行开发。

  4. 虽然你可以通过简单的命令 (npm run dev) 开始你的项目,但这正是安装你的 CSS 框架 Tailwind CSS 的机会,因为它使用 Tailwind CSS 设置更容易开始,而且不需要处理少量捆绑的 Vite 特定的 CSS 样式。运行以下命令以安装 CSS 框架,以便安装 Tailwind 框架并初始化其配置文件:

    npm install -D tailwindcss postcss autoprefixer
    npx tailwindcss init -p
    

    虽然第一个命令安装了 Tailwind 本身以及一些需要的开发依赖包,但第二个命令创建了一个 tailwind.config.js 文件,这是你将用于微调和配置 Tailwind 实例的文件。

  5. 设置一个简单的项目来展示基本的 React 概念是有用的。通过替换文件内容来配置你新创建的 tailwind.config.js 文件,如下所示。Tailwind 为 React 的配置如下:

    /** @type {import('tailwindcss').Config} */
    export default {
      content: [
        "./index.html",
        "./src/**/*.{js,ts,jsx,tsx}",
      ],
      theme: {
        extend: {},
      },
      plugins: [],
    }
    
  6. 最后,编辑 Vite 创建并填充了一些默认样式的 src/index.css 文件。删除所有内容,并插入 tailwind 指令:

    @tailwind base;
    @tailwind components;
    @tailwind utilities;
    

    现在,你有一个基本的 React 应用程序,并已设置 Tailwind。

    这个过程的最新文档通常可以在优秀的 Tailwind CSS 网站上找到(tailwindcss.com/docs/guides/vite),以及 Next.js、Remix.js 和其他框架的类似文档。

    删除 App.css 文件,因为你不会使用它,然后按照以下步骤填充你应用程序的着陆页。

  7. 用以下代码替换 App.jsx 的内容:

    export default function App() {
      return (
        <div className="bg-purple-800 text-white min-h-screen p-4 flex flex-col justify-center  items-center">
          <h1 className="text-3xl font-thin">
            Hello FARM stack!
          </h1>
        </div>
      )
    }
    
  8. 回到终端,使用以下命令启动你的 React 项目:

    npm run dev
    

如果你打开端口 5173http://localhost:5173/)的浏览器标签页,这是 Vite 的默认端口,你会看到一个紫色的屏幕,页面中间有标题 Hello FARM stack!。然而,在这个页面背后,有一些代码和许多包,你可以通过查看 Vite 为你构建的前端文件夹来检查这个生成的代码。

注意

在你的项目中,有一个包含所有项目依赖项的 node_modules 目录。除非进行极端的调试操作,否则你不需要触摸这个文件夹。

public 文件夹中,有一些你在这个项目中不会使用的通用文件,例如 png 标志和 favicon.ico 文件。这个文件夹将包含 Vite 不会处理的静态资产,例如图片、字体等。你可以保持原样,或者稍后用于需要用户接收且未经 Vite 修改的文件。

/src 目录中有一个重要的 HTML 文件,名为 index.html。这个裸骨文件包含一个具有 id 参数的 div 元素,这是 React 加载你整个应用程序的地方。

你将在 /src 目录下创建大部分应用程序。代表你整个应用程序的 App.jsx 文件将位于此文件中,而这个文件反过来将在 index.html 文件中根元素的 id 参数指定的单个 div 元素中渲染。这种复杂性对于 React 在开发过程中仅通过几个额外步骤就能提供的声明式方法来说是必要的。在这个阶段,不同的方法取决于你的用例,因此你可能需要为组件或页面创建额外的文件夹,或者按功能分组功能。

React 允许你以无数种方式为应用程序设置样式。你可以使用经典的 CSS 样式表或 语法优美的样式表SASS),你可以选择 JavaScript 风格的对象,或者你可以选择一个现代且高效的解决方案,如 styled-components。此外,所有主要的 UI/CSS 框架都有 React 版本,例如 Material UI、Bootstrap 和 Semantic UI。

在整本书中,你将使用 Tailwind CSS,它采用了一种典型的开发者喜欢的非典型方法,因为它不会妨碍你。它非常适合定义基本的简单样式,使页面看起来简单整洁,但如果有需要,它也能很好地实现来自 Figma 或 Adobe XD 文件的像素级设计。

Tailwind CSS 和安装

Tailwind CSS 是一个以实用工具为首要的框架,它将 CSS 转换为可以直接在标记中使用的类,并使你能够实现复杂的设计。只需向你的 HTML 元素添加类,你就能创建完全样式的文档。查看 Tailwind 文档 tailwindcss.com/,因为你会用它来满足所有你的 React 样式需求。

你的 App.jsx 文件有一个具有以下类列表的 div 元素:

  • bg-purple-800:使背景为紫色

  • text-white:使文本为白色

  • min-h-screen:使高度全屏

  • p-4:添加填充

  • flex:显示一个 flex 容器

  • flex-col:将 flex 方向设置为垂直

  • justify-center:使项目居中

  • items-center:在次轴上居中项目

className 来自 JavaScript 语法扩展JSX),这是 React 创建 HTML 的语言。Visual Studio Code 会在你输入第一个引号时提供一些自动完成。

这是一个基本的 React + Tailwind 设置。如果你想练习 Tailwind CSS,尝试创建一个全高页面,带有一些虚线边框和一些标题。

下一个部分将通过使用 JSX 来探讨 React 最基本的部分。

JSX 的组件和构建块

根据 2023 年最新的 Stack Overflow 开发者调查 1,React 是开发者的首选,并且仍然是最受欢迎的前端 JavaScript 库。像 FastAPI 一样,React 拥有一个写得非常好且结构化的文档网站 (react.dev/),因此从那里开始并一路向上是你在开始 React 之旅时以及成为资深开发者时能做的最好的事情之一。

1 survey.stackoverflow.co/2023/#most-popular-technologies-webframe

简单来说,React 允许你以一种比纯 JavaScript 或第一代 JavaScript 库(如 jQuery)更简单、更高效的方式构建 UI,因为它处理了如果用纯 JavaScript 执行将会非常繁琐且容易出错的操作。React 通过 JSX 实现这一点,JSX 是一种增强的 JavaScript 和 HTML 混合,React 会将其编译成 JavaScript。

更精确地说,JSX 是 React 中用于以直观方式构建交互功能和 UI 的 JavaScript 扩展。它允许你在 JavaScript 中编写类似 HTML 的代码,使代码更容易理解和维护。

React 执行两个基本功能,这在你的新创建的 Vite 项目的 main.jsx 文件中是可见的。如果你打开并检查该文件,你会看到导入了两个包。React 负责使用 JSX 等功能,而 ReactDOM 则在 DOM 上执行操作。

每个 React 描述中的关键词是 声明式,因此作为开发者,你可以描述(声明)UI 以及相关的数据流和操作。然后,React 将通过其机制和优化来确定如何实现所需的功能。

JSX 是将整个 React 概念粘合在一起的内聚力。React 页面或应用的最小构建块是 React 元素。一个简单的元素可能如下所示:

const title = <h1>The Car Sales App</h1>

这段代码看起来像 h1 HTML 元素,但它也看起来像 JavaScript。这两个观察都是有效的,因为 JSX 允许你创建可以插入到 React 的虚拟 DOM 树中的 React 元素,这与实际的 HTML 不同。React 通过一个称为 diffing 的过程来处理更新 DOM 以匹配虚拟 DOM 的繁琐工作,然后通过一个名为 Babel 的工具将 JSX 元素编译成实际的 HTML 元素。

React 元素是不可变的,这意味着一旦你创建了它们,就无法更改它们,正如 React 网站所述,它们就像电影中的单个帧。然而,它们可以被新的元素或帧所替换。

需要注意的是,每个 React 组件,包括你当前的唯一组件App.jsx文件,都必须只返回一个元素——一个div元素或一个片段(本质上,一个空标签<>)以及其中包含的所有 React 元素。以下示例将展示如何构建一些组件:

在你的App.jsx文件中创建一些简单的元素,通过粘贴以下代码:

export default function App() {
    const data = [{
            id: 1,
            name: "Fiat"
        },
        {
            id: 2,
            name: "Peugeot"
        },
        {
            id: 3,
            name: "Ford"
        },
        {
            id: 4,
            name: "Renault"
        },
        {
            id: 5,
            name: "Citroen"
        }
    ]
    return (
        <div className="bg-purple-800 text-white min-h-screen p-4 flex flex-col items-center">
            <div className="mb-4 space-y-5">
                <h2>Your budget is {budget}</h2>
                <label htmlFor="budget">Budget : </label>
                <input type="number" className="text-black" step={1000} id="budget" value={budget} onChange={(e) => setBudget(e.target.value)} />
            </div>
            <div className="grid grid-cols-3 gap-4">
                {data.filter((el) => el.price <= budget).map((el) => {
                    return (
                        <Card car={el} key={el.id} />
                    )
                }
                )}
            </div>
        </div >
    );
}

当你运行你的 Web 应用时,你应该看到以下页面被渲染:

图 4.1:使用 React 生成的简单页面

概述

让我们回顾一下你在 React 应用中创建的内容:

  1. 首先,你声明了一些数据,一个简单的汽车品牌列表数组。目前,这些数据是硬编码的,但这个数据可能来自外部 API。

  2. 然后,在return语句中,你通过使用 JavaScript 的map函数映射这个数组,通过引用数组的每个元素作为el来迭代。

最后,你需要返回这些元素。在这种情况下,它们是字符串,并且你将它们包裹在div元素中。由于className关键字,你可以看到 Tailwind 非常详尽地使用了它。最后,在App.jsx文件中添加了一些小的改动,这样 React 就不会在控制台抱怨——这是一个关键属性,这样 React 才能处理我们的列表,即使它发生变化。你可以在文档中阅读有关此键的用途和需求:react.dev/learn/rendering-lists#keeping-list-items-in-order-with-key

键是一个 React 在创建 DOM 元素数组时需要的唯一标识符,这样它就知道哪个要替换、保留或删除。这是一个相当简单的例子,但它展示了 JSX 的基本功能。需要记住的一个重要事情是,你必须返回恰好一个元素,例如一个div元素、一个标题或一个 React 片段。毕竟,函数组件是函数(你将只使用函数组件)。

React 没有为遍历对象数组或if-else结构提供专门的模板语言和特殊语法。相反,你可以依赖 JavaScript 的全部功能,并使用标准语言特性,如map用于遍历数组,filter用于过滤数据,三元运算符用于if-else结构,模板字符串用于字符串插值,等等。

下一个部分将讨论 React 组件。

组件

组件是 UI 的可重用部分。它们是返回 UI 片段或单元的函数,这些片段或单元是用 JSX 编写的。它们是 React 中 UI 的构建块,允许你创建模块化、可重用的代码片段,这些代码片段可以组合成用户界面的所需输出。

图 4.2 展示了一个用户界面应用,该界面在视觉上被拆分为独立的组件。每个矩形代表一个导入到主应用组件中的独立组件。有些可能被重复多次,而其他组件,如页眉和页脚,可能只有一个实例:

图片

图 4.2:将应用拆分为组件

规划 React 网站开发的第一阶段之一是识别可以抽象为组件并以某种方式重用或至少抽象为单独单元的区域或部分。

接下来,我们将创建一个用于在页面上显示页眉的最小组件。该组件的任务很简单:显示页眉,在你的情况下,是页面的标题。

在 React.js 中的函数组件定义为具有 .jsx.js 扩展名的文件,并且像你的 App.jsx 文件(根组件)一样,它们必须返回一个单一的 JSX 元素。文件名应该大写。这是一个使用你之前安装的 Visual Studio Code React 扩展的绝佳时刻,因为它提供了创建标准组件的有用代码片段。按照以下步骤操作:

  1. /src 文件夹中创建一个文件夹,命名为 components,并在其中创建一个名为 Header.jsx 的新文件。

  2. 现在,打开新创建的文件,输入 rafce。编辑器应该建议创建一个名为 reactArrowFunctionExportComponent 的组件外壳。

  3. 从建议列表中选择此条目,你会看到你的文件被填充了一个典型的 ES6 箭头函数组件导出:

    const Header = () => {
        return (
            <div>Header</div>
        )
    }
    export default Header
    

    此文件定义了一个单一的 JSX 最顶层元素——称为 Header——并在底部导出它。

  4. 对此文件进行一些编辑,使用我们的 Tailwind CSS 框架类来创建一个 div 元素。在此阶段,不必担心响应式或花哨的配色。用以下代码替换 Header 元素:

    const Header = () => {
      return <div className="text-3xl border-yellow-500 border-4 p-4">Header</div>;
    };
    export default Header;
    
  5. 在这些编辑(纯粹与 Tailwind 相关)之后,将第一个组件导入到我们的 App.jsx 文件中。导入以相对路径处理——记住,点表示文件的当前目录(在你的情况下是 /src),而 /components 是你存放组件的文件夹。App.jsx 文件还应包含一个 Header 组件的实例。用以下代码替换 App.jsx 文件的内容:

    import Header from "./components/Header";
    export default function App() {
      const data = [
        { id: 1, name: "Fiat" },
        { id: 2, name: "Peugeot" },
        { id: 3, name: "Ford" },
        { id: 4, name: "Renault" },
        { id: 5, name: "Citroen" },
      ];
      return (
        <div className="bg-purple-800 text-white min-h-screen p-4 flex flex-col justify-between items-center">
          <Header/>
          <h1 className="text-3xl font-thin border-b-white border-b m-3">
            Hello FARM stack!
          </h1>
          <div>
            {data.map((el) => {
              return (
                <div key={el.id}>
                  Cars listed as{" "}
                  <span className="font-bold">{el.name.toUpperCase()}</span>
                </div>
              );
            })}
          </div>
        </div>
      );
    
  6. 如果你没有停止 npm run dev 进程,Vite 应该会自动为你重新加载应用。

你会看到你的简单网页现在有一个简单的页眉组件。它是一个 H1 元素,并有一些基本的格式化,因为它呈紫色,居中,并带有黄色边框。你以自闭合标签的形式导入了组件。值得注意的是,组件可以是自闭合的,或者(例如,H1 标签)可以包含通过子元素提供的数据。

你刚刚制作了第一个非常简单的 React 函数组件。通过这种方式,你可以将整个网站的功能分解开来。你可以添加页脚、一些导航等。将应用程序分解成组件并决定什么应该构成一个单独的组件的过程非常重要,React 文档有一个专门的页面介绍了这个过程:reactjs.org/docs/thinking-in-react.html

创建动态组件

制作这样的组件既方便又快捷,但如果输出是固定的,可能会变得繁琐。幸运的是,React 组件是函数,函数可以接受参数,然后对这些参数进行有用的操作。假设你想创建一个组件,用它来替换你的普通汽车品牌列表,并以更令人愉悦和更有信息量的方式显示信息。你可以传递数据数组中每个汽车的数据(一个对象),并按指定方式格式化。

要重新执行显示列表的流程,请按照以下步骤操作:

  1. components文件夹中创建一个新文件,命名为Card.jsx,并输入rafce以获取正确的组件模板。用以下代码替换Card组件:

    const Card = ({ car: { name, year, model, price } }) => {
        return (
            <div className="bg-white rounded m-4 p-4 shadow-lg">
                <h1 className="text-2xl text-gray-600">{name}</h1>
                <p className="text-sm text-gray-600">{year} - {model}</p>
                <p className="text-lg text-right text-gray-600 align-text-bottom">${price}</p>
            </div>
        )
    }
    export default Card
    

    与你之前制作的Header组件不同,这个组件接受 props,即定义组件行为的属性。Card组件是一个简单的可重复使用的抽象,在需要的地方重复出现在页面上。你还使用了 ES7 对象解构,使组件看起来更易于阅读,并且不必重复props.nameprops.model等。

  2. 更新App.jsx主文件,以正确使用Card。用以下代码替换App.jsx的内容:

    import { useState } from "react";
    import Card from "./components/Card";
    export default function App() {
        const data = [
            { id: 1, name: "Fiat", year: 2023, model: "Panda", price: 12000 },
            { id: 2, name: "Peugeot", year: 2018, model: "308", price: 16000 },
            { id: 3, name: "Ford", year: 2022, model: "Mustang", price: 25000 },
            { id: 4, name: "Renault", year: 2019, model: "Clio", price: 18000 },
            { id: 5, name: "Citroen", year: 2021, model: "C3 Aircross", price: 22000 },
            { id: 6, name: "Toyota", year: 2020, model: "Yaris", price: 15000 },
            { id: 7, name: "Volkswagen", year: 2021, model: "Golf", price: 28000 },
            { id: 8, name: "BMW", year: 2022, model: "M3", price: 45000 },
            { id: 9, name: "Mercedes", year: 2021, model: "A-Class", price: 35000 },
            { id: 10, name: "Audi", year: 2022, model: "A6", price: 40000 }
        ]
        const [budget, setBudget] = useState(20000)
        return (
            <div className="bg-purple-800 text-white min-h-screen p-4 flex flex-col items-center">
                <div className="mb-4 space-y-5">
                    <h2>Your budget is {budget}</h2>
                    <label htmlFor="budget">Budget : </label>
                    <input type="number" className="text-black" step={1000} id="budget" value={budget} onChange={(e) => setBudget(e.target.value)} />
                </div>
                <div className="grid grid-cols-3 gap-4">
                    {data.map((el) => {
                        return (
                            <Card car={el} key={el.id} />
                        )
                    }
                    )}
                </div>
            </div>
        );
    }
    
  3. 接下来,目标是使用新创建的Card组件,并传递所有所需的数据。用以下代码更新Card.jsx

    const Card = ({ car: { name, year, model, price } }) => {
        return (
            <div className="bg-white rounded m-4 p-4 shadow-lg">
                <h1 className="text-2xl text-gray-600">{name}</h1>
                <p className="text-sm text-gray-600">{year} - {model}</p>
                <p className="text-lg text-right text-gray-600 align-text-bottom">${price}</p>
            </div>
        )
    }
    export default Card;
    

现在,在通过数据映射时,你不再返回div元素,而是返回你的Card组件,并传递给它键——即car对象的 ID。请注意,ID 必须是唯一的,否则 React 将在控制台抛出警告,表明我们没有指定它。此外,你传递了一个可以称为el的东西,并将其设置为元素——即来自你的数据数组的car对象。

你的Card组件现在能够显示与汽车相关的数据——每张卡片都包含一辆汽车的数据。你通过 props(简称属性)将数据传递给每张卡片。你只需在组件中接受它即可。

向组件传递 props 很容易,但由于 props 提供单向通信,在大多数应用程序中,你还将不得不处理状态,这将在下一节讨论。

事件和状态

React 提供并包装了所有标准的 DOM 事件——按钮和链接点击、表单提交、鼠标悬停、按键和键释放,等等。在 React 中处理这些事件相对直观。一个点击事件将由一个名为 onClick 的合成事件来处理;事件命名使用 驼峰命名法。在 React 中,事件处理程序是在发生交互时被触发的函数。这些函数接受函数处理程序作为属性,这些处理程序是其他函数。最简单的情况是点击一个按钮(尽管它可以是任何 DOM 元素)。

/components 目录中创建一个名为 Button.jsx 的文件,其中包含一个按钮,当点击时,会在控制台中显示一条消息:

  1. 在执行 racfe 操作后,将以下代码粘贴到你的 Button.jsx 文件中:

    const Button = () => {
        const handleClick = () => {
            console.log("click")
        }
        return ( <
            button className = "bg-white text-purple-800 hover:bg-gray-300 p-3 rounded-md"
            onClick = {
                handleClick
            } > Button < /button>
        )
    }
    export default Button
    

    这是一个简单的示例,它展示了底层机制;onClick 是 React 知道它应该监听什么事件的反应方式,而 handleClick 函数执行你的(相当简单)业务逻辑。如果你将按钮导入 App.jsx 文件并点击按钮,你应该在控制台中看到消息。

  2. 实现非常简单;更新 App.jsx 组件,如下所示:

    import { useState } from "react";
    import Card from "./components/Card";
    import Button from "./components/Card";
    export default function App() {
      const data = [
        { id: 1, name: "Fiat", year: 2023, model: "Panda", price: 12000 },
        // continued
      ];
      const [budget, setBudget] = useState(20000);
      return (
        <div className="bg-purple-800 text-white min-h-screen p-4 flex flex-col items-center">
          <div className="mb-4 space-y-5">
            <h2>Your budget is {budget}</h2>
            <Button />
            <label htmlFor="budget">Budget : </label>
            <input
              type="number"
              className="text-black"
              step={1000}
              id="budget"
              value={budget}
              onChange={(e) => setBudget(e.target.value)}
            />
          </div>
          <div className="grid grid-cols-3 gap-4">
            {data.map((el) => {
              return <Card car={el} key={el.id} />;
            })}
          </div>
        </div>
      );
    

带有事件和状态的 React 钩子

React 的组件本质上是将状态转换为用户界面的函数。一个 React 组件是一个接受 props 作为参数的函数。它可以被视为一个可更新的数据结构,负责组件的行为。函数的输出,即组件,是一个 JSX 元素。本质上,React 钩子是功能结构,它使你能够访问组件的生命周期并改变其状态。

虽然有许多标准的 React 钩子和许多外部的钩子,但你将只使用两个,这两个是最基本的 React 理解:useStateuseEffect。这两个钩子将保留在即将到来的 React 版本 19 中,而其他如 useMemouseCallback 以及一些其他钩子将被逐渐弃用。同样,掌握 React 需要一些时间,但许多标准的 UI 功能可以通过这两个钩子的巧妙组合来实现。

使用 useState 创建有状态变量

useState 钩子允许你在组件中维护某种状态。例如,你可能想在你的单页应用(SPA)中维护某种状态,这样网站就不会根据你指定的预算显示任何太贵的汽车。你可以创建一个简单的文本框,将其设置为仅显示数值,并将其与名为 budget 的状态变量连接起来。

App.jsx 文件的内容替换为以下代码:

import { useState } from "react";
import Card from "./components/Card";
export default function App() {
  const data = [
    { id: 1, name: "Fiat", year: 2023, model: "Panda", price: 12000 },
    { id: 2, name: "Peugeot", year: 2018, model: "308", price: 16000 },
    { id: 3, name: "Ford", year: 2022, model: "Mustang", price: 25000 },
    { id: 4, name: "Renault", year: 2019, model: "Clio", price: 18000 },
    { id: 5, name: "Citroen", year: 2021, model: "C3 Aircross", price: 22000 },
    { id: 6, name: "Toyota", year: 2020, model: "Yaris", price: 15000 },
    { id: 7, name: "Volkswagen", year: 2021, model: "Golf", price: 28000 },
    { id: 8, name: "BMW", year: 2022, model: "M3", price: 45000 },
    { id: 9, name: "Mercedes", year: 2021, model: "A-Class", price: 35000 },
    { id: 10, name: "Audi", year: 2022, model: "A6", price: 40000 }
  ]
  const [budget, setBudget] = useState(20000)
  return (
    <div className="bg-purple-800 text-white min-h-screen p-4 flex flex-col items-center">
      <div className="mb-4 space-y-5">
        <h2>Your budget is {budget}</h2>
        <label htmlFor="budget">Budget : </label>
        <input type="number" className="text-black" step={1000} id="budget" value={budget} onChange={(e) => setBudget(e.target.value)} />
      </div>
      <div className="grid grid-cols-3 gap-4">
        {data.filter((el) => el.price <= budget).map((el) => {
          return (
            <Card car={el} key={el.id} />
          )
        }
        )}
      </div>
    </div >
  );
}

在前面的示例中,你首先从 React 中导入 useState 钩子。useState 钩子返回两个值:

  • 一个变量,可以是任何你想要的东西——一个数组或一个对象,一个简单的数字,或一个字符串

  • 一个设置此状态变量值的函数

虽然你可以使用任何合法的 JavaScript 名称,但使用变量的名称——在你的情况下,budget——以及以相同名称前缀的名称是一个好习惯:setBudget。通过这一行简单的代码,你已经告诉 React 设置一个名为budget的状态单元,并设置一个设置器。useState()调用的参数是初始值。在以下情况下,你将其设置为 20,000 美元。

以下图像显示了带有可更新预算框的更新后的 Web 应用:

图片

图 4.3:列出汽车

现在,你可以在整个页面上自由使用这个状态变量。在这里,你将useState调用放在了App函数组件内部——如果你尝试将其放在其他地方,它将不会工作:钩子从定义组件本身的函数体内部访问组件的生命周期!

一直向下到组件的底部,你添加了一个简单的文本框。你可以使用 HTML 将其设置为仅显示数值,步长为1000,并添加一个onChange处理程序。

这是一个再次强调的好时机,即 React 使用所谓的合成事件,它是浏览器原生事件的一个包装器,使 React 能够实现跨浏览器兼容性。一旦你记住了几个差异(事件使用的是 camelCase 而不是小写,你必须将它们传递给 JSX 中的函数),你将很快就能编写事件处理程序。

在你的应用中,你向文本框添加了一个onChange事件,并设置它来处理状态,然后你设置了预算的新值。每次你更改值时,setBudget函数都会触发,因此预算更新,并显示符合你的预算约束条件的不同Card实例。

这个onChange事件获取文本框的当前值(target.value,就像原始 DOM 事件一样,因为它只是一个包装器),并使用我们定义在函数上方刚刚的useState调用将你的预算状态设置为这个值。

最后,你添加了一个使用此预算值并显示它的div元素组件。你已经在你的应用根组件中添加了一个状态变量。你可以设置它、获取它,并在页面上显示它。

现在,你已经完成了开发 React 应用时典型的另一个任务。你允许用户输入他们的预算,并在页面上显示它。如果你想区分符合该预算的汽车和不符合的汽车,你可以使用一些简单的 JavaScript 和组件状态。为了使这生效,将当前硬编码的小数据样本设置为状态变量本身,这样用户就可以过滤它并仅显示那些在价格范围内的汽车。

这个过程很简单,涉及到纯 JavaScript 来完成显示满足价格小于或等于你的预算条件的汽车数组的任务。提示:使用以下代码示例中加粗显示的 JavaScript 过滤数组:

{data.filter((el) => el.price <= budget).map((el) => {
    return (
        <Card car={el} key={el.id} />
        )
    }
)}

到目前为止,你可以深入研究优秀的 React.js 文档,了解更多关于useStateHook 及其兄弟 Hook useReducer的信息(react.dev/reference/react/useState)。这是一个可能被视为useStateHook 泛化的 Hook。它最适合当你必须处理多个相互关联的状态时,使用多个简单的useStateHook 来管理它们可能会变得繁琐且难以维护。

在本节中,你看到了如何通过useStateHook 以非常简单直接的方式添加一个有状态的变量,以及如何通过常规事件来操作状态。

接下来,你将学习如何将你的数据从高效的 FastAPI 后端传输到你的 React.js 前端。你将了解另一个名为useEffect的 Hook。

使用useEffect与 API 和外部世界进行通信

在这里,你可以使用免费的模拟 REST API。然而,你确实需要解决访问外部数据以及管理外部事件的问题。外部指的是什么,你可能想知道?

你已经看到,React 及其强大的 Hooks 围绕着同步 UI 到状态和数据这一任务。组件可以包含其他组件,它们共同构成了所谓的组件树,然后这个组件树会不断与当前状态进行比较。React 负责所有这些协调工作,以确定应该渲染、更新以及更多。

在 React 数据流过程之外的事件被称为副作用。以下是一些显著的 React 副作用示例:

  • 执行 API 调用——从外部服务器发送或接收数据

  • 通过 WebSockets 或流订阅外部数据源

  • 在本地存储或会话存储中设置或获取数据值

  • 事件监听器和它们的清理函数

记住,React 在一个持续的数据流中工作,其底层系统不断扫描更新,并准备好重新渲染它认为需要更新的组件。以下示例将说明这一点。

假设你正在开发你的汽车销售应用程序,并且你需要列出所有注册了账户的用户。当前的任务既简单又常见。你有一个专门的页面——它将存在于一个名为/users或类似 URL 上,并且应该用来自外部 API 的数据(想象一下一个包含对象的 JavaScript 数组)填充。这个 API 将由 FastAPI 提供支持,但就目前而言,你将使用一个现成的模拟解决方案,称为 Jsonplaceholder。

你需要发出的GET请求应指向 URL jsonplaceholder.typicode.com/users/

你已经了解了如何创建组件,为它们提供 props 并设置状态,所以这不应该是个问题。当涉及到从外部 API 加载数据时,你可能只是使用像 FetchAxios 这样的工具,就像你在使用一个普通的纯 JavaScript 应用程序一样。FetchAxios 是用于向服务器发起 HTTP 请求的最流行的两个 JavaScript 库。

尝试在 React 组件中获取数据并将状态设置为结果 JSON 将会启动一个无限循环。记住,React 在异步代码存在的服务器组件之前就已经存在了。

每当组件的状态发生变化并且组件被重新渲染时,新的渲染会再次触发对 API 的 fetch 调用,状态再次改变以设置为用户列表,等等。

在这个组件的数据流中,数据的获取被认为是外部的——不是主组件生命周期的一部分。它是在组件执行之后执行的。

为了处理这个问题,React 有一个非常优雅的解决方案——useEffect 钩子。你可以通过编辑 App.jsx 主组件来创建一个新的应用程序,然后从你的 API 端点显示用户列表。

你可以使用 useEffect 钩子实现一个可能的解决方案。将以下代码粘贴到你的 App.jsx 文件中(参考 App3.jsx):

import { useState, useEffect } from "react";
export default function App() {
    const [users, setUsers] = useState([]);
    useEffect(() => {
        fetchUsers();
    }, []);
    const fetchUsers = () => {
        fetch("https://jsonplaceholder.typicode.com/users")
            .then((res) => res.json())
            .then((data) => setUsers(data));
    };
    return (
        <div className="bg-purple-800 text-white min-h-screen p-4 flex flex-col items-center">
            <h2 className="mb-4">List of users</h2>
            <div className="grid grid-cols-3 gap-4">
                <ol>
                    {users.map((user) => (
                        <li key={user.id}>{user.name}</li>
                    ))}
                </ol>
            </div>
        </div>
    );
}

App.jsx 文件顶部,导入 useStateuseEffect,然后你可以开始创建你的唯一状态变量——users 数组,将其初始化为空数组。

fetchUsers 函数很简单——它调用 API 并使用承诺返回 JSON 格式的数据,也可以是一个 async/await 函数。

useEffect 钩子,就像所有钩子一样,是在组件函数内部执行的。然而,它不返回任何值,它接受两个参数:要执行的功能(在这种情况下,fetchUsers),以及一个依赖数组,一个值数组的改变将触发效果的重新执行。如果函数应该只触发一次,则数组应为空。如果你想从下一个 API URL 获取其他用户,你必须将 URL 添加到数组中。

useState 一样,其中还涉及许多细微之处。例如,你可以在 useEffect 体的底部提供一个清理函数,以确保任何长期效果都被移除,但这应该能给你一个如何处理调用外部 API 的动作的基本概念。

此外,useContext 允许 React 覆盖整个组件区域并直接传递值,而无需通过可能实际上不需要它的几个组件传递,这个过程被称为属性钻取。你甚至可以创建自己的钩子并抽象出可以在应用程序的多个地方重用的功能,确保更好的可维护性和减少重复。

随着 Hooks 的引入,整个生态系统变得更加清晰和简洁,将业务逻辑映射到 UI 的过程也变得更加流畅和逻辑化。

你现在拥有了在组件或应用中设置和获取状态,以及以可预测和可控的方式与外部 API 服务通信所需的知识,同时编写干净和简单的代码。仅仅使用 React 和其 Hooks 就可以让你获得网络开发的专业技能,但围绕 React 构建的整个生态系统中的包和模块同样重要和有用,就像核心库一样。

探索 React Router 和其他有用的包

到目前为止,你只创建了一些单页应用,但还没有接触一些高级功能。然而,单页应用并不局限于单个 URL。例如,当你导航到你的 Gmail 账户时,你会看到 URL 会随着你可能采取的每个动作而改变。

虽然有几种解决方案可以实现单页应用中的路由,但 React Router 是标准解决方案,它是一个经过充分测试的成熟包。

前端页面路由的底层理念是它应该能够根据加载的路由在同一个页面上渲染不同的组件。例如,/about 路由会导致应用在主 App 组件中加载一个名为 About.jsx 的组件,移除之前加载的其他组件。该包提供了一个基本结构,在 BrowserRouter 类中,可以用来包装整个根 App 组件。

React 是如此受欢迎的框架,以至于有一个多样化的工具和集成生态系统,你可以了解。正如你之前已经看到的,除了 Tailwind,你几乎可以使用任何 UI 或 CSS 框架,无论是直接使用还是通过一些优化的 React 版本,如 Bootstrap,或者更紧密地与 React 结合,如 Ant Design。你可以通过 Framer Motion 的微妙动画来增强用户体验,也可以通过一些优秀的表单库如 React Hook Form 来加速表单的开发。对于复杂的状态问题,Redux 是最受欢迎和最广泛采用的行业标准,但还有许多针对本地和全局状态管理的较小或专用库,如 RecoilZustandReact Query

摘要

本章简要介绍了世界上最受欢迎的用户界面库——React.js。

本章还详细介绍了 JSX 是什么以及为什么它对开发者来说如此方便。它探讨了 React 的基本构建块——函数组件,以及在设计它们时必须遵循的基本规则。它还介绍了两个基本的 React Hooks,当结合使用时,允许你开始构建基本的用户界面,维护和更改组件的状态,以及与外部 API 交互。

最后,本章介绍了实现一些可以使开发自定义应用时生活更轻松的 React 库。这些库都拥有优秀的文档,并且经常更新。

下一章将利用这些基础知识以及 React 来创建一个简单但功能齐全且动态的应用程序。

第六章:认证和授权

认证的概念——证明用户是他们所声称的人——以及授权——确保经过认证的用户应该或不应该能够对你的 API 执行某些操作——都是非常复杂的。在本章中,您将从非常实用的角度以及 FARM 堆栈的角度来探讨认证和授权的主题。

本章将详细说明一个简单、健壮且可扩展的 FastAPI 后端设置,基于JSON Web Token(JWT)——可以说是近年来出现的最受欢迎和实用的认证方法。然后,您将看到如何将基于 JWT 的认证方法集成到 React 中,利用 React 的一些强大功能——即 Hooks、Context 和 React Router。

到本章结束时,您应该对 FastAPI 在后端和 React 在前端提供的认证方法有一个牢固的掌握,您将能够以细粒度和精确度对用户进行认证并控制他们在应用中的操作。

本章将涵盖以下主题:

  • 用户模型及其与其他资源的关系

  • JWT 认证机制——整体概述

  • FastAPI 中的认证和授权工具

  • 如何保护路由、路由器或整个应用

  • React 认证的各种解决方案

技术要求

要运行本章中的示例应用,您应该具备以下条件:

  • Node.js 版本 18 或更高

  • Python 3.11.7 或更高版本

要求与前面章节中的要求相同,您将安装的新包将按其使用情况进行描述。

理解 JSON Web Token

HTTP 是一种无状态协议,仅此一点就暗示了几个重要的后果。其中之一是,如果您想在请求之间持久化某种状态,您必须求助于一种能够记住一组数据(例如,登录的用户是谁,在之前的浏览器会话期间选择了哪些项目,或者网站偏好设置是什么)的机制。为了实现这种功能并识别当前用户,作为开发人员,您有众多选项可供选择。以下是一些最受欢迎和最现代的解决方案:

  • 基于凭证的认证:它要求用户输入个人凭证,如用户名或电子邮件,以及密码

  • 无密码登录:用户在创建账户后,通过电子邮件或其他通信渠道接收一个安全、时间有限的令牌进行认证,而不是使用传统的密码。安全的令牌用于会话认证,消除了输入或记住密码的需要。

  • 生物识别密码:它利用用户的生物特征,如指纹,进行认证。

  • 社交认证:用户利用他们现有的社交媒体账户(例如,Google、Facebook 或 LinkedIn)进行认证。这将用户的社交媒体账户与平台上的账户关联起来。

  • 经典个人凭证方法:用户在注册时提供电子邮件并选择密码。用户还可以选择用户名作为可选项。

本章将考虑经典的个人凭证方法。当用户注册时,他们提供电子邮件并选择密码,以及可选的用户名。

JWT 是什么?

虽然有不同方式在应用程序的不同部分之间维护用户的身份,但 JWT 可以说是连接前端应用程序(如 React、Vue.js 和 Angular)或移动应用程序与 API(在我们的案例中,是 REST API)最常见和最受欢迎的方法。JWT 仅仅是一个标准,一种结构化由看似随机的字符和数字组成的大字符串的方式,以安全的方式封装用户数据。

JWT 包含三个部分——头部负载签名。头部包含有关令牌本身的元数据——用于签名令牌的算法和令牌的类型。

负载数据是最有趣的部分。它包含以下必要的认证信息:

  • 数据(声明):用户的 ID(或用户名)

  • 签发时间字段iat):签发令牌的日期和时间

  • 令牌失效的时间:与令牌的有效期相关联

  • 可选的其他字段:例如,用户名、角色等。

负载数据可以被每个人解码和阅读。您可以阅读更多关于令牌的信息,了解它们在 JWT 文档中的样子:jwt.io.

最后,令牌最重要的部分是签名。签名保证了令牌所声明的信息。签名被重新生成(计算)并与原始签名进行比较——从而防止声明被修改。

例如,考虑一个声明用户名为 John 的 JWT。现在,如果有人试图将其更改为 Rita,他们还需要修改签名以匹配。然而,修改签名将使令牌无效。这种机制确保令牌的内容保持不变且真实。

因此,令牌可以完全取代认证数据——用户或电子邮件和密码组合不需要多次传输。

在接下来的章节中,您将学习如何在您的应用程序中实现基于 JWT 的认证流程。

带有用户和依赖项的 FastAPI 后端

如果应用程序不安全,那么网络应用程序(或移动应用程序)将没有太大用处。您可能已经听说过在认证实现中出现的微小错误,这些错误可能导致数十万甚至数百万个账户被破坏,可能暴露敏感和有价值的信息。

FastAPI 基于 OpenAPI——之前被称为 apiKeyhttpOAuth 2.0openIdConnect 等)。虽然 FastAPI 文档网站 (fastapi.tiangolo.com/tutorial/security/) 提供了一个优秀且详细的教程,用于创建身份验证流程,但它基于 OAuth 2.0 协议,该协议使用表单数据发送凭证(用户名和密码)。

在以下章节中,你将设计一个简单的用户模型,这将使身份验证流程成为可能。然后,你将学习如何将用户数据编码成 JWT,以及如何使用令牌来访问受保护的路线。

身份验证的用户模型

每个身份验证流程的基础是用户模型,它必须能够存储一组最小数据,以便明确地识别用户。最常见的唯一字段是一个电子邮件地址、一个用户名,当然,还有一个主键——在 MongoDB 的情况下是一个 ObjectId 实例。

使用 MongoDB 模型数据与在 第二章使用 MongoDB 设置数据库 中讨论的建模关系型数据库本质上是不同的。驱动思想是提前考虑查询,并考虑你的应用程序将要最频繁执行的查询来建模你的关系。

使用 FastAPI 进行身份验证和授权:教程

通过示例,使用 FastAPI 进行身份验证和授权更容易理解。在接下来的几个子章节中,你将开发一个简单但功能齐全的身份验证系统,它将包含所有必需的步骤。为了突出重要部分,同时尽可能使示例简洁,你将不会使用真实的 MongoDB 连接。相反,你将创建自己的基于 JSON 文件的基础 数据库,该数据库将存储用户在应用程序中注册时的数据,并有效地模拟 MongoDB 集合。首要步骤是审查你的身份验证系统。

审查你的身份验证系统的所有部分

以下列表提供了一个快速回顾,列出了实现 FastAPI 身份验证工作流程所需的工具和包:

  • 要实现 FastAPI 的身份验证工作流程,你必须使用 FastAPI 的安全工具。在 FastAPI 中,当你需要使用 Security() 类声明依赖项时。其他你将需要的 FastAPI 导入是可信赖的类型——在这种情况下,你将使用 bearer 令牌进行授权。你可以参考 FastAPI 文档:fastapi.tiangolo.com/reference/security/#fastapi.security.HTTPBearer

  • 您还需要密码散列和比较功能,这 passlib 可以提供。passlib.context 模块包含一个主要类:passlib.context.CryptContext,设计用于处理与散列和比较字符串相关的许多更常见的编码任务。您的身份验证系统需要两个主要功能:在用户注册期间散列密码,以及在登录期间将散列密码与存储在您的数据库中的散列密码进行比较。

  • 最后,PyJWT 将提供编码和解码 JWT 的功能。

创建模型

下一步涉及在新的虚拟环境中创建基本的 FastAPI 应用程序,激活环境,安装必要的软件包,并创建具有所需字段的合适用户模型:

  1. 创建一个新的目录,使用 cd(更改目录)命令将其设置为工作目录,在 /venv 中创建一个新的 Python 环境,并激活它:

    mkdir chapter6
    cd chapter6
    python -m venv venv
    source ./venv/bin/activate
    
  2. 一旦新的 Python 环境激活,安装身份验证系统和应用程序所需的软件包:

    pip install fastapi uvicorn bcrypt==4.0.1 passlib pyjwt
    

注意

如果您想能够精确地重现书中的代码,强烈建议您使用附带存储库中的 /backend/requirements.txt 文件,并使用 pip install -r requirements.txt 命令安装软件包。

以下是为您的身份验证系统所需的最后三个软件包:

  • Passlib 是一个用于 Python 的密码散列库,它支持广泛的散列算法,包括 bcrypt。它非常有用,因为它提供了一个统一的接口用于散列和验证密码。

  • bcrypt 软件包是一个 Python 模块,它提供了一个基于 Blowfish 密码散列算法的密码散列方法,您将使用此方法。请坚持使用提供的软件包版本,因为后续版本存在一些未解决的问题。

  • PyJWT 是用于编码和解码 JWT 的 Python 库。

  1. 接下来,创建应用程序的模型。由于此应用程序将仅处理用户,因此 models.py 文件相当简单:

    from pydantic import BaseModel, Field
    from typing import List
    class UserBase(BaseModel):
        id: str = Field(...)
        username: str = Field(
            ..., 
            min_length=3, 
            max_length=15)
        password: str = Field(...)
    class UserIn(BaseModel):
        username: str = Field(
            ..., 
            min_length=3,
            max_length=15)
        password: str = Field(...)
    class UserOut(BaseModel):
        id: str = Field(...)
        username: str = Field(
            ..., 
            min_length=3, 
            max_length=15)
    class UsersList(BaseModel):
        users: List[UserOut]
    

模型是自解释的,并且被留得尽可能明确。UserBase 对应于将存储在您的虚拟数据库中或 MongoDB 集合中的用户表示(请特别注意 Object_id)。在给定解决方案中,id 字段将是一个 UUID,因此您将其设置为字符串类型。

注意

为此演示目的的 Python ObjectId() 类。

models.py 文件包含两个额外的辅助 Pydantic 模型:UserIn,它接受用于注册或登录的用户数据(通常是用户名和密码,但可以轻松扩展以包括电子邮件或其他数据),以及 UserOut,它负责在应用程序中表示用户,不包括散列密码但包括 ID 和用户名。

UsersList 最终只是输出所有用户的列表,你将使用这个模型作为受保护路由的示例。现在,构建你的 app.py 文件并创建实际的应用程序。

创建应用程序文件

在定义了模型之后,你现在可以继续创建主要的 FastAPI 应用程序和认证类:

  1. 打开一个新的 Python 文件,命名为 app.py。在这个文件中,创建一个最小的 FastAPI 应用程序:

    from fastapi import FastAPI
    app = FastAPI()
    

    我们很快就会回到这个文件,但现在,让我们尽量让它尽可能短。现在,是时候构建你认证系统的核心了。

  2. 在同一个文件夹中,创建 authentication.py 文件并开始构建它。有了所有这些,打开新创建的 authentication.py 文件,开始构建认证类。为此,你必须首先构建 AuthHandler 类并添加所需的导入:

    import datetime
    import jwt
    from fastapi import HTTPException, Security
    from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
    from passlib.context import CryptContext
    class AuthHandler:
        security = HTTPBearer()
        pwd_context = CryptContext(schemes=[“bcrypt”], deprecated=”auto”)
        secret = “FARMSTACKsecretString”
    

现在你已经了解了所有这些导入,你可以创建一个名为 AuthHandler 的类,该类使用 FastAPI 的 HTTPBearer 作为安全依赖,并从 passlib 定义密码处理上下文。

添加安全依赖和密码处理上下文

这个过程包括多个步骤。你需要添加一个秘密字符串,理想情况下应该是随机生成的,并安全地存储在环境变量中,远离任何 git commit。这个秘密字符串对于哈希密码是必要的。在这里,为了简单起见,你将在这个文件中硬编码它。

因此,继续使用同一个文件,逐步编写所需的功能,如下所示:

  1. authentication.py 文件在 AuthHandler 类下:

    def get_password_hash(self, password: str) -> str:
            return self.pwd_context.hash(password)
    

    这个函数只是创建给定密码的哈希值,这个结果就是你将在数据库中存储的内容。它很好地使用了你之前定义的 passlib 上下文。

  2. authentication.py 文件:

        def verify_password(
            self,
            plain_password: str, 
            hashed_password: str) -> bool:
            return self.pwd_context.verify(
               plain_password, 
                hashed_password)
    

    与前面的函数类似,verify_password 简单地验证 plain_password 的哈希值是否确实等于(已经)哈希过的密码,并返回 TrueFalse

  3. authentication.py 文件:

    def encode_token(self, user_id: int, username: str) -> str:
    payload = {
                “exp”: datetime.datetime.now(datetime.timezone.utc)
                + datetime.timedelta(minutes=30),
                “iat”: datetime.datetime.now(datetime.timezone.utc),
                “sub”: {“user_id”: user_id, “username”: username},
            }
            return jwt.encode(payload, self.secret, algorithm=”HS256”)
    

    你的类的 encode_token 方法利用 PyJWT 包的 encode 方法来创建 JWT 本身,它非常明确;有效载荷包含过期时间(非常重要——你不想 JWT 持续太久)和 发行时间iat 部分)。它还引用了名为 sub 的字典,其中包含你希望编码的所有数据——在这种情况下,是用户 ID 和用户名,尽管你也可以添加角色(普通用户、管理员等)或其他数据。总结一下,JWT 编码了三份数据:

    • 过期持续时间,在这个例子中,30 分钟。

    • 令牌的发行时间,在这个例子中,设置为 now()

    • sub 部分是你想要包含在令牌中的数据(以字典的形式)。在这个例子中,它是用户 ID 和用户名。

  4. 解码 令牌

    继续构建类,因为现在需要反向功能——解码令牌的方法:

    def decode_token(self, token: str):
        try:
            payload = jwt.decode(
                token, 
                self.secret,
                algorithms=[“HS256”])
            return payload[“sub”]
        except jwt.ExpiredSignatureError:
            raise HTTPException(
                status_code=401, 
                detail=”Signature has expired”)
        except jwt.InvalidTokenError:
            raise HTTPException(
                status_code=401, 
                detail=”Invalid token”)
    Defining the dependencyFinalize your class with the dependency to be injected in the routes that will need protection:
    
    

    def auth_wrapper(

    self,
    
    auth: HTTPAuthorizationCredentials = Security(security)) -> dict:
    
    return self.decode_token(auth.credentials)
    
    
    

您将使用此auth_wrapper作为依赖项——它将检查请求头中是否存在有效的 JWT 作为承载令牌,用于所有需要授权的路由或整个路由器。

authorization.py 文件是对认证/授权流程的最小实现。

在前面的步骤中,您将大多数认证和授权功能封装到一个简单紧凑的类中。创建令牌、编码和解码、以及密码哈希和验证。最后,您创建了一个简单的依赖项,用于验证用户并启用或禁用对受保护路由的访问。

构建应用程序的 FastAPI 路由器将与您在第二章中构建的类似,即使用 MongoDB 设置数据库。您将有两个基本端点用于注册和登录,它们将严重依赖于AuthHandler类。

创建用户的 APIRouter

在本节中,您将创建用于用户的 APIRouter,并在认证类和用字典和 UUID 实现的模拟数据库服务的帮助下实现登录和注册功能。为了实现此功能,请执行以下步骤:

  1. 在应用程序的根目录下创建一个名为routers的文件夹,并在其中创建一个名为users.py的文件。将以下代码添加到users.py文件中:

    import json
    import uuid
    from fastapi import APIRouter, Body, Depends, HTTPException, Request
    from fastapi.encoders import jsonable_encoder
    from fastapi.responses import JSONResponse
    from authentication import AuthHandler
    from models import UserBase, UserIn, UserOut, UsersList
    
  2. 在文件开头添加导入之后,创建 APIRouter 和注册端点。注册函数使用模拟的 JSON 数据库存储用户名和哈希密码,通过使用您之前创建的authentication.py文件。

    router = APIRouter()
    auth_handler = AuthHandler()
    @router.post(“/register”, response_description=”Register user”)
    async def register(request: Request, newUser: UserIn = Body(...)) -> UserBase:
        users = json.loads(open(“users.json”).read())[“users”]
        newUser.password = auth_handler.get_password_hash(newUser.password)
        if any(user[“username”] == newUser.username for user in users):
            raise HTTPException(status_code=409, detail=”Username already taken”)
        newUser = jsonable_encoder(newUser)
        newUser[“id”] = str(uuid. uuid4())
        users.append(newUser)
        with open(“users.json”, “w”) as f:
            json.dump({“users”: users}, f, indent=4)
        return newUser
    

    为了演示基于 JWT 的基本认证和授权系统,使用了模拟数据存储解决方案。您不是通过驱动程序连接到 MongoDB 集群,而是使用简单的 JSON 文件来存储用户及其哈希密码——这是一个类似于用于测试和脚手架目的的流行 JSON Server Node 包的解决方案。然而,所有展示的功能和逻辑都适用于真实数据库场景,并且很容易适应 MongoDB 驱动程序或 ODM,如 PyMongo、Motor 或 Beanie。

    在导入之后,包括一些您在处理真实 MongoDB 数据库时可能不需要的包,例如uuid,您已经实例化了 APIRouter 和自定义的AuthHandler类。

    /register 端点接受新用户的数据并在models.py文件中定义的UserIn Pydantic 类中对其进行塑形,而输出设置为UserBase类。这可能是您想要避免的,因为它会将哈希密码发送给新注册的用户。

    代替真实的 MongoDB 数据库,你正在读取一个名为 users.json 的 JSON 文件的内容——这个文件将托管一个非常简单的数据结构,将模拟你的用户 MongoDB 集合:一个包含用户数据的简单字典数组——ID、用户名和哈希密码。

    现在你有了这个“数据库”,或者用户数组,很容易遍历它们并验证是否包含一个尝试注册的用户具有相同的用户名——如果是这样,你只需用温和的 HTTP 409 响应代码和 Username already taken 消息将其忽略。

    如果用户名未被占用,则继续使用你的 auth_handler 实例,并将纯文本原始密码设置为它的哈希版本,安全地存储在数据库中。

    为了能够将用户存储为 Python 字典,请使用 jsonable_encoder 并向其中添加一个新的键:用作新用户 ID 的 uuid 字符串。

    最后,将用户(以包含 ID、用户名和哈希密码的字典形式表示)添加到你的用户列表中,将修改后的列表写入 JSON 文件,并返回用户。

  3. 现在,继续使用 users.py 路由器,你还可以通过在文件末尾添加以下代码来创建 login 端点:

    @router.post(“/login”, response_description=”Login user”)
    async def login(request: Request, loginUser: UserIn = Body(...)) -> str:
        users = json.loads(open(“users.json”).read())[“users”]
        user = next(
            (user for user in users if user[“username”] == loginUser.username), None
        )
        if (user is None) or (
            not auth_handler.verify_password(loginUser.password, user[“password”])
        ):
            raise HTTPException(status_code=401, detail=”Invalid username and/or password”)
        token = auth_handler.encode_token(str(user[“id”]), user[“username”])
        response = JSONResponse(content={“token”: token})
        return response
    

    此代码遵循类似的逻辑:它加载用户数据并尝试通过用户名找到登录用户(类似于查找查询)。如果用户未找到或密码验证失败,端点将引发异常。不具体说明哪个部分失败,而是告知用户整个用户名和密码组合无效,被认为是一种良好的安全实践。如果两个检查都通过,你将编码令牌并将其返回给用户。

  4. 是时候连接路由器了。通过替换文件内容来编辑之前创建的 app.py 文件:

    from fastapi import FastAPI
    from fastapi.middleware.cors import CORSMiddleware
    from routers.users import router as users_router
    origins = [“*”]
    app = FastAPI()
    app.add_middleware(
        CORSMiddleware,
        allow_origins=origins,
        allow_credentials=True,
        allow_methods=[“*”],
        allow_headers=[“*”],
    )
    app.include_router(users_router, prefix=”/users”, tags=[“users”])
    

    这里,你添加了 users 路由器。

  5. 现在,在你的项目根目录下创建一个名为 users.json 的文件,并用一个空的 users 数组填充它:

    {
        users:[]
    }
    
  6. 保存文件并从 shell 中启动 FastAPI 应用程序:

    uvicorn app:app --reload
    
  7. 你应该能够执行用户注册和用户登录。使用 HTTPie 客户端尝试一下:

    http 127.0.0.1:8000/users/register username=”marko” password=”marko123”
    
  8. 服务器应该发送以下响应,但请注意,你的哈希和 UUID 将不同:

    HTTP/1.1 200 OK
    content-length: 138
    content-type: application/json
    date: Sun, 07 Apr 2024 18:38:41 GMT
    server: uvicorn
    {
        “id”: “45cd212b-71eb-42b4-9d06-a74f2609764b”,
        “password”: “$2b$12$owWXcY5KgI9s6Rdfjcpx7eXaZOMWf8NaxN.SoLJ4h8O.xzFpRqEee”,
        “username”: “marko”
    }
    

    如果你查看 users.json 文件,你应该能看到类似以下的内容:

    {
        “users”: [
            {
                “username”: “marko”,
                “password”: “$2b$12$owWXcY5KgI9s6Rdfjcpx7eXaZOMWf8NaxN.SoLJ4h8O.xzFpRqEee”,
                “id”: “45cd212b-71eb-42b4-9d06-a74f2609764b”
            }
        ]
    

注意

在现实世界的系统中,你甚至不希望将哈希密码发送给已登录的用户,但这个整个系统是为了演示目的而创建的,旨在尽可能具有说明性。

你已经创建了一个完整的身份验证流程(仅用于演示目的——在生产中你不会使用包含字典和 UUID 的 JSON 文件),并且你已经实现了所有必需的功能:创建用户(注册)、检查提交数据的有效性以及用户登录。最后,你通过创建一个测试用户来测试了注册功能。

使用 HTTPie 测试登录功能

现在,使用正确的用户/密码组合测试登录功能,然后使用错误的组合。

  1. 首先,登录。在终端中,发出以下 HTTPie 命令:

    http POST 127.0.0.1:8000/users/login username=”marko” password=”marko123”
    

    响应应该只是一个大字符串——你的 JWT——这个令牌的值(这里,它以字符串eyJhbGciOiJ…开头)应该被复制并保存以供稍后测试已验证的路由:

    HTTP/1.1 200 OK
    content-length: 241
    content-type: application/json
    date: Sun, 07 Apr 2024 18:43:07 GMT
    server: uvicorn
    {
        “token”: 
    “eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MTI1MTcxODgsImlhdCI6MTcxMjUxNTM4OCwic3ViIjp7InVzZXJfaWQ iOiI0NWNkMjEyYi03MWViLTQyYjQtOWQwNi1hNzRmMjYwOTc2NGIiLCJ1c2VybmFtZS I6Im1hcmtvIn19.tFcJoKhTdDBDIBhCX-dCUEkCD3Fc8E-smQd2M_h5h2k”
    }
    
  2. 尝试以下操作(注意密码是错误的):

    http POST 127.0.0.1:8000/users/login username=”marko” password=”marko111”
    

    响应将类似于以下内容:

    HTTP/1.1 401 Unauthorized
    content-length: 45
    content-type: application/json
    date: Sun, 07 Apr 2024 18:44:34 GMT
    server: uvicorn
    {
        “detail”: “Invalid username and/or password”
    }
    

你已经从头开始实现了自己的 FastAPI 认证系统。现在,把它用在路由上会很好。

创建受保护的路由

假设你现在想要一个新端点,该端点列出你系统中所有的用户,并且你希望它只对已登录用户可见。这种方法将允许你通过利用强大的 FastAPI 依赖注入系统来保护不同路由中的任何路由:

  1. 打开users.py文件,在末尾添加以下路由:

    @router.get(“/list”, response_description=”List all users”)
    async def list_users(request: Request, user_data=Depends(auth_handler.auth_wrapper)):
    
        users = json.loads(open(“users.json”).read())[“users”]
        return UsersList(users=users)
    

    这条路径的关键在于user_data部分——如果依赖项不满足,该路径将响应异常,并显示authentication.py中定义的消息。

  2. 尝试登录,获取从登录端点获得的 JWT(如果它还没有过期!),然后将其作为承载令牌传递:

    http GET 127.0.0.1:8000/users/list ‘Authorization:Bearer <your Bearer Token>’     
    

    结果应包含你迄今为止创建的所有用户:

    HTTP/1.1 200 OK
    content-length: 76
    content-type: application/json
    date: Sun, 07 Apr 2024 19:07:45 GMT
    server: uvicorn
    {
        “users”: [
            {
                “id”: “45cd212b-71eb-42b4-9d06-a74f2609764b”,
                “username”: “marko”
            }
        ]
    }
    
  3. 如果你尝试修改令牌,或者让它过期,结果将是以下内容:

    HTTP/1.1 401 Unauthorized
    content-length: 26
    content-type: application/json
    date: Sun, 07 Apr 2024 19:10:12 GMT
    server: uvicorn
    {
        “detail”: “Invalid token”
    }
    

在本节中,你看到了如何在 FastAPI 后端创建一个简单但高效的认证系统,创建 JWT 生成器,验证令牌,保护一些路由,并提供创建(注册)新用户和登录所需的路由。下一节将展示前端的工作方式。

在 React 中认证用户

在本节中,你将了解一个基本机制,这将使你能够在客户端实现简单的认证流程。一切都将围绕 JWT 以及你决定如何处理它展开。

React.js 是一个无偏见的 UI 库。它提供了多种实现用户认证和授权的方法。由于你的 FastAPI 后端实现了基于 JWT 的认证,你必须决定如何在 React 中处理 JWT。

在本章中,你将把它存储在内存中,然后存储在localStorage中(JavaScript 中的 HTML5 简单 Web 存储对象,允许应用程序在用户的 Web 浏览器中存储无过期日期的键值对)。本章不会涵盖基于 cookie 的解决方案,因为这种解决方案通常是最稳健和安全的,下一章将介绍此类解决方案。

这些方法各有优缺点,了解它们非常有用。认证应该始终非常认真对待,并且根据你的应用程序范围和需求,它应该始终是一个需要彻底分析的话题。

关于存储认证数据的最佳解决方案一直存在争议——在这种情况下,是 JWT。像往常一样,每种解决方案都有其优缺点。

Cookie 已经存在很长时间了——它们可以在浏览器中以键值对的形式存储数据,并且可以从浏览器和服务器中读取。它们的流行与经典的服务器端渲染网站相吻合。然而,它们只能存储非常有限的数据,并且数据结构必须非常简单。

localstoragesessionStorage随着 HTML5 的引入而出现,是为了解决在单页应用SPAs)中存储复杂数据结构的需求,以及其他一些需求。它们的容量大约为 10 MB,具体取决于浏览器的实现,而 cookie 的容量为 4 KB。会话存储数据在会话期间持续存在,而本地存储在浏览器中即使关闭和重新打开后也会保留,直到手动删除,这使得 SPAs 成为最令人愉悦但也最易受攻击的解决方案。两者都可以托管复杂的 JSON 数据结构。

localstorage中存储 JWT 很简单,并且提供了极佳的用户和开发者体验。

大多数该领域的权威人士建议将 JWT 存储在 HTTP-only cookie 中,因为它们不能通过 JavaScript 访问,并且需要前端和后端在同一个域上运行。

这可以通过不同的方式实现,通过路由请求或使用代理。另一种流行的策略是使用所谓的刷新令牌。在此方法中,应用程序在登录时发行一个令牌,然后使用此令牌自动生成其他(刷新)令牌,从而在安全性和用户体验之间找到正确的平衡。

Context API

第三章,“Python 类型提示和 Pydantic”,你学习了如何通过useState钩子管理组件状态的简单部分。

假设你有一个顶级组件——甚至可能是根App.js组件——并且你需要将一些状态传递到 React 组件树中的深层嵌套组件。你需要将这部分数据传递给App.js状态组件内的组件,然后将其进一步传递到树中,直到达到真正需要这些数据的子组件。

这种模式被称为属性钻取——通过属性传递状态值,并且有多个不使用该状态值的组件;它们只是将其传递下去。属性钻取有几个影响,其中大多数最好是避免的:

  • 重构和修改代码更困难,因为你必须始终保持状态值通信通道的完整性

  • 代码的可重用性较低,因为组件需要始终提供状态值

  • 需要编写的代码更多,因为组件需要接受和转发属性

React 通过Context API引入了一种在组件之间提供值而不需要属性钻取的方法。

创建一个简单的 SPA

在下一节中,你将创建一个非常简单的 SPA,允许用户注册(如果他们尚未注册),使用用户名和密码登录,如果认证成功,将看到所有注册用户的列表。UI 将紧密模仿你的后端。

注意

为了使前端功能正常且可测试,必须提供上一节中的后端,因此请确保使用以下命令运行 FastAPI 后端:

uvicorn app:app --reload

前端将通过 API 连接到正在运行的 FastAPI 后端。虽然 FastAPI 在地址http://127.0.0.1:8000上提供服务,但 React 前端将使用相同的 URL 进行连接,执行 GET 和 POST 请求,认证用户并列出资源。

你将了解将 JWT 存储在应用程序中的 Context API 的主要概念。以下步骤开始:

  1. 创建一个新的 Vite React 项目,安装 Tailwind,并添加 Tailwind CSS,因为它简化了应用程序的样式。请参阅第五章设置 React 工作流程,以了解如何操作。同时,删除不需要的文件和文件夹(如App.css)。

  2. /src文件夹中创建一个新文件,并将其命名为AuthContext.jsx.jsx扩展名是一个提醒,即上下文确实是一个 React 组件,它将包装所有需要访问上下文变量、函数、对象或数组的其他组件:

    import {
        createContext
    } from ‘react’;
    const AuthContext = createContext();
    export const AuthProvider = ({
        children
    }) => {
        const [user, setUser] = useState(null);
        const [jwt, setJwt] = useState(null);
        const [message, setMessage] = useState(null);
        return (<AuthContext.Provider value={
            {
                user,
                jwt,
                register,
                login,
                logout,
                message,
                setMessage
            }
        } > {
                children
            } </AuthContext.Provider>)
    }
    

    上述代码显示了上下文创建的结构 - 你从 React 中导入了createContext并创建了你的第一个上下文(AuthContext)。在定义了一些状态变量和设置器(用于用户、jwt令牌和消息)之后,你返回了AuthContext组件和将在上下文中可用的值。语法与第四章FastAPI 入门中检查的钩子语法略有不同,但这是一个你将多次重用的简单模板,如果你选择使用 Context API。

  3. 虽然简单,但创建上下文涉及多个步骤:

    1. 首先,你需要创建将在应用程序中共享的实际上下文。

    2. 之后,应该将上下文提供给所有需要访问其值的组件。

    3. 需要访问上下文值的组件需要订阅上下文,以便能够读取,但也可以写入。

    因此,创建上下文的第一步应该是明确你需要传递给组件的确切信息类型。如果你这么想,你肯定希望使用 JWT,因为这就是这个练习的全部意义。为了展示上下文功能,你还将包括已登录的用户和将显示应用程序状态的消息。

    但是,由于上下文还可以包含并传递函数——这确实是它的最有用功能之一——你还将向上下文中添加 registerloginlogout 函数。这可能在生产系统中不是你想要做的事情,但它将展示 Context API 的功能。

  4. 现在,唯一剩下的事情是将函数添加到上下文中。为此,编辑现有的 AuthContext.jsx 文件,在声明状态变量之后,定义注册新用户的函数:

        const register = async (username, password) => {
          try {
            const response = await fetch(‘http://127.0.0.1:8000/users/register’, {
              method: ‘POST’,
              headers: {
                ‘Content-Type’: ‘application/json’,
              },
              body: JSON.stringify({
                username,
                password
              }),
            });
            if (response.ok) {
              const data = await response.json();
              setMessage(`Registration successful: user ${data.username} created`);
            } else {
              const data = await response.json();
              setMessage(`Registration failed: ${JSON.stringify(data)}`);
            }
          } catch (error) {
            setMessage(`Registration failed: ${JSON.stringify(error)}`);
          }
        };
    

    这个简单的 JavaScript 函数是上下文的一部分,唯一与你的上下文交互的是设置状态消息——如果用户成功创建,消息将确认这一点。如果发生错误,消息将被设置为错误。你可能想要提供更复杂的验证逻辑和更友好的用户界面,但这对上下文的工作方式有很好的说明。

  5. 现在添加与认证相关的其他函数——login() 函数:

    const login = async (username, password) => {
      setJwt(null)
      const response = await     fetch(‘http://127.0.0.1:8000/users/login’, {
        method: ‘POST’,
        headers: {
          ‘Content-Type’: ‘application/json’,
        },
        body: JSON.stringify({
          username,
          password
        }),
      });
      if (response.ok) {
        const data = await response.json();
        setJwt(data.token);
        setUser({
          username
        });
        setMessage(`Login successful: token ${data.token.slice(0, 10)}..., user ${username}`);
      } else {
        const data = await response.json();
        setMessage(‘Login failed: ‘ + data.detail);
        setUser({
          username: null
        });
      }
    };
    

    上述代码与 register 函数类似——它向 FastAPI /login 端点发送一个 POST 请求,包含用户提供的用户名和密码,并在过程中清除任何现有的 JWT。如果请求成功,检索到的令牌将被设置为状态变量,并相应地设置用户名。

  6. 最后一个拼图是注销用户。由于你只处理 Context API 而不是某些持久存储解决方案,代码非常简短;它只需要清除上下文变量并设置适当的消息:

    const logout = () => {
      setUser(null);
      setJwt(null);
      setMessage(‘Logout successful’);
    };
    
  7. 你的 AuthContext 几乎完成了——唯一剩下的事情是通知上下文它需要提供之前定义的函数。因此,修改 return 语句以包含所有内容:

    return ( <
      AuthContext.Provider value = {
        {
          user,
          jwt,
          register,
          login,
          logout,
          message,
          setMessage
        }
      } > {
        children
      } <
      /AuthContext.Provider>
    );
    
  8. 作为最后的润色,添加一个 useContext React 钩子,以简化与上下文的工作:

    export const useAuth = () => useContext(AuthContext);
    

    这条简单的单行钩子现在允许你在任何可以访问上下文的组件中使用 AuthContext ——也就是说,任何被 AuthContext 包裹的组件——只需进行一些简单的 ES6 解构。现在你的 AuthContext 已经设置好了,你可以直接将其放入 App.jsx 组件中,并将其包裹在其他所有组件周围。

  9. 打开 App.jsx 文件并编辑它:

    import { AuthProvider } from “./AuthContext”;
    const App = () => {
      return (
        <div className=”bg-blue-200 flex flex-col justify-center items-center min-h-screen”>
          <AuthProvider>
            <h1 className=”text-2xl text-blue-800”> Simple Auth App </h1>
          </AuthProvider>{“ “}
        </div>
      );
    };
    export default App
    

    这个根组件不包含你之前没有见过的内容——除了导入 AuthProvider——这是你的自定义认证上下文组件,负责包裹组件区域和一点 Tailwind 样式。

  10. 现在是定义将包裹在上下文内部的组件的部分——因为这些组件将能够消费上下文,访问上下文数据,并对其进行修改。对于更复杂的应用程序,你可能会求助于 React Router 包,但由于这将是一个非常简单的应用程序,你将把所有组件挤在一个页面上。它们并不多:

    • 上下文中的 login() 函数。

    • 注册:与登录组件类似,但用于注册新用户。

    • 消息:最简单的组件,仅用于显示应用程序的状态。

    • 用户:其状态依赖于身份验证状态的组件:如果用户已登录,他们可以看到用户列表,这意味着 JWT 存在且有效;否则,将提示用户进行登录。

  11. Register 组件将用于用户注册。它需要显示一个表单。在 /src 文件夹中创建 Register.jsx 文件,并创建一个包含两个字段的简单表单:

    import { useState } from ‘react’;
    import { useAuth } from ‘./AuthContext’;
    const Register = () => {
        const [username, setUsername] = useState(‘’);
        const [password, setPassword] = useState(‘’);
        const { register } = useAuth();
        const handleSubmit = (e) => {
            e.preventDefault();
            register(username, password)
            setUsername(‘’)
            setPassword(‘’)
        };
        return (
            <div className=”m-5 p-5  border-2”>
                <form onSubmit={handleSubmit} className=’grid grid-rows-3 gap-2’>
                    <input
                        type=”text”
                        placeholder=”Username”
                        className=’p-2’
                        value={username}
                        onChange={(e) => setUsername(e.target.value)}
                    />
                    <input
                        type=”password”
                        placeholder=”Password”
                        className=’p-2’
                        value={password}
                        onChange={(e) => setPassword(e.target.value)}
                    />
                    <button type=”submit” className=’bg-blue-500 text-white rounded’>Register</button>
                </form>
            </div>
        );
    };
    export default Register
    

    你已经使用两个本地状态变量创建了一个特定于 React 的表单,这些变量负责跟踪并发送用户名和密码到你的 FastAPI 实例。register 函数通过 useAuth() 钩子从 AuthContext 中导入。那一行真正展示了在包装组件内部与上下文一起工作是多么容易。

    最后,handleSubmit 执行对 register 函数的调用,清除字段并阻止默认的 HTML 表单行为。

  12. 创建 Login.jsx 文件,它与之前几乎相同(在这里你可以练习你的 React 技能并进行一些重构)。该组件有一个登录表单,将用于登录:

    import { useState } from ‘react’;
    import { useAuth } from ‘./AuthContext’;
    const Login = () => {
        const [username, setUsername] = useState(‘’);
        const [password, setPassword] = useState(‘’);
        const { login } = useAuth();
        const handleSubmit = (e) => {
            e.preventDefault();
            login(username, password);
            setUsername(‘’);
            setPassword(‘’);
        };
        return (
            <div className=”m-5 p-5  border-2”>
                <form onSubmit={handleSubmit} className=’grid grid-rows-3 gap-2’>
                    <input
                        type=”text”
                        placeholder=”Username”
                        className=’p-2’
                        value={username}
                        onChange={(e) => setUsername(e.target.value)}
                    />
                    <input
                        type=”password”
                        placeholder=”Password”
                        className=’p-2’
                        value={password}
                        onChange={(e) => setPassword(e.target.value)}
                    />
                    <button type=”submit” className=’bg-blue-500 text-white rounded’>Login</button>
                </form>
            </div>
        );
    };
    export default Login
    
  13. 在你的由 FastAPI 和 React 驱动的简单身份验证应用程序中,还有两个组件需要插入。首先,创建 src/Message.jsx 组件,它将用于显示状态消息:

    import { useAuth } from “./AuthContext”
    const Message = () => {
        const { message } = useAuth()
        return (
            <div className=”p-2 my-2”>
                <p>{message}</p>
            </div>
        )
    }
    export default Message
    

    Messages 组件从上下文中读取消息状态变量并将其显示给用户。

  14. 现在,你终于可以创建 src/Users.jsx 组件并对其进行编辑:

    import { useEffect, useState } from ‘react’;
    import { useAuth } from ‘./AuthContext’;
    const Users = () => {
        const { jwt, logout } = useAuth();
        const [users, setUsers] = useState(null);
        useEffect(() => {
            const fetchUsers = async () => {
                const response = await fetch(‘http://127.0.0.1:8000/users/list’, {
                    headers: {
                        Authorization: `Bearer ${jwt}`,
                    },
                });
                const data = await response.json();
                setUsers(data.users);
            };
            if (jwt) {
                fetchUsers();
            }
        }, [jwt]);
        if (!jwt) return <div>Please log in to see all the users</div>;
        return (
            <div>
                {users ? (
                    <div className=’flex flex-col’>
                        <h1>The list of users</h1>
                        <ol>
                            {users.map((user) => (
                                <li key={user.id}>{user.username}</li>
                            ))}
                        </ol>
                        <button onClick={logout} className=’bg-blue-500 text-white rounded’>Logout</button>
                    </div>
                ) : (
                    <p>Loading...</p>
                )}
            </div>
        );
    };
    export default Users;
    

    与其他组件相比,这个组件做了一些繁重的工作。它从上下文中导入 jwt(以及 logout 函数)。这很重要,因为 Users.jsx 组件的输出完全取决于 JWT 的存在和有效性。

    在声明一个本地状态变量 users 之后,组件使用 useEffect React 钩子执行对 REST API 的调用,由于 /users/list 端点是受保护的,JWT 令牌需要存在且有效。

    如果对 /users/list 端点的调用成功,检索到的用户数据将被发送到 users 变量并显示。最后,如果上下文中没有 jwt,将要求用户执行登录操作并从上下文中调用 logout 函数。

  15. 最后,为了将所有这些整合在一起,用以下代码替换 App.jsx 文件以导入组件,并最终完成根组件:

    import { useState } from ‘react’;
    import { AuthProvider } from ‘./AuthContext’;
    import Register from ‘./Register’;
    import Login from ‘./Login’;
    import Users from ‘./Users’;
    import Message from ‘./Message’;
    const App = () => {
      const [showLogin, setShowLogin] = useState(true)
      return (
        <div className=’bg-blue-200 flex flex-col justify-center items-center min-h-screen’>
          <AuthProvider>
            <h1 className=’text-2xl text-blue-800’>Simple Auth App</h1>
            <Message />
            {showLogin ? <Login /> : <Register />}
            <button onClick={() => setShowLogin(!showLogin)}>{showLogin ? ‘Register’ : ‘Login’}</button>
            <hr />
            <Users />
          </AuthProvider>
        </div>
      );
    };
    export default App;
    

现在,你将能够测试应用程序——尝试注册、登录、输入无效数据等等。你已经创建了一个非常简单但完整的全栈身份验证解决方案。在下一节中,你将了解一些持久化登录数据的方法。

使用 localStorage 持久化身份验证数据

如前所述,对于持久化认证,最符合开发者需求的选项是使用 localStoragesessionStorage。当涉及到存储临时、本地数据时,localStorage 非常有用。它被广泛用于记住购物车数据或用户在任何网站上的登录信息(在这些网站上安全性不是首要考虑因素)。与 cookies 相比,localStorage 具有更高的存储限制(5 MB 对 4 KB),并且不会随着每个 HTTP 请求发送。这使得它成为客户端存储的更好选择。

要使用 localStorage,你可以分别使用 setItem()getItem() 方法来设置和获取 JSON 项。需要记住的一个重要事项是 localStorage 只存储字符串,因此你需要使用 JSON.stringify()JSON.parse() 在 JavaScript 对象和字符串之间进行转换。

带着这些知识,尝试总结应用的要求——你希望用户能够在刷新或关闭并重新打开应用程序窗口/标签页的情况下保持登录状态,如果他们最初已经登录。用 React 术语来说,你需要一个 useEffect 钩子,它会运行并验证 localStorage 中是否存储了令牌。如果存在,你想要通过 FastAPI 的 /me 端点检查这个令牌,并相应地设置用户名:

  1. 打开现有的 AuthContext.jsx 文件,在 useState 钩子之后定义 useEffect 调用:

        export const AuthProvider = ({ children }) => {
        const [user, setUser] = useState(null);
        const [jwt, setJwt] = useState(null);
        const [message, setMessage] = useState(null);
        useEffect(() => {
    
            const storedJwt = localStorage.getItem(‘jwt’);
            if (storedJwt) {
                setJwt(storedJwt);
                fetch(‘http://127.0.0.1:8000/users/me’, {
                    headers: {
                        Authorization: `Bearer ${storedJwt}`,
                    },
                })
                    .then(res => res.json())
                    .then(data => {
                        if (data.username) {
                            setUser({ username: data.username });
                            setMessage(`Welcome back, ${data.username}!`);
                        }
                    })
                    .catch(() => {
                        localStorage.removeItem(‘jwt’);
                    });
            }
        }, []);
    

    大部分持久化逻辑都位于 useEffect 调用中。首先,你可以尝试从 localStorage 获取 jwt 令牌,然后使用该令牌从 /me 路由获取用户数据。如果找到用户名,它会被设置在上下文中,并且用户(已经)登录。如果没有找到,你将清除 localStorage 或在 Users.jsx 组件中发送一个令牌已过期的消息。

  2. login() 函数也必须进行修改,以便考虑到 localStorage。在相同的 AuthContext.jsx 中修改 login() 函数:

    const login = async (username,
      password) => {
        setJwt(null)
        const response = await fetch(
          ‘http://127.0.0.1:8000/users/login’, {
            method: ‘POST’,
            headers: {
              ‘Content-Type’: ‘application/json’,
            },
            body: JSON.stringify({
              username,
              password
            }),
          });
        if (response.ok) {
          const data = await response
            .json();
          setJwt(data.token);
     localStorage.setItem(‘jwt’, data.token);
          setUser({
            username
          });
          setMessage(
            `Login successful: token ${data.token.slice(0, 10)}..., user ${username}`
            );
        } else {
          const data = await response
            .json();
          setMessage(‘Login failed: ‘ +
            data.detail);
          setUser({
            username: null
          });
        }
      };
    

    唯一的修改是将新的 JWT 设置到 localStoragejwt 变量中。因此,logout() 函数也需要清除 localStorage

  3. 在相同的 AuthContext.jsx 文件中,修改 logout 函数:

    const logout = () => {
        setUser(null);
        setJwt(‘’);
        localStorage .removeItem(‘jwt’);
        setMessage(‘Logout successful’);
    };
    
  4. 最后,为了使你的应用程序更加明确和易于理解,打开 Users.jsx 组件并将其替换为以下代码:

    import { useEffect, useState } from ‘react’;
    import { useAuth } from ‘./AuthContext’;
    const Users = () => {
        const { jwt, logout } = useAuth();
        const [users, setUsers] = useState(null);
        const [error, setError] = useState(null);
        useEffect(() => {
            const fetchUsers = async () => {
                const response = await fetch(‘http://127.0.0.1:8000/users/list’, {
                    headers: {
                        Authorization: `Bearer ${jwt}`,
                    },
                });
                const data = await response.json();
                if (!response.ok) {
                    setError(data.detail);
                }
                setUsers(data.users);
            };
            if (jwt) {
                fetchUsers();
            }
        }, [jwt]);
        if (!jwt) return <div>Please log in to see all the users</div>;
        return (
            <div>
                {users ? (
                    <div className=’flex flex-col’>
                        <h1>The list of users</h1>
                        <ol>
                            {users.map((user) => (
                                <li className=’’ key={user.id}>{user.username}</li>
                            ))}
                        </ol>
                        <button onClick={logout} className=’bg-blue-500 text-white rounded’>Logout</button>
                    </div>
                ) : (
                    <p>{error}</p>
                )}
            </div>
        );
    };
    export default Users;
    

现在应用程序能够持久化已登录用户,检索存储的 JWT,并恢复之前的认证状态。在尝试登录之前,请确保 FastAPI 后端在端口 8000 上正常运行。

尝试登录,刷新浏览器,关闭标签页,然后重新打开。

你也可以在 Chrome 或 Firefox 的开发者工具栏中的 Application 标签页内尝试使用令牌,看看如果你篡改它或删除它会发生什么。

其他认证解决方案

重要的是再次强调,例如 localStorage,但需要考虑到两种解决方案的具体细节。

最后,熟悉各种第三方身份验证选项也很重要。FirebaseSupabase是流行的数据库和身份验证服务,可以仅用于管理用户和验证他们。ClerkKinde是该领域的后起之秀,特别针对 React/Next.js/Remix.js 生态系统,而Auth0Cognito是行业标准解决方案。几乎所有第三方身份验证系统都提供慷慨的免费或几乎免费的级别,但一旦你的应用程序增长,你不可避免地会遇到付费级别,而且费用各不相同,如果需要,替换这些服务并不容易。

摘要

在本章中,你看到了一个非常基础但相当有代表性的身份验证机制两种版本的实现。你学习了 FastAPI 如何启用符合标准身份验证方法的使用,并实现了最简单但有效的解决方案之一——不持久化身份验证数据和不存储localStorage

你已经了解到,在定义细粒度角色和权限方面,FastAPI 是多么优雅和灵活,尤其是在 MongoDB 的帮助下,Pydantic作为中间人。本章专注于JWTs作为通信手段,因为它是当今 SPA 的主要和最受欢迎的工具,它使得服务或微服务之间具有很好的连接性。当你需要使用相同的 FastAPI 和 MongoDB 后端开发不同的应用程序时,JWT机制特别出色——例如,一个 React 网络应用程序和一个基于 React Native 或 Flutter 的移动应用程序。

此外,仔细考虑你的身份验证和授权策略至关重要,尤其是在从第三方系统中提取用户数据可能不可行或不切实际的情况下。这突出了制定稳健的身份验证和授权方法的重要性。

在下一章中,你将创建一个更复杂的 FastAPI 后端,通过第三方服务上传图片,并使用 MongoDB 数据库进行持久化。

第七章:使用 FastAPI 构建后端

在前面的章节中,你学习了认证和授权的基本机制,现在你准备好实现它并保护一个用 FastAPI 构建的 Web API 了。在本章中,你将充分利用这些知识,创建一个简单但功能齐全的 REST API,展示二手车及其图片。

在本章中,你将了解以下操作,这些操作可以被视为在用 FastAPI 创建 REST API 时的一个松散耦合的蓝图。

本章将涵盖以下主题:

  • 通过使用 Python Motor 驱动程序将 FastAPI 实例连接到 MongoDB Atlas

  • 根据规范定义 Pydantic 模型,并初始化 FastAPI 应用程序

  • 创建 API 路由并实现 CRUD 操作

  • 使用 JWT 保护 API

  • 部署到 Render

技术要求

本章的要求与之前定义的要求相似。你将使用以下内容进行工作:

  • Python 3.11.7 或更高版本

  • Visual Studio Code

  • MongoDB Atlas 的介绍

之后,你需要在图像托管服务Cloudinary(免费)和 API 托管平台Render(也是一个免费层账户)上创建账户。再次,你将使用 HTTPie 手动测试你将要实现的 API 端点。

让我们先了解将要开发的应用程序以及后端需要什么。

介绍应用程序

在有一个具体问题需要解决的情况下开始使用框架要容易得多,即使要求有些模糊。当前的任务相当简单:你需要为一家虚构的汽车销售公司创建一个用于存储和检索二手车数据的 REST API 后端。

描述车辆的这种数据结构相当简单,但一旦你深入研究细节,如发动机型号、内饰颜色、悬挂类型等,它可能会变得更加复杂。

在你的第一个简单的创建读取更新删除CRUD)应用程序中,你将保持资源数据有限。一辆车将由以下字段描述:

  • 品牌:汽车品牌(福特、雷诺等),用一个字符串表示

  • 型号或型号:例如,Fiesta 或 Clio,用一个字符串表示

  • 年份:生产年份,一个限制在合理范围内的整数(1970–2024)

  • Cm3:发动机排量,与发动机功率成正比,一个范围整数

  • kW:发动机功率,以 kW 为单位的整数

  • Km:汽车行驶的公里数,一个位于数十万范围内的整数

  • 价格:欧元价格

  • 一个图片 URL:这是可选的

每个汽车销售网站的一个基本功能是存在图片,因此您将使用领先的图像托管和处理服务之一——Cloudinary——实现一个图像上传管道。稍后,您将为员工提供更多帮助,为每个汽车型号生成吸引人的文案,这将使 API 更加丰富,同时展示 FastAPI 的简洁性。

创建 Atlas 实例和集合

登录您的 Atlas 账户,并在名为 cars 的集合中创建一个名为 carBackend 的新数据库。您可以参考第二章使用 MongoDB 设置数据库。在创建数据库和集合后,注意 MongoDB 连接字符串并将其保存在文本文件中,以备后用,当您创建秘密环境密钥时。

设置 Python 环境

在 Atlas 上创建 MongoDB 数据库并连接后,现在是时候为您设置一个新的 Python 虚拟环境并安装需求了:

  1. 首先,创建一个名为 requirements.txt 的纯文本文件,并在其中插入以下行:

    fastapi==0.111.0
    motor==3.4.0
    uvicorn==0.29.0
    pydantic-settings==2.2.1
    
  2. 如果您想要能够精确地重现本书中使用的代码,则包版本控制很重要,并且您始终可以参考书中存储库中的 requirements.txt 文件。运行之前定义的需求文件中的 pip 安装命令:

    pip install -r requirements.txt
    

您的环境已准备就绪。现在,凭借从第三章Python 类型提示和 Pydantic中学到的 Python 类型提示和 Pydantic 知识,您将构建这个相对简单的汽车数据结构。

定义 Pydantic 模型

让我们从第一个 Pydantic 模型开始,针对单一车辆。在这里,需要提前解决的一个主要问题是如何在 Pydantic 中序列化和定义 MongoDB 的 ObjectID 键。虽然表示 ObjectID 的方法有很多种,但最简单且目前由 MongoDB 推荐的方法是将 ObjectID 转换为字符串。您可以参考以下文档以获取更多详细信息:www.mongodb.com/developer/languages/python/python-quickstart-fastapi/

MongoDB 使用 _id 字段作为标识符。在 Python 中,由于以下划线开头的属性具有特殊含义,您不能使用原始的字段名进行模型填充。

Pydantic 别名提供了一个简单而优雅的解决方案;您可以命名字段为 id,但也可以将其别名为 _id,并将 populate_by_name 标志设置为 True,如第三章Python 类型提示和 Pydantic中所示。

最后,您需要将 ObjectID 转换为字符串。为此,您将使用简单的 Python 注解和 Pydantic 的 BeforeValidator 模块。

  1. 创建一个名为 Chapter7 的文件夹,并在其中创建一个 models.py 文件,然后开始编写导入和 ObjectID 类型:

    #models.py
    from typing import Optional, Annotated, List
    from pydantic import BaseModel, ConfigDict, Field, BeforeValidator, field_validator
    PyObjectId = Annotated[str, BeforeValidator(str)]
    
  2. 在导入之后,创建一个新的类型 PyObjectId,它将用于将 MongoDB 的原始 ObjectID 作为字符串表示,继续填充模型:

    class CarModel(BaseModel):
        id: Optional[PyObjectId] = Field(
           alias="_id", default=None)
        brand: str = Field(...)
        make: str = Field(...)
        year: int = Field(..., gt=1970, lt=2025)
        cm3: int = Field(..., gt=0, lt=5000)
        km: int = Field(..., gt=0, lt=500000)
        price: int = Field(..., gt=0, lt=100000)
    

    如果你阅读了 Pydantic 的章节,这些字段应该非常熟悉;你只是在声明汽车字段,将所有字段标记为必需,并对数值量(cm3kmpriceyear)设置一些合理的限制。

    请记住,汽车品牌数量有限,因此创建一个 枚举 类型的品牌名称是有可能的,也是建议的,但在这个例子中,你将保持简单。

  3. 添加两个方便的字段验证器作为修饰符。你希望返回每个汽车品牌和型号的标题:

    @field_validator("brand")
    @classmethod
    def check_brand_case(cls, v: str) -> str:
        return v.title()
    @field_validator("make")
    @classmethod
    def check_make_case(cls, v: str) -> str:
        return v.title()
    
  4. 最后,为了完成模型,添加一个配置字典,允许它通过名称填充并允许任意类型:

    model_config = ConfigDict(
        populate_by_name=True,
        arbitrary_types_allowed=True,
        json_schema_extra={
            "example": {
                "brand": "Ford",
                "make": "Fiesta",
                "year": 2019,
                "cm3": 1500,
                "km": 120000,
                "price": 10000,
            }
        },
    )
    
  5. 你现在可以通过在文件末尾添加以下(临时)行来测试模型,在类定义之外运行它:

    test_car = CarModel(
        brand="ford", make="fiesta", year=2019, cm3=1500, km=120000, price=10000
    )
    print(test_car.model_dump())
    
  6. 运行 models.py 文件:

    python models.py
    {'id': None, 'brand': 'Ford', 'make': 'Fiesta', 'year': 2019, 'cm3': 1500, 'km': 120000, 'price': 10000}.
    

    现在,是时候定义其他模型以更新单个实例和获取汽车列表了。update 模型需要只允许更改特定字段。理论上,只有 price 应该是可更新的,因为汽车本身是相当不可变的对象,但这个系统将允许一些模糊性和需要通过 API 手动纠正的错误数据的情况。

  7. 在从 models.py 中删除或注释掉测试行之后,继续创建 UpdateCarModel 模型:

    class UpdateCarModel(BaseModel):
        brand: Optional[str] = Field(...)
        make: Optional[str] = Field(...)
        year: Optional[int] = Field(..., gt=1970, lt=2025)
        cm3: Optional[int] = Field(..., gt=0, lt=5000)
        km: Optional[int] = Field(..., gt=0, lt=500 * 1000)
        price: Optional[int] = Field(..., gt=0, lt=100 * 1000)
    

    类的剩余部分与 CarModel 类相同,为了简洁起见将省略。

  8. 最后,ListCarsModel 类将非常简单,因为它只需要处理 CarModel 类的列表:

    class CarCollection(BaseModel):
        cars: List[CarModel]
    

    现在模型已经就绪,你可以进行简单的测试,看看 ListCarsModel 是如何工作的。

  9. 创建一个名为 test_models.py 的新测试文件,按照顺序添加以下行以创建两个不同的汽车模型和一个列表,然后打印模型转储:

    from models import CarCollection, CarModel
    test_car_1 = CarModel(
        brand="ford", make="fiesta", year=2019, cm3=1500, km=120000, price=10000
    )
    test_car_2 = CarModel(
        brand="fiat", make="stilo", year=2003, cm3=1600, km=320000, price=3000
    )
    car_list = CarCollection(cars=[test_car_1, test_car_2])
    print(car_list.model_dump())
    

    如果你用 Python 运行 test_models.py 文件,输出应该是以下内容:

    {'cars': [{'id': None, 'brand': 'Ford', 'make': 'Fiesta', 'year': 2019, 'cm3': 1500, 'km': 120000, 'price': 10000}, {'id': None, 'brand': 'Fiat', 'make': 'Stilo', 'year': 2003, 'cm3': 1600, 'km': 320000, 'price': 3000}]}
    

模型,至少是它们的初始迭代(MongoDB 在迭代数据建模方面非常出色),现在已经完成,所以你可以在下一节开始构建你的 FastAPI 应用程序结构。

构建 FastAPI 应用程序

Motor 驱动器。最初,你将只创建一个通用的最小 FastAPI 应用程序,并逐步添加功能。

你将开始将秘密环境数据——在你的情况下,只是 MongoDB Atlas 数据库 URL——存储到 .env 文件中。这些值应该始终保持在存储库之外。你希望能够连接到你的 MongoDB 数据库并验证连接是否成功。

创建 .env 文件以保存秘密

对于应保持秘密并排除在版本控制系统之外的价值,您将使用一个环境文件(.env)。执行以下步骤以设置环境变量并将它们排除在版本控制之外:

  1. 首先,创建一个 .env 文件,并在其中以以下格式放入您的秘密连接字符串,无需引号:

    DB_URL=mongodb+srv://<USERNAME>:<PASSWORD>@cluster0.fkm24.mongodb.net/?retryWrites=true&w=majority&appName=Cluster0
    DB_NAME=carBackend
    

    .env 文件将后来托管其他外部服务可能需要的秘密文件,您可能在 API 开发中使用。

  2. 现在,创建一个 .gitignore 文件,并填充基本条目:Git 不应跟踪的目录和文件。打开一个文件,命名为 .gitignore,并插入以下内容:

    __pycache__/
    .env
    venv/
    

    网上有很多与 Python 相关的 .gitignore 文件示例,所以请随意查找,但这将足以满足我们的需求。

  3. 现在,您可以使用以下 Git 命令将工作目录置于版本控制之下:

    git init
    git add .
    git commit -m "initial commit"
    

使用 pydantic_settings 创建 Pydantic 配置

在接下来的步骤中,您将使用之前创建的环境变量,并将它们提供给 pydantic_settings——这是用于管理应用程序设置的 Pydantic 类,在第三章**,Python 类型提示和 Pydantic 中有介绍。这个类将在需要环境变量的任何地方轻松调用。

在完成这项准备工作后,创建一个名为 config.py 的文件,该文件将利用 pydantic_settings 包来管理您的设置,如下所示:

  1. 创建一个配置文件,命名为 config.py,您将使用它来读取应用程序的设置。当您引入一些自动化测试或为生产环境、不同的数据库等设置不同的设置时,您将能够轻松地更改它们。将以下代码粘贴到 config.py 文件中:

    from typing import Optional
    from pydantic_settings import BaseSettings, SettingsConfigDict
    class BaseConfig(BaseSettings):
        DB_URL: Optional[str]
        DB_NAME: Optional[str]
        model_config = SettingsConfigDict(env_file=".env", extra="ignore")
    

    现在,您将使用这些配置设置来获取环境数据以连接到 MongoDB Atlas 实例。

  2. 最后,您可以通过创建一个名为 app.py 的新 Python 文件来开始构建实际的应用程序文件。在这个文件中,首先实例化一个 FastAPI 实例,并创建一个带有简单消息的根路由:

    from fastapi import FastAPI
    app = FastAPI()
    @app.get("/")
    async def get_root():
        return {"Message": "Root working"}
    
  3. 您应该能够在终端中使用您选择的任何服务器运行这个裸骨应用程序:

    uvicorn app:app
    

简单的根消息在 127.0.0.1:8000 上可用,并且应用程序正在运行。

连接到 Atlas

现在,是时候将其连接到 Atlas 了。为此,您将使用 FastAPI 的 生命周期事件,这是在应用程序启动并开始接收请求之前需要只发生一次的事件处理的新方法。生命周期事件还允许您处理在应用程序完成请求处理后应该触发的其他事件。

注意

FastAPI 网站关于此主题有出色的文档:fastapi.tiangolo.com/advanced/events/

对于本章的使用案例,您将使用一个异步上下文管理器,这将允许您提供应用程序实例,并在应用程序启动前后触发事件。按照以下步骤操作:

  1. 为了展示这是如何工作的,编辑app.py文件:

    from contextlib import asynccontextmanager
    from fastapi import FastAPI
    @asynccontextmanager
    async def lifespan(app: FastAPI):
        print("Starting up!")
        yield
        print("Shutting down!")
    app = FastAPI(lifespan=lifespan)
    @app.get("/")
    async def get_root():
        return {"Message": "Root working!"}
    

    如果您使用之前显示的相同命令启动应用程序,然后使用Ctrl+C关闭它,您将看到print语句在控制台显示消息。

    lifespan事件异步上下文是您将通过设置连接到您的 Atlas 实例的机制。

  2. 再次打开app.py文件,添加配置设置,更改lifespan函数,并引入Motor驱动程序:

    from fastapi import FastAPI
    from motor import motor_asyncio
    from config import BaseConfig
    settings = BaseConfig()
    async def lifespan(app: FastAPI):
        app.client = motor_asyncio.AsyncIOMotorClient(settings.DB_URL)
        app.db = app.client[settings.DB_NAME]
        try:
            app.client.admin.command("ping")
            print("Pinged your deployment. You have successfully connected to MongoDB!")
            print("Mongo address:", settings.DB_URL)
        except Exception as e:
            print(e)
        yield
        app.client.close()
    app = FastAPI(lifespan=lifespan)
    @app.get("/")
    async def get_root():
        return {"Message": "Root working!"}
    
  3. 如果您现在启动应用程序,您应该会收到以下类似的消息:

    INFO:     Started server process [28228]
    INFO:     Waiting for application startup.
    Pinged your deployment. You have successfully connected to MongoDB!
    Mongo address: <your connection string>
    INFO:     Application startup complete.
    INFO:     Uvicorn running on http://127.0.0.1:8000 (Press
    

在这个设置中,您已经实现了很多内容:

  • 您已创建了 FastAPI 实例,这是您 API 的骨干。

  • 您已使用pydantic_settings设置了环境变量,因此它们是可管理和可维护的。

  • 您已连接到您设置的 Atlas 集群。

  • 您还“附加”了 MongoDB 数据库到应用程序,因此您将能够通过请求方便地从 API 路由器中访问它。

现在,让我们开始实现CRUD(创建、读取、更新和删除)操作的路线,从一个稳固且可扩展的设置开始。

CRUD 操作

几乎每个 Web 应用程序核心的四个基本操作通常被称为CRUD(创建、读取、更新和删除)的缩写。这些操作使用户能够通过创建新资源、检索现有资源的一个或多个实例以及修改和删除资源来与数据交互。在这里,使用了一个更正式的 API 定义,但资源在这种情况下只是汽车。

FastAPI 与网络标准紧密相连,因此这些操作映射到特定的 HTTP 请求方法:POST用于创建新实例,GET用于读取一个或多个汽车,PUT用于更新,而DELETE用于删除资源。在您的案例中,资源由cars表示,它们映射到 MongoDB 文档。

设置 API 路由器

在应用程序准备就绪并开始服务基本根端点,设置了环境变量,并建立了与 Atlas MongoDB 数据库的连接后,您现在可以开始实现端点。

实际上,在接下来的章节中,我们将添加一个用户路由器;这将使您能够将单个汽车与特定的用户/销售人员关联起来,并允许进行一些基本的身份验证和授权。

与大多数现代 Web 框架(Express.js、Flask 等)一样,FastAPI 允许您将端点结构化和分组到 API 路由器中。APIRouter是一个模块,用于处理与单一类型对象或资源相关的一组操作:在您的案例中,是汽车,稍后是用户。

执行以下步骤以创建用于管理汽车的 API 路由器:

  1. 在您的应用程序目录内创建一个专门的文件夹,并命名为 /routers。该目录将包含所有 API 路由器。在其内部,创建一个空的 __init__.py 文件,将文件夹转换为 Python 包。

  2. 现在,创建一个名为 /routers/cars.py 的文件。这将是该应用程序中的第一个路由器,但如果有必要,随着应用程序的增长,您可以添加更多。按照惯例,根据它们管理的资源命名路由器。

  3. /routers/cars.py 内部,开始构建路由器:

    from fastapi import APIRouter, Body, Request, status
    from models import CarModel
    router = APIRouter()
    

    APIRouter 的实例化与创建主 FastAPI 实例非常相似——它可以被视为一个小的 FastAPI 应用程序,它成为主应用程序的一个组成部分,以及其自动文档。

    APIRouter 本身没有任何功能——它需要连接到主应用程序 (app.py) 才能执行其任务。

  4. 在继续之前,让我们修改 app.py 文件并将新创建的 APIRouter 插入其中:

    from fastapi import FastAPI, status
    from fastapi.middleware.cors import CORSMiddleware
    from motor import motor_asyncio
    from fastapi.exceptions import RequestValidationError
    from fastapi.responses import JSONResponse
    from fastapi.encoders import jsonable_encoder
    from collections import defaultdict
    from config import BaseConfig
    from routers.cars import router as cars_router
    from routers.users import router as users_router
    settings = BaseConfig()
    async def lifespan(app: FastAPI):
        app.client = motor_asyncio.AsyncIOMotorClient(settings.DB_URL)
        app.db = app.client[settings.DB_NAME]
        try:
            app.client.admin.command("ping")
            print("Pinged your deployment. You have successfully connected to MongoDB!")
        except Exception as e:
            print(e)
        yield
        app. client.close()
    app = FastAPI(lifespan=lifespan)
    app.include_router(cars_router, prefix="/cars", tags=["cars"])
    @app.get("/")
    async def get_root():
        return {"Message": "Root working!"}
    

您已创建了第一个 APIRouter,它将处理有关汽车的操作,并且您已经通过 app.py 文件将其连接到主 FastAPI 实例。

现在,您将通过实现各种操作的处理器来为 APIRouter 添加功能。

POST 处理器

现在,与 APIRouter 连接后,您可以返回到 /routers/cars.py 文件并创建第一个端点,一个用于创建新实例的 POST 请求处理器:

@router.post(
    "/",
    response_description="Add new car",
    response_model=CarModel,
    status_code=status.HTTP_201_CREATED,
    response_model_by_alias=False,
)
async def add_car(request: Request, car: CarModel = Body(...)):
    cars = request.app.db["cars"]
    document = car.model_dump(
        by_alias=True, exclude=["id"])
    inserted = await cars.insert_one(document)
    return await cars.find_one({"_id": inserted.inserted_id})

代码相当简单且易于理解,因为它使用了之前定义的 Pydantic 模型 (CarModel),该模型足够灵活,可以通过别名作为输入和输出模型重用。

创建从模型插入文档的行使用了几个 Pydantic 功能,这些功能在 第三章Python 类型提示和 Pydantic 中有介绍,即 别名排除 字段。

现在,启动应用程序:

uvicorn app:app

在另一个终端中,仍然位于您项目的当前工作目录内,并且虚拟环境已激活,使用 HTTPie 测试端点:

http POST http://127.0.0.1:8000/cars/ brand="KIA" make="Ceed" year=2015 price=2000 km=100000 cm3=1500

您的终端应该输出以下响应:

HTTP/1.1 201 Created
content-length: 109
content-type: application/json
date: Sun, 12 May 2024 15:29:45 GMT
server: uvicorn
{
    "brand": "Kia",
    "cm3": 1500,
    "id": "6640e06ad82a890d261a8a40",
    "km": 100000,
    "make": "Ceed",
    "price": 2000,
    "year": 2015
}

您已创建了第一个端点——您可以使用 HTTPie 或 http://127.0.0.1:8000/docs 上的交互式文档进一步测试它,并尝试插入一些无效数据,例如年份大于 2024 或类似的数据。

端点应该返回包含信息的 JSON,这将迅速引导您找到问题或向最终用户提供反馈。现在,您将创建用于查看数据库中汽车的 GET 处理器。

处理 GET 请求

为了查看系统中的资源——汽车,您将使用 HTTP GET 方法。FastAPI 充分利用 HTTP 动词语义,并紧密遵循网络标准和良好实践。

按照以下步骤操作:

  1. 首先,返回整个汽车集合——如果你已经玩过POST端点,你可能已经插入了几辆。继续编辑/routers/cars.py文件,让我们添加GET处理器:

    @router.get(
        "/",
        response_description="List all cars",
        response_model=CarCollection,
        response_model_by_alias=False,
    )
    async def list_cars(request: Request):
        cars = request.app.db["cars"]
        results = []
        cursor = cars.find()
        async for document in cursor:
            results.append(document)
        return CarCollection(cars=results)
    
  2. 使用 HTTPie 测试此端点:

    http http://127.0.0.1:8000/cars/
    

    运行前面的命令后,你应该会得到到目前为止插入的所有汽车,以一个漂亮的 JSON 结构呈现。函数签名和装饰器与POST端点相似。

  3. 不使用async for(如果你不习惯,一开始可能会觉得有点反直觉),你也可以用以下方式交换空结果列表的填充:

    return CarCollection(
        cars=await cars.find().to_list(1000)
        )
    

    然后,你可以使用to_list()方法将结果获取为一个列表。如果你希望深入了解处理游标的Motor文档,他们的页面可能有点枯燥但内容完整:motor.readthedocs.io/en/stable/api-tornado/cursors.html

    之后,你将学习如何手动添加分页,因为集合可能会增长到数百辆汽车,你不会希望立即向用户发送数百个结果。现在,创建一个通过 ID 查找单个汽车的GET端点。

  4. 在同一个/routers/cars.py文件中,添加以下GET处理器:

    @router.get(
        "/{id}",
        response_description="Get a single car by ID",
        response_model=CarModel,
        response_model_by_alias=False,
    )
    async def show_car(id: str, request: Request):
        cars = request.app.db["cars"]
        try:
            id = ObjectId(id)
        except Exception:
            raise HTTPException(status_code=404, detail=f"Car {id} not found")
        if (car := await cars.find_one({"_id": ObjectId(id)})) is not None:
            return car
        raise HTTPException(status_code=404, detail=f"Car with {id} not found")
    

端点的逻辑包含在检查集合是否包含具有所需 ID 的汽车,并且 ID 是通过路径参数提供的这一行。Python 的 walrus 运算符(:=),也称为赋值表达式,使你的代码更加简洁:如果找到汽车(它不是None),则返回,并将真值检查的操作数——汽车实例本身——传递下去;否则,代码将继续执行并抛出 HTTP 异常。

再次,对于 HTTPie 测试命令,你需要查找一个 ID 并将其作为路径参数提供(你的 ID 值将不同于以下):

http http://127.0.0.1:8000/cars/6640e06ad82a890d261a8a40

你已经实现了映射到GET HTTP 方法的最重要两种结果列出方法:检索所有项的列表和特定单个项。其他GET端点可以基于 MongoDB 聚合、简单查询和过滤来检索查询,但这两个涵盖了基础。

现在,让我们通过UPDATEDELETE方法完成 API。

更新和删除记录

现在,你将处理最复杂的端点——用于更新汽车实例的PUT方法。同样,在同一个/routers/cars.py文件中,在GET路由之后,继续编辑:

async def update_car(
    id: str,
    request: Request,
    user=Depends(auth_handler.auth_wrapper),
    car: UpdateCarModel = Body(...),
):
    try:
        id = ObjectId(id)
    except Exception:
        raise HTTPException(status_code=404, detail=f"Car {id} not found")
    car = {
        k: v
        for k, v in car.model_dump(by_alias=True).items()
        if v is not None and k != "_id"
    }

端点函数的第一部分分析提供的用户数据,并检查哪些字段应该被更新,只需通过确认它们在提供的UpdateCarModel Pydantic 模型中的存在即可。如果字段在请求体中存在,其值将被传递到update字典中。

因此,你得到一个转换后的car对象,如果不为空,则将其传递给 MongoDB 的find_one_and_update()函数:

    if len(car) >= 1:
        cars = request.app.db["cars"]
        update_result = await cars.find_one_and_update(
            {"_id": ObjectId(id)},
            {"$set": car},
            return_document=ReturnDocument.AFTER,
        )
        if update_result is not None:
            return update_result
        else:
            raise HTTPException(status_code=404, detail=f"Car {id} not found")

更新结果简单地执行异步更新,并利用 PyMongo 的ReturnDocument.AFTER在更新完成后返回更新后的文档。

最后,你还需要考虑这样一种情况:在更新时没有任何字段被设置,如果发现这种情况,就简单地返回原始文档:

    if (existing_car := await cars.find_one({"_id": id})) is not None:
        return existing_car
    raise HTTPException(status_code=404, detail=f"Car {id} not found")

端点提供了两种404异常的可能性:当有字段需要更新时,以及当没有字段需要更新时。

现在,使用删除汽车的方法完成基本 CRUD 功能的实现:

@router.delete("/{id}", response_description="Delete a car")
async def delete_car(
    id: str, request: Request, user=Depends(auth_handler.auth_wrapper)
):
    try:
        id = ObjectId(id)
    except Exception:
        raise HTTPException(status_code=404, detail=f"Car {id} not found")
    cars = request.app.db["cars"]
    delete_result = await cars.delete_one({"_id": id})
    if delete_result.deleted_count == 1:
        return Response(status_code=status.HTTP_204_NO_CONTENT)
    raise HTTPException(status_code=404, detail=f"Car with {id} not found")

这可能是最简单的端点;如果找到具有该 ID 的汽车,它将被删除,并在空(No Content)响应上返回适当的 HTTP 状态。

这就完成了基本的 CRUD 功能,但在继续之前,让我们解决另一个方面的问题,虽然它不是基本功能的一部分,但将在每个实际项目中出现:结果分页。

结果分页

每个与数据和用户打交道的应用程序都必须有一种适当的方式来启用和促进他们的沟通。将数百个结果强行推入浏览器并不是最佳解决方案。

使用 MongoDB 以及其他数据库进行结果分页是通过skiplimit参数实现的。

在这种情况下,你将创建一个简单的、对前端友好的分页系统,使用自定义的 Pydantic 模型,该模型将提供两个额外的 JSON 属性:当前页和has_more标志,以指示是否有更多结果页。

此模式将分页 UI 与箭头和页码匹配,这些箭头和页码向用户指示结果总数。

首先,注释掉现有的GET路由。打开models.py文件并添加以下模型:

class CarCollectionPagination(CarCollection):
    page: int = Field(ge=1, default=1)
    has_more: bool

此模型继承自CarCollection模型并添加了两个所需的字段——当处理大型且复杂的模型时,此模式非常有用。

cars.py文件中,在实例化APIRouter后,添加一个硬编码的常量,该常量将定义每页默认的结果数:

CARS_PER_PAGE = 10

现在你将更新(或者更好,完全替换)routers/cars.py文件中的get all方法:

@router.get(
    "/",
    response_description="List all cars, paginated",
    response_model=CarCollectionPagination,
    response_model_by_alias=False,
)
async def list_cars(
    request: Request,
    page: int = 1,
    limit: int = CARS_PER_PAGE,
):
    cars = request.app.db["cars"]
    results = []

函数的第一部分与上一个版本非常相似,但我们有两个新的参数:pagelimit(每页的结果数)。现在,创建实际的分页:

cursor = cars.find().sort("companyName").limit(limit).skip((page - 1) * limit)
    total_documents = await cars.count_documents({})
    has_more = total_documents > limit * page
    async for document in cursor:
        results.append(document)
    return CarCollectionPagination(cars=results, page=page, has_more=has_more)

大部分工作直接由 MongoDB 处理,使用limitskip参数。端点需要集合中汽车的总数,以便提供有关剩余结果及其存在的信息。

此端点将像之前的端点一样工作,因此为了正确测试它,请打开 MongoDB Compass 并导入一些数据。附带的 GitHub 仓库包含一个名为cars.csv的文件,其中包含 1,249 辆汽车。

在导入这些数据后,你可以执行以下GET请求:

http http://127.0.0.1:8000/cars/?page=12

输出应包含汽车列表,就像上一个案例一样,但也要指明页面和是否有更多结果:

{
    "has_more": false,
    "page": 12
}

由于你已经在从数据库中提取总文档计数,你可以扩展此分页模型以包括数据库中的汽车总数或根据当前分页提供的总页数。这将是一个很好的练习,展示了如何轻松扩展和修改 FastAPI 设置。

你已经使用 FastAPI 成功创建了一个功能齐全的 REST API。现在,让我们通过提供图像上传功能来进一步增强应用程序。

上传图像到 Cloudinary

虽然 FastAPI 完全能够通过StaticFiles模块(fastapi.tiangolo.com/tutorial/static-files/)提供静态文件服务,但你很少会想用你的服务器空间和带宽来存储图像或视频。

许多专业服务可以处理数字资产媒体管理,在本节中,你将学习如何与该领域的主要参与者之一——Cloudinary合作。

如其名所示,Cloudinary 是一个基于云的服务,为数字媒体资产和 Web 和移动应用程序提供各种解决方案。这些服务包括上传和存储图像和视频,这正是我们现在将要使用的功能。

然而,Cloudinary 和其他类似的专业服务提供了更多(图像和视频处理、过滤器、自动裁剪和格式化以及实时转换)的功能,它们可能非常适合许多媒体工作流程,尤其是非常繁重的工作流程。

要使用该服务,你首先需要通过cloudinary.com/users/register_free上的说明创建一个免费账户。

在成功注册并登录后,你将自动分配一个产品环境密钥,位于左上角。就你的用途而言,你将通过 Python API 进行交互,因为你需要能够通过 FastAPI 将图像上传到你的环境。

要开始使用 Python API,或者任何其他 API,除了这个环境密钥外,你还需要另外两块信息:API 密钥API 密钥。这两者都可以从设置页面(console.cloudinary.com./settings)通过选择左侧菜单中的API 密钥获取。

复制 API 密钥和 API 密钥,或者创建新的并复制到现有的.``env文件中:

DB_URL=mongodb+srv://xxxxxx:xxxxxxxx@cluster0.fkm24.mongodb.net/?retryWrites=true&w=majority&appName=Cluster0
DB_NAME=carBackend
CLOUDINARY_SECRET_KEY=xxxxxxxxxxxxxxxx
CLOUDINARY_API_KEY=xxxxxxxxxx
CLOUDINARY_CLOUD_NAME=xxxxxxxx

环境名称映射为CLOUDINARY_CLOUD_NAME,而密钥和 API 密钥则由CLOUDINARY前缀。

你还需要修改config.py文件以适应新的变量:

from typing import Optional
from pydantic_settings import BaseSettings, SettingsConfigDict
class BaseConfig(BaseSettings):
    DB_URL: Optional[str]
    DB_NAME: Optional[str]
    CLOUDINARY_SECRET_KEY: Optional[str]
    CLOUDINARY_API_KEY: Optional[str]
    CLOUDINARY_CLOUD_NAME: Optional[str]
    model_config = SettingsConfigDict(env_file=".env", extra="ignore")

下一步是安装cloudinaryPython 包:

pip install cloudinary

你还可以将其添加到你的 requirements.txt 文件中,此时它应该看起来像这样:

fastapi==0.111.0
motor==3.4.0
uvicorn==0.29.0
httpie==3.2.2
cloudinary==1.40.0
pydantic-settings==2.2.1

当涉及到 JavaScript 时,Cloudinary 文档更为丰富,设置上传客户端时似乎有几个怪癖,但本质上是简单的。

更新模型

首先,你需要更新 models.py 文件以适应新的字段——一个将存储从 Cloudinary 上传的图片 URL 的字符串:

  1. 打开 models.py 文件,并在其他字段和验证器之后仅添加一行到 CarModel 类中:

    # add the picture file
        picture_url: Optional[str] = Field(None)
    
  2. 在这一点上,你应该打开 cars 集合,因为你将创建一个新的、空的集合。现在,在 cars.py 文件中注释掉之前的 POST 处理器路由,并创建一个新的路由,考虑到图像上传过程。

  3. Cloudinary 提供了一个简单的实用模块,称为 uploader,需要导入,以及 cloudinary 模块本身。在现有的导入之后,添加以下行(cars.py):

    import cloudinary
    from cloudinary import uploader  # noqa: F401
    

    这些行导入了 cloudinaryuploader 包,而 # noqa 行防止代码检查器在保存时删除该行(因为它是从已整体导入的包中导入的)。

  4. 下一步是配置你的 Cloudinary 实例,你可以为了方便在 /routers/cars.py 文件中这样做,尽管这也可以是应用级别的。

    为了能够读取环境变量,你需要在同一文件中再次实例化 Settings 类,并将变量传递给 cloudinary 配置对象。

  5. 打开 cars 路由器并对其进行修改。现在 /routers/cars.py 文件的前一部分应该看起来像这样:

    from bson import ObjectId
    from fastapi import (
        APIRouter,
        Body,
        File,
        Form,
        HTTPException,
        Request,
        UploadFile,
        status,
    )
    from fastapi.responses import Response
    from pymongo import ReturnDocument
    import cloudinary
    from cloudinary import uploader  # noqa: F401
    from config import BaseConfig
    from models import CarCollectionPagination, CarModel, UpdateCarModel
    settings = BaseConfig()
    router = APIRouter()
    CARS_PER_PAGE = 10
    cloudinary.config(
        cloud_name=settings.CLOUDINARY_CLOUD_NAME,
        api_key=settings.CLOUDINARY_API_KEY,
        api_secret=settings.CLOUDINARY_SECRET_KEY,
    )
    

    现在,你必须以不同的方式处理 POST 处理器,因为它将接受表单和一个文件(你的汽车图片),而不是 JSON。你需要接受表单数据:

    @router.post(
        "/",
        response_description="Add new car with picture",
        response_model=CarModel,
        status_code=status.HTTP_201_CREATED,
    )
    async def add_car_with_picture(
        request: Request,
        brand: str = Form("brand"),
        make: str = Form("make"),
        year: int = Form("year"),
        cm3: int = Form("cm3"),
        km: int = Form("km"),
        price: int = Form("price"),
        picture: UploadFile = File("picture"),
    ):
    

    所有 CarModel 字段现在都映射到具有名称的表单字段,而图片被定义为 UploadFile 并期望一个文件。

    继续使用相同的函数,并添加上传功能:

        cloudinary_image = cloudinary.uploader.upload(
            picture.file, crop="fill", width=800
        )
        picture_url = cloudinary_image["url"]
    

    处理实际上传的代码非常简单:只需调用 uploader 并传入接收到的文件,你可以使用许多选项、转换和过滤器。

注意

Cloudinary 文档详细介绍了可用的转换:cloudinary.com/documentation/transformations_intro.

在你的情况下,你只是裁剪图片并设置最大宽度。Cloudinary 会在图片上传后返回一个 URL,这个 URL 将成为模型的一部分,以及我们之前使用的其他数据。

最后,你可以构建一个 Pydantic 模型来表示汽车,并将其传递给 MongoDB 的 cars 集合:

    car = CarModel(
        brand=brand,
        make=make,
        year=year,
        cm3=cm3,
        km=km,
        price=price,
        picture_url=picture_url,
    )
    cars = request.app.db["cars"]
    document = car.model_dump(by_alias=True, exclude=["id"])
    inserted = await cars.insert_one(document)
    return await cars.find_one({"_id": inserted.inserted_id})

你可以通过 FastAPI 在127.0.0.1:8000/docs上提供的交互式文档测试端点;只需选择一张图片并将其传递给根路由的POST处理程序中的文件字段,别忘了填写剩余的字段,否则会出现错误——就像处理 JSON 一样。

你也可以使用 HTTPie 测试路由,但首先提供一张图片并相应地命名它:

http --form POST 127.0.0.1:8000/cars brand="Ford" make="Focus" year=2000 cm3=1500 price=12000 km=23000 picture="ford.jpg"

在准备好Cars API 路由器之后,现在你将创建第二个路由器来处理用户。

添加用户模型

你已经成功创建了一个由 Cloudinary 图像托管和处理能力驱动的 REST API,并且通过类似的过程,你可以轻松地将其他第三方服务集成到你的 API 中,使你的应用程序更加复杂和强大。

然而,没有认证,将即使是简单的 API 部署到线上也是非常危险的。例如,一个恶意用户(甚至是一个愿意恶作剧的孩子)可以轻易地用你不想显示的图片“轰炸”你的 API,并且数量足以迅速填满你的免费配额。因此,在将你的 API 提交到 GitHub 并部署——在这种情况下,到 Render.com——之前,你将添加一个用户模型和一个与第六章中展示的非常相似的基于 JWT 的认证方案,认证 和授权

在下一节中,你将创建一个简单的用户模型,并允许用户登录到应用程序,以便执行一些其他情况下不可用的操作——即创建、更新和删除资源(汽车)。你将从将认证逻辑抽象成一个类开始。

创建认证功能

在本节中,你将实现一个认证类,类似于在第六章中使用的,认证和授权,该类将抽象出认证和授权所需的功能——密码加密、JWT 编码和解码,以及用于保护路由的依赖项。请按照以下步骤操作:

  1. 首先,在你的项目根目录下创建一个名为authentication.py的文件,并导入所需的认证模块:

    from datetime import datetime
    import jwt
    from fastapi import HTTPException, Security
    from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
    from passlib.context import CryptContext
    
  2. 接下来,实现一个AuthHandler类,它将提供所有必要的功能来散列和验证密码以及编码和解码令牌:

    class AuthHandler:
        security = HTTPBearer()
        pwd_context = CryptContext(
            schemes=["bcrypt"], deprecated="auto"
        )
        secret = "FARMSTACKsecretString"
        def get_password_hash(self, password):
            return self.pwd_context.hash(password)
        def verify_password(
            self, plain_password, hashed_password
        ):
            return self.pwd_context.verify(
                plain_password, hashed_password
            )
        def encode_token(self, user_id, username):
            payload = {
                "exp": datetime.datetime.now(
                    datetime.timezone.utc)
                + datetime.timedelta(minutes=30),
                "iat": datetime.datetime.now(datetime.timezone.utc),
                "sub": {
                    "user_id": user_id,
                     "username": username},
            }
            return jwt.encode(payload, self.secret, algorithm="HS256")
        def decode_token(self, token):
    try:
        payload = jwt.decode(
            token, self.secret, algorithms=["HS256"]
        )
        return payload["sub"]
    except jwt.ExpiredSignatureError:
        raise HTTPException(
            status_code=401,
            detail="Signature has expired"
        )
    except jwt.InvalidTokenError:
        raise HTTPException(
            status_code=401,
            detail="Invalid token"
        )
    
  3. 最后,你将在文件末尾添加auth_wrapper函数,该函数将被注入到需要认证用户的 FastAPI 端点中:

        def auth_wrapper(
            self,
            auth: HTTPAuthorizationCredentials =
                Security(security)
        ):
            return self.decode_token(auth.credentials)
    

    认证类几乎与在第六章**认证和授权中定义的相同——它提供了密码散列和验证、JWT 编码和解码的方法,以及一个方便的auth_wrapper方法,用作依赖注入。

  4. authentication.py 文件准备好后,添加用户模型,它与上一章中定义的模型非常相似,请注意,此模型可能更加复杂。

  5. models.py 文件中,编辑 CarModel 类并添加另一个字段——user_id。这样,您就可以将插入的汽车与特定用户关联起来,并要求每个创建操作都有效用户:

    class CarModel(BaseModel):
        id: Optional[PyObjectId] = Field(alias="_id", default=None)
        brand: str = Field(...)
        make: str = Field(...)
        year: int = Field(..., gt=1970, lt=2025)
        cm3: int = Field(..., gt=0, lt=5000)
        km: int = Field(..., gt=0, lt=500 * 1000)
        price: int = Field(..., gt=0, lt=100000)
        user_id: str = Field(...)
        picture_url: Optional[str] = Field(None)
    
  6. 更新汽车的模型不需要 user_id 字段,因为您不希望使该字段可编辑。现在,在所有汽车模型之后,让我们在同一个 models.py 文件中添加与用户相关的模型:

    class UserModel(BaseModel):
        id: Optional[PyObjectId] = Field(alias="_id", default=None)
        username: str = Field(..., min_length=3, max_length=15)
        password: str = Field(...)
    class LoginModel(BaseModel):
        username: str = Field(...)
        password: str = Field(...)
    class CurrentUserModel(BaseModel):
        id: PyObjectId = Field(alias="_id", default=None)
        username: str = Field(..., min_length=3, max_length=15)
    

三种模型对应于您将访问用户数据的三种方式:包含所有数据的完整模型、登录和注册模型,以及应返回 _id 和用户名的当前用户。

创建用户路由器

在设置 Pydantic 模型之后,为用户创建一个新的路由器,并允许一些基本操作,例如注册、登录和基于 JWT 验证用户。

打开 routers 文件夹内名为 users.py 的文件并添加导入:

from bson import ObjectId
from fastapi import APIRouter, Body, Depends, HTTPException, Request, Response
from fastapi.responses import JSONResponse
from authentication import AuthHandler
from models import CurrentUserModel, LoginModel, UserModel
router = APIRouter()
auth_handler = AuthHandler()

authhandler 类封装了所有的身份验证逻辑,您将在端点函数中看到此功能。

让我们创建注册路由:

@router.post("/register", response_description="Register user")
async def register(request: Request, newUser: LoginModel = Body(...)) -> UserModel:
    users = request.app.db["users"]
    # hash the password before inserting it into MongoDB
    newUser.password = auth_handler.get_password_hash(newUser.password)
    newUser = newUser.model_dump()
    # check existing user or email 409 Conflict:
    if (
        existing_username := await users.find_one({"username": newUser["username"]})
        is not None
    ):
        raise HTTPException(
            status_code=409,
            detail=f"User with username {newUser['username']} already exists",
        )
    new_user = await users.insert_one(newUser)
    created_user = await users.find_one({"_id": new_user.inserted_id})
    return created_user

端点执行与第六章中所示的功能相同,身份验证和授权,但这次,您正在处理一个真实的 MongoDB 集合。登录功能也非常相似:

@router.post("/login", response_description="Login user")
async def login(request: Request, loginUser: LoginModel = Body(...)) -> str:
    users = request.app.db["users"]
    user = await users.find_one({"username": loginUser.username})
    if (user is None) or (
        not auth_handler.verify_password(loginUser.password, user["password"])
    ):
        raise HTTPException(status_code=401, detail="Invalid username and/or password")
    token = auth_handler.encode_token(str(user["_id"]), user["username"])
    Wrong indentation. check and replace with:
response = JSONResponse(
    content={
        "token": token,
        "username": user["username"]
    }
)
    return response

如果找不到用户或密码不匹配,端点将响应 HTTP 401 状态并抛出一个通用消息;否则,将返回用户名和令牌。

最终端点由一个 /me 路由组成——该路由将由前端(React)定期使用以检查现有的 JWT 及其有效性:

@router.get(
    "/me",
    response_description="Logged in user data",
    response_ model=CurrentUserModel
)
async def me(
    request: Request,
    response: Response,
    user_data=Depends(auth_handler.auth_wrapper)
):
    users = request.app.db["users"]
    currentUser = await users.find_one(
        {"_id": ObjectId(user_data["user_id"])}
    )
    return currentUser

在完成 users 路由器后,将其连接到 app.py 文件,位于 cars 路由器下方:

app.include_router(
    cars_router, prefix="/cars", tags=["cars"]
    )
app.include_router(
    users_router, prefix="/users", tags=["users"]
    )

包含管理汽车的 APIRoutercars.py 文件将需要更新以考虑新添加的用户数据。创建端点现在将如下所示:

@router.post(
    "/",
    response_description="Add new car with picture",
    response_model=CarModel,
    status_code=status.HTTP_201_CREATED,
)
async def add_car_with_picture(
    request: Request,
    brand: str = Form("brand"),
    make: str = Form("make"),
    year: int = Form("year"),
    cm3: int = Form("cm3"),
    km: int = Form("km"),
    price: int = Form("price"),
    picture: UploadFile = File("picture"),
    user: str =Depends(auth_handler.auth_wrapper),
):

用户数据通过依赖注入和 auth_wrapper 提供。函数的其余部分基本未更改——您只需要登录用户的 user_id 值:

cloudinary_image = cloudinary.uploader.upload(
    picture.file, folder="FARM2", crop="fill", width=800
)
picture_url = cloudinary_image["url"]
car = CarModel(
    brand=brand,
    make=make,
    year=year,
    cm3=cm3,
    km=km,
    price=price,
    picture_url=picture_url,
    user_id=user["user_id"],
)
cars = request.app.db["cars"]
document = car.model_dump(by_alias=True, exclude=["id"])
inserted = await cars.insert_one(document)
return await cars.find_one({"_id": inserted.inserted_id})

API 现在相当完整;它处理各种复杂性的数据,并可以使用顶级云服务处理图像。然而,在将您的 API 部署到在线云平台供全世界查看之前,还有一件事需要做:设置 跨源资源共享CORS)中间件。

FastAPI 中间件和 CORS

中间件的概念在几乎每个值得信赖的 Web 框架中都很常见,FastAPI 也不例外。中间件只是一个在将请求传递给路径操作处理之前接受请求的函数,并在它们返回之前进行响应。

这个简单的概念非常强大,有众多用途——一个中间件可以检查包含认证数据(例如令牌)的特定头信息,并根据情况接受或拒绝请求,它可以用于速率限制(通常与 Redis 键值数据库一起使用),等等。

在 FastAPI 中创建中间件基于 Starlette 的中间件,就像 FastAPI 中大多数与网络相关的概念一样,文档提供了一些很好的示例:fastapi.tiangolo.com/tutorial/middleware/

在你的应用程序中,你将使用现成的中间件来启用基于 FastAPI 的后端——该后端将运行在一台机器上——与运行在不同源的前端(在你的情况下,React)进行通信。

CORS 指的是当后端和前端位于不同的源时应用的政策,默认情况下它非常严格——只允许使用相同源的系统之间共享数据(例如调用 JavaScript fetch 函数):协议(例如 HTTP)、域名(例如 www.packt.com)和端口号(例如 300080)的组合。

默认情况下,该策略阻止所有通信,所以如果你像现在这样部署你的后端,你将无法从运行在同一台机器但不同端口的 React.js 或 Next.js 应用程序中访问它。

FastAPI 解决这个任务的方案是通过中间件实现的,并且它允许细粒度的精确控制。

在你的 app.py 文件中,导入以下内容以导入 CORS 中间件:

from fastapi.middleware.cors import CORSMiddleware

导入中间件后,你需要对其进行配置。在用生命周期实例化 FastAPI 实例后,添加中间件:

app = FastAPI(lifespan=lifespan)
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

这是一个通用的 CORS 设置,在生产环境中应避免使用,但对我们目的和这个示例后端来说已经足够了。方括号包含允许的方法列表(例如 POSTGET 等)、源、头信息和是否允许凭据。

你可以重新启动 Uvicorn 服务器并检查它是否像以前一样工作。现在,你将在云平台上部署后端。

部署到 Render.com

Render.com 是众多简化部署和管理 Web 应用程序、API、静态网站和其他类型软件项目的现代云平台之一。它为开发者提供了一个直观且简单的界面,以及强大的自动化工具和管道。

部署 FastAPI 实例的方法有很多:Vercel(主要作为 Next.js 背后的公司)、Fly.io、Ralway、Heroku 等等。

在这种情况下,我们将选择 Render.com,因为它提供了一个简单、快速、简化的部署流程,并提供免费层和优秀的文档。

部署过程可以分为几个步骤,您将简要地审查每一个;如果您希望快速了解,也可以访问他们的 FastAPI 专用页面:docs.render.com/deploy-fastapi

这里是步骤:

  1. 为您的后端设置一个 GitHub 仓库。

    再次确保您的.gitignore文件包含.env文件的条目,以及 Python 环境的env/目录——您不希望意外地将机密和密码提交到公共仓库,也不希望上传整个虚拟环境的内容。

    如果您还没有将后端最后的更改提交,现在就使用以下命令进行提交:

    git add .
    git commit -m "ready for deployment"
    

    现在,前往github.com,使用您的凭证登录,并创建一个新的仓库。您可以随意命名;在这个例子中,我们将使用名称FastAPIbackendCh7

  2. 设置 Render.com 账户。

    现在,前往render.com并创建一个免费账户。您可以使用 GitHub 账户登录,然后导航到仪表板链接:dashboard.render.com

    定位到新+按钮,选择网络服务。在下一个提示中,选择从 Git 仓库构建和部署并点击下一步

  3. 选择 GitHub 仓库。

    在右侧菜单中选择GitHub Configure账户,您将被带到 GitHub,要求您安装 Render。选择您的账户,即您用于后端仓库源的那个账户,然后继续选择仓库。这将使 Render 知道要拉取哪个仓库。

  4. 配置网络服务。

    这是整个过程中最重要且最复杂的步骤。Render 已经知道涉及哪个仓库,现在它必须获取部署网络服务所需的所有数据。我们将逐一检查:

    • main,尤其是在我们的案例中它是唯一分支的情况下。

    • /.

    • 运行时:您将使用 Python 3;它应该会被 Render 自动识别。

    • 构建命令:设置环境的命令——在您的案例中,是 Python 3 虚拟环境,因此应该是以下命令:

      pip install -r requirements.txt
      
    • 80),命令应该是以下这样:

      uvicorn app:app --host 0.0.0.0 --port 80
      
    • .env文件,一个接一个:DB_URLDB_NAME用于 MongoDB,以及三个 Cloudinary 变量。

在确认您已经输入了所有设置和变量后,您最终可以点击蓝色的创建网络服务按钮。

最终的设置页面将类似于以下图片。设置页面相当长,您可能需要滚动一下,但首先必须指定的是服务的名称和区域:

图 7.1:Render 网络服务常规设置页面

在设置名称和区域后,您将看到您选择的仓库和要部署的分支(在您的案例中是main)。您可以将根目录默认留空。

图 7.2:仓库和分支设置

接下来,您将指定构建和启动命令。构建命令是安装您的 Python 环境的那一个,而启动命令则是启动您的网络服务——您的 API。

图片

图 7.3:构建和启动命令

在开始实际的部署命令之前,最后一步是将环境变量传递给 Render.com:

图片

图片

在启动部署流程后,您需要稍等片刻——服务将需要创建一个新的 Python 环境,安装所有必需的依赖项,并启动服务。在过程完成后,您可以在页面上的 URL 上点击(在您的例子中,将是farm2ch7.onrender.com,您将需要使用另一个地址)并在线检查您的 API。

您的 API 现在已经在互联网上上线,并准备好接收请求。值得一提的是,由于 FastAPI 最近越来越受欢迎,越来越多的托管服务和.env文件。

摘要

在本章中,您将一个简单的业务需求转变为一个完全功能性的 API,并部署到了互联网上。

您创建了 Pydantic 模型,并对数据结构应用了一些约束,学习了如何连接到 MongoDB Atlas 实例,并开发了一个基本但功能齐全的 CRUD 功能 FASTAPI 服务。

您已经学会了如何通过 Pydantic 来建模实体(在您的例子中,是汽车和用户),以及如何通过简单的 Pythonic FastAPI 端点,将数据无缝地流向和从您选择的数据库——MongoDB——中流动。

您通过pydantic_settings管理了密钥——用于连接到 MongoDB Atlas 和 Cloudinary——并精心设计了简单而灵活的模型,这些模型可以轻松地适应更多需求,进行扩展或增加更多功能。

服务现在已准备好在前端使用——最终,赋予全栈 Web 应用生命。

在下一章中,您将向这个 API 添加一个简单的用户模型,并构建一个 React 前端,该前端将消费 FastAPI 后端。

第八章:构建应用程序的前端

在上一章中,您探讨了如何构建您的 FastAPI 后端并连接到 MongoDB。这将用于本章中您将构建的 React 前端。该应用程序将简单且功能丰富,最重要的是,它将允许您看到堆栈的各个部分协同工作。

在本章中,您将构建一个全栈 FARM 应用程序的前端。您将学习如何设置 React Vite 应用程序并安装和设置 React Router,以及加载内容的各种方法。该应用程序将允许认证用户插入新项目(汽车),同时将有多页用于显示汽车。

您将开发一个网站,该网站将列出待售的二手车,并且只允许登录用户发布新的汽车广告。您将首先使用 Vite 创建一个 React 应用程序,然后使用 React Router 布局页面结构,并逐步引入认证、受保护页面和数据加载等功能。在本章之后,您将能够轻松地利用 React Router 为您的单页应用程序SPAs)提供支持,并使用强大的React Hook FormRHF)进行细粒度表单控制。

本章将涵盖以下主题:

  • 使用 Vite 创建新的 React 应用程序

  • 设置 React Router 以进行 SPA 页面导航

  • 使用数据加载器管理数据

  • RHF 和 Zod 的数据验证简介

  • 使用 Context API 进行认证和授权

  • 使用 React Router 页面保护路由和显示数据

技术要求

本章的技术要求与第四章**,使用 FastAPI 入门*中列出的类似。您需要以下内容:

  • Node 版本 18.14

  • 一个好的代码编辑器,例如 Visual Studio Code

  • 节点包管理器

创建 Vite React 应用程序

在本节中,您将构建 Vite React 应用程序并设置 Tailwind CSS 进行样式化。此过程已在第五章设置 React 工作流程中介绍,您可以参考它。请确保完成第五章中的简要教程,因为以下指南在很大程度上基于其中介绍的概念。

您将使用create vite命令与 Node 包管理器通过以下步骤创建您的项目:

  1. 在包含先前创建的后端文件夹的项目目录中打开您的终端客户端,并执行以下命令以创建 Vite React 项目:

    npm create vite@latest frontend-app -- --template react
    
  2. 现在,将目录更改为新创建的frontend-app文件夹,并安装依赖项和 Tailwind:

    npm install -D tailwindcss postcss autoprefixer
    
  3. 初始化 Tailwind 配置——以下命令创建一个空的 Tailwind 配置文件:

    npx tailwindcss init -p
    
  4. 最后,根据最新的文档配置生成的 tailwind.config.js 和 React 的 index.css 文件,文档地址为 tailwindcss.com/docs/guides/vite

您的 index.css 应现在只包含 Tailwind 的导入:

@tailwind base;
@tailwind components;
@tailwind utilities;

为了测试 Tailwind 是否已正确配置,修改 App.jsx 文件并启动开发服务器:

export default function App() {
  return ( <
    h1 className = “text-3xl font-bold” >
    Cars FARM <
    /h1>
  )
}

当您刷新应用程序时,您应该看到一个带有文本 Cars FARM 的白色页面。

在设置好一个功能性的 React 应用程序和 Tailwind 之后,是时候介绍可能最重要的第三方 React 包——React Router。

React Router

到目前为止,由于您正在构建单页应用(SPA),所有组件都适合在单个页面上。为了使您的应用程序能够根据提供的路由显示完全不同的页面,您将使用一个名为 React Router 的包——在 React 中进行页面路由的事实标准。

虽然有一些非常好且健壮的替代方案,例如 TanStack Router (tanstack.com/router/),但 React Router 被广泛采用,了解其基本机制将极大地帮助您,作为一名开发者,因为您很可能会遇到基于它的代码。

React Router 的第 6.4 版本有一些重大变化,同时保留了之前的基本原则,您将使用这些原则来构建您的前端。然而,截至 2024 年 5 月,还宣布了更多激进的变化——React Remix,这是一个完整的全栈框架(具有与 Next.js 相当的功能),它基于 React Router,而 React Router 本身应该合并到一个单一的项目中。在本节中,您将了解最重要的组件,这些组件将允许您创建单页体验,无需页面重新加载或了解 React Router 6.4,这在以后将非常有用,因为它是最广泛采用的 React 路由解决方案。

React Router 的基本底层原理是监听 URL 路径变化(如 /about/login),并根据条件在布局中显示组件。显示的组件可以被视为“页面”,而布局则保留了一些始终应显示的页面部分——例如页脚和导航。

在查看 React Router 之前,请回顾一下您应用程序中的页面:

  • /) 路径

  • /cars)

  • /cars/car_id)

  • /login)

  • “插入新车辆”页面:这将只为认证用户提供表单

为了简化,您将不包括注册路由(因为只有几个认证员工),前端也不会有删除或更新功能。在下一节中,您将安装和配置 React Router,并将其作为您应用程序的基础。

安装和设置 React Router

React Router 只是一个 Node.js 包,因此安装过程很简单。然而,在应用程序内部设置路由器包括许多功能和不同选项。你将使用最强大且推荐的带数据路由器,它提供数据加载,并且是 React Router 团队建议的选项。

使用路由器通常涉及两个步骤:

  1. 使用提供的生成所需路线的方法之一(reactrouter.com/en/main/routers/picking-a-router)。

  2. 创建组件,通常被称为 Login.jsxHome.jsx。此外,你几乎总是会创建一个或多个布局,这些布局将包含如导航或页脚等常见组件。

现在,你将执行安装 React Router 到你的应用程序中所需的步骤:

  1. 第一步,与任何第三方包一样,是安装 router 包:

    npm i react-router-dom@6.23.1
    

    版本号对应于写作时的最新版本,因此你可以重现确切的功能。

    在本章中,应用程序的 CSS 样式将被有意保持到最小——仅足以区分组件。

  2. 首先,在 /src 文件夹内创建一个名为 /pages 的新目录,并搭建所有你的页面。页面名称将是 HomeCarsLoginNewCarNotFoundSingleCar,所有这些都有 .jsx 扩展名,你将以与 Home.jsx 页面相同的方式搭建这些其他页面。

    位于 /src/pages/Home.jsx 的第一个组件将看起来像这样:

    const Home = () => {
        return (
            <div>Home</div>
        )
    }
    export default Home
    

    虽然在讨论 React Router 时,它们通常被称为页面,但这些页面实际上不过是普通的 React 组件。这种区别,以及它们通常被组织在名为 pages 的目录中,纯粹是基于这些组件对应于单页应用(SPA)的页面结构,并且通常不打算在其他地方重用。

  3. 在搭建好所需的页面后,实现路由器。此过程包括创建路由器并将其插入到顶级 React 组件中。你将使用 App.jsx 组件,该组件加载并插入整个 React 应用程序到 DOM 中。

自从 6.4 版本以来,React Router 引入了在需要数据的路由(或页面)加载之前获取数据的功能,通过简单的函数 createBrowserRouter 实现(reactrouter.com/en/main/routers/create-browser-router),因为它如文档所述,是所有 React Router 网络项目的推荐路由器。

在选择 createBrowserRouter 作为创建路由器的所需方法后,是时候将其集成到你的应用程序中了。

将路由器与应用程序集成

在以下步骤中,你将集成路由器到你的应用程序中,创建一个 Layout 组件,并将组件(页面)连接到每个定义的 URI:

  1. 为了正确配置路由器,你需要另一个组件——Layout 组件,在其中将渲染之前创建的页面。在 /src 文件夹内,创建一个 /layouts 文件夹,并在其中创建一个 RootLayout.jsx 文件:

    const RootLayout = () => {
      return (
        <div>RootLayout</div>
      )
    }
    export default RootLayout
    

    你将要使用的 React 路由器以及支持数据加载的路由器基于 react-router-dom 包中的三个导入:createBrowserRoutercreateRoutesFromElementsRoute

  2. 打开 App.jsx 文件并导入包和之前创建的页面:

    import {
      createBrowserRouter,
      Route,
      createRoutesFromElements,
      RouterProvider
    } from “react-router-dom”
    import RootLayout from “./layouts/RootLayout”
    import Cars from “./pages/Cars”
    import Home from “./pages/Home”
    import Login from “./pages/Login”
    import NewCar from “./pages/NewCar”
    import SingleCar from “./pages/SingleCar”
    
  3. 现在,继续使用相同的 App.jsx 文件,将你刚刚导入并定义的元素创建的路由连接起来:

    const router = createBrowserRouter(
      createRoutesFromElements(
        <Route path=”/” element={<RootLayout />}>
          <Route index element={<Home />} />
          <Route path=”cars” element={<Cars />} />
          <Route path=”login” element={<Login />} />
          <Route path=”new-car” element={<NewCar />} />
          <Route path=”cars/:id” element={<SingleCar />} />
        </Route>
      )
    )
    export default function App() {
      return (
        <RouterProvider router={router} />
      )
    }
    

在前面的代码中,有几个重要的事项需要注意。在创建路由器后,你调用了名为 createRoutesFromElements 的 React Router 函数,该函数创建了实际的路线。路由用于定义与组件对应和映射的单独路径;它可以是一个自闭合标签(如用于页面的那些),或者它可以包含其他路由——例如主页路径,它反过来对应于 RootLayout

如果你再次启动 React 服务器并访问页面 http://localhost:5173,你将只会看到文本 RootLayout。尝试导航到路由器中定义的任何路由:/cars/cars/333/login。你将看到相同的 RootLayout 文本,但如果你输入一个未定义的路径,例如 /about,React 将会显示一个类似于以下的消息来告知页面不存在:意外应用程序错误!404 找不到

这意味着路由器确实在运行;它没有设置为处理用户导航到未定义路由的情况,并且不会显示页面内容。现在你将修复这两个问题。

创建布局和未找到页面

为了正常工作,路由器需要一个地方来显示页面内容——记住,“页面”只是 React 组件。现在你将创建 Layout.jsx 并处理用户访问不存在的 URI 导致的 页面未找到 错误的情况:

  1. 首先,在 /src/pages 目录下创建一个新页面,命名为 NotFound.jsx,内容如下:

    const NotFound = () => {
      return (
        <div>This page does not exist yet!</div>
      )
    }
    export default NotFound
    

    现在,创建一个通配符路由,当路径不匹配任何定义的路由时,将显示 Not Found 页面。记住路由的顺序很重要——React Router 将按顺序尝试匹配路由,因此使用 * 符号来捕获所有之前未定义的路由并将它们与 NotFound 组件关联是有意义的。

  2. 更新 App.jsx 文件,将 NotFound 路由作为 RootLayout 路由中的最后一个路由显示:

      createRoutesFromElements(
        <Route path=”/” element={<RootLayout />}>
          <Route index element={<Home />} />
    	// more routes here…
          <Route path=”*” element={<NotFound />} />
        </Route>
      )
    <Route path=”/” element={<RootLayout />}>
    

    所有其他页面都是嵌套的。你需要修改 RootLayout(即使对于非现有路由也会始终加载!)并为渲染特定页面组件提供 Outlet 组件。

  3. 打开 RootLayout.jsx 并进行修改:

    import { Outlet } from “react-router-dom”
    const RootLayout = () => {
        return (
            <div className=” bg-blue-200 min-h-screen p-2”>
                <h2>RootLayout</h2>
                <main className=”p-8 flex flex-col flex-1 bg-white “>
     <Outlet />
                </main>
            </div>
        )
    }
    export default RootLayout
    

    现在已经有了 Outlet 组件,你已经实现了路由。如果你尝试导航到路由器中定义的页面,你应该会看到页面更新,其中布局如之前所示,但 Outlet 组件会改变并显示 URL 中选择的页面内容。

    使用路由器的整个目的是通过“页面”进行导航,而无需重新加载页面。

  4. 现在,为了最终完成 RootLayout 组件,你将更新组件并添加一些链接,使用提供的 React Router 的 NavLink 组件:

    import {
      Outlet,
      NavLink
    } from “react-router-dom”
    const RootLayout = () => {
      return (
        <div className=” bg-blue-200 min-h-screen p-2”>
          <h2>RootLayout</h2>
          <header className=”p-8 w-full”>
            <nav className=”flex flex-row 
              justify-between”>
              <div className=”flex flex-row space-x-3”>
                <NavLink to=”/”>Home</NavLink>
                <NavLink to=”/cars”>Cars</NavLink>
                <NavLink to=”/login”>Login</NavLink>
                <NavLink to=”/new-car”>New Car</NavLink>
              </div>
            </nav>
          </header>
          <main className=”p-8 flex flex-col flex-1
            bg-white “>
            <Outlet />
         </main>
       </div>
      )
    }
    export default RootLayout
    

现在你已经实现了简单的导航,并且当需要时 NotFound 页面会加载。路由器还提供了导航历史,因此浏览器的后退和前进按钮是可用的。应用样式故意简约,仅用于强调不同的组件。

到目前为止,你只有一个布局,但可能还有更多——一个是汽车列表页面和单个汽车页面——嵌入到主布局中。就像 FastAPI 中的 APIRouters 一样,React 路由和布局可以嵌套。React Router 的嵌套是一个强大的功能,它能够构建只加载或更新必要组件的分层网站。

在设置好 React Router 之后,让我们探索一个仅在使用数据路由时才可用的重要功能,例如你使用的——数据加载器——允许开发者以更有效的方式访问数据的特殊函数。

React Router 加载器

加载器是简单的函数,可以在路由加载之前提供数据(reactrouter.com/en/main/route/loader)通过一个简单的 React 钩子。

为了使用一些数据,首先创建一个新的 .env 文件,并添加你 Python 后端的地址:

VITE_API_URL=http://127.0.0.1:8000

如果你现在重启服务器,Vite 将能够获取你代码中的地址,URI 将在 import.meta.env.VITE_API_URL 中可用。

注意

要了解更多关于 Vite 如何处理环境变量的信息,请查看他们的文档:vitejs.dev/guide/env-and-mode

现在,你将学习 React Router 如何管理数据加载和预取。执行以下步骤,将后端数据加载到 React 应用程序中,并学习如何使用强大且简单的 useLoader 钩子。

首先,处理 /src/pages/Cars.jsx 组件,看看数据加载器如何帮助你管理组件数据:

  1. 创建一个 src/components 文件夹,并在其中创建一个简单的静态 React 组件,名为 CarCard.jsx,用于显示单个汽车:

    const CarCard = ({ car }) => {
      return (
        <div className=”flex flex-col p-3 text-black 
          bg-white rounded-xl overflow-hidden shadow-md
          hover:scale-105 transition-transform
          duration-200”>
          <div>{car.brand} {car.make} {car.year} {car.cm3}
            {car.price} {car.km}
          </div>
          <img src={car.picture_url} alt={car.make}
            className=”w-full h-64 object-cover
            object-center” />
        </div>
      )
    }
    export default CarCard
    

    在处理完 Card 组件后,你现在可以查看数据加载器是如何工作的。

    加载器是函数,在组件渲染之前向路由器中的组件提供数据。这些函数通常由同一组件定义和导出,尽管这不是强制性的。

  2. 打开 Cars.jsx 并相应地更新它:

    import { useLoaderData } from “react-router-dom”
    import CarCard from “../components/CarCard”
    const Cars = () => {
      const cars = useLoaderData()
      return (
        <div>
          <h1>Available cars</h1>
          <div className=”md:grid md:grid-cols-3 sm:grid
            sm:grid-cols-2 gap-5”>
            {cars.map(car => (
              <CarCard key={car.id} car={car} />
            ))}
          </div>
        </div>
      )
    }
    export default Cars
    

    组件导入 useLoaderData——这是 React Router 提供的一个自定义钩子,其唯一目的是将加载函数的数据提供给需要它的组件。这种范式是 React Remix 的核心,类似于一些 Next.js 功能,因此了解它是有用的。useLoader 函数将包含来自服务器的数据,通常以 JSON 格式。

  3. 现在,在同一文件中也将 carsLoader 函数导出:

    export const carsLoader = async () => {
      const res = await fetch(
        `${import.meta.env.VITE_API_URL}/cars?limit=30`
        )
      const response = await res.json()
      if (!res.ok){
        throw new Error(response.message)
      }
      return response[‘cars’]
    }
    

注意

这两个部分——组件和函数——尚未连接。这种连接必须在路由器中发生,并允许在路由器级别预加载数据。

  1. 现在,你将通过路由器将组件和加载器连接起来。打开 App.jsx 文件,通过向 /cars 路由提供加载器参数来修改代码:

    import Cars, { carsLoader } from “./pages/Cars”
    // continues
      <Route path=”/” element={<RootLayout />}>
        <Route index element={<Home />} />
        <Route path=”cars” element={<Cars />}     
          loader={carsLoader} />
          <Route path=”login” element={<Login />} />
          <Route path=”new-car” element={<NewCar />} />
          <Route path=”cars/:id” 
            element={<SingleCar />} />
          <Route path=”*” element={<NotFound />} />
        </Route>
    

现在加载函数已经就位,你可以测试你的 /cars 页面了,它应该显示到目前为止保存的汽车集合。

接下来的几节将探讨实现你在每个 React(或 Next.js,或一般意义上的 web 开发)项目中都可能遇到的功能——使用 RHF 处理表单。你将借助处理 React 表单最流行的第三方包来实现登录功能,并使用 Zod 包进行数据验证。

React Hook Form 和 Zod

处理 React 表单有许多方法,其中最常见的一种模式在第 第五章 中展示,即 设置 React 工作流程。状态变量使用 useState 钩子创建,表单提交被阻止并拦截,最后数据通过 JSON 或表单数据传递。当处理简单数据和少量字段时,这种工作流程是可以接受的,但在需要跟踪数十个字段、它们的约束和可能状态的情况下,它很快就会变得难以管理。

RHF 是一个成熟的项目,拥有繁荣的社区,它与其他类似库的区别在于其速度、渲染量最小以及与 TypeScript 和 JavaScript 中最受欢迎的数据验证库(如 Zod 和 Yup)的深度集成。在这种情况下,你将学习 Zod 的基础知识。

使用 Zod 进行数据验证

目前,JavaScript 和 TypeScript 生态系统中有几个验证库——Zod 和 Yup 可能是最受欢迎的。Zod 是一个以 TypeScript 为首的架构声明和验证库,它提供了数据结构的验证。Zod 为 JavaScript 应用程序中的对象和值提供了简单直观的对象语法,以创建复杂的验证规则,并极大地简化了确保应用程序数据完整性的过程。

这些包的基本思想是提供所需数据结构的原型,并对数据与定义的数据结构进行验证:

  1. 首先,安装该包:

    npm i react-hook-form@7.51.5
    

    由于撰写本文时和书中仓库中使用的版本号是 7.51.5,如果你想重现仓库中的确切代码,请使用前面的命令。

  2. 更新 Login.jsx 组件,使其显示 LoginForm,你将在稍后创建它:

    import LoginForm from “../components/LoginForm”
    const Login = () => {
      return (
      <div>
        <h1>Login</h1>
        <LoginForm />
      </div>
      )
    }
    export default Login
    
  3. 现在,/src/components/LoginForm.jsx 文件将包含所有表单功能以及使用 Zod 的数据验证:

    import { useForm } from “react-hook-form”
    import { z } from ‘zod’;
    import { zodResolver } from ‘@hookform/resolvers/zod’;
    const schema = z.object({
      username: z.string().min(4, ‘Username must be at least 4 characters long’).max(10, ‘Username cannot exceed 10 characters’),
      password: z.string().min(4, ‘Password must be at least 4 characters long’).max(10, ‘Password cannot exceed 10 characters’),
    });
    

    组件开始于导入——useForm 钩子、Zod 以及与表单钩子集成的 Zod 解析器。在 Zod 中的数据验证类似于 Pydantic 中的方式——你定义一个对象,并在各个字段上设置所需的属性。在这种情况下,我们设置用户名和密码长度在 4 到 10 个字符之间,但 Zod 允许进行一些非常复杂的验证,正如你可以在他们的网站上看到的那样(zod.dev/)。

    useForm 钩子提供了几个有用的函数:

  4. 现在,设置 form 钩子:

    const LoginForm = () => {
      const { register, handleSubmit, 
        formState: { errors } } = useForm({
          resolver: zodResolver(schema),
        });
      const onSubmitForm = (data) => {
          console.log(data)
        }
    

    在这种情况下,你将只跟踪错误(与之前用 Zod 定义的验证相关),但这个对象跟踪的内容要多得多。在你的代码中,一旦验证通过,你只需将数据输出到控制台即可。

  5. 现在,构建表单的 JSX 并添加一些样式以查看发生了什么:

    return (
      <div className=”flex items-center justify-center”>
        <div className=”w-full max-w-xs”>
        <form className=”bg-white shadow-md rounded 
          px-8 pt-6 pb-8 mb-4”                      
          onSubmit={handleSubmit(onSubmit event is bound to the handle. This process is quite simple: the form has an onSubmit method that you handed over to the handleSubmit method of RHF. This handleSubmit method is destructured from the hook itself, along with the register function (for mapping input fields) and the errors that reside in the form state. After establishing the connection, the handleSubmit method needs to know which function should process the form and its data. In this case, it should pass the handling to the onSubmitForm function.The two form fields, for the username and the password, are nearly identical:
    
    

    <div className=”mb-4”>

    <label htmlFor=”username” className=”block

    `text-gray-700 text-sm font-bold mb-2”>
    
    用户名
    

    <label>

    <input id=”username” type=”text”

    `placeholder=”Username” required`
    

    {...register(‘username’)}

    `className=”shadow appearance-none border`
    
    `rounded w-full py-2 px-3 text-gray-700`
    
    `leading-tight focus:outline-none`
    
    `focus:shadow-outline”/>
    
    `{errors.username && <p className=”text-red-500
    

    `text-xs italic”>{errors.username.message}

    }

    
    

代码中突出显示的部分是使用 useForm 钩子注册字段——这是让表单知道预期哪些字段以及与各自字段相关的错误(如果有的话)的一种方式。

这样,字段通过这个扩展运算符语法注册到钩子表单中。由于表单提供的错误绑定到字段上,利用这个机会,将它们显示在报告错误的字段旁边,以提供更好的用户体验。

组件的其余部分直观易懂,涵盖了密码字段和提交按钮:

<div className=”mb-6”>
  <label htmlFor=”password” className=”block text-gray-700   
    text-sm font-bold mb-2”>Password</label>
  <input id=”password” type=”password” placeholder=”****”
    required
    {...register(‘password’)}
    className=”shadow appearance-none border rounded w-full
    py-2 px-3 text-gray-700 mb-3 leading-tight
    focus:outline-none focus:shadow-outline” />
  {errors.password && <p className=”text-red-500 text-xs
 italic”>{errors.password.message}</p>}
</div>
<div className=”flex items-center justify-between”>
          <button type=”submit”>Sign In</button>
        </div>
      </form>
    </div>
  </div>
  )
}
export default LoginForm

书中的完整代码可在书库中找到。

表单现在已准备就绪,并由带有 Zod 验证的钩子表单完全处理。如果您尝试输入不符合验证标准的数据(例如用户名或密码少于四个字符),您将在字段旁边收到错误消息。在设置登录表单后,您将创建一个认证上下文,允许用户保持登录状态。认证过程——创建 React 上下文和存储 JWT——将与第六章“认证和授权”中介绍的过程非常相似,因此下一节仅涵盖并突出代码中的重要部分。

认证上下文和存储 JWT

在本节中,您将使用由 RHF 提供动力的全新表单,并将其连接到上下文 API。定义 React Context API 的流程在第四章“FastAPI 入门”中进行了详细说明,本章中,您将应用这些知识并创建一个类似上下文来跟踪应用程序的认证状态:

  1. /src目录中创建一个新的文件夹,命名为contexts。在此文件夹内,创建一个名为AuthContext.jsx的新文件,并创建提供者:

    import { createContext, useState, useEffect } from ‘react’;
    import { Navigate } from ‘react-router-dom’;
    export const AuthContext = createContext();
    export const AuthProvider = ({ children }) => {
      const [user, setUser] = useState(null);
      const [jwt, setJwt] =  useState(localStorage.getItem('jwt')||null);
      const [message, setMessage] = useState(
        “Please log in”
      );
    

    您正在创建的上下文相当简单,包含一些状态变量和设置器,这些变量和设置器将用于认证流程:用户名(其存在或不存在将指示用户是否已认证)、JWT 以及一个辅助消息,在这种情况下,它仅用于调试和说明。

    初始值通过useState钩子设置为null和通用消息——用户名设置为null,JWT 设置为空字符串,消息设置为“请登录”。

  2. 接下来,添加一个useEffect钩子,它将在上下文加载或页面重新加载时触发:

    useEffect(() => {
      const storedJwt = localStorage
        .getItem(‘jwt’);
      if(storedJwt) {
        setJwt(storedJwt);
        fetch(
          `${import.meta.env.VITE_API_URL}/users/me`, {
          headers: {
          Authorization: `Bearer ${storedJwt}`,
          },
            })
        .then(res => res.json())
    

    useEffect钩子的第一部分检查本地存储中是否存在 JWT。如果存在,useEffect钩子将对 FastAPI 服务器执行 API 调用,以确定 JWT 是否能够返回有效的用户:

    .then(data => {
      if(data.username) {
        setUser({user: data.username});
        setMessage(`Welcome back, ${data.username}!`);
      } else {
    

    如果令牌无效或被篡改或已过期,useEffect钩子将其从本地存储中删除,将上下文状态变量设置为null,并设置适当的消息给用户:

    localStorage.removeItem(
      ‘jwt’);
    setJwt(null);
    setUser(null);
    setMessage(data.message)
    }
    })
    .catch(() => {
      localStorage
        .removeItem(
          ‘jwt’);
      setJwt(null);
      setUser(null);
      setMessage(
        ‘Please log in or register’
      );
    });
    }
    else {
      setJwt(null);
      setUser(null);
      setMessage(
        ‘Please log in or register’
      );
    }
    }, []); };
    

总结一下,useEffect钩子执行了一个周期。首先,它检查本地存储,如果没有找到 JWT,它将从上下文中删除 JWT,将用户名设置为null,并提示用户登录。如果使用现有 JWT 对/me路由的 API 调用没有返回有效的用户名,也会得到相同的结果。这意味着令牌存在,但无效或已过期。如果 JWT 确实存在并且可以用来获取有效的用户名,那么将设置用户名并将 JWT 存储在上下文中。由于依赖项数组为空,此钩子将在第一次渲染时只运行一次。

实现登录功能

为了简单起见,登录函数将再次位于上下文中,尽管它也可以在单独的文件中。以下为登录流程:

  1. 用户提供他们的用户名和密码。

  2. 执行了对后端的 fetch 调用。

  3. 如果响应具有 HTTP 状态200并且返回了 JWT,则localStorage和上下文都会被设置,用户被认证。

  4. 如果响应没有返回 HTTP 状态200,这意味着登录信息未被接受,在这种情况下,上下文中的 JWT 和用户名值都被设置为null,从而有效失效。

要实现登录功能,执行以下步骤:

  1. 首先,login函数需要调用带有提供的用户名和密码的登录 API 路由。将以下代码粘贴到AuthContext.jsx文件的末尾:

    const login = async (username,
      password) => {  const response = await fetch(`${import.meta.env.VITE_API_URL}/users/login`, {
          method: ‘POST’,
          headers: {
            ‘Content-Type’: ‘application/json’,
          },
          body: JSON.stringify({
            username,
            password
          }),
        });
    
  2. 接下来,根据响应,函数将相应地设置上下文中的状态变量:

      const data = await response
        .json();
      if(response.ok) {
        setJwt(data.token);
        localStorage.setItem(‘jwt’, data
          .token);
        setUser(data.username);
        setMessage(
          `Login successful: welcome  ${data.username}`
        );
      } else {
        setMessage(‘Login failed: ‘ +
          data.detail);
        setUser(null)
        setJwt(null);
        localStorage.removeItem(‘jwt’);
      }
      return data
    };
    

    逻辑与useEffect钩子中应用的方法类似——如果找到有效的用户,上下文状态变量(用户名和 JWT)将被设置;否则,它们将被设置为null

  3. 最后的部分只是logout函数和上下文提供者的返回。下面的logout函数是在AuthProvider内部定义的:

        const logout = () => {
          setUser(null);
          setJwt(null);
          localStorage.removeItem(‘jwt’);
          setMessage(‘Logout successful’);
        };
        return ( <
          AuthContext.Provider value = {
            {
              username,
              jwt,
              login,
              logout,
              message,
              setMessage
            }
          } > {
            children
          } <
          /AuthContext.Provider>
        );
    

到目前为止,你已经完成了相当多的事情:你设置了上下文,定义了登录和注销函数,并创建了上下文提供者。现在,为了方便使用上下文,你将创建一个简单的自定义 React 钩子,基于内置的useContext钩子。

创建用于访问上下文的自定义钩子

在设置好 Context API 之后,你现在可以继续创建一个位于新文件夹/src/hooks中的useAuth.jsx文件,这将允许从各个地方轻松访问上下文:

  1. 在新文件夹中创建useAuth.jsx文件:

    import {
      useContext
    } from “react”;
    import {
      AuthContext
    } from “../contexts/AuthContext”;
    export const useAuth = () => {
      const context = useContext(
        AuthContext)
      if (!context) {
        throw new Error(
          ‘Must be used within an AuthProvider’
          )
      }
      return context
    }
    

    如果钩子不在上下文中访问,useAuth钩子将包含一个错误消息——但你的上下文将包围整个应用程序。

    使用 React 上下文的最后一步是将需要访问它的组件包裹起来;在你的情况下,这将涉及App.jsx——根组件。

  2. 打开App.jsx文件,并将当前返回的唯一组件RouterProvider包裹在AuthProvider内部:

    import { AuthProvider } from “./contexts/AuthContext”
    // continues
    export default function App() {
      return (
        <AuthProvider>
          <RouterProvider router={router} />
        </AuthProvider>
      )
    }
    

    最后,在当前托管所有页面的RootLayout组件中显示上下文数据和状态变量。这是在使用 React Context API 时的一种有用的调试技术;你不需要频繁地在开发者工具之间切换。

  3. 打开RootLayout.jsx并编辑文件:

    import { Outlet, NavLink } from “react-router-dom”
    import { useAuth } from “../hooks/useAuth”
    const RootLayout = () => {
        logout function, you can now add a little bit of JSX conditional rendering and create a dynamic menu:
    
    

    const RootLayout = () => {

    const {

    user,
    
    消息,
    
    logout
    

    } = useAuth()

    return (

    <div className=” bg-blue-200 min-h-screen p-2”>
    
    <h2>根布局</h2>
    
    <p className=”text-red-500 p-2 border”>
    
        {message}
    
    </p>
    
    <p>用户名: {user}</p>
    
    <header className=”p-3 w-full”>
    
    <nav className=”flex flex-row justify-between
    
        mx-auto”>
    
    <div className=”flex flex-row space-x-3”>
    
        <NavLink to=”/”>主页</NavLink>
    
        <NavLink to=”/cars”>汽车</NavLink>
    
        {user ? <>
    
        <NavLink to=”/new-car”>新车</NavLink>
    
        <button onClick={logout}>登出</button>
    
        </> : <>
    
        <NavLink to=”/login”>登录</NavLink>
    
        </>}
    
    </div>
    
    </nav>
    
    bg-white “> <Outlet />
    </div>
    

    )

    }

    export default RootLayout

    
    

该应用程序相当简单,但很好地展示了登录/登出过程。作为一个练习,你可以轻松实现注册页面——API 端点已经存在,你应该创建处理注册表单的逻辑。

以下部分将专注于完成一些更多功能——插入新车的路由对于未登录的用户仍然可访问,而且表单还不存在。现在你将保护资源创建端点,并使用 React Router 创建受保护页面。

保护路由

受保护的路由是所有人无法访问的路由和页面——它们通常要求用户登录或拥有某些权限(管理员或创建者)。在 React Router 中有很多种保护路由的方法。一种流行的模式是通过高阶组件——它们是包裹需要登录用户的路由的包装组件。新的 React Router 及其Outlet组件允许你轻松实现门控逻辑,并在需要授权时重定向用户。

创建一个基本的组件,用于检查用户的存在(通过用户名)。如果用户存在,该组件将使用Outlet组件让被包裹的路由到达浏览器;否则,将重定向到登录页面:

  1. /src/components文件夹中创建一个新的组件,命名为AuthRequired.jsx

    import {
      Outlet,
      Navigate
    } from “react-router-dom”
    import {
      useAuth
    } from “../hooks/useAuth”
    const AuthRequired = () => {
      const {
        jwt
      } = useAuth()
      return (
        <div>
                <h1>AuthRequired</h1>
                {jwt ? <Outlet /> : <Navigate to=”/login” />}
            </div>
      )
    }
    export default AuthRequired
    

    逻辑很简单;该组件确保你执行 JWT 存在性检查。然后它像一个信号量或简单的 IF 结构,检查条件——如果 JWT 存在,Outlet组件将显示封装的组件(在我们的例子中只有一个:NewCar页面),如果不存在,则使用 React Router 的Navigate组件进行程序性导航到主页。

    这个简单的解决方案不会强制认证用户在重新加载受保护的页面时被重定向到主页,因为Layout.jsx中的useEffect钩子只有在组件加载后才会检测 JWT 是否无效。如果 JWT 确实无效,useEffect钩子将使 JWT 无效,从而触发重定向。

  2. 现在,更新App.jsx组件,导入AuthRequired组件,并将NewCar页面包围起来:

    import AuthRequired from “./components/AuthRequired”
    import { AuthProvider } from “./contexts/AuthContext”
    // code continues
    const router = createBrowserRouter(
      createRoutesFromElements(
        <Route path=”/” element={<RootLayout />}>
          <Route index element={<Home />} />
          <Route path=”cars” element={<Cars />} loader={carsLoader} />
          <Route path=”login” element={<Login />} />
     <Route element={<AuthRequired />}>
     <Route path=”new-car” element={<NewCar />} />
     </Route>
          <Route path=”cars/:id” element={<SingleCar />} />
    

你已经学会了如何保护需要认证的路由。现在,你将构建另一个表单来插入关于新车的数据,并通过 FastAPI 将图像(每辆车一张图像)上传到 Cloudinary。

创建插入新车的页面

插入新车到集合的页面——NewCar.jsx组件——是受保护的,并且只能由认证用户访问。在本节中,你将构建一个更复杂的表单,并逐步模块化代码:

  1. 首先,更新NewCar.jsx页面并添加一个CarForm组件,你很快就会构建它:

    import CarForm from “../components/CarForm”
    const NewCar = () => {
        return (
            <div>
     <CarForm />
            </div>
        )
    }
    export default NewCar
    
  2. 现在,在/src/components文件夹中创建这个组件。在这个文件夹中,创建一个新文件并命名为CarForm.jsx。在开始编写表单代码之前,快速回顾一下表单需要收集哪些类型的数据并将其发送到 API:

    • Brand: 字符串

    • Make: 字符串

    • Year: 整数

    • Price: 整数

    • Km: 整数

    • Cm3: 整数

    • Picture: 文件对象

    如果将表单中的每个字段都作为单独的输入创建,并且只是将所有内容复制粘贴到文件中,将会非常繁琐且重复。相反,你可以抽象输入字段并使其成为一个可重用的组件。这个组件将需要接受一些属性,例如名称和类型(数字或字符串),并且 RHF 可以将其注册并关联到该字段上的任何错误。因此,在开始表单之前,创建另一个将被多次重用的组件,这将显著简化创建和更新表单的过程——在实际场景中,汽车可能有数百个字段。

  3. /src/components文件夹中创建一个新文件并命名为InputField.jsx

    const InputField = ({ props }) => {
      const { name, type, error } = props;
      return (
        <div className=”mb-4”>
          <label
            className=”block text-gray-700 text-sm mb-2”
            htmlFor={name}
          >
            {name}
          </label>
          <input
            className=”shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline”
            id={name}
            name={name}
            type={type}
            placeholder={name}
            required
            autoComplete=”off”
            {...props}
          />
          {error && <p className=”text-red-500 text-xs italic”>{error.message}</p>}
        </div>
      );
    };
    export default InputField;
    

    字段组件简单而有用——它抽象了所有功能,甚至添加了一些样式。

  4. 现在,回到CarForm文件并开始导入:

    import { useForm } from “react-hook-form”
    import { z } from ‘zod’;
    import { zodResolver } from ‘@hookform/resolvers/zod’;
    import { useNavigate } from “react-router-dom”;
    import { useAuth } from “../hooks/useAuth”;
    import InputField from “./InputField”;
    
  5. 你将再次使用 Zod 进行数据验证,因此添加一个模式——它应该理想地与后端的 Pydantic 验证规则相匹配以保持一致性:

    const schema = z.object({
        brand: z.string().min(2, ‘Brand must contain at least two letters’).max(20, ‘Brand cannot exceed 20 characters’),
        make: z.string().min(1, ‘Car model must be at least 1 character long’).max(20, ‘Model cannot exceed 20 characters’),
        year: z.coerce.number().gte(1950).lte(2025),
        price: z.coerce.number().gte(100).lte(1000000),
        km: z.coerce.number().gte(0).lte(500000),
        cm3: z.coerce.number().gt(0).lte(5000),
        picture: z.any()
            .refine(file => file[0] && file[0].type.startsWith(‘image/’), { message: ‘File must be an image’ })
            .refine(file => file[0] && file[0].size <= 1024 * 1024, { message: ‘File size must be less than 1MB’ }),
    });
    

    Zod 模式语法相当直观,尽管可能有一些方面需要小心——数字需要被强制转换,因为 HTML 表单默认发送字符串,并且可以通过方便的函数验证文件。

  6. 现在,开始实际的表单组件:

    const CarForm = () => {
        const navigate = useNavigate();
        const { jwt } = useAuth();
        const { register, handleSubmit, 
        formState: { errors, isSubmitting } } = useForm({
            resolver: zodResolver(schema),
        });
    

    useNavigate钩子用于在提交完成后从页面导航离开,而useForm与用于登录用户的钩子类似。

  7. 创建一个简单的 JavaScript 数组,包含表单所需的字段数据:

        let formArray = [
            {
                name: “brand”,
                type: “text”,
                error: errors.brand
            },
            {
                name: “make”,
                type: “text”,
                error: errors.make
            },
            {
                name: “year”,
                type: “number”,
                error: errors.year
            },
            {
                name: “price”,
                type: “number”,
                error: errors.price
            },
            {
                name: “km”,
                type: “number”,
                error: errors.km
            },
            {
                name: “cm3”,
                type: “number”,
                error: errors.cm3
            },
            {
                name: “picture”,
                type: “file”,
                error: errors.picture
            }
        ]
    
  8. 使用这个数组,表单代码变得更加易于管理。看看onSubmit函数:

    const onSubmit = async (data) => {
      const formData = new FormData();
      formArray.forEach((field) => {
        if (field == “picture”) {
          formData.append(field, data[field][0]);
        } else {
          formData.append(field.name, data[field.name]);
        }
      });
    };
    

    突然,onSubmit函数变得更加简洁——它遍历数组并将字段添加到formData对象中。记住,file字段是特殊的——它是一个数组,你只想获取第一个元素,即图片。

  9. 为了完成onSubmit函数,你需要向 API 发送POST请求:

    const result = await fetch(`${import.meta.env.VITE_API_URL}/cars/`, {
      method: “POST”,
      body: formData,
      headers: {
        Authorization: `Bearer ${jwt}`,
      },
    });
    const json = await result.json();
    if (result.ok) {
      navigate(“/cars”);
    } else if (json.detail) {
      setMessage(JSON.stringify(json));
      navigate(“/”);
    }
    

    获取调用很简单。在你得到结果后,你可以应用自定义逻辑。在这种情况下,你将 JSON 化——将错误对象渲染为 JSON 字符串,并将消息设置为显示它,如果错误来自服务器。

  10. 最后,由于你的InputField组件和formArray,JSX 变得非常简单,同时你也使用了useForm钩子中的提交值:

    return (
      <div className=”flex items-center justify-center”>
        <div className=”w-full max-w-xs”>
          <form
            className=”bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4 “
            encType=”multipart/form-data”
            onSubmit={handleSubmit(onSubmit)}
          >
            <h2 className=”text-center text-2xl font-bold mb-6”>Insert new car</h2>
            {formArray.map((item, index) => (
              <InputField
                key={index}
                props={{
                  name: item.name,
                  type: item.type,
                  error: item.error,
                  ...register(item.name),
                }}
              />
            ))}
            <div className=”flex items-center justify-between”>
              <button
                className=”bg-gray-900 hover:bg-gray-700 text-white w-full font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline”
                type=”submit”
                disabled={isSubmitting}
              >
                {isSubmitting ? “Saving...” : “Save new car”}
              </button>
            </div>
          </form>
        </div>
      </div>
    );}
    export default CarForm
    

提交按钮现在被重用作为提交指示器——在提交时显示不同的消息,并且也被禁用以防止多次请求。

创建一个用于更新汽车的页面将与之前的端点非常相似——RHF 与可以从现有对象中填充的初始或默认数据配合得非常好,你还可以使用在线表单构建器:react-hook-form.com/form-builder。删除汽车也相对简单,因为请求只需要认证并包含汽车 ID。

你现在已经创建了一个汽车创作页面,它可以以多种方式扩展。你已经学会了如何模块化你的 React 代码,以及如何根据数据流向和从服务器提供有意义的信息和逻辑给你的应用程序。现在你将创建一个用于显示单个汽车的页面,并再次使用加载器。

显示单个汽车

现在你已经创建了用于显示多个项目(汽车)、认证和创建新项目的页面,创建一个单独的汽车页面,看看 React Router 是如何处理 URL 中的参数的:

  1. 编辑SingleCar.jsx文件,并引入useLoaderData钩子,它已经在汽车页面中用于预加载数据:

    import { useLoaderData } from “react-router-dom”;
    import CarCard from “../components/CarCards”;
    const SingleCar = () => {
        const car = useLoaderData()
        return (
            <CarCard car={car} />
        );
    };
    export default SingleCar
    

    为了节省空间,我们重用了CarCard函数来显示汽车的数据。然而,在现实场景中,这个页面可能包含一个图片库、更多的数据,也许还有一些评论或笔记等等。但在这里的目标只是展示创建加载器函数的另一种方式。

  2. 打开当前托管路由器的App.jsx文件,并更新cars/:id路由,记住冒号表示一个参数,在这种情况下,是 MongoDB 集合中汽车ObjectId组件的字符串版本:

    import fetchCarData from “./utils/fetchCarData”
    // continues
    const router = createBrowserRouter(
      createRoutesFromElements(
        <Route path=”/” element={<RootLayout />}>
          <Route index element={<Home />} />
          <Route path=”cars” element={<Cars />} loader={carsLoader} />
          <Route path=”login” element={<Login />} />
          <Route element={<AuthRequired />}>
            <Route path=”new-car” element={<NewCar />} />
          </Route>
          <Route
            path=”cars/:id”
            element={<SingleCar />}
     loader={async ({ params }) => {
     return fetchCarData(params.id);
     }}
     errorElement={<NotFound />} />
          <Route path=”*” element={<NotFound />} />
        </Route>
      )
    )
    

    路线上只有两个变化:一个是loader函数,它是作为异步函数的一部分提供的,该函数接收参数 ID,另一个是errorElement。如果loader函数在获取数据时遇到错误,将显示NotFound组件。在这里,你再次重用了一个现有元素,但它可以进行定制。

  3. 最后一部分是位于/src/utils文件夹中的fetchCarData.js文件:

    export default async function fetchCarData(id) {
        const res = await fetch(`${import.meta.env.VITE_API_URL}/cars/${id}`)
        const response = await res.json()
        if (!res.ok) {
            throw new Error(response.message)
        }
        return response
    }
    

async函数仅执行单个 API 调用以检索与单个实体相关的数据,如果发生错误,将触发errorElement

加载函数非常实用。通过预加载数据,它们使用户拥有更好的用户体验,应用程序感觉更快。

摘要

在本章中,你使用现代 Vite 设置创建了一个 React 应用程序,并实现了基本功能——创建新资源、列出和显示汽车。本章还对你关于基本 React 钩子,如useStateuseEffect,以及 Context API 进行了复习。你还学习了 React Router 的基本知识,包括其强大的加载函数。在本章中,你创建了两个表单,使用 RHF 并学习了如何管理 API 使用过程中涉及的各种步骤和状态。

下一章将探讨 Next.js 14 版本——这是最强大且功能丰富的基于 React.js 的全栈框架。

第九章:使用 FastAPI 和 Beanie 进行第三方服务集成

在学习了构成 FARM 堆栈的工具之后,你将在本章中看到它们在一个更复杂的设置中结合使用。你将基于你对 Pydantic 和 FastAPI 的知识,了解Beanie,这是最受欢迎的 MongoDB 对象-文档映射器ODM)之一,以及它如何使你的代码更高效并提升你的开发者体验。

最后,你将看到当需要扩展应用程序以包含外部第三方功能时,堆栈的灵活性如何有用。在本章中,你将添加一个完全基于 AI 的销售助手,该助手将利用 OpenAI 创建吸引人的汽车描述,然后你将使用Resend API 服务发送自动化的电子邮件。

这些功能在现代 Web 应用程序的要求中变得越来越重要,通过本章,你将看到正确的一组工具如何使应用程序开发更高效。

本章将指导你完成以下任务:

  • 安装和使用 Beanie – 一个 Python MongoDB ODM

  • 了解 Beanie 的基本功能(连接、CRUD 操作和聚合)

  • 使用 FastAPI 的后台任务处理长时间运行的过程,同时保持应用程序的响应性

  • 从应用程序中编程发送电子邮件

  • 集成 OpenAI 的 ChatGPT(或任何其他大型语言模型LLM))

技术要求

本章的技术要求与我们在 FastAPI 中创建后端章节中的要求相似,增加了用于电子邮件发送功能和对 AI 集成的几个库和服务:

  • Python 3.11.7 或更高版本

  • 配置了 Python 扩展的 Visual Studio Code(与第三章中相同)

  • MongoDB Atlas 上的账户

  • Render.com 上的账户(如果你希望部署 FastAPI 后端)

  • 一个具有 API 访问权限的 OpenAI 账户,或者如果你不想部署应用程序并产生费用,可以使用免费的、本地运行的 LLM,如 Llama 2 或 Llama 3

  • Netlify 账户(免费级别)

我们强烈建议从之前账户的免费(或最便宜的)级别开始,并确保你在这些环境中感到舒适。

在解决了技术要求之后,让我们讨论你将在本章中构建的项目。

项目概述

在你运营一个(小型)二手车销售代理机构的情境下,要求与前面章节中的要求有些相似。你将构建一个用于显示待售汽车信息和图片的 Web 应用的后端。与前面的章节不同,现在你将使用 ODM,并且将包括电子邮件发送和 OpenAI 集成,这些将由 FastAPI 的后台任务处理。

汽车数据模型将由 Pydantic 和 Beanie 处理。应用程序将需要认证用户,而你将使用iron-session

最后,你将集成一个 LLM API(在这种情况下,是 OpenAI),以帮助创建有用的汽车模型描述,列出新插入的汽车模型在营销页面上的优缺点,并在每次新汽车广告插入时向指定的收件人发送定制电子邮件。

注意

LLMs 是专门设计用于生成和理解人类语言的机器学习系统。在大型数据集上训练后,它们能够在文本摘要和生成、翻译和图像生成等任务上高效执行。在过去的几年中,LLMs 获得了流行和采用,并且随着时间的推移,它们的实施领域将只会增长。

在下一节中,你将学习如何使用 FastAPI 和 Beanie 创建后端,以及如何集成 OpenAI 和电子邮件发送功能。

使用 FastAPI 和 Beanie 构建后端

为了简化起见,并使应用程序尽可能具有说明性,本章中你将构建的 API 将与在 第七章使用 FastAPI 构建后端 中构建的 API 差别不大。这样,你将能够自然地掌握使用 Motor(或 PyMongo)直接和 Beanie ODM 的方法之间的主要差异。

对象关系映射器ORMs)和 ODMs 是工具,其主要目的是抽象底层数据库(无论是关系型数据库还是非关系型数据库),并简化开发过程。一些著名的 Python 示例包括 Django ORMSQLAlchemy——两个经过验证和实战检验的解决方案——以及由 FastAPI 的创建者创建的 SQLModel,它与 FastAPI/Pydantic 世界紧密集成。

在 Python 和 MongoDB 社区中越来越受欢迎的两个现代 ODM 是 Beanie (beanie-odm.dev/) 和 Odmantic (art049.github.io/odmantic/)。在这个项目中,你将使用这两个中更成熟、更老的那个——Beanie ODM。

Beanie ODM 简介

Beanie 是 Python 最受欢迎的 MongoDB ODM 之一。ODM 是一种编程技术,允许开发人员直接与表示 NoSQL 文档的类(在我们的例子中是 Python 类)一起工作。使用 Beanie 时,每个 MongoDB 集合都映射到一个相应的文档类,这使得你可以检索或聚合数据,并执行 CRUD 操作,通过消除样板代码的必要性来节省时间。

Beanie 还优雅地处理 MongoDB 的 ObjectId 类型,并且由于其文档类基于 Pydantic,你可以直接使用 Pydantic 的所有强大验证和解析功能。

简而言之,Beanie 的显著特性包括以下内容:

  • 异步的,基于 Motor 驱动器,非常适合性能良好的 FastAPI 应用程序

  • 基于 Pydantic 并兼容 Pydantic 版本 2

  • 基于模式,无缝处理 ObjectId 字符串转换

  • 简单的 CRUD 操作,以及支持 MongoDB 强大的聚合框架

在下一节中,你将通过创建一个 Beanie 驱动的应用程序来开始学习 ODM 的某些功能。

创建 Beanie 应用程序

你将通过创建一个新应用程序并探索 ODM 提供的功能来学习如何使用 Beanie——连接到数据库、将集合映射到文档类,以及在文档上执行 CRUD 操作。

要开始项目并搭建 FastAPI 应用程序,请执行以下步骤:

  1. 创建一个新文件夹(chapter9)和一个虚拟环境,使用以下命令:

    python -m venv venv
    
  2. 使用以下命令激活虚拟环境(适用于 Linux 或 Mac):

    source venv/bin/activate
    

    或者,对于 Windows 系统,使用以下命令:

    venv\Scripts\activate.bat
    
  3. 激活它,并创建一个包含以下包的初始 requirements.txt 文件:

    fastapi==0.111.0
    fastapi_cors==0.0.6
    beanie==1.26.00
    bcrypt==4.0.1
    cloudinary==1.40.0
    uvicorn==0.30.1
    pydantic-settings
    PyJWT==2.8.0
    python-multipart==0.0.9
    openai==1.33.0
    resend==2.0.0
    
  4. 通过运行以下命令安装所需的包:

    pip install –r requirements.txt
    

    如果你仔细查看 requirements.txt 文件,你会注意到你正在安装一个新的包——fastapi-cors——它有助于管理 .env 文件,然后创建一个包含以下内容的 .gitignore 文件:

    .env
    .venv
    env/
    venv/
    

在准备基本包和设置之后,你现在将使用 Beanie 创建模型。

使用 Beanie 定义模型

在搭建主要 FastAPI 应用程序之前,你将学习 Beanie 如何处理数据模型。如前所述,Beanie 的 Document 类代表最终将保存到 MongoDB 数据库中的文档,这些模型继承自 Beanie 的 Document 类,而 Document 类本身是基于 Pydantic 的 BaseModel 类。正如 Beanie 网站所述:“Beanie 中的 Document 类负责映射和处理集合中的数据。它继承自 Pydantic 的 BaseModel 类,因此遵循相同的数据类型和解析行为。” (beanie-odm.dev/tutorial/defining-a-document/)

让我们开始创建模型,同时记住该文件还将包含几个纯 Pydantic 模型,用于输入和输出的验证(并非所有模型都是基于 Beanie 的,只有映射集合中文档的模型):

  1. 在目录根目录下创建一个名为 models.py 的文件,并导入必要的模块:

    from datetime import datetime
    from typing import List, Optional
    from beanie import Document, Link, PydanticObjectId
    from pydantic import BaseModel, Field
    

    这段代码中唯一的新导入来自 Beanie:你正在导入 Document 类——Beanie 用于处理数据的工具类,以及 Link(用于引用数据,因为你不会在汽车文档中嵌入用户数据,而是引用用户)和 PydanticObjectId——一个表示与 Pydantic 兼容的 ObjectId 字段类型。

  2. 继续在 models.py 文件上工作并创建基本用户模型:

    class User(Document):
        username: str = Field(min_length=3, max_length=50)
        password: str
        email: str
        created: datetime = Field(default_factory=datetime.now)
        class Settings:
            name = "user"
        class Config:
            json_schema_extra = {
                "example": {
                    "username": "John",
                    "password": "password",
                    "email": "john@mail.com",
                }
            }
    

    User模型继承自 Beanie 的Document类而不是 Pydantic 的BaseModel类,但其余部分大致相同。实际上,Document类基于BaseModel类并继承其功能——你能够使用具有默认工厂的 Pydantic 字段来创建datetime类型。

    然后,你使用了Settings类来指定将在 MongoDB 中使用的集合名称。这个类非常强大,允许在保存时设置缓存、索引、验证以及更多功能,如你可以在文档页面看到:beanie-odm.dev/tutorial/defining-a-document/#settings

  3. 继续使用相同的models.py文件,你现在将提供一些用于特定目的的 Pydantic 模型:注册新用户、用户登录以及提供当前用户的信息:

    class RegisterUser(BaseModel):
        username: str
        password: str
        email: str
    class LoginUser(BaseModel):
        username: str
        password: str
    class CurrentUser(BaseModel):
        username: str
        email: str
        id: PydanticObjectId
    
  4. 之前的代码应该感觉熟悉,因为它完全基于 Pydantic,所以定义汽车的文档模型:

    class Car(Document):
        brand: str
        make: str
        year: int
        cm3: int
        price: float
        description: Optional[str] = None
        picture_url: Optional[str] = None
        pros: List[str] = []
        cons: List[str] = []
        date: datetime = datetime.now()
        user: Link[User] = None
        class Settings:
            name = "car"
    

    Beanie 文档模型包含你在整本书中使用的所有字段,以及一些新的字段:两个字符串列表,将包含每个汽车模型的优点和缺点的小文本片段——类似于*c**ompact 和易于停放。此外,汽车描述有意留空——这些字段将在稍后的后台任务中,通过 OpenAI 聊天完成提示来填充。

    这个模型的有趣之处在于user部分:Link字段类型提供了一个直接链接到用户。你可以查看文档以了解 Beanie 关系可以实现什么以及当前的限制是什么:beanie-odm.dev/tutorial/relations/

    Beanie 通过相应字段中的链接来管理关系,在撰写本文时,仅支持顶级字段。相关文档的链接可以是链接、可选链接以及链接列表,以及反向链接。

    反向链接是反向关系:如果一个名为House的对象有一个指向所有者——例如一个Person对象——的链接,那么该Person对象可以通过反向链接拥有所有房屋。

  5. 最后,添加一个用于更新汽车的UpdateCar Pydantic 模型:

    class UpdateCar(BaseModel):
        price: Optional[float] = None
        description: Optional[str] = None
        pros: Optional[List[str]] = None
        cons: Optional[List[str]] = None
    

注意,你几乎在字段上没有定义任何验证——这样做只是为了节省空间并简化模型。由于 Beanie 基于 Pydantic,它可以依赖 Pydantic 的全部功能,从而实现复杂而强大的验证。

现在已经定义了模型,你可以继续连接到 MongoDB 数据库。提前定义模型很重要,因为它们的名称将被输入到 Beanie 初始化代码中,你将在下一节中看到。

连接到 MongoDB 数据库

Beanie ODM 使用pydantic-settings及其BasicSettings类,以便在应用程序内部轻松访问环境变量。

该过程与在第七章中使用的类似,即使用 FastAPI 构建后端

  • 环境变量存储在.env文件中。

  • pydantic-settings用于读取环境变量并创建一个设置对象(通过config.py文件)。

  • 这些设置,连同模型一起,用于初始化到 Atlas 的数据库连接。

要创建数据库连接并使用模型,请执行以下步骤:

  1. 使用pydantic-settings定义配置和环境变量。由于你需要在初始化数据库连接之前获取设置,并且它们是从环境中读取的,因此请填充将包含环境变量的.env文件,然后通过config.py文件读取并将它们实例化为设置对象。

    .env文件应包含以下条目:

    DB_URL=mongodb://localhost:27017/ or the Atlas address
    CLOUDINARY_SECRET_KEY=<cloudinary.secret.key>
    CLOUDINARY_API_KEY=<cloudinary.api.key>
    CLOUDINARY_CLOUD_NAME=<cloudinary.cloud.name>
    OPENAI_API_KEY=<openai.api.key>
    RESEND_API_KEY=<resend.api.key>
    

    你将在稍后设置 OpenAI 和 Resend API 密钥,但现在,你可以插入 MongoDB Atlas 和config.py的其他值。打开config.py文件,创建BaseConfig类以读取环境值并轻松覆盖这些值,基于所需的配置:

    from typing import Optional
    from pydantic_settings import BaseSettings, SettingsConfigDict
    class BaseConfig(BaseSettings):
        DB_URL: Optional[str]
        CLOUDINARY_SECRET_KEY: Optional[str]
        CLOUDINARY_API_KEY: Optional[str]
        CLOUDINARY_CLOUD_NAME: Optional[str]
        OPENAI_API_KEY: Optional[str]
        RESEND_API_KEY: Optional[str]
        model_config = SettingsConfigDict(
            env_file=".env", extra="ignore"
        )
    
  2. 与使用 Beanie 连接 MongoDB 数据库相比,与基于 Motor 的普通连接的差异在database.py文件中变得明显,你将在同一根目录中创建此文件,并用以下代码填充:

    import motor.motor_asyncio
    from beanie import init_beanie
    from config import BaseConfig
    from models import Car, User
    settings = BaseConfig()
    async def init_db():
        client = motor.motor_asyncio.AsyncIOMotorClient(
            settings.DB_URL
        )
        await init_beanie(database=client.carAds,
            document_models=[User, Car]
        )
    

初始化代码被突出显示:异步init_beanie函数需要 Motor 客户端和文档模型。

在定义了模型并建立了数据库连接后,你现在将开始构建 FastAPI 应用程序和路由器。

创建 FastAPI 应用程序

所有必要的组件都已就绪,现在你已经准备好了连接到 MongoDB 数据库的连接,可以开始构建应用程序。使用新创建的database.py文件连接到你的 MongoDB 实例,并将其包装在生命周期上下文管理器中,以确保应用程序启动时连接,并在关闭时删除连接。

要创建主 FastAPI 应用程序文件(app.py),请执行以下步骤:

  1. 在根目录中创建app.py文件,它将非常类似于在第七章中创建的,即使用 FastAPI 构建后端

    from contextlib import asynccontextmanager
    from fastapi import FastAPI
    from fastapi_cors import CORS
    from database import init_db
    @asynccontextmanager
    async def lifespan(app: FastAPI):
        await init_db()
        yield
    app = FastAPI(lifespan=lifespan)
    init_db function, you imported the fastapi_cors package, which allows easier management of CORS.All you need to do now is add one line to the `.env` file to specify the allowed origins: `ALLOW_ORIGINS=*`.You can explore the documentation of this simple package here: [`pypi.org/project/fastapi-cors/`](https://pypi.org/project/fastapi-cors/).
    
  2. 连接初始化代码嵌套在一个生命周期事件中,就像之前使用 Motor 的解决方案一样,而其余的代码只是包含你即将创建的路由和一个根端点:

    @app.get("/", tags=["Root"])
    async def read_root() -> dict:
        return {"message": "Welcome to your beanie powered app!"}
    
  3. 如果你已安装了 FastAPI 的较新版本(0.111 或更高版本),该版本会安装fastapi-cli包,你现在可以使用以下命令启动开发 FastAPI 服务器:

    fastapi dev
    

    或者,你可以使用以下标准代码行:

    uvicorn app:app --reload
    

之前的代码使用了新的 fastapi-cli 包以简化开发(fastapi.tiangolo.com/fastapi-cli/)。fastapi-cors 将提供一个名为“健康检查”的新端点。如果你尝试使用它,你会看到与 CORS 相关的环境变量(ALLOWED_CREDENTIALSALLOWED_METHODSALLOWED_ORIGINS 等),并且现在可以通过 .env 文件进行设置。

FastAPI 主应用程序现在已准备就绪,但它需要两个路由器:一个用于用户和一个用于汽车,以及认证逻辑。首先,你将处理认证类以及 users 路由器。

创建用户和认证类的 APIRouter 类

认证类将封装认证逻辑,类似于第六章 认证和授权中所示,并创建管理用户的配套 APIRouter——注册、登录和验证。

为了简化,authentication.py 文件将与之前使用的文件相同。位于项目根目录的 authentication.py 文件包含 JWT 编码和解码逻辑、密码加密和依赖注入,如第七章 使用 FastAPI 构建后端所示。

我们在此提供文件内容,以方便您使用:

import datetime
import jwt
from fastapi import HTTPException, Security
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from passlib.context import CryptContext
class AuthHandler:
    security = HTTPBearer()
    pwd_context = CryptContext(
        schemes=["bcrypt"], deprecated="auto"
        )
    secret = "FARMSTACKsecretString"
    def get_password_hash(self, password):
        return self.pwd_context.hash(password)
    def verify_password(
        self, plain_password, hashed_password
    ):
        return self.pwd_context.verify(
            plain_password, hashed_password
        )
    def encode_token(self, user_id, username):
        payload = {
            "exp": datetime.datetime.now(datetime.timezone.utc)
            + datetime.timedelta(minutes=30),
            "iat": datetime.datetime.now(datetime.timezone.utc),
            "sub": {"user_id": user_id, "username": username},
        }
        return jwt.encode(payload, self.secret, algorithm="HS256")
    def decode_token(self, token):
        try:
            payload = jwt.decode(token, self.secret, algorithms=["HS256"])
            return payload["sub"]
        except jwt.ExpiredSignatureError:
            raise HTTPException(
              status_code=401,
              detail="Signature has expired"
            )
        except jwt.InvalidTokenError:
            raise HTTPException(status_code=401, detail="Invalid token")
    def auth_wrapper(self, auth: HTTPAuthorizationCredentials = Security(security)):
        return self.decode_token(auth.credentials)

user.py 路由器将被放置在 /routers 文件夹中,并且它将公开三个端点:用于注册新用户、用于登录用户和用于验证用户——在头部提供 Bearer 令牌。最后一个路由是可选的,因为在下一章(关于 Next.js)中你不会直接使用它,因为我们选择了一个简单的基于 cookie 的解决方案。

要创建用户的 API 路由器,请执行以下步骤:

  1. 创建一个 routers/user.py 文件并填充它以创建用户的路由器。这个路由器与 Motor 版本相似,并且它共享相同的逻辑,但在以下代码中突出了某些差异:

    from fastapi import APIRouter, Body, Depends, HTTPException
    from fastapi.responses import JSONResponse
    from authentication import AuthHandler
    from models import CurrentUser, LoginUser, RegisterUser, User
    auth_handler = AuthHandler()
    router = APIRouter()
    @router.post(
        "/register",
        response_description="Register user",
        response_model=CurrentUser
    )
    async def register(
        newUser: RegisterUser = Body(...),
        response_model=User):
        newUser.password = auth_handler.get_password_hash(
            newUser.password)
        query = {
    "$or": [{"username": newUser.username},
        	{"email": newUser.email}]}
        existing_user = await User.find_one(query)
        if existing_user is not None:
            raise HTTPException(
                status_code=409,
                detail=f"{newUser.username} or {newUser.email}
                already exists"
            )
        user = await User(**newUser.model_dump()).save()
        return user
    

    路由器展示了 Beanie 的一些功能:使用 MongoDB 查询直接查询 User 模型(users 集合),如果现有用户的检查通过,则简单异步创建一个新实例。在这种情况下,你有两个条件:用户名和电子邮件必须是可用的(不在集合中)。Beanie 的查询语法非常直观:beanie-odm.dev/tutorial/finding-documents/

  2. user.py 文件中创建登录路由:

    @router.post("/login", response_description="Login user and return token")
    async def login(loginUser: LoginUser = Body(...)) -> str:
        user = await User.find_one(
            User.username == loginUser.username
        )
        if user and auth_handler.verify_password(
            loginUser.password, user.password):
            token = auth_handler.encode_token(
              str(user.id),
              user.username
              )
            response = JSONResponse(
                content={
                    "token": token,
                    "username": user.username})
            return response
        else:
            raise HTTPException(
                status_code=401,
                detail="Invalid username or password")
    

    登录功能使用 Beanie 中可用的 find_one MongoDB 方法。

  3. 最后,添加 /me 路由,用于验证已登录用户。此方法使用 get 方法,它接受一个 ObjectId

    @router.get(
        "/me", response_description="Logged in user data", response_model=CurrentUser
    )
    async def me(
        user_data=Depends(auth_handler.auth_wrapper)
    ):
        currentUser = await User.get(user_data["user_id"])
        return currentUser
    

这完成了 users.py APIRouter,它使用了几个 Beanie 查询方法。现在,你将使用 Beanie ODM 创建 Car 路由器。

Car APIRouter

与前几章所完成的内容类似,Cars路由器将负责执行一些 CRUD 操作。为了简单起见,您将只实现汽车实例的部分更新:您将能够更新在UpdateCar模型中定义的字段。由于描述和优缺点列表最初为空,它们需要能够在以后更新(通过调用 OpenAI 的 API)。

要创建Cars路由器,在/routers文件夹和cars.py文件中,执行以下步骤:

  1. 首先,创建一个/routers/cars.py文件并列出初始导入(在开始实现后台任务时将添加更多):

    from typing import List
    import cloudinary
    from beanie import PydanticObjectId, WriteRules
    from cloudinary import uploader  # noqa: F401
    from fastapi import (APIRouter, Depends, File, Form,
        HTTPException, UploadFile, status)
    from authentication import AuthHandler
    from config import BaseConfig
    from models import Car, UpdateCar, User
    

    这些导入与直接使用 Motor 时使用的导入类似;主要区别是 Beanie 的导入:PydanticObjectId(用于处理 Pydantic 的 ObjectIds)和WriteRules,这将使CarUser的关系能够作为引用写入 MongoDB 数据库。

  2. 继续处理文件,现在您可以实例化认证处理器(auth_handler)类、设置和路由器,以及 Cloudinary 配置:

    auth_handler = AuthHandler()
    settings = BaseConfig()
    cloudinary.config(
        cloud_name=settings.CLOUDINARY_CLOUD_NAME,
        api_key=settings.CLOUDINARY_API_KEY,
        api_secret=settings.CLOUDINARY_SECRET_KEY,
    )
    router = APIRouter()
    
  3. 在完成必要的设置和认证后,您可以创建第一条路由——GET处理器,在这种情况下,它只是简单地检索数据库中的所有汽车:

    @router.get("/", response_model=List[Car])
    async def get_cars():
        return await find_all() Beanie method is asynchronous, like all Beanie methods, and it simply returns all the documents in the database. Other querying methods are .find(search query) and .first_or_none(), which are often used to check for the existence of a certain condition (such as a user with a given username or email). Finally, the to_list() method, like with Motor, returns a list of documents, but you could also use the async for construct (shown in *Chapter 4*, *Getting Started with FastAPI*) and generate a list that way.
    
  4. 创建用于通过其 ID 获取单个汽车实例的GET方法:

    @router.get("/{car_id}", response_model=Car)
    async def get_car(car_id: PydanticObjectId):
        car = await Car.get(car_id)
        if not car:
            raise HTTPException(status_code=404, detail="Car not found")
        return car
    

    此实现也很简单——它使用get()快捷方式通过ObjectId查询集合,这由 Beanie 优雅地处理。

  5. 创建新汽车实例的方法稍微复杂一些,但并不太重。由于您正在上传图像(一个文件),您使用表单数据而不是 JSON,并且端点必须将图像上传到 Cloudinary,从 Cloudinary 获取图像 URL,然后才能将图像与其他数据一起插入 MongoDB 数据库:

    @router.post(
        "/",
        response_description="Add new car with picture",
        response_model=Car,
        status_code=status.HTTP_201_CREATED,
    )
    async def add_car_with_picture(
        brand: str = Form("brand"),
        make: str = Form("make"),
        year: int = Form("year"),
        cm3: int = Form("cm3"),
        km: int = Form("km"),
        price: int = Form("price"),
        picture: UploadFile = File("picture"),
        user_data=Depends(auth_handler.auth_wrapper),
    ):
        cloudinary_image = cloudinary.uploader.upload(
          picture.file,
          folder="FARM2",
          crop="fill",
          width=800,
          gravity="auto" )
        picture_url = cloudinary_image["url"]
        user = await User.get(user_data["user_id"])
        car = Car(
            brand=brand,
            make=make,
            year=year,
            cm3=cm3,
            km=km,
            price=price,
            picture_url=picture_url,
            user=user,
        )
        return await car.insert(link_rule=WriteRules.WRITE)
    

    创建新资源的路由使用 Beanie 方法通过 ID(在请求头中的Bearer令牌中提供)获取用户,并使用insert()方法插入新汽车。

    最后,link_rule允许您保存销售人员的 ID(beanie-odm.dev/tutorial/relations/)。

  6. update方法与 Motor 的对应方法类似,可以轻松地集成到仪表板中,用于更新或删除汽车型号广告:

    @router.put("/{car_id}", response_model=Car)
    async def update_car(
        car_id: PydanticObjectId,
        cardata: UpdateCar):
        car = await Car.get(car_id)
        if not car:
            raise HTTPException(
                status_code=404,
                detail="Car not found")
        updated_car = {
            k: v for k, v in cardata.model_dump().items()   if v is not None}
        return await car.set(updated_car)
    

    再次强调,您只更新请求中提供的字段,使用 Pydantic 的model_dump方法来验证哪些字段实际上被提供,其他字段(在 Python 术语中为nullNone)保持不变。

  7. delete方法中,您只需要提供所选文档并调用delete()方法:

    @router.delete("/{car_id}")
    async def delete_car(car_id: PydanticObjectId):
        car = await Car.get(car_id)
        if not car:
            raise HTTPException(status_code=404, detail="Car not found")
        await car.delete()
    

您现在已经完成了您的 API 路由器,并准备好实现一些更高级的功能,FastAPI 和 FARM 栈通常使这项任务变得快速且有趣。然而,在使用路由器之前,您需要将它们导入到 app.py 文件中。打开 app.py 文件并修改顶部的导入,添加路由器并将它们别名为 cars 和 users:

from contextlib import asynccontextmanager
from fastapi import FastAPI
from database import init_db
from routers import cars as cars_router
from routers import user as user_router
from fastapi_cors import CORS

最后,通过修改相同的 app.py 文件将这些功能集成到应用程序中:

@asynccontextmanager
async def lifespan(app: FastAPI):
    await init_db()
    yield
app = FastAPI(lifespan=lifespan)
CORS(app)
app.include_router(
    cars_router.router,
    prefix="/cars",
    tags=["Cars"]
)
app.include_router(
    user_router.router,
    prefix="/users",
    tags=["Users"]
)
@app.get("/", tags=["Root"])
async def read_root() -> dict:
    return {"message": "Welcome to your beanie powered app!"}

连接好路由器后,您将集成一个简单但实用的 AI 助手,该助手将提供有关新插入的汽车的市场信息,并自动向销售人员、客户列表或订阅者群体发送电子邮件。

FastAPI 的后台任务

FastAPI 最有趣的功能之一是它如何处理后台任务——这些任务应该在向客户端发送响应之后异步运行。

后台任务有许多用例。任何可能需要一些时间的操作,例如等待外部 API 调用返回响应、发送电子邮件或基于端点的数据处理创建复杂文档,都是后台任务的潜在候选者。在这些所有情况下,仅仅让应用程序挂起等待结果是不良的做法,会导致糟糕的用户体验。相反,这些任务被交给后台处理,而响应则立即返回。

虽然对于简单任务非常有用,但不应将后台任务用于需要大量处理能力或/和多任务处理的进程。在这种情况下,一个更健壮的工具,如 Celery (docs.celeryq.dev/) 可能是最佳解决方案。Celery 是一个 Python 任务队列框架,可以在线程或不同的机器之间分配工作。

FastAPI 定义了一个名为 BackgroundTasks 的类,它继承自 Starlette 网络框架,它简单直观,您将在以下部分使用它将外部服务连接到您的 FastAPI 应用程序时看到。

在使用后台任务与第三方服务接口之前,创建一个非常简单的任务以供演示:

  1. 在项目的根目录下创建一个名为 background.py 的文件,并填充以下代码:

    from time import sleep
    def delayed_task(username: str) -> None:
        sleep(5)
        print(
            f"User just logged in: {username}"
        )
    

    此函数非常简单——它将在控制台上打印一条消息,等待五秒钟。

    将任务集成到端点的语法将在以下 API 路由器中展示。

  2. 打开 /routers/user.py 文件,因为您将把这个简单的后台任务附加到 login 函数上。

    此函数还可以执行一些日志记录或一些更复杂且耗时的操作,这些操作会阻塞响应直到完成,但在此情况下,将使用一个简单的 print 语句。

  3. 在文件顶部导入后台任务,并仅以以下方式修改 login 端点:

    from fastapi import APIRouter, BackgroundTasks, Body, Depends, HTTPException
    from background import delayed_task
    # code continues …
    @router.post("/login", response_description="Login user and return token")
    async def login(
        background_tasks: BackgroundTasks,
        loginUser: LoginUser = Body(...)
    ) -> str:
        user = await User.find_one(
            User.username == loginUser.username
        )
        if user and auth_handler.verify_password(
            loginUser.password, user.password
        ):
            token = auth_handler.encode_token(
                str(user.id), user.username
            )
            background_tasks.add_task(
                delayed_task,
                username=user.username
            )
            response = JSONResponse(
                content={
                    "token": token,
                    "username": user.username
                    }
                )
            return response
        else:
            raise HTTPException(
                    status_code=401,
                    detail="Invalid username or password"
        )
    fastapi dev
    

    你可以导航到交互式文档的地址(127.0.0.1:8000/docs)并尝试登录。

  4. 如果你已经安装了 HTTPie,你可以让一个终端以开发模式运行 FastAPI 应用,打开另一个终端,并发出登录 POST 请求,确保使用你之前创建的用户正确的用户名和密码。例如,以下命令测试了用户 tanja 的登录:

    http POST 127.0.0.1:8000/users/login username=tanja password=tanja123
    

    如果你查看第一个终端,五秒后你会看到以下信息:

    User just logged in: tanja
    

你刚刚创建了一个简单但可能很有用的后台任务,并学习了语法。

在下一节中,你将创建两个后台任务,使用 OpenAI 的 API 创建新的汽车描述,并将描述和汽车数据通过电子邮件发送给已登录的用户——即插入汽车的用户。

将 OpenAI 集成到 FastAPI 中

在过去几年中,LLM(大型语言模型)一直是热门词汇,它们主导着网络开发的讨论,而且越来越难以找到不使用某种形式的 LLM 集成的成功应用。现代应用利用图像、文本和音频处理,这可能会给你的下一个网络应用带来优势。

在你的汽车销售和广告应用中,你将使用 OpenAI 这样的巨无霸的一个最简单功能——当前的任务是让销售人员的工作变得更容易一些,并为每辆即将上市的新车提供一条基准营销线:

  1. 在获取了 OpenAI 密钥并设置环境变量后,修改 background.py 文件:

    import json
    from openai import OpenAI
    from config import BaseConfig
    from models import Car
    settings = BaseConfig()
    json for decoding the OpenAI response, the openai module, as well as the config module for reading the API keys. After instantiating the settings and the OpenAI client, you will create a helper function that will generate the prompt for OpenAI.Although these tasks are handled much more elegantly with a library called LangChain—the de facto standard when working with LLMs in Python—for simplicity’s sake, you will use a simple Python `f-string` to regenerate the prompt on each request.Remember, the prompt needs to provide a text description and two arrays—one for the positive aspects and one for the negative aspects of the car.
    

注意

你可以轻松地将 OpenAI 替换为另一个 LLM,例如 Google Gemini

  1. 以下是一种创建用于生成汽车数据的提示的方法,但根据你的情况,你可能希望更加有创意或保守地使用 OpenAI 提供的描述:

    def generate_prompt(brand: str, model: str, year: int) -> str:
        return f"""
        You are a helpful car sales assistant. Describe the {brand} {model} from {year} in a playful manner.
        Also, provide five pros and five cons of the model, but formulate the cons in a not overly negative way.
        You will respond with a JSON format consisting of the following:
        a brief description of the {brand} {model}, playful and positive, but not over the top.
        This will be called *description*. Make it at least 350 characters.
        an array of 5 brief *pros* of the car model, short and concise, maximum 12 words, slightly positive and playful
        an array of 5 brief *cons* drawbacks of the car model, short and concise, maximum 12 words, not too negative, but in a slightly negative tone
        make the *pros* sound very positive and the *cons* sound negative, but not too much
        """
    
  2. 现在提示已经准备好生成,是时候调用 OpenAI API 了。请始终参考最新的 OpenAI API 文档(platform.openai.com/docs/overview),因为它经常发生变化。目前,在撰写本文时,以下代码演示了与 API 通信的方式,你应该将其粘贴到你的 background.py 文件中:

    async def create_description(
        brand,
        make,
        year,
        picture_url):
        prompt = generate_prompt(brand, make, year)
        try:
            response = client.chat.completions.create(
                model="gpt-4",
                messages=[{"role": "user", "content": prompt}],
                max_tokens=500,
                temperature=0.2,
            )
            content = response.choices[0].message.content
            car_info = json.loads(content)
            await Car.find(
                Car.brand == brand,
                Car.make == make,
                Car.year == year
            ).set(
                {
                    "description": car_info["description"],
                    "pros": car_info["pros"],
                    "cons": car_info["cons"],
                }
            )
        except Exception as e:
            print(e)
    

    上述代码通过聊天完成方法调用 OpenAI 客户端。你已经选择了一个模型(gpt-4),启动了 messages 数组,并设置了 max_tokenstemperature。再次提醒,对于所有参数设置,请参考最新的 OpenAI 文档。在这种情况下,你将令牌数量限制为 500,并将温度设置为 0.2(这个数量影响响应的“创意”和“保守性”)。

    在收到 OpenAI 的响应后,你将 JSON 内容(car_info)解析为包含所需键的 Python 字典:描述(文本)和两个字符串数组(优点和缺点)。有了这些新生成数据,你通过 Beanie 执行 MongoDB 更新,选择所有匹配品牌、型号和生产年份的汽车,并将它们的描述、优点和缺点设置为 OpenAI 返回的数据。如果发生错误,我们简单地显示错误。

  3. 现在将后台任务连接到POST端点。打开/routers/cars.py文件,并在顶部导入新创建的后台函数:

    from background import create_description
    
  4. 其余的代码将保持不变;你只修改POST端点:

    @router.post(
        "/",
        response_description="Add new car with picture",
        response_model=Car,
        status_code=status.HTTP_201_CREATED,
    )
    async def add_car_with_picture(
        background_tasks: BackgroundTasks,
        brand: str = Form("brand"),
        make: str = Form("make"),
        year: int = Form("year"),
        cm3: int = Form("cm3"),
        km: int = Form("km"),
        price: int = Form("price"),
        picture: UploadFile = File("picture"),
        user_data=Depends(auth_handler.auth_wrapper),
    ):
        cloudinary_image = cloudinary.uploader.upload(
          picture.file,
          folder="FARM2",
          crop="fill",
          width=800,
          height=600,
          gravity="auto"
        )
        picture_url = cloudinary_image["url"]
        user = await User.get(user_data["user_id"])
        car = Car(
            brand=brand,
            make=make,
            year=year,
            cm3=cm3,
            km=km,
            price=price,
            picture_url=picture_url,
            user=user,
        )
        background_tasks.add_task(
            create_description, brand=brand, make=make,
            year=year, picture_url=picture_url
        )
        return await car.insert(link_rule=WriteRules.WRITE)
    

这可以通过更细粒度的方式进行:你可以等待新插入的汽车生成的 ID,并仅更新那个特定实例。该函数还缺少一些基本验证,用于处理提供的汽车品牌和型号不存在的情况,或者 OpenAI 没有提供有效响应的情况。关键是端点函数立即返回响应——也就是说,在执行 MongoDB 插入后几乎立即,描述和两个数组稍后更新。

如果你尝试重新运行开发服务器并插入一辆汽车,你应该会看到新创建的文档(在 Compass 或 Atlas 中),几秒钟后,文档将更新为最初为空的字段:descriptionproscons

你可以想象出许多可能被这个功能覆盖的场景:可能需要由人类审核汽车描述,然后设置广告发布(通过添加已发布的布尔变量),可能你想向所有注册用户发送电子邮件,等等。

下一节将进一步介绍这个后台工作,并展示你如何快速将电子邮件集成到你的应用程序中。

将电子邮件集成到 FastAPI 中

现代网络应用最常见的需求之一是发送自动化的电子邮件。今天,有众多发送电子邮件的选项,其中最受欢迎的两个选项是 Twilio 的MailgunSendGrid

通过这个应用程序,你将学习如何使用名为Resend的相对较新的服务设置电子邮件功能。他们的以 API 为中心的方法非常适合开发者,并且易于上手。

导航到 Resend 主页(resend.com)并创建一个免费账户。登录后,导航到FARMstack。密钥只会显示一次,所以请确保复制并存储在.``env文件中。

执行以下步骤以将 Resend 功能添加到你的应用程序中:

  1. 安装resend包:

    pip install resend==2.0.0
    
  2. 安装resend包后,更新background.py文件:

    import json
    import resend
    from openai import OpenAI
    from config import BaseConfig
    from models import Car
    settings = BaseConfig()
    client = OpenAI(api_key=settings.OPENAI_API_KEY)
    resend.api_key = settings.RESEND_API_KEY
    # code continues …
    
  3. 更新create_description函数,以便在从 OpenAI 返回响应后发送消息:

    async def create_description(brand, make, year, picture_url):
        prompt = generate_prompt(brand, make, year)
        try:
            response = client.chat.completions.create(
                model="gpt-4",
                messages=[
                    {"role": "user", "content": prompt}],
                max_tokens=500,
                temperature=0.2,
            )
            content = response.choices[0].message.content
            car_info = json.loads(content)
            await Car.find(
                Car.brand == brand,
                Car.make == make,
                 Car.year == year).set(
                {
                    "description": car_info["description"],
                    "pros": car_info["pros"],
                    "cons": car_info["cons"],
                }
            )
            def generate_email():
                pros_list = "<br>".join([f"- {pro}" for pro in car_info["pros"]])
                cons_list = "<br>".join([f"- {con}" for con in car_info["cons"]])
                return f"""
                Hello,
                We have a new car for you: {brand} {make} from {year}.
                <p><img src="img/{picture_url}"/></p>
                {car_info['description']}
                <h3>Pros</h3>
                {pros_list}
                <h3>Cons</h3>
                {cons_list}
                """
            params: resend.Emails.SendParams = {
                "from":"FARM Cars <onboarding@resend.dev>",
                "to": ["youremail@gmail.com"],
                "subject": "New Car On Sale!",
                "html": generate_email(),
            }
            resend.Emails.send(params)
        except Exception as e:
            print(e)
    

收件人电子邮件地址应该是您在 Resend 上注册的同一电子邮件地址,因为这将是您注册和验证域名之前的唯一选项,但对于开发和测试目的来说已经足够:resend.com/docs/knowledge-base/

resend包使发送邮件变得简单——您只需调用一次resend.Emails.Send函数并定义参数。在您的案例中,参数如下:

  • to – 收件人电子邮件列表。

  • from – 发件人的电子邮件地址。在这种情况下,您将保留 Resend 提供的默认地址,但稍后您将用您自己的域名地址替换它。

  • subject – 邮件的主题。

  • html – 邮件的 HTML 内容。

参数以字典的形式传递给resend.Email.send()函数。

该应用程序中的邮件 HTML 内容直接由 Python 中的f-string构建,但您始终可以求助于更复杂和高级的解决方案,例如使用Jinja2(对于纯 Python 解决方案,因为后端是用 Python 编写的)或使用 Resend 的 React Email(react.email/)。Jinja2 可以说是最流行的 Python HTML 模板引擎,它被 Flask Web 框架所使用,而 React Email 提供基于 React 的邮件模板。

注意

请参阅第七章使用 FastAPI 构建后端,了解如何将您的后端部署到 Render.com。程序将基本保持不变:只需跟踪环境变量,并确保添加新创建的变量(OpenAI 和 Render 密钥)。或者,您可以从本章运行后端,以便在下一章中使用它。

摘要

在本章中,您学习了 Beanie 的基础知识,这是一个基于 Motor 和 Pydantic 的流行 ODM 库,用于 MongoDB。您学习了如何定义模型和定义与 MongoDB 集合映射的 Beanie 文档,以及如何使用 ODM 进行查询和执行 CRUD 操作。

您构建了另一个 FastAPI 应用程序,在该应用程序中,您通过后台任务集成了第三方服务,这是 FastAPI 的一个功能,允许在后台执行慢速和长时间运行的任务,同时保持应用程序的响应性。

本章还介绍了如何将最受欢迎的 AI 服务 ChatGPT 集成到您的应用程序中,为您的最新插入实体提供智能附加数据。最后,您学习了如何实现一个简单的邮件发送解决方案,这在许多 Web 应用程序中很常见。

在下一章中,您将深入了解基于 React.js 的最受欢迎和最先进的 Web 框架:Next.js。您将学习 Next.js 最新版本(14)的基础知识,并发现使其与其他前端甚至全栈解决方案区别开来的最重要的特性。

第十章:使用 Next.js 14 进行 Web 开发

Next.js 是一个用于构建全栈 Web 应用的 React 框架。虽然 React 是一个用于构建用户界面(Web 或原生)的库,但 Next.js 是一个完整的框架,基于 React 构建,提供了数十个特性,最重要的是,为从简单网站(如本章中将要构建的网站)到极其复杂的应用程序的项目结构。

虽然 React.js 是一个用于构建 UI 的无意见声明性库,但作为一个框架,Next.js 提供了配置、工具、打包、编译等功能,使开发者能够专注于构建应用程序。

本章将涵盖以下主题:

  • 如何创建 Next.js 项目并将其部署

  • 最新的 Next.js App Router 及其特性

  • 不同类型的页面渲染:动态、服务器端、静态

  • Next.js 实用工具:Image组件和Head组件

  • 服务器操作以及基于 cookie 的认证

技术要求

要创建本章中的示例应用程序,您应该具备以下条件:

  • Node.js 版本 18.17 或更高

  • 用于运行上一章后端的 Python 3.11.7(无论是本地还是从部署,如 Render)

要求与上一章相同,您将要安装的新包将在介绍时进行描述。

Next.js 简介

Next.js 14 是流行的基于 React 的框架的最新版本,用于创建全栈和可生产就绪的 Web 应用程序。

Next.js 甚至提供了通过名为Route Handlersnextjs.org/docs/app/building-your-application/routing/route-handlers)的新特性来创建后端服务器的可能性。这个特性提供了允许你创建自定义 HTTP 请求处理器,并通过使用 Web 请求和响应 API 来创建完整 API 的函数。

这些路由处理器类似于 FastAPI(GETPOST等)公开 HTTP 方法,并允许构建支持中间件、缓存、动态函数、设置和获取 cookie 和头部的复杂 API 等。

在接下来的几节中,您将能够插入自己的基于 Python 的服务器,并让该服务器独立运行,可能同时服务于其他应用程序(例如移动应用程序)。您将能够释放 Python 生态系统在集成某些数据科学或 AI 库方面的力量,并快速拥有与 Python 的出色开发者体验。

注意

如需了解特定主题的更详细说明,您可以参考以下网站:nextjs.org/docs

创建 Next.js 14 项目

在这个以项目为导向的部分,你将学习如何利用你的 React 知识创建和部署你的项目。你将通过执行一系列简单的步骤来创建一个全新的 Next.js 应用。该项目将使用 Tailwind CSS(集成到 Next.js 中)和 JavaScript 而不是 TypeScript。

本章中你将构建的前端需要运行后端——来自上一章。它可以在你的本地机器上运行,或者在执行部署的情况下,从Render.com 运行。在开发过程中,在单独的终端中本地运行上一章的背景,并激活虚拟环境,将会更容易和更快。

要创建一个全新的 Next.js 项目并按照我们指定的方式设置(使用 JavaScript 而不是 TypeScript,新的 App Router 等),请执行以下步骤:

  1. 在你选择的文件夹中打开终端并输入以下命令:

    npx create-next-app@latest
    

    提示将询问你是否希望安装最新的create-next-app包,在撰写本文时是版本 14.2.4。确认安装。

    在安装create-next-app包并使用之前的命令启动它之后,CLI 工具将提出一系列问题(nextjs.org/docs/getting-started/installation)。对于你的项目,你应该选择以下选项:

    • 你的项目叫什么名字?src/目录?@/*)? 使用cd FARM命令并运行开发服务器:

      npm run dev
      

      CLI 将通知你服务器正在 URL http://127.0.0.1:3000上运行。如果你在浏览器中访问这个页面,页面的首次渲染可能会有些延迟,这是正常的,因为 Next.js 会编译第一个也是目前唯一的页面。

    • 当前页面显示了很多 Next.js 特定的样式,因此为了从零开始,打开/src/app/page.js中的唯一自动定义的页面,并将其变成一个空的 React 组件(你可以使用 React Snippets 扩展的rafce快捷键):

      const Home = () => {
        return (
          <div>Home</div>
        )
      }
      export default Home
      
    • 此外,从/src/app/globals.css文件中删除 Next.js 特定的样式,只留下顶部的三个 Tailwind 导入:

      @tailwind base;
      @tailwind components;
      @tailwind utilities;
      

现在你已经运行了一个空的 Next.js 应用,并且你准备好定义应用程序的页面。Next.js 使用与 React Router 不同的路由系统。在下一节中,你将学习如何根据需要使用 Next.js 框架的最重要功能。在继续之前,你将简要观察 Next.js 项目结构,并在下一节中熟悉主要文件夹和文件。

Next.js 项目结构

虽然文档详细解释了每个文件和文件夹的功能(nextjs.org/docs/getting-started/project-structure),但了解你从哪里开始是很重要的。/app文件夹是应用程序的中心。其结构将决定以下部分中将要介绍的应用程序路由。

定义 Next.js 项目结构的最重要文件和文件夹如下:

  • 根项目目录中的/public文件夹可用于提供静态文件,并且它们通过基本 URL 进行引用。

  • next.config.js文件是一个 Node.js 模块,用于配置你的 Next.js 应用程序——从该文件中可以配置前缀资产、gzip压缩、管理自定义头、允许远程图像托管、日志记录等等(nextjs.org/docs/app/api-reference/next-config-js)。

  • globals.css文件是导入到每个路由的全局 CSS 样式。在你的应用程序中,你保持它最小化,并仅导入 Tailwind 指令。

  • 可选地,你可以创建一个middleware.js函数,该函数将包含将在每个或仅选定请求上应用的中件。查看中件文档以了解更多信息:nextjs.org/docs/app/building-your-application/routing/middleware

  • 可选地,你可以在/app文件夹(具有特殊路由角色)外部创建一个/components目录,并在其中创建你的 React 组件。

现在你已经了解了简要的项目结构,你将创建应用程序的页面,并在过程中学习 Next.js App Router 的基础知识。你将故意将样式保持到最小,以展示功能性和组件边界。

使用 Next.js 14 进行路由

Next.js 中最新且推荐的路由系统依赖于src/App文件夹——通常,每个 URL 都有一个对应名称的文件夹,其中包含一个page.js文件。这种结构允许你甚至用route.js文件替换page.js文件,然后将其视为 API 端点。你将创建一个简单的路由处理程序用于演示目的,但在项目中你不会使用路由处理程序。

注意

在 Next.js 文档网站上可以找到对 App Router 的详细介绍(nextjs.org/docs/pages/building-your-application/routing)。

现在,你将构建基本页面结构:一个主页、一个显示所有汽车以及单个汽车的页面、一个仅供授权用户插入新汽车的私有页面,以及一个登录页面。

使用 App Router 创建页面结构

你已经在App目录的根目录中有一个page.js文件;它映射到网站的/root URL。

现在,您将构建剩余页面的路由:

  1. 要创建一个显示汽车的路由(在 URL 中的/cars),在/app目录中创建一个新的文件夹,命名为cars,并在其中创建一个简单的page.js文件(文件名page.js是强制性的):

    const Cars = () => {
        return (
            <div>Cars</div>
        )
    }
    export default Cars
    
  2. 当在/src/app/cars目录内时,创建一个基于汽车 ID 显示单个汽车的嵌套文件夹。在cars目录内创建另一个文件夹,并命名为[id]。这将告诉路由器该路由应该映射到/cars/someID/cars/部分是基于文件夹位于/cars目录内的事实,而括号语法通知 Next.js 存在一个动态参数(在这种情况下是id)。在[id]文件夹内创建一个page.js文件,并将组件命名为CarDetails

  3. 重复相同的步骤,创建一个/app/login/page.js文件和一个/app/private/page.js文件,并使用相应的文件结构。运行rafce命令,为每个页面创建一个简单的组件。

现在,您已经定义了页面,可以通过手动访问各种 URL 来测试它们的功能:/, /cars, /private, 和 /login

这是个很好的时机来比较 App Router 与其他我们在前几章中使用过的解决方案——即 React Router。

Next.js 中的布局

与 React Router 及其Slot组件类似,Next.js App Router 提供了一个强大的Layout组件,它融合到目录结构概念中。Layout是一个在路由间共享的用户界面;它保留状态,保持交互性,并且不会重新渲染。与 React Router 中使用的Slot组件不同,Next.js 布局接受一个children属性,它将在基本页面内部渲染——实际上,整个应用程序都将加载在这个布局组件内部。

您可以检查整个 Next.js 应用程序中使用的强制根布局,它位于/app/layout.js。尝试在 body 内部和{{children}}组件之前添加一个元素,并检查该元素在哪些页面上可见——它应该在每一页上都可见。根布局不是您能使用的唯一布局;实际上,您可以为相关路由创建布局,以封装共同的功能或用户界面元素。

要创建一个简单的布局,该布局将被用于汽车列表页面和单个汽车页面(因此它将位于/app/cars文件夹内),在/app/cars目录内创建一个名为layout.js的文件:

const layout = ({ children }) => {
    return (
        <div className="p-4 bg-slate-300 border-2
            border-black">
            <h2>Cars Layout</h2>
            <p>More common cars functionality here.</p>
            {children}
        </div>
    )
}
export default layout

您会注意到布局影响了/cars/cars/id路由,但不会影响其他路由;布局文件的位置定义了它何时会被加载。这个功能使您能够创建不同的嵌套路由,并基于您的应用程序逻辑保持可重用的 UI 功能。

在继续之前,需要提到 Next.js 路由器的几个特性:

在创建了必要的页面并了解了 App Router 的主要功能之后,在下一节中,您将学习 Next.js 组件以及如何在应用程序结构中利用布局。

Next.js 组件

Next.js 的一个主要新概念是区分 localstorage 等。

注意

Next.js 文档解释了这里的主要差异以及更微妙的不同之处:nextjs.org/docs/app/building-your-application/rendering/composition-patterns

一般而言,由于服务器组件可以直接在服务器上访问数据,因此它们更适合数据获取和敏感信息(访问令牌、API 密钥等)处理等任务。客户端组件更适合经典的 React 单页应用(SPA)任务:添加交互性、使用 React 钩子、依赖于状态的自定义钩子、与浏览器接口、地理位置等。

默认情况下,Next.js 组件是 服务器 组件。要将它们转换为客户端组件,您必须在第一行添加 "use client" 指令。此指令定义了服务器和客户端组件模块之间的边界。

创建导航组件

开始构建 Next.js 组件,现在您将创建一个简单的导航组件,并了解 Next.js 中的 Link 组件。

要创建导航组件,请执行以下步骤:

  1. /app 文件夹旁边创建一个名为 /src/components/ 的文件夹(不要放在里面,因为这些页面不会被用户导航)并在其中创建 NavBar.js 文件:

    import Link from "next/link"
    const Navbar = async () => {
        return (
            <nav className="flex justify-between
                items-center bg-gray-800 p-4">
                <h1 className="text-white">Farm Cars</h1>
                <div className="flex space-x-4 text-white
                    child-hover:text-yellow-400">
                    <Link href="/">Home</Link>
                    <Link href="/cars">Cars</Link>
                    <Link href="/private">Private</Link>
                    <Link href="/login">Login</Link>
                </div>
            </nav>
        )
    }
    export default Navbar
    

    NavBar.js 组件与前面章节中创建的组件非常相似。然而,在这里,您已经导入了 Link 组件——这是扩展 <a> 元素(原生的 HTML 链接组件)并提供数据预获取的 Next.js 组件。nextjs.org/docs/app/api-reference/components/link

  2. 之前的代码使用了一个 Tailwind 插件,它允许开发者直接定位后代选择器。要使用它,打开 tailwind.config.js 文件并编辑内容,通过更改 plugins 数组值:

      plugins: [
        function ({ addVariant }) {
          addVariant('child', '& > *');
          addVariant('child-hover', '& > *:hover');
        }
      ],
    
  3. 现在打开位于 /src/app/layout.js 的根布局,在 children 属性之前插入 NavBar.js 组件,用以下代码替换现有的 RootLayout 函数:

    import Navbar from "@/components/NavBar";
    ...
    export default function RootLayout({ children }) {
      return (
        <html lang="en">
          <body>
            <Navbar />
            {children}
          </body>
        </html>
      );
    }
    

在这一步中,您将新创建的组件添加到根布局中,因为它将在每个页面上显示。

您现在已定义了路由,构建了应用程序的基本页面,并创建了一个简单的导航菜单。在下一节中,您将看到 Next.js 如何通过服务器组件简化数据加载。

使用服务器组件加载数据

以下过程将帮助您学习如何从您的 FastAPI 服务器加载数据到 /cars 页面,而不需要使用钩子和状态,并了解 Next.js 如何扩展原生的 fetch 功能。

要在不使用钩子的情况下从您的 FastAPI 服务器加载数据到 /cars 页面,请执行以下步骤:

  1. 在创建应显示您当前汽车收藏中所有汽车信息的页面之前,在 Next.js 项目的根目录中(与 /src 文件夹平行)创建一个 .env 文件,并使用它来映射您的 API 地址:

    API_URL=http://127.0.0.1:8000
    

    此值将在您部署并希望使用您的 Render.com API URL 或您可能选择的任何后端部署解决方案时需要更改。

  2. 一旦在环境中设置,地址将在您的代码中可用:

    process.env.API_URL
    

    重要的是要记住,为了在浏览器中可见,环境变量需要以 NEXT_PUBLIC_ 字符串开头。然而,在这种情况下,您正在服务器组件中进行数据获取,所以隐藏 API 地址是完全可以接受的。

    现在您已经准备好执行第一次服务器端获取。请确保您的后端服务器正在指定的端口 8000 上运行。

  3. 打开 /app/cars/page.js 文件并编辑它:

    import Link from "next/link"
    const Cars = async () => {
        const data = await fetch(
            `${process.env.API_URL}/cars/`, {
            next: {
                revalidate: 10
            }
        }
        )
        const cars = await data.json()
        return (
            <>
                <h1>Cars</h1>
                <div>
                    {cars.map((car) => (
                        <div key={car._id} className="m-4 bg-white p-2">
                            <Link href={`/cars/${car._id}`}>
                                <p>{car.brand} {car.make} from {car.year}</p>
                            </Link>
                        </div>
                    ))}
                </div>
            </>
        )
    }
    export default Cars
    

之前的代码可能看起来很简单,但它代表了基于 React 的开发中的一种全新的范式。

您使用了 Next.js 的 fetch 函数,它扩展了原生的 Web API fetch 方法并提供了一些额外的功能。它是一个 async 函数,因此整个组件是异步的,调用被等待。

注意

此 fetch 功能在 Next.js 网站上有详细的解释:nextjs.org/docs/app/building-your-application/data-fetching/fetching-caching-and-revalidating

在提供各种功能,如访问头和 cookie 的同时,fetch 函数允许对缓存和重新验证接收到的数据进行细粒度控制。在此上下文中,重新验证意味着缓存失效和重新获取最新数据。你的汽车页面可能非常频繁地更新,你可以设置内容的时间限制。在前面的代码中,内容每 10 秒重新验证一次。在某些情况下,在几小时或几天后重新验证数据可能是有意义的。

在学习框架提供的专用组件之前,你将了解用于在布局和路由组边界内捕获错误的 error.js 文件。

Next.js 中的错误页面

为了捕获服务器组件和客户端组件中可能出现的意外错误,并显示备用用户界面,你可以在所需文件夹内创建一个名为 error.js 的文件(文件名是强制性的):

  1. 创建一个文件,/src/app/cars/error.js,包含以下简单内容:

    "use client"
    const error = () => {
      return (
        <div className="bg-red-800 text-white p-3">
          There was an error while fetching car data!
        </div>
      )
    }
    export default error
    

    组件必须按照文档使用 "use client" 指令。

  2. 你可以通过在 [id]/page.js 中抛出一个通用错误来测试错误处理页面:

    const SingleCar = () => {
      throw new Error('Error')
    }
    export default SingleCar
    

如果你现在尝试导航到任何车辆详情页面,你会看到页面已加载——导航存在,主布局和车辆布局已渲染。只有包含 error.js 文件的内部最深层路由组显示错误信息。

在学习如何直接从服务器获取页面内部数据之后,在下一节中,你将创建一个静态生成的单车辆页面,并了解强大的 Next.js Image 组件。

静态页面生成和 Image 组件

Next.js 提供了另一种生成页面的方法——静态渲染。在这种情况下,页面是在构建时(而不是在请求时)渲染的,或者在数据重新验证的情况下,在后台进行。然后生成的页面被缓存并推送到内容分发网络,以实现高效和快速的服务。这使得 Next.js 有效地表现得像一个静态网站生成器,就像 Gatsby.js 或 Hugo 一样,并在网站速度方面实现最大性能。

然而,并非所有路由都适合静态渲染;个性化页面和包含特定用户数据的页面是不应进行静态生成的页面示例。然而,博客文章、文档页面,甚至是汽车广告,都不是应该向不同用户显示不同功能的页面。

在本节中,你将首先生成单个汽车页面作为服务器端渲染页面,就像之前的汽车页面一样,然后你将修改页面(们)以进行静态渲染。

在你开始使用 Image 组件之前,修改 next.js.mjs 文件——Next.js 配置文件——并让 Next.js 知道它应该允许来自外部域的图片——在你的情况下,Cloudinary——因为我们的汽车图片就是托管在那里。

执行以下步骤:

  1. 打开 next.config.mjs 文件并编辑配置:

    /** @type {import('next').NextConfig} */
    const nextConfig = {
      images: {
        remotePatterns: [
          {
            hostname: 'res.cloudinary.com',
          },
        ]
      }
    };
    export default nextConfig;
    
  2. 在此修改之后,手动重新启动 Next.js 开发服务器:

    npm run dev
    

    现在您将创建汽车页面的服务器端渲染版本。

  3. 打开 /app/cars/[id]/page.js 并相应地修改它:

    import {
      redirect
    } from "next/navigation"
    import Image from "next/image"
    const CarDetails = async ({
      params
    }) => {
      const carId = params.id
      const res = await fetch(
        `${process.env.API_URL}/cars/${carId}`, {
          next: {
            revalidate: 10
          }
        }
      )
      if(!res.ok) {
        redirect("/error")
      }
      const data = await res.json()
    

    在前面的代码中,您导入了 next/image 组件,并将 URL 的参数解构为 params。然后,您执行了一个类似的 fetch 请求并检查了结果状态。如果发生错误,您使用了 Next.js 的 redirect 函数将用户重定向到尚未创建的错误页面。

  4. 现在,继续编辑组件并返回一些基本的 JSX:

    return (
      <div className="p-4 flex flex-col justify-center
        items-center min-h-full bg-white">
        <h1>{data.brand} {data.make} ({data.year})</h1>
        <p>{data.description}</p>
        <div className="p-2 shadow-md bg-white">
          <Image src={data.picture_url}
            alt={`${data.brand} ${data.make}`}
            width={600} height={400}
            className="object-cover w-full" />
        </div>
        <div className="grid grid-cols-2 gap-3 my-3">
          {data.pros && <div className="bg-green-200
            p-5 flex flex-col justify-center
            items-center">
            <h2>Pros</h2>
            <ol className="list-decimal">
              {data.pros.map((pro, index) => (
                <li key={index}>{pro}</li>
              ))}
            </ol>
          </div>}
          {data.cons && <div className="bg-red-200 p-5
            flex flex-col justify-center items-center">
            <h2>Cons</h2>
            <ol className="list-decimal">
              {data.cons.map((con, index) => (
                <li key={index}>{con}</li>
              ))}
            </ol>
          </div>}
        </div>
      </div >
      )
    }
    export default CarDetails
    

    功能组件的其余部分相当简单。您使用了 Image 组件并提供了必要的数据,例如 widthheightalt 文本。Image 组件有一个丰富的 API,在 Next.js 网站上有文档(nextjs.org/docs/app/api-reference/components/image),并且尽可能使用它,因为它大大提高了您网站的性能。

    redirect 函数是从 next/navigation 导入的(nextjs.org/docs/app/building-your-application/routing/redirecting)。

    页面的静态生成版本包括向页面提供一个 generateStaticParams() 函数并将其导出;Next.js 使用此函数在构建时知道要生成哪些页面。

  5. 对于您的 /app/cars/[id]/page.js 文件,此函数需要遍历所有需要静态页面的汽车(在这种情况下是所有汽车)并提供一个包含 ID 的数组:

    export async function generateStaticParams() {
      const cars = await fetch(
        `${process.env.API_URL}/cars/`).then((res) =>
        res.json())
      return cars.map((car) => ({id: car._id,}))
    }
    

如果您将前面的 generateStaticParams() 函数添加到组件中,请停止开发服务器并运行另一个 Next.js 命令:

npm run build

Next.js 将生成整个站点的优化构建,在构建时将单个汽车页面渲染为静态 HTML 页面。如果您检查控制台,您将看到路由列表和一个图例,显示哪些页面是在构建时渲染的。

使用以下命令可以运行生产构建:

npm run start

在关闭本节之前,让我们处理用户点击错误 URL,导致不存在汽车的情况。为了处理这些 404 页面未找到 错误,创建一个名为 /src/app/not-found.js 的新文件并填充它:

import Link from "next/link"
const NotFoundPage = () => {
  return (
    <div className="min-h-screen flex flex-col
      justify-center items-center">
      <h1>Custom Not Found Page</h1>
      <p>take a look at <Link href="/cars"
        className="text-blue-500">our cars</Link>
      </p>
   </div>
  )
}
export default NotFoundPage

此路由将涵盖所有路由组,类似于 React Router 包中的 * 路由。

在创建了动态服务器端和静态生成的页面,并探索了 Next.js 的一些最重要的功能之后,您将在下一节中学习如何使用现有的 API 对用户进行身份验证。

Next.js 中的身份验证和服务器操作

你已经了解了相当多的 Next.js 功能,这些功能使它成为首屈一指的 Web 框架,但如果没有对 Server Actions 的简要介绍,最重要的功能列表将不完整。

服务器操作是仅在实际服务器上执行的异步函数,旨在处理数据获取和变更(通过 POSTPUTDELETE 方法),并且可以通过普通表单提交(默认浏览器表单处理方法)调用,也可以通过事件处理程序(React 风格的方法)或通过第三方库如 Axios 调用。

这种方法的好处有很多。性能得到提升,因为客户端 JavaScript 显著减少,并且由于操作仅在服务器上运行,应用程序的整体安全性得到增强,甚至可以在禁用 JavaScript 的情况下运行,就像几十年前的老式应用程序一样。

你现在将创建第一个用于登录用户的服务器操作,这需要使用一个名为 localStorage 的包:签名和加密 cookies。使用方法相当简单,这里有所记录:github.com/vvo/iron-session

  1. 使用以下命令安装 Iron Session 包:

    npm i iron-session
    
  2. 要使用 iron-session 功能,在名为 /src/lib.js 的文件中创建一个 sessionOptions 对象:

    export const sessionOptions = {
      password:
       "complex_password_at_least_32_characters_long",
      cookieName: "farmcars_session",
      cookieOptions: {
        httpOnly: true,
        secure: false,
        maxAge: 60 * 60,
      }
    };
    

配置对象定义了用于 cookie 加密和解密的选项,你应该使用一个强大、由计算机生成的随机密码。

Iron Session API 非常简单,因为会话对象允许设置和获取类似字典的值。你将用它来设置两个简单的值:当前登录的用户名以及 jwt 本身,这对于调用你的 FastAPI 端点至关重要。

现在你将开始创建应用程序所需的服务器操作,从用于验证用户的登录操作开始:

  1. 创建一个 /src/actions.js 文件并导入必要的包:

    "use server";
    import { cookies } from "next/headers"
    import { getIronSession } from "iron-session"
    import { sessionOptions } from "./lib"
    import { redirect } from "next/navigation"
    export const getSession = async () => {
      const session = await getIronSession(
        cookies(), sessionOptions)
        return session
    }
    

    之前的代码从 Next.js 导入了 cookies,以及来自 Iron Session 的 getIronSession() 函数,以及你之前定义的 sessionOptions 类。然后你创建了一个简单的函数来获取当前会话及其中的数据。

  2. 现在,在同一个文件中处理登录功能:

    export const login = async (status, formData) => {
      const username = formData.get("username")
      const password = formData.get("password")
      const result = await fetch(
        `${process.env.API_URL}/users/login`, {
          method: "POST",
          headers: {
            "Content-Type": "application/json"
          },
          body: JSON.stringify({ username, password })
        })
      const data = await result.json()
      const session = await getSession()
      if (result.ok) {
        session.username = data.username
        session.jwt = data.token
        await session.save()
        redirect("/private")
        } else {
          session.destroy()
          return { error: data.detail }
      }
    }
    

    代码结构简单,与你在 React Router 和 localStorage 解决方案中看到的代码相似。重要的是与会话对象相关的部分——如果 fetch 调用返回成功响应,这意味着找到了有效的用户,并且会话已通过用户名和相应的 jwt 设置。如果没有,会话将被销毁。

    只有当用户登录并且会话成功设置时,才会执行重定向到 /private 页面的操作。

    现在你已经创建了第一个 Server Action,你就可以创建一个 Next.js 客户端组件了——登录表单,它将在登录页面上使用。

  3. 创建一个新的组件文件,/src/app/components/LoginForm.js

    "use client"
    import {login} from "@/actions"
    import { useFormState } from "react-dom";
    const LoginForm = () => {
      const [state, formAction] = useFormState(login, {})
    

    LoginForm 与之前创建的 NavBar 组件不同,是一个客户端组件,这意味着它将在客户端渲染,因此需要以 "use client" 指令开始。

    useFormState 钩子是 React 生态系统中最新的添加之一(实际上,它是从 React-Dom 包中导入的,而不是 Next.js),它允许你根据表单操作更新状态(pl.react.dev/reference/react-dom/hooks/useFormState)。

  4. 继续构建 LoginForm 组件:

    return (
        <div className="flex flex-col items-center justify-center max-w-sm mx-auto mt-10">
            <form className="bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4" action={formAction}>
                <div className="mb-4">
                    <label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="username">
                        Username
                    </label>
                    <input
                        className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" id="username" name="username" type="text" placeholder="Username" required />
                </div>
                <div className="mb-6">
                    <label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="password">
                        Password
                    </label>
                    <input className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 mb-3 leading-tight focus:outline-none focus:shadow-outline" id="password" name="password" type="password" placeholder="******************" required />
                </div>
                <div className="flex items-center justify-between">
                    <button className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 w-full rounded focus:outline-none focus:shadow-outline" type="submit">
                        Sign In
                    </button>
                </div>
                <pre>{JSON.stringify(state, null, 2)}</pre>
            </form>
        </div >
    )
    }
    export default LoginForm
    

    此登录表单使用 useFormState 钩子,它提供了状态——本质上是一个错误对象和 formAction。在表单中,你将状态显示为字符串化的 JSON 对象,但在实际场景中,你可以访问服务器(在你的情况下是 FastAPI)提供的所有单个错误,并相应地显示它们。

  5. 在更新 /src/app/login/page.js 页面并简单地添加 LoginForm 组件后,你将得到以下内容:

    import LoginForm from "@/components/LoginForm"
    const page = () => {
      return (
        <div>
          <h2>Login Page</h2>
          <LoginForm />
        </div>
      )
    }
    export default page
    

现在,如果你尝试导航到 /login 路由并输入一些无效凭据,错误将以字符串化的 JSON 格式打印在表单下方。如果凭据有效,你应该被重定向到 /private 路由,并在整个应用程序中可用的 jwt 中。

你已经通过使用 iron-session 包和 Next.js 服务器操作添加了认证功能。

在下一节中,你将创建一个仅对认证用户可见的受保护页面。尽管在 Next.js 中有不同方式来保护页面,包括使用 Next.js 中间件,但你将使用简单的会话验证来保护一个页面。

创建受保护的页面

在本节中,你将创建一个受保护的页面——用于将新车插入 MongoDB 数据库集合的页面。使用 Iron Session 检查 cookie 的有效性,并将登录用户的用户名和 jwt 值跨页面传递。

你将通过验证会话中的数据来创建一个受保护的页面。如果会话存在(并且包含用户名和 jwt),用户将能够导航到它并通过表单和相关的服务器操作创建新车。如果没有,用户将被重定向到登录页面。

在这个应用程序中,你将需要的唯一认证页面是用于插入新车的页面,Iron Session 使这项工作变得非常简单:

  1. 打开 /src/app/private/page.js 并编辑该文件:

    import { getSession } from "@/actions"
    import { redirect } from "next/navigation"
    const page = async () => {
      const session = await getSession()
      if (!session?.jwt) {
        redirect("/login")
      }
      return (
        <div className="p-4">
          <h1>Private Page</h1>
          <pre>{JSON.stringify(session, null, 2)}</pre>
        </div>
      )
    }
    export default page
    

    之前的代码使用了 Iron Session 对象:如果会话中的 jwt 存在,用户能够看到当前包含会话数据的页面。如果会话无效,用户将被重定向到 /login 页面。

  2. 要使用会话添加注销功能,请向 /src/actions.js 文件添加另一个操作:

    export const logout = async () => {
      const session = await getSession()
      session.destroy()
      redirect("/")
    }
    

    现在,这个操作可以从 NavBar 组件中调用,并且可以使用会话对象相应地显示或隐藏登录和登出链接。

  3. 要将登出功能集成到网站中,在新的 LogoutForm.js 文件中创建一个简单的单按钮表单用于登出:

    import { logout } from "@/actions"
    const LogoutForm = () => {
      return (
        <form action={logout}>
          <button className="bg-blue-500
              hover:bg-blue-700" type="submit">
              Logout
          </button>
       </form>
      )
    }
    export default LogoutForm
    

    LogoutForm 只包含一个按钮,该按钮调用之前定义的登出操作。让我们使用一些条件逻辑将其添加到导航(NavBar.js)组件中。

  4. 打开 src/components/Navbar.js 文件并编辑导航组件:

    import Link from "next/link"
    import { getSession } from "@/actions";
    import LogoutForm from "./LogoutForm";
    

    在导入 getSession 函数(用于跟踪用户是否已登录)和 LogoutForm 按钮之后,您可以定义组件:

    const Navbar = async () => {
      const session = await getSession()
      return (
        <nav className="flex justify-between items-center
          bg-gray-800 p-4">
          <h1 className="text-white">Farm Cars</h1>
          <div className="flex space-x-4 text-white
            child-hover:text-yellow-400">
            <Link href="/">Home</Link>
            <Link href="/cars">Cars</Link>
            <Link href="/private">Private</Link>
            {!session?.jwt && <Link
              href="/login">Login</Link>}
            {session?.jwt && <LogoutForm />}
          </div>
        </nav>
      )
    }
    export default Navbar
    

该组件现在跟踪已登录用户,并根据用户的登录状态条件性地显示登录或登出链接。私有链接故意总是可见的,但您可以测试一下;如果您未登录,您将无法访问该页面,并将被重定向到登录页面。

您现在已完全实现了登录功能。有几个因素需要考虑,首先是 cookie 的持续时间——通过文件 /src/lib.js 中的 maxAge 属性设置——它应该与 FastAPI 从后端提供的 jwt 持续时间相匹配。该应用程序故意缺少用户注册功能,因为想法是有几个员工——可以通过 API 直接创建的用户。作为一个练习,您可以编写用户注册页面并使用 FastAPI 的 /users/register 端点。

在下一节中,您将通过创建一个仅对认证用户可见且仅允许销售人员插入新汽车的私有页面来最终完成应用程序。

实现新汽车页面

在本节中,您将创建插入新汽车的表单。您将不会使用表单验证库,因为这在第八章中已经介绍过,即使用 Zod 库构建应用程序的前端。在实际应用中,表单肯定会有类似类型的验证。您将创建一个新的服务器操作来执行 POST API 调用,并再次使用 useFormState——这是您用于登录用户的相同模式。

由于插入汽车的表单包含许多字段(并且可能会有更多),您将首先将表单字段抽象成一个单独的组件。新汽车广告创建的实现将分为以下步骤:

  1. 在名为 /src/components/InputField.js 的文件中创建一个新的 Field 组件:

    const InputField = ({ props }) => {
      // eslint-disable-next-line react/prop-types
      const { name, type } = props
      return (
        <div className="mb-4">
          <label className="block text-gray-700
            text-sm font-bold mb-2" htmlFor={name}>
              {name}
          </label>
          <input className="shadow appearance-none
            border rounded w-full py-2 px-3
            text-gray-700 leading-tight
            focus:outline-none focus:shadow-outline"
            id={name}
            name={name}
            type={type}
            placeholder={name}
            required
            autoComplete="off"
          />
        </div>
      )
    }
    export default InputField
    

    现在 InputField 已经处理完毕,创建 CarForm

  2. /src/components/CarForm.js 文件中创建一个新的组件,并从导入和所需字段的数组开始:

    "use client"
    import { createCar } from "@/actions"
    import { useFormState } from "react-dom"
    import InputField from "./InputField"
    const CarForm = () => {
      let formArray = [
        {
          name: "brand",
          type: "text"
        },
        {
          name: "make",
          type: "text"
        },
        {
          name: "year",
          type: "number"
        },
        {
          name: "price",
          type: "number"
        },
        {
          name: "km",
          type: "number"
        },
        {
          name: "cm3",
          type: "number"
        },
        {
          name: "picture",
          type: "file"
        }
      ]
    

    该组件使用 useFormState 钩子;您已经知道它需要一个客户端组件。

  3. 组件的其余部分只是对 fields 数组的映射和钩子的实现:

    const [state, formAction] = useFormState(
      createCar, {})
      return (
        <div className="flex items-center justify-center">
          <pre>{JSON.stringify(state, null, 2)}</pre>
            <div className="w-full max-w-xs">
              <form className="bg-white shadow-md rounded
                px-8 pt-6 pb-8 mb-4"
                action={formAction}>
                  <h2 className="text-center text-2xl
                    font-bold mb-6">Insert new car
                  </h2>
                  {formArray.map((item, index) => (
                  <InputField key={index}
                    props={{
                    name: item.name, type: item.type
                    }} />
                   ))}
                   <div className="flex items-center
                     justify-between">
                     <button className="bg-gray-900
                       hover:bg-gray-700 text-white w-full
                       font-bold py-2 px-4 rounded
                       focus:outline-none
                       focus:shadow-outline"
                       type="submit">Save new car
                     </button>
                   </div>
                 </form>
               </div>
             </div>
           )
      }
    export default CarForm
    

    表单使用 createCar 动作,您将在后续步骤中定义该动作的 actions.js 文件。

  4. 表单需要在私有页面上显示,因此编辑 /src/app/private/page.js 文件:

    import CarForm from "@/components/CarForm"
    import {getSession} from "@/actions"
    import { redirect } from "next/navigation"
    const page = async () => {
      const session = await getSession()
      if (!session?.jwt) {
        redirect("/login")
        }
      return (
        <div className="p-4">
          <h1>Private Page</h1>
          <CarForm />
        </div>
      )
    }
    export default page
    

    表单已创建,并在 /private 页面上显示。唯一缺少的是相应的动作,您将在下一步中创建。

  5. 打开 /src/actions.js 文件,并在文件末尾添加以下动作以创建新汽车:

    export const createCar = async (state, formData) => {
      const session = await getSession()
      const jwt = session.jwt
      const result = await fetch(`${
        process.env.API_URL}/cars/`,
        {
          method: "POST",
          headers: {
            Authorization: `Bearer ${jwt}`,
            },
            body: formData
        })
        const data = await result.json()
        if (result.ok) {
          redirect("/")
        } else {
          return { error: data.detail }
        }
    }
    

动作很简单——这就是服务器动作的美丽之处。它只是一个检查会话和 jwt 并执行 API POST 请求的函数。该函数还应包括在找不到 jwt 的情况下将用户重定向到登录页面的早期重定向,但这样您可以让 useFormState 钩子显示来自后端的任何错误。

您已实现了网站规范——用户能够登录并插入新汽车,在重新验证期(15-20 秒)后,汽车将在 /car 页面以及新插入汽车的专用页面上显示。

在下一节中,您将部署您的应用程序到 Netlify,并学习如何简化流程,同时提供环境变量并配置部署设置。

提供元数据

Next.js 的一个主要特性是提供比 SPAs 更好的 搜索引擎优化SEO)。虽然生成易于爬虫抓取的静态内容很重要,但提供有用的页面元数据是至关重要的。

元数据是每个网络应用程序或网站的重要特性,Next.js 通过 Metadata 组件以优雅的方式解决了这个问题。元数据使与搜索引擎(如 Google)的直接通信成为可能,提供有关网站内容、标题和描述的精确信息,以及页面特定的信息。

在本节中,您将学习如何设置页面的标题标签。Next.js 文档非常详细(nextjs.org/docs/app/building-your-application/optimizing/metadata),并解释了可以设置的各种信息片段,但在此情况下,您只需设置页面标题:

  1. 打开 src/app/layout.js 页面并编辑 metadata 部分:

    export const metadata = {
      title: "Farm Cars App",
      description: "Next.js + FastAPI + MongoDB App",
    };
    

    这个简单的更改将导致布局内的所有页面都拥有新设置的标题和描述。由于您已编辑包含所有页面的 Root 布局,这意味着网站上的每个页面都将受到影响。这些可以在每个页面上进行覆盖。

  2. 打开 /src/app/cars/[id]/page.js 以访问单个汽车页面,并添加以下导出:

    export async function generateMetadata({ params }, parent) {
        const carId = params.id
        const car = await fetch(`${process.env.API_URL}/cars/${carId}`).then((res) => res.json())
        const title = `FARM Cars App - ${car.brand} ${car.make} (${car.year})`
        return { title }
    }
    

上述导出向 Next.js 传达了只有这些页面应该有从函数返回的标题,而其他页面将保留未更改的标题。

你已经成功编辑了页面的元数据,现在是时候将应用程序部署到互联网上了,下一节将详细介绍。

Netlify 部署

Next.js 可以说是最受欢迎的全栈和前端框架,并且有大量的部署选项。

在本节中,你将学习如何在 Netlify 上部署你的 Next.js 应用程序——Netlify 是最受欢迎的用于部署、内容编排、持续集成等功能的 Web 平台之一。

为了在 Netlify 上部署你的网站,你需要部署 FastAPI 后端。如果你还没有这样做,请参考第七章使用 FastAPI 构建后端,了解如何进行操作。一旦你有了后端地址(在你的例子中,部署的 FastAPI 应用程序的 URL 是chapter9backend2ed.onrender.com),它将被用作 Next.js 前端的 API URL。

为了执行 Netlify 的部署,请执行以下步骤:

  • 创建 Netlify 账户:使用 GitHub 账户登录并创建一个免费的 Netlify 账户,因为 Netlify 将从你为 Next.js 应用程序创建的仓库中提取代码。

  • 创建 GitHub 仓库:为了能够部署到 Netlify(或者 Vercel),你需要为你的 Next.js 项目创建一个 GitHub 仓库。

要创建 GitHub 仓库,请执行以下步骤:

  1. 在你的终端中,进入项目文件夹并输入以下命令:

    git add .
    

    此命令将修改后的和新创建的文件添加到仓库中。

  2. 接下来,提交更改:

    git commit -m "Next.js project"
    
  3. 现在你的项目已置于版本控制之下,在你的 GitHub 账户中创建一个新的仓库并选择一个合适的名称。在你的情况下,仓库被命名为chapter10frontend

推送更改到 GitHub

现在你可以将新的源添加到你的本地仓库。在项目中的同一终端内,输入以下命令:

  1. 首先,将分支名称设置为main

    git branch -M main
    
  2. 然后,将源设置为新建的仓库:

    git remote add origin https://github.com/<your username>/<name_of_the_repo>.git
    

    在这里,你需要替换仓库名称和你的用户名:(<username><name_of_the_repo>)。

  3. 最后,将项目推送到 GitHub:

    git push -u origin main
    

现在,你可以以下述方式在 Netlify 上部署仓库:

  1. (在你的例子中是chapter10frontend)。

  2. main,因为这是你唯一的分支

  3. 基础目录:保持为空

  4. 构建命令:保持为npm run build

  5. 发布目录:保持为.next

  6. 设置唯一的环境变量:点击API_URL,其值将是 FastAPI 后端 URL。如果你遵循了上一章中关于在 Render 上托管后端的步骤,该值将是chapter9backend2ed.onrender.com

  • 点击部署你的 repo)按钮!

一段时间后,你应该能在页面上显示的地址看到你的网站已部署。然而,请注意,API 必须处于工作状态,例如,如果你使用 Render.com 的免费层作为后端部署选项(如果你使用了 Render 作为后端部署选项),在数据过时后可能需要一分钟才能唤醒,因此请准备好唤醒 API。建议等待后端响应——你可以通过简单地访问 API 地址来检查它——然后开始部署过程。这样,你将防止潜在的部署和页面生成错误。

这是个分析你提供给 Netlify 以构建网站的命令——build命令——的好时机。如果你在 Next.js 命令行中运行npm run build,Next.js 将执行一系列操作并生成一个优化的构建。

这些操作包括代码优化(如压缩和代码拆分)、创建包含优化、生产就绪代码的.next目录,以及实际上在互联网上提供服务的目录。

build命令还会生成静态页面和路由处理程序。在成功完成构建后,你可以使用以下命令测试构建:

npm run start

你现在已经成功部署了一个优化的、由 FastAPI MongoDB 驱动的 Next.js 网站,你准备好使用这个强大且灵活的堆栈来处理大量的 Web 开发任务了。

摘要

在本章中,你学习了 Next.js 的基础知识,这是一个流行的基于 React 的全栈框架,结合 FastAPI 和 MongoDB,允许你构建几乎任何类型的 Web 应用程序。

你已经学会了如何创建新的 Next.js 项目,如何使用新的 App Router 实现路由,以及如何使用服务器组件获取数据。

还介绍了并实现了重要的 Next.js 概念,如服务器操作、表单处理和 cookies。除此之外,你还探索了一些 Next.js 优化,例如用于提供优化图像的Image组件、Metadata标签以及如何创建生产构建。

最后,你在 Netlify 上部署了你的 Next.js 应用程序,但其他提供者的部署基本原理仍然相同。

Next.js 本身就是一个丰富且复杂的生态系统,你应该将本章视为你下一个应用的起点,该应用融合了三个世界的最佳之处:FastAPI、MongoDB 和 React,并添加了应用程序可能需要的第三方外部服务。

下一章将分享一些在实际使用 FARM 堆栈时对你有帮助的实用建议,以及一些可以帮助你立即开始的项目想法。

第十一章:有用资源及项目想法

在本章的最后部分,你将了解FastAPI、React 和 MongoDBFARM)堆栈组件以及一些推荐的操作来理解构成这个灵活堆栈的技术。

对于构建数据驱动或数据密集型应用,本章在处理 FARM 堆栈时提供了一些实用建议,以及 FARM 堆栈或非常相似的堆栈可能适用和有帮助的项目想法。你还将学习如何在不断变化的网络开发和数据分析领域中找到自己的道路。这对于来自最多样化背景的人来说将是有帮助的,但他们的工作或新发现的热情驱使他们通过数据驱动世界找到一条道路。

本章将涵盖以下主题:

  • MongoDB 注意事项

  • FastAPI 和 Python 注意事项

  • React 实践

  • 初学者项目想法

MongoDB 注意事项

第二章使用 MongoDB 设置数据库中,你被介绍到 MongoDB 以帮助你开始更简单的项目。然而,MongoDB 是一个由企业级公司使用的复杂生态系统。因此,深入了解其特性和模式将对你作为开发者有益,并帮助你理解 NoSQL 范式。

在使用 MongoDB 时,第一步之一是理解数据建模或模式设计。你的数据模型应该反映你的应用程序如何看待数据及其流动,从你提出的查询开始。有一些高级设计模式适用于 MongoDB 模式,但超出了本书的范围。

第二章使用 MongoDB 设置数据库,涵盖了 MongoDB 文档建模的一些流行最佳实践。以下列表提供了更多建议:

  • 如果对象打算一起使用,应该在同一份文档中组合。引用“一起访问的数据,应该保持在一起”可能有助于你的模式设计。

  • 当将对象分离到不同的文档中时,尽量不使 JOIN 变得必要,尽管通过 MongoDB 聚合框架可以实现简单的 LEFT JOIN。

  • 数据使用案例的频率应该决定模式。最频繁的数据流应该是最容易访问的。

来自关系型数据库世界的人,建模关系通常归结为嵌入引用之间的选择。在前面章节中你处理过的简单应用中,当你用用户创建 CRUD 应用程序时,你选择了引用用户 ID,因为这是最简单的事情。

然而,这也许同样适用于现实世界的场景。存在许多经验法则。例如,如果一个一对多关系的多个方面可能包含数百个项目,嵌入可能不是最佳选择。

广泛的 MongoDB 文档指出,在 一对一一对少一对多 的关系中应首选 嵌入,而在 一对一非常多的多多对多 情况下应使用 引用

注意

要了解使用真实世界示例的数据建模基础知识,请查看以下文档:www.mongodb.com/developer/products/mongodb/mongodb-schema-design-best-practices/

此外,Python 驱动程序,如 PyMongo 和其异步对应物 Motor,与 MongoDB 无缝协作。借助 Python 丰富的数据结构系统和数据处理能力,相对容易地更改和混合事物,更改模式,并尝试不同类型的文档,直到找到特定用例的最佳解决方案。

这里有两个可能包含在你的某些应用程序中的有趣项目:

  • Beanie (roman-right.github.io/beanie/) 是一个基于 Motor 和 Pydantic 的异步 Python 对象文档映射器,用于 MongoDB,可以加快 CRUD 应用程序的开发。你已经学习了如何使用 Beanie 进行后端开发。请参阅 第九章FastAPI 和 Beanie 的第三方服务集成

  • Mongita (github.com/scottrogowski/mongita) 可以被视为 MongoDB 的 SQLite。它可以用作需要本地保存数据的轻量级嵌入式数据库,或者在设置 MongoDB 或 Atlas 之前进行原型设计。

FastAPI 和 Python 考虑事项

Python 包含数据与文本处理、Web 开发、数据科学、机器学习、数值计算、可视化以及计算几乎所有的可能方面。

Python 的最佳实践也适用于 FastAPI。然而,由于 FastAPI 将简单的 Python 函数(甚至受 Django 基于类的视图启发的类)转换为 REST API 端点,你不需要做任何额外的事情。FastAPI 以一种有利于开发者的方式构建,在编写 API 时为你提供必要的灵活性和流畅性。

以下列表提供了应成为你的 FastAPI 开发过程一部分的通用考虑事项:

  • 使用 GitGitHub 并学习一个简单的流程。学习一个流程并一直使用,直到你习惯它并切换,比一次性学习所有命令要容易得多,尤其是如果你是唯一一个试图自动化或 REST-ify 业务流程的开发者。

  • 将你的环境变量保存在 .env 文件中,但也要在别处备份它们(API 密钥、外部服务凭证等)。

  • 学习 Python 的类型提示系统。它与 Pydantic 密切相关,并为您的整体代码添加了一层鲁棒性。它也是编写 FastAPI 应用程序的一个基本组成部分。

  • 正确构建应用程序的结构。在单个文件中创建一个功能丰富的应用程序非常容易且诱人。这尤其适用于您没有明确的规范时,但您应该抵制这种冲动。请参考 FastAPI 文档中关于构建大型应用程序的结构(fastapi.tiangolo.com/tutorial/bigger-applications)。

主要思想是将应用程序分解为路由器和 Pydantic 模型,使它们有各自的目录。例如,书中有一个 /routers 目录,因此您也应该有一个 /models 目录。这些目录应该各自有一个空的 __init__.py 文件,使它们成为 Python 模块。您可以将外部服务工具保存在一个单独的文件中,或者在一个 /helpers 目录中。您可以根据应用程序的复杂度进行细化。请记住,您最终会得到一个 ASGI 应用程序,这是您选择的服务器(如 Uvicorn 或其他服务器)唯一引用的端点。

测试 FastAPI 应用程序

测试是确保您的应用程序按预期运行所必需的。本章不会涵盖测试驱动开发TDD),其中测试是在实际代码之前编写的。然而,当与异步 MongoDB Python 驱动程序 Motor 和 FastAPI 一起工作时,您可能会遇到一些特定的问题。

对您的 API 进行单元测试是必不可少的,并且设置起来很简单。每个端点都应该进行测试,并且每个端点都应该执行它们被分配的任务。虽然 Python 中的单元测试已经有了几个成熟的框架,如 unittestpytest,但有一些 FastAPI 特定的点值得提及。

FastAPI 文档([fastapi.tiangolo.com/tutorial/testing/](https://fastapi.tiangolo.com/tutorial/testing/))建议您使用 Starlette 提供的 TestClient 类。弗朗索瓦·沃龙在他的优秀书籍《使用 FastAPI 构建数据科学应用》中,推荐使用 HTTPX(一个类似于 Requests 的异步 HTTP 库,由 Starlette 团队开发)和 pytest-asyncio,使整个过程完全异步。

包含 Pydantic 使得测试 FastAPI 应用程序成为一种愉快的体验,并强制执行某些倾向于产生更稳定软件的实践。另一方面,FastAPI 的自动文档是一个非常有帮助的工具,可以节省您的时间,并减少在代码编辑器和客户端之间频繁切换。

React 实践

第一章Web 开发和 FARM 栈 中,你选择 React 作为前端是因为它的简单性和灵活性。如果你是一个视觉学习者,尝试由 Academind GMBH 和其主要作者 Maximilian Schwarzmüller 提供的视频课程,名为 React – The Complete Guide

对 JavaScript 和 ES6 的深入了解是成为更好的 React 开发者的最佳基础,但深入了解一些基本的 React 概念也很重要,探索 Hooks 机制、组件生命周期和组件层次结构。

你应该熟悉其他 Hooks;在这本书中,你将了解两到三个最受欢迎的 Hooks,但还有很多。了解 Hooks 如何以及为什么以这种方式工作将使你成为更好的 React 开发者。

截至 2024 年,React 函数式组件通常比旧的基于类的组件更受欢迎,因为它们更简洁、易于维护和灵活。

其他主题

本节强调了在使用 FARM 栈时一些其他重要的要点,这些要点可能会很有用。虽然你可以为几乎任何类型的网络应用选择使用 FARM 栈,但这个栈可能更适合某些类型的应用,而不太适合其他类型的。

身份验证和授权

第六章身份验证和授权,专注于使用 FastAPI 实现基于 JWT 的身份验证解决方案及其在 React 中的后续应用。然而,正如该章节中提到的,这可能不是最佳或可行的解决方案,对于某些用例来说。你可能需要回退到第三方提供商,如 Firebase、Auth0 或 Cognito。在承诺使用第三方解决方案之前,务必充分了解其优缺点、潜在锁定后果以及价格因素,尤其是如果你计划扩展应用程序的话。

数据可视化和 FARM 栈

第一章Web 开发和 FARM 栈,描述了一些相当简单的可视化,但通过格式化良好的细粒度 JSON 响应和 React 作为前端,几乎任何事都是可能的。这种根据你的需求实际塑造数据的能力为你提供了一个巨大的游乐场,你可以在这里测试、尝试和尝试不同的解决方案,也许是通过迭代,直到你达到满意的类型的数据可视化。

存在着广泛的可视化需求,并不一定需要尝试制作类似 Shirley Wu D3.js 的艺术品,其中简单的双色堆叠柱状图就能完成任务。然而,有了快速的后端和 MongoDB 可以容纳你抛给它的几乎任何类型的数据结构,你将准备好应对任何任务。D3.js 的 Observable 包装器有一个非常有趣的接口,并抽象了 D3.js 的许多机制,因此这可能是一个良好的起点。

关系数据库

如果您的用例需要关系数据库的复杂性,例如 SQL 和它们的严格结构,您不需要完全放弃 FARM 栈。鉴于 FastAPI 的模块化和本书探讨的一些部署选项,您可以插入一个关系数据库,如 Postgres 或 MySQL,探索 SQLAlchemy 或一些异步数据库 Python 驱动程序的文档,并简单地添加所述功能,同时管理用户,例如通过 MongoDB。

一些启动项目想法

本节列出了一些项目想法,以帮助您探索 FARM 栈的可能性,磨练您的技能,但最重要的是发挥您的创造力。这些示例项目想法将帮助您探索 FARM 栈的一些功能,例如构建文档自动化流程、创建数据仪表板应用程序以及构建投资组合网站。

传统的投资组合网站

这个项目展示了 FastAPI、React 和 MongoDB 完全能够处理包括关于页面、服务、画廊、联系表单等内容在内的简单投资组合网站。

以下步骤概述了您可能如何创建此类应用程序:

  1. 创建一个漂亮的设计(或者尝试在 Tailwind CSS 中重新创建它)。

  2. 如果您想要快速实现,可以插入 React-Router 或 Next.js。

  3. 使用服务器端生成和图像优化。

  4. 对于内容,定义几个 Pydantic 模型:博客文章、投资组合项目、文章等。然后,创建简单的路由,通过 GET 请求提供服务。

  5. 由于这是一个开发者博客,您甚至不需要创建身份验证系统以及 POSTPUT 路由:与文本相关的内容将直接输入到 MongoDB(Atlas 或 Compass)中,而图像将放入 Cloudinary 的单独文件夹中,并通过 API 直接查询。

  6. 集成 Markdown,一种强大的文本预处理器,可以将简单的文本(Markdown)转换为有效的 HTML。Python 和 ES6/React 都有处理 Markdown 的优秀库,因此您将能够找到一个好的组合。

React-admin 库存

另一个项目想法是创建一个基于 React-admin (marmelab.com/react-admin/) 的库存系统,使用 Auth0 或 Firebase 进行身份验证,并有一个面向公众的界面。React-admin 提供了一个类似于 Django 使用的管理界面,它基于 CRUD 动词:每个暴露 POSTPUTGETDELETE 操作接口的资源(或项目)都可以进行编辑、删除和读取,并且可以创建新的实例。

探索这个包,并尝试思考一些你可能想要管理的集合类型。有一些优秀的工具,例如 Airtable,它提供了可以从你的 FastAPI 路由中调用的 REST API。

使用 Plotly Dash 或 Streamlit 创建探索性数据分析应用程序

使用 Plotly Dash 或 Streamlit,你可以构建可以用来玩转数据的应用程序。要查看这些工具的实际效果,请按照以下步骤操作:

  1. 选择一个你熟悉的数据集。

  2. 创建一个程序化接受数据并彻底测试的输入管道。这些数据可能来自网络或更好的 API 爬虫,或者来自上传 JSON 或 CSV 文件的输入文件。

  3. 清洗数据,预处理它,并将其插入到 MongoDB 数据存储中。

  4. 接下来,根据数据的结构,找出一些有用的过滤器和管理控制,这类似于 Tableau 或 Looker Studio 等企业工具。如果你已经熟悉这样的数据,你将知道可以期待什么。

  5. 然后,你可以打开一个Jupyter笔记本,安装几个可视化库,看看会出现哪些类型的相关性或分组。

  6. 在找到一些有趣的 pandas 驱动的数据处理之后,你只需将它们提取到单独的功能中,进行测试,并将它们集成到 FastAPI 端点中,就可以用 D3.js 或 Chart.js 进行可视化。

  7. 最后,你可以部署你的应用程序,并与管理你团队的朋友分享,向他们展示支持你草案决策的数据。

之前,你已经看到了如何轻松地嵌入使用 scikit-learn 构建的机器学习模型。接下来,你可以尝试嵌入使用 Keras 的神经网络模型,或者尝试一些简单的线性回归。

注意

了解数据可视化和探索框架,如 Streamlit (streamlit.io/) 或 Dash (dash.plotly.com/),将有助于你构建和部署你的数据仪表板应用程序。

文档自动化管道

你是否一直被结构相同的重复性文档所包围?以下是你能做的事情:

  1. 尝试想象一个基于docx-tpl包的文档服务器,这个包允许你定义一个 Word 模板,按照应有的格式进行格式化,然后传递一个包含所有需要在文档中的数据的上下文,例如文本、图像、表格、段落和标题,同时保持最初定义的样式。使用 Excel 可以实现类似的甚至更强大的自动化。你可以使用 pandas 进行复杂的计算、数据透视和将不同的文档合并成一个。

  2. 在创建模板后,考虑一些执行POST请求并将发布的数据保存到 MongoDB 数据库中的 FastAPI 端点,包括数据(例如,文档的标题、作者、数据或其他细节),然后触发 DOCX 或 XLSX 文档渲染。

  3. 将文件保存为可识别的名称(可能通过添加当前时间或 UUID 库来实现唯一性)在目录中,并确保这个目录可以通过 FastAPI 直接(通过静态文件功能)提供服务。如果你计划处理大量的重文档,甚至整个 Nginx 服务器块也可能适用。

  4. 这些文件可以供所有团队成员访问,或者通过 cron 命令行工具或类似的方式直接通过邮件发送。

摘要

本章提供了一些指导方针,以帮助您巩固您的 FARM 栈知识,同时提供了一些您可以自定义并用作自己项目起点的项目想法。使用它们,您可以创建众多简单以及一些复杂的应用程序,以展示该栈的能力和灵活性。关于使用 FARM 栈可以轻松实现的内容,您还可以探索其他功能,例如使用 Next.js 进行服务器端渲染和图像优化,发送电子邮件,以及执行数据可视化。

FARM 栈作为专业开发团队和数据整理者或只需通过网络应用讲述故事的自由职业者的首选栈,拥有未来。通过采用其组件,您可以构建高度交互和响应式的应用程序,以满足各种需求。

在本书中,您已经学习了所有知识和动手实践示例,现在您应该对自己的使用 FARM 栈构建完整功能的应用程序的旅程充满信心。正如任何技术或工具一样,您练习得越多,您就会变得越好!

posted @ 2025-09-18 14:34  绝不原创的飞龙  阅读(113)  评论(0)    收藏  举报