FastAPI-指南-全-

FastAPI 指南(全)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

序言

这是对 FastAPI——一个现代 Python Web 框架的务实介绍。这也是一个关于我们偶尔会碰到的闪亮新物体如何变得非常有用的故事。当你遇到狼人时,一发银弹可谓非常实用。(而且你将在本书后面遇到狼人。)

我从 70 年代中期开始编写科学应用程序。在 1977 年我第一次在 PDP-11 上遇到 Unix 和 C 之后,我有一种这个 Unix 东西可能会流行起来的感觉。

在 80 年代和 90 年代初期,互联网虽然还没有商业化,但已经是一个免费软件和技术信息的良好来源。当名为 Mosaic 的网络浏览器在 1993 年在初生的开放互联网上发布时,我有一种这个网络东西可能会流行起来的感觉。

几年后我创立了自己的 Web 开发公司时,我的工具还是当时的惯用选择:PHP、HTML 和 Perl。几年后的一个合同工作中,我最终尝试了 Python,并对我能够多快地访问、操作和显示数据感到惊讶。在两周的空闲时间内,我成功复制了一个四名开发者耗时一年才编写完整的 C 应用程序的大部分功能。现在我有一种这个 Python 东西可能会流行起来的感觉。

此后,我的大部分工作涉及 Python 及其 Web 框架,主要是 Flask 和 Django。我特别喜欢 Flask 的简单性,并在许多工作中更倾向于使用它。但就在几年前,我在灌木丛中发现了一抹闪光:一款名为 FastAPI 的新 Python Web 框架,由 Sebastián Ramírez 编写。

在阅读他(优秀)的文档时,我对设计和所投入的思考深感印象深刻。特别是他的历史页面展示了他在评估替代方案时所付出的努力。这不是一个自我项目或有趣的实验,而是一个面向现实开发的严肃框架。现在我有一种这个 FastAPI 东西可能会流行起来的感觉。

我使用 FastAPI 编写了一个生物医学 API 站点,效果非常好,以至于我们团队在接下来的一年中用 FastAPI 重写了我们旧的核心 API。这个系统仍在运行中,并表现良好。我们的团队学习了本书中你将阅读到的基础知识,所有人都觉得我们正在写出更好的代码,速度更快,bug 更少。顺便说一句,我们中的一些人以前并没有用过 Python,只有我用过 FastAPI。

所以当我有机会向 O'Reilly 建议续集《介绍 Python》时,FastAPI 是我的首选。在我看来,FastAPI 至少会像 Flask 和 Django 一样有影响力,甚至更大。

正如我之前提到的,FastAPI 网站本身提供了世界级的文档,包括许多关于常见 Web 主题的细节:数据库、身份验证、部署等等。那么为什么要写一本书呢?

这本书并不打算全面无遗漏,因为那样会令人筋疲力尽。它的目的是实用——帮助你快速掌握 FastAPI 的主要思想并应用它们。我将指出各种需要一些侦查的技术,并提供日常最佳实践建议。

每一章都以预览内容开始。接下来,我尽量不忘记刚承诺的内容,提供细节和偶尔的旁白。最后,有一个简短易消化的复习。

俗话说,“这些是我事实依据的观点。”您的经验将是独特的,但我希望您能在这里找到足够有价值的内容,以成为更高效的 Web 开发者。

本书中使用的约定

本书中使用了以下排版约定:

Italic

指示新术语、网址、电子邮件地址、文件名和文件扩展名。

Constant width

用于程序列表,以及段落内引用程序元素,如变量或函数名称、数据库、数据类型、环境变量、语句和关键字。

Constant width bold

显示用户应按照字面意义输入的命令或其他文本。

Constant width italic

显示应由用户提供值或由上下文确定值的文本。

提示

此元素表示提示或建议。

注意

此元素表示一般提示。

使用代码示例

补充材料(代码示例、练习等)可在https://github.com/madscheme/fastapi下载。

如果您在使用示例代码时遇到技术问题或困难,请发送电子邮件至support@oreilly.com

本书旨在帮助您完成工作。一般来说,如果本书提供了示例代码,您可以在自己的程序和文档中使用它。除非您要复制大部分代码,否则无需联系我们以获取许可。例如,编写一个使用本书多个代码片段的程序不需要许可。销售或分发 O’Reilly 书籍中的示例需要许可。引用本书并引用示例代码来回答问题不需要许可。将本书大量示例代码整合到产品文档中需要许可。

我们感谢,但通常不要求归属。归属通常包括标题、作者、出版商和 ISBN 号码。例如:“FastAPI by Bill Lubanovic (O’Reilly)。2024 年版权归 Bill Lubanovic 所有,978-1-098-13550-8。”

如果您认为您使用的示例代码超出了合理使用范围或上述许可,请随时联系我们permissions@oreilly.com

O’Reilly 在线学习

注意

40 多年来,O’Reilly Media提供技术和商业培训、知识和洞察,帮助公司取得成功。

我们独特的专家和创新者网络通过图书、文章和我们的在线学习平台分享他们的知识和专长。奥莱利的在线学习平台为您提供了按需访问实时培训课程、深入学习路径、交互式编码环境以及奥莱利和其他 200 多家出版商的大量文本和视频的机会。有关更多信息,请访问https://oreilly.com

如何联系我们

有关本书的评论和问题,请联系出版商:

  • 奥莱利媒体公司

  • 北格拉文斯坦高速公路 1005 号

  • 加州塞巴斯托波尔市 95472

  • 800-889-8969(美国或加拿大)

  • 707-829-7019(国际或本地)

  • 707-829-0104(传真)

  • support@oreilly.com

  • https://www.oreilly.com/about/contact.html

我们有一个为本书准备的网页,我们在其中列出勘误、示例和任何其他信息。您可以在https://oreil.ly/FastAPI访问此页面。

有关我们的图书和课程的新闻和信息,请访问https://oreilly.com

在 LinkedIn 上找到我们:https://linkedin.com/company/oreilly-media

在 Twitter 上关注我们:https://twitter.com/oreillymedia

在 YouTube 上观看我们:https://youtube.com/oreillymedia

致谢

感谢许多人,在许多地方,从中我学到了很多:

  • 艾拉高中

  • 匹兹堡大学

  • 生物钟实验室,

    明尼苏达大学

  • Intran

  • Crosfield-Dicomed

  • 西北航空公司

  • Tela

  • WAM!NET

  • 疯狂计划

  • SSESCO

  • Intradyn

  • Keep

  • 汤姆逊路透社

  • Cray

  • 企鹅计算

  • 互联网档案馆

  • CrowdStrike

  • Flywheel

第一部分:有什么新东西?

世界因蒂姆·伯纳斯-李爵士发明的万维网¹ 和吉多·范罗苏姆设计的 Python 编程语言受益匪浅。

唯一微小的问题是,一家无名的计算机书出版商经常在其相关的网络和 Python 封面上放置蜘蛛和蛇。要是网络被称为“万维 Woof”(织物中的交叉线,也称为 weft),而 Python 被称为“Pooch”,这本书的封面可能会像图 I-1 一样。

Tonks

图 I-1. FastAPI:现代狗狗开发

但我离题了。² 本书讨论以下内容:

互联网

特别高效的技术,它的变化以及如何为其开发软件

Python

特别高效的网络开发语言

FastAPI

特别高效的 Python 网络框架

第一部分的两章讨论了互联网和 Python 中的新兴主题:服务和 API;并发性;分层架构;以及大数据。

第二部分 是对 FastAPI 的高层次介绍,这是一个新的 Python 网络框架,对第一部分 中提出的问题有很好的答案。

第三部分 深入研究了 FastAPI 工具箱,包括在生产开发中学到的技巧。

最后,第四部分 展示了 FastAPI 网络示例的画廊。它们使用了一个共同的数据源——虚构的生物,可能比通常的随机说明更有趣和连贯。这些应该为特定应用程序提供了一个起点。

¹ 我曾经和他握过手。我一个月都没洗我的手,但我敢打赌他当时就洗了。

² 这不是最后一次。

第一章:现代网络

我所设想的网络,我们还没有见到。未来比过去要大得多。

Tim Berners-Lee

预览

曾经,互联网小而简单。开发者们喜欢在单个文件中混合使用 PHP、HTML 和 MySQL 调用,并自豪地告诉每个人去查看他们的网站。但随着时间的推移,网络变得庞大无比,页面数达到数不清、甚至是数不尽的数量——早期的游乐场成为了一个主题公园的元宇宙。

在本章中,我将指出一些对现代网络越来越重要的领域:

  • 服务和 API

  • 并发

  • 层次

  • 数据

下一章将展示 Python 在这些领域提供了什么。之后,我们将深入了解 FastAPI Web 框架及其提供的功能。

服务和 API

网络是一个连接的伟大结构。虽然大量活动仍然发生在内容方面——HTML、JavaScript、图片等,但越来越重视连接这些内容的应用程序接口(APIs)。

通常,一个网络服务负责低级数据库访问和中级业务逻辑(通常被合称为后端),而 JavaScript 或移动应用程序提供丰富的顶级前端(交互式用户界面)。这两个世界已变得更加复杂和分化,通常需要开发人员专注于其中之一。比以往更难成为全栈开发者了。¹

这两个世界通过 API 彼此交流。在现代网络中,API 设计与网站设计同样重要。API 是一种合同,类似于数据库模式。定义和修改 API 现在是一项主要工作。

API 的种类

每个 API 定义如下内容:

协议

控制结构

格式

内容结构

随着技术的发展,多种 API 方法已经发展,从孤立的机器到多任务系统,再到网络服务器。在某些时候,您可能会遇到其中的一种或多种,因此以下是在HTTP及其朋友中特别介绍的简要总结:

  • 在网络出现之前,API 通常意味着非常紧密的连接,就像在应用程序中与中的函数调用一样——比如在数学库中计算平方根。

  • 远程过程调用(RPCs)的发明是为了在调用应用程序时调用其他进程中的函数,无论是在同一台机器上还是其他机器上。一个流行的现代例子是gRPC

  • 消息传递在进程之间的管道中发送小块数据。消息可能是类似动词的命令,也可能仅仅是感兴趣的名词事件。当前流行的消息传递解决方案广泛变化,从工具包到完整的服务器,包括Apache KafkaRabbitMQNATSZeroMQ。通信可以遵循不同的模式:

    请求-响应

    一对一,如 Web 浏览器调用 Web 服务器。

    发布-订阅,或 pub-sub

    发布者 发布消息,订阅者 根据消息中的某些数据(如主题)对每条消息作出响应。

    队列

    类似于 pub-sub,但仅有一个池中的订阅者获取消息并对其作出响应。

任何这些都可以与 Web 服务一起使用,例如执行像发送电子邮件或创建缩略图图像这样的慢速后端任务。

HTTP

伯纳斯-李为他的万维网提出了三个组件:

HTML

一种用于显示数据的语言

HTTP

客户端-服务器协议

URL

用于 Web 资源的寻址方案

尽管这些在回顾中显而易见,但它们事实上是一个极其有用的组合。随着 Web 的发展,人们进行了各种尝试,一些想法,如 IMG 标签,经历了达尔文式的竞争。随着需求变得更加清晰,人们开始严格定义标准。

REST(ful)

Roy Fielding 博士论文中的一个章节定义了 表现状态转移(REST) —— 一种用于 HTTP 的 架构风格。² 尽管经常被引用,但它往往被广泛 误解

一个粗略共享的适应已经演变并主导了现代网络。它被称为 RESTful,具有以下特点:

  • 使用 HTTP 和客户端-服务器协议

  • 无状态(每个连接都是独立的)

  • 可缓存的

  • 基于资源的

资源 是可以区分并执行操作的数据。Web 服务为每个要公开的功能提供一个 终端点 —— 一个独特的 URL 和 HTTP 动词(操作)。终端点也被称为 路由,因为它将 URL 路由到一个功能。

数据库用户熟悉 CRUD 缩写的过程:创建、读取、更新、删除。HTTP 动词非常 CRUD:

POST

创建(写入)

PUT

完全修改(替换)

PATCH

部分修改(更新)

GET

嗯,获取(读取、检索)

DELETE

嗯,删除

客户端通过 RESTful 终端点向服务器发送 请求,数据位于 HTTP 消息的以下区域之一:

  • 头部信息

  • URL 字符串

  • 查询参数

  • 主体数值

接着,HTTP 响应 返回这些内容:

  • 一个整数 状态码 指示以下内容:

    100s

    信息,请继续

    200s

    成功

    300s

    重定向

    400s

    客户端错误

    500s

    服务器错误

  • 各种头部信息

  • 一个可能为空、单一或 分块 的主体(以连续的片段)

至少有一个状态码是一个复活节彩蛋:418(我是 茶壶)应该由一个连接到网络的茶壶返回,如果被要求煮咖啡。

fapi 01in01

您将会发现很多关于 RESTful API 设计的网站和书籍,它们都有有用的经验法则。本书将在途中提供一些。

JSON 和 API 数据格式

前端应用程序可以与后端 Web 服务交换纯 ASCII 文本,但如何表示像列表这样的数据结构呢?

正是在我们真正需要的时候,JavaScript 对象表示法(JSON)横空出世——又一个简单的想法解决了一个重要问题,在事后看来显得显而易见。尽管 J 代表 JavaScript,其语法看起来也很像 Python。

JSON 已经大量取代了像 XML 和 SOAP 这样的旧尝试。在本书的其余部分,你会看到 JSON 是默认的 Web 服务输入和输出格式。

JSON:API

RESTful 设计与 JSON 数据格式的结合现在很常见。但在模糊性和书呆子之间仍然有一些余地。最近的JSON:API提案旨在稍微加强规范。本书将使用宽松的 RESTful 方法,但如果你有重大争论,JSON:API 或类似严格的方法可能会有所帮助。

GraphQL

对于某些目的来说,RESTful 接口可能很笨重。Facebook(现在是 Meta)设计了图形查询语言(GraphQL来指定更灵活的服务查询。本书不会深入讲解 GraphQL,但如果你觉得 RESTful 设计对你的应用不足够,你可能需要深入了解一下。

并发

除了服务导向的增长外,连接到 Web 服务的数量的快速扩展要求更好的效率和规模。

我们希望减少以下内容:

延迟

前期等待时间

吞吐量

服务与其调用者之间每秒传输的字节数

在早期的网络时代,³ 人们梦想支持数百个同时连接,后来又担心“10K 问题”,现在却假定可以同时处理数百万个连接。

并发并不意味着完全的并行处理。多个处理不会在同一微秒内在单个 CPU 上发生。相反,并发主要避免忙等(即空闲 CPU 直到收到响应)。CPU 是很快的,但网络和磁盘慢了成千上万倍。所以,每当我们与网络或磁盘通信时,我们不希望只是一动不动地等待响应。

正常的 Python 执行是同步的:按代码指定的顺序一次处理一件事。有时我们希望是异步的:一会儿做一件事,然后做另一件事,再回到第一件事,依此类推。如果我们所有的代码都用 CPU 来计算事物(CPU bound),那么没有多余的时间去异步。但是如果我们执行某些让 CPU 等待外部事物完成的操作(I/O bound),我们就可以异步执行。

异步系统提供一个事件循环:将慢操作的请求发送并记录下来,但我们不会阻塞 CPU 等待它们的响应。相反,每次循环通过时都会进行一些即时处理,接下来处理在这段时间内收到的任何响应。

这些效果可能非常显著。本书后面会介绍,FastAPI 支持异步处理,使其比典型的 Web 框架快得多。

异步处理并不是魔法。在事件循环中仍需小心,避免进行过多的 CPU 密集型工作,因为那会拖慢一切。在本书的后面,你将看到 Python 的asyncawait关键字的用法,以及 FastAPI 如何让你混合使用同步和异步处理。

层次

绿巨人的粉丝可能还记得他提到自己的层次性格,对此驴子回答道:“像洋葱一样?”

fapi 01in02

嗯,如果食人魔和泪眼汪汪的蔬菜都有层次,那么软件也可以有。为了管理大小和复杂性,许多应用程序长期以来一直使用所谓的三层模型。⁴ 这并不是特别新鲜。术语可能有所不同,⁵ 但在本书中,我使用以下简单的术语分离(见图 1-1):

Web

HTTP 上的输入/输出层,组装客户端请求,调用服务层,并返回响应

服务

业务逻辑在需要时调用数据层

数据

访问数据存储和其他服务

模型

所有层共享的数据定义

Web 客户端

Web 浏览器或其他 HTTP 客户端软件

数据库

数据存储,通常是 SQL 或 NoSQL 服务器

fapi 0101

图 1-1. 垂直层次

这些组件将帮助你在无需从头开始的情况下扩展你的站点。它们不是量子力学的法则,所以请将它们视作本书阐述的指南。

各层通过 API 进行通信。这些可以是简单的函数调用到分离的 Python 模块,也可以通过任何方法访问外部代码。如前所述,这可能包括 RPC、消息等。在本书中,我假设一个单一的 Web 服务器,Python 代码导入其他 Python 模块。模块处理分离和信息隐藏。

Web 层是用户通过客户端应用程序和 API 看到的层面。通常我们讨论的是一个符合 RESTful 标准的 Web 接口,包括 URL 和 JSON 编码的请求与响应。但也可以在 Web 层之外构建替代文本(或命令行界面,CLI)客户端。Python Web 代码可以导入服务层模块,但不应导入数据模块。

服务层包含这个网站提供的实际细节。这一层本质上看起来像一个。它导入数据模块来访问数据库和外部服务,但不应知道具体细节。

数据层通过文件或客户端调用其他服务为服务层提供数据访问。还可能存在替代的数据层,与单一的服务层通信。

模型框并不是实际的层,而是各层共享的数据定义来源。如果你在它们之间传递内置的 Python 数据结构,则不需要这一层。正如你将看到的,FastAPI 包含 Pydantic,可以定义具有许多有用特性的数据结构。

为什么要进行这些分层?有很多原因,其中之一是每个层次可以:

  • 由专家撰写。

  • 在隔离环境中进行测试。

  • 替换或补充:你可以添加第二个 Web 层,使用不同的 API,比如 gRPC,与一个 Web 层并行使用。

遵循幽灵剧组的一条规则:不要交叉流动。也就是说,不要让 Web 层的细节泄露到 Web 外,或者数据库层的细节泄露到数据层外。

你可以将层次想象成一个垂直堆叠,就像英国烘焙大赛中的蛋糕^(6)。

fapi 01in03

这里有分层分离的一些原因:

  • 如果你不分离这些层,预期将会出现一个广为传播的网络模因:现在你有两个问题

  • 一旦层次混合,稍后分离将会非常困难

  • 如果代码逻辑混乱,你需要了解两个或更多专业知识来理解和编写测试将会很困难。

顺便说一句,虽然我称它们为,但你不需要假设一个层次是“高于”或“低于”另一个,并且命令是沿着重力流动的。垂直沙文主义!你也可以将层次视为侧向通信的盒子(图 1-2)。

fapi 0102

图 1-2. 侧向通信的盒子

无论你如何想象它们,盒子/层之间唯一的通信路径是箭头(API)。这对于测试和调试非常重要。如果工厂中存在未记录的门,夜间看守员将不可避免地感到惊讶。

Web 客户端与 Web 层之间的箭头使用 HTTP 或 HTTPS 传输大多数 JSON 文本。数据层与数据库之间的箭头使用特定于数据库的协议,并携带 SQL(或其他)文本。层之间的箭头是传递数据模型的函数调用。

此外,通过箭头流动的推荐数据格式如下:

客户端 ⇔ Web

使用 JSON 的 RESTful HTTP

Web ⇔ 服务

模型

服务 ⇔ 数据

模型

数据 ⇔ 数据库和服务

特定的 API

根据我自己的经验,这是我选择在本书中构建主题结构的方式。它是可行的,并且已经扩展到相当复杂的站点,但并不是神圣不可侵犯的。你可能有更好的设计!不管你如何做,以下是重要的几点:

  • 分开领域特定的细节。

  • 定义层次之间的标准 API。

  • 不作弊;不泄露。

有时候决定代码的最佳归属层次是一个挑战。例如,第十一章 讨论了认证和授权需求以及如何实现它们——作为 Web 和服务之间的额外层,或者在其中一个中。软件开发有时与艺术同样重要。

数据

虽然 Web 经常被用作关系数据库的前端,但随着 NoSQL 或 NewSQL 数据库等多种存储和访问数据的方式的发展,其它方式也在增多。

但除了数据库外,机器学习(ML) — 或者 深度学习 或仅仅 AI — 正在从根本上改变技术景观。开发大型模型需要 大量 处理数据,这在传统上被称为提取、转换、加载(ETL)。

作为一种通用的服务架构,网络可以帮助解决许多 ML 系统的琐碎问题。

复习

网络使用许多 API,特别是 RESTful 的 API。异步调用允许更好的并发性,这加快了整体过程。Web 服务应用通常足够大,可以划分为多个层次。数据已经成为一个独立的重要领域。所有这些概念都在 Python 编程语言中得到了解决,在下一章中详细介绍。

¹ 几年前我放弃了尝试。

² 风格 意味着一个更高级别的模式,如 客户端-服务器,而不是一个特定的设计。

³ 大约在穴居人与巨型地懒一起踢毽子的时候。

⁴ 选择你自己的方言:层/层次,番茄/番茄/感谢。

⁵ 你经常会看到 模型-视图-控制器(MVC) 及其变体。通常伴随着宗教战争,我对此持不可知论立场。

⁶ 正如观众所知,如果你的层次结构松散,可能下周就不会回到帐篷了。

第二章:现代 Python

这都是 Confuse-a-Cat 每天的工作内容。

Monty Python

预览

Python 在与我们变化的技术世界保持同步时在进化。本章讨论了适用于前一章问题的具体 Python 功能,以及一些额外的:

  • 工具

  • API 和服务

  • 变量和类型提示

  • 数据结构

  • Web 框架

工具

每种计算语言都有以下内容:

  • 核心语言和内置标准包

  • 添加外部包的方法

  • 推荐的外部包

  • 开发工具环境

接下来的章节列出了本书所需或推荐的 Python 工具。

这些可能随时间而改变!Python 的打包和开发工具是移动的目标,时不时会有更好的解决方案出现。

入门

你应该能够编写和运行像示例 2-1 这样的 Python 程序。

示例 2-1。这个 Python 程序是这样的:this.py
def paid_promotion():
    print("(that calls this function!)")

print("This is the program")
paid_promotion()
print("that goes like this.")

要在文本窗口或终端命令行中执行此程序,我将使用一个$ 提示(系统请求您输入一些内容)。您在提示后键入的内容显示为bold print。如果您已将示例 2-1 保存为this.py文件,则可以像在示例 2-2 中显示的那样运行它。

示例 2-2。测试 this.py
$ python this.py
This is the program
(that calls this function!)
that goes like this.

一些代码示例使用交互式的 Python 解释器,只需键入python即可获得:

$ python
Python 3.9.1 (v3.9.1:1e5d33e9b9, Dec  7 2020, 12:10:52)
[Clang 6.0 (clang-600.0.57)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>>

前几行是特定于您的操作系统和 Python 版本。这里的>>>是您的提示符。交互解释器的一个方便额外功能是,如果您键入其名称,它将为您打印变量的值:

>>> wrong_answer = 43
>>> wrong_answer
43

这也适用于表达式:

>>> wrong_answer = 43
>>> wrong_answer - 3
40

如果你对 Python 比较陌生或想要快速复习,可以阅读接下来的几节。

Python 本身

你至少需要 Python 3.7 作为基本要求。这包括像类型提示和 asyncio 这样的功能,这些是 FastAPI 的核心要求。我建议至少使用 Python 3.9,它将有更长的支持周期。Python 的标准来源是Python 软件基金会

包管理

你会想要在计算机上安全下载外部的 Python 包并将其安装。这方面的经典工具是pip

但是你如何下载这个下载器呢?如果你从 Python 软件基金会安装了 Python,你应该已经有了 pip。如果没有,请按照 pip 网站上的说明获取它。在本书中,当我介绍一个新的 Python 包时,我会包括下载它的 pip 命令。

虽然你可以用普通的 pip 做很多事情,但你可能也想使用虚拟环境,并考虑像 Poetry 这样的替代工具。

虚拟环境

Pip 将下载和安装软件包,但它们应该放在哪里?尽管标准 Python 及其包含的库通常安装在操作系统的标准位置,但您可能(并且可能不应该)能够在那里进行任何更改。Pip 使用除系统目录之外的默认目录,因此您不会覆盖系统的标准 Python 文件。您可以更改此设置;有关详细信息,请参阅 pip 网站,了解适用于您操作系统的详情。

但是通常会使用多个版本的 Python,或者为项目安装特定的版本,这样你就能确切地知道其中包含哪些软件包。为此,Python 支持虚拟环境。这些只是目录(在非 Unix 世界中称为文件夹),pip 将下载的软件包写入其中。当你激活一个虚拟环境时,你的 Shell(主系统命令解释器)在加载 Python 模块时首先查找这些目录。

这个程序就是venv,自 Python 3.4 版本起就已经包含在标准 Python 中。

让我们创建一个名为venv1的虚拟环境。您可以像独立程序一样运行 venv 模块:

$ venv venv1

或作为 Python 模块:

$ python -m venv venv1

要使这成为您当前的 Python 环境,请运行此 Shell 命令(在 Linux 或 Mac 上;有关 Windows 和其他系统的详情,请参阅 venv 文档):

$ source venv1/bin/activate

现在,每次你运行pip install,它将在venv1下安装软件包。当你运行 Python 程序时,Python 解释器和模块就在那里。

停用你的虚拟环境,请按 Control-D(Linux 或 Mac),或者键入**deactivate**(Windows)。

你可以创建像venv2这样的备选环境,并在它们之间进行停用/激活操作(尽管我希望你的命名想象力比我更强)。

Poetry

pip 和 venv 的这种组合非常常见,人们开始将它们组合在一起以节省步骤,并避免那些source命令的复杂性。其中一个这样的包是Pipenv,但一个更新的竞争对手叫做Poetry正在变得更加流行。

使用了 pip、Pipenv 和 Poetry 之后,我现在更喜欢 Poetry。用pip install poetry来获取它。Poetry 有许多子命令,比如poetry add用于向您的虚拟环境添加软件包,poetry install用于实际下载和安装它等等。查看 Poetry 网站或运行poetry命令以获取帮助。

除了下载单个软件包外,pip 和 Poetry 还管理配置文件中的多个软件包:requirements.txt用于 pip,pyproject.toml用于 Poetry。Poetry 和 pip 不仅下载软件包,还管理软件包可能对其他软件包的复杂依赖关系。您可以指定所需软件包的版本,如最小值、最大值、范围或确切值(也称为固定版本)。随着项目的增长和所依赖的软件包发生变化,这可能很重要。如果您使用的功能首次出现在某个版本中,您可能需要该软件包的最小版本,或者如果删除了某个功能,则可能需要该软件包的最大版本。

源代码格式化

源代码格式化比前几节的主题不那么重要,但仍然有帮助。避免使用工具对源代码进行格式化(小工具论)争论。一个不错的选择是 Black。使用 pip install black 安装它。

测试

测试在第十二章中有详细说明。尽管标准的 Python 测试包是 unittest,但大多数 Python 开发人员使用的产业强度 Python 测试包是 pytest。使用 pip install pytest 安装它。

源代码控制和持续集成

现在几乎普遍的源代码控制解决方案是Git,使用像 GitHub 和 GitLab 这样的存储库(repos)。使用 Git 不限于 Python 或 FastAPI,但你可能会在开发中花费大量时间使用 Git。pre-commit 工具在提交到 Git 之前在本地运行各种测试(如 blackpytest)。推送到远程 Git 存储库后,可能会在那里运行更多的持续集成(CI)测试。

第十二章 和 “故障排除” 有更多细节。

Web 工具

第三章 展示了如何安装和使用本书中使用的主要 Python Web 工具:

FastAPI

Web 框架本身

Uvicorn

异步 Web 服务器

HTTPie

一个类似于 curl 的文本 Web 客户端

Requests

同步 Web 客户端包

HTTPX

同步/异步 Web 客户端包

API 和服务

Python 的模块和包对于创建不会成为“大块泥巴”的大型应用程序至关重要。即使在单进程 Web 服务中,通过模块和导入的精心设计,你也可以保持第一章中讨论的分离。

Python 的内置数据结构非常灵活,非常诱人,可以在各处使用。但在接下来的章节中,你会看到我们可以定义更高级的模型来使我们的层间通信更清洁。这些模型依赖于一个相对较新的 Python 添加功能称为类型提示。让我们深入了解一下,但首先简要了解 Python 如何处理变量。这不会伤害到你。

变量是名称

在软件世界中,术语对象有许多定义——也许太多了。在 Python 中,对象是程序中每个不同数据的数据结构,从像 5 这样的整数,到函数,到你可能定义的任何东西。它指定了,除其他事务信息外,以下内容:

  • 一个独特的标识

  • 与硬件匹配的低级类型

  • 特定的(物理位)

  • 变量的引用计数,即指向它的变量数目

Python 在对象级别上是强类型的(它的类型不会改变,尽管其可能会)。如果一个对象的值可以被改变,则称其为可变的,否则称其为不可变的。

但在变量层面上,Python 与许多其他计算语言不同,这可能令人困惑。在许多其他语言中,变量本质上是指向内存区域的直接指针,该区域包含按照计算机硬件设计存储的原始的位。如果您给该变量赋予一个新值,语言将会用新值覆盖内存中的旧值。

这是直接且快速的。编译器跟踪了每个东西的位置。这是 C 等语言比 Python 更快的一个原因。作为开发者,您需要确保每个变量只分配正确类型的值。

现在,这里是 Python 的一个重要区别:Python 变量只是一个名称,暂时关联到内存中的一个更高级的对象。如果你给一个引用不可变对象的变量赋予一个新值,实际上你创建了一个包含该值的新对象,然后让该名称指向这个新对象。旧对象(名称曾经引用的对象)随后变为自由状态,并且如果没有其他名称仍然引用它(即其引用计数为 0),其内存可以被回收。

Introducing Python(O’Reilly)中,我将对象比作坐在内存架子上的塑料盒子,而名称/变量则是粘在这些盒子上的便签。或者你可以将名称想象为附在这些盒子上的带有字符串的标签。

通常情况下,当你使用一个名称时,你将其分配给一个对象,并且它会保持附着状态。这种简单的一致性有助于你理解你的代码。变量的作用域是名称在其中引用相同对象的代码区域,例如在函数内部。你可以在不同的作用域中使用相同的名称,但每个作用域都引用不同的对象。

虽然在 Python 程序中,您可以使一个变量引用不同的对象,但这并不一定是一个好的实践。如果不查看,您无法确定第 100 行的名称x是否与第 20 行的名称x在相同的作用域内。(顺便说一句,x是一个糟糕的名称。我们应该选择那些实际上有意义的名称。)

类型提示

所有这些背景都有一个重点。

Python 3.6 增加了类型提示,用于声明变量引用的对象的类型。这些提示并不由 Python 解释器在运行时强制执行!相反,它们可以被各种工具用来确保您对变量的使用是一致的。标准类型检查器称为mypy,我稍后会展示给你看。

类型提示可能看起来只是一件好事,就像程序员使用的许多 lint 工具,用于避免错误。例如,它可能提醒您,您的变量count引用了一个 Python 类型为int的对象。但是提示,尽管它们是可选的并且是未强制执行的注释(字面上是提示),却有意想不到的用途。在本书的后面,您将看到 FastAPI 如何调整 Pydantic 包以巧妙利用类型提示。

类型声明的添加可能是其他以前无类型语言的趋势。例如,许多 JavaScript 开发人员已转向TypeScript

数据结构

在第五章中,您将了解有关 Python 和数据结构的详细信息。

Web 框架

作为其他功能之一,Web 框架在 HTTP 字节和 Python 数据结构之间进行转换。它可以节省大量精力。另一方面,如果其中一部分不按您的需求工作,则可能需要入侵解决方案。俗话说,不要重复造轮子——除非您不能获得圆形的。

Web 服务器网关接口(WSGI)是将应用程序代码连接到 Web 服务器的同步 Python标准规范。传统的 Python Web 框架都建立在 WSGI 之上。但同步通信可能意味着等待一些比 CPU 慢得多的东西,如磁盘或网络。然后您将寻找更好的并发性。近年来,并发性变得更加重要。因此,开发了 Python 的异步服务器网关接口(ASGI)规范。第四章详细讨论了这一点。

Django

Django是一个全功能的 Web 框架,自称为“完美主义者的截止日期 Web 框架”。它由 Adrian Holovaty 和 Simon Willison 于 2003 年宣布,并以 20 世纪比利时爵士吉他手 Django Reinhardt 命名。Django 经常用于数据库支持的企业网站。在第七章中,我将更多地详细介绍 Django。

Flask

相比之下,由 Armin Ronacher 在 2010 年推出的Flask是一个微框架。第七章更多地讨论了 Flask 及其与 Django 和 FastAPI 的比较。

FastAPI

在舞会上与其他求婚者见面后,我们最终遇到了引人入胜的 FastAPI,这本书的主题。尽管 FastAPI 由 Sebastián Ramírez 于 2018 年发布,但它已经攀升至 Python Web 框架的第三位,仅次于 Flask 和 Django,并且增长速度更快。2022 年的比较显示它可能在某个时候超过它们。

注意

截至 2023 年 10 月底,GitHub 上的星标数如下:

  • Django:73.8 千

  • Flask:64.8 千

  • FastAPI:64 千

经过对替代方案的仔细调查,Ramírez 提出了一个设计,该设计主要基于两个第三方 Python 包:

  • Starlette 用于 Web 的详细信息

  • Pydantic 的数据详情

他还为最终产品添加了自己的成分和特殊酱汁。您将在下一章中看到我所指的。

回顾

本章涵盖了与今天的 Python 相关的许多内容:

  • Python Web 开发者的有用工具

  • API 和服务的显著性

  • Python 的类型提示、对象和变量

  • Web 服务的数据结构

  • Web 框架

第二部分:FastAPI 之旅

本部分的章节提供了 FastAPI 的千尺视角——更像是无人机而不是间谍卫星。它们迅速介绍基础知识,但保持在水面之上,避免将你淹没在细节中。这些章节相对较短,旨在为第三部分的深度提供背景。

当你适应了本部分的理念后,第三部分 将深入探讨这些细节。那里你可以做出一些真正的贡献,或者造成一些伤害。不评判;这取决于你。

第三章:FastAPI 指南

FastAPI 是一个现代的、快速(高性能)的 Web 框架,用于使用 Python 3.6+ 标准的类型提示构建 API。

FastAPI 的创始人 Sebastián Ramírez

预览

FastAPISebastián Ramírez 在 2018 年发布。在许多方面,它比大多数 Python Web 框架更现代化——利用了最近几年内添加到 Python 3 中的功能。本章是 FastAPI 主要特性的快速概述,重点是你需要了解的首要内容:如何处理 Web 请求和响应。

FastAPI 是什么?

与任何 Web 框架一样,FastAPI 帮助您构建 Web 应用程序。每个框架都设计了一些操作以使某些操作更加简便——通过功能、省略和默认设置。顾名思义,FastAPI 的目标是开发 Web API,尽管您也可以将其用于传统的 Web 内容应用程序。

FastAPI 网站声称具有以下优势:

性能

在某些情况下,速度与 Node.js 和 Go 一样快,这在 Python 框架中是不寻常的。

更快的开发速度

没有锋利的边缘或怪异行为。

更好的代码质量

类型提示和模型有助于减少错误。

自动生成的文档和测试页面

比手动编辑 OpenAPI 描述更容易。

FastAPI 使用以下内容:

  • Python 类型提示

  • Starlette 用于 Web 机制,包括异步支持

  • Pydantic 用于数据定义和验证

  • 特殊集成以利用和扩展其他功能

这种组合为 Web 应用程序,特别是 RESTful Web 服务,提供了令人愉悦的开发环境。

一个 FastAPI 应用程序

让我们编写一个微小的 FastAPI 应用——一个只有一个端点的 Web 服务。目前,我们处于我所谓的 Web 层,仅处理 Web 请求和响应。首先,安装我们将要使用的基本 Python 包:

  • FastAPI 框架:pip install fastapi

  • Uvicorn Web 服务器:pip install uvicorn

  • HTTPie 文本 Web 客户端:pip install httpie

  • Requests 同步 Web 客户端包:pip install requests

  • HTTPX 同步/异步 Web 客户端包:pip install httpx

虽然 curl 是最著名的文本 Web 客户端,但我认为 HTTPie 更易于使用。此外,它默认使用 JSON 编码和解码,这与 FastAPI 更匹配。本章后面,你将看到一个包含访问特定端点所需 curl 命令行语法的截图。

让我们在 示例 3-1 中跟随一个内向的 Web 开发者,并将此代码保存为文件 hello.py

示例 3-1. 一个害羞的端点(hello.py)
from fastapi import FastAPI

app = FastAPI()

@app.get("/hi")
def greet():
    return "Hello? World?"

以下是需要注意的一些要点:

  • app 是代表整个 Web 应用程序的顶级 FastAPI 对象。

  • @app.get("/hi") 是一个 路径装饰器。它告诉 FastAPI 如下内容:

    • 对于此服务器上的 "/hi" URL 的请求应该被指向以下函数。

    • 此装饰器仅适用于 HTTP 的 GET 动词。您还可以使用其他 HTTP 动词(PUTPOST 等)响应 "/hi" URL,每个对应一个单独的函数。

  • def greet() 是一个 path function,它是 HTTP 请求和响应的主要接触点。在本例中,它没有参数,但后续章节将展示 FastAPI 更多功能。

下一步是在 Web 服务器中运行此 Web 应用程序。FastAPI 本身不包含 Web 服务器,但推荐使用 Uvicorn。您可以以两种方式启动 Uvicorn 和 FastAPI Web 应用程序:外部或内部。

要通过命令行外部启动 Uvicorn,请参阅 示例 3-2。

Example 3-2. 使用命令行启动 Uvicorn
$ uvicorn hello:app --reload

hello 指的是 hello.py 文件,而 app 是其中的 FastAPI 变量名。

或者,您可以在应用程序本身内部启动 Uvicorn,如 示例 3-3。

Example 3-3. 在内部启动 Uvicorn
from fastapi import FastAPI

app = FastAPI()

@app.get("/hi")
def greet():
    return "Hello? World?"

if __name__ == "__main__":
    import uvicorn
    uvicorn.run("hello:app", reload=True)

在任一情况下,reload 告诉 Uvicorn 如果 hello.py 更改了,重新启动 Web 服务器。在本章中,我们将频繁使用此自动重新加载功能。

无论您使用外部还是内部方法,默认情况下都将在您的计算机(名为 localhost)上使用端口 8000。如果您希望使用其他设置,两种方法都有 hostport 参数。

现在服务器有一个单一的端点(/hi),并准备接受请求。

让我们用多个 web 客户端测试:

  • 对于浏览器,在顶部地址栏输入 URL。

  • 对于 HTTPie,请输入显示的命令($ 表示您系统 shell 的命令提示符)。

  • 对于 Requests 或 HTTPX,请在交互模式下使用 Python,并在 >>> 提示后输入。

如前言所述,您输入的内容位于一个

bold monospaced font

并且输出在一个

normal monospaced font

示例 3-4 到 3-7 展示了测试 Web 服务器全新 /hi 端点的不同方式。

Example 3-4. 在浏览器中测试 /hi
http://localhost:8000/hi
Example 3-5. 使用 Requests 测试 /hi
>>> import requests
>>> r = requests.get("http://localhost:8000/hi")
>>> r.json()
'Hello? World?'
Example 3-6. 使用 HTTPX 测试 /hi,这几乎与 Requests 相同
>>> import httpx
>>> r = httpx.get("http://localhost:8000/hi")
>>> r.json()
'Hello? World?'
注意

无论您使用 Requests 还是 HTTPX 来测试 FastAPI 路由都无所谓。但是 第十三章 展示了在进行其他异步调用时使用 HTTPX 的情况。因此,本章的其余示例使用 Requests。

Example 3-7. 使用 HTTPie 测试 /hi
$ http localhost:8000/hi
HTTP/1.1 200 OK
content-length: 15
content-type: application/json
date: Thu, 30 Jun 2022 07:38:27 GMT
server: uvicorn

"Hello? World?"

使用 -b 参数在 示例 3-8 中跳过响应头,并只打印主体。

Example 3-8. 使用 HTTPie 测试 /hi,仅打印响应主体
$ http -b localhost:8000/hi
"Hello? World?"

示例 3-9 获取完整的请求头和响应,带有 -v

Example 3-9. 使用 HTTPie 测试 /hi 并获取所有内容
$ http -v localhost:8000/hi
GET /hi 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: 15
content-type: application/json
date: Thu, 30 Jun 2022 08:05:06 GMT
server: uvicorn

"Hello? World?"

本书中的一些示例显示了默认的 HTTPie 输出(响应头和主体),而其他示例仅显示主体。

HTTP 请求

示例 3-9 仅包含一个特定请求:对 localhost 服务器上端口 8000GET 请求 /hi URL。

Web 请求在 HTTP 请求的不同部分中存储数据,而 FastAPI 允许您顺利访问它们。从 示例 3-9 中的示例请求开始,示例 3-10 显示了 http 命令发送到 Web 服务器的 HTTP 请求。

示例 3-10. HTTP 请求
GET /hi HTTP/1.1
Accept: /
Accept-Encoding: gzip, deflate
Connection: keep-alive
Host: localhost:8000
User-Agent: HTTPie/3.2.1

本请求包含以下内容:

  • 动词 (GET) 和路径 (/hi)

  • 任何查询参数(在此例中任何 ? 后面的文本,无)

  • 其他 HTTP 标头

  • 没有请求体内容

FastAPI 将其解析为方便的定义:

Header

HTTP 标头

Path

URL

Query

查询参数(在 URL 结尾的 ? 后面)

Body

HTTP body

注意

FastAPI 提供数据来自 HTTP 请求的各个部分的方式是其最佳特性之一,也是大多数 Python Web 框架改进的一部分。所有需要的参数可以在路径函数内声明和直接提供,使用之前列表中的定义 (Path, Query 等),以及你编写的函数。这使用了一种称为依赖注入的技术,我们将在接下来的内容中讨论,并在 第六章 中详细展开。

通过添加一个名为 who 的参数,让我们的早期应用程序变得更加个性化,以回应那个哀求般的 Hello?。我们将尝试不同的方法来传递这个新参数:

  • 在 URL 路径

  • 作为查询参数,在 URL 的 ? 后面

  • 在 HTTP body

  • 作为 HTTP 标头

URL 路径

在 示例 3-11 中编辑 hello.py

示例 3-11. 返回问候路径
from fastapi import FastAPI

app = FastAPI()

@app.get("/hi/{who}")
def greet(who):
    return f"Hello? {who}?"

一旦从编辑器保存此更改,Uvicorn 应该重新启动。(否则,我们将创建 hello2.py 等,并每次重新运行 Uvicorn。)如果有拼写错误,请不断尝试直到修复,Uvicorn 不会对你造成困扰。

在 URL 中添加 {who}(在 @app.get 后面)告诉 FastAPI 在该位置期望一个名为 who 的变量。FastAPI 然后将其分配给下面 greet() 函数中的 who 参数。这显示了路径装饰器和路径函数之间的协调。

注意

此处不要使用 Python f-string 来修改 URL 字符串 ("/hi/{who}")。大括号由 FastAPI 本身用于匹配作为路径参数的 URL 片段。

在示例 3-12 到 3-14 中,使用先前讨论的各种方法测试这个修改后的端点。

示例 3-12. 在浏览器中测试 /hi/Mom
localhost:8000/hi/Mom
示例 3-13. 使用 HTTPie 测试 /hi/Mom
$ http localhost:8000/hi/Mom
HTTP/1.1 200 OK
content-length: 13
content-type: application/json
date: Thu, 30 Jun 2022 08:09:02 GMT
server: uvicorn

"Hello? Mom?"
示例 3-14. 使用 Requests 测试 /hi/Mom
>>> import requests
>>> r = requests.get("http://localhost:8000/hi/Mom")
>>> r.json()
'Hello? Mom?'

在每种情况下,字符串 "Mom" 被作为 URL 的一部分传递,并作为 greet() 路径函数中的 who 变量传递,并作为响应的一部分返回。

在每种情况下,响应都是 JSON 字符串(取决于使用的测试客户端是单引号还是双引号) "Hello? Mom?"

查询参数

查询参数是在 URL 中 *name=value* 字符串之后的 ? 后面,用 & 字符分隔。在 示例 3-15 中再次编辑 hello.py

示例 3-15. 返回问候查询参数
from fastapi import FastAPI

app = FastAPI()

@app.get("/hi")
def greet(who):
    return f"Hello? {who}?"

端点函数再次被定义为 greet(who),但是这次在前一个装饰器行中 URL 上没有 {who},所以 FastAPI 现在假设 who 是一个查询参数。测试示例 3-16 和 3-17。

示例 3-16. 使用浏览器测试 示例 3-15
localhost:8000/hi?who=Mom
示例 3-17. 使用 HTTPie 测试 示例 3-15
$ http -b localhost:8000/hi?who=Mom
"Hello? Mom?"

在 示例 3-18 中,您可以使用查询参数调用 HTTPie(注意 ==)。

示例 3-18. 使用 HTTPie 和参数测试 示例 3-15
$ http -b localhost:8000/hi who==Mom
"Hello? Mom?"

您可以在 HTTPie 中使用多个这些参数,将它们作为空格分隔的参数更容易输入。

示例 3-19 和 3-20 展示了 Requests 的相同替代方案。

示例 3-19. 使用 Requests 测试 示例 3-15
>>> import requests
>>> r = requests.get("http://localhost:8000/hi?who=Mom")
>>> r.json()
'Hello? Mom?'
示例 3-20. 使用 Requests 和参数测试 示例 3-15
>>> import requests
>>> params = {"who": "Mom"}
>>> r = requests.get("http://localhost:8000/hi", params=params)
>>> r.json()
'Hello? Mom?'

在每种情况下,您以一种新的方式提供字符串 "Mom",并将其传递给路径函数并通过最终的响应。

主体

我们可以向 GET 端点提供路径或查询参数,但不能从请求体中获取值。在 HTTP 中,GET 应该是 幂等 的——一个计算机术语,意思是 询问相同的问题,得到相同的答案。HTTP GET 应该只返回内容。请求体用于在创建(POST)或更新(PUTPATCH)时将内容发送到服务器。第九章 展示了绕过此限制的方法。

因此,在 示例 3-21 中,让我们将端点从 GET 更改为 POST。(严格来说,我们没有创建任何内容,所以 POST 不合适,但如果 RESTful 大师起诉我们,那么嘿,看看酷炫的法院。)

示例 3-21. 返回问候主体
from fastapi import FastAPI, Body

app = FastAPI()

@app.post("/hi")
def greet(who:str = Body(embed=True)):
    return f"Hello? {who}?"
注意

这次需要 Body(embed=True) 告诉 FastAPI,我们这次从 JSON 格式的请求体中获取 who 的值。embed 部分意味着它应该像 {"who": "Mom"} 这样看起来,而不只是 "Mom"

尝试在 示例 3-22 中使用 HTTPie 进行测试,使用 -v 显示生成的请求体(注意单个 = 参数表示 JSON 主体数据)。

示例 3-22. 使用 HTTPie 测试 示例 3-21
$ http -v localhost:8000/hi who=Mom
POST /hi HTTP/1.1
Accept: application/json, /;q=0.5
Accept-Encoding: gzip, deflate
Connection: keep-alive
Content-Length: 14
Content-Type: application/json
Host: localhost:8000
User-Agent: HTTPie/3.2.1

{
    "who": "Mom"
}

HTTP/1.1 200 OK
content-length: 13
content-type: application/json
date: Thu, 30 Jun 2022 08:37:00 GMT
server: uvicorn

"Hello? Mom?"

最后,在 示例 3-23 中使用 Requests 进行测试,其中使用其 json 参数将 JSON 编码数据传递到请求体中。

示例 3-23. 使用 Requests 测试 示例 3-21
>>> import requests
>>> r = requests.post("http://localhost:8000/hi", json={"who": "Mom"})
>>> r.json()
'Hello? Mom?'

HTTP 头信息

最后,在 示例 3-24 中尝试将问候参数作为 HTTP 头信息传递。

示例 3-24. 返回问候头信息
from fastapi import FastAPI, Header

app = FastAPI()

@app.post("/hi")
def greet(who:str = Header()):
    return f"Hello? {who}?"

让我们在 示例 3-25 中只使用 HTTPie 进行测试。它使用 *name:value* 来指定 HTTP 头信息。

示例 3-25. 使用 HTTPie 测试 示例 3-24
$ http -v localhost:8000/hi who:Mom
GET /hi HTTP/1.1
Accept: */\*
Accept-Encoding: gzip, deflate
Connection: keep-alive
Host: localhost:8000
User-Agent: HTTPie/3.2.1
who: Mom

HTTP/1.1 200 OK
content-length: 13
content-type: application/json
date: Mon, 16 Jan 2023 05:14:46 GMT
server: uvicorn

"Hello? Mom?"

FastAPI 将 HTTP 头键转换为小写,并将连字符 (-) 转换为下划线 (_)。因此,您可以在示例 3-26 和 3-27 中像这样打印 HTTP User-Agent 头信息的值。

示例 3-26. 返回 User-Agent 头信息(hello.py)
from fastapi import FastAPI, Header

app = FastAPI()

@app.post("/agent")
def get_agent(user_agent:str = Header()):
    return user_agent
示例 3-27. 使用 HTTPie 测试 User-Agent 头信息
$ http -v localhost:8000/agent
GET /agent 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: 14
content-type: application/json
date: Mon, 16 Jan 2023 05:21:35 GMT
server: uvicorn

"HTTPie/3.2.1"

多个请求数据

您可以在同一个路径函数中使用多个方法。也就是说,您可以从 URL、查询参数、HTTP 主体、HTTP 头部、cookie 等获取数据。并且您可以编写自己的依赖函数,以特殊方式处理和组合它们,例如用于分页或身份验证。您将在 第六章 和 第 III 部分 的各个章节中看到其中的一些。

哪种方法最好?

这里有一些建议:

  • 在传递 URL 中的参数时,遵循 RESTful 准则是标准做法。

  • 查询字符串通常用于提供可选参数,如分页。

  • 通常用于更大输入的主体,例如整体或部分模型。

在每种情况下,如果您在数据定义中提供类型提示,您的参数将由 Pydantic 自动进行类型检查。这确保它们既存在又正确。

HTTP 响应

默认情况下,FastAPI 将从端点函数返回的任何内容转换为 JSON;HTTP 响应具有标题行 Content-type: application/json。因此,虽然 greet() 函数最初返回字符串 "Hello? World?",但 FastAPI 将其转换为 JSON。这是 FastAPI 为简化 API 开发选择的默认之一。

在这种情况下,Python 字符串 "Hello? World?" 被转换为其等效的 JSON 字符串 "Hello? World?",它仍然是同样的字符串。但是无论您返回什么,FastAPI 都会将其转换为 JSON,无论是内置的 Python 类型还是 Pydantic 模型。

状态码

默认情况下,FastAPI 返回 200 状态码;异常会引发 4*xx* 代码。

在路径装饰器中,指定应该在一切顺利时返回的 HTTP 状态码(异常将生成自己的代码并覆盖它)。将 示例 3-28 中的代码添加到您的 hello.py 中的某个位置,并使用 示例 3-29 进行测试。

示例 3-28. 指定 HTTP 状态码(添加到 hello.py)
@app.get("/happy")
def happy(status_code=200):
    return ":)"
示例 3-29. 测试 HTTP 状态码
$ http localhost:8000/happy
HTTP/1.1 200 OK
content-length: 4
content-type: application/json
date: Sun, 05 Feb 2023 04:37:32 GMT
server: uvicorn

":)"

头部

您可以像 示例 3-30 中那样注入 HTTP 响应头部(您不需要返回 response)。

示例 3-30. 设置 HTTP 头部(添加到 hello.py)
from fastapi import Response

@app.get("/header/{name}/{value}")
def header(name: str, value: str, response:Response):
    response.headers[name] = value
    return "normal body"

让我们看看是否成功(示例 3-31)。

示例 3-31. 测试响应的 HTTP 头部
$ http localhost:8000/header/marco/polo
HTTP/1.1 200 OK
content-length: 13
content-type: application/json
date: Wed, 31 May 2023 17:47:38 GMT
marco: polo
server: uvicorn

"normal body"

响应类型

响应类型(从 fastapi.responses 导入这些类)包括以下内容:

  • JSONResponse(默认)

  • HTMLResponse

  • PlainTextResponse

  • RedirectResponse

  • FileResponse

  • StreamingResponse

我将在 第十五章 中进一步讨论最后两点。

对于其他输出格式(也称为 MIME 类型),您可以使用一个通用的 Response 类,需要以下内容:

content

字符串或字节

media_type

字符串 MIME 类型

status_code

HTTP 整数状态码

headers

一个字符串 dict

类型转换

路径函数可以返回任何内容,默认情况下(使用JSONResponse),FastAPI 将其转换为 JSON 字符串并返回,包括任何 Pydantic 模型类。

但是它是如何做到的呢?如果你使用过 Python 的 json 库,可能已经看到它在给定某些数据类型(如datetime)时会引发异常。FastAPI 使用名为jsonable_encoder()的内部函数将任何数据结构转换为“可 JSON 化”的 Python 数据结构,然后调用通常的json.dumps()将其转换为 JSON 字符串。示例 3-32 展示了一个可以用 pytest 运行的测试。

示例 3-32. 使用jsonable_encoder()避免 JSON 爆炸
import datetime
import pytest
from fastapi.encoders import jsonable_encoder
import json

@pytest.fixture
def data():
    return datetime.datetime.now()

def test_json_dump(data):
    with pytest.raises(Exception):
        _ = json.dumps(data)

def test_encoder(data):
    out = jsonable_encoder(data)
    assert out
    json_out = json.dumps(out)
    assert json_out

模型类型和response_model

可能存在具有许多相同字段但一个专门用于用户输入、一个用于输出和一个用于内部使用的不同类。这些变体的一些原因可能包括以下几点:

  • 从输出中删除一些敏感信息,比如去识别个人医疗数据,如果你遇到了《健康保险可移植性和责任法案》(HIPAA)的要求。

  • 向用户输入添加字段(例如创建日期和时间)。

示例 3-33 展示了一个虚构案例的三个相关类:

  • TagIn 是定义用户需要提供的类(在本例中仅为名为tag的字符串)。

  • Tag 是基于TagIn创建的,增加了两个字段:created(创建此Tag的时间)和secret(一个内部字符串,可能存储在数据库中,但不应该对外界公开)。

  • TagOut 是定义可以返回给用户(通过查找或搜索端点)的类。它包含原始TagIn对象的tag字段及其派生的Tag对象,还有为Tag生成的created字段,但不包括secret

示例 3-33. 模型变体(model/tag.py)
from datetime import datetime
from pydantic import BaseClass

class TagIn(BaseClass):
    tag: str

class Tag(BaseClass):
    tag: str
    created: datetime
    secret: str

class TagOut(BaseClass):
    tag: str
    created: datetime

你可以以不同的方式从 FastAPI 路径函数返回除默认 JSON 以外的数据类型。一种方法是在路径装饰器中使用response_model参数,让 FastAPI 返回其他内容。FastAPI 将删除你返回的对象中出现但未在response_model指定对象中的任何字段。

在示例 3-34 中,假设你编写了一个名为service/tag.py的新服务模块,其中包含 create()get() 函数,为这个 web 模块提供调用。这些低层次的细节在此不重要。重要的是底部的get_one()路径函数以及其路径装饰器中的response_model=TagOut。这会自动将内部的Tag对象转换为经过清理的TagOut对象。

示例 3-34. 使用response_model返回不同的响应类型(web/tag.py)
import datetime
from model.tag import TagIn, Tag, TagOut
import service.tag as service

@app.post('/')
def create(tag_in: TagIn) -> TagIn:
    tag: Tag = Tag(tag=tag_in.tag, created=datetime.utcnow(),
        secret="shhhh")
    service.create(tag)
    return tag_in

@app.get('/{tag_str}', response_model=TagOut)
def get_one(tag_str: str) -> TagOut:
    tag: Tag = service.get(tag_str)
    return tag

尽管我们返回了一个Tagresponse_model将其转换为TagOut

自动化文档

本节假设您正在运行来自示例 3-21 的 Web 应用程序版本,该版本通过POST请求将who参数发送到 http://localhost:8000/hi

说服你的浏览器访问 URL http://localhost:8000/docs

你会看到一些类似于图 3-1 的东西(我裁剪了以下截图,以强调特定区域)。

文档页面

图 3-1. 生成的文档页面

那是从哪里来的?

FastAPI 从你的代码生成 OpenAPI 规范,并包括此页面来显示和测试所有你的端点。这只是它秘密酱料的一部分。

点击绿色框右侧的下箭头以打开它以进行测试(图 3-2)。

文档页面

图 3-2. 打开文档页面

点击右侧的“试一试”按钮。现在你会看到一个区域,让你在主体部分输入一个值(图 3-3)。

文档页面

图 3-3. 数据输入页面

点击那个"string"。将它改成**"Cousin Eddie"**(保持双引号)。然后点击底部的蓝色执行按钮。

现在看看执行按钮下面的响应部分(图 3-4)。

“响应主体”框显示了出现了 Cousin Eddie。

所以,这是测试网站的另一种方式(除了之前使用浏览器、HTTPie 和 Requests 的例子)。

文档页面

图 3-4. 响应页面

顺便提一下,在响应显示的 Curl 框中可以看到,与使用 HTTPie 相比,使用 curl 进行命令行测试需要更多的输入。HTTPie 的自动 JSON 编码在这里非常有帮助。

提示

这种自动化文档实际上非常重要。随着您的 Web 服务增长到数百个端点,一个始终更新的文档和测试页面将非常有帮助。

复杂数据

这些示例仅展示了如何将一个字符串传递给端点。许多端点,特别是GETDELETE端点,可能根本不需要参数,或者只需要一些简单的参数,如字符串和数字。但是,在创建(POST)或修改(PUTPATCH)资源时,我们通常需要更复杂的数据结构。第五章展示了 FastAPI 如何使用 Pydantic 和数据模型来实现这些操作的清洁方法。

复习

在这一章中,我们使用了 FastAPI 来创建一个只有一个端点的网站。多个 Web 客户端进行了测试:一个是网页浏览器,另外还有 HTTPie 文本程序、Requests Python 包和 HTTPX Python 包。从一个简单的GET调用开始,请求参数通过 URL 路径、查询参数和 HTTP 头部发送到服务器。然后,HTTP 主体用于向POST端点发送数据。接着,本章展示了如何返回不同类型的 HTTP 响应。最后,一个自动生成的表单页面为第四个测试客户端提供了文档和实时表单。

这份 FastAPI 概述将在 第八章 中进一步展开。

第四章:异步、并发和 Starlette 之旅

Starlette 是一个轻量级的 ASGI 框架/工具包,非常适合在 Python 中构建异步 Web 服务。

Starlette 的创建者 Tom Christie

预览

上一章简要介绍了开发者在编写新 FastAPI 应用时会遇到的第一件事情。本章强调了 FastAPI 的底层 Starlette 库,特别是其对async处理的支持。在概述 Python 中“同时执行多个任务”的多种方式之后,您将看到其最新的asyncawait关键字如何被整合到 Starlette 和 FastAPI 中。

Starlette

FastAPI 的许多 Web 代码基于Tom Christie 创建的 Starlette 包。它可以作为自己的 Web 框架使用,也可以作为其他框架(如 FastAPI)的库使用。与任何其他 Web 框架一样,Starlette 处理所有常规的 HTTP 请求解析和响应生成。它类似于Flask 底层的 Werkzeug

但是它最重要的特性是支持现代 Python 异步 Web 标准:ASGI。直到现在,大多数 Python Web 框架(如 Flask 和 Django)都是基于传统的同步WSGI 标准。因为 Web 应用程序经常连接到较慢的代码(例如数据库、文件和网络访问),ASGI 避免了基于 WSGI 的应用程序的阻塞和忙等待。

因此,使用 Starlette 及其相关框架的 Python Web 包是最快的,甚至与 Go 和 Node.js 应用程序一较高下。

并行计算类型

在深入了解 Starlette 和 FastAPI 提供的async支持的详细信息之前,了解一下我们可以实现concurrency的多种方式是很有用的。

parallel计算中,任务同时分布在多个专用 CPU 上。这在像图形和机器学习这样的“数值计算”应用程序中很常见。

concurrent计算中,每个 CPU 在多个任务之间切换。某些任务比其他任务花费的时间更长,我们希望减少所需的总时间。读取文件或访问远程网络服务比在 CPU 中运行计算慢得多,字面上慢了成千上百万倍。

Web 应用程序完成了大量缓慢的工作。我们如何使 Web 服务器或任何服务器运行得更快?本节讨论了一些可能性,从系统范围内到本章重点:FastAPI 对 Python 的asyncawait的实现。

分布和并行计算

如果您有一个真正大型的应用程序——一个在单个 CPU 上会显得吃力的应用程序——您可以将其分解成片段,并使这些片段在单个机器的多个 CPU 上运行或在多台机器上运行。您可以以许多种方式做到这一点,如果您拥有这样的应用程序,您已经了解了其中的一些方式。管理所有这些片段比管理单个服务器更复杂和昂贵。

本书关注的是可以放在单个盒子上的小到中型应用程序。这些应用程序可以有同步和异步代码的混合,由 FastAPI 很好地管理。

操作系统进程

操作系统(或OS,因为打字疼)调度资源:内存、CPU、设备、网络等等。它运行的每个程序都在一个或多个进程中执行其代码。操作系统为每个进程提供受管理的、受保护的资源访问,包括它们何时可以使用 CPU。

大多数系统使用抢占式进程调度,不允许任何进程独占 CPU、内存或任何其他资源。操作系统根据其设计和设置不断挂起和恢复进程。

对于开发者来说,好消息是:这不是你的问题!但通常伴随着好消息而来的坏消息是:即使你想改变,你也不能做太多事情。

对于 CPU 密集型 Python 应用程序,通常的解决方案是使用多个进程并让操作系统管理它们。Python 有一个multiprocessing module用于此目的。

操作系统线程

您也可以在单个进程内运行控制线程。Python 的threading package管理这些线程。

当程序受 I/O 限制时,通常建议使用线程,而当程序受 CPU 限制时,建议使用多个进程。但线程编程很棘手,可能导致难以找到的错误。在介绍 Python中,我把线程比作在闹鬼的房子中飘荡的幽灵:独立而看不见,只能通过它们的效果来检测。嘿,谁移动了那个烛台?

传统上,Python 保持基于进程和基于线程的库分开。开发者必须学习其中的奥秘细节才能使用它们。一个更近期的包叫做concurrent.futures,它是一个更高级别的接口,使它们更易于使用。

如您将看到的,通过新的 async 函数,您可以更轻松地获得线程的好处。FastAPI 还通过线程池管理普通同步函数(def而不是async def)的线程。

绿色线程

更神秘的机制由绿色线程,例如greenletgeventEventlet呈现出来。这些线程是协作式的(而不是抢占式的)。它们类似于操作系统线程,但在用户空间(即您的程序)而不是操作系统内核中运行。它们通过猴子补丁标准 Python 函数(在运行时修改标准 Python 函数)使并发代码看起来像正常的顺序代码:当它们阻塞等待 I/O 时,它们放弃控制权。

操作系统线程比操作系统进程“轻”(使用更少内存),而绿色线程比操作系统线程更轻。在一些基准测试中,所有异步方法通常比它们的同步对应方法更快。

注意

在你读完本章之后,你可能会想知道哪个更好:gevent 还是 asyncio?我认为并没有一个适用于所有情况的单一偏好。绿色线程早在之前就已实现(使用了来自多人在线游戏Eve Online的思想)。本书采用了 Python 的标准 asyncio,这与线程相比更简单,性能也更好。

回调

互动应用程序的开发者,如游戏和图形用户界面的开发者,可能已经熟悉回调。您编写函数并将其与事件(如鼠标点击、按键或时间)关联起来。这个类别中最显著的 Python 包是Twisted。它的名字反映了基于回调的程序有点“内外颠倒”和难以理解的现实。

Python 生成器

像大多数语言一样,Python 通常是顺序执行代码。当您调用一个函数时,Python 会从它的第一行运行到结束或return

但是在 Python 的生成器函数中,您可以在任何点停止并返回,并在以后返回到该点。这个技巧就是yield关键字。

在一集Simpsons中,Homer 将他的车撞到了一尊鹿的雕像上,接着有三行对话。 示例 4-1 定义了一个普通的 Python 函数来return这些行作为列表,并让调用者对其进行迭代。

示例 4-1. 使用 return
>>> def doh():
...     return ["Homer: D'oh!", "Marge: A deer!", "Lisa: A female deer!"]
...
>>> for line in doh():
...     print(line)
...
Homer: D'oh!
Marge: A deer!
Lisa: A female deer!

当列表相对较小时,这种方法非常有效。但是如果我们要获取所有Simpsons剧集的对话呢?列表会占用内存。

示例 4-2 展示了一个生成器函数如何分配这些行。

示例 4-2. 使用 yield
>>> def doh2():
...     yield "Homer: D'oh!"
...     yield "Marge: A deer!"
...     yield "Lisa: A female deer!"
...
>>> for line in doh2():
...     print(line)
...
Homer: D'oh!
Marge: A deer!
Lisa: A female deer!

我们不是在普通函数doh()返回的列表上进行迭代,而是在生成器函数doh2()返回的生成器对象上进行迭代。实际的迭代(for...in)看起来一样。Python 从doh2()返回第一个字符串,但会追踪它的位置以便于下一次迭代,直到函数耗尽对话。

任何包含yield的函数都是生成器函数。鉴于这种能力可以返回到函数中间并恢复执行,下一节看起来像是一个合理的适应。

Python 的 async、await 和 asyncio

Python 的asyncio特性已经在不同的版本中引入。您至少要运行 Python 3.7,这是asyncawait成为保留关键字的版本。

以下示例展示了一个只有在异步运行时才会有趣的笑话。自己运行这两个示例,因为时机很重要。

首先,运行不好笑的 示例 4-3。

示例 4-3. 枯燥
>>> import time
>>>
>>> def q():
...     print("Why can't programmers tell jokes?")
...     time.sleep(3)
...
>>> def a():
...     print("Timing!")
...
>>> def main():
...     q()
...     a()
...
>>> main()
Why can't programmers tell jokes?
Timing!

在问题和答案之间会有三秒钟的间隙。哈欠。

但是异步的 示例 4-4 有些不同。

示例 4-4. 滑稽
>>> import asyncio
>>>
>>> async def q():
...     print("Why can't programmers tell jokes?")
...     await asyncio.sleep(3)
...
>>> async def a():
...     print("Timing!")
...
>>> async def main():
...     await asyncio.gather(q(), a())
...
>>> asyncio.run(main())
Why can't programmers tell jokes?
Timing!

这次,答案应该在问题之后立即出现,然后是三秒的沉默——就像一个程序员在讲述一样。哈哈!咳咳。

在 示例 4-4 中,我使用了 asyncio.gather()asyncio.run(),但调用异步函数有多种方法。在使用 FastAPI 时,你不需要使用这些。

运行 示例 4-4 时,Python 认为是这样的:

  1. 执行 q()。现在只是第一行。

  2. 好吧,你懒惰的异步 q(),我已经设置好秒表,三秒钟后我会回来找你。

  3. 与此同时,我将运行 a(),并立即打印答案。

  4. 没有其他的 await,所以回到 q()

  5. 无聊的事件循环!我将坐在这里,等待剩下的三秒钟。

  6. 好了,现在我完成了。

此示例使用 asyncio.sleep() 用于需要一些时间的函数,就像读取文件或访问网站的函数一样。在那些可能大部分时间都在等待的函数前面加上 await。该函数在其 def 前需要有 async

注意

如果你定义了一个带有 async def 的函数,它的调用者必须在调用它之前放置一个 await。而且调用者本身必须声明为 async def,并且 调用者必须 await 它,以此类推。

顺便说一句,即使函数中没有调用其他异步函数的 await 调用,你也可以将函数声明为 async。这不会有害处。

FastAPI 和异步

经过那段漫长的田野之旅,让我们回到 FastAPI,看看为什么任何这些都很重要。

因为 Web 服务器花费大量时间在等待上,通过避免部分等待,即并发,可以提高性能。其他 Web 服务器使用了之前提到的多种方法:线程、gevent 等等。FastAPI 作为最快的 Python Web 框架之一的原因之一是其整合了异步代码,通过底层 Starlette 包的 ASGI 支持,以及其自己的一些发明。

注意

单独使用 asyncawait 并不能使代码运行更快。事实上,由于异步设置的开销,它可能会慢一点。async 的主要用途是避免长时间等待 I/O。

现在,让我们看看我们之前的 Web 端点调用,并了解如何使它们异步。

FastAPI 文档中将将 URL 映射到代码的函数称为 路径函数。我还称它们为 Web 端点,你在 第三章 中看到它们的同步示例。让我们制作一些异步的。就像之前的示例一样,我们现在只使用简单的类型,比如数字和字符串。第五章 引入了 类型提示 和 Pydantic,我们将需要处理更复杂的数据结构。

示例 4-5 重新访问了上一章的第一个 FastAPI 程序,并将其改为异步。

示例 4-5. 一个害羞的异步端点(greet_async.py)
from fastapi import FastAPI
import asyncio

app = FastAPI()

@app.get("/hi")
async def greet():
    await asyncio.sleep(1)
    return "Hello? World?"

要运行那一块 Web 代码,你需要像 Uvicorn 这样的 Web 服务器。

第一种方法是在命令行上运行 Uvicorn:

$ uvicorn greet_async:app

第二种方法,就像 示例 4-6 中一样,是在示例代码内部从主程序而不是模块中调用 Uvicorn。

示例 4-6. 另一个害羞的异步端点(greet_async_uvicorn.py)
from fastapi import FastAPI
import asyncio
import uvicorn

app = FastAPI()

@app.get("/hi")
async def greet():
    await asyncio.sleep(1)
    return "Hello? World?"

if __name__ == "__main__":
    uvicorn.run("greet_async_uvicorn:app")

当作为独立程序运行时,Python 将其命名为main。那个if __name__...的东西是 Python 只在被调用为主程序时运行它的方式。是的,这很丑陋。

这段代码在返回其畏缩的问候之前将暂停一秒钟。与使用标准sleep(1)函数的同步函数唯一的区别是,异步示例允许 Web 服务器在此期间处理其他请求。

使用asyncio.sleep(1)模拟可能需要一秒钟的真实世界函数,比如调用数据库或下载网页。后面的章节将展示从 Web 层到服务层再到数据层的这些调用的实际示例,实际上在进行真正的工作时花费这段等待时间。

FastAPI 在接收到对 URL /hiGET请求时会自行调用这个异步greet()路径函数。您不需要在任何地方添加await。但是对于您创建的任何其他async def函数定义,调用者必须在每次调用前加上await

注意

FastAPI 运行一个异步事件循环,协调异步路径函数,并为同步路径函数运行一个线程池。开发者不需要了解复杂的细节,这是一个很大的优点。例如,您不需要像之前的(独立的、非 FastAPI)笑话示例中那样运行asyncio.gather()asyncio.run()方法。

直接使用 Starlette

FastAPI 没有像它对 Pydantic 那样公开 Starlette。Starlette 主要是引擎室内不断运行的机器。

但是如果你感兴趣,你可以直接使用 Starlette 来编写 Web 应用程序。前一章节的示例 3-1 可能看起来像示例 4-7。

示例 4-7. 使用 Starlette:starlette_hello.py
from starlette.applications import Starlette
from starlette.responses import JSONResponse
from starlette.routing import Route

async def greeting(request):
    return JSONResponse('Hello? World?')

app = Starlette(debug=True, routes=[
    Route('/hi', greeting),
])

使用以下命令运行这个 Web 应用程序:

$ uvicorn starlette_hello:app

在我看来,FastAPI 的新增功能使得 Web API 的开发更加容易。

插曲:清洁线索之屋

你拥有一个小(非常小:只有你一个人)的清洁公司。你一直靠泡面过活,但刚刚签下的合同将让你能够买更好的泡面。

您的客户购买了一座建造在 Clue 棋盘游戏风格中的老宅,并希望很快在那里举办角色派对。但是这个地方一团糟。如果玛丽·康多(Marie Kondo)看到这个地方,她可能会做如下事情:

  • 尖叫

  • 笑喷

  • 逃跑

  • 以上所有内容

您的合同包括一个速度奖金。如何在最少的时间内彻底清洁这个地方?最好的方法本来应该是有更多的线索保护单元(CPU),但是你就是全部。

所以你可以尝试以下其中之一:

  • 在一个房间里做完所有事情,然后再下一个房间,以此类推。

  • 在一个房间内完成一个特定任务,然后再下一个房间,以此类推。比如在厨房和餐厅里擦拭银器,或者在台球室里擦拭台球。

你选择的方法的总时间会不同吗?也许会。但更重要的是要考虑是否需要等待任何步骤的时间。一个例子可能是在地面上:清洁地毯和打蜡后,它们可能需要几个小时才能干透,然后才能把家具搬回去。

所以,这是你每个房间的计划:

  1. 清洁所有静态部分(窗户等)。

  2. 把房间里的所有家具移到大厅。

  3. 从地毯和/或硬木地板上去除多年的污垢。

  4. 做以下任何一种:

    1. 等地毯或打蜡干透,但告别你的奖金吧。

    2. 现在去下一个房间,然后重复。在最后一个房间之后,把家具搬回第一个房间,依此类推。

等待干燥的方法是同步的,如果时间不是一个因素并且你需要休息的话可能是最好的选择。第二种是异步的,为每个房间节省等待时间。

假设你选择了异步路径,因为有钱可赚。你让旧的垃圾发光,从你感激的客户那里获得了奖金。后来的派对结果非常成功,除了这些问题:

  1. 一个没有梗的客人扮成了马里奥。

  2. 你在舞厅里打蜡过度了,在醉醺醺的普拉姆教授穿着袜子溜冰,最终撞到桌子上,把香槟洒在了斯卡雷特小姐身上。

故事的道德:

  • 需求可能会有冲突和/或奇怪。

  • 估算时间和精力可能取决于许多因素。

  • 顺序任务可能更多是一种艺术而不是科学。

  • 当所有事情都完成时,你会感觉很棒。嗯,拉面。

复习

在总览了增加并发方式后,本章扩展了使用最近的 Python 关键字asyncawait的函数。它展示了 FastAPI 和 Starlette 如何处理传统的同步函数和这些新的异步函数。

下一章介绍了 FastAPI 的第二部分:Pydantic 如何帮助您定义数据。

第五章:Pydantic、类型提示和模型之旅

使用 Python 类型提示进行数据验证和设置管理。

快速且可扩展,Pydantic 与您的 linters/IDE/大脑完美配合。使用纯粹、规范的 Python 3.6+ 定义数据,然后用 Pydantic 进行验证。

Samuel Colvin,Pydantic 的开发者

预览

FastAPI 主要依赖于一个名为 Pydantic 的 Python 包。它使用模型(Python 对象类)来定义数据结构。在编写更大型应用程序时,这些在 FastAPI 应用中广泛使用,并且是一个真正的优势。

类型提示

是时候了解更多关于 Python 类型提示 的内容了。

第二章 提到,在许多计算机语言中,变量直接指向内存中的值。这要求程序员声明其类型,以确定值的大小和位数。在 Python 中,变量只是与对象相关联的名称,而对象才有类型。

在标准编程中,变量通常与相同的对象相关联。如果我们为该变量关联一个类型提示,我们可以避免一些编程错误。因此,Python 在语言中添加了类型提示,位于标准 typing 模块中。Python 解释器会忽略类型提示语法,并将程序运行为若不存在一样。那么,它的意义何在?

你可能在一行中将变量视为字符串,但后来忘记了,并将其分配给不同类型的对象。虽然其他语言的编译器会抱怨,但 Python 不会。标准 Python 解释器将捕获常规语法错误和运行时异常,但不会检查变量类型的混合。像 mypy 这样的辅助工具会关注类型提示,并警告您任何不匹配的情况。

Python 开发者可以利用提示,编写超越类型错误检查的工具。接下来的部分将描述 Pydantic 包的开发过程,以解决一些不太明显的需求。稍后,您将看到它与 FastAPI 的集成,大大简化了许多 Web 开发问题的处理。

顺便说一句,类型提示是什么样的?变量有一种语法,函数返回值有另一种语法。

变量类型提示可能仅包括类型:

*name*: *type*

或者初始化变量并赋予一个值:

*name*: *type* = *value*

类型 可以是标准的 Python 简单类型,如 intstr,也可以是集合类型,如 tuplelistdict

thing: str = "yeti"
注意

在 Python 3.9 之前,您需要从 typing 模块导入这些标准类型名称的大写版本:

from typing import Str
thing: Str = "yeti"

这里有一些初始化的示例:

physics_magic_number: float = 1.0/137.03599913
hp_lovecraft_noun: str = "ichor"
exploding_sheep: tuple = "sis", "boom", bah!"
responses: dict = {"Marco": "Polo", "answer": 42}

您还可以包含集合的子类型:

*name*: dict[*keytype*, *valtype*] = {*key1*: *val1*, *key2*: *val2*}

typing 模块对子类型有有用的额外功能;最常见的如下:

Any

任何类型

Union

任何指定的类型,比如 Union[str, int]

注意

在 Python 3.10 及以上版本中,您可以使用 *type1* | *type2* 而不是 Union[*type1*, *type2*]

对于 Python dict 的 Pydantic 定义示例如下:

from typing import Any
responses: dict[str, Any] = {"Marco": "Polo", "answer": 42}

或者,稍微具体一点:

from typing import Union
responses: dict[str, Union[str, int]] = {"Marco": "Polo", "answer": 42}

或(Python 3.10 及以上版本):

responses: dict[str, str | int] = {"Marco": "Polo", "answer": 42}

注意,类型提示的变量行是合法的 Python 语法,但裸变量行不是:

$ python
...
>>> thing0
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
NameError: name *thing0* is not defined
>>> thing0: str

此外,正常的 Python 解释器无法捕捉到错误的类型使用:

$ python
...
>>> thing1: str = "yeti"
>>> thing1 = 47

但它们将被 mypy 捕获。如果您尚未安装它,请运行 pip install mypy。将这两行保存到一个名为 stuff.py 的文件中,¹,然后尝试这样做:

$ mypy stuff.py
stuff.py:2: error: Incompatible types in assignment
(expression has type "int", variable has type "str")
Found 1 error in 1 file (checked 1 source file)

函数返回类型提示使用箭头而不是冒号:

*function*(*args*) -> *type*:

下面是一个 Pydantic 函数返回的示例:

def get_thing() -> str:
   return "yeti"

您可以使用任何类型,包括您定义的类或它们的组合。您将在几页中看到这一点。

数据分组

通常,我们需要将一组相关的变量放在一起,而不是传递许多单独的变量。我们如何将多个变量作为一组集成,并保留类型提示?

让我们抛开前几章中单调的问候例子,从现在开始使用更丰富的数据。就像本书的其余部分一样,我们将使用神秘动物(虚构的生物)的例子,以及(同样虚构的)探险家。我们的初始神秘动物定义仅包含以下字符串变量:

name

country

两个字符的 ISO 国家代码(3166-1 alpha 2)或 * = all

area

可选;美国州或其他国家的分区

description

自由格式

aka

也称为…​

探险家将拥有以下内容:

name

country

两个字符的 ISO 国家代码

description

自由格式

Python 的历史数据分组结构(超出基本的 intstring 等)在这里列出:

tuple

一个不可变的对象序列

list

一个可变的对象序列

set

可变的不同对象

dict

可变的键-值对象对(键必须是不可变类型)

元组(示例 5-1)和列表(示例 5-2)仅允许您通过其偏移访问成员变量,因此您必须记住每个位置的内容。

示例 5-1. 使用元组
>>> tuple_thing = ("yeti", "CN", "Himalayas",
    "Hirsute Himalayan", "Abominable Snowman")
>>> print("Name is", tuple_thing[0])
Name is yeti
示例 5-2. 使用列表
>>> list_thing = ["yeti", "CN", "Himalayas",
    "Hirsute Himalayan", "Abominable Snowman"]
>>> print("Name is", list_thing[0])
Name is yeti

示例 5-3 表明,通过为整数偏移定义名称,您可以得到更详细的解释。

示例 5-3. 使用元组和命名偏移
>>> NAME = 0
>>> COUNTRY = 1
>>> AREA = 2
>>> DESCRIPTION = 3
>>> AKA = 4
>>> tuple_thing = ("yeti", "CN", "Himalayas",
    "Hirsute Himalayan", "Abominable Snowman")
>>> print("Name is", tuple_thing[NAME])
Name is yeti

在 示例 5-4 中,字典更好一些,可以通过描述性键访问。

示例 5-4. 使用字典
>>> dict_thing = {"name": "yeti",
...     "country": "CN",
...     "area": "Himalayas",
...     "description": "Hirsute Himalayan",
...     "aka": "Abominable Snowman"}
>>> print("Name is", dict_thing["name"])
Name is yeti

集合仅包含唯一值,因此对于聚类不是非常有用。

在 示例 5-5 中,命名元组 是一个可以通过整数偏移或名称访问的元组。

示例 5-5. 使用命名元组
>>> from collections import namedtuple
>>> CreatureNamedTuple = namedtuple("CreatureNamedTuple",
...     "name, country, area, description, aka")
>>> namedtuple_thing = CreatureNamedTuple("yeti",
...     "CN",
...     "Himalaya",
...     "Hirsute HImalayan",
...     "Abominable Snowman")
>>> print("Name is", namedtuple_thing[0])
Name is yeti
>>> print("Name is", namedtuple_thing.name)
Name is yeti
注意

您不能说 namedtuple_thing["name"]。它是一个 tuple,而不是一个 dict,所以索引需要是一个整数。

示例 5-6 定义了一个新的 Python class,并添加了所有属性与 self。但您需要大量键入才能定义它们。

示例 5-6. 使用标准类
>>> class CreatureClass():
...     def __init__(self,
...       name: str,
...       country: str,
...       area: str,
...       description: str,
...       aka: str):
...         self.name = name
...         self.country = country
...         self.area = area
...         self.description = description
...         self.aka = aka
...
>>> class_thing = CreatureClass(
...     "yeti",
...     "CN",
...     "Himalayas"
...     "Hirsute Himalayan",
...     "Abominable Snowman")
>>> print("Name is", class_thing.name)
Name is yeti
注意

你可能会想,没什么大不了的?使用常规类,你可以添加更多数据(属性),但特别是行为(方法)。你可能会在一个疯狂的日子里决定添加一个查找探险者最爱歌曲的方法。(这不适用于某些生物。²)但这里的用例只是为了在各层之间无干扰地移动一堆数据,并在进出时进行验证。同时,方法是方形的钉子,会在数据库的圆孔中挣扎着不合适。

Python 有没有类似于其他计算机语言所说的记录结构体(一组名称和值)?Python 的一个新特性是数据类。示例 5-7 展示了在数据类中,所有的self内容如何消失。

示例 5-7. 使用数据类
>>> from dataclasses import dataclass
>>>
>>> @dataclass
... class CreatureDataClass():
...     name: str
...     country: str
...     area: str
...     description: str
...     aka: str
...
>>> dataclass_thing = CreatureDataClass(
...     "yeti",
...     "CN",
...     "Himalayas"
...     "Hirsute Himalayan",
...     "Abominable Snowman")
>>> print("Name is", dataclass_thing.name)
Name is yeti

这对于保持变量在一起的部分相当不错。但我们想要更多,所以让我们向圣诞老人要这些:

  • 一个可能的替代类型的联合

  • 缺失/可选值

  • 默认值

  • 数据验证

  • 序列化为和从 JSON 等格式

替代方案

使用 Python 的内置数据结构,特别是字典,是很有吸引力的。但你最终会发现字典有点太“松散”。自由是有代价的。你需要检查所有的

  • 键是可选的吗?

  • 如果键缺失,是否有默认值?

  • 键是否存在?

  • 如果是,键的值是正确的类型吗?

  • 如果是,值是否在正确的范围内或匹配某个模式?

至少有三个解决方案至少解决了一些这些要求:

数据类

Python 的标准部分。

attrs

第 第三方,但比数据类更加完善。

Pydantic

也是第三方,但已集成到 FastAPI 中,如果你已经在使用 FastAPI,这是一个容易的选择。如果你正在阅读这本书,那很可能是这样。

三者的一个方便比较在YouTube上。一个结论是 Pydantic 在验证方面脱颖而出,并且与 FastAPI 的集成捕捉了许多潜在的数据错误。另一个是 Pydantic 依赖于继承(从BaseModel类),而其他两个使用 Python 装饰器来定义它们的对象。这更像是风格问题。

另一项比较中,Pydantic 在marshmallow和令人着迷的Voluptuous这样的旧验证包上表现更优。Pydantic 的另一个大优点是它使用标准的 Python 类型提示语法;旧的库在类型提示出现之前就已经存在,并自己开发了类型提示。

所以我在这本书中选择使用 Pydantic,但如果你没有使用 FastAPI,你可能会找到这两个替代方案的用法。

Pydantic 提供了指定任何组合这些检查的方法:

  • 必需与可选

  • 如果未指定但必需的默认值

  • 预期的数据类型或类型

  • 值范围限制

  • 其他基于函数的检查(如有需要)。

  • 序列化和反序列化

一个简单的示例

你已经看到如何通过 URL、查询参数或 HTTP 主体向 Web 端点提供简单的字符串。问题在于,通常请求和接收多种类型的数据组。这就是 Pydantic 模型首次出现在 FastAPI 中的地方。

此初始示例将使用三个文件:

  • model.py 定义了一个 Pydantic 模型。

  • data.py 是一个虚假数据源,定义了一个模型实例。

  • web.py 定义了一个 FastAPI Web 端点,返回虚假数据。

为了简单起见,在本章中,让我们将所有文件放在同一个目录中。在讨论更大网站的后续章节中,我们将把它们分开放置到各自的层中。首先,在 Example 5-8 中定义一个 model 用于一个生物。

Example 5-8. 定义一个生物模型:model.py
from pydantic import BaseModel

class Creature(BaseModel):
    name: str
    country: str
    area: str
    description: str
    aka: str

thing = Creature(
    name="yeti",
    country="CN",
    area="Himalayas",
    description="Hirsute Himalayan",
    aka="Abominable Snowman")
)
print("Name is", thing.name)

Creature 类继承自 Pydantic 的 BaseModel: strnamecountryareadescriptionaka 后面是类型提示,表示每个都是 Python 字符串。

注意

在本示例中,所有字段都是必需的。在 Pydantic 中,如果类型描述中没有 Optional,则字段必须具有值。

在 Example 5-9 中,如果包括它们的名称,则参数可以按任意顺序传递。

Example 5-9. 创建一个生物
>>> thing = Creature(
...     name="yeti",
...     country="CN",
...     area="Himalayas"
...     description="Hirsute Himalayan",
...     aka="Abominable Snowman")
>>> print("Name is", thing.name)
Name is yeti

现在,Example 5-10 定义了一个数据的小来源;在后续章节中,数据库将执行此操作。类型提示 list[Creature] 告诉 Python 这是一个 Creature 对象列表。

Example 5-10. 在 data.py 中定义虚假数据
from model import Creature

_creatures: list[Creature] = [
    Creature(name="yeti",
             country="CN",
             area="Himalayas",
             description="Hirsute Himalayan",
             aka="Abominable Snowman"
             ),
    Creature(name="sasquatch",
             country="US",
             area="*",
             description="Yeti's Cousin Eddie",
             aka="Bigfoot")
]

def get_creatures() -> list[Creature]:
    return _creatures

(因为大脚几乎无处不在,我们在 "*" 处使用 Bigfoot 的 area。)

此代码导入了我们刚刚编写的 model.py。它通过调用 _creaturesCreature 对象列表来进行一些数据隐藏,并提供 get_creatures() 函数来返回它们。

Example 5-11 列出了 web.py,一个定义 FastAPI Web 端点的文件。

Example 5-11. 定义一个 FastAPI Web 端点:web.py
from model import Creature
from fastapi import FastAPI

app = FastAPI()

@app.get("/creature")
def get_all() -> list[Creature]:
    from data import get_creatures
    return get_creatures()

现在,在 Example 5-12 中启动此单端点服务器。

Example 5-12. 启动 Uvicorn
$ uvicorn creature:app
INFO:     Started server process [24782]
INFO:     Waiting for application startup.
INFO:     Application startup complete.
INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)

在另一个窗口中,Example 5-13 使用 HTTPie Web 客户端访问 Web 应用程序(如果喜欢也可以尝试浏览器或 Requests 模块)。

Example 5-13. 使用 HTTPie 进行测试
$ http http://localhost:8000/creature
HTTP/1.1 200 OK
content-length: 183
content-type: application/json
date: Mon, 12 Sep 2022 02:21:15 GMT
server: uvicorn


    {
        "aka": "Abominable Snowman",
        "area": "Himalayas",
        "country": "CN",
        "name": "yeti",
        "description": "Hirsute Himalayan"
    },
    {
        "aka": "Bigfoot",
        "country": "US",
        "area": "*",
        "name": "sasquatch",
        "description": "Yeti's Cousin Eddie"
    }

FastAPI 和 Starlette 自动将原始 Creature 模型对象列表转换为 JSON 字符串。这是 FastAPI 中的默认输出格式,因此我们无需指定它。

另外,你最初启动 Uvicorn Web 服务器的窗口应该打印了一行日志:

INFO:     127.0.0.1:52375 - "GET /creature HTTP/1.1" 200 OK

验证类型

前一节展示了如何执行以下操作:

  • 将类型提示应用于变量和函数

  • 定义和使用 Pydantic 模型

  • 从数据源返回模型列表

  • 将模型列表返回给 Web 客户端,自动将模型列表转换为 JSON

现在,让我们真正开始验证数据工作。

尝试将错误类型的值分配给一个或多个 Creature 字段。让我们使用一个独立的测试来进行测试(Pydantic 不适用于任何 Web 代码;它是一个数据处理工具)。

[Example 5-14 列出了 test1.py

示例 5-14. 测试 Creature 模型
from model import Creature

dragon = Creature(
    name="dragon",
    description=["incorrect", "string", "list"],
    country="*" ,
    area="*",
    aka="firedrake")

现在在 示例 5-15 中尝试测试。

示例 5-15. 运行测试
$ python test1.py
Traceback (most recent call last):
  File ".../test1.py", line 3, in <module>
    dragon = Creature(
  File "pydantic/main.py", line 342, in
    pydantic.main.BaseModel.*init*
    pydantic.error_wrappers.ValidationError:
    1 validation error for Creature description
  str type expected (type=type_error.str)

发现我们已经将字符串列表分配给 description 字段,但它希望是一个普通的字符串。

验证值

即使值的类型与其在 Creature 类中的规格相匹配,可能仍需要通过更多检查。一些限制可以放置在值本身上:

  • 整数(conint)或浮点数:

    gt

    大于

    lt

    小于

    ge

    大于或等于

    le

    小于或等于

    multiple_of

    值的整数倍

  • 字符串(constr):

    min_length

    最小字符(非字节)长度

    max_length

    最大字符长度

    to_upper

    转换为大写

    to_lower

    转换为小写

    regex

    匹配 Python 正则表达式

  • 元组、列表或集合:

    min_items

    最小元素数量

    max_items

    最大元素数量

这些在模型的类型部分指定。

示例 5-16 确保 name 字段始终至少为两个字符长。否则,""(空字符串)是有效的字符串。

示例 5-16. 查看验证失败
>>> from pydantic import BaseModel, constr
>>>
>>> class Creature(BaseModel):
...     name: constr(min_length=2)
...     country: str
...     area: str
...     description: str
...     aka: str
...
>>> bad_creature = Creature(name="!",
...     description="it's a raccoon",
...     area="your attic")
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "pydantic/main.py", line 342,
  in pydantic.main.BaseModel.__init__
pydantic.error_wrappers.ValidationError:
1 validation error for Creature name
  ensure this value has at least 2 characters
  (type=value_error.any_str.min_length; limit_value=2)

constr 意味着受限字符串。 示例 5-17 使用另一种方式,即 Pydantic 的 Field 规范。

示例 5-17. 另一个验证失败,使用 Field
>>> from pydantic import BaseModel, Field
>>>
>>> class Creature(BaseModel):
...     name: str = Field(..., min_length=2)
...     country: str
...     area: str
...     description: str
...     aka: str
...
>>> bad_creature = Creature(name="!",
...     area="your attic",
...     description="it's a raccoon")
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "pydantic/main.py", line 342,
  in pydantic.main.BaseModel.__init__
pydantic.error_wrappers.ValidationError:
1 validation error for Creature name
  ensure this value has at least 2 characters
  (type=value_error.any_str.min_length; limit_value=2)

... 参数传递给 Field() 意味着需要一个值,并且没有默认值。

这是 Pydantic 的简要介绍。主要的收获是它允许您自动验证数据。当从 Web 或数据层获取数据时,您将看到这是多么有用。

回顾

在您的 Web 应用程序中传递的最佳数据定义方式是模型。Pydantic 利用 Python 的类型提示来定义在应用程序中传递的数据模型。接下来是:定义依赖项以将特定细节与通用代码分离。

¹ 我有任何可察觉的想象力吗?嗯……没有。

² 除了那些尖叫的雪人小团体(一个乐队的好名字)。

第六章:依赖项

预览

FastAPI 的一个非常好的设计特性之一是一种称为 依赖注入 的技术。这个术语听起来技术性和神秘,但它是 FastAPI 的一个关键方面,并在多个层面上都非常有用。本章将介绍 FastAPI 的内置能力以及如何编写您自己的能力。

什么是依赖项?

依赖 是您在某些时候需要的特定信息。获取此信息的通常方法是编写代码以获取它,就在您需要它的时候。

当您编写 Web 服务时,某时您可能需要执行以下操作:

  • 从 HTTP 请求中收集输入参数

  • 验证输入

  • 检查某些端点的用户认证和授权

  • 从数据源(通常是数据库)查找数据

  • 发出度量、日志或跟踪信息

Web 框架将 HTTP 请求字节转换为数据结构,并且在您的 Web 层函数内部逐步从中获取您需要的内容。

依赖项的问题

在你需要的时候获取你想要的内容,并且不需要外部代码知道你是如何获取它的,似乎是相当合理的。但事实证明,这样做会带来一些后果:

测试

您无法测试可能以不同方式查找依赖项的函数变体。

隐藏的依赖项

隐藏细节意味着你的函数所需的代码可能会在外部代码更改时中断。

代码重复

如果你的依赖是常见的(比如在数据库中查找用户或者组合来自 HTTP 请求的值),你可能会在多个函数中重复查找代码。

OpenAPI 可见性

FastAPI 为您生成的自动测试页面需要依赖注入机制提供的信息。

依赖注入

依赖注入 这个术语比听起来简单:将函数所需的任何 特定 信息 传递 到函数中。传统的方法是传递一个辅助函数,然后您调用它以获取特定的数据。

FastAPI 依赖项

FastAPI 更进一步:您可以将依赖项定义为函数的参数,并由 FastAPI 自动 调用并传递它们返回的 。例如,user_dep 依赖项可以从 HTTP 参数获取用户的用户名和密码,查找它们在数据库中,并返回一个标记,您可以用它来跟踪该用户之后的活动。您的 Web 处理函数永远不会直接调用这个函数;这是在函数调用时处理的。

你已经看到了一些依赖项,但并没有看到它们被称为这样:像 PathQueryBodyHeader 这样的 HTTP 数据源。这些是从 HTTP 请求中的各个区域获取请求数据的函数或 Python 类。它们隐藏了细节,如有效性检查和数据格式。

为什么不编写您自己的函数来执行此操作?您可以这样做,但您将不会有这些功能:

  • 数据有效性检查

  • 格式转换

  • 自动文档

在许多其他 Web 框架中,你会在自己的函数内部进行这些检查。你将在 第七章 中看到这方面的例子,该章节将 FastAPI 与 Flask 和 Django 等 Python Web 框架进行了比较。但在 FastAPI 中,你可以处理自己的依赖项,就像内置的依赖项一样。

编写一个依赖项

在 FastAPI 中,一个依赖项是一个被执行的东西,所以一个依赖项对象需要是 Callable 类型,其中包括函数和类——你会用到括号和可选参数。

示例 6-1 展示了一个 user_dep() 依赖函数,它接受名称和密码字符串参数,并且如果用户有效则返回 True。对于这个第一个版本,让我们让函数对任何情况都返回 True

示例 6-1. 一个依赖函数
from fastapi import FastAPI, Depends, Params

app = FastAPI()

# the dependency function:
def user_dep(name: str = Params, password: str = Params):
    return {"name": name, "valid": True}

# the path function / web endpoint:
@app.get("/user")
def get_user(user: dict = Depends(user_dep)) -> dict:
    return user

在这里,user_dep() 是一个依赖函数。它的作用类似于一个 FastAPI 路径函数(它知道诸如 Params 等的内容),但它上面没有路径装饰器。它是一个辅助函数,而不是一个 Web 端点本身。

路径函数 get_user() 表明它期望一个名为 user 的参数变量,并且该变量将从依赖函数 user_dep() 中获取其值。

注意

get_user() 的参数中,我们不能说 user = user_dep,因为 user_dep 是一个 Python 函数对象。我们也不能说 user = user_dep(),因为那样会在定义 get_user() 时调用 user_dep() 函数,而不是在使用时调用。所以我们需要额外的帮助 FastAPI Depends() 函数来在需要时调用 user_dep()

你可以在路径函数的参数列表中定义多个依赖项。

依赖范围

你可以定义依赖项来覆盖单个路径函数、一组路径函数或整个 Web 应用程序。

单一路径

在你的 路径函数 中,包含一个像这样的参数:

def *pathfunc*(*name*: *depfunc* = Depends(*depfunc*)):

或者只是这样:

def *pathfunc*(*name*: *depfunc* = Depends()):

name 是你想要称呼由 depfunc 返回的值的任何名称。

来自先前示例:

  • pathfuncget_user()

  • depfuncuser_dep()

  • nameuser

示例 6-2 使用这个路径和依赖项来返回一个固定的用户 name 和一个 valid 布尔值。

示例 6-2. 返回一个用户依赖项
from fastapi import FastAPI, Depends, Params

app = FastAPI()

# the dependency function:
def user_dep(name: str = Params, password: str = Params):
    return {"name": name, "valid": True}

# the path function / web endpoint:
@app.get("/user")
def get_user(user: dict = Depends(user_dep)) -> dict:
    return user

如果你的依赖函数只是检查一些东西而不返回任何值,你也可以在你的路径 装饰器 中定义这个依赖项(前一行,以 @ 开头):

@*app*.*method*(*url*, dependencies=[Depends(*depfunc*)])

让我们在 示例 6-3 中试试看。

示例 6-3. 定义一个用户检查依赖项
from fastapi import FastAPI, Depends, Params

app = FastAPI()

# the dependency function:
def check_dep(name: str = Params, password: str = Params):
    if not name:
        raise

# the path function / web endpoint:
@app.get("/check_user", dependencies=[Depends(check_dep)])
def check_user() -> bool:
    return True

多条路径

第九章 提供了有关如何构建更大的 FastAPI 应用程序的详细信息,包括在顶级应用程序下定义多个 路由器 对象,而不是将每个端点附加到顶级应用程序。示例 6-4 勾勒了这个想法。

示例 6-4. 定义一个子路由器依赖项
from fastapi import FastAPI, Depends, APIRouter

router = APIRouter(..., dependencies=[Depends(*`depfunc`*)])

这将导致 depfunc()router 下的所有路径函数中被调用。

全局

在定义你的顶级 FastAPI 应用对象时,你可以向其添加依赖项,这些依赖项将应用于其所有路径函数,如示例 6-5 所示。

示例 6-5. 定义应用级别依赖
from fastapi import FastAPI, Depends

def depfunc1():
    pass

def depfunc2():
    pass

app = FastAPI(dependencies=[Depends(depfunc1), Depends(depfunc2)])

@app.get("/main")
def get_main():
    pass

在这种情况下,你正在使用 pass 来忽略其他细节,以展示如何附加依赖项。

回顾

本章讨论了依赖项和依赖注入——在需要时以直接的方式获取所需数据的方法。下一章内容预告:Flask、Django 和 FastAPI 走进酒吧……

第七章:框架比较

你不需要一个框架。你需要一幅画,而不是一个框架。

演员克劳斯·金斯基

预览

对于已经使用过 Flask、Django 或流行的 Python Web 框架的开发人员,本章节指出了 FastAPI 的相似之处和差异。本章并未详尽说明每个细节,因为否则这本书的粘合剂就无法将其结合在一起。如果你考虑从这些框架之一迁移到 FastAPI 或者只是好奇,本章的比较可能会有所帮助。

关于新的 Web 框架,你可能最想知道的第一件事是如何开始,并且一种自上而下的方法是通过定义 路由(将 URL 和 HTTP 方法映射到函数)来实现。接下来的部分将比较如何在 FastAPI 和 Flask 中实现这一点,因为它们彼此之间比 Django 更相似,更有可能被同时考虑用于类似的应用程序。

Flask

Flask 自称为 微框架。它提供了基本功能,并允许您根据需要下载第三方包来补充。与 Django 相比,它更小,对于初学者来说学习起来更快。

Flask 是基于 WSGI 而不是 ASGI 的同步框架。一个名为 quart 的新项目正在复制 Flask 并添加 ASGI 支持。

让我们从顶层开始,展示 Flask 和 FastAPI 如何定义 Web 路由。

路径

在顶层,Flask 和 FastAPI 都使用装饰器来将路由与 Web 端点关联。在 示例 7-1 中,让我们复制 示例 3-11(来自 第三章)的内容,该示例从 URL 路径中获取要问候的人。

示例 7-1 FastAPI 路径
from fastapi import FastAPI

app = FastAPI()

@app.get("/hi/{who}")
def greet(who: str):
    return f"Hello? {who}?"

默认情况下,FastAPI 将 f"Hello? {who}?" 字符串转换为 JSON 并返回给 Web 客户端。

示例 7-2 展示了 Flask 的操作方式。

示例 7-2 Flask 路径
from flask import Flask, jsonify

app = Flask(__name__)

@app.route("/hi/<who>", methods=["GET"])
def greet(who: str):
    return jsonify(f"Hello? {who}?")

注意,装饰器中的 who 现在被 <> 绑定起来了。在 Flask 中,方法需要作为参数包含——除非是默认的 GET。所以 meth⁠ods=​["GET"] 在这里可以省略,但明确表达从未有过伤害。

注意

Flask 2.0 支持类似 FastAPI 风格的装饰器,如 @app.get,而不是 app.route

Flask 的 jsonify() 函数将其参数转换为 JSON 字符串并返回,同时返回带有指示其为 JSON 的 HTTP 响应头。如果返回的是 dict(而不是其他数据类型),Flask 的最新版本将自动将其转换为 JSON 并返回。显式调用 jsonify() 对所有数据类型都有效,包括 dict

查询参数

在 示例 7-3 中,让我们重复 示例 3-15,其中 who 作为查询参数传递(在 URL 中的 ? 后面)。

示例 7-3 FastAPI 查询参数
from fastapi import FastAPI

app = FastAPI()

@app.get("/hi")
def greet(who):
    return f"Hello? {who}?"

Flask 的等效方法显示在 示例 7-4 中。

示例 7-4 Flask 查询参数
from flask import Flask, request, jsonify

app = Flask(__name__)

@app.route("/hi", methods=["GET"])
def greet():
    who: str = request.args.get("who")
    return jsonify(f"Hello? {who}?")

在 Flask 中,我们需要从 request 对象中获取请求值。在这种情况下,args 是包含查询参数的 dict

主体

在示例 7-5 中,让我们复制旧的示例 3-21。

示例 7-5. FastAPI 主体
from fastapi import FastAPI

app = FastAPI()

@app.get("/hi")
def greet(who):
    return f"Hello? {who}?"

Flask 版本看起来像示例 7-6。

示例 7-6. Flask 主体
from flask import Flask, request, jsonify

app = Flask(__name__)

@app.route("/hi", methods=["GET"])
def greet():
    who: str = request.json["who"]
    return jsonify(f"Hello? {who}?")

Flask 将 JSON 输入存储在request.json中。

头部

最后,让我们重复一下示例 3-24,在示例 7-7 中。

示例 7-7. FastAPI 头部
from fastapi import FastAPI, Header

app = FastAPI()

@app.get("/hi")
def greet(who:str = Header()):
    return f"Hello? {who}?"

Flask 版本显示在示例 7-8 中。

示例 7-8. Flask 头部
from flask import Flask, request, jsonify

app = Flask(__name__)

@app.route("/hi", methods=["GET"])
def greet():
    who: str = request.headers.get("who")
    return jsonify(f"Hello? {who}?")

与查询参数类似,Flask 将请求数据保存在request对象中。这一次,是headers dict属性。头部键应该是大小写不敏感的。

Django

Django 比 Flask 或 FastAPI 更大更复杂,其目标是“有截止期的完美主义者”,根据其网站。其内置的对象关系映射器(ORM)对于具有主要数据库后端的站点非常有用。它更像是一个单体而不是一个工具包。是否值得额外的复杂性和学习曲线取决于你的应用程序。

虽然 Django 是传统的 WSGI 应用程序,但 3.0 版本添加了对 ASGI 的支持。

与 Flask 和 FastAPI 不同,Django 喜欢在单个URLConf表中定义路由(将 URL 与 Web 函数关联,它称之为视图函数),而不是使用装饰器。这使得在一个地方查看所有路由更容易,但在仅查看函数时,很难看出哪个 URL 与哪个函数关联。

其他 Web 框架特性

在前几节中比较这三个框架时,我主要比较了如何定义路由。一个 Web 框架可能也会在这些其他领域提供帮助:

表单

这三个包都支持标准的 HTML 表单。

文件

所有这些包都处理文件的上传和下载,包括多部分 HTTP 请求和响应。

模板

模板语言允许你混合文本和代码,并且对于一个内容导向的网站(HTML 文本与动态插入的数据),非常有用,而不是一个 API 网站。最著名的 Python 模板包是Jinja,并且得到了 Flask、Django 和 FastAPI 的支持。Django 还有其自己的模板语言

如果你想在基本 HTTP 之外使用网络方法,请尝试这些:

服务器发送事件

根据需要向客户端推送数据。由 FastAPI(sse-starlette)、Flask(Flask-SSE)和 Django(Django EventStream)支持。

队列

作业队列、发布-订阅和其他网络模式由外部包支持,如 ZeroMQ、Celery、Redis 和 RabbitMQ。

WebSockets

直接由 FastAPI 支持,Django(Django Channels),以及 Flask(第三方包)。

数据库

Flask 和 FastAPI 的基础包中不包括任何数据库处理,但数据库处理是 Django 的关键特性。

你的站点的数据层可能在不同级别访问数据库:

  • 直接 SQL(PostgreSQL,SQLite)

  • 直接 NoSQL(Redis、MongoDB、Elasticsearch)

  • 生成 SQL 的ORM

  • 生成 NoSQL 的对象文档/数据映射器/管理器(ODM)

对于关系数据库,SQLAlchemy是一个很好的包,包括从直接 SQL 到 ORM 的多个访问级别。这是 Flask 和 FastAPI 开发人员的常见选择。FastAPI 的作者利用了 SQLAlchemy 和 Pydantic 来创建SQLModel 包,这在第十四章中进行了更多讨论。

Django 通常是需要大量数据库的网站的框架选择。它拥有自己的ORM和一个自动化的数据库管理页面。尽管一些来源建议非技术人员使用这个管理页面进行常规数据管理,但要小心。在一个案例中,我曾见过一个非专家误解了管理页面的警告信息,导致数据库需要手动从备份中恢复。

第十四章对 FastAPI 和数据库进行了更深入的讨论。

推荐

对于基于 API 的服务,FastAPI 现在似乎是最佳选择。Flask 和 FastAPI 在快速启动服务方面几乎相同。Django 需要更多时间理解,但为更大的站点提供了许多有用的特性,特别是对于那些依赖重度数据库的站点。

其他 Python Web 框架

当前最主要的三个 Python Web 框架是 Flask、Django 和 FastAPI。谷歌python web frameworks,你会得到许多建议,我这里不会重复了。一些在这些列表中可能不太突出但因某种原因有趣的包括以下几个:

Bottle

一个非常精简(单个 Python 文件)的包,适用于快速概念验证

Litestar

类似于 FastAPI——它基于 ASGI/Starlette 和 Pydantic,但有自己的观点

AIOHTTP

带有有用的演示代码的 ASGI 客户端和服务器

Socketify.py

声称性能非常高的新参与者

回顾

Flask 和 Django 是最流行的 Python Web 框架,尽管 FastAPI 的受欢迎程度增长速度更快。这三个框架都处理基本的 Web 服务器任务,学习曲线不同。FastAPI 似乎具有更清晰的语法来指定路由,并且它的 ASGI 支持使得在许多情况下运行速度比竞争对手更快。接下来:让我们开始建立一个网站吧。

第三部分:制作网站

第二部分 是对 FastAPI 的快速浏览,让您能够迅速上手。这部分将更广泛和深入地讨论细节。我们将构建一个中型网络服务,用于访问和管理关于神秘生物(虚构的生物)和同样虚构的探险家的数据。

正如我之前所述,整个服务将有三个层次:

网络

网络界面

服务

业务逻辑

数据

整个系统的宝贵 DNA

除此之外,网络服务还将具备以下跨层组件:

模型

Pydantic 数据定义

测试

单元、集成和端到端测试

网站设计将解决以下问题:

  • 每个层次应包含哪些内容?

  • 信息是如何在各层之间传递的?

  • 我们是否可以在不破坏任何东西的情况下修改/添加/删除代码?

  • 如果出现问题,如何查找并修复?

  • 安全问题如何处理?

  • 网站能够扩展并表现良好吗?

  • 我们能够保持一切尽可能清晰简单吗?

  • 为什么我问这么多问题?为什么啊,为什么?

第八章:网页层

预览

第三章快速查看如何定义 FastAPI 的 Web 端点,将简单的字符串输入传递给它们,并获得响应。本章进一步探讨了 FastAPI 应用程序的顶层(也可以称为接口路由器层)及其与服务和数据层的集成。

正如之前一样,我将从小例子开始。然后,我会引入一些结构,将层次划分为子部分,以便进行更清晰的开发和扩展。我们写的代码越少,以后需要记住和修复的就越少。

本书中基本的示例数据涉及想象中的生物,或称为神秘动物,以及它们的探险者。你可能会发现与其他信息领域的类似之处。

一般情况下,我们如何处理信息?与大多数网站一样,我们的网站将提供以下方式来做以下事情:

  • 检索

  • 创建

  • 修改

  • 替换

  • 删除

从顶部开始,我们将创建 Web 端点来执行这些功能对我们的数据。起初,我们将提供虚假数据使端点能够与任何 Web 客户端一起工作。在接下来的章节中,我们将把虚假数据的代码移动到较低层次。在每个步骤中,我们将确保网站仍然能够正确地传递数据。最后,在第十章中,我们将放弃伪造,并在真实数据库中存储真实数据,以实现完整的端到端(Web → 服务 → 数据)网站。

注意

允许任何匿名访问者执行所有这些操作将成为“为什么我们不能拥有好东西”的一个教训。第十一章讨论了auth(身份验证和授权)需要定义角色和限制谁可以做什么。在本章的其余部分中,我们将避开认证,只展示如何处理原始的网页功能。

插曲:自上而下,自下而上,中间到外围?

在设计网站时,你可以从以下之一开始:

  • 网页层及以下工作

  • 数据层及以上

  • 服务层及两端工作

你已经有一个安装和加载数据的数据库,只是渴望找到一种与世界分享的方法吗?如果是这样,你可能想先处理数据层的代码和测试,然后是服务层,最后编写网页层。

如果你遵循领域驱动设计,你可能会从中间的服务层开始,定义你的核心实体和数据模型。或者你可能想先演化 Web 界面,并伪造调用低层,直到你知道它们的预期结果。

你会在这些书中找到非常好的设计讨论和建议:

在这些和其他来源中,您将看到诸如 六边形架构端口适配器 等术语。您如何继续的选择主要取决于您已有的数据及您希望如何处理构建站点的工作。

我猜您中的许多人主要是想尝试 FastAPI 及其相关技术,并不一定有一个预定义的成熟数据域,想要立即开始设置。

因此,在本书中,我采用了 Web 优先方法——逐步,从基本部分开始,根据需要逐步添加其他部分。有时实验有效,有时不有效。我将避免一开始就把所有内容塞进这个 Web 层。

注意

此 Web 层只是将数据在用户和服务之间传递的一种方式。还存在其他方式,例如 CLI 或软件开发工具包(SDK)。在其他框架中,您可能会看到此 Web 层称为 视图演示 层。

RESTful API 设计

HTTP 是在 Web 客户端和服务器之间传递命令和数据的一种方式。但是,就像您可以将冰箱中的食材以从可怕到美味的方式组合一样,HTTP 的一些用法比其他用法更有效。

在 第一章 中,我提到 RESTful 成为 HTTP 开发中有用的,尽管有时模糊的模型。RESTful 设计具有以下核心组件:

资源

您的应用程序管理的数据元素

标识符

唯一资源标识符

URL

结构化资源和 ID 字符串

动词或操作

伴随 URL 的用途术语:

GET

检索资源。

POST

创建新资源。

PUT

完全替换资源。

PATCH

部分替换资源。

DELETE

资源爆炸。

注意

关于 PUTPATCH 的相对优点,存在不同意见。如果您不需要区分部分修改和完全替换,可能不需要两者。

用于结合动词和包含资源和标识符的 URL 的一般 RESTful 规则使用路径参数的这些模式(URL 中 / 之间的内容):

动词 /资源/

动词 应用于所有 资源 类型的资源。

动词 /资源/id

动词 应用于带有 ID id资源

使用本书示例数据,对端点 /thingGET 请求将返回所有探险者的数据,但对 /thing/abcGET 请求将仅返回 ID 为 abcthing 资源的数据。

最后,Web 请求通常包含更多信息,指示要执行以下操作:

  • 对结果进行排序

  • 分页结果

  • 执行另一个功能

这些参数有时可以表示为 路径 参数(附加到另一个 / 后面),但通常作为 查询 参数(URL 中 ? 后面的 var=val 格式)。因为 URL 有大小限制,所以大请求通常通过 HTTP 主体传递。

注意

大多数作者建议在命名资源和相关的命名空间(如 API 部分和数据库表)时使用复数。我长期以来遵循这个建议,但现在感觉使用单数名称在许多方面更简单(包括英语语言的怪异性):

  • 一些词是它们自己的复数形式:seriesfish

  • 一些词具有不规则的复数形式:childrenpeople

  • 您需要在许多地方编写定制的单数到复数转换代码

由于这些原因,本书中许多地方我都使用了单数命名方案。这与通常的 RESTful 建议相悖,如果您不同意,可以自由忽略。

文件和目录站点布局

我们的数据主要涉及生物和探险者。最初,我们可以在单个 Python 文件中定义所有 URL 及其 FastAPI 路径函数,以访问它们的数据。让我们抵制这种诱惑,开始就像我们已经是神秘动物网站空间的新星一样。有了良好的基础,添加新功能就容易多了。

首先,在您的计算机上选择一个目录。将其命名为fastapi,或任何有助于您记住您将从本书中的代码中混合的地方。在其中,创建以下子目录:

源码

包含所有网站代码

网络

FastAPI Web 层

服务

业务逻辑层

数据

存储接口层

模型

Pydantic 模型定义

虚构的

早期硬编码(stub)数据

这些目录中的每一个很快都会增加三个文件:

init.py

必须将此目录视为一个包

creature.py

为这一层创建的代码

explorer.py

为这一层的探险者代码

许多意见存在于如何布置开发站点的问题上。此设计旨在显示层次分离并为将来的增加留出空间。

现在需要一些解释。首先,init.py文件是空的。它们是 Python 的一种黑客,因此应该将它们的目录视为 Python package,可以从中导入。其次,fake目录为较高层提供了一些 stub 数据,因为较低层正在构建。

此外,Python 的import逻辑并不严格遵循目录层次结构。它依赖于 Python 的packagesmodules。之前描述的树状结构中列出的.py文件是 Python 模块(源文件)。如果它们包含一个init.py文件,则它们的父目录是包。(这是一个约定,告诉 Python,如果你有一个叫sys的目录,你键入import sys,你实际上想要系统的还是你本地的一个。)

Python 程序可以导入包和模块。Python 解释器有一个内置的 sys.path 变量,其中包括标准 Python 代码的位置。环境变量 PYTHONPATH 是一个空字符串或以冒号分隔的目录名称字符串,告诉 Python 在检查 sys.path 之前要检查哪些父目录以查找导入的模块或包。因此,如果切换到新的 fastapi 目录,可以在 Linux 或 macOS 上输入以下内容,以确保在导入时首先检查其下的新代码:

$ export PYTHONPATH=$PWD/src

那个 $PWD 意味着 打印当前工作目录,可以避免您输入 fastapi 目录的完整路径,尽管如果愿意,您也可以输入。而 src 部分表示仅在其中查找要导入的模块和包。

要在 Windows 下设置 PWD 环境变量,请参阅“Python 软件基金会网站上的环境变量设置”

哇。

第一个网站代码

本节讨论如何使用 FastAPI 为 RESTful API 站点编写请求和响应。然后,我们将开始将这些应用到我们实际的,变得越来越复杂的站点上。

让我们从 Example 8-1 开始。在 src 中,创建这个新的顶层 main.py 程序,它将启动 Uvicorn 程序和 FastAPI 包。

Example 8-1. 主程序,main.py
from fastapi import FastAPI

app = FastAPI()

@app.get("/")
def top():
    return "top here"

if __name__ == "__main__":
    import uvicorn
    uvicorn.run("main:app", reload=True)

那个 app 就是将一切连接在一起的 FastAPI 对象。Uvicorn 的第一个参数是 "main:app",因为文件名为 main.py,第二个参数是 app,FastAPI 对象的名称。

Uvicorn 将继续运行,并在同一目录或任何子目录中进行代码更改后重新启动。如果没有 reload=True,每次修改代码后,您都需要手动杀死并重新启动 Uvicorn。在接下来的许多示例中,您将仅仅保持对同一个 main.py 文件的更改并强制重新启动,而不是创建 main2.pymain3.py 等等。

在 Example 8-2 中启动 main.py

Example 8-2. 运行主程序
$ python main.py &
INFO:     Will watch for changes in these directories: [.../fastapi']
INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
INFO:     Started reloader process [92543] using StatReload
INFO:     Started server process [92551]
INFO:     Waiting for application startup.
INFO:     Application startup complete.

最后那个 & 将程序放入后台,您可以在同一个终端窗口中运行其他程序(如果愿意的话)。或者省略 & 并在不同的窗口或标签页中运行其他代码。

现在,您可以使用浏览器或到目前为止看到的任何测试程序访问站点 localhost:8000。Example 8-3 使用 HTTPie:

Example 8-3. 测试主程序
$ http localhost:8000
HTTP/1.1 200 OK
content-length: 8
content-type: application/json
date: Sun, 05 Feb 2023 03:54:29 GMT
server: uvicorn

"top here"

从现在开始,当您进行更改时,Web 服务器应该会自动重新启动。如果出错导致其停止,请再次使用 python main.py 来重新启动它。

Example 8-4 添加了另一个测试端点,使用了 path 参数(URL 的一部分)。

Example 8-4. 添加一个端点
import uvicorn
from fastapi import FastAPI

app = FastAPI()

@app.get("/")
def top():
    return "top here"

@app.get("/echo/{thing}")
def echo(thing):
    return f"echoing {thing}"

if __name__ == "__main__":
    uvicorn.run("main:app", reload=True)

一旦您在编辑器中保存对 main.py 的更改,运行您的 Web 服务器的窗口应该会打印类似这样的内容:

WARNING:  StatReload detected changes in 'main.py'. Reloading...
INFO:     Shutting down
INFO:     Waiting for application shutdown.
INFO:     Application shutdown complete.
INFO:     Finished server process [92862]
INFO:     Started server process [92872]
INFO:     Waiting for application startup.
INFO:     Application startup complete.

Example 8-5 显示了新端点是否被正确处理(-b 仅打印响应正文)。

Example 8-5. 测试新的端点
$ http -b localhost:8000/echo/argh
"echoing argh"

在接下来的几节中,我们将在 main.py 中添加更多的端点。

请求

HTTP 请求由文本 header 后跟一个或多个 body 部分组成。你可以编写自己的代码将 HTTP 解析为 Python 数据结构,但你不会是第一个这样做的人。在你的 Web 应用程序中,让框架为你完成这些细节更具生产力。

FastAPI 的依赖注入在这里特别有用。数据可以来自 HTTP 消息的不同部分,你已经看到可以指定其中一个或多个依赖项来说明数据的位置:

Header

在 HTTP 头部中

Path

在 URL 中

Query

在 URL 中的 ? 后面

Body

在 HTTP body 中

其他更间接的来源包括以下内容:

  • 环境变量

  • 配置设置

示例 8-6 展示了一个 HTTP 请求,使用我们的老朋友 HTTPie,并忽略返回的 HTML body 数据。

示例 8-6. HTTP 请求和响应头部
$ `http -p HBh http://example.com/` GET / HTTP/1.1
Accept: `/` Accept-Encoding: gzip, deflate
Connection: keep-alive
Host: example.com
User-Agent: HTTPie/3.2.1

HTTP/1.1 200 OK
Age: 374045
Cache-Control: max-age=604800
Content-Encoding: gzip
Content-Length: 648
Content-Type: text/html; charset=UTF-8
Date: Sat, 04 Feb 2023 01:00:21 GMT
Etag: "3147526947+gzip"
Expires: Sat, 11 Feb 2023 01:00:21 GMT
Last-Modified: Thu, 17 Oct 2019 07:18:26 GMT
Server: ECS (cha/80E2)
Vary: Accept-Encoding
X-Cache: HIT

第一行要求在 example.com 上获取顶层页面(一个任何人都可以在示例中使用的免费网站)。它只请求一个 URL,没有任何其他参数。第一块行是发送到网站的 HTTP 请求头部,下一块包含 HTTP 响应头部。

注意

从这里开始的大多数测试示例不需要所有这些请求和响应头部,因此你会看到更多的使用 http -b

多个路由器

大多数 Web 服务处理多种资源类型。虽然你可以将所有路径处理代码都放在一个文件中,然后去愉快的时光中度过,但通常使用多个 子路由器 比起大多数到目前为止使用的单个 app 变量更方便。

web 目录下(与你迄今修改的 main.py 文件相同的目录中),创建一个名为 explorer.py 的文件,就像 示例 8-7 中的那样。

示例 8-7. 在 web/explorer.py 中使用 APIRouter
from fastapi import APIRouter

router = APIRouter(prefix = "/explorer")

@router.get("/")
def top():
    return "top explorer endpoint"

现在,示例 8-8 让顶级应用程序 main.py 知道有一个新的子路由器出现了,它将处理所有以 /explorer 开头的 URL:

示例 8-8. 连接主应用程序(main.py)到子路由器
from fastapi import FastAPI
from .web import explorer

app = FastAPI()

app.include_router(explorer.router)

这个新文件将被 Uvicorn 捡起。像往常一样,在 示例 8-9 中进行测试,而不是假设它会起作用。

示例 8-9. 测试新的子路由器
$ http -b localhost:8000/explorer/
"top explorer endpoint"

构建 Web 层

现在让我们开始向 Web 层添加实际的核心函数。最初,在 Web 函数本身中假装所有的数据都是假的。在 第九章 中,我们将把假数据移到相应的服务函数中,然后在 第十章 中移到数据函数中。最后,将添加一个真实的数据库供数据层访问。在每个开发步骤中,对 Web 端点的调用仍然应该有效。

定义数据模型

首先,定义我们将在各个级别之间传递的数据。我们的领域包含探险家和生物,所以让我们为它们定义最小的初始 Pydantic 模型。稍后可能会出现其他想法,例如探险、日志或咖啡杯的电子商务销售。但现在,只包括 示例 8-10 中的两个呼吸(通常是生物)模型。

示例 8-10. 在 model/explorer.py 中的模型定义
from pydantic import BaseModel

class Explorer(BaseModel):
    name: str
    country: str
    description: str

示例 8-11 从早期章节中复活了Creature的定义。

示例 8-11. 在 model/creature.py 中的模型定义
from pydantic import BaseModel

class Creature(BaseModel):
    name: str
    country: str
    area: str
    description: str
    aka: str

这些是非常简单的初始模型。你没有使用任何 Pydantic 的特性,比如必需与可选,或受限制的值。稍后可以通过不进行大规模逻辑变动来增强这段简单的代码。

对于country值,你将使用 ISO 两位字符的国家代码;这样做可以节省一些输入,但代价是查找不常见的国家。

存根和假数据

也称为mock 数据存根是返回而不调用正常“实时”模块的预制结果。它们是测试路由和响应的快速方式。

假数据是真实数据源的替代品,它执行至少一些相同的功能。一个例子是模拟数据库的内存中类。在这一章和接下来的几章中,你将制作一些假数据,填写定义层及其通信的代码。在第十章中,你将定义一个真实的生数据存储(数据库)来替换这些假数据。

通过堆栈创建通用函数

与数据示例类似,构建此站点的方法是探索性的。通常不清楚最终需要什么,所以让我们从一些对类似站点常见的部分开始。通常情况下,提供数据前端通常需要以下操作方式:

  • 获取一个、一些、全部

  • 创建

  • 完全替换

  • 部分修改

  • 删除

本质上,这些是来自数据库的 CRUD 基础知识,尽管我已经将 U 分成了部分(modify)和完整(replace)函数。也许这种区别是不必要的!这取决于数据的方向。

创建假数据

自上而下地工作,你将在所有三个级别中重复一些函数。为了节省输入,示例 8-12 引入了名为fake的顶级目录,其中的模块提供了关于探险家和生物的假数据。

示例 8-12. 新模块 fake/explorer.py
from model.explorer import Explorer

# fake data, replaced in Chapter 10 by a real database and SQL
_explorers = [
    Explorer(name="Claude Hande",
             country="FR",
             description="Scarce during full moons"),
    Explorer(name="Noah Weiser",
             country="DE",
             description="Myopic machete man"),
    ]

def get_all() -> list[Explorer]:
    """Return all explorers"""
    return _explorers

def get_one(name: str) -> Explorer | None:
    for _explorer in _explorers:
        if _explorer.name == name:
            return _explorer
    return None

# The following are nonfunctional for now,
# so they just act like they work, without modifying
# the actual fake _explorers list:
def create(explorer: Explorer) -> Explorer:
    """Add an explorer"""
    return explorer

def modify(explorer: Explorer) -> Explorer:
    """Partially modify an explorer"""
    return explorer

def replace(explorer: Explorer) -> Explorer:
    """Completely replace an explorer"""
    return explorer

def delete(name: str) -> bool:
    """Delete an explorer; return None if it existed"""
    return None

在示例 8-13 中的生物设置是类似的。

示例 8-13. 新模块 fake/creature.py
from model.creature import Creature

# fake data, until we use a real database and SQL
_creatures = [
    Creature(name="Yeti",
             aka="Abominable Snowman",
             country="CN",
             area="Himalayas",
             description="Hirsute Himalayan"),
    Creature(name="Bigfoot",
             description="Yeti's Cousin Eddie",
             country="US",
             area="*",
             aka="Sasquatch"),
    ]

def get_all() -> list[Creature]:
    """Return all creatures"""
    return _creatures

def get_one(name: str) -> Creature | None:
    """Return one creature"""
    for _creature in _creatures:
        if _creature.name == name:
            return _creature
    return None

# The following are nonfunctional for now,
# so they just act like they work, without modifying
# the actual fake _creatures list:
def create(creature: Creature) -> Creature:
    """Add a creature"""
    return creature

def modify(creature: Creature) -> Creature:
    """Partially modify a creature"""
    return creature

def replace(creature: Creature) -> Creature:
    """Completely replace a creature"""
    return creature

def delete(name: str):
    """Delete a creature; return None if it existed"""
    return None
注意

是的,模块函数几乎是相同的。当真正的数据库到来并且必须处理两个模型的不同字段时,它们将会改变。此外,你在这里使用的是单独的函数,而不是定义一个Fake类或抽象类。模块有自己的命名空间,因此它是捆绑数据和函数的等效方式。

现在让我们修改示例 8-12 和 8-13 中的 Web 函数。在构建稍后的层(服务和数据)时,导入刚刚定义的虚假数据提供程序,但在这里将其命名为 serviceimport fake.explorer as service(示例 8-14)。在 第九章 中,你将执行以下操作:

  • 创建一个新的 service/explorer.py 文件。

  • 在那里导入虚假数据。

  • 使 web/explorer.py 导入新的服务模块,而不是虚假模块。

在 第十章 中,你将在数据层做同样的事情。所有这些只是添加部分并将它们连接在一起,尽可能少地重构代码。直到稍后的 第十章 才打开电(即实时数据库和持久数据)。

示例 8-14. web/explorer.py 的新端点
from fastapi import APIRouter
from model.explorer import Explorer
import fake.explorer as service

router = APIRouter(prefix = "/explorer")

@router.get("/")
def get_all() -> list[Explorer]:
    return service.get_all()

@router.get("/{name}")
def get_one(name) -> Explorer | None:
    return service.get_one(name)

# all the remaining endpoints do nothing yet:
@router.post("/")
def create(explorer: Explorer) -> Explorer:
    return service.create(explorer)

@router.patch("/")
def modify(explorer: Explorer) -> Explorer:
    return service.modify(explorer)

@router.put("/")
def replace(explorer: Explorer) -> Explorer:
    return service.replace(explorer)

@router.delete("/{name}")
def delete(name: str):
    return None

现在,为 /creature 终点做同样的事情(示例 8-15)。是的,目前这只是类似的剪切和粘贴代码,但事先这样做简化了以后的更改——而且以后总会有更改。

示例 8-15. web/creature.py 的新端点
from fastapi import APIRouter
from model.creature import Creature
import fake.creature as service

router = APIRouter(prefix = "/creature")

@router.get("/")
def get_all() -> list[Creature]:
    return service.get_all()

@router.get("/{name}")
def get_one(name) -> Creature:
    return service.get_one(name)

# all the remaining endpoints do nothing yet:
@router.post("/")
def create(creature: Creature) -> Creature:
    return service.create(creature)

@router.patch("/")
def modify(creature: Creature) -> Creature:
    return service.modify(creature)

@router.put("/")
def replace(creature: Creature) -> Creature:
    return service.replace(creature)

@router.delete("/{name}")
def delete(name: str):
    return service.delete(name)

上次我们修改 main.py 是为了添加 /explorer URL 的子路由器。现在,让我们为 /creature 在 示例 8-16 中再添加一个。

示例 8-16. 在 main.py 中添加 creature 子路由器
import uvicorn
from fastapi import FastAPI
from web import explorer, creature

app = FastAPI()

app.include_router(explorer.router)
app.include_router(creature.router)

if __name__ == "__main__":
    uvicorn.run("main:app", reload=True)

所有这些工作都做好了吗?如果你精确地输入或粘贴了所有内容,Uvicorn 应该已经重新启动了应用程序。让我们试试手动测试。

测试!

第十二章 将展示如何使用 pytest 在各个层级自动化测试。示例 8-17 到 8-21 进行了一些手动的 Web 层测试,使用 HTTPie 测试了探险者终点。

示例 8-17. 测试获取所有终点
$ http -b localhost:8000/explorer/
[
    {
        "country": "FR",
        "name": "Claude Hande",
        "description": "Scarce during full moons"
    },
    {
        "country": "DE",
        "name": "Noah Weiser",
        "description": "Myopic machete man"
    }
]
示例 8-18. 测试获取单个终点
$ http -b localhost:8000/explorer/"Noah Weiser"
{
    "country": "DE",
    "name": "Noah Weiser",
    "description": "Myopic machete man"
}
示例 8-19. 测试替换终点
$ http -b PUT localhost:8000/explorer/"Noah Weiser"
{
    "country": "DE",
    "name": "Noah Weiser",
    "description": "Myopic machete man"
}
示例 8-20. 测试修改终点
$ http -b PATCH localhost:8000/explorer/"Noah Weiser"
{
    "country": "DE",
    "name": "Noah Weiser",
    "description": "Myopic machete man"
}
示例 8-21. 测试删除终点
$ http -b DELETE localhost:8000/explorer/Noah%20Weiser
true

$ http -b DELETE localhost:8000/explorer/Edmund%20Hillary
false

对于 /creature 终点,你可以做同样的事情。

使用 FastAPI 自动化测试表单

除了大多数示例中使用的手动测试外,FastAPI 还提供了 /docs/redocs 端点非常好的自动化测试表单。它们是同样信息的两种不同样式,所以我将在 图 8-1 中只展示 /docs 页面的一点内容。

顶部文档页面

图 8-1. 生成的文档页面

尝试第一个测试:

  1. 在上方 GET /explorer/ 部分右侧的下箭头下点击。那将打开一个大的浅蓝色表单。

  2. 点击左侧的蓝色执行按钮。你将在 图 8-2 中看到结果的顶部部分。

GET /explorer 结果页面

图 8-2. GET /explorer/ 生成的结果页面

在下面的 “响应体” 部分,你将看到到目前为止你定义的(虚假的)探险者数据返回的 JSON:

[
  {
    "name": "Claude Hande",
    "country": "FE",
    "description": "Scarce during full moons"
  },
  {
    "name": "Noah Weiser",
    "country": "DE",
    "description": "Myopic machete man"
  }
]

尝试所有其他事情。 对于一些(例如GET /explorer/{name}),您需要提供一个输入值。 您将为每个请求得到一个响应,尽管在添加数据库代码之前,有些仍然无效。 您可以在第九章和第十章结束时重复这些测试,以确保在这些代码更改期间未损坏数据管道。

与服务层和数据层交流

每当 Web 层中的函数需要由数据层管理的数据时,该函数应请求服务层作为中介。 这需要更多的代码,可能看起来是不必要的,但这是个好主意:

  • 就像罐子上的标签所说,Web 层处理 Web,数据层处理外部数据存储和服务。 完全将它们各自的详细信息保持分开,这样更安全。

  • 各层可以独立测试。 层次机制的分离允许此操作。

注意

对于非常小的站点,如果没有增加任何价值,可以跳过服务层。 第九章 最初定义了几乎只传递请求和响应的服务函数,位于 Web 和数据层之间。 至少保持 Web 和数据层分离。

服务层函数做什么? 您将在下一章中看到。 提示:它与数据层通信,但声音很低,以便 Web 层不知道确切内容。 它还定义了任何特定的业务逻辑,例如资源之间的交互。 主要是,Web 和数据层不应关心其中发生的事情。(服务层是特工机构。)

分页和排序

在 Web 界面中,当使用像GET /resource这样的 URL 模式返回许多或所有内容时,通常希望请求查找并返回以下内容:

  • 只有一件事

  • 可能很多事情

  • 所有事物

如何让我们那位心地善良但又极度直接的计算机做这些事情? 对于第一种情况,我之前提到的 RESTful 模式是在 URL 路径中包含资源的 ID。 当获取多个资源时,我们可能希望按特定顺序查看结果:

排序

排列所有的结果,即使您一次只得到一组结果。

分页

仅返回部分结果,并遵守任何排序。

在每种情况下,一组用户指定的参数表示您想要的内容。 常见的做法是将这些参数提供为查询参数。 以下是一些示例:

排序

GET /explorer?sort=country:按国家代码排序获取所有探险者。

分页

GET /explorer?offset=10&size=10:返回(在这种情况下,未排序的)整个列表中第 10 到第 19 个位置的探险者。

两者

GET /explorer?sort=country&offset=10&size=10

尽管您可以将这些指定为单独的查询参数,但 FastAPI 的依赖注入可以提供帮助:

  • 将排序和分页参数定义为 Pydantic 模型。

  • 将参数模型提供给带有路径函数参数中的Depends功能的get_all()路径函数。

排序和分页应该发生在哪里? 起初,将完整结果传递到 Web 层,并在那里使用 Python 来划分数据似乎最简单。 但那不太高效。 这些任务通常最适合在数据层中进行,因为数据库擅长处理这些事情。 我最终将在第十七章中提供一些关于这些任务的代码,该章节除了第十章中介绍的内容之外,还有更多数据库方面的小提示。

回顾

本章更详细地填补了第三章和其他章节的细节。 它开始了创建一个完整站点的过程,用于提供有关虚构生物及其探险者的信息。 从 Web 层开始,您使用 FastAPI 路径装饰器和路径函数定义了端点。 路径函数从 HTTP 请求字节的任何位置收集请求数据。 模型数据由 Pydantic 自动检查和验证。 路径函数通常将参数传递给相应的服务函数,这将在下一章中介绍。

第九章:服务层

那个中间的东西是什么?

奥托·韦斯特,《一条名为万达的鱼》

预览

本章扩展了服务层——中间层。一个漏水的屋顶可能会花费很多钱。漏水的软件不那么明显,但会花费大量时间和精力。你应该如何构建你的应用程序,以避免层间的泄漏?特别是,什么应该放入服务层中,什么不应该?

定义一个服务

服务层是网站的核心,它存在的原因。它接受来自多个来源的请求,访问构成网站 DNA 的数据,并返回响应。

常见的服务模式包括以下组合:

  • 创建 / 检索 / 修改(部分或完全)/ 删除

  • 一个东西 / 多个东西

在 RESTful 路由器层,名词是资源。在本书中,我们的资源最初将包括神秘动物(虚构生物)和人物(神秘动物探险者)。

稍后,可以定义类似这些的相关资源:

  • 地点

  • 事件(例如,探险、目击)

布局

这里是当前的文件和目录布局:

main.py
web
├── __init__.py
├── creature.py
├── explorer.py
service
├── __init__.py
├── creature.py
├── explorer.py
data
├── __init__.py
├── creature.py
├── explorer.py
model
├── __init__.py
├── creature.py
├── explorer.py
fake
├── __init__.py
├── creature.py
├── explorer.py
└── test

在本章中,你将会操作service目录中的文件。

保护

层次结构的一个好处是你不必担心一切。服务层只关心数据的输入和输出。正如你将在第十一章中看到的,一个更高层次(在本书中是Web)可以处理认证和授权的混乱问题。创建、修改和删除的功能不应该是完全开放的,甚至get函数最终可能也需要一些限制。

函数

让我们从creature.py开始。此时,explorer.py的需求几乎相同,我们几乎可以借用所有内容。编写一个处理两者的单一服务文件是如此诱人,但几乎不可避免地,我们最终会需要不同方式处理它们。

此时的服务文件基本上是一个透传层。这是一个情况,在这种情况下,开始时稍微多做一些结构化工作会在后面得到回报。就像你在第八章中为web/creature.pyweb/explorer.py所做的那样,你将为两者定义服务模块,并暂时将它们都连接到相应的fake数据模块(示例 9-1 和 9-2)。

示例 9-1. 初始的 service/creature.py 文件
from models.creature import Creature
import fake.creature as data

def get_all() -> list[Creature]:
    return data.get_all()

def get_one(name: str) -> Creature | None:
    return data.get(id)

def create(creature: Creature) -> Creature:
    return data.create(creature)

def replace(id, creature: Creature) -> Creature:
    return data.replace(id, creature)

def modify(id, creature: Creature) -> Creature:
    return data.modify(id, creature)

def delete(id, creature: Creature) -> bool:
    return data.delete(id)
示例 9-2. 初始的 service/explorer.py 文件
from models.explorer import Explorer
import fake.explorer as data

def get_all() -> list[Explorer]:
    return data.get_all()

def get_one(name: str) -> Explorer | None:
    return data.get(name)

def create(explorer: Explorer) -> Explorer:
    return data.create(explorer)

def replace(id, explorer: Explorer) -> Explorer:
    return data.replace(id, explorer)

def modify(id, explorer: Explorer) -> Explorer:
    return data.modify(id, explorer)

def delete(id, explorer: Explorer) -> bool:
    return data.delete(id)
提示

get_one()函数返回值的语法(Creature | None)至少需要 Python 3.9。对于早期版本,你需要Optional

from typing import Optional
...
def get_one(name: str) -> Optional[Creature]:
...

测试!

现在代码库逐渐完善,是引入自动化测试的好时机。(前一章的 Web 测试都是手动测试。)因此,让我们创建一些目录:

测试

一个顶层目录,与webservicedatamodel并列。

单元

练习单个函数,但不要跨层边界。

web

Web 层单元测试。

service

服务层单元测试。

数据

数据层单元测试。

完整

Also known as end-to-end or contract tests, these span all layers at once. They address the API endpoints in the Web layer.

The directories have the test_ prefix or _test suffix for use by pytest, which you’ll start to see in Example 9-4 (which runs the test in Example 9-3).

Before testing, a few API design choices need to be made. What should be returned by the get_one() function if a matching Creature or Explorer isn’t found? You can return None, as in Example 9-2. Or you could raise an exception. None of the built-in Python exception types deal directly with missing values:

  • TypeError may be the closest, because None is a different type than Creature.

  • ValueError is more suited for the wrong value for a given type, but I guess you could say that passing a missing string id to get_one(id) qualifies.

  • You could define your own MissingError if you really want to.

Whichever method you choose, the effects will bubble up all the way to the top layer.

Let’s go with the None alternative rather than the exception for now. After all, that’s what none means. Example 9-3 is a test.

Example 9-3. Service test test/unit/service/test_creature.py
from model.creature import Creature
from service import creature as code

sample = Creature(name="yeti",
        country="CN",
        area="Himalayas",
        description="Hirsute Himalayan",
        aka="Abominable Snowman",
        )

def test_create():
    resp = code.create(sample)
    assert resp == sample

def test_get_exists():
    resp = code.get_one("yeti")
    assert resp == sample

def test_get_missing():
    resp = code.get_one("boxturtle")
    assert data is None

Run the test in Example 9-4.

Example 9-4. Run the service test
$ pytest -v test/unit/service/test_creature.py
test_creature.py::test_create PASSED                         [ 16%]
test_creature.py::test_get_exists PASSED                     [ 50%]
test_creature.py::test_get_missing PASSED                    [ 66%]

======================== 3 passed in 0.06s =========================
Note

In Chapter 10, get_one() will no longer return None for a missing creature, and the test_get_missing() test in Example 9-4 would fail. But that will be fixed.

Other Service-Level Stuff

We’re in the middle of the stack now—the part that really defines our site’s purpose. And so far, we’ve used it only to forward web requests to the (next chapter’s) Data layer.

So far, this book has developed the site iteratively, building a minimal base for future work. As you learn more about what you have, what you can do, and what users might want, you can branch out and experiment. Some ideas might benefit only larger sites, but here are some technical site-helper ideas:

  • Logging

  • Metrics

  • Monitoring

  • Tracing

This section discusses each of these. We’ll revisit these options in “Troubleshooting”, to see if they can help diagnose problems.

Logging

FastAPI logs each API call to an endpoint—including the timestamp, method, and URL—but not any data delivered via the body or headers.

Metrics, Monitoring, Observability

If you run a website, you probably want to know how it’s doing. For an API website, you might want to know which endpoints are being accessed, how many people are visiting, and so on. Statistics on such factors are called metrics, and the gathering of them is monitoring or observability.

Popular metrics tools nowadays include Prometheus for gathering metrics and Grafana for displaying metrics.

Tracing

网站表现如何?通常情况下,整体指标可能很好,但这里或那里的结果令人失望。或者整个网站可能一团糟。无论哪种情况,拥有一个工具来测量 API 调用的全过程时间是很有用的——不仅仅是总体时间,还包括每个中间步骤的时间。如果某些步骤很慢,你可以找到链条中的薄弱环节。这就是追踪

一个新的开源项目已经将早期的追踪产品(如Jaeger)打造成OpenTelemetry。它具有Python API,并且至少与一个FastAPI 的集成

要使用 Python 安装和配置 OpenTelemetry,请按照OpenTelemetry Python 文档中的说明操作。

其他

这些生产问题将在第十三章讨论。除此之外,还有我们的领域——神秘动物及其相关内容?除了探险家和生物的基本信息,还有什么其他事情可能需要你考虑?你可能会想出需要对模型和其他层进行更改的新想法。以下是一些你可以尝试的想法:

  • 探险家与他们寻找的生物之间的链接

  • 观测数据

  • 探险

  • 照片和视频

  • 大脚杯子和 T 恤(见图 9-1)

fapi 0901

图 9-1. 我们的赞助商发来的一句话

这些类别通常需要定义一个或多个新模型,并创建新的模块和函数。其中一些将会在第四部分中添加,这是一个基于第三部分构建的应用程序库。

回顾

在本章中,你复制了 Web 层的一些函数,并移动了它们所使用的虚假数据。目标是启动新的服务层。到目前为止,这一过程一直是标准化的,但在此之后将会发展和分歧。下一章将构建最终的数据层,使网站真正活跃起来。

第十章:数据层

如果我没记错,我认为 Data 是该节目中的喜剧缓解角色。

Brent Spiner,《星际迷航:下一代》

预览

本章最终为我们网站的数据创建了一个持久的家,最终连接了三个层次。它使用关系数据库 SQLite,并引入了 Python 的数据库 API,名为 DB-API。第十四章更详细地介绍了数据库,包括 SQLAlchemy 包和非关系数据库。

DB-API

20 多年来,Python 已经包含了一个名为 DB-API 的关系数据库接口的基本定义:PEP 249。任何编写 Python 关系数据库驱动程序的人都应至少包括对 DB-API 的支持,尽管可能包括其他特性。

这些是主要的 DB-API 函数:

  • 使用connect()创建到数据库的连接conn

  • 使用conn.cursor()创建一个游标curs

  • 使用curs.execute(stmt)执行 SQL 字符串stmt

execute...()函数运行一个带有可选参数的 SQL 语句*stmt*字符串,列在此处:

  • 如果没有参数,则使用execute(*stmt*)

  • execute(*stmt*, *params*),使用单个序列(列表或元组)或字典中的参数*params*

  • executemany(*stmt*, *params_seq*),在序列*params_seq*中有多个参数组。

有五种指定参数的方法,并非所有数据库驱动程序都支持。如果我们有一个以"select * from creature where"开头的语句*stmt*,并且我们想要为生物的namecountry指定字符串参数,那么剩余的*stmt*字符串及其参数看起来像表 10-1 中的那些。

表 10-1。指定语句和参数

类型 语句部分 参数部分
qmark name=? or country=? (*name*, *country*)
numeric name=:0 or country=:1 (*name*, *country*)
format name=%s or country=%s (*name*, *country*)
named name=:name or country=:country {"name": *name*, "country": *​coun⁠try*}
pyformat name=%(name)s or country=%(country)s {"name": *name*, "country": *​coun⁠try*}

前三个采用元组参数,其中参数顺序与语句中的?:N%s匹配。最后两个采用字典,其中键与语句中的名称匹配。

因此,命名样式的完整调用将如示例 10-1 所示。

示例 10-1。使用命名样式参数。
stmt = """select * from creature where
 name=:name or country=:country"""
params = {"name": "yeti", "country": "CN"}
curs.execute(stmt, params)

对于 SQL INSERTDELETEUPDATE语句,execute()的返回值告诉您它的工作原理。对于SELECT,您遍历返回的数据行(作为 Python 元组),使用fetch方法:

  • fetchone()返回一个元组,或者None

  • fetchall()返回一个元组序列。

  • fetchmany(*num*)最多返回*num*个元组。

SQLite

Python 包括对一个数据库(SQLite)的支持,使用其标准包中的 sqlite3 模块。

SQLite 是不寻常的:它没有单独的数据库服务器。所有的代码都在一个库中,并且存储在一个单独的文件中。其他数据库运行独立的服务器,客户端通过特定的协议通过 TCP/IP 与它们通信。让我们将 SQLite 作为这个网站的第一个物理数据存储。第十四章 将包括其他数据库,关系型和非关系型,以及更高级的包如 SQLAlchemy 和像 ORM 这样的技术。

首先,我们需要定义我们在网站中使用的数据结构(模型)如何在数据库中表示。到目前为止,我们唯一的模型是简单而相似的,但并非完全相同:CreatureExplorer。随着我们考虑到更多要处理的事物并允许数据不断演变而无需大规模的代码更改,它们将会改变。

示例 10-2 显示了裸的 DB-API 代码和 SQL 来创建和处理第一个表。它使用了 *name* 形式的命名参数字符串(值被表示为 *name*),这是 sqlite3 包支持的。

示例 10-2. 使用 sqlite3 创建文件 data/creature.py
import sqlite3
from model.creature import Creature

DB_NAME = "cryptid.db"
conn = sqlite3.connect(DB_NAME)
curs = conn.cursor()

def init():
    curs.execute("create table creature(name, description, country, area, aka)")

def row_to_model(row: tuple) -> Creature:
    name, description, country, area, aka = row
    return Creature(name, description, country, area, aka)

def model_to_dict(creature: Creature) -> dict:
    return creature.dict()

def get_one(name: str) -> Creature:
    qry = "select * from creature where name=:name"
    params = {"name": name}
    curs.execute(qry, params)
    row = curs.fetchone()
    return row_to_model(row)

def get_all(name: str) -> list[Creature]:
    qry = "select * from creature"
    curs.execute(qry)
    rows = list(curs.fetchall())
    return [row_to_model(row) for row in rows]

def create(creature: Creature):
    qry = """insert into creature values
 (:name, :description, :country, :area, :aka)"""
    params = model_to_dict(creature)
    curs.execute(qry, params)

def modify(creature: Creature):
    return creature

def replace(creature: Creature):
    return creature

def delete(creature: Creature):
    qry = "delete from creature where name = :name"
    params = {"name": creature.name}
    curs.execute(qry, params)

在顶部附近,init() 函数连接到 sqlite3 和数据库假的 cryptid.db。它将此存储在变量 conn 中;这在 data/creature.py 模块内是全局的。接下来,curs 变量是用于迭代通过执行 SQL SELECT 语句返回的数据的 cursor;它也是该模块的全局变量。

两个实用函数在 Pydantic 模型和 DB-API 之间进行转换:

  • row_to_model() 将由 fetch 函数返回的元组转换为模型对象。

  • model_to_dict() 将 Pydantic 模型转换为字典,适合用作 named 查询参数。

到目前为止,在每一层下(Web → Service → Data)存在的虚假的 CRUD 函数现在将被替换。它们仅使用纯 SQL 和 sqlite3 中的 DB-API 方法。

布局

到目前为止,(虚假的)数据已经被分步修改:

  1. 在 第八章,我们在 web/creature.py 中制作了虚假的 *creatures* 列表。

  2. 在 第八章,我们在 web/explorer.py 中制作了虚假的 *explorers* 列表。

  3. 在 第九章,我们将假的 *creatures* 移动到 service/creature.py

  4. 在 第九章,我们将假的 *explorers* 移动到 service/explorer.py

现在数据已经最后一次移动,到 data/creature.py。但它不再是虚假的:它是真实的数据,存储在 SQLite 数据库文件 cryptids.db 中。生物数据,由于缺乏想象力,再次存储在此数据库中的 SQL 表 creature 中。

一旦保存了这个新文件,Uvicorn 应该从你的顶级 main.py 重新启动,它调用 web/creature.py,这将调用 service/creature.py,最终到这个新的 data/creature.py

让它工作

我们有一个小问题:这个模块从未调用它的 init() 函数,因此没有 SQLite 的 conncurs 可供其他函数使用。这是一个配置问题:如何在启动时提供数据库信息?可能的解决方案包括以下几种:

  • 将数据库信息硬编码到代码中,就像 Example 10-2 中那样。

  • 将信息传递到各层。但这样做会违反层次间的分离原则;Web 层和服务层不应了解数据层的内部。

  • 从不同的外部源传递信息,比如

    • 配置文件

    • 一个环境变量

环境变量很简单,并且得到了像 Twelve-Factor App 这样的建议的支持。如果环境变量未指定,代码可以包含一个默认值。这种方法也可以用于测试,以提供一个与生产环境不同的测试数据库。

在 Example 10-3 中,让我们定义一个名为 CRYPTID_SQLITE_DB 的环境变量,默认值为 cryptid.db。创建一个名为 data/init.py 的新文件用于新数据库初始化代码,以便它也可以在探险者代码中重用。

Example 10-3. 新数据初始化模块 data/init.py
"""Initialize SQLite database"""

import os
from pathlib import Path
from sqlite3 import connect, Connection, Cursor, IntegrityError

conn: Connection | None = None
curs: Cursor | None = None

def get_db(name: str|None = None, reset: bool = False):
    """Connect to SQLite database file"""
    global conn, curs
    if conn:
        if not reset:
            return
        conn = None
    if not name:
        name = os.getenv("CRYPTID_SQLITE_DB")
        top_dir = Path(__file__).resolve().parents[1] # repo top
        db_dir = top_dir / "db"
        db_name = "cryptid.db"
        db_path = str(db_dir / db_name)
        name = os.getenv("CRYPTID_SQLITE_DB", db_path)
    conn = connect(name, check_same_thread=False)
    curs = conn.cursor()

get_db()

Python 模块是一个 单例,即使多次导入也只调用一次。因此,当首次导入 init.py 时,初始化代码只会运行一次。

最后,在 Example 10-4 中修改 data/creature.py 使用这个新模块:

  • 主要是,删除第 4 至 8 行代码。

  • 哦,还要在第一次创建 creature 表的时候!

  • 表字段都是 SQL 的 text 字符串。这是 SQLite 的默认列类型(不像大多数 SQL 数据库),所以之前不需要显式包含 text,但明确写出来也无妨。

  • if not exists 避免在表已创建后覆盖它。

  • name 字段是该表的显式 primary key。如果该表存储了大量探险者数据,这个键将对快速查找至关重要。另一种选择是可怕的 表扫描,在这种情况下,数据库代码需要查看每一行,直到找到 name 的匹配项。

Example 10-4. 将数据库配置添加到 data/creature.py
from .init import conn, curs
from model.creature import Creature

curs.execute("""create table if not exists creature(
 name text primary key,
 description text,
 country text,
 area text,
 aka text)""")

def row_to_model(row: tuple) -> Creature:
    (name, description, country, area, aka) = row
    return Creature(name, description, country, area, aka)

def model_to_dict(creature: Creature) -> dict:
    return creature.dict()

def get_one(name: str) -> Creature:
    qry = "select * from creature where name=:name"
    params = {"name": name}
    curs.execute(qry, params)
    return row_to_model(curs.fetchone())

def get_all() -> list[Creature]:
    qry = "select * from creature"
    curs.execute(qry)
    return [row_to_model(row) for row in curs.fetchall()]

def create(creature: Creature) -> Creature:
    qry = "insert into creature values"
          "(:name, :description, :country, :area, :aka)"
    params = model_to_dict(creature)
    curs.execute(qry, params)
    return get_one(creature.name)

def modify(creature: Creature) -> Creature:
    qry = """update creature
 set country=:country,
 name=:name,
 description=:description,
 area=:area,
 aka=:aka
 where name=:name_orig"""
    params = model_to_dict(creature)
    params["name_orig"] = creature.name
    _ = curs.execute(qry, params)
    return get_one(creature.name)

def delete(creature: Creature) -> bool:
    qry = "delete from creature where name = :name"
    params = {"name": creature.name}
    res = curs.execute(qry, params)
    return bool(res)

通过从 init.py 中导入 conncurs,就不再需要 data/creature.py 自己导入 sqlite3 —— 除非有一天需要调用 conncurs 对象之外的另一个 sqlite3 方法。

再次,这些更改应该能够促使 Uvicorn 重新加载所有内容。从现在开始,使用迄今为止看到的任何方法进行测试(如 HTTPie 和其他工具,或自动化的 /docs 表单),都将显示持久化的数据。如果你添加了一个生物,下次获取所有生物时它将会存在。

让我们对 Example 10-5 中的探险者做同样的事情。

Example 10-5. 将数据库配置添加到 data/explorer.py
from .init import curs
from model.explorer import Explorer

curs.execute("""create table if not exists explorer(
 name text primary key,
 country text,
 description text)""")

def row_to_model(row: tuple) -> Explorer:
    return Explorer(name=row[0], country=row[1], description=row[2])

def model_to_dict(explorer: Explorer) -> dict:
    return explorer.dict() if explorer else None

def get_one(name: str) -> Explorer:
    qry = "select * from explorer where name=:name"
    params = {"name": name}
    curs.execute(qry, params)
    return row_to_model(curs.fetchone())

def get_all() -> list[Explorer]:
    qry = "select * from explorer"
    curs.execute(qry)
    return [row_to_model(row) for row in curs.fetchall()]

def create(explorer: Explorer) -> Explorer:
    qry = """insert into explorer (name, country, description)
 values (:name, :country, :description)"""
    params = model_to_dict(explorer)
    _ = curs.execute(qry, params)
    return get_one(explorer.name)

def modify(name: str, explorer: Explorer) -> Explorer:
    qry = """update explorer
 set country=:country,
 name=:name,
 description=:description
 where name=:name_orig"""
    params = model_to_dict(explorer)
    params["name_orig"] = explorer.name
    _ = curs.execute(qry, params)
    explorer2 = get_one(explorer.name)
    return explorer2

def delete(explorer: Explorer) -> bool:
    qry = "delete from explorer where name = :name"
    params = {"name": explorer.name}
    res = curs.execute(qry, params)
    return bool(res)

测试!

这是大量没有测试的代码。一切都有效吗?如果一切都有效,我会感到惊讶。所以,让我们设置一些测试。

test 目录下创建这些子目录:

单元

在一个层内

完整

跨所有层

你应该先写和运行哪种类型的测试?大多数人先写自动化单元测试;它们规模较小,而且其他所有层的组件可能尚不存在。本书的开发是自顶向下的,现在我们正在完成最后一层。此外,我们在第 8 和 9 章节做了手动测试(使用 HTTPie 和朋友们)。这些测试帮助快速暴露出错误和遗漏;自动化测试确保你以后不会反复犯同样的错误。因此,我建议以下操作:

  • 在你第一次编写代码时进行一些手动测试

  • 在你修复了 Python 语法错误之后的单元测试

  • 在你的数据流经过所有层后进行完整测试

完整测试

这些调用 web 端点,通过 Service 到 Data 的代码电梯,然后再次上升。有时这些被称为 端到端合同 测试。

获取所有探险家

在还不知道测试是否会被食人鱼侵袭的情况下,勇敢的志愿者是 示例 10-6。

示例 10-6. 获取所有探险家的测试
$ http localhost:8000/explorer
HTTP/1.1 405 Method Not Allowed
allow: POST
content-length: 31
content-type: application/json
date: Mon, 27 Feb 2023 20:05:18 GMT
server: uvicorn

{
    "detail": "Method Not Allowed"
}

唉呀!发生了什么事?

哦。测试要求 /explorer,而不是 /explorer/,并且没有适用于 URL /explorer(没有最终斜杠的情况下)的 GET 方法路径函数。在 web/explorer.py 中,get_all() 路径函数的路径装饰器如下所示:

@router.get("/")

这,再加上之前的代码

router = APIRouter(prefix = "/explorer")

意味着这个 get_all() 路径函数提供了一个包含 /explorer/ 的 URL。

示例 10-7 高兴地展示,你可以为一个路径函数添加多个路径装饰器。

示例 10-7. 为 get_all() 路径函数添加一个非斜杠路径装饰器
@router.get("")
@router.get("/")
def get_all() -> list[Explorer]:
    return service.get_all()

在示例 10-8 和 10-9 中的两个网址的测试。

示例 10-8. 测试非斜杠端点
$ http localhost:8000/explorer
HTTP/1.1 200 OK
content-length: 2
content-type: application/json
date: Mon, 27 Feb 2023 20:12:44 GMT
server: uvicorn

[]
示例 10-9. 测试斜杠端点
$ http localhost:8000/explorer/
HTTP/1.1 200 OK
content-length: 2
content-type: application/json
date: Mon, 27 Feb 2023 20:14:39 GMT
server: uvicorn

[]

现在这两者都正常工作后,创建一个探险家,并在之后重新测试。示例 10-10 尝试这样做,但有一个情节转折。

示例 10-10. 测试创建探险家时的输入错误
$ http post localhost:8000/explorer name="Beau Buffette", contry="US"
HTTP/1.1 422 Unprocessable Entity
content-length: 95
content-type: application/json
date: Mon, 27 Feb 2023 20:17:45 GMT
server: uvicorn

{
    "detail": [
        {
            "loc": [
                "body",
                "country"
            ],
            "msg": "field required",
            "type": "value_error.missing"
        }
    ]
}

我拼错了 country,虽然我的拼写通常是无可挑剔的。Pydantic 在 Web 层捕获了这个错误,返回了 422 的 HTTP 状态码和问题的描述。一般来说,如果 FastAPI 返回 422,那么 Pydantic 找到了问题的源头。"loc" 部分指出了错误发生的位置:字段 "country" 丢失,因为我是一个笨拙的打字员。

修正拼写并在 示例 10-11 中重新测试。

示例 10-11. 使用正确的值创建一个探险家
$ http post localhost:8000/explorer name="Beau Buffette" country="US"
HTTP/1.1 201 Created
content-length: 55
content-type: application/json
date: Mon, 27 Feb 2023 20:20:49 GMT
server: uvicorn

{
    "name": "Beau Buffette,",
    "country": "US",
    "description": ""
}

这次调用返回了 201 状态码,这在资源创建时是传统的(所有 2*xx* 状态码都被认为表示成功,其中最通用的是 200)。响应还包含刚刚创建的 Explorer 对象的 JSON 版本。

现在回到最初的测试:Beau 会出现在获取所有探险家的测试中吗?示例 10-12 回答了这个激动人心的问题。

示例 10-12. 最新的create()工作了吗?
$ http localhost:8000/explorer
HTTP/1.1 200 OK
content-length: 57
content-type: application/json
date: Mon, 27 Feb 2023 20:26:26 GMT
server: uvicorn

[
    {
        "name": "Beau Buffette",
        "country": "US",
        "description": ""
    }
]

耶。

获取一个资源管理器

现在,如果您尝试使用 Get One 端点(示例 10-13)查找 Beau 会发生什么?

示例 10-13. 测试获取单个端点
$ http localhost:8000/explorer/"Beau Buffette"
HTTP/1.1 200 OK
content-length: 55
content-type: application/json
date: Mon, 27 Feb 2023 20:28:48 GMT
server: uvicorn

{
    "name": "Beau Buffette",
    "country": "US",
    "description": ""
}

我使用引号保留了名字中的第一个和最后一个名字之间的空格。在 URL 中,您也可以使用Beau%20Buffette%20是空格字符在 ASCII 中的十六进制代码。

缺少和重复的数据

到目前为止,我忽略了两个主要的错误类别:

缺少数据

如果您尝试通过数据库中不存在的名称获取、修改或删除资源管理器。

重复数据

如果您尝试多次使用相同的名称创建资源管理器。

那么,如果您请求一个不存在的或重复的资源管理器会发生什么?到目前为止,代码过于乐观,异常将从深渊中冒出。

我们的朋友 Beau 刚刚被添加到数据库中。想象一下,他的邪恶克隆人(与他同名)密谋在一个黑暗的夜晚替换他,使用 示例 10-14。

示例 10-14. 重复错误:尝试多次创建资源管理器
$ http post localhost:8000/explorer name="Beau Buffette" country="US"
HTTP/1.1 500 Internal Server Error
content-length: 3127
content-type: text/plain; charset=utf-8
date: Mon, 27 Feb 2023 21:04:09 GMT
server: uvicorn

Traceback (most recent call last):
  File ".../starlette/middleware/errors.py", line 162, in *call*
... (lots of confusing innards here) ...
  File ".../service/explorer.py", line 11, in create
    return data.create(explorer)
           ^^^^^^^
  File ".../data/explorer.py", line 37, in create
    curs.execute(qry, params)
sqlite3.IntegrityError: UNIQUE constraint failed: explorer.name

我省略了错误跟踪中的大部分行(并用省略号替换了一些部分),因为它主要包含 FastAPI 和底层 Starlette 进行的内部调用。但是最后一行:Web 层中的一个 SQLite 异常!晕厥沙发在哪里?

紧随其后,另一个恐怖在 示例 10-15 中出现:一个缺少的资源管理器。

示例 10-15. 获取一个不存在的资源管理器
$ http localhost:8000/explorer/"Beau Buffalo"
HTTP/1.1 500 Internal Server Error
content-length: 3282
content-type: text/plain; charset=utf-8
date: Mon, 27 Feb 2023 21:09:37 GMT
server: uvicorn

Traceback (most recent call last):
  File ".../starlette/middleware/errors.py", line 162, in *call*
... (many lines of ancient cuneiform) ...
  File ".../data/explorer.py", line 11, in row_to_model
    name, country, description = row
    ^^^^^^^
TypeError: cannot unpack non-iterable NoneType object

什么是在底层(数据)层捕获这些异常并将详细信息传达给顶层(Web)的好方法?可能的方法包括以下几种:

  • 让 SQLite 吐出一个毛球(异常),并在 Web 层处理它。

    • 但是:这混淆了各个层级,这是不好的。Web 层不应该知道任何关于具体数据库的信息。
  • 让服务和数据层中的每个函数返回Explorer | None,而不是返回Explorer。然后,None表示失败。(您可以通过在model/explorer.py中定义OptExplorer = Explorer | None来缩短这个过程。)

    • 但是:函数可能因多种原因失败,您可能需要详细信息。这需要大量的代码编辑。
  • MissingDuplicate数据定义异常,包括问题的详细信息。这些异常将通过各个层级传播,无需更改代码,直到 Web 路径函数捕获它们。它们也是应用程序特定的,而不是数据库特定的,保持了各个层级的独立性。

    • 但是:实际上,我喜欢这个,所以它放在 示例 10-16 中。
示例 10-16. 定义一个新的顶级 errors.py
class Missing(Exception):
    def __init__(self, msg:str):
        self.msg = msg

class Duplicate(Exception):
    def __init__(self, msg:str):
        self.msg = msg

每个异常都有一个msg字符串属性,可以通知高级别代码发生了什么。

要实现这一点,在 示例 10-17 中,让data/init.py导入 SQLite 会为重复操作引发的异常。

示例 10-17. 在 data/init.py 中添加一个 SQLite 异常导入
from sqlite3 import connect, IntegrityError

在 示例 10-18 中导入和捕获此错误。

示例 10-18. 修改 data/explorer.py 以捕获并引发这些异常
from init import (conn, curs, IntegrityError)
from model.explorer import Explorer
from error import Missing, Duplicate

curs.execute("""create table if not exists explorer(
 name text primary key,
 country text,
 description text)""")

def row_to_model(row: tuple) -> Explorer:
    name, country, description = row
    return Explorer(name=name,
        country=country, description=description)

def model_to_dict(explorer: Explorer) -> dict:
    return explorer.dict()

def get_one(name: str) -> Explorer:
    qry = "select * from explorer where name=:name"
    params = {"name": name}
    curs.execute(qry, params)
    row = curs.fetchone()
    if row:
        return row_to_model(row)
    else:
        raise Missing(msg=f"Explorer {name} not found")

def get_all() -> list[Explorer]:
    qry = "select * from explorer"
    curs.execute(qry)
    return [row_to_model(row) for row in curs.fetchall()]

def create(explorer: Explorer) -> Explorer:
    if not explorer: return None
    qry = """insert into explorer (name, country, description) values
 (:name, :country, :description)"""
    params = model_to_dict(explorer)
    try:
        curs.execute(qry, params)
    except IntegrityError:
        raise Duplicate(msg=
            f"Explorer {explorer.name} already exists")
    return get_one(explorer.name)

def modify(name: str, explorer: Explorer) -> Explorer:
    if not (name and explorer): return None
    qry = """update explorer
 set name=:name,
 country=:country,
 description=:description
 where name=:name_orig"""
    params = model_to_dict(explorer)
    params["name_orig"] = explorer.name
    curs.execute(qry, params)
    if curs.rowcount == 1:
        return get_one(explorer.name)
    else:
        raise Missing(msg=f"Explorer {name} not found")

def delete(name: str):
    if not name: return False
    qry = "delete from explorer where name = :name"
    params = {"name": name}
    curs.execute(qry, params)
    if curs.rowcount != 1:
        raise Missing(msg=f"Explorer {name} not found")

这消除了需要声明任何函数返回Explorer | NoneOptional[Explorer]的需要。你只为正常的返回类型指定类型提示,而不是异常。因为异常独立于调用堆栈向上传播,直到有人捕获它们,所以你不必在服务层更改任何内容。但是这是在示例 10-19 中的新的web/explorer.py,带有异常处理程序和适当的 HTTP 状态代码返回。

示例 10-19. 在 web/explorer.py 中处理MissingDuplicate异常
from fastapi import APIRouter, HTTPException
from model.explorer import Explorer
from service import explorer as service
from error import Duplicate, Missing

router = APIRouter(prefix = "/explorer")

@router.get("")
@router.get("/")
def get_all() -> list[Explorer]:
    return service.get_all()

@router.get("/{name}")
def get_one(name) -> Explorer:
    try:
        return service.get_one(name)
    except Missing as exc:
        raise HTTPException(status_code=404, detail=exc.msg)

@router.post("", status_code=201)
@router.post("/", status_code=201)
def create(explorer: Explorer) -> Explorer:
    try:
        return service.create(explorer)
    except Duplicate as exc:
        raise HTTPException(status_code=404, detail=exc.msg)

@router.patch("/")
def modify(name: str, explorer: Explorer) -> Explorer:
    try:
        return service.modify(name, explorer)
    except Missing as exc:
        raise HTTPException(status_code=404, detail=exc.msg)

@router.delete("/{name}", status_code=204)
def delete(name: str):
    try:
        return service.delete(name)
    except Missing as exc:
        raise HTTPException(status_code=404, detail=exc.msg)

在示例 10-20 中测试这些更改。

示例 10-20. 再次测试不存在的一次性探索者,使用新的Missing异常
$ http localhost:8000/explorer/"Beau Buffalo"
HTTP/1.1 404 Not Found
content-length: 44
content-type: application/json
date: Mon, 27 Feb 2023 21:11:27 GMT
server: uvicorn

{
    "detail": "Explorer Beau Buffalo not found"
}

很好。现在,在示例 10-21 中再次尝试邪恶的克隆尝试。

示例 10-21. 测试重复修复
$ http post localhost:8000/explorer name="Beau Buffette" country="US"
HTTP/1.1 404 Not Found
content-length: 50
content-type: application/json
date: Mon, 27 Feb 2023 21:14:00 GMT
server: uvicorn

{
    "detail": "Explorer Beau Buffette already exists"
}

缺失检查也将适用于修改和删除端点。你可以尝试为它们编写类似的测试。

单元测试

单元测试仅涉及数据层,检查数据库调用和 SQL 语法。我将此部分放在完整测试之后,因为我希望已经定义、解释和编码了MissingDuplicate异常,存储在data/creature.py中。示例 10-22 列出了测试脚本test/unit/data/test_creature.py。以下是一些需要注意的点:

  • 在导入data中的initcreature之前,将环境变量CRYPTID_SQLITE_DATABASE设置为":memory:"。这个值使 SQLite 完全在内存中运行,不会覆盖任何现有的数据库文件,甚至不会在磁盘上创建文件。在首次导入该模块时,data/init.py会检查它。

  • 名为samplefixture被传递给需要Creature对象的函数。

  • 测试按顺序运行。在本例中,相同的数据库在整个过程中保持开启状态,而不是在函数之间重置。原因是允许前一个函数的更改保持持久化。使用 pytest,fixture 可能具有以下内容:

    函数范围(默认)

    每个测试函数之前都会调用它。

    会话范围

    它仅在开始时被调用一次。

  • 一些测试强制引发MissingDuplicate异常,并验证它们是否被捕获。

因此,示例 10-22 中的每个测试都会获得一个全新的、未更改的名为sampleCreature对象。

示例 10-22. data/creature.py 的单元测试
import os
import pytest
from model.creature import Creature
from error import Missing, Duplicate

# set this before data imports below for data.init
os.environ["CRYPTID_SQLITE_DB"] = ":memory:"
from data import creature

@pytest.fixture
def sample() -> Creature:
    return Creature(name="yeti", country="CN", area="Himalayas",
        description="Harmless Himalayan",
        aka="Abominable Snowman")

def test_create(sample):
    resp = creature.create(sample)
    assert resp == sample

def test_create_duplicate(sample):
    with pytest.raises(Duplicate):
        _ = creature.create(sample)

def test_get_one(sample):
    resp = creature.get_one(sample.name)
    assert resp == sample

def test_get_one_missing():
    with pytest.raises(Missing):
        _ = creature.get_one("boxturtle")

def test_modify(sample):
    creature.area = "Sesame Street"
    resp = creature.modify(sample.name, sample)
    assert resp == sample

def test_modify_missing():
    thing: Creature = Creature(name="snurfle", country="RU", area="",
        description="some thing", aka="")
    with pytest.raises(Missing):
        _ = creature.modify(thing.name, thing)

def test_delete(sample):
    resp = creature.delete(sample.name)
    assert resp is None

def test_delete_missing(sample):
    with pytest.raises(Missing):
        _ = creature.delete(sample.name)

提示:你可以制作自己版本的test/unit/data/test_explorer.py

回顾

本章介绍了一个简单的数据处理层,根据需要在层栈中上下移动几次。第十二章包含每个层的单元测试,以及跨层集成和完整的端到端测试。第十四章深入探讨了数据库的更多细节和详细示例。

第十一章:认证与授权

尊重我的权威!

艾瑞克·卡特曼,南方公园

预览

有时一个网站是完全开放的,任何访客都可以访问任何页面。但如果网站的内容可能被修改,某些端点将被限制只允许特定的人或组访问。如果任何人都能修改亚马逊的页面,想象一下会出现什么奇怪的物品,以及某些人突然获得的惊人销量。不幸的是,这是人类的本性——对于一些人来说,他们会利用其他人支付他们活动的隐藏税。

我们应该让我们的神秘动物网站对任何用户开放访问吗?不!几乎任何规模的网络服务最终都需要处理以下问题:

认证(authn)

你是谁?

授权(authz)

你想要什么?

认证和授权(auth)代码是否应该有自己的新层,例如在 Web 和服务之间添加一个新层?还是应该所有东西都由 Web 或服务层自己处理?本章将涉及认证技术及其放置位置的讨论。

往往关于 Web 安全的描述似乎比必要的复杂得多。攻击者可以非常狡猾,而反制措施可能并不简单。

注意

正如我多次提到的,官方的 FastAPI 文档非常出色。如果本章提供的细节不足以满足您的需求,请查看安全部分

因此,让我们一步步地进行这次讲解。我将从简单的技术开始,这些技术仅用于将认证挂接到 Web 端点以进行测试,但不能在公共网站上使用。

插曲 1:您是否需要认证?

再次强调,认证 关注的是 身份:你是谁?要实施认证,我们需要将秘密信息映射到唯一的身份。有许多方法可以做到这一点,复杂度也有很多变化。让我们从简单开始逐步深入。

往往关于 Web 开发的书籍和文章会立即深入到认证和授权的细节中,有时会把它们弄混。它们有时会跳过第一个问题:您真的需要吗?

您可以允许完全匿名访问所有网站页面。但这将使您容易受到拒绝服务攻击等攻击的威胁。尽管某些保护措施(如速率限制)可以在 Web 服务器外部实施(参见第十三章),几乎所有公共 API 提供者都至少需要一些身份验证。

除了安全之外,我们还想知道网站的效果如何:

  • 有多少独立访问者?

  • 哪些页面最受欢迎?

  • 某些更改是否会增加页面浏览量?

  • 哪些页面序列是常见的?

这些问题的答案需要对特定访问者进行认证。否则,您只能得到总计数。

注意

如果您的网站需要身份验证或授权,访问它应该是加密的(使用 HTTPS 而不是 HTTP),以防止攻击者从明文中提取机密数据。有关设置 HTTPS 的详细信息,请参见第十三章。

认证方法

有许多网络身份验证方法和工具:

用户名/电子邮件和密码

使用经典的 HTTP 基本和摘要身份验证

API 密钥

一个不透明的长字符串,带有一个附带的 秘密

OAuth2

一组用于身份验证和授权的标准

JavaScript Web Tokens(JWT)

一种包含经过加密签名的用户信息的编码格式

在本节中,我将回顾前两种方法,并向你展示如何传统地实现它们。但是在填写 API 和数据库代码之前,我会停下来。相反,我们将完全实现一个更现代的方案,使用 OAuth2 和 JWT。

全局身份验证:共享秘密

最简单的身份验证方法是传递一个通常只有 web 服务器知道的秘密。如果匹配,你就能进入。如果你的 API 网站暴露在公共网络中,使用 HTTP 而不是 HTTPS 是不安全的。如果它被隐藏在一个前端网站背后,而该前端网站本身是公开的,前端和后端可以使用一个共享的常量秘密进行通信。但是如果你的前端网站被黑客攻击了,那就麻烦了。让我们看看 FastAPI 如何处理简单的身份验证。

创建一个名为 auth.py 的新顶级文件。检查一下,你之前的章节中是否仍然有另一个仍在运行的 FastAPI 服务器,该服务器来自于那些不断变化的 main.py 文件。示例 11-1 实现了一个服务器,只是使用 HTTP 基本身份验证返回发送给它的任何 usernamepassword——这是网络原始时代的一种方法。

示例 11-1. 使用 HTTP 基本身份验证获取用户信息:auth.py
import uvicorn
from fastapi import Depends, FastAPI
from fastapi.security import HTTPBasic, HTTPBasicCredentials

app = FastAPI()

basic = HTTPBasic()

@app.get("/who")
def get_user(
    creds: HTTPBasicCredentials = Depends(basic)):
    return {"username": creds.username, "password": creds.password}

if __name__ == "__main__":
    uvicorn.run("auth:app", reload=True)

在示例 11-2 中,告诉 HTTPie 发送这个基本身份验证请求(这需要参数 -a name:password)。在这里,让我们使用名称 me 和密码 secret

示例 11-2. 使用 HTTPie 进行测试
$ http -q -a me:secret localhost:8000/who
{
    "password": "secret",
    "username": "me"
}

使用 示例 11-3 中的 Requests 包进行测试类似,使用 auth 参数。

示例 11-3. 使用 Requests 进行测试
>>> import requests
>>> r = requests.get("http://localhost:8000/who",
    auth=("me", "secret"))
>>> r.json()
{'username': 'me', 'password': 'secret'}

你还可以使用自动文档页面(http://localhost:8000/docs)测试 示例 11-1,如 图 11-1 所示。

fapi 1101

图 11-1. 简单身份验证的文档页面

点击右边的下箭头,然后点击尝试按钮,然后点击执行按钮。你会看到一个请求用户名和密码的表单。输入任何内容。文档表单将命中该服务器端点,并在响应中显示这些值。

这些测试表明你可以将用户名和密码发送到服务器并返回(虽然这些测试实际上没有检查任何内容)。服务器中的某些东西需要验证这个名称和密码是否与批准的值匹配。因此,在示例 11-4 中,我将在 Web 服务器中包含一个单一的秘密用户名和密码。你现在传递的用户名和密码需要与它们匹配(每个都是一个 共享的秘密),否则你会得到一个异常。HTTP 状态码 401 官方称为 Unauthorized,但实际上它的意思是未经验证

注意

而不是记忆所有的 HTTP 状态码,你可以导入 FastAPI 的 status 模块(该模块直接从 Starlette 导入)。因此,你可以在示例 11-4 中使用更加解释性的sta⁠tus_code=​HTTP_401_UNAUTHORIZED,而不是简单的sta⁠tus_​code=401

示例 11-4. 在 auth.py 中添加一个秘密的用户名和密码
import uvicorn
from fastapi import Depends, FastAPI, HTTPException
from fastapi.security import HTTPBasic, HTTPBasicCredentials

app = FastAPI()

secret_user: str = "newphone"
secret_password: str = "whodis?"

basic: HTTPBasicCredentials = HTTPBasic()

@app.get("/who")
def get_user(
    creds: HTTPBasicCredentials = Depends(basic)) -> dict:
    if (creds.username == secret_user and
        creds.password == secret_password):
        return {"username": creds.username,
            "password": creds.password}
    raise HTTPException(status_code=401, detail="Hey!")

if __name__ == "__main__":
    uvicorn.run("auth:app", reload=True)

在示例 11-5 中,如果用户名和密码猜测错误,会收到轻微的401警告。

示例 11-5. 使用 HTTPie 测试不匹配的用户名/密码
$ http -a me:secret localhost:8000/who
HTTP/1.1 401 Unauthorized
content-length: 17
content-type: application/json
date: Fri, 03 Mar 2023 03:25:09 GMT
server: uvicorn

{
    "detail": "Hey!"
}

使用这种魔法组合返回用户名和密码,如以前在示例 11-6 中所示。

示例 11-6. 使用 HTTPie 测试正确的用户名/密码
$ http -q -a newphone:whodis? localhost:8000/who
{
    "password": "whodis?",
    "username": "newphone"
}

简单的个人认证

前一节展示了如何使用共享秘密来控制访问。这是一种广义的方法,安全性不高。它并没有告诉你有关个体访问者的任何信息,只是它们(或有感知的人工智能)知道这个秘密。

许多网站希望执行以下操作:

  • 某种方式定义个别访问者

  • 标识特定访问者在访问某些端点时(认证)

  • 可能为某些访问者和端点分配不同的权限(授权)

  • 可能保存每个访问者的特定信息(兴趣,购买等)

如果你的访问者是人类,你可能希望他们提供用户名或电子邮件以及密码。如果它们是外部程序,你可能希望它们提供 API 密钥和密钥。

注意

从现在开始,我将仅使用用户名来指代用户选择的名称或电子邮件。

要认证真实的个人用户而不是虚拟用户,你需要做更多工作:

  • 将用户值(名称和密码)作为 HTTP 标头传递给 API 服务器端点。

  • 使用 HTTPS 而不是 HTTP,以避免任何人窥视这些标头的文本。

  • 哈希密码为不同的字符串。其结果是不可“解哈希化”的——你不能从其哈希值推导出原始密码。

  • 创建一个真实的数据库存储一个User数据库表,包含用户名和哈希密码(绝不是原始明文密码)。

  • 对新输入的密码进行哈希处理,并将结果与数据库中的哈希密码进行比较。

  • 如果用户名和哈希密码匹配,将匹配的User对象传递到堆栈上。如果它们不匹配,则返回None或引发异常。

  • 在服务层中,触发与个人用户认证相关的任何指标/日志等。

  • 在 Web 层中,将认证用户信息发送给需要的任何函数。

我将在接下来的章节中向你展示如何使用 OAuth2 和 JWT 等最新工具来完成所有这些事情。

更复杂的个人认证

如果你想要认证个体,你必须在某处存储一些个体信息,例如在一个包含至少一个键(用户名或 API 密钥)和一个密钥(密码或 API 密钥)的数据库中。当访问受保护的 URL 时,你的网站访问者将提供这些信息,而你需要在数据库中找到匹配项。

官方的 FastAPI 安全文档(入门高级)详细描述了如何为多个用户设置认证,使用本地数据库。但是,示例网络功能模拟了实际数据库访问。

在这里,你将会做相反的操作:从数据层开始,逐步向上。你将定义用户/访客的定义、存储和访问方式。然后逐步向上到 Web 层,讨论用户身份验证的传递、评估和认证。

OAuth2

OAuth 2.0,即“开放授权”,是一个标准,旨在允许网站或应用程序代表用户访问其他网站应用程序托管的资源。

Auth0

在早期信任的 Web 时代,你可以将你在一个网站(我们称之为 B)的登录名和密码提供给另一个网站(当然是 A),让其代表你访问 B 上的资源。这会让 A 获得对 B 的完全访问权限,尽管它只被信任访问它应该访问的内容。B 和资源的例子包括 Twitter 的粉丝、Facebook 的好友、电子邮件联系人等。当然,这种做法不可能长久存在,所以各种公司和组织联合起来定义了 OAuth。它最初的设计目的只是允许网站 A 访问网站 B 上特定(而非全部)的资源。

OAuth2 是一个流行但复杂的授权标准,适用于 A/B 例子之外的多种情况。有许多对它的解释,从轻量级深入

注意

曾经有一个 OAuth1,但它不再使用。一些最初的 OAuth2 建议现在已被弃用(计算机术语,意思是不要使用它们)。在未来,还会有 OAuth2.1 和更远的 txauth

OAuth 提供了各种流程以应对不同的情况。本节将使用授权码流来进行实现,逐步迈出平均大小的步骤。

首先,你需要安装这些第三方 Python 包:

JWT 处理

pip install python-jose[cryptography]

安全的密码处理

pip install passlib

表单处理

pip install python-multipart

以下部分从用户数据模型和数据库管理开始,逐步向上到服务和 Web 层,其中包括 OAuth。

用户模型

让我们从 示例 11-7 中的极简用户模型定义开始。这些将在所有层中使用。

示例 11-7. 用户定义:model/user.py
from pydantic import BaseModel

class User(BaseModel):
    name: str
    hash: str

User 对象包含一个任意的 name 和一个 hash 字符串(经过哈希处理的密码,而不是原始的明文密码),这是保存在数据库中的内容。我们需要这两者来验证访客。

用户数据层

示例 11-8 包含了用户数据库代码。

注意

代码创建了 user(活跃用户)和 xuser(已删除用户)表。通常开发者会向用户表添加一个布尔类型的 deleted 字段,以指示用户不再活跃,而实际上并没有从表中删除记录。我更倾向于将删除的用户数据移动到另一个表中。这样可以避免在所有用户查询中重复检查 deleted 字段。它还有助于加快查询速度:对于像布尔类型这样的低基数字段,创建索引并不会带来实质的好处。

示例 11-8. 数据层:data/user.py
from model.user import User
from .init import (conn, curs, get_db, IntegrityError)
from error import Missing, Duplicate

curs.execute("""create table if not exists
 user(
 name text primary key,
 hash text)""")
curs.execute("""create table if not exists
 xuser(
 name text primary key,
 hash text)""")

def row_to_model(row: tuple) -> User:
    name, hash = row
    return User(name=name, hash=hash)

def model_to_dict(user: User) -> dict:
    return user.dict()

def get_one(name: str) -> User:
    qry = "select * from user where name=:name"
    params = {"name": name}
    curs.execute(qry, params)
    row = curs.fetchone()
    if row:
        return row_to_model(row)
    else:
        raise Missing(msg=f"User {name} not found")

def get_all() -> list[User]:
    qry = "select * from user"
    curs.execute(qry)
    return [row_to_model(row) for row in curs.fetchall()]

def create(user: User, table:str = "user"):
    """Add <user> to user or xuser table"""
    qry = f"""insert into {table}
 (name, hash)
 values
 (:name, :hash)"""
    params = model_to_dict(user)
    try:
        curs.execute(qry, params)
    except IntegrityError:
        raise Duplicate(msg=
            f"{table}: user {user.name} already exists")

def modify(name: str, user: User)  -> User:
    qry = """update user set
 name=:name, hash=:hash
 where name=:name0"""
    params = {
        "name": user.name,
        "hash": user.hash,
        "name0": name}
    curs.execute(qry, params)
    if curs.rowcount == 1:
        return get_one(user.name)
    else:
        raise Missing(msg=f"User {name} not found")

def delete(name: str) -> None:
    """Drop user with <name> from user table, add to xuser table"""
    user = get_one(name)
    qry = "delete from user where name = :name"
    params = {"name": name}
    curs.execute(qry, params)
    if curs.rowcount != 1:
        raise Missing(msg=f"User {name} not found")
    create(user, table="xuser")

用户伪数据层

在排除数据库但需要一些用户数据的测试中使用了 示例 11-9 模块。

示例 11-9. 伪数据层:fake/user.py
from model.user import User
from error import Missing, Duplicate

# (no hashed password checking in this module)
fakes = [
    User(name="kwijobo",
         hash="abc"),
    User(name="ermagerd",
         hash="xyz"),
    ]

def find(name: str) -> User | None:
    for e in fakes:
        if e.name == name:
            return e
    return None

def check_missing(name: str):
    if not find(name):
        raise Missing(msg=f"Missing user {name}")

def check_duplicate(name: str):
    if find(name):
        raise Duplicate(msg=f"Duplicate user {name}")

def get_all() -> list[User]:
    """Return all users"""
    return fakes

def get_one(name: str) -> User:
    """Return one user"""
    check_missing(name)
    return find(name)

def create(user: User) -> User:
    """Add a user"""
    check_duplicate(user.name)
    return user

def modify(name: str, user: User) -> User:
    """Partially modify a user"""
    check_missing(name)
    return user

def delete(name: str) -> None:
    """Delete a user"""
    check_missing(name)
    return None

用户服务层

示例 11-10 定义了用户的服务层。与其他服务层模块的不同之处在于增加了 OAuth2 和 JWT 函数。我认为将它们放在这里比放在 Web 层中更清晰,尽管一些 OAuth2 Web 层函数位于即将到来的 web/user.py 中。

CRUD 函数目前仍然是传递功能,但将来可以根据需求进行调整。请注意,与生物和探险者服务类似,这种设计支持在运行时使用伪数据层或真实数据层访问用户数据。

示例 11-10. 服务层:service/user.py
from datetime import timedelta, datetime
import os
from jose import jwt
from model.user import User

if os.getenv("CRYPTID_UNIT_TEST"):
    from fake import user as data
else:
    from data import user as data

# --- New auth stuff

from passlib.context import CryptContext

# Change SECRET_KEY for production!
SECRET_KEY = "keep-it-secret-keep-it-safe"
ALGORITHM = "HS256"
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")

def verify_password(plain: str, hash: str) -> bool:
    """Hash <plain> and compare with <hash> from the database"""
    return pwd_context.verify(plain, hash)

def get_hash(plain: str) -> str:
    """Return the hash of a <plain> string"""
    return pwd_context.hash(plain)

def get_jwt_username(token:str) -> str | None:
    """Return username from JWT access <token>"""
    try:
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
        if not (username := payload.get("sub")):
            return None
    except jwt.JWTError:
        return None
    return username

def get_current_user(token: str) -> User | None:
    """Decode an OAuth access <token> and return the User"""
    if not (username := get_jwt_username(token)):
        return None
    if (user := lookup_user(username)):
        return user
    return None

def lookup_user(username: str) -> User | None:
    """Return a matching User from the database for <name>"""
    if (user := data.get(username)):
        return user
    return None

def auth_user(name: str, plain: str) -> User | None:
    """Authenticate user <name> and <plain> password"""
    if not (user := lookup_user(name)):
        return None
    if not verify_password(plain, user.hash):
        return None
    return user

def create_access_token(data: dict,
    expires: timedelta | None = None
):
    """Return a JWT access token"""
    src = data.copy()
    now = datetime.utcnow()
    if not expires:
        expires = timedelta(minutes=15)
    src.update({"exp": now + expires})
    encoded_jwt = jwt.encode(src, SECRET_KEY, algorithm=ALGORITHM)
    return encoded_jwt

# --- CRUD passthrough stuff

def get_all() -> list[User]:
    return data.get_all()

def get_one(name) -> User:
    return data.get_one(name)

def create(user: User) -> User:
    return data.create(user)

def modify(name: str, user: User) -> User:
    return data.modify(name, user)

def delete(name: str) -> None:
    return data.delete(name)

用户 Web 层

示例 11-11 在 Web 层中定义了基础用户模块。它使用了来自 service/user.py 模块中 示例 11-10 的新授权代码。

示例 11-11. Web 层:web/user.py
import os
from fastapi import APIRouter, HTTPException
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from model.user import User
if os.getenv("CRYPTID_UNIT_TEST"):
    from fake import user as service
else:
    from service import user as service
from error import Missing, Duplicate

ACCESS_TOKEN_EXPIRE_MINUTES = 30

router = APIRouter(prefix = "/user")

# --- new auth stuff

# This dependency makes a post to "/user/token"
# (from a form containing a username and password)
# and returns an access token.
oauth2_dep = OAuth2PasswordBearer(tokenUrl="token")

def unauthed():
    raise HTTPException(
        status_code=401,
        detail="Incorrect username or password",
        headers={"WWW-Authenticate": "Bearer"},
        )

# This endpoint is directed to by any call that has the
# oauth2_dep() dependency:
@router.post("/token")
async def create_access_token(
    form_data: OAuth2PasswordRequestForm =  Depends()
):
    """Get username and password from OAuth form,
 return access token"""
    user = service.auth_user(form_data.username, form_data.password)
    if not user:
        unauthed()
    expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
    access_token = service.create_access_token(
        data={"sub": user.username}, expires=expires
    )
    return {"access_token": access_token, "token_type": "bearer"}

@app.get("/token")
def get_access_token(token: str = Depends(oauth2_dep)) -> dict:
    """Return the current access token"""
    return {"token": token}

# --- previous CRUD stuff

@router.get("/")
def get_all() -> list[User]:
    return service.get_all()

@router.get("/{name}")
def get_one(name) -> User:
    try:
        return service.get_one(name)
    except Missing as exc:
        raise HTTPException(status_code=404, detail=exc.msg)

@router.post("/", status_code=201)
def create(user: User) -> User:
    try:
        return service.create(user)
    except Duplicate as exc:
        raise HTTPException(status_code=409, detail=exc.msg)

@router.patch("/")
def modify(name: str, user: User) -> User:
    try:
        return service.modify(name, user)
    except Missing as exc:
        raise HTTPException(status_code=404, detail=exc.msg)

@router.delete("/{name}")
def delete(name: str) -> None:
    try:
        return service.delete(name)
    except Missing as exc:
        raise HTTPException(status_code=404, detail=exc.msg)

测试!

对于这个新用户组件的单元测试和完整测试与你已经看过的生物和探险者的测试类似。与其在这里使用墨水和纸张,不如在本书附带的网站上查看它们。¹

顶层

前一节为以 /user 开头的 URL 定义了一个新的 router 变量,所以 示例 11-12 添加了这个子路由器。

示例 11-12. 顶层:main.py
from fastapi import FastAPI
from web import explorer, creature, user

app = FastAPI()
app.include_router(explorer.router)
app.include_router(creature.router)
app.include_router(user.router)

当 Uvicorn 自动重载时,/user/… 终端现在应该是可用的。

那真是有趣,按一种拉伸的定义来说是有趣的。考虑到刚刚创建的所有用户代码,让我们给它一些事情做。

认证步骤

下面是前面章节中大量代码的回顾:

  • 如果一个端点依赖于 oauth2_dep()(在 web/user.py 中),则会生成一个包含用户名和密码字段的表单,并发送给客户端。

  • 在客户端填写并提交此表单后,用户名和密码(与本地数据库中已存储的相同算法的哈希值)将与本地数据库匹配。

  • 如果匹配成功,将生成一个访问令牌(JWT 格式)并返回。

  • 此访问令牌作为Authorization HTTP 头传回 Web 服务器。此 JWT 令牌在本地服务器上被解码为用户名和其他详细信息。这个名称不需要再次在数据库中查找。

  • 用户名已经过认证,服务器可以对其进行任意操作。

服务器可以如何处理这些辛苦获得的认证信息?服务器可以执行以下操作:

  • 生成指标(此用户、此端点、此时段),以帮助研究谁查看了什么内容,持续多长时间等等。

  • 保存用户特定信息。

JWT

此部分包含 JWT 的一些详细信息。在本章中,您实际上不需要这些信息来使用所有之前的代码,但如果您有点好奇……

JWT是一种编码方案,而不是一种认证方法。其低级细节在RFC 7519中有定义。它可用于传达 OAuth2(以及其他方法)的认证信息,在这里我将展示出来。

JWT 是一个可读的字符串,由三部分点分隔而成:

  • Header:使用的加密算法和令牌类型

  • Payload:……

  • Signature:……

每个部分由一个 JSON 字符串组成,以Base 64 URL格式编码。这里有一个示例(已在点处拆分以适应本页):

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

作为纯 ASCII 字符串,它也可以安全地用作 URL 的一部分、查询参数、HTTP 头、Cookie 等,传递给 Web 服务器。

JWT 避免了数据库查找,但这也意味着您无法直接检测到已撤销的授权。

第三方认证:OIDC

您经常会看到一些网站,让您使用 ID 和密码登录,或者让您通过不同网站的账户登录,比如 Google、Facebook/Meta、LinkedIn 或许多其他网站。这些通常使用一个称为OpenID Connect (OIDC)的标准,它是建立在 OAuth2 之上的。当您连接到外部支持 OIDC 的站点时,您将收到一个 OAuth2 访问令牌(如本章的示例中所示),还会收到一个ID 令牌

官方的 FastAPI 文档不包含与 OIDC 集成的示例代码。如果您想尝试,一些第三方包(特定于 FastAPI 和更通用的)将比自行实现节省时间:

FastAPI Repo Issues 页面包含多个代码示例,以及来自 tiangelo(Sebastián Ramírez)的评论,未来 FastAPI OIDC 示例将包含在官方文档和教程中。

授权

认证处理who(身份),授权处理what:您允许访问哪些资源(Web 端点)以及以何种方式?whowhat的组合数量可能很大。

在本书中,探险者和生物一直是主要资源。查找探险者或列出它们通常比添加或修改现有资源更“开放”。如果网站应该是一种可靠的接口到某些数据,写访问应该比读访问受到更严格的限制。因为,唉,人们。

如果每个端点完全开放,您不需要授权,可以跳过此部分。最简单的授权可能是一个简单的布尔值(这个用户是否是管理员?);对于本书中的示例,您可能需要管理员授权来添加、删除或修改探险者或生物。如果您的数据库条目很多,您可能还希望为非管理员限制get_all()函数的进一步权限。随着网站变得更加复杂,权限可能变得更加细粒度化。

让我们看一下授权案例的进展。我们将使用User表,其中name可以是电子邮件、用户名或 API 密钥;“配对”表是关系数据库匹配两个单独表条目的方式。

  • 如果您只想跟踪管理员访问者并让其他人匿名:

    • 使用Admin表进行经过身份验证的用户名。您可以从Admin表中查找名称,并且如果匹配,则与User表中的哈希密码进行比较。
  • 如果所有访问者都应该经过身份验证,但您只需为某些端点授权管理员:

    • 与之前示例中的每个人进行身份验证(来自User表),然后检查Admin表以查看此用户是否也是管理员。
  • 对于多种类型的权限(如只读、读取、写入):

    • 使用Permission定义表。

    • 使用UserPermission表对用户和权限进行配对。这有时被称为访问控制列表(ACL)。

  • 如果权限组合复杂,添加一个级别并定义角色(独立的权限集合):

    • 创建一个Role表。

    • 创建一个UserRole表,配对UserRole条目。这有时被称为基于角色的访问控制(RBAC)。

中间件。

FastAPI 允许在 Web 层插入代码执行以下操作:

  • 拦截请求。

  • 处理请求的某些操作。

  • 将请求传递给路径函数。

  • 拦截由补丁函数返回的响应。

  • 对响应执行某些操作。

  • 返回响应给调用者。

它类似于 Python 装饰器对其“包装”的函数所做的事情。

在某些情况下,您可以使用中间件或依赖注入与Depends()。中间件更方便处理像 CORS 这样的全局安全问题,这也提出了...​​

跨源资源共享(CORS)。

跨源资源共享(CORS)涉及与其他受信任的服务器和您的网站之间的通信。如果您的站点将所有前端和后端代码放在一个地方,那就没问题了。但是现在,将 JavaScript 前端与 FastAPI 等后端进行通信已经很普遍了。这些服务器将不具有相同的

协议

httphttps

域名

互联网域名,比如 google.comlocalhost

端口

在该域上的数字 TCP/IP 端口,比如 804438000

后端如何知道可信任的前端和一个充满霉菌的萝卜箱或一个胡子拨弄的攻击者之间的区别?这是 CORS 的工作,它指定了后端信任的内容,最重要的是以下内容:

  • 来源

  • HTTP 方法

  • HTTP 头部

  • CORS 缓存超时

你在 Web 层级上连接到 CORS。示例 11-13 展示了如何允许只有一个前端服务器(具有域名 https://ui.cryptids.com)以及任何 HTTP 头部或方法。

示例 11-13. 激活 CORS 中间件。
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware

app = FastAPI()

app.add_middleware(
    CORSMiddleware,
    allow_origins=["https://ui.cryptids.com",],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
    )

@app.get("/test_cors")
def test_cors(request: Request):
    print(request)

一旦完成,任何试图直接联系您的后端站点的其他域都将被拒绝。

第三方包

你现在已经阅读了如何使用 FastAPI 编写身份验证和授权解决方案的示例。但也许你不需要自己做所有的事情。FastAPI 生态系统发展迅速,可能已经有可用的包来为你做大部分工作了。

以下是一些未经测试的示例。不能保证此列表中的任何包在未来仍然存在并得到支持,但可能值得一看:

回顾

这是一个比大多数更重的章节。它展示了你可以对访问者进行身份验证并授权他们执行某些操作的方式。这是 Web 安全的两个方面。该章还讨论了 CORS,另一个重要的 Web 安全主题。

¹ 如果我按行收费,命运可能会干涉。

第十二章:测试

一个 QA 工程师走进一家酒吧。点了一杯啤酒。点了 0 杯啤酒。点了 99999999999 杯啤酒。点了一只蜥蜴。点了 -1 杯啤酒。点了一个 ueicbksjdhd。

第一个真正的客户走进来问洗手间在哪里。酒吧突然起火,所有人都死了。

Brenan Keller,Twitter

预览

本章讨论了在 FastAPI 站点上进行的各种测试类型:单元集成完整。它以 pytest 和自动化测试开发为特色。

Web API 测试

您已经看到几个手动 API 测试工具,因为已经添加了端点:

  • HTTPie

  • 请求

  • HTTPX

  • Web 浏览器

还有许多其他测试工具可用:

  • Curl 是非常有名的,尽管在本书中我使用了更简单语法的 HTTPie。

  • Httpbin,由 Requests 的作者编写,是一个提供许多视图来查看您的 HTTP 请求的免费测试服务器。

  • Postman 是一个完整的 API 测试平台。

  • Chrome DevTools 是 Chrome 浏览器的一个丰富工具集。

这些都可以用于完整(端到端)测试,例如您在前几章中看到的那些。那些手动测试在代码刚敲完后迅速验证是非常有用的。

但是如果您稍后进行的更改破坏了早期的某些手动测试(回归)怎么办?您不想在每次代码更改后重新运行数十个测试。这时自动化测试变得重要。本章的其余部分将重点介绍这些内容,并讲解如何使用 pytest 构建它们。

测试位置

我已经提到了各种测试:

单元

在一个层内,测试单个函数

集成

跨层测试连接性

完整

测试完整的 API 和其下的堆栈

有时这些被称为测试金字塔,宽度表示每个组中应该有的相对测试数量(图 12-1)。

fapi 1201

图 12-1。测试金字塔

什么要测试

在编写代码时,您应该测试什么?基本上,对于给定的输入,请确认您得到了正确的输出。您可以检查以下内容:

  • 缺少输入

  • 重复输入

  • 不正确的输入类型

  • 不正确的输入顺序

  • 无效的输入值

  • 大输入或输出

错误可能发生在任何地方:

Web 层

Pydantic 将捕获模型不匹配,并返回 422 HTTP 状态码。

数据层

数据库将因为缺失或重复数据,以及 SQL 查询语法错误而引发异常。当一次传递大量数据结果而不是使用生成器或分页时,可能会发生超时或内存耗尽。

任何层

可能发生普通的错误和疏忽。

章节 8 到 10 包含了一些这样的测试:

  • 完全手动测试,使用像 HTTPie 这样的工具

  • 单元手动测试,作为 Python 片段

  • 自动化测试,使用 pytest 脚本

接下来的几节将详细介绍 pytest。

Pytest

Python 一直拥有标准包 unittest。稍后的第三方包称为 nose,试图对其进行改进。现在大多数 Python 开发者更喜欢 pytest,它比这两者都更强大且更易于使用。它没有内置在 Python 中,因此如果没有安装,你需要运行 pip install pytest。此外,运行 pip install pytest-mock 可获取自动的 mocker 固定装置;稍后在本章中你将看到这一点。

pytest 提供了什么?其中包括以下不错的自动功能:

测试发现

Python 文件名中的测试前缀或测试后缀将自动运行。此文件名匹配会进入子目录,执行那里的所有测试。

断言失败的详细信息

一个失败的 assert 语句会打印预期的内容和实际发生的内容。

固定装置

这些函数可以为整个测试脚本运行一次,或者为每个测试运行一次(其作用域),为测试函数提供参数,如标准测试数据或数据库初始化。固定装置类似于依赖注入,就像 FastAPI 为 Web 路径函数提供的那样:特定的数据传递给通用的测试函数。

参数化

这为测试函数提供了多个测试数据。

布局

你应该把测试放在哪里?似乎没有广泛的一致意见,但这里有两种合理的设计:

  • 顶层有一个 test 目录,其中包含被测试的代码区域的子目录(如 webservice 等)。

  • 在每个代码目录下都有一个 test 目录(如 webservice 等)。

此外,在特定的子目录中(如 test/web),是否应该为不同的测试类型(如 unitintegrationfull)创建更多目录?在本书中,我使用了这种层次结构:

test
├── unit
│   ├── web
│   ├── service
│   └── data
├── integration
└── full

各个测试脚本存放在底层目录中。这些在本章中。

自动化单元测试

单元测试应该检查一个事物,在一个层次内。通常意味着向函数传递参数,并断言应该返回什么。

单元测试要求对被测试的代码进行隔离。如果不这样做,你也在测试其他东西。那么,如何为单元测试隔离代码呢?

模拟

在本书的代码堆栈中,通过 Web API 访问 URL 通常会调用 Web 层中的函数,该函数调用服务层中的函数,后者调用数据层中的函数,后者访问数据库。结果通过链条向上流动,最终从 Web 层返回给调用者。

单元测试听起来很简单。对于代码库中的每个函数,传入测试参数并确认其返回预期值即可。这对于纯函数(仅接受输入参数并返回响应,不引用任何外部代码)非常有效。但大多数函数还会调用其他函数,那么如何控制这些其他函数的操作?那么这些来自外部来源的数据如何处理?最常见的外部因素是控制数据库访问,但实际上可以是任何东西。

一种方法是模拟每个外部函数调用。因为在 Python 中函数是一级对象,你可以用另一个函数替换一个函数。unittest 包有一个 mock 模块可以做到这一点。

许多开发者认为模拟是隔离单元测试的最佳方法。我将首先在这里展示模拟的例子,并且提出一个论点:模拟往往需要对代码如何运作有太多了解,而不是其结果。你可能会听到结构化测试(如模拟,其中被测试的代码非常可见)和行为测试(其中代码内部不需要)这些术语。

示例 12-1 和 12-2 定义了mod1.pymod2.py 这两个模块。

示例 12-1. 被调用的模块(mod1.py)
def preamble() -> str:
    return "The sum is "
示例 12-2. 被调用的模块(mod2.py)
import mod1

def summer(x: int, y:int) -> str:
    return mod1.preamble() + f"{x+y}"

summer()函数计算其参数的和,并返回一个包含前文和总和的字符串。示例 12-3 是一个最小化的 pytest 脚本,用于验证summer()

示例 12-3. Pytest 脚本 test_summer1.py
import mod2

def test_summer():
    assert "The sum is 11" == mod2.summer(5,6)

示例 12-4 成功运行了测试。

示例 12-4. 运行 pytest 脚本
$ pytest -q test_summer1.py
.                                                                    [100%]
1 passed in 0.04s

-q 可以安静地运行测试,不会输出大量额外的细节。)好的,测试通过了。但summer()函数从preamble函数中得到了一些文本。如果我们只是想测试加法是否成功呢?

我们可以编写一个新函数,它只返回两个数字的字符串化的和,然后重写summer()函数将其返回附加到preamble()字符串中。

或者,我们可以模拟preamble()以消除其效果,正如在示例 12-5 中展示的多种方式那样。

示例 12-5. Pytest 中的模拟(test_summer2.py)
from unittest import mock
import mod1
import mod2

def test_summer_a():
    with mock.patch("mod1.preamble", return_value=""):
        assert "11" == mod2.summer(5,6)

def test_summer_b():
    with mock.patch("mod1.preamble") as mock_preamble:
        mock_preamble.return_value=""
        assert "11" == mod2.summer(5,6)

@mock.patch("mod1.preamble", return_value="")
def test_summer_c(mock_preamble):
    assert "11" == mod2.summer(5,6)

@mock.patch("mod1.preamble")
def test_caller_d(mock_preamble):
    mock_preamble.return_value = ""
    assert "11" == mod2.summer(5,6)

这些测试显示,模拟对象可以通过多种方式创建。test_caller_a()函数使用mock.patch()作为 Python 的上下文管理器(使用with语句)。其参数列在此处:

"mod1.preamble"

在模块mod1中,preamble()函数的完整字符串名称。

return_value=""

使得这个模拟版本返回一个空字符串。

test_caller_b()函数几乎相同,但在下一行添加了as mock_preamble来使用模拟对象。

test_caller_c()函数使用 Python 的装饰器定义了模拟对象。模拟对象作为参数传递给了test_caller2()

test_caller_d()函数类似于test_caller_b(),但在对mock_preamble进行单独调用设置return_value时添加了as mock_preamble

在每种情况下,要模拟的事物的字符串名称必须与在正在测试的代码中调用它的方式匹配——在本例中是summer()。模拟库将该字符串名称转换为一个变量,该变量将拦截对原始具有该名称的任何变量的引用。(请记住,在 Python 中,变量只是对真实对象的引用。)

当运行示例 12-6 时,在所有四个summer()测试函数中,当调用summer(5,6)时,替代的变身模拟preamble()被调用而不是真正的函数。模拟版本会丢弃该字符串,因此测试可以确保summer()返回其两个参数的字符串版本的和。

示例 12-6. 运行模拟的 pytest
$ pytest -q test_summer2.py
....                                                                 [100%]
4 passed in 0.13s
注意

那是一个刻意的案例,为了简单起见。模拟可以非常复杂;查看像 “Understanding the Python Mock Object Library” by Alex Ronquillo 这样的文章,以及官方的 Python 文档 获取详细信息。

测试 Doubles 和 Fakes

要执行该模拟,您需要知道summer()函数从模块mod1导入了函数preamble()。这是一个结构测试,需要了解特定变量和模块名称。

是否有一种不需要这样做的行为测试方法?

一种方法是定义一个 double:在测试中执行我们希望的操作的独立代码——在本例中,使preamble()返回一个空字符串。其中一种方法是使用导入。在接下来的三个部分的层中使用它进行单元测试之前,首先将其应用于此示例。

首先,在示例 12-7 中重新定义 mod2.py

示例 12-7. 如果进行单元测试,使 mod2.py 导入一个 double
import os
if os.get_env("UNIT_TEST"):
    import fake_mod1 as mod1
else:
    import mod1

def summer(x: int, y:int) -> str:
    return mod1.preamble() + f"{x+y}"

示例 12-8 定义了双重模块 fake_mod1.py

示例 12-8. 双重 fake_mod1.py
def preamble() -> str:
    return ""

而示例 12-9 是测试。

示例 12-9. 测试脚本 test_summer_fake.py
import os
os.environ["UNIT_TEST"] = "true"
import mod2

def test_summer_fake():
    assert "11" == mod2.summer(5,6)

…​.运行示例 12-10。

示例 12-10. 运行新的单元测试
$ pytest -q test_summer_fake.py
.                                                                    [100%]
1 passed in 0.04s

这种导入切换方法确实需要添加一个环境变量的检查,但避免了必须为函数调用编写特定的模拟。您可以自行决定喜欢哪种方法。接下来的几节将使用import方法,这与我在定义代码层时使用的 fake 包非常匹配。

总结一下,这些示例将preamble()替换为测试脚本中的模拟,或者导入了一个 double。您可以以其他方式隔离被测试的代码,但这些方法工作起来并不像 Google 可能为您找到的其他方法那么棘手。

Web

该层实现了站点的 API。理想情况下,每个路径函数(终点)都应至少有一个测试——也许更多,如果该函数可能以多种方式失败。在 Web 层,您通常希望查看端点是否存在,是否能够使用正确的参数工作,并返回正确的状态代码和数据。

注意

这些都是浅层 API 测试,仅在 Web 层内部进行测试。因此,需要拦截服务层调用(这些调用将进一步调用数据层和数据库),以及任何其他退出 Web 层的调用。

使用上一节的 import 思想,使用环境变量 CRYPTID_UNIT_TESTfake 包作为 service 导入,而不是真正的 service。这样一来,Web 函数不再调用 Service 函数,而是直接绕过它们到达 fake(双重)版本。然后,较低的数据层和数据库也不参与其中。我们得到了想要的单元测试。例子 12-11 有修改后的 web/creature.py 文件。

例子 12-11. 修改后的 web/creature.py
import os
from fastapi import APIRouter, HTTPException
from model.creature import Creature
if os.getenv("CRYPTID_UNIT_TEST"):
    from fake import creature as service
else:
    from service import creature as service
from error import Missing, Duplicate

router = APIRouter(prefix = "/creature")

@router.get("/")
def get_all() -> list[Creature]:
    return service.get_all()

@router.get("/{name}")
def get_one(name) -> Creature:
    try:
        return service.get_one(name)
    except Missing as exc:
        raise HTTPException(status_code=404, detail=exc.msg)

@router.post("/", status_code=201)
def create(creature: Creature) -> Creature:
    try:
        return service.create(creature)
    except Duplicate as exc:
        raise HTTPException(status_code=409, detail=exc.msg)

@router.patch("/")
def modify(name: str, creature: Creature) -> Creature:
    try:
        return service.modify(name, creature)
    except Missing as exc:
        raise HTTPException(status_code=404, detail=exc.msg)

@router.delete("/{name}")
def delete(name: str) -> None:
    try:
        return service.delete(name)
    except Missing as exc:
        raise HTTPException(status_code=404, detail=exc.msg)

例子 12-12 有两个 pytest fixtures 的测试:

sample()

一个新的 Creature 对象

fakes()

一个“现有”生物列表

假物是从一个更低层次的模块中获取的。通过设置环境变量 CRYPTID_UNIT_TEST,Web 模块从 例子 12-11 导入假服务版本(提供虚假数据而不调用数据库),而不是真实的版本。这隔离了测试,这正是它的目的。

例子 12-12. 用于生物的 Web 单元测试,使用 fixtures
from fastapi import HTTPException
import pytest
import os
os.environ["CRYPTID_UNIT_TEST"] = "true"
from model.creature import Creature
from web import creature

@pytest.fixture
def sample() -> Creature:
    return Creature(name="dragon",
        description="Wings! Fire! Aieee!",
        country="*")

@pytest.fixture
def fakes() -> list[Creature]:
    return creature.get_all()

def assert_duplicate(exc):
    assert exc.value.status_code == 404
    assert "Duplicate" in exc.value.msg

def assert_missing(exc):
    assert exc.value.status_code == 404
    assert "Missing" in exc.value.msg

def test_create(sample):
    assert creature.create(sample) == sample

def test_create_duplicate(fakes):
    with pytest.raises(HTTPException) as exc:
        _ = creature.create(fakes[0])
        assert_duplicate(exc)

def test_get_one(fakes):
    assert creature.get_one(fakes[0].name) == fakes[0]

def test_get_one_missing():
    with pytest.raises(HTTPException) as exc:
        _ = creature.get_one("bobcat")
        assert_missing(exc)

def test_modify(fakes):
    assert creature.modify(fakes[0].name, fakes[0]) == fakes[0]

def test_modify_missing(sample):
    with pytest.raises(HTTPException) as exc:
        _ = creature.modify(sample.name, sample)
        assert_missing(exc)

def test_delete(fakes):
    assert creature.delete(fakes[0].name) is None

def test_delete_missing(sample):
    with pytest.raises(HTTPException) as exc:
        _ = creature.delete("emu")
        assert_missing(exc)

服务

从某种角度来看,服务层是重要的一层,可以连接到不同的 Web 和数据层。例子 12-13 类似于 例子 12-11,主要区别在于 import 和对较低级数据模块的使用。它还没有捕捉到可能来自数据层的任何异常,这些异常留待 Web 层处理。

例子 12-13. 修改后的 service/creature.py
import os
from model.creature import Creature
if os.getenv("CRYPTID_UNIT_TEST"):
    from fake import creature as data
else:
    from data import creature as data

def get_all() -> list[Creature]:
    return data.get_all()

def get_one(name) -> Creature:
    return data.get_one(name)

def create(creature: Creature) -> Creature:
    return data.create(creature)

def modify(name: str, creature: Creature) -> Creature:
    return data.modify(name, creature)

def delete(name: str) -> None:
    return data.delete(name)

例子 12-14 有相应的单元测试。

例子 12-14. 在 test/unit/service/test_creature.py 中的服务测试
import os
os.environ["CRYPTID_UNIT_TEST"]= "true"
import pytest

from model.creature import Creature
from error import Missing, Duplicate
from data import creature as data

@pytest.fixture
def sample() -> Creature:
    return Creature(name="yeti",
        aka:"Abominable Snowman",
        country="CN",
        area="Himalayas",
        description="Handsome Himalayan")

def test_create(sample):
    resp = data.create(sample)
    assert resp == sample

def test_create_duplicate(sample):
    resp = data.create(sample)
    assert resp == sample
    with pytest.raises(Duplicate):
        resp = data.create(sample)

def test_get_exists(sample):
    resp = data.create(sample)
    assert resp == sample
    resp = data.get_one(sample.name)
    assert resp == sample

def test_get_missing():
    with pytest.raises(Missing):
        _ = data.get_one("boxturtle")

def test_modify(sample):
    sample.country = "CA" # Canada!
    resp = data.modify(sample.name, sample)
    assert resp == sample

def test_modify_missing():
    bob: Creature = Creature(name="bob", country="US", area="*",
        description="some guy", aka="??")
    with pytest.raises(Missing):
        _ = data.modify(bob.name, bob)

数据

数据层更容易在隔离环境中进行测试,因为不必担心意外调用更低层的函数。单元测试应覆盖该层中的函数以及它们使用的具体数据库查询。到目前为止,SQLite 一直是数据库“服务器”,SQL 是查询语言。但正如我在 第十四章 中提到的,您可能决定使用像 SQLAlchemy 这样的包,并使用其 SQLAlchemy 表达语言或其 ORM。那么这些需要完整的测试。到目前为止,我一直保持在最低层次:Python 的 DB-API 和原始的 SQL 查询。

与 Web 和服务单元测试不同,这次我们不需要“fake”模块来替换现有的数据层模块。相反,设置一个不同的环境变量,使数据层使用内存中的 SQLite 实例而不是基于文件的实例。这不需要对现有的数据模块进行任何更改,只需在 例子 12-15 的测试 之前 设置。

例子 12-15. 数据单元测试 test/unit/data/test_creature.py
import os
import pytest
from model.creature import Creature
from error import Missing, Duplicate

# set this before data import below
os.environ["CRYPTID_SQLITE_DB"] = ":memory:"
from data import creature

@pytest.fixture
def sample() -> Creature:
    return Creature(name="yeti",
        aka="Abominable Snowman",
        country="CN",
        area="Himalayas",
        description="Hapless Himalayan")

def test_create(sample):
    resp = creature.create(sample)
    assert resp == sample

def test_create_duplicate(sample):
    with pytest.raises(Duplicate):
        _ = creature.create(sample)

def test_get_one(sample):
    resp = creature.get_one(sample.name)
    assert resp == sample

def test_get_one_missing():
    with pytest.raises(Missing):
        resp = creature.get_one("boxturtle")

def test_modify(sample):
    creature.country = "JP"  # Japan!
    resp = creature.modify(sample.name, sample)
    assert resp == sample

def test_modify_missing():
    thing: Creature = Creature(name="snurfle",
        description="some thing", country="somewhere")
    with pytest.raises(Missing):
        _ = creature.modify(thing.name, thing)

def test_delete(sample):
    resp = creature.delete(sample.name)
    assert resp is None

def test_delete_missing(sample):
    with pytest.raises(Missing):
        _ = creature.delete(sample.name)

自动化集成测试

集成测试展示了不同代码在层之间交互的良好程度。但是如果你寻找这方面的例子,你会得到许多不同的答案。你是否应该测试像 Web → Service、Web → Data 等部分调用轨迹的例子呢?

要完全测试 A → B → C 管道中的每个连接,您需要测试以下内容:

  • A → B

  • B → C

  • A → C

如果您有更多的三个连接点,箭头将填充箭袋。

或者集成测试应该基本上是完整的测试,但最后一部分——磁盘上的数据存储——被模拟了吗?

到目前为止,你一直在使用 SQLite 作为数据库,你可以使用内存中的 SQLite 作为磁盘上 SQLite 数据库的双倍(假)模拟。如果您的查询非常标准的 SQL,SQLite-in-memory 可能是其他数据库的足够模拟。如果不是,这些模块专门用于模拟特定的数据库:

PostgreSQL

pgmock

MongoDB

Mongomock

许多

Pytest Mock Resources 在 Docker 容器中启动各种测试数据库,并与 pytest 集成。

最后,您可以只需启动与生产相同类型的测试数据库。环境变量可以包含具体信息,就像您一直在使用的单元测试/假技巧一样。

仓储模式

尽管我没有在本书中实现它,但仓储模式是一个有趣的方法。仓储 是一个简单的内存中间数据存储器——就像你到目前为止在这里看到的假数据层一样。然后它与真实数据库的可插拔后端进行通信。它伴随着 工作单元 模式,它确保单个 会话 中的一组操作要么全部提交,要么全部回滚。

到目前为止,本书中的数据库查询都是原子性的。对于真实世界的数据库工作,您可能需要多步查询,并进行某种形式的会话处理。仓储模式还与依赖注入结合,这是您在本书的其他地方已经看到并且现在可能已经有点欣赏了。

自动化完整测试

完整的测试将所有层次一起练习,尽可能接近生产使用。这本书中大多数你已经看过的测试都是完整的:调用 Web 端点,通过 Servicetown 到市中心 Dataville,然后返回带有杂货。这些是封闭的测试。一切都是真实的,你不关心它是如何做到的,只关心它是否做到了。

您可以通过两种方式完全测试整体 API 中的每个端点:

通过 HTTP/HTTPS

编写独立的 Python 测试客户端来访问服务器。本书中的许多示例都已经这样做了,使用像 HTTPie 这样的独立客户端,或者在使用 Requests 的脚本中。

使用 TestClient

使用这个内置的 FastAPI/Starlette 对象直接访问服务器,而无需显式的 TCP 连接。

但这些方法需要为每个端点编写一个或多个测试。这可能有点过时,我们现在已经过了几个世纪。一个更近代的方法基于 基于属性的测试。它利用 FastAPI 自动生成的文档。每次在 Web 层更改路径函数或路径装饰器时,FastAPI 都会创建一个名为 openapi.json 的 OpenAPI 模式。此模式详细描述了每个端点的所有内容:参数、返回值等等。这就是 OpenAPI 的作用,正如OpenAPI Initiative’s FAQ page所述:

OAS 定义了一个标准的、与编程语言无关的接口描述,用于 REST API,允许人类和计算机在不需要访问源代码、额外文档或检查网络流量的情况下发现和理解服务的能力。

OAS(OpenAPI 规范)

需要两个包:

Hypothesis

pip install hypothesis

Schemathesis

pip install schemathesis

假设是基础库,而 Schemathesis 则将其应用于 FastAPI 生成的 OpenAPI 3.0 模式。运行 Schemathesis 读取此模式,使用不同的数据生成大量测试(无需自行生成!),并与 pytest 协同工作。

为了简洁起见,示例 12-16 首先将 main.py 精简到其基础的 creature 和 explorer 端点。

示例 12-16. 简化后的 main.py
from fastapi import FastAPI
from web import explorer, creature

app = FastAPI()
app.include_router(explorer.router)
app.include_router(creature.router)

示例 12-17 运行测试。

示例 12-17. 运行 Schemathesis 测试
$ schemathesis http://localhost:8000/openapi.json
===================== Schemathesis test session starts =====================
Schema location: http://localhost:8000/openapi.json
Base URL: http://localhost:8000/
Specification version: Open API 3.0.2
Workers: 1
Collected API operations: 12

GET /explorer/ .                                                      [  8%]
POST /explorer/ .                                                     [ 16%]
PATCH /explorer/ F                                                    [ 25%]
GET /explorer .                                                       [ 33%]
POST /explorer .                                                      [ 41%]
GET /explorer/{name} .                                                [ 50%]
DELETE /explorer/{name} .                                             [ 58%]
GET /creature/ .                                                      [ 66%]
POST /creature/ .                                                     [ 75%]
PATCH /creature/ F                                                    [ 83%]
GET /creature/{name} .                                                [ 91%]
DELETE /creature/{name} .                                             [100%]

我得到两个 F,都是在 PATCH 调用中 (modify() 函数)。真尴尬。

这个输出部分后面是一个标记为 FAILURES 的部分,显示任何失败测试的详细堆栈跟踪。这些需要修复。最后一个部分标记为 SUMMARY

Performed checks:
    not_a_server_error                    717 / 727 passed          FAILED

Hint: You can visualize test results in Schemathesis.io
by using `--report` in your CLI command.

这真是太快了,每个端点并不需要多个测试,只需想象可能导致它们出错的输入即可。基于属性的测试从 API 模式中读取输入参数的类型和约束,并为每个端点生成一系列值。

这是类型提示的又一个意想不到的好处,最初它们似乎只是一些美好的东西:

  • 类型提示 → OpenAPI 模式 → 生成的文档 测试

安全测试

安全不是单一的事物,而是一切。你需要防范恶意行为,也要防止简单的错误,甚至是无法控制的事件。让我们把扩展性问题推迟到下一节,主要在这里分析潜在威胁。

第十一章 讨论了认证和授权。这些因素总是混乱且容易出错。诱人的是使用聪明的方法来对抗聪明的攻击,设计易于理解和实施的保护措施总是一种挑战。

但现在您已了解了 Schemathesis,请阅读其关于身份验证基于属性的测试的文档。就像它极大地简化了大多数 API 的测试一样,它可以自动化需要身份验证的端点的大部分测试。

负载测试

负载测试展示您的应用程序如何处理大量流量:

  • API 调用

  • 数据库读取或写入

  • 内存使用

  • 磁盘使用

  • 网络延迟和带宽

一些可以是完整测试,模拟一群用户争先使用您的服务;在那一天到来之前,您需要做好准备。本节内容与“性能”和“故障排除”中的内容有所重叠。

有很多优秀的负载测试工具,但我在这里推荐Locust。使用 Locust,您可以用简单的 Python 脚本定义所有测试。它可以模拟数十万用户同时访问您的网站,甚至多台服务器。

使用pip install locust在本地安装它。您的第一个测试可能是您的网站可以处理多少并发访问者。这就像在面对飓风/地震/暴风雪或其他家庭保险事件时测试建筑物能承受多少极端天气。因此,您需要一些网站结构测试。查看 Locust 的文档获取所有详细信息。

但正如电视上所说的,这还不是全部!最近,Grasshopper扩展了 Locust,可以测量跨多个 HTTP 调用的时间等功能。要尝试此扩展,请使用pip install locust-grasshopper进行安装。

回顾

本章详细介绍了各种测试类型,并举例说明了 pytest 在单元测试、集成测试和完整测试中的自动化代码测试。API 测试可以使用 Schemathesis 进行自动化。本章还讨论了如何在问题出现之前暴露安全性和性能问题。

第十三章:生产

如果建筑工人建造建筑物的方式就像程序员编写程序一样,那么第一个啄木鸟就会毁掉文明。

杰拉尔德·温伯格,计算机科学家

预览

你在本地机器上运行着一个应用程序,现在你想要分享它。本章介绍了许多场景,说明了如何将你的应用程序移动到生产环境,并确保它正确高效地运行。由于一些细节可能非常详细,在某些情况下我会参考一些有用的外部文档,而不是在这里堆砌它们。

部署

迄今为止,本书中的所有代码示例都使用了在 localhost 的端口 8000 上运行的单个 uvicorn 实例。为了处理大量流量,你需要多个服务器,运行在现代硬件提供的多个核心上。你还需要在这些服务器之上添加一些东西来执行以下操作:

  • 使它们保持运行(监督者

  • 收集和提供外部请求(反向代理

  • 返回响应

  • 提供 HTTPS 终止(SSL 解密)

多个工作进程

你可能见过另一个名为 Gunicorn 的 Python 服务器。这个服务器可以监视多个工作进程,但它是一个 WSGI 服务器,而 FastAPI 是基于 ASGI 的。幸运的是,有一个特殊的 Uvicorn 工作进程类可以由 Gunicorn 管理。

示例 13-1 在 localhost 的端口 8000 上设置了这些 Uvicorn 工作进程(这是从 官方文档 改编的)。引号保护 shell 免受任何特殊解释。

示例 13-1。使用 Gunicorn 和 Uvicorn 工作进程
$ pip install "uvicorn[standard]" gunicorn
$ gunicorn main:app --workers 4 --worker-class \
uvicorn.workers.UvicornWorker --bind 0.0.0.0:8000

当 Gunicorn 执行你的命令时,你会看到许多行。它将启动一个顶级 Gunicorn 进程,与四个 Uvicorn 工作进程子进程交流,所有进程共享 localhost (0.0.0.0) 上的端口 8000。如果你想要其他内容,可以更改主机、端口或工作进程数。main:app 指的是 main.py 和带有变量名 app 的 FastAPI 对象。Gunicorn 的文档宣称如下:

Gunicorn 只需要 4-12 个工作进程来处理每秒数百或数千个请求。

结果发现 Uvicorn 本身也可以启动多个 Uvicorn 工作进程,就像 示例 13-2 中一样。

示例 13-2。使用 Uvicorn 和 Uvicorn 工作进程
$ uvicorn main:app --host 0.0.0.0 --port 8000 --workers 4

但这种方法不涉及进程管理,因此通常更喜欢 gunicorn 方法。其他进程管理器也适用于 Uvicorn:参见其官方文档

这处理了前一节提到的四项工作中的三项,但不包括 HTTPS 加密。

HTTPS

官方 FastAPI HTTPS 文档,就像所有官方 FastAPI 文档一样,都非常丰富。我建议先阅读它们,然后再阅读 Ramírez 的描述,了解如何通过使用 Traefik 向 FastAPI 添加 HTTPS 支持。Traefik 位于你的 Web 服务器“之上”,类似于作为反向代理和负载均衡器的 nginx,但它包含了 HTTPS 魔法。

尽管这个过程有许多步骤,但比以前简单得多。特别是,以前你经常为数字证书向证书颁发机构支付高昂费用,以便为你的网站提供 HTTPS。幸运的是,这些机构大多被免费服务Let's Encrypt所取代。

Docker

当 Docker(在 2013 年 PyCon 的 Solomon Hykes 的五分钟闪电演讲中出现)时,大多数人第一次听说 Linux 容器。随着时间的推移,我们了解到 Docker 比虚拟机更快、更轻。每个容器不是模拟完整的操作系统,而是共享服务器的 Linux 内核,并将进程和网络隔离到自己的命名空间中。突然间,通过使用免费的 Docker 软件,你可以在单台机器上托管多个独立服务,而不必担心它们互相干扰。

十年后,Docker 已被普遍认可和支持。如果你想在云服务上托管你的 FastAPI 应用程序,通常需要首先创建它的Docker 镜像官方 FastAPI 文档包含了如何构建你的 FastAPI 应用程序的 Docker 化版本的详细描述。其中一步是编写Dockerfile:一个包含 Docker 配置信息的文本文件,如要使用的应用程序代码和要运行的进程。只为证明这不是在火箭发射期间进行的脑外科手术,这里是来自该页面的 Dockerfile:

FROM python:3.9
WORKDIR /code
COPY ./requirements.txt /code/requirements.txt
RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt
COPY ./app /code/app
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "80"]

我建议阅读官方文档,或者通过谷歌搜索fastapi docker得到的其他链接,比如“使用 Docker 部署你的应用程序的终极 FastAPI 教程第十三部分” by Christopher Samiullah。

云服务

在网上有许多付费或免费的主机来源。一些关于如何与它们一起托管 FastAPI 的指南包括以下内容:

Kubernetes

Kubernetes 起源于 Google 内部用于管理越来越复杂的内部系统的代码。当时的系统管理员(他们当时被称为)过去常常手动配置诸如负载均衡器、反向代理、湿度计¹等工具。Kubernetes 的目标是获取这些知识并自动化:不要告诉我如何处理这个问题;告诉我你想要什么。这包括保持服务运行或在流量激增时启动更多服务器等任务。

有很多关于如何在 Kubernetes 上部署 FastAPI 的描述,包括“在 Kubernetes 上部署 FastAPI 应用程序” by Sumanta Mukhopadhyay

性能

FastAPI 的性能目前位于最高水平之一,甚至可以与像 Go 这样更快语言的框架相媲美。但其中很大一部分归功于 ASGI,通过异步避免 I/O 等待。Python 本身是一种相对较慢的语言。以下是一些提高整体性能的技巧和窍门。

异步

通常 Web 服务器不需要真正快。它大部分时间都在获取 HTTP 网络请求和返回结果(本书中的 Web 层)。在此期间,Web 服务执行业务逻辑(服务层)并访问数据源(数据层),再次花费大部分时间在网络 I/O 上。

每当 Web 服务中的代码需要等待响应时,使用异步函数(async def 而不是 def)是个不错的选择。这样可以让 FastAPI 和 Starlette 调度异步函数,在等待获取响应时做其他事情。这也是 FastAPI 的基准测试比基于 WSGI 的框架(如 Flask 和 Django)好的原因之一。

性能有两个方面:

  • 处理单个请求所需的时间

  • 可同时处理的请求数量

缓存

如果您有一个最终从静态源获取数据的 Web 端点(例如几乎不会更改或从不更改的数据库记录),可以在函数中缓存数据。这可以在任何层中完成。Python 提供了标准的functools 模块和函数 cache()lru_cache()

数据库、文件和内存

慢网站最常见的原因之一是数据库表缺少适当大小的索引。通常直到表增长到特定大小之后,查询突然变得更慢才能看到问题。在 SQL 中,WHERE 子句中的任何列都应该有索引。

在本书的许多示例中,creatureexplorer 表的主键一直是文本字段 name。在创建表时,name 被声明为 primary key。到目前为止,在本书中看到的小表中,SQLite 无论如何都会忽略该键,因为仅扫描表更快。但一旦表达到一个可观的大小,比如一百万行,缺少索引就会显著影响性能。解决方案是运行查询优化器

即使有一个小表,也可以使用 Python 脚本或开源工具进行数据库负载测试。如果您正在进行大量顺序数据库查询,可能可以将它们合并成一个批处理。如果要上传或下载大文件,请使用流式版本而不是整体读取。

队列

如果您执行的任何任务花费超过一小部分秒钟的时间(例如发送确认电子邮件或缩小图像),可能值得将其交给作业队列,例如Celery

Python 本身

如果您的 Web 服务似乎因使用 Python 进行大量计算而变慢,您可能需要一个“更快的 Python”。替代方案包括以下内容:

  • 使用PyPy而不是标准的 CPython。

  • 用 C、C++或 Rust 编写 Python 扩展

  • 将缓慢的 Python 代码转换为Cython(由 Pydantic 和 Uvicorn 自身使用)。

最近一个非常引人注目的公告是Mojo 语言。它旨在成为 Python 的完整超集,具有新功能(使用相同友好的 Python 语法),可以将 Python 示例的速度提高数千倍。主要作者 Chris Lattner 之前曾在 Apple 上开发了像LLVMClangMLIR以及Swift语言等编译器工具。

Mojo 旨在成为 AI 开发的单一语言解决方案,现在(在 PyTorch 和 TensorFlow 中)需要 Python/C/C++三明治,这使得开发、管理和调试变得困难。但 Mojo 也将是一个除了 AI 之外的好的通用语言。

我多年来一直在 C 中编码,并一直在等待一个像 Python 一样易于使用但性能良好的后继者。D、Go、Julia、Zig 和 Rust 都是可能的选择,但如果 Mojo 能够实现其目标,我将广泛使用 Mojo。

故障排除

从遇到问题的时间和地点向下查看。这包括时间和空间性能问题,还包括逻辑和异步陷阱。

问题类型

乍一看,您得到了什么 HTTP 响应代码?

404

出现身份验证或授权错误。

422

通常是 Pydantic 对模型使用的投诉。

500

FastAPI 背后的服务失败。

记录

Uvicorn 和其他 Web 服务器通常将日志写入 stdout。您可以检查日志以查看实际进行的调用,包括 HTTP 动词和 URL,但不包括正文、标头或 Cookie 中的数据。

如果特定的端点返回 400 级状态码,您可以尝试将相同的输入反馈并查看是否再次出现错误。如果是这样,我的第一个原始调试直觉是在相关的 Web、服务和数据函数中添加print()语句。

此外,无论何处引发异常,都要添加详细信息。如果数据库查找失败,请包含输入值和具体错误,例如尝试加倍唯一键字段。

指标

术语指标监控可观察性遥测可能似乎有重叠之处。在 Python 领域中,使用以下常见实践:

您可以将这些应用于站点的所有层次:Web、服务和数据。服务层可能更加面向业务,其他层更多是技术方面的,对站点开发人员和维护者有用。

这里有一些链接可用于收集 FastAPI 的指标:

回顾

生产显然并不容易。问题包括网络和磁盘超载,以及数据库问题。本章提供了如何获取所需信息的提示,以及在问题出现时从哪里开始挖掘。

¹ 等等,这些可以保持雪茄的新鲜。

第四部分:画廊

在第三部分,您创建了一个带有一些基本代码的最小网站。现在让我们用它来做些有趣的事情。接下来的章节将把 FastAPI 应用到常见的网络用途中:表单、文件、数据库、图表与图形、地图和游戏。

要将这些应用程序联系起来,并使它们比通常枯燥的计算书籍示例更有趣,我们将从一个不寻常的来源中获取数据,其中一些您已经略见一斑:来自世界民间传说的虚构生物及其追求者。将会有雪人,但也会有更为晦涩——尽管同样引人注目——的成员。

第十四章:数据库、数据科学和少量 AI

预览

本章讨论如何使用 FastAPI 存储和检索数据。它扩展了第十章中简单的 SQLite 示例,包括以下内容:

  • 其他开源数据库(关系型和非关系型)

  • SQLAlchemy 的更高级用法

  • 更好的错误检查

数据存储替代方案

注意

不幸地,“database”这个术语被用来指代三件事:

  • 服务器 type,如 PostgreSQL、SQLite 或 MySQL

  • 运行中的 server 实例

  • 在该服务器上的 collection of tables

为了避免混淆——将上述最后一个项目的实例称为“PostgreSQL 数据库数据库数据库”,我会附加其他术语以表明我的意思。

网站的典型后端是数据库。网站和数据库如花生酱和果冻一般搭配,尽管你可能会考虑其他存储数据的方式(或者把花生酱配上泡菜),但在本书中我们将专注于数据库。

数据库处理了许多问题,否则您将不得不用代码自行解决,例如以下问题:

  • 多重访问

  • 索引

  • 数据一致性

数据库的一般选择如下:

  • 关系数据库,带有 SQL 查询语言

  • 非关系型数据库,具有各种查询语言

关系数据库和 SQL

Python 有一个名为 DB-API 的标准关系 API 定义,由所有主要数据库的 Python 驱动程序包支持。表 14-1 列出了一些显著的关系数据库及其主要的 Python 驱动程序包。

表 14-1. 关系数据库和 Python 驱动程序

数据库 Python 驱动程序
开源
SQLite sqlite3
PostgreSQL psycopg2asyncpg
MySQL MySQLdbPyMySQL
商业
Oracle python-oracledb
SQL Server pyodbcpymssql
IBM Db2 ibm_db

Python 主要用于关系数据库和 SQL 的软件包如下:

SQLAlchemy

可以在多个层次上使用的功能齐全的库

SQLModel

FastAPI 的作者结合了 SQLAlchemy 和 Pydantic 的组合

Records

来自 Requests 包的作者,一个简单的查询 API

SQLAlchemy

最流行的 Python SQL 包是 SQLAlchemy。尽管许多关于 SQLAlchemy 的解释只讨论其 ORM,但它有多个层次,我将从底层向上讨论这些。

Core

SQLAlchemy 的基础,称为 Core,包括以下内容:

  • 实现了 DB-API 标准的Engine对象

  • 表达 SQL 服务器类型、驱动程序以及该服务器上特定数据库集合的 URL

  • 客户端-服务器连接池

  • 事务(COMMITROLLBACK

  • 各种数据库类型之间的 SQL 方言 差异

  • 直接 SQL(文本字符串)查询

  • 在 SQLAlchemy 表达语言中进行查询

其中一些功能,如方言处理,使 SQLAlchemy 成为处理各种服务器类型的首选包。你可以用它来执行纯粹的 DB-API SQL 语句或使用 SQLAlchemy 表达语言。

到目前为止我一直在使用原始的 DB-API SQLite 驱动程序,并将继续使用。但对于更大的站点或可能需要利用特殊服务器功能的站点,SQLAlchemy(使用基本的 DB-API、SQLAlchemy 表达语言或完整的 ORM)是非常值得使用的。

SQLAlchemy 表达语言

SQLAlchemy 表达语言不是ORM,而是另一种表达对关系表查询的方式。它将底层存储结构映射到像TableColumn这样的 Python 类,并将操作映射到像select()insert()这样的 Python 方法。这些函数转换为普通的 SQL 字符串,你可以访问它们来查看发生了什么。该语言与 SQL 服务器类型无关。如果你觉得 SQL 困难,这可能值得一试。

让我们比较几个例子。示例 14-1 显示了纯 SQL 版本。

示例 14-1. 数据/explorer.py 中get_one()的直接 SQL 代码
def get_one(name: str) -> Explorer:
    qry = "select * from explorer where name=:name"
    params = {"name": name}
    curs.execute(qry, params)
    return row_to_model(curs.fetchone())

示例 14-2 显示了部分 SQLAlchemy 表达语言的等效内容,用于设置数据库、构建表并执行插入。

示例 14-2. SQLAlchemy 表达语言用于get_one()功能
from sqlalchemy import Metadata, Table, Column, Text
from sqlalchemy import connect, insert

conn = connect("sqlite:///cryptid.db")
meta = Metadata()
explorer_table = Table(
    "explorer",
    meta,
    Column("name", Text, primary_key=True),
    Column("country", Text),
    Column("description", Text),
    )
insert(explorer_table).values(
    name="Beau Buffette",
    country="US",
    description="...")

要获取更多示例,一些备选文档比官方页面更易读。

ORM

ORM 将查询表达为领域数据模型的术语,而不是数据库机器底层的关系表和 SQL 逻辑。官方文档详细介绍了所有细节。ORM 比 SQL 表达语言复杂得多。偏爱完全面向对象模式的开发人员通常更喜欢 ORM。

许多关于 FastAPI 的书籍和文章在数据库部分直接跳到 SQLAlchemy 的 ORM。我理解其吸引力,但也知道这要求你学习另一种抽象。SQLAlchemy 是一个优秀的库,但如果其抽象不总是适用,那么你就会遇到两个问题。最简单的解决方案可能是直接使用 SQL,如果 SQL 变得过于复杂再转向表达语言或 ORM。

SQLModel

FastAPI 的作者结合了 FastAPI、Pydantic 和 SQLAlchemy 的各个方面,创建了SQLModel。它重新利用了一些来自网络世界的开发技术到关系数据库中。SQLModel 将 SQLAlchemy 的 ORM 与 Pydantic 的数据定义和验证结合起来。

SQLite

我在 第十章 中介绍了 SQLite,将其用于数据层示例。它是公有领域的——你不能找到比这更开源的了。SQLite 在每个浏览器和每个智能手机中都被使用,使其成为世界上部署最广泛的软件包之一。在选择关系数据库时,它经常被忽视,但是有可能多个 SQLite “服务器” 也可以支持一些大型服务,就像一个强大的服务器一样,例如 PostgreSQL。

PostgreSQL

在关系数据库的早期,IBM 的 System R 是先驱,而分支为新市场而战——主要是开源的 Ingres 对商业的 Oracle。Ingres 采用了名为 QUEL 的查询语言,而 System R 采用了 SQL。尽管 QUEL 被一些人认为比 SQL 更好,但是 Oracle 将 SQL 作为标准,再加上 IBM 的影响力,帮助推动了 Oracle 和 SQL 的成功。

几年后,Michael Stonebraker 回归,将 Ingres 迁移到 PostgreSQL。如今,开源开发者倾向于选择 PostgreSQL,尽管几年前 MySQL 很受欢迎,现在仍然存在。

EdgeDB

尽管 SQL 多年来取得了成功,但它确实存在一些设计缺陷,使得查询变得笨拙。与 SQL 基于的数学理论(由 E. F. Codd 提出的 关系演算)不同,SQL 语言设计本身不具有 可组合性。主要是这意味着很难在较大的查询中嵌套查询,导致代码更加复杂和冗长。

所以,就只是为了好玩,我在这里加入了一个新的关系数据库。EdgeDB(用 Python 写的!)是由 Python 的 asyncio 的作者编写的。它被描述为 Post-SQLgraph-relational。在内部,它使用 PostgreSQL 处理繁重的系统任务。Edge 的贡献是 EdgeQL:一种旨在避免 SQL 中那些棘手的边缘的新查询语言;它实际上被转换为 SQL 以供 PostgreSQL 执行。“我对 EdgeDB 的体验” by Ivan Daniluk 方便地比较了 EdgeQL 和 SQL。可读的图解官方文档与书籍 Dracula 相呼应。

EdgeQL 是否会超越 EdgeDB 并成为 SQL 的替代品?时间会告诉我们。

非关系(NoSQL)数据库

在开源 NoSQL 或 NewSQL 领域的主要人物在 表 14-2 中列出。

表 14-2. NoSQL 数据库和 Python 驱动程序

数据库 Python 驱动程序
Redis redis-py
MongoDB PyMongo, Motor
Apache Cassandra DataStax Apache Cassandra 驱动
Elasticsearch Python Elasticsearch Client

有时 NoSQL 的意思是字面上的 no SQL,但有时是 not only SQL。关系型数据库对数据强制实施结构,通常可视化为带有列字段和数据行的矩形表,类似于电子表格。为了减少冗余并提高性能,关系型数据库使用 normal forms(数据和结构的规则)进行 normalized,例如只允许每个单元格(行/列交叉点)有一个值。

NoSQL 数据库放宽了这些规则,有时允许在单个数据行中跨列/字段使用不同类型。通常,schemas(数据库设计)可以是杂乱的结构,就像您可以在 JSON 或 Python 中表示的那样,而不是关系型的盒子。

Redis

Redis 是一个完全运行在内存中的数据结构服务器,虽然它可以保存到磁盘并从磁盘恢复。它与 Python 自身的数据结构非常匹配,因此变得非常流行。

MongoDB

MongoDB 类似于 NoSQL 服务器中的 PostgreSQL。Collection 相当于 SQL 表,document 相当于 SQL 表中的一行。另一个区别,也是使用 NoSQL 数据库的主要原因,是您不需要定义文档的结构。换句话说,没有固定的 schema。文档类似于 Python 字典,任何字符串都可以作为键。

Cassandra

Cassandra 是一个可以分布在数百个节点上的大规模数据库。它是用 Java 编写的。

一种名为 ScyllaDB 的替代数据库是用 C++ 编写的,声称与 Cassandra 兼容但性能更好。

Elasticsearch

Elasticsearch 更像是数据库索引,而不是数据库本身。它通常用于全文搜索。

SQL 数据库中的 NoSQL 特性

正如前面所述,关系型数据库传统上是规范化的——受到称为 normal forms 的不同级别规则的约束。一个基本规则是每个单元格中的值(行列交叉点)必须是 scalar(无数组或其他结构)。

NoSQL(或 document)数据库直接支持 JSON,并且通常是如果您有“不均匀”或“杂乱”数据结构的唯一选择。它们通常是 denormalized:所有需要的文档数据都包含在该文档中。在 SQL 中,您经常需要跨表进行 join 来构建完整的文档。

然而,SQL 标准的最近修订允许在关系型数据库中存储 JSON 数据。一些关系型数据库现在允许您在表单元格中存储复杂(非标量)数据,并在其中进行搜索和索引。JSON 函数以各种方式得到支持,包括 SQLitePostgreSQLMySQLOracle 等等。

具有 JSON 的 SQL 可能是两者兼得的最佳选择。SQL 数据库存在已经很长时间了,并且具有非常有用的功能,如外键和二级索引。此外,SQL 在某种程度上相当标准化,而 NoSQL 查询语言则各不相同。

最后,新的数据设计和查询语言正在尝试结合 SQL 和 NoSQL 的优势,就像我之前提到的 EdgeQL 一样。

因此,如果您的数据无法适应矩形关系框,请考虑 NoSQL 数据库,支持 JSON 的关系数据库,或者“Post-SQL”数据库。

数据库负载测试

本书主要讲述的是 FastAPI,但网站经常与数据库相关联。

本书中的数据示例都很小。要真正对数据库进行压力测试,数百万条数据将是很好的选择。与其考虑要添加的内容,不如使用像Faker这样的 Python 包更容易。Faker 可以快速生成许多类型的数据—名称、地点或您定义的特殊类型。

在示例 14-3 中,Faker 生成名称和国家,然后由load()加载到 SQLite 中。

示例 14-3. 在 test_load.py 中加载虚拟探险家
from faker import Faker
from time import perf_counter

def load():
    from error import Duplicate
    from data.explorer import create
    from model.explorer import Explorer

    f = Faker()
    NUM = 100_000
    t1 = perf_counter()
    for row in range(NUM):
        try:
            create(Explorer(name=f.name(),
                country=f.country(),
                description=f.description))
        except Duplicate:
            pass
    t2 = perf_counter()
    print(NUM, "rows")
    print("write time:", t2-t1)

def read_db():
    from data.explorer import get_all

    t1 = perf_counter()
    _ = get_all()
    t2 = perf_counter()
    print("db read time:", t2-t1)

def read_api():
    from fastapi.testclient import TestClient
    from main import app

    t1 = perf_counter()
    client = TestClient(app)
    _ = client.get("/explorer/")
    t2 = perf_counter()
    print("api read time:", t2-t1)

load()
read_db()
read_db()
read_api()

load()中捕获Duplicate异常并忽略它,因为 Faker 从一个有限的列表中生成名称,偶尔可能会重复。所以结果可能少于加载的 10 万个探险家。

此外,您两次调用了read_db(),以便在 SQLite 执行查询时消除任何启动时间。然后read_api()的时间应该是公平的。示例 14-4 启动它。

示例 14-4. 测试数据库查询性能
$ python test_load.py
100000 rows
write time: 14.868232927983627
db read time: 0.4025074450764805
db read time: 0.39750714192632586
api read time: 2.597553930943832

所有探险家的 API 读取时间比数据层的读取时间慢得多。其中一些可能是由于 FastAPI 将响应转换为 JSON 的开销。此外,写入数据库的初始时间并不是很快。它一次写入一个探险家,因为数据层 API 有一个单独的create()函数,但没有create_many()函数;在读取方面,API 可以返回一个(get_one())或所有(get_all())。因此,如果您想要进行批量加载,可能最好添加一个新的数据加载函数和一个新的 Web 端点(带有受限制的授权)。

此外,如果您期望数据库中的任何表增长到 10 万行,也许您不应该允许随机用户在一个 API 调用中获取所有这些行。分页会很有用,或者一种从表中下载单个 CSV 文件的方法。

数据科学与人工智能

Python 已经成为数据科学总体上以及机器学习特别是最突出的语言。因此需要大量的数据处理工作,而 Python 擅长于此。

有时开发人员会使用外部工具如 pandas 来进行在 SQL 中过于棘手的数据操作。

PyTorch 是最流行的 ML 工具之一,因为它充分利用了 Python 在数据处理方面的优势。底层计算可能使用 C 或 C++ 来提高速度,但 Python 或 Go 更适合“更高级”的数据集成任务。(The Mojo 语言,Python 的超集,如果计划成功,可能会处理高低端任务。虽然它是一种通用语言,但专门解决了 AI 开发中的某些当前复杂性。)

一个名为 Chroma 的新 Python 工具是一个数据库,类似于 SQLite,但专门针对机器学习,特别是大型语言模型(LLMs)。阅读 Getting Started page 来,你懂的,开始使用。

尽管 AI 开发复杂且发展迅速,但你可以在自己的机器上使用 Python 尝试一些 AI,而不需要像 GPT-4 和 ChatGPT 那样花费巨额资金。让我们构建一个小型 FastAPI 网页接口到一个小型 AI 模型。

注意

Model 在 AI 和 Pydantic/FastAPI 中有不同的含义。在 Pydantic 中,一个 model 是一个捆绑相关数据字段的 Python 类。AI models 则涵盖了广泛的技术,用于确定数据中的模式。

Hugging Face 提供免费的 AI models、数据集和 Python 代码供使用。首先,安装 PyTorch 和 Hugging Face 代码:

$ pip install torch torchvision
$ pip install transformers

Example 14-5 展示了一个 FastAPI 应用程序,它使用 Hugging Face 的 transformers 模块访问预训练的中型开源机器语言模型,并尝试回答你的提示。(这是从 YouTube 频道 CodeToTheMoon 的命令行示例中改编的。)

示例 14-5. 顶层 LLM 测试(ai.py)
from fastapi import FastAPI

app = FastAPI()

from transformers import (AutoTokenizer,
    AutoModelForSeq2SeqLM, GenerationConfig)
model_name = "google/flan-t5-base"
tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModelForSeq2SeqLM.from_pretrained(model_name)
config = GenerationConfig(max_new_tokens=200)

@app.get("/ai")
def prompt(line: str) -> str:
    tokens = tokenizer(line, return_tensors="pt")
    outputs = model.generate(**tokens,
        generator_config=config)
    result = tokenizer.batch_decode(outputs,
        skip_special_tokens=True)
    return result[0]

使用 uvicorn ai:app 运行此程序(像往常一样,首先确保你没有另一个仍在 localhost 端口 8000 上运行的网络服务器)。在 /ai 端点输入问题并获得答案,如此(注意 HTTPie 查询参数的双 ==):

$ http -b localhost:8000/ai line=="What are you?"
"a sailor"

这是一个相当小的模型,正如你所见,它并不能特别好地回答问题。我尝试了其他提示(line 参数)并得到了同样值得注意的答案:

  • Q: 猫比狗更好吗?

  • A: 不

  • Q: 大脚怪早餐吃什么?

  • A: 一只鱿鱼

  • Q: 谁会从烟囱里下来?

  • A: 一只尖叫的猪

  • Q: 约翰·克利斯在什么组里?

  • A: 披头士乐队

  • Q: 什么有尖尖的牙齿?

  • A: 一个泰迪熊

这些问题在不同时间可能会有不同的答案!有一次同一端点说大脚怪早餐吃沙子。在 AI 术语中,像这样的答案被称为 幻觉。你可以通过使用像 google/flan-75-xl 这样的更大模型来获得更好的答案,但在个人电脑上下载模型数据和响应会花费更长时间。当然,像 ChatGPT 这样的模型是使用它们能找到的所有数据训练的(使用每个 CPU、GPU、TPU 和任何其他类型的 PU),并且会给出优秀的答案。

回顾

本章扩展了我们在第十章中介绍的 SQLite 的用法,涵盖了其他 SQL 数据库,甚至 NoSQL 数据库。它还展示了一些 SQL 数据库如何通过 JSON 支持实现 NoSQL 技巧。最后,它讨论了随着机器学习持续爆炸性增长,数据库和特殊数据工具的用途变得更加重要。

第十五章:文件

预览

除了处理 API 请求和传统的内容(如 HTML)之外,Web 服务器还需要处理双向文件传输。非常大的文件可能需要以不占用太多系统内存的的形式传输。您还可以通过 StaticFiles 提供对文件目录(以及任意深度的子目录)的访问。

多部分支持

要处理大文件,FastAPI 的上传和下载功能需要这些额外的模块:

Python-Multipart

pip install python-multipart

aio-files

pip install aiofiles

文件上传

FastAPI 的目标是 API 开发,本书中大多数示例都使用了 JSON 请求和响应。但在下一章中,您将看到处理方式不同的表单。本章涵盖了文件,它在某些方面被视为表单处理。

FastAPI 提供了两种文件上传技术:File()UploadFile

File()

File() 用作直接文件上传的类型。您的路径函数可以是同步 (def) 或异步 (async def) 的,但是异步版本更好,因为它在文件上传时不会占用您的 Web 服务器。

FastAPI 将以块的形式拉取文件并将其重新组装到内存中,因此 File() 应仅用于相对较小的文件。FastAPI 不假定输入为 JSON,而是将文件编码为表单元素。

让我们编写请求文件并进行测试的代码。您可以选择您的机器上的任何文件进行测试,或者从网站(如 Fastest Fish)下载一个文件进行测试。我从那里抓取了一个 1 KB 的文件并将其保存到本地为 1KB.bin

在 示例 15-1 中,将以下行添加到你的 main.py 的顶部。

示例 15-1. 使用 FastAPI 处理小文件上传
from fastapi import File

@app.post("/small")
async def upload_small_file(small_file: bytes = File()) -> str:
    return f"file size: {len(small_file)}"

在 Uvicorn 重新启动后,尝试在 示例 15-2 中进行 HTTPie 测试。

示例 15-2. 使用 HTTPie 上传小文件
$ http -f -b POST http://localhost:8000/small small_file@1KB.bin
"file size: 1000"

关于这个测试的一些注意事项:

  • 你需要包括 -f(或 --form),因为文件是以表单形式而不是 JSON 文本上传的。

  • small_file@1KB.bin:

    small_file

    匹配 FastAPI 路径函数中的变量名 small_file,参见 示例 15-1

    @

    HTTPie 的快捷方式来制作表单

    1KB.bin

    正在上传的文件

示例 15-3 是一个等价的编程测试。

示例 15-3. 使用 Requests 上传小文件
$ python
>>> import requests
>>> url = "http://localhost:8000/small"
>>> files = {'small_file': open('1KB.bin', 'rb')}
>>> resp = requests.post(url, files=files)
>>> print(resp.json())
file size: 1000

UploadFile

对于大文件,最好使用 UploadFile。这将在服务器的磁盘上创建一个 Python SpooledTemporaryFile 对象,而不是在内存中。这是一个类似于 Python 文件的对象,支持 read()write()seek() 方法。示例 15-4 展示了这一点,并且还使用了 async def 而不是 def,以避免在上传文件片段时阻塞 Web 服务器。

示例 15-4. 使用 FastAPI 上传大文件(添加到 main.py
from fastapi import UploadFile

@app.post("/big")
async def upload_big_file(big_file: UploadFile) -> str:
    return f"file size: {big_file.size}, name: {big_file.filename}"
注意

File() 创建了一个 bytes 对象并需要括号。UploadFile 是一个不同类的对象。

如果 Uvicorn 的起动电机还没磨损,现在是测试时间了。这次,示例 15-5 到 15-6 使用了一个来自 Fastest Fish 的 1 GB 文件 (1GB.bin)。

示例 15-5. 使用 HTTPie 测试大文件上传
$ http -f -b POST http://localhost:8000/big big_file@1GB.bin
"file size: 1000000000, name: 1GB.bin"
示例 15-6. 使用 Requests 测试大文件上传
>>> import requests
>>> url = "http://localhost:8000/big"
>>> files = {'big_file': open('1GB.bin', 'rb')}
>>> resp = requests.post(url, files=files)
>>> print(resp.json())
file size: 1000000000, name: 1GB.bin

下载文件

不幸的是,重力不会使文件下载更快。相反,我们将使用类似于上传方法的等效方法。

FileResponse

首先,在 示例 15-7 中是一次性的版本,FileResponse

示例 15-7. 使用 FileResponse 下载小文件(添加到 main.py)
from fastapi.responses import FileResponse

@app.get("/small/{name}")
async def download_small_file(name):
    return FileResponse(name)

这里大概有个测试。首先,将文件 1KB.bin 放到与 main.py 相同的目录下。然后,运行 示例 15-8。

示例 15-8. 使用 HTTPie 下载小文件
$ http -b http://localhost:8000/small/1KB.bin

-----------------------------------------
| NOTE: binary data not shown in terminal |
-----------------------------------------

如果你不信任那个抑制消息,示例 15-9 将输出传递到像 wc 这样的实用程序,以确保你收到了 1,000 字节。

示例 15-9. 使用 HTTPie 下载小文件,并计算字节数
$ http -b http://localhost:8000/small/1KB.bin | wc -c
    1000

StreamingResponse

类似于 FileUpload,最好使用 StreamingResponse 下载大文件,它会以块返回文件。示例 15-10 展示了这一点,使用了一个 async def 路径函数,以避免在不使用 CPU 时阻塞。我暂时跳过错误检查;如果文件 path 不存在,open() 调用会引发异常。

示例 15-10. 使用 StreamingResponse 返回大文件(添加到 main.py)
from pathlib import path
from typing import Generator
from fastapi.responses import StreamingResponse

def gen_file(path: str) -> Generator:
    with open(file=path, mode="rb") as file:
        yield file.read()

@app.get("/download_big/{name}")
async def download_big_file(name:str):
    gen_expr = gen_file(file_path=path)
    response = StreamingResponse(
        content=gen_expr,
        status_code=200,
    )
    return response

gen_expr 是由 generator function gen_file() 返回的 generator expressionStreamingResponse 使用它作为其可迭代的 content 参数,因此可以分块下载文件。

示例 15-11 是配套的测试。(这首先需要将文件 1GB.binmain.py 放在一起,并且可能需要一点时间。)

示例 15-11. 使用 HTTPie 下载大文件
$ http -b http://localhost:8000/big/1GB.bin | wc -c
 1000000000

提供静态文件

传统的 Web 服务器可以像处理普通文件系统一样处理服务器文件。FastAPI 允许您使用 StaticFiles 实现这一点。

对于这个示例,让我们创建一个(无聊的)免费文件的目录供用户下载:

  • 在与 main.py 同级的目录下创建一个名为 static 的目录。(这个名字可以随意取;我只是称之为 static 是为了帮助记住我为什么创建它。)

  • 在其中放置一个名为 abc.txt 的文本文件,其文本内容为 abc :)

示例 15-12 将为 /static 开头的任何 URL (你也可以在这里使用任何文本字符串)提供来自 static 目录的文件。

示例 15-12. 使用 StaticFiles 服务于目录中的所有内容(添加到 main.py)
from pathlib import Path
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles

# Directory containing main.py:
top = Path(__file__).resolve.parent

app.mount("/static",
    StaticFiles(directory=f"{top}/static", html=True),
    name="free")

那个 top 计算确保你将 static 放在 main.py 旁边。__file__ 变量是此文件(main.py)的完整路径名。

示例 15-13 是手动测试 示例 15-12 的一种方式。

示例 15-13. 获取一个静态文件
$ http -b localhost:8000/static/abc.txt
abc :)

关于您传递给StaticFiles()html=True参数呢?这使它更像是传统服务器,如果该目录中存在index.html文件,则会返回该文件,但您在 URL 中并没有显式要求index.html。因此,让我们在static目录中创建一个包含内容为Oh. Hi!index.html文件,然后使用示例 15-14 进行测试。

示例 15-14. 从/static 获取一个 index.html 文件
$ http -b localhost:8000/static/
Oh. Hi!

您可以拥有任意数量的文件(及其子目录及文件等)。在static下创建一个名为xyz的子目录,并放入两个文件:

xyx.txt

包含文本xyz :(

index.html

包含文本How did you find me?

我不会在这里包含示例。请自行尝试,希望您能有更多的命名想象力。

回顾

本章展示了如何上传和下载文件——无论是小文件,大文件,甚至是巨大文件。另外,您还学会了如何以怀旧(非 API)的 Web 风格从目录中提供静态文件

第十六章:表单和模板

预览

尽管 FastAPI 中的 API 暗示了其主要关注点,FastAPI 也能处理传统的 Web 内容。本章讨论了用于将数据插入 HTML 的标准 HTML 表单和模板。

表单

正如你所见,FastAPI 主要设计用于构建 API,并且其默认输入是 JSON。但这并不意味着它不能提供标准的香蕉 HTML、表单和朋友们。

FastAPI 支持来自 HTML 表单的数据,就像支持来自 QueryPath 的数据一样,使用 Form 依赖项。

你将需要安装 Python-Multipart 包来处理任何 FastAPI 表单工作,所以如果需要的话,请运行 pip install python-multipart。此外,第十五章 中的 static 目录将用于存放本章中的测试表单。

让我们重新做 示例 3-11,但是通过表单而不是 JSON 字符串提供 who 的值。(如果旧的 greet() 路径函数仍然存在,请将其命名为 greet2()。)将 示例 16-1 添加到 main.py

示例 16-1. 从 GET 表单获取值
from fastapi import FastAPI, Form

app = FastAPI()

@app.get("/who2")
def greet2(name: str = Form()):
    return f"Hello, {name}?"

主要的区别在于值来自 Form 而不是来自 第三章 的 PathQuery 和其他内容。

在 示例 16-2 中,使用 HTTPie 尝试一个初始的表单测试(你需要 -f 来使用表单编码而不是 JSON)。

示例 16-2. 使用 HTTPie 进行的表单 GET 请求
$ http -f -b GET localhost:8000/who2 name="Bob Frapples"
"Hello, Bob Frapples?"

你还可以从标准的 HTML 表单文件发送请求。第十五章 展示了如何创建一个名为 static 的目录(在 URL /static 下访问),可以存放任何内容,包括 HTML 文件,所以在 示例 16-3 中,我们把这个文件(form1.html)放在那里。

示例 16-3. 表单 GET 请求(static/form1.html)
<form action="http://localhost:8000/who2" method="get">
Say hello to my little friend:
<input type="text" name="name" value="Bob Frapples">
<input type="submit">
</form>

如果你请求浏览器加载 http://localhost:8000/static/form1.html,你将会看到一个表单。如果你填入任何测试字符串,你会得到以下结果:

"detail":[{"loc":["body","name"],
           "msg":"field required",
           "type":"value_error.missing"}]}

嗯?

查看 Uvicorn 运行时窗口中的日志信息:

INFO:     127.0.0.1:63502 -
  "GET /who2?name=rr23r23 HTTP/1.1"
  422 Unprocessable Entity

当我们在表单字段中有 name 时,为什么这个表单发送了 name 作为查询参数?这是一个 HTML 的怪事,记录在 W3C 网站 上。此外,如果你的 URL 中有任何查询参数,它将擦除它们并替换为 name

那么,为什么 HTTPie 能够如预期地处理它?我不知道。这是一个需要注意的不一致性。

官方的 HTML 咒语是将动作从 GET 改为 POST。因此,让我们在 示例 16-4 中为 /who2 添加一个 POST 端点到 main.py 中。

示例 16-4. 从 POST 表单获取值
from fastapi import FastAPI, Form

app = FastAPI()

@app.post("/who2")
def greet3(name: str = Form()):
    return f"Hello, {name}?"

示例 16-5 是 stuff/form2.html,将 get 更改为 post

示例 16-5. 表单 POST 请求(static/form2.html)
<form action="http://localhost:8000/who2" method="post">
Say hello to my little friend:
<input type="text" name="name">
<input type="submit">
</form>

请求你的浏览器摆脱数字后腿,并为你获取这个新的表单。填写 **Bob Frapples** 并提交表单。这一次,你将会得到从 HTTPie 得到的结果:

"Hello, Bob Frapples?"

因此,如果你从 HTML 文件提交表单,请使用 POST

模板

你可能见过词语游戏 Mad Libs。你要求人们提供一系列词语 — 名词、动词或更具体的东西 — 然后将它们输入到文本页面的标记位置。一旦你有了所有的词语,你就可以读取插入值的文本,然后就会发生欢乐,有时还会出现尴尬。

好吧,一个 Web 模板 类似,尽管通常不会尴尬。模板包含一堆文本,其中有服务器插入数据的位置。它通常的目的是生成包含可变内容的 HTML,与 Chapter 15 的 静态 HTML 不同。

Flask 的用户对其伴随项目,模板引擎 Jinja(也经常称为 Jinja2)非常熟悉。FastAPI 支持 Jinja,以及其他模板引擎。

main.py 旁边创建一个名为 template 的目录,用于存放增强了 Jinja 的 HTML 文件。在其中创建一个名为 list.html 的文件,就像 Example 16-6 中的那样。

Example 16-6. 定义一个模板文件(template/list.html)
<html>
<table bgcolor="#eeeeee">
  <tr>
    <th colspan=3>Creatures</th>
  </tr>
  <tr>
    <th>Name</th>
    <th>Description</th>
    <th>Country</th>
    <th>Area</th>
    <th>AKA</th>
  </tr>
{% for creature in creatures: %}
  <tr>
    <td>{{ creature.name }}</td>
    <td>{{ creature.description }}</td>
    <td>{{ creature.country }}</td>
    <td>{{ creature.area }}</td>
    <td>{{ creature.aka }}</td>
  </tr>
{% endfor %}
</table>

<br>

<table bgcolor="#dddddd">
  <tr>
    <th colspan=2>Explorers</th>
  </tr>
  <tr>
    <th>Name</th>
    <th>Country</th>
    <th>Description</th>
  </tr>
{% for explorer in explorers: %}
  <tr>
    <td>{{ explorer.name }}</td>
    <td>{{ explorer.country }}</td>
    <td>{{ explorer.description }}</td>
  </tr>
{% endfor %}
</table>
</html>

我不关心它的外观,所以没有 CSS,只有古老的不使用 CSS 的 bgcolor 表格属性来区分两个表格。

双大括号包含应插入的 Python 变量,而 {%%} 则包含 if 语句、for 循环和其他控制语句。请参阅 Jinja 的 documentation 以获取语法和示例。

这个模板期望传递名为 creaturesexplorers 的 Python 变量,它们是 CreatureExplorer 对象的列表。

Example 16-7 显示了要添加到 main.py 中设置模板并使用 Example 16-6 中的模板的内容。它将 creaturesexplorers 提供给模板,使用前几章提供的 fake 目录下的模块,如果数据库为空或未连接,则提供测试数据。

Example 16-7. 配置模板并使用一个(main.py)
from pathlib import Path
from fastapi import FastAPI, Request
from fastapi.templating import Jinja2Templates

app = FastAPI()

top = Path(__file__).resolve().parent

template_obj = Jinja2Templates(directory=f"{top}/template")

# Get some small predefined lists of our buddies:
from fake.creature import fakes as fake_creatures
from fake.explorer import fakes as fake_explorers

@app.get("/list")
def explorer_list(request: Request):
    return template_obj.TemplateResponse("list.html",
        {"request": request,
        "explorers": fake_explorers,
        "creatures": fake_creatures})

请求你喜欢的浏览器,甚至是你不太喜欢的浏览器,访问 http://localhost:8000/list,你应该会得到 Figure 16-1 返回。

fapi 1601

Figure 16-1. /list 的输出

复习

本章是关于 FastAPI 如何处理非 API 主题,如表单和模板的快速概述。以及前一章关于文件的内容,这些都是你经常会遇到的传统 Web 任务。

第十七章:数据发现与可视化

预览

虽然 FastAPI 的名称中确实有API,但它可以提供的不仅限于 API。本章将向您展示如何使用世界各地想象中生物的小型数据库从数据生成表格、图表、图形和地图。

Python 和数据

几年来,Python 因为许多原因变得非常流行:

  • 学习的容易程度

  • 干净的语法

  • 丰富的标准库

  • 大量高质量的第三方包

  • 特别强调数据操作、转换和内省

最后一点一直与传统的 ETL 任务和数据库创建相关。一个名为PyData的非营利组织甚至组织会议并开发用于 Python 开源数据分析的工具。Python 的流行也反映了近年来 AI 的激增以及准备供应 AI 模型的数据所需工具的需求。

在本章中,我们将尝试一些 Python 数据包,并了解它们与现代 Python Web 开发和 FastAPI 的关系。

PSV 文本输出

在本节中,我们将使用附录 B 中列出的生物。数据位于本书的 GitHub 存储库中,以管道分隔的文件 cryptid.psv 和 SQLite 数据库 cryptid.db 中。逗号分隔(.csv)和制表符分隔(.tsv)文件很常见,但数据单元格内部使用逗号,并且有时很难将制表符与其他空白字符区分开。管道字符(|)是独特的,在标准文本中很少见,因此可用作良好的分隔符。

让我们先尝试.psv文本文件,只使用文本输出示例以简化操作,然后继续使用 SQLite 数据库进行完整的 Web 示例。

.psv 文件的初始标题行包含字段的名称:

  • name

  • country*表示多个国家)

  • area(可选,美国州或其他国家地区)

  • description

  • aka(也称为)

文件中的其余行描述了每次一个生物,按照指定顺序的字段,由|字符分隔。

csv

示例 17-1 将生物数据读入 Python 数据结构中。首先,管道分隔的文件 cryptids.psv 可以使用标准 Python csv 包读取,生成一个元组列表,其中每个元组表示文件中的一行数据。(csv 包还包括一个DictReader,返回一个字典列表。)此文件的第一行是带有列名的标题;即使没有这个,我们仍然可以通过 csv 函数的参数提供标题。

在示例中包含类型提示,但如果您使用的是旧版本的 Python,可以将其省略,代码仍然可以正常工作。我们只打印标题和前五行,以节省一些树木。¹

示例 17-1 用 csv 读取 PSV 文件(load_csv.py)
import csv
import sys

def read_csv(fname: str) -> list[tuple]:
    with open(fname) as file:
        data = [row for row in csv.reader(file, delimiter="|")]
    return data

if __name__ == "__main__":
    data = read_csv(sys.argv[1])
    for row in data[0:5]:
        print(row)

现在运行示例 17-2 中的测试。

示例 17-2 测试 CSV 数据库加载
$ python load_csv.py cryptid.psv
['name', 'country', 'area', 'description', 'aka']
['Abaia', 'FJ', ' ', 'Lake eel', ' ']
['Afanc', 'UK', 'CYM', 'Welsh lake monster', ' ']
['Agropelter', 'US', 'ME', 'Forest twig flinger', ' ']
['Akkorokamui', 'JP', ' ', 'Giant Ainu octopus', ' ']
['Albatwitch', 'US', 'PA', 'Apple stealing mini Bigfoot', ' ']

python-tabulate

让我们再尝试一个开源工具,python-tabulate,专门用于表格输出。您需要先运行pip install tabulate。示例 17-3 展示了代码。

示例 17-3。用 python-tabulate(load_tabulate.py)读取 PSV 文件
from tabulate import tabulate
import sys

def read_csv(fname: str) -> list[tuple]:
    with open(fname) as file:
        data = [row for row in csv.reader(file, delimiter="|")]
    return data

if __name__ == "__main__":
    data = read_csv(sys.argv[1])
    print(tabulate(data[0:5]))

在示例 17-4 中运行示例 17-3。

示例 17-4。运行表格加载脚本
$ python load_tabulate.py cryptid.psv
-----------  -------  ----  -------------------  ---
Name         Country  Area  Description          AKA
Abaia        FJ             Lake eel
Afanc        UK       CYM   Welsh lake monster
Agropelter   US       ME    Forest twig flinger
Akkorokamui  JP             Giant Ainu octopus
-----------  -------  ----  -------------------  ---

pandas

前两个示例大部分是输出格式化程序。Pandas是一个非常好的工具,用于切片和切块数据。它超越了标准的 Python 数据结构,具有像DataFrame这样的高级结构:表格、字典和系列的组合。它还可以读取.csv和其他字符分隔文件。示例 17-5 与前面的示例类似,但 Pandas 返回的是 DataFrame 而不是元组列表。

示例 17-5。用 pandas(load_pandas.py)读取 PSV 文件
import pandas
import sys

def read_pandas(fname: str) -> pandas.DataFrame:
    data = pandas.read_csv(fname, sep="|")
    return data

if __name__ == "__main__":
    data = read_pandas(sys.argv[1])
    print(data.head(5))

在示例 17-6 中运行示例 17-5。

示例 17-6。运行 pandas 加载脚本
$ python load_pandas.py cryptid.psv
          name country area                  description aka
0        Abaia      FJ                          Lake eel
1        Afanc      UK  CYM           Welsh lake monster
2   Agropelter      US   ME          Forest twig flinger
3  Akkorokamui      JP                Giant Ainu octopus
4   Albatwitch      US   PA  Apple stealing mini Bigfoot

Pandas 有很多有趣的功能,所以可以看一看。

SQLite 数据源和 Web 输出

在本章的其余示例中,您将从 SQLite 数据库中读取生物数据,使用前几章的一些网站代码。然后,您将用不同的方法切割、切块和腌制数据。而不是简单的文本输出,您将把每个示例安装到我们不断增长的神秘生物网站中。您需要对现有的 Web、服务和数据级别进行一些补充。

首先,您需要一个 Web 级别的函数和一个相关的 HTTP GET路由来返回所有生物数据。而且您已经有了!让我们做一个网络调用获取所有内容,但再次仅显示前几行(树,你知道的)。这就是示例 17-7,就在这里。

示例 17-7。运行生物下载测试(截断;树正在观察)
$ http -b localhost:8000/creature
[
    {
        "aka": "AKA",
        "area": "Area",
        "country": "Country",
        "description": "Description",
        "name": "Name"
    },
    {
        "aka": " ",
        "area": " ",
        "country": "FJ",
        "description": "Lake eel",
        "name": "Abaia"
    },
...
]

图表/图形包

现在我们可以超越文本进入 GUI。一些用于图形数据显示的最有用和最流行的 Python 包包括以下几个:

Matplotlib

非常广泛,但需要一些调整才能获得最漂亮的结果

Plotly

类似于 Matplotlib 和 Seaborn,侧重于交互式图形

Dash

基于 Plotly 构建的一种数据仪表盘

Seaborn

基于 Matplotlib 构建,并提供一个更高级的接口,但支持的图形类型较少

Bokeh

与 JavaScript 集成,以提供对非常大型数据集的仪表板视图

如何做出决策?您可以考虑以下几个标准:

  • 图表类型(例如散点图、条形图、折线图)

  • 样式

  • 使用便捷

  • 性能

  • 数据限制

“Top 6 Python Libraries for Visualization: Which One to Use?” 这样的比较,可以帮助您做出选择。最终,选择往往取决于您首先掌握的内容。在本章中,我选择了 Plotly,它可以在不需要太多编码的情况下创建吸引人的图表。

图表示例 1:测试

Plotly 是一个具有多层控制和详细信息的开源(免费)Python 库:

Plotly Express

一个简单的 Plotly 库

Plotly

主要库

Dash

数据应用工具

还有 Dash Enterprise,它像几乎所有带有 Enterprise 的名字(包括宇宙飞船模型)一样需要花钱。

我们实际上可以从生物数据中展示什么?图表和图形有一些常见的形式:

  • 散点图

  • 折线图

  • 柱状图

  • 直方图

  • 箱线图(统计学)

我们的数据字段都是字符串,有意保持简单,以避免示例过于复杂。对于每个示例,我们将使用前几章的代码从 SQLite 数据库中读取所有生物数据,并添加 Web 和 Service 函数以选择要提供给绘图库函数的特定数据。

首先安装 Plotly,以及 Plotly 导出图像所需的库:

  • pip install plotly

  • pip install kaleido

然后,在 示例 17-8 中,添加一个测试函数到 web/creature.py,以查看我们是否将正确的部件放在了正确的位置。

示例 17-8. 添加一个测试绘图端点(编辑 web/creature.py)
# (insert these lines in web/creature.py)

from fastapi import Response
import plotly.express as px

@router.get("/test")
def test():
    df = px.data.iris()
    fig = px.scatter(df, x="sepal_width", y="sepal_length", color="species")
    fig_bytes = fig.to_image(format="png")
    return Response(content=fig_bytes, media_type="image/png")

文档通常建议调用 fig.show() 来显示您刚刚创建的图像,但我们试图与 FastAPI 和 Starlette 的工作方式保持一致。

首先获取 fig_bytes(图像的实际 bytes 内容);然后返回一个自定义的 Response 对象。

在将此端点添加到 web/creature.py 并重新启动 Web 服务器(如果您使用 --reload 运行 Uvicorn,则会自动重启)后,尝试在浏览器的地址栏中输入 localhost:8000/creature/test 来访问此新端点。您应该会看到 图 17-1。

fapi 1701

图 17-1. 测试 Plotly 图像

如果您从 Uvicorn 得到一个奇怪的错误,如 ValueError: 'not' is not a valid parameter name,则更新 Pydantic 以修复错误:pip install -U pydantic

图表示例 2:直方图

如果一切顺利,让我们开始使用我们的生物数据。我们将在 web/creature.py 中添加一个 plot() 函数。我们将通过 service/creature.pydata/creature.py 中的 get_all() 函数从数据库中获取所有生物数据。然后,我们将提取所需数据,并使用 Plotly 显示各种结果的图像。

对于我们的第一个技巧(示例 17-9),我们将只使用 name 字段,并制作一个柱状图,显示以每个字母开头的生物名称的数量。

示例 17-9. 生物名称首字母的柱状图
# (insert these lines in web/creature.py)

from collections import Counter
from fastapi import Response
import plotly.express as px
from service.creature import get_all

@router.get("/plot")
def plot():
    creatures = get_all()
    letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
    counts = Counter(creature.name[0] for creature in creatures)
    y = { letter: counts.get(letter, 0) for letter in letters }
    fig = px.histogram(x=list(letters), y=y, title="Creature Names",
        labels={"x": "Initial", "y": "Initial"})
    fig_bytes = fig.to_image(format="png")
    return Response(content=fig_bytes, media_type="image/png")

在浏览器的地址栏中输入**localhost:8000/creature/plot**。你应该会看到图 17-2。

fapi 1702

图 17-2. 生物名称首字母柱状图

地图包

如果你尝试在 Google 中搜索**Python****maps**,你会得到许多关于 Python 字典的链接,这在语言中是一种内置的映射类型,并不是同一回事。因此,你可能需要尝试一些同义词,如GIS地理制图学空间等。一些流行的包建立在列表中的其他包之上,包括以下内容:

PyGIS

Python 空间数据处理参考

PySAL

Python 空间分析库

Cartopy

分析和绘制地理空间数据

Folium

与 JavaScript 集成

Python 客户端用于 Google 地图服务

API 访问 Google 地图

Geemap

Google Earth 支持

Geoplot

扩展了 Cartopy 和 Matplotlib

GeoPandas

我们朋友 pandas 的扩展

ArcGIS 和 ArcPy

Esri 的开源接口

类似于绘图包的选择标准,地图的选择也可能取决于以下几点:

  • 地图类型(例如,等值线图、矢量、栅格)

  • 样式

  • 使用便捷性

  • 性能

  • 数据限制

与图表和图形一样,地图有多种类型,可用于各种目的。

地图示例

我将再次使用 Plotly 来展示这些地图示例;它既不过于基础也不过于复杂,这有助于展示如何将一个小型基于 Web 的地图与 FastAPI 集成。

示例 17-10 获取我们生物的两个字母 ISO 国家代码。但事实证明,绘制 Plotly 地图的功能(choropleth,听起来像是一个能改变形状的神秘生物)希望使用另一种三个字母的 ISO 国家代码标准。所以我们可以重新处理数据库和 PSV 文件中的所有代码,或者更简单地运行pip install country_converter,将一组国家代码映射到另一组上。

示例 17-10. 映射带有神秘生物的国家(编辑 web/creature.py)
# (insert these lines in web/creature.py)

import plotly.express as px
import country_converter as coco

@router.get("/map")
def map():
    creatures = service.get_all()
    iso2_codes = set(creature.country for creature in creatures)
    iso3_codes = coco.convert(names=iso2_codes, to="ISO3")
    fig = px.choropleth(
        locationmode="ISO-3",
        locations=iso3_codes)
    fig_bytes = fig.to_image(format="png")
    return Response(content=fig_bytes, media_type="image/png")

请求你的浏览器前往**localhost:8000/creature/map**,然后你有可能会看到一个突显神秘生物国家的地图(图 17-3)。

fapi 1703

图 17-3. 神秘生物国家地图

如果countryUS,你可以通过使用area字段扩展地图并关注美国。使用locationmode="USA-states",并将这些area值分配给px.choropleth()locations参数。

复习

本章节将探讨各种绘图、图表和地图工具如何查询令人担忧生物数据库,从而查明是否有神秘生物在你家附近活动。

¹ 如果有任何类似托尔金的树人(Ents),我们不希望它们在某个夜晚漫步到我们家门口来交谈。

第十八章:游戏

预览

游戏涵盖了很多领域,从简单的文本到多人 3D 盛宴。本章将演示一个简单的游戏,以及 Web 端点如何在多个步骤中与用户交互。这个过程与你迄今所见的传统的一次性请求-响应 Web 端点有所不同。

Python 游戏包

如果你真的想深入研究 Python 游戏,这里有一些有用的工具:

但在本章中我不会使用这些。示例代码可能变得非常庞大和复杂,而这超出了本书的目标:尽可能简单地创建网站——使用 FastAPI,包括 API 和传统内容。

分离游戏逻辑

写游戏的方式有很多种。谁做什么,谁在哪里保存什么——客户端还是服务器?Web 是无状态的,所以每次客户端调用服务器时,服务器都是完全健忘的,并发誓从未见过这个客户端。因此,我们需要在某处保持状态:跨游戏步骤保留数据以将它们全部连接在一起。

我们可以完全在客户端 JavaScript 中编写游戏,并在那里保持所有状态。如果你很熟悉 JavaScript,那是一个不错的解决方案,但如果不熟悉(这是可能的,因为你正在阅读一本 Python 书),让我们也给 Python 一些事情可做。

另一方面,我们可以编写一个服务器重型应用程序:在初始 Web 调用中为此特定游戏生成一些独特的 ID,并在后续游戏步骤中将该 ID 与其他数据一起传递到服务器,并在某些服务器端数据存储中维护所有这些变化的状态,如数据库。

最后,我们可以将游戏结构化为一系列客户端-服务器网络端点调用,即所谓的单页应用程序(SPA)。编写 SPA 通常需要 JavaScript 向服务器发出 Ajax 调用,并将 Web 响应定位到页面的部分,而不是整个显示。客户端 JavaScript 和 HTML 会完成部分工作,服务器处理部分逻辑和数据。

游戏设计

首先,这款游戏是什么?我们将构建一个简单的类 Wordle 游戏,但只使用来自cryptid.db数据库的生物名称。这比 Wordle 要简单得多,尤其是如果你作弊并查看附录 B。

我们将使用上述最终平衡的设计方法:

  1. 让我们在客户端使用原生 JavaScript,而不是像 React、Angular 甚至 jQuery 这样著名的 JavaScript 库。

  2. 新的 FastAPI 端点,GET /game,初始化游戏。它从我们的神秘生物数据库获取一个随机生物名称,并将其作为 HTML、CSS 和 JavaScript 的 Jinja 模板文件中的隐藏值返回。

  3. 在客户端上,新返回的 HTML 和 JavaScript 显示类似 Wordle 的界面。一系列框出现,每个框代表隐藏生物名称中的一个字母。

  4. 玩家在每个框中输入一个字母,然后将此猜测和隐藏的真实名称提交给服务器。这是通过使用 JavaScript 的fetch()函数进行的 AJAX 调用。

  5. 第二个新的 FastAPI 端点,POST /game,获取此猜测和实际秘密名称,并对该名称进行评分。它将猜测和得分返回给客户端。

  6. 客户端在新生成的表格行中使用适当的 CSS 颜色显示猜测和得分:绿色表示字母在正确位置,黄色表示字母在名称中但位置不对,灰色表示隐藏名称中不存在的字母。得分是一串单个字符,这些字符作为 CSS 类名用于显示猜测字母的正确颜色。

  7. 如果所有字母都是绿色,则相应庆祝。否则,客户端显示一个新的文本输入框序列用于下一个猜测,并重复步骤 4 及以后,直到猜出名称或放弃。大多数神秘动物名称并不是家喻户晓的词汇,因此根据需要查看附录 B。

这些规则与官方 Wordle 略有不同,它只允许五个字母的词典单词,并限制为六步。

不要抱太大希望。像本书中大多数示例一样,游戏逻辑和设计都很简单——只是足够使各个部件一起工作。在有工作基础的情况下,您可以赋予更多的风格和优雅。

Web 第一部分:游戏初始化

我们希望有两个新的 Web 端点。我们正在使用生物名称,因此我们可能考虑命名端点为 GET /creature/game和 POST /creature/game。但这不起作用,因为我们已经有类似的端点 GET /creature/{name}和 POST /creature/{name},而 FastAPI 将首先匹配它们。所以让我们创建一个新的顶级路由命名空间/game,并将这两个新端点放在其中。

示例 18-1 中的第一个端点初始化游戏。它需要从数据库获取一个随机生物名称,并返回此名称以及实现多步游戏逻辑的所有客户端代码。为此,我们将使用 Jinja 模板(您在第十六章中看到过),其中包含 HTML、CSS 和 JavaScript。

示例 18-1. Web 游戏初始化(web/game.py)
from pathlib import Path

from fastapi import APIRouter, Body, Request
from fastapi.templating import Jinja2Templates

from service import game as service

router = APIRouter(prefix = "/game")

# Initial game request
@router.get("")
def game_start(request: Request):
    name = service.get_word()
    top = Path(__file__).resolve().parents[1] # grandparent
    templates = Jinja2Templates(directory=f"{top}/template")
    return templates.TemplateResponse("game.html",
        {"request": request, "word": name})

# Subsequent game requests
@router.post("")
async def game_step(word: str = Body(), guess: str = Body()):
    score = service.get_score(word, guess)
    return score

FastAPI 要求game_start()路径函数具有request参数,并将其作为参数传递给模板。

接下来,在示例 18-2 中,将这个/game子路由钩入到主模块中,该模块已经监视/explorer/creature路由。

示例 18-2. 添加/game 子路由(web/main.py)
from fastapi import FastAPI
from web import creature, explorer, game

app = FastAPI()

app.include_router(explorer.router)
app.include_router(creature.router)
app.include_router(game.router)

if __name__ == "__main__":
    import uvicorn
    uvicorn.run("main:app",
        host="localhost", port=8000, reload=True)

Web 第二部分:游戏步骤

客户端模板(HTML、CSS 和 JavaScript)的最大组件可以在示例 18-3 中看到。

示例 18-3. 工作中的 Jinja 模板文件(template/game.html)
<head>
<style>
html * {
  font-size: 20pt;
  font-family: Courier, sans-serif;
}
body {
  margin: 0 auto;
  max-width: 700px;
}
input[type=text] {
  width: 30px;
  margin: 1px;
  padding: 0px;
  border: 1px solid black;
}
td, th {
  cell-spacing: 4pt;
  cell-padding: 4pt;
  border: 1px solid black;
}
.H { background-color: #00EE00; } /* hit (green) */
.C { background-color: #EEEE00; } /* close (yellow) */
.M { background-color: #EEEEEE; } /* miss (gray) */
</style>
</head>
<body>
<script>
function show_score(guess, score){
    var table = document.getElementById("guesses");
    var row = table.insertRow(row);
    for (var i = 0; i < guess.length; i++) {
        var cell = row.insertCell(i);
        cell.innerHTML = guess[i];
        cell.classList.add(score[i]);
    }
    var word = document.getElementById("word").value;
    if (guess.toLowerCase() == word.toLowerCase()) {
        document.getElementById("status").innerHTML = "&#x1F600";
    }
}

async function post_guess() {
    var word = document.getElementById("word").value;
    var vals = document.getElementsByName("guess");
    var guess = "";
    for (var i = 0; i < vals.length; i++) {
        guess += vals[i].value;
    }
    var req = new Request("http://localhost:8000/game", {
        method: "POST",
        headers: {"Content-Type": "application/json"},
        body: JSON.stringify({"guess": guess, "word": word})
        }
     )
     fetch(req)
        .then((resp) => resp.json())
        .then((score) => {
            show_score(guess, score);
            for (var i = 0; i < vals.length; i++) {
                vals[i].value = "";
            }
         });
}
</script>
<h2>Cryptonamicon</h2>

<table id="guesses">
</table>

<span id="status"></span>

<hr>

<div>
{% for letter in word %}<input type=text name="guess">{% endfor %}
<input type=hidden id="word" value="{{word}}">
<br><br>
<input type=submit onclick="post_guess()">
</div>

</body>

服务第一部分:初始化

示例 18-4 展示了连接 Web 层游戏启动功能与数据层提供随机生物名称的服务代码。

示例 18-4. 计算分数(service/game.py)
import data.game as data

def get_word() -> str:
    return data.get_word()

服务第二部分:评分

将代码从示例 18-5(下一步)添加到示例 18-4 中。分数是一个由单个字符组成的字符串,指示猜测的字母是否匹配正确位置、匹配其他位置或是失误。为了进行大小写不敏感的匹配,猜测和单词都被转换为小写。如果猜测的长度与隐藏单词不同,将返回空字符串分数。

示例 18-5. 计算分数(service/game.py)
from collections import Counter, defaultdict

HIT = "H"
MISS = "M"
CLOSE = "C"  # (letter is in the word, but at another position)

def get_score(actual: str, guess: str) -> str:
    length: int = len(actual)
    if len(guess) != length:
        return ERROR
    actual_counter = Counter(actual) #  {letter: count, ...}
    guess_counter = defaultdict(int)
    result = [MISS] * length
    for pos, letter in enumerate(guess):
        if letter == actual[pos]:
            result[pos] = HIT
            guess_counter[letter] += 1
    for pos, letter in enumerate(guess):
        if result[pos] == HIT:
            continue
        guess_counter[letter] += 1
        if (letter in actual and
            guess_counter[letter] <= actual_counter[letter]):
            result[pos] = CLOSE
    result = ''.join(result)
    return result

测试!

示例 18-6 包含了一些关于服务分数计算的 pytest 练习。它使用 pytest 的parametrize特性来传递一系列测试,而不是在测试函数内部编写循环。从示例 18-5 中记得,H是直击,C是接近(位置错误),M是失误。

示例 18-6. 测试分数计算(test/unit/service/test_game.py)
import pytest
from service import game

word = "bigfoot"
guesses = [
    ("bigfoot", "HHHHHHH"),
    ("abcdefg", "MCMMMCC"),
    ("toofgib", "CCCHCCC"),
    ("wronglength", ""),
    ("", ""),
    ]

@pytest.mark.parametrize("guess,score", guesses)
def test_match(guess, score):
    assert game.get_score(word, guess) == score

运行它:

$ pytest -q test_game.py
.....                                               [100%]
5 passed in 0.05s

数据:初始化

在新的data/game.py模块中我们只需要一个函数,如示例 18-7 所示。

示例 18-7. 获取随机生物名称(data/game.py)
from .init import curs

def get_word() -> str:
    qry = "select name from creature order by random() limit 1"
    curs.execute(qry)
    row = curs.fetchone()
    if row:
        name = row[0]
    else:
        name = "bigfoot"
    return name

让我们玩 Cryptonomicon

(请有人想出一个更好的名字。)

在你的浏览器中,访问http://localhost:8000/game。你应该会看到一个初始显示像这样:

fapi 18in01

让我们输入一些字母并将它们作为猜测提交,看看会发生什么:

fapi 18in02

字母bfg是黄色的(如果你没有在彩色环境中查看这个,你就只能相信我的话了!),这意味着它们在隐藏名称中,但位置错误:

fapi 18in03

让我们猜一下这个名字,但是最后一个字母搞错了。我们在第二行看到了很多绿色。哦,差一点!

fapi 18in04

让我们修正最后一个字母,然后为了好玩,将一些字母大写以确保我们得到大小写不敏感的匹配。现在提交它,哇哦:

fapi 18in05

回顾

我们使用 HTML、JavaScript、CSS 和 FastAPI 构建了一个(非常简单的!)交互式 Wordle 风格的游戏。本章展示了如何使用 JSON 和 Ajax 管理 Web 客户端和服务器之间的多线程对话。

附录 A. 进一步阅读

如果您希望了解更多内容并填补我没有足够深入或完全涵盖的领域,有许多优秀的资源可供参考。本附录列出了有关 Python、FastAPI、Starlette 和 Pydantic 的资源。

Python

这些是一些知名的 Python 网站:

Python 软件基金会

主站

Real Python 教程

详细的 Python 教程

Reddit

Python subreddit

Stack Overflow

标记为“Python”的问题

Pycoder's 周刊

每周的电子邮件通讯

Anaconda

科学分发

这些是在撰写本书时我发现有用的一些 Python 书籍:

  • 介绍 Python,第 2 版,by Bill Lubanovic(O’Reilly)

  • Python 精粹 by David Beazley(Pearson Education)

  • 流畅的 Python by Luciano Ramalho(O’Reilly)

  • Robust Python by Patrick Viafore(O’Reilly)

  • 使用 Python 架构模式 by Harry J. W. Percival 和 Bob Gregory(O’Reilly)

FastAPI

以下是一些 FastAPI 网站:

主页

官方网站,以及我看过的最好的技术文档

外部链接和文章

来自官方网站

FastAPI GitHub

FastAPI 代码库

Awesome FastAPI

一个资源的精选列表

终极 FastAPI 教程

详细的多部分指南

蓝书:FastAPI

FastAPI 的详细概述

Medium

标记为“FastAPI”的文章

使用 FastAPI 构建 Python Web API

精简的 FastAPI 文档

Twitter

使用@FastAPI 或#FastAPI 的推文

Gitter

帮助请求和答案

GitHub

包含 FastAPI 名称的存储库

虽然 FastAPI 在 2018 年末才推出,但出版的书籍并不多。以下是我阅读并学习的一些书籍:

  • 使用 FastAPI 构建数据科学应用 by François Voron(Packt)

  • 使用 FastAPI 构建 Python 微服务 by Sherwin John C. Tragura(Packt)

  • 微服务 API by José Haro Peralta(Manning)

Starlette

Starlette 的顶级链接包括以下内容:

Pydantic

主要的 Pydantic 链接列在这里:

附录 B. 生物与人类

鬼魅与幽灵

还有长腿怪兽

还有夜间撞击声

天主保佑我们。

康沃尔宗教赞美诗中的一节

想象中的生物,或称为神秘动物,据报告遍布各地。一些曾被认为是想象的动物——如熊猫、鸭嘴兽和黑天鹅——最终被证明是真实存在的。所以我们不会臆测。无畏的探险家正在寻找它们。它们共同为本书中的例子提供数据。

fapi Bin01

生物

表格 B-1 列出了我们将调查的生物。

表 B-1. 一本迷你兽群志

名称 国家 地区 描述 AKA
阿巴亚 FJ 湖鳗
阿凡克 UK CYM 威尔士湖怪
阿格罗佩尔特 US ME 森林树枝投掷者
阿科罗卡木 JP 巨大的阿伊努章鱼
阿尔巴特威奇 US PA 偷苹果的迷你大脚怪
阿利坎托 CL 吃金的鸟
阿尔塔马塔哈 US GA 沼泽生物 奥尔蒂
阿马洛克 CA 因纽特人狼灵
奥利 CY 阿伊亚纳帕海怪 友好的怪物
阿泽班 CA 恶作剧之灵 浣熊
蝙蝠鬼 US WA 飞行萨斯奎奇
布莱登伯勒野兽 US NC 吸血狗
布雷路斯路怪物 US WI 威斯康星狼人
博斯科野兽 US IN 巨型乌龟
吉沃当野兽 FR 法国狼人
食海狸者 CA 筑巢翻转者 萨托奇因
大脚怪 US 雪人的表兄埃迪 萨斯奎奇
布卡瓦克 HR 湖泊扼杀者
巴尼普 AU 水生澳大利亚人
卡多博罗萨鲁斯 CA BC 海蛇 卡迪
小说 US VT 钱普莱恩湖潜伏者 钱普
切帕卡布拉 MX 吸血山羊
达胡 FR 瓦姆帕胡弗斯的法国表兄
多亚尔祖 IE 狗獭 爱尔兰鳄鱼
* 翅膀!火焰!
落树熊 AU 肉食性树袋熊
邓加文胡特 US 将猎物磨成蒸汽,然后吸入
恩坎塔多 BR 调皮的河豚
福克怪物 US AR 臭大脚 波吉克里克怪物
格洛斯特鬼怪 US RI 罗得岛龙
格洛斯特海蛇 US MA 美国尼斯湖怪
伊戈波戈 CA ON 加拿大尼斯湖怪
伊什伊 JP 湖怪 伊西
独角兔 US * 鹿角兔
杰西魔鬼 US NJ 雪顶屋顶跳跃者
科迪亚克恐龙 US AK 巨大的海洋蜥蜴
海怪 * 巨型乌贼
蜥蜴人 US SC 沼泽生物
LLaammaa CL 美洲驼的头,美洲驼的身体。但不是同一只美洲驼。
尼斯湖怪^(a) UK SC 著名的洛克湖怪 尼西
卢斯卡 BS 巨型章鱼
马埃罗 NZ 巨人
梅内胡内 US HI 夏威夷小精灵
莫克莱姆贝贝 CG 沼泽怪物
蒙古死亡虫 MN 阿拉克访客
蛾人 US WV 仅限理查德·杰尔电影中的神秘动物
Snarly Yow US MD 地狱猎犬
吸血鬼 * 吸血鬼
弗拉德·伊瓜拉 KE 乡村吸血鬼
温迪戈 CA 食人大脚怪
狼人 * 变形者 狼人, 森林鬼怪^(b)
翼龙 UK 后腿无龙
旋足兽 US VT 不对称山地居民 侧山搅土怪
雪人 CN 多毛的喜马拉雅人 雪人
^(a) 我曾与彼得·麦克纳布见过面,他拍摄了尼斯湖水怪的照片之一。^(b) 法国。或者说:史酷比:“Ruh-roh!Rougarou!”

探险家

我们的调查团队,从四面八方,详见 表 B-2。

fapi Bin02

表 B-2. 人类

名称 国家 描述
克劳德·汉德 UK 满月时稀少
海伦娜·汉德·巴斯凯特 UK 女爵士^(a) 名声在外
波·巴菲特 US 永远不摘他的帽子
O. B. Juan Cannoli MX 森林智者
西蒙·N·格洛芬戴尔 FR 卷发,精于林间
“帕”·图希 IE 探险家/吐痰者
拉达·图希 IN 神秘的大地母亲
诺亚·韦瑟 DE 近视的砍刀手
^(a) 在贵族,而不是

探险家出版物

这里是我们假想探险家的虚构出版物:

  • 鼠岛的秘密 作者 B. Buffette

  • 我在想什么? 作者 O. B. J. Cannoli

  • “蜘蛛从不睡眠,” 令人不安的结果杂志, 作者 N. Weiser

  • “Sehr Böse Spinnen,” 比较神秘动物学期刊, 作者 N. Weiser

其他来源

神秘动物传说有许多来源。一些神秘动物可能被归类为虚构生物,而有些可能只能在远处拍摄的模糊照片中看到。我的来源包括以下内容:

posted @ 2024-06-17 19:07  绝不原创的飞龙  阅读(87)  评论(0编辑  收藏  举报