微服务-API-指南-全-

微服务 API 指南(全)

原文:Microservice APIs

译者:飞龙

协议:CC BY-NC-SA 4.0

前置内容

前言

API 和微服务已经席卷了软件行业。在软件复杂性不断增加和需要扩展的压力下,越来越多的组织正在从单体架构迁移到微服务架构。O'Reilly 的“2020 年微服务采用”报告发现,77%的受访者已经采用了微服务,预计这一趋势在未来几年将继续增长。

使用微服务带来了通过 API 驱动服务集成的挑战。根据 Nordic APIs 的数据,90%的开发者使用 API,他们花费 30%的时间在构建 API 上。¹ API 经济的增长已经改变了我们构建应用程序的方式。如今,越来越多地构建完全通过 API 交付的产品和服务,例如 Twilio 和 Stripe。即使是传统行业如银行和保险业,也在通过开放 API 和集成到 Open Banking 生态系统中找到新的业务线。API-first 产品的广泛可用性意味着我们可以在构建自己的应用程序时专注于核心业务能力,同时使用外部 API 来处理诸如用户认证和发送电子邮件等常见任务。

成为这个不断发展的生态系统的成员令人兴奋。然而,在我们拥抱微服务和 API 之前,我们需要知道如何架构微服务,如何设计 API,如何制定 API 策略,如何确保我们提供可靠的集成,如何选择部署模式,以及如何保护我们的系统。根据我的经验,大多数组织在这些问题上都会遇到一个或多个挑战,而 IBM 最近的一份报告发现,31%的企业没有采用微服务,原因是缺乏内部专业知识。² 同样,Postman 的 2022 年 API 状态报告发现,14%的受访者有 11%–25%的时间遇到 API 集成失败,根据 Salt Security 的数据,2022 年 94%的组织经历了 API 安全事件。³

许多书籍都讨论了上一段提到的那些问题,但它们通常从非常具体的视角来处理:有些专注于架构,有些关注 API,还有些关注安全。我感觉缺少一本能够将这些疑问汇集在一起,并以实用方法来解答的书:本质上,一本能让普通开发者快速上手,掌握设计构建微服务 API 的最佳实践、原则和模式的书。我正是带着这个目标来写这本书的。

在过去的几年里,我有机会与不同的客户合作,帮助他们设计微服务并交付 API 集成。在这些项目中工作让我对开发团队在处理微服务和 API 时面临的主要挑战有了更深入的了解。事实证明,这两种技术都看似简单。一个设计良好的 API 易于导航和消费,而良好的微服务架构可以提升开发者的生产力,并且易于扩展。另一方面,设计不良的 API 容易出错且难以使用,而设计不良的微服务会导致所谓的分布式单体。

显而易见的问题随之而来:你如何设计好的 API?你如何设计松散耦合的微服务?这本书将帮助你回答这些问题以及更多。你还将有机会亲手构建 API 和服务,并学习如何确保它们的安全、测试和部署。我在这本书中教授的方法、模式和原则是多年试验和实验的结果,我非常期待与你们分享它们。我希望你们能在这本书中找到成为更好的软件开发者和架构师旅程中的宝贵资源。

致谢

写这本书是我职业生涯中最迷人的旅程之一,没有家人和一支了不起的同事团队的帮助和支持,我无法完成它。这本书献给我的妻子 Jiwon,没有她不断的鼓励和理解,我无法完成这本书;也献给我们的小女儿 Ivy,她确保我在日程中从未有过无聊的时刻。

我从为这本书贡献想法的人中受益匪浅,他们帮助我更好地理解了我在其中使用的工具和协议,并对各个章节和草稿提供了反馈。特别感谢 Dmitry Dygalo、Kelvin Meeks、Sebastián Ramírez Montaño、Chris Richardson、Jean Yang、Gajendra Deshpande、Oscar Islas、Mehdi Medjaoui、Ben Hutton、Andrej Baranovskij、Alex Mystridis、Roope Hakulinen、Steve Ardagh-Walter、Kathrin Björkelund、Thomas Dean、Marco Antonio Sanz、Vincent Vandenborne 以及 Mirumee 的 Ariadne 项目的出色维护者。

自 2020 年以来,我在各种会议上展示了这本书的草稿和想法,包括 EuroPython、PyCon India、API World、API 规格会议以及各种播客和聚会。我想感谢所有参加我的演讲并给予宝贵反馈的人。我还想感谢参加 microapis.io 上我研讨会的人,他们对这本书的深思熟虑的评论。

我想感谢我的收购编辑 Andy Waldron。Andy 在帮助我把我的书稿提案整理得很好,并确保这本书聚焦于相关主题方面做得非常出色。他还不知疲倦地支持我推广这本书,并帮助我触及更广泛的受众。

您现在手中的这本书之所以易于阅读和理解,多亏了我的编辑 Marina Michaels 的无价工作。她不遗余力地帮助我写出更好的书籍。她出色地帮助我改进写作风格,并保持我保持方向和动力。

我要感谢我的技术编辑 Nick Watts,他正确地指出了许多不准确之处,并总是挑战我提供更好的解释和插图,以及我的技术校对员 Al Krinker,他勤奋地检查了所有代码列表和这本书的 GitHub 仓库,确保代码正确无误且无执行问题。

我还要感谢参与这本书生产的 Manning 团队的其他成员,包括 Candace Gillhoolley、Gloria Lukos、Stjepan Jureković、Christopher Kaufmann、Radmila Ercegovac、Mihaela Batinić、Ana Romac、Aira Dučić、Melissa Ice、Eleonor Gardner、Breckyn Ely、Paul Wells、Andy Marinkovich、Katie Tennant、Michele Mitchell、Sam Wood、Paul Spratley、Nick Nason 和 Rebecca Rinehart。也要感谢 Marjan Bace,他对我下注,给了这本书一个机会。

在撰写这本书的过程中,我有机会从最令人惊叹的审稿人团队那里获得详细而出色的反馈,包括 Alain Lompo、Björn Neuhaus、Bryan Miller、Clifford Thurber、David Paccoud、Debmalya Jash、Gaurav Sood、George Haines、Glenn Leo Swonk、Hartmut Palm、Ikechukwu Okonkwo、Jan Pieter Herweijer、Joey Smith、Juan Jimenez、Justin Baur、Krzysztof Kamyczek、Manish Jain、Marcus Young、Mathijs Affourtit、Matthieu Evrin、Michael Bright、Michael Rybintsev、Michal Rutka、Miguel Montalvo、Ninoslav Cerkez、Pierre-Michel Ansel、Rafael Aiquel、Robert Kulagowski、Rodney Weis、Sambasiva Andaluri、Satej Kumar Sahu、Simeon Leyzerzon、Steven K Makunzva、Stuart Woodward、Stuti Verma 和 William Jamir Silva。我感谢他们为书中内容的质量做出了巨大贡献。

自从这本书进入 MEAP 以来,我收到了许多读者通过各种渠道(如 LinkedIn 和 Twitter)发送的鼓励和反馈,这让我感到非常幸运。我还很幸运能与一群才华横溢的读者进行交流,他们积极参与了 Manning 的 liveBook 平台上的书籍论坛。我对你们所有人都心怀感激。

没有数千名开源贡献者的不懈努力,这本书是不可能完成的。他们创建了并维护了我在这本书中使用的令人惊叹的库。我对你们所有人都非常感激,并希望我的书能帮助使你们的工作更加突出。

最后,感谢您,读者,购买了这本书的一本。我只希望您觉得这本书有用且信息丰富,并且您阅读这本书的乐趣能和我写作这本书的乐趣一样。我喜欢收到读者的来信,如果您愿意与我分享对这本书的看法,我将非常高兴。

关于这本书

本书的目标是教你如何使用 API 构建微服务并推动它们的集成。你将学习如何设计微服务平台,构建 REST 和 GraphQL API 以实现微服务之间的通信。你还将学习测试和验证你的微服务 API,确保它们的安全,并在云中部署和运行它们。

适合阅读本书的人群?

本书对使用微服务和 API 的软件开发者很有帮助。本书采用了一种非常实用的方法,几乎每一章都通过完整的编码示例来阐述解释。因此,直接与微服务 API 工作的实践开发者会发现本书的内容很有价值。

编码示例使用 Python 编写;然而,了解该语言并不是能够跟随示例的必要条件。在引入新代码之前,每个概念都得到了彻底的解释。

本书对设计策略、最佳实践和开发工作流程给予了大量关注,因此它对需要决定微服务是否是合适的架构解决方案的 CTO、架构师和工程副总裁也很有用,或者需要在不同 API 策略之间进行选择以及如何使集成工作。

本书是如何组织的:一个路线图

本书分为四个部分,共 14 章。

第一部分介绍了微服务和 API 的概念,展示了如何构建一个简单的 API,并解释了如何设计微服务平台:

  • 第一章介绍了本书的主要概念:微服务和 API。它解释了微服务与单体架构的区别,以及何时使用单体与微服务更合适。它还解释了 API 是什么以及它们如何帮助我们推动微服务之间的集成。

  • 第二章提供了使用 Python 流行的 FastAPI 框架实现 API 的逐步指南。你将学习如何阅读 API 规范并理解其要求。你还将学习逐步构建 API,以及如何测试你的数据验证模型。

  • 第三章解释了如何设计微服务平台。它介绍了三个基本的微服务设计原则,并解释了如何通过业务能力和子域分解来将系统分解为微服务。

第二部分解释了如何设计、记录和构建 REST API,以及如何构建微服务:

  • 第四章解释了 REST API 的设计原则。它介绍了 REST 架构的六个约束和 Richardson 成熟度模型,然后继续解释我们如何利用 HTTP 协议来设计结构良好且高度表达的 REST API。

  • 第五章解释了如何使用 OpenAPI 规范标准来记录 REST API。你将学习 JSON Schema 语法的基础知识,如何定义端点,如何建模你的数据,以及如何使用可重用模式重构你的文档。

  • 第六章解释了如何使用两个流行的 Python 框架:FastAPI 和 Flask 来构建 REST API。您将了解这两个框架之间的区别,但您也将了解构建 API 的原则和模式保持相同,并且超越了任何技术栈的实现细节。

  • 第七章解释了构建微服务的基本原则和模式。它介绍了六边形架构的概念,并解释了如何强制执行应用程序层之间的松耦合。它还解释了如何使用 SQLAlchemy 实现数据库模型以及如何使用 Alembic 管理数据库迁移。

第三部分解释了如何设计、消费和构建 GraphQL API:

  • 第八章解释了如何设计 GraphQL API 以及 Schema 定义语言的工作原理。它介绍了 GraphQL 的内置类型,并解释了如何定义自定义类型。您将学习如何创建类型之间的关系,以及如何定义查询和突变。

  • 第九章解释了如何消费 GraphQL API。您将学习如何运行模拟服务器以及如何使用 GraphiQL 探索 GraphQL 文档。您将学习如何对 GraphQL 服务器运行查询和突变以及如何参数化您的操作。

  • 第十章解释了如何使用 Python 的 Ariadne 框架构建 GraphQL API。您将学习如何利用 API 文档自动加载数据验证模型,以及如何实现自定义类型、查询和突变的解析器。

第四部分解释了如何测试、安全和部署您的微服务 API:

  • 第十一章解释了如何使用标准协议,如 OpenID Connect (OIDC)和 Open Authorization (OAuth) 2.1,向您的 API 添加身份验证和授权。您将学习如何生成和验证 JSON Web Tokens (JWTs)以及如何为您的 API 创建授权中间件。

  • 第十二章解释了如何测试和验证您的 API。您将了解基于属性的测试是什么以及如何使用它来测试您的 API,您还将学习如何使用 Dredd 和 schemathesis 等 API 测试自动化框架。

  • 第十三章解释了如何将您的微服务 API Docker 化,如何使用 Docker Compose 在本地运行它们,以及如何将您的 Docker 构建发布到 AWS 弹性容器注册库(ECR)。

  • 第十四章解释了如何使用 Kubernetes 将您的微服务 API 部署到 AWS。您将学习如何使用 AWS EKS 创建和操作 Kubernetes 集群,如何将 Aurora 无服务器数据库启动到安全网络中,如何使用信封加密安全地注入应用程序配置,以及如何设置您的服务以进行大规模操作。

所有章节都有一个共同的主题:构建一个虚构的、按需咖啡配送平台 CoffeeMesh 的组件。我们在第一章介绍了 CoffeeMesh,在第三章,我们将平台分解为微服务。因此,我建议阅读第一章和第三章,以便更好地理解后续章节中介绍的示例。否则,本书的每一部分都是相当独立的,每个章节都相当自包含。例如,如果你想学习如何设计和构建 REST API,可以直接跳转到第二部分,如果你想关注 GraphQL API,可以专注于第三部分。同样,如果你想学习如何给你的 API 添加身份验证和授权,可以直接跳转到第十一章,或者如果你想学习如何测试 API,可以直接跳转到第十二章。

之间有一些章节之间的交叉引用:例如,第十二章引用了第二部分和第三部分的 API 实现,但如果你熟悉构建 API,你应该可以直接跳转到第十二章。第四部分的其他章节也是如此。

关于代码

这本书包含了大量的源代码示例,既有编号列表中的,也有与普通文本混排的。在两种情况下,源代码都以固定宽度字体的形式呈现,以便与普通文本区分开来。有时代码也会被加粗,以突出显示章节中从先前步骤中更改的代码,例如当新功能添加到现有代码行时。

在许多情况下,原始源代码已经被重新格式化;我们添加了换行并重新调整了缩进,以适应书籍中的可用页面空间。在某些情况下,即使这样也不够,列表中还包括了行续接标记(➥)。此外,当代码在文本中描述时,源代码中的注释通常也会从列表中移除。许多列表旁边都有代码注释,突出显示重要概念。

除了第一章、第三章和第四章外,本书的每一章都充满了代码示例,这些示例展示了向读者介绍的所有新概念和模式。大多数代码示例都是用 Python 编写的,但在第五章、第八章和第九章中,重点在于 API 设计,因此包含 OpenAPI/JSON Schema(第五章)和 Schema 定义语言(第八章和第九章)的示例。所有代码都进行了彻底的解释,因此应该对所有读者都易于理解,包括那些不了解 Python 的读者。

您可以从本书的 liveBook(在线)版本中获取可执行的代码片段,请访问livebook.manning.com/book/microservice-apis。本书中示例的完整代码可以从 Manning 网站www.manning.com下载,也可以从专门为此书建立的 GitHub 仓库中下载:github.com/abunuwas/microservice-apis。GitHub 仓库中的每个章节都有一个对应的文件夹,例如 ch02 对应第二章。除非另有说明,每个章节中所有文件引用都是相对于 GitHub 中相应文件夹的。例如,在第二章中,orders/app.py 指的是 GitHub 中的 ch02/orders/app.py 文件。

本书 GitHub 仓库显示了每个章节代码的最终状态。有些章节展示了如何逐步构建功能,以迭代步骤进行。在这些情况下,您在 GitHub 上找到的代码版本与章节中的最终代码版本相匹配。

本书中的 Python 代码示例已在 Python 3.10 上进行了测试,尽管任何高于 3.7 版本的 Python 都应该可以正常工作。我在整个书中使用的代码和命令已在 Mac 机器上进行了测试,但它们也应该在 Windows 和 Linux 上没有问题。如果您在 Windows 上工作,我建议您使用 POSIX 兼容的终端,例如 Cygwin。

我在每个章节中使用了 Pipenv 来管理依赖项。在每个章节的文件夹中,您将找到描述我用来运行代码示例的精确依赖项的 Pipfile 和 Pipfile.lock 文件。为了避免运行代码时出现问题,我建议您在每个章节的开始时下载这些文件,并从它们中安装依赖项。

liveBook 讨论论坛

购买 微服务 API 包含对 Manning 在线阅读平台 liveBook 的免费访问。使用 liveBook 的独家讨论功能,您可以在全球范围内或特定章节或段落中添加评论。为自己做笔记、提问和回答技术问题以及从作者和其他用户那里获得帮助都非常简单。要访问论坛,请访问livebook.manning.com/book/microservice-apis/discussion。您还可以在livebook.manning.com/discussion了解更多关于 Manning 论坛和行为准则的信息。

曼宁对读者的承诺是提供一个场所,在那里个人读者之间以及读者与作者之间可以进行有意义的对话。这不是对作者参与特定数量活动的承诺,作者对论坛的贡献仍然是自愿的(且未付费)。我们建议你尝试向他提出一些挑战性的问题,以免他的兴趣转移!只要本书有售,论坛和先前讨论的存档将可通过出版社的网站访问。

其他在线资源

如果你想了解更多关于微服务 API 的信息,你可以查看我的博客,microapis.io/blog,其中包含补充本书课程内容的额外资源。在同一网站上,我还保持着一个经常更新的、我组织的研讨会和研讨会的最新列表,这些活动也补充了本书的内容。

关于作者

何塞·哈罗·佩拉尔塔(José Haro Peralta)是一位软件和架构顾问。拥有超过 10 年的经验,何塞帮助大小组织构建复杂系统,设计微服务平台,并交付 API 集成。他也是 microapis.io 公司的创始人,该公司提供软件咨询和培训服务。何塞在云计算、DevOps 和软件自动化领域被认为是思想领袖,他经常在国际会议上发表演讲,并经常组织公开研讨会和研讨会。

关于封面插图

《微服务 API》封面上的插图标题为“L’invalide”,或“残疾人”,描绘了一位受伤的法国士兵,他曾是国家残疾人之家(Hôtel national des Invalides)的居民。这幅图像取自雅克·格拉塞·德·圣索沃尔(Jacques Grasset de Saint-Sauveur)的收藏,该收藏于 1797 年出版。每一幅插图都是手工精心绘制和着色的。

在那些日子里,人们的生活地点和他们的职业或社会地位很容易通过他们的着装来识别。曼宁通过基于几个世纪前丰富多样的地区文化的封面,以及此类收藏中的图片,来庆祝计算机行业的创新精神和主动性。


¹ J. Simpson,"20 个令人印象深刻的 API 经济统计数据" (nordicapis.com/20-impressive-api-economy-statistics/ [访问日期:2022 年 5 月 26 日])。

² "企业中的微服务,2021 年:实际效益,值得挑战",(www.ibm.com/downloads/cas/OQG4AJAM [访问日期:2022 年 5 月 26 日])。

³ Salt Security,"API 安全状态 Q3 2022",第 4 页 (content.salt.security/state-api-report.html)。

第一部分:介绍微服务 API

微服务是一种架构风格,其中系统的组件被设计为独立和可独立部署的应用程序。微服务的概念自 2000 年代初以来就已经存在,自 2010 年代以来,其受欢迎程度一直在增加。如今,微服务是构建现代网站的热门选择。正如您将在第一章中学到的,微服务允许您利用分布式应用程序的力量,更轻松地扩展组件,并更快地发布。

然而,尽管微服务带来了许多好处,但它们也带来了自己的挑战。它们带来了大量的基础设施开销,并且更难以监控、操作和追踪。当与微服务一起工作时,第一个挑战是确保它们的设计正确,在第三章中,您将学习到一些原则和策略,这些原则和策略将帮助您构建健壮的微服务。

微服务通过 API 进行协作,在这本书中,您将学习如何为您的微服务设计和构建 REST 和 GraphQL API。第二章为您提供了构建 REST API 的初步体验,在本书的第二部分,您将学习到更多构建健壮 REST API 的模式和原则。与 API 一起工作的最具挑战性的方面是确保 API 客户端和 API 服务器都遵循 API 规范,在第一章中,您将了解文档驱动开发以及以良好的和有良好文档的设计开始 API 之旅的重要性。

本书的第一部分向您介绍了构建微服务和驱动它们与 API 集成的基本模式和原则。在本书的其余部分,我们将在此基础上构建,您将学习如何构建健壮的 API,如何测试它们,如何保护它们,以及如何将您的微服务 API 部署到云端。我们勇敢的旅程即将开始!

1 微服务 API 是什么?

本章涵盖

  • 微服务是什么以及它们与单体应用如何比较

  • 网络 API 是什么以及它们如何帮助我们推动微服务之间的集成

  • 开发和运营微服务面临的最重要挑战

本章定义了本书中最重要的一些概念:微服务和 API。微服务是一种架构风格,其中系统的组件被设计为独立部署的服务,API 是允许我们与这些服务交互的接口。我们将看到微服务架构的定义特征以及它们与单体应用的比较。单体应用围绕单个代码库构建并部署在单个构建中。

我们将讨论微服务架构的优点和缺点。本章的最后部分将讨论在设计、实施和运营微服务时面临的最重要挑战。这次讨论不是为了阻止你接受微服务,而是为了让你能够做出明智的决定,判断微服务是否是你架构选择的正确选择。

1.1 微服务是什么?

在本节中,我们定义微服务架构是什么,并分析微服务与单体应用的比较。我们将探讨每种架构模式的优点和挑战。最后,我们还将简要回顾导致现代微服务架构出现的历史发展。

1.1.1 定义微服务

那么,微服务是什么?微服务可以以不同的方式定义,并且,根据我们想要强调的微服务架构的哪个方面,作者提供了略微不同但相关的定义。Sam Newman,微服务领域最有影响力的作者之一,提供了一个最小化定义:“微服务是小型、自治的服务,它们协同工作。”¹

这个定义强调了微服务是独立运行的应用程序,但可以在执行任务时相互协作的事实。定义还强调了微服务是“小型”的。在这种情况下,“小型”并不指微服务的代码库大小,而是指微服务是具有狭窄和明确范围的应用程序,遵循单一责任原则,即做好一件事。

James Lewis 和 Martin Fowler 撰写的一篇开创性文章提供了更详细的定义。他们将微服务定义为一种架构风格,即“将单个应用程序作为一系列小型服务开发的方法,每个服务都在自己的进程中运行,并通过轻量级机制进行通信,通常是 HTTP 资源 API”(martinfowler.com/articles/microservices.html)。这个定义通过指出它们在独立进程中运行来强调服务的自治性。Lewis 和 Fowler 还强调微服务具有狭窄的责任范围,他们说它们是“小的”,并明确描述了微服务通过轻量级协议(如 HTTP)进行通信的方式。

定义:微服务是一种架构风格,其中系统的组件被设计为独立部署的服务。微服务围绕定义良好的业务子域设计,并使用轻量级协议(如 HTTP)相互通信。

从前面的定义中,我们可以看出微服务可以被定义为一种架构风格,其中服务是执行一组小而明确定义的相关功能的组件。正如你在图 1.1 中所看到的,这个定义意味着微服务是围绕特定的业务子域设计和构建的,例如处理支付、发送电子邮件或处理客户的订单。

图片 1.1

图 1.1 在微服务架构中,每个服务实现特定的业务子域,并作为独立组件部署,在它自己的进程中运行。

微服务作为独立进程部署,通常在独立环境中运行,并通过定义良好的接口公开其功能。在这本书中,你将学习如何设计和构建通过 Web API 公开其功能的微服务,尽管其他类型的接口也是可能的,例如消息队列。²

1.1.2 微服务与单体

既然我们已经知道了什么是微服务,让我们看看它们与单体应用模式的比较。与微服务相反,单体是一个所有功能都作为一个单一构建部署在一起并运行在相同进程中的系统。例如,图 1.2 展示了一个包含四个服务的食品配送应用程序:支付服务、订单服务、配送服务和客户支持服务。由于应用程序被实现为单体,所有功能都一起部署。我们可以运行多个单体应用程序的实例,并让它们并行运行以提高冗余和可伸缩性,但每个进程仍然运行整个应用程序。

图片 1.2

图 1.2 在单体应用程序中,所有功能都作为一个单一构建部署到每个服务器。

定义 一个单体是一种架构模式,其中整个应用程序作为一个单一构建部署。

在某些情况下,单体是架构的正确选择。例如,当我们的代码库很小且预期不会变得很大时,我们会使用单体。³ 单体也带来了一些优势。首先,整个实现都在同一个代码库中,这使得从不同的子域访问数据和逻辑变得更容易。而且因为所有内容都在同一个进程中运行,所以很容易通过应用程序跟踪错误:你只需要在你代码的不同部分放置几个断点,你就能得到一个详细的错误发生时的画面。此外,因为所有代码都在同一个项目的范围内,当你从不同的子域消费功能时,你可以利用你最喜欢的开发编辑器的生产力功能。

然而,随着应用程序的增长和复杂化,这种类型的架构显示出局限性。当代码库增长到难以管理的程度,以及当你通过代码找到路径变得困难时,这种情况就会发生。此外,能够在同一项目内部的其他子域中重用代码通常会导致组件之间的紧密耦合。紧密耦合发生在组件依赖于另一段代码的实现细节时。

单体越大,测试它所需的时间就越长。单体中的每个部分都必须经过测试,并且随着我们向其中添加新功能,测试套件会越来越大。因此,部署变得缓慢,并鼓励开发者在同一个版本中堆积更改,这使得发布变得更加具有挑战性。因为许多更改一起发布,如果在发布中引入了新的错误,通常很难找到导致错误的特定更改并将其回滚。而且因为整个应用程序在同一个进程中运行,当你为某个组件扩展资源时,你实际上是在为整个应用程序扩展资源。简而言之,代码更改变得越来越有风险,部署变得越来越难以管理。微服务如何帮助我们解决这些问题?

微服务通过强制执行严格分离组件的边界来解决了与单体应用程序相关的一些问题。当你使用微服务实现应用程序时,每个微服务都在不同的进程中运行,通常在不同的服务器或虚拟机上,并且可以拥有完全不同的部署模型。实际上,它们可以用完全不同的编程语言编写(这并不意味着它们应该这样做!)。

由于微服务的代码库比单体应用小,并且它们的逻辑是自包含的,并且定义在特定的业务子域范围内,因此测试它们更容易,它们的测试套件运行更快。由于它们在代码级别上没有与其他平台组件的依赖关系(除了可能是一些共享库),它们的代码更清晰,重构它们也更容易。这意味着代码可以随着时间的推移变得更好,并变得更加易于维护。因此,我们可以对代码进行小幅度修改并更频繁地发布。较小的发布更容易控制,如果我们发现了一个错误,发布回滚也更容易。我想强调的是,微服务并不是万能的。正如我们将在第 1.3 节中看到的,微服务也有其局限性,并带来了一些自己的挑战。

现在我们已经了解了微服务是什么以及它们与单体应用相比如何,让我们退一步看看是什么发展导致了这种类型架构的出现。

1.1.3 当前微服务及其发展历程

在许多方面,微服务并不是什么新鲜事物。⁴ 公司在微服务概念流行之前就已经开始实施和部署独立的应用程序组件。他们只是没有称之为微服务。亚马逊的首席技术官 Werner Vogels 解释了亚马逊是如何在 21 世纪初开始尝试这种架构的。到那时,亚马逊网站的代码库已经增长成为一个没有明确架构模式的复杂系统,发布新版本和扩展系统已经变成了严重的痛点。为了解决这些问题,他们决定在代码中寻找独立的逻辑片段,并将它们分离出来成为可以独立部署的组件,并在它们前面提供一个 API。在这个过程中,他们还确定了属于这些组件的数据,并确保系统的其他部分不能通过 API 之外的方式访问数据。他们将这种新的架构类型称为面向服务的架构(vimeo.com/29719577)。Netflix 也在大规模上开创了这种架构风格,并将它称为“细粒度面向服务的架构”。⁵

术语微服务在 2010 年代初开始流行,用来描述这种类型的架构。例如,James Lewis 在 2012 年克拉科夫第 33 度会议上使用了这个概念,标题为“Micro-Services—Java, the Unix way”(vimeo.com/74452550)。2014 年,Martin Fowler 和 James Lewis 关于微服务架构特征的论文以及 Newman 有影响力的书籍《Building Microservices》的出版,使这一概念得到了巩固(martinfowler.com/articles/microservices.html)。

今天,微服务是一种广泛使用的架构风格。在技术扮演重要角色的公司中,大多数公司已经在使用微服务或正在转向其采用。对于初创公司来说,开始使用微服务方法实现其平台也是常见的。然而,微服务并不适合所有人,尽管它们带来了实质性的好处,正如我们所展示的,它们也带来了相当大的挑战,我们将在第 1.3 节中看到。

1.2 什么是 Web API?

在本节中,我们将解释 Web API。您将了解到 Web API 是更一般的应用程序编程接口(API)概念的特定实例。重要的是要理解 API 只是应用程序之上的一个层,并且存在不同类型的接口。因此,我们将从定义 API 是什么开始,然后我们将继续解释 API 如何帮助我们驱动微服务之间的集成。

1.2.1 什么是 API?

API 是一种接口,允许我们以编程方式与应用程序交互。编程接口是我们可以从代码或终端使用的那种接口,与图形界面相对,在图形界面中,我们使用用户界面与应用程序交互。应用程序接口有多种类型,例如命令行界面(CLI;允许您从终端使用应用程序的接口)、桌面 UI 接口、Web UI 接口或 Web API 接口。如图 1.3 所示,一个应用程序可以有一个或多个这些接口。

图 1.3

图 1.3 一个应用程序可以拥有多个接口,例如 Web API、CLI、Web UI 和桌面 UI。

为了说明这个概念,想想流行的客户端 URL(cURL)。cURL 是 libcurl 库的 CLI。libcurl 实现了允许我们与 URL 交互的功能,而 cURL 通过 CLI 揭示这些功能。例如,我们可以使用 cURL 向 URL 发送 GET 请求:

$ curl -L http://www.google.com

我们还可以使用带有 -O 选项的 cURL 来将 URL 的内容下载到文件中:

$ curl -O http://www.gnu.org/software/gettext/manual/gettext.html

libcurl 库位于 cURL CLI 之后,没有任何东西阻止我们通过源代码直接访问它(如果您好奇,您可以从 Github 上获取它:github.com/curl/curl)并为该应用程序构建更多类型的接口。

1.2.2 什么是 Web API?

现在我们已经了解了 API 是什么,我们将解释 Web API 的定义特征。Web API 是一种使用超文本传输协议(HTTP)传输数据的 API。HTTP 是支撑互联网的通信协议,它允许我们在网络上交换不同类型的媒体类型,如文本、图像、视频和 JSON。HTTP 使用统一资源定位符(即 URL)的概念来定位互联网上的资源,并且它具有 API 技术可以利用的功能,以增强与服务器的交互,例如请求方法(例如 GET、POST、PUT)和 HTTP 头。Web API 使用诸如 SOAP、REST、GraphQL、gRPC 等技术实现,这些技术将在附录 A 中更详细地讨论。

1.2.3 API 如何帮助我们驱动微服务集成?

微服务通过 API 相互通信,因此 API 代表了我们的微服务接口。API 使用标准协议进行文档化。API 文档告诉我们如何与微服务交互以及我们可以期望从它那里得到什么样的响应。API 文档越好,API 消费者对 API 的工作方式就越清晰。从这个意义上说,如图 1.4 所示,API 文档代表了服务之间的合同:只要客户端和服务器都遵循 API 文档,通信就会按预期进行。

图 1.4

图 1.4 API 规范代表了 API 服务器和 API 客户端之间的合同。只要客户端和服务器都遵循规范,API 集成就会正常工作。

弗劳尔和刘易斯普及了这样一个观点:集成微服务的最佳策略是通过暴露智能端点并通过哑管道进行通信(martinfowler.com/articles/microservices.html)。这个想法受到了 Unix 系统设计原则的启发,这些原则规定:

  • 一个系统应由小型、独立的组件组成,这些组件只做一件事。

  • 每个组件的输出都应该设计得易于成为另一个组件的输入。

Unix 程序通过管道相互通信,管道是简单地将消息从一个应用程序传递到另一个应用程序的机制。为了说明这个过程,请考虑以下命令链,您可以从基于 Unix 的机器(例如 Mac 或 Linux 计算机)的终端运行:

$ history | less

history命令显示了使用你的 Bash 配置文件运行的所有命令列表。命令列表可能很长,所以你可能想使用less命令分页显示history的输出。要从一个命令传递数据到另一个命令,使用管道字符(|),这指示 shell 捕获history命令的输出并将其作为less命令的输入。我们称这种类型的管道为“哑”管道,因为它的唯一任务是传递消息从一个进程到另一个进程。如图 1.5 所示,Web API 通过 HTTP 交换数据。数据传输层对我们使用的特定 API 协议一无所知,因此它代表我们的“哑”管道,而 API 本身包含处理数据的所有必要逻辑。

图 1.5

图 1.5 微服务通过数据传输层(如 TCP 上的 HTTP)进行 API 通信。

API 必须稳定,并且在其背后,你可以更改任何服务的内部实现,只要它们遵守 API 文档。这意味着 API 的消费者必须能够继续以前的方式调用 API,并且必须获得相同的响应。这导致微服务架构中的另一个重要概念:可替换性。⁶ 理念是,你应该能够完全替换端点背后的代码库,而端点以及因此服务之间的通信仍然可以工作。现在我们了解了 API 是什么以及它们如何帮助我们驱动服务之间的集成,让我们看看微服务带来的最重要的挑战。

1.3 微服务架构的挑战

如我们在 1.1.2 节中看到的,微服务带来了实质性的好处。然而,它们也带来了重大的挑战。在本节中,我们讨论微服务带来的最重要的挑战,我们将它们分为五个主要类别:

  • 有效的服务分解

  • 微服务集成测试

  • 处理服务不可用

  • 跟踪分布式事务

  • 增加的操作复杂性和基础设施开销

本节中讨论的所有问题和困难都可以通过特定的模式和策略来解决,其中一些我们在本书的后续内容中详细说明。你还会找到其他深入处理这些问题的资源的引用。这里的理念是让你意识到微服务并不是解决单体应用所呈现的所有问题的神奇疗法。

1.3.1 有效的服务分解

在设计微服务时,最关键的挑战之一是服务分解。我们必须将平台分解成松散耦合但足够独立的组件,并具有明确定义的边界。如果您发现自己每次更改另一个服务时都会更改一个服务,那么您就可以知道您在服务之间有不合理的耦合。在这种情况下,要么服务之间的合同不够弹性,要么两个组件之间有足够的依赖关系,这足以证明它们应该合并。未能将系统分解成独立的微服务可能导致 Chris Richardson,微服务模式一书的作者所说的分布式单体,这是一种将单体架构的所有问题与微服务的所有问题结合在一起的情况,而没有享受到任何一种架构的好处。在第三章中,您将学习有用的设计模式和分解策略,这些策略将帮助您将系统分解成微服务。

1.3.2 微服务集成测试

在 1.1.2 节中,我们提到微服务通常更容易测试,并且它们的测试套件通常运行得更快。然而,微服务集成测试可能运行起来要困难得多,尤其是在单个事务涉及多个微服务协作的情况下。当您的整个应用程序运行在同一个进程中时,测试不同组件之间的集成相对容易,其中大部分只需要编写良好的单元测试。在微服务环境中,为了测试多个服务之间的集成,您需要能够以类似于您的生产环境的方式进行设置来运行所有这些服务。

您可以使用不同的策略来测试微服务集成。第一步是确保每个服务都有一个良好文档化和正确实现的 API。您可以使用 Dredd 和 Schemathesis 等工具,正如您将在第十二章中学到的,来测试 API 实现是否符合 API 规范。您还必须确保 API 客户端按照 API 文档的要求准确消费 API。您可以使用 API 文档编写针对 API 客户端的单元测试,以从服务生成模拟响应。⁷ 最后,如果没有一个完整的端到端测试来运行实际的微服务并相互调用,那么上述任何测试都不足以满足要求。

1.3.3 处理服务不可用

我们必须确保我们的应用程序在面对服务不可用、连接和请求超时、错误请求等情况时具有弹性。例如,当我们通过像 Uber Eats、Delivery Hero 或 Deliveroo 这样的食品配送应用程序下单时,服务之间的一系列请求展开以处理和交付订单,任何这些请求在任何一点都可能失败。让我们从用户下单时发生的过程的宏观角度来审视这个过程(见图 1.6 以展示请求链的示意图):

  1. 客户下单并支付。订单是通过订单服务进行的,为了处理支付,订单服务与支付服务协同工作。

  2. 如果支付成功,订单服务向厨房服务发出请求以安排订单的生产。

  3. 一旦订单生产完成,厨房服务向配送服务发出请求以安排配送。

图 1.6 微服务必须能够应对服务不可用、请求超时和其他服务的处理错误等事件,要么重试请求,要么向用户返回有意义的响应。

在这个复杂的请求链中,如果参与的服务中的任何一个未能按预期响应,它可能会通过平台触发级联错误,导致订单未处理或处于不一致的状态。因此,设计微服务时,它们必须能够可靠地处理失败的端点非常重要。我们的端到端测试应该考虑这些场景,并测试我们在这些情况下的服务行为。

1.3.4 跟踪分布式事务

协作服务有时必须处理分布式事务。分布式事务是指需要两个或更多服务协作的事务。例如,在一个食品配送应用程序中,我们希望跟踪现有原料的库存,以便我们的目录可以准确地反映产品可用性。当用户下单时,我们希望更新原料库存以反映新的可用性。具体来说,我们希望在支付成功处理之后更新原料库存。正如您在图 1.7 中看到的,订单成功处理涉及以下操作:

  1. 处理支付。

  2. 如果支付成功,将订单状态更新为表示其正在处理中。

  3. 与厨房服务接口,安排订单的生产。

  4. 更新原料库存以反映其当前可用性。

图 1.7 分布式事务涉及多个服务之间的协作。如果其中任何服务失败,我们必须能够处理失败并提供对用户有意义的响应。

所有这些操作都是相关的,并且它们必须协调一致,要么全部成功,要么全部失败。我们不能在没有正确更新其状态的情况下成功完成订单,如果支付失败,我们也不应该安排其生产。我们可能希望在制作订单时更新原料的可用性,如果支付后来失败,我们想要确保回滚更新。如果所有这些操作都在同一个流程中发生,管理流程就很简单,但使用微服务时,我们必须管理各个流程的结果。当使用微服务时,挑战在于确保我们在服务之间有一个健壮的通信过程,以便我们确切知道当错误发生时是什么类型的错误,并采取适当的措施来应对。

在服务协同工作以处理某些请求的情况下,你还必须能够跟踪请求在其穿越不同服务时的周期,以便在事务过程中发现错误。为了获得分布式事务的可见性,你需要为你的微服务设置分布式日志和跟踪。你可以从 Jamie Riedesel 的《软件遥测》(Manning,2021 年)中了解更多关于这个主题的信息。

1.3.5 运营复杂性和基础设施开销增加

随着微服务的引入,另一个重要的挑战是它们给平台带来的增加的运营复杂性和运营开销。当你的整个网站后端运行在单个应用程序构建中时,你只需要部署和监控一个进程。当你有十几个微服务时,每个服务都必须进行配置、部署和管理。这包括不仅为部署服务而提供服务器,还包括日志聚合流、监控系统、警报、自恢复机制等等。正如你将在第三章中学到的,每个服务都有自己的数据库,这意味着它们也需要多个数据库设置,包括所有在规模上运行所需的功能。而且,一个新部署改变微服务的端点并不罕见,无论是 IP、基本 URL 还是通用 URL 中的特定路径,这意味着其消费者必须被告知这些变化。

当亚马逊刚开始向微服务架构转型时,他们发现开发团队大约会花费 70%的时间来管理基础设施(vimeo.com/29719577 在 07:53 处)。如果你从一开始就没有采用最佳实践进行基础设施自动化,这将是一个非常真实的风险。即使你采取了这些措施,你也可能需要花费大量时间开发定制工具来有效地管理你的服务。

1.4 介绍文档驱动开发

正如我们在 1.2.3 节中解释的,API 集成的成功取决于良好的 API 文档,在本节中,我们介绍了一种将文档置于 API 开发前沿的 API 开发工作流程。如图 1.8 所示,以文档驱动的开发是构建 API 的一种方法,它分为三个阶段:

  1. 您设计和记录 API。

  2. 按照文档说明,您构建 API 客户端和 API 服务器。

  3. 您将 API 客户端和 API 服务器都针对文档进行测试。

图片

图 1.8:以文档驱动的开发分为三个阶段:设计和文档、实施和验证。

让我们深入探讨这些要点。第一步包括设计和记录规范。我们为他人构建 API,因此在构建 API 之前,我们必须创建一个满足 API 客户端需求的 API 设计。正如我们在设计应用程序的用户界面(UI)时涉及用户一样,在设计 API 时也必须与 API 消费者进行互动。

良好的 API 设计可以提供良好的开发者体验,而良好的 API 文档有助于实现成功的 API 集成。什么是 API 文档?API 文档是根据标准接口描述语言(IDL)对 API 的描述,例如 OpenAPI 用于 REST API 和 Schema Definition Language(SDL)用于 GraphQL API。标准 IDL 拥有工具和框架的生态系统,这使得构建、测试和可视化我们的 API 变得更加容易,因此投入时间学习它们是值得的。在这本书中,您将学习如何使用 OpenAPI(第五章)和 SDL(第八章)来记录您的 API。

一旦我们完成了文档化的 API 设计,我们就进入第二阶段,这一阶段包括根据 API 文档构建 API 服务器和 API 客户端。在第二章和第六章中,您将学习如何分析 OpenAPI 规范的要求,并根据这些要求构建 API 应用程序,而在第十章中,我们将应用相同的方法来处理 GraphQL API。API 客户端开发者还可以利用 API 文档来运行 API 模拟服务器,并对其代码进行测试。⁸

最后的阶段涉及测试我们的实现与 API 文档的一致性。在第十二章中,您将学习如何使用自动化的 API 测试工具,如 Dredd 和 Schemathesis,这些工具可以为您的 API 生成一系列可靠的测试。将 Dredd 和 Schemathesis 与您的应用程序单元测试套件结合使用,将使您确信您的 API 实现按预期工作。您应该在持续集成服务器上运行这些测试,以确保不会发布任何违反 API 文档约定的代码。

通过将 API 文档置于开发过程的前端,文档驱动开发有助于你避免 API 开发者面临的最常见问题之一:客户端和服务器开发团队在 API 应该如何工作的问题上存在分歧。在没有健壮的 API 文档的情况下,开发者经常需要猜测 API 的实现细节。在这种情况下,API 很少在第一次集成测试中成功。尽管文档驱动开发不能保证你的 API 集成 100%成功,但它将显著降低 API 集成失败的风险。

1.5 介绍 CoffeeMesh 应用程序

为了说明本书中解释的概念和想法,我们将构建一个名为 CoffeeMesh 的应用程序的部分组件。CoffeeMesh 是一个虚构的应用程序,允许客户在任何地点、任何时间订购咖啡。CoffeeMesh 平台由一组实现不同功能的微服务组成,例如处理订单和安排配送。我们将在第三章中对 CoffeeMesh 平台进行正式的分析和设计。为了让你了解这本书中你将学到的东西,我们将在第二章中开始实现 CoffeeMesh 订单服务的 API。在我们结束这一章之前,我想专门用一节来解释你将从这本书中学到什么。

1.6 这本书面向的对象以及你将学到什么

为了充分利用这本书,你应该熟悉网络开发的基础知识。书中的代码示例使用 Python 编写,因此对 Python 的基本理解有益,但不是必须的,以便能够跟随示例。你不需要了解网络 API 或微服务,因为我们将深入解释这些技术。如果你熟悉网络开发中的模型-视图-控制器(MVC)模式或其变体,例如 Python 流行的 Django 框架实现的模型-模板-视图(MTV)模式,这将很有用。我们将不时将这些模式与某些概念进行比较以说明。对 Docker 和云计算的基本熟悉将有助于通过部署章节,但我会尽最大努力详细解释每个概念。

本书通过实践方法展示了如何使用 Python 开发 API 驱动的微服务。你将学习

  • 设计微服务架构的服务分解策略

  • 如何设计和使用 OpenAPI 规范来记录 REST API

  • 如何使用 FastAPI 和 Flask 等流行框架在 Python 中构建 REST API

  • 如何设计和消费 GraphQL API,以及如何使用 Python 的 Ariadne 框架构建它们

  • 如何使用基于属性的测试和 Dredd、Schemathesis 等 API 测试框架测试你的 API

  • 在你的微服务中实现松耦合的有用设计模式

  • 如何使用开放授权(OAuth)和开放 ID 连接(OIDC)为您的 API 添加身份验证和授权

  • 如何使用 Docker 和 Kubernetes 将您的微服务部署到 AWS

在这本书结束时,您将熟悉微服务架构为 Web 应用程序带来的好处,以及随之而来的挑战和困难。您将了解如何使用 API 集成微服务,您将了解如何使用标准和最佳实践构建和记录这些 API,您将准备好以清晰的应用程序边界定义 API 的领域。最后,您还将了解如何测试、部署和确保您的微服务 API 的安全性。

摘要

  • 微服务是一种架构模式,其中系统的组件被设计和构建为独立部署的服务。这导致代码库更小、更易于维护,并允许服务独立于彼此进行优化和扩展。

  • 单体架构是一种架构模式,其中整个应用程序在一个构建中部署,并在同一进程中运行。这使得应用程序更容易部署和监控,但当代码库变得很大时,部署也更具挑战性。

  • 应用程序可以有多种类型的接口,例如 UI、CLI 和 API。API 是一种允许我们从代码或终端以编程方式与应用程序交互的接口。

  • Web API 是在 Web 服务器上运行的 API,使用 HTTP 进行数据传输。我们使用 Web API 通过互联网公开服务功能。

  • 微服务通过智能端点和“哑管道”相互通信。哑管道是一种简单地将数据从一个组件传输到另一个组件的管道。对于微服务来说,HTTP 是一个很好的哑管道示例,它在不了解所使用的 API 协议的情况下,在 API 客户端和 API 服务器之间交换数据。因此,Web API 是推动微服务之间集成的一种优秀技术。

  • 尽管它们有好处,但微服务也带来了以下挑战:

    • 有效的服务分解——我们必须围绕特定的子域设计服务,以具有清晰的边界;否则,我们可能会构建一个“分布式单体”。

    • 微服务集成测试——对所有微服务进行集成测试具有挑战性,但我们可以通过确保 API 正确实现来降低集成失败的风险。

    • 处理服务不可用——协作服务容易受到服务不可用、请求超时和处理错误的影响,因此必须能够处理这些场景。

    • 跟踪分布式事务——跨多个服务跟踪错误具有挑战性,需要软件遥测工具,这些工具允许您集中日志、启用 API 可见性并跨服务跟踪请求。

    • 增加的操作复杂性和基础设施开销——每个微服务都需要自己的基础设施配置,包括服务器、监控系统以及警报,因此您需要在基础设施自动化方面投入额外的努力。

  • 以文档驱动开发是一种 API 开发工作流程,分为三个阶段:

    • 设计并记录 API。

    • 根据文档构建 API。

    • 测试 API 与文档的一致性。

    通过将 API 文档置于开发过程的前端,文档驱动开发有助于您避免 API 开发者面临的一些常见问题,从而降低 API 集成失败的风险。


¹ Sam Newman,Building Microservices(O’Reilly,2015 年),第 2 页。

² 为了全面了解可用于实现微服务之间通信的不同接口,请参阅 Chris Richardson 所著的《Microservices Patterns》(Manning,2019 年)。

³ 为了对单体架构和微服务架构的战略性架构决策进行彻底分析,请参阅 Vernon、Vaughn 和 Tomasz Jaskula 合著的《Strategic Monoliths and Microservices》(Addison-Wesley,2021 年)。

⁴ 为了更全面地分析微服务架构及其前身的历史,请参阅 Nicola Dragoni 等人撰写的“Microservices: Yesterday, Today, and Tomorrow”,载于《Present and Ulterior Software Engineering》(Springer,2017 年),第 195–216 页。

⁵ Allen Wang 和 Sudhir Tonse,"Announcing Ribbon: Tying the Netflix Mid-Tier Services Together",载于《Netflix Technology Blog》,2013 年 1 月 18 日,netflixtechblog.com/announcing-ribbon-tying-the-netflix-mid-tier-services-together-a89346910a62。关于面向服务架构(SOA)与微服务架构之间差异的精彩讨论,请参阅 Richardson 所著的《Microservices Patterns》,第 13–14 页。

⁶ Newman,Building Microservices,第 7–8 页。

⁷ 要了解更多关于 API 开发工作流程以及如何使用 API 模拟服务器构建客户端的信息,请查看我的演示文稿“API Development Workflows for Successful Integrations”,Manning API 会议,2021 年 8 月 3 日,youtu.be/SUKqmEX_uwg

⁸ 要了解 API 服务器和客户端开发者如何在软件开发过程中利用 API 文档,请查看我的演讲“Leveraging API Documentation to Deliver Reliable API Integrations”,API 规范会议,2021 年 9 月 28 日至 29 日,youtu.be/kAWvM-CVcnw

2 基本的 API 实现

本章涵盖

  • 阅读和理解 API 规范的要求

  • 将我们的应用程序结构化为数据层、应用层和接口层

  • 使用 FastAPI 实现 API 端点

  • 使用 pydantic 实现数据验证模型(模式)

  • 使用 Swagger UI 测试 API

在本章中,我们实现了订单服务的 API,这是我们在 1.5 节中介绍的 CoffeeMesh 网站的一个微服务。CoffeeMesh 是一个可以在任何时间、任何地点按需制作和配送咖啡的应用程序。订单服务允许客户通过 CoffeeMesh 下订单。在我们实现订单 API 的过程中,您将提前了解我们在本书中更详细地剖析的概念和流程。本章的代码可在本书提供的 GitHub 仓库的 ch02 文件夹中找到。

2.1 订单 API 规范的介绍

让我们从分析订单 API 的要求开始。使用订单 API,我们可以下订单、更新订单、检索它们的详细信息或取消订单。订单 API 规范可在本书 GitHub 仓库的 ch02/oas.yaml 文件中找到。OAS 代表OpenAPI 规范,这是一种用于记录 REST API 的标准格式。如图 2.1 所示,API 规范描述了一个具有四个主要 URL 路径的 REST API。

  • /orders—允许我们检索订单列表(GET)和创建订单(POST)。

  • /orders/{order_id}—允许我们检索特定订单的详细信息(GET),更新订单(PUT),以及删除订单(DELETE)。

  • /orders/{order_id}/cancel—允许我们取消订单(POST)。

  • /orders/{order_id}/pay—允许我们为订单付款(POST)。

图片

图 2.1 订单 API 公开了围绕四个 URL 路径结构的七个端点。每个端点实现不同的功能,例如下订单和取消订单。

除了记录 API 端点外,规范还包括数据模型,告诉我们通过这些端点交换的数据看起来像什么。在 OpenAPI 中,我们称这些模型为模式,您可以在订单 API 规范的组件部分找到它们。模式告诉我们必须包含哪些属性以及它们的类型。

例如,OrderItemSchema模式指定productsize属性是必需的,但quantity属性是可选的。当quantity属性从有效负载中缺失时,默认值是1。因此,我们的 API 实现必须在尝试创建订单之前强制执行有效负载中productsize属性的存在。

列表 2.1 OrderItemSchema规范

# file: oas.yaml

OrderItemSchema:
  type: object
  required:
    - product
    - size
  properties:
    product:
      type: string
    size:
      type: string
      enum:
        - small
        - medium
        - big
    quantity:
      type: integer
      default: 1
      minimum: 1

现在我们已经了解了构建订单 API 的要求,让我们看看我们将用于实施的架构布局。

2.2 订单应用的高级架构

本节提供了订单 API 架构布局的高级概述。我们的目标是确定应用程序的层,并在所有层之间强制执行清晰的边界和关注点的分离。

如图 2.2 所示,我们将组织成三层:API 层、业务层和数据层。

图片

图 2.2 为了在我们的服务不同组件之间强制执行关注点的分离,我们围绕三个层次结构化我们的代码:数据层知道如何与数据源接口;业务层实现服务的功能;接口层实现服务的 API。

这种构建应用程序的方式是对三层架构模式的改编,它将应用程序结构化为数据层、业务层和表示层。如图 2.3 所示,数据层是应用程序中知道如何持久化数据以便我们以后可以检索的部分。数据层实现了与我们的数据源接口所需的数据模型。例如,如果我们的持久化存储是 SQL 数据库,数据层中的模型将代表数据库中的表,通常需要使用对象关系映射(ORM)框架。

图片

图 2.3 当用户请求到达订单服务时,它首先由接口层进行验证。然后接口层与业务层接口以处理请求。处理完毕后,数据层将请求中包含的数据持久化。

业务层实现我们服务的功能。它控制 API 层和数据层之间的交互。对于订单服务来说,它是知道如何处理订单放置、取消或支付的部分。

服务的 API 层与业务层不同。业务层实现服务的功能,而 API 层是应用逻辑之上的适配器,它将服务的功能暴露给消费者。图 2.2 说明了服务层之间的关系,而图 2.3 说明了用户请求是如何被每一层处理的。

API 层是业务层之上的适配器。其最重要的任务是验证传入的请求并返回预期的响应。API 层与业务层通信,传递用户发送的数据,以便资源可以在服务器上被处理和持久化。API 层相当于三层架构中的表示层。既然我们已经知道了我们将如何构建我们的应用程序,那么我们就直接进入代码吧!

2.3 实现 API 端点

在本节中,你将学习如何实现订单服务的 API 层。我会向你展示如何将 API 的实现分解成逐步的步骤。在第一步中,我们将使用模拟响应生成端点的最小实现。在本章的后续部分,我们将通过添加数据验证和动态响应来增强实现。你还将了解 FastAPI 库以及如何使用它来构建 Web API。

什么是 FastAPI?

FastAPI (github.com/tiangolo/fastapi)是一个建立在 Starlette (github.com/encode/starlette)之上的 Web API 框架。Starlette 是一个高性能、轻量级、异步的服务器网关接口(ASGI)Web 框架,这意味着我们可以将我们的服务实现为一系列异步任务,以在我们的应用程序中获得性能提升。此外,FastAPI 使用 pydantic (github.com/samuelcolvin/pydantic/)进行数据验证。以下图示说明了所有这些不同技术是如何结合在一起的。

图片

Uvicorn (github.com/encode/uvicorn)是一个异步 Web 服务器,通常用于运行 Starlette 应用程序。Uvicorn 处理 HTTP 请求并将它们传递给 Starlette,Starlette 在你的应用程序中工作,当服务器收到请求时调用。FastAPI 建立在 Starlette 之上,并通过数据验证和 API 文档功能增强了 Starlette 的路由。

在我们开始实现 API 之前,我们需要为这个项目设置我们的环境。创建一个名为 ch02 的文件夹,并使用终端中的cd命令进入该文件夹。我们将使用 Pipenv 来安装和管理我们的依赖项。

关于依赖项

如果你想要确保你使用的是我在编写这本书时使用的相同依赖项,你可以从这本书的 GitHub 仓库中获取 ch02/Pipfile 和 ch02/Pipfile.lock 文件,并运行pipenv install

Pipfile描述了我们希望使用 Pipenv 创建的环境。其中包含了许多内容,例如必须用于创建环境的 Python 版本和必须用于拉取依赖项的 PyPi 仓库的 URL。Pipenv 还通过为每个集合提供特定的安装标志,使得将生产依赖项与开发依赖项分开变得更容易。例如,要安装pytest,我们运行pipenv install pytest --dev。Pipenv 还公开了允许我们轻松管理我们的虚拟环境的命令,例如使用pipenv shell激活虚拟环境或使用pipenv --rm删除虚拟环境。

Pipenv 是 Python 的一个依赖项管理工具,它确保在不同的环境中安装了相同版本的依赖项。换句话说,Pipenv 使得以确定性的方式创建环境成为可能。为了实现这一点,Pipenv 使用一个名为 Pipfile.lock 的文件,其中包含已安装的确切包版本描述。

列表 2.2 使用pipenv创建虚拟环境并安装依赖项

$ pipenv --three                     ①

$ pipenv install fastapi uvicorn     ②

$ pipenv shell                       ③

① 使用 pipenv 创建虚拟环境并设置运行时为 Python 3。

② 安装 FastAPI 和 Uvicorn。

③ 激活虚拟环境。

现在我们已经安装了依赖项,让我们构建 API。首先,复制 GitHub 仓库中 ch02/oas.yaml 下的 API 规范到我们之前创建的 ch02 文件夹中。然后创建一个名为 orders 的子文件夹,它将包含我们的 API 实现。在 orders 文件夹内,创建一个名为 app.py 的文件。再创建一个名为 orders/api 的子文件夹,并在该文件夹内创建一个名为 orders/api/api.py 的文件。此时,项目结构应该看起来像这样:

.
├── Pipfile
├── Pipfile.lock
├── oas.yaml
└── orders
    ├── api
    │   └── api.py
    └── app.py

列表 2.3 展示了如何在文件 orders/app.py 中创建 FastAPI 应用程序的实例。从 FastAPI 来的FastAPI类实例代表我们正在实现的 API。它提供了装饰器(向函数或类添加额外功能的函数),允许我们注册我们的视图函数。¹

列表 2.3 创建 FastAPI 应用程序实例

# file: orders/app.py

from fastapi import FastAPI

app = FastAPI(debug=True)      ①

from orders.api import api     ②

① 我们创建 FastAPI 类的实例。这个对象代表我们的 API 应用程序。

② 我们导入 api 模块,以便我们的视图函数可以在加载时注册。

列表 2.4 展示了我们 API 端点的最小实现。代码位于 orders/api/api.py 文件中。我们声明了一个静态order对象,并在所有端点中返回相同的数据,除了 DELETE /orders/{order_id}端点,它返回一个空响应。稍后,我们将更改实现以使用动态订单列表。FastAPI 装饰器将每个函数返回的数据转换为 HTTP 响应;它们还将我们的函数映射到服务器中的特定 URL。默认情况下,FastAPI 在我们的响应中包含 200(OK)状态码,但我们可以通过在路由装饰器中使用status_code参数来覆盖此行为,就像我们在 POST /orders和 DELETE /orders/{order_id}端点中所做的那样。

列表 2.4 订单 API 的最小实现

# file: orders/api/api.py

from datetime import datetime
from uuid import UUID

from starlette.responses import Response
from starlette import status

from orders.app import app

order = {                                                     ①
    'id': 'ff0f1355-e821-4178-9567-550dec27a373',
    'status': "delivered",
    'created': datetime.utcnow(),
    'order': [
        {
            'product': 'cappuccino',
            'size': 'medium',
            'quantity': 1
        }
    ]
}

@app.get('/orders')                                           ②
def get_orders():
    return {'orders': [orders]}

@app.post('/orders', status_code=status.HTTP_201_CREATED)     ③
def create_order():
    return order

@app.get('/orders/{order_id}')                                ④
def get_order(order_id: UUID):                                ⑤
    return order

@app.put('/orders/{order_id}')
def update_order(order_id: UUID):
    return order

@app.delete('/orders/{order_id}', status_code=status.HTTP_204_NO_CONTENT)
def delete_order(order_id: UUID):
    return Response(status_code=HTTPStatus.NO_CONTENT.value)  ⑥

@app.post('/orders/{order_id}/cancel')
def cancel_order(order_id: UUID):
    return order

@app.post('/orders/{order_id}/pay')
def pay_order(order_id: UUID):
    return order

① 我们定义一个订单对象以返回我们的响应。

② 我们为/orders URL 路径注册了一个 GET 端点。

③ 我们指定响应的状态码为 201(已创建)。

④ 我们在花括号内定义 URL 参数,例如 order_id。

⑤ 我们捕获 URL 参数作为函数参数。

⑥ 我们使用 HTTPStatus.NO_CONTENT.value 来返回一个空响应。

FastAPI 公开了以 HTTP 方法命名的装饰器,例如get()post()。我们使用这些装饰器来注册我们的 API 端点。FastAPI 的装饰器至少接受一个参数,即我们想要注册的 URL 路径。

我们的观点函数可以接受任意数量的参数。如果参数的名称与 URL 路径参数的名称匹配,FastAPI 在调用时会将路径参数从 URL 传递到我们的视图函数中。例如,如图 2.4 所示,URL /orders/{order_id} 定义了一个名为 order_id 的路径参数,相应地,我们为该 URL 路径注册的视图函数接受一个名为 order_id 的参数。如果用户导航到 URL /orders/53e80ed2-b9d6-4c3b-b549-258aaaef9533,我们的视图函数将被调用,并将 order_id 参数设置为 53e80ed2-b9d6-4c3b-b549-258aaaef9533。FastAPI 允许我们通过使用类型提示来指定 URL 路径参数的类型和格式。在列表 2.4 中,我们指定 order_id 的类型是一个通用唯一标识符(UUID)。FastAPI 将使任何不符合该格式的 order_id 调用无效。

图片

图 2.4 FastAPI 知道如何将请求映射到正确的函数,并将任何相关的参数从请求传递到函数中。在这个示例中,一个对/orders/{order_id}端点的 GET 请求,其中order_id设置为ff0f1355-e821-4178-9567-550dec27a373,被传递到get_order()函数。

FastAPI 的响应默认包含一个 200(OK)状态码,但我们可以通过在端点装饰器中设置status_code参数来改变这种行为。在列表 2.4 中,我们在 POST /orders端点中将status_code设置为 201(已创建),在 DELETE /orders/{order_id}端点中设置为 204(无内容)。有关状态码的详细解释,请参阅第四章第 4.6 节。

您现在可以从顶级orders目录执行以下命令来运行应用程序,以了解 API 的外观:

$ uvicorn orders.app:app --reload

此命令加载服务器并启用热重载。热重载会在您更改文件时重启您的服务器。在浏览器中访问 http://127.0.0.1:8000/docs URL,您将看到由 FastAPI 从我们的代码生成的 API 文档的交互式显示(见图 2.5)。这种可视化称为 Swagger UI,它是可视化 REST API 中最受欢迎的方式之一。另一种流行的可视化是 Redoc,它也由 FastAPI 支持,可通过 http://127.0.0.1:8000/redoc URL 访问。

图片

图 2.5 由 FastAPI 从我们的代码动态生成的 Swagger UI 视图。我们可以使用这个视图来测试端点的实现。

如果你点击 Swagger UI 中表示的任何端点,你将看到有关端点的附加文档。你还将看到一个“尝试一下”按钮,它给你直接从该 UI 测试端点的机会。点击该按钮,然后点击“执行”,你将得到我们包含在端点中的硬编码响应(参见图 2.6 以获得说明)。

图 2.6 要测试一个端点,点击它以展开。你将在端点描述的右上角看到一个“尝试一下”按钮。点击该按钮,然后点击“执行”按钮。这将触发对服务器的请求,你将能够看到响应。

现在我们已经有了我们 API 的基本框架,接下来我们将转向实现我们传入的有效载荷和输出的响应的验证器。下一节将指导你完成这一步骤。

2.4 使用 pydantic 实现数据验证模型

现在我们已经实现了我们 API 的 URL 路径的主要布局,我们需要添加对传入的有效载荷和我们的输出响应的验证。数据验证和序列化是 API 中的关键操作,为了成功实现 API 集成,我们需要正确处理它们。在接下来的章节中,你将学习如何为你的 API 添加强大的数据验证和序列化功能。FastAPI 使用 pydantic 进行数据验证,因此我们将从本节开始学习如何创建 pydantic 模型。

定义 序列化 是将内存中的数据结构转换为适合存储或通过网络传输的格式的过程。在 Web API 的上下文中,序列化指的是将对象转换为可以序列化为所选内容类型(如 XML 或 JSON)的数据结构的过程,并具有对对象属性的显式映射(参见图 2.7 以获得说明)。

图 2.7 要从 Python 对象构建响应有效载荷,我们首先将对象序列化为可序列化的数据结构,并在对象和新的结构之间显式映射属性。反序列化有效载荷将给我们回一个与序列化时相同的对象。

订单 API 规范包含三个模式:CreateOrderSchemaGetOrderSchemaOrderItemSchema。让我们分析这些模式,以确保我们理解我们需要如何实现我们的验证模型。

列表 2.5 订单 API 模式的规范

# file: oas.yaml

components:
  schemas:
    OrderItemSchema:
      type: object                                        ①
      required:                                           ②
        - product
        - size
      properties:                                         ③
        product:
          type: string
        size:
          type: string
          enum:                                           ④
            - small
            - medium
            - big
        quantity:
          type: integer
          default: 1                                      ⑤
          minimum: 1                                      ⑥

    CreateOrderSchema:
      type: object
      required:
        - order
      properties:
        order:
          type: array
          items:                                          ⑦
            $ref: '#/components/schemas/OrderItemSchema'  ⑧

    GetOrderSchema:
      type: object
      required:
        - order
        - id
        - created
        - status
      properties:
        id:
          type: string
          format: uuid
        created:
          type: string
          format: date-time
        status:
          type: string
          enum:
            - created
            - progress
            - cancelled
            - dispatched
            - delivered
        order:
          type: array
          items:
            $ref: '#/components/schemas/OrderItemSchema'

① 每个模式都有一个类型,在这个例子中是一个对象。

② 我们在 required 关键字下列出强制属性。

③ 我们在 properties 关键字下列出对象属性。

④ 我们使用枚举来约束属性的值。

⑤ 属性可以有一个默认值。

⑥ 我们还可以指定属性的最低值。

⑦ 我们使用 items 关键字指定数组中项的类型。

⑧ 我们使用 JSON 指针来引用同一文档内的另一个模式。

当我们从服务器返回订单详情时使用GetOrderSchema,而当验证客户放置的订单时使用CreateOrderSchema。图 2.8 说明了CreateOrderSchema的数据验证流程。如图所示,CreateOrderSchema只要求有效载荷中存在一个属性:order属性,它是一个对象数组,其规范由OrderItemSchema定义。OrderItemSchema有两个必需属性,productsize,以及一个可选属性quantity,其默认值为1。这意味着在处理请求有效载荷时,我们必须检查有效载荷中是否存在productsize属性,并且它们具有正确的类型。图 2.8 显示了当有效载荷中缺少quantity属性时会发生什么。在这种情况下,我们在服务器中将该属性设置为默认值1

图片

图 2.8 CreateOrderSchema模型对请求有效载荷的数据验证流程。该图显示了请求有效载荷的每个属性是如何与模式中定义的属性进行验证的,以及我们是如何从验证结果构建对象的。

现在我们已经理解了我们的 API 模式,是时候实现它们了。创建一个名为orders/api/schemas.py的新文件。该文件将包含我们的 pydantic 模型。列表 2.6 显示了如何使用 pydantic 实现CreateOrderSchemaGetOrderSchemaOrderItemSchema。列表 2.6 中的代码位于orders/api/schemas.py模块中。我们定义每个模式为一个继承自 pydantic 的BaseModel类的类,并使用 Python 类型提示指定每个属性的类型。对于只能取有限值集合的属性,我们定义一个枚举类。在这种情况下,我们为sizestatus属性定义了枚举。我们将OrderItemSchemaquantity属性的类型设置为 pydantic 的conint类型,该类型强制执行整数值。我们还指定quantity是一个可选属性,其值应等于或大于 1,并为其提供一个默认值 1。最后,我们使用 pydantic 的conlist类型将CreateOrderSchemaorder属性定义为至少包含一个元素的列表。

列表 2.6 使用 pydantic 实现验证模型

# file: orders/api/schemas.py

from enum import Enum
from typing import List
from uuid import UUID

from pydantic import BaseModel, Field, conlist, conint

class Size(Enum):                                       ①
    small = 'small'
    medium = 'medium'
    big = 'big'

class Status(Enum):
    created = 'created'
    progress = 'progress'
    cancelled = 'cancelled'
    dispatched = 'dispatched'
    delivered = 'delivered'

class OrderItemSchema(BaseModel):                       ②
    product: str                                        ③
    size: Size                                          ④
    quantity: Optional[conint(ge=1, strict=True)] = 1   ⑤

class CreateOrderSchema(BaseModel):
    order:  conlist(OrderItemSchema, min_items=1)       ⑥

class GetOrderSchema(CreateOrderSchema):
    id: UUID
    created: datetime
    status: Status

class GetOrdersSchema(BaseModel):
    orders: List[GetOrderSchema]

① 我们声明一个枚举模式。

② 每个 pydantic 模型都继承自 pydantic 的 BaseModel。

③ 我们使用 Python 类型提示来指定属性的类型。

④ 我们通过将属性的类型设置为枚举来约束属性的值。

⑤ 我们指定数量的最小值,并为其提供一个默认值。

⑥ 我们使用 pydantic 的 conlist 类型来定义至少包含一个元素的列表。

现在我们已经实现了验证模型,在接下来的部分中,我们将它们与 API 链接起来以验证和打包有效载荷。

2.5 使用 pydantic 验证请求有效载荷

在本节中,我们使用在第 2.4 节中实现的模型来验证请求负载。我们如何在视图函数中访问请求负载?我们通过将它们声明为视图函数的参数来拦截请求负载,并通过将它们的类型设置为相关的 pydantic 模型来验证它们。

列表 2.7 将验证模型与 API 端点连接

# file: orders/api/api.py

from uuid import UUID

from starlette.responses import Response
from starlette import status

from orders.app import app
from orders.api.schemas import CreateOrderSchema         ①

...

@app.post('/orders', status_code=status.HTTP_201_CREATED)
def create_order(order_details: CreateOrderSchema):      ②
    return order

@app.get('/orders/{order_id}')
def get_order(order_id: UUID):
    return order

@app.put('/orders/{order_id}')
def update_order(order_id: UUID, order_details: CreateOrderSchema):
    return order

...

① 我们导入 pydantic 模型,以便可以使用它们进行验证。

② 我们通过在函数中声明它作为参数来拦截负载,并使用类型提示来验证它。

如果你保持应用程序运行,更改将自动由服务器加载,因此你只需刷新浏览器即可更新 UI。如果你点击/orders URL 路径的 POST 端点,你会看到 UI 现在为你提供了一个服务器期望的负载示例。现在,如果你尝试编辑负载以删除任何必需的字段,例如product字段,并将其发送到服务器,你将收到以下错误信息:

{
  "detail": [
    {
      "loc": [
        "body",
        "order",
        0,
        "product"
      ],
      "msg": "field required",
      "type": "value_error.missing"
    }
  ]
}

FastAPI 生成了一个错误信息,该信息指向了负载中错误发生的位置。错误信息使用 JSON 指针来指示问题所在。JSON 指针是一种语法,允许你在 JSON 文档中表示特定值的路径。如果你第一次遇到 JSON 指针,可以将它们视为在 Python 中表示字典语法和索引记法的一种不同方式。例如,错误信息"loc: /body/order/0/product"大致等同于 Python 中的以下表示:loc['body']['order'][0]['product']。图 2.9 展示了如何从错误信息中解释 JSON 指针,以确定负载中问题的来源。

图 2.9

图 2.9 当请求因负载格式错误而失败时,我们收到一个包含错误信息的响应。错误信息使用 JSON 指针告诉我们错误的位置。在这种情况下,错误信息指出,属性/body/order/0/product在负载中缺失。

你也可以更改负载,使其不缺少必需的属性,而是包含size属性的非法值:

{
  "order": [
    {
      "product": "string",
      "size": "somethingelse"
    }
  ]
}

在这种情况下,你也会收到以下信息的错误信息:"value is not a valid enumeration member; permitted: 'small', 'medium', 'big'"。如果我们对负载中的内容输入错误会发生什么?例如,想象一个客户端向服务器发送以下负载:

{
  "order": [
    {
      "product": "string",
      "size": "small",
      "quantit": 5
    }
  ]
}

在这种情况下,FastAPI 假设quantity属性缺失,并且客户端希望将其值设置为1。这种结果可能会导致客户端和服务器之间的混淆,在这种情况下,通过验证非法属性来使 API 集成更加可靠。在第六章中,你将学习如何处理这些情况。

带有可选属性的边缘情况,例如OrderItemSchemaquantity,是 pydantic 假设它们是可空的,因此会接受将quantity设置为null的有效负载。例如,如果我们向 POST /orders端点发送以下有效负载,我们的服务器将接受它:

{
  "order": [
    {
      "product": "string",
      "size": "small",
      "quantity": null
    }
  ]
}

在 API 集成方面,可选的并不完全等同于可空的:一个属性可以是可选的,因为它有一个默认值,但这并不意味着它可以设置为null。为了在 pydantic 中强制正确的行为,我们需要包含一个额外的验证规则,防止用户将quantity的值设置为null。我们使用 pydantic 的validator()装饰器为我们的模型定义额外的验证规则。

列表 2.8 为 pydantic 模型包含额外的验证规则

# file: orders/api/schemas.py

from datetime import datetime
from enum import Enum
from typing import List, Optional
from uuid import UUID

from pydantic import BaseModel, conint, validator

...

class OrderItemSchema(BaseModel):
    product: str
    size: Size
    quantity: Optional[conint(ge=1, strict=True)] = 1

    @validator('quantity')
    def quantity_non_nullable(cls, value):
        assert value is not None, 'quantity may not be None'
        return value
...

现在我们知道了如何使用 Swagger UI 测试我们的 API 实现,让我们看看我们如何使用 pydantic 来验证和序列化我们的 API 响应。

2.6 使用 pydantic 序列化和验证响应有效负载

在本节中,我们将使用第 2.4 节中实现的 pydantic 模型来序列化和验证我们的 API 的响应有效负载。格式错误的有效负载是 API 集成失败的最常见原因之一,因此这一步对于提供健壮的 API 至关重要。例如,POST /orders端点响应有效负载的模式是GetOrderSchema,它要求存在idcreatedstatusorder字段。API 客户端将期望响应有效负载中存在所有这些字段,如果任何字段缺失或类型或格式不正确,将引发错误。

注意:格式错误的有效负载是 API 集成失败的一个常见原因。你可以在它们离开服务器之前验证你的响应有效负载来避免这个问题。在 FastAPI 中,这可以通过设置路由装饰器的response_model参数轻松完成。

列表 2.9 展示了我们如何使用 pydantic 模型来验证 GET /orders和 POST /orders端点的响应。正如你所见,我们在 FastAPI 的路由装饰器中将response_model参数设置为一个 pydantic 模型。我们遵循相同的方法来验证所有其他端点的响应,除了返回空响应的 DELETE /orders/{order_id}端点。你可以自由地查看 GitHub 仓库中这本书的完整实现。

列表 2.9 在 API 端点中挂钩验证模型以响应

# file: orders/api/api.py

from uuid import UUID

from starlette.responses import Response
from starlette import status

from orders.app import app
from orders.api.schemas import (
    GetOrderSchema,
    CreateOrderSchema,
    GetOrdersSchema,
)

...

@app.get('/orders', response_model=GetOrdersSchema)
def get_orders():
    return [
        order
    ]

@app.post(
    '/orders',
    status_code=status.HTTP_201_CREATED,
    response_model=GetOrderSchema,
)

def create_order(order_details: CreateOrderSchema):
    return order

现在我们有了响应模型,如果响应有效负载中缺少必需的属性,FastAPI 将引发错误。它还会删除不属于模式的任何属性,并尝试将每个属性转换为正确的类型。让我们看看这个行为是如何工作的。

在浏览器中,访问 http://127.0.0.1:8000/docs URL 以加载我们的 API 的 Swagger UI。然后转到 GET /orders 端点 并发送请求。你会得到位于 orders/api/api.py 文件顶部的硬编码订单。让我们对那个有效载荷进行一些修改,以查看 FastAPI 如何处理它们。首先,让我们添加一个额外的属性 updated

# orders/api/api.py
...

order = {
    'id': 'ff0f1355-e821-4178-9567-550dec27a373',
    'status': 'delivered',
    'created': datetime.utcnow(),
    'updated': datetime.utcnow(),
    'order': [
        {
            'product': 'cappuccino',
            'size': 'medium',
            'quantity': 1
        }
    ]
}

...

如果我们再次调用 GET /orders 端点,我们将获得之前获得相同的响应,但没有 updated 属性,因为它不是 GetOrderSchema 模型的一部分:

[
  {
    "order": [
      {
        "product": "cappuccino",
        "size": "medium",
        "quantity": 1
      }
    ],
    "id": "ff0f1355-e821-4178-9567-550dec27a373",
    "created": datetime.utcnow(),
    "status": "delivered"
  }
]

现在让我们从订单有效载荷中删除 created 属性,并再次调用 GET /orders 端点:

# orders/api/api.py
...

order = {
    'id': 'ff0f1355-e821-4178-9567-550dec27a373',
    'status': "delivered",
    'updated': datetime.utcnow(),
    'order': [
        {
            'product': 'cappuccino',
            'size': 'medium',
            'quantity': 1
        }
    ]
}

这次,FastAPI 抛出一个服务器错误,告诉我们必需的 created 属性在有效载荷中缺失:

pydantic.error_wrappers.ValidationError: 1 validation error for GetOrderSchema
response -> 0 -> created
  field required (type=value_error.missing)

现在让我们将 created 属性的值更改为一个随机字符串,并再次对 GET /orders 端点发起请求:

# orders/api/api.py
...

order = {
    'id': 'ff0f1355-e821-4178-9567-550dec27a373',
    'status': "delivered",
    'created': 'asdf',
    'updated': 1740493905,
    'order': [
        {
            'product': 'cappuccino',
            'size': 'medium',
            'quantity': 1
        }
    ]
}

...

在这种情况下,FastAPI 会抛出一个有用的错误:

pydantic.error_wrappers.ValidationError: 1 validation error for GetOrderSchema
response -> 0 -> created
  value is not a valid integer (type=type_error.integer)

我们的反应正在被正确验证和打包。现在让我们为应用程序添加一个简单的状态管理机制,这样我们就可以通过 API 放置订单并更改它们的状态。

2.7 向 API 添加内存订单列表

到目前为止,我们的 API 实现已经返回了相同的响应对象。让我们通过添加一个简单的内存订单集合来改变这一点,以管理应用程序的状态。为了简化实现,我们将订单集合表示为 Python 列表。我们将在 API 层的视图函数中管理该列表。在第七章中,你将学习到向应用程序添加健壮的控制器和数据持久化层的实用模式。

列表 2.10 展示了在 api.py 中管理视图函数中内存订单列表所需的更改。列表 2.9 中的更改涉及 orders/api/api.py 文件。我们将订单集合表示为 Python 列表,并将其分配给变量 ORDERS。为了简化,我们将每个订单的详细信息存储为字典,并通过更改字典中的属性来更新它们。

列表 2.10 使用内存列表管理应用程序的状态

# file: orders/api/api.py

import time
import uuid
from datetime import datetime
from uuid import UUID

from fastapi import HTTPException
from starlette.responses import Response
from starlette import status

from orders.app import app
from orders.api.schemas import GetOrderSchema, CreateOrderSchema

ORDERS = []                                              ①

@app.get('/orders', response_model=GetOrdersSchema)
def get_orders():
    return ORDERS                                        ②

@app.post(
    '/orders',
    status_code=status.HTTP_201_CREATED,
    response_model=GetOrderSchema,
)
def create_order(order_details: CreateOrderSchema):
    order = order_details.dict()                         ③
    order['id'] = uuid.uuid4()                           ④
    order['created'] = datetime.utcnow()
    order['status'] = 'created'
    ORDERS.append(order)                                 ⑤
    return order                                         ⑥

@app.get('/orders/{order_id}', response_model=GetOrderSchema)
def get_order(order_id: UUID):
    for order in ORDERS:                                 ⑦
        if order['id'] == order_id:
            return order
    raise HTTPException(                                 ⑧
        status_code=404, detail=f'Order with ID {order_id} not found'
    )

@app.put('/orders/{order_id}', response_model=GetOrderSchema)
def update_order(order_id: UUID, order_details: CreateOrderSchema):
    for order in ORDERS:
        if order['id'] == order_id:
            order.update(order_details.dict())
            return order
    raise HTTPException(
        status_code=404, detail=f'Order with ID {order_id} not found'
    )

@app.delete(
    '/orders/{order_id}',
    status_code=status.HTTP_204_NO_CONTENT,
    response_class=Response,
)
def delete_order(order_id: UUID):
    for index, order in enumerate(ORDERS):               ⑨
        if order['id'] == order_id:
            ORDERS.pop(index)
            return Response(status_code=HTTPStatus.NO_CONTENT.value)
    raise HTTPException(
        status_code=404, detail=f'Order with ID {order_id} not found'
    )

@app.post('/orders/{order_id}/cancel', response_model=GetOrderSchema)
def cancel_order(order_id: UUID):
    for order in ORDERS:
        if order['id'] == order_id:
            order['status'] = 'cancelled'
            return order
    raise HTTPException(
        status_code=404, detail=f'Order with ID {order_id} not found'
    )

@app.post('/orders/{order_id}/pay', response_model=GetOrderSchema)
def pay_order(order_id: UUID):
    for order in ORDERS:
        if order['id'] == order_id:
            order['status'] = 'progress'
            return order
    raise HTTPException(
        status_code=404, detail=f'Order with ID {order_id} not found'
    )

① 我们将内存中的订单列表表示为 Python 列表。

② 为了返回订单列表,我们只需返回 ORDERS 列表。

③ 我们将每个订单转换为字典。

④ 我们通过服务器端属性(如 ID)丰富订单对象。

⑤ 为了创建订单,我们将它添加到列表中。

⑥ 在将订单追加到列表后,我们返回它。

⑦ 为了通过 ID 查找订单,我们遍历 ORDERS 列表并检查它们的 ID。

⑧ 如果找不到订单,我们将抛出一个 HTTPException,将 status_code 设置为 404,以返回 404 响应。

⑨ 我们使用 list.pop() 方法从列表中删除订单。

如果你尝试操作 /orders 端点,你将能够创建新的订单,并且使用它们的 ID,你可以通过访问 /orders/{order_id} 端点来更新它们。在 /orders/{order_id} URL 路径下的每个端点,我们都会检查 API 客户端请求的订单是否存在,如果不存在,我们将返回一个包含有用信息的 404(未找到)响应。

我们现在能够使用订单 API 来创建订单、更新它们、支付它们、取消它们以及获取它们的详细信息。你已经为微服务应用程序实现了一个完全工作的 Web API!你已经熟悉了构建 Web API 的一堆新库,并且看到了如何为你的 API 添加健壮的数据验证。你还学会了如何将它们全部组合起来并成功运行。希望这一章能够激发你对设计和构建暴露 Web API 的微服务的兴趣和热情。在接下来的章节中,我们将更深入地探讨这些主题,你将学习如何构建和交付健壮且安全的微服务 API 集成。

摘要

  • 为了将微服务结构化为模块化层,我们使用了一种三层架构模式的变体:

    • 一个知道如何与数据源接口的数据层

    • 一个实现服务功能的业务层

    • 一个接口或表示层,通过 API 公开服务的功能

  • FastAPI 是一个流行的用于构建 Web API 的框架。它性能卓越,并且拥有丰富的库生态系统,这使得构建 API 更加容易。

  • FastAPI 使用 pydantic,这是一个流行的 Python 数据验证库。Pydantic 使用类型提示来创建验证规则,这导致模型干净且易于理解。

  • FastAPI 会从我们的代码中动态生成 Swagger UI。Swagger UI 是一个流行的 API 交互式可视化 UI。使用 Swagger UI,我们可以轻松地测试我们的实现是否正确。


¹ 对于装饰器模式的经典解释,请参阅 Erich Gamma 等人所著的《设计模式》(Addison-Wesley,1995 年),第 175-184 页。对于装饰器的更 Pythonic 介绍,请参阅 Luciano Ramalho 所著的《Fluent Python》(O’Reilly,2015 年),第 189-222 页。

3 设计微服务

本章涵盖

  • 微服务设计原则

  • 按业务能力进行服务分解

  • 按子域进行服务分解

当我们设计一个微服务平台时,我们面临的首要问题是,“如何将一个系统分解成微服务?如何决定一个服务在哪里结束,另一个服务在哪里开始?”换句话说,如何定义微服务之间的边界?在本章中,你将学会如何回答这些问题,以及如何通过应用一系列设计原则来评估微服务架构的质量。

将系统分解成微服务的过程称为服务分解。服务分解是我们微服务设计中的一个基本步骤,因为它帮助我们定义具有明确边界、定义良好的范围和明确责任的应用程序。一个设计良好的微服务架构对于降低分布式单体风险至关重要。在本章中,你将学习两种服务分解策略:按业务能力分解和按子域分解。我们将了解这些方法是如何工作的,并使用一个实际例子来学习如何应用它们。在我们深入服务分解策略之前,我们介绍了一个项目,它将指导本章以及本书其余部分的所有示例:CoffeeMesh。

3.1 介绍 CoffeeMesh

CoffeeMesh 是一家虚构的公司,允许客户订购各种咖啡衍生产品,包括饮料和糕点。CoffeeMesh 有一个使命:无论客户在哪里,无论他们何时下单,都能按需制作并交付世界上最好的咖啡。CoffeeMesh 拥有的生产工厂形成了一个密集的网络,一个跨越几个国家的咖啡生产单元网。咖啡生产是完全自动化的,配送由 24/7 运营的无人机群执行。

当客户通过 CoffeeMesh 网站下单时,所订购的商品将按需生产。一个算法根据可用库存、工厂正在处理的待处理订单数量以及距离客户的距离,确定哪个工厂是最适合生产每个商品的地方。一旦商品生产出来,它们将立即派送给客户。CoffeeMesh 的使命宣言之一是确保客户收到的每一件商品都是新鲜且热腾腾的。

现在我们有一个例子可以操作,让我们看看我们是如何为 CoffeeMesh 平台设计微服务架构的。在我们学习应用微服务的服务分解策略之前,下一节将教你三个将指导我们设计的原则。

3.2 微服务设计原则

什么使一个微服务设计得很好?正如我们在第一章中确立的,微服务是围绕定义良好的业务子域设计的,它们有明确的应用边界,并且通过轻量级协议相互通信。这在实践中意味着什么?在本节中,我们探讨三个设计原则,帮助我们测试我们的微服务是否设计正确:

  • 每服务数据库原则

  • 松耦合原则

  • 单一职责原则(SRP)

遵循这些原则将帮助您避免构建分布式单体架构的风险。在接下来的几节中,我们将评估我们的架构设计是否符合这些原则,并且它们帮助我们发现设计中的错误。

3.2.1 每服务数据库原则

每服务数据库原则指出,每个微服务拥有特定的一组数据,并且除了通过 API 之外,没有其他服务应该访问这样的数据。尽管这个模式的名称是“每服务数据库”,但这并不意味着每个微服务都应该连接到一个完全不同的数据库。它可能是 SQL 数据库中的不同表或 NoSQL 数据库中的不同集合。这个模式的目的在于确保特定服务拥有的数据不会被其他服务直接访问。

图 3.1 展示了微服务如何共享它们的数据。在图中,订单服务计算客户订单的价格。为了计算价格,订单服务需要订单中每个项目的价格,这些价格在产品数据库中可用。它还需要知道用户是否有适用的折扣,这可以在用户数据库中检查。然而,订单服务不是直接访问这两个数据库,而是从产品和用户服务请求这些数据。

图片

图 3.1 每个微服务都有自己的数据库,访问其他服务的数据库通过 API 进行。

为什么这个原则很重要?将数据访问封装在服务背后,使我们能够为服务设计最优的数据模型。这也允许我们在不破坏其他服务代码的情况下对数据库进行更改。如果图 3.1 中的订单服务直接访问产品数据库,该数据库的架构更改将需要更新产品和订单服务。这样,我们就会将订单服务的代码耦合到产品数据库上,因此我们就会破坏我们在下一节讨论的松耦合原则。

3.2.2 松耦合原则

耦合 指出我们必须设计具有明确关注点分离的服务。松耦合的服务不依赖于其他服务的实现细节。这在实践中意味着什么?这个原则有两个实际的影响:

  • 每个服务都可以独立于其他服务工作。如果我们有一个服务不能在不调用另一个服务的情况下完成单个请求,那么这两个服务之间没有明确的职责分离,它们应该属于一起。

  • 每个服务都可以在不影响其他服务的情况下进行更新。如果对服务的更改需要更新其他服务,那么这些服务之间存在紧密耦合,因此它们需要被重新设计。

图 3.2 展示了一个销售预测服务,该服务知道如何根据历史数据计算预测。它还展示了一个拥有历史销售数据的历史数据服务。为了计算预测,销售预测服务通过 API 调用历史数据服务以获取历史数据。在这种情况下,销售预测服务不能在不调用历史数据服务的情况下处理任何请求,因此这两个服务之间存在紧密耦合。解决方案是重新设计这两个服务,使它们不相互依赖,或者将它们合并为一个单一的服务。

图 3.2 当一个服务不能在不调用另一个服务的情况下处理单个请求时,我们说这两个服务是紧密耦合的。

3.2.3 单一职责原则

SRP 原则指出,我们必须设计具有少量职责的组件,理想情况下只有一个职责。当应用于微服务架构设计时,这意味着我们应该努力围绕单一业务能力或子域来设计服务。在接下来的章节中,你将学习如何通过业务能力和子域来分解服务。如果你遵循任何这些方法,你将能够设计遵循 SRP 的微服务。

3.3 通过业务能力进行服务分解

当使用业务能力分解时,我们研究业务执行的活动以及业务如何组织自己来完成这些活动。然后我们设计反映业务组织结构的微服务。例如,如果业务有一个客户管理团队,我们构建一个客户管理服务;如果业务有一个索赔管理团队,我们构建一个索赔管理服务;对于一个厨房团队,我们构建相应的厨房服务;等等。对于围绕产品构建的业务,我们可能对每个产品都有一个微服务。例如,一个生产宠物食品的公司可能有一个专门制作狗粮的团队,另一个专门制作猫粮的团队,另一个专门制作龟粮的团队,等等。在这种情况下,我们为这些团队中的每一个构建微服务。

如图 3.3 所示,通过业务能力分解通常会导致一个将每个业务团队映射到微服务的架构。让我们看看我们如何将这种方法应用于 CoffeeMesh 平台。

图 3.3 通过业务能力进行服务分解,我们在微服务架构中反映了业务的结构。

3.3.1 分析 CoffeeMesh 的业务结构

要应用按业务能力分解,我们需要分析业务的结构和组织。让我们为 CoffeeMesh 进行此分析。通过 CoffeeMesh 网站,客户可以订购由产品团队管理的目录中的不同类型的咖啡相关产品,该团队负责创建新产品。产品和配料的可供性取决于订单时 CoffeeMesh 的配料库存,这由库存团队负责。

一个销售团队致力于通过 CoffeeMesh 网站改善产品订购体验。他们的目标是最大化销售额,并确保客户对他们的体验感到满意,并希望再次光临。一个财务团队确保公司盈利,并负责处理客户支付和退款的财务基础设施。

一旦用户下单,厨房就会获取其详情以开始生产。厨房工作完全自动化,一个由工程师和厨师组成的专门团队称为厨房团队,负责监控厨房操作以确保生产过程中没有发生故障。当订单准备好交付时,无人机将其取走并飞往客户处。一个由工程师组成的专门团队称为配送团队,负责监控此过程以确保配送过程的运营卓越。

这完成了我们对 CoffeeMesh 组织结构的分析。我们现在可以根据这个分析设计基于微服务的架构。

3.3.2 按业务能力分解微服务

要按业务能力分解服务,我们将每个业务团队映射到一个微服务。根据 3.3.1 节的分析,我们可以将以下业务团队映射到微服务:

  • 产品团队对应产品服务—此服务拥有 CoffeeMesh 产品目录数据。产品团队使用此服务通过服务界面添加新产品或更新现有产品来维护 CoffeeMesh 的目录。

  • 配料团队对应配料服务—此服务拥有关于 CoffeeMesh 配料库存的资料。配料团队使用此服务来确保配料数据库与 CoffeeMesh 仓库保持同步。

  • 销售团队对应销售服务—此服务引导客户完成订购旅程并跟踪订单。销售团队拥有关于客户订单的资料,并管理每个订单的生命周期。它从该服务收集数据以分析和改进客户旅程。

  • 财务团队对应财务服务—此服务实现支付处理器,并拥有关于用户支付详情和支付历史的资料。财务团队使用此服务来确保公司账户保持最新,并确保支付正确无误。

  • 厨房团队对应厨房服务—此服务将订单发送到自动化厨房系统,并跟踪其进度。它拥有厨房生产订单的数据。厨房团队从该服务收集数据以监控自动化厨房系统的性能。

  • 交付团队对应交付服务—此服务在厨房生产订单后,负责将其交付给客户。此服务知道如何将用户位置转换为坐标,以及如何计算到达该目的地的最佳路线。它拥有 CoffeeMesh 所有交付的数据。交付团队从该服务收集数据以监控自动化交付系统的性能。

在这个微服务架构中,我们根据它所代表的业务结构命名了每个服务。我们这样做是为了方便这个例子,但并不一定必须这样。例如,财务服务可以被重命名为支付服务,因为所有与该服务的用户交互都将与其支付相关。

根据业务能力进行分解,给我们提供了一个每个服务都映射到业务团队的架构。这个结果是否与我们在 3.2 节学到的微服务设计原则一致?让我们看看这个问题。

从前面的分析中可以看出,每个服务都拥有自己的数据:产品服务拥有产品数据,成分服务拥有成分数据,等等。单一职责原则(SRP)也适用,因为每个服务都限制在一个业务领域:财务服务仅处理支付,交付服务仅管理交付,等等。

然而,如图 3.4 所示,这个解决方案并不满足松耦合原则。为了服务 CoffeeMesh 目录,产品服务需要确定每个产品的可用性,这取决于成分的可用库存。由于成分库存数据由成分服务拥有,产品服务需要为每个产品向成分服务发起一次 API 调用。

图片

图 3.4 为了确定产品是否可用,产品服务通过成分服务检查成分的库存。

产品服务和成分服务之间存在高度耦合,因此这两个业务能力应该在同一服务中实现。图 3.5 显示了使用业务能力分解策略的 CoffeeMesh 微服务架构的最终布局。

图片

图 3.5 当我们根据业务能力分解服务时,我们将每个团队映射到一个服务。

现在我们知道了如何根据业务能力分解服务,让我们看看子域分解是如何工作的。

3.4 子域服务分解

通过子域进行分解是一种从领域驱动设计(DDD)领域汲取灵感的方法——一种侧重于使用与业务用户相同的语言,用软件对业务的过程和流程进行建模的软件开发方法。当应用于微服务平台的设计时,DDD 帮助我们定义每个服务的核心责任及其边界。

3.4.1 什么是领域驱动设计?

领域驱动设计(DDD)是一种软件方法,它侧重于通过使用与业务用户相同的语言,用软件对业务用户的过程和流程进行建模。DDD 的方法在埃里克·埃文斯(Eric Evans)有影响力的书籍《领域驱动设计》(Domain-Driven Design,Addison-Wesley,2003 年出版),也被称为“大蓝书”中得到了最好的描述。DDD 提供了一种软件开发方法,试图尽可能准确地反映企业或软件的最终用户用来指代其过程和流程的思想和语言。为了实现这种一致性,DDD 鼓励开发者创建一种严格、基于模型的、软件开发者可以与最终用户共享的语言。这种语言不能有歧义,被称为通用语言

要创建一种通用语言,我们必须确定企业的核心领域,这对应于组织为了生成价值而执行的主要活动。对于一个物流公司来说,这可能是在全球范围内运输产品。对于一个电子商务公司来说,这可能是在销售产品。对于一个社交媒体平台来说,这可能是在向用户提供相关内容。对于一个约会应用来说,这可能是在匹配用户。对于 CoffeeMesh 来说,核心领域是以尽可能快的速度将高品质咖啡送到客户手中,无论他们的位置在哪里。

核心领域通常不足以涵盖企业活动的所有领域,因此领域驱动设计(DDD)还区分了支持性子域和通用子域。一个支持性子域代表与企业价值生成不直接相关,但对其基本支持的业务领域。对于一个物流公司来说,这可能包括为运输产品的用户提供客户支持、租赁设备、管理与其他企业的合作关系等等。对于一个电子商务公司来说,这可能包括市场营销、客户支持、仓储等等。

核心领域为你提供了一个问题空间的定义:你试图用软件解决的问题。解决方案包括一个模型,在这里被理解为描述领域并解决问题的抽象系统。理想情况下,只有一个通用模型为问题提供了一个解决方案空间,并具有明确定义的通用语言。然而,在实践中,大多数问题都足够复杂,需要不同模型之间的协作,每个模型都有自己的通用语言。我们将定义此类模型的过程称为战略****设计

3.4.2 将战略分析应用于 CoffeeMesh

DDD 在实践中是如何工作的?我们如何将其应用于将 CoffeeMesh 分解为子域?为了将系统分解为子域,考虑系统为了实现其目标必须执行的操作是有帮助的。在 CoffeeMesh 中,我们希望模拟下单并交付给顾客的过程。如图 3.6 所示,我们将此过程分解为八个步骤:

  1. 当顾客登录网站时,我们向他们展示产品目录。每个产品都被标记为可用或不可用。顾客可以通过可用性过滤列表,并按价格排序(从低到高和从高到低)。

  2. 顾客选择产品。

  3. 顾客为他们的订单付款。

  4. 一旦顾客付款,我们就将订单的详细信息传递给厨房。

  5. 厨房接单并制作产品。

  6. 顾客监控他们订单的进度。

  7. 一旦订单准备就绪,我们就安排其配送。

  8. 顾客跟踪无人机的行程,直到他们的订单被送达。

图片

图 3.6 要下单,顾客登录 CoffeeMesh 网站,从产品目录中选择商品,并为订单付款。付款后,我们将订单的详细信息传递给厨房,厨房在顾客监控其进度的同时制作产品。最后,我们安排订单的配送。

让我们将每个步骤映射到其相应的子域(见图 3.7 对此分析的分析表示)。第一步代表一个为 CoffeeMesh 产品目录服务的子域。我们可以称之为产品子域。此子域告诉我们哪些产品可用,哪些不可用。为此,产品子域跟踪每种产品和成分的库存量。

图片

图 3.7 我们将订单放置和配送过程中的每一步映射到一个子域。例如,服务产品目录的过程由产品子域满足,而接收订单的过程由订单子域满足。

第二步代表一个允许用户选择产品的子域。此子域管理每个订单的生命周期,我们称之为订单子域。此子域拥有关于用户订单的数据,并公开一个接口,允许我们管理订单并检查其状态。它隐藏了平台的复杂性,这样用户就不必了解不同的端点以及如何使用它们。订单子域还负责第四步的第二部分:在支付成功处理后,将订单的详细信息传递给厨房。它还满足第六步的要求:允许用户检查他们订单的状态。作为订单管理员,订单子域还与配送子域合作安排配送。

第三步代表了一个可以处理用户支付的子域。我们将称之为支付子域。这个域包含用于支付处理的专用逻辑,包括卡验证、与第三方支付提供商的集成、处理不同的支付方式等。支付子域拥有与用户支付相关的数据。

第五步代表了一个与厨房协作以管理客户订单生产的子域。我们称之为厨房子域。厨房中的生产系统是完全自动化的,厨房子域与厨房系统接口以安排客户订单的生产并跟踪其进度。一旦订单生产完成,厨房子域会通知订单子域,然后安排其配送。厨房子域拥有与客户订单生产相关的数据,并公开了一个接口,允许我们向厨房发送订单并跟踪其进度。订单子域与厨房子域接口以更新订单的状态,以满足第六步的要求。

第七步代表了一个与自动化配送系统接口的子域。我们称之为配送子域。这个子域包含用于解决客户地理位置和计算到达他们最优化路线的专用逻辑。它管理配送无人机编队并优化配送,并拥有与所有配送相关的数据。订单子域与配送子域接口以更新客户订单的行程,以满足第八步的要求。

通过战略分析,我们获得了 CoffeeMesh 在五个子域中的分解,这些子域可以映射到微服务中,因为每个都封装了一个定义明确且清晰区分的逻辑区域,拥有自己的数据。DDD 的战略分析产生了满足我们在第 3.2 节中列举的设计原则的微服务:所有这些子域都可以在不依赖其他微服务的情况下执行其核心任务,因此我们说它们是松散耦合的;每个服务都拥有自己的数据,因此符合数据库服务原则;最后,每个服务在狭窄定义的子域内执行任务,这符合单一职责原则。

如图 3.8 所示,战略分析为我们提供了以下微服务架构:

  • 产品子域映射到产品服务——管理 CoffeeMesh 的产品目录

  • 订单子域映射到订单服务——管理客户订单

  • 支付子域映射到支付服务——管理客户支付

  • 厨房子域映射到厨房服务——管理厨房中的订单生产

  • 配送子域映射到配送服务——管理客户配送

图 3.8

图 3.8 展示了应用 DDD 的战略分析将 CoffeeMesh 平台分解为五个可以直接映射到微服务的子域。

在下一节中,我们将比较 DDD 的战略分析结果与按业务能力进行服务分解的结果,并评估每种方法的益处和挑战。

3.5 按业务能力分解与按子域分解

我们应该使用哪种服务分解策略来设计我们的微服务:按业务能力分解还是按子域分解?虽然按业务能力分解侧重于业务结构和组织,但按子域分解则分析业务流程和流程。因此,这两种方法为我们提供了对业务的不同视角,如果你有时间,最佳策略是同时应用这两种服务分解方法。

有时我们可以结合两种方法的结果。例如,CoffeeMesh 平台可以允许客户为每个产品撰写评论,CoffeeMesh 可以利用这些信息向其他客户推荐新产品。公司可以有一个专门的团队负责这一业务方面。从技术角度来看,评论可能只是产品数据库中的另一个表。然而,为了便于与业务合作,建立一个评论服务可能是有意义的。评论服务能够将新的评论输入到推荐系统中,而订单服务将使用评论服务的接口为新用户提供推荐。

按业务能力分解的优势在于,平台的架构与业务现有的组织结构相一致。这种一致性可能有助于业务和技术团队之间的协作。这种方法的缺点是,业务现有的组织结构并不一定是最高效的。事实上,它可能是过时的,反映了旧的业务流程。在这种情况下,业务的不效率将在微服务架构中得到反映。如果组织结构重组,按业务能力分解也可能会与业务脱节。

当我们在 3.3.2 节中应用按业务能力进行分解时,我们得到了产品和成分服务之间一个不理想的部分划分。在进一步分析这两个服务之间的依赖关系后,我们得出结论,这两个能力应该归入同一个服务。然而,在现实生活中的情况中,这种额外的分析往往被忽略,导致的结果架构并不最优。从 3.3 节和 3.4 节的分析中,我们可以得出结论,按子域进行分解能更好地适应业务流程和流程的建模,如果你必须选择其中一种方法,按子域进行分解是更好的策略。

现在我们已经知道了如何设计我们的微服务,是时候设计和构建它们的接口了。在接下来的章节中,你将学习如何为微服务构建 REST 和 GraphQL 接口。

摘要

  • 我们将系统分解成微服务的过程称为服务分解。服务分解定义了服务之间的边界,我们必须正确执行这个过程以避免构建分布式单体架构的风险。

  • 按业务能力进行分解分析业务结构,并为组织中的每个团队设计微服务。这种方法使业务与我们的系统架构保持一致,但也将业务的低效性复制到平台中。

  • 按子域进行分解将领域驱动设计(DDD)应用于通过子域建模业务的过程和流程。通过使用这种方法,我们为每个子域设计一个微服务,从而得到一个更稳健的技术设计。

  • 为了评估我们的微服务架构的质量,我们应用了三个设计原则:

    • 数据库服务原则—每个微服务拥有自己的数据,并且对数据的访问通过服务的 API 进行。

    • 松耦合原则—你必须能够更新一个服务而不影响其他服务,并且每个服务应该能够在不不断调用其他服务的情况下工作。

    • 单一职责原则—我们必须围绕特定的业务能力或子域来设计每个服务。

第二部分. 设计和构建 REST API

在第一部分中,你学习了什么是微服务 API 以及如何将系统分解成微服务。现在自然的问题是,“你如何构建一个微服务?”以及“你如何使你的服务相互通信?”

我们通过 API 使服务相互通信,在第二部分中,你将学习如何设计和构建 REST API。表示状态转移(REST)是构建 API 最流行的技术,在第四章中,你将学习 REST API 设计的所有基本原理。我们将保持方法的实用性:在第一章中,我们介绍了 CoffeeMesh,一个按需咖啡配送应用程序,在第六章中,你将学习使用 Python 流行的 FastAPI 和 Flask 框架构建 CoffeeMesh 的订单和厨房 API。

在第一章中,我们介绍了文档驱动的开发,并强调了 API 文档的重要性,它告诉 API 客户端 API 是如何工作的;因此,良好的文档对于实现成功的集成至关重要。我们使用 OpenAPI 标准来记录 REST API,在第五章中,你将逐步学习如何记录 REST API。

最后,在第七章中,你将学习构建微服务所需的一切。你将学习使用 SQLAlchemy 实现你的数据层,并使用 Alembic 管理迁移。你将学习使用六边形架构来结构化你的应用程序,以及许多其他有用的模式和原则来封装你的代码,并在层之间保持松散耦合。

到第二部分结束时,你将能够设计出色的 REST API,制作优秀的 API 文档,并编写高度可读和可维护的服务实现。我迫不及待地想要开始!

REST API 设计的 4 个原则

本章涵盖

  • REST API 的设计原则

  • Richardson 成熟度模型如何帮助我们理解 REST 最佳设计原则的优势

  • 资源的概念和 REST API 端点的设计

  • 使用 HTTP 动词和 HTTP 状态码创建高度表达的 REST API

  • 设计高质量的负载和 URL 查询参数的 REST API

表示性状态转移(REST)描述了一种用于通过网络通信的应用程序的架构风格。最初,REST 的概念包括了一组用于设计分布式和可扩展 Web 应用程序的约束。随着时间的推移,详细协议和规范已经出现,为我们提供了设计 REST API 的明确指南。今天,REST 是构建 Web API 中最受欢迎的选择。¹ 在本章中,我们研究 REST 的设计原则,并通过设计 CoffeeMesh 平台上的订单 API,即我们在第一章中介绍的需求咖啡配送应用程序,来学习如何应用这些原则。

我们解释了资源的概念,以及它对 REST API 设计意味着什么。你还将学习如何利用 HTTP 协议的功能,如 HTTP 动词和状态码,来创建高度表达的 API。本章的最后部分涵盖了设计 API 负载和 URL 查询参数的最佳实践。

4.1 什么是 REST?

REST,由 Roy Fielding 在其博士论文“架构风格和网络软件架构设计”(PhD diss., University of California, Irvine, 2000, p. 109)中提出,描述了一种用于通过网络通信的松散耦合和高度可扩展应用程序的架构风格。它指的是传输资源状态表示的能力。资源概念在 REST 应用程序中是基本的。

定义 REST 是一种用于构建松散耦合和高度可扩展 API 的架构风格。REST API 围绕资源构建,这些资源可以通过 API 进行操作。

一个 资源 是一个可以通过唯一的超文本引用(即 URL)进行引用的实体。资源有两种类型:集合和单例。一个 单例 代表一个单一实体,而 集合 代表实体列表。² 这在实践中意味着我们为每种类型的资源使用不同的 URL 路径。例如,CoffeeMesh 的订单服务管理订单,通过其 API,我们可以通过/orders/{order_id} URL 路径访问特定的订单,而订单集合则位于/orders URL 路径下。因此,/orders/{order_id}是一个单例端点,而/orders是一个集合端点。

一些资源可以嵌套在其他资源内部,例如一个订单的负载,其中列出了嵌套数组中的多个项目。

列表 4.1 具有嵌套资源的负载示例

{
    "id": "924721eb-a1a1-4f13-b384-37e89c0e0875",
    "status": "progress",
    "created": "2023-09-01",
    "order": [
        {
            "product": "cappuccino",
            "size": "small",
            "quantity": 1
        },
        {
            "product": "croissant",
            "size": "medium",
            "quantity": 2
        }
    ]
}

我们可以创建嵌套端点来表示嵌套资源。嵌套端点允许我们访问资源的特定细节。例如,我们可以公开一个 GET /orders/{order_id}/status 端点,允许我们获取订单的状态,而不需要订单的所有其他细节。当资源由大型有效负载表示时,使用嵌套端点是一种常见的优化策略,因为它们帮助我们避免在只对单个属性感兴趣时进行昂贵的传输。

REST API 的资源导向性质有时可能显得有限。一个常见的担忧是如何通过端点建模操作同时保持我们的 API 是 RESTful 的。例如,我们如何表示取消订单的操作?一个常见的启发式方法是表示操作为嵌套资源。例如,我们可以有一个用于取消订单的 POST /orders/{order_id}/cancel 端点。在这种情况下,我们将订单的取消建模为创建一个取消事件。

设计干净的端点是构建易于维护和消费的 REST API 的第一步。在本节中学到的模式对于实现干净的端点大有裨益,在本章的其余部分,你将学习更多关于干净 API 设计的模式和原则。在下一节中,你将了解 REST API 应用的六个架构约束。

4.2 REST 应用的架构约束

在本节中,我们研究 REST 应用的架构约束。这些约束由 Fielding 列举,并指定了服务器应该如何处理和响应用户请求。在我们深入细节之前,让我们首先简要概述每个约束:

  • 客户端-服务器架构——用户界面(UI)必须与后端解耦。

  • 无状态——服务器必须在请求之间不管理状态。

  • 可缓存性——始终返回相同响应的请求必须是可缓存的。

  • 分层系统——API 可以分层架构,但这种复杂性必须对用户隐藏。

  • 按需代码——服务器可以在需要时将代码注入用户界面。

  • 统一接口——API 必须提供一致的接口来访问和操作资源。

让我们更详细地讨论这些约束。

4.2.1 关注点分离:客户端-服务器架构原则

REST 依赖于关注点分离的原则,因此它要求用户界面与数据存储和服务器逻辑解耦。这允许服务器端组件独立于 UI 元素进行演变。如图 4.1 所示,客户端-服务器架构模式的一个常见实现是将 UI 构建为一个独立的应用程序,例如,作为一个单页应用程序(SPA)。

图 4.1

图 4.1 REST 的客户端-服务器架构原则指出,服务器实现必须与客户端解耦。

4.2.2 使其可扩展:无状态原则

在 REST 中,对服务器的每个请求都必须包含处理它所需的所有信息。特别是,服务器不得从一次请求保持到下一次请求的状态。正如您在图 4.2 中看到的,从服务器组件中移除状态管理使得水平扩展后端变得更加容易。这允许我们部署多个服务器实例,并且由于这些实例中没有任何一个管理 API 客户端的状态,客户端可以与任何一个实例通信。

图 4.2 REST 的无状态原则指出,服务器不得管理客户端的状态。这允许我们部署多个 API 服务器实例,并使用任何一个实例来响应 API 客户端。

4.2.3 优化性能:缓存原则

当适用时,服务器响应必须被缓存。缓存提高了 API 的性能,因为它意味着我们不必再次执行为提供响应所需的全部计算。GET 请求适合缓存,因为它们返回已经保存在服务器中的数据。正如您在图 4.3 中看到的,通过缓存 GET 请求,我们避免了每次用户请求相同信息时都要从源获取数据。组装 GET 请求响应所需的时间越长,缓存它的好处就越大。

图 4.3 REST 的缓存原则指出,可缓存的响应必须被缓存,这有助于提高 API 服务器的性能。在这个例子中,我们缓存订单状态一段时间,以避免对厨房服务进行多次请求。

图 4.3 说明了缓存的优点。正如我们在第三章中学到的,一旦订单提交给厨房,客户就可以跟踪订单的进度。订单服务与厨房服务接口,以获取订单进度的信息。为了节省时间,当客户下次检查订单状态时,我们将其值缓存一段时间。

4.2.4 简化客户端操作:分层系统原则

在 REST 架构中,客户端必须有一个唯一的 API 入口点,并且不能判断他们是直接连接到端服务器还是连接到负载均衡器等中间层。您可以在不同的服务器上部署服务器端应用程序的不同组件,或者为了冗余和可扩展性,可以在不同的服务器上部署相同的组件。这种复杂性应该通过暴露一个封装了对您的服务访问的单个端点来隐藏给用户。

如您在图 4.4 中看到的,解决这个问题的常见方法是 API 网关模式,这是一个充当所有微服务入口点的组件。API 网关知道每个服务的服务器地址,并且知道如何将每个请求映射到相应的服务。³

图 4.4 REST 分层系统原则指出,我们的后端复杂性必须对客户端隐藏。解决这个问题的常见方法是 API 网关模式,它作为平台中所有服务的入口点。

4.2.5 可扩展接口:按需代码原则

服务器可以通过直接从后端发送可执行代码来扩展客户端应用程序的功能,例如运行 UI 所需的 JavaScript 文件。这个限制是可选的,并且仅适用于后端提供客户端界面的应用程序。

4.2.6 保持一致性:统一接口原则

REST 应用程序必须向其消费者提供一个统一和一致的接口。该接口必须得到文档化,服务器和客户端必须严格遵循 API 规范。单个资源通过统一资源标识符(URI)⁴进行标识,并且每个 URI 必须是唯一的,并且始终返回相同的资源。例如,URI /orders/8 代表 ID 为 8 的订单,对该 URI 的 GET 请求始终返回 ID 为 8 的订单的状态。如果订单从系统中删除,则不得重新使用该 ID 来表示不同的订单。

资源必须使用选择的序列化方法表示,并且在整个 API 中应始终使用这种方法。如今,REST API 通常使用 JSON 作为序列化格式,尽管其他格式也是可能的,例如 XML。

REST 的架构约束为我们设计健壮和可扩展的 API 提供了坚实的基础。但正如我们在本章的后续部分将看到的,在设计 API 时,我们还需要考虑更多因素。在下一节中,你将学习如何通过丰富资源的描述来包含相关的超媒体链接,使你的 API 变得可发现。

4.3 超媒体作为应用状态引擎

现在我们已经了解了 REST API 最重要的设计约束,让我们来看看 REST 的另一个重要概念:超媒体作为应用状态引擎(HATEOAS)。HATEOAS 是 REST API 设计中的一个范例,强调可发现性概念。HATEOAS 通过丰富响应,包含用户与资源交互所需的所有信息,使得 API 更容易使用。在本节中,我们解释了 HATEOAS 是如何工作的,并讨论了这种方法的优点和缺点。

HATEOAS 究竟是什么?在 2008 年发表的一篇题为“REST APIs Must Be Hypertext-Driven”的文章(mng.bz/p6y5)中,菲尔德宁建议 REST API 必须在它们的响应中包含相关链接,以便客户端可以通过跟随这些链接来导航 API。

定义 超媒体作为应用状态引擎(HATEOAS)是 REST 的一种设计范式,强调可发现性的理念。每当客户端从服务器请求一个资源时,响应必须包含与该资源相关的链接列表。例如,如果客户端请求一个订单的详细信息,响应必须包括取消和付款的链接。

例如,如图 4.5 所示,当客户端请求一个订单的详细信息时,API 会包含与该订单相关的链接集合。通过这些链接,我们可以取消订单,或者为其付款。

图片

图 4.5 在 HATEOAS 范式中,API 发送请求资源的表示,以及与资源相关的其他链接。

列表 4.2 包含超媒体链接的订单表示

{
    "id": 8,
    "status": "progress",
    "created": "2023-09-01",
    "order": [
        {
            "product": "cappuccino",
            "size": "small",
            "quantity": 1
        },
        {
            "product": "croissant",
            "size": "medium",
            "quantity": 2
        }
    ],
    "links": [
        {
            "href": "/orders/8/cancel",
            "description": "Cancels the order",
            "type": "POST"

        },
        {
            "href": "/orders/8/pay",
            "description": "Pays for the order",
            "type": "POST"
        }
    ]
}

提供关系链接使 API 可导航且易于使用,因为每个资源都附带我们与之交互所需的所有 URL。然而,在实践中,由于几个原因,许多 API 并没有这样实现:

  • 超链接提供的信息已经在 API 文档中提供。事实上,OpenAPI 规范中包含的信息比在特定资源的相关链接列表中提供的信息更为丰富和结构化。

  • 并非总是清楚应该返回哪些链接。不同的用户有不同的权限和角色,这使他们能够执行不同的操作和访问不同的资源。例如,外部用户可以使用 CoffeeMesh API 中的/orders端点下单,并且他们也能够使用/orders/{order_id}端点检索订单的详细信息。然而,他们不能使用/orders/{order_id}端点的 DELETE 操作来删除订单,因为这个端点是限制给 CoffeeMesh 平台内部用户的。如果 HATEOAS 的目的是从单一入口点使 API 可导航,那么将 DELETE /orders/{order_id}端点返回给外部用户就没有意义,因为他们无法使用它。因此,有必要根据用户的权限返回不同的相关链接列表。然而,这种灵活性在我们的 API 设计和实现中引入了额外的复杂性,并将授权层与 API 层耦合在一起。

  • 根据资源的状态,某些操作和资源可能不可用。例如,你可以在一个活跃订单上调用/orders/1234/cancel端点,但不能在一个已取消的订单上调用。这种模糊性使得定义和实现遵循 HATEOAS 原则的健壮接口变得困难。

  • 最后,在某些 API 中,相关链接的列表可能很大,因此会使响应负载过大,从而影响 API 的性能和低网络连接设备连接的可靠性。

当你在自己的 API 上工作时,你可以决定是否遵循 HATEOAS 原则。在某些情况下,提供相关资源的列表有一定的好处。例如,在一个维基应用程序中,有效载荷的链接资源部分可以用来列出与特定文章相关的文章内容、同一文章在其他语言的链接以及可以对该文章执行的操作的链接。总的来说,你可能希望在 API 文档已经以更清晰和详细的方式提供给客户端的内容,以及你可以在响应中提供的内容之间找到一个平衡,以促进客户端和 API 之间的交互。如果你正在构建面向公众的 API,你的客户端将从关系链接中受益。然而,如果它是一个小型的内部 API,可能没有必要包含关系链接。

现在我们已经了解了如何使我们的 API 可发现以及何时这样做是值得的,让我们来研究理查森成熟度模型,这将帮助你了解你的 API 在多大程度上符合 REST 的设计原则。

4.4 使用理查森成熟度模型分析 API 的成熟度

本节讨论了理查森成熟度模型,这是一个由伦纳德·理查森开发的心理模型,帮助我们思考一个 API 符合 REST 原则的程度。⁵ 理查森成熟度模型区分了 API 中“成熟度”的四个级别(从 0 级到 3 级)。每个级别都引入了额外的良好 REST API 设计元素(见图 4.6)。让我们详细讨论每个级别。

图 4.6

图 4.6 理查森成熟度模型区分了 API 成熟度的四个级别,其中最高级别代表一个遵守 REST 最佳实践和标准的 API 设计,而最低级别代表一种不应用任何 REST 原则的 API 类型。

4.4.1 第 0 级:Web API 类似于 RPC

在第 0 级,HTTP 基本上被用作一个传输系统来承载与服务器的交互。在这种情况下,API 的概念更接近于远程过程调用(RPC;见附录 A)。所有对服务器的请求都是在同一个端点上,使用相同的 HTTP 方法进行的,通常是 GET 或 POST。客户端请求的详细信息包含在 HTTP 有效载荷中。例如,为了通过 CoffeeMesh 网站下订单,客户端可能会在通用的/api端点上发送以下有效载荷的 POST 请求:

{
    "action": "placeOrder",
    "order": [
        {
            "product": "mocha",
            "size": "medium",
            "quantity": 2
        }
    ]
} 

服务器总是以 200 状态码和伴随的有效载荷响应,让我们知道请求处理的结果。同样,为了获取订单的详细信息,客户端可能会在通用的/api端点上发出以下 POST 请求(假设订单 ID 为 8):

{
    "action": "getOrder",
    "order": [
        {
            "id": 8
        }
    ]
}

4.4.2 第 1 级:引入资源概念

第 1 级介绍了资源 URL 的概念。服务器不再暴露通用的/api端点,而是暴露代表资源的 URL。例如,/orders URL 代表订单集合,而/orders/{order_id} URL 代表单个订单。为了下订单,客户端在/orders端点上发送一个与第 0 级类似的 POST 请求:

{
    "action": "placeOrder",
    "order": [
        {
            "product": "mocha",
            "size": "medium",
            "quantity": 2
        }
    ]
} 

这次当请求最后一个订单的详细信息时,客户端将在代表该订单的 URI 上发送一个 POST 请求:/orders/8。在这个级别,API 不区分 HTTP 方法来表示不同的操作。

4.4.3 第 2 级:使用 HTTP 方法和状态码

第 2 级介绍了 HTTP 动词和状态码的概念。在这个级别,HTTP 动词用于表示特定的操作。例如,为了下订单,客户端在/orders端点上发送一个 POST 请求,带有以下有效负载:

{
    "order": [
        {
            "product": "mocha",
            "size": "medium",
            "quantity": 2
        }
    ]
}

在这种情况下,HTTP 方法 POST 表示我们想要执行的操作,有效负载仅包括我们想要下订单的订单详情。同样,为了获取订单的详细信息,我们向订单的 URI 发送一个 GET 请求:/orders/ {order_id}。在这种情况下,我们使用 HTTP 动词 GET 来告诉服务器我们想要检索 URI 中指定的资源的详细信息。

虽然前几个级别在所有响应中都包含相同的状态码(通常是 200),但第 2 级引入了 HTTP 状态码的语义使用,以报告处理客户端请求的结果。例如,当我们使用 POST 请求创建资源时,我们得到一个 201 响应状态码,而对于一个不存在的资源的请求,我们得到一个 404 响应状态码。有关 HTTP 状态码和最佳实践的更多信息,请参阅第 4.6 节。

4.4.4 第 3 级:API 可发现性

第 3 级通过应用 HATEOAS 原则并丰富响应,通过包含表示我们可以对资源执行的操作的链接来引入可发现性的概念。例如,对/orders/{order_id}端点的 GET 请求返回一个订单表示,并包括相关链接列表。

列表 4.3 订单表示,包括超媒体链接

{
    "id": 8
    "status": "progress",
    "created": "2023-09-01",
    "order": [
        {
            "product": "cappuccino",
            "size": "small",
            "quantity": 1
        },
        {
            "product": "croissant",
            "size": "medium",
            "quantity": 2
        }
    ],
    "links": [
        {
            "href": "/orders/8/cancel",
            "description": "Cancels the order",
            "type": "POST"

        },
        {
            "href": "/orders/8/pay",
            "description": "Pays for the order",
            "type": "GET"
        }
    ]
}

在里查德森成熟度模型中,第 3 级代表他所称的“REST 的荣耀”的最后一步。

里查德森成熟度模型对我们 API 的设计意味着什么?该模型为我们提供了一个框架,以思考我们的 API 设计在 REST 整体原则中的位置。这个模型并不是用来衡量 API“遵守”REST 原则的程度,或者评估 API 设计质量;相反,它为我们提供了一个框架,以思考我们如何利用 HTTP 协议创建易于理解和消费的表达式丰富的 API。

现在我们已经理解了 REST API 的主要设计原则,是时候开始设计订单 API 了!在下一节中,我们将通过学习如何使用 HTTP 方法来设计 API 端点。

4.5 使用 HTTP 方法的具有结构性的资源 URL

正如我们在 4.4 节中学到的,使用 HTTP 方法和状态码与 Richardson 成熟度模型中的成熟 API 设计相关联。在本节中,我们通过将 HTTP 方法应用于 CoffeeMesh 应用程序订单 API 的设计来学习如何正确使用 HTTP 方法。

HTTP 方法是用于 HTTP 请求中的特殊关键字,用于指示我们希望在服务器上执行的操作类型。正确使用 HTTP 方法可以使我们的 API 更加结构化和优雅,并且由于它们是 HTTP 协议的一部分,它们也使得 API 更加易于理解和使用。

HTTP 请求方法的定义是用于 HTTP 请求中的关键字,用于指示我们希望执行的操作类型。例如,GET 方法用于检索资源的详细信息,而 POST 方法用于创建新的资源。对于 REST API 来说,最重要的 HTTP 方法是 GET、POST、PUT、PATCH 和 DELETE。HTTP 方法也被称为动词。

在我的经验中,关于 HTTP 方法正确使用的问题常常存在混淆。让我们通过学习每个方法的语义来消除这种混淆。在 REST API 中最相关的 HTTP 方法是 GET、POST、PUT、PATCH 和 DELETE:

  • GET—返回关于请求资源的详细信息

  • POST—创建新的资源

  • PUT—通过替换资源执行完全更新

  • PATCH—更新资源的特定属性

  • DELETE—删除资源

PUT 方法的语义

根据 HTTP 规范,PUT 可以是幂等的,因此如果资源不存在,我们可以使用它来创建资源。然而,规范也强调了“在收到状态改变请求后,代表客户端选择适当 URI 的服务应该使用 POST 方法而不是 PUT 方法。”这意味着,当服务器负责生成新资源的 URI 时,我们应该使用 POST 方法来创建资源,而 PUT 方法只能用于更新。

参见 R. Fielding,“超文本传输协议(HTTP/1.1):语义和内容”(RFC 7231,2014 年 6 月,tools.ietf.org/html/rfc7231#section-4.3.4)。

HTTP 方法允许我们模拟对资源可以执行的基本操作:创建(POST)、读取(GET)、更新(PUT 和 PATCH)和删除(DELETE)。我们用缩写 CRUD 来指代这些操作,它来自数据库领域,⁶,但在 API 的世界中非常流行。你经常会听到关于 CRUD API 的讨论,这些 API 被设计来对资源执行这些操作。

PUT 与 PATCH:它们有什么区别,何时使用它们?

我们可以使用 PUT 和 PATCH 来执行更新。那么,两者之间的区别是什么?PUT 要求 API 客户端发送资源的新完整表示(因此具有替换语义),而 PATCH 允许您只发送已更改的属性。

例如,想象一个订单具有以下表示:

{
    "id": "96247264-7d42-4a95-b073-44cedf5fc07d",
    "status": "progress",
    "created": "2023-09-01",
    "order": [
        {
            "product": "cappuccino",
            "size": "small",
            "quantity": 1
        },
        {
            "product": "croissant",
            "size": "medium",
            "quantity": 2
        }
    ]
}

现在假设用户想要对这个订单进行小的修改,并将羊角面包的大小从 "medium" 更改为 "small"。尽管用户只想更改一个特定的字段,但使用 PUT 他们必须将整个有效负载发送回服务器。然而,使用 PATCH 他们只需要发送服务器中必须更新的字段。PATCH 请求更优,因为发送到服务器的有效负载更小。然而,正如以下示例所示,PATCH 请求也有更复杂的结构,有时在后端处理起来也更困难:

{
    "op": "replace",
    "path": "order/1/size",
    "value": "medium"
}

这遵循 JSON Patch 规范的指南:^a JSON Patch 请求必须指定我们想要执行的操作类型,以及目标属性及其期望的值。我们使用 JSON Patch 来声明目标属性。

虽然实现 PATCH 端点是面向公众的 API 的良好实践,但内部 API 通常只实现 PUT 端点用于更新,因为它们更容易处理。在订单 API 中,我们将实现更新为 PUT 请求。

^a P. Bryan 和 M. Nottingham,"JavaScript 对象表示法 (JSON) Patch" (www.rfc-editor.org/rfc/rfc6902)。

我们如何使用 HTTP 方法来定义 CoffeeMesh 订单 API 的端点?我们结合使用 HTTP 方法和 URL,因此首先定义资源 URL。在第 4.1 节中,我们学习了在 REST 中区分两种类型的资源 URL:单例,它表示单个资源,和集合,它表示资源列表。在订单 API 中,我们有这两个资源 URL:

  • /orders—表示订单列表。

  • /orders/{order_id}—表示单个订单。大括号 {order_id} 中的圆括号表示这是一个 URL 路径参数,必须用订单的 ID 替换。

如图 4.7 所示,我们使用单例 URL /orders/{order_id} 来执行对订单的操作,例如更新它,以及集合 URL /orders 来放置和列出过去的订单。HTTP 方法帮助我们建模这些操作:

  • 使用 POST /orders 来下订单,因为我们使用 POST 来创建新资源。

  • 使用 GET /orders 来检索订单列表,因为我们使用 GET 来获取信息。

  • 使用 GET /orders/{order_id} 来检索特定订单的详细信息。

  • 使用 PUT /orders/{order_id} 来更新订单,因为我们使用 PUT 来更新资源。

  • 使用 DELETE /orders/{order_id} 来删除订单,因为我们使用 DELETE 进行删除。

  • 使用 POST /orders/{order_id}/cancel 来取消订单。我们使用 POST 来创建取消操作。

  • 使用 POST /orders/{order_id}/pay 来支付订单。我们使用 POST 来创建支付。

图 4.7 我们将 HTTP 方法与 URL 路径结合来设计我们的 API 端点。我们利用 HTTP 方法的语义来传达每个端点的意图。例如,我们使用 POST 方法来创建新资源,因此我们在 POST /orders 端点中使用它来下订单。

现在我们知道了如何通过结合 URL 路径和 HTTP 方法来设计 API 端点,让我们看看如何利用 HTTP 状态码的语义来返回表达性的响应。

4.6 使用 HTTP 状态码创建表达性的 HTTP 响应

本节解释了我们在 REST API 的响应中使用 HTTP 状态码的方式。我们首先明确 HTTP 状态码是什么,以及我们如何将它们分类到不同的组中,然后解释如何使用它们来模拟我们的 API 响应。

4.6.1 什么是 HTTP 状态码?

我们使用状态码来表示服务器处理请求的结果。当正确使用时,HTTP 状态码帮助我们向 API 的消费者提供表达性的响应。状态码分为以下五个组:

  • 1xx 组—表示操作正在进行中

  • 2xx 组—表示请求被成功处理

  • 3xx 组—表示资源已移动到新位置

  • 4xx 组—表示请求存在问题

  • 5xx 组—表示在处理请求时出现错误

注意:HTTP 响应状态码用于指示处理 HTTP 请求的结果。例如,200 状态码表示请求被成功处理,而 500 状态码表示在处理请求时引发了内部服务器错误。HTTP 状态码与一个有理的短语相关联,该短语解释了代码的意图。例如,404 状态码的有理短语是“未找到”。您可以在 mng.bz/z5lw 查看状态码的完整列表并了解更多信息。

HTTP 状态码的完整列表很长,逐一列举它们并不会对我们理解如何使用它们有很大帮助。相反,让我们看看最常用的代码,并看看我们如何在 API 设计中应用它们。

在考虑 HTTP 状态码时,区分成功和失败响应是有用的。成功响应意味着请求被成功处理,而失败响应意味着在处理请求时出现了问题。对于我们在 4.5 节中定义的每个端点,我们使用以下成功 HTTP 状态码:

  • POST /orders: 201 (已创建)—表示已创建资源。

  • GET /orders: 200 (OK)—表示请求被成功处理。

  • GET /orders/{order_id}: 200 (OK)—表示请求被成功处理。

  • PUT /orders/{order_id}: 200 (OK)—表示资源已成功更新。

  • DELETE /orders/{order_id}: 204 (No Content)—表示请求已成功处理,但响应中没有内容。与所有其他方法不同,DELETE 请求不需要带有有效载荷的响应,因为毕竟我们是在指示服务器删除资源。因此,204 (No Content) 状态码是这类 HTTP 请求的好选择。

  • POST /orders/{order_id}/cancel: 200 (OK)—尽管这是一个 POST 端点,我们使用 200 (OK) 状态码,因为我们实际上并没有创建资源,客户端只想知道取消操作已成功处理。

  • POST /orders/{order_id}/pay: 200 (OK)—尽管这是一个 POST 端点,我们使用 200 (OK) 状态码,因为我们实际上并没有创建资源,客户端只想知道支付已成功处理。

对于成功的响应来说,这些都很好,但错误响应呢?在服务器处理请求时,我们可能会遇到哪些类型的错误,以及哪些 HTTP 状态码是合适的?我们区分两组错误:

  • 用户在发送请求时犯的错误,例如,由于有效载荷格式不正确,或者由于请求发送到了一个不存在的端点。我们使用 4xx 组中的 HTTP 状态码来处理这类错误。

  • 在服务器处理请求时意外抛出的错误,通常是由于我们代码中的错误。我们使用 5xx 组中的 HTTP 状态码来处理这类错误。

让我们更详细地讨论这些错误类型。

4.6.2 使用 HTTP 状态码来报告请求中的客户端错误

API 客户端在向 API 发送请求时可能会犯不同类型的错误。这类错误中最常见的一种是向服务器发送格式不正确的有效载荷。我们区分两种格式不正确的有效载荷:无效语法的有效载荷和不可处理的实体。

无效语法 的有效载荷是服务器既无法解析也无法理解的有效载荷。无效语法有效载荷的一个典型例子是格式不正确的 JSON。如图 4.8 所示,我们使用 400 (Bad Request) 状态码来处理这类错误。

图 4.8 当客户端发送一个格式不正确的有效载荷时,我们以 400 (Bad Request) 状态码进行响应。

不可处理的实体 是语法上有效的有效载荷,但缺少所需的参数,包含无效的参数,或将错误的值或类型分配给参数。例如,假设为了下订单,我们的 API 期望在 /orders URL 路径上发送一个类似以下的 POST 请求:

{
    "order": [
        {
            "product": "mocha",
            "size": "medium",
            "quantity": 2
        }
    ]
}

即,我们期望用户发送给我们一个元素列表,其中每个元素代表订单中的一个项目。每个项目由以下属性描述:

  • product—标识用户正在订购的产品

  • size—标识适用于订购产品的尺寸

  • quantity—告诉我们用户希望订购多少个相同产品和大小的项目

如图 4.9 所示,API 客户端可以发送缺少所需属性之一的有效负载,例如product。我们使用 422(不可处理实体)状态码来处理此类错误,该状态码表示请求存在问题,无法处理。

图片

图 4.9 当 API 客户端发送格式错误的负载时,服务器会以 400(错误请求)状态码响应。

另一个常见的错误发生在 API 客户端请求一个不存在的资源时。例如,我们知道 GET /orders/{order_id} 端点提供订单的详细信息。如果客户端使用该端点并带有不存在的订单 ID,我们应该以一个表示订单不存在的 HTTP 状态码进行响应。如图 4.10 所示,我们使用 404(未找到)状态码来处理这个错误,该状态码表示请求的资源不可用或找不到。

图片

图 4.10 当 API 客户端请求一个不存在的资源时,服务器会以状态码 404(未找到)响应。

另一个常见的错误发生在 API 客户端使用不支持的方法发送请求时。例如,如果用户在/orders端点上发送 PUT 请求,我们必须告诉他们该 URL 路径不支持 PUT 方法。我们可以使用两种 HTTP 状态码来处理这种情况。如图 4.11 所示,如果方法尚未实现但将来将可用(即我们有计划实现它),我们可以返回 501(未实现)状态码。

图片

图 4.11 当 API 客户端向一个未来将公开但尚未实现的 URL 路径发送请求时,服务器会以 501(未实现)状态码响应。

如果请求的 HTTP 方法不可用且我们没有计划实现它,我们像图 4.12 所示的那样以 405(方法不允许)状态码进行响应。

图片

图 4.12 当 API 客户端对一个不支持且未来也不会支持的 HTTP 方法进行 URL 路径请求时,服务器会以 405(方法不允许)状态码响应。

API 请求中常见的两个错误与身份验证和授权有关。第一个错误发生在客户端向受保护的端点发送未经身份验证的请求时。在这种情况下,我们必须告诉他们他们应该首先进行身份验证。如图 4.13 所示,我们使用 401(未授权)状态码来处理这种情况,该状态码表示用户尚未经过身份验证。

图片

图 4.13 当 API 客户端向需要身份验证的端点发送未经身份验证的请求时,服务器会以 401(未授权)状态码响应。

第二种错误发生在用户正确认证后,试图访问他们无权访问的端点或资源。一个例子是用户试图访问不属于他们的订单详情。如图 4.14 所示,我们使用 403(禁止)状态码来处理这种情况,这表示用户没有权限访问请求的资源或执行请求的操作。

图 4.14

图 4.14 当认证用户使用他们不被允许使用的 HTTP 方法发起请求时,服务器会以 403(禁止)状态码进行响应。

既然我们已经知道了如何使用 HTTP 状态码来报告用户错误,那么让我们将注意力转向服务器错误的状态码。

4.6.3 使用 HTTP 状态码在服务器中报告错误

第二组错误是由于我们代码中的错误或我们基础设施的限制在服务器中引发的。这一类别中最常见的错误类型是当我们的应用程序因错误而意外崩溃时。在这些情况下,我们以 500(内部服务器错误)状态码进行响应,如图 4.15 所示。

图 4.15

图 4.15 当服务器因我们代码中的错误而引发错误时,我们以 500(内部服务器错误)状态码进行响应。

当我们的应用程序无法处理请求时,会发生一种相关的错误类型。我们通常借助代理服务器或 API 网关(见 4.2.4 节)来处理这种情况。当服务器过载或维护关闭时,我们的 API 可能变得无响应,我们必须通过发送信息状态码来让用户知道这一点。我们区分两种情况:

  • 如图 4.16 所示,当服务器无法接受新的连接时,我们必须响应 503(服务不可用)状态码,这表示服务器过载或维护关闭,因此无法处理更多请求。

图 4.16

图 4.16 当 API 服务器过载且无法处理更多请求时,我们向客户端响应 503(服务不可用)状态码。

  • 当服务器响应请求过慢时,我们以 504(网关超时)状态码进行响应,如图 4.17 所示。

图 4.17

图 4.17 当 API 服务器响应请求非常慢时,代理服务器会以 504(网关超时)状态码向客户端响应。

这完成了我们对在 Web API 设计中最常用 HTTP 状态码的概述。正确使用状态码对于为您的 API 客户端提供良好的开发者体验大有裨益,但我们还需要设计好一件事:API 有效载荷。在下一节中,我们将关注这个重要话题。

4.7 设计 API 有效载荷

本节解释了设计用户友好的 HTTP 请求和响应负载数据的最佳实践。负载数据代表客户端和服务器通过 HTTP 请求交换的数据。当我们想要创建或更新资源时,我们会向服务器发送负载数据,而当请求数据时,服务器会向我们发送负载数据。API 的可用性很大程度上取决于良好的负载数据设计。设计不良的负载数据会使 API 难以使用,并导致糟糕的用户体验。因此,在设计高质量的负载数据时投入一些努力是很重要的。在本节中,你将学习一些模式和最佳实践,以帮助你完成这项任务。⁷

4.7.1 什么是 HTTP 负载数据,我们何时使用它们?

HTTP 请求是应用程序客户端发送给 Web 服务器的消息,HTTP 响应是服务器对请求的回复。HTTP 请求包括一个 URL、一个 HTTP 方法、一组标头,以及可选的正文或负载数据。HTTP 标头包含有关请求内容元数据的信息,例如编码格式。同样,HTTP 响应包括状态码、一组标头,以及可选的负载数据。我们可以用不同的数据序列化方法表示负载数据,例如 XML 和 JSON。在 REST API 中,数据通常表示为 JSON 文档。

定义:HTTP 消息正文负载数据是包含 HTTP 请求中交换的数据的消息。HTTP 请求和响应都可以包含消息正文。消息正文以 HTTP 支持的媒体类型之一进行编码,例如 XML 或 JSON。HTTP 请求的 Content-Type 标头告诉我们消息的编码类型。在 REST API 中,消息正文通常编码为 JSON。

当我们需要向服务器发送数据时,HTTP 请求会包含负载数据。例如,POST 请求通常用于发送数据以创建资源。HTTP 规范允许我们在所有 HTTP 方法中包含负载数据,但建议不要在 GET (mng.bz/O69K) 和 DELETE (mng.bz/YKeo) 请求中使用它们。

HTTP 规范在是否允许 DELETE 和 GET 请求包含负载数据方面故意表述模糊。它没有禁止使用负载数据,但指出它们没有定义的语义。这允许一些 API 在 GET 请求中包含负载数据。一个著名的例子是 Elasticsearch,它允许客户端在 GET 请求的正文发送查询文档 (mng.bz/G14M)。

那么 HTTP 响应呢?响应可能包含有效载荷,这取决于状态码。根据 HTTP 规范,状态码为 1xx 的响应,以及 204(无内容)和 304(未修改)状态码的响应,不得包含有效载荷。所有其他响应都包含有效载荷。在 REST API 的上下文中,最重要的有效载荷是 4xx 和 5xx 错误响应中的有效载荷,以及 2xx 成功响应中的有效载荷,除了 204 状态码。在下一节中,你将学习如何为所有这些响应设计高质量的有效载荷。

4.7.2 HTTP 有效载荷设计模式

既然我们已经知道了何时使用有效载荷,那么让我们学习设计它们的最佳实践。我们将重点关注响应有效载荷的设计,因为它们具有更多的多样性。正如我们在 4.6.1 节中学到的,我们区分错误响应和成功响应。错误响应的有效载荷应包括一个 "error" 关键字,详细说明客户端为什么收到错误。例如,当请求的资源在服务器中找不到时生成的 404 响应,可以包含以下错误信息:

{
    "error": "Resource not found"
} 

"error" 是错误消息中常用的关键字,但你也可以使用其他关键字,如 "detail""message"。大多数 Web 开发框架处理 HTTP 错误,并为错误响应提供默认模板。例如,FastAPI 使用 "detail",因此我们将在订单 API 规范中使用该关键字。

在成功响应中,我们区分三种场景:当我们创建一个资源时,当我们更新一个资源时,以及当我们获取一个资源的详细信息时。让我们看看我们是如何为这些场景中的每一个设计响应的。

POST 请求的响应有效载荷

我们使用 POST 请求来创建资源。在 CoffeeMesh 的订单 API 中,我们通过 POST /orders 端点下订单。为了下订单,我们将我们想要购买的商品列表发送到服务器,服务器负责分配一个唯一的 ID 给订单,因此订单的 ID 必须在响应有效载荷中返回。服务器还设置订单被接受的时间和其初始状态。我们将服务器设置的属性称为 服务器端只读属性,我们必须在响应有效载荷中包含它们。正如你在图 4.18 中看到的,对于 POST 请求的响应返回资源的完整表示是一个好习惯。这个有效载荷的作用是验证资源是否被正确创建。

图 4.18 当 API 客户端发送 POST 请求以创建新资源时,服务器会响应一个包含其 ID 和服务器设置的任何其他属性的资源的完整表示。

PUT 和 PATCH 请求的响应有效载荷

要更新资源,我们使用 PUT 或 PATCH 请求。正如我们在 4.5 节中看到的,我们在单例资源 URI 上执行 PUT/PATCH 请求,例如 CoffeeMesh 订单 API 的 PUT /orders/ {order_id}端点。如图 4.19 所示,在这种情况下,返回资源的完整表示也是良好的实践,客户端可以使用它来验证更新是否正确处理。

图 4.19 当 API 客户端向更新资源的 PUT 请求发送时,服务器响应资源的完整表示。

GET 请求的响应负载

我们使用 GET 请求从服务器检索资源。正如我们在 4.5 节中确立的,CoffeeMesh 的订单 API 公开了两个 GET 端点:GET /orders和 GET /orders/{orders_id}端点。让我们看看在设计这些端点的响应负载时我们有哪些建议。

GET /orders 返回订单列表。为了设计列表的内容,我们有两种策略:包含每个订单的完整表示或包含每个订单的部分表示。如图 4.20 所示,第一种策略在一个请求中向 API 客户端提供所有所需信息。然而,当列表中的项目很大时,这种策略可能会影响 API 的性能,导致响应负载过大。

图 4.20 当 API 客户端向 GET /orders端点发送请求时,服务器响应订单列表,其中每个订单对象包含关于订单的完整详细信息。

GET /orders端点负载的第二种策略是包含每个订单的部分表示,如图 4.21 所示。例如,在集合端点的 GET 请求响应中只包含每个项目的 ID 是一种常见的做法,如 GET /orders。在这种情况下,客户端必须调用 GET /orders/{order_id}端点来获取每个订单的完整表示。

图 4.21 当 API 客户端对/orders URL 路径发出 GET 请求时,服务器响应订单 ID 列表。客户端使用这些 ID 在 GET /orders/{order_id}端点上请求每个订单的详细信息。

哪种方法更好?这取决于用例。在公共 API 中,发送每个资源的完整表示更可取。然而,如果你正在开发内部 API,并且不需要每个项目的全部详细信息,你可以通过只包含客户端需要的属性来缩短负载。较小的负载处理速度更快,这会导致更好的用户体验。最后,单例端点,如 GET /orders/{order_id},必须始终返回资源的完整表示。

现在我们已经知道了如何设计 API 负载,让我们将注意力转向 URL 查询参数。

4.8 设计 URL 查询参数

现在让我们谈谈 URL 查询参数以及何时、为什么以及如何使用它们。某些端点,如订单 API 的 GET /orders端点,返回资源列表。当一个端点返回资源列表时,最佳实践是允许用户过滤和分页结果。例如,当使用 GET /orders端点时,我们可能只想限制结果为最近的五笔订单,或者只列出已取消的订单。URL 查询参数允许我们实现这些目标,并且始终应该是可选的,并且,在适当的情况下,服务器可以为它们分配默认值。

URL 查询参数的定义是 URL 中的键值参数。查询参数位于问号(?)之后,通常用于过滤端点的结果,例如订单 API 的 GET /orders端点。我们可以通过使用和号(&)来分隔它们,组合多个查询参数。

URL 查询参数是构成 URL 的一部分的键值对,但它们通过问号与 URL 路径分开。例如,如果我们想调用 GET /orders端点并通过已取消的订单过滤结果,我们可能会写如下:

GET /orders?cancelled=true

我们可以通过使用和号(&)来分隔多个查询参数,在同一个 URL 中链式调用多个查询参数。让我们向 GET /orders端点添加一个名为limit的查询参数,以便我们可以限制结果的数量。为了通过已取消的订单过滤 GET /orders端点并限制结果数量为 5,我们发出以下 API 请求:

GET /orders?cancelled=true&Limit=5

允许 API 客户端分页结果也是常见的做法。分页包括将结果分割成不同的集合,并一次服务一个集合。我们可以使用几种策略来分页结果,但最常见的方法是使用pageper_page参数的组合。page代表数据集,而per_page告诉我们每个集合中要包含多少项。服务器使用per_page的值来确定我们将得到多少数据集。我们可以在以下示例中将这两个参数组合在 API 请求中:

GET /orders?page=1&per_page=10

这标志着我们通过 REST API 的最佳实践和设计原则的旅程的结束。你现在拥有了设计高度表达性和结构化的 REST API 所需的资源,这些 API 易于理解和消费。在下一章中,你将学习如何使用 OpenAPI 标准来记录你的 API 设计。

摘要

  • 表现性状态转移(REST)定义了良好架构的 REST API 的设计原则:

    • 客户端-服务器架构—客户端和服务器代码必须解耦。

    • 无状态—服务器必须在请求之间不保持状态。

    • 可缓存性—可缓存的请求必须被缓存。

    • 分层系统—后端架构的复杂性不得暴露给最终用户。

    • 代码按需(可选)—客户端应用程序可能能够从服务器下载可执行代码。

    • 统一接口——API 必须提供统一且一致的接口。

  • 作为应用程序状态引擎的超媒体(HATEOAS)是一种范式,它指出 REST API 必须在它们的响应中包含引用链接。HATEOAS 使 API 具有导航性并更容易使用。

  • 良好的 REST API 设计利用了 HTTP 协议的特性,例如 HTTP 方法和状态码,以创建结构良好且高度表达的 API,这些 API 易于消费。

  • 对于 REST API 来说,最重要的 HTTP 方法是

    • GET 用于从服务器检索资源

    • POST 用于创建新资源

    • PUT 和 PATCH 用于更新资源

    • DELETE 用于删除资源

  • 我们使用有效载荷与 API 服务器交换数据。有效载荷位于 HTTP 请求或响应的主体中。客户端使用 POST、PUT 和 PATCH HTTP 方法发送请求有效载荷。服务器响应总是包含有效载荷,除非状态码是 204、304 或 1xx 组中的任何一个。

  • URL 查询参数是 URL 中的键值对,我们使用它们来过滤、分页和排序 GET 端点的结果。


¹ Postman 的 2022 年“API 状态报告”发现,调查的大多数参与者(89%)使用 REST(www.postman.com/state-of-api/api-technologies/#api-technologies)。

² 关于 REST API 中资源和资源建模的深入讨论,请参阅 Prakash Subramaniam 的出色文章“REST API 设计——资源建模”(www.thoughtworks.com/en-gb/insights/blog/rest-api-design-resource-modeling)。

³ 关于此模式的更多信息,请参阅 Chris Richardson 的《微服务模式》(Manning,2019,第 259-291 页;livebook.manning.com/book/microservices-patterns/chapter-8/point-8620-53-297-0)。

⁴ 关于 URI 的最新规范,请参阅 M. Nottingham 的“RFC 7320:URI 设计和所有权”(2004 年 7 月,tools.ietf.org/html/rfc7320)。

⁵ Leonard Richardson 在 2008 年 QCon 旧金山会议上的演讲“正义将需要数百万个复杂的步骤”中提出了他的成熟度模型(www.crummy.com/writing/speaking/2008-QCon/)。

⁶ CRUD 缩写据说是由 James Martin 在他的有影响力的书籍《管理数据库环境》中引入的(Prentice-Hall,1983,第 381 页)。

⁷ 除了学习最佳实践外,阅读有关反模式的内容也会很有用。我的文章“如何糟糕的模型破坏 API(或为什么设计优先是正确的方法)”概述了你应该避免的常见反模式(www.microapis.io/blog/how-bad-models-ruin-an-api)。

5 使用 OpenAPI 记录 REST API

本章节涵盖

  • 使用 JSON Schema 创建 JSON 文档的验证模型

  • 使用 OpenAPI 文档标准描述 REST API

  • 模型 API 请求和响应的有效载荷

  • 在 OpenAPI 规范中创建可重用的模式

在本章节中,你将学习如何使用 OpenAPI:描述 RESTful API 最流行的标准,它拥有丰富的工具生态系统,用于测试、验证和可视化 API。大多数编程语言都有支持 OpenAPI 规范的库,在第六章中,你将学习如何使用 Python 生态系统中的 OpenAPI 兼容库。

OpenAPI 使用 JSON Schema 来描述 API 的结构和模型,因此我们首先提供 JSON Schema 的工作概述。JSON Schema 是一个用于定义 JSON 文档结构的规范,包括文档中值的类型和格式。

在了解 JSON Schema 之后,我们研究 OpenAPI 文档的结构、其属性以及我们如何使用它为 API 消费者提供有信息的 API 规范。API 端点是规范的核心,因此我们特别关注它们。我们逐步分解定义 API 请求和响应有效载荷端点和模式的过程。在本章的示例中,我们与 CoffeeMesh 订单服务的 API 进行合作。正如我们在第一章中提到的,CoffeeMesh 是一个虚构的按需咖啡配送平台,订单服务是允许客户下单和管理订单的组件。订单 API 的完整规范可在本书 GitHub 仓库的 ch05/oas.yaml 下找到。

5.1 使用 JSON Schema 模型数据

本节介绍了 JSON Schema 规范标准,并解释了如何利用它来生成 API 规范。OpenAPI 使用 JSON Schema 规范的扩展子集来定义 JSON 文档的结构及其属性的类型和格式。这对于记录使用 JSON 表示数据并验证交换的数据是否正确非常有用。JSON Schema 规范正在积极开发中,最新版本为 2020-12。¹

定义 JSON Schema 是一个用于定义 JSON 文档结构和其属性类型及格式的规范标准。OpenAPI 使用 JSON Schema 来描述 API 的属性。

JSON Schema 规范通常定义了一个具有某些属性或属性的对象。JSON Schema 的 object 通过键值对关联数组表示。JSON Schema 规范通常看起来像这样:

{
    "status": {             ①
        "type": "string"    ②
    }
}

① JSON Schema 规范中的每个属性都是一个键,其值是该属性的描述符。

② 属性所需的最小描述符是类型。在这种情况下,我们指定状态属性是一个字符串。

在这个例子中,我们定义了一个具有一个名为 status 的属性的对象模式,其类型为 string

JSON Schema 允许我们对服务器和客户端应从有效载荷中期望的数据类型和格式非常明确。这对于 API 提供者和 API 消费者之间的集成是基本的,因为它让我们知道如何解析有效载荷以及如何在我们的运行时将它们转换为正确的数据类型。

JSON Schema 支持以下基本数据类型:

  • 对于字符值,使用 string

  • 对于整数和小数值,使用 number

  • 对于关联数组(即 Python 中的字典),使用 object

  • 对于其他数据类型的集合(即 Python 中的列表),使用 array

  • 对于 truefalse 值,使用 boolean

  • 对于未初始化的数据,使用 null

要使用 JSON Schema 定义一个对象,我们声明其类型为 object,并列出其属性及其类型。以下是如何定义一个名为 order 的对象,它是订单 API 的核心模型之一。

列表 5.1 使用 JSON Schema 定义对象的模式

{
    "order": {
        "type": "object",      ①
        "properties": {        ②
            "product": {
                "type": "string"
            },
            "size": {
                "type": "string"
            },
            "quantity": {
                "type": "integer"
            }
        }
    }
}

① 我们可以将模式声明为一个对象。

② 我们在 properties 关键字下描述对象的属性。

由于 order 是一个对象,因此 order 属性也有属性,这些属性在 properties 属性下定义。每个属性都有自己的类型。符合列表 5.1 中规范的一个 JSON 文档如下:

{
    "order": {
        "product": "coffee",
        "size": "big",
        "quantity": 1
    }
}

正如你所看到的,规范中描述的每个属性都用于本文档中,并且每个属性都有预期的类型。

属性也可以表示一个项目数组。在下面的代码中,order 对象代表一个对象数组。正如你所看到的,我们使用 items 关键字来定义数组中的元素。

列表 5.2 使用 JSON Schema 定义对象数组

{
    "order": {
        "type": "array",
        "items": {              ①
            "type": "object",
            "properties": {
                "product": {
                    "type": "string"
                },
                "size": {
                    "type": "string"
                },
                "quantity": {
                    "type": "integer"
                }
            }
        }
    }
}

① 我们使用 items 关键字定义数组中的元素。

在这个例子中,order 属性是一个数组。数组类型在其模式中需要额外的属性,即 items 属性,它定义了数组中每个元素的类型。在这种情况下,数组中的每个元素都是一个对象,它代表订单中的一个项目。

一个对象可以有任意数量的嵌套对象。然而,当嵌套的对象太多时,缩进会变得很大,使得规范难以阅读。为了避免这个问题,JSON Schema 允许我们分别定义每个对象,并使用 JSON 指针来引用它们。JSON 指针 是一种特殊语法,允许我们在同一规范中指向另一个对象定义。

正如以下代码所示,我们可以将 order 数组中每个项目的定义作为一个名为 OrderItemSchema 的模型提取出来,并使用特殊的 $ref 关键字通过 JSON 指针引用 OrderItemSchema

列表 5.3 使用 JSON 指针引用其他模式

{
    "OrderItemSchema": {
        "type": "object",
        "properties": {
            "product": {
                "type": "string"
            },
            "size": {
                "type": "string"
            },
            "quantity": {
                "type": "integer"
            }
        }
    },
    "Order": {
        "status": {
            "type": "string"
        },
        "order": {
            "type": "array",
            "items": {
                "$ref": '#/OrderItemSchema'    ①
            }
        }
    }
}

① 我们可以使用 JSON 指针来指定数组项的类型。

JSON 指针使用特殊关键字 $ref 和 JSONPath 语法来指向模式中的另一个定义。在 JSONPath 语法中,文档的根由井号符号(#)表示,嵌套属性的相互关系由正斜杠(/)表示。例如,如果我们想创建一个指向 OrderItemSchema 模型中 size 属性的指针,我们会使用以下语法:'#/OrderItemSchema/size'

定义 A JSON 指针 是 JSON Schema 中的一个特殊语法,它允许我们在同一规范中指向另一个定义。我们使用特殊关键字 $ref 来声明一个 JSON 指针。要构建指向另一个模式的路径,我们使用 JSONPath 语法。例如,要指向在文档顶层定义的名为 OrderItemSchema 的模式,我们使用以下语法:{"$ref": "#/OrderItemSchema"}

我们可以通过提取通用的模式对象到可重用模型,并使用 JSON 指针来重构我们的规范。我们可以通过 JSON 指针来引用它们。这有助于我们避免重复,并保持规范简洁。

除了能够指定属性的类型之外,JSON Schema 还允许我们指定属性的格式。我们可以开发自己的自定义格式或使用 JSON Schema 内置的格式。例如,对于一个表示日期的属性,我们可以使用 date 格式——这是 JSON Schema 支持的内置格式,它表示 ISO 日期(例如,2025-05-21)。以下是一个示例:

{
    "created": {
        "type": "string",
        "format": "date"
    }
}

在本节中,我们使用了 JSON 格式的示例。然而,JSON Schema 文档不需要用 JSON 编写。实际上,更常见的是用 YAML 格式编写,因为它更易于阅读和理解。OpenAPI 规范也通常以 YAML 格式提供,因此在本章的剩余部分,我们将使用 YAML 来开发订单 API 的规范。

5.2 OpenAPI 规范的结构

在本节中,我们介绍了 OpenAPI 标准,并学习了如何构建 API 规范。OpenAPI 的最新版本是 3.1;然而,这个版本在当前生态系统中仍然支持很少,因此我们将使用 OpenAPI 3.0 来记录 API。这两个版本之间没有太大差异,你关于 OpenAPI 3.0 所学到的几乎所有内容都适用于 3.1。²

OpenAPI 是一种用于记录 RESTful API 的标准规范格式(图 5.1)。OpenAPI 允许我们详细描述 API 的每个元素,包括其端点、请求和响应负载的格式、其安全方案等。OpenAPI 于 2010 年以 Swagger 的名义创建,作为一个开源规范格式,用于描述 RESTful 网络 API。随着时间的推移,这个框架越来越受欢迎,2015 年,Linux 基金会和一家主要公司联盟赞助了 OpenAPI 创新项目的创建,该项目旨在改进构建 RESTful API 的协议和标准。如今,OpenAPI 是最流行的规范格式,用于记录 RESTful API,³ 并且它受益于一个丰富的工具生态系统,用于 API 可视化、测试和验证。

图 5.1 OpenAPI 规范包含五个部分。例如,paths 部分描述了 API 端点,而 components 部分包含在文档中引用的可重用模式。

OpenAPI 规范包含了 API 消费者需要了解的所有信息,以便能够与 API 交互。正如你在图 5.1 中所看到的,OpenAPI 围绕五个部分进行结构化:

  • openapi—指示我们用于生成规范的 OpenAPI 版本。

  • info—包含一般信息,如 API 的标题和版本。

  • servers—包含 API 可用的 URL 列表。你可以为不同的环境(如生产环境和预发布环境)列出多个 URL。

  • paths—描述 API 提供的端点,包括预期的负载、允许的参数和响应的格式。这是规范中最重要的一部分,因为它代表了 API 接口,并且是消费者将寻找以了解如何与 API 集成的部分。

  • components—定义了在规范中可重用的元素,例如模式、参数、安全方案、请求体和响应。⁴ 一个 模式 是对请求和响应对象中预期属性和类型的定义。OpenAPI 模式使用 JSON Schema 语法定义。

现在我们已经知道了如何构建 OpenAPI 规范的结构,让我们继续记录订单 API 的端点。

5.3 记录 API 端点

在本节中,我们声明订单 API 的端点。正如我们在第 5.2 节中提到的,OpenAPI 规范的 paths 部分描述了您的 API 接口。它列出了 API 暴露的 URL 路径,以及它们实现的 HTTP 方法、它们期望的请求类型以及它们返回的响应,包括状态码。每个路径都是一个对象,其属性是该路径支持的 HTTP 方法。在本节中,我们将特别关注记录 URL 路径和 HTTP 方法。在第四章中,我们确定了订单 API 包含以下端点:

  • POST /orders—下单。它需要一个包含订单详细信息的有效负载。

  • GET /orders—返回订单列表。它接受 URL 查询参数,允许我们过滤结果。

  • GET /orders/{order_id}—返回特定订单的详细信息。

  • PUT /orders/{order_id}—更新订单的详细信息。由于这是一个 PUT 端点,它需要一个完整的订单表示。

  • DELETE /orders/{order_id}—删除一个订单。

  • POST /orders/{order_id}/pay—为订单付款。

  • POST /orders/{order_id}/cancel—取消订单。

以下显示了订单 API 端点的高级定义。我们声明 URL 和每个 URL 实现的 HTTP,并为每个端点添加一个操作 ID,以便我们可以在文档的其他部分中引用它们。

列表 5.4 订单 API 端点的概述

paths:
  /orders:                     ①
    get:                       ②
      operationId: getOrders
    post:  # creates a new order 
      operationId: createOrder

  /orders/{order_id}:
    get:
      operationId: getOrder
    put: 
      operationId: updateOrder
    delete: 
      operationId: deleteOrder

  /orders/{order_id}/pay:
    post:
      operationId: payOrder

  /orders/{order_id}/cancel:
    post: 
      operationId: cancelOrder

① 我们声明一个 URL 路径。

② 由 /orders URL 路径支持的 HTTP 方法

现在我们有了端点,我们需要填写详细信息。对于 GET /orders 端点,我们需要描述端点接受的参数,对于 POST 和 PUT 端点,我们需要描述请求有效负载。我们还需要描述每个端点的响应。在以下章节中,我们将学习为 API 的不同元素构建规范,从 URL 查询参数开始。

5.4 记录 URL 查询参数

正如我们在第四章中学到的,URL 查询参数允许我们过滤和排序 GET 端点的结果。在本节中,我们将学习如何使用 OpenAPI 定义 URL 查询参数。GET /orders 端点允许我们使用以下参数过滤订单:

  • cancelled—订单是否被取消。此值将是一个布尔值。

  • limit—指定应返回给用户的最大订单数。此参数的值将是一个数字。

cancelledlimit 都可以在同一请求中组合使用以过滤结果:

GET /orders?cancelled=true&limit=5

此请求要求服务器列出五个已取消的订单。列表 5.5 显示了 GET /orders端点查询参数的规范。参数的定义需要一个name,这是我们实际 URL 中用来引用它的值。我们还指定了它是哪种type的参数。OpenAPI 3.1 区分四种类型的参数:路径参数、查询参数、头参数和 cookie 参数。头参数是放入 HTTP 头字段中的参数,而 cookie 参数放入 cookie 有效载荷中。路径参数是 URL 路径的一部分,通常用于标识资源。例如,在/orders/ {order_id}中,order_id是一个路径参数,用于标识一个特定的订单。查询参数是可选参数,允许我们过滤和排序端点的结果。我们使用schema关键字(对于cancelled是布尔值,对于limit是数字)来定义参数的类型,并在相关的情况下,我们还指定了参数的format。⁵

列表 5.5 GET /orders端点查询参数规范

paths: 
  /orders:
    get:
      parameters:            ①
        - name: cancelled    ②
          in: query          ③
          required: false    ④
          schema:            ⑤
            type: boolean
        - name: limit
          in: query
          required: false
          schema:
            type: integer

① 我们在“参数”属性下描述 URL 查询参数。

② 参数的名称

③ 我们使用 in 描述符来指定参数位于 URL 路径中。

④ 我们指定参数是否必需。

⑤ 我们在“模式”下指定参数的类型。

现在我们知道了如何描述 URL 查询参数,在下一节中,我们将处理更复杂的事情:记录请求有效载荷。

5.5 记录请求有效载荷

在第四章中,我们了解到一个请求代表客户端通过 POST 或 PUT 请求发送给服务器的数据。在本节中,我们将学习如何记录订单 API 端点的请求有效载荷。让我们从 POST /orders方法开始。在第 5.1 节中,我们确定了 POST /orders端点的有效载荷如下所示:

{
    "order": [
        {
            "product": "cappuccino",
            "size": "big",
            "quantity": 1
        }
    ]
}

此有效载荷包含一个名为order的属性,它表示一个物品数组。每个物品由以下三个属性和约束定义:

  • product—用户订购的产品类型。

  • size—产品的尺寸。它可以有以下三种选择之一:smallmediumbig

  • quantity—产品的数量。它可以是一个等于或大于 1 的任何整数。

列表 5.6 展示了如何定义此负载的方案。我们在方法的 requestBody 属性的 content 属性下定义请求负载。我们可以以不同的格式指定负载。在这种情况下,我们只允许 JSON 格式的数据,其媒体类型定义为 application/json。我们的负载模式是一个具有一个属性的对象:order,其类型为 array。数组中的项是具有三个属性的对象:product 属性,类型为 stringsize 属性,类型为 string;和 quantity 属性,类型为 integer。此外,我们还为 size 属性定义了一个枚举,将接受的值限制为 smallmediumbig。最后,我们还为 quantity 属性提供了一个默认值 1,因为它是在负载中唯一的非必需字段。每当用户发送包含没有 quantity 属性的项目的请求时,我们假设他们只想订购该项目的单个单位。

列表 5.6 规范 POST /orders 端点

paths:
  /orders:
    post:
      operationId: createOrder
      requestBody:                       ①
        required: true                   ②
        content:                         ③
          application/json:
            schema:                      ④
              type: object
              properties:
                order:
                  type: array
                  items:
                    type: object
                    properties:
                      product:
                        type: string
                      size:
                        type: string
                        enum:            ⑤
                          - small
                          - medium
                          - big
                      quantity:
                        type: integer
                        required: false
                        default: 1       ⑥
                    required:
                      - product
                      - size

① 我们在 requestBody 下描述请求负载。

② 我们指定负载是否必需。

③ 我们指定负载的内容类型。

④ 我们定义负载的方案。

⑤ 我们可以使用枚举来约束属性的值。

⑥ 我们指定一个默认值。

如列表 5.6 所示,在端点的定义中嵌入负载模式可以使我们的规范更难以阅读和理解。在下一节中,我们将学习如何重构负载模式以提高可重用性和可读性。

5.6 重构模式定义以避免重复

在本节中,我们学习重构模式以保持 API 规范清洁和可读的策略。列表 5.6 中的 POST /orders 端点定义很长,包含多层缩进。因此,它难以阅读,这意味着将来它将难以扩展和维护。我们可以通过将负载模式移至 API 规范的不同部分(即 components 部分)来做得更好。正如我们在 5.2 节中解释的,components 部分用于声明在规范中引用的方案。每个方案都是一个对象,其中键是方案名称,值是描述它的属性。

列表 5.7 使用 JSON 指针规范 POST /orders 端点的规范

paths:
  /orders:
    post:
      operationId: createOrder
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateOrderSchema'    ①

components:                                                     ②
  schemas:
    CreateOrderSchema:                                          ③
      type: object
      properties:
        order:
          type: array
          items:
            type: object
            properties:
              product:
                type: string
              size:
                type: string
                enum:
                  - small
                  - medium
                  - big
              quantity:
                type: integer
                required: false
                default: 1
            required:
              - product
              - size

① 我们使用 JSON 指针引用文档中其他地方定义的方案。

② 模式定义位于 components 下。

③ 每个方案都是一个对象,其中键是名称,值是描述它的属性。

将 POST /orders 请求负载的模式移至 API 的 components 部分可以使文档更易于阅读。它允许我们保持文档的 paths 部分清洁并专注于端点的高级细节。我们只需通过 JSON 指针引用 CreateOrderSchema 模式即可:

#/components/schemas/CreateOrderSchema

规范看起来不错,但可以做得更好。CreateOrderSchema有点长,并且包含多层嵌套定义。如果CreateOrderSchema变得复杂,随着时间的推移,它将变得难以阅读和维护。我们可以通过以下代码中数组中order项的定义重构来使其更易于阅读。这种策略允许我们在 API 的其他部分重用订单项的架构。

列表 5.8 OrderItemSchemaOrder的架构定义

components:
  schemas:
      OrderItemSchema:                     ①
        type: object
        properties:
          product:
            type: string
          size:
            type: string
            enum:
              - small
              - medium
              - big
          quantity:
            type: integer
            default: 1
      CreateOrderSchema:
        type: object
        properties:
          order:
            type: array
            items:
              $ref: '#/OrderItemSchema'    ②

① 我们引入了 OrderItemSchema。

② 我们使用 JSON 指针指向 OrderItemSchema。

我们的模式看起来不错!CreateOrderSchema架构可用于创建订单或更新订单,因此我们可以在 PUT /orders/{order_id}端点中重用它,如列表 5.9 所示。正如我们在第四章中学到的,/orders/{order_id} URL 路径代表一个单例资源,因此 URL 包含一个路径参数,即订单的 ID。在 OpenAPI 中,路径参数用大括号表示。我们指定order_id参数是一个具有 UUID 格式的字符串(一个长随机字符串,常用于 ID)。⁶ 我们直接在 URL 路径下定义 URL 路径参数,以确保它适用于所有 HTTP 方法。

列表 5.9 PUT /orders/{order_id}端点的规范

paths:
  /orders:
    get:
      ...

  /orders/{order_id}:          ①
    parameters:                ②
      - in: path               ③
        name: order_id         ④
        required: true         ⑤
        schema:
          type: string
          format: uuid         ⑥
    put:                       ⑦
      operationId: updateOrder
      requestBody:             ⑧
        required: true
        content:
          application/json:
            schema:
           $ref: '#/components/schemas/CreateOrderSchema'

① 我们声明订单的资源 URL。

② 我们定义 URL 路径参数。

③ order_id 参数是 URL 路径的一部分。

④ 参数的名称

⑤ order_id 参数是必需的。

⑥ 我们指定参数的格式(UUID)。

⑦ 我们为当前 URL 路径定义 HTTP 方法 PUT。

⑧ 我们记录了 PUT 端点的请求体。

现在我们已经了解了如何定义请求有效载荷的架构,让我们将注意力转向响应。

5.7 记录 API 响应

在本节中,我们学习如何记录 API 响应。我们首先定义 GET /orders/{order_id}端点的有效载荷。GET /orders/ {order_id}端点的响应如下:

{
    "id": "924721eb-a1a1-4f13-b384-37e89c0e0875",
    "status": "progress",
    "created": "2022-05-01",
    "order": [
        {
            "product": "cappuccino",
            "size": "small",
            "quantity": 1
        },
        {
            "product": "croissant",
            "size": "medium",
            "quantity": 2
        }
    ]
}

此有效载荷显示了用户订购的产品、订单的放置时间和订单的状态。此有效载荷与我们在第 5.6 节中为 POST 和 PUT 端点定义的请求有效载荷类似,因此我们可以重用之前的模式。

列表 5.10 GetOrderSchema架构的定义

components:
  schemas:
    OrderItemSchema:
      ...

  GetOrderSchema:                                       ①
    type: object
    properties:
      status:
        type: string
        enum:                                           ②
          - created
          - paid
          - progress
          - cancelled
          - dispatched
          - delivered
      created:
        type: string
        format: date-time                               ③
      order:
        type: array
        items:
          $ref: '#/components/schemas/OrderItemSchema'  ④

① 我们定义 GetOrderSchema 架构。

② 我们使用枚举约束状态属性的值。

③ 具有日期时间格式的字符串

④ 我们使用 JSON 指针引用 OrderItemSchema 模式。

在列表 5.10 中,我们使用 JSON 指针指向 GetOrderSchema。另一种重用现有模式的方法是使用继承。在 OpenAPI 中,我们可以使用称为 模型组合 的策略来继承和扩展一个模式,这允许我们在单个对象定义中结合不同模式的属性。在这些情况下,使用特殊关键字 allOf 来指示该对象需要列出的模式中的所有属性。

定义 模型组合 是 JSON Schema 中的一种策略,允许我们将不同模式的属性组合成一个单一的对象。当模式包含已在其他地方定义的属性时,它非常有用,因此可以避免重复。

以下代码展示了使用 allOf 关键字对 GetOrderSchema 的另一种定义。在这种情况下,GetOrderSchema 是两个其他模式的组合:CreateOrderSchema 和一个包含两个键——statuscreated 的匿名模式。

列表 5.11 使用 allOf 关键字对 GetOrderSchema 的替代实现

components:
  schemas:
    OrderItemSchema:
      ...

    GetOrderSchema:
      allOf:                                                ①
        - $ref: '#/components/schemas/CreateOrderSchema'    ②
        - type: object                                      ③
          properties:
            status:
              type: string
              enum:
                - created
                - paid
                - progress
                - cancelled
                - dispatched
                - delivered
            created:
              type: string
              format: date-time

① 我们使用 allOf 关键字从其他模式继承属性。

② 我们使用 JSON 指针引用另一个模式。

③ 我们定义了一个新对象,以包含特定于 GetOrderSchema 的属性。

模型组合导致更简洁的规范,但它仅在模式严格兼容时才有效。如果我们决定通过新属性扩展 CreateOrderSchema,那么这个模式可能就不再适用于 GetOrderSchema 模型。从这个意义上说,有时最好在不同的模式中寻找共同元素,并将它们的定义重构为独立的模式。

现在我们有了 GET /orders/{order_id} 端点响应负载的规范,我们可以完成端点的规范。我们将端点的响应定义为对象,其键是响应的状态码,例如 200。我们还描述了响应的内容类型及其模式,GetOrderSchema

列表 5.12 GET /orders/{order_id} 端点的规范

paths:
  /orders:
    get:
        ...

  /orders/{order_id}:
    parameters: 
      - in: path
        name: order_id
        required: true
        schema:
          type: string
          format: uuid

    put:
      ... 

    get:                                                      ①
      summary: Returns the details of a specific order        ②
      operationId: getOrder
      responses:                                              ③
        '200':                                                ④
          description: OK                                     ⑤
          content:                                            ⑥
            application/json:
              schema:
                $ref: '#/components/schemas/GetOrderSchema'   ⑦

① 我们定义了 /orders/{order_id} URL 路径的 GET 端点。

② 我们提供了此端点的摘要描述。

③ 我们定义了这个端点的响应。

④ 每个响应都是一个对象,其键是状态码。

⑤ 对响应的简要描述

⑥ 我们描述了响应的内容类型。

⑦ 我们使用 JSON 指针引用 GetOrderSchema

如您所见,我们在端点的 responses 部分定义了响应模式。在这种情况下,我们只提供了 200 (OK) 成功响应的规范,但我们也可以记录其他状态码,例如错误响应。下一节解释了我们如何创建可以在端点之间重用的通用响应。

5.8 创建通用响应

在本节中,我们学习如何向我们的 API 端点添加错误响应。正如我们在第四章中提到的,错误响应更为通用,因此我们可以使用 API 规范的 components 部分提供这些响应的通用定义,然后在我们的端点中重复使用它们。

我们在 API 的 components 部分的 responses 标题内定义通用响应。以下是一个名为 NotFound 的 404 响应的通用定义。与任何其他响应一样,我们也记录了内容的有效负载,在这种情况下,它由 Error 架构定义。

列表 5.13 通用 404 状态码响应定义

components:
  responses:                                                ①
    NotFound:                                               ②
      description: The specified resource was not found.    ③
      content:                                              ④
        application/json:
          schema:
            $ref: '#/components/schemas/Error'              ⑤

  schemas:
    OrderItemSchema:
      ...
    Error:                                                  ⑥
      type: object
      properties:
        detail: 
          type: string
      required:
        - detail

① 通用响应位于组件部分的 responses 下。

② 我们命名了响应。

③ 我们描述了响应。

④ 我们定义了响应的内容。

⑤ 我们引用了 Error 架构。

⑥ 我们定义了 Error 有效负载的架构。

由于所有这些端点都是专门设计来针对特定资源的,因此这个 404 响应的规范可以在 /orders/{order_id} URL 路径下所有端点的规范中重复使用。

注意:你可能想知道,如果某些响应对所有 URL 路径的端点都是通用的,为什么我们不能直接在 URL 路径下定义响应以避免重复?答案是,目前这是不可能的。responses 关键字不允许直接位于 URL 路径下,因此我们必须为每个端点单独记录所有响应。OpenAPI GitHub 仓库中有一个请求,允许直接在 URL 路径下包含通用响应,但尚未实现(mng.bz/097p)。

我们可以在 GET /orders/ {order_id} 端点下使用列表 5.13 中的通用 404 响应。

列表 5.14 在 GET /orders/{order_id} 下使用 404 响应架构

paths:
  ...

  /orders/{order_id}:
    parameters:
      - in: path
        name: order_id
        required: true
        schema:
          type: string
          "format": uuid
    get:
      summary: Returns the details of a specific order
      operationId: getOrder
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/GetOrderSchema'
        '404':                                               ①
          $ref: '#/components/responses/NotFound'            ②

① 我们定义了一个 404 响应。

② 我们使用 JSON 指针引用 NotFound 响应。

本书 GitHub 仓库中的订单 API 规范还包含了对 422 响应的通用定义和对 Error 组件的扩展定义,该定义考虑了从 FastAPI 获得的不同错误有效负载。

我们几乎完成了。唯一剩下的端点是 GET /orders,它返回订单列表。端点有效负载重复使用 GetOrderSchema 来定义 orders 数组中的项目。

列表 5.15 GET /orders 端点的规范

paths:
  /orders:
    get:                                                             ①
      operationId: getOrders
      responses:
        '200':
          description: A JSON array of orders
          content:
            application/json:
              schema:
                type: object
                properties:
                  orders:
                    type: array                                      ②
                    items:
                      $ref: '#/components/schemas/GetOrderSchema'    ③
                required:
                  - order

    post:
      ...

  /orders/{order_id}:
    parameters:
      ...

① 我们定义了 /orders URL 路径的新 GET 方法。

② 订单是一个数组。

③ 数组中的每个项目都由 GetOrderSchema 定义。

我们 API 的端点现在已完全记录!你可以在端点的定义中使用许多其他元素,例如 tagsexternalDocs。这些属性不是严格必要的,但可以帮助提供更多结构给你的 API 或使其更容易分组端点。例如,你可以使用 tags 创建逻辑上属于一起或具有共同特征的端点组。

在我们完成本章之前,还有一个话题我们需要解决:记录我们 API 的认证方案。这就是下一节的主题!

5.9 定义 API 的认证方案

如果我们的 API 受保护,API 规范必须描述用户如何进行身份验证和授权他们的请求。本节解释了我们如何记录我们的 API 安全方案。API 的安全定义位于规范中的 components 部分的 securitySchemes 标题下。

使用 OpenAPI,我们可以描述不同的安全方案,例如基于 HTTP 的认证、基于密钥的认证、开放授权 2 (OAuth2) 和 OpenID Connect。⁷ 在第十一章中,我们将使用 OpenID Connect 和 OAuth2 协议实现认证和授权,因此让我们继续添加这些方案的定义。列表 5.16 展示了我们需要对我们的 API 规范进行的更改,以记录安全方案。

我们描述了三种安全方案:一个用于 OpenID Connect,另一个用于 OAuth2,还有一个用于携带授权。我们将使用 OpenID Connect 通过前端应用程序授权用户访问,对于直接 API 集成,我们将提供 OAuth 的客户端凭据流。在第十一章中,我们将详细解释每个协议和每个授权流程的工作原理。对于 OpenID Connect,我们必须提供一个配置 URL,该 URL 描述了我们的后端身份验证如何在 openIdConnectUrl 属性下工作。对于 OAuth2,我们必须描述可用的授权流程,以及客户端必须使用的 URL 来获取他们的授权令牌和可用的作用域。携带授权告诉用户,他们必须在授权头中包含一个 JSON Web Token (JWT) 来授权他们的请求。

列表 5.16 记录 API 的安全方案

components:
  responses:
    ...

  schemas:
    ...

  securitySchemes:                                                    ①
    openId:                                                           ②
      type: openIdConnect                                             ③
      openIdConnectUrl: https://coffeemesh-dev.eu.auth0.com/.well-
➥ known/openid-configuration                                         ④
    oauth2:                                                           ⑤
      type: oauth2                                                    ⑥
      flows:                                                          ⑦
        clientCredentials:                                            ⑧
          tokenUrl: https://coffeemesh-dev.eu.auth0.com/oauth/token ⑨
          scopes: {}                                                  ⑩
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT                                               ⑪
  ...

security:
  - oauth2:
      - getOrders
      - createOrder
      - getOrder
      - updateOrder
      - deleteOrder
      - payOrder
      - cancelOrder
  - bearerAuth:
      - getOrders
      - createOrder
      - getOrder
      - updateOrder
      - deleteOrder
      - payOrder
      - cancelOrder

① API 组件部分的 securitySchemes 标题下的安全方案

② 我们为安全方案提供一个名称(可以是任何名称)。

③ 安全方案的类型

④ 描述我们后端 OpenID Connect 配置的 URL

⑤ 另一个安全方案的名称

⑥ 安全方案的类型

⑦ 此安全方案下的可用授权流程

⑧ 客户端凭据流的描述

⑨ 用户可以请求授权令牌的 URL

⑩ 请求授权令牌时的可用作用域

⑪ 携带令牌具有 JSON Web Token (JWT) 格式。

这标志着我们通过 OpenAPI 记录 REST API 的旅程结束。这是一次多么精彩的旅程!你学习了如何使用 JSON Schema;OpenAPI 的工作原理;如何构建 API 规范;如何将记录 API 的过程分解为小而渐进的步骤;以及如何生成完整的 API 规范。下次你处理 API 时,你将能够使用这些标准技术来记录其设计。

摘要

  • JSON Schema 是定义 JSON 文档属性类型和格式的规范。JSON Schema 以语言无关的方式定义数据验证模型非常有用。

  • OpenAPI 是描述 REST API 的标准文档格式,并使用 JSON Schema 来描述 API 的属性。通过使用 OpenAPI,您可以利用围绕标准构建的整个工具和框架生态系统,这使得构建 API 集成变得更加容易。

  • JSON 指针允许您使用$ref关键字引用模式。使用 JSON 指针,我们可以创建可重用的模式定义,这些定义可以在 API 规范的不同部分中使用,从而保持 API 规范整洁且易于理解。

  • OpenAPI 规范包含以下部分:

    • openapi—指定用于记录 API 的 OpenAPI 版本

    • info—包含有关 API 的信息,例如其标题和版本

    • servers—记录 API 可用的 URL

    • paths—描述 API 公开的端点,包括 API 请求和响应的模式以及任何相关的 URL 路径或查询参数

    • components—描述 API 的可重用组件,例如有效载荷模式、通用响应和身份验证方案


¹ A. Wright, H. Andrews, B. Hutton, “JSON Schema: A Media Type for Describing JSON Documents” (December 8, 2020); datatracker.ietf.org/doc/html/draft-bhutton-json-schema-00。您可以通过参与 GitHub 上的其仓库来跟踪 JSON Schema 的发展并为其改进做出贡献:github.com/json-schema-org/json-schema-spec。还可以查看项目的网站:json-schema.org/

² 要详细了解 OpenAPI 3.0 和 3.1 之间的差异,请查看 OpenAPI 从 3.0 迁移到 3.1 的指南:www.openapis.org/blog/2021/02/16/migrating-from-openapi-3-0-to-3-1-0

³ 根据 2022 年 Postman 发布的“API 状态”报告(www.postman.com/state-of-api/api-technologies/#api-technologies)。

⁴ 有关 API 规范中components部分可以定义的所有可重用元素的完整列表,请参阅swagger.io/docs/specification/components/

⁵ 要了解 OpenAPI 3.1 中可用的日期类型和格式,请参阅spec.openapis.org/oas/v3.1.0#data-types

⁶ P. Leach, M. Mealling, and R. Salz, “A Universally Unique Identifier (UUID) URN Namespace,” RFC 4112 (datatracker.ietf.org/doc/html/rfc4122).

⁷ 关于 OpenAPI 中所有可用安全模式的完整参考,请参阅swagger.io/docs/specification/authentication/.

6 使用 Python 构建 REST API

本章涵盖

  • 使用 FastAPI 向端点添加 URL 查询参数

  • 使用 pydantic 和 marshmallow 禁止在有效负载中出现未知属性

  • 使用 flask-smorest 实施 REST API

  • 使用 marshmallow 定义验证模式和 URL 查询参数

在前面的章节中,你学习了如何设计和记录 REST API。在本章中,你将学习通过在 CoffeeMesh 平台的两个示例上工作来实施 REST API,这是我们在第一章中介绍的需求咖啡配送应用程序。我们将构建订单服务和厨房服务的 API。订单服务是 CoffeeMesh 平台客户的主要入口。通过它,他们可以下单、支付订单、更新订单并跟踪订单。厨房服务负责在 CoffeeMesh 工厂安排订单的生产并跟踪其进度。我们将通过这些示例学习实施 REST API 的最佳实践。

在第二章中,我们实现了订单 API 的一部分。在本章的前几节中,我们将在第二章中留下的订单 API 上继续工作,并使用 FastAPI(一个针对 Python 的高性能 API 框架,也是构建 REST API 的热门选择)实现其剩余功能。我们将学习如何使用 FastAPI 向我们的端点添加 URL 查询参数。正如我们在第二章中看到的,FastAPI 使用 pydantic 进行数据验证,在本章中我们将使用 pydantic 禁止有效负载中的未知字段。我们将了解容错读取模式,并权衡其好处与由于错误(如拼写错误)导致的 API 集成失败的风险。

在完成订单 API 的实施后,我们将实施厨房服务的 API。厨房服务在工厂安排订单的生产并跟踪其进度。我们将使用 flask-smorest 实施厨房 API,这是一个基于 Flask 和 marshmallow 的流行 API 框架。我们将学习按照 Flask 应用程序模式实施我们的 API,并使用 marshmallow 定义验证模式。

到本章结束时,你将了解如何使用 FastAPI 和 Flask(Python 生态系统中最受欢迎的库之一)实施 REST API。你将看到实施 REST API 的原则如何超越每个框架的实现细节,并且可以应用于你使用的任何技术。本章的代码可在本书提供的存储库中的 ch06 文件夹下找到。ch06 文件夹包含两个子文件夹:一个用于订单 API(ch06/orders)和一个用于厨房 API(ch06/kitchen)。话虽如此,我们就不多说了,让我们开始吧!

6.1 订单 API 概述

在本节中,我们回顾了我们在第二章中进行的订单 API 的最小实现。您可以在本书的 GitHub 仓库中 ch06/orders/oas.yaml 下找到订单 API 的完整规范。在我们直接进入实现之前,让我们简要分析一下规范,看看还剩下哪些要实现的内容。

在第二章中,我们实现了订单 API 的 API 端点,并创建了 pydantic 架构来验证请求和响应负载。我们故意跳过了实现应用程序的业务层,因为这是一个复杂任务,我们将在第七章中解决。

作为提醒,订单 API 公开的端点如下:

  • /orders—允许我们检索订单列表(GET)以及放置订单(POST)

  • /orders/{order_id}—允许我们检索特定订单的详细信息(GET)、更新订单(PUT)以及删除订单(DELETE)

  • /orders/{order_id}/cancel—允许我们取消订单(POST)

  • /orders/{order_id}/pay—允许我们为订单付款(POST)

POST /orders 和 PUT /orders/{order_id} 需要包含请求负载,以定义订单的属性,在第二章中我们已经实现了这些负载的架构。在实现中缺少的是 GET /orders 端点的 URL 查询参数。此外,我们在第二章中实现的 pydantic 架构不会使包含非法属性的负载无效。正如我们将在 6.3 节中看到的,在某些情况下这是可以的,但在其他情况下可能会导致集成问题,您将学习如何配置架构以使包含非法属性的负载无效。

如果您想跟随本章的示例,请创建一个名为 ch06 的文件夹,并将 ch02 中的代码复制到 ch06/orders 中。请记住安装依赖项并激活虚拟环境:

$ mkdir ch06
$ cp -r ch02 ch06/orders
$ cd ch06/orders
$ pipenv install --dev && pipenv shell

您可以通过运行以下命令来启动 Web 服务器:

$ uvicorn orders.app:app --reload

FastAPI + uvicorn 快速回顾 我们使用 FastAPI 框架实现订单 API,这是一个用于构建 REST API 的流行 Python 框架。FastAPI 建立在 Starlette 之上,这是一个异步 Web 服务器实现。为了执行我们的 FastAPI 应用程序,我们使用 Uvicorn,这是另一个异步服务器实现,它有效地处理传入的请求。

--reload标志使 Uvicorn 监视您的文件上的更改,这样每次您进行更新时,应用程序都会重新加载。这为您节省了每次更改代码时都需要重新启动服务器的时间。在这一点上,让我们完成订单 API 的实现!

6.2 订单 API 的 URL 查询参数

在本节中,我们通过添加 URL 查询参数来增强订单 API 的 GET /orders端点。我们还实现了参数的验证模式。在第四章中,我们了解到 URL 查询参数允许我们过滤 GET 端点的结果。在第五章中,我们确定 GET /orders端点接受 URL 查询参数以通过取消来过滤订单,并且还可以限制端点返回的订单列表。

列表 6.1 GET /orders URL 查询参数的规范

# file: orders/oas.yaml

paths:
  /orders:
    get:
      parameters:
        - name: cancelled
          in: query
          required: false
          schema:
            type: boolean
        - name: limit
          in: query
          required: false
          schema:
            type: integer

我们需要实现两个 URL 查询参数:cancelled(布尔值)和limit(整数)。这两个参数都不是必需的,因此用户必须能够在不指定它们的情况下调用 GET /orders端点。让我们看看我们如何做到这一点。

使用 FastAPI 实现端点的 URL 查询参数非常简单。我们只需要在端点的函数签名中包含它们,并使用类型提示为它们添加验证规则。由于查询参数是可选的,我们将使用Optional类型将它们标记为可选,并将它们的默认值设置为None

列表 6.2 GET /orders端点 URL 查询参数的实现

# file: orders/orders/api/api.py

import uuid
from datetime import datetime
from typing import Optional
from uuid import UUID

...

@app.get('/orders', response_model=GetOrdersSchema)
def get_orders(cancelled: Optional[bool] = None, limit: Optional[int] = None):①
    ...

① 我们在函数签名中包含 URL 查询参数。

现在我们已经在 GET /orders端点中有了查询参数,我们如何在函数内部处理它们呢?由于查询参数是可选的,我们首先检查它们是否已设置。我们可以通过检查它们的值是否不是None来实现这一点。列表 6.3 显示了如何在 GET /orders端点的函数体内部处理 URL 查询参数。研究图 6.1 以了解基于查询参数过滤订单列表的决策流程。

列表 6.3 GET /orders端点 URL 查询参数的实现

# file: orders/orders/api/api.py

@app.get('/orders', response_model=GetOrdersSchema)
def get_orders(cancelled: Optional[bool] = None, limit: Optional[int] = None): 
    if cancelled is None and limit is None:                     ①
        return {'orders': orders}

    query_set = [order for order in orders]                     ②

    if cancelled is not None:                                   ③
        if cancelled:
            query_set = [
                order
                for order in query_set
                if order['status'] == 'cancelled'
            ]
        else:
            query_set = [
                order
                for order in query_set
                if order['status'] != 'cancelled'
            ]
    if limit is not None and len(query_set) > limit:            ④
        return {'orders': query_set[:limit]}

    return {'orders': query_set}

① 如果参数尚未设置,我们立即返回。

② 如果任何参数已设置,我们将列表过滤到query_set中。

③ 我们检查cancelled是否已设置。

④ 如果设置了limit并且其值小于query_set的长度,我们返回query_set的子集。

图 6.1 基于查询参数过滤订单的决策流程。如果cancelled参数设置为TrueFalse,我们使用它来过滤订单列表。在此步骤之后,我们检查limit参数是否已设置。如果设置了limit,我们只从列表中返回相应数量的订单。

现在我们知道了如何将 URL 查询参数添加到我们的端点中,让我们看看我们如何增强我们的验证模式。

6.3 验证具有未知字段的负载

到目前为止,我们的 Pydantic 模型对请求负载一直持宽容态度。如果一个 API 客户端发送的负载包含在我们模式中未声明的字段,该负载将被接受。正如您在本节中将会看到的,在某些情况下这可能很方便,但在其他上下文中可能会产生误导或危险。为了避免集成错误,在本节中,我们将学习如何配置 Pydantic 以禁止未知字段的的存在。未知字段是指那些在模式中未定义的字段。

Pydantic 快速回顾正如我们在第二章中看到的,FastAPI 使用 Pydantic 来定义我们 API 的验证模型。Pydantic 是一个流行的 Python 数据验证库,它具有现代接口,允许您使用类型提示来定义数据验证规则。

在第二章中,我们按照宽容读取器模式实现了订单 API 的模式定义(martinfowler.com/bliki/TolerantReader.html),该模式遵循 Postel 的法律,建议在您所做的事情上要保守,在您从他人那里接受的事情上要宽容。¹

在网络 API 领域,这意味着我们必须严格验证发送给客户端的负载,同时允许接收来自 API 客户端的负载中的未知字段。JSON Schema 默认遵循此模式,除非明确声明,否则 JSON Schema 对象接受任何类型的属性。要使用 JSON Schema 禁止未声明的属性,我们将 additionalProperties 设置为 false。如果我们使用模型组合,一个更好的策略是将 unevaluatedProperties 设置为 false,因为 additionalProperties 会导致不同模型之间的冲突。² OpenAPI 3.1 允许我们使用 additionalPropertiesunevaluatedProperties,但 OpenAPI 3.0 只接受 additionalProperties。由于我们使用 OpenAPI 3.0.3 来记录我们的 API,我们将使用 additionalProperties 来禁止未声明的属性:

# file: orders/oas.yaml

    GetOrderSchema:
      additionalProperties: false
      type: object
      required:
        - order
        - id
        - created
        - status
      properties:
        id:
          type: string
          format: uuid
      ...

要查看更多关于 additionalProperties 的示例,请查看本书 GitHub 仓库中 ch06/orders/oas.yaml 下的订单 API 规范。

宽容读取器模式在 API 未经充分整合或可能频繁更改,并且我们希望能够在不破坏现有客户端集成的情况下对其进行更改时很有用。然而,在其他情况下,例如我们在第二章中看到的(第 2.5 节),宽容读取器模式可能会引入新的错误或导致意外的集成问题。

例如,OrderItemSchema 有三个属性:productsizequantityproductsize 是必需属性,但 quantity 是可选的,如果缺失,服务器将其分配为默认值 1。在某些场景中,这可能会导致令人困惑的情况。想象一下,一个客户端发送了一个包含 quantity 属性表示错误的负载,例如以下负载:

{
  "order": [
    {
      "product": "capuccino",
      "size": "small",
      "quantit": 5
    }
  ]
}

使用容错读取器实现,我们忽略来自负载的字段 quantit,并假设 quantity 属性缺失,将其值设置为默认的 1。这种情况可能会让客户端感到困惑,因为客户端原本打算为 quantity 设置不同的值。

API 客户端应该测试他们的代码!你可以争辩说客户端应该测试他们的代码,并在调用服务器之前验证它是否正常工作。你是对的。但在现实生活中,代码往往未经测试,或者测试不充分,服务器上的一点额外验证将有助于这些情况。如果我们检查负载是否存在非法属性,这个错误将被捕获并向客户端报告。

我们如何使用 pydantic 实现这一点?为了禁止未知属性,我们需要在我们的模型中定义一个 Config 类,并将 extra 属性设置为 forbid

列表 6.4 在模型中禁止额外的属性

# file: orders/orders/api/schemas.py

from datetime import datetime
from enum import Enum
from typing import List, Optional
from uuid import UUID

from pydantic import BaseModel, Extra, conint, conlist, validator

...

class OrderItemSchema(BaseModel):
    product: str
    size: Size
    quantity: int = Optional[conint(ge=1, strict=True)] = 1

    class Config:                 ①
        extra = Extra.forbid

class CreateOrderSchema(BaseModel):
    order: List[OrderItemSchema]

    class Config:
        extra = Extra.forbid

class GetOrderSchema(CreateOrderSchema):
    id: UUID
    created: datetime
    status: StatusEnum

① 我们使用 Config 禁止在模式中未定义的属性。

让我们测试这个新功能。运行以下命令以启动服务器:

$ uvicorn orders.app:app --reload

正如我们在第二章中看到的,FastAPI 从代码中生成 Swagger UI,我们可以使用它来测试端点。我们将使用此 UI 使用以下负载测试我们的新验证规则:

{
  "order": [
    {
      "product": "string",
      "size": "small",
      "quantit": 5
    }
  ]
}

定义 A Swagger UI 是表示 REST API 交互式可视化的流行样式。它们提供了一个用户友好的界面,帮助我们理解 API 实现。另一个流行的 REST 接口 UI 是 Redoc (github.com/Redocly/redoc)。

要访问 Swagger UI,请访问 http://127.0.0.1:8000/docs 并按照图 6.2 中的步骤操作,学习如何对 POST /orders 端点执行测试。

图 6.2 使用 Swagger UI 测试 API:要测试端点,点击端点本身,然后点击“尝试一下”按钮,然后点击“执行”按钮。

运行此测试后,您会看到现在 FastAPI 会验证此负载,并返回一个包含以下信息的有用 422 响应:“不允许额外的字段。”

6.4 覆盖 FastAPI 的动态生成规范

到目前为止,我们一直依赖 FastAPI 动态生成的 API 规范来测试、可视化和记录订单 API。动态生成的规范有助于我们理解 API 的实现方式。然而,我们的代码可能包含实现错误,这些错误可能导致文档不准确。此外,API 开发框架在生成 API 文档方面存在局限性,并且通常缺乏对 OpenAPI 某些特性的支持。例如,一个常见的缺失特性是记录 OpenAPI 链接,我们将在第十二章中将其添加到我们的 API 规范中。

要了解 API 应该如何工作,我们需要查看我们的 API 设计文档,它位于 orders/oas.yaml 下,因此是我们部署 API 时想要展示的规范。在本节中,你将学习如何使用我们的 API 设计文档覆盖 FastAPI 动态生成的 API 规范。

要加载 API 规范文档,我们需要 PyYAML,你可以使用以下命令安装:

$ pipenv install pyyaml

在 orders/app.py 文件中,我们加载 API 规范,并覆盖我们应用程序的对象openapi属性。

列表 6.5 覆盖 FastAPI 动态生成的 API 规范

# file: orders/orders/app.py

from pathlib import Path

import yaml
from fastapi import FastAPI

app = FastAPI(debug=True)

oas_doc = yaml.safe_load(
    (Path(__file__).parent / '../oas.yaml').read_text()
)                                 ①

app.openapi = lambda: oas_doc     ②

from orders.api import api

① 我们使用 PyYAML 加载 API 规范。

② 我们覆盖 FastAPI 的 openapi 属性,使其返回我们的 API 规范。

要能够使用 Swagger UI 测试 API,我们需要将 localhost URL 添加到 API 规范中。打开 orders/oas.yaml 文件,并将 localhost 地址添加到规范的servers部分:

# file: orders/oas.yaml

servers:
  - url: http://localhost:8000
    description: URL for local development and testing
  - url: https://coffeemesh.com
    description: main production server
  - url: https://coffeemesh-staging.com
    description: staging server for testing purposes only

默认情况下,FastAPI 在/docs URL 下提供 Swagger UI,在/openapi.json 下提供 OpenAPI 规范。当我们只有一个 API 时,这很好,但 CoffeeMesh 有多个微服务 API;因此,我们需要多个路径来访问每个 API 的文档。我们将为 orders API 的 Swagger UI 提供/docs/orders,以及其 OpenAPI 规范提供/openapi/orders.json。我们可以在 FastAPI 的应用程序对象初始化器中直接覆盖这些路径:

# file: orders/app.py

app = FastAPI(
    debug=True, openapi_url='/openapi/orders.json', docs_url='/docs/orders'
)

这标志着我们使用 FastAPI 构建订单 API 之旅的结束。现在是时候继续构建厨房服务的 API 了,我们将使用一个新的堆栈:Flask + marshmallow。让我们开始吧!

6.5 厨房 API 概述

在本节中,我们分析了厨房 API 的实现需求。如图 6.3 所示,厨房服务管理客户订单的生产。当客户下单或检查订单状态时,他们通过订单服务与厨房服务接口。CoffeeMesh 的员工也可以使用厨房服务来检查已安排的订单数量并管理它们。

图 6.3 厨房服务安排生产订单,并跟踪它们的进度。CoffeeMesh 的员工使用厨房服务来管理已安排的订单。

厨房 API 的规范位于本书提供的存储库中的 ch06/kitchen/oas.yaml 文件下。厨房 API 包含四个 URL 路径(参见图 6.4 以获取更多说明):

  • /kitchen/schedules—允许我们在厨房中安排生产订单(POST)并检索已安排生产订单的列表(GET)

  • /kitchen/schedules/{schedule_id}—允许我们检索已安排订单的详细信息(GET),更新其详细信息(PUT),并从我们的记录中删除它(DELETE)

  • /kitchen/schedules/{schedule_id}/status—允许我们读取已安排生产订单的状态

  • /kitchen/schedules/{schedule_id}/cancel—允许我们取消一个已计划的订单

图 6.4 厨房 API 有四个 URL 路径:/kitchen/schedules公开一个 GET 和一个 POST 端点;/kitchen/schedules/{schedule_id}公开 PUT、GET 和 DELETE 端点;/kitchen/schedules/{schedule_id}/cancel公开一个 POST 端点;而/kitchen/schedules/{schedule_id}/status公开一个 GET 端点。

厨房 API 包含三个模式:OrderItemSchemaScheduleOrderSchemaGetScheduledOrderSchemaScheduleOrderSchema表示为生产调度订单所需的负载,而GetScheduledOrderSchema表示已调度的订单的详细信息。就像在订单 API 中一样,OrderItemSchema表示订单中每个项目的详细信息。

正如我们在第二章中所做的那样,我们将保持实现简单,仅关注 API 层。我们将使用服务管理的调度内存表示来模拟业务层。在第七章中,我们将学习服务实现模式,这将帮助我们实现业务层。

6.6 介绍 flask-smorest

本节介绍了我们将用于构建厨房 API 的框架:flask-smorest (github.com/marshmallow-code/flask-smorest)。Flask-smorest 是在 Flask 和 marshmallow 之上构建的 REST API 框架。Flask 是构建 Web 应用的流行框架,而 marshmallow 是一个流行的数据验证库,用于处理复杂数据结构到和从原生 Python 对象的转换。Flask-smorest 建立在两个框架之上,这意味着我们使用 marshmallow 实现我们的 API 模式,我们按照典型 Flask 应用程序的模式实现我们的 API 端点,如图 6.5 所示。正如您将看到的,我们在使用 FastAPI 构建订单 API 时使用的原则和模式可以应用于任何框架,我们将使用相同的方法使用 flask-smorest 构建厨房 API。

图 6.5 使用 flask-smorest 构建的应用程序架构。Flask-smorest 实现了一个典型的 Flask 蓝图,这允许我们像在标准 Flask 应用程序中一样构建和配置我们的 API 端点。

使用 flask-smorest 构建 API 提供与使用 FastAPI 构建类似的经验,只有两个主要区别:

  • FastAPI 使用 pydantic 进行数据验证,而 flask-smorest 使用 marshmallow。这意味着在 FastAPI 中,我们使用原生 Python 类型提示来创建数据验证规则,而在 marshmallow 中,我们使用字段类。

  • Flask 允许我们使用基于类的视图实现 API 端点。这意味着我们可以使用一个类来表示一个 URL 路径,并实现其 HTTP 方法作为类的成员方法。基于类的视图帮助你编写更结构化的代码,并将每个 URL 路径的特定行为封装在类中。相比之下,FastAPI 只允许你使用函数定义端点。请注意,Starlette 允许你实现基于类的路由,因此 FastAPI 的这种限制可能在将来消失。

在此基础上,让我们开始厨房 API 的实现!

6.7 初始化 API 的 Web 应用程序

在本节中,我们设置环境以开始对厨房 API 进行工作。我们还将创建应用程序的入口点并添加对 Web 服务器的基本配置。这样做,你将学习如何使用 flask-smorest 设置项目以及如何将配置对象注入到 Flask 应用程序中。

Flask-smorest 建立在 Flask 框架之上,因此我们将按照典型 Flask 应用程序的模式来布局我们的 Web 应用程序。为厨房 API 实现创建一个名为 ch06/kitchen 的文件夹。在该文件夹中,复制本书 GitHub 仓库中 ch06/kitchen/oas.yaml 下的厨房 API 规范。oas.yaml 包含厨房 API 的规范。使用cd命令导航到 ch06/kitchen 文件夹,并运行以下命令来安装我们进行实现所需的依赖项:

$ pipenv install flask-smorest

注意:如果你想确保安装的依赖项与我编写本章时使用的版本相同,请从 GitHub 仓库复制 ch06/kitchen/ Pipfile 和 ch06/kitchen/Pipfile.lock 文件到你的本地机器上,并运行pipenv install

此外,运行以下命令以激活环境:

$ pipenv shell

现在我们有了所需的库,让我们创建一个名为 kitchen/app.py 的文件。这个文件将包含一个Flask应用程序对象的实例,它代表我们的 Web 服务器。我们还将创建一个 flask-smorest 的Api对象的实例,它将代表我们的 API。

列表 6.6 初始化Flask应用程序对象和Api对象

# file: kitchen/app.py

from flask import Flask
from flask_smorest import Api

app = Flask(__name__)     ①

kitchen_api = Api(app)    ②

① 我们创建了一个 Flask 应用程序对象的实例。

② 我们创建了一个 flask-smorest 的 Api 对象的实例。

Flask-smorest 需要一些配置参数才能工作。例如,我们需要指定我们使用的 OpenAPI 版本、我们 API 的标题以及我们 API 的版本。我们通过Flask应用程序对象传递此配置。Flask 提供了不同的注入配置策略,但最方便的方法是从类中加载配置。让我们创建一个名为 kitchen/config.py 的文件来存储我们的配置参数。在这个文件中,我们创建一个BaseConfig类,它包含 API 的通用配置。

列表 6.7 orders API 的配置

# file: kitchen/config.py

class BaseConfig:
    API_TITLE = 'Kitchen API'                                              ①
    API_VERSION = 'v1'                                                     ②
    OPENAPI_VERSION = '3.0.3'                                              ③
    OPENAPI_JSON_PATH = 'openapi/kitchen.json'                             ④
    OPENAPI_URL_PREFIX = '/'                                               ⑤
    OPENAPI_REDOC_PATH = '/redoc'                                          ⑥
    OPENAPI_REDOC_URL = 'https://cdn.jsdelivr.net/npm/redoc@next/bundles/redoc.standalone.js' ⑦
    OPENAPI_SWAGGER_UI_PATH = '/docs/kitchen'                              ⑧
    OPENAPI_SWAGGER_UI_URL = 'https://cdn.jsdelivr.net/npm/swagger-ui-
➥ dist/'                                                                  ⑨

① 我们 API 的标题

② 我们 API 的版本

③ 我们使用的 OpenAPI 版本

④ 动态生成的 JSON 规范路径

⑤ OpenAPI 规范文件 URL 路径的前缀

⑥ 我们 API 的 Redoc UI 路径

⑦ 用于渲染 Redoc UI 的脚本路径

⑧ 我们 API 的 Swagger UI 路径

⑨ 用于渲染 Swagger UI 的脚本路径

现在配置已经就绪,我们可以将其传递给Flask应用程序对象。

列表 6.8 加载配置

# file: kitchen/app.py

from flask import Flask
from flask_smorest import Api

from config import BaseConfig          ①
app = Flask(__name__)
app.config.from_object(BaseConfig) ②

kitchen_api = Api(app)

① 我们导入我们之前定义的 BaseConfig 类。

② 我们使用 from_object 方法从类中加载配置。

当我们的应用程序入口点和配置就绪并配置好后,让我们继续实现厨房 API 的端点!

6.8 实现 API 端点

本节解释了我们如何使用 flask-smorest 实现厨房 API 的端点。由于 flask-smorest 建立在 Flask 之上,因此我们为我们的 API 构建端点的方式与构建任何其他 Flask 应用程序的方式完全相同。在 Flask 中,我们使用 Flask 的route装饰器注册我们的端点:

@app.route('/orders')
def process_order():
    pass

使用route装饰器适用于简单情况,但对于更复杂的应用程序模式,我们使用 Flask blueprints。Flask blueprints 允许你为一组 URL 提供特定的配置。为了实现厨房 API 端点,我们将使用 flask-smorest 的Blueprint类。Flask-smorest 的Blueprint是 Flask 的Blueprint的子类,因此它提供了 Flask blueprints 的功能,并增强了额外的功能和配置,用于生成 API 文档,并提供负载验证模型等。

我们可以使用Blueprintroute装饰器创建端点或 URL 路径。正如你在图 6.6 中看到的,对于只暴露一个 HTTP 方法的 URL 路径,函数是方便的。当一个 URL 暴露多个 HTTP 方法时,使用基于类的路由会更方便,我们使用 Flask 的MethodView类来实现这些路由。

图 6-6

图 6.6 当一个 URL 路径暴露多个 HTTP 方法时,将其实现为一个基于类的视图会更方便,其中类方法实现了暴露的每个 HTTP 方法。

正如你在图 6.7 中看到的,使用MethodView,我们将 URL 路径表示为一个类,并将它暴露的 HTTP 方法实现为类的成员方法。

图 6-7

图 6.7 当一个 URL 路径只暴露一个 HTTP 方法时,将其实现为一个基于函数的视图会更方便。

例如,如果我们有一个暴露 GET 和 POST 端点的 URL 路径/kitchen,我们可以实现以下基于类的视图:

class Kitchen(MethodView):

    def get(self):
        pass

    def post(self):
        pass

列表 6.9 展示了如何使用基于类的视图和基于函数的视图实现厨房 API 的端点。列表 6.9 中的内容放入 kitchen/api/api.py 文件中。首先,我们创建 flask-smorest 的 Blueprint 的一个实例。Blueprint 对象允许我们注册我们的端点并为它们添加数据验证。要实例化 Blueprint,我们必须传递两个必需的位置参数:Blueprint 本身的名称以及实现 Blueprint 路由的模块的名称。在这种情况下,我们使用 __name__ 属性传递模块的名称,它解析为文件的名称。

一旦 Blueprint 被实例化,我们就使用 route() 装饰器将我们的 URL 路径注册到它。对于 /kitchen/schedules/kitchen/schedules/{schedule_id} 路径,我们使用基于类的路由,因为它们暴露了多个 HTTP 方法,而对于 /kitchen/schedules/{schedule_id}/cancel/kitchen/schedules/{schedule_id}/status 路径,我们使用基于函数的路由,因为它们只暴露一个 HTTP 方法。为了说明目的,我们每个端点都返回一个模拟的调度对象,我们将在第 6.12 节中将它更改为动态的内存中的调度集合。每个函数的返回值是一个元组,其中第一个元素是有效载荷,第二个是响应的状态码。

列表 6.9 订单 API 端点的实现

# file: kitchen/api/api.py

import uuid
from datetime import datetime

from flask.views import MethodView
from flask_smorest import Blueprint

blueprint = Blueprint('kitchen', __name__, description='Kitchen API')   ①

schedules = [{                                                          ②
    'id': str(uuid.uuid4()),
    'scheduled': datetime.now(),
    'status': 'pending',
    'order': [
        {
            'product': 'capuccino',
            'quantity': 1,
            'size': 'big'
        }
    ]
}]

@blueprint.route('/kitchen/schedules')                                  ③
class KitchenSchedules(MethodView):                                     ④

    def get(self):                                                      ⑤
        return {
            'schedules': schedules
        }, 200                                                          ⑥

    def post(self, payload):
        return schedules[0], 201

@blueprint.route('/kitchen/schedules/<schedule_id>')                    ⑦
class KitchenSchedule(MethodView):
    def get(self, schedule_id):                                         ⑧
        return schedules[0], 200

    def put(self, payload, schedule_id):
        return schedules[0], 200

    def delete(self, schedule_id):
        return '', 204

@blueprint.route(
    '/kitchen/schedules/<schedule_id>/cancel', methods=['POST']
)                                                                       ⑨
def cancel_schedule(schedule_id):
    return schedules[0], 200

@blueprint.route('/kitchen/schedules/<schedule_id>/status, methods=[GET])
def get_schedule_status(schedule_id):
    return schedules[0], 200

① 我们创建 flask-smorest 的 Blueprint 类的一个实例。

② 我们声明一个硬编码的调度列表。

③ 我们使用 Blueprint 的 route() 装饰器来注册一个类或函数作为 URL 路径。

④ 我们将 /kitchen/schedules URL 路径实现为一个基于类的视图。

⑤ 在基于类的视图中,每个方法视图的名称都与其实现的 HTTP 方法相对应。

⑥ 我们返回有效载荷和状态码。

⑦ 我们在尖括号内定义 URL 参数。

⑧ 我们在函数签名中包含 URL 路径参数。

⑨ 我们将 /kitchen/schedules/<schedule_id>/cancel URL 路径实现为一个基于函数的视图。

现在我们已经创建了蓝图,我们可以在 kitchen/app.py 文件中将它注册到我们的 API 对象。

列表 6.10 将蓝图注册到 API 对象

# file: kitchen/app.py

from flask import Flask
from flask_smorest import Api

from api.api import blueprint                ①
from config import BaseConfig

app = Flask(__name__)
app.config.from_object(BaseConfig)

kitchen_api = Api(app)

kitchen_api.register_blueprint(blueprint) ②

① 我们导入之前定义的蓝图。

② 我们将蓝图注册到厨房 API 对象。

使用 cd 命令导航到 ch06/kitchen 目录,并使用以下命令运行应用程序:

$ flask run --reload

就像在 Uvicorn 中一样,--reload 标志会在你的文件上运行一个监视器,以便当你修改代码时服务器会重新启动。

如果您访问 http://127.0.0.1:5000/docs URL,您将看到由我们之前实现的端点动态生成的交互式 Swagger UI。您还可以看到由 flask-smorest 在 http://127.0.0.1:5000/openapi.json 下动态生成的 OpenAPI 规范。在我们当前的实施阶段,通过 Swagger UI 与端点交互是不可能的。由于我们还没有 marshmallow 模型,flask-smorest 不知道如何序列化数据,因此不返回负载。然而,仍然可以通过 cURL 调用 API 并检查响应。如果您运行curl http://127.0.0.1:5000/kitchen/schedules,您将得到我们在厨房/api/api.py 模块中定义的模拟对象。

看起来一切都很顺利,现在是时候通过添加 marshmallow 模型来丰富实现。继续阅读下一节,了解如何做到这一点!

6.9 使用 marshmallow 实现负载验证模型

Flask-smorest 使用 marshmallow 模型来验证请求和响应负载。在本节中,我们通过实现厨房 API 的架构来学习如何使用 marshmallow 模型。marshmallow 模型将帮助 flask-smorest 验证我们的负载并序列化我们的数据。

正如您可以在本书 GitHub 仓库的 ch06/kitchen/oas.yaml 文件下的厨房 API 规范中看到的那样,厨房 API 包含三个架构:ScheduleOrderSchema架构,其中包含安排订单所需的详细信息;GetScheduledOrderSchema,它表示已安排订单的详细信息;以及OrderItemSchema,它表示订单中的项目集合。列表 6.11 展示了如何在厨房/api/schemas.py 下实现这些架构作为 marshmallow 模型。

要创建 marshmallow 模型,我们创建 marshmallow 的Schema类的子类。我们使用 marshmallow 的字段类,如StringInteger,来定义模型的属性。Marshmallow 使用这些属性定义来验证负载与模型的一致性。为了自定义 marshmallow 模型的行为,我们使用Meta类将unknown属性设置为EXCLUDE,这指示 marshmallow 无效化具有未知属性的负载。

列表 6.11 订单 API 的架构定义

# file: kitchen/api/schemas.py

from marshmallow import Schema, fields, validate, EXCLUDE

class OrderItemSchema(Schema):
    class Meta:                                                 ①
        unknown = EXCLUDE

    product = fields.String(required=True)
    size = fields.String(
        required=True, validate=validate.OneOf(['small', 'medium', 'big'])
    )
    quantity = fields.Integer(
        validate=validate.Range(1, min_inclusive=True), required=True
    )

class ScheduleOrderSchema(Schema):
    class Meta:
        unknown = EXCLUDE

    order = fields.List(fields.Nested(OrderItemSchema), required=True)

class GetScheduledOrderSchema(ScheduleOrderSchema):             ②
    id = fields.UUID(required=True)
    scheduled = fields.DateTime(required=True)
    status = fields.String(
        required=True,
        validate=validate.OneOf(
            ["pending", "progress", "cancelled", "finished"]
        ),
    )

class GetScheduledOrdersSchema(Schema):
    class Meta:
        unknown = EXCLUDE

    schedules = fields.List(
        fields.Nested(GetScheduledOrderSchema), required=True
    )

class ScheduleStatusSchema(Schema):
    class Meta:
        unknown = EXCLUDE

    status = fields.String(
        required=True,
        validate=validate.OneOf(
            ["pending", "progress", "cancelled", "finished"]
        ),
    )

① 我们使用 Meta 类来禁止未知属性。

② 我们使用类继承来重用现有架构的定义。

现在我们已经准备好了验证模型,我们可以将它们与我们的视图链接起来。列表 6.12 展示了我们如何使用这些模型在我们的端点上添加请求和响应负载的验证。要向视图添加请求负载验证,我们使用蓝图中的arguments()装饰器与 marshmallow 模型结合使用。对于响应负载,我们使用蓝图中的response()装饰器与 marshmallow 模型结合使用。

通过使用蓝图中的response()装饰器装饰我们的方法和函数,我们不再需要返回负载加状态码的元组。Flask-smorest 会为我们处理添加状态码。默认情况下,flask-smorest 将 200 状态码添加到我们的响应中。如果我们想自定义它,我们只需在装饰器中指定所需的status_code参数。

当蓝图中的arguments()装饰器验证和反序列化请求负载时,蓝图中的response()装饰器不执行验证,仅序列化负载。我们将在 6.11 节中更详细地讨论此功能,并了解我们如何确保在序列化之前验证数据。

列表 6.12 向 API 端点添加验证

# file: kitchen/api/api.py

import uuid
from datetime import datetime

from flask.views import MethodView
from flask_smorest import Blueprint

from api.schemas import (
    GetScheduledOrderSchema,
    ScheduleOrderSchema,
    GetScheduledOrdersSchema,
    ScheduleStatusSchema, ①
) 

blueprint = Blueprint('kitchen', __name__, description='Kitchen API')

...

@blueprint.route('/kitchen/schedulles')
class KitchenSchedules(MethodView):

    @blueprint.response(status_code=200, schema=GetScheduledOrdersSchema) ②
    def get(self):
        return {'schedules': schedules}

    @blueprint.arguments(ScheduleOrderSchema)                              ③
    @blueprint.response(status_code=201, schema=GetScheduledOrderSchema) ④
    def post(self, payload):
        return schedules[0]

@blueprint.route('/kitchen/schedules/<schedule_id>')
class KitchenSchedule(MethodView):

    @blueprint.response(status_code=200, schema=GetScheduledOrderSchema)
    def get(self, schedule_id):
        return schedules[0]

    @blueprint.arguments(ScheduleOrderSchema)
    @blueprint.response(status_code=200, schema=GetScheduledOrderSchema)
    def put(self, payload, schedule_id):
        return schedules[0]

    @blueprint.response(status_code=204)
    def delete(self, schedule_id):
        return

@blueprint.response(status_code=200, schema=GetScheduledOrderSchema)
@blueprint.route(
    '/kitchen/schedules/<schedule_id>/cancel', methods=['POST']
)
def cancel_schedule(schedule_id):
    return schedules[0]

@blueprint.response(status_code=200, schema=ScheduleStatusSchema)
@blueprint.route(
    '/kitchen/schedules/<schedule_id>/status', methods=['GET']
)
def get_schedule_status(schedule_id):
    return schedules[0]

① 我们导入我们的 Marshmallow 模型。

② 我们使用蓝图中的response()装饰器来注册用于响应负载的 Marshmallow 模型。

③ 我们使用蓝图中的arguments()装饰器来注册用于请求负载的 Marshmallow 模型。

④ 我们将status_code参数设置为所需的状态码。

要查看实现中新的更改的效果,请再次访问 http://127.0.0.1:5000/docs URL。如果您使用--reload标志运行服务器,更改将自动重新加载。否则,停止服务器并重新运行。如图 6.8 所示,flask-smorest 现在识别 API 中需要使用的验证模式,因此它们在 Swagger UI 中表示。如果您现在尝试操作 UI,例如通过调用 GET /kitchen/schedules端点,您将能够看到响应负载。

图片

图 6.8 Swagger UI 显示了 POST /kitchen/schedules端点请求负载的模式,并提供了示例。

API 看起来不错,我们几乎完成了实现。下一步是向 GET /kitchen/schedules端点添加 URL 查询参数。继续下一节,了解如何做到这一点!

6.10 验证 URL 查询参数

在本节中,我们学习如何向 GET /kitchen/ schedules端点添加 URL 查询参数。如图表 6.13 所示,GET /kitchen/schedules端点接受三个 URL 查询参数:

  • progress (布尔值)—指示订单是否正在进行中。

  • limit (整数)—限制端点返回的结果数量。

  • since (日期时间)—通过订单计划的时间过滤结果。日期时间格式的日期是 ISO 日期,具有以下结构:YYYY-MM-DDTHH:mm:ssZ。此日期格式的一个示例是 2021-08-31T01:01:01Z。有关此格式的更多信息,请参阅tools.ietf.org/html/rfc3339 #section-5.6

列表 6.13 GET /kitchen/schedules URL 查询参数规范

# file: kitchen/oas.yaml

paths:
  /kitchen/schedules:
    get:
      summary: Returns a list of orders scheduled for production
      parameters:
        - name: progress
          in: query
          description: >-
            Whether the order is in progress or not.
            In progress means it's in production in the kitchen.
          required: false
          schema:
            type: boolean
        - name: limit
          in: query
          required: false
          schema:
            type: integer
        - name: since
          in: query
          required: false
          schema:
            type: string
            format: 'date-time'

我们如何在 flask-smorest 中实现 URL 查询参数?首先,我们需要创建一个新的 marshmallow 模型来表示它们。我们使用 marshmallow 定义厨房 API 的 URL 查询参数。您可以将 URL 查询参数的模型添加到 kitchen/api/schemas.py 中,与其他 marshmallow 模型一起。

列表 6.14 marshmallow 中的 URL 查询参数

# file: kitchen/api/schemas.py

from marshmallow import Schema, fields, validate, EXCLUDE

...

class GetKitchenScheduleParameters(Schema):
    class Meta:
        unknown = EXCLUDE

    progress = fields.Boolean() ①
    limit = fields.Integer()
    since = fields.DateTime()

① 我们定义 URL 查询参数的字段。

我们使用蓝图中的 arguments() 装饰器注册 URL 查询参数的方案。我们指定方案中定义的属性预期在 URL 中,因此我们将 location 参数设置为 query

列表 6.15 向 GET /kitchen/schedules 添加 URL 查询参数

# file: kitchen/api/api.py

import uuid
from datetime import datetime

from flask.views import MethodView
from flask_smorest import Blueprint

from api.schemas import (
    GetScheduledOrderSchema, ScheduleOrderSchema, GetScheduledOrdersSchema,
    ScheduleStatusSchema, GetKitchenScheduleParameters                     ①
)

blueprint = Blueprint('kitchen', __name__, description='Kitchen API')

...

@blueprint.route('/kitchen/schedules')
class KitchenSchedules(MethodView):

    @blueprint.arguments(GetKitchenScheduleParameters, location='query') ②
    @blueprint.response(status_code=200, schema=GetScheduledOrdersSchema)
    def get(self, parameters):                                             ③
        return schedules

...

① 我们导入 URL 查询参数的 marshmallow 模型。

② 我们使用 arguments() 装饰器注册模型,并将位置参数设置为查询。

③ 我们在函数签名中捕获 URL 查询参数。

如果你重新加载 Swagger UI,你会看到 GET /kitchen/schedules 端点现在接受三个可选的 URL 查询参数(如图 6.9 所示)。我们应该将这些参数传递给我们的业务层,它将使用它们来过滤结果列表。URL 查询参数以字典的形式出现。如果用户没有设置任何查询参数,则字典将为空,因此评估为 False。由于 URL 查询参数是可选的,我们通过使用字典的 get() 方法来检查它们的存在。由于 get() 在参数未设置时返回 None,我们知道当参数的值不是 None 时,参数已被设置。我们将在第七章实现业务层,但我们可以使用查询参数来过滤我们的内存中的调度列表。

图 6.9 Swagger UI 显示了 GET /kitchen/schedules 端点的 URL 查询参数,并提供表单字段,我们可以填写以尝试不同的值。

列表 6.16 在 GET /kitchen/schedules 中使用过滤器

# file: kitchen/api/api.py

...

@blueprint.route('/kitchen/schedules')
class KitchenSchedules(MethodView):

    @blueprint.arguments(GetKitchenScheduleParameters, location='query') 
    @blueprint.response(status_code=200, schema=GetScheduledOrdersSchema)
    def get(self, parameters):
        if not parameters:                                    ①
            return {'schedules': schedules}

        query_set = [schedule for schedule in schedules]      ②

        in_progress = parameters.get(progress)                ③
        if in_progress is not None:
            if in_progress:
                query_set = [
                    schedule for schedule in schedules
                    if schedule['status'] == 'progress'
                ]
            else:
                query_set = [
                    schedule for schedule in schedules
                    if schedule['status'] != 'progress'
                ]

        since = parameters.get('since')
        if since is not None:
            query_set = [
                schedule for schedule in schedules
                if schedule['scheduled'] >= since
            ]

        limit = parameters.get('limit')
        if limit is not None and len(query_set) > limit: ④
            query_set = query_set[:limit]

        return {'schedules': query_set}                       ⑤
...

① 如果没有设置任何参数,我们返回完整的调度列表。

② 如果用户设置了任何 URL 查询参数,我们使用它们来过滤调度列表。

③ 我们通过使用字典的 get() 方法检查每个 URL 查询参数的存在。

④ 如果设置了 limit 并且其值小于 query_set 的长度,我们返回 query_set 的子集。

⑤ 我们返回过滤后的调度列表。

现在我们知道了如何使用 flask-smorest 处理 URL 查询参数,还有一个主题需要讨论,那就是在序列化之前的数据验证。继续到下一节了解更多关于这个主题的信息!

6.11 在序列化响应之前验证数据

现在我们有了用于验证请求负载的模式,并且我们已经将它们与我们的路由连接起来,我们必须确保我们的响应负载也被验证。在本节中,我们将学习如何使用 marshmallow 模型来验证数据。我们将使用此功能来验证我们的响应负载,但你也可以使用相同的方法来验证任何类型的数据,例如配置对象。

图片

图 6.10 展示了使用 flask-smorest 框架的数据负载的工作流程。响应负载应该来自“可信区域”,因此在序列化之前不进行验证。

当我们在响应中发送负载时,flask-smorest 使用 marshmallow 序列化负载。然而,如图 6.10 所示,它不会验证其是否正确形成。³正如你在图 6.11 中看到的,与 marshmallow 不同,FastAPI 在序列化响应之前会验证我们的数据。

图片

图 6.11 展示了使用 FastAPI 框架的数据负载的工作流程。在序列化响应之前,FastAPI 会验证负载是否符合指定的模式。

marshmallow 在序列化之前不执行验证的事实并不一定是不受欢迎的。事实上,可以认为这是一种期望的行为,因为它将序列化任务与验证负载的任务解耦。有两个理由可以证明为什么 marshmallow 在序列化之前不执行验证(mng.bz/9Vwx):

  • 它提高了性能,因为验证是缓慢的。

  • 从服务器来的数据应该被信任,因此不需要进行验证。

marshmallow 维护者用来证明这一设计决策的理由是合理的。然而,如果你在 API 和网站方面工作的时间足够长,你就会知道,通常情况下,即使是来自你自己的系统,也很少有什么可以信任的。

零信任方法对于健壮的 API 集成至关重要,API 集成失败的原因既可能是服务器发送了错误的负载,也可能是客户端向服务器发送了格式不正确的负载。在可能的情况下,采取零信任方法来设计我们的系统,并验证所有数据,无论其来源如何。

我们从厨房 API 发送的数据来自数据库。在第七章中,我们将学习模式和技巧来确保我们的数据库包含正确格式的数据。然而,即使在最严格的安全措施下,总有可能出现格式不正确的数据进入数据库。尽管这种情况不太可能发生,但我们不希望在这种情况下破坏用户体验,在序列化之前验证我们的数据可以帮助我们做到这一点。

幸运的是,使用 marshmallow 验证数据非常简单。我们只需获取我们想要验证的模式的实例,并使用其 validate() 方法传递我们需要验证的数据。如果 validate() 函数发现错误,它不会抛出异常。相反,它返回一个包含错误的字典,如果没有错误则返回一个空字典。为了了解这是如何工作的,请在终端中输入 python 打开 Python 壳,并运行以下代码:

>>> from api.schemas import GetScheduledOrderSchema
>>> GetScheduledOrderSchema().validate({'id': 'asdf'})
{'order': ['Missing data for required field.'], 'scheduled': ['Missing
➥ data for required field.'], 'status': ['Missing data for required 
➥ field.'], 'id': ['Not a valid UUID.']}

在第 1 行导入模式后,在第 2 行我们传递一个包含仅 id 字段的无效日程表示,在第 3 行 marshmallow 有助于报告 orderscheduledstatus 字段缺失,以及 id 字段不是一个有效的 UUID。我们可以使用这些信息在服务器上抛出一个有用的错误消息,如列表 6.17 所示。我们在构建和返回查询集之前在 GET /kitchen/schedules 方法视图中验证日程,并逐个迭代日程列表进行验证。在验证之前,我们创建日程的深拷贝,这样我们就可以将其 datetime 对象转换为 ISO 日期字符串,因为这是验证方法期望的格式。如果我们得到验证错误,我们将抛出 marshmallow 的 ValidationError 异常,该异常会自动将错误消息格式化为适当的 HTTP 响应。

列表 6.17 在序列化前验证数据

# file: kitchen/api/api.py

import copy
import uuid
from datetime import datetime

from flask.views import MethodView
from flask_smorest import Blueprint
from marshmallow import ValidationError                               ①

...

@blueprint.route('/kitchen/schedules')
class KitchenSchedules(MethodView):

    @blueprint.arguments(GetKitchenScheduleParameters, location='query') 
    @blueprint.response(status_code=200, schema=GetScheduledOrdersSchema)
    def get(self, parameters):
        for schedule in schedules:
            schedule = copy.deepcopy(schedule)
            schedule['scheduled'] = schedule['scheduled'].isoformat()
            errors = GetScheduledOrderSchema().validate(schedule)     ②
            if errors:
                raise ValidationError(errors)                         ③
        ...
        return {'schedules': query_set}
...

① 我们从 marshmallow 导入 ValidationError 类。

② 我们将验证错误捕获在 errors 变量中。

③ 如果 validate() 函数发现错误,我们将抛出 ValidationError 异常。

请注意,marshmallow 中存在已知的验证问题,尤其是在你的模型包含用于确定哪些字段应该序列化以及哪些字段不应该序列化的复杂配置时(有关更多信息,请参阅 github.com/marshmallow-code/marshmallow/issues/682)。此外,请注意,验证是一个已知的过程,速度较慢,因此如果你正在处理大量有效负载,你可能想使用不同的工具来验证你的数据,只验证数据的一部分,或者完全跳过验证。然而,只要可能,你最好对你的数据进行验证。

这标志着厨房 API 功能实现的完成。然而,API 仍然在所有端点返回相同的模拟日程安排。在结束这一章之前,让我们添加一个内存中的日程列表的最小实现,这样我们就可以使我们的 API 动态化。这将允许我们验证所有端点是否按预期工作。

6.12 实现内存中的日程列表

在本节中,我们实现了一个简单的日程表内存表示,以便我们可以从 API 中获得动态结果。在本节结束时,我们将能够通过 API 安排订单、更新它们以及取消它们。因为日程表被管理为一个内存列表,所以每次服务器重启时,我们都会丢失之前会话的信息。在下一章中,我们将通过向我们的服务添加持久化层来解决这个问题。

我们在内存中的日程表集合将由一个 Python 列表表示,我们将在 API 层中简单地添加和删除元素。列表 6.18 显示了我们需要对kitchen/api/api.py进行的更改,以实现这一点。我们初始化一个空列表并将其分配给名为schedules的变量。我们还重构了我们的数据验证代码到一个名为validate_schedule()的独立函数中,这样我们就可以在其他视图方法或函数中重用它。当KitchenSchedulespost()方法接收到日程表有效载荷时,我们设置服务器端属性,如 ID、预定时间和状态。在单例端点中,我们通过遍历日程表列表并检查它们的 ID 来查找请求的日程表。如果找不到请求的日程表,我们返回一个 404 响应。

列表 6.18:日程表的内存实现

# file: kitchen/api/api.py

import copy
import uuid
from datetime import datetime

from flask import abort
...

schedules = []                                                             ①

def validate_schedule(schedule):                                           ②
    schedule = copy.deepcopy(schedule)
    schedule['scheduled'] = schedule['scheduled'].isoformat()
    errors = GetScheduledOrderSchema().validate(schedule)
    if errors:
        raise ValidationError(errors)

@blueprint.route('/kitchen/schedules')
class KitchenSchedules(MethodView):

    @blueprint.arguments(GetKitchenScheduleParameters, location='query')  
    @blueprint.response(GetScheduledOrdersSchema)
    def get(self, parameters):
        ...

    @blueprint.arguments(ScheduleOrderSchema)
    @blueprint.response(status_code=201, schema=GetScheduledOrderSchema,)
    def post(self, payload):
        payload['id'] = str(uuid.uuid4())                                  ③
        payload['scheduled'] = datetime.utcnow()
        payload['status'] = 'pending'
        schedules.append(payload)
        validate_schedule(payload)
        return payload

@blueprint.route('/kitchen/schedules/<schedule_id>')
class KitchenSchedule(MethodView):

    @blueprint.response(status_code=200, schema=GetScheduledOrderSchema)
    def get(self, schedule_id):
        for schedule in schedules:
            if schedule['id'] == schedule_id:
                validate_schedule(schedule)
                return schedule
        abort(404, description=f'Resource with ID {schedule_id} not found')④

    @blueprint.arguments(ScheduleOrderSchema)
    @blueprint.response(status_code=200, schema=GetScheduledOrderSchema)
    def put(self, payload, schedule_id):
        for schedule in schedules:
            if schedule['id'] == schedule_id:
                schedule.update(payload)                                   ⑤
                validate_schedule(schedule)
                return schedule
        abort(404, description=f'Resource with ID {schedule_id} not found')

    @blueprint.response(status_code=204)
    def delete(self, schedule_id):
        for index, schedule in enumerate(schedules):
            if schedule['id'] == schedule_id:
                schedules.pop(index)                                       ⑥
                return
        abort(404, description=f'Resource with ID {schedule_id} not found')

@blueprint.response(status_code=200, schema=GetScheduledOrderSchema)
@blueprint.route(
    '/kitchen/schedules/<schedule_id>/cancel', methods=['POST']
)
def cancel_schedule(schedule_id):
    for schedule in schedules:
        if schedule['id'] == schedule_id:
            schedule['status'] = 'cancelled'                               ⑦
            validate_schedule(schedule)
            return schedule
    abort(404, description=f'Resource with ID {schedule_id} not found')

@blueprint.response(status_code=200, schema=ScheduleStatusSchema)
@blueprint.route(
    '/kitchen/schedules/<schedule_id>/status', methods=['GET']
)
def get_schedule_status(schedule_id):
    for schedule in schedules:
        if schedule['id'] == schedule_id:
            validate_schedule(schedule)
            return {'status': schedule['status']}
    abort(404, description=f'Resource with ID {schedule_id} not found')

① 我们将日程表初始化为一个空列表。

② 我们将数据验证代码重构为一个函数。

③ 我们设置日程表的服务器端属性,如 ID。

④ 如果找不到日程表,我们返回一个 404 响应。

⑤ 当用户更新日程表时,我们使用有效载荷的内容更新日程表属性。

⑥ 我们从列表中删除日程表并返回一个空响应。

⑦ 我们将日程表的状态设置为已取消。

如果你重新加载 Swagger UI 并测试端点,你会看到你现在能够添加日程表、更新它们、取消它们、列出和过滤它们、获取它们的详细信息以及删除它们。在下一节中,你将学习如何覆盖 flask-smorest 动态生成的 API 规范,以确保我们提供我们的 API 设计而不是实现。

6.13 覆盖 flask-smorest 动态生成的 API 规范

正如我们在 6.4 节中学到的,从代码动态生成的 API 规范对于测试和可视化我们的实现是有好处的,但为了发布我们的 API,我们想确保我们提供的是我们的 API 设计文档。为此,我们将覆盖 flask-smorest 动态生成的 API 文档。首先,我们需要安装 PyYAML,我们将使用它来加载 API 设计文档:

$ pipenv install pyyaml

我们使用自定义的APISpec对象覆盖 API 对象的spec属性。我们还覆盖了APISpecto_dict()方法,以便它返回我们的 API 设计文档。

列表 6.19:覆盖 flask-smorest 动态生成的 API 规范

# file: kitchen/app.py

from pathlib import Path

import yaml
from apispec import APISpec
from flask import Flask
from flask_smorest import Api

from api.api import blueprint
from config import BaseConfig

app = Flask(__name__)

app.config.from_object(BaseConfig)

kitchen_api = Api(app)

kitchen_api.register_blueprint(blueprint)

api_spec = yaml.safe_load((Path(__file__).parent / "oas.yaml").read_text())
spec = APISpec(
    title=api_spec["info"]["title"],
    version=api_spec["info"]["version"],
    openapi_version=api_spec["openapi"],
)
spec.to_dict = lambda: api_spec
kitchen_api.spec = spec

这标志着我们使用 Python 实现 REST API 的旅程的结束。在下一章中,我们将学习如何遵循最佳实践和有用的设计模式来实施服务的其余部分。事情变得越来越有趣了!

摘要

  • 你可以使用 FastAPI 和 flask-smorest 等框架在 Python 中构建 REST API,这些框架拥有强大的工具和库生态系统,使构建 API 更加容易。

  • FastAPI 是一个现代 API 框架,它使构建高性能和健壮的 REST API 更加容易。FastAPI 是建立在 Starlette 和 pydantic 之上的。Starlette 是一个高性能的异步服务器框架,而 pydantic 是一个使用类型提示来创建验证规则的数据验证库。

  • Flask-smorest 是建立在 Flask 之上的,并作为一个 Flask 蓝图工作。Flask 是 Python 最受欢迎的框架之一,通过使用 flask-smorest,你可以利用其丰富的库生态系统,使构建 API 更加容易。

  • FastAPI 使用 pydantic 进行数据验证。Pydantic 是一个现代框架,它使用类型提示来定义验证规则,这导致代码更加清晰和易于阅读。默认情况下,FastAPI 验证请求和响应负载。

  • Flask-smorest 使用 marshmallow 进行数据验证。Marshmallow 是一个经过实战考验的框架,它使用类字段来定义验证规则。默认情况下,flask-smorest 不验证响应负载,但你可以通过使用 marshmallow 模型的 validate() 方法来验证响应。

  • 使用 flask-smorest,你可以使用 Flask 的 MethodView 来创建表示 URL 路径的基于类的视图。在基于类的视图中,你将 HTTP 方法实现为类的成员方法,例如 get()post()

  • 容忍的读取模式遵循波斯尔的法则,该法则建议对 HTTP 请求中的错误保持容忍,并验证响应负载。在设计你的 API 时,你必须平衡容忍的读取模式的好处与由于错误(如拼写错误)导致的集成失败的风险。


¹ 约翰·波斯尔,编辑,“传输控制协议”,RFC 761,第 13 页,tools.ietf.org/html/rfc761

² 要了解为什么在使用模型组合时 additionalProperties 不起作用,请参阅 JSON Schema 的 GitHub 存储库中关于此主题的优秀讨论:github.com/json-schema-org/json-schema-spec/issues/556

³ 在 3.0.0 版本之前,marshmallow 会在序列化之前执行验证(参见变更日志:github.com/marshmallow-code/marshmallow/blob/dev/CHANGELOG.rst#300-2019-08-18)。

7 微服务实现模式

本章涵盖

  • 六边形架构如何帮助我们设计松散耦合的服务

  • 为微服务实现业务层,并使用 SQLAlchemy 实现数据库模型

  • 使用仓储模式将数据层从业务层解耦

  • 使用工作单元模式确保所有事务的原子性,并使用依赖倒置原则构建对变化具有弹性的软件

  • 使用控制反转原则和依赖注入模式解耦相互依赖的组件

在本章中,我们将学习如何实现微服务的业务层。在之前的章节中,我们学习了如何设计和实现 REST API。在这些实现中,我们使用了服务管理的资源的内存表示。我们采取这种方法是为了保持实现简单,并让我们能够专注于服务的 API 层。

在本章中,我们将通过添加业务层和数据层来完成订单服务的实现。业务层将实现订单服务的功能,例如接收订单、处理其支付或安排其生产。对于其中一些任务,订单服务需要与其他服务的协作,我们将学习处理这些集成模式的有用模式。

数据层将实现服务的数据管理功能。订单服务拥有并管理订单数据,因此我们将实现一个持久化存储解决方案及其接口。然而,作为用户关于订单生命周期的网关,订单服务还需要从其他服务中获取数据——例如,在生产和交付过程中跟踪订单。我们还将学习处理对这些服务访问的有用模式。

为了阐述服务的实现模式,我们还将涵盖保持我们微服务各个部分松散耦合所需的架构布局元素。松散耦合将帮助我们确保我们可以在不修改依赖它的其他组件的情况下更改特定组件的实现。它还将使我们的代码库更易于阅读、维护和测试。本章的代码可在本书提供的存储库中的 ch07 目录中找到。

7.1 微服务的六边形架构

本章介绍了六边形架构的概念以及我们将如何将其应用于订单服务的开发。在第二章中,我们介绍了三层架构模式,以帮助我们以模块化和松散耦合的方式组织应用程序的组件。在本节中,我们将进一步应用六边形架构的概念来指导我们的设计。

在 2005 年,Alistair Cockburn 引入了六边形架构的概念,也称为端口和适配器架构,作为一种帮助软件开发者将代码结构化为松散耦合组件的方法。¹ 如图 7.1 所示,六边形或端口和适配器架构背后的思想是,在任何应用程序中,都有一个核心逻辑块实现了服务的功能,并且围绕这个核心,我们“附加”适配器以帮助核心与外部组件通信。例如,Web API 是一个适配器,帮助核心通过互联网与 Web 客户端通信。数据库也是如此,它只是一个帮助服务持久化数据的简单外部组件。如果我们想更换数据库,服务仍然是一样的。因此,数据库也是一个适配器。

图 7.1

图 7.1 在六边形架构中,我们在应用程序中区分一个核心层,即业务层,它实现了服务的功能。其他组件,如 Web API 接口或数据库,被认为是依赖于业务层的适配器。

这如何帮助我们构建松散耦合的服务?六边形架构要求我们将服务的核心逻辑和适配器的逻辑严格分离。换句话说,实现我们的 Web API 层的逻辑不应该干扰核心业务逻辑的实现。同样,对于数据库也是如此:无论我们选择什么技术,以及其设计和特性,它都不应该干扰核心业务逻辑。我们如何实现这一点?通过在核心业务层和适配器之间构建端口。端口是技术无关的接口,用于连接业务层和适配器。在本章的后面部分,我们将学习一些设计模式,这些模式将帮助我们设计那些端口或接口。

当确定核心业务逻辑和适配器之间的关系时,我们应用依赖倒置原则,该原则指出(见图 7.2 以获得澄清)

  • 高级模块不应该依赖于低级细节。相反,两者都应该依赖于抽象,例如接口。例如,在保存数据时,我们希望通过一个不需要了解数据库特定实现细节的接口来完成。无论是 SQL 数据库、NoSQL 数据库还是缓存存储,接口应该是相同的。

  • 抽象不应该依赖于细节。相反,细节应该依赖于抽象。² 例如,在设计业务层和数据层之间的接口时,我们希望确保接口不会根据数据库的实现细节而改变。相反,我们修改数据层以使其与接口兼容。换句话说,数据层依赖于接口,而不是相反。

图片

图 7.2 我们应用依赖倒置原则来确定哪些组件驱动变化。在六边形架构中,这意味着我们的适配器将依赖于核心业务层公开的接口。

定义:依赖倒置原则鼓励我们针对接口设计我们的软件,并确保我们不会在组件的低级细节之间创建依赖。

依赖倒置的概念经常与控制反转和控制注入的概念一起出现。这些是相关但不同的概念。正如我们在第 7.5 节中将要看到的,控制反转原则包括通过执行上下文(也称为控制反转容器)提供代码依赖。为了提供这样的依赖,我们可以使用依赖注入模式,我们将在第 7.5 节中描述它。

这在实践中意味着我们应该让适配器依赖于核心业务逻辑公开的接口。也就是说,我们的 API 层了解核心业务逻辑的接口是可以的,但我们的业务逻辑了解 API 层的具体细节或 HTTP 协议的低级细节是不可以的。对于数据库也是如此:我们的数据层应该知道应用程序的工作方式以及如何适应我们选择的存储技术,但核心业务层对数据库的了解应该是具体的。我们的业务层将公开一个接口,所有其他组件都将针对它实现。

我们到底是通过依赖倒置原则来反转什么?这个原则反转了我们对软件的思考方式。不是先构建软件的低级细节,然后再在其上构建接口的更传统的方法,依赖倒置原则鼓励我们首先考虑接口,然后针对它们构建低级细节。³

正如图 7.3 所示,当我们谈到订单服务时,我们将有一个核心包来实现服务的功能。这包括处理订单及其支付、安排其生产或跟踪其进度的能力。核心服务包将向应用程序的其他组件公开接口。另一个包实现了 Web API 层,我们的 API 模块将使用业务层接口中的函数和类来响应用户的请求。另一个包实现了数据层,它知道如何与数据库交互并返回业务对象供核心业务层使用。

图片

图 7.3 订单服务由三个包组成:核心业务逻辑,它实现了服务的功能;API 层,它允许客户端通过 HTTP 与服务交互;以及数据层,它允许服务与数据库交互。核心业务逻辑暴露了 API 层和数据层实现的接口。

现在我们知道了我们将如何构建应用程序的结构,是时候开始实现了!在下一节中,我们将设置环境以开始处理服务。

7.2 设置环境和项目结构

在本节中,我们设置环境以处理订单服务并概述项目的高级结构。与前面的章节一样,我们将使用 Pipenv 来管理我们的依赖项。运行以下命令以设置 Pipenv 环境并激活它:

$ pipenv --three
$ pipenv shell

我们将在以下章节中按需安装我们的依赖项。或者,如果您愿意,从 ch07 文件夹下的 GitHub 仓库复制 Pipfile 和 Pipfile.lock 文件,然后运行pipenv install

我们的服务实现将位于名为 orders 的文件夹下,所以请创建它。为了加强核心业务层与 API 和数据库适配器之间的关注点分离,我们将它们各自实现于不同的目录中,如图 7.4 所示。业务层将位于 orders/orders_service。由于 API 层是一个网络组件,它将位于 orders/web,其中包含订单服务的网络适配器。在这种情况下,我们只包含一种类型的网络适配器,即 REST API,但没有任何东西阻止您添加一个从服务器返回动态渲染内容的网络适配器,就像在更传统的 Django 应用程序中做的那样。

图片

图 7.4 为了加强关注点分离,我们在不同的目录中实现应用程序的每一层:orders_service 用于核心业务层;repository 用于数据层;web/api 用于 API 层。

数据层将位于 orders/repository。虽然“repository”这个名字对于我们的数据层来说可能看起来不太合适,但我们选择这个名字是因为我们将实现仓库模式来与我们的数据接口。这个概念将在 7.4 节中变得更加清晰。在第二章和第六章中,我们介绍了 API 层的实现,所以请将 GitHub 仓库下 ch07/order/web 中的文件复制到您的本地目录。请注意,API 实现已经为本章进行了适配。

列表 7.1 订单服务的高级结构

├── Pipfile                ①
├── Pipfile.lock
└── orders                 ②
    ├── orders_service     ③
    ├── repository         ④
    └── web                ⑤
        ├── api            ⑥
        │   ├── api.py
        │   └── schemas.py
        └── app.py         ⑦

① Pipfile 包含依赖项列表。

② 订单服务的完整实现

③ 业务层

④ 数据层

⑤ 网络适配器

⑥ REST API 实现

⑦ 此文件包含我们的网络服务器对象实例。

由于文件夹结构已更改,我们的 FastAPI 应用程序对象的路径也发生了变化,因此现在运行 API 服务器的命令是

$ uvicorn orders.web.app:app --reload

由于文件夹结构的变化,一些导入路径和文件位置也发生了变化。有关更改的完整列表,请参阅 GitHub 存储库中本书的 ch07 文件夹。

现在我们已经设置了项目并准备开始,是时候着手实现部分了。进入下一节,了解如何将数据库模型添加到服务中!

7.3 实现数据库模型

在上一节中,我们学习了如何将我们的项目结构划分为三个不同的层:核心业务层、API 层和数据层。这种结构强化了每一层之间的关注点分离,正如我们在 7.1 节中学到的六边形架构模式所推荐的那样。现在我们知道了代码的结构,是时候专注于实现了。在本节中,我们将定义订单服务的数据库模型;也就是说,我们将设计数据库表及其字段。我们从数据库开始实现,因为它将有助于本章其余部分的讨论。在实际环境中,你可能会从业务层开始,模拟数据层,并在每个层之间迭代,直到完成实现。只需记住,本章中我们采取的线性方法并不是要反映实际的开发过程,而是旨在说明我们想要解释的概念。

为了使本章内容简单易懂,我们将使用 SQLite 作为我们的数据库引擎。SQLite 是一个基于文件的数据库系统。使用它时,我们不需要设置和运行服务器,就像使用 PostgreSQL 或 MySQL 那样,也不需要配置即可开始使用。Python 的核心库内置了对 SQLite 的支持,这使得它成为在准备迁移到生产就绪数据库系统之前进行快速原型设计和实验的合适选择。

我们不会手动管理数据库连接和查询。也就是说,我们不会编写自己的 SQL 语句来与数据库交互。相反,我们将使用 SQLAlchemy——在 Python 生态系统中最受欢迎的 ORM(对象关系映射器)。ORM 是一个实现数据映射模式的框架,它允许我们将数据库中的表映射到对象上。

定义 一个 数据映射器 是围绕数据库表和行的一个对象包装器。它以类方法的形式封装数据库操作,并允许我们通过类属性访问数据字段。⁴

如您在图 7.5 中所见,使用 ORM 可以使管理我们的数据更容易,因为它为我们提供了数据库表的一个类接口。这使我们能够利用面向对象编程的好处,包括向我们的数据库模型添加自定义方法和属性,以增强其功能并封装其行为。

图 7.5 使用 ORM,我们可以将数据模型实现为映射到数据库表的类。由于模型是类,我们可以通过添加自定义方法来增强它们,以添加新的功能。

随着时间的推移,我们的数据库模型将会发生变化,我们需要能够跟踪这些变化。改变我们数据库的模式被称为迁移。随着数据库的发展,我们将积累越来越多的迁移。我们需要跟踪我们的迁移,因为它们允许我们在不同的环境中可靠地复制数据库模式,并且有信心地将数据库更改部署到生产环境中。为了管理这个复杂的任务,我们将使用 Alembic。Alembic 是一个与 SQLAlchemy 无缝集成的模式迁移库。

让我们首先通过运行以下命令来安装这两个库:

$ pipenv install sqlalchemy alembic

在我们开始工作于数据库模型之前,让我们设置 Alembic。(如需更多帮助,请查看我关于使用 SQLAlchemy 设置 Alembic 的视频教程,链接为 youtu.be/nt5sSr1A_qw。)运行以下命令以创建一个包含我们数据库所有迁移历史的 migrations 文件夹:

$ alembic init migrations

这将创建一个名为 migrations 的文件夹,其中包含一个名为 env.py 的配置文件和一个 versions/ 目录。versions/ 目录将包含迁移文件。设置命令还会创建一个名为 alembic.ini 的配置文件。为了使 Alembic 能够与 SQLite 数据库一起工作,打开 alembic.ini,找到包含 sqlalchemy.url 变量声明的行,并将其替换为以下内容:

sqlalchemy.url = sqlite:///orders.db

提交 alembic 生成的文件迁移文件夹包含了管理我们数据库模式变化所需的所有信息,因此你应该提交这个文件夹,以及 alembic.ini。这将允许你在新的环境中复制数据库设置。

此外,打开 migrations/env.py 并找到包含以下内容的行:⁵

# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
target_metadata = None

将其替换为以下内容:

from orders.repository.models import Base
target_metadata = Base.metadata

通过将target_metadata设置为我们的Base模型的metadata,我们使 Alembic 能够加载我们的 SQLAlchemy 模型并从它们生成数据库表。接下来,我们将实现我们的数据库模型。在我们跳入实现之前,让我们暂停一下,思考我们需要多少个模型以及每个模型应该具有哪些属性。订单服务的核心对象是订单。用户下单、支付、更新或取消订单。订单有一个生命周期,我们将通过status属性来跟踪它。我们将使用以下属性列表来定义我们的订单模型:

  • ID—订单的唯一标识符。我们将使用通用唯一识别码(UUID)的格式。如今,使用 UUID 而不是递增整数相当常见。UUID 在分布式系统中表现良好,并且有助于隐藏数据库中存在的订单数量信息,从而保护用户。

  • 创建日期—订单下单的时间。

  • 项目—订单中包含的项目列表以及每种产品的数量。由于一个订单可以与任何数量的项目相关联,我们将使用不同的项目模型,并在订单和项目之间创建一对一的关系。

  • 状态—订单在整个系统中的状态。订单可以有以下状态:

    • 创建—订单已下单。

    • 已支付—订单已成功支付。

    • 进度—订单正在厨房生产中。

    • 已取消—订单已被取消。

    • 已派遣—订单正在向用户配送。

    • 已送达—订单已送达用户。

  • 调度 ID—厨房服务中订单的 ID。此 ID 由厨房服务在调度订单生产后创建,我们将使用它来跟踪其在厨房中的进度。

  • 配送 ID—配送服务中订单的 ID。此 ID 由配送服务在调度发货后创建,我们将使用它来跟踪其在配送过程中的进度。

当用户下单时,他们可以将任何数量的项目添加到订单中。每个项目包含用户选择的产品信息、产品的尺寸以及用户希望购买的数量。订单和项目之间存在一对一的关系,因此我们将实现一个项目模型并将它们通过外键关系链接起来。项目模型将具有以下属性列表:

  • ID—以 UUID 格式表示的项目唯一标识符。

  • 订单 ID—表示项目所属订单 ID 的外键。这正是我们能够连接属于同一订单的项目和订单的原因。

  • 产品—用户选择的产品。

  • 尺寸—产品的尺寸。

  • 数量—用户希望购买的产品数量。

我们将 SQLAlchemy 模型放在我们创建的 orders/repository 文件夹下,以封装我们的数据层,在名为 orders/repository/models.py 的文件中。我们将使用这些类与数据库接口,并依赖 SQLAlchemy 在幕后将这些模型转换为相应的数据库表。列表 7.2 显示了订单服务的数据库模型定义。首先,我们使用 SQLAlchemy 的declarative_base()函数创建一个声明性基模型。声明性基模型是一个可以将 ORM 类映射到数据库表和列的类,因此所有我们的数据库模型都必须继承自它。我们通过将它们设置为 SQLAlchemy 的Column类的实例来将类属性映射到特定的数据库列。

要将一个属性映射到另一个模型,我们使用 SQLAlchemy 的relationship()函数。在列表 7.2 中,我们使用relationship()创建OrderModelitems属性与OrderItemModel模型之间的一对多关系。这意味着我们可以通过OrderModelitems属性访问订单中的项目列表。每个项目也通过order_id属性映射到它所属的订单,该属性被定义为外键列。此外,relationship()backref参数允许我们通过一个名为order的属性直接从项目访问完整的订单对象。

由于我们希望我们的 ID 以 UUID 格式,我们创建了一个函数,SQLAlchemy 可以使用它来生成值。如果我们后来切换到具有内置 UUID 值生成支持的数据库引擎,我们将让数据库生成 ID。每个数据库模型都增强了dict()方法,允许我们以字典格式输出记录的属性。由于我们将使用此方法将数据库模型转换为业务对象,dict()方法仅返回与业务层相关的属性。

列表 7.2 SQLAlchemy 的订单服务模型

# file: orders/repository/models.py

import uuid
from datetime import datetime

from sqlalchemy import Column, Integer, String, ForeignKey, DateTime
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship

Base = declarative_base()                                          ①

def generate_uuid():                                               ②
    return str(uuid.uuid4())

class OrderModel(Base):                                            ③
    __tablename__ = 'order'                                        ④

    id = Column(String, primary_key=True, default=generate_uuid)   ⑤
    items = relationship('OrderItemModel', backref='order')        ⑥
    status = Column(String, nullable=False, default='created')
    created = Column(DateTime, default=datetime.utcnow)
    schedule_id = Column(String)
    delivery_id = Column(String)

    def dict(self):                                                ⑦
        return {
            'id': self.id,
            'items': [item.dict() for item in self.items],         ⑧
            'status': self.status,
            'created': self.created,
            'schedule_id': self.schedule_id,
            'delivery_id': self.delivery_id,
        }

class OrderItemModel(Base):
    __tablename__ = 'order_item'

    id = Column(String, primary_key=True, default=generate_uuid)
    order_id = Column(Integer, ForeignKey('order.id'))
    product = Column(String, nullable=False)
    size = Column(String, nullable=False)
    quantity = Column(Integer, nullable=False)

    def dict(self):
        return {
            'id': self.id,
            'product': self.product,
            'size': self.size,
            'quantity': self.quantity
        }

① 我们创建我们的声明性基模型。

② 自定义函数用于为我们的模型生成随机 UUID

③ 所有我们的模型都必须继承自 Base。

④ 映射到此模型的表名

⑤ 每个类属性都通过 Column 类映射到数据库列。

⑥ 我们使用 relationship()与 OrderItemModel 模型创建一对一关系。

⑦ 自定义方法以将我们的对象渲染为 Python 字典

⑧ 我们对每个项目调用 dict()以获取其字典表示形式。

要将模型应用到数据库,请在 ch07 目录下运行以下命令:

$ PYTHONPATH=`pwd` alembic revision --autogenerate -m "Initial migration"

这将在 migrations/versions 下创建一个迁移文件。我们使用 pwd 命令将 PYTHONPATH 环境变量设置为当前目录,这样 Python 就会相对于这个目录查找我们的模型。你应该提交你的迁移文件,并将它们保存在你的版本控制系统(例如,Git 仓库)中,因为它们将允许你在不同的环境中重新创建你的数据库。你可以查看这些文件来了解 SQLAlchemy 将执行哪些数据库操作以应用迁移。要应用迁移并在数据库中为这些模型创建模式,请运行以下命令:

$ PYTHONPATH=`pwd` alembic upgrade heads 

这将在我们的数据库中创建所需的模式。现在,我们的数据库模型已经实现,我们的数据库包含所需的模式,是时候进行下一步了。前往下一节了解存储库模式!

7.4 实现数据访问的存储库模式

在上一节中,我们学习了如何为订单服务设计数据库模型,并通过迁移管理数据库模式的变化。随着我们的数据库模型准备就绪,我们可以与数据库交互以创建订单并管理它们。现在我们必须决定如何使数据对业务层可用。在本节中,我们将首先讨论将业务层与数据层连接的不同策略,我们将了解存储库模式是什么,以及我们如何使用它来在业务层和数据库之间创建接口。然后我们将继续实现它。

7.4.1 存储库模式的案例:它是什麼,为什么它有用?

在本节中,我们讨论了从业务层与数据库交互的不同策略,并介绍了存储库模式作为一种策略,帮助我们解耦业务层与数据库实现细节。

如图 7.6 所示,一种常见的实现业务层和数据库之间交互的策略是在业务层直接使用数据库模型。我们的数据库模型已经包含了关于订单的数据,因此我们可以通过实现业务功能的方法来增强它们。这被称为 活动记录模式,它代表同时携带数据和领域逻辑的对象。⁶ 当我们有一个服务能力和数据库操作之间的一对一映射,或者当我们不需要多个领域的协作时,这种模式是有用的。

图 7.6 一种常见的实现数据层和业务层之间交互的方法是直接在业务层使用数据库模型。

这种方法适用于简单情况;然而,它将业务层的实现与数据库和所选的 ORM 框架耦合在一起。如果我们想稍后更改 ORM 框架,或者如果我们想切换到不涉及 SQL 的不同数据存储技术,会发生什么?在这些情况下,我们不得不修改我们的业务层。这违反了我们第 7.1 节中介绍的原则。记住,数据库是订单服务用来持久化数据的一个适配器,数据库的实现细节不应该泄露到业务逻辑中。相反,数据访问将由我们的数据访问层封装。

为了使业务层与数据层解耦,我们将使用仓库模式。这种模式为我们提供了数据的内存列表接口。这意味着我们可以从列表中获取、添加或删除订单,而仓库将负责将这些操作转换为数据库特定的命令。使用仓库模式意味着数据层向业务层提供了一个一致的接口,以便与数据库交互,无论我们使用哪种数据库技术来存储我们的数据。无论是使用 SQL 数据库如 PostgreSQL,还是使用 NoSQL 数据库如 MongoDB,或者使用内存缓存如 Redis,仓库模式的接口都将保持不变,并将封装与数据库交互所需的任何特定操作。图 7.7 说明了仓库模式如何帮助我们反转数据层和业务层之间的依赖关系。

图 7.7

图 7.7 仓库模式通过向业务层公开内存列表接口来封装数据层的实现细节,并将数据库模型转换为业务对象。

定义 仓库模式是一种软件开发模式,它为我们提供数据存储的内存列表接口。这有助于我们使我们的组件与数据库的低级实现细节解耦。仓库负责管理与数据库的交互,并为我们的组件提供一个一致的接口,无论使用哪种数据库技术。这允许我们在不更改核心业务逻辑的情况下更改数据库系统。

现在我们知道了如何使用仓库模式允许业务层与数据库接口,同时将其实现从数据库的低级细节中解耦,我们将学习如何实现仓库模式。

7.4.2 实现仓库模式

我们如何实现仓储模式?只要我们满足以下约束条件,我们可以使用不同的方法来实现:仓储执行的操作不能由仓储本身提交。这意味着什么?这意味着当我们向仓储添加一个订单对象时,仓储会将订单添加到数据库会话中,但不会提交更改。相反,提交更改的责任将落在OrdersService的消费者(即 API 层)身上。图 7.8 展示了这个过程。

图 7.8

图 7.8 使用仓储模式,API 层使用OrdersServiceplace_order()功能下单。为了下单,OrdersService与订单仓储接口将订单添加到数据库。最后,API 层必须提交更改以在数据库中持久化。

为什么我们不能在仓储内提交数据库更改?首先,因为仓储就像是我们数据在内存中的列表表示,因此它没有数据库会话和事务的概念;其次,因为仓储不是执行数据库事务的正确地方。相反,仓储被调用的上下文提供了执行数据库事务的正确上下文。在许多情况下,我们的应用程序将执行涉及一个或多个仓储以及调用其他服务的多个操作。例如,图 7.9 显示了处理支付所涉及的操作数量:

  1. API 层接收用户的请求,并使用OrdersServicepay_order()方法处理请求。

  2. OrdersService与支付服务接口处理支付。

  3. 如果支付成功,OrdersService将与厨房服务安排订单。

  4. OrdersService使用订单仓储更新数据库中订单的状态。

  5. 如果所有之前的操作都成功,API 层将在数据库中提交事务;否则,它将回滚更改。

图 7.9

图 7.9 在某些情况下,OrdersService必须与多个仓储或服务接口进行交互以执行操作。在这个例子中,OrdersService与支付服务接口以处理支付,然后与厨房服务接口安排订单生产,最后通过订单仓储更新订单的状态。所有这些操作必须同时成功或失败,相应地提交或回滚的责任落在 API 层。

这些步骤可以同步进行,一个接一个,也可以异步进行,没有特定的顺序,但无论采用哪种方法,所有步骤都必须全部成功或全部失败。作为执行上下文的单元,API 层的责任是确保所有更改都按要求提交或回滚。在第 7.6 节中,我们将学习 API 层如何精确地控制数据库会话并提交事务。

至少,一个仓库模式实现包括一个类,该类分别暴露get()add()方法,以便能够从仓库中检索和添加对象。为了我们的目的,我们还将实现以下方法:update()delete()list()。这将简化仓库的 CRUD 接口。

在这个背景下,以下问题值得考虑:当我们通过仓库获取数据时,仓库应该返回什么类型的对象?在许多实现中,你会看到仓库返回数据库模型实例(即,在orders/repository/models.py中定义的类)。在本章中,我们不会这样做。相反,我们将返回代表业务层领域订单的对象。为什么通过仓库返回数据库模型实例是一个坏主意?因为它违背了仓库的目的,即解耦业务层和数据层。记住,我们可能想要改变我们的持久化存储技术或我们的 ORM 框架。如果发生这种情况,我们在第 7.2 节中实现的数据库类将不再存在,而且不能保证新的框架会允许我们返回具有相同接口的对象。因此,我们不希望将我们的业务层与它们耦合。图 7.10 说明了业务层和订单仓库之间的关系。

图片

图 7.10 仓库模式封装了用于管理我们数据的持久化存储技术的实现细节。我们的业务层只与仓库打交道,因此我们可以自由地将我们的持久化存储解决方案更改为不同的技术,而不会影响我们的核心应用程序实现。

我们的订单仓库实现将位于orders/repository/orders_repository.py下。列表 7.3 显示了订单仓库的实现。它接受一个表示数据库会话的必需参数。对象被添加到或从数据库会话中删除。add()update()方法接受表示订单的 Python 字典形式的负载。我们的负载相当简单,所以在这里字典就足够了,但如果我们有更复杂的负载,我们应该考虑使用对象。

除了delete()方法外,仓库的所有方法都从业务层返回Order对象(有关Order的实现细节,请参阅第 7.5 节)。要创建Order的实例,我们使用列表 7.2 中的自定义dict()方法传递 SQLAlchemy 模型的字典表示。在add()方法中,我们还通过Orderorder_参数包含对实际 SQLAlchemy 模型的指针。正如我们将在第 7.5 节中看到的,这个指针将帮助我们提交数据库事务后访问订单的 ID。

OrdersRepositoryget()update()delete()方法使用相同的逻辑在返回、更新或删除之前拉取记录,因此我们定义了一个通用的_get()方法,它知道如何根据 ID 和可选的过滤器获取记录。我们使用 SQLAlchemy 查询对象的first()方法获取记录。first()如果存在则返回记录的实例,否则返回None。或者,也可以使用one()方法,如果记录不存在则引发错误。_get()返回数据库记录,因此它不是为服务层设计的,我们通过在方法名前加下划线来表示这一点。

list()方法接受一个limit参数和可选的过滤器。我们使用 SQLAlchemy 的query对象动态构建我们的查询。我们还利用 SQLAlchemy 的filter_by()方法将额外的过滤器作为关键字参数包含在查询中,并通过添加limit参数来限制查询结果。最后,我们使用我们在列表 7.2 中实现的dict()方法将数据库记录转换为Order对象,以便业务层使用。

仓库实现与 SQLAlchemy 的Session对象的方法紧密耦合,但它也封装了这些细节,并且对于业务层来说,仓库看起来是一个接口,我们向其提交 ID 和有效负载,并返回Order对象。这是仓库的目的:封装和隐藏数据层的实现细节,以便于业务层。这意味着如果我们切换到不同的 ORM 框架或不同的数据库系统,我们只需要修改仓库。

列表 7.3 订单仓库

# file: orders/repository/orders_repository.py

from orders.orders_service.orders import Order
from orders.repository.models import OrderModel, OrderItemModel

class OrdersRepository:
    def __init__(self, session):                                      ①
        self.session = session

    def add(self, items):
        record = OrderModel(
            items=[OrderItemModel(**item) for item in items]
        )                                                             ②
        self.session.add(record)                                      ③
        return Order(**record.dict(), order_=record)                  ④

    def _get(self, id_):                                              ⑤
        return (
            self.session.query(OrderModel)
            .filter(OrderModel.id == str(id_))
            .filter_by(**filters)
            .first()
        )                                                             ⑥

    def get(self, id_):
        order = self._get(id_)                                        ⑦
        if order is not None:                                         ⑧
            return Order(**order.dict())

    def list(self, limit=None, **filters):                            ⑨
        query = self.session.query(OrderModel)                        ⑩
        if 'cancelled' in filters:                                    ⑪
            cancelled = filters.pop('cancelled')
            if cancelled:
                query = query.filter(OrderModel.status == 'cancelled')
            else:
                query = query.filter(OrderModel.status != 'cancelled')
        records = query.filter_by(**filters).limit(limit).all()
        return [Order(**record.dict()) for record in records]         ⑫
    def update(self, id_, **payload):
        record = self._get(id_)
        if 'items' in payload:                                        ⑬
            for item in record.items:
                self.session.delete(item)
            record.items = [
                OrderItemModel(**item) for item in payload.pop('items')
            ]
        for key, value in payload.items():                            ⑭
            setattr(record, key, value)
        return Order(**record.dict())

    def delete(self, id_):
        self.session.delete(self._get(id_))                           ⑮

① 仓库的初始化方法需要一个会话对象。

② 在创建订单记录时,我们也会为订单中的每个项目创建一个记录。

③ 我们将记录添加到会话对象中。

④ 我们返回Order类的一个实例。

⑤ 通过 ID 检索记录的通用方法

⑥ 我们使用 SQLAlchemy 的 first()方法获取记录。

⑦ 我们使用 _get()方法检索记录。

⑧ 如果订单存在,我们返回一个Order对象。

⑨ list()接受一个限制参数和其他可选过滤器。

⑩ 我们动态构建我们的查询。

⑪ 我们使用 SQLAlchemy 的 filter()方法根据订单是否已取消进行过滤。

⑫ 我们返回一个Order对象的列表。

⑬ 要更新订单,我们首先删除与订单链接的项目,然后从提供的有效负载中创建新的项目。

⑭ 我们使用 setattr()函数动态更新数据库对象。

⑮ 要删除记录,我们调用 SQLAlchemy 的 delete()方法。

这完成了我们数据层的实现。我们借助 SQLAlchemy 实现了持久化存储解决方案,并使用存储库模式封装了该解决方案的细节。现在是时候着手业务层,看看它将如何与存储库交互了!

7.5 实现业务层

我们已经为订单服务设计了大量的数据库模型,并使用存储库模式来构建数据接口。现在是时候专注于业务层了!在本节中,我们将实现订单服务的业务层。这就是我们在 7.1 节中介绍并在图 7.1 中展示的六边形架构的核心,为了您的方便,这里以图 7.11 的形式重现。业务层实现了服务的功能。订单服务的业务能力有哪些?从第三章(3.4.2 节)的分析中,我们知道订单服务允许平台用户下单并管理订单。

图 7.11 在六边形架构中,我们在应用程序中区分了一个核心层,即业务层,它实现了服务的功能。其他组件,如 Web API 接口或数据库,被视为依赖于业务层的适配器。

如图 7.12 所示,订单服务通过与其他服务的集成来管理订单的生命周期。以下列表描述了订单服务的功能,并突出了与其他服务的集成(有关进一步说明,请参阅图 7.9):

  • 下单——在系统中创建订单记录。用户支付之前,订单不会被安排到厨房。

  • 处理支付——在支付服务的帮助下处理订单的支付。如果支付服务确认支付成功,订单服务将使用厨房服务安排订单的生产。

  • 更新订单——用户可以随时更新订单,添加或删除其中的项目。为了确认更改,必须进行新的支付,并使用支付服务进行处理。

  • 取消订单——用户可以随时取消他们的订单。根据订单的状态,订单服务将与厨房或配送服务通信以取消订单。

  • 在厨房安排订单生产——支付后,订单服务在厨房的帮助下安排订单的生产。

  • 跟踪订单进度——用户可以通过订单服务跟踪其订单的状态。根据订单的状态,订单服务会与厨房或配送服务联系,以获取订单状态的最新信息。

图片 7-12

图 7.12 为了执行某些功能,订单服务需要与订单服务进行交互。例如,为了处理支付,它必须与支付服务交互,为了安排订单生产,它必须与厨房服务交互。

我们如何在业务层中最好地建模这些操作?我们可以使用不同的方法,但为了使其他组件更容易与业务层交互,我们将通过一个名为OrdersService的类公开一个单一的统一接口。我们将在orders/orders_service/orders_service.py下定义这个类。为了履行其职责,OrdersService使用订单存储库与数据库进行接口。我们可以让OrdersService导入并初始化订单存储库,如下面的代码所示:

from repository.orders_repository import OrdersRepository

class OrdersService:
    def __init__(self):
        self.repository = OrdersRepository()

然而,这样做会给订单服务带来过多的责任,因为它需要知道如何配置订单存储库。它还会使订单存储库的实现和订单服务紧密耦合,如果我们需要使用不同的存储库,我们就无法这样做。如图 7.13 和 7.14 所示,更好的方法是结合使用依赖注入和控制反转原则。

图片 7-13

图 7.13 在传统的软件开发中,依赖关系遵循线性关系,每个组件负责实例化和配置其自身的依赖。在许多情况下,这会使我们的组件与其依赖的低级实现细节紧密耦合。

图片 7-14

图 7.14 在控制反转中,我们通过在运行时使用如依赖注入等方法提供依赖项来解耦组件和它们的依赖项。在这种方法中,提供正确配置的依赖项实例的责任在于上下文。实线显示依赖关系,而虚线显示依赖项是如何注入的。

定义 控制反转 是一种软件开发原则,它鼓励我们在运行时提供依赖项,从而解耦我们的组件和它们的依赖项。这使我们能够控制依赖项的提供方式。实现这一目标的一种流行模式是依赖注入。依赖项实例化和提供的上下文称为 控制反转容器。在订单服务中,一个合适的控制反转容器是请求对象,因为大多数操作都是特定于请求上下文的。

控制反转原则指出,我们应该通过让执行上下文在运行时提供这些依赖项来解耦我们的代码中的依赖项。这意味着,而不是让订单服务导入和实例化订单存储库,我们应该在运行时提供存储库。我们如何做到这一点?我们可以使用不同的模式来向我们的代码提供依赖项,但最流行的一种,由于其简单性和有效性,是依赖注入。

定义 依赖注入 是一种软件开发模式,其中我们在运行时提供代码依赖项。这有助于我们解耦我们的组件与它们所依赖的代码的特定实现细节,因为它们不需要知道如何配置和实例化它们的依赖项。

为了使订单存储库可注入到订单服务中,我们对其进行参数化:

class OrdersService:
    def __init__(self, orders_repository):
        self.orders_repository = orders_repository

现在调用者有责任正确地实例化和配置订单存储库。如图 7.11 所示,这有一个非常理想的结果:根据上下文,我们可以提供不同的存储库实现或添加不同的配置。这使得订单服务在不同上下文中更容易使用。⁷

列表 7.4 显示了OrdersService公开的接口。类的初始化器接受一个订单存储库的实例作为参数,使其可注入。根据控制反转原则,当我们将OrdersService与 API 层集成时,获取有效的订单存储库实例并将其传递给OrdersService的责任将属于 API。这种方法很方便,因为它允许我们在必要时随意交换存储库,并且它将使我们在下一章编写测试变得非常容易。

列表 7.4 OrdersService类的接口

# file: orders/orders_service/orders_service.py

class OrdersService:
    def __init__(self, orders_repository):
        self.orders_repository = orders_repository 

    def place_order(self, items):
        pass

    def get_order(self, order_id):
        pass

    def update_order(self, order_id, items):
        pass

    def list_orders(self, **filters):
        pass

    def pay_order(self, order_id):
        pass

    def cancel_order(self, order_id):
        pass

OrdersService下列出的某些操作,例如支付或调度,是在单个订单级别进行的。由于订单包含数据,因此拥有一个代表订单并具有执行与订单相关任务的类将是有用的。在订单服务上下文中,订单是订单域的核心对象。在领域驱动设计(DDD)中,我们称这些对象为领域对象。这些是订单存储库返回的对象。我们将在orders/orders_service/orders.py下实现我们的Order类。列表 7.5 显示了Order类的初步实现。

除了Order类之外,列表 7.5 还提供了一个OrderItem类,它代表订单中的每个项目。我们将使用Order类来表示在保存到数据库之前和之后的订单。订单的一些属性,如创建时间或其 ID,由数据层设置,并且只能在数据库更改提交后才能知道。正如我们在第 7.4 节中解释的,提交更改超出了存储库的范围,这意味着当我们向存储库添加订单时,返回的对象将不会有这些属性。订单的 ID 和创建时间在提交事务后通过订单的数据库记录变得可用。因此,Order的初始化方法将订单的 ID、创建时间和状态绑定为带前导下划线的私有属性(如self._id),我们在Order类中使用order_参数来持有订单的数据库记录的指针。如果我们检索已保存到数据库的订单的详细信息,_id_created_status将在初始化器中具有相应的值;否则,它们将是None,我们将从order_中拉取它们的值。这就是为什么我们使用property()装饰器定义Orderidcreatedstatus属性,因为它允许我们根据对象的状态来解析它们的值。这是我们将在业务层和数据层之间允许的唯一耦合程度。并且为了确保如果需要的话,这个依赖关系可以轻松移除,我们将order_默认设置为None

列表 7.5 Order业务对象类的实现

# file: orders/orders_service/orders.py

class OrderItem:                                                     ①
    def __init__(self, id, product, quantity, size):                 ②
        self.id = id
        self.product = product
        self.quantity = quantity
        self.size = size

class Order:
    def __init__(self, id, created, items, status, schedule_id=None,
                 delivery_id=None, order_=None):                     ③
        self._id = id                                                ④
        self._created = created
        self.items = [OrderItem(**item) for item in items]           ⑤
        self._status = status
        self.schedule_id = schedule_id
        self.delivery_id = delivery_id

    @property
    def id(self):                                                    ⑥
        return self._id or self._order.id

    @property
    def created(self):       
        return self._created or self._order.created

    @property
    def status(self):       
        return self._status or self._order.status

① 代表订单项的业务对象

② 我们声明了OrderItem初始化方法的参数。

order_参数代表一个数据库模型实例。

④ 由于我们动态解析 ID,我们将提供的 ID 存储为私有属性。

⑤ 我们为每个订单项构建一个OrderItem对象。

⑥ 我们使用property()装饰器动态解析 ID。

除了持有订单的数据之外,Order类还需要处理诸如取消、支付和安排订单等任务。为了完成这些任务,我们必须与外部依赖项接口,例如厨房和支付服务。正如我们在第 7.1 节中解释的,六边形架构的目标是通过适配器封装对外部依赖项的访问。然而,为了使本章的内容简单,我们将在Order类中实现外部 API 调用。封装外部 API 调用的良好适配器模式是外观模式。⁸在我们继续实施之前,我们应该知道这些 API 调用看起来是什么样子。

为了构建订单服务与厨房和支付服务之间的集成,我们希望运行厨房和支付服务,看看它们是如何工作的。然而,我们不需要运行实际的服务。本书 GitHub 仓库中这一章的文件夹包含三个 OpenAPI 文件:一个用于订单 API(ch07/oas.yaml),一个用于厨房 API(ch07/kitchen.yaml),还有一个用于支付 API(ch07/payments.yaml)。kitchen.yaml 和 payments.yaml 告诉我们厨房和支付 API 是如何工作的,这就是我们构建集成所需的所有信息。确保从 GitHub 拉取 kitchen.yaml 和 payments.yaml 文件,以便能够使用以下示例。

事实上,我们还可以使用厨房和支付 API 规范,通过模拟服务器来模拟它们的行为。API 模拟服务器复制 API 背后的服务器,验证我们的请求并返回有效的响应。我们将使用由 Stoplight 构建和维护的 Prism CLI(github.com/stoplightio/prism),来模拟厨房和支付服务的 API 服务器。Prism 是一个 Node.js 库,但不用担心,它只是一个命令行工具;你不需要了解任何 JavaScript 就能使用它。要安装这个库,运行以下命令:

$ yarn add @stoplight/prism-cli

运行 Prism 时处理错误你可能会在运行 Prism 时遇到错误。一个常见错误是没有兼容版本的 Node.js。我建议你安装 nvm 来管理你的 Node 版本,并使用 Node 的最新稳定版本来运行 Prism。此外,确保你选择的运行 Prism 的端口是可用的。

这个命令将在你的应用程序文件夹内创建一个 node_modules/文件夹,其中将安装 Prism 及其所有依赖项。你不希望提交这个文件夹,所以请确保将其添加到你的.gitignore 文件中。你还会看到一个新的文件叫做 package.json,以及另一个文件叫做 yarn.lock 在你的应用程序目录中。这些是你想要提交的文件,因为它们将允许你在任何其他环境中重新创建相同的 node_modules/文件夹。

要查看 Prism 与厨房 API 的实际应用,运行以下命令:

$ ./node_modules/.bin/prism mock kitchen.yaml --port 3000

这将在 3000 端口上启动一个服务器,该服务器运行厨房 API 的模拟服务。为了体验我们可以用它做什么,运行以下命令来调用 GET /kitchen/schedules端点,该端点返回一个日程表列表:

$ curl http://localhost:3000/kitchen/schedules

使用 jq 在终端中像专业人士一样显示 json 当你在终端输出 JSON 时,无论是使用 cURL 与 API 交互还是使用 cat 查看 JSON 文件,我建议你使用 JQ——这是一个命令行实用工具,它可以解析 JSON 并产生美观的显示。你可以这样使用 JQ:curl http://localhost: 3000/kitchen/schedules | jq

你会看到由 Prism 启动的模拟服务器能够返回一个表示调度列表的完全有效的有效负载。至少可以说,这是令人印象深刻的!现在我们知道了如何为厨房和支付 API 运行模拟服务器,让我们分析与它们的 API 集成的需求:

  • 厨房服务 (kitchen.yaml)—为了通过厨房服务安排订单,我们必须调用 POST /kitchen/schedules 端点,并带有包含订单中项目列表的有效负载。在这个调用的响应中,我们将找到 schedule_id,我们可以用它来跟踪订单的状态。

  • 支付服务 (payments.yaml)—为了处理订单的支付,我们必须调用 POST /payments 端点,并带有包含订单 ID 的有效负载。这是一个用于集成测试的模拟端点。

在我们能够取消订单之前,我们需要检查其状态。如果订单已安排生产,我们必须调用 POST /kitchen/schedules/{schedule_id}/cancel 端点来取消调度。如果订单正在配送,我们不允许用户取消订单,因此我们将引发异常。

为了实现 API 集成,我们将使用流行的 Python requests 库。运行以下命令使用 pipenv 安装库:

$ pipenv install requests

列表 7.6 通过添加实现厨房和支付服务 API 调用的方法扩展了 Order 类的实现。为了测试目的,我们期望厨房 API 在端口 3001 上运行,支付服务在端口 3000 上运行。你可以通过运行以下命令来完成此操作:

$ ./node_modules/.bin/prism mock kitchen.yaml --port 3000
$ ./node_modules/.bin/prism mock payments.yaml --port 3001

在每次 API 调用中,我们检查响应是否包含预期的状态码,如果不包含,我们将引发自定义的 APIIntegrationError 异常。此外,如果用户尝试执行无效的操作,例如在订单已经发货时取消订单,我们将引发 InvalidActionError 异常。

列表 7.6 在 Order 类中封装每个订单的功能

# file: orders/orders_service/orders.py

import requests
from orders.orders_service.exceptions import (
    APIIntegrationError, InvalidActionError
)

...
class Order:
  ...

    def cancel(self):
        if self.status == 'progress':                              ①
            kitchen_base_url = "http://localhost:3000/kitchen"
            response = requests.post(
                f"{kitchen_base_url}/schedules/{self.schedule_id}/cancel",
                json={"order": [item.dict() for item in self.items]},
            )
               if response.status_code == 200:                     ②
                return
            raise APIIntegrationError(                             ③
                f'Could not cancel order with id {self.id}'
            )
        if self.status == 'delivery':                              ④
            raise InvalidActionError(
                f'Cannot cancel order with id {self.id}'
            )

    def pay(self):
        response = requests.post(                                  ⑤
            'http://localhost:3001/payments', json={'order_id': self.id}
        )
        if response.status_code == 201:
            return
        raise APIIntegrationError(
            f'Could not process payment for order with id {self.id}'
        )

    def schedule(self):
        response = requests.post(                                  ⑥
            'http://localhost:3000/kitchen/schedules',
            json={'order': [item.dict() for item in self.items]}
        )
        if response.status_code == 201:                            ⑦
            return response.json()['id']
        raise APIIntegrationError(
            f'Could not schedule order with id {self.id}'
        )

① 如果订单正在进行中,我们将通过调用厨房 API 来取消其调度。

② 如果厨房服务的响应成功,我们将返回。

③ 否则,我们将引发 APIIntegrationError 异常。

④ 我们不允许取消正在配送的订单。

⑤ 我们通过调用支付 API 来处理支付。

⑥ 我们通过调用厨房 API 来安排订单进行生产。

⑦ 如果厨房服务的响应成功,我们将返回调度 ID。

列表 7.7 包含了我们用于订单服务以表示出现问题的自定义异常的实现。当用户尝试获取一个不存在的订单的详细信息时,我们将在 OrdersService 类中使用 OrderNotFoundError

列表 7.7 订单服务自定义异常

# file: orders/orders_service/exceptions.py

class OrderNotFoundError(Exception):      ①
    pass

class APIIntegrationError(Exception):     ②
    pass

class InvalidActionError(Exception):      ③
    pass

① 异常用于表示订单不存在

② 异常用于表示发生了 API 集成错误

③ 异常用于表示正在执行的操作无效

如我们之前提到的,API 模块不会直接使用Order类。相反,它将通过OrdersService类使用所有适配器的统一接口,我们在第 7.4 节中展示了该接口。OrdersService封装了订单域的能力,并负责使用订单仓库获取订单对象并对它们执行操作。第 7.8 节展示了OrdersService类的实现。

要实例化OrdersService类,我们需要一个订单仓库对象,我们可以用它来添加或删除我们的记录中的订单。为了下订单,我们使用订单仓库创建一个数据库记录,为了获取订单的详细信息,我们从数据库中检索相应的记录。如果请求的订单未找到,我们抛出OrderNotFoundError异常。list_orders()方法接受字典形式的过滤器。为了获取订单列表,订单仓库强制我们为limit参数传递一个特定的值,因此我们使用pop()方法从filters字典中提取其值,这允许我们设置一个默认值,同时也从字典中删除了该键。在pay_order()方法中,我们使用支付 API 处理支付,如果支付成功,我们通过调用厨房 API 来安排订单。在安排订单后,我们通过将schedule_id属性设置为厨房 API 返回的安排 ID 来更新订单记录。

列表 7.8 OrdersService实现

# file: orders/orders_service/orders_service.py

from orders.orders_service.exceptions import OrderNotFoundError

class OrdersService:
    def __init__(self, orders_repository):              ①
        self.orders_repository = orders_repository

    def place_order(self, items):
        return self.orders_repository.add(items)        ②

    def get_order(self, order_id):
        order = self.orders_repository.get(order_id)    ③
        if order is not None:                           ④
            return order
        raise OrderNotFoundError(f'Order with id {order_id} not found')

    def update_order(self, order_id, items):
        order = self.orders_repository.get(order_id)
        if order is None:
            raise OrderNotFoundError(f'Order with id {order_id} not found')
        return self.orders_repository.update(order_id, {'items': items})

    def list_orders(self, **filters):
        limit = filters.pop('limit', None)              ⑤
        return self.orders_repository.list(limit, **filters)

    def pay_order(self, order_id):
        order = self.orders_repository.get(order_id)
        if order is None:
            raise OrderNotFoundError(f'Order with id {order_id} not found')
        order.pay()
        schedule_id = order.schedule()                  ⑥
        return self.orders_repository.update(
            order_id, {'status': 'scheduled', 'schedule_id': schedule_id}
        )

    def cancel_order(self, order_id):
        order = self.orders_repository.get(order_id)
        if order is None:
            raise OrderNotFoundError(f'Order with id {order_id} not found')
        order.cancel()
        return self.orders_repository.update(order_id, status="cancelled")

① 要实例化OrdersService类,我们需要订单仓库的一个实例。

② 我们通过创建数据库记录来下订单。

③ 我们通过使用订单仓库并传入请求的 ID 来获取订单的详细信息。

④ 如果订单不存在,我们抛出OrderNotFoundError异常。

⑤ 我们通过使用关键字参数将过滤器捕获为字典。

⑥ 在安排订单后,我们更新其schedule_id属性。

订单服务已准备好在我们的 API 模块中使用。然而,在我们继续进行此集成之前,这个谜题中还有一个部分需要我们解决。正如我们在第 7.4 节中提到的,订单仓库不会将任何操作提交到数据库。作为OrdersService的消费者,API 的责任确保在操作结束时一切都被提交。这究竟是如何工作的?继续阅读第 7.6 节来了解详情!

7.6 实现工作单元模式

在本节中,我们将学习在交互OrdersService时处理数据库提交和回滚。正如你在图 7.15 中看到的,当我们使用OrdersService类访问其任何功能时,我们必须注入OrdersRepository类的实例。我们必须在执行任何操作之前打开一个 SQLAlchemy 会话,并且我们必须提交对数据的任何更改以将它们持久化到数据库中。

图片

图 7.15 为了将我们的更改持久化到数据库中,我们可以简单地让 API 层使用 SQLAlchemy 会话对象来提交事务。在这个图中,实线代表调用,而虚线代表依赖注入。

最佳的操作编排方式是什么?我们可以使用不同的方法来实现这一点。我们可以简单地使用 SQLAlchemy 会话对象来包装我们对 OrdersService 的调用,一旦我们的操作成功,就使用会话来提交,否则回滚。如果 OrdersService 只需要处理单个 SQL 数据库,这将有效。然而,如果我们同时需要与不同类型的数据库交互呢?我们需要为它打开一个新的会话。如果我们还必须在同一操作中处理与其他微服务的集成,并确保在事务结束时正确调用 API,以防需要回滚,又会怎样呢?我们还可以在代码中添加特殊的子句和守卫。相同的代码必须在每个与 OrdersService 交互的 API 函数中重复,所以如果有一个模式可以帮助我们将所有这些内容集中在一个地方,那岂不是很好?这就是工作单元模式的作用。

定义 工作单元 是一种设计模式,保证了我们业务事务的原子性,确保所有事务一次性提交,或者如果有任何事务失败,则回滚。

工作单元是一种模式,确保业务事务中的所有对象都一起更改,如果出现错误,则确保它们中没有任何一个发生变化。⁹ 这个概念来自数据库领域,其中数据库事务作为工作单元实现,确保每个事务都是

  • 原子性——整个事务要么成功,要么失败。

  • 一致性——它符合数据库的约束。

  • 隔离——它不会干扰其他事务。

  • 持久性——它被写入持久存储。

这些属性在数据库领域被称为 ACID 原则 (en.wikipedia.org/wiki/Database_transaction)。当涉及到服务时,工作单元模式帮助我们将这些原则应用于我们的操作。SQLAlchemy 的 Session 对象已经实现了数据库事务的工作单元模式(mng.bz/jA5z)。这意味着我们可以将所需的所有更改添加到同一个会话中,并一起提交。如果出现问题,我们可以调用 rollback 方法来撤销任何更改。在 Python 中,我们可以使用上下文管理器来协调这些步骤。

如图 7.16 所示,上下文管理器是一种允许我们在操作期间锁定资源、确保在出现任何错误时执行必要的清理工作,并在操作完成后最终释放锁的模式。上下文管理器的关键语法特性是使用 with 语句,如图 7.16 所示。如图所示,上下文管理器可以返回对象,我们可以通过使用 Python 的 as 子句来捕获这些对象。如果上下文管理器正在创建对资源(例如文件)的访问,我们希望对其进行操作,这非常有用。

图 7.16

图 7.16 基于类的上下文管理器具有 __init__()__enter__()__exit__() 方法。__init__() 在初始化上下文管理器时触发。__enter__() 方法允许我们进入上下文,并在使用 with 语句时被调用。在同一行内使用 as 语句允许我们将 __enter__() 方法的返回值绑定到一个变量(在本例中为 unit_of_work)。最后,当我们退出上下文管理器时,__exit__() 方法被触发。

在 Python 中,我们可以以多种方式实现上下文管理器,包括作为类或使用 contextlib 模块中的 contextmanager() 装饰器。¹⁰ 在本节中,我们将以类的方式实现我们的工作单元上下文管理器。上下文管理器类必须实现至少以下两个特殊方法:

  • __enter__() — 定义进入上下文时必须执行的操作,例如创建会话或打开文件。如果我们需要在 __enter__() 方法创建的任何对象上执行操作,我们可以返回该对象,并通过 as 子句捕获其值,如图 7.16 所示。

  • __exit__() — 定义退出上下文时必须执行的操作,例如关闭文件或会话。__exit__() 方法通过其方法签名中的三个参数捕获在上下文执行过程中抛出的任何异常:

    • exc_type — 捕获抛出的异常类型

    • exc_value — 捕获绑定到异常的值,通常是错误消息

    • traceback — 一个可以用来确定异常发生确切位置的回溯对象

如果没有抛出异常,这三个参数的值将为 None

列表 7.9 展示了将工作单元模式作为订单服务的上下文管理器的实现。在初始化方法中,我们使用 SQLAlchemy 的 sessionmaker() 函数获取一个会话工厂对象,该函数需要一个连接对象,我们通过 SQLAlchemy 的 create_engine() 函数生成这个连接对象。为了简化示例,我们将数据库连接字符串硬编码为指向我们的本地 SQLite 数据库。在第十三章中,你将学习如何参数化此值并从环境中获取它。

当我们进入上下文时,我们创建一个新的数据库会话,并将其绑定到UnitOfWork实例,以便我们可以在其他方法中访问它。我们还返回上下文管理器对象本身,以便调用者可以访问其任何属性,例如session对象或commit()方法。在退出上下文时,我们检查在向会话添加或删除对象时是否抛出了任何异常,如果是这样,我们将回滚更改以避免使数据库处于不一致的状态。我们可以访问异常的类型(exc_type)、值(exc_val)和跟踪信息(traceback),我们可以使用这些信息来记录错误的详细信息。如果没有发生异常,所有三个参数都将设置为None。最后,我们关闭数据库会话以释放数据库资源并结束事务的作用域。我们还添加了commit()rollback()方法的包装器,以避免将数据库内部暴露给业务层。

列表 7.9 将工作单元模式作为上下文管理器

# file: orders/repository/unit_of_work.py

from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker

class UnitOfWork:

    def __init__(self):
        self.session_maker = sessionmaker(               ①
            bind=create_engine('sqlite:///orders.db')
        )

    def __enter__(self):
        self.session = self.session_maker()              ②
        return self                                      ③

    def __exit__(self, exc_type, exc_val, traceback):    ④
        if exc_type is not None:                         ⑤
            self.rollback()                              ⑥
            self.session.close()                         ⑦
        self.session.close()

    def commit(self):
        self.session.commit()                            ⑧

    def rollback(self):
        self.session.rollback()                          ⑨

① 我们获取一个会话工厂对象。

② 我们打开一个新的数据库会话。

③ 我们返回工作单元对象的实例。

④ 在上下文退出时,我们可以访问上下文执行期间抛出的任何异常。

⑤ 我们检查是否发生了异常。

⑥ 如果发生异常,回滚事务。

⑦ 我们关闭数据库会话。

⑧ SQLAlchemy 的commit()方法的包装器

⑨ SQLAlchemy 的rollback()方法的包装器

这一切看起来都很不错,但我们究竟应该如何使用UnitOfWork与订单存储库和OrdersService结合使用呢?在下一节中,我们将更深入地探讨这个细节,但在我们这样做之前,列表 7.10 为你提供了一个如何将这些组件一起使用的模板。我们使用 Python 的上下文管理器语法中的with语句进入工作单元上下文。我们还使用as语句将UnitOfWork__enter__()方法的返回值绑定到unit_of_work变量。然后我们通过传递UnitOfWork的数据库会话对象获取订单存储库的实例,并通过传递订单存储库对象获取OrdersService类的实例。然后我们使用订单服务对象下订单,并使用UnitOfWorkcommit()方法提交事务。

列表 7.10 使用工作单元和存储库的模板模式

with UnitOfWork() as unit_of_work:                 ①
    repo = OrdersRepository(unit_of_work.session)  ②
    orders_service = OrdersService(repo)           ③
    orders_service.place_order(order_details)      ④
    unit_of_work.commit()                          ⑤

① 我们进入工作单元上下文。

② 我们通过传递UnitOfWork的会话获取订单存储库的实例。

③ 我们通过传递订单存储库对象来获取OrdersService类的实例。

④ 我们下订单。

⑤ 我们提交事务。

现在我们有了可以使用来提交事务的工作单元,让我们看看如何通过将 API 层与服务层集成来将这些全部组合起来!继续阅读第 7.7 节,了解我们是如何做到这一点的。

7.7 集成 API 层和服务层

在本节中,我们将本章所学的一切整合起来,将服务层与 API 层集成。我们将利用列表 7.10 中展示的模板模式,结合使用 UnitOfWork 类和 OrdersRepository 以及 OrdersService。当用户尝试对订单执行操作时,我们确保我们设置了检查来验证订单是否首先存在;否则,我们返回 404(未找到)错误响应。

列表 7.11 显示了 orders/web/api/api.py 模块的新版本。在每一个函数中,我们首先进入 UnitOfWork 的上下文,确保我们将上下文对象绑定到一个变量 unit_of_work 上。然后我们使用 UnitOfWork 上下文对象中的会话对象创建一个 OrdersRepository 的实例。一旦我们有了仓库的实例,我们就在创建服务实例时将其注入到 OrdersService 中。然后我们使用服务在每个端点执行所需的操作。在执行对特定订单的操作的端点中,我们防范 OrdersService 如果请求的订单不存在而抛出 OrderNotFoundError 的可能性。

create_order() 函数中,我们在退出 UnitOfWork 上下文之前使用 order.dict() 获取订单的字典表示形式,以便我们可以访问在提交过程中由数据库生成的属性,例如订单的 ID。请记住,订单 ID 在更改提交到数据库之前不存在,因此它只能在数据库会话的作用域内访问。在我们的实现中,这意味着我们必须在退出 UnitOfWork 上下文之前访问 ID,因为数据库会话在退出上下文之前关闭。图 7.17 说明了这个过程。

图 7.17 当我们下订单时,订单仓库返回的对象不包含 ID。一旦我们通过 OrderModel 实例提交数据库事务,ID 就会可用。因此,我们将模型实例绑定到 Order 对象上,以便在提交后从模型中提取 ID。记住,订单 ID 在提交到数据库之前不存在,因此它只能在数据库会话的作用域内访问。在我们的实现中,这意味着我们必须在退出 UnitOfWork 上下文之前访问 ID,因为数据库会话在退出上下文之前关闭。图 7.17 说明了这个过程。

列表 7.11 API 层与服务层之间的集成

# file: orders/web/api/api.py

from http import HTTPStatus
from typing import List, Optional
from uuid import UUID

from fastapi import HTTPException
from starlette import status
from starlette.responses import Response

from orders.orders_service.exceptions import OrderNotFoundError
from orders.orders_service.orders_service import OrdersService
from orders.repository.orders_repository import OrdersRepository
from orders.repository.unit_of_work import UnitOfWork
from orders.web.app import app
from orders.web.api.schemas import (
    GetOrderSchema,
    CreateOrderSchema,
    GetOrdersSchema,
)
@app.get('/orders', response_model=GetOrdersSchema)
def get_orders(
    cancelled: Optional[bool] = None,
    limit: Optional[int] = None,
):
    with UnitOfWork() as unit_of_work: ①
        repo = OrdersRepository(unit_of_work.session)
        orders_service = OrdersService(repo)
        results = orders_service.list_orders(
            limit=limit, cancelled=cancelled
        )
    return {'orders': [result.dict() for result in results]}

@app.post(
    '/orders',
    status_code=status.HTTP_201_CREATED,
    response_model=GetOrderSchema,
)
def create_order(payload: CreateOrderSchema):
    with UnitOfWork() as unit_of_work:
        repo = OrdersRepository(unit_of_work.session)
        orders_service = OrdersService(repo)
        order = orders_service.place_order(payload.dict()['order'])
        order = payload.dict()['order']
        for item in order:
            item['size'] = item['size'].value
        order = orders_service.place_order(order) ②

        unit_of_work.commit()
        return_payload = order.dict() ③
    return return_payload

@app.get('/orders/{order_id}', response_model=GetOrderSchema)
def get_order(order_id: UUID):
    try: ④
        with UnitOfWork() as unit_of_work:
            repo = OrdersRepository(unit_of_work.session)
            orders_service = OrdersService(repo)
            order = orders_service.get_order(order_id=order_id)
        return order.dict()
    except OrderNotFoundError:
        raise HTTPException(
            status_code=404, detail=f'Order with ID {order_id} not found'
        )

@app.put('/orders/{order_id}', response_model=GetOrderSchema)
def update_order(order_id: UUID, order_details: CreateOrderSchema):
    try:
        with UnitOfWork() as unit_of_work:
            repo = OrdersRepository(unit_of_work.session)
            orders_service = OrdersService(repo)
            order = order_details.dict()['order']
            for item in order:
                item['size'] = item['size'].value
            order = orders_service.update_order(
                order_id=order_id, items=order
            )
            unit_of_work.commit()
        return order.dict()
    except OrderNotFoundError:
        raise HTTPException(
            status_code=404, detail=f'Order with ID {order_id} not found'
        )

@app.delete(
    "/orders/{order_id}",
    status_code=status.HTTP_204_NO_CONTENT,
    response_class=Response,
)
def delete_order(order_id: UUID):
    try:
        with UnitOfWork() as unit_of_work:
            repo = OrdersRepository(unit_of_work.session)
            orders_service = OrdersService(repo)
            orders_service.delete_order(order_id=order_id)
            unit_of_work.commit()
        return
    except OrderNotFoundError:
        raise HTTPException(
            status_code=404, detail=f'Order with ID {order_id} not found'
        )

@app.post('/orders/{order_id}/cancel', response_model=GetOrderSchema)
def cancel_order(order_id: UUID):
    try:
        with UnitOfWork() as unit_of_work:
            repo = OrdersRepository(unit_of_work.session)
            orders_service = OrdersService(repo)
            order = orders_service.cancel_order(order_id=order_id)
            unit_of_work.commit()
        return order.dict()
    except OrderNotFoundError:
        raise HTTPException(
            status_code=404, detail=f'Order with ID {order_id} not found'
        )

@app.post('/orders/{order_id}/pay', response_model=GetOrderSchema)
def pay_order(order_id: UUID):
    try:
        with UnitOfWork() as unit_of_work:
            repo = OrdersRepository(unit_of_work.session)
            orders_service = OrdersService(repo)
            order = orders_service.pay_order(order_id=order_id)
            unit_of_work.commit()
        return order.dict()
    except OrderNotFoundError:
        raise HTTPException(
            status_code=404, detail=f'Order with ID {order_id} not found'
        )

① 我们进入工作单元上下文。

② 我们下订单。

③ 在退出工作单元上下文之前,我们访问订单的字典表示形式。

④ 我们使用 try/except 块来捕获 OrderNotFoundError 异常。

这标志着我们完成对订单服务服务层实现的旅程。在本章中我们学到的模式不仅适用于 API 和微服务的世界,而且适用于所有通用应用程序模型。特别是,仓库模式将始终帮助您确保您的数据访问层与业务层完全解耦,而工作单元模式将帮助您确保业务操作的所有事务都是原子性和一致性地处理的。

摘要

  • 六边形架构,或称为端口和适配器架构,是一种软件架构模式,它鼓励我们将业务层与数据库实现细节和应用接口的实现细节解耦。

  • 依赖倒置原则教导我们,我们应用程序组件的实现细节应该依赖于接口。这有助于我们解耦我们的组件与其依赖的实现细节。

  • 要与数据库接口,你可以使用如 SQLAlchemy 这样的 ORM 库,它可以将数据库表和行转换为类和对象。这为我们提供了增强数据库模型以适应应用程序需求的有用功能的可能性。

  • 仓库是一种软件开发模式,通过添加一个抽象层来帮助解耦数据层和业务层,该抽象层暴露了数据的内存列表接口。无论我们使用哪种数据库引擎,业务层都将始终从仓库接收相同的对象。

  • 工作单元模式有助于确保所有作为应用程序操作一部分的业务事务要么全部成功,要么全部失败。如果其中一项事务失败,工作单元模式将确保所有更改都被回滚。这种机制确保数据永远不会处于不一致的状态。


¹ Alistair Cockburn,“六边形架构”,alistair.cockburn.us/hexagonal-architecture/。你可能想知道为什么是六边形而不是五边形或七边形。正如 Alistair 所指出的,它“不是六边形不是因为数字六很重要”,而是因为它有助于从视觉上突出核心应用程序通过端口(六边形的边)与外部组件通信的概念,并允许我们表示应用程序的两个主要方面:面向公众的方面(Web 组件、API 等)和内部方面(数据库、第三方集成等)。

² Robert C. Martin,《敏捷软件开发:原则、模式和实践》(Prentice Hall,2003 年),第 127-131 页。

³ 关于依赖倒置原则的出色介绍,请参阅 Eric Freeman、Elizabeth Robson、Kathy Sierra 和 Bert Bates 合著的《Head First Design Patterns》(O’Reilly,2014 年),第 141-143 页。

⁴ Martin Fowler,《企业架构模式》(Addison-Wesley,2003 年),第 165-181 页。

⁵ 这个文件的形状和格式可能会随时间而改变,但为了参考,在撰写本文时,这些行是第 18-20 行。

⁶ Fowler,《企业架构模式》,第 160-164 页。

⁷ 关于控制反转原则和依赖注入模式的更多细节,请参阅 Martin Fowler 的“控制反转容器和依赖注入模式”,martinfowler.com/articles/injection.html

⁸ Erich Gamma, Richard Helm, Ralph Johnson, 和 John Vlissides, 《设计模式》 (Addison-Wesley, 1995), 第 185–193 页。

⁹ Fowler, 《企业架构模式》 (第 184–194 页)。

(10) Ramalho, 《流畅的 Python》 (O’Reilly, 2015), 第 463–478 页。

第三部分:设计和构建 GraphQL API

在第二部分中,你了解到 REST 是一种 API 技术,它允许我们从服务器更改或检索资源的状态。当一个资源由一个大的负载表示时,从服务器获取它意味着大量的数据传输。随着在移动设备上运行的 API 客户端的出现,这些设备具有受限的网络访问、有限的存储和内存容量,交换大量负载通常会导致不可靠的通信。2012 年,Facebook 对这些问题的认识非常深刻,并开发了一种新技术,允许 API 客户端在服务器上运行细粒度的数据查询。这项技术于 2015 年以 GraphQL 的名称发布。

GraphQL 是一种 API 的查询语言。与获取资源的完整表示不同,GraphQL 允许你获取一个或多个资源的属性,例如产品的价格或订单的状态。使用 GraphQL,我们还可以建模不同对象之间的关系,这使得我们可以在单个请求中从服务器检索各种资源的属性,例如产品的成分和库存可用性。

尽管它有好处,但许多开发者对 GraphQL 不熟悉或不知道它是如何工作的,因此它通常不是构建 API 的首选技术。在第三部分中,你将学习设计和高品质 GraphQL API 所需的一切,以及如何消费它们。阅读第三部分后,你将了解 GraphQL 是什么,它是如何工作的,以及在何时使用它,以便你可以在 API 策略中做出更好的决策。

8 设计 GraphQL API

本章涵盖

  • 理解 GraphQL 的工作原理

  • 使用模式定义语言(SDL)生成 API 规范

  • 学习 GraphQL 的内置标量类型和数据结构,以及构建自定义对象类型

  • 在 GraphQL 类型之间建立有意义的连接

  • 设计 GraphQL 查询和突变

GraphQL 是构建 Web API 最受欢迎的协议之一。它是驱动微服务之间集成和构建与前端应用程序集成的合适选择。GraphQL 使 API 消费者能够完全控制他们从服务器获取的数据以及他们希望如何获取这些数据。

在本章中,你将学习如何设计一个 GraphQL API。你将通过一个实际案例来完成这项工作:你将为 CoffeeMesh 平台的产品服务设计一个 GraphQL API。产品服务拥有关于 CoffeeMesh 产品及其成分的数据。每个产品和成分都包含一个丰富的属性列表,描述了它们的特性。然而,当客户端请求产品列表时,他们最可能只对获取每个产品的少量详细信息感兴趣。此外,客户端可能还希望能够遍历产品、成分以及产品服务拥有的其他对象之间的关系。出于这些原因,GraphQL 是构建产品 API 的一个绝佳选择。

随着我们为产品 API 构建规范,你将了解 GraphQL 的标量类型、自定义对象类型的设计,以及查询和突变。到本章结束时,你将了解 GraphQL 与其他类型 API 的比较,以及何时使用它最为合理。我们有很多内容要覆盖,所以无需多言,让我们开始我们的旅程吧!

要跟随本章中开发的规范,你可以使用本书提供的 GitHub 仓库。本章的代码位于名为 ch08 的文件夹中。

8.1 介绍 GraphQL

本节介绍了 GraphQL 是什么,它的优势是什么,以及在什么情况下使用它是合理的。GraphQL 规范的官方网站将 GraphQL 定义为“API 的查询语言,以及用现有数据满足这些查询的运行时。”¹ 这到底是什么意思呢?这意味着 GraphQL 是一个规范,允许我们在 API 服务器上运行查询。就像 SQL 为数据库提供查询语言一样,GraphQL 为 API 提供了查询语言。² GraphQL 还提供了一个规范,说明如何在服务器中解析这些查询,以便任何人都可以在任何编程语言中实现 GraphQL 运行时。³

正如我们可以使用 SQL 来定义数据库表的模式一样,我们也可以使用 GraphQL 来编写描述从我们的服务器可以查询的数据类型的规范。GraphQL API 规范被称为模式,它使用一种称为模式定义语言(SDL)的标准编写。在本章中,我们将学习如何使用 SDL 为产品 API 生成规范。

GraphQL 首次发布于 2015 年,自那时起,它已成为构建 Web API 最受欢迎的选择之一。我必须说,在 GraphQL 规范中并没有说 GraphQL 应该通过 HTTP 使用,但在实践中,这是 GraphQL API 中最常见的协议类型。

GraphQL 的伟大之处在于它能够让用户完全控制他们希望从服务器获取哪些数据。例如,正如我们将在下一节中看到的,在产品 API 中,我们存储了关于每个产品的许多详细信息,例如其名称、价格、可用性和成分等。如图 8.1 所示,如果用户只想获取产品名称和价格列表,使用 GraphQL 可以实现这一点。相比之下,在其他类型的 API,如 REST 中,你会得到每个产品的完整详细信息列表。因此,每当需要让客户端完全控制他们从服务器获取数据的方式时,GraphQL 都是一个很好的选择。

图片

图 8.1 展示了使用 GraphQL API,客户端可以请求具有特定详细信息的项目列表。在这个例子中,客户端正在请求产品 API 中每个产品的名称和价格。

GraphQL 的另一个巨大优势是能够在不同类型的资源之间创建连接,并将这些连接暴露给我们的客户端以供他们在查询中使用。例如,在产品 API 中,产品和成分是不同但相关的资源类型。如图 8.2 所示,如果用户想要获取包括产品名称、价格和成分的产品列表,使用 GraphQL 可以通过利用这些资源之间的连接来实现这一点。因此,在具有高度互联资源的服务中,并且当客户端探索和查询这些连接对客户端有用时,GraphQL 是一个极佳的选择。

图片

图 8.2 展示了使用 GraphQL,客户端可以请求资源的详细信息及其相关联的其他资源。在这个例子中,产品 API 有两种类型的资源:产品和成分,它们通过产品的ingredients字段相互连接。利用这种连接,客户端可以请求每个产品的名称和价格,以及每个产品成分的名称。

在接下来的章节中,我们将学习如何为产品服务生成 GraphQL 规范。我们将学习如何定义数据类型,如何创建资源之间的有意义连接,以及如何定义查询数据和更改服务器状态的操作。但在我们这样做之前,我们应该了解产品 API 的要求,这就是我们在下一节要做的。

8.2 介绍产品 API

本节讨论了产品 API 的要求。在着手编写 API 规范之前,收集有关 API 要求的信息非常重要。如图 8.3 所示,产品 API 是产品服务的接口。为了确定产品 API 的要求,我们需要知道产品服务的用户可以用它做什么。

图片

图 8.3 客户端通过产品 API 与产品服务交互。

产品服务拥有关于 CoffeeMesh 平台提供的产品数据。如图 8.4 所示,CoffeeMesh 员工必须能够使用产品服务来管理每种产品的可用库存,以及保持产品配料的最新状态。特别是,他们必须能够查询产品或配料的库存,并在新库存到达仓库时更新它们。他们还必须能够向系统中添加新产品或配料,并删除旧的。这些信息已经给我们提供了一个复杂的要求列表,所以让我们将其分解为具体的技術要求。

图片

图 8.4 CoffeeMesh 员工使用产品服务来管理产品和配料。

让我们从为产品 API 管理的资源建模开始。我们想知道应该通过 API 公开哪种类型的资源以及产品的属性。从上一段的描述中,我们知道产品服务管理两种类型的资源:产品和配料。让我们首先分析产品。

CoffeeMesh 平台提供两种类型的产品:蛋糕和饮料。如图 8.5 所示,蛋糕和饮料都有一些共同的属性,包括产品的名称、价格、大小、配料列表以及其可用性。蛋糕有两个额外的属性:

  • hasFilling—表示蛋糕是否有填充物

  • hasNutsToppingOption—表示客户是否可以给蛋糕添加坚果作为配料

图片

图 8.5 CoffeeMesh 公开了两种产品类型:蛋糕饮料,它们都共享一组通用的属性列表。

饮料有以下两个额外的属性:

  • hasCreamOnTopOption—表示客户是否可以加奶油到饮料上

  • hasServeOnIceOption—表示客户是否可以选择要冰镇饮料

关于成分呢?正如图 8.6 所示,我们可以通过以下属性使用一个实体来表示所有成分:

  • name—成分的名称。

  • stock—该成分的可用库存。由于不同的成分使用不同的单位进行测量,例如千克或升,我们用每单位测量的数量来表示可用库存。

  • description—一组笔记,CoffeeMesh 员工可以使用它来描述和说明产品。

  • supplier—关于向 CoffeeMesh 供应成分的公司信息,包括他们的名称、地址、联系电话和电子邮件。

图 8.6

图 8.6 展示了描述成分的属性列表。成分的供应商由一个名为Supplier的资源来描述,而成分的库存通过一个Stock对象来描述。

现在我们已经模拟了产品服务管理的主要资源,让我们将注意力转向我们必须通过 API 公开的操作。我们将区分读取操作和写入/删除操作。当我们更仔细地查看第 8.8 节和第 8.9 节中的这些操作时,这种区分将变得有意义。

根据之前的讨论,我们将公开以下读取操作:

  • allProducts()—返回 CoffeeMesh 目录中可用的产品完整列表

  • allIngredients()—返回 CoffeeMesh 用于制作其产品的所有成分的完整列表

  • products()—允许用户根据某些标准(如可用性、最高价格等)过滤产品完整列表

  • product()—允许用户获取单个产品的信息

  • ingredient()—允许用户获取单个成分的信息

在写入/删除操作方面,根据之前的讨论,很明显我们应该公开以下功能:

  • addIngredient()—添加新成分

  • updateStock()—更新成分的库存

  • addProduct()—添加新产品

  • updateProduct()—更新现有产品

  • deleteProduct()—从目录中删除产品

现在我们已经了解了产品 API 的要求,是时候继续创建 API 规范了!在接下来的章节中,我们将学习如何为产品 API 创建 GraphQL 规范,并且在这个过程中,我们将了解 GraphQL 是如何工作的。我们的第一个停靠点是 GraphQL 的类型系统,我们将使用它来模拟 API 管理的资源。

8.3 介绍 GraphQL 的类型系统

在本节中,我们介绍 GraphQL 的类型系统。在 GraphQL 中,类型是允许我们描述数据属性的定义。它们是 GraphQL API 的构建块,我们使用它们来模拟 API 拥有的资源。在本节中,你将学习如何使用 GraphQL 的类型系统来描述我们在第 8.2 节中定义的资源。

8.3.1 使用标量创建属性定义

本节解释了我们如何使用 GraphQL 的类型系统来定义属性的类型。我们区分标量类型和对象类型。正如我们将在第 8.3.2 节中看到的,对象类型是表示实体的属性集合。标量类型是布尔值或整数等类型。定义属性类型的语法与我们使用 Python 中的类型提示非常相似:我们包括属性名称后跟一个冒号,以及冒号右侧的属性类型。例如,在第 8.2 节中,我们讨论了蛋糕有两个不同的属性:hasFillinghasNutsToppingOption,这两个属性都是布尔值。使用 GraphQL 的类型系统,我们这样描述这些属性:

hasFilling: Boolean
hasNutsToppingOption: Boolean

GraphQL 支持以下类型的标量:

  • 字符串 (String)—用于基于文本的对象属性。

  • 整数 (Int)—用于数值对象属性。

  • 浮点数 (Float)—用于具有小数精度的数值对象属性。

  • 布尔值 (Boolean)—用于对象的二进制属性。

  • 唯一标识符 (ID)—用于描述对象 ID。技术上,ID 是字符串,但 GraphQL 会检查并确保每个对象的 ID 是唯一的。

除了定义属性的类型外,我们还可以指示属性是否为非可选的。可选属性是在我们不知道其值时可以设置为 null 的属性。我们通过在属性定义的末尾放置一个感叹号来标记属性为非可选的:

name: String!

这行定义了一个类型为 String 的属性 name,并通过使用感叹号将其标记为非可选的。这意味着,无论何时我们从 API 提供这个属性,它都将始终是一个字符串。

现在我们已经了解了属性和标量,让我们看看我们如何利用这些知识来建模资源!

8.3.2 使用对象类型建模资源

本节解释了我们如何使用 GraphQL 的类型系统来建模资源。资源是由 API 管理的实体,例如我们在第 8.2 节中讨论的成分、蛋糕和饮料。在 GraphQL 中,这些资源中的每一个都被建模为一个对象类型。对象类型是一组属性,正如其名称所示,我们使用它们来定义对象。要定义一个对象类型,我们使用 type 关键字后跟对象名称,以及用大括号括起来的对象属性列表。一个属性通过声明属性名称后跟一个冒号,以及冒号右侧的类型来定义。在 GraphQL 中,ID 是一个具有唯一值的类型。属性末尾的感叹号表示该属性是非可选的。以下展示了我们如何将蛋糕资源描述为一个对象类型。列表包含了蛋糕类型的基本属性,如 ID、名称和价格。

列表 8.1 Cake 对象类型的定义

type Cake {          ①
  id: ID!            ②
  name: String!
  price: Float
  available: Boolean!
  hasFilling: Boolean!
  hasNutsToppingOption: Boolean!
}

① 我们定义一个对象类型。

② 我们定义一个非可选的 ID 属性。

类型与对象类型 为了方便起见,除非另有说明,否则在本书中,我们将类型和对象类型的概念互换使用。

列表 8.1 中的一些属性定义以感叹号结尾。在 GraphQL 中,感叹号表示属性是非空白的,这意味着我们 API 返回的每个蛋糕对象都将包含一个 ID、一个名称、其可用性,以及 hasFillinghasNutsToppingOption 属性。这也保证了这些属性都不会被设置为 null。对于 API 客户端开发者来说,这些信息非常有价值,因为他们知道他们可以依赖这些属性始终存在,并据此构建他们的应用程序。以下代码显示了 BeverageIngredient 类型的定义。它还显示了 Supplier 类型的定义,该类型包含有关供应特定成分的企业的信息,在 8.5.1 节中我们将看到如何将其与 Ingredient 类型连接起来。

列表 8.2 BeverageIngredient 对象类型的定义

type Beverage {
  id: ID!
  name: String!
  price: Float
  available: Boolean!
  hasCreamOnTopOption: Boolean!
  hasServeOnIceOption: Boolean!
}

type Ingredient {
  id: ID!
  name: String!
}

type Supplier {
  id: ID!
  name: String!
  address: String!
  contactNumber: String!
  email: String!
}

现在我们已经知道了如何定义对象类型,让我们通过学习如何创建自己的自定义类型来完善对 GraphQL 类型系统的探索!

8.3.3 创建自定义标量

本节解释了如何创建自定义标量定义。在 8.3.1 节中,我们介绍了 GraphQL 的内置标量:StringIntFloatBooleanID。在许多情况下,这个标量类型列表足以模拟我们的 API 资源。然而,在某些情况下,GraphQL 的内置标量类型可能显得有限。在这种情况下,我们可以定义自己的自定义标量类型。例如,我们可能希望能够表示日期类型、URL 类型或电子邮件地址类型。

由于产品 API 用于管理产品和成分以及对其进行更改,因此添加一个 lastUpdated 属性很有用,该属性告诉我们记录最后一次更改的时间。lastUpdated 应该是一个 Datetime 标量。GraphQL 没有内置该类型的标量,因此我们必须自己创建。要声明一个自定义日期时间标量,我们使用以下语句:

scalar Datetime

我们还需要定义如何验证和序列化此标量类型。我们定义自定义标量在服务器实现中的验证和序列化规则,这将是第十章的主题。

列表 8.3 使用自定义的 Datetime 标量类型

scalar Datetime                    ①

type Cake {
  id: ID!
  name: String!
  price: Float
  available: Boolean!
  hasFilling: Boolean!
  hasNutsToppingOption: Boolean!
  lastUpdated: Datetime!           ②
}

① 我们声明一个自定义的 Datetime 标量。

② 我们声明一个类型为 Datetime 的非空属性。

这就结束了我们对 GraphQL 标量和对象类型的探索。你现在可以定义 GraphQL 中的基本对象类型并创建自己的自定义标量。在接下来的章节中,我们将学习如何在不同的对象类型之间创建连接,以及如何使用列表、接口、枚举等!

8.4 使用列表表示项目集合

本节介绍了 GraphQL 列表。列表是类型的数组,它们通过在类型周围加上方括号来定义。当我们需要定义表示项目集合的属性时,列表非常有用。如第 8.2 节所述,Ingredient类型包含一个名为description的属性,它包含有关成分的笔记集合,如下面的代码所示。

列表 8.4 表示字符串列表

type Ingredient {
  id: ID!
  name: String!
  description: [String!]     ①
}

① 我们定义了一个不可为空的项列表。

仔细观察description属性中感叹号的使用:我们将其定义为具有不可为空项的可空属性。这意味着什么?当我们从 API 返回成分时,它可能包含也可能不包含description字段,如果该字段存在,它将包含字符串列表。

当涉及到列表时,你必须仔细注意感叹号的使用。在列表属性中,我们可以使用两个感叹号:一个用于列表本身,另一个用于列表中的项。为了使列表及其内容都不可为空,我们为两者都使用感叹号。对于列表类型使用感叹号是 GraphQL 用户中最常见的混淆来源之一。表 8.1 总结了列表属性定义中感叹号每种组合的可能返回值。

使用感叹号和列表要小心!在 GraphQL 中,感叹号表示属性不可为空,这意味着该属性必须在对象中存在,并且其值不能为null。当涉及到列表时,我们可以使用两个感叹号:一个用于列表本身,另一个用于列表中的项。感叹号的不同组合将产生属性的不同表示。表 8.1 显示了每种组合的有效表示。

表 8.1 列表属性的合法返回值

[Word] [Word!] [Word]! [Word!]!
null 合法 合法 非法 非法
[] 合法 合法 合法 合法
["word"] 合法 合法 合法 合法
[null] 合法 非法 合法 非法
["word", null] 合法 非法 合法 非法

现在我们已经了解了 GraphQL 的类型系统和列表属性,我们准备探索 GraphQL 最强大和最令人兴奋的功能之一:类型之间的连接。

8.5 思考图形:在对象类型之间建立有意义的连接

本节解释了如何在 GraphQL 中创建对象之间的连接。GraphQL 的一个巨大好处是能够连接对象。通过连接对象,我们清楚地说明了我们的实体之间的关系。正如我们将在下一章中看到的,这使得我们的 GraphQL API 更容易被消费。

8.5.1 通过边缘属性连接类型

本节解释了如何通过使用边属性来连接类型:指向另一个类型的属性。可以通过创建一个指向另一个类型的属性来连接类型。如图 8.7 所示,连接另一个对象的属性被称为边。以下代码展示了我们如何通过向Ingredient添加一个名为supplier的属性来连接Ingredient类型和Supplier类型,该属性指向Supplier

列表 8.5 一对一连接的 Edge

type Ingredient {
  id: ID!
  name: String!
  supplier: Supplier!       ①
  description: [String!]
}

① 我们使用边属性来连接 Ingredient 和 Supplier 类型。

图 8.7 要将Ingredient类型与Supplier类型连接起来,我们在Ingredient中添加一个名为supplier的属性,该属性指向Supplier类型。由于Ingredientsupplier属性正在在两个类型之间创建连接,我们称它为边。

这是一个一对一连接的例子:一个对象中的属性指向恰好一个对象。在这个例子中,这个属性被称为边,因为它将Ingredient类型与Supplier类型连接起来。它也是一个有向连接的例子:如图 8.7 所示,我们可以从Ingredient类型到达Supplier类型,但不能反过来,所以连接只在一个方向上工作。

为了使SupplierIngredient之间的连接双向,⁴我们需要向Supplier类型添加一个指向Ingredient类型的属性。由于一个供应商可以提供多种成分,ingredients属性指向一个Ingredient类型的列表。这是一个一对一连接的例子。图 8.8 显示了IngredientSupplier类型之间新的关系。

列表 8.6 SupplierIngredient之间的双向关系

type Supplier {
  id: ID!
  name: String!
  address: String!
  contactNumber: String!
  email: String!
  ingredients: [Ingredient!]!     ①
}

① 我们在 Ingredient 和 Supplier 类型之间创建一个双向关系。

图 8.8 要在两个类型之间创建双向关系,我们需要向每个类型添加指向对方的属性。在这个例子中,Ingredientsupplier属性指向Supplier类型,而Supplieringredients属性指向一个成分列表。

现在我们知道了如何通过边属性创建简单的连接,让我们看看如何使用专用类型创建更复杂的连接。

8.5.2 使用通过类型创建连接

本节讨论了通过类型:这些类型告诉我们其他对象类型是如何相互连接的。它们提供了关于连接本身的一些额外信息。我们将使用通过类型来连接我们的产品、蛋糕和饮料及其成分。我们可以通过向CakeBeverage添加一个简单的成分列表来连接它们,如图 8.9 所示,但这不会告诉我们每种成分在产品配方中各占多少。

图 8.9 我们可以将Cakeingredients字段表示为Ingredient类型的列表,但这不会告诉我们每种成分在蛋糕配方中各占多少。

为了将蛋糕和饮料与它们的成分连接起来,我们将使用一个称为IngredientRecipe的关联类型。如图 8.10 所示,IngredientRecipe有三个属性:成分本身、其数量以及数量所测量的单位。这为我们提供了关于我们的产品如何与它们的成分相关联的更多有意义的信息。

图片

图 8.10 为了表达Ingredient如何与Cake连接,我们使用IngredientRecipe关联类型,这允许我们详细说明每种成分在蛋糕配方中各占多少。

列表 8.7 通过类型表示两种类型之间关系的类型

type IngredientRecipe {                   ①
  ingredient: Ingredient!
  quantity: Float!
  unit: String!
}

type Cake {
  id: ID!
  name: String!
  price: Float
  available: Boolean!
  hasFilling: Boolean!
  hasNutsToppingOption: Boolean!
  lastUpdated: Datetime!
  ingredients: [IngredientRecipe!]!       ②
}

type Beverage {
  id: ID!
  name: String!
  price: Float
  available: Boolean!
  hasCreamOnTopOption: Boolean!
  hasServeOnIceOption: Boolean!
  lastUpdated: Datetime!   
  ingredients: [IngredientRecipe!]!
}

① 我们声明IngredientRecipe关联类型。

② 我们通过类型声明成分为一个IngredientRecipe列表。

通过在对象类型之间创建连接,我们为 API 消费者提供了通过仅跟随类型中的连接边来探索我们的数据的能力。通过创建双向关系,我们为用户提供在数据图之间往返的能力。这是 GraphQL 最强大的功能之一,总是值得花时间设计跨我们数据的有意义连接。

更多的时候,我们需要创建表示多个类型的属性。例如,我们可能有一个表示蛋糕或饮料的属性。这是下一节的主题。

8.6 通过联合和接口组合不同类型

本节讨论了如何处理我们拥有多种相同实体类型的情况。你经常会遇到指向多个类型集合的属性。这在实践中意味着什么,又是如何工作的呢?让我们通过产品 API 的一个例子来看看!

在产品 API 中,CakeBeverage是两种产品类型。在第 8.4.2 节中,我们看到了如何将CakeBeverageIngredient类型连接起来。但我们是怎样将Ingredient连接到CakeBeverage的呢?我们可以简单地给Ingredient类型添加一个名为products的属性,它指向一个CakesBeverages的列表,如下所示:

products: [Cake, Beverage]

这可行,但它不允许我们将CakesBeverages表示为单一的产品实体。我们为什么要这样做呢?原因如下:

  • CakeBeverage是同一事物:一个产品,因此将它们视为同一实体是有意义的。

  • 正如我们在第 8.8 节和第 8.9 节中将要看到的,我们将在代码的其他部分引用我们的产品,能够使用单一类型来做这一点将非常有帮助。

  • 如果我们在未来向系统中添加新的产品类型,我们不希望不得不更改所有引用产品的规范部分。相反,我们希望有一个单一的类型来代表它们所有,并只更新那个类型。

GraphQL 提供了两种将各种类型组合到单个类型下的方法:联合和接口。让我们详细看看每种方法。

当我们有一些具有共同属性的类型时,接口非常有用。例如,对于CakeBeverage类型,它们共享了大部分属性。GraphQL 接口与编程语言(如 Python)中的类接口类似:它们定义了一组必须由其他类型实现的属性。列表 8.8 展示了我们如何使用接口来表示CakeBeverage共享的属性集合。正如你所看到的,我们使用interface关键字声明接口类型。CakeBeverage类型实现了ProductInterface接口,因此它们必须定义ProductInterface类型中定义的所有属性。通过查看ProductInterface类型,任何使用我们 API 的用户都可以快速了解在BeverageCake类型上可访问哪些属性。

列表 8.8 通过接口表示公共属性

interface ProductInterface {                   ①
  id: ID!
  name: String!
  price: Float
  ingredients: [IngredientRecipe!]
  available: Boolean!
  lastUpdated: Datetime!
}

type Cake implements ProductInterface {        ②
  id: ID!
  name: String!
  price: Float
  available: Boolean!
  hasFilling: Boolean!                         ③
  hasNutsToppingOption: Boolean!
  lastUpdated: Datetime!
  ingredients: [IngredientRecipe!]!
}

type Beverage implements ProductInterface {    ④
  id: ID!
  name: String!
  price: Float
  available: Boolean!
  hasCreamOnTopOption: Boolean!
  hasServeOnIceOption: Boolean!
  lastUpdated: Datetime!
  ingredients: [IngredientRecipe!]!
}

① 我们声明了ProductInterface接口类型。

Cake类型实现了ProductInterface接口。

③ 我们定义了特定于Cake的属性。

Beverage实现了ProductInterface接口。

通过创建接口,我们使 API 消费者更容易理解我们的产品类型共享的公共属性。正如我们将在下一章中看到的,接口也使 API 更容易消费。

虽然接口帮助我们定义各种类型的公共属性,但联合帮助我们将各种类型归入同一类型。当我们想要将各种类型视为单个实体时,这非常有用。在产品 API 中,我们希望能够将CakeBeverage类型视为单个Product类型,联合允许我们这样做。联合类型是使用管道(|)运算符组合不同类型的结果。

列表 8.9 不同类型的联合

type Cake implements ProductInterface {
  id: ID!
  name: String!
  price: Float
  available: Boolean!
  hasFilling: Boolean!
  hasNutsToppingOption: Boolean!
  lastUpdated: Datetime!
  ingredients: [IngredientRecipe!]!
}

type Beverage implements ProductInterface {
  id: ID!
  name: String!
  price: Float
  available: Boolean!
  hasCreamOnTopOption: Boolean!
  hasServeOnIceOption: Boolean!
  lastUpdated: Datetime!
  ingredients: [IngredientRecipe!]!
}

union Product = Beverage | Cake      ①

① 我们创建了BeverageCake类型的联合。

使用联合和接口使我们的 API 更容易维护和消费。如果我们向 API 添加新的产品类型,我们可以确保它通过实现ProductInterface类型提供与CakeBeverage类似的接口。通过将新产品添加到Product联合中,我们确保它可以在使用Product联合类型的所有操作中使用。

现在我们已经知道了如何组合多种对象类型,现在是时候学习如何通过枚举来约束对象类型属性的值了。

8.7 使用枚举约束属性值

本节介绍了 GraphQL 的枚举类型。技术上讲,枚举是一种只能取预定义值的特定类型的标量。枚举在只能接受受限列表中选择值的属性中非常有用。在 GraphQL 中,我们使用enum关键字后跟枚举名称来声明枚举,并在大括号内列出其允许的值。

在产品 API 中,我们需要枚举来表达成分的数量。例如,在第 8.5.2 节中,我们定义了一个通过类型IngredientRecipe,它表示产品中每种成分的量。IngredientRecipe以每单位测量的数量来表示数量。我们可以用不同的方式来衡量成分。例如,我们可以用品脱、升、盎司、加仑等方式来衡量牛奶。为了保持一致性,我们希望确保每个人都使用相同的单位来描述我们成分的数量,因此我们将创建一个名为MeasureUnit的枚举类型,它可以用来约束单位属性的值。

列表 8.10 使用MeasureUnit枚举类型

enum MeasureUnit {       ①
  LITERS                 ②
  KILOGRAMS
  UNITS
}

type IngredientRecipe {
    ingredient: Ingredient!
    quantity: Float!
    unit: MeasureUnit!   ③
}

① 我们声明一个枚举。

② 我们在这个枚举中列出允许的值。

③ 单位是非空属性,类型为MeasureUnit

我们还希望使用MeasureUnit枚举来描述成分的可用库存。为此,我们定义一个Stock类型,并使用它来定义Ingredient类型的stock属性。

列表 8.11 使用Stock枚举类型

type Stock {              ①
  quantity: Float!
  unit: MeasureUnit!      ②
}

type Ingredient {
  id: ID!
  name: String!
  stock: Stock            ③
  products: [Product!]!
  supplier: Supplier!
  description: [String!]
}

① 我们声明Stock类型,以帮助我们表达有关成分可用库存的信息。

② 库存的单位属性是一个枚举。

③ 我们通过成分的库存属性将成分类型与库存类型连接起来。

枚举有助于确保某些值在整个接口中保持一致。这有助于避免当用户自行选择和编写这些值时发生的错误。

这标志着我们通过 GraphQL 类型系统的旅程结束。类型是 API 规范的构建块,但没有查询或与之交互的机制,我们的 API 将非常有限。要在服务器上执行操作,我们需要了解 GraphQL 查询和突变。这些将是本章剩余部分的主题!

8.8 定义查询以从 API 提供数据

本节介绍了 GraphQL 查询:允许我们从服务器获取或读取数据的操作。提供数据是任何 Web API 最重要的功能之一,GraphQL 提供了极大的灵活性来创建强大的查询接口。查询对应于我们在第 8.2 节中讨论的读取操作组。作为提醒,这些是需要产品 API 支持查询操作:

  • allProducts()

  • allIngredients()

  • products()

  • product()

  • ingredient()

我们将首先处理allProducts()查询,因为它是最简单的,然后继续处理products()查询。当我们处理products()时,我们将看到如何向我们的查询定义添加参数,我们将了解分页,最后,我们将学习如何将查询参数重构为其自己的类型以提高可读性和维护性。

GraphQL 查询的规范看起来类似于 Python 函数签名定义:我们定义查询名称,可选地在括号内定义查询参数列表,并在冒号后指定返回类型。以下代码展示了产品 API 中最简单的查询:allProducts() 查询,它返回所有产品的列表。allProducts() 不接受任何参数,仅返回服务器中存在的所有产品列表。

列表 8.12 简单的 GraphQL 查询以返回产品列表

type Query {                   ①
  allProducts: [Products!]!    ②
}

① 所有查询都是在查询对象类型下定义的。

② 我们定义了 allProducts() 查询。在冒号之后,我们指明查询的返回类型。

allProducts() 返回 CoffeeMesh 数据库中所有产品的列表。此类查询如果我们想对所有产品进行彻底分析时很有用,但在现实生活中,我们的 API 用户希望能够过滤结果。他们可以通过使用 products() 查询来实现,根据我们在第 8.2 节中收集的要求,该查询返回过滤后的产品列表。

查询参数在括号内定义,类似于我们定义 Python 函数参数的方式。列表 8.13 展示了如何定义 products() 查询。它包括允许我们的 API 用户通过可用性或最大和最小价格过滤产品的参数。所有参数都是可选的。API 用户可以自由使用任何或所有查询参数,或者不使用任何参数。如果他们在使用 products() 查询时没有指定任何参数,他们将获得所有产品的列表。

列表 8.13 简单的 GraphQL 查询以返回产品列表

type Query {
  products(available: Boolean, maxPrice: Float, minPrice: Float):
    [Product!]      ①
}

① 查询参数在括号内定义。

除了过滤产品列表之外,API 用户可能还希望能够对列表进行排序并分页显示结果。分页是将查询结果以指定大小的不同集合的形式提供的能力,并且在 API 中通常用于确保 API 客户端在每次请求中接收合理数量的数据。如图 8.11 所示,如果查询的结果有 10 条或更多记录,我们可以将查询结果分成每组五项的组,并一次服务一组。每组被称为 页面

图片

图 8.11 一种更常见的分页方法是让用户决定他们希望每页显示多少结果,并让他们选择他们想要获取的特定页面。

我们通过向查询中添加 resultsPerPage 参数和 page 参数来启用分页。为了对结果集进行排序,我们公开了一个 sort 参数。以下代码片段以粗体显示了在添加这些参数后对 products() 查询所做的更改:

type Query {
  products(available: Boolean, maxPrice: Float, minPrice: Float, sort: String, 
      resultsPerPage: Int, page: Int): [Product!]!
}

提供众多的查询参数给我们的 API 消费者提供了很大的灵活性,但为所有这些参数设置值可能会很繁琐。我们可以通过为一些参数设置默认值来使我们的 API 更容易使用。我们将设置一个默认排序顺序,以及 resultsPerPage 参数和 page 参数的默认值。以下代码显示了如何为 products() 查询中的某些参数分配默认值,并包括一个 SortingOrder 枚举,该枚举将 sort 参数的值限制为 ASCENDINGDESCENDING

列表 8.14 为查询参数设置默认值

enum SortingOrder {                      ①
  ASCENDING
  DESCENDING
}

type Query {
  products(
    maxPrice: Float
    minPrice: Float
    available: Boolean = true            ②
    sort: SortingOrder = DESCENDING ③
    resultsPerPage: Int = 10
    page: Int = 1
  ): [Product!]!
}

① 我们声明了 SortingOrder 枚举。

② 我们为一些参数分配默认值。

③ 我们通过将 sort 的类型设置为 SortingOrder 枚举来限制 sort 的值。

products() 查询的签名正变得越来越杂乱。如果我们继续向其中添加参数,它将变得难以阅读和维护。为了提高可读性,我们可以将参数从查询规范中重构出来,形成它们自己的类型。在 GraphQL 中,我们可以通过使用输入类型来定义参数列表,这些输入类型看起来和感觉上与任何其他 GraphQL 对象类型相同,但它们是用于查询和 mutations 的输入。

列表 8.15 将查询参数重构为输入类型

input ProductsFilter {                            ①
  maxPrice: Float                                 ②
  minPrice: Float
  available: Boolean = true,                      ③
  sort: SortingOrder = DESCENDING
  resultsPerPage: Int = 10
  page: Int = 1
}

type Query {
  products(input: ProductsFilter): [Product!]!    ④
}

① 我们声明了 ProductsFilter 输入类型。

② 我们定义 ProductsFilter 的参数。

③ 我们为一些参数分配默认值。

④ 我们将输入参数的类型设置为 ProductsFilter。

剩余的 API 查询,即 allIngredients()product()ingredient(),在列表 8.16 中以粗体显示。allIngredients() 返回完整的成分列表,因此不需要参数,就像 allProducts() 查询一样。最后,product()ingredient() 通过 ID 返回单个产品或成分,因此需要一个必需的 id 参数,参数类型为 ID。如果找到了提供的 ID 对应的产品或成分,查询将返回请求项的详细信息;否则,它们将返回 null

列表 8.16 产品 API 中所有查询的规范

type Query {
  allProducts: [Product!]!
  allIngredients: [Ingredient!]!
  products(input: ProductsFilter!): [Product!]!
  product(id: ID!): Product                        ①
  ingredient(id: ID!): Ingredient
}

① product() 返回一个可空的 Product 类型结果。

现在我们已经知道了如何定义查询,是时候学习 mutations 了,这是下一节的主题。

8.9 使用 mutations 改变服务器的状态

本节介绍了 GraphQL mutations:允许我们触发改变服务器状态的操作的命令。虽然查询的目的是让我们从服务器获取数据,但 mutations 允许我们创建新资源、删除它们或改变它们的状态。mutations 有一个返回值,可以是标量,例如布尔值,或是一个对象。这允许我们的 API 消费者验证操作是否成功完成,并获取服务器生成的任何值,例如 ID。

在 8.2 节中,我们讨论了产品 API 需要支持以下操作以在服务器中添加、删除和更新资源:

  • addIngredient()

  • updateStock()

  • addProduct()

  • updateProduct()

  • deleteProduct()

在本节中,我们将记录 addProduct()updateProduct()deleteProduct() 演变。其他演变的规范与这些类似,你可以在本书提供的 GitHub 仓库中查看它们。

GraphQL 演变看起来类似于 Python 中函数的签名:我们定义演变的名称,在括号中描述其参数,并在冒号后提供其返回类型。列表 8.17 展示了 addProduct() 演变的规范。addProduct() 接受一系列参数,并返回 Product 类型。所有参数都是可选的,除了 nametype。我们使用 type 来指示我们正在创建的产品类型,是蛋糕还是饮料。我们还包含了一个 ProductType 枚举来约束 type 参数的值只能是 cakebeverage。由于这个演变用于创建蛋糕和饮料,我们允许用户指定每种类型的属性,即蛋糕的 hasFillinghasNutsToppingOption,以及饮料的 hasCreamOnTopOptionhasServeOnIceOption,但我们默认将它们设置为 false 以简化演变的用法。

列表 8.17 定义 GraphQL 演变

enum ProductType {                           ①
  cake
  beverage
}

input IngredientRecipeInput {
  ingredient: ID!
  quantity: Float!
  unit: MeasureUnit!
}

enum Sizes {
  SMALL
  MEDIUM
  BIG
}

type Mutation {                              ②
  addProduct(
    name: String!
    type: ProductType!
    price: String
    size: Sizes
    ingredients: [IngredientRecipeInput!]! 
    hasFilling: Boolean = false
    hasNutsToppingOption: Boolean = false
    hasCreamOnTopOption: Boolean = false
    hasServeOnIceOption: Boolean = false
  ): Product!                                ③
}

① 我们声明一个 ProductType 枚举。

② 我们在 Mutation 对象类型下声明演变。

③ 我们指定了 addProduct() 的返回类型。

你会同意 addProduct() 演变的签名定义看起来有点杂乱。我们可以通过将参数列表重构为它们自己的类型来提高可读性和可维护性。列表 8.18 展示了如何通过将参数列表移动到输入类型中来重构 addProduct() 演变。AddProductInput 包含了在创建新产品时可以设置的 所有可选参数。我们留出了 name 参数,这是创建新产品时唯一的必需参数。正如我们很快就会看到的,这允许我们在不需要 name 参数的其他演变中重用 AddProductInput 输入类型。

列表 8.18 使用输入类型重构参数

input AddProductInput { ①
  price: String ②
  size: Sizes 
  ingredients: [IngredientRecipeInput!]!
  hasFilling: Boolean = false ③
  hasNutsToppingOption: Boolean = false
  hasCreamOnTopOption: Boolean = false
  hasServeOnIceOption: Boolean = false
}

type Mutation {
  addProduct(
    name: String!
    type: ProductType!
    input: AddProductInput!
  ): Product!                             ④
}

① 我们声明 AddProductInput 输入类型。

② 我们列出 AddProductInput 的参数。

③ 我们为一些参数指定了默认值。

addProduct() 方法的输入参数具有 AddProduct 输入类型。

输入类型不仅帮助我们使规范更易于阅读和维护,而且还可以允许我们创建可重用的类型。我们可以在 updateProduct() 变异的签名中重用 AddProductInput 输入类型。当我们更新产品的配置时,我们可能只想更改其某些参数,例如名称、价格或其成分。下面的片段显示了我们在 updateProduct() 中如何重用 AddProductInput 参数。除了 AddProductInput 之外,我们还包括一个强制性的产品 id 参数,这是识别我们想要更新的产品所必需的。我们还包括 name 参数,在这种情况下是可选的:

type Mutation {
  updateProduct(id: ID!, input: AddProductInput!): Product!
}

让我们现在看看 deleteProduct() 变异,它从目录中删除一个产品。为此,用户必须提供他们想要删除的产品的 ID。如果操作成功,变异返回 true;否则,返回 false。下面的片段显示了 deleteProduct() 变异的规范:

deleteProduct(id: ID!): Boolean!

这标志着我们通过 GraphQL 的 SDL 的旅程结束!你现在已经拥有了定义自己的 API 模式的所有必要工具。在第九章中,我们将学习如何使用产品 API 规范启动模拟服务器,以及如何消费和交互 GraphQL API。

摘要

  • GraphQL 是构建 Web API 的流行协议。它在需要给予 API 客户端对要获取的数据有完全控制权的情况下,以及在数据高度互联的情况下表现出色。

  • 一个 GraphQL API 规范被称为模式,它使用模式定义语言(SDL)编写。

  • 我们使用 GraphQL 的标量类型来定义对象类型的属性:布尔值、字符串、浮点数、整数和 ID。此外,我们还可以创建自己的自定义标量类型。

  • GraphQL 的对象类型是属性的集合,它们通常代表由 API 服务器管理的资源或实体。

  • 我们可以通过使用边缘属性来连接对象,即指向另一个对象的属性,以及通过使用通过类型。通过类型是添加关于两个对象如何连接的额外信息的对象类型。

  • 要约束属性的值,我们使用枚举类型。

  • GraphQL 查询是允许 API 客户端从服务器获取数据的操作。

  • GraphQL 变异是允许 API 客户端触发改变服务器状态的行动的操作。

  • 当查询和变更有长参数列表时,我们可以将它们重构为输入类型以提高可读性和可维护性。输入类型也可以在多个查询或变体中重用。


¹ 此定义出现在 GraphQL 规范的主页上:graphql.org/

² 我将 GraphQL 与 SQL 的比较归功于 Eve Porcello 和 Alex Banks,学习 GraphQL,现代 Web 应用程序声明式数据获取(O’Reilly,2018),第 31-32 页。

³ GraphQL 网站维护了一个在不同语言中构建 GraphQL 服务器的可用运行时列表:graphql.org/code/.

⁴ 在关于 GraphQL 的文献中,你经常会发现关于 GraphQL 受图论启发的讨论,以及我们如何使用图论的一些概念来阐述类型之间的关系。遵循这一传统,我们这里提到的双向关系是一个无向图的例子,因为可以从 Supplier 类型到达 Ingredient 类型,反之亦然。关于 GraphQL 上下文中的图论的良好讨论,请参阅 Eve Porcello 和 Alex Banks 的作品 Learning GraphQL, Declarative Data Fetching for Modern Web Apps (O’Reilly, 2018),第 15-30 页。

9 消费 GraphQL API

本章涵盖

  • 运行 GraphQL 模拟服务器以测试我们的 API 设计

  • 使用 GraphiQL 客户端探索和消费 GraphQL API

  • 对 GraphQL API 执行查询和突变

  • 使用 cURL 和 Python 以编程方式消费 GraphQL API

本章教您如何消费 GraphQL API。正如我们在第八章中学到的,GraphQL 为 Web API 提供了一种查询语言,在本章中,您将学习如何使用这种语言在服务器上运行查询。特别是,您将学习如何对 GraphQL API 进行查询。您将学习探索 GraphQL API 以发现其可用的类型、查询和突变。从客户端理解 GraphQL API 的工作原理是掌握 GraphQL 的重要一步。

学习与 GraphQL API 交互将帮助您学习消费其他供应商公开的 API,它将允许您对自己的 API 进行测试,并帮助您设计更好的 API。您将学习使用 GraphiQL 客户端探索和可视化 GraphQL API。正如您将看到的,GraphiQL 提供了一个交互式查询面板,这使得在服务器上运行查询变得更容易。

为了说明 GraphQL 查询语言背后的概念和思想,我们将使用第八章中设计的产品 API 运行实际示例。由于我们尚未实现产品 API 的 API 规范,我们将学习运行模拟服务器——这是 API 开发过程中的重要部分,因为它使测试和验证 API 设计变得容易得多。最后,您还将学习如何使用 cURL 和 Python 等工具以编程方式对 GraphQL API 进行查询。

9.1 运行 GraphQL 模拟服务器

在本节中,我们解释了如何运行 GraphQL 模拟服务器以探索和测试我们的 API。模拟服务器是一个模仿真实服务器行为的假服务器,提供相同的端点和功能,但使用假数据。例如,产品 API 的模拟服务器是一个模仿产品 API 实现并提供与我们第八章中开发的相同接口的服务器。

定义模拟服务器是模仿真实服务器行为的假服务器。它们通常在实现后端时用于开发 API 客户端。您可以使用 API 规范启动模拟服务器。模拟服务器返回假数据,通常不会持久化数据。

模拟服务器在开发 Web API 中起着至关重要的作用,因为它们允许我们的 API 消费者在我们进行后端实现的同时开始编写客户端代码。在本节中,我们将运行产品 API 的模拟服务器。我们运行模拟服务器所需的一切就是 API 规范,这是我们第八章中开发的。您可以在本书的 GitHub 仓库中的 ch08/schema.graphql 下找到 API 规范。

您可以从许多不同的库中选择来运行一个 GraphQL 模拟服务器。在本章中,我们将使用 GraphQL Faker (github.com/APIs-guru/graphql-faker),这是最受欢迎的 GraphQL 模拟工具之一。要安装 GraphQL Faker,请运行以下命令:

$ npm install graphql-faker

这将在您的当前目录下创建一个 package-lock.json 文件,以及一个 node_modules 文件夹。package-lock.json 包含了与 graphql-faker 一起安装的依赖项信息,而 node_modules 是这些依赖项安装的目录。要运行模拟服务器,请执行以下命令:

$ ./node_modules/.bin/graphql-faker schema.graphql

GraphQL Faker 通常在端口 9002 上运行,并暴露了三个端点:

  • /editor—一个交互式编辑器,您可以在其中开发您的 GraphQL API。

  • /graphql—这是您的 GraphQL API 的 GraphiQL 接口。这是我们用来探索 API 和运行查询的接口。

  • /voyager—这是您 API 的交互式展示,有助于您理解类型之间的关系和依赖(见图 9.1)。

图片

图 9.1 产品 API 的 Voyager UI。此 UI 显示了 API 中可用查询捕获的对象类型之间的关系。通过跟随连接箭头,您可以看到我们可以从每个查询到达哪些对象。

要开始探索和测试产品 API,请在您的浏览器中访问以下地址:http://localhost:9002/graphql(如果您在另一个端口上运行 GraphQL Faker,则您的 URL 将不同)。此端点加载了我们的产品 API 的 GraphiQL 接口。图 9.2 展示了此接口的外观,并突出了其中的最重要的元素。

图片

图 9.2 GraphiQL 中的 API 文档浏览器和查询面板界面

要发现 API 暴露的查询和突变,请点击 UI 右上角处的 Docs 按钮。点击 Docs 按钮后,将弹出一个侧边导航栏,提供两个选项:查询或突变(见图 9.3)。如果您选择查询,您将看到服务器暴露的查询列表及其返回类型。您可以点击返回类型来探索它们的属性,如图 9.3 所示。在下一节中,我们将开始测试 GraphQL API!

图片

图 9.3 通过在 GraphiQL 中的文档浏览器中点击,您可以检查 API 中所有可用的查询和突变,以及它们返回的类型及其属性。

9.2 介绍 GraphQL 查询

在本节中,我们学习通过使用 GraphiQL 运行查询来消费 GraphQL API。我们将从不需要任何参数的简单查询开始,然后我们将继续到带有参数的查询。

9.2.1 运行简单查询

在本节中,我们介绍了一些不需要参数的简单查询。产品 API 提供了两种此类查询:allProducts(),它返回 CoffeeMesh 提供的所有产品的列表,以及 allIngredients(),它返回所有成分的列表。

我们将使用 GraphiQL 来运行针对 API 的查询。要运行查询,请转到 GraphiQL UI 中的查询编辑器面板,如图 9.2 所示。列表 9.1 展示了如何运行 allIngredients() 查询。正如你所看到的,要运行查询,我们必须使用查询操作的名称后跟大括号。在大括号内,我们声明从服务器获取的属性选择。大括号内的块称为 选择集。GraphQL 查询必须始终包含选择集。如果你不包括它,你将收到来自服务器的错误响应。在这里,我们只选择了每个成分的名称。表示查询的文本称为 查询文档

列表 9.1 运行 allIngredients() 查询的查询文档

{                     ①
  allIngredients {    ②
    name              ③
  }
}

① 我们将查询放在大括号内。

② 我们运行 allIngredients() 查询。

③ 我们查询名称属性。

来自 GraphQL API 的成功查询的响应包含一个包含“data”字段的 JSON 文档,它包装了查询结果。不成功的查询会导致包含“error”键的 JSON 文档。由于我们正在运行模拟服务器,API 返回随机值。

列表 9.2 allIngredients() 查询的成功响应示例

{
  "data": {                 ①
    "allIngredients": [     ②
      {
        "name": "string"
      },
      {
        "name": "string"
      }
    ]
  }
}

① 成功的响应包括一个 "data" 键。

② 查询的结果在以查询本身命名的键下索引。

既然我们已经了解了 GraphQL 查询的基础知识,让我们通过添加参数来丰富我们的查询吧!

9.2.2 使用参数运行查询

本节解释了我们在 GraphQL 查询中使用参数的方式。allIngredients() 是一个不需要任何参数的简单查询。现在让我们看看如何运行需要参数的查询。此类查询的一个例子是 ingredient() 查询,它需要一个 id 参数。以下代码展示了我们如何使用随机 ID 调用 ingredient() 查询。正如你所看到的,我们将查询参数作为冒号分隔的键值对包含在括号内。

列表 9.3 使用必需参数运行查询

{
  ingredient(id: "asdf") {    ①
    name
  }
}

① 我们调用 ingredient(),将 ID 参数设置为 "asdf "。

现在我们已经知道了如何运行带参数的查询,让我们看看在运行查询时可能会遇到的问题类型以及如何处理它们。

9.2.3 理解查询错误

本节解释了在运行 GraphQL 查询时可能会遇到的一些常见错误,并教你如何阅读和解释它们。

如果你运行 ingredient() 查询时省略了所需的参数,你将收到来自 API 的错误。错误响应包括一个错误键,指向服务器找到的所有错误的列表。每个错误都是一个具有以下键的对象:

  • message——包含错误的人类可读描述

  • locations—指定错误在查询中的位置,包括行和列

列表 9.4 显示了当你运行空括号的查询时会发生什么。正如你所见,我们得到了一个带有某种隐晦信息的语法错误:Expected Name, found )。这是一个在 GraphQL 中发生语法错误时的常见错误。在这种情况下,这意味着 GraphQL 期望在开括号之后有一个参数,但相反,它找到了一个闭括号。

列表 9.4 缺少查询参数错误

# Query:
{
  ingredient() {                                           ①
    name
  }
}

# Error:
{
  "errors": [                                              ②
    {
      "message": "Syntax Error: Expected Name, found )",   ③
      "locations": [                                       ④
        {
          "line": 2,                                       ⑤
          "column": 14                                     ⑥
        }
      ]
    }
  ]
}

① 我们运行 ingredient()查询时没有包含必需的参数 id。

② 不成功的响应包括一个"errors"键。

③ 我们得到一个通用的语法错误。

④ 错误的确切位置在我们的查询中

⑤ 错误出现在我们的查询文档的第二行。

⑥ 错误出现在第二行的第 14 个字符。

另一方面,如果你像列表 9.5 中所示的那样运行没有任何括号的ingredient()查询,你将得到一个错误,指出你遗漏了必需的参数id

在 GraphQL 查询和突变中使用括号 在 GraphQL 中,查询的参数定义在括号内。如果你运行一个包含必需参数的查询,例如ingredient,你必须将参数包含在括号内(参见列表 9.3)。如果不这样做,将会抛出错误(参见列表 9.4 和 9.5)。如果你运行一个不带参数的查询,你必须省略括号。例如,当我们运行allIngredients()查询时,我们省略括号(参见列表 9.1),因为allIngredients()不需要任何括号。

列表 9.5 缺少查询参数错误

# Query:
{
  ingredient {                           ①
    name
  }
}

# Error:
{
  "errors": [
    {
      "message": "Field \"ingredient\" argument \"id\" of type \"ID!\" is 
➥ required, but it was not provided.",  ②
      "locations": [
        {
          "line": 2,                     ③
          "column": 3                    ④
        }
      ]
    }
  ]
}

① 我们运行 ingredient()查询时没有括号。

② 错误信息表明查询中缺少 id 参数。

③ 错误出现在我们的查询文档的第二行。

④ 错误出现在第二行的第三个字符。

现在我们知道了如何在查询出错时阅读和解释错误信息,让我们来探索返回多个类型的查询。

9.3 在查询中使用片段

本节解释了我们如何运行返回多个类型的查询。到目前为止,本章中我们看到的查询很简单,因为它们只返回一个类型,即Ingredient。然而,我们的与产品相关的查询,如allProducts()product(),返回的是Product联合类型,它是CakeBeverage类型的组合。在这种情况下,我们如何运行我们的查询?

当一个 GraphQL 查询返回多个类型时,我们必须为每个类型创建选择集。例如,如果你使用单个选择集运行allProducts()查询,你将得到一个错误信息,表明服务器不知道如何解析选择集中的属性。

列表 9.6 使用单个选择集调用allProducts()

# Query
{
  allProducts {              ①
    name                     ②
  }
}

# Error message
{
  "errors": [                ③
    {
      "message": "Cannot query field \"name\" on type \"Product\". Did you 
➥ mean to use an inline fragment on \"ProductInterface\", \"Beverage\", 
➥ or \"Cake\"?",            ④
      "locations": [
        {
          "line": 3,         ⑤
          "column": 5        ⑥
        }
      ]
    }
  ]
}

① 我们运行 allProducts()查询时没有参数。

② 我们在选择集中包含名称属性。

③ 我们得到一个错误响应。

④ 服务器不知道如何解析选择集中的属性。

⑤ 错误出现在查询文档的第三行。

⑥ 错误出现在第三行的第五个位置。

列表 9.6 中的错误信息询问你是否想在ProductInterfaceBeverageCake上使用内联片段。什么是内联片段?内联片段是在特定类型上的匿名选择集。内联片段的语法包括三个点(JavaScript 中的扩展运算符)后跟on关键字和选择集应用到的类型,以及花括号之间的属性选择:

...on ProductInterface {
      name
    }

列表 9.7 通过添加选择ProductInterfaceCakeBeverage类型属性的内联片段来修复allProducts()查询。allProducts()的返回类型是Product,它是CakeBeverage的联合,因此我们可以从这两种类型中选择属性。从规范中,我们还知道CakeBeverage实现了ProductInterface接口类型,因此我们可以方便地在接口上直接选择CakeBeverage共有的属性。

列表 9.7 添加每个返回类型的内联片段

{
  allProducts {
    ...on ProductInterface {     ①
      name
    }
    ...on Cake {                 ②
      hasFilling
    }
    ...on Beverage {             ③
      hasCreamOnTopOption
    }
  }
}

① 在 ProductInterface 类型上有选择集的内联片段

② 在 Cake 类型上有选择集的内联片段

③ 在 Beverage 类型上有选择集的内联片段

列表 9.7 使用内联片段,但片段的实际好处是我们可以将它们定义为独立的变量。这使得片段可重用,同时也使我们的查询更易于阅读。列表 9.8 展示了我们如何重构列表 9.7 以使用独立片段。查询变得如此干净!在实际情况下,你很可能会处理大量的选择集,因此将你的片段组织成独立的、可重用的代码片段将使你的查询更容易阅读。

列表 9.8 使用独立片段

{
  allProducts {
    ...commonProperties
    ...cakeProperties
    ...beverageProperties
  }
}
fragment commonProperties on ProductInterface {
  name
}

fragment cakeProperties on Cake {
  hasFilling
}

fragment beverageProperties on Beverage {
  hasCreamOnTopOption
}

现在我们知道了如何处理返回多个对象类型的查询,让我们将我们的查询技能提升到下一个层次。在下一节中,我们将学习如何使用一种称为输入参数的特定类型参数运行查询。

9.4 使用输入参数运行查询

本节解释了如何使用输入类型参数运行查询。在第 8.8 节中,我们了解到输入类型类似于对象类型,但它们是为了用作 GraphQL 查询或变异的参数。在产品 API 中,ProductsFilter是一个输入类型的例子,它允许我们通过可用性、最低或最高价格等因素过滤产品。ProductsFilterproducts()查询的参数。我们如何调用products()查询?

当一个查询以输入类型的形式接受参数时,查询的输入类型参数必须以输入对象的形式传递。这可能听起来很复杂,但实际上非常简单。我们使用 ProductsFiltermaxPrice 参数调用 products() 查询。要使用输入类型中的任何参数,我们只需用大括号将它们括起来。

列表 9.9 使用必需参数调用查询

{
  products(input: {maxPrice: 10}) {    ①
    ...on ProductInterface {           ②
      name
    }
  }
}

① 指定 ProductFilter 的 maxPrice 参数。

② 在 ProductInterface 类型上的内联片段

现在我们已经知道了如何使用输入参数调用查询,让我们更深入地了解 API 规范中定义的对象之间的关系,并看看我们如何构建允许我们遍历我们的数据图的查询。

9.5 导航 API 图

本节解释了如何通过利用它们的连接从多个类型中选择属性。在 8.5 节中,我们学习了如何通过使用边属性和通过类型创建对象类型之间的连接。这些连接允许 API 客户端遍历 API 管理的资源之间的关系图。例如,在产品 API 中,CakeBeverage 类型通过一个称为 IngredientRecipe 的通过类型与 Ingredient 类型连接。通过利用这个连接,我们可以运行查询,获取与每个产品相关的成分信息。在本节中,我们将学习如何构建这样的查询。

在我们的查询中,每当我们添加一个指向另一个对象类型的属性的选择器时,我们必须包括一个嵌套的选择集。例如,如果我们添加了 ProductInterface 类型上的 ingredient 属性的选择器,我们必须包括一个包含在 ingredients 属性中的 IngredientRecipe 的任何属性的选择集。我们在 allProducts() 查询中包含了 ProductInterfaceingredients 属性的嵌套选择集。该查询选择了每个产品的名称以及产品配方中每个成分的名称。

列表 9.10 查询嵌套对象类型

{
  allProducts {
    ...on ProductInterface {    ①
      name,
      ingredients {             ②
        ingredient {            ③
          name
        }
      }
    }
  }
}

① 在 ProductInterface 类型上的内联片段

② ProductInterface 的 ingredients 属性选择器

③ IngredientRecipe 的 ingredient 属性选择器

列表 9.10 利用 ProductInterfaceIngredient 类型之间的连接,在单个查询中从这两个类型中获取信息,但我们还可以更进一步。Ingredient 类型包含一个 supplier 属性,它指向 Supplier 类型。假设我们想要获取一个产品列表,包括它们的名称和成分,以及每个成分的供应商名称。(我鼓励您访问由 graphql-faker 生成的 Voyager UI,以可视化此查询捕获的关系;图 9.1 是 Voyager UI 的示意图。)

列表 9.11 通过类型之间的连接遍历产品 API 图

{
  allProducts {
    ...on ProductInterface {     ①
      name
      ingredients {              ②
        ingredient {             ③
          name
          supplier {             ④
            name
          }
        }
      }
    }
  }
}

① 在 ProudctInterface 类型上的内联片段

② ProductInterface 的 ingredients 属性的选择器

③ IngredientRecipe 的 ingredient 属性选择器

④ Ingredient 的 supplier 属性选择器

列表 9.11 正在遍历我们的类型图。从ProductInterface类型开始,我们可以通过利用它们的连接来获取其他对象的信息,例如IngredientSupplier

这就是 GraphQL 最强大的功能之一,也是与其他类型的 API(如 REST)相比的主要优势之一。使用 REST,我们需要进行多个请求才能获取在列表 9.11 中通过一个请求就能获取的所有信息。GraphQL 赋予你获取所需所有信息,以及仅获取所需信息的权力,在一个请求中完成。

现在我们已经知道了如何在 GraphQL API 中遍历类型图,让我们通过学习如何在单个请求中运行多个查询来将我们的查询技能提升到下一个层次!

9.6 运行多个查询和查询别名

本节解释了如何在每个请求中运行多个查询以及如何为服务器返回的响应创建别名。别名我们的查询意味着更改服务器返回的数据集索引下的键。正如我们将看到的,别名可以提高服务器返回结果的可读性,尤其是在我们每个请求进行多个查询时。

9.6.1 在同一请求中运行多个查询

在前面的章节中,我们每次请求只运行一个查询。然而,GraphQL 也允许我们在一个请求中发送多个查询。这是 GraphQL 的另一个强大功能,可以帮助我们节省不必要的网络往返到服务器,从而提高我们应用程序的整体性能和用户体验。

假设我们想要获取 CoffeeMesh 平台中所有产品和成分的列表,如图 9.4 所示。为此,我们可以运行allIngredients()allProducts()查询。列表 9.12 显示了如何在同一个查询文档中包含这两个操作。通过在同一个查询文档中包含多个查询,我们确保它们都通过同一个请求发送到服务器,从而节省了往返服务器的次数。代码还包括一个命名片段,该片段选择ProductInterface类型的属性。命名片段有助于保持我们的查询简洁和专注。

列表 9.12 每个请求中的多个查询

{
  allProducts {                                       ①
    ...commonProperties                               ②
  }
  allIngredients {                                    ③
    name
  }
}

fragment commonProperties on ProductInterface {       ④
  name
}

① 我们运行不带参数的 allProducts()查询。

② 我们使用命名片段选择属性。

③ 运行 allIngredients()查询。

④ 在 ProductInterface 类型上具有选择集的命名片段

图 9.4 在 GraphQL 中,我们可以在同一个请求中运行多个查询,并且响应将包含每个查询的一个数据集。

9.6.2 别名我们的查询

我们在前几节中运行的所有查询都是匿名查询。当我们进行匿名查询时,服务器返回的数据会出现在我们调用的查询名称命名的键下。

列表 9.13 匿名查询的结果

# Query:
{
  allIngredients {        ①
    name
  }
}

# Result:
{
  "data": {               ②
    "allIngredients": [   ③
      {
        "name": "string"
      },
      {
        "name": "string"
      }
    ]
  }
}

① 我们运行 allIngredients()查询。

② 查询成功响应

③ 查询结果

运行匿名查询有时可能会令人困惑。allIngredients()返回一个成分列表,因此将成分列表索引在ingredients键下,而不是allIngredients()下是有帮助的。更改此键的名称称为查询别名。我们可以通过使用别名使我们的查询更易读。当我们在同一个请求中包含多个查询时,别名的优势变得更加明显。例如,如果我们在 9.12 列表中使用别名,那么所有产品和成分的查询将变得更加易读。以下代码显示了如何使用别名重命名每个查询的结果:allProducts()的结果出现在product别名下,而allIngredients()查询的结果出现在ingredients别名下。

列表 9.14 使用查询别名以使查询更易读

{
  products: allProducts {                          ①
    ...commonProperties                            ②
  }
  ingredients: allIngredients {                    ③
    name
  }
}

fragment commonProperties on ProductInterface {    ④
  name
}

① allProducts()查询的别名

② 我们使用命名片段选择属性。

③ allIngredients()查询的别名

④ 在 ProductInterface 上设置选择集的命名片段

在某些情况下,使用查询别名是必要的,以便使我们的请求生效。例如,在 9.15 列表中,我们运行了products()查询两次以选择两个数据集:一个用于可用产品,另一个用于不可用产品。这两个数据集都是由同一个查询生成的:products。正如你所看到的,如果没有查询别名,这个请求会导致冲突错误,因为两个数据集都在同一个键下返回:products

列表 9.15 由于没有别名而多次调用相同的查询导致的错误

{
  products(input: {available: true}) {            ①
    ...commonProperties                           ②
  }
  products(input: {available: false}) {           ③
    ...commonProperties
  }
}
fragment commonProperties on ProductInterface {   ④
  name
}

# Error
{
  "errors": [                                     ⑤
    {
      "message": "Fields \"products\" conflict because they have differing 
➥ arguments. Use different aliases on the fields to fetch both if this 
➥ was intentional.",                             ⑥
      "locations": [
        {
          "line": 2,                              ⑦
          "column": 3
        },
        {
          "line": 5,
          "column": 3
        }
      ]
    }
  ]
}

① 运行用于过滤可用产品的 products()查询

② 我们使用 commonProperties 片段选择属性。

③ 我们运行用于过滤不可用产品的 products()查询。

④ 在 ProductInterface 类型上设置选择集的命名片段。

⑤ 查询返回失败响应,因此有效载荷包括一个错误键。

⑥ 错误信息表明查询文档包含冲突。

⑦ 服务器在查询文档的第 2 行和第 5 行发现了错误。

为了解决 9.15 列表中查询所造成的冲突,我们必须使用别名。9.16 列表通过为每个操作添加一个别名来修复查询:availableProducts用于过滤可用产品的查询,unavailableProducts用于过滤不可用产品的查询。

列表 9.16 使用别名多次调用相同的查询

{
  availableProducts: products(input: {available: true}) {      ①
    ...commonProperties
  }
  unavailableProducts: products(input: {available: false}) {   ②
    ...commonProperties
  }
}

fragment commonProperties on ProductInterface {
  name
}

# Result (datasets omitted for brevity)
{
  "data": {                                                    ③
    "availableProducts": [...],                                ④
    "unavailableProducts": [...]                               ⑤
  }
}

① 可用产品()查询的别名

② 不可用产品()查询的 unavailableProducts 别名

③ 服务器成功响应

④ 可用产品()查询的结果

⑤ 不可用产品()查询的结果

这就结束了我们对 GraphQL 查询的概述。你已经学会了如何使用参数、输入类型、内联和命名的片段以及别名来运行查询,你还学会了如何在同一请求中包含多个查询。我们已经走了很长的路!但是,没有学习如何运行变异,任何 GraphQL 查询语言的概述都不会完整。

9.7 运行 GraphQL 变异

本节解释了如何运行 GraphQL 变异。变异是允许我们创建资源或更改服务器状态的 GraphQL 函数。运行变异与运行查询类似。这两者之间的唯一区别在于它们的意图:查询的目的是从服务器读取数据,而变异的目的是在服务器中创建或更改数据。

让我们通过一个例子来说明我们如何运行一个变异。列表 9.17 展示了如何运行 deleteProduct() 变异。当我们使用变异时,我们必须通过指定我们的操作为变异来开始我们的查询文档。deleteProduct() 变异有一个必需的参数,即产品 ID,它的返回值是一个简单的布尔值,所以在这种情况下,我们不需要包含选择集。

列表 9.17 调用一个变异

mutation {                    ①
  deleteProduct(id: "asdf")   ②
}

① 我们将我们要运行的运算符指定为变异。

② 我们调用 deleteProduct() 变异,传递必需的 id 参数。

现在我们来看一个更复杂的变异,比如 addProduct(),它用于向 CoffeeMesh 目录中添加新产品。addProduct() 有三个必需的参数:

  • name—产品名称。

  • type—产品类型。此参数的值受 ProductType 枚举的限制,它提供了两个选择:蛋糕和饮料。

  • input—额外的产品属性,例如其价格、尺寸、成分列表等。属性的全列表由 AddProductInput 类型给出。

addProduct() 返回一个 Product 类型的值,这意味着在这种情况下,我们必须包含一个选择集。记住,ProductCakeBeverage 类型的联合,所以我们的选择集必须使用片段来指示我们想在返回的有效载荷中包含哪个类型的属性。在下面的示例中,我们选择了 ProductInterface 类型的 name 属性。

列表 9.18 使用输入参数和复杂返回类型调用变异

mutation {                                                                 ①
  addProduct(name: "Mocha", type: beverage, input: {price: 10, size: BIG, ingredients: [{ingredient: 1, quantity: 1, unit: LITERS}]}) {         ②
    ...commonProperties                                                    ③
  }
}

fragment commonProperties on ProductInterface {
  name
}

① 我们将我们要运行的运算符指定为变异。

② 我们调用 addProduct() 变异。

③ 我们使用命名的片段选择属性。

现在我们已经知道了如何运行变异,是时候学习我们如何通过参数化参数来编写更结构化和可读的查询文档了。

9.8 运行参数化查询和变异

本节介绍了参数化查询,并解释了我们可以如何使用它们来构建更结构化和可读的查询文档。在之前的章节中,当使用需要参数的查询和突变时,我们在调用函数的同一行定义了每个参数的值。在有很多参数的查询中,这种方法可能导致查询文档杂乱无章,难以阅读和维护。GraphQL 为此提供了一个解决方案,即使用参数化查询。

参数化查询使我们能够将查询/突变调用与数据解耦。图 9.5 说明了我们如何使用 GraphiQL 参数化对addProduct()突变的调用(查询的代码也显示在列表 9.19 中,以便您可以检查并更容易地复制它)。当我们参数化一个查询或突变时,我们需要做两件事:在查询变量对象中为查询/突变参数分配值,并在查询/突变周围创建一个函数包装器。图 9.6 说明了所有这些部分如何组合在一起,将参数化值绑定到addProduct()突变调用。

图 9.5 GraphiQL 提供了一个查询变量面板,我们可以在这里包含参数化查询的输入值。

列表 9.19 使用参数化语法

# Query document
mutation CreateProduct(                                   ①
  $name: String!
  $type: ProductType!
  $input: AddProductInput!
) {
  addProduct(name: $name, type: $type, input: $input) {   ②
    ...commonProperties                                   ③
  }
}

fragment commonProperties on ProductInterface {
  name
}

# Query variables
{
  "name": "Mocha",                                        ④
  "type": "beverage",                                     ⑤
  "input": {                                              ⑥
    "price": 10,
    "size": "BIG",
    "ingredients": [{"ingredient": 1, "quantity": 1, "unit": "LITERS"}]
  }
}

① 我们创建一个名为CreateProduct()的包装器。

② 我们调用addProduct()突变。

③ 我们使用命名片段选择属性。

④ 我们为名称参数分配一个值。

⑤ 我们为类型参数分配一个值。

⑥ 我们为输入参数分配一个值。

让我们详细看看这些步骤。

  1. 创建查询/突变包装器。为了参数化我们的查询,我们在查询或突变周围创建一个函数包装器。如图 9.5 所示,我们称这个包装器为CreateProduct()。包装器的语法看起来与我们用来定义查询的语法非常相似。参数化参数必须包含在包装器函数的签名中。在图 9.5 中,我们参数化了addProduct()突变中的nametypeinput参数。参数化参数用美元符号($)标记。在包装器的签名中(即CreateProduct()中),我们指定参数化参数的预期类型。

  2. 通过查询变量对象进行参数化。分别地,我们将查询变量定义为一个 JSON 文档。如图 9.5 所示,在 GraphiQL 中,我们在查询变量面板中定义查询变量。为了进一步了解参数化查询的工作原理,请参阅图 9.6。

在图 9.5 中,我们使用参数化语法仅包装了一个突变,但没有任何阻止我们在同一个查询文档中包装更多突变。当我们包装多个查询或突变时,所有参数化参数必须在包装器函数签名内定义。以下代码显示了如何将列表 9.19 中的查询扩展到包括对 deleteProduct() 突变的调用。在这里,我们调用包装器 CreateAndDeleteProduct() 以更好地表示此请求中的操作。

图片

图 9.6 为了参数化查询和突变,我们在查询或突变周围创建了一个函数包装器。在包装器的签名中,我们包括参数化参数。参数化变量带有前导美元符号 ($)。

列表 9.20 使用参数化语法

# Query document
mutation CreateAndDeleteProduct(                           ①
  $name: String!
  $type: ProductType!
  $input: AddProductInput!
  $id: ID!
) {
  addProduct(name: $name, type: $type, input: $input) {    ②
    ...commonProperties                                    ③
  }
  deleteProduct(id: $id)                                   ④
}

fragment commonProperties on ProductInterface {
  name
}
# Query variables
{
  "name": "Mocha",                                         ⑤
  "type": "beverage",
  "input": {
    "price": 10,
    "size": "BIG",
    "ingredients": [{"ingredient": 1, "quantity": 1, "unit": "LITERS"}]
  },
  "id": "asdf"                                             ⑥
}

① 我们创建了一个名为 CreateAndDeleteProduct() 的包装器。

② 我们调用 addProduct() 突变。

③ 我们使用命名片段选择属性。

④ 我们调用 deleteProduct() 突变。

⑤ 我们为 addProduct() 的参数分配值。

⑥ 我们为 deleteProduct() 函数的 id 参数设置了值。

这完成了我们学习如何消费 GraphQL API 的旅程。你现在可以检查任何 GraphQL API,探索其类型,并对其查询和突变进行实验。在我们关闭这一章之前,我想向你展示一个 GraphQL API 请求是如何在底层工作的。

9.9 揭秘 GraphQL 查询

本节解释了在 HTTP 请求的上下文中,GraphQL 查询是如何在底层工作的。在前面的章节中,我们使用了 GraphiQL 客户端来探索我们的 GraphQL API 并与之交互。GraphiQL 将我们的查询文档转换为 GraphQL 服务器能够理解的 HTTP 请求。GraphiQL 等客户端如 GraphiQL 是使与 GraphQL API 交互更简单的接口。但没有任何阻止你直接向 API 发送 HTTP 请求,例如,从你的终端使用类似 cURL 的工具。与一个普遍的误解相反,你实际上并不需要任何特殊的工具来与 GraphQL API 一起工作。¹

要向 GraphQL API 发送请求,你可以使用 GET 或 POST 方法之一。如果你使用 GET,你将使用 URL 查询参数发送你的查询文档;如果你使用 POST,你将在请求有效载荷中包含查询。GraphQL Faker 的模拟服务器只接受 GET 请求,所以我将说明如何使用 GET 发送查询。

让我们运行 allIngredients() 查询,仅选择每个成分的 name 属性。由于这是一个 GET 请求,我们的查询文档必须作为查询参数包含在 URL 中。然而,查询文档包含特殊字符,如大括号,这些字符被认为是不可安全的,因此不能包含在 URL 中。为了处理 URL 中的特殊字符,我们需要对它们进行 URL 编码。URL 编码 是将特殊字符(如大括号、标点符号等)转换为适合 URL 的格式的过程。URL 编码的字符以百分号开头,因此这种编码也被称为 百分编码。² 当我们使用 --data-urlencode 选项时,cURL 会负责对数据进行 URL 编码。通过使用 --data-urlencode,cURL 将我们的命令转换为以下 URL 的 GET 请求:http://localhost:9002/graphql?query={allIngredients{name}}。以下代码片段显示了你需要运行的 cURL 命令来执行此调用:

$ curl http://localhost:9002/graphql --data-urlencode \
'query={allIngredients{name}}'

现在你已经了解了 GraphQL API 请求的工作原理,让我们看看如何利用这些知识来编写 Python 代码,以消耗 GraphQL API。

9.10 使用 Python 代码调用 GraphQL API

本节说明了我们如何使用 Python 与 GraphQL API 进行交互。GraphQL 客户端如 GraphiQL 有助于探索和熟悉 GraphQL API,但在实际应用中,你将花费大部分时间编写消耗这些 API 的应用程序。在本节中,我们将学习如何使用用 Python 编写的 GraphQL 客户端来消耗产品 API。

要与 GraphQL API 一起工作,Python 生态系统提供了如 gql (github.com/graphql-python/gql) 和 sgqlc (github.com/profusion/sgqlc) 这样的库。当我们需要使用 GraphQL 的高级功能,如订阅时,这些库非常有用。在微服务环境中,你很少需要这些功能,因此在本节中,我们将采用更简单的方法,并使用流行的 requests 库 (github.com/psf/requests)。请记住,GraphQL 查询仅仅是带有查询文档的 GET 或 POST 请求。

列表 9.21 展示了如何调用allIngredients()查询,并为Ingredientname属性添加选择器。该列表也可在本书的 GitHub 仓库 ch09/client.py 中找到。由于我们的 GraphQL 模拟服务器只接受 GET 请求,我们以 URL 编码数据的形式发送查询文档。使用 requests 库,我们通过传递查询文档到get方法的params参数来实现这一点。如您所见,查询文档看起来与我们在 GraphiQL 查询面板中写的相同,API 的响应结果也相同。这是一个好消息,因为它意味着,当您编写查询时,您可以从 GraphiQL 开始工作,利用其对语法高亮和查询验证的优秀支持,当您准备好时,可以将查询直接移动到 Python 代码中。

列表 9.21 使用 Python 调用 GraphQL 查询

# file: ch09/client.py 

import requests                                                 ①
URL = 'http://localhost:9002/graphql'                           ②

query_document = '''                                            ③
{
  allIngredients {
    name
  }
}
'''

result = requests.get(URL, params={'query': query_document})    ④

print(result.json())                                            ⑤

# Result
{'data': {'allIngredients': [{'name': 'string'}, {'name': 'string'}, 
➥ {'name': 'string'}]}}

① 我们导入 requests 库。

② 我们 GraphQL 服务器的基 URL

③ 查询文档

④ 我们将查询文档作为 URL 查询参数发送 GET 请求到服务器。

⑤ 我们解析并打印服务器返回的 JSON 有效负载。

这标志着我们通过 GraphQL 的旅程结束。您从第八章学习 GraphQL 支持的基本标量类型,到本章使用 GraphiQL、cURL 和 Python 等多样化的工具进行复杂查询。在这个过程中,我们构建了产品 API 的规范,并使用 GraphQL 模拟服务器与之交互。这是一项了不起的成就。如果您已经读到这儿,您已经学到了很多关于 API 的知识,您应该为此感到自豪!

GraphQL 是网络 API 世界中最受欢迎的协议之一,其采用率每年都在增长。GraphQL 是构建微服务 API 和与前端应用集成的绝佳选择。在下一章中,我们将着手实现产品 API 及其服务。敬请期待!

摘要

  • 当我们调用返回对象类型的查询或突变时,我们的查询必须包含选择集。选择集是我们想要从查询返回的对象中获取的属性列表。

  • 当查询或突变返回多个类型的列表时,我们的选择集必须包含片段。片段是特定类型上的属性选择,并且由扩展运算符(三个点)作为前缀。

  • 当调用包含参数的查询或突变时,我们可以通过围绕查询或查询构建包装器来参数化这些参数。这使我们能够编写更易于阅读和维护的查询文档。

  • 在设计 GraphQL API 时,使用模拟服务器进行测试是个好主意,这允许我们在服务器实现的同时构建 API 客户端。

  • 您可以使用graphql-faker运行 GraphQL 模拟服务器,它还会为 API 创建一个 GraphiQL 界面。这有助于测试我们的设计是否符合预期。

  • 在幕后,一个 GraphQL 查询是一个简单的 HTTP 请求,它使用 GET 或 POST 方法中的任意一种。当使用 GET 时,我们必须确保我们的查询文档是 URL 编码的,而当使用 POST 时,我们将其包含在请求负载中。


¹ 除非你想使用订阅(与 GraphQL 服务器的连接,允许你在服务器发生某些事件时接收通知,例如,当资源的状态发生变化时)。订阅需要与服务器建立双向连接,因此你需要比 cURL 更复杂的东西。要了解更多关于 GraphQL 订阅的信息,请参阅 Eve Porcello 和 Alex Banks 的著作 Learning GraphQL, Declarative Data Fetching for Modern Web Apps(O’Reilly,2018),第 50–53 页和第 150–160 页。

² Tim Berners-Lee, R. Fielding, 和 L. Masinter, “统一资源标识符 (URI):通用语法,” RFC 3986,第 2.1 节,datatracker.ietf.org/doc/html/rfc3986#section-2.1.

10 使用 Python 构建 GraphQL API

本章涵盖

  • 使用 Ariadne 网络服务器框架创建 GraphQL API

  • 验证请求和响应有效载荷

  • 为查询和突变创建解析器

  • 为复杂对象类型,如联合类型创建解析器

  • 为自定义标量类型和对象属性创建解析器

在第八章中,我们为产品服务设计了 GraphQL API,并生成了一份详细说明产品 API 要求的规范。在本章中,我们根据规范实现 API。为了构建 API,我们将使用 Ariadne 框架,这是 Python 生态系统中最受欢迎的 GraphQL 库之一。Ariadne 允许我们利用文档驱动开发的优点,通过自动从规范中加载数据验证模型。我们将学习创建解析器,这些解析器是 Python 函数,用于实现查询或突变的逻辑。我们还将学习处理返回多个类型的查询。阅读本章后,您将拥有开始开发自己的 GraphQL API 所需的所有工具!

本章的代码可在本书提供的 GitHub 存储库中找到,位于 ch10 文件夹下。除非另有说明,本章中所有文件引用均相对于 ch10 文件夹。例如,server.py 指的是 ch10/server.py 文件,而 web/schema.py 指的是 ch10/web/schema.py 文件。此外,为了确保本章中使用的所有命令按预期工作,请使用cd命令将 ch10 文件夹移动到您的终端中。

10.1 分析 API 要求

在本节中,我们分析 API 规范的要求。在开始实现 API 之前,花些时间分析 API 规范及其要求是值得的。让我们为产品 API 进行这项分析!

产品 API 规范可在本书的 GitHub 存储库的 ch10/web/products.graphql 下找到。规范定义了一个表示我们可以从 API 检索的数据的对象类型集合,以及一组查询和突变,它们公开了产品服务的功能。我们必须创建验证模型,这些模型忠实地表示规范中定义的模式,以及正确实现查询和突变功能的函数。我们将与一个框架一起工作,该框架可以从规范中自动处理模式验证,因此我们不需要担心实现验证模型。

我们的实现将主要关注查询和突变。在模式中定义的大多数查询和突变都会返回IngredientProduct类型的数组或单个实例。由于Ingredient是一个对象类型,所以它比较简单,因此我们将首先查看使用此类型的查询和突变。ProductBeverageCake类型的联合,这两个类型都实现了ProductInterface类型。正如我们将看到的,实现返回联合类型的查询和突变稍微复杂一些。返回Product对象列表的查询包含BeverageCake类型的实例,因此我们需要实现额外的功能,使服务器能够确定列表中每个元素属于哪种类型。

话虽如此,让我们分析一下我们将用于本章的技术栈,然后直接进入实现阶段!

10.2 介绍技术栈

在本节中,我们讨论我们将用于实现产品 API 的技术栈。我们讨论了可用于在 Python 中实现 GraphQL API 的库,并从中选择了一个。我们还讨论了我们将用于运行应用程序的服务器框架。

由于我们将实现 GraphQL API,我们首先想要寻找的是一个好的 GraphQL 服务器库。GraphQL 的网站(graphql.org/code/)是寻找 GraphQL 生态系统工具和框架的绝佳资源。由于生态系统不断演变,我建议您偶尔查看该网站,以了解任何新增内容。该网站列出了四个支持 GraphQL 的 Python 库:

  • Graphene (github.com/graphql-python/graphene) 是为 Python 构建的第一个 GraphQL 库之一。它经过实战检验,并且是最广泛使用的库之一。

  • Ariadne (github.com/mirumee/ariadne) 是一个为模式优先(或文档驱动)开发构建的库。它是一个非常流行的框架,并且能够自动处理模式验证和序列化。

  • Strawberry (github.com/strawberry-graphql/strawberry) 是一个较新的库,它通过提供一个受 Python 数据类启发的干净接口,使实现 GraphQL 模式模型变得容易。

  • Tartiflette (github.com/tartiflette/tartiflette) 是 Python 生态系统中的另一个新成员,它允许您使用模式优先方法实现 GraphQL 服务器,并且它建立在 asyncio 之上,这是 Python 的异步编程核心库。

对于本章,我们将使用 Ariadne,因为它支持模式优先或文档驱动的开发方法,并且是一个成熟的项目。API 规范已经可用,所以我们不想花时间在 Python 中实现每个模式模型。相反,我们希望使用一个可以直接从 API 规范中处理模式验证和序列化的库,Ariadne 就能这样做。

我们将使用 Uvicorn 运行 Ariadne 服务器,我们在第二章和第六章中与 FastAPI 一起工作时遇到了 Uvicorn。要安装本章的依赖项,您可以使用本书提供的存储库中 ch10 文件夹下的 Pipfile 和 Pipfile.lock 文件。将 Pipfile 和 Pipfile.lock 文件复制到您的 ch10 文件夹中,然后使用 cd 命令进入该文件夹,并运行以下命令:

pipenv install

如果您想安装 Ariadne 和 Uvicorn 的最新版本,只需运行

pipenv install ariadne uvicorn

现在我们已经安装了依赖项,让我们激活环境:

pipenv shell

在安装了所有依赖项之后,我们现在可以开始编码了,所以让我们开始吧!

10.3 介绍 Ariadne

在本节中,我们介绍 Ariadne 框架,并通过一个简单的示例来了解它是如何工作的。我们将学习如何使用 Ariadne 运行 GraphQL 服务器,如何加载 GraphQL 规范,以及如何实现一个简单的 GraphQL 解析器。正如我们在第九章中看到的,用户通过运行查询和突变与 GraphQL API 交互。GraphQL 解析器是一个知道如何执行这些查询或突变之一的函数。在我们的实现中,我们将拥有与 API 规范中查询和突变一样多的解析器。如图 10.1 所示,解析器是 GraphQL 服务器的基础,因为正是通过解析器,我们才能向 API 用户返回实际数据。

图 10.1 为了向用户提供服务,GraphQL 服务器使用解析器,这些解析器是知道如何为给定查询构建有效载荷的函数。

让我们从编写一个非常简单的 GraphQL 模式开始。打开 server.py 文件,并将以下内容复制到其中:

# file: server.py

schema = '''
  type Query {
    hello: String
  }
'''

我们定义了一个名为 schema 的变量,并将其指向一个简单的 GraphQL 模式。此模式仅定义了一个名为 hello() 的查询,该查询返回一个字符串。hello() 查询的返回值是可选的,这意味着 null 也是一个有效的返回值。为了通过我们的 GraphQL 服务器公开此查询,我们需要使用 Ariadne 实现一个解析器。

Ariadne 可以从这个简单的模式定义中运行 GraphQL 服务器。我们如何做到这一点?首先,我们需要使用 Ariadne 的 make_executable_schema() 函数加载模式。make_executable_schema() 解析文档,验证我们的定义,并构建模式内部表示。如图 10.2 所示,Ariadne 使用此函数的输出来验证我们的数据。例如,当我们返回查询的有效载荷时,Ariadne 会将有效载荷与模式进行验证。

图 10.2 要使用 Ariadne 运行 GraphQL 服务器,我们通过加载 API 的 GraphQL 模式和查询及变异的解析器集合来生成一个可执行的模式。Ariadne 使用可执行模式来验证用户发送给服务器的数据,以及从服务器发送给用户的数据。

一旦我们加载了模式,我们可以使用 Ariadne 的GraphQL类(列表 10.1)来初始化我们的服务器。Ariadne 提供了两种服务器实现:一种同步实现,位于ariande.wsgi模块下,另一种异步实现,位于ariande.asgi模块下。在本章中,我们将使用异步实现。

列表 10.1 使用 Ariadne 初始化 GraphQL 服务器

# file: server.py

from ariadne import make_executable_schema
from ariadne.asgi import GraphQL

schema = '''
  type Query {                                                ①
    hello: String
  }
'''

server = GraphQL(make_executable_schema(schema), debug=True)  ②

① 我们声明一个简单的模式。

② 我们实例化 GraphQL 服务器。

要运行服务器,请在终端中执行以下命令:

$ uvicorn server:server --reload

您的应用程序将在 http://localhost:8000 上可用。如果您访问该地址,您将看到一个指向应用程序的 Apollo Playground 界面。如图 10.3 所示,Apollo Playground 与我们在第八章中学到的 GraphiQL 类似。在左侧面板中,我们编写我们的查询。编写以下查询:

{
  hello
}

图片

图 10.3 Apollo Playground 界面包含一个查询面板,我们可以在这里执行查询和变异;一个结果面板,用于评估查询和变异;以及一个文档面板,我们可以在这里检查 API 模式。

此查询执行我们在列表 10.1 中定义的查询函数。如果您按下执行按钮,您将在右侧面板上获得此查询的结果:

{
  "data": {
    "hello": null
  }
}

查询返回null。这并不令人惊讶,因为hello()查询的返回值是一个可空的字符串。我们如何让hello()查询返回一个字符串?请输入解析器。解析器是函数,它让服务器知道如何为类型或属性生成值。为了让hello()查询返回实际的字符串,我们需要实现一个解析器。让我们创建一个返回 10 个随机字符字符串的解析器。

在 Ariadne 中,解析器是一个 Python 可调用对象(例如,一个函数),它接受两个位置参数:objinfo

Ariadne 中的解析器参数

Ariadne 的解析器总是有两个位置参数,通常称为objinfo。基本 Ariadne 解析器的签名是

def simple_resolver(obj: Any, info: GraphQLResolveInfo):
  pass

如图中所示,obj通常会被设置为None,除非解析器有一个父解析器,在这种情况下obj会被设置为父解析器返回的值。当我们遇到一个不返回显式类型的解析器时,我们会遇到后一种情况。例如,我们将在第 10.4.4 节中实现的allProducts()查询解析器不返回显式类型。它返回一个类型为Product的对象,这是CakeBeverage类型的联合。为了确定每个对象的类型,Ariadne 需要调用Product类型的解析器。

图片

当解析器没有父解析器时,obj参数被设置为None。当存在父解析器时,obj将被设置为父解析器返回的值。

info参数是GraphQLResolveInfo的一个实例,它包含执行查询所需的信息。Ariadne 使用这些信息来处理和响应每个请求。对于应用程序开发者来说,info对象暴露的最有趣的属性是info.context,它包含关于调用解析器时上下文的详细信息,例如 HTTP 上下文。要了解更多关于objinfo对象的信息,请查看 Ariadne 的文档:ariadnegraphql.org/docs/resolvers.html

解析器需要绑定到其对应的对象类型。Ariadne 为每个 GraphQL 类型提供了可绑定的类:

  • ObjectType用于对象类型。

  • QueryType用于查询类型。在 GraphQL 中,查询类型代表一个模式中所有查询的集合。正如我们在第八章(第 8.8 节)中看到的,查询是一个从 GraphQL 服务器读取数据的函数。

  • MutationType用于突变类型。正如我们在第八章(第 8.9 节)中看到的,突变是一个改变 GraphQL 服务器状态的函数。

  • UnionType用于联合类型。

  • InterfaceType用于接口类型。

  • EnumType用于枚举类型。

由于hello()是一个查询,我们需要将其解析器绑定到 Ariadne 的QueryType实例。列表 10.2 显示了如何做到这一点。我们首先创建一个QueryType类的实例并将其分配给一个名为query的变量。然后我们使用QueryTypefield()装饰器方法绑定我们的解析器,这在 Ariadne 的大多数可绑定类上都是可用的,并允许我们将解析器绑定到特定字段。按照惯例,我们在解析器的名称前加上resolve_前缀。Ariadne 的解析器默认总是获取两个位置参数:objinfo。在这种情况下,我们不需要使用这些参数,所以我们使用一个通配符后跟一个下划线(*_),这是 Python 中忽略一系列位置参数的惯例。为了使 Ariadne 了解我们的解析器,我们需要将我们的可绑定对象作为数组传递给make_executable_ schema()函数。这些更改在server.py中进行。

列表 10.2 使用 Ariadne 实现 GraphQL 解析器

# file: server.py

import random
import string

from ariadne import QueryType, make_executable_schema
from ariadne.asgi import GraphQL

query = QueryType()                                                   ①

@query.field('hello')                                                 ②
def resolve_hello(*_):                                                ③
    return ''.join(
        random.choice(string.ascii_letters) for _ in range(10)        ④
    )

schema = '''
type Query {                                                          ⑤
        hello: String
    }
'''

server = GraphQL(make_executable_schema(schema, [query]), debug=True) ⑥

QueryType实例

② 我们使用 QueryType 的 field() 装饰器绑定 hello() 查询的解析器。

③ 我们跳过仅位置参数。

④ 我们返回一个随机生成的 ASCII 字符列表。

⑤ 我们声明我们的 GraphQL 模式。

⑥ GraphQL 服务器的实例

由于我们使用热重载标志(--reload)运行服务器,一旦你将文件中的更改保存,服务器会自动重新加载。返回到 http://127.0.0.1:8000 上的 Apollo Playground 接口,并再次运行 hello() 查询。这次,你应该得到一个由 10 个字符组成的随机字符串作为结果。

这完成了我们对 Ariadne 的介绍。你已经学会了如何使用 Ariadne 加载 GraphQL 模式,如何运行 GraphQL 服务器,以及如何为查询函数实现解析器。在接下来的章节中,我们将应用这些知识来构建产品服务的 GraphQL API。

10.4 实现产品 API

在本节中,我们将使用上一节学到的所有知识来构建产品服务的 GraphQL API。具体来说,你将学习如何构建产品 API 的查询和突变(mutations)解析器,处理查询参数,以及构建你的项目结构。在这个过程中,我们将学习 Ariadne 框架的额外功能和实现 GraphQL 解析器的各种策略。到本节结束时,你将能够为你的微服务构建 GraphQL API。让我们开始这段旅程吧!

10.4.1 项目结构布局

在本节中,我们为产品 API 实现结构化我们的项目。到目前为止,我们已经在 server.py 文件下包含了所有我们的代码。为了实现整个 API,我们需要将我们的代码拆分到不同的文件中,并为项目添加结构;否则,代码库将变得难以阅读和维护。为了保持实现简单,我们将使用我们数据的内存表示。

如果你跟随着上一节中的代码,请删除 server.py 中我们之前编写的代码,这代表了我们应用程序的入口点,因此将包含 GraphQL 服务器的实例。我们将在名为 web/ 的文件夹中封装 Web 服务器实现。创建这个文件夹,并在其中创建以下文件:

  • data.py 将包含我们数据的内存表示。

  • mutations.py 将包含产品 API 中突变(mutations)的解析器。

  • queries.py 将包含查询的解析器。

  • schema.py 将包含加载可执行模式所需的所有代码。

  • types.py 将包含对象类型、自定义标量类型和对象属性的解析器。

产品规格文件 products.graphql 也位于 web 文件夹下,因为它由 web/schema.py 文件下的代码处理。你可以从本书 GitHub 仓库中 ch10/web/products.graphql 文件复制 API 规范。产品 API 的目录结构如下所示:

.
├── Pipfile
├── Pipfile.lock
├── server.py
└── web
    ├── data.py
    ├── mutations.py
    ├── products.graphql
    ├── queries.py
    ├── schema.py
    └── types.py

本书 GitHub 仓库中包含一个名为 exceptions.py 的附加模块,你可以从中查看如何在 GraphQL API 中处理异常的示例。现在我们已经结构化了我们的项目,是时候开始编码了!

10.4.2 创建 GraphQL 服务器的入口点

现在我们已经结构化了我们的项目,是时候着手实现工作了。在本节中,我们将创建 GraphQL 服务器的入口点。我们需要创建 Ariadne 的 GraphQL 类的实例,并从产品规范中加载一个可执行的架构。

正如我们在 10.4.1 节中提到的,产品 API 服务器的入口点位于 server.py 之下。将以下内容包含在这个文件中:

# file: server.py

from ariadne.asgi import GraphQL

from web.schema import schema

server = GraphQL(schema, debug=True)

接下来,让我们在 web/schema.py 下创建可执行的架构:

# file: web/schema.py

from pathlib import Path

from ariadne import make_executable_schema

schema = make_executable_schema(
    (Path(__file__).parent / 'products.graphql').read_text()
)

产品 API 的 API 规范位于 web/products.graphql 文件下。我们读取架构文件的内容,并将它们传递给 Ariadne 的 make_executable_schema() 函数。然后,我们将生成的架构对象传递给 Ariadne 的 GraphQL 类以实例化服务器。如果你还没有启动服务器,你现在可以执行以下命令:

$ uvicorn server:server --reload

和之前一样,API 可在 http://localhost:8000 上访问。如果你再次访问这个地址,你会看到熟悉的 Apollo Playground UI。在这个阶段,我们可以尝试运行产品 API 规范中定义的任何查询;然而,由于我们没有实现任何解析器,大多数查询都会失败。例如,如果你运行以下查询

{
  allIngredients {
    name
  }
}

你会得到以下错误消息:“Cannot return null for non-nullable field Query.allProducts。”服务器不知道如何为 Ingredient 类型生成值,因为我们没有为其提供解析器,所以让我们构建它!

10.4.3 实现查询解析器

在本节中,我们学习如何实现查询解析器。如图 10.4 所示,查询解析器是一个 Python 函数,它知道如何为给定的查询返回一个有效的有效载荷。我们将为 allIngredients() 查询构建一个解析器,这是产品 API 规范中(列表 10.3)最简单的查询之一。

图片

图 10.4 GraphQL 使用解析器来服务用户发送到服务器的查询请求。解析器是一个 Python 函数,它知道如何为给定的查询返回一个有效的有效载荷。

要实现 allIngredients() 查询的解析器,我们只需要创建一个返回与 Ingredient 类型形状相同的数据结构的功能,该类型有四个非空属性:idnamestockproductsstock 属性反过来是一个 Stock 对象类型的实例,根据规范,它必须包含 quantityunit 属性。最后,products 属性必须是一个 Product 对象的数组。数组的内含是非空的,但空数组是一个有效的返回值。

列表 10.3 Ingredient 类型的规范

# file: web/products.graphql

type Stock {                  ①
    quantity: Float!          ②
    unit: MeasureUnit!
}

type Ingredient {
    id: ID!
    name: String!
    stock: Stock!
    products: [Product!]!     ③
    supplier: Supplier        ④
    description: [String!]
    lastUpdated: Datetime!
}

① 声明 Stock 类型。

② 数量是一个非空浮点数。

③ 产品是一个非空的产品列表。

④ 供应商通过类型指向 Supplier 类型,是一个可空的。

在 web/data.py 文件下,让我们给我们的数据内存列表表示添加一个成分列表:

# file: web/data.py

from datetime import datetime

ingredients = [
    {
        'id': '602f2ab3-97bd-468e-a88b-bb9e00531fd0',
        'name': 'Milk',
        'stock': {
            'quantity': 100.00,
            'unit': 'LITRES',
        },
        'supplier': '92f2daae-a4f8-4aae-8d74-51dd74e5de6d',
        'products': [],
        'lastUpdated': datetime.utcnow(),
    },
]

现在我们有一些数据,我们可以在allIngredients()解析器中使用它。列表 10.4 显示了allIngredients()解析器的外观。正如我们在第 10.3 节中所做的那样,我们首先创建了一个QueryType类的实例,并将解析器与这个类绑定。由于这是一个查询类型的解析器,实现代码位于 web/queries.py 文件下。

列表 10.4 allIngredients()查询的解析器

# file: web/queries.py

from ariadne import QueryType

from web.data import ingredients

query = QueryType()

@query.field('allIngredients')     ①
def resolve_all_ingredients(*_):
    return ingredients             ②

① 我们使用装饰器绑定 allIngredients()解析器。

② 我们返回一个硬编码的响应。

为了启用查询解析器,我们必须将查询对象传递给 web/schema.py 下的make_executable_ schema()函数:

# file: web/schema.py

from pathlib import Path

from ariadne import make_executable_schema

from web.queries import query

schema = make_executable_schema(
    (Path(__file__).parent / 'products.graphql').read_text(), [query]
)

如果我们回到 Apollo Playground UI 并运行查询

{
  allIngredients {
    name
  }
}

我们得到了一个有效的有效载荷。查询只选择了成分的名称,这本身并不很有趣,并且它并没有真正告诉我们我们的当前解析器是否如预期地适用于其他字段。让我们编写一个更复杂的查询来更彻底地测试我们的解析器。以下查询选择了成分的idnamedescription,以及与之相关的每个产品的name

{
  allIngredients {
    id,
    name,
    products {
      ...on ProductInterface {
        name
      }
    },
    description
  }
}

此查询的响应有效载荷也是有效的:

{
  "data": {
    "allIngredients": [
      {
        "id": " "602f2ab3-97bd-468e-a88b-bb9e00531fd0",
        "name": "Milk",
        "products": [],
        "description": null
      }
    ]
  }
}

产品列表为空,因为我们还没有将任何产品与成分关联,descriptionnull,因为这是一个可空字段。现在我们知道如何实现简单查询的解析器实现,在下一节中,我们将学习如何实现处理更复杂情况的解析器。

10.4.4 实现类型解析器

在本节中,我们将学习如何实现返回多个类型的查询解析器。allIngredients()查询相当简单,因为它只返回一种类型的对象:Ingredient类型。现在让我们考虑allProducts()查询。如图 10.5 所示,allProducts()更复杂,因为它返回Product类型,它是BeverageCake两种类型的联合,这两种类型都实现了ProductInterface类型。

图片

图 10.5 allIngredients()查询返回一个Ingredient对象的数组,而allProducts()查询返回一个Product对象的数组,其中ProductBeverageCake两种类型的联合。

让我们从向 web/data.py 文件下的内存数据列表中添加产品列表开始。我们将添加两个产品:一个 Beverage 和一个 C``ake。我们应该在产品中包含哪些字段?如图 10.6 所示,由于 BeverageCake 实现了 ProductInterface 类型,我们知道它们都需要一个 id、一个 name、一个 ingredients 列表和一个名为 available 的字段,该字段表示产品是否可用。在这些从 ProductInterface 继承的公共字段之上,Beverage 需要两个额外的字段:hasCreamOnTopOptionhasServeOnIceOption,这两个都是布尔值。反过来,Cake 需要属性 hasFillinghasNutsToppingOption,这些也是布尔值。

图 10.6 产品是 BeverageCake 类型的联合,这两个类型都实现了 ProductInterface 类型。由于 BeverageCake 实现了相同的接口,这两个类型共享从接口继承的属性。除了这些属性外,每个类型都有自己的特定属性,例如 Cake 类型的 hasFilling

列表 10.5 allProducts() 查询的解析器

# file: web/data.py

...

products = [
    {
        'id': '6961ca64-78f3-41d4-bc3b-a63550754bd8',
        'name': 'Walnut Bomb',
        'price': 37.00,
        'size': 'MEDIUM',
        'available': False,
        'ingredients': [
            {
                'ingredient': '602f2ab3-97bd-468e-a88b-bb9e00531fd0', ①
                'quantity': 100.00,
                'unit': 'LITRES',
            }
        ],
        'hasFilling': False,
        'hasNutsToppingOption': True,
        'lastUpdated': datetime.utcnow(),
    },
    {
        'id': 'e4e33d0b-1355-4735-9505-749e3fdf8a16',
        'name': 'Cappuccino Star',
        'price': 12.50,
        'size': 'SMALL',
        'available': True,
        'ingredients': [
            {
                'ingredient': '602f2ab3-97bd-468e-a88b-bb9e00531fd0',
                'quantity': 100.00,
                'unit': 'LITRES',
            }
        ],
        'hasCreamOnTopOption': True,
        'hasServeOnIceOption': True,
        'lastUpdated': datetime.utcnow(),
    },
]

① 此 ID 引用了我们在 web/data.py 中之前添加的牛奶成分的 ID。

现在我们有了产品列表,让我们在 allProducts() 解析器中使用它。

列表 10.6 添加 allProducts() 解析器

# file: web/queries.py

from ariadne import QueryType

from web.data import ingredients, products

query = QueryType()

...

@query.field('allProducts')     ①
def resolve_all_products(*_):
    return products             ②

① 我们使用 field() 装饰器绑定 allProducts() 的解析器。

② 我们返回一个硬编码的响应。

让我们运行一个简单的查询来测试解析器:

{
  allProducts {
    ...on ProductInterface {
      name
    }
  }
}

如果你运行此查询,你会得到一个错误,表示服务器无法确定我们列表中每个元素的类型。在这些情况下,我们需要一个类型解析器。如图 10.7 所示,一个 类型解析器 是一个 Python 函数,它确定对象的类型,并返回类型的名称。

图 10.7 类型解析器是一个确定对象类型的函数。此示例显示了 resolve_product_type() 解析器如何确定 resolve_all_products() 解析器返回的对象的类型。

在查询和突变中,我们需要类型解析器来返回多于一个对象类型。在产品 API 中,这影响到所有返回 Product 类型的查询和突变,例如 allProducts()addProduct()product()

返回多个类型 当查询或突变返回多个类型时,你需要实现一个类型解析器。这适用于返回联合类型和实现接口的对象类型的查询和突变。

列表 10.7 展示了我们在 Ariadne 中如何实现 Product 类型的类型解析器。类型解析器函数接受两个位置参数,第一个是一个对象。我们需要确定这个对象的类型。如图 10.8 所示,由于我们知道 CakeBeverage 有不同的必需字段,我们可以使用这些信息来确定它们的类型:如果对象有 hasFilling 属性,我们知道它是一个 Cake;否则,它是一个 Beverage

图 10.8 类型解析器检查有效负载的属性以确定其类型。在这个例子中,resolve_product_type() 寻找区分 CakeBeverage 类型的区分属性。

类型解析器必须绑定到 Product 类型。由于 Product 是一个联合类型,我们使用 UnionType 类创建它的一个可绑定对象。Ariadne 保证解析器中的第一个参数是一个对象,我们检查这个对象以解析其类型。我们不需要任何其他参数,因此我们使用 Python 的 *_ 语法忽略它们,这是忽略位置参数的标准做法。为了解析对象的类型,我们检查它是否有 hasFilling 属性。如果有,我们知道它是一个 Cake 对象;否则,它是一个 Beverage。最后,我们将产品可绑定对象传递给 make_executable_schema() 函数。由于这是一个类型解析器,这段代码将放入 web/types.py。

列表 10.7 实现对 Product 联合类型的类型解析器

# file: web/types.py

from ariadne import UnionType

product_type = UnionType('Product')    ①

@product_type.type_resolver            ②
def resolve_product_type(obj, *_):     ③
    if 'hasFilling' in obj:
        return 'Cake'
    return 'Beverage'

① 我们使用 UnionType 类为 Product 类型创建一个可绑定对象。

② 我们使用 resolver() 装饰器绑定 Product 的解析器。

③ 我们将解析器的第一个位置参数捕获为 obj。

要启用类型解析器,我们需要将产品对象添加到 web/schema.py 下的 make_executable_ schema() 函数中:

# file: web/schema.py

from pathlib import Path

from ariadne import make_executable_schema

from web.queries import query
from web.types import product_type

schema = make_executable_schema(
    (Path(__file__).parent / 'products.graphql').read_text(), 
    [query, product_type]
)

让我们再次运行 allProducts() 查询:

{
  allProducts {
    ...on ProductInterface {
      name
    }
  }
}

你现在将获得一个成功的响应。你已经学会了如何实现类型解析器和处理返回多个类型的查询!在下一节中,我们将继续通过学习如何处理查询参数来探索查询。

10.4.5 处理查询参数

在本节中,我们学习如何在解析器中处理查询参数。大多数产品 API 中的查询都接受过滤参数,并且所有突变至少需要一个参数。让我们通过研究产品 API 中的一个示例来了解我们如何访问参数:products() 查询接受一个 input 过滤对象,其类型为 ProductsFilter。我们如何在解析器中访问这个过滤对象?

图 10.9 查询参数作为关键字参数传递给我们的解析器。这个例子说明了 resolve_products() 解析器是如何被调用的,其中 input 参数作为关键字参数传递。参数 inputProductsFilter 类型的对象,因此它以字典的形式出现。

如图 10.9 所示,当查询或突变带有参数时,Ariadne 将这些参数作为关键字参数传递给我们的解析器。列表 10.8 显示了如何访问products()查询解析器的input参数。由于input参数是可选的,因此可能是 null,我们将其默认设置为Noneinput参数是ProductsFilter输入类型的一个实例,因此当它在查询中存在时,它以字典的形式出现。从 API 规范中,我们知道ProductsFilter保证以下字段的可用性:

  • available——布尔字段,通过是否可用过滤产品

  • sortBy——枚举类型,允许我们按价格或名称对产品进行排序

  • sort——枚举类型,允许我们按升序或降序排序结果

  • resultsPerPage——指示每页应显示多少结果

  • page——指示应返回哪个结果页

除了这些参数之外,ProductsFilter还可能包括两个可选参数:maxPrice,它通过最大价格过滤结果,以及minPrice,它通过最小价格过滤结果。由于maxPriceminPrice不是必填字段,我们使用 Python 字典的get()方法检查它们是否存在,如果找不到则返回None。让我们首先实现过滤和排序功能,然后再处理分页。以下代码位于 web/queries.py 文件中。

列表 10.8 在解析器中访问input参数

# file: web/queries.py

...

Query = QueryType()

...

@query.field('products')                                ①
def resolve_products(*_, input=None):                   ②
    filtered = [product for product in products]        ③
    if input is None:                                   ④
        return filtered
    filtered = [                                        ⑤
        product for product in filtered
        if product['available'] is input['available']
    ]
    if input.get('minPrice') is not None:               ⑥
        filtered = [
            product for product in filtered
            if product['price'] >= input['minPrice']
        ]
    if input.get('maxPrice') is not None:
        filtered = [
            product for product in filtered
            if product['price'] <= input['maxPrice']
        ]
    filtered.sort(                                      ⑦
        key=lambda product: product.get(input['sortBy'], 0],
        reverse=input['sort'] == 'DESCENDING'
    )
    return filtered                                     ⑧

① 我们使用 field()装饰器绑定 products()的解析器。

② 我们忽略默认的位置参数,而是捕获输入参数。

③ 我们复制产品列表。

④ 如果输入为 None,我们返回整个数据集。

⑤ 我们通过可用性过滤产品。

⑥ 我们通过 minPrice 过滤产品。

⑦ 我们对过滤后的数据集进行排序。

⑧ 我们返回过滤后的数据集。

让我们运行一个查询来测试这个解析器:

{
  products(input: {available: true}) {
    ...on ProductInterface {
      name
    }
  }
}

你应该从服务器获得一个有效的响应。现在我们已经过滤了结果,我们需要对它们进行分页。列表 10.9 向 web/queries.py 添加了一个名为get_page()的通用分页函数。提醒一下:在正常情况下,你会在数据库中存储数据,并将过滤和分页委托给数据库。这里的示例是为了说明如何在解析器中使用查询参数。我们使用itertools模块中的islice()函数进行分页。

图 10-10

图 10.10 itertools模块中的islice()函数允许你通过选择子集的startstop索引来获取可迭代对象的切片。

正如你在图 10.10 中可以看到,islice() 允许我们从可迭代对象中提取一个片段。islice() 需要我们提供我们想要切片的部分的起始和停止索引。例如,一个包含数字 0 到 9 的 10 项列表,提供起始索引为 2 和停止索引为 6,将给我们以下项的切片:[2, 3, 4, 5]。API 从 1 开始分页结果,而 islice() 使用基于零的索引,因此 get_page()page 参数中减去一个单位来补偿这个差异。

列表 10.9 分页结果

# file: web/queries.py

From itertools import islice                                           ①

from ariadne import QueryType

from web.data import ingredients, products

...

def get_page(items, items_per_page, page):
    page = page - 1
    start = items_per_page * page if page > 0 else page                ②
    stop = start + items_per_page                                      ③
    return list(islice(items, start, stop))                            ④

@query.field('products')
def resolve_products(*_, input=None):
    ...
    return get_page(filtered, input['resultsPerPage'], input['page']) ⑤

① 我们导入 islice()。

② 我们解析起始索引。

③ 我们计算停止索引。

④ 我们返回列表的一个切片。

⑤ 我们分页结果。

我们硬编码的数据集只包含两个产品,所以让我们将 resutlsPerPage 设置为 1 来测试分页,这将把列表分成两页:

{
  products(input: {resultsPerPage: 1, page: 1}) {
    ...on ProductInterface {
      name
    }
  }
}

你应该得到一个确切的结果。一旦我们在下一节实现 addProduct() 突变,我们就能通过 API 添加更多产品并充分利用分页参数。

你刚刚学会了如何处理查询参数!我们现在处于一个很好的位置来学习如何实现突变。突变解析器与查询解析器类似,但它们总是有参数。但这就足够剧透了;继续到下一节,了解更多关于突变的信息。

10.4.6 实现突变解析器

在本节中,我们学习如何实现突变解析器。实现突变解析器遵循我们看到的查询的相同指南。唯一的区别是我们用来绑定突变解析器的类。虽然查询绑定到 QueryType 类的实例,但突变绑定到 MutationType 类的实例。

让我们看看如何实现 addProduct() 突变的解析器。从规范中,我们知道 addProduct() 突变有三个必需参数:nametypeinputinput 参数的形状由 AddProductInput 对象类型给出。AddProductInput 定义了在创建新产品时可以设置的附加属性,所有这些属性都是可选的,因此是可空的。最后,addProduct() 突变必须返回一个产品类型。

图 10.11

图 10.11 突变参数作为关键字参数传递给我们的解析器。此示例说明了如何调用 resolve_add_product() 解析器,其中 nametypeinput 参数作为关键字参数传递。

列表 10.10 展示了我们如何实现addProduct()变异的解析器(参见图 10.11 以获取说明)。我们首先导入MutationType可绑定类并实例化它。然后我们声明我们的解析器,并使用其field()装饰器将其绑定到MutationType。我们不需要使用 Ariadne 的默认位置参数objinfo,所以我们使用一个通配符后跟一个下划线(*_)来跳过它们。由于规范指出它们都是必需的,我们没有为addProduct()的参数设置默认值。addProduct()必须返回一个有效的Product对象,因此我们在解析器的主体中构建具有预期属性的该对象。由于ProductCakeBeverage类型的联合,并且每种类型都需要不同的属性集,我们检查type参数以确定我们应该向我们的对象添加哪些字段。以下代码将放入web/mutations.py文件中。

列表 10.10 addProduct()变异的解析器

# file: web/mutations.py

import uuid
from datetime import datetime

from ariadne import MutationType

from web.data import products

mutation = MutationType()                              ①

@mutation.field('addProduct')                          ②
def resolve_add_product(*_, name, type, input):        ③
    product = {                                        ④
        'id': uuid.uuid4(),                            ⑤
        'name': name,
        'available': input.get('available', False),    ⑥
        'ingredients': input.get('ingredients', []),
        'lastUpdated': datetime.utcnow(),
    }
    if type == 'cake':                                 ⑦
        product.update({
            'hasFilling': input['hasFilling'],
            'hasNutsToppingOption': input['hasNutsToppingOption'],
        })
    else:
        product.update({
            'hasCreamOnTopOption': input['hasCreamOnTopOption'],
            'hasServeOnIceOption': input['hasServeOnIceOption'],
        })
    products.append(product)                           ⑧
    return product

① 用于变异的可绑定对象

② 我们使用 field()装饰器绑定addProduct()的解析器。

③ 我们捕获addProduct()的参数。

④ 我们将新产品声明为一个字典。

⑤ 我们设置服务器端属性,如 ID。

⑥ 我们解析可选参数并设置它们的默认值。

⑦ 我们检查产品是饮料还是蛋糕。

⑧ 我们返回新创建的产品。

为了启用列表 10.10 中实现的解析器,我们需要将mutation对象添加到web/schema.py中的make_executable_schema()函数中:

# file: web/schema.py

from pathlib import Path

from ariadne import make_executable_schema

from web.mutations import mutation
from web.queries import query
from web.types import product_type
schema = make_executable_schema(
    (Path(__file__).parent / 'products.graphql').read_text(), 
    [query, mutation, product_type]
)

让我们通过运行一个简单的测试来使用这个新的变异。转到运行在 http://127.0.0.1:8000 上的 Apollo Playground,并运行以下变异:

mutation {
  addProduct(name: "Mocha", type: beverage, input:{ingredients: []}) {
    ...on ProductInterface {
      name,
      id
    }
  }
}

你将得到一个有效的响应,并且一个新的产品将被添加到我们的列表中。为了验证一切是否正常工作,运行以下查询并检查响应是否包含刚刚创建的新条目:

{
  allProducts {
    ...on ProductInterface {
      name
    }
  }
}

记住,我们正在使用我们数据的内存列表表示运行服务,所以如果你停止或重新加载服务器,列表将被重置,你将丢失任何新创建的数据。

你刚刚学习了如何构建变异!这是一个强大的功能:使用变异,你可以在 GraphQL 服务器中创建和更新数据。我们现在已经涵盖了 GraphQL 服务器实现的大部分主要方面。在下一节中,我们将进一步学习如何实现自定义标量类型的解析器。

10.4.7 为自定义标量类型构建解析器

在本节中,我们学习如何实现自定义标量类型的解析器。正如我们在第八章中看到的,GraphQL 提供了一定数量的标量类型,例如布尔值、整数和字符串。在许多情况下,GraphQL 的默认标量类型足以开发 API。有时,然而,我们需要定义我们自己的自定义标量。产品 API 包含一个名为Datetime的自定义标量。IngredientProduct类型中的lastUpdated字段都有一个Datetime标量类型。由于Datetime是一个自定义标量,Ariadne 不知道如何处理它,因此我们需要为它实现一个解析器。我们如何做到这一点?

图片

图 10.12 当 GraphQL 服务器从用户接收数据时,它验证并反序列化数据为原生 Python 对象。在这个例子中,服务器将名称"Mocha"反序列化为 Python 字符串,将日期"2021-01-01"反序列化为 Python datetime 对象。

图片

图 10.13 当 GraphQL 服务器向用户发送数据时,它将原生 Python 对象转换为可序列化的数据。在这个例子中,服务器将名称和日期都序列化为字符串。

正如你在图 10.12 和 10.13 中可以看到的,当我们遇到 GraphQL API 中的自定义标量类型时,我们需要确保我们能够对自定义标量执行以下三个操作:

  • 序列化—当用户从服务器请求数据时,Ariadne 必须能够序列化数据。Ariadne 知道如何序列化 GraphQL 的内置标量,但对于自定义标量,我们需要实现一个自定义序列化器。在产品 API 中Datetime标量的情况下,我们必须实现一个序列化datetime对象的方法。

  • 反序列化—当用户向我们的服务器发送数据时,Ariadne 将数据反序列化并使其以 Python 原生数据结构的形式(如字典)可用。如果数据中包含自定义标量,我们需要实现一个方法,让 Ariadne 知道如何解析和将标量加载到原生 Python 数据结构中。对于Datetime标量,我们希望能够将其加载为datetime对象。

  • 验证—GraphQL 强制执行每个标量和类型的验证,Ariadne 知道如何验证 GraphQL 的内置标量。对于自定义标量,我们必须实现我们自己的验证方法。在Datetime标量的情况下,我们想要确保它具有有效的 ISO 格式。

Ariadne 通过其ScalarType类提供了一个简单的 API 来处理这些操作。我们需要做的第一件事是创建这个类的实例:

from ariadne import ScalarType

datetime_scalar = ScalarType('Datetime') 

ScalarType公开了装饰器方法,允许我们实现序列化、反序列化和验证。对于序列化,我们使用ScalarTypeserializer()装饰器。我们希望将datetime对象序列化为 ISO 标准日期格式,Python 的datetime库提供了一个方便的 ISO 格式化方法,即isoformat()方法:

@datetime_scalar.serializer
def serialize_datetime(value):
  return value.isoformat()

对于验证和反序列化,ScalarType 提供了 value_parser() 装饰器。当用户向服务器发送包含 Datetime 标量数据时,我们期望日期以 ISO 格式,因此可以被 Python 的 datetime.fromisoformat() 方法解析:

from datetime import datetime

@datetime_scalar.value_parser
def parse_datetime_value(value):
  return datetime.fromisoformat(value)

如果日期以错误的格式传入,fromisoformat() 将引发一个 ValueError,这将由 Ariadne 捕获并向用户显示以下消息:“Invalid isoformat string.” 以下代码位于 web/types.py 中,因为它实现了类型解析器。

列表 10.11 序列化和解析自定义标量

# file: web/types.py

import uuid
from datetime import datetime

from ariadne import UnionType, ScalarType

...
datetime_scalar = ScalarType('Datetime') ①

@datetime_scalar.serializer                ②
def serialize_datetime_scalar(date):       ③
    return date.isoformat()                ④

@datetime_scalar.value_parser              ⑤
def parse_datetime_scalar(date):           ⑥
    return datetime.fromisoformat(date) ⑦

① 我们使用 ScalarType 类创建一个可绑定的 Datetime 标量对象。

② 我们使用 serializer() 装饰器绑定 Datetime 的序列化器。

③ 我们将序列化器的参数捕获为日期。

④ 我们序列化日期对象。

⑤ 我们使用 value_parser() 装饰器绑定 Datetime 的解析器。

⑥ 我们捕获解析器的参数。

⑦ 我们解析一个日期。

要启用 Datetime 解析器,我们将 datetime_scalar 添加到 web/schema.py 中 make_executable_schema() 函数的可绑定对象数组中:

from pathlib import Path

from ariadne import make_executable_schema

from web.mutations import mutation
from web.queries import query
from web.types import product_type, datetime_scalar

schema = make_executable_schema(
    (Path(__file__).parent / 'products.graphql').read_text(),
    [query, mutation, product_type, datetime_scalar]
)

让我们将新的解析器付诸实践!回到运行在 http://127.0.0.1:8000 的 Apollo Playground,并执行以下查询:

# Query document
{
  allProducts {
    ...on ProductInterface {
      name,
      lastUpdated
    }
  }
}

# result:
{
  "data": {
    "allProducts": [
      {
        "name": "Walnut Bomb",
        "lastUpdated": "2022-06-19T18:27:53.171870"
      },
      {
        "name": "Cappuccino Star",
        "lastUpdated": "2022-06-19T18:27:53.171871"
      }
    ]
  }
}

你应该得到一个包含所有产品名称以及 lastUpdated 字段中 ISO 格式日期的产品列表。现在你有了在 GraphQL 中实现自定义标量类型的能力。明智地使用它!在我们结束这一章之前,还有一个话题我们需要探讨:实现对象类型字段的解析器。

10.4.8 实现字段解析器

在本节中,我们学习如何实现对象类型字段的解析器。我们已经实现了几乎所有在产品 API 上提供各种查询所需的解析器,但我们的服务器仍然无法解析一种类型的查询:涉及映射到其他 GraphQL 类型的字段。例如,Products 类型有一个名为 ingredients 的字段,它映射到一个 IngredientRecipe 对象数组。根据规范,IngredientRecipe 类型的形状如下:

# file: web/products.graphql

type IngredientRecipe {
    ingredient: Ingredient!
    quantity: Float!
    unit: String!
}

每个 IngredientRecipe 对象都有一个 ingredient 字段,它映射到 Ingredient 对象类型。这意味着,当我们查询产品的 ingredients 字段时,我们应该能够获取每个成分的信息,例如其名称、描述或供应商信息。换句话说,我们应该能够在服务器上运行以下查询:

{
  allProducts {
    ...on ProductInterface {
      name,
      ingredients {
        quantity,
        unit,
        ingredient{
          name
        }
      }
    }
  }
}

如果你在这个时候在 Apollo Playground 中运行这个查询,你会得到一个错误,错误信息如下:“Cannot return null for non-nullable field Ingredient.name。”

为什么会发生这种情况?如果你查看 10.5 列表中的产品列表,你会注意到 ingredients 字段映射到一个包含三个字段的对象数组:ingredientquantityunit。例如, Walnut Bomb 有以下成分:

# file: web/data.py

ingredients = [
  {
    'ingredient': '602f2ab3-97bd-468e-a88b-bb9e00531fd0',
    'quantity': 100.00,
    'unit': 'LITRES',
  }
]

ingredient字段映射到成分 ID,而不是完整的成分对象。这是我们产品成分的内部表示。这是我们如何在数据库中存储产品数据(在本实现中为内存列表)的方式。并且这是一个有用的表示,因为它允许我们通过 ID 识别每个成分。然而,API 规范告诉我们,ingredients字段应该映射到IngredientRecipe对象的数组,并且每个ingredient应该代表一个Ingredient对象,而不仅仅是 ID。

我们如何解决这个问题?我们可以使用不同的方法。例如,我们可以确保每个成分负载在返回Product类型的每个查询的解析器中都被正确构建。例如,列表 10.12 展示了我们如何修改allProducts()解析器来完成这个任务。该片段修改了每个产品的ingredients属性,以确保它包含一个完整的成分负载。由于每个产品都由一个字典表示,我们为每个产品进行深度复制,以确保在这个函数中应用的变化不会影响我们的内存产品列表。

列表 10.12 更新产品以包含完整的成分负载,而不仅仅是 ID

# file: web/queries.py

...

@query.field('allProducts')
def resolve_all_products(*_):
    products_with_ingredients = [deepcopy(product) for product in products]①
    for product in products_with_ingredients:
        for ingredient_recipe in product['ingredients']:
            for ingredient in ingredients: 
                if ingredient['id'] == ingredient_recipe['ingredient']:
                    ingredient_recipe['ingredient'] = ingredient           ②
    return products_with_ingredients                                       ③

① 我们对产品列表中的每个对象进行深度复制。

② 我们使用成分的完整表示更新成分属性。

③ 我们返回包含成分的产品列表。

列表 10.12 中的方法完全可行,但正如你所看到的,它使得代码的复杂性增加。如果我们需要为更多的属性做同样的事情,函数很快就会变得难以理解和维护。

图 10.14 GraphQL 允许我们为对象的特定字段创建解析器。在这个例子中,resolve_product_ingredients()解析器负责为产品的ingredients属性返回一个有效的负载。

正如你在图 10.14 中可以看到的,GraphQL 提供了一种解决对象属性的方法的替代方式。我们不是在allProducts()解析器中修改产品负载,而是可以创建一个针对产品ingredients属性的特定解析器,并在该解析器内进行任何必要的修改。列表 10.13 展示了产品ingredients属性的解析器是什么样的,并且位于web/types.py下,因为它实现了对象属性的解析器。

列表 10.13 实现字段解析器

# file: web/types.py

...

@product_interface.field('ingredients')
def resolve_product_ingredients(product, _):
    recipe = [                                ①
        copy.copy(ingredient)
        for ingredient in product.get("ingredients", [])
    ]

    for ingredient_recipe in recipe:
        for ingredient in ingredients:
            if ingredient['id'] == ingredient_recipe['ingredient']:
                ingredient_recipe['ingredient'] = ingredient
    return recipe

① 我们为每个成分创建深度复制。

对象属性解析器帮助我们使代码更加模块化,因为每个解析器只做一件事。它们还帮助我们避免重复。通过拥有一个单独的解析器来处理更新产品负载中的ingredients属性,我们避免了在每个返回产品类型的解析器中执行此操作。缺点是属性解析器可能更难追踪和调试。如果ingredients负载有问题,你不会在allProducts()解析器中找到错误。你必须知道有一个解析器负责产品的成分,并查看该解析器。应用日志将帮助你在调试此类问题时指明方向,但请记住,这种设计对于不熟悉 GraphQL 的其他开发者来说可能并不完全明显。就像软件设计中的其他一切一样,确保代码的可重用性不会损害代码的可读性和易于维护性。

摘要

  • Python 生态系统提供了各种框架来实现 GraphQL API。有关可用框架的最新消息,请参阅 GraphQL 的官方网站:graphql.org/code/

  • 您可以使用 Ariadne 框架按照 schema-first 方法实现 GraphQL API,这意味着我们首先设计 API,然后根据规范实现服务器。这种方法的好处在于它允许服务器和客户端开发团队并行工作。

  • Ariadne 可以使用规范自动验证请求和响应负载,这意味着我们不必花费时间实现自定义验证模型。

  • 对于 API 规范中的每个查询和突变,我们需要实现一个解析器。解析器是一个知道如何处理给定查询或突变请求的函数。解析器是允许我们公开 GraphQL API 功能的代码,因此代表了实现的骨干。

  • 要注册一个解析器,我们使用 Ariadne 的可绑定类之一,例如QueryTypeMutationType。这些类公开了装饰器,允许我们绑定解析器函数。

  • GraphQL 规范可以包含复杂类型,例如联合类型,它们结合了两个或多个对象类型。如果我们的 API 规范包含联合类型,我们必须实现一个解析器,该解析器知道如何确定对象的类型;否则,GraphQL 服务器不知道如何解析它。

  • 使用 GraphQL,我们可以定义自定义标量。如果规范包含自定义标量,我们必须实现知道如何序列化、解析和验证自定义标量类型的解析器;否则,GraphQL 服务器不知道如何处理它们。

第四部分:确保、测试和部署微服务 API

正如我们在第一章中学到的,API 是我们应用程序的程序化接口,使我们的 API 公开化允许其他组织构建与我们自己的 API 的集成。作为交付软件产品的手段,API 的日益增多催生了 API 经济。API 为业务增长开辟了新的机遇,但同时也代表了安全风险。缺乏适当的测试或错误实施的安全协议会使我们的 API 变得脆弱。本书的第四部分将帮助你掌握 API 测试、安全和运维的主要主题。

API 认证的现代标准是 OpenID Connect,而 API 授权则是 Open Authorization (OAuth) 2.1。第十一章以介绍这些标准作为第四部分的开始。根据我的经验,这是 API 开发中最容易被误解的领域之一,这会导致安全漏洞和违规。第十一章将教你如何为你的 API 实施一个健壮的 API 认证和授权策略。

当你使用 API 进行集成时,你需要一个可靠的 API 测试和验证方法。你必须确保你的 API 后端提供 API 规范中定义的接口。我们如何做到这一点?正如你将在第十二章中学到的,一种强大的 API 测试方法是使用合同测试工具,如 Dredd 和 Schemathesis,并应用基于属性的测试。使用这些策略,你可以在将其发布到生产之前,有信心地测试和验证你的代码。

最后,关于部署和运维方面呢?本书的最后一章将教你如何使用 Kubernetes 将你的微服务 API 容器化并部署。你将学习如何使用 AWS EKS(在云中运行 Kubernetes 最流行的解决方案之一)来部署和运维 Kubernetes 集群。阅读完第四部分后,你将准备好在规模上测试、保护和运维你的微服务 API。

11 API 授权和身份验证

本章节涵盖了

  • 使用开放授权允许访问我们的 API

  • 使用 OpenID Connect 验证我们的 API 用户的身份

  • 存在哪些类型的授权流程,以及每种授权场景更适合哪种流程

  • 理解 JSON Web Tokens (JWT) 并使用 Python 的 PyJWT 库生成和验证它们

  • 将身份验证和授权中间件添加到我们的 API 中

2018 年,美国邮政系统 API 身份验证系统的一个弱点(usps.com)允许黑客从 6000 万用户那里获取数据,包括他们的电子邮件地址、电话号码和其他个人信息。¹ 类似这样的 API 安全攻击变得越来越普遍,预计 2021 年攻击数量增长了 300%以上。² API 漏洞不仅可能暴露用户的敏感数据;它们也可能使你的业务陷入困境³ 好消息是,你可以采取一些措施来降低 API 泄露的风险。第一道防线是一个健壮的身份验证和授权系统。在本章中,你将学习如何通过使用标准的身份验证和授权协议来防止未授权访问你的 API。

根据我的经验,API 身份验证和授权是开发者最困惑的两个主题,也是实施错误经常发生的领域。在你实现 API 的安全层之前,我强烈建议你阅读本章,以确保你知道自己在做什么,并且知道如何正确地做。我已经尽力提供了一个关于 API 身份验证和授权如何工作的全面总结,到本章结束时,你应该能够为自己的 API 添加一个健壮的授权流程。

身份验证是验证用户身份的过程,而授权是确定用户是否有权访问某些资源或操作的过程。在本章中你将学习的关于身份验证和授权的概念和标准适用于所有类型的 Web API。

你将学习不同的身份验证和授权协议及流程,以及如何验证授权令牌。你还将学习如何使用 Python 的 PyJWT 库来生成签名令牌并验证它们。我们将通过一个实际示例来讲解如何向订单 API 添加身份验证和授权。我们有很多内容要介绍,所以让我们开始吧!

11.1 设置本章的环境

让我们为本章设置环境。本章的代码可在本书 GitHub 仓库中名为 ch11 的目录下找到。在第七章中,我们实现了一个功能齐全的订单服务,包括业务层、数据库和 API。本章从第七章中我们停止的地方继续订单服务。如果您想跟随本章的变化,请将第七章的代码复制到一个名为 ch11 的新文件夹中:

$ cp -r ch07 ch11

在 ch11 目录下运行 pipenv install 来安装依赖项。对于本章,我们需要一些额外的依赖项,因此请运行以下命令来安装它们:

$ pipenv install cryptography pyjwt 

PyJWT 是一个 Python 库,它允许我们处理 JSON Web Tokens,而 cryptography 将允许我们验证令牌的签名。(有关 Python 生态系统中的替代 JWT 库列表,请参阅 jwt.io/libraries?language=Python。)

我们的环境现在已经准备好了,让我们开始我们的旅程,探索用户身份验证和授权的奇妙世界。这是一段充满陷阱的旅程,但却是必要的。请系好安全带,并仔细观察我们一路上的进展!

11.2 理解身份验证和授权协议

当涉及到 API 身份验证时,您需要了解的两个最重要的协议是 OAuth(开放授权)和 OpenID Connect(OIDC)。本节解释了每个协议的工作原理以及它们如何适合我们 API 的身份验证和授权流程。

11.2.1 理解开放授权

OAuth 是一种用于访问委派的标准化协议。⁴ 如图 11.1 所示,OAuth 允许用户在不共享其凭证的情况下,授权第三方应用程序访问他们拥有的另一个网站上的受保护资源。

图 11.1 使用 OAuth,用户可以授予第三方应用程序访问另一个网站上的其信息的权限。

定义 OAuth 是一种开放标准,允许用户授予第三方应用程序访问他们在其他网站上的信息。通常,通过发放令牌来授予访问权限,第三方应用程序使用该令牌来访问用户的信息。

例如,假设苏珊在她的 Facebook 账户中有一个联系人列表。有一天,苏珊登录到 LinkedIn,并希望从 Facebook 导入她的联系人列表。为了允许 LinkedIn 导入她的 Facebook 联系人,苏珊必须授予 LinkedIn 访问该资源的权限。她如何授予 LinkedIn 访问她联系人列表的权限?她可以给 LinkedIn 她的 Facebook 凭证以访问她的账户。但那将是一个重大的安全风险。相反,OAuth 定义了一种协议,允许苏珊告诉 Facebook,LinkedIn 可以访问她的联系人列表。通过 OAuth,Facebook 会发放一个 LinkedIn 可以用来导入苏珊联系人的临时令牌。

OAuth 在授予资源访问权限的过程中区分了不同的角色:

  • 资源所有者——授予资源访问权限的用户。在先前的例子中,Susan 是资源所有者。

  • 资源服务器——托管用户受保护资源的服务器。在先前的例子中,Facebook 是资源服务器。

  • 客户端——请求访问用户资源的应用程序或服务器。在先前的例子中,LinkedIn 是客户端。

  • 授权服务器——授予客户端访问资源的服务器。在先前的例子中,Facebook 是授权服务器。

OAuth 提供了四种不同的流程,根据访问条件授予用户授权。了解每个流程的工作原理以及在哪些场景下可以使用它们非常重要。根据我的经验,OAuth 流程是围绕授权的最大困惑之一,也是现代网站中最大的安全问题的来源之一。这些是 OAuth 流程:

  • 授权码流

  • PKCE 流程

  • 客户端凭证流

  • 刷新令牌流

OAuth

OAuth 流程是客户端应用程序用来授权其访问 API 的策略。OAuth 的最佳实践随着时间的推移而变化,因为我们更多地了解应用程序漏洞并改进了协议。当前的最佳实践在 IETF 的“OAuth 2.0 安全最佳当前实践”中有描述(mng.bz/o58v),由 T. Lodderstedt,J. Bradley,A. Labunets 和 D. Fett 撰写。如果你阅读关于 OAuth 2.0 的内容,可能会遇到我们本章未描述的两个流程的引用:资源所有者密码流和隐式流。这两个流程现在都已弃用,因为它们暴露了严重的漏洞,因此你不应该使用它们。

本章未讨论的另一个流行扩展是设备授权授予(mng.bz/5mZD),它允许输入受限的设备,如智能电视,获取访问令牌。OAuth 的最新版本是 2.1,它在 IETF 的“OAuth 2.1 授权框架”中有描述(mng.bz/69m6)。

让我们深入了解每个流程,了解它们是如何工作的以及何时使用它们!

授权码流

在授权码流中,客户端服务器与授权服务器交换一个秘密,以生成一个签名 URL。如图 11.2 所示,用户使用此 URL 登录后,客户端服务器获得一个一次性代码,它可以用来交换访问令牌。此流程使用客户端秘密,因此仅适用于代码未公开暴露的应用程序,例如用户界面在后台渲染的传统 Web 应用程序。OAuth 2.1 建议结合使用 PKCE(证明密钥编码挑战)的授权码流,这在下一节中将有描述。

图 11.2 在授权码流中,授权服务器生成一个签名 URL,用户可以使用它来证明自己的身份并授予第三方应用程序访问权限。

代码交换密钥证明流程

代码交换密钥证明(PKCE,发音为“pixie”)是授权代码流程的扩展,旨在保护源代码公开的应用程序,如移动应用程序和单页应用程序(SPAs)。⁵ 由于源代码是公开的,客户端不能使用秘密,因为这也会被公开。

如您在图 11.3 中看到的,在 PKCE 流程中,客户端生成一个称为代码验证器的秘密,并将其编码。编码后的代码称为代码挑战。当向服务器发送授权请求时,客户端在请求中包含代码验证器和代码挑战。作为回报,服务器生成一个授权代码,客户端可以用它来交换访问令牌。为了获取访问令牌,客户端必须发送授权代码和代码挑战。

多亏了代码挑战,PKCE 流程还防止了授权代码注入攻击,在这种攻击中,恶意用户拦截授权代码并使用它来获取访问令牌。由于这种流程的安全优势,PKCE 也推荐用于服务器端应用程序。我们将在附录 C 中看到一个使用 SPA 的此流程的示例。

图片

图 11.3 在 PKCE 流程中,由客户端提供的单页应用(SPA)通过交换代码验证器和代码挑战,直接从授权服务器请求用户的访问权限。

客户端凭据流程

客户端凭据流程旨在用于服务器到服务器的通信,如您在图 11.4 中看到的,它涉及交换秘密以获取访问令牌。此流程适用于在安全网络上启用微服务之间的通信。我们将在附录 C 中看到一个此流程的示例。

图片

图 11.4 在客户端凭据流程中,服务器应用程序与授权服务器交换秘密以获取访问令牌。

刷新令牌流程

刷新令牌流程允许客户端用刷新令牌交换新的访问令牌。出于安全原因,访问令牌的有效期是有限的。然而,API 客户端通常需要在访问令牌过期后能够与 API 服务器通信,并且为了获取新令牌,它们使用刷新令牌流程。

如您在图 11.5 中看到的,API 客户端在成功访问 API 时通常会收到一个访问令牌和一个刷新令牌。刷新令牌通常只在有限的时间内有效,并且只能一次性使用。每次您刷新访问令牌时,您都会获得一个新的刷新令牌。

图片

图 11.5 为了允许 API 客户端在访问令牌过期后继续与 API 服务器通信,授权服务器每次客户端请求新的访问令牌时都会颁发一个新的刷新令牌。

现在我们已经了解了 OAuth 的工作原理,让我们将注意力转向 OpenID Connect!

11.2.2 理解 OpenID Connect

OpenID Connect (OIDC) 是一个建立在 OAuth 之上的开放标准,用于身份验证。如图 11.6 所示,OIDC 允许用户通过使用第三方身份提供者来验证网站。如果您已经使用 Facebook、Twitter 或 Google 账户在其他网站上登录,您已经熟悉 OIDC 了。在这种情况下,Facebook、Twitter 和 Google 是身份提供者。您使用它们将您的身份带到新的网站。OIDC 是一个方便的认证系统,因为它允许用户在不同的网站上使用相同的身份,而无需创建和管理新的用户名和密码。

图片

图 11.6 在 OIDC 中,用户使用 OIDC 服务器登录。OIDC 服务器颁发一个 ID 令牌和一个访问令牌,用户可以使用这些令牌来访问应用程序。

定义 OpenID Connect (OIDC) 是一个允许用户将他们的身份从网站(身份提供者)带到另一个网站的身份验证协议。OIDC 建立在 OAuth 之上,我们可以使用 OAuth 定义的相同流程来验证用户。

由于 OIDC 建立在 OAuth 之上,我们可以使用上一节中描述的任何授权流程来验证和授权用户。如图 11.6 所示,当我们使用 OIDC 协议进行认证时,我们区分两种类型的令牌:ID 令牌和访问令牌。这两种令牌都采用 JSON Web Tokens 的形式,但它们有不同的用途:ID 令牌用于识别用户,并包含诸如用户姓名、电子邮件和其他个人详细信息等信息。您仅使用 ID 令牌来验证用户身份,而永远不用于确定用户是否有权访问 API。API 访问通过访问令牌进行验证。访问令牌通常不包含用户信息,而是一组关于用户访问权利的声明。

ID 令牌与访问令牌 比较常见的安全问题是对 ID 令牌和访问令牌的误用。ID 令牌是携带用户身份信息的令牌。它们必须仅用于验证用户的身份,而不能用于验证对 API 的访问。API 访问通过访问令牌进行验证。访问令牌很少包含用户的身份信息,而是包含关于用户访问 API 权利的声明。ID 令牌和访问令牌之间的一个基本区别是受众:ID 令牌的受众是授权服务器,而访问令牌的受众是我们的 API 服务器。

提供 OIDC 集成的身份提供者公开了一个/.well-known/openid-configuration端点(带有一个前置句点!),也称为发现端点,它告诉 API 消费者如何进行认证以及如何获取他们的访问令牌。例如,OIDC 为 Google 账户的知名端点是accounts.google.com/.well-known/openid-configuration。如果你调用此端点,你会获得以下载荷(示例被省略号截断):

{
  "issuer": "https://accounts.google.com",
  "authorization_endpoint": "https://accounts.google.com/o/oauth2/v2/auth",
  "device_authorization_endpoint": 
➥ "https://oauth2.googleapis.com/device/code",
  "token_endpoint": "https://oauth2.googleapis.com/token",
  "userinfo_endpoint": "https://openidconnect.googleapis.com/v1/userinfo",
  "revocation_endpoint": "https://oauth2.googleapis.com/revoke",
  "jwks_uri": "https://www.googleapis.com/oauth2/v3/certs",
  "response_types_supported": [
    "code",
    "token",
    "id_token",
    "code token",
    "code id_token",
    "token id_token",
    "code token id_token",
    "none"
  ],
  ...
}

如您所见,知名端点告诉我们必须使用哪个 URL 来获取授权访问令牌,哪个 URL 返回用户信息,或者我们使用哪个 URL 来撤销访问令牌。在此载荷中还有其他一些信息,例如可用的声明或 JSON Web Keys URI(JWKS)。通常,您会使用库来代表您处理这些端点,或者您会使用身份即服务提供商来处理这些集成。如果您想了解更多关于 OpenID Connect 的信息,我推荐 Prabath Siriwardena 的OpenID Connect in Action(Manning,2022)。

现在我们已经了解了 OAuth 和 OpenID Connect 的工作原理,是时候深入了解认证和授权的具体工作了。我们将在下一节研究 JSON Web Tokens(JWT)是什么。

11.3 与 JSON Web Tokens 一起工作

在 OAuth 和 OpenID Connect 中,用户访问是通过一种称为JSON Web Token(JWT)的令牌来验证的。本节将解释 JWT 是什么,它们的结构是怎样的,它们包含哪些声明,以及如何生成和验证它们。

JWT 是一个代表 JSON 文档的令牌。该 JSON 文档包含声明,例如谁发行了令牌、令牌的受众或令牌何时过期。JSON 文档通常编码为 Base64 字符串。JWT 通常使用私有密钥或加密密钥进行签名。⁶一个典型的 JWT 看起来像这样:

eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL2F1dGguY29mZmVlbW
➥ VzaC5pby8iLCJzdWIiOiJlYzdiYmNjZi1jYTg5LTRhZjMtODJhYy1iNDFlNDgzMWE5NjIiL
➥ CJhdWQiOiJodHRwOi8vMTI3LjAuMC4xOjgwMDAvb3JkZXJzIiwiaWF0IjoxNjM4MjI4NDg2
➥ LjE1Otg4MSwiZXhwIjoxNjM4MzE0Odg2LjE1Otg4Mswic2NvcGUiOiJvcGVuaWQifQ.oblJ
➥ 5wV9GqrhIDzNSzcClrpEQTMK8hZGzn1S707tDtQE__OCDsP9J2Wa70aBua6X81-
➥ zrvWBfzrcX--nSyT-
➥ A9uQxL5j3RHHycToqSVi87I9H6jgP4FEKH6ClwZfabVwzNIy52Zs7zRdcSI4WRz1OpHoCM-
➥ 2hNtZ67dMJQgBVIlrXcwKAeKQWP8SxSDgFbwnyRTZJt6zijRnCJQqV4KrK_M4pv2UQYqf9t
➥ Qpj2uflTsVcZq6XsrFLAgqvAg-YsIarYw9d63rs4H_I2aB3_T_1dGPY6ic2R8WDT1_Axzi-
➥ crjoWq9A51SN-kMaTLhE_v2MSBB3A0zrjbdC4ZvuszAqQ

如果你仔细观察示例,你会看到字符串中包含两个句点。句点作为分隔符,将 JWT 的每个部分分开。如图 11.7 所示,一个 JWT 文档有三个部分:

  • 头部——标识令牌的类型以及用于签名令牌的算法和密钥。我们使用这些信息来应用正确的算法以验证令牌的签名。

  • 载荷——包含文档的声明集。JWT 规范包括一系列保留声明,用于标识令牌的发行者(授权服务器)、令牌的受众或预期接收者(我们的 API 服务器)以及其过期日期等详细信息。除了 JWT 的标准声明外,载荷还可以包含自定义声明。我们使用这些信息来确定用户是否有权访问 API。

  • 签名——表示令牌签名的字符串。

图 11.7 JWT 由三部分组成:包含令牌本身信息的头部,包含关于用户对网站访问声明的负载,以及证明令牌真实性的签名。

现在我们已经了解了 JWT 是什么以及它的结构看起来像什么,让我们更深入地探讨它的属性。接下来的几节将解释 JWT 负载和头部中我们可以找到的主要声明和属性类型,以及我们如何使用它们。

11.3.1 理解 JWT 头部

JWT 包含一个描述令牌类型、签名算法以及用于签名字令牌的密钥的头部。JWT 通常使用 HS256 和 RS256 算法进行签名。HS256 使用密钥加密令牌,而 RS256 使用私钥/公钥对来签名字令牌。我们使用这些信息来应用正确的算法以验证令牌签名。

JWT 的签名算法

用于签名字令牌的最常见的两种算法是 HS256 和 RS256。HS256 代表 HMAC-SHA256,这是一种使用密钥生成哈希的加密形式。

RS256 代表 RSA-SHA256。RSA(Rivest-Shamir-Adleman)是一种使用私钥加密负载的加密形式。在这种情况下,我们可以通过使用公钥来验证令牌签名是否正确。

你可以在 David Wong 的《现实世界密码学》(Manning, 2021)中了解更多关于 HMAC 和 RSA 的信息。

一个典型的 JWT 头部如下所示:

{
  "alg": "RS256",
  "typ": "JWT",
  "kid": "ZweIFRR4l1dJlVPHOoZqf"
}

让我们分析这个头部:

  • alg—告诉我们令牌是使用 RS256 算法签名的

  • typ—告诉我们这是一个 JWT 令牌

  • kid—告诉我们用于签名字令牌的密钥具有 ID ZweIFRR4l1dJlVPHOoZqf

令牌的签名只能使用用于签名的相同密钥或密钥进行验证。出于安全考虑,我们通常使用一组密钥或密钥来签名字令牌。kid字段告诉我们使用哪个密钥或密钥来签名字令牌,以便在验证令牌签名时使用正确的值。

一些令牌还在头部包含一个nonce字段。如果你看到这样的令牌,那么这个令牌可能不是为你自己的 API 服务器准备的,除非你是令牌的创建者并且知道nonce的值。nonce字段通常包含一个加密的密钥,为 JWT 添加额外的安全层。例如,Azure Active Directory 发行的用于访问其 Graph API 的令牌包含一个nonce令牌,这意味着你不应该使用这些令牌来授权访问你的自定义 API。现在我们已经了解了令牌头部的属性,下一节将解释如何读取令牌的声明。

11.3.2 理解 JWT 声明

JWT 的负载包含一组声明。由于 JWT 负载是一个 JSON 文档,声明以键值对的形式出现。

声明有两种类型:保留声明,它是 JWT 规范的一部分,以及自定义声明,我们可以添加这些声明以丰富令牌并添加更多信息。⁷ JWT 规范定义了七个保留声明:

  • iss (发行者)—标识 JWT 的发行者。如果您使用身份即服务提供商,发行者标识该服务。它通常以 ID 或 URL 的形式出现。

  • sub (主题)—标识 JWT 的主题(即向服务器发送请求的用户)。它通常以不透明的 ID(即不披露用户个人详情的 ID)的形式出现。

  • aud (观众)—表示 JWT 的目标接收者。这是我们 API 服务器。它通常以 ID 或 URL 的形式出现。检查此字段以验证令牌是否针对我们的 API 至关重要。如果我们不认识此字段中的值,则意味着令牌不是为我们准备的,我们必须忽略该请求。

  • exp (过期时间)—一个 UTC 时间戳,表示 JWT 何时过期。带有过期令牌的请求必须被拒绝。

  • nbf (不早于时间)—一个 UTC 时间戳,表示 JWT 必须在此时间之前不被接受。

  • iat (签发时间)—一个 UTC 时间戳,表示 JWT 签发的时间。它可以用来确定 JWT 的年龄。

  • jti (JWT ID)—JWT 的唯一标识符。

保留声明在 JWT 有效载荷中不是必需的,但建议包括它们以确保与第三方集成的互操作性。

列表 11.1 JWT 有效载荷声明示例

{
  "iss": "https://auth.coffeemesh.io/",
  "sub": "ec7bbccf-ca89-4af3-82ac-b41e4831a962",
  "aud": "http://127.0.0.1:8000/orders",
  "iat": 1667155816,
  "exp": 1667238616,
  "azp": "7c2773a4-3943-4711-8997-70570d9b099c",
  "scope": "openid"
}

让我们分析列表 11.1 中的声明:

  • iss告诉我们,令牌是由 https://auth.coffeemesh.io 服务器身份服务签发的。

  • sub告诉我们,用户具有标识符ec7bbccf-ca89-4af3-82ac-b41e4831a962。此标识符的值属于身份服务。我们的 API 可以使用此值以透明的方式控制对用户拥有的资源的访问。我们说此 ID 是透明的,因为它不披露任何关于用户的个人信息。

  • aud告诉我们,此令牌已签发以授予对订单 API 的访问权限。如果此字段的值是不同的 URL,则订单 API 将拒绝请求。

  • iat告诉我们,令牌是在 2022 年 10 月 30 日晚上 6:50 UTC 签发的。

  • exp告诉我们,令牌在 2022 年 10 月 31 日晚上 5:50 UTC 到期。

  • azp告诉我们,令牌是由标识符为7c2773a4-3943-4711-8997-70570d9b099c的应用程序请求的。这通常是一个前端应用程序。此声明在已使用 OpenID Connect 协议签发的令牌中很常见。

  • scope字段告诉我们,此令牌是使用 OpenID Connect 协议签发的。

现在我们知道了如何处理令牌声明,让我们看看我们如何生成和验证令牌!

11.3.3 生成 JWT

要形成最终的 JWT,我们需要使用 base64url 编码来编码头部、有效载荷和签名。如 RFC 4648 (mng.bz/aPRj) 所述,base64url 编码类似于 Base64,但它使用非字母数字字符并省略填充。然后使用点作为分隔符将头部、有效载荷和签名连接起来。像 PyJWT 这样的库会处理生成 JWT 的繁重工作。假设我们想要为列表 11.1 中看到的有效载荷生成一个令牌:

payload = {
  "iss": "https://auth.coffeemesh.io/",
  "sub": "ec7bbccf-ca89-4af3-82ac-b41e4831a962",
  "aud": "http://127.0.0.1:8000/orders",
  "iat": 1667155816,
  "exp": 1667238616,
  "azp": "7c2773a4-3943-4711-8997-70570d9b099c",
  "scope": "openid"
}

要使用此有效载荷生成一个签名令牌,我们使用 PyJWT 的 encode() 函数,传入令牌、用于签名令牌的密钥以及我们想要用于签名令牌的算法:

>>> import jwt
>>> jwt.encode(payload=payload, key='secret', algorithm='HS256')
➥ 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJodHRwczovL2F1dGguY29mZ
➥ mVlbWVzaC5pby8iLCJzdWIiOiJlYzdiYmNjZi1jYTg5LTRhZjMtODJhYy1iNDFlNDgzMWE5
➥ NjIiLCJhdWQiOiJodHRwOi8vMTI3LjAuMC4xOjgwMDAvb3JkZXJzIiwiaWF0IjoxNjY3MTU
➥ 1ODE2LCJleHAiOjE2NjcyMzg2MTYsImF6cCI6IjdjMjc3M2E0LTM5NDMtNDcxMS04Otk3Lt
➥ cwNTcwZDliMDk5YyIsInNjb3BlIjoib3BlbmlkIn0.sZEXZVitCv0iVrbxGN54GJr8QecZf
➥ HA_pdvfEMzT1dI'

在这种情况下,我们使用 HS256 算法使用一个秘密关键字来签名令牌。为了更安全的加密,我们使用私钥/公钥对来使用 RS256 算法签名令牌。为了签名 JWT,我们通常使用遵循 X.509 标准的证书,这允许我们将一个身份绑定到一个公钥。要生成私钥/公钥对,请在您的终端中运行以下命令:

$ openssl req -x509 -nodes -newkey rsa:2048 -keyout private_key.pem \
-out public_key.pem -subj "/CN=coffeemesh"

X.509 证书的最小输入是主题的通用名称(CN),在这种情况下我们将其设置为 coffeemesh。如果您省略了 -subj 标志,您将收到一系列关于您想要将证书绑定到的身份的问题。此命令在名为 private_key.pem 的文件下生成一个私钥,以及相应的名为 public_key.pem 的公钥证书。如果您无法运行这些命令,您可以在本书提供的 GitHub 存储库中找到示例密钥对,位于 ch11/private_key.pem 和 ch11/public_key.pem。

现在我们有了私钥/公钥对,我们可以使用它们来签名我们的令牌并验证它们。创建一个名为 jwt_generator.py 的文件,并将列表 11.2 的内容粘贴到其中,该列表显示了如何使用私钥生成 JWT 令牌。该列表定义了一个名为 generate_jwt() 的函数,它为函数内部定义的有效载荷生成一个 JWT。在有效载荷中,我们动态设置 iatexp 属性:iat 设置为当前的 UTC 时间;exp 设置为从现在起 24 小时。我们使用 cryptographyserialization() 函数加载私钥,传入参数是我们私钥文件的字节内容以及以字节编码的密码。最后,我们使用 PyJWT 的 encode() 函数对有效载荷进行编码,传入有效载荷、加载的私钥以及我们想要用于签名令牌的算法(RS256)。

列表 11.2 使用私钥生成 JWT

# file: jwt_generator.py

from datetime import datetime, timedelta
from pathlib import Path

import jwt
from cryptography.hazmat.primitives import serialization

def generate_jwt():
    now = datetime.utcnow()
    payload = {
        "iss": "https://auth.coffeemesh.io/",
        "sub": "ec7bbccf-ca89-4af3-82ac-b41e4831a962",
        "aud": "http://127.0.0.1:8000/orders",
        "iat": now.timestamp(),
        "exp": (now + timedelta(hours=24)).timestamp(),
        "scope": "openid",
    }
    private_key_text = Path("private_key.pem").read_text()
    private_key = serialization.load_pem_private_key(
        private_key_text.encode(),
        password=None,
    )
    return jwt.encode(payload=payload, key=private_key, algorithm="RS256")

print(generate_jwt())

要查看此代码的工作情况,通过运行 pipenv shell 激活您的虚拟环境,并执行以下命令:

$ python jwt_generator.py
➥ eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL2F1dGguY29mZm
➥ VlbWVzaC5pby8iLCJzdWIiOiJlYzdiYmNjZi1jYTg5LTRhZjMtODJhYy1iNDFlNDgzMWE5N
➥ jIiLCJhdWQiOiJodHRwOi8vMTI3LjAuMC4xOjgwMDAvb3JkZXJzIiwiaWF0IjoxNjM4MDMx
➥ LjgzOTY5ODczOTEsImV4cCI6MTYzODExOC4yMzk2Otg5OTMsInNjb3BlIjoib3BlbmlkIn0
➥ .GipMvEvZG8ErmMA99geYUq5IkeWpRrnHoViLb1CkRufqC5vgM9555re4IsLLa7yVxNAXIp
➥ FVFBqaoWrloJl6dSQ5r00dvUBSM1EM78KMZ7f0gQqUDFWNoKWCeyQu1QCBzuHTouS4l_mzz
➥ Ii75Sal3DJLTaj4zr6c_bQdUuDU1GyrIOJiPSCHSlnKPgg9tjrX8eOcB_ESGSo9ipnCbPAl
➥ uWp0cDjPRPBNRuiU53sbli-
➥ dTy7WoCD1mXAbqhztwO39kG3DZBkysB4vTnKU4Eul2yNNYK2hHVZQEvAqq8TJjETUS7iekf
➥ 0NSt1qQArJ7cxg6Jh5D7y5pbKmYYsBlFohPg

现在你已经知道如何生成 JWT 了!列表 11.2 中的 JWT 生成器非常适合运行测试,我们将在接下来的章节中使用它来测试我们的代码。现在我们了解了 JWT 的生成方式,让我们看看如何检查它们的载荷以及如何验证它们。

11.3.4 检查 JWT

在处理 JWT 时,你经常会遇到验证问题。要了解令牌验证失败的原因,检查载荷并验证其声明是否正确非常有用。在本节中,你将学习使用三种不同的工具来检查 JWT:jwt.io (jwt.io)、终端的 base64 命令和 Python。要尝试这些工具,请运行我们在 11.3.3 节中创建的 jwt_generator.py 脚本来发布新的令牌。

图 11-08

图 11.8 jwt.io 是一个帮助你轻松检查和可视化 JWT 的工具。只需将令牌粘贴到左侧面板即可。你还可以通过在右侧的“验证签名”框中粘贴公钥来验证令牌的签名。

jwt.io 是一个优秀的工具,它提供了一种简单的方式来检查 JWT。如图 11.8 所示,你所需要做的就是将 JWT 粘贴到左侧的输入面板中。右侧的显示面板将显示令牌头部和载荷的内容。你还可以通过提供你的公钥来验证令牌的签名。要从我们的公钥证书中提取公钥,可以使用以下命令:

$ openssl x509 -pubkey -noout < public_key.pem > pubkey.pem

此命令将公钥输出到名为 pubkey.pem 的文件中。你需要将文件内容复制到 jwt.io 的公钥输入面板中,以验证令牌的签名。

你还可以通过在终端中使用 base64 命令解码 JWT 的头部和载荷来检查 JWT 的内容。例如,要在终端中解码令牌的头部,运行以下命令:

$ echo eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9 | base64 --decode
{"alg":"RS256","typ":"JWT"}

我们还可以使用 Python 的 base64 库来检查 JWT 的内容。要使用 Python 解码 JWT 头部,打开 Python 命令行并运行以下代码:

>>> import base64
>>> base64.decodebytes('eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9'.encode())
b'{"alg":"RS256","typ":"JWT",}'

由于 JWT 载荷也是 base64url 编码的,我们使用相同的方法来解码它。现在我们知道了如何检查 JWT 载荷,让我们看看如何验证它们!

11.3.5 验证 JWT

验证 JWT 有两个部分。一方面,你必须验证其签名,另一方面,你必须验证其声明是否正确,例如,确保令牌未过期且受众正确。这个过程必须清晰;验证过程的两个步骤都是必需的。带有有效签名的过期令牌不应被 API 服务器接受,而带有无效签名的活动令牌也没有任何价值。每个用户请求服务器都必须携带令牌,并且必须在每次请求中验证令牌。

在每个请求中验证 JWT 当用户与我们的 API 服务器交互时,他们必须在每个请求中发送一个 JWT,我们必须在每个请求中验证该令牌。一些实现,特别是那些使用我们在 11.2.1 节中讨论的授权代码流,将令牌存储在会话缓存中,并将请求的令牌与缓存进行比较。这不是 JWT 应该被使用的方式。JWT 是为客户端和服务器之间的无状态通信而设计的,因此必须使用我们在本节中描述的方法进行验证。

正如我们在 11.3.3 节中看到的,令牌可以用秘密密钥或用私钥/公钥对进行签名。出于安全考虑,大多数网站使用用私钥/公钥签名的令牌,为了验证此类令牌的签名,我们使用公钥。

让我们看看如何在代码中验证令牌。我们将使用我们在 11.3.3 节中创建的签名密钥来生成和验证令牌。通过运行 pipenv shell 激活您的 Pipenv 环境,并执行 jwt_generator.py 脚本来颁发新的令牌。

为了验证令牌,我们必须首先使用以下代码加载公钥:

>>> from cryptography.x509 import load_pem_x509_certificate
>>> from pathlib import Path
>>> public_key_text = Path('public_key.pem').read_text()
>>> public_key = load_pem_x509_certificate(public_key_text.encode('utf-
➥ 8')).public_key()

现在我们有了公钥,我们可以使用以下代码来验证令牌:

>>> import jwt
>>> access_token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ..."
>>> jwt.decode(access_token, key=public_key, algorithms=['RS256'], 
➥ audience=["http://127.0.0.1:8000/orders"])
{'iss': 'https://auth.coffeemesh.io/', 'sub': 'ec7bbccf-ca89-4af3-82ac-
➥ b41e4831a962', 'aud': 'http://127.0.0.1:8000/orders', 'iat': 
➥ 1638114196.49375, 'exp': 1638200596.49375, 'scope': 'openid'}

如您所见,如果令牌有效,我们将返回 JWT 负载。如果令牌无效,此代码将引发异常。现在我们知道了如何处理和验证 JWT,让我们看看如何在 API 服务器中授权请求。

11.4 向 API 服务器添加授权

现在我们知道了如何验证访问令牌,让我们将所有这些代码组合到我们的 API 服务器中。在本节中,我们向订单 API 添加授权。订单 API 的某些端点是受保护的,而其他端点必须对每个人可访问。我们的目标是确保我们的服务器在受保护端点下检查有效的访问令牌。

我们将允许公开访问 /docs/orders 和 /openapi/orders.json 端点,因为它们提供必须对所有消费者可用的 API 文档。所有其他端点都需要有效的令牌。如果令牌无效或请求中缺少令牌,我们必须以 401(未授权)状态码拒绝请求,这表示凭证缺失。

我们如何向我们的 API 添加授权?有两种主要策略:在 API 网关中处理验证或在每个服务中处理验证。API 网关 是位于我们 API 前的网络层。⁸ API 网关的主要作用是促进服务发现,但它也可以用于授权用户访问、验证访问令牌以及通过添加有关用户信息的自定义头来自定义请求。

第二种方法是处理每个 API 内部的授权。当你的 API 网关无法处理授权或 API 网关不适合你的架构时,你将在服务级别处理授权。在本节中,我们将学习如何在服务内部处理授权,因为我们没有 API 网关。

常常出现的一个问题是,我们的代码中在哪里处理授权?由于授权是验证用户通过 API 访问服务所必需的,我们将其实现为 API 中间件。如图 11.9 所示,中间件 是一层代码,为处理所有请求提供通用功能。大多数 Web 服务器都有一个中间件或请求预处理器的概念,我们的授权代码就放在那里。中间件组件通常按顺序执行,我们通常可以选择它们的执行顺序。由于授权控制对服务器的访问,授权中间件必须尽早执行。

图片 11-09

图 11.9 请求首先由服务器中间件处理,例如 CORS 和 auth 中间件,然后再到达路由器,路由器将请求映射到相应的视图函数。

11.4.1 创建授权模块

让我们先创建一个模块来封装我们的授权代码。创建一个名为 orders/web/api/auth.py 的文件,并将列表 11.3 中的代码复制进去。我们首先加载在 11.3.3 节中创建的公钥。为了验证令牌,我们首先检索头信息并加载公钥。我们使用 PyJWT 的 decode() 函数来验证令牌,传入参数包括令牌本身、验证令牌所需的公钥、预期的受众列表以及用于签名密钥的算法。

列表 11.3 向 API 添加授权模块

# file: orders/web/api/auth.py

from pathlib import Path

import jwt
from cryptography.x509 import load_pem_x509_certificate

public_key_text = (
    Path(__file__).parent / "../../../public_key.pem"
).read_text()
public_key = load_pem_x509_certificate(
    public_key_text.encode()
).public_key()

def decode_and_validate_token(access_token):
    """
    Validates an access token. If the token is valid, it returns the token payload.
    """
    return jwt.decode(
        access_token,
        key=public_key,
        algorithms=["RS256"],
        audience=["http://127.0.0.1:8000/orders"],
    )

现在我们创建了一个封装验证 JWT 所需功能的模块,让我们通过添加一个使用它来验证 API 访问的中间件将其集成到 API 中。

11.4.2 创建授权中间件

要将授权添加到我们的 API 中,我们创建一个授权中间件。列表 11.4 展示了如何实现授权中间件。列表 11.4 中的代码放入 orders/web/app.py 文件中,新添加的代码以粗体显示。我们将中间件实现为一个简单的类,称为 AuthorizeRequestMiddleware,它继承自 Starlette 的 BaseHTTPMiddleware 类。中间件的入口点必须在名为 dispatch() 的函数中实现。

我们使用一个标志来确定是否应该启用身份验证。这个标志是一个名为AUTH_ON的环境变量,我们默认将其设置为False。通常在开发新功能或调试 API 中的问题时,在本地运行服务器而不进行身份验证是很方便的。使用标志允许我们根据需要切换身份验证的开启和关闭。如果身份验证关闭,我们为请求用户添加默认 ID test

接下来,我们检查用户是否请求 API 文档。如果是这种情况,我们不会阻止请求,因为我们希望让所有用户都能看到 API 文档;否则,他们不知道如何正确地构建他们的请求。

我们还检查请求的方法。如果是 OPTIONS 请求,我们不会尝试授权请求。OPTIONS 请求是预检请求,也称为跨源资源共享(CORS)请求。预检请求的目的是检查 API 服务器接受哪些来源、方法和请求头,根据 W3 的规范,CORS 请求不得要求凭据(www.w3.org/TR/2020/SPSD-cors-20200602/)。CORS 请求通常由 Web 服务器框架处理。

CORS 请求,也称为预检请求,是由网络浏览器发送的,以了解 API 服务器接受哪些方法、来源和头。如果我们没有正确处理 CORS 请求,网络浏览器将终止与 API 的通信。幸运的是,大多数 Web 框架都包含处理 CORS 请求的正确插件或扩展。CORS 请求未进行身份验证,因此当我们向我们的服务器添加身份验证时,我们必须确保预检请求不需要凭据。

如果不是 CORS 请求,我们尝试从请求头中捕获令牌。我们期望在“Authorization”头下找到令牌。如果找不到“Authorization”头,我们将以 401(未授权)状态码响应拒绝请求。Authorization 头的值格式为Bearer <ACCESS_TOKEN>,因此如果找到 Authorization 头,我们将通过在空格周围分割头值来捕获令牌,并尝试验证它。如果令牌无效,PyJWT 将抛出异常。在我们的中间件中,我们捕获 PyJWT 的无效化异常,以确保我们可以返回 401 状态码响应。如果没有抛出异常,这意味着令牌是有效的,因此我们可以处理请求,所以返回对下一个回调的调用。我们还从令牌的有效载荷中捕获用户 ID 并将其存储在请求的state对象中,以便我们可以在 API 视图中稍后访问它。最后,为了注册中间件,我们使用 FastAPI 的add_middleware()方法。

JSON Web 令牌去哪里了?JWTs 放在请求头中,通常在 Authorization 头下。带有 JWT 的 Authorization 头通常具有以下格式:Authorization: Bearer <JWT>

列表 11.4 向订单 API 添加授权中间件

# file: orders/web/app.py

import os

from fastapi import FastAPI
from jwt import (
    ExpiredSignatureError,
    ImmatureSignatureError,
    InvalidAlgorithmError,
    InvalidAudienceError,
    InvalidKeyError,
    InvalidSignatureError,
    InvalidTokenError,
    MissingRequiredClaimError,
)
from starlette import status
from starlette.middleware.base import (
    RequestResponseEndpoint,
    BaseHTTPMiddleware,
)
from starlette.requests import Request
from starlette.responses import Response, JSONResponse

from orders.api.auth import decode_and_validate_token

app = FastAPI(debug=True)

class AuthorizeRequestMiddleware(BaseHTTPMiddleware):                      ①
    async def dispatch(                                                    ②
        self, request: Request, call_next: RequestResponseEndpoint
    ) -> Response:
        if os.getenv("AUTH_ON", "False") != "True":                        ③
            request.state.user_id = "test"                                 ④
            return await call_next(request)                                ⑤

        if request.url.path in ["/docs/orders", "/openapi/orders.json"]: ⑥
            return await call_next(request)
        if request.method == "OPTIONS":
            return await call_next(request)

        bearer_token = request.headers.get("Authorization")                ⑦
        if not bearer_token:                                               ⑧
            return JSONResponse(
                status_code=status.HTTP_401_UNAUTHORIZED,
                content={
                    "detail": "Missing access token",
                    "body": "Missing access token",
                },
            )
        try:
            auth_token = bearer_token.split(" ")[1].strip()                ⑨
            token_payload = decode_and_validate_token(auth_token)          ⑩
        except (                                                           ⑪
            ExpiredSignatureError,
            ImmatureSignatureError,
            InvalidAlgorithmError,
            InvalidAudienceError,
            InvalidKeyError,
            InvalidSignatureError,
            InvalidTokenError,
            MissingRequiredClaimError,
        ) as error:
            return JSONResponse(
                status_code=status.HTTP_401_UNAUTHORIZED,
                content={"detail": str(error), "body": str(error)},
            )
        else:
            request.state.user_id = token_payload["sub"]                   ⑫
        return await call_next(request)

app.add_middleware(AuthorizeRequestMiddleware)                             ⑬

from orders.api import api

① 我们通过从 Starlette 的 BaseHTTPMiddleware 基类继承来创建一个中间件类。

② 我们实现中间件的入口点。

③ 如果 AUTH_ON 设置为 True,我们授权请求。

④ 如果未启用授权,我们将默认用户 test 绑定到请求。

⑤ 我们通过调用下一个回调来返回。

⑥ 文档端点是公开可用的,所以我们不对它们进行授权。

⑦ 我们尝试获取 Authorization 头。

⑧ 如果没有设置 Authorization 头,我们返回一个 401 响应。

⑨ 我们从 Authorization 头中捕获令牌。

⑩ 我们验证并检索令牌的有效载荷。

⑪ 如果令牌无效,我们返回一个 401 响应。

⑫ 我们从令牌的 sub 字段中捕获用户 ID。

⑬ 我们使用 FastAPI 的 add_middleware() 方法注册中间件。

服务器已准备好开始使用 JWT 验证请求!让我们运行一个测试来看看我们的授权代码是如何工作的。通过运行 pipenv shell 激活虚拟环境,并使用以下命令启动服务器:

$ AUTH_ON=True uvicorn orders.web.app:app --reload

从不同的终端,使用 cURL 发送一个未认证的请求(一些输出被截断),使用 -i 标志,它显示额外的信息,例如响应状态码:

$ curl -i http://localhost:8000/orders
HTTP/1.1 401 Unauthorized
[...]

{"detail":"Missing access token","body":"Missing access token"}

如您所见,缺少令牌的请求被拒绝,并显示一个 401 错误和一个消息,告诉我们访问令牌缺失。现在使用我们在 11.3.3 节中实现的 jwt_generator.py 脚本生成一个令牌,并使用该令牌发送一个新的请求:

curl http://localhost:8000/orders -H 'Authorization: Bearer 
➥ eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6ImI3NTQwM2QxLWUzZDktNDgzYy0
➥ 5MjZhLTM4NDRhM2Q4OWY1YyJ9.eyJpc3MiOiJodHRwczovL2F1dGguY29mZmVlbWVzaC5pb
➥ y8iLCJzdWIiOiJlYzdiYmNjZi1jYTg5LTRhZjMtODJhYy1iNDFlNDgzMWE5NjIiLCJhdWQi
➥ OiJodHRwOi8vMTI3LjAuMC4xOjgwMDAvb3JkZXJzIiwiaWF0IjoxNjM4MTE3MjEyLjc5OTE
➥ 3OSwiZXhwIjoxNjM4MjAzNjEyLjc5OTE3OSwic2NvcGUiOiJvcGVuaWQifQ.F1bmgYm1acf
➥ i1NMm5JGkbYQYWFNvG1-7BAXEnIqNdF0th_DYcnEm_p3YZ5hQ93v4QWxDx9muit6InKs-
➥ MHqhChP2k6DakpSocaqbgJ_IHpqNhTaEzByqZjoNfZFyQLZMo3yEaQB8S_x0LcKOOqeoPYl
➥ GSWM1eAUy7VFBXmvMUZrUj-yoK721U9vevgM-wdVyYFVtpTRuyjCoWMjJEVadNn-
➥ Zrxr0ghlRQnwEx-YdTbbEMkk_vVLWoWeEgj7mkBE167fr-
➥ fyGUKBqa2F71Zwh8DaDQz79Ph_STOY6BTlCnAVL8XwnlIOhJWpSHuc90Kynn_RX49_yJrQH
➥ KF-xLoflWg'
{"orders":[]}

如果令牌有效,这次你会得到一个包含订单列表的成功响应。我们的授权代码正在工作!下一步是确保用户只能访问服务器上的自己的资源。在我们这样做之前,让我们再添加一个中间件来处理 CORS 请求。

11.4.3 添加 CORS 中间件

由于我们打算允许与前端应用程序进行交互,因此我们还需要启用 CORS 中间件。正如我们在 11.4.2 节中看到的,CORS 请求是由浏览器发送的,以了解服务器允许哪些头、方法和来源。FastAPI 的 CORS 中间件负责用正确的信息填充我们的响应。列表 11.5 展示了如何修改 orders/web/app.py 文件以注册 CORS 中间件,其中加粗的新代码和省略号省略了列表 11.5 中的部分代码。

如我们之前所做的那样,我们使用 FastAPI 的 add_middleware() 方法来注册 CORS 中间件,并传递必要的配置。出于测试目的,我们使用通配符来允许所有来源、方法和头部信息,但在您的生产环境中,您必须更加具体。特别是,您必须限制允许的来源为您的网站域名和其他受信任的来源。

我们注册中间件的顺序很重要。中间件是按照注册的逆序执行的,因此最新注册的中间件首先执行。由于 CORS 中间件对于前端客户端和 API 服务器之间的所有交互都是必需的,所以我们最后注册它,这确保了它总是被执行。

列表 11.5 添加 CORS 中间件

# file: orders/web/app.py

import os

from fastapi import FastAPI
from jwt import (
    ExpiredSignatureError,
    ImmatureSignatureError,
    InvalidAlgorithmError,
    InvalidAudienceError,
    InvalidKeyError,
    InvalidSignatureError,
    InvalidTokenError,
    MissingRequiredClaimError,
)
from starlette import status
from starlette.middleware.base import RequestResponseEndpoint, BaseHTTPMiddleware
from starlette.middleware.cors import CORSMiddleware ①
from starlette.requests import Request
from starlette.responses import Response, JSONResponse

from orders.api.auth import decode_and_validate_token

app = FastAPI(debug=True)

...

app.add_middleware(AuthorizeRequestMiddleware)

app.add_middleware(
    CORSMiddleware,                                    ②
    allow_origins=["*"],                               ③
    allow_credentials=True,                            ④
    allow_methods=["*"],                               ⑤
    allow_headers=["*"],                               ⑥
)

from orders.api import api

① 我们导入 Starlette 的 CORSMiddleware 类。

② 我们使用 FastAPI 的 add_middleware() 方法注册 CORSMiddleware。

③ 我们允许所有来源。

④ 我们支持跨域请求的 cookies。

⑤ 我们允许所有 HTTP 方法。

⑥ 我们允许所有头部信息。

我们几乎准备好了!我们的服务器现在可以授权用户并处理 CORS 请求。下一步是确保每个用户只能访问他们的数据。

11.5 授权资源访问

我们通过确保只有经过身份验证的用户可以访问我们的 API 来保护了我们的 API。现在我们必须确保每个订单的详细信息只能由放置订单的用户访问;我们不希望允许用户访问彼此的数据。我们称这种类型的验证为 授权,在本节中,您将学习如何将其添加到您的 API 中。

11.5.1 更新数据库以链接用户和订单

我们首先将数据库中现有的订单删除。这些订单没有与用户关联,因此一旦我们强制执行每个订单与用户的关联,它们将无法工作。进入 ch11 目录,通过运行 pipenv shell 激活虚拟环境,并通过运行 python 命令打开 Python shell。在 Python shell 中,运行以下代码:

>>> from orders.repository.orders_repository import OrdersRepository
>>> from orders.repository.unit_of_work import UnitOfWork
>>> with UnitOfWork() as unit_of_work:
...     orders_repository = OrdersRepository(unit_of_work.session)
...     orders = orders_repository.list()
...     for order in orders: order.delete(order.id)
...     unit_of_work.commit()

我们的数据库名现在已清理,因此我们准备开始。我们如何将每个订单与用户关联起来?一种典型策略是创建一个用户表,并通过外键将我们的订单链接到用户记录。但为订单服务创建用户表真的有意义吗?我们想要为每个服务都有一个用户表吗?

不,我们不想为每个服务都有一个用户表,因为这会涉及大量的重复。正如您在图 11.10 中所看到的,我们只想有一个用户表,并且该表必须由用户服务拥有。我们的用户服务是我们的身份即服务提供商,因此我们的用户表已经存在。每个用户都有一个 ID,正如我们在 11.3.1 节中看到的,ID 存在于 JWT 有效载荷的 sub 字段下。我们所需做的只是向订单表添加一个新列来存储创建订单的用户 ID。

图 11.10 为了避免重复,我们在身份服务提供程序下只保留一个用户表。为了避免服务之间的紧密耦合,我们避免在不同服务拥有的表之间使用外键。

将用户与其资源关联起来 微服务架构中的两个常见反模式是每个服务创建一个用户表,以及有一个共享的用户表,该表被多个服务直接访问以创建用户与其他资源之间的外键。每个服务有一个用户表是不必要的,并且涉及重复,而多个服务之间的共享用户表会在服务之间创建紧密耦合,并冒着在更改用户表模式时破坏它们的危险。由于 JWTs 已经在 sub 字段下包含不透明的用户 ID,因此依赖该标识符将用户与其资源关联起来是一个好的做法。

列表 11.6 展示了如何向 OrderModel 类添加 user_id 字段。以下代码位于 orders/repository/models.py 文件中,新添加的代码以粗体突出显示。

列表 11.6 向订单表添加用户 ID 外键

# file: orders/repository/models.py

class OrderModel(Base):
    __tablename__ = 'order'

    id = Column(String, primary_key=True, default=generate_uuid)
    user_id = Column(String, nullable=False)                       ①
    items = relationship('OrderItemModel', backref='order')
    status = Column(String, nullable=False, default='created')
    created = Column(DateTime, default=datetime.utcnow)
    schedule_id = Column(String)
    delivery_id = Column(String)

① 我们添加了一个名为 user_id 的新列。

现在我们已经更新了模型,我们需要通过运行迁移来更新数据库。正如我们在第七章中看到的,运行迁移是更新数据库模式的过程。正如我们在第七章中所做的那样,我们使用 Alembic 来管理我们的迁移,这是 Python 最好的数据库迁移管理库。Alembic 检查 OrderModel 模型和 order 表当前模式之间的差异,并执行必要的更新以添加 user_id 列。

在 SQLITE 中修改表 SQLite 对 ALTER 语句的支持有限。例如,SQLite 不支持通过 ALTER 语句向表中添加新列。如图 11.11 所示,为了解决这个问题,我们需要将表的数据复制到临时表并删除原始表。然后我们使用新字段重新创建表,从临时表复制数据,并删除临时表。Alembic 通过其批处理操作策略处理这些操作。

图片

图 11.11 当与 SQLite 一起工作时,我们使用批处理操作来更改我们的表。在批处理操作中,我们从原始表复制数据到一个临时表;然后,我们删除原始表并使用新字段重新创建它;最后,我们从临时表复制数据回来。

在我们可以运行迁移之前,我们需要更新 Alembic 配置。列表 11.6 中的更改向订单表添加了一个新列,这转化为一个 ALTER TABLE SQL 语句。对于本地开发,我们使用 SQLite,它对 ALTER 语句的支持有限。为了确保 Alembic 为 SQLite 生成正确的迁移,我们需要更新其配置以运行批处理操作。只有当你使用 SQLite 时才需要这样做

要更新 Alembic 配置以便我们可以运行迁移,打开 migrations/env.py 文件并搜索一个名为 run_migrations_online() 的函数。这个函数运行迁移以针对我们的数据库。在该函数内部,搜索以下代码块:

# file: migrations/env.py

with connectable.connect() as connection:
    context.configure(
        connection=connection,
        target_metadata=target_metadata

    )

在调用 configure() 方法的内部添加以下行(以粗体突出显示):

# file: migrations/env.py

with connectable.connect() as connection:
    context.configure(
        connection=connection,
        target_metadata=target_metadata,
        render_as_batch=True
    )

现在我们可以生成 Alembic 迁移并更新数据库。运行以下命令以创建新的迁移:

$ PYTHONPATH=`pwd` alembic revision --autogenerate -m "Add user id to order table"

接下来,我们使用以下命令运行迁移:

$ PYTHONPATH=`pwd` alembic upgrade heads

我们的数据库名单现在已准备好开始链接订单和用户。下一节将解释我们如何从 request 对象中获取用户 ID 并将其传递给我们的数据存储库。

11.5.2 限制用户访问他们自己的资源

现在我们数据库已准备好,我们需要更新我们的 API 视图以在创建或更新订单,或检索订单列表时捕获用户 ID。由于我们需要对视图函数进行的更改都非常相似,我们将展示如何将这些更改应用到一些视图中。你可以参考这本书的 GitHub 仓库以获取更改的完整列表。

列表 11.7 展示了如何更新 create_order() 视图函数以在下单时捕获用户 ID。新添加的代码以粗体突出显示。正如我们在 11.4.2 节中看到的,我们在请求的 state 属性下存储用户 ID,所以我们做的第一个更改是将 create_order() 函数的签名更改以包括 request 对象。第二个更改是将用户 ID 传递给 OrderServiceplace_order() 方法。

列表 11.7 在下单时捕获用户 ID

# file: orders/web/api/api.py

@app.post(
    "/orders", status_code=status.HTTP_201_CREATED, response_model=GetOrderSchema
)
def create_order(request: Request, payload: CreateOrderSchema):            ①
    with UnitOfWork() as unit_of_work:
        repo = OrdersRepository(unit_of_work.session)
        orders_service = OrdersService(repo)
        order = payload.dict()["order"]
        for item in order:
            item["size"] = item["size"].value
        order = orders_service.place_order(order, request.state.user_id)   ②
        unit_of_work.commit()
        return_payload = order.dict()
    return return_payload

① 我们在函数签名中捕获请求对象。

② 我们从请求的状态对象中捕获用户 ID。

我们还需要更改 OrdersServiceOrdersRepository 以确保它们也能捕获用户 ID。以下代码展示了如何更新 OrdersService 以捕获用户 ID:

# file: orders/orders_service/orders_service.py

class OrdersService:
    def __init__(self, orders_repository: OrdersRepository):
        self.orders_repository = orders_repository

    def place_order(self, items, user_id):
        return self.orders_repository.add(items, user_id)

以下代码展示了如何更新 OrdersRepository 以捕获用户 ID:

# file: orders/repository/orders_repository.py

class OrdersRepository:
    def __init__(self, session):
        self.session = session

    def add(self, items, user_id):
        record = OrderModel(
            items=[OrderItemModel(**item) for item in items],
            user_id=user_id
        )
        self.session.add(record)
        return Order(**record.dict(), order_=record)

现在我们知道了如何使用用户 ID 保存订单,让我们看看我们如何确保当用户调用 GET /orders 端点时,他们只能获取他们自己的订单列表。列表 11.8 展示了对 get_orders() 函数所需的更改,该函数实现了 GET /orders 端点。新添加的代码以粗体显示。正如你所见,在这种情况下,我们还需要更改函数的签名以捕获请求对象。然后我们简单地将用户 ID 作为查询过滤器之一传递。由于 OrdersServiceOrdersRepository 都被设计为接受任意字典的过滤器,所以代码的其他部分不需要任何额外更改。

列表 11.8 确保用户只能获取他们自己的订单列表

# file: orders/web/api/api.py

@app.get("/orders", response_model=GetOrdersSchema)
def get_orders(
    request: Request,
    cancelled: Optional[bool] = None,
    limit: Optional[int] = None
):
    with UnitOfWork() as unit_of_work:
        repo = OrdersRepository(unit_of_work.session)
        orders_service = OrdersService(repo)
        results = orders_service.list_orders(
            limit=limit, cancelled=cancelled, user_id=request.state.user_id
        )
    return {"orders": [result.dict() for result in results]}

现在,让我们将注意力转向 GET /orders/{order_id}端点。如果用户尝试检索不属于他们的订单的详细信息会发生什么?我们可以采取两种策略:返回 404(未找到)响应,表明请求的订单不存在,或者返回 403(禁止)响应,表明用户没有访问请求资源的权限。

技术上讲,当用户试图访问不属于他们的资源时,返回 403 响应比 404 响应更正确。但这也暴露了不必要的详细信息。一个拥有有效凭证的恶意用户可能会利用我们的 403 响应来构建服务器上现有资源的映射。为了避免这个问题,我们选择披露更少的信息,并返回 404 响应。当尝试从数据库中检索订单时,用户 ID 将变成一个额外的过滤器。

以下代码显示了get_order()函数所需的更改,以包括用户 ID 在我们的查询中,新添加的代码以粗体显示。同样,我们将请求对象包含在函数签名中,并将用户 ID 传递给OrderServiceget_order()方法。

列表 11.9 使用订单 ID 和用户 ID 过滤订单

# file: orders/web/api/api.py

@app.get("/orders/{order_id}", response_model=GetOrderSchema)
def get_order(request: Request, order_id: UUID):
    try:
        with UnitOfWork() as unit_of_work:
            repo = OrdersRepository(unit_of_work.session)
            orders_service = OrdersService(repo)
            order = orders_service.get_order(
                order_id=order_id, user_id=request.state.user_id
            )
        return order.dict()
    except OrderNotFoundError:
        raise HTTPException(
            status_code=404, detail=f"Order with ID {order_id} not found"
        )

为了能够按用户 ID 查询订单,我们还需要更新OrdersServiceOrdersRepository类。我们将更改它们的方法以接受一个可选的任意过滤器字典。OrdersServiceget_order()方法更改如下:

# file: orders/orders_service/orders_service.py

def get_order(self, order_id, **filters):
    order = self.orders_repository.get(order_id, **filters)
    if order is not None:
        return order
    raise OrderNotFoundError(f"Order with id {order_id} not found")

并且OrdersRepositoryget()_get()方法需要以下更改:

# file: orders/repository/orders_repository.py

def _get(self, id_, **filters):
    return (
        self.session.query(OrderModel)
        .filter(OrderModel.id == str(id_)).filter_by(**filters)
        .first()
    )

def get(self, id_, **filters):
    order = self._get(id_, **filters)
    if order is not None:
        return Order(**order.dict())

orders/web/api/api.py文件中其余的视图函数需要与这一节中看到的类似更改,同样适用于OrdersServiceOrdersRepository类的其余方法。作为一个练习,我建议您尝试完成添加授权到剩余 API 端点所需的更改。本书的 GitHub 存储库包含了完整的更改列表,因此请随时查看以获取指导。

这标志着我们通过 API 身份验证和授权的旅程结束,这是一段怎样的旅程!您已经学习了 OAuth 和 OpenID Connect 是什么以及它们是如何工作的。您已经了解了 OAuth 流程以及何时使用每个流程。您已经了解了 JWT 是什么,如何检查它们的负载,以及如何生成和验证它们。最后,您已经学习了如何授权 API 请求以及如何授权用户访问特定资源。您已经拥有了开始为您的 API 添加强大身份验证和授权所需的一切!

附录 C 教您如何与身份提供者(如 Auth0)集成。您还将看到如何使用 PKCE 和客户端凭证流量的实际示例,并学习如何使用 Swagger UI 授权您的请求。

摘要

  • 我们使用标准的 OAuth 和 OpenID Connect 协议授权访问我们的 API。

  • OAuth 是一种访问委派协议,允许用户授予应用程序访问他们不同网站上的资源的权限。它区分了四种授权流程:

    • 授权代码——API 服务器与授权服务器交换代码,以请求用户的访问令牌。

    • PKCE——客户端应用程序(通常是单页应用程序)使用代码验证器和代码挑战从授权服务器获取访问令牌。

    • 客户端凭证——客户端(通常是另一个微服务)通过交换一个私有密钥来换取访问令牌。

    • 刷新令牌——客户端通过交换刷新令牌来获取新的访问令牌。

  • OpenID Connect 是一个基于 OAuth 的身份验证协议,它帮助用户通过从其他网站(如 Google 或 Facebook)带来他们的身份,轻松地验证到新网站。

  • JWT 是包含关于用户访问权限声明的 JSON 文档。JWT 使用 base64url 编码,通常使用私有/公钥进行签名。

  • 要验证请求,用户将在请求的授权头中发送他们的访问令牌。此头的预期格式为 Authorization: Bearer <ACCESS_TOKEN>

  • 我们使用 PyJWT 验证访问令牌。PyJWT 检查令牌是否未过期,受众是否正确,以及签名是否可以使用可用的公钥之一进行验证。如果令牌无效,我们以 401(未授权)响应拒绝请求。

  • 为了将用户与其资源链接起来,我们使用 JWT 的 sub 声明中表示的用户 ID。

  • 如果用户尝试访问不属于他们的资源,我们将以 403(禁止)响应。

  • OPTIONS 请求被称为 CORS 请求或预检请求。CORS 请求不得由凭据保护。


¹ 该问题首先由 Brian Krebs 报告,“USPS 网站泄露了 6000 万用户的个人信息”,KrebsOnSecurity,2018 年 11 月 21 日,krebsonsecurity.com/2018/11/usps-site-exposed-data-on-60-million-users/

² Bill Doerfeld,“过去六个月内 API 攻击流量增长了 300%以上”,Security Boulevard,2021 年 7 月 30 日,securityboulevard.com/2021/07/api-attack-traffic-grew-300-in-the-last-six-months/

³ Joe Galvin,“60%的小企业在遭受网络攻击后的六个月内倒闭,”Inc.,2018 年 5 月 7 日,www.inc.com/joe-galvin/60-percent-of-small-businesses-fold-within-6-months-of-a-cyber-attack-heres-how-to-protect-yourself.html

oauth.net/ 是一个很好的网站,提供了大量关于 OAuth 规范的学习资源。

⁵ N. Sakimura、J. Bradley 和 N. Agarwal,“OAuth 公共客户端的代码交换证明密钥”,IETF RFC 7636,2015 年 9 月,datatracker.ietf.org/doc/html/rfc7636

⁶ JSON Web Tokens(JWT)的完整生成和验证规范可在 J. Jones、J. Bradley 和 N. Sakimura 的“JSON Web Token (JWT)”RFC-7519 文档中找到,发布于 2015 年 5 月,datatracker.ietf.org/doc/html/rfc7519

您可以在www.iana.org/assignments/jwt/jwt.xhtml查看最常用的 JWT 声明的完整列表。

请参阅 Chris Richardson 的“模式:API 网关/前端后端”,microservices.io/patterns/apigateway.html

12 测试和验证 API

本章涵盖

  • 使用 Dredd 和 Schemathesis 为 REST API 生成自动测试

  • 编写 Dredd 钩子来自定义 Dredd 测试套件的行为

  • 使用基于属性的测试来测试 API

  • 利用 OpenAPI 链接增强 Schemathesis 测试套件

  • 使用 Schemathesis 测试 GraphQL API

本章教你如何测试和验证 API 实现。到目前为止,我们已经学会了设计和构建 API 以驱动微服务之间的集成。在这个过程中,我们进行了一些手动测试以确保我们的实现表现出正确的行为。然而,这些测试是有限的,更重要的是,它们完全是手动的,因此无法以自动化的方式重复。

在本章中,我们学习如何使用 Dredd 和 Schemathesis 等工具对 API 实现运行全面的测试套件,这些工具是每个 API 开发人员工具包中的 API 测试工具。Dredd 和 Schemathesis 都通过查看 API 规范并自动生成针对我们的 API 服务器的测试来工作。对于 API 开发人员来说,这非常方便,因为它意味着你可以将精力集中在构建 API 上,而不是测试它们。

通过使用 Dredd 和 Schemathesis 等工具,你可以节省时间和精力,同时确保你交付的实现是正确的。你可以组合使用 Dredd 和 Schemathesis,或者选择其中之一。正如你将看到的,Dredd 运行的是一个更基础的测试套件,这在 API 开发周期的早期阶段非常有用,而 Schemathesis 运行的是一个健壮的测试套件,在你将 API 发布到生产之前非常有用。

为了说明如何测试 REST API,我们将使用在第二章和第六章中实现的 orders API。为了说明如何测试 GraphQL API,我们将使用在第十章中实现的 products API。作为回顾,这两个 API 都是 CoffeeMesh 的一部分,这是一个虚构的按需咖啡配送平台,我们在本书中构建了这个平台。orders API 是订单服务的接口,它管理客户的订单,而 products API 是产品服务的接口,它管理 CoffeeMesh 提供的商品目录。

本章的代码可在 GitHub 上找到,位于名为 ch12 的文件夹下。在第 12.1 节中,我们设置了文件夹结构和环境,以便于本章示例的编写,所以如果你想要跟随本章的示例,请确保你已经阅读了那一节。

12.1 设置 API 测试环境

在本节中,我们设置环境以跟随本章中的示例。让我们首先设置文件夹结构。创建一个名为 ch12 的新文件夹,并进入它。在这个文件夹中,我们将复制订单 API 和产品 API。为了使本章内容简单,我们使用第六章中留下的订单 API 实现。第六章包含了订单 API 的完整实现,但它缺少真实的数据库和其他服务的集成(这些功能在第七章中添加)。由于本章的目标是学习如何测试 API,第六章中的实现就足够了,这将帮助我们保持专注,因为我们不需要设置数据库和运行额外的服务。在现实生活中,您可能希望单独测试 API 层,并运行包括数据库在内的集成测试。有关在章节 7 和 11 之后运行测试的说明,请参阅 GitHub 仓库中 ch12/orders 文件夹下的 README.md 文件。

在 ch12 文件夹内,通过运行以下命令复制 ch06/orders 中订单 API 的实现:

$ cp -r ../ch06/orders orders

进入 ch12/orders 目录,并运行以下命令来安装依赖项:

$ pipenv install --dev

在运行 pipenv install 时,不要忘记包含 --dev 标志,这会告诉 pipenv 安装生产环境和开发环境的依赖项。在本章中,我们将使用开发包来测试订单 API。要运行测试,我们需要 pytestdredd_hooksschemathesis,您可以使用以下命令进行安装:

$ pipenv install --dev dredd_hooks pytest schemathesis

要运行测试,我们将使用一个略微修改过的订单 API 规范,其中不包含 bearerAuth 安全方案,您可以在本书 GitHub 仓库的 ch12/orders/oas.yaml 文件中找到它。在本章中,我们将专注于测试 API 实现是否符合 API 规范,即确保 API 使用正确的模式、正确的状态码等。API 安全测试是一个完全不同的主题,为此我推荐 Mark Winteringham 的 Testing Web APIs(Manning,2022)和 Corey J. Ball 的 Hacking APIs(No Starch Press,2022)的第十一章。

现在让我们复制第十章中产品的 API 实现。通过运行 cd .. 返回 ch12 目录的顶层,然后执行以下命令:

$ cp -r ../ch10 products

进入 ch12/products 目录,并运行 pipenv install --dev 命令来安装依赖项。我们将使用 pytestschemathesis 来测试产品 API,您可以通过运行以下命令进行安装:

$ pipenv install pytest schemathesis

我们现在已经准备好开始测试 API 了。我们将从了解 Dredd API 测试框架开始我们的旅程。

12.2 使用 Dredd 测试 REST API

本节解释了 Dredd 是什么以及我们如何使用它来测试 REST API。Dredd 是一个 API 测试框架,它可以自动生成测试来验证我们的 API 服务器的行为。它通过解析 API 规范并从中学习 API 应该如何工作来生成测试。使用 Dredd 在开发过程中非常有帮助,因为它意味着我们可以专注于构建 API,而 Dredd 确保我们的工作方向正确。Dredd 于 2017 年由 Apiary 发布,成为该类工具的第一个工具 (mng.bz/5maq),自那时起它一直是每个 API 开发者必备的工具包的一部分。

在本节中,我们将通过使用 Dredd 验证订单 API 的实现来学习 Dredd 的工作原理。我们首先将运行一个基本的测试套件对 API 进行测试,然后我们将探索框架的更多高级功能。

12.2.1 什么是 Dredd?

在我们开始使用 Dredd 之前,让我们花一点时间来了解 Dredd 是什么以及它是如何工作的。Dredd 是一个 API 测试框架。如图 12.1 所示,Dredd 通过解析 API 规范并发现可用的 URL 路径以及它们接受的 HTTP 方法来工作。

图 12.1 Dredd 通过解析 API 规范,发现可用的端点,并为每个端点启动测试来工作。

为了测试 API,Dredd 会向 API 规范中定义的每个端点发送请求,如果有的话,还包括预期的有效载荷以及端点接受的任何查询参数。最后,它检查 API 收到的响应是否符合 API 规范中声明的模式,以及它们是否携带预期的状态码。

现在我们已经了解了 Dredd 的工作原理,让我们开始使用它!下一节将解释如何安装 Dredd。

12.2.2 安装和运行 Dredd 的默认测试套件

在本节中,我们将安装 Dredd 并运行其默认测试套件对订单 API 进行测试。在 ch12/orders 目录下使用 cd 命令进入,然后运行 pipenv shell 来激活环境。Dredd 是一个 npm 包,这意味着您需要在您的机器上有一个可用的 Node.js 运行时,以及一个 JavaScript 的包管理工具,例如 npm 或 Yarn。要从 npm 安装 Dredd,请在 ch12/orders 目录下运行以下命令:

$ npm install dredd

这将在名为 node_modules/ 的文件夹下安装 Dredd。一旦安装完成,我们就可以开始使用 Dredd 来测试 API。Dredd 随附一个 CLI,位于以下目录:node_modules/.bin/dredd。Dredd CLI 提供了可选的参数,这使我们能够在运行测试时具有很大的灵活性。我们将在本节后面使用其中的一些参数。现在,让我们执行最简单的 Dredd 命令来运行测试:

$ ./node_modules/.bin/dredd oas.yaml http://127.0.0.1:8000 --server \
 "uvicorn orders.app:app"

Dredd CLI 的第一个参数是 API 规范文件的路径,第二个参数表示 API 服务器的基准 URL。使用--server选项,我们告诉 Dredd 需要使用哪个命令来启动订单 API 服务器。如果你现在运行此命令,你将得到 Dredd 的一些警告,如下所示(省略号省略了 API 规范文件的路径,它将在你的机器上有所不同):

warn: [...] (Orders API > /orders/{order_id}/cancel > Cancels an order > 
➥ 200 > application/json): Ambiguous URI parameter in template: 
➥ /orders/{order_id}/cancel
No example value for required parameter in API description document: 
➥ order_id

Dredd 正在抱怨,因为我们没有提供 URL 参数order_id的示例,该参数在一些 URL 路径中是必需的。Dredd 抱怨缺少示例,因为它无法从规范中生成随机值。为了解决 Dredd 的抱怨,我们在每个使用order_id参数的 URL 中添加了该参数的示例。例如,对于/orders/{order_id} URL 路径,我们进行了如图 12.1 所示的修改(省略号表示省略的代码)。/orders/{order_id}/pay/orders/{order_id}/cancel URL 也包含了order_id参数的描述,因此也要为它们添加示例。Dredd 将使用示例中提供的确切值来测试 API。

列表 12.1 为order_id URL 路径参数添加示例

# file: orders/oas.yaml

[...]
  /orders/{order_id}:
    parameters:
      - in: path
        name: order_id
        required: true
        schema:
          type: string
        example: d222e7a3-6afb-463a-9709-38eb70cc670d      ①
    get:
      [...]

① 我们为 order_id URL 参数添加了一个示例。

一旦我们为order_id参数添加了示例,我们就可以再次运行 Dredd CLI。这次,测试套件运行没有问题,你将得到如下结果:

complete: 7 passing, 5 failing, 0 errors, 0 skipped, 12 total
complete: Tests took 90ms
INFO:     Shutting down
INFO:     Finished server process [23593]

这个总结告诉我们,Dredd 运行了 18 个测试,其中 7 个通过,11 个失败。测试的完整结果太长,无法在此重现,但如果你在终端中向上滚动,你会看到失败的测试是在针对特定资源的端点上:

  • GET、PUT 和 DELETE /orders/{order_id}

  • POST /orders/{order_id}/pay

  • POST /orders/{order_id}/cancel

Dredd 为每个这些端点运行三个测试,并期望每个端点获得一个成功的响应。然而,在上一次执行中,Dredd 只获得了 404 响应,这意味着服务器找不到 Dredd 请求的资源。在测试这些端点时,Dredd 使用列表 12.1 中提供的示例 ID。为了解决这个问题,我们可以在内存中的订单列表中添加一个具有该 ID 的硬编码订单(如果我们使用数据库进行测试,我们会将其添加到数据库中)。然而,正如我们将在下一节中看到的,更好的方法是使用 Dredd 钩子。

还有一个针对 POST /orders端点的失败测试,其中 Dredd 期望得到 422 响应。422 响应的失败测试发生是因为 Dredd 不知道如何创建生成这些响应的测试,而 Dredd 钩子也将帮助我们解决这个问题。

12.2.3 使用钩子自定义 Dredd 的测试套件

Dredd 的默认行为可以受到限制。正如我们在 12.2.1 节中看到的,Dredd 不知道如何处理带有 URL 路径参数的端点,例如 /orders/{order_id} URL 中的 order_id。Dredd 不知道如何生成随机资源 ID,并且如果我们提供一个示例,它期望样本 ID 在测试套件执行期间存在于系统中。这种期望是无用的,因为它意味着我们的 API 只有在它处于某种状态时才能进行测试——当某些资源或 fixtures 已加载到数据库中时。

定义 在软件测试中,fixtures 是运行测试所需的先决条件。通常,fixtures 是我们为测试而加载到数据库中的数据,但它们也可以包括配置、目录和文件,或基础设施资源。

与使用 fixtures 相比,我们可以通过使用 Dredd 钩子采取更好的方法。本节解释了 Dredd 钩子是什么以及我们如何使用它们。Dredd 钩子是脚本,允许我们在测试套件执行期间自定义 Dredd 的行为。使用 Dredd 钩子,我们可以在测试期间创建资源,保存它们的 ID,并在测试完成后清理它们。

Dredd 钩子允许我们在整个测试套件之前和之后,以及在每个端点特定测试之前和之后触发操作。对于涉及创建资源并对它们执行操作的有状态测试非常有用。例如,我们可以使用钩子通过 POST /orders 端点下单,保存订单的 ID,并使用该 ID 对订单执行操作,例如支付和取消,与其他端点一起使用。使用这种方法,我们可以测试 POST /orders 端点是否完成了创建资源的任务,并且我们可以使用真实资源测试其他端点。如图 12.2、12.3 和 12.4 所示,我们将按照以下步骤创建以下钩子:

  1. 在 POST /orders 测试之后,我们使用钩子保存服务器为新创建的订单返回的 ID。

  2. 在执行 GET、PUT 和 DELETE /orders/{order_id} 测试之前,我们使用钩子来告诉 Dredd 使用在点 (1) 创建的订单的 ID。这些端点用于检索订单的详细信息(GET),更新订单(PUT),以及从服务器中删除订单(DELETE)。因此,在运行 DELETE /orders/{order_id} 测试后,订单将不再存在于服务器上。

  3. 在 POST /orders/{order_id}/pay 和 POST /orders/{order_id}/cancel 端点之前,我们使用钩子创建新的订单以供这些测试使用。由于点 (2) 的 DELETE /orders/{order_id} 测试已从服务器中删除订单,因此我们无法重用点 (1) 的 ID。

  4. 对于 422 响应,我们需要一种从服务器生成 422 响应的策略。我们将使用两种方法:对于 POST /orders 端点,我们将发送一个无效的有效负载,而对于其他端点,我们将修改订单的 URI 并包含一个无效的标识符。

图片

图 12.2 在 POST /orders 端点测试之后,save_created_order() 钩子将服务器响应体中的 ID 保存到 response_stash 中。before_get_order()before_put_order()before_delete_order() 钩子使用 response_stash 中的 ID 来形成它们的资源 URL。

图 12-03

图 12.3 在执行测试之前,before_pay_order()before_cancel_order() 钩子使用 POST /orders 端点创建一个新订单,并使用响应有效载荷中的 ID 从它们的资源 URL 中获取。

图 12-04

图 12.4 fail_create_order()fail_target_specific_order() 钩子注入无效的有效载荷和无效的订单标识符,以触发服务器返回的 422 响应。

使用 Dredd 钩子保存创建的资源 ID

既然我们已经知道我们想要做什么,让我们编写我们的钩子!首先,如果您还没有这样做,请使用 cd 命令进入 ch12/orders 目录,并通过运行 pipenv shell 激活虚拟环境。创建一个名为 orders/hooks.py 的文件,我们将在这里编写我们的钩子。尽管 Dredd 是一个 npm 包,但我们可以通过使用 dredd-hooks 库在 Python 中编写我们的钩子。在第 12.1 节中,我们为这一章设置了环境,因此 dredd-hooks 已经被安装。

为了理解 Dredd 钩子是如何工作的,让我们详细看看其中一个。列表 12.2 展示了 POST /orders 端点的后钩子实现。这段代码放入 orders/hooks.py 文件中。我们首先声明一个名为 response_stash 的变量,我们将使用它来存储 POST /orders 请求中的数据。dredd-hooks 提供了装饰器函数,如 dredd_hooks.before()dredd_hooks.after(),允许我们将函数绑定到特定的操作。dredd-hooks 的装饰器接受一个参数,它代表我们想要绑定钩子的特定操作的路径。如图 12.5 所示,在 Dredd 中,一个操作被定义为具有其响应状态码和内容编码格式的 URL 端点。在列表 12.2 中,我们将 save_created_order() 钩子绑定到 POST /orders 端点的 201 响应。

图 12-05

图 12.5 在 Dredd 钩子中形成特定操作的路径时,您使用操作摘要的 URL 路径、响应状态码和响应的内容编码。

在 Dredd 钩子中定义操作路径 当使用 dredd-hooks 定义操作的路径时,您不能将 HTTP 方法作为操作路径的一部分使用;也就是说,以下语法将不起作用:/orders > post > 201 > application/json。相反,我们使用 POST 端点的其他属性,如 summaryoperationId,如下例所示:/orders > Creates an order > 201 > application/json

Dredd hooks 接收一个参数,表示 Dredd 在测试期间执行的事务。该参数以字典的形式出现。在列表 12.2 中,我们命名 hook 的参数为 transaction。由于我们在 save_created_order() hook 中的目标是获取创建的订单的 ID,我们检查 POST /orders 端点返回的有效载荷,该有效载荷可以在 transaction['real']['body'] 下找到。由于我们的 API 返回 JSON 有效载荷,我们使用 Python 的 json 库加载其内容。一旦我们获取到订单的 ID,我们就将其保存到用于以后在全局状态字典中,我们将其命名为 response_stash

列表 12.2 实现 POST /orders 端点的 after hook

# file: orders/hooks.py

import json
import dredd_hooks                                                        ①

response_stash = {}                                                       ②

@dredd_hooks.after('/orders > Creates an order > 201 > application/json') ③
def save_created_order(transaction):
    response_payload = transaction['real']['body']                        ④
    order_id = json.loads(response_payload)['id']                         ⑤
    response_stash['created_order_id'] = order_id                         ⑥

① 我们导入 dredd_hooks 库。

② 我们创建一个全局对象来存储和管理测试套件的状态。

③ 我们创建一个在 POST /orders 端点测试之后被触发的 hook。

④ 我们从 POST /orders 端点访问响应有效载荷。

⑤ 我们使用 Python 的 json 库加载响应并检索订单的 ID。

⑥ 我们将订单 ID 存储在我们的全局 response_stash 对象中。

使用 hooks 使 Dredd 使用自定义 URL

现在我们知道了如何保存 POST 请求中创建的订单的 ID,让我们看看我们如何使用该 ID 来形成订单的资源 URL。列表 12.3 展示了如何为订单资源端点构建 hooks。列表 12.3 中显示的代码放入了 orders/hooks.py 文件。列表 12.2 中的代码使用省略号省略,而新的添加内容以粗体显示。

要指定 Dredd 在测试 /orders/{order_id} 路径时应该使用的 URL,我们需要修改事务有效载荷。特别是,我们需要修改事务的 fullPath 和其 requesturi 属性,并确保它们指向正确的 URL。为了形成 URL,我们从 response_stash 字典中访问订单的 ID。

列表 12.3 使用 before hooks 告诉 Dredd 使用哪个 URL

# file: orders/hooks.py

import json
import dredd_hooks

response_stash = {}

[...]
@dredd_hooks.before(
    '/orders/{order_id} > Returns the details of a specific order > 200 > '
    'application/json'
)                                                           ①
def before_get_order(transaction):
    transaction["fullPath"] = (
        "/orders/" + response_stash["created_order_id"]     ②
    ) 
    transaction['request']['uri'] = (
        '/orders/' + response_stash['created_order_id']
    )

@dredd_hooks.before(
    '/orders/{order_id} > Replaces an existing order > 200 > '
    'application/json'
)
def before_put_order(transaction):
    transaction['fullPath'] = (
        '/orders/' + response_stash['created_order_id']
    )
    transaction['request']['uri'] = (
        '/orders/' + response_stash['created_order_id']
    )

@dredd_hooks.before('/orders/{order_id} > Deletes an existing order > 204')
def before_delete_order(transaction):
    transaction['fullPath'] = (
        '/orders/' + response_stash['created_order_id']
    )
    transaction['request']['uri'] = (
        '/orders/' + response_stash['created_order_id']
    )

① 我们创建一个在 GET /orders/{order_id} 端点测试之前被触发的 hook。

② 我们更改 GET /orders/{order_id} 端点测试的 URL,以包含我们之前创建的订单的 ID。

使用 Dredd hooks 在测试前创建资源

DELETE /orders/{order_id} 端点从数据库中删除订单,因此我们无法使用相同的订单 ID 来测试 /orders/{order_id}/pay/orders/{order_id}/cancel 端点。相反,我们将在测试这些端点之前使用 hooks 来创建新的订单。列表 12.4 展示了如何完成这个任务。列表 12.4 中的代码放入了 orders/hooks.py 文件。新的代码以粗体显示,而之前列表中的代码使用省略号省略。

要创建新的订单,我们将使用requests库调用 POST /orders端点,这使得发送 HTTP 请求变得容易。要启动一个 POST 请求,我们使用requestspost()函数,传入请求的目标 URL 和创建订单所需的 JSON 有效负载。在这种情况下,我们硬编码服务器基本 URL 为 http://127.0.0.1:8000,但如果你想在不同的环境中运行测试套件,你可能希望使这个值可配置。一旦我们创建了订单,我们就从响应有效负载中获取其 ID,并使用 ID 来修改transactionfullPath属性及其requesturi属性。

列表 12.4 使用 before 钩子在测试前创建资源

# file: orders/hooks.py

import json
import dredd_hooks
import requests                                                   ①

response_stash = {}

[...]

@dredd_hooks.before(
    '/orders/{order_id}/pay > Processes payment for an order > 200 > '
    'application/json'
)
def before_pay_order(transaction):
    response = requests.post(                                     ②
        "http://127.0.0.1:8000/orders",
        json={
            "order": [{"product": "string", "size": "small", "quantity":1}]
        },
    )
    id_ = response.json()['id']                                   ③
    transaction['fullPath'] = '/orders/' + id_ + '/pay'           ④
    transaction['request']['uri'] = '/orders/' + id_ + '/pay'

@dredd_hooks.before(
    '/orders/{order_id}/cancel > Cancels an order > 200 > application/json'
)
def before_cancel_order(transaction):
    response = requests.post(
        "http://127.0.0.1:8000/orders",
        json={
            "order": [{"product": "string", "size": "small", "quantity":1}]
        },
    )
    id_ = response.json()['id']
    transaction['fullPath'] = '/orders/' + id_ + '/cancel'
    transaction['request']['uri'] = '/orders/' + id_ + '/cancel'

① 我们导入 requests 库。

② 我们放置一个新的订单。

③ 我们获取新创建订单的 ID。

④ 我们通过将之前创建的订单 ID 包含在 URL 中来更改 POST /orders/{order_id}/pay 端点测试的 URL。

使用钩子生成 422 响应

订单 API 中的一些端点接受请求有效负载或 URL 路径参数。如果 API 客户端发送无效的有效负载或使用无效的 URL 路径参数,API 将响应一个 422 状态码。如我们之前所见,Dredd 不知道如何从服务器生成 422 响应,因此我们将为这种情况创建钩子。

如你在列表 12.5 中看到的,我们只需要两个函数:

  • fail_create_order()在请求到达服务器之前拦截对 POST /orders端点的请求,并修改其有效负载中的size属性为无效值。

  • fail_target_specific_order()通过使用无效标识符修改订单的 URI。由于我们知道 Dredd 使用我们在 API 规范中提供的示例 ID 来触发这个测试,我们只需将那个 ID 替换为无效值。order_id路径参数的类型是 UUID,所以通过将其替换为整数,服务器将响应 422 状态码。

这些钩子是测试服务器对不同类型有效负载和参数的行为的好机会,如果你需要,你可以为每个端点创建特定的测试以获得更全面的测试覆盖率。

列表 12.5 使用 Dredd 钩子生成 422 响应

# file: orders/hooks.py

@dredd_hooks.before('/orders > Creates an order > 422 > application/json')
def fail_create_order(transaction):
    transaction["request"]["body"] = json.dumps(
        {"order": [{"product": "string", "size": "asdf"}]}
    )

@dredd_hooks.before(
    "/orders/{order_id} > Returns the details of a specific order > 422 > "
    "application/json"
)
@dredd_hooks.before(
    "/orders/{order_id}/cancel > Cancels an order > 422 > application/json"
)
@dredd_hooks.before(
    "/orders/{order_id}/pay > Processes payment for an order > 422 > "
    "application/json"
)
@dredd_hooks.before(
    "/orders/{order_id} > Replaces an existing order > 422 > "
    "application/json"
)
@dredd_hooks.before(
    "/orders/{order_id} > Deletes an existing order > 422 > "
    "application/json"
)
def fail_target_specific_order(transaction):
    transaction["fullPath"] = transaction["fullPath"].replace(
        "d222e7a3-6afb-463a-9709-38eb70cc670d", "8"
    )
    transaction["request"]["uri"] = transaction["request"]["uri"].replace(
        "d222e7a3-6afb-463a-9709-38eb70cc670d", "8"
    )

使用自定义钩子运行 Dredd

现在我们有了 Dredd 钩子来确保每个 URL 都是正确形成的,我们可以再次运行 Dredd 测试套件。以下命令显示了如何使用钩子文件运行 Dredd:

$ ./node_modules/.bin/dredd oas.yaml http://127.0.0.1:8000 --server \ 
 "uvicorn orders.app:app" --hookfiles=./hooks.py --language=python

如你所见,我们只需使用--hookfiles标志传递我们钩子文件的路径。我们还需要使用--language标志指定钩子编写的语言。如果你现在运行这个命令,你会看到所有测试都通过了。

12.2.4 在你的 API 测试策略中使用 Dredd

Dredd 是一个用于测试 API 实现的出色工具,但其测试套件有限。Dredd 只测试每个端点的成功路径。例如,为了测试 /orders 端点的 POST 请求,Dredd 只向端点发送有效的有效载荷并期望它被正确处理。它不会发送格式错误的载荷,因此仅使用 Dredd,我们不知道服务器在这些情况下会如何反应。在我们服务的早期开发阶段,我们不想被 API 层面所吸引时,这是可以接受的。

然而,在我们发布代码之前,我们必须确保它在所有情况下都能按预期工作,并且为了运行超出成功路径的测试,我们需要使用不同的库:schemathesis。我们将在第 12.4 节中了解 Schemathesis,但在我们这样做之前,我们需要了解 Schemathesis 所使用的核心测试方法:基于属性的测试。这就是我们下一节的主题,所以继续学习更多关于它的内容!

12.3 基于属性的测试简介

本节解释了什么是基于属性的测试,它是如何工作的,以及它是如何帮助我们为我们的 API 编写更全面的测试的。在这个过程中,你还将了解 Python 的出色基于属性的测试库 hypothesis。正如你将看到的,基于属性的测试帮助我们为 API 创建健壮的测试套件,使我们能够轻松地生成具有多个属性和类型组合的数百个测试用例。本节为本章即将到来的部分铺平了道路,我们将学习 Schemathesis,这是一个使用基于属性的测试的 API 测试框架。

12.3.1 什么是基于属性的测试?

正如你在图 12.6 中可以看到的,基于属性的测试是一种测试策略,其中我们向我们的代码提供测试数据,并设计我们的测试来对我们代码运行结果的属性提出主张。¹ 通常,基于属性的框架会根据我们定义的一组条件为我们生成测试用例。

图 12.6 在基于属性的测试中,我们使用一个框架为我们生成函数的测试用例,并对我们在这些情况下运行代码的结果提出断言。

定义 基于属性的测试是一种测试方法,其中我们对我们函数或方法的返回值的属性提出主张。我们不是手动编写大量具有各种输入的不同测试,而是让框架为我们生成输入,并定义我们期望我们的代码如何处理它们。在 Python 中,一个出色的基于属性的测试库是 Hypothesis (github.com/HypothesisWorks/hypothesis)。

12.3.2 API 测试的传统方法

假设我们想要测试我们的 POST /orders 端点以确保它只接受有效的有效负载。正如您从 ch012/orders/oas.yaml 文件中订单 API 的 OpenAPI 规范中可以看到,POST /orders 端点的有效有效负载包含一个名为 order 的键,它表示一个有序项的数组。每个项目有两个必需的键:productsize

列表 12.6 POST /orders 端点的请求有效负载模式

# file: orders/oas.yaml

components:
  schemas:
    OrderItemSchema:
      type: object
      additionalProperties: false
      required:
        - product
        - size
      properties:
        product:
          type: string
        size:
          type: string
          enum:
            - small
            - medium
            - big
        quantity:
          type: integer
          format: int64
          default: 1
          minimum: 1

    CreateOrderSchema:
      type: object
      additionalProperties: false
      required:
        - order
      properties:
        order:
          type: array
          minItems: 1
          items:
            $ref: '#/components/schemas/OrderItemSchema'

在传统方法中,我们会手动编写各种有效负载,然后将它们提交到 POST /orders 端点,并为每个有效负载编写预期的结果。列表 12.7 展示了如何使用两种不同的有效负载测试 POST /orders 端点。如果您想尝试列表 12.7 中的代码,请创建一个名为 orders/test.py 的文件,并使用以下命令运行测试:pytest test.py

在列表 12.7 中,我们定义了两个测试用例:一个缺少订单项必需的 size 属性的无效有效负载,另一个是有效有效负载。在两种情况下,我们使用 FastAPI 的测试客户端将有效负载发送到我们的 API 服务器,并通过检查响应的状态码来测试服务器的行为。我们期望无效有效负载的响应携带 422 状态码(不可处理实体),而有效有效负载的响应携带 201 状态码(已创建)。FastAPI 使用 pydantic 验证我们的有效负载,并且它会自动为格式错误的有效负载生成 422 响应。因此,这个测试旨在验证我们的 pydantic 模型是否正确实现。

列表 12.7 使用不同有效负载测试 POST /orders 端点

# file: orders/test.py

from fastapi.testclient import TestClient                        ①

from orders.app import app

test_client = TestClient(app=app)                                ②

def test_create_order_fails():                                   ③
    bad_payload = {
        'order': [{'product': 'coffee'}]                         ④
    }
    response = test_client.post('/orders', json=bad_payload)     ⑤
    assert response.status_code == 422                           ⑥
def test_create_order_succeeds():
    good_payload = {
        'order': [{'product': 'coffee', 'size': 'big'}]          ⑦
    }
    response = test_client.post('/orders', json=good_payload)
    assert response.status_code == 201                           ⑧

① 我们导入 FastAPI 的 TestClient 类。

② 我们实例化测试客户端。

③ 我们创建一个测试。

④ 我们为 POST /orders 端点定义一个无效的有效负载。

⑤ 我们测试有效负载。

⑥ 我们确认响应状态码是 422。

⑦ 我们为 POST /orders 端点定义一个有效的有效负载。

⑧ 我们确认响应状态码是 201。

12.3.3 使用 Hypothesis 进行基于属性的测试

列表 12.7 中所示的传统测试策略,即手动编写所有测试用例,是 API 测试中的一种常见方法。这种方法的问题在于,除非我们愿意花费大量时间编写详尽的测试套件,否则它相当有限。列表 12.7 中的测试套件远非完整:它没有测试如果 size 属性包含无效值,或者如果 quantity 属性具有负值,或者如果订单项列表为空时会发生什么。

为了更全面地测试 API,我们希望使用一个能够生成所有可能类型的有效负载并对其 API 服务器进行测试的框架。这正是基于属性的测试允许我们做到的。在 Python 中,我们可以借助出色的 hypothesis 库运行基于属性的测试。

假设使用策略的概念来生成测试数据。例如,如果我们想生成随机整数,我们使用假设的integers()策略,如果我们想生成文本数据,我们使用假设的text()策略。假设的策略公开了一个名为example()的方法,您可以使用它来了解它们产生的值。您可以通过在 Python shell 中(由于假设产生随机值,您将在 shell 中看到不同的结果)玩弄它们来获得假设策略的工作感觉:

>>> from hypothesis import strategies as st
>>> st.integers().example()
0
>>> st.text().example()
'r'

如图 12.7 所示,假设还允许我们使用pipe运算符(|)组合各种策略。例如,我们可以定义一个生成整数或文本的策略:

>>> strategy = st.integers() | st.text()
>>> strategy.example()
-2781

图片

图 12.7 我们可以将各种假设策略组合成一个。结果策略将随机从任何组合策略中产生一个值。

为了使用假设测试 POST /orders端点,我们想要定义一个生成具有随机值的字典的策略。为了处理字典,我们可以使用假设的dictionaries()fixed_dictionaries()策略。例如,如果我们想生成一个包含两个键的字典,例如productsize,其中每个键可以是整数或文本,我们将使用以下声明:

>>> strategy = st.fixed_dictionaries(
    {
        "product": st.integers() | st.text(),
        "size": st.integers() | st.text(),
    }
)

>>> strategy.example()
{'product': -7958791642907854994, 'size': 16875}

12.3.4 使用假设来测试 REST API 端点

让我们将所有这些内容组合起来,为the POST /orders端点创建一个实际的测试。首先,让我们定义一个策略,用于我们负载中属性可以采取的所有值。为了说明目的,我们将保持简单,并假设属性只能是 null、布尔值、文本或整数:

>>> values_strategy = (
        st.none() |
        st.booleans() |
        st.text() |
        st.integers()
)

现在,让我们定义一个表示订单项的模式的策略。为了简化,我们使用一个具有有效键的固定字典,即productsizequantity。由于size属性只能取自一个枚举值,其选择为smallmediumbig,我们定义一个策略,允许假设从该枚举值或我们之前定义的values_strategy策略中选择一个值:

>>> order_item_strategy = st.fixed_dictionaries(
    {
        "product": values_strategy,
        "size": st.one_of(st.sampled_from(("small", "medium", "big")))
        | values_strategy,
        "quantity": values_strategy,
    }
)

最后,如图 12.8 所示,我们将所有这些内容整合成一个针对CreateOrderSchema模式的策略。从列表 12.4 中,我们知道CreateOrderSchema需要一个名为order的属性,其值是一个订单项的列表。使用假设,我们可以定义一个策略来生成用于测试CreateOrderSchema模式的负载,如下所示:

>>> strategy = st.fixed_dictionaries({
    'order': st.lists(order_item_strategy)
})
>>> strategy.example()
{'order': [{'product': None, 'size': 'small', 'quantity': None}]}

图片

图 12.8 通过结合假设的fixed_dictionaries()策略与lists()策略和values_strategy,我们可以生成类似于CreateOrderSchema模式的负载。

现在我们已经准备好将列表 12.6 中的测试套件重写为一个更通用和全面的 POST /orders 端点测试。列表 12.7 展示了如何将假设策略注入到测试函数中。列表 12.7 中的代码位于 orders/test.py 文件中。我在列表 12.7 中省略了一些变量的定义,例如 values_strategyorder_item_strategy,因为我们已经在之前的示例中遇到过它们。

列表 12.8 中的测试策略使用 jsonschema 库验证 Hypothesis 生成的有效载荷。为了使用 jsonschema 库验证有效载荷,我们首先加载 orders API 的 OpenAPI 规范,它位于 ch012/orders/oas.yaml 下。我们使用 pathlibPath().read_text() 方法读取文件内容,并使用 Python 的 yaml 库进行解析。为了检查有效载荷是否有效,我们创建了一个名为 is_valid_payload() 的实用函数,如果有效载荷有效则返回 True,否则返回 False

我们使用 jsonschemavalidate() 函数验证有效载荷,该函数需要两个参数:我们想要验证的有效载荷和我们想要验证的架构。由于 CreateOrderSchema 包含对 API 规范中另一个架构的引用,即 OrderItemSchema 架构,我们还提供了一个解析器,jsonschema 可以使用它来解析文档中其他架构的引用。如果有效载荷无效,jsonschemavalidate() 函数会引发 ValidationError,因此我们在 try/except 块中调用它,并根据结果返回 TrueFalse

为了将数据注入我们的测试函数中,Hypothesis 提供了 given() 装饰器,它接受一个 Hypothesis 策略作为参数,并使用它将测试用例提供给我们的测试函数。如果有效载荷有效,我们期望我们的 API 返回一个状态码为 201 的响应,而对于无效的有效载荷,我们期望一个 422 状态码。

列表 12.8 使用 hypothesis 运行针对 API 的基于属性的测试

# file: orders/test.py

from pathlib import Path

import hypothesis.strategies as st
import jsonschema
import yaml
from fastapi.testclient import TestClient
from hypothesis import given, Verbosity, settings
from jsonschema import ValidationError, RefResolver

from orders.app import app

orders_api_spec = yaml.full_load(
    (Path(__file__).parent / 'oas.yaml').read_text()                    ①
)
create_order_schema = ( orders_api_spec['components']['schemas']['CreateOrderSchema']      ②
)

def is_valid_payload(payload, schema):                                  ③
    try:
        jsonschema.validate(
            payload, schema=schema,
            resolver=RefResolver('', orders_api_spec)                   ④
        )
    except ValidationError:
        return False
    else:
        return True

test_client = TestClient(app=app)                                       ⑤

values_strategy = [...]

order_item_strategy = [...]

strategy = [...]

@given(strategy)                                                        ⑥
def test(payload):                                                      ⑦
    response = test_client.post('/orders', json=payload)                ⑧
    if is_valid_payload(payload, create_order_schema):                  ⑨
        assert response.status_code == 201
    else:
        assert response.status_code == 422

① 我们加载 API 规范。

② 指向 CreateOrderSchema 架构的指针

③ 用于确定有效载荷是否有效的辅助函数

④ 我们使用 jsonschema 的 validate() 函数验证有效载荷。

⑤ 我们实例化了测试客户端。

⑥ 我们将假设策略输入到我们的测试函数中。

⑦ 我们通过有效载荷参数捕获每个测试用例。

⑧ 我们将有效载荷发送到 POST /orders 端点。

⑨ 根据有效载荷是否有效,我们断言预期的状态码。

事实上,Hypothesis 非常适合根据 JSON Schema 模式生成数据集,并且已经有一个库可以将模式转换为 Hypothesis 策略,因此你不必自己来做这件事:hypothesis-jsonschema (github.com/Zac-HD/hypothesis-jsonschema)。我强烈建议你在尝试为测试 Web API 生成自己的 Hypothesis 策略之前查看这个库。现在我们了解了基于属性的测试是什么以及 Hypothesis 是如何工作的,我们就准备好学习 Schemathesis 了,这是我们下一节的主题!

12.4 使用 Schemathesis 测试 REST API

本节介绍了 Schemathesis,并解释了它是如何工作的以及我们如何使用它来测试 REST API。Schemathesis 是一个 API 测试框架,它使用基于属性的测试来验证我们的 API。它底层使用hypothesis库,并且得益于其方法,它能够运行比 Dredd 更全面的测试套件。一旦你准备将 API 发布到生产环境,我建议你使用 Schemathesis 来测试它们,以确保你覆盖了所有边缘情况。

12.4.1 运行 Schemathesis 的默认测试套件

在本节中,我们将通过运行其默认测试套件来熟悉 Schemathesis。由于我们在 12.1 节中已经安装了依赖项,我们只需要cd到 orders 文件夹并运行pipenv shell来激活我们的环境。与 Dredd 不同,Schemathesis 要求你在运行测试套件之前启动你的 API 服务器。你可以通过在新终端窗口中打开并运行服务器,或者通过以下命令启动服务器并将其推送到后台来启动服务器:

$ uvicorn orders.app:app &

&符号将进程推送到后台。然后你可以使用以下命令运行 Schemathesis:

$ schemathesis run oas.yaml --base-url=http://localhost:8000 \
--hypothesis-database=none

Hypothesis,Schemathesis 用来生成测试用例的库,创建了一个名为.hypothesis/的文件夹,其中缓存了一些测试。根据我的经验,Hypothesis 的缓存有时会导致后续测试执行中的误导性结果,所以直到这个问题得到修复之前,我的建议是避免缓存测试。我们设置--hypothesis-database标志为none,这样 Schemathesis 就不会缓存测试用例。

执行命令后,你会看到 Schemathesis 针对 API 运行了大约 700 个测试,测试所有可能的参数、类型和格式的组合。所有测试都应该正确通过。一旦 Schemathesis 完成,你可以通过运行fg命令将 Uvicorn 进程带到前台,如果你希望停止它的话。(我确信你知道,但请记住,要停止进程,你需要使用 Ctrl-C 键组合)。

12.4.2 使用链接增强 Schemathesis 的测试套件

我们刚刚使用 Schemathesis 运行的测试套件有一个主要限制:它没有测试 POST /orders 端点是否正确创建订单,也没有测试我们是否可以在订单上执行预期的操作,例如支付和取消。它只是向订单 API 中的每个端点发送独立且无关的请求。为了检查我们是否正确创建了资源,我们需要通过链接增强我们的 API 规范。正如您在图 12.9 中所见,在 OpenAPI 标准 中,链接是声明,允许我们描述不同端点之间的关系。²

图 12.9 在 OpenAPI 中,我们可以使用链接来描述端点之间的关系。例如,POST /orders 的响应包含一个 id 属性,我们可以使用它来替换 /orders/{order_id} URL 中的 order_id 参数。

例如,使用链接,我们可以指定 POST /orders 端点返回一个包含 ID 的有效负载,并且我们可以使用该 ID 来形成在 GET /orders/{order_id} 端点下刚刚创建的订单的资源 URL。我们使用操作 ID 来描述端点之间的关系。正如我们在第五章(5.3 节)中学到的,操作 ID 是 API 中每个端点的唯一标识符。列表 12.9 展示了我们是如何通过一个描述 POST /orders 端点和 GET /orders/{order_id} 端点之间关系的链接来增强订单 API 的。有关链接的完整列表,请参阅本书 GitHub 仓库中的 ch12/orders/oas_with_links.yaml 文件。省略号用于隐藏与示例无关的代码部分,新添加的代码以粗体显示。

在列表 12.9 中,我们将 POST /orders 和 GET /order/{order_id} 端点之间的链接命名为 GetOrderGetOrderoperationId 属性标识了这个链接所引用的端点(getOrder)。GET /order/{order_id} 端点有一个名为 order_id 的 URL 参数,而 GetOrderparameters 属性告诉我们,POST /orders 端点的响应体包含一个 id 属性,我们可以使用它来替换 GET /order/{order_id} 端点中的 order_id

列表 12.9 在 OpenAPI 中添加链接以在端点之间创建关系的示例

# file: orders/oas.yaml

paths:
  /orders:
    get:
      [...]

    post:
      operationId: createOrder
      summary: Creates an order
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateOrderSchema'
      responses:
        '201':
          description: A JSON representation of the created order
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/GetOrderSchema'
          links:                                            ①
            GetOrder:
              operationId: getOrder                         ②
              parameters:
                order_id: '$response.body#/id'              ③
              description: >                                ④
                The `id` value returned in the response can be used as
                the `order_id` parameter in `GET /orders/{order_id}`
            [...]

  /orders/{order_id}:
    [...]
    get:
      operationId: getOrder
      [...]

① 我们为 POST /orders 端点添加了链接。

② 我们使用 GET /orders/{order_id} 端点定义了一个链接。

③ 在 getOrder 端点中,可以将 order_id URL 参数替换为响应有效负载的 id 属性。

④ 我们将解释这个链接是如何工作的。

现在我们可以运行 Schemathesis 并通过以下命令利用我们的链接:

$ schemathesis run oas_with_link.yaml --base-url=http://localhost:8000 \
--stateful=links

--stateful=links 标志指示 Schemathesis 在我们的文档中查找链接,并使用它们通过 POST /orders 端点创建的资源运行测试。如果你现在运行 Schemathesis,你会看到它针对 API 运行超过一千个测试。由于 Schemathesis 生成随机测试,测试用例的确切数量可能因时而异。列表 12.10 展示了使用 --stateful 参数设置为 links 运行 Schemathesis 测试套件后的输出。列表省略了测试套件的前几行,因为它们只包含系统特定的元数据。请注意,一些测试似乎嵌套在 POST /orders 端点内部(以 -> 符号开始的行)。嵌套测试是利用我们 API 文档中的链接进行的测试。如果 POST /orders 端点的链接测试通过,我们可以确信我们的资源正在正确创建。

列表 12.10 Schemathesis 测试套件的输出

[...]
Base URL: http://localhost:8000                                             ①
Specification version: Open API 3.0.3                                       ②
Workers: 1                                                                  ③
Collected API operations: 7                                                 ④

GET /orders .                                                        [ 14%] ⑤
POST /orders .                                                       [ 28%]
    -> GET /orders/{order_id} .                                      [ 37%] ⑥
    -> PUT /orders/{order_id} .                                      [ 44%]
    -> DELETE /orders/{order_id} .                                   [ 50%]
    -> POST /orders/{order_id}/cancel .                              [ 54%]
    -> POST /orders/{order_id}/pay .                                 [ 58%]
GET /orders/{order_id} .                                             [ 66%]
PUT /orders/{order_id} .                                             [ 75%]
DELETE /orders/{order_id} .                                          [ 83%]
POST /orders/{order_id}/pay .                                        [ 91%]
POST /orders/{order_id}/cancel .                                     [100%]

================================ SUMMARY ==================================

Performed checks:
    not_a_server_error        1200 / 1200 passed          PASSED            ⑦
========================== 12 passed in 57.57s ============================

① 服务器的基准 URL

② 我们服务器使用的 OpenAPI 版本

③ 并行运行测试套件的进程数量

④ API 规范中定义的操作数量

⑤ 测试 GET /orders 端点

⑥ 测试与 POST /orders 端点关联的 GET /orders/{order_id} 端点

⑦ 测试套件运行了 1,200 个测试,并且全部通过。

上一个测试的输出表明,我们的 API 在 not_a_ server_error 类别中通过了所有检查。默认情况下,Schemathesis 只检查 API 不会引发服务器错误,但它可以被配置为验证我们的 API 是否使用 API 规范中记录的正确状态码、内容类型、头和模式。要应用所有这些检查,我们使用 --checks 标志并将其设置为 all

$ schemathesis run oas_with_link.yaml --base-url=http://localhost:8000 \
--hypothesis-database=none --stateful=links --checks=all

如你所见,这次 Schemathesis 每次检查运行超过一千个测试用例:

================================ SUMMARY ==================================

Performed checks:
    not_a_server_error              1200 / 1200 passed          PASSED
    status_code_conformance         1200 / 1200 passed          PASSED
    content_type_conformance        1200 / 1200 passed          PASSED
    response_headers_conformance    1200 / 1200 passed          PASSED
    response_schema_conformance     1200 / 1200 passed          PASSED

========================== 12 passed in 70.54s ============================

在某些情况下,Schemathesis 可能会抱怨生成测试用例花费的时间太长。你可以通过使用 --hypothesis-suppress-health-check=too_slow 标志来抑制该警告。通过针对你的 API 运行整个 Schemathesis 检查集,你可以确信它按预期工作并符合 API 规范。如果你想要通过额外的自定义有效载荷或场景扩展测试,你也可以这样做。由于 schemathesis 是一个 Python 库,添加额外的自定义测试非常容易。请查看文档以获取如何做到这一点的示例(mng.bz/69Q5)。

这标志着我们通过测试 REST API 的旅程结束。现在是时候进入 GraphQL API 测试的世界了,这是下一节的主题!

12.5 测试 GraphQL API

本节解释了如何测试和验证 GraphQL API,以确保在将它们发布到生产环境之前它们按预期工作。我们将使用在第十章中实现的商品 API 作为指导示例。为了完成本节中的示例,请使用 cd 命令进入 ch12/products 目录,并通过运行 pipenv shell 激活环境。

在第 12.2 节和第 12.4 节中,我们学习了 Dredd 和 Schemathesis,它们可以根据 API 规范自动生成 REST API 的测试。对于 GraphQL,自动测试生成的支持较少。特别是,Dredd 不支持 GraphQL API,而 Schemathesis 只提供部分支持。然而,这是一个活跃的开发领域,因此预计未来将看到对自动 GraphQL 测试的支持不断增加。

12.5.1 使用 Schemathesis 测试 GraphQL API

本节解释了如何使用 Schemathesis 测试和验证 GraphQL API。正如我们在第 12.4 节中解释的那样,Schemathesis 是一个 API 测试框架,它使用一种称为基于属性的测试方法来验证我们的 API。Schemathesis 可以用于测试 REST 和 GraphQL API。在两种情况下,如图 12.10 所示,Schemathesis 会查看 API 规范以了解其端点和模式,并决定要运行哪些测试。

图片

图 12.10 Schemathesis 解析 GraphQL API 规范以查找可用操作,并生成包含有效和无效参数以及选择集的查询文档来测试服务器的响应。

要为 GraphQL API 生成测试,Schemathesis 使用 hypothesis-graphql (mng.bz/o5Pj),这是一个从 GraphQL 模式生成 Hypothesis 策略的库。在我们运行测试之前,我们需要启动 GraphQL API 服务器。您可以在不同的终端窗口中这样做,或者您可以使用以下命令在后台运行进程:

$ uvicorn server:server &

& 符号将 Uvicorn 进程推送到后台。要使用 Schemathesis 测试 GraphQL API,我们只需提供我们的 API 规范托管的位置的 URL。在我们的例子中,GraphQL API 托管在以下 URL 下:http://127.0.0.1:8000/graphql。有了这些信息,我们现在可以运行我们的测试:

$ schemathesis run --hypothesis-deadline=None http://127.0.0.1:8000/graphql

--hypothesis-deadline=None 标志指示 Schemathesis 避免对请求进行计时。这在我们的查询可能很慢的情况下很有用,有时 GraphQL API 就会发生这种情况。以下显示了测试套件的输出,省略了包含平台特定元数据的前几行。如图所示,Schemathesis 测试了商品 API 所公开的所有查询和突变,生成了一套非常坚实的测试:1,100 个测试用例!

列表 12.11 Schemathesis 测试套件对 GraphQL API 的输出

[...]
Schema location: http://127.0.0.1:8000/graphql
Base URL: http://127.0.0.1:8000/graphql
Specification version: GraphQL
Workers: 1
Collected API operations: 11
Query.allProducts .                                                  [  9%]
Query.allIngredients .                                               [ 18%]
Query.products .                                                     [ 27%]
Query.product .                                                      [ 36%]
Query.ingredient .                                                   [ 45%]
Mutation.addSupplier .                                               [ 54%]
Mutation.addIngredient .                                             [ 63%]
Mutation.addProduct .                                                [ 72%]
Mutation.updateProduct .                                             [ 81%]
Mutation.deleteProduct .                                             [ 90%]
Mutation.updateStock .                                               [100%]

================================ SUMMARY ==================================

Performed checks:
    not_a_server_error.           1100 / 1100 passed          PASSED

========================== 11 passed in 36.82s ============================

在对产品 API 运行 Schemathesis 测试套件后,我们可以确信我们的查询和突变按预期工作。你可以进一步自定义你的测试,以确保应用程序在特定条件下正确运行。要了解如何添加自定义测试用例,请查看 Schemathesis 的优秀文档(schemathesis.readthedocs.io/en/stable/)。

12.6 设计你的 API 测试策略

你在本章中学到了很多。你学会了如何使用 Dredd 和 Schemathesis 等框架,这些框架根据 API 文档对 API 运行自动测试套件。你还了解了基于属性的测试以及如何使用 Hypothesis 自动生成测试用例来测试你的 REST 和 GraphQL API。

正如我们在 12.2 节中看到的,Dredd 对你的 API 运行一个简单的测试套件。Dredd 只测试快乐路径:它确保你的 API 接受预期的有效载荷并响应预期的有效载荷。它不测试当错误的有效载荷发送到你的服务器时会发生什么。

Dredd 的测试策略在 API 的早期开发阶段非常有用,当你想要专注于应用程序的整体功能而不是陷入 API 集成的特定边缘情况时。然而,在你将 API 发布到生产之前,你想要确保你的 API 已经用 Schemathesis 进行了测试。Schemathesis 运行一个更全面的测试套件,确保你的 API 精确地按预期工作。

我建议你在开发过程中本地运行 Dredd 和 Schemathesis,并在发布代码之前在你的持续集成(CI)服务器上运行。关于如何将 Dredd 和 Schemathesis 集成到你的 CI 服务器中的示例,请查看我在 Manning 的 API 会议上的演讲,“API 开发工作流程以实现成功集成”(2021 年 8 月 3 日,youtu.be/SUKqmEX_uwg)。

你在本章中学到的一些技术和技能仍然非常新颖和实验性,所以你在团队和就业市场上具有优势。明智地使用你的新力量!

摘要

  • Dredd 和 Schemathesis 是 API 测试工具,可以从文档中自动生成 API 的验证测试。这有助于你避免手动编写测试的努力,并专注于构建你的 API 和服务。

  • Dredd 是一个 REST API 测试框架。它对你的 API 运行一个基本的测试套件,不涵盖边缘情况,因此在 API 周期的早期阶段非常方便。

  • 你可以通过向你的测试中添加 Dredd 钩子来自定义 Dredd 的行为。尽管 Dredd 是一个 npm 包,但你可以用 Python 编写你的钩子。Dredd 钩子对于从一次测试中保存信息以供另一次测试重用,以及在每次测试前后创建或删除资源非常有用。

  • Schemathesis 是一个更通用的 API 测试框架,它会对您的 API 运行详尽的测试套件。在将 API 发布到生产环境之前,您想要确保已经使用 Schemathesis 测试了它们。您可以使用 Schemathesis 来测试 REST 和 GraphQL API。

  • 为了测试您的 POST 端点是否正确创建资源,您可以通过添加链接来丰富您的 OpenAPI 规范,并指导 Schemathesis 在测试套件中使用它们。链接是描述 OpenAPI 规范中不同操作之间关系的属性。

  • 基于属性的测试是一种方法,其中您让框架生成随机测试用例,并通过对测试结果属性进行断言来验证您代码的行为。这种方法可以节省您手动编写测试用例的时间。在 Python 中,您可以使用出色的hypothesis库运行基于属性的测试。


¹ 关于基于属性的测试的更详细解释,请参阅 David R. MacIver 的优秀文章:“什么是基于属性的测试?”,hypothesis.works/articles/what-is-property-based-testing/

² 为了了解链接如何工作以及如何在您的 API 文档中利用它们,请参阅swagger.io/docs/specification/links/

13 Docker 化微服务 API

本章涵盖了

  • 如何 Docker 化一个应用程序

  • 如何运行 Docker 容器

  • 如何使用 Docker Compose 运行应用程序

  • 将 Docker 镜像发布到 AWS 弹性容器注册库

Docker 是一种虚拟化技术,它允许我们通过简单地拥有 Docker 执行运行时在任何地方运行我们的应用程序。Docker 去除了调整和配置环境以运行代码所需的所有痛苦和努力。它还使部署更加可预测,因为它产生可复制的工件(容器镜像),我们可以在本地以及云中运行。

在本章中,你将学习如何将 Python 应用程序 Docker 化。Docker 化是将应用程序打包成 Docker 镜像的过程。你可以将 Docker 镜像视为一个构建或工件,它已准备好部署和执行。要执行镜像,Docker 会创建镜像的运行实例,称为 容器。为了部署 Docker 镜像,我们通常使用容器编排器,如 Kubernetes,它负责管理容器的生命周期。在下一章中,你将学习如何使用 Kubernetes 部署 Docker 构建。我们将演示如何使用 CoffeeMesh 平台的订单服务来 Docker 化应用程序。你还将学习如何通过将镜像上传到 AWS 的弹性容器注册库(ECR)来发布你的 Docker 构建。

所有代码示例都可在本书 GitHub 仓库的 ch13 文件夹中找到。我们将从设置环境开始,以便在 13.1 节中处理本章内容。

13.1 设置本章环境

在本节中,我们设置环境,以便你可以跟随本章其余部分的示例。我们继续实现订单服务,其中我们在第十一章中停止,那时我们添加了身份验证和授权层。首先,将第十一章的代码复制到一个名为 ch13 的新文件夹中:

$ cp -r ch11 ch13

在 ch13 中 cd,并运行以下命令安装依赖项并激活虚拟环境:

$ cd ch13 && pipenv install --dev && pipenv shell

当我们部署应用程序时,我们使用 PostgreSQL 引擎,这是在生产环境中运行应用程序最受欢迎的 SQL 引擎之一。为了与数据库通信,我们使用 psycopg2,这是 Python 最受欢迎的 PostgreSQL 驱动之一:

$ pipenv install psycopg2

安装 PSYCOPG2 如果你在安装和编译 psycopg2 时遇到问题,尝试通过运行 pipenv install psycopg2-binary 安装编译好的包,或者从本书的 GitHub 仓库中拉取 ch13/Pipfile 和 ch13/Pipfile.lock,然后运行 pipenv install --dev。还有两个强大的 PostgreSQL 驱动程序是 asyncpg (github.com/MagicStack/asyncpg) 和 pscycopg3 (github.com/psycopg/psycopg),它们都支持异步操作。我鼓励你检查它们!

要构建和运行 Docker 容器,您需要在您的机器上安装 Docker 运行时。安装说明因平台而异,请参阅官方文档了解如何在您的系统上安装 Docker (docs.docker.com/get-docker/)。

由于我们打算将 Docker 镜像发布到 AWS 的 ECR,我们需要安装 AWS CLI:

$ pipenv install --dev awscli

接下来,访问 aws.amazon.com/。创建一个 AWS 账户并获取一个访问密钥,以便能够以编程方式访问 AWS 服务。您用于创建 AWS 账户的用户配置文件是账户的根用户。出于安全考虑,建议您不要使用根用户生成您的访问密钥。相反,创建一个 IAM 用户并为该用户生成一个访问密钥。IAM 是 AWS 的身份访问管理服务,它允许您创建用户、角色和细粒度的策略,以授予对您账户中其他服务的访问权限。按照 AWS 文档了解如何创建 IAM 用户 (mng.bz/neP8) 以及如何生成您的访问密钥和配置 AWS CLI (mng.bz/vXxq)。

现在我们已经准备好了环境,是时候将我们的应用程序 Docker 化了!

13.2 Docker 化微服务

将应用程序 Docker 化意味着什么?Docker 化是将应用程序打包成 Docker 镜像的过程。您可以将 Docker 镜像想象为一个可以在 Docker 运行时部署和执行的构建或工件。所有系统依赖项都已安装在 Docker 镜像中,要运行镜像,我们只需要一个 Docker 运行时。要执行镜像,Docker 运行时会创建一个容器,这是镜像的运行实例。如图 13.1 所示,使用 Docker 非常方便,因为它允许我们在隔离进程中运行我们的应用程序。根据您的平台,安装 Docker 运行时有不同的选项,请参阅官方文档以确定哪个选项最适合您 (docs.docker.com/get-docker/)。

图 13.1 Docker 容器在主机操作系统之上运行在隔离进程中。

在本节中,我们创建了一个优化后的订单服务的 Docker 镜像。在这个过程中,您将学习如何编写 Dockerfile,这是一个包含构建 Docker 镜像所需所有指令的文档。您还将学习如何运行 Docker 容器,以及如何将容器端口映射到主机操作系统,以便您能够与容器内运行的应用程序交互。最后,您还将学习如何使用 Docker CLI 管理容器。

Docker 基础知识 如果你想了解更多关于 Docker 如何工作以及它与宿主操作系统的交互方式,请查看 Prabath Siriwardena 和 Nuwan Dias 在他们所著的《Microservices Security in Action》(Manning,2020)一书中优秀的“Docker 基础知识”部分。mng.bz/49Ag

在构建镜像之前,我们需要对我们的应用程序代码进行两项小的修改,以便为部署做好准备。到目前为止,订单服务一直在使用硬编码的数据库 URL,但为了在不同的环境中运行服务,我们需要使这个设置可配置。以下代码展示了需要修改orders/repository/unit_of_work.py文件以从环境中获取数据库 URL 的更改,其中新添加的代码以粗体字符显示。我们使用断言语句,如果未提供数据库 URL,则立即退出应用程序。

列表 13.1 从环境中获取数据库 URL

# file: orders/repository/unit_of_work.py

import os

from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker

DB_URL = os.getenv('DB_URL')                                             ①

assert DB_URL is not None, 'DB_URL environment variable needed.'         ②

class UnitOfWork:
    def __init__(self):
        self.session_maker = sessionmaker(bind=create_engine(DB_URL))    ③

    def __enter__(self):
        self.session = self.session_maker()
        return self

    ...

① 从 DB_URL 环境变量中获取数据库 URL。

② 如果未设置 DB_URL,则退出应用程序。

③ 使用 DB_URL 的值连接到数据库。

我们还需要更新我们的 Alembic 文件,以便从环境中获取数据库 URL。以下代码展示了需要修改migrations/env.py以实现此目的的更改,其中新添加的代码以粗体显示。我们使用省略号省略了代码的非相关部分,以便更容易观察更改。

列表 13.2 从环境中获取alembic的数据库 URL

# file: migrations/env.py

import os
from logging.config import fileConfig

from sqlalchemy import create_engine
from sqlalchemy import pool

from alembic import context

...
def run_migrations_online():
    """...
    """

    url = os.getenv('DB_URL')                                        ①

    assert url is not None, 'DB_URL environment variable needed.' ②

    connectable = create_engine(url)

    context.configure(
        url=url,
        target_metadata=target_metadata,
        literal_binds=True,
        dialect_opts={"paramstyle": "named"},
    )

    ...

① 从 DB_URL 环境变量中获取数据库 URL。

② 如果未设置 DB_URL,则退出应用程序。

现在代码已经准备好了,是时候将其 Docker 化了!要构建一个 Docker 镜像,我们需要编写一个 Dockerfile。创建一个名为 Dockerfile 的文件。表 13.3 显示了该文件的包含内容。我们使用官方 Python 3.9 Docker 镜像的精简版作为我们的基础镜像。精简镜像只包含运行我们的应用程序所需的依赖项,这导致了更轻的镜像。要使用基础镜像,我们使用 Docker 的FROM指令。然后我们创建一个名为/orders/orders 的应用程序代码文件夹。要运行 bash 命令,例如本例中的mkdir,我们使用 Docker 的RUN指令。我们还使用 Docker 的WORKDIR指令将/orders/orders 设置为工作目录。工作目录是应用程序运行时的目录。

接下来,我们安装 pipenv,复制我们的 Pipenv 文件,并安装依赖项。我们使用 Docker 的 COPY 指令将文件从我们的文件系统复制到 Docker 镜像中。由于我们在 Docker 中运行,我们不需要虚拟环境,所以我们使用 pipenv--system 标志来安装依赖项。我们还使用 pipenv--deploy 标志来检查我们的 Pipenv 文件是否是最新的。最后,我们复制我们的源代码并指定需要执行的命令以启动订单服务。Docker 必须使用 Docker 的 CMD 指令来执行我们的应用程序。我们还使用 Docker 的 EXPOSE 指令确保正在运行的容器监听端口 8000,这是我们的 API 运行的端口。如果我们不暴露端口,我们就无法与 API 交互。

我们在 Dockerfile 中的语句顺序很重要,因为 Docker 会缓存构建的每个步骤。如果上一个步骤发生了变化,例如,如果我们安装了新的依赖项,或者我们的某个文件发生了变化,Docker 才会再次执行一个步骤。由于我们的应用程序代码可能比我们的依赖项变化更频繁,所以我们将在构建的最后复制代码。这样,Docker 将只安装一次依赖项并缓存该步骤,直到它们发生变化。

列表 13.3 订单服务的 Dockerfile

# file: Dockerfile

FROM python:3.9-slim                                         ①

RUN mkdir -p /orders/orders                                  ②

WORKDIR /orders                                              ③

RUN pip install -U pip && pip install pipenv

COPY Pipfile Pipfile.lock /orders/                           ④

RUN pipenv install --system --deploy                         ⑤

COPY orders/orders_service /orders/orders/orders_service/    ⑥
COPY orders/repository /orders/orders/repository/
COPY orders/web /orders/orders/web/
COPY oas.yaml /orders/
COPY public_key.pem /orders/public_key.pem
COPY private.pem /orders/private.pem

EXPOSE 8000                                                  ⑦

CMD ["uvicorn", "orders.web.app:app", "--host", "0.0.0.0"]   ⑧

① 基础镜像

② 我们应用程序的基本文件夹结构

③ 我们将运行代码的工作目录

④ 我们复制我们的 pipenv 文件。

⑤ 我们安装依赖项。

⑥ 我们复制应用程序的其他文件。

⑦ 我们将应用程序的端口暴露给主机机器。

⑧ API 服务器的启动命令

要从列表 13.3 构建 Docker 镜像,你需要从 ch13 目录运行以下命令:

$ docker build -t orders:1.0 .

-t 标志代表 标签。Docker 标签有两个部分:冒号左侧的镜像名称和冒号右侧的标签名称。标签名称通常是构建的版本。在这种情况下,我们命名镜像为 orders 并将其标记为 1.0。确保你不要漏掉构建语句末尾的点:它代表构建的源代码路径(在 Docker 术语中称为 上下文)。一个点表示当前目录。

一旦镜像构建完成,你可以使用以下命令来执行它:

$ docker run --env DB_URL=sqlite:///orders.db \
-v $(pwd)/orders.db:/orders/orders.db -p 8000:8000 -it orders:1.0

如你在图 13.2 中所见,--env 标志允许我们在容器中设置环境变量,我们用它来设置数据库的 URL。为了使应用程序对主机机器可访问,我们使用 -p 标志,它允许我们将容器内应用程序运行的端口绑定到主机机器上的端口。我们还使用 -v 标志将卷挂载到 SQLite 数据库文件上。Docker 卷允许容器从主机机器的文件系统中访问文件。

图片

图 13.2 当我们运行容器时,我们可以包含各种配置来在容器内设置环境变量,或者允许它访问宿主操作系统的文件。

您现在可以通过以下 URL 访问应用程序:http://127.0.0.1:8000/docs/orders。之前的命令执行了连接到您当前终端会话的容器,这使得您可以在与应用程序交互时看到日志展开。在这种情况下,您可以通过按 Ctrl-C 组合键来停止容器,就像停止任何其他进程一样。

您还可以以分离模式运行容器,这意味着进程没有连接到您的终端会话,因此当您关闭终端时,进程将继续运行。如果您只想运行容器与之交互,而不需要查看日志,这很方便。我们通常以分离模式运行容器化的数据库。要分离模式运行容器,您使用 -d 标志:

$ docker run -d –-env DB_URL=sqlite:///orders.db \
-v $(pwd)/orders.db:/orders/orders.db -p 8000:8000 orders:1.0

在这种情况下,您需要使用 dockerstop 命令停止容器。首先,您需要使用以下命令找出运行中容器的 ID:

$ docker ps

此命令将列出您机器上当前运行的所有容器。输出看起来像这样(输出已使用省略号截断):

CONTAINER ID   IMAGE       COMMAND       CREATED         STATUS...    
83e6189a02ee   orders:1.0  "uvicorn..."  7 seconds ago   Up 6 seconds

拿到容器 ID(在本例中为 83e6189a02ee),然后使用以下命令停止进程:

$ docker stop 83e6189a02ee

构建和运行 Docker 容器只需这些步骤!Docker 的功能远不止我们在本节中看到的,如果您想了解更多关于这项技术的信息,我推荐您阅读 Ian Miell 和 Aidan Hobson Sayers 所著的 Docker in Practice(Manning, 2019)以及 Jeff Nickoloff 和 Stephen Kuenzli 所著的 Docker in Action(Manning, 2019)。

13.3 使用 Docker Compose 运行应用程序

在上一节中,我们通过将其挂载到我们的本地 SQLite 数据库上来运行了订单服务的容器。这对于快速测试来说是可以的,但它并不能真正告诉我们我们的应用程序是否能够像预期的那样与 PostgreSQL 数据库一起工作。将我们的容器化应用程序连接到数据库的常见策略是使用 Docker Compose,它允许我们在共享网络中运行多个容器,这样它们就可以相互通信。在本节中,您将学习如何使用 docker-compose 运行带有 PostgreSQL 数据库的订单服务。

要使用 Docker Compose,首先我们需要安装它。它是一个 Python 包,所以我们使用 pip 来安装它:

$ pip install docker-compose

接下来,让我们编写我们的 Docker Compose 文件——声明我们运行应用程序所需的资源。列表 13.4 显示了订单服务的 docker-compose 文件。我们使用 Docker Compose 的最新规范格式,版本 3.9,并声明了两个服务:databaseapidatabase 运行 PostgreSQL 的官方 Docker 镜像,而 api 运行订单服务。我们使用 build 关键字指向 Docker 构建上下文,并给它一个点值(.)。通过使用点,我们指示 Docker Compose 在当前目录中查找 Dockerfile 并构建镜像。通过 environment 关键字,我们配置运行我们的应用程序所需的环境变量。我们暴露 database 的 5432 端口,以便我们可以从我们的主机机器连接到数据库,以及 api 的 8000 端口,以便我们可以访问 API。最后,我们使用一个名为 database-data 的卷,docker-compose 将使用它来持久化我们的数据。这意味着如果你重启 docker-compose,你不会丢失你的数据。

列表 13.4 为订单服务的 docker-compose 文件

# file: docker-compose.yaml

version: "3.9"                               ①

services:                                    ②

  database:                                  ③
    image: postgres:14.2                     ④
    ports:                                   ⑤
      - 5432:5432
    environment:                             ⑥
      POSTGRES_PASSWORD: postgres
      POSTGRES_USER: postgres
      POSTGRES_DB: postgres
    volumes:                                 ⑦
      - database-data:/var/lib/postgresql/data

  api:                                       ⑧
    build: .                                 ⑨
    ports:                                   ⑩
      - 8000:8000
    depends_on:                              ⑪
      - database
    environment:                             ⑫
      DB_URL: postgresql://postgres:postgres@database:5432/postgres

volumes:                                     ⑬
  database-data:

① 此文件的 docker-compose 格式版本。

② 我们声明我们的服务。

③ 数据库服务

④ 数据库服务的 Docker 镜像

⑤ 我们将数据库端口暴露给主机机器。

⑥ 数据库环境配置

⑦ 我们将数据库的数据文件夹挂载到本地卷上。

⑧ API 服务

⑨ API 的构建上下文

⑩ 我们将 API 的端口暴露给主机机器。

⑪ API 依赖于数据库。

⑫ API 的环境配置

⑬ 数据库的卷

执行以下命令以运行我们的 Docker Compose 文件:

$ docker-compose up --build

--build 标志指示 Docker Compose 在你的文件更改时重新构建你的镜像。一旦 Web API 启动并运行,你可以在 http://localhost:8000/docs/orders 上访问它。如果你尝试任何端点,你的表不存在。那是因为我们没有在我们的新 PostgreSQL 数据库上运行迁移!要运行迁移,打开一个新的终端窗口,cd 进入 ch13 文件夹,激活你的 pipenv 环境,并运行以下命令:

$ PYTHONPATH=`pwd` \
DB_URL=postgresql://postgres:postgres@localhost:5432/postgres alembic \
upgrade heads

一旦应用了迁移,你就可以再次点击 API 端点,一切应该都会正常工作。要停止 docker-compose,请在另一个终端窗口中,在 ch13 文件夹内运行以下命令:

$ docker-compose down

这就是运行 Docker Compose 所需的全部!你已经学会了使用最强大的自动化工具之一。Docker Compose 经常用于运行集成测试,并为在客户端应用程序(如 SPAs)上工作的开发者提供一个简单的方式来运行后端。

当我们的 Docker 栈准备就绪且我们的镜像经过测试后,是时候学习如何将镜像推送到容器注册库了。继续阅读下一节,了解如何操作!

13.4 将 Docker 构建发布到容器注册库

要部署我们的 Docker 构建,我们需要首先将它们发布到一个 Docker 容器注册库。容器注册库是 Docker 镜像的存储库。在下一章中,我们将部署我们的应用程序到 AWS 的 Elastic Kubernetes 服务,因此我们将我们的构建发布到 AWS 的 ECR。将我们的 Docker 镜像保留在 AWS 中将使它们部署到 EKS 更容易。

首先,让我们使用以下命令为我们的镜像创建一个 ECR 仓库:

$ aws ecr create-repository --repository-name coffeemesh-orders
{
    "repository": {
        "repositoryArn": 
➥ "arn:aws:ecr:<aws_region>:<aws_account_id>:repository/coffeemesh-orders",
        "registryId": "876701361933",
        "repositoryName": "coffeemesh-orders",
        "repositoryUri": 
➥ "<aws_account_id>.dkr.ecr.<aws_region>.amazonaws.com/coffeemesh-orders",
        "createdAt": "2021-11-16T10:08:42+00:00",
        "imageTagMutability": "MUTABLE",
        "imageScanningConfiguration": {
            "scanOnPush": false
        },
        "encryptionConfiguration": {
            "encryptionType": "AES256"
        }
    }
}

在此命令中,我们创建了一个名为 coffeemesh-orders 的 ECR 仓库。命令的输出是一个描述我们刚刚创建的仓库的有效负载。当你运行此命令时,输出有效负载中的 <aws_account_id> 占位符将包含你的 AWS 账户 ID,而 <aws_region> 将包含你的默认 AWS 区域。要将我们的 Docker 构建发布到 ECR,我们需要用 ECR 仓库的名称标记我们的构建。获取上一条命令输出中的 repository.repositoryArn 属性(加粗),并使用它来标记我们在 13.2 节中创建的 Docker 构建,如下所示:

$ docker tag orders:1.0 \
<aws_account_id>.dkr.ecr.<aws_region>.amazonaws.com/coffeemesh-orders:1.0

要将我们的镜像发布到 ECR,我们需要使用以下命令获取登录凭证:

$ aws ecr get-login-password --region <aws_region> | docker login \
--username AWS --password-stdin \ 
<aws_account_id>.dkr.ecr.<region>.amazonaws.com

确保将此命令中的 <aws_region> 替换为你创建 Docker 仓库的 AWS 区域,例如欧洲(爱尔兰)的 eu-west-1 或美国东部(俄亥俄州)的 us-east-2。同时将 <aws_account_id> 替换为你的 AWS 账户 ID。查看 AWS 文档了解如何找到你的 AWS 账户 ID (mng.bz/Qnye)。

AWS 区域 当你在 AWS 上部署服务时,你将它们部署到特定的区域。每个区域都有一个标识符,例如爱尔兰的 eu-west-1 或俄亥俄州的 eu-east-2。有关 AWS 中可用区域的最新列表,请参阅 mng.bz/XaPM

aws ecr get-login-password 命令生成一个 Docker 可以使用的指令来登录到 ECR。我们现在已经准备好发布我们的构建了!运行以下命令将镜像推送到 ECR:

$ docker push \
<aws_account_id>.dkr.ecr.<aws_region>.amazonaws.com/coffeemesh-orders:1.0

哇!我们的 Docker 构建现在已经在 ECR 中了。在下一章中,你将学习如何将此构建部署到 AWS 的 Kubernetes 集群。

概述

  • Docker 是一种虚拟化技术,它允许我们通过简单地拥有一个 Docker 执行运行时,在任何地方运行我们的应用程序。Docker 构建被称为镜像,它是在称为 Docker 容器的进程中执行的。

  • Docker Compose 是一个容器编排框架,它允许你同时运行多个容器,例如数据库和 API。使用 Docker Compose 是运行整个后端的一种简单而有效的方式,无需安装和配置额外的依赖项。

  • 要部署 Docker 镜像,我们需要将它们发布到一个容器注册库,例如 AWS 的 ECR——一个强大且安全的容器注册库,它使得将我们的容器部署到 AWS 服务变得容易。

14 使用 Kubernetes 部署微服务 API

本章涵盖了

  • 使用 AWS 的 Elastic Kubernetes Service (EKS) 创建集群

  • 使用 AWS Load Balancer Controller 暴露服务

  • 将服务部署到 Kubernetes 集群

  • 在 Kubernetes 中安全地管理机密

  • 部署 Aurora Serverless 数据库

Kubernetes 是一个开源的容器编排框架,它正在迅速成为跨平台部署和管理应用程序的标准方式。您可以自己将 Kubernetes 部署到自己的服务器上,或者您可以使用托管 Kubernetes 服务。在两种情况下,您都将获得对服务的统一接口,这意味着跨云提供商迁移对您的运营影响较小。您还可以在自己的机器上部署 Kubernetes 集群,并以与云中相同的方式在本地运行测试。

使用 minikube 在本地运行 kubernetes 您可以使用 minikube 在本地运行 Kubernetes 集群。尽管我们不会在本章中介绍它,但 minikube 是一个很好的工具,可以帮助您更熟悉 Kubernetes。请查看 minikube 的官方文档(minikube.sigs.k8s.io/docs/start/)。

自己部署 Kubernetes 是熟悉这项技术的良好练习,但在实践中,大多数公司都使用托管服务。在本章中,我们将使用 Kubernetes 托管服务来部署我们的集群。许多供应商提供 Kubernetes 托管服务。主要玩家包括 Google Cloud 的 Google Kubernetes Engine (GKE)、Azure 的 Kubernetes 服务 (AKS) 和 AWS 的 Elastic Kubernetes Service (EKS)。这三个服务都非常稳健,并提供类似的功能。¹ 在本章中,我们将使用 EKS,它是目前最受欢迎的托管 Kubernetes 服务。²

为了说明如何将应用程序部署到 Kubernetes 集群,我们将以订单服务为例。我们还将创建一个 Aurora Serverless 数据库,并展示如何使用 Kubernetes secrets 安全地将数据库连接凭证传递给服务。

本章不假设您对 AWS 或 Kubernetes 有先前的知识。我已经努力详细解释了 Kubernetes 和 AWS 的每个概念,以便您即使没有这两种技术的先前经验,也能跟随示例。关于这些主题已经写出了整本书,所以本章只是一个概述,并提供了一些其他资源的引用,您可以使用这些资源深入了解这些话题。

在继续之前,请记住,本章中使用的 EKS 和其他 AWS 服务是付费服务,所以这是本书中唯一一个如果你跟随示例可能会让你花费一些金钱的章节。在 AWS EKS 中的 Kubernetes 集群的基础费用是每小时 $0.10,相当于每天 $2.40,大约每月 $72。如果预算是个问题,我的建议是先阅读本章,了解我们在做什么,然后尝试 EKS 示例。如果你是第一次使用 EKS 和 Kubernetes,可能需要一两天的时间来处理示例,所以尽量安排时间来处理这些示例。第 14.9 节描述了如何删除 EKS 集群以及本章创建的所有其他资源,以确保你不产生额外的费用。

不再拖延,让我们开始吧!我们将从设置环境开始。

14.1 设置本章的环境

在本节中,我们设置环境,以便你可以跟随本章其余部分的示例。即使你并不打算尝试这些示例,我也建议你至少快速浏览这一节,了解我们将要使用的工具。本章工具较多,因此在这里我们安装最重要的依赖项,在接下来的章节中,你将找到其他工具的额外说明。

首先,通过运行以下命令将第十三章的代码复制到一个名为 ch14 的新文件夹中:

$ cp -r ch13 ch14

进入 ch14 目录,安装依赖项,并通过运行以下命令激活虚拟环境:

$ cd ch14 && pipenv install --dev && pipenv shell

由于我们将部署到 AWS,我们需要能够以编程方式访问 AWS 服务。在第十三章中,我们安装并配置了 AWS CLI。如果你还没有这样做,请回到 13.1 节,按照步骤安装和配置 AWS CLI。

你将学习如何将服务部署到 Kubernetes,因此你还需要安装 Kubernetes CLI,即 kubectl。根据你使用的平台,安装 kubectl 有不同的方法,所以请参考官方文档以查看哪个选项最适合你 (kubernetes.io/docs/tasks/tools/)。

最后,在本章中我们将大量使用 jq——一个帮助我们解析和查询 JSON 文档的 CLI 工具。jq 并非严格必要,以跟随本章中的示例,但它确实让一切变得更容易,如果你之前没有使用过这个工具,我强烈建议你了解它。我们将主要使用 jq 来过滤 JSON 有效负载并从中检索特定属性。与 Kubernetes 一样,根据你的平台,有不同的安装选项,所以请参考官方文档以了解哪种策略最适合你 (stedolan.github.io/jq/download/)。

现在我们已经准备好了环境,是时候部署了!在我们创建集群之前,下一节将解释一些与 Kubernetes 相关的主要概念,以确保你能理解接下来的章节。如果你有 Kubernetes 的先前经验,可以跳过第 14.2 节。

14.2 Kubernetes 的工作原理:简化版

那么,Kubernetes 是什么?如果你没有 Kubernetes 的先前经验或者对其工作原理仍然感到困惑,本节提供了一个对 Kubernetes 主要组件的超压缩介绍。

Kubernetes 是一个开源的容器编排工具。容器编排是指运行容器化应用的过程。除了容器编排,Kubernetes 还帮助我们自动化部署,并处理优雅的滚动发布和回滚、应用扩展等更多功能。

图 14.1 提供了 Kubernetes 集群主要组件的高级概述。Kubernetes 集群的核心是控制平面,这是一个运行我们集群的 Kubernetes API、控制其状态并管理可用资源的过程,以及其他许多任务。在控制平面上也可以安装附加组件,包括特定的 DNS 服务器,如 CoreDNS。

图 14.1 Kubernetes 集群的高级架构图,展示了集群所有组件如何协同工作。

定义 Kubernetes 控制平面是一个运行 Kubernetes API 并控制集群状态以及管理可用资源、调度和其他许多任务的过程。有关控制平面的更多信息,请参阅 Jay Vyas 和 Chris Love 所著的《Core Kubernetes》的第十一章(mng.bz/yayE)和第十二章(mng.bz/M0dm)(Manning, 2022)。

Kubernetes 中最小的计算单元是pod:围绕一个或多个容器的一个包装器。最常见的做法是每个pod运行一个容器,在本章中,我们将订单服务作为每个pod的单个容器进行部署。

要将pods部署到集群中,我们使用工作负载。Kubernetes 有四种类型的工作负载:DeploymentStatefulSetDaemonSetJob/CronJobDeployment是 Kubernetes 中最常见的工作负载类型,适用于运行无状态分布式应用。StatefulSet用于运行需要同步状态的状态化分布式应用。使用DaemonSet来定义应在集群的所有或大多数节点上运行的进程,例如日志收集器。JobCronJob帮助我们定义一次性进程或应用,这些进程或应用需要按计划运行,例如每天或每周一次。

要部署微服务,我们使用 DeploymentStatefulSet。由于我们的服务都是无状态的,在本章中我们将订单服务作为 Deployment 进行部署。为了管理 pod 的数量,部署使用 ReplicaSet 的概念,这是一个维护集群中所需 pod 数量的进程。

工作负载通常在 命名空间 内进行范围划分。在 Kubernetes 中,命名空间是资源的逻辑分组,允许我们隔离和范围我们的部署。例如,我们可以在平台中的每个服务上创建一个命名空间。命名空间使得管理我们的部署和避免名称冲突变得更容易:我们的资源名称必须在命名空间内是唯一的,但不需要在命名空间之间是唯一的。

要将我们的应用程序作为网络服务运行,Kubernetes 提供了 服务 的概念——这些是管理我们 pod 接口并使它们之间能够通信的进程。为了通过互联网公开我们的服务,我们使用一个 负载均衡器,它位于 Kubernetes 集群之前,并根据入口规则将流量转发到服务。

Kubernetes 系统的最后一部分是 节点,它代表我们的服务运行的实际计算资源。我们将节点定义为计算资源,因为它们可以是物理服务器到虚拟机中的任何东西。例如,当在 AWS 上运行 Kubernetes 集群时,我们的节点将由 EC2 机器表示。

现在我们已经了解了 Kubernetes 的主要组成部分,让我们创建一个集群吧!

14.3 使用 EKS 创建 Kubernetes 集群

在本节中,您将学习如何使用 AWS EKS 创建 Kubernetes 集群。我们使用 eksctl 启动 Kubernetes 集群,这是在 AWS 中管理 Kubernetes 的推荐工具。

eksctl 是由 Weaveworks 创建和维护的一个开源工具。它使用 CloudFormation 在幕后创建和管理 Kubernetes 集群的变化。这是一个好消息,因为它意味着我们可以重用 CloudFormation 模板来在不同环境中复制相同的架构。这也使得我们对集群的所有更改都通过 CloudFormation 可见。

定义 CloudFormation 是 AWS 的基础设施即代码服务。使用 CloudFormation,我们可以在称为 模板 的 YAML 或 JSON 文件中声明我们的资源。当我们提交模板到 CloudFormation 时,AWS 创建一个 CloudFormation 堆栈,即模板中定义的资源集合。CloudFormation 模板不应包含敏感信息,并且可以提交到我们的代码仓库中,这使得我们对基础设施的更改非常可见,并且可以在不同的环境中复制。

根据您使用的平台,安装 eksctl 有多种方法,因此请参阅官方文档以了解哪种策略最适合您(github.com/weaveworks/eksctl)。

要在 Kubernetes 集群中运行容器,我们使用 AWS Fargate。如图 14.2 所示,Fargate 是 AWS 的无服务器容器服务,允许我们在云中运行容器而无需配置服务器。使用 AWS Fargate,你无需担心服务器扩展的问题,因为 Fargate 会处理这些。

图片

图 14.2 AWS Fargate 自动配置运行我们的 Kubernetes 集群所需的服务器。

要使用 eksctl 创建 Kubernetes 集群,请运行以下命令:

$ eksctl create cluster --name coffeemesh --region <aws_region> --fargate \
--alb-ingress-access

创建过程大约需要 30 分钟来完成。让我们看看这个命令中的每个标志:

  • --name—集群的名称。我们将集群命名为coffeemesh

  • --region—你想要部署集群的 AWS 区域。这个区域应该与你在第 13.4 节中创建 ECR 仓库时使用的区域相同。

  • --fargate—创建一个 Fargate 配置文件,用于在defaultkube-system命名空间中调度 Pod。Fargate 配置文件是确定哪些 Pod 必须由 Fargate 启动的策略。

  • --alb-ingress-access—通过应用程序负载均衡器启用对集群的访问。

图 14.3 说明了 eksctl 在启动 Kubernetes 集群时创建的堆栈架构。默认情况下,eksctl 为集群创建一个专用的虚拟私有云(VPC)。

图片

图 14.3 eksctl 创建了一个包含三个公共网络、三个私有网络、两个 CIDR 预留和两个 VPC 安全组的 VPC。它还在 VPC 内部部署了 Kubernetes 集群。

Kubernetes 网络要高级使用 Kubernetes,你需要了解 Kubernetes 中的网络是如何工作的。要了解更多关于 Kubernetes 网络的信息,请查看 James Strong 和 Vallery Lancey 所著的《Networking and Kubernetes: A Layered Approach》(O’Reilly,2021 年)。

还可以在现有的 VPC 内部启动集群,通过指定你想要在其中运行部署的子网来实现。如果在现有的 VPC 内部启动,你必须确保 VPC 和提供的子网已正确配置以运行 Kubernetes 集群。有关 Kubernetes 集群的网络要求,请参阅 eksctl 文档(eksctl.io/usage/vpc-networking/)和 AWS 关于 Kubernetes 集群 VPC 网络要求的官方文档(mng.bz/aPRY)。

如图 14.3 所示,eksctl 默认创建六个子网:三个公共和三个私有,以及它们对应的 NAT 网关和路由表。子网是 VPC 中可用 IP 地址的子集。公共子网可以通过互联网访问,而私有子网则不行。eksctl 还创建了两个用于 Kubernetes 内部使用的子网 CIDR 预留,以及两个安全组;其中一个允许集群中所有节点之间的通信,另一个允许控制平面与节点之间的通信。

CIDR定义的无类域间路由,是一种用于表示 IP 地址范围的表示法。CIDR 表示法包括一个 IP 地址后跟一个斜杠和一个十进制数,其中十进制数表示地址范围。例如,255.255.255.255/32 表示一个地址的范围。要了解更多关于 CIDR 表示法的信息,请参阅维基百科的文章:en.wikipedia.org/wiki/Classless_Inter-Domain_Routing

一旦我们创建了集群,我们就可以配置 kubectl 指向它,这将允许我们通过命令行管理集群。使用以下命令将 kubectl 指向集群:

$ aws eks update-kubeconfig --name coffeemesh --region <aws_region>

现在我们已经连接到集群,我们可以检查其属性。例如,我们可以使用以下命令获取正在运行的节点列表:

$ kubectl get nodes
# output truncated:
NAME                                       STATUS  ROLES  AGE    VERSION
fargate-ip-192-168-157-75.<aws_region>...  Ready  <none>  4d16h  v1.20.7...
fargate-ip-192-168-170-234.<aws_region>... Ready  <none>  4d16h  v1.20.7...
fargate-ip-192-168-173-63.<aws_region>...  Ready  <none>  4d16h  v1.20.7...

要获取集群中运行的 Pod 列表,请运行以下命令:

$ kubectl get pods -A
NAMESPACE    NAME                      READY  STATUS   RESTARTS  AGE
kube-system  coredns-647df9f975-2ns5m  1/1    Running  0         2d15h
kube-system  coredns-647df9f975-hcgjq  1/1    Running  0         2d15h

您可以运行许多其他有用的命令来了解更多关于您的集群的信息。查看 Kubernetes CLI 的官方文档以获取更多命令和选项(kubernetes.io/docs/reference/kubectl/)。一个好的起点是 kubectl 速查表(kubernetes.io/docs/reference/kubectl/cheatsheet/)。现在我们的集群已经启动并运行,在下一节中,我们将为我们的 Kubernetes 服务账户创建一个 IAM 角色。

14.4 为 Kubernetes 服务账户使用 IAM 角色

在您的 Kubernetes 集群中运行的每个进程都有一个身份,这个身份由一个服务账户提供。服务账户决定了进程在集群内的访问权限。有时,我们的服务需要使用 AWS API 与 AWS 资源进行交互。为了给 AWS API 提供访问权限,我们需要为我们的服务创建IAM 角色——这些实体为应用程序提供访问 AWS API 的权限。如图 14.4 所示,要将 Kubernetes 服务账户链接到 IAM 角色,我们使用 OpenID Connect (OIDC)。通过使用 OIDC,我们的 Pod 可以获取临时凭证以访问 AWS API。

图片

图 14.4 Pod 可以通过 OIDC 提供者进行身份验证以假定 IAM 角色,这使它们能够访问 AWS API,因此能够访问 AWS 服务。

要检查您的集群是否有 OIDC 提供者,请运行以下命令,将<cluster_name>替换为您的集群名称:

$ aws eks describe-cluster --name coffeemesh \
--query "cluster.identity.oidc.issuer" --output text

您将得到以下类似的输出:

https://oidc.eks.<aws_region>.amazonaws.com/id/BE4E5EE7DCDF9FB198D06FC9883F
➥ F1BE

在这种情况下,集群 OIDC 提供者的 ID 是BE4E5EE7DCDF9FB198D06FC9883FF1BE。获取 OIDC 提供者的 ID 并运行以下命令:

$ aws iam list-open-id-connect-providers | \
grep BE4E5EE7DCDF9FB198D06FC9883FF1BE

此命令列出您 AWS 账户中的所有 OIDC 提供者,并使用grep根据您集群 OIDC 提供者的 ID 进行过滤。如果您得到结果,这意味着您已经为您的集群配置了 OIDC 提供者。如果您没有输出任何内容,这意味着您没有 OIDC 提供者,因此让我们创建一个!要为您的集群创建 OIDC 提供者,请运行以下命令,将<cluster_name>替换为您的集群名称:

$ eksctl utils associate-iam-oidc-provider --cluster <cluster_name> \
--approve

就这么简单。现在我们可以将 IAM 角色链接到我们的服务账户!在下一节中,我们将部署一个 Kubernetes 负载均衡器以使集群能够接收外部流量。

14.5 部署 Kubernetes 负载均衡器

目前,我们的集群无法从 VPC 外部访问。如果我们部署应用程序,它们将只获得内部 IP,因此无法对外部世界访问。为了使集群能够从外部访问,我们需要一个入口控制器。如图 14.5 所示,入口控制器接受来自 Kubernetes 集群外部的流量,并在我们的 Pod 之间进行负载均衡。为了将流量重定向到特定的 Pod,我们为每个服务创建入口资源。入口控制器负责管理入口资源。

图 14.5 入口控制器接受来自 Kubernetes 集群外部的流量,并根据入口资源定义的规则将其转发到 Pod。

在本节中,我们将部署一个 Kubernetes 入口控制器作为 AWS 负载均衡器控制器。³如图 14.5 所示,AWS 负载均衡器控制器部署了一个 AWS 应用程序负载均衡器(ALB),它位于我们的集群前面,捕获传入的流量并将其转发到我们的服务。为了将流量转发到我们的服务,ALB 使用目标组的概念——这是从 ALB 到特定资源转发流量的规则。例如,我们可以根据 IP、服务 ID 和其他因素设置目标组。负载均衡器监控其注册目标的状态,确保流量只被重定向到健康的目标。

要安装 AWS 负载均衡器控制器,我们需要在集群中有一个 OIDC 提供者,因此请确保您在继续之前已经阅读了第 14.4 节。部署 AWS 负载均衡器控制器的第一步是创建一个 IAM 策略,该策略允许控制器访问相关的 AWS API。维护 AWS 负载均衡器控制器项目的开源社区提供了一个我们需要策略的样本,因此我们只需获取它:

$ curl -o alb_controller_policy.json \
https://raw.githubusercontent.com/kubernetes-sigs/aws-load-balancer-
➥ controller/main/docs/install/iam_policy.json

运行此命令后,你会在你的目录中看到一个名为 alb_controller_policy.json 的文件。现在我们可以使用此文件创建 IAM 策略:

$ aws iam create-policy \
--policy-name ALBControllerPolicy \
--policy-document file://alb_controller_policy.json

下一步是创建一个 IAM 角色与 Kubernetes 服务账户关联,用于负载均衡器,命令如下:

$ eksctl create iamserviceaccount \
  --cluster=coffeemesh \
  --namespace=kube-system \
  --name=alb-controller \
  --attach-policy-arn=arn:aws:iam::<aws_account_id>:policy/ALBControllerPolicy \
  --override-existing-serviceaccounts \
  --approve

此命令创建一个 CloudFormation 堆栈,其中包括与之前创建的策略关联的 IAM 角色,以及名为 alb-controller 的服务账户,位于为 Kubernetes 集群的系统组件保留的 kube-system 命名空间内。

现在我们可以安装负载均衡器控制器。我们将使用 Helm 来安装控制器,它是 Kubernetes 的包管理器。如果你在机器上没有安装 Helm,你需要安装它。根据你的平台,安装 Helm 有不同的策略,所以请确保查看文档以了解哪种选项最适合你(helm.sh/docs/intro/install/))。

一旦 Helm 在你的机器上可用,你需要通过添加 EKS 图表存储库到你的本地 helm(在 Helm 中,包被称为 charts)来更新它。要添加 EKS 图表,请运行以下命令:

$ helm repo add eks https://aws.github.io/eks-charts

现在让我们更新 helm,以确保我们获取到最新的图表更新:

$ helm repo update

现在 helm 已更新,我们可以安装 AWS 负载均衡器控制器。要安装控制器,我们需要获取在启动集群时 eksctl 创建的 VPC 的 ID。要找到 VPC ID,请运行以下命令:

$ eksctl get cluster --name coffeemesh -o json | \
jq '.[0].ResourcesVpcConfig.VpcId'
# output: "vpc-07d35ccc982a082c9"

要成功运行前面的命令,你需要安装 jq。请参阅第 14.1 节了解如何安装它。现在我们可以通过运行以下命令来安装控制器:

$ helm install aws-load-balancer-controller eks/aws-load-balancer-
➥ controller \
  -n kube-system \
  --set clusterName=coffeemesh \
  --set serviceAccount.create=false \
  --set serviceAccount.name=alb-controller \
  --set vpcId=<vpc_id>

由于控制器是 Kubernetes 的内部组件,我们在 kube-system 命名空间内安装它。我们确保控制器为 coffeemesh 集群安装。我们还指示 Helm 不要为控制器创建新的服务账户,而是使用我们之前创建的 alb-controller 服务账户。

所有资源创建需要几分钟时间。要验证部署是否成功,请运行以下命令:

$ kubectl get deployment -n kube-system aws-load-balancer-controller
NAME              READY    UP-TO-DATE    AVAILABLE    AGE
alb-controller    2/2      2             2            84s

READY 列显示 2/2 时,你就知道控制器正在运行,这意味着所需的资源数量已经启动。我们的集群现在已准备就绪,是时候部署订单服务了!

14.6 在 Kubernetes 集群中部署微服务

现在我们的 Kubernetes 集群已准备就绪,是时候开始部署我们的服务了!在本节中,我们将介绍部署订单服务所需的步骤。你可以遵循相同的步骤来部署 CoffeeMesh 平台的其他服务。

图 14.6 要部署一个微服务,我们创建一个新的命名空间,并在该命名空间内部署所有运行微服务所需的组件,例如 Deployment 对象和 Service 对象。

如图 14.6 所示,我们将订单服务部署到名为orders-service的新命名空间。这允许我们逻辑上分组和隔离操作订单服务所需的所有资源。要创建新的命名空间,请运行以下命令:

$ kubectl create namespace orders-service

由于我们将在新的命名空间中运行订单服务,因此我们还需要创建一个新的 Fargate 配置文件,该配置文件配置为在orders-service命名空间内调度作业。要创建新的 Fargate 配置文件,请运行以下命令:

$ eksctl create fargateprofile --namespace orders-service --cluster \
coffeemesh --region <aws_region>

随着orders-service命名空间和 Fargate 配置文件的准备就绪,我们可以部署订单服务。要执行部署,我们采取以下步骤:

  1. 为订单服务创建一个部署对象。

  2. 创建一个服务对象。

  3. 创建一个 ingress 资源以公开服务。

以下各节将详细说明如何在每个步骤中进行操作。

14.6.1 创建部署对象

让我们从创建一个用于订单服务的部署开始,使用服务清单文件。如图 14.7 所示,部署是 Kubernetes 对象,它们操作我们的 Pod,并为它们提供运行所需的一切,包括 Docker 镜像和端口配置。创建一个名为 orders-service-deployment.yaml 的文件,并将列表 14.1 的内容复制到其中。

图 14.7 Deployment对象为 Pod 提供必要的配置,例如它们的 Docker 镜像和端口配置,并确保我们运行所需的 Pod 数量。

我们使用 Kubernetes 的 API 版本 apps/v1,并声明此对象为Deployment。在元数据中,我们命名部署为orders-service,指定其命名空间,并添加标签app: orders-service标签是 Kubernetes 对象的自定义标识符,可用于监控、跟踪或调度任务等多种用途。⁴

spec部分,我们定义了一个选择器规则,该规则匹配带有标签app: orders-service的 Pod,这意味着此部署将仅操作具有此标签的 Pod。我们还声明我们只想运行一个 Pod 副本。

spec.template部分,我们定义由此部署操作的 Pod。我们使用与部署选择器规则一致的app: orders-service键值对标记 Pod。在 Pod 的spec部分,我们声明属于 Pod 的容器。在这种情况下,我们只想运行一个容器,即订单服务应用程序。在订单服务容器的定义中,我们指定运行应用程序必须使用的镜像以及应用程序运行的端口。

列表 14.1 声明部署清单

# file: orders-service-deployment.yaml

apiVersion: apps/v1                   ①
kind: Deployment                      ②
metadata:
  name: orders-service                ③
  namespace: orders-service           ④
  labels:                             ⑤
    app: orders-service
spec:                                 ⑥
  replicas: 1                         ⑦
  selector:
    matchLabels:
      app: orders-service             ⑧
  template:                           ⑨
    metadata:
      labels:
        app: orders-service           ⑩
    spec:                             ⑪
      containers:
      - name: orders-service
        image: <aws_account_id>.dkr.ecr.<aws_region>.amazonaws.com/coffeemesh-orders:1.0            ⑫
        ports:
          - containerPort: 8000       ⑬
        imagePullPolicy: Always

① 此清单中使用的 Kubernetes API 版本

② 此清单定义了一个 Deployment 对象。

③ 部署的名称

④ 部署必须位于的命名空间

⑤ 部署的标签

⑥ 部署的规范

⑦ 需要部署多少个 Pod

⑧ Pod 的标签选择器

⑨ Pod 的模板

⑩ Pod 的标签

⑪ Pod 的规范

⑫ Pod 的镜像

⑬ API 运行的端口

要创建部署,我们运行以下命令:

$ kubectl apply -f orders-service-deployment.yaml

此命令创建部署并启动清单文件中定义的 Pod。Pod 变为可用可能需要几秒钟。您可以使用以下命令检查它们的状态:

$ kubectl get pods -n orders-service

Pod 的初始状态将是Pending,一旦它们启动并运行,其状态将变为Running

什么是 Kubernetes 清单文件?

在 Kubernetes 中,我们可以使用清单文件创建对象。对象是资源,如命名空间、部署、服务等。清单文件是一个 YAML 文件,描述了对象的属性及其期望状态。使用清单文件很方便,因为它们可以在源控制中跟踪,这有助于我们跟踪对基础设施的更改。

每个清单文件至少包含以下属性:

  • apiVersion—我们想要使用的 Kubernetes API 版本。每个 Kubernetes 对象都有自己的稳定版本。您可以通过运行以下命令来检查您 Kubernetes 集群中每个对象的最新稳定版本:kubectl api-resources

  • kind—我们正在创建的对象类型。可能的值包括ServiceIngressDeployment等。

  • metadata—一组属性,提供有关对象标识信息的集合,例如其名称、其命名空间和附加标签。

  • spec—对象的规范。例如,如果我们正在创建服务,我们使用此部分来指定我们正在创建的服务类型(例如,NodePort)和选择规则。

要从清单文件创建对象,我们使用kubectl apply命令。例如,如果我们有一个名为 deployment.yaml 的清单文件,我们使用以下命令应用它:

$ kubectl apply -f deployment.yaml

14.6.2 创建服务对象

现在我们的部署已经就绪,我们将为订单服务创建一个服务对象。正如我们在第 14.2 节中学到的,服务是 Kubernetes 对象,允许我们将 Pod 作为网络服务暴露。如图 14.8 所示,服务对象将我们的应用程序作为 Web 服务暴露,并将流量从集群重定向到指定端口上的我们的 Pod。创建一个名为 orders-service.yaml 的文件,并将列表 14.2 的内容复制进去,该列表显示了如何配置简单的服务清单。

图 14.8 一个服务对象将集群中的流量重定向到指定端口上的 Pod。在这个例子中,端口 80 的集群入站流量被重定向到 Pod 的端口 8000。

我们使用 Kubernetes API 的 v1 版本来声明我们的服务。在元数据中,我们指定服务的名称为orders-service,并且要在orders-service命名空间内启动。我们还添加了一个标签:app: orders-service。在服务的spec部分,我们配置了ClusterIP类型,这意味着 pod 只能在集群内部访问。Kubernetes 中还有其他类型的服务,例如NodePortLoadBalancer。(要了解更多关于服务类型及其使用情况,请参阅侧边栏,“我应该使用哪种类型的 Kubernetes 服务?”)

我们还创建了一个转发规则,将来自 80 端口的流量重定向到 8000 端口,这是我们容器运行的端口。最后,我们指定了一个app: orders-service标签的选择器,这意味着此服务将仅操作带有该标签的 pods。

列表 14.2 声明服务清单

# file: orders-service.yaml

apiVersion: v1
kind: Service                  ①
metadata:
  name: orders-service
  namespace: orders-service
  labels:
    app: orders-service
spec:
  selector:
    app: orders-service
  type: ClusterIP              ②
  ports:
    - protocol: http           ③
      port: 80                 ④
      targetPort: 8000         ⑤

① 此清单定义了一个 Service 对象。

② 这是一个 ClusterIP 类型的 Service。

③ 服务通过 HTTP 进行通信。

④ 服务必须映射到端口 80。

⑤ 服务在内部运行在端口 8000 上。

要部署此服务,请运行以下命令:

$ kubectl apply -f orders-service.yaml

我应该使用哪种类型的 Kubernetes 服务?

Kubernetes 有四种类型的服务。在这里,我们讨论每种服务类型的特性和用例:

  • ClusterIP—在集群的内部 IP 上暴露服务,因此只能在集群内部访问

  • NodePort—在节点的外部 IP 上暴露服务,因此使它们在集群网络上可用

  • LoadBalancer—通过专用云负载均衡器直接暴露服务

  • ExternalName—通过集群内部的内部 DNS 记录暴露服务

应该使用哪种类型?这取决于您的需求。NodePort如果您想能够从运行它们的节点的 IP 地址外部访问服务,那么它很有用。缺点是服务使用节点的静态端口,因此每个节点只能运行一个服务。ClusterIP如果您更愿意通过集群的 IP 访问服务,那么它很有用。ClusterIP服务不能从集群外部直接访问,但您可以通过创建将流量转发到它们的 ingress 规则来暴露它们。LoadBalancer如果您想为每个服务使用一个云负载均衡器,那么它很有用。使用每个服务的负载均衡器可以使配置稍微简单一些,因为您不需要配置多个 ingress 规则。然而,负载均衡器通常是集群中最昂贵的组件,所以如果预算是一个因素,您可能不想使用这个选项。最后,ExternalName如果您想能够使用自定义域名从集群内部访问服务,那么它很有用。

14.6.3 使用 ingress 对象暴露服务

最后一步是通过互联网公开服务。为了公开服务,我们需要创建一个路由到服务的入口资源。如图 14.9 所示,入口资源是一个服务,它将 HTTP 流量重定向到我们在指定端口和 URL 路径上运行的 Kubernetes 集群中的 pod。创建一个名为 orders-service-ingress.yaml 的文件,并将列表 14.3 的内容复制到其中。

图 14.9 入口对象允许我们将特定端口和 URL 路径上的 HTTP 流量重定向到服务对象。

在入口清单中,我们使用 Kubernetes API 的 networking.k8s.io/v1 版本,并声明对象为 Ingress 类型。在 metadata 中,我们命名入口对象为 orders-service-ingress,并指定它应在 orders-service 命名空间内部署。我们使用注解将入口对象绑定到我们在 14.5 节中部署的 AWS 负载均衡器。在 spec 部分中,我们定义入口资源的转发规则。我们声明了一个 HTTP 规则,将所有 /orders 路径下的流量转发到订单服务,以及访问服务 API 文档的附加规则。

列表 14.3 声明入口清单

# file: orders-service-ingress.yaml

apiVersion: networking.k8s.io/v1
kind: Ingress                                              ①
metadata:
  name: orders-service-ingress
  namespace: orders-service
  annotations:                                             ②
    kubernetes.io/ingress.class: alb                       ③
    alb.ingress.kubernetes.io/target-type: ip              ④
    alb.ingress.kubernetes.io/scheme: internet-facing      ⑤
spec:
  rules:                                                   ⑥
  - http:
      paths:
      - path: /orders                                      ⑦
        pathType: Prefix                                   ⑧
        backend:                                           ⑨
          service:
            name: orders-service                           ⑩
            port:
              number: 80                                   ⑪
      - path: /docs/orders
        pathType: Prefix
        backend:
          service:
            name: orders-service
            port:
              number: 80
      - path: /openapi/orders.json
        pathType: Prefix
        backend:
          service:
            name: orders-service
            port:
              number: 80

① 清单定义了一个入口对象。

② 入口的 AWS 配置

③ 入口暴露了一个应用程序负载均衡器。

④ 根据 IP 地址将流量路由到 pod。

⑤ 入口可用于外部连接。

⑥ 流量转发规则

⑦ 针对 /orders URL 路径的规则

⑧ 该规则适用于以 /orders 前缀开始的请求。

⑨ 处理此流量的后端服务

⑩ 流量必须路由到 orders-service 服务。

⑪ orders-service 服务在端口 80 上可用。

要创建此入口资源,我们运行以下命令:

$ kubectl apply -f orders-service-ingress.yaml

订单 API 现在可访问。要调用 API,我们首先需要找到我们刚刚创建的入口规则的端点。运行以下命令以获取入口资源的详细信息:

$ kubectl get ingress/orders-service-ingress -n orders-service
# output truncated:
NAME                     CLASS    HOSTS   ADDRESS...
orders-service-ingress   <none>   *       k8s-ordersse-ordersse-3c391193...

ADDRESS 字段下的值是负载均衡器的 URL。您也可以通过运行以下命令来获取此值:

$ kubectl get ingress/orders-service-ingress -n orders-service -o json | \
jq '.status.loadBalancer.ingress[0].hostname'
"k8s-ordersse-ordersse-3c39119336-236890178.<aws_region>.elb.amazonaws.com"

我们可以使用此 URL 调用订单服务 API。由于数据库尚未就绪,API 本身将无法工作,但我们可以访问 API 文档:

$ curl http://k8s-ordersse-ordersse-3c39119336-
➥ 236890178.<aws_region>.elb.amazonaws.com/openapi/orders.json

负载均衡器变为可用可能需要一些时间,在此期间 curl 将无法解析主机。如果发生这种情况,请等待几分钟再试。为了能够与 API 交互,我们必须设置一个数据库,这将是我们下一节的目标!

14.7 使用 AWS Aurora 设置无服务器数据库

订单服务几乎准备好了:应用程序正在运行,我们可以通过互联网访问它。唯一缺少的部分是数据库。我们有很多选择来设置数据库。我们可以在我们的 Kubernetes 集群中设置数据库作为部署,使用挂载的卷,或者我们可以选择云提供商提供的众多托管数据库服务之一。

为了保持简单且成本效益,在本节中,我们将在 AWS 中设置一个 Aurora Serverless 数据库——这是一个强大的数据库引擎,由于你只需为使用的部分付费,因此具有成本效益,并且非常方便,因为你无需担心管理或扩展数据库。

14.7.1 创建 Aurora Serverless 数据库

我们将在 Kubernetes 集群的 VPC 内启动我们的 Aurora 数据库。为了能够在现有的 VPC 内启动数据库,我们需要创建一个数据库子网组:VPC 内的一组子网。正如我们在第 14.3 节中学到的,eksctl 将 Kubernetes 集群的 VPC 划分为六个子网:三个公共和三个私有。这六个子网分布在三个可用区(AWS 区域内的数据中心)中,每个可用区有一个公共子网和一个私有子网。

在选择数据库子网组的子网时,我们需要考虑以下约束条件:

  • Aurora Serverless 在每个可用区只支持一个子网。

  • 在创建数据库子网组时,子网必须全部为私有或公共。⁵

为了安全起见,在数据库子网组中使用私有子网是最佳实践,因为它确保数据库服务器无法从 VPC 外部访问,这意味着外部和未经授权的用户无法直接连接到它。为了找到 VPC 中私有子网的列表,我们首先需要使用以下命令获取 Kubernetes 集群 VPC 的 ID:

$ eksctl get cluster --name coffeemesh -o json | \
jq '.[0].ResourcesVpcConfig.VpcId'

然后使用以下命令获取 VPC 中私有子网的 ID:

$ aws ec2 describe-subnets --filters Name=vpc-id,Values=<vpc_id> \
--output json | jq '.Subnets[] | select(.MapPublicIpOnLaunch == false) | \
.SubnetId'

之前的命令列出了 Kubernetes 集群 VPC 中的所有子网,并使用jq过滤出公共子网。有了所有这些信息,我们现在可以使用以下命令创建数据库子网组:

$ aws rds create-db-subnet-group --db-subnet-group-name \
coffeemesh-db-subnet-group --db-subnet-group-description "Private subnets" \
--subnet-ids "<subnet_id>" "<subnet_id>" "<subnet_id>"

如图 14.10 所示,此命令创建了一个名为coffeemesh-db-subnet-group的数据库子网组。在运行命令时,请确保将<subnet_id>占位符替换为你的私有子网的 ID。我们将在该数据库子网组内部署我们的 Aurora 数据库。

图 14-10

图 14.10 我们在名为coffeemesh-db-subnet-group的数据库子网组中部署了一个 Aurora 数据库。该数据库子网组是在我们的 VPC 的三个私有子网之上创建的,以防止未经授权的访问。

接下来,我们需要创建一个VPC 安全组——一组规则,定义了从 VPC 进入和出去的允许流量,以便允许流量访问数据库,使我们的应用程序能够连接到它。以下命令创建了一个名为db-access的安全组:

$ aws ec2 create-security-group --group-name db-access --vpc-id <vpc-id> \
--description "Security group for db access"
# output:
{
    "GroupId": "sg-00b47703a4299924d"
}

在之前的命令中,将<vpc-id>替换为你的 Kubernetes 集群 VPC 的 ID。之前命令的输出是我们刚刚创建的安全组的 ID。我们将允许所有 IP 地址在 PostgreSQL 的默认端口(5432)上的流量。由于我们将数据库部署到私有子网中,监听所有 IP 是可行的,但为了额外的安全性,你可能想限制地址范围到你的 Pods 的地址。我们使用以下命令为我们的数据库访问安全组创建一个入站流量规则:

$ aws ec2 authorize-security-group-ingress --group-id \
<db-security-group-id> --ip-permissions \ 
'FromPort=5432,IpProtocol=TCP,IpRanges=0.0.0.0/0'

在此命令中,将<db-security-group-id>替换为你的数据库访问安全组的 ID。

现在我们已经有一个数据库子网组和允许我们的 Pods 连接到它的安全组,我们可以使用子网组在我们的 VPC 内启动一个 Aurora 无服务器集群!运行以下命令来启动 Aurora 无服务器集群:

$ aws rds create-db-cluster --db-cluster-identifier coffeemesh-orders-db \
--engine aurora-postgresql --engine-version 10.14 \
--engine-mode serverless \
--scaling-configuration MinCapacity=8,MaxCapacity=64,
➥ SecondsUntilAutoPause=1000,AutoPause=true \
--master-username <username> \
--master-user-password <password> \
--vpc-security-group-ids <security_group_id> \
--db-subnet-group <db_subnet_group_name>

让我们仔细看看命令的参数:

  • --db-cluster-identifier—数据库集群的名称。我们将集群命名为coffeemesh-orders-db

  • --engine—你想要使用的数据库引擎。我们使用兼容 PostgreSQL 的引擎,但如果你更喜欢,也可以选择兼容 MySQL 的引擎。

  • --engine-version—你想要使用的 Aurora 引擎版本。我们选择版本 10.14,这是目前 Aurora PostgreSQL 无服务器可用的唯一版本。参见 AWS 文档以了解新版本信息(mng.bz/gRyn)。

  • --engine-mode—数据库引擎模式。我们选择无服务器模式以保持示例简单且成本效益高。

  • --scaling-configuration—Aurora 集群的自动扩展配置。我们配置集群以最小 Aurora 容量单元(ACU)为 8,最大为 64。每个 ACU 提供大约 2 GB 的内存。我们还配置集群在 1,000 秒无活动后自动缩小到 0 ACU。⁶

  • --master-username—数据库主用户的用户名。

  • --master-user-password—数据库主用户的密码。

  • --vpc-security-group-ids—我们在上一步中创建的数据库访问安全组的 ID。

  • --db-subnet-group—我们之前创建的数据库安全组的名称。

运行此命令后,你将获得一个包含数据库详细信息的 JSON 有效负载。要连接到数据库,我们需要有效负载中DBCluster.Endpoint属性的值,它表示数据库的主机名。我们将在下一节中使用此值来连接到数据库。

14.7.2 Kubernetes 中的秘密管理

为了将我们的服务连接到数据库,我们需要一种安全的方式来传递连接凭据。在 Kubernetes 中管理敏感信息的原生方式是使用 Kubernetes 机密。这样,我们就避免了通过代码或通过我们的镜像构建暴露敏感信息。在本节中,你将学习如何安全地管理 Kubernetes 机密。

AWS EKS 提供两种安全的方式来管理 Kubernetes 机密:我们可以使用 AWS Secrets & Configuration Provider for Kubernetes,⁷,或者我们可以使用 AWS 密钥管理服务(KMS)通过封装加密来保护我们的机密。在本节中,我们将使用封装加密来保护我们的机密。⁸

正如你在图 14.11 中看到的,封装加密是指使用数据加密密钥(DEK)加密你的数据,然后使用密钥加密密钥(KEK)加密 DEK 的做法。⁹ 这听起来很复杂,但由于 AWS 为我们承担了繁重的工作,所以使用起来很简单。

图 14.11

图 14.11 封装加密是指使用数据加密密钥(DEK)加密数据,然后使用密钥加密密钥(KEK)加密 DEK 的做法。

要使用封装加密,首先我们需要生成一个 AWS KMS 密钥。你可以使用以下命令来创建密钥:

$ aws kms create-key

此命令的输出是一个包含新创建密钥元数据的有效负载。从这个有效负载中,我们想要使用 KeyMetadata.Arn 属性,它表示密钥的 ARN,或 Amazon 资源名称。下一步是在我们的 Kubernetes 集群中使用 eksctl 启用机密加密:`

$ eksctl utils enable-secrets-encryption --cluster coffeemesh \
--key-arn=<key_arn> --region <aws_region>

确保将 <key_arn> 替换为你的 KMS 密钥的 ARN,并将 <aws_region> 替换为你部署 Kubernetes 集群的区域。由前面的命令触发的操作可能需要最多 45 分钟才能完成。该命令会一直运行,直到集群创建完成,所以只需等待它完成。一旦完成,我们就可以创建 Kubernetes 机密。让我们创建一个表示数据库连接字符串的机密。数据库连接字符串具有以下结构:

<engine>://<username>:<password>@<hostname>:<port>/<database_name>

让我们看看连接字符串的每个组成部分:

  • engine—数据库引擎,例如,postgresql

  • username—我们在创建数据库时选择的用户名。

  • password—我们在创建数据库时选择的密码。

  • hostname—数据库的主机名,这是我们上一节中从 aws rds create-db-cluster 命令返回的有效负载的 DBCluster.Endpoint 属性中获得的。

  • port—数据库运行在上的端口。每个数据库都有自己的默认端口,例如 PostgreSQL 的 5432 和 MySQL 的 3306。

  • database_name—我们要连接到的数据库的名称。在 PostgreSQL 中,默认数据库的名称为 postgres

例如,对于 PostgreSQL 数据库,一个典型的连接字符串看起来像这样:

postgresql://username:password@localhost:5432/postgres

要将数据库连接字符串作为 Kubernetes 机密存储,我们运行以下命令:

$ kubectl create secret generic -n orders-service db-credentials \
--from-literal=DB_URL=<connection_string>

之前的命令在 orders-service 命名空间内创建了一个名为 db-credentials 的密钥对象。要获取此密钥对象的详细信息,您可以运行以下命令:

$ kubectl get secret db-credentials -n orders-service -o json
# output:
{
    "apiVersion": "v1",
    "data": {
        "DB_URL": "cG9zdGdyZXNxbDovL3VzZXJuYW1lOnBhc3N3b3JkQGNvZmZlZW1lc2gtZGIuY2x1c3Rlci1jYn
➥ Y0YWhnc2JjZWcuZXUtd2VzdC0xLnJkcy5hbWF6b25hd3MuY29tOjU0MzIvcG9zdGdyZXM="
    },
    "kind": "Secret",
    "metadata": {
        "creationTimestamp": "2021-11-19T15:21:42Z",
        "name": "db-credentials",
        "namespace": "orders-service",
        "resourceVersion": "599258",
        "uid": "d2c210e7-c61c-46b7-9f43-9407766e147c"
    },
    "type": "Opaque"
}

密钥列在有效载荷的 data 属性下,并且它们是 Base64 编码的。要获取它们的值,您可以运行以下命令:

$ echo <DB_URL> | base64 --decode

其中 <DB_URL>DB_URL 键的 Base64 编码值。

为了使密钥对订单服务可用,我们需要更新订单服务部署以消费密钥并将其作为环境变量公开。

列表 14.4 在部署中作为环境变量消费密钥

# file: orders-service-deployment.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: orders-service
  namespace: orders-service
  labels:
    app: orders-service
spec:
  replicas: 1
  selector:
    matchLabels:
      app: orders-service
  template:
    metadata:
      labels:
        app: orders-service
    spec:
      containers:
      - name: orders-service
        image: 
➥ <aws_account_id>.dkr.ecr.<aws_region>.amazonaws.com/coffeemesh-orders:1.0
        ports:
          - containerPort: 8000
        imagePullPolicy: Always
        envFrom:                       ①
          - secretRef:                 ②
              name: db-credentials ③

① Pod 的环境配置

② 配置用于识别密钥

③ 环境必须从名为 db-credentials 的密钥中加载。

让我们通过运行以下命令来应用这些更改:

$ kubectl apply -f orders-service-deployment.yaml

我们的服务现在可以连接到数据库了!我们几乎完成了。最后一步是应用数据库迁移,我们将在下一节中完成。

14.7.3 运行数据库迁移并将我们的服务连接到数据库

我们的数据库正在运行,现在我们可以将其与订单服务连接起来。然而,在我们能够创建记录和运行查询之前,我们必须确保数据库具有预期的模式。正如我们在第七章中看到的,创建数据库模式的过程被称为迁移。我们的应用程序的迁移可以在 migrations 文件夹下找到。在本节中,我们将对 Aurora Serverless 数据库运行迁移。

在上一节中,我们将 Aurora 数据库部署到了我们的私有子网中,这意味着我们无法直接访问数据库来运行迁移。我们有两个主要选项来连接到数据库:通过堡垒机服务器连接或创建一个应用迁移的 Kubernetes Job。由于我们正在使用 Kubernetes,并且我们的集群已经启动并运行,因此使用 Kubernetes Job 对我们来说是一个合适的选项。

定义 A 堡垒机服务器 是一个允许您与私有网络建立安全连接的服务器。通过连接到堡垒机服务器,您能够访问私有网络中的其他服务器。

要创建 Kubernetes 作业,我们首先需要为运行数据库迁移创建一个 Docker 镜像。创建一个名为 migrations.dockerfile 的文件,并将列表 14.5 的内容复制到其中。此 Dockerfile 安装了生产环境和开发依赖项,并将迁移和 Alembic 配置复制到容器中。正如我们在第七章中看到的,我们使用 Alembic 来管理我们的数据库迁移。此容器的命令是单次执行的 alembic upgrade

列表 14.5 数据库迁移作业的 Dockerfile

# file: migrations.dockerfile

FROM python:3.9-slim

RUN mkdir -p /orders/orders

WORKDIR /orders

RUN pip install -U pip && pip install pipenv

COPY Pipfile Pipfile.lock /orders/

RUN pipenv install --dev --system --deploy

COPY orders/repository /orders/orders/repository/
COPY migrations /orders/migrations
COPY alembic.ini /orders/alembic.ini

ENV PYTHONPATH=/orders                 ①

CMD ["alembic", "upgrade", "heads"]

① 我们设置了 PYTHONPATH 环境变量。

构建 Docker 镜像,请运行以下命令:

$ docker build -t 
➥ <aws_account_number>.dkr.ecr.<aws_region>.amazonaws.com/coffeemesh-
➥ orders-migrations:1.0 -f migrations.dockerfile .

我们将镜像命名为coffeemesh-orders-migrations并标记为版本 1.0。确保你用你的 AWS 账户 ID 替换<aws_account_id>,并用你想要存储 Docker 构建的区域的名称替换<aws_region>。在我们将镜像推送到容器注册库之前,我们需要创建一个仓库:

$ aws ecr create-repository --repository-name coffeemesh-orders-migrations

现在让我们将镜像推送到容器注册库:

$ docker push 
➥ <aws_account_id>.dkr.ecr.<aws_region>.amazonaws.com/coffeemesh-orders-
➥ migrations:1.0

如果你的 ECR 凭证已过期,你可以通过再次运行以下命令来刷新它们:

$ aws ecr get-login-password --region <aws_region> | docker login \
--username AWS --password-stdin \
<aws_account_id>.dkr.ecr.<aws_region>.amazonaws.com

现在我们已经准备好了镜像,我们需要创建一个 Kubernetes Job 对象。我们使用清单文件来创建 Job。创建一个名为 orders-migrations-job.yaml 的文件,并将列表 14.6 的内容复制进去。列表 14.6 使用 batch/v1 API 定义了一个类型为Job的 Kubernetes 对象。就像我们在上一节为订单服务所做的那样,我们通过在容器的定义中使用envFrom属性加载db-credentials机密来在环境中公开数据库连接字符串。我们还设置了ttlSecondsAfterFinished参数为 30 秒,这控制了 Pod 在完成作业后将在orders-service命名空间中持续多长时间。

列表 14.6 创建数据库迁移 Job

# file: orders-migrations-job.yaml

apiVersion: batch/v1
kind: Job
metadata:
  name: orders-service-migrations
  namespace: orders-service
  labels:
    app: orders-service
spec:
  ttlSecondsAfterFinished: 30      ①
  template:
    spec:
      containers:
      - name: orders-service-migrations
        image: 
➥ <aws_account_id>.dkr.ecr.<aws_region>.amazonaws.com/coffeemesh-orders-
➥ migrations:1.0
        imagePullPolicy: Always
        envFrom:
          - secretRef:
              name: db-credentials
      restartPolicy: Never

① Pod 必须在完成 30 秒后删除。

让我们通过运行以下命令来创建 Job:

$ kubectl apply -f orders-migrations-job.yaml

直到job的 Pod 启动并运行,可能需要几秒钟。你可以通过运行以下命令来检查其状态:

$ kubectl get pods -n orders-service

一旦 Pod 的状态为RunningCompleted,你可以通过运行以下命令来检查job的日志:

$ kubectl logs -f jobs/orders-service-migrations -n orders-service

以这种方式查看 Pod 的日志对于检查进程的进展和发现执行中出现的任何问题很有用。由于迁移作业是短暂的,完成后会删除,确保你在进程运行时检查日志。一旦迁移作业完成,数据库最终就绪,可以使用了!我们终于可以与订单服务交互了——我们一直等待的时刻!我们的服务现在已准备好使用。下一节将解释我们需要进行的另一个更改以完成部署。

14.8 使用 ALB 的主机名更新 OpenAPI 规范

现在我们的服务已经就绪,数据库已部署并配置,是时候玩转应用程序了!在第二章和第六章,我们学习了如何使用 Swagger UI 与我们的 API 交互。为了在我们的部署中使用 Swagger UI,我们需要更新 API 规范,以包含我们 Kubernetes 集群 ALB 的主机名。在本节中,我们更新了订单的 API 规范,创建了一个新的部署,并对其进行了测试。

列表 14.7 将 ALB 的主机名添加为服务器

# file: oas.yaml

openapi: 3.0.0

info:
  title: Orders API
  description: API that allows you to manage orders for CoffeeMesh
  version: 1.0.0

servers:
  - url: <alb-hostname>
    description: ALB's hostname
  - url: https://coffeemesh.com
    description: main production server
  - url: https://coffeemesh-staging.com
    description: staging server for testing purposes only
  - url: http://localhost:8000
    description: URL for local testing

...

在列表 14.8 中,将<alb-hostname>替换为你自己的 ALB 的主机名。正如我们在 14.6 节中学到的,你可以通过运行以下命令来获取 ALB 的主机名:

$ kubectl get ingress/orders-service-ingress -n orders-service -o json | \
jq '.status.loadBalancer.ingress[0].hostname'
# output:
# "k8s-ordersse-ordersse-8cf837ce7a-1036161040.<aws_region>.elb.amazonaws.com"

现在我们需要重新构建我们的 Docker 镜像:

$ docker build -t 
<aws_account_number>.dkr.ecr.<aws_region>.amazonaws.com/coffeemesh-
➥ orders:1.1 .

然后,我们将新的构建发布到 AWS ECR:

$ docker push 
<aws_account_number>.dkr.ecr.<aws_region>.amazonaws.com/coffeemesh-
➥ orders:1.1

接下来,我们需要更新订单服务部署清单。

列表 14.8 声明部署清单

# file: orders-service-deployment.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: orders-service
  namespace: orders-service
  labels:
    app: orders-service
spec:
  replicas: 1
  selector:
    matchLabels:
      app: orders-service
  template:
    metadata:
      labels:
        app: orders-service
    spec:
      containers:
      - name: orders-service
        image: 
➥ <aws_account_id>.dkr.ecr.<aws_region>.amazonaws.com/coffeemesh-orders:1.1
        ports:
          - containerPort: 8000
        imagePullPolicy: Always

最后,通过运行以下命令应用新的部署配置:

$ kubectl apply -f orders-service-deployment.yaml

通过运行以下命令来监控部署:

kubectl get pods -n orders-service

一旦旧节点终止,新节点启动并运行,通过在浏览器中粘贴 ALB 的主机名并访问 /docs/orders 页面来加载订单服务的 Swagger UI。你可以使用在 2 章和 6 章中学到的方法来玩转 API:创建订单、修改它们,并从服务器获取它们的详细信息。

旅程终于完成了!如果你能够跟到这里,并且成功地将你的 Kubernetes 集群启动并运行,请接受我最真诚的祝贺!你已经成功了!图 14.12 展示了你本章部署的架构的高级概述。

图片

图 14.12 本章部署的架构高级概述

本章对 Kubernetes 的概述是简要的,但足以了解 Kubernetes 的工作原理,并且足以在你的生产环境中启动并运行一个集群。如果你正在使用或打算使用 Kubernetes,我强烈建议你继续阅读有关这项技术的信息。你可以查看我在本章中引用的所有参考文献,我想要补充的是 Marko Lukša 的基础书籍 Kubernetes in Action(第 2 版,Manning,预计 2023 年出版)。

在下一节中,我们将删除本章中创建的所有资源。如果你不想支付不必要的费用,请不要错过!

14.9 删除 Kubernetes 集群

本节解释了如何删除本章中创建的所有资源。这一步至关重要,以确保你在完成示例工作后不会为 Kubernetes 集群付费。如图 14.13 所示,我们的一些资源之间存在依赖关系。为了成功删除所有资源,我们必须按照它们的依赖关系反向删除。例如,数据库集群依赖于数据库子网组,数据库子网组依赖于 VPC 子网,VPC 子网依赖于 VPC。在这种情况下,我们将首先删除数据库集群,最后一步将删除 VPC。

图片

图 14.13 我们堆栈中的资源之间存在依赖关系。依赖关系的方向由箭头的方向表示。为了删除资源,我们首先删除那些没有箭头指向的资源。

使用以下命令删除数据库集群:

$ aws rds delete-db-cluster --db-cluster-identifier coffeemesh-db \
--skip-final-snapshot

--skip-final-snapshot 标志指示命令在删除前不要创建数据库快照。删除数据库需要几分钟时间。一旦删除完成,我们可以使用以下命令删除数据库子网组:

$ aws rds delete-db-subnet-group --db-subnet-group-name \
coffeemesh-db-subnet-group

接下来,让我们删除 AWS 负载均衡器控制器。删除 AWS 负载均衡器控制器是一个两步过程:首先我们使用helm卸载控制器,然后我们删除在安装控制器时创建的 ALB。要删除 ALB,我们需要它的 URL,所以让我们首先获取这个值(确保在用helm卸载之前运行此步骤):

$ kubectl get ingress/orders-service-ingress -n orders-service -o json | \
jq '.status.loadBalancer.ingress[0].hostname'
# output: "k8s-ordersse-ordersse-8cf837ce7a-
➥ 1036161040.<aws_region>.elb.amazonaws.com"

现在,让我们使用以下命令卸载控制器:

$ helm uninstall aws-load-balancer-controller -n kube-system

运行此命令后,我们需要删除 ALB。要删除 ALB,我们需要找到它的 ARN。我们将使用 AWS CLI 列出我们账户中的负载均衡器,并通过它们的 DNS 名称过滤它们。以下命令获取与 ALB 的 URL 匹配的负载均衡器的 ARN,这是我们之前获得的:

$ aws elbv2 describe-load-balancers | jq '.LoadBalancers[] | \
select(.DNSName == "<load_balancer_url>") | .LoadBalancerArn'
# output: "arn:aws:elasticloadbalancing:<aws_region>:<aws_account_id>:
➥ loadbalancer/app/k8s-ordersse-ordersse-8cf837ce7a/cf708f97c2485719"

确保将<load_balancer_url>替换为你的负载均衡器的 URL,这是我们之前步骤中获得的。此命令为我们提供了负载均衡器的 ARN,我们可以用它来删除它:

$ aws elbv2 delete-load-balancer --load-balancer-arn "<load_balancer_arn>"

现在,我们可以使用以下命令删除 Kubernetes 集群:

$ eksctl delete cluster coffeemesh

最后,让我们删除我们之前创建的用于加密 Kubernetes 机密的 KMS 密钥。要删除密钥,我们运行以下命令:

$ aws kms schedule-key-deletion --key-id <key_id>

其中<key_id>是我们之前创建的密钥的 ID。

摘要

  • Kubernetes 是一个容器编排工具,它正在成为大规模部署微服务的标准。使用 Kubernetes 可以帮助我们在云提供商之间迁移,同时保持对服务的一致接口。

  • 三个主要的托管 Kubernetes 服务是 Google 的 Kubernetes Engine (GKE)、Azure 的 Kubernetes Service (AKS)和 AWS 的 Elastic Kubernetes Service (EKS)。在本章中,我们学习了如何使用 EKS 部署 Kubernetes 集群,EKS 是最广泛采用的 Kubernetes 托管服务。

  • 我们可以使用控制台、CloudFormation 或 eksctl 命令行工具在 AWS 中部署一个 Kubernetes 集群。在本章中,我们使用了 eksctl CLI,因为它是 AWS 推荐的方式来管理 Kubernetes 集群。

  • 为了使我们的 Kubernetes 集群可以从互联网访问,我们使用一个 ingress 控制器,例如 AWS 负载均衡器控制器。

  • 要将微服务部署到 Kubernetes 集群,我们创建以下资源:

    • 一个Deployment,它管理 pods 的期望状态,运行 Docker 构建的过程

    • 一个允许我们将应用程序作为 Web 服务公开的Service

    • 一个绑定到 ingress 控制器(AWS 负载均衡器控制器)的Ingress对象,它将流量转发到服务

  • Aurora Serverless 是一个强大的数据库引擎,是微服务的便捷选择。使用 Aurora Serverless,你只需为使用的部分付费,你不需要担心数据库的扩展,从而降低你的成本和管理时间。

  • 为了在 Kubernetes 中安全地将敏感配置细节提供给应用程序,我们使用 Kubernetes 机密。使用 EKS,我们有两种策略来安全地管理 Kubernetes 机密:

    • 使用 AWS Secrets & Configuration Provider for Kubernetes

    • 在 Kubernetes 中使用密钥管理与 AWS 密钥管理服务结合使用


¹ 关于 GKE、AKS 和 EKS 的快速比较,请参阅 Alexander Postasnick 的文章“AWS vs EKS vs GKE: Managed Kubernetes Services Compared”,发布于 2021 年 6 月 9 日,acloudguru.com/blog/engineering/aks-vs-eks-vs-gke-managed-kubernetes-services-compared

² Flexera,“2022 云状态报告”(第 52-53 页),info.flexera.com/CM-REPORT-State-of-the-Cloud

³ AWS 负载均衡器控制器是一个托管在 GitHub 上的开源项目 (github.com/kubernetes-sigs/aws-load-balancer-controller/)。该项目最初由 Ticketmaster 和 CoreOS 创建。

⁴ 若想了解更多关于标签及其使用方法的信息,请参阅官方文档,kubernetes.io/docs/concepts/overview/working-with-objects/labels/,以及 Zane Hitchcox 的文章“matchLabels, Labels, and Selectors Explained in Detail, for Beginners”,发表于 Medium(2018 年 7 月 15 日),medium.com/@zwhitchcox/matchlabels-labels-and-selectors-explained-in-detail-for-beginners-d421bdd05362

⁵ 关于此点的更多信息,请参阅官方 AWS 文档:docs.aws.amazon.com/AmazonRDS/latest/UserGuide/USER_VPC.WorkingWithRDSInstanceinaVPC.html

⁶ 若想了解更多关于 Aurora Serverless 的工作原理和自动扩展配置参数的信息,请参阅官方文档:docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/aurora-serverless-v2.html

⁷ 您可以通过 Tracy Pierce 的文章“如何使用 AWS Secrets & Configuration Provider 与您的 Kubernetes Secrets Store CSI 驱动程序一起使用”了解更多关于此选项的信息,aws.amazon.com/blogs/security/how-to-use-aws-secrets-configuration-provider-with-kubernetes-secrets-store-csi-driver/

⁸ 安全管理 Kubernetes 是一个庞大且重要的主题,若想了解更多,可以查看 Alex Soto Bueno 和 Andrew Block 的著作 Securing Kubernetes Secrets(Manning,2022),livebook.manning.com/book/securing-kubernetes-secrets/chapter-4/v-3/point-13495-119-134-1

⁹ 同上。

附录 A. 网络 API 和协议类型

在本附录中,我们研究我们可以用来实现应用程序接口的 API 协议。每个协议都是为了解决 API 消费者和生产者之间集成中的特定问题而演化的。我们讨论每个协议的优缺点,以便我们在设计和构建自己的 API 时做出最佳选择。我们将讨论以下协议:

  • RPC 及其变体,JSON-RPC 和 XML-RPC

  • SOAP

  • gRPC

  • REST

  • GraphQL

选择正确的 API 类型对于我们的微服务性能和集成策略至关重要。将影响我们选择 API 协议的因素包括:

  • API 是否为公开或私有

  • API 消费者类型:小型设备、移动应用、浏览器或其他微服务

  • 我们希望公开的能力和资源;例如,是否是一个可以围绕端点组织的数据模型,或者是一个高度互联的资源网络,其中资源之间存在交叉引用

在以下各节中,我们讨论每个协议的优缺点时,会考虑这些因素,以评估它们在不同场景中的适用性。

A.1 API 的黎明:RPC、XML-RPC 和 JSON-RPC

让我们先解释远程过程调用及其两种最常见实现,即 XML-RPC 和 JSON-RPC。如图 A.1 所示,远程过程调用(RPC)是一种允许客户端在另一台机器上调用过程或子程序的协议。这种通信形式的起源可以追溯到 20 世纪 80 年代,随着分布式计算系统的出现,随着时间的推移,它已经发展成为标准实现。¹ 两种流行的实现是 XML-RPC 和 JSON-RPC。

图片 A-1

图 A.1 使用 RPC,程序从 API 服务器调用函数或子程序。

XML-RPC 是一种 RPC 协议,它使用可扩展标记语言 (XML) 通过 HTTP 在客户端和服务器之间交换数据。它由 Dave Winer 在 1998 年创建,并最终发展成为后来被称为 SOAP 的东西(见附录 A.2)。

随着 JavaScript 对象表示法(JSON)作为数据序列化格式的日益流行,RPC 的另一种实现形式是 JSON-RPC。它于 2005 年推出,为 API 客户端和服务器之间交换数据提供了一种简化的方式。如图 A.2 所示,JSON-RPC 有效载荷通常包括三个属性:

  • method—客户端希望在远程服务器上调用的方法或函数

  • params—在调用方法或函数时必须传递的参数

  • id—用于标识请求的值

图片 A-2

图 A.2 使用 JSON-RPC,API 客户端向 API 服务器发送请求,调用 calculate_price() 函数以获取中杯卡布奇诺的价格。服务器响应调用结果:$10.70。

相应地,JSON-RPC 响应负载包括以下参数:

  • result—被调用的方法或函数返回的值

  • error—在调用过程中引发的错误代码(如果有)

  • id—正在处理请求的 ID

RPC 是一种轻量级协议,允许您在不实现复杂接口的情况下驱动 API 集成。RPC 客户端只需要知道它需要在远程服务器上调用的函数的名称及其签名。它不需要像 REST 那样寻找不同的端点并遵守它们的模式。然而,API 消费者和生产者之间缺乏适当的接口层不可避免地倾向于在客户端和服务器实现细节之间创建紧密耦合。因此,实现细节的微小变化可能会破坏集成。因此,RPC 主要推荐用于内部 API 集成,在这种情况下,您可以完全控制客户端和服务器。

A.2 SOAP 和 API 标准的出现

本节讨论简单对象访问协议(SOAP)。SOAP 通过交换 XML 负载来实现与 Web 服务的通信。它于 1998 年由 Dave Winer、Don Box、Bob Atkisnon 和 Mohsen Al-Ghosein 为微软公司引入,经过多次迭代,于 2003 年成为 Web 应用程序的标准协议。SOAP 被构想为一个消息协议,它运行在数据传输层之上,例如 HTTP。

SOAP 被设计来满足三个主要目标:

  • 可扩展性—SOAP 可以通过其他消息系统中找到的功能进行扩展。

  • 中立性—它可以操作任何选择的数据传输协议,包括 HTTP,或者直接通过 TCP 或 UDP 等其他协议。

  • 独立性—它使得无论 Web 应用程序的编程模型如何,都可以进行通信。

与 SOAP 端点交换的负载以 XML 表示,如图 A.3 所示,它们包括以下属性:

  • Envelope (必需)—标识 XML 文档为 SOAP 负载

  • Header (可选)—包含关于消息中包含的数据的附加信息,例如编码类型

  • Body (必需)—包含请求/响应的负载(实际交换的消息)

  • Fault (可选)—包含在处理请求时发生的错误

图 A.3 在 SOAP 消息的顶部,我们找到一个名为 Envelope 的部分,它告诉我们这是一个 SOAP 负载。一个可选的 Header 部分包含了关于消息的元数据,例如编码类型。Body 部分包含了消息的实际负载:客户端和服务器之间交换的数据。最后,一个名为 Fault 的部分包括了在处理负载时出现的任何错误详情。

SOAP 对 API 领域做出了重大贡献。跨 Web 应用程序通信的标准协议的可用性导致了供应商 API 的出现。突然之间,通过简单地公开一个每个人都能理解和消费的 API,就可以销售数字服务。

近年来,SOAP 已被新的协议和架构所取代。导致 SOAP 衰落的因素包括这些:

  • 通过 SOAP 交换的有效负载包含大型 XML 文档,这消耗了大量的带宽。

  • XML 难以阅读和维护,它需要仔细解析,这使得交换以 XML 结构化的消息不太方便。

  • SOAP 没有提供一个清晰的框架来组织我们希望通过 API 公开的数据和能力。它提供了一种交换消息的方式,并且 API 两边的代理必须决定如何理解这些消息。

A.3 RPC 再次出击:在 gRPC 上快速交换

本节讨论了 RPC 协议的一个特定实现,称为 gRPC,²,该协议由谷歌在 2015 年开发。该协议使用 HTTP/2 作为传输层,并交换使用 Protocol Buffers(Protobuf)编码的有效负载——一种序列化结构化数据的方法。正如我们在第二章中解释的,序列化是将数据转换为可以存储或通过网络传输的格式的过程。另一个过程必须能够拾取保存的数据并将其恢复到原始格式。恢复序列化数据的过程也被称为反序列化

一些序列化方法是语言特定的,例如 Python 的pickle。其他一些,如流行的 JavaScript 对象表示法(JSON)格式,是语言无关的,并且可以转换为其他语言的本地数据结构。

JSON 的一个明显缺点是它只允许序列化由字符串、布尔值、数组、关联数组以及null值组成的简单数据表示。由于 JSON 是语言无关的,并且必须在语言和环境之间严格可传输,因此它不能允许序列化语言特定的功能,如 JavaScript 中的NaN(不是一个数字)、Python 中的元组或集合,或在面向对象语言中的类。

Python 的pickle格式允许您序列化在您的 Python 程序中运行的任何类型的数据结构,包括自定义对象。然而,缺点是序列化数据高度特定于您在导出数据时运行的 Python 版本。由于 Python 在不同版本之间内部实现的细微变化,您不能期望不同的过程能够可靠地解析一个 pickle 文件。

Protobuf 处于中间位置:它允许你定义比 JSON 更复杂的数据结构,包括枚举,并且能够从序列化数据生成原生类,你可以扩展这些类以添加自定义功能。如图 A.4 所示,在 gRPC 中,你必须首先使用 Protobuf 规范格式定义你想要通过 API 交换的数据结构的模式,然后使用 Protobuf 命令行工具自动生成客户端和 API 服务器的代码。

图片 A-4

图 A.4 gRPC 使用 Protobuf 对 API 交换的数据进行编码。使用 protoc 命令行工具,我们可以从 Protobuf 规范生成客户端和服务器端的代码(存根)。

从 Protobuf 规范生成的数据结构称为 存根。存根是用我们构建 API 客户端和服务器所使用的语言本地编写的代码实现的。如图 A.5 所示,存根负责解析和验证客户端和服务器之间交换的数据。

图片 A-5

图 A.5 使用 Protobuf 生成的存根负责解析 API 客户端和 API 服务器之间交换的有效载荷,并将它们转换为本地代码。

gRPC 提供了一种比普通 RPC 更可靠的 API 集成方法。使用 Protobuf 作为强制机制,确保客户端和服务器之间交换的数据符合预期的格式。它还有助于确保 API 通信高度优化,因为数据是以二进制格式直接交换的。因此,gRPC 是实现内部 API 集成的一个理想选择,其中性能是一个相关因素。³

A.4 基于 HTTP 的 REST API

本节解释了表示状态转移(REST)及其主要特性。REST 是一种用于设计网络服务和其接口的架构风格。正如我们在第四章中看到的,REST API 是围绕资源构建的。我们区分两种资源类型,集合和单例,并使用不同的 URL 路径来表示它们。例如,在图 A.6 中,/orders 代表订单集合,而 /orders/{order_id} 代表单个订单的 URI。我们使用 /orders 来检索订单列表和创建新订单,并使用 /orders/{order_id} 来对单个订单执行操作。

图片 A-6

图 A.6 REST API 的结构围绕端点构建。我们区分单例端点,例如 GET /orders/8,和集合端点,例如 GET /orders。利用 HTTP 方法的语义,REST API 响应包括 HTTP 状态码,用以指示请求处理的结果。

良好的 REST API 设计利用 HTTP 协议的功能来提供高度表达的 API。例如,如图 A.7 所示,我们使用 HTTP 方法来定义 API 端点并表达它们的意图(POST 用于创建资源,GET 用于检索资源);我们使用 HTTP 状态码来表示请求处理的结果;我们使用 HTTP 有效载荷在客户端和服务器之间传输交换数据。

我们使用 OpenAPI 标准来记录 REST API,该标准最初于 2010 年由 Tony Tam 创建,当时名为 Swagger API。随着项目的流行,2015 年启动了 OpenAPI 创新计划以维护该规范。2016 年,该规范正式以 OpenAPI 规范(OAS)的名义发布。

通过 REST API 交换的数据位于 HTTP 请求/响应的主体中。API 生产者可以将其编码为任何他们希望实施的格式,但通常的做法是使用 JSON。

由于可以在标准规范格式中创建具有高度详细信息的 API 文档,因此 REST 是企业 API 集成和构建面向大量且多样化的消费者的公共 API 的理想选择。

A.5 使用 GraphQL 进行细粒度查询

本节解释了 GraphQL 以及它与 REST 的比较。GraphQL 是一种基于图和节点的查询语言。截至本文撰写时,它是实现 Web API 最受欢迎的选择之一。⁴ 它由 Facebook 于 2012 年开发并于 2015 年公开发布。

GraphQL 被设计用来解决 REST API 的一些局限性,例如通过 HTTP 端点表示某些操作的困难。例如,假设您通过 CoffeeMesh 网站订购了一杯咖啡,后来您改变了主意并决定取消订单。哪种 HTTP 方法最适合表示这个动作?您可以争论说取消订单类似于删除,因此可以使用 DELETE 方法。但是,取消真的等同于删除吗?您在取消后是否会从您的记录中删除订单?可能不会。您可以争论说它应该是一个 PUT 或 PATCH 请求,因为您正在将订单的状态更改为“已取消”。或者您可以说它应该是一个 POST 请求,因为用户正在触发一个涉及更多不仅仅是更新记录的操作。无论如何看待这个问题,HTTP 在建模用户动作时确实存在一些局限性,而 GraphQL 通过不将自己限制在仅使用 HTTP 协议的元素来解决这个问题。

REST 的另一个限制是客户端无法对数据进行细粒度请求,这在技术上被称为过度获取。例如,想象一个 API 公开了/products/ingredients资源。如图 A.7 所示,使用/products我们可以获取产品列表,包括其成分的 ID。然而,如果我们想获取每个成分的名称,我们必须向/ingredients API 请求每个成分的详细信息。结果是 API 客户端需要向 API 发送各种请求以获取产品的简单表示。API 客户端还接收了比所需更多的信息:在针对/ingredients API 的每个请求中,客户端接收了每个成分的完整描述,而它只需要名称。过度获取对于如手机等小型设备来说是一个挑战,这些设备可能无法处理和存储大量数据,并且可能具有更有限的网络访问。

图 A-7

图 A.7 REST API 的一个限制是 API 客户端无法对数据进行细粒度请求,这被称为过度获取。在图中,/products端点返回包含其成分 ID 的产品列表。为了获取成分的名称,客户端必须从/ingredients端点请求每个成分的详细信息。结果,API 客户端最终向服务器发送过多的请求,并接收比所需更多的数据。

GraphQL 通过允许客户端在服务器上执行细粒度查询来避免这些问题。使用 GraphQL,我们可以创建不同数据模型之间的关系,允许 API 客户端从相关实体中获取数据。例如,在图 A.8 中,API 客户端可以请求产品列表及其成分名称的单个请求。通过允许客户端在单个请求中从服务器检索所需数据,GraphQL 是 API 的理想选择,这些 API 由具有有限网络访问或有限存储能力的客户端消费,如移动设备。GraphQL 也是高度互联资源 API 的良好选择,其中用户很可能从相关实体中获取数据,如图 A.8 中的产品和成分。

图 A-8

图 A.8 使用 GraphQL API,我们可以查询相关实体中的数据,例如产品和成分。在此图中,API 客户端请求带有其成分名称的产品列表。

尽管 GraphQL 有其优点,但它也伴随着一些限制。GraphQL 的一个主要限制是它对自定义标量类型的支持并不充分。GraphQL 自带一组基本的内置标量,例如整数(Int)和字符串(String)。GraphQL 允许你声明自己的自定义标量,但你无法使用 SDL 文档化它们的形状或验证方式。正如 GraphQL 官方文档所说,“定义该类型如何序列化、反序列化和验证取决于我们的实现” (graphql.org/learn/schema/)。由于稳健的 API 集成的一个基石是优秀的文档,因此 GraphQL 对于必须被外部客户端可靠消费的公共 API 来说是一个具有挑战性的选择。

GraphQL 的另一个限制是所有查询通常都使用 POST 请求来完成,这使得缓存响应变得更加困难。根据我的经验,大多数开发者也发现与 GraphQL API 交互更加困难。实际上,Postman 的 2022 年 API 状态报告发现,只有 28%的调查开发者使用 GraphQL,其中高达 14%的人甚至没有听说过它。虽然与 REST API 交互可能只需简单地调用 GET 端点,但使用 GraphQL 你必须知道如何构建查询文档以及如何将它们发送到服务器。由于开发者对 GraphQL 不太熟悉,选择这项技术可能会使你的 API 不太可能被消费。


¹ 布鲁斯·杰·尼尔森在他的博士论文(技术报告 CSL-81-9,Xero 帕洛阿尔托研究中心,帕洛阿尔托 CA,1981 年)中引入了术语远程过程调用。关于 RPC 实现要求的更正式描述,请参阅 Andrew B. Birrell 和 Bruce Jay Nelson 的“Implementing Remote Procedure Calls”,ACM Transactions on Computer Systems,第 2 卷,第 1 期,1984 年,第 39-59 页。

² 你肯定想知道 gRPC 中的“g”代表什么。根据官方文档,每个版本中“g”代表不同的单词。例如,在版本 1.1 中它代表“good”,而在版本 1.2 中它代表“green”,以此类推 (grpc.github.io/grpc/core/md_doc_g_stands_for.html)。有些人认为“g”代表 Google,因为该协议是由 Google 发明的(参见 Bleeding Edge Press 的“Is gRPC the Future of Client-Server Communication?”,Medium,2018 年 7 月 19 日,medium.com/@EdgePress/is-grpc-the-future-of-client-server-communication-b112acf9f365)。

³ 根据 Postman 的 2022 年 API 状态报告,11%的调查开发者使用 gRPC (www.postman.com/state-of-api/api-technologies/#api-technologies)。

根据 Postman 的 2022 年 API 状态报告,28% 的受访开发者使用 GraphQL (www.postman.com/state-of-api/api-technologies/#api-technologies).

附录 B. 管理 API 的生命周期

API 很少是静态的。随着你的产品发展,你需要通过 API 公开新的功能和特性,这意味着你需要创建新的端点或更改你的模式以引入新的实体或字段。通常,API 变更是不向后兼容的,这意味着未意识到新变更的客户端将收到失败的响应。管理 API 的一部分是确保你做出的任何变更都不会破坏与其他应用程序已经存在的集成,而 API 版本控制就是为了这个目的。在本附录中,我们研究 API 版本控制策略来管理 API 变更。

除了发展和变化之外,API 有时也会结束。也许你正在将 REST API 迁移到 GraphQL,或者你正在完全停止一个产品。如果你计划弃用 API,你必须让你的客户知道何时以及如何发生,在本附录的第二部分,你将学习如何向用户广播此信息。

B.1 API 版本控制策略

让我们看看我们如何使用版本控制来管理 API 变更。我们为 API 使用两种主要的版本控制系统:

  • 语义版本控制(SemVer,semver.org/)——这是最常见的版本控制类型,它被广泛用于管理软件发布。其格式如下:MAJOR.MINOR.PATCH,例如,1.1.0。第一个数字表示发布的主版本号,第二个数字表示次版本号,第三个数字表示补丁版本号。

    每当你对 API 进行重大变更时,主版本号就会发生变化,例如,当请求负载中需要新的字段时。次版本号代表 API 的非破坏性变更,例如引入新的可选查询参数。API 消费者期望能够在不同的次版本上以相同的方式调用你的端点,并继续获得响应。补丁版本表示错误修复。

    在 API 的上下文中,我们通常只使用主版本号,所以我们可能有 API 的 v1 和 v2 版本。一般而言,改进 API 的次级变更和补丁可以安全地推出,而不会破坏现有的集成。

  • 日历版本控制(CalVer,calver.org/)——日历版本控制使用日历来为发布版本。当你的 API 变更非常频繁,或者你的发布对时间敏感时,这个系统非常有用。越来越多的软件产品使用日历版本控制,包括 Ubuntu (ubuntu.com/)。AWS 也在其一些产品中使用了日历版本控制,例如 CloudFormation (mng.bz/epQZ) 和 S3 API (mng.bz/p6B0)。

    CalVer 没有提供关于如何格式化版本的完整规范;它只强调使用日期。一些项目使用 YYYY.MM.DD 的格式,而另一些则使用 YY.MM。如果您每天发布多个版本,您可以使用额外的计数器来跟踪每个版本,例如,2022.12.01.3,这意味着这是在 2022 年 12 月 12 日发布的第三个版本。(有关日历版本化的更多详细信息,请参阅mng.bz/O6MO。)

哪种版本控制系统更好?这取决于您的具体需求和整体 API 管理策略。SemVer 因其直观性而被更广泛地使用。然而,如果您的产品发布对时间敏感,CalVer 可能更适合。您选择的版本控制系统也将受到您的版本管理策略的影响,因此让我们看看我们用来表示 API 版本的不同方法:

  • 使用 URL 进行版本控制—您可以将 API 版本嵌入到 URL 中,例如,coffeemesh.com/api/v1/coffee。这非常方便,因为您的 API 消费者知道他们始终能够调用相同的端点并获得相同的结果。如果您发布 API 的新版本,该版本将进入不同的 URL 路径(/api/v2),因此不会与之前的发布冲突。这也使得您的 API 更容易探索,因为要发现和测试其不同版本,API 消费者只需更改 URL 中的版本字段。然而,在处理 REST API 时,使用 URL 来管理版本被认为违反了 REST 原则,因为每个资源应该只由一个且仅一个 URI 表示。

  • 使用Accept头部字段进行版本控制—API 消费者使用Accept HTTP 请求头部字段来声明他们可以解析的内容类型。在 API 的上下文中,Accept头部字段的典型值是application/json,这意味着客户端只接受 JSON 格式的数据。由于 API 版本也影响我们从服务器接收的内容类型,我们可以使用Header字段来声明我们想要使用的 API 版本。一个指定内容类型和 API 版本的Header字段示例是Accept: application/json;v1

    这种方法与 REST 原则更为和谐,因为它不修改资源端点,但需要仔细解析。在头部值中引入额外的字符,如以下片段所示,可能导致运行时错误:

    Accept: application/json; v1  # note the additional space after the 
    ➥ semicolon
    

    由于我们使用Accept头部,对于 API 版本声明中的任何错误,或者当客户端请求不可用的 API 版本时,我们以 415(不支持的媒体类型)响应。

  • 使用自定义 Request Header 字段进行版本控制——在这种方法中,你使用一个自定义的Request Header字段,例如Accept-version,来指定你想要使用的 API 版本。这种方法是最不受欢迎的,因为某些框架可能不接受非标准的Header字段,这可能导致与客户的集成问题。

每种版本控制策略都有其自身的优点和挑战。URL 版本控制是最广泛采用的策略,因为它直观且易于使用。然而,在 URL 中指示 API 版本也意味着我们的资源 URI 会根据 API 的版本而变化,这可能会让一些客户感到困惑。

使用Accept头信息是另一个流行的选项,但它将处理我们的媒体类型的逻辑与处理我们的 API 版本的逻辑耦合在一起。此外,使用相同的错误状态码来处理媒体类型和 API 版本可能会让我们的 API 客户端感到困惑。最好的策略是仔细考虑我们应用程序的需求,并与你的 API 客户端就最理想的解决方案达成一致。

B.2 管理你的 API 的生命周期

在本节中,我们研究如何优雅地弃用我们的 API。API 不会永远存在;随着你通过 API 提供的产品和服务的发展和变化,一些 API 将变得过时,你最终将淘汰它们。然而,你可能有一些外部消费者,他们的系统依赖于你的 API,因此你不能随意关闭它们,以免给客户造成干扰。你必须协调你的 API 弃用过程,正如你所看到的,我们使用特定的 HTTP 头来通知 API 的弃用。让我们看看它是如何工作的!

在你退役一个 API 之前,你应该首先弃用它。一个已弃用的 API 仍然在服务中,但它缺乏维护、增强和修复。一旦你弃用了你的 API,你的用户就不会期望它们有进一步的变更。弃用为用户提供了一个宽限期,以便他们有时间将系统迁移到新的 API,而不会干扰他们的操作。

一旦你决定弃用你的 API,你应该通过标准通信渠道,例如通过电子邮件或在通讯稿中,通知你的 API 消费者。同时,你应该在你的响应中设置Deprecation头信息。¹ 如果 API 将在未来被弃用,我们将Deprecation头信息设置为 API 将被弃用的日期:

Deprecation: Friday, 22nd March 2025 23:59:59 GMT

一旦 API 被弃用,我们将Deprecation头信息设置为true

Deprecation: true

你还可以使用Link头信息来提供有关你的 API 弃用过程的额外信息。例如,你可以提供一个链接到你的弃用策略:

Link: <https://coffeemesh.com/deprecation>; rel=”deprecation”; 
➥ type=”text/html”

在这种情况下,我们正在告诉用户,他们可以点击coffeemesh.com/deprecation链接来找到有关 API 弃用的更多信息。

如果您正在弃用 API 的旧版本,您可以使用 Link 标头提供替换或取代当前 API 版本的 URL:

Link: <https://coffeemesh.com/v2.0.0/coffee>; rel=”successor-version”

除了广播您的 API 弃用信息外,您还应该宣布 API 将何时被弃用。我们使用 Sunset 标头来表示 API 将何时被弃用:²。

Sunset: Friday, 22nd June 2025 23:59:59 GMT

Sunset 标头的日期必须晚于或与 Deprecation 标头中给出的日期相同。一旦您弃用了一个 API,您必须让您的 API 客户知道旧端点不再可用。当用户调用旧 API 时,您可以使用任何 3xx 和 4xx 状态码的组合。一个好的选择是 410(已消失)状态码。我们使用 410 状态码来表示请求的资源因已知原因不再存在。在某些情况下,301(永久移动)可能是有用的。我们使用 301 状态码来表示请求的资源已被分配了新的 URI,因此当您将 API 迁移到新的端点时,这可能是有用的。

正确管理 API 变更和弃用是确保提供高质量和可靠 API 集成的一个关键但常常被忽视的要素。通过应用本附录中的建议,您将能够有信心地演进您的 API,而不会破坏与客户的集成。


¹ Sanjay Dalal 和 Erik Wilde,“The Deprecation HTTP Header Field,”datatracker.ietf.org/doc/html/draft-ietf-httpapi-deprecation-header-02

² Erik Wilde,“The Sunset HTTP Header Field,”RFC 8594,tools.ietf.org/html/rfc8594

附录 C. 使用身份提供者进行 API 授权

在第十一章中,你学习了开放授权(OAuth)和开放 ID 连接(OIDC)协议的工作原理。你还学习了如何生成、检查和验证 JSON Web Tokens(JWT)。最后,你学习了向 API 添加授权中间件的模式。我们仍需回答的问题是,我们如何构建一个端到端的身份验证和授权系统?

你可以使用各种策略来处理身份验证和授权。你可以构建自己的身份验证服务,或者使用身份即服务提供商,如 Auth0、Okta、Azure Active Directory 或 AWS Cognito。除非你是网络安全和身份验证协议的专家,并且有足够的资源正确构建系统,否则我建议你使用身份服务提供商。在本附录中,你将学习如何使用 Auth0 为你的 API 添加身份验证,Auth0 是最受欢迎的身份管理系统之一。

我们将使用 Auth0 的免费计划。Auth0 负责管理用户账户和发放安全令牌,它还提供了与 Google、Facebook、Twitter 等身份提供者的社交登录的简单集成。Auth0 的身份验证系统基于标准,因此你关于使用 Auth0 进行身份验证所学的所有内容都适用于任何其他提供商。如果你在自己的项目或工作中使用不同的身份验证系统,你将能够将本附录中的经验教训应用到你所使用的任何其他系统上。

本附录的代码可在本书 GitHub 仓库的附录 _c 文件夹中找到。我建议你拉取此代码以跟随示例,特别是名为 appendix_c/ui 的文件夹,因为你将需要它来运行 C.2 节中的示例。

C.1 使用身份即服务提供商

本节解释了如何将我们的代码与身份即服务(IDaaS)提供商集成。IDaaS 提供商是一种负责处理用户身份验证和为我们的用户提供访问令牌的服务。使用 IDaaS 提供商很方便,因为它意味着我们可以将时间和精力集中在构建我们的 API 上。好的 IDaaS 提供商基于标准并具有强大的安全协议,这也有助于降低我们服务器的安全风险。在本节中,你将学习如何与 Auth0 建立集成,Auth0 是最受欢迎的 IDaaS 提供商之一。

要与 Auth0 一起工作,首先创建一个账户,然后根据 Auth0 的文档(auth0.com/docs/get-started)创建一个租户。作为第一步,前往你的仪表板并创建一个 API 来表示订单 API。按照图 C.1 进行配置,将其标识符的值配置为 http://127.0.0.1:8000/orders,并选择 RS256 签名算法。

图片

图 C.1 要创建一个新的 API,请点击创建 API 按钮,并在表单中填写 API 的名称、其 URL 标识符以及你想要用于其访问令牌的签名算法。

一旦你创建了 API,前往权限并添加一个权限范围到 API,如图 C.2 所示。

图片 C-2

图 C.2 要向 API 添加权限范围,请点击权限选项卡,并填写添加权限(范围)表单。

接下来,点击左侧栏的设置,然后点击自定义域名选项卡,如图 C.3 所示。

图片 C-3

图 C.3 要找出你的租户默认域名,请转到租户设置页面并点击自定义域名选项卡。

如果你想要,你可以添加一个自定义域名,或者你可以使用 Auth0 为你的租户提供的默认域名。我们使用这个域名来构建我们认证服务的知名 URL:

https://<tenant>.<region>.auth0.com/.well-known/openid-configuration

例如,对于 CoffeeMesh,租户的域名是coffeemesh-dev.eu.auth0.com/.well-known/openid-configuration

现在调用这个 URL,并捕获jwks_uri属性,它表示返回我们可以用来验证 Auth0 令牌的公钥的 URL。以下是一个示例:

$ curl https://coffeemesh.eu.auth0.com/.well-known/openid-configuration \
| jq .jwks_uri
# output:
"https://coffeemesh-dev.eu.auth0.com/.well-known/jwks.json"

如果你调用这个 URL,你会得到一个包含每个租户公钥信息的对象数组。每个对象看起来像这样:

{
  "alg": "RS256",
  "kty": "RSA",
  "use": "sig",
  "n": "sV2z9AApyKK-
➥ Zo9vrzHbonNsHTgYiIOx1dHx3U102fUhPFzUcdnjb7li960iTKyTbFlMRbsN2fFZOHa5_4Q
➥ 3C7UzjkVw__jK3AcPZ-0cCiLBS-HQzE_6ii-OPo84-
➥ W9Pp2ScKdAlJIqBimDtNv8vuOEMr5c5YbJz1HlppFY_hA71dgc101SHp0n9GZYqP5HV713m
➥ 6smE5b7abHLqrUSz9eVbSOrTUOcSd5_LUHvQqFb5Wt7kRalIiHnQFob-
➥ cyM1AmxDNsX1qR2cX_jqjWCRO2iK5DTG--ure8GQUTCMPZ0LkBKSDelTwHuEn_r4z-
➥ x30wf-2lA0yzMSlcxcJIojpQ",
  "e": "AQAB",
  "kid": "ZweIFRR4l1dJlVPHOoZqf",
  "x5t": "OJXBmAMkfObrQ9YkfUb4O20l_us",
  "x5c": [
    "MIIDETCCAfmgAwIBAgIJUbXpEMz8nlmXMA0GCSqGSIb3DQEBCwUAMCYxJDAiBgNVBAMTG2NvZm
➥ ZlZW1lc2gtZGV2LmV1LmF1dGgwLmNvbTAeFw0yMTEwMjkyMjQ4MjBaFw0zNTA3MDgyMjQ4M[
➥ jBaMCYxJDAiBgNVBAMTG2NvZmZlZW1lc2gtZGV2LmV1LmF1dGgwLmNvbTCCASIwDQYJKoZI
➥ hvcNAQEBBQADggEPADCCAQoCggEBALFds/QAKciivmaPb68x26JzbB04GIiDsdXR8d1NdNn
➥ 1ITxc1HHZ42+5YvetIkysk2xZTEW7DdnxWTh2uf+ENwu1M45FcP/4ytwHD2ftHAoiwUvh0M
➥ xP+oovjj6POPlvT6dknCnQJSSKgYpg7Tb/L7jhDK+XOWGyc9R5aaRWP4QO9XYHNdNUh6dJ/
➥ RmWKj+R1e9d5urJhOW+2mxy6q1Es/XlW0jq01DnEnefy1B70KhW+Vre5EWpSIh50BaG/nMj
➥ NQJsQzbF9akdnF/46o1gkTtoiuQ0xvvrq3vBkFEwjD2dC5ASkg3pU8B7hJ/6+M/sd9MH/tp
➥ QNMszEpXMXCSKI6UCAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUWrl+q/
➥ l4wp/MWDdYrhjxns0iP2wwDgYDVR0PAQH/BAQDAgKEMA0GCSqGSIb3DQEBCwUAA4IBAQA+Y
➥ H+sxcMlBzEOJ5hJgZw1upRroCgmeQzEh+Cx73sTKw+vi8u70bdkDt9sBLKlGK9xbPJt3+QW
➥ ZDJF9rwx4vXbfFvxZD+dthIvn4NH4/sLQXG20JN/b6GtHdVllbJIGUeWb8DBsx94wXYMwag
➥ 0gXUk5spgaGGdoc16uSrrbxt/rmzFk3VMQ8qG5i8E33N/DZb88P4u3WJMNMsmujw9Q8meg4
➥ ygEFadXBcfJPHuiriLWi0j1Gm+m6DZQM51OtpQ/cvcZXRNPogqj7wsZXH4za9DJjnQf8ZOK
➥ Q86WKl/9CE5AvHBTTTr810DviJIqv8sqC866+2t2euxcfOYMIw5E42o"
  ]
}

在这个有效载荷中,最重要的两个字段是kidx5ckid是密钥的 ID,我们用它来匹配 JWT 头部的kid字段。它告诉我们需要使用哪个密钥来验证令牌的签名。x5c字段包含一个以 X.509 证书形式存在的公钥数组,我们使用其中的第一个来验证 JWT 的签名。

这是我们集成我们的代码与 Auth0 所需的所有信息。我们将在第十一章(11.4.1 节)中创建的 orders/web/api/auth.py 模块中实现我们的 Auth0 集成,该模块用于封装我们的授权代码。删除 orders/web/api/auth.py 的内容,并用列表 C.1 的内容替换。我们首先导入必要的依赖项,创建 X.509 证书的模板,并从知名端点加载公钥。X.509 证书被包裹在-----BEGIN CERTIFICATE----------END CERTIFICATE-----语句之间,因此我们的模板包括这两个语句,并使用一个名为key的模板变量,我们将用实际密钥替换它。

由于 Auth0 使用多个密钥来签名令牌,我们通过调用 JWKS 端点来加载公钥,并动态加载给定令牌的正确密钥。如图 C.4 所示,令牌头部的 kid 属性告诉我们需要使用哪个密钥,我们的自定义函数 _get_certificate_for_kid() 找到令牌的 kid 对应的 X.509 证书。为了加载密钥,我们使用 cryptographyload_pem_x509_certificate() 函数,传入格式化为我们的 X.509 字节编码证书的公钥。

图 C.4 要验证 JWT,我们使用其对应的签名密钥验证其签名。签名密钥可在 JWKS 端点找到。

由于令牌可以使用不同的算法进行签名,我们直接从令牌的头部获取算法。Auth0 签发的令牌可以访问我们的 API 和用户信息 API,因此我们在受众中包含这两个服务。

列表 C.1 向 API 添加授权模块

# file: orders/web/api/auth.py

import jwt
import requests
from cryptography.x509 import load_pem_x509_certificate

X509_CERT_TEMPLATE = (
       "-----BEGIN CERTIFICATE-----\n{key}\n-----END CERTIFICATE-----"     ①
   )

public_keys = requests.get(
    "https://coffeemesh-dev.eu.auth0.com/.well-known/jwks.json"
).json()["keys"]                                                           ②

def _get_certificate_for_kid(kid):                                         ③
    """
    Return the public key whose ID matches the provided kid.
    If no match is found, an exception is raised.
    """
    for key in public_keys:
        if key["kid"] == kid:                                              ④
            return key["x5c"][0]
    raise Exception(f"Not matching key found for kid {kid}")               ⑤

def load_public_key_from_x509_cert(certificate):                           ⑥
    """
    Loads the public signing key into a RSAPublicKey object. To do that,
    we first need to format the key into a PEM certificate and make sure
    it's utf-8 encoded. We can then load the key using cryptography's
    convenient `load_pem_x509_certificate` function.
    """
    return load_pem_x509_certificate(certificate).public_key()             ⑦

def decode_and_validate_token(access_token):                               ⑧
    """
    Validates an access token. If the token is valid, it returns the token 
    payload.
    """
    unverified_headers = jwt.get_unverified_header(access_token)           ⑨
    x509_certificate = _get_certificate_for_kid(
        unverified_headers["kid"]                                          ⑩
    )
    public_key = load_public_key_from_x509_cert(                           ⑪
        X509_CERT_TEMPLATE.format(key=x509_certificate).encode("utf-8")
    )
    return jwt.decode(                                                     ⑫
        access_token,
        key=public_key,
        algorithms=unverified_headers["alg"],                              ⑬
        audience=[                                                         ⑭
            "http://127.0.0.1:8000/orders",
            "https://coffeemesh-dev.eu.auth0.com/userinfo",
        ],
    )

① X509 证书的模板

② 我们从租户的已知端点拉取签名密钥列表。

③ 返回给定密钥 ID 的证书的函数

④ 我们寻找与提供的密钥 ID 匹配的证书。

⑤ 如果找不到匹配项,我们抛出异常。

⑥ 加载给定证书的公钥对象的函数

⑦ 我们加载公钥。

⑧ 解码和验证 JWT 的函数

⑨ 我们获取令牌的头部信息而不进行验证。

⑩ 我们获取与令牌的密钥 ID 对应的证书。

⑪ 我们加载与令牌的密钥 ID 对应的证书的公钥对象。

⑫ 我们验证并解码令牌。

⑬ 我们使用令牌头部指示的算法验证令牌的签名。

⑭ 我们传递令牌预期的受众列表。

我们准备出发!订单服务现在能够验证由 Auth0 签发的令牌。以下章节将说明如何利用此集成使我们的 API 服务器对单页应用(SPA)和另一个微服务可用。

C.2 使用 PKCE 授权流程

在 PKCE 流程中,API 客户端直接从授权服务器请求 ID 令牌和访问令牌。正如我们在第十一章中解释的,我们必须使用访问令牌与 API 服务器交互。ID 令牌可以在 UI 中用于显示用户的详细信息,但它绝不能发送到我们的 API 服务器。

为了说明此流程的工作原理,我在本书的 GitHub 仓库的附录 _c/ui 目录下包含了一个 SPA。该 SPA 是一个使用 Vue.js 构建的简单应用程序,它与订单 API 进行通信,并配置为使用 Auth0 服务器进行身份验证。

我们首先配置应用程序。转到您的 Auth0 账户,创建一个新的应用程序。选择单页 Web 应用程序,给它一个名称,然后点击创建。在应用程序设置页面中,在应用程序 URI 部分,将 http://localhost:8000 的值分配给允许回调 URL、允许注销 URL、允许 Web 来源和允许来源(CORS)字段。从应用程序设置中,我们需要两个值来配置我们的应用程序:域名和客户端 ID。打开 ui/.env.local 文件,并将VUE_APP_AUTH_CLIENT_ID的值替换为客户端 ID,将VUE_APP_AUTH_DOMAIN替换为 Auth0 应用程序设置页面中的域名。

要运行 UI,您需要一个最新的 Node.js 和 npm 版本,您可以从 node.js 网站下载(nodejs.org/en/)。一旦安装了这些,您需要使用以下命令安装 yarn:

$ npm install -g yarn

接下来,cd到 ui/文件夹,并运行以下命令来安装依赖项:

$ yarn

一旦配置了应用程序,您可以通过执行以下命令来运行它:

$ yarn serve --mode local

应用程序将在 http://localhost:8080 地址下可用。请确保订单 API 也在运行,因为 Vue.js 应用程序会与之通信。要从订单文件夹运行订单 API,请执行以下命令:

$ AUTH_ON=True uvicorn orders.web.app:app –-reload

一旦您通过 UI 注册了用户,您将能够在 UI 中看到您的授权令牌。您可以使用此令牌直接从终端调用 API。例如,您可以通过以下命令获取您用户的订单列表:

$ curl http://localhost:8000/orders \
-H 'Authorization: Bearer <ACCESS_TOKEN>'

通过 Vue.js 应用程序,您可以通过点击“显示我的订单”按钮创建新订单并显示用户放置的订单。

PKCE 流适用于通过浏览器访问您的 API 的用户。然而,这种流对于机器到机器通信来说并不方便。为了允许对 API 进行更多程序化访问,您需要支持客户端凭据流。在下一节中,我们将解释如何启用该流!

C.3 使用客户端凭据流

本节解释了如何实现客户端凭据流以实现服务器到服务器的通信。当我们必须对我们的服务进行身份验证以访问其他 API,或者当我们想允许对 API 进行程序化访问时,我们使用服务器到服务器的流。在客户端凭据流中,我们的服务通过提供与客户端 ID 和期望受众共享的秘密来从认证服务请求访问令牌。然后我们可以使用这个访问令牌来访问目标受众的 API。

要使用此授权流程,您需要使用您的 IDaaS 提供商注册一个服务器到服务器的客户端。在您的 Auth0 仪表板的“应用程序”页面中,点击“创建应用程序”并选择“机器到机器应用程序”。给它起一个名字,然后点击“创建”。在下一屏幕上,当您被要求选择要为此客户端授权的 API 时,选择订单 API,然后选择我们在第十一章(第 11.6 节)中创建的权限。一旦注册了客户端,您将获得一个客户端 ID 和一个客户端密钥,您可以使用它们来获取访问令牌。

列表 C.2 展示了如何实现服务器到服务器的授权以获取访问令牌并调用订单 API。列表 C.2 中的代码可在本书的 GitHub 仓库中找到,位于 machine_to_machine_test.py 文件下。我们创建了一个函数,通过调用 POST https://coffeemesh-dev.eu.auth0.com/oauth/token端点从授权服务器获取访问令牌。在有效载荷中,我们提供了客户端 ID 和客户端密钥,并指定了我们想要生成访问令牌的受众。我们还声明我们想要在grant_type属性下使用客户端凭据流。如果客户端认证正确,我们将获得一个访问令牌,然后我们使用它来调用订单 API。

列表 C.2 为机器到机器访问订单 API 授权客户端

# file: machine_to_machine_test.py

import requests

def get_access_token():
    payload = {
        "client_id": "<client_id>",
        "client_secret": "<client_secret>",
        "audience": "http://127.0.0.1:8000/orders",
        "grant_type": "client_credentials"
    }

    response = requests.post(
        "https://coffeemesh-dev.eu.auth0.com/oauth/token",
        json=payload,
        headers={'content-type': "application/json"}
    )

    return response.json()['access_token']

def create_order(token):
    order_payload = {
        'order': [{
            'product': 'cappuccino',
            'size': 'small',
            'quantity': 1
        }]
    }

    order = requests.post(
        'http://127.0.0.1:8000/orders',
        json=order_payload,
        headers={
            "content-type": "application/json", 
            "authorization": f"Bearer {token}",
        }
    )

    return order.json()

access_token = get_access_token()
print(access_token)
order = create_order(access_token)
print(order)

使用客户端凭据流只需这些步骤!在下一节中,您将学习如何使用 Swagger UI 进行请求认证,以便您可以更轻松地测试您的 API。

C.4 在 Swagger UI 中授权请求

在本书的整个过程中,您已经学会了使用 Swagger UI 测试您的 API。您可以使用 Swagger UI 来测试您的 API 授权,在本节中您将学习如何操作。首先,cd到附录 C 的 orders 目录,并带上授权启动 API 服务器:

$ AUTH_ON=True uvicorn orders.web.app:app --reload

您现在可以访问 http://localhost:8000/docs/orders 上的 Swagger UI。如图 C.5 所示,如果您尝试任何端点,您将收到 401 响应,因为我们尚未授权我们的请求。

图 C-5

图 C.5 如果我们使用 Swagger UI 进行未经授权的请求,我们将收到 401 响应。

要授权一个请求,点击屏幕右上角的“授权”按钮。您将获得一个弹出菜单,其中包含 API 规范中记录的安全方案:openId(授权代码和 PKCE 流程)、oauth2(客户端凭据流)和bearerAuth。测试 API 授权层最简单的方法是使用bearerAuth安全方案,因为它只需要您提供授权令牌。您可以使用 Vue.js 应用程序在附录 C 的 ui 或使用 machine_to_machine_test.py 脚本生成令牌。例如,如果您运行 machine_to_machine_test.py 脚本,您将获得一个令牌和创建订单的结果:

$ python machine_to_machine_test.py
# output:
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Ilp3ZUlGUlI0bDFkSmxWUEhPb1px...
➥ {'order': [{'product': 'latte', 'size': 'small', 'quantity': 1}], 'id': 
➥ '6e420d2e-b213-4d15-bc46-0c680e590154', 'created': '2022-06-
➥ 07T09:01:47.757223', 'status': 'created'}

复制令牌,并将其粘贴到bearerAuth安全方案的值输入字段中,如图 C.6 所示,然后点击授权。如果你现在向 GET /orders端点发送请求,你会得到一个成功的响应。在令牌有效(即,在它过期之前),你可以尝试任何其他端点,你的请求将会被成功处理。

图 C.6

图 C.6 要授权一个请求,请将授权令牌粘贴到bearerAuth表单的值输入框中。

这就是使用 Swagger UI 测试你的 API 授权的全部步骤。你刚刚学习了如何通过集成外部身份提供者来添加一个强大的身份验证和授权层,如何测试 PKCE 和客户端凭证流程,以及如何使用 Swagger 测试你的 API 授权实现。你现在可以开始构建安全的 API 了!

posted @ 2025-11-24 09:12  绝不原创的飞龙  阅读(15)  评论(0)    收藏  举报