Python-架构模式-全-
Python 架构模式(全)
原文:
zh.annas-archive.org/md5/6f69bfb2569ae444d5094faa98735c8e译者:飞龙
前言
软件的发展意味着随着时间的推移,系统变得越来越复杂,需要越来越多的开发者以协调的方式共同工作。随着规模的增加,一个一般结构由此产生。如果这个结构没有良好规划,可能会变得非常混乱且难以处理。
软件架构的挑战在于规划和设计这个结构。良好的架构使不同的团队能够相互交互,同时对自己的责任和目标有清晰的理解。
系统的架构应该设计得尽可能减少阻力,使得日常软件开发变得可能,从而能够添加功能和扩展系统。在运行中的系统中,架构也总是处于变化之中,可以进行调整和扩展,以故意和流畅的方式重塑不同的软件元素。
在这本书中,我们将看到软件架构的不同方面,从顶层到支持更高视角的一些较低级别的细节。本书分为四个部分,涵盖了生命周期中的所有不同方面:
-
在编写任何代码之前进行设计
-
使用经过验证的方法的架构模式
-
实际代码中的设计实现
-
持续的运营以涵盖变化,并验证一切是否按预期工作
在本书中,我们将涵盖所有这些方面的不同技术。
这本书面向的对象
这本书是为想要扩展他们对软件架构知识的软件开发者而写的,无论是经验丰富的开发者想要扩展和巩固他们对复杂系统的直觉,还是经验较少的开发者想要学习和成长,面对更大系统时拥有更广阔的视角。
我们将使用 Python 编写的代码作为示例。虽然你不需要成为专家,但一些基本的 Python 知识是推荐的。
这本书涵盖的内容
第一章,软件架构简介,介绍了软件架构是什么以及为什么它有用,以及提供了一个设计示例。
书的第一部分涵盖了在编写软件之前的设计阶段:
第二章,API 设计,展示了设计有用 API 的基本知识,这些 API 可以方便地抽象操作。
第三章,数据建模,讨论了存储系统的特性以及如何为应用程序设计适当的数据表示。
第四章,数据层,讨论了存储数据的代码处理,以及如何使其适合用途。
接下来,我们将展示一个部分,涵盖可用的不同架构模式,这些模式重用了经过验证的结构:
第五章,十二要素应用方法,展示了这种方法如何包括在操作网络服务时可能有用的良好实践,并且可以应用于各种情况。
第六章,Web 服务器结构,解释了网络服务和在确定操作和软件设计时需要考虑的不同元素。
第七章,事件驱动结构,描述了另一种异步工作的系统,它接收信息而不立即返回响应。
第八章,高级事件驱动结构,解释了异步系统的更高级用法以及可以创建的一些不同模式。
第十章,微服务与单体,介绍了这两种用于复杂系统的架构,并概述了它们之间的差异。
书中的实现部分涵盖了代码的编写方式:
第十章,测试和 TDD,讨论了测试的基础以及如何将测试驱动开发用于编码过程。
第十一章,包管理,遵循创建代码可重用部分的过程以及如何分发它们。
最后,最后一部分涉及持续操作,其中系统正在运行并需要同时进行监控、调整和更改:
第十二章,日志记录,描述了如何记录工作系统正在做什么。
第十三章,度量,讨论了聚合不同值以查看整个系统的行为。
第十四章,性能分析,解释了如何理解代码的执行以改进其性能。
第十五章,调试,涵盖了深入挖掘代码执行过程以查找和修复错误的过程。
第十六章,持续架构,描述了如何在运行系统中成功操作架构变更。
要充分利用本书
-
本书使用 Python 语言编写代码示例,并假设读者能够阅读它,尽管不需要专家水平。
-
对具有多个服务的复杂系统的先前接触将有助于理解软件架构所提出的不同挑战。这对有几年或更多经验的开发者来说应该是简单的。
-
熟悉网络服务和 REST 接口有助于更好地理解一些元素。
下载示例代码文件
本书代码包托管在 GitHub 上,网址为github.com/PacktPublishing/Python-Architecture-Patterns。我们还有其他来自我们丰富图书和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们吧!
下载彩色图像
我们还提供了一份包含本书中使用的截图/图表的彩色图像的 PDF 文件。您可以从这里下载:static.packt-cdn.com/downloads/9781801819992_ColorImages.pdf (ColorImages.pdf)
使用的约定
本书使用了多种文本约定。
CodeInText: 表示文本中的代码词汇、对象名称、模块名称、文件夹名称、文件名、文件扩展名、路径名、虚拟 URL 和用户输入。以下是一个示例:“在这个菜谱中,我们需要导入requests模块。”
代码块设置如下:
def leonardo(number):
if number in (0, 1):
return 1
# EXAMPLE COMMENT
return leonardo(number - 1) + leonardo(number - 2) + 1
注意,代码可能为了简洁和清晰而被编辑。当需要时,请参考 GitHub 上的完整代码。
任何命令行输入或输出都按以下方式编写(注意$符号):
$ python example_script.py parameters
任何 Python 解释器中的输入都按以下方式编写(注意>>>符号)。预期输出将不带>>>符号:
>>> import logging
>>> logging.warning('This is a warning')
WARNING:root:This is a warning
要进入 Python 解释器,请使用不带参数的python3命令:
$ python3
Python 3.9.7 (default, Oct 13 2021, 06:45:31)
[Clang 13.0.0 (clang-1300.0.29.3)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>>
任何命令行输入或输出都按以下方式编写:
$ cp example.txt copy_of_example.txt
粗体: 表示新术语、重要词汇或您在屏幕上看到的词汇,例如在菜单或对话框中,这些词汇在文本中也会这样显示。例如:“从管理面板中选择系统信息。”
警告或重要提示如下所示。
技巧和窍门如下所示。
联系我们
我们始终欢迎读者的反馈。
一般反馈:请通过feedback@packtpub.com发送电子邮件,并在邮件主题中提及书籍的标题。如果您对本书的任何方面有疑问,请通过questions@packtpub.com发送电子邮件给我们。
勘误: 尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们非常感谢您能向我们报告。请访问www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。
盗版: 如果您在互联网上以任何形式发现我们作品的非法副本,我们非常感谢您能提供位置地址或网站名称。请通过copyright@packtpub.com与我们联系,并附上材料的链接。
如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com。
分享您的想法
一旦您阅读了《Python 架构模式》,我们非常乐意听到您的想法!请点击此处直接访问此书的亚马逊评论页面并分享您的反馈。
您的评论对我们和科技社区都非常重要,它将帮助我们确保提供高质量的内容。
第一章:软件架构简介
本章的目标是介绍什么是软件架构以及它在哪些方面有用。我们将探讨在定义系统架构时使用的一些基本技术,以及网络服务架构的基线示例。
本章讨论了软件结构对团队结构和沟通的影响。由于任何非微小的软件的成功构建都严重依赖于一个或多个开发团队之间的成功沟通和协作,因此应考虑这一因素。此外,软件的结构可能对如何访问不同元素有深远的影响,因此软件的结构对安全性有影响。
此外,在本章中,我们将简要介绍我们将使用来展示本书其余部分不同模式和讨论的示例系统的架构。
在本章中,我们将涵盖以下主题:
-
定义系统的结构
-
将其划分为更小的单元
-
软件架构中的康威定律
-
示例的一般概述
-
软件架构的安全性方面
让我们深入探讨。
定义系统的结构
软件开发的本质是创建和管理复杂系统。
在计算机的早期,程序相对简单。最多,它们可能能够计算抛物线轨迹或分解数字。最早的计算机程序是由阿达·洛芙莱斯在 1843 年设计的,计算了伯努利数列。在那之后的一百年,在第二次世界大战期间,电子计算机被发明出来以破解加密代码。随着这一新发明可能性的探索,越来越多的复杂操作和系统被设计出来。编译器和高级语言等工具增加了可能性的数量,硬件的快速进步使得更多的操作得以执行。这迅速产生了管理不断增长的复杂性和将一致性的工程原则应用于软件创建的需求。
在计算机产业诞生 50 多年后,我们可用的软件工具种类繁多且功能强大。我们站在巨人的肩膀上构建自己的软件。我们可以以相对较小的努力快速添加许多功能,无论是利用高级语言和 API,还是使用现成的模块和包。随着这种巨大的力量而来的是管理由此产生的复杂性爆炸的巨大责任。
简而言之,软件架构定义了软件系统的结构。这种架构可以有机地发展,通常在项目的早期阶段,但在系统增长和几个变更请求之后,仔细思考架构的需求变得越来越重要。随着系统变得更大,结构变得更难以更改,这会影响未来的努力。遵循结构进行更改比违反结构进行更改更容易。
使某些更改变得困难并不一定是坏事。应该变得困难的更改可能涉及需要由不同团队监督的元素,或者可能影响外部客户的元素。虽然主要重点是创建一个未来易于且高效更改的系统,但一个明智的架构设计将根据需求在易用性和难度之间取得适当的平衡。在本章的后面部分,我们将研究安全作为一个明确的例子,说明何时保持某些操作难以实施。
因此,软件架构的核心是审视全局:关注系统未来将处于何种状态,能够将这种观点具体化,同时也帮助当前情况。在短期收益和长期运营之间的选择在开发中非常重要,其最常见的后果是技术债务的产生。软件架构主要处理长期影响。
软件架构的考虑因素可能相当多,需要在它们之间取得平衡。一些例子可能包括:
-
业务愿景,如果系统将要进行商业利用。这可能包括来自营销、销售或管理的利益相关者的需求。业务愿景通常由客户驱动。
-
技术要求,例如确保系统可扩展并能处理一定数量的用户,或者系统足够快以适应其用例。新闻网站需要与实时交易系统不同的更新时间。
-
安全和可靠性问题,其严重性取决于应用程序和数据存储的风险或关键程度。
-
任务划分,允许多个团队,可能在不同领域专业化,同时以灵活的方式在同一系统上工作。随着系统的发展,将它们划分为半自主的、更小的组件的需求变得更加迫切。小型项目可能通过“单一块”或单体方法存在得更久。
-
使用特定技术,例如,允许与其他系统集成或利用团队中现有的知识。
这些考虑将影响系统的结构和设计。在某种程度上,软件架构师负责实现应用程序的愿景,并将其与将开发它的特定技术和团队相匹配。这使得软件架构师成为业务团队和技术团队之间,以及不同技术团队之间的重要中间人。沟通是这个职位的一个关键方面。
为了实现成功的沟通,一个好的架构应该定义不同方面之间的边界,并分配明确的职责。除了定义清晰的边界之外,软件架构师还应促进系统组件之间的接口通道的创建,并跟进实施细节。
理想情况下,架构设计应该在系统设计之初进行,基于项目需求进行深思熟虑的设计。这是本书中的一般方法,因为这是解释不同选项和技术最佳的方式。但在现实生活中,这并不是最常见的用例。
软件架构师面临的主要挑战之一是处理需要适应的现有系统,朝着更好的系统逐步改进,同时不中断保持业务运行的正常日常运营。
将系统划分为更小的单元
软件架构的主要技术是将整个系统划分为更小的元素,并描述它们如何相互交互。每个较小的元素或单元应该有一个明确的职能和接口。
例如,一个典型系统的常见架构可能是一个由以下部分组成的网络服务架构:
-
一个存储所有数据的 MySQL 数据库
-
一个提供用 PHP 编写的动态 HTML 内容的网络工作者
-
一个 Apache 网络服务器处理所有网络请求,返回任何静态文件,如 CSS 和图像,并将动态请求转发给网络工作者

图 1.1:典型的网络架构
这种架构和技术堆栈自 2000 年代初以来一直非常流行,被称为 LAMP,这是一个由涉及的不同开源项目组成的缩写:(L)inux 作为操作系统,(A)pache,(M)ySQL,和(P)HP。如今,这些技术可以被替换为等效的技术,例如使用 PostgreSQL 代替 MySQL 或 Nginx 代替 Apache,但仍然使用 LAMP 这个名字。LAMP 架构可以被认为是使用 HTTP 设计基于网络的客户端/服务器系统的默认起点,为构建更复杂的系统提供了一个坚实和经过验证的基础。
如您所见,每个不同的元素在系统中都有独特的功能。它们以明确定义的方式相互交互。这被称为单一职责原则。当面对新功能时,大多数用例将明确属于系统的一个元素。任何样式更改将由 Web 服务器处理,动态更改由 Web 工作器处理。元素之间存在依赖关系,因为数据库中存储的数据可能需要更改以支持动态请求,但这些依赖关系可以在处理过程中早期检测到。
我们将在第九章中更详细地描述这种架构。
每个元素都有不同的需求和特性:
-
数据库需要可靠,因为它存储了所有数据。维护工作,如备份和恢复相关的工作,将非常重要。数据库不会频繁更新,因为数据库非常稳定。对表架构的更改将通过 Web 工作器中的重启进行。
-
Web 工作器需要可扩展且不存储任何状态。相反,所有数据都将从数据库发送和接收。这个元素将经常更新。可以在同一台机器或多台机器上运行多个副本,以实现水平扩展。
-
Web 服务器可能需要一些新的样式更改,但这不会经常发生。一旦配置设置正确,这个元素将保持相当稳定。每台机器只需要一个 Web 服务器,因为它能够平衡多个 Web 工作器的负载。
如我们所见,元素之间的工作平衡非常不同,因为 Web 工作器将是大多数新工作的焦点,而其他两个元素将更加稳定。数据库需要特定的工作以确保其处于良好状态,因为它可以说是三个元素中最关键的一个。其他两个元素在出现问题时可以快速恢复,但数据库中的任何损坏都会引发大量问题。
系统中最关键和最有价值的元素几乎总是存储的数据。
通信协议也是独特的。Web 工作器使用 SQL 语句与数据库进行通信。Web 服务器通过专用接口与 Web 工作器通信,通常是 FastCGI 或类似的协议。Web 服务器通过 HTTP 请求与外部客户端通信。Web 服务器和数据库之间不直接通信。
这三个协议是不同的。对于所有系统来说,情况不一定如此;不同的组件可以共享相同的协议。例如,可以有多个 RESTful 接口,这在微服务中很常见。
进程内通信
观察不同单元的典型方式是它们作为独立运行的不同进程,但这不是唯一的选择。同一个进程内的两个不同模块仍然可以遵循单一职责原则。
单一职责原则可以在不同的层面上应用,并用于定义功能或其他块之间的划分。因此,它可以在越来越小的范围内应用。这是“乌龟一直到底”!但是,从架构的角度来看,高级元素是最重要的,因为它是定义结构的高层。清楚地知道在细节上走多远显然很重要,但在采用架构方法时,最好是偏向“大局”而不是“过多细节”。
这的一个清晰的例子是一个独立维护的库,但也可能是代码库中的某些模块。例如,你可以创建一个模块来执行所有的外部 HTTP 调用,并处理保持连接、重试、错误处理等所有复杂性,或者你可以创建一个模块来根据某些参数生成多种格式的报告。
重要的特性是,为了创建一个独立的元素,API 需要被明确定义,责任也需要被明确界定。模块应该能够被提取到不同的仓库中,并作为第三方元素安装,这样才被认为是真正独立的。
仅创建具有内部划分的大组件是一种众所周知的模式,称为单体架构。上面描述的 LAMP 架构就是这样一个例子,因为大部分代码都是在 web worker 内部定义的。单体通常是项目的实际默认起点,因为通常在开始时没有大的计划,当代码库较小时,将事物严格划分为多个组件并没有很大的优势。随着代码库和系统的日益复杂,单体内部的元素划分开始变得有意义,后来可能开始有意义地将其拆分为几个组件。我们将在第九章,微服务与单体中进一步讨论单体。
在同一组件内部,通信通常是直接的,因为会使用内部 API。在绝大多数情况下,会使用相同的编程语言。
康威定律——对软件架构的影响
在处理架构设计时始终需要牢记的一个关键概念是康威定律。康威定律是一个广为人知的格言,它假设组织引入的系统反映了组织结构的通信模式(www.thoughtworks.com/insights/articles/demystifying-conways-law):
任何设计系统(广义上)的组织都会产生一个结构,其结构与组织的通信结构相匹配。
—— 梅尔文·E·康威
这意味着组织的员工结构,无论是明确还是隐含地,都会被复制,以形成组织创建的软件结构。在一个非常简单的例子中,一个有两个大型部门的公司——比如说采购和销售——可能会倾向于创建两个大型系统,一个专注于采购,另一个专注于销售,它们之间相互通信,而不是其他可能的架构,比如按产品划分的体系。
这可能感觉是自然的;毕竟,团队间的沟通比团队内的沟通更困难。团队间的沟通需要更加结构化,并需要更多的主动工作。单个团队内部的沟通会更加流畅,不那么僵化。这些元素对于良好软件架构的设计至关重要。
任何软件架构成功应用的主要因素是团队结构需要非常紧密地遵循设计的架构。试图偏离太多会导致困难,因为趋势将是根据群体划分来结构化一切。同样,改变系统的架构可能需要重组组织。这是一个困难和痛苦的过程,任何经历过公司重组的人都会证实这一点。
职责划分也是一个关键方面。单个软件元素应该有一个明确的负责人,而且这个负责人不应该分散到多个团队中。不同的团队有不同的目标和重点,这会复杂化长期愿景并产生紧张关系。
相反,一个团队负责多个元素也是可能的,但这也需要仔细考虑,以确保不会过度压垮团队。
如果工作单元分配到团队之间的不平衡很大(例如,一个团队的工作单元太多,而另一个团队的工作单元太少),那么很可能是系统的架构存在问题。
随着远程工作的日益普遍,团队越来越多地分布在世界的不同地区,沟通也受到了影响。这就是为什么建立不同的分支机构来处理系统的不同元素,并使用详细的 API 来克服地理距离的物理障碍变得非常普遍。沟通的改善也对协作能力产生了影响,使远程工作更加高效,并允许完全远程的团队能够紧密地共同工作在同一代码库上。
近期的 COVID-19 危机极大地增加了远程工作的趋势,尤其是在软件行业。这导致越来越多的人远程工作,并使用适应这种工作方式的更好工具。尽管时区差异仍然是沟通的一个大障碍,但越来越多的公司和团队正在学习以全远程模式有效地工作。记住,康威定律在很大程度上取决于组织的沟通依赖性,但沟通本身可以改变和改进。
康威定律不应被视为需要克服的障碍,而应视为组织结构对软件结构有影响的反映。软件架构与不同团队如何协调以及责任如何划分紧密相关。它包含一个重要的人际沟通成分。
考虑这一点将有助于您设计一个成功的软件架构,以便在任何时候沟通流程都是流畅的,并且您可以提前识别问题。当然,软件架构与人为因素紧密相关,因为架构最终将由工程师实施和维护。
应用程序示例 – 概述
在这本书中,我们将使用一个应用程序作为示例来展示所呈现的不同元素和模式。这个应用程序将简单,但为了演示目的,将分为不同的元素。示例的完整代码可在 GitHub 上找到,不同章节将展示其不同部分。示例是用 Python 编写的,使用了知名的框架和模块。
示例应用程序是一个微博客网络应用程序,非常类似于 Twitter。本质上,用户将撰写简短的文本消息,可供其他用户阅读。
该示例系统的架构在本图中描述:

自动生成的描述](https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/py-arch-ptn/img/B17580_01_02.png)
图 1.2:示例架构
它具有以下高级功能元素:
-
一个可访问的公共网站,使用 HTML 编写。这包括登录、登出、撰写新微帖子以及阅读其他用户的微帖子(阅读不需要登录)的功能。
-
一个公共 RESTful API,允许使用其他客户端(移动、JavaScript 等)而不是 HTML 网站。这将使用 OAuth 对用户进行身份验证并执行类似于网站的操作。
这两个元素虽然不同,但将被合并成一个单一的应用程序,如图所示。应用程序的前端部分将包括一个 Web 服务器,正如我们在 LAMP 架构描述中看到的,这里为了简单起见没有展示。
-
一个将执行事件驱动任务的任务管理器。我们将添加周期性任务,计算每日统计数据,并在用户被提及在微帖子中时向用户发送电子邮件通知。
-
一个数据库,存储所有信息。请注意,不同元素之间可以共享对它的访问。
-
在内部,有一个常见的包来确保所有服务正确访问数据库。这个包作为一个不同的元素工作。
软件架构的安全方面
在创建架构时需要考虑的一个重要元素是安全要求。并非每个应用程序都是相同的,因此有些可能在这一点上比其他更宽松。例如,银行应用程序需要比讨论猫的互联网论坛安全 100 倍。最常见的例子是密码的存储。对密码最天真方法是存储它们,以纯文本形式与用户名或电子邮件地址关联——比如在一个文件或数据库表中。当用户尝试登录时,我们接收输入的密码,将其与之前存储的密码进行比较,如果它们相同,我们允许用户登录。对吧?
嗯,这是一个非常糟糕的想法,因为它可能会产生严重的问题:
- 如果攻击者可以访问应用程序的存储,他们将能够读取所有用户的密码。用户倾向于重复使用密码(即使这是一个坏主意),因此,与他们的电子邮件配对,他们将在多个应用程序上暴露于攻击,而不仅仅是被破坏的那个。
这可能看起来不太可能,但请记住,存储的任何数据副本都容易受到攻击,包括备份。
-
另一个真实的问题是内部威胁,可能合法访问系统但出于恶意目的或错误地复制数据的员工。对于非常敏感的数据,这可能是一个非常重要的考虑因素。
-
例如,在状态日志中显示用户密码的错误。
为了确保安全,数据需要以尽可能保护的方式结构化,防止访问或甚至复制,同时不暴露用户的真实密码。通常的解决方案是采用以下架构:
-
密码本身并不存储。相反,存储的是密码的加密哈希值。这将对密码应用一个数学函数并生成一个可复制的位序列,但反向操作在计算上非常困难。
-
由于哈希值是基于输入的确定性,恶意行为者可以检测到重复的密码,因为它们的哈希值相同。为了避免这个问题,为每个账户添加一个随机字符序列,称为“盐”。这意味着具有相同密码但不同盐的两个用户将具有不同的哈希值。
-
结果的哈希值和盐都会被存储。
-
当用户尝试登录时,他们的输入密码会添加到盐中,然后将结果与存储的哈希值进行比较。如果正确,用户将被登录。
注意,在这个设计中,实际密码对系统来说是未知的。它不会被存储在任何地方,只是在临时接受并与预期的哈希值进行比较后进行处理。
这个例子是以简化的方式呈现的。有多种使用此模式的方法和比较哈希的不同方式。例如,bcrypt函数可以被多次应用,每次加密都会增加,这会增加生成有效哈希所需的时间,使其对暴力攻击更具抵抗力。
与直接存储密码的系统相比,这种系统更安全,因为操作系统的人不知道密码,也没有任何地方存储密码。
在状态日志中错误地显示用户密码的问题仍然可能发生!应该格外小心,以确保敏感信息没有被错误地记录。
在某些情况下,可以采取与密码相同的方法来加密其他存储数据,以便只有客户可以访问他们自己的数据。例如,可以为通信通道启用端到端加密。
安全性与系统的架构有着非常紧密的联系。正如我们之前所看到的,架构定义了哪些方面容易改变,哪些方面难以改变,并且可以使某些不安全的行为变得不可能,例如,像我们在上一个例子中所描述的那样,知道用户的密码。其他选项包括不存储用户数据以保持隐私或减少内部 API 中暴露的数据,例如。软件安全是一个非常困难的问题,通常是一把双刃剑,试图使系统更加安全可能会产生使操作变得冗长和不方便的副作用。
摘要
在本章中,我们探讨了软件架构是什么以及何时需要它,以及它对长期方法的关注,这是该学科的特点。我们了解到软件的底层结构很难改变,因此在设计和更改软件系统时应该考虑这一方面。
我们描述了如何将复杂系统分解成更小的部分,并为每个部分分配清晰的目标和目标,同时考虑到这些较小的部分可以使用多种编程语言并引用不同的范围。我们还描述了 LAMP 架构以及它如何是创建简单 Web 服务系统的广泛成功的起点。
我们讨论了康威定律如何影响系统的架构,因为底层团队结构对软件的实现和结构有直接影响。毕竟,软件是由人类操作和开发的,人类沟通需要被考虑到才能成功实施。
我们描述了本书中将使用的例子,以描述我们将要展示的不同元素和模式。最后,我们评论了软件架构的安全方面以及如何通过作为系统结构设计一部分的数据访问障碍来缓解安全问题。
在本书的下一节中,我们将讨论设计一个系统的不同方面。
第一部分
设计
我们将首先花一些时间解释设计系统的基本步骤。我的建议如下:“设计是任何成功系统的第一阶段,包括你开始实施之前所做的一切工作。”在本节中,我们将关注系统的每个元素的通用原则和核心方面。
设计系统时,应将以下两个主要核心元素放在首位:接口,即系统元素如何与其他元素连接,以及数据存储,即该元素如何存储可以稍后检索的信息。
这两点都是至关重要的。接口定义了系统及其功能,从任何用户的角度来看。一个设计良好的接口隐藏了实现细节,并提供了一些抽象,使得执行操作的方式既一致又全面。
几乎每个成功运行系统的核心都是数据。这是系统价值所在。任何经验丰富的工程师都会告诉你,当数据可用时,组织可以重建系统,即使生成它的代码丢失,也不如从数据的完全丢失中恢复,即使应用程序代码可用。
因此,数据的存储是系统的核心。在存储我们的数据时,我们有多种选择。选择哪种数据库?将数据存储在一个数据存储设施中,还是几个?传统的使用原始数据库访问的方式,通常是在普通的 SQL 语句中,并不是最有效的方法,而且在涉及复杂系统时容易出问题。还存在其他类型的数据库,甚至不使用 SQL。我们将探讨多种选项及其优缺点。
一旦系统开始运行,改变系统中数据的存储方式就很困难。虽然并非不可能,但需要大量工作。存储选项可以说是设计新系统时的基石,所以请确保所选选项符合您的需求。设计一个既不过于复杂又允许分配空间随着应用程序开始存储越来越多的数据而增长的东西可能很困难。
本书本节包括以下章节:
-
API 设计,描述如何创建既实用又灵活的接口
-
数据建模,以不同的方式处理和表示数据,以确保从一开始就充分考虑这一关键方面
第二章:API 设计
在本章中,我们将讨论基本的应用程序编程接口(API)设计原则。我们将了解如何通过定义有用的抽象来开始我们的设计,这些抽象将为设计奠定基础。
我们将接着介绍 RESTful 接口的原则,涵盖严格的、学术性的定义以及更实用的定义,以帮助进行设计。我们将探讨设计方法和技巧,以帮助基于标准实践创建有用的 API。我们还将花一些时间讨论认证,因为这是大多数 API 的关键元素。
我们将在这本书中专注于 RESTful 接口,因为它们现在是最常见的。在此之前,还有其他替代方案,包括 20 世纪 80 年代的远程过程调用(RPC),一种进行远程函数调用的方法,或者 21 世纪初的单一对象访问协议(SOAP),它标准化了远程调用的格式。当前的 RESTful 接口更容易阅读,并且更强烈地利用了已经建立的 HTTP 使用,尽管本质上,它们可以通过这些较旧的规范进行集成。
尽管现在主要存在于较老的系统上,但它们仍然可用。
我们将介绍如何为 API 创建版本控制系统,考虑到可能受到影响的不同用例。
我们将探讨前端和后端之间的区别,以及它们的交互。尽管本章的主要目标是讨论 API 接口,但我们也会讨论 HTML 接口,以了解它们之间的差异以及它们如何与其他 API 交互。
最后,我们将描述我们将在书中使用的示例的设计。
在本章中,我们将涵盖以下主题:
-
抽象
-
RESTful 接口
-
认证
-
API 版本控制
-
前端和后端
-
HTML 界面
-
为示例设计 API
让我们先看看抽象。
抽象
一个 API 允许我们使用一段软件,而无需完全理解其中涉及的所有不同步骤。它提供了一个清晰的动作菜单,允许外部用户(可能并不理解操作的复杂性)高效地执行这些操作。它简化了过程。
这些动作可以是纯粹的功能性的,其中输出仅与输入相关;例如,一个计算行星和恒星质心的数学函数,给定它们的轨道和质量。
或者,它们可以处理状态,因为相同的动作重复两次可能产生不同的效果;例如,检索系统中的时间。也许甚至一个调用允许设置计算机的时区,随后两次检索时间的调用可能返回非常不同的结果。
在这两种情况下,API 都在定义抽象。通过单个操作检索系统时间很简单,但也许执行此操作的细节并不那么容易。这可能涉及到以某种方式读取跟踪时间的某些硬件。
不同的硬件可能以不同的方式报告时间,但结果应该始终以标准格式转换。需要应用时区和夏令时。所有这些复杂性都由公开 API 并为用户提供清晰、易懂契约的模块开发者处理。“调用这个函数,将以 ISO 格式返回时间。”
虽然我们主要在讨论 API,并且在整本书中我们将主要描述与在线服务相关的 API,但抽象的概念实际上可以应用于任何事物。一个用于管理用户的网页是一个抽象,因为它定义了“用户账户”及其相关参数的概念。另一个无处不在的例子是电子商务中的“购物车”。创建一个清晰的思维图像是很有好处的,因为它有助于为用户提供更清晰、更一致的界面。
这当然是一个简单的例子,但 API 可以在其界面下隐藏大量的复杂性。一个值得思考的好例子是像 curl 这样的程序。即使只是向 URL 发送 HTTP 请求并打印返回的头部,这也与大量的复杂性相关:
$ curl -IL http://google.com
HTTP/1.1 301 Moved Permanently
Location: http://www.google.com/
Content-Type: text/html; charset=UTF-8
Date: Tue, 09 Mar 2021 20:39:09 GMT
Expires: Thu, 08 Apr 2021 20:39:09 GMT
Cache-Control: public, max-age=2592000
Server: gws
Content-Length: 219
X-XSS-Protection: 0
X-Frame-Options: SAMEORIGIN
HTTP/1.1 200 OK
Content-Type: text/html; charset=ISO-8859-1
P3P: CP="This is not a P3P policy! See g.co/p3phelp for more info."
Date: Tue, 09 Mar 2021 20:39:09 GMT
Server: gws
X-XSS-Protection: 0
X-Frame-Options: SAMEORIGIN
Transfer-Encoding: chunked
Expires: Tue, 09 Mar 2021 20:39:09 GMT
Cache-Control: private
Set-Cookie: NID=211=V-jsXV6z9PIpszplstSzABT9mOSk7wyucnPzeCz-TUSfOH9_F-07V6-fJ5t9L2eeS1WI-p2G_1_zKa2Tl6nztNH-ur0xF4yIk7iT5CxCTSDsjAaasn4c6mfp3fyYXMp7q1wA2qgmT_hlYScdeAMFkgXt1KaMFKIYmp0RGvpJ-jc; expires=Wed, 08-Sep-2021 20:39:09 GMT; path=/; domain=.google.com; HttpOnly
这将调用 www.google.com 并使用 -I 标志显示响应的头部。添加 -L 标志是为了自动重定向任何请求,这正是这里发生的情况。
与服务器建立远程连接需要很多不同的部件:
-
DNS 访问将服务器地址
www.google.com转换为实际的 IP 地址。 -
两个服务器之间的通信,这涉及到使用 TCP 协议来生成持久连接并保证数据的接收。
-
根据第一次请求的结果进行重定向,因为服务器返回了一个指向另一个 URL 的代码。这是由于使用了
-L标志。 -
重定向指向一个 HTTPS URL,这需要在上面添加一个验证和加密层。
每个这些步骤也使用了其他 API 来执行更小的操作,这可能涉及到操作系统的功能,甚至调用远程服务器(如 DNS 服务器)以从那里获取数据。
在这里,使用命令行从 curl 界面进行操作。虽然 API 严格的定义规定最终用户是人类,但实际上并没有太大的变化。好的 API 应该很容易被人类用户测试。命令行界面也可以通过 bash 脚本或其他语言轻松自动化。
但是,从curl用户的观点来看,这并不太相关。它被简化到只需一个带有几个标志的单行命令行就可以执行一个定义良好的操作,无需担心从 DNS 获取数据的格式或如何使用 SSL 加密请求。
使用正确的抽象
对于一个成功的接口,其根本在于创建一系列抽象并将它们呈现给用户,以便他们可以执行操作。因此,在设计新的 API 时,最重要的一个问题就是决定哪些是最合适的抽象。
当这个过程自然发生时,抽象主要是在过程中决定的。有一个最初的想法,被认可为对问题的理解,然后得到调整。
例如,以添加不同的标志到用户开始启动用户管理系统是非常常见的。因此,用户有权限执行操作 A,然后有一个参数执行操作 B,依此类推。通过一次添加一个标志,到第十个标志时,这个过程变得非常混乱。
然后,可以使用一个新的抽象;角色和权限。某些类型的用户可以执行不同的操作,例如管理员角色。用户可以有一个角色,该角色描述了相关的权限。
注意,这简化了问题,因为它易于理解和管理。然而,从“单个标志集合”到“多个角色”的转变可能是一个复杂的过程。可能减少了可能的选项数量。也许一些现有用户有一些独特的标志组合。所有这些都需要谨慎处理。
在设计新的 API 时,最好尝试明确描述 API 使用的固有抽象,至少在高级别上,以澄清它们。这也具有这样的优势,即作为 API 的用户思考这些问题,看看是否合理。
在软件开发者的工作中,最有用的观点之一是脱离你的“内部视角”,站在软件实际用户的立场上。这比听起来要难,但确实是一项值得培养的技能。这将使你成为一个更好的设计师。不要害怕请朋友或同事检测你设计中的盲点。
然而,每个抽象都有其局限性。
抽象泄露
当一个抽象从实现中泄露细节,而不是呈现一个完美的不透明图像时,它被称为泄露的抽象。
虽然一个好的 API 应该尽量避免这种情况,但有时它还是会发生。这可能是由服务于 API 的代码中的底层错误引起的,或者有时直接由代码在特定操作中的运行方式引起的。
这种情况的常见例子是关系型数据库。SQL 抽象了从数据库中实际存储方式搜索数据的过程。你可以使用复杂的查询进行搜索并获取结果,而不需要知道数据的结构。但有时,你会发现某个特定的查询速度很慢,重新组织查询参数会对这个过程产生重大影响。这是一个泄漏的抽象。
这非常常见,这也是为什么有大量的工具可以帮助确定在运行 SQL 查询时发生了什么,而 SQL 查询与实现非常分离。其中最主要的是 EXPLAIN 命令。
操作系统是生成良好抽象且大多数情况下不会泄漏的系统的好例子。有很多例子。由于空间不足而无法读取或写入文件(现在比三十年前少见的难题);由于网络问题而与远程服务器断开连接;或者由于达到打开文件描述符的数量限制而无法创建新的连接。
泄漏的抽象在某种程度上是不可避免的。这是不生活在完美世界的结果。软件是会出错的。理解和准备这一点至关重要。
"所有非平凡的抽象,在某种程度上,都是泄漏的。"
– 乔尔·斯波尔斯基的泄漏抽象定律
在设计 API 时,考虑到以下几个原因,重要的是要考虑这个事实:
-
为了在外部清晰地展示错误和提示。一个好的设计将始终包括出错的情况,并尝试用适当的错误代码或错误处理方式清晰地展示它们。
-
为了内部处理可能来自依赖服务的错误。依赖服务可能会失败或出现其他类型的问题。API 应该在一定程度上抽象这个问题,如果可能的话从问题中恢复,如果不能优雅地失败,并返回适当的结果。
最好的设计不仅要在预期工作的情况下设计事物,还要为意外问题做好准备,并确信它们可以被分析和纠正。
资源和操作抽象
在设计 API 时考虑的一个非常有用的模式是生成一组可以执行操作的资源。这个模式使用两种类型的元素:资源和操作。
资源是被引用的被动元素,而操作是在资源上执行的。
例如,让我们定义一个非常简单的接口来玩一个简单的猜硬币游戏。这是一个由三次硬币投掷组成的游戏,如果用户至少猜对其中两次,则获胜。
资源和操作可能如下所示:
| 资源 | 操作 | 详情 |
|---|---|---|
| HEADS | None | 硬币投掷结果。 |
| TAILS | None | 硬币投掷结果。 |
| GAME | START | 开始一个新的游戏。 |
| READ | 返回当前轮次(1 到 3)和当前正确猜测。 | |
| COIN_TOSS | 投掷 | 投掷硬币。如果 GUESS 尚未生成,则返回错误。 |
| 猜测 | 接受 HEADS 或 TAILS 作为猜测。 | |
| 结果 | 返回 HEADS 或 TAILS 以及猜测是否正确。 |
单个游戏的可能顺序可以是:
GAME START
> (GAME 1)
GAME 1 COIN_TOSS GUESS HEAD
GAME 1 COIN_TOSS TOSS
GAME 1 COIN_TOSS RESULT
> (TAILS, INCORRECT)
GAME 1 COIN_TOSS GUESS HEAD
GAME 1 COIN_TOSS TOSS
GAME 1 COIN_TOSS RESULT
> (HEAD, CORRECT)
GAME 1 READ
> (ROUND 2, 1 CORRECT, IN PROCESS)
GAME 1 COIN_TOSS GUESS HEAD
GAME 1 COIN_TOSS TOSS
GAME 1 COIN_TOSS RESULT
> (HEAD, CORRECT)
GAME 1 READ
> (ROUND 3, 2 CORRECT, YOU WIN)
注意每个资源都有其自己的动作集,可以执行。如果方便,动作可以重复,但这不是必需的。资源可以组合成层次表示(如这里,COIN_TOSS依赖于更高的GAME资源)。动作可能需要参数,这些参数可以是其他资源。
然而,抽象是围绕具有一致的资源集和动作集组织的。这种明确组织 API 的方式很有用,因为它阐明了系统中什么是被动的,什么是主动的。
面向对象编程(OOP)使用这些抽象,因为一切都是可以接收消息以执行某些动作的对象。另一方面,函数式编程并不适合这种结构,因为“动作”可以像资源一样工作。
这是一个常见的模式,它被用于 RESTful 接口,正如我们接下来将要看到的。
RESTful 接口
RESTful 接口在当今非常普遍,这是有充分理由的。它们已经成为服务其他应用程序的 Web 服务的既定标准。
表征状态转移(REST)由 Roy Fielding 在 2000 年的一篇博士论文中定义,它使用 HTTP 标准作为基础来创建一种软件架构风格的定义。
要使一个系统被认为是 RESTful 的,它应该遵循某些规则:
-
客户端-服务器架构。它通过远程调用工作。
-
无状态。与特定请求相关的所有信息都应该包含在请求本身中,使其独立于特定服务器。
-
缓存性。响应的缓存性应该是明确的,要么说明它们是可缓存的,要么不是。
-
分层系统。客户端无法判断它们是否连接到最终服务器或是否存在中间服务器。
-
统一接口,有四个先决条件:
-
请求中的资源标识,意味着资源被明确表示,其表示是独立的
-
通过表示进行资源操作,允许客户端在拥有表示时拥有所有所需信息以进行更改
-
自描述消息,意味着消息本身是完整的
-
超媒体作为应用状态引擎,意味着客户端可以通过引用的超链接遍历系统
-
-
按需代码。这是一个可选要求,通常不使用。服务器可以提交代码以帮助执行操作或改进客户端;例如,提交要在浏览器中执行的 JavaScript。
这是最正式的定义。如您所见,它不一定基于 HTTP 请求。为了更方便的使用,我们需要限制一些可能性并设定一个共同框架。
更实用的定义
当人们非正式地谈论 RESTful 接口时,通常它们被理解为基于 HTTP 资源使用 JSON 格式请求的接口。这与我们之前看到的定义完全兼容,但考虑了一些关键元素。
这些关键元素有时会被忽略,导致伪 RESTful 接口,它们不具有相同的属性。
主要的一点是URI(统一资源标识符)应该描述清晰的资源,以及对其执行的操作的 HTTP 方法和动作,使用CRUD(创建、检索、更新、删除)方法。
CRUD 接口简化了这些动作的执行:创建(保存新条目)、检索(读取)、更新(覆盖)和删除条目。这些是任何持久存储系统的基本操作。
有两种 URI,无论是描述单个资源还是资源集合,如下表所示:
| 资源 | 示例 | 方法 | 描述 |
|---|---|---|---|
| 集合 | /books |
GET |
列表操作。返回集合中所有可用的元素,例如,所有书籍。 |
POST |
创建操作。创建集合的新元素。返回新创建的资源。 | ||
| 单个 | /books/1 |
GET |
检索操作。返回资源的数据,例如,ID 为 1 的书籍。 |
PUT |
设置(更新)操作。发送资源的新数据。如果不存在,则创建。如果存在,则覆盖。 | ||
PATCH |
部分更新操作。仅覆盖资源的部分值,例如,仅发送和写入用户对象的电子邮件。 | ||
DELETE |
删除操作。删除资源。 |
这个设计的关键要素是将一切定义为资源,正如我们之前所看到的。资源通过其 URI 定义,其中包含资源的分层视图,例如:
/books/1/cover 定义了 ID 为 1 的书的封面图片资源。
为了简化,我们将在本章中使用整数 ID 来标识资源。在实际操作中,这并不推荐。它们没有任何意义,更糟糕的是,它们有时会泄露系统元素数量或其内部顺序的信息。例如,竞争对手可能会估计每周新增了多少条记录。为了摆脱任何内部表示,如果可能的话,尽量始终使用外部自然键,例如书的 ISBN 号码,或者创建一个随机的通用唯一标识符(UUID)。
顺序整数的一个问题是,在高速率下,系统可能难以正确创建它们,因为它不可能同时创建两个。这可能会限制系统的发展。
资源的大部分输入和输出将以 JSON 格式表示。例如,这可能是一个请求和响应示例,用于检索用户:
GET /books/1
HTTP/1.1 200 OK
Content-Type: application/json
{"name": "Frankenstein", "author": "Mary Shelley", "cover": "http://library.lbr/books/1/cover"}
响应格式为 JSON,如Content-Type中指定。这使得自动解析和分析变得容易。请注意,avatar字段返回指向另一个资源的超链接。这使得接口易于导航,并减少了客户端事先需要的信息量。
这是设计 RESTful 接口时最容易被遗忘的特性之一。最好返回资源的完整 URI,而不是间接引用,例如无上下文 ID。
例如,在创建新资源时,在Location头中包含新的 URI。
要发送新值以覆盖,应使用相同的格式。请注意,某些元素可能是只读的,例如cover,并且不是必需的:
PUT /books/1
Content-Type: application/json
{"name": "Frankenstein or The Modern Prometheus", "author": "Mary Shelley"}
HTTP/1.1 200 OK
Content-Type: application/json
{"name": "Frankenstein or The Modern Prometheus", "author": "Mary Shelley", "cover": "http://library.com/books/1/cover"}
相同的表示应用于输入和输出,这使得客户端能够轻松检索资源、修改它,然后重新提交。
这非常方便,并在实现客户端时创建了一个非常受欢迎的一致性水平。在测试时,请确保检索值并重新提交是有效的,并且不会造成问题。
当资源将由二进制内容直接表示时,它可以返回在Content-Type头中指定的正确格式。例如,检索头像资源可能返回一个图像文件:
GET /books/1/cover
HTTP/1.1 200 OK
Content-Type: image/png
...
同样地,在创建或更新新头像时,应使用适当的格式发送。
虽然 RESTful 接口的原始意图是利用多种格式,例如接受 XML 和 JSON,但在实践中这并不常见。总的来说,JSON 是目前最标准的格式。尽管如此,一些系统可能从使用多种格式中受益。
另一个重要的属性是确保某些操作是幂等的,而其他操作则不是。幂等操作可以多次重复,产生相同的结果,而重复非幂等操作将产生不同的结果。显然,操作应该是相同的。
这的一个明显例子是创建新元素。如果我们提交两个相同的POST创建资源列表中新元素的请求,它将创建两个新元素。例如,提交两个同名和同作者的书籍将创建两本相同的书籍。
这是在假设资源内容没有限制的情况下。如果有,第二次请求将失败,在任何情况下都会产生与第一次不同的结果。
另一方面,两个GET请求将产生相同的结果。对于PUT或DELETE也是如此,因为它们将覆盖或“再次删除”资源。
事实表明,唯一非幂等请求是POST操作,这显著简化了处理问题时是否应该重试的设计措施。幂等请求可以在任何时候安全地重试,从而简化了处理如网络问题等错误。
头部和状态
HTTP 协议的一个重要细节有时可能会被忽视的是不同的头部和状态码。
头部包括关于请求或响应的元数据信息。其中一些是自动添加的,例如请求或响应体的尺寸。以下是一些值得考虑的有趣头部:
| 头部 | 类型 | 详情 |
|---|---|---|
Authorization |
标准 | 用于验证请求的凭证。 |
Content-Type |
标准 | 请求体的类型,如application/json或text/html。 |
Date |
标准 | 消息创建的时间。 |
If-Modified-Since |
标准 | 发送者此时拥有资源的副本。如果自那时起没有变化,可以返回一个304 未修改响应(带有空体)。这允许缓存数据,并通过不返回重复信息来节省时间和带宽。这可以在GET请求中使用。 |
X-Forwarded-From |
实际标准 | 存储消息起源的 IP 地址以及它经过的不同代理。 |
Forwarded |
标准 | 与X-Forwarded-From相同。这是一个较新的头部,比X-Forwarded-From更不常见。 |
一个设计良好的 API 将利用头部来传达适当的信息,例如正确设置Content-Type或如果可能接受缓存参数。
可以在developer.mozilla.org/en-US/docs/Web/HTTP/Headers找到完整的头部列表。
另一个重要细节是充分利用可用的状态码。状态码提供了关于发生情况的重要信息,并且尽可能为每种情况使用最详细的信息将提供更好的接口。
以下是一些常见的状态码:
| 状态码 | 描述 |
|---|---|
200 OK |
成功的资源访问或修改。它应该返回一个体;如果不返回,则使用204 无内容。 |
201 已创建 |
成功的POST请求,用于创建新资源。 |
204 无内容 |
一个成功但不返回体的请求,例如成功的DELETE请求。 |
301 永久移动 |
访问的资源现在永久位于不同的 URI。它应该返回一个包含新 URI 的Location头。大多数库将自动跟进GET访问。例如,API 仅可通过HTTPS访问,但被访问的是HTTP。 |
302 找到 |
访问的资源临时位于不同的 URI。一个典型例子是在经过身份验证后重定向到登录页面。 |
304 未修改 |
缓存的资源仍然有效。正文应为空。只有在客户端请求缓存信息时,例如使用If-Modified-Since头,才会返回此状态码。 |
400 错误请求 |
请求中存在一个通用错误。这是服务器在说:“你的端发生了错误。”应该在正文中添加一个更详细的错误信息。如果可能的话,应该使用更详细的错误状态码。 |
401 未授权 |
请求不允许,因为请求没有正确认证。请求可能缺少用于认证的有效头。 |
403 禁止 |
请求已认证,但不能访问此资源。这与401 未授权状态不同,因为请求已经正确认证,但没有访问权限。 |
404 未找到 |
这可能是最著名的状态码!由 URI 描述的资源无法找到。 |
405 方法不允许 |
请求的方法不能使用;例如,资源不能被删除。 |
429 请求过多 |
如果客户端可以进行的请求数量有限制,服务器应返回此状态码。应在正文中返回描述或更多信息,并且理想情况下,返回一个Retry-After头,指示下一次重试的时间(以秒为单位)。 |
500 服务器错误 |
服务器中存在一个通用错误。只有在服务器发生意外错误时才应使用此状态。 |
502 网关错误 |
服务器正在将请求重定向到不同的服务器,并且通信不正确。此错误通常出现在某些后端服务不可用或配置不正确的情况下。 |
503 服务不可用 |
服务器目前无法处理请求。这通常是一个临时情况,例如负载问题。它可以用来标记维护停机时间,但这通常很少见。 |
504 网关超时 |
与502 网关错误类似,但在此情况下,后端服务没有响应,导致超时。 |
通常,非描述性错误代码,如400 错误请求和500 服务器错误,应保留用于通用情况。然而,如果有更好的、更详细的错误状态码,则应使用该状态码。
例如,如果要对参数进行覆盖的PATCH请求,如果参数由于任何原因不正确,则应返回400 错误请求,但如果资源 URI 未找到,则返回404 未找到。
有其他的状态码。您可以在以下位置查看一个包含每个状态码详细信息的综合列表:httpstatuses.com/.
在任何错误中,请向用户提供一些额外的反馈,包括原因。一个通用的描述符可以帮助处理意外情况并简化问题调试。
这对于4XX错误特别有用,因为它们将帮助 API 的用户修复自己的错误,并迭代地改进他们的集成。
例如,提到的PATCH可能会返回以下正文:
{
"message": "Field 'address' is unknown"
}
这将给出关于问题的具体细节。其他选项包括返回错误代码、在存在多个可能错误的情况下返回多条消息,以及在正文中重复状态代码。
设计资源
RESTful API 中可用的操作仅限于 CRUD 操作。因此,资源是 API 的基本构建块。
将一切变成资源有助于创建非常明确的 API,并有助于满足 RESTful 接口的无状态要求。
无状态服务意味着满足请求所需的所有信息要么由调用者提供,要么从外部检索,通常是从数据库中检索。这排除了其他保持信息的方式,例如在相同服务器的硬盘上本地存储信息。这使得任何服务器都能处理每个单独的请求,这对于实现可扩展性至关重要。
可以通过创建不同的动作激活的元素可以分离到不同的资源中。例如,模拟笔的接口可能需要以下元素:
-
开启和关闭笔。
-
写东西。只有开启的笔才能书写。
在某些 API 中,如面向对象的 API,这可能涉及创建笔对象并更改其状态:
pen = Pen()
pen.open()
pen.write("Something")
pen.close()
在 RESTful API 中,我们需要为笔及其状态创建不同的资源:
# Create a new pen with id 1
POST /pens
# Create a new open pen for pen 1
POST /pens/1/open
# Update the new open text for the open pen 1
PUT /pens/1/open/1/text
# Delete the open pen, closing the pen
DELETE /pens/1/open/1
这可能看起来有点繁琐,但 RESTful API 应该旨在比典型的面向对象 API 更高层次。可以直接创建文本,或者创建笔然后创建文本,而不必执行开启/关闭操作。
请记住,RESTful API 是在远程调用上下文中使用的。这意味着它们不能是低级的,因为与本地 API 相比,每个调用都是一个大的投资,因为调用时间将是操作中一个合理的部分。
注意,每个方面和步骤都会被注册,并有自己的标识符集,是可寻址的。这比面向对象中可以找到的内部状态更明确。正如我们所看到的,我们希望它是无状态的,而对象则非常具有状态性。
请记住,资源不一定需要直接翻译成数据库对象。这是从存储到 API 的逆向思维。记住,你并不局限于这一点,可以组合从多个来源获取信息或不适合直接翻译的资源。我们将在下一章中看到示例。
如果来自更传统的面向对象环境,仅处理资源可能需要某些适应,但它们是非常灵活的工具,可以分配多种执行动作的方式。
资源和参数
虽然一切都是资源,但某些元素作为与资源交互的参数更有意义。这在修改资源时非常自然。任何更改都需要提交以更新资源。但是,在其他情况下,某些资源可能因其他原因而修改。最常见的情况是搜索。
典型的搜索端点将定义一个 search 资源并检索其结果。然而,没有过滤参数的搜索实际上并不实用,因此需要额外的参数来定义搜索,例如:
# Return every pen in the system
GET /pens/search
# Return only red pens
GET /pens/search?color=red
# Return only red pens, sorted by creation date
GET /pens/search?color=red&sort=creation_date
这些参数存储在查询参数中,这是检索它们的自然扩展。
作为一般规则,只有 GET 请求应该有查询参数。其他类型的请求方法应将任何参数作为正文的一部分提供。
如果包含查询参数,GET 请求也容易进行缓存。如果搜索返回每个请求相同的值,鉴于这是一个幂等请求,包括查询参数在内的完整 URI 可以在外部进行缓存。
按照惯例,存储 GET 请求的所有日志也将存储查询参数,而作为请求头或正文发送的任何参数都不会被记录。这具有安全影响,因为任何合理的参数,如密码,都不应该作为查询参数发送。
有时,这是创建通常会是 GET 请求的 POST 操作的原因,但更倾向于在请求的正文而不是查询参数中设置参数。虽然在 HTTP 协议中可以在 GET 请求中设置正文,但这绝对是非常不寻常的。
这种情况的例子可能是通过电话号码、电子邮件或其他个人信息进行搜索,因此中间人代理可以拦截并了解它们。
使用 POST 请求的另一个原因是允许更大的参数空间,因为包括查询参数在内的完整 URL 通常限制在 2K 大小,而正文的大小限制则小得多。
分页
在 RESTful 接口中,任何返回合理数量元素的 LIST 请求都应该进行分页。
这意味着可以从请求中调整元素和页面的数量,只返回特定页面的元素。这限制了请求的范围,避免了非常慢的响应时间和传输字节的浪费。
一个例子可能涉及使用 page 和 size 参数,例如:
# Return only first 10 elements
GET /pens/search?page=1&size=10
一个构建良好的响应将具有与此类似的格式:
{
"next": "http://pens.pns/pens/search?page=2&size=10",
"previous": null,
"result": [
# elements
]
}
它包含一个 result 字段,其中包含结果列表,以及 next 和 previous 字段,它们是到下一页和上一页的超链接,如果不可用,则值为 null。这使得遍历所有结果变得容易。
一个 sort 参数也可以用来确保页面的一致性。
这种技术还允许并行检索多页,这可以加快信息的下载速度,通过进行几个小请求而不是一个大请求来实现。然而,目标是为通常返回不太多的信息提供足够的过滤参数,以便只检索相关信息。
分页有一个问题,即集合中的数据可能在多个请求之间发生变化,尤其是在检索多页时。问题如下:
# Obtain first page
GET /pens/search?page=1&size=10&sort=name
# Create a new resource that is added to the first page
POST /pens
# Obtain second page
GET /pens/search?page=2&size=10&sort=name
第二页现在有一个重复的元素,它曾经在一页上,但现在已移动到第二页,然后还有一个未返回的元素。通常,新资源的未返回并不是一个大问题,因为毕竟信息检索是在其创建之前开始的。然而,相同资源的重复返回可能会是。
为了避免这类问题,默认按创建日期或类似方式排序值是有可能的。这样,任何新的资源都将添加到分页的末尾,并且可以一致地检索。
对于返回固有“新”元素的资源,如通知或类似资源,添加一个updated_since参数以检索自最近访问以来仅有的新资源。这以实际方式加快了访问速度,并检索了相关信息。
创建一个灵活的分页系统可以增加任何 API 的有用性。确保您的分页定义在所有不同资源中是一致的。
设计 RESTful API 的过程
设计 RESTful API 的最佳方式是明确声明资源,然后描述它们,包括以下细节:
-
描述:操作的描述
-
资源 URI:请注意,这可以用于多个操作,通过方法区分(例如,GET 用于检索和 DELETE 用于删除)
-
适用的方法:在此端点定义的操作中使用的 HTTP 方法
-
(只有相关时) 输入体:请求的输入体
-
预期结果在体中:结果
-
可能预期的错误:根据特定错误返回状态码
-
描述:操作的描述
-
(只有相关时) 输入查询参数:要添加到 URI 的查询参数以提供额外功能
-
(只有相关时) 相关头信息:任何支持的头部
-
(只有相关时) 返回非普通状态码(200 和 201):与错误不同,如果存在被认为是成功但不是常规情况的状态码;例如,成功返回重定向
这将足够创建一个其他工程师可以理解的设计文档,并允许他们在此接口上工作。
然而,先快速草拟不同的 URI 和方法,并快速查看系统中的所有不同资源,而不深入细节,如正文描述或错误,是一种良好的实践。这有助于检测 API 中缺失的资源差距或其他不一致性。
例如,本章中描述的 API 有以下操作:
GET /pens
POST /pens
POST /pens/<pen_id>/open
PUT /pens/<pen_id>/open/<open_pen_id>/text
DELETE /pens/<pen_id>/open/<open_pen_id>
GET /pens/search
这里有一些细节可以调整和改进:
-
看起来我们忘记添加删除笔的动作了,一旦创建
-
应该添加几个
GET操作来检索有关已创建资源的详细信息 -
在
PUT操作中,添加/text感觉有点冗余
在这个反馈的基础上,我们再次如下描述 API(修改处有箭头):
GET /pens
POST /pens
GET /pens/<pen_id>
DELETE /pens/<pen_id> ←
POST /pens/<pen_id>/open
GET /pens/<pen_id>/open/<open_pen_id> ←
PUT /pens/<pen_id>/open/<open_pen_id> ←
DELETE /pens/<pen_id>/open/<open_pen_id>
GET /pens/search
注意在分层结构中的组织如何有助于仔细查看所有元素,并找到可能一开始并不明显的空白或关系。
之后,我们可以深入了解。我们可以使用本节开头描述的模板,或者任何适合你的其他模板。例如,我们可以定义创建一个新笔和读取系统中笔的端点:
创建一个新的笔:
-
描述:创建一个新的笔,指定颜色。
-
资源 URI:
/pens -
方法:
POST -
输入体:
{ "name": <pen name>, "color": (black|blue|red) } -
错误:
400 Bad Request
主体中的错误,例如一个未识别的颜色、重复的名称或格式错误。
检索现有的笔:
-
描述:检索现有的笔。
-
资源 URI:
/pens/<pen id> -
方法:
GET -
返回体:
{ "name": <pen name>, "color": (black|blue|red) } -
错误:
404 Not Found The pen ID is not found.
主要目标是这些小模板既实用又简洁。请随意按预期调整它们,不必担心对错误或细节过于详尽。最重要的是它们是实用的;例如,添加一个405 Method Not Allowed消息可能是多余的。
API 也可以使用 Postman (www.postman.com)等工具设计,这是一个 API 平台,可以用来设计或测试/调试现有的 API。虽然很有用,但能够在不使用外部工具的情况下设计 API 是很好的,以防万一需要,并且因为它迫使你思考设计而不是工具本身。我们还将看到如何使用基于定义的 Open API,而不是提供测试环境。
设计和定义一个 API 也可以使其在之后以标准方式结构化,以便利用工具。
使用 Open API 规范
一个更结构化的替代方案是使用像 Open API (www.openapis.org/)这样的工具。Open API 是通过 YAML 或 JSON 文档定义 RESTful API 的规范。这允许这个定义与其他工具交互,为 API 生成自动文档。
它允许定义可以重复的不同组件,无论是作为输入还是输出。这使得构建一致的可重复使用的对象变得容易。还有从彼此继承或组合的方法,从而创建一个丰富的接口。
详细描述整个 Open API 规范超出了本书的范围。大多数常见的 Web 框架都允许与之集成,自动生成 YAML 文件或我们稍后看到的 Web 文档。它之前被称为 Swagger,其网页(swagger.io/)有一个非常有用的编辑器和其他资源。
例如,这是一个描述上述两个端点的 YAML 文件。该文件可在 GitHub 上找到:github.com/PacktPublishing/Python-Architecture-Patterns/blob/main/pen_example.yaml
openapi: 3.0.0
info:
version: "1.0.0"
title: "Swagger Pens"
paths:
/pens:
post:
tags:
- "pens"
summary: "Add a new pen"
requestBody:
description: "Pen object that needs to be added to the store"
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/Pen"
responses:
"201":
description: "Created"
"400":
description: "Invalid input"
/pens/{pen_id}:
get:
tags:
- "pens"
summary: "Retrieve an existing pen"
parameters:
- name: "pen_id"
in: path
description: "Pen ID"
required: true
schema:
type: integer
format: int64
responses:
"200":
description: "OK"
content:
application/json:
schema:
$ref: "#/components/schemas/Pen"
"404":
description: "Not Found"
components:
schemas:
Pen:
type: "object"
properties:
name:
type: "string"
color:
type: "string"
enum:
- black
- blue
- red
在components部分,定义了Pen对象,然后在其两个端点中使用。您可以看到如何定义两个端点,POST /pens和GET /pens/{pen_id},并描述预期的输入和输出,考虑到可能产生的不同错误。
Open API 最有趣的特点之一是能够自动生成包含所有信息的文档页面,以帮助任何可能的实现。生成的文档看起来像这样:
![图形用户界面,文本,应用程序,电子邮件]
自动生成的描述](https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/py-arch-ptn/img/B17580_02_01.png)
图 2.1:Swagger Pens 文档
如果 YAML 文件正确且完整地描述了您的界面,这将非常有用。在某些情况下,从 YAML 到 API 的工作可能更有优势。这首先生成 YAML 文件,并允许从那里双向工作,无论是前端方向还是后端方向。对于以 API 为先的方法,这可能是有意义的。甚至可以自动在多种语言中创建客户端和服务器的基本框架,例如,Python Flask 或 Spring 中的服务器,以及 Java 或 Angular 中的客户端。
请记住,确保实现与定义紧密匹配的责任在于您。这些基本框架仍然需要足够的工作才能正确运行。Open API 将简化这个过程,但不会神奇地解决所有集成问题。
每个端点都包含更多信息,甚至可以在同一文档中进行测试,从而极大地帮助希望使用 API 的外部开发者,正如我们可以在下一张图中看到:
![图形用户界面,应用程序]
自动生成的描述](https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/py-arch-ptn/img/B17580_02_02.png)
图 2.2:Swagger Pens 扩展文档
考虑到即使设计不是从 Open API YAML 文件开始的,也很容易确保服务器可以生成这种自动文档,因此生成它是件好事,以便创建自生成文档。
认证
几乎任何 API 的关键部分都是能够区分授权和不授权的访问。能够正确记录用户至关重要,从安全角度来看,这是一个头疼的问题。
安全性很难,因此最好依赖于标准来简化操作。
正如我们之前所说,这些只是一些一般性的建议,但绝不是一套全面的网络安全实践。本书并不专注于安全。请关注安全问题及其解决方案,因为这是一个始终在发展的领域。
与认证相关的最重要的安全问题是在生产环境中始终使用 HTTPS 端点。这允许保护通道免受窃听,并使通信私密。请注意,一个 HTTP 网站仅仅意味着通信是私密的;你可能在和恶魔交谈。但它是最基本的要求,允许您的 API 用户发送密码和其他敏感信息,而不用担心外部用户会接收到这些信息。
通常,大多数架构在请求达到数据中心或安全网络之前使用 HTTPS,然后内部使用 HTTP。这允许检查内部流动的数据,同时也保护了跨互联网传输的数据。虽然现在这不太重要,但它也提高了效率,因为将请求编码为 HTTPS 需要额外的处理能力。
HTTPS 端点对所有访问都有效,但其他细节取决于它们是 HTML 界面还是 RESTful 界面。
验证 HTML 界面
在 HTML 网页中,通常验证流程如下:
-
用户界面会显示登录界面。
-
用户输入他们的登录名和密码,并将它们发送到服务器。
-
服务器验证密码。如果正确,它将返回一个包含会话 ID 的 cookie。
-
浏览器接收到响应并存储 cookie。
-
所有新的请求都会发送 cookie。服务器将验证 cookie 并正确识别用户。
-
用户可以注销,删除 cookie。如果这样做是明确的,则会向服务器发送请求以删除会话 ID。通常,会话 ID 将有一个过期时间来自动清理。这个过期时间可以在每次访问时更新,或者强制用户时不时地重新登录。
将 cookie 设置为Secure、HttpOnly和SameSite非常重要。Secure确保 cookie 只发送到 HTTPS 端点,而不是 HTTP 端点。HttpOnly使 cookie 无法通过 JavaScript 访问,这使得通过恶意代码获取 cookie 更加困难。cookie 将自动发送到设置它的主机。SameSite确保只有在源页面来自同一主机时才会发送 cookie。它可以设置为Strict、Lax和None。Lax允许您从不同站点导航到页面,从而发送 cookie,而Strict则不允许。
您可以在 Mozilla SameSite Cookie 页面获取更多信息:developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite。
cookie 的潜在不良使用是通过 XSS(跨站脚本)攻击。受损害的脚本读取该 cookie,然后伪造出作为用户认证的恶意请求。
另一种重要的安全问题是跨站请求伪造(CSRF)。在这种情况下,利用用户在外部服务上登录的事实,通过展示一个将在不同的、受损害的网站上自动执行的 URL。
例如,在访问论坛时,会调用一个来自普通银行的 URL,以图片的形式展示。如果用户已经登录到这个银行,操作将会执行。
SameSite属性大大降低了 CSRF 的风险,但如果老版本的浏览器不理解这个属性,银行向用户展示的操作应该提供一个随机令牌,使用户发送带有 cookie 的认证请求和一个有效的令牌。外部页面不会知道一个有效的随机令牌,这使得这种攻击变得更加困难。
存储在 cookie 中的会话 ID 可以是存储在数据库中,仅仅是一个随机唯一的标识符,或者是一个丰富的令牌。
随机标识符正是如此,只是一个随机数字,用于在数据库中存储相关信息,主要是谁在访问以及会话何时过期。每次访问时,这个会话 ID 都会查询到服务器,并检索相关信息。在非常大的部署中,由于访问量很大,这可能会造成问题,因为它不够可扩展。存储会话 ID 的数据库需要所有工作者访问,这可能会造成瓶颈。
一种可能的解决方案是创建一个丰富的数据令牌。这是通过直接将所有必要的信息添加到 cookie 中实现的;例如,直接存储用户 ID、过期时间等。这避免了数据库访问,但使得 cookie 可能被伪造,因为所有信息都是公开的。为了解决这个问题,cookie 被签名。
签名证明数据是由受信任的登录服务器生成的,并且可以被任何其他服务器独立验证。这更可扩展,避免了瓶颈。可选地,内容也可以被加密,以避免被读取。
这个系统的另一个优点是令牌的生成可以独立于通用系统。如果令牌可以独立验证,就没有必要让登录服务器与通用服务器相同。
更重要的是,单个令牌签发者可以为多个服务发行令牌。这是单点登录(SSO)的基础:登录到认证提供者,然后使用相同的账户在多个相关服务中使用。这在像 Google、Facebook 或 GitHub 这样的常见服务中非常普遍,以避免为某些网页创建特定的登录。
那种拥有令牌授权的操作模式是 OAuth 授权框架的基础。
验证 RESTful 接口
OAuth 已成为 API 认证的通用标准,尤其是 RESTful API。
认证和授权之间存在差异,本质上,OAuth 是一个授权系统。认证是确定用户身份,而授权是用户能够做什么。OAuth 使用作用域的概念来返回用户的能力。
大多数 OAuth 实现,如 OpenID Connect,也将用户信息包含在返回的令牌中,以验证用户,并返回用户身份。
它基于这样一个想法:存在一个授权者可以检查用户的身份并向他们提供包含允许用户登录信息的令牌。该服务将接收此令牌并记录用户:

图 2.3:认证流程
目前最常用的版本是 OAuth 2.0,它允许在登录和流程方面具有灵活性。请记住,OAuth 并不完全是一个协议,但它提供了一些可以针对特定用例进行调整的想法。
这意味着你可以以不同的方式实现 OAuth,并且,关键的是,不同的授权者将会有不同的实现方式。在实施集成时,请仔细验证他们的文档。
通常,授权者使用基于 OAuth 的 OpenID Connect 协议。
当 API 访问系统是最终用户直接访问,还是代表用户访问时,在这一点上有重要差异。后者的一个例子可能是用于访问 Twitter 等服务智能手机应用,或者需要访问 GitHub 中存储的用户数据的工具,如代码分析工具。该应用本身并不执行操作,而是转移用户的操作。
这种流程被称为授权代码授予。其主要特点是授权提供商会向用户展示登录页面,并使用认证令牌将他们重定向。
例如,这是授权代码授予的调用序列:
GET https://myservice.com/login
Return a page with a form to initiate the login with authorizer.com
Follow the flow in the external authorize until login, with something like.
POST https://authorizer.com/authorize
grant_type=authorization_code
redirect_uri=https://myservice.com/redirect
user=myuser
password=mypassword
Return 302 Found to https://myservice.com/redirect?code=XXXXX
GET https://myservice.com/redirect?code=XXXXX
-> Login into the system and set proper cookie,
return 302 to https://myservice.com
如果访问 API 的系统直接来自最终用户,则可以使用客户端凭据授予类型流程。在这种情况下,第一个调用将发送client_id(用户 ID)和client_secret(密码)以直接检索认证令牌。此令牌将在新的调用中作为头信息设置,以验证请求。
注意,这跳过了一步,并且更容易自动化:
POST /token HTTP/1.1
grant_type=authorization_code
&client_id=XXXX
&client_secret=YYYY
Returns a JSON body with
{
"access_token":"ZZZZ",
"token_type":"bearer",
"expires_in":86400,
}
Make new requests setting the header
Authorization: "Bearer ZZZZ"
虽然 OAuth 允许你使用外部服务器来检索访问令牌,但这并不是严格必需的。它可以与其它服务器相同。这对于最后一个流程很有用,在这个流程中,使用外部提供者(如 Facebook 或 Google)进行登录的能力并不那么有用。我们的示例系统将使用客户端凭据流程。
自编码令牌
来自授权服务器的返回令牌可以包含足够的信息,以至于不需要与授权者进行外部检查。
正如我们所见,将用户信息包含在令牌中对于确定用户身份很重要。如果不这样做,我们将得到一个能够执行工作但缺乏代表谁的信息的请求。
要这样做,通常将令牌编码为JSON Web Token(JWT)。JWT 是一种标准,它将 JSON 对象编码为一系列 URL 安全的字符。
JWT 具有以下元素:
-
一个头信息。这包含有关令牌如何编码的信息。
-
一个有效载荷。令牌的主体。此对象中的一些字段,称为声明,是标准的,但也可以分配自定义声明。标准声明不是必需的,可以描述元素,例如发行者(
iss)或令牌的过期时间作为 Unix 纪元(exp)。 -
一个签名。这验证令牌是由正确来源生成的。这使用不同的算法,基于头信息中的信息。
通常,JWT 是编码的,但不是加密的。标准的 JWT 库将解码其部分并验证签名是否正确。
您可以在交互式工具中测试不同的字段和系统:jwt.io/.
例如,要使用pyjwt(pypi.org/project/PyJWT/)生成令牌,如果尚未安装,您需要使用 pip 安装 PyJWT:
$ pip install PyJWT
然后,在打开 Python 解释器时,要创建一个包含用户 ID 并使用 HS256 算法签名的令牌,使用以下代码:
>>> import jwt
>>> token = jwt.encode({"user_id": "1234"}, "secret", algorithm="HS256")
>>> token
'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoiMTIzNCJ9.vFn0prsLvRu00Kgy6M8s6S2Ddnuvz-FgtQ7nWz6NoC0'
JWT 令牌然后可以被解码,并提取有效载荷。如果密钥不正确,它将产生错误:
>>> jwt.decode(token,"secret", algorithms=['HS256'])
{'user_id': '1234'}
>>> jwt.decode(token,"badsecret", algorithms=['HS256'])
Traceback (most recent call last):
…
jwt.exceptions.InvalidSignatureError: Signature verification failed
要使用的算法存储在头信息中,但出于安全考虑,只使用预期的算法验证令牌是个好主意,不要依赖于头信息。在过去,某些 JWT 实现和令牌伪造存在一些安全问题,您可以在此处阅读:www.chosenplaintext.ca/2015/03/31/jwt-algorithm-confusion.html.
然而,最有趣的算法并不是像HS256这样的对称算法,其中编码和解码时添加相同的值,而是像 RSA-256(RS256)这样的公私钥。这允许使用私钥对令牌进行编码,并使用公钥进行验证。
此架构非常常见,因为公钥可以广泛分发,但只有拥有私钥的正确授权者才能成为令牌的来源。
包含可用于识别用户的有效载荷信息,允许使用有效载荷中的信息进行请求的认证,一旦验证,正如我们之前讨论的那样。
API 版本控制
接口很少是从零开始完全创建的。它们不断地被调整,添加新功能,修复错误或不一致。为了更好地传达这些变化,创建某种形式的版本控制来传递这些信息是有用的。
为什么需要版本控制?
版本控制的主要优势是塑造关于何时包含哪些内容的对话。这可以是错误修复、新功能,甚至是新引入的错误。
如果我们知道当前发布的接口版本是v1.2.3,而我们即将发布版本v1.2.4,该版本修复了错误 X,我们可以更容易地讨论这个问题,以及创建发布说明通知用户这一事实。
内部版本与外部版本
有两种版本可能会有些混淆。一种是内部版本,这是项目开发者有意义的东西。这通常与软件版本相关,通常需要版本控制(如 Git)的帮助。
这个版本非常详细,可以涵盖非常小的变化,包括小的错误修复。它的目的是能够检测到软件之间的最小变化,以便检测错误或引入代码。
另一种是外部版本。外部版本是使用外部服务的用户将能够感知到的版本。虽然它可以像内部版本一样详细,但这通常对用户没有太大帮助,并且可能会提供令人困惑的信息。
这在很大程度上取决于系统的类型以及预期的用户是谁。技术用户会欣赏额外的细节,但更随意的用户则不会。
例如,内部版本可能区分两种不同的错误修复,因为这有助于复制。外部沟通的版本可以将它们两者结合为“多个错误修复和改进。”
另一个有用的例子是在接口发生重大变化时区分版本。例如,一个网站外观和感觉的全新改版可以使用“版本 2 接口”,但这可以在多个内部新版本中发生,以供内部测试或选定的小组(例如,测试人员)测试。最后,当“版本 2 接口”准备就绪时,它可以对所有用户激活。
描述外部版本的一种方法可能是将其称为“营销版本。”
注意,我们在这里避免使用“发布版本”这个术语,因为它可能会引起误导。这个版本仅用于对外部信息进行沟通。
这个版本将更多地依赖于营销努力,而不是技术实现。
语义版本控制
定义版本的一个常见模式是使用语义版本控制。语义版本控制描述了一种使用三个递增整数的方法,这些整数具有不同的含义,按不兼容性降序排列:
vX.Y.Z
X 被称为主版本。主版本的任何变化都意味着向后不兼容的变化。
Y 是次要版本。次要更改可能会添加新功能,但任何更改都将保持向后兼容。
Z 是修补版本。它只会进行小的更改,例如错误修复和安全补丁,但它不会改变接口本身。
开头的v是可选的,但有助于表明它是一个版本号。
这意味着设计用于与v1.2.15一起工作的软件将能够与版本v1.2.35和v1.3.5一起工作,但它不能与版本v2.1.3或版本v1.1.4一起工作。它可能与版本v1.2.14一起工作,但可能存在后来被纠正的错误。
有时,可以添加额外的细节来描述尚未准备好的接口,例如,v1.2.3-rc1(发布候选)或v1.2.3-dev0(开发版本)。
通常,在软件准备发布之前,主版本号被设置为零(例如,v0.1.3),使得版本v1.0.0成为第一个公开可用的版本。
这种语义版本号非常容易理解,并提供了关于更改的良好信息。它被广泛使用,但在某些情况下存在一些问题:
-
对于没有明确向后兼容性的系统,严格采用主版本号可能很困难。这就是为什么 Linux 内核停止使用正确的语义版本号的原因,因为他们永远不会更新主版本号,因为每个发布都需要向后兼容。在这种情况下,主版本号可能被冻结多年,并停止作为一个有用的参考。在 Linux 内核中,这种情况发生在版本 2.6.X 上,它持续了 8 年,直到 2011 年发布了没有向后不兼容变化的版本 3.0。
-
语义版本号要求对接口有一个相当严格的定义。如果接口经常随着新功能的变化而变化,就像在线服务通常发生的那样,次要版本会迅速增加,而修补版本几乎没有任何用处。
对于在线服务,两者的组合只会使一个数字有用,这不是很好的利用。语义版本号在需要同时运行多个 API 版本的情况下效果更好,例如:
-
该 API 非常稳定,变化很少,尽管有定期的安全更新。每隔几年,就会有一个主要更新。一个好的例子是数据库,如 MySQL。操作系统是另一个例子。
-
该 API 属于一个软件库,可以被多个支持的环境使用;例如,一个与 Python 2 版本 v4 和 Python 3 版本 v5 兼容的 Python 库。这可以在需要时保持多个版本存活。
如果系统实际上同时运行一个版本,那么最好不要额外努力保持适当的语义版本号,因为这种努力与所需的回报相比不值得。
简单版本号
相比于严格的语义版本控制,可以采用简化的版本控制。这不会传达相同的意义,但将是一个不断增长的计数器。这将有助于协调团队,尽管它不会要求相同的承诺。
这与编译器可以自动创建的构建号相同,是一个递增的数字,用于区分不同版本并作为参考。然而,纯构建号可能有点枯燥。
使用与语义版本控制类似的架构会更好,因为这样每个人都能理解;但与使用特定规则相比,它更为宽松:
-
通常,对于新版本,增加补丁版本号。
-
如果补丁版本号过高(换句话说,100、10 或另一个任意数字),则增加小版本号并将补丁版本号设为零。
-
或者,如果项目有任何特殊里程碑,如项目组成员所定义的,则应提前增加小版本号。
-
同样对主版本号进行相同的处理。
这样做可以让数字以一致的方式增加,而不必过于担心其意义。
这种结构非常适合像在线云服务这样的东西,本质上需要递增的计数器,因为它们同时部署了单个版本。在这种情况下,版本的最重要用途是内部使用,并且不需要严格的语义版本控制所要求的维护。
前端和后端
常见的区分不同服务的方式是通过谈论“前端”和“后端”。它们描述了软件的层级,其中靠近最终用户的层级是前端,而位于其后的则是后端。
传统上,前端是负责用户界面的层级,紧邻用户,而后端是数据访问层,它服务于业务逻辑。在客户端-服务器架构中,客户端是前端,服务器是后端:

图 2.4:客户端-服务器架构
随着架构的日益复杂,这些术语变得有些多义,它们通常根据上下文来理解。虽然前端几乎总是被理解为直接的用户界面,后端可以应用于多个层级,意味着为正在讨论的任何系统提供支持的下一层。例如,在云应用中,Web 应用可能使用 MySQL 数据库作为存储后端,或者使用 Redis 作为缓存后端。
前端和后端的一般方法相当不同。
前端专注于用户体验,因此最重要的元素是可用性、令人愉悦的设计、响应性等。其中很多都需要对“最终外观”和如何使事物易于使用有敏锐的洞察力。前端代码在最终用户处执行,因此不同类型硬件之间的兼容性可能很重要。同时,它分散了负载,因此从用户界面的角度来看,性能最为重要。
后端更注重稳定性。在这里,硬件处于严格控制之下,但负载并未分配,因此在控制总资源使用方面,性能变得尤为重要。修改后端也更为容易,因为一旦更改,所有用户都会同时受到影响。但这也更具风险,因为这里的问题可能会影响每一个用户。这种环境更倾向于关注稳健的工程实践和可复制性。
“全栈工程师”这个术语通常用来描述那些对这两种工作都感到舒适的人。虽然在某些方面这可能可行,但实际上很难找到在长期内对这两个元素都同样舒适或倾向于同时从事这两个元素的人。
大多数工程师自然会倾向于某一端,而大多数公司都会有不同的团队分别负责这两个方面。在某种程度上,每个工作所需的个性特征是不同的,前端工作需要更多对设计的关注,而后端用户则更适应稳定性和可靠性实践。
通常,用于前端的一些常见技术如下:
-
HTML 及其相关技术,如 CSS
-
JavaScript 以及用于增加交互性的库或框架,如 jQuery 或 React
-
设计工具
后端技术,由于它们处于更直接的控制之下,因此可以更加多样化,例如:
-
多种编程语言,无论是脚本语言如 Python、PHP、Ruby,还是使用 Node.js 的 JavaScript,甚至是编译语言如 Java 或 C#。它们甚至可以混合使用,使不同元素使用不同的语言。
-
数据库,无论是关系型数据库如 MySQL 或 PostgreSQL,还是非关系型数据库如 MongoDB、Riak 或 Cassandra。
-
网络服务器,如 Nginx 或 Apache。
-
可扩展性和高可用性工具,如负载均衡器。
-
基础设施和云技术,如 AWS 服务。
-
与容器相关的技术,如 Docker 或 Kubernetes。
前端将利用后端定义的接口以用户友好的方式展示操作。同一个后端可以有多个前端,一个典型的例子是针对不同平台的多个智能手机界面,但它们使用相同的 API 与后端通信。
请记住,前端和后端是概念上的划分,但它们不一定需要划分为不同的进程或存储库。一个常见的前端和后端共存的情况是像 Ruby on Rails 或 Django 这样的 Web 框架,您可以在定义后端处理数据访问和业务逻辑的控制器的同时定义前端 HTML 界面。在这种情况下,HTML 代码直接从执行数据访问的同一进程提供。这个过程使用模型视图控制器结构来分离关注点。
模型视图控制器结构
模型视图控制器,或 MVC,是一种将程序逻辑分为三个不同组件的设计。
模型视图控制器模式在图形用户界面的设计初期就已经出现,并且自 80 年代第一个完整的图形交互界面以来,该模式一直被应用于该领域。在 90 年代,它开始作为一种处理 Web 应用程序的方式被引入。
-
这种结构非常成功,因为它创造了概念之间的清晰分离:
-
模型管理数据
-
控制器接受用户的输入并将其转换为对模型的操作
-
视图代表用户理解的信息
从本质上讲,模型是系统的核心,因为它处理数据的操作。控制器代表输入,视图代表操作的输出。

图 2.5:模型视图控制器模式
MVC 结构可以在不同的层面上进行考虑,它可以被视为分形。如果几个元素相互作用,它们可以有自己的 MVC 结构,并且系统的模型部分可以与提供信息的后端进行通信。
MVC 模式可以以不同的方式实现。例如,Django 声称它是一个模型视图模板,因为控制器更像是框架本身。然而,这些是微不足道的细节,并不与总体设计相矛盾。
模型可以说是三个元素中最重要的,因为它是其核心部分。它包含数据访问,同时也包含业务逻辑。一个丰富的模型组件充当一种从输入和输出中抽象应用程序逻辑的方式。
通常,控制器之间的某些障碍会变得有些模糊。不同的输入可能在控制器中处理,产生对模型的不同的调用。同时,输出可以在传递到视图之前在控制器中进行调整。虽然始终很难强制执行清晰、严格的边界,但记住每个组件的主要目标以提供清晰度是很好的。
HTML 界面
虽然 API 的严格定义适用于设计为被其他程序访问的界面,但花点时间讨论如何创建成功的人机界面基础是很好的。为此,我们将主要讨论 HTML 界面,旨在由最终用户在浏览器中使用。
我们将要处理的大部分概念也适用于其他类型的人机界面,如 GUI 或移动应用。
HTML 技术与 RESTful 技术高度相关,因为它们在互联网早期并行开发。通常,它们在现代网络应用中被交织在一起展示。
传统 HTML 界面
传统网络界面通过 HTTP 请求工作,仅使用GET和POST方法。GET从服务器检索页面,而POST与一些表单配对,将数据提交到服务器。
这是一个先决条件,因为浏览器只实现了这些方法。虽然如今,大多数现代浏览器都可以在请求中使用所有 HTTP 方法,但允许与旧浏览器兼容仍然是一个常见的要求。
虽然这确实比所有可用选项都更加限制性,但它对于简单的网站界面来说可以很好地工作。
例如,博客的阅读次数远多于写作次数,因此读者会使用大量的GET请求来获取信息,也许还会使用一些POST请求来发送评论。删除或更改评论的传统需求很小,尽管它可以通过使用POST的其他 URL 来分配。
注意,在重试POST请求之前,浏览器会询问您,因为它们不是幂等的。
由于这些限制,HTML 界面与 RESTful 界面工作方式不同,但也可以通过考虑抽象和资源方法的设计来得到改进。
例如,以下是一些博客的常见抽象:
-
每个帖子及其相关评论
-
一个包含最新帖子的主页
-
一个可以返回包含特定单词或标签的帖子的搜索页面
这与资源界面中的界面非常相似,其中只有“评论”和“帖子”这两个资源,在 RESTful 方式中将它们分开,将它们结合在同一个概念中。
传统 HTML 界面的主要限制是每次更改都需要刷新整个页面。对于像博客这样的简单应用,这可以很好地工作,但更复杂的应用可能需要更动态的方法。
动态页面
为了给浏览器添加交互性,我们可以添加一些 JavaScript 代码,这些代码将在浏览器表示中直接执行操作以改变页面;例如,从下拉选择器中选择界面的颜色。
这被称为操作文档对象模型(DOM),它包含由 HTML 定义的文档表示,以及可能包含的 CSS。JavaScript 可以访问这种表示,并通过编辑任何参数或添加或删除元素来更改它。
从 JavaScript 中,也可以进行独立的 HTTP 请求,因此我们可以使用它来发出特定调用以检索可以添加以改善用户体验的详细信息。
例如,对于一个输入地址的表单,下拉菜单可以选择国家。一旦选择,服务器调用将检索适当的区域以包含输入。如果用户选择美国,将检索所有州并可在下一个下拉菜单中可用。如果用户选择加拿大,将使用地区和省的列表代替:

图 2.5:使用适当的下拉菜单改进用户体验
另一个例子,可以稍微反转界面,可能是使用 ZIP 代码自动确定州。
实际上有一个名为zippopotam.us/的服务可以检索此类信息。它可以被调用,并不仅返回州,还以 JSON 格式返回更多信息。
这类调用被称为异步 JavaScript 和 XML(AJAX)。尽管名称中提到了 XML,但并非必需,任何格式都可以检索。目前,使用 JSON 或甚至纯文本非常普遍。一种可能性是使用 HTML,这样可以将来自服务器的代码片段替换页面上的某个区域:

图 2.6:使用 HTML 替换页面区域
纯 HTML,虽然有些不优雅,但可能有效,因此非常常见使用返回 JSON 的 RESTful API 来检索这些小元素所需的数据,然后通过 JavaScript 代码使用它来修改 DOM。鉴于此 API 的目标不是完全替换 HTML 界面,而是补充它,因此这个 RESTful API 可能是不完整的。仅使用这些 RESTful 调用无法创建完整体验。
其他应用直接从创建 API 优先的方法开始,并从那里创建浏览器体验。
单页应用
单页应用背后的理念很简单。让我们打开一个 HTML 页面,并动态更改其内容。如果有任何新数据需要,将通过特定的(通常是 RESTful)API 访问。
这完全将人类界面,即负责将信息显示给人类的元素,从服务中分离出来。该服务仅提供 RESTful API,无需担心数据的表示形式。
这种方法有时被称为 API 优先,因为它从 API 到表示设计系统,而不是相反,这是在有机服务中自然创建的方式。
尽管有专门为此目的设计的特定框架和工具,例如 React 或 AngularJS,但采用这种方法的两个主要挑战是:
- 在单页上创建成功的人机界面的技术技能要求相当高,即使有工具的帮助也是如此。任何非平凡的界面表示都需要保持大量的状态和应对多次调用。这容易出错,可能会损害页面的稳定性。传统的浏览器页面方法使用独立的页面,限制了每一步的范围,这更容易处理。
请记住,浏览器携带的界面期望可能难以避免或替换,例如点击后退按钮。
- 需要在项目开始前设计和准备 API 可能会导致项目启动缓慢。即使双方都并行开发,这也需要更多的规划和前期承诺,这也存在其挑战。
这些问题确保了这种方法通常不会用于从头开始的新应用。然而,如果应用是从另一种类型的用户界面开始的,比如智能手机应用,它可以利用现有的 REST API 来生成一个复制的 HTML 界面,以实现其功能。
这种方法的主要优势是将应用与用户界面分离。如果一个应用以一个小项目开始,其开发是从常规 HTML 界面开始的,那么任何其他用户界面都倾向于符合 HTML 界面。这可能会迅速积累大量的技术债务,并损害 API 的设计,因为所使用的抽象可能来自现有界面,而不是最合适的界面。
完全采用 API 优先的方法可以极大地分离界面,因此创建新界面与现有 API 一样容易使用。对于需要多个界面,如 HTML 界面,以及 iOS 和 Android 的不同智能手机应用的应用,这可能是一个好的解决方案。
单页应用在呈现完整界面方面也可以非常创新。这可以创建丰富和复杂的界面,与通常所说的“网页”不同,例如在游戏或交互式应用中。
混合方法
如我们所见,全面采用单页应用可能会相当具有挑战性。在某种程度上,这是在用浏览器覆盖其使用。
正因如此,通常设计不会走得太远,而是创建一个更传统的 Web 界面。这个界面仍然可以被识别为 Web 应用程序,但严重依赖于 JavaScript 来使用 RESTful 接口获取信息。这可能是从传统的 HTML 界面迁移到单页应用程序的自然步骤,但也可能是一个有意识的决策。
这种方法结合了前两种方法。一方面,它仍然需要一个 HTML 界面来处理界面的通用方法,有清晰的页面进行导航。另一方面,它创建了一个 RESTful API,填充了大部分信息,并使用 JavaScript 来利用这个 API。
这种方法类似于动态页面方法,但有一个重要的区别,即创建一个可以不针对 HTML 界面而使用的连贯 API 的意图。这显著改变了方法。
实际上,这往往会导致一个不太完整的 RESTful API,因为一些元素可能直接添加到它的 HTML 部分。但与此同时,它允许元素迭代迁移到 API 中,从某些元素开始,但随着时间的推移添加更多。这个阶段非常灵活。
为示例设计 API
正如我们在第一章中描述的,示例概述,我们需要为示例中将要工作的不同界面设置定义。请记住,示例是一个微博客应用程序,将允许用户编写自己的文本微帖子,以便其他人可以阅读。
示例中有两个主要界面:
-
一个 HTML 界面,允许用户使用浏览器与服务交互
-
一个 RESTful 接口,允许创建其他客户端,如智能手机应用程序
在本章中,我们将描述第二个界面的设计。我们将从描述我们将使用的基本定义和资源开始:
-
用户:应用程序用户的表示。它将通过用户名和密码来定义,以便能够登录。
-
微帖子:由用户发布的最多 255 个字符的小文本。微帖子可以可选地指向一个用户。它还包括创建时间。
-
集合:显示来自用户的微帖子。
-
关注者:一个用户可以关注另一个用户。
-
时间线:按顺序排列的由关注的用户发布的微帖子。
-
搜索:允许通过用户或微帖子中包含的文本进行搜索。
我们可以以 RESTful 的方式定义这些元素作为资源,正如本章之前介绍的那样,首先是对 URI 的快速描述:
POST /api/token
DELETE /api/token
GET /api/user/<username>
GET /api/user/<username>/collection
POST /api/user/<username>/collection
GET /api/user/<username>/collection/<micropost_id>
PUT /api/user/<username>/collection/<micropost_id>
PATCH /api/user/<username>/collection/<micropost_id>
DELETE /api/user/<username>/collection/<micropost_id>
GET /api/user/<username>/timeline
GET /api/user/<username>/following
POST /api/user/<username>/following
DELETE /api/user/<username>/following/<username>
GET /api/user/<username>/followers
GET /api/search
注意,我们在/token中添加了POST和DELETE资源来处理登录和注销。
一旦这个简短的设计完成,我们就可以详细阐述每个端点的定义。
端点
我们将更详细地描述所有 API 端点,遵循本章之前引入的模板。
登录:
-
描述:使用适当的认证凭据,返回有效的访问令牌。令牌需要包含在请求的
Authorization头信息中。 -
资源 URI:
/api/token -
方法:
POST -
请求体:
{ "grant_type": "authorization_code" "client_id": <client id>, "client_secret": <client secret> } -
返回体:
{ "access_token": <access token>, "token_type":"bearer", "expires_in":86400, } -
错误:
400 Bad Request Incorrect body. 400 Bad Request Bad credentials.
登出:
-
描述:使令牌失效。如果成功,将返回
204 No Content错误。 -
资源 URI:
/api/token -
方法:
DELETE -
头信息:
Authentication: Bearer: <token> -
错误:
401 Unauthorized Trying to access this URI without being properly authenticated.
检索用户:
-
描述:返回用户名资源。
-
资源 URI:
/api/users/<username> -
方法:
GET -
头信息:
Authentication: Bearer: <token> -
查询参数:
size Page size. page Page number. -
返回体:
{ "username": <username>, "collection": /users/<username>/collection, } -
错误:
401 Unauthorized Trying to access this URI without being authenticated. 404 Not Found Username does not exist.
检索用户的收藏夹:
-
描述:以分页形式返回用户的所有微帖子集合。
-
资源 URI:
/api/users/<username>/collection -
方法:
GET -
头信息:
Authentication: Bearer: <token> -
返回体:
{ "next": <next page or null>, "previous": <previous page or null>, "result": [ { "id": <micropost id>, "href": <micropost url>, "user": <user url>, "text": <Micropost text>, "timestamp": <timestamp for micropost in ISO 8601> }, ... ] } -
错误:
401 Unauthorized Trying to access this URI without being authenticated. 404 Not Found Username does not exist.
创建新的微帖子:
-
描述:创建一个新的微帖子。
-
资源 URI:
/api/users/<username>/collection -
方法:
POST -
头信息:
Authentication: Bearer: <token> -
请求体:
{ "text": <Micropost text>, "referenced": <optional username of referenced user> } -
错误:
400 Bad Request Incorrect body. 400 Bad Request Invalid text (for example, more than 255 characters). 400 Bad Request Referenced user not found. 401 Unauthorized Trying to access this URI without being authenticated. 403 Forbidden Trying to create a micropost of a different user to the one logged in.
检索微帖子:
-
描述:返回单个微帖子。
-
资源 URI:
/api/users/<username>/collection/<micropost_id> -
方法:
GET -
头信息:
Authentication: Bearer: <token> -
返回体:
{ "id": <micropost id>, "href": <micropost url>, "user": <user url>, "text": <Micropost text>, "timestamp": <timestamp for micropost in ISO 8601>, "referenced": <optional username of referenced user> } -
错误:
401 Unauthorized Trying to access this URI without being authenticated. 404 Not Found Username does not exist. 404 Not Found Micropost ID does not exist.
更新微帖子:
-
描述:更新微帖子的文本。
-
资源 URI:
/api/users/<username>/collection/<micropost_id> -
方法:
PUT, PATCH -
头信息:
Authentication: Bearer: <token> -
请求体:
{ "text": <Micropost text>, "referenced": <optional username of referenced user> } -
错误:
400 Bad Request Incorrect body. 400 Bad Request Invalid text (for example, more than 255 characters). 400 Bad Request Referenced user not found. 401 Unauthorized Trying to access this URI without being authenticated. 403 Forbidden Trying to update a micropost of a different user to the one logged in. 404 Not Found Username does not exist. 404 Not Found Micropost ID does not exist.
删除微帖子:
-
描述:删除微帖子。如果成功,将返回
204 No Content错误。 -
资源 URI:
/api/users/<username>/collection/<micropost_id> -
方法:
DELETE -
头信息:
Authentication: Bearer: <token> -
错误:
401 Unauthorized Trying to access this URI without being authenticated. 403 Forbidden Trying to delete a micropost of a different user to the one logged in. 404 Not Found Username does not exist. 404 Not Found Micropost ID does not exist.
检索用户的动态:
-
描述:以分页形式返回用户时间线上的所有微帖子集合。微帖子将按时间戳顺序返回,最早的先返回。
-
资源 URI:
/api/users/<username>/timeline -
方法:
GET -
头信息:
Authentication: Bearer: <token> -
返回体:
{ "next": <next page or null>, "previous": <previous page or null>, "result": [ { "id": <micropost id>, "href": <micropost url>, "user": <user url>, "text": <Micropost text>, "timestamp": <timestamp for micropost in ISO 8601>, "referenced": <optional username of referenced user> }, ... ] } -
错误:
401 Unauthorized Trying to access this URI without being authenticated. 404 Not Found Username does not exist.
检索用户关注的用户:
-
描述:返回所选用户关注的用户集合。
-
资源 URI:
/api/users/<username>/following -
方法:
GET -
头信息:
Authentication: Bearer: <token> -
返回体:
{ "next": <next page or null>, "previous": <previous page or null>, "result": [ { "username": <username>, "collection": /users/<username>/collection, }, ... ] } -
错误:
401 Unauthorized Trying to access this URI without being authenticated. 404 Not Found Username does not exist.
关注一个用户:
-
描述:使所选用户关注不同的用户。
-
资源 URI:
/api/users/<username>/following -
方法:
POST -
头信息:
Authentication: Bearer: <token> -
请求体:
{ "username": <username> } -
错误:
400 Bad Request The username to follow is incorrect or does not exist. 400 Bad Request Bad body. 401 Unauthorized Trying to access this URI without being authenticated. 404 Not Found Username does not exist.
停止关注一个用户:
-
描述:停止关注一个用户。如果成功,将返回
204 No Content错误。 -
资源 URI:
/api/users/<username>/following/<username> -
方法:
DELETE -
头信息:
Authentication: Bearer: <token> -
错误:
401 Unauthorized Trying to access this URI without being authenticated. 403 Forbidden Trying to stop following a user who is not the authenticated one. 404 Not Found Username to stop following does not exist.
检索用户的关注者:
-
描述: 以分页形式返回此用户的全部关注者。
-
资源 URI:
/api/users/<username>/followers -
方法:
GET -
头部:
Authentication: Bearer: <token> -
返回体:
{ "next": <next page or null>, "previous": <previous page or null>, "result": [ { "username": <username>, "collection": /users/<username>/collection, }, ... ] } -
错误:
401 Unauthorized Trying to access this URI without being authenticated. 404 Not Found Username does not exist.
搜索微帖子:
-
描述: 以分页形式返回满足搜索查询的微帖子。
-
资源 URI:
/api/search -
方法:
GET -
头部:
Authentication: Bearer: <token> -
查询参数:
username: Optional username to search. Partial matches will be returned. text: Mandatory text to search, with a minimum of three characters. Partial matches will be returned. -
返回体:
{ "next": <next page or null>, "previous": <previous page or null>, "result": [ { "id": <micropost id>, "href": <micropost url>, "user": <user url>, "text": <Micropost text>, "timestamp": <timestamp for micropost in ISO 8601>, "referenced": <optional username of referenced user> }, ] } -
错误:
400 Bad Request No mandatory query parameters. 400 Bad Request Incorrect value in query parameters. 401 Unauthorized Trying to access this URI without being authenticated.
设计和实现的审查
这种两步法,即展示和设计新的 API,使您能够快速查看设计是否有不合适的地方。然后,可以迭代修复。下一步是开始实施,正如我们将在后续章节中看到的。
概述
在本章中,我们描述了 API 设计的基本原则,即创建一组有用的抽象,使用户能够在不关心内部细节的情况下执行操作。这导致了如何使用资源和动作定义 API 的描述。
这个 API 的定义已经发展到了涵盖遵循某些特性的 RESTful 接口,这些特性使得它们对于网络服务器设计非常有趣。我们在设计 RESTful 接口以创建一致和完整的接口时,描述了一系列有用的标准和技巧,包括 OpenAPI 工具。我们还讨论了认证细节,因为它是 API 的一个重要元素。
记住,在保护具有外部使用的 API 时,应特别注意。我们讨论了一些一般想法和常见策略,但请注意,本书并不专注于安全性。这是任何 API 设计的一个关键方面,应该谨慎处理。
我们涵盖了版本化的理念以及如何创建适合 API 特定用例的适当版本化模式。我们还讨论了前端和后端的差异以及如何进行一般化。我们还讨论了 MVC 模式,这是一种非常常见的软件结构方式。
我们描述了 HTML 界面的不同选项,以提供一个关于网络服务中不同接口的完整概述。我们讨论了 HTML 服务如何构建以及如何与其他 API 交互的不同选项。
最后,我们在审查总体设计和端点的同时,展示了示例的 RESTful 接口设计。
设计的另一个关键元素是数据结构。我们将在下一章中介绍这一点。
第三章:数据建模
任何应用程序的核心是其数据。在计算机应用程序的最底层,它是一个设计来处理信息、接收它、转换它并返回相同信息或从中提取的见解的系统。存储的数据是这个周期中的关键部分,因为它允许你使用之前已经传达的信息。
在本章中,我们将讨论如何对应用程序中存储的数据进行建模,以及存储和结构化要持久化的数据的不同选项。
我们将首先描述可用的不同数据库选项,这对于理解它们的不同应用至关重要,但在本章中,我们将主要关注关系数据库,因为它们是最常见的类型。我们将描述事务的概念,以确保不同的更改能够一次性应用。
我们将描述使用多个服务器来扩展关系数据库的不同方法,以及每个选项的使用案例。
之后,我们将描述在设计模式时不同的替代方案,以确保我们的数据以最佳方式结构化。我们将讨论如何通过使用索引来启用对数据的快速访问。
在本章中,我们将涵盖以下主题:
-
数据库类型
-
数据库事务
-
分布式关系数据库
-
模式设计
-
数据索引
让我们从介绍现有的不同数据库开始。
数据库类型
应用程序的所有持久数据都应该存储在数据库中。正如我们讨论的那样,数据是任何应用程序最关键的部分,正确处理它对于确保项目的可行性至关重要。
从技术角度来看,数据库本身就是数据的集合,并由数据库管理系统(DBMS)处理,该软件允许数据的输入和输出。通常,根据上下文,“数据库”一词用于集合和管理系统。大多数 DBMS 将允许访问同一类型的多个数据库,而无法在它们之间交叉数据,以允许数据的逻辑分离。
数据库在软件系统存在的大部分时间里都是一个关键工具。它们创建了一个抽象层,允许访问数据而无需过多担心硬件如何结构化数据。大多数数据库允许定义数据的结构,而无需担心幕后如何实现。
正如我们在第二章,API 设计中看到的,这种抽象并不完美,有时我们不得不了解数据库的内部结构,以提高性能或以“正确的方式”做事。
DBMS 是软件中最受投资和成熟的工程项目之一。每个 DBMS 都有其独特的特点,以至于有一个专门的职位是“数据库专家”:数据库管理员(DBA)。
数据库管理员(DBA)角色在很长时间内非常受欢迎,需要高度专业化的工程师,以至于 DBA 专门从事单个特定的 DBMS。DBA 将作为数据库的专家,既了解如何访问它,又确保对其所做的任何更改都能适当地工作。他们通常是唯一被允许在数据库中执行更改或维护任务的。
硬件和软件的性能改进以及处理数据库复杂性的外部工具使得这个角色不那么常见,尽管一些组织仍在使用它。在某种程度上,架构师角色接管了这一角色的部分,尽管更多的是监督角色而不是把关角色。
市场上有多款数据库管理系统(DBMS)可供选择,其中包括丰富的开源软件,可以覆盖大多数用例。粗略地说,我们可以将现有的 DBMS 替代品分为以下非详尽分类:
-
关系型数据库:数据库中的默认标准。使用 SQL 查询语言并具有定义的架构。例如,像 MySQL 或 PostgreSQL 这样的开源项目,或者像 Oracle 或 MS SQL Server 这样的商业产品。
-
非关系型数据库:传统数据库的新替代品。这是一个多样化的群体,有多种替代品,包括 MongoDB、Riak 或 Cassandra 等非常不同的选项。
-
小型数据库:这些数据库旨在嵌入到系统中。最著名的例子是 SQLite。
让我们更深入地了解一下它们。
关系型数据库
这些是最常见的数据库,当谈到数据库时,人们首先想到的想法。数据库的关系模型是在 20 世纪 70 年代开发的,它基于创建一系列可以相互关联的表。自 20 世纪 80 年代以来,它们变得极其流行。
每个定义的表都有一定数量的固定字段或列,数据被描述为记录或行。理论上,表是无限的,因此可以添加越来越多的行。其中一列被定义为主键,并唯一描述了该行。因此,它需要是唯一的。
如果有一个值既独特又足够描述性,它可以用作主键;这被称为自然键。自然键也可以是字段的组合,但这限制了它们的便利性。当没有自然键可用时,数据库可以直接处理递增计数器以确保每行都是唯一的。这被称为代理键。
当需要时,主键用于在其他表中引用该记录。这创建了数据库的关系方面。当表中的一列引用另一个表时,这被称为外键。
这些引用可以产生一对一的关系;一对一,当单行可以在另一个表中的多行中引用时;甚至多对多,这需要一个中间表来跨越数据。
所有这些信息都需要在模式中描述。模式描述了每个表,以及每个字段的类型,以及它们之间的关系。
关系型数据库中的关系实际上是约束。这意味着如果一个值在某处被引用,则不能删除它。关系型数据库来自严格的数学背景,尽管这种背景在不同程度上得到了实施。
需要注意的是,定义模式需要提前思考和意识到可以做出的更改。在拥有数据之前定义类型,也需要考虑到可能的改进。虽然模式可以更改,但这始终是一项严肃的操作,如果不妥善处理,可能会导致数据库在一段时间内不可用,或者在最坏的情况下,数据可能会被不一致地更改或处理。
也可以执行查询来搜索满足特定条件的数据。为此,可以根据它们之间的关系将表连接起来。
几乎所有关系型数据库都是通过结构化查询语言(SQL)进行交互的。这种语言已经成为与关系型数据库一起工作并遵循我们在这里描述的相同概念的标准。它描述了如何查询数据库以及如何添加或更改其中包含的数据。
SQL 最相关的特性是它是一种声明式语言。这意味着语句描述的是结果,而不是获取该结果的过程,这与命令式语言中的典型做法不同。这抽象了内部细节,从而关注于“什么”。
命令式语言描述控制流,是最常见的语言。命令式语言的例子包括 Python、JavaScript、C 和 Java。声明式语言通常限于特定的领域(领域特定语言,或 DSL),允许你用更简单的术语描述结果,而命令式语言则更加灵活。
这一特性使得 SQL 在不同系统之间具有可移植性,因为不同数据库的内部“如何”可能不同。使用特定的关系型数据库并适应另一个数据库相对容易。
这有时用于设置一个用于运行测试的本地数据库,该数据库与系统投入生产后将存在的最终数据库不同。这在某些 Web 框架中是可能的,但需要注意一些注意事项,因为复杂系统有时必须为特定数据库使用特定特性,这使得进行此类简单替换变得不可能。
虽然关系型数据库非常成熟且灵活,并且被用于非常不同的场景,但存在两个主要问题难以处理。一个是需要预定义的模式,正如我们上面所说的。另一个,在达到一定规模后更为严重,是处理扩展性问题。关系型数据库被认为是一个中心访问点,一旦达到垂直扩展的极限,就需要一些技术来扩展。
我们将在本章后面讨论处理此问题并提高关系型数据库可扩展性的具体技术。
非关系型数据库
非关系型数据库是一组多样化的数据库管理系统,它们不符合关系型范式。
非关系型数据库也被称为 NoSQL,强调 SQL 语言的关联性质,代表“非 SQL”或“不仅 SQL”,以更准确地反映增加可能性而不是移除它们。
在关系型数据库引入之前甚至与之并行,自 2000 年代以来,已经引入或恢复了旨在寻找替代方案的方法和设计。大多数它们旨在解决关系型数据库的两个主要弱点,即严格性和可扩展性问题。
它们非常多样,结构也各不相同,但最常见的非关系型系统类型如下几组:
-
键值存储
-
文档存储
-
宽列数据库
-
图数据库
让我们描述每一个。
键值存储
键值存储在功能上可以说是所有数据库中最简单的。它们定义了一个存储值的单一键。值对系统来说是完全透明的,无法以任何方式查询。在某些实现中,甚至没有查询系统中的键的方法;相反,它们需要成为任何操作的输入。
这与哈希表或字典非常相似,但规模更大。缓存系统通常基于这种类型的数据存储。
虽然该技术与数据库相似,但缓存和数据库之间存在一个重要的区别。缓存是一个存储已计算数据以加快检索速度的系统,而数据库存储原始数据。如果数据不在缓存中,可以从不同的系统检索,但如果数据不在数据库中,要么数据未存储,要么出现了大问题。
这就是为什么缓存系统倾向于仅在内存中存储信息,并且对重启或问题更具弹性,这使得它们更容易处理。如果缓存缺失,系统仍然可以工作,只是速度较慢。
信息最终不应存储在缺乏适当存储支持的缓存系统中,这是有时无意中犯下的错误,例如在处理时间数据时,但风险是在错误的时间遇到问题并丢失数据,因此要对此保持警觉。
该系统的主要优势在于其简单性,一方面允许快速存储和检索数据。它还允许你水平扩展到很大程度。由于每个键与其他键独立,它们甚至可以存储在不同的服务器上。系统还可以引入冗余,为每个键和值创建多个副本,尽管这会使信息检索变慢,因为需要比较多个副本以检测数据损坏。
一些键值数据库的例子是 Riak 和 Redis(如果启用了持久性)。
文档存储
文档存储围绕“文档”这一概念展开,这与关系数据库中的“记录”类似。然而,文档更加灵活,因为它们不需要遵循预定义的格式。它们通常还允许在子字段中嵌入更多数据,这是关系数据库通常不做的,而是通过创建关系并将数据存储在不同的表中。
例如,一个文档可以看起来像这样,这里以 JSON 格式表示:
{
"id": "ABCDEFG"
"name": {
"first": "Sherlock",
"surname": "Holmes"
}
"address": {
"country": "UK",
"city": "London",
"street": "Baker Street",
"number": "221B",
"postcode": "NW16XE"
}
}
文档通常按集合分组,类似于“表”。通常通过一个唯一的 ID 来检索文档,该 ID 作为主键,但也可以构建查询以搜索文档中创建的字段。
因此,在我们的情况下,我们可以检索键(ID)ABCDEFG,就像在键值存储中一样;或者进行更丰富的查询,例如“获取所有在detectives集合中且address.country等于UK的条目”,例如。
请记住,虽然从技术上讲,可以创建一个包含完全独立且格式不同的文档的集合,但在实践中,一个集合中的所有文档都将遵循某种相似的格式,包括可选字段或嵌入数据。
一个集合中的文档可以通过其 ID 与另一个集合中的文档相关联,创建一个引用,但通常这些数据库不允许你创建连接查询。相反,应用层应该允许你检索这些链接信息。
通常,文档倾向于嵌入信息而不是创建引用。这可能导致信息去规范化,在多个地方重复信息。我们将在本章后面更多地讨论去规范化。
一些文档存储的例子是 MongoDB (www.mongodb.com/) 和 Elasticsearch (www.elastic.co/elasticsearch/)。
宽列数据库
宽列数据库通过列来组织数据。它们创建具有特定列的表,但这些列是可选的。它们也无法原生地将一个表中的记录与另一个表中的记录相关联。
它们比纯键值存储更易于查询,但需要对系统中的可能查询类型进行更多前期设计工作。这比在文档存储中更为限制性,在文档设计完成后,文档存储在这方面有更大的灵活性。
通常,列是相关的,并且只能按特定顺序查询,这意味着如果存在列 A、B 和 C,则一行可以基于 A、A 和 B 或 A、B 和 C 进行查询,但不能仅基于 C 或 B 和 C,例如。
它们旨在针对具有高可用性和复制数据的非常大的数据库部署。一些宽列数据库的例子包括Apache Cassandra (cassandra.apache.org/)和谷歌的Bigtable (cloud.google.com/bigtable)。
图数据库
虽然之前的非关系型数据库是基于放弃元素之间创建关系的能力以获得其他功能(如可扩展性或灵活性),但图数据库则走向了相反的方向。它们极大地增强了元素的关系方面,以创建复杂的图。
它们存储节点和边或节点之间的关系对象。边和节点都可以有属性来更好地描述它们。
图数据库的查询能力旨在根据关系检索信息。例如,给定一家公司及其供应商的列表,是否存在任何特定国家的特定公司的供应链中的供应商?达到多少层级?这些问题对于关系数据库的第一层级可能很容易回答(获取公司的供应商及其国家),但对于第三层级的关系来说则相当复杂且耗时。

图 3.1:图数据库中典型数据的示例
它们通常用于社交图,其中存在人与人或组织之间的连接。一些例子包括Neo4j (neo4j.com/)或ArangoDB (www.arangodb.com/)。
小型数据库
与其他数据库相比,这一组有点特别。它由不作为独立进程区分的数据库系统组成,作为独立的客户端-服务器结构运行。相反,它们嵌入到应用程序的代码中,直接从硬盘读取。它们通常用于作为单个进程运行且希望以结构化方式保存信息的简单应用程序。
表示此方法的一种粗略但有效的方式是将信息作为 JSON 对象保存到文件中,并在需要时恢复,例如智能手机应用程序的客户设置。设置文件在应用程序启动时从内存中加载,如果有任何更改则保存。
例如,在 Python 代码中,这可以表示如下:
>>> import json
>>> with open('settings.json') as fp:
... settings = json.load(fp)
...
>>> settings
{'custom_parameter': 5}
>>> settings['custom_parameter'] = 3
>>> with open('settings.json', 'w') as fp:
... json.dump(settings, fp)
对于少量数据,这种结构可能适用,但它有一个限制,那就是查询起来比较困难。最完整的替代方案是 SQLite,它是一个完整的 SQL 数据库,但它被嵌入到系统中,无需外部调用。数据库存储在一个二进制文件中。
SQLite 非常流行,以至于它甚至被许多标准库支持,无需外部模块,例如,在 Python 标准库中。
>>> import sqlite3
>>> con = sqlite3.connect('database.db')
>>> cur = con.cursor()
>>> cur.execute('''CREATE TABLE pens (id INTEGER PRIMARY KEY DESC, name, color)''')
<sqlite3.Cursor object at 0x10c484c70>
>>> con.commit()
>>> cur.execute('''INSERT INTO pens VALUES (1, 'Waldorf', 'blue')''')
<sqlite3.Cursor object at 0x10c484c70>
>>> con.commit()
>>> cur.execute('SELECT * FROM pens');
<sqlite3.Cursor object at 0x10c484c70>
>>> cur.fetchall()
[(1, 'Waldorf', 'blue')]
此模块遵循 DB-API 2.0 标准,这是连接到数据库的 Python 标准。它的目标是标准化对不同数据库后端的访问。这使得创建一个可以访问多个 SQL 数据库并可以最小化更改进行交换的高级模块变得容易。
您可以在 PEP-249 中查看完整的 DB-API 2.0 规范:www.python.org/dev/peps/pep-0249/。
SQLite 实现了大部分 SQL 标准。
数据库事务
对于数据库来说,存储数据可能是一个复杂的内部操作。在某些情况下,它可能包括在单个位置更改数据,但有一些操作可以在单个操作中影响数百万条记录,例如,“更新在此时间戳之前创建的所有记录”。
这些操作的范围和可能性在很大程度上取决于数据库,但它们与关系数据库非常相似。在这种情况下,通常有事务的概念。
事务是一次性发生的操作。要么发生,要么不发生,但数据库不会处于中间的不一致状态。例如,如果之前描述的“更新在此时间戳之前创建的所有记录”的操作可能会产生一个效果,即通过错误,只有一半的记录被更改,那么它不是一个事务,而是多个独立的操作。
在事务中间可能会出现错误。在这种情况下,它将回滚到开始处,因此没有任何记录会更改。
在某些应用中,这种特性可能成为对数据库的强烈要求,这被称为原子性。这意味着当它被应用时,事务是原子的。这种特性是所谓的 ACID 属性的主要特性。
其他属性是一致性、隔离性和持久性。这四个属性是:
-
原子性,意味着事务作为一个单元应用。要么完全应用,要么不应用。
-
一致性,意味着事务在应用时考虑到数据库中定义的所有限制。例如,外键约束得到尊重,或者任何修改数据的存储触发器。
-
隔离性,意味着并行事务以相同的方式运行,就像它们一个接一个地运行一样,确保一个事务不会影响另一个。显然,它们的运行顺序可能会有影响。
-
持久性,这意味着在事务报告为完成后,即使在灾难性故障(如数据库进程崩溃)的情况下也不会丢失。
这些特性是处理数据的黄金标准。这意味着数据是安全和一致的。
大多数关系型数据库都有开始事务、执行多个操作,然后最终提交事务的概念,这样所有更改都可以一次性应用。如果出现问题,事务将失败,回滚到之前的状态。如果在执行操作期间检测到任何问题,如约束问题,事务也可以被中止。
这种操作方式允许创建额外的验证步骤,因为在事务内部,数据仍然可以被查询并在最终提交之前进行验证。
ACID 事务在性能方面有成本,尤其是在可扩展性方面。持久性的需求意味着数据需要在事务返回之前存储在磁盘或其他永久性支持上。隔离性的需求意味着每个打开的事务都需要以某种方式操作,使其无法看到新的更新,这可能需要存储临时数据直到事务完成。一致性还要求检查以确保所有约束都得到满足,这可能需要复杂的检查。
几乎所有关系型数据库都是完全 ACID 兼容的,这已经成为它们的定义特征。在非关系型世界中,事情更加灵活。
使用具有这些特性的多个服务器或节点扩展数据库很困难。这个系统创建了分布式事务,同时在多个服务器上运行。在具有多个服务器的数据库中维护完整的 ACID 事务非常困难,并且在性能方面有沉重的代价,因为理解其他节点所做的工作以及在它们中的任何一个出现故障时回滚事务的额外延迟。问题也会以非线性方式增加,某种程度上抵消了拥有多个服务器的优势。
虽然这是可能的,但许多应用程序可以绕过这些限制。我们将看到一些有用的模式。
分布式关系型数据库
正如我们之前讨论的,关系型数据库并不是为了可扩展性而设计的。它们在强制执行强大的数据保证方面很出色,包括 ACID 事务,但它们首选的操作方式是通过单个服务器。
这可能会对使用关系型数据库的应用程序的大小施加限制。
值得注意的是,数据库服务器可以垂直扩展,这意味着使用更好的硬件。增加服务器容量或用更大的服务器替换它,对于高需求来说,比应用这些技术要容易,但有一个限制。无论如何,请确保预期的尺寸足够大。如今,云服务提供商的某些服务器内存达到 1TB 或更多。这足以覆盖大量情况。
注意,这些技术在系统运行后扩展系统时很有用,并且可以添加到大多数关系型数据库的使用中。
ACID 属性的不利之处在于最终一致性。不是一次性处理的原子操作,系统逐渐转换为所需的系统。系统不是所有部分同时处于相同状态。相反,在系统传播这个变化的过程中会有一定的延迟。另一个大优点是我们可以提高可用性,因为它不会依赖于单个节点来做出更改,任何不可用的元素在恢复后会赶上。由于集群的分布式特性,这可能涉及咨询不同的来源并试图在它们之间达到共识。
在考虑是否放宽一些 ACID 属性是否值得做时,很大程度上取决于你心中的应用程序。对于延迟或数据损坏影响较大且可能不可接受的关键数据,可能不适合分布式数据库。
为了增加容量,首先要了解应用程序的数据访问方式。
主/副本
一个非常常见的例子是读取次数远高于写入次数。或者用 SQL 术语来说,SELECT语句的数量远高于UPDATE或DELETE语句的数量。这在信息访问远多于信息更新的应用程序中非常典型,例如,报纸,阅读新闻文章的访问量很大,但相比之下新文章不多。
对于这种情况,一个常见的模式是创建一个集群,添加一个或多个数据库只读副本,然后在这些副本之间分配读取,这种情况类似于以下:

图 3.2:处理多个读取查询
所有写入都发送到主节点,然后自动传播到副本节点。因为副本包含整个数据库,唯一的写入活动来自主节点,这增加了系统中可以同时运行的查询数量。
该系统由大多数关系型数据库原生支持,尤其是最常见的 MySQL 和 PostgreSQL。写入节点配置为主节点,副本节点指向主节点以开始复制数据。经过一段时间,它们将更新并同步到主节点。
主服务器上的每个新更改都会自动复制。然而,这有一个延迟,称为复制延迟。这意味着刚刚写入的数据在一段时间内不可读,通常不到一秒。
复制延迟是数据库健康状态的良好指标。如果延迟随时间增加,这表明集群无法处理当前流量水平,需要调整。这个时间将受到每个节点网络和总体性能的极大影响。
因此,要避免的操作是在外部操作中立即写入和读取相同或相关数据,因为这可能导致不一致的结果。这可以通过暂时保留数据以避免查询需求,或者通过使特定读取指向主节点以确保数据一致性来解决。
这些直接读取仅在必要时使用,因为它们违反了减少主服务器查询数量的理念。这正是设置多个服务器的原因!

图 3.3:对主节点上的特定读取查询
此系统还允许数据冗余,因为数据始终被复制到副本中。如果出现问题,可以将副本提升为新的主服务器。
副本服务器并不完全履行与备份相同的角色,尽管它可以用于类似的目的。副本旨在执行快速操作并保持系统的可用性。备份更容易且成本更低,允许您保留数据的历史记录。备份也可以位于与副本非常不同的位置,而副本需要与主服务器有良好的网络连接。
即使有副本可用,也不要跳过备份。备份可以在灾难性故障的情况下增加一层安全防护。
注意,这种数据库结构方式可能需要调整应用层以了解所有更改并访问不同的数据库服务器。存在一些现有工具,如 Pgpool(用于 PostgreSQL)或 ProxySQL(用于 MySQL),它们位于路径中间并重定向查询。应用将查询发送到代理,然后代理根据配置重定向它们。有些情况,如我们上面看到的读写模式,不容易覆盖,可能需要在应用代码中进行特定更改。在使用这些工具之前,请确保理解它们的工作原理并对其进行一些测试。
这种结构的简单案例是创建离线副本。这些副本可以从备份中创建,并且不更新来自实时系统。这些副本可以用于创建不需要最新信息的查询,在可能的情况下,每日快照就足够了。它们在统计分析或数据仓库等应用中很常见。
分片
如果应用程序的写入次数较多,主从结构可能不够好。太多的写入都指向同一个服务器,这会形成瓶颈。或者如果系统流量增长足够大,单个服务器可以接受的写入次数将有限。
一种可能的解决方案是将数据水平分区。这意味着根据特定的键将数据分割到不同的数据库中,这样所有相关数据都可以发送到同一个服务器。每个不同的分区被称为分片。
注意,“分区”和“分片”可以被认为是同义词,尽管在现实中,分片只有在分区是水平的时候才存在,即把单个表分割到不同的服务器上。分区可以更通用,比如把一个表分成两个,或者分割到不同的列,这通常不被称为分片。
分区键被称为分片键,根据其值,每一行都会被分配到特定的分片。

图 3.4:分片键
“分片”这个名字来源于电子游戏《Ultima Online》,在 20 世纪 90 年代末,该游戏使用这种策略创建了一个“多元宇宙”,不同的玩家可以在不同的服务器上玩同样的游戏。他们称之为“分片”,因为它们是同一现实的不同方面,但其中包含了不同的玩家。这个名字一直沿用至今,现在仍用来描述这种架构。
任何查询都需要能够确定要应用的正确分片。任何影响两个或更多分片的查询可能无法执行,或者只能依次执行。当然,这排除了在单个事务中执行这些查询的可能性。在任何情况下,这些操作都将非常昂贵,应尽可能避免。当数据自然分区时,分片是一个极好的想法,而当执行影响多个分片的查询时,分片就非常糟糕。
一些 NoSQL 数据库允许原生分片,这将自动处理所有这些选项。一个常见的例子是 MongoDB,它甚至能够在多个分片中透明地运行查询。无论如何,这些查询都会很慢。
选择分片键也非常关键。一个好的键应该遵循数据之间的自然分区,这样就不需要执行跨分片查询。例如,如果某个用户的数据与其他数据独立,这在照片分享应用中可能发生,那么用户标识符可以是一个好的分片键。
另一个重要的特性是,需要根据分片键确定要解决查询的分片。这意味着每个查询都需要有分片键可用。这意味着分片键应该是每个操作的输入。
分片键的另一个特性是,数据应该理想地分割成分片大小相同,或者至少相似到足够程度。如果一个分片比其他分片大得多,可能会导致数据不平衡的问题、查询分布不足,以及一个分片成为瓶颈。
纯分片
在纯分片中,数据全部分区在分片中,分片键是每个操作的输入。分片是根据分片键确定的。
为了确保分片平衡,每个键都通过一种方式散列,使得它们在分片数量之间均匀分布。一个典型的例子是使用取模操作,例如。如果我们有 8 个分片,我们根据一个均匀分布的数字来确定数据分区的分片。
| 用户 ID | 操作 | 分片 |
|---|---|---|
| 1234 | 1234 mod 8 | 2 |
| 2347 | 2347 mod 8 | 3 |
| 7645 | 7645 mod 8 | 5 |
| 1235 | 1235 mod 8 | 3 |
| 4356 | 4356 mod 8 | 4 |
| 2345 | 2345 mod 8 | 1 |
| 2344 | 2344 mod 8 | 0 |
如果分片键不是一个数字,或者它分布不均匀,可以应用一个哈希函数。例如,在 Python 中:
>>> import hashlib
>>> shard_key = 'ABCDEF'
>>> hashlib.md5(shard_key.encode()).hexdigest()[-6:]
'b9fcf6'
>>> int('b9fcf6', 16) # Transform in number for base 16
12188918
>>> int('b9fcf6', 16) % 8
6
这种策略仅在分片键始终作为每个操作的输入可用时才可行。当这不是一个选项时,我们需要考虑其他选项。
改变分片数量不是一个容易的任务,因为每个键的目的是由一个固定的公式决定的。然而,通过提前做一些准备,是有可能增加或减少分片数量的。
我们可以创建指向同一服务器的“虚拟分片”。例如,为了创建 100 个分片,并使用两个服务器,最初虚拟分片分布将如下所示:
| 虚拟分片 | 服务器 |
|---|---|
| 0-49 | 服务器 A |
| 50-99 | 服务器 B |
如果需要增加服务器数量,虚拟分片结构将按这种方式改变。
| 虚拟分片 | 服务器 |
|---|---|
| 0-24 | 服务器 A |
| 25-49 | 服务器 C |
| 50-74 | 服务器 B |
| 75-99 | 服务器 D |
这种更改对应于每个分片的具体服务器可能需要一些代码更改,但因为它不会改变分片键的计算,所以更容易处理。同样的操作可以反向应用,尽管它可能会造成不平衡,所以需要谨慎操作。
| 虚拟分片 | 服务器 |
|---|---|
| 0-24 | 服务器 A |
| 25-49 | 服务器 C |
| 50-99 | 服务器 B |
每个操作都需要根据分片键更改数据的位置。这是一个昂贵的操作,尤其是当需要交换大量数据时。
混合分片
有时无法创建纯分片,需要从输入进行转换以确定分片键。例如,当用户登录且分片键是用户 ID 时,就是这种情况。用户将使用他们的电子邮件登录,但需要将其转换为用户 ID,以便能够确定要搜索信息的分片。
在这种情况下,可以使用外部表纯粹地翻译特定查询的输入到分片键。

图 3.5:外部表用于翻译分片键的输入
这会形成一个情况,即单个分片负责这个翻译层。这个分片可以专门用于此,也可以作为任何其他分片。
请记住,这需要对每个可能的输入参数都添加一个翻译层,这些参数不是直接的分片键,并且需要在一个数据库中保留所有分片的信息。这需要受到控制,并尽可能存储最少的信息,以避免问题。
这种策略也可以用来直接存储哪个分片键对应哪个分片,并执行查询而不是直接操作,就像我们上面看到的。

图 3.6:将分片键存储到分片中
这的不便之处在于,根据键确定分片需要在一个数据库中进行查询,尤其是对于大型数据库。但它也允许以一致的方式更改数据分片,这可以用来调整分片数量,如增长或减少。而且可以在不要求停机的情况下完成。
如果在翻译表中存储了特定的分片(而不仅仅是分片键),则可以逐个、连续地更改分片到键的分配。这个过程将大致如下:
-
分片键 X 在参考表中分配给服务器 A。这是初始状态。
-
服务器 A 为分片键 X 的数据已复制到服务器 B。请注意,尚未将涉及分片键 X 的任何查询指向服务器 B。
-
一旦所有数据都已复制,分片键 X 的参考表条目将更改为服务器 B。
-
所有针对分片键 X 的查询都指向服务器 B。
-
服务器 A 中分片键 X 的数据可以进行清理。
第 3 步是关键步骤,必须在所有数据复制后、任何新写入操作之前发生。确保这一点的办法是在参考表中创建一个标志,在操作进行时可以停止或延迟数据的写入。这个标志将在第 2 步之前设置,并在第 3 步完成后移除。
这个过程将在一段时间内产生平滑迁移,但它需要足够的空间来工作,并且可能需要相当长的时间。
扩容操作比缩容操作更复杂,因为空间增加提供了充足的空间。幸运的是,数据库集群需要缩容的情况很少,因为大多数应用程序会随着时间的推移而增长。
请留出足够的时间来完成迁移。根据数据集的大小和复杂性,迁移可能需要很长时间,极端情况下甚至可能需要数小时或数天。
表分片
对于较小的集群,可以通过服务器来分离表或集合,作为按分片键分片的一种替代方案。这意味着对表 X 的任何查询都会被导向特定的服务器,而其他查询则导向另一个服务器。这种策略仅适用于无关的表,因为不同服务器上的表之间无法执行连接操作。
注意,这可以被认为是一种过于拘泥于细节的做法,因为它并不真正地实现了分片,尽管结构相似。
这是一种较为简单的替代方案,但它的灵活性要低得多。它仅适用于相对较小的集群,其中一张或两张表与其他表的大小存在很大不均衡,例如,如果一张表存储的日志数据比数据库中的其他部分大得多,并且访问频率较低。
分片的优势和劣势
总结来说,分片的主要优势包括:
-
允许将写入分散到多个服务器上,从而提高系统的写入吞吐量。
-
数据存储在多个服务器上,因此可以存储大量数据,而不会限制单个服务器上可以存储的数据量。
从本质上讲,分片允许创建大型、可扩展的系统。但这也存在一些劣势:
-
分片系统运行起来更复杂,并且在配置不同服务器等方面有一些开销。虽然任何大型部署都会遇到问题,但与主从设置相比,分片需要更多的工作,因为维护和操作需要更加细致的计划,操作也会更加耗时。
-
原生支持分片仅在少数数据库中可用,例如 MongoDB,但关系型数据库并没有原生实现这一功能。这意味着需要用专门的代码来处理复杂性,这将需要投入开发成本。
-
一旦数据被分片,某些查询将变得不可能或几乎不可能执行。聚合和连接操作,取决于数据的分区方式,将无法执行。分片键需要仔细选择,因为它将对可能的查询产生重大影响。我们还会失去 ACID 属性,因为某些操作可能需要涉及多个分片。分片数据库的灵活性较低。
正如我们所见,设计、运营和维护分片数据库只适用于非常大的系统,当数据库中的操作数量需要如此复杂的系统时才有意义。
模式设计
对于需要定义模式的数据库,需要考虑使用特定的设计。
本节将具体讨论关系数据库,因为它们强制执行更严格的模式。其他数据库在更改方面更加灵活,但它们也受益于花时间思考其结构。
更改模式是一个重要的操作,需要规划,并且当然,需要对设计采取长期视角。
我们将在本章后面讨论如何更改数据库的模式。在此我们只需指出,更改数据库模式是构建系统过程中不可避免的一部分。尽管如此,这是一个需要尊重并理解可能问题的过程。确保模式设计足够合理,花时间思考和确保这一点绝对是个好主意。
设计模式的最好方式是绘制不同的表、字段及其关系,如果有外键指向其他表。

图 3.7:绘制模式
此数据的展示应允许你发现可能的盲点或元素的重复。如果表的数量太多,可能需要将其分成几个组。
虽然有工具可以帮助完成这项工作,但就我个人而言,手动绘制这些关系有助于我思考不同的关系,并在脑海中构建设计图。
每个表都可以与其他不同类型的表有外键关系:
- 一对多,其中为另一个表中的多个元素添加单个引用。例如,一位作者在其所有书籍中都有引用。在这种情况下,简单的外键关系就足够了,因为《书籍》表将有一个指向《作者》表中条目的外键。多个书籍行可以引用同一作者。

图 3.8:第一张表中的键引用了第二张表中的多行
- 一对一或零一是特定情况,其中一行只能与另一行相关联。例如,假设编辑可以处理一本书(并且一次只能处理一本书)。在《书籍》表中编辑的引用是一个外键,如果没有编辑过程,则可以将其设置为
null。从编辑到书籍的另一个反向引用将确保关系的唯一性。这两个引用都需要在事务中更改。
严格的一对一关系,例如一本书和它的标题,两者总是相关联,通常最好将所有信息添加到单个表中。

图 3.9:关系仅使匹配两行成为可能
- 多对多,在两个方向上都可以有多个分配。例如,一本书可能被归类在不同的类型下。一个类型将被分配给多本书,一本书也可以被分配给多个类型。在关系型数据结构中,需要一个中间的额外表来建立这种关系,该表将指向书籍和类型。
这个额外表可能包含更多信息,例如,类型对书籍的准确性。这样,它可以描述 50%恐怖和 90%冒险的书籍。
在关系型数据世界之外,有时不需要迫切创建多对多关系,而可以直接将其作为标签集合添加。一些关系型数据库现在允许在允许字段为列表或 JSON 对象方面有更多的灵活性,这可以用来简化设计。

图 3.10:注意中间表允许多种组合。第一个表可以引用第二个表的多个行,第二个表也可以引用第一个表的多个行
在大多数情况下,每个表要存储的字段类型是直接的,尽管应该考虑某些细节:
-
为未来的增长留出足够的空间。一些字段,如字符串,需要定义一个最大存储大小。例如,存储表示电子邮件地址的字符串将需要最多 254 个字符。但有时大小并不明显,例如存储客户的名称。在这些情况下,最好选择安全的大小并增加限制。
-
限制不仅应该应用于数据库,还应该在此级别以上强制执行,以确保任何处理该字段的 API 或 UI 都能优雅地处理。
在处理数字时,在大多数情况下,常规整数足以表示大多数使用的数字。尽管一些数据库接受像
smallint(2 字节)或tinyint(1 字节)这样的类别,但并不建议使用它们。使用的空间差异将非常小。 -
内部数据库表示不需要与外部可用的内容相同。例如,数据库中存储的时间应始终为协调世界时(UTC),然后转换为用户的时区。
总是在 UTC 格式中存储时间,允许在服务器上使用一致的时间,特别是如果有不同时区的用户。通过应用用户的时区来存储时间会在数据库中产生不可比较的时间,而使用服务器默认的时区可能会根据服务器的位置产生不同的结果,或者更糟糕的是,如果涉及不同时区的多个服务器,可能会产生不一致的数据。确保所有时间都在数据库中以 UTC 格式存储。
另一个例子是如果存储价格,最好以分存储,以避免浮点数,然后以美元和分的形式展示。
例如,这意味着$99.95 的价格将被存储为整数9995。处理浮点算术可能会为价格带来问题,而价格可以转换为分以便于处理。
如果出于某种原因以不同的格式存储它们更好,内部表示不需要遵循相同的约定。
-
同时,最好以自然的方式表示数据。一个典型的例子是过度使用数字 ID 来表示具有自然键的行,或者使用
Enums(分配给表示选项列表的小整数)而不是使用短字符串。虽然这些选择在过去某个时候是有意义的,当时空间和处理能力更加受限,但现在性能提升微乎其微,以可理解的方式存储数据在开发过程中有很大帮助。例如,与其使用整数字段来存储颜色,其中
1代表RED,2代表BLUE,3代表YELLOW,不如使用短字符串字段,使用字符串RED、BLUE和YELLOW。即使有数百万条记录,存储差异也是微不足道的,而且导航数据库要容易得多。
我们稍后会看到关于规范化(Normalization)和反规范化(Denormalization)的内容,它们与这个概念相关。
- 没有任何设计会是完美或完整的。在一个正在开发中的系统中,模式总是会需要改变。这是完全正常和预期的,应该被接受为这样的。完美是好的敌人。设计应该尽可能地简单,以适应系统的当前需求。过度设计,试图推进每一个可能未来的需求,并使设计复杂化,这是一个真正的问题,可能会浪费为那些永远不会实现的需求奠定基础的努力。保持你的设计简单和灵活。
模式规范化
正如我们所看到的,在关系型数据库中,一个关键概念是外键。数据可以存储在一张表中,并与另一张表相链接。这种数据分割意味着一组有限的数据,而不是存储在单个表中,可以被分割成两部分。
例如,让我们看看这个表,最初字段House是一个字符串:
角色
| id | 名称 | 家族 |
|---|---|---|
| 1 | 艾德·史塔克 | 史塔克 |
| 2 | 约恩·史塔克 | 史塔克 |
| 3 | 丹妮莉丝·坦格利安 | 坦格利安 |
| 4 | 詹姆·兰尼斯特 | 兰尼斯特 |
为了确保数据的一致性并且没有错误,字段House可以被规范化。这意味着它存储在不同的表中,并且通过这种方式强制执行FOREIGN KEY约束。
角色 家族
| id | 名称 | 家族 Id (FK) | id | 名称 | 词语 | |
|---|---|---|---|---|---|---|
| 1 | 艾德·史塔克 | 1 | 1 | 史塔克 | 冬风将至 | |
| 2 | 约恩·史塔克 | 1 | 2 | 兰尼斯特 | 听我咆哮 | |
| 3 | 丹妮莉丝·坦格利安 | 3 | 3 | 坦格利安 | 火与血 | |
| 4 | 詹姆·兰尼斯特 | 2 |
这种操作方式标准化了数据。除非首先在“家族”表中引入,否则不能添加带有新“家族”的新条目。同样,如果“角色”表中有一个条目引用了它,则不能删除“家族”表中的条目。这确保了数据非常一致,没有问题,例如,为新的条目引入一个像“家族”兰尼斯特(单 n)这样的打字错误,这可能会使后续查询复杂化。
它还有这样的优点:可以为“家族”表中的每个条目添加额外信息。在这种情况下,我们可以添加“家族”的“词语”。数据也更紧凑,因为重复信息存储在单个位置。
另一方面,这有几个问题。首先,任何需要知道“家族”信息的“角色”引用都需要执行一个JOIN查询。在第一个“角色”表中,我们可以这样生成查询:
SELECT Name, House FROM Characters;
而在第二个架构中,我们将需要这个:
SELECT Characters.Name, Houses.Name
FROM Characters JOIN Houses ON Characters.HouseId = Houses.id;
这个查询将需要更长的时间来执行,因为需要从两个表中组合信息。对于大型表,这个时间可能会非常长。如果我们添加例如“首选武器”字段和每个“角色”的标准化“武器”表,这也可能需要从不同的表中执行JOIN。或者,随着“角色”表字段的增长,我们还可以添加更多的表。
插入和删除数据也会花费更长的时间,因为需要执行更多的检查。一般来说,操作会花费更长的时间。
标准化数据也难以分片。将每个元素描述保存在其自己的表中并从那里引用的概念本身就很难以分片,因为它使得分区变得非常困难。
另一个问题在于数据库更难以阅读和操作。删除需要按顺序进行,随着更多字段的添加,这变得更加难以跟踪。此外,对于简单的操作,需要执行复杂的JOIN查询。查询更长,更复杂。
虽然这种通过数值标识符创建外键的标准化结构相当典型,但并非唯一的选择。
为了提高数据库的清晰度,可以使用自然键来简化它们,以这种方式描述数据。而不是使用整数作为主键,我们在“家族”表上使用“名称”字段。
角色 家族
| Id | 名称 | 家族(外键) | 名称(主键) | 词语 | |
|---|---|---|---|---|---|
| 1 | 艾德·史塔克 | 史塔克家族 | 史塔克家族 | 冬风将至 | |
| 2 | 约恩·雪诺 | 史塔克家族 | 兰尼斯特家族 | 听我咆哮 | |
| 3 | 丹妮莉丝·坦格利安 | 坦格利安家族 | 坦格利安家族 | 火与血 | |
| 4 | 詹姆·兰尼斯特 | 兰尼斯特家族 |
这不仅消除了使用额外字段的需求,还允许你使用描述性值进行引用。即使数据是标准化的,我们也能恢复原始查询。
正如我们之前所描述的,存储字符串而不是单个整数的额外空间是可以忽略不计的。一些开发者非常反对自然键,并更喜欢使用整数值,但如今并没有真正的技术理由来限制自己。
只有当我们想要获取Words字段中的信息时,我们才需要进行JOIN查询:
SELECT Name, House FROM Characters;
总之,这个技巧可能无法避免在正常操作中使用JOIN查询。也许有很多引用,系统在执行查询所需的时间上遇到了问题。在这种情况下,可能需要减少对JOIN表的需求。
反规范化
反规范化是规范化的对立面。规范化数据时,将其拆分到不同的表中以确保所有数据的一致性,而反规范化则将信息重新组合到一个表中,以避免必须JOIN表。
按照我们上面的例子,我们想要替换如下JOIN查询:
SELECT Characters.Name, Houses.Name, House.Words
FROM Characters JOIN Houses ON Characters.House = Houses.Name;
它遵循以下模式:
角色 家族
| Id | 姓名 | 家族(外键) | 姓名(主键) | 话语 | |
|---|---|---|---|---|---|
| 1 | 艾德·史塔克 | 斯塔克 | 斯塔克 | 冬风将至 | |
| 2 | 约恩·雪诺 | 斯塔克 | 兰尼斯特 | 听我咆哮 | |
| 3 | 丹妮莉丝·坦格利安 | 坦格利安 | 坦格利安 | 火与血 | |
| 4 | 詹姆·兰尼斯特 | 兰尼斯特 |
对于类似这样的查询,查询单个表,可以使用如下方法:
SELECT Name, House, Words FROM Characters
要做到这一点,数据需要在一个表中结构化。
角色
| id | 姓名 | 家族 | 话语 |
|---|---|---|---|
| 1 | 艾德·史塔克 | 斯塔克 | 冬风将至 |
| 2 | 约恩·雪诺 | 斯塔克 | 冬风将至 |
| 3 | 丹妮莉丝·坦格利安 | 坦格利安 | 火与血 |
| 4 | 詹姆·兰尼斯特 | 兰尼斯特 | 听我咆哮 |
注意,信息是重复的。每个Character都有一个House的Words的副本,这在之前是不必要的。这意味着反规范化使用更多的空间;在一个行数众多的表中,空间会更多。
反规范化也增加了数据不一致的风险,因为没有东西可以确保没有新的值是旧值的打字错误,或者错误地添加了不同的House中的Words。
但是,另一方面,我们现在可以摆脱JOIN表的需求。对于大型表,这可以大大加快读写处理速度。它还消除了分片的问题,因为现在表可以根据方便的任何分片键进行分区,并将包含所有信息。
反规范化是 NoSQL 数据库中常见的选择,这些数据库移除了执行JOIN查询的能力。例如,文档数据库将数据作为子字段嵌入到更大的实体中。虽然它确实有其缺点,但在某些操作中,这种权衡是有意义的。
数据索引
随着数据的增长,数据的访问开始变慢。从充满信息的大表中检索确切的数据需要执行更多内部操作来定位它。
尽管我们将描述与关系数据库相关的数据索引,但大多数基本原理也适用于其他数据库。
通过以易于搜索的方式智能地组织数据,可以大大加快这个过程。这导致创建索引,通过搜索它们可以非常快速地定位数据。索引的基本原理是创建一个指向数据库中每条记录的一个或多个字段的外部排序数据结构。这个索引结构始终保持排序状态,因为表中的数据发生变化。
例如,一个简短的表可能包含以下信息
| id | Name | Height (cm) |
|---|---|---|
| 1 | Eddard | 178 |
| 2 | John | 173 |
| 3 | Daenerys | 157 |
| 4 | Jaime | 188 |
在没有索引的情况下,要查询具有最高身高的条目,数据库需要逐行检查并排序。这被称为全表扫描。如果表有数百万行,全表扫描可能会非常昂贵。
通过为Height字段创建索引,一个始终排序的数据结构会与数据保持同步。
| id | Name | Height (cm) | Height (cm) | id | |
|---|---|---|---|---|---|
| 1 | Eddard | 178 | 188 | 4 | |
| 2 | John | 173 | 178 | 1 | |
| 3 | Daenerys | 157 | 173 | 5 | |
| 4 | Jaime | 188 | 157 | 3 |
因为它总是排序的,所以任何与身高相关的查询都很容易完成。例如,获取最高的三个身高不需要任何检查,只需从索引中检索前三条记录,并且使用排序列表中的搜索方法,如二分搜索,来确定在 180 到 170 厘米之间的身高也很容易。再次强调,如果这个索引不存在,唯一找到这些查询的方法就是检查表中的每一条记录。
注意,索引并不涵盖所有字段。例如,Name字段没有索引。可能还需要其他索引来涵盖其他字段。相同的表可以接受多个索引。
表的主键总是索引的,因为它需要是一个唯一值。
索引可以组合,为两个或更多字段创建索引。这些组合索引根据两个字段的有序组合对数据进行排序,例如,一个(Name, Height)的组合索引将快速返回以J开头的Names的身高。一个(Height, Name)的组合索引将做相反的操作,首先对身高进行排序,然后对Name字段进行排序。
在组合索引中查询索引的第一部分是可能的。在我们的例子中,(Height, Name)的索引将始终用于查询Height。
使用或不使用索引来检索信息是由数据库自动完成的;SQL 查询本身没有任何变化。内部,数据库在运行查询之前会运行查询分析器。这部分数据库软件将确定如何检索数据,以及是否使用索引。
查询分析器需要快速运行,因为确定最佳搜索信息的方法可能比运行简单方法并返回数据花费的时间更长。这意味着有时它可能会出错,不会使用最佳组合。在另一个 SQL 语句之前使用的 SQL 命令EXPLAIN将显示查询将被如何解释和运行,这允许你理解和调整它以改善其执行时间。
请记住,在同一个查询中使用不同的独立索引可能不可行。有时数据库无法通过结合两个索引来执行更快的查询,因为数据需要在它们之间进行关联,这可能会是一项昂贵的操作。
索引可以极大地加快使用它们的查询速度,特别是对于有数千或数百万行的大表。它们也是自动使用的,因此不会给查询生成增加额外的复杂性。所以,如果它们如此之好,为什么不索引一切呢?嗯,索引也有一些问题:
-
每个索引都需要额外的空间。虽然这是优化的,但在单个表中添加大量索引将占用更多的空间,包括硬盘和 RAM。
-
每次表发生变化时,都需要调整表中的所有索引,以确保索引被正确排序。这在写入新数据时更为明显,例如添加记录或更新索引字段。索引是花费更多时间在写入上以加快读取速度的权衡。对于写入密集型的表,这种权衡可能不足,维护一个或多个索引可能适得其反。
-
小表实际上并不真正受益于索引。如果行数低于千行,全表扫描和索引搜索之间的差异很小。
按照经验法则,最好在检测到需求后尝试创建索引。一旦发现查询缓慢,分析索引是否可以改善情况,然后再创建它。
基数
每个索引的有用性的一个重要特征是其基数。这是索引包含的不同值的数量。
例如,此表中的Height索引具有 4 个基数。有四个不同的值。
| id | 身高 (cm) |
|---|---|
| 1 | 178 |
| 2 | 165 |
| 3 | 167 |
| 4 | 192 |
这样的表只有一个 2 的基数。
| id | 身高 (cm) |
|---|---|
| 1 | 178 |
| 2 | 165 |
| 3 | 178 |
| 4 | 165 |
卡尔达数低的索引质量较低,因为它无法像预期的那样加快搜索速度。可以将索引理解为一种过滤器,允许您减少要搜索的行数。如果在应用过滤器后,表中的行数没有显著减少,那么索引将没有用。让我们用一个极端的例子来描述它。
想象一个有百万行记录的表,这些记录通过一个字段索引,而这个字段在所有记录中都是相同的。现在想象一下,如果我们对一个未索引的不同字段进行查询以找到单个行,如果我们使用索引,我们将无法加快这个过程,因为索引将返回数据库中的每一行。

图 3.11:使用无用的索引从查询中返回每一行
现在想象有两个值的情况。首先返回表的一半行,然后我们需要查询它们。这比之前好,但与全表扫描相比,使用索引有一些开销,所以在实际应用中,这并不很有优势。

图 3.12:使用两个值的索引返回行
随着索引的卡尔达数增加,即添加越来越多的值,索引就越有用。

图 3.13:使用四个值的索引返回行
随着卡尔达数的提高,数据库能够更好地区分,并指向更小的值子集,这极大地加快了对正确数据的访问。
作为经验法则,确保索引的卡尔达数始终为 10 或更高。低于这个值可能不足以用作索引。查询分析器将考虑卡尔达数值,以确定是否使用索引。
请记住,只允许少量值的字段(如布尔值和枚举)的卡尔达数总是有限的,这使得它们不适合作为索引,至少单独使用时是这样。另一方面,唯一的值总是具有最高的可能卡尔达数,它们是索引的良好候选者。由于这个原因,主键总是自动索引。
摘要
在本章中,我们描述了处理存储层的方法和技术,既从数据库本身提供的不同容量和选项的角度,也从我们的应用程序代码如何交互以存储和检索信息的角度进行了描述。
我们描述了不同类型的数据库,包括关系型和非关系型,以及每种数据库的区别和用途,以及事务的概念,这是关系型数据库的基本特性之一,它如何允许符合 ACID 属性。由于一些非关系型数据库旨在处理大规模数据并且是分布式的,我们介绍了提高关系型系统扩展性的技术,因为这种类型的数据库最初并没有设计用来处理多个服务器。
我们继续描述了如何设计模式以及数据规范化与反规范化的优缺点。我们还解释了为什么我们需要索引字段以及何时这样做是适得其反的。
在第四章,数据层中,我们将了解如何设计数据层。
第四章:数据层
与应用程序代码交互时的数据建模与数据在存储中的存储方式一样重要。数据层是开发者将与之交互最多的层,因此创建一个良好的接口对于创建一个高效的环境至关重要。
在本章中,我们将描述如何创建一个与存储交互的软件数据层,以抽象存储数据的细节。我们将了解领域驱动设计是什么,如何使用对象关系映射(Object-Relational Mapping,ORM)框架,以及更高级的模式,如命令查询责任分离(Command Query Responsibility Segregation,CQRS)。
我们还将讨论如何随着应用程序的发展对数据库进行更改,最后,我们将介绍在结构在我们介入之前已经定义的情况下处理遗留数据库的技术。
在本章中,我们将探讨以下主题:
-
模型层
-
数据库迁移
-
处理遗留数据库
让我们从模型-视图-控制器(Model-View-Controller,MVC)模式中的模型部分的数据设计背景开始。
模型层
正如我们在第二章中介绍的模型-视图-控制器架构和API 设计时所见,模型层是与数据紧密相关,负责存储和检索数据的部分。
模型抽象了所有数据处理。这不仅包括数据库访问,还包括相关的业务逻辑。这创建了一个双层结构:
-
内部数据建模层,负责从数据库中存储和检索数据。这一层需要了解数据在数据库中的存储方式,并相应地处理它。
-
下一层创建业务逻辑,并使用内部数据建模层来支持它。这一层负责确保要存储的数据是一致的,并强制执行任何关系或约束。
将数据层视为数据库设计的纯粹扩展,移除业务层或将它作为代码存储在控制器部分是很常见的。虽然这样做是可行的,但最好考虑是否明确添加业务层,并确保实体模型与数据库模型之间有明确的分离,这符合良好的业务逻辑,并且数据库模型包含如何访问数据库的详细信息。
领域驱动设计
这种操作方式已成为领域驱动设计(Domain-Driven Design,DDD)的一部分。当 DDD 首次引入时,其主要目标是弥合特定应用与其实现技术之间的差距,试图使用适当的命名法并确保代码与用户将使用的实际操作保持同步。例如,银行软件将使用存入和取出资金的函数,而不是从账户中增加或减少。
DDD 不仅要求命名方法和属性与领域专业术语保持一致,还要求复制其用途和流程。
当与面向对象编程(OOP)结合时,DDD 技术将复制特定领域所需的概念为对象。在我们的上一个例子中,我们会有一个 Account 对象,它接受 lodge() 和 withdraw() 方法。这些方法可能接受一个 Transfer 对象,以保持资金的来源账户的正确余额。
这些天,DDD 被理解为在模型层创建面向业务接口的过程,这样我们就可以抽象出如何将其映射到数据库访问的内部细节,并呈现一个复制业务流程的一致接口。
DDD 需要深入了解特定领域,以创建一个有意义的接口并正确地模拟业务操作。它需要与业务专家进行密切的沟通和协作,以确保覆盖所有可能的差距。
对于许多不同的概念,模型纯粹是作为数据库模式的复制来工作的。这样,如果有表,它会被翻译成一个访问该表的模型,复制字段等。一个例子是将用户存储在具有用户名、全名、订阅和密码字段的表中。
但请记住,这并不是一个硬性要求。模型可以使用多个表或以更有业务意义的方式组合多个字段,甚至不暴露某些字段,因为它们应该保持内部。
我们将使用 SQL 作为我们的默认示例,因为我们默认使用的关系数据库是最常见的一种数据库。但我们讨论的一切都高度适用于其他类型的数据库,特别是文档型数据库。
例如,上述用户的示例在数据库中作为 SQL 表的列具有以下字段:
| 字段 | 类型 | 描述 |
|---|---|---|
用户名 |
String |
唯一用户名 |
密码 |
String |
描述散列密码的字符串 |
全名 |
String |
用户姓名 |
订阅结束 |
Datetime |
订阅结束的时间 |
订阅类型 |
Enum (Normal, Premium, NotSubscribed) |
订阅类型 |
但模型可能暴露以下内容:
| 属性/方法 | 类型 | 描述 |
|---|---|---|
username |
字符串属性 | 直接翻译用户名列 |
全名 |
字符串属性 | 直接翻译全名列 |
subscription |
只读属性 | 返回订阅类型列。如果订阅已结束(如订阅结束列所示),则返回 NotSubscribed |
check_password(password) |
方法 | 内部检查输入的 password 是否有效,通过与散列密码列进行比较,并返回是否正确 |
注意,这隐藏了密码本身,因为其内部细节对数据库外部不相关。它还隐藏了内部订阅字段,而是呈现一个执行所有相关检查的单个属性。
此模型将原始数据库访问的操作转换为完全定义的对象,该对象抽象了数据库的访问。将对象映射到表或集合的操作方式被称为对象关系映射(ORM)。
使用 ORM
如上所述,本质上,ORM 是在数据库中的集合或表之间执行映射,并在面向对象环境中生成对象。
虽然 ORM 本身指的是技术,但通常理解的是作为一个工具。有多种 ORM 工具可供选择,可以将 SQL 表转换为 Python 对象。这意味着,我们不会编写 SQL 语句,而是设置在类和对象中定义的属性,然后由 ORM 工具自动转换,并连接到数据库。
例如,对“pens”表中的查询的低级访问可能看起来像这样:
>>> cur = con.cursor()
>>> cur.execute('''CREATE TABLE pens (id INTEGER PRIMARY KEY DESC, name, color)''')
<sqlite3.Cursor object at 0x10c484c70>
>>> con.commit()
>>> cur.execute('''INSERT INTO pens VALUES (1, 'Waldorf', 'blue')''')
<sqlite3.Cursor object at 0x10c484c70>
>>> con.commit()
>>> cur.execute('SELECT * FROM pens');
<sqlite3.Cursor object at 0x10c484c70>
>>> cur.fetchall()
[(1, 'Waldorf', 'blue')]
注意,我们正在使用 DB-API 2.0 标准的 Python 接口,它抽象了不同数据库之间的差异,并允许我们使用标准的fetchall()方法检索信息。
要连接 Python 和 SQL 数据库,最常用的 ORM 是 Django 框架中包含的 ORM(www.djangoproject.com/)和 SQLAlchemy(www.sqlalchemy.org/)。还有其他较少使用的选项,如 Pony(ponyorm.org/)或 Peewee(github.com/coleifer/peewee),它们旨在采用更简单的方法。
使用 ORM,例如 Django 框架中可用的 ORM,而不是创建CREATE TABLE语句,我们在代码中将表描述为一个类:
from django.db import models
class Pens(models.Model):
name = models.CharField(max_length=140)
color = models.CharField(max_length=30)
此类允许我们使用类检索和添加元素。
>>> new_pen = Pens(name='Waldorf', color='blue')
>>> new_pen.save()
>>> all_pens = Pens.objects.all()
>>> all_pens[0].name
'Waldorf'
在原始 SQL 中是INSERT操作的操作是创建一个新的对象,然后使用.save()方法将数据持久化到数据库中。同样,而不是编写一个SELECT查询,可以调用搜索 API。例如,以下代码:
>>> red_pens = Pens.objects.filter(color='red')
等同于以下代码:
SELECT * FROM Pens WHERE color = 'red;
与直接编写 SQL 相比,使用 ORM 有一些优势:
-
使用 ORM 将数据库从代码中分离
-
它消除了使用 SQL(或学习它)的需要
-
它消除了编写 SQL 查询时的一些问题,如安全问题
让我们更详细地看看这些优势及其局限性。
独立于数据库
首先,使用 ORM 将数据库的使用与代码分离。这意味着可以更改特定的数据库,而代码保持不变。有时这可以在不同的环境中运行代码或快速切换到使用不同的数据库很有用。
这种非常常见的用例是在 SQLite 中运行测试,一旦代码在生产环境中部署,就使用 MySQL 或 PostgreSQL 等其他数据库。
这种方法并非没有问题,因为某些选项可能在一种数据库中可用,而在另一种数据库中不可用。这可能是一种适用于新项目的可行策略,但最好的方法是使用相同的技术进行测试和生产,以避免意外的兼容性问题。
独立于 SQL 和仓储模式
另一个优势是,你不需要学习 SQL(或数据库后端使用的任何语言)来处理数据。相反,ORM 使用自己的 API,这可能很直观,更接近面向对象。这可以降低使用代码的门槛,因为不熟悉 SQL 的开发者可以更快地理解 ORM 代码。
使用类来抽象对持久层的访问,从数据库使用中分离出来,称为 仓储模式。使用 ORM 将自动利用此模式,因为它将使用程序性操作,而不需要了解数据库的任何内部知识。
这个优势也有其对立面,即某些操作的转换可能会很笨拙,并生成效率非常低的 SQL 语句。这尤其适用于需要 JOIN 多个表的复杂查询。
这的典型例子是以下示例代码。Books 对象有一个指向其作者的不同表的引用,该引用存储为外键引用。
for book in Books.objects.find(publisher='packt'):
author = book.author
do_something(author)
这段代码被解释如下:
Produce a query to retrieve all the books from publisher 'packt'
For each book, make a query to retrieve the author
Perform the action with the author
当书籍数量很多时,所有这些额外的查询可能会非常昂贵。我们真正想要做的是
Produce a query to retrieve all the books from publisher 'packt', joining with their authors
For each book, perform the action with the author
这样,只生成一个查询,这比第一种情况要高效得多。
这个连接必须手动指示给 API,方式如下。
for book in Books.objects.find(publisher='packt').select_related('author'):
author = book.author
do_something(author)
需要添加额外信息的必要性实际上是抽象泄露的一个好例子,正如在 第二章 中讨论的那样。你仍然需要了解数据库的细节,才能编写高效的代码。
对于 ORM 框架来说,在易于使用和有时需要了解底层实现细节之间取得平衡,这是一个需要定义的平衡。框架本身将根据所使用的特定 SQL 语句如何通过方便的 API 抽象来采取或多或少灵活的方法。
与编写 SQL 相关的没有问题
即使开发者知道如何处理 SQL,但在使用它时仍然存在许多陷阱。一个相当重要的优势是使用 ORM 可以避免直接操作 SQL 语句时的一些问题。当直接编写 SQL 时,最终会变成纯字符串操作来生成所需的查询。这可能会产生很多问题。
最明显的是需要正确编写 SQL 语句,而不是生成语法上无效的 SQL 语句。例如,考虑以下代码:
>>> color_list = ','.join(colors)
>>> query = 'SELECT * FROM Pens WHERE color IN (' + color_list + ')'
这段代码适用于包含值的 colors 值,但如果 colors 为空则会出错。
更糟糕的是,如果查询是直接使用输入参数组成的,它可能会产生安全问题。有一种叫做SQL 注入攻击的攻击,正是针对这种行为。
例如,假设上述查询是在用户调用一个可以通过不同颜色进行筛选的搜索时产生的。用户直接被要求提供颜色。一个恶意用户可能会请求颜色 'red'; DROP TABLE users;。这将利用查询被作为纯字符串组成的事实,生成一个包含隐藏的、非预期的操作的恶意字符串。
为了避免这个问题,任何可能用作 SQL 查询(或任何其他语言)一部分的输入都需要被清理。这意味着删除或转义可能影响预期查询行为的字符。
转义字符意味着它们被正确编码,以便被理解为一个普通字符串,而不是语法的一部分。例如,在 Python 中,要将字符 " 转义以包含在字符串中而不是结束字符串定义,它需要前面有一个 \ 字符。当然,如果需要在字符串中使用 \ 字符,它也需要被转义,在这种情况下需要将其加倍,使用 \\。
例如:
"这个字符串包含双引号字符 " 和反斜杠字符 \。"
虽然有特定的技术可以手动编写 SQL 语句和清理输入,但任何 ORM 都会自动清理它们,从而大大降低 SQL 注入的风险。这在安全性方面是一个巨大的胜利,可能是 ORM 框架最大的优势。手动编写 SQL 语句通常被认为是一个坏主意,而是依赖于间接的方式,这样可以保证任何输入都是安全的。
相反,即使对 ORM API 有很好的理解,对于某些查询或结果,元素读取的方式也有其局限性,这可能会导致使用 ORM 框架的操作比创建定制的 SQL 查询要复杂得多或效率低下。
这种情况通常发生在创建复杂的连接时。从 ORM 生成的查询对于简单的查询来说很好,但当存在太多关系时,它可能会使查询过于复杂,从而难以创建查询。
ORM 框架也会在性能方面产生影响,因为它们需要时间来编写正确的 SQL 查询,编码和解码数据,以及进行其他检查。虽然对于大多数查询来说,这段时间可以忽略不计,但对于特定的查询,可能会大大增加检索数据所需的时间。不幸的是,有很大可能性,在某个时候,需要为某些操作创建特定的、定制的 SQL 查询。在处理 ORM 框架时,总是在便利性和能够为手头的任务创建正确的查询之间保持平衡。
ORM 框架的另一个限制是,SQL 访问可能允许 ORM 界面中不可能的操作。这可能是特定插件或特定于所使用数据库的独特功能的结果。
如果使用 SQL 是可行的方式,一个常见的方法是使用预处理语句,这些语句是不可变的查询,带有参数,因此它们是作为 DB API 执行的一部分被替换的。例如,以下代码将以类似print语句的方式工作。
db.execute('SELECT * FROM Pens WHERE color={color}', color=color_input)
此代码将安全地用正确的输入替换颜色,以安全的方式编码。如果有一个需要替换的元素列表,这可以在两个步骤中完成:首先,准备适当的模板,每个输入一个参数,然后进行替换。例如:
# Input list
>>> color_list = ['red', 'green', 'blue']
# Create a dictionary with a unique name per parameter (color_X) and the value
>>> parameters = {f'color_{index}': value for index, value in enumerate(color_list)}
>>> parameters
{'color_0': 'red', 'color_1': 'green', 'color_2': 'blue'}
# Create a clausule with the name of the parameters to be replaced
# by string substitution
# Note that {{ will be replaced by a single {
>>> query_params = ','.join(f'{{{param}}}' for param in parameters.keys())
>>> query_params
'{color_0},{color_1},{color_2}'
# Compose the full query, replacing the prepared string
>>> query = f'SELECT * FROM Pens WHERE color IN ({query_params})'
>>> query
'SELECT * FROM Pens WHERE color IN ({color_0},{color_1},{color_2})'
# To execute, using ** on front of a dictionary will put all its keys as
# input parameters
>>> query.format(**parameters)
'SELECT * FROM Pens WHERE color IN (red,green,blue)'
# Execute the query in a similar way, it will handle all
# required encoding and escaping from the string input
>>> db.execute(query, **query_params)
在我们的示例中,我们使用了一个SELECT *语句,为了简单起见,它将返回表中的所有列,但这不是正确处理它们的方式,应该避免使用。问题是返回所有列可能不稳定。
可以向表中添加新列,因此检索所有列可能会改变检索到的数据,增加产生格式错误的机会。例如:
>>> cur.execute('SELECT * FROM pens');
<sqlite3.Cursor object at 0x10e640810>
# This returns a row
>>> cur.fetchone()
(1, 'Waldorf', 'blue')
>>> cur.execute('ALTER TABLE pens ADD brand')
<sqlite3.Cursor object at 0x10e640810>
>>> cur.execute('SELECT * FROM pens');
<sqlite3.Cursor object at 0x10e640810>
# This is the same row as above, but now it returns an extra element
>>> cur.fetchone()
(1, 'Waldorf', 'blue', None)
ORM 会自动处理这种情况,但使用原始 SQL 需要你考虑到这种效果,并在更改模式时始终明确包含要检索的列,以避免问题。
>>> cur.execute('SELECT name, color FROM pens');
<sqlite3.Cursor object at 0x10e640810>
>>> cur.fetchone()
('Waldorf', 'blue')
处理存储数据时,向后兼容性至关重要。我们将在本章后面更多地讨论这一点。
通过组合生成的程序化查询被称为动态查询。虽然默认策略应该是避免使用它们,而偏好使用预处理语句,但在某些情况下,动态查询仍然非常有用。除非涉及动态查询,否则可能无法产生某种程度的定制。
动态查询的具体定义可能取决于环境。在某些情况下,任何不是存储查询(事先存储在数据库中并通过某些参数调用的查询)的查询都可能被认为是动态的。从我们的角度来看,我们将把需要字符串操作来生成查询的任何查询视为动态查询。
即使选择的数据库访问方式是原始 SQL 语句,创建一个处理所有访问特定细节的抽象层也是好的。这个层应该负责以适当格式在数据库中存储数据,而不涉及业务逻辑。
ORM 框架通常会对此有所抵触,因为它们能够处理大量的复杂性,并会邀请你为每个定义的对象添加业务逻辑。当业务概念与数据库表之间的转换是直接的时候,例如用户对象,这是可以的。但确实可以在存储和有意义的业务对象之间创建一个额外的中间层。
工作单元模式和数据封装
正如我们之前看到的,ORM 框架直接在数据库表和对象之间进行转换。这以数据库中存储的方式创建了对数据的表示。
在大多数情况下,数据库的设计将与我们在 DDD 哲学中引入的业务实体紧密相关。但这个设计可能需要额外的一步,因为某些实体可能与其在数据库内部的数据表示分离。
将表示独特实体的动作的方法创建称为 工作单元模式(Unit of Work pattern)。这意味着在这个高级动作中发生的所有事情都作为一个单一单元执行,即使内部实现可能涉及多个数据库操作。对于调用者来说,操作是原子性的。
如果数据库允许,一个工作单元中的所有操作都应该在一个事务中完成,以确保整个操作一次性完成。工作单元(Unit of Work)这个名称与事务和关系数据库紧密相关,通常不用于无法创建事务的数据库,尽管从概念上讲,这种模式仍然可以使用。
例如,我们之前看到了一个接受 .lodge() 和 .withdraw() 方法的 Account 类的例子。虽然我们可以直接实现一个包含表示资金的整数的 Account 表,但我们也可以自动创建一个双入账责任系统,该系统会跟踪系统中的任何变动。
Account 可以被称为 领域模型(Domain Model),以表明它与数据库表示独立。
要做到这一点,每个 Account 应该有相应的 debit 和 credit 内部值。如果我们还在不同的表中添加一个额外的 Log 记录,以跟踪变动,它可能被实现为三个不同的类。Account 类将用于封装日志,而 InternalAccount 和 Log 将对应于数据库中的表。请注意,单个 .lodge() 或 .withdraw() 调用将生成对数据库的多次访问,正如我们稍后将会看到的。

图 4.1:Account 类的设计
代码可能如下所示:
class InternalAccount(models.Model):
''' This is the model related to a DB table '''
account_number = models.IntegerField(unique=True)
initial_amount = models.IntegerField(default=0)
amount = models.IntegerField(default=0)
class Log(models.Model):
''' This models stores the operations '''
source = models.ForeignKey('InternalAccount',
related_name='debit')
destination = models.ForeignKey('InternalAccount',
related_name='credit')
amount = models.IntegerField()
timestamp = models.DateTimeField(auto_now=True)
def commit():
''' this produces the operation '''
with transaction.atomic():
# Update the amounts
self.source.amount -= self.amount
self.destination.amount += self.amount
# save everything
self.source.save()
self.destination.save()
self.save()
class Account(object):
''' This is the exposed object that handled the operations '''
def __init__(self, account_number, amount=0):
# Retrieve or create the account
self.internal, _ = InternalAccount.objects.get_or_create(
account_number=account_number,
initial_amount=amount,
amount=amount)
@property
def amount(self):
return self.internal.amount
def lodge(source_account, amount):
'''
This operation adds funds from the source
'''
log = Log(source=source_account, destination=self,
amount=amount)
log.commit()
def withdraw(dest_account, amount):
'''
This operation transfer funds to the destination
'''
log = Log(source=self, destination=dest_account,
amount=amount)
log.commit()
Account 类是预期的接口。它不直接与数据库中的任何内容相关,但通过 account_number 的唯一引用与 InternalAccount 保持关系。
存储不同元素的逻辑在不同于 ORM 模型的不同类中呈现。这可以通过 ORM 模型类是 仓库(Repositories) 类,而 Account 模型是 工作单元(Unit of Work) 类来理解。
在某些手册中,他们使用工作单元类,但缺乏上下文,仅仅作为一个执行存储多个元素的容器。然而,给Account类赋予一个清晰的概念以提供上下文和意义更有用。并且可能存在几个适合业务实体的适当操作。
每当有操作时,都需要另一个账户,然后创建一个新的Log。Log引用了资金来源、目的地和金额,并在单个事务中执行操作。这是在commit方法中完成的。
def commit():
''' this produces the operation '''
with transaction.atomic():
# Update the amounts
self.source.amount -= self.amount
self.destination.amount += self.amount
# save everything
self.source.save()
self.destination.save()
self.save()
在单个事务中,通过使用with transaction.atomic()上下文管理器,它从账户中增加和减少资金,然后保存三个相关行,即源账户、目标账户和日志本身。
Django ORM 要求你设置这个原子装饰器,但其他 ORM 可能工作方式不同。例如,SQLAlchemy 倾向于通过向队列中添加操作来工作,并要求你显式地在批量操作中应用所有这些操作。请检查你使用的特定软件的文档以了解每种情况。
由于简单性而遗漏的细节是验证是否有足够的资金执行操作。在资金不足的情况下,应产生一个异常来终止事务。
注意这种格式如何允许每个InternalAccount检索与交易相关的所有Log,包括借方和贷方。这意味着可以检查当前金额是否正确。此代码将根据日志计算账户中的金额,这可以用来检查金额是否正确。
class InternalAccount(models.Model):
...
def recalculate(self):
'''
Recalculate the amount, based on the logs
'''
total_credit = sum(log.amount for log in self.credit.all())
total_debit = sum(log.amount for log in self.debit.all())
return self.initial_amount + total_credit - total_debit
需要初始金额。debit和credit字段是Log类的反向引用,正如在Log类中定义的那样。
从仅对操作Account对象感兴趣的用户的角度来看,所有这些细节都是无关紧要的。这一额外的层允许我们干净地抽象数据库实现,并将任何相关的业务逻辑存储在那里。这可以是公开的业务模型层(领域模型的一部分),它使用适当的逻辑和命名法处理相关的业务操作。
CQRS,使用不同的模型进行读取和写入
有时,数据库的简单 CRUD 模型并不能描述系统中的数据流。在某些复杂设置中,可能需要使用不同的方式来读取数据,以及写入或与数据交互。
一种可能性是发送数据和读取数据发生在管道的不同端。例如,这在事件驱动系统中发生,其中数据记录在队列中,然后稍后处理。在大多数情况下,这些数据在不同的数据库中处理或汇总。
让我们看看一个更具体的例子。我们存储不同产品的销售额。这些销售额包含 SKU(销售产品的唯一标识符)和价格。但在销售时,我们不知道销售利润是多少,因为产品的购买取决于市场的波动。销售存储到队列中,以启动与支付价格进行对账的过程。最后,关系数据库存储最终的销售额条目,包括购买价格和利润。
信息流从领域模型到队列,然后通过某些外部过程到关系数据库,在那里它以 ORM 方式表示为关系模型,然后返回到领域模型。
这种结构被称为命令查询责任分离(CQRS),意味着命令(写操作)和查询(读操作)是分离的。这种模式并非仅限于事件驱动结构;它们通常出现在这些系统中,因为它们的本质是使输入数据与输出数据分离。
领域模型可能需要不同的方法来处理信息。输入和输出数据有不同的内部表示,有时可能更容易清楚地区分它们。无论如何,使用显式的领域模型层来对 CQRS 进行分组并将它作为一个整体处理都是一个好主意。在某些情况下,读和写模型和数据可能相当不同。例如,如果有一个生成聚合结果的步骤,那么它可能会在读取部分创建额外的数据,而这些数据永远不会被写入。
在我们的示例中,读取和写入部分连接的过程超出了范围。在我们的示例中,这个过程将是数据如何在数据库中存储,包括支付的金额。
以下图表描述了 CQRS 结构中的信息流:

图 4.2:CQRS 结构中的信息流
我们模型定义可能如下所示:
Class SaleModel(models.Model):
''' This is the usual ORM model '''
Sale_id = models.IntegerField(unique=True)
sku = models.IntegerField()
amount = models.IntegerField()
price = models.IntegerField()
class Sale(object):
'''
This is the exposed Domain Model that handled the operations
In a domain meaningful way, without exposing internal info
'''
def __init__(self, sale_id, sku, amount):
self.sale_id = sale_id
self.sku = sku
self.amount = amount
# These elements are won't be filled when creating a new element
self._price = None
self._profit = None
@property
def price(self):
if self._price is None:
raise Exception('No price yet for this sale')
return self._price
@property
def profit(self):
if self._profit is None:
raise Exception('No price yet for this sale')
return self._profit
def save(self):
# This sends the sale to the queue
event = {
'sale_id': self.sale_id,
'sku': self.sku,
'amount': self.amount,
}
# This sends the event to the external queue
Queue.send(event)
@classmethod
def get(cls, sale_id):
# if the sale is still not available it will raise an
# Exception
sale = SaleModel.objects.get(sale_id=sale_id)
full_sale = Sale(sale_id=sale_id, sku=sale.sku,
amount=sale.amount)
# fill the private attributes
full_sale._price = sale.price
full_sale._profit = sale.amount - full_sale._price
return full_sale
注意保存和检索时的流程是不同的:
# Create a new sale
sale = Sale(sale_id=sale_id, sku=sale.sku, amount=sale.amount)
sale.save()
# Wait some time until totally processed
full_sale = Sale.get(sale_id=sale_id)
# retrieve the profit
full_sale.profit
CQRS 系统是复杂的,因为输入数据和输出数据是不同的。它们通常在检索信息时会有一些延迟,这可能会不方便。
CQRS 系统中的另一个重要问题是不同部分需要保持同步。这包括读模型和写模型,以及管道中发生的任何转换。随着时间的推移,这会创建维护需求,尤其是在需要保持向后兼容性时。
所有这些问题都使得 CQRS 系统变得复杂。只有在绝对必要时才应谨慎使用。
数据库迁移
开发中不可避免的事实是软件系统总是在变化。虽然数据库变化的步伐通常不如其他领域快,但仍然有变化,并且需要谨慎处理。
数据更改大致分为两种不同类型:
-
格式或模式更改:需要添加或删除的新元素,如字段或表格;或者某些字段格式的更改。
-
数据更改:需要更改数据本身,而不修改格式。例如,将包括邮政编码的地址字段进行规范化,或将字符串字段转换为大写。
向后兼容性
与数据库更改相关的基本原则是向后兼容性。这意味着数据库中的任何单个更改都需要在没有代码更改的情况下工作而不需要任何更改。
这允许你在不中断服务的情况下进行更改。如果数据库中的更改需要更改代码来理解它,则必须中断服务。这是因为你不能同时应用这两个更改,如果有多个服务器正在执行代码,则不能同时应用。
当然,还有一个选项,即停止服务,执行所有更改,然后重新启动。虽然这不是最佳选择,但对于小型服务或如果可以接受计划内的停机时间,这可能是一个选择。
根据数据库的不同,数据更改有不同的方法。
对于关系型数据库,由于它们需要定义固定的结构,任何模式更改都需要作为一个单一操作应用到整个数据库。
对于不强制定义模式的数据库,有方法以更迭代的方式更新数据库。
让我们来看看不同的方法。
关系型模式更改
在关系型数据库中,每个单独的模式更改都作为一个操作类似事务的 SQL 语句来应用。模式更改,称为迁移,可以在某些数据转换(例如,将整数转换为字符串)发生的同时发生。
迁移是执行更改的原子性 SQL 命令。它们可以涉及更改数据库中表格式,也可以涉及更多操作,如更改数据或一次进行多个更改。这可以通过创建一个将所有这些更改组合在一起的单一事务来实现。大多数 ORM 框架都包括创建迁移和执行这些操作的原生支持。
例如,Django 将通过运行命令 makemigrations 自动创建迁移文件。这个命令需要手动运行,但它将检测模型中的任何更改并做出适当的更改。
例如,如果我们向之前引入的类中添加一个额外的值 branch_id
class InternalAccount(models.Model):
''' This is the model related to a DB table '''
account_number = models.IntegerField(unique=True)
initial_amount = models.IntegerField(default=0)
amount = models.IntegerField(default=0)
branch_id = models.IntegerField()
运行命令 makemigrations 将生成描述迁移的适当文件。
$ python3 manage.py makemigrations
Migrations for 'example':
example/migrations/0002_auto_20210501_1843.py
- Add field branch_id to internalaccount
注意,Django 会跟踪模型中的状态,并自动调整更改以创建适当的迁移文件。可以通过命令 migrate 自动应用挂起的迁移。
$ python3 manage.py migrate
Operations to perform:
Apply all migrations: admin, auth, contenttypes, example, sessions
Running migrations:
Applying example.0002_auto_20210501_1843... OK
Django 将存储已应用迁移的状态,以确保每个迁移都恰好应用一次。
请记住,为了通过 Django 正确使用迁移,不应在此方法之外进行任何更改,因为这可能会造成混淆并产生冲突。如果您需要应用无法通过模型更改自动复制的更改,如数据迁移,您可以创建一个空迁移,并用您的自定义 SQL 语句填充它。这可以创建复杂、定制的迁移,但它们将与自动创建的其余 Django 迁移一起应用并保持跟踪。模型也可以明确标记为 Django 不处理,以便手动管理。
有关 Django 迁移的更多详细信息,请查看docs.djangoproject.com/en/3.2/topics/migrations/文档。
无中断更改数据库
迁移数据的流程需要按照以下顺序进行:
-
旧代码和旧数据库架构已经就绪。这是起点。
-
数据库应用了一个与旧代码向后兼容的迁移。由于数据库可以在运行时应用此更改,因此服务不会被中断。
-
利用新架构的新代码已部署。这次部署不会需要任何特殊的停机时间,并且可以在不中断进程的情况下执行。
这个流程的关键步骤是第 2 步,以确保迁移与之前的代码向后兼容。
大多数常规更改相对简单,比如向表中添加一个新的表格或列,你不会遇到任何问题。旧代码不会使用该列或表格,这完全没问题。但其他迁移可能更复杂。
例如,让我们假设一个字段Field1之前一直是整数,现在需要将其转换为字符串。将存储数字,但也有一些数据库不支持的特殊值,如NaN或Inf。新代码将解码它们并正确处理。
但显然,如果旧代码没有考虑到这一点,将代码从整数迁移到字符串的更改将产生错误。
为了解决这个问题,需要将其视为一系列步骤:
-
旧代码和旧数据库架构已经就绪。这是起点。
-
数据库应用了一个添加新列
Field2的迁移。在这个迁移中,Field1的值被转换为字符串并复制。 -
部署了一个新版本的代码,中间代码。这个版本理解可能有一个(
Field2)或两个列(Field1和Field2)。它使用Field2中的值,而不是Field1中的值,但如果进行写入,则应覆盖两者。为了避免在迁移应用和新代码之间可能出现的更新问题,代码需要检查列
Field1是否存在,如果存在并且其值与Field2不同,则在执行任何操作之前更新后者。 -
可以应用一个新的迁移来删除现在不再使用的
Field1。在相同的迁移中,应应用上述相同的注意事项——如果
Field1中的值与Field2中的值不同,则用Field1覆盖它。注意,这种情况可能发生的唯一情况是如果它已经被旧代码更新过。 -
新的代码,仅对
Field2有意识,现在可以安全部署。
根据是否Field2是一个可接受的名字,可能还需要部署进一步的迁移,将名字从Field2更改为Field1。在这种情况下,需要提前准备新代码以使用Field2,如果不存在,则使用Field1。
之后可以执行新的部署,再次仅使用Field1:

图 4.3:从 Field1 迁移到 Field2
如果这看起来像是一项大量工作,那么确实如此。所有这些步骤都是必需的,以确保平稳运行并实现零停机。另一种选择是停止旧代码,执行在Field1中格式更改的迁移,然后启动新代码。但这可能会引起几个问题。
最明显的是停机时间。虽然可以通过尝试设置适当的维护窗口来最小化影响,但大多数现代应用程序都期望 24x7 工作,任何停机都被视为问题。如果应用程序有全球受众,可能很难证明仅仅为了避免可避免的维护而停止是合理的。
停机时间也可能持续一段时间,这取决于迁移的一侧。一个常见的问题是,在比生产数据库小得多的数据库中测试迁移。这可能在生产中运行时产生意外问题,所需时间比预期的要长。根据数据的大小,复杂的迁移可能需要数小时才能完成。而且,由于它将作为事务的一部分运行,必须在继续之前完全完成,否则将回滚。
如果可能,尝试使用足够大的、具有代表性的测试数据库来测试系统的迁移。一些操作可能相当昂贵。可能有些迁移可能需要调整以加快运行速度,甚至可以分成更小的步骤,以便每个步骤都可以在自己的事务中运行,以在合理的时间内完成。在某些情况下,数据库可能需要更多的内存来允许在合理的时间内运行迁移。
但另一个问题是引入一个步骤的风险,这个步骤在新代码的开始处可能会出现问题,无论是与迁移相关还是无关。使用这个过程,在迁移应用后,就没有使用旧代码的可能性了。如果新代码中存在错误,需要修复并部署更新的版本。这可能会造成大麻烦。
虽然确实,由于迁移不可逆,应用迁移始终存在风险,但代码的稳定性有助于减轻问题。更改单个代码片段的风险低于更改两个而无法撤销任何一个的风险。
迁移可能是可逆的,因为可能存在执行反向操作的步骤。虽然这在理论上是正确的,但在实际操作中强制执行却极为困难。可能存在一种迁移,如删除列,实际上是不可逆的,因为数据会丢失。
因此,迁移需要非常小心地应用,并确保每一步都小而谨慎。
请记住,迁移如何与我们在分布式数据库相关技术中讨论的技术交互。例如,分片数据库需要独立地对每个分片应用每个迁移,这可能是一项耗时的操作。
数据迁移
数据迁移是数据库中的更改,不会改变格式,但会改变某些字段的值。
这些迁移通常是为了纠正数据中的某些错误,例如存储有编码错误的值的错误,或者将旧记录移动到更现代的格式。例如,如果地址中尚未包含,则包括邮政编码,或将测量的刻度从英寸更改为厘米。
在任何情况下,这些操作可能需要针对所有行或仅针对其中的一部分执行。如果可能的话,仅对相关子集应用这些操作可以大大加快处理速度,尤其是在处理大型数据库时。
在如上所述的刻度更改等情况下,可能需要更多步骤来确保代码可以处理两种刻度并区分它们。例如,通过一个额外字段来描述刻度。在这种情况下,过程将如下所示:
-
创建一个迁移来为所有行设置一个新的列,
scale,默认值为英寸。任何由旧代码引入的新行将自动使用默认值设置正确的值。 -
部署一个新版本的代码,能够处理英寸和厘米,读取
scale中的值。 -
设置另一个迁移来更改测量值。每一行将相应地更改
scale和measurement的值。将scale的默认值设置为厘米。 -
现在数据库中的所有值都在厘米。
-
可选地,通过部署一个不访问
scale字段且只理解厘米的新版本代码来清理,因为这两个刻度都不使用。之后,还可以运行一个新的迁移来删除该列。
第 5 步是可选的,通常没有强烈的清理需求,因为这并不是严格必要的,而保留额外列的灵活性可能值得保留以供未来使用。
正如我们之前讨论的,关键要素是部署能够同时处理旧的和新的数据库值,并理解它们的代码。这允许在值之间实现平稳过渡。
不强制执行模式的变化
非关系型数据库的一个灵活方面是通常没有强制执行的架构。相反,存储的文档接受不同的格式。
这意味着,与关系型数据库的全有或全无更改不同,更连续的更改和处理多个格式更受欢迎。
不同于应用迁移的概念,这里并不适用,代码将不得不随着时间的推移进行更改。在这种情况下,步骤如下:
-
旧代码和旧数据库架构已经就绪。这是起点。
-
数据库中的每个文档都有一个
version字段。 -
新的代码包含一个模型层,其中包含从旧版本到新版本的迁移指令 - 在我们上面的例子中,将
Field1从整数转换为字符串。 -
每次访问特定文档时,都会检查
version。如果不是最新的,Field1将转换为字符串,并更新版本。此操作在执行任何操作之前发生。更新后,正常执行操作。此操作与系统的正常操作并行运行。给定足够的时间,它将逐个文档地迁移整个数据库。
version字段可能不是绝对必要的,因为Field1的类型可能很容易推断和更改。但它提供了使过程明确化的优势,并且可以在单个访问中连接,将旧文档从不同版本迁移。如果没有
version字段,它可能被视为version 0,并迁移到version 1,现在包括该字段。

图 4.4:随时间的变化
这个过程非常干净,但有时即使数据未被访问,长时间保留旧格式可能也不可取。如果代码中仍然存在,它可能会导致代码从version 1 迁移到 2,从version 2 迁移到 3 等。在这种情况下,可能需要并行运行一个额外的过程,覆盖每个文档,更新并保存它,直到整个数据库迁移完成。
这个过程与描述的数据迁移类似,尽管强制执行模式的数据库需要执行迁移以更改格式。在无模式数据库中,格式可以在更改值的同时更改。
同样,纯数据更改,如之前看到的示例中更改比例的情况,可以在不需要迁移的情况下执行,缓慢地更改数据库,正如我们在这里所描述的。使用迁移确保更改更干净,并且可能允许同时更改格式。
还要注意,如果此功能封装在内部数据库访问层中,上面的逻辑可以使用较新的功能,而无需关心旧格式,因为它们将在运行时自动转换。
当数据库中仍有旧版本的数据时,代码需要能够解释它。这可能会导致一些旧技术的积累,因此也可以在后台迁移所有数据,因为它可以逐文档进行,通过旧版本进行过滤,同时一切都在运行。一旦完成后台迁移,代码就可以重构和清理,以删除对过时版本的处理。
处理遗留数据库
ORM 框架可以生成创建数据库模式的正确 SQL 命令。当从头开始设计和实现数据库时,这意味着我们可以在代码中创建 ORM 模型,ORM 框架将进行适当的调整。
这种在代码中描述模式的方式被称为声明式。
但有时,我们需要处理之前手动运行 SQL 命令创建的现有数据库。有两种可能的使用场景:
-
模式将永远不受 ORM 框架的控制。在这种情况下,我们需要一种方法来检测现有模式并使用它。
-
我们希望从这种情况使用 ORM 框架来控制字段和任何新的更改。在这种情况下,我们需要创建一个反映当前情况的模型,并从那里过渡到一个声明式的情况。
让我们看看如何处理这些情况。
从数据库检测模式
对于某些应用程序,如果数据库稳定或足够简单,可以直接使用,并尝试最小化处理它的代码。SQLAlchemy 允许您自动检测数据库的模式并与之工作。
SQLAlchemy 是一个非常强大的 ORM 功能库,可以说是执行复杂和定制访问关系型数据库的最佳解决方案。它允许对表之间如何相互关联进行复杂的定义,并允许您调整查询和创建精确的映射。与 Django ORM 等其他 ORM 框架相比,它可能更复杂,也更难以使用。
要自动检测数据库,可以自动检测表和列:
>>> from sqlalchemy.ext.automap import automap_base
>>> from sqlalchemy.sql import select
>>> from sqlalchemy import create_engine
# Read the database and detect it
>>> engine = create_engine("sqlite:///database.db")
>>> Base = automap_base()
>>> Base.prepare(engine, reflect=True)
# The Pens class maps the table called "pens" in the DB
>>> Pens = Base.classes.pens
# Create a session to query
>>> session = Session(engine)
# Create a select query
>>> query = select(Pens).where(Pens.color=='blue')
# Execute the query
>>> result = session.execute(query)
>>> for row, in result:
... print(row.id, row.name, row.color)
...
1 Waldorf blue
注意描述pens表和id、name、color列的名称是如何自动检测到的。查询的格式也非常类似于 SQL 构造。
SQLAlchemy 允许更复杂的用法和类的创建。更多信息,请参阅其文档:docs.sqlalchemy.org/。
Django ORM 也有一个命令,允许您使用inspectdb导出定义的表和关系的定义。
$ python3 manage.py inspectdb > models.py
这将创建一个models.py文件,该文件包含基于 Django 可以做的数据库解释。此文件可能需要调整。
这些操作方法对于简单情况工作得很好,其中最重要的部分是不必花费太多精力在代码中复制模式。在其他情况下,模式会发生变化,需要更好的代码处理和控制,这就需要不同的方法。
检查 Django 文档以获取更多信息:docs.djangoproject.com/en/3.2/howto/legacy-databases/.
将现有模式同步到 ORM 定义
在其他情况下,有一个由无法复制的方法定义的遗留数据库。可能它是通过手动命令完成的。当前代码可能使用该数据库,但我们希望迁移代码,以便我们能够跟上它,这样我们一方面可以确切地了解不同的关系和格式,另一方面允许 ORM 以兼容的方式对模式进行控制性更改。我们将后者视为迁移。
在这种情况下,挑战在于在 ORM 框架中创建一系列与数据库定义保持一致的模型。这比说起来要难得多,有以下几个原因:
-
可能存在 ORM 无法精确翻译的数据库功能。例如,ORM 框架不原生处理存储过程。如果数据库有存储过程,它们需要作为软件操作的一部分被移除或复制。
存储过程是数据库内部的代码函数,可以修改它。它们可以通过使用 SQL 查询手动调用,但通常它们是由某些操作触发的,如插入新行或更改列。存储过程现在并不常见,因为它们操作起来可能会令人困惑,而且大多数情况下,系统设计倾向于将数据库视为仅存储的设施,没有改变存储数据的容量。管理存储过程很复杂,因为它们可能难以调试,并且难以与外部代码保持同步。
存储过程可以通过处理该复杂性的代码在单个工作单元操作中复制。这是目前最常见的方法。但是,当然,将现有的存储过程迁移到外部代码是一个可能不容易且需要谨慎规划和准备的过程。
-
ORM 框架在设置某些元素方面可能有它们的怪癖,这可能与现有的数据库不兼容。例如,某些元素的命名方式。Django ORM 不允许你为索引和约束设置自定义名称。一段时间内,约束可能只存在于数据库中,但在 ORM 中是“隐藏”的,但从长远来看可能会产生问题。这意味着在某个时候,需要在外部将索引名称更改为兼容的名称。
-
这里的另一个例子是 Django ORM 对复合主键的支持不足,这可能需要你创建一个新的数字列来创建一个代理键。
这些限制要求创建模型时必须谨慎,并且需要检查以确保它们与当前模式一起按预期工作。基于 ORM 框架中的代码模型创建的创建的方案可以生成并与实际方案进行比较,直到它们达到一致性或足够接近。
例如,对于 Django,可以使用以下一般步骤:
-
创建数据库模式的备份。这将用作参考。
-
创建适当的模型文件。起点可以是上面描述的
inspectdb命令的输出。注意,
inspectdb创建的模型将它们的元数据设置为不跟踪数据库中的更改。这意味着 Django 将模型标记为不跟踪更改的迁移。一旦验证,这将需要更改。 -
创建一个包含数据库所需所有更改的单个迁移。这个迁移是通过
makemigrations正常创建的。 -
使用命令
sqlmigrate生成将要由迁移应用的所有 SQL 语句的 SQL 备份。这将生成一个可以与参考进行比较的数据库模式。 -
调整差异并从步骤 2 开始重复。记住每次都要删除迁移文件,以便从头开始生成。
一旦迁移调整到产生当前应用的确切结果,可以使用参数
--fake或–fake-initial应用此迁移,这意味着它将被注册为已应用,但 SQL 不会运行。
这是一个非常简化的方法。如我们上面讨论的,有一些元素可能难以复制。为了解决不兼容性问题,可能需要对外部数据库进行更改。
另一方面,有时可以容忍一些不会造成任何问题的微小差异。例如,主键索引中的不同名称可能是可以接受的,稍后可以修复。通常,这类操作需要很长时间才能从复杂的模式中完全完成。相应地规划,并分小步骤进行。
之后,可以通过更改模型然后自动生成迁移来正常应用更改。
摘要
在本章中,我们描述了领域驱动设计的原则,以指导数据的存储抽象和使用遵循业务原则的丰富对象。我们还描述了 ORM 框架以及它们如何有助于消除与特定库的低级交互,以便与存储层工作。我们描述了代码与数据库交互的不同有用技术,如与事务概念相关的工作单元模式,以及针对读写操作针对不同后端的高级情况下的 CQRS。
我们还讨论了如何处理数据库变更,包括显式的迁移,这些迁移会改变数据库模式,以及更柔和的变更,这些变更在应用程序运行时迁移数据。
最后,我们描述了处理遗留数据库的不同方法,以及在没有控制当前数据模式的情况下如何创建模型以创建适当的软件抽象。
第二部分
架构模式
要能够产生成功的设计,并不一定需要从头开始。相反,你的努力最好放在理解哪些常见的架构模式已经被证明是成功的。
在本书的这一部分,我们将看到许多成功系统中常见的不同想法。所有这些元素在特定情境下都是有用的,我们将在接下来的章节中看到它们的优点和局限性:
-
十二要素应用方法,解释这一方法
-
Web Server Structures,描述如何有效地处理响应-请求服务
-
事件驱动结构基础,介绍如何使用事件以及如何通过它们与不同的服务进行通信
-
高级事件驱动结构,用于创建复杂的信息流、优先级和 CQRS
-
微服务与单体,解释它们之间的差异以及处理它们的工具
我们将向您介绍十二要素应用方法,因为它包含了一系列有用的建议,用于处理服务的具体问题。我们还将深入了解网络服务器请求-响应结构,这通常是服务器的基础。
我们还将涵盖事件驱动系统,用两章内容确保涵盖基本和更高级的使用。事件驱动系统本质上是异步的,这意味着调用系统不会等待处理完成,在许多情况下,甚至没有类似响应的东西。这些系统在处理使用相同输入触发多个服务或生成需要很长时间才能处理的结果时非常有用。
我们还将讨论与单体系统相比的微服务,以及在这两种情况下使用不同的工具和技术,包括从一种迁移到另一种。
第五章:十二要素应用方法论
当设计一个软件系统时,每次为每个新项目重新发明轮子并不是一个好主意。软件的某些部分在大多数网络服务项目中是通用的。学习一些经过时间证明的成功实践,对于避免犯容易修复的错误非常重要。
在本章中,我们将重点关注十二要素应用的方法。这种方法是一系列经过时间证明的针对部署在 Web 上的网络服务的推荐。
十二要素应用起源于 Heroku,这是一家提供便捷部署访问的公司。其中一些因素比其他因素更通用,一切都应该被视为一般性建议,而不一定是强制性的。这种方法在 Web 云服务之外不太适用,但仍然是一个好主意,要审查它并尝试提取有用的信息。
我们将在本章中介绍这一方法的基本细节,并将花一些时间详细描述该方法涵盖的一些最重要的因素。
在本章中,我们将涵盖以下主题:
-
十二要素应用的简介
-
持续集成
-
可扩展性
-
配置
-
十二要素
-
容器化的十二要素应用
让我们从介绍十二要素应用的基本概念开始。
十二要素应用的简介
十二要素应用是一种方法论,包含 12 个不同的方面或因素,涵盖了在设计网络系统时应该遵循的良好实践。它们的目的是提供清晰度并简化一些可能性,详细说明已知的工作模式。
这些因素足够通用,不会在如何实现它们或强制使用特定工具方面具有指导性,同时给出明确的方向。十二要素应用方法论在某种程度上是有偏见的,因为它旨在以可扩展的方式涵盖云服务,并推广持续集成(CI)作为这些操作的关键方面。这也导致了本地、开发环境和生产环境之间的差异减少。
这两个方面,本地和生产的部署一致性,以及 CI,是相互作用的,因为它允许系统以一致的方式进行测试,无论是在开发环境中还是在 CI 系统中运行测试。
可扩展性是另一个关键要素。由于云服务需要处理可变的工作负载,我们需要允许我们的服务能够增长,并且能够处理系统进入的更多请求而不会出现任何问题。
我们将解决的第三个一般性问题,也是十二要素应用的核心,是配置的挑战。配置允许相同的代码在不同的环境中设置,同时也可以调整某些功能以适应特定情况。
持续集成
持续集成,或称 CI,是指在新代码提交到中央仓库时自动运行测试的实践。而最初在 1991 年提出时,它可以理解为运行一个“夜间构建”,因为运行测试需要花费时间且成本高昂,如今,它通常被理解为每次新代码提交时运行一系列测试。
目标是生成始终有效的代码。毕竟,如果代码无效,它会被失败的测试迅速检测到。这个快速的反馈循环有助于开发者提高速度,并创建一个安全网,使他们能够专注于他们正在实施的功能,并将整个测试流程的运行交给 CI 系统。自动运行测试并在每个测试上执行的纪律极大地有助于确保代码质量,因为任何错误都能迅速被发现。
这也取决于运行测试的质量,因此为了拥有一个良好的 CI 系统,了解良好测试的重要性并定期优化测试流程非常重要,既要确保它给我们提供足够的信心,也要确保它运行得足够快,以免引起问题。
足够快,当处理 CI 系统时可能会有所不同。记住,测试将在后台自动运行,无需开发者的参与,因此它们可能需要一段时间才能返回结果,与开发者调试问题时期望的快速反馈相比。作为一个非常一般的近似值,如果你的测试流程可以在 20 分钟或更短的时间内完成,那么就朝着这个目标努力。
CI 基于自动化任何用作代码中央仓库的系统的能力,因此一旦开发者有新的更改,测试就会立即启动。使用像 git 这样的源代码控制系统非常常见,并添加一个钩子来自动运行测试。
在更实际的方法中,git 通常在像 GitHub (github.com/) 或 GitLab (about.gitlab.com/) 这样的云系统中使用。它们都提供与其他服务集成的其他服务,允许通过一些配置自动运行操作。例如,TravisCI (www.travis-ci.com/) 和 CircleCI (circleci.com/)。在 GitHub 的情况下,它们有自己的原生系统,称为 GitHub Actions。所有这些都是基于添加一个特殊文件来配置服务的想法,从而简化了管道的设置和运行。
CI 管道是一系列按顺序运行的步骤。如果有错误,它将停止管道的执行并报告检测到的问题,从而允许早期检测和为开发者提供反馈。通常,我们将软件构建成可测试的状态,然后运行测试。如果有不同类型的测试,例如单元测试和集成测试,则运行它们,要么一个接一个,要么并行运行。
运行测试的典型管道可以执行以下操作:
-
由于它从一个新的、空的环境中开始,因此安装所需的依赖工具以运行测试;例如,特定的 Python 版本和编译器,或者将在步骤 3中使用的静态分析工具。
-
执行任何构建命令以准备代码,例如编译或打包化。
-
运行静态分析工具,如
flake8,以检测样式问题。如果结果显示有问题,请在此处停止并报告。 -
运行单元测试。如果结果不正确,请在此处停止并显示错误。
-
准备并运行其他测试,例如集成或系统测试。
在某些情况下,这些阶段可以并行运行。例如,步骤 3和步骤 4可以同时运行,因为它们之间没有依赖关系,而步骤 2需要在进入步骤 3之前完成。这些步骤可以在某些 CI 系统中描述,以允许更快地执行。
CI 管道中的关键字是自动化。为了允许管道执行,所有步骤都需要能够自动运行,无需任何人工干预。这要求任何依赖项也能够自动设置。例如,如果需要用于测试的数据库或其他依赖项,则需要分配。
一种常见的模式是 CI 工具分配一个虚拟机,允许数据库启动,以便在环境中可用,包括 MySQL、PostgreSQL 和 MongoDB 等常用数据库。请记住,数据库将启动为空,如果需要播种测试数据,则需要在设置环境期间完成。请查阅您特定工具的文档以获取更多详细信息。
一种可能性是使用 Docker 构建一个或多个容器,以标准化流程并在构建过程中明确所有依赖项。这正变得越来越常见。
我们将在第八章,高级事件驱动结构中更多地讨论 Docker。
十二因素应用的某些因素在 CI 管道的设置中发挥作用,因为它们旨在拥有易于构建的代码,以便部署用于测试或操作和配置。
可扩展性
预期云系统在高负载下表现正确,或者至少在不同负载之间进行调整。这要求软件是可扩展的。可扩展性是软件允许增长并接受更多请求的能力,通常是通过增加资源来实现。
有两种类型可扩展性:
-
垂直扩展性:向每个节点增加资源,使它们更强大。这相当于购买一台更强大的计算机;增加更多 RAM、更多硬盘空间、更快的 CPU...
-
水平扩展性:向系统中添加更多节点,而不一定需要它们更强大。例如,而不是有两个网络服务器,增加到五个。
通常,水平扩展性被认为更可取。在云系统中,添加和删除节点的容量可以自动化,允许部署根据系统流入的当前请求数量自动调整。与必须为系统最大负载时刻进行尺寸设计的传统操作方式相比,这可以大大降低成本,因为大多数时候,系统将处于未充分利用状态。
例如,让我们比较一下中午系统需要 11 个服务器的情况,此时大多数客户都已连接。午夜时分,系统处于最低利用率点,只需要 2 个服务器。
以下图表显示了当服务器数量根据负载增长时的典型情况:

自动生成的描述](https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/py-arch-ptn/img/B17580_05_01.png)
图 5.1:服务随时间进行扩展和缩减
传统情况将使用 264 个成本单位(11 个服务器 * 24 小时),而自动扩展大约使用 166 个成本单位,节省了大量资源。
更重要的是,传统系统需要额外的空间来允许可能发生的意外峰值。通常,系统将设置至少允许 30% 的额外负载,甚至可能更多。在这种情况下,成本将永久增加。
要使系统具有水平扩展性,它需要是无状态的。这意味着每个节点是无法区分的。每个请求将被分配到某个形式的旋转节点,以在整个节点之间分配负载。每个请求的所有状态都需要来自请求本身(输入参数)或来自外部存储源。从应用程序的角度来看,每个请求都进入一个空空间,并且在任何事件中都不能携带。这意味着在请求之间不在本地硬盘或本地内存中存储任何内容。
在请求内部存储信息,例如,从数据库中获取信息来组成文件并在请求中返回它是可以的,尽管如果可能的话,将其保存在内存中可能会比使用硬盘更快。
外部存储源通常是数据库,但使用更多面向存储文件或其他大型二进制数据块(例如,AWS S3)的存储服务也很常见。
AWS S3 是一种网络服务,允许通过 URL 存储和检索文件。它允许创建一个桶,其中将包含多个键或路径;例如,访问类似于https://s3.amazonaws.com/mybucket/path/to/file的 URL,以便上传和下载类似文件的对象。还有许多库可以帮助处理此服务,例如 Python 的boto3。
此服务非常适合以可扩展的方式处理文件,并允许以这种方式进行配置,使得读取访问可以公开进行,从而简化了通过系统存储数据并通过公共 URL 允许用户读取的模式。
更多信息请参考 AWS 文档:aws.amazon.com/s3/
缓存也应该在每个单独的节点之外保持,使用如 Riak 或 memcached 等工具。使用本地内存的内部缓存存在一个问题,即它们可能不会被使用,因为下一个相关的请求可能由系统中的另一个节点提供服务。使用外部服务允许所有节点访问缓存,并提高系统的整体性能。
请记住,整个系统不能是无状态的。特别是存储元素,如数据库和缓存,需要不同的操作方式,因为它们是存储数据的地方。我们在第三章,数据建模中讨论了如何扩展存储系统。
配置
十二要素应用的基本思想之一是代码是唯一的,但可以通过配置进行调整。这使得相同的代码可以在不同的环境中使用和部署。
使用不同的环境允许设置测试环境,在那里可以运行测试而不会影响生产数据。它们是进行实验或尝试在沙盒中复制真实问题的更受控的地方。还有一个通常不被视为此类环境的环境,即本地开发环境,在那里开发者能够检查系统是否正常工作。
创建一个全面且易于使用的本地环境是提高开发者生产力的关键方面。当与单个服务或进程(如网页服务器)一起工作时,设置相对容易,因为大多数项目都允许以开发模式启动,但一旦有更多元素,设置就变得更加困难。
复杂设置已经相当普遍多年了。最近有一股趋势是使用从头开始设置的虚拟机,而最近则是容器化,以确保可以从已知点轻松启动。
配置系统比最初看起来要困难。总会有越来越多的参数需要处理。在复杂系统中,以某种方式对参数进行结构化很重要,以便将它们分成更易于管理的部分。
配置参数可以分为两大类:
-
操作配置:这些参数连接系统的不同部分或与监控相关;例如,数据库的地址和凭证、用于访问外部 API 的 URL,或设置日志级别为
INFO。这些配置参数仅在集群发生变化时更改,但应用程序的外部行为不会改变;例如,仅记录WARNING日志或更高,或更换凭证以进行轮换。 -
这些参数受运维控制,通常在透明或维护期间更改。这些参数的配置错误通常是一个严重问题,因为它可能会影响系统的功能。
-
功能配置:这些参数改变外部行为,启用或禁用功能或更改软件的某些方面;例如,设置颜色和页眉图片的主题参数;或者启用高级功能以允许对高级访问收费,或者更新数学模型的参数,以改变内部轨道计算的方式。
这些参数与软件的操作无关。这里的配置错误可能不会引起问题,因为它将继续正常运行。这里的更改更多地受开发者或甚至业务经理的控制,以在特定时间启用一个功能。
旨在激活或禁用完整功能的配置参数被称为功能标志。它们用于在特定时间生成一个“商业版本”,在功能内部开发的同时,将新代码部署到生产环境中而不包含该功能。
一旦功能准备就绪并经过彻底测试,代码可以在生产环境中提前部署,只需更改适当的配置参数即可激活完整功能。
这使我们能够以小步增加的方式继续工作,朝着一个大功能前进,例如用户界面的全面更新,同时频繁构建和发布小增量。一旦功能发布,代码可以重构以删除该参数。
这两个类别有不同的目标,通常由不同的人维护。虽然操作配置参数与单个环境紧密相关,需要适合该环境的参数,但功能配置通常在本地开发中进行测试,直到在生产环境中更改并具有相同的值。
传统上,配置参数存储在一个或多个文件中,通常按环境分组。这会创建一个名为production.cnf的文件和另一个名为staging.cnf的文件,它们附加到代码库中,根据环境的不同,使用其中一个。这带来了一定的问题:
-
进行配置更改实际上是一种代码更改。这限制了可以执行更改的速度,并可能导致范围问题。
-
当环境数量增加时,文件数量也会同时增加。这可能导致由于重复而产生的错误;例如,更改错误文件而没有撤销,并在意外部署后出现。旧文件也可能没有被删除。
-
在开发者之间集中控制。正如我们所见,一些参数不一定在开发者的控制之下,而是在运维团队的控制之下。将所有数据存储在代码库中使得创建工作之间的划分更加困难,需要两个团队访问相同的文件。虽然这对小型团队来说是可以的,但长期来看,尝试减少大群体人员访问同一文件而只关心其中一半的需求是有意义的。
-
将敏感参数(如密码)存储在文件中并在代码仓库中存储它们是一个明显的安全风险,因为任何有权访问仓库的人都可以使用这些凭据访问所有环境,包括生产环境。
这些问题使得直接将配置作为文件存储在代码库内部不可取。我们将在配置要素中具体了解十二要素应用如何处理它。
十二要素
十二要素应用的因素如下:
-
代码库。将代码存储在单个仓库中,并通过配置进行区分。
-
依赖项。明确且清晰地声明它们。
-
配置。通过环境进行配置。
-
后端 服务。任何后端服务都应被视为附加资源。
-
构建、发布、运行。区分构建状态和运行状态。
-
进程。以无状态进程执行应用程序。
-
端口绑定。通过端口公开服务。
-
并发性。将服务设置为进程。
-
可丢弃性。快速启动和优雅关闭。
-
开发/生产一致性。所有环境应尽可能相似。
-
日志。将日志发送到事件流。
-
管理进程。独立运行一次性管理进程。
这些因素可以围绕不同的概念进行分组:
-
代码库、构建、发布、运行和开发/生产一致性围绕生成一个在不同环境中运行的单个应用程序的理念,仅通过配置进行区分
-
配置、依赖项、端口绑定和后端服务围绕不同服务的配置和连接性
-
进程、可丢弃性和并发性与可扩展性概念相关
-
日志和管理进程是与监控和一次性进程相关的实用理念
让我们来看看这四个组中的所有四个。
构建一次,运行多次
十二要素应用的一个关键概念是它易于构建和管理,但同时也是统一的系统。这意味着没有从一种版本到另一种版本的临时代码更改,只有可配置的选项。
代码库要素的目标是,一个应用程序的所有软件都存储在单个仓库中,具有单一状态,没有为每个客户设置的特殊分支,也没有仅在特定环境中可用的特殊功能。
非常特定的环境通常被称为雪花环境。任何处理过这些环境的人都知道维护它们是多么痛苦,这就是为什么十二要素应用的目标是消除它们,或者至少使它们仅基于配置进行更改。
这意味着部署的代码始终相同,只有配置发生变化。这允许轻松测试所有配置更改,并且不会引入盲点。
注意,一个系统可能包含多个项目,这些项目分布在多个仓库中,每个项目单独满足十二要素应用的要求,并且协同工作。其他因素讨论的是应用程序之间的互操作性。
通过协调的 API 保持多个应用程序协同工作始终是一个挑战,需要跨团队的良好协调。一些公司采用单仓库方法,即有一个包含所有公司项目且分布在多个子目录中的单一仓库,以确保对整个系统有一个完整的视图,并在整个组织内保持单一状态。
这也带来了一些挑战,需要跨团队进行更多协调,并且可能对大型仓库提出重大挑战。
单一代码库允许在构建、发布、运行要素中对阶段进行严格区分。该要素确保有三个不同的阶段:
-
构建阶段将代码仓库的内容转换为稍后运行的软件包或可执行文件。
-
发布阶段使用这个构建好的软件包,将其与所选环境的适当配置结合,并使其准备就绪以执行。
-
运行阶段最终在所选环境中执行打包的软件包。
如我们之前讨论的,配置存储在代码库的不同位置。这种分离是有意义的,并且也可以在源代码控制下进行。它可能以文件的形式存储,但可以通过环境进行访问分离,这在某些环境中是有意义的,例如生产环境,比其他环境更为关键。将配置作为代码库的一部分存储会使其难以进行这种分离。
请记住,可以合并多个文件,允许将参数分离为功能和操作配置。
由于阶段是严格分开的,代码部署后不可能更改配置或代码。这要求在任何情况下都进行新的发布。这使得发布非常明确,每个发布都应该独立执行。请注意,如果出现新的服务器或服务器崩溃,运行阶段可能需要再次执行,因此目标应该是尽可能简单地进行。正如我们所看到的,十二要素应用的一个共同主题是严格的分离,这样每个元素都容易识别和操作。我们将检查如何在其他因素中定义配置。
在构建阶段之后执行测试也确保了代码在测试和发布及操作之间没有变化。
由于这种严格的分离,特别是在构建阶段,很容易遵循Dev/prod parity。本质上,开发环境与生产环境相同,因为它们使用相同的构建阶段,但通过适当的配置来本地运行。这个因素也使得使用相同的(或尽可能接近的)后端服务,如数据库或队列,成为可能,以确保本地开发尽可能代表生产环境。Docker 这样的容器工具或 Chef 或 Puppet 这样的配置工具也可以帮助自动设置包含所有必需依赖项的环境。
获得快速且简单的开发、构建和部署过程对于加快周期和快速调整至关重要。
依赖项和配置
十二要素应用提倡明确定义依赖项和配置,同时在如何执行它们方面有明确的观点,并提供了经过验证的坚实标准。
正因如此,在Config因素中,它提到将系统的所有配置存储在环境变量中。环境变量独立于代码,这允许保留我们在Build, release, run factor中讨论的严格区分,并避免我们在之前描述的将它们存储在代码库内部文件中的问题。它们也是语言和操作系统无关的,并且易于使用。将环境变量注入到新的环境中也很简单。
这比其他替代方案更受欢迎,例如将描述环境如staging或production的不同文件放入代码库中,因为它们允许更精细的控制,并且因为这种处理方式最终会创建太多文件,并改变那些未受影响的环境的代码;例如,需要更新一个短暂存在的demo环境的代码库。
虽然十二要素应用鼓励以变量无关的方式处理配置,但工作的现实情况意味着存在有限数量的环境,并且它们的配置应该存储在某个地方。关键要素是将它们存储在代码库之外的不同位置,仅在 发布 阶段进行管理。这提供了足够的灵活性。
请记住,对于本地开发,这些环境变量可能需要独立更改以测试或调试不同的功能。
可以使用标准库直接从环境中的配置文件获取配置;例如,在 Python 中:
import os
PARAMETER = os.environ['PATH']
此代码将在常量 PARAMETER 中存储 PATH 环境变量的值。请注意,如果没有 PATH 环境变量,将会生成一个 KeyError,因为它不会出现在 environ 字典中。
对于以下示例,请记住,定义的环境变量需要在你的环境中定义。这些定义不包括在内,以简化说明。你可以通过运行 $ MYENVVAR=VALUE python3 来运行 Python,添加一个本地环境。
为了允许可选的环境变量,并防止它们丢失,请使用 .get 方法来设置默认值:
PARAMETER = os.environ.get('MYENVVAR', 'DEFAULT VALUE')
作为一般建议,在配置变量缺失时抛出异常比继续使用默认参数更好。这样做可以使配置问题更容易被发现,因为它会在进程启动时停止,并大声报错。记住,遵循十二要素应用的理念,你希望明确地描述事物,并且任何问题都应该尽早失败,以便能够正确地修复它,而不是在未被发现的情况下传递。
注意,环境变量始终定义为文本。如果值需要不同的格式,则需要将其转换,例如:
NUMBER_PARAMETER = int(os.environ['ENVINTEGERPARAMETER'])
这在定义 Boolean 值时提出了一个常见问题。以下定义此转换代码的方式是不正确的:
BOOL_PARAMETER = bool(os.environ['ENVBOOLPARAMETER'])
如果 ENVPARAMETER 的值是 "TRUE",则 BOOL_PARAMETER 的值是 True(布尔值)。但如果 ENVPARAMETER 的值是 "FALSE",则 BOOL_PARAMETER 的值也是 True。这是因为字符串 "FALSE" 是一个非空字符串,被转换为 True。相反,可以使用标准库包 distutils:
import os
from distutils.util import strtobool
BOOL_PARAMETER = strtobool(os.environ['ENVBOOLPARAMETER'])
strtobool 返回的不是 True 或 False 作为 Booleans,而是 0 或 1 作为整数。这通常工作正常,但如果你需要严格的 Boolean 值,可以添加 bool 如此:bool(strtobool(os.environ['ENVPARAMETER']))
环境变量还允许注入敏感值,如机密信息,而无需在代码库中存储它们。请注意,机密信息将在执行环境中被检查到,但通常这是受保护的,因此只有授权团队成员可以通过 ssh 或类似方式在环境中访问它。
作为此配置的一部分,任何后端服务也应定义,以及环境变量。后端服务是应用程序通过网络使用的服务。它们可以是同一网络内的本地服务,也可以是外部服务,例如由外部公司处理或 AWS 服务。
从应用程序的角度来看,这种区分应该是无关紧要的。资源应通过 URI 和凭证访问,并且作为配置的一部分,可以根据环境进行更改。这使得资源松散耦合,意味着它们可以很容易地被替换。如果有迁移,并且数据库需要在两个网络之间移动,我们可以启动新的数据库,通过配置更改进行新的发布,应用程序将指向新的数据库。这可以不更改代码来完成。
为了允许多个应用程序的连接,端口绑定因子确保任何公开的服务都是一个端口,这可能会根据服务而有所不同。这使得将每个应用程序视为后端服务变得容易。最好以 HTTP 的形式公开,因为这使其连接非常标准化。
对于应用程序,如果可能,使用端口80上的 HTTP。这使得所有连接都很容易,例如使用http://service-a.local/这样的 URL。
一些应用程序需要几个协同工作的进程的组合。例如,对于 Python 应用程序的 Web 服务器,如 Django,通常使用应用程序服务器如 uWSGI 来运行它,然后使用 Web 服务器如 nginx 或 Apache 来提供服务和静态文件。

图 5.2:连接 Web 服务器和应用程序服务器
它们通过暴露已知的端口和协议相互连接,这使得设置变得简单。
在同一问题上,为了清晰起见,所有库的依赖项都应该明确设置,而不是依赖于现有操作系统中预安装的某些包。依赖项应通过依赖声明来描述,例如 Python 的requisites.txt pip 文件。
然后,依赖项应作为构建阶段的一部分进行安装,使用如pip install -r requirements.txt之类的命令。
请记住,特定的 Python 版本也是一个应该严格控制依赖项。其他必需的操作系统依赖项也是如此。理想情况下,应从头开始创建操作系统环境,并指定依赖项。
尤其重要的是,依赖项应该尽可能隔离,以确保没有不紧密控制的隐式依赖项。依赖项也应尽可能紧密地定义,以避免在发布新版本时安装不同版本的依赖项的问题。
例如,在一个 pip 文件中,依赖项可以用不同的方式描述:
requests
requests>=v2.22.0
requests==v2.25.1
第一种方式接受任何版本,所以通常使用最新版本。第二种描述了最小(和可选的最大)版本。第三种版本固定了特定版本。
这与操作系统中的其他包管理系统等效,例如 Ubuntu 中的 apt。您可以使用 apt-get install dependency=version 安装特定版本。
使用非常明确的依赖关系可以使构建可重复和确定性的。它确保在构建阶段没有未知的变化,因为已经发布了新版本。虽然大多数新包都将兼容,但它也可能 有时 引入影响系统行为的变化。更糟糕的是,这些变化可能是 无意中 引入的,导致严重问题。
可扩展性
我们在章节中较早地讨论了可扩展性的原因。十二要素应用也讨论了如何成功增长或缩减系统。
进程 因素讨论了确保运行阶段由启动一个或多个进程组成。这些进程应该是无状态的,不共享任何内容,这意味着所有数据都需要从外部备份服务(如数据库)检索。可以在同一请求内使用临时本地磁盘来存储临时数据,尽管其使用应保持在最低限度。
例如,文件上传可能使用本地硬盘存储临时副本,然后处理数据。数据处理完毕后,文件应从磁盘上删除。
如果可能,尽量使用内存进行这种临时存储,因为这会使这种区分更加严格。
进程需要满足的下一个属性是它们的 可处置性。进程需要能够快速启动和停止,并且可以在任何时间进行。
快速启动允许系统快速响应发布或重启。目标应该是让进程在几秒钟内启动并运行。快速周转也很重要,以便在需要为缩放目的添加更多进程时,系统能够快速增长。
相反,允许进程优雅地关闭。这在缩放情况下可能是必需的,以确保在此情况下任何请求都不会被中断。按照惯例,进程应通过发送 SIGTERM 信号来停止。
使用 Docker 容器自动通过发送 SIGTERM 信号到主进程来使用这种惯例,每当容器需要停止时。如果在宽限期后进程没有自行停止,它将被杀死。如果需要,宽限期可以配置。
确保容器的主体进程能够接收 SIGTERM 信号并妥善处理,以确保容器能够优雅地停止。
例如,对于 Web 请求,首先进行优雅的关闭将减少对新请求的接受,完成队列中的任何请求,最后关闭进程。Web 请求通常回答得很快,但对于其他进程,如长时间异步任务,如果需要完成当前任务,停止可能需要很长时间。
相反,长时间任务的工作者应该将作业返回到队列并取消执行。这样,任务将再次执行,为了确保不会重复操作,我们需要确保所有任务都可以通过等待其结束并保存其结果,然后将它们包装成事务或类似的方式来取消。
在某些情况下,可能有必要区分准备作业的大部分和保存结果的部分。我们可能希望等待,如果作业在关闭时保存结果,或者停止执行并将任务返回到队列。某些保存操作可能需要调用不接受事务的系统。关闭长时间运行进程的可接受时间可能比关闭 Web 服务器的时间更长。
进程还应能够抵御意外的中断。这些中断可能由错误、硬件错误或通常在软件中出现的意外惊喜引起。创建一个具有弹性的队列系统,以便在任务中断的情况下可以重试,将大大有助于这些情况。
因为系统是通过进程创建的,基于这一点,我们可以通过创建更多的进程来扩展。进程是独立的,可以在同一服务器或其他服务器上同时运行。这是并发性因素的基础。
请记住,同一个应用程序可以使用多个进程,这些进程之间协调以处理不同的任务,并且每个进程可能有不同数量的副本。在我们上面的前一个例子中,使用 nginx 服务器和 uWSGI,最佳数量可能是一个 nginx 进程,其数量是 uWSGI 工作者的多倍。
传统的部署过程是为节点设置一个物理服务器(或虚拟机)并安装一定数量的元素,这通常包括调整工作者的数量,直到找到最佳数量以充分利用硬件。
使用容器,这个过程在一定程度上是相反的。容器通常更轻量级,可以创建更多。虽然优化过程仍然是必需的,但使用容器时,更多的是创建一个单元,然后检查单个节点可以容纳多少个这样的单元,因为容器可以在节点之间更容易地移动,并且生成的应用程序通常更小。我们不是找出给定服务器的应用程序的正确大小,而是确定一个小应用程序在服务器中可以容纳多少副本,因为我们知道我们可以轻松地使用不同大小的服务器或添加更多服务器。
添加更多节点,由于它们是独立和无状态的,在十二要素应用中变得容易操作。这允许整个操作的大小根据系统的负载进行调整。这可以是一个手动操作,随着系统负载和请求的增加而缓慢添加新节点,或者它可以自动完成,正如我们在本章前面所描述的。
十二要素应用并不要求自动执行此扩展,但确实使其成为可能。自动化调整应谨慎处理,因为它需要仔细的系统负载指标。留出时间进行测试,以进行适当的调整。
十二要素应用的处理程序也应该由某种操作系统进程管理器运行,如upstart或systemd。这些系统确保进程持续运行,即使在崩溃的情况下也能处理优雅的手动重启,并优雅地管理输出流。我们将更多地讨论输出流作为日志的一部分。
当与容器一起工作时,这会有所变化,因为主要处理的是容器而不是进程。而不是操作系统进程管理器,工作由容器编排器执行,确保容器正常运行并捕获任何输出流。在容器内部,进程可以在没有管理器控制的情况下启动。如果进程停止,容器将停止。
自动重启进程,结合快速启动时间和在关闭情况下的弹性,使应用程序动态且能够自我修复,以防出现意外问题导致进程崩溃。它还允许在避免长时间运行进程和作为内存泄漏或其他长时间运行问题的应急计划的一部分使用受控关闭。
这相当于老式的技巧:先关闭再打开!如果操作非常快,可以节省很多情况!
监控和管理
一个全面的监控系统对于检测问题和分析系统的操作非常重要。虽然它不是唯一的监控工具,但日志是任何监控系统的关键部分。
日志是提供正在运行的应用程序行为可见性的文本字符串。它们应该始终包含生成时的时间戳。它们在代码执行时生成,提供关于不同动作发生时的信息。关于要记录的具体内容可能因应用程序而异,但通常框架会根据常见实践自动创建日志。
例如,任何与网络相关的软件都会记录接收到的请求,例如:
[16/May/2021 13:32:16] "GET /path HTTP/1.1" 200 10697
注意,它包括:
-
生成时的时间戳
[16/May/2021 13:32:16] -
HTTP
GET方法和HTTP/1.1协议 -
访问路径 –
/path -
返回的状态码 –
200 -
请求的大小 –
10697
这种日志被称为访问日志,并且将以不同的格式生成。至少,它应该始终包括时间戳、HTTP 方法、路径和状态码,但它可以被配置为返回额外的信息,例如发起请求的客户端的 IP 地址,或者处理请求所需的时间。
访问日志是由包括 nginx 和 Apache 在内的 Web 服务器生成的。正确配置它们以调整产生的信息对于运营目的非常重要。
访问日志不是唯一有用的日志。应用程序日志也非常有用。应用程序日志是在代码内部生成的,可以用来传达重要的里程碑或错误。Web 框架准备日志,因此很容易生成新的日志。例如,在 Django 中,你可以这样创建日志:
import logging
logger = logging.getLogger(__name__)
...
def view(request, arg):
logger.info('Testing condition')
if something_bad:
logger.warning('Something bad happened')
这将生成如下日志:
2021-05-16 14:01:37,269 INFO Testing condition
2021-05-16 14:01:37,269 WARNING Something bad happened
我们将在第十一章,包管理中详细介绍日志的更多细节。
日志因素表明日志不应该由进程本身管理。相反,日志应该直接打印到它们自己的标准输出,而不经过任何中间步骤。围绕进程的环境,如并发因素中描述的操作系统的进程管理器,应该负责接收日志、合并它们并将它们正确路由到长期归档和监控系统。请注意,这种配置完全超出应用程序的控制范围。
对于本地开发,仅通过终端显示日志可能就足够用于开发目的。
这与将日志作为硬盘上的日志文件存储形成对比。这有一个问题,即需要轮换日志并确保有足够的空间。这也要求不同的进程在日志轮换和存储方面协调一致。相反,标准输出可以合并并汇总成一个系统的整体图像,而不是单个进程。
日志也可以被导向外部日志索引系统,例如 ELK 堆栈(Elasticsearch、Kibana 和 Logstash:www.elastic.co/products/),它将捕获日志并提供分析工具来搜索它们。外部工具也可用,包括 Loggly (www.loggly.com/) 或 Splunk (www.splunk.com/) 以避免维护。所有这些工具都允许捕获标准输出日志并将它们重定向到它们的解决方案。
在容器世界中,这个建议更有意义。Docker 编排工具可以轻松捕获容器中的标准输出,然后将它们重定向到其他地方。
这些其他工具可以提供诸如在特定时间窗口内搜索和查找特定事件、观察每小时请求数量的变化等趋势,甚至可以根据某些规则创建自动警报,例如在一段时间内超过某个值的ERROR日志数量的增加。
管理进程因素涵盖了有时需要运行以进行特定操作但不是应用程序正常操作一部分的一些进程。以下是一些例子:
-
数据库迁移
-
生产临时报告,例如生成针对特定销售的单一报告或检测受某个错误影响的记录数量
-
用于调试目的的运行控制台
在生产环境中,仅在没有其他替代方案时才应使用控制台执行命令,不应将其作为创建特定脚本的替代方式以进行重复操作。应采取极端谨慎。请记住,生产环境中的错误可能会造成严重问题。对待您的生产环境应持适当的尊重态度。
这些操作不是日常运营的一部分,但可能需要运行。界面明显不同。要执行它们,它们应在与常规进程相同的环境中运行,使用相同的代码库和配置。这些管理操作应作为代码库的一部分,以避免代码不匹配的问题。
在传统环境中,可能需要通过 ssh 登录到服务器以允许执行此过程。在容器环境中,可以启动一个完整的容器专门用于执行此过程。
在迁移的情况下,这是非常常见的。一个准备命令可能包括运行构建以执行迁移。
这应该在实际发布之前完成,以确保数据库已迁移。有关迁移的更多详细信息,请参阅第四章。
要在容器中运行这些管理命令,容器镜像应与运行应用程序的镜像相同,但使用不同的命令,这样代码和环境就与运行中的应用程序相同。
容器化的 Twelve-Factor Apps
尽管 Twelve-Factor App 方法比当前使用 Docker 和相关工具的容器化趋势更早,但它非常一致。这两个工具都面向云的可扩展服务,容器有助于创建与 Twelve-Factor 方法中描述的匹配的模式。
我们将在第八章,高级事件驱动结构中更多地讨论 Docker 容器。
最重要的,可以说是创建一个不变的容器镜像,然后运行它,与Build, release, run因素非常契合,并且对Dependencies非常明确,因为整个镜像将包括诸如要使用的特定操作系统和任何库等详细信息。将构建过程作为仓库的一部分也有助于实现Code base因素。
每个容器也作为一个Process工作,这允许通过创建相同容器的多个副本来实现扩展,使用Concurrency模型。
虽然容器在概念上通常被视为轻量级虚拟机,但最好将它们视为封装在其自身文件系统中的进程。这更接近它们的实际操作方式。
容器的概念使得它们易于启动和停止,利用Disposability因素,并通过如 Kubernetes 之类的编排工具连接彼此,这使得设置Backing services因素也变得容易,并且根据Port binding因素,在容器中特定端口之间共享服务也很简单。然而,在大多数情况下,它们将作为标准端口80上的 Web 界面进行共享。
在 Docker 和 Kubernetes 之类的编排工具中,通过注入环境变量设置不同的环境非常容易,从而满足Configuration因素。这种环境配置以及集群的描述可以存储在文件中,这使得可以轻松创建多个环境。它还包括处理机密信息的工具,以确保它们被正确加密,并且不会存储在配置文件中,以避免泄露机密信息。
容器另一个关键优势是集群可以轻松地在本地进行复制,因为运行在生产环境中的相同镜像也可以在本地环境中运行,只需对其配置进行少量更改。这极大地有助于确保不同环境能够根据Dev/Prod parity因素保持最新。
通常,容器方法旨在定义一个集群,并以一致的方式在不同服务和容器之间引发清晰的分离。这汇集了不同的环境,因为开发环境可以在小规模上复制生产设置。
根据Logs因素将信息发送到标准输出也是存储日志的绝佳方式,因为容器工具将接收和处理或重定向这些日志。
最后,可以通过使用不同的命令启动相同的容器镜像来处理Admin processes,该命令运行特定的管理命令。如果需要定期执行,例如在部署之前运行迁移,或者如果它是一个周期性任务,这可以由编排器处理。
如我们所见,与容器一起工作是遵循十二要素应用建议的绝佳方式,因为工具的方向是一致的。这并不意味着它们是免费的,但方法论与容器背后的理念之间存在显著的契合度。
这并不令人惊讶,因为两者都来自类似的背景,处理需要在云中运行的网络服务。
摘要
在本章中,我们看到了拥有稳固和可靠的模式来构建软件是很好的,这样我们就可以站在经过测试的决策的肩膀上,这些决策可以帮助我们塑造新的设计。对于生活在云中的网络服务,我们可以将十二要素应用方法论作为许多有用建议的指南。
我们讨论了十二要素应用如何与两个主要理念——持续集成(CI)和可扩展性——相一致。
持续集成(CI)是指在代码共享后自动运行测试以不断验证任何新代码的实践。这创建了一个安全网,使开发者能够快速行动,尽管这要求在开发新功能时正确添加自动化测试需要纪律。
我们还讨论了可扩展性的概念,即软件通过增加更多资源来允许更多负载的能力。我们讨论了为什么允许软件根据负载增长和减少很重要,甚至能够动态调整。我们还看到了使系统无状态是实现可扩展软件的关键。
我们看到了配置的挑战,这是十二要素应用也处理的问题,以及并非每个配置参数都是平等的。我们描述了如何将配置分为操作配置和功能配置,这有助于划分并给每个参数提供适当的上下文。
我们逐一分析了十二要素应用的各个因素,并将它们分为四个不同的组,将它们联系起来,并解释了不同的因素如何相互支持。我们将因素分为以下几组:
-
编译一次,运行多次,基于生成单个在不同环境中运行的包的想法
-
依赖和配置,关于应用程序的配置和软件及服务依赖
-
可扩展性,为了实现我们之前讨论过的可扩展性
-
监控和管理其他元素以处理软件在运行时的操作
最后,我们花了一些时间讨论十二要素应用理念与容器化所涉及的内容非常一致,以及不同的 Docker 功能和概念如何使我们能够轻松创建十二要素应用。
第六章:网络服务器结构
目前,网络服务器是远程访问中最常见的服务器。基于 HTTP 的 Web 服务灵活且强大。
在本章中,我们将了解网络服务器的结构,首先描述基本请求-响应架构是如何工作的,然后深入探讨三层 LAMP 风格的架构:服务器本身、执行代码的工作者以及控制这些工作者并向服务器提供标准化连接的中间层。
我们将详细描述每一层,展示特定的工具,例如用于网络服务器的 nginx,用于中间层的 uWSGI,以及用于工作者内部特定代码的 Python Django 框架。我们将详细描述每一个。
我们还将包括 Django REST 框架,因为它是一个构建在 Django 之上的工具,用于生成 RESTful API 接口。
最后,我们将描述如何添加额外的层以实现更大的灵活性、可扩展性和性能。
本章我们将涵盖以下主题:
-
请求-响应
-
网络架构
-
网络服务器
-
uWSGI
-
Python 工作者
-
外部层
让我们先描述请求-响应架构的基础。
请求-响应
经典的服务器架构在通信上严重依赖于请求-响应。客户端向远程服务器发送请求,服务器处理它并返回响应。
这种通信模式自主机时代以来一直很流行,它以类似的方式工作,即软件通过库与内部通信,但通过网络。软件调用一个库并从它那里接收响应。
一个重要元素是请求发送和响应接收之间的时间延迟。内部,调用通常不会超过几毫秒,但对于网络来说,它可能以百毫秒和秒来衡量,非常常见。
网络调用非常依赖于服务器所在的位置。在同一数据中心内的调用将会很快,可能不到 100 毫秒,而连接到外部 API 的调用可能接近一秒或更长。
时间也会有很大的变化,因为网络条件可能会极大地影响它们。这种时间差异使得正确处理它变得很重要。
在发出请求时,通常的策略是同步进行。这意味着代码会停止并等待直到响应准备好。这很方便,因为代码将会很简单,但这也低效,因为当服务器正在计算响应并通过网络传输时,计算机将不会做任何事情。
客户端可以被改进以同时执行多个请求。这可以在请求彼此独立时进行,允许它并行执行。实现这一点的简单方法是使用多线程系统来执行它们,这样它们可以加快处理过程。
通常,需要一个流程,其中一些请求可以并行执行,而其他请求则需要等待收到信息。例如,一个常见的请求是检索网页,它将发送一个请求来检索页面,稍后将以并行方式下载多个引用的文件(例如,头文件、图像)。
我们将在本章后面看到,如何设计这种效果来提高网页的响应性。
事实上,网络比本地调用更不可靠,需要更好的错误处理来理解这一事实。任何请求-响应系统都应该特别注意捕获不同的错误,并重试,因为网络问题通常是短暂的,如果在等待后重试,可以恢复。
正如我们在第二章,API 设计中看到的,HTTP 的多个状态码可以提供详细的信息。
请求-响应模式的另一个特点是服务器不能主动调用客户端,只能返回信息。这简化了通信,因为它不是完全的双向的。客户端需要发起请求,而服务器只需要监听新的请求。这也使得两个角色不对称,并要求客户端知道服务器的位置,通常是通过 DNS 地址和访问端口(默认情况下,HTTP 为 80 端口,HTTPS 为 443 端口)。
这种特性使得某些通信模式难以实现。例如,完全双向通信,其中两部分都希望发起消息的发送,在请求-响应中很难实现。
这种方法的粗略例子是仅通过请求-响应实现的邮件服务器。两个客户端需要使用一个中间服务器。
这种基本结构在允许用户之间进行某种直接消息传递的应用程序中很常见,如论坛或社交网络。
每个用户可以执行两个操作:
-
请求任何发送给他们的新消息
-
向另一个用户发送新消息
用户需要定期检查是否有新消息可用,这通过轮询来实现。这是低效的,因为对于任何新消息,都可能存在大量的检查返回“没有新消息可用”。更糟糕的是,如果检查不够频繁,可能会出现显著的延迟,才会注意到有新消息可用。
在实际应用中,通常通过向客户端发送通知来避免这种轮询。例如,移动操作系统有一个系统来传递通知,允许服务器通过操作系统提供的 API 发送通知,通知用户有新消息。一个较老的替代方案是发送一封电子邮件达到相同的目的。
当然,还有其他替代方案。有 P2P 替代方案,其中两个客户端可以相互连接,还有通过 websockets 与服务器建立连接,这些连接可以保持打开状态,允许服务器通知用户新的信息。它们都偏离了请求-响应架构。
即使有这些限制,请求-响应架构仍然是 Web 服务的基础,并且在几十年中被证明是非常可靠的。存在一个中央服务器来控制通信并可以被动地接受新请求的可能性,使得架构易于实现和快速演进,并简化了客户端的工作。集中的方面允许进行大量控制。
网络架构
在本章的引言中,我们介绍了 LAMP 架构,它是网络服务器架构的基础:

图 6.1:LAMP 架构
LAMP 架构更为通用,但我们将更详细地研究网络服务器和网络工作进程。我们将使用基于 Python 生态系统的特定工具,但我们将讨论可能的替代方案。

图 6.2:Python 环境中的更详细架构
从传入请求的角度来看,Web 请求访问不同的元素。
网络服务器
网络服务器公开 HTTP 端口,接受传入的连接,并将它们重定向到后端。一个常见的选项是 nginx (www.nginx.com/)。另一个常见的选项是 Apache (httpd.apache.org/)。网络服务器可以直接服务请求,例如,通过直接返回静态文件、永久重定向或类似的简单请求。如果请求需要更多的计算,它将被重定向到后端,充当反向代理。
在所展示的架构中,网络服务器的主要目标是充当反向代理,接受 HTTP 请求,稳定数据输入,并排队处理传入的请求。
nginx 的基本配置可能看起来像这样。代码可在 GitHub 上找到:github.com/PacktPublishing/Python-Architecture-Patterns/blob/main/chapter_06_web_server/nginx_example.conf。
server {
listen 80 default_server;
listen [::]:80 default_server;
error_log /dev/stdout;
access_log /dev/stdout;
root /opt/;
location /static/ {
autoindex on;
try_files $uri $uri/ =404;
}
location / {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
uwsgi_pass unix:///tmp/uwsgi.sock;
include uwsgi_params;
}
}
指令server用于打开和关闭基本块,以定义如何服务数据。注意每行都以分号结尾。
在 nginx 术语中,每个服务器指令定义了一个虚拟服务器。通常只有一个,但也可以配置多个,例如,根据 DNS 地址定义不同的行为。
在内部,我们有一个基本配置,说明在哪个端口提供服务——在我们的例子中,端口 80 和 IPv4 和 IPv6 地址。default_server子句表示这是默认要使用的服务器:
listen 80 default_server;
listen [::]:80 default_server;
IPv4 是常见的四数字地址,如127.0.0.1。IPv6 更长,它被设计为 IPv4 的替代品。例如,一个 IPv6 地址可以表示为2001:0db8:0000:0000:0000:ff00:0042:7879。IPv4 地址已经耗尽,这意味着没有新的地址可用。从长远来看,IPv6 将提供足够的地址以避免这个问题,尽管 IPv4 仍然被广泛使用,并且可能还会继续使用很长时间。
接下来,我们定义静态文件的位置,包括外部 URL 以及与硬盘某个部分的映射。
注意静态位置需要在反向代理之前定义:
root /opt/;
location /static/ {
autoindex on;
try_files $uri $uri/ =404;
}
root定义了起点,而location开始一个将 URL /static/file1.txt从硬盘上的/opt/static/file1.txt文件中提供的服务部分。
try_files将扫描 URI 中的文件,如果不存在则引发 404 错误。
autoindex自动生成索引页面以检查目录的内容。
这个选项通常在生产服务器上被禁用,但在测试模式下运行时检测静态文件问题非常有用。
在生产环境中,直接从 Web 服务器提供静态文件,而不是在 Python 工作流中进一步处理,这是很重要的。虽然这是可能的,并且在开发环境中是一个常见情况,但它非常低效。速度和内存使用将大大增加,而 Web 服务器已经优化了提供静态文件。请始终记住,在生产环境中通过 Web 服务器提供静态文件。
外部提供静态内容
另一个选择是使用外部服务来处理文件,例如 AWS S3,它允许你提供静态文件。文件将位于与服务的不同 URL 下,例如:
-
服务的 URL 是
https://example.com/index -
静态文件在
https://mybucket.external-service/static/
因此,服务网页内的所有引用都应指向外部服务端点。
这种操作方式要求你将代码作为部署的一部分推送到外部服务。为了允许不间断的部署,请记住,静态内容需要在之前可用。另一个重要细节是使用不同的路径上传它们,以便部署之间的静态文件不会混淆。
使用不同的根路径来做这件事很容易。例如:
-
服务的
v1版本已部署。这是起点。静态内容从https://mybucket.external-service/static/v1/提供。对服务的调用,如
https://example.com/index,返回所有指向版本v1的静态内容。 -
一旦服务的
v2版本准备就绪,首先要做的就是将其推送到外部服务,以便在https://mybucket.external-service/static/v2/中可用。注意,在这个时候,没有用户访问/static/v2;服务仍然返回/static/v1。部署新服务。一旦部署完成,用户在调用
https://example.com/index时将开始访问/static/v2。
正如我们在前面的章节中看到的,无缝部署的关键是分小步骤执行操作,并且每一步都必须执行可逆的操作,并准备好地形,以便没有某个必需的元素尚未准备好的时刻。
这种方法可以用于大型操作。在一个 JavaScript 为主的界面,如单页应用程序中,更改静态文件实际上可以视为一个新的部署。底层服务 API 可以保持不变,但更改所有 JavaScript 代码和其他静态内容的下载版本,这实际上将部署一个新版本。
我们在 第二章 中讨论了单页应用程序。
这种结构使得静态内容的两个版本可以同时可用。这也可以用来进行测试或发布测试版。由于服务返回是否使用版本 A 或 B,这可以动态设置。
例如,在任意调用中添加一个可选参数来覆盖返回的版本:
-
调用
https://example.com/index返回默认版本,例如,v2。 -
调用
https://example.com/index?overwrite_static=v3将返回指定的版本,例如v3。
其他选项是为特定用户返回 v3,例如测试人员或内部员工。一旦 v3 被认为正确,可以通过对服务进行微小更改将其更改为新的默认版本。
这种方法可以极端到将任何单个提交推送到源控制到公共 S3 存储桶,然后在任何环境中进行测试,包括生产环境。这可以帮助生成一个非常快速的反馈循环,其中 QA 或产品所有者可以快速在自己的浏览器中看到更改,而无需进行任何部署或特殊环境。
不要将版本号限制为唯一的整数;它也可以与自动生成的随机 UUID 或内容的 SHA 一起工作。Web 存储相当便宜,所以只有当版本非常多且文件非常大时,才会真正开始担心成本。并且可以定期删除旧版本。
虽然这种方法可能非常激进,并不适用于所有应用程序,但对于需要在大型的 JavaScript 界面中进行许多更改或对外观和感觉进行重大更改的应用程序,它可以非常高效。
这种外部服务可以与 CDN(内容分发网络)支持的多区域代理结合使用。这将把文件分布到世界各地,为用户提供更接近的副本。
将 CDN 视为提供服务公司的内部缓存。例如,我们有一个服务,他们的服务器位于欧洲,但用户从日本访问它。这家公司在日本有服务器,存储了静态内容的副本。这意味着用户可以以比请求必须到达欧洲的服务器(超过 8000 公里)低得多的延迟访问文件。
使用 CDN 对于真正全球的受众来说非常强大。它们特别适用于需要全球低延迟的数据服务。例如,广播接近实时视频。
在线视频广播通常以持续几秒的小视频块形式传输。一个索引文件会记录最新生成的块是什么,这样客户端就可以保持最新状态。这是HTTP 实时流(HLS)格式的基础,这种格式非常常见,因为数据传输是通过 HTTP 直接进行的。
由于它们之间将使用专用网络而不是外部网络,因此 CDN 服务提供商的不同服务器之间可以非常快速地内部分发数据。
在任何情况下,使用外部服务来存储静态文件将显然消除为它们配置 Web 服务器的需要。
反向代理
让我们继续描述 Web 服务器配置。在描述静态文件之后,我们需要定义一个连接到后端,充当反向代理的连接。
反向代理是一种代理服务器,可以将接收到的请求重定向到一个或多个定义的后端。在我们的例子中,后端是 uWSGI 进程。
反向代理的工作方式与负载均衡器类似,尽管负载均衡器可以与更多协议一起工作,而反向代理只能处理 Web 请求。除了在不同服务器之间分配请求之外,它还可以添加一些功能,如缓存、安全性、SSL 终止(接收 HTTPS 请求并使用 HTTP 连接到其他服务器),或者在这种情况下,接收 Web 请求并通过 WSGI 连接将其传输。
Web 服务器将以多种方式与后端进行通信,从而提供灵活性。这可以使用不同的协议,如 FastCGI、SCGI、直接 HTTP 进行纯代理,或者在我们的情况下,直接连接到 uWSGI 协议。我们需要定义它,以便通过 TCP 套接字或 UNIX 套接字连接。我们将使用 UNIX 套接字。
TCP 套接字旨在允许不同服务器之间的通信,而 UNIX 套接字旨在本地通信进程。UNIX 套接字在相同主机内部通信时稍微轻量一些,并且它们的工作方式类似于文件,允许你分配权限以控制哪些进程可以访问哪些套接字。
套接字需要与 uWSGI 的配置方式相协调。正如我们稍后将会看到的,uWSGI 进程将创建它:
location / {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
include uwsgi_params;
uwsgi_pass unix:///tmp/uwsgi.sock;
}
首先,服务器的根目录位于 / URL。在反向代理之前制作静态内容非常重要,因为检查的顺序是按照位置进行的。所以任何对 /static 的请求都会在检查 / 之前被检测到,并且会得到适当的处理。
反向代理配置的核心是 uwsgi_pass 子句。它指定了请求重定向的位置。include uwgi_params 将添加一些标准配置传递到下一阶段。
uwsgi_params 实际上是一个在 nginx 配置中默认包含的已定义文件,它添加了许多带有 SERVER_NAME、REMOTE_ADDRESS 等元素的 uwsgi_param 语句。
如果需要,可以添加更多的 uwsgi_param,方法与头部类似。
可以添加额外的元素作为 HTTP 头部。它们将被添加到请求中,因此它们在请求的后续部分是可用的。
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
在这种情况下,我们添加了 Host 头部,其中包含有关请求的主机信息。请注意,$host 是 nginx 填充请求所指向的主机值的指示。同样,添加了 X-Real-IP 头部,其中包含远程地址的 IP 地址。
正确设置头部信息以传递是一个不受重视的工作,但这对正确监控问题至关重要。设置头部可能需要在不同的阶段进行。正如我们稍后将要讨论的,单个请求可以穿过多个代理,并且每个代理都需要充分转发头部信息。
在我们的配置中,我们只使用单个后端,因为 uWSGI 会在不同的工作者之间进行平衡。但是,如果需要,可以定义多个后端,甚至可以混合 UNIX 和 TCP 套接字,定义一个集群。
upstream uwsgibackends {
server unix:///tmp/uwsgi.sock;
server 192.168.1.117:8080;
server 10.0.0.6:8000;
}
然后,定义 uwsgi_pass 以使用集群。请求将在不同的后端之间均匀分配。
uwsgi_pass uwsgibackends;
日志记录
我们还需要跟踪任何可能的错误或访问。nginx(以及其他网络服务器)产生两个不同的日志:
-
错误日志:错误日志跟踪来自网络服务器本身的可能问题,如无法启动、配置问题等。
-
访问日志:访问日志报告任何访问系统的请求。这是系统流动的基本信息。它可以用来查找特定问题,如后端无法连接时的 502 错误,或者,当作为聚合处理时,可以检测到异常数量的错误状态代码(
4xx或5xx)。
我们将在第十一章中更详细地讨论日志。
这两个日志都是需要充分检测的关键信息。遵循十二要素应用原则,我们应该将它们视为数据流。最简单的方法是将它们都重定向到标准输出。
access_log /dev/stdout;
error_log /dev/stdout;
这要求 nginx 不要以守护进程的方式启动,或者如果它是以守护进程方式启动的,则正确捕获标准输出。
另一个选项是将日志重定向到集中日志设施,使用适当的协议。这将所有日志重定向到集中服务器,以捕获信息。在这个例子中,我们将其发送到 syslog_host 上的 syslog 主机。
error_log syslog:server=syslog_host:514;
access_log syslog:server=syslog_host:514,tag=nginx;
此协议允许您包含标签和额外信息,这些信息可以帮助在以后区分每个日志的来源。
能够区分每个日志的来源至关重要,并且始终需要一些调整。请确保花些时间使日志易于搜索。当生产中出现错误需要收集信息时,这将极大地简化工作。
高级用法
Web 服务器非常强大,不应被低估。除了纯粹作为代理外,还有很多其他功能可以启用,如返回自定义重定向、在维护窗口期间用静态页面覆盖代理、重写 URL 以调整更改、提供 SSL 终止(解密接收到的 HTTPS 请求,以便通过常规 HTTP 传递解密后的请求,并将结果加密回),缓存请求、根据百分比分割请求进行 A/B 测试、根据请求者的地理位置选择后端服务器等。
请务必阅读 nginx 的文档,地址为 nginx.org/en/docs/,以了解所有可能性。
uWSGI
链接的下一个元素是 uWSGI 应用程序。该应用程序接收来自 nginx 的请求并将它们重定向到独立的 Python 工作进程,以 WSGI 格式。
Web 服务器网关接口(WSGI)是处理 Web 请求的 Python 标准。它非常流行,并且得到了许多软件的支持,包括发送端(如 nginx,以及其他 Web 服务器,如 Apache 和 GUnicorn)和接收端(几乎每个 Python Web 框架,如 Django、Flask 或 Pyramid)。
uWSGI 还将启动和协调不同的进程,处理每个进程的生命周期。应用程序作为一个中介,启动一组接收请求的工作进程。
uWSGI 通过 uwsgi.ini 文件进行配置。让我们看看一个例子,可在 GitHub 上找到,地址为 github.com/PacktPublishing/Python-Architecture-Patterns/blob/main/chapter_06_web_server/uwsgi_example.uni。
[uwsgi]
chdir=/root/directory
wsgi-file = webapplication/wsgi.py
master=True
socket=/tmp/uwsgi.sock
vacuum=True
processes=1
max-requests=5000
# Used to send commands to uWSGI
master-fifo=/tmp/uwsgi-fifo
第一个元素定义了工作目录是什么。应用程序将在这里启动,其他文件引用也将从这里开始:
chdir=/root/directory
然后,我们描述 wsgi.py 文件的位置,该文件描述了我们的应用程序。
WSGI 应用程序
在此文件中是 application 函数的定义,uWSGI 可以以受控的方式使用它来访问内部 Python 代码。
例如:
def application(environ, start_response):
start_response('200 OK', [('Content-Type', 'text/plain')])
return [b'Body of the response\n']
第一个参数是一个包含预定义变量的字典,这些变量详细说明了请求(如 METHOD、PATH_INFO、CONTENT_TYPE 等)以及与协议或环境相关的参数(例如,wsgi.version)。
第二个参数 start_response 是一个可调用对象,允许您设置返回状态和任何头信息。
函数应该返回体。注意它是以字节流格式返回的。
文本流(或字符串)与字节流之间的区别是 Python 3 引入的很大区别之一。为了总结,字节流是原始的二进制数据,而文本流通过特定的编码解释这些数据来包含意义。
这两种之间的区别有时可能有些令人困惑,尤其是在 Python 3 使这种区别变得明确的情况下,这与其他一些先前宽松的做法相冲突,尤其是在处理可以用相同方式表示的 ASCII 内容时。
请记住,文本流需要被编码才能转换为字节流,字节流需要被解码为文本流。编码是将文本的抽象表示转换为精确的二进制表示。
例如,西班牙语单词 "cañón" 包含两个在 ASCII 中不存在的字符,ñ 和 ó。您可以看到通过 UTF8 编码它们是如何被替换为 UTF8 中描述的特定二进制元素的:
>>> 'cañón'.encode('utf-8')
b'ca\xc3\xb1\xc3\xb3n'
>>> b'ca\xc3\xb1\xc3\xb3n'.decode('utf-8')
'cañón'
该函数还可以作为生成器工作,并在返回体需要流式传输时使用关键字 yield 而不是 return。
任何使用 yield 的函数在 Python 中都是生成器。这意味着当被调用时,它返回一个迭代器对象,逐个返回元素,通常用于循环中。
这对于每种循环元素需要一些时间来处理但可以返回而不必计算每个单独的项目的情况非常有用,从而减少延迟和内存使用,因为不需要在内存中维护所有元素。
>>> def mygenerator():
... yield 1
... yield 2
... yield 3
>>> for i in mygenerator():
... print(i)
...
1
2
3
在任何情况下,WSGI 文件通常由使用的框架默认创建。例如,Django 创建的 wsgi.py 文件看起来像这样。
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "webapplication.settings")
application = get_wsgi_application()
注意函数 get_wsgi_application 将如何自动设置正确的应用程序函数,并将其与定义的其余代码连接起来——这是使用现有框架的一个巨大优势!
与网络服务器交互
让我们继续使用 uwsgi.ini 配置文件中的套接字配置:
socket=/tmp/uwsgi.sock
vacuum=True
socket 参数为网络服务器创建用于连接的 UNIX 套接字。这在本章之前讨论网络服务器时已经讨论过。这需要在双方进行协调,以确保它们能够正确连接。
uWSGI 还允许您使用本机 HTTP 套接字,使用选项 http-socket。例如,http-socket = 0.0.0.0:8000 用于在端口 8000 上服务所有地址。如果您使用的网络服务器不在同一服务器上并且需要通过网络进行通信,则可以使用此选项。
当可能时,避免直接将 uWSGI 公开暴露在互联网上。使用一个网络服务器会更安全、更高效。它还能更高效地服务静态内容。如果你确实必须跳过网络服务器,请使用选项 http 而不是 http-socket,后者包含一定程度的保护。
vacuum 选项在服务器关闭时清理套接字。
进程
下一个参数控制进程的数量以及如何控制它们:
master=True
processes=1
master 参数创建一个主进程,确保工作进程的数量正确,如果不正确则重启,并处理进程生命周期等任务。在生产环境中,为了平稳运行,应始终启用此参数。
processes 参数非常直接,描述了应该启动多少个 Python 工作进程。接收到的请求将在它们之间进行负载均衡。
uWSGI 生成新进程的方式是通过预分叉。这意味着启动一个进程,在应用程序加载(可能需要一段时间)之后,通过分叉进程进行克隆。这合理地加快了新进程的启动时间,但同时也意味着应用程序的设置可以被复制。
这种假设在罕见情况下可能会导致某些库出现问题,例如,在初始化期间打开文件描述符,而这些文件描述符无法安全共享。如果是这种情况,参数 lazy-apps 将使每个工作进程从头开始独立启动。这会慢一些,但会创建更一致的结果。
选择正确的进程数量高度依赖于应用程序本身及其支持的硬件。硬件很重要,因为具有多个核心的 CPU 将能够更高效地运行更多进程。应用程序中的 IO 与 CPU 使用量将决定 CPU 核心可以运行多少个进程。
理论上,不使用 IO 且纯粹进行数值计算的进程将使用整个核心而无需等待时间,不允许核心在此期间切换到另一个进程。具有高 IO 的进程,在等待数据库和外部服务的结果时核心空闲,将通过执行更多上下文切换来提高其效率。这个数字应该经过测试以确保最佳结果。一个常见的起点是核心数的两倍,但请记住监控系统以调整它并获得最佳结果。
关于创建的进程的一个重要细节是,它们默认禁用新线程的创建。这是一个优化选择。在大多数网络应用程序中,不需要在每个工作进程内部创建独立的线程,这允许你禁用 Python GIL,从而加快代码执行。
全局解释器锁(Global Interpreter Lock)或GIL是一种互斥锁,它只允许单个线程控制 Python 进程。这意味着在单个进程中,不会有两个线程同时运行,这是多核 CPU 架构使得成为可能的事情。请注意,当另一个线程运行时,多个线程可能正在等待 IO 结果,这在实际应用中是一种常见情况。GIL 通常会被频繁地获取和释放,因为每个操作首先获取 GIL,然后在结束时释放它。
GIL 通常被指责是 Python 效率低下的原因,尽管这种影响只有在原生 Python 中的高 CPU 多线程操作(与使用优化库如 NumPy 相比)中才会感觉到,而这些操作并不常见,而且它们本身就启动缓慢。
与 GIL 的这些交互只有在没有线程将运行的情况下才是浪费的,这就是为什么 uWSGI 默认禁用它的原因。
如果需要使用线程,enable-threads选项将启用它们。
进程生命周期
在操作期间,进程不会保持静态。任何正在运行的网络应用都需要定期重新加载以应用新的代码更改。接下来的参数与进程的创建和销毁有关。
max-requests=5000
# Used to send commands to uWSGI
master-fifo=/tmp/uwsgi-fifo
max-requests指定了单个工作进程在重启之前需要处理的请求数量。一旦工作进程达到这个数量,uWSGI 将销毁它,并从头开始创建另一个工作进程,遵循常规流程(默认为 fork,或者如果配置了lazy-apps,则使用lazy-apps)。
这对于避免内存泄漏或其他类型的陈旧问题很有用,这些问题会导致工作进程的性能随时间下降。回收工作进程是一种可以预先采取的保护措施,因此即使存在问题,它也会在造成任何问题之前得到纠正。
记住,根据十二要素应用,网络工作进程需要能够在任何时候停止和启动,因此这种回收是无痛的。
当工作进程空闲时,uWSGI 也会回收工作进程,在服务了 5,000 次请求之后,这将是一个受控的操作。
请记住,这种回收可能会干扰其他操作。根据启动时间,启动工作进程可能需要几秒钟,或者更糟(特别是如果使用了lazy-apps),这可能会创建请求数据的积压。uWSGI 将排队等待的请求数据。在我们的示例配置中,processes中只定义了一个工作进程。如果有多个工作进程,这可以通过其他工作进程处理额外的负载来减轻。
当涉及多个工作者时,如果他们中的每一个在完成 5,000 次请求后都会重启,可能会产生一种群羊效应,其中所有工作者一个接一个地被回收。请记住,负载在工作者之间是平均分配的,因此这个计数将在多个工作者之间同步。虽然预期例如在 16 个工作者的系统中,至少有 15 个是可用的,但在实践中我们可能会发现所有工作者同时被回收。
为了避免这个问题,使用max-requests-delta参数。此参数为每个工作者添加一个可变数字。它将乘以工作者 ID 的 Delta(每个工作者从 1 开始的唯一连续数字)。因此,配置 Delta 为 200,每个工作者将具有以下配置:
| 工作者 | 基础最大请求 | Delta | 总回收请求 |
|---|---|---|---|
| 工作者 1 | 5,000 | 1 * 200 | 5,200 |
| 工作者 2 | 5,000 | 2 * 200 | 5,400 |
| 工作者 3 | 5,000 | 3 * 200 | 5,600 |
| … | |||
| 工作者 16 | 5,000 | 16 * 200 | 8,200 |
这使得回收在不同的时间发生,增加了同时可用的工人数量,因为它们不会同时重启。
这个问题与所谓的缓存群羊效应是同一类型的。这是在多个缓存值同时失效时产生的,同时产生值的再生。因为系统期望在某种缓存加速下运行,突然需要重新创建缓存的重要部分可能会产生严重的性能问题,甚至可能导致系统完全崩溃。
为了避免这种情况,避免为缓存设置固定的过期时间,例如时钟的某个小时。例如,如果后端在午夜更新了当天的新闻,这可能会诱使你在此时过期缓存。相反,添加一个元素使不同的键在略微不同的时间过期以避免这个问题。这可以通过为每个键添加一小段时间的随机量来实现,这样它们就可以在不同的时间可靠地刷新。
master-fifo参数创建了一种与 uWSGI 通信并发送命令的方式:
# Used to send commands to uWSGI
master-fifo=/tmp/uwsgi-fifo
这在/tmp/uwsgi-fifo中创建了一个 UNIX 套接字,可以接收以字符形式重定向到它的命令。例如:
# Generate a graceful reload
echo r >> /tmp/uwsgi-fifo
# Graceful stop of the server
echo q >> /tmp/uwsgi-fifo
与发送信号相比,这种方法可以更好地处理情况,因为提供了更多的命令,并且允许对进程和整个 uWSGI 进行相当细粒度的控制。
例如,发送Q将直接关闭 uWSGI,而q将产生一个优雅的关闭。优雅的关闭将首先停止 uWSGI 接受新的请求,然后等待直到内部 uWSGI 队列中的任何请求正在处理,当一个工作者完成其请求后,有序地停止它。最后,当所有工作者都完成后,停止 uWSGI 主进程。
使用 r 键优雅地重新加载与保持请求在内部队列中并等待工作者完成以停止和重新启动它们的方法类似。它还会加载与 uWSGI 本身相关的任何新配置。请注意,在操作期间,内部 uWSGI 监听队列可能会填满,导致问题。
监听队列的大小可以通过 listen 参数进行调整,但请记住,Linux 设置了一个限制,您可能需要更改它。默认值为监听 100,Linux 配置为 128。
在更改这些值之前进行测试,因为处理大量任务队列有其自身的问题。
如果进程的加载是通过 fork 进程完成的,则在启动第一个进程后,其余的将是副本,因此它们将很快被加载。相比之下,使用 lazy-apps 可能会延迟达到满负荷,因为每个单独的工作者都需要从头开始单独启动。这可能会根据工作者的数量和启动程序产生额外的服务器负载。
对于 lazy-apps 的一个可能替代方案是使用 c 选项,通过链式重新加载来重新加载工作者。这将独立地重新加载每个工作者,等待单个工作者完全重新加载后再进行下一个。此过程不会重新加载 uWSGI 配置,但会处理工作者中的代码更改。这将花费更长的时间,但将以控制器速度工作。
在负载下重新加载单个服务器可能很复杂。使用多个 uWSGI 服务器可以简化这个过程。在这种情况下,重新加载应在不同的时间进行,以便您可以分配负载。
可以采用集群式方法使用多个服务器来执行此操作,在多个服务器中创建 uWSGI 配置的副本,然后逐个回收它们。当其中一个正在重新加载时,其他服务器将能够处理额外的负载。在极端情况下,可以使用额外的服务器在重新加载期间产生额外的容量。
这在云环境中很常见,其中可以使用额外的服务器,然后将其销毁。在 Docker 情况下,可以添加新的容器以提供额外的容量。
有关 master-fifo 和接受命令的更多信息,包括如何暂停和恢复实例,以及其他异构操作,请参阅 uWSGI 文档uwsgi-docs.readthedocs.io/en/latest/MasterFIFO.html。
uWSGI 是一个非常强大的应用程序,具有几乎无限多的配置可能性。其文档包含大量细节,令人印象深刻,但内容全面且富有洞察力。你可以学到很多,不仅关于 uWSGI,还关于整个 Web 栈是如何工作的。我强烈建议您慢慢地、坚定地学习,以获得很多知识。您可以在uwsgi-docs.readthedocs.io/访问文档。
Python 工作进程
系统的核心是 Python WSGI 工作进程。该工作进程在经过外部 Web 服务器等路由后,从 uWSGI 接收 HTTP 请求。
这就是魔法发生的地方,并且它对应用程序来说是特定的。这是将比链中的其他链接更快迭代元素。
每个框架与请求的交互方式略有不同,但总体上,它们将遵循类似的模式。我们将以 Django 为例。
我们不会讨论 Django 的所有方面,也不会深入探讨其功能,但会选取一些对其他框架有用的课程。
Django 项目有非常好的文档。说真的,自从项目开始以来,它一直以其世界级的文档而闻名。您可以在这里阅读:www.djangoproject.com。
Django MVT 架构
Django 大量借鉴了 MVC 结构,但稍作调整,形成了所谓的MVT(模型-视图-模板):
-
模型保持不变,数据的表示和与存储的交互。
-
视图接收 HTTP 请求并处理它,与可能需要的不同模型进行交互。
-
模板是一个生成 HTML 文件的系统,从传递的值中生成。
虽然这稍微改变了模型-视图-控制器,但结果相似。

图 6.3:模型-视图-控制器
在两个系统中,模型的工作方式相同。Django 视图充当视图和控制器组合的角色,模板是 Django 视图视图组件的辅助系统。
模板系统并非必须使用,因为并非每个 Django 接口都需要作为结果的 HTML 页面。
虽然 Django 被设计用来创建 HTML 界面,但有一些方法可以创建其他类型的界面。特别是,对于 RESTful 界面,Django REST 框架(www.django-rest-framework.org)允许您轻松扩展功能并生成自文档化的 RESTful 界面。
我们将在本章后面讨论 Django REST 框架。
Django 是一个强大且全面的框架,并对事物应该如何运行有一些假设,例如使用 Django ORM 或使用其模板系统。虽然这样做是“顺流而行”,但肯定可以采取其他方法并定制系统的任何部分。这可能包括不使用模板、使用不同的模板系统、使用不同的 ORM 库如 SQLAlchemy,以及添加额外的库以连接到不同的数据库,包括 Django 本身不支持的原生数据库(如 NoSQL 数据库)。不要让系统的限制限制您实现目标。
Django 在展示元素的方式上持有一定的观点,这些元素以一定的假设协同工作。它们彼此紧密相关。如果这成为障碍,例如,因为您需要使用完全不同的工具,一个好的替代方案可以是 Pyramid (trypyramid.com),这是一个旨在构建您自己的工具组合的 Python 网络框架,以确保灵活性。
将请求路由到视图
Django 提供了从特定 URL 到特定视图的正确路由的工具。
这是在 urls.py 文件中完成的。让我们看看一个例子。
from django.urls import path
from views import first_view, second_view
urlpatterns = [
path('example/', first_view)
path('example/<int:parameter>/<slug:other_parameter>', second_view)
]
所需的视图(通常声明为函数)从它们当前所在的模块导入到文件中。
urlpatterns 列表定义了一个按顺序排列的 URL 模式列表,这些模式将针对输入 URL 进行测试。
第一个 path 定义非常直接。如果 URL 是 example/,它将调用视图 first_view。
第二个 path 定义包含捕获参数的定义。它将正确转换定义的参数并将它们传递给视图。例如,URL example/15/example-slug 将创建以下参数:
-
parameter=int(15) -
other_parameter=str("example-slug")
可以配置不同类型的参数。int 是不言自明的,但 slug 是一个有限的字符串,它将只包含字母数字、_(下划线)和 –(破折号)符号,不包括像 . 或其他符号的字符。
还有更多类型可用。还有一个 str 类型可能过于宽泛。字符 / 在 URL 中被视为特殊字符,并且始终被排除。这允许轻松分离参数。slug 类型应该覆盖 URL 内参数的更多典型用例。
另一个选项是直接作为正则表达式生成路径。如果您熟悉正则表达式格式,这将非常强大,并允许有大量的控制。同时,正则表达式可能会变得非常复杂,难以阅读和使用。
from django.urls import re_path
urlpatterns = [
re_path('example/(?P<parameter>\d+)/', view)
]
这是在之前的 Django 中可用的唯一选项。正如您在示例中看到的,新的路径定义的 URL 模式更容易阅读和处理,相当于 example/<int:parameter>/。
一个中间方案是定义类型以确保它们匹配特定的值,例如,创建一个只匹配像Apr或Jun这样的月份的类型。如果以这种方式定义类型,像Jen这样的错误模式将自动返回 404。内部来说,这仍然需要编写一个正则表达式来匹配正确的字符串,但之后它可以转换值。例如,将月份Jun转换为数字 1,将其规范化为JUNE,或任何其他后续有意义的值。正则表达式的复杂性将由类型抽象化。
请记住,模式是按顺序检查的。这意味着,如果一个模式可能满足两个路径,它将选择第一个。当先前的路径“隐藏”下一个路径时,这可能会产生意想不到的影响,因此最不限制的模式应该放在后面。
例如:
from django.urls import path
urlpatterns = [
path('example/<str:parameter>/', first_view)
path('example/<int:parameter>/', second_view)
]
没有任何 URL 会被传递到second_view,因为任何整数参数都会首先被捕获。
这种错误通常在大多数 Web 框架的 URL 路由器中是可能的,因为它们大多数都是基于模式的。请注意,它是否会影响你的代码。
有趣的事情发生在视图内部。
视图
视图是 Django 的核心元素。它接收请求信息,以及来自 URL 的任何参数,并对其进行处理。视图通常将使用不同的模型来组合信息,并最终返回一个响应。
视图负责根据请求决定是否有任何行为上的变化。请注意,路由到视图的路径仅区分不同的路径,但其他区分,如 HTTP 方法或参数,需要在这里进行区分。
这使得区分对同一 URL 的 POST 和 GET 请求成为一种非常常见的模式。在网页中的一种常见用法是创建一个表单页面来显示空表单,然后对同一 URL 进行 POST。例如,在一个只有一个参数的表单中,结构将类似于以下示例:
这是为了不使伪代码变得复杂。
def example_view(request):
# create an empty form
form_content = Form()
if request.method == 'POST':
# Obtain the value
value = request.POST['my-value']
if validate(value):
# Perform actions based on the value
do_stuff()
content = 'Thanks for your answer'
else:
content = 'Sorry, this is incorrect' + form_content
elif request.method == 'GET':
content = form_content
return render(content)
虽然 Django 确实包含一个简化表单验证和报告的表单系统,但这种结构可能会变得复杂且令人疲惫。特别是,多个嵌套的if块让人困惑。
我们不会深入探讨 Django 中的表单系统。它相当完整,允许你渲染丰富的 HTML 表单,这些表单将验证并显示可能的错误给用户。阅读 Django 文档以了解更多信息。
相反,通过两个不同的子函数来划分视图可能更清晰。
def display_form(form_content, message=''):
content = message + form_content
return content
def process_data(parameters, form_content):
# Obtain the value
if validate(parameters):
# Perform actions based on the value
do_stuff()
content = 'Thanks for your answer'
else:
message = 'Sorry, this is incorrect'
content = display_form(form_content , message)
return content
def example_view(request):
# create an empty form
form_content = Form()
if request.method == 'POST':
content = process_data(request.POST, form_content)
elif request.method == 'GET':
content = display_form(form_content)
return render(content)
这里的挑战是保留这样一个事实,即当参数不正确时,表单需要重新渲染。根据DRY(不要重复自己)的原则,我们应该尝试将这段代码定位在单一位置。在这里,在display_form函数中。我们允许对消息进行一些定制,以添加一些额外内容,以防数据不正确。
在一个更完整的示例中,表单将被调整以显示特定的错误。Django 表单能够自动完成这项工作。这个过程是创建一个带有请求参数的表单,验证它,并打印它。它将自动生成适当的错误消息,基于每个字段的类型,包括自定义类型。再次,请参阅 Django 的文档以获取更多信息。
注意,display_form 函数既从 example_view 调用,也来自 process_data 内部。
HttpRequest
传递信息的关键元素是 request 参数。此对象类型为 HttpRequest,包含用户在请求中发送的所有信息。
它最重要的属性包括:
-
method,它包含使用的 HTTP 方法。 -
如果方法是
GET,它将包含一个GET属性,其中包含请求中所有查询参数的QueryDict(字典子类)。例如,一个请求如下:/example?param1=1¶m2=text¶m1=2将生成一个类似
request.GET的值:<QueryDict: {'param1': ['1', '2'], 'param2': ['text']}>注意,参数在内部作为值的列表存储,因为查询参数接受具有相同键的多个参数,尽管通常不是这种情况。查询时它们仍将返回一个唯一值:
>>> request.GET['param1'] 2 >>> request.GET['param2'] text
它们将按顺序报告,返回最新的值。如果您需要访问所有值,请使用 getlist 方法:
>>> request.GET.getlist('param1')
['1', '2']
所有参数都定义为字符串,如果需要,需要转换为其他类型。
- 如果方法是
POST,将创建一个类似的POST属性。在这种情况下,它将首先由请求体填充,以便允许编码表单提交。如果请求体为空,它将使用查询参数填充值,就像GET的情况一样。
在多选表单中,通常会用 POST 多个值。
-
content_type,包含请求的 MIME 类型。 -
FILES,包括请求中任何上传文件的请求,对于某些POST请求。 -
headers,一个包含请求和头信息的字典。另一个字典META包含可能引入的额外信息,这些信息不一定是基于 HTTP 的,如SERVER_NAME。通常,从headers属性获取信息更好。
此外,还有一些有用的方法可以检索请求中的信息,例如:
-
使用
.get_host()获取主机名。它将解释不同的头信息以确定正确的宿主,因此比直接读取HTTP_HOST头更可靠。 -
使用
.build_absolute_uri(location)生成完整的 URI,包括主机、端口等。此方法对于创建完整的引用以返回它们非常有用。
这些属性和方法,结合请求中描述的参数,允许您检索处理请求和调用所需模型的所有相关信息。
HttpResponse
HttpResponse类处理视图返回给 Web 服务器的信息。视图函数的返回值需要是一个HttpResponse对象。
from django.http import HttpResponse
def my_view(request):
return HttpResponse(content="example text", status_code=200)
如果未指定,响应的默认status_code为 200。
如果响应需要分几个步骤写入,可以通过.write()方法添加。
response = HttpResponse()
response.write('First part of the body')
response.write('Second part of the body')
主体也可以由可迭代对象组成。
body= ['Multiple ', 'data ', 'that ', 'will ', 'be ', 'composed']
response = HttpResponse(content=body)
所有来自HttpResponse的响应在返回之前都将完全组合。可以以流式方式返回响应,这意味着首先返回状态码,然后随着时间的推移发送主体块。为此,还有一个名为StreamingHttpResponse的类,它将以这种方式工作,并且对于在一段时间内发送大响应可能很有用。
使用整数定义状态码不如使用 Python 中定义的常量好,例如:
from django.http import HttpResponse
from http import HTTPStatus
def my_view(request):
return HttpResponse(content="example text", status_code=HTTPStatus.OK)
这样可以使每个状态码的使用更加明确,并有助于提高代码的可读性,使它们明确地成为HTTPStatus对象。
你可以在 Python 中看到所有定义的状态码:docs.python.org/3/library/http.html。注意名称是它们的标准 HTTP 状态码名称,如定义在多个 RFC 文档中,例如,201 CREATED,404 NOT FOUND,502 BAD GATEWAY等。
content参数定义了请求的主体。它可以描述为一个 Python 字符串,但如果响应不是纯文本,它也接受二进制数据。如果是这种情况,应该添加一个content_type参数,以便适当地用正确的 MIME 类型标记数据。
HttpResponse(content=img_data, content_type="image/png")
返回的Content-Type与主体的格式相匹配非常重要。这将使任何其他工具,如浏览器,正确地适当地解释内容。
可以使用headers参数向响应添加头。
headers = {
'Content-Type': 'application/pdf',
'Content-Disposition': 'attachment; filename="report.pdf"',
}
response = HttpResponse(content=img_data, headers=header)
可以使用Content-Disposition将响应标记为附件,以便下载到硬盘。
此外,我们还可以通过headers参数或直接通过content_type参数手动设置Content-Type头。
当响应作为字典访问时,头也会存储在响应中。
response['Content-Disposition'] = 'attachment; filename="myreport.pdf"'
del response['Content-Disposition']
对于常见情况,有专门的子类。对于 JSON 编码的请求,与其使用通用的HttpResponse,不如使用JsonResponse,这将正确填充Content-Type并对其进行编码:
from django.http import JsonResponse
response = JsonResponse({'example': 1, 'key': 'body'})
以同样的方式,FileResponse允许你直接下载文件,提供一个类似文件的对象,并直接填充头和内容类型,包括如果它需要作为附件。
from django.http import FileResponse
file_object = open('report.pdf', 'rb')
response = FileResponse(file_object, is_attachment=True)
响应也可以通过渲染模板来创建。这是 HTML 界面的常用方法,这正是 Django 最初被设计的目的。render函数将自动返回一个HttpResponse对象。
from django.shortcuts import render
def my_view(request):
...
return render(request, 'mytemplate.html')
中间件
WSGI 请求中的一个关键概念是它们可以被链式处理。这意味着请求可以经过不同的阶段,在每个阶段围绕原始请求包装一个新的请求,这允许你添加功能。
这导致了中间件的概念。中间件通过简化请求的多个方面、添加功能或简化其使用来改进系统之间的处理。
中间件这个词可以根据其使用的上下文指代不同的概念。在 HTTP 服务器环境中使用时,通常指的是增强或简化请求处理的插件。
中间件的典型例子是以标准方式记录每个接收到的请求。中间件将接收请求,生成日志,并将请求交给下一级。
另一个例子是管理用户是否已登录。有一个标准的 Django 中间件可以检测存储在 cookie 中的任何会话,并在数据库中搜索关联的用户。然后,它将填充request.user对象以包含正确的用户。
另一个例子是 Django 默认启用的,它检查POST请求上的 CSRF 令牌。如果 CSRF 令牌不存在或是不正确的,请求将被立即拦截,并返回403 FORBIDDEN,在访问视图代码之前。
我们在第二章中介绍了 CSRF 和令牌的概念。
中间件可以在请求接收时以及响应准备时访问请求,因此它们可以在协调中在任一侧或两侧工作:
-
生成包含接收到的请求路径和方法的日志的日志中间件可以在请求发送到视图之前生成。
-
同时记录状态码的日志中间件需要具有状态码的信息,因此它需要在视图完成并且响应准备就绪后执行。
-
记录生成请求所需时间的日志中间件需要首先注册请求接收的时间以及响应准备的时间,以便记录差异。这需要在视图前后编写代码。
中间件是这样定义的:
def example_middleware(get_response):
# The example_middleware wraps the actual middleware
def middleware(request):
# Any code to be executed before the view
# should be located here
response = get_response(request)
# Code to be executed after the view
# should be located here
return response
return middleware
返回函数的结构允许初始化链式元素。输入get_reponse可以是另一个中间件函数,或者可以是最终的视图。这允许这种结构:
chain = middleware_one(middleware_two(my_view))
final_response = chain(request)
中间件的顺序也很重要。例如,日志应该在任何可能停止请求的中间件之前发生,如果顺序相反,任何被拒绝的请求(例如,没有添加适当的 CSRF)将不会被记录。
通常,中间件函数有一些关于它们应该位于何处的地方建议。有些比其他更敏感于它们的位置。请检查每个的文档。
中间件可以轻松添加,无论是自定义的还是使用第三方选项。有许多包为 Django 创建了具有有用功能的自定义中间件函数。在考虑添加新功能时,花点时间搜索看看是否已有现成的功能。
Django REST 框架
虽然 Django 最初是为了支持 HTML 界面而设计的,但其功能已经得到了扩展,包括 Django 项目本身的新功能,以及其他增强 Django 的外部项目。
其中一个特别有趣的是 Django REST 框架。我们将用它作为可用可能性的示例。
Django REST 框架不仅是一个流行且强大的模块。它还使用了许多在多种编程语言的 REST 框架中常见的约定。
在我们的示例中,我们将实现我们在第二章中定义的一些端点。我们将使用以下端点,以跟踪微帖的整个生命周期。
| 端点 | 方法 | 操作 |
|---|---|---|
/api/users/<username>/collection |
GET |
从用户检索所有微帖 |
/api/users/<username>/collection |
POST |
为用户创建一个新的微帖 |
/api/users/<username>/collection/<micropost_id> |
GET |
检索单个微帖 |
/api/users/<username>/collection/<micropost_id> |
PUT, PATCH |
更新一个微帖 |
/api/users/<username>/collection/<micropost_id> |
DELETE |
删除一个微帖 |
Django REST 框架背后的基本原理是创建不同的类,这些类封装了作为 URL 公开的资源。
另一个概念是,对象将通过序列化器从内部模型转换为外部 JSON 对象,反之亦然。序列化器将处理创建并验证外部数据是否正确。
序列化器不仅可以转换模型对象,还可以转换任何内部 Python 类。你可以使用它们创建“虚拟对象”,这些对象可以从多个模型中提取信息。
Django REST 框架的一个特点是序列化器对于输入和输出是相同的。在其他框架中,有不同的模块用于输入和输出方式。
模型
我们首先需要介绍用于存储信息的模型。我们将使用Usr模型来存储用户,以及Micropost模型。
from django.db import models
class Usr(models.Model):
username = models.CharField(max_length=50)
class Micropost(models.Model):
user = models.ForeignKey(Usr, on_delete=models.CASCADE,
related_name='owner')
text = models.CharField(max_length=300)
referenced = models.ForeignKey(Usr, null=True,
on_delete=models.CASCADE,
related_name='reference')
timestamp = models.DateTimeField(auto_now=True
Usr模型非常简单,只存储用户名。Micropost模型存储一段文本和创建该微帖的用户。可选地,它可以存储一个引用用户。
注意,关系有自己的命名反向引用,reference和owner。这些默认由 Django 创建,因此你可以搜索Usr被引用的地方,例如。
还要注意,text允许 300 个字符,而不是我们之前在 API 中说的 255 个字符。这是为了在数据库中留出更多空间。我们稍后会保护更多字符。
URL 路由
基于这些信息,我们创建了两个不同的视图,一个对应于我们需要创建的每个 URL。它们将被命名为 MicropostsListView 和 MicropostView。让我们首先看看 urls.py 文件中如何定义这些 URL:
from django.urls import path
from . import views
urlpatterns = [
path('users/<username>/collection', views.MicropostsListView.as_view(),
name='user-collection'),
path('users/<username>/collection/<pk>', views.MicropostView.as_view(),
name='micropost-detail'),
]
注意,有两个 URL,对应于这个定义:
/api/users/<username>/collection
/api/users/<username>/collection/<micropost_id>
每个都映射到相应的视图。
视图
每个视图都继承自适当的 API 端点,集合视图从 ListCreateAPIView 继承,它定义了 LIST(GET)和 CREATE(POST)的操作:
from rest_framework.generics import ListCreateAPIView
from .models import Micropost, Usr
from .serializers import MicropostSerializer
class MicropostsListView(ListCreateAPIView):
serializer_class = MicropostSerializer
def get_queryset(self):
result = Micropost.objects.filter(
user__username=self.kwargs['username']
)
return result
def perform_create(self, serializer):
user = Usr.objects.get(username=self.kwargs['username'])
serializer.save(user=user)
我们稍后会检查序列化器。该类需要定义当类中的 LIST 部分被调用时它将使用的查询集。由于我们的 URL 包含用户名,我们需要识别它:
def get_queryset(self):
result = Micropost.objects.filter(
user__username=self.kwargs['username']
)
return result
self.kwargs['username'] 将检索在 URL 中定义的用户名。
对于 CREATE 部分,我们需要重写 perform_create 方法。该方法接收一个序列化器参数,该参数已经包含验证过的参数。
我们需要从相同的 self.kwargs 中获取用户名和用户,以确保将其添加到 Micropost 对象的创建中。
def perform_create(self, serializer):
user = Usr.objects.get(username=self.kwargs['username'])
serializer.save(user=user)
新的对象是通过结合用户和其他数据创建的,这些数据作为序列化器的 save 方法的一部分添加。
单个视图遵循类似的模式,但不需要重写创建:
from rest_framework.generics import ListCreateAPIView
from .models import Micropost, Usr
from .serializers import MicropostSerializer
class MicropostView(RetrieveUpdateDestroyAPIView):
serializer_class = MicropostSerializer
def get_queryset(self):
result = Micropost.objects.filter(
user__username=self.kwargs['username']
)
return result
在这种情况下,我们允许更多的操作:RETRIEVE(GET)、UPDATE(PUT 和 PATCH)以及 DESTROY(DELETE)。
序列化器
序列化器将模型对象的 Python 对象转换为 JSON 结果,反之亦然。序列化器定义如下:
from .models import Micropost, Usr
from rest_framework import serializers
class MicropostSerializer(serializers.ModelSerializer):
href = MicropostHyperlink(source='*', read_only=True)
text = serializers.CharField(max_length=255)
referenced = serializers.SlugRelatedField(queryset=Usr.objects.all(),
slug_field='username',
allow_null=True)
user = serializers.CharField(source='user.username', read_only=True)
class Meta:
model = Micropost
fields = ['href', 'id', 'text', 'referenced', 'timestamp', 'user']
ModelSerializer 将自动检测在 Meta 子类中定义的模型中的字段。我们在 fields 部分指定了要包含的字段。注意,除了直接翻译的字段 id 和 timestamp 之外,我们还包含了其他会变化的字段(user、text、referenced)以及一个额外的字段(href)。直接翻译的字段很简单;我们那里不需要做任何事情。
text 字段再次被描述为 CharField,但这次我们限制了字符的最大数量。
user 字段也被重新描述为 CharField,但使用源参数我们将其定义为引用用户的用户名。该字段被定义为 read_only。
referenced 与之类似,但我们需要将其定义为 SlugRelatedField,以便它理解这是一个引用。一个 slug 是一个引用值的字符串。我们定义 slug_field 是引用的用户名,并添加查询集以允许搜索它。
href 字段需要一个额外定义的类来创建正确的 URL 引用。让我们详细看看:
from .models import Micropost, Usr
from rest_framework import serializers
from rest_framework.reverse import reverse
class MicropostHyperlink(serializers.HyperlinkedRelatedField):
view_name = 'micropost-detail'
def get_url(self, obj, view_name, request, format):
url_kwargs = {
'pk': obj.pk,
'username': obj.user.username,
}
result = reverse(view_name, kwargs=url_kwargs, request=request,
format=format)
return result
class MicropostSerializer(serializers.ModelSerializer):
href = MicropostHyperlink(source='*', read_only=True)
...
view_name描述了将要使用的 URL。reverse调用将参数转换成正确的完整 URL。这被封装在get_url方法中。此方法主要接收带有完整对象的obj参数。这个完整对象是在序列化器中对MicropostHyperlink类进行source='*'调用时定义的。
所有这些因素的组合使得界面能够正确工作。Django REST 框架也可以创建一个界面来帮助你可视化整个界面并使用它。
例如,列表将看起来像这样:

图 6.4:微帖子列表
微帖子页面将看起来像这样,它允许你测试不同的操作,如PUT、PATCH、DELETE和GET。

图 6.5:微帖子页面
Django REST 框架非常强大,可以以不同的方式使用,以确保它表现得完全符合你的预期。它有自己的特性,并且它对参数的敏感性直到一切配置正确才会减弱。同时,它允许你在每个方面自定义界面。务必仔细阅读文档。
你可以在这里找到完整的文档:www.django-rest-framework.org/。
外部层级
在网络服务器之上,可以通过添加额外的层级来继续链接,这些层级在 HTTP 层上工作。这允许你在多个服务器之间进行负载均衡,并增加系统的总吞吐量。如果需要,这些层级可以链式连接成多层。

图 6.6:链式负载均衡器
用户到我们系统边缘的路径由互联网处理,但一旦到达边缘负载均衡器,它就会将请求内部化。边缘负载均衡器作为外部网络和我们的网络受控环境之间的网关工作。
边缘负载均衡器通常是唯一处理 HTTPS 连接的,允许系统其余部分仅使用 HTTP。这很方便,因为 HTTP 请求更容易缓存和处理。HTTPS 请求是端到端编码的,不能被正确缓存或分析。内部流量受到外部访问的保护,应该有强大的策略来确保只有批准的工程师能够访问它,并且应该有访问日志来审计访问。但与此同时,它也易于调试,任何流量问题都可以更容易地解决。
网络配置可能有很大的不同,在许多情况下不需要多个负载均衡器,边缘负载均衡器可以直接处理多个 Web 服务器。在这种情况下,容量是关键,因为负载均衡器对它能够处理的请求数量有限制。
一些关键的负载均衡器可以设置为专用硬件,以确保它们有处理所需请求数量的能力。
这种多层结构允许你在系统的任何位置引入缓存。这可以提高系统的性能,尽管需要小心处理以确保其足够。毕竟,软件开发中最困难的问题之一就是正确处理缓存及其失效。
摘要
在本章中,我们详细介绍了 Web 服务器的工作原理以及涉及的各个层。
我们首先描述了请求-响应和 Web 服务器架构的基本细节。然后,我们继续描述了一个具有三层结构的系统,使用 nginx 作为前端 Web 服务器,并使用 uWSGI 来处理运行 Django 代码的多个 Python 工作进程。
我们从 Web 服务器本身开始,它允许你直接服务 HTTP,直接返回存储在文件中的静态内容,并将其路由到下一层。我们分析了不同的配置元素,包括启用头部转发和日志记录。
我们继续描述了 uWSGI 的工作原理以及它如何能够创建和设置不同的进程,这些进程通过 Python 中的 WSGI 协议进行交互。我们描述了如何设置与上一级(nginx Web 服务器)和下一级(Python 代码)的交互,还描述了如何有序地重启工作进程,以及如何定期自动回收它们以减轻某些类型的问题。
我们描述了 Django 如何定义 Web 应用程序,以及请求和响应如何通过代码流动,包括如何使用中间件在流程中串联元素。我们还介绍了 Django REST 框架作为创建 RESTful API 的方法,并展示了如何在第二章中引入的示例通过 Django REST 框架提供的视图和序列化器来实现。
最后,我们描述了如何通过在顶部添加层来扩展结构,以确保将负载分布到多个服务器并扩展系统。
接下来,我们将描述事件驱动系统。
第七章:事件驱动结构
请求-响应不是在系统中可以使用的唯一软件架构。也可以有不需要立即响应的请求。也许没有兴趣在响应,因为任务可以在调用者不需要等待的情况下完成,或者可能需要很长时间,而调用者不想等待。无论如何,从调用者的角度来看,有选择只是发送消息并继续进行。
这条消息被称为事件,这类系统有多种用途。在本章中,我们将介绍这一概念,并详细描述其中最流行的用途之一:创建在任务调用者不间断的情况下在后台执行的后台异步任务。
在本章中,我们将描述异步任务的基础,包括排队系统的细节以及如何生成自动计划的任务。
我们将以 Celery 为例,它是 Python 中一个具有多种功能的流行任务管理器。我们将展示如何执行常见任务的特定示例。我们还将探索 Celery Flower,这是一个创建 Web 界面的工具,用于监控和控制 Celery,并具有 HTTP API,允许您控制该界面,包括发送新任务以执行。
在本章中,我们将涵盖以下主题:
-
发送事件
-
异步任务
-
任务细分
-
计划任务
-
队列效应
-
Celery
让我们先描述事件驱动系统的基础。
发送事件
事件驱动结构基于“发射后不管”的原则。不是发送数据并等待另一部分返回响应,而是发送数据并继续执行。
这使得它与我们在上一章中看到的请求-响应架构有所不同。请求-响应过程将等待直到生成适当的响应。同时,更多代码的执行将停止,因为需要外部系统产生的新数据来继续。
在事件驱动系统中,没有响应数据,至少不是在相同的意义上。相反,将发送包含请求的事件,任务将继续进行。可以返回一些最小信息以确保事件可以被跟踪。
事件驱动系统可以使用请求-响应服务器实现。这并不意味着它们是纯请求-响应系统。例如,一个创建事件并返回事件 ID 的 RESTful API。任何工作尚未完成,唯一返回的细节是一个标识符,以便能够检查任何后续任务的状态。
这不是唯一的选择,因为这个事件 ID 可能是本地生成的,甚至可能根本不生成。
差别在于任务本身不会在同一时刻完成,因此从生成事件返回将非常快。一旦生成,事件将前往不同的系统,该系统将把它传输到目的地。
这个系统被称为总线,其工作原理是使消息在系统中流动。一个架构可以使用一个充当发送消息到系统中央位置的单个总线,或者可以使用多个总线。
通常,建议使用单个总线来通信所有系统。有多种工具允许我们实现多个逻辑分区,因此消息被路由到和从正确的目的地。
每个事件都将被插入到一个队列中。队列是一个逻辑 FIFO 系统,它将从入口点传输事件到定义的下一阶段。在那个点上,另一个模块将接收事件并处理它。
这个新系统正在监听队列,并提取所有接收到的事件以进行处理。这个工作员不能通过相同的通道直接与事件发送者通信,但它可以与其他元素交互,如共享数据库或公开的端点,甚至可以向队列发送更多事件以进一步处理结果。
队列两端的系统被称为发布者和订阅者。
多个订阅者可以管理同一个队列,并且他们会并行提取事件。多个发布者也可以将事件发布到同一个队列。队列的容量将由可以处理的事件数量来描述,并且应该提供足够的订阅者,以便队列能够快速处理。
可以作为总线的典型工具有 RabbitMQ、Redis 和 Apache Kafka。虽然可以使用工具“原样”使用,但有多种库可以帮助你使用这些工具以创建自己的处理发送消息的方式。
异步任务
一个简单的基于事件的系统允许你执行异步任务。
由基于事件的系统产生的事件描述了要执行的特殊任务。通常,每个任务都需要一些时间来执行,这使得它作为发布者代码流的一部分直接执行变得不切实际。
典型的例子是一个需要合理时间内响应用户的 Web 服务器。如果 HTTP 请求耗时过长,一些 HTTP 超时可能会产生错误,通常在超过一秒或两秒后响应并不是一个好的体验。
这些耗时操作可能包括将视频编码为不同的分辨率、使用复杂算法分析图像、向客户发送 1,000 封电子邮件、批量删除一百万个注册信息、将数据从外部数据库复制到本地数据库、生成报告或从多个来源提取数据。
解决方案是发送一个事件来处理这个任务,生成一个任务 ID,并立即返回任务 ID。事件将被发送到消息队列,然后将其传递到后端系统。后端系统将执行任务,该任务可能需要执行很长时间。
同时,可以使用任务 ID 来监控执行进度。后端任务将在共享存储中,如数据库中,更新执行状态,因此当它完成时,Web 前端可以通知用户。这个共享存储还可以存储任何可能有趣的结果。

图 7.1:事件流程
因为任务状态存储在一个前端 Web 服务器可以访问的数据库中,用户可以通过任务 ID 在任何时候请求任务的状态。

图 7.2:使用共享存储检查异步任务的进度
后端系统如果需要,可以产生中间更新,显示任务完成 25%或 50%的时间。这将需要存储在相同的共享存储中。
虽然这个过程是一种简化,但队列通常能够返回任务是否已完成。只有在任务需要返回一些数据时,才需要共享存储/数据库。对于小结果,数据库运行良好,但如果任务生成了像文档这样的大元素,这可能不是一个有效的选项,可能需要不同类型的存储。
例如,如果任务是要生成一个报告,后端会将其存储在文档存储中,如 AWS S3,以便用户稍后可以下载。
共享数据库不是确保 Web 服务器前端能够接收信息的唯一方式。Web 服务器可以公开一个内部 API,允许后端发送回信息。这在所有效果上等同于将数据发送到不同的外部服务。后端需要访问 API,配置它,并可能需要进行认证。API 可以专门为后端创建,也可以是一个通用 API,它也接受后端系统将产生的特定数据。
在两个不同的系统之间共享数据库的访问可能很困难,因为数据库需要同步才能满足两个系统。我们需要将系统分离,以便它们可以独立部署,而不会破坏向后兼容性。任何对模式的更改都需要额外的注意,以确保系统在任何时候都能正常运行,不会中断。公开一个 API 并保持数据库完全受前端服务的控制是一个好方法,但请注意,来自后端的请求将与外部请求竞争,因此我们需要足够的容量来满足两者。
在这种情况下,所有信息、任务 ID、状态和结果都可以保留在 Web 服务器内部存储中。
记住,队列可能会存储任务 ID 和任务状态。为了方便,这些信息可能在内部存储中进行了复制。

图 7.3:将信息发送回源服务
记住,这个 API 不必指向同一个前端。它也可以调用任何其他服务,无论是内部还是外部,从而在元素之间生成复杂的流程。它甚至创建自己的事件,这些事件将被重新引入队列以产生其他任务。
任务细分
从初始任务生成更多任务是完全可能的。这是通过在任务内部创建正确的事件并将其发送到正确的队列来完成的。
这允许单个任务分配其负载并并行化其操作。例如,如果一个任务生成报告并通过电子邮件发送给一组收件人,该任务可以先生成报告,然后通过创建仅专注于创建电子邮件和附加报告的新任务来并行发送电子邮件。
这将负载分散到多个工作者上,加快了处理速度。另一个优点是,单个任务将更短,这使得它们更容易控制、监控和操作。
一些任务管理器可能允许创建工作流,其中任务被分配,并且它们的结果被返回和合并。在某些情况下可以使用此功能,但在实践中,它不如最初看起来那么有用,因为它引入了额外的等待时间,我们最终可能会发现任务花费了更长的时间。
但容易获得的成功是批量任务,在多个元素上执行类似操作,而不需要合并结果,这在实践中相当常见。
然而,请注意,这将使初始任务快速完成,使得初始任务的 ID 状态检查整个操作是否完成不是一个好方法。如果需要监控,初始任务可能会返回新任务的 ID。
如果需要,可以重复此过程,子任务创建自己的子任务。某些任务可能需要在后台创建大量信息,因此细分它们可能是有意义的,但这也将增加跟踪代码流程的复杂性,因此请谨慎使用此技术,并且仅在它创造明显优势时使用。
计划任务
异步任务不需要由前端直接生成,也不需要用户的直接操作,但也可以设置为在特定时间运行,通过一个计划。
一些计划任务的例子包括在夜间生成每日报告、每小时通过外部 API 更新信息、预先缓存值以便稍后快速可用、在周初生成下周的日程表,以及每小时发送提醒电子邮件。
大多数任务队列都允许生成计划任务,并在其定义中明确指出,因此它们将自动触发。
我们将在本章后面看到如何为 Celery 生成计划任务。
一些计划任务可能相当大,例如每晚向数千个收件人发送电子邮件。将计划任务分解是非常有用的,这样就可以触发一个小型的计划任务,仅用于将所有单个任务添加到稍后处理的队列中。这分散了负载,并允许任务更早完成,充分利用系统。
在发送电子邮件的例子中,每晚都会触发一个单独的任务,读取配置并为每个找到的电子邮件创建一个新任务。然后,新任务将接收电子邮件,通过从外部信息中提取来编写正文,并发送它。
队列效应
异步任务的一个重要元素是引入队列可能产生的影响。正如我们所见,后台任务运行缓慢,这意味着运行它们的任何工人都会忙一段时间。
同时,可以引入更多任务,这可能导致队列开始积累。

图 7.4:单个队列
一方面,这可能是一个容量问题。如果工人的数量不足以处理队列中引入的平均任务数,队列将积累到其极限,新的任务将被拒绝。
但通常,负载不会像恒定的任务流入那样工作。相反,有时没有任务要执行,而其他时候,任务的数量会突然增加,填满队列。此外,还需要计算正确的工人数量,以确保在所有工人忙碌时任务延迟的等待期不会造成问题。
计算正确的工人数量可能很困难,但通过一些尝试和错误,可以得到一个“足够好”的数量。有一个数学工具可以处理它,即排队论,它基于几个参数进行计算。
在任何情况下,如今每个工人的资源都很便宜,不需要精确生成工人的数量,只要足够接近,以便任何可能的峰值都能在合理的时间内处理。
你可以在people.brunel.ac.uk/~mastjjb/jeb/or/queue.html了解更多关于排队论的信息。
另一个额外的困难,正如我们通过计划任务所看到的,是在特定时间,可能同时触发大量任务。这可能在特定时间饱和队列,可能需要一个小时来处理所有任务,例如,创建每日报告,每 4 小时在外部 API 中摄取新更新,或汇总一周的数据。
这意味着,例如,如果添加了 100 个创建背景报告的任务,它们将阻塞一个由用户发送的报告生成任务,这将产生不良体验。如果用户在预定任务启动后几分钟内请求报告,他们不得不等待很长时间。
一种可能的解决方案是使用多个队列,不同的工人从它们中提取任务。
自动生成的描述](https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/py-arch-ptn/img/B17580_07_05.png)
图 7.5:优先级和背景队列
这使得不同的任务被分配到不同的工人,使得为某些任务预留容量以不间断运行成为可能。在我们的例子中,背景报告可以分配到它们自己的专用工人,用户报告也有自己的工人。然而,这会浪费容量。如果背景报告每天只运行一次,一旦 100 个任务被处理,工人将在剩余的时间里空闲,即使用户报告服务的工人队列很长。
而是采用混合方法。
自动生成的描述](https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/py-arch-ptn/img/B17580_07_06.png)
图 7.6:普通工人从多个队列中提取任务
在这种情况下,用户报告的工人将继续使用相同的方法,但背景报告的工人将同时从两个队列中提取任务。在这种情况下,我们限制背景报告的容量,但同时,当有可用容量时,我们增加用户报告任务的容量。
我们为用户报告任务预留容量,这些任务是优先的,并让其他工人从所有可用的任务中提取,包括优先和非优先任务。
为了能够将这些任务分配到这两个队列,需要对任务进行仔细划分:
-
优先任务。代表用户启动。它们对时间敏感。执行速度快,因此延迟很重要。
-
后台任务。通常由自动化系统和计划任务启动。它们对时间不太敏感。可以长时间运行,因此更高的延迟更容易接受。
两者之间的平衡应该保持。如果太多任务被标记为优先级,队列将很快被填满,变得毫无意义。
总是会有创建多个队列以设置不同优先级并为每个队列预留容量的诱惑。这通常不是一个好主意,因为它们会浪费容量。最有效率的系统是单队列系统,因为所有容量都将始终被使用。然而,也存在优先级问题,因为它使得某些任务耗时过长。超过两个队列会过于复杂,并可能导致许多工人大部分时间空闲,而其他队列却满载。两个队列的简单性有助于培养在两种选项之间做出决定的纪律,并使人们容易理解为什么我们想要多个队列。
根据峰值数量和频率以及预期的周转时间,可以调整优先级工人的数量。只要这些峰值是可预测的,就只需要足够的优先级工人来覆盖在后台任务大峰值期间的正常流量。
良好的指标对于监控和理解队列的行为至关重要。我们将在第十三章,指标中更多地讨论指标。
另一种方法是基于特定的优先级(如数字)生成优先级系统。这样,优先级为 3 的任务将在优先级为 2 的任务之前执行,然后是优先级为 1 的任务,依此类推。拥有优先级的巨大优势是工人可以一直工作,而不会浪费任何容量。
但这种方法也有一些问题:
-
许多队列后端不支持其高效执行。为了按优先级对队列进行排序,所需的成本远高于仅将任务分配到普通队列。在实践中,可能不会产生你预期的那么好的结果,可能需要许多调整和修改。
-
这意味着你需要处理优先级膨胀。随着时间的推移,团队开始增加任务的优先级很容易,尤其是如果涉及多个团队。关于哪个任务应该首先返回的决定可能会变得复杂,随着时间的推移,压力可能会使优先级数字增加。
虽然看起来排序队列是理想的,但两个级别(优先级和背景)的简单性使得理解系统非常容易,并在开发和创建新任务时产生简单的期望。它更容易调整和理解,并且可以以更少的努力产生更好的结果。
所有工人的单一代码
当有不同工人从不同的队列中提取时,工人可能有不同的代码库,一个处理优先级任务,另一个处理背景任务。
注意,为了使这可行,需要严格分离任务。关于这一点,我们稍后会详细讨论。
这通常是不建议的,因为它将区分代码库,并需要并行维护两个代码库,存在一些问题:
-
一些任务或任务部分可能是优先级或背景,这取决于触发它们的系统或用户。例如,报告可以是即时为用户生成,也可以作为批量处理的一部分每天生成,最终通过邮件发送。报告生成应保持通用,以便任何更改都应用于两者。
-
处理两个代码库而不是一个会更不方便。大部分通用代码是共享的,因此更新需要独立运行。
-
一个独特的代码库可以处理所有类型的任务。这使得有可能有一个工人可以处理优先级和背景任务。两个代码库将需要严格的任务分离,不使用背景工人中可用的额外容量来帮助处理优先级任务。
在构建时使用单个工人数更好,并通过配置决定从单个队列或两个队列接收消息。这简化了本地开发和测试的架构。
当任务性质可能产生冲突时,这可能是不够的。例如,如果一些任务需要大依赖项或专用硬件(例如某些与人工智能相关的任务),这可能需要特定任务在专用工人数上运行,使得它们共享相同的代码库变得不切实际。这些情况很少见,除非遇到,否则最好尝试合并并使用相同的工人数处理所有任务。
云队列和工人数
云计算的主要特点是服务可以动态启动和停止,使我们能够仅使用特定时刻所需的资源。这使得系统可以快速增加和减少容量。
在云环境中,可能需要修改从队列中提取事件的工人数。这缓解了我们上面讨论的资源问题。我们是否有满队列?按需增加工人数!理想情况下,我们甚至可以为每个触发任务的每个事件生成一个单独的工人数,从而使系统无限可扩展。
显然,说起来容易做起来难,因为尝试动态创建现场工人数有一些问题:
-
启动时间可能会给任务的执行增加显著的时间,甚至可能比任务的执行时间还要长。根据创建工人数的重量,启动它可能需要相当长的时间。
在传统的云设置中,启动新的虚拟服务器所需的最小粒度相对较重,至少需要几分钟。使用较新的工具,如容器,这可以合理地加快速度,但基本原理将保持不变,因为最终某个时间点将需要生成新的虚拟服务器。
-
单个新的虚拟工人数可能对单个工人数来说太大,为每个任务生成一个可能效率低下。再次强调,容器化解决方案可以通过简化创建新容器和需要启动云服务中的新虚拟服务器之间的区别来帮助解决这个问题。
-
任何云服务都应该有限制。每个新创建的工人数都会产生费用,如果没有控制地扩展,云服务可能会变得非常昂贵。如果没有对成本方面的某些控制,这可能会因为高昂且意外的费用而成为一个问题。通常这种情况可能是意外发生的,由于系统中的某些问题导致工人数激增,但还有一种名为“现金溢出”的安全攻击,旨在使服务尽可能昂贵地运行,迫使服务所有者停止服务甚至破产。
由于这些问题,通常解决方案需要以批量方式工作,以便有额外的空间增长,并且仅在需要减少队列时才生成额外的虚拟服务器。同样,当不再需要额外容量时,它将被移除。
在停止之前,应特别注意确保位于同一虚拟服务器中的所有工作者都是空闲的。这是通过优雅地停止服务器来完成的,这样它们就会完成任何剩余的任务,不会启动新的任务,并在一切完成后结束。
该过程应类似于以下内容:

图 7.7:启动新服务器
确切知道何时应该启动新服务器很大程度上取决于对延迟、流量和新服务器创建速度(如果服务器启动快速,可能可以减少扩展的积极性)的要求。
一个好的起点是在队列中有与单个服务器中工作者数量相等或更多的任务时创建一个新服务器。这将触发一个新服务器,它将能够处理这些任务。如果触发创建的任务少于这个数量,它将创建一个不太满的服务器。如果启动时间非常长,可以将这个数量减少,以确保在新服务器启动之前不会形成显著的队列。但这将需要针对特定系统进行实验和测试。
Celery
Celery 是在 Python 中创建的最受欢迎的任务队列。它允许我们轻松创建新任务,并可以处理触发新任务的事件的创建。
Celery 需要设置一个代理,它将被用作队列来处理消息。
在 Celery 术语中,代理是消息队列,而后端则用于与存储系统交互以返回信息。
创建消息的代码将把它添加到代理,代理会将它传递给连接的工人之一。当所有事情都通过 Python 代码发生,并且可以安装celery包时,操作很简单。我们将在稍后看到如何在其他情况下操作它。
Celery 可以使用多个系统作为代理。最流行的是 Redis 和 RabbitMQ。
在我们的示例中,我们将使用 Redis,因为它可以用作代理和后端,并且在云系统中广泛可用。它也非常可扩展,并且容易处理大量负载。
使用后端是可选的,因为任务不需要定义返回值,并且异步任务通常不会直接返回响应数据,除了任务的状态。这里的关键词是“直接”;有时,一个任务将生成一个外部结果,可以访问,但不是通过 Celery 系统。
这些值的示例包括可以存储在其他存储设施中的报告、在任务处理过程中发送的电子邮件以及预缓存值,其中没有直接的结果,但生成了新的数据并存储在其他地方。
返回值也需要足够小,以便可以存储在作为后端工作的系统中。此外,如果使用强持久性,建议使用数据库作为后端。
我们将使用 GitHub 上的示例:github.com/PacktPublishing/Python-Architecture-Patterns/tree/main/chapter_07_event_driven/celery_example。我们将使用这个示例创建一个任务,从外部 API 检索某些用户的待办TO DO操作,并生成一封提醒邮件。
记得通过运行pip install -r requirements.txt来安装所需的依赖项。
让我们看看代码。
配置 Celery
代码分为两个文件:celery_tasks.py,它描述了任务,以及start_task.py,它连接到队列并将任务入队。
在每个开始之前,我们需要配置要使用的代理。在这种情况下,我们将使用运行在localhost上的 Redis 服务器:
from celery import Celery
app = Celery('tasks', broker='redis://localhost')
作为先决条件,我们需要设置一个运行在我们预期的localhost地址上的 Redis 服务器。如果你已经安装了 Docker,这样做的一个简单方法就是启动一个容器:
$ docker run -d -p 6379:6379 redis
这将启动一个标准的 Redis 容器,该容器将在标准端口 6379 上公开服务。这将自动与之前的代理 URL redis://localhost连接。
这就是所需的全部配置,它将允许双方,即发布者和订阅者,连接到队列。
Celery 工作进程
我们将使用jsonplaceholder.typicode.com/来模拟调用外部 API。这个测试网站提供了一个可访问的 REST 端点来检索一些模拟信息。你可以看到它们的定义,但基本上,我们将访问/todos和/users端点。/todos端点暴露了用户存储的操作,因此我们将查询它们以检索待办事项,并将其与/users端点中的信息结合起来。
celery_tasks.py工作进程定义了一个主要任务obtain_info和一个次要任务send_email。第一个从 API 中提取信息并决定需要发送哪些电子邮件。第二个然后发送电子邮件。
发送电子邮件只是模拟,以避免使系统复杂化并需要处理模拟电子邮件地址。它留作读者的练习。
文件从队列配置和导入开始:
from celery import Celery
import requests
from collections import defaultdict
app = Celery('tasks', broker='redis://localhost')
logger = app.log.get_default_logger()
BASE_URL = 'https://jsonplaceholder.typicode.com'
logger定义允许使用原生 Celery 日志,这些日志将被流式传输到 Celery 配置中用于日志。默认情况下,这是标准输出。
让我们看看obtain_info任务。注意定义函数为 Celery 任务的@app.task:
@app.task
def obtain_info():
logger.info('Stating task')
users = {}
task_reminders = defaultdict(list)
# Call the /todos endpoint to retrieve all the tasks
response = requests.get(f'{BASE_URL}/todos')
for task in response.json():
# Skip completed tasks
if task['completed'] is True:
continue
# Retrieve user info. The info is cached to only ask
# once per user
user_id = task['userId']
if user_id not in users:
users[user_id] = obtain_user_info(user_id)
info = users[user_id]
# Append the task information to task_reminders, that
# aggregates them per user
task_data = (info, task)
task_reminders[user_id].append(task_data)
# The data is ready to process, create an email per
# each user
for user_id, reminders in task_reminders.items():
compose_email(reminders)
logger.info('End task')
我们用INFO日志包装函数,为任务执行提供上下文。首先,它在这一行调用/todos端点,然后独立地遍历每个任务,跳过任何已完成的任务。
response = requests.get(f'{BASE_URL}/todos')
for task in response.json():
if task['completed'] is True:
continue
然后,它检查用户的信息并将其放入info变量中。因为这项信息可以在同一个循环中多次使用,所以它被缓存在users字典中。一旦信息被缓存,就不再需要再次请求:
user_id = task['userId']
if user_id not in users:
users[user_id] = obtain_user_info(user_id)
info = users[user_id]
将单个任务数据添加到创建以存储用户所有任务的列表中。task_reminders字典被创建为一个defaultdict(list),这意味着当首次访问特定的user_id时,如果它不存在,它将被初始化为一个空列表,允许新元素被附加。
task_data = (info, task)
task_reminders[user_id].append(task_data)
最后,task_reminders中存储的元素被迭代以组成最终的电子邮件:
for user_id, reminders in task_reminders.items():
compose_email(reminders)
调用了两个后续函数:obtain_user_info和compose_email。
obtain_user_info直接从/users/{user_id}端点检索信息并返回它:
def obtain_user_info(user_id):
logger.info(f'Retrieving info for user {user_id}')
response = requests.get(f'{BASE_URL}/users/{user_id}')
data = response.json()
logger.info(f'Info for user {user_id} retrieved')
return data
compose_email从任务列表中获取信息,包括一组user_info, task_info,提取每个task_info的标题信息,然后是匹配的user_info中的电子邮件,然后调用send_email任务:
def compose_email(remainders):
# remainders is a list of (user_info, task_info)
# Retrieve all the titles from each task_info
titles = [task['title'] for _, task in remainders]
# Obtain the user_info from the first element
# The user_info is repeated and the same on each element
user_info, _ = remainders[0]
email = user_info['email']
# Start the task send_email with the proper info
send_email.delay(email, titles)
如您所见,send_email任务包括一个.delay调用,它将此任务与适当的参数入队。send_email是另一个 Celery 任务。它非常简单,因为我们只是在模拟电子邮件投递。它只是记录其参数:
@app.task
def send_email(email, remainders):
logger.info(f'Send an email to {email}')
logger.info(f'Reminders {remainders}')
触发任务
start_task.py脚本包含触发任务的全部代码。这是一个简单的脚本,它从另一个文件导入任务。
from celery_tasks import obtain_info
obtain_info.delay()
注意,它在导入时继承了celery_tasks.py的所有配置。
重要的是,它通过.delay()调用任务。这会将任务发送到队列,以便 worker 可以将其拉出并执行。
注意,如果您直接使用obtain_info()调用任务,您将直接执行代码,而不是将任务提交到队列。
现在我们来看看这两个文件是如何交互的。
连接点
为了能够设置这两部分,即发布者和消费者,首先以调用方式启动 worker:
$ celery -A celery_tasks worker --loglevel=INFO -c 3
注意:一些使用的模块,例如 Celery,可能不与 Windows 系统兼容。更多信息可以在docs.celeryproject.org/en/stable/faq.html#does-celery-support-windows找到。
这通过-A参数启动了celery_tasks模块(celery_tasks.py文件)。它将日志级别设置为INFO,并使用-c 3参数启动三个 worker。它将显示一个类似于这样的启动日志:
$ celery -A celery_tasks worker --loglevel=INFO -c 3
v5.1.1 (sun-harmonics)
macOS-10.15.7-x86_64-i386-64bit 2021-06-22 20:14:09
[config]
.> app: tasks:0x110b45760
.> transport: redis://localhost:6379//
.> results: disabled://
.> concurrency: 3 (prefork)
.> task events: OFF (enable -E to monitor tasks in this worker)
[queues]
.> celery exchange=celery(direct) key=celery
[tasks]
. celery_tasks.obtain_info
. celery_tasks.send_email
[2021-06-22 20:14:09,613: INFO/MainProcess] Connected to redis://localhost:6379//
[2021-06-22 20:14:09,628: INFO/MainProcess] mingle: searching for neighbors
[2021-06-22 20:14:10,666: INFO/MainProcess] mingle: all alone
注意,它显示了两个可用的任务,obtain_info和send_email。在另一个窗口中,我们可以通过调用start_task.py脚本来发送任务:
$ python3 start_task.py
这将在 Celery 工作者中触发任务,生成日志(为了清晰和简洁进行了编辑)。我们将在下一段落中解释日志。
[2021-06-22 20:30:52,627: INFO/MainProcess] Task celery_tasks.obtain_info[5f6c9441-9dda-40df-b456-91100a92d42c] received
[2021-06-22 20:30:52,632: INFO/ForkPoolWorker-2] Stating task
[2021-06-22 20:30:52,899: INFO/ForkPoolWorker-2] Retrieving info for user 1
...
[2021-06-22 20:30:54,128: INFO/MainProcess] Task celery_tasks.send_email[08b9ed75-0f33-48f8-8b55-1f917cfdeae8] received
[2021-06-22 20:30:54,133: INFO/MainProcess] Task celery_tasks.send_email[d1f6c6a0-a416-4565-b085-6b0a180cad37] received
[2021-06-22 20:30:54,132: INFO/ForkPoolWorker-1] Send an email to Sincere@april.biz
[2021-06-22 20:30:54,134: INFO/ForkPoolWorker-1] Reminders ['delectus aut autem', 'quis ut nam facilis et officia qui', 'fugiat veniam minus', 'laboriosam mollitia et enim quasi adipisci quia provident illum', 'qui ullam ratione quibusdam voluptatem quia omnis', 'illo expedita consequatur quia in', 'molestiae perspiciatis ipsa', 'et doloremque nulla', 'dolorum est consequatur ea mollitia in culpa']
[2021-06-22 20:30:54,135: INFO/ForkPoolWorker-1] Task celery_tasks.send_email[08b9ed75-0f33-48f8-8b55-1f917cfdeae8] succeeded in 0.004046451000021989s: None
[2021-06-22 20:30:54,137: INFO/ForkPoolWorker-3] Send an email to Shanna@melissa.tv
[2021-06-22 20:30:54,181: INFO/ForkPoolWorker-2] Task celery_tasks.obtain_info[5f6c9441-9dda-40df-b456-91100a92d42c] succeeded in 1.5507660419999638s: None
...
[2021-06-22 20:30:54,141: INFO/ForkPoolWorker-3] Task celery_tasks.send_email[d1f6c6a0-a416-4565-b085-6b0a180cad37] succeeded in 0.004405897999959052s: None
[2021-06-22 20:30:54,192: INFO/ForkPoolWorker-2] Task celery_tasks.send_email[aff6dfc9-3e9d-4c2d-9aa0-9f91f2b35f87] succeeded in 0.0012900159999844618s: None
因为我们启动了三个不同的工作者,日志是交织在一起的。请注意第一个任务,它对应于obtain_info。这个任务在我们的执行中是在工作者ForkPoolWorker-2中执行的。
[2021-06-22 20:30:52,627: INFO/MainProcess] Task celery_tasks.obtain_info[5f6c9441-9dda-40df-b456-91100a92d42c] received
[2021-06-22 20:30:52,632: INFO/ForkPoolWorker-2] Stating task
[2021-06-22 20:30:52,899: INFO/ForkPoolWorker-2] Retrieving info for user 1
...
[2021-06-22 20:30:54,181: INFO/ForkPoolWorker-2] Task celery_tasks.obtain_info[5f6c9441-9dda-40df-b456-91100a92d42c] succeeded in 1.5507660419999638s: None
当这个任务正在执行时,其他工作者也在将send_email任务入队并执行。
例如:
[2021-06-22 20:30:54,133: INFO/MainProcess] Task celery_tasks.send_email[d1f6c6a0-a416-4565-b085-6b0a180cad37] received
[2021-06-22 20:30:54,132: INFO/ForkPoolWorker-1] Send an email to Sincere@april.biz
[2021-06-22 20:30:54,134: INFO/ForkPoolWorker-1] Reminders ['delectus aut autem', 'quis ut nam facilis et officia qui', 'fugiat veniam minus', 'laboriosam mollitia et enim quasi adipisci quia provident illum', 'qui ullam ratione quibusdam voluptatem quia omnis', 'illo expedita consequatur quia in', 'molestiae perspiciatis ipsa', 'et doloremque nulla', 'dolorum est consequatur ea mollitia in culpa']
[2021-06-22 20:30:54,135: INFO/ForkPoolWorker-1] Task celery_tasks.send_email[08b9ed75-0f33-48f8-8b55-1f917cfdeae8] succeeded in 0.004046451000021989s: None
执行结束时,有一个日志显示了所花费的时间,以秒为单位。
如果只有一个工作者参与,任务将依次运行,这使得区分任务变得更容易。
我们可以看到send_email任务在obtain_info任务结束之前就开始了,并且在obtain_info任务结束后仍然有send_email任务在运行,这显示了任务是如何独立运行的。
计划任务
在 Celery 内部,我们也可以生成具有特定计划的任务,这样它们可以在适当的时间自动触发。
要这样做,我们需要定义一个任务和一个计划。我们在celery_scheduled_tasks.py文件中定义了它们。让我们看看:
from celery import Celery
from celery.schedules import crontab
app = Celery('tasks', broker='redis://localhost')
logger = app.log.get_default_logger()
@app.task
def scheduled_task(timing):
logger.info(f'Scheduled task executed {timing}')
app.conf.beat_schedule = {
# Executes every 15 seconds
'every-15-seconds': {
'task': 'celery_scheduled_tasks.scheduled_task',
'schedule': 15,
'args': ('every 15 seconds',),
},
# Executes following crontab
'every-2-minutes': {
'task': 'celery_scheduled_tasks.scheduled_task',
'schedule': crontab(minute='*/2'),
'args': ('crontab every 2 minutes',),
},
}
此文件以与上一个示例相同的配置开始,我们定义了一个小型、简单的任务,它只显示执行时的情况。
@app.task
def scheduled_task(timing):
logger.info(f'Scheduled task executed {timing}')
有趣的部分在后面,因为计划是在app.conf.beat_schedule参数中配置的。我们创建了两个条目。
app.conf.beat_schedule = {
# Executes every 15 seconds
'every-15-seconds': {
'task': 'celery_scheduled_tasks.scheduled_task',
'schedule': 15,
'args': ('every 15 seconds',),
},
第一个定义了每 15 秒执行一次正确任务的执行。任务需要包含模块名(celery_scheduled_tasks)。schedule参数以秒为单位定义。args参数包含传递给执行的任何参数。请注意,它被定义为参数的列表。在这种情况下,我们创建了一个只有一个条目的元组,因为只有一个参数。
第二个条目将计划定义为 Crontab 条目。
# Executes following crontab
'every-2-minutes': {
'task': 'celery_scheduled_tasks.scheduled_task',
'schedule': crontab(minute='*/2'),
'args': ('crontab every 2 minutes',),
},
这个crontab对象,作为schedule参数传递,每两分钟执行一次任务。Crontab 条目非常灵活,允许执行广泛的可能操作。
以下是一些示例:
| Crontab entry | 描述 |
|---|---|
crontab() |
每分钟执行,最低的分辨率 |
crontab(minute=0) |
每小时在 0 分钟执行 |
crontab(minute=15) |
每小时在 15 分钟执行 |
crontab(hour=0, minute=0) |
每天在午夜执行(在你的时区) |
crontab(hour=6, minute=30, day_of_week='monday') |
每周一在 6:30 执行 |
crontab(hour='*/8', minute=0) |
每小时执行,且小时数是 8 的倍数(0, 8, 16)。每天三次,每次在 0 分钟 |
crontab(day_of_month=1, hour=0, minute=0) |
在每月的第一天午夜执行 |
crontab(minute='*/2') |
每分钟执行,且分钟数是 2 的倍数。每两分钟执行一次 |
有更多选项,包括将时间与太阳时间相关联,如黎明和黄昏,或自定义调度器,但大多数用例将每 X 秒执行一次或使用 crontab 定义将完全适用。
您可以在此处查看完整文档:docs.celeryproject.org/en/stable/userguide/periodic-tasks.html#starting-the-scheduler.
要启动调度器,我们需要启动一个特定的工人,即 beat 工人:
$ celery -A celery_scheduled_tasks beat
celery beat v4.4.7 (cliffs) is starting.
__ - ... __ - _
LocalTime -> 2021-06-28 13:53:23
Configuration ->
. broker -> redis://localhost:6379//
. loader -> celery.loaders.app.AppLoader
. scheduler -> celery.beat.PersistentScheduler
. db -> celerybeat-schedule
. logfile -> [stderr]@%WARNING
. maxinterval -> 5.00 minutes (300s)
我们以通常的方式启动 celery_scheduled_tasks 工人。
$ celery -A celery_scheduled_tasks worker --loglevel=INFO -c 3
但您可以看到仍然没有传入的任务。我们需要启动 celery beat,这是一个特定的工人,它将任务插入队列:
$ celery -A celery_scheduled_tasks beat
celery beat v4.4.7 (cliffs) is starting.
__ - ... __ - _
LocalTime -> 2021-06-28 15:13:06
Configuration ->
. broker -> redis://localhost:6379//
. loader -> celery.loaders.app.AppLoader
. scheduler -> celery.beat.PersistentScheduler
. db -> celerybeat-schedule
. logfile -> [stderr]@%WARNING
. maxinterval -> 5.00 minutes (300s)
一旦 celery beat 启动,您将开始看到任务按预期进行调度和执行:
[2021-06-28 15:13:06,504: INFO/MainProcess] Received task: celery_scheduled_tasks.scheduled_task[42ed6155-4978-4c39-b307-852561fdafa8]
[2021-06-28 15:13:06,509: INFO/MainProcess] Received task: celery_scheduled_tasks.scheduled_task[517d38b0-f276-4c42-9738-80ca844b8e77]
[2021-06-28 15:13:06,510: INFO/ForkPoolWorker-2] Scheduled task executed every 15 seconds
[2021-06-28 15:13:06,510: INFO/ForkPoolWorker-1] Scheduled task executed crontab every 2 minutes
[2021-06-28 15:13:06,511: INFO/ForkPoolWorker-2] Task celery_scheduled_tasks.scheduled_task[42ed6155-4978-4c39-b307-852561fdafa8] succeeded in 0.0016690909999965697s: None
[2021-06-28 15:13:06,512: INFO/ForkPoolWorker-1] Task celery_scheduled_tasks.scheduled_task[517d38b0-f276-4c42-9738-80ca844b8e77] succeeded in 0.0014504210000154671s: None
[2021-06-28 15:13:21,486: INFO/MainProcess] Received task: celery_scheduled_tasks.scheduled_task[4d77b138-283c-44c8-a8ce-9183cf0480a7]
[2021-06-28 15:13:21,488: INFO/ForkPoolWorker-2] Scheduled task executed every 15 seconds
[2021-06-28 15:13:21,489: INFO/ForkPoolWorker-2] Task celery_scheduled_tasks.scheduled_task[4d77b138-283c-44c8-a8ce-9183cf0480a7] succeeded in 0.0005252540000242334s: None
[2021-06-28 15:13:36,486: INFO/MainProcess] Received task: celery_scheduled_tasks.scheduled_task[2eb2ee30-2bcd-45af-8ee2-437868be22e4]
[2021-06-28 15:13:36,489: INFO/ForkPoolWorker-2] Scheduled task executed every 15 seconds
[2021-06-28 15:13:36,489: INFO/ForkPoolWorker-2] Task celery_scheduled_tasks.scheduled_task[2eb2ee30-2bcd-45af-8ee2-437868be22e4] succeeded in 0.000493534999975509s: None
[2021-06-28 15:13:51,486: INFO/MainProcess] Received task: celery_scheduled_tasks.scheduled_task[c7c0616c-857a-4f7b-ae7a-dd967f9498fb]
[2021-06-28 15:13:51,488: INFO/ForkPoolWorker-2] Scheduled task executed every 15 seconds
[2021-06-28 15:13:51,489: INFO/ForkPoolWorker-2] Task celery_scheduled_tasks.scheduled_task[c7c0616c-857a-4f7b-ae7a-dd967f9498fb] succeeded in 0.0004461000000333115s: None
[2021-06-28 15:14:00,004: INFO/MainProcess] Received task: celery_scheduled_tasks.scheduled_task[59f6a323-4d9f-4ac4-b831-39ca6b342296]
[2021-06-28 15:14:00,006: INFO/ForkPoolWorker-2] Scheduled task executed crontab every 2 minutes
[2021-06-28 15:14:00,006: INFO/ForkPoolWorker-2] Task celery_scheduled_tasks.scheduled_task[59f6a323-4d9f-4ac4-b831-39ca6b342296] succeeded in 0.0004902660000425385s: None
您可以看到这两种类型的任务都按相应的方式进行了调度。在此日志中检查时间,您会发现它们相隔 15 秒:
[2021-06-28 15:13:06,510: INFO/ForkPoolWorker-2] Scheduled task executed every 15 seconds
[2021-06-28 15:13:21,488: INFO/ForkPoolWorker-2] Scheduled task executed every 15 seconds
[2021-06-28 15:13:36,489: INFO/ForkPoolWorker-2] Scheduled task executed every 15 seconds
[2021-06-28 15:13:51,488: INFO/ForkPoolWorker-2] Scheduled task executed every 15 seconds
另一个任务每 2 分钟正好执行一次。请注意,第一次执行可能并不完全精确。在这种情况下,调度是在 15:12 的稍后秒数触发的,并且仍然在之后执行。无论如何,它将位于 crontab 的 1 分钟分辨率窗口内。
[2021-06-28 15:13:06,510: INFO/ForkPoolWorker-1] Scheduled task executed crontab every 2 minutes
[2021-06-28 15:14:00,006: INFO/ForkPoolWorker-2] Scheduled task executed crontab every 2 minutes
在创建周期性任务时,请记住不同的优先级,正如我们在本章之前所描述的。
将周期性任务用作“心跳”以检查系统是否正常工作是一种良好的做法。此任务可以用来监控系统中的任务是否按预期流动,没有大的延迟或问题。
这导致了一种监控不同任务执行情况的方法,比仅仅检查日志要好得多。
Celery Flower
如果您想了解已执行的任务并找到并修复问题,Celery 中的良好监控非常重要。为此,一个好的工具是 Flower,它通过添加一个实时监控网页来增强 Celery,允许您通过网页和 HTTP API 控制 Celery。
您可以在此处查看整个文档:flower.readthedocs.io/en/latest/.
它也非常容易设置并与 Celery 集成。首先,我们需要确保 flower 包已安装。在之前的步骤之后,该包包含在 requirements.txt 中,但如果它没有,您可以使用 pip3 独立安装它。
$ pip3 install flower
一旦安装,您可以使用以下命令启动 flower:
$ celery --broker=redis://localhost flower -A celery_tasks --port=5555
[I 210624 19:23:01 command:135] Visit me at http://localhost:5555
[I 210624 19:23:01 command:142] Broker: redis://localhost:6379//
[I 210624 19:23:01 command:143] Registered tasks:
['celery.accumulate',
'celery.backend_cleanup',
'celery.chain',
'celery.chord',
'celery.chord_unlock',
'celery.chunks',
'celery.group',
'celery.map',
'celery.starmap',
'celery_tasks.obtain_info',
'celery_tasks.send_email']
[I 210624 19:23:01 mixins:229] Connected to redis://localhost:6379//
命令与启动 Celery 工人非常相似,但包括使用 Redis 定义的代理,正如我们之前所看到的,使用 --broker=redis://localhost,并指定要暴露的端口 --port=5555。
接口暴露在 http://localhost:5555。
![图形用户界面,应用程序]
自动生成的描述](https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/py-arch-ptn/img/B17580_07_08.png)
图 7.8:Celery Flower 界面
首页显示了系统中的不同工作者。请注意,它显示了活跃任务的数量以及已处理任务的数量。在这种情况下,我们有 11 个任务对应于start_task.py的一次完整运行。你可以转到任务标签页来查看每个执行任务的详细信息,如下所示:

图 7.9:任务页面
你可以看到有关输入参数、任务状态、任务名称以及运行了多长时间的信息。
每个 Celery 进程将独立显示,即使它能够运行多个工作者。你可以在工作者页面上检查其参数。查看最大并发数参数。

图 7.10:工作者页面
从这里,你还可以审查和更改每个 Celery 进程的工作者数量配置,设置速率限制等。
Flower HTTP API
Flower 的一个很好的补充是 HTTP API,它允许我们通过 HTTP 调用来控制 Flower。这使系统能够自动控制,并允许我们通过 HTTP 请求直接触发任务。这可以用于用任何编程语言调用任务,大大增加了 Celery 的灵活性。
调用异步任务的 URL 如下:
POST /api/task/async-apply/{task}
它需要一个 POST 请求,并且调用参数应该包含在主体中。例如,使用curl进行调用:
$ curl -X POST -d '{"args":["example@email.com",["msg1", "msg2"]]}' http://localhost:5555/api/task/async-apply/celery_tasks.send_email
{"task-id": "79258153-0bdf-4d67-882c-30405d9a36f0"}
任务在工作者中执行:
[2021-06-24 22:35:33,052: INFO/MainProcess] Received task: celery_tasks.send_email[79258153-0bdf-4d67-882c-30405d9a36f0]
[2021-06-24 22:35:33,054: INFO/ForkPoolWorker-2] Send an email to example@email.com
[2021-06-24 22:35:33,055: INFO/ForkPoolWorker-2] Reminders ['msg1', 'msg2']
[2021-06-24 22:35:33,056: INFO/ForkPoolWorker-2] Task celery_tasks.send_email[79258153-0bdf-4d67-882c-30405d9a36f0] succeeded in 0.0021811629999999305s: None
使用相同的 API,可以通过 GET 请求检索任务的状态:
GET /api/task/info/{task_id}
例如:
$ curl http://localhost:5555/api/task/info/79258153-0bdf-4d67-882c-30405d9a36f0
{"uuid": "79258153-0bdf-4d67-882c-30405d9a36f0", "name": "celery_tasks.send_email", "state": "SUCCESS", "received": 1624571191.674537, "sent": null, "started": 1624571191.676534, "rejected": null, "succeeded": 1624571191.679662, "failed": null, "retried": null, "revoked": null, "args": "['example@email.com', ['msg1', 'msg2']]", "kwargs": "{}", "eta": null, "expires": null, "retries": 0, "worker": "celery@Jaimes-iMac-5K.local", "result": "None", "exception": null, "timestamp": 1624571191.679662, "runtime": 0.0007789200000161145, "traceback": null, "exchange": null, "routing_key": null, "clock": 807, "client": null, "root": "79258153-0bdf-4d67-882c-30405d9a36f0", "root_id": "79258153-0bdf-4d67-882c-30405d9a36f0", "parent": null, "parent_id": null, "children": []}
注意state参数,在这里它显示任务已成功完成,但如果尚未完成,它将返回PENDING。
这可以用来轮询任务的完成状态,直到它完成或显示错误,正如我们在本章前面所描述的。
摘要
在本章中,我们看到了事件驱动结构是什么。我们从关于如何使用事件来创建不同于传统请求-响应结构的不同流程的一般讨论开始。我们讨论了事件如何被引入队列以传输到其他系统。我们引入了发布者和订阅者的概念,以从该队列中引入或提取事件。
我们描述了如何使用这种结构来处理异步任务:在后台运行并允许界面的其他元素快速响应的任务。我们描述了如何通过利用多个可以执行这些较小任务的订阅者来增加吞吐量,将异步任务划分为更小的任务。我们还描述了如何在特定时间自动添加任务,以允许定期执行预定的任务。
由于任务的引入可能会有很大的变化,我们讨论了一些关于队列如何工作的重要细节,我们可能遇到的不同问题以及处理它们的策略。我们讨论了在大多数情况下,一个简单的背景队列和优先队列的策略是如何工作的,并警告不要过度复杂化。我们还解释了,在同样的精神下,最好在所有工作者之间保持代码同步,即使在队列可能不同的情况下。我们还简要提到了云计算在异步工作者中的应用能力。
我们解释了如何使用流行的任务管理器 Celery 来创建异步任务。我们涵盖了设置不同元素的过程,包括后端代理、如何定义合适的工作者以及如何从不同的服务生成任务。我们还包含了一个关于如何在 Celery 中创建计划任务的章节。
我们介绍了 Celery Flower,它是 Celery 的一个补充,包括一个网页界面,通过它可以监控和控制 Celery。它还包含一个 HTTP API,允许我们通过发送 HTTP 请求来创建任务,使得任何编程语言都可以与我们的 Celery 系统交互。
第八章:高级事件驱动结构
正如我们在上一章中看到的,事件驱动架构非常灵活,能够创建复杂的场景。在本章中,我们将探讨可能的、覆盖更高级用例的事件驱动结构,以及如何处理它们的复杂性。
我们将看到一些常见应用,如日志和指标,如何被视为事件驱动系统,并使用它们生成将反馈到产生事件的系统的控制系统。
我们还将通过示例讨论如何创建复杂的管道,其中产生不同的事件,系统进行协调。我们还将转向更一般的概述,引入总线作为连接所有事件驱动组件的概念。
我们将介绍一些关于更复杂系统的一般想法,以描述这类大型事件驱动系统可能产生的挑战,例如需要使用 CQRS 技术检索跨越多个模块的信息。最后,我们将给出一些关于如何测试系统的注意事项,注意测试的不同级别。
在本章中,我们将涵盖以下主题:
-
流式事件
-
管道
-
定义总线
-
更复杂的系统
-
测试事件驱动系统
我们将首先描述事件流。
流式事件
在某些情况下,仅产生捕获信息并存储以供以后访问的事件可能是有益的。这种结构对于监控来说很典型,例如,每次发生错误时我们都会创建一个事件。这个事件将包含有关错误生成位置、调试细节以便理解等信息。然后事件被发送,应用程序继续从错误中恢复。
这也可以应用于代码的特定部分。例如,为了捕获对数据库的访问时间,可以捕获计时和相关数据(如特定查询)并作为事件发送。
所有这些事件都应该被编译到一个位置,以便可以查询和聚合。
虽然通常不将其视为事件驱动过程,但日志和指标的工作方式基本上就是这样。在日志的情况下,事件通常是文本字符串,每当代码决定创建它们时就会触发。日志被转发到允许我们稍后搜索的目的地。
日志可以存储在不同的格式中。也常见的是以 JSON 格式创建它们,以便更好地搜索。
这类事件简单但非常强大,因为它允许我们发现程序在实时系统中的执行情况。
这种监控可能也被用来在满足某些条件时启用控制或警报。一个典型的例子是,如果日志捕获的错误数量超过某个阈值,就会向我们发出警报。

图 8.1:监控事件流程
这也可以用来产生反馈系统,其中监控系统中的仪表可以用来确定是否需要在系统本身中更改某些内容。例如,捕获指标以确定系统是否需要扩展或缩减规模,并根据请求数量或其他参数更改可用的服务器数量。

图 8.2:扩展事件的反馈
虽然这不是监控系统唯一的方法,但这种操作方法也可以用作检测配额的方式,例如,如果某个配额已超过,则短路处理传入的请求。

图 8.3:监控以检测配额并停止额外请求
这种结构与预先设置一个控制系统的模块的方法不同,而是依赖于仅在阈值被突破时采取行动,在后台进行计算。这可以减少预先需要的处理量。
例如,对于一个每分钟最大请求数量的配额,过程可能如下伪代码所示:
def process_request(request):
# Search for the owner of the request
owner = request.owner
info = retrieve_owner_info_from_db(owner)
if check_quota_info(info):
return process_request(request)
else:
return 'Quota exceeded'
check_quota_info在这两种情况下将有所不同。预先的方法需要维护和存储有关先前请求的信息:
def check_quota_info(info):
current_minute = get_current_minute()
if current_minute != info.minute:
# New minute, start the quota
info.requests = 0
info.minute = current_minute
else:
info.requests += 1
# Update the information
info.save()
if info.requests > info.quota:
# Quota exceeded
return False
# Quota still valid
return False
如果验证是在一个外部系统中完成的,基于生成的事件,check_quota_info不需要存储信息,而是只需检查配额是否已超过:
def check_quota_info(info):
# Generate the proper event for a new event
generate_event('request', info.owner)
if info.quota_exceeded:
return False
# Quota still valid
return False
整个检查都是在后端监控系统完成的,基于生成的事件,然后存储在信息中。这将从检查本身中分离出是否应用配额的逻辑,从而降低延迟。但另一方面,配额超过的检测可能会延迟,允许某些请求即使根据配额不应该被处理也能被处理。
理想情况下,生成的事件应该已经用于监控接收到的请求。这个操作非常有用,因为它重用了为其他用途生成的事件,减少了收集额外数据的需求。
同时,检查可以更复杂,并且不需要在每次收到新请求时都进行。例如,对于每小时配额,如果每秒收到多个请求,可能每分钟检查一次就足够确保遵守配额。与每次收到请求时检查条件相比,这可以节省大量的处理能力。
当然,这高度依赖于不同系统中涉及的具体规模、特性和请求。对于某些系统,预先的方法可能更好,因为它更容易实现,且不需要监控系统。在实施之前,始终要验证选项是否适合您的系统。
我们将在第十二章“日志”和第十三章“指标”中更详细地讨论日志和指标。
管道
事件流不必局限于单个系统。系统的接收端可以产生自己的事件,指向其他系统。事件将级联到多个系统,生成一个过程。
这与之前提出的情况类似,但在这个情况下,它是一个更有目的性的过程,旨在创建特定的数据管道,其中系统之间的流动被触发和处理。
这的一个可能例子是一个将视频缩放到不同大小和格式的系统。当视频被上传到系统中时,它需要转换成多个版本以用于不同的情况。还应创建一个缩略图来显示视频播放前的第一帧。
我们将分三步进行。首先,一个队列将接收事件以开始处理。这将触发两个不同队列中的两个事件,分别独立处理缩放和缩略图生成。这将形成我们的管道。
由于输入和输出数据是视频和图像,我们需要外部存储来存储它们。我们将使用 AWS S3,或者更确切地说,是 S3 的模拟。
AWS S3 是亚马逊在云中提供的一种对象存储服务,因其易于使用和非常稳定而非常受欢迎。我们将使用 S3 的模拟,这将允许我们启动一个类似 S3 的本地服务,这将简化我们的示例。
这是系统的概要图:

图 8.4:视频和图像队列
要开始,我们需要将源视频上传到模拟 S3 并启动任务。我们还需要一种方式来检查结果。为此,将提供两个脚本。
代码可在 GitHub 上找到:github.com/PacktPublishing/Python-Architecture-Patterns/tree/main/chapter_08_advanced_event_driven。
让我们从设置配置开始。
准备
如上所述,我们有两个关键前提条件:一个队列后端和模拟的 S3 存储。
对于队列后端,我们再次使用 Redis。Redis 很容易配置多个队列,我们稍后会看到。为了启动 Redis 队列,我们再次使用 Docker 来下载和运行官方镜像:
$ docker run -d -p 6379:6379 redis
这启动了一个在标准端口6379上暴露的 Redis 容器。注意,-d选项将使容器在后台运行。
对于模拟 S3 服务,我们将采用相同的方法,启动一个容器来启动 S3 Mock,这是一个复制 S3 API 的系统,但文件是本地存储的。这让我们避免了设置真实的 S3 实例,这涉及到获取 AWS 账户,支付我们的使用费用等等。
S3 Mock 是一个在无需使用真实 S3 连接的情况下进行 S3 存储开发测试的绝佳选项。我们将在后面看到如何使用标准模块连接到模拟。完整文档可以在 github.com/adobe/S3Mock 找到。
要启动 S3 Mock,我们还将使用 Docker:
$ docker run -d -p 9090:9090 -t adobe/s3mock
容器在端口 9090 上公开端点。我们将把 S3 请求指向这个本地端口。我们将使用 videos 存储桶来存储所有数据。
我们将定义三个不同的 Celery 工作者,它们将执行三个不同的任务:基本任务、图像任务和视频任务。每个任务都将从不同的队列中拉取事件。
这种为不同工作者指定特定任务的做法是故意为之,用于解释目的。在这个例子中,可能没有很好的理由来区分这一点,因为所有任务都可以在同一个工作者中运行,并且新事件可以重新引入到同一个队列中,正如我们在上一章中看到的,这是推荐的。然而,有时可能会有其他条件需要改变方法。
例如,一些任务可能需要特定的硬件进行 AI 处理,使用更多的 RAM 或 CPU 功率,这使得所有工作者都相等变得不切实际,或者有其他需要分离工作者的原因。不过,确保有充分的理由进行分割。这将使系统的操作和性能复杂化。
我们还将使用一些第三方库。这包括我们在上一章中看到的 Celery,还包括其他库,如 boto3、click 和 MoviePy。所有必需的库都在 requirements.txt 文件中,可以使用以下命令安装:
$ pip3 install -r requirements.txt
让我们从过程的第一个阶段开始,这是将重定向到其他两个阶段的基本任务。
基本任务
主要任务将接收包含图像的路径。然后,它将为视频尺寸调整和缩略图提取创建两个任务。
这是 base_tasks.py 的代码:
from celery import Celery
app = Celery(broker='redis://localhost/0')
images_app = Celery(broker='redis://localhost/1')
videos_app = Celery(broker='redis://localhost/2')
logger = app.log.get_default_logger()
@app.task
def process_file(path):
logger.info('Stating task')
logger.info('The file is a video, needs to extract thumbnail and '
'create resized version')
videos_app.send_task('video_tasks.process_video', [path])
images_app.send_task('image_tasks.process_video', [path])
logger.info('End task')
注意,我们在这里创建了三个不同的队列:
app = Celery(broker='redis://localhost/0')
images_app = Celery(broker='redis://localhost/1')
videos_app = Celery(broker='redis://localhost/2')
Redis 允许我们通过整数引用轻松创建不同的数据库。因此,我们为基本队列创建数据库 0,为图像队列创建数据库 1,为视频队列创建数据库 2。
我们使用 .send_task 函数在这些队列中生成事件。请注意,在每一个队列中我们发送适当的任务。我们将路径作为参数包含在内。
注意,所有任务的参数都定义在 .send_task 的第二个参数中。这要求参数是一个参数列表。在这种情况下,我们只有一个需要描述为 [path] 列表的单一参数。
当任务被触发时,它将排队下一个任务。让我们看看图像任务。
图像任务
为了生成视频的缩略图,我们需要两个第三方模块的帮助:
- boto3。这个常用的库帮助我们连接到 AWS 服务。特别是,我们将使用它来下载和上传到我们自己的模拟 S3 服务。
你可以在boto3.amazonaws.com/v1/documentation/api/latest/index.html查看整个boto3文档。它可以用来控制所有 AWS API。
- MoviePy。这是一个用于处理视频的库。我们将使用这个库将第一帧提取为独立的文件。
完整的MoviePy文档可在zulko.github.io/moviepy/找到。
之前章节中描述的requirements.txt文件和 GitHub 仓库中都包含了这两个库。让我们看看image_tasks.py:
from celery import Celery
import boto3
import moviepy.editor as mp
import tempfile
MOCK_S3 = 'http://localhost:9090/'
BUCKET = 'videos'
videos_app = Celery(broker='redis://localhost/1')
logger = videos_app.log.get_default_logger()
@videos_app.task
def process_video(path):
logger.info(f'Stating process video {path} for image thumbnail')
client = boto3.client('s3', endpoint_url=MOCK_S3)
# Download the file to a temp file
with tempfile.NamedTemporaryFile(suffix='.mp4') as tmp_file:
client.download_fileobj(BUCKET, path, tmp_file)
# Extract first frame with moviepy
video = mp.VideoFileClip(tmp_file.name)
with tempfile.NamedTemporaryFile(suffix='.png') as output_file:
video.save_frame(output_file.name)
client.upload_fileobj(output_file, BUCKET, path + '.png')
logger.info('Finish image thumbnails')
注意,我们使用正确的数据库定义了 Celery 应用程序。然后我们描述任务。让我们将其分为不同的步骤。我们首先将path中定义的源文件下载到临时文件中:
client = boto3.client('s3', endpoint_url=MOCK_S3)
# Download the file to a temp file
with tempfile.NamedTemporaryFile(suffix='.mp4') as tmp_file:
client.download_fileobj(BUCKET, path, tmp_file)
注意,我们定义了连接到MOCK_S3的端点,这是我们的 S3 模拟容器,如我们之前所述,暴露在http://localhost:9090/。
紧接着,我们生成一个临时文件来存储下载的视频。我们定义临时文件的后缀为.mp4,这样稍后VideoPy可以正确地检测到临时文件是一个视频。
注意,接下来的步骤都在定义临时文件的with块内部。如果它在这个块外部定义,文件就会被关闭并且不可用。
下一步是将文件加载到MoviePy中,然后提取第一帧到另一个临时文件中。这个第二个临时文件的后缀为.png,以标识它是一个图像:
video = mp.VideoFileClip(tmp_file.name)
with tempfile.NamedTemporaryFile(suffix='.png') as output_file:
video.save_frame(output_file.name)
最后,文件被上传到 S3 模拟,在原始名称的末尾添加.png:
client.upload_fileobj(output_file, BUCKET, path + '.png')
再次注意缩进,以确保在各个阶段临时文件都是可用的。
调整视频大小的任务遵循类似的模式。让我们看看。
视频任务
视频 Celery 工作进程从视频队列中提取并执行与图像任务类似的步骤:
from celery import Celery
import boto3
import moviepy.editor as mp
import tempfile
MOCK_S3 = 'http://localhost:9090/'
BUCKET = 'videos'
SIZE = 720
videos_app = Celery(broker='redis://localhost/2')
logger = videos_app.log.get_default_logger()
@videos_app.task
def process_video(path):
logger.info(f'Starting process video {path} for image resize')
client = boto3.client('s3', endpoint_url=MOCK_S3)
# Download the file to a temp file
with tempfile.NamedTemporaryFile(suffix='.mp4') as tmp_file:
client.download_fileobj(BUCKET, path, tmp_file)
# Resize with moviepy
video = mp.VideoFileClip(tmp_file.name)
video_resized = video.resize(height=SIZE)
with tempfile.NamedTemporaryFile(suffix='.mp4') as output_file:
video_resized.write_videofile(output_file.name)
client.upload_fileobj(output_file, BUCKET, path + f'x{SIZE}.mp4')
logger.info('Finish video resize')
与图像任务唯一的区别是将视频调整到 720 像素的高度并上传结果:
# Resize with moviepy
video = mp.VideoFileClip(tmp_file.name)
video_resized = video.resize(height=SIZE)
with tempfile.NamedTemporaryFile(suffix='.mp4') as output_file:
video_resized.write_videofile(output_file.name)
但总体流程非常相似。注意它从不同的 Redis 数据库中提取,对应于视频队列。
连接任务
为了测试系统,我们需要启动所有不同的元素。每个元素都在不同的终端中启动,这样我们可以看到它们的不同的日志:
$ celery -A base_tasks worker --loglevel=INFO
$ celery -A video_tasks worker --loglevel=INFO
$ celery -A image_tasks worker --loglevel=INFO
要开始这个过程,我们需要一个要处理的视频在系统中。
找到好的免费视频的一个可能性是使用www.pexels.com/,它有免费的股票内容。在我们的示例运行中,我们将下载 URL 为www.pexels.com/video/waves-rushing-and-splashing-to-the-shore-1409899/的 4K 视频。
我们将使用以下脚本将视频上传到 S3 Mock 存储并启动任务:
import click
import boto3
from celery import Celery
celery_app = Celery(broker='redis://localhost/0')
MOCK_S3 = 'http://localhost:9090/'
BUCKET = 'videos'
SOURCE_VIDEO_PATH = '/source_video.mp4'
@click.command()
@click.argument('video_to_upload')
def main(video_to_upload):
# Note the credentials are required by boto3, but we are using
# a mock S3 that doesn't require them, so they can be fake
client = boto3.client('s3', endpoint_url=MOCK_S3,
aws_access_key_id='FAKE_ACCESS_ID',
aws_secret_access_key='FAKE_ACCESS_KEY')
# Create bucket if not set
client.create_bucket(Bucket=BUCKET)
# Upload the file
client.upload_file(video_to_upload, BUCKET, SOURCE_VIDEO_PATH)
# Trigger the
celery_app.send_task('base_tasks.process_file', [SOURCE_VIDEO_PATH])
if __name__ == '__main__':
main()
脚本的开始部分描述了 Celery 队列,即基础队列,它将是管道的起点。我们定义了与配置相关的几个值,正如我们在前面的任务中所看到的。唯一的增加是 SOURCE_VIDEO_PATH,它将在 S3 Mock 中托管视频。
在此脚本中,我们使用相同的名称上传所有文件,如果脚本再次运行,则会覆盖它。如果您觉得这样做更有意义,请随意更改。
我们使用 click 库生成一个简单的 命令行界面(CLI)。以下行生成一个简单的界面,要求输入要上传的视频名称作为函数的参数。
@click.command()
@click.argument('video_to_upload')
def main(video_to_upload):
….
click 是一个快速生成 CLIs 的绝佳选项。您可以在其文档中了解更多信息:click.palletsprojects.com/。
主函数的内容只是连接到我们的 S3 Mock,如果尚未设置,则创建存储桶,将文件上传到 SOURCE_VIDEO_PATH,然后将任务发送到队列以启动进程:
client = boto3.client('s3', endpoint_url=MOCK_S3)
# Create bucket if not set
client.create_bucket(Bucket=BUCKET)
# Upload the file
client.upload_file(video_to_upload, BUCKET, SOURCE_VIDEO_PATH)
# Trigger the
celery_app.send_task('base_tasks.process_file', [SOURCE_VIDEO_PATH])
让我们运行它并查看结果。
运行任务
在添加视频名称后,您可以运行此脚本。请记住,requirements.txt 中的所有库都需要安装:
$ python3 upload_video_and_start.py source_video.mp4
将文件上传到 S3 Mock 需要一些时间。一旦调用,首先响应的是基础工作者。这个工作者将创建两个新的任务:
[2021-07-08 20:37:57,219: INFO/MainProcess] Received task: base_tasks.process_file[8410980a-d443-4408-8f17-48e89f935325]
[2021-07-08 20:37:57,309: INFO/ForkPoolWorker-2] Stating task
[2021-07-08 20:37:57,660: INFO/ForkPoolWorker-2] The file is a video, needs to extract thumbnail and create resized version
[2021-07-08 20:37:58,163: INFO/ForkPoolWorker-2] End task
[2021-07-08 20:37:58,163: INFO/ForkPoolWorker-2] Task base_tasks.process_file[8410980a-d443-4408-8f17-48e89f935325] succeeded in 0.8547832089971052s: None
另外两个将在不久后开始。图像工作者将显示新的日志,开始创建图像缩略图:
[2021-07-08 20:37:58,251: INFO/MainProcess] Received task: image_tasks.process_video[5960846f-f385-45ba-9f78-c8c5b6c37987]
[2021-07-08 20:37:58,532: INFO/ForkPoolWorker-2] Stating process video /source_video.mp4 for image thumbnail
[2021-07-08 20:38:41,055: INFO/ForkPoolWorker-2] Finish image thumbnails
[2021-07-08 20:38:41,182: INFO/ForkPoolWorker-2] Task image_tasks.process_video[5960846f-f385-45ba-9f78-c8c5b6c37987] succeeded in 42.650344008012326s: None
视频工作者需要更长的时间,因为它需要调整视频大小:
[2021-07-08 20:37:57,813: INFO/MainProcess] Received task: video_tasks.process_video[34085562-08d6-4b50-ac2c-73e991dbb58a]
[2021-07-08 20:37:57,982: INFO/ForkPoolWorker-2] Starting process video /source_video.mp4 for image resize
[2021-07-08 20:38:15,384: WARNING/ForkPoolWorker-2] Moviepy - Building video /var/folders/yx/k970yrd11hb4lmrq4rg5brq80000gn/T/tmp0deg6k8e.mp4.
[2021-07-08 20:38:15,385: WARNING/ForkPoolWorker-2] Moviepy - Writing video /var/folders/yx/k970yrd11hb4lmrq4rg5brq80000gn/T/tmp0deg6k8e.mp4
[2021-07-08 20:38:15,429: WARNING/ForkPoolWorker-2] t: 0%| | 0/528 [00:00<?, ?it/s, now=None]
[2021-07-08 20:38:16,816: WARNING/ForkPoolWorker-2] t: 0%| | 2/528 [00:01<06:04, 1.44it/s, now=None]
[2021-07-08 20:38:17,021: WARNING/ForkPoolWorker-2] t: 1%| | 3/528 [00:01<04:17, 2.04it/s, now=None]
...
[2021-07-08 20:39:49,400: WARNING/ForkPoolWorker-2] t: 99%|#########9| 524/528 [01:33<00:00, 6.29it/s, now=None]
[2021-07-08 20:39:49,570: WARNING/ForkPoolWorker-2] t: 99%|#########9| 525/528 [01:34<00:00, 6.16it/s, now=None]
[2021-07-08 20:39:49,874: WARNING/ForkPoolWorker-2] t: 100%|#########9| 527/528 [01:34<00:00, 6.36it/s, now=None]
[2021-07-08 20:39:50,027: WARNING/ForkPoolWorker-2] t: 100%|##########| 528/528 [01:34<00:00, 6.42it/s, now=None]
[2021-07-08 20:39:50,723: WARNING/ForkPoolWorker-2] Moviepy - Done !
[2021-07-08 20:39:50,723: WARNING/ForkPoolWorker-2] Moviepy - video ready /var/folders/yx/k970yrd11hb4lmrq4rg5brq80000gn/T/tmp0deg6k8e.mp4
[2021-07-08 20:39:51,170: INFO/ForkPoolWorker-2] Finish video resize
[2021-07-08 20:39:51,171: INFO/ForkPoolWorker-2] Task video_tasks.process_video[34085562-08d6-4b50-ac2c-73e991dbb58a] succeeded in 113.18933968200872s: None
要检索结果,我们将使用 check_results.py 脚本,该脚本下载 S3 Mock 存储的内容:
import boto3
MOCK_S3 = 'http://localhost:9090/'
BUCKET = 'videos'
client = boto3.client('s3', endpoint_url=MOCK_S3)
for path in client.list_objects(Bucket=BUCKET)['Contents']:
print(f'file {path["Key"]:25} size {path["Size"]}')
filename = path['Key'][1:]
client.download_file(BUCKET, path['Key'], filename)
通过运行它,我们将文件下载到本地目录:
$ python3 check_results.py
file /source_video.mp4 size 56807332
file /source_video.mp4.png size 6939007
file /source_video.mp4x720.mp4 size 8525077
您可以检查生成的文件并确认它们已正确生成。请注意,source_video.mp4 将与您的输入视频相同。
此示例演示了如何设置一个相对复杂的管道,其中不同的队列和工作者在协调一致的方式下触发。请注意,虽然我们直接使用 Celery 将任务发送到队列,但我们也可以使用 Celery Flower 和 HTTP 请求来完成此操作。
定义总线
当我们讨论队列后端系统时,这还没有真正扩展到总线的概念。术语 总线 来自于在硬件系统不同组件之间传输数据的硬件总线。这使得它们成为系统的一个中心、多源和多目的地的部分。
软件总线是对这个概念的推广,它允许我们互联多个逻辑组件。
从本质上讲,总线是一种专门用于数据传输的组件。与直接通过网络连接到服务而无需任何中间组件的常规替代方案相比,这是一种有序的通信。
由于总线负责数据传输,这意味着发送者除了要知道要传输的消息和发送到的队列之外,不需要了解太多。总线本身将传输到目的地或目的地。
总线的概念与消息代理的概念密切相关。然而,消息代理通常具有比纯总线更多的能力,例如在途中转换消息和使用多个协议。消息代理可以非常复杂,允许大量定制和服务解耦。一般来说,大多数支持使用总线的工具都会被标记为消息代理,尽管有些比其他更强大。
虽然我们将使用“总线”这个术语,但其中一些能力将更紧密地与诸如路由消息等功能相关联,这些功能可能需要被视为消息代理的工具。分析您特定用例的要求,并使用能够满足这些要求的工具。
然后,总线将被定义为所有与事件相关的通信都将指向的中心点。这简化了配置,因为事件可以路由到正确的目的地,而无需不同的端点。

图 8.5:消息总线
然而,在内部,总线将包含不同的逻辑分区,允许正确路由消息。这些就是队列。
如果总线允许,路由可能会变得复杂,这里就是这种情况。
在我们之前的例子中,我们使用了 Redis 作为总线。尽管连接 URL 略有不同,但它可以被重构以使其更清晰:
# Remember that database 0 is the base queue
BASE_BROKER = 'redis://localhost/0'
Base_app = Celery(broker=BROKER)
# Refactor for base
BROKER_ROOT = 'redis://localhost'
BROKER_BASE_QUEUE = 0
base_app = Celery(broker=f'{BASE_BROKER}/{BROKER_BASE_QUEUE}')
# To address the image queue
BROKER_ROOT = 'redis://localhost'
BROKER_IMAGE_QUEUE = 1
image_app = Celery(broker=f'{BASE_BROKER}/{BROKER_IMAGE_QUEUE}')
这个中心位置使得配置所有不同的服务变得容易,无论是将事件推送到队列还是从队列中拉取。
更复杂的系统
可以创建更复杂的系统,其中事件通过多个阶段,甚至设计为易于插件系统的同一队列。
这可以创建复杂的设置,其中数据通过复杂的管道流动,并由独立的模块处理。这类场景通常出现在旨在分析和处理大量数据以尝试检测模式和行为的仪器中。
想象一下,例如,一个为旅行社预订的系统。系统中发生了大量的搜索和预订请求,以及相关的购买,如租车、行李箱、食物等。每个动作都会产生一个常规响应(搜索、预订、购买等),但描述该动作的事件将被引入队列以在后台处理。不同的模块将根据不同的目标分析用户行为。
例如,以下模块可以添加到这个系统中:
-
按时间汇总经济结果,以获得服务随时间运行的全局视图。这可能包括每天的销售、收入、利润等细节。
-
分析普通用户的行为。跟踪用户以发现他们的模式。他们在预订前搜索什么?他们是否使用优惠?他们多久预订一次航班?他们的平均旅行时间有多长?是否有异常情况?
-
确保库存充足以供购买。根据系统中购买的物品,备货任何所需的元素。这还包括根据预先购买的食品安排足够的航班。
-
根据搜索收集关于首选目的地的信息。
-
对于可能导致安排更多飞机的满座航班等情况,触发警报。
这些模块基本上是关于不同的事情,并从不同的角度看待系统。有些更倾向于用户行为和营销,而有些则更与物流相关。根据系统的大小,可能需要确定模块需要不同的、专门的团队来独立处理每个模块。

图 8.6:前端系统到不同模块的总线
注意,每个系统可能都有自己的存储来允许它存储信息。这也可能导致创建自己的 API 来访问这些信息。
要查询信息,系统需要查询存储数据的模块的数据库。这可能是一个独立的服务,但很可能是同一系统的前端,因为它通常包含所有外部接口和权限处理。
这使得前端系统访问存储的信息成为必要,无论是直接访问数据库还是通过使用某些 API 来访问。前端系统应该模拟数据访问,正如我们在第三章,数据建模中看到的,并且很可能需要一个抽象复杂数据访问的模型定义。
相同的事件将被发送到总线上,然后不同的服务将接收它。为了能够这样做,你需要一个接受来自多个系统的订阅并给所有订阅系统传递相同消息的总线。
这种模式被称为发布/订阅或pub/sub。事件的消费者需要订阅主题,在 pub/sub 术语中,这相当于一个队列。大多数总线都接受这个系统,尽管可能需要一些工作来配置。
例如,有一个库允许 Celery 在这个系统下工作,可以在github.com/Mulugruntz/celery-pubsub找到。
注意,在这种情况下,工作者可以创建更多的事件来引入。例如,任何模块都将能够创建一个警报,警报系统将会被通知。例如,如果库存太低,它可能需要在同时下订单时发出快速警报,以确保快速采取行动。

图 8.7:注意模块和警报之间的通信也是通过总线进行的
复杂的事件驱动系统可以帮助您在不同组件之间分配工作。在这个例子中,您可以看到即时响应(预订航班)与后台的进一步详细分析(可用于长期规划)是完全独立的。如果所有组件都在请求服务时添加,可能会影响性能。后端组件可以在前端系统不受影响的情况下进行交换和升级。
要正确实现这类系统,事件需要使用一种易于适应和扩展的标准格式,以确保任何接收它的模块都能快速扫描并通过它,如果它不是必需的。
一个好主意是使用以下简单的 JSON 结构:
{
"type": string defining the event type,
"data": subevent content
}
例如,当产生搜索时,将创建如下事件:
{
"type": "SEARCH",
"data": {
"from": "Dublin",
"to": "New York",
"depart_date": 2021-12-31,
"return_date": null,
"user": null
}
}
type字段使得如果事件对任何模块没有兴趣,可以很容易地丢弃它。例如,经济分析模块将丢弃任何SEARCH事件。其他模块可能需要进一步处理。例如,用户行为模块将分析SEARCH事件,其中data中的user字段被设置。
请记住,对于事件驱动系统来说,一个重要元素是存储可能不是通用的。也许每个独立的模块都有自己的数据库。您需要使用我们在第三章,数据建模中讨论的 CQRS 技术来在这些模块中建模数据。本质上,您需要以不同的方式读取和保存新数据,因为写入新数据需要生成事件;并且您需要将它们建模为业务单元。更重要的是,模型在某些情况下可能需要合并来自多个模块的信息。例如,如果系统中有一个需要获取用户某些经济信息的查询,它需要查询用户行为模块和经济分析模块,同时将信息呈现为EconomicInfoUser的唯一模型。
当信息频繁访问时,在多个地方重复它可能是有意义的。这违反了单一责任原则(即每个功能应该是单个模块的唯一责任),但另一种选择是创建复杂的方法来获取常用信息。在设计系统时,要小心划分,以避免这些问题。
灵活的数据结构将允许生成新的事件,添加更多信息,并通过强制变化的向后兼容性,允许模块之间进行受控的更改。然后不同的团队可以并行工作,改进系统而不会过多地相互干扰。
但确保它们的行为正确可能很复杂,因为存在多个相互作用的部件。
测试事件驱动系统
事件驱动系统非常灵活,在特定情况下,在分离不同元素方面可以非常有用。但这种灵活性和分离性可能会使它们难以测试以确保一切按预期工作。
通常,单元测试是生成最快的测试,但事件驱动系统的独立性质使得它们在正确测试事件接收方面不太有用。当然,事件可以被模拟,接收事件的总体行为可以被测试。但问题是:我们如何确保事件已经被正确生成?并且是在正确的时间?
唯一的选择是使用集成测试来检查系统的行为。但这些测试的设计和运行成本更高。
总是会有关于命名测试的无限争论,比如单元测试与集成测试、系统测试、验收测试等究竟是什么。为了避免在这里陷入过于深入的讨论,因为这并不是本书的目标,我们将使用“单元测试”一词来描述只能在单个模块中运行的测试,而“集成测试”则指那些需要两个或更多模块相互交互才能成功的测试。单元测试将模拟任何依赖,但集成测试将实际调用依赖以确保模块之间的连接正确无误。
这两个级别在测试的每个测试的成本方面有显著差异。在相同的时间内可以编写和运行比集成测试多得多的单元测试。
例如,在我们的上一个例子中,为了测试购买食品是否正确触发警报,我们需要:
-
生成一个购买食品项目的调用。
-
产生适当的事件。
-
在库存控制中处理事件。当前的库存应配置为低,这将产生一个警报事件。
-
正确处理警报事件。
所有这些步骤都需要在三个不同的系统中进行配置(前端系统、库存控制模块和警报模块),以及设置总线以连接它们。理想情况下,这个测试将需要系统能够启动自动化系统来自动化测试。这要求每个涉及的模块都应该是可自动化的。
如我们所见,这在进行测试设置和运行方面是一个很高的标准,尽管它仍然值得去做。为了在集成测试和单元测试之间达到合理的平衡,我们应该增长它们并应用一些策略以确保我们对两者都有合理的覆盖率。
单元测试成本低廉,因此每个案例都应该通过单元测试进行健康覆盖,其中外部模块被模拟。这包括不同输入格式、不同配置、所有流程、错误等情况。好的单元测试应该从隔离的角度覆盖大多数可能性,模拟数据的输入和任何发送的事件。
例如,继续库存控制示例,许多单元测试可以通过更改输入请求来控制以下需求:
-
购买库存量高的元素。
-
购买库存量低的元素。这应该产生一个警报事件。
-
购买一个不存在的元素。这应该生成一个错误。
-
格式无效的事件。这应该生成一个错误。
-
购买库存量为零的元素。这应该生成一个警报事件。
-
更多案例,例如不同类型的购买、格式等。
相反,集成测试应该只有少数几个测试,主要覆盖“愉快路径”。愉快路径意味着正在发送并处理一个常规的代表性事件,但不会产生预期的错误。集成测试的目标是确认所有部分都连接并按预期工作。鉴于集成测试的运行和操作成本更高,应仅实现最重要的测试,并密切关注任何不值得维护且可以修剪的测试。
在上述关于集成测试的讨论中,我们描述了一个愉快的路径场景。事件触发库存中的处理程序并生成一个同样被处理的警报。对于集成测试来说,这比不生成警报更有利,因为它更能考验系统。
虽然这取决于系统,但单元测试与集成测试的比例应该严重偏向单元测试,有时高达 20 倍以上(意味着 1 个集成测试对应 20 个单元测试)。
摘要
在本章中,我们看到了更多的事件驱动系统,它们具有各种高级和复杂的架构,可以设计。我们展示了事件驱动设计可以给设计带来的灵活性和强大功能,但也提到了事件驱动设计所面临的挑战。
我们首先将日志和度量等常见系统作为事件驱动系统进行介绍,因为它们本身就是这样的,并考虑了以这种方式观察它们如何使我们能够创建用于控制事件来源的警报和反馈系统。
我们还提供了一个使用 Celery 的更复杂管道的例子,包括使用多个队列和共享存储来生成多个协调任务,例如调整视频大小和提取缩略图。
我们提出了总线(bus)的概念,即系统中所有事件的共享访问点,并探讨了如何生成更复杂的系统,在这些系统中事件被发送到多个系统,并级联成复杂的行为。我们还讨论了解决这些复杂交互的挑战,包括在事件生成后需要使用 CQRS 技术来建模可读信息,以及在单元和集成测试不同级别上的需求。
在下一章中,我们将看到复杂系统的两种主要架构:单体架构和微服务架构。
第九章:微服务与单体
在本章中,我们将介绍并讨论复杂系统中最常见的两种架构。单体架构创建了一个单一块,其中包含整个系统,并且操作简单。另一方面,微服务架构将系统划分为相互通信的较小微服务,旨在让不同的团队能够拥有不同的元素,并帮助大型团队能够并行工作。
我们将讨论何时选择每一种架构,基于它们不同的特性。我们还将讨论它们的团队合作方面,因为它们在如何结构化工作方面有不同的要求。
记住,架构不仅与技术相关,而且在很大程度上与沟通的结构有关!请参阅第一章,软件架构简介,以进一步讨论康威定律。
一种常见的模式是从旧的单体架构迁移到微服务架构。我们将讨论这种变化所涉及的阶段。
我们还将介绍 Docker 作为服务容器化的方式,这在创建微服务时非常有用,但也可以应用于单体。我们将对第五章,十二要素应用方法中展示的 Web 应用进行容器化。
最后,我们将简要描述如何使用编排工具部署和操作多个容器,并介绍目前最流行的一个——Kubernetes。
在本章中,我们将涵盖以下主题:
-
单体架构
-
微服务架构
-
选择哪种架构
-
从单体架构迁移到微服务
-
服务容器化
-
编排和 Kubernetes
让我们先深入谈谈单体架构。
单体架构
当一个系统自然设计时,倾向于生成一个包含整个系统功能的单一单元软件块。
这是一个逻辑上的发展过程。当设计一个软件系统时,它通常从简单开始,通常具有简单的功能。但是,随着软件的使用,它在使用方面开始增长,并开始收到对新功能的需求,以补充现有功能。除非有足够的资源和规划来结构化增长,否则最简单的路径就是将所有内容都添加到相同的代码结构中,几乎没有模块化。

描述自动生成,置信度低](https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/py-arch-ptn/img/B17580_09_01.png)
图 9.1:单体应用
这个过程确保所有代码和功能都绑定在一个单一块中,因此得名单体架构。
并且,通过扩展,遵循这种模式的软件被称为单体。
虽然这种结构相当常见,但一般来说,单体结构具有更好的模块化和内部结构。即使软件由单个块组成,也可以逻辑上将其划分为不同的部分,将不同的责任分配给不同的模块。
例如,在之前的章节中,我们讨论了 MVC 架构。这是一个单体架构。模型、视图和控制器都在同一个进程中,但有一个明确的结构来区分责任和功能。
单体架构不等于缺乏结构。
单体的定义特征是模块之间的所有调用都是通过内部API 进行的,在同一个进程中。这提供了非常灵活的优势。部署单体新版本的策略也很简单。重启进程将确保完全部署。
请记住,一个单体应用可以运行多个副本。例如,一个单体 Web 应用可以在并行运行多个相同的软件副本,负载均衡器将请求发送给所有这些副本。在这种情况下,重启将分为多个阶段。
单体的版本容易识别,因为所有代码都是同一结构的一部分。如果代码在源代码控制下,所有代码都将位于同一仓库中。
微服务架构
微服务架构是作为拥有包含所有代码的单个块的替代方案而开发的。
采用微服务架构的系统是一组松散耦合的专业服务,它们协同工作以提供全面的服务。让我们将定义分解以使其更清晰:
-
一组专业服务,意味着存在不同且定义明确的模块
-
松散耦合,因此每个微服务都可以独立部署和开发
-
协同工作。每个微服务都需要与其他服务进行通信
-
提供全面服务,意味着整个系统创建了一个具有明确动机和功能的全系统
与单体相比,它不是将整个软件放在同一个进程中,而是使用多个独立的、功能性的部分(每个微服务),这些部分通过定义良好的 API 进行通信。这些元素可以位于不同的进程中,通常被移动到不同的服务器上,以允许系统的适当扩展。

图 9.2:请注意,并非所有微服务都将连接到存储。每个微服务可能都有自己的独立存储
定义特征是不同服务之间的调用都是通过外部API 进行的。这些 API 充当功能之间的清晰、定义明确的障碍。正因为如此,微服务架构需要高级规划,并需要明确定义组件之间的差异。
尤其是微服务架构需要良好的前期设计,以确保不同的元素能够正确连接,因为任何跨服务的都会在处理上非常昂贵。
遵循微服务架构的系统不是自然形成的,而是事先制定并精心执行的计划的结果。这种架构通常不是从零开始构建系统,而是从先前存在的、成功的单体架构迁移而来。
选择哪种架构
有一种趋势认为,更先进的架构,如微服务架构,更好,但这是一种过度简化。每个架构都有自己的优点和缺点。
首先,几乎每个小型应用程序都会从一个单体应用程序开始。这是因为这是启动系统的最自然方式。所有东西都在手边,模块数量减少,并且是一个容易的起点。
另一方面,微服务架构需要制定一个计划,将功能仔细地划分为不同的模块。这项任务可能很复杂,因为某些设计可能在以后可能证明是不充分的。
请记住,没有任何设计可以完全适应未来。任何完美的架构决策可能在一年或两年后,当系统变化需要调整时,可能证明是错误的。虽然考虑未来是一个好问题,但试图涵盖每一个可能性是徒劳的。在为当前功能设计和为系统的未来愿景设计之间找到适当的平衡,是软件架构中一个持续的挑战。
这需要事先做大量的工作,这需要对微服务架构进行投资。
话虽如此,随着单体应用程序的增长,它们可能会仅仅因为代码的规模而开始出现问题。单体架构的主要特征是所有代码都集中在一起,它可能会开始展示出许多可能导致开发者困惑的连接。通过良好的实践和持续的警惕来确保良好的内部结构可以减少复杂性,但这需要现有开发者投入大量工作来执行。当处理一个大而复杂的系统时,通过将不同的区域划分为不同的进程,可能更容易呈现清晰和严格的边界。
模块也可能需要不同的特定知识,这使得将不同的团队成员分配到不同的领域变得自然。为了在模块中建立适当的所有权感,它们可以在代码标准、适合工作的适当编程语言、执行任务的方式等方面有不同的观点;例如,一个具有上传照片界面的光合作用系统和用于分类的 AI 系统。虽然第一个模块将作为一个网络服务运行,但训练和处理用于分类数据的 AI 模型所需的能力将非常不同,这使得模块分离既自然又高效。如果它们在同一个代码库中同时工作,可能会产生问题。
单体应用的另一个问题是资源利用效率低下,因为单体应用的每次部署都会携带每个模块的每个副本。例如,所需的 RAM 将根据多个模块的最坏情况场景来确定。当存在多个单体副本时,这将浪费大量 RAM 来准备可能很少发生的最坏情况。另一个例子是,如果任何模块需要与数据库建立连接,无论是否使用,都会创建一个新的连接。
相比之下,使用微服务可以根据每个服务的最坏情况使用情况进行调整,并独立控制每个服务的副本数量。从整体来看,这在大规模部署中可能导致巨大的资源节省。

图 9.3:请注意,使用不同的微服务可以将请求分割到不同的微服务中,从而减少 RAM 的使用,而在单体应用中,最坏情况场景驱动了 RAM 的利用率
单体和微服务的部署方式也非常不同。由于单体应用需要一次性部署,因此每次部署实际上都是整个团队的任务。如果团队规模较小,创建新的部署并确保新功能在模块之间得到适当协调且不会产生错误,并不复杂。然而,随着团队规模的扩大,如果代码没有严格的结构,这可能会带来严重的挑战。特别是,系统小部分的一个错误可能会完全使整个系统崩溃,因为单体中的任何关键错误都会影响整个代码。
单体应用的部署需要在模块之间进行协调,这意味着它们需要相互协作,这通常导致团队在功能准备发布之前紧密合作,并在部署准备就绪之前需要某种形式的监督。当几个团队在同一个代码库上工作,有竞争目标时,这会模糊部署的所有权和责任。
相比之下,不同的微服务是独立部署的。API 应该是稳定的,并且与旧版本向后兼容,这是需要强制执行的一个强烈要求。然而,边界非常清晰,在发生关键错误的情况下,最坏的情况是特定的微服务会崩溃,而其他无关的微服务则继续不受影响。
这使得系统工作在“降级状态”,与单体的“全有或全无”方法相比。它限制了灾难性故障的范围。
当然,某些微服务可能比其他微服务更为关键,因此它们值得额外的关注和照顾以确保其稳定性。但是,在这种情况下,它们可以预先定义为关键,并实施更严格的稳定性规则。
当然,在这两种情况下,都可以使用坚实的测试技术来提高发布软件的质量。
与单体相比,微服务可以独立部署,无需与其他服务紧密协调。这为从事这些工作的团队带来了独立性,并允许进行更快、更连续的部署,需要较少的中心协调。
这里的关键词是减少协调。协调仍然是必要的,但微服务架构的目标必然是每个微服务可以独立部署并由一个团队拥有,因此大多数变更可以完全由所有者决定,而不需要通知其他团队的过程。
单体应用,因为它们通过内部操作与其他模块通信,通常可以比通过外部 API 更快地执行这些操作。这允许模块之间有非常高的交互水平,而不需要付出显著的性能代价。
使用外部 API 和通过网络进行通信会产生一些开销,这可能导致明显的延迟,尤其是在向不同的微服务发出过多内部请求时。需要仔细考虑以尝试避免重复外部调用并限制在单个任务中可以联系的服务数量。
在某些情况下,使用工具来抽象与其他微服务的接触可能会产生额外的调用,这些调用将是绝对必要的。例如,处理文档的任务需要获取一些用户信息,这需要调用不同的微服务。文档的开始需要名字,而结束需要电子邮件。一个简单的实现可能会产生两个请求来获取信息,而不是一次性请求所有信息。
微服务的另一个有趣的优势是技术要求的独立性。在单体应用程序中,由于需要不同模块的不同版本库,可能会出现问题。例如,更新 Python 版本需要整个代码库都为此做好准备。这些库更新可能很复杂,因为不同的模块可能有不同的要求,一个模块可以通过要求升级两个模块都使用的某个库的版本,实际上与另一个模块混合。
另一方面,微服务有其自身的技术要求,因此不存在这种限制。由于使用了外部 API,不同的微服务甚至可以用不同的编程语言编写。这允许为不同的微服务使用专门的工具,针对每个目的进行定制,从而避免冲突。
虽然不同的微服务可以用不同的语言编程,但这并不意味着它们应该这样做。避免在微服务架构中使用过多的编程语言,因为这会使维护复杂化,并使得不同团队的一员难以提供帮助,从而形成更多孤立的团队。
提供一到两种默认的语言和框架,然后允许特殊合理的案例是一个合理的做法。
正如我们所见,微服务的大多数特性使其更适合大型操作,当开发者的数量足够高,以至于需要分成不同的团队,并且需要更明确的协调时。大型应用程序的高变化速度也要求有更好的部署和工作独立性的方法,通常来说。
一个小团队能够很好地自我协调,并且能够在单体中快速高效地工作。
这并不是说单体可以非常大。有些确实很大。但在一般意义上,只有当有足够的开发者,使得不同的团队在同一个系统中工作,并且需要在他们之间达到良好的独立性时,微服务架构才有意义。
关于类似设计的旁白
虽然单体与微服务的决策通常是在讨论网络服务时提出的,但这并不是一个新想法,也不是唯一存在类似想法和结构的环境。
操作系统的内核也可以是单体的。在这种情况下,如果一个内核结构在其所有操作都在内核空间内进行,那么它被称为单体结构。在计算机中运行的程序如果在内核空间运行,可以直接访问整个内存和硬件,这对于操作系统的使用至关重要,同时这也非常危险,因为它具有很大的安全和安全影响。由于内核空间中的代码与硬件紧密工作,任何故障都可能导致系统完全崩溃(内核恐慌)。另一种选择是在用户空间运行,这是程序只能访问其自己的数据,并且必须明确与操作系统交互以检索信息的区域。
例如,一个想要从文件中读取的用户空间程序需要调用操作系统,操作系统在内核空间中会访问文件,检索信息,并将其返回给请求的程序,将其复制到程序可以访问的内存部分。
单体内核的想法是它可以最小化不同内核元素(如库或硬件驱动程序)之间的移动和上下文切换。
单体内核的替代品被称为微内核。在微内核结构中,内核部分大大减少,文件系统、硬件驱动程序和网络堆栈等元素在用户空间而不是内核空间中执行。这要求这些元素通过通过微内核传递消息来进行通信,这效率较低。
同时,它还可以提高元素的模块化和安全性,因为任何用户空间中的崩溃都可以轻松重启。
安德鲁·S·坦能鲍姆和林纳斯·托瓦兹之间就 Linux 被创建为单体内核时哪种架构更好进行了著名的争论。从长远来看,内核已经演变为混合模型,其中它们结合了两个元素的特点,将微内核思想融入现有的单体内核中以提高灵活性。
发现和分析相关的架构思想可以帮助提高优秀架构师可用的工具,并提高对架构的理解和知识。
关键因素——团队沟通
微服务和单体架构之间的一个关键区别是它们支持的通信结构的不同。
如果单体应用是从一个小型项目有机地成长起来的,这通常会发生,其内部结构可能会变得混乱,需要具有系统经验的开发者来对其进行更改和适应以应对任何变化。在糟糕的情况下,代码可能会变得非常混乱,并且越来越难以工作。
随着开发团队规模的增加变得复杂,因为每个工程师都需要大量的上下文信息,学习如何导航代码是困难的。那些经验丰富的老队员可以帮助培训新队员,但他们将成为瓶颈,而指导是一个缓慢的过程,有其局限性。每个新队员都需要大量的培训时间,直到他们能够有效地修复错误和添加新功能。
团队也有一个最大自然规模限制。管理一个成员过多的团队,而不将其划分为更小的组,是困难的。
团队的理想规模取决于许多不同的因素,但通常认为 5 到 9 人是在有效工作时的理想规模。
大于这个规模的团队往往会自发组织成自己的小团队,作为一个整体失去焦点,并形成小的信息孤岛,其中团队的一部分人并不知道正在发生什么。
成员较少的团队在管理和与其他团队沟通方面会产生过多的开销。它们在稍微大一点规模的情况下将能够更快地工作。
如果代码的增长规模需要,这就是应用我们在本书中描述的所有技术来生成更多结构、构建系统架构的时候。这将涉及定义具有明确责任和边界的模块。这种划分允许团队划分为小组,并允许他们为每个团队创建所有权和明确的目标。
这使得团队可以在没有太多干扰的情况下并行工作,因此额外的成员可以增加在功能方面的吞吐量。正如我们之前所评论的,清晰的边界有助于为每个团队定义工作内容。
然而,在单体架构中,这些限制是“软”的,因为整个系统都是可访问的。当然,在专注于某些领域方面有一定的纪律性,趋势是某个团队将能够访问一切,并调整和弯曲内部 API。
这种特性并不一定是坏事,尤其是在较小规模的情况下。这种与小型、专注的团队一起工作的方式可以产生惊人的结果,因为它们将能够快速调整所有相关的软件部分。缺点是团队成员需要经验丰富,并且熟悉软件,这通常随着时间的推移变得越来越困难。
当迁移到微服务架构时,工作的划分变得更加明确。团队之间的 API 成为硬性限制,并且需要更多前期工作来在团队之间进行沟通。权衡的是,团队将更加独立,因为它们可以:
-
完全拥有微服务,而其他团队不在同一代码库中编码
-
独立于其他团队进行部署
由于代码库将更小,新加入团队的成员将能够快速学习并尽早变得高效。由于与其他微服务交互的外部 API 将明确定义,将应用更高层次的抽象,这使得交互更容易。
注意这也意味着,当至少对它有表面了解时,不同团队对其他微服务的内部了解将比单体应用少。这可能在人员从一个团队调动到另一个团队时产生一些摩擦。
正如我们在第一章中看到的,康威定律是在做出影响组织内部沟通的架构决策时需要牢记的东西。让我们记住,这条软件定律指出,软件的结构将复制组织的沟通结构。
康威定律的一个好例子是 DevOps 实践的产生。过去的工作划分方式是拥有不同的团队,一个与开发新功能相关,另一个负责部署和运营软件。毕竟,每个任务都需要不同的能力。
这种结构的风险在于“我不知道它是啥/我不知道它在哪运行”的划分,这可能导致负责开发新功能的团队对软件操作相关的错误和问题一无所知,而运维团队在反应时间很短的情况下发现变化,并识别出软件内部的错误,却未能理解软件的内部运作。
这种划分在许多组织中仍然存在,但 DevOps 背后的理念是,负责开发软件的同一团队也负责部署它,从而创建一个良性的反馈循环,其中开发者了解部署的复杂性,可以在生产中反应并修复错误,并改进软件的运行。
注意这通常涉及创建一个多功能团队,其中既有理解运维也有理解开发的人,尽管他们不一定需要是同一人。有时,一个外部团队负责为其他团队创建一套通用的工具,用于他们的运维。
这是一个巨大的变化,从旧结构转变为 DevOps 结构涉及以可能对企业文化造成极大破坏的方式混合团队。正如我们在这里试图强调的,这涉及到人员变化,这些变化缓慢,并伴随着大量的痛苦。例如,可能有一个很好的运维文化,他们分享知识并一起享受乐趣,但现在他们需要解散这些团队并与新人整合。
这种过程很困难,应该仔细规划,理解其人类和社会规模。
同一团队内部的沟通与不同团队之间的沟通不同。与其他团队沟通总是更困难且成本更高。这可能很容易说,但其对团队合作的含义很大。以下是一些例子:
-
由于要从团队外部使用的 API 将由其他工程师使用,而这些工程师对内部知识的掌握程度不同,因此使它们通用且易于使用,以及创建适当的文档是有意义的。
-
如果新的设计遵循现有团队的架构,那么实施起来会比反过来更容易。存在于团队之间的架构变化需要组织上的变化。改变组织的结构是一个漫长而痛苦的过程。任何参与过公司重组的人都可以证明这一点。这些组织变化会在软件中自然反映出来,因此理想情况下,将生成一个计划以允许这种变化。
-
在同一服务中工作的两个团队将产生问题,因为每个团队都会试图将其拉向自己的目标。这种情况可能发生在一些常见的库或被多个团队使用的“核心”微服务中。尝试为它们设定清晰的负责人,以确保只有一个团队负责任何变更。
明确的负责人可以明确谁负责变更和新功能。即使某些东西是由其他人实施的,负责人也应负责批准它并提供方向和反馈。他们还应准备好有一个长期愿景并处理任何技术债务。
-
由于不同的地理位置和时区自然地设置了各自的沟通障碍,他们通常会被用来建立不同的团队,描述他们自己跨时区的结构化沟通,例如 API 定义。
由于 COVID-19 危机,远程工作显著增加。这也产生了与同一房间内一起工作的团队不同的不同沟通结构的需求。这已经发展和提高了沟通技巧,这可能导致更好的工作组织方式。无论如何,团队划分不仅仅是物理上位于同一地点,而是创造团队工作的纽带和结构。
开发中的沟通方面是工作的重要部分,不应被低估。记住,对这些方面的改变是“人员变化”,这比技术变化更难实施。
从单体架构迁移到微服务架构
一个常见的案例是需要从现有的单体架构迁移到新的微服务架构。
想要实施这一变化的主要原因是系统的规模。正如我们之前讨论的,微服务系统的主要优势是创建多个独立的部分,这些部分可以并行开发,通过允许更多工程师同时工作,使开发可扩展并加快速度。
如果单体已经增长到超出可管理的大小,并且存在足够的问题,如发布问题、功能冲突和相互干扰,这是一个有意义的举措。但与此同时,这也是一个非常巨大且痛苦的过渡。
迁移的挑战
尽管最终结果可能比显示其年龄的单一架构应用要好得多,但迁移到新架构是一项重大任务。现在我们将探讨在迁移过程中我们可以预期的某些挑战和问题:
-
迁移到微服务将需要大量的努力,积极改变组织的运营方式,并且直到开始产生回报之前,需要大量的前期投资。过渡时间将会痛苦,需要在迁移速度和服务的常规运营之间做出妥协,因为完全停止运营不是一种选择。这需要大量的会议和文档来规划和传达迁移给每个人。它需要在管理层有积极的支持,以确保对完成任务的充分承诺,并清楚地了解为什么要这样做。
-
这还要求进行深刻的文化变革。正如我们上面所看到的,微服务的关键要素是团队之间的互动,与单一架构的运营方式相比,这将发生显著变化。这可能会涉及改变团队和改变工具。团队在对外部 API 的使用和文档方面将更加严格。
他们需要在与其他团队的互动中更加正式,并且可能需要承担之前没有的职责。一般来说,人们不喜欢改变,这可能会以某些团队成员的抵抗形式出现。确保这些因素被考虑在内。
-
另一个挑战是培训方面。新工具肯定会得到应用(我们将在本章后面介绍 Docker 和 Kubernetes),因此一些团队可能需要适应使用它们。管理服务集群可能很复杂,难以理解,并且可能涉及与之前使用的不同工具。例如,本地开发者可能会有很大不同。如果选择走这条路,学习如何操作和使用容器将需要一些时间。这需要规划和支持团队成员,直到他们对新系统感到舒适。
这种复杂性的一个非常清晰的例子是调试进入系统的请求时增加的复杂性,因为请求可能会在不同的微服务之间跳跃。以前,这个请求在单体架构中可能更容易追踪。理解请求的移动和寻找由此产生的细微错误可能很困难。为了确保修复这个问题,他们可能需要在本地开发中复制并修复,正如我们所看到的,这将涉及使用不同的工具和系统。
-
将现有的单体架构划分为不同的服务需要周密的规划。服务之间的不良划分可能会使两个服务紧密耦合,从而不允许独立部署。这可能导致一种情况,即实际上对任何一个服务的任何更改都可能导致另一个服务的更改,即使理论上可以独立完成。这会导致工作重复,因为通常需要更改和部署多个微服务才能完成单个功能的开发。微服务可以在以后进行变异和边界重新定义,但这会带来很高的成本。在添加新服务时,也应采取同样的谨慎态度。
-
创建微服务会有一些开销,因为每个服务都需要重复一些工作。这种开销通过允许独立和并行开发来补偿。但是,为了充分利用这一点,你需要有足够的人数。一个由多达 10 人组成的小型开发团队能够非常高效地协调和处理单体架构。只有当团队规模扩大并形成独立团队时,迁移到微服务才开始有意义。公司规模越大,这样做就越有意义。
-
在允许每个团队做出自己的决定和标准化一些共同元素和决策之间取得平衡是必要的。如果团队的方向过于模糊,他们就会不断地重复造轮子。他们还可能最终形成知识孤岛,其中公司的某个部分的知识完全无法转移到另一个团队,这使得集体学习变得困难。团队之间需要良好的沟通,以便达成共识和重用共同解决方案。允许进行受控的实验,将其标记为实验,并将所学到的经验传达给所有团队,以便其他团队也能从中受益。共享和可重用想法与独立、多实现想法之间将存在紧张关系。
在服务之间引入共享代码时要小心。如果代码增长,它将使服务相互依赖。这可能会降低微服务的独立性。
-
遵循敏捷原则,我们知道,工作软件比广泛的文档更重要。然而,在微服务中,最大化每个单独微服务的可用性以减少团队之间的支持量是很重要的。这涉及到一定程度的文档。最佳的方法是创建自文档化的服务。
-
正如我们之前讨论的,对每个不同微服务的调用都可能增加响应延迟,因为需要涉及多个层次。这可能导致延迟问题,外部响应时间更长。它们还会受到连接微服务的内部网络性能和容量的影响。
在采取微服务迁移时应该谨慎行事,并仔细分析其利弊。在成熟系统中完成迁移可能需要数年时间。但对于大型系统,结果系统将更加敏捷,易于更改,使您能够有效地解决技术债务,并赋予开发者完全的拥有权和创新能力,构建沟通并交付高质量、可靠的服务。
这是一个分为四个部分的动作。
从一个架构迁移到另一个架构应考虑分为四个步骤:
-
分析现有的系统。
-
设计以确定期望的目标是什么。
-
计划。创建一个路线图,逐步从当前系统移动到第一阶段设计的愿景。
-
执行计划。这一阶段需要缓慢而谨慎地进行,并且每一步都需要重新评估设计和计划。
让我们更详细地看看每个步骤。
1. 分析
第一步是充分理解我们现有的单体架构的起点。这看起来可能微不足道,但事实是,可能没有特定的人对系统的所有细节有很好的理解。可能需要收集信息、汇编和深入挖掘以理解系统的复杂性。
现有的代码可以描述为遗留代码。虽然目前正在进行关于哪些代码可以归类为遗留代码的辩论,但其主要特性是已经存在的代码,它不遵循新代码的最佳和最新实践。
换句话说,遗留代码是来自很久以前的旧代码,很可能不符合当前的最佳实践。然而,遗留代码是至关重要的,因为它正在使用中,并且可能是组织日常运营的关键。
本阶段的主要目标应该是确定变更是否真正有益,并对迁移后可能产生的微服务有一个初步的了解。执行这次迁移是一项重大承诺,始终检查是否会带来实际效益是一个好主意。即使在这个阶段,可能无法估计所需的工作量,但它将开始塑造任务的规模。
这种分析将极大地受益于良好的指标和实际数据,这些数据显示了系统中实际产生的请求数量和交互。这可以通过良好的监控,以及向系统添加指标和日志来实现,以便测量当前行为。这可以揭示哪些部分被常用,甚至更好的是,几乎从未使用且可能被弃用和删除的部分。监控可以继续使用以确保过程按计划进行。
我们将在第十一章“包管理”和第十二章“日志”中更详细地讨论监控。
如果系统已经很好地架构并得到妥善维护,这种分析几乎可以立即完成,但如果单体是混乱代码的集合,可能需要数月的会议和代码挖掘。然而,这一阶段将使我们能够在了解当前系统的基础上建立坚实的基础。
2. 设计
下一个阶段是生成一个愿景,即系统在将单体拆分为多个微服务后的样子。
每个微服务都需要单独考虑,并作为整体的一部分。从分离什么有意义的角度思考。以下是一些可能有助于您构建设计的疑问:
-
应该创建哪些微服务?能否用清晰的目标和控制区域描述每个微服务?
-
是否有任何关键或核心微服务需要更多关注或特殊要求?例如,更高的安全或性能要求。
-
团队将如何构建以覆盖微服务?团队是否支持过多?如果是这样,是否可以将多个请求或区域作为同一微服务的一部分?
-
每个微服务的先决条件是什么?
-
将引入哪些新技术?是否需要培训?
-
微服务是否独立?微服务之间的依赖关系是什么?是否有任何微服务被访问的频率高于其他微服务?
-
微服务能否相互独立部署?如果引入了需要更改依赖项的新变更,过程是怎样的?
-
将要对外暴露哪些微服务?哪些微服务仅内部暴露?
-
在 API 限制方面是否有任何先决条件?例如,是否有任何服务需要特定的 API,如 SOAP 连接?
其他有助于告知设计的事情可以是绘制需要与多个微服务交互的预期流程图,以便分析服务之间的预期移动。
应对每个微服务选择的存储方式应特别小心。一般来说,一个微服务的存储不应与其他微服务共享,以隔离数据。
这有一个非常具体的应用,即不要让两个或更多微服务直接访问数据库或其他类型的原始存储。相反,一个微服务应该控制格式并公开数据,并通过可访问的 API 允许对数据进行更改。
例如,让我们想象有两个微服务,一个控制报告,另一个控制用户。对于某些报告,我们可能需要访问用户信息,例如获取生成报告的用户的姓名和电子邮件。我们可以通过允许报告服务直接访问包含用户信息的数据库来分解微服务的职责。

图 9.4:不正确使用示例,直接从存储中访问信息
相反,报告服务需要通过 API 访问用户微服务并拉取数据。这样,每个微服务都负责其自己的存储和格式。

图 9.5:这是正确的结构。每个微服务都保持其独立的存储。这样,任何信息都只通过定义良好的 API 进行共享
正如我们之前所评论的,创建一些请求的流程图将有助于加强这种分离并找到可能的改进点;例如,返回在处理过程中稍后不需要的数据。
虽然一个先决条件是不混合存储,并保持分离,但你可以使用相同的后端服务为不同的微服务提供支持。同一个数据库服务器可以处理两个或更多逻辑数据库,可以存储不同的信息。
然而,一般来说,大多数微服务不需要存储自己的数据,并且可以以完全无状态的方式工作,而是依赖其他微服务来存储数据。
在这个阶段,没有必要在微服务之间设计详细的 API,但了解哪些服务处理哪些数据以及微服务之间所需的数据流的一般想法将是有益的。
3. 计划
一旦明确了大致区域,就到了更详细地规划如何从起点到终点改变系统的时候了。
这里的挑战是在系统始终保持功能的同时,迭代地迁移到新系统。新功能可能正在被引入,但让我们暂时放下这一点,只谈论迁移本身。
要能够做到这一点,我们需要使用所谓的断头蛇模式。这种模式旨在逐步用新部分替换系统的旧部分,直到整个旧系统被“勒死”并可以安全地移除。这种模式是迭代地、缓慢地、以小步骤将功能从旧系统迁移到新系统。

图 9.6:断头蛇模式
要创建新的微服务,有三种可能的方法:
-
用新的代码替换功能,以替代旧代码,功能上产生相同的结果。从外部来看,代码对外部请求的反应完全相同,但内部实现是新的。这种策略允许你从头开始,修复一些旧代码的奇怪之处。甚至可以在新的工具中进行,如框架或编程语言。
同时,这种方法可能非常耗时。如果遗留系统没有文档记录和/或未经测试,可能很难保证相同的功能。此外,如果此微服务覆盖的功能变化很快,它可能会进入新旧系统之间的追赶游戏,没有时间复制任何新的功能。
这种方法在需要复制的旧部分很小且过时的情况下最有意义,例如使用被认为已过时的技术栈。
-
将功能分割,将单体中存在的代码复制粘贴到新的微服务结构中。如果现有代码状况良好且结构良好,这种方法相对较快,只需将一些内部调用替换为外部 API 调用即可。
可能需要在单体中包含新的访问点,以确保新的微服务可以回调以获取一些信息。
也可能需要重构单体,以明确元素并将它们划分为与新技术更一致的结构。
此过程也可以通过首先将单个功能迁移到新的微服务中,然后逐个移动代码,直到功能完全迁移来进行迭代。到那时,可以安全地从旧系统中删除代码。
- 这是分割和替换的组合。同一功能的某些部分可能可以直接复制,但对于其他部分,则更倾向于采用新的方法。
这将为每个微服务计划提供信息,尽管我们需要创建一个全局视图来确定按什么顺序创建哪些微服务。
这里有一些有用的观点需要考虑,以确定最佳的行动方案:
-
需要首先提供哪些微服务,考虑到将产生的依赖关系。
-
了解最大的痛点是什么,以及是否优先处理它们。痛点是经常更改的代码或其他元素,在单体架构中处理它们的方式使得它们变得困难。迁移后可以产生巨大的好处。
-
有哪些难点和潜在问题?很可能会有些。承认它们的存在并尽量减少它们对其他服务的影响。请注意,它们可能与痛点相同,也可能不同。例如,非常稳定的旧系统是难点,但根据我们的定义,它们并不痛苦,因为它们没有变化。
-
现在让我们来谈谈几个快速胜利,这将保持项目的势头。快速向你的团队和利益相关者展示优势!这也会让每个人理解你想要迁移到的新操作模式,并开始这样工作。
-
需要考虑团队所需的培训以及你想要引入的新元素。还要考虑团队中是否缺少某些技能——你可能正在计划招聘。
-
任何团队变化和新服务的所有权。考虑团队的反馈很重要,这样他们就可以表达在制定计划过程中对任何疏忽的担忧。让团队参与并重视他们的反馈。
一旦我们有了如何进行的计划,就是时候付诸实施了。
4. 执行
最后,我们需要采取行动,开始从过时的单体架构迁移到新的美好微服务领域!
这实际上将是四个阶段中最长的,可以说是最困难的。正如我们之前所说,目标是让服务在整个过程中保持运行。
成功过渡的关键要素是保持向后兼容性。这意味着从外部角度来看,系统保持像单体系统一样的行为。这样,我们可以在不影响客户的情况下,改变系统的内部工作方式。
理想情况下,新的架构将使我们变得更快,这意味着唯一能感知到的变化就是系统更加响应迅速!
这显然是说得容易做起来难。在生产环境中进行软件开发被比作驾驶福特 T 型车开始汽车比赛,然后在法拉利上冲过终点线,同时不断更换每一个部件而不停车。幸运的是,软件的灵活性如此之高,以至于我们甚至可以讨论这一点。
要能够实现这种变化,从单体架构到新的微服务或处理相同功能的微服务,关键工具是在请求入口处使用负载均衡器。如果新的微服务直接替换请求,这尤其有用。负载均衡器可以接管请求的输入并将它们以受控的方式重定向到适当的服务。
我们将假设所有传入的请求都是 HTTP 请求。负载均衡器可以处理其他类型的请求,但 HTTP 无疑是最常见的。
这可以用来将单体架构的请求缓慢迁移到应该接收此请求的新微服务。记住,负载均衡器可以通过不同的 URL 配置来将请求定向到不同的服务,因此它可以利用这种小的粒度来在不同服务之间正确分配负载。
这个过程看起来可能像这样。首先,负载均衡器将所有请求都导向遗留单体。一旦新微服务部署,请求可以通过引入新微服务进行负载均衡。最初,平衡应该只将少量请求转发到新系统,以确保行为相同。
慢慢地,随着时间的推移,它可以增长,直到所有请求都迁移。例如,第一周只能移动 10%的请求,第二周 30%,第三周 50%,然后在接下来的那一周移动所有请求的 100%。
迁移周期为 4 周。在这段时间内,不应引入任何新功能或更改,因为接口需要在遗留单体和新微服务之间保持稳定。确保所有相关人员都知道计划以及每个步骤。
到那时,在遗留单体中处理请求的操作将不再使用,如果这样做有意义,可以将其移除以进行清理。
这个过程与我们之前讨论的“杀手模式”类似,但在这个情况下是应用于单个请求。负载均衡器将是实施完整模式的宝贵盟友,以更大的规模扩展这个程序,因为我们正在添加更多功能,并缓慢地将它们迁移,以确保任何问题都能及早发现,并且不会影响大量请求。
执行阶段
整个执行计划应包括三个阶段:
-
试点阶段。任何计划都需要经过仔细的测试。试点阶段将是检查计划的可行性以及测试工具的时候。应该由一个团队来领导这项工作,以确保他们专注于它,并且可以快速学习和分享。尽量从几个小型服务和低垂的果实开始,这样团队就能明显看到改进。合适的候选是非关键服务,如果出现问题,不会造成重大影响。这个阶段将使你为迁移做准备,并从不可避免的错误中调整和吸取教训。
-
整合阶段。在这个阶段,迁移的基本原理已经理解,但仍有许多代码需要迁移。此时,试点团队可以开始培训其他团队并传播知识,这样每个人都能理解应该如何进行。到那时,基本基础设施将到位,希望最明显的问题已经得到纠正,或者至少对如何处理它们有良好的理解。
为了帮助知识的传播,制定文档标准将帮助团队进行协调,并减少反复询问相同问题的情况。为新微服务部署和在生产中运行制定一系列先决条件将阐明所需内容。还务必保持反馈渠道,以便新团队可以分享他们的发现并改进流程。
这个阶段可能会看到一些计划的变化,因为现实将克服之前制定的任何计划。确保在解决问题的过程中适应变化,并密切关注目标。
在这个阶段,速度将会加快,因为随着越来越多的代码迁移,不确定性正在减少。在某个时候,为团队创建和迁移新的微服务将变得司空见惯。
-
最终阶段。在这个阶段,单体架构已经被拆分,所有新的开发都是在微服务中进行的。可能仍然有一些被视为不重要或低优先级的单体残留。如果是这样,边界应该清晰,以包含旧的方式做事。
现在,团队可以完全掌控他们的微服务,并开始承担更宏伟的任务,例如通过在另一种编程语言中创建一个等效的微服务来完全替换一个微服务,或者通过合并或拆分微服务来改变架构。这是最终阶段,从现在开始,你将生活在微服务架构中。务必与团队一起庆祝这一时刻。
这大致就是过程。当然,这可能是一个漫长而艰巨的过程,可能需要数月甚至数年。确保保持可持续的步伐,并从长远的角度看待目标,以便能够继续直到达到目标。
容器化服务
传统的服务操作方式是使用一个运行完整操作系统的服务器,例如 Linux,然后在上面安装所有必需的包(例如 Python 或 PHP)和服务(例如 nginx,uWSGI)。服务器作为单元,因此每个物理机器都需要独立维护和管理。从硬件利用率的观点来看,这也许并不最优。
通过用虚拟机替换物理服务器,可以提高这一点,这样单个物理服务器就可以处理多个虚拟机。这有助于提高硬件利用率和灵活性,但仍然需要将每个服务器作为独立的物理机器进行管理。
多种工具有助于这项管理,例如配置管理工具,如 Chef 或 Puppet。它们可以管理多个服务器,并确保它们安装了正确的版本,并运行了正确的服务。
容器为这个领域带来了不同的方法。不是使用一个完整的计算机(服务器),安装一个操作系统,包和依赖项,然后在上面安装你的软件,这比底层系统变化更频繁,而是创建一个包含所有内容的包(容器镜像)。
容器拥有自己的文件系统,包括操作系统、依赖项、软件包和代码,并且作为一个整体部署。容器不是在稳定的平台上运行服务,而是作为一个整体运行,包含所需的所有内容。平台(主机机器)是一个薄层,只需要能够运行容器。容器与主机共享相同的内核,这使得它们在运行效率上非常高效,与可能需要模拟整个服务器的虚拟机相比。
这允许,例如,在相同的物理机器上运行不同的容器,并且每个容器运行不同的操作系统、不同的软件包和不同的代码版本。
有时,人们将容器视为“轻量级虚拟机”。这并不正确。相反,将它们视为被其自身文件系统包裹的进程。这个进程是容器的主要进程,当它完成时,容器停止运行。
构建和运行容器的最流行工具是 Docker (www.docker.com/)。我们现在将检查如何操作它。
要安装 Docker,您可以访问docs.docker.com/get-docker/上的文档并遵循说明。使用 20.10.7 或更高版本。
安装完成后,您应该能够检查正在运行的版本,并获得以下类似的内容:
$ docker version
Client:
Cloud integration: 1.0.17
Version: 20.10.7
API version: 1.41
Go version: go1.16.4
Git commit: f0df350
Built: Wed Jun 2 11:56:22 2021
OS/Arch: darwin/amd64
Context: desktop-linux
Experimental: true
Server: Docker Engine - Community
Engine:
Version: 20.10.7
API version: 1.41 (minimum version 1.12)
Go version: go1.13.15
Git commit: b0f5bc3
Built: Wed Jun 2 11:54:58 2021
OS/Arch: linux/amd64
Experimental: false
containerd:
Version: 1.4.6
GitCommit: d71fcd7d8303cbf684402823e425e9dd2e99285d
runc:
Version: 1.0.0-rc95
GitCommit: b9ee9c6314599f1b4a7f497e1f1f856fe433d3b7
docker-init:
Version: 0.19.0
GitCommit: de40ad0
现在我们需要构建一个可以运行的容器镜像。
构建和运行镜像
容器镜像包括整个文件系统和启动时的指令。为了开始使用容器,我们需要构建构成系统基础的适当镜像。
记住之前提供的描述,即容器是一个被其自身文件系统包围的进程。构建镜像创建了该文件系统。
通过应用 Dockerfile,一个创建镜像的配方,通过逐层执行不同的层来创建镜像。
让我们看看一个非常简单的 Dockerfile。创建一个名为 sometext.txt 的文件,包含一些小的示例文本,另一个名为 Dockerfile.simple 的文件,包含以下文本:
FROM ubuntu
RUN mkdir -p /opt/
COPY sometext.txt /opt/sometext.txt
CMD cat /opt/sometext.txt
第一行,FROM,将通过使用 Ubuntu 镜像来启动镜像。
有许多可以作为起点使用的镜像。您有所有常见的 Linux 发行版,如 Ubuntu、Debian 和 Fedora,还有用于完整系统的镜像,如存储系统(MySQL、PostgreSQL 和 Redis)或用于特定工具的镜像,如 Python、Node.js 或 Ruby。查看 Docker Hub (hub.docker.com) 以获取所有可用的镜像。
一个有趣的起点是使用 Alpine Linux 发行版,它被设计成小巧且专注于安全。更多信息请查看www.alpinelinux.org。
容器的主要优势之一是能够使用和共享已经创建的容器,无论是直接使用还是作为增强它们的起点。如今,创建并推送到 Docker Hub 以允许他人直接使用它是非常常见的。这就是容器的一大优点!它们非常容易分享和使用。
第二行在容器内运行一个命令。在这种情况下,它会在/opt中创建一个新的子目录:
RUN mkdir -p /opt/
接下来,我们将当前sometext.txt文件复制到容器内部的新子目录中:
COPY sometext.txt /opt/sometext.txt
最后,我们定义当镜像运行时要执行的命令:
CMD cat /opt/sometext.txt
要构建镜像,我们运行以下命令:
docker build -f <Dockerfile> --tag <tag name> <context>
在我们的案例中,我们使用定义好的 Dockerfile 和example作为标签。上下文是.(当前目录),它定义了所有COPY命令的根点:
$ docker build -f Dockerfile.sample -–tag example .
[+] Building 1.9s (8/8) FINISHED
=> [internal] load build definition from Dockerfile.sample
=> => transferring dockerfile: 92B
=> [internal] load .dockerignore
=> => transferring context: 2B
=> [internal] load metadata for docker.io/library/ubuntu:latest
=> [1/3] FROM docker.io/library/ubuntu@sha256:82becede498899ec668628e7cb0ad87b6e1c371cb8a1e597d83a47fac21d6af3
=> [internal] load build context
=> => transferring context: 82B
=> CACHED [2/3] RUN mkdir -p /opt/
=> CACHED [3/3] COPY sometext.txt /opt/sometext.txt
=> exporting to image
=> => exporting layers
=> => writing image sha256:e4a5342b531e68dfdb4d640f57165b704b1132cd18b5e2ba1220e2d800d066cb
如果我们列出可用的镜像,你将能够看到example这个镜像:
$ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
example latest e4a5342b531e 2 hours ago 72.8MB
ubuntu latest 1318b700e415 47 hours ago 72.8MB
我们现在可以运行容器,它将在容器内执行cat命令:
$ docker run example
Some example text
容器将在命令完成后停止执行。你可以使用docker ps -a命令查看已停止的容器,但通常停止的容器并不很有趣。
一个常见的例外是,生成的文件系统被存储到磁盘上,因此停止的容器可能产生了作为命令一部分的有趣文件。
虽然这种方式在某些时候运行容器可能很有用,比如编译二进制文件或其他类似操作,但通常情况下,更常见的是创建始终运行的RUN命令。在这种情况下,它将一直运行,直到外部停止,因为命令将永远运行。
构建和运行一个网络服务
网络服务容器是我们已经看到的最常见的微服务类型。为了能够构建和运行一个,我们需要以下部分:
-
正确的基础设施将网络服务运行到容器中的端口
-
将运行的我们的代码
按照前几章中介绍的常规架构,我们将使用以下技术栈:
-
我们的代码将使用 Python 编写,并使用 Django 作为网络框架
-
Python 代码将通过 uWSGI 执行
-
该服务将通过 nginx 网络服务器在端口 8000 上公开
让我们看看不同的元素。
代码结构在两个主要目录和一个文件中:
-
docker:此子目录包含与 Docker 和其他基础设施操作相关的文件。 -
src:网络服务的源代码本身。源代码与我们在第五章,“十二要素应用方法论”中看到的是相同的。 -
requirements.txt:包含运行源代码所需的 Python 依赖的文件。
Dockerfile 镜像位于./docker子目录中。我们将按照它来解释不同部分是如何连接的:
FROM ubuntu AS runtime-image
# Install Python, uwsgi and nginx
RUN apt-get update && apt-get install -y python3 nginx uwsgi uwsgi-plugin-python3
RUN apt-get install -y python3-pip
# Add starting script and config
RUN mkdir -p /opt/server
ADD ./docker/uwsgi.ini /opt/server
ADD ./docker/nginx.conf /etc/nginx/conf.d/default.conf
ADD ./docker/start_server.sh /opt/server
# Add and install requirements
ADD requirements.txt /opt/server
RUN pip3 install -r /opt/server/requirements.txt
# Add the source code
RUN mkdir -p /opt/code
ADD ./src/ /opt/code
WORKDIR /opt/code
# compile the static files
RUN python3 manage.py collectstatic --noinput
EXPOSE 8000
CMD ["/bin/sh", "/opt/server/start_server.sh"]
文件的第一部分从标准的 Ubuntu Docker 镜像启动容器,并安装基本需求:Python 解释器、nginx、uWSGI 以及一些补充包——运行python3代码的 uWSGI 插件和能够安装 Python 包的pip:
FROM ubuntu AS runtime-image
# Install Python, uwsgi and nginx
RUN apt-get update && apt-get install -y python3 nginx uwsgi uwsgi-plugin-python3
RUN apt-get install -y python3-pip
下一个阶段是将所有必需的脚本和配置文件添加到启动服务器并配置 uWSGI 和 nginx。所有这些文件都在./docker子目录中,并存储在容器中的/opt/server目录内(除了 nginx 配置,它存储在默认的/etc/nginx子目录中)。
我们确保启动脚本可执行:
# Add starting script and config
RUN mkdir -p /opt/server
ADD ./docker/uwsgi.ini /opt/server
ADD ./docker/nginx.conf /etc/nginx/conf.d/default.conf
ADD ./docker/start_server.sh /opt/server
RUN chmod +x /opt/server/start_server.sh
接下来安装 Python 需求。添加requirements.txt文件,然后通过pip3命令安装:
# Add and install requirements
ADD requirements.txt /opt/server
RUN pip3 install -r /opt/server/requirements.txt
一些 Python 包可能需要在容器第一阶段安装某些包以确保某些工具可用;例如,安装某些数据库连接模块将需要安装适当的客户端库。
接下来,我们将源代码添加到/opt/code。使用WORKDIR命令,在该子目录中执行任何RUN命令,然后使用 Django 的manage.py命令运行collectstatic以在正确的子目录中生成静态文件:
# Add the source code
RUN mkdir -p /opt/code
ADD ./src/ /opt/code
WORKDIR /opt/code
# compile the static files
RUN python3 manage.py collectstatic --noinput
最后,我们描述暴露的端口(8000)和要运行的CMD以启动容器,即之前复制的start_server.sh脚本:
EXPOSE 8000
CMD ["/bin/bash", "/opt/server/start_server.sh"]
uWSGI 配置
uWSGI 配置与第五章中介绍的配置非常相似,十二要素应用方法:
[uwsgi]
plugins=python3
chdir=/opt/code
wsgi-file = microposts/wsgi.py
master=True
socket=/tmp/uwsgi.sock
vacuum=True
processes=1
max-requests=5000
uid=www-data
# Used to send commands to uWSGI
master-fifo=/tmp/uwsgi-fifo
唯一的区别是需要包含plugins参数来指示它运行python3插件(这是因为 Ubuntu 安装的uwsgi包默认没有激活它)。此外,我们将使用与 nginx 相同的用户运行进程,以便它们可以通过/tmp/uwsgi.sock套接字进行通信。这是通过uid=www-data添加的,其中www-data是默认的 nginx 用户。
nginx 配置
nginx 配置也与第五章中介绍的配置非常相似,十二要素应用方法:
server {
listen 8000 default_server;
listen [::]:8000 default_server;
root /opt/code/;
location /static/ {
autoindex on;
try_files $uri $uri/ =404;
}
location / {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
uwsgi_pass unix:///tmp/uwsgi.sock;
include uwsgi_params;
}
}
唯一的区别是暴露的端口,它是8000。请注意,根目录是/opt/code,这使得静态文件目录是/opt/code/static。这需要与 Django 的配置保持同步。
启动脚本
让我们看看启动服务的脚本,start_script.sh:
#!/bin/bash
_term() {
# See details in the uwsgi.ini file and
# in http://uwsgi-docs.readthedocs.io/en/latest/MasterFIFO.html
# q means "graceful stop"
echo q > /tmp/uwsgi-fifo
}
trap _term TERM
nginx
uwsgi --ini /opt/server/uwsgi.ini &
# We need to wait to properly catch the signal, that's why uWSGI is started
# in the background. $! is the PID of uWSGI
wait $!
# The container exits with code 143, which means "exited because SIGTERM"
# 128 + 15 (SIGTERM)
# http://www.tldp.org/LDP/abs/html/exitcodes.html
# http://tldp.org/LDP/Bash-Beginners-Guide/html/sect_12_02.html
echo "Exiting, bye!"
启动脚本的核心位于中心,在这些 nginx 行中:
uwsgi --ini /opt/server/uwsgi.ini &
wait $!
这将启动nginx和uwsgi,并等待uwsgi进程停止。在 Bash 中,$!是最后一个进程(uwsgi进程)的 PID。
当 Docker 尝试停止容器时,它将首先向容器发送 SIGTERM 信号。这就是为什么我们创建了一个 trap 命令来捕获这个信号并执行 _term() 函数。这个函数会发送一个优雅的停止命令到 uwsgi 队列,正如我们在 第五章,十二要素应用方法 中所描述的,以优雅的方式结束进程:
_term() {
echo q > /tmp/uwsgi-fifo
}
trap _term TERM
如果初始的 SIGTERM 信号不成功,Docker 将在一段宽限期后停止容器并杀死它,但这可能会使进程的结束变得不优雅。
构建 和 运行
现在我们可以构建镜像并运行它。要构建镜像,我们执行与之前类似的命令:
$ docker build -f docker/Dockerfile --tag example .
[+] Building 0.2s (19/19) FINISHED
=> [internal] load build definition from Dockerfile
=> => transferring dockerfile: 85B
=> [internal] load .dockerignore
=> => transferring context: 2B
=> [internal] load metadata for docker.io/library/ubuntu:latest
=> [ 1/14] FROM docker.io/library/ubuntu
=> [internal] load build context
=> => transferring context: 4.02kB
=> CACHED [ 2/14] RUN apt-get update && apt-get install -y python3 nginx uwsgi uwsgi-plugin-pytho
=> CACHED [ 3/14] RUN apt-get install -y python3-pip
=> CACHED [ 4/14] RUN mkdir -p /opt/server
=> CACHED [ 5/14] ADD ./docker/uwsgi.ini /opt/server
=> CACHED [ 6/14] ADD ./docker/nginx.conf /etc/nginx/conf.d/default.conf
=> CACHED [ 7/14] ADD ./docker/start_server.sh /opt/server
=> CACHED [ 8/14] RUN chmod +x /opt/server/start_server.sh
=> CACHED [ 9/14] ADD requirements.txt /opt/server
=> CACHED [10/14] RUN pip3 install -r /opt/server/requirements.txt
=> CACHED [11/14] RUN mkdir -p /opt/code
=> CACHED [12/14] ADD ./src/ /opt/code
=> CACHED [13/14] WORKDIR /opt/code
=> CACHED [14/14] RUN python3 manage.py collectstatic --noinput
=> exporting to image
=> => exporting layers
=> => writing image sha256:7be9ae2ab0e16547480aef6d32a11c2ccaa3da4aa5efbfddedb888681b8e10fa
=> => naming to docker.io/library/example
要运行服务,启动容器,将其端口 8000 映射到本地端口,例如,local 8000:
$ docker run -p 8000:8000 example
[uWSGI] getting INI configuration from /opt/server/uwsgi.ini
*** Starting uWSGI 2.0.18-debian (64bit) on [Sat Jul 31 20:07:20 2021] ***
compiled with version: 10.0.1 20200405 (experimental) [master revision 0be9efad938:fcb98e4978a:705510a708d3642c9c962beb663c476167e4e8a4] on 11 April 2020 11:15:55
os: Linux-5.10.25-linuxkit #1 SMP Tue Mar 23 09:27:39 UTC 2021
nodename: b01ce0d2a335
machine: x86_64
clock source: unix
pcre jit disabled
detected number of CPU cores: 2
current working directory: /opt/code
detected binary path: /usr/bin/uwsgi-core
setuid() to 33
chdir() to /opt/code
your memory page size is 4096 bytes
detected max file descriptor number: 1048576
lock engine: pthread robust mutexes
thunder lock: disabled (you can enable it with --thunder-lock)
uwsgi socket 0 bound to UNIX address /tmp/uwsgi.sock fd 3
Python version: 3.8.10 (default, Jun 2 2021, 10:49:15) [GCC 9.4.0]
*** Python threads support is disabled. You can enable it with --enable-threads ***
Python main interpreter initialized at 0x55a60f8c2a40
your server socket listen backlog is limited to 100 connections
your mercy for graceful operations on workers is 60 seconds
mapped 145840 bytes (142 KB) for 1 cores
*** Operational MODE: single process ***
WSGI app 0 (mountpoint='') ready in 1 seconds on interpreter 0x55a60f8c2a40 pid: 11 (default app)
*** uWSGI is running in multiple interpreter mode ***
spawned uWSGI master process (pid: 11)
spawned uWSGI worker 1 (pid: 13, cores: 1)
完成此操作后,您可以通过本地地址 http://localhost:8000 访问服务;例如,访问 URL http://localhost:8000/api/users/jaime/collection:

图 9.7:微帖子列表
您将在启动容器的屏幕上看到访问日志:
[pid: 13|app: 0|req: 2/2] 172.17.0.1 () {42 vars in 769 bytes} [Sat Jul 31 20:28:56 2021] GET /api/users/jaime/collection => generated 10375 bytes in 173 msecs (HTTP/1.1 200) 8 headers in 391 bytes (1 switches on core 0)
可以使用 docker stop 命令优雅地停止容器。为此,您需要首先使用 docker ps 命令发现容器 ID:
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
b01ce0d2a335 example "/bin/bash /opt/serv…" 23 minutes ago Up 23 minutes 0.0.0.0:8000->8000/tcp, :::8000->8000/tcp hardcore_chaum
$ docker stop b01ce0d2a335
b01ce0d2a335
容器日志将在捕获 Docker 发送的 SIGTERM 信号时显示详细信息,然后退出:
Caught SIGTERM signal! Sending graceful stop to uWSGI through the master-fifo
Exiting, bye!
为了能够设置此示例,我们做出了一些有意识的决策,简化了与典型服务相比的操作。
注意事项
请记住查看 第五章,十二要素应用方法,以了解定义的 API 并更好地理解它。
Django settings.py 文件中的 DEBUG 模式设置为 True,这使得在触发例如 404 或 500 错误时,我们可以看到更多信息。在生产环境中,应禁用此参数,因为它可能会泄露关键信息。
STATIC_ROOT 和 STATIC_URL 参数需要在 Django 和 nginx 之间协调,以便指向同一位置。这样,collectstatic 命令将数据存储在 nginx 将从中获取数据的地方。
最重要的细节是使用 SQLite 数据库而不是内部数据库。此数据库存储在容器的文件系统中的 src/db.sqlite3 文件中。这意味着如果容器停止并重新启动,任何更改都将被销毁。
GitHub 仓库中的 db.sqlite3 文件包含一些为了方便存储的信息,两个用户 jaime 和 dana,每人都有几条微帖子。API 目前还没有定义创建新用户的方式,因此需要通过 Django 工具或直接操作 SQL 来创建它们。这些用户是为了演示目的添加的。
作为练习,创建一个脚本,在构建过程中将信息作为种子添加到数据库中。
通常,这种数据库使用不适合生产使用,需要连接到容器外部的数据库。这显然需要一个可用的外部数据库,这会复杂化设置。
现在我们已经知道了如何使用容器,我们或许可以启动另一个带有数据库的 Docker 容器,例如 MySQL,以获得更好的配置。
容器化数据库对于生产来说不是一个好主意。一般来说,容器非常适合经常变化的无状态服务,因为它们可以轻松启动和停止。数据库通常非常稳定,有很多服务为托管数据库做出安排。容器带来的优势对于典型的数据库来说并不相关。
这并不意味着它没有在生产环境中的用途。例如,它是一个很好的本地开发选项,因为它允许轻松创建可复制的本地环境。
如果我们想要创建多个容器并将它们连接起来,比如一个作为数据存储后端的 Web 服务器和数据库,而不是单独启动所有容器,我们可以使用编排工具。
或许是编排和 Kubernetes
管理多个容器并将它们连接起来被称为编排它们。部署在容器中的微服务必须进行编排,以确保多个微服务能够相互连接。
这个概念包括诸如发现其他容器所在位置、服务之间的依赖关系以及生成相同容器的多个副本等细节。
编排工具非常强大,但也非常复杂,需要你熟悉许多术语。要完全解释它们超出了本书的范围,但我们将指出一些并给出简要介绍。请参阅下文中的链接文档以获取更多信息。
有几种工具可以执行编排,其中最常见的是 docker-compose 和 Kubernetes。
docker-compose 是 Docker 通用服务的一部分。它在小型部署或本地开发中工作得非常好。它定义了一个包含不同服务定义及其可用名称的单个 YAML 文件。它可以用来替代许多 docker build 和 docker run 命令,因为它可以在 YAML 文件中定义所有参数。
您可以在此处查看 Docker Compose 的文档:docs.docker.com/compose/。
Kubernetes 针对更大规模的部署和集群,允许生成一个完整的逻辑结构来定义容器如何相互连接,从而允许对底层基础设施进行抽象。
在 Kubernetes 中配置的任何物理(或虚拟)服务器都称为 节点。所有节点定义了集群。每个节点都由 Kubernetes 处理,Kubernetes 将在节点之间创建网络,并将不同的容器分配给每个节点,同时考虑到它们上的可用空间。这意味着节点的数量、位置或类型不需要由服务来处理。
相反,集群中的应用程序在逻辑层中分布。可以定义几个元素:
-
Pod。Pod 是 Kubernetes 中定义的最小单元,它被定义为作为一个单元运行的容器组。通常,Pod 将仅包含一个容器,但在某些情况下,它们可能包含多个。Kubernetes 中的所有内容都在 Pod 中运行。
-
部署。Pod 的集合。部署将定义所需的副本数量,并创建适当数量的 Pod。同一部署的每个 Pod 可以位于不同的节点上,但这由 Kubernetes 控制。
由于部署控制 Pod 的数量,如果一个 Pod 崩溃,部署将重新启动它。此外,可以通过创建自动扩展器等来操纵部署以更改数量。如果要在 Pod 中部署的镜像更改,部署将创建具有正确镜像的新 Pod,并相应地根据滚动更新或其他策略删除旧 Pod。
-
服务。一个可以用来将请求路由到特定 Pod 的标签,充当 DNS 名称。通常,这将指向为部署创建的 Pod。这允许系统中的其他 Pod 向已知位置发送请求。请求将在不同的 Pod 之间进行负载均衡。
-
入口。对服务的外部访问。这将映射一个传入的 DNS 到一个服务。入口允许应用程序对外暴露。外部请求将通过通过入口进入、被引导到服务、然后由特定的 Pod 处理的过程。
一些组件可以在 Kubernetes 集群中描述,例如 ConfigMaps,定义可用于配置目的的键值对;Volumes 用于在 Pod 之间共享存储;以及 Secrets 用于定义可以注入到 Pod 中的秘密值。
Kubernetes 是一个出色的工具,可以处理相当大的集群,拥有数百个节点和数千个 Pod。它也是一个复杂的工具,需要你学习如何使用它,并且有一个显著的学习曲线。它现在非常受欢迎,关于它的文档也很多。官方文档可以在以下位置找到:kubernetes.io/docs/home/。
摘要
在本章中,我们描述了单体和微服务架构。我们首先介绍了单体架构以及它通常是如何成为一个“默认架构”的,作为一个应用程序被设计时自然产生的。单体被创建为包含单个块内所有代码的单元块。
与此相比,微服务架构将整个应用程序的功能划分为更小的部分,以便它们可以并行工作。为了使这种策略有效,它需要定义清晰的边界并记录如何连接不同的服务。与单体架构相比,微服务旨在生成更结构化的代码,并通过将它们划分为更小、更易于管理的系统来控制大型代码库。
我们讨论了最佳架构是什么,以及如何选择是否将系统设计为单体或微服务。每种方法都有其优缺点,但一般来说,系统最初是单体的,当代码库和开发人员数量达到一定规模后,才会将代码库划分为更小的微服务。
两种架构之间的区别不仅仅是技术上的。它主要涉及在系统上工作的开发者需要如何沟通和划分团队。我们讨论了需要考虑的不同方面,包括团队的结构和规模。
由于从旧的单体架构迁移到新的微服务架构是一个非常常见的案例,我们讨论了如何着手这项工作,分析它,并执行它,使用一个四阶段路线图:分析、设计、计划和执行。
我们讨论了如何将服务容器化(特别是微服务)可能带来的帮助。我们探讨了如何使用 Docker 作为工具来容器化服务,以及它的多种优点和用途。我们还提供了一个将示例 Web 服务容器化的例子,如第五章中所述的十二要素应用方法。
最后,我们简要描述了使用编排工具来协调和多个容器之间相互通信的用法,以及最受欢迎的 Kubernetes。然后我们简要介绍了 Kubernetes。
你可以在本书作者所著的《Hands-On Docker for Microservices with Python》一书中了解更多关于微服务以及如何从单体架构迁移到微服务架构的信息,该书对这些概念进行了扩展,并深入探讨了这些内容。
第三部分
实现
设计是一个重要的阶段,以有一个行动计划,但真正的发展过程的核心在于实现。
实施通用架构设计将需要关于如何构建和开发代码的多个较小设计决策。设计有多好无关紧要,执行是关键,它将验证或调整准备好的计划。
因此,一个稳健的实现需要开发者对自己的编码能力持怀疑态度,代码在被视为“完成”之前需要彻底测试。这是一个正常操作,并且当持续进行时,会产生良好的级联效应,不仅提高代码质量、减少问题数量,而且增加团队预见弱点并加固它们的能力,以确保一旦投入运行,软件是可靠的,并且尽可能少有问题。
我们将看到如何进行测试,包括使用测试驱动设计(TDD),这是一种将测试置于开发过程中心的实践。
有时某些代码方面需要多次共享以供重用。在 Python 世界中,一个强大的工具是轻松创建和共享模块,这些模块可以实施。我们将看到如何构建、创建和维护标准的 Python 模块,包括将它们上传到 PyPI,这是第三方包的标准 Python 仓库。
本书本节包括以下章节:
-
第十章,测试和 TDD,解释了不同的测试方法、测试驱动设计方法论以及编写测试的简单工具
-
第十一章,包管理,描述了如何构建代码以便在不同的系统部分使用或与更广泛的社区共享
第十章:测试和 TDD
无论开发者多么优秀,他们都会编写出不一定总是正确执行的代码。这是不可避免的,因为没有任何开发者是完美的。但这也因为预期的结果有时并不是在编码过程中会想到的。
设计很少按预期进行,在实施过程中总会有来回讨论,直到它们被精炼并变得正确。
每个人都有自己的计划,直到他们被打得满嘴是血。 —— 迈克·泰森
编写软件因其极端的灵活性而闻名地困难,但与此同时,我们可以使用软件来双重检查代码是否正在执行其应有的操作。
请注意,就像任何其他代码一样,测试也可能有错误。
编写测试可以在代码新鲜时检测到问题,并带有一些合理的怀疑来验证预期的结果是否是实际的结果。在本章中,我们将看到如何轻松编写测试,以及不同的策略来编写不同类型的测试,以捕获不同种类的问题。
我们将描述如何在 TDD(测试驱动开发)下工作,这是一种通过首先定义测试来确保验证尽可能独立于实际代码实现的方法。
我们还将展示如何使用常见的单元测试框架、标准unittest模块以及更高级和强大的pytest在 Python 中创建测试。
注意,本章比其他章节要长一些,主要是因为需要展示示例代码。
在本章中,我们将涵盖以下主题:
-
测试代码
-
不同的测试级别
-
测试哲学
-
测试驱动开发
-
Python 单元测试简介
-
测试外部依赖
-
高级 pytest
让我们从一些关于测试的基本概念开始。
测试代码
讨论代码测试时的第一个问题很简单:我们究竟指的是什么测试代码?
虽然对此有多种答案,但从最广泛的意义上讲,答案可能是“任何在最终客户到达之前检查应用程序是否正确工作的程序。”在这个意义上,任何正式或非正式的测试程序都将满足定义。
最轻松的方法,有时在只有一个或两个开发者的小型应用程序中可以看到,就是不去创建特定的测试,而是进行非正式的“完整应用程序运行”,检查新实现的功能是否按预期工作。
这种方法可能适用于小型、简单的应用程序,但主要问题是确保旧功能保持稳定。
但是,对于大型且复杂的、高质量的软件,我们需要对测试更加小心。所以,让我们尝试给出一个更精确的测试定义:测试是任何记录在案的程序,最好是自动化的,它从一个已知的设置出发,检查应用程序的不同元素在到达最终客户之前是否正确工作。
如果我们将与之前定义的差异进行检查,有几个关键词。让我们逐一检查它们,看看不同的细节:
-
已记录的:与之前的版本相比,目标应该是测试应该被记录下来。这样,如果需要的话,你可以精确地重新执行它们,并且可以比较它们以发现盲点。
测试可以有多种记录方式,要么指定要运行的步骤和预期结果列表,要么创建运行测试的代码。主要思想是测试可以被分析,可以被不同的人多次运行,如果需要的话可以更改,并且有一个清晰的设计和结果。
-
最好是自动化的:测试应该能够自动运行,尽可能减少人为干预。这允许你触发持续集成技术,反复运行许多测试,创建一个“安全网”,能够尽早捕捉到意外错误。我们说“最好是”是因为也许有些测试是完全不可能或非常昂贵的自动化。无论如何,目标应该是让绝大多数测试自动化,让计算机承担繁重的工作,节省宝贵的人类时间。也有多种软件工具可以帮助你运行测试。
-
从已知设置开始:为了能够独立运行测试,我们需要知道在运行测试之前系统的状态应该是什么。这确保了测试的结果不会创建一个可能会干扰下一个测试的特定状态。在测试前后,可能需要进行一些清理工作。
与不考虑初始或最终状态相比,这可能会使批量运行测试的速度变慢,但它将创建一个坚实的基础,以避免问题。
作为一般规则,尤其是在自动化测试中,测试执行的顺序应该是无关紧要的,以避免交叉污染。这说起来容易做起来难,在某些情况下,测试的顺序可能会造成问题。例如,测试 A 创建了一个条目,测试 B 读取。如果测试 B 单独运行,它将失败,因为它期望 A 创建的条目。这些情况应该得到修复,因为它们可能会极大地复杂化调试。此外,能够独立运行测试也允许它们并行化。
- 应用程序的不同元素:大多数测试不应该针对整个应用程序,而应该针对其较小的部分。我们稍后会更多地讨论不同的测试级别,但测试应该具体说明它们正在测试什么,并覆盖不同的元素,因为覆盖更多范围的测试将更昂贵。
测试的一个关键要素是获得良好的投资回报率。设计和运行测试需要时间,这些时间需要被充分利用。任何测试都需要维护,这应该是值得的。在整个章节中,我们将讨论测试的这个重要方面。
有一种重要的测试类型我们没有在这个定义中涵盖,这被称为探索性测试。这些测试通常由质量保证工程师运行,他们使用最终应用程序而没有明确的先入之见,但试图预先发现问题。如果应用程序有一个面向客户的用户界面,这种测试风格在检测设计阶段未检测到的不一致和问题方面可能非常有价值。
例如,一个优秀的质量保证工程师能够说出页面 X 上的按钮颜色与页面 Y 上的按钮颜色不同,或者按钮不够明显以至于无法执行操作,或者执行某个特定操作有一个不明显或在新界面中不可能的前提条件。任何用户体验(UX)检查都可能属于这一类别。
从本质上讲,这种测试不能“设计”或“文档化”,因为它最终取决于解释和良好的洞察力来理解应用程序是否“感觉正确”。一旦发现问题,就可以记录下来以避免。
虽然这确实很有用且被推荐,但这种测试风格更多的是一种艺术而不是工程实践,我们不会详细讨论它。
这个一般定义有助于开始讨论,但我们可以更具体地讨论通过测试时系统受测试的部分来定义的不同测试。
不同的测试级别
正如我们之前所描述的,测试应该覆盖系统的不同元素。这意味着一个测试可以针对系统的小部分或大部分(或整个系统),试图缩小其作用范围。
当测试系统的小部分时,我们减少了测试的复杂性和范围。我们只需要调用系统的那一小部分,并且设置更容易开始。一般来说,要测试的元素越小,测试的速度越快,越容易。
我们将定义三个不同级别或种类的测试,从小范围到大范围:
-
单元测试,用于检查服务的一部分
-
集成测试,用于检查单个服务作为一个整体
-
系统测试,用于检查多个服务协同工作的情况
名称可能实际上有很大差异。在这本书中,我们不会对定义过于严格,而是定义软限制,并建议找到适合您特定项目的平衡点。不要害羞,对每个测试的适当级别做出决定,并定义自己的命名法,同时始终牢记创建测试所需的努力,以确保它们总是值得的。
级别的定义可能有些模糊。例如,集成测试和单元测试可以并列定义,在这种情况下,它们之间的区别可能更多地体现在学术上。
让我们更详细地描述每个级别的细节。
单元测试
最小类型的测试也是通常投入最多努力的测试,即单元测试。这种测试检查的是一小段代码的行为,而不是整个系统。这个代码单元可能小到只是一个单个函数,或者测试一个单个 API 端点,等等。
正如我们上面所说的,关于单元测试应该有多大,基于“单元”是什么以及它是否实际上是一个单元,存在很多争议。例如,在某些情况下,人们只有在测试涉及单个函数或类时才会将其称为单元测试。
由于单元测试检查的是功能的一个小部分,因此它可以非常容易地设置并快速运行。因此,创建新的单元测试既快又能够彻底测试系统,确保构成整个系统的小部分能够按预期工作。
单元测试的目的是深入检查服务中定义的功能的行为。任何外部请求或元素都应该被模拟,这意味着它们被定义为测试的一部分。我们将在本章的后面更详细地介绍单元测试,因为它们是 TDD 方法的关键元素。
集成测试
下一个层次是集成测试。这是检查一个服务或几个服务的整体行为。
集成测试的主要目标是确保不同的服务或同一服务内的不同模块可以相互工作。在单元测试中,外部请求是模拟的,而集成测试使用的是真实的服务。
可能仍然需要模拟外部 API。例如,模拟外部支付提供者进行测试。但,总的来说,尽可能多地使用真实服务进行集成测试,因为测试的目的在于测试不同的服务是否能够协同工作。
需要注意的是,通常情况下,不同的服务将由不同的开发者或甚至不同的团队开发,他们对于特定 API 实现的理解可能会有所不同,即使在定义良好的规范下也是如此。
集成测试的设置比单元测试更复杂,因为需要正确设置更多的元素。这使得集成测试比单元测试更慢、更昂贵。
集成测试非常适合检查不同的服务是否能够协同工作,但也有一些局限性。
集成测试通常不如单元测试彻底,它们主要关注检查基本功能并遵循快乐路径。快乐路径是测试中的一个概念,意味着测试用例应该不会产生错误或异常。
预期错误和异常通常在单元测试中进行测试,因为它们也是可能失败的因素。但这并不意味着每个集成测试都应该遵循快乐路径;一些集成错误可能值得检查,但总的来说,快乐路径测试的是预期的通用行为。它们将构成大部分的集成测试。
系统测试
最后一级是系统级。系统测试检查所有不同的服务是否能够正确协同工作。
进行这类测试的一个要求是系统中实际上存在多个服务。如果不是这样,它们与低级别的测试没有区别。这些测试的主要目标是检查不同的服务能否协同工作,并且配置是否正确。
系统测试缓慢且难以实施。它们需要整个系统设置好,所有不同的服务都正确配置。创建这样的环境可能很复杂。有时,这太难了,唯一实际执行系统测试的方法是在实时环境中运行它们。
环境配置是这些测试检查的重要部分。这可能会使它们在测试每个环境时都变得很重要,包括实时环境。
虽然这并不理想,但有时这是不可避免的,并且有助于在部署后提高信心,确保新代码能够正确运行。在这种情况下,考虑到限制条件,应该只运行最少量的测试,因为实时环境至关重要。要运行的测试还应该测试最大量的常用功能和服务,以便尽可能快地检测到任何关键问题。这组测试有时被称为验收测试或冒烟测试。它们可以手动运行,作为一种确保一切看起来正确的手段。
当然,冒烟测试不仅可以在实时环境中运行,还可以作为一种确保其他环境正常工作的手段。
冒烟测试应该非常清晰、有良好的文档记录,并且精心设计以涵盖整个系统的最关键部分。理想情况下,它们还应该是只读的,这样它们在执行后不会留下无用的数据。
测试哲学
与测试相关的所有事情中的一个关键问题是:为什么要测试?我们试图通过它达到什么目标?
正如我们所看到的,测试是一种确保代码行为符合预期的方式。测试的目标是在代码发布和真实用户使用之前检测可能的问题(有时称为缺陷)。
缺陷和错误之间有一个细微的差别。错误是一种缺陷,指的是软件的行为不符合预期。例如,某些输入会产生意外的错误。缺陷更为普遍。一个缺陷可能是指按钮不够明显,或者页面上显示的标志不是正确的。一般来说,测试在检测错误方面比检测其他缺陷更有效,但请记住我们之前提到的探索性测试。
一个未被发现的缺陷被部署到实际系统中去修复的成本相当高。首先,它需要被发现。在一个活动频繁的实际应用中,发现问题可能很困难(尽管我们将在第十六章,持续架构中讨论),但更糟糕的是,它通常是由使用该应用的用户检测到的。用户可能无法正确地传达问题,因此问题仍然存在,造成问题或限制活动。检测问题的用户可能会放弃系统,或者至少他们对系统的信心会下降。
任何声誉损失都将是不利的,但同时也可能很难从用户那里提取足够的信息来确切了解发生了什么以及如何修复它。这使得从发现问题到修复问题的周期变得很长。
任何测试系统都会提高早期修复缺陷的能力。我们不仅可以创建一个模拟确切相同问题的特定测试,还可以创建一个定期执行测试的框架,以明确的方法来检测和修复问题。
不同的测试级别对这种成本有不同的影响。一般来说,任何可以在单元测试级别检测到的问题,在那里修复的成本都会更低,成本从那里开始增加。设计和运行单元测试比集成测试更容易、更快,而集成测试的成本比系统测试低。
不同的测试级别可以理解为不同的层级,它们捕捉可能的问题。如果问题出现,每个层级都会捕捉不同的问题。越接近过程的开始(编码时的设计和单元测试),创建一个能够检测和警告问题的密集网络就越便宜。问题修复的成本随着它远离过程开始时的控制环境而增加。

图 10.1:修复缺陷的成本随着发现时间的延迟而增加
一些缺陷在单元测试级别是无法检测到的,比如不同部分的集成。这就是下一个级别发挥作用的地方。正如我们所见,最糟糕的情况是没有发现问题,它影响了实际系统上的真实用户。
但拥有测试不仅是一次捕捉问题的好方法。因为测试可以保留下来,并在新的代码更改上运行,它还在开发过程中提供了一个安全网,以确保创建新代码或修改代码不会影响旧功能。
这是最有力的论据之一,即按照持续集成实践自动和持续地运行测试。开发者可以专注于正在开发的功能,而持续集成工具将运行每个测试,如果某个测试出现问题,会提前警告。之前引入的功能出现问题而失败的称为回归。
回归问题相当常见,因此拥有良好的测试覆盖率以防止它们未被检测到是非常好的。可以引入覆盖先前功能的特定测试,以确保它按预期运行。这些是回归测试,有时在检测到回归问题后添加。
拥有良好的测试来检查系统的行为的好处之一是,代码本身可以大量更改,因为我们知道行为将保持不变。这些更改可以用来重构代码、清理它,并在一般情况下改进它。这些更改被称为重构代码,即在不改变代码预期行为的情况下改变代码的编写方式。
现在,我们应该回答“什么是好的测试?”这个问题。正如我们讨论的,编写测试不是免费的,需要付出努力,我们需要确保它是值得的。我们如何创建好的测试?
如何设计一个优秀的测试
设计良好的测试需要一定的思维模式。在设计覆盖特定功能的代码时,目标是使代码实现该功能,同时保持高效,编写清晰、甚至可以说是优雅的代码。
测试的目标是确保功能符合预期行为,并且所有可能出现的不同问题都会产生有意义的输出。
现在,为了真正测试功能,心态应该是尽可能多地压榨代码。例如,让我们想象一个函数 divide(A, B),它将两个介于 -100 和 100 之间的整数 A 除以 B:A 介于 B 之间。
在接近测试时,我们需要检查这个测试的极限是什么,试图检查函数是否以预期的行为正确执行。例如,可以创建以下测试:
| 操作 | 预期行为 | 备注 |
|---|---|---|
divide(10, 2) |
return 5 |
基本情况 |
divide(-20, 4) |
return -5 |
除以一个负整数和一个正整数 |
divide(-10, -5) |
return 2 |
除以两个负整数 |
divide(12, 2) |
return 5 |
非精确除法 |
divide(100, 50) |
return 2 |
A 的最大值 |
divide(101, 50) |
产生输入错误 |
A 的值超过最大值 |
divide(50, 100) |
return 0 |
B 的最大值 |
divide(50, 101) |
产生输入错误 |
B 的值超过最大值 |
divide(10, 0) |
产生异常 |
除以零 |
divide('10', 2) |
产生输入错误 |
参数 A 的格式无效 |
divide(10, '2') |
产生输入错误 |
参数 B 的格式无效 |
注意我们如何测试不同的可能性:
-
所有参数的常规行为都是正确的,除法操作也是正确的。这包括正数和负数,精确除法和非精确除法。
-
在最大值和最小值之间的值:我们检查最大值是否被正确击中,并且下一个值被正确检测。
-
除以零:功能上已知的一个限制,应该产生一个预定的响应(异常)。
-
错误的输入格式。
我们可以为简单的功能真正创建很多测试用例!请注意,所有这些情况都可以扩展。例如,我们可以添加divide(-100, 50)和divide(100, -50)的情况。在这些情况下,问题是相同的:这些测试是否增加了对问题的更好检测?
最好的测试是真正对代码施加压力并确保其按预期工作的测试,尽力覆盖最困难的用例。让测试向被测试的代码提出困难的问题,是让你的代码为实际操作做好准备的最佳方式。在负载下的系统将看到各种组合,因此最好的准备是创建尽可能努力寻找问题的测试,以便在进入下一阶段之前解决这些问题。
这类似于足球训练,一系列非常苛刻的练习被提出,以确保受训者能够在比赛中进行表现。确保你的训练计划足够艰难,以便为高强度的比赛做好准备!
在测试数量和避免重复测试已由现有测试覆盖的功能(例如,创建一个大的表格,用很多除法来划分数字)之间找到适当的平衡,可能很大程度上取决于被测试的代码和你们组织中的实践。某些关键区域可能需要更彻底的测试,因为那里的失败可能更重要。
例如,任何外部 API 都应该仔细测试任何输入,并对此进行真正的防御,因为外部用户可能会滥用外部 API。例如,测试当在整数字段中输入字符串时会发生什么,添加了无穷大或NaN(非数字)值,超出了有效负载限制,列表或页面的最大大小被超过等情况。
相比之下,大部分内部接口将需要较少的测试,因为内部代码不太可能滥用 API。例如,如果divide函数仅是内部的,可能不需要测试输入格式是否错误,只需检查是否尊重了限制。
注意,测试是在代码实现独立进行的。测试定义纯粹是从外部视角对要测试的函数进行,而不需要了解其内部结构。这被称为黑盒测试。一个健康的测试套件总是从这种方法开始。
作为编写测试的开发者,一个关键的能力是脱离对代码本身的知识,独立地对待测试。
测试可以如此独立,以至于可能需要独立的人员来创建测试,就像一个 QA 团队进行测试一样。不幸的是,这种方法对于单元测试来说是不可能的,单元测试很可能会由编写代码的同一开发者创建。
在某些情况下,这种外部方法可能不足以。如果开发者知道存在一些可能存在问题的特定区域,那么补充一些检查功能性的测试可能是个好主意,这些功能性从外部视角看可能不明显。
例如,一个基于某些输入计算结果的函数可能有一个内部点,其中算法改变以使用不同的模型来计算。外部用户不需要知道这些信息,但添加一些检查以验证转换是否正确将是有益的。
这种测试被称为白盒测试,与之前讨论的黑盒方法相对。
重要的是要记住,在测试套件中,白盒测试应该始终是次要的黑盒测试。主要目标是测试从外部视角的功能性。白盒测试可能是一个很好的补充,特别是在某些方面,但它应该有较低的优先级。
发展能够创建良好黑盒测试的能力非常重要,并且应该传达给团队。
黑盒测试试图避免一个常见问题,即同一个开发者编写了代码和测试,然后检查代码中实现的功能的解释是否按预期工作,而不是检查从外部端点看它是否按预期工作。我们稍后会看看 TDD,它试图通过在编写代码之前编写测试来确保测试的创建不考虑实现。
结构化测试
在结构方面,特别是对于单元测试,使用安排-行动-断言(AAA)模式来结构化测试是一个很好的方法。
这种模式意味着测试分为三个不同的阶段:
-
安排:为测试准备环境。这包括所有设置,以确保在执行下一步之前系统处于稳定状态。
-
行动:执行测试的目标行为。
-
断言:检查行为的结果是预期的。
测试被结构化为一个句子,如下所示:
给定(安排)一个已知的环境,行动(行动)产生指定的结果(断言)
这种模式有时也被称为给定、当、然后,因为每个步骤都可以用这些术语来描述。
注意,这种结构旨在使所有测试都独立,并且每个测试都测试一个单独的东西。
一种常见的不同模式是在测试中分组行动步骤,在一个测试中测试多个功能。例如,测试写入值是否正确,然后检查搜索该值是否返回正确的值。这不会遵循 AAA 模式。相反,为了遵循 AAA 模式,应该创建两个测试,第一个测试验证写入是否正确,第二个测试在搜索之前,将值作为在安排步骤中的设置部分创建。
注意,无论测试是通过代码执行还是手动运行,这种结构都可以使用,尽管它们更多地用于自动化测试。当手动运行时,Arrange 阶段可能需要花费很长时间才能为每个测试生成,导致在该阶段花费大量时间。相反,手动测试通常按照我们上面描述的模式分组,执行一系列的 Act 和 Assert,并使用前一个阶段的输入作为下一个阶段的设置。这创建了一个依赖性,需要按照特定的顺序运行测试,这对单元测试套件来说不是很好,但可能对烟雾测试或其他环境中 Arrange 步骤非常昂贵的情况更好。
同样,如果要测试的代码是纯函数式的(意味着只有输入参数决定了其状态,如上面的divide示例),则不需要 Arrange 步骤。
让我们看看使用这种结构创建的代码示例。假设我们有一个想要测试的方法,称为method_to_test。该方法属于名为ClassToTest的类。
def test_example():
# Arrange step
# Create the instance of the class to test
object_to_test = ClassToTest(paramA='some init param',
paramB='another init param')
# Act step
response = object_to_test.method_to_test(param='execution_param')
# Assert step
assert response == 'expected result'
每个步骤都非常清晰地定义。第一个步骤是准备,在这种情况下,是我们想要测试的类中的一个对象。请注意,我们可能需要添加一些参数或一些准备,以便对象处于一个已知的起始点,以便下一个步骤按预期工作。
Act 步骤仅生成要测试的动作。在这种情况下,使用适当的参数调用准备好的对象的method_to_test方法。
最后,Assert 步骤非常直接,只是检查响应是否是预期的。
通常,Act 和 Assert 步骤都很容易定义和编写。Arrange 步骤通常是测试中需要的大部分努力所在。
使用 AAA 模式进行测试时出现的另一个常见模式是在 Arrange 步骤中创建用于测试的通用函数。例如,创建一个基本环境,这可能需要复杂的设置,然后有多个副本,其中 Act 和 Assert 步骤不同。这减少了代码的重复。
例如:
def create_basic_environment():
object_to_test = ClassToTest(paramA='some init param',
paramB='another init param')
# This code may be much more complex and perhaps have
# 100 more lines of code, because the basic environment
# to test requires a lot of things to set up
return object_to_test
def test_exampleA():
# Arrange
object_to_test = create_basic_environment()
# Act
response = object_to_test.method_to_test(param='execution_param')
# Assert
assert response == 'expected result B'
def test_exampleB():
# Arrange
object_to_test = create_basic_environment()
# Act
response = object_to_test.method_to_test(param='execution_param')
# Assert
assert response == 'expected result B'
我们稍后会看到如何结构化多个非常相似的测试,以避免重复,这在拥有大型测试套件时是一个问题。拥有大型测试套件对于创建良好的测试覆盖率很重要,如我们上面所看到的。
在测试中,重复在某种程度上是不可避免的,甚至在某种程度上是有益的。当因为某些更改而更改代码的一部分的行为时,测试需要相应地更改以适应这些更改。这种更改有助于衡量更改的大小,并避免轻率地做出大的更改,因为测试将作为受影响功能的一个提醒。
然而,无意义的重复并不好,我们稍后会看到一些减少重复代码量的选项。
测试驱动开发
一种非常流行的编程方法是测试驱动开发或TDD。TDD 包括将测试置于开发体验的中心。
这基于我们在本章前面提出的一些想法,尽管是以更一致的观点来工作的。
TDD 开发软件的流程如下:
-
决定将新功能添加到代码中。
-
编写一个新的测试来定义新的功能。注意,这是在代码之前完成的。
-
运行测试套件以显示它正在失败。
-
然后将新功能添加到主代码中,重点是简洁性。应该只添加所需的功能,而不添加额外的细节。
-
运行测试套件以显示新测试正在工作。这可能需要多次进行,直到代码准备就绪。
-
新功能已准备就绪!现在可以重构代码以改进它,避免重复,重新排列元素,将其与先前存在的代码分组等。
对于任何新的功能,循环可以再次开始。
如您所见,TDD 基于三个主要思想:
-
在编写代码之前编写测试:这防止了创建与当前实现过于紧密耦合的测试的问题,迫使开发者在编写之前先思考测试和功能。这也迫使开发者在编写功能之前检查测试是否实际失败,确保以后发现的问题。这与我们在“如何设计一个优秀的测试”部分中描述的黑盒测试方法类似。
-
持续运行测试:过程的关键部分是运行整个测试套件,以确保系统中的所有功能都是正确的。这需要反复进行,每次创建新测试时都要这样做,而且在功能编写过程中也是如此。在 TDD(测试驱动开发)中,运行测试是开发的一个基本部分。这确保了所有功能始终得到检查,并且代码在所有时间都能按预期工作,因此任何错误或不一致都可以迅速解决。
-
以非常小的增量工作:专注于手头的任务,这样每一步都会构建和扩展一个大的测试套件,深入覆盖代码的所有功能。
这个大的测试套件创建了一个安全网,允许你经常进行代码的重构,无论是大重构还是小重构,因此可以不断改进代码。小的增量意味着具体的测试,在添加代码之前需要思考。
这个想法的一个扩展是只编写完成任务所需的代码,而不是更多。这有时被称为YAGNI原则(You Ain't Gonna Need It)。这个原则的目的是防止过度设计或为“可预见的未来请求”编写代码,实际上这些请求有很大概率永远不会实现,而且更糟糕的是,这会使代码在其他方向上更难以更改。鉴于软件开发在事先规划上众所周知地困难,重点应该放在保持事物的小规模上,不要过于超越自己。
这三个想法在开发周期中不断相互作用,并使测试始终处于开发过程的核心,因此这种实践被称为“测试驱动开发”。
TDD 的另一个重要优势是,如此重视测试意味着从一开始就会考虑代码的测试方式,这有助于设计易于测试的代码。此外,减少要编写的代码量,专注于它是否严格必要以通过测试,这降低了过度设计的发生概率。创建小型测试并在增量中工作的要求也倾向于生成模块化代码,这些代码以小单元组合在一起,但能够独立进行测试。
一般的流程是持续地与新的失败测试一起工作,让它们通过,然后进行重构,这有时被称为“红/绿/重构”模式:当测试失败时为红色,当所有测试都通过时为绿色。
重构是 TDD 过程中的一个关键方面。强烈鼓励不断改进现有代码的质量。这种方式工作的最佳结果之一是生成非常广泛的测试套件,覆盖代码功能的所有细节,这意味着重构代码时可以知道有一个坚实的基础,可以捕捉到代码更改引入的任何问题,并添加错误。
通过重构来提高代码的可读性、可用性等,这在提高开发者的士气和加快引入变更的速度方面有着良好的影响,因为这样可以保持代码的良好状态。
通常来说,不仅在 TDD 中,留出时间清理旧代码并改进它对于保持良好的变更节奏至关重要。陈旧的旧代码往往越来越难以处理,随着时间的推移,将其更改以进行更多变更将需要更多的努力。鼓励健康的习惯,关注代码的当前状态,并留出时间进行维护性改进,对于任何软件系统的长期可持续性至关重要。
TDD 的另一个重要方面是快速测试的要求。由于测试始终在 TDD 实践中运行,因此总执行时间非常重要。每个测试所需的时间应仔细考虑,因为测试套件的增长将使运行时间更长。
存在一个普遍的阈值,超过这个阈值注意力就会分散,因此运行时间超过大约 10 秒的测试将使它们不再是“同一操作的一部分”,这会风险开发者去想其他事情。
显然,在 10 秒内运行整个测试套件将非常困难,尤其是在测试数量增加的情况下。一个复杂应用的完整单元测试套件可能包含 10,000 个测试或更多!在现实生活中,有多种策略可以帮助缓解这一事实。
整个测试套件并不需要一直运行。相反,任何测试运行器都应该允许你选择要运行的测试范围,允许你在开发功能时减少每次运行要运行的测试数量。这意味着只运行与同一模块相关的测试,例如。在某些情况下,甚至可以运行单个测试来加快结果。
当然,在某个时候,应该运行整个测试套件。TDD 实际上与持续集成相一致,因为它也基于运行测试,这次是在代码被检出到仓库后自动运行。能够在本地运行一些测试以确保开发过程中一切正常,同时在将代码提交到仓库后,在后台运行整个测试套件,这种组合是非常好的。
无论如何,在 TDD 中,运行测试所需的时间很重要,观察测试的持续时间很重要,生成可以快速运行的测试是能够以 TDD 方式工作的关键。这主要通过创建覆盖代码小部分的测试来实现,因此可以保持设置时间在可控范围内。
TDD 实践与单元测试配合得最好。集成和系统测试可能需要一个大的设置,这与 TDD 工作所需的速度和紧密的反馈循环不兼容。
幸运的是,正如我们之前所看到的,单元测试通常是大多数项目中测试的重点。
将 TDD 引入新团队
在一个组织中引入 TDD 实践可能很棘手,因为它们改变了执行基本操作的方式,并且与通常的工作方式(在写代码后编写测试)有些相悖。
当考虑将 TDD 引入一个团队时,有一个可以充当团队其他成员的联系人并解决可能通过创建测试而出现的问题的倡导者是很好的。
TDD 在结对编程也普遍的环境中非常受欢迎,因此,在培训其他开发人员并引入这一实践的同时,让某人驱动一个会话是另一种可能性。
记住,TDD 的关键要素是迫使开发者首先思考如何测试特定的功能,然后再开始考虑实现。这种心态不是自然而然产生的,需要训练和实践。
在现有代码中应用 TDD 技术可能具有挑战性,因为在这种配置下,预存的代码可能难以测试,尤其是如果开发者对这种实践不熟悉。然而,对于新项目来说,TDD 效果很好,因为新代码的测试套件将与代码同时创建。在现有项目中启动一个新模块的混合方法,因此大部分代码都是新的,可以使用 TDD 技术进行设计,这减少了处理遗留代码的问题。
如果你想看看 TDD 是否对新代码有效,尝试从小处开始,使用一些小型项目和小型团队来确保它不会过于破坏性,并且原则可以正确消化和应用。有些开发者非常喜欢使用 TDD 原则,因为它符合他们的个性和他们处理开发过程的方式。记住,这并不一定适合每个人,并且开始这些实践需要时间,也许不可能 100%地应用它们,因为之前的代码可能会限制它。
问题与局限性
TDD 实践在业界非常流行且被广泛遵循,尽管它们有其局限性。其中一个是运行时间过长的大测试问题。在某些情况下,这些测试可能是不可避免的。
另一个问题是,如果这不是从一开始就做的,那么完全采用这种方法可能会有困难,因为代码的部分已经编写,可能还需要添加新的测试,违反了在编写代码之前创建测试的规则。
另一个问题是在要实现的功能不流动且未完全定义的情况下设计新代码。这需要实验,例如,设计一个函数来返回与输入颜色形成对比的颜色,例如,根据用户可选择的主题呈现对比颜色。这个函数可能需要检查它是否“看起来合适”,这可能需要调整,而使用预配置的单元测试很难实现。
这不是 TDD 特有的问题,但需要注意的一点是,要记住避免测试之间的依赖关系。这可能在任何测试套件中发生,但鉴于对创建新测试的关注,如果团队从 TDD 实践开始,这很可能是一个问题。依赖关系可能通过要求测试以特定顺序运行而引入,因为测试可能会污染环境。这通常不是故意为之,但在编写多个测试时无意中发生。
典型的效果是,如果独立运行,某些测试可能会失败,因为在这种情况下没有运行它们的依赖关系。
无论如何,请记住,TDD 并不一定是一切或无的东西,而是一套可以帮助你设计经过良好测试和高质量代码的想法和实践。系统中并非每个测试都需要使用 TDD 来设计,但其中很多可以。
TDD 流程的示例
让我们想象我们需要创建一个函数,它:
-
对于小于 0 的值,返回零
-
对于大于 10 的值,返回 100
-
对于介于两者之间的值,它返回该值的 2 的幂。注意,对于边缘,它返回输入的 2 的幂(0 对应于 0,100 对应于 10)
要以全 TDD 风格编写代码,我们从可能的最小测试开始。让我们创建最小的骨架和第一个测试。
def parameter_tdd(value):
pass
assert parameter_tdd(5) == 25
我们运行测试,并且测试失败时出现错误。现在,我们将使用纯 Python 代码,但稍后在本章中,我们将看到如何更有效地运行测试。
$ python3 tdd_example.py
Traceback (most recent call last):
File ".../tdd_example.py", line 6, in <module>
assert parameter_tdd(5) == 25
AssertionError
用例的实现相当直接。
def parameter_tdd(value):
return 25
是的,我们实际上返回了一个硬编码的值,但这确实是通过第一个测试所必需的全部。现在让我们运行测试,你将看到没有错误。
$ python3 tdd_example.py
但现在我们添加了对较低边缘的测试。虽然这两行,但它们可以被认为是同一个测试,因为它们检查边缘是否正确。
assert parameter_tdd(-1) == 0
assert parameter_tdd(0) == 0
assert parameter_tdd(5) == 25
让我们再次运行测试。
$ python3 tdd_example.py
Traceback (most recent call last):
File ".../tdd_example.py", line 6, in <module>
assert parameter_tdd(-1) == 0
AssertionError
我们需要添加代码来处理较低的边缘。
def parameter_tdd(value):
if value <= 0:
return 0
return 25
当运行测试时,我们看到它正在正确地运行测试。现在让我们添加参数来处理上边缘。
assert parameter_tdd(-1) == 0
assert parameter_tdd(0) == 0
assert parameter_tdd(5) == 25
assert parameter_tdd(10) == 100
assert parameter_tdd(11) == 100
这触发了相应的错误。
$ python3 tdd_example.py
Traceback (most recent call last):
File "…/tdd_example.py", line 12, in <module>
assert parameter_tdd(10) == 100
AssertionError
让我们添加更高的边缘。
def parameter_tdd(value):
if value <= 0:
return 0
if value >= 10:
return 100
return 25
这运行正确。我们并不确信所有代码都正确,我们真的想确保中间部分是正确的,所以我们添加了另一个测试。
assert parameter_tdd(-1) == 0
assert parameter_tdd(0) == 0
assert parameter_tdd(5) == 25
assert parameter_tdd(7) == 49
assert parameter_tdd(10) == 100
assert parameter_tdd(11) == 100
哎呀!现在显示了一个错误,这是由于初始的硬编码造成的。
$ python3 tdd_example.py
Traceback (most recent call last):
File "/…/tdd_example.py", line 15, in <module>
assert parameter_tdd(7) == 49
AssertionError
所以让我们修复它。
def parameter_tdd(value):
if value <= 0:
return 0
if value >= 10:
return 100
return value ** 2
这运行了所有的测试并且正确无误。现在,有了测试的安全网,我们认为我们可以稍微重构一下代码来清理它。
def parameter_tdd(value):
if value < 0:
return 0
if value < 10:
return value ** 2
return 100
我们可以在整个过程中运行测试,并确保代码是正确的。最终结果可能基于团队认为的好代码或更明确的内容而有所不同,但我们有我们的测试套件,这将确保测试是一致的,行为是正确的。
这里的函数相当小,但这显示了以 TDD 风格编写代码时的流程。
Python 单元测试简介
在 Python 中运行测试有多种方式。一种,如我们上面所见,有点粗糙,是执行带有多个断言的代码。一种常见的方式是标准库 unittest。
Python unittest
unittest 是 Python 标准库中的一个模块。它基于创建一个测试类来分组多个测试方法的概念。让我们写一个新文件,用适当的格式编写测试,命名为 test_unittest_example.py。
import unittest
from tdd_example import parameter_tdd
class TestTDDExample(unittest.TestCase):
def test_negative(self):
self.assertEqual(parameter_tdd(-1), 0)
def test_zero(self):
self.assertEqual(parameter_tdd(0), 0)
def test_five(self):
self.assertEqual(parameter_tdd(5), 25)
def test_seven(self):
# Note this test is incorrect
self.assertEqual(parameter_tdd(7), 0)
def test_ten(self):
self.assertEqual(parameter_tdd(10), 100)
def test_eleven(self):
self.assertEqual(parameter_tdd(11), 100)
if __name__ == '__main__':
unittest.main()
让我们分析不同的元素。首先是顶部的导入。
import unittest
from tdd_example import parameter_tdd
我们导入 unittest 模块和要测试的函数。最重要的部分接下来,它定义了测试。
class TestTDDExample(unittest.TestCase):
def test_negative(self):
self.assertEqual(parameter_tdd(-1), 0)
TestTDDExample类将不同的测试分组。请注意,它继承自unittest.TestCase。然后,以test_开头的方法将产生独立的测试。在这里,我们将展示一个。内部,它调用函数并使用self.assertEqual函数将结果与 0 进行比较。
注意,test_seven定义不正确。我们这样做是为了在运行时产生错误。
最后,我们添加这段代码。
if __name__ == '__main__':
unittest.main()
如果我们运行文件,它会自动运行测试。所以,让我们运行这个文件:
$ python3 test_unittest_example.py
...F..
======================================================================
FAIL: test_seven (__main__.TestTDDExample)
----------------------------------------------------------------------
Traceback (most recent call last):
File ".../unittest_example.py", line 17, in test_seven
self.assertEqual(parameter_tdd(7), 0)
AssertionError: 49 != 0
----------------------------------------------------------------------
Ran 6 tests in 0.001s
FAILED (failures=1)
如您所见,它已运行所有六个测试,并显示了任何错误。在这里,我们可以清楚地看到问题。如果我们需要更多细节,我们可以使用-v showing选项运行,显示正在运行的每个测试:
$ python3 test_unittest_example.py -v
test_eleven (__main__.TestTDDExample) ... ok
test_five (__main__.TestTDDExample) ... ok
test_negative (__main__.TestTDDExample) ... ok
test_seven (__main__.TestTDDExample) ... FAIL
test_ten (__main__.TestTDDExample) ... ok
test_zero (__main__.TestTDDExample) ... ok
======================================================================
FAIL: test_seven (__main__.TestTDDExample)
----------------------------------------------------------------------
Traceback (most recent call last):
File ".../unittest_example.py", line 17, in test_seven
self.assertEqual(parameter_tdd(7), 0)
AssertionError: 49 != 0
----------------------------------------------------------------------
Ran 6 tests in 0.001s
FAILED (failures=1)
您也可以使用-k选项运行单个测试或它们的组合,该选项会搜索匹配的测试。
$ python3 test_unittest_example.py -v -k test_ten
test_ten (__main__.TestTDDExample) ... ok
----------------------------------------------------------------------
Ran 1 test in 0.000s
OK
unittest非常受欢迎,可以接受很多选项,并且与 Python 中的几乎每个框架都兼容。它在测试方式上也非常灵活。例如,有多种方法可以比较值,如assertNotEqual和assertGreater。
有一个特定的断言函数工作方式不同,即assertRaises,用于检测代码生成异常的情况。我们将在测试模拟外部调用时稍后查看它。
它还具有setUp和tearDown方法,用于在每个测试执行前后执行代码。
请确保查看官方文档:docs.python.org/3/library/unittest.html。
虽然unittest可能是最受欢迎的测试框架,但它并不是最强大的。让我们来看看它。
Pytest
Pytest 进一步简化了编写测试的过程。关于unittest的一个常见抱怨是它强制您设置许多不是显而易见的assertCompare调用。它还需要对测试进行结构化,添加一些样板代码,如test类。其他问题可能不那么明显,但创建大型测试套件时,不同测试的设置可能会变得复杂。
一个常见的模式是创建继承自其他测试类的类。随着时间的推移,这可能会发展出自己的特性。
Pytest 简化了测试的运行和定义,并使用更易于阅读和识别的标准assert语句捕获所有相关信息。
在本节中,我们将以最简单的方式使用pytest。在章节的后面部分,我们将介绍更多有趣的案例。
请确保通过 pip 在您的环境中安装pytest。
$ pip3 install pytest
让我们看看如何在test_pytest_example.py文件中运行定义的测试。
from tdd_example import parameter_tdd
def test_negative():
assert parameter_tdd(-1) == 0
def test_zero():
assert parameter_tdd(0) == 0
def test_five():
assert parameter_tdd(5) == 25
def test_seven():
# Note this test is deliberatly set to fail
assert parameter_tdd(7) == 0
def test_ten():
assert parameter_tdd(10) == 100
def test_eleven():
assert parameter_tdd(11) == 100
如果您将其与test_unittest_example.py中的等效代码进行比较,代码会显著更简洁。当使用pytest运行时,它也会显示更详细、带颜色的信息。
$ pytest test_unittest_example.py
================= test session starts =================
platform darwin -- Python 3.9.5, pytest-6.2.4, py-1.10.0, pluggy-0.13.1
collected 6 items
test_unittest_example.py ...F.. [100%]
====================== FAILURES =======================
______________ TestTDDExample.test_seven ______________
self = <test_unittest_example.TestTDDExample testMethod=test_seven>
def test_seven(self):
> self.assertEqual(parameter_tdd(7), 0)
E AssertionError: 49 != 0
test_unittest_example.py:17: AssertionError
=============== short test summary info ===============
FAILED test_unittest_example.py::TestTDDExample::test_seven
============= 1 failed, 5 passed in 0.10s =============
与unittest一样,我们可以使用-v选项看到更多信息,并使用-k选项运行测试的选择。
$ pytest -v test_unittest_example.py
========================= test session starts =========================
platform darwin -- Python 3.9.5, pytest-6.2.4, py-1.10.0, pluggy-0.13.1 -- /usr/local/opt/python@3.9/bin/python3.9
cachedir: .pytest_cache
collected 6 items
test_unittest_example.py::TestTDDExample::test_eleven PASSED [16%]
test_unittest_example.py::TestTDDExample::test_five PASSED [33%]
test_unittest_example.py::TestTDDExample::test_negative PASSED [50%]
test_unittest_example.py::TestTDDExample::test_seven FAILED [66%]
test_unittest_example.py::TestTDDExample::test_ten PASSED [83%]
test_unittest_example.py::TestTDDExample::test_zero PASSED [100%]
============================== FAILURES ===============================
______________________ TestTDDExample.test_seven ______________________
self = <test_unittest_example.TestTDDExample testMethod=test_seven>
def test_seven(self):
> self.assertEqual(parameter_tdd(7), 0)
E AssertionError: 49 != 0
test_unittest_example.py:17: AssertionError
======================= short test summary info =======================
FAILED test_unittest_example.py::TestTDDExample::test_seven - AssertionErr...
===================== 1 failed, 5 passed in 0.08s =====================
$ pytest test_pytest_example.py -v -k test_ten
========================= test session starts =========================
platform darwin -- Python 3.9.5, pytest-6.2.4, py-1.10.0, pluggy-0.13.1 -- /usr/local/opt/python@3.9/bin/python3.9
cachedir: .pytest_cache
collected 6 items / 5 deselected / 1 selected
test_pytest_example.py::test_ten PASSED [100%]
=================== 1 passed, 5 deselected in 0.02s ===================
它与unittest定义的测试完全兼容,这允许你结合两种风格或迁移它们。
$ pytest test_unittest_example.py
========================= test session starts =========================
platform darwin -- Python 3.9.5, pytest-6.2.4, py-1.10.0, pluggy-0.13.1
collected 6 items
test_unittest_example.py ...F.. [100%]
============================== FAILURES ===============================
______________________ TestTDDExample.test_seven ______________________
self = <test_unittest_example.TestTDDExample testMethod=test_seven>
def test_seven(self):
> self.assertEqual(parameter_tdd(7), 0)
E AssertionError: 49 != 0
test_unittest_example.py:17: AssertionError
======================= short test summary info =======================
FAILED test_unittest_example.py::TestTDDExample::test_seven - AssertionErr...
===================== 1 failed, 5 passed in 0.08s =====================
pytest的另一个出色功能是易于自动发现以查找以test_开头的文件,并在所有测试中运行。如果我们尝试它,指向当前目录,我们可以看到它运行了test_unittest_example.py和test_pytest_example.py。
$ pytest .
========================= test session starts =========================
platform darwin -- Python 3.9.5, pytest-6.2.4, py-1.10.0, pluggy-0.13.1
collected 12 items
test_pytest_example.py ...F.. [50%]
test_unittest_example.py ...F.. [100%]
============================== FAILURES ===============================
_____________________________ test_seven ______________________________
def test_seven():
# Note this test is deliberatly set to fail
> assert parameter_tdd(7) == 0
E assert 49 == 0
E + where 49 = parameter_tdd(7)
test_pytest_example.py:18: AssertionError
______________________ TestTDDExample.test_seven ______________________
self = <test_unittest_example.TestTDDExample testMethod=test_seven>
def test_seven(self):
> self.assertEqual(parameter_tdd(7), 0)
E AssertionError: 49 != 0
test_unittest_example.py:17: AssertionError
======================= short test summary info =======================
FAILED test_pytest_example.py::test_seven - assert 49 == 0
FAILED test_unittest_example.py::TestTDDExample::test_seven - AssertionErr...
==================== 2 failed, 10 passed in 0.23s =====================
在本章中,我们将继续讨论pytest的更多功能,但首先,我们需要回到如何定义代码有依赖时的测试。
测试外部依赖
在构建单元测试时,我们讨论了它是如何围绕将代码中的单元隔离以独立测试的概念。
这种隔离概念是关键,因为我们想专注于代码的小部分来创建小而清晰的测试。创建小测试也有助于保持测试的快速。
在我们上面的例子中,我们测试了一个没有依赖的纯功能函数parameter_tdd。它没有使用任何外部库或任何其他函数。但不可避免的是,在某个时候,你需要测试依赖于其他东西的东西。
在这种情况下的问题是其他组件是否应该包含在测试中?
这是一个不容易回答的问题。一些开发者认为所有单元测试都应该纯粹关于一个函数或方法,因此任何依赖都不应该包含在测试中。但是,在更实际的水平上,有时有一些代码片段形成一个单元,联合测试比单独测试更容易。
例如,考虑一个函数:
-
对于小于 0 的值,返回 0。
-
对于大于 100 的值,返回 10。
-
对于介于两者之间的值,它返回值的平方根。注意,对于边缘值,它返回它们的平方根(0 对于 0 和 10 对于 100)。
这与上一个函数parameter_tdd非常相似,但这次我们需要外部库的帮助来计算数字的平方根。让我们看看代码。
它分为两个文件。dependent.py包含函数的定义。
import math
def parameter_dependent(value):
if value < 0:
return 0
if value <= 100:
return math.sqrt(value)
return 10
代码与parameter_tdd示例中的代码非常相似。模块math.sqrt返回数字的平方根。
测试文件位于test_dependent.py。
from dependent import parameter_dependent
def test_negative():
assert parameter_dependent(-1) == 0
def test_zero():
assert parameter_dependent(0) == 0
def test_twenty_five():
assert parameter_dependent(25) == 5
def test_hundred():
assert parameter_dependent(100) == 10
def test_hundred_and_one():
assert parameter_dependent(101) == 10
在这种情况下,我们完全使用外部库,并在测试我们的代码的同时测试它。对于这个简单的例子,这是一个完全有效的选项,尽管对于其他情况可能并非如此。
代码可在 GitHub 上找到:github.com/PacktPublishing/Python-Architecture-Patterns/tree/main/chapter_10_testing_and_tdd。
例如,外部依赖可能是需要捕获的外部 HTTP 调用,以防止在运行测试时执行它们,并控制返回的值,或者应该单独测试的其他大型功能功能。
要将函数与其依赖项分离,有两种不同的方法。我们将使用 parameter_dependent 作为基准来展示它们。
再次强调,在这种情况下,包含依赖项时测试工作得非常完美,因为它简单且不会产生像外部调用等副作用。
我们将在下一节中看到如何模拟外部调用。
模拟
模拟是一种内部替换依赖项的实践,用假的调用替换它们,由测试本身控制。这样,我们可以为任何外部依赖项引入已知的响应,而不调用实际的代码。
在内部,模拟是通过所谓的 monkey-patching 实现的,即用替代品动态替换现有库。虽然这可以在不同的编程语言中以不同的方式实现,但在像 Python 或 Ruby 这样的动态语言中特别流行。虽然模拟可以用于测试之外的其他目的,但应该谨慎使用,因为它可以改变库的行为,并且对于调试来说可能会相当令人不安。
为了能够模拟代码,在我们的测试代码中,我们需要在 Arrange 步骤中准备模拟。有不同库可以模拟调用,但最简单的是使用标准库中包含的 unittest.mock 库。
mock 的最简单用法是修补一个外部库:
from unittest.mock import patch
from dependent import parameter_dependent
@patch('math.sqrt')
def test_twenty_five(mock_sqrt):
mock_sqrt.return_value = 5
assert parameter_dependent(25) == 5
mock_sqrt.assert_called_once_with(25)
patch 装饰器拦截对定义的库 math.sqrt 的调用,并用一个 mock 对象替换它,这里称为 mock_sqrt。
这个对象有点特殊。它基本上允许任何调用,几乎可以访问任何方法或属性(除了预定义的),并且持续返回一个模拟对象。这使得模拟对象非常灵活,可以适应周围的任何代码。当需要时,可以通过调用 .return_value 来设置返回值,就像我们在第一行中展示的那样。
从本质上讲,我们是在说对 mock_sqrt 的调用将返回值 5。因此,我们正在准备外部调用的输出,以便我们可以控制它。
最后,我们检查我们只调用了一次模拟 mock_sqrt,使用输入(25)并通过 assert_called_once_with 方法。
从本质上讲,我们是在:
-
准备模拟以替换
math.sqrt -
设置它在被调用时将返回的值
-
检查调用是否按预期工作
-
再次确认模拟是否以正确的值被调用
对于其他测试,例如,我们可以检查模拟没有被调用,这表明外部依赖没有被调用。
@patch('math.sqrt')
def test_hundred_and_one(mock_sqrt):
assert parameter_dependent(101) == 10
mock_sqrt.assert_not_called()
有多个 assert 函数允许你检测模拟是如何被使用的。以下是一些示例:
-
called属性根据模拟是否被调用返回True或False,允许你编写:`assert mock_sqrt.called is True` -
call_count属性返回模拟被调用的次数。 -
assert_called_with()方法用于检查它被调用的次数。如果最后的调用不是以指定的方式产生的,它将引发异常。 -
assert_any_call()方法用于检查是否以指定方式产生了任何调用。
根据这些信息,完整的测试文件 test_dependent_mocked_test.py 将如下所示。
from unittest.mock import patch
from dependent import parameter_dependent
@patch('math.sqrt')
def test_negative(mock_sqrt):
assert parameter_dependent(-1) == 0
mock_sqrt.assert_not_called()
@patch('math.sqrt')
def test_zero(mock_sqrt):
mock_sqrt.return_value = 0
assert parameter_dependent(0) == 0
mock_sqrt.assert_called_once_with(0)
@patch('math.sqrt')
def test_twenty_five(mock_sqrt):
mock_sqrt.return_value = 5
assert parameter_dependent(25) == 5
mock_sqrt.assert_called_with(25)
@patch('math.sqrt')
def test_hundred(mock_sqrt):
mock_sqrt.return_value = 10
assert parameter_dependent(100) == 10
mock_sqrt.assert_called_with(100)
@patch('math.sqrt')
def test_hundred_and_one(mock_sqrt):
assert parameter_dependent(101) == 10
mock_sqrt.assert_not_called()
如果模拟需要返回不同的值,你可以将模拟的 side_effect 属性定义为列表或元组。side_effect 与 return_value 类似,但它有一些区别,我们将在下面看到。
@patch('math.sqrt')
def test_multiple_returns_mock(mock_sqrt):
mock_sqrt.side_effect = (5, 10)
assert parameter_dependent(25) == 5
assert parameter_dependent(100) == 10
如果需要,side_effect 也可以用来产生异常。
import pytest
from unittest.mock import patch
from dependent import parameter_dependent
@patch('math.sqrt')
def test_exception_raised_mock(mock_sqrt):
mock_sqrt.side_effect = ValueError('Error on the external library')
with pytest.raises(ValueError):
parameter_dependent(25)
with 块断言在块中引发了预期的 Exception。如果没有,它将显示错误。
在 unittest 中,可以使用类似的 with 块来检查引发的异常。
with self.assertRaises(ValueError):
parameter_dependent(25)
Mocking 不是处理测试依赖关系的唯一方法。我们将在下一个示例中看到不同的方法。
依赖注入
当通过外部修补来替换依赖关系时,mocking 不会让原始代码察觉到,而依赖注入是一种在调用测试函数时使该依赖关系明确的技术,这样就可以用测试替身来替换它。
本质上,这是一种设计代码的方式,通过要求它们作为输入参数来明确依赖关系。
虽然依赖注入对测试很有用,但它并不仅限于此。通过显式添加依赖关系,它还减少了函数需要知道如何初始化特定依赖关系的需要,而是依赖于依赖关系的接口。它创建了“初始化”依赖关系(这应该由外部处理)和“使用”它(这是依赖代码唯一会做的部分)之间的分离。这种区分将在我们看到面向对象示例时变得更加清晰。
让我们看看这如何改变测试中的代码。
def parameter_dependent(value, sqrt_func):
if value < 0:
return 0
if value <= 100:
return sqrt_func(value)
return 10
注意现在 sqrt 函数是一个输入参数。
如果我们想在正常场景中使用 parameter_dependent 函数,我们必须产生依赖关系,例如。
import math
def test_good_dependency():
assert parameter_dependent(25, math.sqrt) == 5
如果我们想进行测试,我们可以通过用特定的函数替换 math.sqrt 函数,然后使用它来完成。例如:
def test_twenty_five():
def good_dependency(number):
return 5
assert parameter_dependent(25, good_dependency) == 5
我们也可以通过调用依赖关系来引发错误,以确保在某些测试中依赖关系没有被使用,例如。
def test_negative():
def bad_dependency(number):
raise Exception('Function called')
assert parameter_dependent(-1, bad_dependency) == 0
注意这种方法比 mocking 更明确。要测试的代码在本质上完全功能,因为它没有外部依赖。
面向对象编程中的依赖注入
依赖注入也可以与面向对象编程(OOP)一起使用。在这种情况下,我们可以从类似这样的代码开始。
class Writer:
def __init__(self):
self.path = settings.WRITER_PATH
def write(self, filename, data):
with open(self.path + filename, 'w') as fp:
fp.write(data)
class Model:
def __init__(self, data):
self.data = data
self.filename = settings.MODEL_FILE
self.writer = Writer()
def save(self):
self.writer.write(self.filename, self.data)
如我们所见,settings 类存储了在数据将存储的位置所需的不同元素。模型接收一些数据然后保存。正在运行的代码将需要最少的初始化。
model = Model('test')
model.save()
模型接收一些数据然后保存它。运行中的代码需要最小的初始化,但与此同时,它并不明确。
要使用依赖注入原则,代码需要以这种方式编写:
class WriterInjection:
def __init__(self, path):
self.path = path
def write(self, filename, data):
with open(self.path + filename, 'w') as fp:
fp.write(data)
class ModelInjection:
def __init__(self, data, filename, writer):
self.data = data
self.filename = filename
self.writer = writer
def save(self):
self.writer.write(self.filename, self.data)
在这种情况下,每个作为依赖的值都是明确提供的。在代码的定义中,settings模块根本不存在,而是在类实例化时指定。现在代码需要直接定义配置。
writer = WriterInjection('./')
model = ModelInjection('test', 'model_injection.txt', writer)
model.save()
我们可以比较如何测试这两种情况,如文件test_dependency_injection_test.py中所示。第一个测试是模拟,正如我们之前所见,模拟Writer类的write方法以断言它已被正确调用。
@patch('class_injection.Writer.write')
def test_model(mock_write):
model = Model('test_model')
model.save()
mock_write.assert_called_with('model.txt', 'test_model')
与之相比,依赖注入的示例不需要通过猴子补丁进行模拟。它只是创建了自己的Writer来模拟接口。
def test_modelinjection():
EXPECTED_DATA = 'test_modelinjection'
EXPECTED_FILENAME = 'model_injection.txt'
class MockWriter:
def write(self, filename, data):
self.filename = filename
self.data = data
writer = MockWriter()
model = ModelInjection(EXPECTED_DATA, EXPECTED_FILENAME,
writer)
model.save()
assert writer.data == EXPECTED_DATA
assert writer.filename == EXPECTED_FILENAME
第二种风格更冗长,但它展示了以这种方式编写代码时的一些差异:
-
不需要猴子补丁模拟。猴子补丁可能会相当脆弱,因为它是在干预不应该暴露的内部代码。虽然在进行测试时这种干预与为常规代码运行时所做的干预不同,但它仍然可能造成混乱并产生意外的效果,尤其是如果内部代码以某种不可预见的方式发生变化。
请记住,模拟可能在某个时候涉及到与二级依赖相关的内容,这可能会产生奇怪或复杂的效果,需要你花费时间处理额外的复杂性。
-
编写代码的方式本身也有所不同。使用依赖注入产生的代码,正如我们所见,更模块化,由更小的元素组成。这往往会产生更小、更可组合的模块,它们可以协同工作,因为它们的依赖关系总是明确的。
-
虽然如此,但请注意,这需要一定的纪律和心智框架来产生真正松散耦合的模块。如果在设计接口时没有考虑到这一点,生成的代码将人为地分割,导致不同模块之间紧密耦合。培养这种纪律需要一定的训练;不要期望所有开发者都能自然而然地做到。
-
有时代码可能更难调试,因为配置将与代码的其他部分分离,有时这会使理解代码流程变得困难。复杂性可能在类之间的交互中产生,这可能更难以理解和测试。通常,以这种方式开发代码的前期工作也会更多一些。
依赖注入是某些软件领域和编程语言中非常流行的一种技术。在比 Python 更静态的语言中,模拟会更困难,而且不同的编程语言都有自己的代码结构理念。例如,依赖注入在 Java 中非常流行,那里有特定的工具来支持这种风格。
高级 pytest
虽然我们已经描述了pytest的基本功能,但在展示其帮助生成测试代码的可能性方面,我们只是触及了表面。
Pytest 是一个庞大且全面的工具。学习如何使用它是值得的。在这里,我们只会触及表面。请务必查看官方文档docs.pytest.org/。
不一一列举,我们将看到这个工具的一些有用可能性。
分组测试
有时候将测试分组在一起是有用的,这样它们就与特定的事物相关联,比如模块,或者一起运行。将测试分组在一起的最简单方法是将它们组合成一个单独的类。
例如,回到之前的测试示例,我们可以将测试结构化为两个类,就像我们在test_group_classes.py中看到的那样。
from tdd_example import parameter_tdd
class TestEdgesCases():
def test_negative(self):
assert parameter_tdd(-1) == 0
def test_zero(self):
assert parameter_tdd(0) == 0
def test_ten(self):
assert parameter_tdd(10) == 100
def test_eleven(self):
assert parameter_tdd(11) == 100
class TestRegularCases():
def test_five(self):
assert parameter_tdd(5) == 25
def test_seven(self):
assert parameter_tdd(7) == 49
这是一个将测试分割开来的简单方法,允许你独立运行它们:
$ pytest -v test_group_classes.py
======================== test session starts =========================
platform darwin -- Python 3.9.5, pytest-6.2.4, py-1.10.0, pluggy-0.13.1 -- /usr/local/opt/python@3.9/bin/python3.9
collected 6 items
test_group_classes.py::TestEdgesCases::test_negative PASSED [16%]
test_group_classes.py::TestEdgesCases::test_zero PASSED [33%]
test_group_classes.py::TestEdgesCases::test_ten PASSED [50%]
test_group_classes.py::TestEdgesCases::test_eleven PASSED [66%]
test_group_classes.py::TestRegularCases::test_five PASSED [83%]
test_group_classes.py::TestRegularCases::test_seven PASSED [100%]
========================= 6 passed in 0.02s ==========================
$ pytest -k TestRegularCases -v test_group_classes.py
========================= test session starts ========================
platform darwin -- Python 3.9.5, pytest-6.2.4, py-1.10.0, pluggy-0.13.1 -- /usr/local/opt/python@3.9/bin/python3.9
collected 6 items / 4 deselected / 2 selected
test_group_classes.py::TestRegularCases::test_five PASSED [50%]
test_group_classes.py::TestRegularCases::test_seven PASSED [100%]
================== 2 passed, 4 deselected in 0.02s ===================
$ pytest -v test_group_classes.py::TestRegularCases
========================= test session starts ========================
platform darwin -- Python 3.9.5, pytest-6.2.4, py-1.10.0, pluggy-0.13.1 -- /usr/local/opt/python@3.9/bin/python3.9
cachedir: .pytest_cache
rootdir: /Users/jaime/Dropbox/Packt/architecture_book/chapter_09_testing_and_tdd/advanced_pytest
plugins: celery-4.4.7
collected 2 items
test_group_classes.py::TestRegularCases::test_five PASSED [50%]
test_group_classes.py::TestRegularCases::test_seven PASSED [100%]
========================== 2 passed in 0.02s =========================
另一种可能性是使用标记。标记是可以通过测试中的装饰器添加的指示器,例如,在test_markers.py中。
import pytest
from tdd_example import parameter_tdd
@pytest.mark.edge
def test_negative():
assert parameter_tdd(-1) == 0
@pytest.mark.edge
def test_zero():
assert parameter_tdd(0) == 0
def test_five():
assert parameter_tdd(5) == 25
def test_seven():
assert parameter_tdd(7) == 49
@pytest.mark.edge
def test_ten():
assert parameter_tdd(10) == 100
@pytest.mark.edge
def test_eleven():
assert parameter_tdd(11) == 100
注意我们正在定义一个装饰器,@pytest.mark.edge,用于检查所有测试的值边界。
如果我们执行测试,可以使用参数 -m 来运行具有特定标签的测试。
$ pytest -m edge -v test_markers.py
========================= test session starts ========================
platform darwin -- Python 3.9.5, pytest-6.2.4, py-1.10.0, pluggy-0.13.1 -- /usr/local/opt/python@3.9/bin/python3.9
collected 6 items / 2 deselected / 4 selected
test_markers.py::test_negative PASSED [25%]
test_markers.py::test_zero PASSED [50%]
test_markers.py::test_ten PASSED [75%]
test_markers.py::test_eleven PASSED [100%]
========================== warnings summary ==========================
test_markers.py:5
test_markers.py:5: PytestUnknownMarkWarning: Unknown pytest.mark.edge - is this a typo? You can register custom marks to avoid this warning - for details, see https://docs.pytest.org/en/stable/mark.html
@pytest.mark.edge
test_markers.py:10
...
-- Docs: https://docs.pytest.org/en/stable/warnings.html
============ 4 passed, 2 deselected, 4 warnings in 0.02s =============
如果标记edge未注册,将产生警告PytestUnknownMarkWarning: Unknown pytest.mark.edge。
注意 GitHub 代码中包含了pytest.ini代码。如果存在pytest.ini文件,例如,如果你克隆了整个仓库,你将不会看到警告。
这对于查找错误非常有用,比如不小心写成egde或类似的错误。为了避免这种警告,你需要添加一个pytest.ini配置文件,其中包含标记的定义,如下所示。
[pytest]
markers =
edge: tests related to edges in intervals
现在,运行测试不再显示警告。
$ pytest -m edge -v test_markers.py
========================= test session starts =========================
platform darwin -- Python 3.9.5, pytest-6.2.4, py-1.10.0, pluggy-0.13.1 -- /usr/local/opt/python@3.9/bin/python3.9
cachedir: .pytest_cache
rootdir: /Users/jaime/Dropbox/Packt/architecture_book/chapter_09_testing_and_tdd/advanced_pytest, configfile: pytest.ini
plugins: celery-4.4.7
collected 6 items / 2 deselected / 4 selected
test_markers.py::test_negative PASSED [25%]
test_markers.py::test_zero PASSED [50%]
test_markers.py::test_ten PASSED [75%]
test_markers.py::test_eleven PASSED [100%]
=================== 4 passed, 2 deselected in 0.02s ===================
注意标记可以在整个测试套件中使用,包括多个文件。这允许标记识别测试中的常见模式,例如,创建一个带有标记basic的快速测试套件,以运行最重要的测试。
此外,还有一些预定义的标记,具有一些内置功能。最常见的是skip(将跳过测试)和xfail(将反转测试,意味着它期望它失败)。
使用 fixtures
在pytest中,使用 fixtures 是设置测试的首选方式。本质上,fixture 是为了设置测试而创建的上下文。
Fixtures 被用作测试函数的输入,因此它们可以设置并创建特定的测试环境。
例如,让我们看看一个简单的函数,它计算字符串中字符出现的次数。
def count_characters(char_to_count, string_to_count):
number = 0
for char in string_to_count:
if char == char_to_count:
number += 1
return number
这是一个相当简单的循环,它遍历字符串并计数匹配的字符。
这相当于使用字符串的.count()函数,但这是为了展示一个工作函数。之后可以对其进行重构!
一个常规测试来覆盖功能可能如下。
def test_counting():
assert count_characters('a', 'Barbara Ann') == 3
非常直接。现在让我们看看我们如何定义一个固定装置来定义一个设置,以防我们想要复制它。
import pytest
@pytest.fixture()
def prepare_string():
# Setup the values to return
prepared_string = 'Ba, ba, ba, Barbara Ann'
# Return the value
yield prepared_string
# Teardown any value
del prepared_string
首先,固定装置被装饰为pytest.fixture以标记它。固定装置分为三个步骤:
-
设置:在这里,我们只是定义了一个字符串,但这可能是最大的部分,其中准备值。
-
返回值:如果我们使用
yield功能,我们将能够进入下一步;如果不使用,固定装置将在这里结束。 -
拆卸和清理值:在这里,我们只是简单地删除变量作为示例,尽管这将在稍后自动发生。
之后,我们将看到一个更复杂的固定装置。在这里,我们只是展示这个概念。
以这种方式定义固定装置将使我们能够轻松地在不同的测试函数中重用它,只需使用名称作为输入参数。
def test_counting_fixture(prepare_string):
assert count_characters('a', prepare_string) == 6
def test_counting_fixture2(prepare_string):
assert count_characters('r', prepare_string) == 2
注意prepare_string参数是如何自动提供我们使用yield定义的值的。如果我们运行测试,我们可以看到效果。甚至更多,我们可以使用参数--setup-show来查看设置和拆卸所有固定装置。
$ pytest -v test_fixtures.py -k counting_fixture --setup-show
======================== test session starts ========================
platform darwin -- Python 3.9.5, pytest-6.2.4, py-1.10.0, pluggy-0.13.1 -- /usr/local/opt/python@3.9/bin/python3.9
plugins: celery-4.4.7
collected 3 items / 1 deselected / 2 selected
test_fixtures.py::test_counting_fixture
SETUP F prepare_string
test_fixtures.py::test_counting_fixture (fixtures used: prepare_string)PASSED
TEARDOWN F prepare_string
test_fixtures.py::test_counting_fixture2
SETUP F prepare_string
test_fixtures.py::test_counting_fixture2 (fixtures used: prepare_string)PASSED
TEARDOWN F prepare_string
=================== 2 passed, 1 deselected in 0.02s ===================
这个固定装置非常简单,并没有做任何不能通过定义字符串来完成的事情,但固定装置可以用来连接数据库或准备文件,同时考虑到它们可以在结束时清理它们。
例如,稍微复杂化同一个例子,而不是从字符串中计数,它应该从文件中计数,因此函数需要打开文件,读取它,并计数字符。函数将如下所示。
def count_characters_from_file(char_to_count, file_to_count):
'''
Open a file and count the characters in the text contained
in the file
'''
number = 0
with open(file_to_count) as fp:
for line in fp:
for char in line:
if char == char_to_count:
number += 1
return number
固定装置应该创建一个文件,返回它,然后在拆卸过程中删除它。让我们看看它。
import os
import time
import pytest
@pytest.fixture()
def prepare_file():
data = [
'Ba, ba, ba, Barbara Ann',
'Ba, ba, ba, Barbara Ann',
'Barbara Ann',
'take my hand',
]
filename = f'./test_file_{time.time()}.txt'
# Setup the values to return
with open(filename, 'w') as fp:
for line in data:
fp.write(line)
# Return the value
yield filename
# Delete the file as teardown
os.remove(filename)
注意,在文件名中,我们定义了名称,并在生成时添加时间戳。这意味着将由该固定装置生成的每个文件都是唯一的。
filename = f'./test_file_{time.time()}.txt'
然后,文件被创建,数据被写入。
with open(filename, 'w') as fp:
for line in data:
fp.write(line)
文件名,正如我们所看到的,是唯一的,被返回。最后,在拆卸过程中删除该文件。
测试与之前的类似,因为大部分复杂性都存储在固定装置中。
def test_counting_fixture(prepare_file):
assert count_characters_from_file('a', prepare_file) == 17
def test_counting_fixture2(prepare_file):
assert count_characters_from_file('r', prepare_file) == 6
当运行它时,我们看到它按预期工作,并且我们可以检查在每次测试后拆卸步骤会删除测试文件。
$ pytest -v test_fixtures2.py
========================= test session starts =========================
platform darwin -- Python 3.9.5, pytest-6.2.4, py-1.10.0, pluggy-0.13.1 -- /usr/local/opt/python@3.9/bin/python3.9
collected 2 items
test_fixtures2.py::test_counting_fixture PASSED [50%]
test_fixtures2.py::test_counting_fixture2 PASSED [100%]
========================== 2 passed in 0.02s ==========================
固定装置不需要定义在同一个文件中。它们也可以存储在一个名为conftest.py的特殊文件中,该文件将由pytest自动在所有测试中共享。
固定装置也可以组合,可以设置为自动使用,并且已经内置了用于处理时间数据和目录或捕获输出的固定装置。PyPI 上还有很多用于有用固定装置的插件,可以作为第三方模块安装,包括连接数据库或与其他外部资源交互等功能。务必检查 Pytest 文档,并在实现自己的固定装置之前进行搜索,以查看是否可以利用现有的模块:docs.pytest.org/en/latest/explanation/fixtures.html#about-fixtures。
在本章中,我们只是对pytest的可能性进行了初步探索。这是一个出色的工具,我鼓励你们去了解它。高效地运行测试并以最佳方式设计测试将带来巨大的回报。测试是项目的一个关键部分,也是开发者花费大部分时间的一个开发阶段。
摘要
在本章中,我们探讨了测试的为什么和怎么做,以描述一个好的测试策略对于生产高质量的软件和防止代码在使用过程中出现问题的必要性。
我们首先描述了测试背后的基本原则,如何编写比成本更高的测试,以及确保这一点的不同测试级别。我们看到了三个主要的测试级别,我们称之为单元测试(单个组件的部分)、系统测试(整个系统)和中间的集成测试(一个或多个组件的整体,但不是全部)。
我们继续描述了确保我们的测试是优秀测试的不同策略,以及如何使用 Arrange-Act-Assert 模式来构建它们,以便在编写后易于编写和理解。
之后,我们详细描述了测试驱动开发背后的原则,这是一种将测试置于开发中心的技术,要求在编写代码之前编写测试,以小步增量工作,并反复运行测试以创建一个良好的测试套件,以防止意外行为。我们还分析了以 TDD 方式工作的局限性和注意事项,并提供了流程的示例。
我们继续通过展示在 Python 中创建单元测试的方法来介绍,包括使用标准的unittest模块和引入更强大的pytest。我们还提供了一个关于pytest高级使用的部分,以展示这个优秀的第三方模块的能力。
我们描述了如何测试外部依赖,这在编写单元测试以隔离功能时至关重要。我们还描述了如何模拟依赖关系以及如何在依赖注入原则下工作。
第十一章:包管理
当在复杂系统中工作时,尤其是在微服务或类似架构中,有时需要共享代码,以便它在系统的不同、不相连的部分中可用。这通常是帮助抽象一些可能差异很大的函数的代码,从安全目的(例如,以其他系统将需要验证的方式计算签名),到连接到数据库或外部 API,甚至帮助一致地监控系统。
我们不必每次都重新发明轮子,我们可以多次重用相同的代码,以确保它经过适当的测试和验证,并在整个系统中保持一致性。一些模块可能不仅可以在组织内部共享,甚至可以在组织外部共享,创建其他人可以利用的标准模块。
之前有人这样做过,许多常见的用例,如连接到现有的数据库、使用网络资源、访问操作系统功能、理解各种格式的文件、计算常见的算法和公式、在各个领域,创建和操作 AI 模型,以及其他许多案例,都是可用的。
为了增强所有这些能力的共享和利用,现代编程语言都有它们自己的创建和共享包的方式,因此语言的有用性大大增加。
在本章中,我们将讨论包的使用,主要从 Python 的角度出发,涵盖何时以及如何决定创建一个包。我们将探讨不同的选项,从简单的结构到包含编译代码的包,以便它可以针对特定任务进行优化。
在本章中,我们将涵盖以下主题:
-
创建一个新的包
-
Python 中的简单打包
-
Python 打包生态系统
-
创建一个包
-
Cython
-
带有二进制代码的 Python 包
-
将你的包上传到 PyPI
-
创建你自己的私有索引
让我们先定义一下哪些代码可能成为创建包的候选者。
创建一个新的包
在任何软件中,都可能会有一些代码片段可以在代码的不同部分之间共享。当处理小型、单体应用程序时,这可以通过创建一些内部模块或函数来实现,通过直接调用它们来共享功能。
随着时间的推移,这个或这些常用函数可能会被分组到一个模块中,以明确它们将在整个应用程序中使用。
避免使用 utils 这样的名字来命名一个预期在不同位置使用的代码模块。虽然这很常见,但它也不是很具有描述性,有点懒惰。别人怎么知道一个函数是否在 utils 模块中?相反,尝试使用一个描述性的名字。
如果不可能,将其划分为子模块,这样你可以创建类似 utils.communication 或 utils.math 的东西,以避免这种影响。
在一定规模内,这将会运行得很好。随着代码的增长和复杂度的增加,可能会出现以下一些问题:
-
创建一个更通用的 API 来与模块交互,旨在提高模块利用的灵活性。这可能包括创建一种更防御性的编程风格,以确保模块按预期使用并返回适当的错误。
-
需要为模块提供具体的文档,以便不熟悉模块的开发者能够使用它。
-
可能需要明确模块的所有权并指定其维护者。这可以采取在更改代码前进行更严格的代码审查的形式,指定一些开发者或开发者为模块的联系人。
-
最关键的是,模块的功能需要在两个或更多独立的服务或代码库中存在。如果发生这种情况,而不是仅仅在不同代码库之间复制/粘贴代码,创建一个独立的模块以便导入是有意义的。这可能是一个事先的明确选择,以标准化某些操作(例如,在多个服务中产生和验证签名消息)或可能是在一个代码库中成功实现功能后的一种后续想法,这种功能在其他服务中可能很有用。例如,对通信消息进行监控会生成日志。这个日志在其他服务中可能很有用,因此,从原始服务中,它会被迁移到其他服务。
通常,模块开始获得自己的实体,而不仅仅是一个共享位置来集成将要共享的代码。那时,将其视为一个独立的库而不是特定代码库的模块更有意义。
一旦决定将某些代码作为一个独立的包来创建,应考虑以下几个方面:
-
如我们之前所见,最重要的是新包的所有权。包存在于不同团队和群体之间的边界上,因为它们被不同的人使用。务必提供关于任何包的明确所有权,以确保负责该包的团队可接触,无论是为了任何可能的询问还是为了设置其自身的维护。
-
任何新的包都需要时间来开发新功能和调整,尤其是在包被使用时,可能因为被多个服务和多种方式使用而拉伸其极限。务必考虑到这一点,并相应地调整负责团队的工作量。这将非常依赖于包的成熟度和所需的新功能数量。
-
同样,务必预留时间来维护包。即使没有新功能,也会发现错误,以及其他一般性维护,如由于安全修复或与新操作系统版本的兼容性而更新依赖项,这些都需要继续进行。
所有这些元素都应该被考虑进去。一般来说,建议创建某种路线图,以便负责的团队可以定义目标及其实现的时间框架。
重要的是,一个新的包是一个新的项目。你需要像对待项目一样对待它。
我们将专注于在 Python 中创建一个新的包,但创建其他语言中的其他包时,基本原理是相似的。
Python 中的简单打包
在 Python 中,创建一个可以被导入的包非常简单,只需将子目录添加到代码中即可。虽然这很简单,但最初可能足够用,因为子目录可以被复制。例如,代码可以直接添加到源代码控制系统中,或者甚至可以通过压缩代码并在原地解压缩来安装。
这不是一个长期解决方案,因为它不会处理多个版本、依赖关系等问题,但在某些情况下可以作为第一步。至少最初,所有需要打包的代码都需要存储在同一个子目录中。
Python 中模块的代码结构可以作为一个具有单个入口点的子目录来处理。例如,当创建一个名为naive_package的模块,其结构如下:
└── naive_package
├── __init__.py
├── module.py
└── submodule
├── __init__.py
└── submodule.py
我们可以看到该模块包含一个子模块,所以让我们从这里开始。子模块目录包含两个文件,包含代码的submodule.py文件和一个空的__init__.py文件,以便其他文件可以被导入,正如我们稍后将会看到的。
__init__.py是一个特殊的 Python 文件,它表示目录包含 Python 代码并且可以被外部导入。它象征着目录本身,正如我们稍后将会看到的。
submodule.py的内容是这个示例函数:
def subfunction():
return 'calling subfunction'
最高级别是模块本身。我们有一个module.py文件,它定义了调用子模块的some_function函数:
from .submodule.submodule import subfunction
def some_function():
result = subfunction()
return f'some function {result}'
import行有一个细节,即位于同一目录中的子模块的点形式。这是 Python 3 中导入时更精确的特定语法。如果没有点,它将尝试从库中导入。
你可以在 PEP-328 中了解更多关于相对导入的信息,它在这里进行了描述:www.python.org/dev/peps/pep-0328/。PEPs(Python Enhancement Proposals)是描述与 Python 语言相关的新特性或与社区相关的信息的文档。它是提出更改和推进语言的官方渠道。
函数的其余部分调用subfunction并将结果组合成一个文本字符串返回。
在这种情况下,__init__.py文件不是空的,而是导入some_function函数:
from .module import some_function
再次注意前面的点所指示的相对import。这允许some_function函数作为naive_package模块最高级别的一部分可用。
我们现在可以创建一个文件来调用模块。我们将编写call_naive_package.py文件,该文件需要与native_package目录处于同一级别:
from naive_package import some_function
print(some_function())
此文件仅调用模块定义的函数并打印结果:
$ python3 call_naive_package.py
some function calling subfunction
这种处理要共享的模块的方法不建议使用,但这个小模块可以帮助我们了解如何创建包以及模块的结构。将模块分离并创建独立包的第一步是创建一个具有清晰 API 的单个子目录,包括使用它的清晰入口点。
但为了得到更好的解决方案,我们需要能够从那里创建一个完整的 Python 包。让我们看看这究竟意味着什么。
Python 打包生态系统
Python 拥有一个非常活跃的第三方开源包生态系统,涵盖了广泛的主题,并增强了任何 Python 程序的功能。您可以通过使用pip来利用安装它们,pip在安装任何新的 Python 时都会自动安装。
例如,要安装名为requests的包,这是一个允许编译更简单、更强大的 HTTP 请求的包,命令如下:
$ pip3 install requests
pip会自动在 Python 包索引中搜索,以查看包是否可用。如果可用,它将下载并安装它。
注意,pip命令可能是pip3的形式。这取决于您系统中 Python 的安装。我们将不加区分地使用它们。
我们将在本章后面更详细地介绍pip的使用,但首先,我们需要讨论包下载的主要来源。
PyPI
Python 包索引(PyPI,通常发音为Pie-P-I,而不是Pie-Pie)是 Python 中包的官方来源,可以在pypi.org上检查:

图 11.1:pypi.org 主页
在 PyPI 网页上,搜索可以找到特定的包以及有用的信息,包括具有部分匹配的可用包。它们也可以被过滤。

图 11.2:搜索包
一旦指定了单个包,就可以找到有关简要文档、项目源和主页的链接以及其他类似类型的许可证或维护者的更多信息。
对于大型包,主页和文档页非常重要,因为它们将包含有关如何使用包的更多信息。较小的包通常只会包含此页面的文档,但检查它们的页面以查看源总是值得的,因为它可能链接到一个 GitHub 页面,其中包含有关错误和提交补丁或报告的可能性。
在撰写本书时,requests页面的样子如下:

图 11.3:关于模块的详细信息
直接在 PyPI 中搜索可以帮助定位一些有趣的模块,在某些情况下,将会非常直接,例如查找连接到数据库的模块(例如,通过数据库的名称进行搜索)。然而,这通常涉及大量的试错,因为名称可能不会表明模块对您的用例有多好。
在互联网上花些时间搜索最适合特定用例的最佳模块是一个好主意,这将提高找到适合您用例的正确包的机会。
在这种情况下,StackOverflow (stackoverflow.com/) 是一个很好的知识来源,它包含大量的问题和答案,可用于确定有趣的模块。一般的谷歌搜索也会有所帮助。
在任何情况下,鉴于 Python 有大量不同质量和成熟度的可用包,花些时间研究替代方案总是值得的。
pypi.org 并没有以任何方式对包进行精选,因为它是公开的,任何人都可以提交他们的包,尽管恶意包将被消除。一个包有多流行将需要更多间接的方法,例如搜索下载次数或通过在线搜索器查看是否有其他项目在使用它。最终,将需要执行一些概念验证程序来分析候选包是否涵盖了所有必需的功能。
虚拟环境
打包链中的下一个元素是创建虚拟环境以隔离模块的安装。
在处理安装包时,使用系统中的默认环境会导致包安装在那里。这意味着 Python 解释器的常规安装将受到影响。
这可能会导致问题,因为您可能安装了在使用 Python 解释器进行其他目的时会产生副作用的其他包,因为包中的依赖项可能会相互干扰。
例如,如果同一台机器上有一个需要 package1 包的 Python 程序,另一个需要 package2 的 Python 程序,并且它们都不兼容,这将产生冲突。安装 package1 和 package2 都是不可能的。
注意,这也可能通过版本不兼容发生,尤其是在包的依赖项或依赖项的依赖项中。例如,package1 需要安装依赖项版本 5,而 package2 需要版本 6 或更高。它们将无法同时运行。
解决这个问题的方法是创建两个不同的环境,这样每个包及其依赖项都会独立存储——不仅彼此独立,而且与系统 Python 解释器也独立,因此不会影响依赖于系统 Python 解释器的任何可能的活动。
要创建一个新的虚拟环境,可以使用 Python 3.3 之后所有安装中包含的标准模块 venv:
$ python3 -m venv venv
这将创建 venv 子目录,其中包含虚拟环境。可以使用以下 source 命令来激活环境:
请注意,我们为创建的虚拟环境使用了名称 venv,这与模块的名称相同。这不是必要的。虚拟环境可以用任何名称创建。确保使用一个在您的用例中具有描述性的名称。
$ source ./venv/bin/activate
(venv) $ which python
./venv/bin/python
(venv) $ which pip
./venv/bin/python
你可以看到执行的是虚拟环境中的 python 解释器和 pip,而不是系统版本,并且在提示符中也有指示,说明虚拟环境 venv 已激活。
虚拟环境也有自己的库,因此任何安装的包都会存储在这里,而不是在系统环境中。
可以通过调用 deactivate 命令来停用虚拟环境。你可以看到 (venv) 标识消失了。
一旦进入虚拟环境,任何对 pip 的调用都会在虚拟环境中安装包,因此它们与其他环境独立。然后每个程序都可以在其自己的虚拟环境中执行。
在无法直接通过命令行激活虚拟环境,需要直接执行命令的情况下,例如 cronjob 情况,可以直接通过虚拟环境的完整路径调用 python 解释器,例如 /path/to/venv/python/your_script.py。
在合适的环境中,我们可以使用 pip 安装不同的依赖项。
准备环境
创建虚拟环境是第一步,但我们需要安装软件的所有依赖项。
为了在所有情况下都能复制环境,最好的做法是创建一个定义了所有应安装依赖项的 requirements 文件。pip 允许通过一个文件(通常称为 requirements.txt)来安装依赖项。
这是一种创建可复制环境的极好方法,当需要时可以从头开始。
例如,让我们看一下下面的 requirements.txt 文件:
requests==2.26.0
pint==0.17
您可以从 GitHub 下载该文件:github.com/PacktPublishing/Python-Architecture-Patterns/blob/main/chapter_11_package_management/requirements.txt。
注意格式为package==version。这指定了用于该包的确切版本,这是安装依赖项的推荐方式。这样可以避免仅使用package,这会导致安装最新版本,可能会引起未计划的升级,这可能导致兼容性问题。
其他选项,如package>=version,可以指定最小版本。
可以使用以下命令在虚拟环境中安装该文件(请记住激活它):
(venv) $ pip install -r requirements.txt
之后,所有指定的需求都将安装到环境中。
注意,你指定的依赖项的依赖项可能并没有完全固定到特定版本。这是因为依赖项有自己的定义,当交付新包时,它可能会在二级依赖项上产生未知的升级。
为了避免出现这个问题,你可以创建一个包含一级依赖的初始安装,然后使用pip freeze命令获取所有已安装的依赖项:
(venv) $ pip freeze
certifi==2021.5.30
chardet==3.0.4
charset-normalizer==2.0.4
idna==2.10
packaging==21.0
Pint==0.17
pyparsing==2.4.7
requests==2.26.0
urllib3==1.26.6
你可以直接使用输出更新requirements.txt,这样下一次安装将具有所有二级依赖项也固定下来。
注意,添加新需求将需要生成相同的过程,首先安装,然后运行freeze,然后使用输出更新requirements.txt文件。
关于容器的一些注意事项
在容器化工作方式中,系统解释器和程序解释器之间的区别更加模糊,因为容器包含了自己的操作系统封装,从而强制执行了强分离。
在传统的服务部署方式中,它们在同一服务器上安装和运行,由于我们之前讨论的限制,这需要保持解释器之间的分离。
通过使用容器,我们已经在每个服务周围创建了一个封装到它们自己的操作系统文件系统,这意味着我们可以跳过虚拟环境的创建。在这种情况下,容器充当虚拟环境,强制不同容器之间的分离。
正如我们在第八章高级事件驱动结构中讨论的那样,当谈论容器时,每个容器应仅服务于单个服务,协调不同容器以生成不同的服务器。这样,它消除了需要共享相同解释器的情况。
这意味着我们可以放宽在传统环境中通常施加的一些限制,只需关注一个环境,能够更少地担心污染系统环境。只有一个环境,所以我们可以更自由地玩耍。如果我们需要更多服务或环境,我们总是可以创建更多容器。
Python 包
一个准备就绪的 Python 模块本质上是一个包含某些 Python 代码的子目录。这个子目录被安装到适当的库子目录中,解释器会在这个子目录中搜索。这个目录被称为site-packages。
如果你正在使用虚拟环境,这个子目录在虚拟环境中是可用的。你可以检查以下子目录:venv/lib/python3.9/site-packages/。
为了分发,这个子目录被打包成两个不同的文件,即Egg文件或Wheel文件。重要的是,pip只能安装Wheel文件。
可以创建源代码包。在这种情况下,文件是一个包含所有代码的 tar 文件。
Egg文件被认为是过时的,因为它们的格式较旧,基本上是一个包含一些元数据的压缩文件。Wheel文件有几个优点:
-
它们定义得更好,允许更多的使用场景。有一个特定的 PEP,PEP-427 (
www.python.org/dev/peps/pep-0427/),它定义了格式。Egg文件从未被正式定义。 -
它们可以被定义为具有更好的兼容性,允许创建在不同版本的 Python 之间兼容的
Wheel文件,包括 Python 2 和 Python 3。 -
Wheel文件可以包含已经编译的二进制代码。Python 允许包含用 C 语言编写的库,但这些库需要针对正确的硬件架构。在Egg文件中,源文件在安装时被包含并编译,但这需要在安装机器上提供适当的编译工具和环境,这很容易导致编译问题。 -
相反,
Wheel文件可以预先编译二进制文件。Wheel文件基于硬件架构和操作系统有更好的定义的兼容性,因此如果可用,将下载并安装正确的Wheel文件。这使得安装更快,因为不需要在安装时进行编译,并且消除了在目标机器上需要编译工具的需求。也可以创建包含源文件的Wheel文件,以便在未预先编译的机器上安装,尽管在这种情况下,它将需要一个编译器。 -
Wheel文件可以进行加密签名,而Eggs不支持此选项。这为避免受损和修改的包添加了一个额外的安全层。
目前,Python 的打包标准是Wheel文件,并且应该作为一般规则优先选择。Egg文件应限制在尚未升级到新格式的旧包中。
可以使用较旧的easy_install脚本来安装 Egg 文件,尽管这个脚本已经不再包含在 Python 的最新版本中。有关如何使用easy_install的设置工具文档,请参阅:setuptools.readthedocs.io/en/latest/deprecated/easy_install.html。
我们现在将看到如何创建自己的软件包。
创建软件包
即使在大多数情况下,我们将使用第三方软件包,但在某些时候,你可能需要创建自己的软件包。
要做到这一点,你需要创建一个setup.py文件,这是软件包的基础,描述了其中包含的内容。基本软件包代码看起来像这样:
package
├── LICENSE
├── README
├── setup.py
└── src
└─── <source code>
LICENSE和README文件不是必需的,但包含有关软件包的信息是很好的。LICENSE文件将自动包含在软件包中。
选择自己的开源许可可能很困难。你可以使用网站(choosealicense.com/),它展示了不同的选项并解释了它们。我们将以 MIT 许可为例。
README文件不包括在内,但我们将将其内容包含在软件包的完整描述中,作为构建过程的一部分,就像我们稍后将要看到的那样。
该过程的代码是setup.py文件。让我们看看一个例子:
import setuptools
with open('README') as readme:
description = readme.read()
setuptools.setup(
name='wheel-package',
version='0.0.1',
author='you',
author_email='me@you.com',
description='an example of a package',
url='http://site.com',
long_description=description,
classifiers=[
'Programming Language :: Python :: 3',
'Operating System :: OS Independent',
'License :: OSI Approved :: MIT License',
],
package_dir={'': 'src'},
install_requires=[
'requests',
],
packages=setuptools.find_packages(where='src'),
python_requires='>=3.9',
)
setup.py文件本质上包含setuptools.setup函数,它定义了软件包。它定义了以下内容:
-
name: 软件包的名称。 -
version: 软件包的版本。它将在安装特定版本或确定最新版本时使用。 -
author和author_email: 包括这些以接收任何可能的错误报告或请求。 -
description: 一个简短的描述。 -
url: 项目的 URL。 -
long_description: 一个更长的描述。在这里,我们正在读取README文件,将内容存储在description变量中:with open('README') as readme: description = readme.read()setup.py的一个重要细节是它是动态的,因此我们可以使用代码来确定任何参数的值。 -
classifier: 允许软件包在不同领域进行分类的类别,例如许可证类型和语言,或者软件包是否应该与 Django 等框架一起工作。你可以在以下链接中查看完整的分类器列表:pypi.org/classifiers/。 -
package_dir: 包代码所在的子目录。在这里,我们指定src。默认情况下,它将使用与setup.py相同的目录,但最好是进行划分,以保持代码整洁。 -
install_requires: 需要与您的软件包一起安装的任何依赖项。在这里,我们以requests为例。请注意,任何二阶依赖项(requests的依赖项)也将被安装。 -
packages: 使用setuptools.find_packages函数,包括src目录中的所有内容。 -
python_requires: 定义与该软件包兼容的 Python 解释器。在这种情况下,我们将其定义为 Python 3.9 或更高版本。
文件准备好后,你可以直接运行setup.py脚本,例如,以检查数据是否正确:
$ python setup.py check
running check
此命令将验证setup.py的定义是否正确,以及是否有任何必需元素缺失。
开发模式
setup.py文件可用于在develop模式下安装包。这意味着以链接方式将包安装到当前环境中。这意味着任何对代码的更改将在解释器重新启动后直接应用于包,这使得更改和与测试一起工作变得容易。请记住,在虚拟环境中运行它:
(venv) $ python setup.py develop
running develop
running egg_info
writing src/wheel_package.egg-info/PKG-INFO
writing dependency_links to src/wheel_package.egg-info/dependency_links.txt
writing requirements to src/wheel_package.egg-info/requires.txt
writing top-level names to src/wheel_package.egg-info/top_level.txt
reading manifest file 'src/wheel_package.egg-info/SOURCES.txt'
adding license file 'LICENSE'
...
Using venv/lib/python3.9/site-packages
Finished processing dependencies for wheel-package==0.0.1
开发版本可以轻松卸载以清理环境:
(venv) $ python setup.py develop --uninstall
running develop
Removing /venv/lib/python3.9/site-packages/wheel-package.egg-link (link to src)
Removing wheel-package 0.0.1 from easy-install.pth file
您可以在此处阅读有关开发模式的官方文档:setuptools.readthedocs.io/en/latest/userguide/development_mode.html。
此步骤将包直接安装到当前环境中,并可用于运行测试并验证包安装后是否按预期工作。一旦完成,我们就可以准备包本身。
纯 Python 包
要创建一个包,我们首先需要定义我们想要创建哪种类型的包。正如我们之前所描述的,我们有三种选择:源分发、Egg或Wheel。每个都由setup.py中的不同命令定义。
要创建源分发,我们将使用sdist(源分发):
$ python setup.py sdist
running sdist
running egg_info
writing src/wheel_package.egg-info/PKG-INFO
writing dependency_links to src/wheel_package.egg-info/dependency_links.txt
writing requirements to src/wheel_package.egg-info/requires.txt
writing top-level names to src/wheel_package.egg-info/top_level.txt
reading manifest file 'src/wheel_package.egg-info/SOURCES.txt'
adding license file 'LICENSE'
writing manifest file 'src/wheel_package.egg-info/SOURCES.txt'
running check
creating wheel-package-0.0.1
creating wheel-package-0.0.1/src
creating wheel-package-0.0.1/src/submodule
creating wheel-package-0.0.1/src/wheel_package.egg-info
copying files to wheel-package-0.0.1...
copying LICENSE -> wheel-package-0.0.1
copying README.md -> wheel-package-0.0.1
copying setup.py -> wheel-package-0.0.1
copying src/submodule/__init__.py -> wheel-package-0.0.1/src/submodule
copying src/submodule/submodule.py -> wheel-package-0.0.1/src/submodule
copying src/wheel_package.egg-info/PKG-INFO -> wheel-package-0.0.1/src/wheel_package.egg-info
copying src/wheel_package.egg-info/SOURCES.txt -> wheel-package-0.0.1/src/wheel_package.egg-info
copying src/wheel_package.egg-info/dependency_links.txt -> wheel-package-0.0.1/src/wheel_package.egg-info
copying src/wheel_package.egg-info/requires.txt -> wheel-package-0.0.1/src/wheel_package.egg-info
copying src/wheel_package.egg-info/top_level.txt -> wheel-package-0.0.1/src/wheel_package.egg-info
Writing wheel-package-0.0.1/setup.cfg
creating dist
Creating tar archive
removing 'wheel-package-0.0.1' (and everything under it)
dist包可在新创建的dist子目录中找到:
$ ls dist
wheel-package-0.0.1.tar.gz
要生成合适的Wheel包,我们首先需要安装wheel模块:
$ pip install wheel
Collecting wheel
Using cached wheel-0.37.0-py2.py3-none-any.whl (35 kB)
Installing collected packages: wheel
Successfully installed wheel-0.37.0
这将在setup.py中添加bdist_wheel命令到可用的命令中,该命令生成一个wheel:
$ python setup.py bdist_wheel
running bdist_wheel
running build
running build_py
installing to build/bdist.macosx-11-x86_64/wheel
...
adding 'wheel_package-0.0.1.dist-info/LICENSE'
adding 'wheel_package-0.0.1.dist-info/METADATA'
adding 'wheel_package-0.0.1.dist-info/WHEEL'
adding 'wheel_package-0.0.1.dist-info/top_level.txt'
adding 'wheel_package-0.0.1.dist-info/RECORD'
removing build/bdist.macosx-11-x86_64/wheel
并且wheel文件再次在dist子目录中可用:
$ ls dist
wheel_package-0.0.1-py3-none-any.whl
注意,它还包括 Python 3 版本。
与 Python 2 和 Python 3 都兼容的 Wheel 包可以用于。这些 wheel 被称为通用。在两个版本之间进行过渡时这很有用。希望到现在为止,Python 中的大多数新代码都在使用版本 3,我们不必担心这一点。
所有这些创建的包都可以直接使用 pip 安装:
$ pip install dist/wheel-package-0.0.1.tar.gz
Processing ./dist/wheel-package-0.0.1.tar.gz
...
Successfully built wheel-package
Installing collected packages: wheel-package
Successfully installed wheel-package-0.0.
$ pip uninstall wheel-package
Found existing installation: wheel-package 0.0.1
Uninstalling wheel-package-0.0.1:
Would remove:
venv/lib/python3.9/site-packages/submodule/*
venv/lib/python3.9/site-packages/wheel_package-0.0.1.dist-info/*
Proceed (Y/n)? y
Successfully uninstalled wheel-package-0.0.1
$ pip install dist/wheel_package-0.0.1-py3-none-any.whl
Processing ./dist/wheel_package-0.0.1-py3-none-any.whl
Collecting requests
Using cached requests-2.26.0-py2.py3-none-any.whl (62 kB)
...
Collecting urllib3<1.27,>=1.21.1
Using cached urllib3-1.26.6-py2.py3-none-any.whl (138 kB)
...
Installing collected packages: wheel-package
Successfully installed wheel-package-0.0.
注意,在这种情况下,依赖项requests以及任何二级依赖项,例如urllib3,都将自动安装。
打包的力量不仅适用于仅包含 Python 代码的包。wheels最有趣的功能之一是能够生成预编译的包,这包括针对目标系统的编译代码。
要展示这一点,我们需要生成一些包含将被编译的代码的 Python 模块。为此,我们需要稍微绕一下。
Cython
Python 能够创建 C 和 C++语言扩展,这些扩展被编译并与 Python 代码交互。Python 本身是用 C 编写的,因此这是一个自然的扩展。
虽然 Python 有很多优秀的特点,但在执行某些操作(如数值操作)时,纯速度并不是其强项。这就是 C 扩展大显身手的地方,因为它们允许访问底层代码,这些代码可以被优化并比 Python 运行得更快。不要低估创建一个小型、局部化的 C 扩展来加速代码关键部分的可能性。
然而,创建一个 C 扩展可能很困难。Python 和 C 之间的接口并不直接,而且 C 中所需的内存管理可能令人望而却步,除非您有丰富的 C 语言工作经验。
如果您想深入研究这个主题并创建自己的 C/C++ 扩展,可以从阅读官方文档开始:docs.python.org/3/extending/index.html。
其他选项还包括在 Rust 中创建扩展。您可以在以下文章中查看如何操作:developers.redhat.com/blog/2017/11/16/speed-python-using-rust。
幸运的是,有一些替代方案可以使任务变得更容易。其中一个非常好的选择是 Cython。
Cython 是一种工具,它使用 C 的一些扩展来编译 Python 代码,因此编写 C 扩展就像编写 Python 代码一样简单。代码有注释来描述变量的 C 类型,但除此之外,它看起来非常相似。
Cython 及其所有可能性的完整描述超出了本书的范围。我们仅提供简要介绍。请查阅完整文档以获取更多信息:cython.org/。
Cython 文件存储为 .pyx 文件。让我们看一个例子,它将使用 wheel_package_compiled.pyx 文件来确定一个数字是否为素数:
def check_if_prime(unsigned int number):
cdef int counter = 2
if number == 0:
return False
while counter < number:
if number % counter == 0:
return False
counter += 1
return True
代码正在检查一个正数是否为素数:
-
如果输入为零,则返回
False。 -
它尝试将数字除以从 2 到该数字的每个数字。如果任何除法是精确的,它返回
False,因为该数字不是素数。 -
如果没有精确的除法,或者数字小于 2,它返回
True。
代码并不完全符合 Python 风格,因为它将被翻译成 C。避免使用 range 或类似的 Python 调用会更有效率。不要害怕测试不同的方法,看看哪种执行速度更快。
代码并不特别出色;它通常尝试过多的除法。它只是为了展示可能被编译且不太复杂的示例代码。
一旦 pyx 文件准备就绪,就可以使用 Cython 编译并导入到 Python 中。首先,我们需要安装 Cython:
$ pip install cython
Collecting cython
Using cached Cython-0.29.24-cp39-cp39-macosx_10_9_x86_64.whl (1.9 MB)
Installing collected packages: cython
Successfully installed cython-0.29.24
现在,使用 pyximport,我们可以像导入 py 文件一样直接导入模块。如果需要,Cython 会自动编译它:
>>> import pyximport
>>> pyximport.install()
(None, <pyximport.pyximport.PyxImporter object at 0x10684a190>)
>>> import wheel_package_compiled
venv/lib/python3.9/site-packages/Cython/Compiler/Main.py:369: FutureWarning: Cython directive 'language_level' not set, using 2 for now (Py2). This will change in a later release! File: wheel_package_compiled.pyx
tree = Parsing.p_module(s, pxd, full_module_name)
.pyxbld/temp.macosx-11-x86_64-3.9/pyrex/wheel_package_compiled.c:1149:35: warning: comparison of integers of different signs: 'int' and 'unsigned int' [-Wsign-compare]
__pyx_t_1 = ((__pyx_v_counter < __pyx_v_number) != 0);
~~~~~~~~~~~~~~~ ^ ~~~~~~~~~~~~~~
1 warning generated.
>>> wheel_package_compiled.check_if_prime(5)
True
你可以看到编译器产生了一个错误,因为存在unsigned int和int(counter和number之间)的比较。
这是有意为之,以便清楚地显示编译发生的时间和任何编译反馈,如警告或错误,将会显示。
一旦代码编译完成,Cython 将创建一个位于目录本地的wheel_package_compiled.c文件和一个编译后的.so文件,默认情况下存储在$HOME/ .pyxbld:
注意,这将是特定于你的系统的。在这里,我们展示了一个为 macOS 编译的模块。
$ ls ~/.pyxbld/lib.macosx-11-x86_64-3.9/
wheel_package_compiled.cpython-39-darwin.so
使用pyximport对本地开发很有用,但我们可以创建一个包,在构建过程中编译并打包它。
带有二进制代码的 Python 包
我们将使用使用 Cython 创建的代码来展示如何构建一个结合 Python 代码和预编译代码的包。我们将生成一个Wheel文件。
我们创建了一个名为wheel_package_compiled的包,它扩展了之前的示例包wheel_package,并包含了要使用 Cython 编译的代码。
代码可在 GitHub 上找到:github.com/PacktPublishing/Python-Architecture-Patterns/tree/main/chapter_11_package_management/wheel_package_compiled。
包的结构将如下所示:
wheel_package_compiled
├── LICENSE
├── README
├── src
│ ├── __init__.py
│ ├── submodule
│ │ ├── __init__.py
│ │ └── submodule.py
│ ├── wheel_package.py
│ └── wheel_package_compiled.pyx
└── setup.py
这与之前介绍的包相同,但增加了.pyx文件。setup.py文件需要添加一些更改:
import setuptools
from Cython.Build import cythonize
from distutils.extension import Exteldnsion
extensions = [
Extension("wheel_package_compiled", ["src/wheel_package_compiled.pyx"]),
]
with open('README') as readme:
description = readme.read()
setuptools.setup(
name='wheel-package-compiled',
version='0.0.1',
author='you',
author_email='me@you.com',
description='an example of a package',
url='http://site.com',
long_description=description,
classifiers=[
'Programming Language :: Python :: 3',
'Operating System :: OS Independent',
'License :: OSI Approved :: MIT License',
],
package_dir={'': 'src'},
install_requires=[
'requests',
],
ext_modules=cythonize(extensions),
packages=setuptools.find_packages(where='src'),
python_requires='>=3.9',
)
除了包的name更改之外,引入的所有更改都与新的扩展有关:
from Cython.Build import cythonize
from distutils.extension import Extension
extensions = [
Extension("wheel_package_compiled", ["src/wheel_package_compiled.pyx"]),
]
...
ext_modules=cythonize(extensions),
扩展定义针对要添加的模块名称和源位置。使用cythonize函数,我们表示希望使用 Cython 来编译它。
扩展模块是使用 C/C++编译的模块。在这种情况下,Cython 将运行中间步骤以确保正在编译的是正确的.c文件。
一旦配置完成,我们可以运行代码生成Wheel,调用setup.py:
$ python setup.py bdist_wheel
Compiling src/wheel_package_compiled.pyx because it changed.
[1/1] Cythonizing src/wheel_package_compiled.pyx
...
running bdist_wheel
running build
running build_py
...
creating 'dist/wheel_package_compiled-0.0.1-cp39-cp39-macosx_11_0_x86_64.whl' and adding 'build/bdist.macosx-11-x86_64/wheel' to it
adding 'wheel_package_compiled.cpython-39-darwin.so'
adding 'submodule/__init__.py'
adding 'submodule/submodule.py'
adding 'wheel_package_compiled-0.0.1.dist-info/LICENSE'
adding 'wheel_package_compiled-0.0.1.dist-info/METADATA'
adding 'wheel_package_compiled-0.0.1.dist-info/WHEEL'
adding 'wheel_package_compiled-0.0.1.dist-info/top_level.txt'
adding 'wheel_package_compiled-0.0.1.dist-info/RECORD'
removing build/bdist.macosx-11-x86_64/wheel
编译后的Wheel包,与之前一样,位于dist子目录中。
$ ls dist
wheel_package_compiled-0.0.1-cp39-cp39-macosx_11_0_x86_64.whl
与之前创建的Wheel相比,我们可以看到它增加了平台和硬件架构(macOS 11 和 x86 64 位,这是编写本书时用于编译的计算机)。cp39部分表明它使用了 Python 3.9 ABI(应用程序二进制接口)。
创建的Wheel包适用于相同的架构和系统。Wheel包直接包含所有编译后的代码,因此安装速度快,只需复制文件即可。此外,无需安装编译工具和依赖项。
当与需要在多个架构或系统上安装的包一起工作时,你需要为每种情况创建一个单独的Wheel,并将源分发文件添加进去,以便其他系统可以与之协同工作。
但是,除非您正在创建一个要提交给 PyPI 的通用包,否则该包将用于自用,并且通常您只需要为您的特定用例创建一个Wheel文件。
这将导致相同的步骤。如果您想与整个 Python 社区分享您的模块怎么办?
将您的包上传到 PyPI
PyPI 对所有开发者开放,接受包。我们可以创建一个新账户并将我们的包上传到官方 Python 仓库,以便任何项目都可以使用它。
开源项目,如 Python 及其生态系统,的一个伟大特征是能够使用其他开发者优雅共享的代码。虽然不是强制性的,但总是好的,要回馈并分享可能对其他开发者感兴趣的代码,以增加 Python 库的有用性。
成为 Python 生态系统的良好参与者,并分享可能对他人有用的代码。
为了帮助测试并确保我们可以验证这个过程,有一个名为TestPyPI的测试网站test.pypi.org/,可以用来执行测试并首先上传您的包。

图 11.4:TestPyPI 主页
网站与生产环境相同,但通过横幅标明它是测试网站。
您可以在test.pypi.org/account/register/注册新用户。之后,您需要创建一个新的 API 令牌,以便允许上传包。
记得验证您的电子邮件。如果没有验证的电子邮件,您将无法创建 API 令牌。
如果 API 令牌有问题或丢失,您总是可以删除它并重新开始。

图 11.5:您需要授予完整权限才能上传新包
创建一个新的令牌并将其复制到安全的地方。出于安全原因,令牌(以pypi-开头)只会显示一次,所以请小心处理。
令牌在上传包时替换了登录名和密码。我们稍后会看到如何使用它。
下一步是安装twine包,它简化了上传过程。请确保在我们的虚拟环境中安装它:
(venv) $ pip install twine
Collecting twine
Downloading twine-3.4.2-py3-none-any.whl (34 kB)
...
Installing collected packages: zipp, webencodings, six, Pygments, importlib-metadata, docutils, bleach, tqdm, rfc3986, requests-toolbelt, readme-renderer, pkginfo, keyring, colorama, twine
Successfully installed Pygments-2.10.0 bleach-4.1.0 colorama-0.4.4 docutils-0.17.1 importlib-metadata-4.8.1 keyring-23.2.0 pkginfo-1.7.1 readme-renderer-29.0 requests-toolbelt-0.9.1 rfc3986-1.5.0 six-1.16.0 tqdm-4.62.2 twine-3.4.2 webencodings-0.5.1 zipp-3.5.0
现在我们可以上传在dist子目录中创建的包。
对于我们的示例,我们将使用之前创建的相同包,但请注意,尝试重新上传可能不起作用,因为在 TestPyPI 中可能已经有一个同名包。TestPyPI 不是永久的,并且定期删除包,但作为本书写作过程的一部分上传的示例可能仍然存在。为了进行测试,创建一个具有唯一名称的自己的包。
我们现在已经构建了编译的Wheel和源分发:
(venv) $ ls dist
wheel-package-compiled-0.0.1.tar.gz
wheel_package_compiled-0.0.1-cp39-cp39-macosx_11_0_x86_64.whl
让我们上传软件包。我们需要指定我们想要上传到testpy仓库。我们将使用__token__作为用户名,以及完整的令牌(包括pypi-前缀)作为密码:
(venv) $ python -m twine upload --repository testpypi dist/*
Uploading distributions to https://test.pypi.org/legacy/
Enter your username: __token__
Enter your password:
Uploading wheel_package_compiled-0.0.1-cp39-cp39-macosx_11_0_x86_64.whl
100%|███████████████████████████████████████████████████████████████████████| 12.6k/12.6k [00:01<00:00, 7.41kB/s]
Uploading wheel-package-compiled-0.0.1.tar.gz
100%|███████████████████████████████████████████████████████████████████████| 24.0k/24.0k [00:00<00:00, 24.6kB/s]
View at:
https://test.pypi.org/project/wheel-package-compiled/0.0.1/
软件包现在已上传!我们可以在 TestPyPI 网站上查看该页面。
![图形用户界面,网站]
自动生成的描述](https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/py-arch-ptn/img/B17580_11_06.png)
图 11.6:软件包的主页
你可以通过点击下载文件来验证上传的文件:
![图形用户界面,应用程序,网站]
自动生成的描述](https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/py-arch-ptn/img/B17580_11_07.png)
图 11.7:验证上传的文件
你也可以通过搜索功能访问文件:
![图形用户界面,网站]
自动生成的描述](https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/py-arch-ptn/img/B17580_11_08.png)
图 11.8:搜索中可用的软件包
你现在可以直接通过pip下载软件包,但你需要指定要使用的索引是 TestPyPI。为了确保干净安装,请按照以下步骤创建一个新的虚拟环境:
$ python3 -m venv venv2
$ source ./venv2/bin/activate
(venv2) $ pip install --index-url https://test.pypi.org/simple/ wheel-package-compiled
Looking in indexes: https://test.pypi.org/simple/
Collecting wheel-package-compiled
Downloading https://test-files.pythonhosted.org/packages/87/c3/881298cdc8eb6ad23456784c80d585b5872581d6ceda6da3dfe3bdcaa7ed/wheel_package_compiled-0.0.1-cp39-cp39-macosx_11_0_x86_64.whl (9.6 kB)
Collecting requests
Downloading https://test-files.pythonhosted.org/packages/6d/00/8ed1b6ea43b10bfe28d08e6af29fd6aa5d8dab5e45ead9394a6268a2d2ec/requests-2.5.4.1-py2.py3-none-any.whl (468 kB)
|████████████████████████████████| 468 kB 634 kB/s
Installing collected packages: requests, wheel-package-compiled
Successfully installed requests-2.5.4.1 wheel-package-compiled-0.0.1
注意,下载的版本是Wheel版本,因为它是编译版本的合适目标。它还正确地下载了指定的requests依赖项。
你现在可以通过 Python 解释器测试该软件包:
(venv2) $ python
Python 3.9.6 (default, Jun 29 2021, 05:25:02)
[Clang 12.0.5 (clang-1205.0.22.9)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> import wheel_package_compiled
>>> wheel_package_compiled.check_if_prime(5)
True
软件包现在已安装并准备好使用。下一步是将此软件包上传到生产 PyPI 而不是 TestPyPI。这与我们在这里看到的流程完全类似,即在 PyPI 中创建账户并从这里开始。
但是,如果软件包的目标不是创建一个公开可用的软件包呢?可能我们需要使用我们的软件包创建自己的索引。
创建自己的私有索引
有时,你可能需要拥有自己的私有索引,这样你就可以在不向整个互联网开放的情况下,为内部需要跨公司使用的包提供服务,但又不适合将它们上传到公共 PyPI。
你可以创建自己的私有索引,用于共享这些软件包并通过调用该索引来安装它们。
要提供软件包服务,我们需要在本地运行一个 PyPI 服务器。在可用的服务器方面有几个选项,但一个简单的方法是使用pypiserver(github.com/pypiserver/pypiserver)。
pypiserver可以通过几种方式安装;我们将看到如何在本地运行它,但要正确提供服务,你需要以在网络中可用的方式安装它。查看文档以查看几个选项,但一个好的选择是使用官方的 Docker 镜像。
要运行pypiserver,首先,使用pip安装该软件包并创建一个用于存储软件包的目录:
$ pip install pypiserver
Collecting pypiserver
Downloading pypiserver-1.4.2-py2.py3-none-any.whl (77 kB)
|████████████████████████████████| 77 kB 905 kB/s
Installing collected packages: pypiserver
Successfully installed pypiserver-1.4.2
$ mkdir ./package-library
启动服务器。我们使用参数-p 8080在该端口提供服务,存储软件包的目录,以及-P . -a .以简化无需身份验证的软件包上传:
$ pypi-server -P . -a . -p 8080 ./package-library
打开浏览器并检查http://localhost:8080。

图 11.9:本地 pypi 服务器
您可以通过访问http://localhost:8080/simple/来检查此索引中可用的包。

图 11.10:到目前为止的空索引
我们现在需要再次使用twine上传包,但指向我们的私有 URL。由于我们能够无认证上传,我们可以输入空的用户名和密码:
$ python -m twine upload --repository-url http://localhost:8080 dist/*
Uploading distributions to http://localhost:8080
Enter your username:
Enter your password:
Uploading wheel_package_compiled-0.0.1-cp39-cp39-macosx_11_0_x86_64.whl
100%|████████████████████████████████| 12.6k/12.6k [00:00<00:00, 843kB/s]
Uploading wheel-package-compiled-0.0.1.tar.gz
100%|████████████████████████████████| 24.0k/24.0k [00:00<00:00, 2.18MB/s]
索引现在显示可用的包。

图 11.11:显示上传的包

图 11.12:包的所有上传文件
文件也上传到了package-library目录:
$ ls package-library
wheel-package-compiled-0.0.1.tar.gz
wheel_package_compiled-0.0.1-cp39-cp39-macosx_11_0_x86_64.whl
任何添加到package-library的文件也将被提供,允许通过将它们移动到目录中来添加包,尽管一旦服务器正确部署到网络上的包,这可能变得复杂。
现在可以通过使用–index-url参数指向您的私有索引来下载和安装此包:
$ pip install --index-url http://localhost:8080 wheel-package-compiled
Looking in indexes: http://localhost:8080
Collecting wheel-package-compiled
Downloading http://localhost:8080/packages/wheel_package_compiled-0.0.1-cp39-cp39-macosx_11_0_x86_64.whl (9.6 kB)
…
Successfully installed certifi-2021.5.30 charset-normalizer-2.0.4 idna-3.2 requests-2.26.0 urllib3-1.26.6 wheel-package-compiled-0.0.1
$ python
Python 3.9.6 (default, Jun 29 2021, 05:25:02)
[Clang 12.0.5 (clang-1205.0.22.9)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> import wheel_package_compiled
>>> wheel_package_compiled.check_if_prime(5)
True
这测试了模块在安装后可以被导入和执行。
摘要
在本章中,我们描述了何时创建标准包是一个好主意,以及我们应该添加的注意事项和要求,以确保我们做出了正确的决定。本质上,创建一个新的包就是创建一个新的项目,我们应该像对组织中的其他项目一样,给予适当的所有权、文档等。
我们仅通过结构化代码就描述了 Python 中最简单的包,但没有创建一个合适的包。这作为了后续代码结构的一个基准。
我们继续描述了当前的打包环境是什么,以及它是如何包含不同元素的,比如 PyPI,它是公开可用包的官方来源,以及如何创建虚拟环境以避免在需要不同依赖项时不同环境之间的交叉污染。我们还描述了Wheel包,这是我们稍后将要创建的包类型。
接下来,我们描述了如何创建这样的包,创建一个setup.py文件。我们描述了如何在开发模式下安装它以便进行测试,以及如何构建并准备好包。
创建包而不是使用标准的setup.py文件有一些替代方案。您可以查看Poetry包([https://python-poetry.org/](https://python-poetry.org/))以了解如何以更集成的方式管理包,特别是如果包有多个依赖项的话。
我们稍作绕道解释了如何生成用 Cython 编译的代码,这是一种通过在 Python 代码中添加一些扩展来创建 Python 扩展的简单方法,可以自动生成 C 代码。
我们使用 Cython 代码展示了如何生成编译后的 Wheel,允许分发已经预先编译的代码,无需在安装时进行编译。
我们展示了如何将软件包上传到 PyPI 以公开分发(展示了如何上传到 TestPyPI,允许测试软件包的上传)并描述了如何创建你自己的个人索引,以便你可以私下分发你自己的软件包。
第四部分
持续运营
当系统上线并运行时,我们的架构工作并未结束。一个运行中的应用程序需要持续的维护和努力来保持其成功运行。
系统在其生命周期的大部分时间里将处于维护阶段。在这个阶段,我们添加功能、检测和修复缺陷,并分析系统的行为以预防问题。
要能够成功做到这一点,我们需要拥有工具来覆盖两个基本要素:
-
可观察性:这是了解实时系统正在发生什么的能 力。低可观察性系统难以理解,甚至可能无法理解,这使得了解是否存在问题或找出问题原因变得困难。在高可观察性系统中,很容易推断系统的内部状态和系统内部流动的事件,这允许轻松检测到产生问题的关键结构。观察系统的主要工具是日志和指标,它们结合使用,使我们能够理解系统并分析其行为。
可观察性是系统本身的属性。通常,监控是获取关于系统当前或过去状态信息的行为。这有点像是命名上的争论,但从技术上讲,你监控系统以收集其可观察的部分。
-
分析:为了在更受控的情况下检测问题,我们有两个重要的工具,调试和性能分析。第一个是开发过程中的基本要素,通过逐步分析代码来理解代码的工作方式,并确定它为什么这样做。性能分析是通过向代码中添加工具来展示其工作方式,并具体确定哪些部分执行时间最长,以便你可以针对它们采取行动并提高其性能。这两个工具相互补充,使我们能够在检测到问题后修复和改进不同类型的问题。
在本节中,我们还将讨论在系统运行时进行更改的挑战。软件中唯一不变的是变化,平衡现有系统与新功能是一个关键能力。这项任务的一部分是协调不同团队,使他们了解他们更改的影响,并作为一个单一单位工作。
本节包括以下章节:
-
记录
-
指标
-
性能分析
-
调试
-
持续架构
我们将首先了解如何使用日志进行监控。
第十二章:日志记录
监控和可观察性的基本要素之一就是日志。日志使我们能够检测正在运行的系统中的动作。这些信息可以用来分析系统的行为,特别是可能出现的任何错误或缺陷,从而为我们提供对实际发生情况的宝贵见解。
正确使用日志看似困难,实则不然。很容易收集过多或过少的信息,或者记录错误的信息。在本章中,我们将探讨一些关键元素,以及确保日志发挥最佳效果的通用策略。
在本章中,我们将涵盖以下主题:
-
日志基础
-
在 Python 中生成日志
-
通过日志检测问题
-
日志策略
-
在开发时添加日志
-
日志限制
让我们从日志的基本原则开始。
日志基础
日志基本上是系统运行时产生的消息。这些消息是在代码执行时由特定的代码片段产生的,使我们能够跟踪代码中的动作。
日志可以是完全通用的,如“函数 X 被调用”,或者可以包含一些执行具体细节的上下文,如“函数 X 使用参数 Y 被调用.”
通常,日志以纯文本消息的形式生成。虽然还有其他选项,但纯文本处理起来非常简单,易于阅读,格式灵活,可以使用像grep这样的纯文本工具进行搜索。这些工具通常运行得非常快,大多数开发人员和系统管理员都知道如何使用它们。
除了主要的消息文本外,每个日志还包含一些元数据,例如产生日志的系统、日志创建的时间等。如果日志是文本格式,这些信息通常附加到行的开头。
标准和一致的日志格式有助于您进行消息的搜索、过滤和排序。请确保您在不同系统之间使用一致的格式。
另一个重要的元数据值是日志的严重性。这使我们能够根据相对重要性对不同的日志进行分类。标准严重性级别,按从低到高的顺序,是DEBUG、INFO、WARNING和ERROR。
CRITICAL级别使用较少,但用于显示灾难性错误是有用的。
重要的是要对日志进行适当的分类,并过滤掉不重要的消息,以便关注更重要的消息。每个日志设施都可以配置为仅生成一个或多个严重性级别的日志。
可以添加自定义日志级别,而不是预定义的级别。这通常不是一个好主意,在大多数情况下应避免,因为所有工具和工程师都很好地理解了日志级别。我们将在本章后面描述如何为每个级别定义策略,以充分利用每个级别。
在一个处理请求的系统(无论是请求-响应还是异步)中,大部分日志都会作为处理请求的一部分生成,这将产生几个日志,指示请求正在做什么。由于通常会有多个请求同时进行,日志将会混合生成。例如,考虑以下日志:
Sept 16 20:42:04.130 10.1.0.34 INFO web: REQUEST GET /login
Sept 16 20:42:04.170 10.1.0.37 INFO api: REQUEST GET /api/login
Sept 16 20:42:04.250 10.1.0.37 INFO api: REQUEST TIME 80 ms
Sept 16 20:42:04.270 10.1.0.37 INFO api: REQUEST STATUS 200
Sept 16 20:42:04.360 10.1.0.34 INFO web: REQUEST TIME 230 ms
Sept 16 20:42:04.370 10.1.0.34 INFO web: REQUEST STATUS 200
前面的日志显示了两个不同的服务,如不同的 IP 地址(10.1.0.34 和 10.1.0.37)和两种不同的服务类型(web 和 api)所示。虽然这足以区分请求,但创建一个单一的请求 ID 来能够以以下方式分组请求是个好主意:
Sept 16 20:42:04.130 10.1.0.34 INFO web: [4246953f8] REQUEST GET /login
Sept 16 20:42:04.170 10.1.0.37 INFO api: [fea9f04f3] REQUEST GET /api/login
Sept 16 20:42:04.250 10.1.0.37 INFO api: [fea9f04f3] REQUEST TIME 80 ms
Sept 16 20:42:04.270 10.1.0.37 INFO api: [fea9f04f3] REQUEST STATUS 200
Sept 16 20:42:04.360 10.1.0.34 INFO web: [4246953f8] REQUEST TIME 230 ms
Sept 16 20:42:04.370 10.1.0.34 INFO web: [4246953f8] REQUEST STATUS 200
在微服务环境中,请求将从一项服务流向另一项服务,因此创建一个跨服务共享的请求 ID 是个好主意,以便可以理解完整的跨服务流程。为此,请求 ID 需要由第一个服务创建,然后传输到下一个服务,通常作为 HTTP 请求的头部。
如我们在第五章,十二要素应用方法中看到的,在十二要素应用方法中,日志应该被视为一个事件流。这意味着应用程序本身不应该关心日志的存储和处理。相反,日志应该被导向 stdout。从那里,在开发应用程序时,开发者可以在运行时提取信息。
在生产环境中,stdout 应该被捕获,以便其他工具可以使用它,然后进行路由,将不同的来源合并到一个单一的流中,然后存储或索引以供以后查阅。这些工具应该在生产环境中配置,而不是在应用程序本身中配置。
可能用于此重路由的工具包括类似 Fluentd (github.com/fluent/fluentd) 或甚至旧爱的直接到 logger Linux 命令来创建系统日志,然后将这些日志发送到配置的 rsyslog (www.rsyslog.com/) 服务器,该服务器可以转发和聚合它们。
无论我们如何收集日志,一个典型的系统都会产生大量的日志,并且需要存储在某个地方。虽然每个单独的日志都很小,但聚合数千个日志会占用大量的空间。任何日志系统都应该配置为有一个政策,以确定它应该接受多少数据以避免无限增长。一般来说,基于时间(例如保留过去 15 天的日志)的保留策略是最好的方法,因为它将很容易理解。在需要回溯多远的历史和系统使用的空间量之间找到平衡是很重要的。
在启用任何新的日志服务时,无论是本地还是基于云的,务必检查保留策略,以确保它与您定义的保留期兼容。您将无法分析时间窗口之前发生的事情。请再次确认日志创建速率是否符合预期,以及空间消耗是否没有使您可以收集日志的有效时间窗口变小。您不希望在你追踪一个错误时意外地超出配额。
生成日志条目很简单,正如我们将在下一节中看到的,在 Python 中生成日志。
在 Python 中生成日志
Python 包含一个用于生成日志的标准模块。这个模块易于使用,具有非常灵活的配置,但如果你不了解它的操作方式,可能会令人困惑。
一个创建日志的基本程序看起来像这样。这个程序可以在 GitHub 上的 github.com/PacktPublishing/Python-Architecture-Patterns/tree/main/chapter_12_logging 找到,文件名为 basic_logging.py。
import logging
# Generate two logs with different severity levels
logging.warning('This is a warning message')
logging.info('This is an info message')
.warning 和 .info 方法创建具有相应严重性消息的日志。消息是一个文本字符串。
当执行时,它会显示以下内容:
$ python3 basic_logging.py
WARNING:root:This is a warning message
默认情况下,日志会被路由到 stdout,这是我们想要的,但它被配置为不显示 INFO 级别的日志。日志的格式也是默认的,不包含时间戳。
要添加所有这些信息,我们需要了解 Python 中用于日志记录的三个基本元素:
-
一个 格式化器,它描述了完整的日志将如何呈现,附加元数据如时间戳或严重性。
-
一个 处理器,它决定了日志如何传播。它通过格式化器设置日志的格式,如上面定义的那样。
-
一个 记录器,它生成日志。它有一个或多个处理器,描述了日志如何传播。
使用这些信息,我们可以配置日志以指定我们想要的全部细节:
import sys
import logging
# Define the format
FORMAT = '%(asctime)s.%(msecs)dZ:APP:%(name)s:%(levelname)s:%(message)s'
formatter = logging.Formatter(FORMAT, datefmt="%Y-%m-%dT%H:%M:%S")
# Create a handler that sends the logs to stdout
handler = logging.StreamHandler(stream=sys.stdout)
handler.setFormatter(formatter)
# Create a logger with name 'mylogger', adding the handler and setting
# the level to INFO
logger = logging.getLogger('mylogger')
logger.addHandler(handler)
logger.setLevel(logging.INFO)
# Generate three logs
logger.warning('This is a warning message')
logger.info('This is an info message')
logger.debug('This is a debug message, not to be displayed')
我们按照之前看到的顺序定义这三个元素。首先是 formatter,然后是 handler,它设置 formatter,最后是 logger,它添加 handler。
formatter 的格式如下:
FORMAT = '%(asctime)s.%(msecs)dZ:APP:%(name)s:%(levelname)s:%(message)s'
formatter = logging.Formatter(FORMAT, datefmt="%Y-%m-%dT%H:%M:%S")
FORMAT 是由 Python % 格式组成的,这是一种描述字符串的旧方法。大多数元素都描述为 %(name)s,其中最后的 s 字符表示字符串格式。以下是每个元素的描述:
-
asctime将时间戳设置为可读的格式。我们在datefmt参数中描述它,以遵循 ISO 8601 格式。我们还添加了毫秒数和一个Z来获取完整的 ISO 8601 格式的时间戳。%(msecs)d末尾的d表示我们将值打印为整数。这是为了将值限制在毫秒,而不显示任何额外的分辨率,这可以作为分数值提供。 -
name是记录器的名称,正如我们稍后将要描述的那样。我们还添加了APP以区分不同的应用程序。 -
levelname是日志的严重性,例如INFO、WARNING或ERROR。 -
最后,
message是日志消息。
一旦我们定义了formatter,我们就可以转向handler:
handler = logging.StreamHandler(stream=sys.stdout)
handler.setFormatter(formatter)
处理器是一个StreamHandler,我们将流的目的地设置为sys.stdout,这是 Python 定义的指向stdout的变量。
有更多可用的处理器,如FileHandler,可以将日志发送到文件,SysLogHandler可以将日志发送到syslog目的地,还有更高级的案例,如TimeRotatingFileHandler,它根据时间旋转日志,这意味着它存储最后定义的时间,并归档旧版本。您可以在文档docs.python.org/3/howto/logging.html#useful-handlers中查看所有可用处理器的更多信息。
一旦定义了handler,我们就可以创建logger:
logger = logging.getLogger('mylogger')
logger.addHandler(handler)
logger.setLevel(logging.INFO)
首件事是为 logger 创建一个名称,这里我们将其定义为mylogger。这允许我们将应用程序的日志划分为子部分。我们使用.addHandler附加处理器。
最后,我们使用.setLevel方法将日志级别定义为INFO。这将显示所有INFO级别及以上的日志,而低于此级别的日志则不会显示。
如果我们运行文件,我们会看到整个配置组合在一起:
$ python3 configured_logging.py
2021-09-18T23:15:24.563Z:APP:mylogger:WARNING:This is a warning message
2021-09-18T23:15:24.563Z:APP:mylogger:INFO:This is an info message
我们可以看到:
-
时间定义为 ISO 8601 格式,即
2021-09-18T23:15:24.563Z。这是asctime和msec参数的组合。 -
APP和mylogger参数允许我们通过应用程序和子模块进行筛选。 -
显示了严重性。请注意,有一个未显示的
DEBUG消息,因为配置的最小级别是INFO。
Python 中的logging模块能够进行高级别的配置。有关更多信息,请参阅官方文档docs.python.org/3/library/logging.html。
通过日志检测问题
对于运行中的系统中的任何问题,都可能发生两种错误:预期错误和意外错误。在本节中,我们将通过日志来了解它们之间的区别,以及我们如何处理它们。
检测预期错误
预期错误是通过在代码中创建ERROR日志来显式检测到的错误。例如,以下代码在访问的 URL 返回的状态码不是200 OK时产生ERROR日志:
import logging
import requests
URL = 'https://httpbin.org/status/500'
response = requests.get(URL)
status_code = response.status_code
if status_code != 200:
logging.error(f'Error accessing {URL} status code {status_code}')
当执行此代码时,会触发一个ERROR日志:
$ python3 expected_error.py
ERROR:root:Error accessing https://httpbin.org/status/500 status code 500
这是一种常见的模式,用于访问外部 URL 并验证其是否正确访问。生成日志的块可以执行一些补救措施或重试,以及其他操作。
在这里,我们使用httpbin.org服务,这是一个简单的 HTTP 请求和响应服务,可用于测试代码。特别是,https://httpbin.org/status/<code>端点返回指定的状态码,这使得生成错误变得容易。
这是一个预期错误的例子。我们事先计划了某些我们不希望发生的事情,但理解了它可能发生的可能性。通过提前规划,代码可以准备好处理错误并充分捕获它。
在这种情况下,我们可以清楚地描述情况,并提供上下文来理解正在发生的事情。问题很明显,即使解决方案可能不是。
这类错误相对容易处理,因为它们描述的是预见的问题。
例如,网站可能不可用,可能存在认证问题,或者可能是基本 URL 配置错误。
请记住,在某些情况下,代码可能能够处理某种情况而不失败,但它仍然被视为错误。例如,你可能想检测是否有某人仍在使用旧的认证系统。当检测到已弃用的操作时,添加ERROR或WARNING日志的方法可以让你采取行动来纠正情况。
这种类型错误的其他例子包括数据库连接和以已弃用的格式存储的数据。
捕获意外错误
但预期错误并不是唯一可能发生的错误。不幸的是,任何正在运行的系统都会以各种意想不到的方式让你感到惊讶,从而以创新的方式破坏代码。Python 中的意外错误通常是在代码的某个点抛出异常时产生的,而这个异常不会被捕获。
例如,想象当我们对某些代码进行小改动时,我们引入了一个拼写错误:
import logging
import requests
URL = 'https://httpbin.org/status/500'
logging.info(f'GET {URL}')
response = requests.ge(URL)
status_code = response.status_code
if status_code != 200:
logging.error(f'Error accessing {URL} status code {status_code}')
注意,在第 8 行中,我们引入了一个拼写错误:
response = requests.ge(URL)
正确的.get调用已被.ge替换。当我们运行它时,会产生以下错误:
$ python3 unexpected_error.py
Traceback (most recent call last):
File "./unexpected_error.py", line 8, in <module>
response = requests.ge(URL)
AttributeError: module 'requests' has no attribute 'ge'
在 Python 中默认情况下,它会在stdout中显示错误和堆栈跟踪。当代码作为 Web 服务器的一部分执行时,这有时足以将这些消息作为ERROR日志发送,具体取决于配置如何设置。
任何网络服务器都会捕获并正确地将这些消息路由到日志中,并生成适当的 500 状态码,指示发生了意外错误。服务器仍然可以处理下一个请求。
如果你需要创建一个需要无限期运行并保护不受任何意外错误影响的脚本,请确保使用try..except块,因为它是一般性的,所以任何可能的异常都将被捕获和处理。
任何使用特定except块正确捕获的 Python 异常都可以被认为是预期错误。其中一些可能需要生成ERROR消息,但其他可能被捕获和处理,而不需要此类信息。
例如,让我们调整代码,使其每隔几秒发送一次请求。代码在 GitHub 上可用,链接为 github.com/PacktPublishing/Python-Architecture-Patterns/tree/main/chapter_12_logging:
import logging
import requests
from time import sleep
logger = logging.getLogger()
logger.setLevel(logging.INFO)
while True:
try:
sleep(3)
logging.info('--- New request ---')
URL = 'https://httpbin.org/status/500'
logging.info(f'GET {URL}')
response = requests.ge(URL)
scode = response.status_code
if scode != 200:
logger.error(f'Error accessing {URL} status code {scode}')
except Exception as err:
logger.exception(f'ERROR {err}')
关键元素是以下无限循环:
while True:
try:
code
except Exception as err:
logger.exception(f'ERROR {err}')
try..except块在循环内部,所以即使有错误,循环也不会中断。如果有任何错误,except Exception将捕获它,无论异常是什么。
这有时被称为宝可梦异常处理,就像“抓到它们所有”。这应该限制为一种“最后的救命网”。一般来说,不精确地捕获异常是一个坏主意,因为你可以通过错误地处理它们来隐藏错误。错误永远不应该无声地通过。
为了确保不仅记录了错误,还记录了完整的堆栈跟踪,我们使用.exception而不是.error来记录它。这通过ERROR严重性记录扩展了信息,而不会超过单条文本消息。
当我们运行命令时,我们会得到这些日志。确保通过按Ctrl + C来停止它:
$ python3 protected_errors.py
INFO:root:--- New request ---
INFO:root:GET https://httpbin.org/status/500
ERROR:root:ERROR module 'requests' has no attribute 'ge'
Traceback (most recent call last):
File "./protected_errors.py", line 18, in <module>
response = requests.ge(URL)
AttributeError: module 'requests' has no attribute 'ge'
INFO:root:--- New request ---
INFO:root:GET https://httpbin.org/status/500
ERROR:root:ERROR module 'requests' has no attribute 'ge'
Traceback (most recent call last):
File "./protected_errors.py", line 18, in <module>
response = requests.ge(URL)
AttributeError: module 'requests' has no attribute 'ge'
^C
...
KeyboardInterrupt
正如你所见,日志中包含了Traceback,这使我们能够通过添加异常产生位置的信息来检测特定的问题。
任何意外错误都应该记录为ERROR。理想情况下,它们还应该被分析,并更改代码以修复错误或至少将它们转换为预期的错误。有时这由于其他紧迫的问题或问题发生的频率低而不可行,但应该实施一些策略以确保处理错误的连贯性。
处理意外错误的一个优秀工具是 Sentry (sentry.io/)。这个工具在许多常见的平台上为每个错误创建触发器,包括 Python Django、Ruby on Rails、Node、JavaScript、C#、iOS 和 Android。它聚合检测到的错误,并允许我们更策略性地处理它们,这在仅仅能够访问日志时有时是困难的。
有时,意外错误会提供足够的信息来描述问题,这可能涉及到外部问题,如网络问题或数据库问题。解决方案可能位于服务本身之外。
日志策略
处理日志时常见的一个问题是确定每个单独服务的适当严重性。这条消息是WARNING还是ERROR?这个声明应该添加为INFO消息吗?
大多数日志严重性描述都有定义,例如程序显示一个可能有害的情况或应用程序突出显示请求的进度。这些是模糊的定义,在现实生活中的情况下很难采取行动。与其使用这些模糊的定义,不如尝试将每个级别与如果遇到问题应该采取的任何后续行动相关联来定义。这有助于向开发者阐明在发现给定的错误日志时应该做什么。例如:“我是否希望每次这种情况发生时都得到通知?”
下表显示了不同严重级别的示例以及可能采取的行动:
| 日志级别 | 应采取的操作 | 备注 |
|---|---|---|
DEBUG |
无。 | 不跟踪。仅在开发期间有用。 |
INFO |
无。 | INFO 日志显示有关应用程序中操作流程的通用信息,以帮助跟踪系统。 |
WARNING |
跟踪日志数量。当级别上升时发出警报。 | WARNING 日志跟踪可以自动修复的错误,如尝试连接外部服务或数据库中的可修复格式错误。突然增加可能需要调查。 |
ERROR |
跟踪日志数量。当级别上升时发出警报。审查所有错误。 | ERROR 日志跟踪无法恢复的错误。突然增加可能需要立即采取行动。所有这些错误都应定期审查,以修复常见问题并减轻它们,可能将它们提升到 WARNING 级别。 |
CRITICAL |
立即响应。 | CRITICAL 日志表明应用程序发生了灾难性故障。单个 CRITICAL 日志表示系统完全无法工作且无法恢复。 |
这明确了如何响应的期望。请注意,这是一个示例,你可能需要根据你特定组织的需要对其进行调整和修改。
不同严重程度的层次结构非常清晰,在我们的示例中,我们接受将产生一定数量的 ERROR 日志。为了开发团队的理智,不是所有问题都需要立即修复,但应强制执行一定的顺序和优先级。
在生产环境中,ERROR 日志通常会被分类从“我们完了”到“嗯”。开发团队应积极修复“嗯”日志或停止记录问题,以从监控工具中去除噪音。这可能包括降低日志级别,如果它们不值得检查的话。你希望尽可能少的 ERROR 日志,这样所有的日志都是有意义的。
记住,ERROR 日志将包括通常需要修复以完全解决或明确捕获并降低其严重性(如果它不重要)的意外错误。
随着应用程序的增长,这种后续工作无疑是一个挑战,因为 ERROR 日志的数量将显著增加。这需要投入时间进行主动维护。如果不认真对待,并且过于频繁地因其他任务而放弃,则会在中期损害应用程序的可靠性。
WARNING 日志表明某些事情可能没有像预期那样顺利运行,但情况仍在控制之中,除非此类日志的数量突然增加。INFO 日志仅在出现问题时提供上下文,否则可以忽略。
一个常见的错误是在存在错误输入参数的操作中生成ERROR日志,例如在 Web 请求中返回400 BAD REQUEST状态码时。一些开发者可能会争辩说,客户发送的格式不正确的请求是一个错误。但如果请求被正确检测并返回,开发团队就没有什么需要做的。这是正常的业务,唯一可能采取的行动可能是向请求者返回一个有意义的消息,以便他们可以修复他们的请求。
如果这种行为在某些关键请求中持续存在,例如反复发送错误的密码,可以创建一个WARNING日志。当应用程序按预期运行时,创建ERROR日志是没有意义的。
在 Web 应用程序中,一般来说,只有在状态码是 50X 变体之一(如 500、502 和 503)时才应创建ERROR日志。记住,40X 错误意味着发送者有问题,而 50X 意味着应用程序有问题,这是你团队的责任去修复。
在团队中采用常见和共享的日志级别定义,所有工程师都将对错误严重性有一个共同的理解,这将有助于形成改进代码的有意义行动。
允许时间对任何定义进行调整和微调。也可能需要处理在定义之前创建的日志,这可能需要工作。在遗留系统中,最大的挑战之一是创建一个适当的日志系统来分类问题,因为这些问题可能非常嘈杂,使得区分真正的问题、烦恼甚至非问题变得困难。
在开发过程中添加日志
任何测试运行器都会在运行测试时捕获日志,并将其作为跟踪的一部分显示出来。
我们在第十章,测试和 TDD中介绍的pytest将显示失败的测试结果中的日志。
这是一个检查在功能开发阶段是否生成预期日志的好机会,尤其是在它是作为 TDD 过程的一部分完成的情况下,在 TDD 过程中,失败的测试和错误作为过程的一部分常规产生,正如我们在第十章,测试和 TDD中看到的。任何检查错误的测试都应该添加相应的日志,并且在开发功能时检查它们是否被生成。
您可以使用像pytest-catchlog(pypi.org/project/pytest-catchlog/)这样的工具显式地向测试添加一个检查,以验证日志是否被生成。
通常,我们只需稍加注意,并在使用 TDD 实践作为初始检查的一部分时,将检查日志的做法纳入其中。然而,确保开发者理解为什么在开发过程中拥有日志是有用的,以便养成习惯。
在开发过程中,可以使用DEBUG日志来添加关于代码流程的额外信息,这些信息对于生产来说可能过多。在开发中,这些额外信息可以帮助填补INFO日志之间的差距,并帮助开发者养成添加日志的习惯。如果测试期间发现DEBUG日志在生产中跟踪问题很有用,则可以将DEBUG日志提升为INFO。
此外,在特殊情况下,可以在受控的情况下在生产环境中启用DEBUG日志来跟踪难以理解的问题。请注意,这将对生成的日志数量产生重大影响,可能导致存储问题。因此,请非常谨慎。
对于显示在INFO和更高严重性日志中的消息,要有理智。在显示的信息方面,应避免敏感数据,如密码、密钥、信用卡号码和个人信息。
在生产过程中,要注意任何大小的限制以及日志生成的速度。当新功能生成、请求数量增加或系统中工人的数量增加时,系统可能会经历日志爆炸。这三种情况可以在系统增长时出现。
总是检查日志是否被正确捕获并在不同的环境中可用是一个好主意。确保日志被正确捕获的所有配置可能需要一些时间,因此最好事先完成。这包括在生产环境中捕获意外错误和其他日志,并检查所有管道是否正确完成。另一种选择是在遇到真正的问题后才发现它没有正确工作。
日志限制
日志对于理解正在运行的系统中的情况非常有用,但它们有一些重要的局限性需要了解:
-
*日志的价值取决于其消息。一个好的、描述性的消息对于使日志有用至关重要。用批判性的眼光审查日志消息,并在需要时进行纠正,对于在生产问题上节省宝贵时间非常重要。
-
*应保持适当的日志数量。过多的日志可能会使流程混乱,而过少的日志可能不会包含足够的信息,使我们能够理解问题。大量的日志也会引起存储问题。
-
日志应作为问题上下文的指示,但很可能不会精确指出问题所在。试图生成能够完全解释错误的特定日志将是一项不可能的任务。相反,应专注于展示动作的一般流程和周围上下文,以便可以在本地复制并调试。例如,对于请求,确保记录请求及其参数,以便可以复制情况。
-
日志使我们能够跟踪单个实例的执行过程。当使用请求 ID 或类似方式分组时,日志可以根据执行进行分组,使我们能够跟踪请求或任务的流程。然而,日志并不直接显示汇总信息。日志回答的问题是“在这个任务中发生了什么?”,而不是“系统中正在发生什么?”对于这类信息,最好使用指标。
有可用的工具可以根据日志创建指标。我们将在第十三章指标中更多地讨论指标。
-
日志仅具有回顾性功能。当检测到任务中的问题时,日志只能显示事先准备好的信息。这就是为什么批判性地分析和精炼信息很重要,移除无用的日志,并添加包含相关上下文信息的其他日志,以帮助重现问题。
日志是一个出色的工具,但它们需要维护以确保它们可以用来检测错误和问题,并允许我们尽可能高效地采取行动。
摘要
在本章中,我们首先介绍了日志的基本元素。我们定义了日志包含消息以及一些元数据,如时间戳,并考虑了不同的严重级别。我们还描述了定义请求 ID 以分组与同一任务相关的日志的需求。此外,我们还讨论了在十二要素应用方法中,日志应发送到stdout,以便将日志生成与处理和路由到适当目的地的过程分离,从而允许收集系统中的所有日志。
然后,我们展示了如何使用标准的logging模块在 Python 中生成日志,描述了logger、handler和formatter的三个关键元素。接下来,我们展示了系统中可能产生的两种不同错误:预期的,理解为可以预见并得到处理的错误;和意外的,意味着那些我们没有预见并且超出我们控制范围的错误。然后我们探讨了这些错误的不同策略和案例。
我们描述了不同的严重性以及当检测到特定严重性的日志时,应采取哪些行动的策略,而不是根据“它们有多关键”来对日志进行分类,这最终会产生模糊的指南,并且不太有用。
我们讨论了几个习惯,通过在 TDD 工作流程中包含它们来提高日志的有用性。这允许开发者在编写测试和产生错误时考虑日志中呈现的信息,这为确保生成的日志正确工作提供了完美的机会。
最后,我们讨论了日志的限制以及我们如何处理它们。
在下一章中,我们将探讨如何通过使用指标来处理汇总信息,以找出系统的总体状态。
第十三章:指标
除了日志记录之外,可观察性的另一个关键元素是指标。指标允许你看到系统的总体状态,并观察由多个任务,甚至可能是许多任务同时执行而引起的大多数趋势和情况。
在本章中,我们将主要使用网络服务的示例,如请求指标。不要受它们的限制;你可以在各种服务中生成指标!
监控实时系统时,通常关注的是指标,因为它们能让你一眼看出是否一切看起来都在正常工作。通常,通过指标,你可以检测到系统是否在努力,例如,突然增加的请求数量,但也可以通过显示趋势来预见问题,比如请求数量的小幅但持续的上升。这让你能够主动行动,而无需等到问题变得严重。
生成一个良好的指标系统来监控系统的生命周期对于在问题出现时能够快速反应至关重要。指标还可以用作自动告警的基础,这有助于警告某些条件的发生,通常是需要调查或纠正的事情。
在本章中,我们将涵盖以下主题:
-
指标与日志的比较
-
使用 Prometheus 生成指标
-
查询 Prometheus
-
积极地与指标合作
-
告警
首先,我们将比较指标与其他主要可观察性工具,即日志。
指标与日志的比较
正如我们在上一章中看到的,日志是在代码执行时产生的文本消息。它们擅长提供对系统执行的每个特定任务的可见性,但它们生成的大量数据难以批量处理。相反,在任何给定时间,只有少量日志组能够被分析。
通常,分析的日志都将与单个任务相关。我们在上一章中看到了如何使用请求 ID 来做到这一点。但在某些情况下,可能需要检查特定时间窗口内发生的所有日志,以查看交叉效应,比如一个服务器的问题在特定时间影响了所有任务。
但有时重要的信息不是特定的请求,而是理解整个系统的行为。与昨天相比,系统的负载是否在增长?我们返回了多少错误?处理任务所需的时间是增加还是减少?
所有这些问题都无法通过日志回答,因为它们需要更广泛的视角,在更高的层面上。为了能够实现这一点,数据需要汇总以便理解整个系统。
需要存储在指标中的信息也有所不同。虽然每条记录的日志都是一条文本消息,但每个生成的指标都是一个数字。这些数字将随后进行统计分析以汇总信息。
我们将在本章后面讨论可以作为指标产生的不同类型的数字。
每个记录产生信息量的不同意味着与日志相比,指标要轻量得多。为了进一步减少存储的数据量,数据会自动进行聚合。
指标解析的分辨率可能取决于工具和设置配置。请记住,更高的分辨率将需要更多的资源来存储所有数据。典型的分辨率是一分钟,除非您有一个非常活跃的系统,通常每秒接收 10 个或更多任务,否则这个分辨率足够小,可以呈现详细的信息。
指标应捕获和分析与性能相关的信息,例如处理任务的平均时间。这允许您检测可能的瓶颈并迅速采取行动以改善系统的性能。这以聚合方式更容易做到,因为单个任务的信息,如生成的日志,可能不足以捕捉到整体情况。这一结果的重要之处在于能够在问题变得太大之前看到趋势并检测到问题,及早进行修复。相比之下,日志通常在事后使用,并且难以用作预防措施的方式。
指标类型
可以产生不同类型的指标。这取决于用于生成指标的特定工具,但一般来说,大多数系统中都有一些常见的指标,如下所示:
-
计数器:每当发生某事时都会生成一个触发器。这将作为总数进行计数和聚合;例如,在 Web 服务中,请求的数量或生成的错误数量。计数器有助于理解在系统中某个特定动作发生的次数。
-
仪表:系统中的单个数字。仪表数字可以上升或下降,但最后一个值会覆盖之前的值,因为它存储了系统的总体状态;例如,队列中的元素数量或系统中现有工作者的数量。
-
测量:与它们相关联的具有数值的事件。这些数字可以以某种方式平均、求和或聚合。与仪表相比,区别在于之前的测量仍然是独立的;例如,当我们以毫秒为单位发出请求时间和以字节为单位的请求大小的事件时。
测量值也可以作为计数器使用,因为每个发出的事件本质上就是一个计数器。例如,跟踪请求时间也会计算请求的数量,因为每个请求都会生成一次。工具通常为每个测量值自动创建相关的计数器。
定义哪个指标适合测量特定值非常重要。在大多数情况下,它们将是测量值,以便存储由事件产生的一个值。计数器通常是明显的(它们是没有值的测量值),而仪表通常是那些不那么明显且在使用时可能更具挑战性的指标。
指标也可以从其他指标中派生出来以生成新的指标。例如,我们可以通过将返回错误代码的请求数量除以总请求数量来生成错误百分比。这样的派生指标可以帮助你以有意义的方式理解信息。
根据指标的产生方式,也存在两种类型的指标系统:
-
每次生成指标时,都会将一个事件 推送 到指标收集器。
-
每个系统内部维护自己的指标,这些指标会定期从指标收集器中 拉取。
每个系统都有其自身的优缺点。推送事件会产生更高的流量和活动,因为每个单独的事件都会立即发送,这可能导致瓶颈和延迟。拉取事件将仅采样信息,并产生较低分辨率的 数据,因为它可能会错过样本之间的发生事件,但它更稳定,因为请求数量不会随着事件数量的增加而增加。
这两种方法都被使用,但当前的趋势是向拉取系统转变。它们减少了推送系统所需的维护量,并且更容易扩展。
我们将使用 Prometheus 的示例,这是一个使用拉取方法的指标系统。推送方法最常用的例子是 Graphite。
使用 Prometheus 生成指标
Prometheus 是一个流行的指标系统,它得到了良好的支持并且易于使用。我们将在本章中使用它作为示例,展示如何收集指标以及它如何与其他工具交互以显示指标。
正如我们之前看到的,Prometheus 使用 拉取 方法来生成指标。这意味着任何产生指标的系统都会运行自己的内部 Prometheus 客户端,以跟踪指标。
对于网络服务,这可以作为一个额外的端点来提供指标。这是 django-prometheus 模块采取的方法,该模块将自动收集许多常见的指标,用于 Django 网络服务。
我们将从 第六章,Web 服务器结构 中展示的 Django 应用程序代码开始构建,以展示一个工作应用程序。请检查 GitHub 上的代码 github.com/PacktPublishing/Python-Architecture-Patterns/tree/main/chapter_13_metrics/microposts。
准备环境
我们需要设置环境以确保安装代码所需的所有包和依赖项。
让我们从创建一个新的虚拟环境开始,如 第十一章,包管理 中介绍的那样,以确保创建我们自己的隔离沙盒来安装包:
$ python3 -m venv venv
$ source venv/bin/activate
我们现在可以安装存储在 requirements.txt 中的准备好的需求列表。这包括在 第六章,Web 服务器结构 中看到的 Django 和 Django REST 框架模块,以及 Prometheus 依赖项:
(venv) $ cat requirements.txt
django
django-rest-framework
django-prometheus
(venv) $ pip install -r requirements.txt
Collecting Django
Downloading Django-3.2.7-py3-none-any.whl (7.9 MB)
|████████████████████████████████| 7.9 MB 5.7 MB/s
...
Installing collected packages: djangorestframework, django-rest-framework
Running setup.py install for django-rest-framework ... done
Successfully installed django-rest-framework-0.1.0 djangorestframework-3.12.4
要启动服务器,请转到 micropost 子目录并运行 runserver 命令:
(venv) $ python3 manage.py runserver 0.0.0.0:8000
Watching for file changes with StatReloader
Performing system checks...
System check identified no issues (0 silenced).
October 01, 2021 - 23:24:26
Django version 3.2.7, using settings 'microposts.settings'
Starting development server at http://0.0.0.0:8000/
Quit the server with CONTROL-C.
应用程序现在可通过根地址访问:http://localhost:8000,例如,http://localhost:8000/api/users/jaime/collection。
注意,我们是在地址 0.0.0.0 上启动服务器的。这使 Django 能够服务任何 IP 地址,而不仅仅是来自 localhost 的请求。这是一个重要的细节,稍后会进行说明。
还要注意,根地址将返回 404 错误,因为没有在那里定义端点。
如果您还记得 第三章,数据建模,我们添加了一些初始数据,因此您可以访问 URL http://localhost:8000/api/users/jaime/collection 和 http://localhost:8000/api/users/dana/collection 来查看一些数据。

图 13.1:访问应用程序中的可用 URL
访问这些页面几次以生成我们可以稍后访问的指标。
配置 Django Prometheus
django-prometheus 模块的配置在 microposts/settings.py 文件中完成,我们需要做两件事。
首先,将 django-prometheus 应用程序添加到已安装的应用程序列表中,这将启用该模块:
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'django_prometheus',
'rest_framework',
'api',
]
我们还需要包含适当的中间件来跟踪请求。我们需要在请求处理过程的开始和结束时放置一个中间件,以确保能够捕获和测量整个过程:
MIDDLEWARE = [
'django_prometheus.middleware.PrometheusBeforeMiddleware',
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'django_prometheus.middleware.PrometheusAfterMiddleware',
]
检查 django.prometheus.middleware.PrometheusBeforeMiddleware 和 django_prometheus.middleware.PrometheusAfterMiddleware 的位置。
我们还将 ALLOWED_HOSTS 的值更改为 '*' 并允许来自任何主机名的请求。这个细节稍后会解释。
使用此配置,Prometheus 收集现在已启用。但我们还需要一种访问它们的方法。记住,Prometheus 系统的一个重要元素是每个应用程序都为其自己的指标收集提供服务。
在这种情况下,我们可以在 microposts/url.py 文件中添加一个端点,该文件处理系统的顶级 URL:
from django.contrib import admin
from django.urls import include, path
urlpatterns = [
path('', include('django_prometheus.urls')),
path('api/', include('api.urls')),
path('admin/', admin.site.urls),
]
path('', include('django_prometheus.urls')) 这一行设置了一个 /metrics URL,我们现在可以访问它。
检查指标
主要 URL 根显示了一个新的端点 - /metrics:

图 13.2:此页面出现是因为 DEBUG 模式处于活动状态。记住在部署到生产环境之前将其停用
当访问 /metrics 端点时,它显示了所有收集的指标。请注意,收集了大量的指标。这些都是文本格式,并且预期将由 Prometheus 指标服务器收集。
一定要多次访问端点 http://localhost:8000/api/users/jaime/collection 和 http://localhost:8000/api/users/dana/collection 以生成一些指标。您可以检查一些指标,如 django_http_requests_total_by_view_transport_method_total{method="GET",transport="http",view="user-collection"},是如何增加的。

图 13.3:由应用程序收集的原始 Prometheus 指标
下一步是启动一个 Prometheus 服务器,它可以拉取信息并显示。
启动 Prometheus 服务器
Prometheus 服务器将定期从所有配置的应用程序中拉取指标,这些应用程序正在收集它们的指标。Prometheus 将这些元素称为 目标。
启动 Prometheus 服务器最简单的方法是启动官方 Docker 镜像。
我们在 第九章,微服务与单体 中介绍了 Docker。请参阅该章节以获取更多信息。
我们需要启动服务器,但在那之前,我们需要在 prometheus.yml 文件中设置配置。你可以在 GitHub 上查看示例:github.com/PacktPublishing/Python-Architecture-Patterns/blob/main/chapter_13_metrics/prometheus.yml:
# my global config
global:
scrape_interval: 15s # Set the scrape interval to every 15 seconds. Default is every 1 minute.
# scrape_timeout is set to the global default (10s).
scrape_configs:
# The job name is added as a label `job=<job_name>` to any timeseries scraped from this config.
- job_name: "prometheus"
# metrics_path defaults to '/metrics'
# scheme defaults to 'http'.
static_configs:
# The target needs to point to your local IP address
# 192.168.1.196 IS AN EXAMPLE THAT WON'T WORK IN YOUR SYSTEM
- targets: ["192.168.1.196:8000"]
配置文件有两个主要部分。第一个部分是 global,它指示多久抓取一次(从目标读取信息)和其他一般配置值。
第二个,scrape_config,描述了要从哪里抓取,主要参数是 targets。在这里,我们需要配置所有我们的目标。特别是这个目标需要通过其外部 IP 地址来描述,这将是从你的电脑来的 IP 地址。
这个地址不能是 localhost,因为在 Prometheus Docker 容器内部,它将解析为相同的容器,这并不是你想要的。你需要找出你自己的本地 IP 地址。
如果你不知道如何通过 ipconfig 或 ifconfig 来查找它,你可以查看这篇文章了解查找方法:lifehacker.com/how-to-find-your-local-and-external-ip-address-5833108。请记住,这是你的 本地地址,而不是外部地址。
这是为了确保 Prometheus 服务器可以访问运行在本地的 Django 应用程序。正如你所记得的,我们在启动服务器时通过选项 0.0.0.0 允许任何主机名进行连接,并在配置参数 ALLOWED_HOSTS 中允许所有主机。
请确保你可以通过本地 IP 访问指标。

图 13.4:注意用于访问的 IP 地址;请记住你应该使用你自己的本地 IP 地址
在所有这些信息的基础上,你现在可以准备在 Docker 中启动 Prometheus 服务器,使用你自己的配置文件。
请注意,这个命令要求你找到 prometheus.yml 文件的完整路径。如果你在同一目录下,你可以将其表示为 $(pwd)/prometheus.yml。
为了做到这一点,运行以下 docker 命令,添加配置文件的完整路径以与新的容器共享:
$ docker run -p 9090:9090 -v /full/path/to/file/prometheus.yml:/etc/prometheus/prometheus.yml prom/prometheus
level=info ts=2021-10-02T15:24:17.228Z caller=main.go:400 msg="No time or size retention was set so using the default time retention" duration=15d
level=info ts=2021-10-02T15:24:17.228Z caller=main.go:438 msg="Starting Prometheus" version="(version=2.30.2, branch=HEAD, revision=b30db03f35651888e34ac101a06e25d27d15b476)"
...
level=info ts=2021-10-02T15:24:17.266Z caller=main.go:794 msg="Server is ready to receive web requests."
docker 命令的结构如下:
-
-p 9090:9090将本地 9090 端口映射到容器内的 9090 端口 -
-v /full/path/to/file/prometheus.yml:/etc/prometheus/prometheus.yml将本地文件(请记住添加完整路径或使用$(pwd)/prometheus.yml)挂载到 Prometheus 预期的配置路径 -
docker run prom/Prometheus是运行prom/Prometheus镜像的命令,这是官方的 Prometheus 镜像
当 Prometheus 服务器启动并运行后,服务器可通过 http://localhost:9090 访问。

图 13.5:Prometheus 空图页面
从这里,我们可以开始查询系统。
查询 Prometheus
Prometheus 有自己的查询系统,称为 PromQL,以及操作指标的方式,虽然功能强大,但一开始可能会有些令人困惑。其中一部分是其对指标的拉取方法。
例如,请求一个有用的指标,如 django_http_requests_latency_seconds_by_view_method_count,将显示每个方法被每个视图调用的次数。

图 13.6:注意 prometheus-django-metrics 视图被调用得更频繁,因为它被 Prometheus 每 15 秒自动调用一次以抓取结果
这以累积值的形式呈现,随着时间的推移而增长。这并不很有用,因为很难理解它确切的意义。
相反,值更有可能以 rate 的形式呈现,表示每秒检测到的请求数量。例如,以 1 分钟的分辨率,rate(django_http_requests_latency_seconds_by_view_method_count[1m]) 会显示以下图表:

图 13.7:注意不同的方法和视图以不同的线条显示
如您所见,从 prometheus-django-metrics 来的请求数量是恒定的,这是 Prometheus 请求指标信息。这每 15 秒发生一次,或大约每秒 0.066 次。
在图表中,还有 user-collection 方法在 15:55 发生的另一个峰值,这是我们手动生成对服务请求的时间。如您所见,分辨率是每分钟,正如速率所描述的。
如果我们想要将这些内容聚合到单个图中,我们可以使用求和运算符,指定我们要聚合的内容。例如,要计算所有 GET 请求,可以使用以下命令:
sum(rate(django_http_requests_latency_seconds_by_view_method_count[1m])) by (method)
这会产生另一个图表:

图 13.8:注意底部值是基于 prometheus-django-metrics 调用创建的基线
要绘制时间而不是分位数,应使用django_http_requests_latency_seconds_by_view_method_bucket指标。桶指标以可以与histogram_quantile函数结合的方式生成,以显示特定的分位数,这对于给出适当的时间感觉非常有用。
例如,0.95 分位数意味着时间是 95%请求中的最高值。这比创建平均值更有用,因为它们可能会被高数值所扭曲。相反,你可以绘制 0.50 分位数(一半请求的最大时间),0.90 分位数(大多数请求的最大时间),以及 0.99 分位数,用于返回请求所需的最长时间。这让你能获得更好的视图,因为它与增长中的 0.50 分位数(大多数请求返回时间更长)和增长中的 0.99 分位数(一些慢查询变得更糟)的情况不同。
要绘制 5 分钟内的 0.95 分位数,可以使用以下查询:
histogram_quantile(0.95, rate(django_http_requests_latency_seconds_by_view_method_bucket[5m]))
当你运行它时,你应该收到以下内容:

图 13.9:注意指标收集的速度比用户收集请求的速度快得多
要绘制时间而不是分位数,应使用django_http_requests_latency_seconds_by_view_method_bucket指标。桶指标以可以与histogram_quantile函数结合的方式生成,以显示特定的分位数,这对于给出适当的时间感觉非常有用。
指标也可以被过滤以仅显示特定的标签,并且有许多乘法、除法、加法、创建平均值以及各种操作的功能。
当尝试显示多个指标的结果,例如成功请求占总请求的百分比时,Prometheus 查询可能会变得相当长且复杂。请确保测试结果是否符合预期,并留出时间稍后调整查询以持续改进。
界面具有自动完成功能,可以帮助你找到特定的指标。
Prometheus 通常与 Grafana 搭配使用。Grafana 是一个开源的交互式可视化工具,可以与 Prometheus 连接以创建丰富的仪表板。这利用了指标收集,并有助于以更易于理解的方式可视化系统的状态。本书不涉及如何使用 Grafana 的描述,但强烈建议使用它来显示指标:grafana.com/.
检查 Prometheus 文档中的查询部分以获取更多信息:prometheus.io/docs/prometheus/latest/querying/basics/.
积极使用指标
正如我们所见,指标显示了整个集群状态的汇总视图。它们允许你检测趋势问题,但很难确定单个错误。
这不应该阻止我们将它们视为成功监控的关键工具,因为它们可以告诉我们整个系统是否健康。在一些公司中,最重要的指标会永久显示在屏幕上,以便运维团队能够看到它们,并快速对任何突发问题做出反应。
找到服务关键指标的正确平衡并不像看起来那么简单,这需要时间和经验,甚至可能需要试错。
然而,对于在线服务,有四个指标被认为是始终重要的。它们是:
-
延迟:系统响应请求所需的毫秒数。根据服务不同,有时也可以使用秒。根据我的经验,毫秒通常是合适的时间尺度,因为大多数 Web 应用在 50 毫秒到 1 秒内响应,具体取决于请求。通常,超过 1 秒的请求较为罕见,尽管系统中总是有一些,这取决于系统本身。
-
流量:单位时间内通过系统的请求数量,例如,每分钟的请求数量。
-
错误:收到并返回错误的请求数量百分比。
-
饱和度:描述集群的容量是否有足够的余量。这包括可用硬盘空间、内存等元素。例如,系统中可用 RAM 为 15%。
检查饱和度的主要工具是可用的多个默认导出器,可以自动收集大多数硬件信息,如内存、CPU 和硬盘空间。当使用云服务提供商时,通常他们会公开自己的相关指标集,例如 AWS 中的 CloudWatch。
这些指标可以在 Google SRE 书中找到,被称为四个黄金信号,并被认为是成功监控最重要的高级元素。
警报
当通过指标检测到问题时,应触发自动警报。Prometheus 有一个警报系统,当定义的指标满足定义的警报条件时,会触发警报。
查阅 Prometheus 关于警报的文档以获取更多信息:prometheus.io/docs/alerting/latest/overview/。
通常情况下,当指标值超过某个阈值时,会配置警报。例如,错误数量高于 X,或者返回请求的时间太长。
警报也可能是一些元素太低;例如,如果系统中的请求数量降至零,这可能表明系统已关闭。
内置的 Alertmanager 可以通过发送电子邮件等方式发出警报,但它也可以连接到其他工具以执行更复杂的行为。例如,连接到一个集成的故障解决方案,如 Opsgenie (www.atlassian.com/software/opsgenie),允许您创建警报流程,例如发送电子邮件和短信、电话。
虽然可以直接从指标生成警报,但也有一些工具允许您直接从日志生成警报。例如,Sentry (sentry.io/) 将根据日志汇总错误,并可以设置阈值以升级到更活跃的警报,如发送电子邮件。另一种选择是使用外部日志系统从日志中派生指标。这允许您,例如,根据 ERROR 日志的数量创建一个计数器,或者更复杂的指标。这些系统再次允许您根据这些派生指标触发警报。
警报,与指标一样,是一个持续的过程。一些关键阈值在系统开始时可能不明显,只有经验才能让您发现它们。同样,很可能创建了一些不需要主动监控的警报,应该断开连接以确保系统中的警报准确且信号与噪声比高。
摘要
在本章中,我们描述了指标是什么以及它们与日志的比较。我们描述了指标如何有助于分析系统的总体状态,而日志则描述具体任务,更难描述汇总情况。
我们列举了可以生成和描述的指标类型,并介绍了 Prometheus,这是一个使用拉取方法捕获指标的通用指标系统。
我们通过安装和配置 django-prometheus 模块,以及如何启动一个抓取生成的指标的 Prometheus 服务器,设置了一个如何自动生成指标的 Django 示例。
请记住,您还可以生成自己的自定义指标,而不仅仅依赖于外部模块中的那些。例如,查看 Prometheus 客户端了解如何操作,以 Python 为例:github.com/prometheus/client_python。
接下来,我们描述了如何在 Prometheus 中查询指标,介绍了 PromQL,并展示了如何显示指标、绘制 rate 图以清楚地看到指标随时间的变化,以及如何使用 histogram_quantile 函数处理时间。
我们还在本章中展示了如何积极工作以尽快检测常见问题,以及 Google 描述的四个黄金信号是什么。最后,我们介绍了警报作为一种在指标超出正常范围时通知的方式。使用警报是一种智能的通知方式,无需手动查看指标。
第十四章:分析
在使用真实数据测试后,编写的代码可能并不完美。除了错误之外,我们还可以发现代码的性能不足的问题。可能某些请求花费了太多时间,或者可能是内存使用过高。
在那些情况下,很难确切知道哪些关键元素花费了最多时间或内存。虽然尝试跟踪逻辑是可能的,但通常一旦代码发布,瓶颈将出现在几乎无法事先知晓的点。
为了了解确切发生的情况并跟踪代码流,我们可以使用分析器来动态分析代码,更好地理解代码的执行情况,特别是大多数时间花在了哪里。这可能导致对代码最关键部分的调整和改进,这些改进是由数据驱动的,而不是模糊的推测。
在本章中,我们将涵盖以下主题:
-
分析基础
-
分析器的类型
-
对代码进行时间分析
-
部分分析
-
内存分析
首先,我们将探讨分析的基本原则。
分析基础
分析是一种动态分析,它通过向代码添加工具来理解其运行情况。这些信息被提取并编译成可以用于基于实际案例更好地了解特定行为的方式,因为代码是正常运行的。这些信息可以用来改进代码。
与动态分析相比,某些静态分析工具可以提供对代码某些方面的洞察。例如,它们可以用来检测某些代码是否是死代码,这意味着它在整个代码中没有被调用过。或者,它们可以检测一些错误,比如使用未定义的变量,比如在打字错误时。但它们不适用于实际运行的代码的特定细节。分析将基于被仪器化的用例提供具体数据,并将返回关于代码流的更多信息。
常规的代码分析应用是为了提升被分析代码的性能。通过理解其在实际中的执行情况,可以揭示代码模块和可能引起问题的部分的动态。然后,可以在这些特定区域采取行动。
性能可以从两个方面来理解:要么是时间性能(代码执行所需的时间)要么是内存性能(代码执行所需的内存)。两者都可能成为瓶颈。某些代码可能执行时间过长或使用大量内存,这可能会限制其运行的硬件。
在本章中,我们将更关注时间性能,因为它通常是一个更大的问题,但我们也会解释如何使用内存分析器。
在软件开发中,一个常见的案例是,直到代码执行之前,你并不真正知道你的代码将要做什么。覆盖那些出现频率较低的边缘情况的条款可能会执行比预期更多的操作,而且当存在大数组时,软件的工作方式也会不同,因为某些算法可能不够充分。
问题在于在系统运行之前进行这种分析非常困难,而且在大多数时候,是徒劳的,因为问题代码很可能完全出乎意料。
程序员会浪费大量的时间思考或担心他们程序中非关键部分的运行速度,而这些试图提高效率的努力在考虑调试和维护时实际上会产生强烈的负面影响。我们应该忘记小的效率,比如说 97%的时间:过早优化是万恶之源。然而,我们不应该错过那关键的 3%的机会。
邓肯·克努特 – 使用 GOTO 语句进行结构化编程 - 1974 年。
分析提供了我们理想的工具来不过早优化,而是根据真实、具体的数据进行优化。其理念是你不能优化你不能衡量的东西。分析器测量,以便可以采取行动。
上面的著名引言有时被简化为“过早优化是万恶之源”,这有点过于简化,没有体现出细微差别。有时,精心设计元素很重要,并且可以提前规划。尽管分析(或其他技术)可能很好,但它们只能走这么远。但重要的是要理解,在大多数情况下,采取简单的方法更好,因为性能已经足够好,而且可以在少数情况下进行改进。
分析可以通过不同的方式实现,每种方式都有其优缺点。
分析器的类型
主要有两种时间分析器:
-
确定性分析器,通过跟踪过程。确定性分析器对代码进行仪器化并记录每个单独的命令。这使得确定性分析器非常详细,因为它们可以跟踪每一步的代码,但与此同时,代码的执行速度比没有仪器化时要慢。
-
确定性分析器不适合持续执行。相反,它们可以在特定情况下被激活,比如在运行特定的离线测试时,以找出问题。
-
统计配置文件,通过采样。这种类型的分析器,而不是通过代码的仪器化和检测每个操作,而是在特定间隔内唤醒并获取当前代码执行堆栈的样本。如果这个过程持续足够长的时间,它将捕获程序的总体执行情况。
对堆栈进行采样类似于拍照。想象一下火车站或地铁站,人们正在穿越以从一个站台到另一个站台。采样类似于每隔一定时间拍照,例如每 5 分钟一次。当然,不可能精确地知道谁从哪个站台来,到哪个站台去,但经过一整天,它将提供足够的信息,了解有多少人经过以及哪些站台最受欢迎。
虽然它们提供的信息不如确定性分析器详细,但统计分析器更加轻量级,并且不消耗许多资源。它们可以启用以持续监控实时系统,而不会干扰其性能。
统计分析器仅在相对负载的系统上才有意义,因为在系统未受压力的情况下,它们会显示大部分时间都在等待。
如果采样是在解释器上直接进行的,统计分析器可以是内部的;如果是一个不同的程序在采样,它甚至可以是外部的。外部分析器的优势在于,即使采样过程中有任何问题,也不会干扰被采样的程序。
这两种分析器可以看作是互补的。统计分析器是理解代码中最常访问部分以及系统总体上花费时间的好工具。它们存在于实时系统中,其中实际的案例使用决定了系统的行为。
确定性分析器是分析开发者笔记本电脑中 Petri Dish 中特定用例的工具,其中某个有问题的特定任务可以被仔细分解和分析,以进行改进。
在某些方面,统计分析器类似于指标,而确定性分析器类似于日志。一个显示聚合元素,另一个显示特定元素。与日志不同,确定性分析器并不是在实时系统中使用时的理想工具,尽管如此。
通常,代码会呈现热点,即经常执行的慢速部分。找到需要关注的特定部分并对其采取行动是提高整体速度的好方法。
这些热点可以通过分析来揭示,无论是通过使用统计分析器检查全局热点,还是使用确定性分析器针对特定任务检查特定热点。前者将显示代码中最常使用的特定部分,这使我们能够了解哪些部分被频繁调用并且总体上花费了最多的时间。确定性分析器可以显示对于特定任务,每一行代码需要多长时间,并确定哪些是慢速元素。
我们不会查看统计分析器,因为它们需要负载下的系统,并且很难在适合本书范围的测试中创建。您可以检查py-spy(pypi.org/project/py-spy/)或pyinstrument(pypi.org/project/pyinstrument/)。
另一种分析器是内存分析器。内存分析器记录内存增加和减少的时间,跟踪内存的使用情况。通常使用内存分析来找出内存泄漏,尽管在 Python 程序中这种情况很少见,但它们确实可能发生。
Python 有一个垃圾收集器,当对象不再被引用时,它会自动释放内存。这不需要采取任何行动,因此与像 C/C++这样的手动内存分配程序相比,内存管理更容易处理。Python 使用的垃圾收集机制称为引用计数,一旦内存对象不再被任何人使用,它就会立即释放内存,与其他类型的垃圾收集器相比,后者会等待。
在 Python 的情况下,内存泄漏可以通过以下三种主要使用场景来创建,从更可能到最少:
-
一些对象即使不再使用,仍然被引用。这通常发生在存在长期存在的对象,它们将小元素保存在大元素中时,例如,当列表或字典被添加而不是删除时。
-
内部 C 扩展没有正确管理内存。这可能需要使用特定的 C 分析工具进行进一步调查,但这本书的范围不包括这一点。
-
复杂的引用循环。引用循环是一组相互引用的对象,例如,对象 A 引用 B,对象 B 引用 A。虽然 Python 有算法可以检测它们并释放内存,但垃圾收集器被禁用或存在其他错误的可能性很小。您可以在 Python 垃圾收集器这里了解更多信息:
docs.python.org/3/library/gc.html。
最可能额外使用内存的情况是使用大量内存的算法,而检测内存分配的时间可以通过内存分析器来实现。
内存分析通常比时间分析更复杂,需要更多的努力。
让我们引入一些代码并对其进行分析。
分析代码以计时
我们将首先创建一个简短的程序,该程序将计算并显示所有小于特定数字的素数。素数是只能被自己和 1 整除的数。
我们首先尝试一种简单的方法:
def check_if_prime(number):
result = True
for i in range(2, number):
if number % i == 0:
result = False
return result
此代码将取从 2 到待测试数字(不包括它本身)的每个数字,并检查该数字是否可被整除。如果在任何点上它可被整除,则该数字不是素数。
为了从 1 到 5,000 进行计算,以验证我们没有犯任何错误,我们将包括小于 100 的第一个素数,并进行比较。这是在 GitHub 上,作为 primes_1.py 可用,github.com/PacktPublishing/Python-Architecture-Patterns/blob/main/chapter_14_profiling/primes_1.py。
PRIMES = [1, 2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53,
59, 61, 67, 71, 73, 79, 83, 89, 97]
NUM_PRIMES_UP_TO = 5000
def check_if_prime(number):
result = True
for i in range(2, number):
if number % i == 0:
result = False
return result
if __name__ == '__main__':
# Calculate primes from 1 to NUM_PRIMES_UP_TO
primes = [number for number in range(1, NUM_PRIMES_UP_TO)
if check_if_prime(number)]
# Compare the first primers to verify the process is correct
assert primes[:len(PRIMES)] == PRIMES
print('Primes')
print('------')
for prime in primes:
print(prime)
print('------')
素数的计算是通过创建一个包含所有数字(从 1 到 NUM_PRIMES_UP_TO)的列表,并验证每个数字来完成的。只有返回 True 的值才会被保留:
# Calculate primes from 1 to NUM_PRIMES_UP_TO
primes = [number for number in range(1, NUM_PRIMES_UP_TO)
if check_if_prime(number)]
下一个 assert 行断言第一个素数与在 PRIMES 列表中定义的相同,这是一个硬编码的包含小于 100 的第一个素数的列表。
assert primes[:len(PRIMES)] == PRIMES
最后打印出素数。让我们执行程序,计时其执行时间:
$ time python3 primes_1.py
Primes
------
1
2
3
5
7
11
13
17
19
…
4969
4973
4987
4993
4999
------
Real 0m0.875s
User 0m0.751s
sys 0m0.035s
从这里,我们将开始分析代码,看看内部发生了什么,并看看我们是否可以改进它。
使用内置的 cProfile 模块
分析模块最简单、最快的方法是直接使用 Python 中包含的 cProfile 模块。此模块是标准库的一部分,可以作为外部调用的一部分进行调用,如下所示:
$ time python3 -m cProfile primes_1.py
Primes
------
1
2
3
5
...
4993
4999
------
5677 function calls in 0.760 seconds
Ordered by: standard name
ncalls tottime percall cumtime percall filename:lineno(function)
1 0.002 0.002 0.757 0.757 primes_1.py:19(<listcomp>)
1 0.000 0.000 0.760 0.760 primes_1.py:2(<module>)
4999 0.754 0.000 0.754 0.000 primes_1.py:7(check_if_prime)
1 0.000 0.000 0.760 0.760 {built-in method builtins.exec}
1 0.000 0.000 0.000 0.000 {built-in method builtins.len}
673 0.004 0.000 0.004 0.000 {built-in method builtins.print}
1 0.000 0.000 0.000 0.000 {method 'disable' of '_lsprof.Profiler' objects}
Real 0m0.895s
User 0m0.764s
sys 0m0.032s
注意,这里正常调用脚本,但也展示了分析结果。表格显示:
-
ncalls:每个元素被调用的次数 -
tottime:每个元素上花费的总时间,不包括子调用 -
percall:每个元素上的调用时间(不包括子调用) -
cumtime:累计时间 – 每个元素上花费的总时间,包括子调用 -
percall:元素上的调用时间,包括子调用 -
filename:lineno:分析下的每个元素
在这种情况下,时间明显花在了 check_if_prime 函数上,该函数被调用了 4,999 次,占用了几乎全部的时间(744 毫秒,与总共 762 毫秒相比)。
由于这是一个小脚本,这里不容易看到,但 cProfile 会增加代码的执行时间。有一个名为 profile 的等效模块,它是直接替换的,但用纯 Python 实现,而不是 C 扩展。请一般使用 cProfile,因为它更快,但 profile 在某些时刻可能很有用,比如尝试扩展功能时。
虽然这个文本表格对于像这样的简单脚本已经足够,但输出可以作为一个文件呈现,然后使用其他工具显示:
$ time python3 -m cProfile -o primes1.prof primes_1.py
$ ls primes1.prof
primes1.prof
现在我们需要安装可视化工具 SnakeViz,通过 pip 安装:
$ pip3 install snakeviz
最后,使用 snakeviz 打开文件,它将打开一个浏览器并显示信息:
$ snakeviz primes1.prof
snakeviz web server started on 127.0.0.1:8080; enter Ctrl-C to exit
http://127.0.0.1:8080/snakeviz/%2FUsers%2Fjaime%2FDropbox%2FPackt%2Farchitecture_book%2Fchapter_13_profiling%2Fprimes1.prof

图 14.1:分析信息的图形表示。整个页面太大,无法全部显示,因此有意裁剪以展示部分信息。
此图是交互式的,我们可以点击并悬停在不同的元素上以获取更多信息:

图 14.2:检查check_if_prime的信息。整页内容太大,无法在这里显示,因此有意裁剪以显示一些信息。
我们可以确认大部分时间都花在了check_if_prime上,但我们没有得到关于其内部的信息。
这是因为cProfile只有函数粒度。你会看到每个函数调用花费了多长时间,但没有更低的分辨率。对于这个特别简单的函数,这可能不够。
不要低估这个工具。提供的代码示例故意很简单,以避免花费太多时间解释其使用方法。大多数情况下,定位占用最多时间的函数就足够了,以便进行视觉检查并发现哪些操作耗时过长。请记住,在大多数实际情况下,耗时将花在外部调用上,如数据库访问、远程请求等。
我们将看到如何使用具有更高分辨率的分析器,分析每一行代码。
行分析器
要分析check_if_prime函数,我们首先需要安装模块line_profiler。
$ pip3 install line_profiler
安装完成后,我们将在代码中进行一些小的更改,并将其保存为primes_2.py。我们将为check_if_prime函数添加装饰器@profile,以指示行分析器查看它。
请记住,您应该只分析您想了解更多信息的代码部分。如果所有代码都以这种方式进行分析,分析将花费很多时间。
代码将如下所示(其余部分不受影响)。您可以在 GitHub 上查看整个文件github.com/PacktPublishing/Python-Architecture-Patterns/blob/main/chapter_14_profiling/primes_2.py。
@profile
def check_if_prime(number):
result = True
for i in range(2, number):
if number % i == 0:
result = False
return result
现在使用kernprof执行代码,该工具将在安装line_profiler后安装。
$ time kernprof -l primes_2.py
Primes
------
1
2
3
5
…
4987
4993
4999
------
Wrote profile results to primes_2.py.lprof
Real 0m12.139s
User 0m11.999s
sys 0m0.098s
注意到执行时间明显更长——启用分析器后为 12 秒,而没有启用分析器时为亚秒级执行。现在我们可以使用以下命令查看结果:
$ python3 -m line_profiler primes_2.py.lprof
Timer unit: 1e-06 s
Total time: 6.91213 s
File: primes_2.py
Function: check_if_prime at line 7
Line # Hits Time Per Hit % Time Line Contents
==============================================================
7 @profile
8 def check_if_prime(number):
9 4999 1504.0 0.3 0.0 result = True
10
11 12492502 3151770.0 0.3 45.6 for i in range(2, number):
12 12487503 3749127.0 0.3 54.2 if number % i == 0:
13 33359 8302.0 0.2 0.1 result = False
14
15 4999 1428.0 0.3 0.0 return result
在这里,我们可以开始分析所使用的算法的具体细节。主要问题似乎是我们做了很多比较。第 11 行和第 12 行都被调用得太多次,尽管每次调用的时间很短。我们需要找到一种方法来减少它们被调用的次数。
第一个很简单。一旦找到False结果,我们就不需要再等待了;我们可以直接返回,而不是继续循环。代码将如下所示(存储在primes_3.py中,可在github.com/PacktPublishing/Python-Architecture-Patterns/blob/main/chapter_14_profiling/primes_3.py找到):
@profile
def check_if_prime(number):
for i in range(2, number):
if number % i == 0:
return False
return True
Let's take a look at the profiler result.
$ time kernprof -l primes_3.py
...
Real 0m2.117s
User 0m1.713s
sys 0m0.116s
$ python3 -m line_profiler primes_3.py.lprof
Timer unit: 1e-06 s
Total time: 0.863039 s
File: primes_3.py
Function: check_if_prime at line 7
Line # Hits Time Per Hit % Time Line Contents
==============================================================
7 @profile
8 def check_if_prime(number):
9
10 1564538 388011.0 0.2 45.0 for i in range(2, number):
11 1563868 473788.0 0.3 54.9 if number % i == 0:
12 4329 1078.0 0.2 0.1 return False
13
14 670 162.0 0.2 0.0 return True
我们看到时间已经大幅减少(与之前用time测量的 12 秒相比,减少了 2 秒),我们看到比较所花费的时间大幅减少(之前是 3,749,127 微秒,然后是 473,788 微秒),这主要是因为比较次数减少了 10 倍,从 1,563,868 次减少到 12,487,503 次。
我们还可以通过限制循环的大小来进一步减少比较次数。
目前,循环将尝试将源数字除以所有小于或等于自身的数字。例如,对于 19,我们尝试这些数字(因为 19 是一个质数,它只能被自身整除)。
Divide 19 between
[2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19]
尝试所有这些数字并不是必要的。至少,我们可以跳过其中的一半,因为没有任何数字能被大于它一半的数字整除。例如,19 除以 10 或更高的数字小于 2。
Divide 19 between
[2, 3, 4, 5, 6, 7, 8, 9, 10]
此外,一个数字的任何因子都将小于它的平方根。这可以这样解释:如果一个数字是两个或更多数字的因子,它们可能的最大值是整个数字的平方根。所以我们只检查到平方根(向下取整)的数字:
Divide 19 between
[2, 3, 4]
但我们可以进一步减少它。我们只需要检查 2 之后的奇数,因为任何偶数都能被 2 整除。所以,在这种情况下,我们甚至进一步减少了它。
Divide 19 between
[2, 3]
为了应用所有这些,我们需要再次调整代码,并将其存储在primes_4.py中,可在 GitHub 上找到:github.com/PacktPublishing/Python-Architecture-Patterns/blob/main/chapter_14_profiling/primes_4.py:
def check_if_prime(number):
if number % 2 == 0 and number != 2:
return False
for i in range(3, math.floor(math.sqrt(number)) + 1, 2):
if number % i == 0:
return False
return True
代码总是检查除以 2 的余数,除非数字是 2。这是为了正确地返回 2 作为质数。
然后,我们创建一个从 3 开始的数字范围(因为我们已经测试了 2),直到该数字的平方根。我们使用math模块来执行操作并将数字向下取整到最接近的整数。range函数需要这个数字的+1,因为它不包括定义的数字。最后,范围步长为 2 个整数,这样所有的数字都是奇数,因为我们从 3 开始。
例如,为了测试像 1,000 这样的数字,这是等效的代码。
>>> import math
>>> math.sqrt(1000)
31.622776601683793
>>> math.floor(math.sqrt(1000))
31
>>> list(range(3, 31 + 1, 2))
[3, 5, 7, 9, 11, 13, 15, 17, 19, 21, 23, 25, 27, 29, 31]
注意,由于我们添加了+1,31 被返回。
让我们再次分析代码。
$ time kernprof -l primes_4.py
Primes
------
1
2
3
5
…
4973
4987
4993
4999
------
Wrote profile results to primes_4.py.lprof
Real 0m0.477s
User 0m0.353s
sys 0m0.094s
我们看到性能有了显著提升。让我们看看线形图。
$ python3 -m line_profiler primes_4.py.lprof
Timer unit: 1e-06 s
Total time: 0.018276 s
File: primes_4.py
Function: check_if_prime at line 8
Line # Hits Time Per Hit % Time Line Contents
==============================================================
8 @profile
9 def check_if_prime(number):
10
11 4999 1924.0 0.4 10.5 if number % 2 == 0 and number != 2:
12 2498 654.0 0.3 3.6 return False
13
14 22228 7558.0 0.3 41.4 for i in range(3, math.floor(math.sqrt(number)) + 1, 2):
15 21558 7476.0 0.3 40.9 if number % i == 0:
16 1831 506.0 0.3 2.8 return False
17
18 670 158.0 0.2 0.9 return True
我们将循环迭代次数大幅减少到 22,228 次,从primes_3.py中的 150 万次和primes_2.py中的超过 1200 万次,当我们开始线形分析时。这是一项重大的改进!
你可以尝试在primes_2.py和primes_4.py中增加NUM_PRIMES_UP_TO的值,并比较它们。变化将非常明显。
线形方法应该只用于小部分。一般来说,我们已经看到cProfile可以更有用,因为它更容易运行,并且提供信息。
前面的章节假设我们能够运行整个脚本并接收结果,但这可能并不正确。让我们看看如何在程序的各个部分进行分析,例如,当接收到请求时。
部分分析
在许多场景中,分析器在系统运行的环境中非常有用,我们不能等待整个过程完成后再获取分析信息。典型的场景是网络请求。
如果我们想要分析特定的网络请求,我们可能需要启动一个网络服务器,生成一个单独的请求,然后停止过程以获取结果。由于我们将看到的一些问题,这并不像您想象的那么有效。
但首先,让我们编写一些代码来解释这种情况。
示例网络服务器返回质数
我们将使用函数 check_if_prime 的最终版本,并创建一个返回请求路径中指定数字的所有质数的网络服务。代码如下,并且可以在 GitHub 上的 server.py 文件中完全找到,网址为 github.com/PacktPublishing/Python-Architecture-Patterns/blob/main/chapter_14_profiling/server.py。
from http.server import BaseHTTPRequestHandler, HTTPServer
import math
def check_if_prime(number):
if number % 2 == 0 and number != 2:
return False
for i in range(3, math.floor(math.sqrt(number)) + 1, 2):
if number % i == 0:
return False
return True
def prime_numbers_up_to(up_to):
primes = [number for number in range(1, up_to + 1)
if check_if_prime(number)]
return primes
def extract_param(path):
'''
Extract the parameter and transform into
a positive integer. If the parameter is
not valid, return None
'''
raw_param = path.replace('/', '')
# Try to convert in number
try:
param = int(raw_param)
except ValueError:
return None
# Check that it's positive
if param < 0:
return None
return param
def get_result(path):
param = extract_param(path)
if param is None:
return 'Invalid parameter, please add an integer'
return prime_numbers_up_to(param)
class MyServer(BaseHTTPRequestHandler):
def do_GET(self):
result = get_result(self.path)
self.send_response(200)
self.send_header("Content-type", "text/html")
self.end_headers()
return_template = '''
<html>
<head><title>Example</title></head>
<body>
<p>Add a positive integer number in the path to display
all primes up to that number</p>
<p>Result {result}</p>
</body>
</html>
'''
body = bytes(return_template.format(result=result), 'utf-8')
self.wfile.write(body)
if __name__ == '__main__':
HOST = 'localhost'
PORT = 8000
web_server = HTTPServer((HOST, PORT), MyServer)
print(f'Server available at http://{HOST}:{PORT}')
print('Use CTR+C to stop it')
# Capture gracefully the end of the server by KeyboardInterrupt
try:
web_server.serve_forever()
except KeyboardInterrupt:
pass
web_server.server_close()
print("Server stopped.")
如果从结尾开始,代码更容易理解。最后的代码块使用 Python 模块 http.server 中的基本 HTTPServer 定义创建了一个网络服务器。之前,我们创建了 MyServer 类,该类在 do_GET 方法中定义了如果发生 GET 请求应该做什么。
do_GET 方法返回一个包含由 get_result 计算的结果的 HTML 响应。它添加所有必要的头信息,并以 HTML 格式格式化正文。
过程中有趣的部分发生在接下来的函数中。
get_result 是根函数。它首先调用 extract_param 来获取一个数字,这是我们用来计算质数的阈值。如果正确,则将其传递给 prime_numbers_up_to。
def get_result(path):
param = extract_param(path)
if param is None:
return 'Invalid parameter, please add an integer'
return prime_numbers_up_to(param)
函数 extract_params 会从 URL 路径中提取一个数字。它首先移除任何 / 字符,然后尝试将其转换为整数并检查该整数是否为正。对于任何错误,它返回 None。
def extract_param(path):
'''
Extract the parameter and transform into
a positive integer. If the parameter is
not valid, return None
'''
raw_param = path.replace('/', '')
# Try to convert in number
try:
param = int(raw_param)
except ValueError:
return None
# Check that it's positive
if param < 0:
return None
return param
函数 prime_numbers_up_to 最终计算的是传入数字的所有质数。这与我们在本章前面看到的代码类似。
def prime_numbers_up_to(up_to):
primes = [number for number in range(1, up_to + 1)
if check_if_prime(number)]
return primes
最后,我们在本章前面广泛讨论的 check_if_prime 函数与 primes_4.py 中的相同。
可以通过以下方式启动此过程:
$ python3 server.py
Server available at http://localhost:8000
Use CTR+C to stop it
然后通过访问 http://localhost:8000/500 来测试,尝试获取 500 以内的所有质数。

图 14.3:显示所有 500 以内质数的界面
如您所见,我们得到了一个可理解的输出。让我们继续分析我们用来获取它的过程。
分析整个过程
我们可以通过在cProfile下启动整个过程来分析整个流程,然后捕获其输出。我们这样启动,向http://localhost:8000/500发送单个请求,并检查结果。
$ python3 -m cProfile -o server.prof server.py
Server available at http://localhost:8000
Use CTR+C to stop it
127.0.0.1 - - [10/Oct/2021 14:05:34] "GET /500 HTTP/1.1" 200 -
127.0.0.1 - - [10/Oct/2021 14:05:34] "GET /favicon.ico HTTP/1.1" 200 -
^CServer stopped.
我们已经将结果存储在文件server.prof中。然后可以使用snakeviz像以前一样分析此文件。
$ snakeviz server.prof
snakeviz web server started on 127.0.0.1:8080; enter Ctrl-C to exit
这显示了以下图示:

图 14.4:完整配置文件的图示。整个页面太大,无法在这里显示,因此有意裁剪以显示一些信息。
如您所见,该图显示在测试的大部分时间内,代码正在等待新的请求,并在内部执行轮询操作。这是服务器代码,而不是我们的代码。
要找到我们关心的代码,我们可以在下面的长列表中手动搜索get_result,这是我们代码中有趣部分的根源。务必选择Cutoff: None以显示所有函数。
选择后,图示将从那里开始显示。务必向上滚动以查看新的图示。

图 14.5:从get_result开始的图示。整个页面太大,无法在这里显示,因此有意裁剪以显示一些信息。
在这里,您可以看到代码执行的更多一般结构。您可以看到,大部分时间都花在了多次check_if_prime调用上,这些调用构成了prime_numbers_up_to的大部分,以及其中包含的列表推导,而花在extract_params上的时间非常少。
但这种方法存在一些问题:
-
首先,我们需要在启动和停止进程之间完成一个完整的周期。对于请求来说,这样做很麻烦。
-
周期中发生的所有事情都被包括在内。这给分析增加了噪音。幸运的是,我们知道有趣的部分在
get_result中,但这可能并不明显。此案例也使用了一个最小结构,但在像 Django 这样的复杂框架中添加它可能会导致很多问题。 -
如果我们处理两个不同的请求,它们将被添加到同一个文件中,再次混合结果。
这些问题可以通过仅对感兴趣的部分应用分析器并为每个请求生成新文件来解决。
每个请求生成一个配置文件
为了能够为每个单独的请求生成包含信息的不同文件,我们需要创建一个易于访问的装饰器。这将进行性能分析和生成独立的文件。
在文件server_profile_by_request.py中,我们得到与server.py中相同的代码,但添加了以下装饰器。
from functools import wraps
import cProfile
from time import time
def profile_this(func):
@wraps(func)
def wrapper(*args, **kwargs):
prof = cProfile.Profile()
retval = prof.runcall(func, *args, **kwargs)
filename = f'profile-{time()}.prof'
prof.dump_stats(filename)
return retval
return wrapper
装饰器定义了一个wrapper函数,它替换了原始函数。我们使用wraps装饰器来保留原始名称和文档字符串。
这只是一个标准的装饰器过程。Python 中的装饰器函数是返回一个函数,然后替换原始函数。如您所见,原始函数func仍然在替换它的包装器内部被调用,但它增加了额外的功能。
在内部,我们启动一个性能分析器,并使用runcall函数在其下运行函数。这一行是核心——使用生成的性能分析器,我们运行原始函数func及其参数,并存储其返回值。
retval = prof.runcall(func, *args, **kwargs)
之后,我们生成一个包含当前时间的新文件,并通过.dump_stats调用将统计信息存入其中。
我们还装饰了get_result函数,因此我们的性能分析从这里开始。
@profile_this
def get_result(path):
param = extract_param(path)
if param is None:
return 'Invalid parameter, please add an integer'
return prime_numbers_up_to(param)
完整代码位于文件server_profile_by_request.py中,可在 GitHub 上找到:github.com/PacktPublishing/Python-Architecture-Patterns/blob/main/chapter_14_profiling/server_profile_by_request.py。
现在让我们启动服务器并通过浏览器进行一些调用,一个调用到http://localhost:8000/500,另一个调用到http://localhost:8000/800。
$ python3 server_profile_by_request.py
Server available at http://localhost:8000
Use CTR+C to stop it
127.0.0.1 - - [10/Oct/2021 17:09:57] "GET /500 HTTP/1.1" 200 -
127.0.0.1 - - [10/Oct/2021 17:10:00] "GET /800 HTTP/1.1" 200 -
我们可以看到新文件的创建过程:
$ ls profile-*
profile-1633882197.634005.prof
profile-1633882200.226291.prof
这些文件可以使用snakeviz显示:
$ snakeviz profile-1633882197.634005.prof
snakeviz web server started on 127.0.0.1:8080; enter Ctrl-C to exit

图 14.6:单个请求的性能信息。整个页面太大,无法全部显示,因此有意裁剪以展示部分信息。
每个文件只包含从get_result开始的信息,该信息只获取到某个点。更重要的是,每个文件只显示特定请求的信息,因此可以单独进行性能分析,具有很高的详细程度。
代码可以调整以更具体地包含诸如调用参数之类的详细信息,这可能很有用。另一个有趣的可能的调整是创建一个随机样本,这样只有 X 次调用中的 1 次会产生性能分析代码。这有助于减少性能分析的开销,并允许您完全分析一些请求。
这与统计性能分析器不同,因为它仍然会完全分析一些请求,而不是检测特定时间发生的事情。这有助于跟踪特定请求的流程。
接下来,我们将看到如何进行内存分析。
内存分析
有时,应用程序会使用过多的内存。最坏的情况是,随着时间的推移,它们会使用越来越多的内存,通常是由于所谓的内存泄漏,由于编码中的某些错误,保持不再使用的内存。其他问题也可能包括内存使用可能得到改进的事实,因为它是有限的资源。
为了分析内存使用情况以及确定占用内存的对象,我们首先需要创建一些示例代码。我们将生成足够的莱昂纳多数。
伦纳德数是遵循以下定义的序列的数:
-
第一个伦纳德数是 1
-
第二个伦纳德数也是 1
-
任何其他的伦纳德数都是前两个伦纳德数加 1
伦纳德数与斐波那契数相似。实际上,它们是相关的。我们用它们代替斐波那契数来展示更多的多样性。数字很有趣!
我们通过创建一个递归函数并存储在leonardo_1.py文件中,来展示前 35 个伦纳德数,该文件可在 GitHub 上找到,链接为github.com/PacktPublishing/Python-Architecture-Patterns/blob/main/chapter_14_profiling/leonardo_1.py。
def leonardo(number):
if number in (0, 1):
return 1
return leonardo(number - 1) + leonardo(number - 2) + 1
NUMBER = 35
for i in range(NUMBER + 1):
print('leonardo[{}] = {}'.format(i, leonardo(i)))
你可以运行代码,并看到它逐渐变慢。
$ time python3 leonardo_1.py
leonardo[0] = 1
leonardo[1] = 1
leonardo[2] = 3
leonardo[3] = 5
leonardo[4] = 9
leonardo[5] = 15
...
leonardo[30] = 2692537
leonardo[31] = 4356617
leonardo[32] = 7049155
leonardo[33] = 11405773
leonardo[34] = 18454929
leonardo[35] = 29860703
real 0m9.454s
user 0m8.844s
sys 0m0.183s
为了加快过程,我们发现可以使用记忆化技术,这意味着存储结果并使用它们而不是每次都进行计算。
我们像这样更改代码,创建leonardo_2.py文件(可在 GitHub 上找到,链接为github.com/PacktPublishing/Python-Architecture-Patterns/blob/main/chapter_14_profiling/leonardo_2.py)。
CACHE = {}
def leonardo(number):
if number in (0, 1):
return 1
if number not in CACHE:
result = leonardo(number - 1) + leonardo(number - 2) + 1
CACHE[number] = result
return CACHE[number]
NUMBER = 35000
for i in range(NUMBER + 1):
print(f'leonardo[{i}] = {leonardo(i)}')
这使用了一个全局字典CACHE来存储所有的伦纳德数,从而加快了过程。注意,我们将要计算的数字数量从35增加到35000,增加了千倍。这个过程运行得相当快。
$ time python3 leonardo_2.py
leonardo[0] = 1
leonardo[1] = 1
leonardo[2] = 3
leonardo[3] = 5
leonardo[4] = 9
leonardo[5] = 15
...
leonardo[35000] = ...
real 0m15.973s
user 0m8.309s
sys 0m1.064s
现在我们来看看内存使用情况。
使用内存分析器
既然我们的应用程序开始存储信息了,让我们使用一个分析器来显示内存存储的位置。
我们需要安装memory_profiler这个包。这个包与line_profiler类似。
$ pip install memory_profiler
我们现在可以在leonardo函数中添加一个@profile装饰器(该函数存储在leonardo_2p.py文件中,可在 GitHub 上找到,链接为github.com/PacktPublishing/Python-Architecture-Patterns/blob/main/chapter_14_profiling/leonardo_2p.py),并使用memory_profiler模块运行它。你会注意到这次运行速度变慢了,但在常规结果之后,它会显示一个表格。
$ time python3 -m memory_profiler leonardo_2p.py
...
Filename: leonardo_2p.py
Line # Mem usage Increment Occurences Line Contents
============================================================
5 104.277 MiB 97.082 MiB 104999 @profile
6 def leonardo(number):
7
8 104.277 MiB 0.000 MiB 104999 if number in (0, 1):
9 38.332 MiB 0.000 MiB 5 return 1
10
11 104.277 MiB 0.000 MiB 104994 if number not in CACHE:
12 104.277 MiB 5.281 MiB 34999 result = leonardo(number - 1) + leonardo(number - 2) + 1
13 104.277 MiB 1.914 MiB 34999 CACHE[number] = result
14
15 104.277 MiB 0.000 MiB 104994 return CACHE[number]
Real 0m47.725s
User 0m25.188s
sys 0m10.372s
这个表格首先显示了内存使用情况,以及增加或减少的量,以及每一行出现的次数。
你可以看到以下内容:
-
第 9 行只执行几次。当它执行时,内存量大约为
38 MiB,这将是最小内存使用量。 -
总共使用的内存接近
105 MiB。 -
整个内存增加都集中在第 12 行和第 13 行,当我们创建一个新的伦纳德数并将其存储在
CACHE字典中时。注意我们在这里从未释放内存。
我们并不真的需要在任何时候都保留所有之前的伦纳德数在内存中,我们可以尝试一种不同的方法来只保留少数几个。
内存优化
我们创建了一个名为 leonardo_3.py 的文件,其中包含以下代码,可在 GitHub 上找到:github.com/PacktPublishing/Python-Architecture-Patterns/blob/main/chapter_14_profiling/leonardo_3.py:
CACHE = {}
@profile
def leonardo(number):
if number in (0, 1):
return 1
if number not in CACHE:
result = leonardo(number - 1) + leonardo(number - 2) + 1
CACHE[number] = result
ret_value = CACHE[number]
MAX_SIZE = 5
while len(CACHE) > MAX_SIZE:
# Maximum size allowed,
# delete the first value, which will be the oldest
key = list(CACHE.keys())[0]
del CACHE[key]
return ret_value
NUMBER = 35000
for i in range(NUMBER + 1):
print(f'leonardo[{i}] = {leonardo(i)}')
注意我们保留了 @profile 装饰器以再次运行内存分析器。大部分代码都是相同的,但我们添加了以下额外的代码块:
MAX_SIZE = 5
while len(CACHE) > MAX_SIZE:
# Maximum size allowed,
# delete the first value, which will be the oldest
key = list(CACHE.keys())[0]
del CACHE[key]
此代码将 CACHE 字典中的元素数量保持在一定范围内。当达到限制时,它将删除 CACHE.keys() 返回的第一个元素,这将是最旧的元素。
自从 Python 3.6 版本以来,所有的 Python 字典都是有序的,因此它们会按照之前输入的顺序返回它们的键。我们正是利用这一点。注意,我们需要将 CACHE.keys()(一个 dict_keys 对象)的结果转换为列表,以便获取第一个元素。
字典无法增长。现在让我们尝试运行它,看看分析的结果。
$ time python3 -m memory_profiler leonardo_3.py
...
Filename: leonardo_3.py
Line # Mem usage Increment Occurences Line Contents
============================================================
5 38.441 MiB 38.434 MiB 104999 @profile
6 def leonardo(number):
7
8 38.441 MiB 0.000 MiB 104999 if number in (0, 1):
9 38.367 MiB 0.000 MiB 5 return 1
10
11 38.441 MiB 0.000 MiB 104994 if number not in CACHE:
12 38.441 MiB 0.008 MiB 34999 result = leonardo(number - 1) + leonardo(number - 2) + 1
13 38.441 MiB 0.000 MiB 34999 CACHE[number] = result
14
15 38.441 MiB 0.000 MiB 104994 ret_value = CACHE[number]
16
17 38.441 MiB 0.000 MiB 104994 MAX_SIZE = 5
18 38.441 MiB 0.000 MiB 139988 while len(CACHE) > MAX_SIZE:
19 # Maximum size allowed,
20 # delete the first value, which will be the oldest
21 38.441 MiB 0.000 MiB 34994 key = list(CACHE.keys())[0]
22 38.441 MiB 0.000 MiB 34994 del CACHE[key]
23
24 38.441 MiB 0.000 MiB 104994 return ret_value
在这种情况下,我们看到内存稳定在约 38 MiB 左右,这是我们看到的最低值。在这种情况下,请注意没有增加或减少。实际上,这里发生的是增加和减少的幅度太小,以至于无法察觉。因为它们相互抵消,所以报告接近零。
memory-profiler 模块还能够执行更多操作,包括根据时间显示内存使用情况并绘制图表,因此您可以查看内存随时间增加或减少。请查看其完整文档:pypi.org/project/memory-profiler/。
摘要
在本章中,我们描述了什么是分析以及何时应用它是有用的。我们描述了分析是一个动态工具,它允许您了解代码是如何运行的。这些信息在理解实际情境中的流程以及能够利用这些信息优化代码时非常有用。代码通常可以通过优化来执行得更快,但还有其他替代方案,例如使用更少的资源(通常是内存)、减少外部访问等。
我们描述了主要的分析器类型:确定性分析器、统计性分析器和内存分析器。前两者主要面向提高代码性能,而内存分析器则分析代码在执行过程中使用的内存。确定性分析器通过在代码执行时详细记录代码的流程来对代码进行测量。统计性分析器在固定时间间隔对代码进行采样,以提供代码中执行频率较高的部分的总体视图。
我们展示了如何使用确定性分析器来分析代码,并给出了一个示例。我们首先使用内置模块cProfile进行分析,它提供了函数解析。我们看到了如何使用图形工具来展示结果。为了更深入地分析,我们使用了第三方模块line-profiler,它会遍历每一行代码。一旦理解了代码的流程,就可以对其进行优化,以大大减少其执行时间。
下一步是了解如何分析一个打算持续运行的过程,比如一个网络服务器。我们展示了在这种情况下尝试分析整个应用程序的问题,并描述了如何为了清晰起见,对每个单独的请求进行分析。
这些技术也适用于其他情况,如条件分析、仅在特定情况下分析,例如在特定时间或每 100 个请求中的一个。
最后,我们还提供了一个示例来分析内存,并使用memory-profiler模块查看其使用情况。
在下一章中,我们将学习更多关于如何通过调试技术找到和修复代码中的问题的细节,包括在复杂情况下的处理。
第十五章:调试
通常,调试问题的周期包括以下步骤:
-
检测问题。发现了一个新的问题或缺陷
-
分析并分配此问题的优先级,以确保我们在有意义的问题上花费时间,并专注于最重要的那些
-
调查导致问题的确切原因。理想情况下,这应该以在本地环境中复制问题的方法结束
-
在本地复制问题,并深入了解为什么它会发生
-
解决问题
如您所见,一般的策略是首先定位和理解问题,然后我们可以适当地调试和修复它。
在本章中,我们将涵盖以下主题,以了解如何有效地处理所有这些阶段的技术:
-
检测和处理缺陷
-
生产环境中的调查
-
理解生产环境中的问题
-
本地调试
-
Python 内省工具
-
使用日志进行调试
-
使用断点进行调试
让我们看看处理缺陷时的第一步。
检测和处理缺陷
第一步实际上是检测问题。这听起来可能有点愚蠢,但它是一个相当关键的阶段。
虽然我们主要使用“bug”这个词来描述任何缺陷,但请记住,它可能包括像性能不佳或意外行为这样的细节,这些可能不会被正确分类为“bug”。修复问题的正确工具可能不同,但检测通常是以相似的方式进行。
发现问题可以通过不同的方式,有些可能比其他方式更明显。通常,一旦代码投入生产,缺陷将由用户检测到,无论是内部(最佳情况)还是外部(最坏情况),或者通过监控。
请记住,监控只能捕获明显的、通常也是严重的错误。
根据问题的检测方式,我们可以将它们分类为不同的严重程度,例如:
-
灾难性问题,这些问题会完全停止操作。这些错误意味着系统中的任何东西都无法工作,甚至包括同一系统中的非相关任务
-
关键问题,这些问题会停止某些任务的执行,但不会停止其他任务
-
严重问题,这些问题会停止或在某些情况下导致某些任务的麻烦。例如,一个参数未进行检查并产生异常,或者某些组合产生了一个运行速度如此之慢的任务,以至于产生了超时
-
轻微问题,包括包含错误或不准确性的任务。例如,某个任务在特定情况下产生空结果,或者 UI 中的问题不允许调用功能
-
外观或轻微问题,如拼写错误等
由于每个开发团队都是有限的,因此总会存在太多错误,并且对关注什么以及首先修复什么有适当的处理方法至关重要。通常,第一组的错误显然非常紧迫,需要立即的全员反应。但分类和优先排序很重要。
对接下来要寻找的事物有一个清晰的信号将帮助开发者有一个清晰的视角,通过专注于重要问题而不是最新事物来提高效率。团队本身可以对问题进行一些分类,但添加一些背景信息是好的。
请记住,通常你需要同时纠正错误和实现新功能,并且每一项任务都可能分散对另一项任务的注意力。
修复错误很重要,不仅因为服务的结果质量,因为任何用户都会发现与有缺陷的服务一起工作非常令人沮丧。但它对开发团队也很重要,因为与低质量的服务一起工作对开发者来说也很沮丧。
在修复错误和引入新功能之间需要找到一个适当的平衡。同时,记得为新功能引入的相应新错误分配时间。一个功能在发布时并不准备就绪,而是在其错误被修复时才准备就绪。
任何检测到的问题,除了那些背景信息无关的灾难性错误,都应该捕捉到产生错误所需的步骤周围的上下文。这个目标是为了能够重现错误。
重现错误是解决问题的关键要素。最坏的情况是,一个错误是间歇性的或似乎在随机时间发生。为了理解为什么它在发生时发生,需要更多的挖掘。
当一个问题可以被复制时,你就已经解决了一半。理想情况下,这个问题可以被复制成一个测试,这样就可以反复测试,直到问题被理解和解决。在最佳情况下,这个测试可以是一个单元测试,如果问题影响单个系统,并且所有条件都被理解和可以复制的话。如果问题影响多个系统,可能需要创建集成测试。
在调查过程中,一个常见的问题是找出具体是什么特定情况触发了问题,例如,在生产中以一种特定方式设置的数据,这触发了某些问题。在这个环境中,找出确切导致问题的原因可能很复杂。我们将在本章后面讨论如何在生产中找到问题。
一旦问题被分类并可以复制,调查就可以继续进行,以了解为什么。
通过视觉检查代码并试图推理问题和错误通常是不够的。即使是简单的代码,在执行方面也会让你感到惊讶。能够精确分析在特定情况下代码是如何执行的,对于分析和修复发现的问题至关重要。
生产中的调查
一旦我们意识到生产中存在问题,我们需要了解发生了什么,以及产生问题的关键要素是什么。
强调能够复制问题的能力非常重要。如果是这样,可以进行测试来产生错误并跟踪其后果。
分析为什么产生特定问题时最重要的工具是可观察性工具。这就是为什么在需要时确保能够找到问题的准备工作很重要。
在前面的章节中,我们讨论了日志和指标。在调试时,指标通常与问题无关,除非用来显示错误的相对重要性。检查返回错误的增加可能对于检测错误很重要,但检测错误本身需要更精确的信息。
然而,不要低估指标。它们可以帮助快速确定哪个特定组件失败,或者是否有与其他元素的关系,例如,如果有一个服务器正在产生错误,或者如果它已经耗尽了内存或硬盘空间。
例如,一个有问题的服务器可能会产生看似随机的错误,如果外部请求被指向不同的服务器,并且故障与针对特定服务器的特定请求的组合有关。
但无论如何,日志通常在确定代码的哪个部分表现不佳时更有用。正如我们在第十二章,日志中看到的,我们可以将错误日志描述为检测两种类型的问题:
-
预期错误。在这种情况下,我们事先进行了调试错误的操作,并且应该很容易了解发生了什么。这类错误的例子可以是返回错误的外部请求,无法连接的数据库等。
这些错误中的大多数将与外部服务(从引发错误的一方来看)有关,这些服务表现不佳。这可能表明存在网络问题、配置错误或其他服务的问题。错误在系统中传播并不罕见,因为一个错误可能会引发级联故障。通常,尽管如此,起源将是意外错误,其余的将是预期的错误,因为它们将从外部来源接收错误。
-
意外错误。这些错误的标志是日志表明出了问题,并且在大多数现代编程语言中,日志中会有某种形式的堆栈跟踪,详细说明了错误产生时的代码行。
默认情况下,任何执行任务的框架,如 Web 框架或任务管理系统,都会产生错误,但保持系统稳定。这意味着只有产生错误的任务会被中断,任何新的任务都将从头开始处理。
系统应该为任务提供适当的处理。例如,一个网络服务器将返回 500 错误,而一个任务管理系统可能会在延迟后重试任务。这可能导致错误像我们之前所见的那样被传播。
在任何一种情况下,检测问题的主要工具将是日志。要么日志显示一个已知的问题,该问题已被捕获并正确标记,要么日志显示一个堆栈跟踪,这应该表明代码的哪个具体部分正在显示错误。
找到错误源和代码中的具体部分对于理解问题和调试特定问题非常重要。这在微服务架构中尤为重要,因为它们将具有多个独立元素。
我们在第九章,“微服务与单体架构”中讨论了微服务和单体架构。在处理错误方面,单体架构更容易处理,因为所有代码都在同一地点处理,但无论如何,随着其增长,它们将变得越来越复杂。
请记住,有时完全避免错误是不可能的。例如,如果有一个外部依赖调用外部 API 并且出现问题,这可能会触发内部错误。这些问题可以通过优雅地失败或生成“服务不可用”的状态来缓解。但错误的根源可能无法完全修复。
缓解外部依赖可能需要创建冗余,甚至使用不同的供应商,以便不依赖于单一故障点,尽管这可能并不现实,因为这可能非常昂贵。
我们可以将这些情况通知我们,但它们不会要求采取进一步的短期行动。
在其他情况下,当错误不是立即明显的并且需要进一步调查时,将需要进行一些调试。
理解生产中的问题
在复杂系统中,挑战在于检测问题变得指数级复杂。随着多个层和模块被添加并相互交互,错误可能变得更加微妙和复杂。
如我们之前所见,微服务架构可能特别难以调试。不同微服务之间的交互可能会产生复杂的交互,这可能导致其不同部分的集成中出现微妙的问题。这种集成在集成测试中可能很难测试,或者问题的源头可能位于集成测试的盲点中。
但随着其部分变得更加复杂,单体架构也可能出现问题。由于特定生产数据以意想不到的方式交互,可能会产生难以发现的错误。单体系统的一个大优点是测试将覆盖整个系统,这使得通过单元测试或集成测试进行复现变得更加容易。
然而,在这个步骤中,目标应该是分析足够的生产环境中的问题,以便能够在本地环境中复制它,因为环境的较小规模将使其更容易和更少侵入性地进行探测和更改。一旦收集到足够的信息,最好让任何生产环境保持原样,并专注于问题的具体细节。
记住,拥有可复制的错误已经超过了一半的战斗!一旦问题可以归类为本地可复制的步骤集,就可以创建一个测试来反复产生它,并在受控环境中调试。
有时,启用通用日志记录就足以确定确切是什么错误或如何在本地复制它。在这些情况下,可能需要研究触发问题的环境。
记录请求 ID
分析大量日志时遇到的一个问题是关联它们。为了正确地将相关的日志分组,我们可以通过生成它们的宿主机进行过滤,并选择一个短时间窗口,但即使这样可能也不够好,因为可能同时运行两个或更多不同的任务。我们需要每个任务或请求的唯一标识符,以便跟踪来自同一来源的所有日志。我们将此标识符称为请求 ID,因为它们在许多框架中是自动添加的。这有时在任务管理器中被称为任务 ID。
在涉及多个服务的情况下,例如在微服务架构中,保持一个通用的请求 ID 非常重要,它可以用于跟踪不同服务之间的不同请求。这允许您跟踪和关联来自具有相同来源的不同服务的不同日志。
以下图表显示了前端和两个内部调用的后端服务之间的流程。请注意,X-Request-ID头由前端设置,并将其转发到服务 A,然后服务 A 将其转发到服务 B。

图 15.1:多个服务之间的请求 ID
因为它们都共享相同的请求 ID,所以可以通过该信息过滤日志,以获取单个任务的所有信息。
为了实现这一点,我们可以使用模块django_log_request_id在 Django 应用程序中创建请求 ID。
您可以在此处查看完整文档:github.com/dabapps/django-log-request-id/。
我们在 GitHub 上展示了部分代码,地址为github.com/PacktPublishing/Python-Architecture-Patterns/tree/main/chapter_15_debug,按照书中的示例进行。这需要创建一个虚拟环境并安装该包,以及其余的要求。
$ python3 -m venv ./venv
$ source ./venv/bin/activate
(venv) $ pip install -r requirements.txt
代码已被修改,在microposts/api/views.py文件中包含了一些额外的日志(如github.com/PacktPublishing/Python-Architecture-Patterns/blob/main/chapter_15_debug/microposts/api/views.py所示):
from rest_framework.generics import ListCreateAPIView
from rest_framework.generics import RetrieveUpdateDestroyAPIView
from .models import Micropost, Usr
from .serializers import MicropostSerializer
import logging
logger = logging.getLogger(__name__)
class MicropostsListView(ListCreateAPIView):
serializer_class = MicropostSerializer
def get_queryset(self):
logger.info('Getting queryset')
result = Micropost.objects.filter(user__username=self.kwargs['username'])
logger.info(f'Querysert ready {result}')
return result
def perform_create(self, serializer):
user = Usr.objects.get(username=self.kwargs['username'])
serializer.save(user=user)
class MicropostView(RetrieveUpdateDestroyAPIView):
serializer_class = MicropostSerializer
def get_queryset(self):
logger.info('Getting queryset for single element')
result = Micropost.objects.filter(user__username=self.kwargs['username'])
logger.info(f'Queryset ready {result}')
return result
注意现在在访问列表集合页面和单个 micropost 页面时添加了一些日志。我们将使用示例 URL /api/users/jaime/collection/5。
要启用请求 ID 的使用,我们需要在microposts/settings.py中正确设置配置(github.com/PacktPublishing/Python-Architecture-Patterns/blob/main/chapter_15_debug/microposts/microposts/settings.py):
LOG_REQUEST_ID_HEADER = "HTTP_X_REQUEST_ID"
GENERATE_REQUEST_ID_IF_NOT_IN_HEADER = True
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'filters': {
'request_id': {
'()': 'log_request_id.filters.RequestIDFilter'
}
},
'formatters': {
'standard': {
'format': '%(levelname)-8s [%(asctime)s] [%(request_id)s] %(name)s: %(message)s'
},
},
'handlers': {
'console': {
'level': 'INFO',
'class': 'logging.StreamHandler',
'filters': ['request_id'],
'formatter': 'standard',
},
},
'root': {
'handlers': ['console'],
'level': 'INFO',
},
}
LOGGING字典是 Django 的一个特性,用于描述如何进行日志记录。filters添加了额外的信息,在这种情况下,我们的request_id,formatter描述了要使用的特定格式(注意,我们将request_id作为一个参数添加,它将以括号的形式呈现)。
handlers描述了每个消息的处理方式,将filters和formatter与显示级别和发送信息的位置的信息结合起来。在这种情况下,StreamHandler将日志发送到stdout。我们将所有日志的root级别设置为使用此handler。
查阅 Django 文档以获取更多信息:docs.djangoproject.com/en/3.2/topics/logging/。在 Django 中进行日志记录可能需要对设置所有参数的正确性有一些经验。配置时请耐心。
以下行,
LOG_REQUEST_ID_HEADER = "HTTP_X_REQUEST_ID"
GENERATE_REQUEST_ID_IF_NOT_IN_HEADER = True
如果在输入中没有找到作为头部的Request ID参数,则应创建一个新的参数,该参数的名称为X-Request-ID。
一旦所有这些配置完成,我们可以通过以下命令启动服务器来运行测试:
(venv) $ python3 manage.py runserver
Watching for file changes with StatReloader
2021-10-23 16:11:16,694 INFO [none] django.utils.autoreload: Watching for file changes with StatReloader
Performing system checks...
System check identified no issues (0 silenced).
October 23, 2021 - 16:11:16
Django version 3.2.8, using settings 'microposts.settings'
Starting development server at http://127.0.0.1:8000/
Quit the server with CONTROL-C
在另一个屏幕上,使用curl调用测试 URL:
(venv) $ curl http://localhost:8000/api/users/jaime/collection/5
{"href":"http://localhost:8000/api/users/jaime/collection/5","id":5,"text":"A referenced micropost","referenced":"dana","timestamp":"2021-06-10T21:15:27.511837Z","user":"jaime"}
同时,你会在服务器屏幕上看到日志:
2021-10-23 16:12:47,969 INFO [66e9f8f1b43140338ddc3ef569b8e845] api.views: Getting queryset for single element
2021-10-23 16:12:47,971 INFO [66e9f8f1b43140338ddc3ef569b8e845] api.views: Queryset ready <QuerySet [<Micropost: Micropost object (1)>, <Micropost: Micropost object (2)>, <Micropost: Micropost object (5)>]>
[23/Oct/2021 16:12:47] "GET /api/users/jaime/collection/5 HTTP/1.1" 200 177
正如你所见,添加了一个新的请求 ID 元素,在这个例子中是66e9f8f1b43140338ddc3ef569b8e845。
但请求 ID 也可以通过调用并使用正确的头部来创建。让我们再次尝试,通过另一个curl请求和-H参数添加一个头部。
$ curl -H "X-Request-ID:1A2B3C" http://localhost:8000/api/users/jaime/collection/5
{"href":"http://localhost:8000/api/users/jaime/collection/5","id":5,"text":"A referenced micropost","referenced":"dana","timestamp":"2021-06-10T21:15:27.511837Z","user":"jaime"}
你可以在服务器上再次检查日志:
2021-10-23 16:14:41,122 INFO [1A2B3C] api.views: Getting queryset for single element
2021-10-23 16:14:41,124 INFO [1A2B3C] api.views: Queryset ready <QuerySet [<Micropost: Micropost object (1)>, <Micropost: Micropost object (2)>, <Micropost: Micropost object (5)>]>
[23/Oct/2021 16:14:41] "GET /api/users/jaime/collection/5 HTTP/1.1" 200 177
这表明请求 ID 已经通过头部中的值设置。
可以通过使用同一模块中包含的Session将请求 ID 传递到其他服务,它充当requests模块中的Session。
from log_request_id.session import Session
session = Session()
session.get('http://nextservice/url')
这将在请求中设置正确的头部,并通过它传递到链的下一个步骤,如服务 A 或服务 B。
一定要检查django-log-request-id的文档。
分析数据
如果默认日志不足以理解问题,那么在这些情况下,下一步是理解与问题相关的数据。通常,数据存储可能需要被检查,以追踪与任务相关的数据,看看是否有任何迹象。
这个步骤可能会因为数据缺失或数据限制而变得复杂,这些限制使得获取数据变得困难或不可能。有时,组织中只有少数人可以访问所需的数据,这可能会延迟调查。另一种可能是数据无法检索。例如,数据政策可能不会存储数据,或者数据可能被加密。这在涉及个人身份信息(PII)、密码或类似数据的情况下是一个常见现象。
分析存储的数据可能需要执行临时的手动查询到数据库或其他类型的数据存储,以找出相关数据是否一致,或者是否存在任何不期望的参数组合。
记住,目标是捕获生产中的信息,以便能够独立理解和复制问题。在某些情况下,当调查生产中的问题时,手动更改数据可能会解决问题。这可能在某些紧急情况下是必要的,但目标仍然是理解为什么这种数据不一致的情况是可能的,或者服务应该如何改变以允许你处理这种数据情况。然后,代码可以相应地更改,以确保问题在未来不会发生。
如果调查数据不足以理解问题,可能有必要增加日志上的信息。
增加日志
如果常规日志和数据调查没有结果,可能有必要通过特殊日志提高日志级别,以追踪问题。
这是一个最后的手段,因为它有两个主要问题:
-
任何日志的变化都需要部署,这使得运行成本高昂。
-
系统中的日志数量将会增加,这将需要更多的空间来存储它们。根据系统中的请求数量,这可能会对日志系统造成压力。
这些额外的日志应该始终是短期的,并且应该尽快撤销。
虽然启用额外的日志级别,如将日志设置为DEBUG级别,在技术上是可以实现的,但这可能会产生过多的日志,使得在大量日志中确定关键日志变得困难。在一些DEBUG日志中,调查区域的详细信息可以临时提升到INFO或更高级别,以确保它们被正确记录。
对临时记录的信息要格外小心。像 PII 这样的机密信息不应被记录。相反,尝试记录周围的信息,这些信息有助于找出问题。
例如,如果有怀疑某些意外的字符可能正在导致密码检查算法出现问题,而不是记录密码,可以添加一些代码来检测是否存在无效字符。
例如,假设有一个密码或秘密包含表情符号的问题,我们可以提取仅非 ASCII 字符来找出这是否是问题,如下所示:
>>> password = 'secret password '
>>> bad_characters = [c for c in password if not c.isascii()]
>>> bad_characters
'![']
可以记录bad_characters中的值,因为它不会包含完整的密码。
注意,这个假设可能更容易通过单元测试快速测试,而不需要任何秘密数据。这只是一个例子。
添加临时日志很麻烦,因为它通常需要几次部署才能找出问题。始终重要的是将日志数量保持在最低,尽快清理无用的日志,并在工作完成后完全删除它们。
记住,工作的目的仅仅是为了能够本地复现问题,这样你就可以更有效地本地调查和修复问题。有时,在查看一些临时日志后,问题可能显得很明显,但,良好的 TDD 实践,正如我们在第十章“测试和 TDD”中看到的,是显示并修复 bug。
一旦我们可以在本地检测到问题,就是进行下一步的时候了。
本地调试
本地调试意味着在我们有了本地复现之后,暴露并修复一个问题。
调试的基本步骤是复现问题,了解当前的不正确结果是什么,以及正确的预期结果应该是什么。有了这些信息,我们就可以开始调试。
一个创建问题复现的好方法是通过测试,如果可能的话。正如我们在第十章“测试和 TDD”中看到的,这是 TDD 的基础。创建一个失败的测试,然后修改代码使其通过。这种方法在修复 bug 时非常实用。
回顾一下,任何调试过程都遵循以下过程:
-
你意识到有问题
-
你理解了正确的行为应该是什么
-
你调查并发现为什么当前系统表现不正确
-
你修复了问题
从本地调试的角度来看,记住这个过程也是有用的,尽管在这个阶段,步骤 1和步骤 2可能已经解决了。在大多数情况下,困难的是步骤 3,正如我们在本章中看到的。
为了理解,一旦提供了代码,为什么代码会以这种方式表现,可以使用类似于科学方法的步骤来系统化地处理:
-
测量和观察代码
-
对产生某个结果的原因提出一个假设
-
通过分析产生的状态(如果可能)或创建一个特定的“实验”(一些特定的代码,如测试)来验证或反驳假设,以强制产生该状态
-
使用得到的信息迭代这个过程,直到完全理解问题的根源
注意,这个过程不一定需要应用到整个问题上。它可以集中在可能影响问题的代码的特定部分。例如,这个设置在这个情况下是否被激活了?代码中的这个循环是否被访问了?计算出的值是否低于阈值,这将会让我们走上一条不同的代码路径?
所有这些答案都会增加我们对代码为何以这种方式运行的了解。
调试是一项技能。有些人可能会说它是一门艺术。无论如何,随着时间的推移,它可以得到改善,因为更多的精力投入其中。练习在培养涉及知道何时深入某些区域而忽略其他区域以识别代码可能失败的潜在区域这种直觉方面起着重要作用。
在处理调试问题时,有一些一般性的想法可能会非常有帮助:
- 分而治之。采取小步骤,隔离代码的各个区域,这样就可以简化代码并使其易于理解。理解代码中存在问题的重要性与检测没有问题同样重要,这样我们就可以将注意力集中在相关部分。
爱德华·J·高斯在 1982 年的一篇文章中描述了这种方法,他称之为“狼栅栏算法”:
阿拉斯加有一只狼;你怎么找到它?首先在州的中部建一个栅栏,等待狼嚎叫,确定它在栅栏的哪一侧。只在那一边重复这个过程,直到你能看到狼。
-
从可见的错误回溯。通常情况下,问题的源头并不是错误发生的地方或明显的地方,而是错误在之前就已经产生了。一个好的方法是从问题明显的地方回溯,然后验证流程。这允许你忽略所有在问题之后出现的代码,并有一个清晰的路径进行分析。
-
你可以做出一个假设,只要你能证明这个假设是正确的。代码很复杂,你不可能把整个代码库都记在脑子里。相反,需要仔细地将注意力转移到不同的部分,对其他部分返回的内容做出假设。
如福尔摩斯曾说过:
当你排除了所有不可能的情况后,无论多么不可能,剩下的就是真相。
正确地消除所有东西可能很困难,但将已证实的假设从脑海中移除将减少需要分析和验证的代码量。
但那些假设需要得到验证,才能真正证明它们是正确的,否则我们可能会冒出错误的假设。很容易陷入错误的假设,认为问题出在代码的某个特定部分,而实际上问题可能出在其他地方。
尽管调试的所有技术和可能性都在那里,而且确实有时错误可能很复杂,难以检测和修复,但大多数错误通常很容易理解和修复。也许它们是一个打字错误,一个加一错误,或者需要检查的类型错误。
保持代码简单对于后续的调试问题有很大帮助。简单的代码易于理解和调试。
在我们继续讨论具体技术之前,我们需要了解 Python 中的工具如何帮助我们进行调查。
Python 内省工具
由于 Python 是一种动态语言,它非常灵活,允许你对其对象执行操作以发现它们的属性或类型。
这被称为内省,允许你在不需要太多关于要检查的对象的上下文的情况下检查元素。这可以在运行时执行,因此可以在调试时使用,以发现任何对象的属性和方法。
主要的起点是type函数。type函数简单地返回对象的类。例如:
>>> my_object = {'example': True}
>>> type(my_object)
<class 'dict'>
>>> another_object = {'example'}
>>> type(another_object)
<class 'set'>
这可以用来再次确认对象是否为预期的type。
一个典型的错误示例是,由于一个变量可以是对象或None,因此可能存在一个问题。在这种情况下,处理变量的错误可能需要我们再次检查类型是否为预期的类型。
虽然type在调试环境中很有用,但请避免直接在代码中使用它。
例如,避免将None、True和False的默认值与它们的类型进行比较,因为它们作为单例创建。这意味着每个这些对象只有一个实例,所以每次我们需要验证一个对象是否为None时,最好进行身份比较,如下所示:
>>> object = None
>>> object is None
True
身份比较可以防止在if块中无法区分None或False的使用所引起的问题。
>>> object = False
>>> if not object:
... print('Check valid')
...
Check valid
>>> object = None
>>> if not object:
... print('Check valid')
...
Check valid
相反,仅通过身份比较才能正确检测None的值。
>>> object = False
>>> if object is None:
... print('object is None')
...
>>> object = None
>>> if object is None:
... print('object is None')
...
object is None
这也可以用于布尔值。
>>> bool('Testing') is True
True
对于其他情况,有isinstance函数,它可以用来检查一个特定对象是否是特定类的实例:
>>> class A:
... pass
...
>>> a = A()
>>> isinstance(a, A)
True
这比使用type进行比较更好,因为它了解可能产生的任何继承。例如,在以下代码中,我们看到一个从继承自另一个类的类创建的对象将返回它是任一类的实例,而type函数只会返回一个。
>>> class A:
... pass
...
>>> class B(A):
... pass
...
>>> b = B()
>>> isinstance(b, B)
True
>>> isinstance(b, A)
True
>>> type(b)
<class '__main__.B'>
然而,最实用的内省函数是dir。dir允许你查看对象中的所有方法和属性,当分析来源不明确或接口不明确的对象时尤其有用。
>>> d = {}
>>> dir(d)
['__class__', '__class_getitem__', '__contains__', '__delattr__', '__delitem__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__getitem__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__ior__', '__iter__', '__le__', '__len__', '__lt__', '__ne__', '__new__', '__or__', '__reduce__', '__reduce_ex__', '__repr__', '__reversed__', '__ror__', '__setattr__', '__setitem__', '__sizeof__', '__str__', '__subclasshook__', 'clear', 'copy', 'fromkeys', 'get', 'items', 'keys', 'pop', 'popitem', 'setdefault', 'update', 'values']
在某些情况下,获取所有属性可能会有些过多,因此返回的值可以过滤掉双下划线的属性,以减少噪声的数量,并能够更容易地检测到可以给出关于对象使用线索的属性。
>>> [attr for attr in dir(d) if not attr.startswith('__')]
['clear', 'copy', 'fromkeys', 'get', 'items', 'keys', 'pop', 'popitem', 'setdefault', 'update', 'values']
另一个有趣的功能是help,它显示对象的帮助信息。这对于方法特别有帮助:
>>> help(d.pop)
Help on built-in function pop:
pop(...) method of builtins.dict instance
D.pop(k[,d]) -> v, remove specified key and return the corresponding value.
If key is not found, default is returned if given, otherwise KeyError is raised
这个功能显示对象的定义docstring。
>>> class C:
... '''
... This is an example docstring
... '''
... pass
...
>>> c = C()
>>> help(c)
Help on C in module __main__ object:
class C(builtins.object)
| This is an example docstring
|
| Data descriptors defined here:
|
| __dict__
| dictionary for instance variables (if defined)
|
| __weakref__
| list of weak references to the object (if defined)
所有这些方法都可以帮助您在不成为专家的情况下导航新代码或正在分析的代码,并避免许多难以搜索的代码检查。
添加合理的docstrings不仅有助于保持代码注释良好并为在代码中工作的开发者提供上下文,而且在调试函数或对象使用的地方时也非常有帮助。您可以在 PEP 257 文档中了解更多关于docstrings的信息:www.python.org/dev/peps/pep-0257/。
使用这些工具是好的,但让我们看看我们如何理解代码的行为。
使用日志进行调试
添加注释是一种简单而有效的方法,可以检测正在发生的事情以及代码是如何执行的。这些注释可以是显示包含类似在这里开始循环的语句或包括变量值,如A 的值 = X。通过战略性地定位这些类型的输出,开发者可以理解程序的流程。
我们在本章以及第十章,测试和 TDD中已经提到了这一点。
这种方法的最简单形式是打印调试。它包括添加print语句,以便能够监视它们的输出,通常是在本地执行代码时在测试或类似环境中进行。
打印调试可能对某些人来说有点争议。它已经存在很长时间了,被认为是一种粗略的调试方式。无论如何,它可能非常快速和灵活,并且可以很好地适应一些调试案例,正如我们将看到的。
显然,这些print语句在过程完成后需要被删除。关于这种技术的主要抱怨正是这一点,即有可能会遗漏一些用于调试的print语句,这是一个常见的错误。
然而,可以通过使用日志而不是直接使用print语句来改进这一点,正如我们在第十二章,日志中介绍的。
理想情况下,这些日志将是DEBUG日志,它们仅在运行测试时显示,但在生产环境中不会生成。
虽然日志可以添加而不在以后生成,但修复错误后删除任何虚假日志是良好的实践。日志会积累,如果不定期处理,将会出现过多的日志。在大量文本中查找信息可能会很困难。
这种方法的优点是它可以快速完成,还可以用来探索日志,一旦适应后,可以将这些日志提升为永久日志。
另一个重要优势是测试可以非常快速地运行,因为添加更多日志是一个简单的操作,日志不会干扰代码的执行。这使得它与 TDD 实践相结合成为一个很好的选择。
日志不会干扰代码,并且代码可以不受影响地运行,这使得基于并发的某些难以调试的 bug 更容易调试,因为在这些情况下中断操作流程将影响 bug 的行为。
并发 bug 可能相当复杂。它们是在两个独立的线程以意想不到的方式交互时产生的。由于一个线程将开始和停止什么或一个线程的动作何时会影响另一个线程的不确定性,它们通常需要大量的日志来尝试捕捉那个问题的具体细节。
虽然通过日志进行调试可能非常方便,但它需要一定的知识,了解在哪里设置什么日志以获取相关信息。任何未记录的内容在下次运行时都不会可见。这种知识可能来自于一个发现过程,并且需要时间来定位将导致修复 bug 的相关信息。
另一个问题是新日志是新代码,如果引入了错误,如错误的假设或打字错误,它们可能会引起问题。这通常很容易修复,但可能会造成不便并需要重新运行。
记住,我们在本章之前讨论的所有内省工具都可用。
使用断点进行调试
在其他情况下,最好停止代码的执行并查看当前状态。鉴于 Python 是一种动态语言,这意味着如果我们停止脚本的执行并进入解释器,我们可以运行任何类型的代码并查看其结果。
这正是通过使用 breakpoint 函数所做的事情。
breakpoint 是 Python 中相对较新的功能,自 Python 3.7 版本起可用。之前,需要导入模块 pdb,通常以这种方式在单行中完成:
import pdb; pdb.set_trace()
除了使用方便之外,breakpoint 还有一些其他优点,我们将在后面看到。
当解释器找到 breakpoint 调用时,它会停止并打开一个交互式解释器。从这个交互式解释器中,可以检查代码的当前状态,并进行任何调查,只需简单地执行代码。这使得可以交互式地了解代码正在做什么。
让我们看看一些代码并分析它是如何运行的。代码可以在 GitHub 上找到,地址为 github.com/PacktPublishing/Python-Architecture-Patterns/blob/main/chapter_15_debug/debug.py,内容如下:
def valid(candidate):
if candidate <= 1:
return False
lower = candidate - 1
while lower > 1:
if candidate / lower == candidate // lower:
return False
return True
assert not valid(1)
assert valid(3)
assert not valid(15)
assert not valid(18)
assert not valid(50)
assert valid(53)
也许你能理解代码的功能,但让我们交互式地看看它。首先,你可以检查末尾的所有assert语句是否正确。
$ python3 debug.py
但我们现在在第 9 行之前引入了一个breakpoint调用,正好在while循环的开始处。
while lower > 1:
breakpoint()
if candidate / lower == candidate // lower:
return False
再次执行程序,它现在会停在那一行并显示一个交互式提示符:
$ python3 debug.py
> ./debug.py(10)valid()
-> if candidate / lower == candidate // lower:
(Pdb)
检查candidate的值以及两个操作。
(Pdb) candidate
3
(Pdb) candidate / lower
1.5
(Pdb) candidate // lower
1
这行代码正在检查将candidate除以lower是否产生一个精确的整数,因为在那种情况下,这两个操作将返回相同的值。通过按n(即n(ext))命令来执行下一行,并检查循环结束并返回True:
(Pdb) n
> ./debug.py(13)valid()
-> lower -= 1
(Pdb) n
> ./debug.py(8)valid()
-> while lower > 1:
(Pdb) n
> ./debug.py(15)valid()
-> return True
(Pdb) n
--Return--
> ./debug.py(15)valid()->True
-> return True
使用命令c(即c(ontinue))继续执行,直到找到新的breakpoint。注意这发生在对valid的下一个调用上,其输入为 15。
(Pdb) c
> ./debug.py(10)valid()
-> if candidate / lower == candidate // lower:
(Pdb) candidate
15
你也可以使用l(ist)命令显示周围的代码。
(Pdb) l
5
6 lower = candidate - 1
7
8 while lower > 1:
9 breakpoint()
10 -> if candidate / lower == candidate // lower:
11 return False
12
13 lower -= 1
14
15 return True
自由地继续调查代码。当你完成时,运行q(uit)以退出。
(Pdb) q
bdb.BdbQuit
经过仔细分析代码后,你可能知道它做什么。它通过检查一个数是否可以被小于它本身的任何数整除来确定这个数是否为素数。
我们在第十四章,性能分析中调查了类似的代码和改进。不用说,这不是设置代码检查的最有效方式,但它已被添加为例子以及教学目的。
另外两个有用的调试命令是s(tep),用于进入函数调用,和r(eturn),用于执行代码直到当前函数返回其执行。
breakpoint也可以自定义以调用其他调试器,而不仅仅是pdb。还有其他 Python 调试器,它们包括更多上下文信息或更高级的使用,例如ipdb(pypi.org/project/ipdb/)。要使用它们,您需要在安装调试器后设置PYTHONBREAKPOINT环境变量,并指定调试器的端点。
$ pip3 install ipdb
…
$ PYTHONBREAKPOINT=IPython.core.debugger.set_trace python3 debug.py
> ./debug.py(10)valid()
8 while lower > 1:
9 breakpoint()
---> 10 if candidate / lower == candidate // lower:
11 return False
12
ipdb>
此环境变量可以设置为 0 以跳过任何断点,从而有效地禁用调试过程:PYTHONBREAKPOINT=0。这可以用作安全措施,以避免被未正确删除的断点语句中断,或者快速运行代码而不受干扰。
有多个调试器可以使用,包括来自 Visual Studio 或 PyCharm 等 IDE 的支持。以下是两个其他调试器的示例:
-
pudb(github.com/inducer/pudb):具有基于控制台的图形界面,以及关于代码和变量的更多上下文信息 -
remote-pdb(github.com/ionelmc/python-remote-pdb):允许你远程调试,连接到 TCP 套接字。这允许你在不同的机器上调试程序,或者在无法访问进程的stdout的情况下触发调试器,例如,因为它在后台运行
正确使用调试器是一项需要时间来学习的技能。确保尝试不同的选项,并熟悉它们。调试也会在运行测试时使用,正如我们在第十章测试和 TDD中描述的那样。
摘要
在本章中,我们描述了检测和修复问题的通用过程。当在复杂系统中工作时,存在一个挑战,那就是正确地检测和分类不同的报告,以确保它们得到优先处理。能够可靠地重现问题,以便展示产生问题的所有条件和上下文,这一点非常重要。
一旦一个问题被认为很重要,就需要调查为什么会出现这个问题。这可以针对正在运行的代码,并使用生产环境中可用的工具来查看是否可以理解问题发生的原因。这次调查的目标是能够在本地复制这个问题。
大多数问题都可以在本地轻松重现并继续前进,但我们还介绍了一些工具,以防问题仍然是个谜。作为理解生产中代码行为的主要工具是日志,我们讨论了创建一个请求 ID,这可以帮助我们追踪不同的调用并关联来自不同系统的日志。我们还描述了环境中的数据可能有助于理解问题发生的原因。如果需要,可能需要增加日志的数量来从生产中提取信息,尽管这应该仅限于非常难以追踪的 bug。
然后,我们在复制问题后,理想情况下,如我们在第十章测试和 TDD中看到的那样,以单元测试的形式讨论了如何在本地进行调试。我们提供了一些一般性的想法来帮助调试,尽管必须说,调试是一项需要练习的技能。
调试可以学习和改进,因此这是一个经验丰富的开发者可以帮助初级开发者的领域。确保创建一个团队,在困难情况下需要帮助调试时,鼓励互相帮助。两个眼睛看到的比一个多!
我们介绍了一些帮助调试 Python 的工具,这些工具利用了 Python 提供的内省可能性。由于 Python 是一种动态语言,所以有很多可能性,因为它能够执行任何代码,包括所有的内省能力。
我们接着讨论了如何创建用于调试的日志,这是使用print语句的改进版本,并且如果以系统化的方式进行,长期来看可以帮助创建更好的日志。最后,我们转向使用breakpoint函数调用来进行调试,这会停止程序的执行,并允许你在那个点检查和理解状态,同时继续执行流程。
在下一章中,我们将讨论在系统运行并需要演进时在该系统架构中工作的挑战。
第十六章:持续架构
正如软件本身永远不会真正完成一样,软件架构也永远不会是一项完成的工作。总会有变化、调整和微调需要执行,以改进系统:添加新功能;提高性能;修复安全问题。虽然良好的架构要求我们深入了解如何设计系统,但持续过程的现实更多是关于进行更改和改进。
在本章中,我们将讨论这些方面的某些内容,以及处理在实际运行系统中进行变更的一些技术和想法,同时考虑到这个过程可以通过反思执行过程并遵循一些指南来进一步改进,以确保系统可以持续变更,同时同时保持对客户的服务。
在本章中,我们将涵盖以下主题:
-
调整架构
-
计划停机时间
-
事件
-
压力测试
-
版本控制
-
向后兼容性
-
功能标志
-
变更的团队合作方面
让我们先看看为什么要对系统的架构进行更改。
调整架构
虽然在这本书的大部分内容中,我们一直在谈论系统设计,这是建筑师的基本职能,但很可能他们的大部分日常工作将更多地集中在重新设计上。
这总是一项永无止境的任务,因为工作软件系统始终处于修订和扩展中。可能需要调整系统架构的一些原因如下:
-
提供之前不可用的某些功能或特性——例如,添加一个事件驱动的系统来运行异步任务,使我们能够避免之前可用的请求-响应模式。
-
因为当前架构存在瓶颈或限制。例如,系统中只有一个数据库,并且对可以运行的查询数量有限制。
-
随着系统的发展,可能有必要将部分内容分割,以便更好地控制它们——例如,将单体分割成微服务,正如我们在第八章“高级事件驱动结构”中看到的。
-
为了提高系统的安全性——例如,删除或编码可能敏感的存储信息,如电子邮件地址和其他个人身份信息(PII)。
-
大型 API 变更,如引入 API 的新版本,无论是内部还是外部。例如,添加一个新端点,它更适合其他内部系统执行某些操作,调用服务应迁移。
-
存储系统的变化,包括我们在第三章“数据建模”中讨论的关于分布式数据库的所有不同想法。这也可以包括添加或替换现有的存储系统。
-
为了适应过时的技术。这可能在具有关键组件不再受支持的遗留系统中发生,或者存在根本的安全问题。例如,用一个能够使用新安全流程的模块替换旧的模块,因为旧的不再维护,并且依赖于旧的加密方法。
-
使用新语言或技术的重写。这可以在某个系统最初是用不同的语言创建的情况下进行,过了一段时间后,决定将其与最常用的语言保持一致,以便更好地维护。这种情况在经历过增长的组织中很典型,某个时候,一个团队决定使用他们最喜欢的语言来创建一个服务。过了一段时间,这可能会因为缺乏这种语言的专长而使维护复杂化。如果原始开发者已经离开组织,情况可能会更糟。最好是调整或重写服务,将其集成到现有的服务中,或者用首选语言中的等效服务替换它。
-
其他类型的债务——例如,可以清理代码并使其更易读的重构,或者为了更精确地更改组件名称等。
这些只是些例子,但事实是,所有系统都需要不断的更新和调整,因为软件很少是完成的工作。
挑战不仅在于设计这些更改以实现预期的结果,而且还要以最小的系统中断从起点移动到终点。如今,人们期望在线系统很少中断,这对任何更改都设定了很高的标准。
要实现这一点,需要逐步进行更改,并格外小心,确保系统在所有时刻都可用。
计划中的停机时间
理想情况下,系统在做出更改后不应出现中断,但有时在不中断系统的情况下进行重大更改是不可能的。
何时以及是否有必要停机可能很大程度上取决于系统。例如,在运营的前几年,流行的网站 Stack Overflow (stackoverflow.com/) 经常出现停机,最初甚至每天,在欧洲上午时段网页会显示“维护中”页面。这种情况最终有所改变,现在很少看到这种信息。
但在项目的早期阶段,这是可以接受的,因为大部分用户都按照北美时间使用网站,而且它(现在仍然是)一个免费网站。
安排停机始终是一个选择,但代价高昂,因此需要以最小化对运营影响的方式进行设计。如果系统是一个对客户至关重要的 24x7 服务,或在运行时为业务产生收入(例如,商店),任何停机都将有一个相当大的价格标签。
在其他情况下,例如一个流量非常小的新服务,客户可能会更加理解,甚至有很大可能不受影响。
应提前通知受影响的客户计划内停机。这种通知可以采取多种形式,并将很大程度上取决于服务类型。例如,一个公共网络商店可能会在星期内通过页面上的一条横幅宣布停机,告知星期日上午将不可用,但为银行操作安排停机可能需要提前数月通知和协商最佳时间。
如果可能的话,定义维护窗口以明确设定服务可能或将有某种中断高风险的时间是一个好习惯。
维护窗口
维护窗口是提前通知可能进行维护的时段。其目的是在维护窗口之外保证系统的稳定性,并明确分配可能进行维护的时间。
维护窗口可能是在系统最活跃时区的周末或夜间。在活动最繁忙的小时内,服务保持不间断,只有在无法等待时,例如防止或修复关键事件时,才会进行维护。
维护窗口不同于计划内停机。虽然在某些情况下会发生,但并非每个维护窗口都需要涉及停机——只是有可能发生。
并非每个维护窗口都需要同等定义——有些可能比其他更安全,能够进行更广泛的维护。例如,周末可能被预留为计划内停机时间,但工作周中的夜晚可能进行常规部署。
提前通知维护窗口非常重要,例如设计如下表格:
| 日期 | 时间 | 维护窗口类型 | 风险 | 备注 |
|---|---|---|---|---|
| 周一到周四 | 08:00 – 12:00 UTC | 定期维护 | 低风险 | 考虑到常规部署被认为是低风险。对服务无影响。 |
| 星期六 | 08:00 – 18:00 UTC | 严重维护 | 高风险 | 考虑到调整被认为是风险较高的。虽然预期服务将完全可用,但在窗口期间可能会在某些时候中断。 |
| 星期六 | 08:00 – 18:00 UTC | 通知计划内停机 | 服务不可用 | 提前一个月通知。进行需要服务不可用的基本维护。 |
关于维护窗口的一个重要细节是,它们应该足够大,以便有足够的时间进行维护。请确保时间充足,因为设置一个大的维护窗口,可以安全地应对任何突发情况,比设置一个短的窗口并经常需要延长要好。
虽然计划中的停机时间和维护窗口有助于界定服务活跃的时间和用户风险较高的时间,但仍然可能出现一些问题,导致系统出现问题。
事件
不幸的是,在系统的某个阶段,系统可能不会按预期运行。它将产生一个重要的错误,需要立即处理。
事件被定义为一种足以干扰服务并需要紧急响应的问题。
这并不一定意味着完整的服务完全中断——它可能是外部服务的明显退化,甚至是一个内部服务的问题,导致整体服务质量下降。例如,如果一个异步任务处理器有 50%的失败率,外部客户可能只会注意到他们的任务执行时间更长,但这可能已经足够重要,需要采取纠正措施。
在事件发生期间,使用所有可用的监控工具至关重要,以便尽快找到问题并能够纠正它。反应时间应尽可能快,同时将纠正措施的风险降到最低。这里需要找到一个平衡点,根据事件性质的不同,可以采取更具风险性的行动,例如当系统完全宕机时,恢复系统将更为重要。
事件期间恢复通常会受到两个因素的制约:
-
监控工具在检测和理解问题方面的好坏
-
系统中引入变更的速度,与更改参数或部署新代码的速度相关
上述第一点是理解部分,第二点是解决部分(尽管可能需要做出改变以更好地理解问题,正如我们在第十四章,性能分析中看到的)。
我们在书中涵盖了这两个方面,其中可观测性工具在第十一章,包管理和第十二章,日志记录中进行了考察。我们也可能需要使用第十四章,性能分析中描述的技术。
向系统中引入变更与我们在第四章,数据层中讨论的持续集成(CI)技术紧密相关。一个快速的 CI 管道可以显著缩短新代码准备部署的时间。
这就是为什么这两个元素,可观察性和进行更改所需的时间,如此重要的原因。在正常情况下,部署或进行更改需要很长时间通常只是轻微的不便,但在关键情况下,它可能会阻碍有助于系统恢复健康的修复。
对事件的反应是一个复杂的过程,需要灵活性和即兴发挥,这些能力会随着经验而提高。但同时也需要有一个持续的过程来提高系统的正常运行时间和理解系统的最薄弱环节,以避免问题或最小化它们。
事后分析
事后分析,也称为事后审查,是在问题影响服务后进行的分析。其目标是了解发生了什么,为什么,并采取纠正措施以确保问题不再发生,或者至少减少其影响。
通常,一个事后分析是从参与问题纠正的人员填写模板表单开始的。预先定义模板有助于塑造讨论并专注于要执行的补救措施。
在线上有许多事后分析模板可供搜索,你可以看看是否有你喜欢的特定模板,或者只是为了获取灵感。与其他流程部分一样,它应该随着过程的进行而改进和精炼。记住要创建和调整你自己的模板。
基本模板应该从发生的主要细节开始,包括发生了什么,然后是为什么会发生,最后是最重要的部分:为了纠正问题,接下来应该采取哪些行动?
记住,事后分析是在事件结束后进行的。虽然可以在事件发生时做一些笔记,但事件发生时的重点是首先解决问题。首先关注最重要的事情。
例如,一个简单的模板可以是以下这样:
事件报告
-
摘要。对发生事件的简要描述。
示例: 服务在 11 月 5 日的 08:30 至 09:45 UTC 之间中断。
-
影响。描述问题的外部影响。外部用户受到了怎样的影响?
示例: 所有用户请求都返回了 500 错误。
-
检测。描述最初是如何检测到的。是否可以更早地检测到?
示例: 监控系统在 8:35 UTC 时发出警报,这是在 100%错误请求持续了 5 分钟后。
-
响应。为纠正问题所采取的行动。
示例: 约翰清理了数据库服务器的磁盘空间并重启了数据库。
-
时间线。事件的时间线,以了解事件是如何发展和每个阶段持续了多长时间。
示例:
8:30 问题开始。
8:35 监控系统触发了一个警报。约翰开始调查这个问题。
8:37 发现数据库无响应且无法重启。
9:05 经过调查,约翰发现数据库磁盘已满。
9:30 数据库服务器中的日志已填满服务器磁盘空间,导致数据库服务器崩溃。
9:40 从服务器中移除旧日志,释放磁盘空间。数据库重启。
9:45 服务恢复正常。
-
根本原因。对已识别的根本原因的描述,如果修复,将完全消除这个问题。
检测根本原因并不一定容易,因为有时会涉及一系列事件。为了帮助找到根本原因,可以使用“五问法”。开始描述影响并询问为什么会发生。然后问为什么会这样,依此类推。继续迭代,直到问“为什么?”五次,最终的结果将是根本原因。不要理解为“必须”问“为什么?”正好五次,但要继续进行,直到得到一个明确的答案。
考虑到调查可能超出在事件期间恢复服务所采取的步骤,快速修复可能已经足够摆脱困境。
示例:
服务器返回错误。为什么?
因为数据库崩溃了。为什么?
因为数据库服务器空间不足。为什么?
因为空间已经被木材完全填满。为什么?
因为磁盘上的日志空间没有限制,可以无限增长。
-
经验教训。在过程中可以改进的事情,以及任何其他做得好的元素,比如在分析问题时使用某些有用的工具或指标。
示例:
在所有情况下,都应该限制日志使用的磁盘空间量。
在磁盘空间完全耗尽之前,并没有监控或发出警报。
警报系统太慢,需要达到较高错误率才会发出警报。
-
下一步行动。过程最重要的部分。描述应该执行哪些操作来消除问题,如果不可能消除,则减轻问题。确保这些操作有明确的负责人,并得到跟进。
如果有票务系统,这些操作应转换为票务,并相应地优先处理,以确保适当的团队实施它们。
不仅应该解决根本原因,还应该解决在经验教训部分中发现的任何可能的改进。
示例:
操作:启用日志轮转,限制所有服务器中日志所占用的空间,从数据库开始。分配给运维团队。
操作:监控磁盘空间并发出警报,如果磁盘空间小于总可用空间的 20%,以便更快地做出反应。分配给运维团队。
操作:调整错误警报,将其改为当 30%或更多请求返回错误时只有一分钟时发出警报。分配给运维团队。
注意,模板不必一次性填写完成。通常,模板会尽可能填写,然后举行一次事后会议,届时可以对事件进行分析,并完全填写模板,包括下一步行动部分,这又是分析中最重要的部分。
请记住,事后流程的重点是改进系统,而不是对问题进行归责。流程的目标是发现弱点,并尽量确保问题不会重复。
在近年来的重要事件之前,已经实施了一个等效的过程来尝试预见问题。
预死亡分析
预死亡分析是一种尝试在重要事件之前分析可能出错的事情的练习。事件可能是某个里程碑、发布活动或类似的事情,预计将显著改变系统的条件。
“预死亡分析”这个词是一个相当有趣的术语,它来自“事后分析”的使用,作为一种指代事后进行的分析的方式,与尸检进行类比。尽管希望没有什么已经死亡!。
它也可以被称为准备分析。
例如,可能会有一个营销活动启动,预计将使流量增加到之前的两倍或三倍。
预死亡分析是事后分析的相反。你将你的心态设定在将来,并问:出了什么问题? 最坏的情况是什么? 从那里,你验证你对系统的假设,并为它们做好准备。
考虑对上述示例中系统流量增加三倍的分析。我们能否模拟条件以验证我们的系统是否已准备好应对?我们认为系统的哪些部分比较脆弱?
所有这些都可以导致对不同场景的规划,并运行测试以确保系统为事件做好准备。
在进行任何预死亡分析时,请确保有足够的时间执行必要的行动和测试来准备系统。像往常一样,行动将需要优先排序,以确保时间得到充分利用。但请记住,这种准备可能是一个无休止的任务,而且时间有限,因此需要集中在系统最重要的或最敏感的部分。确保尽可能多地使用数据驱动的行动,并将分析集中在真实数据而不是直觉上。
压力测试
在这种情况下,准备的关键要素是压力测试。
压力测试是指创建一个模拟增加流量的负载。它可以以探索性的方式进行,即让我们找出我们系统的极限;或者以确认性的方式进行,即让我们再次确认我们能否达到这个流量水平。
负载测试通常不在生产环境中进行,而是在预生产环境中进行,复制生产中的配置和硬件,尽管通常需要创建一个最终的负载测试来验证生产环境中的配置是正确的。
在云环境中进行负载测试分析的一个有趣的部分是确保系统中的任何自动扩展都能正确工作,以便在接收更大负载时自动提供更多硬件,并在不需要时删除它。这里需要谨慎,因为每次运行到集群最大容量时的完全负载测试可能都很昂贵。
负载测试的基本元素是模拟典型用户在系统上执行操作。例如,一个典型用户可以登录,查看几个页面,添加一些信息,然后登出。我们可以使用工作在我们外部界面的自动化工具来复制这种行为。
使用这些工具的一个好方法是重用任何可以创建的自动化测试,并将其作为模拟的基础。这使得集成或系统测试框架成为启用负载测试的单位。
然后,我们可以将模拟单个用户行为的单元多次乘以,以模拟N个用户的效果,产生足够的负载来测试我们的系统。
为了简单起见,最好使用一个单一的模拟,它作为用户典型行为的组合来工作,而不是尝试生成多个更小的模拟来复制不同的用户。
正如我们之前所说的,在这些情况下,使用一些系统测试来锻炼系统的主体部分效果非常好,一旦你确认行为与系统中的典型情况兼容。
如果有必要,或者要进行微调,可以通过分析日志来生成用户执行的典型接口的适当配置文件。记住,在可能的情况下要依赖数据。然而,当没有可靠数据时,有时需要进行负载测试,因为它们通常在新功能引入时进行,所以必须使用估计值。
记得监控每个模拟的结果,特别是错误。这将有助于检测可能的问题。负载测试也锻炼了系统的监控,因此它是检测弱点并改进它们的好练习。
负载测试越密集,它们能捕获的问题就越多。然后我们可以在实际流量中避免这些问题。
请记住,创建负载也可能有自己的瓶颈。为了乘以模拟,可能需要使用多个服务器,并确保网络能够支持流量。
通过多次启动进程,可以直接对模拟进行乘法。虽然这个程序很简单,但非常有效,可以用简单的脚本来控制。它还具有灵活性,模拟可以是任何类型的进程,包括使用任何现有软件进行的调整后的系统测试。这加快了负载测试的准备,并建立了对模拟准确性的信任,因为它重用了之前已经测试过的现有软件。
也可以使用针对常见用例(如 HTTP 接口)的特定工具,例如 Locust (locust.io/)。这个工具允许我们创建一个网络会话,模拟用户访问系统。Locust 的巨大优势在于它已经内置了报告系统,并且可以以最小的准备进行扩展。然而,它需要显式创建一个新的会话来进行负载测试,并且只能与 Web 接口一起工作。
负载测试也应旨在在生产集群中创建一些余量,以便验证负载始终处于控制之下,即使在增长的情况下也是如此,而不是在常规操作中寻找瓶颈,这可能会导致事件发生。
版本控制
当对任何服务进行更改时,需要建立一个系统来跟踪不同的更改。这样,我们就可以了解何时部署了什么,以及与上周相比有什么变化。
当你面临事件时,这些信息非常有用。在系统中,最危险的时刻之一就是有新的部署,因为新代码可能会产生新的问题。由于新版本的发布而产生事件并不罕见。
版本控制意味着为每个服务或系统分配一个唯一的代码版本。这使得理解已部署的软件以及追踪从一个版本到另一个版本的变化变得容易。
版本号通常在源代码控制系统中在特定点分配,以精确跟踪该特定点的代码。拥有定义明确的版本的目的,是为了对具有唯一版本号的代码有一个精确的定义。适用于代码多个迭代的版本号是无用的。
版本号是在讨论同一项目的不同快照时,关于代码差异的沟通。它们的主要目的是沟通并允许我们了解软件如何演变,不仅是在团队内部,而且在外部也是如此。
传统上,版本与打包软件高度相关,并且销售在盒子中的软件的不同版本是营销版本。当需要内部版本时,使用了一个构建号,这是一个基于软件编译次数的连续数字。
版本不仅可以应用于整个软件,还可以应用于其元素,如 API 版本、库版本等。同样,可以为同一软件使用不同的版本,例如为技术团队创建内部版本,为营销目的创建外部版本。
例如,某些软件可能以“Awesome Software v4”出售,API 为v2,而内部描述为构建号v4.356。
在现代软件中,由于发布频繁且版本需要经常更改,这种方法是不够的,因此创建了不同的版本模式。最常见的是语义版本控制。
我们在第二章,API 设计中讨论了语义版本控制,但这个话题很重要,值得重复。请注意,这个概念可以同时用于 API 和代码发布。
语义版本控制使用两个或三个由点分隔的数字。可以添加可选的v前缀来明确它指的是版本:
`vX.Y.Z`
第一个数字(X)被称为主版本。第二个(Y)是次要版本,最后一个数字(Z)是补丁版本。这些数字在新版本生成时增加:
-
主版本的增加表示该软件与先前存在的软件不兼容。
-
次要版本的增加意味着这个版本包含新功能,但它们不会破坏与旧版本的兼容性。
-
最后,补丁版本的增加仅涵盖错误修复和其他改进,如安全补丁。它修复了问题,但不会改变系统的兼容性。
请记住,增加主版本号也可能标记出通常出现在次要版本更新中的变化。主版本号的变化可能会带来新功能和重大改进。
这种版本控制的一个很好的例子是 Python 解释器本身:
-
Python 3 是主版本的增加,因此,Python 2 的代码需要在 Python 3 下进行更改才能运行
-
Python 3.9 与 Python 3.8 相比引入了新功能,例如,字典的新联合操作符
-
Python 3.9.7 在之前的补丁版本上增加了错误修复和改进
语义版本控制非常流行,尤其是在处理 API 和将要外部使用的库时特别有用。它仅从版本号就能清楚地预期到新变化的内容,并在添加新功能时提供清晰性。
这种版本控制方法,尽管如此,可能对某些项目来说过于限制性,尤其是对于内部接口。因为它通过小迭代来保持兼容性,只在它们过时后才废弃功能,所以它更像是一个始终在演变的窗口。因此,引入一个有意义的特定版本是困难的。
例如,Linux 内核决定由于这个原因放弃使用语义版本控制,决定新的大版本将很小,不会改变事物,并且不会携带任何特定的意义:lkml.iu.edu/hypermail/linux/kernel/1804.1/06654.html。
当与内部 API 一起工作时,尤其是与经常更改且被组织其他部分使用的微服务或内部库一起工作时,放宽规则会更好,同时使用类似于语义版本控制的方法,只是将其用作一种通用工具,以一致的方式增加版本号,以提供对代码更改的理解,但不必强制在主版本或次版本中做出更改。
然而,当通过外部 API 进行通信时,版本号不仅具有技术意义,还具有营销意义。使用语义版本控制可以提供对 API 能力的强烈保证。
由于版本控制非常重要,一个好的想法是允许服务通过特定的端点如/api/version或另一种易于访问的方式自行报告其版本号,以确保它清晰且可以被其他依赖服务检查。
请记住,即使系统的不同组件具有自己的独立版本,也可以创建整个系统的一般版本。然而,在在线服务的情况下,这可能很棘手或毫无意义。相反,重点应放在保持向后兼容性上。
向后兼容性
在运行系统中更改架构的关键方面是始终在其接口和 API 中保持向后兼容性的必要性。
我们还在第三章,数据建模中讨论了关于数据库更改的向后兼容性。在这里,我们将讨论接口,但遵循相同的思想。
向后兼容性意味着系统保持其旧接口按预期工作,因此任何调用系统都不会受到更改的影响。这允许它们在任何时候升级,而不会中断服务。
请记住,向后兼容性需要在外部应用,因为客户依赖于稳定的接口,但同时也需要在内部应用,因为多个服务相互交互。如果系统复杂且具有多个部分,连接它们的 API 应该是向后兼容的。这在微服务架构中尤为重要,以允许独立部署微服务。
这个概念相当简单,但它对如何设计和实施更改有影响:
-
变更应始终是累加的。这意味着它们添加选项,而不是删除它们。这使得任何现有的系统调用都能继续使用现有的功能和选项,而不会破坏它们。
-
移除选项应极其谨慎,并且只有在确认它们不再被使用后才能进行。为了能够检测到这一点,我们需要调整监控,以便我们有真实的数据,可以清楚地提供可靠的数据,使我们能够确定这一点。
对于外部接口,可能几乎不可能移除任何选项或端点,尤其是在 API 上。除非有充分的理由,否则客户不想改变现有的系统以适应任何变化,即使在这种情况下,也需要大量的工作来充分沟通。我们将在本章后面讨论这种情况。
网络界面允许更大的灵活性,因为它们是由人类手动使用的。
-
即使是在外部可访问的 API 中进行增量更改也很困难。外部客户倾向于记住 API 的当前状态,因此更改现有调用的格式可能很困难,即使只是添加一个新字段。
这取决于使用的格式。在 JSON 对象中添加一个新字段比更改需要事先定义的 SOAP 定义更安全。这也是 JSON 如此受欢迎的原因之一——因为它在定义返回的对象方面具有灵活性。
尽管如此,对于外部 API,如果必要,添加新的端点可能更安全。API 更改通常分阶段进行,创建 API 的新版本,并试图鼓励客户切换到新的更好的 API。这些迁移可能非常漫长和艰巨,因为外部用户将需要明显的优势才能说服他们在自己的端进行改变。
一个很好的例子是 API 变化可能有多么痛苦,就是从 Python 2 迁移到 Python 3。Python 3 自 2008 年以来就已经可用,但需要很长时间才能获得任何形式的吸引力,因为用 Python 2 编写的程序需要被更改。迁移过程相当漫长,甚至到了最后 Python 2 解释器(Python 2.7)从 2010 年的首次发布一直支持到 2020 年。即使有这样一个漫长的过程,遗留系统中仍然有使用 Python 2 的代码。这表明,如果不尊重向后兼容性,从一个 API 迁移到另一个 API 是非常困难的。
- 现有的测试,无论是单元测试还是集成测试,都是确保 API 向后兼容的最佳方式。本质上,任何新功能都应该没有问题地通过测试,因为旧的行为不会改变。对 API 功能的良好测试覆盖是维护兼容性的最佳方式。
在外部接口中引入变化更为复杂,通常需要定义更严格的 API 和更慢的变化速度。内部接口允许更大的灵活性,因为它们的变化可以通过增量方式在组织内部进行沟通,从而允许在不中断服务的情况下进行适应。
增量变化
对系统的增量更改,缓慢地变异和调整 API,可以按顺序与多个服务一起发布。但更改需要按顺序应用,并考虑向后兼容性。
例如,假设我们有两个服务:服务 A 生成一个显示参加考试的学生界面的接口,并调用服务 B 来获取考生的名单。这是通过调用一个内部端点来完成的:
GET /examinees (v1)
[
{
"examinee_id": <student id>,
"name": <name of the examinee>
}, …
]
服务 A 需要引入一个新功能,该功能需要从考生那里获取额外信息,并需要我们知道每个考生尝试特定考试的次数,以便根据该参数对他们进行适当的排序。使用当前信息,这是不可能的,但服务 B 可以被调整以返回该信息。
为了做到这一点,API 需要被扩展,以便返回该信息:
GET /examinees (v2)
[
{
"examinee_id": <student id>,
"name": <name of the examinee>,
"exam_tries", <num tries>
}, …
]
只有在这次更改得到正确实施并部署后,服务 A 才能使用它。这个过程发生在以下阶段:
-
初始阶段。
-
使用
/examinees (v2)部署服务 B。注意服务 A 将忽略额外的字段并正常工作。 -
部署读取和使用新参数
exam_tries的服务 A。
所有步骤都是稳定的。在整个过程中,服务没有问题,因此不同服务之间存在分离。
这种分离很重要,因为如果部署出现问题,它可以被撤销,并且只影响单个服务,可以快速恢复到之前稳定的状态,直到问题得到解决。最糟糕的情况是同时需要发生两个服务变更,因为一个服务的失败将影响另一个服务,并且撤销情况可能并不容易。更糟糕的是,问题可能存在于它们之间的交互中,在这种情况下,不清楚哪个是责任人,因为可能是两者都有可能。保持小而独立的步骤非常重要,每个步骤都应该是稳固和可靠的。
这种操作方式使我们能够实施更大的更改,例如,重命名字段。假设我们不喜欢examinee_id字段,并希望将其更改为更合适的student_id。过程将如下所示:
-
更新返回的对象,包括一个名为
student_id的新字段,复制服务 B 中的先前值:GET /examinees (v3) [ { "examinee_id": <student id>, "student_id": <student id>, "name": <name of the examinee>, "exam_tries", <num tries> }, … ] -
更新并部署服务 A 以使用
student_id而不是examinee_id。 -
在可能调用服务 B 的其他服务中也进行相同的操作。
使用监控工具和日志来验证这一点!
-
从服务 B 中删除旧字段并部署服务:
GET /examinees (v3) [ { "examinee_id": <student id>, "student_id": <student id>, "name": <name of the examinee>, "exam_tries", <num tries> }, … ] -
从服务 B 中删除旧字段并部署服务。
这一步在技术上不是必需的,尽管出于维护原因删除 API 中的冗余内容会更好。但现实中的日常工作意味着它可能仍然存在,只是不再被访问。需要在保持现状的便利性和维护一个干净、更新的 API 之间找到良好的平衡。
这说明了我们如何在不停机的情况下部署更改,从部署的内容方面来说。但是,我们如何确保在部署新版本时服务始终可用?
无中断部署
为了允许在不停机的情况下进行持续发布,我们需要在服务仍然响应时部署向后兼容的更改。
要做到这一点,最佳盟友是负载均衡器。
我们在第五章、十二要素应用方法学和第八章、高级事件驱动结构中讨论了负载均衡器。它们非常有用!
成功平稳部署的过程需要更新服务的多个实例,如下所示:
我们将假设我们使用的是可以轻松创建和销毁的云实例或容器。请记住,可以将它们视为在单个服务器内部充当负载均衡器的 nginx 或其他类型的网络服务器下的工作者。这就是nginx reload命令的工作方式。
-
这是初始阶段,所有实例都使用要更新的服务的版本 1:
![图 自动生成的描述]()
图 16.1:起点
-
创建了一个带有服务 2 的新实例。请注意,它尚未添加到负载均衡器中。
![图 自动生成的描述]()
图 16.2:创建的新服务器
-
新版本已添加到负载均衡器中。目前,请求可以指向版本 1 或版本 2。如果我们遵循向后兼容的原则,则这不应该引起任何问题。
![图 自动生成的描述]()
图 16.3:包含在负载均衡器中的新服务器
-
为了保持实例数量不变,需要移除一个旧实例。谨慎的做法是首先在负载均衡器中禁用旧实例,这样就不会有新的请求被处理。在服务完成所有已进行的请求后(记住,不会向此实例发送新的请求),实例实际上被禁用,可以从负载均衡器中完全移除。
![图 自动生成的描述]()
图 16.4:从负载均衡器中移除旧服务器
-
可以销毁/回收旧实例。
![图 自动生成的描述]()
图 16.5:旧服务器已被完全移除
-
可以重复此过程,直到所有实例都达到版本 2。
![图 自动生成的描述]()
图 16.6:所有新服务器的最终阶段
有工具可以让我们自动执行此过程。例如,Kubernetes 在向容器推出更改时会自动执行此操作。我们还看到,像 nginx 或 Apache 这样的网络服务也会这样做。但是,在需要不寻常用例时,也可以手动执行或通过开发自定义工具来执行此过程。
功能标志
特性标志的想法是在配置更改下隐藏尚未准备好发布的功能。遵循小步增量快速迭代的原理,使得创建像新用户界面这样的大型变更变得不可能。
更进一步的是,这些大型变更可能会与其他变更并行发生。没有机会推迟整个发布过程 6 个月或更长时间,直到新用户界面正确工作。
创建一个长期存在的独立分支也不是一个好的解决方案,因为合并这个分支会变成一场噩梦。长期存在的分支难以管理,并且总是难以协作。
一个更好的解决方案是创建一个配置参数来激活或停用此特性。然后,该特性可以在特定环境中进行测试,而所有开发工作都以相同的速度继续。
这意味着其他变更,如错误修复或性能改进,仍在进行中并被部署。对大型新功能的工作也像往常一样频繁地合并到主分支。这意味着大型新功能已开发的部分也被发布到生产环境中,但它们尚未激活。
测试需要确保两种选项——特性激活和停用——都能正确工作,但以小步增量工作使得这相对容易。
特性将会以小步增量开发,直到准备好发布。最后一步就是通过配置更改简单地启用它。
注意,该特性可能对某些用户或环境是活跃的。这就是测试 beta 特性的方式:它们依赖于某些用户在完全发布之前能够访问该特性。最初,测试用户可能是组织内部的,如 QA 团队、经理、产品负责人等,他们可以提供关于特性的反馈,但使用生产数据。
这种技术使我们能够在不牺牲对它的微小增量方法的情况下,增强信心并发布大型特性。
变更的团队合作方面
软件架构不仅关乎技术,其中一部分高度依赖于沟通和人文因素。
在系统中实施变更的过程有一些影响团队合作的要素,需要考虑。
一些例子:
-
请记住,软件架构师的工作通常在于与多个团队进行沟通管理,这需要关注和具备在积极倾听团队的同时解释甚至协商设计变更的软技能。根据组织的规模,这可能具有挑战性,因为不同的团队可能有截然不同的文化。
-
组织中技术变革的步伐和接受程度与组织的文化(或亚文化)紧密相关。组织工作方式的变革通常发生得较慢,尽管能够快速改变技术的组织在调整组织范围内的变革方面往往更快。
-
同样,技术变革需要支持和培训,即使它完全在组织内部进行。在需要一些重大的技术变革时,确保有一个团队可以前往解决疑问和问题的联系人。许多问题可以通过解释为什么需要这种变革,并从这里着手来解决。
-
记得我们在第一章,软件架构导论中讨论康威定律时提到的软件架构的通信结构和架构结构之间的关系吗?一个方面的变化很可能会影响到另一个方面,这意味着足够大的架构变化将导致组织结构的重组,这本身也带来了一些挑战。
-
同时,变革可能会在受影响的团队中产生赢家和输家。一位工程师可能会感到威胁,因为他们将无法使用他们喜欢的编程语言。同样,他们的合作伙伴可能会感到兴奋,因为现在有机会使用他们喜欢的技术是令人难以置信的。
在人员流动或创建新团队时,这个问题在团队重组时尤其尖锐。影响开发速度的一个重要因素是拥有一个高效的团队,而改变团队会影响他们的沟通和效率。这种影响需要被分析和考虑。
-
维护需要作为组织日常运营的一部分定期引入。定期的维护应包括所有安全更新,但也包括升级操作系统版本、依赖项等任务。
处理这类常规维护的一般计划将提供清晰性和明确的期望。例如:在新版 LTS 发布后的三到六个月内将升级操作系统版本。这产生了可预测性,为遵循的明确目标,并使系统持续改进。同样,自动检测安全漏洞的工具使团队能够知道何时是时候升级代码或底层系统中的依赖项。
-
同样,技术债务的偿还需要作为一种习惯来引入,以确保系统健康。技术债务通常由团队自己发现,因为他们对它的理解最好,并且表现为代码变更速度的逐渐减慢。如果不解决技术债务,它将变得越来越复杂,使开发过程更加困难,并可能导致开发者的过度劳累。确保在失控之前留出时间来处理它。
作为一项一般性考虑,请记住,架构的变更需要由团队成员执行,并且信息需要正确传达和执行。与任何其他以沟通为重要组成部分的任务一样,这也带来了其自身的挑战和问题,因为与人们沟通,尤其是与多个人沟通,可以说是软件开发中最困难的任务之一。任何软件架构设计师都需要意识到这一点,并分配足够的时间以确保一方面能够充分传达计划,另一方面能够接收反馈并相应调整以获得最佳结果。
摘要
在本章中,我们描述了在开发和更改系统时保持系统运行的不同方面和挑战,包括其架构。
我们首先描述了架构可能需要调整和改变的不同方式。然后,我们继续讨论如何管理变更,包括在系统不可用的一段时间内进行变更的选项,并引入了维护窗口的概念,以清楚地传达对稳定性和变更的期望。
我们接下来讨论了当问题出现,系统出现问题时可能发生的不同情况。我们回顾了在发生此类事件后必要的持续改进和反思过程,并探讨了在风险增加的重大事件之前可以使用的准备流程,例如,由于预期增加系统负载的市场推动。
为了应对这种情况,我们接下来介绍了负载测试及其如何用于验证系统接受定义负载的能力,确保它准备好支持预期的流量。我们还讨论了创建版本控制系统的重要性,该系统能够清楚地传达当前部署的软件版本。
接下来,我们讨论了向后兼容性的关键方面以及它在确保小而快速的增量中的重要性,这些增量是持续改进和进步的关键。我们还讨论了功能标志如何帮助混合释放更大特征的过程,这些特征需要作为一个整体激活。
最后,我们描述了系统架构变化如何影响人类协作和沟通的不同方面,以及在进行系统变更时需要考虑这些因素,特别是在可能影响团队结构的变化,正如我们所看到的,这些变化往往会复制软件的结构。








浙公网安备 33010602011771号