原文:
annas-archive.org/md5/a1f4ad3f5a4649378151351d58ad6e73
译者:飞龙
前言
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 版本。
在任何地方、任何设备上阅读。直接将您最喜欢的技术书籍中的代码搜索、复制并粘贴到您的应用程序中。
优惠不止于此,您还可以获得独家的折扣、新闻通讯和每天送到您邮箱的精彩免费内容。
按照以下简单步骤即可获得优惠:
- 扫描二维码或访问下面的链接
https://packt.link/free-ebook/9781837632749
-
提交您的购买证明
-
就是这样!我们会直接将免费的 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 子系统 Linux(WSL)(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
),但以下是您应该运行的命令:
-
安装构建依赖项:
-
对于 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
-
包管理器
brew
和 apt
是通常所说的软件包管理器。它们的作用是自动化系统上软件的安装和管理。因此,你不必担心从哪里下载它们,以及如何安装和卸载它们。这些命令只是告诉包管理器更新其内部的软件包索引,然后安装所需的软件包列表。
-
安装
pyenv
:$ curl https://pyenv.run | bash
macOS 用户提示
如果你是 macOS 用户,你也可以使用 Homebrew 安装它:brew
install pyenv
。
-
这将下载并执行一个安装脚本,为你处理所有事情。最后,它会提示你添加一些行到 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=“ H O M E / . p y e n v " ′ > > / . z s h r c e c h o ′ c o m m a n d − v p y e n v > / d e v / n u l l ∣ ∣ e x p o r t P A T H = " HOME/.pyenv"' >> ~/.zshrcecho 'command -v pyenv >/dev/null || export PATH=" HOME/.pyenv"′>> /.zshrcecho′command−vpyenv>/dev/null∣∣exportPATH="PYENV_ROOT/bin: P A T H " ′ > > / . z s h r c e c h o ′ e v a l " PATH"' >> ~/.zshrcecho 'eval " PATH"′>> /.zshrcecho′eval"(pyenv init -)”’ >> ~/.zshrc
-
什么是 shell,我怎么知道自己在使用哪个 shell?
Shell 是你启动命令行时正在运行的底层程序。它负责解释并执行你的命令。随着时间的推移,已经开发出了多种变种程序,如 bash
和 zsh
。尽管它们在某些方面有所不同,尤其是在配置文件的命名上,但它们大多是兼容的。要查找你使用的是哪个 shell,你可以运行 echo $``SHELL
命令。
-
重新加载你的 shell 配置以应用这些更改:
pyenv tool:
$ pyenv>>> pyenv 2.3.6>>> 用法:pyenv []
-
我们现在可以安装我们选择的 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 版本仍然是相关的。
-
最后,你可以使用以下命令设置默认的 Python 版本:
$ pyenv global 3.10
这将告诉系统,除非在特定项目中另有指定,否则默认始终使用 Python 3.10。
-
为确保一切正常,运行以下命令检查默认调用的 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 一起捆绑:
-
创建一个将包含你的项目的目录:
$ 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
。
-
你现在可以创建一个虚拟环境:
$ python -m venv venv
基本上,这个命令告诉 Python 运行标准库中的 venv
包,在 venv
目录中创建一个虚拟环境。这个目录的名称是一个约定,但你可以根据需要选择其他名称。
-
完成此操作后,你需要激活这个虚拟环境。它会告诉你的 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 执行简单请求:
-
首先,让我们获取数据:
$ 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 请求体。
-
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 在响应之前 输出请求,这对于检查我们是否正确指定了请求非常有用。
-
最后,让我们看看如何指定 请求头部:
$ 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}")
简单来说,这个脚本在控制台打印 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]
在这个脚本中,我们定义了 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
,表示True
或False
-
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)
这个函数简单地返回商和余数,它们被包裹在一个元组中。现在我们来计算3
和2
的欧几里得除法:
chapter02_basics_03.py
t = euclidean_division(3, 2)print(t[0]) # 1
print(t[1]) # 1
在这种情况下,我们将结果赋值给一个名为t
的元组,并通过索引来提取商和余数。然而,我们可以做得更好。让我们计算 42
和 4
的欧几里得除法:
chapter02_basics_03.py
q, r = euclidean_division(42, 4)print(q) # 10
print(r) # 2
你可以看到我们直接将商和余数分别赋值给q
和r
变量。这种语法称为t
是一个元组,它是不可变的,所以你不能重新赋值。而 q
和 r
是新的变量,因此是可变的。
字典
字典是 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 在工作中非常高效的运算符。
执行布尔逻辑
布尔逻辑通过and
、or
和not
关键字来执行。让我们回顾一些简单的例子:
>>> x = 10>>> x > 0 and x < 100
True
>>> x > 0 or (x % 2 == 0)
True
>>> not (x > 0)
False
你可能会在程序中经常使用它们,尤其是在条件语句块中。现在让我们回顾一下身份运算符。
检查两个变量是否相同
is
和 is not
身份运算符检查两个变量是否 指向 同一个对象。这与比较运算符 ==
和 !=
不同,后者检查的是两个变量是否具有相同的 值。
在 Python 内部,变量是通过指针存储的。身份运算符的目标就是检查两个变量是否实际上指向内存中的同一个对象。让我们来看一些例子:
>>> a = [1, 2, 3]>>> b = [1, 2, 3]
>>> a is b
False
即使 a
和 b
列表是相同的,它们在内存中并不是同一个对象,因此 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
值附加特殊含义。
现在我们来回顾一下成员运算符。
检查数据结构中是否存在某个值
成员运算符,in
和 not 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
第一个条件用 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"]
retrieve_page
函数是一个虚拟函数,它返回一个字典,其中包含传递给它的页面的项目以及下一页的页码,或者如果到达最后一页则返回None
。A priori,我们并不知道有多少页。因此,我们反复调用retrieve_page
,直到页面是None
。在每次迭代中,我们将当前页面的项目保存到累加器items
中。
当你处理第三方 REST API 并希望检索所有可用项目时,这种使用场景非常常见,while
循环对此非常有帮助。
最后,有一些情况下你希望提前结束循环或跳过某次迭代。为了解决这个问题,Python 实现了经典的break
和continue
语句。
定义函数
现在我们知道如何使用常见的运算符并控制程序的流程,让我们将它放入可重用的逻辑中。正如你可能已经猜到的,我们将学习函数以及如何定义它们。我们在之前的一些例子中已经看到了它们,但让我们更正式地介绍它们。
在 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")
这个模块只包含一个函数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 包示例层次结构
然后,你可以使用完整的命名空间导入你的模块:
>>> 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 – 带有__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]
使用这种方法,我们简单地遍历每个元素,检查条件,并在元素满足条件时将其添加到累加器中。
为了进一步提升可读性,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]
就这样!基本上,列表推导式通过打包一个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]
这种语法在 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}
要创建一个字典,指定键和值,并用冒号分隔:
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}
生成器
你可能认为,如果用圆括号替换方括号,你可以得到一个元组。实际上,你会得到一个生成器对象。生成器和列表推导式之间的主要区别在于,生成器的元素是按需生成的,而不是一次性计算并存储在内存中的。你可以把生成器看作是生成值的食谱。
正如我们所说,生成器可以通过使用与列表推导式相同的语法,并加上圆括号来定义:
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) # []
在这个例子中,我们定义了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]
正如你在这个函数中看到的,我们使用了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)
如果你在 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"
请注意,每个方法的第一个参数必须是 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"
在这个例子中,__init__
允许我们设置一个 default_name
属性,如果在参数中没有提供名称,greet
方法将使用该属性。如你所见,你可以通过点表示法轻松访问这个属性。
不过要小心:__init__
并不是构造函数。在典型的面向对象语言中,构造函数是用于实际在内存中创建对象的方法。在 Python 中,当 __init__
被调用时,对象已经在内存中创建(请注意我们可以访问 self
实例)。实际上,确实有一个方法用于定义构造函数,__new__
,但在常见的 Python 程序中它很少被使用。
私有方法和属性
在 Python 中,并不存在 私有 方法或属性的概念。一切都可以从外部访问。然而,按照惯例,你可以通过在私有方法和属性前加下划线来 表示 它们应该被视为私有:_private_method
。
现在你已经掌握了 Python 中面向对象编程的基础!接下来我们将重点讲解魔法方法,它们可以让我们对对象做一些巧妙的操作。
实现魔法方法
魔法方法是一组在语言中具有特殊意义的预定义方法。它们很容易识别,因为它们的名称前后都有两个下划线。事实上,我们已经见过其中一个魔法方法:__init__
!这些方法不是直接调用的,而是由解释器在使用其他构造函数,如标准函数或操作符时调用。
为了理解它们的作用,我们将回顾最常用的方法。我们从__repr__
和__str__
开始。
对象表示 —— repr 和 str
当你定义一个类时,通常需要能够获得一个实例的可读且清晰的字符串表示。为此,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)
如果你运行这个示例,你会注意到print(t)
和print(str(t))
打印的内容是一样的。通过print
,解释器调用了__str__
方法来获取我们对象的字符串表示。这就是__str__
的作用:提供一个优雅的字符串表示,供最终用户使用。
另一方面,你会看到,尽管它们非常相似,我们实现了__repr__
的方式却有所不同。这个方法的目的是给出对象的内部表示,并且它是唯一明确的。按照约定,这应该给出一个准确的语句,允许我们重建出完全相同的对象。
现在我们已经可以用我们的类来表示温度,那么如果我们尝试比较它们,会发生什么呢?
比较方法 —— eq、gt、lt,等等
当然,比较不同单位的温度会导致意外的结果。幸运的是,魔法方法允许我们重载默认的操作符,以便进行有意义的比较。让我们扩展一下之前的例子:
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
在__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
如你所见,这些方法只是接受另一个参数,即要与之比较的另一个对象实例。然后我们只需要执行比较逻辑。通过这样做,我们可以像处理任何变量一样进行比较:
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
就是这样!如果你希望所有比较操作符都可用,你还应该实现所有其他的比较魔法方法:__le__
、__gt__
和 __ge__
。
另一个实例的类型无法保证
在这个例子中,我们假设 other
变量也是一个 Temperature
对象。然而,在现实中,这并不能保证,开发者可能会尝试将 Temperature
与另一个对象进行比较,这可能导致错误或异常行为。为避免这种情况,你应该使用 isinstance
检查 other
变量的类型,确保我们处理的是 Temperature
,否则抛出适当的异常。
操作符 – add、sub、mul 等等
类似地,你还可以定义当尝试对两个 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
__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
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}"
现在你知道如何在 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
再次强调,语法非常简单:只需用逗号列出所有父类。现在,Child
类可以调用 f
和 g
两个方法。
Mixins
Mixins 是 Python 中常见的设计模式,利用了多重继承特性。基本上,mixins 是包含单一功能的简短类,通常用于重用。然后,你可以通过组合这些 mixins 来构建具体的类。
但是,如果 A
和 B
两个类都实现了名为 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
如果你调用 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}"
正如你所看到的,我们在冒号后简单地添加了name
参数的类型。我们还指定了str
或int
,我们可以简单地将它们用作类型注解。稍后在本节中,我们将看到如何注解更复杂的类型,如列表或字典。
现在我们将安装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 中类型提示的基础知识。接下来,我们将回顾更高级的例子,特别是涉及非标量类型的情况。
类型数据结构
到目前为止,我们已经看到了如何为标量类型(如str
或int
)注解变量。但我们也看到了像列表和字典这样的数据结构,它们在 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}
你可以看到,这里我们可以使用list
、tuple
、set
和dict
这些标准类作为类型提示。然而,它们要求你提供构成结构的值的类型。这就是面向对象编程中广为人知的泛型概念。在 Python 中,它们是通过方括号定义的。
当然,还有更复杂的用例。例如,在 Python 中,拥有一个包含不同类型元素的列表是完全有效的。为了让类型检查器正常工作,我们可以简单地使用|
符号来指定多个允许的类型:
chapter02_type_hints_03.py
l: list[int | float] = [1, 2.5, 3.14, 5]
在这种情况下,我们的列表将接受整数或浮点数。当然,如果你尝试向列表中添加一个既不是 int
也不是 float
类型的元素,mypy
会报错。
还有一种情况也非常有用:你会经常遇到这样的函数参数或返回类型,它们要么返回一个值,要么返回 None
。因此,你可以写类似这样:
chapter02_type_hints_04.py
def greeting(name: str | None = None) -> str: return f"Hello, {name if name else 'Anonymous'}"
允许的值是字符串或 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)
按照惯例,类型应该使用驼峰命名法,就像类名一样。说到类,我们来看看类型提示在类中的应用:
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)]
什么是 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)
然而,值得注意的是,Python 中没有语法来指示可选或关键字参数。在这种情况下,你可以写 Callable[..., bool]
,其中的省略号(...
)表示任意数量的参数。
Any
和 cast
在某些情况下,代码非常动态或复杂,无法正确地进行注解,或者类型检查器可能无法正确推断类型。为此,我们可以使用 Any
和 cast
。它们可在 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])
第二个方法,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"
但要小心:cast
函数对类型检查器才有意义。对于其他类型的注解,解释器会完全忽略它,并且 不会 执行真正的类型转换。
尽管很方便,但尽量不要过于频繁地使用这些工具。如果一切都是 Any
或强制转换为其他类型,你将完全失去静态类型检查的好处。
正如我们所看到的,类型提示和类型检查在减少开发和维护高质量代码时非常有帮助。但这还不是全部。实际上,Python 允许你在运行时获取类型注解,并基于此执行一些逻辑。这使得你能够做一些聪明的事情,比如 依赖注入:只需在函数中为参数添加类型提示,库就能自动解析并在运行时注入相应的值。这一概念是 FastAPI 的核心。
FastAPI 中的另一个关键方法是 异步 I/O。这是我们在本章中要讲解的最后一个主题。
与异步 I/O 一起工作
如果你已经使用过 JavaScript 和 Node.js,你可能已经接触过 promise 和 async
/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)
我们可以看到,脚本会被阻塞,直到从磁盘中检索到数据,正如我们所说,这可能需要很长时间。程序 99%的执行时间都花费在等待磁盘上。对于像这样的简单脚本来说,这通常不是问题,因为你可能不需要在此期间执行其他操作。
然而,在其他情况下,这可能是执行其他任务的机会。本书中非常关注的典型案例是 Web 服务器。假设我们有一个用户发出请求,该请求需要执行一个持续 10 秒钟的数据库查询才能返回响应。如果此时第二个用户发出了请求,他们必须等到第一个响应完成后,才能收到自己的答复。
为了解决这个问题,传统的基于Web 服务器网关接口(WSGI)的 Python Web 服务器(如 Flask 或 Django)会生成多个工作进程。这些是 Web 服务器的子进程,都能够处理请求。如果其中一个忙于处理一个长时间的请求,其他进程可以处理新的请求。
使用异步 I/O 时,单个进程在处理一个长时间 I/O 操作的请求时不会被阻塞。它在等待此操作完成的同时,可以处理其他请求。当 I/O 操作完成时,它会恢复请求逻辑,并最终返回响应。
从技术上讲,这是通过select
和poll
调用实现的,正是通过它们来请求操作系统级别的 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())
当你想定义一个异步函数时,只需要在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())
这里,我们有一个printer
协程,它会打印自己的名字指定次数。每次打印之间,它会睡眠 1 秒。
然后,我们的主协程使用了asyncio.gather
工具,它将多个协程调度为并发执行。如果你运行这个脚本,你将得到以下结果:
$ python chapter02_asyncio_03.pyA
B
A
B
A
B
我们得到了一连串的A
和B
。这意味着我们的协程是并发执行的,并且我们没有等第一个协程完成才开始第二个协程。
你可能会想知道为什么我们在这个例子中添加了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"}
在这个例子中,我们在根路径上定义了一个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 自动交互式文档
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}
在这个示例中,我们定义了一个 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}
这个方法很有效,但是端点将接受任何字符串作为 type
参数。
限制允许的值
如果我们只想接受一组有限的值怎么办?再次,我们将依赖类型提示。Python 中有一个非常有用的类:Enum
。枚举是列出特定类型数据所有有效值的一种方式。让我们定义一个 Enum
类来列出不同类型的用户:
chapter03_path_parameters_03.py
class UserType(str, Enum): STANDARD = "standard"
ADMIN = "admin"
要定义一个字符串枚举,我们需要同时继承 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}
如果你运行此示例,并调用一个类型不在枚举中的端点,你将得到以下响应:
$ 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
。在以下示例中,我们只允许一个大于或等于 1
的 id
参数:
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}
这里有几个需要注意的事项: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}
现在我们只需定义 min_length
和 max_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}
多亏了这个正则表达式,我们只接受与车牌格式完全匹配的字符串。请注意,正则表达式前面有一个 r
。就像 f-strings
一样,这是 Python 语法,用于表示接下来的字符串应被视为正则表达式。
参数元数据
数据验证并不是参数函数唯一接受的选项。你还可以设置其他选项,用于在自动生成的文档中添加关于参数的信息,例如 title
、description
和 deprecated
。
现在你应该能够定义路径参数并对其应用一些验证。另一个有用的参数是查询参数。我们接下来将讨论它们。
查询参数
查询参数是一种常见的向 URL 添加动态参数的方式。你可以在 URL 末尾找到它们,形式如下:?param1=foo¶m2=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}
你只需要将它们声明为路径操作函数的参数。如果它们没有出现在路径模式中,就像路径参数那样,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}
现在,如果你在 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}
在这里,我们强制页面参数值为 大于 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}
与查询参数的方式相同,我们为每个参数定义类型提示,并使用 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 使用Path
、Query
和Body
函数,我们迄今所学的都使用 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
首先,我们从pydantic
导入BaseModel
。这是每个模型都应该继承的基类。然后,我们定义我们的User
类,并将所有属性列为类属性。每个属性都应具有适当的类型提示:这是 Pydantic 将能够验证字段类型的方式。
最后,我们只需将user
声明为路径操作函数的一个参数,并使用User
类作为类型提示。FastAPI 会自动理解用户数据可以在请求负载中找到。在函数内部,您可以访问一个适当的user
对象实例,只需使用点表示法访问单个属性,例如user.name
。
请注意,如果只返回对象,FastAPI 足够智能,会自动将其转换为 JSON 以生成 HTTP 响应。
在接下来的章节中,第四章,在 FastAPI 中管理 Pydantic 数据模型,我们将更详细地探讨 Pydantic 的可能性,特别是在验证方面。
多个对象
有时,您可能希望一次性发送同一有效负载中的多个对象。例如,user
和company
。在这种情况下,您可以简单地添加几个由 Pydantic 模型类型提示的参数,FastAPI 将自动理解存在多个对象。在这种配置中,它将期望包含每个对象以其 参数名称 索引:
chapter03_request_body_03.py
@app.post("/users")async def create_user(user: User, company: Company):
return {"user": user, "company": company}
这里,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}
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}
唯一的不同之处是,我们使用 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 响应,无论输入数据的形式如何。
当然,我们之前看到的Path
、Query
和Body
的验证选项仍然可用。你可以在路径 参数部分找到它们的描述。
值得注意的是,与 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)}
再次可以看到,这种方法依然是相同的:我们为路径操作函数定义一个参数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}
请注意,在这里,我们返回了 filename
和 content_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
]
要使用 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}
在这里,你可以看到我们只需使用 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}
现在,让我们发出一个非常简单的请求。我们将保持 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}
请注意,我们为参数添加了类型提示 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}
在底层,这是来自 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
装饰器的参数紧跟路径作为关键字参数。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
注意,你完全可以在路径操作函数中返回 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]
然后调用这个端点:
$ 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
然后,唯一的变化是将 response_model
选项作为关键字参数添加到路径装饰器中:
chapter03_response_path_parameters_04.py
@app.get("/posts/{id}", response_model=PublicPost)async def get_post(id: int):
return posts[id]
现在,让我们尝试调用这个端点:
$ 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 – 交互式文档中的响应模型架构
到目前为止,您已经了解了可以帮助您快速定制 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"}
Response
对象提供了一组属性,包括headers
。它是一个简单的字典,键是头部名称,值是其关联的值。因此,设置自定义头部相对简单。
此外,请注意你不需要返回Response
对象。你仍然可以返回可编码为 JSON 的数据,FastAPI 会处理形成正确的响应,包括你设置的头部。因此,我们在路径操作参数部分讨论的response_model
和status_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 也特别有用。
如果你想让浏览器在响应中保存一些 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"}
在这里,我们简单地设置了一个名为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]
首先,我们检查路径中的 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 Request
或404 Not Found
。实际上,你不应该这么做。FastAPI 提供了一种专门的方法来处理这个问题:HTTPException
。
引发 HTTP 错误
在调用 REST API 时,很多时候你会发现事情不太顺利;你可能会遇到错误的参数、无效的有效载荷,或者对象已经不再存在了。错误可能有很多原因。
这就是为什么检测这些错误并向最终用户抛出清晰、明确的错误信息如此重要,让他们能够纠正自己的错误。在 REST API 中,有两个非常重要的东西可以用来返回信息:状态码和有效载荷。
状态码可以为你提供关于错误性质的宝贵线索。由于 HTTP 协议提供了各种各样的错误状态码,最终用户甚至可能不需要读取有效载荷就能理解问题所在。
当然,同时提供一个清晰的错误信息总是更好的,以便提供更多的细节并增加一些有用的信息,告诉最终用户如何解决问题。
错误状态码至关重要
一些 API 选择始终返回200
状态码,并在有效载荷中包含一个属性,指明请求是否成功,例如{"success": false}
。不要这么做。RESTful 哲学鼓励你使用 HTTP 语义来赋予数据含义。必须解析输出并寻找一个属性来判断调用是否成功,是一种糟糕的设计。
要在 FastAPI 中抛出 HTTP 错误,你需要抛出一个 Python 异常 HTTPException
。这个异常类允许我们设置状态码和错误信息。它会被 FastAPI 的错误处理程序捕获,后者负责形成正确的 HTTP 响应。
在下面的示例中,如果 password
和 password_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."}
正如你所看到的,如果密码不匹配,我们直接抛出 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",
],
},
)
就这样!现在你已经能够抛出错误并向最终用户提供有意义的错误信息。
到目前为止,你所看到的方法应该涵盖了开发 API 时大多数情况下会遇到的情况。然而,有时你会遇到需要自己构建完整 HTTP 响应的场景。这是下一节的内容。
构建自定义响应
大多数情况下,你只需向 FastAPI 提供一些数据以进行序列化,FastAPI 就会自动处理构建 HTTP 响应。FastAPI 底层使用了 Response
的一个子类 JSONResponse
。很容易预见,这个响应类负责将一些数据序列化为 JSON 并添加正确的 Content-Type
头部。
然而,还有其他一些响应类可以处理常见的情况:
-
HTMLResponse
:可以用来返回一个 HTML 响应 -
PlainTextResponse
:可以用来返回原始文本 -
RedirectResponse
:可以用来执行重定向 -
StreamingResponse
:可以用来流式传输字节流 -
FileResponse
:可以用来根据本地磁盘中文件的路径自动构建正确的文件响应
你有两种使用它们的方法:要么在路径装饰器上设置 response_class
参数,要么直接返回一个响应实例。
使用response_class
参数
这是返回自定义响应最简单直接的方式。实际上,做到这一点,你甚至不需要创建类实例:你只需要像标准 JSON 响应那样返回数据。
这非常适合HTMLResponse
和PlainTextResponse
:
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!"
通过在装饰器上设置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")
默认情况下,它将使用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)
提供文件
现在,让我们来看看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)
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-Length
和Content-Type
头部。响应甚至设置了Etag
和Last-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")
你可以在 Starlette 文档中查看完整的参数列表:www.starlette.io/responses/#response
。
路径操作参数和响应参数不会有任何效果
请记住,当你直接返回一个Response
类(或它的子类)时,你在装饰器上设置的参数或者在注入的Response
对象上进行的操作将不起作用。它们会被你返回的Response
对象完全覆盖。如果你需要自定义状态码或头部信息,则在实例化类时使用status_code
和headers
参数。
做得好!现在,你已经掌握了创建 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())
如你所见,在这里,你不是实例化 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"])
和往常一样,我们实例化了 FastAPI
类。然后,我们使用 include_router
方法添加子路由器。你可以看到,我们只是从相关模块中导入了路由器,并将其作为 include_router
的第一个参数。注意,我们在导入时使用了 as
语法。由于 users
和 posts
路由器在它们的模块中命名相同,这种语法让我们可以给它们取别名,从而避免了命名冲突。
此外,你还可以看到我们设置了 prefix
关键字参数。这使我们能够为该路由器的所有端点路径添加前缀。这样,你就不需要将其硬编码到路由器逻辑中,并且可以轻松地为整个路由器更改它。它还可以用来为你的 API 提供版本化路径,比如 /v1
。
最后,tags
参数帮助你在交互式文档中对端点进行分组,以便更好地提高可读性。通过这样做,posts
和 users
端点将在文档中清晰分开。
这就是你需要做的所有事情!你可以像往常一样使用 Uvicorn 运行整个应用:
$ uvicorn chapter03_project.app:app
如果你打开 http://localhost:8000/docs
的交互式文档,你会看到所有的路由都在其中,并且按我们在包含路由器时指定的标签进行分组:
图 3.3 – 交互式文档中的标记路由器
再次说明,你可以看到 FastAPI 功能强大且非常轻量化。路由器的一个好处是你可以将它们嵌套,甚至将子路由器包含在包含其他路由器的路由器中。因此,你可以在很少的努力下构建一个相当复杂的路由层次结构。
总结
干得好!你现在已经熟悉了 FastAPI 的所有基础功能。在本章中,你学习了如何创建和运行 API 端点,验证和获取 HTTP 请求的各个部分的数据:路径、查询、参数、头部,当然还有正文。你还学习了如何根据需求定制 HTTP 响应,无论是简单的 JSON 响应、错误信息还是文件下载。最后,你了解了如何定义独立的 API 路由器,并将它们包含到你的主应用中,以保持清晰且易于维护的项目结构。
现在你已经掌握了足够的知识,可以开始使用 FastAPI 构建你自己的 API。在接下来的章节中,我们将重点讲解 Pydantic 模型。你现在知道它们是 FastAPI 数据验证功能的核心,因此彻底理解它们的工作原理以及如何高效地操作它们至关重要。