FastAPI-指南-二-

146 阅读45分钟

FastAPI 指南(二)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

第六章:依赖项

预览

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。指定语句和参数

类型语句部分参数部分
qmarkname=? or country=?(*name*, *country*)
numericname=:0 or country=:1(*name*, *country*)
formatname=%s or country=%s(*name*, *country*)
namedname=:name or country=:country{"name": *name*, "country": *​coun⁠try*}
pyformatname=%(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

回顾

本章介绍了一个简单的数据处理层,根据需要在层栈中上下移动几次。第十二章包含每个层的单元测试,以及跨层集成和完整的端到端测试。第十四章深入探讨了数据库的更多细节和详细示例。