FastAPI-数据科学应用构建指南-全-

FastAPI 数据科学应用构建指南(全)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

FastAPI 是一个用于构建 Python 3.6 及其后续版本 API 的 Web 框架,基于标准的 Python 类型提示。通过本书,你将能够通过实际示例创建快速且可靠的数据科学 API 后端。

本书从 FastAPI 框架的基础知识和相关的现代 Python 编程概念开始。接着将带你深入了解该框架的各个方面,包括其强大的依赖注入系统,以及如何利用这一系统与数据库进行通信、实现身份验证和集成机器学习模型。稍后,你将学习与测试和部署相关的最佳实践,以运行高质量、强健的应用程序。你还将被介绍到 Python 数据科学包的广泛生态系统。随着学习的深入,你将学会如何使用 FastAPI 在 Python 中构建数据科学应用。书中还演示了如何开发快速高效的机器学习预测后端。为此,我们将通过两个涵盖典型 AI 用例的项目:实时物体检测和文本生成图像。

在完成本书的学习后,你不仅将掌握如何在数据科学项目中实现 Python,还将学会如何利用 FastAPI 设计和维护这些项目,以满足高编程标准。

本书读者对象

本书面向那些希望了解 FastAPI 及其生态系统,进而构建数据科学应用的 数据科学家 和 软件开发人员。建议具备基本的数据科学和机器学习概念知识,并了解如何在 Python 中应用这些知识。

本书涵盖内容

第一章Python 开发环境设置,旨在设置开发环境,使你可以开始使用 Python 和 FastAPI。我们将介绍 Python 社区中常用的各种工具,帮助简化开发过程。

第二章Python 编程的特点,向你介绍 Python 编程的具体特点,特别是代码块缩进、控制流语句、异常处理和面向对象范式。我们还将讲解诸如列表推导式和生成器等特性。最后,我们将了解类型提示和异步 I/O 的工作原理。

第三章使用 FastAPI 开发 RESTful API,讲解了使用 FastAPI 创建 RESTful API 的基础:路由、参数、请求体验证和响应。我们还将展示如何使用专门的模块和分离的路由器来正确地组织 FastAPI 项目。

第四章在 FastAPI 中管理 Pydantic 数据模型,更详细地介绍了如何使用 FastAPI 的底层数据验证库 Pydantic 来定义数据模型。我们将解释如何通过类继承实现相同模型的不同变体,避免重复代码。最后,我们将展示如何在这些模型上实现自定义数据验证逻辑。

第五章FastAPI 中的依赖注入,解释了依赖注入是如何工作的,以及我们如何定义自己的依赖关系,以便在不同的路由器和端点之间重用逻辑。

第六章数据库和异步 ORM,演示了如何设置与数据库的连接以读取和写入数据。我们将介绍如何使用 SQLAlchemy 与 SQL 数据库异步工作,以及它们如何与 Pydantic 模型交互。最后,我们还将展示如何与 MongoDB(一种 NoSQL 数据库)一起工作。

第七章在 FastAPI 中管理身份验证和安全性,展示了如何实现一个基本的身份验证系统,以保护我们的 API 端点并返回经过身份验证的用户的相关数据。我们还将讨论关于 CORS 的最佳实践以及如何防范 CSRF 攻击。

第八章在 FastAPI 中定义 WebSocket 以进行双向交互通信,旨在理解 WebSocket 以及如何创建它们并处理 FastAPI 接收到的消息。

第九章使用 pytest 和 HTTPX 异步测试 API,展示了如何为我们的 REST API 端点编写测试。

第十章部署 FastAPI 项目,介绍了在生产环境中平稳运行 FastAPI 应用程序的常见配置。我们还将探索几种部署选项:PaaS 平台、Docker 和传统服务器配置。

第十一章Python 中的数据科学介绍,简要介绍了机器学习,然后介绍了 Python 中数据科学的两个核心库:NumPy 和 pandas。我们还将展示 scikit-learn 库的基础,它是一套用于执行机器学习任务的现成工具。

第十二章使用 FastAPI 创建高效的预测 API 端点,展示了如何使用 Joblib 高效地存储训练好的机器学习模型。接着,我们将其集成到 FastAPI 后端,考虑到 FastAPI 内部的一些技术细节,以实现最大性能。最后,我们将展示如何使用 Joblib 缓存结果。

第十三章使用 WebSockets 和 FastAPI 实现实时目标检测系统,实现了一个简单的应用程序,用于在浏览器中执行目标检测,背后由 FastAPI WebSocket 和 Hugging Face 库中的预训练计算机视觉模型支持。

第十四章使用 Stable Diffusion 模型创建分布式文本到图像 AI 系统,实现了一个能够通过文本提示生成图像的系统,采用流行的 Stable Diffusion 模型。由于这一任务资源消耗大且过程缓慢,我们将学习如何通过工作队列创建一个分布式系统,支持我们的 FastAPI 后端,并在后台执行计算。

第十五章监控数据科学系统的健康和性能,涵盖了额外的内容,帮助您构建稳健的、生产就绪的系统。实现这一目标最重要的方面之一是拥有确保系统正常运行所需的所有数据,并尽早发现问题,以便我们采取纠正措施。在本章中,我们将学习如何设置适当的日志记录设施,以及如何实时监控软件的性能和健康状况。

为了最大限度地利用本书

在本书中,我们将主要使用 Python 编程语言。第一章将解释如何在操作系统上设置合适的 Python 环境。某些示例还涉及使用 JavaScript 运行网页,因此您需要一个现代浏览器,如 Google Chrome 或 Mozilla Firefox。

第十四章中,我们将运行 Stable Diffusion 模型,这需要一台强大的机器。我们建议使用配备 16 GB RAM 和现代 NVIDIA GPU 的计算机,以便能够生成好看的图像。

本书中涉及的软件/硬件 操作系统要求
Python 3.10+ Windows、macOS 或 Linux
Javascript

下载示例代码文件

您可以从 GitHub 上下载本书的示例代码文件,网址是github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition。如果代码有更新,它会在 GitHub 仓库中进行更新。

我们还提供了来自我们丰富书籍和视频目录的其他代码包,您可以在github.com/PacktPublishing/查看。快去看看吧!

使用的约定

本书中使用了一些文本约定。

文本中的代码:表示文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟网址、用户输入和 Twitter 账户名。示例:“显然,如果一切正常,我们将获得一个Person实例,并能够访问正确解析的字段。”

一段代码的设置如下:


from fastapi import FastAPIapp = FastAPI()
@app.get("/users/{type}/{id}")
async def get_user(type: str, id: int):
    return {"type": type, "id": id}

当我们希望引起您注意某段代码时,相关行或项目会设置为粗体:


class PostBase(BaseModel):    title: str
    content: str
    def excerpt(self) -> str:
        return f"{self.content[:140]}..."

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


$ http http://localhost:8000/users/abcHTTP/1.1 422 Unprocessable Entity
content-length: 99
content-type: application/json
date: Thu, 10 Nov 2022 08:22:35 GMT
server: uvicorn

提示或重要说明

以这种方式出现。

联系我们

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

一般反馈:如果您对本书的任何方面有疑问,请通过电子邮件联系我们:customercare@packtpub.com,并在邮件主题中注明书名。

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

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

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

分享您的想法

阅读完 《使用 FastAPI 构建数据科学应用程序(第二版)》 后,我们很想听听您的想法!请 点击这里直接访问亚马逊的评论页面并分享您的反馈。

您的评论对我们和技术社区非常重要,将帮助我们确保提供优质的内容。

下载本书的免费 PDF 副本

感谢您购买本书!

您喜欢随时随地阅读,但又无法携带纸质书籍吗?

您购买的电子书无法与您选择的设备兼容吗?

不用担心,现在购买每本 Packt 书籍时,您可以免费获得该书的 DRM-free PDF 版本。

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

优惠不止于此,您还可以获得独家的折扣、新闻通讯和每天送到您邮箱的精彩免费内容。

按照以下简单步骤即可获得优惠:

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

https://packt.link/free-ebook/9781837632749

  1. 提交您的购买证明

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

第一部分:Python 和 FastAPI 简介

在设置好开发环境后,我们将介绍 Python 的特性,然后开始探索 FastAPI 的基本功能,并运行我们的第一个 REST API。

本节包含以下章节:

  • 第一章Python 开发环境搭建

  • 第二章Python 编程特性

  • 第三章使用 FastAPI 开发 RESTful API

  • 第四章在 FastAPI 中管理 Pydantic 数据模型

  • 第五章FastAPI 中的依赖注入

第一章:Python 开发环境设置

在我们开始 FastAPI 之旅之前,我们需要按照 Python 开发者日常使用的最佳实践和约定来配置 Python 环境,以运行他们的项目。在本章结束时,您将能够在一个受限的环境中运行 Python 项目,并安装第三方依赖项,这样即使您在处理另一个使用不同 Python 版本或依赖项的项目时,也不会产生冲突。

在本章中,我们将讨论以下主要内容:

  • 使用 pyenv 安装 Python 发行版

  • 创建 Python 虚拟环境

  • 使用 pip 安装 Python 包

  • 安装 HTTPie 命令行工具

技术要求

在本书中,我们假设您可以访问基于 Unix 的环境,例如 Linux 发行版或 macOS。

如果您还没有这样做,macOS 用户应该安装 Homebrew 包管理器(brew.sh),它在安装命令行工具时非常有用。

如果您是 Windows 用户,您应该启用 Windows 子系统 LinuxWSL)(docs.microsoft.com/windows/wsl/install-win10)并安装与 Windows 环境并行运行的 Linux 发行版(如 Ubuntu),这将使您能够访问所有必需的工具。目前,WSL 有两个版本:WSL 和 WSL2。根据您的 Windows 版本,您可能无法安装最新版本。然而,如果您的 Windows 安装支持,建议使用 WSL2。

使用 pyenv 安装 Python 发行版

Python 已经捆绑在大多数 Unix 环境中。为了确保这一点,您可以在命令行中运行以下命令,查看当前安装的 Python 版本:


  $ python3 --version

显示的版本输出将根据您的系统有所不同。您可能认为这足以开始,但它带来了一个重要问题:您无法为您的项目选择 Python 版本。每个 Python 版本都引入了新功能和重大变化。因此,能够切换到较新的版本以便为新项目利用新特性,同时还能运行可能不兼容的旧项目是非常重要的。这就是为什么我们需要 pyenv

pyenv 工具(github.com/pyenv/pyenv)帮助您管理并在系统中切换多个 Python 版本。它允许您为整个系统设置默认的 Python 版本,也可以为每个项目设置。

在开始之前,您需要在系统上安装一些构建依赖项,以便 pyenv 可以在您的系统上编译 Python。官方文档提供了明确的指导(github.com/pyenv/pyenv/wiki#suggested-build-environment),但以下是您应该运行的命令:

  1. 安装构建依赖项:

    • 对于 macOS 用户,请使用以下命令:

      
      $ brew install openssl readline sqlite3 xz zlib tcl-tk
      
    • 对于 Ubuntu 用户,使用以下命令:

      $ sudo apt update; sudo apt install make build-essential libssl-dev zlib1g-dev \libbz2-dev libreadline-dev libsqlite3-dev wget curl llvm \libncursesw5-dev xz-utils tk-dev libxml2-dev libxmlsec1-dev libffi-dev liblzma-dev
      

包管理器

brewapt 是通常所说的软件包管理器。它们的作用是自动化系统上软件的安装和管理。因此,你不必担心从哪里下载它们,以及如何安装和卸载它们。这些命令只是告诉包管理器更新其内部的软件包索引,然后安装所需的软件包列表。

  1. 安装 pyenv

    
    $ curl https://pyenv.run | bash
    

macOS 用户提示

如果你是 macOS 用户,你也可以使用 Homebrew 安装它:brew install pyenv

  1. 这将下载并执行一个安装脚本,为你处理所有事情。最后,它会提示你添加一些行到 shell 脚本中,以便 pyenv 能被 shell 正确发现:

    • 如果你的 shell 是 bash(大多数 Linux 发行版和旧版 macOS 的默认 shell),运行以下命令:

      
      zsh (the default in the latest version of macOS), run the following commands:
      
      

      echo 'export PYENV_ROOT="\(HOME/.pyenv"' >> ~/.zshrcecho 'command -v pyenv >/dev/null || export PATH="\)PYENV_ROOT/bin:\(PATH"' >> ~/.zshrcecho 'eval "\)(pyenv init -)"' >> ~/.zshrc

      
      

什么是 shell,我怎么知道自己在使用哪个 shell?

Shell 是你启动命令行时正在运行的底层程序。它负责解释并执行你的命令。随着时间的推移,已经开发出了多种变种程序,如 bashzsh。尽管它们在某些方面有所不同,尤其是在配置文件的命名上,但它们大多是兼容的。要查找你使用的是哪个 shell,你可以运行 echo $``SHELL 命令。

  1. 重新加载你的 shell 配置以应用这些更改:

    
    pyenv tool:
    
    

    $ pyenv>>> pyenv 2.3.6>>> 用法:pyenv []

    
    
  2. 我们现在可以安装我们选择的 Python 发行版。虽然 FastAPI 兼容 Python 3.7 及以上版本,但本书中我们将使用 Python 3.10,这个版本在处理异步范式和类型提示方面更为成熟。本书中的所有示例都是用这个版本测试的,但应该在更新版本中也能顺利运行。让我们安装 Python 3.10:

    
    $ pyenv install 3.10
    

这可能需要几分钟,因为系统需要从源代码编译 Python。

那 Python 3.11 呢?

你可能会想,既然 Python 3.11 已经发布并可用,为什么我们在这里使用 Python 3.10?在写这本书的时候,我们将使用的所有库并不都正式支持最新版本。这就是我们选择使用一个更成熟版本的原因。不过别担心:你在这里学到的内容对未来的 Python 版本仍然是相关的。

  1. 最后,你可以使用以下命令设置默认的 Python 版本:

    
    $ pyenv global 3.10
    

这将告诉系统,除非在特定项目中另有指定,否则默认始终使用 Python 3.10。

  1. 为确保一切正常,运行以下命令检查默认调用的 Python 版本:

    
    $ python --versionPython 3.10.8
    

恭喜!现在你可以在系统上处理任何版本的 Python,并随时切换!

为什么显示的是 3.10.8 而不是仅仅 3.10?

3.10 版本对应 Python 的一个主要版本。Python 核心团队定期发布主要版本,带来新特性、弃用和有时会有破坏性更改。然而,当发布新主要版本时,之前的版本并没有被遗忘:它们继续接收错误和安全修复。这就是版本号第三部分的意义。

当你阅读本书时,你很可能已经安装了更新版本的 Python 3.10,例如 3.10.9,这意味着修复已发布。你可以在这个官方文档中找到更多关于 Python 生命周期以及 Python 核心团队计划支持旧版本的时间的信息:devguide.python.org/versions/

创建 Python 虚拟环境

和今天的许多编程语言一样,Python 的强大来自于庞大的第三方库生态系统,其中当然包括 FastAPI,这些库帮助你非常快速地构建复杂且高质量的软件。pip

默认情况下,当你使用 pip 安装第三方包时,它会为整个系统安装。与一些其他语言不同,例如 Node.js 的 npm,它默认会为当前项目创建一个本地目录来安装这些依赖项。显然,当你在多个 Python 项目中工作,且这些项目的依赖项版本冲突时,这可能会导致问题。它还使得仅检索部署项目所需的依赖项变得困难。

这就是为什么 Python 开发者通常使用虚拟环境的原因。基本上,虚拟环境只是项目中的一个目录,其中包含你的 Python 安装副本和项目的依赖项。这个模式非常常见,以至于用于创建虚拟环境的工具已与 Python 一起捆绑:

  1. 创建一个将包含你的项目的目录:

    
    $ mkdir fastapi-data-science$ cd fastapi-data-science
    

针对使用 WSL 的 Windows 用户的提示

如果你使用的是带有 WSL 的 Windows,我们建议你在 Windows 驱动器上创建工作文件夹,而不是在 Linux 发行版的虚拟文件系统中。这样,你可以在 Windows 中使用你最喜欢的文本编辑器或集成开发环境(IDE)编辑源代码文件,同时在 Linux 中运行它们。

为此,你可以通过 /mnt/c 在 Linux 命令行中访问你的 C: 驱动器。因此,你可以使用常规的 Windows 路径访问个人文档,例如 cd /mnt/c/Users/YourUsername/Documents

  1. 你现在可以创建一个虚拟环境:

    
    $ python -m venv venv
    

基本上,这个命令告诉 Python 运行标准库中的 venv 包,在 venv 目录中创建一个虚拟环境。这个目录的名称是一个约定,但你可以根据需要选择其他名称。

  1. 完成此操作后,你需要激活这个虚拟环境。它会告诉你的 shell 会话使用本地目录中的 Python 解释器和依赖项,而不是全局的。运行以下命令:

    
    $ source venv/bin/activatee
    

完成此操作后,你可能会注意到提示符添加了虚拟环境的名称:


(venv) $

请记住,这个虚拟环境的激活仅对当前会话有效。如果你关闭它或打开其他命令提示符,你将需要重新激活它。这很容易被忘记,但经过一些 Python 实践后,它会变得自然而然。

现在,你可以在项目中安全地安装 Python 包了!

使用 pip 安装 Python 包

正如我们之前所说,pip是内置的 Python 包管理器,它将帮助我们安装第三方库。

关于替代包管理器,如 Poetry、Pipenv 和 Conda 的一些话

在探索 Python 社区时,你可能会听说过像 Poetry、Pipenv 和 Conda 这样的替代包管理器。这些管理器的出现是为了解决pip的一些问题,特别是在子依赖项管理方面。虽然它们是非常好的工具,但我们将在第十章《部署 FastAPI 项目》中看到,大多数云托管平台期望使用标准的pip命令来管理依赖项。因此,它们可能不是 FastAPI 应用程序的最佳选择。

开始之前,让我们先安装 FastAPI 和 Uvicorn:


(venv) $ pip install fastapi "uvicorn[standard]"

我们将在后面的章节中讨论它,但运行 FastAPI 项目需要 Uvicorn。

“标准”在“uvicorn”后面是什么意思?

你可能注意到uvicorn后面方括号中的standard一词。有时,一些库有一些子依赖项,这些依赖项不是必需的来使库工作。通常,它们是为了可选功能或特定项目需求而需要的。方括号在这里表示我们想要安装uvicorn的标准子依赖项。

为了确保安装成功,我们可以打开 Python 交互式 Shell 并尝试导入fastapi包:


(venv) $ python>>> from fastapi import FastAPI

如果没有错误出现,恭喜你,FastAPI 已经安装并准备好使用了!

安装 HTTPie 命令行工具

在进入主题之前,我们还需要安装最后一个工具。正如你可能知道的,FastAPI 主要用于构建REST API。因此,我们需要一个工具来向我们的 API 发送 HTTP 请求。为了做到这一点,我们有几种选择:

  • FastAPI 自动文档

  • Postman:一个 GUI 工具,用于执行 HTTP 请求

  • cURL:广泛使用的命令行工具,用于执行网络请求

即使像 FastAPI 自动文档和 Postman 这样的可视化工具很好用且容易操作,它们有时缺乏一些灵活性,并且可能不如命令行工具高效。另一方面,cURL 是一个非常强大的工具,具有成千上万的选项,但对于测试简单的 REST API 来说,它可能显得复杂和冗长。

这就是我们将介绍 HTTPie 的原因,它是一个旨在简化 HTTP 请求的命令行工具。与 cURL 相比,它的语法更加友好,更容易记住,因此你可以随时运行复杂的请求。而且,它内置支持 JSON 和语法高亮。由于它是一个 命令行界面 (CLI) 工具,我们保留了命令行的所有优势:例如,我们可以直接将 JSON 文件通过管道传输,并作为 HTTP 请求的主体发送。它可以通过大多数包管理器安装:

  • macOS 用户可以使用此命令:

    
    $ brew install httpie
    
  • Ubuntu 用户可以使用此命令:

    
    $ sudo apt-get update && sudo apt-get install httpie
    

让我们看看如何对一个虚拟 API 执行简单请求:

  1. 首先,让我们获取数据:

    
    $ http GET https://603cca51f4333a0017b68509.mockapi.io/todos>>>HTTP/1.1 200 OKAccess-Control-Allow-Headers: X-Requested-With,Content-Type,Cache-Control,access_tokenAccess-Control-Allow-Methods: GET,PUT,POST,DELETE,OPTIONSAccess-Control-Allow-Origin: *Connection: keep-aliveContent-Length: 58Content-Type: application/jsonDate: Tue, 08 Nov 2022 08:28:30 GMTEtag: "1631421347"Server: CowboyVary: Accept-EncodingVia: 1.1 vegurX-Powered-By: Express[    {        "id": "1",        "text": "Write the second edition of the book"    }]
    

如你所见,你可以使用 http 命令调用 HTTPie,简单地输入 HTTP 方法和 URL。它以清晰且格式化的方式输出 HTTP 头和 JSON 请求体。

  1. HTTPie 还支持非常快速地在请求体中发送 JSON 数据,而无需手动格式化 JSON:

    
    $ http -v POST https://603cca51f4333a0017b68509.mockapi.io/todos text="My new task"POST /todos HTTP/1.1Accept: application/json, */*;q=0.5Accept-Encoding: gzip, deflateConnection: keep-aliveContent-Length: 23Content-Type: application/jsonHost: 603cca51f4333a0017b68509.mockapi.ioUser-Agent: HTTPie/3.2.1{"text": "My new task"}HTTP/1.1 201 CreatedAccess-Control-Allow-Headers: X-Requested-With,Content-Type,Cache-Control,access_tokenAccess-Control-Allow-Methods: GET,PUT,POST,DELETE,OPTIONSAccess-Control-Allow-Origin: *Connection: keep-aliveContent-Length: 31Content-Type: application/jsonDate: Tue, 08 Nov 2022 08:30:10 GMTServer: CowboyVary: Accept-EncodingVia: 1.1 vegurX-Powered-By: Express{    "id": "2",    "text": "My new task"}
    

只需输入属性名称及其值,用 = 分隔,HTTPie 就会理解这是请求体的一部分(JSON 格式)。注意,这里我们指定了 -v 选项,告诉 HTTPie 在响应之前 输出请求,这对于检查我们是否正确指定了请求非常有用。

  1. 最后,让我们看看如何指定 请求头部

    
    $ http -v GET https://603cca51f4333a0017b68509.mockapi.io/todos "My-Header: My-Header-Value"GET /todos HTTP/1.1Accept: */*Accept-Encoding: gzip, deflateConnection: keep-aliveHost: 603cca51f4333a0017b68509.mockapi.ioMy-Header: My-Header-ValueUser-Agent: HTTPie/3.2.1HTTP/1.1 200 OKAccess-Control-Allow-Headers: X-Requested-With,Content-Type,Cache-Control,access_tokenAccess-Control-Allow-Methods: GET,PUT,POST,DELETE,OPTIONSAccess-Control-Allow-Origin: *Connection: keep-aliveContent-Length: 90Content-Type: application/jsonDate: Tue, 08 Nov 2022 08:32:12 GMTEtag: "1849016139"Server: CowboyVary: Accept-EncodingVia: 1.1 vegurX-Powered-By: Express[    {        "id": "1",        "text": "Write the second edition of the book"    },    {        "id": "2",        "text": "My new task"    }]
    

就是这样!只需输入你的头部名称和值,用冒号分隔,告诉 HTTPie 这是一个头部。

概述

现在你已经掌握了所有必要的工具和配置,可以自信地运行本书中的示例以及所有未来的 Python 项目。理解如何使用 pyenv 和虚拟环境是确保在切换到另一个项目或在其他人的代码上工作时一切顺利的关键技能。你还学会了如何使用 pip 安装第三方 Python 库。最后,你了解了如何使用 HTTPie,这是一种简单高效的方式来运行 HTTP 查询,能够提高你在测试 REST API 时的生产力。

在下一章,我们将重点介绍 Python 作为编程语言的一些独特之处,并理解什么是 Pythonic

第二章:Python 编程特性

Python 语言的设计重点是强调代码的可读性。因此,它提供了语法和结构,使开发者能够用几行易读的代码快速表达复杂的概念。这使得它与其他编程语言有很大的不同。

本章的目标是让你熟悉 Python 的特性,但我们希望你已经具备一定的编程经验。我们将首先介绍 Python 的基础知识、标准类型和流程控制语法。你还将了解列表推导式和生成器的概念,这些是处理和转换数据序列的非常强大的方法。你还会看到,Python 可以作为面向对象语言来使用,依然通过非常轻量且强大的语法。我们在继续之前,还将回顾类型提示和异步 I/O 的概念,这在 Python 中相对较新,但它们是 FastAPI 框架的核心。

在本章中,我们将覆盖以下主要内容:

  • Python 编程基础

  • 列表推导式和生成器

  • 类和对象

  • 使用 mypy 进行类型提示和类型检查

  • 异步 I/O

技术要求

你需要一个 Python 虚拟环境,正如我们在第一章中设置的那样,Python 开发 环境设置

你可以在本书的 GitHub 仓库中找到本章的所有代码示例:github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter02

Python 编程基础

首先,让我们回顾一下 Python 的一些关键特点:

  • 它是一种解释型语言。与 C 或 Java 等语言不同,Python 不需要编译,这使得我们可以交互式地运行 Python 代码。

  • 它是动态类型的。值的类型是在运行时确定的。

  • 它支持多种编程范式:过程化编程、面向对象编程和函数式编程。

这使得 Python 成为一种非常多用途的语言,从简单的自动化脚本到复杂的数据科学项目。

现在,让我们编写并运行一些 Python 代码吧!

运行 Python 脚本

正如我们所说,Python 是一种解释型语言。因此,运行一些 Python 代码最简单和最快的方法就是启动一个交互式 shell。只需运行以下命令启动一个会话:


$ pythonPython 3.10.8 (main, Nov    8 2022, 08:55:03) [Clang 14.0.0 (clang-1400.0.29.202)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>>

这个 shell 使得运行一些简单语句并进行实验变得非常容易:


>>> 1 + 12
>>> x = 100
>>> x * 2
200

要退出 shell,请使用 Ctrl + D 键盘快捷键。

显然,当你开始有更多语句,或者仅仅是希望保留你的工作以便以后重用时,这会变得繁琐。Python 脚本保存为 .py 扩展名的文件。让我们在项目目录中创建一个名为 chapter2_basics_01.py 的文件,并添加以下代码:

chapter02_basics_01.py


print("Hello world!")x = 100
print(f"Double of {x} is {x * 2}")

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/blob/main/chapter02/chapter02_basics_01.py

简单来说,这个脚本在控制台打印 Hello world,将值 100 赋给名为 x 的变量,并打印一个包含 x 和其双倍值的字符串。要运行它,只需将脚本的路径作为参数传递给 Python 命令:


$ python chapter2_basics_01.pyHello world!
Double of 100 is 200

f-strings

你可能已经注意到字符串以 f 开头。这个语法被称为 f-strings,是一种非常方便且简洁的字符串插值方式。在其中,你可以简单地将变量插入大括号中,它们会自动转换为字符串来构建最终的字符串。我们将在示例中经常使用它。

就这样!你现在已经能够编写并运行简单的 Python 脚本。接下来,让我们深入了解 Python 的语法。

缩进很重要

Python 最具标志性的特点之一是代码块不是像许多其他编程语言那样通过大括号来定义,而是通过 空格缩进 来区分。这听起来可能有些奇怪,但它是 Python 可读性哲学的核心。让我们看看如何编写一个脚本来查找列表中的偶数:

chapter02_basics_02.py


numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]even = []
for number in numbers:
        if number % 2 == 0:
                even.append(number)
print(even)    # [2, 4, 6, 8, 10]

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/blob/main/chapter02/chapter02_basics_02.py

在这个脚本中,我们定义了 numbers,一个从 1 到 10 的数字列表,和 even,一个空列表,用于存储偶数。

接下来,我们定义了一个 for 循环语句来遍历 numbers 中的每个元素。正如你所看到的,我们使用冒号 : 打开一个块,换行并开始在下一行写入语句,缩进一个级别。

下一行是一个条件语句,用来检查当前数字的奇偶性。我们再次使用冒号 : 来打开一个代码块,并在下一行添加一个额外的缩进级别。这个语句将偶数添加到偶数列表中。

之后,接下来的语句没有缩进。这意味着我们已经退出了 for 循环块;这些语句应该在迭代完成后执行。

让我们来运行一下:


$ python chapter02_basics_02.py[2, 4, 6, 8, 10]

缩进风格和大小

你可以选择你喜欢的缩进风格(制表符或空格)和大小(2、4、6 等),唯一的约束是你应该在一个代码块内保持一致性 within。然而,根据惯例,Python 开发者通常使用 四个空格的缩进

Python 的这一特性可能听起来有些奇怪,但经过一些练习后,你会发现它能强制执行清晰的格式,并大大提高脚本的可读性。

现在我们将回顾一下内置类型和数据结构。

使用内置类型

Python 在标量类型方面相当传统。它有六种标量类型:

  • int,用于存储x = 1

  • float,表示x = 1.5

  • complex,表示x = 1 + 2j

  • bool,表示TrueFalse

  • str,表示x = "``abc"

  • NoneType,表示x = None

值得注意的是,Python 中的int值和str值相加会引发错误,正如你在下面的示例中所看到的:


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

但仍然,添加一个int值和一个float值会自动将结果向上转型为float


>>> 1 + 1.52.5

正如你可能已经注意到的,Python 在这些标准类型方面相当传统。现在,让我们看看基本数据结构是如何处理的。

处理数据结构——列表、元组、字典和集合

除了标量类型,Python 还提供了方便的数据结构:一种数组结构,当然在 Python 中称为列表,但也有元组字典集合,这些在很多情况下都非常方便。我们从列表开始。

列表

列表在 Python 中等同于经典的数组结构。定义一个列表非常简单:


>>> l = [1, 2, 3, 4, 5]

正如你所见,将一组元素包裹在方括号中表示一个列表。你当然可以通过索引访问单个元素:


>>> l[0]1
>>> l[2]
3

它还支持-1索引表示最后一个元素,-2表示倒数第二个元素,以此类推:


>>> l[-1]5
>>> l[-4]
2

另一个有用的语法是切片,它可以快速地让你获取一个子列表:


>>> l[1:3][2, 3]

第一个数字是起始索引(包含),第二个是结束索引(不包含),用冒号分隔。你可以省略第一个数字;在这种情况下,默认是0


>>> l[:3][1, 2, 3]

你也可以省略第二个数字;在这种情况下,默认使用列表的长度:


>>> l[1:][2, 3, 4, 5]

最后,这种语法还支持一个第三个参数来指定步长。它可以用于选择列表中的每第二个元素:


>>> l[::2][1, 3, 5]

使用这种语法的一个有用技巧是使用-1来反转列表:


>>> l[::-1][5, 4, 3, 2, 1]

列表是可变的。这意味着你可以重新赋值元素或添加新的元素:


>>> l[1] = 10>>> l
[1, 10, 3, 4, 5]
>>> l.append(6)
[1, 10, 3, 4, 5, 6]

这与它们的“表亲”元组不同,元组是不可变的

元组

元组与列表非常相似。它们不是用方括号定义,而是使用圆括号:


>>> t = (1, 2, 3, 4, 5)

它们支持与列表相同的语法来访问元素或切片:


>>> t[2]3
>>> t[1:3]
(2, 3)
>>> t[::-1]
(5, 4, 3, 2, 1)

然而,元组是不可变的。你不能重新赋值元素或添加新的元素。尝试这样做会引发错误:


>>> t[1] = 10Traceback (most recent call last):
    File "<stdin>", line 1, in <module>
TypeError: 'tuple' object does not support item assignment
>>> t.append(6)
Traceback (most recent call last):
    File "<stdin>", line 1, in <module>
AttributeError: 'tuple' object has no attribute 'append'

一个常见的用法是将其用于返回多个值的函数。在下面的示例中,我们定义了一个函数来计算并返回欧几里得除法的商和余数:

chapter02_basics_03.py


def euclidean_division(dividend, divisor):        quotient = dividend // divisor
        remainder = dividend % divisor
        return (quotient, remainder)

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/blob/main/chapter02/chapter02_basics_03.py

这个函数简单地返回商和余数,它们被包裹在一个元组中。现在我们来计算32的欧几里得除法:

chapter02_basics_03.py


t = euclidean_division(3, 2)print(t[0])    # 1
print(t[1])    # 1

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/blob/main/chapter02/chapter02_basics_03.py

在这种情况下,我们将结果赋值给一个名为t的元组,并通过索引来提取商和余数。然而,我们可以做得更好。让我们计算 424 的欧几里得除法:

chapter02_basics_03.py


q, r = euclidean_division(42, 4)print(q)    # 10
print(r)    # 2

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/blob/main/chapter02/chapter02_basics_03.py

你可以看到我们直接将商和余数分别赋值给qr变量。这种语法称为t是一个元组,它是不可变的,所以你不能重新赋值。而 qr 是新的变量,因此是可变的。

字典

字典是 Python 中也广泛使用的数据结构,用于将键映射到值。它是通过花括号定义的,其中键和值由冒号分隔:


>>> d = {"a": 1, "b": 2, "c": 3}

元素可以通过键访问:


>>> d["a"]1

字典是可变的,因此你可以在映射中重新赋值或添加元素:


>>> d["a"] = 10>>> d
{'a': 10, 'b': 2, 'c': 3}
>>> d["d"] = 4
>>> d
{'a': 10, 'b': 2, 'c': 3, 'd': 4}

集合

集合是一个便捷的数据结构,用于存储唯一项的集合。它是通过花括号定义的:


>>> s = {1, 2, 3, 4, 5}

元素可以添加到集合中,但结构确保元素只出现一次:


>>> s.add(1)>>> s
{1, 2, 3, 4, 5}
>>> s.add(6)
{1, 2, 3, 4, 5, 6}

也提供了方便的方法来执行集合之间的并集或交集等操作:


>>> s.union({4, 5, 6}){1, 2, 3, 4, 5, 6}
>>> s.intersection({4, 5, 6})
{4, 5}

这就是本节对 Python 数据结构的概述。你在程序中可能会频繁使用它们,所以花些时间熟悉它们。显然,我们没有涵盖它们的所有方法和特性,但你可以查看官方的 Python 文档以获取详尽的信息:docs.python.org/3/library/stdtypes.html

现在让我们来谈谈 Python 中可用的不同类型的运算符,这些运算符允许我们对这些数据进行一些逻辑操作。

执行布尔逻辑及其他几个运算符

可预测地,Python 提供了运算符来执行布尔逻辑。然而,我们也会看到一些不太常见但使得 Python 在工作中非常高效的运算符。

执行布尔逻辑

布尔逻辑通过andornot关键字来执行。让我们回顾一些简单的例子:


>>> x = 10>>> x > 0 and x < 100
True
>>> x > 0 or (x % 2 == 0)
True
>>> not (x > 0)
False

你可能会在程序中经常使用它们,尤其是在条件语句块中。现在让我们回顾一下身份运算符。

检查两个变量是否相同

isis not 身份运算符检查两个变量是否 指向 同一个对象。这与比较运算符 ==!= 不同,后者检查的是两个变量是否具有相同的

在 Python 内部,变量是通过指针存储的。身份运算符的目标就是检查两个变量是否实际上指向内存中的同一个对象。让我们来看一些例子:


>>> a = [1, 2, 3]>>> b = [1, 2, 3]
>>> a is b
False

即使 ab 列表是相同的,它们在内存中并不是同一个对象,因此 a is b 为假。但是,a == b 为真。让我们看看如果将 a 赋值给 b 会发生什么:


>>> a = [1, 2, 3]>>> b = a
>>> a is b
True

在这种情况下,b 变量将指向与 a 相同的对象,也就是内存中的同一列表。因此,身份运算符的结果为真。

“is None” 还是 “== None”?

要检查一个变量是否为 null,你可以写 a == None。虽然大多数时候它能工作,但通常建议写 a is None

为什么?在 Python 中,类可以实现自定义的比较运算符,因此 a == None 的结果在某些情况下可能是不可预测的,因为类可以选择为 None 值附加特殊含义。

现在我们来回顾一下成员运算符。

检查数据结构中是否存在某个值

成员运算符,innot in,对于检查元素是否存在于诸如列表或字典等数据结构中非常有用。它们在 Python 中是惯用的,使得该操作非常高效且易于编写。让我们来看一些例子:


>>> l = [1, 2, 3]>>> 2 in l
True
>>> 5 not in l
True

使用成员运算符,我们可以通过一个语句检查元素是否存在于列表中。它也适用于元组和集合:


>>> t = (1, 2, 3)>>> 2 in t
True
>>> s = {1, 2, 3}
>>> 2 in s
True

最后,它同样适用于字典。在这种情况下,成员运算符检查的是 是否存在,而不是值:


>>> d = {"a": 1, "b": 2, "c": 3}>>> "b" in d
True
>>> 3 in d
False

我们现在已经清楚了这些常见的操作。接下来,我们将通过条件语句来实际应用它们。

控制程序的流程

没有控制流语句,一个编程语言就不算是一个编程语言了。再次提醒你,Python 在这方面与其他语言有所不同。我们从条件语句开始。

有条件地执行操作 – if, elif 和 else

传统上,这些语句用于根据布尔条件执行一些逻辑。在下面的例子中,我们将考虑一个包含电子商务网站订单信息的字典。我们将编写一个函数,基于当前状态将订单状态更新为下一个步骤:

chapter02_basics_04.py


def forward_order_status(order):        if order["status"] == "NEW":
                order["status"] = "IN_PROGRESS"
        elif order["status"] == "IN_PROGRESS":
                order["status"] = "SHIPPED"
        else:
                order["status"] = "DONE"
        return order

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/blob/main/chapter02/chapter02_basics_04.py

第一个条件用 if 来表示,后跟一个布尔条件。然后我们打开一个缩进块,正如我们在本章的 缩进很重要 部分所解释的那样。

替代条件被标记为elif(而不是else if),回退块被标记为else。当然,如果你不需要替代条件或回退条件,这些都是可选的。

另外值得注意的是,与许多其他语言不同,Python 没有提供switch语句。

在一个迭代器上重复操作——for循环语句

现在我们将讨论另一个经典的控制流语句:for循环。你可以使用for循环语句在一个序列上重复操作。

我们已经在本章的缩进很重要部分看到了for循环的示例。如你所理解的,这条语句对于重复执行代码块非常有用。

你可能也已经注意到它的工作方式与其他语言有些不同。通常,编程语言会像这样定义for循环:for (i = 0; i <= 10; i++)。它们让你负责定义和控制用于迭代的变量。

Python 不是这样工作的。相反,它希望你将一个for循环提供给循环体。让我们看几个例子:


>>> for i in [1,2,3]:...         print(i)
...
1
2
3
>>> for k in {"a": 1, "b": 2, "c": 3}:
...         print(k)
...
a
b
c

但是如果你只想迭代某个特定次数怎么办?幸运的是,Python 内置了生成一些有用迭代器的函数。最著名的就是range,它精确地创建了一个数字序列。让我们看看它是如何工作的:


>>> for i in range(3):...         print(i)
...
0
1
2

range将根据你在第一个参数中提供的大小生成一个序列,从零开始。

你也可以通过指定两个参数来更精确地控制:起始索引(包含)和最后索引(不包含):


>>> for i in range(1, 3):...         print(i)
...
1
2

最后,你甚至可以提供一个步长作为第三个参数:


>>> for i in range(0, 5, 2):...         print(i)
...
0
2
4

请注意,这种语法与我们之前在本章中列表元组部分看到的切片语法非常相似。

range输出不是一个列表

一个常见的误解是认为range返回一个列表。实际上,它是一个Sequence对象,仅存储开始结束步长参数。这就是为什么你可以写range(1000000000)而不会让你的系统内存崩溃:数十亿个元素并不会一次性分配到内存中。

正如你所见,Python 中的for循环语法相当简单易懂,并且强调可读性。接下来我们将讨论它的“亲戚”——while循环。

重复操作直到满足条件——while循环语句

经典的while循环在 Python 中也可以使用。冒昧地说,这个语句并没有什么特别的地方。传统上,这个语句允许你重复执行指令直到满足条件。我们将回顾一个示例,其中我们使用while循环来获取分页元素直到我们到达结束:

chapter02_basics_05.py


def retrieve_page(page):        if page > 3:
                return {"next_page": None, "items": []}
        return {"next_page": page + 1, "items": ["A", "B", "C"]}
items = []
page = 1
while page is not None:
        page_result = retrieve_page(page)
        items += page_result["items"]
        page = page_result["next_page"]
print(items)    # ["A", "B", "C", "A", "B", "C", "A", "B", "C"]

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/blob/main/chapter02/chapter02_basics_05.py

retrieve_page函数是一个虚拟函数,它返回一个字典,其中包含传递给它的页面的项目以及下一页的页码,或者如果到达最后一页则返回NoneA priori,我们并不知道有多少页。因此,我们反复调用retrieve_page,直到页面是None。在每次迭代中,我们将当前页面的项目保存到累加器items中。

当你处理第三方 REST API 并希望检索所有可用项目时,这种使用场景非常常见,while 循环对此非常有帮助。

最后,有一些情况下你希望提前结束循环或跳过某次迭代。为了解决这个问题,Python 实现了经典的breakcontinue语句。

定义函数

现在我们知道如何使用常见的运算符并控制程序的流程,让我们将它放入可重用的逻辑中。正如你可能已经猜到的,我们将学习函数以及如何定义它们。我们在之前的一些例子中已经看到了它们,但让我们更正式地介绍它们。

在 Python 中,函数是通过def关键字定义的,后面跟着函数的名称。然后,你会看到在括号中列出支持的参数,在冒号后面是函数体的开始。我们来看一个简单的例子:


>>> def f(a):...         return a
...
>>> f(2)
2

就是这样!Python 也支持为参数设置默认值:


>>> def f(a, b = 1):...         return a, b
...
>>> f(2)
(2, 1)
>>> f(2, 3)
(2, 3)

调用函数时,你可以通过参数的名称指定参数的值:


>>> f(a=2, b=3)(2, 3)

这些参数被称为关键字参数。如果你有多个默认参数,但只希望设置其中一个,它们特别有用:


>>> def f(a = 1, b = 2, c = 3):...         return a, b, c
...
>>> f(c=1)
(1, 2, 1)

函数命名

按约定,函数应该使用my_wonderful_function这种格式命名,而不是MyWonderfulFunction

但不仅如此!实际上,你可以定义接受动态数量参数的函数。

动态接受参数的*args 和**kwargs

有时,你可能需要一个支持动态数量参数的函数。这些参数会在运行时在你的函数逻辑中处理。为了做到这一点,你必须使用*args**kwargs语法。让我们定义一个使用这种语法的函数,并打印这些参数的值:


>>> def f(*args, **kwargs):...         print("args", args)
...         print("kwargs", kwargs)
...
>>> f(1, 2, 3, a=4, b=5)
args (1, 2, 3)
kwargs {'a': 4, 'b': 5}

正如你所看到的,标准参数被放置在一个元组中,顺序与它们被调用时的顺序相同。另一方面,关键字参数被放置在一个字典中,键是参数的名称。然后由你来使用这些数据来执行你的逻辑!

有趣的是,你可以将这两种方法混合使用,以便同时拥有硬编码参数和动态参数:


>>> def f(a, *args):...         print("a", a)
...         print("arg", args)
...
>>> f(1, 2, 3)
a 1
arg (2, 3)

做得好!你已经学会了如何在 Python 中编写函数来组织程序的逻辑。接下来的步骤是将这些函数组织到模块中,并将它们导入到其他模块中以便使用!

编写和使用包与模块

你可能已经知道,除了小脚本之外,你的源代码不应该存放在一个包含成千上万行的大文件中。相反,你应该将它拆分成逻辑上合理且易于维护的块。这正是包和模块的用途!我们将看看它们是如何工作的,以及你如何定义自己的模块。

首先,Python 提供了一组自己的模块——标准库,这些模块可以直接在程序中导入:


>>> import datetime>>> datetime.date.today()
datetime.date(2022, 12, 1)

仅使用import关键字,你就可以使用datetime模块,并通过引用其命名空间datetime.date来访问其所有内容,datetime.date是用于处理日期的内置类。然而,你有时可能希望显式地导入该模块的一部分:


>>> from datetime import date>>> date.today()
datetime.date(2022, 12, 1)

在这里,我们显式地导入了date类以直接使用它。相同的原则也适用于通过pip安装的第三方包,例如 FastAPI。

使用现有的包和模块很方便,但编写自己的模块更好。在 Python 中,模块是一个包含声明的单个文件,但也可以包含在首次导入模块时执行的指令。你可以在以下示例中找到一个非常简单模块的定义:

chapter02_basics_module.py


def module_function():        return "Hello world"
print("Module is loaded")

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/blob/main/chapter02/chapter02_basics_module.py

这个模块只包含一个函数module_function和一个print语句。在你的项目根目录下创建一个包含该代码的文件,并将其命名为module.py。然后,打开一个 Python 解释器并运行以下命令:


>>> import moduleModule is loaded

请注意,print语句在导入时已经执行。现在你可以使用该函数了:


>>> module.module_function()'Hello world'

恭喜!你刚刚编写了你的第一个 Python 模块!

现在,让我们来看一下如何构建一个。包是将模块组织在层次结构中的一种方式,你可以通过它们的命名空间导入这些模块。

在你的项目根目录下,创建一个名为package的目录。在其中,再创建一个名为subpackage的目录,并将module.py移入该目录。你的项目结构应该像图 2.1所示:

图 2.1 – Python 包示例层次结构

图 2.1 – Python 包示例层次结构

然后,你可以使用完整的命名空间导入你的模块:


>>> import package.subpackage.moduleModule is loaded

它有效!然而,为了定义一个合适的 Python 包,强烈推荐在每个包和子包的根目录下创建一个空的__init__.py文件。在旧版本的 Python 中,必须创建该文件才能让解释器识别一个包。在较新的版本中,这已变为可选项,但带有__init__.py文件的包(一个包)和没有该文件的包(一个命名空间包)之间实际上存在一些微妙的区别。我们在本书中不会进一步解释这个问题,但如果你希望了解更多细节,可以查阅关于命名空间包的文档:packaging.python.org/en/latest/guides/packaging-namespace-packages/

因此,你通常应该始终创建__init__.py文件。在我们的示例中,最终的项目结构应该如下所示:

图 2.2 – 带有文件的 Python 包层次结构

图 2.2 – 带有__init__.py文件的 Python 包层次结构

值得注意的是,即使是空的__init__.py文件也是完全可以的,实际上你也可以在其中编写一些代码。在这种情况下,当你第一次导入该包或其子模块时,这些代码会被执行。这对于执行一些包的初始化逻辑非常有用。现在你已经对如何编写一些 Python 代码有了很好的概览。可以自由编写一些小脚本来熟悉它独特的语法。接下来我们将探讨一些关于语言的高级话题,这些将对我们在 FastAPI 之旅中的学习大有裨益。

在序列上操作 – 列表推导式和生成器

在本节中,我们将介绍可能是 Python 中最具典型性的构造:列表推导式和生成器。你将看到,它们对于用最简洁的语法读取和转换数据序列非常有用。

列表推导式

在编程中,一个非常常见的任务是将一个序列(比如说,列表)转换成另一个序列,例如,过滤或转换元素。通常,你会像我们在本章之前的示例中那样编写这样的操作:

chapter02_basics_02.py


numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]even = []
for number in numbers:
        if number % 2 == 0:
                even.append(number)
print(even)    # [2, 4, 6, 8, 10]

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/blob/main/chapter02/chapter02_basics_02.py

使用这种方法,我们简单地遍历每个元素,检查条件,并在元素满足条件时将其添加到累加器中。

为了进一步提升可读性,Python 支持一种简洁的语法,使得只用一句话就能执行这个操作:列表推导式。让我们看看之前的示例在这种语法下的样子:

chapter02_list_comprehensions_01.py


numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]even = [number for number in numbers if number % 2 == 0]
print(even)    # [2, 4, 6, 8, 10]

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/blob/main/chapter02/chapter02_list_comprehensions_01.py

就这样!基本上,列表推导式通过打包一个for循环并将其用方括号包裹来工作。要添加到结果列表的元素出现在前面,然后是迭代。我们可以选择性地添加一个条件,就像这里一样,用来筛选列表输入中的一些元素。

实际上,结果元素可以是任何有效的 Python 表达式。在下面的示例中,我们使用random标准模块的randint函数生成一个随机整数列表:

chapter02_list_comprehensions_02.py


from random import randint, seedseed(10)    # Set random seed to make examples reproducible
random_elements = [randint(1, 10) for I in range(5)]
print(random_elements)    # [10, 1, 7, 8, 10]

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/blob/main/chapter02/chapter02_list_comprehensions_02.py

这种语法在 Python 程序员中被广泛使用,你可能会非常喜欢它。这个语法的好处在于它也适用于集合字典。很简单,只需将方括号替换为大括号即可生成集合:

chapter02_list_comprehensions_03.py


from random import randint, seedseed(10)    # Set random seed to make examples reproducible
random_unique_elements = {randint(1, 10) for i in range(5)}
print(random_unique_elements)    # {8, 1, 10, 7}

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/blob/main/chapter02/chapter02_list_comprehensions_03.py

要创建一个字典,指定键和值,并用冒号分隔:

chapter02_list_comprehensions_04.py


from random import randint, seedseed(10)    # Set random seed to make examples reproducible
random_dictionary = {i: randint(1, 10) for i in range(5)}
print(random_dictionary)    # {0: 10, 1: 1, 2: 7, 3: 8, 4: 10}

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/blob/main/chapter02/chapter02_list_comprehensions_04.pyeee

生成器

你可能认为,如果用圆括号替换方括号,你可以得到一个元组。实际上,你会得到一个生成器对象。生成器和列表推导式之间的主要区别在于,生成器的元素是按需生成的,而不是一次性计算并存储在内存中的。你可以把生成器看作是生成值的食谱。

正如我们所说,生成器可以通过使用与列表推导式相同的语法,并加上圆括号来定义:

chapter02_list_comprehensions_05.py


numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]even_generator = (number for number in numbers if number % 2 == 0)
even = list(even_generator)
even_bis = list(even_generator)
print(even)    # [2, 4, 6, 8, 10]
print(even_bis)    # []

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/blob/main/chapter02/chapter02_list_comprehensions_05.py

在这个例子中,我们定义了even_generator来输出numbers列表中的偶数。然后,我们使用这个生成器调用list构造函数,并将其赋值给名为even的变量。这个构造函数会耗尽传入参数的迭代器,并构建一个正确的列表。我们再执行一次,并将其赋值给even_bis

正如你所看到的,even是一个包含所有偶数的列表。然而,even_bis是一个列表。这个简单的例子是为了向你展示生成器只能使用一次。一旦所有值都生成完毕,生成器就结束了。

这非常有用,因为你可以开始迭代生成器,暂停去做其他事情,然后再继续迭代。

创建生成器的另一种方式是通过定义2作为传入参数的限制:

chapter02_list_comprehensions_06.py


def even_numbers(max):        for i in range(2, max + 1):
                if i % 2 == 0:
                        yield i
even = list(even_numbers(10))
print(even)    # [2, 4, 6, 8, 10]

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/blob/main/chapter02/chapter02_list_comprehensions_06.py

正如你在这个函数中看到的,我们使用了yield关键字代替了return。当解释器执行到这个语句时,它会暂停函数的执行,并将值传递给生成器的消费者。当主程序请求另一个值时,函数会恢复执行以便再次生成值。

这使我们能够实现复杂的生成器,甚至是那些在生成过程中会输出不同类型值的生成器。生成器函数的另一个有趣的特性是,它们允许我们在生成完所有值之后执行一些指令。让我们在刚刚复习过的函数末尾添加一个print语句:

chapter02_list_comprehensions_07.py


def even_numbers(max):        for i in range(2, max + 1):
                if i % 2 == 0:
                        yield i
        print("Generator exhausted")
even = list(even_numbers(10))
print(even)

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/blob/main/chapter02/chapter02_list_comprehensions_07.py

如果你在 Python 解释器中执行它,你将得到以下输出:


$ python chapter02_list_comprehensions_07.pyGenerator exhausted
[2, 4, 6, 8, 10]

我们在输出中看到Generator exhausted,这意味着我们的代码在最后一个yield语句之后已经正确执行。

这特别有用,当你想在生成器耗尽后执行一些清理操作时:关闭连接、删除临时文件等等。

编写面向对象的程序

正如我们在本章的第一部分所说,Python 是一种多范式语言,其中一个范式是 面向对象编程。在这一部分,我们将回顾如何定义类,以及如何实例化和使用对象。你会发现 Python 的语法再次是非常简洁的。

定义类

在 Python 中定义一个类非常简单:使用 class 关键字,输入类名,然后开始一个新块。你可以像定义普通函数一样在其下定义方法。让我们来看一个例子:

chapter02_classes_objects_01.py


class Greetings:        def greet(self, name):
                return f"Hello, {name}"
c = Greetings()
print(c.greet("John"))    # "Hello, John"

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/blob/main/chapter02/chapter02_classes_objects_01.py

请注意,每个方法的第一个参数必须是 self,它是当前对象实例的引用(相当于其他语言中的 this)。

要实例化一个类,只需像调用函数一样调用类,并将其赋值给一个变量。然后,你可以通过点表示法访问方法。

类和方法命名

按照惯例,类名应使用 MyWonderfulClass 而不是 my_wonderful_class。方法名应使用蛇形命名法,就像普通函数一样。

显然,你也可以设置 __init__ 方法,其目标是初始化值:

chapter02_classes_objects_02.py


class Greetings:        def __init__(self, default_name):
                self.default_name = default_name
        def greet(self, name=None):
                return f"Hello, {name if name else self.default_name}"
c = Greetings("Alan")
print(c.default_name)    # "Alan"
print(c.greet())    # "Hello, Alan"
print(c.greet("John"))    # "Hello, John"

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/blob/main/chapter02/chapter02_classes_objects_02.py

在这个例子中,__init__ 允许我们设置一个 default_name 属性,如果在参数中没有提供名称,greet 方法将使用该属性。如你所见,你可以通过点表示法轻松访问这个属性。

不过要小心:__init__ 并不是构造函数。在典型的面向对象语言中,构造函数是用于实际在内存中创建对象的方法。在 Python 中,当 __init__ 被调用时,对象已经在内存中创建(请注意我们可以访问 self 实例)。实际上,确实有一个方法用于定义构造函数,__new__,但在常见的 Python 程序中它很少被使用。

私有方法和属性

在 Python 中,并不存在 私有 方法或属性的概念。一切都可以从外部访问。然而,按照惯例,你可以通过在私有方法和属性前加下划线来 表示 它们应该被视为私有:_private_method

现在你已经掌握了 Python 中面向对象编程的基础!接下来我们将重点讲解魔法方法,它们可以让我们对对象做一些巧妙的操作。

实现魔法方法

魔法方法是一组在语言中具有特殊意义的预定义方法。它们很容易识别,因为它们的名称前后都有两个下划线。事实上,我们已经见过其中一个魔法方法:__init__!这些方法不是直接调用的,而是由解释器在使用其他构造函数,如标准函数或操作符时调用。

为了理解它们的作用,我们将回顾最常用的方法。我们从__repr____str__开始。

对象表示 —— reprstr

当你定义一个类时,通常需要能够获得一个实例的可读且清晰的字符串表示。为此,Python 提供了两个魔法方法:__repr____str__。让我们看看它们如何在表示摄氏度或华氏度温度的类中工作:

chapter02_classes_objects_03.py


class Temperature:        def __init__(self, value, scale):
                self.value = value
                self.scale = scale
        def __repr__(self):
                return f"Temperature({self.value}, {self.scale!r})"
        def __str__(self):
                return f"Temperature is {self.value} °{self.scale}"
t = Temperature(25, "C")
print(repr(t))    # "Temperature(25, 'C')"
print(str(t))    # "Temperature is 25 °C"
print(t)

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/blob/main/chapter02/chapter02_classes_objects_03.py

如果你运行这个示例,你会注意到print(t)print(str(t))打印的内容是一样的。通过print,解释器调用了__str__方法来获取我们对象的字符串表示。这就是__str__的作用:提供一个优雅的字符串表示,供最终用户使用。

另一方面,你会看到,尽管它们非常相似,我们实现了__repr__的方式却有所不同。这个方法的目的是给出对象的内部表示,并且它是唯一明确的。按照约定,这应该给出一个准确的语句,允许我们重建出完全相同的对象。

现在我们已经可以用我们的类来表示温度,那么如果我们尝试比较它们,会发生什么呢?

比较方法 —— eqgtlt,等等

当然,比较不同单位的温度会导致意外的结果。幸运的是,魔法方法允许我们重载默认的操作符,以便进行有意义的比较。让我们扩展一下之前的例子:

chapter02_classes_objects_04.py


class Temperature:        def __init__(self, value, scale):
                self.value = value
                self.scale = scale
                if scale == "C":
                        self.value_kelvin = value + 273.15
                elif scale == "F":
                        self.value_kelvin = (value–- 32) * 5 / 9 + 273.15

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/blob/main/chapter02/chapter02_classes_objects_04.py

__init__方法中,我们根据当前的单位将温度值转换为开尔文温标。这将帮助我们进行比较。接下来,我们定义__eq____lt__

chapter02_classes_objects_04.py


        def __eq__(self, other):                return self.value_kelvin == other.value_kelvin
        def __lt__(self, other):
                return self.value_kelvin < other.value_kelvin

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/blob/main/chapter02/chapter02_classes_objects_04.py

如你所见,这些方法只是接受另一个参数,即要与之比较的另一个对象实例。然后我们只需要执行比较逻辑。通过这样做,我们可以像处理任何变量一样进行比较:

chapter02_classes_objects_04.py


tc = Temperature(25, "C")tf = Temperature(77, "F")
tf2 = Temperature(100, "F")
print(tc == tf)    # True
print(tc < tf2)    # True

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/blob/main/chapter02/chapter02_classes_objects_04.py

就是这样!如果你希望所有比较操作符都可用,你还应该实现所有其他的比较魔法方法:__le____gt____ge__

另一个实例的类型无法保证

在这个例子中,我们假设 other 变量也是一个 Temperature 对象。然而,在现实中,这并不能保证,开发者可能会尝试将 Temperature 与另一个对象进行比较,这可能导致错误或异常行为。为避免这种情况,你应该使用 isinstance 检查 other 变量的类型,确保我们处理的是 Temperature,否则抛出适当的异常。

操作符 – addsubmul 等等

类似地,你还可以定义当尝试对两个 Temperature 对象进行加法或乘法操作时会发生什么。我们在这里不会详细讨论,因为它的工作方式与比较操作符完全相同。

可调用对象 – call

我们要回顾的最后一个魔法方法是 __call__。这个方法有些特殊,因为它使你能够像调用 普通函数 一样调用你的对象实例。让我们看一个例子:

chapter02_classes_objects_05.py


class Counter:        def __init__(self):
                self.counter = 0
        def __call__(self, inc=1):
                self.counter += inc
c = Counter()
print(c.counter)    # 0
c()
print(c.counter)    # 1
c(10)
print(c.counter)    # 11

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/blob/main/chapter02/chapter02_classes_objects_05.py

__call__ 方法可以像定义其他方法一样定义,接受你希望的任何参数。唯一的区别是如何调用它:你只需要像调用普通函数那样,直接在对象实例变量上传递参数。

如果你想定义一个保持某种局部状态的函数,就像我们在这个例子中所做的那样,或者在需要提供 可调用 对象并设置一些参数的情况下,这种模式会很有用。实际上,这正是我们在为 FastAPI 定义类依赖时会遇到的用例。

如我们所见,魔法方法是实现自定义类操作的一个绝佳方式,使它们能够以纯面向对象的方式易于使用。我们并没有涵盖所有可用的魔法方法,但你可以在官方文档中找到完整的列表:docs.python.org/3/reference/datamodel.html#special-method-names

现在我们将重点讨论面向对象编程的另一个重要特性:继承。

通过继承重用逻辑,避免重复代码

继承是面向对象编程的核心概念之一:它允许你从现有的类派生出一个新类,从而重用一些逻辑,并重载对这个新类特有的部分。当然,Python 也支持这种方式。我们将通过非常简单的例子来理解其底层机制。

首先,让我们来看一个非常简单的继承示例:

chapter02_classes_objects_06.py


class A:        def f(self):
                return "A"
class Child(A):
        pass

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/blob/main/chapter02/chapter02_classes_objects_06.py

Child 类继承自 A 类。语法很简单:我们想继承的类通过括号写在子类名后面。

pass 语句

pass 是一个什么也不做的语句。由于 Python 仅依赖缩进来表示代码块,它是一个有用的语句,可以用来创建一个空的代码块,就像在其他编程语言中使用大括号一样。

在这个示例中,我们不想给 Child 类添加任何逻辑,所以我们只写了 pass

另一种方法是在类定义下方添加文档字符串(docstring)。

如果你希望重载一个方法,但仍然想获得父类方法的结果,可以调用 super 函数:

chapter02_classes_objects_07.py


class A:        def f(self):
                return "A"
class Child(A):
        def f(self):
                parent_result = super().f()
                return f"Child {parent_result}"

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/blob/main/chapter02/chapter02_classes_objects_07.py

现在你知道如何在 Python 中使用基本的继承了。但还有更多:我们还可以使用多重继承!

多重继承

正如其名称所示,多重继承允许你从多个类派生一个子类。这样,你可以将多个类的逻辑组合成一个。我们来看一个例子:

chapter02_classes_objects_08.py


class A:        def f(self):
                return "A"
class B:
        def g(self):
                return "B"
class Child(A, B):
        pass

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/blob/main/chapter02/chapter02_classes_objects_08.py

再次强调,语法非常简单:只需用逗号列出所有父类。现在,Child 类可以调用 fg 两个方法。

Mixins

Mixins 是 Python 中常见的设计模式,利用了多重继承特性。基本上,mixins 是包含单一功能的简短类,通常用于重用。然后,你可以通过组合这些 mixins 来构建具体的类。

但是,如果 AB 两个类都实现了名为 f 的方法,会发生什么呢?我们来试试看:

chapter02_classes_objects_09.py


class A:        def f(self):
                return "A"
class B:
        def f(self):
                return "B"
class Child(A, B):
        pass

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/blob/main/chapter02/chapter02_classes_objects_09.py

如果你调用 Child 类的 f 方法,你将得到值 "A"。在这个简单的例子中,Python 会根据父类的顺序考虑第一个匹配的方法。然而,对于更复杂的继承结构,解析可能就不那么明显了:这就是 方法解析顺序MRO)算法的目的。我们在这里不会深入讨论,但你可以查看 Python 官方文档,了解该算法的实现:www.python.org/download/releases/2.3/mro/

如果你对类的 MRO(方法解析顺序)感到困惑,可以在类上调用 mro 方法来获取按顺序考虑的类列表:


>>> Child.mro()[<class 'chapter2_classes_objects_09.Child'>, <class 'chapter2_classes_objects_09.A'>, <class 'chapter2_classes_objects_09.B'>, <class 'object'>]

做得好!现在你对 Python 的面向对象编程有了一个很好的概览。这些概念在定义 FastAPI 中的依赖关系时会非常有帮助。

接下来,我们将回顾一些 Python 中最新和最流行的特性,FastAPI 在这些特性上有很大的依赖。我们将从 类型提示 开始。

使用 mypy 进行类型提示和类型检查

在本章的第一部分,我们提到 Python 是一种动态类型语言:解释器不会在编译时检查类型,而是在运行时进行检查。这使得语言更加灵活,开发者也更加高效。然而,如果你对这种语言类型有经验,你可能知道在这种上下文中很容易产生错误和漏洞:忘记参数、类型不匹配等问题。

这就是 Python 从 3.5 版本 开始引入类型提示的原因。目的是提供一种语法,用于通过 mypy 注解源代码,mypy 在这个领域被广泛使用。

入门

为了理解类型注解如何工作,我们将回顾一个简单的注解函数:

chapter02_type_hints_01.py


def greeting(name: str) -> str:        return f"Hello, {name}"

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/blob/main/chapter02/chapter02_type_hints_01.py

正如你所看到的,我们在冒号后简单地添加了name参数的类型。我们还指定了strint,我们可以简单地将它们用作类型注解。稍后在本节中,我们将看到如何注解更复杂的类型,如列表或字典。

现在我们将安装mypy来对这个文件进行类型检查。这可以像其他任何 Python 包一样完成:


$ pip install mypy

然后,你可以对你的源文件运行类型检查:


$ mypy chapter02_type_hints_01.pySuccess: no issues found in 1 source file

正如你所看到的,mypy告诉我们我们的类型没有问题。让我们尝试稍微修改一下代码,触发一个类型错误:


def greeting(name: str) -> int:        return f"Hello, {name}"

很简单,我们只是说我们的函数的返回类型现在是int,但我们仍然返回一个字符串。如果你运行这段代码,它会完美执行:正如我们所说,解释器会忽略类型注解。然而,让我们看看mypy会给我们什么反馈:


$ mypy chapter02_type_hints_01.pychapter02_type_hints_01.py:2: error: Incompatible return value type (got "str", expected "int")    [return-value]
Found 1 error in 1 file (checked 1 source file)

这次,它发出了警告。它清楚地告诉我们这里出了什么问题:返回值是字符串,而预期的是整数!

代码编辑器和 IDE 集成

有类型检查是好的,但手动在命令行上运行mypy可能有点繁琐。幸运的是,它与最流行的代码编辑器和 IDE 集成得很好。一旦配置完成,它将在你输入时执行类型检查,并直接在错误的行上显示错误。类型注解还帮助 IDE 执行一些聪明的操作,例如自动补全

你可以在mypy的官方文档中查看如何为你最喜欢的编辑器进行配置:github.com/python/mypy#integrations

你已经理解了 Python 中类型提示的基础知识。接下来,我们将回顾更高级的例子,特别是涉及非标量类型的情况。

类型数据结构

到目前为止,我们已经看到了如何为标量类型(如strint)注解变量。但我们也看到了像列表和字典这样的数据结构,它们在 Python 中被广泛使用。在下面的例子中,我们将展示如何为 Python 中的基本数据结构添加类型提示:

chapter02_type_hints_02.py


l: list[int] = [1, 2, 3, 4, 5]t: tuple[int, str, float] = (1, "hello", 3.14)
s: set[int] = {1, 2, 3, 4, 5}
d: dict[str, int] = {"a": 1, "b": 2, "c": 3}

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/blob/main/chapter02/chapter02_type_hints_02.py

你可以看到,这里我们可以使用listtuplesetdict这些标准类作为类型提示。然而,它们要求你提供构成结构的值的类型。这就是面向对象编程中广为人知的泛型概念。在 Python 中,它们是通过方括号定义的。

当然,还有更复杂的用例。例如,在 Python 中,拥有一个包含不同类型元素的列表是完全有效的。为了让类型检查器正常工作,我们可以简单地使用|符号来指定多个允许的类型:

chapter02_type_hints_03.py


l: list[int | float] = [1, 2.5, 3.14, 5]

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/blob/main/chapter02/chapter02_type_hints_03.py

在这种情况下,我们的列表将接受整数或浮点数。当然,如果你尝试向列表中添加一个既不是 int 也不是 float 类型的元素,mypy 会报错。

还有一种情况也非常有用:你会经常遇到这样的函数参数或返回类型,它们要么返回一个值,要么返回 None。因此,你可以写类似这样:

chapter02_type_hints_04.py


def greeting(name: str | None = None) -> str:        return f"Hello, {name if name else 'Anonymous'}"

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/blob/main/chapter02/chapter02_type_hints_04.py

允许的值是字符串或 None

在 Python 3.9 之前,类型注解有所不同

在 Python 3.9 之前,无法使用标准类对列表、元组、集合和字典进行注解。我们需要从 typing 模块中导入特殊类:l: List[int] = [1, 2, 3, 4, 5]

| 符号也不可用。我们需要使用 typing 模块中的特殊 Union 类:l: List[Union[int, float]] = [1, 2.5, 3.14, 5]

这种注解方式现在已经被弃用,但你仍然可能会在旧的代码库中找到它。

处理复杂类型时,别名 和复用它们可能会很有用,这样你就无需每次都重写它们。为此,你只需像为任何变量赋值一样进行赋值:

chapter02_type_hints_05.py


IntStringFloatTuple = tuple[int, str, float]t: IntStringFloatTuple = (1, "hello", 3.14)

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/blob/main/chapter02/chapter02_type_hints_05.py

按照惯例,类型应该使用驼峰命名法,就像类名一样。说到类,我们来看看类型提示在类中的应用:

chapter02_type_hints_06.py


class Post:        def __init__(self, title: str) -> None:
                self.title = title
        def __str__(self) -> str:
                return self.title
posts: list[Post] = [Post("Post A"), Post("Post B")]

https://github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/blob/main/chapter02/chapter02_type_hints_06.py

实际上,类的类型提示并没有什么特别的。你只需像对待普通函数一样注解类的方法。如果你需要在注解中使用类,例如这里的帖子列表,你只需要使用类名。

有时,你需要编写一个接受另一个函数作为参数的函数或方法。在这种情况下,你需要提供该函数的 类型签名

使用 Callable 标注类型函数签名

一个更高级的使用场景是能够为函数签名指定类型。例如,当你需要将函数作为其他函数的参数时,这会非常有用。为此,我们可以使用 Callable 类,它在 collections.abc 模块中可用。在以下示例中,我们将实现一个名为 filter_list 的函数,期望接受一个整数列表和一个给定整数返回布尔值的函数作为参数:

chapter02_type_hints_07.py


from collections.abc import CallableConditionFunction = Callable[[int], bool]
def filter_list(l: list[int], condition: ConditionFunction) -> list[int]:
        return [i for i in l if condition(i)]

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/blob/main/chapter02/chapter02_type_hints_07.py

什么是 collections.abc 模块?

collections.abc 是 Python 标准库中的一个模块,提供了常用对象的抽象基类,这些对象在 Python 中日常使用:迭代器、生成器、可调用对象、集合、映射等。它们主要用于高级用例,在这些用例中,我们需要实现新的自定义对象,这些对象应该像迭代器、生成器等一样工作。在这里,我们仅将它们作为类型提示使用。

你可以看到这里我们通过 Callable 定义了一个类型别名 ConditionFunction。再次强调,这是一个泛型类,期望两个参数:首先是参数类型的列表,其次是返回类型。在这里,我们期望一个整数类型的参数,返回类型是布尔类型。

然后,我们可以在 filter_list 函数的注解中使用这种类型。mypy 会确保传递给参数的条件函数符合此签名。例如,我们可以编写一个简单的函数来检查整数的奇偶性,如下所示:

chapter02_type_hints_07.py


def is_even(i: int) -> bool:        return i % 2 == 0
filter_list([1, 2, 3, 4, 5], is_even)

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/blob/main/chapter02/chapter02_type_hints_07.py

然而,值得注意的是,Python 中没有语法来指示可选或关键字参数。在这种情况下,你可以写 Callable[..., bool],其中的省略号(...)表示任意数量参数

Anycast

在某些情况下,代码非常动态或复杂,无法正确地进行注解,或者类型检查器可能无法正确推断类型。为此,我们可以使用 Anycast。它们可在 typing 模块中找到,该模块是 Python 引入的,旨在帮助处理类型提示方面的更具体的用例和构造。

Any 是一种类型注解,告诉类型检查器变量或参数可以是任何类型。在这种情况下,类型检查器将接受任何类型的值:

chapter02_type_hints_08.py


from typing import Anydef f(x: Any) -> Any:
        return x
f("a")
f(10)
f([1, 2, 3])

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/blob/main/chapter02/chapter02_type_hints_08.py

第二个方法,cast,是一个让你覆盖类型检查器推断的类型的函数。它会强制类型检查器考虑你指定的类型:

chapter02_type_hints_09.py


from typing import Any, castdef f(x: Any) -> Any:
        return x
a = f("a")    # inferred type is "Any"
a = cast(str, f("a"))    # forced type to be "str"

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/blob/main/chapter02/chapter02_type_hints_09.py

但要小心:cast 函数对类型检查器才有意义。对于其他类型的注解,解释器会完全忽略它,并且 不会 执行真正的类型转换。

尽管很方便,但尽量不要过于频繁地使用这些工具。如果一切都是 Any 或强制转换为其他类型,你将完全失去静态类型检查的好处。

正如我们所看到的,类型提示和类型检查在减少开发和维护高质量代码时非常有帮助。但这还不是全部。实际上,Python 允许你在运行时获取类型注解,并基于此执行一些逻辑。这使得你能够做一些聪明的事情,比如 依赖注入:只需在函数中为参数添加类型提示,库就能自动解析并在运行时注入相应的值。这一概念是 FastAPI 的核心。

FastAPI 中的另一个关键方法是 异步 I/O。这是我们在本章中要讲解的最后一个主题。

与异步 I/O 一起工作

如果你已经使用过 JavaScript 和 Node.js,你可能已经接触过 promiseasync/await 关键字,这些都是异步 I/O 范式的特点。基本上,这是使 I/O 操作非阻塞的方式,并允许程序在读取或写入操作进行时执行其他任务。这样做的主要原因是 I/O 操作是 的:从磁盘读取、网络请求的速度比从 RAM 中读取或处理指令慢 百万 倍。在下面的示例中,我们有一个简单的脚本,它读取磁盘上的文件:

chapter02_asyncio_01.py


with open(__file__) as f:        data = f.read()
# The program will block here until the data has been read
print(data)

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/blob/main/chapter02/chapter02_asyncio_01.py

我们可以看到,脚本会被阻塞,直到从磁盘中检索到数据,正如我们所说,这可能需要很长时间。程序 99%的执行时间都花费在等待磁盘上。对于像这样的简单脚本来说,这通常不是问题,因为你可能不需要在此期间执行其他操作。

然而,在其他情况下,这可能是执行其他任务的机会。本书中非常关注的典型案例是 Web 服务器。假设我们有一个用户发出请求,该请求需要执行一个持续 10 秒钟的数据库查询才能返回响应。如果此时第二个用户发出了请求,他们必须等到第一个响应完成后,才能收到自己的答复。

为了解决这个问题,传统的基于Web 服务器网关接口WSGI)的 Python Web 服务器(如 Flask 或 Django)会生成多个工作进程。这些是 Web 服务器的子进程,都能够处理请求。如果其中一个忙于处理一个长时间的请求,其他进程可以处理新的请求。

使用异步 I/O 时,单个进程在处理一个长时间 I/O 操作的请求时不会被阻塞。它在等待此操作完成的同时,可以处理其他请求。当 I/O 操作完成时,它会恢复请求逻辑,并最终返回响应。

从技术上讲,这是通过selectpoll调用实现的,正是通过它们来请求操作系统级别的 I/O 操作事件。你可以在 Julia Evans 的文章《Async IO on Linux: select, poll, and epoll》中阅读到非常有趣的细节:jvns.ca/blog/2017/06/03/async-io-on-linux--select--poll--and-epoll

Python 在 3.4 版本中首次实现了异步 I/O,之后它得到了极大的发展,特别是在 3.6 版本中引入了 async/await 关键字。所有用于管理这种编程范式的工具都可以通过标准的 asyncio 模块获得。不久之后,异步启用 Web 服务器的 WSGI 的精神继任者——异步服务器网关接口ASGI)被引入。FastAPI 就是基于这一点,这也是它展示出如此卓越性能的原因之一。

我们现在来回顾一下 Python 中异步编程的基础知识。下面的示例是一个使用asyncio的简单Hello world脚本:

chapter02_asyncio_02.py


import asyncioasync def main():
        print("Hello ...")
        await asyncio.sleep(1)
        print("... World!")
asyncio.run(main())

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/blob/main/chapter02/chapter02_asyncio_02.py

当你想定义一个异步函数时,只需要在def前面添加async关键字。这允许你在函数内部使用await关键字。这种异步函数被称为协程

在其中,我们首先调用print函数,然后调用asyncio.sleep协程。这是async版的time.sleep,它会让程序阻塞指定的秒数。请注意,我们在调用前加上了await关键字。这意味着我们希望等待这个协程完成后再继续。这就是async/await关键字的主要好处:编写看起来像同步代码的代码。如果我们省略了await,协程对象会被创建,但永远不会执行。

最后,注意我们使用了asyncio.run函数。这是会创建一个新的事件循环,执行你的协程并返回其结果的机制。它应该是你async程序的主要入口点。

这个示例不错,但从异步的角度来看并不太有趣:因为我们只等待一个操作,所以这并不令人印象深刻。让我们看一个同时执行两个协程的例子:

chapter02_asyncio_03.py


import asyncioasync def printer(name: str, times: int) -> None:
        for i in range(times):
                print(name)
                await asyncio.sleep(1)
async def main():
        await asyncio.gather(
                printer("A", 3),
                printer("B", 3),
        )
asyncio.run(main())

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/blob/main/chapter02/chapter02_asyncio_03.py

这里,我们有一个printer协程,它会打印自己的名字指定次数。每次打印之间,它会睡眠 1 秒。

然后,我们的主协程使用了asyncio.gather工具,它将多个协程调度为并发执行。如果你运行这个脚本,你将得到以下结果:


$ python chapter02_asyncio_03.pyA
B
A
B
A
B

我们得到了一连串的AB。这意味着我们的协程是并发执行的,并且我们没有等第一个协程完成才开始第二个协程。

你可能会想知道为什么我们在这个例子中添加了asyncio.sleep调用。实际上,如果我们去掉它,我们会得到如下结果:


AA
A
B
B
B

这看起来并不太并发,实际上确实不是。这是asyncio的主要陷阱之一:在协程中编写代码不一定意味着它不会阻塞。像计算这样的常规操作阻塞的,并且阻塞事件循环。通常这不是问题,因为这些操作很快。唯一不会阻塞的操作是设计为异步执行的 I/O 操作。这与多进程不同,后者的操作是在子进程中执行的,天生不会阻塞主进程。

因此,在选择与数据库、API 等交互的第三方库时,你必须小心。有些库已经适配为异步工作,有些则与标准库并行开发。我们将在接下来的章节中看到其中一些,尤其是在与数据库交互时。

我们将在此结束对异步 I/O 的简要介绍。虽然还有一些更深层次的细节,但一般来说,我们在这里学到的基础知识已经足够让你利用 asyncio 与 FastAPI 一起工作。

总结

恭喜!在本章中,你了解了 Python 语言的基础,它是一种非常简洁高效的编程语言。你接触到了更高级的概念——列表推导式和生成器,它们是处理数据序列的惯用方法。Python 也是一种多范式语言,你还学会了如何利用面向对象的语法。

最后,你了解了语言的一些最新特性:类型提示,它允许静态类型检查,从而减少错误并加速开发;以及异步 I/O,这是一组新的工具和语法,可以在执行 I/O 密集型操作时最大化性能并支持并发。

你现在已经准备好开始你的 FastAPI 之旅!你将会发现该框架充分利用了所有这些 Python 特性,提供了快速且愉悦的开发体验。在下一章中,你将学会如何使用 FastAPI 编写你的第一个 REST API。

第三章:使用 FastAPI 开发 RESTful API

现在是时候开始学习FastAPI了!在这一章中,我们将介绍 FastAPI 的基础知识。我们将通过非常简单且集中的示例来演示 FastAPI 的不同特性。每个示例都将通向一个可用的 API 端点,你可以使用 HTTPie 进行测试。在本章的最后一部分,我们将展示一个更复杂的 FastAPI 项目,其中的路由分布在多个文件中。它将为你提供一个如何构建自己应用程序的概览。

到了本章结束时,你将知道如何启动 FastAPI 应用程序以及如何编写 API 端点。你还将能够处理请求数据,并根据自己的逻辑构建响应。最后,你将学会一种将 FastAPI 项目结构化为多个模块的方法,这样长期来看,项目更容易维护和操作。

在这一章中,我们将涵盖以下主要内容:

  • 创建第一个端点并在本地运行

  • 处理请求参数

  • 自定义响应

  • 使用多个路由器构建更大的项目

技术要求

你将需要一个 Python 虚拟环境,就像我们在第一章中设置的那样,Python 开发 环境设置

你可以在这个专门的 GitHub 仓库中找到本章的所有代码示例:github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter03

创建第一个端点并在本地运行

FastAPI 是一个易于使用且快速编写的框架。在接下来的示例中,你会发现这不仅仅是一个承诺。事实上,创建一个 API 端点只需要几行代码:

chapter03_first_endpoint_01.py


from fastapi import FastAPIapp = FastAPI()
@app.get("/")
async def hello_world():
    return {"hello": "world"}

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter03/chapter03_first_endpoint_01.py

在这个例子中,我们在根路径上定义了一个GET端点,它总是返回{"hello": "world"}的 JSON 响应。为了做到这一点,我们首先实例化一个 FastAPI 对象,app。它将是主应用对象,负责管理所有 API 路由。

然后,我们简单地定义一个协程,包含我们的路由逻辑,即路径操作函数。其返回值会被 FastAPI 自动处理,生成一个包含 JSON 负载的正确 HTTP 响应。

在这里,这段代码中最重要的部分可能是以@开头的那一行,可以在协程定义之上找到,app.get("/")(hello_world)

FastAPI 为每个 HTTP 方法提供一个装饰器,用来向应用程序添加新路由。这里展示的装饰器添加了一个以路径作为第一个参数的GET端点。

现在,让我们运行这个 API。将示例代码复制到项目的根目录,并运行以下命令:


$ uvicorn chapter03_first_endpoint_01:appINFO:     Started server process [21654]
INFO:     Waiting for application startup.
INFO:     Application startup complete.
INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)

正如我们在 第二章中提到的,Python 编程特性部分的 异步 I/O 小节中,FastAPI 会暴露一个 :,最后是你的 ASGI 应用实例的变量名(在我们的示例中是 app)。之后,它会负责实例化应用并在你的本地机器上暴露它。

让我们尝试用 HTTPie 来测试我们的端点。打开另一个终端并运行以下命令:


$ http http://localhost:8000HTTP/1.1 200 OK
content-length: 17
content-type: application/json
date: Thu, 10 Nov 2022 07:52:36 GMT
server: uvicorn
{
    "hello": "world"
}

它有效!正如你所见,我们确实得到了一个带有我们所需负载的 JSON 响应,只用了几行 Python 代码和一个命令!

FastAPI 最受欢迎的功能之一是 自动交互式文档。如果你在浏览器中打开 http://localhost:8000/docs URL,你应该能看到一个类似于以下截图的网页界面:

图 3.1 – FastAPI 自动交互式文档

图 3.1 – FastAPI 自动交互式文档

FastAPI 会自动列出你所有定义的端点,并提供关于期望输入和输出的文档。你甚至可以直接在这个网页界面中尝试每个端点。在背后,它依赖于 OpenAPI 规范和来自 Swagger 的相关工具。你可以在其官方网站 swagger.io/ 阅读更多关于这方面的信息。

就这样!你已经用 FastAPI 创建了你的第一个 API。当然,这只是一个非常简单的示例,但接下来你将学习如何处理输入数据,并开始做一些有意义的事情!

巨人的肩膀

值得注意的是,FastAPI 基于两个主要的 Python 库构建:Starlette,一个低级 ASGI Web 框架(www.starlette.io/),以及 Pydantic,一个基于类型提示的数据验证库(pydantic-docs.helpmanual.io/)。

处理请求参数

表现状态转移REST)API 的主要目标是提供一种结构化的方式来与数据交互。因此,最终用户必须发送一些信息来定制他们需要的响应,例如路径参数、查询参数、请求体负载、头部等。

Web 框架通常要求你操作一个请求对象来获取你感兴趣的部分,并手动应用验证来处理它们。然而,FastAPI 不需要这样做!实际上,它允许你声明性地定义所有参数。然后,它会自动在请求中获取它们,并根据类型提示应用验证。这就是为什么我们在 第二章中介绍了类型提示:FastAPI 就是用它来执行数据验证的!

接下来,我们将探索如何利用这个功能来从请求的不同部分获取和验证输入数据。

路径参数

API 路径是最终用户将与之交互的主要位置。因此,它是动态参数的好地方。一个典型的例子是将我们想要检索的对象的唯一标识符放在路径中,例如 /users/123。让我们看看如何在 FastAPI 中定义这个:

chapter03_path_parameters_01.py


from fastapi import FastAPIapp = FastAPI()
@app.get("/users/{id}")
async def get_user(id: int):
    return {"id": id}

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter03/chapter03_path_parameters_01.py

在这个示例中,我们定义了一个 API,它期望在其路径的最后部分传递一个整数。我们通过将参数名称放在花括号中定义路径来做到这一点。然后,我们将这个参数作为路径操作函数的参数进行了定义。请注意,我们添加了一个类型提示来指定参数是整数。

让我们运行这个示例。你可以参考之前的 创建第一个端点并在本地运行 部分,了解如何使用 Uvicorn 运行 FastAPI 应用程序。

首先,我们将尝试发起一个省略路径参数的请求:


$ http http://localhost:8000/usersHTTP/1.1 404 Not Found
content-length: 22
content-type: application/json
date: Thu, 10 Nov 2022 08:20:51 GMT
server: uvicorn
{
    "detail": "Not Found"
}

我们得到了一个 404 状态的响应。这是预期的:我们的路由在 /users 后面期待一个参数,如果我们省略它,它就不会匹配任何模式。

现在让我们尝试使用一个正确的整数参数:


http http://localhost:8000/users/123HTTP/1.1 200 OK
content-length: 10
content-type: application/json
date: Thu, 10 Nov 2022 08:21:27 GMT
server: uvicorn
{
    "id": 123
}

它运行成功了!我们得到了 200 状态,并且响应确实包含了我们传递的整数。请注意,它已经被正确地 转换 成了整数。

如果传递的值不是有效的整数会发生什么?让我们来看看:


$ http http://localhost:8000/users/abcHTTP/1.1 422 Unprocessable Entity
content-length: 99
content-type: application/json
date: Thu, 10 Nov 2022 08:22:35 GMT
server: uvicorn
{
    "detail": [
        {
            "loc": [
                "path",
                "id"
            ],
            "msg": "value is not a valid integer",
            "type": "type_error.integer"
        }
    ]
}

我们得到了一个 422 状态的响应!由于 abc 不是有效的整数,验证失败并输出了错误。请注意,我们有一个非常详细和结构化的错误响应,准确告诉我们哪个元素导致了错误以及原因。我们触发这种验证的唯一要求就是 类型提示 我们的参数!

当然,你不仅仅限于一个路径参数。你可以有任意多个路径参数,类型可以各不相同。在以下示例中,我们添加了一个字符串类型的 type 参数:

chapter03_path_parameters_02.py


from fastapi import FastAPIapp = FastAPI()
@app.get("/users/{type}/{id}")
async def get_user(type: str, id: int):
    return {"type": type, "id": id}

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter03/chapter03_path_parameters_02.py

这个方法很有效,但是端点将接受任何字符串作为 type 参数。

限制允许的值

如果我们只想接受一组有限的值怎么办?再次,我们将依赖类型提示。Python 中有一个非常有用的类:Enum。枚举是列出特定类型数据所有有效值的一种方式。让我们定义一个 Enum 类来列出不同类型的用户:

chapter03_path_parameters_03.py


class UserType(str, Enum):    STANDARD = "standard"
    ADMIN = "admin"

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter03/chapter03_path_parameters_03.py

要定义一个字符串枚举,我们需要同时继承 str 类型和 Enum 类。然后,我们简单地将允许的值列出为类属性:属性名称及其实际字符串值。最后,我们只需要为 type 参数指定此类的类型提示:

chapter03_path_parameters_03.py


@app.get("/users/{type}/{id}")async def get_user(type: UserType, id: int):
    return {"type": type, "id": id}

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter03/chapter03_path_parameters_03.py

如果你运行此示例,并调用一个类型不在枚举中的端点,你将得到以下响应:


$ http http://localhost:8000/users/hello/123HTTP/1.1 422 Unprocessable Entity
content-length: 184
content-type: application/json
date: Thu, 10 Nov 2022 08:33:36 GMT
server: uvicorn
{
    "detail": [
        {
            "ctx": {
                "enum_values": [
                    "standard",
                    "admin"
                ]
            },
            "loc": [
                "path",
                "type"
            ],
            "msg": "value is not a valid enumeration member; permitted: 'standard', 'admin'",
            "type": "type_error.enum"
        }
    ]
}

如你所见,你可以获得一个非常好的验证错误,显示此参数允许的值!

高级验证

我们可以进一步通过定义更高级的验证规则,特别是对于数字和字符串。在这种情况下,类型提示已经不再足够。我们将依赖 FastAPI 提供的函数,允许我们为每个参数设置一些选项。对于路径参数,这个函数叫做 Path。在以下示例中,我们只允许一个大于或等于 1id 参数:

chapter03_path_parameters_04.py


from fastapi import FastAPI, Pathapp = FastAPI()
@app.get("/users/{id}")
async def get_user(id: int = Path(..., ge=1)):
    return {"id": id}

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter03/chapter03_path_parameters_04.py

这里有几个需要注意的事项:Path 的结果作为路径操作函数中 id 参数的默认值

此外,你可以看到我们使用了 Path。事实上,它期望参数的默认值作为第一个参数。在这种情况下,我们不想要默认值:该参数是必需的。因此,省略号用来告诉 FastAPI 我们不希望有默认值。

省略号在 Python 中并不总是意味着这个

使用省略号符号来指定参数是必需的,如我们这里展示的,这是 FastAPI 特有的:这是 FastAPI 创建者的选择,决定使用这种方式。在其他 Python 程序中,这个符号可能有不同的含义。

然后,我们可以添加我们感兴趣的关键字参数。在我们的示例中,我们使用 ge,大于或等于,以及其关联的值。以下是验证数字时可用的关键字列表:

  • gt: 大于

  • ge: 大于或等于

  • lt: 小于

  • le: 小于或等于

还有针对字符串值的验证选项,这些选项基于长度正则表达式。在以下示例中,我们想定义一个路径参数,用来接受以 AB-123-CD(法国车牌)的形式出现的车牌号。一种方法是强制字符串的长度为 9(即两个字母,一个连字符,三个数字,一个连字符,再加上两个字母):

chapter03_path_parameters_05.py


@app.get("/license-plates/{license}")async def get_license_plate(license: str = Path(..., min_length=9, max_length=9)):
    return {"license": license}

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter03/chapter03_path_parameters_05.py

现在我们只需定义 min_lengthmax_length 关键字参数,就像我们为数字验证所做的一样。当然,针对这种用例的更好解决方案是使用正则表达式来验证车牌号码:

chapter03_path_parameters_06.py


@app.get("/license-plates/{license}")async def get_license_plate(license: str = Path(..., regex=r"^\w{2}-\d{3}-\w{2}$")):
    return {"license": license}

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter03/chapter03_path_parameters_06.py

多亏了这个正则表达式,我们只接受与车牌格式完全匹配的字符串。请注意,正则表达式前面有一个 r。就像 f-strings 一样,这是 Python 语法,用于表示接下来的字符串应被视为正则表达式。

参数元数据

数据验证并不是参数函数唯一接受的选项。你还可以设置其他选项,用于在自动生成的文档中添加关于参数的信息,例如 titledescriptiondeprecated

现在你应该能够定义路径参数并对其应用一些验证。另一个有用的参数是查询参数。我们接下来将讨论它们。

查询参数

查询参数是一种常见的向 URL 添加动态参数的方式。你可以在 URL 末尾找到它们,形式如下:?param1=foo&param2=bar。在 REST API 中,它们通常用于读取端点,以应用分页、过滤器、排序顺序或选择字段。

你会发现,使用 FastAPI 定义它们是相当简单的。实际上,它们使用与路径参数完全相同的语法:

chapter03_query_parameters_01.py


@app.get("/users")async def get_user(page: int = 1, size: int = 10):
    return {"page": page, "size": size}

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter03/chapter03_query_parameters_01.py

你只需要将它们声明为路径操作函数的参数。如果它们没有出现在路径模式中,就像路径参数那样,FastAPI 会自动将它们视为查询参数。让我们试试看:


$ http "http://localhost:8000/users?page=5&size=50"HTTP/1.1 200 OK
content-length: 20
content-type: application/json
date: Thu, 10 Nov 2022 09:35:05 GMT
server: uvicorn
{
    "page": 5,
    "size": 50
}

在这里,你可以看到我们为这些参数定义了默认值,这意味着它们在调用 API 时是 可选的。当然,如果你希望定义一个 必填的 查询参数,只需省略默认值:

chapter03_query_parameters_02.py


from enum import Enumfrom fastapi import FastAPI
class UsersFormat(str, Enum):
    SHORT = "short"
    FULL = "full"
app = FastAPI()
@app.get("/users")
async def get_user(format: UsersFormat):
    return {"format": format}

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter03/chapter03_query_parameters_02.py

现在,如果你在 URL 中省略了 format 参数,你会收到 422 错误响应。另外,注意在这个例子中,我们定义了一个 UsersFormat 枚举来限制此参数允许的值数量;这正是我们在前一节中对路径参数所做的事情。

我们还可以通过 Query 函数访问更高级的验证功能。它的工作方式与我们在 路径 参数 部分演示的相同:

chapter03_query_parameters_03.py


from fastapi import FastAPI, Queryapp = FastAPI()
@app.get("/users")
async def get_user(page: int = Query(1, gt=0), size: int = Query(10, 
le=100)):
    return {"page": page, "size": size}

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter03/chapter03_query_parameters_03.py

在这里,我们强制页面参数值为 大于 0 且大小 小于或等于 100。注意,默认的参数值是 Query 函数的第一个参数。

自然地,在发送请求数据时,最直观的方式是使用请求体。让我们看看它是如何工作的。

请求体

请求体是 HTTP 请求的一部分,包含表示文档、文件或表单提交的原始数据。在 REST API 中,它通常以 JSON 编码,用于在数据库中创建结构化对象。

对于最简单的情况,从请求体中获取数据的方式与查询参数完全相同。唯一的区别是你必须始终使用 Body 函数;否则,FastAPI 会默认在查询参数中查找它。让我们探索一个简单的例子,我们想要发布一些用户数据:

chapter03_request_body_01.py


@app.post("/users")async def create_user(name: str = Body(...), age: int = Body(...)):
    return {"name": name, "age": age}

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter03/chapter03_request_body_01.py

与查询参数的方式相同,我们为每个参数定义类型提示,并使用 Body 函数且不提供默认值来使其成为必填项。让我们尝试以下端点:


$ http -v POST http://localhost:8000/users name="John" age=30POST /users HTTP/1.1
Accept: application/json, */*;q=0.5
Accept-Encoding: gzip, deflate
Connection: keep-alive
Content-Length: 29
Content-Type: application/json
Host: localhost:8000
User-Agent: HTTPie/3.2.1
{
    "age": "30",
    "name": "John"
}
HTTP/1.1 200 OK
content-length: 24
content-type: application/json
date: Thu, 10 Nov 2022 09:42:24 GMT
server: uvicorn
{
    "age": 30,
    "name": "John"
}

这里,我们使用了 HTTPie 的-v选项,以便您可以清楚地看到我们发送的 JSON 有效负载。FastAPI 成功地从有效负载中检索每个字段的数据。如果您发送的请求缺少或无效字段,则会收到422状态错误响应。

通过Body函数,您还可以进行更高级的验证。它的工作方式与我们在Path parameters部分演示的方式相同。

然而,定义此类有效负载验证也有一些主要缺点。首先,它非常冗长,使得路径操作函数原型巨大,尤其是对于更大的模型。其次,通常情况下,您需要在其他端点或应用程序的其他部分重用数据结构。

这就是为什么 FastAPI 使用PathQueryBody函数,我们迄今所学的都使用 Pydantic 作为其基础!

通过定义自己的 Pydantic 模型并在路径参数中使用它们作为类型提示,FastAPI 将自动实例化模型实例并验证数据。让我们使用这种方法重写我们之前的例子:

chapter03_request_body_02.py


from fastapi import FastAPIfrom pydantic import BaseModel
class User(BaseModel):
    name: str
    age: int
app = FastAPI()
@app.post("/users")
async def create_user(user: User):
    return user

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter03/chapter03_request_body_02.py

首先,我们从pydantic导入BaseModel。这是每个模型都应该继承的基类。然后,我们定义我们的User类,并将所有属性列为类属性。每个属性都应具有适当的类型提示:这是 Pydantic 将能够验证字段类型的方式。

最后,我们只需将user声明为路径操作函数的一个参数,并使用User类作为类型提示。FastAPI 会自动理解用户数据可以在请求负载中找到。在函数内部,您可以访问一个适当的user对象实例,只需使用点表示法访问单个属性,例如user.name

请注意,如果只返回对象,FastAPI 足够智能,会自动将其转换为 JSON 以生成 HTTP 响应。

在接下来的章节中,第四章在 FastAPI 中管理 Pydantic 数据模型,我们将更详细地探讨 Pydantic 的可能性,特别是在验证方面。

多个对象

有时,您可能希望一次性发送同一有效负载中的多个对象。例如,usercompany。在这种情况下,您可以简单地添加几个由 Pydantic 模型类型提示的参数,FastAPI 将自动理解存在多个对象。在这种配置中,它将期望包含每个对象以其 参数名称 索引

chapter03_request_body_03.py


@app.post("/users")async def create_user(user: User, company: Company):
    return {"user": user, "company": company}

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter03/chapter03_request_body_03.py

这里,Company 是一个简单的 Pydantic 模型,只有一个字符串类型的 name 属性。在这种配置下,FastAPI 期望接收一个看起来类似于以下内容的有效载荷:


{    "user": {
        "name": "John",
        "age": 30
    },
    "company": {
        "name": "ACME"
    }
}

对于更复杂的 JSON 结构,建议你将格式化后的 JSON 通过管道传输给 HTTPie,而不是使用参数。让我们按如下方式尝试:


$ echo '{"user": {"name": "John", "age": 30}, "company": {"name": "ACME"}}' | http POST http://localhost:8000/usersHTTP/1.1 200 OK
content-length: 59
content-type: application/json
date: Thu, 10 Nov 2022 09:52:12 GMT
server: uvicorn
{
    "company": {
        "name": "ACME"
    },
    "user": {
        "age": 30,
        "name": "John"
    }
}

就这样!

你甚至可以添加 Body 函数,就像我们在本节开始时看到的那样。如果你希望有一个不属于任何模型的单一属性,这会很有用:

chapter03_request_body_04.py


@app.post("/users")async def create_user(user: User, priority: int = Body(..., ge=1, le=3)):
    return {"user": user, "priority": priority}

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter03/chapter03_request_body_04.py

priority 属性是一个介于 1 和 3 之间的整数,预计在 user 对象 旁边


$ echo '{"user": {"name": "John", "age": 30}, "priority": 1}' | http POST http://localhost:8000/usersHTTP/1.1 200 OK
content-length: 46
content-type: application/json
date: Thu, 10 Nov 2022 09:53:51 GMT
server: uvicorn
{
    "priority": 1,
    "user": {
        "age": 30,
        "name": "John"
    }
}

现在你已经很好地了解了如何处理 JSON 有效载荷数据。然而,有时你会发现需要接受更传统的表单数据,甚至是文件上传。接下来我们来看看如何做!

表单数据和文件上传

即使 REST API 大多数时候使用 JSON,偶尔你可能需要处理表单编码的数据或文件上传,这些数据要么被编码为 application/x-www-form-urlencoded,要么为 multipart/form-data

再次强调,FastAPI 使得实现这一需求变得非常简单。然而,你需要一个额外的 Python 依赖项 python-multipart 来处理这种数据。和往常一样,你可以通过 pip 安装它:


$ pip install python-multipart

然后,你可以使用 FastAPI 提供的专门处理表单数据的功能。首先,让我们看看如何处理简单的表单数据。

表单数据

获取表单数据字段的方法类似于我们在 请求体 部分讨论的获取单个 JSON 属性的方法。以下示例与在那里探索的示例大致相同。不过,这个示例期望接收的是表单编码的数据,而不是 JSON:

chapter03_form_data_01.py


@app.post("/users")async def create_user(name: str = Form(...), age: int = Form(...)):
    return {"name": name, "age": age}

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter03/chapter03_form_data_01.py

唯一的不同之处是,我们使用 Form 函数代替 Body。你可以使用 HTTPie 和 --form 选项来尝试这个端点,以强制数据进行表单编码:


$ http -v --form POST http://localhost:8000/users name=John age=30POST /users HTTP/1.1
Accept: */*
Accept-Encoding: gzip, deflate
Connection: keep-alive
Content-Length: 16
Content-Type: application/x-www-form-urlencoded; charset=utf-8
Host: localhost:8000
User-Agent: HTTPie/3.2.1
name=John&age=30
HTTP/1.1 200 OK
content-length: 24
content-type: application/json
date: Thu, 10 Nov 2022 09:56:28 GMT
server: uvicorn

{
    "age": 30,
    "name": "John"
}

注意请求中Content-Type头和正文数据表示方式的变化。你还可以看到,响应依旧以 JSON 格式提供。除非另有说明,FastAPI 默认总是输出 JSON 响应,无论输入数据的形式如何。

当然,我们之前看到的PathQueryBody的验证选项仍然可用。你可以在路径 参数部分找到它们的描述。

值得注意的是,与 JSON 有效负载不同,FastAPI 不允许你定义 Pydantic 模型来验证表单数据。相反,你必须手动为路径操作函数定义每个字段作为参数。

现在,让我们继续讨论如何处理文件上传。

文件上传

上传文件是 Web 应用程序中的常见需求,无论是图片还是文档,FastAPI 提供了一个参数函数File来实现这一功能。

让我们看一个简单的例子,你可以直接将文件作为bytes对象来获取:

chapter03_file_uploads_01.py


from fastapi import FastAPI, Fileapp = FastAPI()
@app.post("/files")
async def upload_file(file: bytes = File(...)):
    return {"file_size": len(file)}

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter03/chapter03_file_uploads_01.py

再次可以看到,这种方法依然是相同的:我们为路径操作函数定义一个参数file,并添加类型提示bytes,然后我们将File函数作为该参数的默认值。通过这种方式,FastAPI 明白它需要从请求体中名为file的部分获取原始数据,并将其作为bytes返回。

我们仅通过调用len函数来返回该bytes对象的文件大小。

在代码示例仓库中,你应该能找到一张猫的图片:github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/blob/main/assets/cat.jpg

让我们使用 HTTPie 将其上传到我们的端点。上传文件时,输入文件上传字段的名称(这里是file),后面跟上@和你要上传的文件路径。别忘了设置--form选项:


$ http --form POST http://localhost:8000/files file@./assets/cat.jpgHTTP/1.1 200 OK
content-length: 19
content-type: application/json
date: Thu, 10 Nov 2022 10:00:38 GMT
server: uvicorn
{
    "file_size": 71457
}

成功了!我们正确地获取了文件的字节大小。

这种方法的一个缺点是,上传的文件完全存储在内存中。因此,尽管它适用于小文件,但对于较大的文件,很可能会遇到问题。而且,操作bytes对象在文件处理上并不总是方便的。

为了解决这个问题,FastAPI 提供了一个 UploadFile 类。该类会将数据存储在内存中,直到达到某个阈值,之后它会自动将数据存储到 磁盘 的临时位置。这使得你可以接受更大的文件,而不会耗尽内存。此外,暴露的对象实例还提供了有用的元数据,比如内容类型,以及一个 类文件 接口。这意味着你可以像处理普通文件一样在 Python 中操作它,并将其传递给任何期望文件的函数。

使用它时,你只需将其指定为类型提示,而不是 bytes

chapter03_file_uploads_02.py


from fastapi import FastAPI, File, UploadFileapp = FastAPI()
@app.post("/files")
async def upload_file(file: UploadFile = File(...)):
    return {"file_name": file.filename, "content_type": file.content_type}

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter03/chapter03_file_uploads_02.py

请注意,在这里,我们返回了 filenamecontent_type 属性。内容类型对于 检查文件类型 特别有用,如果上传的文件类型不是你预期的类型,可以考虑拒绝它。

这是使用 HTTPie 的结果:


$ http --form POST http://localhost:8000/files file@./assets/cat.jpgHTTP/1.1 200 OK
content-length: 51
content-type: application/json
date: Thu, 10 Nov 2022 10:04:22 GMT
server: uvicorn
{
    "content_type": "image/jpeg",
    "file_name": "cat.jpg"
}

你甚至可以通过将参数类型提示为 UploadFile 的列表来接受多个文件:

chapter03_file_uploads_03.py


@app.post("/files")async def upload_multiple_files(files: list[UploadFile] = File(...)):
    return [
        {"file_name": file.filename, "content_type": file.content_type}
        for file in files
    ]

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter03/chapter03_file_uploads_03.py

要使用 HTTPie 上传多个文件,只需重复该参数。它应该如下所示:


$ http --form POST http://localhost:8000/files files@./assets/cat.jpg files@./assets/cat.jpgHTTP/1.1 200 OK
content-length: 105
content-type: application/json
date: Thu, 10 Nov 2022 10:06:09 GMT
server: uvicorn
[
    {
        "content_type": "image/jpeg",
        "file_name": "cat.jpg"
    },
    {
        "content_type": "image/jpeg",
        "file_name": "cat.jpg"
    }
]

现在,你应该能够在 FastAPI 应用程序中处理表单数据和文件上传了。到目前为止,你已经学会了如何管理用户交互的数据。然而,还有一些不太显眼但非常有趣的信息:headers。接下来我们将探讨它们。

Headers 和 cookies

除了 URL 和主体,HTTP 请求的另一个重要部分是 headers。它们包含各种元数据,当处理请求时非常有用。常见的用法是将它们用于身份验证,例如通过著名的 cookies

再次强调,在 FastAPI 中获取它们只需要一个类型提示和一个参数函数。让我们来看一个简单的例子,我们想要检索名为 Hello 的 header:

chapter03_headers_cookies_01.py


@app.get("/")async def get_header(hello: str = Header(...)):
    return {"hello": hello}

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter03/chapter03_headers_cookies_01.py

在这里,你可以看到我们只需使用 Header 函数作为 hello 参数的默认值。参数的名称决定了我们想要检索的 header 的 key。让我们看看这个如何实现:


$ http GET http://localhost:8000 'Hello: World'HTTP/1.1 200 OK
content-length: 17
content-type: application/json
date: Thu, 10 Nov 2022 10:10:12 GMT
server: uvicorn
{
    "hello": "World"
}

FastAPI 能够成功获取头部值。由于没有指定默认值(我们放置了省略号),因此该头部是必需的。如果缺失,将再次返回 422 状态错误响应。

此外,请注意,FastAPI 会自动将头部名称转换为 小写字母。除此之外,由于头部名称通常由短横线 - 分隔,它还会自动将其转换为蛇形命名法。因此,它开箱即用,能够适配任何有效的 Python 变量名。以下示例展示了这种行为,通过获取 User-Agent 头部来实现:

chapter03_headers_cookies_02.py


@app.get("/")async def get_header(user_agent: str = Header(...)):
    return {"user_agent": user_agent}

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter03/chapter03_headers_cookies_02.py

现在,让我们发出一个非常简单的请求。我们将保持 HTTPie 的默认用户代理来看看会发生什么:


$ http -v GET http://localhost:8000GET / HTTP/1.1
Accept: */*
Accept-Encoding: gzip, deflate
Connection: keep-alive
Host: localhost:8000
User-Agent: HTTPie/3.2.1
HTTP/1.1 200 OK
content-length: 29
content-type: application/json
date: Thu, 10 Nov 2022 10:12:17 GMT
server: uvicorn
{
    "user_agent": "HTTPie/3.2.1"
}

什么是用户代理?

用户代理是大多数 HTTP 客户端(如 HTTPie 或 cURL 和网页浏览器)自动添加的 HTTP 头部。它是服务器识别请求来源应用程序的一种方式。在某些情况下,Web 服务器可以利用这些信息来调整响应。

头部的一个非常特殊的案例是 cookies。你可以通过自己解析 Cookie 头部来获取它们,但那会有点繁琐。FastAPI 提供了另一个参数函数,它可以自动为你处理这些。

以下示例简单地获取一个名为 hello 的 cookie:

chapter03_headers_cookies_03.py


@app.get("/")async def get_cookie(hello: str | None = Cookie(None)):
    return {"hello": hello}

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter03/chapter03_headers_cookies_03.py

请注意,我们为参数添加了类型提示 str | None,并为 Cookie 函数设置了默认值 None。这样,即使请求中没有设置 cookie,FastAPI 仍会继续处理,并且不会生成 422 状态错误响应。

头部和 cookies 可以成为实现身份验证功能的非常有用的工具。在 第七章在 FastAPI 中管理身份验证和安全性,你将了解到一些内置的安全函数,它们能帮助你实现常见的身份验证方案。

请求对象

有时,你可能会发现需要访问一个包含所有相关数据的原始请求对象。那是完全可以做到的。只需在路径操作函数中声明一个以 Request 类为类型提示的参数:

chapter03_request_object_01.py


from fastapi import FastAPI, Requestapp = FastAPI()
@app.get("/")
async def get_request_object(request: Request):
    return {"path": request.url.path}

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter03/chapter03_request_object_01.py

在底层,这是来自 Starlette 的Request对象,Starlette 是一个为 FastAPI 提供所有核心服务器逻辑的库。你可以在 Starlette 的官方文档中查看这个对象的完整方法和属性描述(www.starlette.io/requests/)。

恭喜!你现在已经学习了有关如何在 FastAPI 中处理请求数据的所有基础知识。如你所学,无论你想查看 HTTP 请求的哪个部分,逻辑都是相同的。只需命名你想要获取的参数,添加类型提示,并使用参数函数告诉 FastAPI 它应该在哪里查找。你甚至可以添加一些验证逻辑!

在接下来的部分,我们将探讨 REST API 任务的另一面:返回响应。

自定义响应

在之前的部分中,你已经学到,直接在路径操作函数中返回字典或 Pydantic 对象就足够让 FastAPI 返回一个 JSON 响应。

大多数情况下,你会想进一步自定义这个响应;例如,通过更改状态码、抛出验证错误和设置 cookies。FastAPI 提供了不同的方式来实现这一点,从最简单的情况到最复杂的情况。首先,我们将学习如何通过使用路径操作参数来声明性地自定义响应。

路径操作参数

创建第一个端点并在本地运行部分中,你学到了为了创建新的端点,你必须在路径操作函数上方放置一个装饰器。这个装饰器接受许多选项,包括自定义响应的选项。

状态码

在 HTTP 响应中最明显的自定义项是当路径操作函数执行顺利时的200状态码。

有时,改变这个状态可能很有用。例如,在 REST API 中,返回201 Created状态码是一种良好的实践,当端点执行结果是创建了一个新对象时。

要设置此项,只需在路径装饰器中指定status_code参数:

chapter03_response_path_parameters_01.py


from fastapi import FastAPI, statusfrom pydantic import BaseModel
class Post(BaseModel):
    title: str
app = FastAPI()
@app.post("/posts", status_code=status.HTTP_201_CREATED)
async def create_post(post: Post):
    return post

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter03/chapter03_response_path_parameters_01.py

装饰器的参数紧跟路径作为关键字参数。status_code 选项简单地接受一个整数,表示状态码。我们本可以写成 status_code=201,但是 FastAPI 在 status 子模块中提供了一个有用的列表,可以提高代码的可读性,正如你在这里看到的。

我们可以尝试这个端点,以获得结果状态码:


$ http POST http://localhost:8000/posts title="Hello"HTTP/1.1 201 Created
content-length: 17
content-type: application/json
date: Thu, 10 Nov 2022 10:24:24 GMT
server: uvicorn
{
    "title": "Hello"
}

我们得到了 201 状态码。

重要的是要理解,这个覆盖状态码的选项只有在一切顺利时才有用。如果你的输入数据无效,你仍然会收到 422 状态错误响应。

另一个有趣的场景是当你没有任何内容可返回时,比如在删除一个对象时。在这种情况下,204 No content 状态码非常适合。以下示例中,我们实现了一个简单的 delete 端点,设置了这个响应状态码:

chapter03_response_path_parameters_02.py


# Dummy databaseposts = {
    1: Post(title="Hello", nb_views=100),
}
@app.delete("/posts/{id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_post(id: int):
    posts.pop(id, None)
    return None

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter03/chapter03_response_path_parameters_02.py

注意,你完全可以在路径操作函数中返回 None。FastAPI 会处理它,并返回一个空正文的响应。

动态设置状态码一节中,你将学习如何在路径操作逻辑中动态定制状态码。

响应模型

在 FastAPI 中,主要的用例是直接返回一个 Pydantic 模型,它会自动转换成格式正确的 JSON。然而,往往你会发现输入数据、数据库中存储的数据和你希望展示给最终用户的数据之间会有一些差异。例如,某些字段可能是私密的或仅供内部使用,或者某些字段可能只在创建过程中有用,之后就会被丢弃。

现在,让我们考虑一个简单的例子。假设你有一个包含博客文章的数据库。这些博客文章有几个属性,比如标题、内容或创建日期。此外,你还会存储每篇文章的浏览量,但你不希望最终用户看到这些数据。

你可以按以下标准方法进行操作:

chapter03_response_path_parameters_03.py


from fastapi import FastAPIfrom pydantic import BaseModel
class Post(BaseModel):
    title: str
    nb_views: int
app = FastAPI()
# Dummy database
posts = {
    1: Post(title="Hello", nb_views=100),
}
@app.get("/posts/{id}")
async def get_post(id: int):
    return posts[id]

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter03/chapter03_response_path_parameters_03.py

然后调用这个端点:


$ http GET http://localhost:8000/posts/1HTTP/1.1 200 OK
content-length: 32
content-type: application/json
date: Thu, 10 Nov 2022 10:29:33 GMT
server: uvicorn
{
    "nb_views": 100,
    "title": "Hello"
}

nb_views 属性出现在输出中。然而,我们并不希望这样。response_model 选项正是为了这个目的,它指定了另一个只输出我们需要的属性的模型。首先,让我们定义一个只有 title 属性的 Pydantic 模型:

chapter03_response_path_parameters_04.py


class PublicPost(BaseModel):    title: str

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter03/chapter03_response_path_parameters_04.py

然后,唯一的变化是将 response_model 选项作为关键字参数添加到路径装饰器中:

chapter03_response_path_parameters_04.py


@app.get("/posts/{id}", response_model=PublicPost)async def get_post(id: int):
    return posts[id]

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter03/chapter03_response_path_parameters_04.py

现在,让我们尝试调用这个端点:


$ http GET http://localhost:8000/posts/1HTTP/1.1 200 OK
content-length: 17
content-type: application/json
date: Thu, 10 Nov 2022 10:31:43 GMT
server: uvicorn
{
    "title": "Hello"
}

nb_views 属性不再存在!多亏了 response_model 选项,FastAPI 在序列化之前自动将我们的 Post 实例转换为 PublicPost 实例。现在我们的私密数据是安全的!

好消息是,这个选项也被交互式文档考虑在内,文档会向最终用户展示正确的输出架构,正如您在 图 3.2 中看到的那样:

图 3.2 – 交互式文档中的响应模型架构

图 3.2 – 交互式文档中的响应模型架构

到目前为止,您已经了解了可以帮助您快速定制 FastAPI 生成的响应的选项。现在,我们将介绍另一种方法,它将开启更多的可能性。

响应参数

HTTP 响应中不仅仅有主体和状态码才是有趣的部分。有时,返回一些自定义头部或设置 cookies 也可能是有用的。这可以通过 FastAPI 直接在路径操作逻辑中动态完成。怎么做呢?通过将 Response 对象作为路径操作函数的一个参数进行注入。

设置头部

和往常一样,这仅涉及为参数设置正确的类型提示。以下示例向您展示了如何设置自定义头部:

chapter03_response_parameter_01.py


from fastapi import FastAPI, Responseapp = FastAPI()
@app.get("/")
async def custom_header(response: Response):
    response.headers["Custom-Header"] = "Custom-Header-Value"
    return {"hello": "world"}

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter03/chapter03_response_parameter_01.py

Response对象提供了一组属性,包括headers。它是一个简单的字典,键是头部名称,值是其关联的值。因此,设置自定义头部相对简单。

此外,请注意你不需要返回Response对象。你仍然可以返回可编码为 JSON 的数据,FastAPI 会处理形成正确的响应,包括你设置的头部。因此,我们在路径操作参数部分讨论的response_modelstatus_code选项仍然有效。

让我们查看结果:


$ http GET http://localhost:8000HTTP/1.1 200 OK
content-length: 17
content-type: application/json
custom-header: Custom-Header-Value
date: Thu, 10 Nov 2022 10:35:11 GMT
server: uvicorn
{
    "hello": "world"
}

我们的自定义头部是响应的一部分。

如我们之前提到的,这种方法的好处是它在你的路径操作逻辑中。这意味着你可以根据业务逻辑的变化动态地设置头部。

当你希望在每次访问之间保持用户在浏览器中的状态时,cookie 也特别有用。

如果你想让浏览器在响应中保存一些 cookie,当然可以自己构建Set-Cookie头部并将其设置在headers字典中,就像我们在前面的命令块中看到的那样。然而,由于这样做可能相当复杂,Response对象提供了一个方便的set_cookie方法:

chapter03_response_parameter_02.py


@app.get("/")async def custom_cookie(response: Response):
    response.set_cookie("cookie-name", "cookie-value", max_age=86400)
    return {"hello": "world"}

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter03/chapter03_response_parameter_02.py

在这里,我们简单地设置了一个名为cookie-name的 cookie,值为cookie-value。它将在浏览器删除之前有效 86,400 秒。

让我们试试看:


$ http GET http://localhost:8000HTTP/1.1 200 OK
content-length: 17
content-type: application/json
date: Thu, 10 Nov 2022 10:37:47 GMT
server: uvicorn
Set-Cookie: cookie-name=cookie-value; Max-Age=86400; Path=/; SameSite=lax
{
    "hello": "world"
}

在这里,你可以看到我们有一个很好的Set-Cookie头部,包含了我们 cookie 的所有属性。

正如你所知道的,cookie 的选项比我们这里展示的要多;例如,路径、域和仅限 HTTP。set_cookie方法支持所有这些选项。你可以在官方的 Starlette 文档中查看完整的选项列表(因为Response也是从 Starlette 借来的),链接为www.starlette.io/responses/#set-cookie

如果你不熟悉Set-Cookie头部,我们还建议你参考MDN Web Docs,可以通过developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie访问。

当然,如果你需要设置多个 cookie,可以多次调用这个方法。

动态设置状态码

路径操作参数部分,我们讨论了如何声明性地设置响应的状态码。这个方法的缺点是,无论内部发生什么,它总是相同的。

假设我们有一个端点,可以在数据库中更新对象,或者如果对象不存在,则创建它。一种好的方法是在对象已经存在时返回200 OK状态,而当对象需要被创建时返回201 Created状态。

要做到这一点,你可以简单地在Response对象上设置status_code属性:

chapter03_response_parameter_03.py


# Dummy databaseposts = {
    1: Post(title="Hello"),
}
@app.put("/posts/{id}")
async def update_or_create_post(id: int, post: Post, response: Response):
    if id not in posts:
        response.status_code = status.HTTP_201_CREATED
    posts[id] = post
    return posts[id]

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter03/chapter03_response_parameter_03.py

首先,我们检查路径中的 ID 是否在数据库中存在。如果不存在,我们将状态码更改为201。然后,我们简单地将帖子分配给数据库中的这个 ID。

让我们先尝试一个已有的帖子:


$ http PUT http://localhost:8000/posts/1 title="Updated title"HTTP/1.1 200 OK
content-length: 25
content-type: application/json
date: Thu, 10 Nov 2022 10:41:47 GMT
server: uvicorn
{
    "title": "Updated title"
}

ID 为1的帖子已经存在,因此我们得到了200状态码。现在,让我们尝试一个不存在的 ID:


$ http PUT http://localhost:8000/posts/2 title="New title"HTTP/1.1 201 Created
content-length: 21
content-type: application/json
date: Thu, 10 Nov 2022 10:42:20 GMT
server: uvicorn
{
    "title": "New title"
}

我们得到了201状态码!

现在,你已经有了一种在逻辑中动态设置状态码的方法。但请记住,它们不会被自动文档检测到。因此,它们不会出现在文档中的可能响应状态码中。

你可能会想使用这种方法设置错误状态码,例如400 Bad Request404 Not Found。实际上,你不应该这么做。FastAPI 提供了一种专门的方法来处理这个问题:HTTPException

引发 HTTP 错误

在调用 REST API 时,很多时候你会发现事情不太顺利;你可能会遇到错误的参数、无效的有效载荷,或者对象已经不再存在了。错误可能有很多原因。

这就是为什么检测这些错误并向最终用户抛出清晰、明确的错误信息如此重要,让他们能够纠正自己的错误。在 REST API 中,有两个非常重要的东西可以用来返回信息:状态码和有效载荷。

状态码可以为你提供关于错误性质的宝贵线索。由于 HTTP 协议提供了各种各样的错误状态码,最终用户甚至可能不需要读取有效载荷就能理解问题所在。

当然,同时提供一个清晰的错误信息总是更好的,以便提供更多的细节并增加一些有用的信息,告诉最终用户如何解决问题。

错误状态码至关重要

一些 API 选择始终返回200状态码,并在有效载荷中包含一个属性,指明请求是否成功,例如{"success": false}。不要这么做。RESTful 哲学鼓励你使用 HTTP 语义来赋予数据含义。必须解析输出并寻找一个属性来判断调用是否成功,是一种糟糕的设计。

要在 FastAPI 中抛出 HTTP 错误,你需要抛出一个 Python 异常 HTTPException。这个异常类允许我们设置状态码和错误信息。它会被 FastAPI 的错误处理程序捕获,后者负责形成正确的 HTTP 响应。

在下面的示例中,如果 passwordpassword_confirm 的负载属性不匹配,我们将抛出一个 400 Bad Request 错误:

chapter03_raise_errors_01.py


@app.post("/password")async def check_password(password: str = Body(...), password_confirm: str = Body(...)):
    if password != password_confirm:
        raise HTTPException(
            status.HTTP_400_BAD_REQUEST,
            detail="Passwords don't match.",
        )
    return {"message": "Passwords match."}

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter03/chapter03_raise_errors_01.py

正如你所看到的,如果密码不匹配,我们直接抛出 HTTPException。第一个参数是状态码,而 detail 关键字参数让我们编写错误信息。

让我们来看看它是如何工作的:


$ http POST http://localhost:8000/password password="aa" password_confirm="bb"HTTP/1.1 400 Bad Request
content-length: 35
content-type: application/json
date: Thu, 10 Nov 2022 10:46:36 GMT
server: uvicorn
{
    "detail": "Passwords don't match."
}

在这里,我们确实收到了 400 状态码,并且我们的错误信息已经很好地包装在一个带有 detail 键的 JSON 对象中。这就是 FastAPI 默认处理错误的方式。

实际上,你不仅仅可以用一个简单的字符串作为错误信息:你可以返回一个字典或列表,以便获得结构化的错误信息。例如,看看下面的代码片段:

chapter03_raise_errors_02.py


        raise HTTPException(            status.HTTP_400_BAD_REQUEST,
            detail={
                "message": "Passwords don't match.",
                "hints": [
                    "Check the caps lock on your keyboard",
                    "Try to make the password visible by clicking on the eye icon to check your typing",
                ],
            },
        )

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter03/chapter03_raise_errors_02.py

就这样!现在你已经能够抛出错误并向最终用户提供有意义的错误信息。

到目前为止,你所看到的方法应该涵盖了开发 API 时大多数情况下会遇到的情况。然而,有时你会遇到需要自己构建完整 HTTP 响应的场景。这是下一节的内容。

构建自定义响应

大多数情况下,你只需向 FastAPI 提供一些数据以进行序列化,FastAPI 就会自动处理构建 HTTP 响应。FastAPI 底层使用了 Response 的一个子类 JSONResponse。很容易预见,这个响应类负责将一些数据序列化为 JSON 并添加正确的 Content-Type 头部。

然而,还有其他一些响应类可以处理常见的情况:

  • HTMLResponse:可以用来返回一个 HTML 响应

  • PlainTextResponse:可以用来返回原始文本

  • RedirectResponse:可以用来执行重定向

  • StreamingResponse:可以用来流式传输字节流

  • FileResponse:可以用来根据本地磁盘中文件的路径自动构建正确的文件响应

你有两种使用它们的方法:要么在路径装饰器上设置 response_class 参数,要么直接返回一个响应实例。

使用response_class参数

这是返回自定义响应最简单直接的方式。实际上,做到这一点,你甚至不需要创建类实例:你只需要像标准 JSON 响应那样返回数据。

这非常适合HTMLResponsePlainTextResponse

chapter03_custom_response_01.py


from fastapi import FastAPIfrom fastapi.responses import HTMLResponse, PlainTextResponse
app = FastAPI()
@app.get("/html", response_class=HTMLResponse)
async def get_html():
    return """
        <html>
            <head>
                <title>Hello world!</title>
            </head>
            <body>
                <h1>Hello world!</h1>
            </body>
        </html>
    """
@app.get("/text", response_class=PlainTextResponse)
async def text():
    return "Hello world!"

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter03/chapter03_custom_response_01.py

通过在装饰器上设置response_class参数,你可以更改 FastAPI 用于构建响应的类。然后,你可以简单地返回这种类型响应的有效数据。请注意,响应类是通过fastapi.responses模块导入的。

这点很棒,因为你可以将这个选项与我们在路径操作参数部分看到的选项结合使用。使用我们在响应参数部分描述的Response参数也能完美工作!

然而,对于其他响应类,你必须自己构建实例,然后返回它。

实现重定向

如前所述,RedirectResponse是一个帮助你构建 HTTP 重定向的类,它仅仅是一个带有Location头指向新 URL 并且状态码在3xx 范围内的 HTTP 响应。它只需要你希望重定向到的 URL 作为第一个参数:

chapter03_custom_response_02.py


@app.get("/redirect")async def redirect():
    return RedirectResponse("/new-url")

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter03/chapter03_custom_response_02.py

默认情况下,它将使用307 Temporary Redirect状态码,但你可以通过status_code参数进行更改:

chapter03_custom_response_03.py


@app.get("/redirect")async def redirect():
    return RedirectResponse("/new-url", status_code=status.HTTP_301_MOVED_PERMANENTLY)

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter03/chapter03_custom_response_03.py

提供文件

现在,让我们来看看FileResponse是如何工作的。如果你希望提供一些文件供下载,这是非常有用的。这个响应类将自动负责打开磁盘上的文件并流式传输字节数据,同时附带正确的 HTTP 头。

让我们来看一下如何使用一个端点下载一张猫的图片。你可以在代码示例库中找到它,地址是github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/blob/main/assets/cat.jpg

我们只需要返回一个FileResponse实例,并将我们希望提供的文件路径作为第一个参数:

chapter03_custom_response_04.py


@app.get("/cat")async def get_cat():
    root_directory = Path(__file__).parent.parent
    picture_path = root_directory / "assets" / "cat.jpg"
    return FileResponse(picture_path)

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter03/chapter03_custom_response_04.py

pathlib 模块

Python 提供了一个模块来帮助你处理文件路径,pathlib。它是推荐的路径操作方式,因为它根据你运行的操作系统,自动正确地处理路径。你可以在官方文档中阅读这个模块的函数:docs.python.org/3/library/pathlib.html

让我们检查一下 HTTP 响应的样子:


$ http GET http://localhost:8000/catHTTP/1.1 200 OK
content-length: 71457
content-type: image/jpeg
date: Thu, 10 Nov 2022 11:00:10 GMT
etag: c69cf2514977e3f18251f1bcf1433d0a
last-modified: Fri, 16 Jul 2021 07:08:42 GMT
server: uvicorn
+-----------------------------------------+
| NOTE: binary data not shown in terminal |
+-----------------------------------------+

如你所见,我们的图片已经有了正确的Content-LengthContent-Type头部。响应甚至设置了EtagLast-Modified头部,以便浏览器能够正确缓存该资源。HTTPie 不会显示正文中的二进制数据;不过,如果你在浏览器中打开该端点,你将看到猫的图片出现!

自定义响应

最后,如果你确实有一个没有被提供的类覆盖的情况,你总是可以选择使用Response类来构建你需要的内容。使用这个类,你可以设置一切,包括正文内容和头部信息。

以下示例向你展示如何返回一个 XML 响应:

chapter03_custom_response_05.py


@app.get("/xml")async def get_xml():
    content = """<?xml version="1.0" encoding="UTF-8"?>
        <Hello>World</Hello>
    """
    return Response(content=content, media_type="application/xml")

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter03/chapter03_custom_response_05.py

你可以在 Starlette 文档中查看完整的参数列表:www.starlette.io/responses/#response

路径操作参数和响应参数不会有任何效果

请记住,当你直接返回一个Response类(或它的子类)时,你在装饰器上设置的参数或者在注入的Response对象上进行的操作将不起作用。它们会被你返回的Response对象完全覆盖。如果你需要自定义状态码或头部信息,则在实例化类时使用status_codeheaders参数。

做得好!现在,你已经掌握了创建 REST API 响应所需的所有知识。你已经了解到,FastAPI 提供了合理的默认设置,帮助你迅速创建适当的 JSON 响应。同时,它还为你提供了更多高级对象和选项,让你能够制作自定义响应。

到目前为止,我们所查看的所有示例都非常简短和简单。然而,当你在开发一个真实的应用程序时,你可能会有几十个端点和模型。在本章的最后一部分,我们将探讨如何组织这样的项目,使其更加模块化和易于维护。

组织一个更大的项目,包含多个路由器

在构建一个现实世界的 Web 应用程序时,你可能会有很多代码和逻辑:数据模型、API 端点和服务。当然,所有这些不能都放在一个文件中;我们必须以易于维护和发展的方式组织项目。

FastAPI 支持路由器的概念。它们是你 API 的“子部分”,通常专注于单一类型的对象,如用户或帖子,这些对象在各自的文件中定义。你可以将它们包含到你的主 FastAPI 应用程序中,以便它能够进行相应的路由。

在这一部分中,我们将探索如何使用路由器,以及如何结构化 FastAPI 项目。虽然这种结构是一种做法,并且效果很好,但它并不是一条金科玉律,可以根据你的需求进行调整。

在代码示例的仓库中,有一个名为chapter03_project的文件夹,里面包含了一个示例项目,具有以下结构:github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter03_project

这是项目结构:


.└── chapter03_project/
    ├── schemas/
    │   ├── __init__.py
    │   ├── post.py
    │   └── user.py
    ├── routers/
    │   ├── __init__.py
    │   ├── posts.py
    │   └── users.py
    ├── __init__.py
    ├── app.py
    └── db.py

在这里,你可以看到我们选择了将包含 Pydantic 模型的包放在一边,将路由器放在另一边。在项目的根目录下,我们有一个名为app.py的文件,将暴露主要的 FastAPI 应用程序。db.py 文件定义了一个虚拟数据库,供示例使用。

__init__.py 文件用于正确地将我们的目录定义为 Python 包。你可以在第二章,“Python 编程特性”章节的“包、模块与导入”部分阅读更多细节。

首先,让我们看看 FastAPI 路由器的样子:

users.py


from fastapi import APIRouter, HTTPException, statusfrom chapter03_project.db import db
from chapter03_project.schemas.user import User, UserCreate
router = APIRouter()
@router.get("/")
async def all() -> list[User]:
    return list(db.users.values())

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter03_project/routers/users.py

如你所见,在这里,你不是实例化 FastAPI 类,而是实例化 APIRouter 类。然后,你可以用相同的方式装饰你的路径操作函数。

同时,注意我们从 schemas 包中的相关模块导入了 Pydantic 模型。

我们不会详细讨论端点的逻辑,但我们邀请你阅读相关内容。它使用了我们到目前为止探索的所有 FastAPI 特性。

现在,让我们看看如何导入这个路由器并将其包含在 FastAPI 应用中:

app.py


from fastapi import FastAPIfrom chapter03_project.routers.posts import router as posts_router
from chapter03_project.routers.users import router as users_router
app = FastAPI()
app.include_router(posts_router, prefix="/posts", tags=["posts"])
app.include_router(users_router, prefix="/users", tags=["users"])

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter03_project/routers/app.py

和往常一样,我们实例化了 FastAPI 类。然后,我们使用 include_router 方法添加子路由器。你可以看到,我们只是从相关模块中导入了路由器,并将其作为 include_router 的第一个参数。注意,我们在导入时使用了 as 语法。由于 usersposts 路由器在它们的模块中命名相同,这种语法让我们可以给它们取别名,从而避免了命名冲突

此外,你还可以看到我们设置了 prefix 关键字参数。这使我们能够为该路由器的所有端点路径添加前缀。这样,你就不需要将其硬编码到路由器逻辑中,并且可以轻松地为整个路由器更改它。它还可以用来为你的 API 提供版本化路径,比如 /v1

最后,tags 参数帮助你在交互式文档中对端点进行分组,以便更好地提高可读性。通过这样做,postsusers 端点将在文档中清晰分开。

这就是你需要做的所有事情!你可以像往常一样使用 Uvicorn 运行整个应用:


$ uvicorn chapter03_project.app:app

如果你打开 http://localhost:8000/docs 的交互式文档,你会看到所有的路由都在其中,并且按我们在包含路由器时指定的标签进行分组:

图 3.3 – 交互式文档中的标记路由器

图 3.3 – 交互式文档中的标记路由器

再次说明,你可以看到 FastAPI 功能强大且非常轻量化。路由器的一个好处是你可以将它们嵌套,甚至将子路由器包含在包含其他路由器的路由器中。因此,你可以在很少的努力下构建一个相当复杂的路由层次结构。

总结

干得好!你现在已经熟悉了 FastAPI 的所有基础功能。在本章中,你学习了如何创建和运行 API 端点,验证和获取 HTTP 请求的各个部分的数据:路径、查询、参数、头部,当然还有正文。你还学习了如何根据需求定制 HTTP 响应,无论是简单的 JSON 响应、错误信息还是文件下载。最后,你了解了如何定义独立的 API 路由器,并将它们包含到你的主应用中,以保持清晰且易于维护的项目结构。

现在你已经掌握了足够的知识,可以开始使用 FastAPI 构建你自己的 API。在接下来的章节中,我们将重点讲解 Pydantic 模型。你现在知道它们是 FastAPI 数据验证功能的核心,因此彻底理解它们的工作原理以及如何高效地操作它们至关重要。

第四章:在 FastAPI 中管理 Pydantic 数据模型

本章将详细讲解如何使用 Pydantic 定义数据模型,这是 FastAPI 使用的底层数据验证库。我们将解释如何在不重复代码的情况下实现相同模型的变种,得益于类的继承。最后,我们将展示如何将自定义数据验证逻辑实现到 Pydantic 模型中。

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

  • 使用 Pydantic 定义模型及其字段类型

  • 使用类继承创建模型变种

  • 使用 Pydantic 添加自定义数据验证

  • 使用 Pydantic 对象

技术要求

要运行代码示例,你需要一个 Python 虚拟环境,我们在 第一章Python 开发环境设置 中进行了设置。

你可以在专门的 GitHub 仓库中找到本章的所有代码示例,链接为:github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter04

使用 Pydantic 定义模型及其字段类型

Pydantic 是一个强大的库,用于通过 Python 类和类型提示定义数据模型。这种方法使得这些类与静态类型检查完全兼容。此外,由于它是常规的 Python 类,我们可以使用继承,并且还可以定义我们自己的方法来添加自定义逻辑。

第三章使用 FastAPI 开发 RESTful API 中,你学习了如何使用 Pydantic 定义数据模型的基础:你需要定义一个继承自 BaseModel 的类,并将所有字段列为类的属性,每个字段都有一个类型提示来强制其类型。

在本节中,我们将重点关注模型定义,并查看我们在定义字段时可以使用的所有可能性。

标准字段类型

我们将从定义标准类型字段开始,这只涉及简单的类型提示。让我们回顾一下一个表示个人信息的简单模型。你可以在以下代码片段中看到它:

chapter04_standard_field_types_01.py


from pydantic import BaseModelclass Person(BaseModel):
    first_name: str
    last_name: str
    age: int

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter04/chapter04_standard_field_types_01.py

正如我们所说,你只需要写出字段的名称,并使用预期的类型对其进行类型提示。当然,我们不仅限于标量类型:我们还可以使用复合类型,如列表和元组,或像 datetime 和 enum 这样的类。在下面的示例中,你可以看到一个使用这些更复杂类型的模型:

chapter04_standard_field_types_02.py


from datetime import datefrom enum import Enum
from pydantic import BaseModel, ValidationError
class Gender(str, Enum):
    MALE = "MALE"
    FEMALE = "FEMALE"
    NON_BINARY = "NON_BINARY"
class Person(BaseModel):
    first_name: str
    last_name: str
    gender: Gender
    birthdate: date
    interests: list[str]

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter04/chapter04_standard_field_types_02.py

在这个示例中有三点需要注意。

首先,我们使用标准 Python Enum 类作为 gender 字段的类型。这允许我们指定一组有效值。如果输入的值不在该枚举中,Pydantic 会引发错误,如以下示例所示:

chapter04_standard_field_types_02.py


# Invalid gendertry:
    Person(
        first_name="John",
        last_name="Doe",
        gender="INVALID_VALUE",
        birthdate="1991-01-01",
        interests=["travel", "sports"],
    )
except ValidationError as e:
    print(str(e))

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter04/chapter04_standard_field_types_02.py

如果你运行前面的示例,你将得到如下输出:


1 validation error for Persongender
  value is not a valid enumeration member; permitted: 'MALE', 'FEMALE', 'NON_BINARY' (type=type_error.enum; enum_values=[<Gender.MALE: 'MALE'>, <Gender.FEMALE: 'FEMALE'>, <Gender.NON_BINARY: 'NON_BINARY'>])

实际上,这正是我们在第三章《使用 FastAPI 开发 RESTful API》中所做的,用以限制 path 参数的允许值。

然后,我们将 date Python 类作为 birthdate 字段的类型。Pydantic 能够自动解析以 ISO 格式字符串或时间戳整数给出的日期和时间,并实例化一个合适的 datedatetime 对象。当然,如果解析失败,你也会得到一个错误。你可以在以下示例中进行实验:

chapter04_standard_field_types_02.py


# Invalid birthdatetry:
    Person(
        first_name="John",
        last_name="Doe",
        gender=Gender.MALE,
        birthdate="1991-13-42",
        interests=["travel", "sports"],
    )
except ValidationError as e:
    print(str(e))

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter04/chapter04_standard_field_types_02.py

这是输出结果:


1 validation error for Personbirthdate
  invalid date format (type=value_error.date)

最后,我们将 interests 定义为一个字符串列表。同样,Pydantic 会检查该字段是否是有效的字符串列表。

显然,如果一切正常,我们将得到一个 Person 实例,并能够访问正确解析的字段。这就是我们在以下代码片段中展示的内容:

chapter04_standard_field_types_02.py


# Validperson = Person(
    first_name="John",
    last_name="Doe",
    gender=Gender.MALE,
    birthdate="1991-01-01",
    interests=["travel", "sports"],
)
# first_name='John' last_name='Doe' gender=<Gender.MALE: 'MALE'> birthdate=datetime.date(1991, 1, 1) interests=['travel', 'sports']
print(person)

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter04/chapter04_standard_field_types_02.py

如你所见,这非常强大,我们可以拥有相当复杂的字段类型。但这还不是全部:字段本身可以是 Pydantic 模型,允许你拥有子对象!在以下代码示例中,我们扩展了前面的代码片段,添加了一个 address 字段:

chapter04_standard_field_types_03.py


class Address(BaseModel):    street_address: str
    postal_code: str
    city: str
    country: str
class Person(BaseModel):
    first_name: str
    last_name: str
    gender: Gender
    birthdate: date
    interests: list[str]
    address: Address

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter04/chapter04_standard_field_types_03.py

我们只需定义另一个 Pydantic 模型,并将其作为类型提示使用。现在,你可以使用已经有效的Address实例来实例化Person,或者更好的是,使用字典。在这种情况下,Pydantic 会自动解析它并根据地址模型进行验证。

在下面的代码片段中,我们尝试输入一个无效的地址:

chapter04_standard_field_types_03.py


# Invalid addresstry:
    Person(
        first_name="John",
        last_name="Doe",
        gender=Gender.MALE,
        birthdate="1991-01-01",
        interests=["travel", "sports"],
        address={
            "street_address": "12 Squirell Street",
            "postal_code": "424242",
            "city": "Woodtown",
            # Missing country
        },
    )
except ValidationError as e:
    print(str(e))

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter04/chapter04_standard_field_types_03.py

这将生成以下验证错误:


1 validation error for Personaddress -> country
  field required (type=value_error.missing)

Pydantic 清晰地显示了子对象中缺失的字段。再次强调,如果一切顺利,我们将获得一个Person实例及其关联的Address,如下面的代码片段所示:

chapter04_standard_field_types_03.py


# Validperson = Person(
    first_name="John",
    last_name="Doe",
    gender=Gender.MALE,
    birthdate="1991-01-01",
    interests=["travel", "sports"],
    address={
        "street_address": "12 Squirell Street",
        "postal_code": "424242",
        "city": "Woodtown",
        "country": "US",
    },
)
print(person)

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter04/chapter04_standard_field_types_03.py

可选字段和默认值

到目前为止,我们假设在实例化模型时,每个字段都必须提供。然而,通常情况下,有些值我们希望是可选的,因为它们可能对每个对象实例并不相关。有时,我们还希望为未指定的字段设置默认值。

正如你可能猜到的,这可以通过| None类型注解非常简单地完成,如以下代码片段所示:

chapter04_optional_fields_default_values_01.py


from pydantic import BaseModelclass UserProfile(BaseModel):
    nickname: str
    location: str | None = None
    subscribed_newsletter: bool = True

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter04/chapter04_optional_fields_default_values_01.py

当定义一个字段时,使用| None类型提示,它接受None值。如上面的代码所示,默认值可以通过将值放在等号后面简单地赋值。

但要小心:不要为动态类型(如日期时间)赋予默认值。如果这样做,日期时间实例化只会在模型导入时评估一次。这样一来,你实例化的所有对象都会共享相同的值,而不是每次都生成一个新的值。你可以在以下示例中观察到这种行为:

chapter04_optional_fields_default_values_02.py


class Model(BaseModel):    # Don't do this.
    # This example shows you why it doesn't work.
    d: datetime = datetime.now()
o1 = Model()
print(o1.d)
time.sleep(1)  # Wait for a second
o2 = Model()
print(o2.d)
print(o1.d < o2.d)  # False

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter04/chapter04_optional_fields_default_values_02.py

即使我们在实例化o1o2之间等待了 1 秒钟,d日期时间仍然是相同的!这意味着日期时间只在类被导入时评估一次。

如果你想要一个默认的列表,比如l: list[str] = ["a", "b", "c"],你也会遇到同样的问题。注意,这不仅仅适用于 Pydantic 模型,所有的 Python 对象都会存在这个问题,所以你应该牢记这一点。

那么,我们该如何赋予动态默认值呢?幸运的是,Pydantic 提供了一个Field函数,允许我们为字段设置一些高级选项,其中包括为创建动态值设置工厂。在展示这个之前,我们首先会介绍一下Field函数。

第三章《使用 FastAPI 开发 RESTful API》中,我们展示了如何对请求参数应用一些验证,检查一个数字是否在某个范围内,或一个字符串是否匹配正则表达式。实际上,这些选项直接来自 Pydantic!我们可以使用相同的技术对模型的字段进行验证。

为此,我们将使用 Pydantic 的Field函数,并将其结果作为字段的默认值。在下面的示例中,我们定义了一个Person模型,其中first_namelast_name是必填字段,必须至少包含三个字符,age是一个可选字段,必须是介于0120之间的整数。我们在下面的代码片段中展示了该模型的实现:

chapter04_fields_validation_01.py


from pydantic import BaseModel, Field, ValidationErrorclass Person(BaseModel):
    first_name: str = Field(..., min_length=3)
    last_name: str = Field(..., min_length=3)
    age: int | None = Field(None, ge=0, le=120)

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter04/chapter04_fields_validation_01.py

如你所见,语法与我们之前看到的PathQueryBody非常相似。第一个位置参数定义了字段的默认值。如果字段是必填的,我们使用省略号...。然后,关键字参数用于设置字段的选项,包括一些基本的验证。

你可以在官方 Pydantic 文档中查看Field接受的所有参数的完整列表,网址为pydantic-docs.helpmanual.io/usage/schema/#field-customization

动态默认值

在上一节中,我们曾提醒你不要将动态值设置为默认值。幸运的是,Pydantic 在Field函数中提供了default_factory参数来处理这种用例。这个参数要求你传递一个函数,这个函数将在模型实例化时被调用。因此,每次你创建一个新对象时,生成的对象将在运行时进行评估。你可以在以下示例中看到如何使用它:

chapter04_fields_validation_02.py


from datetime import datetimefrom pydantic import BaseModel, Field
def list_factory():
    return ["a", "b", "c"]
class Model(BaseModel):
    l: list[str] = Field(default_factory=list_factory)
    d: datetime = Field(default_factory=datetime.now)
    l2: list[str] = Field(default_factory=list)

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter04/chapter04_fields_validation_02.py

你只需将一个函数传递给这个参数。不要在其上放置参数:当你实例化新对象时,Pydantic 会自动调用这个函数。如果你需要使用特定的参数调用一个函数,你需要将它包装成自己的函数,正如我们为list_factory所做的那样。

还请注意,默认值所使用的第一个位置参数(如None...)在这里完全省略了。这是有道理的:同时使用默认值和工厂是不一致的。如果你将这两个参数一起设置,Pydantic 会抛出错误。

使用 Pydantic 类型验证电子邮件地址和 URL

为了方便,Pydantic 提供了一些类,可以作为字段类型来验证一些常见模式,例如电子邮件地址或 URL。

在以下示例中,我们将使用EmailStrHttpUrl来验证电子邮件地址和 HTTP URL。

要使EmailStr工作,你需要一个可选的依赖项email-validator,你可以使用以下命令安装:


(venv)$ pip install email-validator

这些类的工作方式与其他类型或类相同:只需将它们作为字段的类型提示使用。你可以在以下代码片段中看到这一点:

chapter04_pydantic_types_01.py


from pydantic import BaseModel, EmailStr, HttpUrl, ValidationErrorclass User(BaseModel):
    email: EmailStr
    website: HttpUrl

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter04/chapter04_pydantic_types_01.py

在以下示例中,我们检查电子邮件地址是否被正确验证:

chapter04_pydantic_types_01.py


# Invalid emailtry:
    User(email="jdoe", website="https://www.example.com")
except ValidationError as e:
    print(str(e))

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter04/chapter04_pydantic_types_01.py

你将看到以下输出:


1 validation error for Useremail
  value is not a valid email address (type=value_error.email)

我们还检查了 URL 是否被正确解析,如下所示:

chapter04_pydantic_types_01.py


# Invalid URLtry:
    User(email="jdoe@example.com", website="jdoe")
except ValidationError as e:
    print(str(e))

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter04/chapter04_pydantic_types_01.py

你将看到以下输出:


1 validation error for Userwebsite
  invalid or missing URL scheme (type=value_error.url.scheme)

如果你查看下面的有效示例,你会发现 URL 被解析为一个对象,这样你就可以访问它的不同部分,比如协议或主机名:

chapter04_pydantic_types_01.py


# Validuser = User(email="jdoe@example.com", website="https://www.example.com")
# email='jdoe@example.com' website=HttpUrl('https://www.example.com', scheme='https', host='www.example.com', tld='com', host_type='domain')
print(user)

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter04/chapter04_pydantic_types_01.py

Pydantic 提供了一套非常丰富的类型,可以帮助你处理各种情况。我们邀请你查阅官方文档中的完整列表:pydantic-docs.helpmanual.io/usage/types/#pydantic-types

现在你对如何通过使用更高级的类型或利用验证功能来细化定义 Pydantic 模型有了更清晰的了解。正如我们所说,这些模型是 FastAPI 的核心,你可能需要为同一个实体定义多个变体,以应对不同的情况。在接下来的部分中,我们将展示如何做到这一点,同时最小化重复。

使用类继承创建模型变体

第三章使用 FastAPI 开发 RESTful API中,我们看到一个例子,在这个例子中我们需要定义 Pydantic 模型的两个变体,以便将我们想要存储在后端的数据和我们想要展示给用户的数据分开。这是 FastAPI 中的一个常见模式:你定义一个用于创建的模型,一个用于响应的模型,以及一个用于存储在数据库中的数据模型。

我们在以下示例中展示了这种基本方法:

chapter04_model_inheritance_01.py


from pydantic import BaseModelclass PostCreate(BaseModel):
    title: str
    content: str
class PostRead(BaseModel):
    id: int
    title: str
    content: str
class Post(BaseModel):
    id: int
    title: str
    content: str
    nb_views: int = 0

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter04/chapter04_model_inheritance_01.py

这里我们有三个模型,涵盖了三种情况:

  • PostCreate将用于POST端点来创建新帖子。我们期望用户提供标题和内容;然而,标识符ID)将由数据库自动确定。

  • PostRead将用于我们检索帖子数据时。我们当然希望获取它的标题和内容,还希望知道它在数据库中的关联 ID。

  • Post 将包含我们希望存储在数据库中的所有数据。在这里,我们还想存储查看次数,但希望将其保密,以便内部进行统计。

你可以看到这里我们重复了很多,特别是 titlecontent 字段。在包含许多字段和验证选项的大型示例中,这可能会迅速变得难以管理。

避免这种情况的方法是利用模型继承。方法很简单:找出每个变种中共有的字段,并将它们放入一个模型中,作为所有其他模型的基类。然后,你只需从这个模型继承来创建变体,并添加特定的字段。在以下示例中,我们可以看到使用这种方法后的结果:

chapter04_model_inheritance_02.py


from pydantic import BaseModelclass PostBase(BaseModel):
    title: str
    content: str
class PostCreate(PostBase):
    pass
class PostRead(PostBase):
    id: int
class Post(PostBase):
    id: int
    nb_views: int = 0

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter04/chapter04_model_inheritance_02.py

现在,每当你需要为整个实体添加一个字段时,所需要做的就是将其添加到 PostBase 模型中,如下所示的代码片段所示。

如果你希望在模型中定义方法,这也是非常方便的。记住,Pydantic 模型是普通的 Python 类,因此你可以根据需要实现尽可能多的方法!

chapter04_model_inheritance_03.py


class PostBase(BaseModel):    title: str
    content: str
    def excerpt(self) -> str:
        return f"{self.content[:140]}..."

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter04/chapter04_model_inheritance_03.py

PostBase 中定义 excerpt 方法意味着它将会在每个模型变种中都可用。

虽然这种继承方法不是强制要求的,但它大大有助于防止代码重复,并最终减少错误。我们将在下一节看到,使用自定义验证方法时,它将显得更加有意义。

使用 Pydantic 添加自定义数据验证

到目前为止,我们已经看到了如何通过 Field 参数或 Pydantic 提供的自定义类型为模型应用基本验证。然而,在一个实际项目中,你可能需要为特定情况添加自定义验证逻辑。Pydantic 允许通过定义 validators 来实现这一点,验证方法可以应用于字段级别或对象级别。

在字段级别应用验证

这是最常见的情况:为单个字段定义验证规则。要在 Pydantic 中定义验证规则,我们只需要在模型中编写一个静态方法,并用 validator 装饰器装饰它。作为提醒,装饰器是一种语法糖,它允许用通用逻辑包装函数或类,而不会影响可读性。

以下示例检查出生日期,确保这个人不超过 120 岁:

chapter04_custom_validation_01.py


from datetime import datefrom pydantic import BaseModel, ValidationError, validator
class Person(BaseModel):
    first_name: str
    last_name: str
    birthdate: date
    @validator("birthdate")
    def valid_birthdate(cls, v: date):
        delta = date.today() - v
        age = delta.days / 365
        if age > 120:
            raise ValueError("You seem a bit too old!")
        return v

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter04/chapter04_custom_validation_01.py

如你所见,validator 是一个静态类方法(第一个参数,cls,是类本身),v 参数是要验证的值。它由 validator 装饰器装饰,要求第一个参数是需要验证的参数的名称。

Pydantic 对此方法有两个要求,如下所示:

  • 如果值根据你的逻辑不合法,你应该抛出一个 ValueError 错误并提供明确的错误信息。

  • 否则,你应该返回将被赋值给模型的值。请注意,它不需要与输入值相同:你可以根据需要轻松地更改它。这实际上是我们将在接下来的章节中做的,在 Pydantic 解析之前应用验证

在对象级别应用验证

很多时候,一个字段的验证依赖于另一个字段——例如,检查密码确认是否与密码匹配,或在某些情况下强制要求某个字段为必填项。为了允许这种验证,我们需要访问整个对象的数据。为此,Pydantic 提供了 root_validator 装饰器,如下面的代码示例所示:

chapter04_custom_validation_02.py


from pydantic import BaseModel, EmailStr, ValidationError, root_validatorclass UserRegistration(BaseModel):
    email: EmailStr
    password: str
    password_confirmation: str
    @root_validator()
    def passwords_match(cls, values):
        password = values.get("password")
        password_confirmation = values.get("password_confirmation")
        if password != password_confirmation:
            raise ValueError("Passwords don't match")
        return values

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter04/chapter04_custom_validation_02.py

使用此装饰器的方法类似于 validator 装饰器。静态类方法与 values 参数一起调用,values 是一个 字典,包含所有字段。这样,你可以获取每个字段并实现你的逻辑。

再次强调,Pydantic 对此方法有两个要求,如下所示:

  • 如果根据你的逻辑,值不合法,你应该抛出一个 ValueError 错误并提供明确的错误信息。

  • 否则,你应该返回一个 values 字典,这个字典将被赋值给模型。请注意,你可以根据需要在这个字典中修改某些值。

在 Pydantic 解析之前应用验证

默认情况下,验证器在 Pydantic 完成解析工作之后运行。这意味着你得到的值已经符合你指定的字段类型。如果类型不正确,Pydantic 会抛出错误,而不会调用你的验证器。

然而,有时你可能希望提供一些自定义解析逻辑,以允许你转换那些对于所设置类型来说原本不正确的输入值。在这种情况下,你需要在 Pydantic 解析器之前运行你的验证器:这就是 validatorpre 参数的作用。

在下面的示例中,我们展示了如何将一个由逗号分隔的字符串转换为列表:

chapter04_custom_validation_03.py


from pydantic import BaseModel, validatorclass Model(BaseModel):
    values: list[int]
    @validator("values", pre=True)
    def split_string_values(cls, v):
        if isinstance(v, str):
            return v.split(",")
        return v
m = Model(values="1,2,3")
print(m.values)  # [1, 2, 3]

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter04/chapter04_custom_validation_03.py

你可以看到,在这里我们的验证器首先检查我们是否有一个字符串。如果有,我们将逗号分隔的字符串进行拆分,并返回结果列表;否则,我们直接返回该值。Pydantic 随后会运行它的解析逻辑,因此你仍然可以确保如果 v 是无效值,会抛出错误。

使用 Pydantic 对象

在使用 FastAPI 开发 API 接口时,你可能会处理大量的 Pydantic 模型实例。接下来,你需要实现逻辑,将这些对象与服务进行连接,比如数据库或机器学习模型。幸运的是,Pydantic 提供了一些方法,使得这个过程变得非常简单。我们将回顾一些开发过程中常用的使用场景。

将对象转换为字典

这可能是你在 Pydantic 对象上执行最多的操作:将其转换为一个原始字典,这样你就可以轻松地将其发送到另一个 API,或者例如用在数据库中。你只需在对象实例上调用 dict 方法。

以下示例重用了我们在本章的标准字段类型部分看到的 PersonAddress 模型:

chapter04_working_pydantic_objects_01.py


person = Person(    first_name="John",
    last_name="Doe",
    gender=Gender.MALE,
    birthdate="1991-01-01",
    interests=["travel", "sports"],
    address={
        "street_address": "12 Squirell Street",
        "postal_code": "424242",
        "city": "Woodtown",
        "country": "US",
    },
)
person_dict = person.dict()
print(person_dict["first_name"])  # "John"
print(person_dict["address"]["street_address"])  # "12 Squirell Street"

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter04/chapter04_working_pydantic_objects_01.py

如你所见,调用 dict 就足以将所有数据转换为字典。子对象也会递归地被转换:address 键指向一个包含地址属性的字典。

有趣的是,dict 方法支持一些参数,允许你选择要转换的属性子集。你可以指定你希望包括的属性,或者希望排除的属性,正如下面的代码片段所示:

chapter04_working_pydantic_objects_02.py


person_include = person.dict(include={"first_name", "last_name"})print(person_include)  # {"first_name": "John", "last_name": "Doe"}
person_exclude = person.dict(exclude={"birthdate", "interests"})
print(person_exclude)

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter04/chapter04_working_pydantic_objects_02.py

includeexclude 参数期望一个集合,集合中包含你希望包含或排除的字段的键。

对于像 address 这样的嵌套结构,你也可以使用字典来指定要包含或排除的子字段,以下示例演示了这一点:

chapter04_working_pydantic_objects_02.py


person_nested_include = person.dict(    include={
        "first_name": ...,
        "last_name": ...,
        "address": {"city", "country"},
    }
)
# {"first_name": "John", "last_name": "Doe", "address": {"city": "Woodtown", "country": "US"}}
print(person_nested_include)

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter04/chapter04_working_pydantic_objects_02.py

结果的 address 字典仅包含城市和国家。请注意,当使用这种语法时,像 first_namelast_name 这样的标量字段必须与省略号 ... 一起使用。

如果你经常进行某种转换,将其放入一个方法中以便于随时重用是很有用的,以下示例演示了这一点:

chapter04_working_pydantic_objects_03.py


class Person(BaseModel):    first_name: str
    last_name: str
    gender: Gender
    birthdate: date
    interests: list[str]
    address: Address
    def name_dict(self):
        return self.dict(include={"first_name", "last_name"})

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter04/chapter04_working_pydantic_objects_03.py

从子类对象创建实例

通过类继承创建模型变体 这一节中,我们研究了根据具体情况创建特定模型类的常见模式。特别地,你会有一个专门用于创建端点的模型,其中只有创建所需的字段,以及一个包含我们想要存储的所有字段的数据库模型。

让我们再看一下 Post 示例:

chapter04_working_pydantic_objects_04.py


class PostBase(BaseModel):    title: str
    content: str
class PostCreate(PostBase):
    pass
class PostRead(PostBase):
    id: int
class Post(PostBase):
    id: int
    nb_views: int = 0

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter04/chapter04_working_pydantic_objects_04.py

假设我们有一个创建端点的 API。在这种情况下,我们会得到一个只有 titlecontentPostCreate 实例。然而,在将其存储到数据库之前,我们需要构建一个适当的 Post 实例。

一种方便的做法是同时使用 dict 方法和解包语法。在以下示例中,我们使用这种方法实现了一个创建端点:

chapter04_working_pydantic_objects_04.py


@app.post("/posts", status_code=status.HTTP_201_CREATED, response_model=PostRead)async def create(post_create: PostCreate):
    new_id = max(db.posts.keys() or (0,)) + 1
    post = Post(id=new_id, **post_create.dict())
    db.posts[new_id] = post
    return post

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter04/chapter04_working_pydantic_objects_04.py

如你所见,路径操作函数为我们提供了一个有效的PostCreate对象。然后,我们想将其转换为Post对象。

我们首先确定缺失的id属性,这是由数据库提供的。在这里,我们使用基于字典的虚拟数据库,因此我们只需取数据库中已存在的最大键并将其递增。在实际情况下,这个值会由数据库自动确定。

这里最有趣的一行是Post实例化。你可以看到,我们首先使用关键字参数分配缺失的字段,然后解包post_create的字典表示。提醒一下,**在函数调用中的作用是将像{"title": "Foo", "content": "Bar"}这样的字典转换为像title="Foo", content="Bar"这样的关键字参数。这是一种非常方便和动态的方式,将我们已有的所有字段设置到新的模型中。

请注意,我们还在路径操作装饰器中设置了response_model参数。我们在第三章使用 FastAPI 开发 RESTful API中解释了这一点,但基本上,它提示 FastAPI 构建一个只包含PostRead字段的 JSON 响应,即使我们最终返回的是一个Post实例。

部分更新实例

在某些情况下,你可能需要允许部分更新。换句话说,你允许最终用户仅向你的 API 发送他们想要更改的字段,并省略不需要更改的字段。这是实现PATCH端点的常见方式。

为此,你首先需要一个特殊的 Pydantic 模型,所有字段都标记为可选,这样在缺少某个字段时不会引发错误。让我们看看在我们的Post示例中这是什么样的:

chapter04_working_pydantic_objects_05.py


class PostBase(BaseModel):    title: str
    content: str
class PostPartialUpdate(BaseModel):
    title: str | None = None
    content: str | None = None

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter04/chapter04_working_pydantic_objects_05.py

我们现在能够实现一个端点,接受Post字段的子集。由于这是一个更新操作,我们将通过其 ID 从数据库中检索现有的帖子。然后,我们需要找到一种方法,只更新负载中的字段,保持其他字段不变。幸运的是,Pydantic 再次提供了便捷的方法和选项来解决这个问题。

让我们看看如何在以下示例中实现这样的端点:

chapter04_working_pydantic_objects_05.py


@app.patch("/posts/{id}", response_model=PostRead)async def partial_update(id: int, post_update: PostPartialUpdate):
    try:
        post_db = db.posts[id]
        updated_fields = post_update.dict(exclude_unset=True)
        updated_post = post_db.copy(update=updated_fields)
        db.posts[id] = updated_post
        return updated_post
    except KeyError:
        raise HTTPException(status.HTTP_404_NOT_FOUND)

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter04/chapter04_working_pydantic_objects_05.py

我们的路径操作函数接受两个参数:id属性(来自路径)和PostPartialUpdate实例(来自请求体)。

首先要做的是检查这个id属性是否存在于数据库中。由于我们使用字典作为虚拟数据库,访问一个不存在的键会引发KeyError。如果发生这种情况,我们只需抛出一个HTTPException并返回404状态码。

现在是有趣的部分:更新现有对象。你可以看到,首先要做的是使用dict方法将PostPartialUpdate转换为字典。然而,这次我们将exclude_unset参数设置为True。这样做的效果是,Pydantic 不会在结果字典中输出未提供的字段:我们只会得到用户在有效负载中发送的字段。

然后,在我们现有的post_db数据库实例上,调用copy方法。这个方法是克隆 Pydantic 对象到另一个实例的一个有用方法。这个方法的好处在于它甚至接受一个update参数。这个参数期望一个字典,包含所有在复制过程中应该更新的字段:这正是我们想用updated_fields字典来做的!

就这样!我们现在有了一个更新过的post实例,只有在有效负载中需要的更改。你在使用 FastAPI 开发时,可能会经常使用exclude_unset参数和copy方法,所以一定要记住它们——它们会让你的工作更轻松!

总结

恭喜你!你已经学习了 FastAPI 的另一个重要方面:使用 Pydantic 设计和管理数据模型。现在,你应该对创建模型、应用字段级验证、使用内建选项和类型,以及实现你自己的验证方法有信心。你还了解了如何在对象级别应用验证,检查多个字段之间的一致性。你还学会了如何利用模型继承来避免在定义模型变体时出现代码重复。最后,你学会了如何正确处理 Pydantic 模型实例,从而以高效且可读的方式进行转换和更新。

到现在为止,你几乎已经掌握了 FastAPI 的所有功能。现在有一个最后非常强大的功能等着你去学习:依赖注入。这允许你定义自己的逻辑和数值,并将它们直接注入到路径操作函数中,就像你对路径参数和有效负载对象所做的那样,你可以在项目的任何地方重用它们。这是下一章的内容。

第五章:FastAPI 中的依赖注入

在本章中,我们将重点讨论 FastAPI 最有趣的部分之一:依赖注入。你将会看到,它是一种强大且易于阅读的方式,用于在项目中重用逻辑。事实上,它将允许你为项目创建复杂的构建模块,这些模块可以在整个逻辑中重复使用。认证系统、查询参数验证器或速率限制器是依赖项的典型用例。在 FastAPI 中,依赖注入甚至可以递归调用另一个依赖项,从而允许你从基础功能构建高级模块。到本章结束时,你将能够为 FastAPI 创建自己的依赖项,并在项目的多个层次上使用它们。

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

  • 什么是依赖注入?

  • 创建和使用函数依赖项

  • 使用类创建和使用带参数的依赖项

  • 在路径、路由器和全局级别使用依赖项

技术要求

要运行代码示例,你需要一个 Python 虚拟环境,我们在第一章中设置了该环境,Python 开发 环境设置

你可以在专门的 GitHub 仓库中找到本章的所有代码示例:github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter05

什么是依赖注入?

一般来说,依赖注入是一种自动实例化对象及其依赖项的系统。开发者的责任是仅提供对象创建的声明,让系统在运行时解析所有的依赖链并创建实际的对象。

FastAPI 允许你通过在路径操作函数的参数中声明它们,仅声明你希望使用的对象和变量。事实上,我们在前几章中已经使用了依赖注入。在以下示例中,我们使用 Header 函数来检索 user-agent 头信息:

chapter05_what_is_dependency_injection_01.py


from fastapi import FastAPI, Headerapp = FastAPI()
@app.get("/")
async def header(user_agent: str = Header(...)):
    return {"user_agent": user_agent}

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter05/chapter05_what_is_dependency_injection_01.py

内部来说,Header 函数具有一些逻辑,可以自动获取 request 对象,检查是否存在所需的头信息,返回其值,或者在不存在时抛出错误。然而,从开发者的角度来看,我们并不知道它是如何处理所需的对象的:我们只需要获取我们所需的值。这就是 依赖注入

诚然,你可以通过在 request 对象的 headers 字典中选取 user-agent 属性来在函数体中轻松地重现这个示例。然而,依赖注入方法相比之下有许多优势:

  • 意图明确:你可以在不阅读函数代码的情况下,知道端点在请求数据中期望什么。

  • 你有一个明确的关注点分离:端点的逻辑和更通用的逻辑之间的头部检索及其关联的错误处理不会污染其他逻辑;它在依赖函数中自包含。此外,它可以轻松地在其他端点中重用。

  • 在 FastAPI 中,它被用来生成 OpenAPI 架构,以便自动生成的文档可以清楚地显示此端点所需的参数。

换句话说,每当你需要一些工具逻辑来检索或验证数据、进行安全检查,或调用你在应用中多次需要的外部逻辑时,依赖是一个理想的选择。

FastAPI 很大程度上依赖于这个依赖注入系统,并鼓励开发者使用它来实现他们的构建模块。如果你来自其他 Web 框架,比如 Flask 或 Express,可能会有些困惑,但你肯定会很快被它的强大和相关性所说服。

为了说服你,我们现在将看到如何创建和使用你自己的依赖,首先从函数形式开始。

创建并使用一个函数依赖

在 FastAPI 中,依赖可以被定义为一个函数或一个可调用的类。在本节中,我们将重点关注函数,因为它们是你最可能经常使用的。

正如我们所说,依赖是将一些逻辑封装起来的方式,这些逻辑会获取一些子值或子对象,处理它们,并最终返回一个将被注入到调用端点中的值。

让我们来看第一个示例,我们定义一个函数依赖来获取分页查询参数 skiplimit

chapter05_function_dependency_01.py


async def pagination(skip: int = 0, limit: int = 10) -> tuple[int, int]:    return (skip, limit)
@app.get("/items")
async def list_items(p: tuple[int, int] = Depends(pagination)):
    skip, limit = p
    return {"skip": skip, "limit": limit}

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter05/chapter05_function_dependency_01.py

这个示例有两个部分:

  • 首先,我们有依赖定义,带有 pagination 函数。你会看到我们定义了两个参数,skiplimit,它们是具有默认值的整数。这些将是我们端点的查询参数。我们定义它们的方式与在路径操作函数中定义的方式完全相同。这就是这种方法的美妙之处:FastAPI 会递归地处理依赖中的参数,并根据需要与请求数据(如查询参数或头部)进行匹配。

我们只需将这些值作为一个元组返回。

  • 第二,我们有路径操作函数 list_items,它使用了 pagination 依赖。你可以看到,使用方法与我们为头部或正文值所做的非常相似:我们定义了结果参数的名称,并使用函数结果作为默认值。对于依赖,我们使用 Depends 函数。它的作用是将函数作为参数传递,并在调用端点时执行它。子依赖会被自动发现并执行。

在该端点中,我们将分页直接作为一个元组返回。

让我们使用以下命令运行这个示例:


$ uvicorn chapter05_function_dependency_01:app

现在,我们将尝试调用 /items 端点,看看它是否能够获取查询参数。你可以使用以下 HTTPie 命令来尝试:


$ http "http://localhost:8000/items?limit=5&skip=10"HTTP/1.1 200 OK
content-length: 21
content-type: application/json
date: Tue, 15 Nov 2022 08:33:46 GMT
server: uvicorn
{
    "limit": 5,
    "skip": 10
}

limitskip 查询参数已经通过我们的函数依赖正确地获取。你也可以尝试不带查询参数调用该端点,并注意它会返回默认值。

依赖返回值的类型提示

你可能已经注意到,我们在路径操作的参数中必须对依赖的结果进行类型提示,即使我们已经为依赖函数本身进行了类型提示。不幸的是,这是 FastAPI 及其 Depends 函数的一个限制,Depends 函数无法传递依赖函数的类型。因此,我们必须手动对结果进行类型提示,就像我们在这里所做的那样。

就这样!如你所见,在 FastAPI 中创建和使用依赖非常简单直接。当然,你现在可以在多个端点中随意重用它,正如你在其余示例中所看到的那样。

chapter05_function_dependency_01.py


@app.get("/things")async def list_things(p: tuple[int, int] = Depends(pagination)):
    skip, limit = p
    return {"skip": skip, "limit": limit}

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter05/chapter05_function_dependency_01.py

在这些依赖中,我们可以做更复杂的事情,就像在常规路径操作函数中一样。在以下示例中,我们为这些分页参数添加了一些验证,并将 limit 限制为 100

chapter05_function_dependency_02.py


async def pagination(    skip: int = Query(0, ge=0),
    limit: int = Query(10, ge=0),
) -> tuple[int, int]:
    capped_limit = min(100, limit)
    return (skip, capped_limit)

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter05/chapter05_function_dependency_02.py

如你所见,我们的依赖开始变得更加复杂:

  • 我们在参数中添加了 Query 函数来增加验证约束;现在,如果 skiplimit 是负整数,系统将抛出 422 错误。

  • 我们确保 limit 最多为 100

我们的路径操作函数中的代码不需要修改;我们清楚地将端点的逻辑与分页参数的更通用逻辑分开。

让我们来看另一个典型的依赖项使用场景:获取一个对象或引发404错误。

获取对象或引发 404 错误

在 REST API 中,你通常会有端点用于根据路径中的标识符获取、更新和删除单个对象。在每个端点中,你很可能会有相同的逻辑:尝试从数据库中检索这个对象,或者如果它不存在,就引发一个404错误。这是一个非常适合使用依赖项的场景!在以下示例中,你将看到如何实现它:

chapter05_function_dependency_03.py


async def get_post_or_404(id: int) -> Post:    try:
        return db.posts[id]
    except KeyError:
        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter05/chapter05_function_dependency_03.py

依赖项的定义很简单:它接受一个参数,即我们想要获取的帖子的 ID。它将从相应的路径参数中提取。然后,我们检查它是否存在于我们的虚拟字典数据库中:如果存在,我们返回它;否则,我们会引发一个404状态码的 HTTP 异常。

这是这个示例的关键要点:你可以在依赖项中引发错误。在执行端点逻辑之前,检查某些前置条件是非常有用的。另一个典型的例子是身份验证:如果端点需要用户认证,我们可以通过检查令牌或 cookie,在依赖项中引发401错误。

现在,我们可以在每个 API 端点中使用这个依赖项,如下例所示:

chapter05_function_dependency_03.py


@app.get("/posts/{id}")async def get(post: Post = Depends(get_post_or_404)):
    return post
@app.patch("/posts/{id}")
async def update(post_update: PostUpdate, post: Post = Depends(get_post_or_404)):
    updated_post = post.copy(update=post_update.dict())
    db.posts[post.id] = updated_post
    return updated_post
@app.delete("/posts/{id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete(post: Post = Depends(get_post_or_404)):
    db.posts.pop(post.id)

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter05/chapter05_function_dependency_03.py

如你所见,我们只需要定义post参数并在get_post_or_404依赖项上使用Depends函数。然后,在路径操作逻辑中,我们可以确保手头有post对象,我们可以集中处理核心逻辑,现在变得非常简洁。例如,get端点只需要返回该对象。

在这种情况下,唯一需要注意的是不要忘记这些端点路径中的 ID 参数。根据 FastAPI 的规则,如果你在路径中没有设置这个参数,它会自动被视为查询参数,这不是我们想要的。你可以在第三章使用 FastAPI 开发 RESTful API路径参数部分找到更多详细信息。

这就是函数依赖项的全部内容。正如我们所说,它们是 FastAPI 项目的主要构建块。然而,在某些情况下,你可能需要在这些依赖项中设置一些参数——例如,来自环境变量的值。为此,我们可以定义类依赖项。

使用类创建和使用带参数的依赖项

在前一部分中,我们将依赖项定义为常规函数,这在大多数情况下效果良好。然而,你可能需要为依赖项设置一些参数,以便精细调整其行为。由于函数的参数是由依赖注入系统设置的,我们无法向函数添加额外的参数。

在分页示例中,我们添加了一些逻辑将限制值设定为 100。如果我们想要动态设置这个最大限制值,该如何操作呢?

解决方案是创建一个作为依赖项使用的类。这样,我们可以通过 __init__ 方法等设置类属性,并在依赖项的逻辑中使用它们。这些逻辑将会在类的 __call__ 方法中定义。如果你还记得我们在第二章可调用对象部分中学到的内容,你会知道它使对象可调用,也就是说,它可以像常规函数一样被调用。事实上,这就是 Depends 对依赖项的所有要求:可调用。我们将利用这一特性,通过类来创建一个带参数的依赖项。

在下面的示例中,我们使用类重新实现了分页示例,这使得我们可以动态设置最大限制:

chapter05_class_dependency_01.py


class Pagination:    def __init__(self, maximum_limit: int = 100):
        self.maximum_limit = maximum_limit
    async def __call__(
        self,
        skip: int = Query(0, ge=0),
        limit: int = Query(10, ge=0),
    ) -> tuple[int, int]:
        capped_limit = min(self.maximum_limit, limit)
        return (skip, capped_limit)

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter05/chapter05_class_dependency_01.py

正如你所看到的,__call__ 方法中的逻辑与我们在前一个示例中定义的函数相同。唯一的区别是,我们可以从类的属性中获取最大限制值,这些属性可以在对象初始化时设置。

然后,你可以简单地创建该类的实例,并在路径操作函数中使用 Depends 作为依赖项,就像你在以下代码块中看到的那样:

chapter05_class_dependency_01.py


pagination = Pagination(maximum_limit=50)@app.get("/items")
async def list_items(p: tuple[int, int] = Depends(pagination)):
    skip, limit = p
    return {"skip": skip, "limit": limit}

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter05/chapter05_class_dependency_01.py

在这里,我们硬编码了 50 的值,但我们完全可以从配置文件或环境变量中获取这个值。

类依赖的另一个优点是它可以在内存中保持局部值。如果我们需要进行一些繁重的初始化逻辑,例如加载一个机器学习模型,我们希望在启动时只做一次。然后,可调用的部分只需调用已加载的模型来进行预测,这应该是非常快速的。

使用类方法作为依赖项

即使__call__方法是实现类依赖的最直接方式,你也可以直接将方法传递给Depends。实际上,正如我们所说,它只需要一个可调用对象作为参数,而类方法是一个完全有效的可调用对象!

如果你有一些公共参数或逻辑需要在稍微不同的情况下重用,这种方法非常有用。例如,你可以有一个使用 scikit-learn 训练的预训练机器学习模型。在应用决策函数之前,你可能想根据输入数据应用不同的预处理步骤。

要做到这一点,只需将你的逻辑写入一个类方法,并通过点符号将其传递给Depends函数。

你可以在以下示例中看到这一点,我们为分页依赖项实现了另一种样式,使用pagesize参数,而不是skiplimit

chapter05_class_dependency_02.py


class Pagination:    def __init__(self, maximum_limit: int = 100):
        self.maximum_limit = maximum_limit
    async def skip_limit(
        self,
        skip: int = Query(0, ge=0),
        limit: int = Query(10, ge=0),
    ) -> tuple[int, int]:
        capped_limit = min(self.maximum_limit, limit)
        return (skip, capped_limit)
    async def page_size(
        self,
        page: int = Query(1, ge=1),
        size: int = Query(10, ge=0),
    ) -> tuple[int, int]:
        capped_size = min(self.maximum_limit, size)
        return (page, capped_size)

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter05/chapter05_class_dependency_02.py

这两种方法的逻辑非常相似。我们只是在查看不同的查询参数。然后,在我们的路径操作函数中,我们将/items端点设置为使用skip/limit样式,而/things端点将使用page/size样式:

chapter05_class_dependency_02.py


pagination = Pagination(maximum_limit=50)@app.get("/items")
async def list_items(p: tuple[int, int] = Depends(pagination.skip_limit)):
    skip, limit = p
    return {"skip": skip, "limit": limit}
@app.get("/things")
async def list_things(p: tuple[int, int] = Depends(pagination.page_size)):
    page, size = p
    return {"page": page, "size": size}

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter05/chapter05_class_dependency_02.py

正如你所看到的,我们只需通过点符号将所需的方法传递给pagination对象。

总结来说,类依赖方法比函数依赖方法更为先进,但在需要动态设置参数、执行繁重的初始化逻辑或在多个依赖项之间重用公共逻辑时非常有用。

到目前为止,我们假设我们关心依赖项的返回值。虽然大多数情况下确实如此,但你可能偶尔需要调用依赖项以检查某些条件,但并不需要返回值。FastAPI 允许这种用例,接下来我们将看到这个功能。

在路径、路由器和全局级别使用依赖项

如我们所说,依赖项是创建 FastAPI 项目构建模块的推荐方式,它允许你在多个端点间重用逻辑,同时保持代码的最大可读性。到目前为止,我们已将依赖项应用于单个端点,但我们能否将这种方法扩展到整个路由器?甚至是整个 FastAPI 应用程序?事实上,我们可以!

这样做的主要动机是能够在多个路由上应用一些全局请求验证或执行副作用逻辑,而无需在每个端点上都添加依赖项。通常,身份验证方法或速率限制器可能是这个用例的很好的候选者。

为了向你展示它是如何工作的,我们将实现一个简单的依赖项,并在以下所有示例中使用它。你可以在以下示例中看到它:

chapter05_path_dependency_01.py


def secret_header(secret_header: str | None = Header(None)) -> None:    if not secret_header or secret_header != "SECRET_VALUE":
        raise HTTPException(status.HTTP_403_FORBIDDEN)

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter05/chapter05_path_dependency_01.py

这个依赖项将简单地查找请求中名为 Secret-Header 的头部。如果它缺失或不等于 SECRET_VALUE,它将引发 403 错误。请注意,这种方法仅用于示例;有更好的方式来保护你的 API,我们将在第七章中讨论,在 FastAPI 中管理身份验证和安全性

在路径装饰器上使用依赖项

直到现在,我们一直假设我们总是对依赖项的返回值感兴趣。正如我们的 secret_header 依赖项在这里清楚地显示的那样,这并非总是如此。这就是为什么你可以将依赖项添加到路径操作装饰器上,而不是传递参数。你可以在以下示例中看到如何操作:

chapter05_path_dependency_01.py


@app.get("/protected-route", dependencies=[Depends(secret_header)])async def protected_route():
    return {"hello": "world"}

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter05/chapter05_path_dependency_01.py

路径操作装饰器接受一个参数 dependencies,该参数期望一个依赖项列表。你会发现,就像为依赖项传递参数一样,你需要用 Depends 函数包装你的函数(或可调用对象)。

现在,每当调用 /protected-route 路由时,依赖项将被调用并检查所需的头部信息。

如你所料,由于 dependencies 是一个列表,你可以根据需要添加任意数量的依赖项。

这很有趣,但如果我们想保护一整组端点呢?手动为每个端点添加可能会有点繁琐且容易出错。幸运的是,FastAPI 提供了一种方法来实现这一点。

在整个路由器上使用依赖项

如果你记得在 第三章中的 使用多个路由器结构化一个更大的项目 部分,使用 FastAPI 开发 RESTful API,你就知道你可以在项目中创建多个路由器,以清晰地拆分 API 的不同部分,并将它们“连接”到你的主 FastAPI 应用程序。这是通过 APIRouter 类和 FastAPI 类的 include_router 方法来完成的。

使用这种方法,将一个依赖项注入整个路由器是很有趣的,这样它会在该路由器的每个路由上被调用。你有两种方法可以做到这一点:

  • APIRouter 类上设置 dependencies 参数,正如以下示例所示:

chapter05_router_dependency_01.py


router = APIRouter(dependencies=[Depends(secret_header)])@router.get("/route1")
async def router_route1():
    return {"route": "route1"}
@router.get("/route2")
async def router_route2():
    return {"route": "route2"}
app = FastAPI()
app.include_router(router, prefix="/router")

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter05/chapter05_router_dependency_01.py

  • include_router 方法上设置 dependencies 参数,正如以下示例所示:

chapter05_router_dependency_02.py


router = APIRouter()@router.get("/route1")
async def router_route1():
    return {"route": "route1"}
@router.get("/route2")
async def router_route2():
    return {"route": "route2"}
app = FastAPI()
app.include_router(router, prefix="/router", dependencies=[Depends(secret_header)])

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter05/chapter05_router_dependency_02.py

在这两种情况下,dependencies 参数都期望一个依赖项的列表。你可以看到,就像传递依赖项作为参数一样,你需要用 Depends 函数将你的函数(或可调用对象)包装起来。当然,由于它是一个列表,如果需要,你可以添加多个依赖项。

现在,如何选择这两种方法呢?在这两种情况下,效果完全相同,所以我们可以说其实并不重要。从哲学角度来看,我们可以说,如果依赖项在这个路由器的上下文中是必要的,我们应该在 APIRouter 类上声明依赖项。换句话说,我们可以问自己这个问题,如果我们独立运行这个路由器,是否没有这个依赖项就无法工作?如果这个问题的答案是,那么你可能应该在 APIRouter 类上设置依赖项。否则,在 include_router 方法中声明它可能更有意义。但再说一次,这只是一个思想选择,它不会改变你 API 的功能,因此你可以选择你更舒适的方式。

现在,我们能够为整个路由器设置依赖项。在某些情况下,为整个应用程序声明依赖项也可能很有趣!

在整个应用程序中使用依赖项

如果你有一个实现了某些日志记录或限流功能的依赖项,例如,将其应用到你 API 的每个端点可能会很有意义。幸运的是,FastAPI 允许这样做,正如以下示例所示:

chapter05_global_dependency_01.py


app = FastAPI(dependencies=[Depends(secret_header)])@app.get("/route1")
async def route1():
    return {"route": "route1"}
@app.get("/route2")
async def route2():
    return {"route": "route2"}

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter05/chapter05_global_dependency_01.py

再次强调,你只需直接在主 FastAPI 类上设置 dependencies 参数。现在,依赖项应用于你 API 中的每个端点!

图 5**.1 中,我们提出了一个简单的决策树,用于确定你应该在哪个级别注入依赖项:

图 5.1 – 我应该在哪个级别注入我的依赖项?

图 5.1 – 我应该在哪个级别注入我的依赖项?

摘要

恭喜!现在你应该已经熟悉了 FastAPI 最具标志性的特性之一:依赖注入。通过实现自己的依赖项,你可以将希望在整个 API 中重用的常见逻辑与端点的逻辑分开。这样可以使你的项目清晰可维护,同时保持最大的可读性;只需将依赖项声明为路径操作函数的参数即可,这将帮助你理解意图,而无需阅读函数体。

这些依赖项可以是简单的包装器,用于检索和验证请求参数,也可以是执行机器学习任务的复杂服务。多亏了基于类的方法,你确实可以设置动态参数或为最复杂的任务保持局部状态。

最后,这些依赖项还可以在路由器或全局级别上使用,允许你对一组路由或整个应用程序执行常见逻辑或检查。

这就是本书第一部分的结束!你现在已经熟悉了 FastAPI 的主要特性,并且应该能够使用这个框架编写干净且高性能的 REST API。

在下一部分中,我们将带你的知识提升到新的高度,并展示如何实现和部署一个强大、安全且经过测试的 Web 后端。第一章将专注于数据库,大多数 API 都需要能够读取和写入数据。

第二部分:使用 FastAPI 构建和部署完整的 Web 后端

本节的目标是向你展示如何使用 FastAPI 构建一个真实世界的后端,该后端能够读取和写入数据,进行用户认证,并且经过充分测试,且为生产环境正确配置。

本节包括以下章节:

  • 第六章数据库和异步 ORM

  • 第七章在 FastAPI 中管理认证和安全性

  • 第八章在 FastAPI 中定义 WebSockets 实现双向互动通信

  • 第九章使用 pytest 和 HTTPX 异步测试 API

  • 第十章部署 FastAPI 项目

第六章:数据库和异步 ORM

REST API 的主要目标当然是读写数据。到目前为止,我们只使用了 Python 和 FastAPI 提供的工具,允许我们构建可靠的端点来处理和响应请求。然而,我们尚未能够有效地检索和持久化这些信息:我们还没有 数据库

本章的目标是展示你如何在 FastAPI 中与不同类型的数据库及相关库进行交互。值得注意的是,FastAPI 对数据库是完全无关的:你可以使用任何你想要的系统,并且集成工作由你负责。这就是为什么我们将回顾两种不同的数据库集成方式:使用 对象关系映射ORM)系统连接 SQL 数据库,以及使用 NoSQL 数据库。

本章我们将讨论以下主要主题:

  • 关系型数据库和 NoSQL 数据库概述

  • 使用 SQLAlchemy ORM 与 SQL 数据库进行通信

  • 使用 Motor 与 MongoDB 数据库进行通信

技术要求

对于本章,你将需要一个 Python 虚拟环境,正如我们在 第一章 中设置的,Python 开发 环境设置

对于 使用 Motor 与 MongoDB 数据库进行通信 部分,你需要在本地计算机上运行 MongoDB 服务器。最简单的方法是将其作为 Docker 容器运行。如果你以前从未使用过 Docker,我们建议你参考官方文档中的 入门教程,链接为 docs.docker.com/get-started/。完成这些步骤后,你将能够使用以下简单命令运行 MongoDB 服务器:


$ docker run -d --name fastapi-mongo -p 27017:27017 mongo:6.0

MongoDB 服务器实例将通过端口 27017 在你的本地计算机上提供。

你可以在本书专门的 GitHub 仓库中找到本章的所有代码示例,地址为 github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter06

关系型数据库和 NoSQL 数据库概述

数据库的作用是以结构化的方式存储数据,保持数据的完整性,并提供查询语言,使你在应用程序需要时能够检索这些数据。

如今,选择适合你网站项目的数据库时,你有两个主要选择:关系型数据库,及其相关的 SQL 查询语言,和 NoSQL 数据库,它们与第一类数据库相对立。

选择适合你项目的技术由你来决定,因为这在很大程度上取决于你的需求和要求。在本节中,我们将概述这两类数据库的主要特点和功能,并尝试为你提供一些选择适合项目的数据库的见解。

关系型数据库

关系型数据库自 1970 年代以来就存在,并且随着时间的推移证明了它们的高效性和可靠性。它们几乎与 SQL 不可分离,SQL 已成为查询此类数据库的事实标准。即使不同数据库引擎之间有一些差异,大多数语法是通用的,简单易懂,足够灵活,可以表达复杂的查询。

关系型数据库实现了关系模型:应用的每个实体或对象都存储在中。例如,如果我们考虑一个博客应用,我们可以有表示用户帖子评论的表。

每个表都会有多个,表示实体的属性。如果我们考虑帖子,可能会有一个标题发布日期内容。在这些表中,会有多行,每行表示这种类型的一个实体;每篇帖子将有自己的行。

关系型数据库的一个关键点是,如其名称所示,关系。每个表可以与其他表建立关系,表中的行可以引用其他表中的行。在我们的示例中,一篇帖子可以与写它的用户相关联。类似地,一条评论可以与其相关的帖子关联。

这样做的主要动机是避免重复。事实上,如果我们在每篇帖子上都重复用户的姓名或邮箱,这并不是很高效。如果需要修改某个信息,我们就得通过每篇帖子修改,这容易出错并危及数据一致性。因此,我们更倾向于在帖子中引用用户。那么,我们该如何实现这一点呢?

通常,关系型数据库中的每一行都有一个标识符,称为主键。这个键在表中是唯一的,允许你唯一标识这一行。因此,可以在另一个表中使用这个键来引用它。我们称之为外键:外键之所以叫做外,是因为它引用了另一个表。

图 6.1展示了使用实体-关系图表示这种数据库模式的方式。请注意,每个表都有自己的主键,名为idPost表通过user_id外键引用一个用户。类似地,Comment表通过user_idpost_id外键分别引用一个用户和一篇帖子:

图 6.1 – 博客应用的关系型数据库模式示例

图 6.1 – 博客应用的关系型数据库模式示例

在一个应用中,你可能希望检索一篇帖子,以及与之相关的评论和用户。为了实现这一点,我们可以执行一个连接查询,根据外键返回所有相关记录。关系型数据库旨在高效地执行此类任务;然而,如果模式更加复杂,这些操作可能会变得昂贵。这就是为什么在设计关系型模式及其查询时需要小心谨慎的原因。

NoSQL 数据库

所有非关系型的数据库引擎都属于 NoSQL 范畴。这是一个相当模糊的术语,涵盖了不同类型的数据库:键值存储,例如 Redis;图数据库,例如 Neo4j;以及面向文档的数据库,例如 MongoDB。也就是说,当我们谈论“NoSQL 数据库”时,通常是指面向文档的数据库。它们是我们关注的对象。

面向文档的数据库摒弃了关系型架构,试图将给定对象的所有信息存储在一个文档中。因此,执行联接查询的情况非常少见,通常也更为困难。

这些文档存储在集合中。与关系型数据库不同,集合中的文档可能没有相同的属性:关系型数据库中的表有定义好的模式,而集合可以接受任何类型的文档。

图 6.2 显示了我们之前博客示例的表示,已经调整为面向文档的数据库结构。在这种配置中,我们选择了一个集合用于用户,另一个集合用于帖子。然而,请注意,评论现在是帖子的组成部分,直接作为一个列表包含在内:

图 6.2 — 博客应用的面向文档的架构示例

图 6.2 — 博客应用的面向文档的架构示例

要检索一篇帖子及其所有评论,你不需要执行联接查询:所有数据只需一个查询即可获取。这是开发面向文档数据库的主要动机:通过减少查看多个集合的需求来提高查询性能。特别是,它们在处理具有巨大数据规模和较少结构化数据的应用(如社交网络)时表现出了极大的价值。

你应该选择哪一个?

正如我们在本节引言中提到的,你选择数据库引擎很大程度上取决于你的应用和需求。关系型数据库和面向文档的数据库之间的详细比较超出了本书的范围,但我们可以看一下你需要考虑的一些要素。

关系型数据库非常适合存储结构化数据,且实体之间存在大量关系。此外,它们在任何情况下都会维护数据的一致性,即使在发生错误或硬件故障时也不例外。然而,你必须精确定义模式,并考虑迁移系统,以便在需求变化时更新你的模式。

另一方面,面向文档的数据库不需要你定义模式:它们接受任何文档结构,因此如果你的数据高度可变或你的项目尚未成熟,它们会很方便。其缺点是,它们在数据一致性方面要求较低,可能导致数据丢失或不一致。

对于小型和中型应用程序,选择并不太重要:关系型数据库和面向文档的数据库都经过了高度优化,在这些规模下都会提供出色的性能。

接下来,我们将展示如何使用 FastAPI 处理这些不同类型的数据库。当我们在第二章中介绍异步 I/O 时,Python 编程特性,我们提到过选择你用来执行 I/O 操作的库是很重要的。当然,在这种情况下,数据库尤为重要!

尽管在 FastAPI 中使用经典的非异步库是完全可行的,但你可能会错过框架的一个关键方面,无法达到它所能提供的最佳性能。因此,在本章中,我们将只专注于异步库。

使用 SQLAlchemy ORM 与 SQL 数据库进行通信

首先,我们将讨论如何使用 SQLAlchemy 库处理关系型数据库。SQLAlchemy 已经存在多年,并且是 Python 中处理 SQL 数据库时最受欢迎的库。从版本 1.4 开始,它也原生支持异步。

理解这个库的关键点是,它由两个部分组成:

  • SQLAlchemy Core,提供了读取和写入 SQL 数据库数据的所有基本功能

  • SQLAlchemy ORM,提供对 SQL 概念的强大抽象

虽然你可以选择只使用 SQLAlchemy Core,但通常使用 ORM 更为方便。ORM 的目标是抽象出表和列的 SQL 概念,这样你只需要处理 Python 对象。ORM 的作用是将这些对象映射到它们所属的表和列,并自动生成相应的 SQL 查询。

第一步是安装这个库:


(venv) $ pip install "sqlalchemy[asyncio,mypy]"

请注意,我们添加了两个可选依赖项:asynciomypy。第一个确保安装了异步支持所需的工具。

第二个是一个为 mypy 提供特殊支持的插件,专门用于 SQLAlchemy。ORM 在后台做了很多“魔法”事情,这些对于类型检查器来说很难理解。有了这个插件,mypy 能够学会识别这些构造。

正如我们在介绍中所说,存在许多 SQL 引擎。你可能听说过 PostgreSQL 和 MySQL,它们是最受欢迎的引擎之一。另一个有趣的选择是 SQLite,它是一个小型引擎,所有数据都存储在你电脑上的单个文件中,不需要复杂的服务器软件。它非常适合用于测试和实验。为了让 SQLAlchemy 能够与这些引擎进行通信,你需要安装相应的驱动程序。根据你的引擎,这里是你需要安装的异步驱动程序:

  • PostgreSQL:

    
    (venv) $ pip install asyncpg
    
  • MySQL:

    
    (venv) $ pip install aiomysql
    
  • SQLite:

    
    (venv) $ pip install aiosqlite
    

在本节的其余部分,我们将使用 SQLite 数据库。我们将一步步展示如何设置完整的数据库交互。图 6.4展示了项目的结构:

图 6.3 – FastAPI 和 SQLAlchemy 项目结构

图 6.3 – FastAPI 和 SQLAlchemy 项目结构

创建 ORM 模型

首先,您需要定义您的 ORM 模型。每个模型是一个 Python 类,其属性代表表中的列。数据库中的实际实体将是该类的实例,您可以像访问任何其他对象一样访问其数据。在幕后,SQLAlchemy ORM 的作用是将 Python 对象与数据库中的行链接起来。让我们来看一下我们博客文章模型的定义:

models.py


from datetime import datetimefrom sqlalchemy import DateTime, Integer, String, Text
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
    pass
class Post(Base):
    __tablename__ = "posts"
    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
    publication_date: Mapped[datetime] = mapped_column(
        DateTime, nullable=False, default=datetime.now
    )
    title: Mapped[str] = mapped_column(String(255), nullable=False)
    content: Mapped[str] = mapped_column(Text, nullable=False)

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter06/sqlalchemy/models.py

第一步是创建一个继承自 DeclarativeBaseBase 类。我们所有的模型都将继承自这个类。在内部,SQLAlchemy 使用它来将所有有关数据库模式的信息集中在一起。这就是为什么在整个项目中只需要创建一次,并始终使用相同的 Base 类。

接下来,我们必须定义我们的 Post 类。再次注意,它是如何从 Base 类继承的。在这个类中,我们可以以类属性的形式定义每一列。它们是通过 mapped_column 函数来赋值的,这个函数帮助我们定义列的类型及其相关属性。例如,我们将 id 列定义为一个自增的整数主键,这在 SQL 数据库中非常常见。

请注意,我们不会详细介绍 SQLAlchemy 提供的所有类型和选项。只需知道它们与 SQL 数据库通常提供的类型非常相似。您可以在官方文档中查看完整的列表,如下所示:

这里另一个值得注意的有趣点是,我们为每个属性添加了类型提示,这些类型与我们列的 Python 类型对应。这将极大地帮助我们在开发过程中:例如,如果我们尝试获取帖子对象的 title 属性,类型检查器会知道它是一个字符串。为了使这一点生效,请注意,我们将每个类型都包裹在 Mapped 类中。这是 SQLAlchemy 提供的一个特殊类,类型检查器可以通过它了解数据的底层类型,当我们将一个 MappedColumn 对象分配给它时。

这是在 SQLAlchemy 2.0 中声明模型的方式

我们将在本节中展示的声明模型的方式是 SQLAlchemy 2.0 中引入的最新方式。

如果你查看网上较老的教程或文档,你可能会看到一种略有不同的方法,其中我们将属性分配给Column对象。虽然这种旧风格在 SQLAlchemy 2.0 中仍然有效,但它应该被视为过时的。

现在我们有了一个帮助我们读写数据库中帖子数据的模型。然而,正如你现在所知道的,使用 FastAPI 时,我们还需要 Pydantic 模型,以便验证输入数据并在 API 中输出正确的表示。如果你需要复习这部分内容,可以查看第三章使用 FastAPI 开发 RESTful API

定义 Pydantic 模型

正如我们所说的,如果我们想正确验证进出 FastAPI 应用的数据,我们需要使用 Pydantic 模型。在 ORM 上下文中,它们将帮助我们在 ORM 模型之间来回转换。这一节的关键要点是:我们将使用 Pydantic 模型来验证和序列化数据,但数据库通信将通过 ORM 模型完成。

为了避免混淆,我们现在将 Pydantic 模型称为模式。当我们谈论模型时,我们指的是 ORM 模型。

这就是为什么那些模式的定义被放置在schemas.py模块中的原因,如下所示:

schemas.py


from datetime import datetimefrom pydantic import BaseModel, Field
class PostBase(BaseModel):
    title: str
    content: str
    publication_date: datetime = Field(default_factory=datetime.now)
    class Config:
        orm_mode = True
class PostPartialUpdate(BaseModel):
    title: str | None = None
    content: str | None = None
class PostCreate(PostBase):
    pass
class PostRead(PostBase):
    id: int

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter06/sqlalchemy/schemas.py

上面的代码对应我们在第四章中解释的模式,在 FastAPI 中管理 Pydantic 数据模型

但有一个新内容:你可能已经注意到Config子类,它是在PostBase中定义的。这是为 Pydantic 模式添加一些配置选项的一种方式。在这里,我们将orm_mode选项设置为True。顾名思义,这是一个使 Pydantic 与 ORM 更好配合的选项。在标准设置下,Pydantic 被设计用来解析字典中的数据:如果它想解析title属性,它会使用d["title"]。然而,在 ORM 中,我们通过点号表示法(o.title)来像访问对象一样访问属性。启用 ORM 模式后,Pydantic 就能使用这种风格。

连接到数据库

现在我们的模型和模式已经准备好了,我们必须设置 FastAPI 应用和数据库引擎之间的连接。为此,我们将创建一个database.py模块,并在其中放置我们需要的对象:

database.py


from collections.abc import AsyncGeneratorfrom sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from chapter06.sqlalchemy.models import Base
DATABASE_URL = "sqlite+aiosqlite:///chapter06_sqlalchemy.db"
engine = create_async_engine(DATABASE_URL)
async_session_maker = async_sessionmaker(engine, expire_on_commit=False)

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter06/sqlalchemy/database.py

在这里,你可以看到我们已经将连接字符串设置在DATABASE_URL变量中。通常,它由以下几个部分组成:

  • 数据库引擎。在这里,我们使用sqlite

  • 可选的驱动程序,后面带有加号。这里,我们设置为aiosqlite。在异步环境中,必须指定我们想要使用的异步驱动程序。否则,SQLAlchemy 会回退到标准的同步驱动程序。

  • 可选的身份验证信息。

  • 数据库服务器的主机名。在 SQLite 的情况下,我们只需指定将存储所有数据的文件路径。

你可以在官方 SQLAlchemy 文档中找到该格式的概述:docs.sqlalchemy.org/en/20/core/engines.html#database-urls

然后,我们使用create_async_engine函数和这个 URL 创建引擎。引擎是一个对象,SQLAlchemy 将在其中管理与数据库的连接。此时,重要的是要理解,尚未建立任何连接:我们只是声明了相关内容。

然后,我们有一个更为复杂的代码行来定义async_session_maker变量。我们不会深入讨论async_sessionmaker函数的细节。只需知道它返回一个函数,允许我们生成与数据库引擎绑定的会话

什么是会话?它是由 ORM 定义的概念。会话将与数据库建立实际连接,并代表一个区域,在该区域中它将存储你从数据库中读取的所有对象以及你定义的所有将在数据库中写入的对象。它是 ORM 概念和基础 SQL 查询之间的代理。

在构建 HTTP 服务器时,我们通常在请求开始时打开一个新的会话,并在响应请求时关闭它。因此,每个 HTTP 请求代表与数据库的一个工作单元。这就是为什么我们必须定义一个 FastAPI 依赖项,其作用是提供一个新的会话给我们:

database.py


async def get_async_session() -> AsyncGenerator[AsyncSession, None]:    async with async_session_maker() as session:
        yield session

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter06/sqlalchemy/database.py

将它作为依赖项将大大帮助我们在实现路径操作函数时。

到目前为止,我们还没有机会讨论with语法。在 Python 中,这被称为with块,对象会自动执行设置逻辑。当你退出该块时,它会执行拆解逻辑。你可以在 Python 文档中阅读更多关于上下文管理器的信息:docs.python.org/3/reference/datamodel.html#with-statement-context-managers

在我们的案例中,async_session_maker作为上下文管理器工作。它负责打开与数据库的连接等操作。

注意,我们在这里通过使用yield定义了一个生成器。这一点很重要,因为它确保了会话在请求结束前保持打开状态。如果我们使用一个简单的return语句,上下文管理器会立即关闭。使用yield时,我们确保只有在请求和端点逻辑被 FastAPI 完全处理后,才会退出上下文管理器。

使用依赖注入来获取数据库实例

你可能会想,为什么我们不直接在路径操作函数中调用async_session_maker,而是使用依赖注入。这是可行的,但当我们尝试实现单元测试时会非常困难。实际上,将这个实例替换为模拟对象或测试数据库将变得非常困难。通过使用依赖注入,FastAPI 使得我们可以轻松地将其替换为另一个函数。我们将在第九章使用 pytest 和 HTTPX 异步测试 API中详细了解这一点。

在这个模块中我们必须定义的最后一个函数是create_all_tables。它的目标是创建数据库中的表模式。如果我们不这么做,数据库将是空的,无法保存或检索数据。像这样创建模式是一种简单的做法,只适用于简单的示例和实验。在实际应用中,你应该有一个合适的迁移系统,确保你的数据库模式保持同步。我们将在本章稍后学习如何为 SQLAlchemy 设置迁移系统。

为了确保在应用启动时创建我们的模式,我们必须在app.py模块中调用这个函数:

app.py


@contextlib.asynccontextmanagerasync def lifespan(app: FastAPI):
    await create_all_tables()
    yield

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter06/sqlalchemy/app.py

创建对象

让我们从向数据库中插入新对象开始。主要的挑战是接受 Pydantic 模式作为输入,将其转换为 SQLAlchemy 模型,并将其保存到数据库中。让我们回顾一下这个过程,如下例所示:

app.py


@app.post(    "/posts", response_model=schemas.PostRead, status_code=status.HTTP_201_CREATED
)
async def create_post(
    post_create: schemas.PostCreate, session: AsyncSession = Depends(get_async_session)
) -> Post:
    post = Post(**post_create.dict())
    session.add(post)
    await session.commit()
    return post

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter06/sqlalchemy/app.py

在这里,我们有一个POST端点,接受我们的PostCreate模式。注意,我们通过get_async_session依赖注入了一个新的 SQLAlchemy 会话。核心逻辑包括两个操作。

首先,我们将post_create转换为完整的Post模型对象。为此,我们可以简单地调用 Pydantic 的dict方法,并用**解包它,直接赋值给属性。此时,文章还没有保存到数据库中:我们需要告诉会话有关它的信息。

第一步是通过add方法将其添加到会话中。现在,post 已经进入会话内存,但尚未存储在数据库中。通过调用commit方法,我们告诉会话生成适当的 SQL 查询并在数据库上执行它们。正如我们所预料的那样,我们发现需要await此方法:我们对数据库进行了 I/O 操作,因此它是异步操作。

最后,我们可以直接返回post对象。你可能会惊讶于我们直接返回了一个 SQLAlchemy ORM 对象,而不是 Pydantic 模式。FastAPI 如何正确地序列化它并保留我们指定的属性呢?如果你留心一下,你会看到我们在路径操作装饰器中设置了response_model属性。正如你可能从 第三章响应模型部分回想起来的那样,使用 FastAPI 开发 RESTful API,你就能理解发生了什么:FastAPI 会自动处理将 ORM 对象转化为指定模式的过程。正因为如此,我们需要启用 Pydantic 的orm_mode,正如前面一节所示!

从这里,你可以看到实现过程非常直接。现在,让我们来检索这些数据吧!

获取和筛选对象

通常,REST API 提供两种类型的端点来读取数据:一种用于列出对象,另一种用于获取特定对象。这正是我们接下来要回顾的内容!

在下面的示例中,你可以看到我们如何实现列出对象的端点:

app.py


@app.get("/posts", response_model=list[schemas.PostRead])async def list_posts(
    pagination: tuple[int, int] = Depends(pagination),
    session: AsyncSession = Depends(get_async_session),
) -> Sequence[Post]:
    skip, limit = pagination
    select_query = select(Post).offset(skip).limit(limit)
    result = await session.execute(select_query)
    return result.scalars().all()

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter06/sqlalchemy/app.py

该操作分为两步执行。首先,我们构建一个查询。SQLAlchemy 的select函数允许我们开始定义查询。方便的是,我们可以直接将model类传递给它:它会自动理解我们所谈论的表格。接下来,我们可以应用各种方法和筛选条件,这些与纯 SQL 中的操作是相似的。在这里,我们能够通过offsetlimit应用我们的分页参数。

然后,我们使用一个新的会话对象的execute方法执行此查询(该会话对象再次通过我们的依赖注入)。由于我们是从数据库中读取数据,这是一项异步操作。

由此,我们得到一个result对象。这个对象是 SQLAlchemy 的Result类的实例。它不是我们直接的帖子列表,而是表示 SQL 查询结果的一个集合。这就是为什么我们需要调用scalarsall。第一个方法会确保我们获得实际的Post对象,而第二个方法会将它们作为一个序列返回。

再次说明,我们可以直接返回这些 SQLAlchemy ORM 对象:感谢response_model设置,FastAPI 会将它们转化为正确的模式。

现在,让我们看看如何通过 ID 获取单个 post:

app.py


@app.get("/posts/{id}", response_model=schemas.PostRead)async def get_post(post: Post = Depends(get_post_or_404)) -> Post:
    return post

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter06/sqlalchemy/app.py

这是一个简单的GET端点,期望在路径参数中提供帖子的 ID。实现非常简单:我们只是返回帖子。大部分逻辑在get_post_or_404依赖项中,我们将在应用程序中经常重复使用它。以下是它的实现:

app.py


async def get_post_or_404(    id: int, session: AsyncSession = Depends(get_async_session)
) -> Post:
    select_query = select(Post).where(Post.id == id)
    result = await session.execute(select_query)
    post = result.scalar_one_or_none()
    if post is None:
        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
    return post

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter06/sqlalchemy/app.py

如你所见,这与我们在列表端点看到的内容非常相似。我们同样从构建一个选择查询开始,但这次,我们添加了一个where子句,以便只检索与所需 ID 匹配的帖子。这个子句本身可能看起来有些奇怪。

首先,我们必须设置我们想要比较的实际列。事实上,当你直接访问model类的属性时,比如Post.id,SQLAlchemy 会自动理解你在引用列。

然后,我们使用等号运算符来比较列与我们实际的id变量。它看起来像是一个标准的比较,会产生一个布尔值,而不是一个 SQL 语句!在一般的 Python 环境中,确实是这样。然而,SQLAlchemy 的开发者在这里做了一些聪明的事情:他们重载了标准运算符,使其产生 SQL 表达式而不是比较对象。这正是我们在第二章Python 编程特性中看到的内容。

现在,我们可以简单地执行查询并在结果集上调用scalar_one_or_none。这是一个方便的快捷方式,告诉 SQLAlchemy 如果存在单个对象则返回它,否则返回None

如果结果是None,我们可以抛出一个404错误:没有帖子匹配这个 ID。否则,我们可以简单地返回帖子。

更新和删除对象

最后,我们将展示如何更新和删除现有对象。你会发现这只是操作 ORM 对象并在session上调用正确方法的事情。

检查以下代码,并审查update端点的实现:

app.py


@app.patch("/posts/{id}", response_model=schemas.PostRead)async def update_post(
    post_update: schemas.PostPartialUpdate,
    post: Post = Depends(get_post_or_404),
    session: AsyncSession = Depends(get_async_session),
) -> Post:
    post_update_dict = post_update.dict(exclude_unset=True)
    for key, value in post_update_dict.items():
        setattr(post, key, value)
    session.add(post)
    await session.commit()
    return post

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter06/sqlalchemy/app.py

这里,主要需要注意的是,我们将直接操作我们想要修改的帖子。这是使用 ORM 时的一个关键点:实体是可以按需修改的对象。当你对数据满意时,可以将其持久化到数据库中。这正是我们在这里所做的:我们通过get_post_or_404获取帖子的最新表示。然后,我们将post_update架构转换为字典,并遍历这些属性,将它们设置到我们的 ORM 对象上。最后,我们可以将其保存在会话中并提交到数据库,就像我们在创建时所做的那样。

当你想删除一个对象时,同样的概念也适用:当你拥有一个实例时,可以将其传递给sessiondelete方法,从而安排它的删除。你可以通过以下示例查看这一过程:

app.py


@app.delete("/posts/{id}", status_code=status.HTTP_204_NO_CONTENT)async def delete_post(
    post: Post = Depends(get_post_or_404),
    session: AsyncSession = Depends(get_async_session),
):
    await session.delete(post)
    await session.commit()

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter06/sqlalchemy/app.py

在这些示例中,你看到我们总是在写操作后调用commit:你的更改必须被写入数据库,否则它们将仅停留在会话内存中并丢失。

添加关系

正如我们在本章开头提到的,关系型数据库关心的是数据及其关系。你经常需要创建与其他实体相关联的实体。例如,在一个博客应用中,评论是与其相关的帖子关联的。在这一部分,我们将讨论如何使用 SQLAlchemy ORM 设置这种关系。

首先,我们需要为评论定义一个新模型。这个新模型必须放在Post模型之上。稍后我们会解释为什么这很重要。你可以在以下示例中查看它的定义:

models.py


class Comment(Base):    __tablename__ = "comments"
    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
    post_id: Mapped[int] = mapped_column(ForeignKey("posts.id"), nullable=False)
    publication_date: Mapped[datetime] = mapped_column(
        DateTime, nullable=False, default=datetime.now
    )
    content: Mapped[str] = mapped_column(Text, nullable=False)
    post: Mapped["Post"] = relationship("Post", back_populates="comments")

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter06/sqlalchemy_relationship/models.py

这里的重要点是post_id列,它是ForeignKey类型。这是一个特殊类型,告诉 SQLAlchemy 自动处理该列的类型和相关约束。我们只需要提供它所指向的表和列名。

但这只是定义中的 SQL 部分。现在我们需要告诉 ORM 我们的Comment对象与Post对象之间存在关系。这就是post属性的目的,它被分配给relationship函数。它是 SQLAlchemy ORM 暴露的一个特殊函数,用来定义模型之间的关系。它不会在 SQL 定义中创建一个新列——这是ForeignKey列的作用——但它允许我们通过comment.post直接获取与评论相关联的Post对象。你还可以看到我们定义了back_populates参数。它允许我们执行相反的操作——也就是说,从一个post获取评论列表。这个选项的名称决定了我们用来访问评论的属性名。这里,它是post.comments

前向引用类型提示

如果你查看post属性的类型提示,你会看到我们正确地将其设置为Post类。然而,我们将其放在了引号中:post: "Post" = …

这就是所谓的PostComment之后定义。如果我们忘记了引号,Python 会抱怨,因为我们试图访问一个尚未存在的东西。为了解决这个问题,我们可以将其放在引号中。类型检查器足够智能,可以理解你指的是什么。

现在,如果你查看以下Post模型,你会看到我们添加了一个内容:

models.py


class Post(Base):    __tablename__ = "posts"
    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
    publication_date: Mapped[datetime] = mapped_column(
        DateTime, nullable=False, default=datetime.now
    )
    title: Mapped[str] = mapped_column(String(255), nullable=False)
    content: Mapped[str] = mapped_column(Text, nullable=False)
    comments: Mapped[list[Comment]] = relationship("Comment", cascade="all, delete")

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter06/sqlalchemy_relationship/models.py

我们还定义了镜像关系,并注意以我们为back_populates选择的相同名称命名。这次,我们还设置了cascade参数,它允许我们定义 ORM 在删除帖子时的行为:我们是应该隐式删除评论,还是将它们保留为孤立的?在这个例子中,我们选择了删除它们。请注意,这与 SQL 的CASCADE DELETE构造不完全相同:它具有相同的效果,但将由 ORM 在 Python 代码中处理,而不是由 SQL 数据库处理。

关于关系有很多选项,所有这些选项都可以在官方文档中找到:docs.sqlalchemy.org/en/20/orm/relationship_api.html#sqlalchemy.orm.relationship.

再次强调,添加这个comments属性并不会改变 SQL 定义:它只是为 ORM 在 Python 端做的连接。

现在,我们可以为评论实体定义 Pydantic 模式。它们非常直接,因此我们不会深入讨论细节。但请注意我们是如何将comments属性添加到PostRead模式中的:

schemas.py


class PostRead(PostBase):    id: int
    comments: list[CommentRead]

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter06/sqlalchemy_relationship/schemas.py

确实,在 REST API 中,有些情况下自动检索实体的相关对象是有意义的。在这里,能够在一次请求中获取帖子的评论会很方便。这个架构将允许我们序列化评论以及帖子数据*。

现在,我们将实现一个端点来创建新的评论。以下示例展示了这一点:

app.py


@app.post(    "/posts/{id}/comments",
    response_model=schemas.CommentRead,
    status_code=status.HTTP_201_CREATED,
)
async def create_comment(
    comment_create: schemas.CommentCreate,
    post: Post = Depends(get_post_or_404),
    session: AsyncSession = Depends(get_async_session),
) -> Comment:
    comment = Comment(**comment_create.dict(), post=post)
    session.add(comment)
    await session.commit()
    return comment

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter06/sqlalchemy_relationship/app.py

这个端点已定义,因此我们需要直接在路径中设置帖子 ID。它允许我们重用get_post_or_404依赖项,并且如果尝试向不存在的帖子添加评论时,会自动触发404错误。

除此之外,它与本章中创建对象部分的内容非常相似。这里唯一需要注意的是,我们手动设置了这个新comment对象的post属性。由于关系定义的存在,我们可以直接分配post对象,ORM 将自动在post_id列中设置正确的值。

之前我们提到过,我们希望同时检索帖子及其评论。为了实现这一点,我们在获取帖子时需要稍微调整一下查询。以下示例展示了我们为get_post_or_404函数所做的调整,但对列表端点也是一样的:

app.py


async def get_post_or_404(    id: int, session: AsyncSession = Depends(get_async_session)
) -> Post:
    select_query = (
        select(Post).options(selectinload(Post.comments)).where(Post.id == id)
    )
    result = await session.execute(select_query)
    post = result.scalar_one_or_none()
    if post is None:
        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
    return post

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter06/sqlalchemy_relationship/app.py

如你所见,我们添加了对options的调用,并使用了selectinload构造。这是告诉 ORM 在执行查询时自动检索帖子的相关评论的一种方式。如果我们不这么做,就会出错。为什么?因为我们的查询是异步的。但我们从头开始讲。

在经典的同步 ORM 上下文中,你可以这样做:


comments = post.comments

如果comments在第一次请求时没有被加载,同步 ORM 将隐式地对 SQL 数据库执行一个新查询。这对用户是不可见的,但实际上会进行 I/O 操作。这被称为懒加载,它是 SQLAlchemy 中关系的默认行为。

然而,在异步上下文中,I/O 操作不能隐式执行:我们必须显式地等待(await)它们。这就是为什么如果你忘记在第一次查询时显式加载关系,系统会报错的原因。当 Pydantic 尝试序列化PostRead模式时,它将尝试访问post.comments,但是 SQLAlchemy 无法执行这个隐式查询。

因此,在使用异步(async)时,你需要在关系上执行预加载(eager loading),以便直接从 ORM 对象访问。诚然,这比同步版本不太方便。然而,它有一个巨大的优势:你可以精确控制执行的查询。事实上,使用同步 ORM 时,某些端点可能因为代码执行了数十个隐式查询而导致性能不佳。而使用异步 ORM 时,你可以确保所有内容都在单个或少数几个查询中加载。这是一种权衡,但从长远来看,它可能会带来好处。

可以在关系中配置预加载(eager loading)

如果你确定无论上下文如何,你始终需要加载实体的相关对象,你可以直接在relationship函数中定义预加载策略。这样,你就无需在每个查询中设置它。你可以在官方文档中阅读更多关于此的信息:docs.sqlalchemy.org/en/20/orm/relationship_api.html#sqlalchemy.orm.relationship.params.lazy

本质上,处理 SQLAlchemy ORM 中的关系就是这些。你已经看到,关键在于正确定义关系,以便 ORM 可以理解对象之间是如何关联的。

使用 Alembic 设置数据库迁移系统

在开发应用程序时,你很可能会对数据库模式进行更改,添加新表、新列或修改现有的列。当然,如果你的应用程序已经投入生产,你不希望删除所有数据并重新创建数据库模式:你希望它能迁移到新的模式。为此任务开发了相关工具,本节将学习如何设置Alembic,它是 SQLAlchemy 的创作者所开发的库。让我们来安装这个库:


(venv) $ pip install alembic

完成此操作后,你将能够使用alembic命令来管理此迁移系统。在开始一个新项目时,首先需要初始化迁移环境,该环境包括一组文件和目录,Alembic 将在其中存储其配置和迁移文件。在项目的根目录下,运行以下命令:


(venv) $ alembic init alembic

这将会在项目的根目录下创建一个名为alembic的目录。你可以在图 6.4所示的示例仓库中查看该命令的执行结果:

图 6.4 – Alembic 迁移环境结构

图 6.4 – Alembic 迁移环境结构

这个文件夹将包含所有迁移的配置以及迁移脚本本身。它应该与代码一起提交,以便你有这些文件版本的记录。

另外,请注意,它创建了一个alembic.ini文件,其中包含了 Alembic 的所有配置选项。我们将在此文件中查看一个重要的设置:sqlalchemy.url。你可以在以下代码中看到:

alembic.ini


sqlalchemy.url = sqlite:///chapter06_sqlalchemy_relationship.db

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter06/sqlalchemy_relationship/alembic.ini

可以预见的是,这是你数据库的连接字符串,它将接收迁移查询。它遵循我们之前看到的相同约定。在这里,我们设置了 SQLite 数据库。但是,请注意,我们没有设置aiosqlite驱动程序:Alembic 只能与同步驱动程序一起使用。这并不是什么大问题,因为它仅在执行迁移的专用脚本中运行。

接下来,我们将重点关注env.py文件。它是一个包含 Alembic 初始化迁移引擎和执行迁移的所有逻辑的 Python 脚本。作为 Python 脚本,它允许我们精细定制 Alembic 的执行。暂时我们保持默认配置,除了一个小改动:我们会导入我们的Base对象。你可以通过以下示例查看:

env.py


from chapter06.sqlalchemy_relationship.models import Base# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
    fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
target_metadata = Base.metadata

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter06/sqlalchemy_relationship/alembic/env.py

默认情况下,该文件定义了一个名为target_metadata的变量,初始值为None。在这里,我们将其修改为指向从models模块导入的Base.metadata对象。但为什么要这么做呢?回想一下,Base是一个 SQLAlchemy 对象,包含了数据库架构的所有信息。通过将它提供给 Alembic,迁移系统将能够自动生成迁移脚本,只需查看你的架构!这样,你就不必从头编写迁移脚本了。

一旦你对数据库架构进行了更改,可以运行以下命令生成新的迁移脚本:


(venv) $ alembic revision --autogenerate -m "Initial migration"

这将根据你的架构更改创建一个新的脚本文件,并将命令反映到versions目录中。该文件定义了两个函数:upgradedowngrade。你可以在以下代码片段中查看upgrade

eabd3f9c5b64_initial_migration.py


def upgrade() -> None:    # ### commands auto generated by Alembic - please adjust! ###
    op.create_table(
        "posts",
        sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
        sa.Column("publication_date", sa.DateTime(), nullable=False),
        sa.Column("title", sa.String(length=255), nullable=False),
        sa.Column("content", sa.Text(), nullable=False),
        sa.PrimaryKeyConstraint("id"),
    )
    op.create_table(
        "comments",
        sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
        sa.Column("post_id", sa.Integer(), nullable=False),
        sa.Column("publication_date", sa.DateTime(), nullable=False),
        sa.Column("content", sa.Text(), nullable=False),
        sa.ForeignKeyConstraint(
            ["post_id"],
            ["posts.id"],
        ),
        sa.PrimaryKeyConstraint("id"),
    )
    # ### end Alembic commands ###

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter06/sqlalchemy_relationship/alembic/versions/eabd3f9c5b64_initial_migration.py

这个函数在我们应用迁移时执行。它描述了创建 postscomments 表所需的操作,包括所有列和约束。

现在,让我们来看一下这个文件中的另一个函数,downgrade

eabd3f9c5b64_initial_migration.py


def downgrade() -> None:    # ### commands auto generated by Alembic - please adjust! ###
    op.drop_table("comments")
    op.drop_table("posts")
    # ### end Alembic commands ###

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter06/sqlalchemy_relationship/alembic/versions/eabd3f9c5b64_initial_migration.py

这个函数描述了回滚迁移的操作,以便数据库恢复到之前的状态。这一点非常重要,因为如果迁移过程中出现问题,或者你需要恢复到应用程序的旧版本,你可以在不破坏数据的情况下做到这一点。

自动生成并不能检测到所有问题

请记住,尽管自动生成非常有帮助,但它并不总是准确的,有时它无法检测到模糊的变化。例如,如果你重命名了一个列,它会删除旧列并创建一个新的列。因此,该列中的数据将丢失!这就是为什么你应该始终仔细审查迁移脚本,并为类似这种极端情况做出必要的修改。

最后,你可以使用以下命令将迁移应用到数据库:


(venv) $ alembic upgrade head

这将运行所有尚未应用到数据库中的迁移,直到最新的版本。值得注意的是,在这个过程中,Alembic 会在数据库中创建一个表,以便它可以记住所有已应用的迁移:这就是它如何检测需要运行的脚本。

一般来说,当你在数据库上运行此类命令时,应该极其小心,特别是在生产环境中。如果犯了错误,可能会发生非常糟糕的事情,甚至丢失宝贵的数据。在在生产数据库上运行迁移之前,你应该始终在测试环境中进行测试,并确保有最新且有效的备份。

这只是对 Alembic 及其强大迁移系统的简短介绍。我们强烈建议你阅读它的文档,以了解所有机制,特别是关于迁移脚本的操作。请参考 alembic.sqlalchemy.org/en/latest/index.html

这就是本章 SQLAlchemy 部分的内容!它是一个复杂但强大的库,用于处理 SQL 数据库。接下来,我们将离开关系型数据库的世界,探索如何与文档导向型数据库 MongoDB 进行交互。

使用 Motor 与 MongoDB 数据库进行通信

正如我们在本章开头提到的,使用文档导向型数据库(例如 MongoDB)与使用关系型数据库有很大的不同。首先,你不需要提前配置模式:它遵循你插入到其中的数据结构。在 FastAPI 中,这使得我们的工作稍微轻松一些,因为我们只需处理 Pydantic 模型。然而,关于文档标识符,还有一些细节需要我们注意。接下来我们将讨论这一点。

首先,我们将安装 Motor,这是一个用于与 MongoDB 异步通信的库,并且是 MongoDB 官方支持的。运行以下命令:


(venv) $ pip install motor

完成这部分工作后,我们可以开始实际操作了!

创建与 MongoDB ID 兼容的模型

正如我们在本节介绍中提到的,MongoDB 用于存储文档的标识符存在一些困难。事实上,默认情况下,MongoDB 会为每个文档分配一个 _id 属性,作为集合中的唯一标识符。这导致了两个问题:

  • 在 Pydantic 模型中,如果一个属性以下划线开头,则被认为是私有的,因此不会作为数据字段使用。

  • _id 被编码为一个二进制对象,称为 ObjectId,而不是简单的整数或字符串。它通常以类似 608d1ee317c3f035100873dc 的字符串形式表示。Pydantic 或 FastAPI 默认不支持这种类型的对象。

这就是为什么我们需要一些样板代码来确保这些标识符能够与 Pydantic 和 FastAPI 一起工作。首先,在下面的示例中,我们创建了一个 MongoBaseModel 基类,用来处理定义 id 字段:

models.py


class MongoBaseModel(BaseModel):    id: PyObjectId = Field(default_factory=PyObjectId, alias="_id")
    class Config:
        json_encoders = {ObjectId: str}

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter06/mongodb/models.py

首先,我们需要定义一个 id 字段,类型为 PyObjectId。这是一个在前面代码中定义的自定义类型。我们不会深入讨论它的实现细节,但只需知道它是一个类,使得 ObjectId 成为 Pydantic 兼容的类型。我们将此类定义为该字段的默认工厂。有趣的是,这种标识符允许我们在客户端生成它们,这与传统的关系型数据库中自动递增的整数不同,在某些情况下可能非常有用。

最有趣的参数是 alias。这是 Pydantic 的一个选项,允许我们在序列化过程中更改字段的名称。在这个例子中,当我们在 MongoBaseModel 的实例上调用 dict 方法时,标识符将被设置为 _id 键,这也是 MongoDB 所期望的名称。这解决了第一个问题。

接着,我们添加了 Config 子类并设置了 json_encoders 选项。默认情况下,Pydantic 完全不了解我们的 PyObjectId 类型,因此无法正确地将其序列化为 JSON。这个选项允许我们使用一个函数映射自定义类型,以便在序列化时调用它们。在这里,我们只是将其转换为字符串(因为 ObjectId 实现了 __str__ 魔法方法)。这解决了 Pydantic 的第二个问题。

我们的 Pydantic 基础模型已经完成!现在,我们可以将其作为 base 类,而不是 BaseModel,来创建我们实际的数据模型。然而请注意,PostPartialUpdate 并没有继承它。实际上,我们不希望这个模型中有 id 字段;否则,PATCH 请求可能会替换文档的 ID,进而导致奇怪的问题。

连接到数据库

现在我们的模型已经准备好,我们可以设置与 MongoDB 服务器的连接。这非常简单,仅涉及类的实例化,代码示例如下所示,在 database.py 模块中:

database.py


from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase# Connection to the whole server
motor_client = AsyncIOMotorClient("mongodb://localhost:27017")
# Single database instance
database = motor_client["chapter06_mongo"]
def get_database() -> AsyncIOMotorDatabase:
    return database

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter06/mongodb/database.py

在这里,你可以看到 AsyncIOMotorClient 仅仅需要一个连接字符串来连接到你的数据库。通常,它由协议、后面的身份验证信息和数据库服务器的主机名组成。你可以在官方的 MongoDB 文档中查看这个格式的概述:docs.mongodb.com/manual/reference/connection-string/

然而,要小心。与我们迄今讨论的库不同,这里实例化的客户端并没有绑定到任何数据库——也就是说,它只是一个与整个服务器的连接。因此,我们需要第二行代码:通过访问 chapter06_mongo 键,我们得到了一个数据库实例。值得注意的是,MongoDB 并不要求你提前创建数据库:如果数据库不存在,它会自动创建。

接着,我们创建一个简单的函数来返回这个数据库实例。我们将把这个函数作为依赖项,在路径操作函数中获取这个实例。我们在使用 SQLAlchemy 与 SQL 数据库通信 ORM 部分中解释了这种模式的好处。

就这样!我们现在可以对数据库执行查询了!

插入文档

我们将首先演示如何实现一个端点来创建帖子。本质上,我们只需要将转换后的 Pydantic 模型插入字典中:

app.py


@app.post("/posts", response_model=Post, status_code=status.HTTP_201_CREATED)async def create_post(
    post_create: PostCreate, database: AsyncIOMotorDatabase = Depends(get_database)
) -> Post:
    post = Post(**post_create.dict())
    await database["posts"].insert_one(post.dict(by_alias=True))
    post = await get_post_or_404(post.id, database)
    return post

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter06/mongodb/app.py

传统上,这是一个接受 PostCreate 模型格式负载的 POST 端点。此外,我们通过之前编写的依赖项注入数据库实例。

在路径操作本身中,你可以看到我们从 PostCreate 数据实例化了一个 Post。如果你有字段只在 Post 中出现并且需要初始化,这通常是一个好做法。

然后,我们有了查询。为了从我们的 MongoDB 数据库中检索一个集合,我们只需要通过名称像访问字典一样获取它。再次强调,如果该集合不存在,MongoDB 会自动创建它。如你所见,面向文档的数据库在架构方面比关系型数据库更加轻量!在这个集合中,我们可以调用 insert_one 方法插入单个文档。它期望一个字典来将字段映射到它们的值。因此,Pydantic 对象的 dict 方法再次成为我们的好朋友。然而,在这里,我们看到了一些新东西:我们用 by_alias 参数设置为 True 来调用它。默认情况下,Pydantic 会使用真实的字段名序列化对象,而不是别名。但是,我们确实需要 MongoDB 数据库中的 _id 标识符。使用这个选项,Pydantic 将使用别名作为字典中的键。

为了确保我们在字典中有一个真实且最新的文档表示,我们可以通过我们的 get_post_or_404 函数从数据库中检索一个。我们将在下一部分中查看这一点是如何工作的。

依赖关系就像函数

在这一部分中,我们使用 get_post_or_404 作为常规函数来检索我们新创建的博客帖子。这完全没问题:依赖项内部没有隐藏或魔法逻辑,因此你可以随意重用它们。唯一需要记住的是,由于你不在依赖注入的上下文中,因此必须手动提供每个参数。

获取文档

当然,从数据库中检索数据是 REST API 工作的重要部分。在这一部分中,我们将演示如何实现两个经典的端点——即列出帖子和获取单个帖子。让我们从第一个开始,看看它的实现:

app.py


@app.get("/posts", response_model=list[Post])async def list_posts(
    pagination: tuple[int, int] = Depends(pagination),
    database: AsyncIOMotorDatabase = Depends(get_database),
) -> list[Post]:
    skip, limit = pagination
    query = database["posts"].find({}, skip=skip, limit=limit)
    results = [Post(**raw_post) async for raw_post in query]
    return results

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter06/mongodb/app.py

最有趣的部分是第二行,我们在这里定义了查询。在获取posts集合后,我们调用了find方法。第一个参数应该是过滤查询,遵循 MongoDB 语法。由于我们想要获取所有文档,所以将其留空。然后,我们有一些关键字参数,允许我们应用分页参数。

MongoDB 返回的是一个字典列表形式的结果,将字段映射到它们的值。这就是为什么我们添加了一个列表推导式结构来将它们转回Post实例——以便 FastAPI 能够正确序列化它们。

你可能注意到这里有些令人惊讶的地方:与我们通常做法不同,我们并没有直接等待查询。相反,我们在列表推导式中加入了async关键字。确实,在这种情况下,Motor 返回了async关键字,我们在遍历时必须加上它。

现在,让我们看一下获取单个帖子的端点。下面的示例展示了它的实现:

app.py


@app.get("/posts/{id}", response_model=Post)async def get_post(post: Post = Depends(get_post_or_404)) -> Post:
    return post

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter06/mongodb/app.py

如你所见,这是一个简单的GET端点,它接受id作为路径参数。大部分逻辑的实现都在可复用的get_post_or_404依赖中。你可以在这里查看它的实现:

app.py


async def get_post_or_404(    id: ObjectId = Depends(get_object_id),
    database: AsyncIOMotorDatabase = Depends(get_database),
) -> Post:
    raw_post = await database["posts"].find_one({"_id": id})
    if raw_post is None:
        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
    return Post(**raw_post)

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter06/mongodb/app.py

逻辑与我们在列表端点中看到的非常相似。然而,这次我们调用了find_one方法,并使用查询来匹配帖子标识符:键是我们要过滤的文档属性的名称,值是我们正在寻找的值。

这个方法返回一个字典形式的文档,若不存在则返回None。在这种情况下,我们抛出一个适当的404错误。

最后,我们在返回之前将其转换回Post模型。

你可能已经注意到,我们是通过依赖get_object_id获取id的。实际上,FastAPI 会从路径参数中返回一个字符串。如果我们尝试用字符串形式的id进行查询,MongoDB 将无法与实际的二进制 ID 匹配。这就是为什么我们使用另一个依赖来将作为字符串表示的标识符(例如608d1ee317c3f035100873dc)转换为合适的ObjectId

顺便提一下,这里有一个非常好的嵌套依赖的例子:端点使用get_post_or_404依赖,它本身从get_object_id获取一个值。你可以在下面的示例中查看这个依赖的实现:

app.py


async def get_object_id(id: str) -> ObjectId:    try:
        return ObjectId(id)
    except (errors.InvalidId, TypeError):
        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter06/mongodb/app.py

在这里,我们只是从路径参数中提取id字符串,并尝试将其重新实例化为ObjectId。如果它不是一个有效值,我们会捕获相应的错误,并将其视为404错误。

这样,我们就解决了 MongoDB 标识符格式带来的所有挑战。现在,让我们讨论如何更新和删除文档。

更新和删除文档

现在我们将回顾更新和删除文档的端点。逻辑还是一样,只需要从请求负载构建适当的查询。

让我们从PATCH端点开始,您可以在以下示例中查看:

app.py


@app.patch("/posts/{id}", response_model=Post)async def update_post(
    post_update: PostPartialUpdate,
    post: Post = Depends(get_post_or_404),
    database: AsyncIOMotorDatabase = Depends(get_database),
) -> Post:
    await database["posts"].update_one(
        {"_id": post.id}, {"$set": post_update.dict(exclude_unset=True)}
    )
    post = await get_post_or_404(post.id, database)
    return post

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter06/mongodb/app.py

在这里,我们使用update_one方法来更新一条文档。第一个参数是过滤查询,第二个参数是要应用于文档的实际操作。同样,它遵循 MongoDB 的语法:$set操作允许我们通过传递update字典,仅修改我们希望更改的字段。

DELETE端点更简单;它只是一个查询,您可以在以下示例中看到:

app.py


@app.delete("/posts/{id}", status_code=status.HTTP_204_NO_CONTENT)async def delete_post(
    post: Post = Depends(get_post_or_404),
    database: AsyncIOMotorDatabase = Depends(get_database),
):
    await database["posts"].delete_one({"_id": post.id})

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter06/mongodb/app.py

delete_one方法期望过滤查询作为第一个参数。

就是这样!当然,这里我们只是演示了最简单的查询类型,但 MongoDB 有一个非常强大的查询语言,允许你执行更复杂的操作。如果你不熟悉这个,我们建议你阅读官方文档中的精彩介绍:docs.mongodb.com/manual/crud

嵌套文档

在本章开始时,我们提到过,与关系型数据库不同,基于文档的数据库旨在将与实体相关的所有数据存储在一个文档中。在我们当前的示例中,如果我们希望将评论与帖子一起存储,我们只需要添加一个列表,每个项目就是评论数据。

在本节中,我们将实现这一行为。你会看到 MongoDB 的工作方式使得这变得非常简单。

我们将从向Post模型添加一个新的comments属性开始。您可以在以下示例中查看:

models.py


class Post(PostBase):    comments: list[Comment] = Field(default_factory=list)

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter06/mongodb_relationship/models.py

这个字段只是一个Comment的列表。我们不会深入讨论评论模型,因为它们非常简单。请注意,我们使用list函数作为此属性的默认工厂。当我们创建一个没有设置评论的Post时,默认会实例化一个空列表。

现在我们已经有了模型,我们可以实现一个端点来创建新的评论。你可以在下面的示例中查看:

app.py


@app.post(    "/posts/{id}/comments", response_model=Post, status_code=status.HTTP_201_CREATED
)
async def create_comment(
    comment: CommentCreate,
    post: Post = Depends(get_post_or_404),
    database: AsyncIOMotorDatabase = Depends(get_database),
) -> Post:
    await database["posts"].update_one(
        {"_id": post.id}, {"$push": {"comments": comment.dict()}}
    )
    post = await get_post_or_404(post.id, database)
    return post

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter06/mongodb_relationship/app.py

正如我们之前所做的,我们将端点嵌套在单个帖子的路径下。因此,如果该帖子存在,我们可以重新使用get_post_or_404来检索我们要添加评论的帖子。

然后,我们触发一个update_one查询:这次,使用$push操作符。这个操作符对于向列表属性添加元素非常有用。也有可用的操作符用于从列表中移除元素。你可以在官方文档中找到每个update操作符的描述:docs.mongodb.com/manual/reference/operator/update/

就这样!我们甚至不需要修改其余的代码。因为评论已经包含在整个文档中,当我们在数据库中查询帖子时,我们总是能够检索到它们。此外,我们的Post模型现在期待一个comments属性,所以 Pydantic 会自动处理它们的序列化。

这部分关于 MongoDB 的内容到此结束。你已经看到,它可以非常快速地集成到 FastAPI 应用中,特别是由于其非常灵活的架构。

总结

恭喜!你已经达到了掌握如何使用 FastAPI 构建 REST API 的另一个重要里程碑。正如你所知道的,数据库是每个系统中不可或缺的一部分;它们允许你以结构化的方式保存数据,并通过强大的查询语言精确而可靠地检索数据。现在,无论是关系型数据库还是文档导向型数据库,你都能在 FastAPI 中充分利用它们的强大功能。

现在可以进行更严肃的操作了;用户可以向你的系统发送和检索数据。然而,这也带来了一个新的挑战:这些数据需要受到保护,以确保它们能够保持私密和安全。这正是我们在下一章将讨论的内容:如何认证用户并为 FastAPI 配置最大安全性。

第七章:在 FastAPI 中管理身份验证和安全性

大多数时候,你不希望互联网上的每个人都能访问你的 API,而不对他们能创建或读取的数据设置任何限制。这就是为什么你至少需要用私有令牌保护你的应用程序,或者拥有一个合适的身份验证系统来管理授予每个用户的权限。在本章中,我们将看到 FastAPI 如何提供安全依赖,帮助我们通过遵循不同的标准来检索凭证,这些标准直接集成到自动文档中。我们还将构建一个基本的用户注册和身份验证系统来保护我们的 API 端点。

最后,我们将讨论当你想从浏览器中的 Web 应用程序调用 API 时需要解决的安全挑战——特别是 CORS 和 CSRF 攻击的风险。

在本章中,我们将讨论以下主要内容:

  • FastAPI 中的安全依赖

  • 获取用户并生成访问令牌

  • 为经过身份验证的用户保护 API 端点

  • 使用访问令牌保护端点

  • 配置 CORS 并防止 CSRF 攻击

技术要求

对于本章内容,你将需要一个 Python 虚拟环境,正如我们在第一章中设置的那样,Python 开发 环境设置

你可以在专门的 GitHub 仓库中找到本章的所有代码示例,地址是github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter07

FastAPI 中的安全依赖

为了保护 REST API,以及更广泛的 HTTP 端点,已经提出了许多标准。以下是最常见的一些标准的非详尽列表:

  • Authorization。该值由Basic关键字组成,后跟以Base64编码的用户凭证。这是一种非常简单的方案,但并不太安全,因为密码会出现在每个请求中。

  • Cookies:Cookies 是一个在客户端(通常是在 Web 浏览器上)存储静态数据的有用方式,这些数据会在每次请求时发送到服务器。通常,一个 cookie 包含一个会话令牌,服务器可以验证并将其与特定用户关联。

  • Authorization头:在 REST API 上下文中,可能是最常用的头部,它仅仅是通过 HTTP Authorization头发送一个令牌。该令牌通常以方法关键字(如Bearer)为前缀。在服务器端,可以验证这个令牌并将其与特定用户关联。

每个标准都有其优缺点,并适用于特定的使用场景。

如你所知,FastAPI 主要是关于依赖注入和可调用项,它们会在运行时被自动检测并调用。身份验证方法也不例外:FastAPI 默认提供了大部分的安全依赖。

首先,让我们学习如何从任意头部检索访问令牌。为此,我们可以使用ApiKeyHeader依赖项,如下例所示:

chapter07_api_key_header.py


from fastapi import Depends, FastAPI, HTTPException, statusfrom fastapi.security import APIKeyHeader
API_TOKEN = "SECRET_API_TOKEN"
app = FastAPI()
api_key_header = APIKeyHeader(name="Token")
@app.get("/protected-route")
async def protected_route(token: str = Depends(api_key_header)):
    if token != API_TOKEN:
        raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
    return {"hello": "world"}

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter07/chapter07_api_key_header.py

在这个简单的示例中,我们硬编码了一个令牌API_TOKEN,并检查头部传递的令牌是否等于这个令牌,之后才授权调用端点。为了做到这一点,我们使用了APIKeyHeader安全依赖项,它专门用于从头部检索值。它是一个类依赖项,可以通过参数实例化。特别地,它接受name参数,该参数保存它将要查找的头部名称。

然后,在我们的端点中,我们注入了这个依赖项来获取令牌的值。如果它等于我们的令牌常量,我们就继续执行端点逻辑。否则,我们抛出403错误。

我们在《第五章》中的路径、路由器和全局依赖项部分的示例,FastAPI 中的依赖注入,与这个示例并没有太大不同。我们只是从一个任意的头部中检索值并进行等式检查。那么,为什么要使用专门的依赖项呢?有两个原因:

  • 首先,检查头部是否存在并检索其值的逻辑包含在APIKeyHeader中。当你到达端点时,可以确定已检索到令牌值;否则,将抛出403错误。

  • 第二个,可能也是最重要的,事情是它被 OpenAPI 架构检测到,并包含在其交互式文档中。这意味着使用此依赖项的端点将显示一个锁定图标,表示这是一个受保护的端点。此外,你将能够访问一个界面来输入你的令牌,如下图所示。令牌将自动包含在你从文档发出的请求中:

图 7.1 – 在交互式文档中的令牌授权

图 7.1 – 在交互式文档中的令牌授权

当然,你可以将检查令牌值的逻辑封装在自己的依赖项中,以便在各个端点之间重用,如下例所示:

chapter07_api_key_header_dependency.py


async def api_token(token: str = Depends(APIKeyHeader(name="Token"))):    if token != API_TOKEN:
        raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
@app.get("/protected-route", dependencies=[Depends(api_token)])
async def protected_route():
    return {"hello": "world"}

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter07/chapter07_api_key_header.py

这种依赖关系非常适合用作路由器或全局依赖项,以保护一组路由,正如我们在第五章《FastAPI 中的依赖注入》中看到的那样。

这是为你的 API 添加授权的一个非常基本的示例。在这个示例中,我们没有用户管理;我们只是检查令牌是否与一个常量值对应。虽然它对于不打算由最终用户调用的私有微服务来说可能有用,但这种方法不应被认为非常安全。

首先,确保你的 API 始终通过 HTTPS 提供服务,以确保令牌不会在头部暴露。然后,如果这是一个私有微服务,你还应该考虑不要公开暴露它到互联网上,并确保只有受信任的服务器才能调用它。由于你不需要用户向这个服务发起请求,因此它比一个简单的令牌密钥要安全得多,因为后者可能会被盗取。

当然,大多数情况下,你会希望通过用户自己的个人访问令牌来验证真实用户,从而让他们访问自己的数据。你可能已经使用过实现这种典型模式的服务:

  • 首先,你必须在该服务上注册一个账户,通常是通过提供你的电子邮件地址和密码。

  • 接下来,你可以使用相同的电子邮件地址和密码登录该服务。该服务会检查电子邮件地址是否存在以及密码是否有效。

  • 作为交换,服务会为你提供一个会话令牌,可以在后续请求中使用它来验证身份。这样,你就不需要在每次请求时都提供电子邮件地址和密码,这样既麻烦又危险。通常,这种会话令牌有一个有限的生命周期,这意味着一段时间后你需要重新登录。这可以减少会话令牌被盗时的安全风险。

在下一部分,你将学习如何实现这样的系统。

将用户及其密码安全地存储在数据库中

将用户实体存储在数据库中与存储任何其他实体并没有区别,你可以像在第六章《数据库和异步 ORM》中一样实现它。你必须特别小心的唯一事项就是密码存储。你绝不能将密码以明文形式存储在数据库中。为什么?如果不幸地,某个恶意的人成功进入了你的数据库,他们将能够获取所有用户的密码。由于许多人会在多个地方使用相同的密码,他们在其他应用程序和网站上的账户安全将受到严重威胁。

为了避免这种灾难,我们可以对密码应用加密哈希函数。这些函数的目标是将密码字符串转换为哈希值。设计这个的目的是让从哈希值中恢复原始数据几乎不可能。因此,即使你的数据库被入侵,密码依然安全。

当用户尝试登录时,我们只需计算他们输入的密码的哈希值,并将其与我们数据库中的哈希值进行比较。如果匹配,则意味着密码正确。

现在,让我们学习如何使用 FastAPI 和 SQLAlchemy ORM 来实现这样的系统。

创建模型

我们从为用户创建 SQLAlchemy ORM 模型开始,如下所示:

models.py


class User(Base):    __tablename__ = "users"
    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
    email: Mapped[str] = mapped_column(
        String(1024), index=True, unique=True, nullable=False
    )
    hashed_password: Mapped[str] = mapped_column(String(1024), nullable=False)

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter07/authentication/models.py

为了简化这个示例,我们在模型中仅考虑了 ID、电子邮件地址和密码。请注意,我们对 email 列添加了唯一约束,以确保数据库中不会有重复的电子邮件。

接下来,我们可以实现相应的 Pydantic 模式:

schemas.py


class UserBase(BaseModel):    email: EmailStr
    class Config:
        orm_mode = True
class UserCreate(UserBase):
    password: str
class User(UserBase):
    id: int
    hashed_password: str
class UserRead(UserBase):
    id: int

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter07/authentication/schemas.py

如你所见,UserCreateUser 之间有一个主要区别:前者接受我们在注册时会进行哈希处理的明文密码,而后者仅会在数据库中保留哈希后的密码。我们还会确保在 UserRead 中不包含 hashed_password,因此哈希值不会出现在 API 响应中。尽管哈希数据应当是不可解读的,但一般不建议泄露这些数据。

哈希密码

在我们查看注册端点之前,让我们先实现一些用于哈希密码的重要工具函数。幸运的是,已有一些库提供了最安全、最高效的算法来完成这项任务。在这里,我们将使用 passlib。你可以安装它以及 argon2_cffi,这是写作时最安全的哈希函数之一:


(venv) $ pip install passlib argon2_cffi

现在,我们只需要实例化 passlib 类,并封装它们的一些函数,以简化我们的工作:

password.py


from passlib.context import CryptContextpwd_context = CryptContext(schemes=["argon2"], deprecated="auto")
def get_password_hash(password: str) -> str:
    return pwd_context.hash(password)

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter07/authentication/password.py

CryptContext 是一个非常有用的类,因为它允许我们使用不同的哈希算法。如果有一天,出现比 argon2 更好的算法,我们只需将其添加到我们的允许的模式中。新密码将使用新算法进行哈希,但现有密码仍然可以识别(并可选择升级为新算法)。

实现注册路由

现在,我们具备了创建合适注册路由的所有要素。再次强调,它将与我们之前看到的非常相似。唯一需要记住的是,在将密码插入到数据库之前,我们必须先对其进行哈希处理。

让我们看一下实现:

app.py


@app.post(    "/register", status_code=status.HTTP_201_CREATED, response_model=schemas.UserRead
)
async def register(
    user_create: schemas.UserCreate, session: AsyncSession = Depends(get_async_session)
) -> User:
    hashed_password = get_password_hash(user_create.password)
    user = User(
        *user_create.dict(exclude={"password"}), hashed_password=hashed_password
    )
    try:
        session.add(user)
        await session.commit()
    except exc.IntegrityError:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST, detail="Email already exists"
        )
    return user

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter07/authentication/app.py

如你所见,我们在将用户插入数据库之前,对输入的密码调用了get_password_hash。请注意,我们捕获了可能出现的exc.IntegrityError异常,这意味着我们正在尝试插入一个已存在的电子邮件。

此外,请注意我们设置了response_modelUserRead。通过这样做,我们确保hashed_password不会出现在输出中。

太棒了!我们现在有了一个合适的用户模型,用户可以通过我们的 API 创建新账户。下一步是允许用户登录并为其提供访问令牌。

获取用户并生成访问令牌

在成功注册后,下一步是能够登录:用户将发送其凭证并接收一个身份验证令牌,以访问 API。在这一部分,我们将实现允许此操作的端点。基本上,我们将从请求有效载荷中获取凭证,使用给定的电子邮件检索用户并验证其密码。如果用户存在且密码有效,我们将生成一个访问令牌并将其返回在响应中。

实现数据库访问令牌

首先,让我们思考一下这个访问令牌的性质。它应该是一个数据字符串,能够唯一标识一个用户,并且无法被恶意第三方伪造。在这个示例中,我们将采用一种简单但可靠的方法:我们将生成一个随机字符串,并将其存储在数据库中的专用表中,同时设置外键引用到用户。

这样,当一个经过身份验证的请求到达时,我们只需检查令牌是否存在于数据库中,并寻找相应的用户。这个方法的优势是令牌是集中管理的,如果它们被泄露,可以轻松作废;我们只需要从数据库中删除它们。

第一步是为这个新实体实现 SQLAlchemy ORM 模型:

models.py


class AccessToken(Base):    __tablename__ = "access_tokens"
    access_token: Mapped[str] = mapped_column(
        String(1024), primary_key=True, default=generate_token
    )
    user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False)
    expiration_date: Mapped[datetime] = mapped_column(
        DateTime, nullable=False, default=get_expiration_date
    )
    user: Mapped[User] = relationship("User", lazy="joined")

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter07/authentication/models.py

我们定义了三个列:

  • access_token:这是将在请求中传递以进行身份验证的字符串。请注意,我们将generate_token函数定义为默认工厂;它是一个简单的先前定义的函数,用于生成随机安全密码。在底层,它依赖于标准的secrets模块。

  • user_id:指向users表的外键,用于标识与此令牌对应的用户。

  • expiration_date:访问令牌将到期并且不再有效的日期和时间。为访问令牌设置到期日期总是一个好主意,以减轻其被盗的风险。在这里,get_expiration_date工厂设置了默认的有效期为 24 小时。

我们还不要忘记定义关系,这样我们可以直接从访问令牌对象访问用户实体。请注意,我们默认设置了一种急加载策略,因此在查询访问令牌时始终检索用户。如果需要其背后的原理,请参阅第六章**,数据库和异步 ORM中的添加关系部分。

在这里我们不需要 Pydantic 模式,因为访问令牌将通过特定方法创建和序列化。

实现登录端点

现在,让我们考虑登录端点。其目标是接收请求有效载荷中的凭据,检索相应的用户,检查密码并生成新的访问令牌。除了一个事项外,它的实现非常直接:用于处理请求的模型。通过下面的示例你将明白为什么:

app.py


@app.post("/token")async def create_token(
    form_data: OAuth2PasswordRequestForm = Depends(OAuth2PasswordRequestForm),
    session: AsyncSession = Depends(get_async_session),
):
    email = form_data.username
    password = form_data.password
    user = await authenticate(email, password, session)
    if not user:
        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
    token = await create_access_token(user, session)
    return {"access_token": token.access_token, "token_type": "bearer"}

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter07/authentication/app.py

正如您所见,我们通过 FastAPI 的安全模块中提供的OAuth2PasswordRequestForm模块检索请求数据。它期望在表单编码中有几个字段,特别是usernamepassword,而不是 JSON。

为什么我们要使用这个类?使用这个类的主要好处是它完全集成到 OpenAPI 模式中。这意味着交互式文档能够自动检测到它,并在授权按钮后显示适当的身份验证表单,如下面的截图所示:

图 7.2 – 交互式文档中的 OAuth2 授权

图 7.2 – 交互式文档中的 OAuth2 授权

但这还不是全部:它还能自动获取返回的访问令牌,并在后续请求中设置正确的授权头。身份验证过程由交互式文档透明处理。

这个类遵循 OAuth2 协议,这意味着你还需要包含客户端 ID 和密钥字段。我们不会在这里学习如何实现完整的 OAuth2 协议,但请注意,FastAPI 提供了所有正确实现它所需的工具。对于我们的项目,我们将只使用用户名和密码。请注意,根据协议,字段被命名为用户名,无论我们是使用电子邮件地址来识别用户与否。这不是大问题,我们只需在获取它时记住这一点。

剩下的路径操作函数相当简单:首先,我们尝试根据电子邮件和密码获取用户。如果没有找到相应的用户,我们将抛出401错误。否则,我们会在返回之前生成一个新的访问令牌。请注意,响应结构中还包括token_type属性。这使得交互式文档能够自动设置授权头。

在下面的示例中,我们将查看authenticatecreate_access_token函数的实现。我们不会深入细节,因为它们非常简单:

authentication.py


async def authenticate(email: str, password: str, session: AsyncSession) -> User | None:    query = select(User).where(User.email == email)
    result = await session.execute(query)
    user: User | None = result.scalar_one_or_none()
    if user is None:
        return None
    if not verify_password(password, user.hashed_password):
        return None
    return user
async def create_access_token(user: User, session: AsyncSession) -> AccessToken:
    access_token = AccessToken(user=user)
    session.add(access_token)
    await session.commit()
    return access_token

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter07/authentication/authentication.py

请注意,我们定义了一个名为verify_password的函数来检查密码的有效性。再一次,它在后台使用passlib,该库负责比较密码的哈希值。

密码哈希升级

为了简化示例,我们实现了一个简单的密码比较。通常,最好在这个阶段实现一个机制来升级密码哈希。假设引入了一个新的、更强大的哈希算法。我们可以借此机会使用这个新算法对密码进行哈希处理并将其存储在数据库中。passlib包含一个函数,可以在一次操作中验证和升级哈希。你可以通过以下文档了解更多内容:passlib.readthedocs.io/en/stable/narr/context-tutorial.html#integrating-hash-migration

我们几乎达成了目标!用户现在可以登录并获取新的访问令牌。接下来,我们只需要实现一个依赖项来检索Authorization头并验证这个令牌!

使用访问令牌保护端点

之前,我们学习了如何实现一个简单的依赖项来保护带有头部的端点。在这里,我们也会从请求头中获取令牌,但接下来,我们需要检查数据库,看看它是否有效。如果有效,我们将返回相应的用户。

让我们看看我们的依赖项是什么样子的:

app.py


async def get_current_user(    token: str = Depends(OAuth2PasswordBearer(tokenUrl="/token")),
    session: AsyncSession = Depends(get_async_session),
) -> User:
    query = select(AccessToken).where(
        AccessToken.access_token == token,
        AccessToken.expiration_date >= datetime.now(tz=timezone.utc),
    )
    result = await session.execute(query)
    access_token: AccessToken | None = result.scalar_one_or_none()
    if access_token is None:
        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
    return access_token.user

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter07/authentication/app.py

首先需要注意的是,我们使用了来自 FastAPI 的 OAuth2PasswordBearer 依赖项。它与我们在前一节中看到的 OAuth2PasswordRequestForm 配合使用。它不仅检查 Authorization 头中的访问令牌,还告知 OpenAPI 架构获取新令牌的端点是 /token。这就是 tokenUrl 参数的目的。通过这一点,自动化文档可以自动调用我们之前看到的登录表单中的访问令牌端点。

然后我们使用 SQLAlchemy 执行了数据库查询。我们应用了两个条件:一个用于匹配我们获得的令牌,另一个确保过期时间是在未来。如果在数据库中找不到相应的记录,我们会抛出一个 401 错误。否则,我们会返回与访问令牌相关的用户。

就这样!我们的整个身份验证系统完成了。现在,我们可以通过简单地注入这个依赖项来保护我们的端点。我们甚至可以访问用户数据,从而根据当前用户量身定制响应。你可以在以下示例中看到这一点:

app.py


@app.get("/protected-route", response_model=schemas.UserRead)async def protected_route(user: User = Depends(get_current_user)):
    return user

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter07/authentication/app.py

至此,你已经学会了如何从头开始实现完整的注册和身份验证系统。我们故意保持其简单,以便专注于最重要的点,但这为你扩展提供了一个良好的基础。

我们在这里展示的模式是适合 REST API 的良好范例,这些 API 是由其他客户端程序外部调用的。然而,你可能希望通过一个非常常见的软件来调用你的 API:浏览器。在这种情况下,有一些额外的安全考虑需要处理。

配置 CORS 并防止 CSRF 攻击

如今,许多软件都被设计为通过使用 HTML、CSS 和 JavaScript 构建的界面在浏览器中使用。传统上,Web 服务器负责处理浏览器请求并返回 HTML 响应,以供用户查看。这是 Django 等框架的常见用例。

近年来,随着 JavaScript 框架如 Angular、React 和 Vue 的出现,这一模式正在发生变化。我们现在往往会看到前端和后端的明确分离,前端是一个由 JavaScript 驱动的高度互动的用户界面,后端则负责数据存储、检索以及执行业务逻辑。这是 REST API 擅长的任务!从 JavaScript 代码中,用户界面可以向你的 API 发送请求并处理结果进行展示。

然而,我们仍然需要处理身份验证:我们希望用户能够登录前端应用,并向 API 发送经过身份验证的请求。虽然如我们到目前为止看到的Authorization头可以工作,但在浏览器中处理身份验证有一个更好的方法:Cookies

Cookies(浏览器 Cookie)旨在将用户信息存储在浏览器内存中,并在每次请求发送到你的服务器时自动发送。多年来它们得到了支持,浏览器也集成了很多机制来确保它们的安全和可靠。

然而,这也带来了一些安全挑战。网站是黑客的常见攻击目标,多年来已经出现了很多攻击方式。

最典型的攻击方式之一是跨站请求伪造CSRF)。在这种情况下,攻击者会在另一个网站上尝试欺骗当前已在你的应用程序中认证的用户,向你的服务器发起请求。由于浏览器通常会在每次请求时发送 Cookie,你的服务器无法识别出请求实际上是伪造的。由于这些恶意请求是用户自己无意中发起的,因此这类攻击并不旨在窃取数据,而是执行改变应用状态的操作,比如更改电子邮件地址或进行转账。

显然,我们应该为这些风险做好准备,并采取措施来减轻它们。

理解 CORS 并在 FastAPI 中进行配置

当你有一个明确分离的前端应用和 REST API 后端时,它们通常不会来自同一个子域。例如,前端可能来自www.myapplication.com,而 REST API 来自api.myapplication.com。正如我们在介绍中提到的,我们希望从前端应用程序通过 JavaScript 向该 API 发起请求。

然而,浏览器不允许跨域****资源共享(CORS)HTTP 请求,即域 A 无法向域 B 发起请求。这遵循了所谓的同源策略。一般来说,这是一个好事,因为它是防止 CSRF 攻击的第一道屏障。

为了体验这种行为,我们将运行一个简单的例子。在我们的示例仓库中,chapter07/cors 文件夹包含一个名为 app_without_cors.py 的 FastAPI 应用程序和一个简单的 HTML 文件 index.html,该文件包含一些用于执行 HTTP 请求的 JavaScript。

首先,让我们使用通常的 uvicorn 命令运行 FastAPI 应用程序:


(venv) $ uvicorn chapter07.cors.app_without_cors:app

这将默认启动 FastAPI 应用程序,端口为 8000。在另一个终端中,我们将使用内置的 Python HTTP 服务器提供 HTML 文件。它是一个简单的服务器,但非常适合快速提供静态文件。我们可以通过以下命令在端口 9000 启动它:


(venv) $ python -m http.server --directory chapter07/cors 9000

启动多个终端

在 Linux 和 macOS 上,你可以通过创建一个新的窗口或标签页来启动一个新的终端。在 Windows 和 WSL 上,如果你使用 Windows 终端应用程序,也可以有多个标签页:apps.microsoft.com/store/detail/windows-terminal/9N0DX20HK701

否则,你可以简单地点击 开始 菜单中的 Ubuntu 快捷方式来启动另一个终端。

现在我们有两个正在运行的服务器——一个在 localhost:8000,另一个在 localhost:9000。严格来说,由于它们在不同的端口上,它们属于不同的源;因此,这是一个很好的设置,可以尝试跨源 HTTP 请求。

在你的浏览器中,访问 http://localhost:9000。你会看到在 index.html 中实现的简单应用程序,如下图所示:

图 7.3 – 尝试 CORS 策略的简单应用

图 7.3 – 尝试 CORS 策略的简单应用

有两个按钮,可以向我们的 FastAPI 应用程序发起 GET 和 POST 请求,端口为 8000。如果点击其中任意一个按钮,你会在错误区域看到一条消息,显示 获取失败。如果你查看开发者工具中的浏览器控制台,你会发现请求失败的原因是没有 CORS 策略,正如下图所示。这正是我们想要的——默认情况下,浏览器会阻止跨源 HTTP 请求:

图 7.4 – 浏览器控制台中的 CORS 错误

图 7.4 – 浏览器控制台中的 CORS 错误

但是,如果你查看正在运行 FastAPI 应用程序的终端,你会看到类似下面的输出:

图 7.5 – 执行简单请求时的 Uvicorn 输出

图 7.5 – 执行简单请求时的 Uvicorn 输出

显然,GETPOST 请求已经接收并处理:我们甚至返回了 200 状态。那么,这意味着什么呢?在这种情况下,浏览器确实会将请求发送到服务器。缺乏 CORS 策略只会禁止它读取响应;请求仍然会执行。

这是浏览器认为是 GETPOSTHEAD 方法的请求,它们不设置自定义头部或不使用不常见的内容类型的情况。你可以通过访问以下 MDN 页面了解更多关于简单请求及其条件的信息:developer.mozilla.org/en-US/docs/Web/HTTP/CORS#simple_requests

这意味着,对于简单请求来说,相同来源策略不足以保护我们免受 CSRF 攻击。

你可能已经注意到,我们的简单 Web 应用再次提供了 GETPOST 请求的切换功能。在你的 FastAPI 终端中,应该会看到类似于以下的输出:

图 7.6 – Uvicorn 在接收到预检请求时的输出

图 7.6 – Uvicorn 在接收到预检请求时的输出

如你所见,我们的服务器接收到了两个奇怪的 OPTIONS 请求。这是我们所说的具有 application/json 值的 Content-Type 头部,这违反了简单请求的条件。

通过执行这个预检请求,浏览器期望服务器提供有关它可以和不可以执行的跨源 HTTP 请求的信息。由于我们这里没有实现任何内容,我们的服务器无法对这个预检请求作出响应。因此,浏览器在这里停止,并且不会继续执行实际请求。

这基本上就是 CORS:服务器用一组 HTTP 头部响应预检请求,提供关于浏览器是否可以发起请求的信息。从这个意义上说,CORS 并不会让你的应用更安全,恰恰相反:它放宽了一些规则,使得前端应用可以向另一个域上的后端发起请求。因此,正确配置 CORS 至关重要,以免暴露于危险的攻击之下。

幸运的是,使用 FastAPI 这非常简单。我们需要做的就是导入并添加由 Starlette 提供的 CORSMiddleware 类。你可以在以下示例中看到它的实现:

app_with_cors.py


app.add_middleware(    CORSMiddleware,
    allow_origins=["http://localhost:9000"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
    max_age=-1,  # Only for the sake of the example. Remove this in your own project.
)

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter07/cors/app_with_cors.py

中间件是一种特殊的类,它将全局逻辑添加到 add_middleware 方法中,将这种中间件集成到你的应用中。

在这里,CORSMiddleware 会捕获浏览器发送的预检请求,并返回带有与你配置相对应的 CORS 头部的适当响应。你可以看到,有选项可以精细调整 CORS 策略以满足你的需求。

最重要的可能是 allow_origins,它是允许向你的 API 发起请求的源列表。由于我们的 HTML 应用是从 http://localhost:9000 提供的,因此我们在此参数中填写该地址。如果浏览器尝试从任何其他源发出请求,它将被阻止,因为 CORS 头部不允许。

另一个有趣的参数是 allow_credentials。默认情况下,浏览器不会为跨域 HTTP 请求发送 cookies。如果我们希望向 API 发出认证请求,需要通过此选项来允许此操作。

我们还可以精细调节请求中允许的 HTTP 方法和头部。你可以在官方 Starlette 文档中找到此中间件的完整参数列表:www.starlette.io/middleware/#corsmiddleware

让我们简要讨论一下 max_age 参数。此参数允许你控制 CORS 响应的缓存时长。在实际请求之前执行预检请求是一个昂贵的操作。为了提高性能,浏览器可以缓存响应,以避免每次都执行此操作。在此,我们将缓存禁用,设置值为 -1,以确保你在这个示例中看到浏览器的行为。在你的项目中,可以删除此参数,以便设置适当的缓存值。

现在,让我们看看启用了 CORS 的应用程序如何在我们的 Web 应用中表现。停止之前的 FastAPI 应用,并使用常规命令运行此应用:


(venv) $ uvicorn chapter07.cors.app_with_cors:app

现在,如果你尝试从 HTML 应用执行请求,你应该会在每种情况下看到有效的响应,无论是否使用 JSON 内容类型。如果你查看 FastAPI 的终端,你应该会看到类似于以下内容的输出:

图 7.7 – 启用 CORS 头的 Uvicorn 输出

图 7.7 – 启用 CORS 头的 Uvicorn 输出

前两个请求是“简单请求”,根据浏览器规则,这些请求无需预检请求。接着,我们可以看到启用了 JSON 内容类型的请求。在 GETPOST 请求之前,执行了一个 OPTIONS 请求:即预检请求!

多亏了这个配置,你现在可以在前端应用和位于另一个源的后端之间进行跨域 HTTP 请求。再次强调,这并不能提升应用的安全性,但它允许你在确保应用其余部分安全的同时,使这个特定场景得以正常运行。

即便这些策略可以作为抵御 CSRF 的第一道防线,但并不能完全消除风险。事实上,“简单请求”仍然是一个问题:POST 请求是允许的,尽管响应不能被读取,但实际上它是在服务器上执行的。

现在,让我们学习如何实现一种模式,以确保我们完全避免此类攻击:双重提交 Cookie

如前所述,当依赖 Cookies 存储用户凭据时,我们容易遭受 CSRF 攻击,因为浏览器会自动将 Cookie 发送到你的服务器。这对于浏览器认为的“简单请求”尤其如此,因为在请求执行之前不会强制执行 CORS 策略。还有其他攻击向量,例如传统的 HTML 表单提交,甚至是图片标签的 src 属性。

由于这些原因,我们需要额外的安全层来缓解这种风险。再次强调,这仅在你计划通过浏览器应用使用 API 并使用 Cookies 进行身份验证时才是必要的。

为了帮助你理解这一点,我们构建了一个新的示例应用程序,使用 Cookie 存储用户访问令牌。它与我们在本章开头看到的应用非常相似;我们只是修改了它,使其从 Cookie 中获取访问令牌,而不是从请求头中获取。

为了使这个示例生效,你需要安装 starlette-csrf 库。我们稍后会解释它的作用。现在,只需运行以下命令:


(venv) $ pip install starlette-csrf

在以下示例中,你可以看到设置了包含访问令牌值的 Cookie 的登录端点:

app.py


@app.post("/login")async def login(
    response: Response,
    email: str = Form(...),
    password: str = Form(...),
    session: AsyncSession = Depends(get_async_session),
):
    user = await authenticate(email, password, session)
    if not user:
        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
    token = await create_access_token(user, session)
    response.set_cookie(
        TOKEN_COOKIE_NAME,
        token.access_token,
        max_age=token.max_age(),
        secure=True,
        httponly=True,
        samesite="lax",
    )

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter07/csrf/app.py

请注意,我们为生成的 Cookie 使用了 SecureHttpOnly 标志。这确保了该 Cookie 仅通过 HTTPS 发送,并且其值不能通过 JavaScript 读取。虽然这不足以防止所有类型的攻击,但对于这种敏感信息来说至关重要。

除此之外,我们还将 SameSite 标志设置为 lax。这是一个相对较新的标志,允许我们控制 Cookie 在跨源上下文中如何发送。lax 是大多数浏览器中的默认值,它允许将 Cookie 发送到 Cookie 域的子域名,但不允许发送到其他站点。从某种意义上讲,它是为防范 CSRF 攻击设计的标准内置保护。然而,目前仍然需要其他 CSRF 缓解技术,比如我们将在此实现的技术。实际上,仍有一些旧版浏览器不兼容 SameSite 标志,依然存在漏洞。

现在,当检查已认证用户时,我们只需从请求中发送的 Cookie 中提取令牌。再次强调,FastAPI 提供了一个安全依赖项,帮助实现这一功能,名为 APIKeyCookie。你可以在以下示例中看到它:

app.py


async def get_current_user(    token: str = Depends(APIKeyCookie(name=TOKEN_COOKIE_NAME)),
    session: AsyncSession = Depends(get_async_session),
) -> User:
    query = select(AccessToken).where(
        AccessToken.access_token == token,
        AccessToken.expiration_date >= datetime.now(tz=timezone.utc),
    )
    result = await session.execute(query)
    access_token: AccessToken | None = result.scalar_one_or_none()
    if access_token is None:
        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
    return access_token.user

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter07/csrf/app.py

基本上就是这样!其余的代码保持不变。现在,让我们实现一个端点,允许我们更新经过身份验证的用户的电子邮件地址。您可以在以下示例中看到:

app.py


@app.post("/me", response_model=schemas.UserRead)async def update_me(
    user_update: schemas.UserUpdate,
    user: User = Depends(get_current_user),
    session: AsyncSession = Depends(get_async_session),
):
    user_update_dict = user_update.dict(exclude_unset=True)
    for key, value in user_update_dict.items():
        setattr(user, key, value)
    session.add(user)
    await session.commit()
    return user

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter07/csrf/app.py

这个实现并不令人惊讶,遵循了我们到目前为止所见的方式。然而,它使我们暴露于 CSRF 威胁中。如您所见,它使用了POST方法。如果我们在浏览器中向该端点发出没有任何特殊头部的请求,它会将其视为普通请求并执行。因此,攻击者可能会更改当前已验证用户的电子邮件地址,这是一个重大威胁。

这正是我们在这里需要 CSRF 保护的原因。在 REST API 的上下文中,最直接的技术是双重提交 cookie 模式。其工作原理如下:

  1. 用户首先发出一个被认为是安全的方法的请求,通常是一个GET请求。

  2. 在响应中,它接收一个包含随机秘密值的 cookie——即 CSRF 令牌。

  3. 当发出不安全请求时,例如POST,用户会从 cookie 中读取 CSRF 令牌,并将相同的值放入请求头中。由于浏览器还会发送内存中存储的 cookie,请求将同时在 cookie 和请求头中包含该令牌。这就是为什么称之为双重提交

  4. 在处理请求之前,服务器将比较请求头中提供的 CSRF 令牌与 cookie 中存在的令牌。如果匹配,它将继续处理请求。否则,它将抛出一个错误。

这是安全的,原因有二:

  • 针对第三方网站的攻击者无法读取他们没有所有权的域名的 cookie。因此,他们无法检索到 CSRF 令牌的值。

  • 添加自定义头部违反了“简单请求”的条件。因此,浏览器在发送请求之前必须进行预检请求,从而强制执行 CORS 策略。

这是一个广泛使用的模式,在防止此类风险方面效果良好。这也是为什么我们在本节开始时安装了starlette-csrf:它提供了一个中间件来实现这一点。

我们可以像使用其他中间件一样使用它,以下示例演示了这一点:

app.py


app.add_middleware(    CSRFMiddleware,
    secret=CSRF_TOKEN_SECRET,
    sensitive_cookies={TOKEN_COOKIE_NAME},
    cookie_domain="localhost",
)

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter07/csrf/app.py

我们在这里设置了几个重要的参数。首先,我们有一个密钥,它应该是一个强密码,用于签名 CSRF 令牌。然后,我们有sensitive_cookies,这是一个包含应该触发 CSRF 保护的 cookie 名称的集合。如果没有 cookie,或者提供的 cookie 不是关键性的,我们可以绕过 CSRF 检查。如果你有其他的认证方法(如不依赖于 cookie 的授权头),这也很有用,因为这些方法不容易受到 CSRF 攻击。最后,设置 cookie 域名将允许你在不同的子域上获取包含 CSRF 令牌的 cookie;这在跨源情况下是必要的。

这就是你需要准备的必要保护。为了简化获取新 CSRF 令牌的过程,我们实现了一个最小的 GET 端点,叫做/csrf。它的唯一目的是提供一个简单的方式来设置 CSRF 令牌的 cookie。我们可以在加载前端应用时直接调用它。

现在,让我们在我们的环境中试试。正如我们在上一节中所做的那样,我们将会在两个不同的端口上运行 FastAPI 应用程序和简单的 HTML 应用程序。为此,只需运行以下命令:


(venv) $ uvicorn chapter07.csrf.app:app

这将会在8000端口上运行 FastAPI 应用程序。现在,运行以下命令:


(venv) $ python -m http.server --directory chapter07/csrf 9000

前端应用程序现在可以在http://localhost:9000访问。打开它在浏览器中,你应该看到一个类似于以下界面的界面:

图 7.8 – 尝试 CSRF 保护 API 的简单应用

图 7.8 – 尝试 CSRF 保护 API 的简单应用

在这里,我们添加了表单来与 API 端点交互:注册、登录获取认证用户,以及更新端点。如果你尝试这些,它们应该没有问题。如果你查看发送的请求,可以看到x-csrftoken中包含了 CSRF 令牌。

在顶部,有一个开关可以防止应用程序在头部发送 CSRF 令牌。如果你禁用它,你会看到所有的POST操作都会导致错误。

太好了!我们现在已经防止了 CSRF 攻击!这里的大部分工作是由中间件完成的,但理解它是如何在后台工作的,以及它如何保护你的应用程序,是很有意思的。然而,请记住,它有一个缺点:它会破坏交互式文档。实际上,它并没有设计成从 cookie 中检索 CSRF 令牌并将其放入每个请求的头部。除非你计划以其他方式进行认证(例如通过头部中的令牌),否则你将无法在文档中直接调用你的端点。

总结

本章内容就到这里,主要介绍了 FastAPI 中的认证和安全性。我们看到,借助 FastAPI 提供的工具,实现一个基本的认证系统是相当简单的。我们展示了一种实现方法,但还有许多其他不错的模式可以用来解决这个问题。然而,在处理这些问题时,始终要牢记安全性,并确保不会将应用程序和用户数据暴露于危险的威胁之中。特别地,我们已经看到,在设计将在浏览器应用中使用的 REST API 时,必须考虑防止 CSRF 攻击。理解 Web 应用程序中所有安全风险的一个好资源是 OWASP Cheat Sheet 系列:cheatsheetseries.owasp.org

至此,我们已经涵盖了关于 FastAPI 应用开发的大部分重要主题。在下一章中,我们将学习如何使用与 FastAPI 集成的最新技术——WebSockets,它允许客户端和服务器之间进行实时、双向通信。

第八章:在 FastAPI 中定义 WebSocket 以实现双向交互通信

HTTP 是一种简单而强大的技术,用于从服务器发送数据以及接收数据。如我们所见,请求和响应的原理是该协议的核心:在开发 API 时,我们的目标是处理传入的请求,并为客户端构建响应。因此,为了从服务器获取数据,客户端总是需要先发起请求。然而,在某些情况下,这可能不是很方便。想象一下一个典型的聊天应用程序:当用户收到新消息时,我们希望他们能够立即通过服务器收到通知。如果仅使用 HTTP,我们必须每秒发出请求,检查是否有新消息到达,这会浪费大量资源。这就是为什么一种新协议应运而生:WebSocket。该协议的目标是打开一个客户端和服务器之间的通信通道,以便它们可以实时地双向交换数据。

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

  • 理解 WebSocket 双向通信的原理

  • 使用 FastAPI 创建 WebSocket

  • 处理多个 WebSocket 连接和广播消息

技术要求

本章你需要一个 Python 虚拟环境,正如我们在第一章中所设置的那样,Python 开发 环境配置

处理多个 WebSocket 连接和广播消息这一部分,你需要在本地计算机上运行一个 Redis 服务器。最简单的方法是将其作为 Docker 容器运行。如果你之前从未使用过 Docker,建议你阅读官方文档中的入门教程,链接为docs.docker.com/get-started/。完成后,你就可以通过以下简单命令启动一个 Redis 服务器:


$ docker run -d --name fastapi-redis -p 6379:6379 redis

你可以在专门的 GitHub 仓库中找到本章的所有代码示例,链接地址为github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter08

理解 WebSocket 双向通信的原理

你可能已经注意到,WebSocket 这个名字直接指代的是 Unix 系统中传统的套接字概念。虽然从技术上看它们没有直接关系,但它们达成了同样的目标:为两个应用程序之间开启一个通信通道。正如我们在介绍中所说,HTTP 仅基于请求-响应原理,这使得需要客户端与服务器之间实时通信的应用程序的实现既困难又低效。

WebSocket 试图通过打开一个全双工通信通道来解决这个问题,这意味着消息可以在两个方向上同时发送。通道一旦打开,服务器就可以向客户端发送消息,而无需等待客户端的请求。

即使 HTTP 和 WebSocket 是不同的协议,WebSocket 的设计仍然是为了与 HTTP 配合使用。实际上,在打开 WebSocket 时,连接首先通过 HTTP 请求发起,然后升级为 WebSocket 通道。这使得它能够直接兼容传统的端口80443,这非常方便,因为我们可以轻松地将这个功能添加到现有的 Web 服务器中,而无需额外的进程。

WebSocket 与 HTTP 还有另一个相似之处:URI。与 HTTP 一样,WebSocket 通过经典的 URI 进行标识,包括主机、路径和查询参数。此外,我们还有两种方案:ws(WebSocket)用于不安全的连接,wss(WebSocket Secure)用于 SSL/TLS 加密连接。

最后,这个协议现在在浏览器中得到了很好的支持,打开与服务器的连接只需要几行 JavaScript 代码,正如我们将在本章中看到的那样。

然而,处理这个双向通信通道与处理传统的 HTTP 请求是完全不同的。由于事情是实时发生的,并且是双向的,我们将看到,我们必须以不同于常规的方式进行思考。在 FastAPI 中,WebSocket 的异步特性将大大帮助我们在这方面找到方向。

使用 FastAPI 创建 WebSocket

多亏了 Starlette,FastAPI 内置了对 WebSocket 的支持。正如我们将看到的,定义 WebSocket 端点非常快速且简单,我们可以在几分钟内开始使用。不过,随着我们尝试为端点逻辑添加更多功能,事情会变得更加复杂。我们从简单的开始,创建一个等待消息并将其简单回显的 WebSocket。

在下面的示例中,你将看到这样一个简单案例的实现:

app.py


from fastapi import FastAPI, WebSocketfrom starlette.websockets import WebSocketDisconnect
app = FastAPI()
@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
    await websocket.accept()
    try:
        while True:
            data = await websocket.receive_text()
            await websocket.send_text(f"Message text was: {data}")
    except WebSocketDisconnect:
        pass

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/blob/main/chapter08/echo/app.py

这段代码本身非常易于理解,但让我们关注一些与经典 HTTP 端点不同的重要部分。

首先,你会看到 FastAPI 提供了一个特殊的websocket装饰器来创建 WebSocket 端点。与常规端点一样,它需要作为参数提供其可用的路径。然而,其他在此上下文中没有意义的参数,例如状态码或响应模型,是不可用的。

然后,在路径操作函数中,我们可以注入一个WebSocket对象,它将提供所有与 WebSocket 交互的方法,正如我们将看到的那样。

我们在实现中调用的第一个方法是accept。这个方法应该首先被调用,因为它告诉客户端我们同意打开隧道。

之后,你可以看到我们启动了一个无限循环。这是与 HTTP 端点的主要区别:因为我们正在打开一个通信通道,它将保持打开状态,直到客户端或服务器决定关闭它。在通道打开期间,它们可以交换尽可能多的消息;因此,无限循环的作用是保持通道开放并重复逻辑,直到隧道关闭。

在循环内部,我们首先调用receive_text方法。正如你可能猜到的,它会返回客户端发送的纯文本数据。这里需要理解的是,该方法会阻塞,直到从客户端接收到数据。在这个事件发生之前,我们不会继续执行剩余的逻辑。

我们可以在这里看到异步输入/输出的重要性,正如我们在第二章《Python 编程特性》中展示的那样。通过创建一个无限循环来等待传入的数据,如果采用传统的阻塞模式,整个服务器进程可能会被阻塞。在这里,得益于事件循环,进程能够在等待当前数据时,响应其他客户端的请求。

当接收到数据时,方法会返回文本数据,我们可以继续执行下一行代码。这里,我们只是通过send_text方法将消息返回给客户端。一旦完成,我们将回到循环的开始,等待另一个消息。

你可能注意到,整个循环被包裹在一个 try...except 语句中。这是为了处理客户端断开连接。实际上,大多数时候,我们的服务器会在receive_text那一行被阻塞,等待客户端数据。如果客户端决定断开连接,隧道将被关闭,receive_text 调用将失败,并抛出 WebSocketDisconnect 异常。因此,捕捉该异常非常重要,以便能够跳出循环并正确结束函数。

来试试吧!你可以像往常一样通过 Uvicorn 服务器运行 FastAPI 应用程序。你需要的命令如下:


(venv) $ uvicorn chapter08.echo.app:app

我们的客户端将是一个简单的 HTML 页面,包含一些与 WebSocket 交互的 JavaScript 代码。演示之后,我们将快速介绍这段代码。要运行它,我们可以像下面这样通过内置的 Python 服务器提供服务:


(venv) $ python -m http.server --directory chapter08/echo 9000

启动多个终端

在 Linux 和 macOS 上,你应该能够通过创建一个新窗口或标签页来简单地启动一个新的终端。在 Windows 和 WSL 上,如果你使用的是 Windows 终端应用程序,你也可以有多个标签页:apps.microsoft.com/store/detail/windows-terminal/9N0DX20HK701

否则,你也可以简单地点击 开始 菜单中的 Ubuntu 快捷方式来启动另一个终端。

这将在你本地机器的 9000 端口上提供我们的 HTML 页面。如果你打开 http://localhost:9000 地址,你将看到一个像这里展示的简单界面:

图 8.1 – 尝试 WebSocket 的简单应用

图 8.1 – 尝试 WebSocket 的简单应用

你有一个简单的输入表单,允许你通过 WebSocket 向服务器发送消息。它们会以绿色显示在列表中,如截图所示。服务器会回显你的消息,这些消息会以黄色显示在列表中。

你可以通过打开浏览器开发者工具中的网络标签页,查看背后发生了什么。重新加载页面以强制 WebSocket 重新连接。此时你应该能看到 WebSocket 连接的行。如果点击该行,你会看到一个消息标签页,在那里可以查看通过 WebSocket 传输的所有消息。你可以在图 8.2中看到这个界面。

图 8.2 – 浏览器开发者工具中的 WebSocket 消息可视化

图 8.2 – 浏览器开发者工具中的 WebSocket 消息可视化

在下面的示例中,你将看到用于打开 WebSocket 连接并发送和接收消息的 JavaScript 代码:

script.js


  const socket = new WebSocket('ws://localhost:8000/ws');  // Connection opened
  socket.addEventListener('open', function (event) {
    // Send message on form submission
    document.getElementById('form').addEventListener('submit', (event) => {
      event.preventDefault();
      const message = document.getElementById('message').value;
      addMessage(message, 'client');
      socket.send(message);
      event.target.reset();
    });
  });
  // Listen for messages
  socket.addEventListener('message', function (event) {
    addMessage(event.data, 'server');
  });

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/blob/main/chapter08/echo/script.js

如你所见,现代浏览器提供了一个非常简单的 API 来与 WebSocket 进行交互。你只需使用你端点的 URL 实例化一个新的 WebSocket 对象,并为一些事件添加监听器:当连接就绪时监听 open,当从服务器接收到数据时监听 message。最后,send 方法允许你向服务器发送数据。你可以在 MDN 文档中查看 WebSocket API 的更多细节:

developer.mozilla.org/en-US/docs/Web/API/WebSockets_API

处理并发

在前面的示例中,我们假设客户端总是先发送消息:我们在发送回消息之前会等待客户端的消息。再次强调,是客户端在对话中采取主动。

然而,在通常的场景中,服务器可以在不主动的情况下向客户端发送数据。在聊天应用中,另一位用户通常会发送一个或多个消息,我们希望立即转发给第一个用户。在这种情况下,我们在前一个示例中展示的 receive_text 阻塞调用就是一个问题:在我们等待时,服务器可能已经有消息需要转发给客户端。

为了解决这个问题,我们将依赖asyncio模块中的更高级工具。事实上,它提供了允许我们并发调度多个协程并等待其中一个完成的函数。在我们的上下文中,我们可以有一个协程等待客户端消息,另一个协程在消息到达时将数据发送给它。第一个完成的协程会“胜出”,我们可以再次开始另一个循环迭代。

为了让这个更清晰,我们来构建另一个示例,在这个示例中,服务器将再次回显客户端的消息。除此之外,它还将定期向客户端发送当前时间。你可以在下面的代码片段中看到实现:

app.py


async def echo_message(websocket: WebSocket):    data = await websocket.receive_text()
    await websocket.send_text(f"Message text was: {data}")
async def send_time(websocket: WebSocket):
    await asyncio.sleep(10)
    await websocket.send_text(f"It is: {datetime.utcnow().isoformat()}")
@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
    await websocket.accept()
    try:
        while True:
            echo_message_task = asyncio.create_task(echo_message(websocket))
            send_time_task = asyncio.create_task(send_time(websocket))
            done, pending = await asyncio.wait(
                {echo_message_task, send_time_task},
                return_when=asyncio.FIRST_COMPLETED,
            )
            for task in pending:
                task.cancel()
            for task in done:
                task.result()
    except WebSocketDisconnect:
        await websocket.close()

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/blob/main/chapter08/concurrency/app.py

如你所见,我们定义了两个协程:第一个,echo_message,等待客户端的文本消息并将其发送回去;第二个,send_time,等待 10 秒钟后将当前时间发送给客户端。它们都期望在参数中传入一个 WebSocket 实例。

最有趣的部分出现在无限循环下面:如你所见,我们调用了我们的两个函数,并通过asynciocreate_task函数将它们包装起来。这将协程转变为task对象。从底层实现来看,任务就是事件循环管理协程执行的方式。简单来说,它使我们完全控制协程的执行——我们可以检索它的结果,甚至取消它。

这些task对象对于使用asyncio.wait是必要的。这个函数对于并发运行任务特别有用。它期望第一个参数是一个要运行的任务集合。默认情况下,这个函数会阻塞,直到所有给定的任务完成。然而,我们可以通过return_when参数来控制这一点:在我们的例子中,我们希望它阻塞直到其中一个任务完成,这对应于FIRST_COMPLETED值。其效果如下:我们的服务器将并发启动协程,第一个将阻塞等待客户端消息,而另一个将阻塞 10 秒。如果客户端在 10 秒内发送消息,它会把消息返回并完成。否则,send_time协程会发送当前时间并完成。

在这一点上,asyncio.wait将返回我们两个集合:第一个,done,包含已完成任务的集合;另一个,pending,包含尚未完成任务的集合。

我们现在想回到循环的起始点重新开始。然而,我们需要先取消所有未完成的任务;否则,它们会在每次迭代时堆积,因此我们需要迭代pending集合并取消那些任务。

最后,我们还对done任务进行了迭代,并调用了它们的result方法。此方法返回协程的结果,同时还会重新引发可能在内部抛出的异常。这对于再次处理客户端断开连接尤其有用:当等待客户端数据时,如果隧道关闭,将引发异常。因此,我们的try...except语句可以捕获它,以便正确地终止函数。

如果你像我们之前一样尝试这个示例,你会看到服务器会定期向你发送当前时间,同时也能回显你发送的消息。

这个send_time示例展示了如何实现一个过程,在服务器上发生事件时将数据发送给客户端:例如数据库中有新数据,外部进程完成了长时间的计算等等。在下一部分,我们将看到如何正确处理多个客户端向服务器发送消息的情况,然后服务器将其广播到所有客户端。

这基本上就是你如何使用asyncio的工具来处理并发。到目前为止,每个人都可以毫无任何限制地连接到这些 WebSocket 端点。当然,像经典的 HTTP 端点一样,你可能需要在打开连接之前进行用户认证。

使用依赖项

就像常规端点一样,你可以在 WebSocket 端点中使用依赖项。它们的工作方式基本相同,因为 FastAPI 能够根据 WebSocket 上下文调整其行为。

唯一的缺点是无法使用安全依赖项,正如我们在第七章中展示的那样,在 FastAPI 中管理认证和安全性。事实上,在底层,大多数安全依赖项是通过注入Request对象来工作的,而该对象仅适用于 HTTP 请求(我们看到 WebSocket 是通过WebSocket对象注入的)。在 WebSocket 上下文中尝试注入这些依赖项会导致错误。

然而,像QueryHeaderCookie这样的基本依赖项可以透明地工作。让我们在下一个示例中尝试它们。在这个例子中,我们将注入两个依赖项,如下所示:

  • 一个username查询参数,我们将用它来在连接时向用户打招呼。

  • 一个token Cookie,我们将与静态值进行比较,以保持示例的简洁性。当然,一个合适的策略是进行适当的用户查找,正如我们在第七章中实现的那样,在 FastAPI 中管理认证和安全性。如果该 Cookie 没有所需的值,我们将抛出一个错误。

让我们看看下面示例中的实现:

app.py


@app.websocket("/ws")async def websocket_endpoint(
    websocket: WebSocket, username: str = "Anonymous", token: str = Cookie(...)
):
    if token != API_TOKEN:
        raise WebSocketException(status.WS_1008_POLICY_VIOLATION)
    await websocket.accept()
    await websocket.send_text(f"Hello, {username}!")
    try:
        while True:
            data = await websocket.receive_text()
            await websocket.send_text(f"Message text was: {data}")
    except WebSocketDisconnect:
        pass

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/blob/main/chapter08/dependencies/app.py

如你所见,注入依赖项与标准的 HTTP 端点没有区别。

然后,我们可以有我们的虚拟身份验证逻辑。如果验证失败,我们可以抛出一个 WebSocketException。它是 WebSocket 版本的 HTTPException,我们在前面的章节中见过。在后台,FastAPI 会通过关闭 WebSocket 并使用指定的状态码来处理这个异常。WebSocket 有自己的一套状态码。你可以在这个 MDN 文档页面查看完整的列表:developer.mozilla.org/fr/docs/Web/API/CloseEvent。出现错误时最常见的状态码是 1008

如果验证通过,我们可以启动经典的回声服务器。请注意,我们可以在逻辑中随意使用 username 值。在这里,我们在连接时发送第一条消息来问候用户。如果你在 HTML 应用程序中尝试这一操作,你将首先看到此消息,如下图所示:

图 8.3 – 连接时的问候消息

图 8.3 – 连接时的问候消息

使用浏览器的 WebSocket API,可以将查询参数传递到 URL 中,浏览器会自动转发 cookies。然而,无法传递自定义头部。这意味着,如果你依赖头部进行身份验证,你将不得不通过 cookies 添加一个头部,或者在 WebSocket 逻辑中实现一个身份验证消息机制。然而,如果你不打算将 WebSocket 与浏览器一起使用,你仍然可以依赖头部,因为大多数 WebSocket 客户端都支持它们。

现在,你已经对如何将 WebSocket 添加到 FastAPI 应用程序中有了一个很好的概览。如我们所说,它们在涉及多个用户并需要实时广播消息的情况下非常有用。接下来的章节中,我们将看到如何可靠地实现这一模式。

处理多个 WebSocket 连接并广播消息

正如我们在本章介绍中所说,WebSocket 的一个典型用例是实现多个客户端之间的实时通信,例如聊天应用程序。在这种配置中,多个客户端与服务器保持一个开放的 WebSocket 通道。因此,服务器的作用是管理所有客户端连接并广播消息到所有客户端:当一个用户发送消息时,服务器必须将该消息发送到所有其他客户端的 WebSocket 中。我们在这里展示了这一原理的示意图:

图 8.4 – 通过 WebSocket 连接多个客户端到服务器

图 8.4 – 通过 WebSocket 连接多个客户端到服务器

一种初步的做法可能是简单地保持所有 WebSocket 连接的列表,并通过它们遍历来广播消息。这是可行的,但在生产环境中会迅速变得问题重重。实际上,大多数情况下,服务器进程在部署时会运行多个工作进程。这意味着我们不仅仅有一个进程来处理请求,我们可以有多个进程来并发地响应更多的请求。我们也可以考虑将其部署在多个数据中心的多个服务器上。

因此,不能保证两个客户端打开 WebSocket 时会由同一个进程提供服务。在这种配置下,我们的简单方法将会失败:由于连接保存在进程内存中,接收消息的进程将无法将消息广播给由其他进程提供服务的客户端。我们在下面的图表中示意了这个问题:

图 8.5 – 没有消息代理的多个服务器工作进程

图 8.5 – 没有消息代理的多个服务器工作进程

为了解决这个问题,我们通常依赖于消息代理。消息代理是一个软件组件,其作用是接收由第一个程序发布的消息,并将其广播给订阅该消息的程序。通常,这种发布-订阅pub-sub)模式被组织成不同的频道,以便根据主题或用途清晰地组织消息。一些最著名的消息代理软件包括 Apache Kafka、RabbitMQ,以及来自亚马逊网络服务AWS)、谷歌云平台GCP)和微软 Azure 的云实现:分别是 Amazon MQ、Cloud Pub/Sub 和 Service Bus。

因此,我们的消息代理将在我们的架构中是唯一的,多个服务器进程将连接到它,进行消息的发布或订阅。这个架构在下面的图表中进行了示意:

图 8.6 – 带有消息代理的多个服务器工作进程

图 8.6 – 带有消息代理的多个服务器工作进程

在本章中,我们将看到如何使用来自 Encode(Starlette 的创建者)和 Redisbroadcaster 库来搭建一个简单的系统,Redis 将充当消息代理。

关于 Redis

本质上,Redis 是一个旨在实现最大性能的数据存储。它在行业中广泛用于存储我们希望快速访问的临时数据,比如缓存或分布式锁。它还支持基本的发布/订阅(pub/sub)范式,使其成为作为消息代理使用的良好候选者。你可以在其官方网站了解更多信息:redis.io

首先,让我们通过以下命令安装这个库:


(venv) $ pip install "broadcaster[redis]"

这个库将为我们抽象掉发布和订阅 Redis 的所有复杂性。

让我们来看看实现的细节。在下面的示例中,你将看到 Broadcaster 对象的实例化:

app.py


broadcast = Broadcast("redis://localhost:6379")CHANNEL = "CHAT"

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/blob/main/chapter08/broadcast/app.py

如你所见,它只需要一个 Redis 服务器的 URL。还要注意,我们定义了一个 CHANNEL 常量,这将是发布和订阅消息的频道名称。我们在这里选择了一个静态值作为示例,但在实际应用中,你可以使用动态的频道名称——例如支持多个聊天室。

然后,我们定义了两个函数:一个用于订阅新消息并将其发送给客户端,另一个用于发布在 WebSocket 中接收到的消息。你可以在以下示例中看到这些函数:

app.py


class MessageEvent(BaseModel):    username: str
    message: str
async def receive_message(websocket: WebSocket, username: str):
    async with broadcast.subscribe(channel=CHANNEL) as subscriber:
        async for event in subscriber:
            message_event = MessageEvent.parse_raw(event.message)
            # Discard user's own messages
            if message_event.username != username:
                await websocket.send_json(message_event.dict())
async def send_message(websocket: WebSocket, username: str):
    data = await websocket.receive_text()
    event = MessageEvent(username=username, message=data)
    await broadcast.publish(channel=CHANNEL, message=event.json())

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/blob/main/chapter08/broadcast/app.py

首先,请注意,我们定义了一个 Pydantic 模型 MessageEvent,以帮助我们构建消息中包含的数据。我们不再像之前那样只是传递原始字符串,而是有一个对象,其中包含消息和用户名。

第一个函数 receive_message 订阅广播频道并等待名为 event 的消息。消息数据包含已序列化的 JSON,我们将其反序列化以实例化一个 MessageEvent 对象。请注意,我们使用了 Pydantic 模型的 parse_raw 方法,这使得我们可以通过一次操作将 JSON 字符串解析为对象。

然后,我们检查消息中的用户名是否与当前用户名不同。事实上,由于所有用户都订阅了该频道,他们也会收到自己发送的消息。这就是为什么我们基于用户名丢弃这些消息以避免这种情况。当然,在实际应用中,你可能更希望依赖一个唯一的用户 ID,而不是简单的用户名。

最后,我们可以通过 send_json 方法通过 WebSocket 发送消息,该方法会自动处理字典的序列化。

第二个函数 send_message 用于将消息发布到消息代理。简单来说,它等待套接字中的新数据,将其结构化为 MessageEvent 对象,然后发布该消息。

这就是 broadcaster 部分的全部内容。接下来是 WebSocket 的实现,实际上与我们在之前的章节中看到的非常相似。你可以在以下示例中看到它:

app.py


@app.websocket("/ws")async def websocket_endpoint(websocket: WebSocket, username: str = "Anonymous"):
    await websocket.accept()
    try:
        while True:
            receive_message_task = asyncio.create_task(
                receive_message(websocket, username)
            )
            send_message_task = asyncio.create_task(send_message(websocket, username))
            done, pending = await asyncio.wait(
                {receive_message_task, send_message_task},
                return_when=asyncio.FIRST_COMPLETED,
            )
            for task in pending:
                task.cancel()
            for task in done:
                task.result()
    except WebSocketDisconnect:
        pass

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/blob/main/chapter08/broadcast/app.py

最后,我们需要告诉 FastAPI 在启动应用程序时打开与中介的连接,并在退出时关闭它,如下所示:

app.py


@contextlib.asynccontextmanagerasync def lifespan(app: FastAPI):
    await broadcast.connect()
    yield
    await broadcast.disconnect()
app = FastAPI(lifespan=lifespan)

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/blob/main/chapter08/broadcast/app.py

现在让我们尝试一下这个应用程序!首先,我们将运行 Uvicorn 服务器。启动之前,请确保你的 Redis 容器正在运行,正如我们在技术要求部分中所解释的那样。以下是你需要的命令:


(venv) $ uvicorn chapter08.broadcast.app:app

我们还在示例中提供了一个简单的 HTML 客户端。要运行它,我们可以通过内置的 Python 服务器提供服务,方法如下:


(venv) $ python -m http.server --directory chapter08/broadcast 9000

现在你可以通过http://localhost:9000访问它。如果你在浏览器中分别打开两个窗口,你可以看到广播是否正常工作。在第一个窗口中输入一个用户名并点击连接。在第二个窗口中做相同的操作,使用不同的用户名。你现在可以发送消息,并看到它们被广播到另一个客户端,如下图所示:

图 8.7 – 多个 WebSocket 客户端广播消息

图 8.7 – 多个 WebSocket 客户端广播消息

这只是一个非常简要的概述,介绍了如何实现涉及消息中介的广播系统。当然,我们这里只覆盖了基础内容,使用这些强大技术可以做更多复杂的事情。再次强调,FastAPI 为我们提供了强大的构建模块,而不会将我们锁定在特定的技术或模式中:我们可以轻松地引入新的库来扩展我们的可能性。

概述

在本章中,你学习了如何使用最新的 Web 技术之一:WebSocket。你现在能够在客户端和服务器之间打开一个双向通信通道,从而实现具有实时约束的应用程序。如你所见,FastAPI 让我们非常容易地添加这样的端点。尽管如此,WebSocket 的思维方式与传统的 HTTP 端点完全不同:管理无限循环并同时处理多个任务是全新的挑战。幸运的是,框架的异步特性让我们在这方面的工作更加轻松,帮助我们编写易于理解的并发代码。

最后,我们还简要概述了处理多个客户端共享消息时需要解决的挑战。你已经看到,像 Redis 这样的消息中介软件是使此用例在多个服务器进程间可靠工作的必要条件。

你现在已经了解了 FastAPI 的所有特性。到目前为止,我们展示了专注于某一特定点的非常简单的示例。然而,在现实世界中,你很可能会开发出能够做很多事情的大型应用程序,并且随着时间的推移,它们会不断增长。为了使这些应用程序可靠、可维护,并保持高质量的代码,进行测试是必要的,这样可以确保它们按预期运行,并且在添加新功能时不会引入漏洞。

在下一章,你将看到如何为 FastAPI 设置一个高效的测试环境。

第九章:使用 pytest 和 HTTPX 异步测试 API

在软件开发中,开发者的工作中很大一部分应当专注于编写测试。一开始,你可能会倾向于通过手动运行应用程序,发起一些请求,并随意决定“所有功能都正常”,来测试你的应用程序。然而,这种做法是有缺陷的,无法保证程序在各种情况下都能正常工作,并且无法确保你在开发过程中没有引入新问题。

正因如此,软件测试领域出现了多个分支:单元测试、集成测试、端到端测试、验收测试等。这些技术旨在从微观层面验证软件的功能,在单元测试中我们验证单个函数的正确性;而在宏观层面,我们验证能够为用户提供价值的整体功能(如验收测试)。在本章中,我们将聚焦于第一个层次:单元测试。

单元测试是用于验证我们编写的代码在每种情况下都能按预期行为运行的简短程序。你可能会认为编写测试非常耗时,并且它们对软件没有附加价值,但从长远来看,它将节省你的时间:首先,测试可以自动在几秒钟内运行,确保你的所有软件功能正常,而不需要你手动逐个检查每个功能。其次,当你引入新功能或重构代码时,确保不会向现有功能中引入错误。总之,测试和程序本身一样重要,它们帮助你交付可靠且高质量的软件。

在本章中,你将学习如何为你的 FastAPI 应用程序编写测试,包括 HTTP 端点和 WebSockets。为此,你将学习如何配置 pytest,一个知名的 Python 测试框架,以及 HTTPX,一个用于 Python 的异步 HTTP 客户端。

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

  • 使用 pytest 进行单元测试简介

  • 为 FastAPI 设置 HTTPX 测试工具

  • 为 REST API 端点编写测试

  • 为 WebSocket 端点编写测试

技术要求

对于本章内容,你需要一个 Python 虚拟环境,正如我们在第一章中设置的那样,Python 开发环境设置

对于使用 Motor 与 MongoDB 数据库通信部分,你需要在本地计算机上运行一个 MongoDB 服务器。最简单的方式是通过 Docker 容器来运行。如果你从未使用过 Docker,建议参考官方文档中的入门教程,网址为docs.docker.com/get-started/。完成这一步后,你可以使用以下简单命令来运行 MongoDB 服务器:


$ docker run -d --name fastapi-mongo -p 27017:27017 mongo:6.0

MongoDB 服务器实例将通过本地计算机上的端口27017提供服务。

你可以在本章节专用的 GitHub 仓库中找到所有代码示例,地址为 github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter09

使用 pytest 进行单元测试简介

正如我们在介绍中提到的,编写单元测试是软件开发中的一项重要任务,旨在交付高质量的软件。为了提高我们的工作效率,许多库提供了专门用于测试的工具和快捷方式。在 Python 标准库中,有一个用于单元测试的模块,叫做unittest。尽管它在 Python 代码库中非常常见,但许多 Python 开发者倾向于使用 pytest,因为它提供了更轻量的语法和强大的高级工具。

在接下来的示例中,我们将为一个名为add的函数编写单元测试,分别使用unittest和 pytest,以便你能比较它们在基本用例中的表现。首先,我们来安装 pytest:


(venv) $ pip install pytest

现在,让我们来看一下我们的简单add函数,它只是执行加法操作:

chapter09_introduction.py


def add(a: int, b: int) -> int:    return a + b

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter09/chapter09_introduction.py

现在,让我们用unittest实现一个测试,检查2 + 3是否确实等于5

chapter09_introduction_unittest.py


import unittestfrom chapter09.chapter09_introduction import add
class TestChapter09Introduction(unittest.TestCase):
    def test_add(self):
        self.assertEqual(add(2, 3), 5)

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter09/chapter09_introduction_unittest.py

正如你所看到的,unittest要求我们定义一个继承自TestCase的类。然后,每个测试都在自己的方法中。要断言两个值是否相等,我们必须使用assertEqual方法。

要运行这个测试,我们可以从命令行调用unittest模块,并通过点路径传递给我们的测试模块:


(venv) $ python -m unittest chapter09.chapter09_introduction_unittest.
----------------------------------------------------------------------
Ran 1 test in 0.000s
OK

在输出中,每个成功的测试由一个点表示。如果一个或多个测试没有成功,你将收到每个测试的详细错误报告,突出显示失败的断言。你可以通过更改测试中的断言来尝试修复问题。

现在,让我们用 pytest 来编写相同的测试:

chapter09_introduction_pytest.py


from chapter09.chapter09_introduction import adddef test_add():
    assert add(2, 3) == 5

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter09/chapter09_introduction_pytest.py

正如你所看到的,这样简短多了!事实上,在 pytest 中,你不一定需要定义一个类:一个简单的函数就足够了。唯一的约束是函数名称必须以 test_ 开头。这样,pytest 就可以自动发现测试函数。其次,它依赖于内置的 assert 语句,而不是特定的方法,这让你能够更自然地编写比较。

要运行这个测试,我们只需调用 pytest 可执行文件并指定测试文件的路径:


(venv) $ pytest chapter09/chapter09_introduction_pytest.py=============== test session starts ===============
platform darwin -- Python 3.10.8, pytest-7.2.0, pluggy-1.0.0
rootdir: /Users/fvoron/Development/Building-Data-Science-Applications-with-FastAPI-Second-Edition, configfile: pyproject.toml
plugins: asyncio-0.20.2, cov-4.0.0, anyio-3.6.2
asyncio: mode=strict
collected 1 item
chapter09/chapter09_introduction_pytest.py .                      [100%]
================ 1 passed in 0.01s ===============

再次强调,输出使用一个点表示每个成功的测试。当然,如果你修改了测试使其失败,你将获得失败断言的详细错误信息。

值得注意的是,如果你在没有任何参数的情况下运行 pytest,它将自动发现你项目中所有的测试,只要它们的名称以 test_ 开头。

在这里,我们对 unittest 和 pytest 做了一个小的比较。在本章的剩余部分,我们将继续使用 pytest,它应该能为你提供更高效的测试体验。

在专注于 FastAPI 测试之前,让我们复习一下 pytest 的两个最强大的功能:parametrize 和 fixtures。

使用 parametrize 生成测试

在我们之前的例子中,使用 add 函数时,我们只测试了一个加法测试,2 + 3。大多数时候,我们会希望检查更多的情况,以确保我们的函数在各种情况下都能正常工作。我们第一步的做法可能是向测试中添加更多断言,例如:


def test_add():    assert add(2, 3) == 5
    assert add(0, 0) == 0
    assert add(100, 0) == 100
    assert add(1, 1) == 2

在实际工作中,这种方法有两个缺点:首先,写下多次相同的断言可能有些繁琐,尤其是只有一些参数发生变化的情况下。在这个例子中不算太糟,但测试可能会变得更复杂,正如我们在 FastAPI 中将看到的那样。其次,我们仍然只有一个测试:第一个失败的断言会停止测试,之后的断言将不会被执行。因此,只有在我们先修复失败的断言并重新运行测试后,我们才会知道结果。

为了帮助完成这一特定任务,pytest 提供了 parametrize 标记。在 pytest 中,标记 是一种特殊的装饰器,用来轻松地将元数据传递给测试。然后,根据测试所使用的标记,可以实现特殊的行为。

在这里,parametrize 允许我们定义多个变量集,这些变量将作为参数传递给测试函数。在运行时,每个变量集都会生成一个新的独立测试。为了更好地理解这一点,我们来看看如何使用这个标记为我们的 add 函数生成多个测试:

chapter09_introduction_pytest_parametrize.py


import pytestfrom chapter09.chapter09_introduction import add
@pytest.mark.parametrize("a,b,result", [(2, 3, 5), (0, 0, 0), (100, 0, 100), (1, 1, 2)])
def test_add(a, b, result):
    assert add(a, b) == result

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter09/chapter09_introduction_pytest_parametrize.py

在这里,您可以看到我们只是简单地用parametrize标记装饰了我们的测试函数。基本用法如下:第一个参数是一个字符串,其中包含每个参数的名称,用逗号分隔。然后,第二个参数是一个元组列表。每个元组按顺序包含参数的值。

我们的测试函数以参数的形式接收这些参数,每个参数的名称与您之前指定的方式相同。因此,您可以在测试逻辑中随意使用它们。正如您所见,这里的巨大好处在于我们只需要一次编写assert语句。此外,添加新的测试用例非常快速:我们只需在parametrize标记中添加另一个元组。

现在,让我们运行这个测试,看看使用以下命令会发生什么:


(venv) $ pytest chapter09/chapter09_introduction_pytest_parametrize.py================ test session starts ================
platform darwin -- Python 3.10.8, pytest-7.2.0, pluggy-1.0.0
rootdir: /Users/fvoron/Development/Building-Data-Science-Applications-with-FastAPI-Second-Edition, configfile: pyproject.toml
plugins: asyncio-0.20.2, cov-4.0.0, anyio-3.6.2
asyncio: mode=strict
collected 4 items
chapter09/chapter09_introduction_pytest_parametrize.py ....   [100%]
================ 4 passed in 0.01s ================

正如你所见,pytest 执行了四个测试而不是一个!这意味着它生成了四个独立的测试,以及它们自己的参数集。如果有几个测试失败,我们将得到通知,并且输出将告诉我们哪组参数导致了错误。

总结一下,parametrize 是一种非常便捷的方式,当给定不同的参数集时,测试不同的结果。

在编写单元测试时,通常需要在测试中多次使用变量和对象,比如应用程序实例、虚假数据等等。为了避免在测试中反复重复相同的事物,pytest 提出了一个有趣的特性:夹具。

通过创建夹具来重用测试逻辑

在测试大型应用程序时,测试往往会变得非常重复:在实际断言之前,许多测试将共享相同的样板代码。考虑以下代表人物及其邮政地址的 Pydantic 模型:

chapter09_introduction_fixtures.py


from datetime import datefrom enum import Enum
from pydantic import BaseModel
class Gender(str, Enum):
    MALE = "MALE"
    FEMALE = "FEMALE"
    NON_BINARY = "NON_BINARY"
class Address(BaseModel):
    street_address: str
    postal_code: str
    city: str
    country: str
class Person(BaseModel):
    first_name: str
    last_name: str
    gender: Gender
    birthdate: date
    interests: list[str]
    address: Address

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter09/chapter09_introduction_fixtures.py

这个例子可能看起来很熟悉:它来自第四章在 FastAPI 中管理 Pydantic 数据模型。现在,假设我们希望使用这些模型的一些实例编写测试。显然,在每个测试中实例化它们并填充虚假数据会有点烦人。

幸运的是,夹具使我们能够一劳永逸地编写它们。以下示例展示了如何使用它们:

chapter09_introduction_fixtures_test.py


import pytestfrom chapter09.chapter09_introduction_fixtures import Address, Gender, Person
@pytest.fixture
def address():
    return Address(
        street_address="12 Squirell Street",
        postal_code="424242",
        city="Woodtown",
        country="US",
    )
@pytest.fixture
def person(address):
    return Person(
        first_name="John",
        last_name="Doe",
        gender=Gender.MALE,
        birthdate="1991-01-01",
        interests=["travel", "sports"],
        address=address,
    )
def test_address_country(address):
    assert address.country == "US"
def test_person_first_name(person):
    assert person.first_name == "John"
def test_person_address_city(person):
    assert person.address.city == "Woodtown"

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter09/chapter09_introduction_fixtures_test.py

再次感谢 pytest 的简便性:夹具是简单的函数,并且使用夹具装饰器进行装饰。在这些函数内部,你可以编写任何逻辑,并返回你在测试中需要的对象。在address中,我们用虚拟数据实例化了一个Address对象并返回它。

现在,我们如何使用这个夹具呢?如果你查看test_address_country测试,你会看到一些魔法:通过在测试函数中设置address参数,pytest 会自动检测到它对应于address夹具,执行它并传递其返回值。在测试中,我们的Address对象已经准备好使用了。pytest 称之为请求 一个夹具

你可能已经注意到,我们还定义了另一个夹具person。再一次,我们用虚拟数据实例化了一个Person模型。然而,值得注意的是,我们实际上请求了address夹具并在其中使用了它!这就是这个系统如此强大的原因:夹具可以依赖于其他夹具,而这些夹具也可以依赖于其他夹具,依此类推。从某种意义上来说,它与我们在第五章中讨论的依赖注入非常相似,FastAPI 中的依赖注入

至此,我们对 pytest 的简短介绍已经结束。当然,还有很多内容可以讨论,但这些内容足以让你开始。如果你想深入了解这个话题,可以阅读官方的 pytest 文档,其中包含大量示例,展示了你如何从其所有功能中受益:docs.pytest.org/en/latest/

现在,让我们把重点放在 FastAPI 上。我们将从设置测试工具开始。

使用 HTTPX 为 FastAPI 设置测试工具

如果你查看 FastAPI 文档中关于测试的部分,你会看到它推荐使用 Starlette 提供的TestClient。在本书中,我们将向你展示一种不同的方法,它涉及一个名为 HTTPX 的 HTTP 客户端。

为什么?默认的TestClient实现方式使其完全同步,这意味着你可以在测试中写代码而不必担心asyncawait。这听起来可能不错,但我们发现它在实际中会引发一些问题:由于你的 FastAPI 应用是设计为异步工作的,因此你很可能会有许多异步工作的服务,比如我们在第六章中讨论的数据库和异步 ORM。因此,在你的测试中,你很可能需要对这些异步服务执行一些操作,比如用虚拟数据填充数据库,这样即使测试本身是异步的。将两种方法混合往往会导致一些难以调试的奇怪错误。

幸运的是,HTTPX 是由与 Starlette 同一团队创建的一个 HTTP 客户端,它使我们能够拥有一个纯异步的 HTTP 客户端,可以向我们的 FastAPI 应用发送请求。为了使这种方法奏效,我们需要三个库:

  • HTTPX,执行 HTTP 请求的客户端

  • asgi-lifespan,一个用于以编程方式管理 FastAPI 应用程序生命周期事件的库

  • pytest-asyncio,一个为 pytest 扩展的库,允许我们编写异步测试

让我们使用以下命令安装这些库:


(venv) $ pip install httpx asgi-lifespan pytest-asyncio

太好了!现在,让我们编写一些夹具,以便我们可以轻松地为 FastAPI 应用程序获取 HTTP 测试客户端。这样,在编写测试时,我们只需请求该夹具,就能立即进行请求。

在以下示例中,我们考虑了一个简单的 FastAPI 应用程序,我们希望对其进行测试:

chapter09_app.py


import contextlibfrom fastapi import FastAPI
@contextlib.asynccontextmanager
async def lifespan(app: FastAPI):
    print("Startup")
    yield
    print("Shutdown")
app = FastAPI(lifespan=lifespan)
@app.get("/")
async def hello_world():
    return {"hello": "world"}

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter09/chapter09_app.py

在一个单独的测试文件中,我们将实现两个夹具(fixtures)。

第一个,event_loop,将确保我们始终使用相同的事件循环实例。它会在执行异步测试之前由pytest-asyncio自动请求。你可以在以下示例中看到它的实现:

chapter09_app_test.py


@pytest.fixture(scope="session")def event_loop():
    loop = asyncio.new_event_loop()
    yield loop
    loop.close()

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter09/chapter09_app_test.py

在这里,你可以看到我们仅仅在生成它之前创建了一个新的事件循环。正如我们在第二章中讨论的那样,Python 编程的特殊性,使用生成器允许我们“暂停”函数的执行并返回到调用者的执行。当调用者完成时,我们可以执行清理操作,比如关闭循环。pytest 足够聪明,能够正确地处理夹具中的这一点,所以这是设置测试数据、使用它并在之后销毁它的一个非常常见的模式。我们在 FastAPI 的生命周期函数中也使用相同的方法。

当然,这个函数被fixture装饰器修饰,使其成为 pytest 的夹具。你可能已经注意到我们设置了一个名为scope的参数,值为session。这个参数控制夹具应该在哪个层级进行实例化。默认情况下,它会在每个单独的测试函数开始时重新创建。session值是最高的层级,意味着该夹具只会在整个测试运行开始时创建一次,这对于我们的事件循环是很重要的。你可以在官方文档中了解更多关于这个更高级功能的信息:docs.pytest.org/en/latest/how-to/fixtures.html#scope-sharing-fixtures-across-classes-modules-packages-or-session

接下来,我们将实现我们的test_client固定装置,它将为我们的 FastAPI 应用程序创建一个 HTTPX 实例。我们还必须记住使用asgi-lifespan触发应用程序事件。您可以在以下示例中看到它的外观:

chapter09_app_test.py


@pytest_asyncio.fixtureasync def test_client():
    async with LifespanManager(app):
        async with httpx.AsyncClient(app=app, base_url="http://app.io") as test_client:
            yield test_client

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter09/chapter09_app_test.py

只需要三行。到目前为止,与我们所见过的固定装置的第一个不同之处在于这是一个异步函数。在这种情况下,请注意,我们使用了@pytest_asyncio.fixture装饰器,而不是@pytest.fixture。这是由pytest-asyncio提供的此装饰器的异步对应项,因此可以正确处理异步固定装置。在以前的版本中,使用标准装饰器曾经有效,但现在不鼓励使用。

接下来,我们有两个上下文管理器:LifespanManagerhttpx.AsyncClient。第一个确保启动和关闭事件被执行,而第二个确保 HTTP 会话已准备就绪。在这两者上,我们设置了app变量:这是我们从其模块chapter09.chapter09_app中导入的 FastAPI 应用程序实例。

请注意,在这里我们再次使用了一个生成器,使用了yield。这很重要,因为即使我们在其后没有更多的代码,我们需要在使用客户端后关闭上下文管理器。如果我们使用return,Python 会立即关闭它们,我们最终会得到一个无法使用的客户端。

在项目中组织测试和全局固定装置

在更大的项目中,您可能会有几个测试文件来组织您的测试。通常,这些文件放置在项目根目录的tests文件夹中。如果您的测试文件以test_前缀,它们将被 pytest 自动发现。图 9**.1显示了一个示例。

此外,您将需要在所有测试中使用我们在本节中定义的固定装置。与其在所有测试文件中一遍又一遍地重复定义它们,pytest 允许您在名为conftest.py的文件中编写全局固定装置。将其放置在您的tests文件夹中后,它将自动被导入,允许您请求您在其中定义的所有固定装置。您可以在官方文档的 https://docs.pytest.org/en/latest/reference/fixtures.html#conftest-py-sharing-fixtures-across-multiple-files 中了解更多信息。

如前所述,图 9**.1显示了位于tests文件夹中的测试文件:

图 9**.1 – 项目带有测试的结构

图 9**.1 – 项目带有测试的结构

就是这样!现在我们已经准备好为我们的 REST API 端点编写测试所需的所有固定装置。这将是我们在下一节要做的事情。

为 REST API 端点编写测试

测试 FastAPI 应用程序所需的所有工具现在都已准备好。所有这些测试都归结为执行一个 HTTP 请求并检查响应,看看它是否符合我们的预期。

让我们从对 hello_world 路径操作函数进行简单的测试开始。你可以在以下代码中看到它:

chapter09_app_test.py


@pytest.mark.asyncioasync def test_hello_world(test_client: httpx.AsyncClient):
    response = await test_client.get("/")
    assert response.status_code == status.HTTP_200_OK
    json = response.json()
    assert json == {"hello": "world"}

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter09/chapter09_app_test.py

首先,注意测试函数被定义为异步函数。如前所述,为了让它与 pytest 一起工作,我们需要安装 pytest-asyncio。这个扩展提供了 asyncio 标记:每个异步测试都应使用此标记进行装饰,以确保它能正常工作。

接下来,我们请求我们之前定义的 test_client 固件。它为我们提供了一个准备好向 FastAPI 应用程序发起请求的 HTTPX 客户端实例。注意,我们手动给这个固件加上了类型提示。虽然这不是严格要求的,但如果你使用像 Visual Studio Code 这样的 IDE,类型提示会大大帮助你,提供方便的自动完成功能。

然后,在我们测试的主体中,我们执行请求。在这里,它是一个简单的对 / 路径的 GET 请求。它返回一个 HTTPX Response 对象(与 FastAPI 的 Response 类不同),包含 HTTP 响应的所有数据:状态码、头信息和正文。

最后,我们基于这些数据做出断言。如你所见,我们验证了状态码确实是 200。我们还检查了正文的内容,它是一个简单的 JSON 对象。注意,Response 对象有一个方便的方法 json,可以自动解析 JSON 内容。

太棒了!我们写出了第一个 FastAPI 测试!当然,你可能会有更复杂的测试,通常是针对 POST 端点的测试。

编写 POST 端点的测试

测试 POST 端点与我们之前看到的并没有太大不同。不同之处在于,我们可能会有更多的用例来检查数据验证是否有效。在下面的例子中,我们实现了一个 POST 端点,该端点接受请求体中的 Person 模型:

chapter09_app_post.py


class Person(BaseModel):    first_name: str
    last_name: str
    age: int
@app.post("/persons", status_code=status.HTTP_201_CREATED)
async def create_person(person: Person):
    return person

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter09/chapter09_app_post.py

一个有趣的测试可能是确保如果请求负载中缺少某些字段,系统会引发错误。在以下提取中,我们编写了两个测试—一个使用无效负载,另一个使用有效负载:

chapter09_app_post_test.py


@pytest.mark.asyncioclass TestCreatePerson:
    async def test_invalid(self, test_client: httpx.AsyncClient):
        payload = {"first_name": "John", "last_name": "Doe"}
        response = await test_client.post("/persons", json=payload)
        assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
    async def test_valid(self, test_client: httpx.AsyncClient):
        payload = {"first_name": "John", "last_name": "Doe", "age": 30}
        response = await test_client.post("/persons", json=payload)
        assert response.status_code == status.HTTP_201_CREATED
        json = response.json()
        assert json == payload

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter09/chapter09_app_post_test.py

你可能首先注意到的是,我们将两个测试包装在了一个类里面。虽然在 pytest 中这不是必须的,但它可以帮助你组织测试——例如,重新组合与单个端点相关的测试。请注意,在这种情况下,我们只需要用asyncio标记装饰类,它会自动应用到单个测试上。另外,确保为每个测试添加self参数:因为我们现在在一个类中,它们变成了方法。

这些测试与我们的第一个例子没有太大不同。正如你所看到的,HTTPX 客户端使得执行带有 JSON 负载的 POST 请求变得非常简单:你只需将字典传递给json参数。

当然,HTTPX 帮助你构建各种各样的 HTTP 请求,包括带有头部、查询参数等等。务必查看它的官方文档,以了解更多使用方法:www.python-httpx.org/quickstart/

使用数据库进行测试

你的应用程序可能会有一个数据库连接,用来读取和存储数据。在这种情况下,你需要在每次运行时都使用一个新的测试数据库,以便拥有一组干净且可预测的数据来编写测试。

为此,我们将使用两样东西。第一个是dependency_overrides,它是 FastAPI 的一个特性,允许我们在运行时替换一些依赖项。例如,我们可以用返回测试数据库实例的依赖项替换返回数据库实例的依赖项。第二个是再次使用 fixtures,它将帮助我们在运行测试之前向测试数据库添加假数据。

为了给你展示一个工作示例,我们将考虑我们在《第六章》中构建的与 MongoDB 数据库进行通信部分中的相同示例,数据库和异步 ORM。在那个示例中,我们构建了用于管理博客文章的 REST 端点。你可能还记得,我们有一个返回数据库实例的get_database依赖项。为了提醒,你可以在这里再次看到它:

database.py


from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase# Connection to the whole server
motor_client = AsyncIOMotorClient("mongodb://localhost:27017")
# Single database instance
database = motor_client["chapter6_mongo"]
def get_database() -> AsyncIOMotorDatabase:
    return database

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter6/mongodb/database.py

路径操作函数和其他依赖项将使用这个依赖项来获取数据库实例。

对于我们的测试,我们将创建一个新的AsyncIOMotorDatabase实例,它指向另一个数据库。然后,我们将在测试文件中直接创建一个新依赖项,返回这个实例。你可以在以下示例中看到这一点:

chapter09_db_test.py


motor_client = AsyncIOMotorClient(    os.getenv("MONGODB_CONNECTION_STRING", "mongodb://localhost:27017")
)
database_test = motor_client["chapter09_db_test"]
def get_test_database():
    return database_test

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter09/chapter09_db_test.py

然后,在我们的 test_client fixture 中,我们将通过使用当前的 get_test_database 依赖项来覆盖默认的 get_database 依赖项。以下示例展示了如何实现这一点:

chapter09_db_test.py


@pytest_asyncio.fixtureasync def test_client():
    app.dependency_overrides[get_database] = get_test_database
    async with LifespanManager(app):
        async with httpx.AsyncClient(app=app, base_url="http://app.io") as test_client:
            yield test_client

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter09/chapter09_db_test.py

FastAPI 提供了一个名为 dependency_overrides 的属性,这是一个字典,用于将原始依赖函数与替代函数进行映射。在这里,我们直接使用 get_database 函数作为键。其余的 fixture 不需要改变。现在,每当 get_database 依赖项被注入到应用程序代码中时,FastAPI 会自动将其替换为 get_test_database。因此,我们的端点现在将使用测试数据库实例。

app 和 dependency_overrides 是全局的

由于我们是直接从模块中导入 app,因此它只会在整个测试运行中实例化一次。这意味着 dependency_overrides 对每个测试都是通用的。如果有一天你想为单个测试覆盖某个依赖项,记住一旦设置,它将应用于剩余的执行过程。在这种情况下,你可以通过使用 app.dependency_overrides = {} 来重置 dependency_overrides

为了测试一些行为,例如获取单个帖子,通常需要在我们的测试数据库中准备一些基础数据。为此,我们将创建一个新的 fixture,它将实例化虚拟的 PostDB 对象并将其插入到测试数据库中。你可以在以下示例中看到这一点:

chapter09_db_test.py


@pytest_asyncio.fixture(autouse=True, scope="module")async def initial_posts():
    initial_posts = [
        Post(title="Post 1", content="Content 1"),
        Post(title="Post 2", content="Content 2"),
        Post(title="Post 3", content="Content 3"),
    ]
    await database_test["posts"].insert_many(
        [post.dict(by_alias=True) for post in initial_posts]
    )
    yield initial_posts
    await motor_client.drop_database("chapter09_db_test")

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter09/chapter09_db_test.py

在这里,你可以看到我们只需要向 MongoDB 数据库发出一个 insert_many 请求来创建帖子。

请注意,我们使用了fixture装饰器的autousescope参数。第一个参数告诉 pytest 自动调用此测试夹具,即使在任何测试中都没有请求它。在这种情况下,它很方便,因为我们将始终确保数据已在数据库中创建,而不会有忘记在测试中请求它的风险。另一个参数scope,如前所述,允许我们在每个测试开始时不运行此测试夹具。使用module值时,测试夹具只会在此特定测试文件的开始时创建对象一次。它有助于提高测试速度,因为在这种情况下,重新创建帖子在每个测试之前是没有意义的。

再次,我们使用生成器来生成帖子,而不是直接返回它们。这个模式使得我们在测试运行后能够删除测试数据库。通过这样做,我们确保每次运行测试时,数据库都是全新的。

完成了!现在我们可以在完全了解数据库中的内容的情况下编写测试。在下面的示例中,您可以看到用于验证获取单个帖子端点行为的测试:

chapter09_db_test.py


@pytest.mark.asyncioclass TestGetPost:
    async def test_not_existing(self, test_client: httpx.AsyncClient):
        response = await test_client.get("/posts/abc")
        assert response.status_code == status.HTTP_404_NOT_FOUND
    async def test_existing(
        self, test_client: httpx.AsyncClient, initial_posts: list[Post]
    ):
        response = await test_client.get(f"/posts/{initial_posts[0].id}")
        assert response.status_code == status.HTTP_200_OK
        json = response.json()
        assert json["_id"] == str(initial_posts[0].id)

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter09/chapter09_db_test.py

请注意,在第二个测试中,我们请求了initial_posts测试夹具,以获取数据库中真实存在的帖子的标识符。

当然,我们也可以通过创建数据并检查它是否正确插入到数据库中来测试我们的端点。您可以在以下示例中看到这一点:

chapter09_db_test.py


@pytest.mark.asyncioclass TestCreatePost:
    async def test_invalid_payload(self, test_client: httpx.AsyncClient):
        payload = {"title": "New post"}
        response = await test_client.post("/posts", json=payload)
        assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
    async def test_valid_payload(self, test_client: httpx.AsyncClient):
        payload = {"title": "New post", "content": "New post content"}
        response = await test_client.post("/posts", json=payload)
        assert response.status_code == status.HTTP_201_CREATED
        json = response.json()
        post_id = ObjectId(json["_id"])
        post_db = await database_test["posts"].find_one({"_id": post_id})
        assert post_db is not None

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter09/chapter09_db_test.py

在第二个测试中,我们使用了database_test实例来执行请求,并检查对象是否正确插入。这展示了使用异步测试的好处:我们可以在测试中使用相同的库和工具。

这就是关于dependency_overrides的所有内容。这个功能在你需要为涉及外部服务的逻辑编写测试时非常有用,例如外部 API。与其在测试期间向这些外部服务发送真实请求(可能会导致问题或产生费用),你可以将它们替换为另一个伪造请求的依赖项。为了理解这一点,我们构建了另一个示例应用程序,其中有一个端点用于从外部 API 获取数据:

chapter09_app_external_api.py


class ExternalAPI:    def __init__(self) -> None:
        self.client = httpx.AsyncClient(base_url="https://dummyjson.com")
    async def __call__(self) -> dict[str, Any]:
        async with self.client as client:
            response = await client.get("/products")
            return response.json()
external_api = ExternalAPI()
@app.get("/products")
async def external_products(products: dict[str, Any] = Depends(external_api)):
    return products

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter09/chapter09_app_external_api.py

为了调用我们的外部 API,我们构建了一个类依赖,就像我们在 第五章在 FastAPI 中创建并使用带参数化的依赖类 一节中所看到的那样。我们使用 HTTPX 作为 HTTP 客户端向外部 API 发出请求并获取数据。这个外部 API 是一个虚拟 API,包含假的数据——非常适合像这样的实验:dummyjson.com

/products 端点只是通过依赖注入来获取依赖并直接返回外部 API 提供的数据。

当然,为了测试这个端点,我们不希望向外部 API 发出真实请求:这样可能会耗费时间,而且可能会受到速率限制。此外,你可能希望测试一些在真实 API 中不容易复现的行为,比如错误。

由于有了 dependency_overrides,我们可以很容易地用返回静态数据的另一个类替换我们的 ExternalAPI 依赖类。在以下示例中,你可以看到我们是如何实现这种测试的:

chapter09_app_external_api_test.py


class MockExternalAPI:    mock_data = {
        "products": [
            {
                "id": 1,
                "title": "iPhone 9",
                "description": "An apple mobile which is nothing like apple",
                "thumbnail": "https://i.dummyjson.com/data/products/1/thumbnail.jpg",
            },
        ],
        "total": 1,
        "skip": 0,
        "limit": 30,
    }
    async def __call__(self) -> dict[str, Any]:
        return MockExternalAPI.mock_data
@pytest_asyncio.fixture
async def test_client():
    app.dependency_overrides[external_api] = MockExternalAPI()
    async with LifespanManager(app):
        async with httpx.AsyncClient(app=app, base_url="http://app.io") as test_client:
            yield test_client

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter09/chapter09_app_external_api_test.py

在这里,你可以看到我们编写了一个简单的名为 MockExternalAPI 的类,它返回硬编码的数据。接下来我们只需要用这个类覆盖原有的依赖。在测试过程中,外部 API 将不会被调用;我们只会使用静态数据。

根据我们目前所看到的指南,你现在可以为 FastAPI 应用中的任何 HTTP 端点编写测试。然而,还有另一种行为不同的端点:WebSocket。正如我们将在下一节中看到的,WebSocket 的单元测试与我们为 REST 端点描述的方式非常不同。

为 WebSocket 端点编写测试

第八章 在 FastAPI 中定义双向交互通信的 WebSocket 一节中,我们解释了 WebSocket 是如何工作的以及如何在 FastAPI 中实现这样的端点。正如你可能已经猜到的那样,为 WebSocket 端点编写单元测试与我们之前所看到的方式有很大不同。

对于这个任务,我们需要稍微调整一下 test_client 固件。实际上,HTTPX 并没有内置支持与 WebSocket 通信。因此,我们需要使用一个插件:HTTPX WS。我们可以通过以下命令来安装它:


(venv) $ pip install httpx-ws

为了在测试客户端启用对 WebSocket 的支持,我们将这样修改它:

chapter09_websocket_test.py


from httpx_ws.transport import ASGIWebSocketTransport@pytest_asyncio.fixture
async def test_client():
    async with LifespanManager(app):
        async with httpx.AsyncClient(
            transport=ASGIWebSocketTransport(app), base_url="http://app.io"
        ) as test_client:
            yield test_client

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter09/chapter09_websocket_test.py

你可以看到,我们没有直接设置 app 参数,而是使用 HTTPX WS 提供的类设置了 transport。这个类提供了对带 WebSocket 端点的应用程序进行测试的支持。除此之外,其他没有变化。值得注意的是,测试标准的 HTTP 端点仍然能够正常工作,因此你可以使用这个测试客户端进行所有的测试。

现在,让我们考虑一个简单的 WebSocket 端点示例:

chapter09_websocket.py


@app.websocket("/ws")async def websocket_endpoint(websocket: WebSocket):
    await websocket.accept()
    try:
        while True:
            data = await websocket.receive_text()
            await websocket.send_text(f"Message text was: {data}")
    except WebSocketDisconnect:
        await websocket.close()

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter09/chapter09_websocket.py

你可能已经认出“回声”示例来自 第八章在 FastAPI 中定义 WebSockets 进行双向交互通信

现在,让我们使用测试客户端为我们的 WebSocket 编写一个测试:

Chapter09_websocket_test.py


from httpx_ws import aconnect_ws@pytest.mark.asyncio
async def test_websocket_echo(test_client: httpx.AsyncClient):
    async with aconnect_ws("/ws", test_client) as websocket:
        await websocket.send_text("Hello")
        message = await websocket.receive_text()
        assert message == "Message text was: Hello"

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter09/chapter09_websocket_test.py

正如你所看到的,HTTPX WS 提供了 aconnect_ws 函数来打开到 WebSocket 端点的连接。它需要 WebSocket 端点的路径以及有效的 HTTPX 客户端作为参数。通过使用 test_client,我们可以直接向 FastAPI 应用程序发起请求。

它打开了一个上下文管理器,给你一个 websocket 变量。它是一个对象,暴露了多个方法,用于发送或接收数据。每个方法都会阻塞,直到发送或接收到消息为止。

在这里,为了测试我们的“回声”服务器,我们通过 send_text 方法发送一条消息。然后,我们使用 receive_text 获取消息,并断言它符合我们的预期。也有等效的方法可以直接发送和接收 JSON 数据:send_jsonreceive_json

这就是 WebSocket 测试有点特别的地方:你需要考虑发送和接收消息的顺序,并以编程方式实现这些顺序,以测试 WebSocket 的行为。

除此之外,我们迄今为止看到的所有关于测试的内容都适用,尤其是当你需要使用测试数据库时的dependency_overrides

总结

恭喜你!你现在已经准备好构建高质量的、经过充分测试的 FastAPI 应用程序。在这一章中,你学习了如何使用 pytest,这是一款强大且高效的 Python 测试框架。得益于 pytest 固件,你了解了如何为 FastAPI 应用程序创建一个可以异步工作的可重用测试客户端。使用这个客户端,你学习了如何发出 HTTP 请求,以断言你的 REST API 的行为。最后,我们回顾了如何测试 WebSocket 端点,这涉及到一种完全不同的思维方式。

现在你已经能够构建一个可靠且高效的 FastAPI 应用程序,是时候将它带给全世界了!在下一章中,我们将回顾一些最佳实践和模式,以准备 FastAPI 应用程序让它面向全球,然后再研究几种部署方法。

第十章:部署 FastAPI 项目

构建一个优秀的应用程序很好,但如果客户能够享受它就更好了。在这一章中,你将学习如何通过使用环境变量设置所需的配置选项以及通过使用 pip 正确管理你的依赖项来结构化你的项目,以便为部署做好准备。一旦完成,我们将展示三种部署应用程序的方式:使用无服务器云平台、使用 Docker 容器以及使用传统的 Linux 服务器。

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

  • 设置和使用环境变量

  • 管理 Python 依赖项

  • 在无服务器平台上部署 FastAPI 应用程序

  • 使用 Docker 部署 FastAPI 应用程序

  • 在传统服务器上部署 FastAPI 应用程序

技术要求

对于这一章,你需要一个 Python 虚拟环境,就像我们在第一章Python 开发环境设置中设置的那样。

你可以在专门的 GitHub 代码库中找到本章的所有代码示例,地址为 https://github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter10。

设置和使用环境变量

在深入讨论不同的部署技术之前,我们需要结构化我们的应用程序,以实现可靠、快速和安全的部署。在这个过程中的一个关键点是处理配置变量:数据库 URL、外部 API 令牌、调试标志等等。在处理这些变量时,需要动态处理而不是将它们硬编码到源代码中。为什么呢?

首先,这些变量在本地环境和生产环境中可能会不同。通常情况下,你的数据库 URL 在开发时可能指向本地计算机上的数据库,但在生产环境中可能会指向一个正式的生产数据库。如果你还有其他环境,比如 staging 或预生产环境,这一点就更加重要了。此外,如果我们需要更改其中一个值,我们必须修改代码、提交并重新部署。因此,我们需要一个方便的机制来设置这些值。

其次,在代码中写入这些值是不安全的。例如数据库连接字符串或 API 令牌等敏感信息。如果它们出现在你的代码中,它们很可能会被提交到你的代码库中:这些信息可以被任何有访问权限的人读取,这会带来明显的安全问题。

为了解决这个问题,我们通常使用环境变量。环境变量是程序本身没有设置的值,而是设置在整个操作系统中的值。大多数编程语言都有必要的函数来从系统中读取这些变量。你可以在 Unix 命令行中很容易地尝试这个:


$ export MY_ENVIRONMENT_VARIABLE="Hello" # Set a temporary variable on the system$ python
>>> import os
>>> os.getenv("MY_ENVIRONMENT_VARIABLE")  # Get it in Python
'Hello'

在 Python 源代码中,我们可以从系统中动态获取值。在部署时,我们只需要确保在服务器上设置正确的环境变量。这样,我们就可以在不重新部署代码的情况下轻松更改值,并且可以让多个不同配置的部署共享相同的源代码。然而,请注意,如果不小心,已经设置为环境变量的敏感值仍然可能会泄露——例如,在日志文件或错误堆栈跟踪中。

为了帮助我们完成这项任务,我们将使用 Pydantic 的一个非常方便的特性:设置管理。这样,我们就可以像使用其他数据模型一样结构化和使用我们的配置变量。它甚至会自动从环境变量中检索这些值!

本章剩余部分,我们将使用一个你可以在chapter10/project中找到的应用程序。它是一个简单的 FastAPI 应用,使用 SQLAlchemy,与我们在第六章中的与 SQL 数据库的通信,使用 SQLAlchemy ORM部分中回顾的非常相似,数据库和 异步 ORM

从项目目录运行命令

如果你克隆了示例仓库,确保从project目录运行本章中显示的命令。在命令行中,直接输入cd chapter10/project

要结构化一个设置模型,你只需要创建一个继承自pydantic.BaseSettings的类。下面的示例展示了一个包含调试标志、环境名称和数据库 URL 的配置类:

settings.py


from pydantic import BaseSettingsclass Settings(BaseSettings):
    debug: bool = False
    environment: str
    database_url: str
    class Config:
        env_file = ".env"
settings = Settings()

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter10/project/project/settings.py

如你所见,创建这个类与创建标准的 Pydantic 模型非常相似。我们甚至可以像为debug做的那样定义默认值。

要使用它,我们只需要创建该类的一个实例。然后,我们可以在项目中的任何地方导入它。例如,下面是如何获取数据库 URL 来创建我们的 SQLAlchemy 引擎:

database.py


from project.settings import settingsengine = create_async_engine(settings.database_url)

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter10/project/project/database.py

我们还使用debug标志在启动时的lifespan事件中打印所有设置:

app.py


@contextlib.asynccontextmanagerasync def lifespan(app: FastAPI):
    if settings.debug:
        print(settings)
    yield

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter10/project/project/app.py

由于我们的应用程序是为了与 SQLAlchemy 一起使用的,我们还处理了使用 Alembic 初始化数据库迁移环境的工作,就像我们在 第六章中展示的那样,数据库与异步 ORM。这里的区别是,我们使用 settings 对象动态配置数据库 URL;我们不再在alembic.ini中硬编码它,而是可以从 env.py 中的设置来配置它,正如你在这里看到的:

env.py


config.set_main_option(    "sqlalchemy.url", settings.database_url.replace("+aiosqlite", "")
)

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter10/project/alembic/env.py

注意,我们手动移除了 URL 中的aiosqlite驱动部分。实际上,正如我们之前提到的,Alembic 被设计为同步工作,因此我们需要传递一个标准的 URL。现在,我们可以从开发数据库生成迁移,并在生产环境中应用这些迁移,而无需更改 Alembic 配置!

使用这个 Settings 模型的好处在于,它像任何其他 Pydantic 模型一样工作:它会自动解析在环境变量中找到的值,如果某个值在环境中缺失,它会抛出错误。通过这种方式,你可以确保应用程序启动时不会遗漏任何值。你可以通过运行应用程序来测试这种行为:


(venv) $ uvicorn project.app:apppydantic.error_wrappers.ValidationError: 2 validation errors for Settings
environment
  field required (type=value_error.missing)
database_url
  field required (type=value_error.missing)

我们已经清楚列出了缺失的变量。让我们将这些变量设置到环境中,并重新尝试:


(venv) $ export DEBUG="true" ENVIRONMENT="development" DATABASE_URL="sqlite+aiosqlite:///chapter10_project.db"(venv) $ uvicorn project.app:app
INFO:     Started server process [34880]
INFO:     Waiting for application startup.
debug=True environment='development' database_url='sqlite+aiosqlite:///chapter10_project.db'
INFO:     Application startup complete.
INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)

应用程序已启动!你甚至可以看到我们的生命周期处理器打印了我们的设置值。注意,当检索环境变量时,Pydantic 默认是不区分大小写的。按照惯例,环境变量通常在系统中以大写字母设置。

使用 .env 文件

在本地开发中,手动设置环境变量有点麻烦,特别是当你同时在机器上处理多个项目时。为了解决这个问题,Pydantic 允许你从 .env 文件中读取值。该文件包含一个简单的环境变量及其关联值的列表,通常在开发过程中更容易编辑和操作。

为了实现这一点,我们需要一个新的库python-dotenv,它的任务是解析这些 .env 文件。你可以像往常一样通过以下命令安装它:


(venv) $ pip install python-dotenv

为了启用这个功能,请注意我们是如何添加具有env_file属性的Config子类的:

settings.py


class Settings(BaseSettings):    debug: bool = False
    environment: str
    database_url: str
    class Config:
        env_file = ".env"

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter10/project/project/settings.py

通过这样做,我们只是简单地告诉 Pydantic 查找名为 .env 的文件中的环境变量,如果该文件存在的话。

最后,你可以在项目的根目录创建你的 .env 文件,内容如下:


DEBUG=trueENVIRONMENT=development
DATABASE_URL=sqlite+aiosqlite:///chapter10_project.db

就这样!这些值现在将从 .env 文件中读取。如果文件丢失,Settings 将像往常一样尝试从环境变量中读取。当然,这只是为了开发时的方便:这个文件不应该被提交,你应该依赖于生产环境中正确设置的环境变量。为了确保你不会不小心提交此文件,通常建议将其添加到你的 .gitignore 文件中。

创建像 .env 文件这样的隐藏文件

在 Unix 系统中,以点(.)开头的文件,如 .env,被视为隐藏文件。如果你尝试从操作系统的文件浏览器创建它们,可能会显示警告,甚至阻止你这么做。因此,通常更方便通过你的 IDE(如 Visual Studio Code)或者通过命令行执行以下命令来创建它们:touch .env

太棒了!我们的应用程序现在支持动态配置变量,这些变量现在可以很容易地在部署平台上设置和更改。另一个需要注意的重要事项是依赖项:到目前为止我们已经安装了相当多的依赖项,但必须确保在部署过程中它们能被正确安装!

管理 Python 依赖项

在本书中,我们使用 pip 安装了库,以便为我们的应用程序添加一些有用的功能:当然是 FastAPI,还有 SQLAlchemy、pytest 等等。当将项目部署到新环境中时,比如生产服务器,我们必须确保所有这些依赖项已经安装,以确保应用程序能正常工作。如果你的同事也需要在项目上工作,他们也需要知道他们需要在自己的机器上安装哪些依赖项。

幸运的是,pip 提供了一个解决方案,帮助我们不需要记住所有这些内容。事实上,大多数 Python 项目会定义一个 requirements.txt 文件,其中包含所有 Python 依赖项的列表。这个文件通常位于项目的根目录。pip 有一个专门的选项来读取此文件并安装所有需要的依赖项。

当你已经有一个工作环境时,比如我们从本书开始就使用的环境,通常推荐你运行以下命令:


(venv) $ pip freezeaiosqlite==0.17.0
alembic==1.8.1
anyio==3.6.2
argon2-cffi==21.3.0
argon2-cffi-bindings==21.2.0
asgi-lifespan==2.0.0
asyncio-redis==0.16.0
attrs==22.1.0
...

pip freeze 的结果是一个列出当前在环境中安装的每个 Python 包,以及它们相应的版本。这个列表可以直接用在 requirements.txt 文件中。

这种方法的问题在于,它列出了每个包,包括你安装的库的子依赖。换句话说,在这个列表中,你会看到一些你并未直接使用的包,但它们是你安装的包所需要的。如果因为某些原因,你决定不再使用某个库,你是可以将其移除的,但很难猜出它安装了哪些子依赖。从长远来看,你的requirements.txt文件会变得越来越大,包含许多在项目中没用的依赖。

为了解决这个问题,有些人建议你手动维护你的requirements.txt 文件。采用这种方法时,你需要自己列出所有使用的库以及它们的版本。在安装时,pip会负责安装子依赖,但它们不会出现在requirements.txt中。通过这种方式,当你删除某个依赖时,你可以确保不会保留任何不必要的包。

在下面的示例中,你可以看到我们在本章中所做项目的requirements.txt文件:

requirements.txt


aiosqlite==0.17.0alembic==1.8.1
fastapi==0.88.0
sqlalchemy[asyncio]==1.4.44
uvicorn[standard]==0.20.0
gunicorn==20.1.0

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter10/project/requirements.txt

如你所见,列表变得简短多了!现在,每当我们安装一个新依赖时,我们的责任就是手动将其添加到requirements.txt中。

关于替代包管理器,如 Poetry、Pipenv 和 Conda

在探索 Python 社区时,你可能会听说过替代的包管理器,如 Poetry、Pipenv 和 Conda。这些管理器是为了解决pip带来的一些问题,特别是在子依赖管理方面。虽然它们是非常好的工具,但许多云平台期望使用传统的requirements.txt文件来指定依赖,而不是那些更现代的工具。因此,它们可能不是 FastAPI 应用程序的最佳选择。

requirements.txt文件应该与源代码一起提交。当你需要在新电脑或服务器上安装依赖时,你只需要运行以下命令:


(venv) $ pip install -r requirements.txt

当然,在进行此操作时,请确保你正在正确的虚拟环境中工作,就像我们在第一章中描述的那样,Python 开发 环境设置

你可能已经注意到在requirements.txt中有gunicorn的依赖。让我们来看看它是什么以及为什么需要它。

将 Gunicorn 添加为部署时的服务器进程

第二章《Python 编程特性》中,我们简要介绍了 WSGI 和 ASGI 协议。它们定义了在 Python 中构建 Web 服务器的规范和数据结构。传统的 Python Web 框架,如 Django 和 Flask,依赖于 WSGI 协议。ASGI 是最近出现的,并被视为 WSGI 的“精神继承者”,为开发运行异步的 Web 服务器提供协议。这个协议是 FastAPI 和 Starlette 的核心。

正如我们在第三章《使用 FastAPI 开发 RESTful API》中提到的,我们使用 Uvicorn 来运行 FastAPI 应用:它的作用是接受 HTTP 请求,将其按照 ASGI 协议转换,并传递给 FastAPI 应用,后者返回一个符合 ASGI 协议的响应对象。然后,Uvicorn 可以从该对象形成适当的 HTTP 响应。

在 WSGI 的世界中,最广泛使用的服务器是 Gunicorn。在 Django 或 Flask 应用的上下文中,它扮演着相同的角色。那么,我们为什么要讨论它呢?Gunicorn 有许多优化和特性,使得它在生产环境中比 Uvicorn 更加稳健和可靠。然而,Gunicorn 设计时是针对 WSGI 应用的。那么,我们该怎么办呢?

实际上,我们可以同时使用这两个:Gunicorn 将作为我们的生产服务器的强大进程管理器。然而,我们会指定 Uvicorn 提供的特殊工作类,这将允许我们运行 ASGI 应用程序,如 FastAPI。这是官方 Uvicorn 文档中推荐的部署方式:www.uvicorn.org/deployment/#using-a-process-manager

所以,让我们通过以下命令将 Gunicorn 安装到我们的依赖中(记得将它添加到 requirements.txt 文件中):


(venv) $ pip install gunicorn

如果你愿意,可以尝试使用以下命令,通过 Gunicorn 运行我们的 FastAPI 项目:


(venv) $ gunicorn -w 4 -k uvicorn.workers.UvicornWorker project.app:app

它的使用方式与 Uvicorn 十分类似,不同之处在于我们告诉它使用 Uvicorn 工作类。同样,这是为了使其与 ASGI 应用兼容。此外,请注意 -w 选项。它允许我们设置为服务器启动的工作进程数。在这里,我们启动了四个实例的应用。然后,Gunicorn 会负责在每个工作进程之间负载均衡传入的请求。这就是 Gunicorn 更加稳健的原因:如果由于某种原因,你的应用因同步操作而阻塞了事件循环,其他工作进程仍然可以处理其他请求。

现在,我们已经准备好部署 FastAPI 应用程序了!在下一节中,你将学习如何在无服务器平台上部署一个。

在无服务器平台上部署 FastAPI 应用

近年来,无服务器平台得到了广泛的应用,并成为部署 Web 应用程序的常见方式。这些平台完全隐藏了设置和管理服务器的复杂性,提供了自动构建和部署应用程序的工具,通常只需要几分钟。Google App Engine、Heroku 和 Azure App Service 是其中最受欢迎的。尽管它们各自有特定的要求,但所有这些无服务器平台都遵循相同的原理。因此,在本节中,我们将概述您应该遵循的通用步骤。

通常,无服务器平台要求您以 GitHub 仓库的形式提供源代码,您可以直接将其推送到他们的服务器,或者他们会自动从 GitHub 拉取代码。在这里,我们假设您有一个 GitHub 仓库,源代码结构如下:

图 10.1 – 无服务器部署的项目结构

图 10.1 – 无服务器部署的项目结构

以下是您应该遵循的在这种平台上部署项目的一般步骤:

  1. 在您选择的云平台上创建一个帐户。在开始任何工作之前,您必须完成这一步。值得注意的是,大多数云平台在您入门时会提供免费积分,让您可以免费试用它们的服务。

  2. 安装必要的命令行工具。大多数云服务提供商提供完整的 CLI 来管理他们的服务。通常,这对于部署您的应用程序是必需的。以下是一些最受欢迎的云服务提供商的相关文档页面:

  3. 设置应用程序配置。根据平台的不同,您需要创建配置文件,或者使用命令行工具或网页界面来完成此操作。以下是一些最受欢迎的云服务提供商的相关文档页面:

这一过程中的关键是正确地设置启动命令。正如我们在上一节看到的,使用gunicorn命令设置 Uvicorn 工作进程类并设置正确的应用路径是至关重要的。

  1. 设置环境变量。根据不同的云服务提供商,你应该能够在配置或部署过程中完成这项操作。请记住,环境变量对你的应用程序正常运行至关重要。以下是一些流行的云服务提供商的相关文档页面:

  2. 部署应用程序。一些平台在检测到托管仓库(例如 GitHub)上的更改时会自动部署。其他平台则要求你从命令行工具启动部署。以下是一些流行的云服务提供商的相关文档页面:

你的应用程序现在应该已经在平台上运行了。实际上,大多数云平台会在后台自动构建和部署 Docker 容器,同时遵循你提供的配置。

它们会在一个通用的子域名上提供你的应用程序,如myapplication.herokuapp.com。当然,它们也提供将其绑定到你自己的域名或子域名的机制。以下是一些流行的云服务提供商的相关文档页面:

添加数据库服务器

大多数情况下,您的应用将由数据库引擎提供支持,例如 PostgreSQL。幸运的是,云服务提供商提供了完全托管的数据库,按所需的计算能力、内存和存储收费。一旦创建,您将获得一个连接字符串,用于连接到数据库实例。之后,您只需将其设置为应用程序的环境变量即可。以下是开始使用最流行云服务提供商的托管数据库的相关文档页面:

正如我们所看到的,无服务器平台是部署 FastAPI 应用最快、最简便的方式。然而,在某些情况下,您可能希望对部署方式有更多控制,或者可能需要一些在无服务器平台上不可用的系统包。在这种情况下,使用 Docker 容器可能是值得的。

使用 Docker 部署 FastAPI 应用

Docker 是一种广泛使用的容器化技术。容器是运行在计算机上的小型、自包含的系统。每个容器包含运行单一应用程序所需的所有文件和配置:如 Web 服务器、数据库引擎、数据处理应用等。其主要目标是能够在不担心依赖关系和版本冲突的情况下运行这些应用,这些问题在尝试在系统上安装和配置应用时经常发生。

此外,Docker 容器被设计为 便携和可复现的:要创建一个 Docker 容器,你只需编写一个 Dockerfile,其中包含所有必要的指令来构建这个小系统,以及你所需的所有文件和配置。这些指令会在 构建 过程中执行,最终生成一个 Docker 镜像。这个镜像是一个包含你小系统的包,准备好使用,你可以通过 镜像仓库 在互联网上轻松分享。任何拥有工作 Docker 安装的开发人员,都可以下载这个镜像,并在他们的系统中通过容器运行它。

Docker 被开发人员迅速采纳,因为它大大简化了复杂开发环境的设置,使他们能够拥有多个项目,并且每个项目使用不同版本的系统包,而不需要担心它们在本地机器上的安装问题。

然而,Docker 不仅仅是为了本地开发:它也广泛用于将应用程序部署到生产环境。由于构建是可复现的,我们可以确保本地和生产环境保持一致,这样在部署到生产环境时能够减少问题。

在本节中,我们将学习如何为 FastAPI 应用编写 Dockerfile,如何构建镜像,以及如何将其部署到云平台。

编写 Dockerfile

正如我们在本节介绍中提到的,Dockerfile 是一组构建 Docker 镜像的指令,它是一个包含运行应用程序所需的所有组件的自包含系统。首先,所有 Dockerfile 都是从一个基础镜像衍生出来的;通常,这个基础镜像是一个标准的 Linux 安装,如 Debian 或 Ubuntu。基于这个基础镜像,我们可以将文件从本地机器复制到镜像中(通常是应用程序的源代码),并执行 Unix 命令——例如,安装软件包或执行脚本。

在我们的案例中,FastAPI 的创建者已经创建了一个基础的 Docker 镜像,包含了运行 FastAPI 应用所需的所有工具!我们要做的就是从这个镜像开始,复制我们的源文件,并安装我们的依赖项!让我们来学习如何做!

首先,你需要在你的机器上安装 Docker。请按照官方的 入门指南,该指南将引导你完成安装过程:docs.docker.com/get-started/

要创建一个 Docker 镜像,我们只需要在项目根目录下创建一个名为 Dockerfile 的文件。以下示例展示了我们当前项目中该文件的内容:

Dockerfile


FROM tiangolo/uvicorn-gunicorn-fastapi:python3.10ENV APP_MODULE project.app:app
COPY requirements.txt /app
RUN pip install --upgrade pip && \
    pip install -r /app/requirements.txt
COPY ./ /app

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter10/project/Dockerfile

让我们逐条讲解每个指令。第一条指令是FROM,它指定了我们所基于的基础镜像。在这里,我们使用了uvicorn-gunicorn-fastapi镜像,这是 FastAPI 的创建者制作的。Docker 镜像有标签,可以用来选择镜像的特定版本。在这里,我们选择了 Python 3.10 版本。该镜像有许多变种,包括其他版本的 Python。你可以在官方的 README 文件中查看它们:github.com/tiangolo/uvicorn-gunicorn-fastapi-docker

接着,我们通过ENV指令设置了APP_MODULE环境变量。在 Docker 镜像中,环境变量可以在构建时设置,就像我们在这里做的那样,也可以在运行时设置。APP_MODULE是由基础镜像定义的一个环境变量。它应该指向你的 FastAPI 应用的路径:它是我们在 Uvicorn 和 Gunicorn 命令的末尾用来启动应用的相同参数。你可以在官方的 README 文件中找到基础镜像接受的所有环境变量列表。

接下来,我们有了第一个COPY语句。正如你可能已经猜到的,这条指令会将一个文件从本地系统复制到镜像中。在这里,我们只复制了requirements.txt文件。稍后我们会解释原因。请注意,我们将文件复制到了镜像中的/app目录;这是由基础镜像定义的主要工作目录。

然后我们有一个RUN语句。这条指令用于执行 Unix 命令。在我们的案例中,我们运行了pip来安装我们依赖的包,依据的是我们刚刚复制的requirements.txt文件。这是确保所有 Python 依赖包都已安装的关键步骤。

最后,我们将剩余的源代码文件复制到了/app目录。现在,让我们来解释为什么我们单独复制了requirements.txt。要理解的关键点是,Docker 镜像是通过层构建的:每个指令都会在构建系统中创建一个新层。为了提高性能,Docker 尽量重用已经构建的层。因此,如果它检测到与上次构建没有变化,它将重用内存中已有的层,而不是重新构建它们。

通过仅复制requirements.txt文件并在源代码的其他部分之前安装 Python 依赖项,我们允许 Docker 重用已安装依赖项的层。如果我们修改了源代码但没有修改requirements.txt,Docker 构建将只会执行最后一条COPY指令,重用所有先前的层。因此,镜像将在几秒钟内构建完成,而不是几分钟。

大多数时候,Dockerfile 会以CMD指令结束,这条指令定义了容器启动时要执行的命令。在我们的案例中,我们会使用在添加 Gunicorn 作为服务器部分中看到的 Gunicorn 命令。然而,在我们的情况下,基础镜像已经为我们处理了这个问题。

添加预启动脚本

在部署应用程序时,通常会在应用程序启动之前运行几个命令。最典型的情况是执行数据库迁移,以确保我们的生产数据库具有正确的表和列。为了帮助我们,基础的 Docker 镜像允许我们创建一个名为prestart.sh的 bash 脚本。如果该文件存在,它将在 FastAPI 应用程序启动之前自动运行。

在我们的例子中,我们只需要运行 Alembic 命令来执行迁移:

prestart.sh


#! /usr/bin/env bash# Let the DB start
sleep 10;
# Run migrations
alembic upgrade head

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter10/project/prestart.sh

请记住,这只是tiangolo/uvicorn-gunicorn-fastapi镜像提供的一个方便机制。如果你从一个更基础的镜像开始,你将需要自己想出一种解决方案来运行预启动脚本。

构建 Docker 镜像

现在我们可以构建 Docker 镜像了!只需在项目的根目录下运行以下命令:


$ docker build -t fastapi-app  .

点号(.)表示构建镜像的根上下文路径——在这种情况下是当前目录。-t选项用于标记镜像并为其指定一个实际的名称。

Docker 将开始构建。你会看到它下载基础镜像,并依次执行你的指令。这应该需要几分钟。如果你再次运行该命令,你将体验到我们之前提到的关于镜像层的内容:如果没有变化,镜像层会被重用,构建仅需几秒钟。

本地运行 Docker 镜像

在将其部署到生产环境之前,你可以尝试在本地运行镜像。为此,运行以下命令:


$ docker run -p 8000:80 -e ENVIRONMENT=production -e DATABASE_URL=sqlite+aiosqlite:///app.db fastapi-app

在这里,我们使用了run命令并指定了我们刚刚构建的镜像名称。当然,这里有一些选项:

  • -p允许你在本地机器上发布端口。默认情况下,Docker 容器在本地机器上是不可访问的。如果你发布端口,它们将通过localhost提供。在容器端,FastAPI 应用程序在80端口运行。我们将它发布到本地机器的8000端口,也就是8000:80

  • -e用于设置环境变量。如我们在设置和使用环境变量部分中提到的,我们需要这些变量来配置我们的应用程序。Docker 允许我们在运行时轻松且动态地设置它们。请注意,我们为测试目的设置了一个简单的 SQLite 数据库。然而,在生产环境中,它应该指向一个合适的数据库。

  • 你可以在官方 Docker 文档中查看此命令的众多选项:docs.docker.com/engine/reference/commandline/run/#options

该命令将运行你的应用程序,应用程序将通过http://localhost:8000访问。Docker 将在终端中显示日志。

部署 Docker 镜像

现在你有了一个可用的 Docker 镜像,你可以在几乎任何运行 Docker 的机器上部署它。这可以是你自己的服务器,也可以是专用平台。许多无服务器平台已经出现,帮助你自动部署容器镜像:Google Cloud Run、Amazon Elastic Container Service 和 Microsoft Azure Container Instances 仅是其中的几个。

通常,你需要做的是将你的镜像上传(在 Docker 术语中是推送)到一个注册中心。默认情况下,Docker 从 Docker Hub(官方 Docker 注册中心)拉取和推送镜像,但许多服务和平台提供了自己的注册中心。通常,为了在该平台上部署,必须使用云平台提供的私有云注册中心。以下是与最流行的云服务提供商的私有注册中心入门相关的文档页面:

如果你按照相关的说明操作,你应该已经有了一个私有注册中心来存储 Docker 镜像。说明中可能会教你如何用本地的 Docker 命令行进行身份验证,以及如何推送你的第一个镜像。基本上,你需要做的就是为你构建的镜像打上标签,并指向你的私有注册中心路径:


$ docker tag fastapi-app aws_account_id.dkr.ecr.region.amazonaws.com/fastapi-app

然后,你需要将其推送到注册中心:


$ docker push fastapi-app aws_account_id.dkr.ecr.region.amazonaws.com/fastapi-app

你的镜像现在已安全存储在云平台注册中心。你现在可以使用无服务器容器平台自动部署它。以下是与最流行的云服务提供商的私有注册中心入门相关的文档页面:

当然,你可以像为完全托管的应用程序一样设置环境变量。这些环境还提供了许多选项,用于调整容器的可扩展性,包括垂直扩展(使用更强大的实例)和水平扩展(启动更多实例)。

完成后,你的应用程序应该已经可以在网络上访问了!与自动化的无服务器平台相比,部署 Docker 镜像的一个好处是,你不受平台支持功能的限制:你可以部署任何东西,甚至是需要大量特殊包的复杂应用,而不必担心兼容性问题。

到这时,我们已经看到了部署 FastAPI 应用程序的最简单和最有效的方法。然而,你可能希望使用传统方法部署,并手动设置服务器。在接下来的章节中,我们将提供一些实施指南。

在传统服务器上部署 FastAPI 应用程序

在某些情况下,你可能没有机会使用无服务器平台来部署应用程序。一些安全或合规政策可能迫使你在具有特定配置的物理服务器上进行部署。在这种情况下,了解一些基本知识会非常有用,帮助你在传统服务器上部署应用程序。

在本节中,我们假设你正在使用 Linux 服务器:

  1. 首先,确保在服务器上安装了最新版本的 Python,理想情况下是与开发中使用的版本相匹配。设置 pyenv 是实现这一点的最简单方法,就像我们在 第一章 Python 开发环境设置中看到的那样。

  2. 为了获取源代码并与最新开发同步,你可以克隆你的 Git 仓库到服务器上。这样,你只需拉取更改并重启服务器进程,就能部署新版本。

  3. 设置一个Python 虚拟环境,正如我们在 第一章 Python 开发环境设置中所解释的那样。你可以通过 requirements.txt 文件使用 pip 安装依赖。

  4. 到那时,你应该能够运行 Gunicorn 并开始为 FastAPI 应用程序提供服务。然而,强烈建议进行一些改进。

  5. 使用进程管理器来确保 Gunicorn 进程始终运行,并在服务器重启时自动重启。一个不错的选择是Supervisor。Gunicorn 文档提供了很好的指南:docs.gunicorn.org/en/stable/deploy.html#supervisor

  6. 还建议将 Gunicorn 放在 HTTP 代理后面,而不是直接将其暴露在前端。其作用是处理 SSL 连接、执行负载均衡,并提供静态文件,如图片或文档。Gunicorn 文档建议使用 nginx 来完成此任务,并提供了基本的配置:docs.gunicorn.org/en/stable/deploy.html#nginx-configuration

正如你所见,在这种情况下,你需要做出许多关于服务器配置的决定。当然,你还应该注意安全,确保你的服务器能够有效防范常见攻击。在以下的 DigitalOcean 教程中,你将找到一些保护服务器安全的指导原则:www.digitalocean.com/community/tutorials/recommended-security-measures-to-protect-your-servers

如果你不是经验丰富的系统管理员,我们建议你优先选择无服务器平台;专业团队会为你处理安全性、系统更新和服务器可扩展性,让你可以专注于最重要的事情:开发出色的应用程序!

总结

你的应用程序现在已经上线!在本章中,我们介绍了在将应用程序部署到生产环境之前应该应用的最佳实践:使用环境变量设置配置选项,如数据库 URL,并通过 requirements.txt 文件管理 Python 依赖。然后,我们展示了如何将应用程序部署到无服务器平台,这个平台会为你处理一切,包括获取源代码、打包依赖项并将其提供给用户。接下来,你学习了如何使用 FastAPI 的创建者提供的基础镜像构建 Docker 镜像。正如你所看到的,这样可以灵活配置系统,但你依然可以在几分钟内通过支持容器的无服务器平台完成部署。最后,我们为你提供了一些在传统 Linux 服务器上手动部署的指导原则。

这标志着本书第二部分的结束。现在你应该对编写高效、可靠的 FastAPI 应用程序充满信心,并能够将其部署到互联网上。

在下一章中,我们将开始一些数据科学任务,并将它们高效地集成到 FastAPI 项目中。

第三部分:使用 FastAPI 构建弹性和分布式数据科学系统

本部分将介绍数据科学和机器学习的基本概念,以及用于这些任务的最流行的 Python 工具和库。我们将了解如何将这些工具集成到 FastAPI 后端中,并如何构建一个分布式系统,以可扩展的方式执行资源密集型任务。

本节包含以下章节:

  • 第十一章Python 中的数据科学入门

  • 第十二章使用 FastAPI 创建高效的预测 API 端点

  • 第十三章使用 WebSockets 与 FastAPI 实现实时物体检测系统

  • 第十四章使用稳定扩散模型创建分布式文本转图像 AI 系统

  • 第十五章监控数据科学系统的健康状况和性能

第十一章:Python 数据科学入门

近年来,Python 在数据科学领域获得了广泛的关注。其高效且易读的语法使得该语言成为科学研究的一个非常好的选择,同时仍然适用于生产工作负载;它非常容易将研究项目部署到能为用户带来价值的实际应用中。由于这种日益增长的兴趣,许多专门的 Python 库应运而生,并且现在已经成为行业标准。在本章中,我们将介绍机器学习的基本概念,然后再深入了解数据科学家日常使用的 Python 库。

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

  • 理解机器学习的基本概念

  • 创建和操作 NumPy 数组和 pandas 数据集

  • 使用 scikit-learn 训练和评估机器学习模型

技术要求

对于本章,你将需要一个 Python 虚拟环境,正如我们在第一章中所设置的,Python 开发 环境设置

你可以在本章的专用 GitHub 仓库中找到所有代码示例,网址为github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter11

什么是机器学习?

机器学习ML)通常被视为人工智能的一个子领域。虽然这种分类仍然存在争议,但由于机器学习在垃圾邮件过滤、自然语言处理和图像生成等广泛而显著的应用领域中得到了广泛应用,近年来它的曝光率非常高。

机器学习是一个从现有数据中构建数学模型的领域,使得机器能够自行理解这些数据。机器是“学习”的,因为开发者不需要为复杂任务编写逐步算法,来解决问题,这是不可能的。一旦模型在现有数据上“训练”完成,它就可以用来预测新数据或理解新的观察结果。

以垃圾邮件过滤器为例:如果我们有一个足够大的电子邮件集合,手动标记为“垃圾邮件”或“非垃圾邮件”,我们可以使用机器学习技术构建一个模型,来判断新收到的电子邮件是否为垃圾邮件。

在本节中,我们将回顾机器学习的最基本概念。

监督学习与非监督学习

机器学习技术可以分为两大类:监督学习非监督学习

在监督学习中,现有的数据集已经标记好,这意味着我们既有输入(观察的特征),称为特征,也有输出。如果我们以垃圾邮件过滤器为例,特征可以是每个单词的频率,而标签则是类别——也就是说,“垃圾邮件”或“非垃圾邮件”。监督学习分为两个组:

  • 分类问题,将数据分类到有限的类别中——例如,垃圾邮件过滤器。

  • 回归问题,预测连续的数值——例如,根据星期几、天气和位置预测租用的电动滑板车数量。

无监督学习,另一方面,是在没有标签参考的数据上进行操作。其目标是从特征本身发现有趣的模式。无监督学习试图解决的两个主要问题如下:

  • 聚类,我们想要找到一组相似的数据点——例如,一个推荐系统,根据其他类似你的人喜欢的商品来推荐你可能喜欢的商品。

  • 降维,目标是找到更紧凑的数据集表示,这些数据集包含了很多不同的特征。通过这样做,我们可以只保留最有意义和最具辨别力的特征,同时在较小的数据集维度下工作。

模型验证

机器学习的一个关键方面是评估模型是否表现良好。我们如何确定模型在新观察到的数据上也会表现良好?在构建模型时,如何判断一个算法是否优于另一个算法?所有这些问题都可以并且应该通过模型验证技术来回答。

正如我们之前提到的,机器学习方法是从一个现有的数据集开始的,我们将用它来训练模型。

直观地,我们可能想要使用所有可用的数据来训练我们的模型。一旦完成,如何测试模型呢?我们可以将模型应用到相同的数据上,看看输出是否正确……结果可能会非常好!在这里,我们用相同的训练数据来测试模型。显然,由于模型已经见过这些数据,它在这些数据上的表现会特别好。正如你可能猜到的,这并不是一个可靠的衡量模型准确性的方法。

验证模型的正确方法是将数据分成两部分:一部分用于训练数据,另一部分用于测试数据。这被称为保留集。通过这种方式,我们将在模型从未见过的数据上进行测试,并将模型预测的结果与真实值进行比较。因此,我们测量的准确性要更加合理。

这种技术效果很好;然而,它也带来一个问题:通过保留一些数据,我们丧失了本可以帮助我们构建更好模型的宝贵信息。如果我们的初始数据集较小,这一点尤其明显。为了解决这个问题,我们可以使用交叉验证。通过这种方法,我们再次将数据分成两个集合。这一次,我们训练模型两次,分别使用每个集合作为训练集和测试集。你可以在下图中看到这一操作的示意图:

图 11.1 – 二折交叉验证

图 11.1 – 二折交叉验证

在操作结束时,我们得到两个准确度指标,这将使我们更好地了解模型在整个数据集上的表现。这项技术可以帮助我们在较小的测试集上进行更多的试验,如下图所示:

图 11.2 – 五折交叉验证

图 11.2 – 五折交叉验证

关于机器学习的这个简短介绍我们就讲到这里。我们仅仅触及了表面:机器学习是一个庞大而复杂的领域,专门讨论这一主题的书籍有很多。不过,这些信息足以帮助你理解我们在本章其余部分展示的基本概念。

使用 NumPy 和 pandas 操作数组

如我们在介绍中所说,许多 Python 库已经被开发出来,以帮助处理常见的数据科学任务。最基础的库可能是 NumPy 和 pandas。它们的目标是提供一套工具,用高效的方式操作大量数据,远远超过我们使用标准 Python 所能实现的功能,我们将在这一部分展示如何做到以及为什么要这样做。NumPy 和 pandas 是大多数 Python 数据科学应用的核心;因此,了解它们是你进入 Python 数据科学领域的第一步。

在开始使用它们之前,让我们解释一下为什么需要这些库。在第二章,《Python 编程特性》中,我们提到过 Python 是一种动态类型语言。这意味着解释器在运行时自动检测变量的类型,而且这个类型甚至可以在程序中发生变化。例如,你可以在 Python 中做如下操作:


$ python>>> x = 1
>>> type(x)
<class 'int'>
>>> x = "hello"
>>> type(x)
<class 'str'>

解释器能够在每次赋值时确定x的类型。

在幕后,Python 的标准实现——CPython,是用 C 语言编写的。C 语言是一种编译型且静态类型的语言。这意味着变量的类型在编译时就已经确定,并且在执行过程中不能改变。因此,在 Python 实现中,变量不仅仅包含它的值:它实际上是一个结构体,包含有关变量的信息,包括类型、大小以及它的值。

多亏了这一点,我们可以在 Python 中非常动态地操作变量。然而,这也有代价:每个变量的内存占用会显著更大,因为它需要存储所有的元数据,而不仅仅是简单的值。

这对于数据结构尤其适用。假设我们考虑一个简单的列表,例如:


$ python>>> l = [1, 2, 3, 4, 5]

列表中的每个项都是一个 Python 整数,并带有所有相关的元数据。在像 C 这样的静态类型语言中,相同的列表只会是内存中一组共享相同类型的值。

现在让我们想象一下一个大型数据集,比如我们在数据科学中经常遇到的那种:将其存储在内存中的成本将是巨大的。这正是 NumPy 的目的:提供一个强大且高效的数组结构来处理大型数据集。在底层,它使用固定类型的数组,这意味着结构中的所有元素都是相同类型的,这使得 NumPy 能够摆脱每个元素的昂贵元数据。此外,常见的算术操作,例如加法或乘法,也会更快。在使用 NumPy 操作数组 – 计算、聚合和比较这一部分中,我们将进行速度比较,以展示与标准 Python 列表的差异。

开始使用 NumPy

让我们来看看 NumPy 是如何工作的!首先需要使用以下命令安装它:


(venv) $ pip install numpy

在 Python 解释器中,我们现在可以导入该库:


(venv) $ python>>> import numpy as np

注意,按照惯例,NumPy 通常以别名 np 导入。现在让我们来发现它的基本功能!

创建数组

要使用 NumPy 创建数组,我们可以简单地使用array函数,并传入一个 Python 列表:


>>> np.array([1, 2, 3, 4, 5])array([1, 2, 3, 4, 5])

NumPy 会自动检测 Python 列表的类型。然而,我们可以通过使用dtype参数来强制指定结果类型:


>>> np.array([1, 2, 3, 4, 5], dtype=np.float64)array([1., 2., 3., 4., 5.])

所有元素都被提升为指定的类型。关键是要记住,NumPy 数组是固定类型的。这意味着每个元素将具有相同的类型,NumPy 会默默地将值转换为array类型。例如,假设我们有一个整数列表,想要插入一个浮动点值:


>>> l = np.array([1, 2, 3, 4, 5])>>> l[0] = 13.37
>>> l
array([13,  2,  3,  4,  5])

13.37的值已经被截断以适应整数类型。

如果无法将值转换为数组类型,则会引发错误。例如,让我们尝试用一个字符串改变第一个元素:


>>> l[0] = "a"Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ValueError: invalid literal for int() with base 10: 'a'

正如我们在本节介绍中所说,Python 列表对于大型数据集效率较低。这就是为什么通常使用 NumPy 函数创建数组更为高效。最常用的函数通常是以下几种:

  • np.zeros,用于创建一个填充了 0 的数组

  • np.ones,用于创建一个填充了 1 的数组

  • np.empty,用于创建一个空的数组,指定内存中的大小,但不初始化值

  • np.arange,用于创建一个包含一系列元素的数组

让我们看看它们如何工作:


>>> np.zeros(5)array([0., 0., 0., 0., 0.])
>>> np.ones(5)
array([1., 1., 1., 1., 1.])
>>> np.empty(5)
array([1., 1., 1., 1., 1.])
>>> np.arange(5)
array([0, 1, 2, 3, 4])

请注意,np.empty 的结果可能会有所不同:由于数组中的值未初始化,它们会采用当前内存块中的任何值。这个函数的主要动机是速度,它允许你快速分配内存,但不要忘记在后续填充每个元素。

默认情况下,NumPy 创建的数组使用浮点类型(float64)。同样,使用 dtype 参数,你可以强制使用其他类型:


>>> np.ones(5, dtype=np.int32)array([1, 1, 1, 1, 1], dtype=int32)

NumPy 提供了广泛的类型选择,通过为数据选择合适的类型,你可以精细地优化程序的内存消耗。你可以在官方文档中找到 NumPy 支持的所有类型列表:numpy.org/doc/stable/reference/arrays.scalars.html#sized-aliases

NumPy 还提供了一个创建随机值数组的函数:


>>> np.random.seed(0)  # Set the random seed to make examples reproducible>>> np.random.randint(10, size=5)
array([5, 0, 3, 3, 7])

第一个参数是随机值的最大范围,size 参数设置生成的值的数量。

到目前为止,我们展示了如何创建一维数组。然而,NumPy 的强大之处在于它原生支持多维数组!例如,创建一个 3 x 4 矩阵:


>>> m = np.ones((3,4))>>> m
array([[1., 1., 1., 1.],
       [1., 1., 1., 1.],
       [1., 1., 1., 1.]])

NumPy 确实创建了一个三行四列的数组!我们所做的只是将一个元组传递给 NumPy 函数来指定我们的维度。拥有这样的数组时,NumPy 为我们提供了访问数组属性的方法,用于了解数组的维度数、形状和大小:


>>> m.ndim2
>>> m.shape
(3, 4)
>>> m.size
12

访问元素和子数组

NumPy 数组紧密遵循标准 Python 语法来操作列表。因此,要访问一维数组中的元素,只需执行以下操作:


>>> l = np.arange(5)>>> l[2]
2

对于多维数组,我们只需再添加一个索引:


>>> np.random.seed(0)>>> m = np.random.randint(10, size=(3,4))
>>> m
array([[5, 0, 3, 3],
       [7, 9, 3, 5],
       [2, 4, 7, 6]])
>>> m[1][2]
3

当然,这也可以用来重新赋值元素:


>>> m[1][2] = 42>>> m
array([[ 5,  0,  3,  3],
       [ 7,  9, 42,  5],
       [ 2,  4,  7,  6]])

但这还不是全部。得益于切片语法,我们可以通过起始索引、结束索引,甚至步长来访问子数组。例如,在一维数组中,我们可以做如下操作:


>>> l = np.arange(5)>>> l
array([0, 1, 2, 3, 4])
>>> l[1:4]  # From index 1 (inclusive) to 4 (exclusive)
array([1, 2, 3])
>>> l[::2]  # Every second element
array([0, 2, 4])

这正是我们在 第二章 中看到的标准 Python 列表操作,Python 编程特性。当然,这同样适用于多维数组,每个维度都用一个切片表示:


>>> np.random.seed(0)>>> m = np.random.randint(10, size=(3,4))
>>> m
array([[5, 0, 3, 3],
       [7, 9, 3, 5],
       [2, 4, 7, 6]])
>>> m[1:, 0:2]  # From row 1 to end and column 0 to 2
array([[7, 9],
       [2, 4]])
>>> m[::, 3:]  # Every row, only last column
array([[3],
       [5],
       [6]])

你可以将这些子数组赋值给变量。然而,出于性能考虑,NumPy 默认不会复制值:它仅仅是一个 视图(或浅拷贝),即现有数据的表示。记住这一点很重要,因为如果你在视图中更改了某个值,它也会更改原始数组中的值:


>>> v = m[::, 3:]>>> v[0][0] = 42
>>> v
array([[42],
       [ 5],
       [ 6]])
>>> m
array([[ 5,  0,  3, 42],
       [ 7,  9,  3,  5],
       [ 2,  4,  7,  6]])

如果你需要对数组进行真正的 copy 操作:


>>> m2 = m[::, 3:].copy()

m2 现在是 m 的一个独立副本,m2 中的值变化不会影响 m 中的值。

现在你已经掌握了使用 NumPy 处理数组的基础知识。正如我们所见,语法与标准 Python 非常相似。使用 NumPy 时需要记住的关键点如下:

  • NumPy 数组具有固定类型,这意味着数组中的所有项都是相同的类型

  • NumPy 原生支持多维数组,并允许我们使用标准切片符号对其进行子集化

当然,NumPy 能做的远不止这些:实际上,它可以以非常高效的方式对这些数组执行常见的计算。

使用 NumPy 操作数组——计算、聚合和比较

如我们所说,NumPy 的核心是操作大型数组,并提供出色的性能和可控的内存消耗。假设我们想要计算一个大数组中每个元素的双倍。在下面的示例中,你可以看到使用标准 Python 循环实现此功能的代码:

chapter11_compare_operations.py


import numpy as npnp.random.seed(0)  # Set the random seed to make examples reproducible
m = np.random.randint(10, size=1000000)  # An array with a million of elements
def standard_double(array):
    output = np.empty(array.size)
    for i in range(array.size):
        output[i] = array[i] * 2
    return output

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter11/chapter11_compare_operations.py

我们实例化了一个包含百万个随机整数的数组。然后,我们的函数创建了一个包含每个元素双倍值的数组。基本上,我们首先实例化一个大小相同的空数组,然后遍历每个元素,设置其双倍值。

让我们来衡量这个函数的性能。在 Python 中,有一个标准模块 timeit,专门用于此目的。我们可以直接从命令行使用它,并传入我们想要测量性能的有效 Python 语句。以下命令将测量我们的大数组上 standard_double 函数的性能:


python -m timeit "from chapter11.chapter11_compare_operations import m, standard_double; standard_double(m)"1 loop, best of 5: 146 msec per loop

结果会根据你的机器有所不同,但规模应该是相同的。timeit 的作用是重复执行你的代码若干次,并测量其执行时间。在这里,我们的函数计算每个数组元素的双倍花费了大约 150 毫秒。对于现代计算机上这样简单的计算来说,这并不算特别令人印象深刻。

让我们将此操作与使用 NumPy 语法的等效操作进行比较。你可以在下一个示例中看到:

chapter11_compare_operations.py


def numpy_double(array):     return array * 2

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter11/chapter11_compare_operations.py

代码要短得多!NumPy 实现了基本的算术运算,并可以将它们应用到数组的每个元素上。通过直接将数组乘以一个值,我们实际上是在告诉 NumPy 将每个元素乘以这个值。让我们使用timeit来衡量性能:


python -m timeit "from chapter11.chapter11_compare_operations import m, numpy_double; numpy_double(m)"500 loops, best of 5: 611 usec per loop

在这里,最佳循环在 600 微秒内完成了计算!这比以前的函数快了 250 倍!我们如何解释这样的变化?在标准循环中,Python(因为其动态性质)必须在每次迭代时检查值的类型以应用正确的函数,这增加了显著的开销。而使用 NumPy,操作被延迟到一个优化和编译的循环中,在这里类型是预先知道的,这节省了大量无用的检查。

在处理大型数据集时,NumPy 数组比标准列表有更多的优势:它本地实现操作以帮助您快速进行计算。

添加和乘以数组

正如您在前面的例子中看到的那样,NumPy 支持算术运算符以在数组上执行操作。

这意味着您可以直接操作两个具有相同维度的数组:


>>> np.array([1, 2, 3]) + np.array([4, 5, 6])array([5, 7, 9])

在这种情况下,NumPy 逐元素地应用操作。但在某些情况下,如果操作数之一不是相同的形状,它也可以工作:


>>> np.array([1, 2, 3]) * 2array([2, 4, 6])

NumPy 自动理解它应该将每个元素乘以两倍。这被称为广播:NumPy“扩展”较小的数组以匹配较大数组的形状。前面的例子等同于这个例子:


>>> np.array([1, 2, 3]) * np.array([2, 2, 2])array([2, 4, 6])

注意,即使这两个例子在概念上是等效的,第一个例子在内存和计算上更加高效:NumPy 足够智能,只使用一个2值,而不需要创建一个完整的2数组。

更一般地说,如果数组的最右维度大小相同或其中一个是1,广播就会起作用。例如,我们可以将4 x 3维度的数组添加到1 x 3维度的数组中:


>>> a1 = np.ones((4, 3))>>> a1
array([[1., 1., 1.],
       [1., 1., 1.],
       [1., 1., 1.],
       [1., 1., 1.]])
>>> a2 = np.ones((1, 3))
>>> a2
array([[1., 1., 1.]])
>>> a1 + a2
array([[2., 2., 2.],
       [2., 2., 2.],
       [2., 2., 2.],
       [2., 2., 2.]])

然而,将4 x 3维度的数组添加到1 x 4维度的数组中是不可能的:


>>> a3 = np.ones((1, 4))>>> a3
array([[1., 1., 1., 1.]])
>>> a1 + a3
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ValueError: operands could not be broadcast together with shapes (4,3) (1,4)

如果这听起来复杂或令人困惑,那是正常的;在概念上理解它需要时间,特别是在三维或更高维度中。要详细了解这个概念,请花时间阅读官方文档中相关文章的详细解释:numpy.org/doc/stable/user/basics.broadcasting.html

聚合数组 - 求和、最小值、最大值、均值等等

在处理数组时,我们经常需要汇总数据以提取一些有意义的统计信息:均值、最小值、最大值等。幸运的是,NumPy 也提供了这些操作的本地支持。简单来说,它们被提供为您可以直接从数组调用的方法:


>>> np.arange(10).mean()4.5
>>> np.ones((4,4)).sum()
16.0

您可以在官方文档中找到所有聚合操作的完整列表:numpy.org/doc/stable/reference/arrays.ndarray.html#calculation

比较数组

NumPy 也实现了标准的比较运算符来比较数组。与我们在加法和乘法数组部分看到的算术运算符一样,广播规则适用。这意味着你可以将数组与单个值进行比较:


>>> l = np.array([1, 2, 3, 4])>>> l < 3
array([ True,  True, False, False])

你还可以将数组与数组进行比较,前提是它们在广播规则的基础上兼容:


>>> m = np.array(    [[1., 5., 9., 13.],
    [2., 6., 10., 14.],
    [3., 7., 11., 15.],
    [4., 8., 12., 16.]]
)
>>> m <= np.array([1, 5, 9, 13])
array([[ True,  True,  True,  True],
       [False, False, False, False],
       [False, False, False, False],
       [False, False, False, False]])

结果数组会填充每个元素的布尔比较结果。

这就是对 NumPy 的简短介绍。这个库有很多内容值得学习和探索,因此我们强烈建议你阅读官方用户指南:numpy.org/doc/stable/user/index.html

对于本书的其余部分,这些内容应该足以帮助你理解未来的示例。现在,让我们来看看一个与 NumPy 一起常被引用和使用的库:pandas。

开始使用 pandas

在上一节中,我们介绍了 NumPy 及其高效存储和处理大量数据的能力。接下来,我们将介绍另一个在数据科学中广泛使用的库:pandas。这个库是建立在 NumPy 之上的,提供了方便的数据结构,能够高效存储带有标签的行和列的大型数据集。这当然在处理大多数代表现实世界数据的数据集时非常有用,尤其是在数据科学项目中进行分析和使用时。

为了开始使用,当然,我们需要通过常见的命令安装这个库:


(venv) $ pip install pandas

安装完成后,我们可以开始在 Python 解释器中使用它:


(venv) $ python>>> import pandas as pd

就像我们将 numpy 别名为 np 一样,导入 pandas 时的约定是将其别名为 pd

使用 pandas Series 处理一维数据

我们将介绍的第一个 pandas 数据结构是 Series。这个数据结构在行为上与 NumPy 中的一维数组非常相似。要创建一个,我们只需用一个值的列表进行初始化:


>>> s = pd.Series([1, 2, 3, 4, 5])>>> s
0    1
1    2
2    3
3    4
4    5
dtype: int64

在底层,pandas 创建了一个 NumPy 数组。因此,它使用相同的数据类型来存储数据。你可以通过访问 Series 对象的 values 属性并检查其类型来验证这一点:


>>> type(s.values)<class 'numpy.ndarray'>

索引和切片的工作方式与 NumPy 完全相同:


>>> s[0]1
>>> s[1:3]
1    2
2    3
dtype: int64

到目前为止,这与常规的 NumPy 数组没有太大区别。如我们所说,pandas 的主要目的是标注数据。为了实现这一点,pandas 数据结构维护一个索引来实现数据标注。可以通过 index 属性来访问:


>>> s.indexRangeIndex(start=0, stop=5, step=1)

在这里,我们使用了一个简单的整数范围索引,但实际上我们可以使用任何自定义索引。在下一个示例中,我们创建了相同的 series,并用字母标注每个值:


>>> s = pd.Series([1, 2, 3, 4, 5], index=["a", "b", "c", "d", "e"])>>> s
a    1
b    2
c    3
d    4
e    5

Series 初始化器中的 index 参数允许我们设置标签列表。现在我们可以使用这些标签来访问相应的值:


>>> s["c"]3

惊人的是,甚至切片符号也可以与这些标签一起使用:


>>> s["b":"d"]b    2
c    3
d    4
dtype: int64

在底层,pandas 保留了索引的顺序,以便进行这样的有用标记。然而,请注意,在这种符号中,最后一个索引是包括在内的d 包含在结果中),这与标准的索引符号不同,后者的最后一个索引是排除在外的:


>>> s[1:3]b    2
c    3
dtype: int64

为了避免这两种风格的混淆,pandas 提供了两种特殊符号,明确表示你希望使用的索引风格:loc(标签符号,最后一个索引包括在内)和 iloc(标准索引符号)。你可以在官方文档中阅读更多内容:pandas.pydata.org/docs/user_guide/indexing.html#different-choices-for-indexing

Series 也可以直接通过字典来实例化:


>>> s = pd.Series({"a": 1, "b": 2, "c": 3, "d": 4, "e": 5})>>> s
a    1
b    2
c    3
d    4
e    5
dtype: int64

在这种情况下,字典的键用作标签。

当然,在实际工作中,你更可能需要处理二维(或更多!)数据集。这正是 DataFrame 的用途!

使用 pandas DataFrame 处理多维数据

大多数时候,数据集由二维数据组成,其中每一行有多个列,就像经典的电子表格应用程序一样。在 Pandas 中,DataFrame 是专为处理这类数据设计的。至于 Series,它可以处理由行和列都标注的大量数据集。

以下示例将使用一个小型数据集,表示 2018 年法国博物馆发放的票务数量(包括付费票和免费票)。假设我们有以下以两个字典形式存储的数据:


>>> paid = {"Louvre Museum": 5988065, "Orsay Museum": 1850092, "Pompidou Centre": 2620481, "National Natural History Museum": 404497}>>> free = {"Louvre Museum": 4117897, "Orsay Museum": 1436132, "Pompidou Centre": 1070337, "National Natural History Museum": 344572}

这些字典中的每个键都是行的标签。我们可以直接从这两个字典构建一个 DataFrame,方法如下:


>>> museums = pd.DataFrame({"paid": paid, "free": free})>>> museums
                                    paid     free
Louvre Museum                    5988065  4117897
Orsay Museum                     1850092  1436132
Pompidou Centre                  2620481  1070337
National Natural History Museum   404497   344572

DataFrame 初始化器接受一个字典的字典,其中键表示列的标签。

我们可以查看 index 属性,它存储了行的索引,以及 columns 属性,它存储了列的索引:


>>> museums.indexIndex(['Louvre Museum', 'Orsay Museum', 'Pompidou Centre',
       'National Natural History Museum'],
      dtype='object')
>>> museums.columns
Index(['paid', 'free'], dtype='object')

我们可以再次使用索引和切片符号来获取列或行的子集:


>>> museums["free"]Louvre Museum                      4117897
Orsay Museum                       1436132
Pompidou Centre                    1070337
National Natural History Museum     344572
Name: free, dtype: int64
>>> museums["Louvre Museum":"Orsay Museum"]
                  paid     free
Louvre Museum  5988065  4117897
Orsay Museum   1850092  1436132
>>> museums["Louvre Museum":"Orsay Museum"]["paid"]
Louvre Museum    5988065
Orsay Museum     1850092
Name: paid, dtype: int64

更强大的功能是:你可以在括号内写一个布尔条件来匹配某些数据。这种操作称为 掩码操作


>>> museums[museums["paid"] > 2000000]                    paid     free
Louvre Museum    5988065  4117897
Pompidou Centre  2620481  1070337

最后,你可以通过相同的索引符号轻松设置新的列:


>>> museums["total"] = museums["paid"] + museums["free"]>>> museums
                                    paid     free     total
Louvre Museum                    5988065  4117897  10105962
Orsay Museum                     1850092  1436132   3286224
Pompidou Centre                  2620481  1070337   3690818
National Natural History Museum   404497   344572    749069

如你所见,就像 NumPy 数组一样,pandas 完全支持在两个 DataFrame 上执行算术运算。

当然,所有基本的聚合操作都被支持,包括 meansum


>>> museums["total"].sum()17832073
>>> museums["total"].mean()
4458018.25

你可以在官方文档中找到所有可用操作的完整列表:pandas.pydata.org/pandas-docs/stable/user_guide/basics.html#descriptive-statistics

导入和导出 CSV 数据

分享数据集的一种非常常见的方式是通过 CSV 文件。这个格式非常方便,因为它只是一个简单的文本文件,每一行代表一行数据,每一列由逗号分隔。我们的简单 museums 数据集作为 CSV 文件在示例库中提供,你可以在下一个示例中看到:

museums.csv


name,paid,freeLouvre Museum,5988065,4117897
Orsay Museum,1850092,1436132
Pompidou Centre,2620481,1070337
National Natural History Museum,404497,344572

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter11/museums.csv

导入 CSV 文件是如此常见,以至于 pandas 提供了一个函数,可以直接将 CSV 文件加载到 DataFrame 中:


>>> museums = pd.read_csv("./chapter11/museums.csv", index_col=0)>>> museums
                                    paid     free
name
Louvre Museum                    5988065  4117897
Orsay Museum                     1850092  1436132
Pompidou Centre                  2620481  1070337
National Natural History Museum   404497   344572

该函数仅仅需要 CSV 文件的路径。提供了多个参数,可以精细控制操作:在这里,我们使用了 index_col 来指定应作为行标签的列的索引。你可以在官方文档中找到所有参数的列表:pandas.pydata.org/pandas-docs/stable/reference/api/pandas.read_csv.html

当然,存在相反的操作来将 DataFrame 导出为 CSV 文件:


>>> museums["total"] = museums["paid"] + museums["free"]>>> museums.to_csv("museums_with_total.csv")

我们将在这里结束对 pandas 的简短介绍。当然,我们仅仅触及了冰山一角,我们建议你通过官方用户指南了解更多:pandas.pydata.org/pandas-docs/stable/user_guide/index.html

尽管如此,你现在应该能够执行基本操作,并在大型数据集上高效地操作。在下一节中,我们将介绍 scikit-learn,它是数据科学中一个基本的 Python 工具包,你将看到它在很大程度上依赖于 NumPy 和 pandas。

使用 scikit-learn 训练模型

scikit-learn 是最广泛使用的 Python 数据科学库之一。它实现了数十种经典的机器学习模型,还提供了许多在训练过程中帮助你的工具,比如预处理方法和交叉验证。如今,你可能会听到更多现代方法的讨论,比如 PyTorch,但 scikit-learn 仍然是许多用例中一个可靠的工具。

开始之前,首先需要在你的 Python 环境中安装它:


(venv) $ pip install scikit-learn

现在我们可以开始我们的 scikit-learn 之旅!

训练模型与预测

在 scikit-learn 中,机器学习模型和算法被称为 fit,用于训练模型,以及 predict,用于在新数据上运行训练好的模型。

要尝试这个,我们将加载一个示例数据集。scikit-learn 附带了一些非常有用的玩具数据集,适合用于实验。你可以在官方文档中了解更多信息:scikit-learn.org/stable/datasets.html

在这里,我们将使用digits数据集,这是一个包含表示手写数字的像素矩阵的集合。正如你可能已经猜到的,这个数据集的目标是训练一个模型来自动识别手写数字。以下示例展示了如何加载这个数据集:

chapter11_load_digits.py


from sklearn.datasets import load_digitsdigits = load_digits()
data = digits.data
targets = digits.target
print(data[0].reshape((8, 8)))  # First handwritten digit 8 x 8 matrix
print(targets[0])  # Label of first handwritten digit

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter11/chapter11_load_digits.py

请注意,这个玩具数据集的函数是从 scikit-learn 的datasets包中导入的。load_digits函数返回一个包含数据和一些元数据的对象。

这个对象最有趣的部分是data,它包含了手写数字的像素矩阵,以及targets,它包含了这些数字的对应标签。两者都是 NumPy 数组。

为了了解这是什么样子,我们将数据中的第一个数字提取出来,并将其重新塑造成一个 8 x 8 的矩阵;这是源图像的大小。每个值表示一个灰度像素,从 0 到 16。

然后,我们打印出第一个数字的标签,即0。如果你运行这段代码,你将得到以下输出:


[[ 0.  0.  5\. 13.  9.  1.  0.  0.] [ 0.  0\. 13\. 15\. 10\. 15.  5.  0.]
 [ 0.  3\. 15.  2.  0\. 11.  8.  0.]
 [ 0.  4\. 12.  0.  0.  8.  8.  0.]
 [ 0.  5.  8.  0.  0.  9.  8.  0.]
 [ 0.  4\. 11.  0.  1\. 12.  7.  0.]
 [ 0.  2\. 14.  5\. 10\. 12.  0.  0.]
 [ 0.  0.  6\. 13\. 10.  0.  0.  0.]]
0

通过矩阵,我们可以在某种程度上猜测出零的形状。

现在,让我们尝试构建一个识别手写数字的模型。为了简单起见,我们将使用高斯朴素贝叶斯模型,这是一种经典且易于使用的算法,能够快速得到不错的结果。以下示例展示了整个过程:

chapter11_fit_predict.py


from sklearn.datasets import load_digitsfrom sklearn.metrics import accuracy_score
from sklearn.model_selection import train_test_split
from sklearn.naive_bayes import GaussianNB
digits = load_digits()
data = digits.data
targets = digits.target
# Split into training and testing sets
training_data, testing_data, training_targets, testing_targets = train_test_split(
     data, targets, random_state=0
)
# Train the model
model = GaussianNB()
model.fit(training_data, training_targets)
# Run prediction with the testing set
predicted_targets = model.predict(testing_data)
# Compute the accuracy
accuracy = accuracy_score(testing_targets, predicted_targets)
print(accuracy)

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter11/chapter11_fit_predict.py

现在我们已经加载了数据集,你可以看到我们已将其拆分为训练集和测试集。正如我们在模型验证部分提到的,这对于计算有意义的准确性评分是必不可少的,以检查我们的模型表现如何。

为此,我们可以依赖model_selection包中的train_test_split函数。它从我们的数据集中随机选择实例来形成这两个数据集。默认情况下,它将 25%的数据保留为测试集,但这可以自定义。random_state参数允许我们设置随机种子,以使示例具有可复现性。你可以在官方文档中了解更多关于此函数的信息:scikit-learn.org/stable/modules/generated/sklearn.model_selection.train_test_split.html#sklearn-model-selection-train-test-split

然后,我们必须实例化GaussianNB类。这个类是 scikit-learn 中许多机器学习估计器之一。每个估计器都有自己的一组参数,用于精细调节算法的行为。然而,scikit-learn 设计时已经为所有估计器提供了合理的默认值,因此通常建议在进行调试之前先使用默认值。

之后,我们必须调用fit方法来训练我们的模型。它需要一个参数和两个数组:第一个是实际数据,包含所有特征,第二个是相应的标签。就这样!你已经训练好了你的第一个机器学习模型!

现在,让我们看看它的表现:我们将用测试集调用模型的predict方法,以便它自动分类测试集中的数字。结果将是一个包含预测标签的新数组。

现在我们需要做的就是将其与测试集的实际标签进行比较。再次,scikit-learn 通过在metrics包中提供accuracy_score函数来提供帮助。第一个参数是实际标签,第二个是预测标签。

如果你运行这段代码,你将得到大约 83%的准确率。作为初步的尝试,这已经相当不错了!正如你所见,使用 scikit-learn 训练和运行机器学习模型的预测非常简单。

在实践中,我们常常需要在将数据输入估计器之前,先对数据进行预处理。为了避免手动按顺序执行这些步骤,scikit-learn 提出了一个便捷的功能,可以自动化这个过程:管道

使用管道链式连接预处理器和估计器

很多时候,你需要对数据进行预处理,以便它可以被你希望使用的估计器所使用。通常,你会希望将图像转换为像素值的数组,或者正如我们在下一个例子中看到的那样,将原始文本转换为数值,以便我们可以对其进行数学处理。

为了避免手动编写这些步骤,scikit-learn 提供了一个功能,可以自动链式连接预处理器和估计器:管道。一旦创建,它们会暴露出与任何其他估计器相同的接口,允许你在一次操作中进行训练和预测。

为了展示这是什么样的,我们来看一个经典数据集的例子——20 newsgroups文本数据集。它包含 18,000 篇新闻组文章,分类为 20 个主题。这个数据集的目标是构建一个模型,自动将文章归类到其中一个主题。

以下示例展示了我们如何利用fetch_20newsgroups函数加载该数据:

chapter11_pipelines.py


import pandas as pdfrom sklearn.datasets import fetch_20newsgroups
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics import accuracy_score, confusion_matrix
from sklearn.naive_bayes import MultinomialNB
from sklearn.pipeline import make_pipeline
# Load some categories of newsgroups dataset
categories = [
     "soc.religion.christian",
     "talk.religion.misc",
     "comp.sys.mac.hardware",
     "sci.crypt",
]
newsgroups_training = fetch_20newsgroups(
     subset="train", categories=categories, random_state=0
)
newsgroups_testing = fetch_20newsgroups(
     subset="test", categories=categories, random_state=0
)

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter11/chapter11_pipelines.py

由于数据集相当大,我们只加载一部分类别。此外,请注意,数据集已经被拆分为训练集和测试集,因此我们只需通过相应的参数加载它们。你可以在官方文档中了解更多关于此数据集的功能:scikit-learn.org/stable/datasets/real_world.html#the-20-newsgroups-text-dataset

在继续之前,理解底层数据非常重要。实际上,这些是文章的原始文本。你可以通过打印数据中的一个样本来检查这一点:


>>> newsgroups_training.data[0]"From: sandvik@newton.apple.com (Kent Sandvik)\nSubject: Re: Ignorance is BLISS, was Is it good that Jesus died?\nOrganization: Cookamunga Tourist Bureau\nLines: 17\n\nIn article <f1682Ap@quack.kfu.com>, pharvey@quack.kfu.com (Paul Harvey)\nwrote:\n> In article <sandvik-170493104859@sandvik-kent.apple.com> \n> sandvik@newton.apple.com (Kent Sandvik) writes:\n> >Ignorance is not bliss!\n \n> Ignorance is STRENGTH!\n> Help spread the TRUTH of IGNORANCE!\n\nHuh, if ignorance is strength, then I won't distribute this piece\nof information if I want to follow your advice (contradiction above).\n\n\nCheers,\nKent\n---\nsandvik@newton.apple.com. ALink: KSAND -- Private activities on the net.\n"

因此,在将文本输入到估算器之前,我们需要从中提取一些特征。当处理文本数据时,常用的一种方法是使用词频-逆文档频率(TF-IDF)。不深入细节,这项技术将计算每个单词在所有文档中的出现次数(词频),并根据该单词在每个文档中的重要性加权(逆文档频率)。其目的是赋予较少出现的单词更高的权重,这些单词比“the”这类常见单词更有意义。你可以在 scikit-learn 文档中了解更多相关信息:scikit-learn.org/dev/modules/feature_extraction.html#tfidf-term-weighting

这个操作包括将文本样本中的每个单词分开并进行计数。通常,我们会应用许多技术来精细化这个过程,例如移除 TfidfVectorizer

这个预处理器可以接收一个文本数组,对每个单词进行分词,并计算每个单词的 TF-IDF。提供了很多选项来精细调整其行为,但默认设置对于英文文本来说已经是一个不错的起点。以下示例展示了如何在管道中将其与估算器一起使用:

chapter11_pipelines.py


# Make the pipelinemodel = make_pipeline(
     TfidfVectorizer(),
     MultinomialNB(),
)

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter11/chapter11_pipelines.py

make_pipeline 函数接受任意数量的预处理器和一个估算器作为参数。在这里,我们使用的是多项式朴素贝叶斯分类器,这适用于表示频率的特征。

然后,我们可以像之前那样简单地训练我们的模型并运行预测来检查其准确性。你可以在以下示例中看到这一点:

chapter11_pipelines.py


# Train the modelmodel.fit(newsgroups_training.data, newsgroups_training.target)
# Run prediction with the testing set
predicted_targets = model.predict(newsgroups_testing.data)
# Compute the accuracy
accuracy = accuracy_score(newsgroups_testing.target, predicted_targets)
print(accuracy)
# Show the confusion matrix
confusion = confusion_matrix(newsgroups_testing.target, predicted_targets)
confusion_df = pd.DataFrame(
     confusion,
     index=pd.Index(newsgroups_testing.target_names, name="True"),
     columns=pd.Index(newsgroups_testing.target_names, name="Predicted"),
)
print(confusion_df)

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter11/chapter11_pipelines.py

请注意,我们还打印了一个混淆矩阵,这是一个非常方便的全局结果表示。scikit-learn 有一个专门的函数叫做confusion_matrix。然后,我们将结果包装在 pandas DataFrame 中,以便可以设置轴标签以提高可读性。如果你运行这个例子,你将获得类似于以下截图的输出。根据你的机器和系统,可能需要几分钟时间才能完成:

图 11.3 – 20 个新闻组数据集的混淆矩阵

图 11.3 – 20 个新闻组数据集的混淆矩阵

在这里,你可以看到我们第一次尝试的结果并不差。请注意,soc.religion.christiantalk.religion.misc类别之间存在一个较大的混淆区域,这并不令人意外,考虑到它们的相似性。

如你所见,构建一个带有预处理器的流水线非常简单。这样做的好处是,它不仅会自动应用于训练数据,也会在你预测结果时使用。

在继续之前,让我们来看一下 scikit-learn 的另一个重要功能:交叉验证。

使用交叉验证验证模型

模型验证部分,我们介绍了交叉验证技术,它允许我们在训练集或测试集中使用数据。正如你可能猜到的,这项技术非常常见,scikit-learn 本身就实现了它!

让我们再看一个手写数字的例子,并应用交叉验证:

chapter11_cross_validation.py


from sklearn.datasets import load_digitsfrom sklearn.model_selection import cross_val_score
from sklearn.naive_bayes import GaussianNB
digits = load_digits()
data = digits.data
targets = digits.target
# Create the model
model = GaussianNB()
# Run cross-validation
score = cross_val_score(model, data, targets)
print(score)
print(score.mean())

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter11/chapter11_cross_validation.py

这次,我们不需要自己拆分数据:cross_val_score函数会自动执行折叠。在参数中,它期望接收估算器、data(包含手写数字的像素矩阵)和targets(包含这些数字的对应标签)。默认情况下,它会执行五次折叠。

这个操作的结果是一个数组,提供了五次折叠的准确度分数。为了获取这些结果的整体概览,我们可以取平均值。例如,如果你运行这个例子,你将获得以下输出:


python chapter11/chapter11_cross_validation.py[0.78055556 0.78333333 0.79387187 0.8718663  0.80501393]
0.8069281956050759

如你所见,我们的平均准确率大约是 80%,比我们使用单一训练集和测试集时获得的 83%稍低。这就是交叉验证的主要好处:我们能获得更为统计准确的模型性能指标。

通过这些,你已经学习了使用 scikit-learn 的基础知识。显然,这是对这个庞大框架的一个快速介绍,但它将为你提供训练和评估第一个机器学习模型的钥匙。

总结

恭喜!你已经掌握了机器学习的基本概念,并用数据科学家的基础工具包进行了第一次实验。现在,你应该能够在 Python 中探索你的第一个数据科学问题。当然,这绝不是一节完整的机器学习课程:这个领域广阔,还有大量的算法和技术等待探索。不过,我希望这已经激发了你的好奇心,并且你会深入学习这个领域的知识。

现在,是时候回到 FastAPI 了!借助我们手头的新 ML 工具,我们将能够利用 FastAPI 的强大功能来服务我们的估算器,并为用户提供一个可靠高效的预测 API。

第十二章:使用 FastAPI 创建高效的预测 API 端点

在上一章中,我们介绍了 Python 社区中广泛使用的最常见的数据科学技术和库。得益于这些工具,我们现在可以构建能够高效预测和分类数据的机器学习模型。当然,我们现在需要考虑一个便捷的接口,以便能够充分利用它们的智能。这样,微服务或前端应用程序就可以请求我们的模型进行预测,从而改善用户体验或业务运营。在本章中,我们将学习如何使用 FastAPI 实现这一点。

正如我们在本书中看到的,FastAPI 允许我们使用清晰而轻量的语法实现非常高效的 REST API。在本章中,你将学习如何以最有效的方式使用它们,以便处理成千上万的预测请求。为了帮助我们完成这项任务,我们将引入另一个库——Joblib,它提供了帮助我们序列化已训练模型和缓存预测结果的工具。

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

  • 使用 Joblib 持久化已训练的模型

  • 实现高效的预测端点

  • 使用 Joblib 缓存结果

技术要求

本章需要你设置一个 Python 虚拟环境,就像我们在第一章中设置的那样,Python 开发环境 设置

你可以在专门的 GitHub 仓库中找到本章的所有代码示例,地址为github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter12

使用 Joblib 持久化已训练的模型

在上一章中,你学习了如何使用 scikit-learn 训练一个估计器。当构建这样的模型时,你可能会获得一个相当复杂的 Python 脚本来加载训练数据、进行预处理,并用最佳的参数集来训练模型。然而,在将模型部署到像 FastAPI 这样的 Web 应用程序时,你不希望在服务器启动时重复执行这些脚本和所有操作。相反,你需要一个现成的已训练模型表示,只需要加载并使用它即可。

这就是 Joblib 的作用。这个库旨在提供高效保存 Python 对象到磁盘的工具,例如大型数据数组或函数结果:这个操作通常被称为持久化。Joblib 已经是 scikit-learn 的一个依赖,因此我们甚至不需要安装它。实际上,scikit-learn 本身也在内部使用它来加载打包的示例数据集。

如我们所见,使用 Joblib 持久化已训练的模型只需要一行代码。

持久化已训练的模型

在这个示例中,我们使用了我们在第十一章中看到的 newsgroups 示例,Python 中的数据科学入门一节的链式预处理器和估算器部分。为了提醒一下,我们加载了newsgroups数据集中 20 个类别中的 4 个,并构建了一个模型,将新闻文章自动分类到这些类别中。一旦完成,我们将模型导出到一个名为newsgroups_model.joblib的文件中:

chapter12_dump_joblib.py


# Make the pipelinemodel = make_pipeline(
           TfidfVectorizer(),
           MultinomialNB(),
)
# Train the model
model.fit(newsgroups_training.data, newsgroups_training.target)
# Serialize the model and the target names
model_file = "newsgroups_model.joblib"
model_targets_tuple = (model, newsgroups_training.target_names)
joblib.dump(model_targets_tuple, model_file)

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter12/chapter12_dump_joblib.py

如你所见,Joblib 提供了一个名为dump的函数,它仅需要两个参数:要保存的 Python 对象和文件路径。

请注意,我们并没有单独导出model变量:相反,我们将它和类别名称target_names一起封装在一个元组中。这使得我们能够在预测完成后检索类别的实际名称,而不必重新加载训练数据集。

如果你运行这个脚本,你会看到newsgroups_model.joblib文件已被创建:


(venv) $ python chapter12/chapter12_dump_joblib.py$ ls -lh *.joblib
-rw-r--r--    1 fvoron    staff       3,0M 10 jan 08:27 newsgroups_model.joblib

注意,这个文件相当大:它超过了 3 MB!它存储了每个词在每个类别中的概率,这些概率是通过多项式朴素贝叶斯模型计算得到的。

这就是我们需要做的。这个文件现在包含了我们 Python 模型的静态表示,它将易于存储、共享和加载。现在,让我们学习如何加载它并检查我们是否可以对其进行预测。

加载导出的模型

现在我们已经有了导出的模型文件,让我们学习如何使用 Joblib 再次加载它,并检查一切是否正常工作。在下面的示例中,我们将加载位于chapter12目录下的 Joblib 导出文件,并进行预测:

chapter12_load_joblib.py


import osimport joblib
from sklearn.pipeline import Pipeline
# Load the model
model_file = os.path.join(os.path.dirname(__file__), "newsgroups_model.joblib")
loaded_model: tuple[Pipeline, list[str]] = joblib.load(model_file)
model, targets = loaded_model
# Run a prediction
p = model.predict(["computer cpu memory ram"])
print(targets[p[0]])

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter12/chapter12_load_joblib.py

在这里,我们只需要调用 Joblib 的load函数,并将导出文件的有效路径传递给它。这个函数的结果是我们导出的相同 Python 对象。在这里,它是一个包含 scikit-learn 估算器和类别列表的元组。

注意,我们添加了一些类型提示:虽然这不是必须的,但它帮助 mypy 或你使用的任何 IDE 识别加载对象的类型,并受益于类型检查和自动补全功能。

最后,我们对模型进行了预测:它是一个真正的 scikit-learn 估算器,包含所有必要的训练参数。

就这样!如你所见,Joblib 的使用非常直接。尽管如此,它是一个重要工具,用于导出你的 scikit-learn 模型,并能够在外部服务中使用这些模型,而无需重复训练阶段。现在,我们可以在 FastAPI 项目中使用这些已保存的文件。

实现一个高效的预测端点

现在我们已经有了保存和加载机器学习模型的方法,是时候在 FastAPI 项目中使用它们了。正如你所看到的,如果你跟随本书的内容进行操作,实施过程应该不会太令你惊讶。实现的主要部分是类依赖,它将处理加载模型并进行预测。如果你需要复习类依赖的内容,可以查看第五章FastAPI 中的依赖注入

开始吧!我们的示例将基于上一节中提到的newgroups模型。我们将从展示如何实现类依赖开始,这将处理加载模型并进行预测:

chapter12_prediction_endpoint.py


class PredictionInput(BaseModel):           text: str
class PredictionOutput(BaseModel):
           category: str
class NewsgroupsModel:
           model: Pipeline | None = None
           targets: list[str] | None = None
           def load_model(self) -> None:
                         """Loads the model"""
                         model_file = os.path.join(os.path.dirname(__file__), "newsgroups_model.joblib")
                         loaded_model: tuple[Pipeline, list[str]] = joblib.load(model_file)
                         model, targets = loaded_model
                         self.model = model
                         self.targets = targets
           async def predict(self, input: PredictionInput) -> PredictionOutput:
                         """Runs a prediction"""
                         if not self.model or not self.targets:
                                       raise RuntimeError("Model is not loaded")
                         prediction = self.model.predict([input.text])
                         category = self.targets[prediction[0]]
                         return PredictionOutput(category=category)

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter12/chapter12_prediction_endpoint.py

首先,我们定义了两个 Pydantic 模型:PredictionInputPredictionOutput。按照纯 FastAPI 的理念,它们将帮助我们验证请求负载并返回结构化的 JSON 响应。在这里,作为输入,我们仅期望一个包含我们想要分类的文本的text属性。作为输出,我们期望一个包含预测类别的category属性。

这个代码片段中最有趣的部分是NewsgroupsModel类。它实现了两个方法:load_modelpredict

load_model方法使用 Joblib 加载模型,如我们在上一节中所见,并将模型和目标存储在类的属性中。因此,它们将可以在predict方法中使用。

另一方面,predict方法将被注入到路径操作函数中。如你所见,它直接接受PredictionInput,这个输入将由 FastAPI 注入。在这个方法中,我们进行预测,就像我们通常在 scikit-learn 中做的那样。我们返回一个PredictionOutput对象,包含我们预测的类别。

你可能已经注意到,首先我们在进行预测之前,会检查模型及其目标是否在类属性中被分配。当然,我们需要确保在进行预测之前,load_model已经被调用。你可能会想,为什么我们不把这个逻辑放在初始化函数__init__中,这样我们可以确保模型在类实例化时就加载。这种做法完全可行,但也会带来一些问题。正如我们所看到的,我们在 FastAPI 之后立即实例化了一个NewsgroupsModel实例,以便可以在路由中使用它。如果加载逻辑放在__init__中,那么每次我们从这个文件中导入一些变量(比如app实例)时,模型都会被加载,例如在单元测试中。在大多数情况下,这样会导致不必要的 I/O 操作和内存消耗。正如我们所看到的,最好使用 FastAPI 的生命周期处理器,在应用运行时加载模型。

以下摘录展示了其余的实现,并包含处理预测的 FastAPI 路由:

chapter12_prediction_endpoint.py


newgroups_model = NewsgroupsModel()@contextlib.asynccontextmanager
async def lifespan(app: FastAPI):
           newgroups_model.load_model()
           yield
app = FastAPI(lifespan=lifespan)
@app.post("/prediction")
async def prediction(
           output: PredictionOutput = Depends(newgroups_model.predict),
) -> PredictionOutput:
           return output

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter12/chapter12_prediction_endpoint.py

正如我们之前提到的,我们创建了一个NewsgroupsModel实例,以便将其注入到路径操作函数中。此外,我们正在实现一个生命周期处理器来调用load_model。通过这种方式,我们确保在应用程序启动时加载模型,并使其随时可用。

预测端点非常简单:正如你所看到的,我们直接依赖于predict方法,它会处理注入和验证负载。我们只需要返回输出即可。

就这样!再次感谢 FastAPI,让我们的生活变得更加轻松,它让我们能够编写简单且可读的代码,即使是面对复杂的任务。我们可以像往常一样使用 Uvicorn 来运行这个应用:


(venv) $ uvicorn chapter12.chapter12_prediction_endpoint:app

现在,我们可以尝试使用 HTTPie 进行一些预测:


$ http POST http://localhost:8000/prediction text="computer cpu memory ram"HTTP/1.1 200 OK
content-length: 36
content-type: application/json
date: Tue, 10 Jan 2023 07:37:22 GMT
server: uvicorn
{
           "category": "comp.sys.mac.hardware"
}

我们的机器学习分类器已经启动!为了进一步推动这一点,让我们看看如何使用 Joblib 实现一个简单的缓存机制。

使用 Joblib 缓存结果

如果你的模型需要一定时间才能进行预测,缓存结果可能会非常有用:如果某个特定输入的预测已经完成,那么返回我们保存在磁盘上的相同结果比再次运行计算更有意义。在本节中,我们将学习如何借助 Joblib 来实现这一点。

Joblib 为我们提供了一个非常方便且易于使用的工具,因此实现起来非常简单。主要的关注点是我们应该选择标准函数还是异步函数来实现端点和依赖关系。这样,我们可以更详细地解释 FastAPI 的一些技术细节。

我们将在前一节中提供的示例基础上进行构建。我们必须做的第一件事是初始化一个 Joblib 的Memory类,它是缓存函数结果的辅助工具。然后,我们可以为想要缓存的函数添加一个装饰器。你可以在以下示例中看到这一点:

chapter12_caching.py


memory = joblib.Memory(location="cache.joblib")@memory.cache(ignore=["model"])
def predict(model: Pipeline, text: str) -> int:
           prediction = model.predict([text])
           return prediction[0]

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter12/chapter12_caching.py

在初始化memory时,主要参数是location,它是 Joblib 存储结果的目录路径。Joblib 会自动将缓存结果保存在硬盘上。

然后,你可以看到我们实现了一个predict函数,它接受我们的 scikit-learn 模型和一些文本输入,并返回预测的类别索引。这与我们之前看到的预测操作相同。在这里,我们将其从NewsgroupsModel依赖类中提取出来,因为 Joblib 缓存主要是为了与常规函数一起使用的。缓存类方法并不推荐。正如你所看到的,我们只需在这个函数上方添加一个@memory.cache装饰器来启用 Joblib 缓存。

每当这个函数被调用时,Joblib 会检查它是否已经有相同参数的结果保存在磁盘上。如果有,它会直接返回该结果。否则,它会继续进行常规的函数调用。

正如你所看到的,我们为装饰器添加了一个ignore参数,这允许我们告诉 Joblib 在缓存机制中忽略某些参数。这里,我们排除了model参数。Joblib 无法存储复杂对象,比如 scikit-learn 估算器。但这不是问题:因为模型在多个预测之间是不会变化的,所以我们不关心是否缓存它。如果我们对模型进行了改进并部署了一个新的模型,我们只需要清除整个缓存,这样较早的预测就会使用新的模型重新计算。

现在,我们可以调整NewsgroupsModel依赖类,使其与这个新的predict函数兼容。你可以在以下示例中看到这一点:

chapter12_caching.py


class NewsgroupsModel:           model: Pipeline | None = None
           targets: list[str] | None = None
           def load_model(self) -> None:
                         """Loads the model"""
                         model_file = os.path.join(os.path.dirname(__file__), "newsgroups_model.joblib")
                         loaded_model: tuple[Pipeline, list[str]] = joblib.load(model_file)
                         model, targets = loaded_model
                         self.model = model
                         self.targets = targets
           def predict(self, input: PredictionInput) -> PredictionOutput:
                         """Runs a prediction"""
                         if not self.model or not self.targets:
                                       raise RuntimeError("Model is not loaded")
                         prediction = predict(self.model, input.text)
                         category = self.targets[prediction]
                         return PredictionOutput(category=category)

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter12/chapter12_caching.py

predict方法中,我们调用了外部的predict函数,而不是直接在方法内部调用,并且注意将模型和输入文本作为参数传递。之后我们只需要做的就是获取对应的类别名称并构建一个PredictionOutput对象。

最后,我们有了 REST API 端点。在这里,我们添加了一个delete/cache路由,以便通过 HTTP 请求清除整个 Joblib 缓存。你可以在以下示例中看到这一点:

chapter12_caching.py


@app.post("/prediction")def prediction(
           output: PredictionOutput = Depends(newgroups_model.predict),
) -> PredictionOutput:
           return output
@app.delete("/cache", status_code=status.HTTP_204_NO_CONTENT)
def delete_cache():
           memory.clear()

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter12/chapter12_caching.py

memory对象上的clear方法会删除硬盘上所有的 Joblib 缓存文件。

我们的 FastAPI 应用程序现在正在缓存预测结果。如果你使用相同的输入发出两次请求,第二次的响应将显示缓存结果。在这个例子中,我们的模型非常快,所以你不会注意到执行时间上的差异;然而,对于更复杂的模型,这可能会变得很有趣。

选择标准函数或异步函数

你可能注意到我们已经修改了predict方法以及predictiondelete_cache路径操作函数,使它们成为标准的非异步的函数。

自从本书开始以来,我们已经向你展示了 FastAPI 如何完全拥抱异步 I/O,以及这对应用程序性能的好处。我们还推荐了能够异步工作的库,例如数据库驱动程序,以便利用这一优势。

然而,在某些情况下,这是不可能的。在这种情况下,Joblib 被实现为同步工作。然而,它执行的是长时间的 I/O 操作:它在硬盘上读取和写入缓存文件。因此,它会阻塞进程,在此期间无法响应其他请求,正如我们在第二章异步 I/O部分中所解释的那样,Python 编程特性

为了解决这个问题,FastAPI 实现了一个巧妙的机制:如果你将路径操作函数或依赖项定义为标准的、非异步的函数,它将在一个单独的线程中运行。这意味着阻塞操作,例如同步文件读取,不会阻塞主进程。从某种意义上说,我们可以说它模仿了一个异步操作。

为了理解这一点,我们将进行一个简单的实验。在以下示例中,我们构建了一个包含三个端点的虚拟 FastAPI 应用程序:

  • /fast,它直接返回响应

  • /slow-async,一个定义为async的路径操作,创建一个同步阻塞操作,需要 10 秒钟才能完成

  • /slow-sync,一个作为标准方法定义的路径操作,创建一个同步阻塞操作,需要 10 秒钟才能完成

你可以在这里查看相应的代码:

chapter12_async_not_async.py


import timefrom fastapi import FastAPI
app = FastAPI()
@app.get("/fast")
async def fast():
           return {"endpoint": "fast"}
@app.get("/slow-async")
async def slow_async():
           """Runs in the main process"""
           time.sleep(10)    # Blocking sync operation
           return {"endpoint": "slow-async"}
@app.get("/slow-sync")
def slow_sync():
           """Runs in a thread"""
           time.sleep(10)    # Blocking sync operation
           return {"endpoint": "slow-sync"}

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter12/chapter12_async_not_async.py

通过这个简单的应用程序,我们的目标是观察那些阻塞操作如何阻塞主进程。让我们使用 Uvicorn 运行这个应用程序:


(venv) $ uvicorn chapter12.chapter12_async_not_async:app

接下来,打开两个新的终端。在第一个终端中,向/``slow-async端点发出请求:


$ http GET http://localhost:8000/slow-async

在没有等待响应的情况下,在第二个终端向/``fast端点发出请求:


$ http GET http://localhost:8000/fast

您会看到,您必须等待 10 秒钟才能收到/fast端点的响应。这意味着/slow-async阻塞了进程,导致服务器无法在此期间响应其他请求。

现在,让我们在/``slow-sync端点进行相同的实验:


$ http GET http://localhost:8000/slow-sync

再次运行以下命令:


$ http GET http://localhost:8000/fast

您将立即收到/fast的响应,而无需等待/slow-sync完成。由于它被定义为标准的非异步函数,FastAPI 会在一个线程中运行它,以避免阻塞。但是请记住,将任务发送到单独的线程会带来一些开销,因此在解决当前问题时,需要考虑最佳的处理方式。

那么,在使用 FastAPI 进行开发时,如何在路径操作和依赖之间选择标准函数和异步函数呢?以下是一些经验法则:

  • 如果函数不涉及长时间的 I/O 操作(例如文件读取、网络请求等),请将其定义为async

  • 如果涉及 I/O 操作,请参考以下内容:

    • 尝试选择与异步 I/O 兼容的库,正如我们在数据库或 HTTP 客户端中看到的那样。在这种情况下,您的函数将是async

    • 如果不可能,如 Joblib 缓存的情况,请将它们定义为标准函数。FastAPI 将会在单独的线程中运行它们。

由于 Joblib 在进行 I/O 操作时是完全同步的,我们将路径操作和依赖方法切换为同步的标准方法。

在这个示例中,差异不太明显,因为 I/O 操作较小且快速。然而,如果您需要实现更慢的操作,例如将文件上传到云存储时,记得考虑这个问题。

总结

恭喜!您现在可以构建一个快速高效的 REST API 来服务您的机器学习模型。感谢 Joblib,您学会了如何将训练好的 scikit-learn 估计器保存到一个文件中,以便轻松加载并在您的应用程序中使用。我们还展示了使用 Joblib 缓存预测结果的方法。最后,我们讨论了 FastAPI 如何通过将同步操作发送到单独的线程来处理,以避免阻塞。虽然这有点技术性,但在处理阻塞 I/O 操作时,牢记这一点是很重要的。

我们的 FastAPI 之旅接近尾声。在让您独立构建令人惊叹的数据科学应用之前,我们将提供三个章节,进一步推动这一进程并研究更复杂的使用案例。我们将从一个可以执行实时物体检测的应用开始,得益于 WebSocket 和计算机视觉模型。

第十三章:使用 WebSockets 和 FastAPI 实现实时物体检测系统

在上一章中,你学习了如何创建高效的 REST API 端点,用于通过训练好的机器学习模型进行预测。这种方法涵盖了很多使用场景,假设我们有一个单一的观测值需要处理。然而,在某些情况下,我们可能需要持续对一系列输入进行预测——例如,一个实时处理视频输入的物体检测系统。这正是我们将在本章中构建的内容。怎么做?如果你还记得,除了 HTTP 端点,FastAPI 还具备处理 WebSockets 端点的能力,这让我们能够发送和接收数据流。在这种情况下,浏览器会通过 WebSocket 发送来自摄像头的图像流,我们的应用程序会运行物体检测算法,并返回图像中每个检测到的物体的坐标和标签。为此任务,我们将依赖于Hugging Face,它既是一组工具,也是一个预训练 AI 模型的库。

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

  • 使用 Hugging Face 库的计算机视觉模型

  • 实现一个 HTTP 端点,执行单张图像的物体检测

  • 在 WebSocket 中从浏览器发送图像流

  • 在浏览器中显示物体检测结果

技术要求

本章你将需要一个 Python 虚拟环境,就像我们在第一章中所设置的那样,Python 开发 环境设置

你可以在这个专门的 GitHub 仓库中找到本章的所有代码示例:github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter13

使用 Hugging Face 库的计算机视觉模型

计算机视觉是一个研究和技术领域,致力于使计算机能够从数字图像或视频中提取有意义的信息,从而模拟人类视觉能力。它涉及基于统计方法或机器学习开发算法,使机器能够理解、分析和解释视觉数据。计算机视觉的一个典型应用是物体检测:一个能够在图像中检测和识别物体的系统。这正是我们将在本章中构建的系统。

为了帮助我们完成这个任务,我们将使用 Hugging Face 提供的一组工具。Hugging Face 是一家旨在让开发者能够快速、轻松地使用最新、最强大的 AI 模型的公司。为此,它构建了两个工具:

  • 一套基于机器学习库(如 PyTorch 和 TensorFlow)构建的开源 Python 工具集。我们将在本章中使用其中的一些工具。

  • 一个在线库,用于分享和下载各种机器学习任务的预训练模型,例如计算机视觉或图像生成。

你可以在其官方网站上了解更多它的功能:huggingface.co/

你会看到,这将极大地帮助我们在短时间内构建一个强大且精确的目标检测系统!首先,我们将安装项目所需的所有库:


(venv) $ pip install "transformers[torch]" Pillow

Hugging Face 的 transformers 库将允许我们下载并运行预训练的机器学习模型。请注意,我们通过可选的 torch 依赖项来安装它。Hugging Face 工具可以与 PyTorch 或 TensorFlow 一起使用,这两者都是非常强大的机器学习框架。在这里,我们选择使用 PyTorch。Pillow 是一个广泛使用的 Python 库,用于处理图像。稍后我们会看到为什么需要它。

在开始使用 FastAPI 之前,让我们先实现一个简单的脚本来运行一个目标检测算法。它包括四个主要步骤:

  1. 使用 Pillow 从磁盘加载图像。

  2. 加载一个预训练的目标检测模型。

  3. 在我们的图像上运行模型。

  4. 通过在检测到的物体周围绘制矩形来显示结果。

我们将一步一步地进行实现:

chapter13_object_detection.py


from pathlib import Pathimport torch
from PIL import Image, ImageDraw, ImageFont
from transformers import YolosForObjectDetection, YolosImageProcessor
root_directory = Path(__file__).parent.parent
picture_path = root_directory / "assets" / "coffee-shop.jpg"
image = Image.open(picture_path)

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter13/chapter13_object_detection.py

如你所见,第一步是从磁盘加载我们的图像。在这个示例中,我们使用名为 coffee-shop.jpg 的图像,该图像可以在我们的示例仓库中找到,地址是 github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/blob/main/assets/coffee-shop.jpg

chapter13_object_detection.py


image_processor = YolosImageProcessor.from_pretrained("hustvl/yolos-tiny")model = YolosForObjectDetection.from_pretrained("hustvl/yolos-tiny")

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter13/chapter13_object_detection.py

接下来,我们从 Hugging Face 加载一个模型。在这个示例中,我们选择了 YOLOS 模型。它是一种先进的目标检测方法,已在 118K 个带注释的图像上进行训练。你可以在以下 Hugging Face 文章中了解更多关于该技术的方法:huggingface.co/docs/transformers/model_doc/yolos。为了限制下载大小并节省计算机磁盘空间,我们选择使用精简版,这是原始模型的一个更轻量化版本,可以在普通机器上运行,同时保持良好的精度。这个版本在 Hugging Face 上有详细描述:huggingface.co/hustvl/yolos-tiny

请注意,我们实例化了两个东西:图像处理器模型。如果你还记得我们在第十一章《Python 中的数据科学入门》中提到的内容,你会知道我们需要一组特征来供我们的机器学习算法使用。因此,图像处理器的作用是将原始图像转换为对模型有意义的一组特征。

这正是我们在接下来的代码行中所做的:我们通过调用image_processor处理图像,创建一个inputs变量。请注意,return_tensors参数被设置为pt,因为我们选择了 PyTorch 作为我们的底层机器学习框架。然后,我们可以将这个inputs变量输入到模型中以获得outputs

chapter13_object_detection.py


inputs = image_processor(images=image, return_tensors="pt")outputs = model(**inputs)

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter13/chapter13_object_detection.py

你可能会认为这就是预测阶段的全部内容,我们现在可以展示结果了。然而,事实并非如此。此类算法的结果是一组多维矩阵,著名的post_process_object_detection操作由image_processor提供:

chapter13_object_detection.py


target_sizes = torch.tensor([image.size[::-1]])results = image_processor.post_process_object_detection(
    outputs, target_sizes=target_sizes
)[0]

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter13/chapter13_object_detection.py

这个操作的结果是一个字典,包含以下内容:

  • labels:每个检测到的物体的标签列表

  • boxes:每个检测到的物体的边界框坐标

  • scores:算法对于每个检测到的物体的置信度分数

我们需要做的就是遍历这些对象,以便利用 Pillow 绘制矩形和相应的标签。最后,我们只展示处理后的图像。请注意,我们只考虑那些得分大于0.7的物体,以减少假阳性的数量:

chapter13_object_detection.py


draw = ImageDraw.Draw(image)font_path = root_directory / "assets" / "OpenSans-ExtraBold.ttf"
font = ImageFont.truetype(str(font_path), 24)
for score, label, box in zip(results["scores"], results["labels"], results["boxes"]):
    if score > 0.7:
        box_values = box.tolist()
        label = model.config.id2label[label.item()]
        draw.rectangle(box_values, outline="red", width=5)
        draw.text(box_values[0:2], label, fill="red", font=font)
image.show()

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter13/chapter13_object_detection.py

多亏了 Pillow,我们能够绘制矩形并在检测到的物体上方添加标签。请注意,我们加载了一个自定义字体——Open Sans,它是一个可以在网上获得的开源字体:fonts.google.com/specimen/Open+Sans。让我们尝试运行这个脚本,看看结果:


(venv) $ python chapter13/chapter13_object_detection.py

首次运行时,你会看到模型被下载。根据你的计算机性能,预测过程可能需要几秒钟。完成后,生成的图像应该会自动打开,如图 13.1所示。

图 13.1 – 在示例图像上的目标检测结果

图 13.1 – 在示例图像上的目标检测结果

你可以看到,模型检测到图像中的几个人物,以及各种物体,如沙发和椅子。就这样!不到 30 行代码就能实现一个可运行的目标检测脚本!Hugging Face 使我们能够高效地利用最新 AI 技术的强大功能。

当然,我们在这一章的目标是将所有这些智能功能部署到远程服务器上,以便能够为成千上万的用户提供这一体验。再次强调,FastAPI 将是我们在这里的得力助手。

实现一个 REST 端点,用于在单张图像上执行目标检测

在使用 WebSockets 之前,我们先从简单开始,利用 FastAPI 实现一个经典的 HTTP 端点,接受图像上传并对其进行目标检测。正如你所看到的,与之前的示例的主要区别在于我们如何获取图像:不是从磁盘读取,而是通过文件上传获取,之后需要将其转换为 Pillow 图像对象。

此外,我们还将使用我们在第十二章中看到的完全相同的模式,使用 FastAPI 创建高效的预测 API 端点——也就是为我们的预测模型创建一个专门的类,该类将在生命周期处理程序中加载。

在此实现中,我们做的第一件事是定义 Pydantic 模型,以便正确地结构化我们预测模型的输出。你可以看到如下所示:

chapter13_api.py


class Object(BaseModel):    box: tuple[float, float, float, float]
    label: str
class Objects(BaseModel):
    objects: list[Object]

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter13/chapter13_api.py

我们有一个表示单个检测到的目标的模型,它包括box,一个包含四个数字的元组,描述边界框的坐标,以及label,表示检测到的物体类型。Objects模型是一个简单的结构,包含物体列表。

我们不会详细介绍模型预测类,因为它与我们在上一章和上一节中看到的非常相似。相反,我们直接关注 FastAPI 端点的实现:

chapter13_api.py


object_detection = ObjectDetection()@contextlib.asynccontextmanager
async def lifespan(app: FastAPI):
    object_detection.load_model()
    yield
app = FastAPI(lifespan=lifespan)
@app.post("/object-detection", response_model=Objects)
async def post_object_detection(image: UploadFile = File(...)) -> Objects:
    image_object = Image.open(image.file)
    return object_detection.predict(image_object)

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter13/chapter13_api.py

这里没有什么特别令人惊讶的!需要关注的重点是正确使用UploadFileFile依赖项,以便获取上传的文件。如果你需要复习这一部分内容,可以查看第三章中关于表单数据和文件上传的章节,快速开发 RESTful API 使用 FastAPI。然后,我们只需将其实例化为合适的 Pillow 图像对象,并调用我们的预测模型。

如我们所说,别忘了在生命周期处理程序中加载模型。

你可以使用常规的 Uvicorn 命令来运行这个示例:


(venv) $ uvicorn chapter13.chapter13_api:app

我们将使用上一节中看到的相同的咖啡店图片。让我们用 HTTPie 上传它到我们的端点:


$ http --form POST http://localhost:8000/object-detection image@./assets/coffee-shop.jpg{
    "objects": [
        {
            "box": [659.8709716796875, 592.8882446289062, 792.0460815429688, 840.2132568359375],
            "label": "person"
        },
        {
            "box": [873.5499267578125, 875.7918090820312, 1649.1378173828125, 1296.362548828125],
            "label": "couch"
        }
    ]
}

我们正确地得到了检测到的对象列表,每个对象都有它的边界框和标签。太棒了!我们的目标检测系统现在已经作为一个 Web 服务器可用。然而,我们的目标仍然是创建一个实时系统:借助 WebSockets,我们将能够处理图像流。

实现 WebSocket 以对图像流进行目标检测

WebSockets 的主要优势之一,正如我们在第八章中看到的,在 FastAPI 中定义用于双向交互通信的 WebSockets,是它在客户端和服务器之间打开了一个全双工通信通道。一旦连接建立,消息可以快速传递,而不需要经过 HTTP 协议的所有步骤。因此,它更适合实时传输大量数据。

这里的关键是实现一个 WebSocket 端点,能够接收图像数据并进行目标检测。这里的主要挑战是处理一个被称为背压的现象。简单来说,我们将从浏览器接收到的图像比服务器能够处理的要多,因为运行检测算法需要一定的时间。因此,我们必须使用一个有限大小的队列(或缓冲区),并在处理流时丢弃一些图像,以便接近实时地处理。

我们将逐步讲解实现过程:

app.py


async def receive(websocket: WebSocket, queue: asyncio.Queue):    while True:
        bytes = await websocket.receive_bytes()
        try:
            queue.put_nowait(bytes)
        except asyncio.QueueFull:
            pass
async def detect(websocket: WebSocket, queue: asyncio.Queue):
    while True:
        bytes = await queue.get()
        image = Image.open(io.BytesIO(bytes))
        objects = object_detection.predict(image)
        await websocket.send_json(objects.dict())

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter13/websocket_object_detection/app.py

我们定义了两个任务:receivedetect。第一个任务是等待从 WebSocket 接收原始字节,而第二个任务则执行检测并发送结果,正如我们在上一节中看到的那样。

这里的关键是使用asyncio.Queue对象。这是一个便捷的结构,允许我们在内存中排队一些数据,并以先进先出FIFO)策略来检索它。我们可以设置存储在队列中的元素数量限制:这就是我们限制处理图像数量的方式。

receive 函数接收数据并将其放入队列末尾。在使用 asyncio.Queue 时,我们有两个方法可以将新元素放入队列:putput_nowait。如果队列已满,第一个方法会等待直到队列有空间。这不是我们在这里想要的:我们希望丢弃那些无法及时处理的图像。使用 put_nowait 时,如果队列已满,会抛出 QueueFull 异常。在这种情况下,我们只需跳过并丢弃数据。

另一方面,detect 函数从队列中提取第一个消息,并在发送结果之前运行检测。请注意,由于我们直接获取的是原始图像字节,我们需要用 io.BytesIO 将它们包装起来,才能让 Pillow 处理。

WebSocket 的实现本身类似于我们在第八章中看到的内容,在 FastAPI 中定义 WebSocket 进行双向交互通信。我们正在调度这两个任务并等待其中一个任务停止。由于它们都运行一个无限循环,因此当 WebSocket 断开连接时,这个情况会发生:

app.py


@app.websocket("/object-detection")async def ws_object_detection(websocket: WebSocket):
    await websocket.accept()
    queue: asyncio.Queue = asyncio.Queue(maxsize=1)
    receive_task = asyncio.create_task(receive(websocket, queue))
    detect_task = asyncio.create_task(detect(websocket, queue))
    try:
        done, pending = await asyncio.wait(
            {receive_task, detect_task},
            return_when=asyncio.FIRST_COMPLETED,
        )
        for task in pending:
            task.cancel()
        for task in done:
            task.result()
    except WebSocketDisconnect:
        pass

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter13/websocket_object_detection/app.py

提供静态文件

如果你查看前面示例的完整实现,你会注意到我们在服务器中定义了另外两个东西:一个 index 端点,它仅返回 index.html 文件,以及一个 StaticFiles 应用,它被挂载在 /assets 路径下。这两个功能的存在是为了让我们的 FastAPI 应用直接提供 HTML 和 JavaScript 代码。这样,浏览器就能够在同一个服务器上查询这些文件。

这部分的关键点是,尽管 FastAPI 是为构建 REST API 设计的,但它同样可以完美地提供 HTML 和静态文件。

我们的后端现在已经准备好!接下来,让我们看看如何在浏览器中使用它的功能。

通过 WebSocket 从浏览器发送图像流

在本节中,我们将展示如何在浏览器中捕捉来自摄像头的图像并通过 WebSocket 发送。由于这主要涉及 JavaScript 代码,坦率来说,它有点超出了本书的范围,但它对于让应用程序正常工作是必要的。

第一步是在浏览器中启用摄像头输入,打开 WebSocket 连接,捕捉摄像头图像并通过 WebSocket 发送。基本上,它会像这样工作:通过 MediaDevices 浏览器 API,我们将能够列出设备上所有可用的摄像头输入。借此,我们将构建一个选择表单,供用户选择他们想要使用的摄像头。你可以在以下代码中看到具体的 JavaScript 实现:

script.js


window.addEventListener('DOMContentLoaded', (event) => {  const video = document.getElementById('video');
  const canvas = document.getElementById('canvas');
  const cameraSelect = document.getElementById('camera-select');
  let socket;
  // List available cameras and fill select
  navigator.mediaDevices.getUserMedia({ audio: true, video: true }).then(() => {
    navigator.mediaDevices.enumerateDevices().then((devices) => {
      for (const device of devices) {
        if (device.kind === 'videoinput' && device.deviceId) {
          const deviceOption = document.createElement('option');
          deviceOption.value = device.deviceId;
          deviceOption.innerText = device.label;
          cameraSelect.appendChild(deviceOption);
        }
      }
    });
  });

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter13/websocket_object_detection/assets/script.js

一旦用户提交表单,我们会调用一个startObjectDetection函数,并传入选定的摄像头。大部分实际的检测逻辑是在这个函数中实现的:

script.js


  // Start object detection on the selected camera on submit  document.getElementById('form-connect').addEventListener('submit', (event) => {
    event.preventDefault();
    // Close previous socket is there is one
    if (socket) {
      socket.close();
    }
    const deviceId = cameraSelect.selectedOptions[0].value;
    socket = startObjectDetection(video, canvas, deviceId);
  });
});

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter13/websocket_object_detection/assets/script.js

让我们看一下下面代码块中的startObjectDetection函数。首先,我们与 WebSocket 建立连接。连接打开后,我们可以开始从选定的摄像头获取图像流。为此,我们使用MediaDevices API 来启动视频捕获,并将输出显示在一个 HTML 的<video>元素中。你可以在 MDN 文档中阅读有关MediaDevices API 的所有细节:developer.mozilla.org/en-US/docs/Web/API/MediaDevices

script.js


const startObjectDetection = (video, canvas, deviceId) => {  const socket = new WebSocket(`ws://${location.host}/object-detection`);
  let intervalId;
  // Connection opened
  socket.addEventListener('open', function () {
    // Start reading video from device
    navigator.mediaDevices.getUserMedia({
      audio: false,
      video: {
        deviceId,
        width: { max: 640 },
        height: { max: 480 },
      },
    }).then(function (stream) {
      video.srcObject = stream;
      video.play().then(() => {
        // Adapt overlay canvas size to the video size
        canvas.width = video.videoWidth;
        canvas.height = video.videoHeight;

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter13/websocket_object_detection/assets/script.js

然后,正如下一个代码块所示,我们启动一个重复的任务,捕获来自视频输入的图像并将其发送到服务器。为了实现这一点,我们必须使用一个<canvas>元素,这是一个专门用于图形绘制的 HTML 标签。它提供了完整的 JavaScript API,允许我们以编程方式在其中绘制图像。在这里,我们可以绘制当前的视频图像,并将其转换为有效的 JPEG 字节。如果你想了解更多关于这个内容,MDN 提供了一个非常详细的<canvas>教程:developer.mozilla.org/en-US/docs/Web/API/Canvas_API/Tutorial

script.js


        // Send an image in the WebSocket every 42 ms        intervalId = setInterval(() => {
          // Create a virtual canvas to draw current video image
          const canvas = document.createElement('canvas');
          const ctx = canvas.getContext('2d');
          canvas.width = video.videoWidth;
          canvas.height = video.videoHeight;
          ctx.drawImage(video, 0, 0);
          // Convert it to JPEG and send it to the WebSocket
          canvas.toBlob((blob) => socket.send(blob), 'image/jpeg');
        }, IMAGE_INTERVAL_MS);
      });
    });
  });

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter13/websocket_object_detection/assets/script.js

请注意,我们将视频输入的大小限制为 640x480 像素,以防止上传过大的图像使服务器崩溃。此外,我们将间隔设置为每 42 毫秒执行一次(该值在IMAGE_INTERVAL_MS常量中设置),大约相当于每秒 24 帧图像。

最后,我们将事件监听器连接起来,以处理从 WebSocket 接收到的消息。它调用了drawObjects函数,我们将在下一节中详细介绍:

script.js


  // Listen for messages  socket.addEventListener('message', function (event) {
    drawObjects(video, canvas, JSON.parse(event.data));
  });
  // Stop the interval and video reading on close
  socket.addEventListener('close', function () {
    window.clearInterval(intervalId);
    video.pause();
  });
  return socket;
};

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter13/websocket_object_detection/assets/script.js

在浏览器中展示物体检测结果

现在我们能够将输入图像发送到服务器,我们需要在浏览器中展示检测结果。与我们在使用 Hugging Face 计算机视觉模型部分中展示的类似,我们将围绕检测到的对象绘制一个绿色矩形,并标注它们的标签。因此,我们需要找到一种方式,将服务器发送的矩形坐标在浏览器中绘制出来。

为了实现这一点,我们将再次使用<canvas>元素。这次,它将对用户可见,我们将使用它来绘制矩形。关键是使用 CSS 使这个元素覆盖视频:这样,矩形就会直接显示在视频和对应对象的上方。你可以在这里看到 HTML 代码:

index.html


<body>  <div class="container">
    <h1 class="my-3">Chapter 13 - Real time object detection</h1>
    <form id="form-connect">
      <div class="input-group mb-3">
        <select id="camera-select"></select>
        <button class="btn btn-success" type="submit" id="button-start">Start</button>
      </div>
    </form>
    <div class="position-relative" style="width: 640px; height: 480px;">
      <video id="video"></video>
      <canvas id="canvas" class="position-absolute top-0 start-0"></canvas>
    </div>
  </div>
  <script src="img/script.js"></script>
</body>

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter13/websocket_object_detection/index.html

我们使用了来自 Bootstrap 的 CSS 类,Bootstrap 是一个非常常见的 CSS 库,提供了很多类似这样的辅助工具。基本上,我们通过绝对定位设置了 canvas,并将其放置在左上角,这样它就能覆盖视频元素。

关键在于使用 Canvas API 根据接收到的坐标绘制矩形。这正是drawObjects函数的目的,下面的示例代码块展示了这一点:

script.js


const drawObjects = (video, canvas, objects) => {  const ctx = canvas.getContext('2d');
  ctx.width = video.videoWidth;
  ctx.height = video.videoHeight;
  ctx.beginPath();
  ctx.clearRect(0, 0, ctx.width, ctx.height);
  for (const object of objects.objects) {
    const [x1, y1, x2, y2] = object.box;
    const label = object.label;
    ctx.strokeStyle = '#49fb35';
    ctx.beginPath();
    ctx.rect(x1, y1, x2 - x1, y2 - y1);
    ctx.stroke();
    ctx.font = 'bold 16px sans-serif';
    ctx.fillStyle = '#ff0000';
    ctx.fillText(label, x1 - 5 , y1 - 5);
  }
};

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter13/websocket_object_detection/assets/script.js

使用<canvas>元素,我们可以使用 2D 上下文在对象中绘制内容。请注意,我们首先清除所有内容,以移除上次检测的矩形。然后,我们遍历所有检测到的对象,并使用给定的坐标x1y1x2y2绘制一个矩形。最后,我们会在矩形上方稍微绘制标签。

我们的系统现在完成了!图 13.2 给出了我们实现的文件结构概览。

图 13.2 – 对象检测应用结构

图 13.2 – 对象检测应用结构

现在是时候尝试一下了!我们可以使用常见的 Uvicorn 命令启动它:


(venv) $ uvicorn chapter13.websocket_object_detection.app:app

您可以通过地址http://localhost:8000在浏览器中访问应用程序。正如我们在前一部分所说,index端点将被调用并返回我们的index.html文件。

您将看到一个界面,邀请您选择要使用的摄像头,如图 13.3所示:

图 13.3 – 用于对象检测网页应用的摄像头选择

图 13.3 – 用于对象检测网页应用的摄像头选择

选择您想要使用的摄像头并点击开始。视频输出将显示出来,通过 WebSocket 开始对象检测,并且绿色矩形将会围绕检测到的对象绘制。我们在图 13.4中展示了这一过程:

图 13.4 – 运行对象检测网页应用

图 13.4 – 运行对象检测网页应用

它成功了!我们将我们 Python 系统的智能带到了用户的网页浏览器中。这只是使用 WebSockets 和机器学习算法可以实现的一种示例,但它绝对可以让您为用户创建接近实时的体验。

总结

在本章中,我们展示了 WebSockets 如何帮助我们为用户带来更具互动性的体验。得益于 Hugging Face 社区提供的预训练模型,我们能够迅速实现一个对象检测系统。接着,在 FastAPI 的帮助下,我们将其集成到一个 WebSocket 端点。最后,通过使用现代 JavaScript API,我们直接在浏览器中发送视频输入并显示算法结果。总的来说,像这样的项目乍一看可能显得很复杂,但我们看到强大的工具,如 FastAPI,能够让我们在非常短的时间内并且通过易于理解的源代码实现结果。

到目前为止,在我们的不同示例和项目中,我们假设我们使用的机器学习模型足够快,可以直接在 API 端点或 WebSocket 任务中运行。然而,情况并非总是如此。在某些情况下,算法如此复杂,以至于运行需要几分钟。如果我们直接在 API 端点内部运行这种算法,用户将不得不等待很长时间才能得到响应。这不仅会让用户感到困惑,还会迅速堵塞整个服务器,阻止其他用户使用 API。为了解决这个问题,我们需要为 API 服务器配备一个助手:一个工作者。

在下一章中,我们将研究这个挑战的一个具体例子:我们将构建我们自己的 AI 系统,从文本提示生成图像!

第十四章:使用 Stable Diffusion 模型创建分布式文本到图像 AI 系统

到目前为止,在这本书中,我们构建的 API 中所有操作都是在请求处理内部计算的。换句话说,用户必须等待服务器完成我们定义的所有操作(如请求验证、数据库查询、ML 预测等),才能收到他们的响应。然而,并非总是希望或可能要求这种行为。

典型例子是电子邮件通知。在 Web 应用程序中,我们经常需要向用户发送电子邮件,因为他们刚刚注册或执行了特定操作。为了做到这一点,服务器需要向电子邮件服务器发送请求,以便发送电子邮件。此操作可能需要几毫秒时间。如果我们在请求处理中执行此操作,响应将延迟直到我们发送电子邮件。这不是一个很好的体验,因为用户并不真正关心电子邮件是如何何时发送的。这个例子是我们通常所说的后台操作的典型例子:需要在我们的应用程序中完成的事情,但不需要直接用户交互。

另一种情况是当用户请求一个耗时的操作,在合理的时间内无法完成。这通常是复杂数据导出或重型 AI 模型的情况。在这种情况下,用户希望直接获取结果,但如果在请求处理程序中执行此操作,将会阻塞服务器进程,直到完成。如果大量用户请求这种操作,会迅速使我们的服务器无响应。此外,某些网络基础设施,如代理或 Web 客户端(如浏览器),具有非常严格的超时设置,这意味着如果响应时间过长,它们通常会取消操作。

为了解决这个问题,我们将引入一个典型的 Web 应用程序架构:web-queue-worker。正如我们将在本章中看到的,我们将把最昂贵、耗时最长的操作推迟到后台进程,即worker。为了展示这种架构的运行方式,我们将建立我们自己的 AI 系统,使用Stable Diffusion模型根据文本提示生成图像。

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

  • 使用 Stable Diffusion 模型与 Hugging Face Diffusers 生成图像的文本提示

  • 使用 Dramatiq 实现工作进程和图像生成任务

  • 存储和服务于对象存储中的文件

技术要求

对于本章,您将需要一个 Python 虚拟环境,就像我们在第一章中设置的那样,Python 开发环境设置

为了正确运行 Stable Diffusion 模型,我们建议你使用配备至少 16 GB RAM 的最新计算机,理想情况下还应配备 8 GB VRAM 的专用 GPU。对于 Mac 用户,配备 M1 Pro 或 M2 Pro 芯片的最新型号也非常适合。如果你没有这种机器,也不用担心:我们会告诉你如何以其他方式运行系统——唯一的缺点是图像生成会变慢并且效果较差。

要运行工作程序,你需要在本地计算机上运行Redis 服务器。最简单的方法是将其作为 Docker 容器运行。如果你以前从未使用过 Docker,我们建议你阅读官方文档中的入门教程,网址为docs.docker.com/get-started/。完成后,你将能够通过以下简单命令运行 Redis 服务器:


$ docker run -d --name worker-redis -p 6379:6379 redis

你可以在专用的 GitHub 仓库中找到本章的所有代码示例,地址为github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter14

使用 Stable Diffusion 从文本提示生成图像

最近,一种新一代的 AI 工具引起了全世界的关注:图像生成模型,例如 DALL-E 或 Midjourney。这些模型是在大量图像数据上进行训练的,能够从简单的文本提示中生成全新的图像。这些 AI 模型非常适合作为后台工作程序:它们的处理时间为几秒钟甚至几分钟,并且需要大量的 CPU、RAM 甚至 GPU 资源。

为了构建我们的系统,我们将依赖于 Stable Diffusion,这是一种非常流行的图像生成模型,发布于 2022 年。该模型是公开的,可以在现代游戏计算机上运行。正如我们在上一章中所做的,我们将依赖 Hugging Face 工具来下载和运行该模型。

首先,让我们安装所需的工具:


(venv) $ pip install accelerate diffusers

现在,我们已经准备好通过 Hugging Face 使用扩散模型了。

在 Python 脚本中实现模型

在下面的示例中,我们将展示一个能够实例化模型并运行图像生成的类的实现。再次提醒,我们将应用懒加载模式,使用单独的 load_modelgenerate 方法。首先,让我们专注于 load_model

text_to_image.py


class TextToImage:    pipe: StableDiffusionPipeline | None = None
    def load_model(self) -> None:
        # Enable CUDA GPU
        if torch.cuda.is_available():
            device = "cuda"
        # Enable Apple Silicon (M1) GPU
        elif torch.backends.mps.is_available():
            device = "mps"
        # Fallback to CPU
        else:
            device = "cpu"
        pipe = StableDiffusionPipeline.from_pretrained("runwayml/stable-diffusion-v1-5")
        pipe.to(device)
        self.pipe = pipe

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter14/basic/text_to_image.py

该方法的第一部分旨在根据你的计算机找到最有效的运行模型的方式。当在 GPU 上运行时,这些扩散模型的速度更快——这就是为什么我们首先检查是否有 CUDA(NVIDIA GPU)或 MPS(Apple Silicon)设备可用。如果没有,我们将退回到 CPU。

然后,我们只需创建一个由 Hugging Face 提供的StableDiffusionPipeline管道。我们只需要设置我们想要从 Hub 下载的模型。对于这个例子,我们选择了runwayml/stable-diffusion-v1-5。你可以在 Hugging Face 上找到它的详细信息:huggingface.co/runwayml/stable-diffusion-v1-5

我们现在可以专注于generate方法:

text_to_image.py


    def generate(        self,
        prompt: str,
        *,
        negative_prompt: str | None = None,
        num_steps: int = 50,
        callback: Callable[[int, int, torch.FloatTensor], None] | None = None,
    )    Image.Image:
        if not self.pipe:
            raise RuntimeError("Pipeline is not loaded")
        return self.pipe(
            prompt,
            negative_prompt=negative_prompt,
            num_inference_steps=num_steps,
            guidance_scale=9.0,
            callback=callback,
        ).images[0]

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter14/basic/text_to_image.py

你可以看到它接受四个参数:

  • prompt,当然,这是描述我们想要生成的图像的文本提示。

  • negative_prompt,这是一个可选的提示,用于告诉模型我们绝对不希望出现的内容。

  • num_steps,即模型应执行的推理步骤数。更多步骤会导致更好的图像,但每次迭代都会延迟推理。默认值50应该在速度和质量之间提供良好的平衡。

  • callback,这是一个可选的函数,它将在每次迭代步骤中被调用。这对于了解生成进度并可能执行更多逻辑(如将进度保存到数据库中)非常有用。

方法签名中的星号(*)是什么意思?

你可能已经注意到方法签名中的星号(*)。它告诉 Python,星号后面的参数应该仅作为关键字参数处理。换句话说,你只能像这样调用它们:.generate("PROMPT", negative_prompt="NEGATIVE", num_steps=10)

尽管不是必须的,但这是一种保持函数清晰且自解释的方式。如果你开发的是供其他开发者使用的类或函数,这尤其重要。

还有一种语法可以强制参数仅作为位置参数传递,方法是使用斜杠(/)符号。你可以在这里阅读更多相关内容:docs.python.org/3/whatsnew/3.8.html#positional-only-parameters

然后,我们只需要将这些参数传递给pipe。如果需要的话,还有更多的参数可以调节,但默认的参数应该会给你不错的结果。你可以在 Hugging Face 文档中找到完整的参数列表:huggingface.co/docs/diffusers/api/pipelines/stable_diffusion/text2img#diffusers.StableDiffusionPipeline.__call__。这个pipe对象能够为每个提示生成多张图像,因此该操作的结果是一个 Pillow 图像列表。这里的默认行为是生成一张图像,所以我们直接返回第一张。

就这些!再次感谢 Hugging Face,通过允许我们在几十行代码内运行最前沿的模型,真的是让我们的生活变得更轻松!

执行 Python 脚本

我们敢打赌你急于自己试一试——所以我们在示例的底部添加了一个小的main脚本:

text_to_image.py


if __name__ == "__main__":    text_to_image = TextToImage()
    text_to_image.load_model()
    def callback(step: int, _timestep, _tensor):
        print(f"🚀 Step {step}")
    image = text_to_image.generate(
        "A Renaissance castle in the Loire Valley",
        negative_prompt="low quality, ugly",
        callback=callback,
    )
    image.save("output.png")

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter14/basic/text_to_image.py

这个小脚本实例化了我们的TextToImage类,加载了模型,并在保存到磁盘之前生成了图像。我们还定义了一个虚拟回调函数,让你能看到它是如何工作的。

当你第一次运行这个脚本时,你会注意到 Hugging Face 会将几个 GB 的文件下载到你的计算机上:那就是稳定扩散模型,确实相当庞大!

然后,推理开始了。你会看到一个进度条,显示剩余的推理步骤数,并显示我们回调函数中的print语句,如图 14.1所示。

图 14.1 – 稳定扩散生成图像

图 14.1 – 稳定扩散生成图像

生成一张图像需要多长时间?

我们在不同类型的计算机上进行了多次测试。在配备 8 GB RAM 的现代 NVIDIA GPU 或 M1 Pro 芯片的 Mac 上,模型能够在大约一分钟内生成一张图像,并且内存使用合理。而在 CPU 上运行时,大约需要5 到 10 分钟,并且会占用多达 16 GB 的内存。

如果你的计算机上推理速度确实太慢,你可以尝试减少num_steps参数。

当推理完成后,你会在磁盘上找到生成的图像和你的脚本。图 14.2展示了这种结果的一个例子。不错吧?

图 14.2 – 稳定扩散图像生成结果

图 14.2 – 稳定扩散图像生成结果

现在,我们已经拥有了我们 AI 系统的基础构件。接下来,我们需要构建一个 API,供用户生成自己的图像。正如我们刚刚看到的,生成一张图像需要一些时间。正如我们在介绍中所说的,我们需要引入一个 Web 队列工作进程架构,使得这个系统既可靠又具有可扩展性。

创建 Dramatiq 工作进程并定义图像生成任务

正如我们在本章的介绍中提到的,直接在我们的 REST API 服务器上运行图像生成模型是不可行的。正如我们在上一节所见,这一操作可能需要几分钟,并消耗大量内存。为了解决这个问题,我们将定义一个独立于服务器进程的其他进程来处理图像生成任务:工作进程。本质上,工作进程可以是任何一个在后台执行任务的程序。

在 Web 开发中,这个概念通常意味着比这更多的内容。工作进程是一个持续运行在后台的进程,等待接收任务。这些任务通常由 Web 服务器发送,服务器会根据用户的操作请求执行特定的操作。

因此,我们可以看到,我们需要一个通信通道来连接 Web 服务器和工作进程。这就是队列的作用。队列会接收并堆积来自 Web 服务器的消息,然后将这些消息提供给工作进程读取。这就是 Web 队列工作进程架构。为了更好地理解这一点,图 14.4 展示了这种架构的示意图。

图 14.3 – Web 队列工作进程架构示意图

图 14.3 – Web 队列工作进程架构示意图

这是不是让你想起了什么?是的,这与我们在第八章中看到的非常相似,在处理多个 WebSocket 连接并广播消息这一节。实际上,这是同一个原理:我们通过一个中央数据源来解决有多个进程的问题。

这种架构的一个伟大特性是它非常容易扩展。试想你的应用程序取得了巨大成功,成千上万的用户想要生成图像:单个工作进程根本无法满足这种需求。事实上,我们所需要做的就是启动更多的工作进程。由于架构中有一个单独的消息代理,每个工作进程会在收到消息时进行拉取,从而实现任务的并行处理。它们甚至不需要位于同一台物理机器上。图 14.4 展示了这一点。

图 14.4 – 带有多个工作进程的 Web 队列工作进程架构

图 14.4 – 带有多个工作进程的 Web 队列工作进程架构

在 Python 中,有多种库可以帮助实现工作进程。它们提供了定义任务、将任务调度到队列中并运行进程、拉取并执行任务所需的工具。在本书中,我们将使用 Dramatiq,一个轻量级但强大且现代的后台任务处理库。正如我们在 第八章 中所做的,我们将使用 Redis 作为消息代理。

实现一个工作进程

和往常一样,我们首先安装所需的依赖项。运行以下命令:


(venv) $ pip install "dramatiq[redis]"

这将安装 Dramatiq,并安装与 Redis 代理通信所需的依赖项。

在一个最小的示例中,设置 Dramatiq 工作进程涉及两件事:

  1. 设置代理类型和 URL。

  2. 通过使用 @``dramatiq.actor 装饰器来定义任务。

它非常适合绝大多数任务,比如发送电子邮件或生成导出文件。

然而,在我们的案例中,我们需要加载庞大的 Stable Diffusion 模型。正如我们通常在 FastAPI 服务器中通过 startup 事件做的那样,我们希望只有在进程实际启动时才执行这一操作。为了使用 Dramatiq 实现这一点,我们需要实现一个中间件。它们允许我们在工作进程生命周期中的几个关键事件插入自定义逻辑,包括当工作进程启动时。

你可以在以下示例中看到我们自定义中间件的实现:

worker.py


class TextToImageMiddleware(Middleware):    def __init__(self) -> None:
        super().__init__()
        self.text_to_image = TextToImage()
    def after_process_boot(self, broker):
        self.text_to_image.load_model()
        return super().after_process_boot(broker)
text_to_image_middleware = TextToImageMiddleware()
redis_broker = RedisBroker(host="localhost")
redis_broker.add_middleware(text_to_image_middleware)
dramatiq.set_broker(redis_broker)

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter14/basic/worker.py

我们定义了一个 TextToImageMiddleware 类,它的作用是承载 TextToImage 的实例,这是我们在上一节中定义的图像生成服务。它继承自 Dramatiq 的 Middleware 类。这里的关键是 after_process_boot 方法。它是 Dramatiq 提供的事件钩子之一,允许我们插入自定义逻辑。在这里,我们告诉它在工作进程启动后加载 Stable Diffusion 模型。你可以在官方文档中查看支持的钩子列表:dramatiq.io/reference.html#middleware

接下来的几行代码让我们可以配置我们的工作进程。我们首先实例化我们自定义中间件的一个实例。然后,我们创建一个与我们选择的技术相对应的代理类;在我们的案例中是 Redis。在告诉 Dramatiq 使用它之前,我们需要将中间件添加到这个代理中。我们的工作进程现在已经完全配置好,可以连接到 Redis 代理,并在启动时加载我们的模型。

现在,让我们来看一下如何定义一个任务来生成图像:

worker.py


@dramatiq.actor()def text_to_image_task(
    prompt: str, *, negative_prompt: str | None = None, num_steps: int = 50
):
    image = text_to_image_middleware.text_to_image.generate(
        prompt, negative_prompt=negative_prompt, num_steps=num_steps
    )
    image.save(f"{uuid.uuid4()}.png")

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter14/basic/worker.py

实现是直接的:Dramatiq 任务实际上是我们用 @dramatiq.actor 装饰的普通函数。我们可以像定义其他函数一样定义参数。然而,这里有一个重要的陷阱需要避免:当我们从服务器调度任务时,参数将必须存储在队列存储中。因此,Dramatiq 会将参数内部序列化为 JSON。这意味着你的任务参数必须是可序列化的数据——你不能有任意的 Python 对象,比如类实例或函数。

函数体在将图像保存到磁盘之前,会调用我们在 text_to_image_middleware 中加载的 TextToImage 实例。为了避免文件覆盖,我们选择在这里生成一个UUID,即通用唯一标识符。它是一个大的随机字符串,保证每次生成时都是唯一的。凭借这个,我们可以安全地将其作为文件名,并确保它不会在磁盘上已存在。

这就是 worker 实现的内容。

启动 worker

我们还没有代码来调用它,但我们可以手动尝试。首先,确保你已经启动了一个 Redis 服务器,正如在技术要求部分中所解释的那样。然后,我们可以使用以下命令启动 Dramatiq worker:


(venv) $ dramatiq -p 1 -t 1 chapter14.basic.worker

Dramatiq 提供了命令行工具来启动 worker 进程。主要的位置参数是 worker 模块的点路径。这类似于我们在使用 Uvicorn 时的操作。我们还设置了两个可选参数,-p-t。它们控制 Dramatiq 启动的进程和线程的数量。默认情况下,它启动 10 个进程,每个进程有 8 个线程。这意味着将会有 80 个 worker 来拉取并执行任务。虽然这个默认配置适合常见需求,但由于两个原因,它不适用于我们的 Stable Diffusion 模型:

  • 进程中的每个线程共享相同的内存空间。这意味着,如果两个(或更多)线程尝试生成图像,它们将对内存中的同一对象进行读写操作。对于我们的模型来说,这会导致并发问题。我们说它是非线程安全的。因此,每个进程应该仅启动一个线程:这就是-t 1选项的意义所在。

  • 每个进程都应该将模型加载到内存中。这意味着,如果我们启动 8 个进程,我们将加载 8 次模型。正如我们之前所看到的,它需要相当大的内存,所以这样做可能会使你的计算机内存爆炸。为了安全起见,我们仅启动一个进程,使用-p 1选项。如果你想尝试并行化并查看我们的 worker 能否并行生成两张图像,你可以尝试-p 2来启动两个进程。但要确保你的计算机能够处理!

如果你运行前面的命令,你应该会看到类似这样的输出:


[2023-02-02 08:52:11,479] [PID 44348] [MainThread] [dramatiq.MainProcess] [INFO] Dramatiq '1.13.0' is booting up.Fetching 19 files:   0%|          | 0/19 [00:00<?, ?it/s]
Fetching 19 files: 100%|██████████| 19/19 [00:00<00:00, 13990.83it/s]
[2023-02-02 08:52:11,477] [PID 44350] [MainThread] [dramatiq.WorkerProcess(0)] [INFO] Worker process is ready for action.
[2023-02-02 08:52:11,578] [PID 44355] [MainThread] [dramatiq.ForkProcess(0)] [INFO] Fork process 'dramatiq.middleware.prometheus:_run_exposition_server' is ready for action.

你可以通过查看 Stable Diffusion 流水线的输出,检查模型文件是否已经下载,直到 worker 完全启动。这意味着它已经正确加载。

在 worker 中调度任务

现在我们可以尝试在工作线程中调度任务了。为此,我们可以启动一个 Python 交互式 Shell 并导入task函数。打开一个新的命令行并运行以下命令(确保你已启用 Python 虚拟环境):


(venv) $ python>>> from chapter14.basic.worker import text_to_image_task
>>> text_to_image_task.send("A Renaissance castle in the Loire Valley")
Message(queue_name='default', actor_name='text_to_image_task', args=('A Renaissance castle in the Loire Valley',), kwargs={}, options={'redis_message_id': '663df44a-cfc1-4f13-8457-05d8181290c1'}, message_id='bf57d112-6c20-49bc-a926-682ca43ea7ea', message_timestamp=1675324585644)

就是这样——我们在工作线程中安排了一个任务!注意我们在task函数上使用了send方法,而不是直接调用它:这是告诉 Dramatiq 将其发送到队列中的方式。

如果你回到工作线程终端,你会看到 Stable Diffusion 正在生成图像。过一会儿,你的图像将保存在磁盘上。你还可以尝试在短时间内连续发送两个任务。你会发现 Dramatiq 会一个接一个地处理它们。

干得好!我们的后台进程已经准备好,甚至能够在其中调度任务。下一步就是实现 REST API,以便用户可以自己请求图像生成。

实现 REST API

要在工作线程中调度任务,我们需要一个用户可以交互的安全接口。REST API 是一个不错的选择,因为它可以轻松集成到任何软件中,如网站或移动应用。在这一节中,我们将快速回顾一下我们实现的简单 API 端点,用于将图像生成任务发送到队列中。以下是实现代码:

api.py


class ImageGenerationInput(BaseModel):    prompt: str
    negative_prompt: str | None
    num_steps: int = Field(50, gt=0, le=50)
class ImageGenerationOutput(BaseModel):
    task_id: UUID4
app = FastAPI()
@app.post(
    "/image-generation",
    response_model=ImageGenerationOutput,
    status_code=status.HTTP_202_ACCEPTED,
)
async def post_image_generation(input: ImageGenerationInput) -> ImageGenerationOutput:
    task: Message = text_to_image_task.send(
        input.prompt, negative_prompt=input.negative_prompt, num_steps=input.num_steps
    )
    return ImageGenerationOutput(task_id=task.message_id)

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter14/basic/api.py

如果你从这本书的开头一直跟到现在,这不应该让你感到惊讶。我们已经妥善地定义了合适的 Pydantic 模型来构建和验证端点负载。然后,这些数据会直接用于发送任务到 Dramatiq,正如我们在前一节看到的那样。

在这个简单的实现中,输出仅包含消息 ID,Dramatiq 会自动为每个任务分配这个 ID。注意我们将 HTTP 状态码设置为202,表示已接受。从语义上讲,这意味着服务器已理解并接受了请求,但处理尚未完成,甚至可能还没有开始。它专门用于处理在后台进行的情况,这正是我们在这里的情况。

如果你同时启动工作线程和这个 API,你将能够通过 HTTP 调用触发图像生成。

你可能在想:这不错……但是用户怎么才能获取结果呢?他们怎么知道任务是否完成? 你说得对——我们完全没有讨论这个问题!实际上,这里有两个方面需要解决:我们如何跟踪待处理任务及其执行情况?我们如何存储并提供生成的图像?这就是下一节的内容。

将结果存储在数据库和对象存储中

在上一节中,我们展示了如何实现一个后台工作程序来执行繁重的计算,以及一个 API 来调度任务给这个工作程序。然而,我们仍然缺少两个重要方面:用户没有任何方式了解任务的进度,也无法获取最终结果。让我们来解决这个问题!

在工作程序和 API 之间共享数据

正如我们所见,工作程序是一个在后台运行的程序,执行 API 请求它做的计算。然而,工作程序并没有与 API 服务器通信的任何方式。这是预期中的:因为可能有任意数量的服务器进程,且它们甚至可能运行在不同的物理服务器上,因此进程之间不能直接通信。始终是同样的问题:需要有一个中央数据源,供进程写入和读取数据。

事实上,解决 API 和工作程序之间缺乏通信的第一种方法是使用我们用来调度任务的相同代理:工作程序可以将结果写入代理,API 可以从中读取。这在大多数后台任务库中都是可能的,包括 Dramatiq。然而,这个解决方案有一些局限性,其中最主要的是我们能保留数据的时间有限。像 Redis 这样的代理并不适合长时间可靠地存储数据。在某些时候,我们需要删除最古老的数据以限制内存使用。

然而,我们已经知道有一些东西能够高效地存储结构化数据:当然是数据库!这就是我们在这里展示的方法。通过拥有一个中央数据库,我们可以在其中存储图像生成请求和结果,这样就能在工作程序和 API 之间共享信息。为此,我们将重用我们在《第六章》的使用 SQLAlchemy ORM 与 SQL 数据库通信部分中展示的很多技巧。我们开始吧!

定义一个 SQLAlchemy 模型

第一步是定义一个 SQLAlchemy 模型来存储单个图像生成任务。你可以如下所示查看它:

models.py


class GeneratedImage(Base):    __tablename__ = "generated_images"
    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
    created_at: Mapped[datetime] = mapped_column(
        DateTime, nullable=False, default=datetime.now
    )
    progress: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
    prompt: Mapped[str] = mapped_column(Text, nullable=False)
    negative_prompt: Mapped[str | None] = mapped_column(Text, nullable=True)
    num_steps: Mapped[int] = mapped_column(Integer, nullable=False)
    file_name: Mapped[str | None] = mapped_column(String(255), nullable=True)

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter14/complete/models.py

像往常一样,我们定义一个自增的 ID 作为主键。我们还添加了 promptnegative_promptnum_steps 列,这些列对应我们传递给工作程序任务的参数。这样,我们就可以直接将 ID 传递给工作程序,它会直接从对象中获取参数。此外,这还允许我们存储并记住用于特定生成的参数。

progress 列是一个整数,用来存储当前生成任务的进度。

最后,file_name 将存储我们在系统中保存的实际文件名。我们将在下一节中关于对象存储的部分看到如何使用它。

将 API 调整为在数据库中保存图像生成任务

有了这个模型后,我们对 API 中图像生成任务的调度方式稍微做了些调整。我们不再直接将任务发送给工作进程,而是首先在数据库中创建一行数据,并将该对象的 ID 作为输入传递给工作进程任务。端点的实现如下所示:

api.py


@app.post(    "/generated-images",
    response_model=schemas.GeneratedImageRead,
    status_code=status.HTTP_201_CREATED,
)
async def create_generated_image(
    generated_image_create: schemas.GeneratedImageCreate,
    session: AsyncSession = Depends(get_async_session),
)    GeneratedImage:
    image = GeneratedImage(**generated_image_create.dict())
    session.add(image)
    await session.commit()
    text_to_image_task.send(image.id)
    return image

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter14/complete/api.py

我们不会深入讨论如何使用 SQLAlchemy ORM 在数据库中创建对象。如果你需要复习,可以参考《第六章》中的使用 SQLAlchemy ORM 与 SQL 数据库通信部分。

在这个代码片段中,主要需要注意的是我们将新创建对象的 ID 作为text_to_image_task的参数传递。正如我们稍后看到的,工作进程会从数据库中重新读取这个 ID,以检索生成参数。

该端点的响应仅仅是我们GeneratedImage模型的表示,使用了 Pydantic 架构GeneratedImageRead。因此,用户将会收到类似这样的响应:


{    "created_at": "2023-02-07T10:17:50.992822",
    "file_name": null,
    "id": 6,
    "negative_prompt": null,
    "num_steps": 50,
    "progress": 0,
    "prompt": "a sunset over a beach"
}

它展示了我们在请求中提供的提示,最重要的是,它给了一个 ID。这意味着用户将能够再次查询此特定请求以检索数据,并查看是否完成。这就是下面定义的get_generated_image端点的目的。我们不会在这里展示它,但你可以在示例仓库中阅读到它。

将工作进程调整为从数据库中读取和更新图像生成任务

你可能已经猜到,我们需要改变任务的实现,以便它能从数据库中检索对象,而不是直接读取参数。让我们一步步来进行调整。

我们做的第一件事是使用在任务参数中获得的 ID 从数据库中检索一个GeneratedImage

worker.py


@dramatiq.actor()def text_to_image_task(image_id: int):
    image = get_image(image_id)

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter14/complete/worker.py

为了实现这一点,你会看到我们使用了一个名为get_image的辅助函数。它定义在任务的上方。让我们来看一下:

worker.py


def get_image(id: int) -> GeneratedImage:    async def _get_image(id: int) -> GeneratedImage:
        async with async_session_maker() as session:
            select_query = select(GeneratedImage).where(GeneratedImage.id == id)
            result = await session.execute(select_query)
            image = result.scalar_one_or_none()
            if image is None:
                raise Exception("Image does not exist")
            return image
    return asyncio.run(_get_image(id))

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter14/complete/worker.py

它看起来可能有些奇怪,但实际上,你已经对其大部分逻辑非常熟悉了。如果你仔细观察,你会发现它定义了一个嵌套的私有函数,在其中我们定义了实际的逻辑来使用 SQLAlchemy ORM 获取和保存对象。请注意,它是异步的,并且我们在其中大量使用了异步 I/O 模式,正如本书中所展示的那样。

这正是我们需要像这样的辅助函数的原因。事实上,Dramatiq 并未原生设计为运行异步函数,因此我们需要手动使用asyncio.run来调度其执行。我们已经在第二章中看到过这个函数,那里介绍了异步 I/O。它的作用是运行异步函数并返回其结果。这就是我们如何在任务中同步调用包装函数而不出现任何问题。

其他方法也可以解决异步 I/O 问题。

我们在这里展示的方法是解决异步工作者问题最直接且稳健的方法。

另一种方法可能是为 Dramatiq 设置装饰器或中间件,使其能够原生支持运行异步函数,但这种方法复杂且容易出现 BUG。

我们也可以考虑拥有另一个同步工作的 SQLAlchemy 引擎和会话生成器。然而,这会导致代码中出现大量重复的内容。而且,如果我们有除了 SQLAlchemy 之外的其他异步函数,这也无法提供帮助。

现在,让我们回到text_to_image_task的实现:

worker.py


@dramatiq.actor()def text_to_image_task(image_id: int):
    image = get_image(image_id)
    def callback(step: int, _timestep, _tensor):
        update_progress(image, step)

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter14/complete/worker.py

我们为 Stable Diffusion 管道定义了一个callback函数。它的作用是将当前的进度保存到数据库中,针对当前的GeneratedImage。为此,我们再次使用了一个辅助函数update_progress

worker.py


def update_progress(image: GeneratedImage, step: int):    async def _update_progress(image: GeneratedImage, step: int):
        async with async_session_maker() as session:
            image.progress = int((step / image.num_steps) * 100)
            session.add(image)
            await session.commit()
    asyncio.run(_update_progress(image, step))

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter14/complete/worker.py

我们使用与get_image相同的方法来包装异步函数。

回到text_to_image_task,我们现在可以调用我们的TextToImage模型来生成图像。这与前一节中展示的调用完全相同。唯一的区别是,我们从image对象中获取参数。我们还使用 UUID 生成一个随机的文件名:

worker.py


    image_output = text_to_image_middleware.text_to_image.generate(        image.prompt,
        negative_prompt=image.negative_prompt,
        num_steps=image.num_steps,
        callback=callback,
    )
    file_name = f"{uuid.uuid4()}.png"

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter14/complete/worker.py

以下部分用于将图像上传到对象存储。我们将在下一部分中更详细地解释这一点:

worker.py


    storage = Storage()    storage.upload_image(image_output, file_name, settings.storage_bucket)

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter14/complete/worker.py

最后,我们调用另一个辅助函数update_file_name,将随机文件名保存到数据库中。它将允许我们为用户检索该文件:

worker.py


    update_file_name(image, file_name)

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter14/complete/worker.py

如你所见,这个实现的重点是我们从数据库中读取和写入GeneratedImage的信息。这就是我们如何在 API 服务器和工作进程之间进行同步。工作进程的部分就到此为止!有了这个逻辑,我们就可以从 API 调度一个图像生成任务,而工作进程则能够在设置最终文件名之前定期更新任务进度。因此,通过 API,一个简单的GET请求就能让我们看到任务的状态。

在对象存储中存储和服务文件

我们必须解决的最后一个挑战是关于存储生成的图像。我们需要一种可靠的方式来存储它们,同时让用户能够轻松地从互联网中检索它们。

传统上,Web 应用程序的处理方式非常简单。它们将文件直接存储在服务器的硬盘中,在指定的目录下,并配置其 Web 服务器,当访问某个 URL 时提供这些文件。这实际上是我们在第十三章中的 WebSocket 示例中做的:我们使用了 StaticFiles 中间件来静态地提供我们磁盘上的 JavaScript 脚本。

虽然这种方式适用于静态文件,比如每个服务器都有自己副本的 JavaScript 或 CSS 文件,但对于用户上传或后台生成的动态文件来说并不合适,尤其是在多个进程运行在不同物理机器上的复杂架构中。问题再次出现,即不同进程读取的中央数据源问题。在前面的部分,我们看到消息代理和数据库可以在多个场景中解决这个问题。而对于任意的二进制文件,无论是图像、视频还是简单的文本文件,我们需要其他解决方案。让我们来介绍对象存储

对象存储与我们日常在计算机中使用的标准文件存储有所不同,后者中的磁盘是以目录和文件的层次结构组织的。而对象存储将每个文件作为一个对象进行存储,其中包含实际数据及其所有元数据,如文件名、大小、类型和唯一 ID。这种概念化的主要好处是,它更容易将这些文件分布到多个物理机器上:我们可以将数十亿个文件存储在同一个对象存储中。从用户的角度来看,我们只需请求一个特定的文件,存储系统会负责从实际的物理磁盘加载该文件。

在云时代,这种方法显然获得了广泛的关注。2006 年,亚马逊网络服务AWS)推出了其自有实现的对象存储——Amazon S3。它为开发人员提供了几乎无限的磁盘空间,允许通过一个简单的 API 存储文件,并且价格非常低廉。Amazon S3 因其广泛的流行,其 API 成为行业事实上的标准。如今,大多数云对象存储,包括微软 Azure 或 Google Cloud 等竞争对手的存储,都与 S3 API 兼容。开源实现也应运而生,如 MinIO。这个通用的 S3 API 的主要好处是,您可以在项目中使用相同的代码和库与任何对象存储提供商进行交互,并在需要时轻松切换。

总结一下,对象存储是一种非常方便的方式,用于大规模存储和提供文件,无论有多少个进程需要访问这些数据。在本节结束时,我们项目的全球架构将像图 14.5中所示。

图 14.5 – Web-队列-工作者架构和对象存储

图 14.5 – Web-队列-工作者架构和对象存储

值得注意的是,对象存储会直接将文件提供给用户。不会有一个端点,服务器在从对象存储下载文件后再将其发送给用户。以这种方式操作并没有太大好处,即使在认证方面也是如此。我们将看到,兼容 S3 的存储具有内建的机制来保护文件不被未授权访问。

实现一个对象存储助手

那么我们开始写代码吧!我们将使用 MinIO 的 Python 客户端库,这是一个与任何兼容 S3 的存储进行交互的库。让我们先安装它:


(venv) $ pip install minio

我们现在可以实现一个类,以便手头有我们需要的所有操作。我们先从初始化器开始:

storage.py


class Storage:    def __init__(self) -> None:
        self.client = Minio(
            settings.storage_endpoint,
            access_key=settings.storage_access_key,
            secret_key=settings.storage_secret_key,
        )

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter14/complete/storage.py

在该类的初始化函数中,我们创建了一个 Minio 客户端实例。你会看到我们使用一个 settings 对象来提取存储 URL 和凭证。因此,使用环境变量就能非常轻松地切换它们。

然后我们将实现一些方法,帮助我们处理对象存储。第一个方法是 ensure_bucket

storage.py


    def ensure_bucket(self, bucket_name: str):        bucket_exists = self.client.bucket_exists(bucket_name)
        if not bucket_exists:
            self.client.make_bucket(bucket_name)

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter14/complete/storage.py

该方法的作用是确保在我们的对象存储中创建了正确的存储桶。在 S3 实现中,存储桶就像是你拥有的文件夹,你可以将文件存储在其中。你上传的每个文件都必须放入一个现有的存储桶中。

然后,我们定义了 upload_image

storage.py


    def upload_image(self, image: Image, object_name: str, bucket_name: str):        self.ensure_bucket(bucket_name)
        image_data = io.BytesIO()
        image.save(image_data, format="PNG")
        image_data.seek(0)
        image_data_length = len(image_data.getvalue())
        self.client.put_object(
            bucket_name,
            object_name,
            image_data,
            length=image_data_length,
            content_type="image/png",
        )

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter14/complete/storage.py

这是用于将图像上传到存储的。为了简化操作,该方法接受一个 Pillow Image 对象,因为这是我们在 Stable Diffusion 流水线的最后得到的结果。我们实现了一些逻辑,将这个 Image 对象转换为适合 S3 上传的原始字节流。该方法还期望接收 object_name,即存储中实际的文件名,以及 bucket_name。请注意,我们首先确保存储桶已经正确创建,然后再尝试上传文件。

最后,我们添加了 get_presigned_url 方法:

storage.py


    def get_presigned_url(        self,
        object_name: str,
        bucket_name: str,
        *,
        expires: timedelta = timedelta(days=7)
    )    str:
        return self.client.presigned_get_object(
            bucket_name, object_name, expires=expires
        )

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter14/complete/storage.py

这种方法将帮助我们安全地将文件提供给用户。出于安全原因,S3 存储中的文件默认对互联网用户不可访问。为了给予文件访问权限,我们可以执行以下任一操作:

  • 将文件设置为公开状态,这样任何拥有 URL 的人都能访问它。这个适合公开文件,但对于私密的用户文件则不适用。

  • 生成一个带有临时访问密钥的 URL。这样,我们就可以将文件访问权限提供给用户,即使 URL 被窃取,访问也会在一段时间后被撤销。这带来的巨大好处是,URL 生成发生在我们的 API 服务器上,使用 S3 客户端。因此,在生成文件 URL 之前,我们可以根据自己的逻辑检查用户是否通过身份验证,并且是否有权访问特定的文件。这就是我们在这里采用的方法,并且此方法会在特定存储桶中的特定文件上生成预签名 URL,且有效期为一定时间。

如你所见,我们的类只是 MinIO 客户端的一个薄包装。现在我们要做的就是用它来上传图像并从 API 获取预签名 URL。

在工作者中使用对象存储助手

在上一节中,我们展示了任务实现中的以下几行代码:

worker.py


    storage = Storage()    storage.upload_image(image_output, file_name, settings.storage_bucket)

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter14/complete/worker.py

现在我们已经谈到了 Storage 类,你应该能猜到我们在这里做的事情:我们获取生成的图像及其随机名称,并将其上传到 settings 中定义的存储桶。就这样!

在服务器上生成预签名 URL

在 API 端,我们实现了一个新端点,角色是返回给定 GeneratedImage 的预签名 URL:

server.py


@app.get("/generated-images/{id}/url")async def get_generated_image_url(
    image: GeneratedImage = Depends(get_generated_image_or_404),
    storage: Storage = Depends(get_storage),
)    schemas.GeneratedImageURL:
    if image.file_name is None:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="Image is not available yet. Please try again later.",
        )
    url = storage.get_presigned_url(image.file_name, settings.storage_bucket)
    return schemas.GeneratedImageURL(url=url)

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter14/complete/server.py

在生成 URL 之前,我们首先检查 GeneratedImage 对象上是否设置了 file_name 属性。如果没有,意味着工作者任务尚未完成。如果有,我们就可以继续调用 Storage 类的 get_presigned_url 方法。

请注意,我们已经定义了依赖注入来获取 Storage 实例。正如本书中所展示的那样,在处理外部服务时,FastAPI 中使用依赖是一个非常好的实践。

好的,看来我们一切准备就绪!让我们看看它如何运行。

运行图像生成系统

首先,我们需要为项目填充环境变量,特别是数据库 URL 和 S3 凭据。为了简化,我们将使用一个简单的 SQLite 数据库和 MinIO 的示例平台作为 S3 存储。MinIO 是一个免费的开源对象存储平台,非常适合示例和玩具项目。当进入生产环境时,你可以轻松切换到任何兼容 S3 的提供商。让我们在项目根目录下创建一个 .env 文件:


DATABASE_URL=sqlite+aiosqlite:///chapter14.dbSTORAGE_ENDPOINT=play.min.io
STORAGE_ACCESS_KEY=Q3AM3UQ867SPQQA43P2F
STORAGE_SECRET_KEY=zuf+tfteSlswRu7BJ86wekitnifILbZam1KYY3TG
STORAGE_BUCKET=fastapi-book-text-to-image

存储端点、访问密钥和秘钥是 MinIO 演示环境的参数。确保查看它们的官方文档,以了解自我们编写本书以来是否有所更改:min.io/docs/minio/linux/developers/python/minio-py.html#id5

我们的Settings类将自动加载此文件,以填充我们在代码中使用的设置。如果你需要复习这一概念,确保查看第十章中的设置和使用环境变量部分。

现在我们可以运行系统了。确保你的 Redis 服务器仍在运行,正如在技术要求部分所解释的那样。首先,让我们启动 FastAPI 服务器:


(venv) $ uvicorn chapter14.complete.api:app

然后,启动工作进程:


(venv) $ dramatiq -p 1 -t 1 chapter14.complete.worker

堆栈现在已准备好生成图像。让我们使用 HTTPie 发起请求,开始一个新的任务:


$ http POST http://localhost:8000/generated-images prompt="a sunset over a beach"HTTP/1.1 201 Created
content-length: 151
content-type: application/json
date: Mon, 13 Feb 2023 07:24:44 GMT
server: uvicorn
{
    "created_at": "2023-02-13T08:24:45.954240",
    "file_name": null,
    "id": 1,
    "negative_prompt": null,
    "num_steps": 50,
    "progress": 0,
    "prompt": "a sunset over a beach"
}

一个新的GeneratedImage已在数据库中创建,分配的 ID 为1。进度为0%;处理尚未开始。让我们尝试通过 API 查询它:


http GET http://localhost:8000/generated-images/1HTTP/1.1 200 OK
content-length: 152
content-type: application/json
date: Mon, 13 Feb 2023 07:25:04 GMT
server: uvicorn
{
    "created_at": "2023-02-13T08:24:45.954240",
    "file_name": null,
    "id": 1,
    "negative_prompt": null,
    "num_steps": 50,
    "progress": 36,
    "prompt": "a sunset over a beach"
}

API 返回相同的对象及其所有属性。注意,进度已更新,现在为36%。过一会儿,我们可以再次尝试相同的请求:


$ http GET http://localhost:8000/generated-images/1HTTP/1.1 200 OK
content-length: 191
content-type: application/json
date: Mon, 13 Feb 2023 07:25:34 GMT
server: uvicorn
{
    "created_at": "2023-02-13T08:24:45.954240",
    "file_name": "affeec65-5d9b-480e-ac08-000c74e22dc9.png",
    "id": 1,
    "negative_prompt": null,
    "num_steps": 50,
    "progress": 100,
    "prompt": "a sunset over a beach"
}

这次,进度为100%,文件名已经填写。图像准备好了!现在我们可以请求 API 为该图像生成一个预签名 URL:


$ http GET http://localhost:8000/generated-images/1/urlHTTP/1.1 200 OK
content-length: 366
content-type: application/json
date: Mon, 13 Feb 2023 07:29:53 GMT
server: uvicorn
{
    "url": "https://play.min.io/fastapi-book-text-to-image/affeec65-5d9b-480e-ac08-000c74e22dc9.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=Q3AM3UQ867SPQQA43P2F%2F20230213%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20230213T072954Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=6ffddb81702bed6aac50786578eb75af3c1f6a3db28e4990467c973cb3b457a9"
}

我们在 MinIO 服务器上得到了一个非常长的 URL。如果你在浏览器中打开它,你会看到刚刚由我们的系统生成的图像,如图 14.6所示。

图 14.6 – 生成的图像托管在对象存储中

图 14.6 – 生成的图像托管在对象存储中

很不错,不是吗?我们现在拥有一个功能齐全的系统,用户能够执行以下操作:

  • 请求根据他们自己的提示和参数生成图像

  • 获取请求进度的信息

  • 从可靠存储中获取生成的图像

我们在这里看到的架构已经可以在具有多台机器的云环境中部署。通常,我们可能会有一台标准的便宜服务器来提供 API 服务,而另一台则是更昂贵的服务器,配有专用 GPU 和充足的 RAM 来运行工作进程。代码无需更改就可以处理这种部署,因为进程间的通信是由中央元素——消息代理、数据库和对象存储来处理的。

总结

太棒了!你可能还没有意识到,但在这一章中,你已经学习了如何架构和实现一个非常复杂的机器学习系统,它能与你在外面看到的现有图像生成服务相媲美。我们在这里展示的概念是至关重要的,且是所有你能想象的分布式系统的核心,无论它们是设计用来运行机器学习模型、提取管道,还是数学计算。通过使用像 FastAPI 和 Dramatiq 这样的现代工具,你将能够在短时间内用最少的代码实现这种架构,最终得到一个非常快速且稳健的结果。

我们的旅程即将结束。在让你用 FastAPI 开始自己的冒险之前,我们将研究构建数据科学应用程序时的最后一个重要方面:日志记录和监控。

第十五章:监控数据科学系统的健康状况和性能

在本章中,我们将深入探讨,以便你能够构建稳健、适用于生产的系统。实现这一目标的最重要方面之一是拥有所有必要的数据,以确保系统正确运行,并尽早检测到问题,以便采取纠正措施。在本章中,我们将展示如何设置适当的日志设施,以及如何实时监控我们软件的性能和健康状况。

我们即将结束 FastAPI 数据科学之旅。到目前为止,我们主要关注的是我们实现的程序的功能。然而,还有一个方面常常被开发者忽视,但实际上非常重要:评估系统是否在生产环境中正确且可靠地运行,并在系统出现问题时尽早收到警告。

为此,存在许多工具和技术,我们可以收集尽可能多的数据,了解我们的程序如何运行。这就是我们在本章中要回顾的内容。

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

  • 配置并使用 Loguru 日志设施

  • 配置 Prometheus 指标并在 Grafana 中监控它们

  • 配置 Sentry 用于报告错误

技术要求

对于本章,你将需要一个 Python 虚拟环境,就像我们在第一章中设置的那样,Python 开发环境设置

要运行 Dramatiq 工作程序,你需要在本地计算机上运行 Redis 服务器。最简单的方式是将其作为 Docker 容器运行。如果你以前从未使用过 Docker,我们建议你阅读官方文档中的入门教程docs.docker.com/get-started/。完成后,你可以通过以下简单命令运行 Redis 服务器:


$ docker run -d --name worker-redis -p 6379:6379 redis

你可以在专门的 GitHub 仓库中找到本章的所有代码示例:github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter15

关于截图的说明

在本章中,我们将展示一些截图,特别是 Grafana 界面的截图。它们的目的是帮助你了解界面的整体布局,帮助你识别不同的部分。如果你在阅读实际内容时遇到困难,不用担心:周围的解释将帮助你找到需要关注的地方并了解该与哪些部分交互。

配置并使用 Loguru 日志设施

在软件开发中,日志可能是控制系统行为最简单但最强大的方式。它们通常由程序中特定位置打印的纯文本行组成。通过按时间顺序阅读这些日志,我们可以追踪程序的行为,确保一切顺利进行。实际上,在本书中我们已经看到过日志行。当你使用 Uvicorn 运行 FastAPI 应用并发出一些请求时,你会在控制台输出中看到这些日志行:


INFO:     Started server process [94918]INFO:     Waiting for application startup.
INFO:     Application startup complete.
INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
INFO:     127.0.0.1:60736 - "POST /generated-images HTTP/1.1" 201 Created

这些是 Uvicorn 生成的日志,告诉我们它何时启动,以及何时处理了一个请求。正如你所见,日志可以帮助我们了解程序发生了什么以及执行了哪些操作。它们还可以告诉我们何时出现问题,这可能是一个需要解决的 bug。

理解日志级别

请注意,在每个日志行之前,我们都有INFO关键字。这就是我们所说的日志级别。它是分类日志重要性的方式。一般来说,定义了以下几种级别:

  • DEBUG

  • INFO

  • WARNING

  • ERROR

你可以将其视为重要性等级DEBUG是关于程序执行的非常具体的信息,这有助于调试代码,而ERROR意味着程序中发生了问题,可能需要你采取行动。关于这些级别的好处是,我们可以配置日志记录器应输出的最小级别。即使日志函数调用仍然存在于代码中,如果它不符合最小级别,日志记录器也会忽略它。

通常,我们可以在本地开发中设置DEBUG级别,这样可以获取所有信息以帮助我们开发和修复程序。另一方面,我们可以在生产环境中将级别设置为INFOWARNING,以便只获取最重要的消息。

使用 Loguru 添加日志

使用标准库中提供的logging模块,向 Python 程序添加日志非常容易。你可以像这样做:


>>> import logging>>> logging.warning("This is my log")
WARNING:root:This is my log

如你所见,这只是一个带有字符串参数的函数调用。通常,日志模块将不同的级别作为方法暴露,就像这里的warning一样。

标准的logging模块非常强大,允许你精细定制日志的处理、打印和格式。如果你浏览官方文档中的日志教程,docs.python.org/3/howto/logging.html,你会发现它很快会变得非常复杂,甚至对于简单的情况也是如此。

这就是为什么 Python 开发者通常使用封装了logging模块并提供更友好函数和接口的库。在本章中,我们将回顾如何使用和配置Loguru,一种现代而简单的日志处理方法。

和往常一样,首先需要在我们的 Python 环境中安装它:


(venv) $ pip install loguru

我们可以立即在 Python shell 中尝试:


>>> from loguru import logger>>> logger.debug("This is my log!")
2023-02-21 08:44:00.168 | DEBUG    | __main__:<module>:1 - This is my log!

你可能会认为这与我们使用标准的 logging 模块没什么不同。然而,注意到生成的日志已经包含了时间戳、级别以及函数调用的位置。这就是 Loguru 的主要优势之一:它自带合理的默认设置,开箱即用。

让我们在一个更完整的脚本中看看它的实际效果。我们将定义一个简单的函数,检查一个整数 n 是否为奇数。我们将添加一行调试日志,让我们知道函数开始执行逻辑。然后,在计算结果之前,我们将首先检查 n 是否确实是一个整数,如果不是,就记录一个错误。这个函数的实现如下:

chapter15_logs_01.py


from loguru import loggerdef is_even(n) -> bool:
    logger.debug("Check if {n} is even", n=n)
    if not isinstance(n, int):
        logger.error("{n} is not an integer", n=n)
        raise TypeError()
    return n % 2 == 0
if __name__ == "__main__":
    is_even(2)
    is_even("hello")

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter15/chapter15_logs_01.py

如你所见,它的使用非常简单:我们只需要导入 logger 并在需要记录日志的地方调用它。还注意到我们如何可以添加变量来格式化字符串:只需要在字符串中添加大括号内的占位符,然后通过关键字参数将每个占位符映射到其值。这个语法实际上类似于标准的 str.format 方法。你可以在官方的 Python 文档中了解更多内容:docs.python.org/fr/3/library/stdtypes.html#str.format

如果我们运行这个简单的脚本,我们将在控制台输出中看到我们的日志行:


(venv) $ python chapter15/chapter15_logs_01.py2023-03-03 08:16:40.145 | DEBUG    | __main__:is_even:5 - Check if 2 is even
2023-03-03 08:16:40.145 | DEBUG    | __main__:is_even:5 - Check if hello is even
2023-03-03 08:16:40.145 | ERROR    | __main__:is_even:7 - hello is not an integer
Traceback (most recent call last):
  File "/Users/fvoron/Development/Building-Data-Science-Applications-with-FastAPI-Second-Edition/chapter15/chapter15_logs_01.py", line 14, in <module>
    is_even("hello")
  File "/Users/fvoron/Development/Building-Data-Science-Applications-with-FastAPI-Second-Edition/chapter15/chapter15_logs_01.py", line 8, in is_even
    raise TypeError()
TypeError

我们的日志行在实际抛出异常之前已经正确添加到输出中。注意,Loguru 能够准确告诉我们日志调用来自代码的哪个位置:我们有函数名和行号。

理解和配置 sinks

我们已经看到,默认情况下,日志会添加到控制台输出。默认情况下,Loguru 定义了一个指向标准错误的sink。Sink 是 Loguru 引入的一个概念,用于定义日志行应该如何由日志记录器处理。我们不限于控制台输出:我们还可以将它们保存到文件、数据库,甚至发送到 Web 服务!

好的一点是,你并不只限于使用一个 sink;你可以根据需要使用多个!然后,每个日志调用都会通过每个 sink 进行处理。你可以在图 15.1中看到这种方法的示意图。

图 15.1 – Loguru sinks 架构

图 15.1 – Loguru sinks 架构

每个sink 都与 一个日志级别 相关联。这意味着我们可以根据 sink 使用不同的日志级别。例如,我们可以选择将所有日志输出到文件中,并且只在控制台保留最重要的警告和错误日志。我们再次以之前的示例为例,使用这种方式配置 Loguru:

chapter15_logs_02.py


logger.remove()logger.add(sys.stdout, level="WARNING")
logger.add("file.log", level="DEBUG", rotation="1 day")

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter15/chapter15_logs_02.py

loggerremove方法有助于删除先前定义的接收器。当这样调用时,没有参数传递,所有定义的接收器都会被移除。通过这样做,我们可以从没有默认接收器的全新状态开始。

接着,我们调用add来定义新的接收器。第一个参数,像sys.stdout或这里的file.log,定义了日志调用应该如何处理。这个参数可以是很多东西,比如一个可调用的函数,但为了方便,Loguru 允许我们直接传递类似文件的对象,如sys.stdout,或被解释为文件名的字符串。接收器的所有方面都可以通过多个参数进行定制,尤其是日志级别。

正如我们所说,标准输出接收器只会记录至少为WARNING级别的消息,而文件接收器会记录所有消息。

请注意,我们为文件接收器添加了rotation参数。由于日志会不断附加到文件中,文件大小会在应用程序生命周期内迅速增长。因此,我们提供了一些选项供您选择:

  • “轮换”文件:这意味着当前文件将被重命名,并且新的日志会添加到一个新文件中。此操作可以配置为在一段时间后发生(例如每天,如我们的示例)或当文件达到一定大小时。

  • 删除旧文件:经过一段时间后,保留占用磁盘空间的旧日志可能就不太有用了。

您可以在 Loguru 的官方文档中阅读有关这些功能的所有详细信息:loguru.readthedocs.io/en/stable/api/logger.html#file

现在,如果我们运行这个示例,我们将在控制台输出中看到以下内容:


(venv) $ python chapter15/chapter15_logs_02.py2023-03-03 08:15:16.804 | ERROR    | __main__:is_even:12 - hello is not an integer
Traceback (most recent call last):
  File "/Users/fvoron/Development/Building-Data-Science-Applications-with-FastAPI-Second-Edition/chapter15/chapter15_logs_02.py", line 19, in <module>
    is_even("hello")
  File "/Users/fvoron/Development/Building-Data-Science-Applications-with-FastAPI-Second-Edition/chapter15/chapter15_logs_02.py", line 13, in is_even
    raise TypeError()
TypeError

DEBUG级别的日志不再出现了。然而,如果我们读取file.log文件,我们将看到两者:


$ cat file.log2023-03-03 08:15:16.803 | DEBUG    | __main__:is_even:10 - Check if 2 is even
2023-03-03 08:15:16.804 | DEBUG    | __main__:is_even:10 - Check if hello is even
2023-03-03 08:15:16.804 | ERROR    | __main__:is_even:12 - hello is not an integer

就这样!接收器非常有用,可以根据日志的性质或重要性,将日志路由到不同的位置。

日志结构化和添加上下文

在最简单的形式下,日志由自由格式的文本组成。虽然这样很方便,但我们已经看到,通常需要记录变量值,以便更好地理解发生了什么。仅用字符串时,通常会导致多个连接值拼接成的混乱字符串。

更好的处理方式是采用结构化日志记录。目标是为每个日志行提供清晰且适当的结构,这样我们就可以在不牺牲可读性的前提下嵌入所有需要的信息。Loguru 本身通过上下文支持这种方法。下一个示例展示了如何使用它:

chapter15_logs_03.py


def is_even(n) -> bool:    logger_context = logger.bind(n=n)
    logger_context.debug("Check if even")
    if not isinstance(n, int):
        logger_context.error("Not an integer")
        raise TypeError()
    return n % 2 == 0

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter15/chapter15_logs_03.py

我们再次使用之前的相同示例。如你所见,我们使用了 logger 的 bind 方法来保留额外信息。在这里,我们设置了 n 变量。这个方法返回一个新的 logger 实例,并附加了这些属性。然后,我们可以正常使用这个实例来记录日志。我们不需要在格式化字符串中再添加 n 了。

然而,如果你直接运行这个示例,你将不会在日志中看到 n 的值。这是正常的:默认情况下,Loguru 不会将上下文信息添加到格式化的日志行中。我们需要自定义它!让我们看看如何操作:

chapter15_logs_04.py


logger.add(    sys.stdout,
    level="DEBUG",
    format="<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | "
    "<level>{level: <8}</level> | "
    "<cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - <level>{message}</level>"
    " - {extra}",
)

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter15/chapter15_logs_04.py

要格式化日志输出,我们必须在配置 sink 时使用 format 参数。它期望一个模板字符串。在这里,我们复制并粘贴了默认的 Loguru 格式,并添加了一个包含 extra 变量的部分。extra 是一个字典,Loguru 在其中存储所有你在上下文中添加的值。在这里,我们只是直接输出它,这样我们就能看到所有变量。

格式语法和可用变量

你可以在 Loguru 文档中找到所有可用的变量,这些变量可以在格式字符串中输出,如 extralevel,网址为:loguru.readthedocs.io/en/stable/api/logger.html#record

格式字符串支持标准的格式化指令,这些指令对于提取值、格式化数字、填充字符串等非常有用。你可以在 Python 文档中阅读更多相关内容:docs.python.org/3/library/string.html#format-string-syntax

此外,Loguru 还添加了特殊的标记语法,你可以用它来为输出着色。你可以在这里了解更多内容:loguru.readthedocs.io/en/stable/api/logger.html#color

这次,如果你运行这个示例,你会看到额外的上下文信息已经被添加到日志行中:


(venv) $ python chapter15/chapter15_logs_04.py2023-03-03 08:30:10.905 | DEBUG    | __main__:is_even:18 - Check if even - {'n': 2}
2023-03-03 08:30:10.905 | DEBUG    | __main__:is_even:18 - Check if even - {'n': 'hello'}
2023-03-03 08:30:10.905 | ERROR    | __main__:is_even:20 - Not an integer - {'n': 'hello'}

这种方法非常方便且强大:如果你想在日志中追踪一个你关心的值,只需添加一次。

以 JSON 对象形式记录日志

另一种结构化日志的方法是将日志的所有数据序列化为一个 JSON 对象。通过在配置 sink 时设置 serialize=True,可以轻松启用此功能。如果你计划使用日志摄取服务,如 Logstash 或 Datadog,这种方法可能会很有用:它们能够解析 JSON 数据并使其可供查询。

现在你已经掌握了使用 Loguru 添加和配置日志的基本知识。接下来,让我们看看如何在 FastAPI 应用中利用它们。

配置 Loguru 作为中央日志记录器

向你的 FastAPI 应用添加日志非常有用,它能帮助你了解不同路由和依赖项中发生了什么。

让我们来看一个来自 第五章 的例子,我们在其中添加了一个全局依赖项,用于检查应该在头部设置的密钥值。在这个新版本中,我们将添加一个调试日志,以跟踪 secret_header 依赖项何时被调用,并添加一个警告日志,告知我们此密钥缺失或无效:

chapter15_logs_05.py


from loguru import loggerdef secret_header(secret_header: str | None = Header(None)) -> None:
    logger.debug("Check secret header")
    if not secret_header or secret_header != "SECRET_VALUE":
        logger.warning("Invalid or missing secret header")
        raise HTTPException(status.HTTP_403_FORBIDDEN)

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter15/chapter15_logs_05.py

如果你一直跟随我们的教程,到这里应该没有什么令人惊讶的!现在,让我们用 Uvicorn 运行这个应用,并发出一个带有无效头部的请求:


INFO:     Started server process [47073]INFO:     Waiting for application startup.
INFO:     Application startup complete.
INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
2023-03-03 09:00:47.324 | DEBUG    | chapter15.chapter15_logs_05:secret_header:6 - Check secret header
2023-03-03 09:00:47.324 | WARNING  | chapter15.chapter15_logs_05:secret_header:8 - Invalid or missing secret header
INFO:     127.0.0.1:58190 - "GET /route1 HTTP/1.1" 403 Forbidden

我们自己的日志在这里,但有一个问题:Uvicorn 也添加了它自己的日志,但是它没有遵循我们的格式!实际上,这是可以预料的:其他库,比如 Uvicorn,可能有自己的日志和设置。因此,它们不会遵循我们用 Loguru 定义的格式。这有点让人烦恼,因为如果我们有一个复杂且经过深思熟虑的设置,我们希望每个日志都能遵循它。幸运的是,还是有一些方法可以配置它。

首先,我们将创建一个名为 logger.py 的模块,在其中放置所有的日志配置。在你的项目中创建这个模块是一个很好的做法,这样你的配置就能集中在一个地方。我们在这个文件中做的第一件事是配置 Loguru:

logger.py


LOG_LEVEL = "DEBUG"logger.remove()
logger.add(
    sys.stdout,
    level=LOG_LEVEL,
    format="<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | "
    "<level>{level: <8}</level> | "
    "<cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - <level>{message}</level>"
    " - {extra}",
)

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter15/logger.py

就像我们在上一节中所做的那样,我们移除了默认的处理器并定义了我们自己的。注意,我们通过一个名为 LOG_LEVEL 的常量来设置级别。我们在这里硬编码了它,但更好的做法是从 Settings 对象中获取这个值,就像我们在 第十章 中所示的那样。这样,我们可以直接从环境变量中设置级别!

之后,我们在名为 InterceptHandler 的类中有一段相当复杂的代码。它是一个自定义处理器,针对标准日志模块,会将每个标准日志调用转发到 Loguru。这段代码直接取自 Loguru 文档。我们不会深入讲解它的工作原理,但只需要知道它会获取日志级别并遍历调用栈来获取原始调用者,然后将这些信息转发给 Loguru。

然而,最重要的部分是我们如何使用这个类。让我们在这里看看:

logger.py


logging.basicConfig(handlers=[InterceptHandler()], level=0, force=True)for uvicorn_logger_name in ["uvicorn.error", "uvicorn.access"]:
    uvicorn_logger = logging.getLogger(uvicorn_logger_name)
    uvicorn_logger.propagate = False
    uvicorn_logger.handlers = [InterceptHandler()]

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter15/logger.py

这里的技巧是调用标准日志模块中的basicConfig方法来设置我们的自定义拦截处理程序。这样,通过根日志记录器发出的每个日志调用,即使是来自外部库的,也会通过它并由 Loguru 处理。

然而,在某些情况下,这种配置是不够的。一些库定义了自己的日志记录器和处理程序,因此它们不会使用根配置。这对于 Uvicorn 来说就是这样,它定义了两个主要的日志记录器:uvicorn.erroruvicorn.access。通过获取这些日志记录器并更改其处理程序,我们强制它们也通过 Loguru。

如果你使用其他像 Uvicorn 一样定义自己日志记录器的库,你可能需要应用相同的技巧。你需要做的就是确定它们日志记录器的名称,这应该很容易在库的源代码中找到。

它与 Dramatiq 开箱即用

如果你实现了一个 Dramatiq 工作程序,正如我们在第十四章中展示的那样,你会看到,如果你使用logger模块,Dramatiq 的默认日志将会被 Loguru 正确处理。

最后,我们在模块的末尾处理设置__all__变量:

logger.py


__all__ = ["logger"]

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter15/logger.py

__all__是一个特殊变量,告诉 Python 在导入此模块时应该公开哪些变量。在这里,我们将暴露 Loguru 中的logger,以便在项目中任何需要的地方都能轻松导入它。

请记住,使用__all__并不是严格必要的:我们完全可以在没有它的情况下导入logger,但它是一种干净的方式来隐藏我们希望保持私有的其他内容,例如InterceptHandler

最后,我们可以像之前在代码中看到的那样使用它:

logger.py


from chapter15.logger import loggerdef secret_header(secret_header: str | None = Header(None))    None:
    logger.debug("Check secret header")
    if not secret_header or secret_header != "SECRET_VALUE":
        logger.warning("Invalid or missing secret header")
        raise HTTPException(status.HTTP_403_FORBIDDEN)

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter15/logger.py

如果我们用 Uvicorn 运行它,你会发现我们所有的日志现在都以相同的格式显示:


2023-03-03 09:06:16.196 | INFO     | uvicorn.server:serve:75 - Started server process [47534] - {}2023-03-03 09:06:16.196 | INFO     | uvicorn.lifespan.on:startup:47 - Waiting for application startup. - {}
2023-03-03 09:06:16.196 | INFO     | uvicorn.lifespan.on:startup:61 - Application startup complete. - {}
2023-03-03 09:06:16.196 | INFO     | uvicorn.server:_log_started_message:209 - Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit) - {}
2023-03-03 09:06:18.500 | DEBUG    | chapter15.chapter15_logs_06:secret_header:7 - Check secret header - {}
2023-03-03 09:06:18.500 | WARNING  | chapter15.chapter15_logs_06:secret_header:9 - Invalid or missing secret header - {}
2023-03-03 09:06:18.500 | INFO     | uvicorn.protocols.http.httptools_impl:send:489 - 127.0.0.1:59542 - "GET /route1 HTTP/1.1" 403 - {}

太好了!现在,每当你需要在应用程序中添加日志时,所要做的就是从logger模块中导入logger

现在,你已经掌握了将日志添加到应用程序的基本知识,并有许多选项可以微调如何以及在哪里输出日志。日志对于监控你的应用程序在微观层面上的行为非常有用,逐操作地了解它在做什么。监控的另一个重要方面是获取更一般层面的信息,以便获取大的数据图并快速发现问题。这就是我们现在要通过指标来实现的目标。

添加 Prometheus 指标

在上一节中,我们看到日志如何帮助我们通过精细地追踪程序随时间执行的操作,来理解程序的行为。然而,大多数时候,你不能整天盯着日志看:它们对于理解和调试特定情况非常有用,但对于获取全球性洞察力、在出现问题时发出警报却要差得多。

为了解决这个问题,我们将在本节中学习如何将指标添加到我们的应用程序中。它们的作用是衡量程序执行中重要的事项:发出的请求数量、响应时间、工作队列中待处理任务的数量、机器学习预测的准确性……任何我们可以轻松地随时间监控的事情——通常通过图表和图形——这样我们就能轻松监控系统的健康状况。我们称之为为应用程序添加监控

为了完成这个任务,我们将使用两种在行业中广泛使用的技术:Prometheus 和 Grafana。

理解 Prometheus 和不同的指标

Prometheus 是一种帮助你为应用程序添加监控的技术。它由三部分组成:

  • 各种编程语言的库,包括 Python,用于向应用程序添加指标

  • 一个服务器,用于聚合并存储这些指标随时间变化的值

  • 一种查询语言 PromQL,用于将这些指标中的数据提取到可视化工具中

Prometheus 对如何定义指标有非常精确的指南和约定。实际上,它定义了四种不同类型的指标。

计数器指标

计数器指标是一种衡量随着时间推移上升的值的方法。例如,这可以是已答复的请求数量或完成的预测数量。它不能用于可以下降的值。对于这种情况,有仪表指标。

图 15.2 – 计数器的可能表示

图 15.2 – 计数器的可能表示

仪表指标

仪表指标是一种衡量随着时间的推移可以上升或下降的值的方法。例如,这可以是当前的内存使用量或工作队列中待处理任务的数量。

图 15.3 – 仪表的可能表示

图 15.3 – 仪表的可能表示

直方图指标

与计数器和仪表不同,直方图将测量值并将其计入桶中。通常,如果我们想测量 API 的响应时间,我们可以统计处理时间少于 10 毫秒、少于 100 毫秒和少于 1 秒的请求数量。例如,做这个比仅获取一个简单的平均值或中位数要有洞察力得多。

使用直方图时,我们有责任定义所需的桶以及它们的值阈值。

图 15.4 – 直方图的可能表示

图 15.4 – 直方图的可能表示

Prometheus 定义了第四种类型的指标——摘要。它与直方图指标非常相似,但它使用滑动分位数而不是定义的桶。由于在 Python 中支持有限,我们不会详细介绍。此外,在本章的 Grafana 部分,我们将看到能够使用直方图指标计算分位数。

您可以在官方 Prometheus 文档中阅读有关这些指标的更多详细信息:

prometheus.io/docs/concepts/metric_types/

测量和暴露指标

一旦定义了指标,我们就可以开始在程序生命周期中进行测量。与我们记录日志的方式类似,指标暴露了方法,使我们能够在应用程序执行期间存储值。然后,Prometheus 会将这些值保存在内存中,以便构建指标。

那么,我们如何访问这些指标以便实际分析和监控呢?很简单,使用 Prometheus 的应用程序通常会暴露一个名为 /metrics 的 HTTP 端点,返回所有指标的当前值,格式是特定的。您可以在图 15.5中查看它的样子。

图 15.5 – Prometheus 指标端点的输出

图 15.5 – Prometheus 指标端点的输出

该端点可以由 Prometheus 服务器定期轮询,Prometheus 会随着时间推移存储这些指标,并通过 PromQL 提供访问。

当您的应用程序重启时,指标会被重置。

值得注意的是,每次重启应用程序时(如 FastAPI 服务器),指标值都会丢失,并且从零开始。这可能有些令人惊讶,但理解指标值仅保存在应用程序的内存中是非常重要的。永久保存它们的责任属于 Prometheus 服务器。

现在我们已经对它们的工作原理有了一个大致了解,接下来让我们看看如何将指标添加到 FastAPI 和 Dramatiq 应用程序中。

将 Prometheus 指标添加到 FastAPI

正如我们所说,Prometheus 为各种语言(包括 Python)维护了官方库。

我们完全可以单独使用它,并手动定义各种指标来监控我们的 FastAPI 应用。我们还需要编写一些逻辑,将其挂钩到 FastAPI 请求处理程序中,以便我们可以衡量诸如请求计数、响应时间、负载大小等指标。

虽然完全可以实现,但我们将采取捷径,再次依赖开源社区,它提供了一个现成的库,用于将 Prometheus 集成到 FastAPI 项目中:/metrics 端点。

首先,当然需要通过 pip 安装它。运行以下命令:


(venv) $ pip install prometheus_fastapi_instrumentator

在下面的示例中,我们实现了一个非常简单的 FastAPI 应用,并启用了仪表监控器:

chapter15_metrics_01.py


from fastapi import FastAPIfrom prometheus_fastapi_instrumentator import Instrumentator, metrics
app = FastAPI()
@app.get("/")
async def hello():
    return {"hello": "world"}
instrumentator = Instrumentator()
instrumentator.add(metrics.default())
instrumentator.instrument(app).expose(app)

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter15/chapter15_metrics_01.py

启用仪表监控器只需要三行代码:

  1. 实例化 Instrumentator 类。

  2. 启用库中提供的默认指标。

  3. 将其与我们的 FastAPI app 连接并暴露 /metrics 端点。

就这样!FastAPI 已经集成了 Prometheus!

让我们用 Uvicorn 运行这个应用并访问 hello 端点。内部,Prometheus 将会测量有关这个请求的一些信息。现在让我们访问 /metrics 来查看结果。如果你滚动查看这个长长的指标列表,你应该能看到以下几行:


# HELP http_requests_total Total number of requests by method, status and handler.# TYPE http_requests_total counter
http_requests_total{handler="/",method="GET",status="2xx"} 1.0

这是计数请求数量的指标。我们看到总共有一个请求,这对应于我们对hello的调用。请注意,仪表监控工具足够智能,可以根据路径、方法,甚至状态码为指标打上标签。这非常方便,因为它使我们能够根据请求的特征提取有趣的数据。

添加自定义指标

内置的指标是一个不错的开始,但我们可能需要根据我们应用的特定需求来定义自己的指标。

假设我们想要实现一个掷骰子的函数,骰子有六个面,并通过 REST API 暴露它。我们希望定义一个指标,允许我们计算每个面出现的次数。对于这个任务,计数器是一个很好的选择。让我们看看如何在代码中声明它:

chapter15_metrics_02.py


DICE_COUNTER = Counter(    "app_dice_rolls_total",
    "Total number of dice rolls labelled per face",
    labelnames=["face"],
)

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter15/chapter15_metrics_02.py

我们必须实例化一个 Counter 对象。前两个参数分别是指标的名称和描述。名称将由 Prometheus 用来唯一标识这个指标。因为我们想要统计每个面出现的次数,所以我们还添加了一个名为 face 的标签。每次我们统计骰子的投掷次数时,都需要将此标签设置为相应的面值。

度量命名规范

Prometheus 为度量命名定义了非常精确的规范。特别是,它应该以度量所属的领域开始,例如 http_app_,并且如果仅是一个值计数,则应该以单位结尾,例如 _seconds_bytes_total。我们强烈建议您阅读 Prometheus 的命名规范:prometheus.io/docs/practices/naming/

现在我们可以在代码中使用这个度量了。在下面的代码片段中,您将看到 roll_dice 函数的实现:

chapter15_metrics_02.py


def roll_dice() -> int:    result = random.randint(1, 6)
    DICE_COUNTER.labels(result).inc()
    return result

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter15/chapter15_metrics_02.py

您可以看到,我们直接使用度量实例 DICE_COUNTER,首先调用 labels 方法来设置骰子面数,然后调用 inc 来实际增加计数器。

这就是我们需要做的:我们的度量已经自动注册到 Prometheus 客户端,并将通过 /metrics 端点开始暴露。在 图 15.6 中,您可以看到此度量在 Grafana 中的可能可视化。

图 15.6 – 在 Grafana 中表示骰子投掷度量

图 15.6 – 在 Grafana 中表示骰子投掷度量

如您所见,声明和使用新度量是非常简单的:我们只需在想要监控的代码中直接调用它。

处理多个进程

第十章中,我们在 为部署添加 Gunicorn 作为服务器进程 部分提到过,在生产部署中,FastAPI 应用程序通常会使用多个工作进程运行。基本上,它会启动多个相同应用程序的进程,并在它们之间平衡传入的请求。这使得我们可以并发处理更多请求,避免因某个操作阻塞进程而导致的阻塞。

不要混淆 Gunicorn 工作进程和 Dramatiq 工作进程

当我们谈论 Gunicorn 部署中的工作进程时,我们指的是通过启动多个进程来并发处理 API 请求的方式。我们不是指 Dramatiq 中的工作进程,这些进程是在后台处理任务。

对于同一应用程序,拥有多个进程在 Prometheus 度量方面是有点问题的。事实上,正如我们之前提到的,这些度量仅存储在内存中,并通过 /``metrics 端点暴露。

如果我们有多个进程来处理请求,每个进程都会有自己的一组度量值。然后,当 Prometheus 服务器请求 /metrics 时,我们将获得响应我们请求的进程的度量值,而不是其他进程的度量值。这些值在下次轮询时可能会发生变化!显然,这将完全破坏我们最初的目标。

为了绕过这个问题,Prometheus 客户端有一个特殊的多进程模式。基本上,它不会将值存储在内存中,而是将它们存储在专用文件夹中的文件里。当调用 /metrics 时,它会负责加载所有文件并将所有进程的值进行合并。

启用此模式需要我们设置一个名为 PROMETHEUS_MULTIPROC_DIR 的环境变量。它应该指向文件系统中一个有效的文件夹,存储指标文件。以下是如何设置这个变量并启动带有四个工作进程的 Gunicorn 的命令示例:


(venv) $ PROMETHEUS_MULTIPROC_DIR=./prometheus-tmp gunicorn -w 4 -k uvicorn.workers.UvicornWorker chapter15.chapter15_metrics_01:app

当然,在生产环境部署时,你应该在平台上全局设置环境变量,正如我们在第十章中所解释的那样。

如果你尝试这个命令,你会看到 Prometheus 会开始在文件夹内存储一些 .db 文件,每个文件对应一个指标和一个进程。副作用是,在重启进程时,指标不会被清除。如果你更改了指标定义,或者运行了完全不同的应用程序,可能会导致意外的行为。确保为每个应用选择一个专用的文件夹,并在运行新版本时清理该文件夹。

我们现在能够精确地对 FastAPI 应用进行监控。然而,正如我们在前一章中所看到的,数据科学应用可能包含一个独立的工作进程,其中运行着大量的逻辑和智能。因此,对应用的这一部分进行监控也至关重要。

向 Dramatiq 添加 Prometheus 指标

第十四章中,我们实现了一个复杂的应用,包含一个独立的工作进程,该进程负责加载并执行 Stable Diffusion 模型来生成图像。因此,架构中的这一部分非常关键,需要进行监控,以确保一切顺利。

在这一部分,我们将学习如何向 Dramatiq 工作进程添加 Prometheus 指标。好消息是,Dramatiq 已经内置了指标,并且默认暴露了 /metrics 端点。实际上,几乎不需要做什么!

让我们来看一个非常基础的 Dramatiq 工作进程的例子,里面包含一个虚拟任务:

chapter15_metrics_03.py


import timeimport dramatiq
from dramatiq.brokers.redis import RedisBroker
redis_broker = RedisBroker(host="localhost")
dramatiq.set_broker(redis_broker)
@dramatiq.actor()
def addition_task(a: int, b: int):
    time.sleep(2)
    print(a + b)

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter15/chapter15_metrics_03.py

正如你现在可能已经理解的,Dramatiq 本质上是一个多进程程序:它会启动多个工作进程来并发处理任务。因此,我们需要确保 Prometheus 处于多进程模式,正如我们在处理多个进程部分中提到的那样。因此,我们需要设置PROMETHEUS_MULTIPROC_DIR环境变量,正如我们之前解释的那样,还需要设置dramatiq_prom_db。事实上,Dramatiq 实现了自己的机制来启用 Prometheus 的多进程模式,这应该是开箱即用的,但根据我们的经验,明确指出这一点会更好。

以下命令展示了如何启动带有PROMETHEUS_MULTIPROC_DIRdramatiq_prom_db设置的工作进程:


(venv) $ PROMETHEUS_MULTIPROC_DIR=./prometheus-tmp-dramatiq dramatiq_prom_db=./prometheus-tmp-dramatiq dramatiq chapter15.chapter15_metrics_03

为了让你能轻松在这个工作进程中调度任务,我们添加了一个小的__name__ == "__main__"指令。在另一个终端中,运行以下命令:


(venv) $ python -m chapter15.chapter15_metrics_03

它将在工作进程中调度一个任务。你可能会在工作进程日志中看到它的执行情况。

现在,尝试在浏览器中打开以下 URL:http://localhost:9191/metrics。你将看到类似于我们在图 15.7中展示的结果。

图 15.7 – Dramatiq Prometheus 度量端点的输出

图 15.7 – Dramatiq Prometheus 度量端点的输出

我们已经看到几个度量指标,包括一个用于统计 Dramatiq 处理的消息总数的计数器,一个用于测量任务执行时间的直方图,以及一个用于衡量当前正在进行的任务数量的仪表。你可以在 Dramatiq 的官方文档中查看完整的度量指标列表:dramatiq.io/advanced.html#prometheus-metrics

添加自定义指标

当然,对于 FastAPI,我们可能也希望向 Dramatiq 工作进程添加我们自己的指标。事实上,这与我们在上一节中看到的非常相似。让我们再次以掷骰子为例:

chapter15_metrics_04.py


DICE_COUNTER = Counter(    "worker_dice_rolls_total",
    "Total number of dice rolls labelled per face",
    labelnames=["face"],
)
@dramatiq.actor()
def roll_dice_task():
    result = random.randint(1, 6)
    time.sleep(2)
    DICE_COUNTER.labels(result).inc()
    print(result)

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter15/chapter15_metrics_04.py

我们所需要做的只是创建我们的Counter对象,正如我们之前所做的那样,并在任务中使用它。如果你尝试运行工作进程并请求/metrics端点,你会看到这个新指标出现。

我们现在可以对我们的 FastAPI 和 Dramatiq 应用进行指标收集。正如我们之前多次提到的那样,我们现在需要将这些指标汇总到 Prometheus 服务器中,并在 Grafana 中进行可视化。这就是我们将在下一节中讨论的内容。

在 Grafana 中监控指标

拥有度量指标固然不错,但能够可视化它们更好!在本节中,我们将看到如何收集 Prometheus 度量指标,将它们发送到 Grafana,并创建仪表板来监控它们。

Grafana 是一个开源的 Web 应用程序,用于数据可视化和分析。它能够连接到各种数据源,比如时间序列数据库,当然也包括 Prometheus。其强大的查询和图形构建器使我们能够创建详细的仪表板,在其中实时监控我们的数据。

配置 Grafana 收集指标

由于它是开源的,你可以在自己的机器或服务器上运行它。详细的安装说明可以在官方文档中找到:grafana.com/docs/grafana/latest/setup-grafana/installation/。不过,为了加快进程并快速开始,我们这里依赖的是 Grafana Cloud,这是一个官方托管平台。它提供了一个免费的计划,足以让你开始使用。你可以在这里创建账户:grafana.com/auth/sign-up/create-user。完成后,你将被要求创建自己的实例,即一个“Grafana Stack”,通过选择子域名和数据中心区域,如图 15**.8 所示。请选择一个靠近你地理位置的区域。

图 15.8 – 在 Grafana Cloud 上创建实例

图 15.8 – 在 Grafana Cloud 上创建实例

然后,你将看到一组常见的操作,帮助你开始使用 Grafana。我们要做的第一件事是添加 Prometheus 指标。点击扩展和集中现有数据,然后选择托管 Prometheus 指标。你将进入一个配置 Prometheus 指标收集的页面。在顶部点击名为配置详情的选项卡。页面将呈现如图 15**.9所示。

图 15.9 – 在 Grafana 上配置托管 Prometheus 指标

图 15.9 – 在 Grafana 上配置托管 Prometheus 指标

你可以看到,我们有两种方式来转发指标:通过 Grafana Agent 或通过 Prometheus 服务器。

如前所述,Prometheus 服务器负责收集我们所有应用程序的指标,并将数据存储在数据库中。这是标准的做法。你可以在官方文档中找到如何安装它的说明:prometheus.io/docs/prometheus/latest/installation/。不过,请注意,它是一个专用的应用服务器,需要适当的备份,因为它会存储所有的指标数据。

最直接的方式是使用 Grafana Agent。它由一个小型命令行程序和一个配置文件组成。当它运行时,它会轮询每个应用程序的指标,并将数据发送到 Grafana Cloud。所有数据都会存储在 Grafana Cloud 上,因此即使停止或删除代理,数据也不会丢失。这就是我们在这里使用的方法。

Grafana 会在页面上显示下载、解压并执行 Grafana Agent 程序的命令。执行这些命令,以便将其放在项目的根目录中。

然后,在最后一步,你需要创建一个 API 令牌,以便 Grafana Agent 可以将数据发送到你的实例。给它起个名字,然后点击创建 API 令牌。一个新的文本区域将出现,显示一个新的命令,用于创建代理的配置文件,正如你在图 15.10中看到的那样。

图 15.10 – 创建 Grafana Agent 配置的命令

图 15.10 – 创建 Grafana Agent 配置的命令

执行 ./grafana-agent-linux-amd64 –config.file=agent-config.yaml 命令。一个名为 agent-config.yaml 的文件将被创建在你的项目中。我们现在需要编辑它,以便配置我们的实际 FastAPI 和 Dramatiq 应用程序。你可以在以下代码片段中看到结果:

agent-config.yaml


metrics:  global:
    scrape_interval: 60s
  configs:
  - name: hosted-prometheus
    scrape_configs:
      - job_name: app
        static_configs:
        - targets: ['localhost:8000']
      - job_name: worker
        static_configs:
        - targets: ['localhost:9191']
    remote_write:
      - url: https://prometheus-prod-01-eu-west-0.grafana.net/api/prom/push
        basic_auth:
          username: 811873
          password: __YOUR_API_TOKEN__

github.com/PacktPublishing/Building-Data-Science-Applications-with-FastAPI-Second-Edition/tree/main/chapter15/agent-config.yaml

这是一个 YAML 配置文件,我们可以在其中设置 Grafana Agent 的各种选项。最重要的部分是 scrape_configs 键。如你所见,我们可以定义所有要收集指标的应用程序列表,并指定它们的主机名,对于 FastAPI 应用程序是“目标”:localhost:8000,而 Dramatiq worker 是 localhost:9191。当然,这个配置适用于本地开发,但在生产环境中,你需要根据实际应用程序的主机名进行调整。

我们现在准备启动 Grafana Agent 并收集指标数据了!确保你的 FastAPI 和 Dramatiq 应用程序正在运行,然后启动 Grafana Agent。根据你的系统,执行文件的名称会有所不同,但大致如下所示:


$ ./grafana-agent-linux-amd64 --config.file=agent-config.yaml

Grafana Agent 将启动并定期收集指标数据,然后将其发送到 Grafana。我们现在可以开始绘制一些数据了!

在 Grafana 中可视化指标

我们的指标数据现在已发送到 Grafana。我们准备好查询它并构建一些图表了。第一步是创建一个新的仪表板,这是一个可以创建和组织多个图表的地方。点击右上角的加号按钮,然后选择新建仪表板

一个新的空白仪表板将出现,正如你在图 15.11中看到的那样。

图 15.11 – 在 Grafana 中创建新仪表板

图 15.11 – 在 Grafana 中创建新仪表板

点击添加新面板。将会出现一个用于构建新图表的界面。主要有三个部分:

  • 左上角的图表预览。在开始时,它是空的。

  • 左下角的查询构建器。这是我们查询指标数据的地方。

  • 右侧的图表设置。这是我们选择图表类型并精细配置其外观和感觉的地方,类似于电子表格软件中的操作。

让我们尝试为我们 FastAPI 应用中的 HTTP 请求时长创建一个图表。在名为指标的选择菜单中,你将能访问到我们应用所报告的所有 Prometheus 指标。选择http_request_duration_seconds_bucket。这是 Prometheus FastAPI Instrumentator 默认定义的直方图指标,用于衡量我们端点的响应时间。

然后,点击运行查询。在后台,Grafana 会构建并执行 PromQL 查询来检索数据。

在图表的右上角,我们选择一个较短的时间跨度,比如过去 15 分钟。由于我们还没有太多数据,如果只看几分钟的数据,而不是几小时的数据,图表会更加清晰。你应该会看到一个类似图 15.12的图表。

图 15.12 – Grafana 中直方图指标的基本图

图 15.12 – Grafana 中直方图指标的基本图

Grafana 已绘制出多个系列:对于每个handler(对应于端点模式),我们有多个桶,le。每条线大致代表了我们在少于“le”秒内处理handler请求的次数

这是指标的原始表示。然而,你可能会发现它不太方便阅读和分析。如果我们能以另一种方式查看这些数据,按分位数排列的响应时间,可能会更好。

幸运的是,PromQL 包含一些数学运算,这样我们就可以对原始数据进行处理。在指标菜单下方的部分允许我们添加这些运算。我们甚至可以看到 Grafana 建议我们使用添加 histogram_quantile。如果点击这个蓝色按钮,Grafana 会自动添加三种操作:速率按 le 求和,最后是直方图分位数,默认设置为0.95

通过这样做,我们现在可以看到响应时间的变化情况:95%的时间,我们的响应时间少于x秒。

默认的y轴单位不太方便。由于我们知道我们使用的是秒,接下来在图表选项中选择这个单位。在右侧,找到标准选项部分,然后在单位菜单中,在时间组下选择秒(s)。现在你的图表应该像图 15.13一样。

图 15.13 – Grafana 中直方图指标的分位数表示

图 15.13 – Grafana 中直方图指标的分位数表示

现在情况更具洞察力了:我们可以看到,我们几乎处理了所有的请求(95%)都在 100 毫秒以内。如果我们的服务器开始变慢,我们会立即在图表中看到上升,这能提醒我们系统出现了问题。

如果我们希望在同一个图表上显示其他分位数,可以通过点击复制按钮(位于运行查询上方)来复制这个查询。然后,我们只需要选择另一个分位数。我们展示了0.950.900.50分位数的结果,见图 15.14

图 15.14 – Grafana 中同一图表上的多个分位数

图 15.14 – Grafana 中同一图表上的多个分位数

图例可以自定义

注意,图例中的系列名称是可以自定义的。在每个查询的选项部分,你可以根据需要进行自定义。你甚至可以包含来自查询的动态值,例如指标标签。

最后,我们可以通过在右侧列中设置面板标题来给我们的图表命名。现在我们对图表感到满意,可以点击右上角的应用按钮,将其添加到我们的仪表板中,如图 15.15所示。

图 15.15 – Grafana 仪表板

图 15.15 – Grafana 仪表板

就这样!我们可以开始监控我们的应用程序了。你可以随意调整每个面板的大小和位置。你还可以设置想要查看的查询时间范围,甚至启用自动刷新功能,这样数据就能实时更新!别忘了点击保存按钮来保存你的仪表板。

我们可以使用完全相同的配置,构建一个类似的图表,用于监控执行 Dramatiq 任务所需的时间,这要感谢名为dramatiq_message_duration_milliseconds_bucket的指标。注意,这个指标是以毫秒为单位表示的,而不是秒,所以在选择图表单位时需要特别小心。我们在这里看到了 Prometheus 指标命名约定的一个优点!

添加柱状图

Grafana 提供了许多不同类型的图表。例如,我们可以将骰子投掷指标绘制成柱状图,其中每根柱子表示某一面出现的次数。让我们来试试:添加一个新面板并选择app_dice_rolls_total指标。你会看到类似图 15.6所示的内容。

图 15.16 – Grafana 中计数器指标的默认表示方式(柱状图)

图 15.16 – Grafana 中计数器指标的默认表示方式(柱状图)

确实,我们为每个面都有一个柱子,但有一个奇怪的地方:每个时间点都有一根柱子。这是理解 Prometheus 指标和 PromQL 的关键:所有指标都作为时间序列存储。这使我们能够回溯时间,查看指标随时间的演变。

然而,对于某些表示方式,像这里显示的这种,实际上并不具备很高的洞察力。对于这种情况,最好是展示我们选择的时间范围内的最新值。我们可以通过将指标面板中的类型设置为即时来实现这一点。我们会看到现在我们有一个单一的图表,显示一个时间点的数据,如图 15.17所示。

图 15.17 – 在 Grafana 中将计数器指标配置为即时类型

图 15.17 – 在 Grafana 中将计数器指标配置为即时类型

这样已经更好了,但我们可以更进一步。通常,我们希望 x 轴显示面孔标签,而不是时间点。首先,让我们用 {{face}} 自定义图例。现在图例将只显示 face 标签。

现在,我们将数据转换,使得 x 轴为 face 标签。点击 Transform 标签。你会看到一系列可以在可视化之前应用到数据的函数。在我们这里,我们将选择 Reduce。这个函数的作用是取每个序列,从中提取一个特定的值并将其绘制在 x 轴上。默认情况下,Grafana 会取最大值 Max,但也有其他选项,如 LastMeanStdDev。在这种情况下,它们没有区别,因为我们已经查询了即时值。

就是这样!我们的图表现在显示了我们看到面孔的次数。这就是我们在第 15.6 图中展示的内容。

总结

恭喜!现在你可以在 Grafana 中报告指标,并构建自己的仪表盘来监控你的数据科学应用程序。随着时间的推移,如果你发现一些盲点,不要犹豫添加新的指标或完善你的仪表盘:目标是能够一目了然地监控每个重要部分,从而快速采取纠正措施。这些指标也可以用来推动你工作的演进:通过监控你的机器学习模型的性能和准确性,你可以跟踪你所做的改动的效果,看看自己是否走在正确的道路上。

本书的内容和我们的 FastAPI 之旅到此结束。我们真诚希望你喜欢本书,并且在这段旅程中学到了很多。我们覆盖了许多主题,有时只是稍微触及表面,但现在你应该已经准备好使用 FastAPI 构建自己的项目,并提供智能数据科学算法。一定要查看我们在旅程中提到的所有外部资源,它们将为你提供掌握这些技能所需的所有见解。

近年来,Python 在数据科学社区中获得了极大的关注,尽管 FastAPI 框架仍然非常年轻,但它已经是一个改变游戏规则的工具,并且已经看到了前所未有的采用率。它很可能会成为未来几年许多数据科学系统的核心……而当你读完这本书时,你可能就是这些系统背后的开发者之一。干杯!

posted @ 2025-10-01 11:29  绝不原创的飞龙  阅读(2)  评论(0)    收藏  举报