Python-微服务开发第二版-全-
Python 微服务开发第二版(全)
原文:
zh.annas-archive.org/md5/35addab4b24c5e216943fa4ac1758aac
译者:飞龙
前言
《Python 微服务开发》介绍了使用流行的 Python 编程语言和 Quart 网络框架设计和创建基于微服务的应用程序。在这本书中,你将了解微服务架构以及它与传统单体方法的区别,它的益处,以及需要克服的潜在问题。
本书面向的对象
这本书是为那些熟悉 Python 编程语言基础并希望开始编写网络服务,或者继承了需要现代化的网络服务的人而写的。预期读者将熟悉诸如函数和循环等简单的 Python 结构,以及一些更高级的特性,如装饰器。对使用网络应用有一定的了解会有所帮助,尽管应用设计的原理已经涵盖。
本书涵盖的内容
第一章,理解微服务,介绍了微服务的概念,单体应用与微服务之间的区别,常见的益处和陷阱,以及测试和扩展。
第二章,发现 Quart,涵盖了 Quart 网络框架以及它如何响应请求、创建模板文档、充当中间件、处理错误和读取配置的方式。
第三章,编码、测试和文档:良性循环,教你了解可能的不同类型的测试,每种类型的好处,以及如何设置自动测试,以及如何在 CI 管道中生成文档。
第四章,设计 Jeeves,探讨了 Jeeves,这是我们在这本书中用来解释微服务背后各种概念的示例应用。我们介绍了 Jeeves 需要完成的工作,并描述了应用设计的单体方法,包括网络 API 接口、数据库使用和工作者池。
第五章,拆分单体,基于前一章中描述的单体 Jeeves。本章提供了如何识别可能成为良好微服务的组件、衡量变化对软件架构的影响,以及如何干净地将功能迁移到新的微服务的指导。
第六章,与其他服务交互,解释了如何向其他服务发送网络请求,如何配置和决定将查询发送到何处,以及如何缓存结果,以及如何使数据传输更高效。
第七章,保护您的服务,探讨了身份验证、令牌、加密和安全漏洞都是任何服务必须考虑的基本话题,在这里我们为 Jeeves 构建了一个身份验证微服务,并讨论了在使应用程序免受攻击时需要考虑的各种事项。
第八章,制作仪表盘,讨论了众多应用程序将具有人类视图。在本章中,我们向 Jeeves 添加了一个仪表盘,以便可以使用 ReactJS 应用程序进行控制,同时讨论在微服务架构中最佳添加前端的位置。
第九章,打包和运行 Python,展示了创建应用程序后,需要打包以便部署和运行。在本章中,我们将学习创建和发布 Python 包,以及管理依赖项。
第十章,在 AWS 上部署,探讨了云服务如何提供一个灵活的平台来运行 Web 服务。本章涵盖了为我们的应用程序创建容器并在 Amazon Web Services 中部署它。
第十一章,接下来是什么?,总结了我们迄今为止学到的主要内容,以及适合进一步阅读的主题,以及对不同类型应用有用的技术。
要充分利用本书
对于有 Python 编程经验或非常类似编程语言经验的读者来说,本书更容易理解。Python 的基础知识没有涵盖,对于基础知识,我们推荐 Packt Publishing 出版的《Learn Python Programming》一书。
运行本书中的代码示例需要一台安装了 Python 3.8 或更高版本的计算机。Python 对所有流行的操作系统,如 Windows、macOS 和 Linux 等,都是免费的。
作者建议不仅运行代码示例,还要进行实验,尝试不同的东西。
下载示例代码文件
本书代码包托管在 GitHub 上,网址为github.com/PacktPublishing/Python-Microservices-Development-2nd-Edition
。我们还有其他来自我们丰富图书和视频目录的代码包,可在github.com/PacktPublishing/
找到。请查看它们!
下载彩色图像
我们还提供了一份包含本书中使用的截图/图表彩色图像的 PDF 文件。您可以从这里下载:static.packt-cdn.com/downloads/9781801076302_ColorImages.pdf
。
使用的约定
本书使用了多种文本约定。
CodeInText
:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。例如;“当您运行单个 Python 模块时,其值将为__main__
的__name__
变量是应用程序包的名称。”
代码块设置如下:
@app.route('/api', methods=['POST', 'DELETE', 'GET'])
def my_microservice():
return {'Hello': 'World!'}
任何命令行输入或输出都如下所示:
`pip install quart`
粗体:表示新术语、重要单词或你在屏幕上看到的单词,例如在菜单或对话框中。例如:“有许多优秀的同步框架可以用 Python 构建微服务,如 Bottle、Pyramid 与 Cornice 或 Flask。”
警告或重要注意事项看起来就像这样。
小贴士和技巧看起来就像这样。
联系我们
我们始终欢迎读者的反馈。
一般反馈:请发送电子邮件至 feedback@packtpub.com
,并在邮件主题中提及书籍标题。如果你对本书的任何方面有疑问,请通过 questions@packtpub.com
发送电子邮件给我们。
勘误:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果你在这本书中发现了错误,我们将不胜感激,如果你能向我们报告这个错误。请访问 www.packtpub.com/submit-errata
,选择你的书籍,点击勘误提交表单链接,并输入详细信息。
盗版:如果你在互联网上以任何形式遇到我们作品的非法副本,我们将不胜感激,如果你能向我们提供位置地址或网站名称。请通过 copyright@packtpub.com
发送电子邮件,并附上材料的链接。
如果你有兴趣成为作者:如果你在某个领域有专业知识,并且对撰写或参与一本书感兴趣,请访问 authors.packtpub.com
。
分享你的想法
一旦你阅读了 Python 微服务开发,第二版,我们很乐意听到你的想法!点击此处直接进入此书的亚马逊评论页面 并分享你的反馈。
你的评论对我们和科技社区都很重要,并将帮助我们确保我们提供高质量的内容。
第一章:理解微服务
我们一直在努力改进我们创建软件的方式。计算机编程还不到 100 年,我们通过技术、设计和哲学的快速发展,来改进我们生产的工具和应用程序。
微服务通过提高服务的可读性和可伸缩性,彻底改变了软件产品,并使组织能够加快其发布周期,并更快速地响应用户的需求。每个人都希望尽可能快地将新产品和新功能推向客户。他们希望通过频繁迭代来变得敏捷,并希望不断地发货,发货,再发货。
当有数千名客户同时使用您的服务时,将实验性功能推送到生产环境并在必要时将其移除,而不是等待数月才能发布它以及许多其他功能,这被认为是良好的实践。
诸如 Netflix 这样的公司正在推广他们的持续交付技术,在生产环境中非常频繁地进行小改动,并在用户子集上进行测试。他们开发了诸如 Spinnaker (www.spinnaker.io/
) 这样的工具,尽可能自动化更新生产和将功能作为独立微服务部署到云中的步骤。
但如果你阅读 Hacker News 或 Reddit,可能会很难区分对你有用的信息和仅仅是符合流行语标准的新闻式信息。正如著名计算机科学家和著名最短路径路由算法的发现者埃德加·迪杰斯特拉所说:
“写一篇承诺救赎的论文,让它成为一个结构化的东西,或者一个虚拟的东西,或者一个抽象的、分布式的、高阶的或应用性的东西,你几乎可以肯定你已经开启了一个新的教派。”
——埃德加·W·迪杰斯特拉
本书将带您了解传统单体服务的创建,并提供如何识别作为微服务将更有效的组件的指导。我们将涵盖与其他服务集成、传递消息和调度任务的方法,以及如何在亚马逊网络服务中安全地部署我们的服务。
本章将帮助您了解微服务是什么,然后重点介绍您可以使用 Python 实现微服务的各种方法。它由以下部分组成:
-
面向服务架构的起源
-
构建应用程序的单体方法
-
构建应用程序的微服务方法
-
微服务的优势
-
微服务中的潜在陷阱
-
使用 Python 实现微服务
希望您在阅读完本章后,能够深入到本书的其余部分,并在对微服务及其不是什么有良好理解的情况下,使用 Python 构建微服务。
面向服务架构的起源
对于微服务,目前没有官方标准,因此查看该领域软件设计的历史是有帮助的。在讨论微服务时,面向服务的架构(SOA)通常被用作起点。SOA 是一种关于软件架构的思考方式,它鼓励可重用的软件组件,这些组件提供了定义良好的接口。这使得这些组件可以被重用,并应用于新的情况。
上述定义中的每个单元都是一个自包含的服务,它实现了一个商业方面的功能,并通过某种接口提供其功能。
虽然 SOA 明确表示服务应该是独立进程,但它并没有强制规定这些进程之间应该使用哪些协议进行交互,并且对于如何部署和组织应用程序相当模糊。
如果你阅读了首次于 2009 年左右在网络上发布的SOA 宣言(www.soa-manifesto.org
),作者甚至没有提到服务是否通过网络进行交互,尽管对原则的理解大多涉及网络服务。
SOA 服务可以通过进程间通信(IPC)使用同一台机器上的套接字、通过共享内存、通过间接消息队列,甚至通过远程过程调用(RPC)进行通信。选项很多,SOA 是一套适用于各种情况的有用原则。
然而,人们通常会说微服务是 SOA 的一种专业化,因为它们允许我们专注于组织的需要、其安全性以及软件的扩展和分离。
如果我们要给出微服务的完整定义,最好的方式是在不同的软件架构的背景下理解它。我们将从一个单体开始,然后讨论微服务是如何不同的。
单体方法
在单体架构中,关于服务的所有内容都在一个地方——API、数据库以及所有相关工具都被视为一个代码库的一部分。让我们以一个非常简单的传统单体应用程序为例:一个酒店预订网站。
除了静态 HTML 内容外,该网站还有一个预订功能,允许其用户在世界上的任何城市预订酒店。用户可以搜索酒店,然后使用信用卡预订。
当用户在酒店网站上执行搜索时,应用程序会经过以下步骤:
-
它在其酒店数据库上运行几个 SQL 查询。
-
向合作伙伴的服务发送 HTTP 请求,以将更多酒店添加到列表中。
-
结果被发送到嵌入网页中的 JavaScript,以渲染供观众查看的信息。
从那里,一旦用户找到了理想的酒店并选择了预订选项,应用程序会执行以下步骤:
-
如果需要,会在数据库中创建客户,并需要进行身份验证。
-
通过与银行的网络服务交互进行支付。
-
应用出于法律原因将支付详情保存在数据库中
-
使用 PDF 生成器生成收据
-
使用电子邮件服务向用户发送总结电子邮件
-
使用电子邮件服务将预订电子邮件转发给第三方酒店
-
添加数据库条目以跟踪预订
这个过程是一个简化的模型,当然,但它描述了足够的内容,让我们从中学习。
应用与包含酒店信息、预订详情、计费、用户信息等的数据库进行交互。它还与外部服务交互,用于发送电子邮件、进行支付和从合作伙伴那里获取更多酒店。
在网络发展的早期,新的服务通常会使用LAMP(Linux-Apache-MySQL-Perl/PHP/Python)架构。这种方法中,每个进入的请求都会在数据库上生成一系列 SQL 查询,以及一些对外部服务的网络调用,然后服务器使用模板引擎生成 HTML 响应。
下面的图示说明了这种集中式架构:
图 1.1:一个示例单体服务架构
这个应用是一个典型的单体应用,它有很多好处。最大的好处是整个应用都在一个代码库中,当项目编码开始时,会使一切变得简单。构建良好的测试覆盖率很容易,你可以在代码库内部以干净和结构化的方式组织代码。将所有数据存储在单个数据库中也有助于简化应用的开发。你可以调整数据模型,以及代码如何查询它。
部署过程也很简单;我们可以构建一个包,安装它,并在某个地方运行它。为了扩展,我们可以运行多个预订应用的实例,并运行一些具有复制机制的数据库。
如果你的应用保持小型,这种模型工作得很好,并且对于单一团队来说很容易维护。但是项目通常会增长,并且变得比最初设想的大。整个应用在单个代码库中会带来一些棘手的问题。例如,如果你需要做出大规模的改动,比如更改你的银行服务或数据库层,这些改动会对整个应用产生风险。这些更改对项目有很大影响,在部署之前需要经过良好的测试,而这种测试通常无法彻底。这样的改变在项目生命周期中是会发生的。
小的改动也可能产生附带损害,因为系统的不同部分有不同的运行时间和稳定性要求。将计费和预订流程置于风险之中,因为创建 PDF 的功能导致服务器崩溃,这确实是个问题。
无控制的增长是另一个问题。应用程序必然会添加新功能,随着开发人员的加入和离开,代码可能会变得杂乱无章,测试可能会变慢,部署可能会变得脆弱。这种增长通常会导致难以维护的意大利面代码库,以及需要复杂迁移计划的复杂数据库。
使项目变得有趣的其他增长形式是容量管理。如果应用程序中的某个元素需要与其它元素非常不同的扩展,那么扩展应用程序就会变得非常困难;例如,如果酒店房间可用性开始被用来生成网站广告,以及服务于访问网站的访客。
大型软件项目通常需要几年时间才能成熟,然后它们会逐渐开始变成难以理解的混乱,难以维护。这并不是因为开发者做得不好。这是因为随着复杂性的增加,越来越少的人能够完全理解他们所做的每一个小更改的后果。
因此,他们试图与代码库的一部分独立工作,混乱只有在查看整个项目结构时才会变得明显。我们都有过这样的经历。
这并不有趣,从事此类项目的开发者梦想着从头开始使用最新的框架来构建应用程序。通过这样做,他们通常又会面临同样的问题——同样的故事再次上演。
总结来说,单体架构有一些好处:
-
以单体形式启动项目很容易,而且可能是最好的方法。
-
集中式数据库简化了数据的设计和组织。
-
部署一个应用程序很简单。
然而:
-
代码中的任何更改都可能影响无关的功能。当某个功能出现问题时,整个应用程序可能会崩溃。
-
扩展应用程序的解决方案有限:你可以部署多个实例,但如果应用程序中的某个特定功能消耗了所有资源,它会影响一切。
-
随着代码库的增长,很难保持其整洁和可控。
当然,有一些方法可以避免这里描述的一些问题。
显而易见的解决方案是将应用程序拆分成独立的组件,即使最终代码仍然会在单个进程中运行。开发者通过使用外部库和框架来构建他们的应用程序来实现这一点。这些工具可以是内部开发的,也可以来自开源软件(OSS)社区。
如果你使用像Quart或Flask这样的框架在 Python 中构建 Web 应用程序,你能够专注于业务逻辑,将一些代码外部化为框架扩展和小的 Python 包变得非常有吸引力。将代码拆分成小包通常是控制应用程序增长的好主意。
“小即是美。”
—UNIX 哲学
例如,酒店预订应用程序中描述的 PDF 生成器可以是一个独立的 Python 包,该包使用ReportLab和一些模板来完成工作。这个包很可能在其他应用程序中重用,甚至可能发布到Python 包索引(PyPI)供社区使用。
但你仍然在构建一个单一的应用程序,一些问题仍然存在,比如无法不同比例地扩展部分,或者由有缺陷的依赖项引入的任何间接问题。
你甚至会面临新的挑战,因为你现在正在使用依赖项。你将面临的一个问题是依赖地狱。如果你的应用程序的两个部分使用相同的库,你可能会陷入这样的情况:一个部分的应用程序需要新版本的功能,但另一个组件不能使用更新的版本,因为其他东西已经改变,你现在处于依赖地狱。有很大可能性,你最终会在大型项目中找到一些丑陋的解决方案来解决这个问题,比如保留一个依赖项的副本,你现在需要单独维护以保持修复更新。
当然,本节中描述的所有问题在项目开始的第一天都不会出现,而是在一段时间内逐渐积累。
让我们现在看看,如果我们使用微服务来构建相同的应用程序,它将看起来如何。
微服务方法
如果我们使用微服务构建相同的应用程序,我们会将代码组织成几个独立的组件,这些组件在单独的进程中运行。我们已经讨论了 PDF 报告生成器,我们可以检查应用程序的其余部分,看看我们可以在哪里将其拆分为不同的微服务,如下面的图所示:
图 1.2:一个示例微服务架构
不要害怕图中显示的组件数量。单体应用程序的内部交互只是通过单独的部分变得可见。我们已经将一些复杂性转移,最终得到了这些七个独立的组件:
-
预订 UI:一个前端服务,生成网页用户界面,并与所有其他微服务进行交互。
-
PDF 报告:一个非常简单的服务,可以根据模板和一些数据创建收据或其他文档。也称为 PDF 报告服务。
-
搜索:一个服务,当给定一个位置时,可以查询以获取酒店列表。此服务有自己的数据库。
-
支付:一个与第三方银行服务交互并管理计费数据库的服务。它还在支付成功时发送电子邮件。
-
预订:管理预订和预订更改。
-
用户:存储用户信息,并通过电子邮件与用户交互。
-
认证:一个基于 OAuth 2 的服务,返回认证令牌,每个微服务都可以在调用其他服务时使用这些令牌进行认证。
这些微服务,以及一些外部服务,如电子邮件服务,将提供类似于单体应用的功能集。在这个设计中,每个组件使用 HTTP 协议进行通信,功能通过 RESTful Web 服务提供。
没有集中式数据库,因为每个微服务内部处理自己的数据结构,进出数据使用一种语言无关的格式,如 JSON。只要它可以被任何语言产生和消费,并且通过 HTTP 请求和响应传输,它也可以使用 XML 或 YAML。
预订 UI 服务在这方面有点特别,因为它生成用户界面(UI)。根据用于构建 UI 的前端框架,预订 UI 输出可能是 HTML 和 JSON 的混合体,或者如果界面使用基于静态 JavaScript 的客户端工具直接在浏览器中生成界面,则可能是纯 JSON。
但除了这个特定的 UI 案例之外,一个使用微服务设计的 Web 应用程序是由几个微服务组成的,这些微服务可能通过 HTTP 相互交互,以提供整个系统。
在这个背景下,微服务是专注于非常特定任务的逻辑单元。以下是一个完整的定义尝试:
微服务是一个轻量级的应用程序,提供一系列具有良好定义契约的功能。它是一个具有单一责任、可以独立开发和部署的组件。
这个定义没有提到 HTTP 或 JSON,因为你可以考虑,例如,一个小型的基于 UDP 的服务,它作为微服务交换二进制数据,或者一个使用 gRPC 进行通信的服务。(gRPC 是一个递归缩写,代表 gRPC 远程过程调用,一个开源的远程过程调用系统。)
但在我们的案例中,以及在整个书中,我们所有的微服务都只是简单的 Web 应用程序,它们使用 HTTP 协议,并且在不是 UI 的情况下消费和产生 JSON。
微服务的好处
虽然微服务架构看起来比其单体对应物更复杂,但它提供了多个优点。它提供了以下优点:
-
关注点的分离
-
更小的项目要处理
-
更多的扩展和部署选项
我们将在接下来的章节中更详细地讨论它们。
关注点的分离
首先,每个微服务都可以由一个单独的团队独立开发。例如,构建一个预订服务可以是一个完整的项目。负责的团队可以使用他们选择的编程语言和数据库进行编码,只要它有一个良好的文档化的 HTTP API。
这也意味着应用程序的演变比单体应用更容易控制。例如,如果支付系统更改其与银行的底层交互,影响将局限于该服务内部,其余的应用程序保持稳定,可能不受影响。
这被称为松耦合,并且当我们应用类似单一职责原则的哲学在服务级别时,它提高了整体项目的速度。相比之下,紧密耦合的支付服务需要了解系统如何表示其数据或执行其任务的内部知识。
软件工程领域许多备受尊敬的书籍的作者罗伯特·马丁(Robert Martin)定义了单一职责原则,以解释一个类应该只有一个改变的理由;换句话说,每个类应该提供一个单一、定义良好的功能。应用于微服务,这意味着我们想要确保每个微服务专注于单一角色。
较小的项目
第二个好处是简化项目的复杂性。当您向应用程序添加一个功能,如 PDF 报告,即使您做得干净利落,也会使代码库变大、更复杂,有时甚至变慢。在单独的应用程序中构建该功能可以避免这个问题,并使其更容易使用您想要的任何工具来编写。您可以经常重构它,缩短发布周期,并保持对事物的控制。应用程序的增长仍然在您的控制之下。
处理较小的项目在改进应用程序时也能降低风险:如果一个团队想要尝试最新的编程语言或框架,他们可以快速迭代实现相同微服务 API 的原型,尝试使用它,并决定是否继续使用。
一个现实生活中的例子是 Firefox Sync 存储微服务。曾经有实验尝试从将数据存储在 MySQL 中,切换到将用户数据存储在独立的 SQLite 数据库中的实现。通过将存储功能隔离在一个具有良好定义的 HTTP API 的微服务中,降低了实验原型可能带来的风险。这最小化了与其他组件的意外交互,并允许一小部分用户尝试新版本的服务。
减少每个组件的大小也使得开发者更容易思考,尤其是对于新加入团队或对处理服务中断感到压力的开发者。开发者不必处理整个系统,可以专注于较小的区域,不必担心应用程序的其他功能。
扩展和部署
最后,将应用程序拆分为组件,根据您的限制更容易进行扩展。假设您的业务增长,每天都有更多的客户预订酒店,PDF 生成开始使用更多资源并变慢。为了解决这个问题,您可以在一些具有更大 CPU 或更多内存的服务器上部署那个特定的微服务。
另一个典型的例子是高内存使用微服务,例如与内存数据库(如Redis或Memcached)交互的微服务。因此,您可以通过使用具有较少 CPU 和更多 RAM 的服务器来调整您的部署。
因此,我们可以总结微服务的以下好处:
-
一个团队可以独立开发每个微服务,并使用任何有意义的科技栈。他们可以定义一个自定义发布周期。他们需要定义的只是一个语言无关的 HTTP API。
-
开发者将应用程序的复杂性拆分为逻辑组件。每个微服务专注于做好一件事。
-
由于微服务是独立的应用程序,对部署有更精细的控制,这使得扩展变得更容易。
微服务架构擅长解决应用程序开始增长后可能出现的许多问题。然而,我们需要意识到随之而来的某些新问题。
微服务的陷阱
如前所述,使用微服务构建应用程序有许多好处,但绝不是万能的。
当编码微服务时,你需要意识到以下主要问题:
-
不合理的拆分
-
更多的网络交互
-
数据存储和共享
-
兼容性问题
-
测试
这些问题将在以下章节中详细讨论。
不合理的拆分
微服务架构的第一个问题是其设计方式。团队不可能在一次尝试中就提出完美的微服务架构。一些微服务,如 PDF 生成器,是一个明显的用例。但是,一旦你处理业务逻辑,就有很大可能性在掌握如何将事物拆分为正确的微服务集合之前,你的代码就会移动。
设计需要通过一些尝试和失败周期来成熟。添加和删除微服务可能比重构单体应用程序更痛苦。你可以通过避免将应用程序拆分为微服务来减轻这个问题,如果拆分不明显的话。
如果有任何疑问,认为拆分是有意义的,那么将代码保留在同一个应用程序中是安全的赌注。总是更容易在以后将一些代码拆分到一个新的微服务中,而不是因为决策错误而将两个微服务合并回同一个代码库。
例如,如果你总是必须一起部署两个微服务,或者如果微服务中的一个更改影响了另一个微服务的数据模型,那么很可能你没有正确地拆分应用程序,这两个服务应该被重新组合。
更多的网络交互
第二个问题是构建相同应用时添加的网络交互数量。在单体版本中,即使代码变得混乱,所有操作都在同一个进程中完成,你可以在不调用太多后端服务构建实际响应的情况下发送回结果。
这需要特别注意如何调用每个后端服务,并引发许多问题,如下所示:
-
当预订用户界面因为网络分割或服务延迟无法访问 PDF 报告服务时会发生什么?
-
预订用户界面是同步调用其他服务还是异步调用?
-
这将如何影响响应时间?
我们需要有一个稳固的策略来回答所有这些问题,我们将在第六章与其他服务交互中解决这些问题。
数据存储和共享
另一个问题在于数据存储和共享。一个有效的微服务需要独立于其他微服务,理想情况下,不应共享数据库。这对我们的酒店预订应用意味着什么?
再次,这引发了许多问题,例如以下这些:
-
我们是否在所有数据库中使用相同的用户 ID,或者每个服务都有独立的 ID,并将其作为隐藏的实现细节?
-
一旦用户被添加到系统中,我们是否通过数据泵送等策略在其他服务的数据库中复制她的部分信息,或者这是否过于过度?
-
我们如何处理数据删除?
这些问题是很难回答的,而且有很多人不同的方式来解决这些问题,正如我们将在本书中学习的那样。
在尽可能避免数据重复的同时,保持微服务的隔离是设计基于微服务应用程序的最大挑战之一。
兼容性问题
另一个问题发生在功能变更影响多个微服务时。如果变更以向后不兼容的方式影响服务之间的数据传输,你将面临一些麻烦。
你能否部署你的新服务,并且它是否与旧版本的其他服务兼容?或者你是否需要一次性更改和部署多个服务?这意味着你是否偶然发现了一些应该合并回一起的服务?
良好的版本控制和 API 设计卫生有助于减轻这些问题,正如我们在本书的第二部分将了解到的那样,当我们构建我们的应用程序时。
测试
最后,当你想要进行一些端到端测试并部署整个应用程序时,你必须处理许多组件。你需要有一个强大且敏捷的部署流程才能高效。你在开发时需要能够玩转整个应用程序。你不能只通过一块拼图来完全测试事物,尽管拥有干净和定义良好的接口确实有帮助。
云编排工具的最新发展,如 Kubernetes、Terraform 和 CloudFormation,在部署由多个组件组成的应用程序时使生活变得更加容易。它们可以用来创建测试和预发布环境,以及面向客户的部署。这些工具的流行有助于微服务的成功和采用。
微服务风格的架构推动了部署工具的创新,而部署工具降低了微服务风格架构的审批门槛。
使用微服务的陷阱可以总结如下:
-
过早地将应用程序拆分为微服务可能导致架构问题。
-
微服务之间的网络交互增加了潜在的故障点和额外的开销。
-
测试和部署微服务可能很复杂。
-
最大的挑战——微服务之间的数据共享很困难。
你现在不必过于担心本节中描述的所有陷阱。它们可能看起来令人难以承受,传统的单体应用可能看起来更安全,但从长远来看,将你的项目拆分为微服务将使作为开发者或作为运维人员(ops)的许多任务更容易。这也可以使运行服务更便宜。要向单体应用添加更多容量,你需要更大的服务器,或者能够添加更多大型服务器的能力。如果架构是分布式的并且基于微服务,那么可以以更小的增量添加额外资源,更接近实际所需的数量。而且,正如我们将在第九章:部署、运行和扩展中发现的,可以更容易地设置云服务提供商以根据需求自动扩展。
使用 Python 实现微服务
Python 是一种非常灵活的语言。正如你可能已经知道的,Python 被用来构建许多不同种类的应用程序——从在服务器上执行任务的简单系统脚本到为数百万人运行服务的庞大面向对象应用程序。Python 也被用于机器学习和数据分析工具。
Python 在 TIOBE 指数中排名前五([www.tiobe.com/tiobe-index/
](http://www.tiobe.com/tiobe-index/)),甚至达到过第二的位置。在 Web 开发领域,它可能更为重要,因为像 C 这样的语言很少被用作构建 Web 应用程序的主语言。
本书假设你已经熟悉 Python 编程语言。如果你不是经验丰富的 Python 开发者,你可以阅读《Expert Python Programming》,《第三版》,在那里你将学习 Python 的高级编程技能。
然而,一些开发者批评 Python 速度慢,不适合构建高效的 Web 服务。Python 确实慢,这是不可否认的,尽管它对于大多数情况来说已经足够快。但它仍然是构建微服务的首选语言,许多大型公司都乐意使用它。
本节将为你提供一些背景信息,介绍你可以使用 Python 编写微服务的不同方式,提供一些关于异步编程与同步编程的见解,并以 Python 性能的细节作为总结。
网络服务的工作原理
如果我们想象一个简单的程序,该程序在网络上回答查询,其描述是直接的。一个新的连接被建立,并协商协议。一个请求被提出,并进行一些处理:可能查询了一个数据库。然后构建响应并发送,连接关闭。这通常是我们要思考应用程序逻辑的方式,因为它使开发者和任何其他负责程序运行的人都能保持事情简单。
然而,网络是一个庞大而复杂的地方。互联网的各个部分都会试图对它们发现的易受攻击的 Web 服务进行恶意操作。其他人只是表现不佳,因为他们没有设置好。即使事情运行良好,也有不同的 HTTP 协议版本、加密、负载均衡、访问控制和一系列其他需要考虑的事情。
而不是重新发明所有这些技术,有一些接口和框架让我们可以使用其他人构建的工具,并将更多时间花在我们自己的应用程序上。它们让我们可以使用 Web 服务器,如Apache和nginx,并让它们处理网络上的困难部分,如证书管理、负载均衡和处理多个网站身份。然后,我们的应用程序有一个更小、更易于管理的配置来控制其行为。
WSGI 标准
对于从 Python 开始学习的多数网络开发者来说,最令人印象深刻的是构建一个 Web 应用程序是多么容易。
受较旧的通用网关接口(CGI)的启发,Python 网络社区创建了一个名为Web 服务器网关接口(WSGI)的标准。它简化了编写 Python 应用程序以服务 HTTP 请求的方式。当您的代码使用此标准时,您的项目可以通过标准 Web 服务器(如 Apache 或 nginx)以及使用 WSGI 扩展(如uwsgi
或mod_wsgi
)来执行。
您的应用程序只需处理传入的请求并发送 JSON 响应,Python 在它的标准库中包含了所有这些优点。
您可以使用少于 10 行的纯 Python 模块创建一个完全功能化的微服务,该服务返回服务器的本地时间:
import json
import time
def application(environ, start_response):
headers = [('Content-type', 'application/json')]
start_response('200 OK', headers)
return [bytes(json.dumps({'time': time.time()}), 'utf8')]
自从其推出以来,WSGI 协议已成为一个基本标准,Python 网络社区已经广泛采用它。开发者编写了中间件,这些是可以在 WSGI 应用函数之前或之后挂载的函数,以在环境中执行某些操作。
一些网络框架,例如Bottle(bottlepy.org
),是专门围绕该标准创建的,并且很快,每个框架都可以以某种方式通过 WSGI 使用。
然而,WSGI 最大的问题是其同步特性。最近,异步服务器网关接口(ASGI)作为 WSGI 的继任者出现,允许框架以前所未有的无缝行为异步运行。那么什么是同步和异步应用程序呢?我们现在就来探讨这个问题。
工作者、线程和同步性
回想一下我们处理请求的简单应用程序,我们的程序模型是同步的。这意味着它接受一项工作,完成这项工作,并返回结果,但在它完成所有这些工作的同时,程序不能做其他任何事情。当它正在处理某项工作时,任何其他到达的请求都必须等待。
有几种方法可以解决这个问题,从使用工作池到早期上下文切换环境,再到最近的全异步 Python。
工作池方法
接受一个新的请求通常非常快,而大部分时间都花在执行请求的工作上。读取一个请求告诉你“给我巴黎所有客户的列表”所花费的时间要比整理列表并发送它的时间少得多。
当应用程序收到大量请求时,一个有效的策略是确保所有重负载都由其他进程或线程来完成。启动一个新的线程可能很慢,启动一个新的进程甚至更慢,因此一个常见的做法是提前启动这些工作者,并保持它们处于就绪状态,以便在请求到达时分配新的工作。
这是一个古老的技术,非常有效,但它确实有限制。就每个工作者而言,它接收工作,直到完成工作之前不能做其他任何事情。这意味着如果你有八个工作进程,你只能处理八个并发请求。如果你的应用程序运行不足,它可以创建更多的工作者,但总会有瓶颈。
应用程序可以创建的进程和线程的数量也有实际限制,而且在这些进程和线程之间切换需要大量时间,而响应式应用程序并不总是能负担得起。
异步处理
一个重要的事情要认识到的是,计算机之间的交互是一个缓慢的过程。并不是从人类的角度来看,因为一条来自家庭成员的新消息可以在我们眼中一闪而过,而是从计算机自身的角度来看。
有几个图表可以说明“程序员应该知道的延迟数字”,最初由Jeff Dean和Peter Norvig编写。可以在colin-scott.github.io/personal_website/research/interactive_latency.html
找到它的一个版本。
这些表格中有许多数字,但对我们来说,重要的是关于网络流量的数字。我们可以了解到从计算机内存中读取大约 1 MB 需要约 3,000 ns,但向同一建筑内的计算机发送数据包并获取响应可能需要约 500,000 ns。与另一个大陆上的计算机通信可能需要数百毫秒。
用人类的话来说:你可能需要几秒钟才能记住你需要问某人一个问题。发送问题并听到他们已经阅读了它,无论你是否得到了你需要的答案,可能需要两天。
你肯定不希望在那里无所事事地等待答案,但如果是同步的,进程通常会这样做。异步程序知道它被告知执行的一些任务可能需要很长时间,因此它可以在等待时继续进行其他工作,而不必一定使用其他进程或线程。
Twisted, Tornado, Greenlets, and Gevent
很长一段时间里,非 WSGI 框架如Twisted和Tornado在 Python 中使用时是处理并发的流行选择,允许开发者为许多并发请求指定回调。在顺序程序中,你可能会调用一个函数并等待它返回一个值给你。回调是一种技术,其中调用程序的部分不等待,而是告诉函数如何处理它生成的结果。通常这将是另一个它应该调用的函数。
另一种流行的方法涉及 Greenlets 和 Gevent。Greenlet项目(github.com/python-greenlet/greenlet
)是一个基于Stackless项目的包,这是一个特定的 CPython 实现,并提供greenlets。
Greenlets 是成本很低的伪线程,与真实线程不同,可以用来调用 Python 函数。在这些函数内部,你可以切换,并将控制权交回给另一个函数。切换是通过事件循环完成的,允许你使用类似线程的接口范式编写异步应用程序。
然而,从一个 greenlet 切换到另一个 greenlet 必须显式进行,并且由此产生的代码可能会很快变得混乱且难以理解。这就是 Gevent 变得非常有用的地方。Gevent项目([www.gevent.org/
](http://www.gevent.org/))建立在 Greenlet 之上,提供了一种隐式且自动地在 greenlet 之间切换的方法,以及其他许多功能。
基于所有这些选项的经验,Python 从 3.5 版本开始将asyncio
作为语言的核心功能,这就是我们将在代码中使用的内容。
异步 Python
当 Guido van Rossum 开始在 Python 3 中添加异步功能时,社区的一部分人推动了一个类似于 Gevent 的解决方案,因为在同步、顺序的方式编写应用程序比在 Tornado 或 Twisted 中添加显式回调要合理得多。
但吉多选择了显式技术,并在一个名为 Tulip 的项目中进行了实验,该项目灵感来源于 Twisted。最终,asyncio
模块就是从这个辅助项目中诞生的,并被添加到 Python 中。
事后看来,在 Python 中实现显式的事件循环机制,而不是走 Gevent 的路,是非常有意义的。Python 核心开发者编写的 asyncio 方式,以及他们如何使用 async
和 await
关键字扩展语言以实现协程,使得使用纯 Python 3.5+ 代码构建的异步应用程序看起来非常优雅,接近同步编程。
Python 3 在 asyncio 包中引入了一套完整的特性和辅助工具,用于构建异步应用程序;请参阅 https://docs.python.org/3/library/asyncio.html。
aiohttp (aiohttp.readthedocs.io
) 是最成熟的 asyncio 包之一,使用它构建早期的 "time" microservice 只需要这几行代码:
from aiohttp import web
import time
async def handle(request):
return web.json_response({'time': time.time()})
if __name__ == '__main__':
app = web.Application()
app.router.add_get('/', handle)
web.run_app(app)
在这个小型示例中,我们非常接近于如何实现一个同步应用程序。我们唯一使用的异步代码提示是 async
关键字,它将 handle
函数标记为协程。
这个概念将是未来在异步 Python 应用程序每个级别上都要使用的内容。以下是一个使用 aiopg
的例子,这是一个来自项目文档的 asyncio PostgreSQL 库:
import asyncio
import aiopg
# Start an example postgres instance with:
# docker run -p5432:5432 --name some-postgres \
# -e POSTGRES_PASSWORD=mysecretpassword -d postgres
dsn = "dbname=postgres user=postgres password=mysecretpassword host=127.0.0.1"
async def go():
pool = await aiopg.create_pool(dsn)
async with pool.acquire() as conn:
async with conn.cursor() as cur:
await cur.execute("SELECT 1")
ret = []
async for row in cur:
ret.append(row)
assert ret == [(1,)]
await pool.clear()
loop = asyncio.get_event_loop()
loop.run_until_complete(go())
几个带有 async
和 await
前缀的函数,执行 SQL 查询并返回结果的方式看起来非常像同步函数。我们将在后面的章节中详细解释这段代码。
如果你的代码需要使用非异步的库,要从异步代码中使用它,那么你需要做一些额外且具有挑战性的工作,以便让不同的库能够良好地协同工作。
使用 Python 构建 microservices 的优秀同步框架有很多,比如 Bottle、Pyramid 与 Cornice 或 Flask。我们将使用一个与 Flask 非常相似,但也是异步的框架:Quart。
请记住,无论你使用哪种 Python Web 框架,你都应能够转换这本书中的所有示例。这是因为构建 microservices 时涉及的编码大部分非常接近纯 Python,而框架主要是为了路由请求并提供一些辅助工具。
语言性能
在前面的章节中,我们介绍了编写 microservices 的两种不同方式:异步与同步,无论你使用哪种技术,Python 的速度都会直接影响你的 microservice 的性能。
当然,每个人都知道 Python 的执行速度比 Java 或 Go 慢,但执行速度并不总是首要考虑的因素。微服务通常是一层薄薄的代码,大部分时间都在等待来自其他服务的网络响应。它的核心速度通常不如你的 SQL 查询从 Postgres 服务器返回的速度重要,因为后者将代表构建响应的大部分时间。
还重要的是要记住,你花费在软件开发上的时间可能同样重要。如果你的服务正在快速变化,或者有新的开发者加入并需要理解代码,那么拥有易于理解、开发和部署的代码就很重要。
但希望应用程序尽可能快是合理的。
在 Python 社区中关于加快语言速度的一个有争议的话题是全局解释器锁(GIL)如何影响性能,因为多线程应用程序无法使用多个进程。
GIL 存在有其合理的原因。它保护了 CPython 解释器中非线程安全的部分,并在像 Ruby 这样的其他语言中存在。迄今为止,所有尝试移除它的尝试都未能产生更快的 CPython 实现。
对于微服务来说,除了防止在同一个进程中使用多个核心之外,由于互斥锁引入的系统调用开销,GIL 还会在高负载下略微降低性能。
然而,围绕 GIL 的所有审查都是有益的:过去已经做了工作来减少解释器中的 GIL 竞争,在某些领域,Python 的性能有了很大的提升。Python 3.8 中引入子解释器和多个锁的更改也帮助了一些领域。
请记住,即使核心团队消除了所有 GIL 性能问题,Python 仍然是一种解释和垃圾回收语言,并因这些特性而遭受性能损失。
如果你感兴趣,Python 提供了 dis
模块来查看解释器如何分解一个函数。在下面的例子中,解释器将分解一个简单的函数,该函数从序列中产生递增的值,至少需要 22 步:
>>> def myfunc(data):
... for value in data:
... yield value + 1
...
>>> import dis
>>> dis.dis(myfunc)
2 0 LOAD_FAST 0 (data)
2 GET_ITER
>> 4 FOR_ITER 14 (to 20)
6 STORE_FAST 1 (value)
3 8 LOAD_FAST 1 (value)
10 LOAD_CONST 1 (1)
12 BINARY_ADD
14 YIELD_VALUE
16 POP_TOP
18 JUMP_ABSOLUTE 4
>> 20 LOAD_CONST 0 (None)
22 RETURN_VALUE
用静态编译语言编写的类似功能将大大减少产生相同结果所需的操作数量。尽管如此,也有方法可以加快 Python 的执行速度。
一种方法是通过在 C、Rust 或其他编译语言中构建扩展,或者使用像 Cython (cython.org/
) 这样的静态扩展语言来编写代码的一部分,但这会使你的代码更加复杂。
另一种解决方案是使用 PyPy 解释器 (pypy.org/
) 运行你的应用程序。这可以通过简单地替换 Python 解释器来带来明显的性能提升。
PyPy 实现了一个即时编译器(JIT)。这个编译器在运行时直接用机器代码替换 Python 代码的一部分,这些机器代码可以直接由 CPU 使用。JIT 编译器的整个技巧是在执行之前实时检测何时以及如何进行替换。
即使 PyPy 始终比 CPython 落后几个版本,但它已经达到了可以在生产中使用,并且其性能可以非常惊人的程度。在我们的 Mozilla 项目中,有一个需要快速执行的项目,PyPy 版本几乎与 Go 版本一样快,我们决定在那里使用 Python。
Pypy Speed Center 网站是一个很好的地方,可以查看 PyPy 与 CPython(speed.pypy.org/
)的比较。
然而,如果你的程序使用 C 扩展或具有任何其他编译依赖项,你将需要为 PyPy 重新编译它们,并且必须权衡额外的工作与速度改进,尤其是如果你依赖于其他项目或其他开发人员维护你使用的扩展。
但是,如果你使用标准库集构建你的微服务,那么它很可能与 PyPy 解释器无缝工作,所以值得一试。在任何情况下,对于大多数项目来说,Python 及其生态系统的优势在很大程度上超过了本节中描述的性能问题,因为微服务中的开销很少是问题。如果性能是问题,微服务方法允许你重写和扩展性能关键组件,而不会影响系统的其他部分。
摘要
在本章中,我们比较了构建 Web 应用的单体和微服务方法,很明显,你不必在第一天就选择一个模型并坚持下去,这不是一个二选一的选择。
你应该将微服务视为一个从单体应用开始的生命周期中的应用程序的改进。随着项目的成熟,服务逻辑的一部分应该迁移到微服务中。正如我们在本章中学到的,这是一个有用的方法,但应该谨慎进行,以避免陷入一些常见的陷阱。
另一个重要的教训是,Python 被认为是最适合编写 Web 应用和微服务的语言之一。因此,它也是其他领域的选择语言,并且因为它提供了许多成熟的框架和包来完成工作。
Python 可能是一种慢速语言,在非常具体的情况下可能会成为问题。了解是什么让它变慢,以及避免这个问题的不同解决方案,通常足以解决任何麻烦。
我们快速地查看了几种框架,包括同步和异步的,在本书的其余部分,我们将使用 Quart。下一章将介绍这个出色的框架。
第二章:发现 Quart
Quart 是在 2017 年作为流行的 Flask 框架的进化而开始的。Quart 与 Flask 具有许多相同的设计决策,因此很多关于 Flask 的建议也适用于 Quart。本书将专注于 Quart,以便我们支持异步操作并探索诸如 WebSocket 和 HTTP/2 支持等功能。
Quart 和 Flask 不是唯一的 Python 框架。在网络上提供服务的项目有着悠久的历史,例如 Bottle、cherrypy 和 Django。所有这些工具都在网络上被使用,它们都拥有一个类似的目标:为 Python 社区提供构建 Web 应用程序的简单工具。
小型框架,如 Quart 和 Bottle,通常被称为微框架;然而,这个术语可能有些误导。这并不意味着你只能创建微应用。使用这些工具,你可以构建任何大小应用。前缀“微”意味着框架试图做出尽可能少的决策。它让你可以自由组织你的应用程序代码,并使用你想要的任何库。
微框架充当粘合代码,将请求传递到你的系统中,并发送响应。它不会对你的项目强制执行任何特定的范式。
这种哲学的一个典型例子是当你需要与 SQL 数据库交互时。Django 等框架是“电池组”式的,提供了构建 Web 应用程序所需的一切,包括一个 对象关系映射器(ORM)来绑定对象与数据库查询结果。
如果你想在 Django 中使用 SQLAlchemy 等替代 ORM 来利用其一些优秀功能,你将选择一条艰难的道路,因为这将涉及重写你希望利用的 Django 库中的大量代码,因为 Django 与其自带的 ORM 集成非常紧密。对于某些应用程序来说,这可能是个好事,但并不一定适用于构建微服务。
另一方面,Quart 没有内置的库来与你的数据交互,这让你可以自由选择自己的库。框架只会尝试确保它有足够的钩子,以便外部库可以扩展并提供各种功能。换句话说,在 Quart 中使用 ORM,并确保你正确处理 SQL 会话和事务,主要就是将 SQLAlchemy 等包添加到你的项目中。如果你不喜欢某个特定库的集成方式,你可以自由地使用另一个库或自己构建集成。Quart 也可以使用更常见的 Flask 扩展,尽管这样做存在性能风险,因为这些扩展不太可能是异步的,可能会阻塞你的应用程序的工作。
当然,这并不是万能的解决方案。在您的选择上完全自由也意味着更容易做出糟糕的决定,构建一个依赖于有缺陷的库或设计不佳的应用程序。但不必担心!本章将确保您了解 Quart 提供的内容,以及如何组织代码来构建微服务。
本章涵盖以下主题:
-
确保我们已安装 Python
-
Quart 如何处理请求
-
Quart 的内置功能
-
微服务骨架
本章的目标是提供您构建 Quart 微服务所需的所有信息。通过这样做,不可避免地会重复一些您可以在 Quart 官方文档中找到的信息,但专注于提供构建微服务时有趣细节和任何相关内容。Quart 和 Flask 都有良好的在线文档。
确保您查看 Quart 和 Flask 的文档,分别列出如下:
这两者都应作为本章的绝佳补充。源代码位于 gitlab.com/pgjones/quart
。
这一点值得注意,因为当您需要了解软件如何工作时,源代码总是终极真理。
确保我们已安装 Python
在我们开始深入研究其功能之前,我们应该确保我们已经安装并配置了 Python!
您可能会在网上看到一些提及 Python 版本 2 的文档或帖子。从 Python 2 到 Python 3 的过渡期很长,如果这本书几年前就写成,我们可能会讨论每个版本的优点。然而,Python 3 已经能够满足大多数人所需的所有功能,Python 2 在 2020 年停止由核心 Python 团队支持。本书使用最新的 Python 3.9 稳定版本来展示所有代码示例,但它们很可能在 Python 3.7 或更高版本上也能运行,因为这是 Quart 所需的最小版本。
如果您的计算机没有至少 Python 3.7,您可以从 Python 的官方网站下载新版本,那里提供了安装说明:www.python.org/downloads/
。
如果你在这个书中所有的代码示例都在虚拟环境中运行,你会发现这会更容易。或者使用 virtualenv (docs.python.org/3/library/venv.html
)。虚拟环境是 Python 保持每个项目独立的方式,这意味着你可以安装 Quart 和任何其他你需要的库;它只会影响你当前正在工作的应用程序。其他应用程序和项目可以有不同的库,或者同一库的不同版本,而不会相互干扰。使用 virtualenv 还意味着你可以轻松地在其他地方重新创建项目依赖,这在我们在后面的章节中部署微服务时将非常有用。
一些代码编辑器,如 PyCharm 或 Visual Studio,可能会为你管理虚拟环境。本书中的每个代码示例都在终端中运行,因此我们将使用终端来创建我们的 virtualenv。这也显示了比在网页或日志文件中查看程序输出更详细的工作方式,并且在未来修复任何问题时将非常有帮助。
在终端中,例如 macOS 终端应用程序或 Windows Subsystem for Linux,切换到你想要工作的目录,并运行以下命令:
python -m venv my-venv
根据你安装 Python 的方式,你可能需要使用 python3
来创建虚拟环境。
这将在当前目录中创建一个名为 my-venv
的新虚拟环境。如果你愿意,可以给它指定另一个路径,但重要的是要记住它的位置。要使用虚拟环境,你必须激活它:
source my-venv/bin/activate
在本书中的大多数命令行示例中,我们假设你正在 Linux 上运行,因为大多数在线服务都使用 Linux,所以熟悉它是很好的。这意味着大多数命令在 macOS 或使用 Windows Subsystem for Linux 的 Windows 上也能正常工作。在这些系统上运行 Docker 容器也是可能的,我们将在讨论部署微服务时再详细介绍容器。
现在,让我们安装 Quart,以便我们可以运行示例代码:
pip install quart
要在不关闭终端的情况下停止使用虚拟环境,你可以输入 deactivate
。不过,现在让我们保持 virtualenv 激活状态,看看 Quart 将如何工作。
Quart 处理请求的方式
框架的入口点是 quart.app
模块中的 Quart
类。运行 Quart 应用程序意味着运行这个类的单个实例,它将负责处理传入的 异步服务器网关接口(ASGI)和 Web 服务器网关接口(WSGI)请求,将它们分发给正确的代码,然后返回响应。记住,在 第一章,理解微服务 中,我们讨论了 ASGI 和 WSGI,以及它们如何定义 Web 服务器和 Python 应用程序之间的接口。
Quart 类提供了一个route
方法,可以用来装饰您的函数。以这种方式装饰函数后,它成为一个视图,并注册到路由系统中。
当一个请求到达时,它将指向一个特定的端点——通常是一个网址(例如duckduckgo.com/?q=quart
)或地址的一部分,例如/api
。路由系统是 Quart 如何将端点与视图连接起来的方式——即运行以处理请求的代码部分。
这是一个功能齐全的 Quart 应用程序的非常基础的示例:
# quart_basic.py
from quart import Quart
app = Quart(__name__)
@app.route("/api")
def my_microservice():
return {"Hello": "World!"}
if __name__ == "__main__":
app.run()
所有代码示例都可以在 GitHub 上找到,地址为github.com/PacktPublishing/Python-Microservices-Development-2nd-Edition/tree/main/CodeSamples
。
我们看到我们的函数返回一个字典,Quart 知道这应该被编码为 JSON 对象以进行传输。然而,只有查询/api
端点才会返回值。其他任何端点都会返回 404 错误,表示它找不到您请求的资源,因为我们没有告诉它任何信息!
__name__
变量,当您运行单个 Python 模块时其值将是__main__
,是应用程序包的名称。Quart 使用该名称创建一个新的日志记录器来格式化所有日志消息,并找到文件在磁盘上的位置。Quart 将使用该目录作为辅助程序的根目录,例如与您的应用程序关联的配置,以及确定static
和templates
目录的默认位置,我们将在后面讨论。
如果您在终端中运行该模块,Quart
应用程序将运行其自己的开发 Web 服务器,并开始监听端口5000
上的传入连接。这里,我们假设您仍然处于之前创建的虚拟环境中,并且上面的代码在名为quart_basic.py
的文件中:
$ python quart_basic.py
* Serving Quart app 'quart_basic'
* Environment: production
* Please use an ASGI server (e.g. Hypercorn) directly in production
* Debug mode: False
* Running on http://localhost:5000 (CTRL + C to quit)
[2020-12-10 14:05:18,948] Running on http://localhost:5000 (CTRL + C to quit)
使用浏览器或curl
命令访问http://localhost:5000/api
将返回一个包含正确头部的有效 JSON 响应:
$ curl -v http://localhost:5000/api
* Trying localhost...
...
< HTTP/1.1 200
< content-type: application/json
< content-length: 18
< date: Wed, 02 Dec 2020 20:29:19 GMT
< server: hypercorn-h11
<
* Connection #0 to host localhost left intact
{"Hello":"World!"}* Closing connection 0
本书将大量使用curl
命令。如果您在 Linux 或 macOS 下,它应该已经预安装;请参阅curl.haxx.se/
。
如果您不是在测试计算机上开发应用程序,您可能需要调整一些设置,例如它应该使用哪些 IP 地址来监听连接。当我们讨论部署微服务时,我们将介绍一些更好的更改其配置的方法,但现在,可以将app.run
行更改为使用不同的host
和port
:
app.run(host="0.0.0.0", port=8000)
虽然许多 Web 框架明确地将request
对象传递给您的代码,但 Quart 提供了一个全局的request
变量,它指向它为传入的 HTTP 请求构建的当前request
对象。
这个设计决策使得简单视图的代码非常简洁。就像我们的例子一样,如果你不需要查看请求内容来回复,就没有必要保留它。只要你的视图返回客户端应该得到的内容,并且 Quart 可以序列化它,一切就会如你所愿发生。对于其他视图,它们只需导入该变量并使用它即可。
request
变量是全局的,但它对每个传入的请求都是唯一的,并且是线程安全的。让我们在这里添加一些print
方法调用,以便我们可以看到底层发生了什么。我们还将显式地使用jsonify
创建一个Response
对象,而不是让 Quart 为我们做这件事,这样我们就可以检查它:
# quart_details.py
from quart import Quart, request, jsonify
app = Quart(__name__)
@app.route("/api", provide_automatic_options=False)
async def my_microservice():
print(dir(request))
response = jsonify({"Hello": "World!"})
print(response)
print(await response.get_data())
return response
if __name__ == "__main__":
print(app.url_map)
app.run()
在另一个终端中与curl
命令一起运行新版本,你会得到很多详细信息,包括以下内容:
$ python quart_details.py
QuartMap([<QuartRule '/api' (HEAD, GET, OPTIONS) -> my_microservice>,
<QuartRule '/static/<filename>' (HEAD, GET, OPTIONS) -> static>])
Running on http://localhost:5000 (CTRL + C to quit)
[… '_load_field_storage', '_load_form_data', '_load_json_data', '_send_push_promise', 'accept_charsets', 'accept_encodings', 'accept_languages', 'accept_mimetypes', 'access_control_request_headers', 'access_control_request_method', 'access_route', 'args', 'authorization', 'base_url', 'blueprint', 'body', 'body_class', 'body_timeout', 'cache_control', 'charset', 'content_encoding', 'content_length', 'content_md5', 'content_type', 'cookies', 'data', 'date', 'dict_storage_class', 'encoding_errors', 'endpoint', 'files', 'form', 'full_path', 'get_data', 'get_json', 'headers', 'host', 'host_url', 'http_version', 'if_match', 'if_modified_since', 'if_none_match', 'if_range', 'if_unmodified_since', 'is_json', 'is_secure', 'json', 'list_storage_class', 'max_forwards', 'method', 'mimetype', 'mimetype_params', 'on_json_loading_failed', 'origin', 'parameter_storage_class', 'path', 'pragma', 'query_string', 'range', 'referrer', 'remote_addr', 'root_path', 'routing_exception', 'scheme', 'scope', 'send_push_promise', 'url', 'url_charset', 'url_root', 'url_rule', 'values', 'view_args']
Response(200)
b'{"Hello":"World!"}'
让我们探索这里发生了什么:
-
路由
: 当服务启动时,Quart 创建QuartMap
对象,我们在这里可以看到它对端点和相关视图的了解。 -
Request
: Quart 创建一个Request
对象,my_microservice
向我们展示它是一个对/api
的GET
请求。 -
dir()
显示了一个类中哪些方法和变量,例如get_data()
用于检索随请求发送的任何数据。 -
Response
: 要发送回客户端的Response
对象;在这种情况下,是curl
。它有一个 HTTP 响应代码200
,表示一切正常,并且其数据是我们告诉它发送的'Hello world'字典。
路由
路由发生在app.url_map
中,这是一个QuartMap
类的实例,它使用一个名为Werkzeug
的库。该类使用正则表达式来确定由@app.route
装饰的函数是否与传入的请求匹配。路由只查看你在路由调用中提供的路径,以查看它是否与客户端的请求匹配。
默认情况下,映射器将只接受在声明的路由上的GET
、OPTIONS
和HEAD
方法。使用不支持的方法向有效的端点发送 HTTP 请求将返回一个405 Method Not Allowed
响应,并在allow
头中附带支持的方法列表:
$ curl -v -XDELETE http://localhost:5000/api
** Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 5000 (#0)
> DELETE /api HTTP/1.1
> Host: localhost:5000
> User-Agent: curl/7.64.1
> Accept: */*
>
< HTTP/1.1 405
< content-type: text/html
< allow: GET, OPTIONS, HEAD
< content-length: 137
< date: Wed, 02 Dec 2020 21:14:36 GMT
< server: hypercorn-h11
<
<!doctype html>
<title>405 Method Not Allowed</title>
<h1>Method Not Allowed</h1>
Specified method is invalid for this resource
* Connection #0 to host 127.0.0.1 left intact
* Closing connection 0
如果你想要支持特定的方法,允许你向端点POST
或DELETE
一些数据,你可以通过methods
参数将它们传递给route
装饰器,如下所示:
@app.route('/api', methods=['POST', 'DELETE', 'GET'])
def my_microservice():
return {'Hello': 'World!'}
注意,由于请求处理器自动管理,所有规则中都会隐式添加OPTIONS
和HEAD
方法。你可以通过将provide_automatic_options=False
参数传递给route
函数来禁用此行为。这在你想要在OPTIONS
被调用时向响应添加自定义头时很有用,例如在处理跨源资源共享(CORS)时,你需要添加几个Access-Control-Allow-*
头。
关于HTTP
请求方法的更多信息,一个很好的资源是 Mozilla 开发者网络:developer.mozilla.org/en-US/docs/Web/HTTP/Methods
.
变量和转换器
对于 API 的一个常见要求是能够指定我们想要请求的确切数据。例如,如果你有一个系统中每个人都有一个唯一的数字来识别他们,你可能会想创建一个处理发送到/person/N
端点的所有请求的函数,这样/person/3
只处理 ID 号为3
的人,而/person/412
只影响 ID 为412
的人。
你可以使用route
中的变量来做这件事,使用<VARIABLE_NAME>
语法。这种表示法相当标准(Bottle
也使用它),允许你用动态值描述端点。如果我们创建一个route
,例如/person/<person_id>
,那么,当 Quart 调用你的函数时,它会将 URL 中找到的值转换为具有相同名称的函数参数:
@app.route('/person/<person_id>')
def person(person_id):
return {'Hello': person_id}
$ curl localhost:5000/person/3
{"Hello": "3"}
如果你有几个匹配相同 URL 的路由,映射器会使用一组特定的规则来确定调用哪个。Quart
和Flask
都使用Werkzeug
来组织它们的路由;这是从 Werkzeug 的路由模块中摘取的实现描述:
-
没有参数的规则优先考虑性能。这是因为我们期望它们匹配得更快,一些常见的规则通常没有参数(索引页面等)。
-
更复杂的规则优先,因此第二个参数是权重的负数。
-
最后,我们按实际权重排序。
因此,Werkzeug 的规则有用于排序的权重,而在 Quart 中这些权重既没有被使用也没有被显示。所以,这归结为首先选择变量较多的视图,然后按出现顺序选择其他视图,当 Python 导入不同的模块时。一个经验法则是确保你的应用程序中声明的每个路由都是唯一的,否则追踪哪个被选中会让你头疼。
这也意味着我们的新路由不会对发送到/person
、/person/3/help
或任何其他变体的查询做出响应——只对/person/
后面跟一些字符集的查询做出响应。然而,字符包括字母和标点符号,而且我们已经决定/api/apiperson_id
是一个数字!这就是转换器有用的地方。
我们可以告诉route
一个变量具有特定的类型。由于/api/apiperson_id
是一个整数,我们可以使用<int:person_id>
,就像前面的例子一样,这样我们的代码只有在给出数字时才响应,而不是给出名称时。你还可以看到,与字符串"3"
不同,person_id
是一个没有引号的数字:
@app.route('/person/<int:person_id>')
def person(person_id):
return {'Hello': person_id}
$ curl localhost:5000/person/3
{
"Hello": 3
}
$ curl localhost:5000/person/simon
<!doctype html>
<title>404 Not Found</title>
<h1>Not Found</h1>
Nothing matches the given URI
如果我们有两个路由,一个用于 /person/<int:person_id>
,另一个用于 /person/<person_id>
(具有不同的函数名!),那么需要整数的更具体的一个将获取所有在正确位置有数字的请求,而另一个函数将获取剩余的请求。
内置转换器包括 string
(默认为 Unicode 字符串)、int
、float
、path
、any
和 uuid
。
路径转换器类似于默认转换器,但包括正斜杠,因此对 URL 的请求,如 /api/some/path/like/this
,将匹配路由 /api/<path:my_path>
,函数将获得一个名为 my_path
的参数,其中包含 some/path/like/this
。如果你熟悉正则表达式,它类似于匹配 [^/].*?
。
int
和 float
用于整数和浮点数——十进制数。any
转换器允许你组合多个值。一开始使用可能会有些困惑,但如果你需要将几个特定的字符串路由到同一位置,它可能很有用。路由 /<any(about, help, contact):page_name>
将匹配对 /about
、/help
或 /contact
的请求,并且所选的哪一个将包含在传递给函数的 page_name
变量中。
uuid
转换器匹配 UUID 字符串,例如从 Python 的 uuid
模块中获得的字符串,提供唯一的标识符。所有这些转换器在实际操作中的示例也包含在本章的 GitHub 代码示例中。
创建自定义转换器相当简单。例如,如果你想匹配用户 ID 和用户名,你可以创建一个查找数据库并将整数转换为用户名的转换器。为此,你需要创建一个从 BaseConverter
类派生的类,该类实现两个方法:to_python()
方法将值转换为视图的 Python 对象,以及 to_url()
方法用于反向转换(由 url_for()
使用,将在下一节中描述):
# quart_converter.py
from quart import Quart, request
from werkzeug.routing import BaseConverter, ValidationError
_USERS = {"1": "Alice", "2": "Bob"}
_IDS = {val: user_id for user_id, val in _USERS.items()}
class RegisteredUser(BaseConverter):
def to_python(self, value):
if value in _USERS:
return _USERS[value]
raise ValidationError()
def to_url(self, value):
return _IDS[value]
app = Quart(__name__)
app.url_map.converters["registered"] = RegisteredUser
@app.route("/api/person/<registered:name>")
def person(name):
return {"Hello": name}
if __name__ == "__main__":
app.run()
如果转换失败,将引发 ValidationError
方法,映射器将考虑该 route
简单地不匹配该请求。让我们尝试几个调用,看看它在实际中是如何工作的:
$ curl localhost:5000/api/person/1
{
"Hello hey": "Alice"
}
$ curl localhost:5000/api/person/2
{
"Hello hey": "Bob"
}
$ curl localhost:5000/api/person/3
<!doctype html>
<title>404 Not Found</title>
<h1>Not Found</h1>
Nothing matches the given URI
注意,上面的只是一个展示转换器强大功能的示例——一个以这种方式处理个人信息的 API 可能会向恶意人员泄露大量信息。当代码演变时,更改所有路由也可能很痛苦,因此最好只在必要时使用这种技术。
路由的最佳实践是尽可能保持其静态和直接。这一点在移动所有端点需要更改所有连接到它们的软件时尤其正确!通常,在端点的 URL 中包含一个版本号是一个好主意,这样就可以立即清楚地知道,例如 /v1/person
和 /v2/person
之间的行为将有所不同。
url_for
函数
Quart 的路由系统的最后一个有趣特性是url_for()
函数。给定任何视图,它将返回其实际 URL。以下是一个使用 Python 交互式使用的示例:
>>> from quart_converter import app
>>> from quart import url_for
>>> import asyncio
>>> async def run_url_for():
... async with app.test_request_context("/", method="GET"):
... print(url_for('person', name='Alice'))
...
>>> loop = asyncio.get_event_loop()
>>> loop.run_until_complete(run_url_for())
/api/person/1
之前的示例使用了读取-评估-打印循环(REPL),您可以通过直接运行 Python 可执行文件来获取它。那里还有一些额外的代码来设置异步程序,因为在这里,Quart 不会为我们做这件事。
当您想在模板中显示某些视图的 URL 时,url_for
功能非常有用——这取决于执行上下文。您不必硬编码一些链接,只需将函数名指向url_for
即可获取它。
请求
当收到请求时,Quart 调用视图并使用请求上下文来确保每个请求都有一个隔离的环境,特定于该请求。我们在上面的代码中看到了一个例子,我们使用辅助方法test_request_context()
进行测试。换句话说,当你在视图中访问全局请求对象时,你可以保证它是针对你特定请求的处理。
如我们之前在调用dir(request)
时看到的,当涉及到获取有关正在发生的事情的信息时,Request
对象包含许多方法,例如请求的计算机地址、请求类型以及其他信息,如授权头。请随意使用示例代码作为起点,实验一些这些请求方法。
在以下示例中,客户端发送的 HTTP 基本认证请求在发送到服务器时始终被转换为 base64 形式。Quart 将检测 Basic 前缀,并将其解析为request.authorization
属性中的username
和password
字段:
# quart_auth.py
from quart import Quart, request
app = Quart(__name__)
@app.route("/")
def auth():
print("Quart's Authorization information")
print(request.authorization)
return ""
if __name__ == "__main__":
app.run()
$ python quart_auth.py
* Running on http://localhost:5000/ (Press CTRL+C to quit)
Quart's Authorization information
{'username': 'alice', 'password': 'password'}
[2020-12-03 18:34:50,387] 127.0.0.1:55615 GET / 1.1 200 0 3066
$ curl http://localhost:5000/ --user alice:password
这种行为使得在request
对象之上实现可插拔的认证系统变得容易。其他常见的请求元素,如 cookies 和 files,都可以通过其他属性访问,正如我们将在整本书中了解到的那样。
响应
在许多之前的示例中,我们只是返回了一个 Python 字典,并让 Quart 为我们生成客户端可以理解的响应。有时,我们调用jsonify()
以确保结果是 JSON 对象。
有其他方法可以为我们的 Web 应用程序生成响应,还有一些其他值会自动转换为正确的对象。我们可以返回以下任何一个,Quart 都会做正确的事:
-
Response()
:手动创建一个Response
对象。 -
str
:字符串将被编码为响应中的 text/html 对象。这对于 HTML 页面特别有用。 -
dict
:字典将被jsonify()
编码为 application/json。 -
可以返回一个生成器或异步生成器对象,以便将数据流式传输到客户端。
-
一个
(response, status)
元组:如果响应匹配前面提到的数据类型之一,它将被转换为response
对象,状态将是使用的 HTTP 响应代码。 -
一个
(response, status, headers)
元组:响应将被转换,并且response
对象将使用提供的字典作为头部信息添加到响应中。
在大多数情况下,一个微服务将返回一些其他软件将解释并选择如何显示的数据,因此如果我们想返回一个列表或其他可以序列化为 JSON 的对象,我们将返回 Python 字典或使用 jsonify()
。
这里有一个使用 YAML 的示例,YAML 是另一种流行的数据表示方式:yamlify()
函数将返回一个 (response, status, headers)
元组,该元组将被 Quart 转换成一个合适的 Response
对象:
# yamlify.py
from quart import Quart
import yaml # requires PyYAML
app = Quart(__name__)
def yamlify(data, status=200, headers=None):
_headers = {"Content-Type": "application/x-yaml"}
if headers is not None:
_headers.update(headers)
return yaml.safe_dump(data), status, _headers
@app.route("/api")
def my_microservice():
return yamlify(["Hello", "YAML", "World!"])
if __name__ == "__main__":
app.run()
Quart 处理请求的方式可以总结如下:
-
当应用程序启动时,任何使用
@app.route()
装饰的函数都被注册为一个视图并存储在app.url_map
中。 -
根据其端点和方法,将调用分配到正确的视图中。
-
在一个局部、隔离的执行上下文中创建了一个
Request
对象。 -
一个
Response
对象封装了要发送的内容。
这四个步骤基本上就是你开始使用 Quart 构建应用程序所需了解的全部内容。下一节将总结 Quart 提供的最重要内置功能,以及这种请求-响应机制。
Quart 的内置功能
上一节让我们对 Quart 处理请求的方式有了很好的理解,这对您开始使用 Quart 已经足够了。还有更多有用的辅助工具。在本节中,我们将发现以下主要工具:
-
session
对象:基于 Cookie 的数据 -
全局变量:在
request
上下文中存储数据 -
信号:发送和拦截事件
-
扩展和中间件:添加功能
-
模板:构建基于文本的内容
-
配置:在
config
文件中分组你的运行选项 -
蓝图:在命名空间中组织你的代码
-
错误处理和调试:处理应用程序中的错误
session
对象
与 request
对象类似,Quart 创建了一个 session
对象,它是 request
上下文唯一的。它是一个类似于字典的对象,Quart 将其序列化成用户端的 cookie。会话映射中包含的数据将被转换为 JSON 映射,然后使用 zlib
进行压缩以减小其大小,最后使用 base64 进行编码。
当 session
被序列化时,itsdangerous (pythonhosted.org/itsdangerous/
) 库使用在应用程序中定义的 secret_key
值对内容进行签名。签名使用 HMAC (en.wikipedia.org/wiki/Hash-based_message_authentication_code
) 和 SHA1。
这个签名作为数据的后缀添加,确保客户端无法篡改存储在 cookie 中的数据,除非他们知道用于签名会话值的秘密密钥。请注意,数据本身并未加密。Quart 允许您自定义要使用的签名算法,但在您需要将数据存储在 cookie 中时,HMAC + SHA1 已经足够好了。
然而,当您构建不生成 HTML 的微服务时,您很少依赖 cookie,因为它们是特定于网络浏览器的。但是,为每个用户保持一个易失性的键值存储的想法对于加快一些服务器端工作可以非常有用。例如,如果您需要在每次用户连接时执行一些数据库查找以获取与用户相关的信息,那么在服务器端将此信息缓存在一个类似 session
的对象中,并根据其身份验证详细信息检索值是非常有意义的。
全局变量
如本章前面所述,Quart 提供了一种机制来存储特定于某个 request
上下文的全局变量。这用于 request
和 session
,但也可以用来存储任何自定义对象。
quart.g
变量包含所有全局变量,您可以在其上设置任何属性。在 Quart 中,可以使用 @app.before_request
装饰器指向一个函数,该函数将在每次请求之前被应用程序调用,在将请求分派到视图之前。
在 Quart 中,使用 before_request
来设置全局值是一种典型模式。这样,在请求上下文中调用的所有函数都可以与名为 g
的特殊全局变量交互并获取数据。在下面的示例中,我们复制了客户端在执行 HTTP Basic Authentication 时提供的 username
,并将其存储在 user
属性中:
# globals.py
from quart import Quart, g, request
app = Quart(__name__)
@app.before_request
def authenticate():
if request.authorization:
g.user = request.authorization["username"]
else:
g.user = "Anonymous"
@app.route("/api")
def my_microservice():
return {"Hello": g.user}
if __name__ == "__main__":
app.run()
当客户端请求 /api
视图时,authenticate
函数将根据提供的头信息设置 g.user
:
$ curl http://localhost:5000/api
{
"Hello": "Anonymous"
}
$ curl http://localhost:5000/api --user alice:password
{
"Hello": "alice"
}
任何您可能想到的特定于 request
上下文的数据,并且在整个代码中可以有用地共享,都可以添加到 quart.g
中。
信号
有时在应用程序中,我们希望在组件没有直接连接的情况下,从一个地方向另一个地方发送消息。我们可以发送此类消息的一种方式是使用信号。Quart 与 Blinker
(pythonhosted.org/blinker/
) 集成,这是一个信号库,允许您将函数订阅到事件。
事件是 AsyncNamedSignal
类的实例,该类基于 blinker.base.NamedSignal
类。它使用一个唯一的标签创建,Quart 在 0.13 版本中创建了 10 个这样的实例。Quart 在请求处理的关键时刻触发信号。由于 Quart
和 Flask
使用相同的系统,我们可以参考以下完整列表:flask.pocoo.org/docs/latest/api/#core-signals-list
。
通过调用信号的connect
方法来注册特定事件。当某些代码调用信号的send
方法时,会触发信号。send
方法接受额外的参数,以便将数据传递给所有已注册的函数。
在以下示例中,我们将finished
函数注册到request_finished
信号。该函数将接收response
对象:
# signals.py
from quart import Quart, g, request_finished
from quart.signals import signals_available
app = Quart(__name__)
def finished(sender, response, **extra):
print("About to send a Response")
print(response)
request_finished.connect(finished)
@app.route("/api")
async def my_microservice():
return {"Hello": "World"}
if __name__ == "__main__":
app.run()
signal
功能由Blinker
提供,当你安装Quart
时,Blinker
作为依赖项默认安装。
Quart 实现的一些信号在微服务中可能没有用,例如当框架渲染模板时发生的信号。然而,有一些有趣的信号在 Quart 的整个request
生命周期中被触发,可以用来记录正在发生的事情。例如,当框架在处理异常之前发生异常时,会触发got_request_exception
信号。这就是Sentry(sentry.io
)的 Python 客户端如何将自己挂钩以记录异常的方式。
当你想通过事件触发一些功能并解耦代码时,在你的应用程序中实现自定义信号可能也很有趣。例如,如果你的微服务生成 PDF 报告,并且你想对报告进行加密签名,你可以触发一个report_ready
信号,并让签名者注册该事件。
信号实现的一个重要方面是,注册的函数不会按任何特定顺序调用,因此如果被调用的函数之间存在依赖关系,这可能会导致问题。如果你需要执行更复杂或耗时的操作,那么考虑使用queue
,如RabbitMQ(www.rabbitmq.com/
)或由云平台如 Amazon Simple Queue Service 或 Google PubSub 提供的queue
,将消息发送到另一个服务。这些消息队列提供了比基本信号更多的选项,并允许两个组件轻松通信,甚至不必在相同的计算机上。我们将在第六章,与其他服务交互中介绍消息队列的示例。
扩展和中间件
Quart 扩展只是 Python 项目,一旦安装,就提供名为quart_something
的包或模块。它们在需要执行如身份验证或发送电子邮件等操作时,可以避免重新发明轮子。
因为Quart
可以支持一些Flask
可用的扩展,你通常可以在 Flask 的扩展列表中找到一些有用的东西:在 Python 包索引pypi.org/
中搜索Framework::Flask
。要使用Flask
扩展,你必须首先导入一个patch
模块以确保它能够正常工作。例如,要导入 Flask 的login
扩展,请使用以下命令:
import quart.flask_patch
import flask_login
已知与 Quart 兼容的 Flask 扩展的最新列表可以在以下地址找到。这是在寻找你的微服务需要的额外功能时的一个好起点:pgjones.gitlab.io/quart/how_to_guides/flask_extensions.html
。
扩展 Quart 的另一种机制是使用 ASGI 或 WSGI 中间件。这些通过围绕端点包装自身来扩展应用程序,并改变进出数据。
在下面的示例中,中间件模拟了一个 X-Forwarded-For
标头,这样 Quart 应用程序就会认为它位于一个代理(如 nginx
)后面。在测试环境中,当你想确保应用程序在尝试获取远程 IP 地址时表现正常时,这很有用,因为 remote_addr
属性将获取代理的 IP 地址,而不是真实客户端的 IP 地址。在这个例子中,我们必须创建一个新的 Headers
对象,因为现有的一个是不可变的:
# middleware.py
from quart import Quart, request
from werkzeug.datastructures import Headers
class XFFMiddleware:
def __init__(self, app, real_ip="10.1.1.1"):
self.app = app
self.real_ip = real_ip
async def __call__(self, scope, receive, send):
if "headers" in scope and "HTTP_X_FORWARDED_FOR" not in scope["headers"]:
new_headers = scope["headers"].raw_items() + [
(
b"X-Forwarded-For",
f"{self.real_ip}, 10.3.4.5, 127.0.0.1".encode(),
)
]
scope["headers"] = Headers(new_headers)
return await self.app(scope, receive, send)
app = Quart(__name__)
app.asgi_app = XFFMiddleware(app.asgi_app)
@app.route("/api")
def my_microservice():
if "X-Forwarded-For" in request.headers:
ips = [ip.strip() for ip in request.headers["X-Forwarded-For"].split(",")]
ip = ips[0]
else:
ip = request.remote_addr
return {"Hello": ip}
if __name__ == "__main__":
app.run()
注意,我们在这里使用 app.asgi_app
来包装 ASGI 应用程序。app.asgi_app
是应用程序存储的地方,以便人们可以以这种方式包装它。send
和 receive
参数是通过它们我们可以通信的通道。值得记住的是,如果中间件向客户端返回响应,那么 Quart
应用程序将永远不会看到该请求!
在大多数情况下,我们不需要编写自己的中间件,只需包含一个扩展来添加其他人已经制作的功能就足够了。
模板
如我们之前所看到的示例,发送回 JSON 或 YAML 文档是足够简单的。同样,大多数微服务产生的是机器可读数据,如果人类需要阅读它,前端必须正确地格式化它,例如在网页上使用 JavaScript。然而,在某些情况下,我们可能需要创建具有某些布局的文档,无论是 HTML 页面、PDF 报告还是电子邮件。
对于任何基于文本的内容,Quart 集成了名为 Jinja 的模板引擎(jinja.palletsprojects.com/
)。你经常会看到示例展示 Jinja 被用来创建 HTML 文档,但它可以与任何基于文本的文档一起使用。配置管理工具,如 Ansible,使用 Jinja 从模板创建配置文件,以便计算机的设置可以自动保持最新。
大多数情况下,Quart 会使用 Jinja 来生成 HTML 文档、电子邮件消息或其他面向人类的通信内容——例如短信或与 Slack 或 Discord 等工具上的人交谈的机器人。Quart 提供了如 render_template
这样的辅助工具,通过选择一个 Jinja 模板并给出一些数据来生成响应。
例如,如果你的微服务发送电子邮件而不是依赖于标准库的电子邮件包来生成电子邮件内容,这可能会很繁琐,那么你可以使用 Jinja。以下示例电子邮件模板应该保存为 email_template.j2
,以便后续的代码示例能够正常工作:
Date: {{date}}
From: {{from}}
Subject: {{subject}}
To: {{to}}
Content-Type: text/plain
Hello {{name}},
We have received your payment!
Below is the list of items we will deliver for lunch:
{% for item in items %}- {{item['name']}} ({{item['price']}} Euros)
{% endfor %}
Thank you for your business!
--
My Fictional Burger Place
Jinja 使用双括号来标记将被值替换的变量。变量可以是执行时传递给 Jinja 的任何内容。你还可以直接在模板中使用 Python 的 if
和 for
块,使用 {% for x in y % }... {% endfor %}
和 {% if x %}...{% endif %}
标记。
以下是一个使用电子邮件模板生成完全有效的 RFC 822
消息的 Python 脚本,你可以通过 SMTP 发送它:
# email_render.py
from datetime import datetime
from jinja2 import Template
from email.utils import format_datetime
def render_email(**data):
with open("email_template.j2") as f:
template = Template(f.read())
return template.render(**data)
data = {
"date": format_datetime(datetime.now()),
"to": "bob@example.com",
"from": "shopping@example-shop.com",
"subject": "Your Burger order",
"name": "Bob",
"items": [
{"name": "Cheeseburger", "price": 4.5},
{"name": "Fries", "price": 2.0},
{"name": "Root Beer", "price": 3.0},
],
}
print(render_email(**data))
render_email
函数使用 Template
类来生成电子邮件,使用提供的数据。
Jinja 是一个强大的工具,并附带了许多在这里描述会占用太多空间的特性。如果你需要在你的微服务中进行一些模板化工作,它是一个不错的选择,也存在于 Quart 中。查看以下链接以获取 Jinja 特性的完整文档:jinja.palletsprojects.com/
。
配置
在构建应用程序时,你需要公开运行它们所需的选项,例如连接到数据库所需的信息、要使用的联系电子邮件地址或任何特定于部署的变量。
Quart 在其配置方法上使用了一种类似于 Django 的机制。Quart
对象包含一个名为 config
的对象,其中包含一些内置变量,并且可以在你启动 Quart
应用程序时通过你的配置对象进行更新。例如,你可以在 Python 格式的文件中定义一个 Config
类,如下所示:
# prod_settings.py
class Config:
DEBUG = False
SQLURI = "postgres://username:xxx@localhost/db"
然后,你可以使用 app.config.from_object
从你的 app
对象中加载它:
>>> from quart import Quart
>>> import pprint
>>> pp = pprint.PrettyPrinter(indent=4)
>>> app = Quart(__name__)
>>> app.config.from_object('prod_settings.Config')
>>> pp.pprint(app.config)
{ 'APPLICATION_ROOT': None,
'BODY_TIMEOUT': 60,
'DEBUG': False,
'ENV': 'production',
'JSONIFY_MIMETYPE': 'application/json',
'JSONIFY_PRETTYPRINT_REGULAR': False,
'JSON_AS_ASCII': True,
'JSON_SORT_KEYS': True,
'MAX_CONTENT_LENGTH': 16777216,
'PERMANENT_SESSION_LIFETIME': datetime.timedelta(days=31),
'PREFER_SECURE_URLS': False,
'PROPAGATE_EXCEPTIONS': None,
'RESPONSE_TIMEOUT': 60,
'SECRET_KEY': None,
'SEND_FILE_MAX_AGE_DEFAULT': datetime.timedelta(seconds=43200),
'SERVER_NAME': None,
'SESSION_COOKIE_DOMAIN': None,
'SESSION_COOKIE_HTTPONLY': True,
'SESSION_COOKIE_NAME': 'session',
'SESSION_COOKIE_PATH': None,
'SESSION_COOKIE_SAMESITE': None,
'SESSION_COOKIE_SECURE': False,
'SESSION_REFRESH_EACH_REQUEST': True,
'SQLURI': 'postgres://username:xxx@localhost/db',
'TEMPLATES_AUTO_RELOAD': None,
'TESTING': False,
'TRAP_HTTP_EXCEPTIONS': False}
然而,当使用 Python 模块作为配置文件时,存在两个显著的缺点。首先,由于这些配置模块是 Python 文件,因此很容易在其中添加代码以及简单的值。这样做的话,你将不得不像对待其他应用程序代码一样对待这些模块;这可能会是一种复杂的方式来确保它始终产生正确的值,尤其是在使用模板生成配置的情况下!通常,当应用程序部署时,配置是独立于代码进行管理的。
其次,如果另一个团队负责管理你的应用程序的配置文件,他们需要编辑 Python 代码来完成这项工作。虽然这通常是可以接受的,但它增加了引入一些问题的可能性,因为它假设其他人熟悉 Python 以及你的应用程序的结构。通常,确保只需要更改配置的人不需要了解代码的工作方式是一种良好的实践。
由于 Quart 通过 app.config
暴露其配置,因此从 JSON、YAML 或其他流行的基于文本的配置格式中加载附加选项相当简单。以下所有示例都是等效的:
>>> from quart import Quart
>>> import yaml
>>> from pathlib import Path
>>> app = Quart(__name__)
>>> print(Path("prod_settings.json").read_text())
{
"DEBUG": false,
"SQLURI":"postgres://username:xxx@localhost/db"
}
>>> app.config.from_json("prod_settings.json")
>>> app.config["SQLURI"]
'postgres://username:xxx@localhost/db'
>>> print(Path("prod_settings.yml").read_text())
---
DEBUG: False
SQLURI: "postgres://username:xxx@localhost/db"
>>> app.config.from_file("prod_settings.yml", yaml.safe_load)
你可以为 from_file
提供一个用于理解数据的函数,例如 yaml.safe_load
、toml.load
和 json.load
。如果你更喜欢带有 [sections]
和 name = value
的 INI 格式,那么存在许多扩展来帮助,标准库的 ConfigParser
也非常直接。
蓝图
当你编写具有多个端点的微服务时,你将结束于许多不同的装饰函数——记住那些是带有装饰器的函数,例如 @app.route
。组织代码的第一个逻辑步骤是每个端点有一个模块,当你创建应用实例时,确保它们被导入,这样 Quart 就可以注册视图。
例如,如果你的微服务管理一个公司的员工数据库,你可以有一个端点用于与所有员工交互,另一个端点用于与团队交互。你可以将你的应用程序组织成这三个模块:
-
app.py
: 包含Quart
应用对象,并运行应用 -
employees.py
: 用于提供所有与员工相关的视图 -
teams.py
: 用于提供所有与团队相关的视图
从那里,员工和团队可以被视为应用的子集,可能有一些特定的实用程序和配置。这是构建任何 Python 应用程序的标准方式。
蓝图通过提供一种将视图分组到命名空间中的方法,进一步扩展了这种逻辑,使得在单独的文件中使用这种结构,并为其提供一些特殊的框架支持。你可以创建一个类似于 Quart
应用对象的 Blueprint
对象,然后使用它来安排一些视图。初始化过程可以通过 app.register_blueprint
注册蓝图,以确保蓝图定义的所有视图都是应用的一部分。员工蓝图的一个可能实现如下:
# blueprints.py
from quart import Blueprint
teams = Blueprint("teams", __name__)
_DEVS = ["Alice", "Bob"]
_OPS = ["Charles"]
_TEAMS = {1: _DEVS, 2: _OPS}
@teams.route("/teams")
def get_all():
return _TEAMS
@teams.route("/teams/<int:team_id>")
def get_team(team_id):
return _TEAMS[team_id]
主要模块(app.py
)可以导入此文件,并使用 app.register_blueprint(teams)
注册其蓝图。当你想在另一个应用程序或同一应用程序中多次重用一组通用的视图时,这种机制也非常有趣——可以想象一个场景,例如,库存管理区域和销售区域可能都需要查看当前库存水平的能力。
错误处理
当你的应用程序出现问题时,能够控制客户端将接收到的响应是很重要的。在 HTML 网络应用中,当你遇到 404
(资源未找到)或 5xx
(服务器错误)时,通常会得到特定的 HTML 页面,这就是 Quart 默认的工作方式。但在构建微服务时,你需要对应该发送回客户端的内容有更多的控制——这就是自定义错误处理器有用的地方。
另一个重要功能是在发生意外错误时调试你的代码;Quart 自带一个内置的调试器,可以在你的应用程序以调试模式运行时激活。
自定义错误处理器
当你的代码没有处理异常时,Quart 会返回一个 HTTP 500
响应,不提供任何特定信息,如跟踪信息。生成通用错误是一个安全的默认行为,以避免在错误信息的正文中向用户泄露任何私人信息。默认的500
响应是一个简单的 HTML 页面以及正确的状态码:
$ curl http://localhost:5000/api
<!doctype html>
<title>500 Internal Server Error</title>
<h1>Internal Server Error</h1>
Server got itself in trouble
当使用 JSON 实现微服务时,一个好的做法是确保发送给客户端的每个响应,包括任何异常,都是 JSON 格式的。你的微服务的消费者期望每个响应都是可机器解析的。告诉客户端你遇到了错误,并让它设置好处理该消息并展示给人类,比给客户端一些他们不理解的东西并让它抛出自己错误要好得多。
Quart 允许你通过几个函数自定义应用程序错误处理。第一个是@app.errorhandler
装饰器,它的工作方式类似于@app.route
。但与提供端点不同,装饰器将一个函数链接到一个特定的错误代码。
在以下示例中,我们使用它来连接一个函数,当 Quart 返回500
服务器响应(任何代码异常)时,该函数将返回 JSON 格式的错误:
# error_handler.py
from quart import Quart
app = Quart(__name__)
@app.errorhandler(500)
def error_handling(error):
return {"Error": str(error)}, 500
@app.route("/api")
def my_microservice():
raise TypeError("Some Exception")
if __name__ == "__main__":
app.run()
无论代码抛出什么异常,Quart 都会调用这个错误视图。然而,如果你的应用程序返回 HTTP 404
或任何其他4xx
或5xx
响应,你将回到 Quart 发送的默认 HTML 响应。为了确保你的应用程序为每个4xx
和5xx
响应发送 JSON,我们需要将这个函数注册到每个错误代码。
error_handling function to every error using app.register_error_handler, which is similar to the @app.errorhandler decorator:
# catch_all_errors.py
from quart import Quart, jsonify, abort
from werkzeug.exceptions import HTTPException, default_exceptions
def jsonify_errors(app):
def error_handling(error):
if isinstance(error, HTTPException):
result = {
"code": error.code,
"description": error.description,
"message": str(error),
}
else:
description = abort.mapping[ error.code].description
result = {"code": error.code, "description": description, "message": str(error)}
resp = jsonify(result)
resp.status_code = result["code"]
return resp
for code in default_exceptions.keys():
app.register_error_handler(code, error_handling)
return app
app = Quart(__name__)
app = jsonify_errors(app)
@app.route("/api")
def my_microservice():
raise TypeError("Some Exception")
if __name__ == "__main__":
app.run()
jsonify_errors
函数修改了一个Quart
应用程序实例,并为可能发生的每个4xx
和5xx
错误设置了自定义的 JSON 错误处理器。
微服务骨架
到目前为止,在本章中,我们探讨了 Quart 的工作原理,以及它提供的几乎所有内置功能——我们将在整本书中使用这些功能。我们还没有涉及的一个主题是如何组织项目中的代码,以及如何实例化你的Quart
应用程序。到目前为止的每个示例都使用了一个 Python 模块和app.run()
调用来运行服务。
将所有内容放在一个模块中是可能的,但除非你的代码只有几行,否则会带来很多麻烦。由于我们希望发布和部署代码,最好将其放在 Python 包中,这样我们就可以使用标准的打包工具,如pip
和setuptools
。
将视图组织到蓝图,并为每个蓝图创建一个模块也是一个好主意。这让我们能更好地跟踪每段代码的作用,并在可能的情况下重用代码。
最后,可以从代码中删除 run()
调用,因为 Quart 提供了一个通用的运行命令,该命令通过 QUART_APP
环境变量的信息查找应用程序。使用该运行器提供了额外的选项,例如,可以在不进入设置的情况下配置用于运行应用程序的主机和端口。
GitHub 上的微服务项目是为本书创建的,是一个通用的 Quart 项目,您可以用它来启动微服务。它实现了一个简单的布局,这对于构建微服务非常有效。您可以安装并运行它,然后对其进行修改。项目可以在 github.com/PythonMicroservices/microservice-skeleton
找到。
microservice
项目骨架包含以下结构:
-
setup.py
: Distutils 的设置文件,用于安装和发布项目。 -
Makefile
: 包含一些有用的目标,用于构建、构建和运行项目。 -
settings.yml
: 在 YAML 文件中的应用默认设置。 -
requirements.txt
: 根据pip freeze
生成的pip
格式的项目依赖。 -
myservices/
: 实际的包-
__init__.py
-
app.py
: 包含应用程序本身的模块 -
views/
: 包含按蓝图组织视图的目录-
__init__.py
-
home.py
: 为主蓝图,它服务于根端点
-
-
tests/:
包含所有测试的目录-
__init__.py
-
test_home.py
: 对主蓝图视图的测试
-
-
在以下代码中,app.py
文件使用名为 create_app
的辅助函数实例化 Quart
应用程序,以注册蓝图并更新设置:
import os
from myservice.views import blueprints
from quart import Quart
_HERE = os.path.dirname(__file__)
_SETTINGS = os.path.join(_HERE, "settings.ini")
def create_app(name=__name__, blueprints=None, settings=None):
app = Quart(name)
# load configuration
settings = os.environ.get("QUART_SETTINGS", settings)
if settings is not None:
app.config.from_pyfile(settings)
# register blueprints
if blueprints is not None:
for bp in blueprints:
app.register_blueprint(bp)
return app
app = create_app(blueprints=blueprints, settings=_SETTINGS)
home.py
视图使用蓝图创建了一个简单的路由,不返回任何内容:
from quart import Blueprint
home = Blueprint("home", __name__)
@home.route("/")
def index():
"""Home view.
This view will return an empty JSON mapping.
"""
return {}
此示例应用程序可以通过 Quart 内置的命令行运行,使用包名:
$ QUART_APP=myservice quart run
* Serving Quart app 'myservice.app'
* Environment: production
* Please use an ASGI server (e.g. Hypercorn) directly in production
* Debug mode: False
* Running on http://localhost:5000 (CTRL + C to quit)
[2020-12-06 20:17:28,203] Running on http://127.0.0.1:5000 (CTRL + C to quit)
从那里开始,为您的微服务构建 JSON 视图包括向微服务/views 添加模块及其相应的测试。
摘要
本章为我们提供了 Quart 框架的详细概述以及如何使用它来构建微服务。需要记住的主要事项是:
-
Quart 在 ASGI 协议周围包装了一个简单的请求-响应机制,这使得您几乎可以用纯 Python 编写应用程序。
-
Quart 很容易扩展,如果需要,可以使用 Flask 扩展。
-
Quart 内置了一些有用的功能:蓝图、全局变量、信号、模板引擎和错误处理器。
-
微服务项目是一个 Quart 骨架,本书将使用它来编写微服务。
下一章将重点介绍开发方法:如何持续编码、测试和记录您的微服务。
第三章:编码、测试和文档:良性循环
我们编写软件是因为我们希望它能做些有用的事情。但我们如何知道代码确实做了我们想要它做的事情呢?显而易见的答案是测试它。有时我们会运行刚刚编写的代码,看看它做了什么,以确定它是否在正确地执行。然而,代码通常很多,我们希望确保很多功能都在正常工作——并且在我们添加新功能时继续工作。
幸运的是,大多数语言都提供了一种自动化测试代码的方法,Python 也不例外。当我们与编写的代码一起创建测试时,会增加发现错误的概率。代码中的错误会耗费时间,也可能给公司造成损失。错误也不可能完全消除——我们能做的最好的事情就是采取合理的步骤来防止尽可能多的错误。
编写测试有助于更清晰地了解软件的预期功能。例如,一个旨在从列表中返回五个最高数字的函数:
def filter(some_numbers):
some_numbers.sort()
return some_numbers[-5:]
这是一个非常简单的函数,所以它可能确实做了我们想要它做的事情。或者呢?.sort()
方法在原地工作而不是返回一个新值,所以我们改变了作为参数传递的变量的顺序;这可能在程序的其它区域产生意外的后果。该函数也没有检查它是否返回了数字,所以如果列表中有其他对象,应该怎么办?如果返回五个数字的数量不足,这是否可以接受,或者函数应该引发错误?
通过编写测试,我们不仅对函数想要做什么有一个清晰的认识,而且对它在不同情况下应该如何表现也有了解:我们继续朝着目标努力,但不是思考“如何赢”,而是思考“如何避免失败”。
测试驱动开发(TDD)是一种方法,你会在创建代码的同时编写测试,并使用这些测试来指导代码应该做什么——以及证明它按预期工作。它并不总是能提高项目的质量,但它会在错误造成损害之前捕捉到很多错误,帮助使团队更加敏捷。需要修复错误或重构应用程序一部分的开发者可以这样做,而不用担心他们破坏了什么,并且更容易向团队展示这项工作是合适的。
行为驱动开发(BDD)是另一种可以与 TDD 良好结合的方法。使用这种方法,测试从更高层次的角度描述软件的期望行为,并且通常使用更符合人类语言的表达方式。开发者可以编写代码来描述当测试使用诸如“用户搜索”和“显示结果”之类的短语时会发生什么,这样可以让编写测试的人专注于应该发生的事情;例如:
Scenario: Basic DuckDuckGo Search
When the user searches for "panda"
Then results are shown for "panda"
一些 BDD 测试的好例子可以在以下链接找到:github.com/AndyLPK247/behavior-driven-python
编写测试最初可能会很耗时,但从长远来看,这通常是确保项目在规模和范围增长时保持稳定性的最佳方法。当然,总是有可能编写出糟糕的测试,最终得到不良的结果,或者创建一个难以维护且运行时间过长的测试套件。世界上最好的工具和流程也无法阻止一个粗心的开发者生产出糟糕的软件。
图 3.1:即使提供的是最好的工具,也无法阻止自满的开发者生产出糟糕的软件……归功于:xkcd.com/303/
一套好的测试应该证明软件确实做了我们想要它做的事情,并且它应该以可预测和可修复的方式失败。这意味着如果你给它无效的数据,或者它依赖的某个部分已经损坏,代码的行为是可预测的。
编写测试也是了解代码的一种好方法。你设计的 API 是否合理?事物是否搭配得当?当团队人数增加或重组时,测试是了解代码状态的良好信息来源,详细说明了代码的意图。软件满足的具体需求也可能会随时间而变化,这意味着可能需要进行重大的重构——不仅仅是重写,还包括改变架构。
文档是项目的一个关键部分,尽管它往往是第一个落后的领域。过了一段时间,除非有专人负责,否则很难看到项目的文档与代码状态完全同步。对于开发者来说,发现文档中的代码示例在重构后已经损坏,可能会非常沮丧。但有一些方法可以减轻这些问题;例如,文档中的代码摘录可以是测试套件的一部分,以确保它们可以正常工作。
在任何情况下,无论你投入多少精力在测试和文档上,有一条黄金法则:测试、编写文档和编码你的项目应该是持续进行的。换句话说,更新测试和文档是更新代码的过程的一部分。我们将看到,我们可以使这个过程更容易。
在提供了一些关于如何在 Python 中测试代码的一般性建议之后,本章重点介绍了在构建使用 Quart 的微服务时可以使用哪些测试和文档工具,以及如何与一些流行的在线服务设置持续集成。
本章分为五个部分:
-
不同的测试类型
-
使用 pytest 和 tox
-
开发者文档
-
版本控制
-
持续集成
不同的测试类型
测试有很多种,有时很难知道正在讨论的是什么。例如,当人们提到功能测试时,他们可能根据项目的性质而指代不同的测试。在微服务领域,我们可以将测试分为以下五个不同的目标:
-
单元测试:这些确保类或函数在隔离状态下按预期工作。
-
功能测试:验证微服务是否从消费者的角度来看做了它所说的,并且即使在接收到不良请求时也能正确行为。
-
集成测试:验证微服务如何与其所有网络依赖项集成。
-
负载测试:衡量微服务的性能。
-
端到端测试:验证整个系统是否正常工作——从初始请求到最终操作——通过其所有组件。
在接下来的几节中,我们将更深入地探讨细节。
单元测试
单元测试是添加到项目中 simplest 和最 self-contained 的测试。被测试的“单元”是代码的一个小组件;例如,一个单元测试可能检查一个函数,几个单元测试可能对该函数运行一系列测试,而整个测试套件可能对该函数所在的模块运行一系列测试。
Python 的标准库包含了编写单元测试所需的一切。在大多数项目中,都有可以独立测试的函数和类,基于 Quart 的项目也不例外。
在 Python 中进行隔离测试通常意味着您使用特定的参数实例化一个类或调用一个函数,并验证您是否得到预期的结果。如果您有一个函数接受一个大型数据结构并在其中搜索特定值,那么这可以很容易地单独测试,因为它已经得到了它所需的一切。然而,当类或函数调用不是由 Python 或其标准库构建的其他代码时,它就不再是隔离的了。
在某些情况下,模拟这些调用以实现隔离将是有用的。模拟意味着用假版本替换一段代码,该版本将返回测试所需的值,但模拟实际代码的行为。一个例子可能是一个查询数据库并在返回之前格式化数据的函数。在我们的单元测试套件中,我们可能不想运行真实的数据库,但我们可以模拟数据库查询,使其返回可预测的值,并确保我们的函数能够正确处理它所提供的内容。
模拟也伴随着其自身的风险,因为很容易在模拟中实现不同的行为,最终得到一些与测试兼容但与实际事物不兼容的代码。当您更新项目的依赖项或外部服务更改其发送的内容时,并且您的模拟没有更新以反映新的行为,这个问题通常会发生。
因此,将模拟的使用限制在以下三个用例中是一种良好的实践:
-
I/O 操作:当代码对第三方服务或资源(套接字、文件等)进行调用,并且你无法在测试中运行它们时。
-
CPU 密集型操作:当调用计算的内容会使测试套件运行得太慢时。
-
具体要复现的行为:当你想要编写一个测试来测试你的代码在特定行为下(例如,网络错误或通过模拟日期和时间模块来更改日期和时间)的表现时。
清洁代码技术可以通过尝试减少具有副作用的功能数量并将所有 I/O 操作聚集到最高级别,从而帮助使我们的单元测试更加直接。考虑以下场景,我们正在从 Mozilla 开发者网络关于 JSON 的文档中读取有关超级英雄的一些数据(developer.mozilla.org/en-US/docs/Learn/JavaScript/Objects/JSON
):
import requests
def query_url(url):
response = requests.get(url)
response.raise_for_status()
return response.json()
def get_hero_names(filter=None):
url = "https://mdn.github.io/learning-area/javascript/oojs/json/superheroes.json"
json_body = query_url(url)
for member in json_body.get("members", []):
if filter(member):
yield member["name"]
def format_heroes_over(age=0):
hero_names = get_hero_names(filter=lambda hero: hero.get("age", 0) > age)
formatted_text = ""
for hero in hero_names:
formatted_text += f"{hero} is over {age}\n"
return formatted_text
if __name__ == "__main__":
print(format_heroes_over(age=30))
为了测试构建字符串的功能或过滤名称的功能,我们需要创建一个到网络的假连接。这意味着我们的一些函数依赖于模拟连接。相反,我们可以编写:
# requests_example2.py
import requests
def query_url(url):
response = requests.get(url)
response.raise_for_status()
return response.json()
def get_hero_names(hero_data, hero_filter=None):
for member in hero_data.get("members", []):
if hero_filter is None or hero_filter(member):
yield member
def render_hero_message(heroes, age):
formatted_text = ""
for hero in heroes:
formatted_text += f"{hero['name']} is over {age}\n"
return formatted_text
def render_heroes_over(age=0):
url = "https://mdn.github.io/learning-area/javascript/oojs/json/superheroes.json"
json_body = query_url(url)
relevant_heroes = get_hero_names(
json_body, hero_filter=lambda hero: hero.get("age", 0) > age
)
return render_hero_message(relevant_heroes, age)
if __name__ == "__main__":
print(render_heroes_over(age=30))
通过这种方式重新排列我们的代码,网络连接查询是“更高层”——我们首先遇到它。我们可以编写测试来检查和过滤结果,而无需创建任何模拟连接,这意味着我们可以在代码中独立使用它们——也许我们从缓存而不是从网络中获取相同的数据,或者从已经拥有它的客户端接收它。我们的代码现在更加灵活,并且更容易测试。
当然,这是一个有些牵强的例子,但原则意味着我们正在安排我们的代码,以便有定义良好的输入和输出的函数,这使得测试更容易。这是从函数式编程语言的世界中吸取的一个重要教训,例如 Lisp 和 Haskell,在这些语言中,函数更接近数学函数。
测试可能看起来像什么?以下是一个使用unittest
的类,它将执行一些基本检查,包括在名为setUp
的方法中设置测试所需的所有先决条件,并在添加名为tearDown
的方法后进行清理。如果这是一个真实的服务,我们可能还想要考虑其他情况,例如get_hero_names
在没有过滤器的情况下会做什么,或者一个返回无结果或抛出错误的过滤器。以下示例使用request_mock
,这是一个方便的库,用于模拟基于请求的网络调用(见requests-mock.readthedocs.io
):
import unittest
from unittest import mock
import requests_mock
import requests_example2 # Our previous example code
class TestHeroCode(unittest.TestCase):
def setUp(self):
self.fake_heroes = {
"members": [
{"name": "Age 20 Hero", "age": 20},
{"name": "Age 30 Hero", "age": 30},
{"name": "Age 40 Hero", "age": 40},
]
}
def test_get_hero_names_age_filter(self):
result = list(
requests_example2.get_hero_names(
self.fake_heroes, filter=lambda x: x.get("age", 0) > 30
)
)
self.assertEqual(result, [{"name": "Age 40 Hero", "age": 40}])
@requests_mock.mock()
def test_display_heroes_over(self, mocker):
mocker.get(requests_mock.ANY, json=self.fake_heroes)
rendered_text = requests_example2.render_heroes_over(age=30)
self.assertEqual(rendered_text, "Age 40 Hero is over 30\n")
if __name__ == "__main__":
unittest.main()
你应该密切关注所有模拟,随着项目的增长,确保它们不是覆盖特定功能的唯一测试类型。例如,如果 Bugzilla 项目为其 REST API 提出了一种新的结构,并且你项目使用的服务器进行了更新,你的测试将带着你损坏的代码高兴地通过,直到模拟反映了新的行为。
测试的数量和测试覆盖率的好坏将取决于您的应用程序用途。除非您的微服务对您的业务至关重要,否则没有必要在第一天就为所有可能出现的故障编写测试。在微服务项目中,单元测试不是优先事项,并且在单元测试中追求 100%的测试覆盖率(即您的代码的每一行都在测试中被调用)将为维护工作增加很多,而好处却很少。
最好专注于构建一个健壮的功能测试集。
功能测试
微服务项目的功能测试是指所有通过发送 HTTP 请求与发布的 API 交互,并断言 HTTP 响应是预期结果的测试。这些测试与单元测试不同,因为它们更多地关注微服务或更大服务中较小部分的行为。
这个定义足够广泛,可以包括任何可以调用应用程序的测试,从模糊测试(向您的应用程序发送乱码并查看会发生什么)到渗透测试(您尝试破坏应用程序的安全性),等等。功能测试的关键部分是调查软件的行为是否符合其要求。作为开发者,我们应该关注的最重要两种功能测试如下:
-
验证应用程序是否按预期工作的测试
-
确保已修复的异常行为不再发生的测试
测试类中这些场景的组织方式由开发者决定,但一般模式是在测试类中创建应用程序的实例,然后与之交互。
在这种情况下,网络层没有被使用,应用程序直接由测试调用;发生相同的请求-响应周期,因此足够真实。然而,我们仍然会模拟应用程序中发生的任何网络调用。
Quart 包含一个QuartClient
类来构建请求,可以直接从app
对象使用其test_client()
方法创建。以下是对我们在第二章,探索 Quart中展示的quart_basic
应用进行的测试示例,该测试在/api/
上返回 JSON 体:
import unittest
import json
from quart_basic import app as tested_app
class TestApp(unittest.IsolatedAsyncioTestCase):
async def test_help(self):
# creating a QuartClient instance to interact with the app
app = tested_app.test_client()
# calling /api/ endpoint
hello = await app.get("/api")
# asserting the body
body = json.loads(str(await hello.get_data(), "utf8"))
self.assertEqual(body["Hello"], "World!")
if __name__ == "__main__":
unittest.main()
QuartClient
类针对每种 HTTP 方法都有一个方法,并返回可以用来断言结果的Response
对象。在先前的示例中,我们使用了.get()
,由于它是异步代码,我们必须等待调用和对get_data()
的请求,以及告诉unittest
模块我们正在运行异步测试。
Quart
类中有一个测试标志,您可以使用它将异常传播到测试中,但有些人更喜欢默认不使用它,以便从应用程序中获取真实客户端会得到的内容——例如,确保5xx
或4xx
错误体的 JSON 转换,以保证 API 的一致性。
在以下示例中,/api/
调用产生了一个异常,并且我们为内部服务器错误和缺失页面设置了错误处理器:
# quart_error.py
from quart import Quart
app = Quart(__name__)
text_404 = (
"The requested URL was not found on the server. "
"If you entered the URL manually please check your "
"spelling and try again."
)
@app.errorhandler(500)
def error_handling_500(error):
return {"Error": str(error)}, 500
@app.errorhandler(404)
def error_handling_404(error):
return {"Error": str(error), "description": text_404}, 404
@app.route("/api")
def my_microservice():
raise TypeError("This is a testing exception.")
if __name__ == "__main__":
app.run()
在我们的测试中,我们确保在test_raise()
中客户端得到一个适当的500
状态码,并带有结构化的 JSON 体。test_proper_404()
测试方法在非存在路径上执行相同的测试,并使用TestCase
类的异步版本及其设置和清理方法:
# test_quart_error.py
import unittest
import json
from quart_error import app as tested_app, text_404
class TestApp(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
# Create a client to interact with the app
self.app = tested_app.test_client()
async def test_raise(self):
# This won't raise a Python exception but return a 500
hello = await self.app.get("/api")
self.assertEqual(hello.status_code, 500)
async def test_proper_404(self):
# Call a non-existing endpoint
hello = await self.app.get("/dwdwqqwdwqd")
# It's not there
self.assertEqual(hello.status_code, 404)
# but we still get a nice JSON body
body = json.loads(str(await hello.get_data(), "utf8"))
self.assertEqual(hello.status_code, 404)
self.assertEqual(body["Error"], "404 Not Found: The requested URL was not found on the server. If you entered the URL manually please check your spelling and try again.")
self.assertEqual(body["description"], text_404)
if __name__ == "__main__":
unittest.main()
QuartClient
方法的替代方案是WebTest (docs.pylonsproject.org/projects/webtest/
),它提供了一些开箱即用的额外功能。这将在本章后面进行讨论。有关更多详细信息,请参阅以下链接:(webtest.pythonpaste.org
)
集成测试
单元测试和功能测试专注于测试你的服务代码,而不调用其他网络资源,因此你的应用程序中的其他微服务或第三方服务,如数据库,不需要可用。为了速度、隔离和简单起见,网络调用被模拟。
集成测试是没有任何模拟的函数测试,应该能够在你的应用程序的实际部署上运行。例如,如果你的服务与 Redis 和 RabbitMQ 交互,当运行集成测试时,它们将像正常一样被你的服务调用。好处是它避免了在模拟网络交互时遇到的问题。
只有在你在一个完全集成、真实场景中尝试你的应用程序时,你才能确保你的应用程序在生产执行上下文中工作。
但要注意的是,针对实际部署运行测试使得设置测试数据或清理测试期间服务内部产生的任何数据变得更加困难。修补应用程序行为以重现服务的响应也是一个困难的任务。
良好的配置管理软件可以显著帮助集成测试,因为拥有 Ansible playbook 或 Puppet 配置意味着部署所有这些组件就像运行一个命令一样简单。我们将在第十章部署在 AWS中进一步讨论这个问题,当我们讨论部署时。
集成测试也可以在你的服务开发或预发布部署上运行。由于许多开发者正在推送更改,这可能会导致对这个有限资源的竞争,但模拟真实环境运行所有集成测试可能更简单——且成本更低。
你可以使用任何你想要的工具来编写你的集成测试。在某些微服务上,一个带有curl
的 bash 脚本可能就足够了,而其他可能需要仔细的编排。
对于集成测试来说,最好用 Python 编写,并成为你项目测试集合的一部分。为此,一个使用 requests 调用你的微服务的 Python 脚本可以做到这一点。如果你为你的微服务提供了一个客户端库,这也是进行集成测试的好地方。
集成测试与功能测试的区别主要在于调用的是真实的服务。我们发现了与数据库、消息队列和其他服务所拥有的依赖项的真实交互的后果。如果我们能够编写可以在本地 Quart 应用程序或针对实际部署运行的功能测试会怎样?这可以通过 WebTest 实现,正如我们将在本章后面了解到的那样。
负载测试
负载测试的目标是了解你的服务在压力下的表现。了解它在高负载下开始失败的地方,将告诉你如何最好地规划未来的情况,以及分配应用程序当前的工作负载。你的服务的第一版可能已经足够快,以应对其所在的情况,但了解其局限性将帮助你确定你想要如何部署它,以及如果负载增加,其设计是否具有前瞻性。
负载测试的信息,连同从生产服务收集到的数据,将帮助你平衡服务的吞吐量与它能够合理同时响应的查询数量、响应所需的工作量、查询等待响应的时间以及服务的成本——这被称为容量管理。
一个好的负载测试还可以指出可以做出哪些改变以消除瓶颈,例如改变写入数据库的方式,这样它们就不再需要独占锁。
真正的问题在于程序员花费了太多时间在不恰当的地方和错误的时间担心效率;过早的优化是编程中所有邪恶(至少是大部分邪恶)的根源。
——唐纳德·克努特,《计算机程序设计艺术》
在我们甚至不知道关键路径是什么以及什么是最需要改进之前,花大量时间让每个微服务尽可能快,这是一个常见的错误。在做出改变之前进行测量,可以让我们确切地看到每个改变的益处,并帮助我们优先考虑时间和精力。
编写负载测试可以帮助你回答以下问题:
-
当我将服务部署到这台机器上时,一个实例可以服务多少用户?
-
当有 10、100 或 1000 个并发请求时,平均响应时间是多少?我能处理这么多的并发量吗?
-
当我的服务处于压力之下时,它是由于内存不足还是主要受 CPU 限制?它是在等待另一个服务吗?
-
我能否添加同一服务的其他实例并实现水平扩展?
-
如果我的微服务调用其他服务,我能使用连接器池,还是必须通过单个连接序列化所有交互?
-
我的这项服务能否一次性运行多天而不会降低性能?
-
在使用高峰之后,我的服务是否仍然运行正常?
根据你想要达到的负载类型,有许多工具可供选择,从简单的命令行工具到更重的分布式负载系统。对于执行不需要任何特定场景的简单负载测试,Salvo 是一个用 Python 编写的Apache Bench(AB)等效工具,可以用来对你的端点应用负载:github.com/tarekziade/salvo
。
在以下示例中,Salvo 模拟了 10 个并发连接,每个连接对/api/
端点的Quart
网络服务器进行 100 个连续请求:
$ salvo http://127.0.0.1:5000/api --concurrency 10 –-requests 100
-------- Server info --------
Server Software: hypercorn-h11
-------- Running 100 queries - concurrency 10 --------
[================================================================>.] 99%
-------- Results --------
Successful calls 1000
Total time 13.3234 s
Average 0.0133 s
Fastest 0.0038 s
Slowest 0.0225 s
Amplitude 0.0187 s
Standard deviation 0.002573
Requests Per Second 75.06
Requests Per Minute 4503.35
-------- Status codes --------
Code 200 1000 times.
Want to build a more powerful load test ? Try Molotov !
Bye!
这些数字没有太多意义,因为它们会根据部署方式和运行位置有很大的变化。例如,如果你的 Flask 应用程序在 nginx 后面运行,并有多个工作进程,那么负载将会分散,一些开销将由 nginx 处理,为了获得完整的视图,我们应该测试整个服务。
但仅此小测试就常常能早期捕捉到问题,尤其是在你的代码本身打开套接字连接时。如果微服务设计中存在问题,这些工具通过突出显示意外的响应或以开发者未预料到的方式使应用程序崩溃,可以更容易地检测到。
Salvo 基于Molotov (molotov.readthedocs.io/en/stable/
),设置起来需要更多的工作,但具有更多功能,例如允许使用查询集和预期响应进行交互式场景。在以下示例中,每个函数都是一个 Molotov 可能选择的可能场景,以运行对服务器的测试:
# molotov_example.py
# Run:
# molotov molotov_example.py --processes 10 --workers 200 --duration 60
import json
from molotov import scenario
@scenario(weight=40)
async def scenario_one(session):
async with session.get("http://localhost:5000/api") as resp:
res = await resp.json()
assert res["Hello"] == "World!"
assert resp.status == 200
@scenario(weight=60)
async def scenario_two(session):
async with session.get("http://localhost:5000/api") as resp:
assert resp.status == 200
这两种工具都会提供一些指标,但由于它们启动的机器上的网络和客户端 CPU 差异,这些指标并不非常准确。负载测试会压力测试运行测试的机器的资源,这会影响指标。
在执行负载测试时,服务器端指标可以让你更深入地了解你的应用程序。在 Quart 级别,你可以使用 Flask 扩展Flask Profiler (github.com/muatik/flask-profiler
),它收集每个请求所需的时间,并提供一个仪表板,让你浏览收集的时间,如图图 3.2所示。
图 3.2:Flask Profiler 允许跟踪请求所需的时间,并以图形格式显示它们
对于生产服务,最好使用像Prometheus (prometheus.io/
)、InfluxDB (www.influxdata.com/
)这样的工具,或者使用你云托管提供商内置的工具,例如 AWS CloudWatch。
端到端测试
端到端测试将检查整个系统从最终用户的角度来看是否按预期工作。测试需要表现得像真实客户端一样,并通过相同的用户界面(UI)调用系统。
根据你创建的应用程序类型,一个简单的 HTTP 客户端可能不足以模拟真实用户。例如,如果用户交互的系统可见部分是一个在客户端渲染 HTML 页面的 Web 应用程序,你需要使用像Selenium (www.selenium.dev/
)这样的工具。它将自动化你的浏览器,以确保客户端请求每个 CSS 和 JavaScript 文件,然后相应地渲染每个页面。
现在 JavaScript 框架在客户端做了很多工作来生成页面。其中一些已经完全移除了模板的服务器端渲染,并通过浏览器 API 通过操作文档对象模型(DOM)从服务器获取数据来生成 HTML 页面。在这种情况下,对服务器的调用包括获取渲染给定 URL 所需的全部静态 JavaScript 文件,以及数据。
编写端到端测试超出了本书的范围,但你可以参考《Selenium 测试工具食谱》来了解更多关于这方面的内容。
以下要点总结了我们在本节中学到的内容:
-
功能测试是编写 Web 服务时最重要的测试,在 Quart 中通过在测试中实例化应用并与它交互,很容易执行。
-
单元测试是一个很好的补充,但应避免误用模拟。
-
集成测试类似于功能测试,但运行在真实的部署上。
-
负载测试有助于了解你的微服务瓶颈并规划下一步的开发。
-
端到端测试需要使用客户端通常会使用的相同 UI。
了解何时需要编写集成、负载或端到端测试取决于你的项目管理方式——但每次你更改内容时,都应该编写单元和功能测试。理想情况下,你代码中的每个更改都应该包括一个新的测试或修改现有的测试。由于标准库中包含优秀的unittest
包,单元测试可以使用标准 Python 编写——我们稍后会看到pytest (docs.pytest.org
)库如何在它之上添加额外的功能。
对于功能测试,我们将在下一节中探讨 pytest。
使用 pytest 和 tox
到目前为止,我们编写的所有测试都使用了unittest
类和unittest.main()
来运行。随着你的项目增长,你将拥有越来越多的测试模块。
为了自动发现和运行项目中的所有测试,unittest
包在 Python 3.2 中引入了测试发现功能,它根据一些选项查找并运行测试。这个功能在像Nose (nose.readthedocs.io
)和 pytest 这样的项目中已经存在了一段时间,这也是标准库中unittest
包测试发现功能的灵感来源。
使用哪个运行器是一个口味问题,只要您坚持在 TestCase
类中编写测试,您的测试将与所有这些运行器兼容。
话虽如此,pytest 项目在 Python 社区中非常受欢迎,并且由于它提供了扩展,人们已经开始围绕它编写有用的工具。它的运行器也非常高效,因为它在后台发现测试的同时开始运行测试,这使得它比其他运行器稍微快一点。它的控制台输出也非常漂亮和明亮。要在您的项目中使用它,您只需使用 pip 安装 pytest
包,并使用提供的 pytest 命令行。在以下示例中,pytest
命令运行所有以 test_
开头的模块:
$ pytest test_*
======================= test session starts ========================
platform darwin -- Python 3.8.5, pytest-6.2.1, py-1.10.0, pluggy-0.13.1
rootdir: /Users/simon/github/PythonMicroservices/CodeSamples/Chapter3
plugins: requests-mock-1.8.0
collected 9 items
test_quart_basic.py . [ 11%]
test_quart_error.py .. [ 33%]
test_requests_example2.py .. [ 55%]
test_requests_example2_full.py .... [100%]
======================= 9 passed in 0.20s ==========================
pytest
包附带了许多扩展,这些扩展在 plugincompat.herokuapp.com/
上列出。
本书中的代码示例已使用 Black
格式化,它也作为 pytest 扩展提供。其他有用的扩展包括 pytest-cov
(https://github.com/pytest-dev/pytest-cov
) 和 pytest-flake8
(github.com/tholo/pytest-flake8
)。
第一个扩展使用覆盖率工具 (coverage.readthedocs.io
) 来显示您项目的测试覆盖率,第二个扩展运行 Flake8
(gitlab.com/pycqa/flake8
) 检查器以确保您的代码遵循 PEP8
风格,并避免各种其他问题。以下是一个包含一些故意风格问题的调用示例:
$ pytest --flake8 --black
======================= test session starts =======================
platform darwin -- Python 3.8.5, pytest-6.2.1, py-1.10.0, pluggy-0.13.1
rootdir: /Users/simon/github/PythonMicroservices/CodeSamples/Chapter3
plugins: flake8-1.0.7, requests-mock-1.8.0, black-0.3.12, cov-2.10.1
collected 29 items
molotov_example1.py ss [ 6%]
quart_basic.py ss [ 13%]
quart_error.py ss [ 20%]
quart_profiled.py ss [ 27%]
requests_example1.py ss [ 34%]
requests_example2.py FF [ 41%]
test_quart_basic.py ss. [ 51%]
test_quart_error.py ss.. [ 65%]
test_requests_example2.py ss.. [ 79%]
test_requests_example2_full.py ss.... [100%]
======================= FAILURES =======================
_____________________ Black format check ________________________
--- /Users/simon/github/PythonMicroservices/CodeSamples/Chapter3/requests_example2.py 2020-12-29 11:56:56.653870 +0000
+++ /Users/simon/github/PythonMicroservices/CodeSamples/Chapter3/requests_example2.py 2020-12-29 11:56:58.595337 +0000
@@ -24,11 +24,11 @@
def render_heroes_over(age=0):
url = "https://mdn.github.io/learning-area/javascript/oojs/json/superheroes.json"
json_body = query_url(url)
relevant_heroes = get_hero_names(
- json_body, filter =lambda hero: hero.get("age", 0) > age
+ json_body, filter=lambda hero: hero.get("age", 0) > age
)
return render_hero_message(relevant_heroes, age)
if __name__ == "__main__":
__________________________ FLAKE8-check __________________________
/Users/simon/github/PythonMicroservices/CodeSamples/Chapter3/requests_example2.py:26:80: E501 line too long (85 > 79 characters)
/Users/simon/github/PythonMicroservices/CodeSamples/Chapter3/requests_example2.py:29:26: E251 unexpected spaces around keyword / parameter equals
----------------------- Captured log call --------------------------
WARNING flake8.options.manager:manager.py:207 option --max-complexity: please update from optparse string `type=` to argparse callable `type=` -- this will be an error in the future
WARNING flake8.checker:checker.py:119 The multiprocessing module is not available. Ignoring --jobs arguments.
===================== short test summary info =======================
FAILED requests_example2.py::BLACK
FAILED requests_example2.py::FLAKE8
============= 2 failed, 9 passed, 18 skipped in 0.51s ===============
另一个可以与 pytest 一起使用的有用工具是 tox (tox.readthedocs.io
)。如果您的项目需要在多个 Python 版本或多个不同的环境中运行,tox 可以自动化创建这些单独的环境来运行您的测试。
一旦您安装了 tox(使用 pip install tox
命令),它需要在您项目的根目录中一个名为 tox.ini
的配置文件。Tox 假设您的项目是一个 Python 包,因此在 tox.ini
文件旁边,根目录中还有一个 setup.py
文件,但这只是唯一的要求。tox.ini
文件包含了运行测试的命令行以及它应该针对的 Python 版本:
[tox]
envlist = py38,py39
[testenv]
deps = pytest
pytest-cov
pytest-flake8
commands = pytest --cov=quart_basic --flake8 test_*
当通过调用 tox
命令执行 tox 时,它将为每个 Python 版本创建一个单独的环境,在该环境中部署您的包及其依赖项,并使用 pytest
命令在其中运行测试。
您可以使用 tox -e
运行单个环境,这在您想使用自动化工具并行运行测试时非常方便。例如,tox -e py38
将仅在 Python 3.8 下运行 pytest。
即使你支持单个 Python 版本,使用 tox 也能确保你的项目可以在当前的 Python 环境中安装,并且你已经正确描述了所有依赖项。我们将在后续章节的工作示例中使用 tox。
开发者文档
到目前为止,我们已经探讨了微服务可能具有的不同类型的测试,并且提到文档应该随着代码的演变而发展。这里我们谈论的是开发者文档。这包括开发者应该了解你微服务项目的所有内容,最值得注意的是:
-
它是如何设计的。
-
如何安装它。
-
如何运行测试。
-
公开的 API 有哪些,以及什么数据进出?
人们会查阅文档来获取问题的答案。这些问题包括谁(Who)、什么(What)、哪里(Where)、何时(When)、为什么(Why)和如何(How),例如:
-
谁应该使用这款软件?
-
这款软件做什么?
-
它可以部署在哪里?
-
在什么情况下使用它是有帮助的?
-
为什么它会以这种方式工作?
-
我该如何安装和配置它?
良好的文档描述了为什么做出某些决策,以便人们——包括你自己——在返回代码时可以决定过去的决策是否值得遵循,或者情况是否已经发生变化,需要重新审视决策。阅读文档的开发者应该对“为什么”问题的答案有一个清晰的认识,以及关于更难回答的“如何”问题的信息。
很少有情况需要在一篇单独的文档中提及每一行代码或函数。相反,源代码中应该包含注释,帮助开发者导航和理解他们正在阅读的内容。函数应该有文档字符串注释来解释其目的、参数和返回值,除非它们确实非常简短且明显。当代码发生变化时,这些注释更容易保持更新,并且可以避免将文档和实现紧密耦合——这是一个既适用于设计软件本身也适用于这里的工程设计原则。
文档工具
Sphinx工具([www.sphinx-doc.org/
](http://www.sphinx-doc.org/)),由Georg Brandl开发用于记录 Python 本身,已成为 Python 社区的行业标准。Sphinx 通过将内容与布局分离来将文档视为源代码。使用 Sphinx 的常规方法是,在项目中有一个名为docs
的目录,其中包含文档的未处理内容,然后使用 Sphinx 的命令行工具生成 HTML 文件。
使用 Sphinx 生成 HTML 输出可以创建一个出色的静态网站,可以发布到网络上;该工具添加索引页面、一个基于 JavaScript 的小型搜索引擎和导航功能。
文档的内容必须使用 reStructuredText (reST) 编写,这是 Python 社区中的标准标记语言。一个 reST 文件是一个简单的文本文件,具有非侵入性的语法来标记章节标题、链接、文本样式等。Sphinx 在此文档中添加了一些扩展,并总结了 reST 的用法:www.sphinx-doc.org/en/master/usage/restructuredtext/basics.html
Markdown (daringfireball.net/projects/markdown/
) 是另一种在开源社区中广泛使用的标记语言,如果你在 GitHub 上更新过 README 文件,你可能已经遇到过它。Markdown 和 reST 足够相似,以至于在两者之间切换应该是直截了当的。这非常有用,因为 Sphinx 对 Markdown 的支持有限。
当你使用 sphinx-quickstart
开始一个 Sphinx 项目时,它将生成一个包含 index.rst
文件的源树,这是你文档的着陆页。从那里,调用 sphinx-build
将创建你的文档。例如,如果你想生成 HTML 文档,你可以在 tox.ini
文件中添加一个 docs
环境,并让工具为你构建文档,如下所示:
[tox]
envlist = py39,docs
...
[testenv:docs]
basepython=python
deps =
-rrequirements.txt
sphinx
commands=
sphinx-build -W -b html docs/source docs/build
运行 tox -e docs
将生成你的文档。
在 Sphinx 中展示代码示例可以通过粘贴你的代码到一个以 ::
标记或代码块指令为前缀的文本块中来实现。在 HTML 中,Sphinx 将使用 Pygments (pygments.org/
) 语法高亮器来渲染它:
Quart Application
=============
下面是官方文档中 **Quart**
应用程序的第一个示例:
.. code-block:: python
from quart import Quart
app = Quart(__name__)
@app.route("/")
async def hello():
return "Hello World!"
if __name__ == "__main__":
app.run()
这段代码是一个完全工作的应用!
extension: https://www.sphinx-doc.org/en/master/usage/extensions/autodoc.html
这将抓取文档字符串以将其注入到文档中,这也是 Python 记录其标准库的方式:docs.python.org/3/library/index.html
在以下示例中,autofunction
指令将捕获位于 myservice/views/home.py
模块中的索引函数的文档字符串:
APIS
====
**myservice** includes one view that's linked to the root path:
.. autofunction :: myservice.views.home.index
当在 HTML 中渲染时,页面将显示如图 图 3.3 所示。
图 3.3:上述代码在 HTML 中的渲染效果
另一个选项是使用 literalinclude
指令,这将允许你直接包含源代码。当文件是一个 Python 模块时,它可以包含在测试套件中以确保其工作。以下是一个使用 Sphinx 的项目文档的完整示例:
Myservice
=========
**myservice** is a simple JSON Quart application.
The application is created with :func:`create_app`:
.. literalinclude:: ../../myservice/app.py
The :file:`settings.ini` file which is passed to :func:`create_app`
contains options for running the Quart app, like the DEBUG flag:
.. literalinclude:: ../../myservice/settings.ini
:language: ini
Blueprint are imported from :mod:`myservice.views` and one
Blueprint and view example was provided in :file:`myservice/views/home.py`:
.. literalinclude:: ../../myservice/views/home.py
:name: home.py
:emphasize-lines: 13
视图可以返回简单的数据结构,就像我们迄今为止在示例代码中所使用的那样。在这种情况下,它们将被转换为 JSON 响应。当在 HTML 中渲染时,页面将显示如图 图 3.4 所示。
图 3.4:使用 Sphinx 渲染的文档
当然,使用Autodoc和literalinclude并不能修复你的流程或设计文档——维护适当的文档是困难的,开发者通常将代码的更改优先于文档的更改。
这是一个容易理解的位置,因为代码满足了组织的需要,但如果它没有完成,将会有后果。任何可以自动化这部分文档工作的方法都是极好的。
在 第四章,设计 Jeeves 中,我们将看到如何使用 Sphinx 通过 OpenAPI 和支持它的 Sphinx 扩展来记录微服务 HTTP API。
以下要点总结了本节内容:
-
Sphinx 是一个强大的工具,可以用来记录你的项目。
-
将你的文档视为源代码将有助于其维护。
-
Tox 可以在发生变化时重新构建文档。
-
如果你的文档指向你的代码,维护起来会更简单。
版本控制
我们中的许多人都在一些项目中工作过,我们想要保留一些东西的副本以备不时之需。这可能是学校的作业,工作项目文档,或者如果你特别有组织,可能是家里某事的规划笔记。通常,当我们做了很多修改后,我们会得到不同名称的文件副本,这些名称在当时可能是有意义的,但很快就会变得难以控制:
myplan.txt
myplan.original.txt
myplan.before_feedback.txt
myplan.final.reviewed.final2.suggestions.txt
当多个人在一个项目上工作时,这种情况会变得更加混乱。这正是版本控制真正发挥作用的地方。使用版本控制系统(VCS)意味着每个项目都作为一个包含所有文件的仓库来维护,你提交的每个更改都将永远保留,除非你非常努力地将其从仓库的历史中删除。不小心删除了一个重要的段落或一段有用的 Python 代码?它将在版本控制历史中,因此可以轻松恢复。有不止一个人在处理某事?这些工具使得跟踪、比较和合并更改变得容易。
现在有许多版本控制系统,如 Git
、Mercurial
和 Subversion
。本书中的示例都将与 Git 一起工作,因为我们正在利用 GitHub 上可用的功能,GitHub 是一个流行的版本控制托管服务——并且为了我们的需求,我们将使用免费层账户。如果你想尝试代码示例,注册一个 github.com/
的账户是个好主意。
许多其他服务也存在,它们执行类似的功能,具有略微不同的功能集或工作流程。例如,GitLab (gitlab.com
) 和 Bitbucket (bitbucket.org/
) 都是托管 Git 仓库的优秀服务。其他版本控制软件,如上面提到的 Mercurial 和 Subversion,也是流行的选择,你将在世界各地的组织中看到它们(以及其他工具)的使用。
主机服务通常除了核心版本控制之外,还提供一系列功能,例如问题跟踪、项目管理,以及我们接下来要讨论的主题:CI/CD 系统。
持续集成和持续部署
Tox 可以为你的项目自动化许多测试步骤:在多种不同的 Python 版本上运行测试;验证覆盖率代码风格;构建文档等。这仍然是你需要手动运行的事情,并且还需要在多个 Python 版本之间维护——使用像pyenv(github.com/pyenv/pyenv
)这样的工具可以使这项任务更容易,尽管仍然需要一些工作来保持组织。
持续集成(CI)系统通过监听版本控制系统的变化,在正确的时间运行你决定的命令,通常还会为你处理不同的环境。例如,如果你需要修改托管在 GitHub 上的开源项目,你将能够克隆该仓库。然后你会在自己的电脑上拥有它的完整副本,包括所有历史记录。然后你进行所需的更改,将其提交到自己的副本历史中,并在 GitHub 上发起一个拉取请求(PR),本质上是在请求拥有仓库原始副本的人拉取你的更改;允许随机人员无控制地访问并不是一个好主意!
你知道你编写的代码是好的,但项目所有者如何知道呢?他们可能每周或每天都会收到数十个这样的请求。如果他们连接一个 CI 系统,他们可以设置系统,以便每次有人提交 PR 或更改合并到他们的副本时,他们想要的测试都会自动执行。他们还可以通过让系统知道要运行哪些命令来自动构建和发布软件包、更新一些已发布的文档或将代码复制到运行的服务器上并设置软件的新版本。这被称为持续部署(CD)。
我们之前提到了集成测试,现在我们正在讨论 CI;重要的是要记住,CI/CD 系统会运行你告诉它们的任何命令。它们不一定是集成测试——根本不需要任何集成——但它们是测试你代码的非常有用的服务,无需担心忘记测试或测试许多不同的版本。
GitHub Actions
许多 CI 系统与流行的版本控制服务集成,因此有许多选项可以运行测试,例如CircleCI
和Jenkins
。对于更复杂的需求,也有像Taskcluster(taskcluster.net/
)这样的选项,它用于构建 Firefox 网络浏览器,需要在半个平台上构建,并运行数万个测试。然而,GitHub 自带内置的服务,因此为了保持清晰,我们将在这本书的示例中使用 GitHub Actions
。
大多数服务运行方式相似,因此了解其工作原理是值得的。一旦 CI 系统连接到你的项目,它将在你的仓库中查找一些配置。GitHub Actions 会在名为github/workflows
的目录中查找文件,CircleCI 会查找名为.circleci/config.yml
的文件,等等。让我们通过 GitHub Actions 的示例来了解一下:
# .github/workflows/python.yml
---
name: Python package
on: [push]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
python: [3.7, 3.8, 3.9]
steps:
- uses: actions/checkout@v2
- name: Setup Python ${{ matrix.python }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python }}
- name: Install Tox and any other packages
run: pip install tox
- name: Run Tox
# Run tox using the version of Python in `PATH`
run: tox -e py
我们可以看到,由于on:
[push]
,这将每次仓库接收到新更改时运行——这包括拉取请求,因此我们可以看到是否有人想要给我们新代码时测试是否通过。
工作流程已被告知运行三次,使用策略中列出的 Python 版本,并且对于每个 Python 版本,它将运行显示的步骤:检出推送的副本;设置 Python;安装 tox;运行 tox。
可以在 CI/CD 管道中连接许多不同的命令或服务,以便我们检查代码质量、格式、检查过时的依赖项、构建软件包以及部署我们的服务。我们将以文档控制和代码测试覆盖率为例进行探讨。
文档
存档文档有许多优秀的选项。GitHub 甚至包含一个内置的文档服务,称为 GitHub Pages
,尽管为了与其他 Python 项目保持一致,并提供使用外部服务的示例,我们将使用ReadTheDocs(RTD)(docs.readthedocs.io
)作为我们的示例微服务。无论你选择哪种选项,都可以设置 GitHub action 或其他 CI 集成,以确保在每次更改时更新文档。
使用这种方法,你的文档将存储在 ReStructuredText(.rst
)或 Markdown(.md
)文件中,这些文件非常容易创建,并且是跨多个不同平台和不同可访问性需求的有用格式。Pages 和 RTD 将从这些文档创建适合在网络上显示的 HTML,你可以应用主题和模板,使生成的网页看起来符合你的要求。
RTD 自带对软件不同版本的简单支持,以便读者可以在版本 1 和 2 的文档之间切换视图。当你维护不同版本时,这非常有用,比如在新版本发布后不久,或者迁移需要一些用户花费很长时间。版本功能扫描 git 标签,并允许你按标签构建和发布文档,并决定哪个是默认的。
Coveralls
一旦项目有了测试,一个自然的问题就是,“这段代码有多少被测试了?”这个问题的答案可以通过另一个流行的服务Coveralls(coveralls.io/
)来提供。这个服务以友好的 Web UI 显示你的测试代码覆盖率,并且可以通过其 CI 配置连接到你的仓库。
图 3.5:关于测试覆盖率的 Coveralls 报告
每次你更改项目并且 GitHub Actions 触发构建时,它反过来会导致 Coveralls 显示关于覆盖率及其随时间演变的出色总结,类似于图 3.5中所示。
徽章
一旦你开始在项目中添加服务,在项目的 README 中使用徽章是一个好习惯,这样社区可以立即看到每个服务的状态,并通过链接访问服务。例如,在你的仓库中添加这个README.rst
文件:
microservice-skeleton
=====================
This project is a template for building microservices with Quart.
.. image:: https://coveralls.io/repos/github/PythonMicroservices/microservice-skeleton/badge.svg?branch=main
:target: https://coveralls.io/github/PythonMicroservices/microservice-skeleton?branch=main
.. image:: https://github.com/PythonMicroservices/microservice-skeleton/workflows/Python%20Testing/badge.svg
.. image:: https://readthedocs.org/projects/microservice/badge/?version=latest
:target: https://microservice.readthedocs.io
前面的文件将显示在 GitHub 上你的项目首页的图 3.6中,展示了三个不同的状态徽章。
图 3.6:GitHub 项目状态徽章
摘要
在本章中,我们介绍了可以为你的项目编写的不同类型的测试。功能测试是你将更频繁编写的测试,WebTest
是用于此目的的绝佳工具。要运行测试,pytest 结合 Tox 将使你的生活更加轻松。
我们还介绍了一些关于编写良好文档和确保测试以自动化方式运行的技巧。最后,但同样重要的是,如果你在 GitHub 上托管你的项目,你可以免费设置整个 CI 系统,这要归功于 GitHub Actions。从那里,你可以连接到许多免费服务来补充可用的工具,如Coveralls
。你还可以自动在 ReadTheDocs 上构建和发布你的文档。
如果你想看看所有这些是如何结合在一起的,GitHub 上发布的微服务项目使用 GitHub Actions
、ReadTheDocs
和coveralls.io
来实现这一点:github.com/PythonMicroservices/microservice-skeleton/
现在我们已经介绍了如何持续开发、测试和记录 Quart 项目,我们可以看看如何设计一个基于微服务的完整项目。下一章将介绍此类应用程序的设计。
第四章:设计 Jeeves
在第一章,理解微服务中,我们提到构建基于微服务的应用的自然方式是从实现所有功能的单体版本开始,然后将其拆分为最有意义的微服务。当你设计软件时,你需要担心的事情很多,包括信息在系统中的流动、满足需求以及弄清楚所有部分如何配合。当设计遇到现实时,你开始对应该拥有哪些类型的组件有一个良好的认识,随着经验的积累,你将更容易在早期就发现潜在的微服务。
在本章中,我们将通过构建单体应用并实现所需功能来经历这个过程。我们将涵盖每个组件的工作原理以及为什么它存在,以及信息如何在系统中流动。
本章分为两个主要部分:
-
我们的应用及其用户故事的介绍
-
如何将 Jeeves 构建为一个单体应用
当然,在现实中,拆分过程是在单体应用设计经过一段时间成熟后逐渐发生的。但为了本书的目的,我们将假设应用的第一版已经使用了一段时间,并为我们提供了关于如何正确拆分的见解,这要归功于我们的时间机器。
Jeeves 机器人
Jeeves是本书创建的一个示例应用。不要在苹果或谷歌应用商店中寻找它,因为它尚未发布或部署给真实用户。
然而,应用确实可以工作,你可以在 GitHub 的 PythonMicroservices 组织中研究其不同的组件:github.com/PythonMicroservices/
。
我们将连接到 Slack,这是一个流行的通信平台,主要用于在频道中发送文本消息,类似于较老的 IRC 服务。Jeeves 将是我们的人工智能助手——这个名字来源于 P. G. Wodehouse 的故事——用于其他软件机器人以及至少一个搜索引擎。我们使用这个名字是因为它的熟悉度,而不是因为它与其他人的工作有任何联系。这里展示的 Jeeves 为 Slack 工作空间的用户提供了一种交互式服务,并且可以轻松地适应其他聊天环境。它还提供了一个网页视图来配置基本设置。
一旦 Jeeves 连接到 Slack 工作空间,用户就可以向机器人发送消息并接收回复。用户还可以访问网页并注册他们可能需要连接的任何第三方服务,这要归功于标准的 OAuth2 机制。更多信息请参阅oauth.net/2/
。
我们将以Strava (www.strava.com
)作为一个第三方网站的例子,尽管这同样可以容易地是 GitHub、谷歌服务、Atlassian 的 JIRA 或其他一些有用的服务。
OAuth2 标准基于授权第三方应用程序使用具有用户服务唯一访问令牌调用服务的想法。令牌由服务生成,通常具有有限的调用范围。
让我们通过它们的使用故事深入了解功能。
用户故事
首先,我们的应用程序应该做什么?描述我们的目标的一个好方法是通过覆盖不同场景中期望的行为。如果你之前参与过敏捷开发,这将以“用户故事”的形式熟悉。用户故事是对用户可以与应用程序进行的所有交互的非常简单的描述,通常是项目开始时编写的第一份高级文档,因为一些故事出现在开始工作的理由或提案中。
试图一开始就填充大量细节也可能使生活变得更困难;从高级故事开始,并在回顾时添加细节。有时,如果某个用户故事不可行,它可能会被丢弃——这非常取决于工作的进展以及每个想法的可行性。用户故事还有助于检测何时值得将一个功能拆分为其微服务:一个独立存在的用户故事可能是一个很好的候选者。
对于 Jeeves,我们可以从以下这个小集合开始:
-
作为 Slack 工作空间用户,我可以向机器人发送消息并获取关于天气的更新,而无需离开 Slack
-
作为 Slack 工作空间用户,我期望机器人能记住我告诉它的关于我的事实,例如我的位置
-
作为 Slack 工作空间用户,我可以访问机器人的控制面板并验证第三方服务,例如 GitHub 和 Strava
-
作为机器人的管理员,我可以获取有关机器人使用情况的统计数据
-
作为机器人的管理员,我可以禁用或更改各种功能的配置
-
作为用户,我可以在机器人所知的第三方网站上创建账户,然后使用机器人执行那里的任务
从这个用户故事集合中已经出现了一些组件。不分先后,如下所示:
-
应用程序需要存储它接收到的某些类型消息的内容。这些内容可能针对特定的 Slack 身份进行存储。
-
用户应该能够导航到第三方网站认证的 Web 界面。
-
应用程序将使用密码或提供的令牌的 URL 对 Web 用户进行身份验证。
-
应用程序应该能够执行周期性的计划任务,如果条件为真,则提醒用户,例如恶劣天气。
这些描述足以让我们开始。下一节将描述应用程序的设计和编码方式。
单一架构
本节展示了 Jeeves 单体版本源代码的摘录。如果您想详细了解,整个应用程序可以在github.com/PacktPublishing/Python-Microservices-Development-2nd-Edition/tree/main/monolith
找到。
首先要考虑的是从 Slack 中检索数据到我们的应用程序。为此将有一个单独的端点,因为 Slack 将所有事件发送到应用程序开发者配置的 URL。稍后,我们还可以添加其他端点来处理其他环境,例如 IRC、Microsoft Teams 或 Discord。
我们还需要一个小的界面,以便人们可以在 Slack 之外调整设置,因为使用网页控制第三方身份验证令牌和更新这些工具的权限要容易得多。我们还需要一个小型数据库来存储所有这些信息,以及我们希望我们的微服务拥有的其他设置。
最后,我们需要一些代码来实际执行我们的机器人代表发送消息的人应该执行的操作。
当构建应用程序时,经常提到的一个设计模式是 模型-视图-控制器(MVC)模式,它将代码分为三个部分:
-
模型:管理数据
-
视图:显示特定上下文(网页视图、PDF 视图等)的模型
-
控制器:通过操作模型来改变其状态
SQLAlchemy
是一个库,可以帮助我们处理模型部分,允许我们在 Python 源代码中指定表、关系以及读取和写入数据的包装器。在微服务中,视图和控制器的区别可能有些模糊,因为所谓的视图是一个接收请求并发送响应的函数——这个函数既可以显示也可以操作数据,使其既能作为视图也能作为控制器。
Django 项目使用 模型-视图-模板(MVT)来描述这种模式,其中视图是 Python 可调用函数,模板是模板引擎,或者负责以特定格式生成响应的任何东西。Quart
使用 Jinja2 进行各种有用的模板渲染——最常见的是生成 HTML,并使用 render_template()
函数从变量中获取值来填充内容。我们将使用这种方法来展示向人类展示数据的视图;对于返回 JSON 的 API 视图,我们将使用 json.dumps()
来生成响应。在任何情况下,设计我们应用程序的第一步是定义模型。
模型
在基于 SQLAlchemy 的 Quart
应用程序中,模型通过类来描述,这些类代表了数据库模式。对于 Jeeves,数据库表包括:
-
用户:包含有关每个用户的信息,包括他们的凭证
-
服务:这是一个列表,列出了机器人可以提供的服务以及它们是否处于活动状态
-
日志:机器人活动日志
使用SQLAlchemy (www.sqlalchemy.org/
)库,每个表都作为模块提供的基类的子类创建,这使我们能够避免重复工作,并使我们的代码保持干净,专注于我们想要处理的数据。SQLAlchemy 具有异步接口,可以在访问数据库时保持异步应用程序的性能优势。要使用这些功能,我们必须安装sqlalchemy
和aiosqlite
。完整的示例可以在代码样本的 GitHub 存储库中找到,作为sqlachemy-async.py
文件:
# sqlalchemy-async.py
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import declarative_base, sessionmaker
from sqlalchemy import Column, Integer, String, Boolean, JSON
from sqlalchemy.orm import Session
from sqlalchemy.future import select
from sqlalchemy import update
# Initialize SQLAlchemy with a test database
DATABASE_URL = "sqlite+aiosqlite:///./test.db"
engine = create_async_engine(DATABASE_URL, future=True, echo=True)
async_session = sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
Base = declarative_base()
# Data Model
class User(Base):
__tablename__ = "user"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String)
email = Column(String)
slack_id = Column(String)
password = Column(String)
config = Column(JSON)
is_active = Column(Boolean, default=True)
is_admin = Column(Boolean, default=False)
def json(self):
return {"id": self.id, "email": self.email, "config": self.config}
User
表主要存储一些 Unicode 字符串,但值得注意的是两个Boolean
值,它们保证了我们不需要解释另一个系统存储True
和False
的方式。还有一个 JSON 列用于存储整个数据结构——这是一个在越来越多的后端数据库中可用的功能,包括 PostgreSQL 和 SQLite。
当在Quart
应用中使用时,SQLAlchemy 允许我们编写一个接口来避免直接编写 SQL;相反,我们可以调用查询和过滤数据的函数。我们可以更进一步,创建一个数据访问层(DAL),为我们处理所有的数据库会话管理。在下面的代码中,我们编写了一个可以作为上下文管理器使用的访问层,同时提供了创建和查找用户的方法。create_user
方法只是简单地使用我们之前定义的模型来创建一个新的 Python 对象——不包含所有字段,以使示例更清晰——然后将它添加到数据库会话中,确保数据在返回数据库写入的值之前已经写入。
建立在这一点上,我们可以使用get_all_users
方法返回使用User
模型存储的所有记录,使用select()
来检索它们,并使用get_user
返回单个记录,同时使用where
方法过滤结果,只显示与提供的参数匹配的记录:
class UserDAL:
def __init__(self, db_session):
self.db_session = db_session
async def create_user(self, name, email, slack_id):
new_user = User(name=name, email=email, slack_id=slack_id)
self.db_session.add(new_user)
await self.db_session.flush()
return new_user.json()
async def get_all_users(self):
query_result = await self.db_session.execute(select(User).order_by(User.id))
return {"users": [user.json() for user in query_result.scalars().all()]}
async def get_user(self, user_id):
query = select(User).where(User.id == user_id)
query_result = await self.db_session.execute(query)
user = query_result.one()
return user[0].json()
在设置了 DAL 之后,我们可以使用 Python 自带的contextlib
提供的功能来创建一个异步上下文管理器:
@asynccontextmanager
async def user_dal():
async with async_session() as session:
async with session.begin():
yield UserDAL(session)
所有这些设置起来可能很多,但一旦完成,它就允许我们仅通过上下文管理器来控制数据库会话,就可以访问User
模型后面存储的任何数据。我们将在我们的视图中使用所有这些代码。
查看和模板
当接收到请求时,通过 URL 映射调用视图,我们可以使用上面创建的上下文管理器来查询和更新数据库。以下Quart
视图将允许我们在查询/users
端点时查看数据库中的所有用户:
@app.get("/users")
async def get_all_users():
async with user_dal() as ud:
return await ud.get_all_users()
当创建 user_dal
上下文时,我们可以访问其中的所有方法,因此我们可以轻松调用 get_all_users
方法并将值返回给客户端。让我们将上述所有内容组合成一个示例应用程序,并添加一些缺失的字段:
# sqlalchemy-async.py
from contextlib import asynccontextmanager
from quart import Quart
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import declarative_base, sessionmaker
from sqlalchemy import Column, Integer, String, Boolean, JSON
from sqlalchemy.orm import Session
from sqlalchemy.future import select
from sqlalchemy import update
# Initialize SQLAlchemy with a test database
DATABASE_URL = "sqlite+aiosqlite:///./test.db"
engine = create_async_engine(DATABASE_URL, future=True, echo=True)
async_session = sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
Base = declarative_base()
# Data Model
class User(Base):
__tablename__ = "user"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String)
email = Column(String)
slack_id = Column(String)
password = Column(String)
config = Column(JSON)
is_active = Column(Boolean, default=True)
is_admin = Column(Boolean, default=False)
def json(self):
return {
"id": self.id,
"email": self.email,
"slack_id": self.slack_id,
"config": self.config,
"is_active": self.is_active,
"is_admin": self.is_admin,
}
# Data Access Layer
class UserDAL:
def __init__(self, db_session):
self.db_session = db_session
async def create_user(
self,
name,
email,
slack_id,
password=None,
config=None,
is_active=True,
is_admin=False,
):
new_user = User(
name=name,
email=email,
slack_id=slack_id,
password=password,
config=config,
is_active=is_active,
is_admin=is_admin,
)
self.db_session.add(new_user)
await self.db_session.flush()
return new_user.json()
async def get_all_users(self):
query_result = await self.db_session.execute(select(User).order_by(User.id))
return [user.json() for user in query_result.scalars().all()]
async def get_user(self, user_id):
query = select(User).where(User.id == user_id)
query_result = await self.db_session.execute(query)
user = query_result.one()
return user[0].json()
app = Quart(__name__)
@app.before_serving
async def startup():
# create db tables
async with engine.begin() as conn:
# This resets the database – remove for a real project!
await conn.run_sync(Base.metadata.drop_all)
await conn.run_sync(Base.metadata.create_all)
async with user_dal() as bd:
await bd.create_user("name", "email", "slack_id")
@asynccontextmanager
async def user_dal():
async with async_session() as session:
async with session.begin():
yield UserDAL(session)
@app.get("/users/<int:user_id>")
async def get_user(user_id):
async with user_dal() as ud:
return await ud.get_user(user_id)
@app.get("/users")
async def get_all_users():
async with user_dal() as ud:
return await ud.get_all_users()
if __name__ == "__main__":
app.run()
人类可读的视图
如果我们想要以易于阅读的格式呈现这些信息,我们可以使用 Jinja2 模板并将查询结果传递以填写详细信息:
@app.get("/users/page")
async def get_all_users_templated():
async with user_dal() as ud:
users = await ud.get_all_users()
return await render_template("users.html", users=users)
如果没有配置来告诉它否则,Jinja 会在其 Python 应用程序的 templates/
子目录中查找模板,这对于小型应用程序来说通常足够了。
我们的 users.html
模板中包含了一些 HTML 代码,但也包含了一些由花括号包围的指令。有一个 for
循环允许我们遍历传递给模板的数据,我们可以看到发送给 Jinja 的指令被 {%
和 %}
包围。模板中的另一个常见指令是 {% if … %}
,它只会在条件为真时包含一段文本。在 for
循环中,我们看到了一些变量扩展在 {{
和 }}
内,这表明 Jinja 应该寻找具有该名称的变量。可以对变量应用过滤器,这在格式化日期时特别有用。以下是一个简单的模板,它遍历所有提供的用户并显示他们的电子邮件地址和 Slack ID:
<html>
<body>
<h1>User List</h1>
<ul>
{% for user in users %}
<li>
{{ user.email }} {{ user.slack_id }}
</li>
{% endfor %}
</ul>
</body>
</html>
通过网络编辑数据时,可以使用 WTForms 为每个模型生成表单。WTForms 是一个库,它使用 Python 定义生成 HTML 表单,负责从传入的请求中提取数据,并在更新模型之前对其进行验证。了解更多信息请访问 wtforms.readthedocs.io
。
Flask-WTF 项目为 Quart
包装了 WTForms,并添加了一些有用的集成,例如使用 跨站请求伪造(CSRF)令牌来保护表单。了解更多信息请访问 flask-wtf.readthedocs.io/
。
CSRF 令牌将确保在您登录时,没有恶意的第三方网站可以向您的应用程序发送有效的表单。第七章,保护您的服务,将详细解释 CSRF 的工作原理以及为什么它对您的应用程序安全很重要。
以下模块实现了一个用于 User
表的表单,以 FlaskForm
为其基础:
import quart.flask_patch
from flask_wtf import FlaskForm
import wtforms as f
from wtforms.validators import DataRequired
class UserForm(FlaskForm):
email = f.StringField("email", validators=[DataRequired()])
slack_id = f.StringField("Slack ID")
password = f.PasswordField("password")
display = ["email", slack_id, "password"]
display
属性只是模板在渲染表单时迭代特定有序字段列表的一个辅助工具。其他所有内容都是使用 WTForms 的基本字段类为用户表创建表单。WTForm 的字段文档提供了完整的列表,请参阅 wtforms.readthedocs.io/en/2.3.x/fields/
。
一旦创建,UserForm
可以在具有两个目标的视图中使用。第一个目标是显示在 GET
调用中的表单,第二个目标是在用户提交表单时,在 POST
调用中更新数据库:
@app.route("/create_user", methods=["GET", "POST"])
async def create_user():
form = UserForm()
if request.method == "POST" and form.validate():
async with user_dal() as ud:
await ud.create_user(form.name.data, form.email.data, form.slack_id.data)
return redirect("/users")
return await render_template("create_user.html", form=form)
UserForm
类有一个方法来验证传入的 POST
数据,使用我们提供的验证器。当某些数据无效时,表单实例将保留错误列表在 field.errors
中,以防模板想要向用户显示它们。
create_user.html
模板会遍历表单字段列表,WTForm 负责渲染适当的 HTML 标签:
<html>
<body>
<form action="" method="POST">
{{ form.hidden_tag() }}
<dl>
{% for field in form.display %}
<dt>{{ form[field].label }}</dt>
<dd>{{ form[field]() }}
{% if form[field].errors %}
{% for e in form[field].errors %}{{ e }} {% endfor %}
{% endif %}
</dd>
{% endfor %}
</dl>
<p>
<input type=submit value="Publish">
</form>
</body>
</html>
form.hidden_tag()
方法将渲染所有隐藏字段,例如 CSRF 令牌。一旦这个表单开始工作,就很容易为应用程序中需要的每个表单重用相同的模式。
在开发过程中,表单通常会定期调整,并且由于必要性,它们与数据库的结构紧密耦合。因此,当我们考虑将 Jeeves 分解为微服务时,我们将考虑这些表单是否需要由数据库微服务提供,以避免在其他服务中出现模式依赖。
Slack 工作区
Slack 允许人们将应用程序连接到工作区。如果您是 FunCorp Slack 实例的管理员,您可以访问以下链接并创建一个新的机器人用户:api.slack.com/apps?new_app=1
。
具体的流程和网页布局可能会改变——正如网络服务经常发生的那样——但将有机会启用事件订阅并提供一个 Slack 消息应发送到的 URL。
将生成一个令牌,您应该将其复制并放置在应用程序的设置中,以便在发送消息时验证 Slack:
图 4.1:订阅 Slack 机器人事件
您提供给 Slack 的 Request
URL 需要从 Slack 自身的服务器可访问,因此仅在您的笔记本电脑上运行可能不够。如果您遇到麻烦,那么在云服务提供商中运行虚拟服务器是一种快速简单的方法来开始。我们将在第十章 Deploying on AWS 中更详细地介绍这一点,其中我们讨论了在云中部署我们的应用程序。
一旦验证了机器人的端点,它将开始接收它已订阅的消息。这意味着如果您选择了 app_mention
事件,那么不提及机器人名称的消息将不会发送,但其他任何消息都会。您的机器人需要一些权限——称为作用域——以读取和发送消息:
图 4.2:示例 Slack 机器人权限以接收消息
发送事件时发送的 JSON 数据会附带所有分类好的数据。以下面的例子为例,当某人输入 @jeeves hello
时,API 端点将接收一个 JSON 对象,该对象标识了工作区、时间戳、事件的类型、输入的用户以及消息本身的组成部分——例如提及 (@jeeves
) 和文本,hello。任何发送的用户 ID 对人类来说都是不可识别的,因为它们是用于表示用户和工作区的内部文本字符串。这也意味着,当我们把用户连接到我们的应用程序时,除非我们向 Slack 请求,否则我们永远不会真正了解他们在 Slack 中选择的用户名。
这里是我们请求 Slack 工作区中的天气时,我们的服务得到的 JSON 数据的简化副本。很容易看出用户和团队的价值不是人类友好的,但 JSON 也很有帮助,因为它已经将可能复杂的信息拆分成了我们不需要担心安全移除其他用户的提及、链接或其他特殊元素的段落:
{
"event": {
"client_msg_id": "79cd47ec-4586-4677-a50d-4db58bdfcd4b",
"type": "app_mention",
"text": "<@U01HYK05BLM>\u00a0weather in London, UK",
"user": "U136F44A0",
"ts": "1611254457.003600",
"team": "T136F4492",
"blocks": [
{
"type": "rich_text",
"block_id": "pqx",
"elements": [
{
"type": "rich_text_section",
"elements": [
{
"type": "user",
"user_id": "U01HYK05BLM"
},
{
"type": "text",
"text": " weather in London, UK"
}
]
}
]
}
],
…
}
执行动作
我们的机器人应该能够为发送消息的人做些有用的事情,我们应该尽可能将这些动作保持为自包含的。即使不使用基于微服务的设计,在组件之间创建明确的边界也远更安全。
我们如何知道采取哪种行动?在收到 Slack 的消息后,我们需要理解我们得到的数据,并寻找适当的关键词。当我们找到匹配的内容时,我们就可以检查应该调用哪个函数。
我们用来处理 Slack 消息的 Quart
视图应该尽可能简单,所以我们只做足够的处理来从我们提供的数据中提取信息,并将其传递给消息处理器。这个处理器负责检查机器人收到的文本,并决定采取的行动。这样安排代码也意味着,如果我们添加对其他聊天服务的支持,我们可以使用相同的消息处理器,并咨询相同的动作列表。
我们可能需要在以后需要更复杂或动态的动作配置,但现在,让我们从配置文件中的简单映射开始。字典键将是消息开头需要查找的一些文本,值是匹配时将被调用的函数的名称。这些函数中的每一个都将接受相同的参数,以使我们的生活更简单:
ACTION_MAP = {
"help": show_help_text,
"weather": fetch_weather,
"config": user_config,
}
def process_message(message, metadata):
"""Decide on an action for a chat message.
Arguments:
message (str): The body of the chat message
metadata (dict): Data about who sent the message,
the time and channel.
"""
reply = None
for test, action in ACTION_MAP.items():
if message.startswith(test):
reply = action(message.lstrip(test), metadata)
break
if reply:
post_to_slack(reply, metadata)
通过使用这种方法,向我们的机器人添加新动作不需要对 process_message
函数进行任何更改,因此对该函数的测试也没有任何更改;相反,我们只需更改文件顶部的字典。如果我们发现将 ACTION_MAP
移入数据库或配置文件会有所帮助,那么以后也将更容易这样做。
这些操作可能会很好地利用元数据,因此我们将这些信息传递下去。例如,查找天气时,可以利用我们被告知的任何存储位置。
OAuth 令牌
OAuth2 (oauth.net/2/
)允许我们向其他人的网站发送经过身份验证的请求。我们可以请求对某人的 Google 日历的只读访问权限,允许向 GitHub 发布问题,或者能够读取健身应用程序中我们记录的锻炼信息的权限。我们可以在不要求用户为不同网站提供密码的情况下完成所有这些操作——这是任何人都永远不会做的事情!
在我们的示例中,我们将连接到 Slack,以便允许使用我们机器人的用户登录并更新他们自己的信息。我们还可以使用此功能获取有关他们的信息,例如他们的 Slack 个人资料详情——如果他们允许我们这样做的话。
我们将向访问网页的人展示一个按钮,他们可以使用该按钮通过 Slack 登录到网站,这将使网络浏览器跳转到 Slack 的页面以授权我们的应用程序。如果他们同意,那么我们的应用程序将获得一个代码,然后我们可以使用该代码请求访问令牌。这个访问令牌将允许我们联系 Slack 并验证令牌是否仍然有效,并允许我们请求我们被允许查看或更改的信息!对于这个示例,我们需要启用 HTTPS 并拥有一个有效的网站证书。最简单的方法是使用代理和“Let's Encrypt”证书。我们将在第七章“保护你的服务”中介绍如何设置这个代理和证书。现在,让我们看看我们如何登录用户:
# slack_oauth2.py
import os
from quart import Quart, request, render_template
import aiohttp
app = Quart(__name__)
@app.route("/")
async def welcome_page():
client_id = os.environ["SLACK_CLIENT_ID"]
return await render_template("welcome.html", client_id=client_id)
@app.route("/slack/callback")
async def oauth2_slack_callback():
code = request.args["code"]
client_id = os.environ["SLACK_CLIENT_ID"]
client_secret = os.environ["SLACK_CLIENT_SECRET"]
access_url = f"https://slack.com/api/oauth.v2.access?client_id={client_id}&client_secret={client_secret}&code={code}"
async with aiohttp.ClientSession() as session:
async with session.get(access_url) as resp:
access_data = await resp.json()
print(access_data)
return await render_template("logged_in.html")
if __name__ == "__main__":
app.run()
我们在这里介绍了aiohttp (docs.aiohttp.org/
),这是一个用于发送网络请求的有用异步库。我们也没有处理我们的回调视图接收到的错误响应,或者将此有用的数据存储在数据库中以供以后使用。在那个视图中,@login_required
和current_user
是下一节中介绍的认证和授权过程的一部分。
认证和授权
我们的单体应用程序几乎准备好了,但它也需要一种处理认证和授权的方法。简单来说:
-
认证是证明你就是你声称的那个人
-
授权是确定你可以执行哪些操作
这是一个必须谨慎但至关重要的区分。在大多数服务中,有一个管理员,他必须提供认证——证明他们是他们所声称的人——才能获得执行配置更新的权限。服务的普通用户仍然必须证明他们是他们所声称的人,但他们可以做的事情将不包括与管理员相同的访问权限。
对于 Jeeves,我们需要将我们的 Slack 用户与我们的网络服务用户界面连接起来,以便人们可以使用外部服务进行身份验证。我们以这种方式设置第三方身份验证,这样我们就不需要对标准的 OAuth 流程进行任何复杂的更改。
由于 Slack 为大多数用户使用内部标识符,我们不会看到——也不需要看到——他们选择显示给其他人的友好名称。相反,我们将通过一个简短的九个字符的字符串来识别他们:你可以通过检查你的 Slack 个人资料来查看自己的,它将在 更多 菜单下可见。我们如何将其与网页界面连接起来?最快的方式是从机器人那里获取一个登录链接。如果用户向 Jeeves 发送消息要求登录,Jeeves 可以回复一个 URL。一旦访问,该 URL 将允许用户设置密码并使用所有网页功能。
对于我们的单体解决方案,我们刚刚看到我们可以让人们使用 Slack 登录,而无需我们处理密码。使用 quart-auth
库使得管理用户的会话信息变得简单,因为它提供了创建和存储会话 cookie 的有用辅助函数,这样我们就可以在访问之间被记住。
看看这些更改,我们的欢迎页面不再在其模板中显示登录按钮,而是现在有一个新的装饰器 @login_required
,它将只允许在声明当前访客已成功认证的情况下加载视图:
@app.route("/")
@login_required
async def welcome_page():
return await render_template("welcome.html")
如果网站访客未进行身份验证,那么他们的访问将由我们设置的 errorhandler
处理,这里将他们重定向到登录页面。登录页面与我们的上一个欢迎页面做同样的工作,并显示用户需要按下的登录按钮:
@app.route("/slack_login")
async def slack_login():
client_id = os.environ["SLACK_CLIENT_ID"]
return await render_template("login.html", client_id=client_id)
@app.errorhandler(Unauthorized)
async def redirect_to_login(_):
return redirect(url_for("slack_login"))
Slack OAuth2 的流程与之前一样继续进行,我们会在回调中收到一条消息。如果这条消息表明一切顺利,那么我们可以使用 quart-auth
的 AuthUser
调用和 login_user
调用来为该用户设置一个会话。让我们通过一个实际的工作示例将整个流程整合起来,利用 secrets
库生成一个安全但临时的密钥用于开发:
# logging_in.py
import os
from quart import Quart, request, render_template, redirect, url_for
from quart_auth import (
AuthManager,
login_required,
logout_user,
login_user,
AuthUser,
Unauthorized,
)
import aiohttp
import secrets
app = Quart(__name__)
AuthManager(app)
app.secret_key = secrets.token_urlsafe(16)
@app.route("/")
@login_required
async def welcome_page():
return await render_template("welcome.html")
@app.route("/slack_login")
async def slack_login():
client_id = os.environ["SLACK_CLIENT_ID"]
return await render_template("login.html", client_id=client_id)
@app.errorhandler(Unauthorized)
async def redirect_to_login(_):
return redirect(url_for("slack_login"))
@app.route("/slack/callback")
async def oauth2_slack_callback():
code = request.args["code"]
client_id = os.environ["SLACK_CLIENT_ID"]
client_secret = os.environ["SLACK_CLIENT_SECRET"]
access_url = f"https://slack.com/api/oauth.v2.access?client_id={client_id}&client_secret={client_secret}&code={code}"
async with aiohttp.ClientSession() as session:
async with session.get(access_url) as resp:
access_data = await resp.json()
if access_data["ok"] is True:
authed_user = access_data["authed_user"]["id"]
login_user(AuthUser(authed_user))
return redirect(url_for("welcome_page")
return redirect(url_for("slack_login"))
如果你确实需要存储密码,最简单的保护形式是确保它们不会以明文形式存储在数据库中;相反,以无法转换回原始密码的散列形式存储。这将最大限度地减少如果服务器被入侵时密码泄露的风险。对于身份验证过程,这意味着当用户登录时,你需要将传入的密码散列并与存储的散列进行比较。始终检查有关散列算法的最新建议,因为发明自己的或使用过时的算法可能非常危险。
传输层通常不是应用程序安全中的弱点。多亏了在传输层安全(TLS)上工作的安全专业人士的辛勤工作,我们只需要关注请求接收后服务内部发生的事情。
同样,可以通过查看quart_auth
在应用程序上下文中设置的current_user
变量来执行更细粒度的权限验证。例如,您可以使用此功能允许用户更改他们的数据,但阻止他们更改任何其他用户的资料。
后台任务
到目前为止,我们的应用程序有几个功能,可以作为计划任务运行,无需用户交互:我们的天气动作可以检查用户所在地区的天气警报并向他们发送消息;日历动作可以在工作日开始时报告您的计划会议;可以生成并电子邮件发送到负责机器人的人员的关于已执行动作的月度报告。
这些是后台任务,它们需要在 HTTP 请求/响应周期之外独立运行。大多数操作系统都有某种形式的计划任务功能,例如 Unix 上的 cron 或 Windows 中的计划任务。这些功能可能不适合我们的应用程序,因为这意味着我们应该在平台无关的情况下连接到特定平台,并且能够运行在容器中,或者如果我们的需求发生变化,可以迁移到无服务器平台。
在 Python Web 应用程序中运行重复的后台任务的一种流行方式是使用Celery,这是一个分布式任务队列,可以在独立进程中执行一些工作:docs.celeryproject.org
。
运行这些工作片段时,一个称为消息代理的中间件负责在应用程序和 Celery 之间传递消息。例如,如果应用程序想让 Celery 运行某些任务,它将在代理中添加一条消息;Celery 会轮询该消息并执行任务。
消息代理可以是任何可以存储消息并提供检索它们方式的任何服务。Celery 项目与高级消息队列协议(AMQP)服务(如RabbitMQ (www.rabbitmq.com
))、Redis (redis.io
)和Amazon SQS (aws.amazon.com/sqs/
)无缝工作。AMQP 提供了一套标准技术,用于以可靠的方式路由和传递消息。在我们下一章更详细地研究微服务设计时,我们将使用 RabbitMQ 作为示例,RabbitMQ 将负责确保消息到达目的地,而 Celery 负责对那些消息采取行动。
执行作业的组件被称为工作进程,Celery 提供了一个类来启动一个。要从Quart
应用程序中使用 Celery,你可以创建一个background.py
模块,该模块实例化一个 Celery 对象,并使用@celery.task
装饰器标记你的后台任务。
在下面的示例中,我们使用 Celery 设置一个任务,该任务将为数据库中设置了位置和 Slack 用户名的每个用户获取天气报告。虽然在实际中,我们希望人们选择加入这个功能,但它允许我们展示如何构建一个任务。
我们将使用本章中创建的数据库,并假设我们已经向其中添加了一个位置字段。我们还应该添加一个函数,以便我们可以搜索设置了位置的用户账户:
class UserDAL:
...
async def get_users_with_locations(self):
query = select(User).where(User.location is not None)
return await self.db_session.execute(query)
现在我们可以设置一个工作进程来执行任务。唯一的困难是需要将我们调用的执行工作的函数包装起来。要使用异步数据库引擎,它必须是一个async
函数,但 Celery 只能调用同步函数,所以我们使用asgiref
库中找到的辅助函数来转换它:
# weather_worker.py
import asyncio
from asgiref.sync import async_to_sync
from celery import Celery
from database import user_dal
celery_app = Celery("tasks", broker="amqp://localhost")
async def fetch_weather(location):
return "This is where we would call the weather service"
async def post_to_slack(message, options):
print(f"This is where we would post {message}")
async def weather_alerts_async():
async with user_dal() as ud:
query_results = await ud.get_users_with_locations()
for user in query_results:
user = user[0] # the database returns a tuple
weather_message = await fetch_weather(user.location)
username = user.slack_id
if not username.startswith("@"):
username = "@" + username
await post_to_slack(weather_message, {"channel": username})
@celery_app.task
def do_weather_alerts():
async_to_sync(weather_alerts_async)()
@celery_app.on_after_configure.connect
def setup_periodic_tasks(sender, **kwargs):
sender.add_periodic_task(10.0, do_weather_alerts, name="fetch the weather", expires=30)
Celery 需要 RabbitMQ 运行才能工作——RabbitMQ 网站上有一些教程,但以下我们假设你已经安装了 Docker 并且可以运行容器。如果你没有,那么不用担心;我们将在第十章部署在 AWS中更详细地讨论容器。我们运行 Celery 后台工作进程,它将等待接收要求它执行工作的消息,然后在另一个终端中启动调度器或 beat,它将使用我们设置的周期性任务:
docker run -d -p 5672:5672 rabbitmq
celery -A background worker --loglevel=INFO
celery -A background beat --loglevel=INFO
这个 Celery 工作进程也使用 AMQP 连接到 RabbitMQ,以便可以通过发送消息通过代理来触发任务。如果我们不需要立即向调用者发送响应,而是期望一个运行时间较长的进程执行一些任务,这将特别有用。
继续我们的设置,我们可以看看调度器。每 10 秒对于这样的报告来说可能有点太频繁了。我们应该改用 Celery 的 crontab 功能,它允许我们指定一个计划,同时使用更熟悉的 Unix crontab 设置:
from celery.schedules import crontab
@celery_app.on_after_configure.connect
def setup_periodic_tasks(sender, **kwargs):
sender.add_periodic_task(
crontab(hour=7, minute=30, day_of_week='monday'),
do_weather_alerts, name="fetch the weather", expires=30
)
当 Celery 服务通过Quart
应用程序传递消息被调用时,它可以被认为是一个本身就是一个微服务。这也从部署的角度来看很有趣,因为 RabbitMQ 服务器和 Celery 应用程序都可以部署在另一台服务器上。然后我们的异步函数可以使用当前的应用程序上下文来访问数据库,运行查询,然后使用我们应用程序的功能来运行任务。
组合单体设计
这种单体设计是一个坚实的基础,应该是你第一次开发迭代中追求的结果。一切都应该与测试和文档一起创建,正如在第三章编码、测试和文档:良性循环中解释的那样。
这是一个在关系型数据库之上的简洁实现,可以使用 PostgreSQL、MySQL 或云服务提供商自带的 SQL 数据库进行部署。得益于 SQLAlchemy 抽象,本地版本可以使用 SQLite 3 运行,并便于你的日常开发和本地测试。为了构建这个应用程序,我们使用了以下扩展和库:
-
aiohttp:处理所有出站 HTTP 请求
-
SQLAlchemy:用于模型
-
Flask-WTF和WTForms:用于所有表单
-
Celery 和 RabbitMQ:这些用于后台进程和周期性任务
-
quart-auth:用于管理身份验证和授权
整体设计可以用图表表示,如图图 4.3所示:
图 4.3:我们第一个设计中的组件
典型的早期部署会将所有这些服务放在同一台服务器上。这样做当然更简单,而且通常给人一种直观的感觉,即给应用程序分配更强大的计算机——这被称为垂直扩展。无论是云服务提供商中的虚拟机还是你建筑中的物理服务器,单个计算机可用的资源数量是有限的,因此垂直扩展有一个实际的上限。
无论你的应用程序是因为内存不足、网络吞吐量、CPU 处理可用性,还是其他瓶颈而运行缓慢,最佳解决方案是更新架构,以便服务可以在多台不同的计算机上运行。这是水平扩展,也是使用微服务的一个好处。如果一个微服务需要比单台计算机能提供的更多 I/O 吞吐量来处理所有请求,那么如果它可以在几十或几百台计算机上运行,这就不成问题。
摘要
Jeeves 机器人是一个典型的 Web 应用程序,它与数据库和一些后端服务交互。唯一不寻常的特点是它的大部分工作量来自一个端点。使用单体架构构建这个应用程序使我们能够快速迭代几个选择,并得到一个在开发和低量使用期间表现良好的原型。
从我们关于动作的讨论中可以看出,有很好的候选者可以迁移到微服务。如果我们为几十或几百个 Slack 工作空间运行这个机器人,我们可能会发现有一个组件比其他组件使用得多,或者有难以在当前架构中解决的可靠性问题。应用程序应该如何扩展?当它依赖的外部服务遇到错误时会发生什么?如果我们的某个组件——我们的数据库或消息代理——崩溃了,会发生什么?
在下一章中,我们将探讨这些问题:如何改变 Jeeves 的架构以使其更具弹性,以及如何对服务进行谨慎、有度的更改。
第五章:分割单体
在上一章中,我们创建了一个单体应用程序作为助手;我们这样做非常迅速,专注于添加功能而不是长期架构。这种做法没有错——毕竟,如果应用程序永远不需要扩展,那么工程努力就是浪费。
但让我们假设我们的服务非常受欢迎,接收到的请求数量正在增长。我们现在必须确保它在负载下表现良好,同时也确保它对不断增长的开发团队来说易于维护。我们应该如何继续?在本章中,我们将:
-
检查如何根据代码复杂度和我们收集的使用数据来确定迁移到新微服务的最佳组件
-
展示准备和执行迁移的技术,以及检查其成功情况
识别潜在的微服务
对于我们熟悉的应用程序,我们可能对哪些组件过载或不稳定有很多直觉。毕竟,要么我们编写了它,要么我们重写了它的大部分内容,并对其进行了测试,同时对其架构做出了决策。在数据库变得更大或注意到在测试期间某个特定函数运行时间过长时,做笔记也是自然的。
然而,我们的直觉可能会误导我们,因此让我们的决定受到我们收集的数据的指导是一个好主意。开发人员和运维人员将会有具体的问题需要回答,以便决定服务的未来方向。产品管理和其他面向业务的人员也会有需要回答的问题,这些问题通常不特定于技术。开发者可能提出的问题包括:
-
HTTP 请求的响应速度有多快?
-
对于不同的端点,HTTP 请求的成功率和错误率是多少?
-
在进行更改时,系统的哪些部分是麻烦的?
-
在峰值使用时,一个组件平均需要处理多少个活动连接?
一些需要考虑的非技术性业务问题包括:
-
慢速响应是否意味着用户将停止使用我们的 Slack 机器人检查,并开始使用其他工具?
-
在一个基于网络的商店中,转化率是多少——也就是说,有多少客户查看了商品,与有多少人购买了东西相比?
-
我们通过服务提供的信息有多准确和及时?
在我们的讨论中,我们将关注技术问题,但始终值得记住软件存在的目的以及如何最好地回答需要应用程序的人以及生产它的人提出的问题。为了做出关于分割我们的单体应用程序的决定,我们将牢记两个问题:
-
当运行时,哪些组件最慢,导致最多的延迟?
-
哪些组件与应用程序的其他部分紧密耦合,因此在更改时变得脆弱?
经常听到的两个术语是数据驱动和数据信息。要成为数据驱动,意味着收集关于某个情况的数据,并始终基于该信息做出决策。成为数据信息也涉及收集数据,但将其与个人经验和更广泛情况的知识相结合使用。
软件及其功能有许多方面——网络连接、读取文件、查询数据库等等。认为收集所有信息并在数据中寻找模式将是监控应用程序的最佳方式是很诱人的。然而,通常有太多数据需要筛选,以及太多变量需要考虑。相反,我们应该从定性问题开始。
然而,问题不应该是“哪些应用程序的部分可以作为微服务运行?”;相反,我们应该考虑诸如“哪些应用程序的部分对性能影响最大?”和“哪些应用程序的部分难以更改?”等问题。答案可能是微服务——既然本书是关于这个选项的,我们将深入探讨它——但还可能存在其他性能问题,以及其他解决方案,例如优化数据库或缓存常见查询结果。
让我们看看一些方法,我们可以识别出应用程序中需要重构的部分,以及哪些部分适合作为微服务。
代码复杂度和维护
正如我们在第一章中讨论的,在理解微服务时,随着项目规模的增加,推理变得更加困难,尤其是对于新加入团队的人来说。保持系统不同逻辑部分的分离,并在它们之间保持清晰的接口,有助于我们更有效地思考所有不同组件之间的交互——使理解在哪里进行更改变得更容易——而无需担心意外破坏看似无关的代码。
在查看维护时做出的许多决策将基于经验:在阅读代码时,开发者会感觉到哪些区域他们理解得很好,哪些区域他们不熟悉,以及更改项目各个部分的风险程度。
我们还可以通过使用评估代码循环复杂度的工具来采取数据驱动的策略。循环复杂度是一个软件度量,于 20 世纪 70 年代开发,用于评估程序有多少代码执行分支和路径。理解数学原理超出了本书的范围,因此,为了我们的目的,我们应该理解分数越高表示代码越复杂,而得分为 1.0 的代码则完全没有决策。
Radon (pypi.org/project/radon/
) 是一个用于快速评估代码复杂度的 Python 工具;它还将复杂度评分分组到不同的区间,类似于 A 到 F 的学术等级。由于我们之前的示例都很简单,让我们运行Radon
对Quart
本身进行评估。
在这里,我们告诉 Radon 计算循环复杂度,并且只报告那些复杂度评分为 C 或更差的区域:
$ git clone https://gitlab.com/pgjones/quart
$ cd quart
$ radon cc . --average --min c
asgi.py
M 205:4 ASGIWebsocketConnection.handle_websocket - C
blueprints.py
M 510:4 Blueprint.register - D
cli.py
F 278:0 routes_command - C
M 48:4 ScriptInfo.load_app - C
app.py
M 1178:4 Quart.run - C
helpers.py
F 154:0 url_for - C
F 347:0 send_file - C
testing/utils.py
F 60:0 make_test_body_with_headers - C
8 blocks (classes, functions, methods) analyzed.
Average complexity: C (15.125)
容易认为高复杂度的函数总是不好的,但情况并不一定如此。我们应该追求简单,但不要过度简化到失去软件中的实用性。我们应该将这些分数作为我们决策的指南。
现在,我们将探讨我们可以收集的其他关于我们代码的数据,以帮助我们做出明智的决策。
指标和监控
容易认为监控工具在提醒我们有问题时是有用的,但还有其他有价值的用途。操作健康监控依赖于一系列高分辨率指标,这些指标以低延迟到达,使我们能够注意到并修复系统中的问题。为了确定是否需要更改架构,我们可能会查看服务的操作健康,但我们还希望查看服务的质量:质量保证发现服务是否满足我们的标准。
这与操作健康有何不同?在一个复杂的系统中,可能会有不可靠或运行缓慢的组件,但系统的整体性能对于使用它的人来说是可以接受的。如果我们要求软件为我们发送电子邮件,并且它晚十秒钟到达,那么大多数人会认为这种服务质量是可以接受的,即使幕后有大量的失败节点、连接超时和重试操作。这样的服务正在运行,但需要维护,否则它将继续以更高的风险出现大规模故障或缺乏突发容量。
收集关于我们的应用程序正在做什么的数据,使我们更了解哪些组件需要关注,哪些运行缓慢,哪些响应良好。进行测量的意义是什么?从历史上看,确定一个良好的定义一直很棘手。然而,心理学家斯坦利·史密斯·史蒂文斯以有用的方式描述了它:
在最广泛的意义上,测量被定义为根据规则将数字分配给对象和事件。
——《测量尺度理论》,S. S. Stevens(1946 年)
做一个好的测量是什么?对这个问题的明确回答也很困难,尽管为了我们的目的,我们可以收集三种主要类型的数据。第一种是仪表,它是在某个时间点的绝对度量。汽车中的燃油表会告诉你剩余多少燃料,而像 netstat 这样的工具会告诉你服务器有多少个打开的网络连接。在服务内部,一个测量值,如活动连接数,就是一个仪表。
计数器是持续增长和累积的测量值——你经常会看到关于网络流量或磁盘 I/O 的测量值作为计数器。无论何时你询问内核从网络接口传输了多少字节,你都会得到一个本身没有太多意义的数字,因为它将是自计数开始以来的总流量。但一秒后再问一次,从另一个数字中减去一个,现在你就有了一个每秒字节的值。Unix 工具如iostat
和vmstat
会为你做这件事,这就是为什么它们显示的第一组数字通常非常高,应该被忽略。
理解你的仪表和计数器正在收集什么信息很重要,因为它会改变它们的使用方式。取平均值——通常是平均值,但有时是中位数——通常给我们一个有意义的数字。如果我们记录在最后 1 秒内,我们的笔记本电脑的六个 CPU 核心使用了 0、0、0、1、1 和 1 秒的 CPU 时间,那么说我们的平均 CPU 使用率为 50%是有意义的。同样,如果我们测量笔记本电脑的温度,并且其三个传感器告诉我们 65、70 和 75°C 的值,那么平均值仍然是有用的,但说总温度是 210 度就没有意义了!
比率是我们关心的第三类数据。这些描述了其他测量值之间的关系。在讨论计数器时,我们已经看到了一个有用的比率,即“传输的字节数”除以“所需时间”给出了一个比率,同样“传输的字节数”除以 API 调用次数也给出了一个比率。
选择要收集哪些指标通常是一个困难的选择,因为可能性有很多。最好从具体问题开始,朝着解答它们的方向努力,而不是试图一次性收集所有内容。如果人们报告我们的应用程序运行缓慢,那么我们需要找出哪些部分响应缓慢,以及原因是什么。幸运的是,我们可以从在 Web 应用程序中监控的两种最容易的事情开始:
-
计数每个端点被访问的次数
-
每个端点完成请求处理所需的时间
一旦我们有了这两方面的信息,这可能会引导我们到一个特定的端点,该端点过载,或者处理请求太慢而落后。如果不起作用,那么我们需要开始调查系统其他组件的类似高级信息,例如数据库或网络吞吐量。为了以云无关的方式调查这个问题,我们将转向一个称为Prometheus(prometheus.io/
)的通用操作监控工具。Prometheus 通过抓取端点来操作——我们通过一些查询 URL 来配置它,并且它期望在发送请求时返回一些指标。为了轻松地将指标集成到我们的应用程序中,我们可以使用aioprometheus
库。其文档可以在aioprometheus.readthedocs.io/en/latest/
找到。
首先,我们需要设置我们想要收集的指标。目前,让我们假设我们感兴趣的是端点正在响应多少并发请求,以及每个请求需要多长时间。我们可以使用aioprometheus
来设置一个Registry
对象来存储这些信息,直到 Prometheus 服务器请求这些信息。活跃请求的数量是一个仪表,因为它是在某个时间点的当前状态的快照。每个请求的持续时间被记录为一个Summary
对象,因为一旦数据进入 Prometheus,我们希望对其进行聚合,并可能查看值的分布。我们可以创建这两个注册表,然后将它们添加到我们的应用程序中:
app.registry = Registry()
app.api_requests_gauge = Gauge(
"quart_active_requests", "Number of active requests per endpoint"
)
app.request_timer = Summary(
"request_processing_seconds", "Time spent processing request"
)
app.registry.register(app.api_requests_gauge)
app.registry.register(app.request_timer)
我们还需要添加一个端点,让 Prometheus 能够访问我们的应用程序并请求收集的指标。aioprometheus
还提供了一个render
函数来为我们生成这些数据,因此指标处理程序很短:
@app.route("/metrics")
async def handle_metrics():
return render(app.registry, request.headers.getlist("accept"))
完成这些后,我们可以利用aioprometheus
提供的辅助函数来记录函数的持续时间,以及自动增加和减少仪表。这里函数的内容只是为了提供一些需要花费一些时间的内容——我们将睡眠 1 到 1.5 秒来生成一组响应所需时间的值。让我们将所有这些整合到一个工作示例中:
# quart_metrics.py
import asyncio
from random import randint
from aioprometheus import Gauge, Registry, Summary, inprogress, render, timer
from quart import Quart, request
app = Quart(__name__)
app.registry = Registry()
app.api_requests_gauge = Gauge(
"quart_active_requests", "Number of active requests per endpoint"
)
app.request_timer = Summary(
"request_processing_seconds", "Time spent processing request"
)
app.registry.register(app.api_requests_gauge)
app.registry.register(app.request_timer)
@app.route("/")
@timer(app.request_timer, labels={"path": "/"})
@inprogress(app.api_requests_gauge, labels={"path": "/"})
async def index_handler():
await asyncio.sleep(1.0)
return "index"
@app.route("/endpoint1")
@timer(app.request_timer, labels={"path": "/endpoint1"})
@inprogress(app.api_requests_gauge, labels={"path": "/endpoint1"})
async def endpoint1_handler():
await asyncio.sleep(randint(1000, 1500) / 1000.0)
return "endpoint1"
@app.route("/endpoint2")
@timer(app.request_timer, labels={"path": "/endpoint2"})
@inprogress(app.api_requests_gauge, labels={"path": "/endpoint2"})
async def endpoint2_handler():
await asyncio.sleep(randint(2000, 2500) / 1000.0)
return "endpoint2"
@app.route("/metrics")
async def handle_metrics():
return render(app.registry, request.headers.getlist("accept"))
if __name__ == "__main__":
app.run(host="0.0.0.0")
对于生产服务,指标收集服务是另一个需要部署和管理的组件;然而,在我们开发自己的实验时,Prometheus 的本地副本就足够了,我们可以在容器中运行它。如果我们设置了一个基本的配置文件,我们需要确保目标匹配我们运行应用程序的计算机的 IP 地址——它不能是 localhost,因为 Prometheus 在其自己的容器内运行,因此流量永远不会离开该容器。以下是我们的配置,我们可以将其放置在一个名为prometheus.yml
的文件中,然后将其包含在容器中:
# prometheus.yml
---
global:
scrape_interval: 15s
external_labels:
monitor: 'quart-monitor'
scrape_configs:
- job_name: 'prometheus'
scrape_interval: 5s
static_configs:
- targets: ['192.168.1.100:5000'] # Replace with your app's IP address
labels:
group: 'quart'
现在我们运行 Prometheus 并访问 Web 界面,如果你在笔记本电脑上运行容器,它将在http://localhost:9090/
:
docker run \
-p 9090:9090 \
-v /path/to/prometheus.yml:/etc/prometheus/prometheus.yml \
prom/prometheus
图 5.1显示了我们对运行中的应用程序运行一系列查询后收集的数据,使用的是我们在第三章,“编码、测试和文档:良性循环”中讨论负载测试时介绍的Boom (github.com/tarekziade/boom
)工具。由于我们在测试中随机化了调用的端点,我们可以看到图中的不同使用率。Prometheus 查询请求每分钟活动请求数量的速率,使用我们在quart_metrics.py
中设置的仪表名称。
关于查询 Prometheus 的更多信息,请在此处查看:prometheus.io/docs/prometheus/latest/getting_started/
。
图 5.1:Prometheus 显示每个端点正在服务的活动请求数量的示例
现在我们对 API 中每个端点被查询的次数以及这些请求所需的时间有了更清晰的了解。我们还可以添加额外的指标,例如 CPU 使用时间、内存消耗量或我们等待其他网络调用完成的时间。这样的数据有助于我们精确地找出应用程序中哪些部分消耗了最多的资源,哪些部分在扩展时遇到困难。
日志记录
数字可以告诉我们应用程序中发生了很多事情,但不是全部。我们还需要使用日志记录,这是产生将被记录的某些文本或数据的行为,但它不是软件基本操作的一部分。这并不意味着日志不重要——它很重要——但应用程序可以在没有任何消息输出的情况下正常运行。
一旦我们了解系统哪些部分运行缓慢,接下来的问题将是“它到底在做什么?”阅读代码只能给我们部分答案——通过记录所做的决策、发送的数据和遇到的错误,日志记录将给我们其余的答案。
记录所有内容将增加应用程序的 I/O 需求,无论是通过网络发送这些消息还是使用磁盘资源在本地写入文件。我们应该仔细考虑要写入的内容以及原因。当日志消息可能包含敏感信息,如关于个人或密码的详细信息时,这一点尤其正确。对于在生产中运行的服务,应尽可能对日志进行清理,移除可能泄露个人数据的任何内容。
Python 拥有强大的日志选项,可以自动为我们格式化消息,并根据消息的严重性进行过滤。日志消息的严重性按调试、信息、警告、错误和严重等级别划分,这使得我们可以通过一个设置轻松地更改应用程序产生的消息数量,而不是逐行更改产生消息的代码。
Quart 提供了一个接口,允许在应用程序中轻松使用 Python 的内置日志记录。让我们看看一个基本示例,其中我们使用app.logger
在hello_handler
被调用时产生日志消息:
# quart_logging.py
import logging
from quart import Quart, request
app = Quart(__name__)
app.logger.setLevel(logging.INFO)
@app.route("/hello")
def hello_handler():
app.logger.info("hello_handler called")
app.logger.debug(f"The request was {request}")
return {"Hello": "World!"}
if __name__ == "__main__":
app.run()
当我们运行应用程序,并且查询/hello
端点时,Quart
将在其运行的终端中显示一条额外的消息:
[2021-06-26 21:21:41,144] Running on http://127.0.0.1:5000 (CTRL + C to quit)
[2021-06-26 21:21:42,743] INFO in quart_logging: hello_handler called
[2021-06-26 21:21:42,747] 127.0.0.1:51270 GET /hello 1.1 200 18 4702
为什么只有一个消息?第二次调用使用了“调试”严重性,而我们设置了日志级别为INFO
,因此只有信息重要性和以上的消息会被产生。如果我们想让调试消息显示出来,我们可以将app.logger.setLevel(logging.INFO)
改为app.logger.setLevel(logging.DEBUG)
。
虽然我们的日志消息目前有一个特定的格式,但产生的仍然是一个单一的文本字符串。如果有一个程序想要检查日志条目中的重要错误或寻找事件中的模式,这可能会很尴尬。
对于应该由计算机读取的日志消息,结构化日志是最佳选择。结构化日志通常是以 JSON 格式生成的日志消息,这样日期、文本描述、消息来源以及任何其他元数据都作为 JSON 中的单独字段。Python 的structlog
库能够正确地格式化输出,并且使得向日志消息中添加处理器以屏蔽名称、密码和其他类似私人信息变得容易:www.structlog.org/en/stable/index.html
。
在 Quart 中使用它需要设置structlog
,并替换创建日志消息所用的函数:
# quart_structlog.py
import logging
from quart import Quart, request
import structlog
from structlog import wrap_logger
from structlog.processors import JSONRenderer
app = Quart(__name__)
logger = wrap_logger(
app.logger,
processors=[
structlog.processors.add_log_level,
structlog.processors.TimeStamper(),
JSONRenderer(indent=4, sort_keys=True),
],
)
app.logger.setLevel(logging.DEBUG)
@app.route("/hello")
def hello_handler():
logger.info("hello_handler called")
logger.debug(f"The request was {request}")
return {"Hello": "World!"}
if __name__ == "__main__":
app.run()
使用上面的代码,我们现在得到了结构化日志条目——仍然被人类可读的文本包围,但现在有了一些计算机可以轻松解析的条目:
[2021-06-26 21:54:24,208] INFO in _base: {
"event": "hello_handler called",
"level": "info",
"timestamp": 1624740864.2083042
}
[2021-06-26 21:54:24,211] DEBUG in _base: {
"event": "The request was <Request 'http://localhost:5000/hello' [GET]>",
"level": "debug",
"timestamp": 1624740864.211336
}
进一步配置structlog
允许您将 JSON 直接发送到中央日志服务器,例如Graylog (www.graylog.org/
),这对于从运行在不同计算机上的多个不同副本的软件中收集日志非常有用。
在了解了关于代码复杂性和我们单体中每个组件的工作情况的所有这些信息后,我们应该对哪些区域需要最多的工作,以及哪些可以从自己的微服务中提取出来以获得最大的好处有一个很好的想法。一旦我们确定了这些组件,我们就可以开始这个过程。
拆分单体
现在我们知道了哪些组件消耗了最多的资源,并且花费了最多的时间,我们应该如何将它们拆分?
我们已经可以将我们服务中的几个组件移动到单独的服务器上。RabbitMQ、Celery 和数据库都通过网络进行通信,因此虽然设置新服务器和配置它们有很多步骤,但安装这些主机并更新我们的应用程序以使用新的 URL 是一个被充分理解的过程。这使得我们的 API 能够专注于处理网络连接,并将更大的任务移动到它们自己的工作者上。
开发者还必须考虑设置网络安全、账户、访问控制和与运行和保障服务相关的其他问题。
我们自己应用程序的部分更复杂:我们调用函数来调用我们自己的功能,我们将需要调用 REST API。这是否应该通过一次大规模部署和一次性完成所有更改来完成?我们应该运行旧版本和新版本一段时间并行运行吗?
谨慎、有节制的改变总是更安全。谨慎并不意味着你必须慢,但它确实涉及到规划。我们如何判断迁移是否成功?如果我们需要撤销更改,会发生什么?提出这些问题让我们在迁移发生之前发现困难的情况——尽管事情可能不会总是按计划进行。有句老话说计划永远无法在接触敌人时存活,但有一个重要的细微差别,归功于前美国总统德怀特·D·艾森豪威尔:
计划是没有价值的,但规划本身是一切。
——德怀特·D·艾森豪威尔,1957 年
如果你制定的计划最终没有用,那没关系。制定这些计划的行为帮助你更好地理解一个情况,并让你拥有处理面前变化情况所需的工具。
任何方法的一个优秀的第一步是回到我们的面向服务的架构原则,并定义未来微服务与应用程序其余部分之间的清晰接口。让我们回顾一下我们的单体应用程序,看看哪个函数负责确定要执行的操作,以及如果用户想要查找天气,另一个被选中的函数。这段代码有很多问题,但我们将解决相关的问题:
# Decide which action to take, and take it.
async def process_message(message, metadata):
"""Decide on an action for a chat message.
Arguments:
message (str): The body of the chat message
metadata (dict): Data about who sent the message,
the time and channel.
"""
reply = None
for test, action in ACTION_MAP.items():
if message.startswith(test):
reply = await action(message[len(test):] metadata)
break
if reply:
post_to_slack(reply, metadata)
# Process the weather action
async def weather_action(text, metadata):
if text:
location = text.strip()
else:
with user_dal() as ud:
user = ud.get_user_by_slack_id(metadata[metadata["sender"])
if user.location:
location = user.location
else:
return "I don't know where you are."
return await fetch_weather(location)
我们看到,我们的weather_action
函数从process_message
中获取了它所需的所有信息,但它还需要理解如何解析作为消息一部分接收到的文本,以及如何解释关于回复的元数据。理想情况下,只有回复功能的函数需要理解这些元数据。如果我们想将天气功能转变为微服务,那么我们就需要有一种方式来理解来自不同来源的消息,这需要读取用户表来了解如果他们在查询期间没有告诉我们,那么某人现在在哪里。我们可以重构这段代码,使函数调用非常清晰,关于它需要哪些数据。
首先,测试从接收到的消息中提取位置的方式并不容易。两个新的专业函数应该有助于解决这个问题,并确保这些函数更容易测试——extract_location
中的文本处理仅依赖于其输入,而fetch_user_location
现在只是一个数据库查找,我们可以在测试中模拟:
async def extract_location(text):
"""Extract location information from free-form text."""
return re.sub(r'^weather (in )?', '', text)
async def fetch_user_location(slack_id):
location = None
with user_dal() as ud:
user = ud.get_user_by_slack_id(metadata[metadata["sender"])
location = user.location
return location
现在生成更复杂的文本分析以找到其中的位置也更容易了,因为这可以在不影响任何其他代码的情况下完成。应该调用这两个函数的是什么?答案是新的预处理器,它可以接受人类编写的自由格式文本消息,并尝试结构化其中的数据。我们还将调整我们的天气动作,使其现在非常简单,调用执行所需 Web 请求的函数,并将该文本传递给发送消息回 Slack 的组件:
async def process_weather_action(text, metadata):
potential_location = await extract_location(text)
if not potential_location:
potential_location = await fetch_user_location(metadata["sender"])
if potential_location:
await weather_action(potential_location, metadata)
else:
await send_response("I don't know where you are", metadata)
async def weather_action(location, metadata):
reply = await fetch_weather(location)
await send_response(reply, metadata)
现在,当迁移到微服务的时候,我们有一个清晰的模型,知道微服务应该接受什么以及需要返回什么数据。因为函数调用可以被替换为执行基于 Web 查询的函数,并且使用相同结构化的数据,我们可以将此数据纳入我们的测试中,并更有信心新微服务将按预期运行。我们也在改变发送响应的方式,这样我们就不再依赖于调用weather_action
的代码,而是可以将消息传递给一个专门的处理程序。一旦我们切换到微服务,调用代码就不再需要等待回复。
特性标志
更改大型代码库通常涉及多个大型补丁,在专业环境中,这些补丁在被接受和合并之前将经过同行审查。在大型更改中,当你必须确定确切哪些补丁集必须存在才能使新功能正常工作时,可能会感到困惑。更糟糕的是,如果出现问题并且需要撤销更改,这可能会在快速变化的环境中引起问题,其他人可能已经做出了新的更改,这些更改假设了已经存在的内容。
特性标志是一种仅用于开启或关闭特定功能的配置选项。它们以类似于正常配置选项的方式运作,让您选择软件的行为,但它们主要存在是为了帮助处理新特性、修复和迁移。而不是协调多个大型软件补丁,这些更改可以在最方便的时候到达生产环境,除非新的配置选项被开启,否则它们将不会被使用。
启用新功能只是一个调整配置文件的问题——无论是通过新版本发布、某些配置管理软件,还是更新服务发现工具,例如etcd (etcd.io/
),我们将在第十章“在 AWS 上部署”中讨论。尽管进行了所有细致的计划,但在某些情况下,你可能需要紧急关闭新行为。功能标志意味着这是一个简单的操作,任何需要审查和理解变更的人都能轻松理解。
功能标志不必是全有或全无的开关。在“调用本地函数”或“发起网络请求”路径之间进行选择时,可以指示将 99%的流量发送到第一条路径,1%发送到第二条路径,以便您检查这些查询的成功率。迁移可以缓慢进行,逐渐增加流向新代码的流量比例。您还可以选择复制调用并将真实流量发送到测试系统,以查看其在负载下的表现。
实现功能标志不应复杂——毕竟,代码只存在于迁移过程中。一个简单的开关标志和一个用于部分流量的路由器可以像以下示例一样简单。第一个示例将在配置值更改时完全切换到新工作者,第二个配置为将一定百分比的流量发送到新工作者,以允许新代码的受控发布:
@app.route("/migrating_endpoint")
async def migration_example():
if current_app.config.get("USE_NEW_WORKER"):
return await new_worker()
else:
return await original_worker()
@app.route("/migrating_gradually")
async def migrating_gradually_example():
percentage_split = current_app.config.get("NEW_WORKER_PERCENTAGE")
if percentage_split and random.randint(1,100) <= percentage_split:
return await new_worker()
else:
return await original_worker()
使用 Prometheus,我们可以监控迁移过程。图 5.2是一个示例图表,展示了我们的应用程序在处理请求数量随时间变化时,original_worker
和new_worker
调用的速率如何变化,随着我们稳步增加应该使用新功能的调用百分比。
图 5.2:使用 Prometheus 跟踪渐进式功能迁移的进度
一旦新功能稳定,可以更改配置选项的默认状态——到目前为止,如果选项缺失,则功能关闭。现在应该可以安全地假设,如果选项缺失,则应该开启。这将捕获任何未正确使用配置的代码片段!这将让您移除功能标志,并允许您移除旧版本的功能以及任何检查标志的代码,完成迁移。
重构 Jeeves
检查 Jeeves 以查看哪些方面可以作为微服务进行改进,我们可能会发现一些外部查询正在减慢我们的响应速度或使用过多的资源。
然而,我们也发现架构有一个更根本的改变。响应收到的消息纯粹是为了 Slack 基础设施的利益,因为用户看不到那条消息。向 Slack 发送消息与接收消息是独立的,所以这两个元素可以是独立的服务。而不是一个单体应用,我们可以有一个简单的微服务,它只接受收到的消息,并将它们适当地路由到其他执行用户请求的操作的微服务。然后这些服务都可以联系一个专门向 Slack 发送消息的微服务。
其中一些服务将需要联系数据库,如果我们保持当前的数据库架构,那么每个新的微服务都需要数据库模型。这是一个紧密耦合的设计,意味着任何数据库模式的更改都需要在这些所有新的服务中重复,并且部署管理以确保旧版本和新版本不会同时运行。为了防止这种情况,我们可以将我们的数据库转换为其自己的微服务,并设置它来回答我们知道它会得到的问题。
没有其他服务需要知道数据的内部结构,因为它只需要知道在哪里询问,并且答案总是以相同的方式结构化——或者通过数据中的版本标记明显地表明应该以不同的方式读取。
图 5.3:我们新的微服务架构;为了简化,Celery 工作进程被省略了
这还有一个额外的优点:所有这些微服务都可以被任何其他工具使用。我们可以有一个接收电子邮件或通过 Signal 和 Telegram 接收消息的服务,或者读取一个 IRC 频道,每个这样的服务都可以解析和理解收到的消息,打包一些如何回复的指令,并将它们发送到正确的服务去执行操作。
使用微服务架构版本,我们可以快速响应组织的需要,开始控制服务,同时以一致的方式处理数据,并允许人们在如何进行自动化请求和接收结果通知方面有灵活性。
让我们更详细地看看工作流程。
工作流程
从 Slack 的角度来看,一切看起来都一样。当用户输入一条消息时,我们配置的 URL 会发送一些 JSON 格式的信息。这些数据被我们的 Slack 请求 API 接收,所有 Slack 消息处理都发生在这里,我们选择正确的微服务作为目的地。我们还构建了一个可以包含有关发送回复位置的信息的数据结构,这个回复将作为我们消息的封皮。动作处理服务不需要理解它,但向 Slack 发布回复的工具需要理解它——在未来,可以通过在元数据中添加自己的信息来添加其他回复方式。
如果我们的 Slack 请求服务随后向微服务发起网络请求,我们必须等待其响应,考虑到它需要等待所有调用的响应时间。这可能会使我们的 API 非常慢;其容错性也较差,因为如果组件出现问题,整个链条就会崩溃,信息就会丢失。
图 5.4:消息如何穿越新的微服务架构
幸运的是,我们有一个消息队列!我们不需要直接按顺序调用每个步骤,我们可以将消息传递给 RabbitMQ 并立即向 Slack 的基础设施返回适当的状态码。它将接受这些消息并确保它们被传递到可以执行我们需要的操作的工人那里。
如果我们的某个工人出现故障,消息将排队并保留在那里,直到我们恢复在线——除非我们告诉它们在一段时间后过期。
一旦创建了回复,我们就可以再次使用 RabbitMQ 并向 Slack 发布服务发送消息。我们使用消息队列获得的可靠性改进与我们对传入消息的改进相同,但现在它们在出现任何故障时更加具有弹性。
摘要
在本章中,我们讨论了如何检查单体服务并确定哪些组件应该转换为微服务,以及我们应该收集哪些指标以便我们能够对服务的运行健康和容量有一个良好的理解。
这个拆分过程应该是保守和迭代的,否则很容易最终得到一个系统,其中构建和维护微服务的开销超过了拆分应用程序的好处。
然而,我们已经从单一的应用程序转变为许多需要相互交互的应用程序。图 5.4 中的每个链接都可能成为你应用程序的弱点。例如,如果 RabbitMQ 崩溃,或者消息处理程序和 Slack 发布服务之间存在网络分割,会发生什么?我们还需要考虑我们的应用程序对外部请求的响应速度,这样如果调用者不需要等待响应,他们就不必等待。
对于我们架构中添加的每个新的网络链接,同样的问题也存在。当出现问题的时候,我们需要有弹性。我们需要知道当某个服务恢复在线时,我们身处何处以及应该做什么。
所有这些问题都在下一章中得到解决。
第六章:与其他服务交互
在上一章中,我们的单体应用被拆分为几个微服务,因此,不同部分之间的网络交互也相应增加。
与其他组件的更多交互可能导致其自身的复杂性,例如,大量消息或大数据量延迟响应,或者长时间运行的任务占用宝贵资源。由于我们许多有用的任务涉及与第三方服务的交互,因此管理这些变化的技术对我们应用程序内部和外部通信都很有用。能够使用一些异步消息松散耦合系统的不同部分,有助于防止阻塞和不受欢迎的依赖纠缠。
无论如何,底线是我们需要通过网络与其他服务进行交互,无论是同步还是异步。这些交互需要高效,当出现问题时,我们需要有一个计划。
通过增加更多的网络连接引入的另一个问题是测试:如何测试一个需要调用其他微服务才能正常工作的独立微服务?在本章中,我们将详细探讨这个问题:
-
如何使用同步和异步库调用另一个服务,以及如何使这些调用更加高效
-
服务如何使用消息进行异步调用,并通过事件与其他服务进行通信
-
我们还将看到一些测试具有网络依赖服务的技巧
调用其他网络资源
正如我们在前几章所看到的,微服务之间的同步交互可以通过使用 JSON 有效载荷的 HTTP API 来实现。这无疑是使用最频繁的模式,因为 HTTP 和 JSON 都是常见的标准。如果你的网络服务实现了接受 JSON 的 HTTP API,任何使用任何编程语言的开发者都将能够使用它。大多数这些接口也是 RESTful 的,这意味着它们遵循表示状态转移(REST)架构原则,即无状态——每个交互都包含所需的所有信息,而不是依赖于之前的交换——以及可缓存和具有良好定义的接口。
虽然遵循 RESTful 方案不是强制性的,但一些项目实现了远程过程调用(RPC)API,这些 API 专注于正在执行的操作,并从处理消息的代码中抽象出网络请求。在 REST 中,重点是资源,操作由 HTTP 方法定义。一些项目是两者的混合,并不严格遵循某个特定标准。最重要的是,你的服务行为应该是连贯且文档齐全的。本书倾向于使用 REST 而不是 RPC,但并不严格,并认识到不同情况有不同的解决方案。
发送和接收 JSON 有效负载是微服务与其他服务交互的最简单方式,只需要微服务知道入口点和通过 HTTP 请求传递的参数。
要做到这一点,你只需要使用一个 HTTP 客户端。Python 在 http.client
模块中提供了一个,在同步 Python 环境中,Requests
库非常受欢迎:docs.python-requests.org
。
由于我们处于异步环境中,我们将使用 aiohttp
,它有一个创建异步 Web 请求的清晰方式,并提供了内置功能,使得执行多个同时进行的异步请求变得更容易:docs.aiohttp.org/en/stable/
。
aiohttp
库中的 HTTP 请求是围绕会话的概念构建的,最佳的使用方式是调用 CreateSession
,创建一个每次与任何服务交互时都可以重用的 Session
对象。
Session
对象可以保存认证信息和一些你可能想要为所有请求设置的默认头信息。它还可以控制默认的错误处理行为,存储 cookies,以及使用哪些超时。在下面的示例中,对 ClientSession
的调用将创建一个具有正确 Content-Type
头部的对象:
# clientsession.py
import asyncio
import aiohttp
async def make_request(url):
headers = {
"Content-Type": "application/json",
}
async with aiohttp.ClientSession(headers=headers) as session:
async with session.get(url) as response:
print(await response.text())
url = "http://localhost:5000/api"
loop = asyncio.get_event_loop()
loop.run_until_complete(make_request(url))
如果我们应该限制对外部端点发出的并发请求数量,有两种主要方法。aiohttp
有一个连接器的概念,我们可以设置选项来控制一个 session
一次可以操作多少个出站 TCP 连接,以及限制单个目标的数量:
conn = aiohttp.TCPConnector(limit=300, limit_per_host=10)
session = aiohttp.ClientSession(connector=conn)
这可能已经足够满足我们的需求;然而,如果我们为了完成一个请求而建立多个出站连接,我们可能会陷入一种情况,即每完成一项工作后,由于达到限制,下一项工作会持续阻塞。理想情况下,我们希望一个独立的工作块能够持续进行,直到完成,为此我们可以使用信号量。信号量是一个简单的令牌,它允许代码执行任务。如果我们添加一个有三个槽位的信号量,那么前三个尝试访问信号量的任务将各自占用一个槽位并继续执行。任何其他请求信号量的任务都必须等待直到其中一个槽位空闲。
由于请求信号量最常见的方式是在 with
块内部,这意味着一旦 with
块的上下文结束,信号量就会被释放——在信号量对象的 __exit__
函数内部:
# clientsession_list.py
import asyncio
import aiohttp
async def make_request(url, session, semaphore):
async with semaphore, session.get(url) as response:
print(f"Fetching {url}")
await asyncio.sleep(1) # Pretend there is real work happening
return await response.text()
async def organise_requests(url_list):
semaphore = asyncio.Semaphore(3)
tasks = list()
async with aiohttp.ClientSession() as session:
for url in url_list:
tasks.append(make_request(url, session, semaphore))
await asyncio.gather(*tasks)
urls = [
"https://www.google.com",
"https://developer.mozilla.org/en-US/",
"https://www.packtpub.com/",
"https://aws.amazon.com/",
]
loop = asyncio.get_event_loop()
loop.run_until_complete(organise_requests(urls))
让我们看看我们如何在需要与其他服务交互的 Quart 应用程序中泛化这种模式。
这种简单的实现基于一切都会顺利进行的假设,但现实生活很少如此简单。我们可以在 ClientSession
中设置不同的错误处理选项,如重试和超时,我们只需要在那个地方设置即可。
寻找去往何方
当我们向一个服务发出 Web 请求时,我们需要知道要使用哪个统一资源定位符(URL)。本书中的大多数示例都使用硬编码的 URL——也就是说,它们被写入源代码。这对于示例来说很方便,易于阅读,但在维护软件时可能会出现问题。当服务获得新的 URI,其主机名或 IP 地址发生变化时会发生什么?它可能会因为故障而在 AWS 区域之间移动,或者从 Google Cloud Platform 迁移到 Microsoft Azure。即使主机名或 IP 地址没有更新,API 更新也可能使资源路径发生变化。
我们希望将有关要使用的 URL 作为配置的数据传递给我们的应用程序。有几种选项可以管理更多的配置选项,而无需直接将它们添加到代码中,例如环境变量和服务发现。
环境变量
基于容器的环境现在很常见,我们将在第十章“在 AWS 上部署”中更详细地讨论它们。将配置选项传递到容器中最常见的方法是向容器传递一些环境变量。这有一个优点,即简单直接,因为代码在处理其配置时只需要检查环境:
import os
def create_app(name=__name__, blueprints=None, settings=None):
app = Quart(name)
app.config["REMOTE_URL"] = os.environ.get("OTHER_SERVICE_URL", "https://default.url/here")
这种方法的缺点是,如果 URL 发生变化,那么我们需要重新启动应用程序,有时甚至需要使用新环境重新部署它。如果你不期望配置经常改变,由于它们的简单性,环境变量仍然是一个好主意,尽管我们必须小心不要在记录消息时记录任何包含在环境变量中的秘密。
服务发现
但如果我们部署服务时不需要告诉它所有选项怎么办?服务发现是一种涉及仅用少量信息配置应用程序的方法:在哪里请求配置以及如何识别正确的提问方式。
例如,etcd
(etcd.io/
)等服务提供了一个可靠的关键值存储,用于保存这些配置数据。例如,让我们使用etcd
来存储生产环境和开发环境 RabbitMQ 实例的 URL:
$ etcdctl put myservice/production/rabbitmq/url https://my.rabbitmq.url/
OK
$ etcdctl get myservice/production/rabbitmq/url
myservice/production/rabbitmq/url
https://my.rabbitmq.url/
当应用程序启动时,它可以检查它是否在生产环境中运行或在本地开发环境中运行,并请求etcd
的正确值——无论是myservice/production/rabbitmq/url
还是myservice/development/rabbitmq/url
。在部署中有一个单一选项,可以更改大量配置选项,使用不同的外部 URL,绑定到不同的端口,或你可能想到的任何其他配置。
还可以更新etcd
中的值,当你的应用程序下次检查新值时,它将更新并使用该值。现在可以在旧版本旁边部署RabbitMQ
的新版本,交换将是etcd
中的值变化——或者如果出错,将是一个回退变化。
这种方法确实增加了复杂性,既作为额外服务运行,也涉及到在您的应用程序中更新这些值,但在更动态的环境中,这可以是一种有价值的方法。我们将在第十章部署在 AWS中更详细地讨论服务发现,当我们介绍在容器和云中部署应用程序时。
数据传输
JSON 是一种可读的数据格式。互联网上有着悠久的人可读数据传输历史——一个很好的例子是电子邮件,因为你可以很愉快地以人类作者的身份键入发送电子邮件所需的协议。这种可读性对于确定代码及其连接中正在发生的事情非常有用,尤其是因为 JSON 直接映射到 Python 数据结构。
这种可读性的缺点是数据的大小。长期来看,发送带有 JSON 有效负载的 HTTP 请求和响应可能会增加一些带宽开销,而且将 Python 对象序列化为 JSON 结构以及反序列化也会增加一些 CPU 开销。
然而,还有其他涉及缓存、压缩、二进制有效负载或 RPC 的数据传输方式。
HTTP 缓存头部
在 HTTP 协议中,有一些缓存机制可以用来向客户端指示它试图获取的页面自上次访问以来没有变化。缓存是我们可以在我们的微服务中的所有只读 API 端点上执行的操作,例如GETs
和HEADs
。
实现它的最简单方法是在响应中返回结果的同时,返回一个 ETag 头部。ETag
值是一个字符串,可以被认为是客户端试图获取的资源的一个版本。它可以是时间戳、增量版本或哈希。由服务器决定在其中放置什么,但理念是它应该对响应值是唯一的。
与网络浏览器类似,当客户端获取包含此类头部的响应时,它可以构建一个本地字典缓存,将响应体和ETags
作为其值存储,将 URL 作为其键。
当发起一个新的请求时,客户端可以查看其本地缓存,并在If-Modified-Since
头部中传递一个存储的ETag
值。如果服务器返回304
状态码,这意味着响应没有变化,客户端可以使用之前存储的那个。
这种机制可以大大减少服务器的响应时间,因为它可以在内容没有变化时立即返回一个空的304
响应。如果内容已更改,客户端将以通常的方式收到完整消息。
当然,这意味着你调用的服务应该通过添加适当的ETag
支持来实现这种缓存行为。由于缓存逻辑取决于你服务管理的数据的性质,因此不可能实现一个通用的解决方案。一般规则是,为每个资源进行版本控制,并在数据更改时更改该版本。在下面的示例中,Quart 应用使用当前服务器时间来创建与用户条目关联的ETag
值。ETag
值是自纪元以来的当前时间,以毫秒为单位,并存储在修改字段中。
get_user()
方法从_USERS
返回一个用户条目,并使用response.set_etag
设置ETag
值。当视图接收到一些调用时,它也会查找If-None-Match
头,将其与用户的修改字段进行比较,如果匹配则返回304
响应:
# quart_etag.py
from datetime import datetime
from quart import Quart, Response, abort, jsonify, request
app = Quart(__name__)
def _time2etag():
return datetime.now().isoformat()
_USERS = {"1": {"name": "Simon", "modified": _time2etag()}}
@app.route("/api/user/<user_id>")
async def get_user(user_id):
if user_id not in _USERS:
return abort(404)
user = _USERS[user_id]
# returning 304 if If-None-Match matches
if user["modified"] in request.if_none_match:
return Response("Not modified", status=304)
resp = jsonify(user)
# setting the ETag
resp.set_etag(user["modified"])
return resp
if __name__ == "__main__":
app.run()
change_user()
视图在客户端修改用户时设置一个新的修改值。在以下客户端会话中,我们正在更改用户,同时确保在提供新的ETag
值时获得304
响应:
$ curl -v http://127.0.0.1:5000/api/user/1
* Trying 127.0.0.1...
...
< HTTP/1.1 200
< content-type: application/json
< content-length: 56
< etag: "2021-06-29T21:32:25.685907"
< date: Tue, 29 Jun 2021 20:32:30 GMT
< server: hypercorn-h11
<
* Connection #0 to host 127.0.0.1 left intact
{"modified":"2021-06-29T21:32:25.685907","name":"Simon"}
$ curl -v -H 'If-None-Match: 2021-06-29T21:32:25.685907' http://127.0.0.1:5000/api/user/1
...
< HTTP/1.1 304
...
这个演示是一个玩具实现,可能在生产环境中工作得不好;依赖于服务器时钟来存储ETag
值意味着你确信时钟永远不会倒退,并且如果你有多个服务器,它们的时钟都通过一个服务(如 ntpdate)与该服务同步。
如果两个请求在相同毫秒内更改相同的条目,也存在竞争条件的问题。根据你的应用,这可能不是问题,但如果它是,那么它可能是一个大问题。一个更干净的选择是让数据库系统直接处理修改字段,并确保其更改是在序列化事务中完成的。使用POST
请求发送ETag
也是防止并发更新之间竞争的好预防措施——服务器可以使用ETag
来验证客户端想要更新的数据版本,如果该版本不匹配,那么更新数据可能是不安全的,因为其他人可能已经先更改了它。
一些开发者使用哈希函数来计算他们的ETag
值,因为在分布式架构中计算简单,而且不会引入时间戳可能带来的任何问题。但是计算哈希值需要 CPU 成本,这意味着你需要拉取整个条目来执行它——所以它可能和发送实际数据一样慢。话虽如此,如果你在数据库中有一个用于所有哈希值的专用表,你可能会想出一个解决方案,使得你的304
响应在返回时更快。
正如我们之前所说的,没有通用的解决方案来实现高效的 HTTP 缓存逻辑——但如果你的客户端在你的服务上做了很多读取操作,那么实现一个缓存机制是值得的。当你别无选择,只能发送一些数据时,有几种方法可以使它尽可能高效,我们将在下一节中看到。
GZIP 压缩
压缩是一个总称,指的是以这种方式减小数据的大小,以便可以恢复原始数据。有许多不同的压缩算法——其中一些是通用算法,可以在任何类型的数据上使用,而另一些则是针对特定数据格式进行优化的,由于它们对数据的结构做出了假设,因此可以实现非常好的结果。
在压缩数据的大小、压缩和解压缩的速度以及压缩算法的普及程度之间需要做出权衡。如果大部分时间数据被存储,那么花几分钟压缩一个大的数据文件可能是可以接受的,因为节省的空间超过了访问时间所付出的代价,但对于短暂存在或经常访问的数据,压缩和解压缩的开销则更为重要。就我们的目的而言,我们需要一个在不同环境中被广泛理解的压缩算法,即使它并不总是实现最小的最终结果。
GZIP 压缩几乎在所有系统中都可用,并且像 Apache 或 nginx 这样的 Web 服务器为通过它们的响应提供了原生支持,这比在 Python 级别实现自己的临时压缩要好得多。重要的是要记住,虽然这会节省网络带宽,但它会使用更多的 CPU,因此通过激活指标收集进行实验将让我们看到结果——并决定这个选项是否是一个好主意。
例如,这个 nginx 配置将启用端口5000
上 Quart 应用程序产生的任何响应的 GZIP 压缩,内容类型为application/json
:
http {
gzip on;
gzip_types application/json;
gzip_proxied any;
gzip_vary on;
server {
listen 80;
server_name localhost;
location / {
proxy_pass http://localhost:5000;
}
}
从客户端来看,向localhost:8080
上的 nginx 服务器发送 HTTP 请求,通过带有Accept-Encoding: gzip
头的代理为localhost:5000
上的应用程序触发压缩:
$ curl http://localhost:8080/api -H "Accept-Encoding: gzip"
<some binary output>
在 Python 中,使用aiohttp
和requests
库发出的请求将自动解压缩 GZIP 编码的响应,因此当你的服务调用另一个服务时,你不必担心这一点。
解压缩数据会增加一些处理,但 Python 的 GZIP 模块依赖于zlib
(http://www.zlib.net/
),它非常快。为了接受压缩的 HTTP 查询响应,我们只需要添加一个头信息,表明我们可以处理 GZIP 编码的响应:
import asyncio
import aiohttp
async def make_request():
url = "http://127.0.0.1:5000/api"
headers = {
"Accept-Encoding": "gzip",
}
async with aiohttp.ClientSession(headers=headers) as session:
async with session.get(url) as response:
print(await response.text())
loop = asyncio.get_event_loop()
loop.run_until_complete(make_request())
要压缩发送到服务器的数据,你可以使用gzip
模块并指定一个Content-Encoding
头信息:
import asyncio
import gzip
import json
import aiohttp
async def make_request():
url = "http://127.0.0.1:8080/api_post"
headers = {
"Content-Encoding": "gzip",
}
data = {"Hello": "World!", "result": "OK"}
data = bytes(json.dumps(data), "utf8")
data = gzip.compress(data)
async with aiohttp.ClientSession(headers=headers) as session:
async with session.post(url, data=data) as response:
print(await response.text())
loop = asyncio.get_event_loop()
loop.run_until_complete(make_request())
然而,在这种情况下,你将在 Quart 应用程序中获得压缩内容,你需要在 Python 代码中对其进行解压缩,或者如果你使用的是处理传入 Web 连接的 nginx 代理,nginx 可以为你解压缩请求。我们将在第十章“在 AWS 上部署”中更详细地讨论 nginx。总结来说,使用 nginx 为所有服务响应设置 GZIP 压缩是一个低成本的更改,你的 Python 客户端可以通过设置正确的头信息从中受益。然而,发送压缩数据要复杂一些,因为这项工作并不是为你完成的——但它可能对大量数据传输仍然有益。
如果你想要进一步减小 HTTP 请求/响应负载的大小,另一个选项是将从 JSON 切换到二进制负载。这样,你就不必处理压缩,处理数据可能更快,但消息大小的减少并不那么显著。
协议缓冲区
虽然通常情况下并不相关,但如果你的微服务处理大量数据,使用替代格式可以是一个吸引人的选项,以提高性能,并减少所需的网络带宽,而无需使用额外的处理能力和时间来压缩和解压缩数据。两种广泛使用的二进制格式是协议缓冲区(protobuf)(developers.google.com/protocol-buffers
)和MessagePack。
协议缓冲区要求你描述正在交换的数据,以便将其索引到某个将用于索引二进制内容的模式中。这些模式增加了一些工作量,因为所有传输的数据都需要在模式中描述,你将需要学习一种新的领域特定语言(DSL)。在像 Rust、C++或 Go 这样的类型语言中,定义这些结构已经是必须完成的任务,因此开销要小得多。
然而,其优势在于消息定义良好,在网络对话的任一端尝试使用信息之前,可以轻松验证。还可能为各种语言生成代码,包括 Python,让你以更适合所用语言的方式构造数据。以下示例取自 protobuf 文档:
syntax = "proto2";
package tutorial;
message Person {
required string name = 1;
required int32 id = 2;
optional string email = 3;
enum PhoneType {
MOBILE = 0;
HOME = 1;
WORK = 2;
}
message PhoneNumber {
required string number = 1;
optional PhoneType type = 2 [default = HOME];
}
repeated PhoneNumber phones = 4;
}
message AddressBook {
repeated Person people = 1;
}
该模式并不非常符合 Python 风格,因为它旨在支持多种语言和环境。如果你与静态类型语言交互或希望有一个功能为你对数据进行基本语法检查,那么这样的定义可能很有帮助。
使用 gRPC 框架(grpc.io/
)与协议缓冲区结合可以抽象出你的应用程序的网络交互,并为客户端提供一个 Python 中的函数调用,几乎不需要考虑它如何生成返回值。
MessagePack
与 Protocol Buffers 不同,MessagePack (msgpack.org/
)是无模式的,只需调用一个函数就可以序列化你的数据。它是 JSON 的简单替代品,并在大多数语言中有实现。msgpack
Python 库(使用pip install
msgpack-python
命令安装)提供了与 JSON 相同级别的集成:
>>> import msgpack
>>> data = {"this": "is", "some": "data"}
>>> msgpack.packb(data, use_bin_type=True)
b'\x82\xa4this\xa2is\xa4some\xa4data'
>>> msgpack.unpackb(msgpack.packb(data, use_bin_type=True))
{'this': 'is', 'some': 'data'}
与 protobuf 相比,使用 MessagePack 很简单,但哪个更快,提供最佳的压缩比率,很大程度上取决于你的数据。在少数情况下,纯 JSON 可能比二进制格式序列化得更快。
在压缩方面,你可以期望使用 MessagePack 有 10%到 20%的压缩率,但如果你的 JSON 包含大量字符串——这在微服务中很常见——GZIP 将表现得更好。
在以下示例中,一个包含大量字符串的 48 KB 的巨大 JSON 有效负载被使用 MessagePack 和 JSON 进行转换,然后在两种情况下都进行了 GZIP 压缩:
>>> sys.getsizeof(json.dumps(data))
35602
>>> sys.getsizeof(msgpack.packb(data))
30777
>>> sys.getsizeof(gzip.compress(bytes(json.dumps(data), 'utf8')))
3138
>>> sys.getsizeof(gzip.compress(msgpack.packb(data)))
3174
使用 MessagePack 可以将有效负载的大小减少大约 14%,但 GZIP 将 JSON 和 MessagePack 有效负载的大小减少到原来的 1/11!
很明显,无论你使用什么格式,最佳方式是使用 GZIP 来减少有效负载大小——如果你的 Web 服务器不处理解压缩,那么在 Python 中通过gzip.uncompress()
进行解压缩是直接的。
消息序列化通常只支持基本数据类型,因为它们必须对源和目标环境中的环境保持无知。这意味着它们不能编码在 Python 中可能常用的数据,例如使用datetime
对象表示时间。虽然其他语言有日期和时间表示,但它们的方式并不相同,因此像这样的数据和其他 Python 对象需要转换为其他平台可以理解的可序列化形式。对于日期和时间,常见的选项包括表示纪元时间的整数(自 1970 年 1 月 1 日起的秒数)或 ISO8601 格式的字符串,例如 2021-03-01T13:31:03+00:00。
在任何情况下,在一个以 JSON 为最接受标准的微服务世界中,处理日期只是坚持一个普遍采用的标准的小烦恼。
除非所有你的服务都在 Python 中并且具有明确的结构,并且你需要尽可能快地加快序列化步骤,否则坚持使用 JSON 可能更简单。
整合起来
在继续之前,我们将快速回顾一下到目前为止我们已经覆盖了什么:
-
实现 HTTP 缓存头是一个加快对数据重复请求的好方法。
-
GZIP 压缩是一种有效的方法来减少请求和响应的大小,并且很容易设置
-
二进制协议是纯 JSON 的有吸引力的替代品,但这取决于具体情况
下一节将重点介绍异步调用;你的微服务可以做的所有超出请求/响应模式的事情。
异步消息
在微服务架构中,当原本在一个单一应用程序中执行的过程现在涉及到多个微服务时,异步调用扮演着基本角色。我们在上一章中简要提到了这一点,通过我们对 Jeeves 应用程序的更改,现在它通过异步消息队列与其工作进程进行通信。为了充分利用这些工具,我们将更深入地研究这些工具。
异步调用可以像微服务应用程序中的一个单独的线程或进程那样简单,它接收一些要执行的工作,并在不干扰同时发生的 HTTP 请求/响应往返过程中执行它。
但直接从同一个 Python 进程中做所有事情并不是非常健壮。如果进程崩溃并重新启动会发生什么?如果它们是这样构建的,我们如何扩展后台任务?
发送一条被另一个程序接收的消息要可靠得多,让微服务专注于其核心目标,即向客户端提供服务。如果一个网络请求不需要立即回答,那么我们服务中的一个端点可以成为接受 HTTP 请求、处理它并将其传递出去的代码,而其对客户端的响应现在是我们的服务是否已成功接收请求,而不是请求是否已被处理。
在上一章中,我们探讨了如何使用 Celery 来构建一个从类似 RabbitMQ 的消息代理那里获取一些工作的微服务。在那个设计中,Celery 工作进程会阻塞——也就是说,它在等待新消息添加到 RabbitMQ 队列时会停止操作。
消息队列可靠性
与任何分布式系统一样,在可靠性和一致性方面都需要考虑。理想情况下,我们希望将一条消息添加到队列中,并确保它被准确无误地投递并执行——恰好一次。在实践中,在分布式系统中几乎不可能实现这一点,因为组件可能会失败,经历高延迟或数据包丢失,同时发生各种复杂的交互。
我们有两个实际的选择,这些选择编码在 RabbitMQ 的投递策略中:“最多一次”和“至少一次”。
一种最多一次投递消息的策略不会考虑消息投递系统中的任何不可靠性或工作进程中的失败。一旦工作进程接受了一条消息,那就结束了:消息队列会忘记它。如果工作进程随后发生故障并且没有完成分配给它的任务部分,这是整个系统需要应对的问题。
有一个承诺至少发送一次消息,在出现任何失败的情况下,交付将再次尝试,直到工作者接受消息并确认它已采取行动。这确保了不会丢失任何数据,但这确实意味着在某些情况下,消息可以发送给多个工作者,因此某种全局唯一标识符(UUID)是一个好主意,这样虽然一些工作可能会重复,但在写入任何数据库或存储时可以进行去重。关于分布式系统可靠性和像 PAXOS 这样的共识协议的更广泛讨论将需要一本自己的书。
基本队列
Celery 工作者使用的模式是推拉任务队列。一个服务将消息推入特定的队列,一些工作者从另一端取走它们并对其执行操作。每个任务都只去一个工作者。考虑以下图示,如图 6.1所示。
图 6.1:任务通过消息队列传递
没有双向通信——发送者只是在队列中存入一条消息然后离开。下一个可用的工作者获取下一条消息。当你想要执行一些异步并行任务时,这种盲目单向的消息传递是完美的,这使得它很容易进行扩展。
此外,一旦发送者确认消息已添加到代理,我们就可以让消息代理,如 RabbitMQ,提供一些消息持久化。换句话说,如果所有工作者都离线,我们不会丢失队列中的消息。
主题交换机和队列
主题是一种过滤和分类通过队列传输的消息的方式。当使用主题时,每条消息都会附带一个额外的标签,有助于识别其类型,我们的工作者可以订阅特定的主题或匹配多个主题的模式。
让我们设想一个场景,我们正在将移动应用到 Android Play 商店和 Apple App 商店发布。当我们的自动化任务完成 Android 应用的构建后,我们可以发送一个带有路由键publish.playstore
的消息,这样 RabbitMQ 就可以将这条消息路由到正确的主题。路由键和主题之间有区别的原因是主题可以匹配模式。能够将文件发布到 Play Store 的工作者可以订阅publish.playstore
主题,并从这些消息中获取其工作量,但我们也可以有一个匹配publish.*
的消息队列和一个工作者,每当有内容即将上传到 Play Store、App Store 或其他可能发布软件的地方时,它会发送通知。
在我们的微服务中,这意味着我们可以拥有专门的工作者,它们都注册到同一个消息代理,并获取添加到其中的消息的子集。
图 6.2:不同类型的任务通过消息队列传递
这种行为在大多数消息队列服务中都有,形式略有不同。让我们看看如何在 RabbitMQ 中设置这个。
要安装RabbitMQ代理,你可以查看www.rabbitmq.com/download.html
的下载页面。
运行容器应该足以进行任何本地实验。RabbitMQ 实现了高级消息队列协议(AMQP)。该协议由一个合作工作的公司团体开发多年,描述在www.amqp.org/
。
AMQP 组织成三个概念:队列、交换机和绑定:
-
队列是一个持有消息并等待消费者取走的接收者
-
交换机是发布者向系统中添加新消息的入口点
-
绑定定义了消息如何从交换机路由到队列
对于我们的主题队列,我们需要设置一个交换机,这样 RabbitMQ 才能接受新消息,以及所有我们希望工作者从中选择消息的队列。在这两个端点之间,我们希望根据主题使用绑定将消息路由到不同的队列。
让我们看看我们如何设置之前提到的应用发布示例。我们将假设我们有两个工作者:一个发布 Android 应用,另一个发送通知,例如更新网站或发送电子邮件。使用与 RabbitMQ 一起安装的rabbitmqadmin
命令行,我们可以创建所有必要的部分。如果管理命令没有安装,你可以在www.rabbitmq.com/management-cli.html
找到安装说明:
$ rabbitmqadmin declare exchange name=incoming type=topic
exchange declared
$ rabbitmqadmin declare queue name=playstore
queue declared
$ rabbitmqadmin declare queue name=notifications
queue declared
$ rabbitmqadmin declare binding source="incoming" destination_type="queue" destination="playstore" routing_key="publish.playstore"
binding declared
$ rabbitmqadmin declare binding source="incoming" destination_type="queue" destination="notifications" routing_key="publish.*"
binding declared
在这个配置中,每当有消息发送到 RabbitMQ——如果主题以publish
开头——它将被发送到通知队列;如果是publish.playstore
,那么它将同时进入通知和 playstore 队列。任何其他主题都将导致消息被丢弃。
要在代码中与 RabbitMQ 交互,我们可以使用Pika。这是一个 Python RPC 客户端,它实现了 Rabbit 服务发布的所有 RPC 端点:pika.readthedocs.io
。
我们使用 Pika 做的所有事情都可以使用rabbitmqadmin
在命令行上完成。你可以直接获取系统所有部分的状态,发送和接收消息,并检查队列中的内容。这是实验你的消息设置的一个极好方式。
以下脚本展示了如何在 RabbitMQ 的入站交换机中发布两条消息。一条是关于新应用发布的,另一条是关于通讯稿的:
from pika import BlockingConnection, BasicProperties
# assuming there's a working local RabbitMQ server with a working # guest/guest account
def message(topic, message):
connection = BlockingConnection()
try:
channel = connection.channel()
props = BasicProperties(content_type="text/plain", delivery_mode=1)
channel.basic_publish("incoming", topic, message, props)
finally:
connection.close()
message("publish.playstore", "We are publishing an Android App!")
message("publish.newsletter", "We are publishing a newsletter!")
这些 RPC 调用将为每个入站主题交换机添加一条消息。对于第一条消息,交换机将为playstore
队列添加一条消息,对于第二条,将添加两条消息——每条消息到一个队列。一个等待需要发布到 Play Store 的工作的工作者脚本可能看起来像这样:
import pika
def on_message(channel, method_frame, header_frame, body):
print(f"Now publishing to the play store: {body}!")
channel.basic_ack(delivery_tag=method_frame.delivery_tag)
connection = pika.BlockingConnection()
channel = connection.channel()
channel.basic_consume("playstore", on_message)
try:
channel.start_consuming()
except KeyboardInterrupt:
channel.stop_consuming()
connection.close()
注意,Pika 会将一个 ACK 发送回 RabbitMQ 关于该消息,因此一旦工人成功处理,就可以安全地从队列中移除。这是至少一次消息传递策略。notifications
接收器除了它订阅的队列和它对消息体的处理外,可以相同:
$ python ./playstore_receiver.py
Now publishing to the play store: b'We are publishing an Android App!'!
$ python ./publish_receiver.py
We have some news! b'We are publishing an Android App!'!
We have some news! b'We are publishing a newsletter!'!
AMQP 提供了许多可以调查的消息交换模式。教程页面有许多示例,它们都是使用 Python 和 Pika 实现的:www.rabbitmq.com/getstarted.html
。
要将以下示例集成到我们的微服务中,发布阶段是直接的。您的 Quart 应用程序可以使用pika.BlockingConnection
创建到 RabbitMQ 的连接并通过它发送消息。例如,pika-pool (github.com/bninja/pika-pool
)实现了简单的连接池,这样您就可以在发送 RPC 时无需每次都连接/断开 RabbitMQ 通道。
另一方面,消费者更难集成到微服务中。Pika 可以嵌入到与 Quart 应用程序在同一进程中运行的事件循环中,并在接收到消息时触发一个函数。它将仅仅是进入相同代码的另一个入口点,如果需要,也可以与 RESTful API 并行运行。
发布/订阅
之前的模式有处理特定消息主题的工人,工人消费的消息将完全从队列中消失。我们甚至添加了代码来确认消息已被消费。
然而,当你想要将消息发布到多个工人时,必须使用发布/订阅(pubsub)模式。
这种模式是构建通用事件系统的基础,其实现方式与之前完全相同,其中有一个交换机和几个队列。区别在于交换部分具有扇出类型。
在这种设置中,每个绑定到扇出交换机的队列都将接收到相同的信息。如果有必要,通过 pubsub 可以广播消息到所有的微服务。
整合
在本节中,我们介绍了以下关于异步消息传递的内容:
-
每当微服务可以执行一些非阻塞工作的时候,都应该使用非阻塞调用。如果你所做的工作在响应中没有被利用,就没有理由阻塞请求。
-
服务到服务的通信并不总是限于任务队列。
-
通过消息队列发送事件是防止组件紧密耦合的好方法。
-
我们可以在一个代理(如 RabbitMQ)周围构建一个完整的事件系统,使我们的微服务通过消息相互交互。
-
可以使用 RabbitMQ 来协调所有消息传递,使用 Pika 发送消息。
测试
如我们在第三章中学习的,编码、测试和文档:良性循环,为调用其他服务的服务编写功能测试时最大的挑战是隔离所有网络调用。在本节中,我们将看到如何模拟使用aiohttp
进行的异步调用。
测试aiohttp
及其出站 Web 请求需要与传统同步测试不同的方法。aioresponses
项目 (github.com/pnuckowski/aioresponses
)允许您轻松创建使用aiohttp
ClientSession
进行的 Web 请求的模拟响应:
# test_aiohttp_fixture.py
import asyncio
import aiohttp
import pytest
from aioresponses import aioresponses
@pytest.fixture
def mock_aioresponse():
with aioresponses() as m:
yield m
@pytest.mark.asyncio
async def test_ctx(mock_aioresponse):
async with aiohttp.ClientSession() as session:
mock_aioresponse.get("http://test.example.com", payload={"foo": "bar"})
resp = await session.get("http://test.example.com")
data = await resp.json()
assert {"foo": "bar"} == data
在这个例子中,我们告诉aioresponses
,对http://test.example.com
发出的任何 GET 请求都应该返回我们指定的数据。这样我们就可以轻松地为多个 URL 提供模拟响应,甚至可以通过多次调用mocked.get
为同一端点创建多个响应。
如果您使用 Requests 执行所有调用——或者您使用的是基于 Requests 的库,并且没有对其进行太多定制——由于requests-mock
项目 (requests-mock.readthedocs.io
),这项隔离工作也变得容易进行,该项目以类似的方式实现了模拟调用,并可能启发了aioresponses
。
话虽如此,模拟其他服务的响应仍然是一项相当多的工作,并且可能难以维护。这意味着需要关注其他服务随时间的发展,以确保您的测试不是基于不再反映真实 API 的模拟。
鼓励使用模拟来构建良好的功能测试覆盖率,但请确保您也在进行集成测试,在该测试中,服务在一个部署环境中被测试,它调用其他服务进行真实操作。
使用 OpenAPI
OpenAPI 规范 (www.openapis.org/
),之前被称为 Swagger,是描述一组 HTTP 端点、它们的使用方式以及发送和接收的数据结构的标准方式。通过使用 JSON 或 YAML 文件描述 API,它使得意图变得机器可读——这意味着有了 OpenAPI 规范,您可以使用代码生成器以您选择的语言生成客户端库,或者自动验证数据在进入或离开系统时的有效性。
OpenAPI 具有与 WSDL (www.w3.org/TR/2001/NOTE-wsdl-20010315
)在 XML 网络服务时代相同的目标,但它更轻量级,更直接。
以下是一个最小的 OpenAPI 描述文件示例,它定义了一个单一的/apis/users_ids
端点,并支持GET
方法来检索用户 ID 列表:
---
openapi: "3.0.0"
info:
title: Data Service
description: returns info about users
license:
name: APLv2
url: https://www.apache.org/licenses/LICENSE-2.0.html
version: 0.1.0
basePath: /api
paths:
/user_ids:
get:
operationId: getUserIds
description: Returns a list of ids
produces:
- application/json
responses:
'200':
description: List of Ids
schema:
type: array
items:
type: integer
完整的 OpenAPI 规范可以在 GitHub 上找到;它非常详细,并允许您描述有关 API、其端点和它使用的数据类型元数据:github.com/OAI/OpenAPI-Specification
。
模式部分中描述的数据类型遵循 JSON Schema 规范(json-schema.org/latest/json-schema-core.html
)。在这里,我们描述了 /get_ids
端点返回一个整数数组。
您可以在该规范中提供有关您的 API 的许多详细信息——例如,您的请求中应该包含哪些标题,或者某些响应的内容类型是什么,以及可以添加到其中的内容。
使用 OpenAPI 描述您的 HTTP 端点提供了许多优秀的机会:
-
有许多 OpenAPI 客户端可以消费您的描述并对其进行有用的操作,例如针对您的服务构建功能测试或验证发送给它的数据。
-
它为您的 API 提供了标准、语言无关的文档
-
服务器可以检查请求和响应是否符合规范
一些 Web 框架甚至使用规范来创建所有路由和 I/O 数据检查,用于您的微服务;例如,Connexion (github.com/zalando/connexion
) 为 Flask 做了这件事。在撰写本文时,Quart 对此的支持有限,但情况总是在不断改善。因此,我们在这里的示例中不会大量使用 OpenAPI。
当人们使用 OpenAPI 构建 HTTP API 时,有两种不同的观点:
-
规范优先,即您首先创建 Swagger 规范文件,然后在它之上创建您的应用程序,使用该规范中提供的信息。这就是 Connexion 的原理。
-
规范提取,即您的代码生成 Swagger 规范文件。一些工具包会通过读取您的视图文档字符串来完成此操作,例如。
摘要
在本章中,我们探讨了服务如何通过使用请求会话同步地与其他服务交互,以及通过使用 Celery 工作进程或基于 RabbitMQ 的更高级的消息模式异步交互。
我们还研究了通过模拟其他服务来单独测试服务的一些方法,但不需要模拟消息代理本身。
单独测试每个服务是有用的,但当出现问题时,很难知道发生了什么,尤其是如果错误发生在一系列异步调用中。
在那种情况下,使用集中式日志系统跟踪发生的事情非常有帮助。下一章将解释我们如何配置我们的微服务以跟踪其活动。
第七章:保护你的服务
到目前为止,这本书中所有服务之间的交互都没有进行任何形式的身份验证或授权;每个 HTTP 请求都会愉快地返回结果。但在实际生产中,这不可能发生,有两个简单的原因:我们需要知道谁在调用服务(身份验证),并且我们需要确保调用者有权执行调用(授权)。例如,我们可能不希望匿名调用者删除数据库中的条目。
在单体 Web 应用程序中,简单的身份验证可以通过登录表单实现,一旦用户被识别,就会设置一个带有会话标识符的 cookie,以便客户端和服务器可以在所有后续请求上协作。在基于微服务的架构中,我们不能在所有地方使用这种方案,因为服务不是用户,也不会使用 Web 表单进行身份验证。我们需要一种自动接受或拒绝服务之间调用的方式。
OAuth2 授权协议为我们提供了在微服务中添加身份验证和授权的灵活性,这可以用来验证用户和服务。在本章中,我们将了解 OAuth2 的基本特性和如何实现一个身份验证微服务。这个服务将被用来保护服务之间的交互。
在代码层面可以做一些事情来保护你的服务,例如控制系统调用,或者确保 HTTP 重定向不会结束在敌对网页上。我们将讨论如何添加对不良格式数据的保护,一些常见的陷阱以及如何扫描你的代码以发现潜在的安全问题。
最后,保护服务还意味着我们希望在恶意网络流量到达我们的应用程序之前将其过滤掉。我们将探讨设置基本 Web 应用程序防火墙来保护我们的服务。
OAuth2 协议
如果你正在阅读这本书,你很可能是那些使用用户名和密码登录网页的人。这是一个简单的模型来确认你是谁,但也有一些缺点。
许多不同的网站存在,每个网站都需要妥善处理某人的身份和密码。随着存储身份的地方增多,以及密码可以通过不同系统采取的路径增多,安全漏洞的可能性也会增加。这也使得攻击者更容易创建假冒网站,因为人们习惯于在多个可能略有不同的地方输入他们的用户名和密码。相反,你可能遇到过允许你“使用 Google”、“Microsoft”、“Facebook”或“GitHub”登录的网站。这个功能使用了 OAuth2,或者基于它的工具。
OAuth2 是一个广泛采用的标准,用于保护 Web 应用程序及其与用户和其他 Web 应用程序的交互。只有一个服务会被告知你的密码或多因素认证码,任何需要认证你的网站都会将你引导到那里。在这里我们将介绍两种认证类型,第一种是认证代码授权,它是由人类使用浏览器或移动应用程序发起的。
用户驱动的认证代码授权流程看起来很复杂,如图 7.1所示,但它发挥着重要的作用。按照图中的流程进行,当客户端请求一个资源——无论是网页还是某些数据,例如——他们必须登录才能查看时,应用程序会将302
重定向发送到认证服务。在那个 URL 中会有另一个地址,认证服务可以使用它将客户端送回应用程序。
一旦客户端连接,认证服务就会执行你可能预期的事情——它会要求用户名、密码和多重因素认证码,有些人甚至还会显示图片或文本来证明你访问的是正确的位置。登录正确后,认证服务将客户端重定向回应用程序,这次带有用于展示的令牌。
应用程序可以使用认证服务验证令牌,并记住该结果直到令牌过期,或者对于某些可配置的时间长度,偶尔重新检查以确保令牌没有被撤销。这样,应用程序就永远不需要处理用户名或密码,只需要学习足够的信息来唯一标识客户端。
图 7.1:OAuth2 认证流程
当为程序设置 OAuth2 以便使用,使一个服务能够连接到另一个服务时,有一个类似的过程称为客户端凭证授权(CCG),其中服务可以连接到认证微服务并请求一个它可以使用的令牌。你可以参考 OAuth2 授权框架中第 4.4 节描述的 CCG 场景以获取更多信息:tools.ietf.org/html/rfc6749#section-4.4
。
这与授权代码的工作方式类似,但服务不会像用户一样重定向到网页。相反,它通过一个可以交换为令牌的秘密密钥隐式授权。
对于基于微服务的架构,使用这两种类型的授权将使我们能够集中管理系统的每个方面的认证和授权。构建一个实现 OAuth2 协议一部分的微服务,用于认证服务和跟踪它们之间的交互,是减少安全问题的良好解决方案——所有内容都集中在一个地方。
在本章中,CCG 流程是迄今为止最有趣的部分,因为它允许我们独立于用户来保护我们的微服务交互。它还简化了权限管理,因为我们可以根据上下文发行具有不同作用域的令牌。应用程序仍然负责执行那些作用域可以做什么和不能做什么的强制措施。
如果您不想实现和维护应用程序的认证部分,并且可以信任第三方来管理此过程,那么 Auth0 是一个出色的商业解决方案,它为基于微服务的应用程序提供了所有所需的 API:auth0.com/
。
基于 X.509 证书的认证
X.509
标准 (datatracker.ietf.org/doc/html/rfc5280
) 用于保护网络。每个使用 TLS 的网站——即带有 https://
URL 的网站——在其网络服务器上都有一个 X.509
证书,并使用它来验证服务器的身份并设置连接将使用的加密。
当客户端面对这样的证书时,它是如何验证服务器身份的?每个正确发行的证书都是由受信任的机构进行加密签名的。证书颁发机构(CA)通常会向您颁发证书,并且是浏览器依赖的最终组织,以了解可以信任谁。当加密连接正在协商时,客户端将检查它所获得的证书,并检查谁签发了它。如果它是一个受信任的 CA 并且加密检查通过,那么我们可以假设该证书代表它所声称的。有时签发者是一个中间机构,因此此步骤应重复进行,直到客户端达到一个受信任的 CA。
可以创建一个自签名证书,这在测试套件或本地开发环境中可能很有用——尽管这在数字上等同于说,“相信我,因为我这么说。”生产服务不应使用自签名证书,如果浏览器发出警告,坐在它前面的人有理由对访问的网站保持警惕。
获取一个好的证书比以前容易得多,这要归功于 Let's Encrypt (letsencrypt.org/
)。仍然收取证书费用的组织仍然提供价值——例如,扩展验证等特性不容易自动化,有时浏览器中的额外显示(通常在地址栏中的绿色锁形图标)也是值得的。
让我们使用 Let's Encrypt 生成一个证书,并使用一些命令行工具来检查它。在 Let's Encrypt 网站上有安装名为 certbot
的实用程序的说明。根据所使用的平台,说明可能会有所不同,因此我们在这里不包括它们。一旦安装了 certbot
,为 nginx
等网络服务器获取证书就变得简单:
$ sudo certbot --nginx
No names were found in your configuration files. Please enter in your domain
name(s) (comma and/or space separated) (Enter 'c' to cancel): certbot-test.mydomain.org
Requesting a certificate for certbot-test.mydomain.org
Performing the following challenges:
http-01 challenge for certbot-test.mydomain.org
Waiting for verification...
Cleaning up challenges
Deploying Certificate to VirtualHost /etc/nginx/sites-enabled/default
Redirecting all traffic on port 80 to ssl in /etc/nginx/sites-enabled/default
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Congratulations! You have successfully enabled https://certbot-test.mydomain.org
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
nginx configuration, we see the parts that certbot has added in order to secure the web service:
listen [::]:443 ssl ipv6only=on; # managed by Certbot
listen 443 ssl; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/certbot-test.mydomain.org/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/certbot-test.mydomain.org/privkey.pem; # managed by Certbot
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
我们可以使用 OpenSSL 工具包来检查我们的证书,无论是通过查看文件还是通过向 Web 服务器发送查询。检查证书将提供大量信息,尽管对我们来说,重要的部分包括有效期和主题部分。服务运行时证书未续期而过期是一个常见的错误条件;certbot
包括帮助程序,可以自动刷新即将到期的证书,因此如果我们使用提供的工具,这应该不会成为问题。
证书主题描述了证书是为哪个实体创建的,在这个例子中,是一个主机名。这里展示的证书的主题通用名称(CN)为certbot-test.mydomain.org
,但如果这不是我们使用的主机名,那么连接到我们服务的客户端将有权抱怨。
为了检查证书的详细信息,包括主题,我们可以使用openssl
实用程序来显示证书:
$ sudo openssl x509 -in /etc/letsencrypt/live/certbot-test.mydomain.org/fullchain.pem -text -noout
Certificate:
Data:
Version: 3 (0x2)
Serial Number:
04:92:e3:37:a4:83:77:4f:b9:d7:5c:62:24:74:7e:a4:5a:e0
Signature Algorithm: sha256WithRSAEncryption
Issuer: C = US, O = Let's Encrypt, CN = R3
Validity
Not Before: Mar 13 14:43:12 2021 GMT
Not After : Jun 11 14:43:12 2021 GMT
Subject: CN = certbot-test.mydomain.org
...
还可以使用openssl
实用程序连接到正在运行的 Web 服务器,这可能有助于确认正在使用正确的证书,运行即将到期的证书的监控脚本,或其他类似的诊断。使用我们上面配置的nginx
实例,我们可以建立一个加密会话,通过这个会话我们可以发送 HTTP 命令:
$ openssl s_client -connect localhost:443
CONNECTED(00000003)
Can't use SSL_get_servername
depth=2 O = Digital Signature Trust Co., CN = DST Root CA X3
verify return:1
depth=1 C = US, O = Let's Encrypt, CN = R3
verify return:1
depth=0 CN = certbot-test.mydomain.org
verify return:1
---
Certificate chain
0 s:CN = certbot-test.mydomain.org
i:C = US, O = Let's Encrypt, CN = R3
1 s:C = US, O = Let's Encrypt, CN = R3
i:O = Digital Signature Trust Co., CN = DST Root CA X3
---
Server certificate
-----BEGIN CERTIFICATE-----
MII
# A really long certificate has been removed here
-----END CERTIFICATE-----
subject=CN = certbot-test.mydomain.org
issuer=C = US, O = Let's Encrypt, CN = R3
---
New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384
Server public key is 2048 bit
Secure Renegotiation IS NOT supported
Compression: NONE
Expansion: NONE
No ALPN negotiated
Early data was not sent
Verify return code: 0 (ok)
---
我们可以轻松地读取这个交换中的公共证书,并确认这是我们期望服务器从其配置文件中使用的证书。我们还可以发现客户端和服务器之间协商了哪些加密套件,并识别出任何可能成为问题的套件,如果正在使用较旧的客户端库或 Web 浏览器。
到目前为止,我们只讨论了服务器使用证书来验证其身份并建立安全连接的情况。客户端也可以出示证书来验证自身。证书将允许我们的应用程序验证客户端是否是他们声称的身份,但我们应该小心,因为这并不意味着客户端被允许做某事——这种控制仍然掌握在我们自己的应用程序手中。管理这些证书、设置 CA 以向客户端颁发适当的证书以及如何正确分发文件,这些都超出了本书的范围。如果您正在创建的应用程序选择这样做,一个好的起点是查看nginx
文档中的nginx.org/en/docs/http/ngx_http_ssl_module.html#ssl_verify_client
。
让我们来看看如何验证使用我们服务的客户端,以及我们如何设置一个专门用于验证客户端访问的微服务。
基于令牌的认证
正如我们之前所说的,当一个服务想要在不进行任何用户干预的情况下访问另一个服务时,我们可以使用 CCG 流。CCG 的理念是,一个服务可以连接到身份验证服务并请求一个令牌,然后它可以使用这个令牌来对其他服务进行身份验证。
在需要不同权限集或身份不同的系统中,身份验证服务可以发行多个令牌。
令牌可以包含对身份验证和授权过程有用的任何信息。以下是一些例子:
-
如果与上下文相关,
username
或ID
-
范围,它表示调用者可以做什么(读取、写入等)
-
一个表示令牌签发时间的
时间戳
-
一个表示令牌有效期的
过期时间戳
令牌通常构建为一个完整的证明,表明你有权使用一项服务。它是完整的,因为可以在不知道其他任何信息或无需查询外部资源的情况下,通过身份验证服务验证令牌。根据实现方式,令牌还可以用来访问不同的微服务。
OAuth2 使用 JWT 标准作为其令牌。OAuth2 中没有要求必须使用 JWT 的内容——它只是恰好适合 OAuth2 想要实现的功能。
JWT 标准
在 RFC 7519 中描述的 JSON Web Token(JWT)是一个常用的标准,用于表示令牌:tools.ietf.org/html/rfc7519
。
JWT 是由三个点分隔的长字符串组成:
-
头部:它提供了有关令牌的信息,例如使用了哪种哈希算法
-
有效载荷:这是实际数据
-
签名:这是头部和有效载荷的签名哈希,用于验证其合法性
JWTs 是 Base64 编码的,因此它们可以安全地用于查询字符串。以下是一个 JWT 的编码形式:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IlNpbW9uIEZyYXNlciIsIm lhdCI6MTYxNjQ0NzM1OH0
.
K4ONCpK9XKtc4s56YCC-13L0JgWohZr5J61jrbZnt1M
令牌上方的每个部分在显示时通过换行符分隔——原始令牌是一行。你可以使用 Auth0 提供的实用工具来实验 JWT 编码和解码,该实用工具位于 jwt.io/
。
如果我们使用 Python 来解码它,数据就是简单的 Base64:
>>> import base64
>>> def decode(data):
... # adding extra = for padding if needed
... pad = len(data) % 4
... if pad > 0:
... data += "=" * (4 - pad)
... return base64.urlsafe_b64decode(data)
...
>>> decode("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9")
b'{"alg":"HS256","typ":"JWT"}'
>>> import base64
>>> decode("eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IlNpbW9uIEZyYXNlciIsImlhdC I6MTYxNjQ0NzM1OH0")
b'{"sub":"1234567890","name":"Simon Fraser","iat":1616447358}'
>>> decode("K4ONCpK9XKtc4s56YCC-13L0JgWohZr5J61jrbZnt1M")
b"+\x83\x8d\n\x92\xbd\\\xab\\\xe2\xcez` \xbe\xd7r\xf4&\x05\xa8\x85\x9a\xf9'\xadc\xad\xb6g\xb7S"
JWT 的每一部分都是一个 JSON 映射,除了签名。头部通常只包含 typ
和 alg
键:typ
键表示这是一个 JWT,而 alg
键指示使用了哪种哈希算法。在下面的头部示例中,我们有 HS256
,代表 HMAC-SHA256
:
{"typ": "JWT", "alg": "HS256"}
有效载荷包含你需要的内容,每个字段在 RFC 7519 的术语中被称为 JWT 断言。RFC 有一个预定义的断言列表,令牌可能包含这些断言,称为 注册的断言名称。以下是一些子集:
-
iss
:这是发行者,即生成令牌的实体的名称。通常是完全限定的主机名,因此客户端可以使用它通过请求/.well-known/jwks.json
来发现其公钥。 -
exp
: 这是过期时间,是一个令牌无效的戳记。 -
nbf
: 这代表不可用之前时间,是一个令牌无效的戳记。 -
aud
: 这表示受众,即令牌发行的接收者。 -
iat
: 代表发行于,这是一个表示令牌发行时间的戳记。
在以下有效载荷示例中,我们提供了自定义的user_id
值以及使令牌在发行后 24 小时内有效的时戳;一旦有效,该令牌可以用于 24 小时:
{
"iss": "https://tokendealer.mydomain.org",
"aud": "mydomain.org",
"iat": 1616447358,
"nbt": 1616447358,
"exp": 1616533757,
"user_id": 1234
}
这些头部为我们提供了很多灵活性,以控制我们的令牌将保持有效的时间。根据微服务的性质,令牌的生存时间(TTL)可以是极短到无限。例如,与系统内其他服务交互的微服务可能需要依赖足够长的令牌,以避免不必要地多次重新生成令牌。另一方面,如果你的令牌在野外分发,或者它们与改变高度重要的事情相关,使它们短暂有效是一个好主意。
JWT 的最后部分是签名。它包含头部和有效载荷的签名哈希。用于签名哈希的算法有几种;一些基于密钥,而另一些基于公钥和私钥对。
PyJWT
在 Python 中,PyJWT
库提供了你生成和读取 JWT 所需的所有工具:pyjwt.readthedocs.io/
。
一旦你使用 pip 安装了pyjwt
(和cryptography
),你就可以使用encode()
和decode()
函数来创建令牌。在以下示例中,我们使用HMAC-SHA256
创建 JWT 并读取它。在读取令牌时,通过提供密钥来验证签名:
>>> import jwt
>>> def create_token(alg="HS256", secret="secret", data=None):
return jwt.encode(data, secret, algorithm=alg)
...
>>>
>>> def read_token(token, secret="secret", algs=["HS256"]):
... return jwt.decode(token, secret, algorithms=algs)
...
>>> token = create_token(data={"some": "data", "inthe": "token"})
>>> print(token)
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzb21lIjoiZGF0YSIsImludGhlIjoidG9rZW4ifQ.vMHiSS_vk-Z3gMMxcM22Ssjk3vW3aSmJXQ8YCSCwFu4
>>> print(read_token(token))
{'some': 'data', 'inthe': 'token'}
当执行此代码时,令牌会以压缩和未压缩两种形式显示。如果你使用已注册的声明之一,PyJWT
将控制它们。例如,如果提供了exp
字段且令牌已过期,库将引发错误。
使用密钥进行签名和验证签名在运行少量服务时很好,但很快可能会成为问题,因为它要求你需要在所有需要验证签名的服务之间共享密钥。因此,当需要更改密钥时,在堆栈中安全地更改它可能是一个挑战。基于你共享的密钥进行身份验证也是一种弱点。如果单个服务被破坏且密钥被盗,你的整个身份验证系统都会受到破坏。
一个更好的技术是使用由公钥和私钥组成的非对称密钥。私钥由令牌发行者用来签名令牌,而公钥可以被任何人用来验证签名是否由该发行者签名。当然,如果攻击者能够访问私钥,或者能够说服客户端伪造的公钥是合法的,你仍然会遇到麻烦。
但使用公私钥对确实减少了您认证过程的攻击面,通常足以阻止大多数攻击者;并且,由于认证微服务将是唯一包含私钥的地方,您可以专注于增加额外的安全性。例如,这样的明智服务通常部署在防火墙环境中,所有访问都受到严格控制。现在让我们看看我们如何在实践中创建非对称密钥。
使用 JWT 证书
为了简化这个例子,我们将使用之前为nginx
生成的letsencrypt
证书。如果您在笔记本电脑或无法从互联网访问的容器上开发,您可能需要使用云实例或certbot
DNS 插件生成这些证书,并将它们复制到正确的位置。
如果certbot
直接生成证书,它们将保存在/etc/letsencrypt/live/your-domain/
。首先,我们关注以下两个文件:
-
cert.pem
,其中包含证书 -
privkey.pem
,其中包含 RSA 私钥
为了使用这些与 PyJWT,我们需要从证书中提取公钥:
openssl x509 -pubkey -noout -in cert.pem > pubkey.pem
RSA代表Rivest, Shamir, 和 Adleman,这三位作者。RSA 加密算法生成的密钥可以长达 4,096 字节,被认为是安全的。
从那里,我们可以在我们的 PyJWT 脚本中使用pubkey.pem
和privkey.pem
来签名和验证令牌的签名,使用RSASSA-PKCS1-v1_5
签名算法和SHA-512
哈希算法:
import jwt
with open("pubkey.pem") as f:
PUBKEY = f.read()
with open("privkey.pem") as f:
PRIVKEY = f.read()
def create_token(**data):
return jwt.encode(data, PRIVKEY, algorithm="RS512")
def read_token(token):
return jwt.decode(token, PUBKEY, algorithms="RS512")
token = create_token(some="data", inthe="token")
print(token)
read = read_token(token)
print(read)
结果与之前的运行相似,只是我们得到了一个更大的令牌:
eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzUxMiJ9.eyJzb21lIjoiZGF0YSIsImludGh lIjoidG9rZW4ifQ.gi5p3k4PAErw8KKrghRjsi8g1IXnflivXiwwaZdFEh84zvgw9RJRa 50uJe778A1CBelnmo2iapSWOQ9Mq5U6gpv4VxoVYv6QR2zFNO13GB_tce6xQ OhjpAd-hRxouy3Ozj4oNmvwLpCT5dYPsCvIiuYrLt4ScK5S3q3a0Ny64VXy 3CcISNkyjs7fnxyMMkCMZq65Z7jOncf1RXpzNNIt546aJGsCcpCPGHR1cRj uvV_uxPAMd-dfy2d5AfiCXOgvmwQhNdaxYIM0gPgz9_yHPzgaPjtgYoJMc9iK ZdOLz2-8pLc1D3r_uP3P-4mfxP7mOhQHYBrY9nv5MTSwFC3JDA
{'some': 'data', 'inthe': 'token'}
在每个请求中添加如此多的额外数据可能会对产生的网络流量产生影响,因此,基于密钥的 JWT 技术是一个可以考虑的选项,如果您需要减少网络开销。
TokenDealer 微服务
在构建认证微服务的第一步,我们将实现执行 CCG 流程所需的一切。为此,应用程序接收来自需要令牌的服务请求,并在需要时生成它们,假设请求中包含已知的密钥。生成的令牌将有一个一天的寿命。这种方法具有最大的灵活性,没有生成我们自己的X.509
证书的复杂性,同时允许我们有一个服务负责生成令牌。
这个服务将是唯一一个拥有用于签署令牌的私钥的服务,并将公开公钥供其他想要验证令牌的服务使用。这个服务也将是唯一一个保存所有客户端 ID 和密钥的地方。
我们将通过声明一旦服务获取到令牌,它就可以访问我们生态系统中的任何其他服务来大大简化实现。当服务使用令牌访问时,它可以本地验证该令牌或调用 TokenDealer 来执行验证。网络请求和微服务中的某些 CPU 使用之间的选择将取决于应用程序做什么以及它的瓶颈在哪里。在平衡安全和性能要求时,可能有必要最多每几分钟验证一次令牌,而不是每次都验证。然而,如果需要使令牌无效,这将会造成延迟,因此我们应该参考用户故事,并在必要时与将使用该服务的人讨论,以确定哪个最重要。
为了实现我们所描述的,这个微服务将创建三个端点:
-
GET /.well-known/jwks.json
: 当其他微服务想要自行验证令牌时,这是以 RFC 7517 中描述的 JSON Web Key (JWK) 格式发布的公钥。有关更多信息,请参阅以下链接:tools.ietf.org/html/rfc7517
。 -
POST /oauth/token
: 这个端点接受带有凭证的请求并返回一个令牌。添加/oauth
前缀是一个广泛采用的约定,因为它在 OAuth RFC 中被使用。 -
POST /verify_token
: 这个端点在给定一个令牌的情况下返回令牌的有效负载。如果令牌无效,它将返回 HTTP 400 错误代码。
使用微服务骨架,我们可以创建一个非常简单的 Quart 应用程序,该应用程序实现了这三个视图。骨架可在github.com/PacktPublishing/Python-Microservices-Development-2nd-Edition/
找到。
让我们来看看这三个 OAuth 视图。
OAuth 实现
对于 CCG 流程,需要令牌的服务会发送一个包含以下字段的 URL 编码体的 POST
请求:
-
client_id
: 这是一个唯一字符串,用于标识请求者。 -
client_secret
: 这是一个用于验证请求者的密钥。它应该是一个预先生成并注册到认证服务的随机字符串。 -
grant_type
: 这是授权类型,在这里必须是client_credentials
。
我们将做出一些假设以简化实现。首先,为了演示目的,我们将保持秘密列表在 Python 数据结构中。在生产服务中,它们应该在静态时加密,并保存在具有弹性的数据存储中。我们还将假设client_id
是调用微服务的名称,并且现在我们将使用binascii.hexlify(os.urandom(16))
生成秘密。
第一个视图将是实际生成其他服务所需令牌的视图。在我们的例子中,我们每次创建令牌时都会读取私钥——对于实际服务来说,最好将其存储在应用程序配置中,以减少从磁盘读取文件所需的时间。我们确保客户端已经向我们发送了一个合理的请求,并且它想要一些client_credentials
。错误处理函数和实用工具可以在本章的完整源代码示例中找到。
令牌本身是一个具有多个字段的复杂数据结构:令牌的发行者(iss
),通常是服务的 URL;令牌的目标受众(aud
),即令牌的目标对象;令牌签发的时间(iat
);以及其过期时间(exp
)。然后我们使用jwt.encode
方法对数据进行签名,并将其返回给请求客户端:
@app.route("/oauth/token", methods=["POST"])
async def create_token():
with open(current_app.config["PRIVATE_KEY_PATH"]) as f:
key = f.read().strip()
try:
data = await request.form
if data.get("grant_type") != "client_credentials":
return bad_request(f"Wrong grant_type {data.get('grant_type')}")
client_id = data.get("client_id")
client_secret = data.get("client_secret")
aud = data.get("audience", "")
if not is_authorized_app(client_id, client_secret):
return abort(401)
now = int(time.time())
token = {
"iss": current_app.config["TOKENDEALER_URL"],
"aud": aud,
"iat": now,
"exp": now + 3600 * 24,
}
token = jwt.encode(token, key, algorithm="RS512")
return {"access_token": token}
except Exception as e:
return bad_request("Unable to create a token")
接下来要添加的视图是一个返回我们令牌生成所使用的公钥的功能,这样任何客户端都可以验证令牌而无需进行进一步的 HTTP 请求。这通常位于一个众所周知的 URL——地址中实际上包含字符串.well-known/
,这是 IETF 鼓励的做法,为客户端提供发现有关服务元数据的方式。在这里,我们响应的是 JWKS。
返回的数据中包含密钥类型(kty
)、算法(alg
)、公钥使用(use
)——这里是一个签名——以及我们使用 RSA 算法生成的加密密钥所用的两个值:
@app.route("/.well-known/jwks.json")
async def _jwks():
"""Returns the public key in the Json Web Key Set (JWKS) format"""
with open(current_app.config["PUBLIC_KEY_PATH"]) as f:
key = f.read().strip()
data = {
"alg": "RS512",
"e": "AQAB",
"n": key,
"kty": "RSA",
"use": "sig",
}
return jsonify({"keys": [data]})
最后一个视图允许客户端验证令牌而无需自己进行工作。与令牌生成相比,这要简单得多,我们只需从输入数据中提取正确的字段,并调用jwt.decode
函数来提供值。请注意,此函数验证令牌是否有效,但并不验证令牌是否允许任何特定的访问——这部分取决于已经向其展示令牌的服务:
@app.route("/verify_token", methods=["POST"])
async def verify_token():
with open(current_app.config["PUBLIC_KEY_PATH"]) as f:
key = f.read()
try:
input_data = await request.form
token = input_data["access_token"]
audience = input_data.get("audience", "")
return jwt.decode(token, key, algorithms=["RS512"], audience=audience)
except Exception as e:
return bad_request("Unable to verify the token")
TokenDealer 微服务的全部源代码可以在 GitHub 上找到:github.com/PacktPublishing/Python-Microservices-Development-2nd-Edition
.
微服务可以提供更多关于令牌生成的功能。例如,管理作用域并确保微服务 A 不允许生成在微服务 B 中使用的令牌,或者管理一个授权请求某些令牌的服务白名单。客户端还可以请求一个仅用于只读用途的令牌。然而,尽管如此,我们已实现的模式是微服务环境中简单基于令牌的认证系统的基石,您可以在自己的基础上进行开发,同时它也足够好,适用于我们的 Jeeves 应用。
回顾我们的示例微服务,TokenDealer 现在作为生态系统中的一个独立微服务存在,创建和验证允许访问我们的数据服务的密钥,并授权访问我们查询其他网站所需的第三方令牌和 API 密钥:
图 7.2:带有 CCG TokenDealer 的微服务生态系统
那些需要 JWT 的服务可以通过调用 TokenDealer 微服务来验证它。图 7.2中的 Quart 应用需要代表其用户从 TokenDealer 获取令牌。
现在我们已经有一个实现了 CCG 的 TokenDealer 服务,让我们看看它如何在下一节中由我们的服务使用。
使用 TokenDealer
在 Jeeves 中,数据服务是一个需要认证的好例子。通过数据服务添加信息需要限制在授权的服务范围内:
图 7.3:请求 CCG 工作流
为该链接添加认证分为四个步骤:
-
TokenDealer为 Strava 工作者管理一个
client_id
和client_secret
对,并与 Strava 工作者开发者共享 -
Strava 工作者使用
client_id
和client_secret
从TokenDealer检索令牌 -
工作者将令牌添加到每个请求到数据服务的头部
-
数据服务通过调用TokenDealer的验证 API 或执行本地JWT验证来验证令牌
在完整实现中,第一步可以部分自动化。生成客户端密钥通常是通过认证服务的 Web 管理面板完成的。然后,该密钥提供给客户端微服务开发者。现在,每个需要令牌的微服务都可以获取一个,无论是首次连接,还是因为它们已经获得的令牌已过期。他们要做的只是在使用时将令牌添加到调用数据服务的授权头中。
以下是一个使用requests
库进行此类调用的示例——假设我们的 TokenDealer 已经在localhost:5000
上运行:
# fetch_token.py
import requests
TOKENDEALER_SERVER = "http://localhost:5000"
SECRET = "f0fdeb1f1584fd5431c4250b2e859457"
def get_token():
data = {
"client_id": "worker1",
"client_secret": secret,
"audience": "jeeves.domain",
"grant_type": "client_credentials",
}
headers = {"Content-Type": "application/x-www-form-urlencoded"}
url = tokendealer_server + "/oauth/token"
response = requests.post(url, data=data, headers=headers)
return response.json()["access_token"]
get_token()
函数检索一个令牌,该令牌随后可以在代码调用数据服务时用于授权头,我们假设数据服务在本例中监听端口 5001
:
# auth_caller.py
_TOKEN = None
def get_auth_header(new=False):
global _TOKEN
if _TOKEN is None or new:
_TOKEN = get_token()
return "Bearer " + _TOKEN
_dataservice = "http://localhost:5001"
def _call_service(endpoint, token):
# not using session and other tools, to simplify the code
url = _dataservice + "/" + endpoint
headers = {"Authorization": token}
return requests.get(url, headers=headers)
def call_data_service(endpoint):
token = get_auth_header()
response = _call_service(endpoint, token)
if response.status_code == 401:
# the token might be revoked, let's try with a fresh one
token = get_auth_header(new=True)
response = _call_service(endpoint, token)
return response
call_data_service()
函数会在调用数据服务并返回 401 响应时尝试获取新的令牌。这种在 401 响应上刷新令牌的模式可以用于你所有的微服务来自动化令牌生成。
这包括服务间的身份验证。你可以在示例 GitHub 仓库中找到完整的实现,以尝试基于 JWT 的身份验证方案,并将其作为构建你的身份验证过程的基础。
下一个部分将探讨保护你的网络服务的重要方面之一,即保护代码本身。
保护你的代码
无论我们做什么,应用程序都必须接收数据并对其采取行动,否则它将不会非常有用。如果一个服务接收数据,那么一旦你将你的应用程序暴露给世界,它就会面临众多可能的攻击类型,你的代码需要考虑到这一点进行设计。
任何发布到网络上的内容都可能受到攻击,尽管我们有优势,即大多数微服务没有暴露在公共互联网上,这减少了它们可能被利用的方式。系统的预期输入和输出更窄,通常可以使用如 OpenAPI 之类的规范工具更好地定义。
攻击并不总是由于恶意意图。如果调用者有错误或者只是没有正确调用你的服务,预期的行为应该是发送回一个4xx
响应,并向客户端解释为什么请求被拒绝。
开放网络应用安全项目(OWASP)(www.owasp.org
)是一个学习如何保护你的网络应用程序免受不良行为侵害的优秀资源。让我们看看一些最常见的攻击形式:
-
注入:在一个接收数据的程序中,攻击者通过请求发送 SQL 语句、shell 命令或其他指令。如果你的应用程序在使用这些数据时不够小心,你可能会运行旨在损害应用程序的代码。在 Python 中,可以通过使用 SQLAlchemy 来避免 SQL 注入攻击,它会以安全的方式为你构造 SQL 语句。如果你直接使用 SQL,或者向 shell 脚本、LDAP 服务器或其他结构化查询提供参数,你必须确保每个变量都被正确地引用。
-
跨站脚本(XSS):这种攻击只发生在显示 HTML 的网页上。攻击者使用一些查询属性尝试在页面上注入他们的 HTML 片段,以欺骗用户执行一系列操作,让他们以为自己在合法网站上。
-
跨站请求伪造(XSRF/CSRF):这种攻击基于通过重用用户从另一个网站的用户凭据来攻击服务。典型的 CSRF 攻击发生在
POST
请求中。例如,一个恶意网站显示一个链接给用户,诱骗该用户使用他们现有的凭据在你的网站上执行POST
请求。
像本地文件包含(LFI)、远程文件包含(RFI)或远程代码执行(RCE)这样的攻击都是通过客户端输入欺骗服务器执行某些操作或泄露服务器文件的攻击。当然,这些攻击可能发生在大多数语言和工具包编写的应用程序中,但我们将检查一些 Python 的工具来防止这些攻击。
安全代码背后的理念简单,但在实践中很难做好。两个基本的原则是:
-
在应用程序和数据中执行任何操作之前,都应该仔细评估来自外部世界的每个请求。
-
应用程序在系统上所做的每一件事都应该有一个明确和有限的作用域。
让我们看看如何在实践中实施这些原则。
限制应用程序的作用域
即使你信任认证系统,你也应该确保连接的人拥有完成工作所需的最小访问级别。如果有客户端连接到你的微服务并能够进行认证,这并不意味着他们应该被允许执行任何操作。如果他们只需要只读访问,那么他们应该只被授予这一点。
这不仅仅是保护免受恶意代码的侵害,还包括错误和意外。每次当你认为“客户端永远不应该调用这个端点”时,就应该有某种机制来积极阻止客户端使用它。
这种作用域限制可以通过 JWTs 通过定义角色(如读写)并在令牌中添加该信息来实现,例如,在权限或作用域键下。然后目标微服务将能够拒绝使用仅应读取数据的令牌进行的POST
调用。
这就是当你授予 GitHub 账户或 Android 手机上的应用程序访问权限时会发生的情况。会显示应用程序想要执行的操作的详细列表,你可以授予或拒绝访问。
这是在网络级控制和防火墙的基础上。如果你控制着微服务生态系统的所有部分,你还可以在系统级别使用严格的防火墙规则来白名单允许与每个微服务交互的 IP 地址,但这种设置在很大程度上取决于你部署应用程序的位置。在亚马逊网络服务(AWS)云环境中,你不需要配置 Linux 防火墙;你只需要在 AWS 控制台中设置访问规则即可。第十章,在 AWS 上部署,涵盖了在亚马逊云上部署微服务的基本知识。
除了网络访问之外,任何其他应用程序可以访问的资源都应在可能的情况下进行限制。在 Linux 上以 root 用户运行应用程序不是一个好主意,因为如果你的应用程序拥有完整的行政权限,那么成功入侵的攻击者也会有。
从本质上讲,如果一层安全措施失败,后面应该还有另一层。如果一个应用程序的 web 服务器被成功攻击,任何攻击者理想情况下都应尽可能有限制,因为他们只能访问应用程序中服务之间定义良好的接口——而不是对运行代码的计算机拥有完整的行政控制。在现代部署中,系统根访问已成为一种间接威胁,因为大多数应用程序都在容器或一个 虚拟机(VM)中运行,但即使其能力被运行的 VM 限制,一个进程仍然可以造成很多损害。如果攻击者访问到您的其中一个 VM,他们已经实现了控制整个系统的第一步。为了减轻这个问题,您应该遵循以下两条规则:
-
所有软件都应该以尽可能小的权限集运行
-
在执行来自您的网络服务的进程时,要非常谨慎,并在可能的情况下避免
对于第一条规则,像 nginx
这样的 web 服务器的默认行为是使用 www-data
用户和组来运行其进程,这样标准用户控制可以防止服务器访问其他文件,并且账户本身可以设置成不允许运行 shell 或任何其他交互式命令。同样的规则也适用于您的 Quart 进程。我们将在 第九章,打包和运行 Python 中看到在 Linux 系统上以用户空间运行堆栈的最佳实践。
对于第二条规则,除非绝对必要,否则应避免使用任何 Python 对 os.system()
的调用,因为它在计算机上创建一个新的用户 shell,增加了运行不良命令的风险,并增加了对系统无控制访问的风险。subprocess
模块更好,尽管它也必须谨慎使用以避免不希望的结果——避免使用 shell=True
参数,这将导致与 os.system()
相同的问题,并避免使用输入数据作为参数和命令。这也适用于发送电子邮件或通过 FTP 连接到第三方服务器的高级网络模块,通过本地系统。
不受信任的传入数据
大多数应用程序接受数据作为输入:要查找哪个账户;为哪个城市获取天气预报;要将钱转入哪个账户,等等。问题是来自我们系统之外的数据不容易被信任。
之前,我们讨论了 SQL 注入攻击;现在让我们考虑一个非常简单的例子,其中我们使用 SQL 查询来查找用户。我们有一个函数,它将查询视为要格式化的字符串,并使用标准的 Python 语法填充它:
import pymysql
connection = pymysql.connect(host='localhost', db='book')
def get_user(user_id):
query = f"select * from user where id = {user_id}"
with connection.cursor() as cursor:
cursor.execute(query)
result = cursor.fetchone()
return result
当 user_id
总是合理的值时,这看起来是正常的。然而,如果有人提供了一个精心制作的恶意值呢?如果我们允许人们为上面的 get_user()
函数输入数据,并且他们不是输入一个数字作为 user_id
,而是输入:
'1'; insert into user(id, firstname, lastname, password) values (999, 'pwnd', 'yup', 'somehashedpassword')
现在我们的 SQL 语句实际上是两个语句:
select * from user where id = '1'
insert into user(id, firstname, lastname, password) values (999, 'pwnd', 'yup', 'somehashedpassword')
get_user
将执行预期的查询,以及一个将添加新用户的查询!它还可以删除表,或执行 SQL 语句可用的任何其他操作。如果认证客户端权限有限,则有一些限制措施,但仍然可能暴露大量数据。可以通过引用构建原始 SQL 查询时使用的任何值来防止这种情况。在 PyMySQL
中,您只需将值作为参数传递给 execute
参数以避免此问题:
def get_user(user_id):
query = 'select * from user where id = %s'
with connection.cursor() as cursor:
cursor.execute(query, (user_id,))
result = cursor.fetchone()
return result
每个数据库库都有这个功能,所以只要您在构建原始 SQL 时正确使用这些库,就应该没问题。更好的做法是完全避免使用原始 SQL,而是通过 SQLAlchemy 使用数据库模型。
如果您有一个视图,它从传入的请求中获取 JSON 数据并将其用于向数据库推送数据,您应该验证传入的请求包含您期望的数据,而不是盲目地将其传递给您的数据库后端。这就是为什么使用 Swagger 将数据描述为模式并使用它们来验证传入数据可能很有趣。微服务通常使用 JSON,但如果你碰巧使用模板提供格式化输出,那么这也是你需要小心处理模板如何处理变量的另一个地方。
服务器端模板注入(SSTI)是一种可能的攻击,其中您的模板盲目执行 Python 语句。在 2016 年,在 Uber 网站的一个 Jinja2 模板上发现了一个这样的注入漏洞,因为原始格式化是在模板执行之前完成的。更多信息请参阅hackerone.com/reports/125980
。
代码类似于这个小应用程序:
from quart import Quart, request, render_template_string
app = Quart(__name__)
SECRET = "oh no!"
_TEMPLATE = """
Hello %s
Welcome to my API!
"""
class Extra:
def __init__(self, data):
self.data = data
@app.route("/")
async def my_microservice():
user_id = request.args.get("user_id", "Anonymous")
tmpl = _TEMPLATE % user_id
return await render_template_string(tmpl, extra=Extra("something"))
app.run()
通过在模板中使用原始的 %
格式化语法进行预格式化,视图在应用程序中创建了一个巨大的安全漏洞,因为它允许攻击者在 Jinja 脚本执行之前注入他们想要的内容。在下面的示例中,user_id
变量的安全漏洞被利用来从模块中读取 SECRET
全局变量的值:
# Here we URL encode the following:
# http://localhost:5000/?user_id={{extra.__class__.__init__.__globals__["SECRET"]}}
$ curl http://localhost:5000/?user_id=%7B%7Bextra.__class__.__init__.__globals__%5B%22SECRET%22%5D%7D%7D
Hello oh no!
Welcome to my API!
这就是为什么避免使用输入数据进行字符串格式化很重要,除非有模板引擎或其他提供保护的层。
如果您需要在模板中评估不受信任的代码,您可以使用 Jinja 的沙盒;请参阅jinja.pocoo.org/docs/latest/sandbox/
。这个沙盒将拒绝访问正在评估的对象的方法和属性。例如,如果您在模板中传递一个可调用对象,您将确保其属性,如 ;__class__
,不能被使用。
话虽如此,由于语言本身的性质,Python 沙盒很难配置正确。很容易误配置沙盒,而且沙盒本身也可能因为语言的新版本而被破坏。最安全的做法是完全避免评估不受信任的代码,并确保你不会直接依赖于传入数据用于模板。
重定向和信任查询
在处理重定向时,也适用相同的预防措施。一个常见的错误是创建一个登录视图,假设调用者将被重定向到内部页面,并使用一个普通的 URL 进行重定向:
@app.route('/login')
def login():
from_url = request.args.get('from_url', '/')
# do some authentication
return redirect(from_url)
这个视图可以将调用者重定向到任何网站,这是一个重大的威胁——尤其是在登录过程中。良好的做法是在调用redirect()
时避免使用自由字符串,而是使用url_for()
函数,这将创建一个指向你的应用域的链接。如果你需要重定向到第三方,你不能使用url_for()
和redirect()
函数,因为它们可能会将你的客户端发送到不受欢迎的地方。
一种解决方案是创建一个受限制的第三方域名列表,你的应用程序允许重定向到这些域名,并确保应用程序或底层第三方库执行的重定向都经过该列表的检查。
这可以通过在视图生成响应后、Quart 将响应发送回客户端之前调用的after_request()
钩子来完成。如果应用程序尝试发送回一个302
状态码,你可以检查其位置是否安全,给定一个域名和端口号列表:
# quart_after_response.py
from quart import Quart, redirect
from quart.helpers import make_response
from urllib.parse import urlparse
app = Quart(__name__)
@app.route("/api")
async def my_microservice():
return redirect("https://github.com:443/")
# domain:port
SAFE_DOMAINS = ["github.com:443", "google.com:443"]
@app.after_request
async def check_redirect(response):
if response.status_code != 302:
return response
url = urlparse(response.location)
netloc = url.netloc
if netloc not in SAFE_DOMAINS:
# not using abort() here or it'll break the hook
return await make_response("Forbidden", 403)
return response
if __name__ == "__main__":
app.run(debug=True)
清洗输入数据
除了处理不受信任数据的其他做法之外,我们可以确保字段本身符合我们的预期。面对上述示例,我们可能会想过滤掉任何分号,或者可能所有花括号,但这让我们处于必须考虑数据可能出现的所有错误格式的位置,并试图战胜恶意程序员和随机错误的独创性。
相反,我们应该专注于我们对我们数据外观的了解——而不是它不应该是什么样子。这是一个更窄的问题,答案通常更容易定义。例如,如果我们知道一个端点接受 ISBN 来查找一本书,那么我们知道我们只应该期望一个由 10 或 13 位数字组成的序列,可能带有分隔符。然而,对于人来说,数据清理要困难得多。
在github.com/kdeldycke/awesome-falsehood
上,有一些关于程序员对各种主题的错误认识的精彩列表。这些列表并不旨在详尽或具有权威性,但它们有助于提醒我们,我们可能对人类信息工作方式存在错误观念。人类姓名、邮政地址、电话号码:我们不应假设这些数据的任何样子,它们有多少行,或元素排列的顺序。我们能做的最好的事情是确保输入信息的人有最好的机会检查其正确性,然后使用前面描述的引用和沙箱技术来避免任何事故。
即使是电子邮件地址的验证也非常复杂。允许的格式有很多不同的部分,并不是所有的电子邮件系统都支持这些部分。有一句经常引用的话是,验证电子邮件地址的最佳方式是尝试发送一封电子邮件,这种方法既被合法网站使用——发送电子邮件并通知你“已发送电子邮件以确认您的账户”——也被垃圾邮件发送者使用,他们向数百万个地址发送无意义的消息,并记录哪些地址没有返回错误。
总结来说,你应该始终将传入的数据视为潜在的威胁,将其视为可能注入你系统的攻击源。转义或删除任何特殊字符,避免在没有隔离层的情况下直接在数据库查询或模板中使用数据,并确保你的数据看起来是你预期的样子。
你还可以使用 Bandit 代码检查器持续检查代码中的潜在安全问题,这在下一节中进行了探讨。
使用 Bandit 代码检查器
由 Python 代码质量权威机构管理,Bandit(github.com/PyCQA/bandit
)是另一个用于扫描源代码中潜在安全风险的工具。它可以在 CI 系统中运行,以在部署之前自动测试任何更改。该工具使用ast
模块以与flake8
和pylint
相同的方式解析代码。Bandit 还将扫描你代码中的一些已知安全问题。一旦使用pip install bandit
命令安装它,你就可以使用bandit
命令针对你的 Python 模块运行它。
如第三章所述,将 Bandit 添加到与其他检查并行的持续集成管道中,即《编码、测试和文档:良性循环》,是捕捉代码中潜在安全问题的好方法。
依赖项
大多数项目都会使用其他库,因为程序员是在他人的工作上构建的,而且通常没有足够的时间密切关注那些其他项目的发展。如果我们的依赖项中存在安全漏洞,我们希望快速了解这一点,以便我们可以更新我们的软件,而无需手动检查。
Dependabot (dependabot.com/
) 是一个会对你的项目依赖进行安全扫描的工具。Dependabot 是 GitHub 的内置组件,其报告应显示在你的项目 安全 选项卡中。在项目的 设置 页面上开启一些额外功能,可以让 Dependabot 自动创建需要进行的任何更改以保持安全的拉取请求。
PyUp 拥有一组类似的功能,但需要手动设置——如果你不使用 GitHub,Dependabot 也是如此。
网络应用防火墙
即使数据处理得再安全,我们的应用程序仍然可能容易受到攻击。当你向世界公开 HTTP 端点时,这始终是一个风险。你希望调用者按预期行事,每个 HTTP 会话都遵循你在服务中编程的场景。
一个客户端可以发送合法的请求,并不断地用这些请求轰炸你的服务,导致由于所有资源都用于处理来自攻击者的请求而出现 服务拒绝 (DoS)。当使用数百或数千个客户端进行此类操作时,这被称为 分布式拒绝服务 (DDoS) 攻击。当客户端具有自动回放相同 API 的功能时,这个问题有时会在分布式系统中发生。如果客户端侧没有采取措施来限制调用,你可能会遇到由合法客户端过载的服务。
在服务器端添加保护以使这些热情的客户退却通常并不困难,并且这可以大大保护你的微服务堆栈。一些云服务提供商还提供针对 DDoS 攻击的保护以及这里提到的许多功能。
在本章前面提到的 OWASP 提供了一套规则,可用于 ModSecurity
工具包的 WAF,以避免许多类型的攻击:github.com/coreruleset/coreruleset/
。
在本节中,我们将专注于创建一个基本的 WAF,该 WAF 将明确拒绝在我们的服务上请求过多的客户端。本节的目的不是创建一个完整的 WAF,而是让你更好地理解 WAF 的实现和使用方式。我们可以在 Python 微服务中构建我们的 WAF,但如果所有流量都必须通过它,这将增加很多开销。一个更好的解决方案是直接依赖 Web 服务器。
OpenResty:Lua 和 nginx
OpenResty (openresty.org/en/
) 是一个嵌入 Lua (www.lua.org/
) 解释器的 nginx
发行版,可以用来编写 Web 服务器脚本。然后我们可以使用脚本将规则和过滤器应用于流量。
Lua 是一种优秀、动态类型的编程语言,它拥有轻量级且快速的解释器。该语言提供了一套完整的特性,并内置了异步特性。你可以在纯 Lua 中直接编写协程。
如果你安装了 Lua(参考 www.lua.org/start.html
),你可以使用 Lua 读取-评估-打印循环(REPL)来玩转这门语言,就像使用 Python 一样:
$ lua
Lua 5.4.2 Copyright (C) 1994-2020 Lua.org, PUC-Rio
> io.write("Hello world\n")
Hello world
file (0x7f5a66f316a0)
> mytable = {}
> mytable["user"] = "simon"
> = mytable["user"]
simon
> = string.upper(mytable["user"])
SIMON
>
要了解 Lua 语言,这是你的起点页面:www.lua.org/docs.html
。
Lua 经常是嵌入到编译应用程序中的首选语言。它的内存占用非常小,并且允许快速动态脚本功能——这就是在 OpenResty
中发生的事情。你不需要构建 nginx
模块,而是可以使用 Lua 脚本来扩展 Web 服务器,并通过 OpenResty 直接部署它们。
当你从你的 nginx
配置中调用一些 Lua 代码时,OpenResty 使用的 LuaJIT
(luajit.org/
)解释器将运行它们,运行速度与 nginx
代码本身相同。一些性能基准测试发现,在某些情况下 Lua 的速度可能比 C 或 C++ 快;请参考:luajit.org/performance.html
。
Lua 函数是协程,因此将在 nginx
中异步运行。这导致即使服务器收到大量并发请求时,开销也很低,这正是 WAF 所需要的。
OpenResty 以 Docker 镜像和一些 Linux 发行版的软件包的形式提供。如果需要,也可以从源代码编译;请参考 openresty.org/en/installation.html
。
在 macOS 上,你可以使用 Brew
和 brew install openresty
命令。
安装 OpenResty 后,你将获得一个 openresty
命令,它可以像 nginx
一样使用来服务你的应用程序。在以下示例中,nginx
配置将代理请求到运行在端口 5000
上的 Quart 应用程序:
# resty.conf
daemon off;
worker_processes 1;
error_log /dev/stdout info;
events {
worker_connections 1024;
}
http {
access_log /dev/stdout;
server {
listen 8888;
server_name localhost;
location / {
proxy_pass http://localhost:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
}
此配置可以使用 openresty
命令行,并在端口 8888
上以前台(守护进程关闭)模式运行,以代理转发到运行在端口 5000
上的 Quart 应用程序:
$ openresty -p $(pwd) -c resty.conf
2021/07/03 16:11:08 [notice] 44691#12779096: using the "kqueue" event method
2021/07/03 16:11:08 [warn] 44691#12779096: 1024 worker_connections exceed open file resource limit: 256
nginx: [warn] 1024 worker_connections exceed open file resource limit: 256
2021/07/03 16:11:08 [notice] 44691#12779096: openresty/1.19.3.2
2021/07/03 16:11:08 [notice] 44691#12779096: built by clang 12.0.0 (clang-1200.0.32.2)
2021/07/03 16:11:08 [notice] 44691#12779096: OS: Darwin 19.6.0
2021/07/03 16:11:08 [notice] 44691#12779096: hw.ncpu: 12
2021/07/03 16:11:08 [notice] 44691#12779096: net.inet.tcp.sendspace: 131072
2021/07/03 16:11:08 [notice] 44691#12779096: kern.ipc.somaxconn: 128
2021/07/03 16:11:08 [notice] 44691#12779096: getrlimit(RLIMIT_NOFILE): 256:9223372036854775807
2021/07/03 16:11:08 [notice] 44691#12779096: start worker processes
2021/07/03 16:11:08 [notice] 44691#12779096: start worker process 44692
注意,此配置也可以用于普通的 nginx
服务器,因为我们还没有使用任何 Lua。这就是 OpenResty 的一个优点:它是 nginx
的直接替换品,可以运行你的现有配置文件。
本节中展示的代码和配置可以在 github.com/PacktPublishing/Python-Microservices-Development-2nd-Edition/tree/main/CodeSamples
找到。
Lua 可以在请求到来时被调用;本章中最吸引人的两个时刻是:
-
access_by_lua_block
: 这在构建响应之前对每个传入请求进行调用,并且是我们构建 WAF 访问规则的地方。 -
content_by_lua_block
: 这使用 Lua 生成响应
让我们看看如何对传入请求进行速率限制。
速率和并发限制
速率限制包括在给定时间段内统计服务器接受的请求数量,并在达到限制时拒绝新的请求。
并发限制包括统计由 Web 服务器为同一远程用户服务的并发请求数量,并在达到定义的阈值时拒绝新的请求。由于许多请求可以同时到达服务器,并发限制器需要在阈值中留有小的余量。
这些技术在我们知道应用可以同时响应多少请求的上限时,可以避免应用内部出现任何问题,并且这可能是跨多个应用实例进行负载均衡的一个因素。这两个功能都是使用相同的技巧实现的。让我们看看如何构建一个并发限制器。
OpenResty 附带了一个用 Lua 编写的速率限制库,名为lua-resty-limit-traffic
;你可以在access_by_lua_block
部分中使用它:github.com/openresty/lua-resty-limit-traffic
。
该函数使用 Lua 的Shared Dict,这是一个由同一进程内的所有nginx
工作进程共享的内存映射。使用内存字典意味着速率限制将在进程级别上工作。
由于我们通常在每个服务节点上部署一个nginx
,因此速率限制将按每个 Web 服务器进行。所以,如果你为同一个微服务部署了多个节点,我们的有效速率限制将是单个节点可以处理的连接数乘以节点数——这在决定整体速率限制和微服务可以处理的并发请求数量时将非常重要。
在以下示例中,我们添加了一个lua_shared_dict
定义和一个名为access_by_lua_block
的部分来激活速率限制。请注意,这个示例是项目文档中示例的简化版本:
# resty_limiting.conf
daemon off;
worker_processes 1;
error_log /dev/stdout info;
events {
worker_connections 1024;
}
http {
lua_shared_dict my_limit_req_store 100m;
server {
listen 8888;
server_name localhost;
access_log /dev/stdout;
location / {
access_by_lua_block {
local limit_req = require "resty.limit.req"
local lim, err = limit_req.new("my_limit_req_store", 200, 100)
local key = ngx.var.binary_remote_addr
local delay, err = lim:incoming(key, true)
if not delay then
if err == "rejected" then
return ngx.exit(503)
end
ngx.log(ngx.ERR, "failed to limit req: ", err)
return ngx.exit(500)
end
if delay >= 0.001 then
local excess = err
ngx.sleep(delay)
end
}
proxy_pass http://localhost:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
}
access_by_lua_block
部分可以被视为一个 Lua 函数,并且可以使用OpenResty
公开的一些变量和函数。例如,ngx.var
是一个包含所有nginx
变量的表,而ngx.exit()
是一个可以用来立即向用户返回响应的函数——在我们的例子中,当因为速率限制需要拒绝调用时,返回一个503
。
该库使用传递给resty.limit.req
函数的my_limit_req_store
字典;每次请求到达服务器时,它都会使用binary_remote_addr
值调用incoming()
函数,这是客户端地址。
incoming()
函数将使用共享字典来维护每个远程地址的活跃连接数,并在该数字达到阈值时返回一个拒绝值;例如,当并发请求超过300
时。
如果连接被接受,incoming()
函数会发送回一个延迟值。Lua 将使用该延迟和异步的 ngx.sleep()
函数保持请求。当远程客户端未达到 200
的阈值时,延迟将为 0
,当在 200
和 300
之间时,会有一个小的延迟,这样服务器就有机会处理所有挂起的请求。
这种设计非常高效,可以防止服务因过多请求而超负荷。设置这样的上限也是避免达到一个你知道你的微服务将开始崩溃的点的好方法。例如,如果你的基准测试得出结论,你的服务在开始崩溃之前无法处理超过 100 个并发请求,你可以适当地设置速率限制,这样 nginx
就会拒绝请求,而不是让你的 Quart 微服务尝试处理所有这些传入的连接,然后再拒绝它们。
在这个例子中,用于计算速率的关键是请求的远程地址头。如果你的 nginx
服务器本身位于代理后面,确保你使用包含真实远程地址的头。否则,你将对单个远程客户端和代理服务器进行速率限制。在这种情况下,通常在 X-Forwarded-For
头中。
如果你需要一个功能更丰富的 WAF,lua-resty-waf
(github.com/p0pr0ck5/lua-resty-waf
) 项目就像 lua-resty-limit-traffic
一样工作,但提供了很多其他保护。它还能够读取 ModSecurity
规则文件,因此你可以使用 OWASP 项目中的规则文件,而无需使用 ModSecurity
本身。
其他 OpenResty 功能
OpenResty 内置了许多 Lua 脚本,这些脚本可以用来增强 nginx
。一些开发者甚至用它来直接提供数据。以下组件页面包含了一些有用的工具,用于让 nginx
与数据库、缓存服务器等交互:openresty.org/en/components.html
。
此外,还有一个网站供社区发布 OpenResty 组件:opm.openresty.org/
。
如果你正在你的 Quart 微服务前面使用 OpenResty,可能还有其他用例,你可以将 Quart 应用中的一些代码转移到 OpenResty 的几行 Lua 代码中。目标不应该是将应用的逻辑移动到 OpenResty,而应该是利用 Web 服务器在调用你的 Quart 应用之前或之后执行任何可以执行的操作。让 Python 专注于应用逻辑,而 OpenResty 则专注于一层保护。
例如,如果你正在使用 Redis 或 Memcached 服务器来缓存一些GET
资源,你可以直接从 Lua 调用它们,为特定的端点添加或检索缓存的版本。srcache-nginx-module
(github.com/openresty/srcache-nginx-module
)就是这样一种行为的实现,如果你能缓存它们,它将减少对 Quart 应用程序的GET
调用次数。
要总结关于 WAF 的这一部分:OpenResty 是一个强大的nginx
发行版,可以用来创建一个简单的 WAF 来保护你的微服务。它还提供了超出防火墙功能的特性。实际上,如果你采用 OpenResty 来运行你的微服务,Lua 将打开一个全新的可能性世界。
摘要
在本章中,我们探讨了如何在基于微服务应用程序环境中使用 OAuth2 和 JWT 来集中式地处理认证和授权。令牌赋予我们限制调用者使用某个微服务的能力,以及他们可以持续使用多长时间。
当与公钥和私钥一起使用时,它还限制了攻击者在整个应用程序的一个组件被攻破时可能造成的损害。它还确保每个连接都经过加密验证。
安全的代码库是构建安全应用程序的第一步。你应该遵循良好的编码实践,并确保你的代码在与传入的用户数据和资源交互时不会做任何坏事。虽然像 Bandit 这样的工具不能保证你代码的安全性和安全性,但它会捕捉到最明显的潜在安全问题,因此你完全没有必要犹豫是否在你的代码库上持续运行它。
最后,WAF(Web 应用防火墙)也是防止端点上的某些欺诈和滥用的好方法,使用像 OpenResty 这样的工具来做这件事非常简单,这要归功于 Lua 编程语言的力量。
OpenResty 也是通过在 Web 服务器级别做一些事情来赋予和加速你的微服务的好方法,当这些事情不需要在 Quart 应用程序内部完成时。
第八章:制作仪表板
到目前为止,大部分工作都集中在构建微服务和使它们相互交互上。现在是时候将人类纳入方程,通过用户界面(UI)让我们的最终用户能够通过浏览器使用系统,并更改可能通过 Slack 进行操作显得尴尬或不智的设置。
现代 Web 应用在很大程度上依赖于客户端 JavaScript(JS,也称为 ECMAScript)。一些 JS 框架在提供完整的模型-视图-控制器(MVC)系统方面做到了极致,该系统在浏览器中运行并操作文档对象模型(DOM),这是在浏览器中渲染的网页的结构化表示。
Web 开发范式已经从在服务器端渲染一切转变为在客户端渲染一切,客户端根据需要从服务器收集数据。原因是现代 Web 应用动态地更改已加载网页的部分,而不是调用服务器进行完整渲染。这更快,需要的网络带宽更少,并提供了更丰富的用户体验。几秒钟的延迟可能导致用户离开你的页面,除非他们有强烈的访问需求,比如更具体地说,有购物或阅读的需求。这一客户端转变的最大例子之一是 Gmail 应用,它在大约 2004 年开创了这些技术。
类似于 Facebook 的ReactJS(facebook.github.io/react/
)这样的工具提供了高级 API,以避免直接操作 DOM,并提供了一种抽象级别,使得客户端 Web 开发如同构建 Quart 应用一样舒适。
话虽如此,每两周似乎都会出现一个新的 JS 框架,而且往往很难决定使用哪一个。AngularJS(angularjs.org/
)曾经是最酷的玩具,但现在许多开发者已经转向使用 ReactJS 来实现他们的大部分应用 UI。还有一些新的语言,例如Elm(elm-lang.org
),它提供了一种编译到 JavaScript 的函数式编程语言,允许在编译时检测许多常见的编程错误,同时其运行时也能与任何浏览器兼容。毫无疑问,未来还将有新的参与者变得流行。
这种波动性根本不是什么坏信号。它仅仅意味着在 JavaScript 和浏览器生态系统中发生了大量的创新。例如,服务工作者(service workers)功能允许开发者以原生方式在后台运行 JS 代码:developer.mozilla.org/en/docs/Web/API/Service_Worker_API
。
WebAssembly
(webassembly.org/
),一个极快且安全的沙箱环境,允许开发者创建资源密集型的工具,如 3D 渲染环境,所有这些都在 Web 浏览器中运行。
如果你将 UI 与系统其他部分进行了清晰的分离,从一种 JS 框架迁移到另一种应该不会太难。这意味着你不应该改变你的微服务发布数据的方式,使其特定于 JS 框架。
对于我们的目的,我们将使用 ReactJS 来构建我们的小型仪表板,并将其包装在一个专门的 Quart 应用程序中,该应用程序将其与系统其他部分连接起来。我们还将看到该应用程序如何与所有我们的微服务交互。我们选择这种方法是因为 ReactJS 当前的流行,尽管你也会在其他任何流行的环境中获得优秀的结果。
本章由以下三个部分组成:
-
构建 ReactJS 仪表板——ReactJS 简介及示例
-
如何在 Quart 应用程序中嵌入 ReactJS 并构建应用程序结构
-
身份验证和授权
到本章结束时,你应该对如何使用 Quart 构建 Web UI 有很好的理解,并了解如何使其与微服务交互——无论你是否选择使用 ReactJS。
构建 ReactJS 仪表板
ReactJS 框架实现了对 DOM 的抽象,并提供快速高效的机制来支持动态事件。创建 ReactJS UI 涉及创建具有一些标准方法的类,这些方法将在事件发生时被调用,例如 DOM 准备就绪、React 类已加载或用户输入发生。
类似于 nginx 这样的网络服务器,处理所有困难和常见的网络流量部分,让你专注于端点的逻辑,ReactJS 允许你专注于方法实现,而不是担心 DOM 和浏览器状态。React 的类可以通过纯 JavaScript 实现,或者使用一个名为 JSX 的扩展。我们将在下一节讨论 JSX。
JSX 语法
在编程语言中表示 XML 标记可能是一项艰巨的工作。一种看似简单的方法可能是将所有标记视为字符串,并将内容格式化为模板,但这种方法意味着你的代码并不理解所有这些标记的含义。另一种极端的做法是创建每个标记元素作为对象,并将它们全部渲染为文本表示。
相反,有一个更好的混合模型,使用转换器——一种生成不同形式源代码而不是可执行程序的编译器。JSX 语法扩展([facebook.github.io/jsx/
](https://facebook.github.io/jsx/))向 JavaScript 添加 XML 标签,并可以转换为纯 JavaScript,无论是在浏览器中还是在之前。JSX 被 ReactJS 社区推广为编写 React 应用程序的最佳方式。
在下面的示例中,一个<script>
部分包含一个greeting
变量,其值是一个表示div
的 XML 结构;这种语法是有效的 JSX。从那里,ReactDOM.render()
函数可以在你指定的id
处将greeting
变量渲染到 DOM 中:
<!DOCTYPE html>
<html>
<head lang="en">
<meta charset="UTF-8">
</head>
<body>
<div id="content"></div>
<script src="img/react.development.js" crossorigin></script>
<script src="img/react-dom.development.js" crossorigin></script>
<script src="img/babel.min.js" crossorigin></script>
<script type="text/babel">
var greeting = (
<div>
Hello World
</div>)
ReactDOM.render(greeting, document.getElementById('content'));
</script>
</body>
</html>
这两个 ReactJS 脚本都是 React 分发的部分,在这里我们使用的是开发版本,它们在编写代码时将提供更有帮助的错误信息。较小的、编码过的版本——称为压缩版本——在生产使用中更受欢迎,因为它们使用更少的网络带宽和缓存存储空间。babel.min.js
文件是 Babel 分发的部分,需要在浏览器遇到任何 JSX 语法之前加载。
Babel(https://babeljs.io/
)是一个转换器,可以将 JSX 即时转换为 JS,以及其他可用的转换。要使用它,你只需将脚本标记为text/babel
类型。
JSX 语法是了解 React 的唯一特定语法差异,因为其他所有操作都是使用常见的 JavaScript 完成的。从那里,构建 ReactJS 应用程序涉及创建类来渲染标记并响应用户事件,这些类将被用来渲染网页。
现在我们来看看 ReactJS 的核心——组件。
React 组件
ReactJS 基于这样的想法:网页可以从基本组件构建,这些组件被调用以渲染显示的不同部分并响应用户事件,如键入、点击和新数据的出现。
例如,如果你想显示人员列表,你可以创建一个Person
类,该类负责根据其值渲染单个人员的详细信息,以及一个People
类,它遍历人员列表并调用Person
类来渲染每个项目。
每个类都是通过React.createClass()
函数创建的,该函数接收一个包含未来类方法的映射。createClass()
函数生成一个新的类,并设置一个props
属性来存储一些属性以及提供的方法。在下面的示例中,在一个新的 JavaScript 文件中,我们定义了一个具有render()
函数的Person
类,该函数返回一个<div>
标签,以及一个People
类,它组装Person
实例:
class Person extends React.Component {
render() {
return (
<div>{this.props.name} ({this.props.email})</div>
);
}
}
class People extends React.Component {
render() {
var peopleNodes = this.props.data.map(function (person) {
return (
<Person
key={person.email}
name={person.name}
email={person.email}
/>
);
});
return (
<div>
{peopleNodes}
</div>
);
}
}
Person
类返回一个div
——一个部分或分区——通过引用实例中的props
属性来包含关于该人的详细信息。更新这些属性将更新对象,从而更新显示。
当创建Person
实例时,props
数组会被填充;这就是在People
类的render()
方法中发生的事情。peopleNodes
变量遍历People.props.data
列表,其中包含我们要展示的人的列表。每个Person
类还提供了一个唯一的键,以便在需要时可以引用。
剩下的工作就是实例化一个 People
类,并将要由 React 显示的人员列表放入其 props.data
列表中。在我们的 Jeeves 应用中,这个列表可以由适当的微服务提供——存储信息的数据服务,或者如果我们是从第三方获取数据,则可能是另一个服务。我们可以使用内置的 fetch 方法,或者另一个辅助库,通过异步 JavaScript 和 XML(AJAX)模式加载数据。
以下代码中的 loadPeopleFromServer()
方法就是这种情况,它基于前面的示例——将其添加到同一个 jsx
文件中。代码在列出所有用户的端点上调用我们的数据服务,使用 GET
请求并期望得到一些 JSON 响应。然后,它使用结果设置 React 组件的属性,这些属性会向下传播到其他类:
class PeopleBox extends React.Component {
constructor(props) {
super(props);
this.state = { data: [] };
}
loadPeopleFromServer() {
fetch('http://localhost:5000/api/users')
.then(response => response.json())
.then(data => {
console.log(data);
this.setState({
data: data,
});
console.log(this.state);
})
.catch(function (error) {
console.log(error);
});
}
componentDidMount() {
this.loadPeopleFromServer();
}
render() {
return (
<div>
<h2>People</h2>
<People data={this.state.data} />
</div>
);
}
}
const domContainer = document.querySelector('#people_list');
ReactDOM.render(React.createElement(PeopleBox), domContainer);
当状态发生变化时,一个事件会被传递给 React
类以更新 DOM 中的新数据。框架调用 render()
方法,该方法显示包含 People
的 <div>
。反过来,People
实例将数据逐级传递给每个 Person
实例。
要触发 loadPeopleFromServer()
方法,类实现了 componentDidMount()
方法,该方法在类实例在 React 中创建并挂载后调用,准备显示。最后但同样重要的是,类的构造函数提供了一个空的数据集,这样在数据加载之前,显示就不会中断。
这个分解和链式的过程一开始可能看起来很复杂,但一旦实施,它就非常强大且易于使用:它允许你专注于渲染每个组件,并让 React 处理如何在浏览器中以最有效的方式完成它。
每个组件都有一个状态,当某个东西发生变化时,React 首先更新其自身对 DOM 的内部表示——虚拟 DOM。一旦虚拟 DOM 发生变化,React 就可以在实际的 DOM 上高效地应用所需的更改。
我们在本节中看到的所有 JSX 代码都可以保存到一个 JSX 文件中——它是静态内容,所以让我们将其放在一个名为 static
的目录中——并在以下方式中用于 HTML 页面。还有一个小的辅助微服务,用于在代码示例中提供这些文件,请参阅 github.com/PacktPublishing/Python-Microservices-Development-2nd-Edition/tree/main/CodeSamples
。
<!DOCTYPE html>
<html>
<head lang="en">
<meta charset="UTF-8">
</head>
<body>
<div class="container">
<h1>Jeeves Dashboard</h1>
<br>
<div id="people_list"></div>
</div>
<script src="img/react.development.js" crossorigin></script>
<script src="img/react-dom.development.js" crossorigin></script>
<script src="img/babel.min.js" crossorigin></script>
<script src="img/people.jsx" type="text/babel"></script>
<script type="text/babel">
</script>
</body>
</html>
在这个演示中,PeopleBox
类使用 /api/users
URL 实例化,一旦网页加载并处理完毕,componentDidMount
方法就会被触发,React 调用该 URL,并期望返回一个人员列表,然后将其传递给组件链。
注意,我们在最后两行中也设置了组件的渲染位置:首先,我们找到 HTML 中具有正确标识符的元素,然后告诉 React 在其中渲染一个类。
在浏览器中直接使用转译是不必要的,因为它可以在构建和发布应用程序时完成,正如我们将在下一节中看到的。
本节描述了 ReactJS 库的非常基本的用法,并没有深入探讨其所有可能性。如果您想了解更多关于 React 的信息,应该尝试在 reactjs.org/tutorial/tutorial.html
上的教程,这是您的第一步。这个教程展示了您的 React 组件如何通过事件与用户交互,这是您在了解如何进行基本渲染之后的下一步。
预处理 JSX
到目前为止,我们一直依赖网络浏览器为我们转换 JSX 文件。然而,我们仍然可以这样做,但这将是每个访问我们网站的浏览器所做的工作。相反,我们可以处理自己的 JSX 文件,并向访问我们网站的人提供纯 JavaScript。为此,我们必须安装一些工具。
首先,我们需要一个 JavaScript 包管理器。最重要的一个是要使用 npm
(www.npmjs.com/
)。npm
包管理器通过 Node.js
安装。在 macOS 上,brew install node
命令可以完成这项工作,或者您可以访问 Node.js 主页 (nodejs.org/en/
) 并将其下载到系统中。一旦安装了 Node.js 和 npm
,您应该能够在 shell 中调用 npm
命令,如下所示:
$ npm -v
7.7.6
将我们的 JSX 文件转换过来很简单。将我们从 static/
创建的 .jsx
文件移动到一个名为 js-src
的新目录中。我们的目录结构现在应该看起来像这样:
-
mymicroservice/
-
templates/
– 我们所有的html
文件 -
js-src/
– 我们的jsx
源代码 -
static/
– 转译后的 JavaScript 结果
-
我们可以使用以下命令安装我们需要的工具:
$ npm install --save-dev @babel/core @babel/cli @babel/preset-env @babel/preset-react
然后,为了我们的开发,我们可以启动一个命令,该命令将连续监视我们的 js-src
目录中的任何文件更改,并自动更新它们,这与 Quart 的开发版本自动重新加载 Python 文件的方式非常相似。在一个新的终端中,输入:
$ npx babel --watch js-src/ --out-dir static/ --presets @babel/preset-react
我们可以看到它为您创建了 .js
文件,并且每次您在 js-src/
中的 JSX 文件上保存更改时,它都会这样做。
要部署我们的应用程序,我们可以生成 JavaScript 文件并将它们提交到仓库,或者作为 CI 流程的一部分生成它们。在两种情况下,处理文件一次的命令都非常相似——我们只是不监视目录,并使用生产预设:
$ npx babel js-src/ --out-dir static/ --presets @babel/preset-react
在所有更改完成后,最终的 index.html
文件只需要进行一个小改动,使用 .js
文件而不是 .jsx
文件:
<script src="img/people.js"></script>
现在我们有了构建基于 React 的 UI 的基本布局,让我们看看我们如何将其嵌入到我们的 Quart 世界中。
ReactJS 和 Quart
从服务器的角度来看,JavaScript 代码是一个静态文件,因此使用 Quart 提供 React 应用程序根本不是问题。HTML 页面可以使用 Jinja2 渲染,并且可以将其与转换后的 JSX 文件一起作为静态内容提供,就像您为纯 JavaScript 文件所做的那样。我们还可以获取 React 分发版并提供服务这些文件,或者依赖 内容分发网络(CDN)来提供它们。
在许多情况下,CDN 是更好的选择,因为检索文件将更快,浏览器随后可以选择识别它已经下载了这些文件,并可以使用缓存的副本来节省时间和带宽。让我们将我们的 Quart 应用程序命名为 dashboard
,并从以下简单结构开始:
-
setup.py
-
dashboard/
-
__init__.py
-
app.py
-
templates/
-
index.html
-
static/
-
people.jsx
-
基本的 Quart 应用程序,用于服务独特的 HTML 文件,将看起来像这样:
from quart import Quart, render_template
app = Quart(__name__)
@app.route('/')
def index():
return render_template('index.html')
if __name__ == '__main__':
app.run()
多亏了 Quart 对静态资源的约定,所有包含在 static/
目录中的文件都将在 /static
URL 下提供服务。index.html
模板看起来就像前一个章节中描述的那样,并且以后可以发展成为 Quart 特有的模板。这就是我们通过 Quart 提供基于 ReactJS 的应用程序所需的所有内容。
在本节中,我们一直假设 React 选择的 JSON 数据是由同一个 Quart 应用程序提供的。在同一域上进行 AJAX 调用不是问题,但如果你需要调用属于另一个域的微服务,服务器和客户端都需要进行一些更改。
跨源资源共享
允许客户端 JavaScript 执行跨域请求是一个潜在的安全风险。如果执行在您的域客户端页面上的 JS 代码试图请求您不拥有的另一个域的资源,它可能会执行恶意 JS 代码并损害您的用户。这就是为什么所有浏览器在发起请求时都使用 W3C 标准(www.w3.org/TR/2020/SPSD-cors-20200602/
)进行跨源资源。它们确保请求只能发送到为我们提供页面的域。
除了安全之外,这也是防止某人使用您的带宽来运行他们的 Web 应用程序的好方法。例如,如果您在网站上提供了一些字体文件,您可能不希望其他网站在他们的页面上使用它们,并且在不加控制的情况下使用您的带宽。然而,有一些合法的理由想要与其他域共享您的资源,并且您可以在您的服务上设置规则以允许其他域访问您的资源。
这就是跨源资源共享(CORS)的全部内容。当浏览器向你的服务发送请求时,会添加一个Origin
头,你可以控制它是否在授权域的列表中。如果不是,CORS 协议要求你发送一些包含允许域的头信息。还有一个preflight
机制,浏览器通过OPTIONS
调用询问端点,以了解它想要发出的请求是否被授权以及服务器有哪些可用功能。在客户端,你不必担心设置这些机制。浏览器会根据你的请求为你做出决定。
在服务器端,然而,你需要确保你的端点能够响应OPTIONS
调用,并且你需要决定哪些域可以访问你的资源。如果你的服务是公开的,你可以使用通配符授权所有域。然而,对于一个基于微服务的应用程序,其中你控制客户端,你应该限制域。Quart-CORS (gitlab.com/pgjones/quart-cors/
) 项目允许我们非常简单地添加对此的支持:
# quart_cors_example.py
from quart import Quart
from quart_cors import cors
app = Quart(__name__)
app = cors(app, allow_origin="https://quart.com")
@app.route("/api")
async def my_microservice():
return {"Hello": "World!"}
当运行此应用程序并使用curl
进行GET
请求时,我们可以在Access-Control-Allow-Origin: *
头中看到结果:
$ curl -H "Origin: https://quart.com" -vvv http://127.0.0.1:5000/api
* Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to 127.0.0.1 (127.0.0.1) port 5200 (#0)
> GET /api HTTP/1.1
> Host: 127.0.0.1:5000
> User-Agent: curl/7.64.1
> Accept: */*
> Origin: https://quart.com
>
< HTTP/1.1 200
< content-type: application/json
< content-length: 18
< access-control-allow-origin: quart.com
< access-control-expose-headers:
< vary: Origin
< date: Sat, 10 Apr 2021 18:20:32 GMT
< server: hypercorn-h11
<
* Connection #0 to host 127.0.0.1 left intact
{"Hello":"World!"}* Closing connection 0
Quart-CORS 允许更细粒度的权限,使用装饰器可以保护单个资源或蓝图,而不是整个应用程序,或者限制方法为GET
、POST
或其他。还可以使用环境变量设置配置,这有助于应用程序保持灵活性,并在运行时获得正确的设置。
要深入了解 CORS,MDN 页面是一个很好的资源,可以在以下链接找到:developer.mozilla.org/en-US/docs/Web/HTTP/CORS
。在本节中,我们探讨了如何在我们的服务中设置 CORS 头以允许跨域调用,这在 JS 应用程序中非常有用。要使我们的 JS 应用程序完全功能,我们还需要认证和授权。
认证和授权
React 仪表板需要能够验证其用户并在某些微服务上执行授权调用。它还需要允许用户授权访问我们支持的任何第三方网站,例如 Strava 或 GitHub。
我们假设仪表板只有在用户认证的情况下才能工作,并且有两种用户:新用户和回访用户。以下是新用户的用户故事:
作为一名新用户,当我访问仪表板时,有一个“登录”链接。当我点击它时,仪表板将我重定向到 Slack 以授权我的资源。Slack 然后把我重定向回仪表板,我就连接上了。然后仪表板开始填充我的数据。
如描述所述,我们的 Quart 应用与 Slack 进行 OAuth2 会话以验证用户——我们知道,由于我们正在设置 Slack 机器人,人们应该已经在那里有账户了。连接到 Slack 还意味着我们需要在用户配置文件中存储访问令牌,以便我们可以在以后使用它来获取数据。
在进一步讨论之前,我们需要做出一个设计决策:我们希望仪表板与数据服务合并,还是希望有两个独立的应用?
关于微前端的一些说明
现在我们正在讨论使用 Web 前端验证我们的用户,这就引出了一个问题:我们应该把相应的代码放在哪里。前端架构中的一个近期趋势是微前端的概念。面对与后端相同的许多扩展性和互操作性难题,一些组织正在转向小型、自包含的用户界面组件,这些组件可以包含在一个更大的网站上。
让我们想象一个购物网站。当你访问首页时,会有几个不同的部分,包括:
-
购物类别
-
网站范围内的新闻和活动,例如即将到来的销售
-
销售的突出和推广商品,包括定制推荐
-
你最近查看的商品列表
-
一个允许你登录或注册账户的小部件,以及其他管理工具
如果我们开发一个单独的网页来处理所有这些元素,它很快就会变得庞大而复杂,尤其是如果我们需要在网站上的不同页面上重复元素的话。在许多网站上,这些不同的功能通过分离锚定它们的<div>
标签来保持独立,并将代码保存在单独的 JavaScript 文件中——无论这些文件在加载到网页时是否是分开的,因为它们很可能已经被编译和压缩。
这种方法引入了一些与单体后端相同的复杂性。对后端或其用户界面的任何更改都意味着更新微服务及其查询的用户界面元素,而这些可能位于不同的源代码控制存储库中,或者由不同的团队管理。可能需要引入对旧方法和新方法的支持,以便进行管理的迁移,或者需要通过不同的部署机制进行谨慎的时间安排。
通过使用微前端架构,这些 UI 功能都可以由不同的团队和服务负责。如果“推荐”功能突然需要新的后端或不同的 JavaScript 框架,这是可能的,因为主站只知道它是一个要包含的自包含功能。任何更改也可以是自包含的,因为推荐引擎的微前端 UI 组件将位于同一个存储库中,并由同一个服务提供。只要包含微前端组件的技术不改变,主用户界面就不需要改变;更改可以通过它所依赖的微服务完全控制。
这也解放了每个组件的工作人员,因为他们可以在自己的时间表上发布新功能和错误修复,而无需进行大量跨团队协调来部署多个区域的新功能。团队只需确保他们的 UI 以一致的方式包含在内,接受相同的数据,例如客户标识符,并返回所需大小的 UI 元素。
让我们以 Packt 网站为例。当加载主网页时,我们可以看到顶部有一条横幅,包含我们通常期望的选项,下面有一条横幅用于显示当前促销和活动,然后是最近添加的库存列表,以引起读者的注意:
图 8.1:Packt 主页及其组成部分
如果我们设计这个页面,我们可以构建至少三个不同的微前端:一个处理会话和登录的认证组件,一个可以显示和响应对即将到来的会议和促销的事件组件,以及一个可以显示当前库存的库存组件。这种方法并不适用于所有情况;在许多情况下,用户界面需要与其他元素紧密交互,或者组织内部的知识传播可能不允许以这种方式产生许多小的用户界面组件。
值得注意的是,这种架构不需要很多不同的 URL。同一个 nginx 负载均衡器可以被配置为将不同的 URL 路由到不同的后端服务,而客户端对此一无所知——这可能会为迁移到这种架构提供一种有用的方法,因为它降低了你需要更新端点 URL 的可能性。
话虽如此,微前端模型仍然相对较新,许多最佳实践甚至术语都还在变化之中。因此,我们将关注这种方法的简化版本,并让认证服务提供自己的 HTML 以登录用户并创建账户,如果需要,可以将其包含在另一个页面中的 iframe 中。
获取 Slack 令牌
Slack 提供了一个典型的三脚 OAuth2 实现,使用一组简单的 HTTP GET
请求。实现交换是通过将用户重定向到 Slack 并暴露一个用户浏览器在访问权限被授予后会被重定向到的端点来完成的。
如果我们请求特殊的身份识别范围,那么我们从 Slack 获得的就是用户身份的确认和唯一的 Slack ID 字符串。我们可以将所有这些信息存储在 Quart 会话中,用作我们的登录机制,并在需要时将电子邮件和令牌值传递给DataService
用于其他组件。
正如我们在第四章,设计 Jeeves中所做的那样,让我们实现一个生成要发送给用户的 URL 的函数,结合 Slack 需要的其他信息,这些信息在api.slack.com/legacy/oauth
上有文档说明:
@login.route("/login/slack")
async def slack_login():
query = {
"client_id": current_app.config["JEEVES_CLIENT_ID"],
"scope": "identify",
"redirect_uri": current_app.config["SLACK_REDIRECT_URI"],
}
url = f"https://slack.com/oauth/authorize?{urlencode(query)}"
return redirect(url)
在这里,我们正在使用 Let's Encrypt 证书在 nginx 后面运行我们的 Quart 应用程序,正如我们在第四章,设计 Jeeves中设置的那样。这就是为什么我们使用配置中的回调 URL 而不是尝试动态处理它,因为这个 URL 与 nginx 相关联。
该函数使用在 Slack 中生成的 Jeeves 应用程序的client_id
,并返回一个我们可以向用户展示的重定向 URL。仪表板视图可以根据需要更改,以便将此 URL 传递给模板。
@login.route("/")
async def index():
return await render_template("index.html", user=session.get("user"))
如果会话中存储了任何user
变量,我们也会传递一个user
变量。模板可以使用 Strava URL 来显示登录/注销链接,如下所示:
{% if not user %}
<a href="{{url_for('login.slack_login')}}">Login via Slack</a>
{% else %}
Hi {{user}}!
<a href="/logout">Logout</a>
{% endif %}
当用户点击登录
链接时,他们会被重定向到 Strava,然后返回到我们定义的SLACK_REDIRECT_URI
端点的我们的应用程序。该视图的实现可能如下所示:
@login.route("/slack/callback")
async def slack_callback():
query = {
"code": request.args.get("code"),
"client_id": current_app.config["JEEVES_CLIENT_ID"],
"client_secret": current_app.config["JEEVES_CLIENT_SECRET"],
"redirect_uri": current_app.config["SLACK_REDIRECT_URI"],
}
url = "https://slack.com/api/oauth.access"
response = requests.get(url, params=query)
response_data = response.json()
session["user"] = response_data["user_id"]
return redirect(url_for("login.index"))
使用我们从 Slack 的 OAuth2 服务获得的响应,我们将收到的临时代码放入查询中,将其转换为真实的访问令牌。然后我们可以将令牌存储在会话中或将其发送到数据服务。
我们不详细说明仪表板
如何与TokenDealer
交互,因为我们已经在第七章,保护您的服务中展示了这一点。过程是类似的——仪表板
应用程序从TokenDealer
获取令牌,并使用它来访问DataService
。
身份验证的最后部分在 ReactJS 代码中,我们将在下一节中看到。
JavaScript 身份验证
当仪表板
应用程序与 Slack 执行 OAuth2 交换时,它在会话中存储用户信息,这对于在仪表板上进行身份验证的用户来说是一个很好的方法。然而,当 ReactJS UI 调用DataService
微服务来显示用户跑步时,我们需要提供一个身份验证头。以下有两种处理此问题的方法:
-
通过仪表板 Web 应用程序使用现有的会话信息代理所有对微服务的调用。
-
为最终用户生成一个 JWT 令牌,该令牌可以存储并用于另一个微服务。
代理解决方案看起来最简单,因为它消除了为访问 DataService
而为每个用户生成一个令牌的需求,尽管这也意味着如果我们想追踪一个交易回一个个人用户,我们必须将 DataService
事件连接到前端事件列表中。
代理允许我们隐藏 DataService
的公共视图。将所有内容隐藏在仪表板后面意味着我们在保持 UI 兼容性的同时有更多的灵活性来更改内部结构。问题在于我们正在强制所有流量通过 Dashboard
服务,即使它不是必需的。对最终用户来说,我们的公开 API 和 Dashboard
看起来有通往数据的不同路由,这可能会引起混淆。这也意味着如果 DataService
发生故障,那么 Dashboard
也会受到影响,可能停止对试图查看页面的人做出响应。如果 JavaScript 直接联系 DataService
,那么 Dashboard
将继续运行,并且可以发布通知让人们知道正在发生问题。
这强烈地引导我们走向第二个解决方案,为最终用户生成一个用于 React 前端的令牌。如果我们已经将令牌处理给其他微服务,那么网络用户界面只是客户端之一。然而,这也意味着客户端有一个第二个身份验证循环,因为它必须首先使用 OAuth2 进行身份验证,然后获取 JWT 令牌用于内部服务。
正如我们在上一章中讨论的,一旦我们进行了身份验证,我们就可以生成一个 JWT 令牌,然后使用它来与其他受我们控制的服务进行通信。工作流程完全相同——它只是从 JavaScript 中调用。
摘要
在本章中,我们探讨了使用 Quart 应用程序提供的 ReactJS UI 仪表板的构建基础。ReactJS 是在浏览器中构建现代交互式 UI 的绝佳方式,因为它引入了一种名为 JSX 的新语法,这可以加快 JS 执行速度。我们还探讨了如何使用基于 npm
和 Babel
的工具链来管理 JS 依赖项并将 JSX 文件转换为纯 JavaScript。
仪表板应用程序使用 Slack 的 OAuth2 API 连接用户,并使用我们的服务进行身份验证。我们做出了将 Dashboard
应用程序与 DataService
分离的设计决策,因此令牌被发送到 DataService
微服务进行存储。该令牌然后可以被周期性工作进程以及 Jeeves 动作使用,代表用户执行任务。
最后,构建仪表板对不同的服务进行的调用是独立于仪表板进行的,这使得我们能够专注于在各个组件中做好一件事。我们的授权服务处理所有令牌生成,而我们的仪表板可以专注于对观众做出响应。
图 8.2 包含了新架构的图表,其中包括 Dashboard
应用程序:
图 8.2:完整的 Jeeves 微服务架构
您可以在 GitHub 上 PythonMicroservices 组织找到Dashboard
的完整代码,链接为github.com/PacktPublishing/Python-Microservices-Development-2nd-Edition/
.
由于它由几个不同的 Quart 应用组成,当您是一名开发者时,开发一个像 Jeeves 这样的应用程序可能是一个挑战。在下一章中,我们将探讨如何打包和运行应用程序,以便维护和升级变得更加容易。
第九章:打包和运行 Python
当 Python 编程语言在 20 世纪 90 年代初首次发布时,Python 应用程序是通过将 Python 脚本指向解释器来运行的。与打包、发布和分发 Python 项目相关的所有事情都是手动完成的。当时没有真正的标准,每个项目都有一个长长的 README,说明了如何使用所有依赖项来安装它。
较大的项目使用系统打包工具发布他们的工作——无论是 Debian 包、为 Red Hat Linux 发行版提供的 RPM 包,还是 Windows 下的 MSI 包。最终,这些项目的 Python 模块都出现在 Python 安装的site-packages
目录中,有时在编译阶段之后,如果它们有 C 扩展。
自那时起,Python 的打包生态系统已经发生了很大的变化。1998 年,Distutils
被添加到标准库中,为 Python 项目提供创建可安装分发的必要支持。从那时起,社区涌现出许多新工具,以改进 Python 项目打包、发布和分发的方式。本章将解释如何使用最新的 Python 打包工具为您的微服务。
打包的另一个热门话题是如何与您日常的工作相结合。当构建基于微服务的软件时,您需要处理许多动态部分。当您在特定的微服务中工作时,您大多数时候可以使用我们在第三章中讨论的 TDD 和模拟方法,即编码、测试和文档:良性循环。
然而,如果您想进行一些实际的测试,并检查系统的所有部分,您需要整个堆栈在本地或测试云实例上运行。此外,在这种背景下开发可能会很繁琐,如果您需要不断重新安装微服务的新版本。这导致了一个特别的问题:您如何在环境中正确安装整个堆栈并在其中开发?
这也意味着如果您想玩转应用程序,您必须运行所有微服务。在 Jeeves 的情况下,每次需要运行应用程序时都要打开多个不同的 shell 来运行所有微服务,这不是开发者每次都需要做的事情。
在本章中,我们将探讨如何利用打包工具从同一环境中运行所有微服务,然后如何通过使用专门的进程管理器从单个命令行界面(CLI)运行它们。首先,然而,我们将探讨如何打包您的项目,以及应该使用哪些工具。
打包工具链
自从那些早期的打包方法以来,Python 已经走了很长的路。许多Python 增强提案(PEPs)被编写出来,以改进 Python 项目的安装、发布和分发方式。
Distutils
存在一些缺陷,使得发布软件变得有些繁琐。最大的痛点是其缺乏依赖关系管理和处理编译和二进制发布的方式。对于与编译相关的一切,90 年代有效的方法在十年后开始过时。核心团队没有人因为缺乏兴趣而没有使库发展,也因为 Distutils
足够编译 Python 和大多数项目。需要高级工具链的人使用了其他工具,如 SCons
(scons.org/
)。
在任何情况下,由于基于 Distutils
的现有遗留系统,改进工具链都不是一项容易的任务。从头开始构建一个新的打包系统相当困难,因为 Distutils
是标准库的一部分,但引入向后兼容的更改也难以正确执行。改进是在其中进行的。像 Setuptools
和 virtualenv
这样的项目是在标准库之外创建的,并且一些更改是直接在 Python 中进行的。
到写作的时候,你仍然能找到这些变化的痕迹,而且仍然很难确切知道应该如何操作。例如,pyvenv
命令在 Python 3 的早期版本中被添加,然后在 Python 3.6 中被移除,尽管 Python 仍然附带其虚拟环境模块,尽管还有像 virtualenv
这样的工具来帮助使生活更轻松。
最好的选择是使用在标准库之外开发和维护的工具,因为它们的发布周期比 Python 短。换句话说,标准库中的更改需要数月才能发布,而第三方项目中的更改可以更快地提供。现在,所有被认为是事实标准打包工具链一部分的第三方项目都被归类在 PyPA (www.pypa.io
) 旗下项目之下。
除了开发工具之外,PyPA
还致力于通过提出 Python 的 PEPs 和开发其早期规范来改进打包标准——请参阅 www.pypa.io/en/latest/roadmap/
。在打包和依赖关系管理方面,经常会有新的工具和实验,让我们无论它们是否流行都能学到新东西。对于本章,我们将坚持使用核心、众所周知的工具。
在我们开始查看应该使用的工具之前,我们需要通过几个定义来避免任何混淆。
几个定义
当我们谈论打包 Python 项目时,一些术语可能会让人感到困惑,因为它们的定义随着时间的推移而演变,同时也因为它们在 Python 世界之外可能意味着略有不同的事情。我们需要定义 Python 包、Python 项目、Python 库和 Python 应用程序。它们的定义如下:
-
Python 包 是一个包含 Python 模块的目录树。你可以导入它,它是模块命名空间的一部分。
-
Python 项目 可以包含多个包、模块和其他资源,是你发布的内容。你用 Flask 构建的每个微服务都是一个 Python 项目。
-
Python 应用程序 是一个可以直接通过用户界面使用的 Python 项目。用户界面可以是命令行脚本或网络服务器。
-
最后,Python 库 是一种特定的 Python 项目,它为其他 Python 项目提供功能,但没有直接面向最终用户的应用程序界面。
应用程序和库之间的区别可能相当模糊,因为有些库有时会提供一些命令行工具来使用它们的一些功能,即使最初的使用案例是为其他项目提供 Python 包。此外,有时一个曾是库的项目变成了应用程序。
为了简化流程,最佳选项是不要在应用程序和库之间做区分。唯一的区别是应用程序会附带更多的数据文件和控制台脚本。
现在我们已经定义了关于 Python 包、项目、应用程序和库的术语,让我们看看项目是如何打包的。
打包
当你打包你的 Python 项目时,你需要有三个标准文件与你的 Python 包一起使用:
-
pyproject.toml
:项目构建系统的配置文件 -
setup.py
或setup.cfg
:一个特殊的模块,用于控制打包和项目的元数据 -
requirements.txt
:一个列出依赖关系的文件
让我们逐一详细看看。
setup.py 文件
当你想要与 Python 项目交互时,setup.py
文件控制着一切。当 setup()
函数执行时,它会生成一个遵循 PEP 314
格式的静态元数据文件。元数据文件包含项目的所有元数据,但你需要通过 setup()
调用来重新生成它,以便将其放入你正在使用的 Python 环境中。
你不能使用静态版本的原因是,一个项目的作者可能在 setup.py
中包含特定平台的代码,这会根据平台和 Python 版本生成不同的元数据文件。依赖于运行 Python 模块来提取关于项目的静态信息一直是一个问题。你需要确保模块中的代码可以在目标 Python 解释器中运行。如果你打算让你的微服务对社区可用,你需要记住这一点,因为安装会在许多不同的 Python 环境中发生。
在创建setup.py
文件时,一个常见的错误是在有第三方依赖项时将其导入。如果像pip
这样的工具尝试通过运行setup.py
来读取元数据,它可能会在有机会列出所有要安装的依赖项之前引发导入错误。你可以在setup.py
文件中直接导入的唯一依赖项是Setuptools
,因为你可以假设尝试安装你的项目的任何人很可能已经在他们的环境中有了它。
另一个重要的考虑因素是你想要包含以描述你的项目的元数据。你的项目只需要一个名称、一个版本、一个 URL 和一个作者就可以工作,但显然这些信息不足以描述你的项目。元数据字段通过setup()
参数设置。其中一些直接与元数据名称匹配,而另一些则不匹配。
以下是一组可用于你的微服务项目的有用参数:
-
name
: 包的名称;应该是简短的小写 -
version
: 项目的版本,如PEP 440
中定义 -
url
: 项目的 URL;可以是它的仓库或主页 -
description
: 一句话来描述项目 -
long_description
: 一个 reStructuredText 或 Markdown 文档 -
author
和author_email
: 作者的姓名和电子邮件地址——可以是组织 -
license
: 项目使用的许可证(MIT、Apache2、GPL 等) -
classifiers
: 从固定列表中选择的分类器列表,如PEP 301
中定义 -
keywords
: 描述你的项目的标签——如果你将项目发布到Python 包索引(PyPI),这很有用 -
packages
: 包含在项目中的包列表——Setuptools
可以使用find_packages()
方法自动填充该选项 -
entry_points
:Setuptools
钩子列表,如控制台脚本(这是一个Setuptools
选项) -
include_package_data
: 一个简化非 Python 文件包含的标志 -
zip_safe
: 一个标志,防止Setuptools
将项目安装为 ZIP 文件,这是一个历史标准(可执行 egg)
如果你缺少任何关键选项,那么当你尝试使用Setuptools
时,它将提供关于它需要的信息。以下是一个包含这些选项的setup.py
文件的示例:
from setuptools import setup, find_packages
with open("README.rst") as f:
LONG_DESC = f.read()
setup(
name="MyProject",
version="1.0.0",
url="http://example.com",
description="This is a cool microservice based on Quart.",
long_description=LONG_DESC,
long_description_content_type="text/x-rst",
author="Simon",
author_email="simon@example.com",
license="MIT",
classifiers=[
"Development Status :: 3 - Alpha",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
],
keywords=["quart", "microservice"],
packages=find_packages(),
include_package_data=True,
zip_safe=False,
install_requires=["quart"],
)
long_description
选项通常从README.rst
文件中提取,因此你不需要在函数中包含大段 reStructuredText 字符串。
Twine
项目(pypi.org/project/twine/
)——我们稍后将其用于上传包到 PyPI——有一个检查命令来确保长描述可以正确渲染。将此检查作为标准测试套件的一部分添加到持续集成(CI)是一个好主意,以确保 PyPI 上的文档可读。将描述分离出来的另一个好处是它会被大多数编辑器自动识别、解析和显示。例如,GitHub 将其用作您在仓库中的项目着陆页,同时提供一个内联的 reStructuredText 编辑器,可以直接从浏览器中更改它。PyPI 也这样做来显示项目的首页。
license
字段是自由形式的,只要人们可以识别所使用的许可证即可。choosealicense.com/
提供了关于哪种开源软件许可证最适合您的公正建议,如果您计划发布源代码——您应该强烈考虑这一点,因为我们的这本书的进展和所使用的各种工具都是基于开源项目,为社区添加更多内容有助于所有相关人员。在任何情况下,您都应该在setup.py
文件旁边添加一个包含该许可证官方文本的LICENCE
文件。在开源项目中,现在通常还包括一个“行为准则”,例如Contributor Covenant
:www.contributor-covenant.org/
。
这是因为与来自世界各地的人合作涉及许多不同的文化和期望,并且公开社区的性质是帮助每个人的另一个方面。
分类器选项可能是编写过程中最痛苦的一个。你需要使用来自pypi.python.org/pypi?%3Aaction=list_classifiers
的字符串来对您的项目进行分类。开发者最常用的三个分类器是支持的 Python 版本列表、许可证(它与许可证选项重复并应匹配),以及开发状态,这是关于项目成熟度的提示。
Trove
分类器是机器可解析的元数据,可以被与PyPI
交互的工具使用。例如,zc.buildout
工具寻找具有Framework :: Buildout :: Recipe
分类器的包。有效的分类器列表可在pypi.org/classifiers/
找到。
如果您将项目发布到 Python 包索引,关键词是一个使您的项目可见的好方法。例如,如果您正在创建一个Quart
微服务,您应该使用“quart”和“microservice”作为关键词。
entry_points
部分是一个类似于 INI 的字符串,它定义了通过可调用项与你的 Python 模块交互的方式——最常见的是控制台脚本。当你向该部分添加函数时,一个命令行脚本将与 Python 解释器一起安装,并通过入口点将其钩子连接。这是为你的项目创建 CLI 的好方法。在示例中,当项目安装时,mycli
应该在 shell 中直接可达。最后,install_requires
列出了所有依赖项。它是一个项目使用的 Python 项目列表,当发生安装时,pip
等工具可以使用它。如果它们在 PyPI 上发布,工具将抓取它们并安装。还可能从我们将要讨论的下一个文件requirements.txt
中读取依赖项,以及从单独的文本文件或 JSON 文件中读取版本,以便在需要时在多个地方轻松使用版本。由于 JSON 模块是标准库的一部分,因此导入它不会添加额外的依赖项。
一旦创建了setup.py
文件,尝试它的一个好方法是创建一个本地虚拟环境。
假设你已经安装了virtualenv
,并且你在包含setup.py
文件的目录中运行这些命令,它将创建几个目录,包括一个包含本地 Python 解释器的bin
目录,并将你放入本地 shell 中:
$ python3 –m venv ./my-project-venv
$ source ./my-project-venv/bin/activate
(my-project-venv) $
有几个辅助工具可以使管理你的虚拟环境更容易,例如virtualenvwrapper
(virtualenvwrapper.readthedocs.io/en/latest/
),但我们将通过我们的示例保持核心功能。
从这里,运行pip install -e command
将以可编辑模式安装项目。此命令通过读取其设置文件来安装项目,但与install
不同,安装是就地发生的。就地安装意味着你将能够直接在项目中的 Python 模块上工作,并且它们将通过其site-packages
目录链接到本地 Python 安装。
使用常规的install
调用会在本地的site-packages
目录中创建文件的副本,而更改源代码对已安装版本没有任何影响。
pip 调用还会生成一个MyProject.egg-info
目录,其中包含元数据。pip 在PKG-INFO
名称下生成元数据规范的版本 1.1:
$ more MyProject.egg-info/PKG-INFO
Metadata-Version: 2.1
Name: MyProject
Version: 1.0.0
Summary: This is a cool microservice based on Quart.
Home-page: http://example.com
Author: Simon
Author-email: simon@example.com
License: MIT
Description: long description!
Keywords: quart,microservice
Platform: UNKNOWN
Classifier: Development Status :: 3 - Alpha
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Description-Content-Type: text/x-rst
这个元数据文件是用来描述你的项目的,并且用于通过其他命令将其注册到 PyPI,正如我们在本章后面将要看到的。
pip 调用也会通过在 PyPI 上查找它们来拉取所有项目依赖项,并将它们安装在本地的site-packages
中。运行此命令是确保一切按预期工作的好方法。
requirements.txt
文件
从 pip 社区中产生的一个标准是使用 requirements.txt
文件,该文件列出了所有项目依赖项,同时也提出了一种扩展语法来安装可编辑的依赖项。请参阅 pip.pypa.io/en/stable/cli/pip_install/#requirements-file-format
。
以下是一个此类文件的示例:
arrow
python-dateutil
pytz
requests
six
stravalib
units
由于它使得记录依赖项变得更加容易,因此使用此文件已被社区广泛采用。你可以在项目中创建任意数量的需求文件,并让用户调用 pip install -r requirements.txt
命令来安装其中描述的包。
例如,你可以有一个名为 dev-requirements.txt
的文件,其中包含开发所需的额外工具,以及一个名为 prod-requirements.txt
的文件,其中包含特定于生产的依赖项。这种格式允许继承,帮助你管理需求文件集合。
使用 requirements
文件会重复 setup.py
文件 install_requires
部分中的一些信息。如前所述,我们可以读取 requirements.txt
文件并将数据包含在 setup.py
中。一些开发者故意将这些来源分开,以区分应用程序和库,使库在依赖关系上具有更大的灵活性,以便与其他已安装的库协作。这确实意味着需要保持两个信息源更新,这通常是一个混淆的来源。
如我们在本章前面所述,我们不希望通过有两种不同的方式来描述 Python 项目依赖项而使我们的生活复杂化,因为应用程序和库之间的区别可能相当模糊。为了避免在两个地方重复信息,社区中的一些工具提供了一些在 setup.py
和需求文件之间的同步自动化功能。
pip-tools
(github.com/jazzband/pip-tools
) 工具是这些实用工具之一,它通过 pip-compile
CLI 生成 requirements.txt
文件(或任何其他文件名),如下所示:
$ pip install pip-tools
...
$ pip-compile
#
# This file is autogenerated by pip-compile
# To update, run:
#
# pip-compile
#
aiofiles==0.6.0
# via quart
blinker==1.4
# via quart
click==7.1.2
# via quart
h11==0.12.0
# via
# hypercorn
# wsproto
…
在没有其他参数的情况下,pip-compile
将检查 setup.py
文件。你也可以传递一个未固定的版本文件,例如 requirements.in
,作为要使用的包列表。
注意,所有依赖项都已固定——我们想要的版本在文件中。在生产环境中,这始终是一个好主意,因为我们希望我们的应用程序是可重复的。如果我们没有指定要安装的版本,那么我们将得到最新的版本,这可能会破坏我们的应用程序。通过指定版本,我们知道我们运行的所有测试都将仍然有效,无论我们将来何时部署该版本的程序。
将依赖项的哈希值添加到requirements.txt
文件中也是一个好主意,因为这可以避免有人上传未更新版本号的包,或者恶意行为者替换现有版本的包。这些哈希值将在安装时与下载的文件进行比较,并且只有在匹配时才会使用:
$ pip-compile —generate-hashes
#
# This file is autogenerated by pip-compile
# To update, run:
#
# pip-compile —generate-hashes
#
aiofiles==0.6.0 \
—hash=sha256:bd3019af67f83b739f8e4053c6c0512a7f545b9a8d91aaeab55e6e0f9d123c27 \
—hash=sha256:e0281b157d3d5d59d803e3f4557dcc9a3dff28a4dd4829a9ff478adae50ca092
# via quart
blinker==1.4 \
—hash=sha256:471aee25f3992bd325afa3772f1063dbdbbca947a041b8b89466dc00d606f8b6
# via quart
click==7.1.2 \
—hash=sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a \
—hash=sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc
# via quart
如果你没有使用pip-tools
,pip 有一个内置的命令叫做freeze
,你可以使用它来生成一个列表,列出所有当前安装在你 Python 虚拟环境中的版本。在没有虚拟环境的情况下使用pip freeze
可能会产生很多用于其他项目的包,而不仅仅是你的工作:
$ pip freeze
aiofiles==0.6.0
blinker==1.4
click==7.1.2
h11==0.12.0
h2==4.0.0
hpack==4.0.0
…
...
当你固定依赖项时,唯一的问题是当另一个项目有相同的依赖项,但使用其他版本固定时。pip 会抱怨并无法满足两个需求集,你将无法安装所有内容。如果你正在制作库,并期望其他人使用并添加到他们自己的依赖项列表中,指定你支持的版本范围是一个好主意,这样 pip 就可以尝试解决任何依赖项冲突。例如:
quart>0.13.0,<0.15.0
在setup.py
文件中不固定依赖项,并在requirements.txt
文件中固定依赖项也是一种常见的做法。这样,pip 可以为每个包安装最新版本,当你在特定阶段或生产环境中部署时,可以通过运行pip install -r requirements.txt
命令来刷新版本。pip 将升级/降级所有依赖项以匹配版本,如果你需要的话,你可以在需求文件中调整它们。
总结来说,定义依赖项应该在每个项目的setup.py
文件中完成,如果你有一个从setup.py
文件生成它们的可重复过程,可以提供带有固定依赖项的需求文件,以避免重复。
你的项目可能还需要的一个有用文件是MANIFEST.in
文件。
MANIFEST.in
文件
当创建源或二进制发布版本时,Setuptools
会自动将所有包模块和数据文件、setup.py
文件以及一些其他文件包含在包归档中。像pip requirements
这样的文件将不会被包含。要添加它们到你的分发中,你需要添加一个MANIFEST.in
文件,其中包含要包含的文件列表。
该文件遵循一个类似于 glob 的简单语法,如下所述,其中你引用一个文件或目录模式,并说明你是否想要包含或删除匹配项:docs.python.org/3/distutils/commandref.html#creating-a-source-distribution-the-sdist-command
。
这里有一个来自 Jeeves 的例子:
include requirements.txt
include README.rst
include LICENSE
recursive-include myservice *.ini
recursive-include docs *.rst *.png *.svg *.css *.html conf.py
prune docs/build/*
包含 Sphinx 文档的docs/directory
将被集成到源分发中,但文档构建时在docs/build/
本地生成的任何工件将被删除。
一旦你有了 MANIFEST.in
文件,当你的项目发布时,所有文件都应该添加到你的分布中。
如本书所述的典型微服务项目,将包含以下文件列表:
-
setup.py
: 设置文件 -
README.rst
:long_description
选项的内容 -
MANIFEST.in
: 如果需要,这是 MANIFEST 模板 -
如果代码是开源项目,则有一份行为准则
-
requirements.txt
: 从install_requires
生成的 pip 需求文件 -
docs/
: Sphinx 文档 -
包含微服务代码的目录,通常以微服务命名,或
src/
从那里开始,发布你的项目包括创建一个源分布,这基本上是这个结构的归档。如果你有一些 C 扩展,你也可以创建一个二进制分布。
在我们学习如何创建这些发布之前,让我们看看如何为你的微服务选择版本号。
版本控制
Python 打包工具不强制执行特定的版本控制模式,尽管版本字段应该是可以使用打包模块转换为有意义的版本的版本。让我们讨论一下什么是有意义的版本号。为了理解版本控制方案,安装程序需要知道如何排序和比较版本。安装程序需要能够解析字符串并知道一个版本是否比另一个版本旧。
一些软件使用基于发布日期的方案,例如如果你的软件在 2021 年 1 月 1 日发布,则使用 20210101
。对于某些用例,这工作得非常好。如果你正在实践持续部署(CD),其中每个达到发布分支的变化都会推送到生产环境,那么可能会有如此多的变化,以至于固定的版本号难以处理。在这种情况下,基于日期的版本或版本控制哈希的版本可能工作得很好。
如果你进行了分支发布,基于日期或提交的版本控制可能不会很好用。例如,如果你的软件有大的行为变化,你需要支持旧版本一段时间,以便人们过渡,那么拥有版本 1 和 2 可以使事情变得清晰,但在这个情况下使用日期将使一些“版本 1”的发布看起来比一些“版本 2”的发布更近,这会让人困惑,不知道应该安装什么。一些软件因为这种原因结合了增量版本和日期,但很明显,使用日期并不是处理分支的最佳方式。
还存在发布 beta、alpha、候选发布和开发版本的问题。开发者希望有标记发布为预发布的能力。例如,当 Python 即将发布新版本时,它将使用 rcX
标记发布候选版本,以便社区可以在最终版本发布之前尝试它,例如,3.10.0rc1
或 3.10.0rc2
。
对于你不会向社区发布的微服务,使用这样的标记通常是不必要的——但是当你开始有来自你组织外部的人使用你的软件时,它可能变得有用。
如果你即将发布一个向后不兼容的项目版本,发布候选版本可能很有用。在发布之前让用户试用总是一个好主意。然而,对于常规发布来说,使用候选版本可能有些过度,因为当发现问题时发布新版本的成本很低。
pip 在确定大多数模式方面做得相当不错,最终会退回到一些字母数字排序,但如果所有项目都使用相同的版本控制方案,世界会变得更好。PEP 386
,然后是440
,是为了尝试为 Python 社区制定一个版本控制方案而编写的。它源自标准的MAJOR.MINOR[.PATCH]
方案,该方案在开发者中得到了广泛采用,并针对预发布和后发布版本有一些特定的规则。
语义版本控制(SemVer)(semver.org/
)方案是社区中出现的另一个标准,它在 Python 之外的地方被广泛使用。如果你使用 SemVer,只要你不使用预发布标记,你将与PEP 440
和 pip 安装程序兼容。例如,3.6.0rc2
在 SemVer 中翻译为3.6.0-rc2
。
与PEP 440
不同,SemVer 要求你始终提供三个版本号。例如,1.0
应该是1.0.0
。python-semver
库在比较不同版本时非常有帮助:github.com/python-semver/python-semver
:
>>> import semver
>>> version1 = semver.parse_version_info('2.2.3-rc2')
>>> version2 = semver.parse_version_info('2.3.1')
>>> version1 < version2
True
对于你的微服务项目,或者任何 Python 项目,你应该从0.1.0
版本开始,以清楚地表明它还不稳定,在早期开发期间可能会发生重大变化,并且不保证向后兼容性。从那里开始,你可以随意增加MINOR
号,直到你觉得软件足够成熟。
一旦达到成熟阶段,一个常见的模式是发布1.0.0
版本,然后开始遵循以下规则:
-
当你引入对现有 API 的向后不兼容更改时,
MAJOR
版本号会增加。 -
当你添加不破坏现有 API 的新功能时,
MINOR
版本号会增加。 -
只有在修复 bug 时,
PATCH
版本号才会增加。
当软件处于早期阶段时,对0.x.x
系列严格遵循此方案并没有太多意义,因为你将进行大量的向后不兼容的更改,并且你的MAJOR
版本号会迅速达到一个很高的数字。
对于开发者来说,1.0.0
版本的发布常常充满情感色彩。他们希望这是第一个稳定版本,将向世界展示——这就是为什么在软件被认为稳定时,通常使用0.x.x
版本并升级到1.0.0
的原因。
对于一个库,我们所说的 API 是所有可能导入和使用的公共和文档化的函数和类。对于一个微服务,代码 API 和 HTTP API 之间有一个区别。你可以在微服务项目中完全更改整个实现,同时仍然实现完全相同的 HTTP API。你需要区分这两个版本。
重要的是要记住,版本号不是小数,或者实际上任何形式的计数数字,所以虽然看起来 3.9
之后的下一个版本应该是 4.0
,但这并不一定——3.10
及以后的版本都是完全可以接受的。数字只是用来排序值并告诉哪个比另一个低或高。
现在我们知道了如何处理版本号,让我们来进行一些发布。
发布
要发布你的项目,我们必须构建一个包,这个包可以被上传到包仓库,如 PyPI,或者直接在任何需要的地方安装。Python 有一个构建工具,使得这个过程变得简单直接。
在以下示例中,我们安装了构建工具,然后在本章前面使用的示例项目中运行它。输出可能相当长,所以下面只包含了一部分:
$ pip install --upgrade build
...
$ python -m build
...
running bdist_wheel
running build
installing to build/bdist.macosx-10.15-x86_64/wheel
running install
running install_egg_info
running egg_info
writing MyProject.egg-info/PKG-INFO
writing dependency_links to MyProject.egg-info/dependency_links.txt
writing requirements to MyProject.egg-info/requires.txt
writing top-level names to MyProject.egg-info/top_level.txt
reading manifest file 'MyProject.egg-info/SOURCES.txt'
writing manifest file 'MyProject.egg-info/SOURCES.txt'
Copying MyProject.egg-info to build/bdist.macosx-10.15-x86_64/wheel/MyProject-1.0.0-py3.8.egg-info
running install_scripts
creating build/bdist.macosx-10.15-x86_64/wheel/MyProject-1.0.0.dist-info/WHEEL
creating '/Users/simon/github/PythonMicroservices/CodeSamples/Chapter9/pyproject-example/dist/tmpcqfu71ms/MyProject-1.0.0-py3-none-any.whl' and adding 'build/bdist.macosx-10.15-x86_64/wheel' to it
adding 'MyProject-1.0.0.dist-info/METADATA'
adding 'MyProject-1.0.0.dist-info/WHEEL'
adding 'MyProject-1.0.0.dist-info/top_level.txt'
adding 'MyProject-1.0.0.dist-info/RECORD'
removing build/bdist.macosx-10.15-x86_64/wheel
build
命令从 setup.py
和 MANIFEST.in
中读取信息,收集所有文件,并将它们放入一个存档中。结果创建在 dist
目录下:
$ ls dist/
MyProject-1.0.0-py3-none-any.whl MyProject-1.0.0.tar.gz
注意,存档的名称由项目的名称和版本组成。存档以 Wheel
格式存在,该格式在 PEP 427
中定义,目前是分发 Python 包的最佳格式,尽管过去有过不同的方法,你可能在现有的项目中遇到。这个存档可以直接使用 pip 来安装项目,如下所示:
$ pip install dist/MyProject-1.0.0-py3-none-any.whl
Processing ./dist/MyProject-1.0.0-py3-none-any.whl
Collecting quart
Using cached Quart-0.15.1-py3-none-any.whl (89 kB)
Collecting hypercorn>=0.11.2
Using cached Hypercorn-0.11.2-py3-none-any.whl (54 kB)
Collecting itsdangerous
Using cached itsdangerous-2.0.1-py3-none-any.whl (18 kB)
…
Installing collected packages: hyperframe, hpack, h11, wsproto, priority, MarkupSafe, h2, werkzeug, jinja2, itsdangerous, hypercorn, click, blinker, aiofiles, quart, MyProject
Successfully installed MarkupSafe-2.0.1 MyProject-1.0.0 aiofiles-0.7.0 blinker-1.4 click-8.0.1 h11-0.12.0 h2-4.0.0 hpack-4.0.0 hypercorn-0.11.2 hyperframe-6.0.1 itsdangerous-2.0.1 jinja2-3.0.1 priority-2.0.0 quart-0.15.1 werkzeug-2.0.1 wsproto-1.0.0
一旦你的存档准备就绪,就是时候分发它了。
分发
如果你在一个开源项目中开发,将你的项目发布到 PyPI 是一种良好的实践,这样它就可以被广泛的人群使用。可以在以下地址找到:pypi.python.org/pypi
。如果项目是私有的,或者在公司内部,那么你可能有一个类似 PyPI 的包仓库来管理你的工作,这个仓库只对你们自己的组织基础设施可见。
和大多数现代语言生态系统一样,PyPI 可以被寻找要下载的发布的安装程序浏览。当你调用 pip install <project>
命令时,pip 将浏览 PyPI 来查看该项目是否存在,以及是否有适合你平台的合适版本。
公共名称是在你的 setup.py
文件中使用的名称,你需要将其在 PyPI 上注册,以便能够发布版本。索引遵循先到先得的原则,所以如果你选择的名称已经被占用,那么你将不得不选择另一个名称。
当为应用程序或组织创建微服务时,你可以为所有项目的名称使用一个共同的名称前缀。对于不应该发布给更广泛世界的项目,也可以设置你自己的 PyPI 私有版本。然而,尽可能地为开源社区做出贡献是有帮助的。
在包级别,前缀有时也可以用来避免冲突。Python 有一个命名空间包特性,允许你创建一个顶级包名(如 jeeves
),然后在单独的 Python 项目中拥有包,最终这些包将安装到顶级的 jeeves
包下。
结果是,当你导入它们时,每个包都会得到一个共同的 jeeves
命名空间,这是一种将代码分组在同一个旗帜下的相当优雅的方式。这个特性通过标准库中的 pkgutil
模块提供。
要做到这一点,你只需要在每个项目中创建相同的顶级目录,包含 __init__.py
文件,并将所有绝对导入前缀化以包含顶级名称:
from pkgutil import extend_path
__path__ = extend_path(__path__, __name__)
例如,在 Jeeves 中,如果我们决定在同一个命名空间下发布所有内容,每个项目都可以有相同的顶级包名。在 tokendealer
中,它可以如下所示:
-
jeeves
-
__init__.py
: 包含extend_path
调用 -
tokendealer/
-
... 实际代码...
-
然后在 dataservice
目录中,如下所示:
-
jeeves
-
init.py: 包含
extend_path
调用 -
dataservice/
-
... 实际代码...
-
两者都将携带 jeeves
顶级命名空间,当 pip 安装它们时,tokendealer
和 dataservice
包最终都将安装并可在 jeeves
名称下使用:
>>> from jeeves import tokendealer, dataservice
这个特性在生产环境中并不那么有用,因为每个微服务都在单独的安装中部署,但这并不妨碍,如果你开始创建大量跨项目使用的库,它可能是有用的。目前,我们将假设每个项目都是独立的,每个名称在 PyPI 上都是可用的。
要在 PyPI 上发布版本,你首先需要使用 pypi.org/account/register/
上的表单注册一个新用户,它看起来就像 图 9.1 中所示的那样。
图 9.1:在 PyPI 上创建账户
值得注意的是,在 PyPI 的测试版本上注册也是值得的,因为这将允许你尝试上传并测试所有命令,而无需将任何内容发布到真实索引。请使用 test.pypi.org/account/register/
在测试服务上创建账户。
Python Distutils
有一个 register
和 upload
命令,可以在 PyPI 上注册新项目,但最好使用 Twine
(github.com/pypa/twine
),它提供了一个更好的用户界面。一旦你安装了 Twine
(使用 pip install twine
命令),下一步就是使用以下命令注册你的包:
$ twine register dist/jeeves-dataservice-0.1.0.tar.gz
完成后,你可以继续上传发布版本。首先让我们上传到 PyPI 的测试版本,以确保一切正常工作。上传后,我们给 pip 一些额外的参数,让它知道要使用 PyPI 的测试版本,然后回退到真正的包索引以解决其他依赖项:
$ twine upload —repository testpypi dist/*
$ pip install —index-url https://test.pypi.org/simple/ —extra-index-url https://pypi.org/simple jeeves-dataservice
一旦我们知道一切正常工作,我们就可以上传到真正的包索引:
$ twine upload dist/*
从那里,你的包应该出现在索引中,在 https://pypi.python.org/pypi/<project>
有一个 HTML 主页。pip install <project>
命令应该可以工作!
现在我们知道了如何打包每个微服务,让我们看看如何在开发目的下在同一台机器上运行它们。
运行所有微服务
到目前为止,我们使用内置的 Quart 包装器或使用 run()
函数来运行我们的 Quart
应用程序。这对于开发来说效果很好,因为应用程序可以检测其源代码的变化并自动重新加载,从而在修改时节省时间。然而,这种方法也有局限性,其中最明显的是,它是以开发模式运行服务器,开启了额外的诊断功能,这会减慢服务器的运行速度。
相反,我们应该使用 Hypercorn
(pgjones.gitlab.io/hypercorn/
) 运行我们的应用程序,这是一个允许 Quart
充分运行的 ASGI Web 服务器,支持 HTTP/2
、HTTP/3
以及 WebSocket
。它已经与 Quart 一起安装,并且使用起来非常简单。对于我们的数据服务应用程序,我们将运行:
$ hypercorn dataservice:app
Hypercorn 是一系列旨在服务于 Web 应用的 WSGI 和 ASGI 服务器中的最新成员,如果你在查找扩展时查看 Flask 文档,可能会遇到对 Gunicorn
的提及(https://gunicorn.org/
),因为它是 Hypercorn 的一个常见等效同步应用程序,使用工作池模型提供并发性,这是我们曾在 第一章,理解微服务 中讨论过的选项。然而,对于 Quart
来说,我们将坚持使用 Hypercorn
。
最后一个难题是避免需要在单独的 Bash 窗口中运行每个控制台脚本。我们希望用一个脚本管理这些进程。让我们在下一节中看看如何使用进程管理器来实现这一点。
进程管理
Hypercorn 专注于运行 Web 应用程序。如果你想要部署一个包含其他几个进程的开发环境,你必须管理多个不同的 Python 微服务、一个 RabbitMQ 实例、一个数据库以及你使用的其他任何东西。为了使你的开发环境更简单,你需要使用另一个进程管理器。
一个好的选择是像Circus
(circus.readthedocs.io
)这样的工具,它可以运行任何类型的进程,即使它们不是 ASGI 或 WSGI 应用程序。它还具有绑定套接字并使它们可用于管理进程的能力。换句话说,Circus 可以运行具有多个进程的Quart
应用程序,并在需要时管理其他进程。
Circus 是一个 Python 应用程序,因此,要使用它,您只需运行命令pip install circus
。一旦安装了 Circus,它就提供了一些命令——通过前面描述的entry_points
方法。两个主要命令是circusd
,它是进程管理器,以及circusctl
,它允许您从命令行控制进程管理器。Circus 使用类似于 INI 的配置文件,您可以在专用部分中列出要运行的命令——并且对于每一个,您想要使用的进程数。
Circus 还可以绑定套接字,并允许通过它们的文件描述符使用它们。当您的系统上创建套接字时,它使用一个文件描述符(FD),这是一个程序可以用来访问文件或像套接字这样的 I/O 资源的系统句柄。从另一个进程派生的进程继承了所有其文件描述符。也就是说,通过这种机制,Circus 启动的所有进程都可以共享相同的套接字。
在以下示例中,正在运行两个命令。第一个命令将在server.py
模块中的 Quart 应用程序上运行五个进程,使用virtualenv
路径中提供的virtualenv
,第二个命令将运行一个 Redis 服务器进程:
[watcher:web]
cmd = hypercorn —bind fd://$(circus.sockets.web) server:app
use_sockets = True
numprocesses = 5
virtualenv = ./venvs/circus-virtualenv/
copy_env = True
[watcher:redis]
cmd = /usr/local/bin/redis-server
use_sockets = False
numprocesses = 1
[socket:web]
host = 0.0.0.0
port = 8000
socket:web
部分描述了绑定 TCP 套接字时要使用的主机和端口,而watcher:web
部分通过$(circus.sockets.web)
变量使用它。当 Circus 运行时,它将替换该变量为套接字的 FD 值。要运行此脚本,您可以使用circusd
命令行:
$ circusd myconfig.ini
对于我们的微服务,使用 Circus 意味着我们可以为每个服务简单地创建一个监视器和套接字部分,然后使用circusd
命令启动它们。
Circus 还提供了将stdout
和stderr
流重定向到日志文件以方便调试和其他许多功能的选项,这些功能可以在circus.readthedocs.io/en/latest/for-ops/configuration/
找到。
摘要
在本章中,我们探讨了如何打包、发布和分发每个微服务。Python 打包的当前技术水平仍然需要一些关于遗留工具的知识,并且这种情况将在 Python 和PyPA
的所有正在进行的工作成为主流之前持续数年。但是,如果您有一个标准、可重复和文档化的方式来打包和安装您的微服务,那么您应该没问题。
当你开发单个应用程序时,拥有众多项目会增加很多复杂性,并且能够从同一个环境中运行所有组件非常重要。像 pip 的开发模式和 Circus 这样的工具对此很有用,因为它们允许你简化整个堆栈的运行方式——但它们仍然需要在你的系统上安装工具,即使是在virtualenv
内部。
从本地计算机运行所有内容的其他问题是,你可能不会使用在生产环境中运行你的服务的操作系统,或者你可能安装了一些用于其他目的的库,这可能会产生干扰。
防止这种问题的最佳方式是在隔离环境中运行你的堆栈。这就是下一章将要介绍的内容:如何在容器内运行你的服务。
第十章:在 AWS 上部署
在前面的章节中,我们直接在宿主操作系统上运行了我们的不同微服务,因为这是开始时最快的方法之一,同时也是一种通用的有用方法——特别是在可以包含在虚拟环境中的较小安装或开发中。然而,如果应用程序需要数据库或编译扩展,那么事情开始与操作系统和版本紧密耦合。其他使用略有不同系统的开发者可能会开始遇到问题,并且开发环境与生产环境之间的差异越大,发布软件时遇到的问题就越多。
虚拟机(VMs)可以是一个很好的解决方案,因为它们提供了一个隔离的环境来运行你的代码。虚拟机本质上是一块软件,它假装成一台真正的计算机,其中在一个假计算机中运行着真实的操作系统。如果你曾经使用过亚马逊 EC2 实例或谷歌计算引擎实例,那么你已经使用过虚拟机了。你可以使用像 VMware 或 VirtualBox 这样的工具在本地运行它们。
然而,虚拟机是重量级的,正是因为它们模拟了完整的计算机。从头开始使用它们需要安装操作系统或使用像 HashiCorp 的 Packer([www.packer.io/
](https://www.packer.io/))这样的工具来构建磁盘镜像——这种东西在你选择 AWS 或 GCP 实例时是预先为你构建好的。
最大的革命来自于Docker,这是一个开源的虚拟化工具,于 2013 年首次发布。Docker 允许使用称为容器的隔离环境以非常便携的方式运行应用程序。
云计算提供商,如亚马逊网络服务(AWS)、谷歌云和微软 Azure,允许人们租用他们的计算机空间,并使创建虚拟机和容器变得更加容易。通过几个鼠标点击或输入终端中的几个命令,就可以配置这些云资源,包括附加的存储和数据库。它们也可以使用配置文件进行配置,以描述资源,使用基础设施即代码工具,例如 HashiCorp 的Terraform。
在本章中,我们介绍了 Docker,并解释了如何使用它运行基于 Quart 的微服务。然后,我们介绍了使用一些常见的编排工具部署基于容器的应用程序,例如 Docker Compose、Docker Swarm,以及简要介绍 Kubernetes。许多这些主题本身就可以填满整本书,因此本章将是一个概述,它依赖于工具本身提供的安装说明来开始。
大多数云计算提供商也将拥有这些工具的版本,经过修改以更好地与其他他们提供的服务集成。如果你已经在使用某个公司的服务,那么调查他们的工具是值得的。同时,了解云无关版本也是值得的,因为采取更独立的方法可以使从一个提供商迁移到另一个提供商变得更加容易。
注意,本章中的一些说明可能会导致 AWS 产生费用。虽然我们将尽量将成本降到最低,但了解可能产生的费用很重要,可以通过与 AWS 联系来确认,并在尝试过之后取消任何未使用的资源。
Docker 是什么?
Docker (https://www.docker.com/)项目是一个容器平台,它允许你在隔离环境中运行应用程序。使用 Linux 的cgroups
功能(en.wikipedia.org/wiki/Cgroups
),Docker 创建了一个称为容器的隔离环境,在 Linux 上运行而不需要虚拟机。在 macOS 和 Windows 上,安装 Docker 会为你创建一个轻量级的虚拟机,以便在其中运行容器,尽管这是一个无缝的过程。这意味着 macOS、Windows 和 Linux 用户都可以开发基于容器的应用程序,而无需担心任何互操作性麻烦,并将它们部署到 Linux 服务器上,在那里它们将原生运行。
今天,Docker 几乎等同于容器,但还有其他容器运行时,例如CRI-O (cri-o.io/
),以及历史项目如rkt和CoreOS,它们与 Docker 一起帮助塑造了我们今天所拥有的标准化生态系统。
由于容器在 Linux 上运行时不需要仿真,容器内运行代码与外部运行之间的性能差异很小。由于 macOS 和 Windows 上存在仿真层,虽然在这些平台上运行容器在生产环境中是可能的,但这样做的好处很小。可以将运行应用程序所需的所有内容打包到容器镜像中,并在任何可以运行容器的位置分发它。
作为 Docker 用户,你只需选择你想要运行的镜像,Docker 将通过与 Linux 内核交互来完成所有繁重的工作。在这个上下文中,镜像是指创建一组在 Linux 内核上运行的进程所需的所有指令的总和,以运行一个容器。镜像包括运行 Linux 发行版所需的所有资源。例如,你可以在 Docker 容器中运行任何版本的 Ubuntu,即使宿主操作系统是不同发行版。
由于容器在基于 Linux 的系统上运行最佳,本章的其余部分假设所有内容都是在 Linux 发行版(如 Ubuntu)下安装的。
我们在第五章,拆分单体中使用了 Docker,讨论了指标和监控,所以你可能已经安装了 Docker。对于一些较老的 Linux 发行版,你可能有一个非常旧的 Docker 版本可用。直接从 Docker 本身安装一个新版本是个好主意,以获取最新的功能和安全补丁。如果你有 Docker 安装,可以直接跳到本章的下一节,Docker 简介。如果没有,你可以访问www.docker.com/get-docker
下载它并找到安装说明。社区版足以用于构建、运行和安装容器。在 Linux 上安装 Docker 很简单——你可能会找到适合你的 Linux 发行版的软件包。
对于 macOS,如果你已经安装了 Homebrew (brew.sh
),那么你可以简单地使用brew install docker
。否则,请遵循 Docker 网站上的说明。在 Windows 上,Docker 可以使用Windows 子系统 Linux(WSL2)或内置的 Hyper-V 来运行虚拟机。我们推荐使用 WSL,因为它是最容易工作的。
如果安装成功,你应该能在你的 shell 中运行docker
命令。尝试运行version
命令来验证你的安装,如下所示:
$ docker version
Client:
Cloud integration: 1.0.14
Version: 20.10.6
API version: 1.41
Go version: go1.16.3
Git commit: 370c289
Built: Fri Apr 9 22:46:57 2021
OS/Arch: darwin/amd64
Context: default
Experimental: true
Server: Docker Engine - Community
Engine:
Version: 20.10.6
API version: 1.41 (minimum version 1.12)
Go version: go1.13.15
Git commit: 8728dd2
Built: Fri Apr 9 22:44:56 2021
OS/Arch: linux/amd64
Experimental: false
containerd:
Version: 1.4.4
GitCommit: 05f951a3781f4f2c1911b05e61c160e9c30eaa8e
runc:
Version: 1.0.0-rc93
GitCommit: 12644e614e25b05da6fd08a38ffa0cfe1903fdec
docker-init:
Version: 0.19.0
GitCommit: de40ad0
Docker 安装由 Docker Engine 组成,它控制运行中的容器和一个命令行界面。它还包括 Docker Compose,这是一种安排多个容器以便它们可以一起工作的方式,以及 Kubernetes,这是一个用于部署和管理基于容器的应用程序的编排工具。
引擎提供了一个 HTTP API,可以通过本地 UNIX 套接字(通常是/var/run/docker.sock
)或通过网络访问。这意味着可以控制运行在不同计算机上的 Docker Engine,或者控制 Docker 客户端或编排工具。
现在 Docker 已经安装在你的系统上,让我们来探索它是如何工作的。
Docker 简介
让我们用 Docker 容器进行实验。运行一个你可以输入命令的容器就像以下这样:
docker run --interactive --tty ubuntu:20.04 bash
使用这个命令,我们告诉 Docker 运行 Ubuntu 镜像,该镜像将从 Docker Hub 获取,Docker Hub 是一个公共镜像的中心注册库。我们在镜像名称后提供了一个20.04
标签,以便我们下载代表 Ubuntu 20.04 操作系统的容器镜像。这不会包含常规 Ubuntu 安装的所有内容,但任何缺失的内容都是可以安装的。
我们还告诉 Docker 以交互式方式运行——使用-i
参数——并使用-t
参数分配一个tty
,这样我们就可以在容器内输入命令。默认情况下,Docker 假设你想启动一个在后台运行、处理请求的容器。通过使用这两个选项并要求在容器内运行bash
命令,我们可以得到一个可以像 Linux 外壳一样使用的 shell,而无需在容器外。
现有的每个 Linux 发行版都提供了一个基础镜像,不仅仅是 Ubuntu。还有针对运行 Python、Ruby 或其他环境的精简基础镜像,以及像 Alpine 这样的基础 Linux 镜像,目标是更小。镜像的大小很重要,因为每次你想更新它或在新的地方运行它时,都必须下载。Alpine 的大小略超过 5MB,而ubuntu:20.04
镜像的大小接近 73MB。你可以使用以下命令比较大小并管理 Docker 引擎所知的镜像——第二个命令将删除任何本地的ubuntu:20.04
镜像副本,所以如果你运行这个命令,你需要再次下载该镜像才能使用它:
$ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
python 3.9 a6a0779c5fb2 2 days ago 886MB
ubuntu 20.04 7e0aa2d69a15 3 weeks ago 72.7MB
alpine latest 6dbb9cc54074 4 weeks ago 5.61MB
$ docker rmi ubuntu:20.04
Untagged: ubuntu:20.04
Untagged: ubuntu@sha256:cf31af331f38d1d7158470e095b132acd126a7180a54f263d386da88eb681d93
Deleted: sha256:7e0aa2d69a153215c790488ed1fcec162015e973e49962d438e18249d16fa9bd
Deleted: sha256:3dd8c8d4fd5b59d543c8f75a67cdfaab30aef5a6d99aea3fe74d8cc69d4e7bf2
Deleted: sha256:8d8dceacec7085abcab1f93ac1128765bc6cf0caac334c821e01546bd96eb741
Deleted: sha256:ccdbb80308cc5ef43b605ac28fac29c6a597f89f5a169bbedbb8dec29c987439
你可能会认为大小意味着 Ubuntu 镜像总是比 Python 基础镜像更好,但 Ubuntu 镜像不包含 Python,因此为了使用它,我们必须构建一个包含我们所需一切内容的镜像,并在其上安装我们自己的软件。与其手动完成所有这些设置,我们可以使用Dockerfile (docs.docker.com/engine/reference/builder/
)。
这些 Docker 配置文件的规范名称是 Dockerfile,以下是一个基本示例:
FROM ubuntu:20.04
RUN apt-get update && apt-get install -y python3
CMD ["bash"]
Dockerfile 是一个包含一系列指令的文本文件。每一行都以大写指令开头,后跟其参数。在我们的例子中,有三个这样的指令:
-
FROM
:指向要使用的基础镜像 -
RUN
:在基础镜像安装后,在容器中运行命令 -
CMD
:当 Docker 执行容器时运行的命令
现在我们应该构建我们的镜像,并给它一个有用的名字,这样我们以后就可以引用它。在这里,我们将运行docker build
并使用ubuntu-with-python
这个名字标记新的镜像,同时使用当前目录作为构建环境——默认情况下,这也是docker build
查找 Dockerfile 的地方:
$ docker build -t ubuntu-with-python .
[+] Building 7.9s (6/6) FINISHED
=> [internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 125B 0.0s
=> [internal] load .dockerignore 0.0s
=> => transferring context: 2B 0.0s
=> [internal] load metadata for docker.io/library/ubuntu:20.04 0.0s
=> [1/2] FROM docker.io/library/ubuntu:20.04 0.0s
=> [2/2] RUN apt-get update && apt-get install -y python3 7.3s
=> exporting to image 0.4s
=> => exporting layers 0.4s
=> => writing image sha256:02602f606721f36e95fbda83af09baaa9f8256e83030197e5df69fd444e5c604 0.0s
=> => naming to docker.io/library/ubuntu-with-python 0.0s
Use 'docker scan' to run Snyk tests against images to find vulnerabilities and learn how to fix them
现在我们可以以与之前运行 Ubuntu 镜像相同的方式运行我们的新镜像:
$ docker run -it ubuntu-with-python bash
root@42b83b0933f4:/# python3
Python 3.8.5 (default, Jan 27 2021, 15:41:15)
[GCC 9.3.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>>
当 Docker 创建镜像时,它会创建一个包含 Dockerfile 中每个指令的缓存。如果你在不更改文件的情况下第二次运行build
命令,它应该在几秒钟内完成。改变指令顺序或更改指令将重建镜像,从第一个更改开始。因此,在编写这些文件时,一个好的策略是将指令排序,使得最稳定的指令(你很少更改的指令)位于顶部。
另一条很好的建议是清理每条指令。例如,当我们运行apt-get update
和apt-get install
时,这会下载大量的软件包索引文件,以及一旦安装我们就不再需要的.deb
软件包。
我们可以通过清理我们的操作来使我们的结果镜像更小,这必须在相同的RUN
命令中完成,以确保我们正在删除的数据不会作为容器镜像的一部分被写入:
$ cat Dockerfile
FROM ubuntu:20.04
RUN apt-get update && \
apt-get install -y python3 && \
apt-get clean && \
rm -fr /var/lib/apt/lists
CMD ["bash"]
$ docker build -t cleaned-ubuntu-python .
$ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
cleaned-ubuntu-python latest 6bbca8ae76fe 3 seconds ago 112MB
ubuntu-with-python latest dd51cfc39b5a 34 minutes ago 140MB
Docker 提供的一个很棒的功能是能够与其他开发者共享、发布和重用镜像。Docker Hub (hub.docker.com
)对 Docker 容器的作用就像 PyPI 对 Python 包的作用一样。
在前面的例子中,Docker 从 Hub 拉取了 Ubuntu 基础镜像,并且有大量的预存镜像可供使用。例如,如果你想启动一个针对 Python 进行了优化的 Linux 发行版,你可以查看官方 Docker Hub 网站上的 Python 页面,并选择一个(hub.docker.com/_/python/
)。
python:version
镜像基于 Debian,是任何 Python 项目的绝佳起点。
基于Alpine Linux的 Python 镜像也非常受欢迎,因为它们可以生成运行 Python 所需的最小镜像。它们的体积比其他镜像小十倍以上,这意味着它们下载和设置的速度要快得多,对于那些想在 Docker 中运行你的项目的人来说(参考gliderlabs.viewdocs.io/docker-alpine/
)。
要从 Alpine 基础镜像使用 Python 3.9,你可以创建一个 Dockerfile,如下所示:
FROM python:3.9-alpine
CMD ["python3.9"]
构建和运行这个 Dockerfile 会将你置于一个 Python 3.9 shell 中。如果你运行的应用程序不需要大量的系统级依赖项或任何编译,Alpine 集合是非常好的。然而,需要注意的是,Alpine 有一组特定的编译工具,有时与某些项目不兼容。
对于基于 Quart 的微服务项目,稍微大一点的基于 Debian 的 Python 镜像可能是一个更简单的选择,因为它具有标准的编译环境和稳定性。此外,一旦下载了基础镜像,它就会被缓存并重用,因此你不需要再次下载所有内容。
注意,使用 Docker Hub 上可信赖的人和组织提供的镜像非常重要,因为任何人都可以上传镜像。除了运行恶意代码的风险之外,还有使用未更新到最新安全补丁的 Linux 镜像的问题。Docker 还支持对镜像进行数字签名,以帮助验证镜像是你期望的,且没有经过修改。
在 Docker 中运行 Quart
要在 Docker 中运行 Quart 应用程序,我们可以使用基础 Python 镜像。从那里,安装应用程序及其依赖项可以通过 pip 完成,pip 已经包含在 Python 镜像中。
假设您的项目有一个 requirements.txt
文件用于其固定依赖项,以及一个 setup.py
文件用于安装项目,可以通过指示 Docker 如何使用 pip
命令来为您的项目创建镜像。
在以下示例中,我们介绍了 COPY
命令,该命令将递归地从容器外部复制文件和目录到镜像中。我们还添加了 EXPOSE
指令,以告知任何运行容器的用户,该端口应公开给外部世界。当我们使用 -p
选项运行容器时,我们仍然需要连接到那个公开的端口。容器内的任何进程都可以监听它想要的任何端口,并使用 localhost 与自身通信,但除非该端口已公开,否则容器外部的任何东西都无法到达容器内部。也值得注意,容器内部的 localhost 仅指容器本身,而不是托管运行容器的计算机;因此,如果您需要与其他服务通信,您将需要使用其实际 IP 地址:
FROM python:3.9
COPY . /app/
RUN pip install -r /app/requirements.txt
RUN pip install /app/
CMD ["hypercorn", "—bind", "0.0.0.0:5000", "myservice:app"]
这里使用的 3.9
标签将获取上传到 Docker Hub 的最新 Python 3.9 镜像。现在我们可以运行我们的新容器,并公开它需要的端口:
$ docker run -p 5000:5000 quart_basic
[2021-05-15 15:34:56 +0000] [1] [INFO] Running on http://0.0.0.0:5000 (CTRL + C to quit)
# In another terminal:
$ curl localhost:5000
{}
按下 Ctrl + C 停止容器,或者从另一个终端窗口中找到容器并告诉它停止:
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
040f7f01d90b quart_basic "hypercorn —bind 0.…" 2 seconds ago Up Less than a second 0.0.0.0:5000->5000/tcp, :::5000->5000/tcp stoic_bhabha
$ docker stop 040f7f01d90b
040f7f01d90b
COPY
命令自动在容器中创建顶级 app
目录,并将 ".
" 中的所有内容复制进去。关于 COPY
命令的一个重要细节是,对本地目录 (".
") 的任何更改都会使 Docker 缓存失效,并从该步骤开始构建。为了调整这个机制,您可以在 .dockerignore
文件中列出 Docker 应该忽略的文件和目录,例如存储所有版本控制历史和元数据的 .git
目录。
我们在容器内没有使用虚拟环境,因为我们已经在一个隔离的环境中。我们还使用 Hypercorn 运行我们的 Quart 应用程序,这是我们在第九章 打包和运行 Python 中讨论的生产使用的好做法。
正因如此,CMD
指令,它告诉容器启动时应该运行什么命令,使用了 Hypercorn。CMD
可以接受一个正常的 shell 命令作为参数,但这会被容器内部的 shell 解释,这意味着如果存在 shell 不同的符号解释,如 *
和 ?
,可能会出错。如果您曾经使用过 Python 的 subprocess 模块 (docs.python.org/3/library/subprocess.html
) 或使用过 exec 系统调用,那么提供一个列表会更安全,格式可能与您熟悉。
我们接下来需要做的是编排不同的容器,以便它们可以协同工作。让我们在下一节中看看我们如何做到这一点。
基于 Docker 的部署
在大规模部署微服务时,可以通过运行分布在单个或多个实例上的多个容器来实现。当我们本地开发应用程序时,我们受限于我们的一台桌面或笔记本电脑所能提供的资源;但对于生产服务来说,它可能运行在数十或数百台服务器上,每台服务器运行一个容器,提供应用程序的不同部分。您在云中部署应用程序或扩展以满足您需求的每个选项都将涉及运行更多实例,以运行更多容器。
首先要检查的是 Docker Compose,它旨在针对较小规模的部署,通常包含在一个实例中,但运行多个容器。这对于开发环境、预发布环境或原型来说非常理想。我们将探讨的其他选项包括 Docker Swarm 和 Kubernetes,它们为部署应用程序的人提供了不同级别的复杂性,但也增加了灵活性和功能。这两种选项还需要有人运行云实例或裸机服务器来运行容器。
一旦创建了 Docker 镜像,每个运行 Docker 守护进程的主机都可以用来在物理资源限制内运行尽可能多的容器。我们将探讨几个不同的选项,以获得有关功能和复杂性的广泛概述。
没有必要使您的初始应用程序过于复杂。可能会诱使您选择一个大的 Kubernetes 集群,但如果您的应用程序不需要以这种方式扩展,那将是一种浪费。使用关于您应用程序的指标和即将到来的业务变化的知识来调整计划,以满足您的需求,而不是您可能想要的需求。
要在此书和github.com/PacktPublishing/Python-Microservices-Development-2nd-Edition/tree/main/CodeSamples
上的Terraform、Docker Swarm和Kubernetes示例进行实验,您需要通过访问aws.amazon.com/
在 AWS 上创建一个账户。
一旦您设置了账户,请访问身份和访问管理(IAM)页面以创建一个可以创建和更改资源的服务用户。您可以使用您的 root 或主要账户来完成所有工作,但最好为此目的创建服务账户,因为这意味着任何泄露的访问密钥或秘密都可以轻松撤销——并创建新的——而不会对访问账户造成重大麻烦。我们应该遵循最小权限原则,正如我们在第七章,保护您的服务中讨论的那样。
一旦进入 IAM 页面,点击添加用户并请求程序访问,以便您可以使用 API 密钥在程序中使用此账户。
图 10.1:AWS 中的 IAM 添加用户页面
创建一个组以更轻松地控制用户的权限。授予这个新组修改 EC2 实例的权限,因为它涵盖了我们将要更改的大部分内容。
图 10.2:命名组和设置权限
一旦创建了组,您将有机会下载新的访问密钥 ID和秘密访问密钥。这些将用于授权访问我们用来创建实例和其他云资源的任何程序。
这些工具中的大多数都是基础设施即代码。也就是说,您将有一个配置文件,或一组文件,描述您的运行服务将看起来是什么样子,以及它们需要哪些资源。此配置还应保留在版本控制中,以便任何更改都可以得到管理。它是否与您的代码存储在同一源代码控制仓库中将取决于您如何部署软件:如果您正在持续部署新版本,那么将配置与应用程序一起保留可能会有所帮助,但在许多情况下,将其分开会更清晰,特别是如果 CI 管道在部署和代码的测试与打包任务之间难以协调。
Terraform
https://github.com/PacktPublishing/Python-Microservices-Development-2nd-Edition:
resource "aws_instance" "swarm_cluster" {
count = 3
ami = data.aws_ami.ubuntu_focal.id
instance_type = "t3.micro" # Small for this demo.
vpc_security_group_ids = [aws_security_group.swarm_security_group.id]
key_name = "your ssh key name here"
root_block_device {
volume_type = "gp2"
volume_size = "40" # GiB
encrypted = true
}
}
在这里,我们定义了一个名为swarm_cluster
的资源,我们将使用Ubuntu Focal
基础镜像创建三个新实例。我们将实例大小设置为t3.micro
,因为我们正在尝试新事物,并希望最小化成本。
https://github.com/PacktPublishing/Python-Microservices-Development-2nd-Edition/tree/main/CodeSamples.
使用 Terraform,我们可以在 CI/CD 管道中以类似测试和部署我们的应用程序代码的方式创建和销毁我们的云资源。以下有深入的教程和示例,并且有许多社区提供的模块来执行常见任务:learn.hashicorp.com/terraform
。
Terraform 的plan
命令将在您运行terraform apply
时显示对您的云基础设施所做的更改:
$ terraform plan
...
# module.docker-swarm.aws_instance.managers[2] will be created
+ resource "aws_instance" "managers" {
+ ami = "ami-0440ba4c79a163c0e"
+ arn = (known after apply)
+ associate_public_ip_address = (known after apply)
+ availability_zone = (known after apply)
…
$ terraform apply
[terraform plan output]
Do you want to perform these actions?
Terraform will perform the actions described above.
Only 'yes' will be accepted to approve.
Enter a value: yes
aws_vpc.main: Creating...
module.docker-swarm.aws_iam_policy.swarm-access-role-policy: Creating...
module.docker-swarm.aws_sns_topic.alarms: Creating...
module.docker-swarm.aws_iam_role.ec2: Creating...
module.docker-swarm.aws_s3_bucket.terraform[0]: Creating...
module.docker-swarm.aws_sns_topic.alarms: Creation complete after 0s [id=arn:aws:sns:eu-central-1:944409847308:swarm-vpc-alarms]
module.docker-swarm.aws_iam_policy.swarm-access-role-policy: Creation complete after 1s [id=arn:aws:iam::944409847308:policy/swarm-vpc-swarm-ec2-policy]
...
一旦完成任何实验,您可以通过运行terraform destroy
来清除 Terraform 管理的任何资源——尽管这是一个对于生产服务来说非常危险的命令!
服务发现
虽然 Docker 试图提供处理容器集群的所有工具,但管理它们可能会变得相当复杂。当正确执行时,它需要在主机之间共享一些配置,并确保启动和停止容器部分自动化。
我们很快就会遇到使静态配置复杂化的场景。如果我们需要将微服务迁移到新的 AWS 区域,或者完全迁移到不同的云服务提供商,那么我们如何通知使用它的所有其他微服务呢?如果我们添加了一个由功能标志控制的新功能,我们如何快速地打开和关闭它?在更小的范围内,负载均衡器如何知道应该接收流量的所有容器?
服务发现是一种旨在解决这些问题的编排方法。例如 Consul (www.consul.io/
) 和 etcd (etcd.io/
) 这样的工具允许在知名键后存储值并动态更新。
你不必完全了解所有可能连接到的 URL 来部署你的服务,你可以提供服务发现工具的地址以及它应该查找的每个元素的键列表。当微服务启动时,以及定期,它可以检查它应该发送流量到何处,或者是否应该开启某个功能。
我们将以 etcd
为例,使用基本的 Quart 服务,同时利用 etcd3
Python 库。假设你按照他们网站上的说明在默认选项下运行了 etcd
,我们可以在我们的服务中添加一些配置更新代码,并有一个返回我们如果应用程序更完整时会联系到的 URL 的端点:
# etcd_basic.py
from quart import Quart, current_app
import etcd3
# Can read this map from a traditional config file
settings_map = {
"dataservice_url": "/services/dataservice/url",
}
settings_reverse_map = {v: k for k, v in settings_map.items()}
etcd_client = etcd3.client()
def load_settings():
config = dict()
for setting, etcd_key in settings_map.items():
config[setting] = etcd_client.get(etcd_key)[0].decode("utf-8")
return config
def create_app(name=__name__):
app = Quart(name)
app.config.update(load_settings())
return app
app = create_app()
def watch_callback(event):
global app
for update in event.events:
# Determine which setting to update, and convert from bytes to str
config_option = settings_reverse_map[update.key.decode("utf-8")]
app.config[config_option] = update.value.decode("utf-8")
# Start to watch for dataservice url changes
# You can also watch entire areas with add_watch_prefix_callback
watch_id = etcd_client.add_watch_callback("/services/dataservice/url", watch_callback)
@app.route("/api")
def what_is_url():
return {"url": app.config["dataservice_url"]}
app.run()
在这个例子中,我们在应用程序启动时加载 settings_map
中的密钥,包括 /services/dataservice/url
,然后我们可以对其进行验证和使用。任何在 etcd
中值变化时,watch_callback
函数将在其自己的线程中运行,并更新应用程序的配置:
$ curl http://127.0.0.1:5000/api
{"url":"https://firsturl.example.com/api"}
$ etcdctl put "/services/dataservice/url" "https://secondurl.example.com/api"
OK
$ curl http://127.0.0.1:5000/api
{"url":"https://secondurl.example.com/api"}
更新实时配置只是一个简单的命令!
如果你的应用程序有相互依赖的配置选项,例如访问令牌对,最好将它们编码在单个选项中,以便在单个操作中更新。如果出现问题并且只有一组相互依赖的配置设置中的一个被更新,你的应用程序将以不受欢迎和不期望的方式运行。
Docker Compose
在同一主机上运行多个容器所需的命令可能会相当长,一旦你需要添加名称和网络以及绑定多个套接字。Docker Compose (docs.docker.com/compose/
) 通过允许你在单个配置文件中定义多个容器的配置以及这些容器如何相互依赖,简化了这项任务。此实用程序与 Docker 一起安装在 macOS 和 Windows 上。对于 Linux 发行版,应该有一个系统包可供安装,或者你可以按照docs.docker.com/compose/install/
中的说明获取安装脚本。
一旦脚本安装到你的系统上,创建一个包含你想要运行的服务和网络信息的 yaml
文件。默认文件名是 docker-compose.yml
,因此我们将使用该名称作为我们的示例以简化命令。
Compose 配置文件有许多选项,允许你定义多个容器部署的各个方面。它就像一组容器的 Makefile。此 URL 列出了所有选项:docs.docker.com/compose/compose-file/
。
在以下示例中,.yaml
文件位于我们的 Jeeves 微服务目录之上,并定义了三个服务:dataservice
和tokendealer
,它们是从各自的 Dockerfile 本地构建的;第三个是 RabbitMQ,我们使用 Docker Hub 上发布的镜像来运行它:
version: '3'
networks:
jeeves:
services:
dataservice:
networks:
- jeeves
build:
context: dataservice/
ports:
- "8080:8080"
tokendealer:
networks:
- jeeves
build:
context: tokendealer/
ports:
- "8090:8090"
rabbitmq:
image: "rabbitmq:latest"
networks:
- jeeves
Compose
文件也通过其networks
部分创建网络,允许容器之间进行通信。它们将获得私有 DNS 条目,因此可以使用图像名称来引用它们,例如上面示例中的dataservice
、tokendealer
和rabbitmq
。要构建和运行这三个容器,您可以使用以下up
命令:
$ docker compose up
[+] Building 9.2s (9/9) FINISHED
...
[+] Running 3/0
 Container pythonmicroservices_tokendealer_1 Created 0.1s
 Container pythonmicroservices_dataservice_1 Created 0.1s
 Container pythonmicroservices_rabbitmq_1 Created 0.1s
Attaching to dataservice_1, rabbitmq_1, tokendealer_1
第一次执行该命令时,将构建两个本地容器镜像。这些镜像可以是静态的,或者您可以分配卷给它们,将源代码挂载到上面并继续在上面开发。
当您想为微服务提供一个完整的运行堆栈时,使用 Docker Compose 是非常好的,这个堆栈包括运行它所需的每一件软件。例如,如果您正在使用 Postgres 数据库,您可以使用 Postgres 镜像(hub.docker.com/_/postgres/
)并将其链接到 Docker Compose 文件中的服务。
将所有内容,包括数据库,都进行容器化是一种展示您软件的绝佳方式,或者简单地说是开发目的的一个好选择。然而,正如我们之前所述,Docker 容器应被视为一个短暂的文件系统。因此,如果您为数据库使用容器,请确保数据写入的目录已挂载在主机文件系统上。然而,在大多数情况下,数据库服务通常是生产部署中其专用的服务器。使用容器并没有太多意义,只会增加一点开销。
Docker Swarm
Docker 有一个内置的集群功能,称为swarm模式([docs.docker.com/engine/swarm/
](https://docs.docker.com/engine/swarm/))。这种模式具有令人印象深刻的特性列表,让您可以从单个实用程序管理所有容器集群。这使得它非常适合较小的部署或不需要灵活地扩展和缩减以满足不断变化的需求。
一旦部署了集群,您需要设置一个负载均衡器,以便集群的所有实例都能共享工作负载。负载均衡器通常是 nginx、OpenResty 或 HAProxy 等软件,它是集群中分发传入请求的入口点。
要设置一个 swarm,我们实际上只需要三个 EC2 实例,前提是我们可以通过端口22
使用 SSH 访问来配置它们,以及端口2377
用于 Docker 自身的通信。我们还应该允许我们的应用程序需要的任何端口,例如端口443
用于 HTTPS 连接。
要创建一个 swarm,我们必须创建一个管理节点来组织其余部分。使用您刚刚创建的一个节点,使用 SSH 连接到它,并将其转换为 Docker Swarm 管理器:
$ sudo docker swarm init —advertise-addr <Public IP Address>
Swarm initialized: current node (l6u7ljqhiaosbeecn4jjlm6vt) is now a manager.
要向这个集群添加一个工作节点,请运行以下命令:
docker swarm join —token <some long token> 52.212.189.167:2377
要向这个集群添加一个管理节点,请运行 docker swarm join-token manager
并遵循指示。
复制提供的 docker
swarm
命令,并将其粘贴到你在其他创建的实例上的 SSH 会话中。在命令生效之前,你可能需要运行 sudo
以获得 root 权限。在管理节点上,我们现在可以看到所有我们的工作节点:
$ sudo docker node ls
ID HOSTNAME STATUS AVAILABILITY MANAGER STATUS ENGINE VERSION
6u81yvbwbvb0fspe06yzlsi13 ip-172-31-17-183 Ready Active 20.10.6
l6u7ljqhiaosbeecn4jjlm6vt * ip-172-31-26-31 Ready Active Leader 20.10.6
873cp1742grhkzoo5xd2aiqls ip-172-31-28-17 Ready Active 20.10.6
现在我们需要做的就是创建我们的服务:
$ sudo docker service create —replicas 1 —name dataservice jeeves/dataservice
sikcno6s3582tdr91dj1fvsse
overall progress: 1 out of 1 tasks
1/1: running [==================================================>]
verify: Service converged
$ sudo docker service ls
ID NAME MODE REPLICAS IMAGE PORTS
sikcno6s3582 dataservice replicated 1/1 jeeves/dataservice:latest
从这里,我们可以根据需要调整我们的服务规模。要创建我们数据服务的五个副本,我们需要发出一个缩放命令:
$ sudo docker service scale dataservice=5
只要我们的管理节点保持可用,并且一些工作节点处于运行状态,我们的容器服务就会保持活跃。我们可以终止一个云实例,并通过 docker service ps
观察事情如何重新平衡到剩余的实例。添加更多节点就像在 Terraform 配置中调整一个变量并重新运行 terraform apply
一样简单,然后再将它们加入集群。
照顾这一套云实例仍然是一项工作,但这个环境提供了一种提供弹性容器部署的整洁方式,尤其是在应用程序生命周期的早期。
Kubernetes
原本由谷歌设计,但现在由一个独立基金会维护的Kubernetes (kubernetes.io/
, 也称为 k8s) 提供了一种平台无关的方式来自动化容器化系统的操作,允许你用不同组件的术语来描述系统,并向控制器发出命令以调整设置。
与 Docker Swarm 一样,Kubernetes 也运行在一组服务器上。虽然你可以自己运行这个集群,但一些云提供商确实提供了一种服务,使得管理实例群组变得更加容易。一个很好的例子是 AWS 的 eksctl 工具 (eksctl.io/
)。虽然它不是由亚马逊创建的,但它是一个官方支持的客户端,用于在亚马逊的弹性 Kubernetes 服务中创建集群。
而不是自己创建所有 AWS 资源,或者创建 Terraform 配置来这样做,eksctl
会为你完成所有工作,并提供合理的默认值来实验 Kubernetes。要开始,最好使用我们为早期示例创建的 AWS 凭据,并安装 eksctl
和 kubectl
——Kubernetes 命令行工具。AWS 凭据将由 eksctl
用于创建集群和其他必要资源,一旦完成,就可以使用 kubectl
来部署服务和软件。与 Docker Swarm 不同,kubectl 的管理命令旨在从你的计算机上运行:
$ eksctl create cluster —name=jeeves-cluster-1 —nodes=4 —region=eu-west-1
2021-05-27 20:13:44 ![] eksctl version 0.51.0
2021-05-27 20:13:44 ![] using region eu-west-1
2021-05-27 20:13:44 ![] setting availability zones to [eu-west-1a eu-west-1c eu-west-1b]
2021-05-27 20:13:44 ![] subnets for eu-west-1a - public:192.168.0.0/19 private:192.168.96.0/19
2021-05-27 20:13:44 ![] subnets for eu-west-1c - public:192.168.32.0/19 private:192.168.128.0/19
2021-05-27 20:13:44 ![] subnets for eu-west-1b - public:192.168.64.0/19 private:192.168.160.0/19
2021-05-27 20:13:44 ![] nodegroup "ng-4e138761" will use "ami-0736921a175c8cebf" [AmazonLinux2/1.19]
2021-05-27 20:13:45 ![] using Kubernetes version 1.19
...
创建集群可能需要几分钟,但一旦完成,它就会将 kubectl
需要的凭据写入正确的文件,因此不需要进一步的设置。我们告诉 eksctl
创建四个节点,这正是它所做的事情:
$ kubectl get nodes
NAME STATUS ROLES AGE VERSION
ip-192-168-2-113.eu-west-1.compute.internal Ready <none> 8m56s v1.19.6-eks-49a6c0
ip-192-168-37-156.eu-west-1.compute.internal Ready <none> 9m1s v1.19.6-eks-49a6c0
ip-192-168-89-123.eu-west-1.compute.internal Ready <none> 9m1s v1.19.6-eks-49a6c0
ip-192-168-90-188.eu-west-1.compute.internal Ready <none> 8m59s v1.19.6-eks-49a6c0
目前,我们在k8s
集群上没有运行任何东西,所以我们将为它创建一些工作要做。k8s
的基本工作单元是pod
,它描述了集群上运行的一组容器。我们还没有创建自己的,但有一些在不同的命名空间中运行,以帮助 k8s 完成我们设定的其他任务的管理。这样的命名空间可以用于将任务集分组在一起,使在查看集群时更容易理解哪些是重要的:
$ kubectl get pods
No resources found in default namespace…
$ kubectl get pods —namespace kube-system
NAME READY STATUS RESTARTS AGE
aws-node-6xnrt 1/1 Running 0 29m
aws-node-rhgmd 1/1 Running 0 28m
aws-node-v497d 1/1 Running 0 29m
aws-node-wcbh7 1/1 Running 0 29m
coredns-7f85bf9964-n8jmj 1/1 Running 0 36m
coredns-7f85bf9964-pk7sq 1/1 Running 0 36m
kube-proxy-4r7fw 1/1 Running 0 29m
kube-proxy-dw9sv 1/1 Running 0 29m
kube-proxy-p7qqv 1/1 Running 0 28m
kube-proxy-t7spn 1/1 Running 0 29m
Pod 是对集群中某些工作的低级描述,为了使生活更轻松,还有针对不同类型工作的更高级抽象,例如用于无状态应用(如 Web 界面或代理)的Deployment
,当你的工作负载需要附加存储而不是将数据保存在不同的服务中时,使用StatefulSet
,以及用于一次性任务和计划重复任务的Jobs
和CronJobs
。
Kubernetes 接受它应该应用的指令的清单。一个好的起点是设置 nginx,使用如下清单:
# nginx.yml
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx-deployment
labels:
app: nginx
spec:
replicas: 3
selector:
matchLabels:
app: nginx
template:
metadata:
labels:
app: nginx
spec:
containers:
- name: nginx
image: nginx:1.21.0
ports:
- containerPort: 80
我们在请求的资源类型(Deployment
)及其名称中包含一些元数据,然后深入到服务的规范中。在文件的底部,我们可以看到我们请求了一个基于nginx:1.21.0 image
的容器,并且它应该打开端口80
。再往上一层,我们把这个容器规范描述为一个模板,我们用它来创建三个不同的副本并在我们的集群上运行。
$ kubectl apply -f nginx.yml
deployment.apps/nginx-deployment created
$ kubectl get pods
NAME READY STATUS RESTARTS AGE
nginx-deployment-6c4ccd94bc-8qftq 1/1 Running 0 21s
nginx-deployment-6c4ccd94bc-hqt8c 1/1 Running 0 21s
nginx-deployment-6c4ccd94bc-v7zpl 1/1 Running 0 21s
使用 kubectl 的describe
子命令,我们可以获取更多关于为我们创建的内容的信息:
$ kubectl describe deployment nginx-deployment
Name: nginx-deployment
Namespace: default
CreationTimestamp: Thu, 27 May 2021 21:06:47 +0100
Labels: app=nginx
Annotations: deployment.kubernetes.io/revision: 1
Selector: app=nginx
Replicas: 3 desired | 3 updated | 3 total | 3 available | 0 unavailable
StrategyType: RollingUpdate
MinReadySeconds: 0
RollingUpdateStrategy: 25% max unavailable, 25% max surge
Pod Template:
Labels: app=nginx
Containers:
nginx:
Image: nginx:1.21.0
Port: 80/TCP
Host Port: 0/TCP
Environment: <none>
Mounts: <none>
Volumes: <none>
Conditions:
Type Status Reason
—— ——— ———
Available True MinimumReplicasAvailable
Progressing True NewReplicaSetAvailable
OldReplicaSets: <none>
NewReplicaSet: nginx-deployment-6c4ccd94bc (3/3 replicas created)
Events:
Type Reason Age From Message
—— ——— —— —— ———-
Normal ScalingReplicaSet 14m deployment-controller Scaled up replica set nginx-deployment-6c4ccd94bc to 3
如果我们决定需要更多的 nginx 容器,我们可以更新清单。将我们的yaml
文件中的副本数量从三个更改为八个,并重新应用清单:
$ kubectl get pods -l app=nginx
NAME READY STATUS RESTARTS AGE
nginx-deployment-6c4ccd94bc-7g74n 0/1 ContainerCreating 0 2s
nginx-deployment-6c4ccd94bc-8qftq 1/1 Running 0 17m
nginx-deployment-6c4ccd94bc-crw2t 1/1 Running 0 2s
nginx-deployment-6c4ccd94bc-fb7cf 0/1 ContainerCreating 0 2s
nginx-deployment-6c4ccd94bc-hqt8c 1/1 Running 0 17m
nginx-deployment-6c4ccd94bc-v7zpl 1/1 Running 0 17m
nginx-deployment-6c4ccd94bc-zpd4v 1/1 Running 0 2s
nginx-deployment-6c4ccd94bc-zwtcv 1/1 Running 0 2s
可以进行类似的更改来升级 nginx 的版本,Kubernetes 有几种策略来执行服务的更新,以便最终用户不太可能注意到它的发生。例如,可以创建一个全新的容器 Pod 并将其流量重定向到它,但也可以在 Pod 内部进行滚动更新,其中容器只有在它的替代品成功启动后才会被销毁。如何判断容器已成功启动?Kubernetes 允许你描述它应该查找的内容以检查容器是否可以执行其工作,以及它应该等待容器启动多长时间,使用其存活性和就绪性检查。
如果你一直在跟随示例,记得当你完成时删除云资源,因为它们会产生费用。要删除我们创建的 nginx-deployment,请使用kubectl
。
$ kubectl delete -f nginx.yml
deployment.apps "nginx-deployment" deleted
但要销毁整个集群,请返回使用eksctl
:
$ eksctl delete cluster —name=jeeves-cluster-1 —region=eu-west-1
2021-05-27 21:33:22 ![] eksctl version 0.51.0
2021-05-27 21:33:22 ![] using region eu-west-1
2021-05-27 21:33:22 ![] deleting EKS cluster "jeeves-cluster-1"
2021-05-27 21:33:23 ![] deleted 0 Fargate profile(s)
2021-05-27 21:33:23 ![] kubeconfig has been updated
2021-05-27 21:33:23 ![] cleaning up AWS load balancers created by Kubernetes objects of Kind Service or Ingress
2021-05-27 21:33:25 ![] 2 sequential tasks: { delete nodegroup "ng-4e138761", delete cluster control plane "jeeves-cluster-1" [async] }
2021-05-27 21:33:26 ![] will delete stack "eksctl-jeeves-cluster-1-nodegroup-ng-4e138761"
...
这只是一个关于一个极其强大工具的简要概述,因为这个主题本身可能需要一本书来详细阐述。对于那些需要它的人来说,花时间学习 Kubernetes 是值得的,但正如以往一样,你必须评估你自己的应用需求,以及是否有一些更简单的方法可以完成任务。
摘要
在本章中,我们探讨了如何使用容器将微服务容器化,以及如何完全基于 Docker 镜像创建部署。容器是一种成熟的技术,广泛用于运行互联网服务。需要牢记的最重要的一点是,容器化应用是短暂的:它被设计成按需销毁和重建,并且任何未通过挂载点外部化的数据都会丢失。
对于服务配置和集群,没有通用的解决方案,因为使用的工具将取决于你的需求。从简单的 Docker Compose 设置到完整的 Kubernetes 集群,每个选项都提供了不同的复杂性和好处。最佳选择通常取决于你打算在哪里部署你的服务,你的团队如何工作,以及你的应用当前需要多大——为不可知的未来规划是没有意义的。
解决这个问题的最佳方式是先通过手动部署一切来迈出小步,然后在合理的地方进行自动化。自动化很棒,但如果使用你不完全理解的工具集,或者工具过于复杂,它很快就会变得困难。
作为指导,请考虑:
-
当需要在小型环境中部署多个容器,且不需要管理大型基础设施时,使用 Docker Compose。
-
当你需要灵活性来部署多少个容器以应对不断变化的情况,并且愿意管理更大的云基础设施时,使用 Docker Swarm。
-
当自动化和灵活性至关重要,且你有人员和时间来管理基础设施和处理复杂性时,使用 Kubernetes。
一旦你选择了某个编排工具,你不会局限于它,因为构建的容器可以在任何工具中使用,但根据你的配置复杂程度,迁移到不同的编排工具可能会是一项艰巨的工作。
在这个方向上,为了使他们的服务更容易使用和更具吸引力,云服务提供商内置了处理部署的功能。目前最大的三个云服务提供商是 AWS、Google Cloud 和 Microsoft Azure,尽管存在许多其他不错的选择。
第十一章:接下来是什么?
在这本书中,我们讨论了使用 Quart 框架编写的 Python 微服务的设计和开发。我们构建了一个单体应用程序作为工作基础,并介绍了从该架构迁移到最佳利用微服务的架构的策略,以及可能出现的潜在错误以及如何避免它们。我们还学习了如何使用基于容器的服务将我们的应用程序部署到云服务提供商。
然而,这并不是故事的结束,还有其他一些话题值得进一步了解。我们的自动化和工具总是有改进的空间,以帮助服务保持更新,还有更多关于性能和容量管理的问题需要回答,我们的监控和日志可以帮助解决这些问题,以及如何扩展和改变我们的部署架构以提高服务的可靠性和可用性的考虑。最后,我们需要记住——除非是为了爱好编写代码——软件本身并不是最终目标,我们必须履行对需要软件的人的承诺。
自动化
我们简要讨论了Terraform作为自动化创建基于云资源的方法,还有更多关于这个工具以及其他可以自动化运行服务的一些工作的工具需要学习。
要在实例内部进行配置,配置管理工具如Ansible、Chef和Puppet允许你复制文件、更改文件内容、安装软件包,并以可重复、可预测的方式设置计算机,使其符合你的喜好。
使用 HashiCorp 的Packer为你的环境构建操作系统镜像,它允许你使用上述配置管理工具创建操作系统镜像,用于 AWS、GCP、VMware 或 Docker 等许多其他环境。
即使你的基础设施很小,使用自动化来创建和维护它仍然很有价值。在发生灾难的情况下,你只需几条简短的命令就可以重新创建你整个应用程序套件,而不是几周痛苦的劳动。
在创建基础设施代码时,很容易不小心创建一个新的单体,负责创建和维护每个组件。如果这是一个深思熟虑的选择,那么它将工作得很好,但同时也值得记住将功能分离成更小的项目所带来的其他原则,比如权限分离和易于维护。以下是一些相关的链接:
-
Terraform:
www.terraform.io/
-
Ansible:
www.ansible.com/
-
Chef:
www.chef.io/
-
Puppet:
puppet.com/
扩展
当应用程序需要做更多工作时,传统的方法是在更大的计算机上运行应用程序。给它更多的内存、更多的 CPU 核心,甚至更多的磁盘空间。这并不会增加应用程序的可靠性,因为它仍然依赖于单一计算机,而且一旦应用程序足够大,就没有足够的计算机可以运行它时,这会带来额外的复杂性。
将程序运行在更大型的计算机上被称为垂直扩展。相比之下,水平扩展则是使用许多小型计算机的方法。我们在讨论基于容器的服务部署以及增加我们使用的 Docker 集群实例数量时遇到了这个想法。为了以这种方式运行,应用程序必须有一个复制的、可扩展的当前状态的概念,以便客户端会话、购物车内容以及访客期望在网站的不同页面之间持久存在的任何其他内容。
微服务允许您更容易地扩展应用程序,尽管重要的是要记住,每个组件都会与其他微服务通信,并且一个区域的负载增加将对其他区域产生后果。
仔细监控将使您能够发现整个系统中的瓶颈,从而确定哪个区域最需要紧急工作,以便为系统提供更多容量。
内容分发网络
我们应用程序提供的一些内容并不经常改变,例如 HTML 页面、JavaScript、图像和视频流。内容分发网络(CDNs)旨在提供全球范围内分布的静态内容。它们可以作为您应用程序前端的层或与其并行工作,能够比定制化服务更快地向客户端提供可缓存的 内容。一些 CDNs 还允许您根据客户端及其网络质量动态调整图像和视频,或者提供针对分布式拒绝服务攻击的保护,使它们成为任何基于 Web 的服务的重要工具。
多云部署
在评估运行服务的风险时,很容易意识到您的组织完全依赖于一个云服务提供商。为了提高冗余性,一个常见的愿望是将服务部署到多个提供商,并将工作负载分散到 Azure、GCP、Amazon 和其他服务上。这听起来可能是个好主意,但它也引入了许多复杂性,因为不同的提供商有不同的功能集可用,需要独特的安全安排,并且无法共享存储和秘密管理。
虽然Terraform
可以帮助处理这种情况,但通常更可行的是在同一提供者内追求多个区域,如果确实需要多个云提供者,可以根据它们之间的交互方式来区分它们运行的内容。将完全独立的服务放在其他地方要容易得多。这与战略方法和将单体拆分为微服务的做法有相似之处,因为成功的迁移需要不同组件之间的清晰接口和良好结构化的关注点和需求隔离。
Lambda 函数
Lambda,或云函数,是一种无服务器部署类型,旨在用于小型、短暂的任务,可以非常快速地扩展和缩减。尽管在 2021 年,异步框架在这个领域支持有限,但它们与同步代码广泛结合使用,因为它们的运行方式意味着响应性由可以同时运行的实例数量控制。
扩展监控
在第五章,拆分单体中,我们讨论了监控和收集指标以记录应用程序正在做什么。测量可以讲述一些故事,并给出涉及计数、大小或时间流逝的画面。为了获取更多信息,我们可以使用日志服务来记录应用程序产生的消息。
如果你已经设置了一个 Linux 服务器,你可能熟悉通过rsyslog
传递并通过位于/var/log
中的文件结束的日志。在云服务中,尤其是在容器中,本地日志记录几乎没有什么用处,因为我们不得不调查所有运行的容器和云实例以发现发生了什么。相反,我们可以使用集中式日志服务。
可以使用 AWS CloudWatch 或 Google 的 Cloud Logging 等工具来完成这项工作,但也可以运行Splunk
或Logstash
等服务。后者是被称为ELK
堆栈的流行开源工具组合的一部分,因为它包含了 Elasticsearch、Logstash 和 Kibana,用于收集、搜索和可视化日志数据。使用这些工具,系统和应用程序的所有日志最终都可以集中在一个地方,并便于检查。
使用结构化日志技术,注释所有日志条目以轻松确定哪个微服务产生了它们,从而更好地关联事件也是一件简单的事情。集中式日志服务将允许你连接一个组件中的错误和来自不同区域的报告之间的联系。同时,每个微服务更加隔离,这意味着它们对其他组件的影响应该通过设计的接口,而不是由于同一服务器上的资源限制或同一进程树中的副作用。
ELK 堆栈是收集大量日志和指标的一个很好的起点,你可以在www.elastic.co/what-is/elk-stack
了解更多关于它的信息。
承诺
在编写软件时,我们通常不是孤立地工作,而是为了帮助我们的公司或开源项目实现目标。仅仅依靠我们的直觉来判断我们是否做得好往往具有误导性,因为我们的本能会受到人类所有不同偏见的影响。相反,我们必须进行测量——收集数据,观察模式,并分析数据。
为了展示我们的软件表现如何,无论是对我们自己还是对他人,我们可以考虑三个级别。第一个是我们可以测量的可能事物的列表,这些被称为服务级别指标(SLIs)。作为开发者,我们很容易列出与技术相关的 SLIs,例如:
-
API 响应时间(以毫秒为单位)
-
不同 HTTP 状态码的计数
-
每个请求中传输的字节数
然而,包含组织级别的指标同样至关重要,例如:
-
在线商店的结账过程需要多长时间
-
有多少潜在客户在结账过程中放弃购买
-
运行服务的财务成本,尤其是自动扩展和缩减的服务
当这两种类型的指标结合使用时,可以为组织提供非常有用的报告和仪表板,但你也不希望不断地检查事情——还有其他工作要做!服务级别目标(SLO)在服务级别指标(SLI)的基础上设置一个阈值或警报值,例如:
-
不到 1% 的 HTTP 状态码必须表示服务器错误
-
完成的结账操作率必须超过 75%
-
用户可以成功完成至少 99.9% 的请求而不会出现错误
如果未达到 SLO,我们应该怎么做?这就是服务级别协议(SLA)发挥作用的地方。SLA 是服务提供者和使用者之间的一种合同——无论是正式的还是非正式的——当 SLO 未达到时,它描述了应该发生什么。
这里有一个涵盖所有级别的示例:
-
服务级别指标:记录的 HTTP 500 错误数量
-
服务级别目标:HTTP 500 错误不应超过总请求的 1%
-
服务级别协议:当有站点可靠性工程师被通知,受影响的客户被通知时
创建 SLO 帮助开发人员和产品团队成员了解应用程序的重要之处,并让我们向所有相关人员展示应用程序正在做它应该做的事情。
摘要
作为软件开发者,我们永远不会停止提高我们的技能和知识,尝试新技术和架构,并建立在许多人的工作之上。我们职业的核心技能是以理性、有条理的方式处理情况,将问题的每一部分分解成可管理的块,并确保我们——以及对我们工作有利益相关的人——能够理解这一切。
微服务方法在系统设计中使用了相同的技巧,使得每个组件更容易进行推理和调查。像许多方法一样,当它经过仔细考虑而不是仅仅为了追随潮流时,它工作得非常好。
设计良好的应用程序需要知识、技能和经验的结合,我们希望这本书已经为你在工作中带来的专业知识做出了贡献,无论是付费工作、志愿服务还是爱好。