FastAPI-Python-微服务构建指南-全-

FastAPI Python 微服务构建指南(全)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

本书教你了解 FastAPI 框架的组件,以及如何将这些组件与一些第三方工具结合使用来构建微服务应用程序。你需要具备 Python 编程的背景知识,了解 API 开发的原则,以及理解构建企业级微服务应用程序背后的原则。这不仅仅是一本参考书:它提供了一些代码蓝图,帮助你解决现实世界中的应用问题,同时详细阐述和演示了每一章的主题。

本书面向的对象

本书面向 Python 网络开发者、高级 Python 用户以及使用 Flask 或 Django 的后端开发者,他们想学习如何使用 FastAPI 框架来实现微服务。了解 REST API 和微服务的读者也将从本书中受益。书中的一些部分包含了一些通用概念、流程和说明,中级开发者以及 Python 爱好者也可以从中找到共鸣。

本书涵盖的内容

第一章FastAPI 入门设置,介绍了如何使用核心模块类和装饰器创建 FastAPI 端点,以及框架如何管理传入的 API 请求和传出的响应。

第二章探索核心功能,介绍了 FastAPI 的异步端点、异常处理机制、后台进程、用于项目组织的 APIRouter、内置的 JSON 编码器和 FastAPI 的 JSON 响应。

第三章探究依赖注入,探讨了Depends()指令和第三方扩展模块。

第四章构建微服务应用程序,讨论了支持构建微服务的原则和设计模式,例如分解、属性配置、日志记录和领域建模策略。

第五章连接到关系型数据库,重点介绍了 Python 对象关系映射器ORMs),它可以无缝集成 FastAPI,使用 PostgreSQL 数据库持久化和管理数据。

第六章使用非关系型数据库,展示了 PyMongo 和 Motor 引擎,包括一些流行的 Python 对象文档映射器ODMs),它们可以将 FastAPI 应用程序连接到 MongoDB 服务器。

第七章保护 REST API,突出了 FastAPI 内置的安全模块类,并探讨了 JWT、Keycloak、Okta 和 Auth0 等第三方工具,以及它们如何应用于实现不同的安全方案以保护应用程序。

第八章创建协程、事件和消息驱动事务,重点介绍了 FastAPI 异步方面的细节,如协程的使用、asyncio 环境、使用 Celery 的异步后台进程、使用 RabbitMQ 和 Apache Kafka 的异步消息、SSE、WebSocket 和异步事件。

第九章利用其他高级功能,包含 FastAPI 可以提供的其他功能,例如其对不同响应类型的支持、中间件的定制、请求和响应、其他 JSON 编码器的应用以及绕过 CORS 浏览器策略。

第十章解决数值、符号和图形问题,突出了 FastAPI 与numpypandasmatplotlibsympyscipy模块的集成,以实现能够执行数值和符号计算以解决数学和统计问题的 API 服务。

第十一章添加其他微服务功能,讨论了其他架构关注点,如监控和检查 API 端点在运行时的属性、OpenTracing、客户端服务发现、管理存储库模块、部署以及使用 Flask 和 Django 应用创建单仓库架构。

要充分利用本书

本书要求您具备使用 Python 3.8 或 3.9 进行 Python 编程的经验,以及使用任何 Python 框架进行一些 API 开发的经验。需要了解 Python 的编码标准和最佳实践,包括一些高级主题,如创建装饰器、生成器、数据库连接、请求-响应事务、HTTP 状态码和 API 端点。

在 Okta 和 Auth0 上为 OpenID 连接安全方案开设账户。两者都偏好使用公司电子邮件进行注册。

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

每章都有一个专门的项目原型,将描述和解释主题。如果在设置过程中迷失方向,每个项目都有一个备份的数据库(.sql.zip)和模块列表(requirements.txt),以解决一些问题。运行\iPostgreSQL 命令安装脚本文件或使用已安装的 Mongo 数据库工具中的 mongorestore 来加载所有数据库内容。此外,每个项目都有一个迷你 README 文件,概述了原型想要实现的内容。

下载示例代码文件

您可以从 GitHub 下载本书的示例代码文件 github.com/PacktPublishing/Building-Python-Microservices-with-FastAPI。如果代码有更新,它将在 GitHub 仓库中更新。

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

下载彩色图像

我们还提供了一份包含本书中使用的截图和图表彩色图像的 PDF 文件。您可以从这里下载:packt.link/ohTNw

使用的约定

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

文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“delete_user() 服务是一个 DELETE API 方法,它使用 username 路径参数来搜索用于删除的登录记录。”

代码块应如下设置:

@app.delete("/ch01/login/remove/{username}")
def delete_user(username: str):
    del valid_users[username]
    return {"message": "deleted user"}

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

@app.get("/ch01/login/")
def login(username: str, password: str):
    if valid_users.get(username) == None:
        return {"message": "user does not exist"}
    else:
        user = valid_users.get(username)

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

pip install fastapi
pip install uvicorn[standard]

粗体:表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“从 管理 面板中选择 系统信息。”

小贴士或重要注意事项

看起来像这样。

联系我们

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

customercare@packtpub.com 并在邮件主题中提及书籍标题。

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

copyright@packt.com 并附有材料链接。

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

分享您的想法

一旦您阅读了《使用 FastAPI 构建 Python 微服务》,我们很乐意听听您的想法!请 点击此处直接进入此书的亚马逊评论页面 并分享您的反馈。

您的评论对我们和科技社区都很重要,并将帮助我们确保我们提供高质量的内容。

第一部分:FastAPI 微服务开发的应用相关架构概念

在本部分,我们将全面了解 FastAPI 框架,并探讨将单体应用系统性地分解为几个业务单元的理想方法。在这个过程中,您将了解如何开始开发,以及 FastAPI 中有哪些组件可以被利用来实现微服务架构。

本部分包括以下章节:

  • 第一章,FastAPI 入门设置

  • 第二章,探索核心组件

  • 第三章,探究依赖注入

  • 第四章,构建微服务应用

第一章:为初学者设置 FastAPI

在任何软件开发工作中,了解项目的业务需求和合适的框架、工具和部署平台始终是重要的,在追求任务之前。易于理解和使用、编码过程中无缝且符合标准的框架总是被选中,因为它们提供的完整性可以在不冒太多开发风险的情况下解决问题。一个名为 FastAPI 的有希望的 Python 框架,由 Sebastian Ramirez 创建,为经验丰富的开发者、专家和爱好者提供了构建 REST APIs 和微服务的最佳选择。

在继续深入探讨使用 FastAPI 构建微服务的核心细节之前,最好首先了解这个框架的构建块,例如它是如何捕获客户端请求的,如何为每个 HTTP 方法构建规则,以及如何管理 HTTP 响应。了解基本组件始终是了解框架的优缺点以及我们可以将 FastAPI 应用于解决不同企业级和微服务相关问题的程度的关键。

因此,在本章中,我们将通过涵盖以下主要主题来对 FastAPI 的基本功能进行概述:

  • 开发环境的设置

  • FastAPI 的初始化和配置

  • REST API 的设计和实现

  • 管理用户请求和服务器响应

  • 处理表单参数

  • 处理 cookies

技术要求

本章的软件示例是一个典型的管理员管理的在线学术讨论论坛,这是一个学术讨论中心,校友、教师和学生可以交流想法。原型正在运行,但它是开放的,因此您可以在阅读本章时修改代码。它没有设计为使用任何数据库管理系统,但所有数据都临时存储在各种 Python 集合中。本书中的所有应用程序都是使用 Python 3.8 编译和运行的。所有代码都已上传至github.com/PacktPublishing/Building-Python-Microservices-with-FastAPI/tree/main/ch01

设置开发环境

FastAPI 框架是一个快速、无缝且健壮的 Python 框架,但只能运行在 Python 版本 3.6 及以上。本参考中使用的集成开发环境IDE)是Visual Studio CodeVS Code),这是一个开源工具,我们可以从以下网站下载:code.visualstudio.com/。只需确保安装 VSC 扩展,如 PythonPython for VS CodePython Extension PackPython IndentMaterial Icon Theme,以提供您的编辑器语法检查、语法高亮和其他编辑器支持。

在成功安装 Python 和 VS Code 之后,我们现在可以使用终端控制台安装 FastAPI。为了确保正确安装,首先通过运行以下命令更新 Python 的包安装程序(pip):

python -m pip install --upgrade pip

之后,我们通过运行以下一系列命令来安装框架:

pip install fastapi
pip install uvicorn[standard]
pip install python-multipart

重要提示

如果您需要安装完整的 FastAPI 平台,包括所有可选依赖项,适当的命令是pip install fastapi[all]。同样,如果您想安装并使用完整的uvicorn服务器,应运行pip install uvicorn命令。此外,安装bcrypt模块以进行加密相关任务。

到目前为止,您应该已经安装了所有需要的 FastAPI 模块依赖项,从uvicorn开始,它是一个基于 ASGI 的服务器,用于运行 FastAPI 应用程序。uvicorn服务器具有运行同步和异步服务的能力。

在安装和配置了基本工具、模块和 IDE 之后,现在让我们开始使用框架进行第一个 API 实现。

初始化和配置 FastAPI

学习如何使用 FastAPI 创建应用程序既简单又直接。只需在您的/ch01项目文件夹内创建一个main.py文件,就可以创建一个简单的应用程序。例如,在我们的在线学术讨论论坛中,应用程序从以下代码开始:

from fastapi import FastAPI
app = FastAPI()

这初始化了 FastAPI 框架。应用程序需要从fastapi模块实例化核心FastAPI类,并使用app作为引用变量到对象。然后,这个对象在以后被用作 Python @app装饰器,为我们提供一些功能,如路由中间件异常处理程序路径操作

重要提示

您可以将app替换为您喜欢的但有效的 Python 变量名,例如main_appforummyapp

现在,您的应用程序已准备好管理技术上是 Python 函数的 REST API。但为了将它们声明为 REST 服务方法,我们需要使用路径操作@app装饰器提供的适当 HTTP 请求方法来装饰它们。这个装饰器包含get()post()delete()put()head()patch()trace()options()路径操作,它们对应于八个 HTTP 请求方法。而且,这些路径操作是装饰或注释在我们想要处理请求和响应的 Python 函数之上的。

在我们的示例中,REST API 创建的第一个样本是:

@app.get("/ch01/index")
def index():
    return {"message": "Welcome FastAPI Nerds"} 

上述是一个返回JSON对象的GET API 服务方法。为了在本地上运行我们的应用程序,我们需要执行以下命令:

uvicorn main:app --reload

此命令将通过应用程序的main.py文件和 FastAPI 对象引用将论坛应用程序加载到 uvicorn 实时服务器。通过添加--reload选项允许实时重新加载,该选项在代码有更改时重启开发服务器。

图 1.1 – uvicorn 控制台日志

图 1.1 – uvicorn 控制台日志

图 1.1 显示 uvicorn 使用 localhost 和默认端口 8000 运行应用程序。我们可以通过 http://localhost:8000/ch01/index 访问我们的首页。要停止服务器,只需按下 Ctrl + C 键盘键即可。

在运行了我们的第一个端点之后,现在让我们来探讨如何实现其他类型的 HTTP 方法,即 POSTDELETEPUTPATCH

设计和实现 REST API

表示状态转移REST)API 包含了允许微服务之间交互的规则、流程和工具。这些是通过它们的端点 URL 识别和执行的方法服务。如今,在构建整个应用程序之前先关注 API 方法是微服务设计中最流行和最有效的方法之一。这种方法称为 API 首选 微服务开发,首先关注客户端的需求,然后确定我们需要实现哪些 API 服务方法来满足这些客户端需求。

在我们的 在线学术讨论论坛 应用中,像 用户注册登录个人资料管理发帖管理帖子回复 这样的软件功能是我们优先考虑的关键需求。在 FastAPI 框架中,这些功能是通过使用 Python 的 def 关键字定义的函数实现的,并通过 @app 提供的 路径操作 关联适当的 HTTP 请求方法。

需要从用户那里获取 usernamepassword 请求参数的 login 服务,被实现为一个 GET API 方法:

@app.get("/ch01/login/")
def login(username: str, password: str):
    if valid_users.get(username) == None:
        return {"message": "user does not exist"}
    else:
        user = valid_users.get(username)
        if checkpw(password.encode(), 
                   user.passphrase.encode()):
            return user
        else:
            return {"message": "invalid user"}

这个 login 服务使用 bcrypt 的 checkpw() 函数来检查用户的密码是否有效。相反,同样需要从客户端以请求参数形式获取用户凭证的 sign-up 服务,被创建为一个 POST API 方法:

@app.post("/ch01/login/signup")
def signup(uname: str, passwd: str):
    if (uname == None and passwd == None):
        return {"message": "invalid user"}
    elif not valid_users.get(uname) == None:
        return {"message": "user exists"}
    else:
        user = User(username=uname, password=passwd)
        pending_users[uname] = user
        return user

个人资料管理 服务中,以下 update_profile() 服务作为一个 PUT API 服务,要求用户使用一个全新的模型对象来替换个人资料信息,并且客户端的用户名作为键:

@app.put("/ch01/account/profile/update/{username}")
def update_profile(username: str, id: UUID, 
                     new_profile: UserProfile):
    if valid_users.get(username) == None:
        return {"message": "user does not exist"}
    else:
        user = valid_users.get(username)
        if user.id == id:
            valid_profiles[username] = new_profile
            return {"message": "successfully updated"}
        else:
            return {"message": "user does not exist"}

并非所有执行更新的服务都是 PUT API 方法,例如以下 update_profile_name() 服务,它只要求用户提交新的名字、姓氏和中间名以部分替换客户端的个人资料。这个比完整的 PUT 方法更方便、更轻量级的 HTTP 请求,只需要一个 PATCH 操作:

@app.patch("/ch01/account/profile/update/names/{username}")
def update_profile_names(username: str, id: UUID, 
                          new_names: Dict[str, str]):
    if valid_users.get(username) == None:
        return {"message": "user does not exist"}
    elif new_names == None:
        return {"message": "new names are required"}
    else:
        user = valid_users.get(username)
        if user.id == id:
            profile = valid_profiles[username]
            profile.firstname = new_names['fname']
            profile.lastname = new_names['lname']
            profile.middle_initial = new_names['mi']
            valid_profiles[username] = profile
            return {"message": "successfully updated"}
        else:
            return {"message": "user does not exist"}

在构建应用程序之前,我们包括的最后必要 HTTP 服务是 DELETE API 方法。我们使用这些服务根据唯一的标识符(如 username 和散列的 id)删除记录或信息。以下是一个示例 delete_post_discussion() 服务,允许用户在提供用户名和发布消息的 UUID(通用唯一标识符)时删除发布的讨论:

@app.delete("/ch01/discussion/posts/remove/{username}")
def delete_discussion(username: str, id: UUID):
    if valid_users.get(username) == None:
        return {"message": "user does not exist"}
    elif discussion_posts.get(id) == None:
        return {"message": "post does not exist"}
    else:
        del discussion_posts[id] 
        return {"message": "main post deleted"}

所有路径操作都需要一个唯一的端点 URL,格式为 str。一个好的做法是让所有 URL 都以相同的顶级基础路径开始,例如 /ch01,然后在到达各自的子目录时进行区分。在运行 uvicorn 服务器后,我们可以通过访问文档 URL http://localhost:8000/docs 来检查和验证我们所有的 URL 是否有效且正在运行。此路径将显示如图 图 1.2 所示的 OpenAPI 仪表板,列出为应用程序创建的所有 API 方法。关于 OpenAPI 的讨论将在 第九章 利用其他高级功能 中介绍。

图 1.2 – Swagger OpenAPI 仪表板

图 1.2 – Swagger OpenAPI 仪表板

在创建端点服务后,让我们仔细看看 FastAPI 如何管理其传入的请求体和传出的响应。

管理用户请求和服务器响应

客户端可以通过路径参数、查询参数或头信息将他们的请求数据传递给 FastAPI 端点 URL,以进行服务事务。有标准和方式使用这些参数来获取传入的请求。根据服务目标,我们使用这些参数来影响和构建客户端需要的必要响应。但在我们讨论这些各种参数类型之前,让我们首先探索如何在 FastAPI 的局部参数声明中使用 类型提示

参数类型声明

所有请求参数都必须在服务方法的参数签名中声明类型,应用 Noneboolintfloat 以及容器类型,如 listtupledictsetfrozensetdeque。还支持其他复杂的 Python 类型,如 datetime.datedatetime.timedatetime.datetimedatetime.deltaUUIDbytesDecimal

该框架还支持 Python 的 typing 模块中包含的数据类型,这些数据类型负责 类型提示。这些数据类型是 Python 的标准表示法,以及变量类型注释,有助于在编译期间进行类型检查和模型验证,例如 OptionalListDictSetUnionTupleFrozenSetIterableDeque

路径参数

FastAPI 允许您通过 API 的端点 URL 的路径参数或路径变量来获取请求数据,这使得 URL 变得有些动态。这个参数持有一个值,该值成为由花括号 ({}) 指示的 URL 的一部分。在 URL 中设置这些路径参数之后,FastAPI 需要通过应用 类型提示 来声明这些参数。

以下 delete_user() 服务是一个使用 username 路径参数来搜索用于删除的登录记录的 DELETE API 方法:

@app.delete("/ch01/login/remove/{username}")
def delete_user(username: str):
    if username == None:
    return {"message": "invalid user"}
else:
    del valid_users[username]
    return {"message": "deleted user"}

如果最左边的变量更有可能被填充值,则可以接受多个路径参数。换句话说,最左边的路径变量的重要性将使过程比右边的更相关和正确。这个标准应用于确保端点 URL 不会看起来像其他 URL,这可能会引起一些冲突和混淆。以下 login_with_token() 服务遵循这个标准,因为 username 是主键,其强度与下一个参数 password 相当,甚至更强。可以保证每次访问端点时 URL 总是唯一的,因为 username 总是需要的,以及 password

@app.get("/ch01/login/{username}/{password}")
def login_with_token(username: str, password:str, 
                     id: UUID):
    if valid_users.get(username) == None:
        return {"message": "user does not exist"}
    else:
        user = valid_users[username]
        if user.id == id and checkpw(password.encode(), 
                 user.passphrase):
            return user
        else:
            return {"message": "invalid user"}

与其他 Web 框架不同,FastAPI 对属于基本路径或顶级域名路径的不同子目录的端点 URL 不友好。这种情况发生在我们具有动态 URL 模式,当分配一个特定的路径变量时,看起来与其他固定端点 URL 相同。这些固定 URL 在这些动态 URL 之后依次实现。以下服务是这些服务的例子:

@app.get("/ch01/login/{username}/{password}")
def login_with_token(username: str, password:str, 
                     id: UUID):
    if valid_users.get(username) == None:
        return {"message": "user does not exist"}
    else:
        user = valid_users[username]
        if user.id == id and checkpw(password.encode(), 
                      user.passphrase.encode()):
            return user
        else:
            return {"message": "invalid user"}
@app.get("/ch01/login/details/info")
def login_info():
        return {"message": "username and password are 
                            needed"}

当访问 http://localhost:8080/ch01/login/details/info 时,这将给我们一个 HTTP 状态码 422 (不可处理实体)。由于 API 服务几乎是一个占位符或简单的 JSON 数据,因此访问 URL 应该没有问题。在这个场景中发生的情况是,固定路径的 detailsinfo 路径目录被分别视为 usernamepassword 参数值。由于混淆,FastAPI 的内置数据验证将显示一个 JSON 格式的错误消息,内容为:{"detail":[{"loc":["query","id"],"msg":"field required","type":"value_error.missing"}]}。要解决这个问题,所有固定路径应该在带有路径参数的动态端点 URL 之前声明。因此,login_info() 服务应该在 login_with_token() 之前声明。

查询参数

查询参数是一个在端点 URL 末尾提供的 键值对,由问号 (?) 表示。就像路径参数一样,这也持有请求数据。API 服务可以管理一系列由 ampersand (&) 分隔的查询参数。就像在路径参数中一样,所有查询参数也在服务方法中声明。以下 login 服务是一个使用查询参数的完美示例:

@app.get("/ch01/login/")
def login(username: str, password: str):
    if valid_users.get(username) == None:
        return {"message": "user does not exist"}
    else:
        user = valid_users.get(username)
        if checkpw(password.encode(), 
               user.passphrase.encode()):
            return user
        else:
            return {"message": "invalid user"}

login服务方法使用usernamepassword作为str类型的查询参数。这两个都是必填参数,将它们分配给None作为参数值将导致编译器错误。

FastAPI 支持复杂类型的查询参数,例如listdict。但是,除非我们为 Python 集合应用泛型类型提示,否则这些 Python 集合类型无法指定要存储的对象类型。以下delete_users()update_profile_names()API 使用泛型类型提示ListDict来声明具有类型检查和数据验证的容器类型查询参数:

from typing import Optional, List, Dict
@app.delete("/ch01/login/remove/all")
def delete_users(usernames: List[str]):
    for user in usernames:
        del valid_users[user]
    return {"message": "deleted users"}
@app.patch("/ch01/account/profile/update/names/{username}")
def update_profile_names(username: str, id: UUID, 
                         new_names: Dict[str, str]):
    if valid_users.get(username) == None:
        return {"message": "user does not exist"}
    elif new_names == None:
        return {"message": "new names are required"}
    else:
        user = valid_users.get(username)
        if user.id == id:
            profile = valid_profiles[username]
            profile.firstname = new_names['fname']
            profile.lastname = new_names['lname']
            profile.middle_initial = new_names['mi']
            valid_profiles[username] = profile
            return {"message": "successfully updated"}
        else:
            return {"message": "user does not exist"}

FastAPI 还允许您明确地为服务函数参数分配默认值。

默认参数

有时候我们需要为某些 API 服务的查询参数和路径参数指定默认值,以避免出现诸如字段必填value_error.missing之类的验证错误信息。为参数设置默认值将允许在提供或不提供参数值的情况下执行 API 方法。根据需求,分配的默认值通常是数值类型的0,布尔类型的False,字符串类型的空字符串,列表类型的空列表([]),以及字典类型的空字典({})。以下delete pending users()change_password()服务展示了如何将默认值应用于查询参数和路径参数:

@app.delete("/ch01/delete/users/pending")
def delete_pending_users(accounts: List[str] = []):
    for user in accounts:
        del pending_users[user]
    return {"message": "deleted pending users"}
@app.get("/ch01/login/password/change")
def change_password(username: str, old_passw: str = '',
                         new_passw: str = ''):
    passwd_len = 8
    if valid_users.get(username) == None:
        return {"message": "user does not exist"}
    elif old_passw == '' or new_passw == '':
        characters = ascii_lowercase
        temporary_passwd = 
             ''.join(random.choice(characters) for i in 
                     range(passwd_len))
        user = valid_users.get(username)
        user.password = temporary_passwd
        user.passphrase = 
                  hashpw(temporary_passwd.encode(),gensalt())
        return user
    else:
        user = valid_users.get(username)
        if user.password == old_passw:
            user.password = new_passw
            user.passphrase = hashpw(new_pass.encode(),gensalt())
            return user
        else:
            return {"message": "invalid user"}

delete_pending_users()可以在不传递任何accounts参数的情况下执行,因为accounts默认总是一个空列表(List)。同样,change_password()也可以在不需要传递任何old_passwdnew_passw的情况下继续其过程,因为它们都默认为空字符串(str)。hashpw()是一个bcrypt实用函数,它从自动生成的生成散列密码。

可选参数

如果服务的路径和/或查询参数不是必须由用户提供的,意味着 API 事务可以在请求事务中包含或不包含它们的情况下进行,那么我们将它们设置为可选的。为了声明一个可选参数,我们需要从typing模块导入Optional类型,然后使用它来设置参数。它应该使用括号([])包装参数的预期数据类型,如果需要,可以具有任何默认值。将Optional参数分配给None值表示服务允许从参数传递中排除它,但它将保留None值。以下服务展示了可选参数的使用:

from typing import Optional, List, Dict
@app.post("/ch01/login/username/unlock")
def unlock_username(id: Optional[UUID] = None):
    if id == None:
        return {"message": "token needed"}
    else:
        for key, val in valid_users.items():
            if val.id == id:
                return {"username": val.username}
        return {"message": "user does not exist"}
@app.post("/ch01/login/password/unlock")
def unlock_password(username: Optional[str] = None, 
                    id: Optional[UUID] = None):
    if username == None:
        return {"message": "username is required"}
    elif valid_users.get(username) == None:
        return {"message": "user does not exist"}
    else:
        if id == None:
            return {"message": "token needed"}
        else:
            user = valid_users.get(username)
            if user.id == id:
                return {"password": user.password}
            else:
                return {"message": "invalid token"}

在线学术讨论论坛 应用中,我们有一些服务,如前述的 unlock_username()unlock_password() 服务,它们将所有参数声明为 可选。只是不要忘记在处理这些类型的参数时在你的实现中应用异常处理或防御性验证,以避免 HTTP 状态 500 (内部服务器错误)。

重要提示

FastAPI 框架不允许你直接将 None 值分配给参数以声明一个 可选 参数。尽管在旧的 Python 行为中这是允许的,但在当前的 Python 版本中,出于内置类型检查和模型验证的目的,不再推荐这样做。

混合所有类型的参数

如果你计划实现一个声明可选、必需和默认查询和路径参数的 API 服务方法,你可以这样做,因为框架支持它,但出于一些标准和规则,需要谨慎处理:

@app.patch("/ch01/account/profile/update/names/{username}")
def update_profile_names(id: UUID, username: str = '' , 
           new_names: Optional[Dict[str, str]] = None):
    if valid_users.get(username) == None:
        return {"message": "user does not exist"}
    elif new_names == None:
        return {"message": "new names are required"}
    else:
        user = valid_users.get(username)
        if user.id == id:
            profile = valid_profiles[username]
            profile.firstname = new_names['fname']
            profile.lastname = new_names['lname']
            profile.middle_initial = new_names['mi']
            valid_profiles[username] = profile
            return {"message": "successfully updated"}
        else:
            return {"message": "user does not exist"}

前述 update_profile_names() 服务更新的版本声明了一个 username 路径参数,一个 UUID id 查询参数,以及一个可选的 Dict[str, str] 类型。在混合参数类型的情况下,所有必需参数应首先声明,然后是默认参数,最后在参数列表中是可选类型。忽略此排序规则将生成一个 编译器错误

请求体

请求体 是通过 POSTPUTDELETEPATCH HTTP 方法操作从客户端传输到服务器的字节数据体。在 FastAPI 中,一个服务必须声明一个模型对象来表示和捕获这个请求体,以便进行进一步的处理。

要实现一个用于 请求体 的模型类,你应该首先从 pydantic 模块导入 BaseModel 类。然后,创建它的子类以利用路径操作在捕获请求体时所需的所有属性和行为。以下是我们应用程序使用的一些数据模型:

from pydantic import BaseModel
class User(BaseModel):
    username: str
    password: str
class UserProfile(BaseModel):
    firstname: str
    lastname: str
    middle_initial: str
    age: Optional[int] = 0
    salary: Optional[int] = 0
    birthday: date
    user_type: UserType

模型类的属性必须通过应用 类型提示 和利用在参数声明中使用的常见和复杂数据类型来显式声明。这些属性也可以设置为必需、默认和可选,就像在参数中一样。

此外,pydantic 模块允许创建嵌套模型,甚至是深度嵌套的模型。这里展示了一个这样的示例:

class ForumPost(BaseModel):
    id: UUID
    topic: Optional[str] = None
    message: str
    post_type: PostType
    date_posted: datetime
    username: str
class ForumDiscussion(BaseModel):
    id: UUID
    main_post: ForumPost
    replies: Optional[List[ForumPost]] = None
    author: UserProfile

如前述代码所示,我们有一个 ForumPost 模型,它有一个 PostType 模型属性,以及 ForumDiscussion,它有一个 List 属性为 ForumPost,一个 ForumPost 模型属性和一个 UserProfile 属性。这种模型蓝图被称为 嵌套模型方法

在创建这些模型类之后,你现在可以将这些对象注入到旨在从客户端捕获 请求体 的服务中。以下服务利用我们的 UserUserProfile 模型类来管理请求体:

@app.post("/ch01/login/validate", response_model=ValidUser)
def approve_user(user: User):
    if not valid_users.get(user.username) == None:
        return ValidUser(id=None, username = None, 
             password = None, passphrase = None)
    else:
        valid_user = ValidUser(id=uuid1(), 
             username= user.username, 
             password  = user.password, 
             passphrase = hashpw(user.password.encode(),
                          gensalt()))
        valid_users[user.username] = valid_user
        del pending_users[user.username]
        return valid_user
@app.put("/ch01/account/profile/update/{username}")
def update_profile(username: str, id: UUID, 
                   new_profile: UserProfile):
    if valid_users.get(username) == None:
        return {"message": "user does not exist"}
    else:
        user = valid_users.get(username)
        if user.id == id:
            valid_profiles[username] = new_profile
            return {"message": "successfully updated"}
        else:
            return {"message": "user does not exist"}

根据 API 的规范,模型可以在服务方法中声明为 必需的、带有 默认实例值可选的。在 approve_user() 服务中缺少或错误的详细信息,如 invalid passwordNone 值,将触发 状态码 500 (内部服务器错误)。FastAPI 如何处理异常将是 第二章 (Exploring the Core Features) 讨论的一部分。

重要提示

在处理 BaseModel 类类型时,我们需要强调两个基本点。首先,pydantic 模块有一个内置的 JSON 编码器,它将 JSON 格式的请求体转换为 BaseModel 对象。因此,无需创建自定义转换器来将请求体映射到 BaseModel 模型。其次,要实例化 BaseModel 类,必须通过构造函数的命名参数立即初始化其所有必需的属性。

请求头

在请求-响应事务中,不仅可以通过 REST API 方法访问参数,还可以访问描述请求来源客户端上下文的信息。一些常见的请求头,如 User-AgentHostAcceptAccept-LanguageAccept-EncodingRefererConnection 通常在请求事务中与请求参数和值一起出现。

要访问请求头,首先从 fastapi 模块导入 Header 函数。然后,在方法服务中声明与头名称相同的变量为 str 类型,并通过调用 Header(None) 函数初始化变量。None 参数使 Header() 函数能够可选地声明变量,这是一种最佳实践。对于带有连字符的请求头名称,连字符 (-) 应转换为下划线 (_);否则,Python 编译器将标记语法错误消息。在请求头处理期间,将下划线 (_) 转换为连字符 (-) 是 Header() 函数的任务。

我们的在线学术讨论论坛应用程序有一个 verify_headers() 服务,用于检索验证客户端访问应用程序所需的核心请求头:

from fastapi import Header
@app.get("/ch01/headers/verify")
def verify_headers(host: Optional[str] = Header(None), 
                   accept: Optional[str] = Header(None),
                   accept_language: 
                       Optional[str] = Header(None),
                   accept_encoding: 
                       Optional[str] = Header(None),
                   user_agent: 
                       Optional[str] = Header(None)):
    request_headers["Host"] = host
    request_headers["Accept"] = accept
    request_headers["Accept-Language"] = accept_language
    request_headers["Accept-Encoding"] = accept_encoding
    request_headers["User-Agent"] = user_agent
    return request_headers

重要提示

在声明中省略 Header() 函数调用将让 FastAPI 将变量视为 查询参数。同时也要注意本地参数名称的拼写,因为它们本身就是请求头名称,除了下划线之外。

响应数据

FastAPI 中的所有 API 服务都应该返回 JSON 数据,否则将无效,默认可能返回 None。这些响应可以使用 dictBaseModelJSONResponse 对象形成。关于 JSONResponse 的讨论将在后续章节中讨论。

pydantic 模块的内置 JSON 转换器将管理将这些自定义响应转换为 JSON 对象,因此无需创建自定义 JSON 编码器:

@app.post("/ch01/discussion/posts/add/{username}")
def post_discussion(username: str, post: Post, 
                    post_type: PostType):
    if valid_users.get(username) == None:
        return {"message": "user does not exist"}
    elif not (discussion_posts.get(id) == None):
        return {"message": "post already exists"}
    else:
        forum_post = ForumPost(id=uuid1(), 
          topic=post.topic, message=post.message, 
          post_type=post_type, 
          date_posted=post.date_posted, username=username)
        user = valid_profiles[username]
        forum = ForumDiscussion(id=uuid1(), 
         main_post=forum_post, author=user, replies=list())
        discussion_posts[forum.id] = forum
        return forum

前面的post_discussion()服务返回两个不同的硬编码的dict对象,以message作为键和一个实例化的ForumDiscussion模型。

另一方面,这个框架允许我们指定服务方法的返回类型。返回类型的设置发生在任何@app路径操作的response_model属性中。不幸的是,该参数只识别BaseModel类类型:

@app.post("/ch01/login/validate", response_model=ValidUser)
def approve_user(user: User):

    if not valid_users.get(user.username) == None:
        return ValidUser(id=None, username = None, 
                   password = None, passphrase = None)
    else:
        valid_user = ValidUser(id=uuid1(), 
         username= user.username, password = user.password,
          passphrase = hashpw(user.password.encode(),
                 gensalt()))
        valid_users[user.username] = valid_user
        del pending_users[user.username]
        return valid_user

前面的approve_user()服务指定了 API 方法的必需返回值,即ValidUser

现在,让我们探索 FastAPI 如何处理表单参数。

处理表单参数

当 API 方法设计为处理 Web 表单时,相关的服务需要检索表单参数而不是请求体,因为这种表单数据通常编码为application/x-www-form-urlencoded媒体类型。这些表单参数通常是string类型,但pydantic模块的 JSON 编码器可以将每个参数值转换为相应的有效类型。

所有表单参数变量都可以声明为必需的,带有默认值,或者使用我们之前使用的相同 Python 类型来声明为可选的。然后,fastapi模块有一个Form函数,需要在声明时导入以初始化这些表单参数变量。要将这些表单参数设置为必需的Form()函数必须包含省略号()参数,因此调用它为Form(…)

from fastapi import FastAPI, Form
@app.post("/ch01/account/profile/add", 
                        response_model=UserProfile)
def add_profile(uname: str, 
                fname: str = Form(...), 
                lname: str = Form(...),
                mid_init: str = Form(...),
                user_age: int = Form(...),
                sal: float = Form(...),
                bday: str = Form(...),
                utype: UserType = Form(...)):
    if valid_users.get(uname) == None:
        return UserProfile(firstname=None, lastname=None, 
              middle_initial=None, age=None, 
              birthday=None, salary=None, user_type=None)
    else:
        profile = UserProfile(firstname=fname, 
             lastname=lname, middle_initial=mid_init, 
             age=user_age, birthday=datetime.strptime(bday,
                '%m/%d/%Y'), salary=sal, user_type=utype)
        valid_profiles[uname] = profile
        return profile

前面的add_profile()服务展示了如何调用Form(…)函数在参数声明期间返回一个Form对象。

重要提示

如果没有安装python-multipart模块,表单处理服务将无法工作。

有时,我们需要浏览器 cookies 来为我们的应用程序建立身份,为每个用户交易在浏览器中留下痕迹,或者为了某种目的存储产品信息。如果 FastAPI 可以管理表单数据,它也可以对 cookies 做同样的事情。

管理 cookies

cookie是存储在浏览器中用于追求某些目的的信息片段,例如登录用户授权、网络代理响应生成和会话处理相关任务。一个 cookie 始终是一个键值对,都是字符串类型。

FastAPI 允许服务通过其fastapi模块的Response库类单独创建 cookies。要使用它,它需要作为服务的第一个局部参数出现,但我们不允许应用程序或客户端向它传递参数。使用依赖注入原则,框架将为服务提供Response实例,而不是应用程序。当服务有其他参数要声明时,附加声明应紧接在Response参数声明之后发生。

Response 对象有一个 set_cookie() 方法,其中包含两个必需的命名参数:key,用于设置 cookie 名称,和 value,用于存储 cookie 值。此方法仅生成一个 cookie 并随后存储在浏览器中:

@app.post("/ch01/login/rememberme/create/")
def create_cookies(resp: Response, id: UUID, 
                   username: str = ''):
    resp.set_cookie(key="userkey", value=username)
    resp.set_cookie(key="identity", value=str(id))
    return {"message": "remember-me tokens created"}

之前的 create_cookies() 方法向我们展示了如何为 remember-me 授权创建 remember-me tokens,例如 userkeyidentity,用于我们的 在线学术讨论论坛 项目。

要检索这些 cookies,在服务方法中声明与 cookies 同名的本地参数为 str 类型,因为 cookie 值始终是字符串。与 HeaderForm 类似,fastapi 模块还提供了一个 Cookie 函数,用于初始化每个声明的 cookie 参数变量。Cookie() 函数应始终带有 None 参数,以可选地设置参数,确保当请求事务中不存在头信息时,API 方法能够无问题执行。以下 access_cookie() 服务检索了之前服务创建的所有 remember-me 授权 cookies:

@app.get("/ch01/login/cookies")
def access_cookie(userkey: Optional[str] = Cookie(None), 
           identity: Optional[str] = Cookie(None)):
    cookies["userkey"] = userkey
    cookies["identity"] = identity
    return cookies

摘要

本章对于熟悉 FastAPI 并理解其基本组件至关重要。从本章中我们可以获得的概念可以衡量我们需要投入多少调整和努力来翻译或重写一些现有的应用程序以适应 FastAPI。了解其基础知识将帮助我们学习如何安装其模块、结构化项目目录,以及学习构建简单企业级应用程序所需的核心库类和函数。

在我们的 在线学术讨论论坛 应用程序的帮助下,本章向我们展示了如何使用 FastAPI 模块类和 Python def 函数构建与 HTTP 方法相关的不同 REST APIs。从那里,我们学习了如何使用 API 方法的本地参数捕获传入的请求数据和头信息,以及这些 API 方法应该如何向客户端返回响应。通过本章,我们看到了 FastAPI 如何轻松地捕获来自任何 UI 模板 <form></form> 的表单数据,这是使用 Form 函数实现的。除了 Form 函数之外,FastAPI 模块还有一个 Cookie 函数,帮助我们创建和检索浏览器中的 cookies,以及 Header 用于检索传入请求事务的请求头部分。

总体而言,本章为我们准备了高级讨论,这些讨论将集中在 FastAPI 的其他功能上,这些功能可以帮助我们将简单应用程序升级为完整的应用程序。下一章将涵盖这些核心功能,这些功能将为我们的应用程序提供所需响应编码器、生成器、异常处理程序、中间件以及其他与异步事务相关的组件。

第二章:探索核心功能

在上一章中,我们发现使用FastAPI框架安装和开始开发 REST API 非常容易。使用 FastAPI 处理请求、cookies 和表单数据既快又简单,构建不同的 HTTP 路径操作也是如此。

为了进一步了解框架的功能,本章将指导我们如何通过添加一些必要的 FastAPI 功能来升级我们的 REST API 实现。这包括一些可以帮助最小化未检查异常的处理程序,可以直接管理端点响应的 JSON 编码器,可以创建审计跟踪和日志的后台作业,以及使用uvicorn的主线程异步运行某些 API 方法的多个线程。此外,本章还将解决大型企业项目中的源文件、模块和包管理问题。本章将使用并剖析一个智能旅游系统原型,以帮助阐述和举例说明 FastAPI 的核心模块。

基于上述功能,本章将讨论以下主要概念,这些概念可以帮助我们扩展对该框架的了解:

  • 结构化和组织大型项目

  • 管理与 API 相关的异常

  • 将对象转换为 JSON 兼容类型

  • 管理 API 响应

  • 创建后台进程

  • 使用异步路径操作

  • 将中间件应用于过滤路径操作

技术要求

本章将实现一个智能旅游系统的原型,旨在提供旅游景点的预订信息和预约服务。它可以提供用户详情、旅游景点详情和位置网格。同时,它还允许用户或游客对旅游进行评论并评分。该原型有一个管理员账户,用于添加和删除所有旅游详情、管理用户和提供一些列表。该应用程序目前不使用任何数据库管理系统,因此所有数据临时存储在 Python 集合中。代码已全部上传至github.com/PacktPublishing/Building-Python-Microservices-with-FastAPI/tree/main/ch02

结构化和组织大型项目

在 FastAPI 中,大型项目通过添加模块来组织和结构化,而不会破坏设置、配置和目的。项目在添加额外功能和需求的情况下,应始终保持灵活性和可扩展性。一个组件必须对应一个包,几个模块相当于 Flask 框架中的一个蓝图

在这个原型智能旅游系统中,应用程序有几个模块,如登录、管理、访问、目的地和反馈相关的功能。其中两个最重要的是访问模块,它管理所有用户的旅行预订,以及反馈模块,它允许客户在每一个目的地发布他们的反馈。由于它们提供了核心交易,这些模块应该与其他模块分开。图 2.1 展示了如何使用来分组实现并将一个模块与其他模块分开:

图 2.1 – FastAPI 项目结构

图 2.1 – FastAPI 项目结构

图 2.1 中的每个包都包含了实现 API 服务和一些依赖项的所有模块。所有上述模块现在都有自己的相应包,这使得测试、调试和扩展应用程序变得容易。将在接下来的章节中讨论测试 FastAPI 组件。

重要提示

在使用VS Code 编辑器Python 3.8进行开发时,FastAPI 不需要在 Python 包中添加__init__.py文件,这与 Flask 不同。在编译过程中,包内部生成的__pycache__文件夹包含被其他模块访问和使用的模块脚本的二进制文件。主文件夹也将成为一个包,因为它将拥有自己的__pycache__文件夹,与其他包一起。但是,在将应用程序部署到仓库时,我们必须排除__pycache__,因为它可能占用大量空间。

另一方面,主文件夹中剩下的核心组件包括后台任务自定义异常处理器中间件以及main.py文件。现在,让我们了解 FastAPI 如何在部署时将这些包捆绑成一个巨大的应用程序。

实现 API 服务

为了使这些模块包能够运行,main.py文件必须通过 FastAPI 实例调用并注册它们所有的 API 实现。每个包内部的脚本已经是微服务的 REST API 实现,只是它们是由APIRouter而不是FastAPI对象构建的。APIRouter也有相同的路径操作、查询和请求参数设置、表单数据处理、响应生成以及模型对象的参数注入。APIRouter缺少的是异常处理器、中间件声明和自定义的支持:

from fastapi import APIRouter
from login.user import Signup, User, Tourist, 
      pending_users, approved_users
router = APIRouter()
@router.get("/ch02/admin/tourists/list")
def list_all_tourists():
    return approved_users

这里list_all_tourists() API 方法操作是admin包中manager.py模块的一部分,由于项目结构使用APIRouter实现。该方法返回允许访问应用程序的游客记录列表,这只能由login包中的user.py模块提供。

导入模块组件

模块脚本可以使用 Python 的from…import语句与其他模块共享它们的容器BaseModel 和其他资源对象。Python 的from…import语句更好,因为它允许我们从模块中导入特定的组件,而不是包含不必要的组件:

from fastapi import APIRouter, status
from places.destination import Tour, TourBasicInfo, 
    TourInput, TourLocation, tours, tours_basic_info, 
    tours_locations
router = APIRouter()
@router.put("/ch02/admin/destination/update", 
            status_code=status.HTTP_202_ACCEPTED)
def update_tour_destination(tour: Tour):
    try:
        tid = tour.id
        tours[tid] = tour
        tour_basic_info = TourBasicInfo(id=tid, 
           name=tour.name, type=tour.type, 
           amenities=tour.amenities, ratings=tour.ratings)
        tour_location = TourLocation(id=tid, 
           name=tour.name, city=tour.city, 
           country=tour.country, location=tour.location )
        tours_basic_info[tid] = tour_basic_info
        tours_locations[tid] = tour_location
        return { "message" : "tour updated" }
    except:
        return { "message" : "tour does not exist" } 

在这里,如果不从places包中的destination.py导入TourTourBasicInfoTourLocation模型类,update_tour_destination()操作将无法工作。这展示了在大型企业级 Web 项目中结构化时模块之间的依赖关系。

当实现需要时,模块脚本也可以从主项目文件夹导入组件。一个这样的例子是从main.py文件中访问中间件异常处理器任务

重要提示

处理from…import语句时避免循环。a.pyb.py访问组件,而b.py则从a.py导入资源对象。FastAPI 不接受这种场景,并将发出错误消息。

实现新的 main.py 文件

从技术上讲,除非通过main.py文件将各自的router对象添加或注入到应用程序的核心中,否则框架不会识别项目包及其模块脚本。main.py,就像其他项目级脚本一样,使用FastAPI而不是APIRouter来创建和注册组件,以及包的模块。FastAPI 类有一个include_router()方法,它添加所有这些路由并将它们注入到框架中,使它们成为项目结构的一部分。除了注册路由外,此方法还可以向路由添加其他属性和组件,例如URL 前缀标签异常处理器依赖项状态码

from fastapi import FastAPI, Request
from admin import manager
from login import user
from feedback import post
from places import destination
from tourist import visit
app = FastAPI()
app.include_router(manager.router)
app.include_router(user.router)
app.include_router(destination.router)
app.include_router(visit.router)
app.include_router(
    post.router,
    prefix="/ch02/post"
)

此代码是智能旅游系统原型main.py的实现,负责在将模块脚本的不同包中的所有注册项添加到框架之前导入它们。使用以下命令运行应用程序:

uvicorn main:app –-reload

这将允许您通过http://localhost:8000/docs访问这些模块的所有 API。

当 API 服务在执行过程中遇到运行时问题时,应用程序会发生什么?除了应用 Python 的try-except块外,还有没有管理这些问题的方法?让我们进一步探讨实现具有异常处理机制的 API 服务。

管理 API 相关异常

FastAPI 框架有一个从其 Starlette 工具包派生的内置异常处理器,当在执行 REST API 操作期间遇到HTTPException时,它总是返回默认的 JSON 响应。例如,在http://localhost:8000/ch02/user/login访问 API 而没有提供usernamepassword时,我们将得到图 2.2中描述的默认 JSON 输出:

图 2.2 – 默认异常结果

图 2.2 – 默认异常结果

在一些罕见的情况下,框架有时会选择返回 HTTP 响应状态而不是默认的 JSON 内容。但开发者仍然可以选择覆盖这些默认处理程序,以便在特定异常原因发生时选择返回哪些响应。

让我们探索如何在我们的 API 实现中制定一种标准化且适当的方式来管理运行时错误。

单个状态码响应

管理应用程序异常处理机制的一种方法是在遇到异常或无异常时,通过应用 try-except 块来管理 API 的返回响应。在应用 try-block 之后,操作应触发单个 FastAPIAPIRouter 具有用于指示我们想要引发的状态码类型的 status_code 参数。

在 FastAPI 中,状态码是位于 status 模块中的整数常量。它还允许使用整数文字来表示所需的状态码,如果它们是有效的状态码数字。

重要提示

状态码是一个三位数,它表示 REST API 操作 HTTP 响应的原因、信息或状态。状态码范围 200 到 299 表示成功响应,300 到 399 与重定向相关,400-499 与客户端相关的问题相关,而 500 到 599 与服务器错误相关。

这种技术很少使用,因为在某些情况下,操作需要清楚地识别它遇到的每个异常,这只能通过返回 HTTPException 而不是包含在 JSON 对象中的自定义错误消息来完成:

from fastapi import APIRouter, status
@router.put("/ch02/admin/destination/update", 
              status_code=status.HTTP_202_ACCEPTED)
def update_tour_destination(tour: Tour):
    try:
        tid = tour.id
        tours[tid] = tour
        tour_basic_info = TourBasicInfo(id=tid, 
           name=tour.name, type=tour.type, 
           amenities=tour.amenities, ratings=tour.ratings)
        tour_location = TourLocation(id=tid, 
           name=tour.name, city=tour.city, 
           country=tour.country, location=tour.location )
        tours_basic_info[tid] = tour_basic_info
        tours_locations[tid] = tour_location
        return { "message" : "tour updated" }
    except:
        return { "message" : "tour does not exist" }
@router.get("/ch02/admin/destination/list", 
            status_code=200)
def list_all_tours():
    return tours

这里展示的 list_all_tours() 方法是那种应该返回状态码 200 的 REST API 服务类型——它仅通过渲染包含数据的 Python 集合就能给出无错误的结果。注意,分配给 GET 路径操作 status_code 参数的文本整数值 200SC 200 总是引发一个 OK 状态。另一方面,update_tour_destination() 方法展示了另一种通过使用 try-except 块来发出状态码的方法,其中两个块都返回自定义的 JSON 响应。无论哪种情况发生,它都会始终触发 SC 202,这可能不适用于某些 REST 实现。在导入 status 模块后,使用其 HTTP_202_ACCEPTED 常量来设置 status_code 参数的值。

多个状态码

如果我们需要 try-except 中的每个块返回它们各自的状态码,我们需要避免使用路径操作的 status_code 参数,而应使用 JSONResponseJSONResponse 是 FastAPI 类之一,用于向客户端渲染 JSON 响应。它被实例化,通过构造函数注入其 contentstatus_code 参数的值,并由路径操作返回。默认情况下,框架使用此 API 帮助路径操作以 JSON 类型渲染响应。其 content 参数应该是一个 JSON 类型的对象,而 status_code 参数可以是一个整数常量和一个有效的状态码数字,或者它可以是模块状态中的一个常量:

from fastapi.responses import JSONResponse
@router.post("/ch02/admin/destination/add")
add_tour_destination(input: TourInput):
    try:
        tid = uuid1()
        tour = Tour(id=tid, name=input.name,
           city=input.city, country=input.country, 
           type=input.type, location=input.location,
           amenities=input.amenities, feedbacks=list(), 
           ratings=0.0, visits=0, isBooked=False)
        tour_basic_info = TourBasicInfo(id=tid, 
           name=input.name, type=input.type, 
           amenities=input.amenities, ratings=0.0)
        tour_location = TourLocation(id=tid, 
           name=input.name, city=input.city, 
           country=input.country, location=input.location )
        tours[tid] = tour
        tours_basic_info[tid] = tour_basic_info
        tours_locations[tid] = tour_location
        tour_json = jsonable_encoder(tour)
        return JSONResponse(content=tour_json, 
            status_code=status.HTTP_201_CREATED)
    except:
        return JSONResponse(
         content={"message" : "invalid tour"}, 
         status_code=status.HTTP_500_INTERNAL_SERVER_ERROR)

这里的 add_tour_destination() 操作包含一个 try-except 块,其 try 块返回旅游详情和 SC 201,而其 catch 块返回一个包含服务器错误 SC 500 的 JSON 类型错误信息。

抛出 HTTPException

另一种管理可能错误的方法是让 REST API 抛出 HTTPException 对象。HTTPException 是一个 FastAPI 类,它有必需的构造参数:detail,它需要一个 str 类型的错误信息,以及 status_code,它需要一个有效的整数值。detail 部分被转换为 JSON 类型,并在操作抛出 HTTPException 实例后作为响应返回给用户。

要抛出 HTTPException,使用任何形式的 if 语句进行验证过程比使用 try-except 块更合适,因为需要在抛出使用 raise 语句的 HTTPException 对象之前识别错误的起因。一旦执行 raise,整个操作将停止,并将指定的状态码的 HTTP 错误信息以 JSON 类型发送给客户端:

from fastapi import APIRouter, HTTPException, status
@router.post("/ch02/tourist/tour/booking/add")
def create_booking(tour: TourBasicInfo, touristId: UUID):
    if approved_users.get(touristId) == None:
         raise HTTPException(status_code=500,
            detail="details are missing")
    booking = Booking(id=uuid1(), destination=tour,
      booking_date=datetime.now(), tourist_id=touristId)
    approved_users[touristId].tours.append(tour)
    approved_users[touristId].booked += 1
    tours[tour.id].isBooked = True
    tours[tour.id].visits += 1
    return booking

这里的 create_booking() 操作模拟了一个为 旅游者 账户的预订过程,但在程序开始之前,它首先检查 旅游者 是否仍然是一个有效的用户;如果不是,它将引发 HTTPException 异常,以停止所有操作并返回一个错误信息。

自定义异常

还可以创建一个用户定义的 HTTPException 对象来处理特定业务问题。这个自定义异常需要一个自定义处理程序来管理它在操作引发时对客户端的响应。这些自定义组件应该在整个项目结构中的所有 API 方法中可用;因此,它们必须在项目文件夹级别实现。

在我们的应用程序中,handler_exceptions.py 文件中创建了两个自定义异常,分别是 PostFeedbackExceptionPostRatingFeedback 异常,它们用于处理与特定旅游中发布反馈和评分相关的问题:

from fastapi import FastAPI, Request, status, HTTPException
class PostFeedbackException(HTTPException):
    def __init__(self, detail: str, status_code: int):
        self.status_code = status_code
        self.detail = detail

class PostRatingException(HTTPException):
    def __init__(self, detail: str, status_code: int):
        self.status_code = status_code
        self.detail = detail

一个有效的 FastAPI 异常是继承自 HTTPException 对象的子类,继承了基本属性,即 status_codedetail 属性。在路径操作引发异常之前,我们需要为这些属性提供值。在创建这些自定义异常之后,实现一个特定的处理器并将其映射到异常。

main.py 中的 FastAPI @app 装饰器有一个 exception_handler() 方法,用于定义自定义处理器并将其映射到适当的自定义异常。处理器只是一个具有两个局部参数的 Python 函数,即 Request 和它管理的 自定义异常Request 对象的目的是在处理器期望任何此类请求数据的情况下,从路径操作中检索 cookies、有效载荷、headers、查询参数和路径参数。现在,一旦引发自定义异常,处理器将被设置为生成一个包含由引发异常的路径操作提供的 detailstatus_code 属性的 JSON 类型的响应给客户端:

from fastapi.responses import JSONResponse
from fastapi import FastAPI, Request, status, HTTPException
@app.exception_handler(PostFeedbackException)
def feedback_exception_handler(req: Request, 
          ex: PostFeedbackException):
    return JSONResponse(
        status_code=ex.status_code,
        content={"message": f"error: {ex.detail}"}
        )

@app.exception_handler(PostRatingException)
def rating_exception_handler(req: Request, 
             ex: PostRatingException):
     return JSONResponse(
        status_code=ex.status_code,
        content={"message": f"error: {ex.detail}"}
        )

post.py 中的操作引发 PostFeedbackException 时,这里提供的 feedback_exception_handler() 将触发其执行以生成一个响应,可以提供有关导致反馈问题的详细信息。对于 PostRatingException 和其 rating_exception_handler() 也会发生同样的事情:

from handlers import PostRatingException,
                         PostFeedbackException

@router.post("/feedback/add")
def post_tourist_feedback(touristId: UUID, tid: UUID, 
      post: Post, bg_task: BackgroundTasks):
    if approved_users.get(touristId) == None and 
          tours.get(tid) == None:
        raise PostFeedbackException(detail='tourist and 
                tour details invalid', status_code=403)
    assessId = uuid1()
    assessment = Assessment(id=assessId, post=post, 
          tour_id= tid, tourist_id=touristId) 
    feedback_tour[assessId] = assessment
    tours[tid].ratings = (tours[tid].ratings + 
                            post.rating)/2
    bg_task.add_task(log_post_transaction, 
           str(touristId), message="post_tourist_feedback")
    assess_json = jsonable_encoder(assessment)
    return JSONResponse(content=assess_json, 
                         status_code=200)
@router.post("/feedback/update/rating")
def update_tour_rating(assessId: UUID, 
               new_rating: StarRating):
    if feedback_tour.get(assessId) == None:
        raise PostRatingException(
         detail='tour assessment invalid', status_code=403)
    tid = feedback_tour[assessId].tour_id
    tours[tid].ratings = (tours[tid].ratings + 
                            new_rating)/2
    tour_json = jsonable_encoder(tours[tid])
    return JSONResponse(content=tour_json, status_code=200)

post_tourist_feedback()update_tour_rating() 这里的 API 操作将分别引发 PostFeedbackExceptionPostRatingException 自定义异常,从而触发其处理器的执行。构造函数中注入的 detailstatus_code 值传递给处理器以创建响应。

默认处理器的覆盖

要覆盖您应用程序的异常处理机制的最佳方式是替换 FastAPI 框架的全局异常处理器,该处理器管理其核心 Starlette 的 HTTPException 和由 raise 从 JSON 类型到纯文本触发的 RequestValidationError。我们可以为上述所有核心异常创建自定义处理器,以执行格式转换。以下 main.py 的代码片段显示了这些类型的自定义处理器:

from fastapi.responses import PlainTextResponse 
from starlette.exceptions import HTTPException as 
         GlobalStarletteHTTPException
from fastapi.exceptions import RequestValidationError
from handler_exceptions import PostFeedbackException, 
        PostRatingException
@app.exception_handler(GlobalStarletteHTTPException)
def global_exception_handler(req: Request, 
                 ex: str
    return PlainTextResponse(f"Error message: 
       {ex}", status_code=ex.status_code)
@app.exception_handler(RequestValidationError)
def validationerror_exception_handler(req: Request, 
                 ex: str
    return PlainTextResponse(f"Error message: 
       {str(ex)}", status_code=400)

global_exception_handler()validationerror_exception_handler() 处理器都实现了将框架的 JSON 类型异常响应更改为 PlainTextResponse。一个别名 GlobalStarletteHTTPException 被分配给 Starlette 的 HTTPException 类,以区分我们之前用于构建自定义异常的 FastAPI 的 HTTPException。另一方面,PostFeedbackExceptionPostRatingException 都在 handler_exceptions.py 模块中实现。

JSON 对象遍布 FastAPI 框架的 REST API 实现中,从传入的请求到发出的响应。然而,如果涉及过程中的 JSON 数据不是 FastAPI 兼容的 JSON 类型怎么办?以下讨论将更详细地阐述这类对象。

将对象转换为与 JSON 兼容的类型

对于 FastAPI 来说,处理像dictlistBaseModel对象这样的与 JSON 兼容的类型更容易,因为框架可以使用其默认的 JSON 编辑器轻松地将它们转换为 JSON。然而,在处理 BaseModel、数据模型或包含数据的 JSON 对象时,可能会引发运行时异常。许多原因之一是这些数据对象具有 JSON 规则不支持的特征,例如 UUID 和非内置日期类型。无论如何,使用框架的模块类,这些对象仍然可以通过将它们转换为与 JSON 兼容的类型来被利用。

当涉及到直接处理 API 操作响应时,FastAPI 有一个内置方法可以将典型模型对象编码为与 JSON 兼容的类型,在将它们持久化到任何数据存储或传递给JSONResponsedetail参数之前。这个方法,jsonable_encoder(),返回一个包含所有键和值的dict类型,这些键和值与 JSON 兼容:

from fastapi.encoders import jsonable_encoder
from fastapi.responses import JSONResponse
class Tourist(BaseModel):
    id: UUID
    login: User
    date_signed: datetime
    booked: int
    tours: List[TourBasicInfo]

@router.post("/ch02/user/signup/")
async def signup(signup: Signup):
    try:
        userid = uuid1()
        login = User(id=userid, username=signup.username, 
               password=signup.password)
        tourist = Tourist(id=userid, login=login, 
          date_signed=datetime.now(), booked=0, 
          tours=list() )
        tourist_json = jsonable_encoder(tourist)
        pending_users[userid] = tourist_json
        return JSONResponse(content=tourist_json, 
            status_code=status.HTTP_201_CREATED)
    except:
        return JSONResponse(content={"message": 
         "invalid operation"}, 
         status_code=status.HTTP_500_INTERNAL_SERVER_ERROR)

我们的应用程序有一个POST操作,signup(),如这里所示,它捕获由管理员批准的新创建用户的个人资料。如果你观察Tourist模型类,它有一个date_signed属性,声明为datettime,而时间类型并不总是与 JSON 兼容。在 FastAPI 相关操作中具有非 JSON 兼容组件的模型对象可能会导致严重的异常。为了避免这些 Pydantic 验证问题,始终建议使用jsonable_encoder()来管理将我们模型对象的所有属性转换为 JSON 类型的转换。

重要提示

可以使用带有dumps()loads()实用方法的json模块来代替jsonable_encoder(),但应该创建一个自定义的 JSON 编码器来成功地将UUID类型、格式化的date类型和其他复杂属性类型映射到str

第九章利用其他高级功能,将讨论其他可以比json模块更快地编码和解码 JSON 响应的 JSON 编码器。

管理 API 响应

使用 jsonable_encoder() 可以帮助 API 方法不仅解决数据持久性问题,还可以确保其响应的完整性和正确性。在 signup() 服务方法中,JSONResponse 返回编码后的 Tourist 模型而不是原始对象,以确保客户端始终收到 JSON 响应。除了抛出状态码和提供错误信息外,JSONResponse 还可以在处理 API 对客户端的响应时做一些技巧。尽管在许多情况下是可选的,但在生成响应时应用编码方法以避免运行时错误是推荐的:

from fastapi.encoders import jsonable_encoder
from fastapi.responses import JSONResponse
@router.get("/ch02/destinations/details/{id}")
def check_tour_profile(id: UUID):
    tour_info_json = jsonable_encoder(tours[id])
    return JSONResponse(content=tour_info_json)

check_tour_profile() 这里使用 JSONResponse 确保其响应是 JSON 兼容的,并从管理其异常的目的进行获取。此外,它还可以用来返回与 JSON 类型的响应一起的头信息:

@router.get("/ch02/destinations/list/all")
def list_tour_destinations():
    tours_json = jsonable_encoder(tours)
    resp_headers = {'X-Access-Tours': 'Try Us', 
       'X-Contact-Details':'1-900-888-TOLL', 
       'Set-Cookie':'AppName=ITS; Max-Age=3600; Version=1'}
    return JSONResponse(content=tours_json, 
          headers=resp_headers)

在这里,list_tour_destinations() 应用程序返回三个 Cookie:AppNameMax-AgeVersion,以及两个用户定义的响应头。以 X- 开头的头是自定义头。除了 JSONResponse 之外,fastapi 模块还有一个 Response 类可以创建响应头:

from fastapi import APIRouter, Response
@router.get("/ch02/destinations/mostbooked")
def check_recommended_tour(resp: Response):
    resp.headers['X-Access-Tours'] = 'TryUs'
    resp.headers['X-Contact-Details'] = '1900888TOLL'
    resp.headers['Content-Language'] = 'en-US'
    ranked_desc_rates = sort_orders = sorted(tours.items(),
         key=lambda x: x[1].ratings, reverse=True)
    return ranked_desc_rates;

我们的原型的 check_recommend_tour() 使用 Response 创建两个自定义响应头和一个已知的 str 类型,并存储在浏览器中,出于许多原因,例如为应用程序创建身份,留下用户轨迹,丢弃与广告相关的数据,或者当 API 遇到错误时向浏览器留下错误信息:

@router.get("/ch02/tourist/tour/booked")
def show_booked_tours(touristId: UUID):
    if approved_users.get(touristId) == None:
         raise HTTPException(
         status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 
         detail="details are missing", 
         headers={"X-InputError":"missing tourist ID"})
    return approved_users[touristId].tours

如此处的 show_booked_tours() 服务方法中所示,HTTPException 不仅包含状态码和错误信息,还包含一些头信息,以防操作需要在抛出时向浏览器留下一些错误信息。

让我们探索 FastAPI 创建和管理在后台使用一些服务器线程运行的交易的能力。

创建后台进程

FastAPI 框架还能够作为 API 服务执行的一部分运行后台作业。它甚至可以在不干扰主服务执行的情况下几乎同时运行多个作业。负责此功能的类是 BackgroundTasks,它是 fastapi 模块的一部分。通常,我们在 API 服务方法的参数列表末尾声明此内容,以便框架注入 BackgroundTask 实例。

在我们的应用程序中,任务是创建所有 API 服务执行的审计日志并将它们存储在 audit_log.txt 文件中。这个操作是主项目文件夹中的 background.py 脚本的一部分,代码如下所示:

from datetime import datetime
def audit_log_transaction(touristId: str, message=""):
    with open("audit_log.txt", mode="a") as logfile:
        content = f"tourist {touristId} executed {message} 
            at {datetime.now()}"
        logfile.write(content)

在这里,必须使用 BackgroundTasksadd_task() 方法将 audit_log_transaction() 注入到应用程序中,使其成为一个稍后由框架执行的背景进程:

from fastapi import APIRouter, status, BackgroundTasks
@router.post("/ch02/user/login/")
async def login(login: User, bg_task:BackgroundTasks):
    try:
        signup_json = 
           jsonable_encoder(approved_users[login.id]) 
        bg_task.add_task(audit_log_transaction,
            touristId=str(login.id), message="login")
        return JSONResponse(content=signup_json, 
            status_code=status.HTTP_200_OK)
    except:
        return JSONResponse(
         content={"message": "invalid operation"}, 
         status_code=status.HTTP_500_INTERNAL_SERVER_ERROR)

@router.get("/ch02/user/login/{username}/{password}")
async def login(username:str, password: str, 
                    bg_task:BackgroundTasks):
     tourist_list = [ tourist for tourist in 
        approved_users.values() 
          if tourist['login']['username'] == username and 
              tourist['login']['password'] == password] 
     if len(tourist_list) == 0 or tourist_list == None:
        return JSONResponse(
           content={"message": "invalid operation"}, 
           status_code=status.HTTP_403_FORBIDDEN)
     else:
        tourist = tourist_list[0]
        tour_json = jsonable_encoder(tourist)
        bg_task.add_task(audit_log_transaction, 
          touristId=str(tourist['login']['id']), message="login")
        return JSONResponse(content=tour_json, 
            status_code=status.HTTP_200_OK)

login() 服务方法只是我们应用程序中记录其详细信息的众多服务之一。它使用 bg_task 对象将 audit_log_transaction() 添加到框架中以便稍后处理。日志记录、SMTP-/FTP-相关需求、事件以及一些数据库相关的触发器是后台作业的最佳候选。

重要提示

尽管后台任务执行时间可能很长,但客户端总是会从 REST API 方法中获取其响应。后台任务是为了处理时间足够长的过程,如果将其包含在 API 操作中可能会导致性能下降。

使用异步路径操作

当谈到提高性能时,FastAPI 是一个异步框架,它使用 Python 的 async 来定义服务的 func 签名:

@router.get("/feedback/list")
async def show_tourist_post(touristId: UUID):
    tourist_posts = [assess for assess in feedback_tour.values() 
            if assess.tourist_id == touristId]
    tourist_posts_json = jsonable_encoder(tourist_posts) 
    return JSONResponse(content=tourist_posts_json,
                   status_code=200)

我们的应用程序有一个 show_tourist_post() 服务,可以检索某个 touristId 关于他们所经历的度假旅游发布的所有反馈。无论该服务需要多长时间,应用程序都不会受到影响,因为它的执行将与 main 线程同时进行。

重要提示

feedback APIRouter 使用在 main.pyinclude_router() 注册中指定的 /ch02/post 前缀。因此,要运行 show_tourist_post(),URL 应该是 http://localhost:8000/ch02/post

异步 API 端点可以调用同步和异步的 Python 函数,这些函数可以是 DAO(数据访问对象)、原生服务或实用工具。由于 FastAPI 也遵循 Async/Await 设计模式,异步端点可以使用 await 关键字调用异步非 API 操作,这将暂停 API 操作,直到非 API 事务完成处理承诺:

from utility import check_post_owner
@router.delete("/feedback/delete")
async def delete_tourist_feedback(assessId: UUID, 
              touristId: UUID ):
    if approved_users.get(touristId) == None and 
            feedback_tour.get(assessId):
        raise PostFeedbackException(detail='tourist and 
              tour details invalid', status_code=403)    post_delete = [access for access in feedback_tour.values()
               if access.id == assessId]
    for key in post_delete:
        is_owner = await check_post_owner(feedback_tour, 
                       access.id, touristId)
        if is_owner:
            del feedback_tour[access.id]
    return JSONResponse(content={"message" : f"deleted
          posts of {touristId}"}, status_code=200)

这里的 delete_tourist_feedback() 是一个异步 REST API 端点,它从 utility.py 脚本中调用异步 Python 函数 check_post_owner()。为了两个组件进行握手,API 服务调用 check_post_owner(),使用 await 关键字让前者等待后者完成验证,并检索它可以从 await 获取的承诺。

重要提示

await 关键字只能与异步 REST API 和原生事务一起使用,不能与同步事务一起使用。

为了提高性能,你可以在运行服务器时通过包含 --workers 选项在 uvicorn 线程池中添加更多线程。在调用选项后指定你喜欢的线程数:

uvicorn main:app --workers 5 --reload

第八章创建协程、事件和消息驱动事务,将更详细地讨论 AsyncIO 平台和协程的使用。

现在,FastAPI 可以提供的最后一个、最重要的核心功能是中间件或“请求-响应过滤器”。

应用中间件以过滤路径操作

FastAPI 有一些固有的异步组件,其中之一就是中间件。它是一个异步函数,充当 REST API 服务的过滤器。它在到达 API 服务方法之前,从 cookie、头部、请求参数、查询参数、表单数据或请求体的认证细节中过滤出传入的请求以进行验证、认证、日志记录、后台处理或内容生成。同样,它还处理出站的响应体,以进行渲染更改、响应头更新和添加以及其他可能应用于响应的转换,在它到达客户端之前。中间件应在项目级别实现,甚至可以是main.py的一部分:

@app.middleware("http")
async def log_transaction_filter(request: Request, 
             call_next):
    start_time = datetime.now()
    method_name= request.method
    qp_map = request.query_parasms
    pp_map = request.path_params
    with open("request_log.txt", mode="a") as reqfile:
        content = f"method: {method_name}, query param: 
            {qp_map}, path params: {pp_map} received at 
            {datetime.now()}"
        reqfile.write(content)
    response = await call_next(request)
    process_time = datetime.now() - start_time
    response.headers["X-Time-Elapsed"] = str(process_time)
    return response

要实现中间件,首先,创建一个具有两个局部参数的async函数:第一个参数是Request,第二个参数是一个名为call_next()的函数,它将Request参数作为其参数以返回响应。然后,使用@app.middleware("http")装饰器将方法装饰,以将组件注入到框架中。

旅游应用程序在这里通过异步的add_transaction_filter()实现了一个中间件,它在执行特定 API 方法之前记录必要的请求数据,并通过添加一个响应头X-Time-Elapsed来修改其响应对象,该响应头携带了执行时间。

await call_next(request)的执行是中间件中最关键的部分,因为它明确控制了 REST API 服务的执行。这是组件中Request通过 API 执行进行处理的区域。同样,这也是Response通过隧道传输到客户端的区域。

除了日志记录外,中间件还可以用于实现单向或双向认证、检查用户角色和权限、全局异常处理以及其他在执行call_next()之前与过滤相关的操作。当涉及到控制出站的Response时,它可以用来修改响应的内容类型、删除一些现有的浏览器 cookie、修改响应细节和状态码、重定向以及其他与响应转换相关的交易。第九章利用其他高级功能,将讨论中间件类型、中间件链以及其他自定义中间件的方法,以帮助构建更好的微服务。

重要提示

FastAPI 框架有一些内置的中间件,可以注入到应用程序中,例如GzipMiddlewareServerErrorMiddlewareTrustedHostMiddlewareExceptionMiddlewareCORSMiddlewareSessionMiddlewareHTTPSRedirectionMiddleware

摘要

探索框架的核心细节总是有助于我们制定全面的计划和设计,以按照所需标准构建高质量的应用程序。我们了解到 FastAPI 将所有传入的表单数据、请求参数、查询参数、cookie、请求头和认证详情注入到 Request 对象中,而出去的 cookie、响应头和响应数据则由 Response 对象传递给客户端。在管理响应数据时,框架内置了一个 jsonable_encoder() 函数,可以将模型转换为 JSONResponse 对象渲染的 JSON 类型。FastAPI 的中间件是其一个强大功能,因为我们可以在它到达 API 执行之前和客户端接收它之前对其进行自定义。

管理异常始终是创建一个实用且可持续的微服务架构弹性和健康解决方案的第一步。FastAPI 拥有强大的默认 Starlette 全局异常处理程序和 Pydantic 模型验证器,它允许对异常处理进行定制,这在业务流程变得复杂时提供了所需的灵活性。

FastAPI 遵循 Python 的 AsyncIO 原则和标准来创建异步 REST 端点,这使得实现变得简单、方便且可靠。这种平台对于构建需要更多线程和异步事务的复杂架构非常有帮助。

本章是全面了解 FastAPI 如何管理其网络容器的原则和标准的一个巨大飞跃。本章中突出显示的功能为我们开启了一个新的知识层面,如果我们想利用 FastAPI 构建出色的微服务,就需要进一步探索。在下一章中,我们将讨论 FastAPI 依赖注入以及这种设计模式如何影响我们的 FastAPI 项目。

第三章:调查依赖注入

自从第一章以来,BaseModelRequestResponseBackgroundTasks 在其过程中应用 DI。应用 DI 证明实例化一些 FastAPI 类并不总是理想的方法,因为框架有一个内置的容器可以提供这些类的对象给 API 服务。这种对象管理方法使得 FastAPI 使用起来既简单又高效。

FastAPI 有一个容器,其中 DI 策略被应用于实例化模块类甚至函数。我们只需要将这些模块 API 指定和声明给服务、中间件、验证器、数据源和测试用例,因为其余的对象组装、管理和实例化现在由内置的容器负责。

本章将帮助您了解如何管理应用程序所需的对象,例如最小化某些实例并在它们之间创建松散的绑定。了解 DI 在 FastAPI 上的有效性是我们设计我们的微服务应用程序的第一步。我们的讨论将集中在以下方面:

  • 应用 控制反转IoC)和 DI

  • 探索依赖注入的方法

  • 基于依赖关系组织项目

  • 使用第三方容器

  • 实例的作用域

技术要求

本章使用一个名为 在线食谱系统 的软件原型,该系统管理、评估、评分和报告不同类型和来源的食谱。应用 DI 模式是这个项目的优先任务,因此请期待在开发策略和方法上的一些变化,例如添加 modelrepositoryservice 文件夹。这款软件是为想要分享他们专长的美食爱好者或厨师、寻找食谱进行实验的新手以及喜欢浏览不同食物菜单的客人准备的。这个开放式的应用程序目前还没有使用任何数据库管理系统,因此所有数据都暂时存储在 Python 容器中。代码已全部上传至 github.com/PacktPublishing/Building-Python-Microservices-with-FastAPI/tree/main/ch03

应用 IoC/DI

FastAPI 是一个支持 IoC 原则的框架,这意味着它有一个容器可以为应用程序实例化对象。在典型的编程场景中,我们实例化类以多种方式使用它们来构建运行中的应用程序。但是,使用 IoC,框架为应用程序实例化组件。图 3.1 展示了 IoC 原则的全貌及其一种形式,称为 DI。

图 3.1 – IoC 原则

图 3.1 – IoC 原则

对于 FastAPI 来说,依赖注入(DI)不仅是一个原则,也是一种将对象集成到组件中的机制,这有助于创建一个松散耦合高度内聚的软件结构。几乎所有的组件都可以成为 DI 的候选者,包括函数。但就目前而言,让我们专注于可调用组件,一旦它们被注入到 API 服务中,就会提供一些 JSON 对象——我们称之为依赖函数的可注入和可调用组件。

注入依赖函数

如以下代码所示,create_login()位于项目的/api/users.py模块中:

def create_login(id:UUID, username: str, password:str, 
                 type: UserType):
    account = {"id": id, "username": username, "password":
                 password, "type": type}
    return account

该函数需要idusernamepassword参数以及type来继续其过程并返回一个有效的 JSON account对象,这些参数是其派生出来的。依赖函数有时会使用一些底层公式、资源或复杂算法来推导其函数值,但就目前而言,我们将其用作数据占位符dict

对于依赖函数来说,常见的做法是将方法参数用作 REST API 接收到的请求的占位符。这些参数通过 DI 连接到 API 的方法参数列表,作为领域模型,对应于查询参数或请求体。fastapi模块中的Depends()函数在将注入项连接到本地参数之前执行注入。模块函数只能接受一个参数进行注入:

from fastapi import APIRouter, Depends
@router.get("/users/function/add")
def populate_user_accounts(
              user_account=Depends(create_login)):
    account_dict = jsonable_encoder(user_account)
    login = Login(**account_dict)
    login_details[login.id] = login
    return login

前面是一个来自我们在线食谱系统的代码片段,展示了Depends()如何将create_login()注入到框架的容器中,并获取其实例以用于将populate_user_accounts()服务连接起来。在语法上,注入过程只需要函数依赖项的名称,无需括号。再次强调,create_login()的目的在于捕获 API 服务的查询参数。jsonable_encoder()对于许多 API 来说非常有用,它可以将这些注入项转换为 JSON 兼容的类型,如dict,这对于实例化所需的数据模型生成响应至关重要。

重要提示

术语依赖项可以与注入项依赖项资源提供者组件互换使用。

注入一个可调用类

FastAPI 还允许将类注入到任何组件中,因为它们也可以被视为可调用组件。一个类在实例化过程中成为可调用,当对其构造函数__init__(self)的调用完成时。其中一些类具有无参构造函数,而其他类,如以下Login类,则需要构造函数参数:

class Login:
    def __init__(self, id: UUID, username: str, 
                 password: str, type: UserType): 
        self.id = id
        self.username = username
        self.password = password
        self.type= type

位于/model/users.pyLogin类在实例化之前需要将idusernamepasswordtype传递给其构造函数。一个可能的实例化方式是Login(id='249a0837-c52e-48cd-bc19-c78e6099f931', username='admin', password='admin2255', type=UserType.admin)。总体来看,我们可以观察到类和依赖函数在可调用行为和捕获请求数据(如模型属性)方面的相似性。

相反,以下代码块中展示的populate_login_without_service()显示了Depends()如何将Login注入到服务中。Depends()函数告诉内置容器实例化Login并获取该实例,准备分配给user_account局部参数:

@router.post("/users/datamodel/add")
def populate_login_without_service(
              user_account=Depends(Login)):
    account_dict = jsonable_encoder(user_account)
    login = Login(**account_dict)
    login_details[login.id] = login
    return login

重要提示

所有依赖都应该在服务参数列表的最右侧声明。如果有查询、路径或表单参数,注入式依赖应该放在最后。此外,如果注入式依赖不包含默认难以编码的数据,使用jsonable_encoder()函数也是一个选项。

构建嵌套依赖

有一些场景下,注入式依赖也依赖于其他依赖。当我们向另一个函数注入函数依赖、向另一个类注入式依赖或向类注入函数资源时,目标是构建嵌套依赖。嵌套依赖对 REST API 有益,特别是对于需要通过子域模型进行结构化和分组的长时间和复杂请求数据。这些子域或模型内的域模型随后被 FastAPI 编码为与 JSON 兼容的类型作为子依赖:

async def create_user_details(id: UUID, firstname: str,
         lastname: str, middle: str, bday: date, pos: str, 
         login=Depends(create_login)):
    user = {"id": id, "firstname": firstname, 
            "lastname": lastname, "middle": middle, 
            "bday": bday, "pos": pos, "login": login}
    return user

之前异步的create_user_details()函数表明,即使依赖函数也需要另一个依赖来满足其目的。这个函数依赖于create_login(),这是另一个可靠的组件。通过这种嵌套依赖设置,将create_user_details()连接到 API 服务也包括将create_login()注入到容器中。简而言之,当应用嵌套依赖时,将创建一系列依赖注入链:

@router.post("/users/add/profile")
async def add_profile_login(
          profile=Depends(create_user_details)): 
    user_profile = jsonable_encoder(profile)
    user = User(**user_profile)
    login = user.login
    login = Login(**login)
    user_profiles[user.id] = user
    login_details[login.id] = login
    return user_profile

之前的add_profile_login()服务清楚地展示了其对create_user_details()的依赖,包括其底层的登录详情。FastAPI 容器通过链式依赖成功创建了两个函数,以在 API 请求事务中捕获请求数据。

相反,一个类也可以依赖于另一个类。这里以Profile类为例,它依赖于UserDetailsLogin类:

class Login:
    def __init__(self, id: UUID, username: str, 
                 password: str, type: UserType): 
        self.id = id
        self.username = username
        self.password = password
        self.type= type
class UserDetails: 
    def __init__(self, id: UUID, firstname: str, 
            lastname: str, middle: str, bday: date, 
               pos: str ):
        self.id = id 
        self.firstname = firstname 
        self.lastname = lastname 
        self.middle = middle 
        self.bday = bday 
        self.pos = pos

class Profile:
    def __init__(self, id: UUID, date_created: date, 
        login=Depends(Login), user=Depends(UserDetails)): 
        self.id = id 
        self.date_created = date_created
        self.login = login 
        self.user = user

这里有一个嵌套依赖,因为一旦Profile连接到 REST API 服务,两个类将一起注入。

以下add_profile_login_models()服务展示了这些链式依赖的明显优势:

@router.post("/users/add/model/profile")
async def add_profile_login_models(
                   profile=Depends(Profile)): 
     user_details = jsonable_encoder(profile.user)
     login_details = jsonable_encoder(profile.login)
     user = UserDetails(**user_details)
     login = Login(**login_details)
     user_profiles[user.id] = user
     login_details[login.id] = login
     return {"profile_created": profile.date_created}

提取profile.userprofile.login使得服务更容易识别需要反序列化的查询数据。它还有助于服务确定哪些数据组需要Login实例化,哪些是为UserDetails。因此,将更容易管理这些对象在其各自的dict存储库中的持久性。

在稍后讨论如何在函数和类之间创建显式依赖关系,但现在,让我们看看如何在应用中使用大量这些嵌套依赖项时进行微调。

缓存依赖项

所有依赖项都是可缓存的,FastAPI 在请求事务期间会缓存所有这些依赖项。如果一个依赖项对所有服务都是通用的,FastAPI 默认不会允许你从其容器中获取这些对象。相反,它会在其缓存中寻找这个可注入项,以便在 API 层跨多次使用。保存依赖项,特别是嵌套依赖项,是 FastAPI 的一个好特性,因为它优化了 REST 服务的性能。

相反,Depends()有一个use_cache参数,如果我们想绕过这个缓存机制,我们可以将其设置为False。配置这个钩子将不会在请求事务期间从缓存中保存依赖项,允许Depends()更频繁地从容器中获取实例。下面所示add_profile_login_models()服务的另一个版本展示了如何禁用依赖项缓存:

@router.post("/users/add/model/profile")
async def add_profile_login_models(
   profile:Profile=Depends(Profile, use_cache=False)): 
     user_details = jsonable_encoder(profile.user)
     login_details = jsonable_encoder(profile.login)
     … … … … … …
     return {"profile_created": profile.date_created}

在前面服务实现中的另一个明显变化是Profile数据类型出现在局部参数声明中。这是 FastAPI 允许的吗?

声明 Depends()参数类型

通常,我们不会声明将引用注入依赖项的局部参数的类型。由于类型提示,我们可以选择性地将引用与其适当的对象类型关联起来。例如,我们可以重新实现populate_user_accounts()以包含user_account的类型,如下所示:

@router.get("/users/function/add")
def populate_user_accounts(
           user_account:Login=Depends(create_login)):
    account_dict = jsonable_encoder(user_account)
    login = Login(**account_dict)
    login_details[login.id] = login
    return login

这种场景很少发生,因为create_login()是一个依赖函数,我们通常不会仅为了提供其返回值的蓝图类型而创建类。但是,当我们使用类依赖项时,将适当的类类型声明给连接的对象是可行的,如下面的add_profile_login_models()服务所示,其中将profile参数声明为Profile

@router.post("/users/add/model/profile")
async def add_profile_login_models(
              profile:Profile=Depends(Profile)): 
     user_details = jsonable_encoder(profile.user)
     login_details = jsonable_encoder(profile.login)
     … … … … … …
     return {"profile_created": profile.date_created}

虽然声明在语法上是有效的,但由于Profile类型在声明部分出现了两次,所以表达式看起来重复冗余。为了避免这种冗余,我们可以通过在Depends()函数内部省略类名来替换语句,使用简写版本。因此,声明前面profile的更好方式应该是以下这样:

@router.post("/users/add/model/profile")
async def add_profile_login_models(
              profile:Profile=Depends()): 
     user_details = jsonable_encoder(profile.user)
     ... ... ... ... ... ...
     return {"profile_created": profile.date_created}

参数列表上的更改不会影响add_profile_login_models()服务请求事务的性能。

注入异步依赖项

FastAPI 内置容器不仅管理 同步 函数依赖项,还管理 异步 依赖项。下面的 create_user_details() 是一个异步依赖项,准备好连接到服务:

async def create_user_details(id: UUID, firstname: str, 
       lastname: str, middle: str, bday: date, pos: str, 
       login=Depends(create_login)):
    user = {"id": id, "firstname": firstname, 
            "lastname": lastname, "middle": middle, 
            "bday": bday, "pos": pos, "login": login}
    return user

容器可以管理同步和异步函数依赖项。它允许在异步 API 服务上连接 异步依赖项 或在同步 API 上连接一些 异步依赖项。当依赖项和服务都是异步的情况下,建议使用 async/await 协议以避免结果的不一致。依赖于同步 create_login()create_user_details() 在异步 API add_profile_login() 上被连接。

在学习 FastAPI 中依赖注入设计模式的工作原理之后,下一步是了解在我们的应用程序中应用 Depends() 的不同策略级别。

探索注入依赖项的方法

从之前的讨论中,我们知道 FastAPI 有一个内置的容器,通过它一些对象被注入和实例化。同样,我们也了解到,唯一可注入的 FastAPI 组件是那些所谓的依赖项、可注入项或依赖。现在,让我们列举出在我们的应用程序中追求依赖注入模式的不同方法。

服务上的依赖注入

DI 发生最常见的地方是在服务方法的 参数列表 中。关于这种策略的任何讨论已经在之前的示例中解决,所以我们只需要提出关于这种策略的额外观点:

  • 首先,服务方法应该接受的定制可注入项的数量也是需要关注的一部分。当涉及到复杂的查询参数或请求体时,只要这些依赖项中没有相似的实例变量名,API 服务就可以接受多个可注入项。这些依赖项之间的 变量名冲突 将导致在请求事务中只有一个参数条目用于冲突变量,从而使得所有这些冲突变量共享相同的值。

  • 其次,与可注入项一起工作的合适的 HTTP 方法操作 也是需要考虑的一个方面。函数和类依赖项都可以与 GETPOSTPUTPATCH 操作一起工作,但那些具有如数值 EnumUUID 等属性类型的依赖项可能会因为转换问题而导致 HTTP 状态 422不可处理实体)。我们必须首先计划适用于某些依赖项的 HTTP 方法,然后再实现服务方法。

  • 第三,并非所有依赖项都是请求数据的占位符。与类依赖项不同,依赖函数并不专门用于返回对象或dict。其中一些用于过滤请求数据审查认证细节管理表单数据验证头值处理 cookie,并在违反某些规则时抛出一些错误。以下get_all_recipes()服务依赖于一个get_recipe_service()注入函数,该函数将从应用的dict存储库中查询所有食谱:

@router.get("/recipes/list/all")
def get_all_recipes(handler=Depends(get_recipe_service)):
      return handler.get_recipes()

依赖函数提供了所需的交易,例如保存和检索食谱的记录。而不是使用常规的实例化或方法调用,更好的策略是将这些依赖服务注入到 API 实现中。handler方法参数,它指的是get_recipe_service()的实例,调用特定服务的get_recipes()交易以检索存储库中存储的所有菜单和成分。

路径操作符上的依赖注入

总是有一个选项来实现触发器验证器异常处理器作为可注入的函数。由于这些依赖项像过滤器一样作用于传入的请求,它们的注入发生在路径操作符中,而不是在服务参数列表中。以下代码是/dependencies/posts.py中找到的check_feedback_length()验证器的实现,该验证器检查用户针对食谱发布的反馈是否至少包含 20 个字符(包括空格):

def check_feedback_length(request: Request): 
    feedback = request.query_params["feedback"]
    if feedback == None:
        raise HTTPException(status_code=500, 
           detail="feedback does not exist")
    if len(feedback) < 20:
        raise HTTPException(status_code=403, 
           detail="length of feedback … not lower … 20")

如果验证器的长度小于 20,验证器会暂停 API 执行以从待验证的帖子中检索反馈。如果依赖函数发现它是True,它将抛出HTTP 状态 403。如果请求数据中缺少反馈,它将发出状态码 500;否则,它将允许 API 事务完成其任务。

create_post()post_service()依赖项相比,以下脚本显示check_feedback_length()验证器在insert_post_feedback()服务内部没有任何地方被调用:

async def create_post(id:UUID, feedback: str, 
    rating: RecipeRating, userId: UUID, date_posted: date): 
    post = {"id": id, "feedback": feedback, 
            "rating": rating, "userId" : userId, 
            "date_posted": date_posted}
    return post
@router.post("/posts/insert",
      dependencies=[Depends(check_feedback_length)])
async def insert_post_feedback(post=Depends(create_post), 
          handler=Depends(post_service)): 
    post_dict = jsonable_encoder(post)
    post_obj = Post(**post_dict)
    handler.add_post(post_obj)
    return post

验证器将始终与传入的请求事务紧密工作,而其他两个注入项posthandler则是 API 事务的一部分。

重要提示

APIRouter的路径路由器可以容纳多个可注入项,这就是为什么它的dependencies参数始终需要一个List值([])。

路由器上的依赖注入

然而,有些事务并不是专门针对一个特定的 API 进行本地化的。有一些依赖函数被创建出来,用于与一个应用中特定组的 REST API 服务一起工作,例如以下count_user_by_type()handler check_credential_error()事件,这些事件被设计用来管理user.router组下的 REST API 的传入请求。这种策略需要在APIRouter级别进行依赖注入:

from fastapi import Request, HTTPException
from repository.aggregates import stats_user_type
import json
def count_user_by_type(request: Request):
    try:
      count = 
          stats_user_type[request.query_params.get("type")]
      count += 1
      stats_user_type[request.query_params.get("type")] =
          count
      print(json.dumps(stats_user_type))
    except:
      stats_user_type[request.query_params.get("type")] = 1
def check_credential_error(request: Request): 
    try:
      username = request.query_params.get("username")
      password = request.query_params.get("password")
      if username == password:
        raise HTTPException(status_code=403, 
         detail="username should not be equal to password")
    except:
      raise HTTPException(status_code=500, 
           detail="encountered internal problems")         

根据 preceding 实现,count_user_by_type() 的目标是根据 UserTypestats_user_type 中构建用户的更新频率。它的执行是在 REST API 从客户端接收到新用户和登录详情后立即开始的。在检查新记录的 UserType 时,API 服务会短暂暂停,并在函数依赖完成其任务后恢复。

相反,check_credential_error() 的任务是确保新用户的 usernamepassword 不应相同。当凭证相同时,它会抛出 HTTP 状态 403,这将停止整个 REST 服务事务。

通过 APIRouter 注入这两个依赖项意味着在该 APIRouter 中注册的所有 REST API 服务都将始终触发这些依赖项的执行。这些依赖项只能与设计用于持久化 userlogin 详情的 API 服务一起工作,如下所示:

from fastapi import APIRouter, Depends
router = APIRouter(dependencies=[
                      Depends(count_user_by_type), 
                      Depends(check_credential_error)])
@router.get("/users/function/add")
def populate_user_accounts(
          user_account:Login=Depends(create_login)):
    account_dict = jsonable_encoder(user_account)
    login = Login(**account_dict)
    login_details[login.id] = login
    return login

注入到 APIRouter 组件中的 check_credential_error() 会过滤从 create_login() 注入式函数中得到的 usernamepassword。同样,它也会过滤 add_profile_login() 服务的 create_user_details() 注入式,如下面的片段所示:

@router.post("/users/add/profile")
async def add_profile_login(
          profile=Depends(create_user_details)): 
    user_profile = jsonable_encoder(profile)
    user = User(**user_profile)
    login = user.login
    login = Login(**login)
    user_profiles[user.id] = user
    login_details[login.id] = login
    return user_profile
@router.post("/users/datamodel/add")
def populate_login_without_service(
          user_account=Depends(Login)):
    account_dict = jsonable_encoder(user_account)
    login = Login(**account_dict)
    login_details[login.id] = login
    return login

Login 注入式类也会通过 check_credential_error() 进行过滤。它也包含注入式函数可以过滤的 usernamepassword 参数。相反,以下 add_profile_login_models() 服务的 Profile 注入式没有被排除在错误检查机制之外,因为它在其构造函数中有一个 Login 依赖。拥有 Login 依赖意味着 check_cedential_error() 也会过滤 Profile

使用 check_credential_error()count_user_by_type() 注入式,用于统计访问 API 服务的用户数量:

@router.post("/users/add/model/profile")
async def add_profile_login_models(
          profile:Profile=Depends(Profile)): 
     user_details = jsonable_encoder(profile.user)
     ... ... ... ... ... ...
     login = Login(**login_details)
     user_profiles[user.id] = user
     login_details[login.id] = login
     return {"profile_created": profile.date_created}

连接到 APIRouter 的依赖函数应该应用防御性编程和适当的 try-except 来避免与 API 服务发生参数冲突。例如,如果我们用 list_all_user() 服务运行 check_credential_error(),可能会遇到一些运行时问题,因为在数据检索期间没有涉及 login 持久化。

重要提示

类似于其路径操作符,APIRouter 的构造函数也可以接受多个注入式,因为它的 dependencies 参数将允许一个 List ([]) 的有效值。

在 main.py 上的依赖注入

由于范围广泛且复杂,软件的某些部分很难自动化,因此考虑它们将始终是时间和精力的浪费。这些 横切关注点 从 UI 层到数据层,这解释了为什么这些功能在实际管理和实现上是不切实际的,甚至难以想象。这些横切关注点包括如 异常记录缓存监控用户授权 等事务,这些是任何应用程序都共有的。

FastAPI 有一个简单的解决方案来解决这些 特性:将它们作为可注入到 main.py 的 FastAPI 实例中的可注入项:

from fastapi import Request
from uuid import uuid1
service_paths_log = dict()
def log_transaction(request: Request): 
    service_paths_log[uuid1()] = request.url.path

前面的 log_transaction() 是一个简单的记录器,用于记录客户端调用的或访问的 URL 路径。当应用程序运行时,这个跨切面应该将不同的 APIRouter 发来的不同 URL 传播到存储库中。这项任务只能在通过 main.py 的 FastAPI 实例注入这个函数时发生:

from fastapi import FastAPI, Depends
from api import recipes, users, posts, login, admin, 
        keywords, admin_mcontainer, complaints
from dependencies.global_transactions import    
        log_transaction
app = FastAPI(dependencies=[Depends(log_transaction)])
app.include_router(recipes.router, prefix="/ch03")
app.include_router(users.router, prefix="/ch03")
   … … … … … …
app.include_router(admin.router, prefix="/ch03")
app.include_router(keywords.router, prefix="/ch03")
app.include_router(admin_mcontainer.router, prefix="/ch03")
app.include_router(complaints.router, prefix="/ch03")

与 FastAPI 构造函数自动连接的依赖项被称为 全局依赖项,因为它们可以通过任何路由器的 REST API 访问。例如,前面脚本中描述的 log_transaction() 将在 recipesuserspostscomplaints 路由器处理它们各自的请求事务时执行。

重要提示

APIRouter 类似,FastAPI 的构造函数允许更多的函数依赖。

除了这些策略之外,依赖注入(DI)还可以通过拥有 repositoryservicemodel 层来组织我们的应用程序。

基于依赖关系组织项目

在一些复杂的 FastAPI 应用程序中,可以通过依赖注入(DI)使用 存储库-服务 模式。存储库-服务模式负责创建应用程序的 存储库层,该层管理数据源的增加、读取、更新和删除(CRUD)。存储库层需要 数据模型 来描述集合或数据库的表结构。存储库层需要 服务层 来与其他应用程序部分建立连接。服务层就像一个业务层,数据源和业务流程在这里相遇,以生成 REST API 所需的所有必要对象。存储库和服务层之间的通信只能通过创建可注入项来实现。现在,让我们通过可注入组件来探索 图 3.2 中显示的层是如何构建的。

图 3.2 – 存储库-服务层

图 3.2 – 存储库-服务层

模型层

这一层纯粹由 资源集合Python 类 组成,这些类可以被存储库层用来创建 CRUD 事务。一些模型类依赖于其他模型,但有些只是为数据占位符设计的独立蓝图。以下是一些存储与食谱相关细节的应用程序模型类示例:

from uuid import UUID
from model.classifications import Category, Origin
from typing import Optional, List
class Ingredient:
    def __init__(self, id: UUID, name:str, qty : float,
               measure : str):
        self.id = id
        self.name = name
        self.qty = qty
        self.measure = measure

class Recipe:
    def __init__(self, id: UUID, name: str, 
           ingredients: List[Ingredient], cat: Category, 
             orig: Origin):
        self.id = id
        self.name = name
        self.ingredients = ingredients
        self.cat = cat
        self.orig = orig

存储库层

这一层由类依赖组成,可以访问 数据存储 或即兴的 dict 存储库,就像在我们的 在线食谱系统 中一样。与模型层一起,这些存储库类构建了 REST API 所需要的 CRUD 事务。以下是一个具有两个事务(即 insert_recipe()query_recipes())的 RecipeRepository 实现示例:

from model.recipes import Recipe
from model.recipes import Ingredient
from model.classifications import Category, Origin
from uuid import uuid1 
recipes = dict()
class RecipeRepository: 
    def __init__(self): 
        ingrA1 = Ingredient(measure='cup', qty=1, 
             name='grape tomatoes', id=uuid1())
        ingrA2 = Ingredient(measure='teaspoon', qty=0.5, 
             name='salt', id=uuid1())
        ingrA3 = Ingredient(measure='pepper', qty=0.25, 
             name='pepper', id=uuid1())
        … … … … … …
        recipeA = Recipe(orig=Origin.european ,
         ingredients= [ingrA1, ingrA2, ingrA3, ingrA4, 
              ingrA5, ingrA6, ingrA7, ingrA8, ingrA9], 
         cat= Category.breakfast, 
         name='Crustless quiche bites with asparagus and 
               oven-dried tomatoes', 
         id=uuid1())
        ingrB1 = Ingredient(measure='tablespoon', qty=1, 
           name='oil', id=uuid1())
        ingrB2 = Ingredient(measure='cup', qty=0.5, 
           name='chopped tomatoes', id=uuid1())
        … … … … … …
        recipeB = Recipe(orig=Origin.carribean ,
           ingredients= [ingrB1, ingrB2, ingrB3, ingrB4, 
             ingrB5], 
           cat= Category.breakfast, 
           name='Fried eggs, Caribbean style', id=uuid1())
        ingrC1 = Ingredient(measure='pounds', qty=2.25, 
           name='sweet yellow onions', id=uuid1())
        ingrC2 = Ingredient(measure='cloves', qty=10, 
           name='garlic', id=uuid1())
        … … … … … …
        recipeC = Recipe(orig=Origin.mediterranean ,
          ingredients= [ingrC1, ingrC2, ingrC3, ingrC4, 
             ingrC5, ingrC6, ingrC7, ingrC8], 
          cat= Category.soup, 
          name='Creamy roasted onion soup', id=uuid1())

        recipes[recipeA.id] = recipeA
        recipes[recipeB.id] = recipeB
        recipes[recipeC.id] = recipeC

    def insert_recipe(self, recipe: Recipe):
        recipes[recipe.id] = recipe

    def query_recipes(self):
        return recipes

其构造函数用于填充一些初始数据。可注入存储库 类的构造函数在数据存储设置和配置中发挥作用,这也是我们在这里 自动连接 依赖的地方。相反,实现包括两个 Enum 类 – CategoryOrigin – 分别为食谱的菜单类别和产地提供查找值。

存储库工厂方法

这一层使用 工厂设计模式 在存储库和服务层之间添加了一种更松散的耦合设计。尽管这种方法是可选的,但这仍然是一个管理两层之间相互依赖阈值的选项,尤其是在 CRUD 事务的性能、流程和结果频繁变化时。以下是我们应用程序使用的存储库工厂方法:

def get_recipe_repo(repo=Depends(RecipeRepository)):
    return repo
def get_post_repo(repo=Depends(PostRepository)): 
    return repo
def get_users_repo(repo=Depends(AdminRepository)): 
    return repo
def get_keywords(keywords=Depends(KeywordRepository)): 
    return keywords
def get_bad_recipes(repo=Depends(BadRecipeRepository)): 
    return repo

从前面的脚本中我们可以看到,RecipeRepository 是工厂方法的可靠对象,这些方法也是可注入的组件,但属于服务层。例如,get_recipe_repo() 将与一个服务类连接,以实现需要从 RecipeRepository 进行一些事务的原生服务。从某种意义上说,我们间接地将存储库类连接到服务层。

服务层

这一层包含所有应用程序的服务和领域逻辑,例如我们的 RecipeService,它为 RecipeRepository 提供业务流程和算法。get_recipe_repo() 工厂通过其构造函数注入,以提供来自 RecipeRepository 的 CRUD 事务。这里使用的注入策略是类依赖函数,如下面的代码所示:

from model.recipes import Recipe
from repository.factory import get_recipe_repo
class RecipeService: 
    def __init__(self, repo=Depends(get_recipe_repo)):
        self.repo = repo

    def get_recipes(self):
        return self.repo.query_recipes()

    def add_recipe(self, recipe: Recipe):
        self.repo.insert_recipe(recipe)

典型的 Python 类的构造函数始终是注入组件的适当位置,这些组件可以是函数或类依赖。由于前面的 RecipeService,其 get_recipes()add_recipe() 是通过从 get_recipe_repo() 得到的事务实现的。

REST API 和服务层

REST API 方法可以直接注入服务类或工厂方法,如果它需要访问服务层。在我们的应用中,每个服务类都有一个与之关联的工厂方法,以应用在RecipeRepository注入中使用的相同策略。这就是为什么在下面的脚本中,get_recipe_service()方法被连接到 REST API 而不是RecipeService

class IngredientReq(BaseModel):
    id: UUID 
    name:str
    qty: int
    measure: str

class RecipeReq(BaseModel):
    id: UUID 
    name: str
    ingredients: List[IngredientReq]
    cat: Category
    orig: Origin

router = APIRouter()
@router.post("/recipes/insert")
def insert_recipe(recipe: RecipeReq, 
            handler=Depends(get_recipe_service)): 
    json_dict = jsonable_encoder(recipe)
    rec = Recipe(**json_dict)
    handler.add_recipe(rec)
    return JSONResponse(content=json_dict, status_code=200)
@router.get("/recipes/list/all")
def get_all_recipes(handler=Depends(get_recipe_service)):
    return handler.get_recipes()

insert_recipe()是一个 REST API,它接受来自客户端的食谱及其成分以进行持久化,而get_all_recipes()则返回List[Recipe]作为响应。

实际项目结构

利用 DI(依赖注入)的力量,我们创建了一个包含组织良好的模型存储库服务层的在线食谱系统图 3.3中显示的项目结构由于增加了这些层而与之前的原型有很大不同,但它仍然包含main.py以及所有带有相应APIRouter的包和模块。

图 3.3 – 在线食谱系统的项目结构

图 3.3 – 在线食谱系统的项目结构

到目前为止,DI 已经为 FastAPI 应用提供了许多优势,从对象实例化工程到分解单体组件以设置松散耦合的结构。但只有一个小问题:FastAPI 的默认容器。框架的容器没有简单的配置来将所有管理的对象设置为单例范围。大多数应用更喜欢获取单例对象以避免在Python 虚拟机PVM)中浪费内存。此外,内置的容器不对更详细的容器配置开放,例如拥有多个容器设置。接下来的讨论将集中在 FastAPI 默认容器的限制以及克服它的解决方案。

使用第三方容器

DI 为我们提供了很多改进应用的方法,但它仍然依赖于我们使用的框架以充分发挥这种设计模式的潜力。当关注点仅在于对象管理和项目组织时,FastAPI 的容器对一些人来说是非常可接受的。然而,当涉及到配置容器以添加更多高级功能时,对于短期项目来说这是不可行的,而对于大型应用来说由于限制将变得不可能。因此,实际的方法是依靠第三方模块来提供支持所有这些进步所需的实用工具集。因此,让我们探索这些与 FastAPI 无缝集成的流行外部模块,即依赖注入器Lagom,我们可以使用它们来设置一个完整且可管理的容器。

使用可配置的容器 – 依赖注入器

当涉及到可配置的容器时,依赖注入器有几个模块 API 可以用来构建自定义容器的变体,这些容器可以管理、组装和注入对象。但在我们能够使用此模块之前,我们需要首先使用pip安装它:

pip install dependency-injector

容器和提供者模块

在所有 API 类型中,依赖注入器 因其 容器提供者 而受到欢迎。其容器类型之一是 DeclarativeContainer,它可以被继承以包含所有提供者。其提供者可以是 FactoryDictListCallableSingleton 或其他 容器DictList 提供者都很容易设置,因为它们只需要分别实例化 listdict。相反,Factory 提供者可以实例化任何类,例如存储库、服务或通用的 Python 类,而 Singleton 只为每个类创建一个实例,该实例在整个应用程序的运行期间都是有效的。Callable 提供者管理函数依赖关系,而 Container 实例化其他容器。另一种容器类型是 DynamicContainer,它由配置文件、数据库或其他资源构建而成。

容器类型

除了这些容器 API 之外,依赖注入器 允许我们根据可信对象的数量、项目结构或其他项目标准来自定义容器。最常见的形式或设置是适合小型、中型或大型应用的单一声明式容器。我们的 在线食谱系统 原型拥有一个单一的声明式容器,该容器在以下脚本中实现:

from dependency_injector import containers, providers
from repository.users import login_details
from repository.login import LoginRepository
from repository.admin import AdminRepository
from repository.keywords import KeywordRepository
from service.recipe_utilities import get_recipe_names 
class Container(containers.DeclarativeContainer):
    loginservice = providers.Factory(LoginRepository)
    adminservice = providers.Singleton(AdminRepository)
    keywordservice = providers.Factory(KeywordRepository)
    recipe_util = providers.Callable(get_recipe_names) 
    login_repo = providers.Dict(login_details)

通过简单地继承 DeclarativeContainer,我们可以轻松地创建一个容器,其实例通过之前提到的各种提供者注入。LoginRepositoryKeywordRepository 都是通过 Factory 提供者注入的新实例。AdminRepository 是一个注入的单例对象,get_recipe_names() 是一个注入的可信函数,而 login_details 是一个包含登录凭证的注入字典。

FastAPI 和依赖注入器集成

要通过依赖注入器将依赖关系连接到组件,请应用 @inject 装饰器。@injectdependency_injector.wiring 模块导入,并装饰在 依赖 组件上。

之后,将使用 Provide 连接标记从容器中检索实例。连接标记在容器中搜索引用注入对象的 Provider 对象,如果存在,它将为 自动连接 准备。@injectProvide 都属于同一个 API 模块:

from repository.keywords import KeywordRepository
from containers.single_container import Container
from dependency_injector.wiring import inject, Provide
from uuid import UUID
router = APIRouter()
@router.post("/keyword/insert")
@inject
def insert_recipe_keywords(*keywords: str, 
         keywordservice: KeywordRepository = 
           Depends(Provide[Container.keywordservice])): 
    if keywords != None:
        keywords_list = list(keywords)
        keywordservice.insert_keywords(keywords_list)
        return JSONResponse(content={"message": 
          "inserted recipe keywords"}, status_code=201)
    else:
        return JSONResponse(content={"message": 
          "invalid operation"}, status_code=403)

当调用 Depends() 函数指令以注册连接标记和 Provider 实例到 FastAPI 时,发生集成。除了确认之外,注册还向第三方 Provider 添加 类型提示Pydantic 验证规则,以便适当地将注入对象连接到 FastAPI。前面的脚本从其模块导入 Container,通过 @inject 连接标记和 依赖注入器keywordservice 提供者来连接 KeywordRepository

现在,最后一部分是要通过 FastAPI 平台 组装创建部署 单个声明性容器。这个最后的集成措施需要在发生注入的模块内部实例化 容器,然后调用其 wire() 方法,该方法构建组装。由于前面的 insert_recipe_keywords()/api/keywords.py 的一部分,我们应该在 keywords 模块脚本中添加以下行,尤其是在其末尾部分:

import sys
… … … … …
container = Container()
container.wire(modules=[sys.modules[__name__]])

多容器配置

对于大型应用程序,仓库事务和服务数量根据应用程序的功能和特殊功能而增加。如果单个声明性类型对于不断增长的应用程序来说变得不可行,那么我们总是可以用 多容器 设置来替换它。

依赖注入器允许我们为每组服务创建一个单独的容器。我们的应用程序创建了一个示例设置,位于 /containers/multiple_containers.py 中,以防这个原型变成完整的产品。以下是如何展示多个声明性容器的示例:

from dependency_injector import containers, providers
from repository.login import LoginRepository
from repository.admin import AdminRepository
from repository.keywords import KeywordRepository
class KeywordsContainer(containers.DeclarativeContainer): 
    keywordservice = providers.Factory(KeywordRepository)
    … … … … …
class AdminContainer(containers.DeclarativeContainer): 
    adminservice = providers.Singleton(AdminRepository)
    … … … … …
class LoginContainer(containers.DeclarativeContainer): 
    loginservice = providers.Factory(LoginRepository)
    … … … … …

class RecipeAppContainer(containers.DeclarativeContainer): 
    keywordcontainer = 
          providers.Container(KeywordsContainer)
    admincontainer = providers.Container(AdminContainer)
    logincontainer = providers.Container(LoginContainer)
    … … … … …

基于前面的配置,创建的三个不同的 DeclarativeContainer 实例是 KeywordsContainerAdminContainerLoginContainerKeywordsContainer 实例将组装所有与关键词相关的依赖项,AdminContainer 将持有所有与行政任务相关的实例,而 LoginContainer 用于登录和用户相关的服务。然后,还有 RecipeAppContainer,它将通过 DI 整合所有这些容器。

将依赖注入到 API 的方式与单个声明性风格类似,只是容器需要在连接标记中指明。以下是一个与行政相关的 API,展示了我们如何将依赖项连接到 REST 服务:

from dependency_injector.wiring import inject, Provide
from repository.admin import AdminRepository
from containers.multiple_containers import 
         RecipeAppContainer

router = APIRouter()
@router.get("/admin/logs/visitors/list")
@inject
def list_logs_visitors(adminservice: AdminRepository =    
   Depends(
     Provide[
      RecipeAppContainer.admincontainer.adminservice])): 
    logs_visitors_json = jsonable_encoder(
           adminservice.query_logs_visitor())
    return logs_visitors_json

Provide 中存在 admincontainer 之前,它会首先检查同名容器,然后再获取引用服务依赖项的 adminservice 提供者。其余的细节与单个声明性相同,包括 FastAPI 集成和对象组装。

这里关于 依赖注入器 的亮点只是简单应用程序的基本配置。还有其他功能和集成可以由这个模块提供,以优化我们的应用程序使用 DI。现在,如果我们需要线程安全且非阻塞,但具有简单、精简和直接的 API、设置和配置,那么有 Lagom 模块。

使用简单的配置 – Lagom

第三方的 Lagom 模块因其连接依赖项时的简单性而广泛使用。它也适用于构建异步微服务驱动应用程序,因为它在运行时是线程安全的。此外,它可以轻松集成到许多 Web 框架中,包括 FastAPI。要应用其 API,我们首先需要使用 pip 安装它:

pip install lagom

容器

Lagom 中,使用其模块中的 Container 类可以即时创建容器。与 Dependency Injector 不同,Lagom 的容器是在 REST API 模块内部注入发生之前创建的:

from lagom import Container
from repository.complaints import BadRecipeRepository
container = Container()
container[BadRecipeRepository] = BadRecipeRepository()
router = APIRouter()

所有依赖项都通过典型实例化注入到容器中。当添加新的依赖项时,容器表现得像 dict,因为它也使用 键值对 作为条目。当我们注入一个对象时,容器需要其类名作为 ,实例作为 。此外,DI 框架还允许在构造函数需要一些参数值时使用带参数的实例化。

FastAPI 和 Lagom 集成

在进行接线之前,必须首先通过实例化一个名为 FastApiIntegration 的新 API 类来集成到 FastAPI 平台,该类位于 lagom.integrations.fast_api 模块中。它需要一个名为 container 的必需参数:

from lagom.integrations.fast_api import FastApiIntegration
deps = FastApiIntegration(container)

依赖项

FastAPIIntegration 实例有一个 depends() 方法,我们将使用它来进行注入。Lagom 的一个最佳特性是它易于无缝集成到任何框架中。因此,不再需要 FastAPI 的 Depends() 函数来连接依赖项:

@router.post("/complaint/recipe")
def report_recipe(rid: UUID, 
     complaintservice=deps.depends(BadRecipeRepository)): 
        complaintservice.add_bad_recipe(rid)
        return JSONResponse(content={"message": 
           "reported bad recipe"}, status_code=201)

前面的 report_recipe() 方法使用了 BadRecipeRepository 作为可注入的服务。由于它是容器的一部分,Lagom 的 depends() 函数将在容器中搜索该对象,然后如果存在,它将被连接到 API 服务,以将投诉保存到 dict 数据存储中。

到目前为止,这两个第三方模块在我们应用中使用 DI 时是最受欢迎和详尽的。这些模块可能会通过未来的更新而改变,但有一点是肯定的:IoC 和 DI 设计模式将始终是管理应用内存使用的强大解决方案。现在让我们讨论围绕内存空间、容器和对象组装的问题。

依赖项的作用域

在 FastAPI 中,依赖项的作用域可以是新实例或单例。FastAPI 的依赖注入默认不支持创建单例对象。在每次执行带有依赖项的 API 服务时,FastAPI 总是获取每个已连接依赖项的新实例,这可以通过使用 id() 获取 对象 ID 来证明。

单例对象由容器创建一次,无论框架注入多少次。其 对象 ID 在整个应用运行期间保持不变。服务和存储库类通常被设置为单例,以控制应用内存使用的增加。由于使用 FastAPI 创建单例并不容易,我们可以使用 Dependency InjectorLagom

Dependency Injector 中有一个 Singleton 提供者,负责创建单例依赖项。在讨论其 DeclarativeContainer 设置时已经提到了这个提供者。使用 Lagom,有两种创建单例注入的方式:(a) 使用其 Singleton 类,和 (b) 通过 FastAPIIntegration 构造函数。

Singleton 类在将实例注入容器之前,会包装依赖项的实例。以下示例代码片段展示了其中一个例子:

container = Container()
container[BadRecipeRepository] = 
         Singleton(BadRecipeRepository())

另一种方式是在 FastAPIIntegration 构造函数的 request_singletons 参数中声明依赖项。以下代码片段展示了如何实现:

container = Container()
container[BadRecipeRepository] = BadRecipeRepository()
deps = FastApiIntegration(container,
      request_singletons=[BadRecipeRepository])

顺便说一下,request_singletons 参数是 List 类型,因此当我们想要创建单例时,它将允许我们声明至少一个依赖项。

摘要

使框架易于使用且实用的一个方面是其对 IoC 原则的支持。FastAPI 内置了一个容器,我们可以利用它来建立组件之间的依赖关系。通过使用 DI 模式通过连接线整合所有这些组件是一个构建微服务驱动应用程序的强烈先决条件。从简单的 Depends() 注入开始,我们可以扩展 DI 来构建用于数据库集成、身份验证、安全和单元测试的可插拔组件。

本章还介绍了一些第三方模块,如 Dependency InjectorLagom,它们可以设计和定制容器。由于 FastAPI 在 DI 方面的限制,存在外部库可以帮助扩展其责任,以在容器中组装、控制和管理工作对象的创建。这些第三方 API 还可以创建单例对象,这有助于减少 PVM 中的堆大小。

除了性能调整和内存管理之外,DI 还可以促进项目的组织,尤其是大型应用程序。添加模型、存储库和服务层是创建依赖项的显著效果。注入使开发面向其他设计模式,例如工厂方法、服务和数据访问对象模式。在下一章中,我们将开始基于微服务的核心设计模式构建一些与微服务相关的组件。

第四章:构建微服务应用程序

以前,我们花费了大量时间使用 FastAPI 的核心功能为各种应用程序构建 API 服务。我们还开始应用重要的设计模式,如控制反转IoC)和依赖注入DI),这对于管理 FastAPI 容器对象至关重要。安装并使用了外部 Python 包来提供在管理对象时选择使用哪些容器的选项。

这些设计模式不仅可以帮助容器中的管理对象,还可以在构建可扩展的、企业级和非常复杂的应用程序时使用。大多数这些设计模式有助于将单体架构分解为松散耦合的组件,这些组件被称为微服务

在本章中,我们将探讨一些架构设计模式和原则,这些模式和原则可以提供策略和方法,从单体应用程序开始构建我们的微服务。我们的重点将放在将大型应用程序分解为业务单元,创建一个单独的网关来捆绑这些业务单元,将领域建模应用于每个微服务,以及管理其他关注点,如日志记录和应用程序配置。

除了阐述每种设计模式的利弊之外,另一个目标是将这些架构模式应用于我们的软件样本,以展示其有效性和可行性。为了支持这些目标,本章将涵盖以下主题:

  • 应用分解模式

  • 创建通用网关

  • 集中日志机制

  • 消费 REST API

  • 应用领域建模方法

  • 管理微服务的配置细节

技术要求

本章使用了一个大学 ERP 系统原型,该原型专注于学生、教职员工和图书馆子模块,但更侧重于学生-图书馆和教职员工-图书馆操作(例如,借书和发放)。每个子模块都有自己的管理、管理和交易服务,尽管它们是 ERP 规范的一部分,但它们之间是独立的。目前,这个示例原型没有使用任何数据库管理系统,因此所有数据都临时存储在 Python 容器中。代码全部上传到github.com/PacktPublishing/Building-Python-Microservices-with-FastAPI,在ch04ch04-studentch04-facultych04-library项目中。

应用分解模式

如果我们应用在前面章节中展示的原型中使用的单体策略,那么在资源和工作量方面,构建这个 ERP 将不会具有成本效益。可能会有一些功能可能会过于依赖其他功能,这将在这些紧密耦合的功能因交易问题而出现时,使开发团队陷入困境。实现我们的大学 ERP 原型的最佳方式是在实施开始之前将整个规范分解成更小的模块。

我们有几种合适的方法可以对我们的应用程序原型进行分解,即按业务单元分解和按子域分解:

  • 按业务单元分解用于当单体应用的分解基于组织结构、架构组件和结构单元时。通常,其结果模块具有固定和结构化的流程和功能,很少进行增强或升级。

  • 按子域分解使用领域模型及其相应的业务流程作为分解的基础。与前者不同,这种分解策略处理的是持续演变和变化的模块,以捕捉模块的确切结构。

在这两种选择中,按业务单元分解是我们用于单体大学 ERP 原型的更实用的分解策略。由于大学使用的信息和业务流程已经是其多年的基础,我们需要通过学院或部门对其庞大而复杂的操作进行组织和分解。图 4.1显示了这些子模块的推导:

图 4.1 – 按业务单元分解

图 4.1 – 按业务单元分解

确定子模块后,我们可以使用 FastAPI 框架将它们实现为独立的微服务。如果一个业务单元或模块的服务可以作为一个组件整体存在,那么我们可以将其实现称为微服务。此外,它还必须能够通过基于 URL 地址和端口号的互连与其他微服务协作图 4.2显示了作为 FastAPI 微服务应用程序实现的学院、图书馆和学生管理模块的项目目录。第一章**,为初学者设置 FastAPI,到第三章**,调查依赖注入*,为我们构建 FastAPI 微服务奠定了基础:

图 4.2 – 学院、图书馆和学生微服务应用程序

图 4.2 – 学院、图书馆和学生微服务应用程序

在服务器实例和管理方面,这些微服务彼此独立。启动和关闭其中一个不会影响其他两个,因为每个都可以有不同的上下文根和端口。每个应用程序都可以有独立的日志机制、依赖环境、容器、配置文件以及微服务的任何其他方面,这些将在后续章节中讨论。

但 FastAPI 还有另一种使用挂载子应用程序设计微服务的方法。

创建子应用程序

FastAPI 允许你在主应用程序内部构建独立的子应用程序。在这里,main.py充当网关,为这些挂载的应用程序提供路径名。它还创建了挂载,指定映射到每个子应用程序 FastAPI 实例的上下文路径。图 4.3显示了使用挂载构建的新大学 ERP 实现:

图 4.3 – 带挂载的主项目

图 4.3 – 带挂载的主项目

在这里,faculty_mgtlibrary_mgtstudent_mgt是典型的独立微服务应用程序,被挂载到main.py组件,即顶级应用程序中。每个子应用程序都有一个main.py组件,例如library_mgt,它在library_main.py设置中创建了其 FastAPI 实例,如下面的代码片段所示:

from fastapi import FastAPI
library_app = FastAPI()
library_app.include_router(admin.router)
library_app.include_router(management.router)

学生子应用程序有一个student_main.py设置,它创建其 FastAPI 实例,如下面的代码所示:

from fastapi import FastAPI
student_app = FastAPI()
student_app.include_router(reservations.router)
student_app.include_router(admin.router)
student_app.include_router(assignments.router)
student_app.include_router(books.router)

同样,教师子应用程序也有其faculty_main.py设置,如下面的代码所示,出于相同的目的,构建微服务架构:

from fastapi import FastAPI
faculty_app = FastAPI()
faculty_app.include_router(admin.router)
faculty_app.include_router(assignments.router)
faculty_app.include_router(books.router)

这些子应用程序是典型的 FastAPI 微服务应用程序,包含所有基本组件,如路由器、中间件异常处理器以及构建 REST API 服务所需的所有必要包。与常规应用程序的唯一区别是,它们的上下文路径或 URL 由处理它们的顶级应用程序定义和决定。

重要提示

可选地,我们可以通过uvicorn main:library_app --port 8001命令独立运行library_mgt子应用程序,通过uvicorn main:faculty_app --port 8082运行faculty_mgt,以及通过uvicorn main:student_app --port 8003运行student_mgt。尽管它们被挂载,但可以独立运行,这解释了为什么这些挂载的子应用程序都是微服务。

挂载子模块

每个子应用的所有 FastAPI 装饰器都必须挂载在顶层应用的 main.py 组件中,以便在运行时访问。顶层应用的 FastAPI 装饰器对象调用 mount() 函数,将子应用的所有 FastAPI 实例添加到网关应用(main.py)中,并将每个实例与其对应的 URL 上下文进行映射。以下脚本展示了在大学 ERP 顶层系统的 main.py 组件中如何实现 图书馆学生教师 子系统的挂载:

from fastapi import FastAPI
from student_mgt import student_main
from faculty_mgt import faculty_main
from library_mgt import library_main
app = FastAPI()
app.mount("/ch04/student", student_main.student_app)
app.mount("/ch04/faculty", faculty_main.faculty_app)
app.mount("/ch04/library", library_main.library_app)

在这种设置下,挂载的 /ch04/student URL 将用于访问 学生模块 应用程序的所有 API 服务,/ch04/faculty 将用于 教师模块 的所有服务,而 /ch04/library 将用于与 图书馆 相关的 REST 服务。一旦在 mount() 中声明,这些挂载路径就变得有效,因为 FastAPI 会自动通过 root_path 规范处理所有这些路径。

由于我们 大学 ERP 系统 的所有三个子应用都是独立的微服务,现在让我们应用另一种设计策略,该策略可以通过使用 ERP 系统的主 URL 来管理对这些应用的请求。让我们利用 主应用程序 作为子应用的网关。

创建一个公共网关

如果我们使用主应用程序的 URL 来管理请求并将用户重定向到任何三个子应用程序之一,将会更容易。主应用程序 可以作为一个伪反向代理或用户请求的入口点,始终将用户请求重定向到任何所需的子应用程序。这种方法基于称为 API 网关 的设计模式。现在,让我们探索如何应用这种设计来管理挂载到主应用程序上的独立微服务,并使用一种变通方法。

实现主端点

在实现此网关端点时,有如此多的解决方案,其中之一是在顶层应用程序中有一个简单的 REST API 服务,该服务具有一个整数路径参数,用于识别微服务的 ID 参数。如果 ID 参数无效,端点将只返回 {'message': 'University ERP Systems'} JSON 字符串,而不是错误。以下脚本是这个端点的直接实现:

from fastapi import APIRouter
router = APIRouter()
@router.get("/university/{portal_id}")
def access_portal(portal_id:int): 
    return {'message': 'University ERP Systems'}

access_portal API 端点是作为一个带有 portal_id 作为路径参数的 GET 路径操作创建的。portal_id 参数对于此过程至关重要,因为它将确定用户想要访问的 学生教师图书馆 微服务中的哪一个。因此,访问 /ch04/university/1 URL 应该将用户引导到学生应用程序,/ch04/university/2 到教师微服务,而 /ch04/university/3 到图书馆应用程序。

评估微服务 ID

portal_id 参数将自动使用一个可信赖的函数获取并评估,该函数被注入到实现 API 端点的 APIRouter 实例中。正如在 第三章调查依赖注入 中所讨论的,一个 可信赖的函数对象 可以在注入到 APIRouterFastAPI 实例后作为所有服务的所有传入请求的过滤器或验证器。在以下脚本中,这个 ERP 原型中使用的可信赖函数评估 portal_id 参数是否为 123

def call_api_gateway(request: Request): 
    portal_id = request.path_params['portal_id']
    print(request.path_params)
    if portal_id == str(1): 
        raise RedirectStudentPortalException() 
    elif portal_id == str(2): 
        raise RedirectFacultyPortalException() 
    elif portal_id == str(3): 
        raise RedirectLibraryPortalException()
class RedirectStudentPortalException(Exception):
    pass
class RedirectFacultyPortalException(Exception):
    pass
class RedirectLibraryPortalException(Exception):
    pass

给定的解决方案是一个可行的解决方案,用于触发自定义事件,因为 FastAPI 除了启动和关闭事件处理器外没有内置的事件处理功能,这些是 第八章创建协程、事件和消息驱动事务 的主题。因此,一旦 call_api_gateway() 发现 portal_id 是一个有效的微服务 ID,它将引发一些自定义异常。如果用户想要访问 Student 微服务,它将抛出 RedirectStudentPortalException。另一方面,如果用户想要 Faculty 微服务,将引发 RedirectFacultyPortalException 错误。否则,当用户想要 Library 微服务时,将触发 RedirectLibraryPortalException 错误。但首先,我们需要通过顶级 ERP 应用程序的 main.py 组件将 call_api_gateway() 注入到处理网关端点的 APIRouter 实例中。以下脚本展示了如何使用前面讨论的概念将其注入到 university.router 中:

from fastapi import FastAPI, Depends, Request, Response
from gateway.api_router import call_api_gateway
from controller import university
app = FastAPI()
app.include_router (university.router, 
           dependencies=[Depends(call_api_gateway)], 
           prefix='/ch04')

所有这些引发的异常都需要一个异常处理器来监听抛出并执行追求微服务所需的某些任务。

应用异常处理器

异常处理器将重定向到适当的微服务。正如你在 第二章探索核心功能 中所学到的,每个抛出的异常都必须有一个相应的异常处理器,在异常处理之后追求所需响应。以下是处理 call_api_gateway() 抛出的自定义异常的异常处理器:

from fastapi.responses import RedirectResponse
from gateway.api_router import call_api_gateway, 
     RedirectStudentPortalException, 
     RedirectFacultyPortalException, 
     RedirectLibraryPortalException
@app.exception_handler(RedirectStudentPortalException)
def exception_handler_student(request: Request, 
   exc: RedirectStudentPortalException) -> Response:
    return RedirectResponse(
        url='http://localhost:8000/ch04/student/index')
@app.exception_handler(RedirectFacultyPortalException)
def exception_handler_faculty(request: Request, 
   exc: RedirectFacultyPortalException) -> Response:
    return RedirectResponse(
       url='http://localhost:8000/ch04/faculty/index')
@app.exception_handler(RedirectLibraryPortalException)
def exception_handler_library(request: Request, 
   exc: RedirectLibraryPortalException) -> Response:
    return RedirectResponse(
       url='http://localhost:8000/ch04/library/index')

在这里,exception_handler_student() 将将用户重定向到 Student 微服务的挂载路径,而 exception_handler_faculty() 将将用户重定向到 Faculty 子应用。此外,exception_handler_library() 将允许用户访问 Library 微服务。异常处理器是完成 API 网关架构所需的最后一个组件。异常触发将用户重定向到安装在 FastAPI 框架上的独立微服务。

虽然有其他更好的解决方案来实现网关架构,但我们的方法仍然是通过不依赖外部模块和工具,仅使用 FastAPI 的核心组件来进行程序化和实用。第十一章添加其他微服务功能,将讨论使用 Docker 和 NGINX 建立有效的 API 网关架构。

现在,让我们探索如何为这种微服务设置设置集中式日志机制。

集中式日志机制

我们在第二章探索核心功能中创建了一个审计跟踪机制,使用了中间件和 Python 文件事务。我们发现,中间件只能通过顶级应用程序的 FastAPI 装饰器来设置,它可以管理任何 API 服务的传入请求和传出响应。这次,我们将使用自定义中间件来设置一个集中式日志功能,该功能将记录顶级应用程序及其独立挂载的微服务的所有服务事务。在许多将日志关注点集成到应用程序中而不更改 API 服务的方法中,我们将专注于以下具有自定义中间件和Loguru模块的实用自定义方法。

利用 Loguru 模块

应用程序日志对于任何企业级应用都是必不可少的。对于部署在单个服务器上的单体应用,日志意味着让服务事务将它们的日志消息写入单个文件。另一方面,在独立的微服务设置中,日志可能过于复杂和难以实现,尤其是在这些服务需要部署到不同的服务器或 Docker 容器时。如果使用的模块不适应异步服务,其日志机制甚至可能导致运行时问题。

对于同时支持异步和同步 API 服务且运行在 ASGI 服务器上的 FastAPI 实例,使用 Python 的日志模块总是会生成以下错误日志:

2021-11-08 01:17:22,336 - uvicorn.error - ERROR - Exception in ASGI application
Traceback (most recent call last):
  File "c:\alibata\development\language\python\
  python38\lib\site-packages\uvicorn\protocols\http\
  httptools_impl.py", line 371, in run_asgi
    result = await app(self.scope, self.receive, self.send)
  File "c:\alibata\development\language\python\
  python38\lib\site-packages\uvicorn\middleware\
  proxy_headers.py", line 59, in __call__
    return await self.app(scope, receive, send)

选择另一个日志扩展是避免由logging模块生成的错误的唯一解决方案。最佳选择是能够完全支持 FastAPI 框架的扩展,即loguru扩展。但首先,我们需要使用pip命令安装它:

pip install loguru

Loguru 是一个简单易用的日志扩展。我们可以立即使用其默认处理器,即sys.stderr处理器进行日志记录,甚至无需添加太多配置。由于我们的应用程序需要将所有消息放置在日志文件中,我们需要在顶级应用程序的main.py组件中FastAPI实例化之后添加以下行:

from loguru import logger
from uuid import uuid4
app = FastAPI()
app.include_router (university.router, 
         dependencies=[Depends(call_api_gateway)], 
         prefix='/ch04')
logger.add("info.log",format="Log: [{extra[log_id]}: 
{time} - {level} - {message} ", level="INFO", 
   enqueue = True)

注意,它的logger实例有一个add()方法,我们可以在这里注册sinkssinks的第一部分是handler,它决定是否在sys.stdout或文件中输出日志。在我们大学的 ERP 原型中,我们需要有一个全局的info.log文件,其中包含所有子应用程序的日志消息。

日志汇聚器的一个重要部分是level类型,它表示需要管理和记录的日志消息的粒度。如果我们将add()函数的level参数设置为INFO,这告诉记录器只考虑那些在INFOSUCCESSWARNINGERRORCRITICAL权重下的消息。记录器将跳过这些级别之外的日志消息。

sinks的另一部分是format日志,我们可以创建一个自定义的日志消息布局来替换其默认格式。这个格式就像一个没有"f"的 Python 插值字符串,其中包含占位符,如{time}{level}{message}以及任何需要在运行时由logger替换的自定义占位符。

log.file中,我们希望我们的日志以Log关键字开头,紧接着是自定义生成的log_id参数,然后是记录日志的时间、级别和消息。

为了添加对异步日志的支持,add()函数有一个enqueue参数,我们可以在任何时候启用它。在我们的情况下,这个参数默认为True,只是为了准备任何async/await执行。

Loguru 的功能和特性有很多可以探索。例如,我们可以为记录器创建额外的处理程序,每个处理程序都有不同的保留、轮换和渲染类型。此外,Loguru 还可以通过一些颜色标记,如<red><blue><cyan>,为我们添加颜色。它还有一个@catch()装饰器,可以在运行时管理异常。我们设置统一应用程序日志所需的所有日志功能都在 Loguru 中。现在我们已经配置了顶层应用程序中的 Loguru,我们需要让它的日志机制在没有修改代码的情况下跨三个子应用程序或微服务工作。

构建日志中间件

这个集中式应用程序日志的核心组件是我们必须在main.py组件中实现的自定义中间件。FastAPI 的*mount*允许我们集中处理一些横切关注点,如日志,而不需要在子应用程序中添加任何内容。顶层应用程序的main.py组件中的一个中间件实现就足够了,可以用于跨独立的微服务进行日志记录。以下是我们示例应用程序的中间件实现:

@app.middleware("http")
async def log_middleware(request:Request, call_next):
    log_id = str(uuid4())
    with logger.contextualize(log_id=log_id):
        logger.info('Request to access ' + 
             request.url.path)
        try:
            response = await call_next(request)
        except Exception as ex: 
            logger.error(f"Request to " + 
              request.url.path + " failed: {ex}")
            response = JSONResponse(content=
               {"success": False}, status_code=500)
        finally: 
            logger.info('Successfully accessed ' + 
               request.url.path)
            return response

首先,log_middleware()每次拦截来自主应用或子应用的任何 API 服务时,都会生成一个log_id参数。然后,通过 Loguru 的contextualize()方法将log_id参数注入到上下文信息的dict中,因为log_id是日志信息的一部分,正如我们在日志格式设置中所示。之后,在 API 服务执行之前和执行成功之后开始记录日志。如果在过程中遇到异常,记录器仍然会生成带有Exception消息的日志消息。因此,无论何时我们从 ERP 原型访问任何 API 服务,以下日志消息都将写入info.log

Log: 1e320914-d166-4f5e-a39b-09723e04400d: 2021-11-28T12:02:25.582056+0800 - INFO - Request to access /ch04/university/1 
Log: [1e320914-d166-4f5e-a39b-09723e04400d: 2021-11-28T12:02:25.597036+0800 - INFO - Successfully accessed /ch04/university/1 
Log: [fd3badeb-8d38-4aec-b2cb-017da853e3db: 2021-11-28T12:02:25.609162+0800 - INFO - Request to access /ch04/student/index 
Log: [fd3badeb-8d38-4aec-b2cb-017da853e3db: 2021-11-28T12:02:25.617177+0800 - INFO - Successfully accessed /ch04/student/index 
Log: [4cdb1a46-59c8-4762-8b4b-291041a95788: 2021-11-28T12:03:25.187495+0800 - INFO - Request to access /ch04/student/profile/add 
Log: [4cdb1a46-59c8-4762-8b4b-291041a95788: 2021-11-28T12:03:25.203421+0800 - 
INFO - Request to access /ch04/faculty/index 
Log: [5cde7503-cb5e-4bda-aebe-4103b2894ffe: 2021-11-28T12:03:33.432919+0800 - INFO - Successfully accessed /ch04/faculty/index 
Log: [7d237742-fdac-4f4f-9604-ce49d3c4c3a7: 2021-11-28T12:04:46.126516+0800 - INFO - Request to access /ch04/faculty/books/request/list 
Log: [3a496d87-566c-477b-898c-8191ed6adc05: 2021-11-28T12:04:48.212197+0800 - INFO - Request to access /ch04/library/book/request/list 
Log: [3a496d87-566c-477b-898c-8191ed6adc05: 2021-11-28T12:04:48.221832+0800 - INFO - Successfully accessed /ch04/library/book/request/list 
Log: [7d237742-fdac-4f4f-9604-ce49d3c4c3a7: 2021-11-28T12:04:48.239817+0800 - 
Log: [c72f4287-f269-4b21-a96e-f8891e0a4a51: 2021-11-28T12:05:28.987578+0800 - INFO - Request to access /ch04/library/book/add 
Log: [c72f4287-f269-4b21-a96e-f8891e0a4a51: 2021-11-28T12:05:28.996538+0800 - INFO - Successfully accessed /ch04/library/book/add

给定的日志消息快照证明我们有一个集中式设置,因为中间件过滤了所有 API 服务的执行并执行了日志事务。它显示日志从访问网关开始,一直延伸到执行来自教师学生图书馆子应用的 API 服务。使用 FastAPI 的挂载功能构建独立微服务时,集中和管理横切关注点是它可以提供的一个优势。

但是,当涉及到这些独立子应用之间的交互时,挂载是否也能成为优势?现在,让我们探索在我们的架构中,独立的微服务如何通过利用彼此的 API 资源进行通信。

消费 REST API 服务

就像在未挂载的微服务设置中一样,挂载的微服务也可以通过访问彼此的 API 服务进行通信。例如,如果一位教师或学生想要从图书馆借书,如何无缝地实现这种设置?

图 4.4中,我们可以看到,通过建立客户端-服务器通信,可以实现交互,其中一个 API 服务可以作为资源提供者,而其他的是客户端:

![图 4.4 – 与教师、学生和图书馆微服务的交互

![图 4.4 – 与教师、学生和图书馆微服务的交互

图 4.4 – 与教师、学生和图书馆微服务的交互

在 FastAPI 中,使用httpxrequests外部模块消费 API 资源可以非常直接。以下讨论将重点介绍这两个模块如何帮助我们挂载的服务相互交互。

使用 httpx 模块

httpx外部模块是一个 Python 扩展,可以消费异步和同步的 REST API,并支持HTTP/1.1HTTP/2。它是一个快速的多功能工具包,用于访问运行在基于 WSGI 平台上的 API 服务,以及像 FastAPI 服务这样的 ASGI。但首先,我们需要使用pip安装它:

pip install httpx

然后,我们可以直接使用它,无需进一步配置,以使两个微服务交互,例如,我们的学生模块向教师模块提交作业:

import httpx
@router.get('/assignments/list')
async def list_assignments(): 
   async with httpx.AsyncClient() as client:
    response = await client.get(
     "http://localhost:8000/ch04/faculty/assignments/list")
    return response.json()
@router.post('/assignment/submit')
def submit_assignment(assignment:AssignmentRequest ):
   with httpx.Client() as client:
      response = client.post("http://localhost:8000/
          ch04/faculty/assignments/student/submit",  
           data=json.dumps(jsonable_encoder(assignment)))
      return response.content

httpx 模块可以处理 GETPOSTPATCHPUTDELETE 路径操作。它可以允许在不那么复杂的条件下向请求的 API 传递不同的请求参数。例如,post() 客户端操作可以接受头信息、cookies、params、json、files 和模型数据作为参数值。我们使用 with 上下文管理器直接管理由其 Client()AsyncClient() 实例创建的流,这些流是可关闭的组件。

之前提到的 list_assignments 服务是一个客户端,它使用 AsyncClient() 实例从 f*aculty* 模块的异步 /ch04/faculty/assignments/list API 端点发起 GET 请求。AsyncClient 访问基于 WSGI 的平台以执行任何异步服务,而不是同步服务,否则会抛出 状态码 500。在某些复杂情况下,它可能需要在构造函数中提供额外的配置细节,以便进一步通过 ASGI 管理资源访问。

另一方面,submit_assignment 服务是一个同步客户端,它访问另一个同步端点 ch04/faculty/assignments/student/submit,这是一个 POST HTTP 操作。在这种情况下,使用 Client() 实例通过 POST 请求访问资源,将作业提交给 *Faculty* 模块。AssignmentRequest 是一个 BaseModel 对象,客户端需要将其填充后提交到请求端点。与 paramsjson 不同,它们直接作为 dict 传递,而 data 是一个模型对象,必须首先通过 jsonable_encoder()json.dumps() 转换为 dict,以便在 HTTP 上传输。新的转换后的模型成为 POST 客户端操作 data 参数的值。

当涉及到客户端服务的响应时,我们可以允许响应被模块的 contentjson() 处理为文本,或者作为 JSON 结果。现在这取决于客户端服务的需求,决定应用程序使用哪种响应类型。

使用 requests 模块

在微服务之间建立客户端-服务器通信的另一种选择是 requests 模块。尽管 httpxrequests 几乎兼容,但后者提供了其他功能,如自动重定向和显式会话处理。requests 的唯一问题是它不支持异步 API,并且在访问资源时性能较慢。尽管有其缺点,requests 模块仍然是 Python 微服务开发中消费 REST API 的标准方式。首先,我们需要安装它才能使用它:

pip install requests

在我们的 ERP 原型中,*faculty* 微服务使用了 requests 扩展从 *library* 模块借书。让我们看看 *Faculty* 客户端服务,它们展示了如何使用 requests 模块访问 *library* 的同步 API:

@router.get('/books/request/list')
def list_all_request(): 
    with requests.Session() as sess:
        response = sess.get('http://localhost:8000/
           ch04/library/book/request/list')
        return response.json()
@router.post('/books/request/borrow')
def request_borrow_book(request:BookRequestReq): 
    with requests.Session() as sess:
        response = sess.post('http://localhost:8000/
           ch04/library/book/request', 
             data=dumps(jsonable_encoder(request)))
        return response.content
@router.get('/books/issuance/list')
def list_all_issuance(): 
    with requests.Session() as sess:
        response = sess.get('http://localhost:8000/
            ch04/library/book/issuance/list')
        return response.json()
@router.post('/books/returning')
def return_book(returning: BookReturnReq): 
    with requests.Session() as sess:
        response = sess.post('http://localhost:8000/
            ch04/library/book/issuance/return', 
              data=dumps(jsonable_encoder(returning)))
        return response.json()

requests 模块有一个 Session() 实例,这在 httpx 模块中相当于 Client()。它提供了所有必要的客户端操作,这些操作将消耗 FastAPI 平台上的 API 端点。由于 Session 是一个可关闭的对象,因此上下文管理器再次被用于处理在访问资源和传输某些参数值时将使用的流。就像在 httpx 中一样,参数细节,如 paramsjsonheadercookiesfilesdata,也是 requests 模块的一部分,并且如果需要通过客户端操作通过 API 端点传输,它们就准备好了。

从前面的代码中,我们可以看到创建了会话以实现 list_all_requestlist_all_issuanceGET 客户端服务。在这里,request_borrow_book 是一个 POST 客户端服务,它从 /ch04/library/book/request API 端点以 BookRequestReq 的形式请求一本书。类似于 httpx,必须使用 jsonable_encoder()json.dumps()BaseModel 对象转换为 dict,以便作为 data 参数值传输。同样的方法也应用于 return_bookPOST 客户端服务,该服务返回由教师借阅的书籍。这些客户端服务的响应也可以是 contentjson(),就像我们在 httpx 扩展中看到的那样。

使用 requestshttpx 模块允许这些挂载的微服务根据某些规范相互交互。从其他微服务中消费公开的端点可以最小化紧密耦合并加强构建独立微服务的分解设计模式的重要性。

下一个技术为您提供使用 领域建模 管理微服务内组件的选项。

应用领域建模方法

专注于数据库的应用或由核心功能构建而不与模型协作的应用,在扩展时可能难以管理,或者在增强或修复错误时不够友好。背后的原因是缺乏遵循、研究和分析的业务逻辑结构和流程。理解应用程序的行为并推导出具有其背后业务逻辑的领域模型,在建立和组织应用程序结构时是最佳方法。这个原则被称为 领域建模方法,我们现在将将其应用于我们的 ERP 示例。

创建层

分层是应用领域驱动开发时不可避免的一种实现方式。之间存在依赖关系,有时在开发过程中修复错误时可能会带来问题。但在分层架构中重要的是分层可以创建的概念、结构、类别、功能以及角色,这有助于理解应用程序的规范。图 4.5显示了子应用程序的模型存储库服务控制器

![图 4.5 – 分层架构

图 4.5 – 分层架构

最关键的一层是models层,它由描述应用程序中涉及的领域和业务流程的领域模型类组成。

识别领域模型

领域模型层是应用程序的初始工件,因为它提供了应用程序的上下文框架。如果在开发的初始阶段首先确定了领域,则可以轻松地对业务流程和交易进行分类和管理。由领域分层创建的代码组织可以提供代码可追溯性,这可以简化源代码更新和调试。

在我们的 ERP 示例中,这些模型分为两类:数据模型和请求模型。数据模型是用于在临时数据存储中捕获和存储数据的模型,而请求模型是 API 服务中使用的BaseModel对象。

例如,教师模块有以下数据模型:

class Assignment: 
    def __init__(self, assgn_id:int, title:str, 
        date_due:datetime, course:str):
        self.assgn_id:int = assgn_id 
        self.title:str = title 
        self.date_completed:datetime = None
        self.date_due:datetime = date_due
        self.rating:float = 0.0 
        self.course:str = course

    def __repr__(self): 
      return ' '.join([str(self.assgn_id), self.title,
        self.date_completed.strftime("%m/%d/%Y, %H:%M:%S"),
        self.date_due.strftime("%m/%d/%Y, %H:%M:%S"), 
        str(self.rating) ])
    def __expr__(self): 
      return ' '.join([str(self.assgn_id), self.title, 
       self.date_completed.strftime("%m/%d/%Y, %H:%M:%S"), 
       self.date_due.strftime("%m/%d/%Y, %H:%M:%S"), 
        str(self.rating) ])
class StudentBin: 
    def __init__(self, bin_id:int, stud_id:int, 
      faculty_id:int): 
        self.bin_id:int = bin_id 
        self.stud_id:int = stud_id 
        self.faculty_id:int = faculty_id 
        self.assignment:List[Assignment] = list()

    def __repr__(self): 
        return ' '.join([str(self.bin_id), 
         str(self.stud_id), str(self.faculty_id)])
    def __expr__(self): 
        return ' '.join([str(self.bin_id), 
         str(self.stud_id), str(self.faculty_id)])

这些数据模型类在实例化时如果需要构造函数注入,其构造函数总是已实现。此外,__repr__()__str__()魔术方法可选地存在,以提供开发者在访问、读取和记录这些对象时的效率。

另一方面,请求模型是熟悉的,因为它们已经在上一章中讨论过了。此外,教师模块还有以下请求模型:

class SignupReq(BaseModel):     
    faculty_id:int
    username:str
    password:str
class FacultyReq(BaseModel): 
    faculty_id:int
    fname:str
    lname:str
    mname:str
    age:int
    major:Major
    department:str
class FacultyDetails(BaseModel): 
    fname:Optional[str] = None
    lname:Optional[str] = None
    mname:Optional[str] = None
    age:Optional[int] = None
    major:Optional[Major] = None
    department:Optional[str] = None

前面片段中列出的请求模型只是简单的BaseModel类型。有关如何创建BaseModel类的更多详细信息,请参阅第一章为初学者设置 FastAPI,它提供了创建不同类型的BaseModel类的指南,以捕获来自客户端的不同请求。

构建存储库和服务层

在构建这种方法的层级结构中至关重要的两个最流行的领域建模模式是 仓库服务层模式。仓库旨在创建管理数据访问的策略。一些仓库层仅提供数据存储的数据连接,就像我们这里的示例一样,但通常,仓库的目标是与 对象关系模型ORM)框架交互以优化和管理数据事务。但除了访问之外,这一层还为应用程序提供了一个高级抽象,使得特定的数据库技术或 方言 对应用程序来说并不重要。它充当任何数据库平台的适配器,以追求应用程序的数据事务,仅此而已。以下是一个 教师 模块的仓库类,它管理为他们的学生创建作业的领域:

from fastapi.encoders import jsonable_encoder
from typing import List, Dict, Any
from faculty_mgt.models.data.facultydb import 
     faculty_assignments_tbl
from faculty_mgt.models.data.faculty import Assignment
from collections import namedtuple
class AssignmentRepository: 

    def insert_assignment(self, 
           assignment:Assignment) -> bool: 
        try:
            faculty_assignments_tbl[assignment.assgn_id] = 
                assignment
        except: 
            return False 
        return True

    def update_assignment(self, assgn_id:int, 
           details:Dict[str, Any]) -> bool: 
       try:
           assignment = faculty_assignments_tbl[assgn_id]
           assignment_enc = jsonable_encoder(assignment)
           assignment_dict = dict(assignment_enc)
           assignment_dict.update(details)         
           faculty_assignments_tbl[assgn_id] =   	 	 	           Assignment(**assignment_dict)
       except: 
           return False 
       return True

    def delete_assignment(self, assgn_id:int) -> bool: 
        try:
            del faculty_assignments_tbl[assgn_id] 
        except: 
            return False 
        return True

    def get_all_assignment(self):
        return faculty_assignments_tbl 

在这里,AssignmentRepository 使用其四个仓库事务管理 Assignment 领域对象。此外,insert_assignment()faculty_assignment_tbl 字典中创建一个新的 Assignment 条目,而 update_assignment() 接受现有作业的新细节或更正信息并更新它。另一方面,delete_assignment() 使用其 assign_id 参数从数据存储中删除现有的 Assignment 条目。为了检索所有创建的作业,仓库类有 get_all_assignment(),它返回 faculty_assignments_tbl 中的所有条目。

服务层模式定义了应用程序的算法、操作和流程。通常,它与仓库交互以构建应用程序其他组件(如 API 服务或控制器)所需的基本业务逻辑、管理和控制。通常,一个服务针对一个或多个仓库类,具体取决于项目的具体要求。以下是一个服务示例,它通过接口仓库提供额外的任务,例如为学生工作区生成 UUID:

from typing import List, Dict , Any
from faculty_mgt.repository.assignments import  
            AssignmentSubmissionRepository
from faculty_mgt.models.data.faculty import Assignment
from uuid import uuid4
class AssignmentSubmissionService: 

    def __init__(self): 
        self.repo:AssignmentSubmissionRepository = 
            AssignmentSubmissionRepository()

    def create_workbin(self, stud_id:int, faculty_id:int): 
        bin_id = uuid4().int
        result = self.repo.create_bin(stud_id, bin_id, 
                     faculty_id )
        return (result, bin_id)

    def add_assigment(self, bin_id:int, 
                   assignment: Assignment): 
        result = self.repo.insert_submission(bin_id, 
                     assignment ) 
        return result

    def remove_assignment(self, bin_id:int, 
                   assignment: Assignment): 
        result = self.repo.insert_submission(bin_id, 
                      assignment )
        return result
    def list_assignments(self, bin_id:int): 
        return self.repo.get_submissions(bin_id)

在前面的代码中引用的 AssignmentSubmissionService 有使用 AssignmentSubmissionRepository 事务的方法。它为它们提供参数,并返回 bool 结果供其他组件评估。其他服务可能比这个示例更复杂,因为算法和任务通常被添加以满足层的要求。

仓库类与服务之间的成功连接发生在后者的构造函数中。通常,仓库类就像在先前的示例中那样被实例化。另一个绝佳的选择是使用前面讨论的 DI(依赖注入),见 第三章

使用工厂方法模式

工厂方法设计模式始终是使用Depends()组件管理可注入类和函数的一个好方法。第三章展示了如何将工厂方法作为将存储库组件注入服务的中介,而不是直接在服务内部实例化它们。这种设计模式提供了组件或层之间的松耦合。这种方法非常适合大型应用程序,其中一些模块和子组件被重用和继承。

现在,让我们看看顶级应用程序如何管理这些挂载的和独立的微服务应用程序的不同配置细节。

管理微服务的配置细节

到目前为止,本章为我们提供了一些流行的设计模式和策略,这些模式和策略可以帮助我们了解如何为我们的 FastAPI 微服务提供最佳的结构和架构。这次,让我们探索 FastAPI 框架如何支持将存储、分配和读取配置细节到挂载的微服务应用程序中,例如数据库凭证、网络配置数据、应用程序服务器信息和部署细节。首先,我们需要使用pip安装python-dotenv

pip install python-dotenv

所有这些设置都是外部于微服务应用程序实现的值。我们通常将它们存储在env属性INI文件中,而不是将它们作为变量数据硬编码到代码中。然而,将这些设置分配给不同的微服务时会出现挑战。

支持外部化配置设计模式的框架具有一个内部处理功能,可以获取环境变量或设置,而无需额外的解析或解码技术。例如,FastAPI 框架通过 pydantic 的BaseSettings类内置了对外部化设置的 支持。

将设置存储为类属性

在我们的架构设置中,应该是顶级应用程序来管理外部化值。一种方法是将它们作为属性存储在BaseSettings类中。以下是与各自应用程序详情相关的BaseSettings类型的类:

from pydantic import BaseSettings
from datetime import date
class FacultySettings(BaseSettings): 
    application:str = 'Faculty Management System' 
    webmaster:str = 'sjctrags@university.com'
    created:date = '2021-11-10'
class LibrarySettings(BaseSettings): 
    application:str = 'Library Management System' 
    webmaster:str = 'sjctrags@university.com'
    created:date = '2021-11-10' 
class StudentSettings(BaseSettings): 
    application:str = 'Student Management System' 
    webmaster:str = 'sjctrags@university.com'
    created:date = '2021-11-10'

在这里,FacultySettings将被分配给学院模块,因为它携带有关模块的一些信息。LibrarySettings用于图书馆模块,而StudentSettings用于学生模块。

要获取值,首先,模块中的一个组件必须从主项目的/configuration/config.py模块中导入BaseSettings类。然后,它需要一个可注入的函数来在将其注入到需要利用这些值的组件之前实例化它。以下脚本是/student_mgt/student_main.py的一部分,其中需要检索设置:

from configuration.config import StudentSettings
student_app = FastAPI()
student_app.include_router(reservations.router)
student_app.include_router(admin.router)
student_app.include_router(assignments.router)
student_app.include_router(books.router)
def build_config(): 
    return StudentSettings()
@student_app.get('/index')
def index_student(
   config:StudentSettings = Depends(build_config)): 
    return {
        'project_name': config.application,
        'webmaster': config.webmaster,
        'created': config.created
      }

在这里,build_config() 是一个可注入的函数,它将 StudentSettings 实例注入到 学生 微服务的 /index 端点。在 DI 之后,应用程序、网站管理员和创建的值将可以通过 config 连接对象访问。这些设置将在调用 /ch04/university/1 网关 URL 后立即出现在浏览器上。

在属性文件中存储设置

另一个选项是将所有这些设置存储在一个扩展名为 .env.properties.ini 的物理文件中。例如,这个项目在 /configuration 文件夹中有一个名为 erp_settings.properties 的文件,它以 键值对 格式包含以下应用程序服务器细节:

production_server = prodserver100
prod_port = 9000
development_server = devserver200
dev_port = 10000

为了获取这些细节,应用程序需要另一个 BaseSettings 类的实现,该实现声明 键值对 作为属性。以下类展示了如何声明 production_serverprod_portdevelopment_serverdev_port 而不分配任何值:

import os
class ServerSettings(BaseSettings): 
    production_server:str
    prod_port:int
    development_server:str 
    dev_port:int

    class Config: 
        env_file = os.getcwd() + 
           '/configuration/erp_settings.properties'

除了类变量声明之外,BaseSetting 还需要一个实现一个 内部类,称为 Config,它将预定义的 env_file 分配给属性文件当前的位置。

当涉及到从文件访问属性细节时,涉及到的过程是相同的。在导入 ServerSettings 之后,它需要一个可注入的函数来注入其实例到需要这些细节的组件中。以下脚本是 /student_mgt/student_main.py 的更新版本,其中包括对 development_serverdevelopment_port 设置的访问:

from fastapi import FastAPI, Depends
from configuration.config import StudentSettings, 
      ServerSettings
student_app = FastAPI()
student_app.include_router(reservations.router)
student_app.include_router(admin.router)
student_app.include_router(assignments.router)
student_app.include_router(books.router)
def build_config(): 
    return StudentSettings()
def fetch_config():
    return ServerSettings()
@student_app.get('/index')
def index_student(
     config:StudentSettings = Depends(build_config), 
     fconfig:ServerSettings = Depends(fetch_config)): 
    return {
        'project_name': config.application,
        'webmaster': config.webmaster,
        'created': config.created,
        'development_server' : fconfig.development_server,
        'dev_port': fconfig.dev_port
      }

基于这个增强的脚本,运行 /ch04/university/1 URL 将会将浏览器重定向到一个显示来自属性文件的额外服务器详细信息的屏幕。在 FastAPI 中管理配置细节非常简单,因为我们要么将它们保存在类内部,要么保存在文件内部。不需要外部模块,也不需要特殊的编码工作来获取所有这些设置,只需创建 BaseSettings 类即可。这种简单的设置有助于构建灵活且适应性强的微服务应用程序,这些应用程序可以在不同的配置细节上运行。

摘要

本章从分解模式开始,这种模式对于将单体应用程序分解为粒度化、独立和可扩展的模块非常有用。实现了这些模块的 FastAPI 应用展示了包括在微服务 12-Factor 应用原则 中的某些原则,例如具有独立性、配置文件、日志系统、代码库、端口绑定、并发性和易于部署。

除了分解之外,本章还展示了将不同的独立子应用安装到 FastAPI 平台上的过程。只有 FastAPI 能够使用安装功能将独立的微服务分组,并将它们绑定到一个端口上,同时使用相应的上下文根。从这个特性中,我们创建了一个伪 API 网关模式,该模式作为独立子应用的门面。

尽管可能存在一些缺点,但本章还强调了领域建模作为在 FastAPI 微服务中组织组件的选项。领域存储库服务层有助于根据项目规范管理信息流和任务分配。当领域层就绪时,跟踪、测试和调试变得容易。

在下一章中,我们将专注于将我们的微服务应用程序与关系型数据库平台集成。重点是建立数据库连接并利用我们的数据模型在存储库层实现 CRUD 事务。

第二部分:以数据为中心和以通信为重点的微服务关注点和问题

在本书的这一部分,我们将探讨其他 FastAPI 组件和功能,以解决 API 框架可以构建的其他设计模式,包括数据、通信、消息、可靠性和安全性。外部模块也将被突出显示,以追求其他行为和框架,例如 ORM 和响应式编程。

本部分包括以下章节:

  • 第五章,连接到关系型数据库

  • 第六章,使用非关系型数据库

  • 第七章,保护 REST API 的安全

  • 第八章,创建协程、事件和消息驱动的交易

第五章:连接到关系数据库

我们之前的应用程序仅使用 Python 集合来存储数据记录,而不是持久数据存储。这种设置会在 Uvicorn 服务器重启时导致数据丢失,因为这些集合仅在 易失性内存,如 RAM 中存储数据。从本章开始,我们将应用数据持久性以避免数据丢失,并提供一个平台来管理我们的记录,即使在服务器关闭模式下也是如此。

本章将重点介绍不同的 对象关系映射器ORMs),这些 ORM 可以有效地使用对象和关系数据库来管理客户端数据。对象关系映射是一种技术,其中 SQL 语句用于 创建读取更新删除CRUD)在面向对象的编程方法中实现和执行。ORM 需要将所有关系或表映射到相应的实体或模型类,以避免与数据库平台的紧密耦合连接。这些模型类是用于连接到数据库的类。

除了介绍 ORM,本章还将讨论一个名为 命令和查询责任分离CQRS)的设计模式,该模式可以帮助在域级别解决读写 ORM 事务之间的冲突。CQRS 可以帮助最小化读写 SQL 事务的运行时间,与数据建模方法相比,随着时间的推移可以提高应用程序的整体性能。

总体而言,本章的主要目标是证明 FastAPI 框架支持所有流行的 ORM,为应用程序提供后端数据库访问,它通过使用流行的关系数据库管理系统来实现,并通过使用 CQRS 设计模式对 CRUD 事务进行优化。

在本章中,我们将涵盖以下主题:

  • 准备数据库连接

  • 使用 SQLAlchemy 创建同步 CRUD 事务

  • 使用 SQLAlchemy 实现异步 CRUD 事务

  • 使用 GINO 进行异步 CRUD 事务

  • 使用 Pony ORM 进行存储库层

  • 使用 Peewee 构建存储库

  • 应用 CQRS 设计模式

技术要求

为本章创建的应用程序原型被称为 健身俱乐部管理系统;它服务于会员和健身房健身运营。此原型具有管理、会员、课程管理和出勤模块,这些模块利用 ch05ach05b 项目。

准备数据库连接

在我们开始讨论 FastAPI 中的数据库连接之前,让我们考虑一些与应用程序相关的问题:

  • 首先,从本章开始,所有应用程序原型都将使用 PostgreSQL 作为唯一的关联数据库管理系统。我们可以从 www.enterprisedb.com/downloads/postgres-postgresql-downloads 下载其安装程序。

  • 其次,健身俱乐部管理系统 原型有一个名为 fcms 的现有数据库,包含六个表,分别是 signuploginprofile_membersprofile_trainersattendance_membergym_class。所有这些表及其元数据和关系都可以在以下图中看到:

图 5.1 – fcms 表格

图 5.1 – fcms 表格

项目文件夹中包含一个名为 fcms_postgres.sql 的脚本,用于安装所有这些模式。

现在我们已经安装了最新版本的 PostgreSQL 并运行了 fcms 脚本文件,让我们来了解 SQLAlchemy,这是 Python 场景中最广泛使用的 ORM 库。

重要提示

本章将比较和对比不同 Python ORM 的功能。在这个实验设置中,每个项目都将拥有多种数据库连接,这与每个项目只有一个数据库连接的惯例相悖。

使用 SQLAlchemy 创建 CRUD 事务

SQLAlchemy 是最受欢迎的 ORM 库,能够建立任何基于 Python 的应用程序和数据库平台之间的通信。它之所以可靠,是因为它持续更新和测试,以确保其 SQL 读写操作高效、高性能和准确。

这个 ORM 是一个模板化接口,旨在创建一个数据库无关的数据层,可以连接到任何数据库引擎。但与其他 ORM 相比,SQLAlchemy 更受数据库管理员(DBA)的欢迎,因为它可以生成优化的原生 SQL 语句。在制定其查询时,它只需要 Python 函数和表达式来执行 CRUD 操作。

在我们开始使用 SQLAlchemy 之前,请使用以下命令检查您系统中是否已安装该模块:

pip list 

如果 SQLAlchemy 不在列表中,请使用 pip 命令进行安装:

pip install SQLAlchemy

目前,开发 健身俱乐部管理系统 应用程序所使用的版本是 1.4

安装数据库驱动程序

SQLAlchemy 没有所需的数据库驱动程序将无法工作。由于选择的是 PostgreSQL 数据库,因此必须安装 psycopg2 方言:

pip install psycopg2

Psycopg2 是一个符合 DB API 2.0-compliant 的 PostgreSQL 驱动程序,可以进行连接池管理,并且可以与多线程 FastAPI 应用程序一起工作。这个包装器或方言对于构建我们的应用程序的同步 CRUD 事务也是必不可少的。一旦安装,我们就可以开始查看 SQLAlchemy 的数据库配置细节。所有与 SQLAlchemy 相关的代码都可以在 ch05a 项目中找到。

设置数据库连接

为了连接到任何数据库,SQLAlchemy 需要一个管理连接池和已安装方言的引擎。create_engine()函数来自sqlalchemy模块,是引擎对象的来源。但为了成功派生它,create_engine()需要一个配置好的数据库 URL 字符串。这个 URL 字符串包含数据库名称数据库 API 驱动程序账户凭证、数据库服务器的IP 地址和其端口。以下脚本展示了如何创建将在我们的健身俱乐部管理系统原型中使用的引擎:

from sqlalchemy import create_engine
DB_URL =   
   "postgresql://postgres:admin2255@localhost:5433/fcms"
engine = create_engine(DB_URL)

engine是一个全局对象,必须在整个应用程序中只创建一次。它的第一个数据库连接发生在应用程序的第一个 SQL 事务之后,因为它遵循了延迟初始化设计模式。

此外,前一个脚本中的engine对于创建将被 SQLAlchemy 用于执行 CRUD 事务的 ORM 会话至关重要。

初始化会话工厂

SQLAlchemy 中的所有 CRUD 事务都是由会话驱动的。每个会话管理一组数据库“写入”和“读取”,并检查是否执行它们。例如,它维护一组已插入、更新和删除的对象,检查更改是否有效,然后与 SQLAlchemy 核心协调,如果所有事务都已验证,则将更改推进到数据库。它遵循工作单元设计模式的行为。SQLAlchemy 依赖于会话来保证数据的一致性和完整性。

但在我们创建会话之前,数据层需要一个绑定到派生引擎的会话工厂。ORM 从sqlalchemy.orm模块中有一个sessionmaker()指令,它需要一个engine对象。以下脚本展示了如何调用sessionmaker()

from sqlalchemy.orm import sessionmaker
engine = create_engine(DB_URL)
SessionFactory = sessionmaker(autocommit=False, 
                     autoflush=False, bind=engine)

除了引擎绑定之外,我们还需要将会话的autocommit属性设置为False以强制执行commit()rollback()事务。应用程序应该是将所有更改刷新到数据库的那个,因此我们还需要将其autoflush功能设置为False

应用程序可以通过SessionFactory()调用创建多个会话,但每个APIRouter拥有一个会话是推荐的。

定义 Base 类

接下来,我们需要设置Base类,这在将模型类映射到数据库表中至关重要。尽管 SQLAlchemy 可以在运行时创建表,但我们选择利用现有的模式定义来构建我们的原型。现在,这个Base类必须由模型类继承,以便在服务器启动时发生到表的映射。以下脚本展示了设置此组件是多么简单:

from sqlalchemy.ext.declarative import declarative_base
Base = declarative_base()

调用declarative_base()函数是创建Base类而不是创建registry()来调用generate_base()的最简单方法,这也可以为我们提供Base类。

注意,所有这些配置都是原型 /db_config/sqlalchemy_connect.py 模块的一部分。由于它们在构建 SQLAlchemy 存储库中至关重要,因此它们被捆绑在一个模块中。但在我们实现 CRUD 事务之前,我们需要使用 Base 类创建模型层。

构建模型层

SQLAlchemy 的模型类已放置在健身俱乐部项目文件夹的 /models/data/sqlalchemy_models.py 文件中。如果 BaseModel 对 API 请求模型很重要,那么 Base 类在构建数据层时是必不可少的。它从配置文件导入以定义 SQLAlchemy 实体或模型。以下代码来自模块脚本,展示了我们如何在 SQLAlchemy ORM 中创建模型类定义:

from sqlalchemy import Time, Boolean, Column, Integer, 
    String, Float, Date, ForeignKey
from sqlalchemy.orm import relationship
from db_config.sqlalchemy_connect import Base
class Signup(Base):
    __tablename__ = "signup"
    id = Column(Integer, primary_key=True, index=True)
    username = Column('username', String, unique=False, 
                       index=False)
    password = Column('password' ,String, unique=False, 
                       index=False)

Signup 类是 SQLAlchemy 模型的示例,因为它继承了 Base 类的属性。它是一个映射类,因为所有其属性都是其物理表模式对应项的列元数据的反映。该模型将 primary_key 属性设置为 True,因为 SQLAlchemy 建议每个表模式至少有一个主键。其余的 Column 对象映射到非主键但可以是 唯一索引 的列元数据。每个模型类继承 __tablename__ 属性,该属性设置映射表的名称。

最重要的是,我们需要确保类属性的数据类型与其在表模式中列对应的列类型相匹配。列属性必须与列对应项具有相同的名称。否则,我们需要在 Column 类的第一个参数中指定实际的列名,如 Signup 中的 usernamepassword 列所示。但大多数情况下,我们必须始终确保它们相同,以避免混淆。

映射表关系

SQLAlchemy 强烈支持不同类型的父子或关联表关系。参与关系的模型类需要使用 sqlalchemy.orm 模块中的 relationship() 指令来利用模型类之间的一对多或一对一关系。此指令通过表模式定义中指示的外键从父类创建对子类的引用。

一个子模型类使用其外键列对象中的 ForeignKey 构造来将其模型类与其父类的参考键列对象链接。此指令表示该列中的值应包含在父表参考列中存储的值内。ForeignKey 指令适用于主键和非主键的 Column 对象。以下模型类定义了我们数据库模式中的一个示例列关系:

class Login(Base): 
    __tablename__ = "login"

    id = Column(Integer, primary_key=True, index=True)
    username = Column(String, unique=False, index=False)
    password = Column(String, unique=False, index=False)
    date_approved = Column(Date, unique=False, index=False)
    user_type = Column(Integer, unique=False, index=False)

    trainers = relationship('Profile_Trainers', 
         back_populates="login", uselist=False)
    members = relationship('Profile_Members', 
         back_populates="login", uselist=False)

Login 模型根据其配置与两个子类 Profile_TrainersProfile_Members 链接。这两个子模型在其 id 列对象中都有 ForeignKey 指令,如下面的模型定义所示:

class Profile_Trainers(Base):
    __tablename__ = "profile_trainers"
    id = Column(Integer, ForeignKey('login.id'), 
         primary_key=True, index=True, )
    firstname = Column(String, unique=False, index=False)
    … … … … …
    … … … … …
    login = relationship('Login', 
         back_populates="trainers")
    gclass = relationship('Gym_Class', 
         back_populates="trainers")

class Profile_Members(Base): 
    __tablename__ = "profile_members"
    id = Column(Integer, ForeignKey('login.id'), 
         primary_key=True, index=True)
    firstname = Column(String, unique=False, index=False)
    lastname = Column(String, unique=False, index=False)
    age = Column(Integer, unique=False, index=False)
    … … … … … …
    … … … … … …
    trainer_id = Column(Integer, 
        ForeignKey('profile_trainers.id'), unique=False, 
        index=False)
    login = relationship('Login', back_populates="members")
    attendance = relationship('Attendance_Member', 
          back_populates="members")
    gclass = relationship('Gym_Class', 
          back_populates="members") 

relationship() 指令是创建表关系的唯一指令。我们需要指定一些其参数,例如 子模型类的名称反向引用指定back_populates 参数指的是相关模型类的互补属性名称。这表示在连接查询事务期间需要使用某些关系加载技术获取的行。backref 参数也可以用来代替 back_populates

另一方面,relationship() 方法可以返回一个 List 或标量对象,具体取决于关系类型。如果是一个 一对一 类型,父类应该将 useList 参数设置为 False 以指示它将返回一个标量值。否则,它将从子表中选取记录列表。之前的 Login 类定义显示 Profile_TrainersProfile_MembersLogin 之间保持一对一关系,因为 Login 将其 uselist 设置为 False。另一方面,Profile_MembersAttendance_Member 之间的模型关系是一个 一对多 类型,因为默认情况下 uselist 被设置为 True,如下面的定义所示:

class Attendance_Member(Base):
    __tablename__ = "attendance_member"
    id = Column(Integer, primary_key=True, index=True)
    member_id = Column(Integer, 
        ForeignKey('profile_members.id'), unique=False, 
        index=False)
    timeout = Column(Time, unique=False, index=False)
    timein = Column(Time, unique=False, index=False)
    date_log = Column(Date, unique=False, index=False)

    members = relationship('Profile_Members', 
             back_populates="attendance")

在设置模型关系时,我们还必须考虑这些相关模型类在连接查询事务期间将使用的 关系加载类型。我们在 relationship()lazy 参数中指定此细节,默认情况下分配给 select。这是因为 SQLAlchemy 默认使用懒加载技术在检索连接查询。然而,您可以将其修改为使用 joined (lazy="joined"), subquery (lazy="subquery"), select in (lazy="selectin"), raise (lazy="raise"), 或 no (lazy="no") 加载。在这些选项中,joined 方法更适合 INNER JOIN 事务。

实现仓库层

在 SQLAlchemy ORM 中,创建仓库层需要 模型类 和一个 Session 对象。Session 对象由 SessionFactory() 指令派生,它建立了与数据库的所有通信,并在 commit()rollback() 事务之前管理所有模型对象。当涉及到查询时,Session 实体将记录的结果集存储在一个称为 身份映射 的数据结构中,该映射使用主键维护每个数据记录的唯一标识。

所有仓库事务都是 无状态的,这意味着在数据库执行 commit()rollback() 操作后,会自动关闭加载模型对象用于插入、更新和删除事务的会话。我们从 sqlalchemy.orm 模块导入 Session 类。

构建 CRUD 事务

现在,我们可以开始构建健身俱乐部应用的仓库层了,因为我们已经满足了构建 CRUD 事务的要求。下面的 SignupRepository 类是蓝图,它将展示我们如何 插入更新删除检索 记录到/从 signup 表:

from typing import Dict, List, Any
from sqlalchemy.orm import Session
from models.data.sqlalchemy_models import Signup
from sqlalchemy import desc
class SignupRepository: 

    def __init__(self, sess:Session):
        self.sess:Session = sess

    def insert_signup(self, signup: Signup) -> bool: 
        try:
            self.sess.add(signup)
            self.sess.commit()
        except: 
            return False 
        return True

到目前为止,insert_signup() 是使用 SQLAlchemy 将记录持久化到 signup 表的最准确的方法。Session 有一个 add() 方法,我们可以调用它来将所有记录对象添加到表中,以及一个 commit() 事务来最终将所有新记录刷新到数据库中。Sessionflush() 方法有时会代替 commit() 来执行插入并关闭 Session,但大多数开发者通常使用后者。请注意,signup 表包含所有想要获取系统访问权限的健身房会员和教练。现在,下一个脚本实现了更新记录事务:

    def update_signup(self, id:int, 
           details:Dict[str, Any]) -> bool: 
       try:
             self.sess.query(Signup).
                 filter(Signup.id == id).update(details)     
             self.sess.commit() 
       except: 
           return False 
       return True

update_signup() 提供了一个简短、直接且健壮的解决方案来更新 SQLAlchemy 中的记录。另一种可能的解决方案是通过 self.sess.query(Signup).filter(Signup.id == id).first() 查询记录,用 details 字典中的新值替换检索到的对象的属性值,然后调用 commit()。这种方式是可以接受的,但它需要三个步骤,而不是在 filter() 之后调用 update() 方法,后者只需要一步。下一个脚本是一个删除记录事务的实现:

    def delete_signup(self, id:int) -> bool: 
        try:
           signup = self.sess.query(Signup).
                  filter(Signup.id == id).delete()
           self.sess.commit()
        except: 
            return False 
        return True

另一方面,delete_signup() 遵循 update_signup() 的策略,在调用 delete() 之前先使用 filter()。另一种实现方式是再次使用 sess.query() 检索对象,并将检索到的对象作为参数传递给 Session 对象的 delete(obj),这是一个不同的函数。始终记得调用 commit() 来刷新更改。现在,以下脚本展示了如何实现查询事务:

    def get_all_signup(self):
        return self.sess.query(Signup).all() 
    def get_all_signup_where(self, username:str):
        return self.sess.
             query(Signup.username, Signup.password).
             filter(Signup.username == username).all() 

    def get_all_signup_sorted_desc(self):
        return self.sess.
            query(Signup.username,Signup.password).
            order_by(desc(Signup.username)).all()

    def get_signup(self, id:int): 
        return self.sess.query(Signup).
             filter(Signup.id == id).one_or_none()

此外,SignupRepository 还突出了以多种形式检索多个和单个记录。Session 对象有一个 query() 方法,它需要一个或多个 模型类模型列名 作为参数。函数参数执行带有列投影的记录检索。例如,给定的 get_all_signup() 选择了所有带有所有列投影的注册记录。如果我们只想包括 usernamepassword,我们可以将查询写成 sess.query(Signup.username, Signup.password),就像在给定的 get_all_signup_where() 中一样。这个 query() 方法还展示了如何使用 filter() 方法以及适当的条件表达式来管理约束。过滤总是在列投影之后进行。

另一方面,Session对象有一个order_by()方法,它接受列名作为参数。它在查询事务的提取之前执行,是查询事务系列中的最后一个操作。给定的示例get_all_signup_sorted_desc()username降序排序所有Signup对象。

query()构建器的最后一部分返回事务的结果,无论是记录列表还是单个记录。all()函数结束返回多个记录的查询语句,而first()scalar()one()one_or_none()可以应用于结果为单行的情况。在get_signup()中,使用one_or_none()在无记录返回时引发异常。对于 SQLAlchemy 的查询事务,所有这些函数都可以关闭Session对象。SQLAlchemy 的存储库类位于ch05a文件夹的/repository/sqlalchemy/signup.py模块脚本文件中。

创建 JOIN 查询

对于 FastAPI 支持的所有 ORM,只有 SQLAlchemy 实现了具有实用性和功能的联合查询,就像我们之前实现 CRUD 事务一样。我们几乎使用了创建联合查询所需的所有方法,除了join()

让我们看看LoginMemberRepository,它展示了如何使用 SQLAlchemy 的模型类在一对一关系中创建联合查询语句:

class LoginMemberRepository(): 
    def __init__(self, sess:Session):
        self.sess:Session = sess

    def join_login_members(self):
        return self.sess.
           query(Login, Profile_Members).
             filter(Login.id == Profile_Members.id).all()

join_login_members()展示了创建JOIN查询的传统方法。此解决方案需要传递父类和子类作为查询参数,并通过filter()方法覆盖ON条件。在query()构建器中,父模型类必须在子类之前出现在列投影中,以便提取所需的结果。

另一种方法是使用select_from()函数而不是query()来区分父类和子类。这种方法更适合一对一关系。

另一方面,MemberAttendanceRepository展示了Profile_MembersAttendance_Member模型类之间的一对多关系:

class MemberAttendanceRepository(): 
    def __init__(self, sess:Session):
        self.sess:Session = sess

    def join_member_attendance(self):
        return self.sess.
           query(Profile_Members, Attendance_Member).
           join(Attendance_Member).all()
    def outer_join_member(self):
         return self.sess.
            query(Profile_Members, Attendance_Member).
            outerjoin(Attendance_Member).all()

join_member_attendance()展示了在构建Profile_MembersAttendance_Member之间的内连接查询时join()方法的使用。由于join()自动检测并识别在开头定义的relationship()参数和ForeignKey构造,因此不再需要filter()来构建ON条件。但如果存在其他附加约束,filter()始终可以调用,但必须在join()方法之后。

outer_join_member()存储库方法实现了从一对多关系中的外连接查询。outerjoin()方法将提取所有映射到相应Attendance_MemberProfile_Members记录,如果没有,则返回null

执行事务

现在,让我们将这些存储库事务应用到我们应用程序的与行政相关的 API 服务中。我们不会使用集合来存储所有记录,而是将利用 ORM 的事务来使用 PostgreSQL 管理数据。首先,我们需要导入存储库所需的必要组件,例如SessionFactory、存储库类和Signup模型类。像Session和其他typing API 这样的 API 只能作为实现类型提示的一部分。

以下脚本展示了管理员 API 服务的一部分,突出了新访问注册的插入和检索服务:

from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse
from sqlalchemy.orm import Session
from db_config.sqlalchemy_connect import SessionFactory
from repository.sqlalchemy.signup import SignupRepository,
   LoginMemberRepository, MemberAttendanceRepository
from typing import List
router = APIRouter()
def sess_db():
    db = SessionFactory()
    try:
        yield db
    finally:
        db.close()

首先,我们需要通过SessionFactory()创建Session实例,这是从sessionmaker()派生出来的,因为存储库层依赖于会话。在我们的应用程序中,使用了一个自定义生成器sess_db()来打开和销毁Session实例。它被注入到 API 服务方法中,以告诉Session实例继续实例化SignupRepository

@router.post("/signup/add")
def add_signup(req: SignupReq, 
          sess:Session = Depends(sess_db)):
    repo:SignupRepository = SignupRepository(sess)
    signup = Signup(password= req.password, 
                 username=req.username,id=req.id)
    result = repo.insert_signup(signup)
    if result == True:
        return signup
    else: 
        return JSONResponse(content={'message':'create 
                  signup problem encountered'}, 
                status_code=500)

一旦实例化,存储库可以通过insert_signup()方法提供记录插入,该方法插入Signup记录。它的另一个方法是get_all_signup(),该方法检索所有待批准的登录账户:

@router.get("/signup/list", response_model=List[SignupReq])
def list_signup(sess:Session = Depends(sess_db)):
    repo:SignupRepository = SignupRepository(sess)
    result = repo.get_all_signup()
    return result
@router.get("/signup/list/{id}", response_model=SignupReq)
def get_signup(id:int, sess:Session = Depends(create_db)): 
    repo:SignupRepository = SignupRepository(sess)
    result = repo.get_signup(id)
    return result

get_signup()list_signup()服务都有一个SignupReq类型的request_model,这决定了 API 的预期输出。但正如你可能已经注意到的,get_signup()返回Signup对象,而list_signup()返回Signup记录的列表。这是如何实现的?如果request_model用于捕获 SQLAlchemy 查询事务的查询结果,则BaseModel类或请求模型必须包含一个嵌套的Config类,其orm_mode设置为True。这个内置配置在所有记录对象被过滤并存储在请求模型之前,为存储库使用的 SQLAlchemy 模型类型启用类型映射和验证。有关response_model参数的更多信息,请参阅第一章为初学者设置 FastAPI

我们应用程序的查询服务使用的SignupReq定义如下:

from pydantic import BaseModel
class SignupReq(BaseModel): 
    id : int 
    username: str 
    password: str 

    class Config:
        orm_mode = True

脚本展示了如何使用等号(=)而不是典型的冒号符号(:)来启用orm_mode,这意味着orm_mode是配置细节,而不是类属性的一部分。

总体而言,使用 SQLAlchemy 作为存储库层是系统化和程序化的。它很容易将模型类与模式定义进行映射和同步。通过模型类建立关系既方便又可预测。尽管涉及许多 API 和指令,但它仍然是领域建模和存储库构建最广泛支持的库。它的文档(docs.sqlalchemy.org/en/14/)完整且信息丰富,足以指导开发者了解不同的 API 类和方法。

SQLAlchemy 受到许多人喜爱的另一个特性是它能够在应用级别生成表模式。

创建表

通常,SQLAlchemy 与数据库管理员已经生成的表模式一起工作。在这个项目中,ORM 设置是从设计领域模型类开始的,然后将其映射到实际的表。但 SQLAlchemy 可以为 FastAPI 平台在运行时自动创建表模式,这在项目的测试或原型阶段可能很有帮助。

sqlalchemy模块有一个Table()指令,可以使用Column()方法创建一个表对象,该方法我们在映射中使用过。以下是一个示例脚本,展示了 ORM 如何在应用级别创建注册表:

from sqlalchemy import Table, Column, Integer, String, 
               MetaData
from db_config.sqlalchemy_connect import engine
meta = MetaData()
signup = Table(
   'signup', meta, 
   Column('id', Integer, primary_key = True, 
          nullable=False), 
   Column('username', String, unique = False, 
          nullable = False), 
   Column('password', String, unique = False, 
          nullable = False), 
)
meta.create_all(bind=engine)

模式定义的一部分是MetaData(),这是一个包含生成表所需方法的注册表。当所有模式定义都获得批准后,MetaData()实例的create_all()方法与引擎一起执行以创建表。这个过程听起来很简单,但在生产阶段的项目中,我们很少追求 SQLAlchemy 的这个 DDL 特性。

现在,让我们探索如何使用 SQLAlchemy 为异步 API 服务创建异步 CRUD 事务。

使用 SQLAlchemy 实现异步 CRUD 事务

从版本 1.4 开始,SQLAlchemy 支持Session对象。我们的ch05b项目展示了 SQLAlchemy 的异步方面。

安装符合 asyncio 规范的数据库驱动程序

在我们开始设置数据库配置之前,我们需要安装以下符合 asyncio 规范的驱动程序:aiopgasyncpg。首先,我们需要安装aiopg,这是一个库,它将帮助进行任何异步访问 PostgreSQL:

pip install aiopg

接下来,我们必须安装asyncpg,它通过 Python 的 AsyncIO 框架帮助构建 PostgreSQL 异步事务:

pip install asyncpg

这个驱动程序是一个非数据库 API 规范的驱动程序,因为它在 AsyncIO 环境之上运行,而不是在同步数据库事务的数据库 API 规范之上。

设置数据库的连接

安装必要的驱动程序后,我们可以通过应用程序的create_async_engine()方法推导出数据库引擎,该方法创建了一个异步版本的 SQLAlchemy 的Engine,称为AsyncEngine。此方法有参数可以设置,如future,当设置为True时,可以在 CRUD 事务期间启用各种异步功能。此外,它还有一个echo参数,可以在运行时提供服务器日志中生成的 SQL 查询。但最重要的是数据库 URL,现在它反映了通过调用asyncpg协议的异步数据库访问。以下是对 PostgreSQL 数据库的异步连接的完整脚本:

from sqlalchemy.ext.asyncio import create_async_engine
DB_URL = 
  "postgresql+asyncpg://postgres:admin2255@
       localhost:5433/fcms"
engine = create_async_engine(DB_URL, future=True, 
               echo=True)

DB_URL中的附加"+asyncpg"细节表明psycopg2将不再是 PostgreSQL 的核心数据库驱动程序;相反,将使用asyncpg。此细节使AsyncEngine能够利用asyncpg建立与数据库的连接。省略此细节将指示引擎识别psycopg2数据库 API 驱动程序,这将在 CRUD 事务期间引起问题。

创建会话工厂

与同步版本类似,sessionmaker()指令被用来创建带有一些新参数的会话工厂以启用AsyncSession。首先,将expire_on_commit参数设置为False,以便在事务期间,即使调用commit()后,模型实例及其属性值仍然可访问。与同步环境不同,所有实体类及其列对象在事务提交后仍然可以被其他进程访问。然后,其class_参数携带类名AsyncSession,该实体将控制 CRUD 事务。当然,sessionmaker()仍然需要AsyncConnection及其底层的异步上下文管理器。

以下脚本展示了如何使用sessionmaker()指令推导出会话工厂:

from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import sessionmaker
engine = create_async_engine(DB_URL, future=True, 
               echo=True)
AsynSessionFactory = sessionmaker(engine, 
       expire_on_commit=False, class_=AsyncSession)

异步 SQLAlchemy 数据库连接的完整配置可以在/db_config/sqlalchemy_async_connect.py模块脚本文件中找到。现在让我们创建模型层。

创建基类和模型层

使用declarative_base()创建Base类和使用Base创建模型类与同步版本中的操作相同。构建异步存储库事务的数据层不需要额外的参数。

构建存储库层

实现异步 CRUD 事务与实现同步事务完全不同。ORM 支持使用AsyncConnection API 的execute()方法来运行一些内置的 ORM 核心方法,即update()delete()insert()。当涉及到查询事务时,使用来自sqlalchemy.future模块的新select()指令而不是核心的select()方法。由于execute()是一个async方法,这要求所有仓库事务都是async,以便应用Async/Await设计模式。下面的AttendanceRepository使用了 SQLAlchemy 的异步类型:

from typing import List, Dict, Any
from sqlalchemy import update, delete, insert
from sqlalchemy.future import select
from sqlalchemy.orm import Session
from models.data.sqlalchemy_async_models import 
            Attendance_Member
class AttendanceRepository: 

    def __init__(self, sess:Session):
        self.sess:Session = sess

    async def insert_attendance(self, attendance: 
           Attendance_Member) -> bool: 
        try:
            sql = insert(Attendance_Member).
                   values(id=attendance.id, 
                     member_id=attendance.member_id, 
                     timein=attendance.timein, 
                     timeout=attendance.timeout, 
                     date_log=attendance.date_log)
            sql.execution_options(
                   synchronize_session="fetch")
            await self.sess.execute(sql)        
        except: 
            return False 
        return True

前面的脚本中给出的异步insert_attendance()方法展示了在创建健身房成员的出勤日志时使用insert()指令。首先,我们需要将模型类名传递给insert(),以便会话知道要访问哪个表进行事务。之后,它发出values()方法来投影插入的所有列值。最后,我们需要调用execute()方法来运行最终的insert()语句,并自动提交更改,因为我们没有在配置期间关闭sessionmaker()autocommit参数。不要忘记在运行异步方法之前调用await,因为这次所有操作都是在 AsyncIO 平台上运行的。

此外,您还有在运行execute()之前添加一些额外执行细节的选项。这些选项之一是synchronize_session,它告诉会话始终使用fetch方法同步模型属性值和数据库中的更新值。

对于update_attendance()delete_attendance()方法,几乎采用相同的程序。我们可以通过execute()运行它们,无需其他操作:

    async def update_attendance(self, id:int, 
           details:Dict[str, Any]) -> bool: 
       try:
           sql = update(Attendance_Member).where(
              Attendance_Member.id == id).values(**details)
           sql.execution_options(
              synchronize_session="fetch")
           await self.sess.execute(sql)

       except: 
           return False 
       return True

    async def delete_attendance(self, id:int) -> bool: 
        try:
           sql = delete(Attendance_Member).where(
                Attendance_Member.id == id)
           sql.execution_options(
                synchronize_session="fetch")
           await self.sess.execute(sql)
        except: 
            return False 
        return True

当涉及到查询时,仓库类包含get_all_attendance()方法,用于检索所有出勤记录,以及get_attendance()方法,通过其id检索特定成员的出勤日志。构建select()方法是一个简单且实用的任务,因为它类似于在 SQL 开发中编写原生的SELECT语句。首先,该方法需要知道要投影哪些列,然后如果有任何约束,它将满足这些约束。然后,它需要execute()方法来异步运行查询并提取Query对象。生成的Query对象有一个scalars()方法,我们可以调用它来检索记录列表。不要忘记通过调用all()方法来关闭会话。

另一方面,check_attendance()使用Query对象的scalar()方法来检索一条记录:特定的出勤记录。除了记录检索外,scalar()还会关闭会话:

    async def get_all_attendance(self):
        q = await self.sess.execute(
               select(Attendance_Member))
        return q.scalars().all()

    async def get_attendance(self, id:int): 
        q = await self.sess.execute(
           select(Attendance_Member).
             where(Attendance_Member.member_id == id))
        return q.scalars().all()
    async def check_attendance(self, id:int): 
        q = await self.sess.execute(
          select(Attendance_Member).
              where(Attendance_Member.id == id))
        return q.scalar()        

异步 SQLAlchemy 的存储库类可以在/repository/sqlalchemy/attendance.py模块脚本文件中找到。现在,让我们将这些异步事务应用到我们的健身馆应用程序的出勤监控服务中。

重要提示

update_attendance()函数中的**操作符是一个 Python 操作符重载,它将字典转换为kwargs。因此,**details的结果是select()指令的values()方法的kwargs参数。

运行 CRUD 事务

在创建Session实例时,AsyncIO 驱动的 SQLAlchemy 与数据库 API 兼容选项之间有两个主要区别:

  • 首先,由AsyncSessionFactory()指令创建的AsyncSession需要一个异步的with上下文管理器,因为连接的AsyncEngine需要在每次commit()事务后关闭。在同步 ORM 版本中,关闭会话工厂不是程序的一部分。

  • 其次,在其创建之后,AsyncSession只有在服务调用其begin()方法时才会开始执行所有的 CRUD 事务。主要原因在于AsyncSession可以被关闭,并且在事务执行后需要关闭。这就是为什么使用另一个异步上下文管理器来管理AsyncSession的原因。

以下代码显示了APIRouter脚本,它实现了使用异步AttendanceRepository监控健身馆会员出勤的服务:

from fastapi import APIRouter
from db_config.sqlalchemy_async_connect import 
          AsynSessionFactory
from repository.sqlalchemy.attendance import 
         AttendanceRepository
from models.requests.attendance import AttendanceMemberReq
from models.data.sqlalchemy_async_models import 
         Attendance_Member
router = APIRouter()
@router.post("/attendance/add")
async def add_attendance(req:AttendanceMemberReq ):
    async with AsynSessionFactory() as sess:
        async with sess.begin():
            repo = AttendanceRepository(sess)
            attendance = Attendance_Member(id=req.id,  
                member_id=req.member_id, 
                timein=req.timein, timeout=req.timeout, 
                date_log=req.date_log)
            return await repo.insert_attendance(attendance)

@router.patch("/attendance/update")
async def update_attendance(id:int, 
                     req:AttendanceMemberReq ):
    async with AsynSessionFactory() as sess:
        async with sess.begin():
            repo = AttendanceRepository(sess)
            attendance_dict = req.dict(exclude_unset=True)
            return await repo.update_attendance(id, 
                    attendance_dict)
@router.delete("/attendance/delete/{id}")
async def delete_attendance(id:int): 
     async with AsynSessionFactory() as sess:
        async with sess.begin():
            repo = AttendanceRepository(sess)
            return await repo.delete_attencance(id)
@router.get("/attendance/list")
async def list_attendance():
     async with AsynSessionFactory() as sess:
        async with sess.begin():
            repo = AttendanceRepository(sess)
            return await repo.get_all_attendance()

前面的脚本显示了存储库类和AsyncSession实例之间没有直接的参数传递。会话必须符合两个上下文管理器,才能成为一个有效的会话。这个语法在SQLAlchemy 1.4下是有效的,未来随着 SQLAlchemy 的下一个版本可能会发生变化。

为异步事务创建的其他 ORM 平台更容易使用。其中之一就是GINO

使用 GINO 进行异步事务

GINO,代表GINO Is Not ORM,是一个轻量级的异步 ORM,它运行在 SQLAlchemy Core 和 AsyncIO 环境之上。它所有的 API 都是异步准备就绪的,这样你可以构建上下文数据库连接和事务。它内置了对JSONB的支持,因此可以将结果转换为 JSON 对象。但有一个限制:GINO 仅支持 PostgreSQL 数据库。

在创建健身馆项目时,唯一可用的稳定 GINO 版本是 1.0.1,它需要SQLAlchemy 1.3。因此,安装 GINO 将自动卸载SQLAlchemy 1.4,从而将 GINO 存储库添加到ch05a项目中,以避免与 SQLAlchemy 的异步版本发生冲突。

你可以使用以下命令安装 GINO 的最新版本:

pip install gino

安装数据库驱动

由于它只支持 PostgreSQL 作为 RDBMS,你只需要使用pip命令安装asyncpg

建立数据库连接

除了Gino指令外,不需要其他 API 来打开数据库连接。我们需要实例化该类以开始构建领域层。Gino类可以从 ORM 的gino模块导入,如下面的脚本所示:

from gino import Gino
db = Gino()

其实例类似于一个门面,它控制所有数据库事务。一旦提供了正确的 PostgreSQL 管理员凭据,它就会开始建立数据库连接。完整的 GINO 数据库连接脚本可以在/db_config/gino_connect.py脚本文件中找到。现在让我们构建模型层。

构建模型层

在结构、列元数据和__tablename__属性的存在方面,GINO 中的模型类定义与 SQLAlchemy 相似。唯一的区别是超类类型,因为 GINO 使用数据库引用实例的db中的Model类。以下脚本展示了如何将Signup领域模型映射到signup表:

from db_config.gino_connect import db
class Signup(db.Model):
    __tablename__ = "signup"
    id = db.Column(db.Integer, primary_key=True, 
               index=True)
    username = db.Column('username',db.String, 
               unique=False, index=False)
    password = db.Column('password',db.String, 
               unique=False, index=False)

与 SQLAlchemy 一样,所有模型类都必须有__tablename__属性来指示其映射的表模式。在定义列元数据时,db对象有一个Column指令可以设置属性,如列类型主键唯一默认值可为空索引。列类型也来自db引用对象,这些类型与 SQLAlchemy 相同,即StringIntegerDateTimeUnicodeFloat

如果模型属性的名称与列名称不匹配,Column指令的第一个参数将注册实际列的名称并将其映射到模型属性。usernamepassword列是映射类属性到表列名称的示例情况。

映射表关系

在编写本文时,GINO 默认只支持多对一关系db引用对象有一个ForeignKey指令,它建立了与父模型的键关系。它只需要父表的实际引用键列和表名称来追求映射。在子模型类的Column对象中设置ForeignKey属性就足够配置以执行左外连接来检索父模型类的所有子记录。GINO 没有relationship()函数来处理有关如何检索父模型类子记录的更多细节。然而,它具有内置的加载器,可以自动确定外键并在之后执行多对一连接查询。此连接查询的完美设置是Profile_TrainersGym_Class模型类之间的关系配置,如下面的脚本所示:

class Profile_Trainers(db.Model):
    __tablename__ = "profile_trainers"
    id = db.Column(db.Integer, db.ForeignKey('login.id'), 
              primary_key=True, index=True)
    firstname = db.Column(db.String, unique=False, 
              index=False)
    … … … … … …
    shift = db.Column(db.Integer, unique=False, 
              index=False)
class Gym_Class(db.Model): 
    __tablename__ = "gym_class"
    id = db.Column(db.Integer, primary_key=True, 
          index=True)
    member_id = db.Column(db.Integer, 
       db.ForeignKey('profile_members.id'), unique=False, 
         index=False)
    trainer_id = db.Column(db.Integer, 
      db.ForeignKey('profile_trainers.id'), unique=False,
         index=False)
    approved = db.Column(db.Integer, unique=False, 
       index=False)

如果我们需要构建一个能够处理一对多一对一关系的查询,我们可能需要做出一些调整。为了使左外连接查询能够工作,父模型类必须定义一个set集合来包含在涉及一对多关系的连接查询期间的所有子记录。对于一对一关系,父模型只需要实例化子模型:

class Login(db.Model): 
    __tablename__ = "login"
    id = db.Column(db.Integer, primary_key=True, 
               index=True)
    username = db.Column(db.String, unique=False, 
               index=False)
    … … … … … …
    def __init__(self, **kw):
        super().__init__(**kw)
        self._child = None
    @property
    def child(self):
        return self._child
    @child.setter
    def child(self, child):
        self._child = child
class Profile_Members(db.Model): 
    __tablename__ = "profile_members"
    id = db.Column(db.Integer, db.ForeignKey('login.id'), 
          primary_key=True, index=True)
    … … … … … … 
    weight = db.Column(db.Float, unique=False, index=False)
    trainer_id = db.Column(db.Integer, 
        db.ForeignKey('profile_trainers.id'), unique=False, 
            index=False)

    def __init__(self, **kw):
        super().__init__(**kw)
        self._children = set()
    @property
    def children(self):
        return self._children
    @children.setter
    def children(self, child):
        self._children.add(child)

这个集合子对象必须在父的__init__()中实例化,以便通过 ORM 的 loader 通过childrenchild @property分别访问。使用@property是管理连接记录的唯一方式。

注意,loader API 的存在证明了 GINO 不支持 SQLAlchemy 所具有的自动化关系。如果我们想偏离其核心设置,就需要使用 Python 编程来添加平台不支持的一些功能,例如在Profile_MembersGym_Class之间以及LoginProfile_Members/Profile_Trainers之间的一对多设置。在前面的脚本中,请注意Profile_Members中包含了一个构造函数和自定义的children Python 属性,以及Login中的自定义child属性。这是因为 GINO 只内置了一个parent属性。

你可以在/models/data/gino_models.py脚本中找到 GINO 的域模型。

重要提示

@property是 Python 装饰器,用于在类中实现 getter/setter。这隐藏了一个实例变量,并暴露了其gettersetter属性字段。使用@property是实现 Python 中封装原则的一种方式。

实现 CRUD 事务

让我们考虑以下TrainerRepository,它管理教练档案。它的insert_trainer()方法展示了实现插入事务的传统方式。GINO 要求其模型类调用从db引用对象继承的create()方法。所有列值都通过命名参数或作为kwargs的包传递给create()方法,在记录对象持久化之前。但 GINO 允许另一种插入选项,它使用通过向其构造函数注入列值得到的模型实例。创建的实例有一个名为create()的方法,它插入记录对象而不需要任何参数:

from models.data.gino_models import Profile_Members, 
           Profile_Trainers, Gym_Class
from datetime import date, time
from typing import List, Dict, Any
class TrainerRepository: 

    async def insert_trainer(self, 
             details:Dict[str, Any]) -> bool: 
        try:
            await Profile_Trainers.create(**details)
        except Exception as e: 
            print(e)
            return False 
        return True

update_trainer()展示了 GINO 如何更新表记录。根据脚本,以 GINO 的方式更新表涉及以下步骤:

  • 首先,它需要模型类的get()类方法来检索具有id主键的记录对象。

  • 第二,提取的记录有一个名为update()的实例方法,它将自动使用其kwargs参数中指定的新数据修改映射的行。apply()方法将提交更改并关闭事务:

        async def update_trainer(self, id:int, 
                     details:Dict[str, Any]) -> bool: 
           try:
                trainer = await Profile_Trainers.get(id)
                await trainer.update(**details).apply()       
           except: 
               return False 
           return True
    

另一个选项是使用 SQLAlchemy 的 ModelClass.update.values(ModelClass).where(expression) 子句,当应用于 update_trainer() 时,将给出以下最终语句:

Profile_Trainers.update.values(**details).
     where(Profile_Trainers.id == id).gino.status()

它的 delete_trainer() 也遵循 GINO 更新 事务的相同方法。这个事务是两步过程,最后一步需要调用提取的记录对象的 delete() 实例方法:

    async def delete_trainer(self, id:int) -> bool: 
        try:
           trainer = await Profile_Trainers.get(id)
           await trainer.delete()        
        except: 
            return False 
        return True

另一方面,TrainerRepository 有两个方法,get_member()get_all_member(),展示了 GINO 如何构建查询语句:

  • 前者通过模型类的 get() 类方法使用其主键检索特定记录对象

  • 后者使用 querygino 扩展来利用 all() 方法,该方法检索记录:

        async def get_all_member(self):
            return await Profile_Trainers.query.gino.all()
        async def get_member(self, id:int): 
                return await Profile_Trainers.get(id)
    

但在查询执行过程中将数据库行转换为模型对象的是 GINO 的内置加载器。如果我们进一步扩展 get_all_member() 中提出的解决方案,这将看起来像这样:

query = db.select([Profile_Trainers])
q = query.execution_options(
         loader=ModelLoader(Profile_Trainers))
users = await q.gino.all()

在 GINO ORM 中,所有查询都利用 ModelLoader 将每个数据库记录加载到模型对象中:

class GymClassRepository:

    async def join_classes_trainer(self):
        query = Gym_Class.join(Profile_Trainers).select()
        result = await query.gino.load(Gym_Class.
            distinct(Gym_Class.id).
                load(parent=Profile_Trainers)).all()
        return result 

    async def join_member_classes(self):
        query = Gym_Class.join(Profile_Members).select()
        result = await query.gino.load(Profile_Members.
           distinct(Profile_Members.id).
              load(add_child=Gym_Class)).all()
        return result

如果正常查询需要 ModelLoader,那么 连接 查询事务需要什么?GINO 没有自动支持表关系,没有 ModelLoader,创建 连接 查询是不可能的。join_classes_trainer() 方法实现了 Profile_TrainersGym_Class一对多 查询。查询中的 distinct(Gym_Class.id).load(parent=Profile_Trainers) 子句为 GymClass 创建了一个 ModelLoader,它将合并并加载 Profile_Trainers 父记录到其子 Gym_Classjoin_member_classes() 创建 一对多 连接,而 distinct(Profile_Members.id).load(add_child=Gym_Class) 创建一个 ModelLoader 来构建 Gym_Class 记录的集合,按照 Profile_Members 父记录。

另一方面,Gym_ClassProfile_Members 之间的 多对一 关系使用 Profile_Memberload() 函数,这是一种将 Gym_Class 子记录与 Profile_Members 匹配的不同方法。以下联合查询是 一对多 设置的反面,因为这里的 Gym_Class 记录在左侧,而配置文件在右侧:

    async def join_classes_member(self):
        result = await 
          Profile_Members.load(add_child=Gym_Class)
           .query.gino.all()

因此,加载器在 GINO 中构建查询时扮演着重要角色,尤其是在连接操作中。尽管它使查询构建变得困难,但它仍然为许多复杂查询提供了灵活性。

所有 GINO 的存储库类都可以在 /repository/gino/trainers.py 脚本中找到。

运行 CRUD 事务

为了让我们的存储库在 APIRouter 模块中运行,我们需要通过将 db 引用对象绑定到实际数据库的 DB_URL 来打开数据库连接。绑定过程使用可靠的函数是理想的,因为更简单的部署形式是通过 APIRouter 注入完成的。以下脚本展示了如何设置这种数据库绑定:

from fastapi import APIRouter, Depends
from fastapi.encoders import jsonable_encoder
from fastapi.responses import JSONResponse
from db_config.gino_connect import db
from models.requests.trainers import ProfileTrainersReq
from repository.gino.trainers import TrainerRepository
async def sess_db():
    await db.set_bind(
     "postgresql+asyncpg://
       postgres:admin2255@localhost:5433/fcms")

router = APIRouter(dependencies=[Depends(sess_db)])
@router.patch("/trainer/update" )
async def update_trainer(id:int, req: ProfileTrainersReq): 
    mem_profile_dict = req.dict(exclude_unset=True)
    repo = TrainerRepository()
    result = await repo.update_trainer(id, 
           mem_profile_dict)
    if result == True: 
        return req 
    else: 
        return JSONResponse(
    content={'message':'update trainer profile problem 
         encountered'}, status_code=500)

@router.get("/trainer/list")
async def list_trainers(): 
    repo = TrainerRepository()
    return await repo.get_all_member()

在前面的代码中显示的 list_trainers()update_trainer() REST 服务是我们 健身俱乐部 应用程序的一些服务,在将 sess_db() 注入 APIRouter 后将成功运行 TrainerRepository。GINO 在建立与 PostgreSQL 的连接时不需要太多细节,除了 DB_URL。始终在 URL 中指定 asyncpg 方言,因为它是 GINO 作为同步 ORM 支持的唯一驱动程序。

创建表

GINO 和 SQLAlchemy 在框架级别创建表架构的方法相同。两者都要求使用 MetaDataColumn 指令来构建 Table 定义。然后,建议使用异步函数通过 create_engine() 方法结合我们的 DB_URL 来获取引擎。与 SQLAlchemy 类似,这个引擎在通过 create_all() 构建表时起着关键作用,但这次它使用 GINO 的 GinoSchemaVisitor 实例。以下脚本展示了 GINO 使用 AsyncIO 平台生成表的完整实现:

from sqlalchemy import Table, Column, Integer, String, 
           MetaData, ForeignKey'
import gino
from gino.schema import GinoSchemaVisitor
metadata = MetaData()
signup = Table(
    'signup', metadata,
    Column('id', Integer, primary_key=True),
    Column('username', String),
    Column('password', String),
)
   … … … … …
async def db_create_tbl():
    engine = await gino.create_engine(DB_URL)
    await GinoSchemaVisitor(metadata).create_all(engine)

如 SQLAlchemy 所述,在开始时执行 DDL 事务(如模式自动生成)是可选的,因为这可能会降低 FastAPI 的性能,甚至可能导致现有数据库模式中的某些冲突。

现在,让我们探索另一个需要自定义 Python 编码的 ORM:Pony ORM

在存储库层使用 Pony ORM

Pony ORM 依赖于 Python 语法来构建模型类和存储库事务。这个 ORM 只使用 Python 数据类型,如 intstrfloat,以及类类型来实现模型定义。它使用 Python lambda 表达式来建立 CRUD 事务,尤其是在映射表关系时。此外,Pony 在读取记录时强烈支持记录对象的 JSON 转换。另一方面,Pony 可以缓存查询对象,这比其他方法提供了更快的性能。Pony ORM 的代码可以在 ch05a 项目中找到。

要使用 Pony,我们需要使用 pip 来安装它。这是因为它是一个第三方平台:

pip install pony

安装数据库驱动程序

由于 Pony 是一个设计用于构建同步事务的 ORM,我们需要 psycopg2 PostgreSQL 驱动程序。我们可以使用 pip 命令来安装它:

pip install psycopg2

创建数据库的连接性

Pony 建立数据库连接的方法简单且声明式。它只需要从 pony.orm 模块实例化 Database 指令来使用正确的数据库凭据连接到数据库。以下脚本用于 健身俱乐部 原型:

from pony.orm import  Database
db = Database("postgres", host="localhost", port="5433", 
  user="postgres", password="admin2255", database="fcms")

如您所见,构造函数的第一个参数是 数据库方言,后面跟着 kwargs,其中包含有关连接的所有详细信息。完整的配置可以在 /db_config/pony_connect.py 脚本文件中找到。现在,让我们创建 Pony 的模型类。

定义模型类

创建的数据库对象 db 是定义 Pony 实体(一个指模型类的术语)的唯一组件。它有一个 Entity 属性,用于将每个模型类子类化以提供 _table_ 属性,该属性负责 表-实体 映射。所有实体实例都绑定到 db 并映射到表。以下脚本展示了 Signup 类如何成为模型层的实体:

from pony.orm import  Database, PrimaryKey, Required, 
         Optional, Set
from db_config.pony_connect import db
from datetime import date, time

class Signup(db.Entity):
    _table_ = "signup"
    id = PrimaryKey(int)
    username = Required(str, unique=True, max_len=100, 
         nullable=False, column='username')
    password = Required(str, unique=Fals, max_len=100, 
         nullable=False, column='password')

pony.orm 模块包含 RequiredOptionalPrimaryKeySet 指令,用于创建列属性。由于每个实体都必须有一个主键,因此使用 PrimaryKey 来定义实体的列属性。如果没有主键,Pony ORM 将隐式地为实体生成一个具有以下定义的 id 主键:

id = PrimaryKey(int, auto=True)

另一方面,Set 指令表示实体之间的关系。所有这些指令都有一个强制属性列类型,它以 Python 语法(例如,intstrfloatdatetime)或任何类类型声明列值类型。其他列属性包括 automax_lenindexuniquenullabledefaultcolumn。现在,让我们在模型类之间建立关系:

class Login(db.Entity): 
    _table_ = "login"
    id = PrimaryKey(int)
    … … … … … …
    date_approved = Required(date)
    user_type = Required(int)

    trainers = Optional("Profile_Trainers", reverse="id")
    members = Optional("Profile_Members", reverse="id")

给定的 Login 类有两个额外的属性,trainersmembers,它们分别作为 Profile_TrainersProfile_Members 模型的引用键。反过来,这些子实体各自有指向 Login 模型的类属性,从而建立了一种关系。这些列属性及其引用外键关系必须与物理数据库模式相匹配。以下代码展示了 Pony 的子模型类示例:

class Profile_Trainers(db.Entity):
    _table_ = "profile_trainers"
    id = PrimaryKey("Login", reverse="trainers")
    firstname = Required(str)
    … … … … … …
    tenure = Required(float)
    shift = Required(int)

members = Set("Profile_Members", 
           reverse="trainer_id")
    gclass = Set("Gym_Class", reverse="trainer_id")
class Profile_Members(db.Entity): 
    _table_ = "profile_members"
    id = PrimaryKey("Login", reverse="members")
    firstname = Required(str)
    … … … … … …
trainer_id = Required("Profile_Trainers", 
            reverse="members")
    … … … … … …

定义关系属性取决于两个实体之间的关系类型。如果关系类型是一对一,则应将属性定义为 Optional(parent)-Required(child)Optional(parent)-Optional(child)。对于一对多,属性应定义为 Set(parent)-Required(child)。最后,对于多对一,属性必须定义为 Set(parent)-Set(child)

LoginProfile_Members 之间有一个一对一的关系,这解释了为什么使用 Optional 属性指向 Profile_Membersid 键。在这个关系中,主键始终是 Pony 的引用键。

另一方面,Profile_Trainers模型与Profile_Members有一个一对一的设置,这解释了为什么前者的trainer_id属性使用Required指令指向后者的Set属性members。有时,框架需要通过指令的reverse参数进行反向引用。前面的代码也描述了Profile_MembersGym_Class模型之间的相同场景,其中Profile_Membersgclass属性被声明为一个包含成员所有报名健身课程的Set集合。在这个关系中,引用键可以是主键,也可以是典型的类属性。以下片段显示了Gym_Class模型的蓝图:

class Gym_Class(db.Entity): 
    _table_ = "gym_class"
    id = PrimaryKey(int)
    member_id = Required("Profile_Members", 
         reverse="gclass")
    trainer_id = Required("Profile_Trainers", 
         reverse="gclass")
    approved = Required(int)
db.generate_mapping()

与其他 ORM 不同,Pony 需要执行generate_mapping()来将所有实体映射到实际表。该方法是db实例的一个实例方法,必须出现在模块脚本的最后部分,如前面的片段所示,其中Gym_Class是最后定义的 Pony 模型类。所有 Pony 模型类都可以在/models/data/pony_models.py脚本文件中找到。

注意,我们可以使用Pony ORM ER Diagram Editor手动或数字化创建 Pony 实体,我们可以在editor.ponyorm.com/访问它。该编辑器可以提供免费和商业账户。现在让我们实现 CRUD 事务。

实现 CRUD 事务

Pony 中的 CRUD 事务是会话驱动的。但与 SQLAlchemy 不同,其存储库类不需要将db_session注入到存储库构造函数中。Pony 中的每个事务在没有db_session的情况下都不会工作。以下代码显示了一个实现管理健身会员列表所需所有事务的存储库类:

from pony.orm import db_session, left_join
from models.data.pony_models import Profile_Members, 
            Gym_Class, Profile_Trainers
from datetime import date, time
from typing import List, Dict, Any
from models.requests.members import ProfileMembersReq 
class MemberRepository: 

    def insert_member(self, 
            details:Dict[str, Any]) -> bool: 
        try:
            with db_session:
                Profile_Members(**details)
        except: 
            return False 
        return True

在 Pony 中,插入记录意味着使用注入的记录值实例化模型类。一个例子是insert_member(),它通过实例化Profile_Members模型并注入会员详情来插入个人资料。然而,更新记录的情况则不同,如下面的脚本所示:

    def update_member(self, id:int, 
               details:Dict[str, Any]) -> bool: 
       try:
          with db_session:
            profile = Profile_Members[id]
            profile.id = details["id"]
            … … … … … …
            profile.trainer_id = details["trainer_id"]
       except: 
           return False 
       return True

在 Pony 中更新记录,通过update_member()脚本实现,意味着通过id进行索引检索记录对象。由于 Pony 内置了对 JSON 的支持,检索到的对象会自动转换为可 JSON 化的对象。然后,那些属性的新值会覆盖原有值,因为它们必须更改。这个UPDATE事务同样在db_session的范围内,因此覆盖后自动刷新记录(s)。

另一方面,存储库类的delete_member()方法展示了与UPDATE相同的方法,只是在检索对象记录后立即调用delete()类方法。以下是这个操作的脚本:

    def delete_member(self, id:int) -> bool: 
        try:
           with db_session: 
               Profile_Members[id].delete()
        except: 
            return False 
        return True

删除事务也是 db_session 绑定的,因此调用 delete() 会自动刷新表。以下代码展示了 Pony 对查询事务的实现:

    def get_all_member(self):
        with db_session:
            members = Profile_Members.select()
            result = [ProfileMembersReq.from_orm(m) 
                 for m in members]
            return result

    def get_member(self, id:int): 
        with db_session:
            login = Login.get(lambda l: l.id == id)
            member = Profile_Members.get(
                lambda m: m.id == login)
            result = ProfileMembersReq.from_orm(member)
        return result

get_member() 方法使用 get() 类方法检索单个记录,该方法在其参数中需要一个 lambda 表达式。由于 LoginProfile_Members 之间存在一对一关系,首先,我们必须提取成员的 Login 记录,并使用 login 对象通过 Profile_Members 实体的 get() 辅助函数获取记录。这种方法也适用于具有其他实体关系的其他实体。现在,get_all_member() 方法使用 select() 方法检索结果集。如果检索操作中有约束条件,select() 方法也可以使用 lambda 表达式。

Pony 模型类具有 get()select() 方法,这两个方法都返回 FastAPI 无法直接处理的 Query 对象。因此,我们需要一个 ORM 友好的 Pydantic 模型来从这些 Query 对象中提取最终实体。类似于 SQLAlchemy,需要一个具有嵌套 Config 类的 ModelBase 类来从 Query 对象中检索记录。嵌套类必须配置 orm_modeTrue。如果涉及关系映射,请求模型还必须声明涉及关系的属性及其对应的子对象转换器。方法转换器,由 Pydantic 的 @validator 装饰,将被 Pony 自动调用以解释和验证 Query 对象为可 JSON 化的组件,如 List 或实体对象。以下代码展示了用于通过列表推导和 Profile_Member dict 对象从 get() 中提取记录的请求模型:

from typing import List, Any
from pydantic import BaseModel, validator
class ProfileMembersReq(BaseModel): 
    id: Any
    firstname: str
    lastname: str
    age: int
    height: float
    weight: float
    membership_type: str
    trainer_id: Any

    gclass: List

    @validator('gclass', pre=True, 
         allow_reuse=True, check_fields=False)
    def gclass_set_to_list(cls, values):
        return [v.to_dict() for v in values]
    @validator('trainer_id', pre=True, 
         allow_reuse=True, check_fields=False)
    def trainer_object_to_map(cls, values):
        return values.to_dict()

    class Config:
        orm_mode = True

ProfileMembersReq 中的 gclass_set_to_list ()trainer_object_to_map() 转换的存在使得数据可以填充到 gclasstrainer_id 属性的子对象中,分别。这些附加功能表明为什么执行 select() 已经可以检索 INNER JOIN 查询。

要构建 LEFT JOIN 查询事务,ORM 有一个内置指令称为 left_join(),它通过 Python 生成器提取带有 LEFT JOIN 原始对象的 Query 对象。以下代码展示了另一个存储库类,展示了 left_join() 的使用:

class MemberGymClassRepository:

    def join_member_class(self): 
      with db_session: 
        generator_args = (m for m in Profile_Members 
              for g in m.gclass)
        joins = left_join(tuple_args)        
        result = [ProfileMembersReq.from_orm(m) 
              for m in joins ]
        return result

所有存储库类都可以在 /repository/pony/members.py 脚本文件中找到。

现在,使 Pony 更快的是它使用了一个 身份映射,其中包含从每个查询事务中检索到的所有记录对象。ORM 应用 身份映射 设计模式来应用其缓存机制,以使读写执行快速。它只需要内存管理和监控,以避免在复杂和大型应用程序中发生内存泄漏问题。

运行存储库事务

由于db_session已经由内部管理,因此对于APIRouter脚本来运行存储库事务,Pony 不需要额外的要求。存储库类直接在每个 API 中访问和实例化,以访问 CRUD 事务。

创建表格

如果表格尚不存在,Pony 可以通过其实体类生成这些表格。当dbgenerate_mapping()方法的create_tables参数设置为True时,此 DDL 事务被启用。

在语法方面,最紧凑和最简单的 ORM 是Peewee

使用 Peewee 构建存储库

在不同的 ORM 中,Peewee 在 ORM 功能和 API 方面是最简单和最小的。该框架易于理解和使用;它不是全面的,但它具有直观的 ORM 语法。其优势在于构建和执行查询事务。

Peewee 不是为异步平台设计的,但它可以通过使用它支持的某些与异步相关的库与它们一起工作。为了使 Peewee 与异步框架 FastAPI 一起工作,我们需要至少安装Python 3.7。要安装 Peewee,我们需要执行以下命令:

pip install peewee

安装数据库驱动程序

ORM 需要psycopg2作为 PostgreSQL 数据库驱动程序。我们可以使用pip来安装它:

pip install psycopg2

创建数据库连接

为了使 Peewee 与 FastAPI 一起工作,我们必须构建一个多线程机制,其中 Peewee 可以在同一线程上处理多个请求事务,并且每个请求可以使用不同的本地线程同时执行更多操作。这个定制的多线程组件,可以使用ContextVar类创建,将 Peewee 连接到 FastAPI 平台。但是,为了使 Peewee 利用这些线程,我们还需要自定义其_ConnectionState,使用新创建的线程状态db_state。以下代码显示了如何从db_state和自定义的_ConnectionState派生出:

from peewee import _ConnectionState
from contextvars import ContextVar
db_state_default = {"closed": None, "conn": None, 
         "ctx": None, "transactions": None}
db_state = ContextVar("db_state", 
          default=db_state_default.copy())
class PeeweeConnectionState(_ConnectionState):
    def __init__(self, **kwargs):
        super().__setattr__("_state", db_state)
        super().__init__(**kwargs)
    def __setattr__(self, name, value):
        self._state.get()[name] = value
    def __getattr__(self, name):
        return self._state.get()[name]

为了应用前面代码中引用的新的db_state_ConnectionState类,即PeeweeConnectionState,我们需要通过Database类打开数据库连接。Peewee 有几种Database类的变体,具体取决于应用程序将选择连接到的数据库类型。由于我们将使用 PostgreSQL,因此PostgresqlDatabase是初始化所有必要数据库详情的正确类。建立连接后,db实例将有一个指向PeeweeConnectionState实例的_state属性。以下片段显示了如何使用数据库凭证连接到我们的健身健身房数据库的fcms

from peewee import PostgresqlDatabase
db = PostgresqlDatabase(
    'fcms',
    user='postgres',
    password='admin2255',
    host='localhost',
    port=5433, 
)
db._state = PeeweeConnectionState()

上述代码还强调,数据库连接的默认状态必须替换为可以与 FastAPI 平台一起工作的非阻塞状态。此配置可以在/db_config/peewee_connect.py脚本文件中找到。现在让我们构建 Peewee 的模型层。

创建表格和领域层

Peewee 与其他 ORM 不同,它更喜欢根据其模型类自动生成表。Peewee 推荐进行 逆向工程,即创建表而不是仅将它们映射到现有表。让应用程序生成表可以减少建立关系和主键的麻烦。这个 ORM 是独特的,因为它有一种“隐含”的方法来创建主键和外键。以下脚本显示了 Peewee 模型类的定义:

from peewee import Model, ForeignKeyField, CharField, 
   IntegerField, FloatField, DateField, TimeField
from db_config.peewee_connect import db
class Signup(Model):
    username = CharField(unique=False, index=False)
    password = CharField(unique=False, index=False)

    class Meta:
      database = db
      db_table = 'signup'

我们在模型类中看不到任何主键,因为 Peewee 引擎将在其模式自动生成期间创建它们。物理外键列和模型属性将具有与模型名称相同的名称,以小写形式采用 modelname_id 模式。如果我们坚持为模型添加主键,将发生冲突,使 Peewee 无法正常工作。我们必须让 Peewee 从模型类创建物理表以避免这种错误。

所有模型类都从 ORM 的 Model 指令继承属性。它还具有如 IntegerFieldFloatFieldDateFieldTimeField 等列指令,用于定义模型类的列属性。此外,每个域类都有一个嵌套的 Meta 类,它注册了对 databasedb_table 的引用,它们映射到模型类。我们还可以在此设置的其他属性包括 primary_keyindexesconstraints

在自动生成中唯一的问题是创建表关系。在自动生成之前,将子类的外键属性链接到父类不存在的主键是困难的。例如,以下 Profile_Trainers 模型暗示了与 Login 类的 多对一 关系,这仅由 ForeignKeyField 指令的 trainer 反向引用属性定义,而不是由 login_id 外键定义:

class Profile_Trainers(Model):
login = ForeignKeyField(Login, 
         backref="trainers", unique=True)
    … … … … … …
    shift = IntegerField(unique=False, index=False)

    class Meta:
      database = db
      db_table = 'profile_trainers'

自动生成后生成的 login_id 列可以在以下屏幕截图中看到:

![图 5.2 – 生成的 profile_trainers 模式图 5.2 – 生成的 profile_trainers 模式

图 5.2 – 生成的 profile_trainers 模式

使用 ForeignKeyField 指令声明外键属性,它接受至少三个关键参数:

  • 父模型名称

  • backref 参数,它引用子记录(如果在 一对一 关系中)或一组子对象(如果在 一对多多对一 关系中)

  • unique 参数,当设置为 True 时表示 一对一 关系,否则为 False

定义了所有模型,包括它们的关系后,我们需要从 Peewee 的 db 实例调用以下方法以进行表映射:

  • 使用 connect() 建立连接

  • 使用 create_tables() 来根据其模型类列表进行模式生成

以下脚本显示了类定义的快照,包括调用两个 db 方法:

class Login(Model): 
    username = CharField(unique=False, index=False)
    … … … … … …
    user_type = IntegerField(unique=False, index=False)

    class Meta:
      database = db
      db_table = 'login'

class Gym_Class(Model): 
    member = ForeignKeyField(Profile_Members, 
          backref="members")
    trainer = ForeignKeyField(Profile_Trainers, 
          backref="trainers")
    approved = IntegerField(unique=False, index=False)

    class Meta:
      database = db
      db_table = 'gym_class'
db.connect()
db.create_tables([Signup, Login, Profile_Members, 
     Profile_Trainers, Attendance_Member, Gym_Class],
           safe=True)

如我们所见,我们需要将 create_tables()safe 参数设置为 True,这样 Peewee 就会在应用程序的初始服务器启动期间只执行一次模式自动生成。所有 Peewee ORM 的模型类都可以在 /models/data/peewee_models.py 脚本文件中找到。现在,让我们实现仓库层。

实现 CRUD 事务

在 Peewee ORM 中为应用程序创建异步连接并构建模型层是棘手的,但实现其仓库层是直接的。所有方法操作完全源自其模型类。例如,以下代码片段中显示的 insert_login() 方法展示了 Logincreate() 静态方法如何接受记录插入的登录详情:

from typing import Dict, List, Any
from models.data.peewee_models import Login, 
   Profile_Trainers, Gym_Class, Profile_Members
from datetime import date
class LoginRepository:

    def insert_login(self, id:int, user:str, passwd:str, 
          approved:date, type:int) -> bool: 
        try:
            Login.create(id=id, username=user, 
                password=passwd, date_approved=approved, 
                user_type=type)
        except Exception as e: 
           return False 
        return True

此方法可以被重新实现以执行批量插入,但 Peewee 有另一种通过其 insert_many() 类方法追求多个插入的方法。使用 insert_many() 需要更精确的列细节来映射多个模式值。它还需要调用 execute() 方法来执行所有批量插入并在之后关闭操作。

同样,update() 类方法在通过 id 主键过滤需要更新的记录后需要调用 execute() 方法。以下代码片段展示了这一点:

    def update_login(self, id:int, 
              details:Dict[str, Any]) -> bool: 
       try:
           query = Login.update(**details).
                  where(Login.id == id)
           query.execute()
       except: 
           return False 
       return True

当涉及到记录删除时,delete_login() 展示了简单的方法——即通过使用 delete_by_id()。但 ORM 有另一种方法,即使用 get() 类方法检索记录对象——例如,Login.get(Login.id == id)——然后通过记录对象的 delete_instance() 实例方法最终删除记录。以下 delete_login() 事务展示了如何利用 delete_by_id() 类方法:

    def delete_login(self, id:int) -> bool: 
        try:
           query = Login.delete_by_id(id)
        except: 
            return False 
        return True

以下脚本,针对 get_all_login()get_login(),突出了 Peewee 如何从数据库中检索记录。Peewee 使用其 get() 类方法通过主键检索单个记录;在之前的代码片段中,同样的方法被应用于其 UPDATE 事务。同样,Peewee 使用类方法提取多个记录,但这次它使用的是 select() 方法。结果对象不能被 FastAPI 编码,除非它包含在 List 集合中,该集合将数据行序列化为可序列化为 JSON 的对象列表:

    def get_all_login(self):
        return list(Login.select())

    def get_login(self, id:int): 
        return Login.get(Login.id == id)

另一方面,以下仓库类展示了如何使用其 join() 方法创建 JOIN 查询:

from peewee import JOIN
class LoginTrainersRepository:

    def join_login_trainers(self): 
        return list(Profile_Trainers.
          select(Profile_Trainers, Login).join(Login))
class MemberGymClassesRepository:
    def outer_join_member_gym(self): 
        return list(Profile_Members.
          select(Profile_Members,Gym_Class).join(Gym_Class, 
                    join_type=JOIN.LEFT_OUTER))

LoginTrainersRepositoryjoin_login_trainers() 方法构建了 Profile_TrainersLogin 对象的 INNER JOIN 查询。在 Profile_Trainers 对象的 select() 指令中指定的最左侧模型是父模型类型,其后是其一对一关系中的子模型类。select() 指令发出带有模型类类型的 join() 方法,这表示属于查询右侧的记录类型。ON 条件和外键约束是可选的,但可以通过添加 join() 构造的 onjoin_type 属性来显式声明。此查询的一个示例是 MemberGymClassesRepositoryouter_join_member_gym(),它使用 join_type 属性的 LEFT_OUTER 选项实现了 Profile_MembersGym_ClassLEFT OUTER JOIN

在 Peewee 中进行连接操作也需要使用 list() 集合来序列化检索到的记录。所有 Peewee 的存储库类都可以在 /repository/peewee/login.py 脚本中找到。

运行 CRUD 事务

由于 Peewee 的数据库连接是在模型层设置的,因此 APIRouterFastAPI 运行 CRUD 事务时不需要额外的要求。API 服务可以轻松访问所有存储库类,而无需从 db 实例调用方法或指令。

到目前为止,我们已经尝试了流行的 ORM 来将关系数据库集成到 FastAPI 框架中。如果应用 ORM 对于微服务架构来说不够用,我们可以利用一些可以进一步优化 CRUD 性能的设计模式,例如 CQRS

应用 CQRS 设计模式

CQRS 是一种微服务设计模式,负责将查询事务(读取)与插入、更新和删除操作(写入)分离。这两个组的分离减少了对这些事务的访问耦合度,从而提供了更少的流量和更快的性能,尤其是在应用程序变得复杂时。此外,此设计模式在 API 服务和存储库层之间创建了一个松散耦合的特性,如果存储库层有多个轮换和变更,这将给我们带来优势。

定义处理程序接口

要实现 CQRS,我们需要创建定义查询和命令事务的两个接口。以下代码显示了将识别 Profile_Trainers读取写入 事务的接口:

class IQueryHandler: 
    pass 
class ICommandHandler: 
    pass

这里,IQueryHandlerICommandHandler 是非正式接口,因为 Python 没有实际的接口定义。

创建命令和查询类

接下来,我们需要实现命令和查询类。命令作为执行写入事务的指令。它还携带执行后的结果状态。另一方面,查询指示读取事务从数据库检索记录并在之后包含结果。这两个组件都是具有getter/setter属性的序列化类。以下代码展示了ProfileTrainerCommand的脚本,它使用 Python 的@property属性来存储INSERT执行的状 态:

from typing import Dict, Any
class ProfileTrainerCommand: 

    def __init__(self): 
        self._details:Dict[str,Any] = dict()

    @property
    def details(self):
        return self._details
    @details.setter
    def details(self, details):
        self._details = details

details属性将存储需要持久化的训练员资料记录的所有列值。

以下脚本实现了一个示例查询类:

class ProfileTrainerListQuery: 

    def __init__(self): 
        self._records:List[Profile_Trainers] = list()

    @property
    def records(self):
        return self._records
    @records.setter
    def records(self, records):
        self._records = records

ProfileTrainerListQuery的构造函数准备了一个字典对象,该对象将在查询事务执行后包含所有检索到的记录。

创建命令和查询处理程序

我们将使用之前的接口来定义命令和查询处理程序。请注意,命令处理程序访问和执行存储库以执行写入事务,而查询处理程序处理读取事务。这些处理程序作为 API 服务和存储库层之间的外观。以下代码展示了AddTrainerCommandHandler的脚本,它管理训练员资料的INSERT事务:

from cqrs.handlers import ICommandHandler
from repository.gino.trainers import TrainerRepository
from cqrs.commands import ProfileTrainerCommand
class AddTrainerCommandHandler(ICommandHandler): 

    def __init__(self): 
        self.repo:TrainerRepository = TrainerRepository()

    async def handle(self, 
             command:ProfileTrainerCommand) -> bool:
        result = await self.repo.
               insert_trainer(command.details)
        return result

处理程序依赖于ProfileTrainerCommand来获取对异步执行其handle()方法至关重要的记录值。

以下脚本展示了查询处理程序的示例实现:

class ListTrainerQueryHandler(IQueryHandler): 
    def __init__(self): 
        self.repo:TrainerRepository = TrainerRepository()
        self.query:ProfileTrainerListQuery = 
             ProfileTrainerListQuery()

    async def handle(self) -> ProfileTrainerListQuery:
        data = await self.repo.get_all_member();
        self.query.records = data
        return self.query

查询处理程序将它们的查询返回给服务,而不是实际的值。ListTrainerQueryHandlerhandle()方法返回ProfileTrainerListQuery,其中包含从读取事务中检索到的记录列表。这种机制是应用 CQRS 到微服务的主要目标之一。

访问处理程序

CQRS(Command Query Responsibility Segregation)除了管理读取写入执行之间的摩擦外,不允许 API 服务直接与 CRUD 事务的执行进行交互。此外,它通过仅分配特定服务所需的处理程序来简化并简化 CRUD 事务的访问。

以下脚本展示了AddTrainerCommand如何仅直接关联到add_trainer()服务,以及LisTrainerQueryHandler如何仅直接关联到list_trainers()服务:

from cqrs.commands import ProfileTrainerCommand
from cqrs.queries import ProfileTrainerListQuery
from cqrs.trainers.command.create_handlers import 
      AddTrainerCommandHandler
from cqrs.trainers.query.query_handlers import 
      ListTrainerQueryHandler
router = APIRouter(dependencies=[Depends(get_db)])
@router.post("/trainer/add" )
async def add_trainer(req: ProfileTrainersReq): 
    handler = AddTrainerCommandHandler()
    mem_profile = dict()
    mem_profile["id"] = req.id
    … … … … … …
    mem_profile["shift"] = req.shift
    command = ProfileTrainerCommand()
    command.details = mem_profile
    result = await handler.handle(command)
    if result == True: 
        return req 
    else: 
        return JSONResponse(content={'message':'create 
          trainer profile problem encountered'}, 
            status_code=500)
@router.get("/trainer/list")
async def list_trainers(): 
    handler = ListTrainerQueryHandler()
    query:ProfileTrainerListQuery = await handler.handle() 
    return query.records

我们可以通过 CQRS 在APIRouter中识别出频繁访问的事务。这有助于我们找到哪些事务需要性能调整和关注,这可以帮助我们在访问量增加时避免性能问题。当涉及到增强和升级时,设计模式可以帮助开发者找到优先考虑的领域,因为仓库层中的方面是分离的。通常,当业务流程需要重整时,它为应用程序提供了灵活性。所有与 CQRS 相关的脚本都可以在/cqrs/项目文件夹中找到。

摘要

对于任何应用程序,应用 ORM 总是有利有弊。它可能会因为过多的配置和组件层而使应用程序膨胀,如果管理不当,甚至可能减慢应用程序的速度。但总的来说,ORM 可以通过使用其 API 简化结构,消除不重要的重复 SQL 脚本,帮助优化查询开发。与使用psycopg2cursor相比,ORM 可以减少软件开发的时间和成本。

在本章中,使用了四个 Python ORM 进行研究和实验,以帮助 FastAPI 创建其仓库层。首先,是SQLAlchemy,它提供了一种创建标准异步数据持久性和查询操作的模板化方法。然后是GINO,它使用 AsyncIO 环境通过其便捷的语法实现异步 CRUD 事务。还有Pony,它是所展示 ORM 中最 Pythonic 的,因为它使用纯 Python 代码构建其仓库事务。最后是Peewee,以其简洁的语法而闻名,但在异步数据库连接和 CRUD 事务的复杂组合方面较为棘手。每个 ORM 都有其优势和劣势,但所有 ORM 都提供了一个逻辑解决方案,而不是应用蛮力和原生 SQL。

如果 ORM 需要微调,我们可以通过使用与数据相关的模式,如 CQRS,来添加一些优化程度,这有助于最小化“读取”和“写入”CRUD 事务之间的摩擦。

本章强调了 FastAPI 在利用 ORM 连接到关系型数据库(如 PostgreSQL)时的灵活性。但如果我们使用如 MongoDB 这样的 NoSQL 数据库来存储信息呢?FastAPI 在执行对 MongoDB 的 CRUD 操作时,性能是否会保持相同水平?下一章将讨论将 FastAPI 集成到 MongoDB 的各种解决方案。

第六章:使用非关系型数据库

到目前为止,我们已经了解到关系型数据库使用表列和行来存储数据。所有这些表记录都是通过不同的键(如主键、唯一键和组合键)进行结构优化和设计的。表通过外键/参考键连接。外键完整性在数据库模式表的表关系方面起着重要作用,因为它为存储在表中的数据提供了一致性和完整性。第五章,连接到关系型数据库,提供了相当多的证据,表明 FastAPI 可以通过任何现有的 ORM 平滑地连接到关系型数据库,而无需大量复杂性。这次,我们将专注于将非关系型数据库作为我们的 FastAPI 微服务应用程序的数据存储。

如果 FastAPI 使用 ORM 进行关系型数据库,它使用 对象文档映射 (ODM) 来管理使用非关系型数据存储或 NoSQL 数据库的数据。ODM 不涉及表、键和外键约束,但需要一个 JSON 文档来存储各种信息。不同的 NoSQL 数据库在存储模型类型上有所不同,用于存储数据。这些数据库中最简单的是将数据管理为键值对,例如 Redis,而复杂的数据库则使用无模式的文档结构,这些结构可以轻松地映射到对象。这通常在 MongoDB 中完成。一些使用列式数据存储,如 Cassandra,而一些则具有图导向的数据存储,如 Neo4j。然而,本章将重点介绍 FastAPI-MongoDB 连接以及我们可以应用的不同 ODM,以实现基于文档的数据库的数据管理。

本章的主要目标是研究、规范和审查不同的方法,以将 MongoDB 作为我们的 FastAPI 应用程序的数据库。构建存储库层和展示 CRUD 实现将是主要亮点。

在本章中,我们将涵盖以下主题:

  • 设置数据库环境

  • 应用 PyMongo 驱动程序进行同步连接

  • 使用 Motor 创建异步 CRUD 事务

  • 使用 MongoEngine 实现 CRUD 事务

  • 使用 Beanie 实现异步 CRUD 事务

  • 使用 ODMantic 为 FastAPI 构建异步存储库

  • 使用 MongoFrames 创建 CRUD 事务

技术要求

本章重点介绍一个电子书店网络门户,在线图书转售系统,用户可以通过互联网在家买卖书籍。虚拟商店允许用户查看卖家资料图书目录订单列表购买档案。在电子商务方面,用户可以选择他们偏好的书籍并将它们添加到购物车中。然后,他们可以检查订单并随后进行支付交易。所有数据都存储在 MongoDB 数据库中。本章的代码可以在github.com/PacktPublishing/Building-Python-Microservices-with-FastAPIch06项目中找到。

设置数据库环境

在我们开始讨论应用程序的数据库连接之前,我们需要从www.mongodb.com/try/download/community下载适当的 MongoDB 数据库服务器。在线图书转售系统在 Windows 平台上使用 MongoDB 5.0.5。安装将提供默认的服务配置详细信息,包括服务名称、数据目录和日志目录。然而,建议您使用不同的目录路径而不是默认路径。

安装完成后,我们可以通过运行/bin/mongod.exe来启动 MongoDB 服务器。这将自动在C:/驱动器(Windows)中创建一个名为/data/db的数据库目录。我们可以将/data/db目录放置在其他位置,但请确保在运行mongod命令时使用--dbpath选项并指定<new path>/data/db

MongoDB 平台有可以辅助管理数据库集合的工具,其中之一是MongoDB Compass。它可以提供一个 GUI 体验,允许您浏览、探索并轻松操作数据库及其集合。此外,它还内置了性能指标、查询视图和模式可视化功能,有助于检查数据库结构的正确性。以下截图显示了 MongoDB Compass 版本 1.29.6 的仪表板:

![图 6.1 – MongoDB Compass 仪表板图 6.2 – obrs 数据库的类图

图 6.1 – MongoDB Compass 仪表板

前面的仪表板显示了profile和销售书籍列表的文档结构。

一旦服务器和实用工具安装完成,我们需要使用obrs为我们的数据库设计数据集合。

![图 6.2 – obrs 数据库的类图图 6.1 – MongoDB Compass 仪表板

图 6.2 – obrs 数据库的类图

我们的应用程序使用前面图中显示的所有集合来存储从客户端捕获的所有信息。每个上下文框代表一个集合,框内显示了所有属性和预期的底层事务。它还显示了将这些集合关联起来的关联,例如loginprofile之间的一对一关联以及BookForSaleUserProfile之间的多对一关联。

现在数据库服务器已经安装和设计好了,让我们看看从我们的 FastAPI 微服务应用程序到其 MongoDB 数据库的不同连接方式。

应用 PyMongo 驱动程序进行同步连接

我们将首先学习 FastAPI 应用程序如何使用 PyMongo 数据库驱动程序连接到 MongoDB。此驱动程序相当于psycopg2,它允许我们无需使用任何 ORM 即可访问 PostgreSQL。一些流行的 ODM(对象文档映射器),如 MongoEngine 和 Motor,使用 PyMongo 作为其核心驱动程序,这让我们有理由在触及关于流行 ODM 的问题之前首先探索 PyMongo。研究驱动程序的行为可以提供基线事务,这将展示 ODM 如何构建数据库连接、模型和 CRUD 事务。但在我们深入细节之前,我们需要使用pip安装pymongo扩展:

pip install pymongo 

设置数据库连接

PyMongo 使用其MongoClient模块类来连接到任何 MongoDB 数据库。我们通过指定主机和端口来实例化它,以提取客户端对象,例如MongoClient("localhost", "27017"),或数据库 URI,例如MongoClient('mongodb://localhost:27017/')。我们的应用程序使用后者来连接到其数据库。但如果我们在实例化时未提供参数,它将使用默认的localhost27017详情。

在提取客户端对象之后,我们可以通过点操作符(.)或attribute-style access(属性式访问)来使用它访问数据库,前提是数据库名称遵循 Python 命名约定;例如,client.obrs。否则,我们可以使用方括号符号([])或字典式访问;例如,client["obrs_db"]。一旦检索到数据库对象,我们就可以使用访问规则来访问集合。请注意,集合在关系型数据库中相当于表,其中存储了称为文档的已排序记录。以下代码展示了应用程序用于打开数据库连接并访问准备 CRUD 实现所需集合的生成器函数:

from pymongo import MongoClient
def create_db_collections():
    client = MongoClient('mongodb://localhost:27017/')
    try:
        db = client.obrs
        buyers = db.buyer
        users = db.login
        print("connect")
        yield {"users": users, "buyers": buyers}
    finally:
        client.close()

像这样的生成器函数,如create_db_collections(),更受欢迎,因为yield语句在管理数据库连接时比return语句表现得更好。当yield语句向调用者发送一个值时,它会暂停函数的执行,但保留函数可以从中恢复执行的状态。这个特性被生成器应用于在finally子句中恢复执行时关闭数据库连接。另一方面,return语句不适用于此目的,因为return会在向调用者发送值之前完成整个事务。

然而,在我们调用生成器之前,让我们仔细审查 PyMongo 如何构建其模型层以追求必要的 CRUD 事务。

构建模型层

MongoDB 中的文档以 JSON 风格的格式表示和整理,具体来说是 BSON 文档。BSON 文档比 JSON 结构提供了更多的数据类型。我们可以使用字典来表示和持久化这些 BSON 文档在 PyMongo 中。一旦字典被持久化,BSON 类型的文档将看起来像这样:

{
   _id:ObjectId("61e7a49c687c6fd4abfc81fa"),
   id:1,
   user_id:10,
   date_purchased:"2022-01-19T00:00:00.000000",
   purchase_history: 
   [
       {
        purchase_id:100,
        shipping_address:"Makati City",
        email:"mailer@yahoo.com",
        date_purchased:"2022-01-19T00:00:00.000000",
        date_shipped:"2022-01-19T00:00:00.000000",
        date_payment:"2022-01-19T00:00:00.000000"
      },
      {
        purchase_id:110,
        shipping_address:"Pasig City",
        email:"edna@yahoo.com",
        date_purchased:"2022-01-19T00:00:00.000000",
        date_shipped:"2022-01-19T00:00:00.000000",
        date_payment:"2022-01-19T00:00:00.000000"
      }
    ],
   customer_status: 
   {
        status_id:90,
        name:"Sherwin John C. Tragura",
        discount:50,
        date_membership:"2022-01-19T00:00:00.000000"
   }
}

常见的 Python 数据类型,如strintfloat,都由 BSON 规范支持,但有一些类型,如ObjectIdDecimal128RegExBinary,仅限于bson模块。规范只支持timestampdatetime时间类型。要安装bson,请使用以下pip命令:

pip install bson

重要注意事项

BSON代表Binary JSON,是类似 JSON 文档的序列化和二进制编码。其背后的规范轻量级且灵活。高效的编码格式在bsonspec.org/spec.html中解释得更详细。

ObjectId是 MongoDB 文档中的一个基本数据类型,因为它作为主文档结构的唯一标识符。它是一个12 字节的字段,由一个 4 字节的 UNIX嵌入时间戳、MongoDB 服务器的 3 字节机器 ID、2 字节进程 ID和 3 字节任意值组成,用于 ID 的增加。通常,文档中声明的字段_id始终指的是文档结构的ObjectId值。我们可以允许 MongoDB 服务器为文档生成_id对象,或者在持久化期间创建该对象类型的实例。当检索时,ObjectId可以是24 个十六进制数字字符串格式。请注意,_id字段是字典准备好作为有效 BSON 文档持久化的关键指标。现在,BSON 文档也可以通过一些关联相互链接。

建立文档关联

MongoDB 没有参照完整性约束的概念,但基于结构,文档之间可以存在关系。存在两种类型的文档:文档和嵌入文档。如果一个文档是另一个文档的嵌入文档,则它与另一个文档具有一对一关联。同样,如果一个文档中的列表与主文档结构相关联,则该文档具有多对一关联

之前的购买 BSON 文档显示了具有与客户状态嵌入文档一对一关联和与购买历史记录文档多对一关联的主要买家文档的样本。从该样本文档中可以看出,嵌入文档没有单独的集合,因为它们没有相应的主_id字段来使它们成为主文档。

使用 BaseModel 类进行事务

由于 PyMongo 没有预定义的模型类,FastAPI 的 Pydantic 模型可以用来表示 MongoDB 文档,并包含所有必要的验证规则和编码器。我们可以使用BaseModel类来包含文档细节,并执行插入更新删除事务,因为 Pydantic 模型与 MongoDB 文档兼容。以下模型正在我们的在线二手书销售应用程序中使用,以存储和检索买家购买历史记录客户状态文档细节:

 from pydantic import BaseModel, validator
from typing import List, Optional, Dict
from bson import ObjectId
from datetime import date
class PurchaseHistoryReq(BaseModel):
    purchase_id: int
    shipping_address: str 
    email: str   
    date_purchased: date
    date_shipped: date
    date_payment: date
    @validator('date_purchased')
    def date_purchased_datetime(cls, value):
        return datetime.strptime(value, 
           "%Y-%m-%dT%H:%M:%S").date()

    @validator('date_shipped')
    def date_shipped_datetime(cls, value):
        return datetime.strptime(value, 
           "%Y-%m-%dT%H:%M:%S").date()

    @validator('date_payment')
    def date_payment_datetime(cls, value):
        return datetime.strptime(value, 
           "%Y-%m-%dT%H:%M:%S").date()

    class Config:
        arbitrary_types_allowed = True
        json_encoders = {
            ObjectId: str
        }

class PurchaseStatusReq(BaseModel):
    status_id: int 
    name: str
    discount: float 
    date_membership: date
    @validator('date_membership')
    def date_membership_datetime(cls, value):
        return datetime.strptime(value, 
            "%Y-%m-%dT%H:%M:%S").date()

    class Config:
        arbitrary_types_allowed = True
        json_encoders = {
            ObjectId: str
        }

class BuyerReq(BaseModel):
    _id: ObjectId
    Buyer_id: int
    user_id: int
    date_purchased: date
    purchase_history: List[Dict] = list()
    customer_status: Optional[Dict]
    @validator('date_purchased')
    def date_purchased_datetime(cls, value):
        return datetime.strptime(value, 
            "%Y-%m-%dT%H:%M:%S").date()

    class Config:
        arbitrary_types_allowed = True
        json_encoders = {
            ObjectId: str
        }

为了让这些请求模型识别 BSON 数据类型,我们需要对这些模型的默认行为进行一些修改。就像在本章早期,我们添加了orm_mode选项一样,我们还需要在BaseModel蓝图上添加一个嵌套的Config类,并将arbitrary_types_allowed选项设置为True。这个额外的配置将识别在属性声明中使用的 BSON 数据类型,包括符合对应 BSON 数据类型所需的基本验证规则。此外,json_encoders选项也应包含在配置中,以便在查询事务期间将文档的ObjectId属性转换为字符串。

使用 Pydantic 验证

然而,某些其他类型对于json_encoders来说过于复杂,无法处理,例如将 BSON 的datettime字段转换为 Python 的datetime.date。由于 ODM 无法自动将 MongoDB 的日期时间转换为 Python 的date类型,我们需要创建自定义验证并通过 Pydantic 的@validation装饰器解析这个 BSON datetime。我们还需要在 FastAPI 服务中使用自定义验证器和解析器将所有传入的 Python 日期参数转换为 BSON 日期时间。这将在稍后进行介绍。

@validator创建一个接受字段(s)的class name作为第一个参数的方法,而不是要验证和解析的实例。它的第二个参数是一个选项,指定需要转换为其他数据类型(如PurchaseRequestReq模型的date_purchaseddate_shippeddate_payment)的字段名或类属性。@validatorpre属性告诉 FastAPI 在 API 服务实现中执行任何内置验证之前先处理类方法。如果请求模型有任何自定义和内置的 FastAPI 验证规则,这些方法将在APIRouter运行其自定义和内置的 FastAPI 验证规则之后立即执行。

注意,这些请求模型已被放置在应用程序的/models/request/buyer.py模块中。

使用 Pydantic 的@dataclass查询文档

使用BaseModel模型类包装查询的 BSON 文档仍然是实现查询事务的最佳方法。但由于 BSON 与 Python 的datetime.date字段存在问题,我们并不能总是通过包装检索到的 BSON 文档来利用用于 CRUD 事务的请求模型类。有时,使用模型会导致出现"invalid date format (type=value_error.date)"错误,因为所有模型都有 Python 的datetime.date字段,而传入的数据有 BSON 的datetimetimestamp。为了避免在请求模型中增加更多复杂性,我们应该求助于另一种提取文档的方法——即利用 Pydantic 的@dataclass。以下数据类被定义为包装提取的buyer文档:

from pydantic.dataclasses import dataclass
from dataclasses import field
from pydantic import validator
from datetime import date, datetime
from bson import ObjectId
from typing import List, Optional
class Config:
        arbitrary_types_allowed = True
@dataclass(config=Config)
class PurchaseHistory:
    purchase_id: Optional[int] = None
    shipping_address: Optional[str] = None
    email: Optional[str] = None   
    date_purchased: Optional[date] = "1900-01-01T00:00:00"
    date_shipped: Optional[date] = "1900-01-01T00:00:00"
    date_payment: Optional[date] = "1900-01-01T00:00:00"

    @validator('date_purchased', pre=True)
    def date_purchased_datetime(cls, value):
        return datetime.strptime(value, 
           "%Y-%m-%dT%H:%M:%S").date()

    @validator('date_shipped', pre=True)
    def date_shipped_datetime(cls, value):
        return datetime.strptime(value, 
           "%Y-%m-%dT%H:%M:%S").date()

    @validator('date_payment', pre=True)
    def date_payment_datetime(cls, value):
        return datetime.strptime(value, 
           "%Y-%m-%dT%H:%M:%S").date()
@dataclass(config=Config)
class PurchaseStatus:
    status_id: Optional[int] = None
    name: Optional[str] = None
    discount: Optional[float] = None
    date_membership: Optional[date] = "1900-01-01T00:00:00"

    @validator('date_membership', pre=True)
    def date_membership_datetime(cls, value):
        return datetime.strptime(value, 
           "%Y-%m-%dT%H:%M:%S").date()

@dataclass(config=Config)
class Buyer:
    buyer_id: int 
    user_id: int 
    date_purchased: date 
    purchase_history: List[PurchaseHistory] = 
          field(default_factory=list )
    customer_status: Optional[PurchaseStatus] = 
          field(default_factory=dict)
    _id: ObjectId = field(default=ObjectId())

    @validator('date_purchased', pre=True)
    def date_purchased_datetime(cls, value):
        print(type(value))
        return datetime.strptime(value, 
             "%Y-%m-%dT%H:%M:%S").date()

@dataclass是一个装饰器函数,它向 Python 类添加一个__init__()方法来初始化其属性和其他特殊函数,例如__repr__()。前面代码中显示的PurchasedHistoryPurchaseStatusBuyer自定义类是典型的可以转换为请求模型类的类。FastAPI 在创建模型类时支持BaseModel和数据类。除了位于Pydantic模块下外,使用@dataclass在创建模型类时并不是BaseModel的替代品。这是因为这两个组件在灵活性、特性和钩子方面有所不同。BaseModel易于配置,可以适应许多验证规则和类型提示,而@dataclass在识别某些Config属性方面存在问题,例如extraallow_population_by_field_namejson_encoders。如果一个数据类需要一些额外的细节,就需要一个自定义类来定义这些配置并设置装饰器的config参数。例如,前面代码中的Config类,它将arbitrary_types_allowed设置为True,已被添加到三个模型类中。

除了 config,装饰器还有其他参数,如 initeqrepr,它们接受 bool 值以生成它们各自的钩子方法。当设置为 True 时,frozen 参数启用有关字段类型不匹配的异常处理。

当涉及到数据处理、转换时,@dataclass 总是依赖于增强验证,与可以通过添加 json_encoders 简单处理数据类型转换的 BaseModel 不同。在之前展示的数据类中,所有验证器都集中在文档检索过程中的 BSON datetime 到 Python datetime.date 的转换。这些验证将在 APIRouter 中的任何自定义或内置验证之前发生,因为 @validator 装饰器的 pre 参数被设置为 True

在处理默认值时,BaseModel 类可以使用典型的类型提示,如 Optional,或对象实例化,如 dict()list(),来定义其复杂属性的预条件状态。使用 @dataclass,当类型提示应用于设置复杂字段类型(如 listdictObjectId)的默认值时,总是在编译时抛出 ValueError 异常。它需要 Python 的 dataclasses 模块的 field() 指定器来设置这些字段的默认值,无论是通过指定器的 default 参数分配实际值,还是通过 default_factory 参数调用函数或 lambda 来返回有效值。使用 field() 指示 Pydantic 的 @dataclass 是 Python 核心数据类的精确替代,但有一些附加功能,例如 config 参数和包含 @validator 组件。

注意,建议在使用类型提示或 field() 时,所有 @dataclass 模型都有默认值,特别是对于嵌入文档和具有 datedatetime 类型的模型,以避免一些缺少构造函数参数的错误。另一方面,@dataclass 也可以在 BaseModel 类中创建嵌入结构,例如,通过定义具有类类型的属性。这在 Buyer 模型中得到了强调。

所有这些模型类都放置在 /models/data/pymongo.py 脚本中。现在让我们应用这些数据模型来创建存储层。

实现存储层

PyMongo 需要 collection 来构建应用程序的存储层。除了 collection 对象外,insertdeleteupdate 事务还需要 BaseModel 类来包含客户端的所有详细信息,并在事务后将它们转换为 BSON 文档。同时,我们的查询事务将需要数据类在文档检索过程中将所有 BSON 文档转换为可 JSON 化的资源。现在,让我们看看如何使用 PyMongo 驱动程序实现存储层。

构建 CRUD 事务

下面的代码块中的仓库类实现了基于在线二手书交易平台基本规范的buyerpurchase_historycustomer_status信息管理的 CRUD 事务:

from typing import Dict, Any
class BuyerRepository: 

    def __init__(self, buyers): 
        self.buyers = buyers

    def insert_buyer(self, users, 
          details:Dict[str, Any]) -> bool: 
        try:
           user = users.find_one(
                {"_id": details["user_id"]})
           print(user)
           if user == None:
               return False
           else: 
               self.buyers.insert_one(details)

        except Exception as e:
            return False 
        return True

让我们检查insert_buyer(),它插入有关在系统中作为login用户进行了一些先前交易的注册书买家的详细信息。PyMongo 集合提供了处理 CRUD 事务的辅助方法,例如insert_one(),它从其Dict参数添加单个主要文档。它还有一个insert_many(),它接受一个有效的字典列表,可以作为多个文档持久化。这两个方法在插入过程中可以为 BSON 文档的_id字段生成ObjectId。买家的详细信息是从BuyerReq Pydantic 模型中提取的。

接下来,update_buyer()展示了如何更新buyer集合中的特定文档:

    def update_buyer(self, id:int, 
              details:Dict[str, Any]) -> bool: 
       try:
          self.buyers.update_one({"buyer_id": id},
                  {"$set":details})
       except: 
           return False 
       return True

    def delete_buyer(self, id:int) -> bool: 
        try:
            self.buyers.delete_one({"buyer_id": id})
        except: 
            return False 
        return True

集合有一个update_one()方法,需要两个参数:一个唯一且有效的字段/值字典对,将作为记录搜索的搜索键,以及另一个具有预定义的$set键的字典对,包含更新的替换详情。它还有一个update_many(),可以更新多个文档,前提是主要字典字段/值参数不是唯一的。

delete_buyer()是删除buyer文档的事务,使用唯一且有效的字段/值对,例如{"buyer_id": id}。如果此参数或搜索键是常见/非唯一数据,集合提供delete_many(),可以删除多个文档。现在,以下脚本展示了如何在 PyMongo 中实现查询事务

from dataclasses import asdict
from models.data.pymongo import Buyer
from datetime import datetime
from bson.json_util import dumps
import json
    … … …
    … … …  
    … … …
    def get_all_buyer(self):
        buyers = [asdict(Buyer(**json.loads(dumps(b)))) 
              for b in self.buyers.find()]
        return buyers

    def get_buyer(self, id:int): 
        buyer = self.buyers.find_one({"buyer_id": id})
        return asdict(Buyer(**json.loads(dumps(buyer))))

在查询文档时,PyMongo 有一个find()方法,用于检索集合中的所有文档,还有一个find_one()方法,可以获取一个唯一且单个文档。这两种方法都需要两个参数:以字典字段/值对形式表示的条件或逻辑查询参数,以及需要在记录中出现的字段集合。前一个代码块中的get_buyer()展示了如何通过唯一的buyer_id字段检索买家文档。其第二个参数的缺失意味着结果中包含所有字段。同时,get_all_buyer()在不加任何约束的情况下检索所有买家文档。约束或过滤表达式使用 BSON 比较运算符来制定,如下表所示:

例如,检索user_id大于 5 的买家文档需要使用buyers.find({"user_id": {"$gte": 5}})查询操作。如果我们需要构建复合过滤器,我们必须应用以下逻辑运算符:

检索buyer_id小于 50 且buyer_id大于 10 的买家文档需要使用find({'and': [{'buyer_id': {'$lt': 50}}, {'user_id':{'$gt':10}}]})查询。

两种方法都返回不是 FastAPI 框架可序列化为 JSON 的 BSON 文档。要将文档转换为 JSON,bson.json_util 扩展有一个 dumps() 方法可以将单个文档或文档列表转换为 JSON 字符串。get_all_buyer()get_buyer() 都将检索到的每个文档转换为 JSON,以便每个文档都可以映射到 Buyer 数据类。映射的主要目标是转换 datetime 字段为 Python datetime.date,同时利用 Buyer 数据类的验证器。映射只有在使用 json 扩展的 loads() 方法将 str 转换为 dict 数据结构时才会成功。在生成 Buyer 数据类的列表后,需要 Python 的 dataclasses 模块的 asdict() 方法将 Buyer 数据类的列表转换为字典列表,以便由 APIRouter 消费。

管理文档关联

在 PyMongo 中,技术上构建文档关联有两种方式。第一种是使用 bison.dbref 模块的 DBRef 类来链接父文档和子文档。唯一的前提是两个文档都必须有一个 ObjectId 类型的 _id 值,并且它们各自的集合必须存在。例如,如果 PurchaseHistoryReq 是核心文档,我们可以通过以下查询将一条购买记录插入列表中:

buyer["purchase_history"].append(new  DBRef("purchase_history", "49a3e4e5f462204490f70911"))

在这里,DBRef 构造函数的第一个参数是子文档放置的集合名称,而第二个参数是子文档的 ObjectId 属性的字符串格式。然而,有些人使用 ObjectId 实例而不是字符串版本。另一方面,要使用 DBRefbuyer 集合中查找特定的 purchase_history 文档,我们可以编写如下查询:

buyer.find({ "purchase_history ": DBRef("purchase_history",ObjectId("49a3e4e5f462204490f70911")) })

第二种方法是通过 BuyerReq 模型将整个 BSON 文档结构添加到 buyerlist 字段中。此解决方案适用于没有 _idcollection 但对核心文档至关重要的嵌入式文档。以下代码中的 add_purchase_history() 展示了如何应用这种方法在 purchase_historybuyer 文档之间创建多对一关联:

def add_purchase_history(self, id:int, 
                details:Dict[str, Any]): 
        try:
            buyer = self.buyers.find_one({"buyer_id": id})
            buyer["purchase_history"].append(details)
            self.buyers.update_one({"buyer_id": id},
           {"$set": {"purchase_history": 
                     buyer["purchase_history"]}})
        except Exception as e: 
           return False 
        return True

    def add_customer_status(self, id:int, 
                  details:Dict[str, Any]): 
        try:
            buyer = self.buyers.find_one({"buyer_id": id})
            self.buyers.update_one({"buyer_id": id},
                {"$set":{"customer_status": details}})
        except Exception as e: 
           return False 
        return True

add_customer_status() 方法展示了如何实现第二种方法,在 buyerpurchase_status 文档之间建立一对一关联。如果 PurchaseStatusReq 是独立的核心文档,则涉及使用 DBRef 的第一种方法也可以应用。

完整的仓库类可以在 /repository/pymongo/buyer.py 脚本文件中找到。现在,让我们将这些 CRUD 事务应用到我们的 API 服务中。

执行事务

在执行BuyerRepository事务之前,应使用Dependscreate_db_collections()生成器注入到 API 服务中。由于 PyMongo 难以处理非 BSON 支持的 Python 类型,例如datetime.date,有时需要自定义验证和序列化器来执行某些事务。

重要提示

@dataclassBaseModel内部的@validator实现将查询检索期间的输出 BSON datetime参数转换为 Python date。同时,在此 API 层中的 JSON 编码器验证在从应用程序到 MongoDB 的转换过程中将传入的 Python date值转换为 BSON datetime值。

例如,以下代码中的add_buyer()update_buyer()add_purchase_history()事务方法需要自定义序列化器,如json_serialize_date(),以将 Python datetime.date值转换为datetime.datetime类型,以便符合 PyMongo 的 BSON 规范:

from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse
from models.request.buyer import BuyerReq, 
      PurchaseHistoryReq, PurchaseStatusReq
from repository.pymongo.buyer import BuyerRepository
from db_config.pymongo_config import create_db_collections
from datetime import date, datetime
from json import dumps, loads
from bson import ObjectId
router = APIRouter()
def json_serialize_date(obj):
    if isinstance(obj, (date, datetime)):
        return obj.strftime('%Y-%m-%dT%H:%M:%S')
    raise TypeError ("The type %s not serializable." % 
            type(obj))
def json_serialize_oid(obj):
    if isinstance(obj, ObjectId):
        return str(obj)
    elif isinstance(obj, date):
        return obj.isoformat()
    raise TypeError ("The type %s not serializable." % 
            type(obj))
@router.post("/buyer/add")
def add_buyer(req: BuyerReq, 
            db=Depends(create_db_collections)): 
    buyer_dict = req.dict(exclude_unset=True)
    buyer_json = dumps(buyer_dict, 
              default=json_serialize_date)
    repo:BuyerRepository = BuyerRepository(db["buyers"])
    result = repo.insert_buyer(db["users"], 
            loads(buyer_json))  

    if result == True: 
        return JSONResponse(content={"message": 
          "add buyer successful"}, status_code=201) 
    else: 
        return JSONResponse(content={"message": 
          "add buyer unsuccessful"}, status_code=500) 
@router.patch("/buyer/update")
def update_buyer(id:int, req:BuyerReq, 
           db=Depends(create_db_collections)): 
    buyer_dict = req.dict(exclude_unset=True)
    buyer_json = dumps(buyer_dict, 
             default=json_serialize_date)
    repo:BuyerRepository = BuyerRepository(db["buyers"])
    result = repo.update_buyer(id, loads(buyer_json))  

    if result == True: 
        return JSONResponse(content={"message": 
         "update buyer successful"}, status_code=201) 
    else: 
        return JSONResponse(content={"message": 
         "update buyer unsuccessful"}, status_code=500)
@router.post("/buyer/history/add")
def add_purchase_history(id:int, req:PurchaseHistoryReq, 
           db=Depends(create_db_collections)): 
    history_dict = req.dict(exclude_unset=True)
    history_json = dumps(history_dict, 
           default=json_serialize_date)
    repo:BuyerRepository = BuyerRepository(db["buyers"])
    result = repo.add_purchase_history(id, 
           loads(history_json))  

json_serialize_date()函数成为dumps()方法的 JSON 序列化过程的一部分,但仅处理在将buyer详细信息转换为 JSON 对象时的类型转换。它在存储库类的INSERTUPDATE事务中应用,以提取BuyerReqPurchaseHistoryReqPurchaseStatusReq模型的序列化 JSON 字符串等效物。

现在,另一个自定义转换器应用于list_all_buyer()get_buyer()方法的数据检索:

@router.get("/buyer/list/all")
def list_all_buyer(db=Depends(create_db_collections)): 
  repo:BuyerRepository = BuyerRepository(db["buyers"])
  buyers = repo.get_all_buyer() 
  return loads(dumps(buyers, default=json_serialize_oid))
@router.get("/buyer/get/{id}")
def get_buyer(id:int, db=Depends(create_db_collections)): 
  repo:BuyerRepository = BuyerRepository(db["buyers"])
  buyer = repo.get_buyer(id)
  return loads(dumps(buyer, default=json_serialize_oid))

我们查询事务中涉及的数据模型是数据类,因此前两个查询方法的结果已经映射并转换为 JSON 格式。然而,不幸的是,它们对于 FastAPI 框架来说并不足以进行 JSON 序列化。除了 BSON datetime类型外,PyMongo ODM 无法自动将ObjectId转换为 Python 中的默认类型,因此在从 MongoDB 检索数据时抛出ValueError。为了解决这个问题,dumps()需要一个自定义序列化器,如json_serialize_oid(),将 MongoDB 中的所有ObjectId参数转换为 FastAPI 转换。它还将 BSON datetime值转换为遵循ISO-8601格式的 Python date值。来自dumps()的有效 JSON 字符串将使loads()方法能够为 FastAPI 服务生成可 JSON 序列化的结果。完整的 API 服务可以在/api/buyer.py脚本文件中找到。

在满足所有要求后,PyMongo 可以帮助使用 MongoDB 服务器存储和管理所有信息。然而,该驱动程序仅适用于同步 CRUD 事务。如果我们选择异步方式实现 CRUD,我们必须始终求助于 Motor 驱动程序。

使用 Motor 创建异步 CRUD 事务

电机是一个异步驱动器,它依赖于 FastAPI 的 AsyncIO 环境。它封装了 PyMongo,以产生创建异步存储库层所需的非阻塞和基于协程的类和方法。在大多数需求方面,它几乎与 PyMongo 相同,除了数据库连接和存储库实现。

但在继续之前,我们需要使用以下pip命令安装motor扩展:

pip install motor

设置数据库连接

使用 FastAPI 的AsyncIO平台,Motor 驱动器通过其AsyncIOMotorClient类打开到 MongoDB 数据库的连接。当实例化时,默认连接凭据始终是localhost27017端口。或者,我们可以通过其构造函数指定新的详细信息,格式为str。以下脚本展示了如何使用指定的数据库凭据创建全局AsyncIOMotorClient引用:

from motor.motor_asyncio import AsyncIOMotorClient
def create_async_db():
    global client
    client = AsyncIOMotorClient(str("localhost:27017"))
def create_db_collections():
    db = client.obrs
    buyers = db["buyer"]
    users = db["login"]
    return {"users": users, "buyers": buyers}
def close_async_db(): 
    client.close()

数据库 URI 的格式是一个包含冒号(:)分隔的详细信息的字符串。现在,应用程序需要以下 Motor 方法来启动数据库事务:

  • create_async_db(): 用于建立数据库连接和加载模式定义的方法

  • close_async_db(): 用于关闭连接的方法

APIRouter将需要事件处理器来管理这两个核心方法作为应用级事件。稍后,我们将注册create_async_db()作为启动事件,将close_async_db()作为关闭事件。另一方面,create_db_collections()方法创建了对loginbuyer集合的一些引用,这些引用将在后续的存储库事务中需要。

通常,创建数据库连接和获取文档集合的引用不需要async/await表达式,因为在这个过程中不涉及 I/O。这些方法可以在/db_config/motor_config.py脚本文件中找到。现在是时候创建 Motor 的存储库层了。

创建模型层

PyMongo 和 Motor 在创建请求和数据模型方面采用相同的方法。所有由 PyMongo 使用的基模型、数据类、验证器和序列化器也适用于 Motor 的连接。

构建异步存储库层

当涉及到 CRUD 实现时,PyMongo 和 Motor 在语法上略有不同,但在每个事务的性能上存在相当大的差异。它们用于插入、更新和删除文档的辅助方法,包括必要的方法参数,都是相同的,除了 Motor 有非阻塞版本。在存储库中调用非阻塞的 Motor 方法需要 async/await 表达式。以下是一个异步版本的 PyMongo 的BuyerRepository

class BuyerRepository: 

    def __init__(self, buyers): 
        self.buyers = buyers

    async def insert_buyer(self, users, 
           details:Dict[str, Any]) -> bool: 
        try:
           user = await users.find_one({"_id": 
                details["user_id"]})
           … … … … …
           else: 
               await self.buyers.insert_one(details)
           … … … … …
        return True

    async def add_purchase_history(self, id:int, 
            details:Dict[str, Any]): 
        try:
            … … … … …
            await self.buyers.update_one({"buyer_id": id},
                   {"$set":{"purchase_history": 
                     buyer["purchase_history"]}})
            … … … … …
        return True

前述代码块中的insert_buyer()被定义为async,因为insert_one()是一个非阻塞操作,需要await调用。同样,对于add_purchase_history(),它使用非阻塞的update_one()更新purchase_history嵌入文档:

    async def get_all_buyer(self):
        cursor = self.buyers.find()
        buyers = [asdict(Buyer(**json.loads(dumps(b)))) 
           for b in await cursor.to_list(length=None)]
        return buyers

    async def get_buyer(self, id:int): 
        buyer = await self.buyers.find_one(
                    {"buyer_id": id})
        return asdict(Buyer(**json.loads(dumps(buyer))))

delete_many()find_one()操作也是通过await表达式调用的。然而,在 Motor 中,find()不是异步的,其行为与 PyMongo 不同。原因是find()在 Motor 中不是一个 I/O 操作,它返回一个AsyncIOMotorCursor或异步游标,这是一个包含所有 BSON 文档的可迭代类型。我们在检索所有存储的文档时将async应用于游标。前述代码中的get_all_buyer()事务展示了我们如何调用find()操作并调用游标以提取 JSON 转换所需的必要文档。这个仓库类可以在/repository/motor/buyer.py脚本文件中找到。现在让我们将这些 CRUD 事务应用到我们的 API 服务中。

运行 CRUD 事务

为了让仓库与APIRouter一起工作,我们需要创建两个事件处理器来管理数据库连接和文档集合检索。第一个事件是启动事件,Uvicorn 服务器在应用程序运行之前执行,应该触发create_async_db()方法的执行以实例化AsyncIOMotorClient并对集合进行引用。第二个事件是关闭事件,当 Uvicorn 服务器关闭时运行,应该触发close_async_db()的执行以关闭连接。APIRouter有一个add_event_handler()方法来创建这两个事件处理器。以下是从APIRouter脚本中摘取的部分,展示了如何为BuyerRepository事务准备数据库连接:

… … … … … …
from db_config.motor_config import create_async_db,
  create_db_collections, close_async_db
… … … … … …
router = APIRouter()
router.add_event_handler("startup", 
            create_async_db)
router.add_event_handler("shutdown", 
            close_async_db)

"startup""shutdown"值是预构建的配置值,而不仅仅是任何任意的字符串值,用于指示事件处理器的类型。我们将在第八章《创建协程、事件和消息驱动事务》中更详细地讨论这些事件处理器。

在设置这些事件处理器之后,API 服务现在可以使用 await/async 表达式异步调用仓库事务。在 PyMongo 中应用的有效性和序列化工具也可以在这个版本的BuyerRepository中使用。在将create_db_collections()注入到 API 服务中后,集合将可供 API 服务使用。add_buyer() API 服务展示了使用 Motor 驱动程序实现异步 REST 事务的实现:

@router.post("/buyer/async/add")
async def add_buyer(req: BuyerReq, 
          db=Depends(create_db_collections)): 
    buyer_dict = req.dict(exclude_unset=True)
    buyer_json = dumps(buyer_dict, 
              default=json_serialize_date)
    repo:BuyerRepository = BuyerRepository(db["buyers"])

    result = await repo.insert_buyer(db["users"], 
                  loads(buyer_json))  
    if result == True: 
        return JSONResponse(content={"message":
            "add buyer successful"}, status_code=201) 
    else: 
        return JSONResponse(content={"message": 
            "add buyer unsuccessful"}, status_code=500)

使用 PyMongo 和 Mongo 驱动程序提供了 MongoDB 事务的最小和详尽的实现。每个 CRUD 事务的核心实现因开发者而异,用于审查和分析涉及过程的方法也以不同的方式管理。此外,没有定义文档字段的标准,例如数据唯一性字段值长度值范围,甚至添加唯一 ID的想法。为了解决围绕 PyMongo 和 Motor 的问题,让我们探索其他打开 MongoDB 连接以创建 CRUD 事务的方法,例如使用ODM

使用 MongoEngine 实现 CRUD 事务

MongoEngine 是一个 ODM,它使用 PyMongo 创建一个易于使用的框架,可以帮助管理 MongoDB 文档。它提供了 API 类,可以帮助使用其字段类型和属性元数据生成模型类。它提供了一种声明式的方式来创建和结构化嵌入文档。

在我们探索这个 ODM 之前,我们需要使用以下pip命令来安装它:

pip install mongoengine

建立数据库连接

MongoEngine 有建立连接最直接的方法之一。它的mongoengine模块有一个connect()辅助方法,当提供适当的数据库连接时,它会连接到 MongoDB 数据库。我们的应用程序必须有一个生成器方法来创建数据库连接的引用,并在事务过期后关闭此创建的连接。以下脚本展示了 MongoEngine 数据库连接性:

from mongoengine import connect
def create_db():
    try:
        db = connect(db="obrs", host="localhost", 
                 port=27017)
        yield db
    finally: 
        db.close()

connect()方法有一个强制性的第一个参数,名为db,它表示数据库名称。其余参数指的是数据库连接的其他详细信息,如hostportusernamepassword。此配置可以在/db_config/mongoengine_config.py脚本文件中找到。现在让我们为我们的 MongoEngine 存储库创建数据模型。

构建模型层

MongoEngine 通过其Document API 类提供了一个方便且声明式的方式来将 BSON 文档映射到模型类。一个模型类必须继承自Document以继承合格和有效 MongoDB 文档的结构和属性。以下是一个使用Document API 类创建的Login定义:

from mongoengine import Document, StringField, 
         SequenceField, EmbeddedDocumentField
import json
class Login(Document): 
    id = SequenceField(required=True, primary_key=True)
    username = StringField(db_field="username", 
         max_length=50, required=True, unique=True)
    password = StringField(db_field="password", 
         max_length=50, required=True)
    profile = EmbeddedDocumentField(UserProfile, 
         required=False)

    def to_json(self):
            return {
            "id": self.id,
            "username": self.username,
            "password": self.password,
            "profile": self.profile
        }

    @classmethod
    def from_json(cls, json_str):
        json_dict = json.loads(json_str)
        return cls(**json_dict)

与 PyMongo 和 Motor 驱动程序不同,MongoEngine 可以使用其Field类及其属性来定义类属性。其中一些Field类包括StringFieldIntFieldFloatFieldBooleanFieldDateField。这些可以分别声明strintfloatbooldatetime.date类属性。

另一个这个 ODM 的便利功能是它可以创建 SequenceField,它在关系数据库中的行为与 auto_increment 列字段相同,或者在对象关系数据库中的 Sequence 相同。模型类的 id 字段应声明为 SequenceField,以便作为文档的主键。像典型的序列一样,这个字段有增加其值或将其重置为零的实用程序,具体取决于必须访问哪个文档记录。

除了字段类型外,字段类还可以为 choicesrequireduniquemin_valuemax_valuemax_lengthmin_length 等属性提供字段参数,以对字段值施加约束。例如,choices 参数接受一个字符串值的可迭代对象,用作枚举。required 参数表示字段是否始终需要字段值,而 unique 参数表示字段值在集合中没有重复。违反 unique 参数将导致以下错误消息:

Tried to save duplicate unique keys (E11000 duplicate key error collection: obrs.login index: username_...)

min_valuemax_value 分别表示数值字段的最低和最高值。min_length 指定字符串值的最低长度,而 max_length 设置最大字符串长度。另一方面,db_field 参数在指定另一个文档字段名而不是类属性名时也可以应用。给定的 Login 类还定义了用于存储字符串值的 usernamepassword 字段,将主键定义为 SequenceField,并定义了一个嵌入文档字段以建立文档关联。

创建文档关联

Loginprofile 字段在 Login 文档和 UserProfile 之间创建了一个一对一的关联。但在关联可以工作之前,我们需要将 profile 字段定义为 EmbeddedDocumentField 类型,并将 UserProfile 定义为 EmbeddedDocument 类型。以下是 UserProfile 的完整蓝图:

class UserProfile(EmbeddedDocument):
   firstname = StringField(db_field="firstname", 
          max_length=50, required=True)
   lastname = StringField(db_field="lastname", 
          max_length=50, required=True)
   middlename = StringField(db_field="middlename", 
          max_length=50, required=True)
   position = StringField(db_field="position", 
          max_length=50, required=True)
   date_approved = DateField(db_field="date_approved", 
          required=True)
   status = BooleanField(db_field="status", required=True)
   level = IntField(db_field="level", required=True)
   login_id = IntField(db_field="login_id", required=True)
   booksale = EmbeddedDocumentListField(BookForSale, 
           required=False)

   def to_json(self):
            return {
            "firstname": self.firstname,
            "lastname": self.lastname,
            "middlename": self.middlename,
            "position": self.position,
            "date_approved": 
               self.date_approved.strftime("%m/%d/%Y"),
            "status": self.status,
            "level": self.level,
            "login_id": self.login_id,
            "books": self.books
        }

   @classmethod
   def from_json(cls, json_str):
        json_dict = json.loads(json_str)
        return cls(**json_dict)

EmbeddedDocument API 是一个没有 idDocument,并且没有自己的集合。这个 API 的子类是模型类,被创建为成为核心文档结构的一部分,例如 UserProfileLogin 详细信息的一部分。现在,指向这个文档的字段有一个 required 属性设置为 False,因为嵌入文档并不总是存在。

另一方面,声明为 EmbeddedDocumentList 的字段用于在文档之间创建多对一关联。由于声明的 booksale 字段,前面的 UserProfile 类与一系列 BookForSale 嵌入文档紧密相连。再次强调,字段类型应始终将其 required 属性设置为 False,以避免处理空值时出现问题。

应用自定义序列化和反序列化

在这个 ODM 中没有内置的验证和序列化钩子。在在线二手书交易应用程序中,每个模型类都实现了from_json()类方法,该方法将 JSON 详情转换为有效的Document实例。当将 BSON 文档转换为 JSON 对象时,模型类必须具有自定义的to_json()实例方法,该方法构建 JSON 结构,并通过格式化自动将 BSON datetime转换为可 JSON 化的date对象。现在让我们使用模型类创建仓库层。

实现 CRUD 事务

MongoEngine 提供了构建应用程序仓库层的最便捷和直接的方法。所有操作都来自Document模型类,并且易于使用。LoginRepository使用 ODM 来实现其 CRUD 事务:

from typing import Dict, Any
from models.data.mongoengine import Login
class LoginRepository: 

    def insert_login(self, details:Dict[str, Any]) -> bool: 
        try:
            login = Login(**details)
            login.save()
        except Exception as e:
            print(e)
            return False 
        return True

    def update_password(self, id:int, newpass:str) -> bool: 
       try:
          login = Login.objects(id=id).get()
          login.update(password=newpass)
       except: 
           return False 
       return True

    def delete_login(self, id:int) -> bool: 
        try:
            login = Login.objects(id=id).get()
            login.delete()
        except: 
            return False 
        return True

insert_login()方法仅需要两行代码来保存Login文档。在创建具有必要文档详情的Login实例后,我们只需调用Document实例的save()方法来执行插入事务。当涉及到修改某些文档值时,Document API 类有一个update()方法来管理每个类属性的状态变化。但首先,我们需要使用objects()实用方法来查找文档,该方法从集合中检索文档结构。此objects()方法可以通过提供带有id字段值的参数来获取文档,或者通过向方法提供通用搜索表达式来提取文档记录列表。检索到的文档实例必须调用其update()方法来执行某些字段值(如果不是所有字段值)的修改。给定的update_password()方法更新了Login的密码字段,这为我们提供了一个关于如何对其他字段属性执行更新操作的优秀模板。

另一方面,delete_login()展示了如何使用实例的简单delete()方法搜索对象后从其集合中删除Login文档。以下脚本展示了如何在 MongoEngine 中执行查询事务:

    def get_all_login(self):
        login = Login.objects()
        login_list = [l.to_json() for l in login]
        return login_list

    def get_login(self, id:int): 
        login = Login.objects(id=id).get()
        return login.to_json()

执行单文档或多文档检索的唯一方法是利用objects()方法。不需要为查询结果实现 JSON 转换器,因为每个Document模型类都有一个to_json()方法来提供实例的可 JSON 化等效物。给定的get_all_login()事务使用列表推导从objects()的结果创建 JSON 文档列表,而get_login()方法在提取单个文档后调用to_json()

管理嵌入式文档

使用 ODM 实现文档关联比使用核心 PyMongo 和 Motor 数据库驱动程序更容易。由于 MongoEngine 的操作使用起来很舒适,因此只需几行代码就可以管理嵌入式文档。在下面的 UserProfileRepository 脚本中,insert_profile() 展示了如何通过执行简单的对象搜索和 update() 调用来将 UserProfile 细节添加到 Login 文档中:

from typing import Dict, Any
from models.data.mongoengine import Login, UserProfile, 
      BookForSale
class UserProfileRepository(): 

    def insert_profile(self, login_id:int, 
             details:Dict[str, Any]) -> bool: 
        try:
            profile = UserProfile(**details)
            login = Login.objects(id=login_id).get()
            login.update(profile=profile)
        except Exception as e:
            print(e)
            return False 
        return True

    def add_book_sale(self, login_id:int, 
             details:Dict[str, Any]): 
        try:
            sale = BookForSale(**details)
            login = Login.objects(id=login_id).get()
            login.profile.booksale.append(sale) 
            login.update(profile=login.profile)
        except Exception as e:
            print(e)
            return False 
        return True

同样,给定的 add_book_sale() 事务使用与 insert_profile() 中相同的方法创建 BookForSaleUserProfile 之间的多对一关联,并添加了一个列表的 append() 操作。

在 MongoEngine 中查询嵌入式文档也是可行的。该 ODM 有一个 filter() 方法,它使用 字段查找语法 来引用特定的文档结构或嵌入式文档列表。这种字段查找语法由 嵌入式文档的字段名 组成,后面跟着一个 双下划线 来代替通常对象属性访问语法中的点。然后,它还有一个 另一个双下划线 来适应一些 运算符,例如 ltgteqexists。在下面的代码中,get_all_profile() 使用 profile__login_id__exists=True 字段查找来过滤所有具有有效 login 结构的 user_profile 嵌入式文档。然而,get_profile() 事务不需要使用 filter() 和字段查找,因为它可以直接访问特定的登录文档来获取其配置文件详细信息:

     def get_all_profile(self):
        profiles = Login.objects.filter(
               profile__login_id__exists=True)
        profiles_dict = list(
              map(lambda h: h.profile.to_json(), 
                Login.objects().filter(
                    profile__login_id__exists=True)))
        return profiles_dict

    def get_profile(self, login_id:int): 
        login = Login.objects(id=login_id).get()
        profile = login.profile.to_json()
        return profile

与一些其他复杂的 MongoEngine 查询相比,前面的查询事务只是简单的实现,这些查询涉及复杂的嵌入式文档结构,需要复杂的字段查找语法。现在,让我们将 CRUD 事务应用到我们的 API 服务中。

运行 CRUD 事务

如果不在 启动 事件中传递我们的 create_db() 方法到 启动 事件,以及不在 关闭 事件中传递 disconnect_db(),则 CRUD 将无法工作。前者将在 Uvicorn 启动期间打开 MongoDB 连接,而后者将在服务器关闭期间关闭它。

以下脚本显示了应用程序的 profile 路由器,其中包含一个 create_profile() REST 服务,该服务要求客户端提供一个配置文件详情,给定一个特定的登录记录,并使用 UserProfileRepository 追踪插入事务:

from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse
from models.request.profile import UserProfileReq, 
         BookForSaleReq
from repository.mongoengine.profile import 
         UserProfileRepository
from db_config.mongoengine_config import create_db
router = APIRouter()
@router.post("/profile/login/add", 
      dependencies=[Depends(create_db)])
def create_profile(login_id:int, req:UserProfileReq): 
    profile_dict = req.dict(exclude_unset=True)
    repo:UserProfileRepository = UserProfileRepository()
    result = repo.insert_profile(login_id, profile_dict)
    if result == True: 
        return req 
    else: 
        return JSONResponse(content={"message": 
          "insert profile unsuccessful"}, status_code=500) 

create_profile() 是一个标准的 API 服务,它处理 MongoEngine 的同步 insert_profile() 事务。当涉及到异步 REST 服务时,不建议使用 MongoEngine,因为它的平台仅适用于同步服务。在下一节中,我们将讨论一个在构建异步存储层时流行的 ODM。

使用 Beanie 实现异步事务

Beanie 是一个非模板映射器,它利用了 Motor 和 Pydantic 的核心功能。这个 ODM 提供了一种比其前身 Motor 驱动程序更直接的方法来实现异步 CRUD 事务。

要使用 Beanie,我们需要使用以下 pip 命令进行安装:

pip install beanie

重要提示

安装 Beanie 可能会卸载你当前版本的 Motor 模块,因为它有时需要较低版本的 Motor 模块。继续这样做将在你的现有 Motor 事务中产生错误。

创建数据库连接

Beanie 使用 Motor 驱动程序打开到 MongoDB 的数据库连接。使用数据库 URL 实例化 Motor 的 AsyncIOMotorClient 类是配置它的第一步。但与其它 ODM 相比,Beanie 的独特之处在于它如何预先初始化和识别将参与 CRUD 事务的模型类。该 ODM 有一个异步的 init_beanie() 辅助方法,使用数据库名称来初始化模型类。调用此方法还将设置集合域映射,其中所有模型类都注册在 init_beanie()document_models 参数中。以下脚本展示了访问我们的 MongoDB 数据库 obrs 所需的数据库配置:

from motor.motor_asyncio import AsyncIOMotorClient
from beanie import init_beanie
from models.data.beanie import Cart, Order, Receipt
async def db_connect():
    global client
    client = 
     AsyncIOMotorClient(f"mongodb://localhost:27017/obrs")
    await init_beanie(client.obrs, 
         document_models=[Cart, Order, Receipt])

async def db_disconnect():
     client.close()

在这里,db_connect() 使用了 async/await 表达式,因为它的方法调用 init_beanie() 是异步的。db_disconnect() 将通过调用 AsyncIOMotorClient 实例的 close() 方法来关闭数据库连接。这两个方法都作为事件执行,就像在 MongoEngine 中一样。它们的实现可以在 /db_config/beanie_config.py 脚本文件中找到。现在让我们创建模型类。

定义模型类

Beanie ODM 拥有一个 Document API 类,该类负责定义其模型类,将它们映射到 MongoDB 集合,并处理存储库事务,就像在 MongoEngine 中一样。尽管没有用于定义类属性的 Field 指令,但 ODM 支持 Pydantic 的验证和解析规则以及 typing 扩展来声明模型及其属性。但它也具有内置的验证和编码功能,可以与 Pydantic 一起使用。以下脚本展示了如何在配置过程中定义 Beanie 模型类:

from typing import Optional, List
from beanie import Document
from bson import datetime 
class Cart(Document):
    id: int 
    book_id: int 
    user_id: int
    qty: int
    date_carted: datetime.datetime
    discount: float

    class Collection:
        name = "cart"
    … … … … … …

class Order(Document):
    id: int 
    user_id: int
    date_ordered: datetime.datetime
    orders: List[Cart] = list()

    class Collection:
        name = "order"
    … … … … … …

class Receipt(Document): 
    id: int 
    date_receipt: datetime.datetime 
    total: float 
    payment_mode: int
    order: Optional[Order] = None

    class Collection:
        name = "receipt"
    class Settings:
        use_cache = True
        cache_expiration_time =    
             datetime.timedelta(seconds=10)
        cache_capacity = 10

给定 Document 类的 id 属性自动转换为 _id 值。这作为文档的主键。Beanie 允许你将默认的 ObjectId 类型的 _id 替换为另一种类型,例如 int,这在其他 ODM 中是不可能的。并且与 Motor 一起,这个 ODM 需要自定义 JSON 序列化器,因为它在 CRUD 事务期间难以将 BSON datetime 类型转换为 Python datetime.date 类型。

Beanie 中的文档可以通过添加CollectionSettings嵌套类进行配置。Collection类可以替换模型应该映射到的默认集合名称。如果需要,它还可以为文档字段提供索引。另一方面,Settings内部类可以覆盖现有的 BSON 编码器,应用缓存,管理并发更新,并在保存文档时添加验证。这三个模型类在其定义中将集合配置包含在内,以用它们的类名替换各自的集合名称。

创建文档关联

在这个映射器中,使用 Python 语法、Pydantic 规则和 API 类来建立文档之间的链接。例如,为了在OrderReceipt之间创建一对一的关联,我们只需要设置一个将链接到单个Receipt实例的Order字段属性。对于多对一关联,例如OrderCart之间的关系,Cart文档只需要一个包含所有嵌入的Order文档的列表字段。

然而,ODM 有一个Link类型,可以用来定义类字段以生成这些关联。它的 CRUD 操作,如save()insert()update(),在提供link_rule参数的情况下,强烈支持这些Link类型。对于查询事务,如果将fetch_links参数设置为Truefind()方法可以在获取文档时包括Link文档。现在,让我们使用模型类实现存储库层。

实现 CRUD 事务

使用 Beanie 实现存储库与 MongoEngine 类似——也就是说,由于Document API 类提供的方便的辅助方法(如 create()、update()和 delete()),它使用简短直接的 CRUD 语法。然而,Beanie 映射器创建了一个异步存储库层,因为所有继承自模型类的 API 方法都是非阻塞的。以下CartRepository类的代码展示了使用这个 Beanie ODM 的异步存储库类的示例实现:

from typing import Dict, Any
from models.data.beanie import Cart
class CartRepository: 

    async def add_item(self, 
             details:Dict[str, Any]) -> bool: 
        try:
            receipt = Cart(**details)
            await receipt.insert()
        except Exception as e:
            print(e)
            return False 
        return True

    async def update_qty(self, id:int, qty:int) -> bool: 
       try:
          cart = await Cart.get(id)
          await cart.set({Cart.qty:qty})
       except: 
           return False 
       return True

    async def delete_item(self, id:int) -> bool: 
        try:
            cart = await Cart.get(id)
            await cart.delete()
        except: 
            return False 
        return True

add_item()方法展示了使用异步insert()方法持久化新创建的Cart实例。Document API 还有一个create()方法,它的工作方式类似于insert()。另一个选择是使用insert_one()类方法而不是实例方法。此外,由于存在insert_many()操作,这个 ODM 允许添加多个文档。

更新文档可以使用两种方法,即set()replace()。在前面的脚本中,update_qty()选择set()操作来更新放置在购物车中的项目的当前qty值。

当涉及到文档删除时,ODM 只有delete()方法来追求事务。这在前面代码的delete_item()事务中是存在的。

使用此 ODM 检索单个文档或文档列表非常简单。在其查询操作期间不需要进一步的序列化和光标包装。在获取单个文档结构时,如果获取过程只需要_id字段,映射器提供get()方法;如果获取过程需要条件表达式,则提供find_one()。此外,Beanie 有一个find_all()方法,可以无约束地获取所有文档,以及用于有条件地检索数据的find()方法。以下代码显示了从数据库中检索购物车项的查询事务:

async def get_cart_items(self):
        return await Cart.find_all().to_list()

    async def get_items_user(self, user_id:int): 
        return await Cart.find(
              Cart.user_id == user_id).to_list()

    async def get_item(self, id:int): 
        return await Cart.get(id)

在方法中使用find()find_all()操作,以返回一个具有to_list()实用程序的FindMany对象,该实用程序返回一个可 JSON 化的文档列表。现在让我们将我们的 CRUD 事务应用到 API 服务中。

运行存储库事务

CartRepository方法只有在将配置文件中的db_connect()注入到路由器中时才能成功运行。虽然将其注入到每个 API 服务中是可以接受的,但我们的解决方案更喜欢使用Depends将组件注入到APIRouter中:

from repository.beanie.cart import CartRepository
from db_config.beanie_config import db_connect
router = APIRouter(dependencies=[Depends(db_connect)])
@router.post("/cart/add/item")
async def add_cart_item(req:CartReq): 
    repo:CartRepository = CartRepository()
    result = await repo.add_item(loads(cart_json))
          "insert cart unsuccessful"}, status_code=500)

异步的add_cart_item()服务使用CartRepository异步地将购物车账户插入到数据库中。

另一个可以完美集成到 FastAPI 中的异步映射器是ODMantic

使用 ODMantic 为 FastAPI 构建异步存储库

Beanie 和 ODMantic 的依赖关系来自 Motor 和 Pydantic。ODMantic 还利用 Motor 的AsyncIOMotorClient类来打开数据库连接。它还使用 Pydantic 功能进行类属性验证,Python 的类型扩展进行类型提示,以及其他 Python 组件进行管理。但它的优势在于它符合 ASGI 框架,如 FastAPI。

要追求 ODMantic,我们需要使用以下pip命令安装扩展:

pip install odmantic

创建数据库连接

在 ODMantic 中设置数据库连接与使用 Beanie 映射器所做的设置相同,但设置包括创建一个将处理所有 CRUD 操作引擎。这个引擎是来自odmantic模块的AIOEngine,它需要创建成功的同时具有 motor 客户端对象和数据库名称。以下是需要 ODMantic 映射器使用的数据库连接的完整实现:

from odmantic import AIOEngine
from motor.motor_asyncio import AsyncIOMotorClient
def create_db_connection():
   global client_od
   client_od = 
     AsyncIOMotorClient(f"mongodb://localhost:27017/")
def create_db_engine():
   engine = AIOEngine(motor_client=client_od, 
         database="obrs")
   return engine
def close_db_connection():
    client_od.close() 

我们需要在APIRouter中创建事件处理器来运行create_db_connection()close_db_connection(),以便我们的存储库事务能够工作。现在让我们实现 ODM 的模型层。

创建模型层

ODMantic 有一个Model API 类,当子类化时,它为模型类提供属性。它依赖于 Python 类型和 BSON 规范来定义类属性。在转换字段类型时,例如将 BSON datetime值转换为 Python datetime.date值,映射器允许你在模型类中添加自定义的@validator方法以实现适当的对象序列化器。通常,ODMantic 在数据验证方面依赖于pydantic模块,与 Beanie 映射器不同。以下是一个标准的 ODMantic 模型类定义:

from odmantic import Model
from bson import datetime
class Purchase(Model): 
    purchase_id: int
    buyer_id: int 
    book_id: int 
    items: int 
    price: float 
    date_purchased: datetime.datetime

    class Config:
        collection = "purchase"

对于高级配置,我们可以在模型类中添加一个嵌套的Config类来设置这些附加选项,例如collection选项,它用自定义名称替换集合的默认名称。我们还可以配置一些熟悉选项,例如json_encoders,将一个字段类型转换为另一个受支持的类型。

建立文档关联

在创建关联时,尽管在这个对象文档映射(ODM)中仍然适用典型的 Python 方法,即声明字段以便它们引用嵌入的文档(s),但这个 ODM 映射器有一个EmbeddedModel API 类来创建没有_id字段的模型;这可以链接到另一个文档。另一方面,Model类可以定义一个字段属性,该属性将引用EmbeddedModel类以建立一对一关联或多个EmbeddedModel实例的多个一对一关联。

实现 CRUD 事务

使用 ODMantic 创建仓库层始终需要创建在启动事件中创建的引擎对象。这是因为所有需要的 CRUD 操作都将来自这个引擎。以下PurchaseRepository展示了我们需要创建 CRUD 事务的AIOEngine对象的操作:

from typing import List, Dict, Any
from models.data.odmantic import Purchase
class PurchaseRepository: 

    def __init__(self, engine): 
        self.engine = engine

    async def insert_purchase(self, 
              details:Dict[str, Any]) -> bool: 
        try:
           purchase = Purchase(**details)
           await self.engine.save(purchase)

        except Exception as e:
            print(e)
            return False 
        return True

这个insert_purchase()方法展示了使用 ODMantic 将记录插入数据库的标准方式。通过引擎的save()方法,我们可以使用模型类一次持久化一个文档。AIOEngine还提供了save_all()方法,用于将多个文档列表插入关联的 MongoDB 集合。

现在,没有特定的方法来更新事务,但 ODMantic 允许你获取需要更新的记录。以下代码可以用来使用 ODMantic 更新记录:

    async def update_purchase(self, id:int, 
              details:Dict[str, Any]) -> bool: 
       try:
          purchase = await self.engine.find_one(
                Purchase, Purchase.purchase_id == id)

          for key,value in details.items():
            setattr(purchase,key,value)

          await self.engine.save(purchase)
       except Exception as e:
           print(e) 
           return False 
       return True

在访问和更改字段值之后,使用save()方法将获取的文档对象重新保存,以反映在物理存储中的更改。完整过程在先前的update_purchase()事务中实现:

     async def delete_purchase(self, id:int) -> bool: 
        try:
            purchase = await self.engine.find_one(
                Purchase, Purchase.purchase_id == id) 
            await self.engine.delete(purchase)
        except: 
            return False 
        return True

当涉及到文档删除时,你必须获取要删除的文档。我们将获取的文档对象传递给引擎的delete()方法以继续删除过程。这种实现方式在delete_purchase()方法中展示。

当需要获取单个文档以便更新或删除时,AIOEngine 提供了一个 find_one() 方法,该方法需要两个参数:模型类名称和条件表达式,该表达式涉及 id 主键或某些非唯一字段。所有字段都可以像类变量一样访问。以下 get_purchase() 方法检索具有指定 idPurchase 文档:

    async def get_all_purchase(self):
        purchases = await self.engine.find(Purchase)
        return purchases

    async def get_purchase(self, id:int): 
        purchase = await self.engine.find_one(
            Purchase, Purchase.purchase_id == id) 
        return purchase

引擎有一个 find() 操作来检索所有 Purchase 文档,例如从数据库中。它只需要一个参数——模型类的名称。现在让我们将我们的存储库层应用到 API 服务中。

运行 CRUD 事务

为了使存储库类运行,所有路由服务都必须是异步的。然后,我们需要为 create_db_connection()close_db_connection() 分别创建启动和关闭事件处理器,以打开存储库事务的连接。最后,为了使存储库类工作,create_db_engine() 必须注入到每个 API 服务中,以获取引擎对象:

from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse
from models.request.purchase import PurchaseReq
from repository.odmantic.purchase import PurchaseRepository
from db_config.odmantic_config import create_db_engine, 
    create_db_connection, close_db_connection
from datetime import date, datetime
from json import dumps, loads
router = APIRouter()
router.add_event_handler("startup", create_db_connection)
router.add_event_handler("shutdown", close_db_connection)
@router.post("/purchase/add")
async def add_purchase(req: PurchaseReq, 
          engine=Depends(create_db_engine)): 
     purchase_dict = req.dict(exclude_unset=True) 
     purchase_json = dumps(purchase_dict, 
                default=json_serial)
     repo:PurchaseRepository = PurchaseRepository(engine)
     result = await 
            repo.insert_purchase(loads(purchase_json))
     if result == True: 
        return req 
     else: 
        return JSONResponse(content={"message": 
          "insert purchase unsuccessful"}, status_code=500)
     return req

到目前为止,我们应该知道如何比较这些映射器和驱动程序在设置和程序方面的差异,这些差异是管理 MongoDB 文档所需的。每个都有其优点和缺点,这取决于它们产生的代码以及解决方案的性能、普及度、支持和复杂性。有些可能适用于其他要求,而有些可能不适用。我们将要介绍的最后一个 ODM 专注于成为最轻量级和最不干扰的映射器。它的目标是适应现有应用程序,而不会产生语法和性能问题。

使用 MongoFrames 创建 CRUD 事务

如果你厌倦了使用复杂且负载沉重的 ODM,那么 MongoFrames 是满足你需求的不二之选。MongoFrames 是最新的 ODM 之一,使用起来非常方便,尤其是在为已经存在的复杂和遗留 FastAPI 微服务应用程序构建新的存储库层时。但这个映射器只能创建同步和标准类型的 CRUD 事务。

但在我们继续之前,让我们使用 pip 安装扩展模块:

pip install MongoFrames

创建数据库连接

MongoFrames 平台运行在 PyMongo 之上,这就是为什么它不能构建异步存储库层。为了创建数据库连接,它使用 pymongo 模块中的 MongoClient API 类,数据库 URL 以字符串格式提供。与其他 ODM 不同,我们在其中创建一个客户端变量,在这个映射器中,我们通过 Frame API 类访问 variable _client 类来引用客户端连接对象。以下代码展示了 create_db_client(),它将为我们的应用程序打开数据库连接,以及 disconnect_db_client(),它将关闭此连接:

from pymongo import MongoClient
from mongoframes import Frame
def create_db_client():
    Frame._client = 
        MongoClient('mongodb://localhost:27017/obrs')

def disconnect_db_client():
    Frame._client.close()

就像在之前的 ODM 中一样,我们需要事件处理器来执行这些核心方法,以开始构建模型和存储库层。

构建模型层

在 MongoFrames 中创建模型类的过程称为 Frame API 类来定义模型类。一旦继承,Frame 不需要模型类来定义其属性。它使用 _fields 属性来包含文档的所有必要字段,而不指示任何元数据。以下模型类是由 Frame API 类定义的:

from mongoframes import Frame, SubFrame
class Book(Frame):
    _fields = {
        'id ',
        'isbn',
        'author', 
        'date_published', 
        'title', 
        'edition',
        'price',
        'category'
    }
    _collection = "book"

class Category(SubFrame):

    _fields = {
        'id',
        'name',
        'description',
        'date_added'
        }

    _collection = "category"
class Reference(Frame):
    _fields = {
        'id',
        'name',
        'description',
        'categories'
        }

    _collection = "reference"

一个 Frame 模型类可以包裹字典形式的文档或包含文档结构键值详情的 kwargs。它还可以提供属性和辅助方法,这些方法可以帮助执行 CRUD 事务。模型类的所有字段都可以通过点(.)表示法访问,就像典型的类变量一样。

创建文档关联

在创建这些文档之间的关联之前,我们需要定义 SubFrame 模型。SubFrame 模型类映射到一个嵌入文档结构,并且没有自己的集合表。MongoFrames 映射器提供了操作,允许您对 Frame 实例的 SubFrame 类进行追加、更新、删除和查询。这些操作将确定文档之间的关联类型,因为 Frame 的字段引用没有特定的字段类型。例如,Reference 文档将有一个与它的 categories 字段链接的类别列表,因为我们的交易将按照设计构建这个关联。另一方面,一个 Book 文档将通过其 category 字段引用一个 Category 子文档,因为交易将在运行时构建这个关联。所以,MongoFrames 在定义这些文档之间关联类型时既受限制又非严格。

创建仓库层

Frame API 类提供了模型类和必要的辅助方法,以实现异步仓库事务。以下代码展示了使用 MongoFrames 创建 CRUD 事务的仓库类的实现:

from mongoframes.factory.makers import Q
from models.data.mongoframe import Book, Category
from typing import List, Dict, Any
class BookRepository: 
    def insert_book(self, 
             details:Dict[str, Any]) -> bool: 
        try:
           book = Book(**details)
           book.insert()

        except Exception as e:
            return False 
        return True

给定的 insert_book() 事务将一个书籍实例插入其映射的集合中。Frame API 提供了一个 insert() 方法,该方法将给定的模型对象保存到数据库中。它还有一个 insert_many() 方法,可以插入多个 BSON 文档或模型实例的列表。以下脚本展示了如何在 MongoFrames 中创建一个 更新 事务:

    def update_book(self, id:int, 
            details:Dict[str, Any]) -> bool: 
       try:
        book = Book.one(Q.id == id)
        for key,value in details.items():
            setattr(book,key,value)
        book.update()
       except: 
           return False 
       return True

给定的 update_book() 事务表明 Frame 模型类还有一个 update() 方法,该方法在从集合中检索后立即识别并保存文档对象字段值中反映的变化。类似的流程应用于 delete_book() 过程,它将在从集合中检索文档对象后立即调用文档对象的 delete() 操作:

    def delete_book(self, id:int) -> bool: 
        try:
           book = Book.one(Q.id == id)
           book.delete()
        except: 
            return False 
        return True

在创建查询事务时,Frame API 提供了两个类方法——many() 方法,它提取所有 BSON 文档,以及 one() 方法,它返回单个文档对象。如果存在任何约束,这两个操作都可以接受一个查询表达式作为参数。此外,MongoFrames 有一个 Q 查询构建器类,用于在查询表达式中构建条件。表达式以 Q 开头,后跟点(.)表示法来定义字段名或路径——例如,Q.categories.fiction——然后是一个运算符(例如,==!=>>=<<=),最后是一个值。以下代码展示了使用 MongoFrames ODM 语法进行查询事务翻译的示例:

    def get_all_book(self):
        books = [b.to_json_type() for b in Book.many()]
        return books

    def get_book(self, id:int): 
        book = Book.one(Q.id == id).to_json_type()
        return book

get_book() 方法展示了如何使用 Q 表达式提取单个 Book 文档,该表达式过滤出正确的 id,而 get_all_book() 则在没有任何约束的情况下检索所有 Book 文档。

many() 操作符返回一个 Frame 对象的列表,而 one() 操作符返回单个 Frame 实例。要将结果转换为可 JSON 化的组件,我们需要在每个 Frame 实例中调用 to_json_type() 方法。

如前所述,嵌入文档的添加是由操作决定的,而不是由模型属性决定的。在以下 add_category() 事务中,很明显已经将一个 Category 对象分配给了 Book 实例的 category 字段,即使该字段没有定义为引用 Category 类型的嵌入文档。而不是抛出异常,MongoFrame 将在 update() 调用后立即更新 Book 文档:

    def add_category(self, id:int, 
               category:Category) -> bool: 
       try:
        book = Book.one(Q.id == id)
        book.category = category
        book.update()
       except: 
           return False 
       return True

现在,是时候将这些 CRUD 事务应用到我们的 API 服务中了。

应用仓库层

如果我们没有将 create_db_client() 注入器注入到路由器中,我们的仓库类将无法工作。以下解决方案将组件注入到 APIRouter 中,即使将其注入到每个 API 服务实现中也是可接受的:

from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse
from models.request.category import BookReq
from repository.mongoframe.book import BookRepository
from db_config.mongoframe_config import create_db_client
from datetime import date, datetime
from json import dumps, loads
router = APIRouter(
         dependencies=[Depends(create_db_client)])
@router.post("/book/create")
def create_book(req:BookReq): 
    book_dict = req.dict(exclude_unset=True) 
    book_json = dumps(book_dict, default=json_serial)
    repo:BookRepository = BookRepository()
    result = repo.insert_book(loads(book_json))
    if result == True: 
        return req 
    else: 
        return JSONResponse(content={"message": 
          "insert book unsuccessful"}, status_code=500)

create_book() 服务使用 BookRepository 将书籍详情插入到 MongoDB 数据库中。一般来说,MongoFrames 设置起来很简单,因为它创建数据库连接、构建模型层和实现仓库事务所需的配置细节较少。其平台可以适应应用程序的现有需求,并且如果需要对其映射机制进行修改,可以轻松地反映变化。

摘要

在本章中,我们探讨了使用 MongoDB 管理数据的各种方法。由于我们预计在书买家和转售商之间交换信息时数据量会变得很大,我们利用 MongoDB 来存储我们的 在线书籍转售系统 中的非关系型数据。此外,交易中涉及到的细节主要是字符串、浮点数和整数,这些都是顺序和购买值,如果存储在无模式的存储中,将更容易进行挖掘和分析。

本章采用了非关系型数据管理路线图,用于在销售预测、书籍读者需求的回归分析以及其他描述性数据分析形式中利用数据。

首先,你学习了如何使用 PyMongo 和 Motor 驱动程序将 FastAPI 应用程序连接到 MongoDB 数据库。在理解了使用这些驱动程序创建 CRUD 事务的细节之后,你了解到 ODM 是追求 MongoDB 连接性的更好选择。我们探讨了 MongoEngine、Beanie、ODMantic 和 MongoFrames 的功能,并研究了它们作为 ODM 映射器的优缺点。所有这些 ODM 都可以很好地与 FastAPI 平台集成,并为应用程序提供了一种标准化的数据备份方式。

现在我们已经用两章的篇幅涵盖了数据管理,在下一章中,我们将学习如何确保我们的 FastAPI 微服务应用程序的安全。

第七章:保护 REST API

构建微服务意味着将整个应用程序暴露给全球互联网。对于每一次请求-响应事务,客户端都会公开访问 API 的端点,这对应用程序构成了潜在风险。与基于 Web 的应用程序不同,API 服务在管理用户访问方面具有较弱的控制机制。因此,本章将提供几种保护使用 FastAPI 框架创建的 API 服务的方法。

没有绝对的安全。主要目标是建立与这些服务的机密性完整性可用性相关的政策和解决方案。机密性政策需要使用令牌、加密和解密以及证书作为机制来使某些 API 私有化。另一方面,完整性政策涉及在认证和授权过程中使用“状态”和散列码来维护数据交换的真实性、准确性和可靠性。可用性政策意味着使用可靠的工具和 Python 模块来保护端点访问,防止 DoS 攻击、钓鱼攻击和时间攻击。总的来说,安全模型这三个方面是构建微服务安全解决方案时必须考虑的基本要素。

尽管 FastAPI 没有内置的安全框架,但它支持不同的认证模式,如基本摘要。它还内置了实现安全规范(如OAuth2OpenIDOpenAPI)的模块。本章将涵盖以下主要主题,以解释和说明确保我们的 FastAPI 服务概念和解决方案:

  • 实现基本和摘要认证

  • 实现基于密码的认证

  • 应用 JWT

  • 创建基于范围的授权

  • 构建授权代码流

  • 应用 OpenID Connect 规范

  • 使用内置中间件进行认证

技术要求

本章的软件原型是一个安全的在线拍卖系统,旨在管理其注册用户拍卖的各种物品的在线竞标。系统可以在价格范围内对任何物品进行竞标,甚至宣布竞标胜利者。系统需要保护一些敏感交易,以避免数据泄露和结果偏差。原型将使用SQLAlchemy作为 ORM 来管理数据。将有 10 个版本的我们的原型,每个原型将展示不同的认证方案。所有 10 个这些项目(ch07ach07j)都可以在这里找到:github.com/PacktPublishing/Building-Python-Microservices-with-FastAPI

实现基本和摘要认证

基本认证和摘要认证方案是我们可以使用来保护 API 端点的最简单认证解决方案。这两种方案都是可以作为替代认证机制应用于小型和低风险应用,而不需要复杂的配置和编码。现在让我们使用这些方案来保护我们的原型。

使用基本认证

保护 API 端点最直接的方法是基本认证方法。然而,这种认证机制不应应用于高风险应用,因为从客户端发送到安全方案提供者的凭据,通常是一个用户名和密码,是以Base64 编码格式发送的,容易受到许多攻击,如暴力破解时间攻击嗅探。Base64 不是一个加密算法,而是一种将凭据表示为密文格式的方法。

应用 HttpBasic 和 HttpBasicCredentials

原型ch07a使用基本认证模式来确保其管理和投标及拍卖交易的安全。其在/security/secure.py模块中的实现如下所示:

from passlib.context import CryptContext
from fastapi.security import HTTPBasicCredentials
from fastapi.security import HTTPBasic
from secrets import compare_digest
from models.data.sqlalchemy_models import Login
crypt_context = CryptContext(schemes=["sha256_crypt", 
                    "md5_crypt"])
http_basic = HTTPBasic()

FastAPI 框架通过其fastapi.security模块支持不同的认证模式和规范。为了追求基本认证方案,我们需要实例化模块中的HTTPBasic类并将其注入到每个 API 服务中,以保护端点访问。一旦http_basic实例被注入到 API 服务中,就会导致浏览器弹出登录表单,通过该表单我们输入用户名密码凭据。登录将触发浏览器向应用程序发送包含凭据的头部信息。如果应用程序在接收凭据时遇到问题,HTTPBasic方案将抛出HTTP 状态码 401并带有"未经授权"的消息。如果没有表单处理错误,应用程序必须接收到一个带有Basic值和可选的realm参数的WWW-Authenticate头部。

另一方面,/ch07/login服务将调用authentication()方法来验证浏览器凭据是否真实且正确。在从浏览器接受用户凭据时,我们需要非常小心,因为它们容易受到各种攻击。首先,我们可以要求端点用户使用电子邮件地址作为用户名,并要求使用不同字符、数字和符号组合的长密码。所有存储的密码都必须使用最可靠的加密工具进行编码,例如passlib模块中的CryptContext类。passlib扩展提供了比任何 Python 加密模块更安全的哈希算法。我们的应用程序使用SHA256MD5哈希算法,而不是推荐的bcrypt,因为bcrypt速度较慢且容易受到攻击。

其次,我们可以避免在源代码中存储凭证,而是使用数据库存储或 .env 文件。authenticate() 方法将凭证与 API 服务提供的 Login 数据库记录进行核对以确保正确性。

最后,始终在比较来自浏览器的凭证与数据库中存储的 Login 凭证时使用 secret 模块中的 compare_digest()。此函数在随机比较两个字符串的同时,保护操作免受时间攻击。时间攻击是一种攻击方式,它破坏了加密算法的执行,这发生在系统中对字符串进行线性比较时:

def verify_password(plain_password, hashed_password):
    return crypt_context.verify(plain_password, 
        hashed_password)
def authenticate(credentials: HTTPBasicCredentials, 
         account:Login):
    try:
        is_username = compare_digest(credentials.username,
             account.username)
        is_password = compare_digest(credentials.password, 
             account.username)
        verified_password = 
             verify_password(credentials.password, 
                   account.passphrase)
        return (verified_password and is_username and 
               is_password)
    except Exception as e:
        return False

我们的 authenticate() 方法具有所有必要的功能,有助于减少来自外部因素的攻击。但确保基本认证安全性的最终解决方案是为应用程序安装和配置 传输层安全性 (TLS)(或 HTTPS,或 SSL)连接。

现在,我们需要实现一个 /ch07/login 端点以应用 基本 认证方案。http_basic 实例被注入到这个 API 服务中,用于提取 HTTPBasicCredentials,这是一个包含从浏览器中获取的 用户名密码 详细信息的对象。此服务也是调用 authenticate() 方法来检查用户凭证的服务。如果该方法返回一个 False 值,服务将抛出一个 HTTP 状态码 400 并带有 "凭证错误" 的消息:

from fastapi import APIRouter, Depends, HTTPException
from fastapi.security import HTTPBasicCredentials
from security.secure import authenticate, 
            get_password_hash, http_basic
router = APIRouter()
@router.get("/login")
def login(credentials: HTTPBasicCredentials = 
     Depends(http_basic), sess:Session = Depends(sess_db)):

    loginrepo = LoginRepository(sess)
    account = loginrepo.get_all_login_username(
                     credentials.username)
    if authenticate(credentials, account) and 
            not account == None:
        return account
    else:
        raise HTTPException(
            status_code=400, 
               detail="Incorrect credentials")

@router.get("/login/users/list")
def list_all_login(credentials: HTTPBasicCredentials = 
     Depends(http_basic), sess:Session = Depends(sess_db)):
    loginrepo = LoginRepository(sess)
    users = loginrepo.get_all_login()
    return jsonable_encoder(users)

在线拍卖系统 的每个端点都必须注入 http_basic 实例以防止公共访问。例如,引用的 list_all_login() 服务只能返回所有用户的列表,如果用户是经过认证的。顺便说一下,没有可靠的方法使用 基本 认证注销。如果 WWW-Authenticate 标头已被浏览器发出并识别,我们很少会看到浏览器登录表单弹出。

执行登录事务

我们可以使用 curl 命令或浏览器来执行 /ch07/login 事务。但为了突出 FastAPI 的支持,我们将使用其 OpenAPI 仪表板来运行 /ch07/login。在浏览器上访问 http://localhost:8000/docs 后,定位到 /ch07/login GET 事务并点击 尝试它 按钮。点击按钮后,浏览器登录表单,如 图 7.1 所示,将弹出:

图 7.1 – 浏览器的登录表单

图 7.1 – 浏览器的登录表单

/ch07/signup/add/ch07/approve/signup 后添加您想要测试的用户凭证。请记住,所有存储的密码都是加密的。图 7.2 展示了在认证过程发现用户凭证有效后,/ch07/login 将如何输出用户的 登录 记录:

图 7.2 – /login 响应

图 7.2 – /login 响应

现在用户已经认证,通过 OpenAPI 控制台运行 /ch07/login/users/list 来检索登录详情。uvicorn 服务器日志将显示以下日志信息:

INFO: 127.0.0.1:53150 - "GET /ch07/login/users/list HTTP/1.1" 200 OK

这意味着用户有权运行该端点。现在,让我们将 Digest 认证方案应用到我们的原型中。

使用 Digest 认证

Digest 认证比基本方案更安全,因为前者需要在发送到应用程序的哈希版本之前先对用户凭据进行哈希处理。FastAPI 中的 Digest 认证不包括使用默认 MD5 加密的自动加密用户凭据的过程。它是一种将凭据存储在 .env.config 属性文件中,并在认证之前为这些凭据创建哈希字符串值的认证方案。ch07b 项目应用 Digest 认证方案以保护投标和拍卖交易。

生成哈希凭据

因此,在我们开始实现之前,我们首先需要创建一个自定义实用脚本 generate_hash.py,该脚本使用 Base64 编码生成二进制的摘要。该脚本必须包含以下代码:

from base64 import urlsafe_b64encode
h = urlsafe_b64encode(b"sjctrags:sjctrags")

base64 模块中的 urlsafe_b64encode() 函数从 username:password 凭据格式创建一个二进制的摘要。在运行脚本后,我们将摘要值保存在任何安全的地方,但不能在源代码中。

传递用户凭据

除了摘要之外,我们还需要保存 Digest 方案提供者的用户凭据,以便稍后使用。与标准 Digest 认证过程不同,在该过程中用户与浏览器协商,FastAPI 需要将用户凭据存储在我们的应用程序内的 .env.config 文件中,以便在认证过程中检索。在 ch07b 项目中,我们将用户名和密码保存在 .config 文件中,如下所示:

[CREDENTIALS]
USERNAME=sjctrags
PASSWORD=sjctrags

然后,我们通过 ConfigParser 工具创建一个解析器,从 .config 文件中提取以下详细信息,并使用序列化的用户详情构建一个 dict。以下 build_map() 是解析器实现的示例:

import os
from configparser import ConfigParser
def build_map():
    env = os.getenv("ENV", ".config")
    if env == ".config":
        config = ConfigParser()
        config.read(".config")
        config = config["CREDENTIALS"]
    else:
        config = {
            "USERNAME": os.getenv("USERNAME", "guest"),
            "PASSWORD": os.getenv("PASSWORD", "guest"),
        }
    return config

使用 HTTPDigest 和 HTTPAuthorizationCredentials

FastAPI 框架在其 fastapi.security 模块中有一个 HTTPDigest,它以不同的方式管理用户凭据并生成摘要,实现了 Digest 认证方案。与基本认证不同,HTTPDigest 认证过程发生在 APIRouter 层级。我们通过 HTTP 操作将以下 authenticate() 可依赖项注入到 API 服务中,包括 /login,认证从这里开始:

from fastapi import Security, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials
from fastapi.security import HTTPDigest
from secrets import compare_digest
from base64 import standard_b64encode
http_digest = HTTPDigest()
def authenticate(credentials: 
    HTTPAuthorizationCredentials = Security(http_digest)):

    hashed_credentials = credentials.credentials
    config = build_map()
    expected_credentials = standard_b64encode(
        bytes(f"{config['USERNAME']}:{config['PASSWORD']}",
           encoding="UTF-8")
    )
    is_credentials = compare_digest(
          bytes(hashed_credentials, encoding="UTF-8"),
               expected_credentials)

    if not is_credentials:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Incorrect digest token",
            headers={"WWW-Authenticate": "Digest"},
        )

authenticate()方法是将http_digest注入到提取包含摘要字节的HTTPAuthorizationCredentials的地方。提取后,它会检查摘要是否与保存在.config文件中的凭证匹配。我们还使用compare_digest来比较来自头部的hashed_credentials和来自.config文件的 Base64 编码的凭证。

执行登录事务

在实现authenticate()方法后,我们将它注入到 API 服务中,不是在方法参数中,而是在其 HTTP 操作符中。请注意,与基本认证方案不同,http_digest对象不是直接注入到 API 服务中的。以下实现展示了如何将authenticate()方法应用于保护应用程序的所有关键端点:

from security.secure import authenticate
@router.get("/login", dependencies=[Depends(authenticate)])
def login(sess:Session = Depends(sess_db)):
    return {"success": "true"}

@router.get("/login/users/list",   
      dependencies=[Depends(authenticate)])
def list_all_login(sess:Session = Depends(sess_db)):
    loginrepo = LoginRepository(sess)
    users = loginrepo.get_all_login()
    return jsonable_encoder(users)

由于摘要认证方案的行为类似于OpenID 认证,我们将使用curl命令运行/ch07/login。命令的关键部分是发出包含由我们之前执行的generate_hash.py脚本生成的 Base64 编码的username:password摘要值的Authorization头。以下curl命令是正确登录我们使用摘要认证方案的 FastAPI 应用程序的方法:

curl --request GET --url http://localhost:8000/ch07/login --header "accept: application/json"                  --header "Authorization: Digest c2pjdHJhZ3M6c2pjdHJhZ3M=" --header "Content-Type: application/json"

我们也使用相同的命令来运行其他受保护的 API 服务。

如今,大多数企业应用程序很少使用基本和摘要认证方案,因为它们容易受到许多攻击。不仅如此,这两种认证方案都需要将凭证发送到受保护的 API 服务,这也是另一个风险。此外,在撰写本文时,FastAPI 尚未完全支持标准的摘要认证,这对需要标准认证的其他应用程序来说也是一个劣势。因此,现在让我们来探讨使用OAuth 2.0 规范来保护 API 端点的解决方案。

实现基于密码的认证

OAuth 2.0 规范,或称 OAuth2,是认证 API 端点访问最首选的解决方案。OAuth2 授权框架定义了四种授权流程,分别是隐式客户端凭证授权码资源密码流程。这三种流程可以与第三方认证提供商一起使用,以授权访问 API 端点。在 FastAPI 平台上,资源密码流程可以在应用程序内部自定义和实现,以执行认证过程。现在让我们来探讨 FastAPI 如何支持 OAuth2 规范。

安装 python-multipart 模块

由于没有表单处理程序,OAuth2 认证将无法进行,因此我们需要在继续实施部分之前安装python-multipart模块。我们可以运行以下命令来安装扩展:

pip install python-multipart

使用 OAuth2PasswordBearer 和 OAuth2PasswordRequestForm

FastAPI 框架完全支持 OAuth2,特别是 OAuth2 规范的密码流类型。它的 fastapi.security 模块有一个 OAuth2PasswordBearer,作为基于密码认证的提供者。它还有一个 OAuth2PasswordRequestForm,可以声明一个包含所需参数(usernamepassword)和一些可选参数(如 scopegrant_typeclient_idclient_secret)的表单体。此类直接注入到 /ch07/login API 端点以从浏览器登录表单中提取所有参数值。但始终可以选择使用 Form(…) 来捕获所有单个参数。

因此,让我们通过创建要注入到自定义函数依赖项中的 OAuth2PasswordBearer 来开始解决方案,该依赖项将验证用户凭据。以下实现显示 get_current_user() 是我们新应用程序 ch07c 中的可注入函数,它利用 oath2_scheme 可注入项提取 token

from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.orm import Session
from repository.login import LoginRepository
from db_config.sqlalchemy_connect import sess_db
oauth2_scheme = 
    OAuth2PasswordBearer(tokenUrl="ch07/login/token")
def get_current_user(token: str = Depends(oauth2_scheme), 
           sess:Session = Depends(sess_db) ):
    loginrepo = LoginRepository(sess)
    user = loginrepo.get_all_login_username(token)
    if user == None:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Invalid authentication credentials",
            headers={"WWW-Authenticate": "Bearer"},
        )
    return user

对于资源密码流,注入 oauth2_scheme 将返回一个作为 token 的 usernameget_current_user() 将检查该用户名是否属于存储在数据库中的有效用户账户。

执行登录事务

在此认证方案中,/ch07/login/token 也是 OAuth2PasswordBearertokenUrl 参数。tokenUrl 参数对于基于密码的 OAuth2 认证是必需的,因为这是从浏览器登录表单中捕获用户凭据的端点服务。OAuth2PasswordRequestForm 注入到 /cho07/login/token 中以检索未认证用户的 usernamepasswordgrant_type 参数。这三个参数是调用 /ch07/login/token 以生成 token 的基本要求。这种依赖关系在以下登录 API 服务的实现中显示:

from sqlalchemy.orm import Session
from db_config.sqlalchemy_connect import sess_db
from repository.login import LoginRepository
from fastapi.security import OAuth2PasswordRequestForm
from security.secure import get_current_user, authenticate
@router.post("/login/token")
def login(form_data: OAuth2PasswordRequestForm = Depends(),
             sess:Session = Depends(sess_db)):
    username = form_data.username
    password = form_data.password
    loginrepo = LoginRepository(sess)
    account = loginrepo.get_all_login_username(username)
    if authenticate(username, password, account) and 
              not account == None:
        return {"access_token": form_data.username, 
                  "token_type": "bearer"}
    else:
        raise HTTPException(
            status_code=400, 
               detail="Incorrect username or password")

除了从数据库验证之外,login() 服务还将检查 password 值是否与从查询的 account 中检索到的加密密码匹配。如果所有验证都成功,则 /ch07/login/token 必须返回一个包含所需属性 access_tokentoken_type 的 JSON 对象。access_token 属性必须具有 username 值,而 token_type 必须是 "bearer" 值。

我们将利用框架中 OpenAPI 提供的 OAuth2 表单来代替创建自定义的前端登录表单。我们只需在 OpenAPI 仪表板的右上角点击 授权 按钮,如 图 7.3 所示:

图 7.3 – 授权按钮

图 7.3 – 授权按钮

该按钮将触发一个内置的登录表单弹出,如 图 7.4 所示,我们可以使用它来测试我们的解决方案:

图 7.4 – OAuth2 登录表单

图 7.4 – OAuth2 登录表单

如果 OAuth2 登录表单检测到在OAuth2PasswordBearer实例化中指定的正确tokenURL,则一切正常。登录表单中指示的 OAuth2 流程或grant_type必须是"password"。在记录验证凭证后,表单的授权按钮将重定向用户到如图 7.5所示的授权表单,该表单将提示用户注销或继续进行经过认证的访问:

图 7.5 – 授权表单

图 7.5 – 授权表单

通常,OAuth2 规范认可两种客户端或应用程序类型:机密公开客户端。机密客户端使用认证服务器进行安全,例如在这个使用 OpenAPI 平台通过 FastAPI 服务器进行的在线拍卖系统中。在其设置中,不需要向登录表单提供client_idclient_secret值,因为服务器将在认证过程中生成这些参数。但不幸的是,这些值并未向客户端透露,如图 7.5所示。另一方面,公开客户端没有生成和使用客户端密钥的手段,就像典型的基于 Web 和移动应用程序一样。因此,这些应用程序必须在登录时包含client_idclient_secret和其他所需参数。

保护端点

要保护 API 端点,我们需要将get_current_user()方法注入到每个 API 服务方法中。以下是一个使用get_current_user()方法的受保护add_auction()服务实现的示例:

@router.post("/auctions/add")
def add_auction(req: AuctionsReq, 
      current_user: Login = Depends(get_current_user), 
      sess:Session = Depends(sess_db)): 
    auc_dict = req.dict(exclude_unset=True)
    repo:AuctionsRepository = AuctionsRepository(sess)
    auction = Auctions(**auc_dict)
    result = repo.insert_auction(auction)
    if result == True:
        return auction
    else: 
        return JSONResponse(content=
         {'message':'create auction problem encountered'}, 
            status_code=500)  

如果允许访问,get_current_user()注入方法将返回一个有效的Login账户。此外,您会注意到所有包含/ch07/auctions/add的受保护 API 端点的锁形图标,如图 7.6所示,都是关闭的。这表明它们已经准备好执行,因为用户已经是一个经过认证的用户:

图 7.6 – 展示受保护 API 的 OpenAPI 仪表板

图 7.6 – 展示受保护 API 的 OpenAPI 仪表板

这种解决方案对于开放网络设置来说是一个问题,例如,因为使用的令牌是一个密码。这种设置允许攻击者轻松地在发行者向客户端传输令牌期间伪造或修改令牌。保护令牌的一种方法是用JSON Web Token(JWT)。

应用 JWT

JWT 是一个开源标准,用于定义在发行者和客户端之间进行身份验证和授权过程中发送任何信息的解决方案。其目标是生成数字签名、URL 安全且始终可由客户端验证的access_token属性。然而,它并不完全安全,因为任何人如果需要都可以解码令牌。因此,建议不要在令牌字符串中包含所有有价值且机密的信息。JWT 是提供比密码更可靠令牌的有效方式,用于 OAuth2 和 OpenID 规范。

生成密钥

但在我们开始构建认证方案之前,我们首先需要生成一个*secret key*,这是创建*signature*的一个基本元素。JWT 有一个sshopenssl是生成这个长且随机化的密钥的适当工具。在这里,在ch07d中,我们从 GIT 工具或任何 SSL 生成器运行以下openssl命令来创建密钥:

openssl rand -hex 32

创建 access_token

ch07d项目中,我们将在其/security/secure.py模块脚本中的某些引用变量中存储*secret key**algorithm type*。这些变量由 JWT 编码过程用于生成令牌,如下面的代码所示:

from jose import jwt, JWTError
from datetime import datetime, timedelta
SECRET_KEY = "tbWivbkVxfsuTxCP8A+Xg67LcmjXXl/sszHXwH+TX9w="
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
def create_access_token(data: dict, 
           expires_after: timedelta):
    plain_text = data.copy()
    expire = datetime.utcnow() + expires_after
    plain_text.update({"exp": expire})
    encoded_jwt = jwt.encode(plain_text, SECRET_KEY, 
            algorithm=ALGORITHM)
    return encoded_jwt

在 JWT Python 扩展中,我们选择了python-jose模块来生成令牌,因为它可靠并且具有额外的加密功能,可以签名复杂的数据内容。在使用它之前,请先使用pip命令安装此模块。

因此,现在/ch07/login/token端点将调用create_access_token()方法来请求 JWT。login服务将提供数据,通常是username,以构成令牌的有效负载部分。由于 JWT 必须是短暂的,这个过程必须更新有效负载的expire部分为几分钟或几秒钟的datetime值,适合应用程序。

创建登录事务

*login*服务的实现与之前的基于密码的 OAuth2 认证类似,但这个版本有一个用于 JWT 生成的create_access_token()调用,以替换密码凭据。以下脚本显示了ch07d项目的/ch07/login/token服务:

@router.post("/login/token")
def login(form_data: OAuth2PasswordRequestForm = Depends(),
          sess:Session = Depends(sess_db)):
    username = form_data.username
    password = form_data.password
    loginrepo = LoginRepository(sess)
    account = loginrepo.get_all_login_username(username)
    if authenticate(username, password, account):
        access_token = create_access_token(
          data={"sub": username}, 
           expires_after=timedelta(
              minutes=ACCESS_TOKEN_EXPIRE_MINUTES))
        return {"access_token": access_token, 
             "token_type": "bearer"}
    else:
        raise HTTPException(
            status_code=400, 
            detail="Incorrect username or password")

端点仍然应该返回access_tokentoken_type,因为这仍然是基于密码的 OAuth2 认证,它从OAuth2PasswordRequestForm检索用户凭据。

访问受保护的端点

与之前的 OAuth2 方案一样,我们需要将get_current_user()注入到每个 API 服务中,以实施安全和限制访问。注入的OAuthPasswordBearer实例将使用指定的解码算法通过 JOSE 解码器返回 JWT 以提取有效负载。如果令牌被篡改、修改或过期,该方法将抛出异常。否则,我们需要继续提取有效负载数据,检索用户名,并将其存储在@dataclass实例中,例如TokenData。然后,用户名将进行进一步的验证,例如检查数据库中是否有该用户名的Login账户。以下代码片段显示了此解码过程,位于ch07d项目的/security/secure.py模块中:

from models.request.tokens import TokenData
from fastapi.security import OAuth2PasswordBearer
from jose import jwt, JWTError
from models.data.sqlalchemy_models import Login
from sqlalchemy.orm import Session
from db_config.sqlalchemy_connect import sess_db
from repository.login import LoginRepository
from datetime import datetime, timedelta
oauth2_scheme = 
     OAuth2PasswordBearer(tokenUrl="ch07/login/token")
def get_current_user(token: str = Depends(oauth2_scheme),
    sess:Session = Depends(sess_db)):
    credentials_exception = HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED,
        detail="Could not validate credentials",
        headers={"WWW-Authenticate": "Bearer"}
    )
    try:
        payload = jwt.decode(token, SECRET_KEY, 
           algorithms=[ALGORITHM])
        username: str = payload.get("sub")
        if username is None:
            raise credentials_exception
        token_data = TokenData(username=username)
    except JWTError:
        raise credentials_exception

    loginrepo = LoginRepository(sess)
    user = 
      loginrepo.get_all_login_username(token_data.username)
    if user is None:
        raise credentials_exception
    return user

get_current_user()必须注入到每个服务实现中,以限制用户访问。但这次,该方法不仅会验证凭据,还会执行JWT 有效负载解码。下一步是向 OAuth2 解决方案添加用户授权

创建基于范围的授权

FastAPI 完全支持基于作用域的认证,它使用 OAuth2 协议的scopes参数来指定哪些端点可供一组用户访问。scopes参数是一种放置在令牌中的权限,为用户提供额外的细粒度限制。在这个项目版本ch07e中,我们将展示基于 OAuth2 密码的认证和用户授权。

自定义 OAuth2 类

首先,我们需要创建一个自定义类,该类继承自fastapi.security模块中的OAuth2 API 类的属性,以在用户凭证中包含scopes参数或“角色”选项。以下是一个自定义 OAuth2 类OAuth2PasswordBearerScopes,它将实现带有授权的认证流程:

class OAuth2PasswordBearerScopes(OAuth2):
    def __init__(
        self,
        tokenUrl: str,
        scheme_name: str = None,
        scopes: dict = None,
        auto_error: bool = True,

    ):
    if not scopes:
         scopes = {}
    flows = OAuthFlowsModel(
       password={"tokenUrl": tokenUrl, "scopes": scopes})
    super().__init__(flows=flows, 
       scheme_name=scheme_name, auto_error=auto_error)
    async def __call__(self, request: Request) -> 
             Optional[str]:
        header_authorization: str = 
              request.headers.get("Authorization")
        … … … … … …
        return param

这个OAuth2PasswordBearerScopes类需要两个构造函数参数,tokenUrlscopes,以实现认证流程。OAuthFlowsModelscopes参数定义为使用Authorization头进行认证的用户凭证的一部分。

构建权限字典

在我们进行身份验证实现之前,我们需要首先构建 OAuth2 方案在身份验证过程中将应用的作用域参数。这个设置是OAuth2PasswordBearerScopes实例化的一部分,我们将这些参数分配给其scopes参数。以下脚本展示了如何将所有自定义定义的用户作用域保存到一个字典中,其中是作用域名称,是对应的描述:

oauth2_scheme = OAuth2PasswordBearerScopes(
    tokenUrl="/ch07/login/token",
    scopes={"admin_read": 
              "admin role that has read only role",
            "admin_write":
              "admin role that has write only role",
            "bidder_read":
              "customer role that has read only role",
            "bidder_write":
              "customer role that has write only role",
            "auction_read":
              "buyer role that has read only role",
            "auction_write":
              "buyer role that has write only role",
            "user":"valid user of the application",
            "guest":"visitor of the site"},
)

在实现这个项目的过程中,没有直接将OAuth2PasswordBearerScopes类连接到数据库以动态查找权限集的可行方法。唯一的解决方案是将所有这些授权“角色”直接静态存储到OAuth2PasswordBearerScopes构造函数中。

实现登录事务

所有作用域都将作为选项添加到 OAuth2 表单登录中,并成为用户登录凭证的一部分。以下是在这个新的ch07e项目中实现/ch07/login/token的示例,展示了如何从OAuth2PasswordRequestForm中检索作用域参数和凭证:

@router.post("/login/token")
def login(form_data: OAuth2PasswordRequestForm = Depends(),
         sess:Session = Depends(sess_db)):
    username = form_data.username
    password = form_data.password
    loginrepo = LoginRepository(sess)
    account = loginrepo.get_all_login_username(username)
    if authenticate(username, password, account):
        access_token = create_access_token(
            data={"sub": username, "scopes": 
              form_data.scopes},  
               expires_delta=timedelta(
               minutes=ACCESS_TOKEN_EXPIRE_MINUTES))
        return {"access_token": access_token, 
                "token_type": "bearer"}
    else:
        raise HTTPException(
            status_code=400, 
            detail="Incorrect username or password")

被选中的作用域存储在一个列表中,例如['user', 'admin_read', 'admin_write', 'bidder_write'],这意味着一个用户拥有用户管理员(写)管理员(读)竞标者(写)权限。create_access_token()将包括这个作用域列表或“角色”作为负载的一部分,该负载将通过get_current_valid_user()通过get_current_user()注入器进行解码和提取。顺便说一下,get_current_valid_user()通过应用身份验证方案来确保每个 API 免受用户访问。

将作用域应用于端点

来自fastapi模块的Security API 替换了注入get_current_valid_user()时的Depends类,因为它除了具有执行依赖注入的能力外,还能为每个 API 服务分配权限。它有一个scopes属性,其中定义了一个有效权限参数列表,限制了用户的访问。例如,以下update_profile()服务仅对包含bidder_writebuyer_write角色的用户可访问:

from fastapi.security import SecurityScopes
@router.patch("/profile/update")
def update_profile(id:int, req: ProfileReq, 
    current_user: Login = Security(get_current_valid_user, 
        scopes=["bidder_write", "buyer_write"]), 
    sess:Session = Depends(sess_db)): 
    … … … … … …
    if result: 
        return JSONResponse(content=
         {'message':'profile updated successfully'}, 
              status_code=201)
    else: 
        return JSONResponse(content=
           {'message':'update profile error'}, 
               status_code=500)

现在,以下代码片段展示了由Security注入到每个 API 服务中的get_current_valid_user()的实现:

def get_current_valid_user(current_user: 
   Login = Security(get_current_user, scopes=["user"])):
    if current_user == None:
        raise HTTPException(status_code=400, 
           detail="Invalid user")
    return current_user

当涉及到 JWT 有效载荷解码、凭证验证和用户权限验证时,此方法依赖于get_current_user()。用户至少必须拥有user权限,授权过程才能继续。Security类负责将get_current_user()注入到get_current_valid_user()中,并附带默认的user权限。以下是get_current_user()方法的实现:

def get_current_user(security_scopes: SecurityScopes, 
      token: str = Depends(oauth2_scheme), 
           sess:Session = Depends(sess_db)):
    if security_scopes.scopes:
        authenticate_value = 
          f'Bearer scope="{security_scopes.scope_str}"'
    else:
        authenticate_value = f"Bearer" 
    … … … … … …
    try:
        payload = jwt.decode(token, SECRET_KEY, 
                   algorithms=[ALGORITHM])
        username: str = payload.get("sub")
        if username is None:
            raise credentials_exception
        token_scopes = payload.get("scopes", [])
        token_data = TokenData(scopes=token_scopes, 
               username=username)
    except JWTError:
        raise credentials_exception
    … … … … … …
    for scope in security_scopes.scopes:
        if scope not in token_data.scopes:
            raise HTTPException(
                status_code=status.HTTP_401_UNAUTHORIZED,
                detail="Not enough permissions",
                headers={"WWW-Authenticate": 
                    authenticate_value},
            )
    return user

给定的get_current_user()函数中的SecurityScopes类提取了用户试图访问的 API 服务分配的权限范围。它有一个包含所有这些 API 权限参数的scope实例变量。另一方面,token_scopes携带从解码的 JWT 有效载荷中提取的所有权限或“角色”。get_current_user()遍历SecurityScopes中的 API 权限,以检查它们是否全部出现在用户的token_scopes中。如果是,get_current_user()将验证并授权用户访问 API 服务。否则,它将抛出一个异常。TokenData的目的在于管理来自token_scopes有效载荷值和用户名的权限参数。

FastAPI 可以支持的下一类 OAuth2 认证方案是授权码流方法。

构建授权码流

如果应用程序是公开类型,并且没有授权服务器来处理client_id参数、client_secret参数和其他相关参数,那么使用 OAuth2 授权码流方法是很合适的。在这个方案中,客户端将从authorizationUrl创建一个短生命周期的授权请求,以获取一个授权码。然后客户端将使用生成的代码从tokenUrl请求令牌。在这个讨论中,我们将展示我们在线拍卖系统的另一个版本,该版本将使用 OAuth2 授权码流方案。

应用 OAuth2AuthorizationCodeBearer

OAuth2AuthorizationCodeBearer类是fastapi.security模块中的一个类,用于构建授权码流。它的构造函数在实例化之前需要authorizationUrltokenUrl和可选的scopes。以下代码展示了如何创建这个 API 类,并在将其注入到get_current_user()方法之前:

from fastapi.security import OAuth2AuthorizationCodeBearer
oauth2_scheme = OAuth2AuthorizationCodeBearer(
    authorizationUrl='ch07/oauth2/authorize',
    tokenUrl="ch07/login/token",  
    scopes={"admin_read": "admin … read only role",
            "admin_write":"admin … write only role",
            … … … … … …
            "guest":"visitor of the site"},
    )

这两个端点,authorizationUrltokenUrl,是本方案认证和授权过程中的关键参数。与之前的解决方案不同,我们在生成 access_token 时不会依赖于授权服务器。相反,我们将实现一个 authorizationUrl 端点,该端点将捕获来自客户端的基本参数,这些参数将构成生成 access_token 的授权请求。client_secret 参数始终不会被暴露给客户端。

实施授权请求

在之前的方案中,/ch07/login/ 令牌或 tokenUrl 端点总是在登录事务之后成为重定向点。但这次,用户将被转发到自定义的 /ch07/oauth2/authorizeauthorizationUrl 端点以生成 认证码。查询参数如 response_typeclient_idredirect_uriscopestateauthorizationUrl 服务的必要输入。以下代码来自 ch07f 项目的 /security/secure.py 模块,将展示 authorizationUrl 事务的实现:

@router.get("/oauth2/authorize")
def authorizationUrl(state:str, client_id: str, 
       redirect_uri: str, scope: str, response_type: str, 
       sess:Session = Depends(sess_db)):

    global state_server
    state_server = state

    loginrepo = LoginRepository(sess)
    account = loginrepo.get_all_login_username(client_id)
    auth_code = f"{account.username}:{account.password}
                    :{scope}"
    if authenticate(account.username, 
              account.password, account):
        return RedirectResponse(url=redirect_uri 
          + "?code=" + auth_code 
          + "&grant_type=" + response_type
          + "&redirect_uri=" + redirect_uri 
          + "&state=" + state)
    else:
        raise HTTPException(status_code=400, 
               detail="Invalid account")

这些是 authorizationUrl 事务所需的查询参数:

  • response_type: 自定义生成的授权码

  • client_id: 应用程序的公共标识符,例如 username

  • redirect_uri: 服务器默认 URI 或一个自定义端点,用于将用户重定向回应用程序

  • scope: 范围参数字符串,如果涉及至少两个参数,则由空格分隔

  • state: 一个任意的字符串值,用于确定请求的状态

redirect_uri 参数是认证和授权过程将发生的目的点,这些查询参数将一起使用。

auth_code 的生成是 authorizationUrl 事务的关键任务之一,包括认证过程。认证码表示认证过程的 ID,通常与其他所有认证都不同。有许多生成代码的方法,但在我们的应用程序中,它只是用户凭证的组合。传统上,auth_code 需要加密,因为它包含了用户凭证、范围和其他请求相关细节。

如果用户有效,authorizationUrl 事务将重定向用户到 redirect_uri 参数,回到 FastAPI 层,并带有 auth_codegrant_typestate 参数,以及 redirect_uri 参数本身。grant_typeredirect_uri 参数只有在应用程序不需要它们时才是可选的。此响应将调用 tokenUrl 端点,该端点恰好是 redirectURL 参数,以继续基于范围的授权的认证过程。

实施授权码响应

/ch07/login/token 服务,或 tokenUrl,必须具有 Form(…) 参数来捕获 authorizationUrl 事务中的 codegrant_typeredirect_uri 参数,而不是 OAuth2PasswordRequestForm。以下代码片段显示了其实现:

@router.post("/login/token")
def access_token(code: str = Form(...), 
  grant_type:str = Form(...), redirect_uri:str = Form(...), 
  sess:Session = Depends(sess_db)):
    access_token_expires = 
       timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)

    code_data = code.split(':')
    scopes = code_data[2].split("+")
    password = code_data[1]
    username = code_data[0]

    loginrepo = LoginRepository(sess)
    account = loginrepo.get_all_login_username(username)
    if authenticate(username, password, account):
        access_token = create_access_token(
            data={"sub": username, "scopes": scopes},
            expires_delta=access_token_expires,
        )

        global state_server
        state = state_server
        return {
            "access_token": access_token,
            "expires_in": access_token_expires,
            "token_type": "Bearer",
            "userid": username,
            "state": state,
            "scope": "SCOPE"
        }
    else:
        raise HTTPException(
            status_code=400, 
             detail="Incorrect credentials")

authorizationUrl 发送的响应数据中,只有 state 参数是 tokenUrl 无法访问的。一种解决方案是在 authorizationURL 中将 state 变量声明为 global,使其在任何地方都可以访问。state 变量是服务 JSON 响应的一部分,API 认证需要它。同样,tokenUrl 无法访问用户凭据,但解析 auth_code 是推导用户名、密码和作用域的可能方法。

如果用户有效,tokenUrl 必须提交包含 access_tokenexpires_intoken_typeuseridstate 的 JSON 数据,以继续进行认证方案。

此授权代码流方案为 OpenID Connect 认证提供了基本协议。各种身份和访问管理解决方案,如 OktaAuth0Keycloak,应用涉及响应 _type 代码的授权请求和响应。下一个主题将强调 FastAPI 对 OpenID Connect 规范的支持。

应用 OpenID Connect 规范

已创建了三个 在线拍卖 项目,以实施 OAuth2 OpenID Connect 认证方案。所有这些项目都使用第三方工具执行认证和授权流程。ch07g 项目使用 Auth0ch07h 使用 Okta,而 ch07i 在认证客户端访问 API 服务时应用了 Keycloak 政策。让我们首先强调 Keycloak 对 OpenID Connect 协议的支持。

使用 HTTPBearer

HTTPBearer 类是 fastapi.security 模块中的一个实用工具类,它提供了一个依赖于带有 Bearer 令牌的授权头的授权方案。与其他 OAuth2 方案不同,这需要在运行认证服务器之前在 Keycloak 端生成 access_token。在此阶段,框架没有直接访问凭证和 access_token 的简单方法。要使用此类,我们只需要实例化它,无需任何构造函数参数。

安装和配置 Keycloak 环境

Keycloak 是一个基于 Java 的应用程序,我们可以从以下链接下载:www.keycloak.org/downloads。下载后,我们可以将其内容解压缩到任何目录。但在运行之前,我们需要在我们的开发机器上至少安装 Java 12 SDK。一旦完成设置,请在控制台运行其 bin\standalone.batbin\standalone.sh,然后在浏览器中打开 http://localhost:8080。之后,创建一个管理账户来设置 realmclientsusersscopes

设置 Keycloak 领域和客户端

Keycloak 的 领域 是一个对象,它包含所有客户端及其 凭证作用域角色。在创建用户配置文件之前的第一步是构建一个领域,如图 7.7 所示:

图 7.7 - 创建 Keycloak 领域

图 7.7 - 创建 Keycloak 领域

在领域之后,Keycloak 的 客户端,它管理用户配置文件和凭证,是下一个优先级。它是在配置 | 客户端面板上创建的,如图所示:

图 7.8 – 创建 Keycloak 客户端

图 7.8 – 创建 Keycloak 客户端

在创建客户端后,我们需要编辑每个客户端配置文件以输入以下详细信息:

  • 其访问类型必须是 confidential

  • 授权启用已开启

  • 提供以下值:根 URL基本 URL管理 URL,它们都指向 API 服务应用程序的 http://localhost:8000

  • 指定一个 有效的重定向 URI 端点,或者如果我们没有特定的自定义端点,我们可以直接分配 http://localhost:8080/*

  • 高级设置 中,设置 访问令牌有效期(例如,15 分钟)

  • 身份验证流程覆盖 下,将 浏览器流程 设置为 browser,将 直接授权流程 设置为 direct grant

client_secret 值所在的位置。设置完成后,我们现在可以将用户分配给客户端。

创建用户和用户角色

首先,我们在配置 | 角色面板上创建 角色,为稍后的用户分配做准备。图 7.9 显示了将处理应用程序的 管理拍卖竞标 任务的三个用户角色:

图 7.9 – 创建用户角色

图 7.9 – 创建用户角色

创建角色后,我们需要在管理 | 用户面板上构建用户列表。图 7.10 显示了三个创建的用户,每个用户都分配了相应的角色:

图 7.10 – 创建客户端用户

图 7.10 – 创建客户端用户

为了为用户提供其角色,我们需要点击 joey_admin 具有具有 auc_admin_role 角色的权限,授权用户执行应用程序的行政任务。顺便说一句,不要忘记在凭证面板为每个用户创建密码:

图 7.11 – 映射用户角色

图 7.11 – 映射用户角色

将角色分配给客户端

除了用户角色外,客户端还可以分配角色。一个 客户端角色 定义了客户端在其范围内必须拥有的用户类型。它还提供了客户端访问 API 服务的边界。图 7.12 显示了具有 admin 角色的 auc_admin

图 7.12 – 创建客户端角色

图 7.12 – 创建客户端角色

然后,我们需要返回到 joey_admin 具有具有 admin 角色的配置,因为 auc_admin 角色已被添加到其配置文件中。所有将 auc_admin 客户端添加到其设置的用户的用户都具有应用程序的 管理员 访问权限,包括 joey_admin

图 7.13 – 将客户端角色映射到用户

图 7.13 – 将客户端角色映射到用户

通过作用域创建用户权限

为了为每个客户端分配权限,我们需要在 Audience 类型的令牌映射器上创建 客户端作用域图 7.14 显示了 auc_admin 客户端的 admin:readadmin:write 作用域,auc_customerauction:readauction:write,以及 auc_bidderbidder:writebidder:read

图 7.14 – 创建客户端作用域

图 7.14 – 创建客户端作用域

这些 客户端作用域 是每个 API 服务 Security 注入中的关键细节,如果 基于作用域的授权 是方案的一部分。

将 Keycloak 与 FastAPI 集成

由于 FastAPI 应用程序无法直接访问 Keycloak 客户端凭证进行身份验证,应用程序有一个 login_keycloak() 服务来将用户重定向到 AuctionRealm URI,我们自定义的 Keycloak 中的 authorizationUrl。该 URI 是 /auth/realms/AuctionRealm/protocol/openid-connect/auth。首先,访问 http://localhost:8080/auth/realms/AuctionRealm/account/ 使用授权用户凭证(如 joey_admin)登录,然后再调用 login_keycloak() 服务。

现在,重定向必须包含 client_id,就像 auc_admin 客户端一样,以及名为 redirect_uri 的自定义回调处理程序。所有 Keycloak 实体详情都必须在 .config 属性文件中。以下代码显示了 login_keycloak() 服务的实现:

import hashlib
import os
import urllib.parse as parse
@router.get("/auth/login")
def login_keycloak() -> RedirectResponse:
    config = set_up()
    state = hashlib.sha256(os.urandom(32)).hexdigest()

    AUTH_BASE_URL = f"{config['KEYCLOAK_BASE_URL']}
     /auth/realms/AuctionRealm/protocol/
         openid-connect/auth"
    AUTH_URL = AUTH_BASE_URL + 
     '?{}'.format(parse.urlencode({
        'client_id': config["CLIENT_ID"],
        'redirect_uri': config["REDIRECT_URI"],
        'state': state,
        'response_type': 'code'
    }))
    response = RedirectResponse(AUTH_URL)
    response.set_cookie(key="AUTH_STATE", value=state)
    return response

状态login_keycloak() 回调方法的响应的一部分,用于验证身份验证,这与我们在利用 OAuth2AuthorizationCodeBearer 时的方法类似。该服务使用 hashlib 模块通过 SHA256 加密算法生成一个随机的哈希字符串值作为状态。另一方面,Keycloak 的 AuctionRealm URI 必须返回以下 JSON 结果:

{"access_token":"eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkI iA6ICJJMFR3YVhiZnh0MVNQSnNzVTByQ09hMzVDaTdZNDkzUnJIeDJTM3paa0V VIn0.eyJleHAiOjE2NDU0MTgzNTAsImlhdCI6MTY0NTQxNzQ1MCwiYXV0aF90a W1lIjoxNjQ1NDE3NDM3LCJqdGkiOiI4YTQzMjBmYi0xMzg5LTQ2NzU……………………………2YTU2In0.UktwOX7H2ZdoyP1VZ5V2MXUX2Gj41D2cuusvwEZXBtVMvnoTDh KJgN8XWL7P3ozv4A1ZlBmy4NX1HHjPbSGsp2cvkAWwlyXmhyUzfQslf8Su00-4 e9FR4i4rOQtNQfqHM7cLhrzr3-od-uyj1m9KsrpbqdLvPEl3KZnmOfFbTwUXfE 9YclBFa8zwytEWb4qvLvKrA6nPv7maF2_MagMD_0Mh9t95N9_aY9dfquS9tcEV Whr3d9B3ZxyOtjO8WiQSJyjLCT7IW1hesa8RL3WsiG3QQQ4nUKVHhnciK8efRm XeaY6iZ_-8jm-mqMBxw00-jchJE8hMtLUPQTMIK0eopA","expires_in":900,"refresh_expires_in":1800,"refresh_token":"eyJhbGciOiJIUzI1NiIs InR5cCIgOiAiSldUIiwia2lkIiA6ICJhNmVmZGQ0OS0yZDIxLTQ0NjQtOGUyOC0 4ZWJkMjdiZjFmOTkifQ.eyJleHAiOjE2NDU0MTkyNTAsImlhdCI6MTY0NTQxNzQ 1MCwianRpIjoiMzRiZmMzMmYtYjAzYi00MDM3LTk5YzMt………………………zc2lvbl9z dGF0ZSI6ImM1NTE3ZDIwLTMzMTgtNDFlMi1hNTlkLWU2MGRiOWM1NmE1NiIsIn Njb3BlIjoiYWRtaW46d3JpdGUgYWRtaW46cmVhZCB1c2VyIiwic2lkIjoiYzU1 MTdkMjAtMzMxOC00MWUyLWE1OWQtZTYwZGI5YzU2YTU2In0.xYYQPr8dm7_o1G KplnS5cWmLbpJTCBDfm1WwZLBhM6k","token_type":"Bearer","not-before-policy":0,"session_state":"c5517d20-3318-41e2-a59d-e60d b9c56a56","scope":"admin:write admin:read user"}

这包含基本凭证,例如 access_tokenexpires_insession_statescope

实现令牌验证

应用程序的 HTTPBearer 需要 access_token 来进行客户端身份验证。在 OpenAPI 仪表板上,我们点击 Keycloak 的 authorizationUrl 提供的 access_token 值。在身份验证成功后,get_current_user() 将根据从 access_token 中提取的凭证验证对每个 API 端点的访问。以下代码突出了 get_current_user(),它使用 PyJWT 工具和算法(如 RSAAlgorithm)从 Keycloak 的 token 中构建用户凭证:

from jwt.algorithms import RSAAlgorithm
from urllib.request import urlopen
import jwt
def get_current_user(security_scopes: SecurityScopes, 
        token: str = Depends(token_auth_scheme)):
    token = token.credentials
    config = set_up()
    jsonurl = urlopen(f'{config["KEYCLOAK_BASE_URL"]}
        /auth/realms/AuctionRealm/protocol
        /openid-connect/certs')
    jwks = json.loads(jsonurl.read())
    unverified_header = jwt.get_unverified_header(token)

    rsa_key = {}
    for key in jwks["keys"]:
        if key["kid"] == unverified_header["kid"]:
            rsa_key = {
                "kty": key["kty"],
                "kid": key["kid"],
                "use": key["use"],
                "n": key["n"],
                "e": key["e"]
            }

    if rsa_key:
        try:
                public_key = RSAAlgorithm.from_jwk(rsa_key)
                payload = jwt.decode(
                    token,
                    public_key,
                    algorithms=config["ALGORITHMS"],
                    options=dict(
                           verify_aud=False,
                           verify_sub=False,
                           verify_exp=False,
                     )
                )
    … … … … … …
    token_scopes = payload.get("scope", "").split()

    for scope in security_scopes.scopes:
        if scope not in token_scopes:
            raise AuthError(
               {
                 "code": "Unauthorized",
                 "description": Invalid Keycloak details,
               },403,
            )
    return payload

首先安装 PyJWT 模块以利用所需的编码和解码函数。jwt 模块有 RSAAlgorithm,它可以帮助使用一些选项禁用(如客户端 audience 的验证)从令牌中解码 rsa_key。

将 Auth0 与 FastAPI 集成

Auth0也可以是一个第三方认证提供者,它可以认证并授权访问我们应用程序的 API 端点。但首先,我们需要在auth0.com/注册一个账户。

在注册账户后,创建一个 Auth0 应用程序以获取域名客户端 ID客户端密钥,并配置一些 URI 和令牌相关的细节。图 7.15显示了创建 Auth0 应用程序的仪表板:

图 7.15 – 创建 Auth0 应用程序

图 7.15 – 创建 Auth0 应用程序

Auth0 应用程序还有一个客户端认证所需的生成受众 API URI。另一方面,认证参数的一部分是发行者,我们可以从生成auth_token/oauth/token服务中获取,类似于 Keycloak 的领域。我们将所有这些 Auth0 详细信息放在.config文件中,包括用于解码auth_tokenPyJWT算法。

ch07g有其自己的get_current_user()版本,该版本处理来自.config文件中 Auth0 详细信息的 API 认证和授权的负载。但首先,HTTPBearer类需要auth_token值,并通过运行以下我们的 Auth0 应用程序AuctionApptokenURL来获取它:

curl --request POST                                      --url https://dev-fastapi1.us.auth0.com/oauth/token   --header
'content-type: application/json'              --data "{"client_id":"KjdwFzHrOLXC3IKe kw8t6YhX4xUV1ZNd",   "client_secret":"_KyPEUOB7DA5Z3mmRXpnqWA3EXfrjLw2R5SoUW7m1wLMj7 KoElMyDLiZU8SgMQYr","audience":"https://fastapi.auction.com/","grant_type":"client_credentials"}"

将 Okta 集成到 FastAPI 中

在 Auth0 中执行的一些过程也出现在 Okta 的程序中,当从ch07h项目中提取时,这些详细信息存储在app.env文件中,由其get_current_user()用于生成负载。但再次强调,HTTPBearer类需要从基于账户发行者的以下 Okta 的tokenURL执行中获取auth_token

curl --location --request POST "https://dev-5180227.okta.com/oauth2/default/v1/token?grant_type=client_credentials&client_id=0oa3tvejee5UPt7QZ5d7&client_secret=LA4WP8lACWKu4Ke9fReol0fNSUvxsxTvGLZdDS5-"   --header "Content-Type: application/x-www-form-urlencoded"

除了基本、摘要、OAuth2 和 OpenID Connect 认证方案之外,FastAPI 可以利用一些内置中间件来帮助保护 API 端点。现在让我们确定这些中间件是否可以提供自定义认证过程。

使用内置中间件进行认证

FastAPI 可以使用 Starlette 中间件,如AuthenticationMiddleware,来实现任何自定义认证。它需要一个AuthenticationBackend来实现我们应用程序安全模型的方案。以下自定义的AuthenticationBackend检查Authorization凭证是否是Bearer类,并验证username令牌是否与中间件提供的固定用户名凭证等效:

class UsernameAuthBackend(AuthenticationBackend):
    def __init__(self, username): 
        self.username = username    

    async def authenticate(self, request):
        if "Authorization" not in request.headers:
            return
        auth = request.headers["Authorization"]
        try:
            scheme, username = auth.split()
            if scheme.lower().strip() != 'bearer'.strip():
                return
        except:
            raise AuthenticationError(
             'Invalid basic auth credentials')
        if not username == self.username:
            return

        return AuthCredentials(["authenticated"]), 
             SimpleUser(username)

激活这个UsernameAuthBackend意味着将其注入到main.py中的 FastAPI 构造函数中,使用AuthenticationMiddleware。它还需要指定的username才能使其认证过程正常工作。以下代码片段显示了如何在main.py文件中激活整个认证方案:

from security.secure import UsernameAuthBackend
from starlette.middleware import Middleware
from starlette.middleware.authentication import 
    AuthenticationMiddleware
middleware = [Middleware(AuthenticationMiddleware, 
    backend=UsernameAuthBackend("sjctrags"))]
app = FastAPI(middleware=middleware)

将 FastAPI 的 Request 注入是应用认证方案的第一步。然后,我们在 @router 装饰器之后,用 @requires("authenticated") 装饰每个 API。我们可以通过添加 JWT 编码和解码、加密或基于角色的自定义授权来进一步扩展 UsernameAuthBackend 的处理过程。

摘要

保护任何应用程序始终是生产高质量软件时的主要优先事项。我们总是选择支持可靠和可信安全解决方案的框架,并且至少可以防止外部环境中的恶意攻击。尽管我们知道完美的安全模型是一个神话,但我们总是开发能够应对我们所知威胁的安全解决方案。

FastAPI 是具有内置支持许多流行认证过程的 API 框架之一,从基本认证到 OpenID Connect 规范。它完全支持所有有效的 OAuth2 认证方案,并且甚至允许进一步自定义其安全 API。

尽管它没有直接支持 OpenID Connect 规范,但它仍然可以无缝集成到不同的流行身份和用户管理系统,例如 Auth0、Okta 和 Keycloak。这个框架未来可能会给我们带来许多安全实用工具和类,我们可以将这些工具应用于构建可扩展的微服务应用。

下一章将重点介绍关于非阻塞 API 服务、事件和消息驱动事务的主题。

第八章:创建协程、事件和消息驱动的交易

FastAPI 框架是一个基于 asyncio 平台的异步框架,它利用了 ASGI 协议。它因其对异步端点和非阻塞任务 100%的支持而闻名。本章将重点介绍我们如何通过异步任务和事件驱动以及消息驱动的交易来创建高度可扩展的应用程序。

我们在 第二章 探索核心功能 中了解到,Async/Await 或异步编程是一种设计模式,它允许其他服务或交易在主线程之外运行。该框架使用 async 关键字创建将在其他线程池之上运行的异步进程,并将被 await,而不是直接调用它们。外部线程的数量在 Uvicorn 服务器启动期间通过 --worker 选项定义。

在本章中,我们将深入研究框架并仔细审查 FastAPI 框架的各个组件,这些组件可以使用多个线程异步运行。以下要点将帮助我们理解异步 FastAPI:

  • 实现协程

  • 创建异步后台任务

  • 理解 Celery 任务

  • 使用 RabbitMQ 构建消息驱动的交易

  • 使用 Kafka 构建发布/订阅消息

  • 在任务中应用响应式编程

  • 自定义事件

  • 实现异步 服务器端事件SSE

  • 构建异步 WebSocket

技术要求

本章将涵盖异步功能、软件规范以及 新闻亭管理系统 原型的组件。讨论将使用这个在线报纸管理系统原型作为样本来理解、探索和实现异步交易,这些交易将管理 报纸内容订阅计费用户资料客户 以及其他与业务相关的交易。所有代码都已上传到 github.com/PacktPublishing/Building-Python-Microservices-with-FastAPI 下的 ch08 项目。

实现协程

在 FastAPI 框架中,始终存在一个 线程池 来执行每个请求的同步 API 和非 API 交易。对于理想情况,即这两种交易都具有最小的性能开销,无论是 CPU 密集型 还是 I/O 密集型 交易,使用 FastAPI 框架的整体性能仍然优于那些使用非 ASGI 基础平台的框架。然而,当由于高 CPU 密集型流量或重 CPU 工作负载导致竞争时,由于 线程切换,FastAPI 的性能开始下降。

线程切换是在同一进程内从一个线程切换到另一个线程的上下文切换。因此,如果我们有多个具有不同工作负载的事务在后台和浏览器上运行,FastAPI 将在线程池中运行这些事务,并执行多个上下文切换。这种情况将导致对较轻工作负载的竞争和性能下降。为了避免性能问题,我们采用 协程切换 而不是线程。

应用协程切换

FastAPI 框架通过称为 协程切换 的机制以最佳速度运行。这种方法允许事务调优的任务通过允许其他运行进程暂停,以便线程可以执行并完成更紧急的任务,并在不抢占线程的情况下恢复 "awaited" 事务。这些协程切换是程序员定义的组件,与内核或内存相关的功能无关。在 FastAPI 中,有两种实现协程的方法:(a) 应用 @asyncio.coroutine 装饰器,和 (b) 使用 async/await 构造。

应用 @asyncio.coroutine

asyncio 是一个 Python 扩展,它使用单线程和单进程模型实现 Python 并发范式,并提供用于运行和管理协程的 API 类和方法。此扩展提供了一个 @asyncio.coroutine 装饰器,将 API 和原生服务转换为基于生成器的协程。然而,这是一个旧的方法,只能在使用 Python 3.9 及以下版本的 FastAPI 中使用。以下是我们 新闻亭管理系统 原型中实现为协程的登录服务事务:

@asyncio.coroutine
def build_user_list(query_list):
    user_list = []
    for record in query_list:
        yield from asyncio.sleep(2)
        user_list.append(" ".join([str(record.id), 
            record.username, record.password]))
    return user_list

build_user_list() 是一个原生服务,它将所有登录记录转换为 str 格式。它使用 @asyncio.coroutine 装饰器将事务转换为异步任务或协程。协程可以使用 yield from 子句调用另一个协程函数或方法。顺便说一句,asyncio.sleep() 方法是 asyncio 模块中最广泛使用的异步实用工具之一,它可以使进程暂停几秒钟,但并不是理想的。另一方面,以下代码是一个作为协程实现的 API 服务,它可以最小化客户端执行中的竞争和性能下降:

@router.get("/login/list/all")
@asyncio.coroutine
def list_login():
    repo = LoginRepository()
    result = yield from repo.get_all_login()
    data = jsonable_encoder(result)
    return data

list_login() API 服务通过在 GINO ORM 中实现的协程 CRUD 事务检索应用程序用户的全部登录详情。API 服务再次使用 yield from 子句来运行和执行 get_all_login() 协程函数。

协程函数可以使用 asyncio.gather() 工具并发调用和等待多个协程。这个 asyncio 方法管理一个协程列表,并等待直到所有协程完成其任务。然后,它将返回对应协程的结果列表。以下是一个通过异步 CRUD 事务检索登录记录的 API,然后并发调用 count_login()build_user_list() 来处理这些记录:

@router.get("/login/list/records")
@asyncio.coroutine
def list_login_records():
    repo = LoginRepository()
    login_data = yield from repo.get_all_login()
    result = yield from 
       asyncio.gather(count_login(login_data), 
            build_user_list(login_data))
    data = jsonable_encoder(result[1])
    return {'num_rec': result[0], 'user_list': data}

list_login_records() 使用 asyncio.gather() 来运行 count_login()build_user_list() 任务,并在之后提取它们对应的返回值进行处理。

使用 async/await 构造

实现协程的另一种方法是使用 async/await 构造。与之前的方法一样,这种语法创建了一个任务,在执行过程中可以随时暂停,直到到达末尾。但这种方法产生的协程被称为 原生协程,它不能像生成器类型那样迭代。async/await 语法还允许创建其他异步组件,例如 async with 上下文管理器和 async for 迭代器。以下代码是之前在基于生成器的协程服务 list_login_records() 中调用的 count_login() 任务:

async def count_login(query_list):
    await asyncio.sleep(2)
    return len(query_list)

count_login() 原生服务是一个原生协程,因为它在方法定义之前放置了 async 关键字。它只使用 await 来调用其他协程。await 关键字暂停当前协程的执行,并将线程控制权传递给被调用的协程函数。在被调用的协程完成其处理后,线程控制权将返回给调用协程。使用 yield from 构造而不是 await 会引发错误,因为我们的协程不是基于生成器的。以下是一个作为原生协程实现的 API 服务,用于管理新管理员资料的录入:

@router.post("/admin/add")
async def add_admin(req: AdminReq):
    admin_dict = req.dict(exclude_unset=True)
    repo = AdminRepository()
    result = await repo.insert_admin(admin_dict)
    if result == True: 
        return req 
    else: 
        return JSONResponse(content={'message':'update 
            trainer profile problem encountered'}, 
              status_code=500)

基于生成器和原生的协程都由一个 事件循环 监控和管理,它代表线程内部的一个无限循环。技术上,它是在 线程 中找到的一个对象,线程池中的每个 线程 只能有一个事件循环,其中包含一个称为 任务 的辅助对象列表。每个任务,无论是预先生成的还是手动创建的,都会执行一个协程。例如,当先前的 add_admin() API 服务调用 insert_admin() 协程事务时,事件循环将挂起 add_admin() 并将其任务标记为 等待 任务。之后,事件循环将分配一个任务来运行 insert_admin() 事务。一旦任务完成执行,它将控制权交还给 add_admin()。在执行转换期间,管理 FastAPI 应用程序的线程不会被中断,因为事件循环及其任务参与了 协程切换 机制。现在让我们使用这些协程来构建我们的应用程序

设计异步事务

在为我们的应用程序创建协程时,我们可以遵循几种编程范式。在过程中利用更多的协程切换可以帮助提高软件性能。在我们的 newsstand 应用程序中,admin.py 路由器中有一个端点 /admin/login/list/enc,它返回一个加密的用户详情列表。在其 API 服务中,如下所示代码所示,每条记录都由一个 extract_enc_admin_profile() 事务调用管理,而不是将整个数据记录传递给单个调用,从而允许任务的并发执行。这种策略比在没有 上下文切换 的情况下运行大量事务在线程中更好:

@router.get("/admin/login/list/enc")
async def generate_encypted_profile():
    repo = AdminLoginRepository()
    result = await repo.join_login_admin()
    encoded_data = await asyncio.gather(
       *(extract_enc_admin_profile(rec) for rec in result))
    return encoded_data

现在,extract_enc_admin_profile() 协程,如下所示代码所示,实现了一个链式设计模式,其中它通过链调用其他较小的协程。将单体和复杂的过程简化并分解成更小但更健壮的协程,通过利用更多的上下文切换来提高应用程序的性能。在这个 API 中,extract_enc_admin_profile() 在链中创建了三个上下文切换,比线程切换更优:

async def extract_enc_admin_profile(admin_rec):
    p = await extract_profile(admin_rec)
    pinfo = await extract_condensed(p)
    encp = await decrypt_profile(pinfo)
    return encp

另一方面,以下实现是 extract_enc_admin_profile() 等待和执行的较小子程序:

async def extract_profile(admin_details):
    profile = {}
    login = admin_details.parent
    profile['firstname'] = admin_details.firstname
    … … … … … …
    profile['password'] = login.password 
    await asyncio.sleep(1)
    return profile
async def extract_condensed(profiles):
    profile_info = " ".join([profiles['firstname'], 
       profiles['lastname'], profiles['username'], 
       profiles['password']])
    await asyncio.sleep(1)
    return profile_info 
async def decrypt_profile(profile_info):
    key = Fernet.generate_key()
    fernet = Fernet(key)
    encoded_profile = fernet.encrypt(profile_info.encode())
    return encoded_profile

这三个子程序将给主协程提供包含管理员配置文件详情的加密 str。所有这些加密字符串都将由 API 服务使用 asyncio.gather() 工具汇总。

利用协程切换的另一种编程方法是使用 asyncio.Queue 创建的管道。在这种编程设计中,队列结构是两个任务之间的共同点:(a)将值放入队列的任务称为 生产者,(b)从队列中获取项的任务称为 消费者。我们可以使用这种方法实现 单生产者/单消费者 交互或 多生产者/多消费者 设置。

以下代码突出了构建 生产者/消费者 交易流程的本地服务 process_billing()extract_billing() 协程是生产者,它从数据库中检索账单记录,并将每个记录逐个传递到队列中。另一方面,build_billing_sheet() 是消费者,它从队列结构中获取记录并生成账单表:

async def process_billing(query_list):
    billing_list = []

    async def extract_billing(qlist, q: Queue):
        assigned_billing = {}
        for record in qlist:
            await asyncio.sleep(2)
            assigned_billing['admin_name'] = "{} {}"
              .format(record.firstname, record.lastname)
            if not len(record.children) == 0:
                assigned_billing['billing_items'] = 
                      record.children
            else:
                assigned_billing['billing_items'] = None

            await q.put(assigned_billing)
    async def build_billing_sheet(q: Queue):
        while True: 
            await asyncio.sleep(2)
            assigned_billing = await q.get()
            name = assigned_billing['admin_name']
            billing_items = 
                assigned_billing['billing_items']
            if not billing_items == None:
                for item in billing_items:
                    billing_list.append(
                    {'admin_name': name, 'billing': item})
            else: 
                billing_list.append(
                    {'admin_name': name, 'billing': None})
            q.task_done()

在这种编程设计中,build_billing() 协程将明确等待由 extract_billing() 队列的记录。这种设置是由于 asyncio.create_task() 工具,它直接分配和调度任务给每个协程。

队列是协程的唯一公共方法参数,因为它它们的共同点。asyncio.Queuejoin() 确保所有通过 extract_billing() 传递到管道的项都被 build_billing_sheet() 获取和处理。它还阻止影响协程交互的外部控制。以下代码展示了如何创建 asyncio.Queue 并调度任务执行:

    q = asyncio.Queue()
    build_sheet = asyncio.create_task(
               build_billing_sheet(q))
    await asyncio.gather(asyncio.create_task(
             extract_billing(query_list, q)))

    await q.join()
    build_sheet.cancel()
    return billing_list

顺便说一句,总是在协程完成处理后立即传递 cancel() 给任务。另一方面,我们也可以采用其他方法,以便提高我们协程的性能。

使用 HTTP/2 协议

在运行在 HTTP/2 协议上的应用程序中,协程执行可以更快。我们可以用 Hypercorn 替换 Uvicorn 服务器,现在它支持基于 ASGI 的框架,如 FastAPI。但首先,我们需要使用 pip 安装 hypercorn

pip install hypercorn

要使 HTTP/2 工作,我们需要创建一个 SSL 证书。使用 OpenSSL,我们的应用程序有两个 PEM 文件用于我们的 newsstand 原型:(a)私有加密密钥(key.pem)和(b)证书信息(cert.pem)。我们在执行以下 hypercorn 命令以运行我们的 FastAPI 应用程序之前,将这些文件放置在主项目文件夹中:

hypercorn --keyfile key.pem --certfile cert.pem main:app       --bind 'localhost:8000' --reload

现在,让我们探索其他可以使用协程的 FastAPI 任务。

创建异步后台任务

第二章**,探索核心功能中,我们首先展示了 BackgroundTasks 注入式 API 类,但并未提及创建异步后台任务。在本讨论中,我们将专注于使用 asyncio 模块和协程创建异步后台任务。

使用协程

框架支持使用 async/await 结构创建和执行异步后台进程。以下原生服务是一个异步事务,在后台以 CSV 格式生成账单表:

async def generate_billing_sheet(billing_date, query_list):
    filepath = os.getcwd() + '/data/billing-' + 
                  str(billing_date) +'.csv'
    with open(filepath, mode="a") as sheet:
        for vendor in query_list:
            billing = vendor.children
            for record in billing:
                if billing_date == record.date_billed:
                    entry = ";".join(
             [str(record.date_billed), vendor.account_name, 
              vendor.account_number, str(record.payable),
              str(record.total_issues) ])
                    sheet.write(entry)
                await asyncio.sleep(1) 

这个 generate_billing_sheet() 协程服务将在以下 API 服务 save_vendor_billing() 中作为后台任务执行:

@router.post("/billing/save/csv")
async def save_vendor_billing(billing_date:date, 
              tasks: BackgroundTasks):
    repo = BillingVendorRepository()
    result = await repo.join_vendor_billing()
    tasks.add_task(generate_billing_sheet, 
            billing_date, result)
    tasks.add_task(create_total_payables_year, 
            billing_date, result)
    return {"message" : "done"}

现在,在定义后台进程方面,并没有什么变化。我们通常将 BackgroundTasks 注入 API 服务方法,并使用 add_task() 提供任务调度、分配和执行。但由于现在的方法是利用协程,后台任务将使用事件循环而不是等待当前线程完成其工作。

如果后台进程需要参数,我们将在 add_task()第一个参数 之后传递这些参数。例如,generate_billing_sheet()billing_datequery_list 参数的参数应该在将 generate_billing_sheet 注入 add_task() 之后放置。此外,billing_date 的值应该在 result 参数之前传递,因为 add_task() 仍然遵循 generate_billing_sheet() 中参数声明的顺序,以避免类型不匹配。

所有异步后台任务将持续执行,即使它们的协程 API 服务已经向客户端返回了响应,也不会被 await

创建多个任务

BackgroundTasks 允许创建多个异步事务,这些事务将在后台并发执行。在 save_vendor_billing() 服务中,为一个新的交易创建了一个名为 create_total_payables_year() 的事务,它需要与 generate_billing_sheet() 相同的参数。再次强调,这个新创建的任务将使用事件循环而不是线程。

当后台进程具有高 CPU 工作负载时,应用程序总是遇到性能问题。此外,由 BackgroundTasks 生成的任务无法从事务中返回值。让我们寻找另一种解决方案,其中任务可以管理高工作量并执行带有返回值的进程。

理解 Celery 任务

Celery 是一个在分布式系统上运行的非阻塞任务队列。它可以管理具有大量和重 CPU 工作负载的异步后台进程。它是一个第三方工具,因此我们需要首先通过 pip 安装它:

pip install celery

它在单个服务器或分布式环境中并发地安排和运行任务。但它需要一个消息传输来发送和接收消息,例如Redis,一个可以用于字符串、字典、列表、集合、位图和流类型的消息代理的内存数据库。我们还可以在 Linux、macOS 和 Windows 上安装 Redis。现在,安装后,运行其redis-server.exe命令以启动服务器。在 Windows 中,Redis 服务默认设置为安装后运行,这会导致TCP 绑定监听器错误。因此,在运行启动命令之前,我们需要停止它。图 8.1显示了 Redis 服务在 Windows任务管理器中处于停止状态:

![图 8.1 – 停止 Redis 服务

![img/Figure_8.01_B17975.jpg]

图 8.1 – 停止 Redis 服务

停止服务后,我们现在应该看到 Redis 正在运行,如图图 8.2所示:

![图 8.2 – 运行中的 Redis 服务器

![img/Figure_8.02_B17975.jpg]

图 8.2 – 运行中的 Redis 服务器

创建和配置 Celery 实例

在创建 Celery 任务之前,我们需要将 Celery 实例放置在我们应用程序的专用模块中。newsstand原型将 Celery 实例放在/services/billing.py模块中,以下代码展示了 Celery 实例化的过程:

from celery import Celery
from celery.utils.log import get_task_logger 
celery = Celery("services.billing",   
   broker='redis://localhost:6379/0', 
   backend='redis://localhost', 
   include=["services.billing", "models", "config"])
class CeleryConfig:
    task_create_missing_queues = True
    celery_store_errors_even_if_ignored = True
    task_store_errors_even_if_ignored = True 
    task_ignore_result = False
    task_serializer = "pickle"
    result_serializer = "pickle"
    event_serializer = "json"
    accept_content = ["pickle", "application/json", 
          "application/x-python-serialize"]
    result_accept_content = ["pickle", "application/json",
          "application/x-python-serialize"]
celery.config_from_object(CeleryConfig)
celery_log = get_task_logger(__name__)

要创建 Celery 实例,我们需要以下详细信息:

  • 包含 Celery 实例的当前模块的名称(第一个参数)

  • 作为我们的消息代理的 Redis 的 URL(broker

  • 存储和监控任务结果的后端(backend

  • 在消息体或 Celery 任务中使用的其他模块的列表(include

实例化后,如果有的话,我们需要设置适当的序列化和内容类型来处理涉及的任务的传入和传出消息体。为了允许传递具有非 JSON 可序列化值的完整 Python 对象,我们需要将pickle作为支持的内容类型包括在内,然后向对象流声明默认的任务和结果序列化器。然而,使用pickle序列化器会带来一些安全问题,因为它倾向于暴露一些事务数据。为了避免损害应用程序,在执行消息操作之前,对消息对象进行清理,例如删除敏感值或凭据。

除了序列化选项之外,其他重要的属性,如task_create_missing_queuestask_ignore_result和与错误相关的配置,也应该成为CeleryConfig类的一部分。现在,我们在一个自定义类中声明所有这些细节,然后将其注入到 Celery 实例的config_from_object()方法中。

此外,我们可以通过其get_task_logger()方法创建一个 Celery 日志记录器,其名称为当前任务。

创建任务

Celery 实例的主要目标是注释 Python 方法以成为任务。Celery 实例有一个task()装饰器,我们可以将其应用于我们想要定义为异步任务的所有可调用过程。task()装饰器的一部分是任务的name,这是一个可选的唯一名称,由packagemodule name(s)transactionmethod name组成。它还有其他属性,可以添加更多细化到任务定义中,例如auto_retry列表,它注册了可能引起执行重试的Exception类,以及max_tries,它限制了任务的重试执行次数。顺便说一下,Celery 5.2.3 及以下版本只能从non-coroutine methods定义任务。

这里显示的services.billing.tasks.create_total_payables_year_celery任务将每天的应付金额相加,并返回总额:

@celery.task(
    name="services.billing.tasks
            .create_total_payables_year_celery", 
                auto_retry=[ValueError, TypeError], 
                  max_tries=5)
def create_total_payables_year_celery(billing_date,
              query_list):
        total = 0.0
        for vendor in query_list:
            billing = vendor.children
            for record in billing:
                if billing_date == record.date_billed:
                    total += record.payable      
        celery_log.info('computed result: ' + str(total))
        return total   

给定的任务在运行时遇到ValueErrorTypeError时只有五次(5)重试来恢复。此外,它是一个返回计算金额的函数,当使用BackgroundTasks时,这是不可能创建的。所有功能任务都使用Redis数据库作为它们返回值的临时存储,这也是为什么在 Celery 构造函数中有后端参数的原因。

调用任务

FastAPI 服务可以使用apply_async()delay()函数调用这些任务。后者是一个更简单的选项,因为它预先配置好,只需要提供事务的参数即可获取结果。apply_async()函数是一个更好的选项,因为它接受更多细节,可以优化任务执行。这些细节包括queuetime_limitretryignore_resultexpires以及一些kwargs参数。但这两个函数都返回一个AsyncResult对象,该对象返回资源,如任务的state、帮助任务完成操作的wait()函数以及返回其计算值或异常的get()函数。以下是一个协程 API 服务,它使用apply_async方法调用services.billing.tasks.create_total_payables_year_celery任务:

@router.post("/billing/total/payable")
async def compute_payables_yearly(billing_date:date):
    repo = BillingVendorRepository()
    result = await repo.join_vendor_billing()
    total_result = create_total_payables_year_celery
       .apply_async(queue='default', 
            args=(billing_date, result))
    total_payable = total_result.get(timeout=1)
    return {"total_payable": total_payable }

CeleryConfig设置中将task_create_missing_queues设置为True总是推荐的做法,因为它会在工作服务器启动时自动创建任务queue,无论是默认的还是有其他名称。工作服务器将所有加载的任务放置在任务queue中以便执行、监控和检索结果。因此,在提取AsyncResult之前,我们应该始终在apply_async()函数的参数中定义一个任务queue

AsyncResult对象有一个get()方法,它从AsyncResult实例中释放任务返回的值,无论是否有超时。在compute_payables_yearly()服务中,使用 5 秒的超时通过get()函数检索AsyncResult中的应付金额。现在让我们使用 Celery 服务器部署和运行我们的任务。

启动工作服务器

运行 Celery 工作进程创建一个处理和管理所有排队任务的单个进程。工作进程需要知道 Celery 实例是在哪个模块中创建的,以及任务以建立服务器进程。在我们的原型中,services.billing 模块是我们放置 Celery 应用程序的地方。因此,启动工作进程的完整命令如下:

celery  -A services.billing worker -Q default -P solo -c 2 -l info

在这里,-A 指定了我们的 Celery 对象和任务的模块。-Q 选项表示工作进程将使用 正常 优先级队列。但首先,我们需要在 Celery 设置中将 task_create_missing_queues 设置为 True。我们还需要通过添加 -c 选项来指定工作进程执行任务所需的线程数。-P 选项指定工作进程将利用的 线程池 类型。默认情况下,Celery 工作进程使用适用于大多数 CPU 密集型事务的 prefork pool。其他选项有 soloeventletgevent,但我们的设置将使用 solo,这是在微服务环境中运行 CPU 密集型任务的最佳选择。另一方面,-l 选项启用了我们在设置期间使用 get_task_logger() 设置的记录器。现在,也有方法来监控我们的运行任务,其中之一就是使用 Flower 工具。

监控任务

是芹菜的监控工具,它通过在基于网页的平台生成实时审计来观察和监控所有任务执行。但首先,我们需要使用 pip 来安装它:

pip install flower

然后,我们使用以下带有 flower 选项的 celery 命令:

celery -A services.billing flower

要查看审计,我们在浏览器中运行 http://localhost:5555/tasks图 8.3 显示了由 services.billing.tasks.create_total_payables_year_celery 任务引起的执行日志的 Flower 快照:

图 8.3 – Flower 监控工具

图 8.3 – Flower 监控工具

到目前为止,我们已经使用 Redis 作为我们的内存后端数据库来存储任务结果和消息代理。现在让我们使用另一个可以替代 Redis 的异步消息代理,即 RabbitMQ

使用 RabbitMQ 构建消息驱动的交易

RabbitMQ 是一个轻量级的异步消息代理,支持多种消息协议,如 AMQPSTOMWebSocketMQTT。在 Windows、Linux 或 macOS 上正常工作之前,它需要 erlang。其安装程序可以从 www.rabbitmq.com/download.html 下载。

创建 Celery 实例

与使用 Redis 作为代理相比,RabbitMQ 作为消息代理是一个更好的替代品,它将在客户端和 Celery 工作线程之间中继消息。对于多个任务,RabbitMQ 可以命令 Celery 工作进程一次处理这些任务中的一个。RabbitMQ 代理适用于大量消息,并将这些消息保存到磁盘内存中。

首先,我们需要设置一个新的 Celery 实例,该实例将使用其 guest 账户利用 RabbitMQ 消息代理。我们将使用 AMQP 协议作为生产者/消费者类型消息设置的机制。以下是替换先前 Celery 配置的代码片段:

celery = Celery("services.billing",   
    broker='amqp://guest:guest@127.0.0.1:5672', 
    result_backend='redis://localhost:6379/0', 
    include=["services.billing", "models", "config"])

如 Celery 的 backend_result 所示,Redis 仍然是后端资源,因为它在消息流量增加时仍然简单且易于控制和管理工作。现在让我们使用 RabbitMQ 来创建和管理消息驱动的交易。

监控 AMQP 消息

我们可以配置 RabbitMQ 管理仪表板以监控 RabbitMQ 处理的消息。设置完成后,我们可以使用账户详情登录仪表板来设置代理。图 8.4 展示了 RabbitMQ 对 API 服务多次调用 services.billing.tasks.create_total_payables_year_celery 任务的截图:

图 8.4 – RabbitMQ 管理工具

图 8.4 – RabbitMQ 管理工具

如果 RabbitMQ 仪表板无法捕获任务的执行行为,Flower 工具将始终是收集关于任务参数、kwargs、UUID、状态和处理日期详情的选项。如果 RabbitMQ 不是合适的消息工具,我们总是可以求助于 Apache Kafka

使用 Kafka 构建发布/订阅消息

与 RabbitMQ 类似,Apache Kafka 是一种异步消息工具,应用程序使用它来在生产者和消费者之间发送和存储消息。然而,它比 RabbitMQ 更快,因为它使用带有分区的 topics,生产者可以在这些类似文件夹的结构中追加各种类型的消息。在这种架构中,消费者可以并行消费所有这些消息,这与基于队列的消息传递不同,后者允许生产者向只能按顺序消费消息的队列发送多个消息。在这个发布/订阅架构中,Kafka 可以以连续和实时模式每秒处理大量数据交换。

我们可以使用三个 Python 扩展将 FastAPI 服务与 Kafka 集成,即 kafka-pythonconfluent-kafkapykafka 扩展。我们的在线 newsstand 原型将使用 kafka-python,因此我们需要使用 pip 命令安装它:

pip install kafka-python

在这三个扩展中,只有 kafka-python 可以将 Java API 库通道并应用于 Python,以实现客户端的实现。我们可以从 kafka.apache.org/downloads 下载 Kafka。

运行 Kafka 代理和服务器

Kafka 有一个 ZooKeeper 服务器,它管理和同步 Kafka 分布式系统内消息的交换。ZooKeeper 服务器作为代理运行,监控和维护 Kafka 节点和主题。以下命令启动服务器:

C:\..\kafka\bin\windows\zookeeper-server-start.bat            C:\..\kafka\config\zookeeper.properties

现在,我们可以通过运行以下控制台命令来启动 Kafka 服务器:

C:\..\kafka\bin\windows\kafka-server-start.bat                C:\..\kafka\config\server.properties

默认情况下,服务器将在本地的 9092 端口上运行。

创建主题

当两个服务器都已启动时,我们现在可以通过以下命令创建一个名为 newstopic 的主题:

C:\..\kafka-topics.bat --create --bootstrap-server             localhost:9092 --replication-factor 1 --partitions 3         --topic newstopic

newstopic 主题有三个(3)分区,将保存我们 FastAPI 服务的所有附加消息。这些也是消费者将同时访问所有已发布消息的点。

实施发布者

在创建主题之后,我们现在可以实施一个生产者,该生产者将消息发布到 Kafka 集群。kafka-python 扩展有一个 KafkaProducer 类,它为所有运行的 FastAPI 线程实例化一个线程安全的生产者。以下是一个 API 服务,它将报纸信使记录发送到 Kafka 的 newstopic 主题,以便消费者访问和处理:

from kafka import KafkaProducer
producer = KafkaProducer(
     bootstrap_servers='localhost:9092')
def json_date_serializer(obj):
    if isinstance(obj, (datetime, date)):
        return obj.isoformat()
    raise TypeError ("Data %s not serializable" % 
             type(obj))
@router.post("/messenger/kafka/send")
async def send_messnger_details(req: MessengerReq): 
    messenger_dict = req.dict(exclude_unset=True)
    producer.send("newstopic", 
       bytes(str(json.dumps(messenger_dict, 
          default=json_date_serializer)), 'utf-8')) 
    return {"content": "messenger details sent"}

协程 API 服务 send_messenger_details() 请求有关报纸信使的详细信息,并将它们存储在 BaseModel 对象中。然后,它以字节格式将配置文件详细信息的字典发送到集群。现在,消费 Kafka 任务的选项之一是运行其内置的 kafka-console-consumer.bat 命令。

在控制台上运行消费者

从控制台运行以下命令是消费 newstopic 主题当前消息的一种方法:

kafka-console-consumer.bat --bootstrap-server                                                                                                                                                                                                                 127.0.0.1:9092 --topic newstopic

此命令创建一个消费者,该消费者将连接到 Kafka 集群,实时读取生产者发送的 newtopic 中的当前消息。图 8.5 展示了消费者在控制台上运行时的捕获情况:

![Figure 8.5 – The Kafka consumer]

![img/Figure_8.05_B17975.jpg]

图 8.5 – Kafka 消费者

如果我们希望消费者从 Kafka 服务器和代理开始运行的位置读取所有由生产者发送的消息,我们需要在命令中添加 --from-beginning 选项。以下命令将读取 newstopic 中的所有消息,并持续实时捕获传入的消息:

kafka-console-consumer.bat --bootstrap-server 127.0.0.1:9092 --topic newstopic --from-beginning

使用 FastAPI 框架实现消费者的一种方法是使用 SSE(服务器发送事件)。典型的 API 服务实现无法满足 Kafka 消费者的需求,因为我们需要一个持续运行的服务,该服务订阅 newstopic 以获取实时数据。因此,现在让我们探讨如何在 FastAPI 框架中创建 SSE 以及它将如何消费 Kafka 消息。

实施异步服务器发送事件(SSE)

SSE 是一种服务器推送机制,它在不重新加载页面的情况下将数据发送到浏览器。一旦订阅,它就会为各种目的实时生成事件驱动的流。

在 FastAPI 框架中创建 SSE 只需要以下步骤:

  • 来自 sse_starlette.see 模块的 EventSourceResponse

  • 事件生成器

总而言之,该框架还允许使用协程实现整个服务器推送机制的非阻塞实现,这些协程甚至可以在 HTTP/2 上运行。以下是一个使用 SSE 的开放和轻量级协议实现 Kafka 消费者的协程 API 服务:

from sse_starlette.sse import EventSourceResponse
@router.get('/messenger/sse/add')
async def send_message_stream(request: Request):

    async def event_provider():
        while True:
            if await request.is_disconnected():
                break
            message = consumer.poll()
            if not len(message.items()) == 0:
                for tp, records in message.items():
                   for rec in records:
                     messenger_dict = 
                      json.loads(rec.value.decode('utf-8'),
                       object_hook=date_hook_deserializer )

                     repo = MessengerRepository()
                     result = await 
                      repo.insert_messenger(messenger_dict)
                     id = uuid4()
                     yield {
                       "event": "Added … status: {},  
                           Received: {}". format(result, 
                            datetime.utcfromtimestamp(
                               rec.timestamp // 1000)
                               .strftime("%B %d, %Y 
                                      [%I:%M:%S %p]")),
                       "id": str(id),
                       "retry": SSE_RETRY_TIMEOUT,
                       "data": rec.value.decode('utf-8')
                      }

            await asyncio.sleep(SSE_STREAM_DELAY)
    return EventSourceResponse(event_provider())

send_message_stream() 是一个协程 API 服务,它实现了整个 SSE。它返回由 EventSourceResponse 函数生成的特殊响应。当 HTTP 流打开时,它持续从其源检索数据,并将任何内部事件转换为 SSE 信号,直到连接关闭。

另一方面,事件生成函数创建内部事件,这些事件也可以是异步的。例如,send_message_stream() 有一个嵌套的生成函数 event_provider(),它使用 consumer.poll() 方法消费生产者服务发送的最后一条消息。如果消息有效,生成器将检索到的消息转换为 dict 对象,并通过 MessengerRepository 将所有详细信息插入到数据库中。然后,它为 EventSourceResponse 函数产生所有内部详细信息,以便转换为 SSE 信号。图 8.6 展示了由 send_message_stream() 渲染的浏览器生成数据流:

![图 8.6 – SSE 数据流图片

图 8.6 – SSE 数据流

另一种实现 Kafka 消费者是通过 WebSocket。但这次,我们将关注如何使用 FastAPI 框架创建异步 WebSocket 应用程序的一般步骤。

构建异步 WebSocket

与 SSE 不同,WebSocket 的连接始终是双向的,这意味着服务器和客户端通过一个长 TCP 套接字连接相互通信。通信总是实时的,并且不需要客户端或服务器对发送的每个事件进行回复。

实现异步 WebSocket 端点

FastAPI 框架允许实现一个可以在 HTTP/2 协议上运行的异步 WebSocket。以下是一个使用协程块创建的异步 WebSocket 的示例:

import asyncio
from fastapi import WebSocket
@router.websocket("/customer/list/ws")
async def customer_list_ws(websocket: WebSocket):
    await websocket.accept()
    repo = CustomerRepository()
    result = await repo.get_all_customer()

    for rec in result:
        data = rec.to_dict()
        await websocket.send_json(json.dumps(data, 
           default=json_date_serializer))
        await asyncio.sleep(0.01)
        client_resp = await websocket.receive_json()
        print("Acknowledging receipt of record id 
           {}.".format(client_resp['rec_id']))
    await websocket.close()    

首先,当使用 APIRouter 时,我们用 @router.websocket() 装饰协程函数,或者当使用 FastAPI 装饰器时,用 @api.websocket() 来声明 WebSocket 组件。装饰器还必须为 WebSocket 定义一个唯一的端点 URL。然后,WebSocket 函数必须将 WebSocket 注入为其第一个方法参数。它还可以包括其他参数,如查询和头参数。

WebSocket 注入器有四种发送消息的方式,即 send()send_text()send_json()send_bytes()。应用 send() 将默认将每个消息管理为纯文本。之前的 customer_list_ws() 协程是一个以 JSON 格式发送每个客户记录的 WebSocket。

另一方面,WebSocket 注入器还可以提供四种方法,这些是 receive()receive_text()receive_json()receive_bytes() 方法。receive() 方法默认期望消息是纯文本格式。现在,我们的 customer_list_ws() 端点期望从客户端接收 JSON 响应,因为它在其发送消息操作之后调用了 receive_json() 方法。

WebSocket 端点必须在交易完成后立即关闭连接。

实现 WebSocket 客户端

创建 WebSocket 客户端有许多方法,但这一章将专注于利用一个协程 API 服务,该服务在浏览器或 curl 命令被调用时将与异步的 customer_list_ws() 端点进行握手。以下是使用 websockets 库实现的 WebSocket 客户端代码,该库运行在 asyncio 框架之上:

import websockets
@router.get("/customer/wsclient/list/")  
async def customer_list_ws_client():
    uri = "ws://localhost:8000/ch08/customer/list/ws"
    async with websockets.connect(uri) as websocket:
        while True:
           try:
             res = await websocket.recv()
             data_json = json.loads(res, 
                object_hook=date_hook_deserializer)

             print("Received record: 
                       {}.".format(data_json))

             data_dict = json.loads(data_json)
             client_resp = {"rec_id": data_dict['id'] }
             await websocket.send(json.dumps(client_resp))

           except websockets.ConnectionClosed:
                 break
        return {"message": "done"}

在使用 websockets.connect() 方法成功建立握手之后,customer_list_ws_client() 将会持续运行一个循环,从 WebSocket 端点获取所有传入的消费者详情。接收到的消息将被转换成其他进程所需的字典格式。现在,我们的客户端也会向 WebSocket 协程发送一个包含配置文件 客户 ID 的 JSON 数据的确认通知消息。一旦 WebSocket 端点关闭其连接,循环将停止。

让我们现在探索其他可以与 FastAPI 框架一起工作的异步编程特性。

在任务中应用反应式编程

反应式编程是一种涉及生成一系列操作以在过程中传播某些变化的流生成范式。Python 有一个 RxPY 库,它提供了我们可以应用于这些流以异步提取订阅者所需终端结果的方法。

在反应式编程范式中,所有在流中工作的中间操作符都会执行,以传播一些变化,如果在此之前有一个 Observable 实例和一个订阅此实例的 Observer。这个范式的主要目标是使用函数式编程在传播过程的最后达到预期的结果。

使用协程创建 Observable 数据

所有这一切都始于一个协程函数的实现,该函数将根据业务流程生成这些数据流。以下是一个 Observable 函数,它以 str 格式发布那些销售表现良好的出版物的详细信息:

import asyncio
from rx.disposable import Disposable
async def process_list(observer):
      repo = SalesRepository()
      result = await repo.get_all_sales()

      for item in result:
        record = " ".join([str(item.publication_id),  
          str(item.copies_issued), str(item.date_issued), 
          str(item.revenue), str(item.profit), 
          str(item.copies_sold)])
        cost = item.copies_issued * 5.0
        projected_profit = cost - item.revenue
        diff_err = projected_profit - item.profit
        if (diff_err <= 0):
            observer.on_next(record)
        else:
            observer.on_error(record)
      observer.on_completed()

一个 Observable 函数可以是同步的或异步的。我们的目标是创建一个异步的,例如 process_list()。协程函数应该有以下回调方法,以符合 Observable 函数的资格:

  • 一个 on_next() 方法,在给定一定条件下发出项目

  • 一个 on_completed() 方法,在函数完成操作时执行一次

  • 当在 Observable 上发生错误时被调用的 on_error() 方法

我们的 process_list() 发射获得一些利润的出版物的详细信息。然后,我们为 process_list() 协程的调用创建一个 asyncio 任务。我们创建了一个嵌套函数 evaluate_profit(),它返回 RxPY 的 create() 方法所需的 Disposable 任务,用于生成 Observable 流。当 Observable 流全部被消费时,此任务会被取消。以下是对执行异步 Observable 函数和使用 create() 方法从该 Observable 函数生成数据流的完整实现:

def create_observable(loop):
    def evaluate_profit(observer, scheduler):
        task = asyncio.ensure_future(
            process_list(observer), loop=loop)
        return Disposable(lambda: task.cancel())
    return rx.create(evaluate_profit)

create_observable() 创建的订阅者是我们应用程序的 list_sales_by_quota() API 服务。它需要获取当前运行的事件循环,以便方法可以生成 Observable。之后,它调用 subscribe() 方法向流发送订阅并提取所需的结果。Observable 的 subscribe() 方法被调用以使客户端订阅流并观察发生的传播:

@router.get("/sales/list/quota")
async def list_sales_by_quota():
    loop = asyncio.get_event_loop()
    observer = create_observable(loop)

    observer.subscribe(
        on_next=lambda value: print("Received Instruction 
              to buy {0}".format(value)),
        on_completed=lambda: print("Completed trades"),
        on_error=lambda e: print(e),
        scheduler = AsyncIOScheduler(loop) 
    )
    return {"message": "Notification 
           sent to the background"}

list_sales_by_quote() 协程服务展示了如何订阅一个 Observable。订阅者应利用以下回调方法:

  • 一个 on_next() 方法来消费流中的所有项目

  • 一个 on_completed() 方法来指示订阅的结束

  • 一个在订阅过程中标记错误的 on_error() 方法

由于 Observable 的处理是异步的,因此调度器是一个可选参数,它提供了正确的管理器来调度和运行这些进程。API 服务使用 AsyncIOScheduler 作为订阅的适当调度。但还有其他生成 Observables 的快捷方式,这些快捷方式不使用自定义函数。

创建后台进程

当我们创建持续运行的 Observables 时,我们使用 interval() 函数而不是使用自定义的 Observable 函数。一些 Observables 被设计为成功结束,但一些则是为了在后台持续运行。以下 Observable 会定期在后台运行,以提供关于从报纸订阅中收到的总金额的一些更新:

import asyncio
import rx
import rx.operators as ops
async def compute_subscriptions():
    total = 0.0
    repo = SubscriptionCustomerRepository()
    result = await repo.join_customer_subscription_total()

    for customer in result:
        subscription = customer.children
        for item in subscription:
            total = total + (item.price * item.qty)
    await asyncio.sleep(1)
    return total
def fetch_records(rate, loop) -> rx.Observable:
    return rx.interval(rate).pipe(
        ops.map(lambda i: rx.from_future(
          loop.create_task(compute_subscriptions()))),
        ops.merge_all()
    )

interval()方法以秒为单位定期创建数据流。但由于pipe()方法的执行,这个 Observable 对其流施加了一些传播。Observable 的pipe()方法创建了一个称为中间操作符的反应式操作符的管道。这个管道可以由一系列一次运行一个操作符的链式操作符组成,以改变流中的项目。似乎这一系列操作在订阅者上创建了多个订阅。因此,fetch_records()在其管道中有一个map()操作符,用于从compute_subcription()方法中提取结果。它在管道的末尾使用merge_all()来合并和展平创建的所有子流,形成一个最终的流,这是订阅者期望的流。现在,我们也可以从文件或 API 响应生成 Observable 数据。

访问 API 资源

创建 Observable 的另一种方法是使用from_()方法,它从文件、数据库或 API 端点提取资源。Observable 函数从我们的应用程序的 API 端点生成的 JSON 文档中检索其数据。假设我们正在使用hypercorn运行应用程序,它使用HTTP/2,因此我们需要通过将httpx.AsyncClient()verify参数设置为False来绕过 TLS 证书。

以下代码突出了fetch_subscription()操作中的from_(),它创建了一个 Observable,从https://localhost:8000/ch08/subscription/list/all端点发出str数据流。Observable 的这些反应式操作符,即filter()map()merge_all(),用于在流中传播所需上下文:

async def fetch_subscription(min_date:date, 
         max_date:date, loop) -> rx.Observable:
    headers = {
            "Accept": "application/json",
            "Content-Type": "application/json"
        }
    async with httpx.AsyncClient(http2=True, 
             verify=False) as client:
        content = await 
          client.get('https://localhost:8000/ch08/
            subscription/list/all', headers=headers)
    y = json.loads(content.text)
    source = rx.from_(y)
    observable = source.pipe(
      ops.filter(lambda c: filter_within_dates(
               c, min_date, max_date)),
      ops.map(lambda a: rx.from_future(loop.create_task(
            convert_str(a)))),
      ops.merge_all(),
    )
    return observable

filter()方法是另一个管道操作符,它从验证规则返回布尔值。它执行以下filter_within_dates()以验证从 JSON 文档检索的记录是否在订阅者指定的日期范围内:

def filter_within_dates(rec, min_date:date, max_date:date):
    date_pur = datetime.strptime(
             rec['date_purchased'], '%Y-%m-%d')
    if date_pur.date() >= min_date and 
             date_pur.date() <= max_date:
        return True
    else:
        return False

另一方面,以下convert_str()是一个由map()操作符执行的协程函数,用于生成从 JSON 数据派生的报纸订阅者的简洁配置文件详情:

async def convert_str(rec):
    if not rec == None:
        total = rec['qty'] * rec['price']
        record = " ".join([rec['branch'], 
            str(total), rec['date_purchased']])
        await asyncio.sleep(1)
        return record

运行这两个函数将原始发出的数据流从 JSON 修改为日期过滤的str数据流。另一方面,list_dated_subscription()协程 API 服务订阅fetch_subscription()以提取min_datemax_date范围内的报纸订阅:

@router.post("/subscription/dated")
async def list_dated_subscription(min_date:date, 
            max_date:date):

    loop = asyncio.get_event_loop()
    observable = await fetch_subscription(min_date, 
             max_date, loop)

    observable.subscribe(
       on_next=lambda item: 
         print("Subscription details: {}.".format(item)),
       scheduler=AsyncIOScheduler(loop)
    )

尽管 FastAPI 框架尚未完全支持反应式编程,我们仍然可以创建可以与各种 RxPY 实用程序一起工作的协程。现在,我们将探讨协程不仅用于后台进程,也用于 FastAPI 事件处理器。

定制事件

FastAPI 框架具有一些称为 事件处理器 的特殊功能,这些功能在应用程序启动之前和关闭期间执行。每当 uvicornhypercorn 服务器重新加载时,都会激活这些事件。事件处理器也可以是协程。

定义启动事件

启动事件 是服务器在启动时执行的事件处理器。我们使用 @app.on_event("startup") 装饰器来创建启动事件。应用程序可能需要一个启动事件来集中处理某些事务,例如某些组件的初始配置或与数据相关的资源的设置。以下是一个应用程序启动事件示例,该事件为 GINO 存储库事务打开数据库连接:

app = FastAPI()
@app.on_event("startup")
async def initialize():
    engine = await db.set_bind("postgresql+asyncpg://
          postgres:admin2255@localhost:5433/nsms")

这个 initialize() 事件定义在我们的应用程序的 main.py 文件中,以便 GINO 可以在每次服务器重新加载或重启时仅创建一次连接。

定义关闭事件

同时,关闭事件清理不需要的内存,销毁不需要的连接,并记录关闭应用程序的原因。以下是我们应用程序的关闭事件,该事件关闭了 GINO 数据库连接:

@app.on_event("shutdown")
async def destroy():
    engine, db.bind = db.bind, None
    await engine.close()

我们可以在 APIRouter 中定义启动和关闭事件,但请确保这不会导致事务重叠或与其他路由器冲突。此外,事件处理器在挂载的子应用程序中不起作用。

摘要

除了使用基于 ASGI 的服务器之外,协程的使用是使 FastAPI 微服务应用程序快速的因素之一。本章已经证明,使用协程实现 API 服务比在线程池中利用更多线程能更好地提高性能。由于框架运行在 asyncio 平台上,我们可以利用 asyncio 工具设计各种设计模式来管理 CPU 密集型和 I/O 密集型服务。

本章使用了 Celery 和 Redis 来创建和管理后台异步任务,例如日志记录、系统监控、时间分片计算和批量作业。我们了解到 RabbitMQ 和 Apache Kafka 为构建 FastAPI 组件之间的异步和松散耦合通信提供了一个集成解决方案,特别是对于这些交互的消息传递部分。最重要的是,协程被应用于创建这些异步和非阻塞的后台进程以及消息传递解决方案,以提升性能。本章还介绍了通过 RxPy 扩展模块引入的反应式编程。

总体而言,本章得出结论,FastAPI 框架已准备好构建一个具有 可靠异步消息驱动实时消息传递分布式核心系统 的微服务应用程序。下一章将突出介绍其他 FastAPI 功能,这些功能提供了与 UI 相关的工具和框架的集成、使用 OpenAPI 规范的 API 文档、会话处理以及绕过 CORS。

第三部分:基础设施相关问题、数值和符号计算以及微服务测试

在本书的最后一部分,我们将讨论其他重要的微服务功能,例如分布式跟踪和日志记录、服务注册、虚拟环境和 API 度量。还将介绍使用 Docker 和 Docker Compose 进行无服务器部署,其中 NGINX 作为反向代理。此外,我们还将探讨 FastAPI 作为构建科学应用的框架,使用来自numpyscipysympypandas模块的数值算法来模拟、分析和可视化其 API 服务的数学和统计解决方案。

本部分包括以下章节:

  • 第九章**,利用其他高级功能

  • 第十章**,解决数值、符号和图形问题

  • 第十一章**,添加其他微服务功能

第九章:利用其他高级功能

前几章已经展示了 FastAPI 框架的一些基本核心功能。然而,有一些功能并非真正固有的框架功能,可以帮助我们微调性能和修补实现中的缺失环节。这些包括会话处理、管理 跨源资源共享CORS)相关问题和为应用程序选择合适的渲染类型。

除了内置功能外,还有一些经过验证的解决方案可以在应用到应用程序时与 FastAPI 一起使用,例如其会话处理机制,它可以使用 SessionMiddleware 运行良好。关于中间件,本章还将探讨除了应用 @app.middleware 装饰器之外自定义请求和响应过滤器的方法。本章还将涵盖其他问题,例如使用自定义 APIRouteRequest,以指导我们管理传入的 字节 正文表单JSON 数据。此外,本章将强调如何使用 pytest 框架和 fastapi.testclient 库测试 FastAPI 组件,以及如何使用 OpenAPI 3.x 规范来记录端点。

总体而言,本章的主要目标是提供其他解决方案,帮助我们完成我们的微服务应用程序。在本章中,包括以下主题:

  • 应用会话管理

  • 管理 CORS 机制

  • 自定义 APIRouteRequest

  • 选择合适的响应

  • 应用 OpenAPI 3.x 规范

  • 测试 API 端点

技术要求

虽然与数据分析无关,但本章的应用原型是 ch09 项目。

应用会话管理

会话管理是一种用于管理由用户访问应用程序创建的请求和响应的功能。它还涉及在用户会话中创建和共享数据。许多框架通常在它们的插件中包含会话处理功能,但 FastAPI 除外。在 FastAPI 中,创建用户会话和存储会话数据是两个独立的编程问题。我们使用 JWT 来建立用户会话,并使用 Starlette 的 SessionMiddleware 来创建和检索会话数据。在 FastAPI 中,创建用户会话和存储会话数据是两种完全不同的编程解决方案。我们使用 JWT 来建立用户会话,并使用 Starlette 的 SessionMiddleware 来创建和检索会话数据。

创建用户会话

我们已经证明了 JWT 在确保 FastAPI 微服务应用程序安全方面的重要性,见 第七章**, “保护 REST API”。然而,在这里,JWT 被应用于根据用户凭证创建会话。在 api/login.py 路由器中,实现了 authenticate() API 服务以创建认证用户的用户会话。FastAPI 生成用户会话利用浏览器 cookie 是固有的。以下代码片段显示了使用 cookie 值的认证过程:

from util.auth_session import secret_key
from jose import jwt
@router.post("/login/authenticate")
async def authenticate(username:str, password: str, 
   response: Response, engine=Depends(create_db_engine)):
    repo:LoginRepository = LoginRepository(engine)
    login = await repo.get_login_username(username, 
                       password)
    if login == None:
            raise HTTPException(
                status_code=status.HTTP_403_FORBIDDEN, 
                detail="Invalid authentication"
            )
    token = jwt.encode({"sub": username}, secret_key)
    response.set_cookie("session", token)
    return {"username": username}

该服务将通过 LoginRepository 使用其 usernamepassword 凭证验证用户是否是有效账户。如果用户是认证的,它将使用 JWT 创建一个基于以下命令生成的 secret_key 生成的令牌:

openssl rand -hex 32

令牌密钥将作为基于 cookie 的会话的会话 ID。JWT 将以 username 凭证作为有效载荷存储为名为 session 的浏览器 cookie。

为了确保会话已被应用,所有后续请求都必须通过 APIKeyCookie 类(fastapi.security 模块的基于 cookie 的认证 API 类)进行基于 cookie 的会话认证。APIKeyCookie 类在将其注入到用于 JWT 解码的可靠函数之前,会通过用于生成会话 ID 的 secret_key 值获取会话。util/auth_session.py 中的以下可靠函数将验证对应用程序每个端点的每次访问:

from fastapi.security import APIKeyCookie
from jose import jwt
cookie_sec = APIKeyCookie(name="session")
secret_key = "pdCFmblRt4HWKNpWkl52Jnq3emH3zzg4b80f+4AFVC8="
async def get_current_user(session: str = 
   Depends(cookie_sec), engine=Depends(create_db_engine)):
    try:
        payload = jwt.decode(session, secret_key)
        repo:LoginRepository = LoginRepository(engine)
        login = await repo.validate_login(
                    payload["sub"])
        if login == None:
            raise HTTPException(
                status_code=status.HTTP_403_FORBIDDEN, 
                detail="Invalid authentication"
            )
        else:
            return login
    except Exception:
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN, 
            detail="Invalid authentication"
        )

上述函数被注入到每个 API 端点以强制进行用户会话验证。当请求端点时,此函数将解码令牌并提取 username 凭证以进行账户验证。然后,如果用户是未认证的或会话无效,它将发出 状态码 403 (禁止)。一个认证服务的示例可以在以下实现中找到:

from util.auth_session import get_current_user
@router.post("/restaurant/add")
async def add_restaurant(req:RestaurantReq, 
         engine=Depends(create_db_engine), 
         user: str = Depends(get_current_user)):
    restaurant_dict = req.dict(exclude_unset=True) 
    restaurant_json = dumps(restaurant_dict, 
              default=json_datetime_serializer)
    repo:RestaurantRepository = 
             RestaurantRepository(engine)
    result = await repo.insert_restaurant(
               loads(restaurant_json))
    if result == True: 
        return req 
    else: 
        return JSONResponse(content={"message": 
         "insert login unsuccessful"}, status_code=500)

add_restaurant() 服务是一个端点,它将一个 Document 类型的餐厅添加到 MongoDB 集合中。但在事务进行之前,它首先通过注入的 get_current_user() 可靠函数检查是否存在基于 cookie 的会话。

管理会话数据

很遗憾,在基于 APIKeyCookie 的会话认证中,添加和检索会话数据并不是其功能的一部分。JWT 负载必须仅包含用户名,但不能包含所有凭证和数据主体。为了管理会话数据,我们需要使用 Starlette 的 SessionMiddleware 创建一个单独的会话。尽管 FastAPI 有其 fastapi.middleware 模块,但它仍然支持 Starlette 内置的中间件。

我们在第二章**,探索核心功能*中提到了中间件,并展示了使用@app.middleware装饰器来实现它的方法。我们已经证明它作为所有传入请求和传出响应到服务的过滤器。这次,我们不会自定义实现中间件,而是使用内置的中间件类。

中间件在main.py模块中实现、配置和激活,因为APIRouter无法添加中间件。我们启用FastAPI构造函数的middleware参数,并将内置的SessionMiddleware及其secret_key和新的会话名称作为构造参数添加到该 List-type 参数中,使用可注入的类Middleware。以下main.py的代码片段显示了如何配置它:

from starlette.middleware.sessions import SessionMiddleware
app = FastAPI(middleware=[
        Middleware(SessionMiddleware, 
        secret_key=
            '7UzGQS7woBazLUtVQJG39ywOP7J7lkPkB0UmDhMgBR8=', 
        session_cookie="session_vars")])

添加中间件的另一种方式是利用FastAPI装饰器的add_middleware()函数。最初,添加SessionMiddleware将创建另一个基于 cookie 的会话,该会话将处理会话范围内的数据。这是唯一的方法,因为 FastAPI 没有直接支持会话处理机制,其中用户会话不仅用于安全,还用于处理会话对象。

要将会话数据添加到我们新创建的会话session_vars中,我们需要将Request注入到每个端点服务中,并利用其会话字典来存储会话范围内的对象。以下list_restaurants()服务从数据库中检索餐厅列表,提取所有餐厅名称,并通过request.session[]在会话中共享名称列表:

@router.get("/restaurant/list/all")
async def list_restaurants(request: Request, 
       engine=Depends(create_db_engine), 
       user: str = Depends(get_current_user)):
    repo:RestaurantRepository = 
             RestaurantRepository(engine)
    result = await repo.get_all_restaurant()
    resto_names = [resto.name for resto in result]
    request.session['resto_names'] = resto_names
    return result
@router.get("/restaurant/list/names")
async def list_restaurant_names(request: Request, 
           user: str = Depends(get_current_user)):
    resto_names = request.session['resto_names']
    return resto_names

另一方面,list_restaurant_names()服务通过request.session[]检索resto_names会话数据,并将其作为其响应返回。顺便说一句,正是由于SessionMiddleware的存在,session[]才存在。否则,使用此字典将引发异常。

移除会话

完成事务后,始终必须从应用程序中注销以移除所有创建的会话。由于创建会话最简单和最直接的方式是通过浏览器 cookie,移除所有会话可以保护应用程序免受任何妥协。以下/ch09/logout端点移除了我们的会话,sessionsession_vars,这在技术上意味着用户从应用程序中注销:

@router.get("/logout")
async def logout(response: Response, 
            user: str = Depends(get_current_user)):
    response.delete_cookie("session")
    response.delete_cookie("session_vars")
    return {"ok": True}

Response类的delete_cookie()方法移除了应用程序使用的任何现有浏览器会话。

自定义 BaseHTTPMiddleware

管理 FastAPI 会话的默认方法是通过 cookie,并且它不提供其他选项,如数据库支持、缓存和基于文件的会话。实现基于非 cookie 策略来管理用户会话和会话数据的最佳方法是自定义BaseHTTPMiddleware。以下自定义中间件是一个原型,为认证用户创建会话:

from repository.login import LoginRepository
from repository.session import DbSessionRepository
from starlette.middleware.base import BaseHTTPMiddleware
from datetime import date, datetime
import re
from odmantic import AIOEngine
from motor.motor_asyncio import AsyncIOMotorClient
class SessionDbMiddleware(BaseHTTPMiddleware):
    def __init__(self, app, sess_key: str, 
                    sess_name:str, expiry:str):
        super().__init__(app)
        self.sess_key = sess_key
        self.sess_name = sess_name 
        self.expiry = expiry
        self.client_od = 
         AsyncIOMotorClient(f"mongodb://localhost:27017/")
        self.engine = 
         AIOEngine(motor_client=self.client_od, 
            database="orrs")

    async def dispatch(self, request: Request, call_next):
        try:
            if re.search(r'\bauthenticate\b', 
                    request.url.path):
                credentials = request.query_params
                username = credentials['username']
                password = credentials['password']
                repo_login:LoginRepository = 
                      LoginRepository(self.engine)
                repo_session:DbSessionRepository = 
                      DbSessionRepository(self.engine)

                login = await repo_login.
                  get_login_credentials(username, password)

                if login == None:
                    self.client_od.close()
                    return JSONResponse(status_code=403) 
                else:
                    token = jwt.encode({"sub": username}, 
                        self.sess_key)
                    sess_record = dict()
                    sess_record['session_key'] = 
                        self.sess_key
                    sess_record['session_name'] = 
                        self.sess_name
                    sess_record['token'] = token
                    sess_record['expiry_date'] = 
                       datetime.strptime(self.expiry, 
                            '%Y-%m-%d')
                    await repo_session.
                        insert_session(sess_record)
                    self.client_od.close()
                    response = await call_next(request)
                    return response
            else:
                response = await call_next(request)
                return response
        except Exception as e :
            return JSONResponse(status_code=403)

如在第 第二章* 探索核心功能 中所述,SessionDbMiddleware 将过滤我们的 /ch09/login/authenticate 端点上的 usernamepassword 查询参数,检查用户是否是已注册用户,并从 JWT 生成数据库支持的会话。之后,端点可以验证存储在数据库中的会话的所有请求。/ch09/logout 端点将不会包括使用其仓库事务从数据库中删除会话,如下面的代码所示:

@router.get("/logout")
async def logout(response: Response, 
       engine=Depends(create_db_engine), 
       user: str = Depends(get_current_user)):
    repo_session:DbSessionRepository = 
             DbSessionRepository(engine)
    await repo_session.delete_session("session_db")
    return {"ok": True}

注意,DbSessionRepository 是我们原型的一个自定义仓库实现,它有一个 delete_session() 方法,可以通过其名称从我们的 MongoDB 数据库的 db_session 集合中删除会话。

另一种可以帮助 FastAPI 应用程序解决与 CORS 浏览器机制相关问题的中间件是 CORSMiddleware

管理 CORS 机制

当将 API 端点与各种前端框架集成时,我们经常遇到浏览器返回的 "没有‘access-control-allow-origin’头存在" 错误。如今,这已成为任何浏览器的基于 HTTP 头的机制,它要求后端服务器向浏览器提供服务器端应用程序的 "origin" 细节,包括服务器域名、方案和端口。这种机制称为 CORS,当前端应用程序及其网络资源属于与后端应用程序不同的域区域时发生。如今,出于安全原因,浏览器禁止服务器端和前端应用程序之间的跨域请求。

为了解决这个问题,我们需要在 main.py 模块中将我们应用程序的所有来源以及原型使用的其他集成资源放在一个 List 中。然后,我们从 fastapi.middleware.cors 模块导入内置的 CORSMiddleware,并将其添加到 FastAPI 构造函数中,与来源列表一起,这个列表不应太长,以避免验证每个 URL 时的开销。以下代码片段显示了将 CORSMiddleware 注入到 FastAPI 构造函数中:

origins = [
    "https://192.168.10.2",
    "http://192.168.10.2",
    "https://localhost:8080",
    "http://localhost:8080"
]
app = FastAPI(middleware=[
           Middleware(SessionMiddleware, secret_key=
            '7UzGQS7woBazLUtVQJG39ywOP7J7lkPkB0UmDhMgBR8=', 
               session_cookie="session_vars"),
           Middleware(SessionDbMiddleware, sess_key=
            '7UzGQS7woBazLUtVQJG39ywOP7J7lkPkB0UmDhMgBR8=',
              sess_name='session_db', expiry='2020-10-10')
            ])
app.add_middleware(CORSMiddleware, max_age=3600,
     allow_origins=origins, allow_credentials=True,
     allow_methods= ["POST", "GET", "DELETE", 
       "PATCH", "PUT"], allow_headers=[
            "Access-Control-Allow-Origin", 
            "Access-Control-Allow-Credentials", 
            "Access-Control-Allow-Headers",
            "Access-Control-Max-Age"])

这次,我们使用了 FastAPI 的add_middleware()函数来为我们的应用程序添加 CORS 支持。除了allow_origins之外,我们还需要将allow_credentials参数添加到CORSMiddleware中,该参数将Access-Control-Allow-Credentials: true添加到响应头,以便浏览器识别域名来源匹配并发送一个Authorization cookie 以允许请求。此外,我们还必须包括allow_headers参数,该参数在浏览器交互期间注册一组可接受的头键。除了默认包含的AcceptAccept-LanguageContent-LanguageContent-Type之外,我们还需要显式注册Access-Control-Allow-OriginAccess-Control-Allow-CredentialsAccess-Control-Allow-HeadersAccess-Control-Max-Age而不是使用通配符(*)。allow_headers参数还必须是中间件的一部分,以指定浏览器需要支持的其他 HTTP 方法。最后,max_age参数也必须在配置中,因为我们需要告诉浏览器它将缓存加载到浏览器中的所有资源的时间。

如果应用程序需要额外的 CORS 支持功能,自定义CORSMiddleware以扩展一些内置实用程序和功能来管理 CORS 是一个更好的解决方案。

顺便说一句,我们不仅可以对中间件进行子类化并用于创建自定义实现,还可以对Request数据和 API 路由进行自定义。

自定义 APIRoute 和 Request

中间件可以处理 FastAPI 应用程序中所有 API 方法的传入Request数据和传出Response对象,但无法操作消息正文,从Request数据中附加状态对象,或修改客户端消费之前的响应对象。只有APIRouteRequest的自定义才能让我们全面掌握如何控制请求和响应事务。控制可能包括确定传入数据是字节正文、表单还是 JSON,并提供有效的日志记录机制、异常处理、内容转换和提取。

管理正文、表单或 JSON 数据

与中间件不同,自定义APIRoute不适用于所有 API 端点。为某些APIRouter实现APIRoute只会为受影响的端点施加新的路由规则,而其他服务可以继续使用默认的请求和响应过程。例如,以下自定义仅适用于api.route_extract.router端点的数据提取:

from fastapi.routing import APIRoute
from typing import Callable
from fastapi import Request, Response
class ExtractContentRoute(APIRoute):
    def get_route_handler(self) -> Callable:
        original_route_handler = 
                super().get_route_handler()

        async def custom_route_handler(request: Request) 
                    -> Response:
            request = ExtractionRequest(request.scope, 
                        request.receive)
            response: Response = await 
                    original_route_handler(request)
            return response
        return custom_route_handler

自定义APIRoute需要从APIRouteoriginal_route_handler创建一个 Python RequestResponse流程。另一方面,我们的ExtractContentRoute过滤器使用一个自定义的ExtractionRequest,该请求识别并分别处理每种传入请求数据。以下是将替换默认Request对象的ExtractionRequest的实现:

class ExtractionRequest(Request):
    async def body(self):
        body = await super().body()
        data = ast.literal_eval(body.decode('utf-8'))
        if isinstance(data, list):
            sum = 0
            for rate in data:
                sum += rate 
            average = sum / len(data)
            self.state.sum = sum 
            self.state.avg = average
        return body 

    async def form(self):
        body = await super().form()
        user_details = dict()
        user_details['fname'] = body['firstname']
        user_details['lname'] = body['lastname']
        user_details['age'] = body['age']
        user_details['bday'] = body['birthday']
        self.session["user_details"] = user_details
        return body

    async def json(self):
        body = await super().json()
        if isinstance(body, dict):

            sum = 0
            for rate in body.values():
                sum += rate  

            average = sum / len(body.values())
            self.state.sum = sum 
            self.state.avg = average
        return body

要激活此ExtractionRequest,我们需要将端点的APIRouterroute_class设置为ExtractContentRoute,如下面的代码片段所示:

router = APIRouter()
router.route_class = ExtractContentRoute

在管理各种请求体时,有三个方法可以选择覆盖:

  • body(): 这管理传入的字节数据请求

  • form(): 这处理传入的表单数据

  • json(): 这管理传入的解析 JSON 数据

  • stream(): 这通过使用async for构造访问字节块中的消息体

所有这些方法都将原始请求数据以字节形式返回给服务。

ExtractionRequest中,我们从给定的选项中实现了三个接口方法来过滤和处理所有来自/api/route_extract.py模块中定义的 API 端点的传入请求。

以下create_profile()服务从客户端接收配置文件数据并实现ExtractContentRoute过滤器,该过滤器将使用会话处理将所有这些配置文件数据存储在字典中:

@router.post("/user/profile")
async def create_profile(req: Request, 
        firstname: str = Form(...), 
        lastname: str = Form(...), age: int = Form(...), 
        birthday: date = Form(...), 
        user: str = Depends(get_current_user)):
    user_details = req.session["user_details"]
    return {'profile' : user_details} 

被覆盖的form()方法ExtractionRequest负责包含所有用户详情的user_details属性。

另一方面,给定的set_ratings()方法有一个包含各种评分的传入字典,其中json()覆盖将推导出一些基本统计信息。所有结果都将作为Request的状态对象或请求属性返回:

@router.post("/rating/top/three")
async def set_ratings(req: Request, data : 
 Dict[str, float], user: str = Depends(get_current_user)):
    stats = dict()
    stats['sum'] = req.state.sum
    stats['average'] = req.state.avg
    return {'stats' : stats } 

最后,前面的compute_data()服务将有一个包含评分的传入列表作为一些基本统计信息(如前一个服务中所示)的来源。ExtractionRequestbody()方法覆盖将处理计算:

@router.post("/rating/data/list")
async def compute_data(req: Request, data: List[float], 
  user: str = Depends(get_current_user)):
    stats = dict()
    stats['sum'] = req.state.sum
    stats['average'] = req.state.avg
    return {'stats' : stats }

加密和解密消息体

另一个需要自定义端点路由的场景是我们必须通过加密来保护消息体。以下自定义请求使用 Python 的cryptography模块和加密消息体的密钥解密加密的消息体:

from cryptography.fernet import Fernet
class DecryptRequest(Request):
    async def body(self):
        body = await super().body()
        login_dict = ast.literal_eval(body.decode('utf-8'))
        fernet = Fernet(bytes(login_dict['key'], 
             encoding='utf-8'))
        data = fernet.decrypt(
          bytes(login_dict['enc_login'], encoding='utf-8'))
        self.state.dec_data = json.loads(
             data.decode('utf-8'))
        return body

重要提示

cryptography模块需要安装itsdangerous扩展来执行本项目中的加密/解密过程。

DecryptRequest将解密消息并返回登录记录列表作为请求state对象。以下服务提供加密的消息体和密钥,并从DecryptRequest返回解密后的登录记录列表作为响应:

@router.post("/login/decrypt/details")
async def send_decrypt_login(enc_data: EncLoginReq, 
   req:Request, user: str = Depends(get_current_user)):
    return {"data" : req.state.dec_data}

注意send_decrypt_login()有一个包含加密消息体和加密密钥的EncLoginReq请求模型。

自定义路由及其Request对象可以帮助优化和简化微服务事务,特别是那些需要大量负载在消息体转换、转换和计算上的 API 端点。

现在,我们的下一次讨论将集中在为 API 服务应用不同的Response类型。

选择合适的响应

FastAPI 框架除了最常见的 JsonResponse 选项外,还提供了其他用于渲染 API 端点响应的选项。以下是我们应用程序支持的响应类型列表及其示例:

  • 如果 API 端点的响应仅基于文本,则可以利用 PlainTextResponse 类型。以下 intro_list_restaurants() 服务向客户端返回一个基于文本的消息:

    @router.get("/restaurant/index")
    def intro_list_restaurants():
      return PlainTextResponse(content="The Restaurants")
    
  • 如果服务需要导航到另一个完全不同的应用程序或同一应用程序的另一个端点,可以使用 RedirectResponse。以下端点跳转到一个关于一些知名米其林星级餐厅的超文本引用:

    @router.get("/restaurant/michelin")
    def redirect_restaurants_rates():
      return RedirectResponse(
          url="https://guide.michelin.com/en/restaurants")
    
  • FileResponse 类型可以帮助服务渲染文件的一些内容,最好是文本文件。以下 load_questions() 服务显示了保存在应用程序 /file 文件夹中的 questions.txt 文件中的问题列表:

    @router.get("/question/load/questions")
    async def load_questions(user: str = 
                        Depends(get_current_user)):
        file_path = os.getcwd() + 
          '\\files\\questions.txt';
        return FileResponse(path=file_path, 
                     media_type="text/plain")
    
  • StreamingResponse 是另一种响应类型,它为我们提供了对 EventSourceResponse 类型的另一种方法:

    @router.get("/question/sse/list")    
    async def list_questions(req:Request, 
             engine=Depends(create_db_engine), 
                user: str = Depends(get_current_user)):
        async def print_questions():
            repo:QuestionRepository = 
                    QuestionRepository(engine)
            result = await repo.get_all_question()
            for q in result:
                disconnected = await req.is_disconnected()
                if disconnected:
                    break
                yield 'data: {}\n\n.format(
                   json.dumps(jsonable_encoder(q), 
                          cls=MyJSONEncoder))
                await asyncio.sleep(1)
        return StreamingResponse(print_questions(), 
                    media_type="text/event-stream")
    
  • 可以渲染图像的服务也可以使用 StreamingResponse 类型。以下 logo_upload_png() 服务上传任何 JPEGPNG 文件并在浏览器中渲染:

    @router.post("/restaurant/upload/logo")
    async def logo_upload_png(logo: UploadFile = File(...)):
        original_image = Image.open(logo.file)
        original_image = 
             original_image.filter(ImageFilter.SHARPEN)
        filtered_image = BytesIO()
        if logo.content_type == "image/png":
            original_image.save(filtered_image, "PNG")
            filtered_image.seek(0)
            return StreamingResponse(filtered_image, 
                     media_type="image/png")
        elif logo.content_type == "image/jpeg":
            original_image.save(filtered_image, "JPEG")
            filtered_image.seek(0)
            return StreamingResponse(filtered_image, 
                   media_type="image/jpeg") 
    
  • StreamingResponse 类型在渲染各种格式的视频(如 sample.mp4)并将其发布到浏览器方面也非常有效:

    @router.get("/restaurant/upload/video")
    def video_presentation():
        file_path = os.getcwd() + '\\files\\sample.mp4'
        def load_file():  
            with open(file_path, mode="rb") as video_file:  
                yield from video_file  
        return StreamingResponse(load_file(), 
                  media_type="video/mp4")
    
  • 如果服务想要发布一个简单的 HTML 标记页面,而不引用静态 CSS 或 JavaScript 文件,那么 HTMLResponse 就是正确的选择。以下服务渲染了一个由某些 CDN 库提供的 Bootstrap 框架的 HTML 页面:

    @router.get("/signup")
    async def signup(engine=Depends(create_db_engine), 
           user: str = Depends(get_current_user) ):
       signup_content = """
        <html lang='en'>
            <head>
              <meta charset="UTF-8">
              <script src="https://code.jquery.com/jquery-
                        3.4.1.min.js"></script>
              <link rel="stylesheet" 
                href="https://stackpath.bootstrapcdn.com/
                  bootstrap/4.4.1/css/bootstrap.min.css">
              <script src="https://cdn.jsdelivr.net/npm/
                popper.js@1.16.0/dist/umd/popper.min.js">
              </script>
              <script   
               src="https://stackpath.bootstrapcdn.com/
           bootstrap/4.4.1/js/bootstrap.min.js"></script>
    
            </head>
            <body>
              <div class="container">
                <h2>Sign Up Form</h2>
                <form>
                    <div class="form-group">
                       <label for="firstname">
                              Firstname:</label>
                       <input type='text' 
                           class="form-control" 
                           name='firstname' 
                           id='firstname'/><br/>
                    </div>
                    … … … … … … … …
                    <div class="form-group">
                       <label for="role">Role:</label>
                       <input type='text' 
                         class="form-control" 
                         name='role' id='role'/><br/>
                    </div>
                    <button type="submit" class="btn 
                        btn-primary">Sign Up</button>
                </form>
               </div>
            </body>
        </html>
        """
        return HTMLResponse(content=signup_content, 
                   status_code=200)
    
  • 如果 API 端点需要发布其他渲染类型,可以通过 Response 类的 media_type 属性进行自定义。以下是一个将 JSON 数据转换为 XML 内容的服务,通过将 Responsemedia_type 属性设置为 application/xml MIME 类型:

    @router.get("/keyword/list/all/xml")
    async def 
       convert_to_xml(engine=Depends(create_db_engine), 
            user: str = Depends(get_current_user)): 
        repo:KeyRepository = KeyRepository(engine)
        list_of_keywords = await repo.get_all_keyword()
        root = minidom.Document() 
        xml = root.createElement('keywords') 
        root.appendChild(xml) 
    
        for keyword in list_of_keywords:
            key = root.createElement('keyword')
            word = root.createElement('word')
            key_text = root.createTextNode(keyword.word)
            weight= root.createElement('weight')
            weight_text = 
                 root.createTextNode(str(keyword.weight))
            word.appendChild(key_text)
            weight.appendChild(weight_text)
            key.appendChild(word)
            key.appendChild(weight)
            xml.appendChild(key)
        xml_str = root.toprettyxml(indent ="\t") 
        return Response(content=xml_str, 
                media_type="application/xml")
    

虽然 FastAPI 不是一个 Web 框架,但它可以支持 Jinja2 模板,在 API 服务需要将响应渲染为 HTML 页面的罕见情况下。让我们突出 API 服务如何将 Jinja2 模板作为响应的一部分来使用。

设置 Jinja2 模板引擎

首先,我们需要使用 pip 安装 jinja2 模块:

pip install jinja2

然后,我们需要创建一个文件夹来存放所有的 Jinja2 模板。Jinja2 必须通过在 FastAPI 或任何 APIRouter 中创建 Jinja2Templates 实例来定义这个文件夹,通常命名为 templates。以下代码片段是 /api/login.py 路由的一部分,展示了 Jinja2 模板引擎的设置和配置:

from fastapi.templating import Jinja2Templates
router = APIRouter()
templates = Jinja2Templates(directory="templates")

设置静态资源

templates 文件夹之后,Jinja2 引擎要求应用程序在项目目录中有一个名为 static 的文件夹来存放 Jinja2 模板的 CSS、JavaScript、图像和其他静态文件。然后,我们需要实例化 StaticFiles 实例来定义 static 文件夹并将其映射到一个虚拟名称。此外,必须通过 FastAPImount() 方法将 StaticFiles 实例挂载到特定的路径。我们还需要将 StaticFiles 实例的 html 属性设置为 True 以将文件夹设置为 HTML 模式。以下配置展示了如何在 main.py 模块中设置静态资源文件夹:

from fastapi.staticfiles import StaticFiles
app.mount("/static", StaticFiles(directory="static", 
          html=True), name="static")

为了让 FastAPI 组件访问这些静态文件,引擎需要安装 aiofiles 扩展:

pip install aiofiles

创建模板布局

以下模板是 static 文件夹,由于模板引擎和 aiofiles 模块:

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta http-equiv="X-UA-Compatible" 
              content="IE=edge">
        <meta name="viewport" content="width=device-width, 
             initial-scale=1.0, shrink-to-fit=no">
        <meta name="apple-mobile-web-app-capable" 
             content="yes">

        <link rel="stylesheet" type="text/css" 
            href="{{url_for('static', 
               path='/css/bootstrap.min.css')}}">
        <script src="{{url_for('static', path='/js/
               jquery-3.6.0.js')}}"></script>
        <script src="{{url_for('static', 
              path='/js/bootstrap.min.js')}}"></script>
    </head>
    <body>
        {% block content %}
        {% endblock content %}
    </body>
</html>

其他模板可以使用 {% extends %} 标签继承此 layout.html 的结构和设计。与我们的 layout.html 一样,Jinja2 基础模板具有这些 Jinja2 标签,即 {% block content %}{% endblock %} 标签,它们指示子模板在翻译阶段可以插入其内容。但是,为了使所有这些模板都能正常工作,它们必须保存在 /templates 目录中。以下是一个名为 users.html 的示例子模板,它从上下文数据生成一个配置文件表:

{% extends "layout.html" %}
{% block content %}
<div class="container">
<h2>List of users </h2>
<p>This is a Boostrap 4 table applied to JinjaTemplate.</p>
<table class="table">
    <thead>
        <tr>
          <th>Login ID</th>
          <th>Username</th>
          <th>Password</th>
          <th>Passphrase</th>
        </tr>
      </thead>
      <tbody>
    {% for login in data %} 
    <tr>
        <td>{{ login.login_id}}</td>
        <td>{{ login.username}}</td>
        <td>{{ login.password}}</td>
        <td>{{ login.passphrase}}</td>
    </tr>
    {% endfor%}
</tbody>
</table>
</div>
{% endblock %}

注意,子 Jinja2 模板也有 "block" 标签来标记要合并到父模板中的内容。

为了让 API 渲染模板,服务必须使用 Jinja2 引擎的 TemplateResponse 类型作为响应类型。TemplateResponse 需要模板的文件名、Request 对象以及如果有任何上下文数据,还需要上下文数据。以下是一个渲染之前 users.html 模板的 API 服务:

@router.get("/login/html/list")
async def list_login_html(req: Request,
       engine=Depends(create_db_engine), 
       user: str = Depends(get_current_user)):
    repo:LoginRepository = LoginRepository(engine)
    result = await repo.get_all_login()
    return templates.TemplateResponse("users.html", 
           {"request": req, "data": result})

使用 ORJSONResponse 和 UJSONResponse

当需要返回大量字典或可 JSON 化的组件时,使用 ORJSONResponseUJSONResponse 都很合适。ORJSONResponse 使用 orjson 将大量的字典对象序列化为 JSON 字符串作为响应。因此,在使用 ORJSONResponse 之前,我们需要使用 pip 命令安装 orjsonORJSONResponse 比常见的 JSONResponse 更快地序列化 UUID、numpy、数据类和 datetime 对象。

然而,UJSONResponse 相比于 ORJSONResponse 要快一些,因为它使用了 ujson 序列化器。在使用 UJSONResponse 之前,必须首先安装 ujson 序列化器。

以下两个 API 服务使用了这两个快速的 JSON 序列化器替代方案:

@router.get("/login/list/all")
async def list_all_login(engine=Depends(create_db_engine), 
         user: str = Depends(get_current_user)): 
    repo:LoginRepository = LoginRepository(engine)
    result = await repo.get_all_login()
    return ORJSONResponse(content=jsonable_encoder(result),
             status_code=201)
@router.get("/login/account")
async def get_login(id:int, 
       engine=Depends(create_db_engine), 
       user: str = Depends(get_current_user) ):
    repo:LoginRepository = LoginRepository(engine)
    result = await repo.get_login_id(id)
    return UJSONResponse(content=jsonable_encoder(result), 
             status_code=201)

我们仍然需要在两个响应继续其序列化过程之前,将结果的 BSONObjectId 转换为 str,应用 jsonable_encoder() 组件。现在,让我们关注我们如何使用 OpenAPI 3.0 规范提供内部 API 文档。

应用 OpenAPI 3.x 规范

OpenAPI 3.0 规范是一种标准的 API 文档和语言无关的规范,可以描述 API 服务,而无需了解其来源、阅读其文档或理解其业务逻辑。此外,FastAPI 支持 OpenAPI,甚至可以根据 OpenAPI 标准自动生成 API 的默认内部文档。

使用规范文档我们的 API 服务有三种方式:

  • 通过扩展 OpenAPI 架构定义

  • 通过使用内部代码库属性

  • 通过使用 QueryBodyFormPath 函数

扩展 OpenAPI 架构定义

FastAPI 从其 fastapi.openapi.utils 扩展中有一个 get_openapi() 方法,可以覆盖一些架构描述。我们可以通过 get_openapi() 函数修改架构定义的 infoserverspaths 细节。该函数返回一个包含应用程序 OpenAPI 架构定义所有详细信息的 dict

默认的 OpenAPI 架构文档始终设置在 main.py 模块中,因为它始终与 FastAPI 实例相关联。为了生成包含架构详细信息的 dict 函数,它必须接受至少 titleversionroutes 参数值。以下自定义函数提取默认的 OpenAPI 架构以进行更新:

def update_api_schema():
   DOC_TITLE = "The Online Restaurant Rating System API"
   DOC_VERSION = "1.0"
   openapi_schema = get_openapi(
       title=DOC_TITLE,
       version=DOC_VERSION,
       routes=app.routes,
   )
app.openapi_schema = openapi_schema
return openapi_schema

title 参数值是文档标题,version 参数值是 API 实现的版本,routes 包含注册的 API 服务列表。注意,在 return 语句之前的最后一行更新了 FastAPI 内置的 openapi_schema 默认值。现在,为了更新一般信息细节,我们使用架构定义的 info 键来更改一些值,如下面的示例所示:

openapi_schema["info"] = {
       "title": DOC_TITLE,
       "version": DOC_VERSION,
       "description": "This application is a prototype.",
       "contact": {
           "name": "Sherwin John Tragura",
           "url": "https://ph.linkedin.com/in/sjct",
           "email": "cowsky@aol.com"
       },
       "license": {
           "name": "Apache 2.0",
           "url": "https://www.apache.org/
                  licenses/LICENSE-2.0.html"
       },
   }

前面的信息架构更新也必须是 update_api_schema() 函数的一部分,包括对每个注册的 API 服务文档的更新。这些细节可以包括 API 服务的描述和摘要、POST 端点的 requestBody 描述和 GET 端点的参数细节,以及 API 标签。添加以下 paths 更新:

openapi_schema["paths"]["/ch09/login/authenticate"]["post"]["description"] = "User Authentication Session"
openapi_schema["paths"]["/ch09/login/authenticate"]["post"]["summary"] = "This is an API that stores credentials in session."
openapi_schema["paths"]["/ch09/login/authenticate"]["post"]["tags"] = ["auth"]

openapi_schema["paths"]["/ch09/login/add"]["post"]
["description"] = "Adding Login User"
openapi_schema["paths"]["/ch09/login/add"]["post"]
["summary"] = "This is an API adds new user."
openapi_schema["paths"]["/ch09/login/add"]["post"]
["tags"] = ["operation"]
openapi_schema["paths"]["/ch09/login/add"]["post"]
["requestBody"]["description"]="Data for LoginReq"

openapi_schema["paths"]["/ch09/login/profile/add"]
["description"] = "Updating Login User"
openapi_schema["paths"]["/ch09/login/profile/add"]
["post"]["summary"] = "This is an API updating existing user record."
openapi_schema["paths"]["/ch09/login/profile/add"]
["post"]["tags"] = ["operation"]
openapi_schema["paths"]["/ch09/login/profile/add"]
["post"]["requestBody"]["description"]="Data for LoginReq"

openapi_schema["paths"]["/ch09/login/html/list"]["get"]["description"] = "Renders Jinja2Template with context data."
openapi_schema["paths"]["/ch09/login/html/list"]["get"]["summary"] = "Uses Jinja2 template engine for rendition."
openapi_schema["paths"]["/ch09/login/html/list"]["get"]["tags"] = ["rendition"]
openapi_schema["paths"]["/ch09/login/list/all"]["get"]["description"] = "List all the login records."
openapi_schema["paths"]["/ch09/login/list/all"]["get"]["summary"] = "Uses JsonResponse for rendition."
openapi_schema["paths"]["/ch09/login/list/all"]["get"]["tags"] = ["rendition"]

前面的操作将为我们提供一个新的 OpenAPI 文档仪表板,如图 9.1 所示:

图 9.1 – 定制的 OpenAPI 仪表板

图 9.1 – 定制的 OpenAPI 仪表板

标签是 OpenAPI 文档的关键变量,因为它们根据路由器、业务流程、需求和模块组织 API 端点。使用标签是一种最佳实践。

一旦所有更新都设置完毕,将 FastAPI 的 openapi() 函数替换为新的 update_api_schema() 函数。

使用内部代码库属性

FastAPI 的构造函数有参数可以替换默认的 info 文档细节,而不使用 get_openapi() 函数。以下代码片段展示了 OpenAPI 文档的 titledescriptionversionservers 细节的示例文档更新:

app = FastAPI(… … … …, 
            title="The Online Restaurant Rating 
                       System API",
            description="This a software prototype.",
            version="1.0.0",
            servers= [
                {
                    "url": "http://localhost:8000",
                    "description": "Development Server"
                },
                {
                    "url": "https://localhost:8002",
                    "description": "Testing Server",
                }
            ])

在向 API 端点添加文档时,FastAPIAPIRouter 的路径操作符也有参数允许更改分配给每个端点的默认 OpenAPI 变量。以下是一个示例服务,它通过 post() 路径操作符更新其 summarydescriptionresponse_description 和其他响应细节:

@router.post("/restaurant/add",
     summary="This API adds new restaurant details.",
     description="This operation adds new record to the 
          database. ",
     response_description="The message body.",
     responses={
        200: {
            "content": {
                "application/json": {
                    "example": {
                        "restaurant_id": 100,
                        "name": "La Playa",
                        "branch": "Manila",
                        "address": "Orosa St.",
                        "province": "NCR",
                        "date_signed": "2022-05-23",
                        "city": "Manila",
                        "country": "Philippines",
                        "zipcode": 1603
                    }
                }
            },
        },
        404: {
            "description": "An error was encountered during 
                     saving.",
            "content": {
                "application/json": {
                    "example": {"message": "insert login 
                       unsuccessful"}
                }
            },
        },
    },
    tags=["operation"])
async def add_restaurant(req:RestaurantReq, 
        engine=Depends(create_db_engine), 
          user: str = Depends(get_current_user)):
    restaurant_dict = req.dict(exclude_unset=True) 
    restaurant_json = dumps(restaurant_dict, 
           default=json_datetime_serializer)
    repo:RestaurantRepository = 
            RestaurantRepository(engine)
    result = await repo.insert_restaurant(
              loads(restaurant_json))
    if result == True: 
        return req 
    else: 
        return JSONResponse(content={"message": 
           "insert login unsuccessful"}, status_code=500)

使用 Query、Form、Body 和 Path 函数

除了声明和额外的验证之外,QueryPathFormBody 参数函数也可以用来向 API 端点添加一些元数据。以下 authenticate() 端点通过 Query() 函数添加了描述和验证:

@router.post("/login/authenticate")
async def authenticate(response: Response, 
    username:str = Query(..., 
       description='The username of the credentials.', 
       max_length=50), 
    password: str = Query(..., 
     description='The password of the of the credentials.', 
     max_length=20), 
    engine=Depends(create_db_engine)):
    repo:LoginRepository = LoginRepository(engine)
    … … … … … …
    response.set_cookie("session", token)
    return {"username": username}

以下 get_login() 使用 Path() 指令插入 id 参数的描述:

@router.get("/login/account/{id}")
async def get_login(id:int = Path(..., 
description="The user ID of the user."), 
   engine=Depends(create_db_engine), 
   user: str = Depends(get_current_user) ):
    … … … … … …
    return UJSONResponse(content=jsonable_encoder(result),
         status_code=201)

Query() 函数的 descriptionmax_length 元数据将成为 authenticate() 的 OpenAPI 文档的一部分,如图 9.2 所示:

图 *9.2* – 查询元数据

9.2 – 查询元数据

此外,Path() 指令的 description 元数据也将出现在 get_login() 文档中,如图 9.3 所示:

图 *9.3* – 路径元数据

9.3 – 路径元数据

同样,我们可以使用 Form 指令为表单参数添加描述。以下服务展示了如何通过 Form 指令插入文档:

@router.post("/user/profile")
async def create_profile(req: Request, 
firstname: str = Form(..., 
description='The first name of the user.'), 
lastname: str = Form(..., 
description='The last name of the user.'), 
age: int = Form(..., 
description='The age of the user.'), 
birthday: date = Form(..., 
description='The birthday of the user.'), 
        user: str = Depends(get_current_user)):
    user_details = req.session["user_details"]
    return {'profile' : user_details}

此外,还可以通过路径操作符的 responses 参数记录 API 服务可能抛出的所有类型的 HTTP 响应或状态码。以下 video_presentation() 服务提供了在遇到无错误(HTTP 状态码 200)和运行时错误(HTTP 状态码 500)时其响应性质的元数据:

from models.documentation.response import Error500Model
… … … … …
@router.get("/restaurant/upload/video",responses={
        200: {
            "content": {"video/mp4": {}},
            "description": "Return an MP4 encoded video.",
        },
        500:{
"model": Error500Model, 
            "description": "The item was not found"
        }
    },)
def video_presentation():
    file_path = os.getcwd() + '\\files\\sample.mp4'
    def load_file():  
        with open(file_path, mode="rb") as video_file:  
            yield from video_file  
    return StreamingResponse(load_file(), 
              media_type="video/mp4")

Error500Model 是一个 BaseModel 类,当应用程序遇到 HTTP 状态码 500 错误时,它将给出清晰的响应图景,并且只会在 OpenAPI 文档中使用。它包含诸如包含硬编码错误消息的消息等元数据。*图 9.4 显示了在为 video_presentation() 的响应添加元数据后,其 OpenAPI 文档的结果:

图 *9.4* – API 响应的文档

9.4 – API 响应的文档

现在,对于我们的最后一次讨论,让我们探讨如何在 FastAPI 中执行单元测试,这可能导致测试驱动开发环境。

测试 API 端点

FastAPI 使用 pytest 框架来运行其测试类。因此,在我们创建测试类之前,首先需要使用 pip 命令安装 pytest 框架:

pip install pytest

FastAPI 有一个名为 fastapi.testclient 的模块,其中所有组件都是基于 Request 的,包括 TestClient 类。为了访问所有 API 端点,我们需要 TestClient 对象。但是,首先我们需要创建一个如 test 的文件夹,它将包含实现我们的测试方法的测试模块。我们将测试方法放在 main.py 或路由器模块之外,以保持代码的整洁和组织。

编写单元测试用例

最好的做法是每个路由器组件写一个测试模块,除非这些路由器之间存在紧密的联系。我们将这些测试模块放在 test 目录中。为了追求自动化测试,我们需要将 APIRouter 实例或 FastAPI 实例导入到测试模块中,以设置 TestClient。当涉及到用于消费 API 的辅助方法时,TestClient 几乎就像 Python 的客户端模块 requests

测试用例的方法名必须以 test_ 前缀开头,这是 pytest 的要求。测试方法都是标准的 Python 方法,不应是异步的。以下是在 test/test_restaurants.py 中的一个测试方法,它检查端点是否返回正确的基于文本的响应:

from fastapi.testclient import TestClient
from api import restaurant
client = TestClient(restaurant.router)
def test_restaurant_index():
    response = client.get("/restaurant/index")
    assert response.status_code == 200
    assert response.text == "The Restaurants"

TestClient 支持断言语句来检查其辅助方法(如 get()post()put()delete())的响应,包括 API 的状态码和响应体。例如,test_restaurant_index() 方法使用 TestClient API 的 get() 方法来运行 /restaurant/index GET 服务并提取其响应。如果 statuc_coderesponse.text 正确,则使用断言语句。端点没有强加依赖,因此测试模块是基于路由器的。

模拟依赖项

测试具有依赖关系的 API 端点不如上一个例子直接。我们的端点通过 JWT 和 APIKeyCookie 类实现基于会话的安全,因此不能仅通过运行 pytest 来测试它们。首先,我们需要通过将这些依赖项添加到 FastAPI 实例的 dependency_overrides 中来对这些依赖项进行模拟。由于 APIRouter 不能模拟依赖项,我们需要使用 FastAPI 实例来设置 TestClient。如果路由器是 FastAPI 配置的一部分,通过 include_router(),则所有端点都可以进行单元测试:

from fastapi.testclient import TestClient
from models.data.orrs import Login
from main import app
from util.auth_session import get_current_user
client = TestClient(app)
async def get_user():
    return Login(**{"username": "sjctrags", 
      "login_id": 101,  
      "password":"sjctrags", "passphrase": None, 
      "profile": None})
app.dependency_overrides[get_current_user] =  get_user
def test_rating_top_three():
   response = client.post("/ch09/rating/top/three", 
     json={
          "rate1": 10.0, 
          "rate2": 20.0 ,
          "rate3": 30.0

    })
    assert response.status_code == 200
    assert response.json() == { "stats": {
          "sum": 60.0,
          "average": 20.0
      }
}

/rating/top/three API 来自 /api/route_extract.py 路由器,需要 dict 类型的评分来生成包含 averagesum 的 JSON 结果。TestClient 的路径操作符具有 JSON 和数据参数,我们可以通过它们向 API 传递测试数据。同样,TestClient 的响应具有可以推导出预期响应体的方法,例如,在这个例子中,json() 函数。

运行测试方法将导致一些APIKeyCookie异常,这是由于依赖于基于会话的安全性的原因。为了绕过这个问题,我们需要创建一个假的get_current_user()依赖函数以继续测试。我们将get_current_user()依赖函数添加到覆盖列表中,并将其与假的函数,如我们的get_user()函数,映射以替换其执行。这个过程就是我们所说的在 FastAPI 上下文中的模拟

除了安全性之外,我们还可以通过创建模拟数据库对象或数据库引擎来模拟数据库连接,具体取决于它是关系型数据库还是 NoSQL 数据库。在下面的测试用例中,我们在/ch09/login/list/all路径下执行单元测试,该测试需要 MongoDB 连接来访问登录配置文件列表。为了使测试工作,我们需要创建一个名为orrs_test的虚拟测试数据库的AsyncIOMotorClient对象。以下是test_list_login()测试用例,它实现了这种数据库模拟:

def db_connect():
client_od = 
         AsyncIOMotorClient(f"mongodb://localhost:27017/")
engine = AIOEngine(motor_client=client_od, 
            database="orrs_test")
    return engine
async def get_user():
    return Login(**{"username": "sjctrags", "login_id": 101,
           "password":"sjctrags", "passphrase": None, 
           "profile": None})
app.dependency_overrides[get_current_user] =  get_user
app.dependency_overrides[create_db_engine] = db_connect
def test_list_login():
    response = client.get("/ch09/login/list/all")
    assert response.status_code == 201

运行测试方法

在命令行上运行pytest命令以执行所有单元测试。pytest引擎将编译并运行test文件夹中所有的TestClient应用,从而运行所有测试方法。图 9.5显示了测试结果的快照:

![图 9.5 – 测试结果

![img/Figure_9.05_B17975.jpg]

图 9.5 – 测试结果

了解更多关于pytest框架的知识有助于理解 FastAPI 中测试用例的自动化。在应用程序的测试阶段,通过模块组织所有测试方法是非常重要的,因为我们以批量方式运行它们。

摘要

本章展示了之前章节中没有涉及的一些基本功能,但在微服务开发过程中可以帮助填补一些空白。其中之一是在将大量数据转换为 JSON 时,选择更好、更合适的 JSON 序列化和反序列化工具。此外,高级定制、会话处理、消息体加密和解密以及测试 API 端点让我们对 FastAPI 创建前沿和进步的微服务解决方案的潜力有了清晰的认识。本章还介绍了 FastAPI 支持的不同的 API 响应,包括 Jinja2 的TemplateResponse

下一章将展示 FastAPI 在解决数值和符号计算方面的优势。

第十章:解决数值、符号和图形问题

微服务架构不仅用于在银行、保险、生产、人力资源和制造业中构建细粒度、优化和可扩展的应用程序。它还用于开发科学和计算相关的研究和科学软件原型,例如 实验室信息管理系统LIMSs)、天气预报系统、地理信息系统GISs)和医疗保健系统。

FastAPI 是构建这些细粒度服务中最佳选择之一,因为它们通常涉及高度计算的任务、工作流和报告。本章将突出一些在前几章中未涉及的交易,例如使用 sympy 进行符号计算,使用 numpy 求解线性系统,使用 matplotlib 绘制数学模型,以及使用 pandas 生成数据存档。本章还将向您展示 FastAPI 如何通过模拟一些业务流程建模符号(BPMN)任务来灵活解决与工作流相关的交易。对于开发大数据应用程序,本章的一部分将展示用于大数据应用程序的 GraphQL 查询和用于图形相关项目的框架 Neo4j 图数据库。

本章的主要目标是介绍 FastAPI 框架作为提供科学研究与计算科学微服务解决方案的工具。

在本章中,我们将涵盖以下主题:

  • 设置项目

  • 实现符号计算

  • 创建数组和 DataFrame

  • 执行统计分析

  • 生成 CSV 和 XLSX 报告

  • 绘制数据模型

  • 模拟 BPMN 工作流

  • 使用 GraphQL 查询和突变

  • 利用 Neo4j 图数据库

技术要求

本章提供了 ch10 项目的基骨架。

设置项目

PCCS 项目有两个版本:ch10-relational,它使用 PostgreSQL 数据库和 Piccolo ORM 作为数据映射器,以及 ch10-mongo,它使用 Beanie ODM 将数据保存为 MongoDB 文档。

使用 Piccolo ORM

ch10-relational 使用一个快速的 Piccolo ORM,它可以支持同步和异步 CRUD 事务。这个 ORM 在 第五章**,连接到关系型数据库 中没有介绍,因为它更适合计算、数据科学相关和大数据应用程序。Piccolo ORM 与其他 ORM 不同,因为它为项目构建了一个包含初始项目结构和自定义模板的项目框架。但在创建项目之前,我们需要使用 pip 安装 piccolo 模块:

pip install piccolo

然后,安装 piccolo-admin 模块,它为其项目提供辅助类,用于 GUI 管理员页面:

pip install piccolo-admin

现在,我们可以通过运行 CLI 命令 piccolo asgi new 在新创建的根项目文件夹内创建一个项目,该命令用于搭建 Piccolo 项目目录。该过程将询问要使用的 API 框架和应用服务器,如以下截图所示:

图 10.1 – 为 Piccolo ORM 项目搭建框架

图 10.1 – 为 Piccolo ORM 项目搭建框架

你必须使用 FastAPI 作为应用程序框架,uvicorn 是推荐的 ASGI 服务器。现在,我们可以在项目文件夹内运行 piccolo app new 命令来添加 Piccolo 应用程序。以下截图显示了主项目目录,我们在其中执行 CLI 命令创建 Piccolo 应用程序:

图 10.2 – Piccolo 项目目录

图 10.2 – Piccolo 项目目录

搭建好的项目总是有一个默认的应用程序名为 home,但可以进行修改或删除。一旦删除,Piccolo 平台允许你通过在项目文件夹内运行 piccolo app new 命令来添加一个新的应用程序替换 home,如前一张截图所示。一个 Piccolo 应用包含 ORM 模型、BaseModel、服务、仓库类和 API 方法。每个应用程序都有一个自动生成的 piccolo_app.py 模块,其中我们需要配置一个 APP_CONFIG 变量来注册所有的 ORM 详细信息。以下是我们项目调查应用的配置:

APP_CONFIG = AppConfig(
    app_name="survey",
    migrations_folder_path=os.path.join(
        CURRENT_DIRECTORY, "piccolo_migrations"
    ),
    table_classes=[Answers, Education, Question, Choices, 
       Profile, Login, Location, Occupation, Respondent],
    migration_dependencies=[],
    commands=[],
)

为了让 ORM 平台识别新的 Piccolo 应用,必须在主项目的 piccolo_conf.py 模块的 APP_REGISTRY 中添加 piccolo_app.py 文件。以下是我们 ch10-piccolo 项目的 piccolo_conf.py 文件内容:

from piccolo.engine.postgres import PostgresEngine
from piccolo.conf.apps import AppRegistry
DB = PostgresEngine(
    config={
        "database": "pccs",
        "user": "postgres",
        "password": "admin2255",
        "host": "localhost",
        "port": 5433,
    }
)
APP_REGISTRY = AppRegistry(
    apps=["survey.piccolo_app", 
          "piccolo_admin.piccolo_app"]
)

piccolo_conf.py 文件也是我们建立 PostgreSQL 数据库连接的模块。除了 PostgreSQL,Piccolo ORM 还支持 SQLite 数据库。

创建数据模型

与 Django ORM 类似,Piccolo ORM 有迁移命令可以根据模型类生成数据库表。但首先,我们需要利用其 Table API 类创建模型类。它还提供了辅助类来建立列映射和外键关系。以下是我们数据库 pccs 中的一些数据模型类:

from piccolo.columns import ForeignKey, Integer, Varchar,
       Text, Date, Boolean, Float
from piccolo.table import Table
class Login(Table):
    username = Varchar(unique=True)
    password = Varchar()
class Education(Table):
    name = Varchar()
class Profile(Table):
    fname = Varchar()
    lname = Varchar()
    age = Integer()
    position = Varchar()
    login_id = ForeignKey(Login, unique=True)
    official_id = Integer()
    date_employed = Date()

在创建模型类之后,我们可以通过创建迁移文件来更新数据库。迁移是更新项目数据库的一种方式。在 Piccolo 平台上,我们可以运行 piccolo migrations new <app_name> 命令来在 piccolo_migrations 文件夹中生成文件。这些被称为迁移文件,它们包含迁移脚本。但为了节省时间,我们将为命令包含 --auto 选项,让 ORM 检查最近执行的迁移文件并自动生成包含新反映的架构更新的迁移脚本。在运行 piccolo migrations forward <app_name> 命令执行迁移脚本之前,首先检查新创建的迁移文件。这个最后的命令将根据模型类自动创建数据库中的所有表。

实现存储库层

在执行所有必要的迁移之后创建存储库层。Piccolo 的 CRUD 操作类似于 Peewee ORM 中的操作。它快速、简洁且易于实现。以下代码展示了 insert_respondent() 事务的实现,该事务添加一个新的受访者资料:

from survey.tables import Respondent
from typing import Dict, List, Any
class RespondentRepository:
    async def insert_respondent(self, 
             details:Dict[str, Any]) -> bool: 
        try:
            respondent = Respondent(**details)
            await respondent.save()
        except Exception as e: 
            return False 
        return True

与 Peewee 一样,Piccolo 的模型类可以持久化记录,如 insert_respondent() 所示,它实现了一个异步的 INSERT 事务。另一方面,get_all_respondent() 获取所有受访者资料,其方法与 Peewee 相同,如下所示:

    async def get_all_respondent(self):
        return await Respondent.select()
                  .order_by(Respondent.id)

项目中的 /survey/repository/respondent.py 模块创建了类似 Peewee 的 DELETEUPDATE 受访者事务。

Beanie ODM

PCCS 项目的第二个版本 ch10-mongo 使用 MongoDB 数据存储库,并使用 Beanie ODM 来实现其异步 CRUD 事务。我们已经在 第六章**,使用非关系型数据库 中介绍了 Beanie。现在,让我们学习如何将 FastAPI 应用于符号计算。我们将使用 ch10-piccolo 项目来完成这项工作。

实现符号计算

使用 pip 命令安装 sympy 模块:

pip install sympy

让我们现在开始创建我们的第一个符号表达式。

创建符号表达式

实现执行符号计算的 FastAPI 端点的一种方法是为接受一个数学模型或方程作为字符串的服务创建一个服务,并将该字符串转换为 sympy 符号表达式。以下 substitute_eqn() 处理 str 格式的方程,并将其转换为包含 xy 变量的有效线性或非线性二元方程。它还接受 xy 的值来推导表达式的解:

from sympy import symbols, sympify
@router.post("/sym/equation")
async def substitute_bivar_eqn(eqn: str, xval:int, 
               yval:int):
    try:
        x, y = symbols('x, y')
        expr = sympify(eqn)
        return str(expr.subs({x: xval, y: yval}))
    except:
        return JSONResponse(content={"message": 
            "invalid equations"}, status_code=500)

在将字符串方程转换为 sympy 表达式之前,我们需要使用 symbols() 工具将 xy 变量定义为 Symbols 对象。此方法接受一个以逗号分隔的变量名字符串,并返回一个与变量等价的符号元组。在创建所有需要的 Symbols() 对象之后,我们可以使用以下任何 sympy 方法将我们的方程转换为 sympy 表达式:

  • sympify(): 此方法使用 eval() 将字符串方程转换为有效的 sympy 表达式,并将所有 Python 类型转换为它们的 sympy 等价物

  • parse_expr(): 一个完整的表达式解析器,它转换和修改表达式的标记,并将它们转换为它们的 sympy 等价物

由于 substitute_bivar_eqn() 服务使用 sympify() 方法,因此在 sympify() 之前需要对字符串表达式进行清理,以避免任何妥协。

另一方面,sympy 表达式对象有一个 subs() 方法来替换值以推导出解。其结果对象必须转换为 str 格式,以便 Response 渲染数据。否则,Response 将引发 ValueError,将结果视为非可迭代对象。

解决线性表达式

sympy 模块允许你实现解决多元线性方程组的服务的功能。以下 API 服务突出显示了一个实现,它接受两个以字符串格式表示的双变量线性模型及其相应的解:

from sympy import Eq, symbols, Poly, solve, sympify
@router.get("/sym/linear")
async def solve_linear_bivar_eqns(eqn1:str, 
            sol1: int, eqn2:str, sol2: int):
    x, y = symbols('x, y')

    expr1 = parse_expr(eqn1, locals())
    expr2 = parse_expr(eqn2, locals())

    if Poly(expr1, x).is_linear and 
                 Poly(expr1, x).is_linear:
        eq1 = Eq(expr1, sol1)
        eq2 = Eq(expr2, sol2)
        sol = solve([eq1, eq2], [x, y])
        return str(sol)
    else:
        return None

solve_linear_bivar_eqns() 服务接受两个双变量线性方程及其相应的输出(或截距)并旨在建立一个线性方程组。首先,它将 xy 变量注册为 sympy 对象,然后使用 parser_expr() 方法将字符串表达式转换为它们的 sympy 等价物。之后,该服务需要使用 Eq() 求解器建立这些方程的线性等式,该求解器将每个 sympy 表达式映射到其解。然后,API 服务将所有这些线性方程传递给 solve() 方法以推导出 xy 的值。solve() 的结果也需要像替换一样以字符串形式呈现。

除了 solve() 方法之外,API 还使用 Poly() 工具从表达式创建多项式对象,以便能够访问方程的基本属性,例如 is_linear()

解决非线性表达式

之前的 solve_linear_bivar_eqns() 可以重用来解决非线性系统。调整是将验证从过滤线性方程更改为任何非线性方程。以下脚本突出了此代码更改:

@router.get("/sym/nonlinear")
async def solve_nonlinear_bivar_eqns(eqn1:str, sol1: int, 
           eqn2:str, sol2: int):
    … … … … … …
    … … … … … …    
    if not Poly(expr1, x, y).is_linear or 
              not Poly(expr1, x, y).is_linear:
    … … … … … …
    … … … … … …
        return str(sol)
    else:
        return None

解决线性和非线性不等式

sympy模块支持解决线性和非线性不等式,但仅限于单变量方程。以下是一个 API 服务,它接受一个带有其输出或截距的单变量字符串表达式,并使用solve()方法提取解:

@router.get("/sym/inequality")
async def solve_univar_inequality(eqn:str, sol:int):
    x= symbols('x')
    expr1 = Ge(parse_expr(eqn, locals()), sol)
    sol = solve([expr1], [x])
    return str(sol)

sympy模块有Gt()StrictGreaterThanLt()StrictLessThanGe()GreaterThanLe()LessThan求解器,我们可以使用它们来创建不等式。但首先,我们需要使用parser_expr()方法将str表达式转换为Symbols()对象,然后再将它们传递给这些求解器。前面的服务使用GreaterThan求解器,它创建一个方程,其中表达式的左侧通常大于右侧。

大多数用于数学建模和数据科学的应用程序设计和开发都使用sympy来创建复杂的数学模型符号,直接从sympy方程中绘制数据,或根据数据集或实时数据生成结果。现在,让我们继续到下一组 API 服务,这些服务涉及使用numpyscipypandas进行数据分析和处理。

创建数组和 DataFrame

当数值算法需要一些数组来存储数据时,一个称为NumPy(代表Numerical Python)的模块是一个很好的资源,用于创建、转换和操作数组的实用函数、对象和类。

该模块最著名的是其 n 维数组或 ndarrays,它们比典型的 Python 列表消耗更少的内存存储。在执行数据操作时,ndarray产生的开销比执行列表操作的总开销要小。此外,ndarray是严格异构的,与 Python 的列表集合不同。

但在我们开始 NumPy-FastAPI 服务实现之前,我们需要使用pip命令安装numpy模块:

pip install numpy

我们的第一个 API 服务将处理一些调查数据,并以ndarray形式返回。以下get_respondent_answers() API 通过 Piccolo 从 PostgreSQL 检索调查数据列表,并将数据列表转换为ndarray

from survey.repository.answers import AnswerRepository
from survey.repository.location import LocationRepository
import ujson
import numpy as np
@router.get("/answer/respondent")
async def get_respondent_answers(qid:int):
    repo_loc = LocationRepository()
    repo_answers = AnswerRepository()
    locations = await repo_loc.get_all_location()
    data = []
    for loc in locations:
        loc_q = await repo_answers
            .get_answers_per_q(loc["id"], qid)
        if not len(loc_q) == 0:
            loc_data = [ weights[qid-1]
              [str(item["answer_choice"])] 
                for item in loc_q]
            data.append(loc_data)
    arr = np.array(data)
    return ujson.loads(ujson.dumps(arr.tolist())) 

根据检索到的数据大小,如果我们应用ujsonorjson序列化和反序列化器将ndarray转换为 JSON 数据,将会更快。尽管numpyuintsingledoubleshortbytelong等数据类型,但 JSON 序列化器仍然可以成功地将它们转换为标准的 Python 等效类型。我们的 API 示例样本更喜欢使用ujson工具将数组转换为可序列化为 JSON 的响应。

除了 NumPy 之外,pandas是另一个在数据分析、操作、转换和检索中广泛使用的流行模块。但为了使用 pandas,我们需要安装 NumPy,然后是pandasmatplotlibopenpyxl模块:

pip install pandas matplotlib openpxyl

让我们讨论一下 numpy 模块中的 ndarray。

应用 NumPy 的线性系统操作

ndarray中进行数据处理更容易、更快,与列表集合相比,后者需要列表推导和循环。numpy创建的向量和矩阵具有操作其项的功能,例如标量乘法、矩阵乘法、转置、向量化以及重塑。以下 API 服务展示了如何使用numpy模块推导出标量梯度与调查数据数组之间的乘积:

@router.get("/answer/increase/{gradient}")
async def answers_weight_multiply(gradient:int, qid:int):
    repo_loc = LocationRepository()
    repo_answers = AnswerRepository()
    locations = await repo_loc.get_all_location()
    data = []
    for loc in locations:
        loc_q = await repo_answers
            .get_answers_per_q(loc["id"], qid)
        if not len(loc_q) == 0:
            loc_data = [ weights[qid-1]
             [str(item["answer_choice"])] 
                 for item in loc_q]
            data.append(loc_data)
    arr = np.array(list(itertools.chain(*data)))
    arr = arr * gradient
    return ujson.loads(ujson.dumps(arr.tolist()))

如前述脚本所示,所有由任何numpy操作产生的ndarray实例都可以使用各种 JSON 序列化器序列化为可 JSON 化的组件。numpy还可以执行其他线性代数操作,而不会牺牲微服务应用程序的性能。现在,让我们看看 pandas 的 DataFrame。

应用 pandas 模块

在此模块中,数据集被创建为一个DataFrame对象,类似于 Julia 和 R。它包含数据行和列。FastAPI 可以使用任何 JSON 序列化器渲染这些 DataFrame。以下 API 服务从所有调查地点检索所有调查结果,并从这些数据集创建一个 DataFrame:

import ujson
import numpy as np
import pandas as pd
@router.get("/answer/all")
async def get_all_answers():
    repo_loc = LocationRepository()
    repo_answers = AnswerRepository()
    locations = await repo_loc.get_all_location()
    temp = []
    data = []
    for loc in locations:
        for qid in range(1, 13):
            loc_q1 = await repo_answers
               .get_answers_per_q(loc["id"], qid)
            if not len(loc_q1) == 0:
                loc_data = [ weights[qid-1]
                   [str(item["answer_choice"])] 
                      for item in loc_q1]
                temp.append(loc_data)
        temp = list(itertools.chain(*temp))
        if not len(temp) == 0:
            data.append(temp)
        temp = list()
    arr = np.array(data)
    return ujson.loads(pd.DataFrame(arr)
           .to_json(orient='split'))

DataFrame对象有一个to_json()实用方法,它返回一个 JSON 对象,可以选择根据所需类型格式化生成的 JSON。另一方面,pandas还可以生成时间序列,这是一个表示 DataFrame 列的一维数组。DataFrame 和时间序列都内置了用于添加、删除、更新以及将数据集保存到 CSV 和 XLSX 文件的有用方法。但在我们讨论 pandas 的数据转换过程之前,让我们看看另一个与numpy在许多统计计算(如微分、积分和线性优化)中协同工作的模块:scipy模块。

执行统计分析

scipy模块使用numpy作为其基础模块,这就是为什么安装scipy之前需要先安装numpy。我们可以使用pip命令来安装模块:

pip install scipy

我们的应用程序使用该模块来推导调查数据的声明性统计信息。以下get_respondent_answers_stats()API 服务使用scipydescribe()方法计算数据集的均值、方差、偏度和峰度:

from scipy import stats
def ConvertPythonInt(o):
    if isinstance(o, np.int32): return int(o)  
    raise TypeError
@router.get("/answer/stats")
async def get_respondent_answers_stats(qid:int):
    repo_loc = LocationRepository()
    repo_answers = AnswerRepository()
    locations = await repo_loc.get_all_location()
    data = []
    for loc in locations:
        loc_q = await repo_answers
           .get_answers_per_q(loc["id"], qid)
             if not len(loc_q) == 0:
                 loc_data = [ weights[qid-1]
                   [str(item["answer_choice"])] 
                       for item in loc_q]
            data.append(loc_data)
    result = stats.describe(list(itertools.chain(*data)))
    return json.dumps(result._asdict(), 
                  default=ConvertPythonInt)

describe()方法返回一个DescribeResult对象,其中包含所有计算结果。为了将所有统计信息作为Response的一部分渲染,我们可以调用DescribeResult对象的as_dict()方法,并使用 JSON 序列化器进行序列化。

我们的 API 示例还使用了额外的实用工具,例如来自itertoolschain()方法来展平数据列表,以及自定义转换器ConvertPythonInt,将 NumPy 的int32类型转换为 Python int类型。现在,让我们探索如何使用pandas模块将数据保存到 CSV 和 XLSX 文件中。

生成 CSV 和 XLSX 报告

DataFrame对象具有内置的to_csv()to_excel()方法,分别将数据保存到 CSV 或 XLSX 文件中。但主要目标是创建一个 API 服务,该服务将返回这些文件作为响应。以下实现展示了 FastAPI 服务如何返回包含受访者列表的 CSV 文件:

from fastapi.responses import StreamingResponse
import pandas as pd
from io import StringIO
from survey.repository.respondent import 
        RespondentRepository
@router.get("/respondents/csv", response_description='csv')
async def create_respondent_report_csv():
    repo = RespondentRepository()
    result = await repo.get_all_respondent()

    ids = [ item["id"] for item in result ]
    fnames = [ f'{item["fname"]}' for item in result ]
    lnames = [ f'{item["lname"]}' for item in result ]
    ages = [ item["age"] for item in result ]
    genders = [ f'{item["gender"]}' for item in result ]
    maritals = [ f'{item["marital"]}' for item in result ]

    dict = {'Id': ids, 'First Name': fnames, 
            'Last Name': lnames, 'Age': ages, 
            'Gender': genders, 'Married?': maritals} 

    df = pd.DataFrame(dict)
    outFileAsStr = StringIO()
    df.to_csv(outFileAsStr, index = False)
    return StreamingResponse(
        iter([outFileAsStr.getvalue()]),
        media_type='text/csv',
        headers={
            'Content-Disposition': 
              'attachment;filename=list_respondents.csv',
            'Access-Control-Expose-Headers': 
               'Content-Disposition'
        }
    )

我们需要创建一个包含来自存储库的数据列的dict(),以创建DataFrame对象。从给定的脚本中,我们将每个数据列存储在一个单独的list()中,将所有列表添加到dict()中,键为列标题名称,并将dict()作为参数传递给DataFrame构造函数。

在创建DataFrame对象后,调用to_csv()方法将其列数据集转换为文本流io.StringIO,该流支持 Unicode 字符。最后,我们必须通过 FastAPI 的StreamResponse渲染StringIO对象,并将Content-Disposition头设置为重命名 CSV 对象的默认文件名。

我们的在线调查应用程序没有使用 pandas 的ExcelWriter,而是选择了通过xlsxwriter模块保存DataFrame的另一种方式。此模块有一个Workbook类,它创建一个包含工作表的电子表格,我们可以按行绘制所有列数据。以下 API 服务使用此模块来渲染 XLSX 内容:

import xlsxwriter
from io import BytesIO
@router.get("/respondents/xlsx", 
          response_description='xlsx')
async def create_respondent_report_xlsx():
    repo = RespondentRepository()
    result = await repo.get_all_respondent()
    output = BytesIO()
    workbook = xlsxwriter.Workbook(output)
    worksheet = workbook.add_worksheet()
    worksheet.write(0, 0, 'ID')
    worksheet.write(0, 1, 'First Name')
    worksheet.write(0, 2, 'Last Name')
    worksheet.write(0, 3, 'Age')
    worksheet.write(0, 4, 'Gender')
    worksheet.write(0, 5, 'Married?')
    row = 1
    for respondent in result:
        worksheet.write(row, 0, respondent["id"])
        … … … … … …
        worksheet.write(row, 5, respondent["marital"])
        row += 1
    workbook.close()
    output.seek(0)
    headers = {
        'Content-Disposition': 'attachment; 
             filename="list_respondents.xlsx"'
    }
    return StreamingResponse(output, headers=headers)

给定的create_respondent_report_xlsx()服务从数据库中检索所有受访者记录,并将每个个人资料记录按行绘制在新创建的Workbook的工作表中。而不是写入文件,Workbook将内容存储在字节流io.ByteIO中,该流将由StreamResponse渲染。

pandas模块还可以帮助 FastAPI 服务读取 CSV 和 XLSX 文件进行渲染或数据分析。它有一个read_csv(),可以从 CSV 文件中读取数据并将其转换为 JSON 内容。io.StringIO流对象将包含完整内容,包括其 Unicode 字符。以下服务检索有效 CSV 文件的内容并返回 JSON 数据:

@router.post("/upload/csv")
async def upload_csv(file: UploadFile = File(...)):
    df = pd.read_csv(StringIO(str(file.file.read(), 
            'utf-8')), encoding='utf-16')
    return orjson.loads(df.to_json(orient='split'))

在 FastAPI 中处理multipart文件上传有两种方式:

  • 使用bytes包含文件

  • 使用UploadFile包装文件对象

第九章**,利用其他高级功能,介绍了用于捕获上传文件的UploadFile类,因为它支持更多的 Pydantic 功能,并且具有与协程一起工作的内置操作。它可以在上传过程达到内存限制时不会引发异常的情况下处理大文件上传,与使用bytes类型存储文件内容不同。因此,给定的read-csv()服务使用UploadFile来捕获任何 CSV 文件,并使用orjson作为其 JSON 序列化器进行数据分析。

处理文件上传事务的另一种方式是通过 Jinja2 表单模板。我们可以使用 TemplateResponse 来实现文件上传,并使用 Jinja2 模板语言渲染文件内容。以下服务使用 read_csv() 读取 CSV 文件,并将其序列化为 HTML 表格格式的文本:

@router.get("/upload/survey/form", 
          response_class = HTMLResponse)
def upload_survey_form(request:Request):
    return templates.TemplateResponse("upload_survey.html",
             {"request": request})
@router.post("/upload/survey/form")
async def submit_survey_form(request: Request, 
              file: UploadFile = File(...)):
    df = pd.read_csv(StringIO(str(file.file.read(), 
               'utf-8')), encoding='utf-8')
    return templates.TemplateResponse('render_survey.html', 
         {'request': request, 'data': df.to_html()})

除了 to_json()to_html()TextFileReader 对象还有其他转换器可以帮助 FastAPI 渲染各种内容类型,包括 to_latex()to_excel()to_hdf()to_dict()to_pickle()to_xarray()。此外,pandas 模块有一个 read_excel() 可以读取 XLSX 内容并将其转换为任何版本类型,就像它的 read_csv() 对应物一样。

现在,让我们探索 FastAPI 服务如何绘制图表和图形,并通过 Response 输出它们的图形结果。

绘制数据模型

numpypandas 模块的帮助下,FastAPI 服务可以使用 matplotlib 工具生成和渲染不同类型的图表和图形。就像之前的讨论一样,我们将使用 io.ByteIO 流和 StreamResponse 为 API 端点生成图形结果。以下 API 服务从存储库检索调查数据,计算每个数据层的平均值,并以 PNG 格式返回数据的折线图:

from io import BytesIO
import matplotlib.pyplot as plt
from survey.repository.answers import AnswerRepository
from survey.repository.location import LocationRepository
@router.get("/answers/line")
async def plot_answers_mean():
    x = [1, 2, 3, 4, 5, 6, 7]
    repo_loc = LocationRepository()
    repo_answers = AnswerRepository()
    locations = await repo_loc.get_all_location()
    temp = []
    data = []
    for loc in locations:
        for qid in range(1, 13):
            loc_q1 = await repo_answers
               .get_answers_per_q(loc["id"], qid)
            if not len(loc_q1) == 0:
                loc_data = [ weights[qid-1]
                  [str(item["answer_choice"])] 
                     for item in loc_q1]
                temp.append(loc_data)
        temp = list(itertools.chain(*temp))
        if not len(temp) == 0:
            data.append(temp)
        temp = list()
    y = list(map(np.mean, data))
    filtered_image = BytesIO()
    plt.figure()

    plt.plot(x, y)

    plt.xlabel('Question Mean Score')
    plt.ylabel('State/Province')
    plt.title('Linear Plot of Poverty Status')

    plt.savefig(filtered_image, format='png')
    filtered_image.seek(0)

    return StreamingResponse(filtered_image, 
                media_type="image/png")

plot_answers_mean() 服务利用 matplotlib 模块的 plot() 方法,在折线图中绘制每个位置的 APP 平均调查结果。该服务不是将文件保存到文件系统,而是使用模块的 savefig() 方法将图像存储在 io.ByteIO 流中。流使用 StreamResponse 渲染,就像之前的示例一样。以下图显示了通过 StreamResponse 渲染的流图像,格式为 PNG:

![Figure 10.3 – StreamResponse 生成的折线图]

![Figure 10.03 – B17975.jpg]

Figure 10.3 – StreamResponse 生成的折线图

我们 APP 的其他 API 服务,例如 plot_sparse_data(),会创建一些模拟或派生数据的条形图图像,格式为 JPEG:

@router.get("/sparse/bar")
async def plot_sparse_data():
   df = pd.DataFrame(np.random.randint(10, size=(10, 4)),
      columns=["Area 1", "Area 2", "Area 3", "Area 4"])
   filtered_image = BytesIO()
   plt.figure()
   df.sum().plot(kind='barh', color=['red', 'green', 
          'blue', 'indigo', 'violet'])
   plt.title("Respondents in Survey Areas")
   plt.xlabel("Sample Size")
   plt.ylabel("State")
   plt.savefig(filtered_image, format='png')

   filtered_image.seek(0)
   return StreamingResponse(filtered_image, 
           media_type="image/jpeg")

方法与我们的折线图版本相同。使用相同的策略,以下服务创建了一个饼图,显示了被调查的男性和女性受访者的百分比:

@router.get("/respondents/gender")
async def plot_pie_gender():
    repo = RespondentRepository()
    count_male = await repo.list_gender('M')
    count_female = await repo.list_gender('F')
    gender = [len(count_male), len(count_female)]
    filtered_image = BytesIO()
    my_labels = 'Male','Female'
    plt.pie(gender,labels=my_labels,autopct='%1.1f%%')
    plt.title('Gender of Respondents')
    plt.axis('equal')
    plt.savefig(filtered_image, format='png')
    filtered_image.seek(0)

    return StreamingResponse(filtered_image, 
               media_type="image/png")

plot_sparse_data()plot_pie_gender() 服务生成的响应如下:

![Figure 10.4 – StreamResponse 生成的条形图和饼图]

![Figure 10.04 – B17975.jpg]

Figure 10.4 – StreamResponse 生成的条形图和饼图

本节将介绍一种创建 API 端点的方法,这些端点使用 matplotlib 生成图形结果。但你可以使用 numpypandasmatplotlib 和 FastAPI 框架在更短的时间内创建其他描述性、复杂和令人惊叹的图表和图形。这些扩展甚至可以在适当的硬件资源下解决复杂的数学和数据科学相关的问题。

现在,让我们将注意力转向另一个项目 ch10-mongo,以解决有关工作流程、GraphQL、Neo4j 图数据库事务以及 FastAPI 如何利用它们的问题。

模拟 BPMN 工作流程

虽然 FastAPI 框架没有内置的实用工具来支持其工作流程,但它足够灵活和流畅,可以通过扩展模块、中间件和其他自定义来集成到其他工作流程工具,如 Camunda 和 Apache Airflow。但本节将仅关注使用 Celery 模拟 BPMN 工作流程的原始解决方案,这可以扩展为一个更灵活、实时和面向企业的方法,如 Airflow 集成。

设计 BPMN 工作流程

ch10-mongo 项目使用 Celery 实现了以下 BPMN 工作流程设计:

  • 一系列服务任务,用于推导调查数据结果的百分比,如下所示图所示:

![图 10.5 – 百分比计算工作流程设计图片

图 10.5 – 百分比计算工作流程设计

  • 一组批处理操作,将数据保存到 CSV 和 XLSX 文件中,如下所示图所示:

![图 10.6 – 数据归档工作流程设计图片

图 10.6 – 数据归档工作流程设计

  • 一组链式任务,独立地对每个位置的数据进行操作,如下所示图所示:

![图 10.7 – 分层调查数据分析工作流程设计图片

图 10.7 – 分层调查数据分析工作流程设计

实现给定设计的方法有很多,但最直接的方法是利用我们在 第七章**,Securing the REST APIs 中使用的 Celery 设置。

实现工作流程

Celery 的 chain() 方法实现了一个链式任务执行的工作流程,如图 10.5 所示,其中每个父任务将结果返回给下一个任务的第一个参数。链式工作流程在运行时每个任务都成功执行且未遇到任何异常的情况下工作。以下是在 /api/survey_workflow.py 中实现的 API 服务,它实现了链式工作流程:

@router.post("/survey/compute/avg")
async def chained_workflow(surveydata: SurveyDataResult):
    survey_dict = surveydata.dict(exclude_unset=True)
    result = chain(compute_sum_results
        .s(survey_dict['results']).set(queue='default'), 
            compute_avg_results.s(len(survey_dict))
             .set(queue='default'), derive_percentile.s()
             .set(queue='default')).apply_async()
    return {'message' : result.get(timeout = 10) }

compute_sum_results()compute_avg_results()derive_percentile() 是绑定任务。绑定任务是 Celery 任务,实现时将第一个方法参数分配给任务实例本身,因此在参数列表中出现了 self 关键字。它们的任务实现总是带有 @celery.task(bind=True) 装饰器。Celery 任务管理器在将工作流程原语签名应用于创建工作流程时更喜欢绑定任务。以下代码显示了在链式工作流程设计中使用的绑定任务:

@celery.task(bind=True)
def compute_sum_results(self, results:Dict[str, int]):
    scores = []
    for key, val in results.items():
        scores.append(val)
    return sum(scores)

compute_sum_results() 计算每个州的调查结果总和,而 compute_avg_results() 消耗 compute_sum_results() 计算出的总和以得出平均值:

@celery.task(bind=True)
def compute_avg_results(self, value, len):
    return (value/len)

另一方面,derive_percentile() 消耗 compute_avg_results() 生成的平均值,以返回一个百分比值:

@celery.task(bind=True)
def derive_percentile(self, avg):
    percentage = f"{avg:.0%}"
    return percentage

给定的 derive_percentile() 消耗 compute_avg_results() 生成的平均值,以返回一个百分比值。

为了实现网关方法,Celery 有一个 group() 原始签名,用于实现并行任务执行,如图 图 10.6 所示。以下 API 展示了具有并行执行的流程结构实现:

@router.post("/survey/save")
async def grouped_workflow(surveydata: SurveyDataResult):
    survey_dict = surveydata.dict(exclude_unset=True)
    result = group([save_result_xlsx
       .s(survey_dict['results']).set(queue='default'), 
         save_result_csv.s(len(survey_dict))
          .set(queue='default')]).apply_async()
    return {'message' : result.get(timeout = 10) } 

图 10.7 中所示的工作流程展示了分组和链式工作流程的混合。在许多现实世界的微服务应用程序中,使用不同 Celery 签名(包括 chord()map()starmap())的混合来解决与工作流程相关的问题是很常见的。以下脚本实现了一个具有混合签名的流程:

@router.post("/process/surveys")
async def process_surveys(surveys: List[SurveyDataResult]):
    surveys_dict = [s.dict(exclude_unset=True) 
         for s in surveys]
    result = group([chain(compute_sum_results
       .s(survey['results']).set(queue='default'), 
         compute_avg_results.s(len(survey['results']))
         .set(queue='default'), derive_percentile.s()
         .set(queue='default')) for survey in 
                surveys_dict]).apply_async()
    return {'message': result.get(timeout = 10) }

Celery 签名在构建工作流程中起着至关重要的作用。在构造中出现的 signature() 方法或 s() 管理任务的执行,包括接受初始任务参数值(s)并利用 Celery 工作者使用的队列来加载任务。如第七章**,保护 REST API*所述,apply_async() 触发整个工作流程执行并检索结果。

除了工作流程之外,FastAPI 框架还可以使用 GraphQL 平台来构建 CRUD 事务,尤其是在处理微服务架构中的大量数据时。

使用 GraphQL 查询和变更

GraphQL 是一个同时实现 REST 和 CRUD 事务的 API 标准。它是一个高性能平台,用于构建只需几步即可设置的 REST API 端点。其目标是创建用于数据操作和查询事务的端点。

设置 GraphQL 平台

Python 扩展,如 Strawberry、Ariadne、Tartiflette 和 Graphene,支持 GraphQL-FastAPI 集成。本章介绍了使用新的 Ariadne 3.x 版本为以 MongoDB 作为存储库的 ch10-mongo 项目构建 CRUD 事务。

首先,我们需要使用 pip 命令安装最新的 graphene 扩展:

pip install graphene

在 GraphQL 库中,Graphene 是设置最简单的,具有更少的装饰器和需要覆盖的方法。它很容易与 FastAPI 框架集成,无需额外的中间件和过多的自动连接。

创建记录插入、更新和删除

数据操作操作始终是 GraphQL 变更机制的一部分。这是一个 GraphQL 功能,它修改应用程序的服务器端状态,并返回任意数据作为状态成功变更的标志。以下是一个 GraphQL 变更的实现,用于插入、删除和更新记录:

from models.data.pccs_graphql import LoginData
from graphene import String, Int, Mutation, Field
from repository.login import LoginRepository
class CreateLoginData(Mutation):
    class Arguments:
      id = Int(required=True)
      username = String(required=True)
      password = String(required=True)
    ok = Boolean()
    loginData = Field(lambda: LoginData)
    async def mutate(root, info, id, username, password):
        login_dict = {"id": id, "username": username, 
                   "password": password}
        login_json = dumps(login_dict, default=json_serial)
        repo = LoginRepository()
        result = await repo.add_login(loads(login_json))
        if not result == None:
          ok = True
        else: 
          ok = False
        return CreateLoginData(loginData=result, ok=ok)

CreateLoginData是一个突变,它将新的登录记录添加到数据存储中。内部类Arguments指示将组成新登录记录以插入的记录字段。这些参数必须在重写的mutate()方法中出现,以捕获这些字段的值。此方法还将调用 ORM,以持久化新创建的记录。

在成功插入事务后,mutate()必须返回突变类内部定义的类变量,如okloginData对象。这些返回值必须是突变实例的一部分。

更新登录属性与CreateLoginData的实现类似,除了需要公开参数。以下是一个更新使用其username检索到的登录记录的password字段的突变类:

class ChangeLoginPassword(Mutation):
    class Arguments:
      username = String(required=True)
      password = String(required=True)
    ok = Boolean()
    loginData = Field(lambda: LoginData)
    async def mutate(root, info, username, password):       
        repo = LoginRepository()
        result = await repo.change_password(username, 
                  password)

        if not result == None:
          ok = True
        else: 
          ok = False
        return CreateLoginData(loginData=result, ok=ok)

同样,删除突变类通过id检索记录并将其从数据存储中删除:

class DeleteLoginData(Mutation):
    class Arguments:
      id = Int(required=True)

    ok = Boolean()
    loginData = Field(lambda: LoginData)
    async def mutate(root, info, id):       
        repo = LoginRepository()
        result = await repo.delete_login(id)
        if not result == None:
          ok = True
        else: 
          ok = False
        return DeleteLoginData(loginData=result, ok=ok)

现在,我们可以将所有突变类存储在一个ObjectType类中,该类将这些事务暴露给客户端。我们将字段名分配给给定突变类的每个Field实例。这些字段名将作为事务的查询名称。以下代码显示了定义我们的CreateLoginDataChangeLoginPasswordDeleteLoginData突变的ObjectType类:

class LoginMutations(ObjectType):
    create_login = CreateLoginData.Field()
    edit_login = ChangeLoginPassword.Field()
    delete_login = DeleteLoginData.Field()

实现查询事务

GraphQL 查询事务是ObjectType基类的实现。在这里,LoginQuery从数据存储中检索所有登录记录:

class LoginQuery(ObjectType):
    login_list = None
    get_login = Field(List(LoginData))

    async def resolve_get_login(self, info):
      repo = LoginRepository()
      login_list = await repo.get_all_login()
      return login_list

该类必须有一个查询字段名,例如get_login,它在查询执行期间将作为其查询名称。字段名必须是resolve_*()方法名的一部分,以便在ObjectType类下注册。必须声明一个类变量,例如login_list,以便它包含所有检索到的记录。

运行 CRUD 事务

在运行 GraphQL 事务之前,我们需要一个 GraphQL 模式来集成 GraphQL 组件并注册 FastAPI 框架的突变和查询类。以下脚本显示了使用LoginQueryLoginMutations实例化 GraphQL 的Schema类:

from graphene import Schema 
schema = Schema(query=LoginQuery, mutation=LoginMutations,
    auto_camelcase=False)

我们将Schema实例的auto_camelcase属性设置为False,以保持使用带下划线的原始字段名,并避免使用驼峰命名法。

之后,我们使用模式实例创建GraphQLApp()实例。GraphQLApp 相当于一个需要挂载到 FastAPI 框架中的应用程序。我们可以使用 FastAPI 的mount()实用工具将GraphQLApp()实例与其 URL 模式以及选择的 GraphQL 浏览器工具集成,以运行 API 事务。以下代码显示了如何将 GraphQL 应用程序与 Playground 作为浏览器工具集成以运行 API:

from starlette_graphene3 import GraphQLApp, 
          make_playground_handler
app = FastAPI()
app.mount("/ch10/graphql/login", 
       GraphQLApp(survey_graphene_login.schema, 
          on_get=make_playground_handler()) )
app.mount("/ch10/graphql/profile", 
       GraphQLApp(survey_graphene_profile.schema, 
          on_get=make_playground_handler()) )

我们可以使用左侧面板通过包含 CreateLoginData 事务字段名 create_login 的 JSON 脚本插入新的记录,并传递必要的记录数据,如下面的屏幕截图所示:

图 10.8 – 运行 create_login 事务

图 10.8 – 运行 create_login 事务

要执行查询事务,我们必须创建一个具有 LoginQuery 字段名的 JSON 脚本,该字段为 get_login,以及需要检索的记录字段。以下屏幕截图显示了如何运行 LoginQuery 事务:

图 10.9 – 运行 get_login 查询事务

图 10.9 – 运行 get_login 查询事务

GraphQL 可以通过简单的设置和配置帮助整合来自不同微服务的所有 CRUD 事务。它可以作为 API 网关,将来自多个微服务的所有 GraphQLApps 挂载以创建单个门面应用程序。现在,让我们将 FastAPI 集成到图数据库中。

利用 Neo4j 图数据库

对于需要强调数据记录之间关系的数据存储的应用程序,图数据库是合适的存储方法之一。使用图数据库的平台之一是 Neo4j。FastAPI 可以轻松地与 Neo4j 集成,但我们需要使用 pip 命令安装 Neo4j 模块:

pip install neo4j

Neo4j 是一个灵活且强大的 NoSQL 数据库,可以根据相关属性管理和连接不同的企业相关数据。它具有半结构化数据库架构,具有简单的 ACID 属性和非 JOIN 策略,这使得其操作快速且易于执行。

注意

ACID,即原子性、一致性、隔离性和持久性,描述数据库事务为一组作为单个单元执行的正确性和一致性的操作。

设置 Neo4j 数据库

neo4j 模块包括 neo4j-driver,这是建立与图数据库连接所需的。它需要一个包含 bolt 协议、服务器地址和端口的 URI。默认数据库端口为 7687。以下脚本显示了如何创建 Neo4j 数据库连接:

from neo4j import GraphDatabase
uri = "bolt://127.0.0.1:7687"
driver = GraphDatabase.driver(uri, auth=("neo4j", 
      "admin2255"))

创建 CRUD 事务

Neo4j 有一种称为 Cypher 的声明式图查询语言,允许执行图数据库的 CRUD 事务。这些 Cypher 脚本需要编码为 str SQL 命令,以便由其查询运行器执行。以下 API 服务将新的数据库记录添加到图数据库:

@router.post("/neo4j/location/add")
def create_survey_loc(node_name: str, 
        node_req_atts: LocationReq):
    node_attributes_dict = 
          node_req_atts.dict(exclude_unset=True)
    node_attributes = '{' + ', '.join(f'{key}:\'{value}\''
        for (key, value) in node_attributes_dict.items()) 
              + '}'
    query = f"CREATE ({node_name}:Location  
         {node_attributes})"
    try:
        with driver.session() as session:
            session.run(query=query)
        return JSONResponse(content={"message":
         "add node location successful"}, status_code=201)
    except Exception as e:
        print(e)
        return JSONResponse(content={"message": "add node 
            location unsuccessful"}, status_code=500)

create_survey_loc() 将新的调查位置详细信息添加到 Neo4j 数据库。在图数据库中,记录被视为具有名称和属性与关系数据库中记录字段等效的节点。我们使用连接对象创建一个会话,该会话具有 run() 方法以执行 Cypher 脚本。

添加新节点的命令是 CREATE,而更新、删除和检索节点的语法可以通过 MATCH 命令添加。以下 update_node_loc() 服务根据节点的名称搜索特定节点,并执行 SET 命令来更新指定的字段:

@router.patch("/neo4j/update/location/{id}")
async def update_node_loc(id:int, 
           node_req_atts: LocationReq):
    node_attributes_dict = 
         node_req_atts.dict(exclude_unset=True)
    node_attributes = '{' + ', '.join(f'{key}:\'{value}\'' 
       for (key, value) in 
            node_attributes_dict.items()) + '}'
    query = f"""
        MATCH (location:Location)
        WHERE ID(location) = {id}
        SET location += {node_attributes}"""
    try:
        with driver.session() as session:
            session.run(query=query)
        return JSONResponse(content={"message": 
          "update location successful"}, status_code=201)
    except Exception as e:
        print(e)
        return JSONResponse(content={"message": "update 
           location  unsuccessful"}, status_code=500)

同样,删除事务使用 MATCH 命令搜索要删除的节点。以下服务实现了 Location 节点的删除:

@router.delete("/neo4j/delete/location/{node}")
def delete_location_node(node:str):
    node_attributes = '{' + f"name:'{node}'" + '}'
    query = f"""
        MATCH (n:Location {node_attributes})
        DETACH DELETE n
    """
    try:
        with driver.session() as session:
            session.run(query=query)
        return JSONResponse(content={"message": 
          "delete location node successful"}, 
             status_code=201)
    except:
        return JSONResponse(content={"message": 
           "delete location node unsuccessful"}, 
               status_code=500)

当检索节点时,以下服务从数据库中检索所有节点:

@router.get("/neo4j/nodes/all")
async def list_all_nodes():
    query = f"""
        MATCH (node)
        RETURN node"""
    try:
        with driver.session() as session:
            result = session.run(query=query)
            nodes = result.data()
        return nodes
    except Exception as e:
        return JSONResponse(content={"message": "listing
            all nodes unsuccessful"}, status_code=500)

以下服务仅基于节点的 id 检索单个节点:

@router.get("/neo4j/location/{id}")
async def get_location(id:int):
    query = f"""
        MATCH (node:Location)
        WHERE ID(node) = {id}
        RETURN node"""
    try:
        with driver.session() as session:
            result = session.run(query=query)
            nodes = result.data()
        return nodes
    except Exception as e:
        return JSONResponse(content={"message": "get 
          location node unsuccessful"}, status_code=500)

如果我们没有将节点基于属性链接的 API 端点,我们的实现将不会完整。节点基于可更新和可删除的关系名称和属性相互链接。以下 API 端点创建 Location 节点和 Respondent 节点之间的节点关系:

@router.post("/neo4j/link/respondent/loc")
def link_respondent_loc(respondent_node: str, 
    loc_node: str, node_req_atts:LinkRespondentLoc):
    node_attributes_dict = 
         node_req_atts.dict(exclude_unset=True)

    node_attributes = '{' + ', '.join(f'{key}:\'{value}\'' 
       for (key, value) in 
          node_attributes_dict.items()) + '}'

    query = f"""
        MATCH (respondent:Respondent), (loc:Location)
        WHERE respondent.name = '{respondent_node}' AND 
            loc.name = '{loc_node}'
        CREATE (respondent) -[relationship:LIVES_IN 
              {node_attributes}]->(loc)"""
    try:
        with driver.session() as session:
            session.run(query=query)
        return JSONResponse(content={"message": "add … 
            relationship successful"}, status_code=201)
    except:
        return JSONResponse(content={"message": "add 
          respondent-loc relationship unsuccessful"}, 
                 status_code=500)

FastAPI 框架可以轻松集成到任何数据库平台。前几章已经证明 FastAPI 可以通过 ORM 处理关系型数据库事务,并通过 ODM 处理基于文档的 NoSQL 事务,而本章已经证明了 Neo4j 图数据库同样可以轻松配置,证明了这一点。

摘要

本章通过展示 API 服务可以通过 numpypandassympymatplotlib 模块提供数值计算、符号公式和数据的图形解释,介绍了 FastAPI 的科学方面。本章还帮助我们理解我们可以将 FastAPI 与新技术和设计策略集成到何种程度,以提供微服务架构的新思路,例如使用 GraphQL 来管理 CRUD 事务,使用 Neo4j 进行实时和基于节点的数据管理。我们还介绍了 FastAPI 可以应用于解决各种 BPMN 工作流的基本方法,即使用 Celery 任务。有了这些,我们开始理解框架在构建微服务应用中的强大功能和灵活性。

下一章将涵盖最后一组主题,以完成我们对 FastAPI 的深入研究。我们将介绍一些部署策略、Django 和 Flask 集成,以及前几章未讨论的其他微服务设计模式。

第十一章:添加其他微服务功能

我们探索 FastAPI 在构建微服务应用程序中的可扩展性的漫长旅程将随着本章的结束而结束,本章涵盖了基于设计模式的项目设置、维护和部署的一些标准建议,使用了一些与微服务相关的工具。本章将讨论OpenTracing机制及其在分布式 FastAPI 架构设置中的应用,例如使用JaegerStarletteTracingMiddleWare等工具。同样,服务注册客户端发现设计模式也包含在如何管理访问微服务 API 端点的详细讨论中。一个检查 API 端点健康状态的微服务组件也将是讨论的一部分。此外,本章在结束之前还将提供关于 FastAPI 应用程序的部署建议,这可能导致其他设计策略和网络设置。

本章的主要目标是完成 FastAPI 应用程序的设计架构,在签发之前。以下是完成我们的 FastAPI 应用程序开发之旅的议题:

  • 设置虚拟环境

  • 检查 API 属性

  • 实施开放跟踪机制

  • 设置服务注册和客户端服务发现

  • 使用 Docker 部署和运行应用程序

  • 使用 Docker Compose 进行部署

  • 利用 NGINX 作为 API 网关

  • 集成 Django 和 Flask 子应用程序

技术要求

我们最后的软件原型将是ch11和其他第十一章相关项目。

设置虚拟环境

让我们从设置我们的 FastAPI 应用程序的开发环境的正确方式开始。在 Python 开发中,使用虚拟环境管理所需的库和扩展模块是很常见的。虚拟环境是一种创建多个不同且并行安装的 Python 解释器和它们的依赖项的方式,其中每个实例都有要编译和运行的应用程序。每个实例都有其自己的库集合,这取决于其应用程序的需求。但首先,我们需要安装virtualenv模块以追求创建这些实例:

pip install virtualenv

以下列表描述了拥有虚拟环境的好处:

  • 避免库版本的冲突

  • 避免由于命名空间冲突而导致的已安装模块文件损坏

  • 将库本地化以避免与某些应用程序非常依赖的全局安装的模块冲突

  • 创建要复制到某些相关项目的模块集的模板或基线副本

  • 维护操作系统性能和设置

安装后,我们需要运行python -m virtualenv命令来创建一个实例。图 11.1显示了如何为ch01项目创建ch01-env虚拟环境:

图 11.1 – 创建 Python 虚拟环境

图 11.1 – 创建 Python 虚拟环境

要使用虚拟环境,我们需要配置我们的 VS Code 编辑器 以使用虚拟环境的 Python 解释器而不是全局解释器来安装模块、编译和运行应用程序。按下 Ctrl + Shift + P 将会打开 命令面板,显示用于 选择解释器 的 Python 命令。图 11.2 展示了为 ch01 项目选择 Python 解释器的过程:

图 11.2 – 选择 Python 解释器

图 11.2 – 选择 Python 解释器

选择命令将会打开一个弹出窗口 文件资源管理器,用于搜索带有 Python 解释器的适当虚拟环境,如图 图 11.3 所示:

图 11.3 – 搜索虚拟环境

图 11.3 – 搜索虚拟环境

为项目打开 终端控制台 将会自动通过运行 Windows 操作系统的 /Scripts/activate.bat 命令来激活虚拟环境。此外,如果自动激活不成功,可以手动运行此 activate.bat 脚本。顺便说一句,使用 Powershell 终端无法激活,但只能使用命令控制台,如图 图 11.4 所示:

图 11.4 – 激活虚拟环境

图 11.4 – 激活虚拟环境

激活后,我们可以从命令行的最左侧确定已激活的虚拟环境名称。图 11.4 显示 ch11-env 的 Python 解释器是项目选择的解释器。通过其 pip 命令安装的任何内容都只在该实例中可用。

我们的每个项目都有一个虚拟环境,因此拥有多个包含不同已安装模块依赖关系的虚拟环境,如图 图 11.5 所示:

图 11.5 – 创建多个虚拟环境

图 11.5 – 创建多个虚拟环境

在启动 Python 微服务应用程序时,设置虚拟环境只是最佳实践之一。除了本地化模块安装外,它还有助于准备应用程序的部署,例如确定在云服务器上安装哪些模块。然而,在我们讨论 FastAPI 部署方法之前,首先让我们讨论在部署项目之前应包含哪些微服务工具,例如 Prometheus

检查 API 属性

Prometheus 是一个流行的监控工具,可以监控和检查任何微服务应用程序中的 API 服务。它可以检查并发请求事务的数量、一定时期内的响应数量以及端点的总请求量。要将 Prometheus 应用于 FastAPI 应用程序,首先,我们需要安装以下模块:

pip install starlette-exporter

然后,我们将 PrometheusMiddleware 添加到应用程序中,并启用其端点以在运行时观察 API 的属性。以下脚本显示了使用 Prometheus 监控模块的应用程序设置:

from starlette_exporter import PrometheusMiddleware, 
         handle_metrics
app = FastAPI()
app.add_middleware(PrometheusMiddleware, app_name=”osms”) 
app.add_route(“/metrics”, handle_metrics)

在这里,我们使用 FastAPI 的 add_middleware() 方法添加 PrometheusMiddleware。然后,我们将一个任意的 URI 模式添加到 handle_metrics() 工具中,以公开所有 API 健康细节。访问 http://localhost:8000/metrics 将提供如图 Figure 11.6 所示的内容:

![Figure 11.6 – 监控端点Figure 11.06_B17975.jpg

Figure 11.6 – 监控端点

Figure 11.6 中的数据显示了每个 API 在处理请求、向客户端提供响应和发出每个 API 事务状态码时所用的时间长度(以秒为单位)。此外,它还包括一些内置值,这些值由工具用于创建直方图。除了直方图之外,Prometheus 还允许自定义特定应用程序的一些固有指标。

监控 FastAPI 微服务应用程序的另一种方式是通过添加一个开放跟踪工具。

实现开放跟踪机制

当监控多个独立且分布式的微服务时,在管理 API 日志和跟踪时,OpenTracing 机制是首选。如 ZipkinJaegerSkywalking 这样的工具是流行的分布式跟踪系统,可以提供跟踪和日志收集的设置。在这个原型中,我们将使用 Jaeger 工具来管理应用程序的 API 跟踪和日志。

将 OpenTracing 工具集成到 FastAPI 微服务中的当前方式是通过 OpenTelemetry 模块,因为 Opentracing for Python 扩展已经是一个已弃用的模块。要使用 Jaeger 作为跟踪服务,OpenTelemetry 提供了一个 OpenTelemetry Jaeger Thrift Exporter 工具,它允许您将跟踪导出到 Jaeger 客户端应用程序。此导出工具使用 Thrift 压缩协议通过 UDP 将这些跟踪发送到配置的代理。但首先,我们需要安装以下扩展来利用此导出工具:

pip install opentelemetry-exporter-jaeger

之后,将以下配置添加到 main.py 文件中:

from opentelemetry import trace
from opentelemetry.exporter.jaeger.thrift import 
          JaegerExporter
from opentelemetry.sdk.resources import SERVICE_NAME, 
          Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import 
          BatchSpanProcessor
from opentelemetry.instrumentation.fastapi import 
          FastAPIInstrumentor
from opentelemetry.instrumentation.logging import 
         LoggingInstrumentor
app = FastAPI()
resource=Resource.create(
        {SERVICE_NAME: “online-sports-tracer”})
tracer = TracerProvider(resource=resource)
trace.set_tracer_provider(tracer)
jaeger_exporter = JaegerExporter(
    # configure client / agent
    agent_host_name=’localhost’,
    agent_port=6831,
    # optional: configure also collector
    # collector_endpoint=
    #     ‘http://localhost:14268/api/traces?
    #            format=jaeger.thrift’,
    # username=xxxx, # optional
    # password=xxxx, # optional
    # max_tag_value_length=None # optional
)
span_processor = BatchSpanProcessor(jaeger_exporter)
tracer.add_span_processor(span_processor)
FastAPIInstrumentor.instrument_app(app, 
          tracer_provider=tracer)
LoggingInstrumentor().instrument(set_logging_format=True)

在上述设置中的第一步是使用 OpenTelemetry 的 Resource 类创建一个具有名称的跟踪服务。然后,我们从服务资源实例化一个跟踪器。为了完成设置,我们需要向跟踪器提供通过 JaegerExporter 详细信息实例化的 BatchSpanProcessor,以使用 Jaeger 客户端管理所有痕迹和日志。一个 痕迹 包含关于所有 API 服务和其他组件之间请求和响应交换的完整详细信息。这与 日志 不同,它只包含关于应用程序内事务的详细信息。

在完成 Jaeger 跟踪器设置后,我们通过 FastAPIInstrumentortracer 客户端与 FastAPI 集成。为了使用这个类,首先,我们需要安装以下扩展:

pip install opentelemetry-instrumentation-fastapi

在我们可以运行我们的应用程序之前,首先,我们需要从 https://www.jaegertracing.io/download/ 下载一个 Jaeger 客户端,解压缩 jaeger-xxxx-windows-amd64.tar.gz 文件,并运行 jaeger-all-in-one.exe。Linux 和 macOS 的安装程序也都可以使用。

现在,打开浏览器并通过默认的 http://localhost:16686 访问 Jaeger 客户端。图 11.7 展示了跟踪器客户端的快照:

图 11.7 – 通过 Jaeger 客户端监控微服务

图 11.7 – 通过 Jaeger 客户端监控微服务

在进行一些浏览器刷新后,Jaeger 应用程序将在运行我们的微服务应用程序后通过其服务名称 online-sports-tracer 检测到我们的跟踪器。所有访问的 API 端点都会被检测和监控,从而创建所有这些端点产生的请求和响应事务的痕迹和可视化分析。图 11.8 展示了 Jaeger 生成的痕迹和图形图表:

图 11.8 – 搜索每个 API 事务的痕迹

图 11.8 – 搜索每个 API 事务的痕迹

在 OpenTelemetry 中,一个跨度相当于一个具有唯一 ID痕迹,我们可以通过点击每个端点的搜索痕迹来检查每个跨度以查看所有细节。点击如 图 11.8 所示的 /ch11/login/list/all 端点的搜索痕迹,可以提供以下痕迹细节:

图 11.9 – 检查端点的痕迹细节

图 11.9 – 检查端点的痕迹细节

除了 图 11.9 中显示的痕迹外,Jaeger 客户端还可以通过名为 opentelemetry-instrumentation-logging 的 OpenTelemetry 模块收集 uvicorn 日志。在安装模块后,我们可以在 main.py 文件中实例化 LoggingInstrumentor 以启用集成,如前代码片段所示。

现在,让我们向我们的应用程序添加 服务注册客户端服务发现 机制。

设置服务注册和客户端服务发现

服务注册工具如Netflix Eureka允许在不知道其服务器确切 DNS 位置的情况下注册微服务应用程序。它使用负载均衡算法管理对所有已注册服务的访问,并动态地为这些服务实例分配网络位置。这种服务注册对于部署在 DNS 名称因故障、升级和增强而变化的服务器上的微服务应用程序非常有用。

为了使服务注册能够工作,服务实例应该在服务器注册之前有一个机制来发现注册服务器。对于 FastAPI,我们需要利用py_eureka_client模块来实现服务发现设计模式。

实现客户端服务发现

创建一个用于发现和注册到服务注册服务器(如Netflix Eureka)的 FastAPI 微服务应用程序是直接的。首先,我们需要通过pip安装py_eureka_client

pip install py_eureka_client

然后,我们使用正确的eureka_serverapp_nameinstance_portinstance_host参数细节实例化其EurekaClient组件类。eureka_server参数必须是 Eureka 服务器的确切机器地址,而不是localhost。此外,客户端实例必须具有适用于 FastAPI 微服务应用程序(或客户端应用程序)的适当app_name参数,instance_port参数设置为8000instance_host设置为192.XXX.XXX.XXX(不是localhost127.0.0.1)。以下代码片段显示了在main.py中实例化EurekaClient组件类的位置:

from py_eureka_client.eureka_client import EurekaClient
app = FastAPI()
@app.on_event(“startup”)
async def init():
    create_async_db() 
    global client
    client = EurekaClient(
     eureka_server=”http://DESKTOP-56HNGC9:8761/eureka”, 
     app_name=”sports_service”, instance_port=8000, 
     instance_host=”192.XXX.XXX.XXX”)
    await client.start()
@app.on_event(“shutdown”)
async def destroy():
    close_async_db() 
    await client.stop()

客户端发现发生在应用程序的startup事件中。它从实例化EurekaClient组件类并调用其start()方法(异步或非异步)开始。EurekaClient组件类可以处理异步或同步的 FastAPI 启动事件。要关闭服务器发现过程,始终在shutdown事件中调用EurekaClientstop()方法。现在,在运行和执行客户端服务发现之前,让我们构建我们的 Netflix Eureka 服务器注册。

设置 Netflix Eureka 服务注册

让我们利用 Spring Boot 平台来创建我们的 Eureka 服务器。我们可以通过https://start.spring.io/Spring STS IDE创建一个应用程序,使用由 Maven 或 Gradle 驱动的应用程序。我们的是一个 Maven 应用程序,具有pom.xml,其中包含以下依赖项用于 Eureka 服务器设置:

<dependency>
      <groupId>org.springframework.cloud</groupId>
      <artifactId>
       spring-cloud-starter-netflix-eureka-server
     </artifactId>
</dependency>

在这种情况下,application.properties必须将server.port设置为8761,启用server.shutdown以实现graceful服务器关闭,并将spring.cloud.inetutils.timeout-seconds属性设置为10以进行主机名计算。

现在,在运行 FastAPI 客户端应用程序之前运行 Eureka 服务器应用程序。Eureka 服务器的日志将显示 FastAPI 的EurekaClient的自动检测和注册,如图11.10所示:

图 11.10 – 发现 FastAPI 微服务应用

图 11.10 – 发现 FastAPI 微服务应用

客户端服务发现的成果在 Eureka 服务器的仪表板http://localhost:8761上也很明显。页面将显示所有包含注册表的服务,我们可以通过这些服务访问和测试每个服务。图 11.11显示了仪表板的示例快照:

图 11.11 – 创建服务注册表

图 11.11 – 创建服务注册表

图 11.11所示,我们的SPORTS_SERVICE作为 Eureka 服务器注册表的一部分,意味着我们成功实现了客户端服务发现设计模式,现在是时候将我们的应用程序部署到 Docker 容器中。

使用 Docker 部署和运行应用程序

Docker 化是一个使用 Docker 容器打包、部署和运行应用程序的过程。将 FastAPI 微服务容器化可以节省安装和设置时间、空间和资源。与传统的部署打包相比,容器化应用程序具有可替换性、可复制性、高效性和可扩展性。

为了追求 Docker 化,我们需要安装Docker Hub和/或Docker Engine以使用 CLI 命令。但请注意关于其新订阅模式的新 Docker Desktop 许可协议(www.docker.com/legal/docker-software-end-user-license-agreement/)。本章主要关注如何运行 CLI 命令,而不是 Docker Hub 的 GUI 工具。现在,让我们生成要安装到 docker 镜像中的模块列表。

生成 requirements.txt 文件

由于我们使用虚拟环境实例进行模块管理,因此很容易确定在 Docker 镜像中要安装哪些扩展模块。我们可以运行以下命令将模块及其版本完整列表生成到requirements.txt文件中:

pip freeze > requirements.txt 

然后,我们可以创建一个命令通过Dockerfile将此文件复制到镜像中。

创建 Docker 镜像

下一步是从Docker Hub中任何可用的基于 Linux 的容器镜像构建容器镜像。但我们需要一个包含所有与从 Docker Hub 拉取可用 Python 镜像、创建工作目录和从本地目录复制项目文件相关的命令的Dockerfile。以下是我们用于将原型部署到 Python 镜像的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 ./ch11 /code
EXPOSE 8000
CMD [“uvicorn”, “main:app”, “--host=0.0.0.0” , “--reload” ,
     “--port”, “8000”]

第一行是一个指令,它将导出一个安装了 Python 3.9 解释器的 Python 镜像,通常是基于 Linux 的。之后的命令创建了一个任意文件夹/code,它将成为应用程序的主要文件夹。COPY命令将我们的requirements.txt文件复制到/code文件夹,然后RUN指令使用以下命令从requirements.txt列表中安装更新的模块:

pip install -r requirements.txt 

之后,第二个 COPY 命令将我们的 ch11 应用程序复制到工作目录。EXPOSE 命令将端口 8000 绑定到本地机器的端口 8000 以运行 CMD 命令,这是 Dockerfile 的最后一条指令。CMD 指令使用 uvicorn 在端口 8000 上使用主机 0.0.0.0 运行应用程序,而不是 localhost 以自动映射并利用分配给镜像的 IP 地址。

Dockerfile 必须与 requirements.txt 文件和 ch11 应用程序在同一文件夹中。图 11.12 展示了需要将文件和文件夹 Docker 化到 Python 容器镜像中的组织结构:

图 11.12 – 设置 Docker 文件夹结构

图 11.12 – 设置 Docker 文件夹结构

一旦所有文件和文件夹都准备好了,我们就在终端控制台使用以下 CLI 命令在该文件夹内运行:

docker build -t ch11-app .

要检查镜像,运行 docker image ls CLI 命令。

使用 Mongo Docker 镜像

我们应用程序的后端是 MongoDB,因此我们需要使用以下 CLI 命令从 Docker Hub 拉取最新的 mongo 镜像:

docker pull mongo:latest

在我们运行 ch11-app 应用程序和 mongo:latest 镜像之前,首先需要通过运行以下命令创建一个 ch11-network

docker network create ch11-network

一旦它们作为容器部署,这个网络就成为了 mongoch11-app 之间的桥梁。它将建立两个容器之间的连接,以追求 Motor-ODM 事务。

创建容器

一个 docker run 命令用于启动和运行已拉取或创建的镜像。因此,使用 ch11-network 路由运行 Mongo 镜像需要执行以下 CLI 命令:

docker run --name=mongo --rm -p 27017:27017 -d                 --network=ch11-network mongo

使用 docker inspect 命令检查 mongo:latest 容器,以获取并使用其 IP 地址为 Motor-ODM 的连接性。将 AsyncIOMotorClient 中使用的 localhost 替换为“检查到的”IP 地址,该地址位于 ch11-appconfig/db.py 模块中。更新后,务必重新构建 ch11-app Docker 镜像。

现在,使用以下命令运行 ch11-app 镜像,并使用 ch11-network

docker run --name=ch11-app --rm -p 8000:8000-d             --network=ch11-network ch11-app

通过 http://localhost:8000/docs 访问应用程序,以检查 OpenAPI 文档中的所有 API 端点。

现在,另一种简化容器化的方法是使用 Docker Compose 工具。

使用 Docker Compose 进行部署

然而,您需要在操作系统上安装 Docker Compose 工具,这需要 Docker 引擎作为预安装要求。安装后,下一步是创建包含构建镜像、处理 Dockerfile、构建 Docker 网络以及创建和运行容器的所有所需服务的 docker-decompose.yaml 文件。以下片段显示了我们的配置文件内容,该文件设置了 mongoch11-app 容器:

version: “3”
services: 
    ch11-mongo:
        image: “mongo”
        ports:
            - 27017:27017
        expose:
            - 27017
        networks:
            - ch11-network

    ch11-app:
        build: .     # requires the Dockerfile
        depends_on: 
            - ch11-mongo
        ports:
            - 8000:8000
        networks:
            - ch11-network
networks:
    ch11-network:
      driver: bridge 

我们将不会运行单独的 Docker CLI 命令,Docker Compose 创建服务,如 ch11-mongoch11-app,以管理容器化,并且只使用一个 CLI 命令来执行这些服务,即 docker-compose up。该命令不仅创建图像网络,还运行所有容器。

使用 Docker Compose 的一个优点是 ORM 和 ODM 配置的简便性。我们不需要执行容器检查来了解使用哪个 IP 地址,我们可以使用数据库设置的 服务名称 作为主机名来建立数据库连接。这很方便,因为每个创建的 mongo 容器的 IP 地址都不同。以下是将 ch11-mongo 服务作为主机名的新 AsyncIOMotorClient

def create_async_db():
    global client
    client = AsyncIOMotorClient(str(“ch11-mongo:27017”))

现在,让我们使用 NGINX 工具为容器化应用程序实现 API 网关设计模式。

使用 NGINX 作为 API 网关

第四章**,构建微服务应用程序 中,我们仅使用一些 FastAPI 组件实现了 API 网关设计模式。在本章的最后,我们将通过 NGINX 建立一个 反向代理服务器,将为每个容器化的微服务应用程序分配一个代理 IP 地址。这些代理 IP 将将客户端请求重定向到各自容器上运行的相应微服务。

我们将不会构建实际的 NGINX 环境,而是从 Docker Hub 拉取可用的 NGINX 镜像来实现反向代理服务器。此镜像创建需要一个包含以下指令的新 Docker 应用程序文件夹和不同的 Dockerfile

FROM nginx:latest
COPY ./nginx_config.conf /etc/nginx/conf.d/default.conf

Dockerfile 指示创建最新的 NGINX 镜像,并将 nginx_config.conf 文件复制到该镜像中。该文件是一个 NGINX 配置文件,其中包含将代理 IP 地址映射到每个微服务应用程序的实际容器地址的映射。它还公开 8080 作为其官方端口。以下是我们 nginx_config.conf 文件的内容:

server {
    listen 8080;
    location / {
        proxy_pass http://192.168.1.7:8000;
    }
} 

该应用程序的 OpenAPI 文档现在可以通过 http://localhost:8080/docs 访问。

NGINX 的 Docker 化必须在将应用程序部署到容器之后进行。但另一种方法是将在应用程序的 Dockerfile 中包含 NGINX 的 Dockerfile 指令,以节省时间和精力。或者我们可以在 docker-decompose.yaml 文件中创建另一个服务来构建和运行 NGINX 镜像。

最后一次,让我们探索 FastAPI 与其他流行的 Python 框架(如 FlaskDjango)集成的强大功能。

集成 Flask 和 Django 子应用程序

Flask 是一个轻量级框架,因其 Jinja2 模板和 WSGI 服务器而受到欢迎。另一方面,Django 是一个 Python 框架,它通过 CLI 命令促进快速开发,并将文件和文件夹的脚手架应用于构建项目和应用程序。Django 应用程序可以在基于 WSGI 或 ASGI 的服务器上运行。

我们可以在 FastAPI 微服务应用程序内部创建、部署和运行 Flask 和 Django 项目。该框架具有 WSGIMiddleware,可以包裹 Flask 和 Django 应用程序并将它们集成到 FastAPI 平台上。通过 uvicorn 运行 FastAPI 应用程序也会运行这两个应用程序。

在这两个框架中,将 Flask 应用程序集成到项目中比 Django 更容易。我们只需将 Flask 的 app 对象导入到 main.py 文件中,用 WSGIMiddleware 包裹它,并将其挂载到 FastAPI 的 app 对象上。以下脚本展示了 main.py 中集成我们的 ch11_flask 项目的部分:

from ch11_flask.app import app as flask_app
from fastapi.middleware.wsgi import WSGIMiddleware
app.mount(“/ch11/flask”, WSGIMiddleware(flask_app))

所有在 ch11_flask 中实现的 API 端点都将使用 mount() 方法中指示的 URL 前缀 /ch11/flask 进行访问。图 11.13 展示了 ch11_flaskch11 项目中的位置:

图 11.13 – 在 FastAPI 项目中创建 Flask 应用程序

图 11.13 – 在 FastAPI 项目中创建 Flask 应用程序

另一方面,以下 main.py 脚本将我们的 ch11_django 应用程序集成到 ch11 项目中:

import os
from django.core.wsgi import get_wsgi_application
from importlib.util import find_spec
from fastapi.staticfiles import StaticFiles
os.environ.setdefault(‘DJANGO_SETTINGS_MODULE’, 
           ‘ch11_django.settings’)
django_app = get_wsgi_application()
app = FastAPI()
app.mount(‘/static’,
    StaticFiles(
         directory=os.path.normpath(
              os.path.join(
           find_spec(‘django.contrib.admin’).origin, 
                  ‘..’, ‘static’)
         )
   ),
   name=’static’,
)
app.mount(‘/ch11/django’, WSGIMiddleware(django_app))

Django 框架有一个 get_wsgi_application() 方法,用于检索其 app 实例。这个实例需要被 WSGIMiddleware 包裹并挂载到 FastAPI 的 app 对象上。此外,我们需要将 ch11_django 项目的 settings.py 模块加载到 FastAPI 平台,以便全局访问。我们还需要挂载 django.contrib.main 模块的所有静态文件,这包括 Django 安全模块 的一些 HTML 模板。

ch11_django 项目的 sports 应用程序创建的所有视图和端点都必须使用 /ch11/django URL 前缀进行访问。图 11.14 展示了 ch11_django 项目在 ch11 应用程序中的位置:

图 11.14 – 在 FastAPI 对象中创建 Django 项目和应用程序

图 11.14 – 在 FastAPI 对象中创建 Django 项目和应用程序

摘要

最后一章为我们提供了如何开始、部署和运行遵循标准和最佳实践的 FastAPI 微服务应用程序的途径。它介绍了从开发开始到将我们的应用程序部署到 Docker 容器中,使用虚拟环境实例来控制和管理工作模块安装的方法。本章详细解释了如何打包、部署和运行容器化应用程序的方法。最后,本章为应用程序实现了一个 NGINX 反向代理服务器,以构建我们的 API 网关。

从一开始,我们就见证了 FastAPI 框架的简洁性、强大、适应性和可扩展性,从创建后台进程到使用 HTML 模板渲染数据。它通过协程快速执行 API 端点,使该框架在未来有望成为最受欢迎的 Python 框架之一。随着 FastAPI 社区的持续增长,我们期待其在未来的更新中带来更多有前景的功能,例如支持响应式编程、断路器和签名安全模块。我们对 FastAPI 框架充满期待!

posted @ 2025-09-18 14:34  绝不原创的飞龙  阅读(6)  评论(0)    收藏  举报