Python-软件架构(全)

Python 软件架构(全)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

软件架构,或者为特定软件应用程序创建蓝图设计,绝非易事。软件架构中最大的两个挑战是保持架构与需求的同步,首先是随着需求的揭示或演变,其次是随着实现的构建和演变。

充满了示例和用例,本指南直接帮助您成为成功的软件架构师所需的一切。本书将帮助您了解 Python 的方方面面,以便您可以在 Python 中设计和构建高度可扩展、健壮、清晰和高性能的应用程序。

本书涵盖内容

第一章,“软件架构原则”,介绍了软件架构的主题,简要介绍了架构质量属性及其背后的一般原则。这将使您能够在软件架构原则和基本属性方面建立坚实的基础。

第二章,“编写可修改和可读的代码”,涵盖了开发架构质量属性,即可修改性和可读性。它将帮助您了解可维护性的架构质量属性以及在 Python 中编写代码的策略,以测试您的应用程序。

第三章,“可测试性-编写可测试代码”,帮助您了解可测试性的架构质量属性以及如何为可测试性设计 Python 应用程序。您还将了解可测试性和软件测试的各个方面,以及 Python 中可用的不同库和模块,以编写可测试的应用程序。

第四章,“良好的性能是值得的!”,涵盖了编写 Python 代码的性能方面。您将了解性能作为架构质量属性的知识,以及何时进行性能优化。您将学习在软件开发生命周期中何时进行性能优化。

第五章,“编写可扩展的应用程序”,讨论了编写可扩展应用程序的重要性。它讨论了实现应用程序可扩展性的不同方法,并讨论了使用 Python 的可扩展性技术。您还将了解可扩展性的理论方面和行业中的最佳实践。

第六章,“安全-编写安全代码”,讨论了架构的安全方面,并教授了编写安全应用程序的最佳实践和技术。您将了解要注意的不同安全问题,并学会在 Python 中构建从根本上安全的应用程序。

第七章,“Python 中的设计模式”,从实用程序员的角度概述了 Python 中的设计模式,并简要介绍了每种模式的理论背景。您将了解 Python 中对实用程序员实际有用的设计模式。

第八章,“Python 架构模式- Pythonic 方法”,从高层次的角度介绍了 Python 中的现代架构模式,同时提供了 Python 库和框架的示例,以实现这些模式的方法来解决高级架构问题。

第九章,“使用 Python 部署应用程序-使用 Python 进行 Devops”,涵盖了使用正确的方式在远程环境或云上轻松部署代码的方面。

第十章,“调试技术”,涵盖了 Python 代码的一些调试技术——从最简单的、策略性放置的打印语句到日志记录和系统调用跟踪,这对程序员非常有用,也有助于系统架构师指导他的团队。

本书所需内容

要运行本书中显示的大多数代码示例,您需要在系统上安装 Python 3。其他先决条件在相应的实例中提到。

本书适合谁

本书适用于有经验的 Python 开发人员,他们希望成为企业级应用程序的架构师,或者希望利用 Python 创建应用程序的有效蓝图的软件架构师。

约定

在本书中,您将找到许多文本样式,用于区分不同类型的信息。以下是一些示例和它们的含义解释。

文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄显示如下:“我们可以通过使用include指令来包含其他上下文。”

代码块设置如下:

class PrototypeFactory(Borg):
    """ A Prototype factory/registry class """

    def __init__(self):
        """ Initializer """

        self._registry = {}

    def register(self, instance):
        """ Register a given instance """

        self._registry[instance.__class__] = instance

    def clone(self, klass):
        """ Return clone given class """

        instance = self._registry.get(klass)
        if instance == None:
            print('Error:',klass,'not registered')
        else:
            return instance.clone()

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

[default]
exten => s,1,Dial(Zap/1|30)
exten => s,2,Voicemail(u100)
exten => s,102,Voicemail(b100)
exten => i,1,Voicemail(s0)

任何命令行输入或输出都写成如下形式:

>>> import hash_stream
>>> hash_stream.hash_stream(open('hash_stream.py'))
'30fbc7890bc950a0be4eaa60e1fee9a1'

新术语重要单词以粗体显示。您在屏幕上看到的单词,例如菜单或对话框中的单词,会以这种形式出现在文本中:“单击下一步按钮会将您移至下一个屏幕。”

注意

警告或重要说明会出现在这样的框中。

提示

提示和技巧会以这种形式出现。

第一章:软件架构原则

这是一本关于 Python 的书。与此同时,它也是一本关于软件架构及其在软件开发生命周期中涉及的各种属性的书。

为了让你理解并结合这两个方面,这对于从本书中获得最大价值是至关重要的,重要的是要掌握软件架构的基本原理,与之相关的主题和概念,以及软件架构的各种质量属性。

许多软件工程师在组织中担任高级角色时,经常对软件设计和架构的定义以及它们在构建可测试、可维护、可扩展、安全和功能性软件中的作用有着非常不同的解释。

尽管该领域有大量的文献,无论是传统的书籍形式还是互联网上,但我们中的从业者往往对这些非常重要的概念产生混淆的印象。这往往是由于学习技术而不是学习技术在构建系统中的基本设计和架构原则方面所带来的压力。这在软件开发组织中是一种常见做法,其中交付可工作的代码的压力往往压倒和掩盖了其他一切。

像本书这样的一本书,努力超越中间路径,将软件开发中与其架构质量属性相关的颇为晦涩的方面与使用编程语言、库和框架构建软件的平凡细节联系起来——在本例中,使用 Python 及其开发者生态系统。

这个开场章节的作用是揭开这些概念的神秘面纱,并以非常清晰的方式向读者解释,为他准备好理解本书其余部分的内容。希望到本书结束时,这些概念及其实际细节将对读者构成一个连贯的知识体系。

我们将立即开始这条道路,将这一章大致分为以下几个部分:

  • 定义软件架构

  • 软件架构与设计

  • 软件架构的方面

  • 软件架构的特点

  • 为什么软件架构很重要?

  • 系统与企业架构

  • 架构质量属性

  • 可修改性

  • 可测试性

  • 可扩展性/性能

  • 安全性

  • 可部署性

定义软件架构

关于这个主题的文献中有各种各样的软件架构定义。一个简单的定义如下:

软件架构是对软件系统的子系统或组件以及它们之间的关系的描述。

以下是来自IEEE技术的软件密集型系统架构描述的推荐实践的更正式定义:

“架构是一个系统在其组件中体现的基本组织,它们相互之间的关系,以及与环境的关系,以及指导其设计和演变的原则。”

如果花一些时间在网上搜索,可能会找到无数关于软件架构的定义。措辞可能不同,但所有的定义都指的是软件架构的一些核心、基本方面。

软件架构与设计

在作者的经验中,系统的软件架构与其设计的问题似乎经常出现在在线和离线论坛中。因此,让我们花一点时间来理解这一方面。

尽管这两个术语有时可以互换使用,但架构与设计的粗略区别可以总结如下:

  • 架构涉及系统中的描述结构和交互的更高层次。它涉及那些需要对系统的骨架做出决策的问题,不仅涉及其功能,还涉及其组织、技术、业务和质量属性。

  • 设计是关于系统的部分或组件的组织以及涉及制作系统的子系统。这里的问题通常更接近于代码或相关模块,比如:

  • 将代码分割成哪些模块?如何组织它们?

  • 将不同的功能分配给哪些类(或模块)?

  • 我应该为类“C”使用哪种设计模式?

  • 我的对象在运行时如何交互?传递了哪些消息,如何组织这种交互?

软件架构是关于整个系统的设计,而软件设计大多是关于细节,通常是关于构成这些子系统的各种子系统和组件的实现级别。

换句话说,设计这个词在两种情境中都出现了,不过前者的抽象程度和范围要比后者高得多。

对于软件架构和设计,即架构模式和设计模式,都有丰富的知识体系可供参考。我们将在本书的后续章节中讨论这两个主题。

软件架构的特点

在正式的 IEEE 定义和之前给出的相当不正式的定义中,我们发现了一些共同的、反复出现的主题。为了进一步讨论软件架构,理解它们是很重要的:

  • 系统:系统是以特定方式组织的组件集合,以实现特定的功能。软件系统是这种软件组件的集合。系统通常可以分为子系统。

  • 结构:结构是根据指导原则或原则组合或组织在一起的一组元素。这些元素可以是软件或硬件系统。根据观察者的情境,软件架构可以展示不同级别的结构。

  • 环境:软件系统构建的上下文或环境,对其架构有直接影响。这些上下文可以是技术、业务、专业、运营等。

  • 利益相关者:任何对系统及其成功感兴趣或关注的人或人群。利益相关者的例子包括架构师、开发团队、客户、项目经理、营销团队等。

现在您已经了解了软件架构的一些核心方面,让我们简要列出一些其特点。

软件架构的特点

所有软件架构都具有一组共同的特征。让我们在这里看一些最重要的特征。

架构定义了一个结构

系统的架构最好表示为系统的结构细节。实践者通常会将系统架构绘制为结构组件或类图,以表示子系统之间的关系。

例如,以下架构图描述了一个应用程序的后端,该应用程序从分层数据库系统中读取数据,使用 ETL 过程加载:

架构定义了一个结构

示例架构图显示系统结构

结构提供了对架构的洞察,并为分析架构提供了独特的视角,以便考虑其质量属性。

以下是一些示例:

  • 运行时结构,即运行时创建的对象以及它们的交互,通常决定了部署架构。部署架构与可伸缩性、性能、安全性和互操作性等质量属性密切相关。

  • 模块结构,即代码如何分解和组织成模块和包以进行任务分解,通常直接影响系统的可维护性和可修改性(可扩展性)。解释如下:

  • 代码的组织方式旨在可扩展性,通常会将父类放在单独定义良好的包中,并配有适当的文档和配置,这样外部模块就可以轻松地进行扩展,而无需解决太多的依赖关系。

  • 依赖于外部或第三方开发人员(库、框架等)的代码通常会提供设置或部署步骤,手动或自动地从外部来源获取这些依赖项。这样的代码还会提供文档(README、INSTALL 等),清楚地记录这些步骤。

架构选择了一组核心元素

良好定义的架构清楚地捕捉了构建系统核心功能所需的一组核心结构元素,并对系统产生持久影响。它并不打算记录系统的每个组件的所有内容。

例如,描述用户与用于浏览网页的 Web 服务器进行交互的架构师(典型的客户端/服务器架构)主要关注两个组件:用户的浏览器(客户端)和远程 Web 服务器(服务器),它们构成了系统的核心元素。

系统可能有其他组件,例如从服务器到客户端的路径上有多个缓存代理,或者服务器上有一个远程缓存,可以加快网页传送速度。然而,这不是架构描述的重点。

架构捕捉早期设计决策

这是先前描述的特征的必然结果。帮助架构师专注于系统的一些核心元素(及其相互作用)的决策是对系统的早期设计决策的结果。因此,这些决策由于其初始权重在系统的进一步发展中起着重要作用。

例如,架构师可能在仔细分析系统需求后做出以下早期设计决策:

  • 系统将仅部署在 Linux 64 位服务器上,因为这满足了客户的要求和性能约束。

  • 系统将使用 HTTP 作为实现后端 API 的协议

  • 系统将尝试使用 HTTPS 来传输从后端到前端的敏感数据的 API,使用 2048 位或更高的加密证书

  • 系统的编程语言将是 Python 用于后端,Python 或 Ruby 用于前端

注意

第一个决定在很大程度上冻结了系统的部署选择,限定了特定操作系统和系统架构。接下来的两个决定在实现后端 API 方面具有很大的影响。最后一个决定冻结了系统的编程语言选择。

早期的设计决策需要在仔细分析需求并将其与约束进行匹配后做出,例如组织、技术、人员和时间约束。

架构管理利益相关者的需求

系统的设计和构建最终是为了满足利益相关者的要求。然而,由于这些要求往往是矛盾的,因此不可能完全满足每个利益相关者的要求。以下是一些例子:

  • 市场团队关心拥有功能齐全的软件应用程序,而开发团队关心在添加许多功能时的功能蔓延和性能问题。

  • 系统架构师关注使用最新技术将其部署扩展到云端,而项目经理关注这种技术部署对其预算的影响。最终用户关注正确的功能、性能、安全性、可用性和可靠性,而开发组织(架构师、开发团队和经理)关注在保持项目进度和预算范围内交付所有这些质量的同时。

  • 一个好的架构尽力平衡这些要求,通过权衡,提供具有良好质量属性的系统,同时保持人力和资源成本在限制范围内。

  • 架构还为利益相关者提供了一个共同的语言,使他们能够通过表达这些约束来有效地进行沟通,并帮助架构师朝着最能满足这些要求和它们的权衡的架构前进。

架构影响组织结构

系统结构描述的架构往往直接映射到构建这些系统的团队的结构。

例如,一个架构可能有一个数据访问层,描述了一组读写大量数据的服务——这样的系统自然会被分配给数据库团队,他们已经具备所需的技能。

由于系统的架构是对自上而下结构的最佳描述,因此它经常被用作任务分解结构的基础。因此,软件架构往往直接影响构建它的组织结构:

架构影响组织结构

搜索 Web 应用程序的系统架构

以下图表显示了构建此应用程序的团队结构的映射:

架构影响组织结构

架构受其环境的影响

环境对架构必须运行的外部约束或限制。在文献中,这些通常被称为上下文中的架构 [参考:Bass,Kazman]。以下是一些例子:

  • 质量属性要求:在现代 Web 应用程序中,很常见地将应用程序的可扩展性和可用性要求作为早期的技术约束,并在架构中加以捕捉。这是从业务角度看的技术背景的一个例子。

  • 标准符合:在一些组织中,软件通常有一大套管理标准,特别是在银行、保险和医疗保健领域,这些标准被添加到架构的早期约束中。这是一个外部技术背景的例子。

  • 组织约束:通常可以看到,那些具有某种架构风格经验或一组团队在某些编程环境中操作的组织(J2EE 是一个很好的例子),更倾向于采用类似的架构来减少成本,并确保由于当前对这些架构和相关技能的投资而提高生产力。这是一个内部业务背景的例子。

  • 专业背景:除了这些外部背景之外,架构师对系统架构的选择大多是根据他独特经验的选择。架构师通常会继续在新项目中使用他过去取得最大成功的一套架构选择。

架构选择也源自个人的教育和专业培训,以及来自专业同行的影响。

架构记录了系统

每个系统都有一个架构,无论它是否被正式记录。然而,适当记录的架构可以作为系统的有效文档。由于架构捕获了系统的初始需求、约束和利益相关者的权衡,适当记录它是一个很好的做法。文档可以作为后续培训的基础。它还有助于持续的利益相关者沟通,并根据不断变化的需求进行架构的后续迭代。

记录架构的最简单方法是为系统的不同方面和组织架构创建图表,例如组件架构、部署架构、通信架构以及团队或企业架构。

可以早期捕获的其他数据包括系统需求、约束、早期设计决策以及这些决策的基本原理。

架构通常符合一种模式

大多数架构符合一定的在实践中取得了很大成功的风格。这些被称为架构模式。此类模式的示例包括客户端-服务器、管道和过滤器、基于数据的架构等。当架构师选择现有模式时,他可以参考和重用许多与这些模式相关的现有用例和示例。在现代架构中,架构师的工作归结为混合和匹配现有的这些可用模式集来解决手头的问题。

例如,以下图表显示了客户端-服务器架构的示例:

架构通常符合一种模式

客户端-服务器架构示例

以下图表描述了另一种常见的架构模式,即管道和过滤器架构,用于处理数据流:

架构通常符合一种模式

管道和过滤器架构示例

我们将在本书的后面看到架构模式的示例。

软件架构的重要性

到目前为止,我们已经讨论了软件架构的基本原则,并且也看到了一些特征。当然,这些部分假定了软件架构是重要的,并且是软件开发过程中的关键步骤。

现在是时候扮演魔鬼的辩护人,回顾软件架构并提出一些关于它的存在性问题,如下所示:

  • 为什么软件架构?

  • 为什么软件架构很重要?

  • 为什么不建立一个没有正式软件架构的系统?

让我们来看看软件架构提供的关键见解,这些见解在非正式的软件开发过程中将会缺失。我们只关注以下表中系统的技术或开发方面:

方面 洞察/影响 示例
架构选择要为系统优化的质量属性。 诸如可伸缩性、可用性、可修改性、安全性等系统方面取决于在选择架构时的早期决策和权衡。通常你会在一个属性和另一个属性之间进行权衡。 一个优化了可伸缩性的系统必须使用分散式架构来开发,其中元素之间没有紧密耦合。例如:微服务、代理。
架构有助于早期原型设计。 定义架构允许开发组织尝试并构建早期原型,这可以为系统的行为提供宝贵的见解,而无需自上而下地构建完整的系统。 许多组织快速构建服务的原型——通常是仅构建这些服务的外部 API 并模拟其余行为。这允许进行早期集成测试,并及早解决架构中的交互问题。
架构允许系统逐个构建组件。 有一个明确定义的架构可以重复使用和组装现有的、现成的组件,以实现功能,而不必从头开始实现所有内容。 提供服务的现成构件的库或框架。例如:Django/RoR 等 Web 应用框架,以及 Celery 等任务分发框架。
架构有助于管理系统的变更。 架构允许架构师以受影响的组件和未受影响的组件来界定系统的变更。这有助于在实现新功能、性能修复等时将系统变更最小化。例如,如果架构实施正确,对系统的数据库读取进行性能修复只需要对数据库和数据访问层(DAL)进行更改,根本不需要触及应用程序代码。例如,这就是大多数现代 Web 框架的构建方式。

还有一些与系统的业务背景相关的其他方面,架构为此提供了宝贵的见解。然而,由于这本书主要讨论软件架构的技术方面,我们将讨论限制在前表中给出的内容。

现在,让我们来探讨第二个问题:

为什么不建立一个没有正式软件架构的系统呢?

如果您迄今为止一直在认真地跟随这些论点,那么很容易看出答案。然而,可以总结为以下几个陈述:

  • 每个系统都有一个架构,无论是否有文档记录

  • 记录架构使其正式化,使其能够在利益相关者之间共享,从而使变更管理和迭代开发成为可能

  • 当您有一个明确定义和记录的正式架构时,所有其他软件架构的好处和特征都可以被利用

  • 您可能仍然能够在没有正式架构的情况下工作和构建一个功能性的系统,但这不会产生一个可扩展和可修改的系统,很可能会产生一个与原始要求相去甚远的一组质量属性的系统

系统与企业架构

您可能已经听说过架构师这个术语。在软件行业中,以下角色头衔对架构师来说是相当常见的:

  • 技术架构师

  • 安全架构师

  • 信息架构师

  • 基础架构架构师

您可能也听说过系统架构师这个术语,也许还有企业架构师,也可能是解决方案架构师。有趣的问题是:这些人做什么?

让我们试着找到这个问题的答案。

企业架构师审视组织的整体业务和组织战略,并应用架构原则和实践指导组织通过业务、信息、流程和技术变化,以执行他们的战略。企业架构师通常更关注战略,而较少关注技术。其他架构师角色负责自己的子系统和流程。例如:

  • 技术架构师:技术架构师关注组织中使用的核心技术(硬件/软件/网络)。安全架构师创建或调整应用程序中使用的安全策略,以适应组织的信息安全目标。信息架构师提出架构解决方案,使信息能够以有利于组织业务目标的方式在应用程序之间可用。

这些特定的建筑角色都关注自己的系统和子系统。因此,这些角色中的每一个都是系统架构师角色。

这些架构师帮助企业架构师了解他们负责的每个业务领域的细节,这有助于企业架构师获取有助于制定业务和组织战略的信息。

  • 系统架构师:系统架构师通常更注重技术,较少关注战略。在一些面向服务的软件组织中,通常会有解决方案架构师,他将不同的系统结合起来为特定客户创建解决方案。在这种情况下,不同的架构师角色通常会根据组织的规模以及项目的特定时间和成本要求进行合并。

  • 解决方案架构师:解决方案架构师通常处于战略与技术关注以及组织与项目范围之间的中间位置。

以下示意图描述了组织中不同层次的技术应用数据人员流程业务,并清晰地展示了架构师角色的关注领域:

系统与企业架构

企业与系统架构师

让我们稍微讨论一下前面的图表,以了解它所呈现的情况。

系统架构师位于图表的左下方,关注企业的系统组件。他的关注点是驱动企业的应用程序、它们的数据以及驱动应用程序的硬件和软件堆栈。

另一方面,企业架构师位于顶部,从顶层视角看待企业,包括业务目标和人员,而不仅仅是支撑组织的基础系统。业务流程的垂直堆栈将支撑组织的技术组件与其人员和业务组件连接起来。这些流程是由企业架构师与其他利益相关者讨论定义的。

既然你已经了解了企业和系统架构背后的图景,让我们来看一些正式的定义:

"企业架构是定义组织结构和行为的概念蓝图。它确定了组织结构、流程、人员和信息流如何与其核心目标对齐,以有效实现当前和未来的目标。"

"系统架构是系统的基本组织,由其结构和行为视图表示。结构由系统的组件确定,行为由它们之间的关系以及与外部系统的互动确定。"

企业架构师关注的是组织中不同元素及其相互作用如何调整以有效实现组织目标。在这项工作中,他不仅需要组织中的技术架构师的支持,还需要项目经理和人力资源专业人员等组织管理人员的支持。

另一方面,系统架构师关注核心系统架构如何映射到软件和硬件架构,以及人与系统组件的各种细节交互。他的关注点永远不会超出系统及其交互所定义的范围。

以下图表描述了我们迄今讨论的不同架构师角色的不同关注领域和范围:

系统与企业架构

软件组织中各种架构师角色的范围和重点

架构质量属性

现在让我们关注本书其余部分的主题——架构质量属性。

在前面的部分中,我们讨论了架构如何平衡和优化利益相关者的需求。我们还看到了一些相互矛盾的利益相关者需求的例子,架构师通过选择必要的权衡来平衡这些需求。

术语质量属性已被用来宽泛地定义架构为之做出权衡的一些方面。现在是正式定义什么是架构质量属性的时候了:

"质量属性是系统的可度量和可测试的属性,可用于评估系统在其规定环境中相对于其非功能方面的性能"

有许多方面符合架构质量属性的一般定义。然而,在本书的其余部分,我们将专注于以下质量属性:

  • 可修改性

  • 可测试性

  • 可扩展性和性能

  • 可用性

  • 安全性

  • 可部署性

可修改性

许多研究表明,典型软件系统的成本约 80%发生在初始开发和部署之后。这显示了可修改性对系统初始架构的重要性。

可修改性可以定义为对系统进行更改的容易程度,以及系统调整到变化的灵活性。这是一个重要的质量属性,因为几乎每个软件系统在其生命周期中都会发生变化——修复问题,添加新功能,进行性能改进等。

从架构师的角度来看,对可修改性的兴趣在于以下方面:

  • 难度:对系统进行更改的容易程度

  • 成本:对进行更改所需的时间和资源而言

  • 风险:与对系统进行更改相关的任何风险

现在,我们在这里谈论的是什么样的变化?是对代码的改动,对部署的改动,还是对整个架构的改动?

答案是:它可以在任何级别。

从架构的角度来看,这些变化通常可以在以下三个级别进行捕捉:

  1. 本地:本地变化只影响特定元素。该元素可以是代码的一部分,如函数、类、模块,或者是配置元素,如 XML 或 JSON 文件。变化不会级联到任何相邻元素或系统的其余部分。本地变化是最容易进行的,也是最不冒险的。这些变化通常可以通过本地单元测试快速验证。

  2. 非本地:这些变化涉及多个元素。以下是一些例子:

  • 修改数据库模式,然后需要在应用程序代码中表示该模式的模型类中进行级联。

  • 在 JSON 文件中添加一个新的配置参数,然后需要由解析器解析文件和/或使用参数的应用程序进行处理。

非本地变化比本地变化更难进行,需要仔细分析,并在可能的情况下进行集成测试,以避免代码回归。

  1. 全局:这些变化要么涉及自顶向下的架构变化,要么涉及全局级别的元素变化,这些变化会级联到软件系统的重要部分。以下是一些例子:
  • 将系统架构从 RESTful 更改为基于消息传递(SOAP、XML-RPC 等)的 Web 服务

  • 将 Web 应用程序控制器从 Django 更改为基于 Angular-js 的组件

  • 性能变化要求所有数据在前端预加载,以避免在线新闻应用程序中的任何内联模型 API 调用

这些变化是最具风险的,也是最昂贵的,涉及资源、时间和金钱。架构师需要仔细审查变化可能带来的不同情景,并让他的团队通过集成测试对其进行建模。在这类大规模变化中,模拟可以非常有用。

以下表格显示了不同系统可修改性水平的成本风险之间的关系:

等级 成本 风险
本地
非本地
全局

代码级别的可修改性也与其可读性直接相关:

“代码越可读,就越容易修改。代码的可修改性与其可读性成反比。”

可修改性方面也与代码的可维护性相关。代码模块中元素耦合度非常紧密的话,修改的可能性就会比元素耦合度较松的模块小得多——这就是可修改性的耦合方面。

同样,一个类或模块如果没有清晰地定义其角色和责任,就会比另一个定义了明确责任和功能的类或模块更难修改。这个方面被称为软件模块的内聚性

以下表格显示了假设模块 A 的内聚性耦合可修改性之间的关系。假设耦合是从这个模块到另一个模块 B:

内聚性 耦合 可修改性

从前面的表格可以清楚地看出,内聚性更高,耦合更低是代码模块可修改性的最佳情况。

影响可修改性的其他因素如下:

  • 模块的大小(代码行数):大小增加时,可修改性减少。

  • 在模块上工作的团队成员数量:通常,当更多的团队成员在模块上工作时,模块变得不太可修改,因为合并和维护统一的代码基础变得更加复杂。

  • 模块的外部第三方依赖:外部第三方依赖的数量越多,修改模块就越困难。这可以被视为模块耦合方面的延伸。

  • 错误使用模块 API:如果有其他模块使用模块的私有数据而不是(正确地)使用其公共 API,那么修改模块就会更加困难。在组织中确保模块的正确使用标准以避免这种情况非常重要。这可以被视为紧密耦合的极端情况。

可测试性

可测试性指的是软件系统通过测试展示其故障的程度。可测试性也可以被视为软件系统隐藏其故障程度的程度——系统越可测试,就越难以隐藏其故障。

可测试性也与软件系统行为的可预测性相关。系统越可预测,就越允许可重复的测试,并且可以基于一组输入数据或标准开发标准测试套件。不可预测的系统很难进行任何形式的测试,或者在极端情况下根本无法测试。

在软件测试中,通常通过发送一组已知输入来控制系统的行为,然后观察系统的一组已知输出。这两者结合起来形成一个测试用例。一个测试套件或测试工具通常包括许多这样的测试用例。

测试断言是用于在测试用例的输出与给定输入的预期输出不匹配时使测试用例失败的技术。这些断言通常在测试执行阶段的特定步骤手动编码,以检查测试用例的不同步骤的数据值:

可测试性

简单单元测试用例函数 f('X') = 'Y'的代表性流程图

前面的图表显示了一个代表性流程图的例子,用于可测试函数“f”,输入为“X”,预期输出为“Y”

为了在故障发生时重新创建会话或状态,通常使用记录/回放策略。这使用专门的软件(如 Selenium),记录导致特定故障的所有用户操作,并将其保存为测试用例。通过使用相同的软件重放测试用例来再现测试,该软件尝试模拟相同的测试用例;这是通过重复相同的 UI 操作集和顺序来完成的。

可测试性也与代码的复杂性有关,与可修改性非常相似。当系统的部分可以被隔离并独立于系统的其余部分工作时,系统变得更具可测试性。换句话说,耦合度低的系统比耦合度高的系统更具可测试性。

测试的另一个方面与前面提到的可预测性有关,即减少非确定性。在编写测试套件时,我们需要将要测试的元素与系统的其他部分隔离开来,这些部分往往表现出不可预测的行为,以便测试元素的行为变得可预测。

一个例子是多线程系统,它响应系统其他部分引发的事件。整个系统可能相当不可预测,不适合重复测试。相反,需要将事件子系统分离出来,并可能模拟其行为,以便可以控制这些输入,并且接收事件的子系统变得可预测,因此可测试。

以下示意图解释了系统的可测试性和可预测性与其组件之间的耦合内聚之间的关系:

可伸缩性

系统的可测试性和可预测性与耦合和内聚的关系

可伸缩性

现代 Web 应用程序都是关于扩展的。如果您是现代软件组织的一部分,很可能您已经听说过或者正在开发一款为云端编写的应用程序,它能够根据需求弹性扩展。

系统的可伸缩性是指其在保持性能在可接受范围内的情况下,能够容纳不断增加的工作负载的能力。

在软件系统的背景下,可伸缩性通常分为两类,如下所示:

  • 水平可伸缩性:水平可伸缩性意味着通过向软件系统添加更多计算节点来扩展/缩减系统。过去十年中集群计算的进步催生了商业水平可伸缩的弹性系统作为 Web 服务的出现。一个著名的例子是亚马逊网络服务。在水平可伸缩系统中,通常数据和/或计算是在单元或节点上进行的,通常是在称为虚拟专用服务器(VPS)的商品系统上运行的虚拟机。通过向系统添加 n 个或更多节点,通常由负载均衡器进行前端处理,可实现“n”倍的可伸缩性。扩展意味着通过添加更多节点来扩展可伸缩性,而缩减意味着通过移除现有节点来减少可伸缩性。

显示水平扩展 Web 应用程序服务器的示例部署架构

  • 垂直可伸缩性:垂直可伸缩性涉及向系统中的单个节点添加或移除资源。通常是通过向集群中的单个虚拟服务器添加或移除 CPU 或 RAM(内存)来实现的。前者称为扩展,后者称为缩减。另一种扩展是增加系统中现有软件进程的容量,通常是通过增加可用于应用程序的进程或线程数量来实现的。一些例子如下:

  • 通过增加其工作进程的数量来增加 Nginx 服务器进程的容量

  • 通过增加其最大连接数来增加 PostgreSQL 服务器的容量

性能

系统的性能与其可伸缩性相关。系统的性能可以定义如下:

“计算机系统的性能是系统使用给定的计算资源单位所完成的工作量。工作/单位比率越高,性能越高。”

用于衡量性能的计算资源单位可以是以下之一:

  • 响应时间:函数或任何执行单元在实时(用户时间)和时钟时间(CPU 时间)方面执行所需的时间。

  • 延迟:系统获取刺激并提供响应所需的时间。一个例子是 Web 应用程序的请求-响应循环完成所需的时间,从最终用户的角度来衡量。

  • 吞吐量:系统处理信息的速率。性能更高的系统通常具有更高的吞吐量,相应地具有更高的可伸缩性。一个例子是电子商务网站的吞吐量,以每分钟完成的交易数量来衡量。

性能与可伸缩性密切相关,特别是纵向可伸缩性。一个在内存管理方面表现出色的系统将通过添加更多 RAM 轻松实现纵向扩展。

同样,具有多线程工作负载特性并且针对多核 CPU 进行了最佳编写的系统,将通过添加更多 CPU 核来扩展。

水平可伸缩性被认为与系统在其自己的计算节点内的性能没有直接联系。然而,如果系统以一种不利用网络的方式编写,从而产生网络延迟问题,它可能会在水平方面有效地扩展,因为在网络延迟上花费的时间会抵消通过分发工作获得的可伸缩性增益。

一些动态编程语言,如 Python,在纵向扩展时存在内置的可伸缩性问题。例如,Python(CPython)的全局解释器锁(GIL)阻止它通过多个线程充分利用可用的 CPU 核进行计算。

可用性

可用性是指软件系统在需要时执行其操作的准备性质。

系统的可用性与其可靠性密切相关。系统越可靠,可用性就越高。

另一个修改可用性的因素是系统从故障中恢复的能力。一个系统可能非常可靠,但如果系统无法从其子系统的完全或部分故障中恢复,那么它可能无法保证可用性。这一方面被称为恢复

系统的可用性可以定义如下:

“系统的可用性是系统在随机调用或调用时完全可操作状态的程度。”

在数学上,这可以表示如下:

可用性 = MTBF / (MTBF + MTTR)

看一下前面公式中使用的以下术语:

  • MTBF:平均故障间隔时间

  • MTTR:平均修复时间

这通常被称为系统的任务可执行率

可用性的技术与恢复技术密切相关。这是因为系统永远无法 100%可用。相反,需要计划故障和从故障中恢复的策略,这直接决定了可用性。这些技术可以分类如下:

  • 故障检测:检测故障并采取行动的能力有助于避免系统或系统部分完全不可用的情况。故障检测通常涉及监视、心跳和 ping/echo 消息等步骤,这些消息被发送到系统中的节点,并测量响应以计算节点是活着的、死了的还是正在失败的。

  • 故障恢复:一旦检测到故障,下一步是准备系统从故障中恢复,并使其达到可以被认为是可用的状态。这里通常使用的策略包括热备份/冷备份(主/备份冗余)、回滚、优雅降级和重试。

  • 故障预防:这种方法使用主动方法来预见和防止故障发生,以便系统没有机会进行恢复。

系统的可用性与其数据的一致性密切相关,根据 CAP 定理,系统在网络分区的情况下在一致性和可用性之间存在理论上的限制。CAP 定理指出系统可以选择在一致性和可用性之间进行权衡,通常导致两种类型的系统,即 CP(一致性和网络故障容忍)和 AP(可用性和网络故障容忍)。

可用性还与系统的可扩展策略、性能指标和安全性相关。例如,高度横向扩展的系统将具有非常高的可用性,因为它允许负载均衡器快速确定非活动节点并将其从配置中移除。

一个试图扩展的系统可能需要仔细监控其性能指标。即使系统所在的节点完全可用,如果软件进程受到系统资源(如 CPU 时间或内存)的挤压,系统可能会出现可用性问题。这就是性能测量变得至关重要的地方,系统的负载因子需要被监控和优化。

随着 Web 应用程序和分布式计算的日益流行,安全也是影响可用性的一个方面。恶意黑客可能对您的服务器发动远程拒绝服务攻击,如果系统没有针对这种攻击做出防范,可能导致系统变得不可用或只部分可用。

安全

在软件领域,安全可以定义为系统避免未经授权访问对其数据和逻辑造成损害的能力,同时继续向其他经过适当认证的系统和角色提供服务。

安全危机或攻击发生在系统被有意破坏,以获取非法访问、损害其服务、复制或修改其数据,或拒绝合法用户访问的情况下。

在现代软件系统中,用户与具有对系统不同部分独占权限的特定角色相关联。例如,具有数据库的典型 Web 应用程序可能定义以下角色:

  • 用户:系统的最终用户,具有登录和访问自己私人数据的权限

  • dbadmin:数据库管理员,可以查看、修改或删除所有数据库数据

  • 报告:报告管理员,只对处理报告生成的数据库和代码部分具有管理员权限

  • 管理员:超级用户,对整个系统具有编辑权限

通过用户角色分配系统控制的方式称为访问控制。访问控制通过将用户角色与某些系统特权关联起来,从而将实际用户登录与这些特权授予的权限分离开来。

这个原则是安全的授权技术。

安全的另一个方面是与交易相关的,每个人都必须验证对方的真实身份。公钥加密、消息签名等是常用的技术。例如,当您用您的 GPG 或 PGP 密钥签署电子邮件时,您正在验证自己——发送此消息的人确实是我,A 先生——给您在电子邮件另一端的朋友 B。这个原则是安全的认证技术。

安全的其他方面如下:

  • 完整性:这些技术用于确保数据或信息在传输到最终用户的过程中没有被篡改。例如消息哈希、CRC 校验和等。

  • 来源:这些技术用于向最终接收者保证数据的来源与其所宣称的完全相同。这些技术包括 SPF、Sender-ID(用于电子邮件)、使用 SSL 的网站的公钥证书和链等。

  • 真实性:这些是将消息的完整性和来源结合在一起的技术。这确保了消息的作者不能否认消息的内容以及其来源(他/她自己)。这通常使用数字证书机制

部署性

部署性是软件质量属性之一,但并非对软件至关重要。然而,在本书中,我们对这一方面感兴趣,因为它在 Python 编程语言的生态系统的许多方面以及对程序员的实用性中起着关键作用。

部署性是指软件从开发环境到生产环境的易用程度。这更多地取决于技术环境、模块结构和构建系统所使用的编程运行时/语言的功能,与系统的实际逻辑或代码无关。

以下是一些影响部署性的因素:

  • 模块结构:如果您的系统将代码组织成明确定义的模块/项目,将系统分隔成易于部署的子单元,那么部署将更加容易。另一方面,如果代码组织成单体项目,只需进行一次设置步骤,那么将很难将代码部署到多节点集群中。

  • 生产环境与开发环境的对比:拥有与开发环境结构非常相似的生产环境可以使部署变得简单。当环境相似时,开发人员/Devops 团队使用的相同一组脚本和工具链可以用于将系统部署到开发服务器以及生产服务器,只需进行少量更改—主要是在配置方面。

  • 开发生态系统支持:拥有成熟的工具链支持系统运行时,可以自动建立和满足依赖关系的配置,可以增加部署性。像 Python 这样的编程语言在其开发生态系统中拥有丰富的支持,为 Devops 专业人员提供了丰富的工具。

  • 标准化配置:保持开发和生产环境的配置结构(文件、数据库表等)相同是一个好主意。实际对象或文件名可以不同,但如果配置结构在两个环境中差异很大,部署性会降低,因为需要额外的工作来将环境的配置映射到其结构。

  • 标准化基础设施:众所周知,将部署保持在同质化或标准化的基础设施集上极大地有助于部署性。例如,如果您将前端应用程序标准化为在 4GB RAM、基于 Debian 的 64 位 Linux VPS 上运行,那么很容易自动化这些节点的部署—可以使用脚本,也可以使用提供商如亚马逊的弹性计算方法—并在开发和生产环境中保持一组标准脚本。另一方面,如果您的生产部署包括异构基础设施,比如混合使用 Windows 和 Linux 服务器,容量和资源规格各不相同,那么对于每种类型的基础设施,工作量通常会增加,从而降低部署性。

  • 容器的使用:容器软件的使用,由 Docker 和 Vagrant 等技术的出现所推广,已成为在服务器上部署软件的最新趋势。使用容器可以使您标准化软件,并通过减少启动/停止节点所需的开销,使部署变得更加容易,因为容器不会带来完整虚拟机的开销。这是一个值得关注的有趣趋势。

总结

在本章中,我们了解了软件架构。我们看到了软件架构的不同方面,并了解到每个架构都包括一个系统,该系统在其利益相关者的环境中运作。我们简要地看了软件架构与软件设计的区别。

我们继续研究了软件架构的各种特征,比如软件架构如何定义结构、选择核心元素并连接利益相关者。

接着,我们讨论了软件架构对组织的重要性,以及为软件系统定义正式软件架构的好处。

接下来讨论了组织中架构师的不同角色。我们看到了系统架构师在组织中扮演的各种角色,以及企业架构师的关注重点与系统架构师的不同。战略和技术广度与技术深度的关注重点通过图示得到了澄清。

然后,我们讨论了本书主题的要素——架构质量属性。我们定义了质量属性是什么,然后详细讨论了可修改性、可测试性、可扩展性/性能、安全性和可部署性等质量属性。在讨论这些属性的细节时,我们讨论了它们的定义、技术以及它们之间的关系。

有了本章作为基础,我们现在准备好去探讨这些质量属性,然后详细讨论使用 Python 编程语言实现它们的各种策略和技术。这构成了本书的其余部分。

在下一章中,我们将从本章讨论的第一个质量属性开始,即可修改性及其相关属性可读性。

第二章:编写可修改和可读的代码

在第一章中,我们讨论了软件架构的各个方面,并介绍了涉及的术语的一些定义。我们看了架构师应该关注的软件架构的不同方面。在章末,我们讨论了在构建系统时架构师应该关注的各种架构质量属性。我们详细讨论了每个属性,看了一些定义,以及在构建系统以实现这些属性时应该牢记的各种关注点。

从本章开始,我们将逐一关注这些质量属性,并逐章详细讨论它们。我们将深入研究一个属性,比如它的各种因素、实现它的技术、在编程时要牢记的方面等等。由于本书的重点是 Python 及其生态系统,我们还将查看 Python 为实现和维护这些质量属性提供的各种代码示例和第三方软件支持。

本章重点关注可修改性的质量属性。

什么是可修改性?

可修改性的架构质量属性可以定义为:

可修改性是指系统可以轻松进行更改的程度,以及系统适应这些更改的灵活性。

我们在第一章中讨论了可修改性的各个方面,如内聚耦合等。在本章中,我们将通过一些示例更深入地挖掘这些方面。然而,在深入研究之前,看一看可修改性如何与其他与之相关的质量属性相互关联可能是个好主意。

与可修改性相关的方面

我们已经在上一章中看到了可修改性的一些方面。让我们进一步讨论一下,并看一看与可修改性密切相关的一些相关质量属性:

  • 可读性:可读性可以定义为程序逻辑能够被跟随和理解的轻松程度。可读的软件是以特定风格编写的代码,遵循通常采用的编程语言的指南,并且其逻辑以简洁、清晰的方式使用语言提供的特性。

  • 模块化:模块化意味着软件系统是以良好封装的模块编写的,这些模块执行非常具体、有文档记录的功能。换句话说,模块化代码为系统的其余部分提供了程序员友好的 API。可修改性与可重用性密切相关。

  • 可重用性:这衡量了软件系统的各个部分(包括代码、工具、设计等)可以在系统的其他部分中零或很少修改地重复使用的数量。一个好的设计会从一开始就强调可重用性。可重用性体现在软件开发的 DRY 原则中。

  • 可维护性:软件的可维护性是指系统可以被其预期的利益相关者轻松高效地更新并保持在有用状态的程度。可维护性是一个度量标准,包括可修改性、可读性、模块化和可测试性的方面。

在本章中,我们将深入研究可读性和可重用性/模块化方面。我们将从 Python 编程语言的背景下逐一查看这些方面。我们将首先从可读性开始。

理解可读性

软件系统的可读性与其可修改性密切相关。写得好、有文档记录的代码,遵循编程语言的标准或采用的实践,往往会产生简单、简洁的代码,易于阅读和修改。

可读性不仅与遵循良好的编码指南相关,而且还与逻辑的清晰程度、代码使用语言的标准特性的程度、函数的模块化程度等相关。

实际上,我们可以总结可读性的不同方面如下:

  • 写得好:如果一段代码使用简单的语法,使用语言的常见特性和习语,逻辑清晰简洁,并且有意义地使用变量、函数和类/模块名称,那么它就是写得好的。

  • 文档良好:文档通常指的是代码中的内联注释。一段文档良好的代码告诉它做了什么,它的输入参数(如果有的话)是什么,它的返回值(如果有的话)是什么,以及详细的逻辑或算法。它还记录了运行代码所需的任何外部库或 API 使用和配置,无论是内联还是在单独的文件中。

  • 格式良好:大多数编程语言,特别是通过分布但紧密结合的编程社区在互联网上开发的开源语言,往往有良好的文档化风格指南。遵循这些缩进和格式等方面的指南的代码,往往比不遵循的代码更易读。

一般来说,不遵循这些指南的代码在可读性方面会有所欠缺。

可读性的缺乏影响了代码的可修改性,因此,维护代码的成本不断增加,主要是资源方面——主要是人力和时间——以保持系统处于有用状态。

Python 和可读性

Python 是一种从头开始设计用于可读性的语言。借用一句著名的 Python 禅语。

可读性很重要

提示

Python 的禅是影响 Python 编程语言设计的 20 个原则,其中 19 个已经被写下来。你可以通过打开 Python 解释器提示符并输入以下内容来查看 Python 的禅:

>>>import this

Python 作为一种语言,强调可读性。它通过清晰、简洁的关键字实现了这一点,这些关键字模仿了它们的英语语言对应词,使用最少的运算符,并遵循以下哲学:

应该有一种——最好只有一种——明显的方法来做到这一点。

例如,在 Python 中迭代一个序列并打印它的索引的一种方法如下:

for idx in range(len(seq)):
    item = seq[idx]
    print(idx, '=>', item)

然而,在 Python 中更常见的习惯是使用enumerate()辅助函数来进行迭代,它为序列中的每个项目返回一个两元组(idxitem):

for idx, item in enumerate(seq):
    print(idx, '=>', item)

在许多编程语言中,如 C++、Java 或 Ruby,第一个版本将被认为与第二个版本一样好。然而,在 Python 中,有一些写代码的习惯,它们比其他一些更符合语言的原则——Python 的禅。

在这种情况下,第二个版本更接近 Python 程序员解决问题的方式。第一种方式被认为不如第二种方式 Pythonic。

当你与 Python 社区互动时,你会经常遇到“Pythonic”这个词。它意味着代码不仅解决了问题,而且遵循了 Python 社区通常遵循的约定和习惯,并且以其预期的方式使用了语言。

注意

Pythonic 的定义是主观的,但你可以把它看作是 Python 代码遵循 Python 禅的方式,或者一般来说,遵循社区采用的众所周知的惯用编程实践。

Python,根据其设计原则和清晰的语法,使得编写可读代码变得容易。然而,对于从其他更为拘谨和不太符合惯用法的语言(比如 C++或 Java)迁移到 Python 的程序员来说,以一种不太符合 Python 习惯的方式编写 Python 代码是一个常见的陷阱。例如,第一个循环的版本更可能是由从这些语言迁移到 Python 的人编写,而不是已经在 Python 中编码了一段时间的人。

对于 Python 程序员来说,早期了解这一方面是很重要的,这样你在逐渐熟悉语言的过程中就更有可能编写符合习惯或 Python 风格的代码。如果你熟悉其编码原则和习惯用法,长期来看你可以更有效地使用 Python。

可读性-反模式

总的来说,Python 鼓励并便于编写可读代码。然而,当然,说任何用 Python 编写的代码都非常可读是非常不现实的。即使具有所有可读性的 DNA,Python 也有其公平份额的难以阅读、编写不佳或难以阅读的代码,这可以通过花一些时间浏览一些在网络上用 Python 编写的公开开源代码来明显看出。

在编程语言中有一些实践往往会产生难以阅读或难以阅读的代码。这些可以被认为是反模式,不仅在 Python 编程中是一种祸害,而且在任何编程语言中都是如此:

  • 几乎没有注释的代码:缺乏代码注释通常是产生难以阅读的代码的主要原因。往往程序员并没有很好地记录他们的想法,这导致了特定实现方式的难以理解。当另一个程序员或同一个程序员几个月后(这种情况经常发生!)阅读相同的代码时,很难弄清为什么采用了特定的实现方式。这使得很难推理出替代方法的利弊。

这也使得在修改代码时做出决策(也许是为了修复客户问题)变得困难,并且一般来说,会影响长期的代码可修改性。代码的注释通常是编写代码的程序员的纪律和严谨的指标,也是组织强制执行这些实践的指标。

  • 违反语言最佳实践的代码:编程语言的最佳实践通常是由开发者社区多年使用该语言的经验和高效反馈所演变而来的。它们捕捉了将编程语言有效地用于解决问题的最佳方式,通常捕捉了使用该语言的习惯用法和常见模式。

例如,在 Python 中,禅可以被认为是其最佳实践和社区采用的常见编程习惯的闪亮火炬。

通常,那些经验不足或从其他编程语言或环境迁移而来的程序员往往会产生不符合这些实践的代码,因此最终编写出了可读性较低的代码。

  • 编程反模式:有许多编码或编程反模式,往往会产生难以阅读,因此难以维护的代码。以下是一些众所周知的反模式:

  • 意大利面代码:没有可辨识的结构或控制流的代码片段。通常是通过遵循复杂逻辑、大量无条件跳转和无结构的异常处理、设计不良的并发结构等方式产生的。

  • 大泥球:一个系统,其中的代码片段没有整体结构或目标。大泥球通常由许多意大利面代码片段组成,通常是代码被多人修改多次,几乎没有文档的迹象。

  • 复制粘贴编程:通常在组织中产生,其中交付速度优先于深思熟虑的设计,复制/粘贴编码会产生长而重复的代码块,基本上一遍又一遍地做同样的事情,只是进行了微小的修改。这导致代码膨胀,并且从长远来看,代码变得难以维护。

类似的反模式是模仿式编程,程序员一遍又一遍地遵循相同的设计或编程模式,而不考虑它是否适合特定的场景或问题。

  • 自我编程:自我编程是指程序员——通常是经验丰富的程序员——更喜欢他个人的风格,而不是文档化的最佳实践或组织的编码风格。这有时会产生晦涩难懂的代码,对其他人——通常是年轻或经验较少的程序员来说,阅读起来困难。一个例子是倾向于在 Python 中使用函数式编程构造将所有东西写成一行的倾向。

通过在组织中采用结构化编程的实践,并强制执行编码准则和最佳实践,可以避免编码反模式。

以下是一些特定于 Python 的反模式:

  • 混合缩进:Python 使用缩进来分隔代码块,因为它缺少像 C/C++或 Java 这样的语言中分隔代码块的大括号或其他语法结构。然而,在 Python 中缩进代码时需要小心。一个常见的反模式是人们在他们的 Python 代码中混合使用制表符(\t字符)和空格。可以通过使用总是使用制表符或空格来缩进代码的编辑器来解决这个问题。

Python 自带内置模块,如tabnanny,可用于检查代码的缩进问题。

  • 混合字符串文字类型:Python 提供了三种不同的创建字符串文字的方式:使用单引号(')、双引号(")或 Python 自己特殊的三引号('''""")。在同一段代码或功能单元中混合这三种文字类型的代码会变得更难阅读。

与之相关的字符串滥用是程序员在他们的 Python 代码中使用三引号字符串来进行内联注释,而不是使用#字符来为他们的注释添加前缀。

  • 过度使用函数式构造:Python 作为一种混合范式语言,通过其 lambda 关键字和map()reduce()filter()函数提供对函数式编程的支持。然而,有时,经验丰富的程序员或从函数式编程背景转到 Python 的程序员会过度使用这些构造,产生过于晦涩的代码,因此对其他程序员来说难以阅读。

可读性技巧

现在我们对提高 Python 代码的可读性的方法有了很好的了解,让我们看看我们可以采用的方法来改善 Python 代码的可读性。

记录你的代码

改善代码可读性的一个简单有效的方法是记录它的功能。文档对于代码的可读性和长期可修改性非常重要。

代码文档可以分为以下几类:

  • 内联文档:程序员通过使用代码注释、函数文档、模块文档等作为代码本身的一部分来记录他的代码。这是最有效和有用的代码文档类型。

  • 外部文档:这些是捕获在单独文件中的附加文档,通常记录代码的使用方式、代码更改、安装步骤、部署等方面。例如,READMEINSTALLCHANGELOG,通常在遵循 GNU 构建原则的开源项目中找到。

  • 用户手册:这些是正式文件,通常由专门的人或团队编写,使用图片和通常面向系统用户的文本。这种文档通常在软件项目结束时准备和交付,当产品稳定并准备发货时。我们在这里的讨论中不关心这种类型的文档。

Python 是一种从头开始设计的智能内联代码文档的语言。在 Python 中,内联文档可以在以下级别完成:

  • 代码注释:这是与代码一起的内联文本,以井号(#)字符为前缀。它们可以在代码内部自由使用,解释代码的每个步骤。

这是一个例子:

# This loop performs a network fetch of the URL, retrying upto 3
# times in case of errors. In case the URL cant be fetched, 
# an error is returned.

# Initialize all state
count, ntries, result, error = 0, 3, None, None
while count < ntries:
    try:
        # NOTE: We are using an explicit   timeout of 30s here
        result = requests.get(url, timeout=30)
    except Exception as error:
        print('Caught exception', error, 'trying again after a while')
      # increment count
      count += 1
      # sleep 1 second every time
      time.sleep(1)

  if result == None:
    print("Error, could not fetch URL",url)
    # Return a tuple of (<return code>, <lasterror>)
    return (2, error)

      # Return data of URL
    return result.content

即使在可能被认为是多余的地方,也要大量使用注释。我们稍后将看一些关于在代码中添加注释的一般规则。

  • 函数文档字符串:Python 提供了一种简单的方法,通过在函数定义的下方使用字符串文字来记录函数的功能。这可以通过使用三种风格的字符串文字之一来完成。

这是一个例子:

def fetch_url(url, ntries=3, timeout=30):
         " Fetch a given url and return its contents "

        # This loop performs a network fetch of the URL, retrying 
        # upto
        # 3 times in case of errors. In case the URL cant be 
        # fetched,       
        # an error is returned.

        # Initialize all state
        count, result, error = 0, None, None
        while count < ntries:
            try:
                result = requests.get(url, timeout=timeout)
            except Exception as error:
                print('Caught exception', error, 'trying again after a while')
                # increment count
                count += 1
                # sleep 1 second every time
                time.sleep(1)

        if result == None:
            print("Error, could not fetch URL",url)
            # Return a tuple of (<return code>, <lasterror>)
            return (2, error)

        # Return data of URL
        return result.content

函数文档字符串是一行,其中写着获取给定 URL 并返回其内容。然而,尽管它很有用,但使用范围有限,因为它只说明函数的功能,而不解释其参数。这里是一个改进版本:

def fetch_url(url, ntries=3, timeout=30):
        """ Fetch a given url and return its contents. 

        @params
            url - The URL to be fetched.
            ntries - The maximum number of retries.
            timeout - Timout per call in seconds.

        @returns
            On success - Contents of URL.
            On failure - (error_code, last_error)
        """

        # This loop performs a network fetch of the URL, 
        # retrying upto      
        # 'ntries' times in case of errors. In case the URL 
        # cant be
        # fetched, an error is returned.

        # Initialize all state
        count, result, error = 0, None, None
        while count < ntries:
            try:
                result = requests.get(url, timeout=timeout)
            except Exception as error:
                print('Caught exception', error, 'trying again after a while')
                # increment count
                count += 1
                # sleep 1 second every time
                time.sleep(1)

        if result == None:
            print("Error, could not fetch URL",url)
            # Return a tuple of (<return code>, <lasterror>)
            return (2, error)

        # Return data of the URL
        return result.content

在前面的代码中,函数的使用对于计划导入其定义并在其代码中使用的程序员来说变得更加清晰。请注意,这种扩展文档通常会跨越多行,因此,始终使用三引号与函数文档字符串是一个好主意。

  • 类文档字符串:这些与函数文档字符串的工作方式相同,只是它们直接为类提供文档。这是在定义类的关键字下方提供的。

这是一个例子:

class UrlFetcher(object):
         """ Implements the steps of fetching a URL.

        Main methods:
            fetch - Fetches the URL.
            get - Return the URLs data.
        """

        def __init__(self, url, timeout=30, ntries=3, headers={}):
            """ Initializer. 
            @params
                url - URL to fetch.
                timeout - Timeout per connection (seconds).
                ntries - Max number of retries.
                headers - Optional request headers.
            """
            self.url = url
            self.timeout = timeout
            self.ntries = retries
            self.headers = headers
            # Enapsulated result object
            self.result = result 

        def fetch(self):
            """ Fetch the URL and save the result """

            # This loop performs a network fetch of the URL, 
            # retrying 
            # upto 'ntries' times in case of errors. 

            count, result, error = 0, None, None
            while count < self.ntries:
                try:
                    result = requests.get(self.url,
                                          timeout=self.timeout,
                                          headers = self.headers)
                except Exception as error:
                    print('Caught exception', error, 'trying again after a while')
                    # increment count
                    count += 1
                    # sleep 1 second every time
                    time.sleep(1)

            if result != None:
                # Save result
                self.result = result

        def get(self):
            """ Return the data for the URL """

            if self.result != None:
                return self.result.content

查看类文档字符串如何定义类的一些主要方法。这是一个非常有用的做法,因为它在顶层为程序员提供了有用的信息,而无需去检查每个函数的文档。

  • 模块文档字符串:模块文档字符串在模块级别捕获信息,通常是关于模块功能的信息以及模块的每个成员(函数、类和其他)的一些详细信息。语法与类或函数文档字符串相同。这些信息通常在模块代码的开头捕获。

如果模块文档还可以捕获模块的任何特定外部依赖项,如果它们不是非常明显的话,例如,导入一个不太常用的第三方包:

"""
    urlhelper - Utility classes and functions to work with URLs.

    Members:

        # UrlFetcher - A class which encapsulates action of 
        # fetching
        content of a URL.
        # get_web_url - Converts URLs so they can be used on the 
        # web.
        # get_domain - Returns the domain (site) of the URL.
"""

import urllib

def get_domain(url):
    """ Return the domain name (site) for the URL"""

    urlp = urllib.parse.urlparse(url)
    return urlp.netloc

def get_web_url(url, default='http'):
    """ Make a URL useful for fetch requests
    -  Prefix network scheme in front of it if not present already
    """ 

    urlp = urllib.parse.urlparse(url)
    if urlp.scheme == '' and urlp.netloc == '':
              # No scheme, prefix default
      return default + '://' + url

    return url

class UrlFetcher(object):
     """ Implements the steps of fetching a URL.

    Main methods:
        fetch - Fetches the URL.
        get - Return the URLs data.
    """

    def __init__(self, url, timeout=30, ntries=3, headers={}):
        """ Initializer. 
        @params
            url - URL to fetch.
            timeout - Timeout per connection (seconds).
            ntries - Max number of retries.
            headers - Optional request headers.
        """
        self.url = url
        self.timeout = timeout
        self.ntries = retries
        self.headers = headers
        # Enapsulated result object
        self.result = result 

    def fetch(self):
        """ Fetch the URL and save the result """

        # This loop performs a network fetch of the URL, retrying 
        # upto 'ntries' times in case of errors. 

        count, result, error = 0, None, None
        while count < self.ntries:
            try:
                result = requests.get(self.url,
                                      timeout=self.timeout,
                                      headers = self.headers)
            except Exception as error:
                print('Caught exception', error, 'trying again after a while')
                # increment count
                count += 1
                # sleep 1 second every time
                time.sleep(1)

        if result != None:
            # Save result
            self.result = result

    def get(self):
        """ Return the data for the URL """

        if self.result != None:
            return self.result.content

遵循编码和风格指南

大多数编程语言都有一个相对知名的编码和/或风格指南。这些要么是作为惯例多年使用而形成的,要么是作为该编程语言在线社区讨论的结果。C/C++是前者的一个很好的例子,Python 是后者的一个很好的例子。

公司通常会制定自己的指南,大多数情况下是通过采用现有的标准指南,并根据公司自己的特定开发环境和要求进行定制。

对于 Python,Python 编程社区发布了一套清晰的编码风格指南。这个指南被称为 PEP-8,可以在线作为 Python 增强提案(PEP)文档的一部分找到。

注意

您可以在以下网址找到 PEP-8:

www.python.org/dev/peps/pep-0008/

PEP-8 首次创建于 2001 年,自那时以来已经经历了多次修订。主要作者是 Python 的创始人 Guido Van Rossum,Barry Warsaw 和 Nick Coghlan 提供了输入。

PEP-8 是通过调整 Guido 的原始Python 风格指南并加入 Barry 的风格指南而创建的。

我们不会在本书中深入讨论 PEP-8,因为本节的目标不是教你 PEP-8。然而,我们将讨论 PEP-8 的基本原则,并列出一些其主要建议。

PEP-8 的基本原则可以总结如下:

  • 代码被阅读的次数比被编写的次数要多。因此,提供一个准则会使代码更易读,并使其在整个 Python 代码的全谱上保持一致。

  • 项目内的一致性很重要。但是,在一个模块或包内的一致性更重要。在代码单元内(如类或函数)的一致性是最重要的。

  • 知道何时忽略一个准则。例如,如果采用该准则使您的代码变得不太可读,破坏了周围的代码,或者破坏了代码的向后兼容性,那么可能会发生这种情况。学习示例,并选择最好的。

  • 如果一个准则对您的组织不直接适用或有用,那么自定义它。如果您对某个准则有任何疑问,请向乐于助人的 Python 社区寻求澄清。

我们不会在这里详细介绍 PEP-8 准则。有兴趣的读者可以参考在线文档,使用这里提供的 URL。

审查和重构代码

代码需要维护。在生产中使用的未维护的代码可能会成为一个问题,如果不定期处理,可能会变成一个噩梦。

定期安排代码审查非常有助于保持代码的可读性和良好的健康,有助于可修改性和可维护性。在生产中对系统或应用程序至关重要的代码往往会随着时间的推移得到许多快速修复,因为它被定制或增强以适应不同的用例或为问题打补丁。观察到程序员通常不会记录这些快速修复(称为“补丁”或“热修复”),因为时间要求通常会加速立即测试和部署,而不是遵循良好的工程实践,如文档和遵循准则!

随着时间的推移,这样的补丁可能会积累,从而导致代码膨胀,并为团队创造巨大的未来工程债务,这可能会成为一项昂贵的事务。解决方案是定期审查。

审查应该由熟悉应用程序的工程师进行,但不一定要在同一段代码上工作。这给了代码一个新鲜的视角,通常有助于发现原始作者可能忽视的错误。最好让经验丰富的开发人员对大的更改进行审查。

这可以与代码的一般重构结合起来,以改进实现,减少耦合,或增加内聚。

注释代码

我们即将结束对代码可读性的讨论,现在是介绍一些编写代码注释时要遵循的一般经验法则的好时机。这些可以列举如下:

  • 注释应该是描述性的,并解释代码。一个简单重复函数名称显而易见的注释并不是很有用。

这是一个例子。以下两个代码都展示了相同的均方根RMS)速度计算实现,但第二个版本比第一个版本有一个更有用的docstring

def rms(varray=[]):
    """ RMS velocity """

    squares = map(lambda x: x*x, varray)
    return pow(sum(squares), 0.5) 

def rms(varray=[]):
    """ Root mean squared velocity. Returns
    square root of sum of squares of velocities """

    squares = map(lambda x: x*x, varray)
    return pow(sum(squares), 0.5)
  • 代码注释应该写在我们正在评论的代码块中,而不是像下面这样:
# This code calculates the sum of squares of velocities 
squares = map(lambda x: x*x, varray)

前一个版本比下一个版本更清晰,下一个版本使用了代码下面的注释,因为它符合从上到下的自然阅读顺序。

squares = map(lambda x: x*x, varray)
# The above code calculates the sum of squares of velocities 
  • 尽量少使用内联注释。这是因为很容易将其混淆为代码本身的一部分,特别是如果分隔注释字符被意外删除,导致错误:
# Not good !
squares = map(lambda x: x*x, varray)   # Calculate squares of velocities
  • 尽量避免多余的、增加很少价值的注释:
# The following code iterates through odd numbers
for num in nums:
    # Skip if number is odd
    if num % 2 == 0: continue

在最后一段代码中,第二条评论增加了很少的价值,可以省略。

可修改性的基本原则-内聚性和耦合性

现在让我们回到代码可修改性的主题,并讨论影响代码可修改性的两个基本方面,即内聚性和耦合。

我们已经在第一章中简要讨论了这些概念。让我们在这里进行快速回顾。

内聚指的是模块的责任之间的紧密关联程度。执行特定任务或一组相关任务的模块具有高内聚性。如果一个模块在没有考虑核心功能的情况下堆积了大量功能,那么它的内聚性就会很低。

耦合是模块 A 和 B 的功能相关程度。如果两个模块的功能在代码级别(在函数或方法调用方面)有很强的重叠,那么它们就是强耦合的。对模块 A 的任何更改可能需要对模块 B 进行更改。

强耦合对可修改性总是具有禁止作用,因为它增加了维护代码库的成本。

旨在提高可修改性的代码应该追求高内聚性和低耦合性。

我们将在以下各小节中通过一些例子分析内聚和耦合。

衡量内聚和耦合

让我们来看一个简单的例子,有两个模块,以找出如何定量地衡量耦合和内聚。以下是模块 A 的代码,据称实现了对一系列(数组)数字进行操作的函数:

"" Module A (a.py) – Implement functions that operate on series of numbers """

def squares(narray):
    """ Return array of squares of numbers """
    return pow_n(array, 2)

def cubes(narray):
    """ Return array of cubes of numbers """
    return pow_n(narray, 3)

def pow_n(narray, n):
    """ Return array of numbers raised to arbitrary power n each """
    return [pow(x, n) for x in narray]

def frequency(string, word):
    """ Find the frequency of occurrences of word in string
    as percentage """

    word_l = word.lower()
    string_l = string.lower()

    # Words in string
    words = string_l.split()
    count = w.count(word_l)

    # Return frequency as percentage
    return 100.0*count/len(words)

接下来是模块 B 的列表。

""" Module B (b.py) – Implement functions provide some statistical methods """

import a

def rms(narray):
    """ Return root mean square of array of numbers"""

    return pow(sum(a.squares(narray)), 0.5)

def mean(array):
    """ Return mean of an array of numbers """

    return 1.0*sum(array)/len(array)

def variance(array):
    """ Return variance of an array of numbers """

    # Square of variation from mean
    avg = mean(array)
    array_d = [(x – avg) for x in array]
    variance = sum(a.squares(array_d))
    return variance

def standard_deviation(array):
    """ Return standard deviation of an array of numbers """

    # S.D is square root of variance
    return pow(variance(array), 0.5)

让我们对模块 A 和 B 中的函数进行分析。以下是报告:

模块 核心功能 无关功能 函数依赖
B 4 0 3 x 1 = 3
A 3 1 0

这有四个函数,可以解释如下:

  • 模块 B 有四个函数,所有这些函数都涉及核心功能。在这个模块中没有与核心功能无关的函数。模块 B 的内聚性为 100%。

  • 模块 A 有四个函数,其中三个与其核心功能相关,但最后一个(frequency)不相关。这使得模块 A 的内聚性约为75%

  • 模块 B 的三个函数依赖于模块 A 中的一个函数,即 square。这使得模块 B 与模块 A 强耦合。从模块 B 到 A 的函数级耦合为75%

  • 模块 A 不依赖于模块 B 的任何功能。模块 A 将独立于模块 B 工作。从模块 A 到 B 的耦合为零。

现在让我们看看如何改进模块 A 的内聚性。在这种情况下,简单地删除最后一个实际上不属于那里的函数就可以了。它可以完全删除或移动到另一个模块。

以下是重写后的模块 A 代码,现在在责任方面具有 100%的内聚性:

""" Module A (a.py) – Implement functions that operate on series of numbers """

def squares(narray):
    """ Return array of squares of numbers """
    return pow_n(array, 2)

def cubes(narray):
    """ Return array of cubes of numbers """
    return pow_n(narray, 3)

def pow_n(narray, n):
    """ Return array of numbers raised to arbitrary power n each """
    return [pow(x, n) for x in narray]

现在让我们分析从模块 B 到 A 的耦合质量,并查看与 A 中的代码相关的 B 代码的可修改性风险因素,如下所示:

  • B 中的三个函数仅依赖于模块 A 中的一个函数。

  • 该函数名为 squares,它接受一个数组并返回每个成员的平方。

  • 函数签名(API)很简单,因此将来更改函数签名的可能性较小。

  • 系统中没有双向耦合。依赖仅来自 B 到 A 的方向。

换句话说,尽管从 B 到 A 存在强耦合,但这是良好的耦合,并且不会以任何方式影响系统的可修改性。

现在让我们看另一个例子。

衡量内聚和耦合 - 字符串和文本处理

现在让我们考虑一个不同的用例,一个涉及大量字符串和文本处理的函数的例子:

""" Module A (a.py) – Provides string processing functions """
import b

def ntimes(string, char):
    """ Return number of times character 'char'
    occurs in string """

    return string.count(char)

def common_words(text1, text2):
    """ Return common words across text1 and text2"""

    # A text is a collection of strings split using newlines
    strings1 = text1.split("\n")
    strings2 = text2.split("\n")

    common = []
    for string1 in strings1:
        for string2 in strings2:
            common += b.common(string1, string2)

    # Drop duplicates
    return list(set(common))

接下来是模块 B 的列表,如下所示:

""" Module B (b.py) – Provides text processing functions to user """

import a

def common(string1, string2):
    """ Return common words across strings1 1 & 2 """

    s1 = set(string1.lower().split())
    s2 = set(string2.lower().split())
    return s1.intersection(s2)    

def common_words(text1, text2):
    """ Return common words across two input files """

    lines1 = open(filename1).read()
    lines2 = open(filename2).read()

    return a.common_words(lines1, lines2)

让我们来看一下这些模块的耦合和内聚分析,如下表所示:

模块 核心功能 无关功能 函数依赖
B 2 0 1 x 1 = 1
A 2 0 1 x 1 = 1

以下是表中这些数字的解释:

  • 模块 A 和 B 各有两个函数,每个函数都处理核心功能。模块 A 和 B 都具有100%的内聚。

  • 模块 A 的一个函数依赖于模块 B 的一个函数。同样,模块 B 的一个函数依赖于模块 A 的一个函数。从 A 到 B 有强耦合,从 B 到 A 也是如此。换句话说,耦合是双向的。

两个模块之间的双向耦合会使它们的可修改性之间产生非常强烈的联系。模块 A 的任何更改都会迅速影响模块 B 的行为,反之亦然。换句话说,这是不好的耦合。

探索可修改性的策略

现在我们已经看到了一些好的和坏的耦合和内聚的例子,让我们来看看软件设计师或架构师可以使用的策略和方法,以减少这些方面对可修改性的影响,从而改进软件系统的可修改性。

提供明确的接口

一个模块应该标记一组函数、类或方法作为其提供给外部代码的接口。这可以被视为该模块的 API,从中导出。使用此 API 的任何外部代码都将成为该模块的客户端。

模块认为是其内部功能的方法或函数,不构成其 API 的,应该明确地作为模块的私有部分,或者应该被记录为这样的部分。

在 Python 中,函数或类方法没有提供变量访问范围,可以通过约定来实现,例如在函数名前加上单下划线或双下划线,从而向潜在客户表明这些函数是内部函数,不应该从外部引用。

减少双向依赖

如前面的例子所示,如果耦合方向是单向的,那么两个软件模块之间的耦合是可以管理的。然而,双向耦合会在模块之间创建非常强的联系,这可能会使模块的使用复杂化,并增加其维护成本。

在像 Python 这样使用基于引用的垃圾收集的语言中,这也可能为变量和对象创建难以理解的引用循环,从而使它们的垃圾收集变得困难。

通过重构代码的方式打破双向依赖,使一个模块始终使用另一个模块,而不是反之。换句话说,将所有相关函数封装在同一个模块中。

以下是我们之前例子中的模块 A 和 B,重写以打破它们的双向依赖:

    """ Module A (a.py) – Provides string processing functions """

    def ntimes(string, char):
        """ Return number of times character 'char'
        occurs in string """

        return string.count(char)

    def common(string1, string2):
        """ Return common words across strings1 1 & 2 """

        s1 = set(string1.lower().split())
        s2 = set(string2.lower().split())
        return s1.intersection(s2)  

    def common_words(text1, text2):
        """ Return common words across text1 and text2"""

        # A text is a collection of strings split using newlines
        strings1 = text1.split("\n")
        strings2 = text2.split("\n")

        common_w = []
        for string1 in strings1:
            for string2 in strings2:
                common_w += common(string1, string2)

        return list(set(common_w))

接下来是模块 B 的清单。

  """ Module B (b.py) – Provides text processing functions to user """

  import a

  def common_words(filename1, filename2):
    """ Return common words across two input files """

    lines1 = open(filename1).read()
    lines2 = open(filename2).read()

    return a.common_words(lines1, lines2)

我们通过简单地将模块 B 中从两个字符串中选择共同单词的函数common移动到模块 A 来实现这一点。这是改进可修改性的重构的一个例子。

抽象共同服务

使用抽象共同函数和方法的辅助模块可以减少两个模块之间的耦合,并增加它们的内聚。例如,在第一个例子中,模块 A 充当了模块 B 的辅助模块。在第二个例子中,重构后,模块 A 也充当了模块 B 的辅助模块。

辅助模块可以被视为中介或调解者,它们为其他模块提供共同服务,以便依赖代码都在一个地方进行抽象,避免重复。它们还可以通过将不需要或不相关的函数移动到合适的辅助模块来帮助模块增加它们的内聚。

使用继承技术

当我们发现类中出现相似的代码或功能时,可能是时候对其进行重构,以创建类层次结构,以便通过继承共享公共代码。

让我们看下面的例子:

""" Module textrank - Rank text files in order of degree of a specific word frequency. """

import operator

class TextRank(object):
    """ Accept text files as inputs and rank them in
    terms of how much a word occurs in them """

    def __init__(self, word, *filenames):
        self.word = word.strip().lower()
        self.filenames = filenames

    def rank(self):
        """ Rank the files. A tuple is returned with
        (filename, #occur) in decreasing order of
        occurences """

        occurs = []

        for fpath in self.filenames:
            data = open(fpath).read()
            words = map(lambda x: x.lower().strip(), data.split())
            # Filter empty words
            count = words.count(self.word)
            occurs.append((fpath, count))

        # Return in sorted order
        return sorted(occurs, key=operator.itemgetter(1), reverse=True)

这里是另一个模块urlrank,它在 URL 上执行相同的功能:

    """ Module urlrank - Rank URLs in order of degree of a specific word frequency """
    import operator
import operator
import requests

class UrlRank(object):
    """ Accept URLs as inputs and rank them in
    terms of how much a word occurs in them """

    def __init__(self, word, *urls):
        self.word = word.strip().lower()
        self.urls = urls

    def rank(self):
        """ Rank the URLs. A tuple is returned with
        (url, #occur) in decreasing order of
        occurences """

        occurs = []

        for url in self.urls:
            data = requests.get(url).content
            words = map(lambda x: x.lower().strip(), data.split())
            # Filter empty words
            count = words.count(self.word)
            occurs.append((url, count))

        # Return in sorted order
        return sorted(occurs, key=operator.itemgetter(1), reverse=True)

这两个模块都执行类似的功能,即根据给定关键字在一组输入数据中出现的频率对其进行排名。随着时间的推移,这些类可能会开发出许多相似的功能,组织可能会出现大量重复的代码,降低了可修改性。

我们可以使用继承来帮助我们在父类中抽象出通用逻辑。这里是名为RankBase的父类,通过将所有通用代码移动到自身来实现这一点:

""" Module rankbase - Logic for ranking text using degree of word frequency """

import operator

class RankBase(object):
    """ Accept text data as inputs and rank them in
    terms of how much a word occurs in them """

    def __init__(self, word):
        self.word = word.strip().lower()

    def rank(self, *texts):
        """ Rank input data. A tuple is returned with
        (idx, #occur) in decreasing order of
        occurences """

        occurs = {}

        for idx,text in enumerate(texts):
            # print text
            words = map(lambda x: x.lower().strip(), text.split())
            count = words.count(self.word)
            occurs[idx] = count

        # Return dictionary
        return occurs

    def sort(self, occurs):
        """ Return the ranking data in sorted order """

        return sorted(occurs, key=operator.itemgetter(1), reverse=True)

现在我们已经重写了textrankurlrank模块,以利用父类中的逻辑:

""" Module textrank - Rank text files in order of degree of a specific word frequency. """

import operator
from rankbase import RankBase

class TextRank(object):
    """ Accept text files as inputs and rank them in
    terms of how much a word occurs in them """

    def __init__(self, word, *filenames):
        self.word = word.strip().lower()
        self.filenames = filenames

    def rank(self):
        """ Rank the files. A tuple is returned with
        (filename, #occur) in decreasing order of
        occurences """

        texts = map(lambda x: open(x).read(), self.filenames)
        occurs = super(TextRank, self).rank(*texts)
        # Convert to filename list
        occurs = [(self.filenames[x],y) for x,y in occurs.items()]

        return self.sort(occurs)

这是urlrank模块的修改列表:

""" Module urlrank - Rank URLs in order of degree of a specific word frequency """

import requests
from rankbase import RankBase

class UrlRank(RankBase):
    """ Accept URLs as inputs and rank them in
    terms of how much a word occurs in them """

def __init__(self, word, *urls):
    self.word = word.strip().lower()
    self.urls = urls

def rank(self):
    """ Rank the URLs. A tuple is returned with
    (url, #occur) in decreasing order of
    occurences"""

    texts = map(lambda x: requests.get(x).content, self.urls)
    # Rank using a call to parent class's 'rank' method
    occurs = super(UrlRank, self).rank(*texts)
    # Convert to URLs list
    occurs = [(self.urls[x],y) for x,y in occurs.items()]

    return self.sort(occurs)

重构不仅减少了每个模块中代码的大小,还通过将通用代码抽象到父模块/类中,从而改善了类的可修改性,这可以独立开发。

使用后期绑定技术

后期绑定是指尽可能晚地将值绑定到代码执行顺序中的参数的做法。后期绑定允许程序员推迟影响代码执行的因素,从而推迟执行结果和代码性能,通过使用多种技术。

以下是一些可以使用的后期绑定技术:

  • 插件机制:这种技术使用在运行时解析的值来加载插件,执行特定的依赖代码,而不是静态地将模块绑定在一起,这会增加耦合。插件可以是 Python 模块,其名称在运行时的计算中获取,也可以是从数据库查询或配置文件中加载的 ID 或变量名称。

  • 经纪人/注册表查找服务:某些服务可以完全推迟到经纪人,经纪人根据需要从注册表中查找服务名称,并动态调用并返回结果。例如,货币兑换服务可以接受特定货币转换作为输入(例如 USDINR),并在运行时动态查找和配置服务,因此系统在任何时候只需要相同的代码执行。由于系统上没有依赖于输入的代码,系统不会受到任何变化的影响,因此它被推迟到外部服务。

  • 通知服务:发布/订阅机制在对象值发生变化或事件发布时通知订阅者,对于将系统与易变参数及其值解耦非常有用。这些系统不会在内部跟踪这些变量/对象的变化,而是将客户端仅绑定到外部 API,该 API 仅通知客户端值的变化。

  • 部署时间绑定:通过将变量值与名称或 ID 关联到配置文件中,我们可以将对象/变量绑定推迟到部署时间。软件系统在启动时通过加载配置文件绑定值,然后调用创建适当对象的代码路径。

这种方法可以与工厂等面向对象模式结合使用,工厂可以在运行时根据名称或 ID 创建所需的对象,从而使依赖于这些对象的客户端免受任何内部更改的影响,增加了它们的可修改性。

  • 使用创建型模式:创建型设计模式(如工厂或生成器)将对象的创建任务与创建细节抽象出来,非常适合客户端模块的关注点分离,这些模块不希望在创建依赖对象的代码发生更改时修改它们的代码。

这些方法与部署/配置时间或动态绑定(使用查找服务)相结合,可以极大地增加系统的灵活性,并帮助其可修改性。

我们将在本书的后面章节中看一些 Python 模式的例子。

度量标准-静态分析工具

静态代码分析工具可以提供关于代码静态属性的丰富摘要信息,可以提供有关代码复杂性和可修改性/可读性等方面的见解。

Python 有很多第三方工具支持,可以帮助衡量 Python 代码的静态方面,比如:

  • 遵守编码规范,如 PEP-8

  • 像 McCabe 度量这样的代码复杂度指标

  • 代码中的错误,如语法错误、缩进问题、缺少导入、变量覆盖等

  • 代码中的逻辑问题

  • 代码异味

以下是 Python 生态系统中一些最流行的工具,可以进行静态分析:

  • Pylint:Pylint 是 Python 代码的静态检查器,可以检测一系列的编码错误、代码异味和风格错误。Pylint 使用接近 PEP-8 的风格。较新版本的 Pylint 还提供有关代码复杂性的统计信息,并可以打印报告。Pylint 要求在检查代码之前执行代码。您可以参考pylint.org链接。

  • Pyflakes:Pyflakes 是一个比 Pylint 更新的项目。它与 Pylint 的不同之处在于,在检查代码错误之前,它不需要执行代码。Pyflakes 不检查编码风格错误,只在代码中执行逻辑检查。您可以参考launchpad.net/pyflakes链接。

  • McCabe:这是一个检查并打印代码 McCabe 复杂度报告的脚本。您可以参考pypi.python.org/pypi/mccabe链接。

  • Pycodestyle:Pycodestyle 是一个检查 Python 代码是否符合 PEP-8 指南的工具。这个工具以前被称为 PEP-8。请参考github.com/PyCQA/pycodestyle链接。

  • Flake8:Flake8 是 Pyflakes、McCabe 和 pycodestyle 工具的包装器,可以执行一些检查,包括这些工具提供的检查。请参考gitlab.com/pycqa/flake8/链接。

什么是代码异味?

代码异味是代码中更深层次问题的表面症状。它们通常表明设计上存在问题,可能会导致将来的错误或对特定代码段的开发产生负面影响。

代码异味本身并不是错误,而是指示代码解决问题的方法不正确,并且应该通过重构来修复的模式。

一些常见的代码异味包括:

在类级别:

  • God Object:一个试图做太多事情的类。简而言之,这个类缺乏任何形式的内聚性。

  • Constant Class:一个仅仅是常量集合的类,被其他地方使用,因此理想情况下不应该存在于这里。

  • Refused Bequest:一个不遵守基类合同的类,因此违反了继承的替换原则。

  • Freeloader:一个函数太少的类,几乎什么都不做,价值很小。

  • Feature Envy:一个过度依赖另一个类方法的类,表明耦合度很高。

在方法/函数级别:

  • Long method:一个变得太大和复杂的方法或函数。

  • Parameter creep:函数或方法的参数太多。这使得函数的可调用性和可测试性变得困难。

  • 圈复杂度:具有太多分支或循环的函数或方法,这会导致复杂的逻辑难以跟踪,并可能导致微妙的错误。这样的函数应该被重构并拆分为多个函数,或者重写逻辑以避免过多的分支。

  • 过长或过短的标识符:使用过长或过短的变量名的函数,使得它们的用途无法从它们的名称中清楚地看出。对函数名称也适用相同的规则。

与代码异味相关的反模式是设计异味,这些是系统设计中的表面症状,表明架构中存在更深层次的问题。

圈复杂度 – McCabe 度量

圈复杂度是计算机程序复杂性的一种度量。它被计算为程序源代码从开始到结束的线性独立路径的数量。

对于没有任何分支的代码片段,例如接下来给出的代码,圈复杂度将为1,因为代码只有一条路径。

""" Module power.py """

def power(x, y):
    """ Return power of x to y """
    return x^y

具有一个分支的代码片段,如下面的代码,复杂度将为 2:

""" Module factorial.py """

def factorial(n):
    """ Return factorial of n """
    if n == 0:
        return 1
    else:
        return n*factorial(n-1)

使用代码的控制图作为度量标准的圈复杂度是由 Thomas J. McCabe 于 1976 年开发的。因此,它也被称为 McCabe 复杂度或 McCabe 指数。

为了测量度量标准,控制图可以被描绘为一个有向图,其中节点表示程序的块,边表示从一个块到另一个块的控制流。

关于程序的控制图,McCabe 复杂度可以表示如下:

M = E - N + 2P

其中,

E => 图中的边数

N => 图中的节点数

P => 图中的连通分量数

在 Python 中,可以使用由 Ned Batcheldor 编写的mccabe包来测量程序的圈复杂度。它可以作为独立模块使用,也可以作为 Flake8 或 Pylint 等程序的插件使用。

例如,这里是我们如何测量前面给出的两个代码片段的圈复杂度:

圈复杂度 – McCabe 度量

一些示例 Python 程序的 McCabe 度量

参数–min告诉mccabe模块从给定的 McCabe 指数开始测量和报告。

测试度量标准

现在让我们尝试一下前面提到的一些工具,并在一个示例模块上使用它们,以找出这些工具报告了什么样的信息。

注意

以下各节的目的不是教授读者如何使用这些工具或它们的命令行选项——这些可以通过工具的文档来学习。相反,目的是探索这些工具在代码的样式、逻辑和其他问题方面提供的深度和丰富信息。

为了进行这项测试,使用了以下人为构造的模块示例。它故意写有许多编码错误、样式错误和编码异味。

由于我们使用的工具按行号列出错误,因此代码已经被呈现为带有编号的行,以便轻松地将工具的输出追溯到代码:

     1  """
     2  Module metrictest.py
     3  
     4  Metric example - Module which is used as a testbed for static checkers.
     5  This is a mix of different functions and classes doing different things.
     6  
     7  """
     8  import random
     9  
    10  def fn(x, y):
    11      """ A function which performs a sum """
    12      return x + y
    13  
    14  def find_optimal_route_to_my_office_from_home(start_time,
    15                                      expected_time,
    16                                      favorite_route='SBS1K',
    17                                      favorite_option='bus'):
    18  
    19      # If I am very late, always drive.
    20      d = (expected_time – start_time).total_seconds()/60.0
    21  
    22      if d<=30:
    23          return 'car'
    24  
    25      # If d>30 but <45, first drive then take metro
    26      if d>30 and d<45:
    27          return ('car', 'metro')
    28  
    29      # If d>45 there are a combination of options
    30      if d>45:
    31          if d<60:
    32              # First volvo,then connecting bus
    33              return ('bus:335E','bus:connector')
    34          elif d>80:
    35              # Might as well go by normal bus
    36              return random.choice(('bus:330','bus:331',':'.join((favorite_option,
    37                           favorite_route))))
    38          elif d>90:
    39              # Relax and choose favorite route
    40              return ':'.join((favorite_option,
    41                               favorite_route))
    42  
    43      
    44  class C(object):
    45      """ A class which does almost nothing """
    46  
    47      def __init__(self, x,y):
    48          self.x = x
    49          self.y = y
    50          
    51      def f(self):
    52          pass
    53  
    54      def g(self, x, y):
    55  
    56          if self.x>x:
    57              return self.x+self.y
    58          elif x>self.x:
    59              return x+ self.y
    60  
    61  class D(C):
    62      """ D class """
    63  
    64      def __init__(self, x):
    65          self.x = x
    66  
    67      def f(self, x,y):
    68          if x>y:
    69              return x-y
    70          else:
    71              return x+y
    72  
    73      def g(self, y):
    74  
    75          if self.x>y:
    76              return self.x+y
    77          else:
    78              return y-self.x

运行静态检查器

让我们看看 Pylint 对我们相当可怕的测试代码的看法。

注意

Pylint 打印了许多样式错误,但由于这个示例的目的是专注于逻辑问题和代码异味,因此日志只显示了从这些报告开始。

$ pylint –reports=n metrictest.py

以下是两个截图中捕获的详细输出:

运行静态检查器

图 2. 测量测试程序的 Pylint 输出(第 1 页)

再看另一张截图:

运行静态检查器

测量测试程序的 Pylint 输出(第 2 页)

让我们专注于 Pylint 报告的最后 10-20 行非常有趣的部分,跳过早期的样式和约定警告。

以下是分类为表格的错误。为了保持表格的简洁,我们已跳过了类似的情况:

错误 出现次数 说明 代码异味类型
无效的函数名称 函数fn 名称fn太短,无法解释函数的作用 标识符太短
无效的变量名称 函数f的变量xy 名称xy太短,无法指示变量代表什么 标识符太短
无效的函数名称 函数名称find_optimal_route_to_my_office_from_home 函数名称太长 标识符太长
无效的变量名称 函数find_optimal的变量d 名称d太短,无法指示变量代表什么 标识符太短
无效的类名 C 名称C不表明类的任何信息 标识符太短
无效的方法名称 C:方法f 名称f太短,无法解释其作用 标识符太短
无效的__init__方法 D:方法__init__ 不调用基类的__init__ 与基类的合同违约
f的参数在类D中与类C不同 D:方法f 方法签名与基类的签名不符 拒绝继承
g的参数在类D中与类C不同 D:方法g 方法签名与基类的签名不符 拒绝继承

正如您所看到的,Pylint 检测到了许多代码异味,我们在上一节中讨论过。其中一些最有趣的是它如何检测到荒谬的长函数名称,以及子类 D 如何在其__init__和其他方法中违反了与基类C的合同。

让我们看看flake8对我们的代码有什么看法。我们将运行它以报告错误计数的统计和摘要:

$  flake8 --statistics --count metrictest.py

运行静态检查器

图 4。度量测试程序的 flake8 静态检查输出

正如您所期望的那样,这个工具大部分是按照 PEP-8 约定编写的,报告的错误都是样式和约定错误。这些错误对于提高代码的可读性并使其更接近 PEP-8 的样式指南是有用的。

注意

通过将选项“-show-pep8”传递给 Flake8,您可以获取有关 PEP-8 测试的更多信息。

现在是检查我们的代码复杂性的好时机。首先,我们将直接使用mccabe,然后通过 Flake8 调用它:

运行静态检查器

度量测试程序的 mccabe 复杂度

正如预期的那样,办公室路线函数的复杂性太高,因为它有太多的分支和子分支。

由于flake8打印了太多的样式错误,我们将专门搜索复杂性的报告:

运行静态检查器

由 flake8 报告的度量测试程序的 mccabe 复杂度

正如预期的那样,Flake8 报告了函数find_optimal_route_to_my_office_from_home的复杂性太高。

注意

还有一种方法可以从 Pylint 作为插件运行mccabe,但由于涉及一些配置步骤,我们将不在这里介绍。

最后一步,让我们在代码上运行pyflakes

运行静态检查器

pyflakes 对度量测试代码的静态分析输出

没有输出!因此,Pyflakes 在代码中找不到任何问题。原因是 PyFlakes 是一个基本的检查器,除了明显的语法和逻辑错误、未使用的导入、缺少变量名称和类似的问题外,它不报告任何其他问题。

让我们在我们的代码中添加一些错误,然后重新运行 Pyflakes。以下是带有行号的调整后的代码:

     1  """
     2  Module metrictest.py
     3  
     4  Metric example - Module which is used as a testbed for static checkers.
     5  This is a mix of different functions and classes doing different things.
     6  
     7  """
     8  import sys
     9  
    10  def fn(x, y):
    11      """ A function which performs a sum """
    12      return x + y
    13  
    14  def find_optimal_route_to_my_office_from_home(start_time,
    15                                      expected_time,
    16                                      favorite_route='SBS1K',
    17                                      favorite_option='bus'):
    18  
    19      # If I am very late, always drive.
    20      d = (expected_time – start_time).total_seconds()/60.0
    21  
    22      if d<=30:
    23          return 'car'
    24  
    25      # If d>30 but <45, first drive then take metro
    26      if d>30 and d<45:
    27          return ('car', 'metro')
    28  
    29      # If d>45 there are a combination of options
    30      if d>45:
    31          if d<60:
    32              # First volvo,then connecting bus
    33              return ('bus:335E','bus:connector')
    34          elif d>80:
    35              # Might as well go by normal bus
    36              return random.choice(('bus:330','bus:331',':'.join((favorite_option,
    37                           favorite_route))))
    38          elif d>90:
    39              # Relax and choose favorite route
    40              return ':'.join((favorite_option,
    41                               favorite_route))
    42  
    43      
    44  class C(object):
    45      """ A class which does almost nothing """
    46  
    47      def __init__(self, x,y):
    48          self.x = x
    49          self.y = y
    50          
    51      def f(self):
    52          pass
    53  
    54      def g(self, x, y):
    55  
    56          if self.x>x:
    57              return self.x+self.y
    58          elif x>self.x:
    59              return x+ self.y
    60  
    61  class D(C):
    62      """ D class """
    63  
    64      def __init__(self, x):
    65          self.x = x
    66  
    67      def f(self, x,y):
    68          if x>y:
    69              return x-y
    70          else:
    71              return x+y
    72  
    73      def g(self, y):
    74  
    75          if self.x>y:
    76              return self.x+y
    77          else:
    78              return y-self.x
    79  
    80  def myfunc(a, b):
    81      if a>b:
    82          return c
    83      else:
    84          return a

看一下以下输出:

运行静态检查器

图 8。修改后的度量测试代码的 pyflakes 静态分析输出

Pyflakes 现在返回一些有用的信息,例如缺少名称(random)、未使用的导入(sys)和新引入函数myfunc中的未定义名称(变量c)。因此,它对代码进行了一些有用的静态分析。例如,有关缺少和未定义名称的信息对于修复前面代码中的明显错误很有用。

提示

在编写代码后,运行 Pylint 和/或 Pyflakes 对代码进行报告和查找逻辑和语法错误是个好主意。要运行 Pylint 仅报告错误,使用-E 选项。要运行 Pyflakes,只需按照前面的示例。

重构代码

现在我们已经看到静态工具如何用于报告我们 Python 代码中的各种错误和问题,让我们做一个简单的重构练习。我们将以我们编写不好的度量测试模块作为用例(它的第一个版本),并执行一些重构步骤。

在进行重构时,我们将遵循以下大致指南:

  1. 首先修复复杂代码:这将大量代码放在一边,因为通常情况下,当复杂的代码被重构时,我们最终会减少代码行数。这将全面改善代码质量,并减少代码异味。您可能会在这里创建新的函数或类,因此最好先执行此步骤。

  2. 现在对代码进行分析:在这一步运行复杂性检查器是个好主意,看看代码的整体复杂性——类/模块或函数——是否有所减少。如果没有,再次迭代。

  3. 接下来修复代码异味:接下来修复任何与代码异味有关的问题——类、函数或模块。这将使您的代码变得更好,并且还会改善整体语义。

  4. 运行检查器:现在在代码上运行 Pylint 等检查器,并获取有关代码异味的报告。理想情况下,它们应该接近零,或者与原始值相比大大减少。

  5. 修复低悬果:最后修复低悬果,如代码风格和约定错误。这是因为在重构过程中,试图减少复杂性和代码异味时,通常会引入或删除大量代码。因此,在较早的阶段尝试改进编码约定是没有意义的。

  6. 使用工具进行最终检查:您可以运行 Pylint 检查代码异味,运行 Flake8 检查 PEP-8 约定,运行 Pyflakes 捕获逻辑、语法和缺少变量问题。

这是一个逐步演示,使用下一节中的方法修复我们编写不好的度量测试模块的过程。

重构代码-修复复杂性

大部分复杂性在 office route 函数中,所以让我们尝试修复它。这是重写的版本(仅显示该函数):

def find_optimal_route_to_my_office_from_home(start_time,
                                              expected_time,
                                              favorite_route='SBS1K',
                                              favorite_option='bus'):

        # If I am very late, always drive.
        d = (expected_time - start_time).total_seconds()/60.0

        if d<=30:
            return 'car'
        elif d<45:
            return ('car', 'metro')
        elif d<60:
            # First volvo,then connecting bus
            return ('bus:335E','bus:connector')
        elif d>80:
            # Might as well go by normal bus
            return random.choice(('bus:330','bus:331',':'.join((favorite_option,
                                         favorite_route))))
        # Relax and choose favorite route
        return ':'.join((favorite_option, favorite_route))

在前面的重写中,我们摆脱了多余的 if.. else 条件。现在让我们检查一下复杂性:

重构代码-修复复杂性

重构步骤#1 后度量测试程序的 mccabe 度量

我们已经将复杂性从7降低到5。我们能做得更好吗?

在以下代码片段中,代码被重写为使用值范围作为键,相应的返回值作为值。这大大简化了我们的代码。此外,之前的默认返回最终永远不会被选中,所以现在它被移除了,从而消除了一个分支,并减少了一个复杂性。代码变得简单多了:

deffind_optimal_route_to_my_office_from_home(start_time,
    expected_time,
    favorite_route='SBS1K',
    favorite_option='bus'):

    # If I am very late, always drive.
    d = (expected_time – start_time).total_seconds()/60.0
    options = { range(0,30): 'car',
    range(30, 45): ('car','metro'),
    range(45, 60): ('bus:335E','bus:connector') }

if d<80:
# Pick the range it falls into
for drange in options:
    if d in drange:
    return drange[d]

    # Might as well go by normal bus
    return random.choice(('bus:330','bus:331',':'.join((favorite_option, favorite_route))))

重构代码-修复复杂性

重构步骤#2 后度量测试程序的 mccabe 度量

该函数的复杂性现在降低到4,这是可以管理的。

重构代码-修复代码异味

下一步是修复代码异味。幸运的是,我们有一个非常好的列表来自上一次分析,所以这并不太困难。大多数情况下,我们需要更改函数名称、变量名称,并且还需要从子类到基类修复合同问题。

这是所有修复的代码:

""" Module metrictest.py - testing static quality metrics of Python code """

import random

def sum_fn(xnum, ynum):
    """ A function which performs a sum """

    return xnum + ynum

def find_optimal_route(start_time,
                       expected_time,
                       favorite_route='SBS1K',
                       favorite_option='bus'):
    """ Find optimal route for me to go from home to office """

    # Time difference in minutes - inputs must be datetime instances
    tdiff = (expected_time - start_time).total_seconds()/60.0

    options = {range(0, 30): 'car',
               range(30, 45): ('car', 'metro'),
               range(45, 60): ('bus:335E', 'bus:connector')}

    if tdiff < 80:
        # Pick the range it falls into
        for drange in options:
            if tdiff in drange:
                return drange[tdiff]

    # Might as well go by normal bus
    return random.choice(('bus:330', 'bus:331', ':'.join((favorite_option,
                                    favorite_route))))

class MiscClassC(object):
    """ A miscellaneous class with some utility methods """

    def __init__(self, xnum, ynum):
        self.xnum = xnum
        self.ynum = ynum

    def compare_and_sum(self, xnum=0, ynum=0):
        """ Compare local and argument variables
        and perform some sums """

        if self.xnum > xnum:
            return self.xnum + self.ynum
        else:
            return xnum + self.ynum

class MiscClassD(MiscClassC):
    """ Sub-class of MiscClassC overriding some methods """

    def __init__(self, xnum, ynum=0):
        super(MiscClassD, self).__init__(xnum, ynum)

    def some_func(self, xnum, ynum):
        """ A function which does summing """

        if xnum > ynum:
            return xnum - ynum
        else:
            return xnum + ynum

    def compare_and_sum(self, xnum=0, ynum=0):
        """ Compare local and argument variables
        and perform some sums """

        if self.xnum > ynum:
            return self.xnum + ynum
        else: 
            return ynum - self.xnum

让我们在这段代码上运行 Pylint,看看这次它输出了什么:

重构代码-修复代码异味

重构后的度量测试程序的 Pylint 输出

您会发现代码异味的数量已经减少到接近零,除了缺少public方法的投诉,以及类MiscClassD的方法some_func可以是一个函数的洞察,因为它不使用类的任何属性。

注意

我们已经使用选项–reports=n调用了 Pylint,以避免 Pylint 打印其摘要报告,因为这样会使整个输出太长而无法在此处显示。可以通过调用 Pylint 而不带任何参数来启用这些报告。

重构代码-修复样式和编码问题

现在我们已经解决了主要的代码问题,下一步是修复代码风格和约定错误。然而,为了缩短步骤数量和在本书中打印的代码量,这已经与最后一步合并,正如您可能从 Pylint 的输出中猜到的那样。

除了一些空白警告之外,所有问题都已解决。

这完成了我们的重构练习。

摘要

在本章中,我们看了修改性的架构质量属性及其各个方面。我们详细讨论了可读性,包括可读性反模式以及一些编码反模式。在讨论过程中,我们了解到 Python 从根本上就是一种为了可读性而编写的语言。

我们看了改进代码可读性的各种技术,并花了一些时间讨论了代码注释的各个方面,并在函数、类和模块级别上看了 Python 的文档字符串。我们还看了 PEP-8,这是 Python 的官方编码约定指南,并了解到持续重构代码对于保持其可修改性并在长期内减少维护成本是很重要的。

然后,我们看了一些代码注释的经验法则,并讨论了代码的修改性基本原理,即代码的耦合性和内聚性。我们用一些例子看了不同情况下的耦合和内聚。然后我们讨论了改进代码修改性的策略,比如提供明确的接口或 API、避免双向依赖、将常见服务抽象到辅助模块中,以及使用继承技术。我们看了一个例子,通过继承重构了一个类层次结构,以抽象出共同的代码并改进系统的修改性。

最后,我们列出了提供 Python 静态代码度量的不同工具,如 PyLint、Flake8、PyFlakes 等。我们通过一些例子了解了 McCabe 圈复杂度。我们还了解了代码异味是什么,并进行了重构练习,以逐步改进代码的质量。

在下一章中,我们将讨论软件架构的另一个重要质量属性,即可测试性。

第三章:可测试性 - 编写可测试代码

在上一章中,我们涵盖了软件的一个非常重要的架构属性,即可修改性及其相关方面。在本章中,我们将讨论软件的一个与之密切相关的质量属性——软件的可测试性。

在本书的第一章中,我们简要介绍了可测试性,了解了可测试性是什么,以及它与代码复杂性的关系。在本章中,我们将详细探讨软件可测试性的不同方面。

软件测试本身已经发展成一个拥有自己标准和独特工具和流程的大领域。本章的重点不是涵盖软件测试的正式方面。相反,我们在这里将努力从架构的角度理解软件测试,了解它与其他质量属性的关系,并在本章的后半部分讨论与我们在 Python 中使用软件测试相关的 Python 工具和库。

理解可测试性

可测试性可以定义如下:

“软件系统通过基于执行的测试轻松暴露其故障的程度”

具有高可测试性的软件系统通过测试提供了其故障的高度暴露,从而使开发人员更容易访问系统的问题,并允许他们更快地找到和修复错误。另一方面,可测试性较低的系统会使开发人员难以找出其中的问题,并且往往会导致生产中的意外故障。

因此,可测试性是确保软件系统在生产中的质量、稳定性和可预测性的重要方面。

软件的可测试性和相关属性

如果软件系统能够很容易地向测试人员暴露其故障,那么它就是可测试的。而且,系统应该以可预测的方式对测试人员进行有用的测试。一个不可预测的系统会在不同的时间给出不同的输出,因此是不可测试的(或者说没有用!)。

与不可预测性一样,复杂或混乱的系统也不太适合测试。例如,一个在负载下行为迥异的系统并不适合进行负载测试。因此,确定性行为对于确保系统的可测试性也是重要的。

另一个方面是测试人员对系统子结构的控制程度。为了设计有意义的测试,系统应该很容易地被识别为具有明确定义的 API 的子系统,可以为其编写测试。一个复杂的软件系统,如果不能轻松访问其子系统,从定义上来说,比那些可以访问的系统要难以测试得多。

这意味着结构上更复杂的系统比那些不复杂的系统更难测试。

让我们把这些列在一个易于阅读的表格中。

确定性 复杂性 可测试性

可测试性 - 架构方面

软件测试通常意味着正在评估被测试的软件产品的功能。然而,在实际的软件测试中,功能只是可能失败的方面之一。测试意味着评估软件的其他质量属性,如性能、安全性、健壮性等。

由于测试的不同方面,软件的可测试性通常被分为不同的级别。我们将从软件架构的角度来看这些方面。

以下是通常属于软件测试的不同方面的简要列表:

  • 功能测试:这涉及测试软件以验证其功能。如果软件单元按照其开发规范的预期行为,它通过了功能测试。功能测试通常有两种类型:

  • 白盒测试:这些通常是由开发人员实施的测试,他们可以看到软件代码。这里测试的单元是组成软件的个别函数、方法、类或模块,而不是最终用户功能。白盒测试的最基本形式是单元测试。其他类型包括集成测试和系统测试。

  • 黑盒测试:这种类型的测试通常由开发团队之外的人员执行。测试对软件代码没有可见性,将整个系统视为黑盒。黑盒测试测试系统的最终用户功能,而不关心其内部细节。这些测试通常由专门的测试或 QA 工程师执行。然而,如今,许多基于 Web 的应用程序的黑盒测试可以通过使用 Selenium 等测试框架进行自动化。

除了功能测试之外,还有许多测试方法,用于评估系统的各种架构质量属性。我们将在下面讨论这些。

  • 性能测试:衡量软件在高负载下的响应性和鲁棒性(稳定性)的测试属于这一类别。性能测试通常分为以下几种:

  • 负载测试:评估系统在特定负载下的性能,无论是并发用户数量、输入数据还是事务。

  • 压力测试:当某些输入突然或高速增长并达到极限时,测试系统的鲁棒性和响应。压力测试通常倾向于在规定的设计极限之外轻微测试系统。压力测试的变体是在一定的负载下长时间运行系统,并测量其响应性和稳定性。

  • 可扩展性测试:衡量系统在负载增加时能够扩展或扩大多少。例如,如果系统配置为使用云服务,这可以测试水平可扩展性——即系统在负载增加时如何自动扩展到一定数量的节点,或垂直可扩展性——即系统 CPU 核心和/或 RAM 的利用程度。

  • 安全测试:验证系统安全性的测试属于这一类别。对于基于 Web 的应用程序,这通常涉及通过检查给定的登录或角色只能执行指定的一组操作而不多(或更少)来验证角色的授权。属于安全性的其他测试包括验证对数据或静态文件的适当访问,以确保应用程序的所有敏感数据都受到适当的登录授权保护。

  • 可用性测试:可用性测试涉及测试系统的用户界面对其最终用户是否易于使用、直观和可理解。可用性测试通常通过包括符合预期受众或系统最终用户定义的选定人员的目标群体来进行。

  • 安装测试:对于运送到客户位置并在那里安装的软件,安装测试很重要。这测试并验证了在客户端构建和/或安装软件的所有步骤是否按预期工作。如果开发硬件与客户的不同,那么测试还涉及验证最终用户硬件中的步骤和组件。除了常规软件安装外,当交付软件更新、部分升级等时,安装测试也很重要。

  • 可访问性测试:从软件角度来看,可访问性指的是软件系统对残障用户的可用性和包容性程度。通常通过在系统中加入对可访问性工具的支持,并使用可访问性设计原则设计用户界面来实现。多年来已经制定了许多标准和指南,允许组织开发软件以使其对这样的受众具有可访问性。例如,W3C 的Web 内容可访问性指南WCAG)、美国政府的第五百零八部分等。

可访问性测试旨在根据这些标准评估软件的可访问性,适用时。

还有各种其他类型的软件测试,涉及不同的方法,并在软件开发的各个阶段调用,例如回归测试、验收测试、Alpha 或 Beta 测试等。

然而,由于我们讨论的重点是软件测试的架构方面,我们将把注意力限制在前面列表中提到的主题上。

可测试性 - 策略

我们在前面的部分中看到,测试性根据正在测试的软件系统的复杂性和确定性而变化。

能够隔离和控制正在测试的工件对软件测试至关重要。在测试系统的关注点分离中,即能够独立测试组件并且不过多地依赖外部是关键。

让我们看看软件架构师可以采用的策略,以确保他正在测试的组件提供可预测和确定的行为,从而提供有效和有用的测试结果。

减少系统复杂性

如前所述,复杂系统的可测试性较低。系统复杂性可以通过将系统拆分为子系统、为系统提供明确定义的 API 以进行测试等技术来减少。以下是这些技术的一些详细列表:

减少耦合:隔离组件,以减少系统中的耦合。组件间的依赖关系应该被明确定义,并且如果可能的话,应该被记录下来。

增加内聚性:增加模块的内聚性,即确保特定模块或类只执行一组明确定义的功能。

提供明确定义的接口:尝试为获取/设置组件和类的状态提供明确定义的接口。例如,getter 和 setter 允许提供用于获取和设置类属性值的特定方法。重置方法允许将对象的内部状态设置为其创建时的状态。在 Python 中,可以通过定义属性来实现这一点。

减少类的复杂性:减少一个类派生的类的数量。一个称为类响应RFC)的度量是类 C 的一组方法,以及类 C 的方法调用的其他类的方法。建议将类的 RFC 保持在可管理的限制范围内,通常对于小到中等规模的系统,不超过 50。

提高可预测性

我们看到,具有确定性行为对设计提供可预测结果的测试非常重要,因此可以用于构建可重复测试的测试工具。以下是一些改善被测试代码可预测性的策略:

  • 正确的异常处理:缺少或编写不当的异常处理程序是软件系统中错误和不可预测行为的主要原因之一。重要的是找出代码中可能发生异常的地方,然后处理错误。大多数情况下,异常发生在代码与外部资源交互时,例如执行数据库查询、获取 URL、等待共享互斥锁等。

  • 无限循环和/或阻塞等待:当编写依赖于特定条件的循环时,比如外部资源的可用性,或者从共享资源(如共享互斥锁或队列)获取句柄或数据时,重要的是要确保代码中始终提供安全的退出或中断条件。否则,代码可能会陷入永远不会中断的无限循环,或者在资源上永远阻塞等待,导致难以排查和修复的错误。

  • 时间相关的逻辑:在实现依赖于一天中特定时间(小时或特定工作日)的逻辑时,确保代码以可预测的方式工作。在测试这样的代码时,通常需要使用模拟或存根来隔离这些依赖关系。

  • 并发:在编写使用并发方法(如多线程和/或进程)的代码时,重要的是确保系统逻辑不依赖于线程或进程以任何特定顺序启动。系统状态应该通过定义良好的函数或方法以一种干净和可重复的方式初始化,从而使系统行为可重复,因此可测试。

  • 内存管理:软件错误和不可预测性的一个非常常见的原因是内存的错误使用和管理不当。在具有动态内存管理的现代运行时环境中,如 Python、Java 或 Ruby,这不再是一个问题。然而,内存泄漏和未释放的内存导致软件膨胀仍然是现代软件系统中非常真实的问题。

重要的是要分析并能够预测软件系统的最大内存使用量,以便为其分配足够的内存,并在正确的硬件上运行。此外,软件应定期进行内存泄漏和更好的内存管理的评估和测试,并且应该解决和修复任何主要问题。

控制和隔离外部依赖

测试通常具有某种外部依赖。例如,一个测试可能需要从数据库中加载/保存数据。另一个可能依赖于一天中特定的时间运行测试。第三个可能需要从 Web 上的 URL 获取数据。

然而,具有外部依赖通常会使测试场景变得更加复杂。这是因为外部依赖通常不在测试设计者的控制范围内。在上述情况下,数据库可能位于另一个数据中心,或者连接可能失败,或者网站可能在配置的时间内不响应,或者出现 50X 错误。

在设计和编写可重复的测试时,隔离这些外部依赖非常重要。以下是一些相同的技术:

  • 数据源:大多数真实的测试都需要某种形式的数据。往往情况下,数据是从数据库中读取的。然而,数据库作为外部依赖,不能被依赖。以下是一些控制数据源依赖的技术:

  • 使用本地文件而不是数据库:经常可以使用预填充数据的测试文件,而不是查询数据库。这些文件可以是文本、JSON、CSV 或 YAML 文件。通常,这些文件与模拟或存根对象一起使用。

  • 使用内存数据库:与连接到真实数据库不同,可以使用一个小型的内存数据库。一个很好的例子是 SQLite DB,它是一个基于文件或内存的数据库,实现了一个良好但是最小的 SQL 子集。

  • 使用测试数据库:如果测试确实需要数据库,操作可以使用一个使用事务的测试数据库。数据库在测试用例的setUp()方法中设置,并在tearDown()方法中回滚,以便在操作结束时不留下真实数据。

  • 资源虚拟化: 为了控制系统外部资源的行为,可以对它们进行虚拟化,即构建这些资源的版本,模仿它们的 API,但不是内部实现。一些常见的资源虚拟化技术如下:

  • 存根: 存根为测试期间进行的函数调用提供标准(预定义)响应。Stub()函数替换了它替代的函数的细节,只返回所需的响应。

例如,这是一个根据给定 URL 返回data的函数:

import hashlib
import requests

def get_url_data(url):
    """ Return data for a URL """

    # Return data while saving the data in a file 
    # which is a hash of the URL
    data = requests.get(url).content
    # Save it in a filename
    filename = hashlib.md5(url).hexdigest()
    open(filename, 'w').write(data)
    return data

以下是替代它的存根,它内部化了 URL 的外部依赖:

import os

def get_url_data_stub(url):
    """ Stub function replacing get_url_data """

    # No actual web request is made, instead 
    # the file is opened and data returned
    filename = hashlib.md5(url).hexdigest()
    if os.path.isfile(filename):
        return open(filename).read()

编写这样一个函数的更常见的方法是将原始请求和文件缓存合并到同一代码中。URL 只被请求一次——在第一次调用函数时——在后续请求中,从文件缓存返回数据。

def get_url_data(url):
    """ Return data for a URL """

    # First check for cached file - if so return its
    # contents. Note that we are not checking for
    # age of the file - so content may be stale.
    filename = hashlib.md5(url).hexdigest()
    if os.path.isfile(filename):
        return open(filename).read()

    # First time - so fetch the URL and write to the
    # file. In subsequent calls, the file contents will
    # be returned.
    data = requests.get(url).content
    open(filename, 'w').write(data)

    return data
  • 模拟: 模拟对象是对它们替代的真实世界对象的 API 进行伪装。一个程序可以通过设置期望来直接在测试中模拟对象——期望函数将期望的参数类型和顺序以及它们将返回的响应。稍后,可以选择性地在验证步骤中验证这些期望。

注意

模拟和存根之间的主要区别在于存根只实现了足够的行为,使得被测试对象能够执行测试。模拟通常会超出范围,还会验证被测试对象是否按预期调用模拟——例如,参数的数量和顺序。

使用模拟对象时,测试的一部分涉及验证模拟是否被正确使用。换句话说,模拟和存根都回答了问题,“结果是什么?”,但模拟还回答了问题,“结果是如何实现的?”

我们将在后面看到使用 Python 进行模拟的单元测试的示例。

  • 伪造: Fake对象具有工作实现,但由于存在一些限制,不足以用于生产。Fake对象提供了一个非常轻量级的实现,不仅仅是存根对象。

例如,这是一个实现非常简单的日志记录的Fake对象,模仿了 Python 的日志记录模块的Logger对象的 API:

import logging

class FakeLogger(object):
    """ A class that fakes the interface of the 
    logging.Logger object in a minimalistic fashion """

    def __init__(self):
        self.lvl = logging.INFO

    def setLevel(self, level):
        """ Set the logging level """
        self.lvl = level

    def _log(self, msg, *args):
        """ Perform the actual logging """

        # Since this is a fake object - no actual logging is 
        # done.
        # Instead the message is simply printed to standard 
        # output.

        print (msg, end=' ')
        for arg in args:
            print(arg, end=' ')
        print()

    def info(self, msg, *args):
        """ Log at info level """
        if self.lvl<=logging.INFO: return self._log(msg, *args)

    def debug(self, msg, *args):
        """ Log at debug level """
        if self.lvl<=logging.DEBUG: return self._log(msg, *args)

    def warning(self, msg, *args):
        """ Log at warning level """
        if self.lvl<=logging.WARNING: return self._log(msg, *args)          

    def error(self, msg, *args):
        """ Log at error level """
        if self.lvl<=logging.ERROR: return self._log(msg, *args)    

    def critical(self, msg, *args):
        """ Log at critical level """
        if self.lvl<=logging.CRITICAL: return self._log(msg, *args)

前面代码中的FakeLogger类实现了logging.Logger类的一些主要方法,它试图伪装。

它作为替换Logger对象来实现测试的伪造对象是理想的。

白盒测试原则

从软件架构的角度来看,测试的一个最重要的步骤是在软件开发时进行。软件的行为或功能,只对最终用户可见,是软件实现细节的产物。

因此,一个早期进行测试并经常进行测试的系统更有可能产生一个可测试和健壮的系统,以满意的方式为最终用户提供所需的功能。

因此,实施测试原则的最佳方式是从源头开始,也就是软件编写的地方,由开发人员来实施。由于源代码对开发人员可见,这种测试通常被称为白盒测试。

那么,我们如何确保我们可以遵循正确的测试原则,并在软件开发过程中进行尽职调查呢?让我们来看看在软件最终呈现给客户之前,在开发阶段涉及的不同类型的测试。

单元测试

单元测试是开发人员执行的最基本的测试类型。单元测试通过使用可执行的断言来应用软件代码的最基本单元——通常是函数或类方法——来检查被测试单元的输出与预期结果是否一致。

在 Python 中,通过标准库中的unittest模块提供对单元测试的支持。

单元测试模块提供以下高级对象。

  • 测试用例unittest模块提供了TestCase类,它提供了对测试用例的支持。可以通过继承这个类并设置测试方法来设置一个新的测试用例类。每个测试方法将通过检查响应与预期结果是否匹配来实现单元测试。

  • 测试固件:测试固件代表一个或多个测试所需的任何设置或准备工作,然后是任何清理操作。例如,这可能涉及创建临时或内存数据库,启动服务器,创建目录树等。在unittest模块中,通过TestCase类的setUp()tearDown()方法以及TestSuite类的相关类和模块方法提供了对固件的支持。

  • 测试套件:测试套件是相关测试用例的聚合。测试套件还可以包含其他测试套件。测试套件允许将在软件系统上执行功能相似的测试的测试用例分组,并且其结果应该一起阅读或分析。unittest模块通过TestSuite类提供了对测试套件的支持。

  • 测试运行器:测试运行器是一个管理和运行测试用例,并向测试人员提供结果的对象。测试运行器可以使用文本界面或图形界面。

  • 测试结果:测试结果类管理向测试人员显示的测试结果输出。测试结果总结了成功、失败和出错的测试用例数量。在unittest模块中,这是通过TestResult类实现的,具体的默认实现是TextTestResult类。

在 Python 中提供支持单元测试的其他模块包括 nose(nose2)和py.test。我们将在接下来的部分简要讨论每一个。

单元测试实例

让我们来做一个具体的单元测试任务,然后尝试构建一些测试用例和测试套件。由于unittest模块是最流行的,并且在 Python 标准库中默认可用,我们将首先从它开始。

为了我们的测试目的,我们将创建一个具有一些用于日期/时间转换的方法的类。

以下代码显示了我们的类:

""" Module datetime helper - Contains the class DateTimeHelper providing some helpful methods for working with date and datetime objects """

import datetime
class DateTimeHelper(object):
    """ A class which provides some convenient date/time
    conversion and utility methods """

    def today(self):
        """ Return today's datetime """
        return datetime.datetime.now()

    def date(self):
        """ Return today's date in the form of DD/MM/YYYY """
        return self.today().strftime("%d/%m/%Y")

    def weekday(self):
        """ Return the full week day for today """
        return self.today().strftime("%A")

    def us_to_indian(self, date):
        """ Convert a U.S style date i.e mm/dd/yy to Indian style dd/mm/yyyy """

        # Split it
        mm,dd,yy = date.split('/')
        yy = int(yy)
        # Check if year is >16, else add 2000 to it
        if yy<=16: yy += 2000
        # Create a date object from it
        date_obj = datetime.date(year=yy, month=int(mm), day=int(dd))
        # Retur it in correct format
        return date_obj.strftime("%d/%m/%Y")

我们的DateTimeHelper类有一些方法,如下所示:

  • date:以 dd/mm/yyyy 格式返回当天的时间戳

  • weekday:返回当天的星期几,例如,星期日,星期一等等

  • us_to_indian:将美国日期格式(mm/dd/yy(yy))转换为印度格式(dd/mm/yyyy)

这是一个unittest TestCase类,它实现了对最后一个方法的测试:

""" Module test_datetimehelper -  Unit test module for testing datetimehelper module """

import unittest
import datetimehelper

class DateTimeHelperTestCase(unittest.TestCase):
     """ Unit-test testcase class for DateTimeHelper class """

    def setUp(self):
        print("Setting up...")
        self.obj = datetimehelper.DateTimeHelper()

    def test_us_india_conversion(self):
        """ Test us=>india date format conversion """

        # Test a few dates
        d1 = '08/12/16'
        d2 = '07/11/2014'
        d3 = '04/29/00'
        self.assertEqual(self.obj.us_to_indian(d1), '12/08/2016')
        self.assertEqual(self.obj.us_to_indian(d2), '11/07/2014')
        self.assertEqual(self.obj.us_to_indian(d3), '29/04/2000')

if __name__ == "__main__":
    unittest.main()

请注意,在测试用例代码的主要部分中,我们只是调用了unittest.main()。这会自动找出模块中的测试用例,并执行它们。以下图片显示了测试运行的输出:

单元测试实例

datetimehelper模块的单元测试案例输出 - 版本#1

从输出中可以看出,这个简单的测试用例通过了。

扩展我们的单元测试用例

您可能已经注意到datetimehelper模块的第一个版本的单元测试用例只包含了一个方法的测试,即将美国日期格式转换为印度日期格式的方法。

但是,其他两种方法呢?难道我们也不应该为它们编写单元测试吗?

其他两种方法的问题在于它们获取来自今天日期的数据。换句话说,输出取决于代码运行的确切日期。因此,无法通过输入日期值并期望结果与预期结果匹配来为它们编写特定的测试用例,因为代码是时间相关的。我们需要一种方法来控制这种外部依赖。

这里是 Mocking 来拯救我们。记住我们曾讨论过 Mock 对象作为控制外部依赖的一种方式。我们可以使用unittest.mock库的修补支持,并修补返回今天日期的方法,以返回我们控制的日期。这样,我们就能够测试依赖于它的方法。

以下是修改后的测试用例,使用了这种技术来支持两种方法:

""" Module test_datetimehelper -  Unit test module for testing datetimehelper module """

import unittest
import datetime
import datetimehelper
from unittest.mock import patch

class DateTimeHelperTestCase(unittest.TestCase):
    """ Unit-test testcase class for DateTimeHelper class """

    def setUp(self):
        self.obj = datetimehelper.DateTimeHelper()

    def test_date(self):
        """ Test date() method """

        # Put a specific date to test
        my_date = datetime.datetime(year=2016, month=8, day=16)

        # Patch the 'today' method with a specific return value
        with patch.object(self.obj, 'today', return_value=my_date):
            response = self.obj.date()
            self.assertEqual(response, '16/08/2016')

    def test_weekday(self):
        """ Test weekday() method """

        # Put a specific date to test
        my_date = datetime.datetime(year=2016, month=8, day=21)

        # Patch the 'today' method with a specific return value
        with patch.object(self.obj, 'today', return_value=my_date):
            response = self.obj.weekday()
            self.assertEqual(response, 'Sunday')            

    def test_us_india_conversion(self):
        """ Test us=>india date format conversion """

        # Test a few dates
        d1 = '08/12/16'
        d2 = '07/11/2014'
        d3 = '04/29/00'
        self.assertEqual(self.obj.us_to_indian(d1), '12/08/2016')
        self.assertEqual(self.obj.us_to_indian(d2), '11/07/2014')
        self.assertEqual(self.obj.us_to_indian(d3), '29/04/2000')

if __name__ == "__main__":
    unittest.main()

正如你所看到的,我们已经对today方法进行了修补,使其在两个测试方法中返回特定日期。这使我们能够控制该方法的输出,并将结果与特定结果进行比较。

以下是测试用例的新输出:

扩展我们的单元测试用例

单元测试用例的输出,用于 datetimehelper 模块,增加了两个测试 - 版本#2

提示

注意:unittest.mainunittest模块上的一个便利函数,它可以轻松地从一个模块中自动加载一组测试用例并运行它们。

要了解测试运行时发生了什么的更多细节,我们可以通过增加冗长度来让测试运行器显示更多信息。可以通过将verbosity参数传递给unittest.main,或者通过在命令行上传递-v选项来实现。

扩展我们的单元测试用例

通过传递-v参数来从单元测试用例中生成冗长输出

用 nose2 四处嗅探

Python 中还有其他单元测试模块,它们不是标准库的一部分,但作为第三方包可用。我们将看一下第一个名为nose的模块。最新版本(写作时)是版本 2,该库已更名为 nose2。

可以使用 Python 包安装程序 pip 来安装 nose2 包。

$ pip install nose2

运行 nose2 非常简单。它会自动检测要从中运行的 Python 测试用例所在的文件夹,方法是查找从unittest.TestCase派生的类,以及以test开头的函数。

在我们的 datetimehelper 测试用例中,nose2 会自动捡起它。只需从包含模块的文件夹中运行它。以下是测试输出:

用 nose2 四处嗅探

使用 nose2 运行单元测试

然而,前面的输出并没有报告任何内容,因为默认情况下,nose2 会静默运行。我们可以通过使用冗长选项(-v)来打开一些测试报告。

用 nose2 四处嗅探

使用 nose2 运行单元测试,带有冗长输出

nose2 还支持使用插件来报告代码覆盖。我们将在后面的部分看到代码覆盖。

使用 py.test 进行测试

py.test 包,通常称为 pytest,是 Python 的一个功能齐全、成熟的测试框架。与 nose2 一样,py.test 也支持通过查找以特定模式开头的文件来发现测试。

py.test 也可以使用 pip 安装。

$ pip install pytest

像 nose2 一样,使用 py.test 进行测试也很容易。只需在包含测试用例的文件夹中运行可执行文件 pytest。

使用 py.test 进行测试

使用 py.test 进行测试发现和执行

像 nose2 一样,pytest 也具有自己的插件支持,其中最有用的是代码覆盖插件。我们将在后面的部分看到示例。

需要注意的是,pytest 不要求测试用例正式派生自unittest.TestCase模块。Py.test 会自动从包含以Test为前缀的类或以test_为前缀的函数的模块中发现测试。

例如,这里有一个新的测试用例,没有依赖于unittest模块,但测试用例类是从 Python 中最基本的类型 object 派生的。新模块名为test_datetimehelper_object

""" Module test_datetimehelper_object - Simple test case with test class derived from object """ 

import datetimehelper

class TestDateTimeHelper(object):

    def test_us_india_conversion(self):
        """ Test us=>india date format conversion """

        obj = datetimehelper.DateTimeHelper()
        assert obj.us_to_indian('1/1/1') == '01/01/2001'

请注意,这个类与unittest模块没有任何依赖关系,并且没有定义任何固定装置。以下是现在在文件夹中运行 pytest 的输出:

使用 py.test 进行测试

使用 py.test 进行测试用例发现和执行,而不使用 unittest 模块支持

pytest 已经捕捉到了这个模块中的测试用例,并自动执行了它,正如输出所示。

nose2 也具有类似的功能来捕捉这样的测试用例。下一张图片显示了 nose2 对新测试用例的输出。

使用 py.test 进行测试

使用 nose2 进行测试用例发现和执行,而不使用 unittest 模块支持

上述输出显示了新测试已被捕捉并执行。

unittest模块、nose2 和 py.test 包提供了大量支持,以非常灵活和可定制的方式开发和实现测试用例、固定装置和测试套件。讨论这些工具的多种选项超出了本章的范围,因为我们的重点是了解这些工具,以理解我们如何使用它们来满足测试性的架构质量属性。

因此,我们将继续讨论单元测试的下一个重要主题,即代码覆盖率。我们将看看这三个工具,即unittest、nose2 和 py.test,以及它们如何允许架构师帮助他的开发人员和测试人员找到有关他们单元测试中代码覆盖率的信息。

代码覆盖率

代码覆盖率是衡量被测试的源代码被特定测试套件覆盖的程度。理想情况下,测试套件应该追求更高的代码覆盖率,因为这将使更大比例的源代码暴露给测试,并有助于发现错误。

代码覆盖率指标通常报告为代码行数LOC)的百分比,或者测试套件覆盖的子程序(函数)的百分比。

现在让我们看看不同工具对于测量代码覆盖率的支持。我们将继续使用我们的测试示例(datetimehelper)进行这些说明。

使用 coverage.py 进行覆盖率测量

Coverage.py 是一个第三方的 Python 模块,它与使用unittest模块编写的测试套件和测试用例一起工作,并报告它们的代码覆盖率。

Coverage.py 可以像其他工具一样使用 pip 进行安装。

$ pip install coverage

这个最后的命令安装了 coverage 应用程序,用于运行和报告代码覆盖率。

Coverage.py 有两个阶段:首先,它运行一段源代码,并收集覆盖信息,然后报告覆盖数据。

要运行 coverage.py,请使用以下语法:

 **$ coverage run <source file1> <source file 2> …

运行完成后,使用此命令报告覆盖率:

 **$ coverage report -m

例如,这是我们测试模块的输出:

使用 coverage.py 进行覆盖率测量

使用 coverage.py 对 datetimehelper 模块进行测试覆盖率报告

Coverage.py 报告称我们的测试覆盖了datetimehelper模块中93%的代码,这是相当不错的代码覆盖率。(您可以忽略关于测试模块本身的报告。)

使用 nose2 进行覆盖率测量

nose2 包带有用于代码覆盖率的插件支持。这不是默认安装的。要为 nose2 安装代码覆盖插件,请使用此命令:

$ pip install cov-core

现在,nose2 可以使用代码覆盖选项运行测试用例,并一次性报告覆盖率。可以这样做:

$ nose2 -v -C

注意

注意:在幕后,cov-core 利用 coverage.py 来完成其工作,因此 coverage.py 和 nose2 的覆盖度度量报告是相同的。

以下是使用 nose2 运行测试覆盖率的输出:

使用 nose2 进行覆盖率测量

使用 nose2 对 datetimehelper 模块进行测试覆盖率报告

默认情况下,覆盖率报告会被写入控制台。要生成其他形式的输出,可以使用–coverage-report选项。例如,--coverage-report html将以 HTML 格式将覆盖率报告写入名为htmlcov的子文件夹。

使用 nose2 进行覆盖率测量

使用 nose2 生成 HTML 覆盖率输出

以下是浏览器中的 HTML 输出效果:

使用 nose2 测量覆盖率

在浏览器中查看的 HTML 覆盖报告

使用 py.test 测量覆盖率

Pytest 还配备了自己的覆盖插件,用于报告代码覆盖。与 nose2 一样,它在后台利用 coverage.py 来完成工作。

为了为 py.test 提供代码覆盖支持,需要安装pytest-cov包,如下所示:

$ pip install pytest-cov

要报告当前文件夹中测试用例的代码覆盖率,请使用以下命令:

$ pytest –cov

以下是 pytest 代码覆盖的示例输出:

使用 py.test 测量覆盖率

使用 py.test 运行当前文件夹的代码覆盖

模拟事物

我们在之前的测试示例中看到了使用unittest.mock的 patch 支持的示例。然而,unittest提供的 Mock 支持甚至比这个更强大,所以让我们看一个更多的例子来理解它的强大和适用性在编写单元测试中。

为了说明这一点,我们将考虑一个在大型数据集上执行关键字搜索并按权重排序返回结果的类,并假设数据集存储在数据库中,并且结果作为(句子、相关性)元组列表返回,其中句子是具有关键字匹配的原始字符串,相关性是其在结果集中的命中权重。

以下是代码:

"""
Module textsearcher - Contains class TextSearcher for performing search on a database and returning results
"""

import operator

class TextSearcher(object):
    """ A class which performs a text search and returns results """

    def __init__(self, db):
        """ Initializer - keyword and database object """

        self.cache = False
        self.cache_dict = {}
        self.db = db
        self.db.connect()

    def setup(self, cache=False, max_items=500):
        """ Setup parameters such as caching """

        self.cache = cache
        # Call configure on the db
        self.db.configure(max_items=max_items)

    def get_results(self, keyword, num=10):
        """ Query keyword on db and get results for given keyword """

        # If results in cache return from there
        if keyword in self.cache_dict:
            print ('From cache')
            return self.cache_dict[keyword]

        results = self.db.query(keyword)
        # Results are list of (string, weightage) tuples
        results = sorted(results, key=operator.itemgetter(1), reverse=True)[:num]
        # Cache it
        if self.cache:
            self.cache_dict[keyword] = results

        return results

该类有以下三种方法:

  • __init__:初始化器,它接受一个充当数据源(数据库)句柄的对象;还初始化了一些属性,并连接到数据库

  • setup:它设置搜索器,并配置数据库对象

  • get_results:它使用数据源(数据库)执行搜索,并返回给定关键字的结果

我们现在想要为这个搜索器实现一个单元测试用例。由于数据库是一个外部依赖,我们将通过模拟来虚拟化数据库对象。我们将仅测试搜索器的逻辑、可调用签名和返回数据。

我们将逐步开发这个程序,以便每个模拟步骤对您来说都是清晰的。我们将使用 Python 交互式解释器会话来进行相同的操作。

首先,是必要的导入。

>>> from unittest.mock import Mock, MagicMock
>>> import textsearcher
>>> import operator

由于我们想要模拟数据库,第一步就是确切地做到这一点。

>>> db = Mock()

现在让我们创建searcher对象。我们不打算模拟这个,因为我们需要测试其方法的调用签名和返回值。

>>> searcher = textsearcher.TextSearcher(db)

此时,数据库对象已被传递给searcher__init__方法,并且已经在其上调用了connect。让我们验证这个期望。

>>> db.connect.assert_called_with()

没有问题,所以断言成功了!现在让我们设置searcher

>>> searcher.setup(cache=True, max_items=100)

查看TextSearcher类的代码,我们意识到前面的调用应该调用数据库对象上的configure方法,并将参数max_items设置为值100。让我们验证一下。

>>> searcher.db.configure.assert_called_with(max_items=100)
<Mock name='mock.configure_assert_called_with()' id='139637252379648'>

太棒了!

最后,让我们尝试并测试get_results方法的逻辑。由于我们的数据库是一个模拟对象,它将无法执行任何实际查询,因此我们将一些预先准备好的结果传递给它的query方法,有效地模拟它。

>>> canned_results = [('Python is wonderful', 0.4),
...                       ('I like Python',0.8),
...                       ('Python is easy', 0.5),
...                       ('Python can be learnt in an afternoon!', 0.3)]
>>> db.query = MagicMock(return_value=canned_results)

现在我们设置关键字和结果的数量,并使用这些参数调用get_results

>>> keyword, num = 'python', 3
>>> data = searcher.get_results(python, num=num)

让我们检查数据。

>>> data
[('I like Python', 0.8), ('Python is easy', 0.5), ('Python is wonderful', 0.4)]

看起来不错!

在下一步中,我们验证get_results确实使用给定的关键字调用了query

>>> searcher.db.query.assert_called_with(keyword)

最后,我们验证返回的数据是否已正确排序并截断到我们传递的结果数(num)值。

>>> results = sorted(canned_results, key=operator.itemgetter(1), reverse=True)[:num]
>>> assert data == results
True

一切正常!

该示例显示了如何使用unittest模块中的 Mock 支持来模拟外部依赖项并有效地虚拟化它,同时测试程序的逻辑、控制流、可调用参数和返回值。

这是一个测试模块,将所有这些测试组合成一个单独的测试模块,并在其上运行 nose2 的输出。

"""
Module test_textsearch - Unittest case with mocks for textsearch module
"""

from unittest.mock import Mock, MagicMock
import textsearcher
import operator

def test_search():
    """ Test search via a mock """

    # Mock the database object
    db = Mock()
    searcher = textsearcher.TextSearcher(db)
    # Verify connect has been called with no arguments
    db.connect.assert_called_with()
    # Setup searcher
    searcher.setup(cache=True, max_items=100)
    # Verify configure called on db with correct parameter
    searcher.db.configure.assert_called_with(max_items=100)

    canned_results = [('Python is wonderful', 0.4),
                      ('I like Python',0.8),
                      ('Python is easy', 0.5),
                      ('Python can be learnt in an afternoon!', 0.3)]
    db.query = MagicMock(return_value=canned_results)

    # Mock the results data
    keyword, num = 'python', 3
    data = searcher.get_results(keyword,num=num)
    searcher.db.query.assert_called_with(keyword)

    # Verify data 
    results = sorted(canned_results, key=operator.itemgetter(1), reverse=True)[:num]
    assert data == results

这是 nose2 在这个测试用例上的输出:

模拟事物

使用 nose2 运行 testsearcher 测试用例

为了保险起见,让我们也看一下我们的模拟测试示例test_textsearch模块的覆盖率,使用 py.test 覆盖率插件。

模拟事物

通过使用 py.test 测试文本搜索测试用例来测量 textsearcher 模块的覆盖率

所以我们的模拟测试覆盖率为90%,只有20个语句中的两个没有覆盖到。还不错!

文档中的内联测试 - doctests

Python 对另一种内联代码测试有独特的支持,通常称为doctests。这些是函数、类或模块文档中的内联单元测试,通过将代码和测试结合在一个地方,无需开发或维护单独的测试套件,从而增加了很多价值。

doctest 模块通过查找代码文档中看起来像 Python 字符串的文本片段来工作,并执行这些会话以验证它们确实与找到的一样工作。任何测试失败都会在控制台上报告。

让我们看一个代码示例来看看它是如何运作的。以下代码实现了简单的阶乘函数,采用了迭代方法:

"""
Module factorial - Demonstrating an example of writing doctests
"""

import functools
import operator

def factorial(n):
    """ Factorial of a number.

    >>> factorial(0)
    1    
    >>> factorial(1)
    1
    >>> factorial(5)
    120
    >>> factorial(10)
    3628800

    """

    return functools.reduce(operator.mul, range(1,n+1))

if __name__ == "__main__":
    import doctest
    doctest.testmod(verbose=True)

让我们来看一下执行这个模块的输出。

文档中的内联测试 - doctests

阶乘模块的 doctest 输出

Doctest 报告说四个测试中有一个失败了。

快速扫描输出告诉我们,我们忘记编写计算零的阶乘的特殊情况。错误是因为代码尝试计算 range(1, 1),这会导致reduce引发异常。

代码可以很容易地重写以解决这个问题。以下是修改后的代码:

"""
Module factorial - Demonstrating an example of writing doctests
"""

import functools
import operator

def factorial(n):
    """ Factorial of a number.

    >>> factorial(0)
    1    
    >>> factorial(1)
    1
    >>> factorial(5)
    120
    >>> factorial(10)
    3628800
    """

    # Handle 0 as a special case
    if n == 0:
        return 1

    return functools.reduce(operator.mul, range(1,n+1))

if __name__ == "__main__":
    import doctest
    doctest.testmod(verbose=True)

下一张图片显示了现在执行模块的新输出:

文档中的内联测试 - doctests

修复后阶乘模块的 doctest 输出

现在所有的测试都通过了。

注意

注意:在这个例子中,我们打开了 doctest 模块的testmod函数的详细选项,以显示测试的详细信息。如果没有这个选项,如果所有测试都通过,doctest 将保持沉默,不产生任何输出。

doctest 模块非常灵活。它不仅可以加载 Python 代码,还可以从文本文件等来源加载 Python 交互会话,并将它们作为测试执行。

Doctest 检查所有文档字符串,包括函数、类和模块文档字符串,以搜索 Python 交互会话。

注意

注意:pytest 包内置支持 doctests。要允许 pytest 发现并运行当前文件夹中的 doctests,请使用以下命令:

$ pytest –doctest-modules

集成测试

单元测试虽然在软件开发生命周期早期的白盒测试中非常有用,可以发现和修复错误,但单靠它们是不够的。只有当不同组件按预期方式协同工作,以向最终用户提供所需的功能并满足预定义的架构质量属性时,软件系统才能完全正常运行。这就是集成测试的重要性所在。

集成测试的目的是验证软件系统的不同功能子系统的功能、性能和其他质量要求,这些子系统作为一个逻辑单元提供某些功能。这些子系统通过它们各自单元的累积行动来提供一些功能。虽然每个组件可能已经定义了自己的单元测试,但通过编写集成测试来验证系统的组合功能也是很重要的。

集成测试通常是在单元测试完成之后,验证测试之前编写的。

在这一点上,列出集成测试提供的优势将是有益的,因为这对于任何在设计和实现了不同组件的单元测试的软件架构师来说都是有用的。

  • 测试组件的互操作性:功能子系统中的每个单元可能由不同的程序员编写。尽管每个程序员都知道他的组件应该如何执行,并可能已经为其编写了单元测试,但整个系统可能在协同工作方面存在问题,因为组件之间的集成点可能存在错误或误解。集成测试将揭示这样的错误。

  • 测试系统需求修改:需求可能在实施期间发生了变化。这些更新的需求可能没有经过单元测试,因此,集成测试非常有用,可以揭示问题。此外,系统的某些部分可能没有正确实现需求,这也可以通过适当的集成测试来揭示。

  • 测试外部依赖和 API:当今软件组件使用大量第三方 API,这些 API 通常在单元测试期间被模拟或存根。只有集成测试才能揭示这些 API 的性能,并暴露调用约定、响应数据或性能方面的任何问题。

  • 调试硬件问题:集成测试有助于获取有关任何硬件问题的信息,调试这些测试可以为开发人员提供有关是否需要更新或更改硬件配置的数据。

  • 揭示代码路径中的异常:集成测试还可以帮助开发人员找出他们在代码中可能没有处理的异常,因为单元测试不会执行引发此类错误的路径或条件。更高的代码覆盖率可以识别和修复许多此类问题。然而,一个良好的集成测试结合每个功能的已知代码路径和高覆盖率是确保在使用过程中可能发生的大多数潜在错误都被发现并在测试期间执行的良好公式。

编写集成测试有三种方法。它们如下:

  • 自下而上:在这种方法中,首先测试低层组件,然后使用这些测试结果来集成链中更高级组件的测试。该过程重复进行,直到达到与控制流相关的组件层次结构的顶部。在这种方法中,层次结构顶部的关键模块可能得到不充分的测试。

如果顶层组件正在开发中,可能需要使用驱动程序来模拟它们。

集成测试

自下而上的集成测试策略

  • 自上而下:测试开发和测试按照软件系统中的工作流程自上而下进行。因此,首先测试层次结构顶部的组件,最后测试低级模块。在这种方法中,首要测试关键模块,因此我们可以首先识别主要的设计或开发缺陷并加以修复。然而,低级模块可能得到不充分的测试。

低级模块可以被模拟其功能的存根所替代。在这种方法中,早期原型是可能的,因为低级模块逻辑可以被存根化。

集成测试

自上而下的集成测试策略

  • 大爆炸:这种方法是在开发的最后阶段集成和测试所有组件。由于集成测试是在最后进行的,这种方法节省了开发时间。然而,这可能不会给予足够的时间来测试关键模块,因为可能没有足够的时间平等地花在所有组件上。

没有特定的软件用于一般集成测试。某些类别的应用程序,如 Web 框架,定义了自己特定的集成测试框架。例如,一些 Web 框架如 Django、Pyramid 和 Flask 都有一些由其自己社区开发的特定测试框架。

另一个例子是流行的webtest框架,它对 Python WSGI 应用程序的自动化测试很有用。这些框架的详细讨论超出了本章和本书的范围。

测试自动化

互联网上有许多有用的工具,用于自动化软件应用程序的集成测试。我们将在这里快速看一些流行的工具。

使用 Selenium Web Driver 进行测试自动化

Selenium 一直是自动化集成、回归和验证测试的热门选择,适用于许多软件应用程序。Selenium 是免费开源的,并支持大多数流行的 Web 浏览器引擎。

在 Selenium 中,主要对象是web driver,它是客户端上的一个有状态的对象,代表一个浏览器。Web driver 可以被编程访问 URL,执行操作(如点击、填写表单和提交表单),有效地替换通常手动执行这些步骤的人类测试对象。

Selenium 为大多数流行的编程语言和运行时提供客户端驱动程序支持。

要在 Python 中安装 Selenium Web Driver,请使用以下命令:

$ pip install selenium

我们将看一个小例子,使用 Selenium 和 pytest 来实现一个小的自动化测试,测试 Python 网站(www.python.org)的一些简单测试用例。

这是我们的测试代码。模块名为selenium_testcase.py

"""
Module selenium_testcase - Example of implementing an automated UI test using selenium framework
"""

from selenium import webdriver
import pytest
import contextlib

@contextlib.contextmanager
@pytest.fixture(scope='session')
def setup():
    driver = webdriver.Firefox()    
    yield driver
    driver.quit()

def test_python_dotorg():
    """ Test details of python.org website URLs """

    with setup() as driver:
        driver.get('http://www.python.org')
        # Some tests
        assert driver.title == 'Welcome to Python.org'
        # Find out the 'Community' link
        comm_elem = driver.find_elements_by_link_text('Community')[0]
        # Get the URL
        comm_url = comm_elem.get_attribute('href')
        # Visit it
        print ('Community URL=>',comm_url)
        driver.get(comm_url)
        # Assert its title
        assert driver.title == 'Our Community | Python.org'
        assert comm_url == 'https://www.python.org/community/'

在运行上述示例并显示输出之前,让我们稍微检查一下函数。

  • 函数setUp是一个测试装置,它为我们的测试设置了主要对象,即 Firefox 的 Selenium Web driver。我们通过在contextlib模块中使用contextmanager装饰器将setUp函数转换为上下文管理器。在setUp函数的末尾,驱动程序退出,因为调用了它的quit方法。

  • 在测试函数test_python_dot_org中,我们设置了一个相当简单的、人为的测试,用于访问主 Python 网站 URL,并通过断言检查其标题。然后我们通过在主页上找到它来加载 Python 社区的 URL,然后访问这个 URL。最后在结束测试之前断言其标题和 URL。

让我们看看程序的运行情况。我们将明确要求 pytest 只加载这个模块,并运行它。这个命令行如下:

$ pytest -s selenium_testcase.py** 

Selenium 驱动程序将启动浏览器(Firefox),并自动打开一个窗口,访问 Python 网站 URL,同时运行测试。测试的控制台输出如下图所示:

使用 Selenium Web Driver 进行测试自动化

简单的 Selenium 测试用例在 Python 编程语言网站上的控制台输出

Selenium 可以用于更复杂的测试用例,因为它提供了许多方法来检查页面的 HTML,定位元素并与之交互。还有一些 Selenium 的插件,可以执行页面的 JavaScript 内容,以通过 JavaScript 执行复杂的交互(如 AJAX 请求)。

Selenium 也可以在服务器上运行。它通过远程驱动程序支持提供对远程客户端的支持。浏览器在服务器上实例化(通常使用虚拟 X 会话),而测试可以通过网络从客户端机器运行和控制。

测试驱动开发

测试驱动开发TDD)是一种敏捷软件开发实践,使用非常短的开发周期,编写代码以满足增量测试用例。

在 TDD 中,将功能需求映射到特定的测试用例。编写代码以通过第一个测试用例。任何新需求都被添加为一个新的测试用例。代码被重构以支持新的测试用例。这个过程一直持续到代码能够支持整个用户功能的范围。

TDD 的步骤如下:

  1. 定义一些起始测试用例作为程序的规范。

  2. 编写代码使早期测试用例通过。

  3. 添加一个定义新功能的新测试用例。

  4. 运行所有测试,看看新测试是失败还是通过。

  5. 如果新测试失败,请编写一些代码使测试通过。

  6. 再次运行测试。

  7. 重复步骤 4 到 6,直到新测试通过。

  8. 重复步骤 3 到 7,通过测试用例添加新功能。

在 TDD 中,重点是保持一切简单,包括单元测试用例和为支持测试用例而添加的新代码。TDD 的实践者认为,提前编写测试允许开发人员更好地理解产品需求,从开发生命周期的最开始就专注于软件质量。

在 TDD 中,通常在系统中添加了许多测试之后,还会进行最终的重构步骤,以确保不会引入编码异味或反模式,并保持代码的可读性和可维护性。

TDD 没有特定的软件,而是一种软件开发的方法和过程。大多数情况下,TDD 使用单元测试,因此,工具链支持主要是unittest模块和本章讨论过的相关软件包。

回文的 TDD

让我们像之前讨论的那样,通过一个简单的示例来理解 TDD,开发一个检查输入字符串是否为回文的 Python 程序。

注意

回文是一个在两个方向上都读取相同的字符串。例如,bobrotatorMalayalam都是回文。当你去掉标点符号时,句子Madam, I'm Adam也是回文。

让我们遵循 TDD 的步骤。最初,我们需要一个定义程序基本规范的测试用例。我们的测试代码的第一个版本看起来像这样:

"""
Module test_palindrome - TDD for palindrome module
"""

import palindrome

def test_basic():
    """ Basic test for palindrome """

    # True positives
    for test in ('Rotator','bob','madam','mAlAyAlam', '1'):
        assert palindrome.is_palindrome(test)==True

    # True negatives
    for test in ('xyz','elephant', 'Country'):
        assert palindrome.is_palindrome(test)==False        

注意,上述代码不仅在早期功能方面为我们提供了程序的规范,还给出了函数名称和签名——包括参数和返回值。我们可以通过查看测试来列出第一个版本的要求。

  • 该函数名为_palindrome。它应该接受一个字符串,如果是回文则返回 True,否则返回 False。该函数位于palindrome模块中。

  • 该函数应将字符串视为不区分大小写。

有了这些规范,这是我们的palindrome模块的第一个版本:

def is_palindrome(in_string):
    """ Returns True whether in_string is palindrome, False otherwise """

    # Case insensitive
    in_string = in_string.lower()
    # Check if string is same as in reverse
    return in_string == in_string[-1::-1]

让我们检查一下这是否通过了我们的测试。我们将在测试模块上运行 py.test 来验证这一点。

回文的 TDD

test_palindrome.py 版本#1 的测试输出

正如你在最后一张图片中看到的,基本测试通过了;所以,我们得到了一个palindrome模块的第一个版本,它可以工作并通过测试。

现在按照 TDD 步骤,让我们进行第三步,添加一个新的测试用例。这增加了对带有空格的回文字符串进行测试的检查。以下是带有这个额外测试的新测试模块:

"""
Module test_palindrome - TDD for palindrome module
"""

import palindrome

def test_basic():
    """ Basic test for palindrome """

    # True positives
    for test in ('Rotator','bob','madam','mAlAyAlam', '1'):
        assert palindrome.is_palindrome(test)==True

    # True negatives
    for test in ('xyz','elephant', 'Country'):
        assert palindrome.is_palindrome(test)==False        

def test_with_spaces():
    """ Testing palindrome strings with extra spaces """

    # True positives
    for test in ('Able was I ere I saw Elba',
                 'Madam Im Adam',
                 'Step on no pets',
                 'Top spot'):
        assert palindrome.is_palindrome(test)==True

    # True negatives
    for test in ('Top post','Wonderful fool','Wild Imagination'):
        assert palindrome.is_palindrome(test)==False        

让我们运行更新后的测试并查看结果。

回文的 TDD

test_palindrome.py 版本#2 的测试输出

测试失败,因为代码无法处理带有空格的回文字符串。所以让我们按照 TDD 步骤(5)的说法,编写一些代码使这个测试通过。

由于明显需要忽略空格,一个快速的解决方法是从输入字符串中清除所有空格。以下是带有这个简单修复的修改后的回文模块:

"""
Module palindrome - Returns whether an input string is palindrome or not
"""

import re

def is_palindrome(in_string):
    """ Returns True whether in_string is palindrome, False otherwise """

    # Case insensitive
    in_string = in_string.lower()
    # Purge spaces
    in_string = re.sub('\s+','', in_string)
    # Check if string is same as in reverse
    return in_string == in_string[-1::-1]

现在让我们重复 TDD 的第四步,看看更新后的代码是否使测试通过。

回文的 TDD

代码更新后的 test_palindrome.py 版本#2 的控制台输出

当然,现在代码通过了测试!

我们刚刚看到的是 TDD 的一个实例,用于在 Python 中实现一个模块的更新周期,该模块检查字符串是否为回文。以类似的方式,可以不断添加测试,并根据 TDD 的第 8 步不断更新代码,从而在维护更新的测试的过程中添加新功能。

我们用检查最终版本的回文测试用例结束了本节,其中添加了一个检查带有额外标点符号的字符串的测试用例。

"""
Module test_palindrome - TDD for palindrome module
"""

import palindrome

def test_basic():
    """ Basic test for palindrome """

    # True positives
    for test in ('Rotator','bob','madam','mAlAyAlam', '1'):
        assert palindrome.is_palindrome(test)==True

    # True negatives
    for test in ('xyz','elephant', 'Country'):
        assert palindrome.is_palindrome(test)==False        

def test_with_spaces():
    """ Testing palindrome strings with extra spaces """

    # True positives
    for test in ('Able was I ere I saw Elba',
                 'Madam Im Adam',
                 'Step on no pets',
                 'Top spot'):
        assert palindrome.is_palindrome(test)==True

    # True negatives
    for test in ('Top post','Wonderful fool','Wild Imagination'):
        assert palindrome.is_palindrome(test)==False        

def test_with_punctuations():
    """ Testing palindrome strings with extra punctuations """

    # True positives
    for test in ('Able was I, ere I saw Elba',
                 "Madam I'm Adam",
                 'Step on no pets.',
                 'Top spot!'):
        assert palindrome.is_palindrome(test)==True

    # True negatives
    for test in ('Top . post','Wonderful-fool','Wild Imagination!!'):
        assert palindrome.is_palindrome(test)==False            

以下是更新后的回文模块,使得这个测试通过:

"""
Module palindrome - Returns whether an input string is palindrome or not
"""

import re
from string import punctuation

def is_palindrome(in_string):
    """ Returns True whether in_string is palindrome, False otherwise """

    # Case insensitive
    in_string = in_string.lower()
    # Purge spaces
    in_string = re.sub('\s+','', in_string)
    # Purge all punctuations
    in_string = re.sub('[' + re.escape(punctuation) + ']+', '', in_string)
    # Check if string is same as in reverse
    return in_string == in_string[-1::-1]

让我们检查一下控制台上_palindrome模块的最终输出。

回文的 TDD

test_palindrome.py 版本#3 的控制台输出,带有匹配的代码更新

总结

在本章中,我们重新审视了可测试性的定义及其相关的架构质量方面,如复杂性和确定性。我们研究了被测试的不同架构方面,并了解了软件测试过程通常执行的测试类型。

然后,我们讨论了改进软件可测试性的各种策略,并研究了减少系统复杂性、提高可预测性以及控制和管理外部依赖的技术。在这个过程中,我们学习了不同的虚拟化和管理外部依赖的方法,例如伪装、模拟和存根,通过示例进行了说明。

然后,我们从 Python unittest模块的角度主要讨论了单元测试及其各个方面。我们通过使用一个 datetime 辅助类的示例,解释了如何编写有效的单元测试——先是一个简单的例子,然后是使用unittest的 Mock 库对函数进行打补丁的有趣的例子。

接下来,我们介绍并学习了 Python 中另外两个著名的测试框架,即 nose2 和 py.test。接下来我们讨论了代码覆盖率的非常重要的方面,并看到了使用 coverage.py 包直接测量代码覆盖率的示例,以及通过 nose2 和 pytest 的插件使用它的示例。

在下一节中,我们勾勒了一个使用高级模拟对象的 textsearch 类的示例,我们对其外部依赖进行了模拟,并编写了一个单元测试用例。我们继续讨论了 Python doctest 支持,通过 doctest 模块在类、模块、方法和函数的文档中嵌入测试的示例。

下一个话题是集成测试,我们讨论了集成测试的不同方面和优势,并看了一下测试可以在软件组织中集成的三种不同方式。接下来讨论了通过 Selenium 进行测试自动化,以及使用 Selenium 和 py.test 在 Python 语言网站上自动化一些测试的示例。

我们以对 TDD 的快速概述结束了本章,并讨论了使用 TDD 原则编写 Python 中检测回文的程序的示例,我们以逐步的方式使用测试开发了这个程序。

在下一章中,我们将讨论在开发软件时架构的一个最关键的质量属性,即性能。

第四章:良好的性能是有回报的!

性能是现代软件应用的基石之一。每天我们以多种不同的方式与高性能计算系统进行交互,作为我们工作和休闲的一部分。

当你在网上的旅行网站之一预订航班时,你正在与一个高性能系统交互,该系统在特定时间内执行数百个此类交易。当你向某人转账或通过互联网银行交易支付信用卡账单时,你正在与一个高性能和高吞吐量的交易系统交互。同样,当你在手机上玩在线游戏并与其他玩家互动时,又有一个网络服务器系统专为高并发和低延迟而建,它接收你和成千上万其他玩家的输入,进行后台计算并向你发送数据 - 所有这些都以合理而安静的效率进行。

现代网络应用程序可以同时为数百万用户提供服务,这是因为高速互联网的出现以及硬件价格/性能比的大幅下降。性能仍然是现代软件架构的关键质量属性,编写高性能和可扩展软件仍然是一门艰难的艺术。你可能编写了一个功能和其他质量属性都符合要求的应用程序,但如果它未通过性能测试,那么它就不能投入生产。

在本章和下一章中,我们将重点关注写高吞吐量软件的两个方面 - 即性能和可扩展性。在本章中,重点是性能,以及它的各个方面,如何衡量它,各种数据结构的性能,以及在何时选择什么 - 重点放在 Python 上。

本章我们将讨论的主题大致包括以下几个部分:

  • 定义性能

  • 软件性能工程

  • 性能测试工具的类型

  • 性能复杂性和大 O 符号:

  • 性能测量

  • 使用图表找到性能复杂性

  • 提高性能

  • 性能分析:

  • 确定性分析

  • cProfileprofile

  • 第三方性能分析工具

  • 其他工具:

  • Objgraph

  • Pympler

  • 为性能编程 - 数据结构:

  • 列表

  • 字典

  • 集合

  • 元组

  • 高性能容器 - collections 模块:

  • deque

  • defaultdict

  • OrderedDict

  • Counter

  • ChainMap

  • namedtuple

  • 概率数据结构 - 布隆过滤器

什么是性能?

软件系统的性能可以广义地定义为:

“系统能够满足其吞吐量和/或延迟要求的程度,以每秒事务数或单个事务所需时间来衡量。”

我们已经在介绍章节中概述了性能测量。性能可以用响应时间/延迟或吞吐量来衡量。前者是应用程序完成请求/响应循环的平均时间。后者是系统以每分钟成功完成的请求或交易数量来处理其输入的速率。

系统的性能是其软件和硬件能力的函数。一个糟糕编写的软件仍然可以通过扩展硬件(例如 RAM 的数量)来提高性能。

同样,通过增加性能(例如,通过重写例程或函数以在时间或内存方面更有效,或通过修改架构),可以使现有硬件上的软件更好地运行。

然而,正确的性能工程是软件以最佳方式针对硬件进行调整,使得软件相对于可用硬件的线性扩展或更好。

软件性能工程

软件性能工程包括软件工程和分析的所有活动,应用于软件开发生命周期SDLC),旨在满足性能要求。

在传统的软件工程中,性能测试和反馈通常是在 SDLC 的最后阶段进行的。这种方法纯粹基于测量,并等待系统开发完成后再应用测试和诊断,并根据结果调整系统。

另一个更正式的模型名为软件性能工程SPE)本身,在 SDLC 的早期开发性能模型,并使用模型的结果来修改软件设计和架构,以满足多次迭代中的性能要求。

在这种方法中,性能作为非功能性需求和软件开发满足其功能性需求并行进行。有一个特定的性能工程生命周期PELC),与 SDLC 中的步骤相对应。从设计和架构一直到部署的每一步,都利用两个生命周期之间的反馈来迭代地提高软件质量:

软件性能工程

SPE - 性能工程生命周期反映软件开发生命周期

在这两种方法中,性能测试和诊断都很重要,随后根据所获得的结果调整设计/架构或代码。因此,性能测试和测量工具在这一步中起着重要作用。

性能测试和测量工具

这些工具分为两大类 - 用于性能测试和诊断的工具,以及用于收集性能指标和仪器的工具。

性能测试和诊断工具可以进一步分类如下:

  • 压力测试工具:这些工具用于向被测试系统提供工作负载,模拟生产中的高峰工作负载。这些工具可以配置为向应用程序发送连续的输入流,以模拟高压力,或者定期发送一大批非常高的流量 - 远远超过甚至高峰压力 - 以测试系统的稳健性。这些工具也被称为负载生成器。用于 Web 应用程序测试的常见压力测试工具的示例包括httpperfApacheBenchLoadRunnerApache JMeterLocust。另一类工具涉及实际记录真实用户流量,然后通过网络重放以模拟真实用户负载。例如,流行的网络数据包捕获和监视工具Wireshark及其控制台表亲程序tcpdump可以用于此目的。我们不会在本章讨论这些工具,因为它们是通用工具,可以在网络上找到大量的使用示例。

  • 监控工具:这些工具与应用程序代码一起生成性能指标,例如函数执行所需的时间和内存,每个请求-响应循环中进行的函数调用次数,每个函数花费的平均和峰值时间等。

  • 仪器工具:仪器工具跟踪指标,例如每个计算步骤所需的时间和内存,并跟踪事件,例如代码中的异常,涵盖诸如发生异常的模块/函数/行号、事件的时间戳以及应用程序的环境(环境变量、应用程序配置参数、用户信息、系统信息等)的详细信息。现代 Web 应用程序编程系统通常使用外部仪器工具来捕获和详细分析此类数据。

  • 代码或应用程序分析工具:这些工具生成关于函数的统计信息,它们的调用频率和持续时间,以及每个函数调用所花费的时间。这是一种动态程序分析。它允许程序员找到代码中花费最多时间的关键部分,从而优化这些部分。不建议在没有进行分析的情况下进行优化,因为程序员可能最终会优化错误的代码,从而无法实现预期的应用程序效益。

大多数编程语言都配备了自己的一套工具和性能分析工具。在 Python 中,标准库中的一组工具(如profilecProfile模块)可以做到这一点 - 这得益于丰富的第三方工具生态系统。我们将在接下来的部分讨论这些工具。

性能复杂度

在我们跳入 Python 中的代码示例并讨论测量和优化性能的工具之前,花点时间讨论一下我们所说的代码的性能复杂度是什么意思会很有帮助。

例程或函数的性能复杂度是根据它们对输入大小的变化的响应来定义的,通常是根据执行代码所花费的时间来定义的。

这通常由所谓的大 O 符号表示,它属于一类称为巴赫曼-兰道符号或渐近符号的符号。

字母 O 用作函数相对于输入大小的增长速度 - 也称为函数的顺序

常用的大 O 符号或函数顺序按照增加复杂度的顺序显示在以下表中:

# 顺序 复杂度 例子
1 O(1) 常数 在常数查找表中查找键,例如 Python 中的 HashMap 或字典
2 O(log (n)) 对数 在排序数组中使用二分搜索查找项目。Python 中对 heapq 的所有操作
3 O(n) 线性 通过遍历数组(Python 中的列表)来搜索项目
4 O(nk)* 线性 基数排序的最坏情况复杂度
5 O(n * log (n)) n 对数星 n 归并排序或堆排序算法的最坏情况复杂度
6 O(n²) 二次 简单的排序算法,如冒泡排序,插入排序和选择排序。某些排序算法的最坏情况复杂度,如快速排序,希尔排序等
7 O(2^n) 指数 尝试使用暴力破解破解大小为 n 的密码,使用动态规划解决旅行推销员问题
8 O(n!) 阶乘 生成集合的所有分区

表 1:关于输入大小“n”的函数顺序的常见大 O 符号

当实现一个接受特定大小输入n的例程或算法时,程序员理想情况下应该目标是将其实现在前五个顺序中。任何O(n)或 O(n * log(n))或更低顺序的东西都表明合理到良好的性能。

具有O(n²)顺序的算法通常可以优化为更低的顺序。我们将在以下图表中的部分中看到一些例子。

以下图表显示了这些顺序随着n的增长而增长的方式:

性能复杂度

每个复杂度顺序的增长率图(y 轴)相对于输入大小(x 轴)的增长率图。

性能测量

既然我们已经概述了性能复杂度是什么,也了解了性能测试和测量工具,让我们实际看看用 Python 测量性能复杂度的各种方法。

最简单的时间测量之一是使用 POSIX/Linux 系统的time命令。

通过使用以下命令行完成:

$ time <command>

例如,这是从 Web 获取一个非常流行页面所需时间的截图:

性能测量

通过 wget 从互联网获取网页的时间命令输出

请注意,它显示了三种时间输出,即realusersys。重要的是要知道这三者之间的区别,让我们简要地看一下它们:

  • real:实际时间是操作所经历的实际挂钟时间。这是操作从开始到结束的时间。它将包括进程休眠或阻塞的任何时间,例如 I/O 完成所花费的时间。

  • User:用户时间是进程在用户模式(在内核之外)内实际花费的 CPU 时间。任何休眠时间或在等待中花费的时间,如 I/O,不会增加用户时间。

  • Sys:系统时间是程序内核中执行系统调用所花费的 CPU 时间。这仅计算在内核空间中执行的函数,如特权系统调用。它不计算在用户空间中执行的任何系统调用(这在User中计算)。

一个进程所花费的总 CPU 时间是user + sys时间。真实或挂钟时间是由简单的时间计数器大多数测量的时间。

使用上下文管理器测量时间

在 Python 中,编写一个简单的函数作为代码块的上下文管理器,用于测量其执行时间并不是很困难。

但首先我们需要一个可以测量性能的程序。

请看以下步骤,了解如何使用上下文管理器来测量时间:

  1. 让我们编写一个计算两个序列之间共同元素的程序作为测试程序。以下是代码:
def common_items(seq1, seq2):
    """ Find common items between two sequences """

    common = []
    for item in seq1:
        if item in seq2:
            common.append(item)

    return common
  1. 让我们编写一个简单的上下文管理器计时器来计时这段代码。为了计时,我们将使用time模块的perf_counter,它可以给出最精确的时间分辨率:
from time import perf_counter as timer_func
from contextlib import contextmanager

@contextmanager
def timer():
    """ A simple timing function for routines """

    try:
        start = timer_func()
        yield
    except Exception as e:
        print(e)
        raise
    finally:
        end = timer_func()
        print ('Time spent=>',1000.0*(end – start),'ms.')
  1. 让我们为一些简单的输入数据计时函数。为此,一个test函数很有用,它可以生成随机数据,给定一个输入大小:
def test(n):
    """ Generate test data for numerical lists given input size """

    a1=random.sample(range(0, 2*n), n)
    a2=random.sample(range(0, 2*n), n)

    return a1, a2

以下是在 Python 交互解释器上对test函数的timer方法的输出:

>>> with timer() as t:
... common = common_items(*test(100))
... Time spent=> 2.0268699999999864 ms.
  1. 实际上,测试数据生成和测试可以结合在同一个函数中,以便轻松地测试和生成一系列输入大小的数据:
def test(n, func):
    """ Generate test data and perform test on a given function """

    a1=random.sample(range(0, 2*n), n)
    a2=random.sample(range(0, 2*n), n)

    with timer() as t:
        result = func(a1, a2)
  1. 现在让我们在 Python 交互控制台中测量不同范围的输入大小所花费的时间:
>>> test(100, common_items)
    Time spent=> 0.6799279999999963 ms.
>>> test(200, common_items)
    Time spent=> 2.7455590000000085 ms.
>>> test(400, common_items)
    Time spent=> 11.440810000000024 ms.
>>> test(500, common_items)
    Time spent=> 16.83928100000001 ms.
>>> test(800, common_items)
    Time spent=> 21.15130400000004 ms.
>>> test(1000, common_items)
    Time spent=> 13.200749999999983 ms.

哎呀,1000个项目所花费的时间比800的时间少!这怎么可能?让我们再试一次:

>>> test(800, common_items)
    Time spent=> 8.328282999999992 ms.
>>> test(1000, common_items)
    Time spent=> 34.85899500000001 ms.

现在,800个项目所花费的时间似乎比400500的时间少。而1000个项目所花费的时间增加到了之前的两倍以上。

原因是我们的输入数据是随机的,这意味着它有时会有很多共同的项目-这需要更多的时间-有时会少得多。因此,在后续调用中,所花费的时间可能会显示一系列值。

换句话说,我们的计时函数对于获得一个大致的图片是有用的,但是当涉及到获取程序执行所花费的真实统计度量时,它并不是非常有用,这更为重要。

  1. 为此,我们需要多次运行计时器并取平均值。这与算法的摊销分析有些类似,它考虑了执行算法所花费的时间的下限和上限,并给程序员一个实际的平均时间估计。

Python 自带了这样一个模块,它可以帮助在其标准库中执行这样的计时分析,即timeit模块。让我们在下一节中看看这个模块。

使用timeit模块计时代码

Python 标准库中的timeit模块允许程序员测量执行小代码片段所花费的时间。代码片段可以是 Python 语句、表达式或函数。

使用timeit模块的最简单方法是在 Python 命令行中将其作为模块执行。

例如,以下是一些简单的 Python 内联代码的计时数据,用于测量在范围内计算数字平方的列表推导的性能:

$ python3 -m timeit '[x*x for x in range(100)]'
100000 loops, best of 3: 5.5 usec per loop

$ python3 -m timeit '[x*x for x in range(1000)]'
10000 loops, best of 3: 56.5 usec per loop

$ python3 -m timeit '[x*x for x in range(10000)]'
1000 loops, best of 3: 623 usec per loop

结果显示了执行代码片段所花费的时间。在命令行上运行时,timeit模块会自动确定运行代码的循环次数,并计算单次执行的平均时间。

注意

结果显示,我们正在执行的语句是线性的或 O(n),因为大小为 100 的范围需要 5.5 微秒,而 1000 的范围需要 56.5 微秒,大约是其时间的 10 倍。微秒是秒的百万分之一,即 1*10-6 秒。

使用 Python 解释器中的timeit模块的方法如下:

>>> 1000000.0*timeit.timeit('[x*x for x in range(100)]', number=100000)/100000.0
6.007622049946804

>>> 1000000.0*timeit.timeit('[x*x for x in range(1000)]', number=10000)/10000.0
58.761584300373215

注意

请注意,以这种方式使用时,程序员必须将正确的迭代次数作为number参数传递,并且为了求平均值,必须除以相同的数字。乘以1000000是为了将时间转换为微秒(usec)。

timeit模块在后台使用Timer类。该类也可以直接使用,以及进行更精细的控制。

使用此类时,timeit成为类的实例的方法,循环次数作为参数传递。

Timer类构造函数还接受一个可选的setup参数,用于设置Timer类的代码。这可以包含用于导入包含函数的模块、设置全局变量等的语句。它接受用分号分隔的多个语句。

使用 timeit 测量我们代码的性能

让我们重写我们的test函数,以测试两个序列之间的共同项目。现在我们将使用timeit模块,可以从代码中删除上下文管理器计时器。我们还将在函数中硬编码调用common_items

注意

我们还需要在测试函数之外创建随机输入,否则它所花费的时间将增加到测试函数的时间中,从而破坏我们的结果。

因此,我们需要将变量作为全局变量移到模块中,并编写一个setup函数,作为第一步为我们生成数据。

我们重写的test函数如下:

def test():
    """ Testing the common_items function """

    common = common_items(a1, a2)

具有全局变量的setup函数如下:

# Global lists for storing test data
a1, a2 = [], []

def setup(n):
    """ Setup data for test function """

    global a1, a2
    a1=random.sample(range(0, 2*n), n)
    a2=random.sample(range(0, 2*n), n)

假设包含testcommon_items函数的模块名为common_items.py

现在可以运行计时器测试如下:

>>> t=timeit.Timer('test()', 'from common_items import test,setup; setup(100)')
>>> 1000000.0*t.timeit(number=10000)/10000
116.58759460115107

因此,100 个数字的范围平均需要 117 微秒(0.12 微秒)。

现在对其他输入大小的几个范围进行执行,得到以下输出:

>>> t=timeit.Timer('test()','from common_items import test,setup; setup(200)')
>>> 1000000.0*t.timeit(number=10000)/10000
482.8089299000567

>>> t=timeit.Timer('test()','from common_items import test,setup; setup(400)')
>>> 1000000.0*t.timeit(number=10000)/10000
1919.577144399227

>>> t=timeit.Timer('test()','from common_items import test,setup; setup(800)')
>>> 1000000.0*t.timeit(number=1000)/1000
7822.607815993251

>>> t=timeit.Timer('test()','from common_items import test,setup; setup(1000)')
>>> 1000000.0*t.timeit(number=1000)/1000
12394.932234004957

因此,此测试运行的最长时间为 1000 个项目的输入大小需要 12.4 微秒。

找出时间复杂度-图表

从这些结果中是否可以找出我们函数的时间性能复杂度?让我们尝试在图表中绘制它并查看结果。

matplotlib库在 Python 中绘制任何类型的输入数据的图表非常有用。我们只需要以下简单的代码即可实现:

import matplotlib.pyplot as plt

def plot(xdata, ydata):
    """ Plot a range of ydata (on y-axis) against xdata (on x-axis) """

    plt.plot(xdata, ydata)
    plt.show()

上述代码给出了以下输出:

This is our x data.
>>> xdata = [100, 200, 400, 800, 1000]
This is the corresponding y data.
>>> ydata = [117,483,1920,7823,12395]
>>> plot(xdata, ydata)

看一下下面的图表:

找出时间复杂度-图表

输入范围与 common_items 函数所花费时间的图表

显然这不是线性的,当然也不是二次的(与大 O 符号的图形相比)。让我们尝试绘制一个 O(n*log(n))的图表叠加在当前图表上,看看是否匹配。

由于我们现在需要两个ydata系列,我们需要另一个略微修改的函数:

def plot_many(xdata, ydatas):
    """ Plot a sequence of ydatas (on y-axis) against xdata (on x-axis) """

    for ydata in ydatas:
        plt.plot(xdata, ydata)
    plt.show()

上述代码给出了以下输出:

>>> ydata2=map(lambda x: x*math.log(x, 2), input)

>>> plot_many(xdata, [ydata2, ydata])

你会得到以下图表:

找出时间复杂度-图表

common_items 的时间复杂度图表叠加在 y=x*log(x)的图表上

叠加的图表显示,该函数与 nlog(n)阶数非常匹配,如果不是完全相同的话。因此,我们当前实现的复杂度似乎大致为 O(nlog(n))。

现在我们已经完成了性能分析,让我们看看是否可以重写我们的例程以获得更好的性能。

以下是当前的代码:

def common_items(seq1, seq2):
    """ Find common items between two sequences """

    common = []
    for item in seq1:
        if item in seq2:
            common.append(item)

    return common

例程首先对外部的for循环(大小为n)进行一次遍历,并在一个序列(同样大小为n)中检查该项。现在第二次搜索的平均时间复杂度也是n

然而,有些项会立即被找到,有些项会花费线性时间(k),其中 1 < k < n。平均而言,分布会在两者之间,这就是为什么代码的平均复杂度接近 O(n*log(n))。

快速分析会告诉你,通过将外部序列转换为字典并将值设置为 1,可以避免内部搜索。内部搜索将被在第二个序列上的循环替代,该循环将值递增 1。

最后,所有共同项在新字典中的值都将大于 1。

新代码如下:

def common_items(seq1, seq2):
    """ Find common items between two sequences, version 2.0 """

    seq_dict1 = {item:1 for item in seq1}

    for item in seq2:
        try:
            seq_dict1[item] += 1
        except KeyError:
            pass

    # Common items will have value > 1
    return [item[0] for item in seq_dict1.items() if item[1]>1]

通过这个改变,计时器给出了以下更新后的结果:

>>> t=timeit.Timer('test()','from common_items import test,setup; setup(100)')
>>> 1000000.0*t.timeit(number=10000)/10000
35.777671200048644

>>> t=timeit.Timer('test()','from common_items import test,setup; setup(200)')
>>> 1000000.0*t.timeit(number=10000)/10000
65.20369809877593

>>> t=timeit.Timer('test()','from common_items import test,setup; setup(400)')
>>> 1000000.0*t.timeit(number=10000)/10000
139.67061050061602

>>> t=timeit.Timer('test()','from common_items import test,setup; setup(800)')
>>> 1000000.0*t.timeit(number=10000)/10000
287.0645995993982

>>> t=timeit.Timer('test()','from common_items import test,setup; setup(1000)')
>>> 1000000.0*t.timeit(number=10000)/10000
357.764518300246

让我们绘制这个图并叠加在 O(n)图上:

>>> input=[100,200,400,800,1000]
>>> ydata=[36,65,140,287,358]

# Note that ydata2 is same as input as we are superimposing with y = x 
# graph
>>> ydata2=input
>>> plot.plot_many(xdata, [ydata, ydata2])

让我们来看一下下面的图表:

找出时间复杂度-图表

common_items 函数(v2)所花费的时间的图与 y = x 图

上面的绿线是参考y = x图,下面的蓝线是我们新函数所花费的时间的图。很明显,时间复杂度现在是线性的或者 O(n)。

然而,这里似乎有一个常数因子,因为两条线的斜率不同。通过快速计算,可以大致计算出这个因子约为0.35

应用这个改变后,你会得到以下输出:

>>> input=[100,200,400,800,1000]
>>> ydata=[36,65,140,287,358]

# Adjust ydata2 with the constant factor
>>> ydata2=map(lambda x: 0.35*x, input)
>>> plot.plot_many(xdata, [ydata, ydata2])

找出时间复杂度-图表

common_items 函数(v2)所花费的时间的图与 y = 0.35*x 图

你可以看到这些图几乎完全叠加在一起。因此我们的函数现在的性能是 O(c*n),其中 c 约等于 0.35。

注意

common_items函数的另一个实现是将两个序列都转换为集合并返回它们的交集。读者可以尝试进行这种改变,计时并绘制图表以确定时间复杂度。

使用 timeit 测量 CPU 时间

Timer模块默认使用时间模块的perf_counter函数作为默认的timer函数。正如前面提到的,这个函数返回小时间段的最大精度的墙钟时间,因此它将包括任何睡眠时间、I/O 时间等。

通过向我们的测试函数添加一点睡眠时间,可以澄清这一点:

def test():
    """ Testing the common_items function using a given input size """

    sleep(0.01)
    common = common_items(a1, a2)

上述代码将给出以下输出:

>>> t=timeit.Timer('test()','from common_items import test,setup; setup(100)')
>>> 1000000.0*t.timeit(number=100)/100
10545.260819926625

由于我们在每次调用时睡眠了0.01秒(10 毫秒),所以时间增加了 300 倍,因此代码实际消耗的时间现在几乎完全由睡眠时间决定,因为结果显示为10545.260819926625微秒(大约 10 毫秒)。

有时候你可能会有这样的睡眠时间和其他阻塞/等待时间,但你只想测量函数实际消耗的 CPU 时间。为了使用这个功能,可以使用时间模块的process_time函数作为timer函数来创建Timer对象。

当你创建Timer对象时,可以通过传入一个timer参数来实现:

>>> from time import process_time
>>> t=timeit.Timer('test()','from common_items import test,setup;setup(100)', timer=process_time)
>>> 1000000.0*t.timeit(number=100)/100
345.22438

如果你现在将睡眠时间增加 10 倍,测试时间也会增加相应的倍数,但计时器的返回值仍然保持不变。

例如,当睡眠 1 秒时,结果如下。输出大约在 100 秒后出现(因为我们迭代了100次),但请注意返回值(每次调用所花费的时间)并没有改变:

>>> t=timeit.Timer('test()','from common_items import test,setup;setup(100)', timer=process_time)
>>> 1000000.0*t.timeit(number=100)/100
369.8039100000002

让我们接下来进行分析。

分析

在本节中,我们将讨论分析器,并深入研究 Python 标准库中提供的支持确定性分析的模块。我们还将研究提供分析支持的第三方库,如line_profilermemory_profiler

确定性分析

确定性性能分析意味着监视所有函数调用、函数返回和异常事件,并对这些事件之间的时间间隔进行精确计时。另一种类型的性能分析,即统计性能分析,会随机抽样指令指针,并推断时间花费在哪里-但这可能不是非常准确。

作为一种解释性语言,Python 在元数据方面已经有一定的开销。大多数确定性性能分析工具利用了这些信息,因此对于大多数应用程序来说,只会增加很少的额外处理开销。因此,在 Python 中进行确定性性能分析并不是一项非常昂贵的操作。

使用 cProfile 和 profile

profilecProfile模块在 Python 标准库中提供了确定性性能分析的支持。profile模块纯粹由 Python 编写。cProfile模块是一个 C 扩展,模仿了profile模块的接口,但与profile相比,它的开销更小。

这两个模块都报告统计数据,使用pstats模块将其转换为可报告的结果。

我们将使用以下代码,这是一个质数迭代器,以展示我们使用profile模块的示例:

class Prime(object):
    """ A prime number iterator for first 'n' primes """

    def __init__(self, n):
        self.n = n
        self.count = 0
        self.value = 0

    def __iter__(self):
        return self

    def __next__(self):
        """ Return next item in iterator """

        if self.count == self.n:
            raise StopIteration("end of iteration")
        return self.compute()

    def is_prime(self):
        """ Whether current value is prime ? """

        vroot = int(self.value ** 0.5) + 1
        for i in range(3, vroot):
            if self.value % i == 0:
                return False
        return True

    def compute(self):
        """ Compute next prime """

        # Second time, reset value
        if self.count == 1:
            self.value = 1

        while True:
            self.value += 2

            if self.is_prime():
                self.count += 1
                break

        return self.value

给定值n,质数迭代器生成前n个质数:

>>> for p in Prime(5):
... print(p)
...
2
3
5
7
11

要对此代码进行性能分析,我们只需要将要执行的代码作为字符串传递给profilecProfile模块的run方法。在以下示例中,我们将使用cProfile模块:

使用 cProfile 和 profile 进行性能分析

对前 100 个质数的质数迭代器函数的性能分析输出

看看性能分析器如何报告其输出。输出按以下六列排序:

  • ncalls:每个函数的调用次数

  • tottime:调用中花费的总时间

  • percallpercall时间(tottime/ncalls的商)

  • cumtime:此函数及任何子函数中的累积时间

  • percall:另一个percall列(cumtime/原始调用次数的商)

  • filename: lineno(function):函数调用的文件名和行号

在这种情况下,我们的函数完成需要4微秒,其中大部分时间(3微秒)花在is_prime方法内部,这也占据了 271 次调用中的大部分。

以下是n = 100010000的性能分析输出:

使用 cProfile 和 profile 进行性能分析

对前 1,000 个质数的质数迭代器函数的性能分析输出

看一下以下额外输出:

使用 cProfile 和 profile 进行性能分析

对前 10,000 个质数的质数迭代器函数的性能分析输出

如您所见,在n=1000时,大约需要0.043秒(43 微秒),而在n=10000时,需要0.458秒(458 微秒)。我们的Prime迭代器似乎以接近 O(n)的顺序执行。

像往常一样,大部分时间都花在is_primes上。有没有办法减少这段时间?

在这一点上,让我们分析一下代码。

质数迭代器类-性能调整

对代码的快速分析告诉我们,在is_prime内部,我们将值除以从3到值的平方根的后继数的范围内的每个数。

这包含许多偶数-我们正在进行不必要的计算,我们可以通过仅除以奇数来避免这种情况。

修改后的is_prime方法如下:

    def is_prime(self):
        """ Whether current value is prime ? """

        vroot = int(self.value ** 0.5) + 1
        for i in range(3, vroot, 2):
            if self.value % i == 0:
                return False
        return True

因此,n=1000n=10000的性能分析如下。

以下是n = 1000的性能分析输出。

质数迭代器类-性能调整

对前 1,000 个质数的质数迭代器函数的性能分析输出,使用了调整后的代码

以下是n=10000时的性能分析输出:

质数迭代器类-性能调整

对调整后的代码进行前 10000 个质数的 Prime 迭代器函数的分析输出

您可以看到,在1000时,时间有所下降(从 43 微秒到 38 微秒),但在10000时,几乎有 50%的下降,从 458 微秒到 232 微秒。此时,该函数的性能优于 O(n)。

分析-收集和报告统计信息

我们之前在示例中使用 cProfile 的方式是直接运行并报告统计数据。使用该模块的另一种方式是将filename参数传递给它,它会将统计数据写入文件,稍后可以由pstats模块加载和解释。

我们修改代码如下:

>>> cProfile.run("list(primes.Prime(100))", filename='prime.stats')

通过这样做,统计数据不会被打印出来,而是保存到名为prime.stats的文件中。

以下是如何使用pstats模块解析统计数据并按调用次数排序打印结果:

分析-收集和报告统计信息

使用 pstats 模块解析和打印保存的配置文件结果

pstats模块允许按照多个标题对配置文件结果进行排序,例如总时间(tottime)、原始调用次数(pcalls)、累积时间(cumtime)等等。您可以从 pstats 的输出中再次看到,大部分处理都是在 is_prime 方法中进行的,因为我们按照'ncalls'或函数调用次数对输出进行排序。

pstats模块的Stats类在每次操作后都会返回对自身的引用。这是一些 Python 类的非常有用的特性,它允许我们通过链接方法调用来编写紧凑的一行代码。

Stats对象的另一个有用方法是找出被调用者/调用者的关系。这可以通过使用print_callers方法而不是print_stats来实现。以下是我们当前统计数据的输出:

分析-收集和报告统计信息

使用 pstats 模块按原始调用次数排序打印被调用者/调用者关系

第三方分析器

Python 生态系统提供了大量用于解决大多数问题的第三方模块。在分析器的情况下也是如此。在本节中,我们将快速浏览一下 Python 社区开发人员贡献的一些流行的第三方分析器应用程序。

行分析器

行分析器是由 Robert Kern 开发的一款应用程序,用于对 Python 应用程序进行逐行分析。它是用 Cython 编写的,Cython 是 Python 的优化静态编译器,可以减少分析的开销。

可以通过以下方式使用 pip 安装行分析器:

$ pip3 install line_profiler

与 Python 中的分析模块相反,它们分析函数,行分析器能够逐行分析代码,从而提供更详细的统计信息。

行分析器附带一个名为kernprof.py的脚本,它使得使用行分析器对代码进行分析变得容易。当使用kernprof时,只需使用@profile装饰器装饰需要进行分析的函数。

例如,我们意识到我们的质数迭代器中大部分时间都花在了is_prime方法上。然而,行分析器允许我们更详细地查找这些函数中哪些行花费了最多的时间。

要做到这一点,只需使用@profile装饰器装饰该方法:

    @profile
    def is_prime(self):
        """ Whether current value is prime ? """

        vroot = int(self.value ** 0.5) + 1
        for i in range(3, vroot, 2):
            if self.value % i == 0:
                return False
        return True

由于kernprof接受脚本作为参数,我们需要添加一些代码来调用质数迭代器。为此,我们可以在primes.py模块的末尾添加以下内容:

# Invoke the code.
if __name__ == "__main__":
    l=list(Prime(1000))

现在,使用行分析器运行它:

$ kernprof -l -v primes.py

通过向kernprof脚本传递-v,我们告诉它显示分析结果,而不仅仅是保存它们。

以下是输出:

行分析器

使用 n = 1000 对 is_prime 方法进行分析的行分析器结果

行分析器告诉我们,大部分时间-接近总时间的 90%都花在了方法的前两行上:for 循环和余数检查。

这告诉我们,如果我们想要优化这种方法,我们需要集中在这两个方面。

内存分析器

内存分析器类似于行分析器,它逐行分析 Python 代码。但是,它不是分析代码每行所花费的时间,而是通过内存消耗逐行分析代码。

内存分析器可以像行分析器一样安装:

$ pip3 install memory_profiler

安装后,可以通过将函数装饰为@profile装饰器来打印行的内存,类似于行分析器。

这是一个简单的例子:

# mem_profile_example.py
@profile
def squares(n):
    return [x*x for x in range(1, n+1)]

squares(1000)

以下是如何运行的:

内存分析器

内存分析器对前 1000 个数字的平方的列表推导式进行分析

内存分析器逐行显示内存增量。在这种情况下,包含平方数(列表推导式)的行几乎没有增量,因为数字相当小。总内存使用量保持在开始时的水平:约 32 MB。

如果我们将n的值更改为一百万会发生什么?可以通过将代码的最后一行改写为以下内容来实现:

squares(100000)

内存分析器

内存分析器对前 100 万个数字的平方的列表推导式进行分析

现在您可以看到,计算平方的列表推导式的内存增加约为 39 MB,最终总内存使用量约为 70 MB。

为了展示内存分析器的真正用处,让我们看另一个例子。

这涉及查找序列中作为另一个序列中任何字符串的子序列的字符串,通常包含较大的字符串。

子字符串(子序列)问题

假设您有一个包含以下字符串的序列:

>>> seq1 = ["capital","wisdom","material","category","wonder"]

假设还有另一个序列如下:

>>> seq2 = ["cap","mat","go","won","to","man"]

问题是要找到seq2中作为seq1中任何字符串中连续出现的子字符串:

在这种情况下,答案如下:

>>> sub=["cap","mat","go","won"]

这可以通过蛮力搜索来解决-逐个检查每个字符串是否在父字符串中,如下所示:

def sub_string_brute(seq1, seq2):
    """ Sub-string by brute force """

    subs = []
    for item in seq2:
        for parent in seq1:
            if item in parent:
                subs.append(item)

    return subs

然而,快速分析会告诉您,该函数的时间复杂度随着序列大小的增加而变得非常糟糕。由于每个步骤都需要迭代两个序列,然后在第一个序列的每个字符串中进行搜索,平均性能将是 O(n1*n2),其中 n1,n2 分别是序列的大小。

以下是对此函数进行一些测试的结果,输入大小为随机字符串的长度 2 到 10 的两个序列的大小相同:

输入大小 花费时间
100 450 微秒
1000 52 微秒
10000 5.4 秒

结果表明性能几乎完全是 O(n²)。

有没有办法重写函数以提高性能?这种方法体现在以下sub_string函数中:

def slices(s, n):
    return map(''.join, zip(*(s[i:] for i in range(n))))

def sub_string(seq1, seq2):
    """ Return sub-strings from seq2 which are part of strings in seq1 """

    # Create all slices of lengths in a given range
    min_l, max_l = min(map(len, seq2)), max(map(len, seq2))
    sequences = {}

    for i in range(min_l, max_l+1):
        for string in seq1:
	      # Create all sub sequences of given length i
         sequences.update({}.fromkeys(slices(string, i)))

    subs = []
    for item in seq2:
        if item in sequences:
            subs.append(item)

    return subs

在这种方法中,我们预先计算seq1中字符串的大小范围的所有子字符串,并将其存储在字典中。然后只需遍历seq2中的字符串,并检查它们是否在此字典中,如果是,则将它们添加到列表中。

为了优化计算,我们只计算大小在seq2字符串的最小和最大长度范围内的字符串。

与几乎所有解决性能问题的解决方案一样,这种方法以时间换空间。通过预先计算所有子字符串,我们在内存中消耗了更多的空间,但这简化了计算时间。

测试代码如下:

import random
import string

seq1, seq2 = [], []

def random_strings(n, N):
     """ Create N random strings in range of 4..n and append
     to global sequences seq1, seq2 """

    global seq1, seq2
    for i in range(N):
        seq1.append(''.join(random.sample(string.ascii_lowercase,
                             random.randrange(4, n))))

    for i in range(N):
        seq2.append(''.join(random.sample(string.ascii_lowercase,
                             random.randrange(2, n/2))))  

def test(N):
    random_strings(10, N)
    subs=sub_string(seq1, seq2)

def test2():
    # random_strings has to be called before this
    subs=sub_string(seq1, seq2)

以下是使用timeit模块运行此函数的时间结果:

>>> t=timeit.Timer('test2()',setup='from sub_string import test2, random_
strings;random_strings(10, 100)')
>>> 1000000*t.timeit(number=10000)/10000.0
1081.6103347984608
>>> t=timeit.Timer('test2()',setup='from sub_string import test2, random_
strings;random_strings(10, 1000)')
>>> 1000000*t.timeit(number=1000)/1000.0
11974.320339999394
>>> t=timeit.Timer('test2()',setup='from sub_string import test2, random_
strings;random_strings(10, 10000)')
>>> 1000000*t.timeit(number=100)/100.0124718.30968977883
124718.30968977883
>>> t=timeit.Timer('test2()',setup='from sub_string import test2, random_
strings;random_strings(10, 100000)')
>>> 1000000*t.timeit(number=100)/100.0
1261111.164370086

以下是此测试的总结结果:

输入大小 花费时间
100 1.08 微秒
1000 11.97 微秒
10000 0.12 微秒
100000 1.26 秒

表 2:通过蛮力解决方案的输入大小与花费时间

快速计算告诉我们,该算法现在的性能为 O(n)。非常好!

但这是以预先计算的字符串的内存为代价。我们可以通过调用内存分析器来估计这一点。

这是用于执行此操作的装饰函数:

@profile
def sub_string(seq1, seq2):
    """ Return sub-strings from seq2 which are part of strings in seq1 """

    # Create all slices of lengths in a given range
    min_l, max_l = min(map(len, seq2)), max(map(len, seq2))
    sequences = {}

    for i in range(min_l, max_l+1):
        for string in seq1:
            sequences.update({}.fromkeys(slices(string, i)))

    subs = []
    for item in seq2:
        if item in sequences:
            subs.append(item)

现在测试函数如下:

def test(N):
    random_strings(10, N)
    subs = sub_string(seq1, seq2)

让我们分别测试大小为 1,000 和 10,000 的序列。

以下是输入大小为 1,000 时的结果:

子串(子序列)问题

测试大小为 1,000 的序列的内存分析器结果

以下是输入大小为 10,000 时的结果:

子串(子序列)问题

测试大小为 10,000 的序列的内存分析器结果

对于大小为 1,000 的序列,内存使用量增加了微不足道的 1.4 MB。对于大小为 10,000 的序列,它增加了 6.2 MB。显然,这些数字并不是非常显著的。

因此,使用内存分析器进行测试清楚地表明,尽管我们的算法在时间性能上效率高,但也具有高效的内存利用率。

其他工具

在本节中,我们将讨论一些其他工具,这些工具将帮助程序员调试内存泄漏,并使其能够可视化其对象及其关系。

Objgraph

Objgraph(对象图)是一个 Python 对象可视化工具,它利用graphviz包绘制对象引用图。

它不是一个分析或检测工具,但可以与此类工具一起使用,以可视化复杂程序中的对象树和引用,同时寻找难以捉摸的内存泄漏。它允许您查找对象的引用,以找出是什么引用使对象保持活动状态。

与 Python 世界中的几乎所有内容一样,它可以通过pip安装:

$ pip3 install objgraph

然而,objgraph 只有在能够生成图形时才真正有用。因此,我们需要安装graphviz包和xdot工具。

在 Debian/Ubuntu 系统中,您可以按照以下步骤安装:

$ sudo apt install graphviz xdot -y

让我们看一个使用objgraph查找隐藏引用的简单示例:

import objgraph

class MyRefClass(object):
    pass

ref=MyRefClass()
class C(object):pass

c_objects=[]
for i in range(100):
    c=C()
    c.ref=ref
    c_objects.append(c)

import pdb; pdb.set_trace()

我们有一个名为MyRefClass的类,其中有一个单一实例ref,由for循环中创建的 100 个C类的实例引用。这些是可能导致内存泄漏的引用。让我们看看objgraph如何帮助我们识别它们。

当执行这段代码时,它会停在调试器(pdb)处:

$ python3 objgraph_example.py
--Return--
[0] > /home/user/programs/chap4/objgraph_example.py(15)<module>()->None
-> import pdb; pdb.set_trace()
(Pdb++) objgraph.show_backrefs(ref, max_depth=2, too_many=2, filename='refs.png')
Graph written to /tmp/objgraph-xxhaqwxl.dot (6 nodes)
Image generated as refs.png

注意

图像的左侧已被裁剪,只显示相关部分。

接下来是 objgraph 生成的图表:

Objgraph

Objgraph 对象引用的可视化

前面图表中的红色框显示99 个更多的引用,这意味着它显示了一个C类的实例,并告诉我们还有 99 个类似的实例 - 总共有 100 个 C 类的实例,引用了单个对象ref

在一个复杂的程序中,我们无法跟踪导致内存泄漏的对象引用,程序员可以利用这样的引用图。

Pympler

Pympler 是一个用于监视和测量 Python 应用程序中对象内存使用情况的工具。它适用于 Python 2.x 和 3.x。可以使用pip安装如下:

$ pip3 install pympler

Pympler 的文档相当缺乏。但是,它的众所周知的用途是通过其asizeof模块跟踪对象并打印其实际内存使用情况。

以下是我们修改后用于打印序列字典(其中存储了所有生成的子串)的内存使用情况的sub_string函数:

from pympler import asizeof

def sub_string(seq1, seq2):
    """ Return sub-strings from seq2 which are part of strings in seq1 """

    # Create all slices of lengths in a given range
    min_l, max_l = min(map(len, seq2)), max(map(len, seq2))
    sequences = {}

    for i in range(min_l, max_l+1):
        for string in seq1:
            sequences.update({}.fromkeys(slices(string, i)))

    subs = []
    for item in seq2:
        if item in sequences:
            subs.append(item)
    print('Memory usage',asizeof.asized(sequences).format())

    return subs

当对大小为 10,000 的序列运行时:

$ python3 sub_string.py
Memory usage {'awg': None, 'qlbo': None, 'gvap': No....te':** 
 **None, 'luwr':
 **None, 'ipat': None}** 
size=5874384** 
flat=3145824

5870408字节(约 5.6 MB)的内存大小与内存分析器报告的一致(约 6 MB)

Pympler 还带有一个名为muppy的包,允许跟踪程序中的所有对象。这可以通过summary包总结应用程序中所有对象(根据其类型分类)的内存使用情况。

这是我们使用 n =10,000 运行的sub_string模块的报告。为此,执行部分必须修改如下:

if __name__ == "__main__":
    from pympler import summary
    from pympler import muppy
    test(10000)
    all_objects = muppy.get_objects()
    sum1 = summary.summarize(all_objects)
    summary.print_(sum1)

以下显示了pympler在程序结束时总结的输出:

Pympler

由 pympler 按对象类型分类的内存使用摘要

为性能编程——数据结构

我们已经看过了性能的定义、性能复杂度的测量以及测量程序性能的不同工具。我们还通过对代码进行统计、内存使用等进行了性能分析。

我们还看到了一些程序优化的例子,以改善代码的时间性能。

在本节中,我们将看一下常见的 Python 数据结构,并讨论它们的最佳和最差性能场景,还将讨论它们适合的理想情况以及它们可能不是最佳选择的一些情况。

可变容器——列表、字典和集合

列表、字典和集合是 Python 中最受欢迎和有用的可变容器。

列表适用于通过已知索引访问对象。字典为具有已知键的对象提供接近常数时间的查找。集合可用于保留项目组,同时丢弃重复项,并在接近线性时间内找到它们的差异、交集、并集等。

让我们依次看看每个。

列表

列表为以下操作提供了接近常数时间 O(1)的顺序:

  • 通过[]运算符的get(index)

  • 通过.append方法的append(item)

但是,在以下情况下,列表的性能表现不佳(O(n)):

  • 通过in运算符寻找项目

  • 通过.insert方法在索引处插入

在以下情况下,列表是理想的选择:

  • 如果您需要一个可变存储来保存不同类型或类的项目(异构)。

  • 如果您的对象搜索涉及通过已知索引获取项目。

  • 如果您不需要通过搜索列表进行大量查找(item in list)。

  • 如果您的任何元素是不可哈希的。字典和集合要求它们的条目是可哈希的。因此,在这种情况下,您几乎默认使用列表。

如果您有一个庞大的列表——比如超过 100,000 个项目——并且您发现自己通过in运算符搜索元素,您应该将其替换为字典。

同样,如果您发现自己大部分时间都在向列表插入而不是附加,您可以考虑使用collections模块中的deque替换列表。

字典

字典为以下情况提供了常数时间顺序:

  • 通过键设置项目

  • 通过键获取项目

  • 通过键删除项目

然而,与列表相比,字典占用的内存略多。字典在以下情况下很有用:

  • 您不关心元素的插入顺序

  • 在键方面没有重复的元素

字典也非常适合在应用程序开始时从源(数据库或磁盘)加载大量通过键唯一索引的数据,并且需要快速访问它们——换句话说,大量随机读取而不是较少的写入或更新。

集合

集合的使用场景介于列表和字典之间。在 Python 中,集合的实现更接近于字典——因为它们是无序的,不支持重复元素,并且通过键提供接近 O(1)的时间访问项目。它们在某种程度上类似于列表,因为它们支持弹出操作(即使它们不允许索引访问!)。

在 Python 中,集合通常用作处理其他容器的中间数据结构——用于删除重复项、查找两个容器之间的共同项等操作。

由于集合操作的顺序与字典完全相同,您可以在大多数需要使用字典的情况下使用它们,只是没有值与键相关联。

示例包括:

  • 在丢弃重复项的同时,保留来自另一个集合的异构、无序数据

  • 在应用程序中为特定目的处理中间数据-例如查找公共元素,组合多个容器中的唯一元素,删除重复项等

不可变容器-元组

元组是 Python 中列表的不可变版本。由于它们在创建后无法更改,因此不支持列表修改的任何方法,例如插入、附加等。

元组与使用索引和搜索(通过item in tuple)时的时间复杂度相同。但是,与列表相比,它们占用的内存开销要少得多;解释器对它们进行了更多优化,因为它们是不可变的。

因此,只要存在读取、返回或创建不会更改但需要迭代的数据容器的用例,就可以使用元组。以下是一些示例:

  • 从数据存储加载的逐行数据,将仅具有读取访问权限。例如,来自 DB 查询的结果,从读取 CSV 文件的处理行等。

  • 需要反复迭代的一组常量值。例如,从配置文件加载的配置参数列表。

  • 从函数返回多个值。在这种情况下,除非显式返回列表,否则 Python 始终默认返回元组。

  • 当可变容器需要成为字典键时。例如,当需要将列表或集合与字典键关联时,快速方法是将其转换为元组。

高性能容器-集合模块

集合模块提供了 Python 内置默认容器类型的高性能替代品,即listsetdicttuple

我们将简要介绍集合模块中的以下容器类型:

  • deque:列表容器的替代品,支持快速插入和弹出

  • defaultdict:为提供缺失值的类型提供工厂函数的dict的子类

  • OrderedDict:记住插入键的顺序的dict的子类

  • Counter:用于保持可散列类型的计数和统计信息的字典子类

  • Chainmap:具有类似字典的接口的类,用于跟踪多个映射

  • namedtuple:用于创建具有命名字段的类似元组的类型

双端队列

双端队列或双端队列类似于列表,但支持几乎恒定的(O(1))时间附加和弹出,而不是列表,列表在左侧弹出和插入的成本为 O(n)。

双端队列还支持旋转等操作,用于将k个元素从后面移动到前面,并且具有 O(k)的平均性能。这通常比列表中的类似操作稍快,列表涉及切片和附加:

def rotate_seq1(seq1, n):
    """ Rotate a list left by n """
    # E.g: rotate([1,2,3,4,5], 2) => [4,5,1,2,3]

    k = len(seq1) - n
    return seq1[k:] + seq1[:k]

def rotate_seq2(seq1, n):
    """ Rotate a list left by n using deque """

    d = deque(seq1)
    d.rotate(n)
    return d

通过简单的timeit测量,您应该发现双端队列在性能上略优于列表(约 10-15%),在上面的示例中。

defaultdict

默认字典是使用类型工厂提供默认值以提供字典键的字典子类。

在 Python 中遇到的一个常见问题是,当循环遍历项目列表并尝试增加字典计数时,可能不存在该项的现有条目。

例如,如果要计算文本中单词出现的次数:

counts = {}
for word in text.split():
    word = word.lower().strip()
    try:
        counts[word] += 1
    except KeyError:
        counts[word] = 1

我们被迫编写前面的代码或其变体。

另一个例子是根据特定条件将对象分组到字典中,例如,尝试将所有长度相同的字符串分组到字典中:

cities = ['Jakarta','Delhi','Newyork','Bonn','Kolkata','Bangalore','Seoul']
cities_len = {}
for city in cities:
  clen = len(city)
  # First create entry
  if clen not in cities_len:
    cities_len[clen] = []
  cities_len[clen].append(city)

defaultdict容器通过定义类型工厂来解决这些问题,以为尚未存在于字典中的任何键提供默认参数。默认工厂类型支持任何默认类型,并默认为None

对于每种类型,其空值是默认值。这意味着:

0 → default value for integers
[] → default value for lists
'' → default value for strings
{} → default value for dictionaries

然后可以将单词计数代码重写如下:

counts = defautldict(int)
for word in text.split():
    word = word.lower().strip()
    # Value is set to 0 and incremented by 1 in one go
    counts[word] += 1

同样,对于按其长度分组字符串的代码,我们可以这样写:

cities = ['Jakarta','Delhi','Newyork','Bonn','Kolkata','Bangalore','Seoul']
cities_len = defaultdict(list)
for city in cities:
    # Empty list is created as value and appended to in one go
    cities_len[len(city)].append(city)

有序字典

OrderedDict 是 dict 的子类,它记住条目插入的顺序。它有点像字典和列表的混合体。它的行为类似于映射类型,但也具有列表般的行为,可以记住插入顺序,并支持诸如popitem之类的方法来移除最后或第一个条目。

这里有一个例子:

>>> cities = ['Jakarta','Delhi','Newyork','Bonn','Kolkata','Bangalore','Seoul']
>>> cities_dict = dict.fromkeys(cities)
>>> cities_dict
{'Kolkata': None, 'Newyork': None, 'Seoul': None, 'Jakarta': None, 'Delhi': None, 'Bonn': None, 'Bangalore': None}

# Ordered dictionary
>>> cities_odict = OrderedDict.fromkeys(cities)
>>> cities_odict
OrderedDict([('Jakarta', None), ('Delhi', None), ('Newyork', None), ('Bonn', None), ('Kolkata', None), ('Bangalore', None), ('Seoul', None)])
>>> cities_odict.popitem()
('Seoul', None)
>>> cities_odict.popitem(last=False)
('Jakarta', None)

你可以比较和对比字典如何改变顺序以及OrdredDict容器如何保持原始顺序。

这允许使用OrderedDict容器的一些配方。

在不丢失顺序的情况下从容器中删除重复项

让我们修改城市列表以包括重复项:

>>> cities = ['Jakarta','Delhi','Newyork','Bonn','Kolkata','Bangalore','Bonn','Seoul','Delhi','Jakarta','Mumbai']
>>> cities_odict = OrderedDict.fromkeys(cities)
>>> print(cities_odict.keys())
odict_keys(['Jakarta', 'Delhi', 'Newyork', 'Bonn', 'Kolkata', 'Bangalore', 'Seoul', 'Mumbai'])

看看重复项是如何被删除但顺序被保留的。

实现最近最少使用(LRU)缓存字典

LRU 缓存优先考虑最近使用(访问)的条目,并丢弃最少使用的条目。这是 HTTP 缓存服务器(如 Squid)中常用的缓存算法,以及需要保持有限大小容器的地方,优先保留最近访问的项目。

在这里,我们利用了OrderedDict的行为:当现有键被移除并重新添加时,它会被添加到末尾(右侧):

class LRU(OrderedDict):
    """ Least recently used cache dictionary """

    def __init__(self, size=10):
        self.size = size

    def set(self, key):
        # If key is there delete and reinsert so
        # it moves to end.
        if key in self:
            del self[key]

        self[key] = 1
        if len(self)>self.size:
            # Pop from left
            self.popitem(last=False)

这里有一个演示。

>>> d=LRU(size=5)
>>> d.set('bangalore')
>>> d.set('chennai')
>>> d.set('mumbai')
>>> d.set('bangalore')
>>> d.set('kolkata')
>>> d.set('delhi')
>>> d.set('chennai')

>>> len(d)
5
>>> d.set('kochi')
>>> d
LRU([('bangalore', 1), ('chennai', 1), ('kolkata', 1), ('delhi', 1), ('kochi', 1)])

由于键mumbai首先设置并且再也没有设置过,它成为了最左边的一个,并被删除了。

注意

注意下一个要删除的候选者是bangalore,接着是chennai。这是因为在bangalore设置后又设置了chennai

计数器

计数器是字典的子类,用于保持可散列对象的计数。元素存储为字典键,它们的计数存储为值。Counter类是 C++等语言中多重集合的并行体,或者是 Smalltalk 等语言中的 Bag。

计数器是在处理任何容器时保持项目频率的自然选择。例如,可以使用计数器在解析文本时保持单词的频率或在解析单词时保持字符的频率。

例如,以下两个代码片段执行相同的操作,但计数器的代码更简洁紧凑。

它们都从在线古腾堡版本的著名福尔摩斯小说《巴斯克维尔的猎犬》的文本中返回最常见的 10 个单词。

  • 在以下代码中使用defaultdict容器:
import requests, operator
    text=requests.get('https://www.gutenberg.org/files/2852/2852-0.txt').text
    freq=defaultdict(int)
    for word in text.split():
        if len(word.strip())==0: continue
        freq[word.lower()] += 1
        print(sorted(freq.items(), key=operator.itemgetter(1), reverse=True) [:10])
  • 在以下代码中使用Counter类:
import requests
text = requests.get('https://www.gutenberg.org/files/2852/2852-0.txt').text
freq = Counter(filter(None, map(lambda x:x.lower().strip(), text.split())))
print(freq.most_common(10))

ChainMap

ChainMap是一个类似字典的类,它将多个字典或类似的映射数据结构组合在一起,创建一个可更新的单一视图。

所有通常的字典方法都受支持。查找会搜索连续的映射,直到找到一个键。

ChainMap类是 Python 中较新的添加内容,它是在 Python 3.3 中添加的。

当你有一个场景,需要一遍又一遍地从源字典更新键到目标字典时,ChainMap类可以在性能方面对你有利,特别是如果更新次数很大。

以下是ChainMap的一些实际用途:

  • 程序员可以将 web 框架的GETPOST参数保持在单独的字典中,并通过单个ChainMap更新配置。

  • 在应用程序中保持多层配置覆盖。

  • 当没有重叠的键时,可以将多个字典作为视图进行迭代。

  • ChainMap类在其 maps 属性中保留了先前的映射。然而,当你使用另一个字典的映射更新一个字典时,原始字典状态就会丢失。这里有一个简单的演示:

>>> d1={i:i for i in range(100)}
>>> d2={i:i*i for i in range(100) if i%2}
>>> c=ChainMap(d1,d2)
# Older value accessible via chainmap
>>> c[5]
5
>>> c.maps[0][5]
5
# Update d1
>>> d1.update(d2)
# Older values also got updated
>>> c[5]
25
>>> c.maps[0][5]
25

namedtuple

命名元组类似于具有固定字段的类。字段可以通过属性查找访问,就像普通类一样,但也可以通过索引访问。整个命名元组也可以像容器一样进行迭代。换句话说,命名元组行为类似于类和元组的结合体:

>>> Employee = namedtuple('Employee', 'name, age, gender, title, department')
>>> Employee
<class '__main__.Employee'>

让我们创建一个 Employee 的实例:

>>> jack = Employee('Jack',25,'M','Programmer','Engineering')
>>> print(jack)
Employee(name='Jack', age=25, gender='M', title='Programmer', department='Engineering')

我们可以遍历实例的字段,就好像它是一个迭代器:

>>> for field in jack:
... print(field)
...
Jack
25
M
Programmer
Engineering

创建后,namedtuple实例就像元组一样是只读的:

>>> jack.age=32
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: can't set attribute

要更新值,可以使用_replace方法。它返回一个具有指定关键字参数替换为新值的新实例:

>>> jack._replace(age=32)
Employee(name='Jack', age=32, gender='M', title='Programmer', department='Engineering')

与具有相同字段的类相比,命名元组在内存效率上要高得多。因此,在以下情况下,命名元组非常有用:

  • 需要将大量数据作为只读加载,从存储中获取键和值。例如,通过 DB 查询加载列和值,或者从大型 CSV 文件加载数据。

  • 当需要创建大量类的实例,但属性上并不需要进行许多写入或设置操作时,可以创建namedtuple实例以节省内存,而不是创建类实例。

  • 可以使用_make方法加载现有的可迭代对象,以相同顺序返回一个namedtuple实例。例如,如果有一个employees.csv文件,其中列名、年龄、性别、职称和部门按顺序排列,我们可以使用以下命令将它们全部加载到namedtuples的容器中:

employees = map(Employee._make, csv.reader(open('employees.csv'))

概率数据结构 - 布隆过滤器

在我们结束对 Python 中容器数据类型的讨论之前,让我们来看看一个重要的概率数据结构,名为布隆过滤器。Python 中的布隆过滤器实现类似于容器,但它们具有概率性质。

布隆过滤器是一种稀疏的数据结构,允许我们测试集合中元素的存在性。但是,我们只能确定元素在集合中不存在 - 也就是说,我们只能断言真负。当布隆过滤器告诉我们元素在集合中时,它可能在那里 - 换句话说,元素实际上可能丢失的概率不为零。

布隆过滤器通常实现为位向量。它们的工作方式类似于 Python 字典,因为它们使用哈希函数。但是,与字典不同,布隆过滤器不存储实际的元素本身。此外,一旦添加元素,就无法从布隆过滤器中删除。

当源数据的数量意味着如果我们存储所有数据而没有哈希冲突,就会占用非常大的内存时,就会使用布隆过滤器。

在 Python 中,pybloom包提供了一个简单的布隆过滤器实现(但是在撰写本文时,它不支持 Python 3.x,因此这里的示例是在 Python 2.7.x 中显示的):

$ pip install pybloom

让我们编写一个程序,从《巴斯克维尔的猎犬》文本中读取并索引单词,这是我们在讨论计数器数据结构时使用的示例,但这次使用布隆过滤器:

# bloom_example.py
from pybloom import BloomFilter
import requests

f=BloomFilter(capacity=100000, error_rate=0.01)
text=requests.get('https://www.gutenberg.org/files/2852/2852-0.txt').text

for word in text.split():
    word = word.lower().strip()
    f.add(word)

print len(f)
print len(text.split())
for w in ('holmes','watson','hound','moor','queen'):
    print 'Found',w,w in f

执行此操作,我们得到以下输出:

$ python bloomtest.py
9403
62154
Found holmes True
Found watson True
Found moor True
Found queen False

注意

在《巴斯克维尔的猎犬》故事中,holmeswatsonhoundmoor是最常见的单词,因此布隆过滤器能够找到这些单词是令人放心的。另一方面,queen这个词在文本中从未出现,因此布隆过滤器在这一点上是正确的(真负)。文本中单词的长度为 62,154,其中只有 9,403 个被索引到过滤器中。

让我们尝试测量布隆过滤器的内存使用情况,与计数器相比。为此,我们将依赖于内存分析器。

对于这个测试,我们将使用Counter类重写代码如下:

# counter_hound.py
import requests
from collections import Counter

@profile
def hound():
    text=requests.get('https://www.gutenberg.org/files/2852/2852-0.txt').text
    c = Counter()
    words = [word.lower().strip() for word in text.split()]
    c.update(words)

if __name__ == "__main__":
    hound()

使用布隆过滤器的情况如下:

# bloom_hound.py
from pybloom import BloomFilter
import requests

@profile
def hound():
    f=BloomFilter(capacity=100000, error_rate=0.01)
    text=requests.get('https://www.gutenberg.org/files/2852/2852-0.txt').text

    for word in text.split():
        word = word.lower().strip()
        f.add(word)

if __name__ == "__main__":
    hound()

以下是运行内存分析器的第一个输出:

概率数据结构 - 布隆过滤器

解析《巴斯克维尔的猎犬》文本时计数器对象的内存使用情况

第二个的结果如下:

概率数据结构 - 布隆过滤器

布隆过滤器用于解析《巴斯克维尔的猎犬》文本的内存使用情况

最终的内存使用量大约相同,每个约为 50 MB。在 Counter 的情况下,当 Counter 类被创建时几乎不使用内存,但在向计数器添加单词时使用了接近 0.7 MB。

然而,这两种数据结构之间的内存增长模式存在明显的差异。

在布隆过滤器的情况下,在创建时为其分配了 0.16 MB 的初始内存。添加单词似乎几乎不会给过滤器或程序增加内存。

那么,什么时候应该使用布隆过滤器,而不是在 Python 中使用字典或集合?以下是一些一般原则和现实世界使用场景:

  • 当您对不存储实际元素本身,而只对元素的存在(或不存在)感兴趣时。换句话说,您的应用用例更依赖于检查数据的缺失而不是其存在。

  • 当您的输入数据量非常大,以至于在内存中存储每个项目(如字典或哈希表)是不可行的时。布隆过滤器在内存中占用的数据要少得多,而不是确定性数据结构。

  • 当您对数据集的假阳性具有一定的明确定义的错误率满意时 - 比如说在 100 万条数据中有 5% - 您可以为特定的错误率配置一个布隆过滤器,并获得满足您要求的数据命中率。

一些使用布隆过滤器的现实世界例子如下:

  • 安全测试:在浏览器中存储恶意 URL 的数据,例如

  • 生物信息学:测试基因组中某种模式(k-mer)的存在

  • 为了避免在分布式网络缓存基础设施中存储只有一个命中的 URL

总结

本章主要讨论了性能。在本章开始时,我们讨论了性能和 SPE。我们看了性能测试和诊断工具的两类 - 即压力测试工具和分析/仪器工具。

然后我们讨论了性能复杂性在大 O 符号中的真正含义,并简要讨论了函数的常见时间顺序。我们看了函数执行所花费的时间,并学习了 POSIX 系统中的三种时间使用方式 - 即realusersys

我们在下一节中转向了性能和时间的测量 - 从简单的上下文管理器计时器开始,然后使用timeit模块进行更准确的测量。我们测量了一系列输入大小的某些算法所花费的时间。通过将所花费的时间与输入大小进行绘图,并将其叠加在标准时间复杂性图上,我们能够直观地了解函数的性能复杂性。我们将常见的项目问题从 O(n*log(n))性能优化到 O(n),并绘制了时间使用的图表来证实这一点。

然后我们开始讨论代码的性能分析,并看到了使用cProfile模块进行性能分析的一些示例。我们选择的示例是一个返回前n个质数的质数迭代器,其性能为 O(n)。使用分析数据,我们对代码进行了一些优化,使其性能优于 O(n)。我们简要讨论了pstats模块,并使用其Stats类来读取分析数据并生成按可用数据字段数量排序的自定义报告。我们讨论了另外两个第三方分析器 - liner_profilermemory_profiler,它们逐行分析代码 - 并讨论了在两个字符串序列中查找子序列的问题,编写了它们的优化版本,并使用这些分析器测量了其时间和内存使用情况。

在其他工具中,我们讨论了 objgraph 和 pympler - 前者作为一种可视化工具,用于查找对象之间的关系和引用,帮助探索内存泄漏,后者作为一种监视和报告代码中对象内存使用情况并提供摘要的工具。

在上一节关于 Python 容器的部分中,我们看了标准 Python 容器(如列表、字典、集合和元组)的最佳和最差的使用情况。然后我们研究了 collections 模块中的高性能容器类,包括dequedefaultdictOrderedDictCounterChainmapnamedtuple,并提供了每个容器的示例和用法。具体来说,我们看到了如何使用OrderedDict非常自然地创建 LRU 缓存。

在本章的最后,我们讨论了一种特殊的数据结构,称为布隆过滤器,它作为一种概率数据结构非常有用,可以确定地报告真负例,并在预定义的错误率内报告真正例。

在下一章中,我们将讨论性能的近亲扩展性,我们将探讨编写可扩展应用程序的技术,以及在 Python 中编写可扩展和并发程序的细节。

第五章:编写可扩展的应用程序

想象一下超市的收银台在一个周六晚上,通常是高峰时间。常见的是看到长队的人在等待结账购物。店长可以做些什么来减少拥挤和等待时间呢?

典型的经理会尝试一些方法,包括告诉那些操作结账柜台的人加快速度,并尝试重新分配人员到不同的队列,以便每个队列大致等待时间相同。换句话说,他将通过优化现有资源的性能来管理当前负载与可用资源。

然而,如果商店有未使用的柜台,并且有足够的人手来管理它们,经理可以启用这些柜台,并将人员移动到这些新柜台。换句话说,他将向商店添加资源以扩展操作。

软件系统也以类似的方式扩展。可以通过向现有软件应用程序添加计算资源来扩展它。

当系统通过添加或更好地利用计算节点内的资源(如 CPU 或 RAM)来扩展时,它被称为垂直扩展向上扩展。另一方面,当系统通过向其添加更多计算节点(例如创建负载平衡的服务器集群)来扩展时,它被称为水平扩展向外扩展

当计算资源增加时,软件系统能够扩展的程度称为其可扩展性。可扩展性是指系统的性能特征,如吞吐量或延迟,随着资源的增加而改善的程度。例如,如果一个系统通过增加服务器数量来扩展其容量,它就是线性扩展的。

增加系统的并发通常会增加其可扩展性。在前面给出的超市例子中,经理能够通过开设额外的柜台来扩展他的业务。换句话说,他增加了商店中同时进行的工作量。并发是系统中同时完成的工作量。

在本章中,我们将探讨使用 Python 扩展软件应用程序的不同技术。

在本章中,我们将按照下面的主题大致讨论。

  • 可扩展性和性能

  • 并发

  • 并发性和并行性

  • Python 中的并发性 - 多线程

缩略图生成器

缩略图生成器 - 生产者/消费者架构

缩略图生成器 - 程序结束条件

缩略图生成器 - 使用锁的资源约束

缩略图生成器 - 使用信号量的资源约束

资源约束 - 信号量 vs 锁

缩略图生成器 - 使用条件控制器的 URL 速率控制器

多线程 - Python 和 GIL

  • Python 中的并发性 - 多进程

素性检查器

排序磁盘文件

排序磁盘文件 - 使用计数器

排序磁盘文件 - 使用多进程

  • 多线程 vs 多进程

  • Python 中的并发性 - 异步执行

抢占式 vs 协作式多任务处理

Python 中的 Asyncio

等待未来 - 异步和等待

并发未来 - 高级并发处理

  • 并发选项 - 如何选择?

  • 并行处理库

  • joblib

  • PyMP

分形 - 曼德布洛特集

分形 - 缩放曼德布洛特集实现

  • 为 Web 扩展

  • 扩展工作流程 - 消息队列和任务队列

  • Celery - 分布式任务队列

曼德布洛特集 - 使用 Celery

  • 在 Web 上提供 Python - WSGI

uWSGI - WSGI 中间件的超级版

gunicorn - WSGI 的独角兽

gunicorn vs uWSGI

  • 可扩展性架构

  • 垂直可扩展性架构

  • 水平可扩展性架构

可扩展性和性能

我们如何衡量系统的可扩展性?让我们举个例子,看看如何做到这一点。

假设我们的应用是一个简单的员工报告生成系统。它能够从数据库中加载员工数据,并批量生成各种报告,如工资单、税收扣除报告、员工请假报告等。

系统能够每分钟生成 120 份报告——这是系统吞吐量或容量的表达,表示在给定时间内成功完成的操作数量。假设在服务器端生成报告所需的时间(延迟)大约为 2 秒。

假设架构师决定通过在服务器上加倍 RAM 来扩展系统

完成此操作后,测试显示系统能够将吞吐量提高到每分钟 180 份报告。延迟保持在 2 秒。

因此,此时系统在增加的内存方面实现了“接近线性”的扩展。系统的可伸缩性以吞吐量增加的方式表达如下:

可伸缩性(吞吐量)= 180/120 = 1.5 倍

作为第二步,架构师决定在后端将服务器数量加倍,所有服务器的内存相同。此步骤后,他发现系统的性能吞吐量现在增加到每分钟 350 份报告。此步骤实现的可伸缩性如下:

可伸缩性(吞吐量)= 350/180 = 1.9 倍

系统现在以接近线性的方式做出了更好的响应,提高了可伸缩性。

经过进一步分析,架构师发现通过重写在服务器上处理报告的代码,使其在多个进程中运行而不是单个进程,他能够在高峰时期将每个请求的处理时间减少约 1 秒。延迟现在从 2 秒降至 1 秒。

系统的性能在延迟方面已经变得更好

性能(延迟):X = 2/1 = 2 倍

这如何改善可伸缩性?由于现在处理每个请求所需的时间更短,系统整体将能够以比之前更快的速度响应类似的负载。在其他因素保持不变的情况下,系统的吞吐性能和因此可伸缩性已经提高。

让我们总结一下我们迄今讨论的内容:

  1. 在第一步中,架构师通过增加额外内存作为资源来扩展单个系统的吞吐量,从而增加了系统的整体可伸缩性。换句话说,他通过“纵向扩展”来扩展单个系统的性能,从而提高了整个系统的性能。

  2. 在第二步中,他向系统添加了更多节点,因此系统能够同时执行工作,并发现系统以接近线性的可伸缩性因子回报他。换句话说,他通过增加计算节点来扩展系统的资源容量,从而提高了系统的可伸缩性。因此,他通过“横向扩展”来增加了系统的可伸缩性,即通过添加更多计算节点。

  3. 在第三步中,他通过在多个进程中运行计算来进行关键修复。换句话说,他通过将计算分成多个部分来增加单个系统的并发性。他发现这提高了应用程序的性能特征,通过减少其延迟,潜在地使应用程序能够更好地处理高压工作负载。

我们发现可伸缩性、性能、并发性和延迟之间存在关系。这可以解释如下:

  1. 当系统中某个组件的性能提高时,通常整个系统的性能也会提高。

  2. 当应用程序通过增加并发性在单台机器上扩展时,它有潜力提高性能,从而提高部署系统的净可伸缩性。

  3. 当系统减少其性能时间或服务器端的延迟时,这对可伸缩性是积极的贡献。

我们已经在以下表格中捕捉到了这些关系:

并发性 延迟 性能 可扩展性
可变 可变

理想的系统是具有良好并发性和低延迟的系统;这样的系统具有高性能,并且对于扩展和/或横向扩展会有更好的响应。

具有高并发性但也有高延迟的系统将具有可变的特征——其性能,因此,可扩展性可能对其他因素非常敏感,例如当前系统负载、网络拥塞、计算资源和请求的地理分布等。

低并发和高延迟的系统是最糟糕的情况——很难扩展这样的系统,因为它具有较差的性能特征。在架构师决定横向或纵向扩展系统之前,应该先解决延迟和并发性问题。

可扩展性总是以性能吞吐量的变化来描述。

并发性

系统的并发性是系统能够同时执行工作而不是顺序执行的程度。一般来说,编写为并发的应用程序在给定时间内可以执行更多的工作单位,而不是编写为顺序或串行的应用程序。

当将串行应用程序并发化时,可以更好地利用系统中现有的计算资源——CPU 和/或 RAM——在给定时间内。换句话说,并发性是以计算资源成本为代价,使应用程序在机器内扩展的最便宜的方式。

并发可以通过不同的技术实现。常见的包括以下几种:

  1. 多线程:并发的最简单形式是将应用程序重写为在不同线程中执行并行任务。线程是 CPU 可以执行的最简单的编程指令序列。一个程序可以包含任意数量的线程。通过将任务分配给多个线程,程序可以同时执行更多的工作。所有线程都在同一个进程内运行。

  2. 多进程:将程序并发地扩展到多个进程而不是单个进程是另一种方法。多进程在消息传递和共享内存方面的开销比多线程更大。然而,执行大量 CPU 密集型计算的程序比多线程更适合多进程。

  3. 异步处理:在这种技术中,操作是异步执行的,没有特定的任务顺序与时间有关。异步处理通常从任务队列中选择任务,并安排它们在将来的某个时间执行,通常在回调函数或特殊的 future 对象中接收结果。异步处理通常发生在单个线程中。

还有其他形式的并发计算,但在本章中,我们将只关注这三种。

Python,特别是 Python 3,在其标准库中内置支持所有这些类型的并发计算技术。例如,它通过其threading模块支持多线程,并通过其multiprocessing模块支持多进程。异步执行支持可通过asyncio模块获得。一种将异步执行与线程和进程结合起来的并发处理形式可通过concurrent.futures模块获得。

在接下来的章节中,我们将逐个查看每个,并提供足够的示例。

注意

注意:asyncio 模块仅在 Python 3 中可用

并发与并行

我们将简要介绍并发概念及其近亲并行概念。

并发和并行都是关于同时执行工作而不是顺序执行。然而,在并发中,两个任务不需要在完全相同的时间执行;相反,它们只需要被安排同时执行。另一方面,并行性要求两个任务在给定的时间点同时执行。

举一个现实生活的例子,假设你正在为你房子的两面外墙涂漆。你只雇用了一个画家,你发现他花的时间比你想象的要多。你可以通过以下两种方式解决问题:

  1. 指示画家在切换到下一面墙之前在一面墙上涂几层,然后在另一面墙上做同样的事情。假设他很有效,他将同时(虽然不是在同一时间)在两面墙上工作,并在给定的时间内达到相同的完成度。这是一个并发解决方案。

  2. 再雇用一个画家。指示第一个画家涂第一面墙,第二个画家涂第二面墙。这是一个并行解决方案。

两个线程在单核 CPU 中执行字节码计算并不完全进行并行计算,因为 CPU 一次只能容纳一个线程。然而,从程序员的角度来看,它们是并发的,因为 CPU 调度程序快速地在线程之间进行切换,使它们看起来是并行运行的。

然而,在多核 CPU 上,两个线程可以在不同的核心上同时进行并行计算。这是真正的并行。

并行计算要求计算资源至少与其规模成线性增长。通过使用多任务处理技术,可以实现并发计算,其中工作被安排并批量执行,更好地利用现有资源。

注意

在本章中,我们将统一使用术语并发来指示两种类型的执行。在某些地方,它可能表示传统方式的并发处理,而在其他地方,它可能表示真正的并行处理。使用上下文来消除歧义。

Python 中的并发 - 多线程

我们将从 Python 中的多线程开始讨论并发技术。

Python 通过其threading模块支持多线程编程。线程模块公开了一个Thread类,它封装了一个执行线程。除此之外,它还公开了以下同步原语:

  1. 一个Lock对象,用于同步保护对共享资源的访问,以及它的表兄弟RLock

  2. 一个 Condition 对象,用于线程在等待任意条件时进行同步。

  3. Event对象,它提供了线程之间的基本信号机制。

  4. 一个Semaphore对象,它允许对有限资源进行同步访问。

  5. 一个Barrier对象,它允许一组固定的线程等待彼此,同步到特定状态,然后继续。

Python 中的线程对象可以与队列模块中的同步Queue类结合使用,用于实现线程安全的生产者/消费者工作流程。

缩略图生成器

让我们从一个用于生成图像 URL 缩略图的程序的例子开始讨论 Python 中的多线程。

在这个例子中,我们使用Pillow,它是Python Imaging LibraryPIL)的一个分支,来执行这个操作:

# thumbnail_converter.py
from PIL import Image
import urllib.request

def thumbnail_image(url, size=(64, 64), format='.png'):
    """ Save thumbnail of an image URL """

    im = Image.open(urllib.request.urlopen(url))
    # filename is last part of the URL minus extension + '.format'
    pieces = url.split('/')
    filename = ''.join((pieces[-2],'_',pieces[-1].split('.')[0],'_thumb',format))
    im.thumbnail(size, Image.ANTIALIAS)
    im.save(filename)  
    print('Saved',filename)

前面的代码对单个 URL 非常有效。

假设我们想要将五个图像 URL 转换为它们的缩略图:

img_urls = ['https://dummyimage.com/256x256/000/fff.jpg',
 **'https://dummyimage.com/320x240/fff/00.jpg',
 **'https://dummyimage.com/640x480/ccc/aaa.jpg',
 **'https://dummyimage.com/128x128/ddd/eee.jpg',
 **'https://dummyimage.com/720x720/111/222.jpg']
for url in img_urls:
    thumbnail_image(urls)

让我们看看这样一个函数在所花费的时间方面的表现:

缩略图生成器

对于 5 个 URL 的串行缩略图转换的响应时间

该函数大约每个 URL 花费了 1.7 秒的时间。

现在让我们将程序扩展到多个线程,这样我们就可以同时进行转换。以下是重写的代码,以便在每个转换中运行自己的线程:

import threading

for url in img_urls:
    t=threading.Thread(target=thumbnail_image,args=(url,))
    t.start()

现在,这个最后一个程序的时间显示在这个截图中:

缩略图生成器

5 个 URL 的线程缩略图转换的响应时间

通过这个改变,程序返回时间为 1.76 秒,几乎等于串行执行之前单个 URL 所花费的时间。换句话说,程序现在与线程数量成线性关系。请注意,我们无需对函数本身进行任何更改即可获得这种可伸缩性提升。

缩略图生成器 - 生产者/消费者架构

在前面的例子中,我们看到一组图像 URL 被缩略图生成器函数并发处理,使用多个线程。通过使用多个线程,我们能够实现接近线性的可伸缩性,与串行执行相比。

然而,在现实生活中,与其处理固定的 URL 列表,更常见的是 URL 数据由某种 URL 生产者生成。例如,可以从数据库、逗号分隔值(CSV)文件或 TCP 套接字中获取这些数据。

在这种情况下,为每个 URL 创建一个线程将是一种巨大的资源浪费。在系统中创建线程需要一定的开销。我们需要一种方法来重用我们创建的线程。

对于涉及一定数量的线程生成数据和另一组线程消耗或处理数据的系统,生产者/消费者模型是一个理想的选择。这样的系统具有以下特点:

  1. 生产者是一种专门的工作者(线程)类,用于生成数据。它们可以从特定来源接收数据,或者自己生成数据。

  2. 生产者将数据添加到共享的同步队列中。在 Python 中,这个队列由名为 queue 的模块中的 Queue 类提供。

  3. 另一组专门的工作者类,即消费者,等待队列获取(消费)数据。一旦获取数据,他们就会处理它并产生结果。

  4. 当生产者停止生成数据并且消费者无法获取数据时,程序结束。可以使用超时、轮询或毒丸等技术来实现这一点。当发生这种情况时,所有线程退出,程序完成。

我们已经将我们的缩略图生成器重写为生产者消费者架构。接下来是生成的代码。由于这有点详细,我们将逐个讨论每个类。

首先,让我们看一下导入部分 - 这些都相当容易理解:

# thumbnail_pc.py
import threading
import time
import string
import random
import urllib.request
from PIL import Image
from queue import Queue

接下来是生产者类的代码:

class ThumbnailURL_Generator(threading.Thread):
    """ Worker class that generates image URLs """

    def __init__(self, queue, sleep_time=1,):
        self.sleep_time = sleep_time
        self.queue = queue
        # A flag for stopping
        self.flag = True
        # choice of sizes
        self._sizes = (240,320,360,480,600,720)
        # URL scheme
        self.url_template = 'https://dummyimage.com/%s/%s/%s.jpg'
        threading.Thread.__init__(self, name='producer')

    def __str__(self):
        return 'Producer'

    def get_size(self):
        return '%dx%d' % (random.choice(self._sizes),
                          random.choice(self._sizes))

    def get_color(self):
        return ''.join(random.sample(string.hexdigits[:-6], 3))

    def run(self):
        """ Main thread function """

        while self.flag:
            # generate image URLs of random sizes and fg/bg colors
            url = self.url_template % (self.get_size(),
                                       self.get_color(),
                                       self.get_color())
            # Add to queue
            print(self,'Put',url)
            self.queue.put(url)
            time.sleep(self.sleep_time)

    def stop(self):
        """ Stop the thread """

        self.flag = False

让我们分析生产者类的代码:

  1. 该类名为 ThumbnailURL_Generator。它生成不同尺寸、前景和背景颜色的 URL(通过使用名为 dummyimage.com 的网站的服务)。它继承自 threading.Thread 类。

  2. 它有一个 run 方法,进入循环,生成一个随机图像 URL,并将其推送到共享队列。每次,线程都会休眠一段固定的时间,由 sleep_time 参数配置。

  3. 该类公开了一个 stop 方法,它将内部标志设置为 False,导致循环中断并且线程完成其处理。这通常可以由另一个线程外部调用,通常是主线程。

现在,URL 消费者类消耗缩略图 URL 并创建缩略图:

class ThumbnailURL_Consumer(threading.Thread):
    """ Worker class that consumes URLs and generates thumbnails """

    def __init__(self, queue):
        self.queue = queue
        self.flag = True
        threading.Thread.__init__(self, name='consumer')     

    def __str__(self):
        return 'Consumer'

    def thumbnail_image(self, url, size=(64,64), format='.png'):
        """ Save image thumbnails, given a URL """

        im=Image.open(urllib.request.urlopen(url))
        # filename is last part of URL minus extension + '.format'
        filename = url.split('/')[-1].split('.')[0] + '_thumb' + format
        im.thumbnail(size, Image.ANTIALIAS)
        im.save(filename)
        print(self,'Saved',filename)    

    def run(self):
        """ Main thread function """

        while self.flag:
            url = self.queue.get()
            print(self,'Got',url)
            self.thumbnail_image(url)

    def stop(self):
        """ Stop the thread """

        self.flag = False            

以下是消费者类的分析:

  1. 该类名为 ThumbnailURL_Consumer,它从队列中获取 URL,并创建其缩略图图像。

  2. 该类的 run 方法进入循环,从队列中获取一个 URL,并通过将其传递给 thumbnail_image 方法将其转换为缩略图。(请注意,此代码与我们之前创建的 thumbnail_image 函数完全相同。)

  3. stop 方法非常相似,每次在循环中检查停止标志,并在标志被取消后结束。

这是代码的主要部分 - 设置一对生产者和消费者,并运行它们:

    q = Queue(maxsize=200)
    producers, consumers = [], []

    for i in range(2):
        t = ThumbnailURL_Generator(q)
        producers.append(t)
        t.start()

    for i in range(2):
        t = ThumbnailURL_Consumer(q)
        consumers.append(t)
        t.start()

这是程序运行的屏幕截图:

缩略图生成器-生产者/消费者架构

使用 4 个线程运行缩略图生产者/消费者程序,每种类型 2 个

在上述程序中,由于生产者不断生成随机数据而没有结束,消费者将继续消耗它而没有结束。我们的程序没有适当的结束条件。

因此,该程序将一直运行,直到网络请求被拒绝或超时,或者由于缩略图而使机器的磁盘空间耗尽。

然而,解决真实世界问题的程序应该以可预测的方式结束。

这可能是由于许多外部约束造成的。

  • 可以引入一个超时,在这种情况下,消费者等待一定的最大时间获取数据,如果在此期间没有可用数据,则退出。例如,这可以在队列的get方法中配置为超时。

  • 另一种技术是在消耗或创建一定数量的资源后发出程序结束信号。例如,在该程序中,可以限制创建的缩略图数量。

在接下来的部分中,我们将看到如何通过使用线程同步原语(如 Locks 和 Semaphores)来强制执行此类资源限制。

注意

您可能已经注意到,我们使用start方法启动线程,尽管线程子类中的重写方法是run。这是因为在父Thread类中,start方法设置了一些状态,然后在内部调用run方法。这是调用线程的运行方法的正确方式。它不应该直接调用。

缩略图生成器-使用锁的资源约束

在前面的部分中,我们看到了如何重写生产者/消费者架构中的缩略图生成器程序。然而,我们的程序有一个问题——它会无休止地运行,直到磁盘空间或网络带宽耗尽。

在本节中,我们将看到如何使用Lock来修改程序,Lock是一种同步原语,用于实现限制创建图像数量的计数器,以结束程序。

Python 中的 Lock 对象允许线程对共享资源进行独占访问。

伪代码如下:

try:
  lock.acquire()
  # Do some modification on a shared, mutable resource
  mutable_object.modify()
finally:
  lock.release()

然而,Lock 对象支持上下文管理器,通过with语句更常见地编写如下:

with lock:
  mutable_object.modify()

为了实现每次运行固定数量的图像,我们的代码需要支持添加一个计数器。然而,由于多个线程将检查和增加此计数器,因此需要通过Lock对象进行同步。

这是我们使用 Locks 实现的资源计数器类的第一个实现。

class ThumbnailImageSaver(object):
    """ Class which saves URLs to thumbnail images and keeps a counter """

    def __init__(self, limit=10):
        self.limit = limit
        self.lock = threading.Lock()
        self.counter = {}

    def thumbnail_image(self, url, size=(64,64), format='.png'):
        """ Save image thumbnails, given a URL """

        im=Image.open(urllib.request.urlopen(url))
        # filename is last two parts of URL minus extension + '.format'
        pieces = url.split('/')
        filename = ''.join((pieces[-2],'_',pieces[-1].split('.')[0],'_thumb',format))
        im.thumbnail(size, Image.ANTIALIAS)
        im.save(filename)
        print('Saved',filename)
        self.counter[filename] = 1      
        return True

    def save(self, url):
        """ Save a URL as thumbnail """

        with self.lock:
            if len(self.counter)>=self.limit:
                return False
            self.thumbnail_image(url)
            print('Count=>',len(self.counter))
            return True

由于这也修改了消费者类,因此讨论这两个更改是有意义的。这是修改后的消费者类,以适应需要跟踪图像的额外计数器:

class ThumbnailURL_Consumer(threading.Thread):
    """ Worker class that consumes URLs and generates thumbnails """

    def __init__(self, queue, saver):
        self.queue = queue
        self.flag = True
        self.saver = saver
        # Internal id
        self._id = uuid.uuid4().hex
        threading.Thread.__init__(self, name='Consumer-'+ self._id)     

    def __str__(self):
        return 'Consumer-' + self._id

    def run(self):
        """ Main thread function """

        while self.flag:
            url = self.queue.get()
            print(self,'Got',url)
            if not self.saver.save(url):
               # Limit reached, break out
               print(self, 'Set limit reached, quitting')
               break

    def stop(self):
        """ Stop the thread """

        self.flag = False

让我们分析这两个类。首先是新类ThumbnailImageSaver

  1. 这个类派生自object。换句话说,它不是一个Thread。它不是一个Thread

  2. 它在初始化方法中初始化了一个锁对象和一个计数器字典。锁用于线程同步访问计数器。它还接受一个等于应保存的图像数量的limit参数。

  3. thumbnail_image方法从消费者类移动到这里。它从一个使用锁的上下文中的save方法调用。

  4. save方法首先检查计数是否超过了配置的限制;当这种情况发生时,该方法返回False。否则,通过调用thumbnail_image保存图像,并将图像文件名添加到计数器,有效地增加计数。

接下来是修改后的ThumbnailURL_Consumer类。

  1. 该类的初始化程序已修改为接受ThumbnailImageSaver的实例作为saver参数。其余参数保持不变。

  2. 在这个类中,thumbnail_image方法不再存在,因为它已经移动到新的类中。

  3. run方法大大简化。它调用保存程序实例的save方法。如果返回False,则表示已达到限制,循环中断,消费者线程退出。

  4. 我们还修改了__str__方法,以返回每个线程的唯一 ID,该 ID 在初始化时使用uuid模块设置。这有助于在实际示例中调试线程。

调用代码也稍有更改,因为它需要设置新对象,并配置消费者线程:

q = Queue(maxsize=2000)
# Create an instance of the saver object
saver = ThumbnailImageSaver(limit=100)

    producers, consumers = [], []
    for i in range(3):
        t = ThumbnailURL_Generator(q)
        producers.append(t)
        t.start()

    for i in range(5):
        t = ThumbnailURL_Consumer(q, saver)     
        consumers.append(t)
        t.start()

    for t in consumers:
        t.join()
        print('Joined', t, flush=True)

    # To make sure producers don't block on a full queue
    while not q.empty():
        item=q.get()

    for t in producers:
        t.stop()
        print('Stopped',t, flush=True)

    print('Total number of PNG images',len(glob.glob('*.png')))

以下是需要注意的主要要点:

  1. 我们创建了新的ThumbnailImageSaver类的实例,并在创建消费者线程时将其传递给它们。

  2. 我们首先等待消费者。请注意,主线程不调用stop,而是对它们调用join。这是因为当达到限制时,消费者会自动退出,因此主线程应该等待它们停止。

  3. 在消费者退出后,我们明确地停止生产者-因为否则它们将永远工作,因为没有条件让生产者退出。

我们使用字典而不是整数,因为数据的性质。

由于图像是随机生成的,因此有可能一个图像 URL 与之前创建的另一个图像 URL 相同,导致文件名冲突。使用字典可以解决这种可能的重复情况。

以下屏幕截图显示了使用 100 张图像限制运行程序的情况。请注意,我们只能显示控制台日志的最后几行,因为它会产生大量输出:

缩略图生成器-使用锁的资源约束

使用锁限制 100 张图像的缩略图生成程序的运行

您可以将此程序配置为任何图像限制,并且它将始终获取完全相同的数量-既不多也不少。

在下一节中,我们将熟悉另一个同步原语,即信号量,并学习如何使用信号量以类似的方式实现资源限制类。

使用信号量的缩略图生成器-资源约束

锁不是实现同步约束和在其上编写逻辑的唯一方法,例如限制系统使用/生成的资源。

信号量是计算机科学中最古老的同步原语之一,非常适合这种用例。

信号量是用大于零的值初始化的:

  1. 当线程在具有正内部值的信号量上调用acquire时,该值会减少一个,并且线程会继续进行。

  2. 当另一个线程在信号量上调用release时,该值会增加 1。

  3. 一旦值达到零,任何线程调用acquire都会在信号量上被阻塞,直到另一个线程调用release唤醒它。

由于这种行为,信号量非常适合在共享资源上实现固定限制。

在下面的代码示例中,我们将使用信号量实现另一个用于限制缩略图生成器程序资源的类:

class ThumbnailImageSemaSaver(object):
    """ Class which keeps an exact counter of saved images
    and restricts the total count using a semaphore """

    def __init__(self, limit = 10):
        self.limit = limit
        self.counter = threading.BoundedSemaphore(value=limit)
        self.count = 0

    def acquire(self):
        # Acquire counter, if limit is exhausted, it
        # returns False
        return self.counter.acquire(blocking=False)

    def release(self):
        # Release counter, incrementing count
        return self.counter.release()

    def thumbnail_image(self, url, size=(64,64), format='.png'):
        """ Save image thumbnails, given a URL """

        im=Image.open(urllib.request.urlopen(url))
        # filename is last two parts of URL minus extension + '.format'
        pieces = url.split('/')
        filename = ''.join((pieces[-2],'_',pieces[-1].split('.')[0],format))        
        try:
            im.thumbnail(size, Image.ANTIALIAS)
            im.save(filename)
            print('Saved',filename)
            self.count += 1
        except Exception as e:
            print('Error saving URL',url,e)
            # Image can't be counted, increment semaphore
            self.release()

        return True

    def save(self, url):
        """ Save a URL as thumbnail """

        if self.acquire():
            self.thumbnail_image(url)
            return True
        else:
            print('Semaphore limit reached, returning False')
            return False

由于基于信号量的新类保持与基于锁的先前类完全相同的接口-具有保存方法-因此不需要更改任何消费者代码!

只有调用代码需要更改。

在先前的代码中初始化了ThumbnailImageSaver实例的这一行:

saver = ThumbnailImageSaver(limit=100)

前一行需要替换为以下行:

   saver = ThumbnailImageSemaSaver(limit=100)

其余代码保持完全相同。

在看到这段代码之前,让我们快速讨论一下使用信号量的新类:

  1. acquirerelease方法只是对信号量上相同方法的简单包装。

  2. 我们在初始化程序中使用图像限制的值来初始化信号量。

  3. 在保存方法中,我们调用acquire方法。如果信号量的限制已达到,它将返回False。否则,线程保存图像并返回True。在前一种情况下,调用线程退出。

注意

这个类的内部计数属性只用于调试。它对限制图像的逻辑没有任何添加。

这个类的行为方式与前一个类似,并且确切地限制资源。以下是一个限制为 200 张图片的示例:

使用信号量的缩略图生成器-资源约束

使用信号量运行缩略图生成程序,限制为 200 张图片

资源约束-信号量与锁

在前两个示例中,我们看到了两个实现固定资源约束的竞争版本——一个使用Lock,另一个使用Semaphore

两个版本之间的区别如下:

  1. 使用锁的版本保护了所有修改资源的代码——在这种情况下,检查计数器、保存缩略图和增加计数器——以确保没有数据不一致。

  2. 信号量版本更像是一个门,当计数低于限制时门是打开的,任意数量的线程可以通过,只有当达到限制时才关闭。换句话说,它不会互斥地排除线程调用缩略图保存函数。

因此,信号量版本的效果将比使用锁的版本更快。

有多快?以下是一个运行 100 张图片的计时示例。

这个截图显示了使用锁版本保存 100 张图片所需的时间:

资源约束-信号量与锁

计时缩略图生成程序的运行——锁版本——100 张图片

以下截图显示了使用信号量版本保存类似数量的时间:

资源约束-信号量与锁

计时缩略图生成程序的运行——信号量版本——100 张图片

通过快速计算,您可以看到信号量版本比锁版本快大约 4 倍,逻辑相同。换句话说,它扩展 4 倍

缩略图生成器-使用条件控制 URL 速率

在本节中,我们将简要介绍线程中另一个重要的同步原语的应用,即Condition对象。

首先,我们将得到一个使用Condition对象的现实生活示例。我们将为我们的缩略图生成器实现一个节流器,以管理 URL 生成的速率。

在现实生活中的生产者/消费者系统中,关于数据生产和消费速度,可能会出现以下三种情况:

  1. 生产者产生的数据速度比消费者消耗的速度快。这导致消费者总是在追赶生产者。生产者产生的多余数据可能会积累在队列中,导致队列消耗更多的内存和 CPU 使用率,从而使程序变慢。

  2. 消费者以比生产者更快的速度消耗数据。这导致消费者总是在队列上等待数据。这本身并不是问题,只要生产者不落后太多。在最坏的情况下,这会导致系统的一半,即消费者,保持空闲,而另一半——生产者——试图满足需求。

  3. 生产者和消费者以几乎相同的速度工作,保持队列大小在限制范围内。这是理想的情况。

有许多方法可以解决这个问题。其中一些如下:

  1. 具有固定大小的队列——一旦队列大小限制达到,生产者将被迫等待,直到数据被消费者消耗。然而,这几乎总是使队列保持满状态。

  2. 为工作线程提供超时和其他职责:生产者和/或消费者可以使用超时在队列上等待,而不是保持阻塞状态。当超时时,它们可以在返回并等待队列之前睡眠或执行其他职责。

  3. 动态配置工作线程的数量:这是一种方法,其中工作线程池的大小会根据需求自动增加或减少。如果某一类工作线程领先,系统将启动相反类别的所需数量的工作线程以保持平衡。

  4. 调整数据生成速率:在这种方法中,我们通过生产者静态或动态地调整数据生成速率。例如,系统可以配置为以固定速率生成数据,比如每分钟 50 个 URL,或者它可以计算消费者的消费速率,并动态调整生产者的数据生成速率以保持平衡。

在下面的示例中,我们将实现最后一种方法 - 使用Condition对象将 URL 的生产速率限制为固定限制。

Condition对象是一种复杂的同步原语,带有隐式内置锁。它可以等待任意条件直到条件变为 True。当线程在条件上调用wait时,内部锁被释放,但线程本身变为阻塞状态:

cond = threading.Condition()
# In thread #1
with cond:
    while not some_condition_is_satisfied():
        # this thread is now blocked
        cond.wait()

现在,另一个线程可以通过将条件设置为 True 来唤醒前面的线程,然后在条件对象上调用notifynotify_all。此时,前面被阻塞的线程被唤醒,并继续执行:

# In thread #2
with cond:
    # Condition is satisfied
    if some_condition_is_satisfied():
        # Notify all threads waiting on the condition
        cond.notify_all()

这是我们的新类,即ThumbnailURLController,它使用条件对象实现 URL 生成的速率控制。

class ThumbnailURLController(threading.Thread):
    """ A rate limiting controller thread for URLs using conditions """

    def __init__(self, rate_limit=0, nthreads=0):
        # Configured rate limit
        self.rate_limit = rate_limit
        # Number of producer threads
        self.nthreads = nthreads
        self.count = 0
        self.start_t = time.time()
        self.flag = True
        self.cond = threading.Condition()
        threading.Thread.__init__(self)

    def increment(self):
        # Increment count of URLs
        self.count += 1

    def calc_rate(self):
        rate = 60.0*self.count/(time.time() - self.start_t)
        return rate

    def run(self):
        while self.flag:
            rate = self.calc_rate()
            if rate<=self.rate_limit:
                with self.cond:
                    # print('Notifying all...')
                    self.cond.notify_all()

    def stop(self):
        self.flag = False

    def throttle(self, thread):
        """ Throttle threads to manage rate """
        # Current total rate
        rate = self.calc_rate()
        print('Current Rate',rate)
        # If rate > limit, add more sleep time to thread
        diff = abs(rate - self.rate_limit)
        sleep_diff = diff/(self.nthreads*60.0)

        if rate>self.rate_limit:
            # Adjust threads sleep_time
            thread.sleep_time += sleep_diff
            # Hold this thread till rate settles down with a 5% error
            with self.cond:
                print('Controller, rate is high, sleep more by',rate,sleep_diff)                
                while self.calc_rate() > self.rate_limit:
                    self.cond.wait()
        elif rate<self.rate_limit:
            print('Controller, rate is low, sleep less by',rate,sleep_diff)                         
            # Decrease sleep time
            sleep_time = thread.sleep_time
            sleep_time -= sleep_diff
            # If this goes off < zero, make it zero
            thread.sleep_time = max(0, sleep_time)

在讨论生产者类中的更改之前,让我们先讨论上述代码:

  1. 该类是Thread的一个实例,因此它在自己的执行线程中运行。它还持有一个 Condition 对象。

  2. 它有一个calc_rate方法,通过保持计数器和使用时间戳来计算 URL 生成的速率。

  3. run方法中,检查速率。如果低于配置的限制,条件对象会通知所有等待它的线程。

  4. 最重要的是,它实现了一个throttle方法。该方法使用通过calc_rate计算的当前速率,并用它来限制和调整生产者的睡眠时间。它主要做这两件事:

  5. 如果速率高于配置的限制,则导致调用线程在条件对象上等待,直到速率稳定下来。它还计算了线程在循环中应该睡眠的额外时间,以调整速率到所需水平。

  6. 如果速率低于配置的限制,则线程需要更快地工作并产生更多数据,因此它计算睡眠差并相应地降低睡眠限制。

以下是生产者类的代码,以包含这些更改:

class ThumbnailURL_Generator(threading.Thread):
    """ Worker class that generates image URLs and supports throttling via an external controller """

    def __init__(self, queue, controller=None, sleep_time=1):
        self.sleep_time = sleep_time
        self.queue = queue
        # A flag for stopping
        self.flag = True
        # sizes
        self._sizes = (240,320,360,480,600,720)
        # URL scheme
        self.url_template = 'https://dummyimage.com/%s/%s/%s.jpg'
        # Rate controller
        self.controller = controller
        # Internal id
        self._id = uuid.uuid4().hex
        threading.Thread.__init__(self, name='Producer-'+ self._id)

    def __str__(self):
        return 'Producer-'+self._id

    def get_size(self):
        return '%dx%d' % (random.choice(self._sizes),
                          random.choice(self._sizes))

    def get_color(self):
        return ''.join(random.sample(string.hexdigits[:-6], 3))

    def run(self):
        """ Main thread function """

        while self.flag:
            # generate image URLs of random sizes and fg/bg colors
            url = self.url_template % (self.get_size(),
                                       self.get_color(),
                                       self.get_color())
            # Add to queue
            print(self,'Put',url)
            self.queue.put(url)
            self.controller.increment()
            # Throttle after putting a few images
            if self.controller.count>5:
                self.controller.throttle(self)

            time.sleep(self.sleep_time)

    def stop(self):
        """ Stop the thread """

        self.flag = False

让我们看看这段最后的代码是如何工作的:

  1. 该类现在在初始化时接受一个额外的控制器对象。这是之前给出的控制器类的实例。

  2. 放置 URL 后,它会增加控制器上的计数。一旦计数达到最小限制(设置为 5 以避免过早地限制生产者),它会在控制器上调用throttle,并将自身作为参数传递。

调用代码也需要进行相当多的更改。修改后的代码如下所示:

    q = Queue(maxsize=2000)
    # The controller needs to be configured with exact number of 
    # producers
    controller = ThumbnailURLController(rate_limit=50, nthreads=3)
    saver = ThumbnailImageSemaSaver(limit=200)

    controller.start()

    producers, consumers = [], []
    for i in range(3):
        t = ThumbnailURL_Generator(q, controller)
        producers.append(t)
        t.start()

    for i in range(5):
        t = ThumbnailURL_Consumer(q, saver)     
        consumers.append(t)
        t.start()

    for t in consumers:
        t.join()
        print('Joined', t, flush=True)

    # To make sure producers dont block on a full queue
    while not q.empty():
        item=q.get()
    controller.stop()

    for t in producers:
        t.stop()
        print('Stopped',t, flush=True)

    print('Total number of PNG images',len(glob.glob('*.png')))

这里的主要更改如下所列:

  1. 控制器对象被创建 - 具有将要创建的生产者的确切数量。这有助于正确计算每个线程的睡眠时间。

  2. 生产者线程本身在初始化时会传入控制器的实例。

  3. 控制器在所有其他线程之前作为一个线程启动。

这是以每分钟 50 张图片的速率配置了 200 张图片的程序运行。我们展示了运行程序输出的两张图片,一张是程序开始时的,另一张是接近结束时的。

缩略图生成器 - 使用条件的 URL 速率控制器

以每分钟 50 个 URL 的速率启动缩略图程序

你会发现,当程序启动时,几乎立即变慢,几乎停止,因为原始速率很高。这里发生的是,生产者调用throttle方法,由于速率很高,它们都被阻塞在条件对象上。

几秒钟后,速率下降到规定的限制,因为没有生成 URL。这在控制器的循环中被检测到,并调用notify_all唤醒它们。

过一会儿,你会发现速率稳定在每分钟 50 个 URL 的设定限制周围。

缩略图生成器 - 使用条件的 URL 速率控制器

带有 URL 速率控制器的缩略图程序在启动后 5-6 秒

在程序接近结束时,你会发现速率几乎已经稳定在确切的限制上:

缩略图生成器 - 使用条件的 URL 速率控制器

朝着结束的方向,带有 URL 速率控制器的缩略图程序

我们即将结束我们关于线程原语的讨论,以及如何在程序中提高并发性和实现共享资源约束和控制时使用它们。

在我们结束之前,我们将看一下 Python 线程的一个方面,它阻止多线程程序在 Python 中充分利用 CPU 的能力 - 即 GIL 或全局解释器锁。

多线程 - Python 和 GIL

在 Python 中,有一个全局锁,防止多个线程同时执行本机字节码。这个锁是必需的,因为 CPython(Python 的本机实现)的内存管理不是线程安全的。

这个锁被称为全局解释器锁GIL

由于全局解释器锁(GIL),Python 无法在 CPU 上并发执行字节码操作。因此,Python 几乎不适用于以下情况:

  • 当程序依赖于一些重型字节码操作,希望并发运行时

  • 当程序使用多线程在单台机器上充分利用多个 CPU 核心的全部性能

I/O 调用和长时间运行的操作通常发生在 GIL 之外。因此,在 Python 中,多线程只有在涉及一定量的 I/O 或类似操作(如图像处理)时才有效。

在这种情况下,将程序扩展到超出单个进程的并发扩展是一个方便的方法。Python 通过其multiprocessing模块实现了这一点,这是我们下一个讨论的主题。

Python 中的并发性 - 多进程

Python 标准库提供了一个多进程模块,允许程序员编写使用多个进程而不是线程并发扩展的程序。

由于多进程可以跨多个进程扩展计算,它有效地消除了 Python 中的 GIL 问题。程序可以有效地利用多个 CPU 核心使用这个模块。

此模块公开的主要类是Process类,它是线程模块中Thread类的类似物。它还提供了一些同步原语,几乎与线程模块中的同类相对应。

我们将通过使用此模块提供的Pool对象来开始一个示例。它允许一个函数在多个输入上并行执行进程。

一个素数检查器

以下函数是一个简单的素数检查函数,即输入的数字是否为素数:

def is_prime(n):
    """ Check for input number primality """

    for i in range(3, int(n**0.5+1), 2):
        if n % i == 0:
            print(n,'is not prime')
            return False

    print(n,'is prime')     
    return True

以下是一个使用上述函数从队列中检查素数的线程类:

# prime_thread.py
import threading

class PrimeChecker(threading.Thread):
    """ Thread class for primality checking """

    def __init__(self, queue):
        self.queue = queue
        self.flag = True
        threading.Thread.__init__(self)     

    def run(self):

        while self.flag:
            try:
                n = self.queue.get(timeout=1)
                is_prime(n)
            except Empty:
                break

我们将用 1000 个大素数进行测试。为了节省这里表示的列表的空间,我们做的是取其中的 10 个数字并将列表乘以 100:

    numbers = [1297337, 1116281, 104395303, 472882027, 533000389,     
               817504243, 982451653, 112272535095293, 115280095190773,    
               1099726899285419]*100

    q = Queue(1000)

    for n in numbers:
        q.put(n)

    threads = []
    for i in range(4):
        t = PrimeChecker(q)
        threads.append(t)
        t.start()

    for t in threads:
        t.join()

我们已经为这个测试使用了四个线程。让我们看看程序的表现,如下截图所示:

一个素数检查器

使用 4 个线程的 1000 个数字的素数检查器

现在,这是使用多进程Pool对象的等效代码:

    numbers = [1297337, 1116281, 104395303, 472882027, 533000389,   
               817504243, 982451653, 112272535095293, 115280095190773,  
               1099726899285419]*100
    pool = multiprocessing.Pool(4)
    pool.map(is_prime, numbers)

下面的截图显示了它在相同一组数字上的表现:

一个素数检查器

使用 4 个进程的多进程池的 1000 个数字的素数检查器

通过比较这些数字,我们得出以下结论:

  1. 进程池版本的实际时间,即 1 分 9.6 秒(69.6 秒)的挂钟时间,几乎比线程池版本的 2 分 12 秒(132 秒)少了 50%。

  2. 但是,请注意,进程池版本的用户时间——即在 CPU 内部用于用户代码的时间——为 4 分 22 秒(262 秒),几乎是线程池版本的 2 分 12 秒(132 秒)的两倍。

  3. 线程池版本的真实 CPU 时间和用户 CPU 时间完全相同,都是 2 分 12 秒。这清楚地表明,线程版本只能在一个 CPU 核心中有效地执行。

这意味着进程池版本能够更好地利用所有的 CPU 核心,因为对于线程池版本的实际时间的 50%,它能够利用 CPU 时间两倍。

因此,两个程序在 CPU 时间/实际时间方面的真正性能提升如下:

  1. 线程版本 → 132 秒/132 秒 = 1

  2. 进程版本 → 262 秒/69.6 秒 = 3.76 约等于 4

因此,进程版本相对于线程版本的真实性能比率如下:

4/1 = 4

程序执行的机器具有四核 CPU。这清楚地表明,代码的多进程版本能够几乎平均利用 CPU 的所有四个核心。

这是因为线程版本受到了 GIL 的限制,而进程版本没有这样的限制,可以自由地利用所有的核心。

在下一节中,让我们来解决一个更复杂的问题——对基于磁盘的文件进行排序。

排序磁盘文件

想象一下,你在磁盘上有数十万个文件,每个文件包含一定数量的整数,范围在给定范围内。假设我们需要对这些文件进行排序并合并成一个单一的文件。

如果我们决定将所有这些数据加载到内存中,将需要大量的 RAM。让我们快速计算一下,对于一百万个文件,每个文件包含大约 100 个整数,范围在 1 到 10,000 之间,总共 1 亿个整数。

假设每个文件都作为一个整数列表从磁盘加载——我们暂时忽略字符串处理等。

使用sys.getsizeof,我们可以进行一个粗略的计算:

>>> sys.getsizeof([100000]*1000)*100000/(1024.0*1024.0)
769.04296875

因此,如果一次性加载到内存中,整个数据将占用接近 800MB。现在乍一看,这可能并不像是一个很大的内存占用,但是列表越大,将其作为一个大列表在内存中排序所需的系统资源就越多。

这是将所有加载到内存中的磁盘文件中的所有整数进行排序的最简单的代码:

# sort_in_memory.py
import sys

all_lists = []

for i in range(int(sys.argv[1])):
    num_list = map(int, open('numbers/numbers_%d.txt' % i).readlines())
    all_lists += num_list

print('Length of list',len(all_lists))
print('Sorting...')
all_lists.sort()
open('sorted_nums.txt','w').writelines('\n'.join(map(str, all_lists)) + '\n')
print('Sorted')

上述代码从磁盘加载了一定数量的文件,每个文件包含 1 到 10,000 范围内的 100 个整数。它读取每个文件,将其映射到一个整数列表,并将每个列表添加到一个累积列表中。最后,对列表进行排序并写入文件。

下表显示了对一定数量的磁盘文件进行排序所需的时间:

文件数量(n) 排序所需时间
1000 17.4 秒
10000 101 秒
100000 138 秒
1000000 不适用

正如您所看到的,所花费的时间相当合理 - 小于O(n)。但是,这是一个更多关于空间 - 即内存及其上的操作 - 而不是时间的问题。

例如,在用于进行测试的机器上,一台配备 8GB RAM、4 核 CPU 和 64 位 Linux 的笔记本电脑上,百万个数字的测试没有完成。相反,它导致系统挂起,因此未完成。

排序磁盘文件 - 使用计数器

如果您查看数据,会发现有一个方面使我们可以将问题视为更多关于空间而不是时间。这是观察到整数处于固定范围内,最大限制为 10,000。

因此,可以使用计数器之类的数据结构,而不是将所有数据加载为单独的列表并将它们合并。

这是它的基本工作原理:

  1. 初始化一个数据结构 - 一个计数器,其中每个整数从 1… 10,000 开始,最大条目初始化为零。

  2. 加载每个文件并将数据转换为列表。对于列表中找到的任何数字,在第 1 步初始化的计数器数据结构中递增其计数。

  3. 最后,循环遍历计数器,并输出每个计数大于零的数字如此多次,并将输出保存到文件中。输出即为合并和排序后的单个文件:

# sort_counter.py
import sys
import collections

MAXINT = 100000

def sort():
    """ Sort files on disk by using a counter """

counter = collections.defaultdict(int)
for i in range(int(sys.argv[1])):
filename = 'numbers/numbers_%d.txt' % i
for n in open(filename):
counter[n] += 1
print('Sorting...')

with open('sorted_nums.txt','w') as fp:
for i in range(1, MAXINT+1):
    count = counter.get(str(i) + '\n', 0)
if count>0:
fp.write((str(i)+'\n')*count)

print('Sorted')

在上述代码中,我们使用了来自 collections 模块的defaultdict作为计数器。每当遇到一个整数,我们就会递增其计数。最后,循环遍历计数器,并将每个项目输出多次,就像它被找到的次数一样。

排序和合并是由于我们将问题从整数排序问题转换为计数并以自然排序顺序输出的方式而发生的。

以下表总结了排序数字所需的时间,以及输入大小(以磁盘文件数量表示):

文件数量(n) 排序所需时间
1000 16.5 秒
10000 83 秒
100000 86 秒
1000000 359 秒

尽管最小情况下的性能 - 即 1,000 个文件的情况与内存排序相似,但随着输入大小的增加,性能会变得更好。该代码还能够在大约 5 分 59 秒内完成对 100 万个文件或 1 亿个整数的排序。

注意

在对读取文件的进程进行时间测量时,总会受到内核缓冲区缓存的影响。您会发现,连续运行相同的性能测试会显示巨大的改进,因为 Linux 会将文件的内容缓存在其缓冲区缓存中。因此,应在清除缓冲区缓存后进行相同输入大小的后续测试。在 Linux 中,可以通过以下命令完成:

$ echo 3 > /proc/sys/vm/drop_caches

在我们对连续数字的测试中,像之前所示那样重置缓冲区缓存。这意味着对于更高的数字,可以从先前运行期间创建的缓存中获得性能提升。但是,由于这对每个测试都是统一进行的,因此结果是可比较的。在针对特定算法的测试套件开始之前,会重置缓存。

由于每次运行的内存需求都是相同的,因此这种算法还需要更少的内存,因为我们使用的是一个整数数组,最多到 MAXINT,并且只是递增计数。

这是使用memory_profiler对 100,000 个文件的内存排序程序的内存使用情况,我们在上一章中已经遇到过。

使用计数器对磁盘文件进行排序

100,000 个文件输入的内存排序程序的内存使用情况

以下截图显示了相同数量文件的计数器排序的内存使用情况:

使用计数器对磁盘文件进行排序

100,000 个文件输入的计数器排序程序的内存使用情况

内存使用率在内存排序程序中为 465MB,比 70MB 的计数排序程序高出六倍多。还要注意,在内存版本中,排序操作本身需要额外的近 10MB 内存。

使用多进程对磁盘文件进行排序

在本节中,我们使用多个进程重写计数排序程序。方法是通过将文件路径列表拆分为进程池,以便为多个进程扩展处理输入文件,并计划利用由此产生的数据并行性。

以下是代码的重写:

# sort_counter_mp.py
import sys
import time
import collections
from multiprocessing import Pool

MAXINT = 100000

def sorter(filenames):
    """ Sorter process sorting files using a counter """

    counter = collections.defaultdict(int)

    for filename in filenames:
for i in open(filename):
counter[i] += 1

return counter

def batch_files(pool_size, limit):
""" Create batches of files to process by a multiprocessing Pool """
batch_size = limit // pool_size

filenames = []

for i in range(pool_size):
batch = []
for j in range(i*batch_size, (i+1)*batch_size):
filename = 'numbers/numbers_%d.txt' % j
batch.append(filename)

filenames.append(batch)

return filenames

def sort_files(pool_size, filenames):
""" Sort files by batches using a multiprocessing Pool """

with Pool(pool_size) as pool:
counters = pool.map(sorter, filenames)
with open('sorted_nums.txt','w') as fp:
for i in range(1, MAXINT+1):
count = sum([x.get(str(i)+'\n',0) for x in counters])
if count>0:
fp.write((str(i)+'\n')*count)
print('Sorted')
if __name__ == "__main__":
limit = int(sys.argv[1])
pool_size = 4
filenames = batch_files(pool_size, limit)
sort_files(pool_size,

这与之前的代码完全相同,只有以下更改:

  1. 将文件名分批处理,每批大小等于池的大小,而不是将所有文件作为单个列表处理。

  2. 我们使用一个排序函数,该函数接受文件名列表,处理它们,并返回一个包含计数的字典。

  3. 对于 1 到 MAXINT 范围内的每个整数,计数都被求和,因此许多数字被写入排序文件。

以下表格显示了不同文件数量的处理数据,分别为池大小为 2 和 4:

文件数量(n) 池大小 排序所需时间
1,000 2 18 秒
4 20 秒
10,000 2 92 秒
4 77 秒
100,000 2 96 秒
4 86 秒
1,000,000 2 350 秒
4 329 秒

这些数字讲述了一个有趣的故事:

  1. 具有 4 个进程的多进程版本整体上比具有 2 个进程和单进程的版本效果更好。

  2. 然而,与单进程版本相比,多进程版本似乎并没有提供太多性能优势。性能数字非常相似,任何改进都在误差和变化范围内。例如,对于 100 万个数字输入,具有 4 个进程的多进程版本仅比单进程版本提高了 8%。

  3. 这是因为瓶颈在于加载文件到内存所需的处理时间,而不是计算(排序)的时间,因为排序只是计数的增量。因此,单进程版本非常高效,因为它能够将所有文件数据加载到相同的地址空间。多进程版本能够通过在多个地址空间中加载文件来稍微改善这一点,但改善并不多。

这个例子表明,在没有太多计算但瓶颈是磁盘或文件 I/O 的情况下,通过多进程进行扩展的影响要小得多。

多线程与多进程

现在我们讨论多进程的内容结束了,现在是比较和对比在 Python 中选择使用单进程中的线程扩展还是使用多个进程的情况的好时机。

以下是一些指导方针。

在以下情况下使用多线程:

  1. 程序需要维护大量共享状态,特别是可变状态。Python 中的许多标准数据结构,如列表、字典等,都是线程安全的,因此使用线程维护可变共享状态的成本要低得多,而不是通过进程。

  2. 程序需要保持低内存占用。

  3. 程序花费大量时间进行 I/O。由于线程执行 I/O 时 GIL 被释放,因此它不会影响线程执行 I/O 所需的时间。

  4. 程序没有太多可以跨多个进程扩展的数据并行操作

在这些情况下使用多进程:

  1. 程序执行大量的 CPU 密集型计算:字节码操作、数值计算等,处理相当大的输入。

  2. 程序有输入,可以并行化成块,然后在之后合并结果——换句话说,程序的输入很适合数据并行计算。

  3. 程序对内存使用没有限制,并且您使用的是具有多核 CPU 和足够大内存的现代计算机。

  4. 在需要同步的进程之间没有太多共享的可变状态-这可能会减慢系统的速度,并抵消多进程带来的任何好处。

  5. 您的程序不太依赖 I/O-文件或磁盘 I/O 或套接字 I/O。

Python 中的并发-异步执行

我们已经看到了使用多个线程和多个进程进行并发执行的两种不同方式。我们看到了使用线程及其同步原语的不同示例。我们还看到了使用多进程的几个示例,结果略有不同。

除了这两种并发编程的方式,另一种常见的技术是异步编程或异步 I/O。

在执行的异步模型中,调度程序从任务队列中选择要执行的任务,并以交错的方式执行这些任务。不能保证任务将按任何特定顺序执行。任务的执行顺序取决于任务愿意向队列中的另一个任务yield多少处理时间。换句话说,异步执行是通过合作式多任务处理来实现的。

异步执行通常发生在单个线程中。这意味着没有真正的数据并行性或真正的并行执行。相反,该模型只提供了一种类似并行的外观。

由于执行是无序的,异步系统需要一种方法将函数执行的结果返回给调用者。这通常通过回调来实现,这些是在结果准备好时要调用的函数,或者使用接收结果的特殊对象,通常称为future

Python 3 通过其asyncio模块使用协程提供了对这种执行的支持。在讨论这个之前,我们将花一些时间了解抢占式多任务处理与合作式多任务处理,以及如何使用生成器在 Python 中实现一个简单的合作式多任务处理调度程序。

抢占式与合作式多任务处理

我们之前使用多个线程编写的程序是并发的示例。然而,我们不必担心操作系统选择何时以及如何运行线程,我们只需要准备好线程(或进程),提供目标函数,并执行它们。调度由操作系统处理。

CPU 时钟的每几个滴答声,操作系统会抢占一个正在运行的线程,并在特定的核心中用另一个线程替换它。这可能是由于不同的原因,但程序员不必担心细节。他只需创建线程,为它们设置它们需要处理的数据,使用正确的同步原语,并启动它们。操作系统会处理剩下的工作,包括切换和调度。

这几乎是所有现代操作系统的工作方式。在所有其他条件相等的情况下,它保证每个线程公平分享执行时间。这被称为抢占式多任务处理

还有一种调度类型,与抢占式多任务处理相反。这被称为合作式多任务处理,操作系统不参与决定竞争线程或进程的优先级和执行。相反,一个进程或线程自愿放弃控制权,让另一个进程或线程运行。或者一个线程可以取代另一个正在空闲(睡眠)或等待 I/O 的线程。

这是使用协程进行并发执行的异步模型中使用的技术。一个函数在等待数据时,比如等待尚未返回的网络调用,可以将控制权让给另一个函数或任务运行。

在讨论使用asyncio的实际协程之前,让我们使用简单的 Python 生成器编写我们自己的协作式多任务调度器。如下所示,这并不是很难做到。

# generator_tasks.py
import random
import time
import collections
import threading

def number_generator(n):
    """ A co-routine that generates numbers in range 1..n """

    for i in range(1, n+1):
        yield i

def square_mapper(numbers):
    """ A co-routine task for converting numbers to squares """

    for n in numbers:
        yield n*n

def prime_filter(numbers):
    """ A co-routine which yields prime numbers """

    primes = []
    for n in numbers:
        if n % 2 == 0: continue
        flag = True
        for i in range(3, int(n**0.5+1), 2):
            if n % i == 0:
                flag = False
                break

        if flag:
            yield n

def scheduler(tasks, runs=10000):
    """ Basic task scheduler for co-routines """

    results = collections.defaultdict(list)

    for i in range(runs):
        for t in tasks:
            print('Switching to task',t.__name__)
            try:
                result = t.__next__()
                print('Result=>',result)
                results[t.__name__].append(result)
            except StopIteration:
                break

    return results

让我们分析前面的代码:

  • 我们有四个函数 - 三个生成器,因为它们使用yield关键字返回数据,以及一个调度器,它运行一定的任务

  • square_mapper函数接受一个迭代器,返回整数并通过它进行迭代,并产生成员的平方

  • prime_filter函数接受一个类似的迭代器,并过滤掉非质数,只产生质数

  • number_generator函数作为这两个函数的输入迭代器,为它们提供整数的输入流

现在让我们看看将所有四个函数联系在一起的调用代码。

    import sys

    tasks = []
    start = time.clock()

    limit = int(sys.argv[1])

    # Append sqare_mapper tasks to list of tasks 
    tasks.append(square_mapper(number_generator(limit)))
    # Append prime_filter tasks to list of tasks
    tasks.append(prime_filter(number_generator(limit))) 

    results = scheduler(tasks, runs=limit)
    print('Last prime=>',results['prime_filter'][-1])
    end = time.clock()
    print('Time taken=>',end-start)

以下是调用代码的分析:

  • 数字生成器初始化为一个计数,通过命令行参数接收。它传递给square_mapper函数。组合函数被添加为tasks列表的一个任务。

  • 对于prime_filter函数也执行类似的操作。

  • 通过将任务列表传递给scheduler方法来运行它,它通过for循环迭代运行每个任务,一个接一个地运行。结果被附加到一个字典中,使用函数的名称作为键,并在执行结束时返回。

  • 我们打印最后一个质数的值来验证正确执行,还有调度器处理所花费的时间。

让我们看看我们的简单协作式多任务调度器在限制为10时的输出。这允许在单个命令窗口中捕获所有输入,如下面的屏幕截图所示:

抢占式与协作式多任务

对于输入为 10 的简单协作式多任务程序示例的输出

让我们分析输出:

  1. square_mapperprime_filter函数的输出在控制台上交替显示。这是因为调度器在for循环中在它们之间切换。每个函数都是协程(生成器),因此它们yield执行 - 即控制从一个函数传递到下一个函数 - 反之亦然。这允许两个函数同时运行,同时保持状态并产生输出。

  2. 由于我们在这里使用了生成器,它们提供了一种自然的方式来生成结果并一次性地让出控制,使用yield关键字。

Python 中的 asyncio 模块

Python 中的asyncio模块支持使用协程编写并发的单线程程序。它仅在 Python 3 中可用。

使用asyncio模块的协程是使用以下方法之一的协程:

  • 使用async def语句来定义函数

  • 使用@asyncio.coroutine表达式进行装饰

基于生成器的协程使用第二种技术,并从表达式中产生。

使用第一种技术创建的协程通常使用await <future>表达式等待未来完成。

协程通过事件循环进行调度执行,它连接对象并将它们安排为任务。为不同的操作系统提供了不同类型的事件循环。

以下代码重新编写了我们之前的一个简单协作式多任务调度器的示例,使用了asyncio模块:

# asyncio_tasks.py
import asyncio

def number_generator(m, n):
    """ A number generator co-routine in range(m...n+1) """
    yield from range(m, n+1)

async prime_filter(m, n):
    """ Prime number co-routine """

    primes = []
    for i in number_generator(m, n):
        if i % 2 == 0: continue
        flag = True

        for j in range(3, int(i**0.5+1), 2):
            if i % j == 0:
                flag = False
                break

        if flag:
print('Prime=>',i)
primes.append(i)

# At this point the co-routine suspends execution
# so that another co-routine can be scheduled
await asyncio.sleep(1.0)
return tuple(primes)

async def square_mapper(m, n):
""" Square mapper co-routine """
squares = []

for i in number_generator(m, n):
print('Square=>',i*i) 
squares.append(i*i)
# At this point the co-routine suspends execution
# so that another co-routine can be scheduled
await asyncio.sleep(1.0)
return squares

def print_result(future):
print('Result=>',future.result())

这是最后一段代码的工作原理:

  1. number_generator函数是一个协程,从子生成器range(m, n+1)中产生,它是一个迭代器。这允许其他协程调用这个协程。

  2. square_mapper函数是使用async def关键字的第一种类型的协程。它使用数字生成器返回一个平方数列表。

  3. prime_filter函数也是相同类型的函数。它也使用数字生成器,将质数附加到列表并返回。

  4. 两个协程通过使用asyncio.sleep函数进入睡眠并等待。这允许两个协程以交错的方式同时工作。

以下是带有event循环和其余管道的调用代码:

loop = asyncio.get_event_loop()
future = asyncio.gather(prime_filter(10, 50), square_mapper(10, 50))
future.add_done_callback(print_result)
loop.run_until_complete(future)

loop.close()

这是程序的输出。请注意每个任务的结果是如何以交错的方式打印出来的。

Python 中的 asyncio 模块

执行计算素数和平方的 asyncio 任务的结果

让我们逐行分析前面的代码是如何工作的,按照自上而下的方式:

  1. 我们首先使用factory函数asyncio.get_event_loop获取一个 asyncio 事件loop。这会返回操作系统的默认事件循环实现。

  2. 我们通过使用模块的gather方法设置了一个 asyncio future对象。这个方法用于聚合作为其参数传递的一组协程或 futures 的结果。我们将prime_filtersquare_mapper都传递给它。

  3. 一个回调被添加到future对象 - print_result函数。一旦 future 的执行完成,它将自动被调用。

  4. 循环运行直到 future 的执行完成。在这一点上,回调被调用并打印结果。请注意输出是交错的 - 每个任务使用 asyncio 模块的sleep函数让步给另一个任务。

  5. 循环被关闭并终止其操作。

等待 future - async 和 await

我们讨论了如何在协程内部使用 await 等待来自 future 的数据。我们看到了一个使用 await 让控制权让给其他协程的示例。现在让我们看一个等待来自网络的 future 的 I/O 完成的示例。

对于这个示例,您需要aiohttp模块,它提供了一个 HTTP 客户端和服务器,可以与 asyncio 模块一起使用,并支持 futures。我们还需要async_timeout模块,它允许在异步协程上设置超时。这两个模块都可以使用 pip 安装。

这是代码 - 这是一个使用超时获取 URL 的协程,并等待 future 的结果的示例:

# async_http.py
import asyncio
import aiohttp
import async_timeout

@asyncio.coroutine
def fetch_page(session, url, timeout=60):
""" Asynchronous URL fetcher """

with async_timeout.timeout(timeout):
response = session.get(url)
return response

以下是带有事件循环的调用代码:

loop = asyncio.get_event_loop()
urls = ('http://www.google.com',
        'http://www.yahoo.com',
        'http://www.facebook.com',
        'http://www.reddit.com',
        'http://www.twitter.com')

session = aiohttp.ClientSession(loop=loop)
tasks = map(lambda x: fetch_page(session, x), urls)
# Wait for tasks
done, pending = loop.run_until_complete(asyncio.wait(tasks, timeout=120))
loop.close()

for future in done:
    response = future.result()
    print(response)
    response.close()
    session.close()

loop.close()

在前面的代码中我们在做什么?

  1. 我们创建一个事件循环和要获取的 URL 列表。我们还创建了aiohttp ClientSession对象的实例,它是获取 URL 的辅助程序。

  2. 我们通过将fetch_page函数映射到每个 URL 来创建一个任务映射。会话对象作为fetch_page函数的第一个参数传递。

  3. 任务被传递给asyncio的等待方法,并设置了120秒的超时时间。

  4. 循环运行直到完成。它返回两组 futures - donepending

  5. 我们遍历完成的 future,并通过使用futureresult方法获取响应并打印它。

您可以在以下截图中看到操作的结果(前几行,因为输出了很多行):

等待 future - async 和 await

进行 5 个 URL 的异步获取程序的输出

正如您所看到的,我们能够以简单的摘要打印响应。那么如何处理响应以获取更多关于它的细节,比如实际的响应文本、内容长度、状态码等呢?

下面的函数解析了一个done futures 列表 - 通过在响应的read方法上使用await等待响应数据。这会异步返回每个响应的数据。

async def parse_response(futures):
""" Parse responses of fetch """
for future in futures:
response = future.result()
data = await response.text()
        print('Response for URL',response.url,'=>', response.status, len(data))
        response.close()

response对象的细节 - 最终的 URL、状态码和数据长度 - 在关闭响应之前,通过这个方法输出每个响应。

我们只需要在完成的响应列表上再添加一个处理步骤,这样就可以工作。

session = aiohttp.ClientSession(loop=loop)
# Wait for futures
tasks = map(lambda x: fetch_page(session, x), urls)
done, pending = loop.run_until_complete(asyncio.wait(tasks, timeout=300))

# One more processing step to parse responses of futures
loop.run_until_complete(parse_response(done))

session.close()
loop.close()

请注意我们如何将协程链接在一起。链中的最后一个链接是parse_response协程,在循环结束之前处理完成的 futures 列表。

以下截图显示了程序的输出:

等待 future - async 和 await

程序异步获取和处理 5 个 URL 的输出

使用asyncio模块可以完成许多复杂的编程。可以等待 futures,取消它们的执行,并从多个线程运行asyncio操作。本章讨论的范围之外。

我们将继续介绍 Python 中执行并发任务的另一个模块,即concurrent.futures模块。

并发 futures - 高级并发处理

concurrent.futures模块提供了使用线程或进程进行高级并发处理的功能,同时使用 future 对象异步返回数据。

它提供了一个执行器接口,主要暴露了两种方法,如下所示:

  • submit:提交一个可调用对象以异步执行,返回代表可调用对象执行的future对象。

  • map:将可调用对象映射到一组可迭代对象,以future对象异步调度执行。但是,该方法直接返回处理结果,而不是返回 futures 列表。

执行器接口有两个具体的实现:ThreadPoolExecutor在线程池中执行可调用对象,而ProcessPoolExecutor在进程池中执行可调用对象。

这是一个异步计算一组整数阶乘的future对象的简单示例:

from concurrent.futures import ThreadPoolExecutor, as_completed
import functools
import operator

def factorial(n):
    return functools.reduce(operator.mul, [i for i in range(1, n+1)])

with ThreadPoolExecutor(max_workers=2) as executor:
    future_map = {executor.submit(factorial, n): n for n in range(10, 21)}
    for future in as_completed(future_map):
        num = future_map[future]
        print('Factorial of',num,'is',future.result())

以下是前面代码的详细解释:

  • factorial函数通过使用functools.reduce和乘法运算符迭代计算给定数字的阶乘。

  • 我们创建了一个具有两个工作线程的执行器,并通过其submit方法将数字(从 10 到 20)提交给它。

  • 通过字典推导式进行提交,返回一个以 future 为键、数字为值的字典

  • 我们通过concurrent.futures模块的as_completed方法迭代已计算完成的 futures。

  • 通过result方法获取 future 的结果并打印结果

当执行时,程序按顺序打印其输出,如下一个截图所示:

并发 futures - 高级并发处理

并发 futures 阶乘程序的输出

磁盘缩略图生成器

在我们之前关于线程的讨论中,我们使用了从 Web 中随机图像生成缩略图的示例,以演示如何使用线程和处理信息。

在这个例子中,我们将做类似的事情。在这里,我们不是从 Web 处理随机图像 URL,而是从磁盘加载图像,并使用concurrent.futures函数将它们转换为缩略图。

我们将重用之前的缩略图创建函数。除此之外,我们将添加并发处理。

首先,这里是导入:

import os
import sys
import mimetypes
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor, as_completed

这是我们熟悉的缩略图创建函数:

def thumbnail_image(filename, size=(64,64), format='.png'):
    """ Convert image thumbnails, given a filename """

    try:
        im=Image.open(filename)         
        im.thumbnail(size, Image.ANTIALIAS)

        basename = os.path.basename(filename)
        thumb_filename = os.path.join('thumbs',
            basename.rsplit('.')[0] + '_thumb.png')
        im.save(thumb_filename)
        print('Saved',thumb_filename)
        return True

    except Exception as e:
        print('Error converting file',filename)
        return False

我们将处理特定文件夹中的图像 - 在这种情况下,是home文件夹的Pictures子目录。为了处理这个,我们需要一个迭代器来产生图像文件名。我们已经使用os.walk函数编写了一个。

def directory_walker(start_dir):
    """ Walk a directory and generate list of valid images """

    for root,dirs,files in os.walk(os.path.expanduser(start_dir)):
        for f in files:
            filename = os.path.join(root,f)
            # Only process if its a type of image
            file_type = mimetypes.guess_type(filename.lower())[0]
            if file_type != None and file_type.startswith('image/'):
                yield filename

正如你所看到的,前面的函数是一个生成器。

以下是主要的调用代码,它设置了一个执行器并在文件夹上运行它:

    root_dir = os.path.expanduser('~/Pictures/')
    if '--process' in sys.argv:
        executor = ProcessPoolExecutor(max_workers=10)
    else:
        executor = ThreadPoolExecutor(max_workers=10)

    with executor:
        future_map = {executor.submit(thumbnail_image, filename): filename for filename in directory_walker(root_dir)}
        for future in as_completed(future_map):
            num = future_map[future]
            status = future.result()
            if status:
                print('Thumbnail of',future_map[future],'saved')

前面的代码使用了相同的技术,异步地向函数提交参数,将结果的 futures 保存在字典中,然后在 futures 完成时处理结果。

要将执行器更改为使用进程,只需将ThreadPoolExecutor替换为ProcessPoolExecutor;代码的其余部分保持不变。我们提供了一个简单的命令行标志--process,以便轻松实现这一点。

这是程序在~/Pictures文件夹上使用线程池和进程池的样本运行输出-在大约相同的时间内生成了大约 2000 张图像。

磁盘缩略图生成器

并发 future 磁盘缩略图程序的输出-使用线程和进程执行器

并发选项-如何选择?

我们讨论了 Python 中的并发技术。我们讨论了线程、进程、异步 I/O 和并发 future。自然而然地,一个问题出现了-何时选择什么?

这个问题已经在选择线程和进程之间得到了解答,决定主要受 GIL 的影响。

在选择并发选项时,以下是一些粗略的指南。

  • 并发 future vs 多处理:并发 future 提供了一种优雅的方式,使用线程或进程池执行并行化任务。因此,如果底层应用程序具有与线程或进程类似的可伸缩性指标,那么它是理想的选择,因为从一个到另一个的切换非常容易,就像我们在之前的例子中看到的那样。当操作的结果不需要立即可用时,也可以选择并发 future。当数据可以被细粒度地并行化,并且操作可以异步执行时,并且操作涉及简单的可调用而不需要复杂的同步技术时,并发 future 是一个不错的选择。

如果并发执行更复杂,并不仅仅基于数据并行性,而是涉及同步、共享内存等方面,则应选择多处理。例如,如果程序需要进程、同步原语和 IPC,则唯一真正扩展的方法是使用多处理模块提供的原语编写并发程序。

同样,当您的多线程逻辑涉及跨多个任务并行化数据时,可以选择使用线程池的并发 future。但是,如果有大量共享状态需要使用复杂的线程同步对象进行管理,则必须使用线程对象,并使用threading模块切换到多个线程以更好地控制状态。

  • 异步 I/O vs 线程并发:当您的程序不需要真正的并发(并行),而更依赖于异步处理和回调时,asyncio是一个不错的选择。当应用程序中涉及大量等待或休眠周期时,例如等待用户输入、等待 I/O 等,需要通过协程让其他任务利用这些等待或休眠时间时,Asyncio 是一个不错的选择。Asyncio 不适用于 CPU 密集型并发处理,或涉及真正数据并行性的任务。

AsyncIO 似乎适用于请求-响应循环,其中发生大量 I/O 操作,因此适用于编写不需要实时数据要求的 Web 应用服务器。

在决定正确的并发包时,您可以使用上述列出的这些要点作为粗略的指南。

并行处理库

除了我们迄今讨论过的标准库模块外,Python 还拥有丰富的第三方库生态系统,支持在对称多处理(SMP)或多核系统中进行并行处理。

我们将看一下几个这样的包,它们有些不同,并且具有一些有趣的特性。

Joblib

joblib是一个提供了对多处理的包装器,用于在循环中并行执行代码。代码被编写为生成器表达式,并使用多处理模块在 CPU 核心上并行执行。

例如,取以下代码,计算前 10 个数字的平方根:

>>> [i ** 0.5 for i in range(1, 11)]
[1.0, 1.4142135623730951, 1.7320508075688772, 2.0, 2.23606797749979, 2.449489742783178, 2.6457513110645907, 2.8284271247461903, 3.0, 3.1622776601683795]

前面的代码可以通过以下方式转换为在两个 CPU 核心上运行:

>>> import math
>>> from joblib import Parallel, delayed
    [1.0, 1.4142135623730951, 1.7320508075688772, 2.0, 2.23606797749979, 2.449489742783178, 2.6457513110645907, 2.8284271247461903, 3.0, 3.1622776601683795]

这里是另一个例子:这是我们之前编写的用于使用多处理重写为使用joblib包的素性检查器:

# prime_joblib.py
from joblib import Parallel, delayed

def is_prime(n):
    """ Check for input number primality """

    for i in range(3, int(n**0.5+1), 2):
        if n % i == 0:
            print(n,'is not prime')
            return False

    print(n,'is prime')     
    return True

if __name__ == "__main__":
    numbers = [1297337, 1116281, 104395303, 472882027, 533000389, 817504243, 982451653, 112272535095293, 115280095190773, 1099726899285419]*100
    Parallel(n_jobs=10)(delayed(is_prime)(i) for i in numbers)

如果你执行并计时前面的代码,你会发现性能指标与使用多处理版本的性能非常相似。

PyMP

OpenMP是一个开放的 API,支持 C/C++和 Fortran 中的共享内存多处理。它使用特殊的工作共享结构,比如指示如何在线程或进程之间分割工作的编译器特殊指令(称为 pragma)。

例如,以下 C 代码使用OpenMP API 指示应该使用多个线程并行初始化数组:

int parallel(int argc, char **argv)
{
    int array[100000];

    #pragma omp parallel for
    for (int i = 0; i < 100000; i++) {
array[i] = i * i;
	}

return 0;
}

PyMP受到OpenMP背后的想法的启发,但使用fork系统调用来并行化在表达式中执行的代码,比如在循环中。为此,PyMP还提供了对共享数据结构(如列表和字典)的支持,并为numpy数组提供了一个包装器。

我们将看一个有趣而奇异的例子——分形图——来说明PyMP如何用于并行化代码并获得性能改进。

注意

注意:PyMP 的 PyPI 软件包名为 pymp-pypi,因此在尝试使用 pip 安装时,请确保使用此名称。还要注意,它在拉取其依赖项(如 numpy)方面做得不好,因此这些必须单独安装。

分形图——Mandelbrot 集

以下是一个非常受欢迎的复数类的代码列表,当绘制时,会产生非常有趣的分形几何图形:即Mandelbrot 集

# mandelbrot.py
import sys
import argparse
from PIL import Image

def mandelbrot_calc_row(y, w, h, image, max_iteration = 1000):
    """ Calculate one row of the Mandelbrot set with size wxh """

    y0 = y * (2/float(h)) - 1 # rescale to -1 to 1

    for x in range(w):
        x0 = x * (3.5/float(w)) - 2.5 # rescale to -2.5 to 1

        i, z = 0, 0 + 0j
        c = complex(x0, y0)
        while abs(z) < 2 and i < max_iteration:
            z = z**2 + c
            i += 1

        # Color scheme is that of Julia sets
        color = (i % 8 * 32, i % 16 * 16, i % 32 * 8)
        image.putpixel((x, y), color)

def mandelbrot_calc_set(w, h, max_iteration=10000, output='mandelbrot.png'):
    """ Calculate a mandelbrot set given the width, height and
    maximum number of iterations """

    image = Image.new("RGB", (w, h))

    for y in range(h):
        mandelbrot_calc_row(y, w, h, image, max_iteration)

    image.save(output, "PNG")

if __name__ == "__main__":
    parser = argparse.ArgumentParser(prog='mandelbrot', description='Mandelbrot fractal generator')
    parser.add_argument('-W','--width',help='Width of the image',type=int, default=640)
    parser.add_argument('-H','--height',help='Height of the image',type=int, default=480) 
    parser.add_argument('-n','--niter',help='Number of iterations',type=int, default=1000)
    parser.add_argument('-o','--output',help='Name of output image file',default='mandelbrot.png')

    args = parser.parse_args()
    print('Creating Mandelbrot set with size %(width)sx%(height)s, #iterations=%(niter)s' % args.__dict__)
    mandelbrot_calc_set(args.width, args.height, max_iteration=args.niter, output=args.output)  

前面的代码使用一定数量的c和可变的几何形状(宽 x 高)计算了 Mandelbrot 集。它完整地解析了参数,以产生不同几何形状的分形图像,并支持不同的迭代次数。

注意

为了简单起见,以及为了产生比 Mandelbrot 通常做的更美丽的图片,代码做了一些自由,并使用了一个相关分形类的颜色方案,即 Julia 集。

它是如何工作的?这里是代码的解释。

  1. mandelbrot_calc_row函数计算了 Mandelbrot 集的一行,对于特定的y坐标值和最大迭代次数。计算了整行的像素颜色值,从x坐标的0到宽度w。像素值被放入传递给这个函数的Image对象中。

  2. mandelbrot_calc_set函数调用mandelbrot_calc_row函数,对从0到图像高度hy坐标的所有值进行计算。为给定的几何形状(宽 x 高)创建了一个Image对象(通过Pillow 库),并填充了像素值。最后,我们将这个图像保存到文件中,我们得到了我们的分形图!

不多说了,让我们看看代码的运行情况。

这是我们的 Mandelbrot 程序为默认迭代次数 1000 生成的图像。

分形图——Mandelbrot 集

1000 次迭代的 Mandelbrot 集分形图像

这是创建这个图像所需的时间。

分形图——Mandelbrot 集

单进程 Mandelbrot 程序的时间——1000 次迭代

然而,如果增加迭代次数,单进程版本的速度会慢下来很多。当我们将迭代次数增加 10 倍时,即 10000 次迭代时,输出如下:

分形图——Mandelbrot 集

单进程 Mandelbrot 程序的时间——10000 次迭代

如果我们看一下代码,我们会发现mandelbrot_calc_set函数中有一个外部的 for 循环,它启动了一切。它为图像的每一行调用mandelbrot_calc_row,范围从0到函数的高度,由y坐标变化。

由于每次调用mandelbrot_calc_row函数计算图像的一行,它自然适用于数据并行问题,并且可以相当容易地并行化。

在下一节中,我们将看到如何使用 PyMP 来实现这一点。

分形图 - 缩放曼德勃罗集实现

我们将使用PyMP来并行化前一个简单实现曼德勃罗集的外部 for 循环,以利用解决方案中固有的数据并行性。

以下是曼德勃罗程序的两个函数的PyMP版本。其余代码保持不变。

# mandelbrot_mp.py
import sys
from PIL import Image
import pymp
import argparse

def mandelbrot_calc_row(y, w, h, image_rows, max_iteration = 1000):
    """ Calculate one row of the mandelbrot set with size wxh """

    y0 = y * (2/float(h)) - 1 # rescale to -1 to 1

    for x in range(w):
        x0 = x * (3.5/float(w)) - 2.5 # rescale to -2.5 to 1

        i, z = 0, 0 + 0j
        c = complex(x0, y0)
        while abs(z) < 2 and i < max_iteration:
            z = z**2 + c
            i += 1

        color = (i % 8 * 32, i % 16 * 16, i % 32 * 8)
        image_rows[y*w + x] = color

def mandelbrot_calc_set(w, h, max_iteration=10000, output='mandelbrot_mp.png'):
    """ Calculate a mandelbrot set given the width, height and
    maximum number of iterations """

    image = Image.new("RGB", (w, h))
    image_rows = pymp.shared.dict()

    with pymp.Parallel(4) as p:
        for y in p.range(0, h):
            mandelbrot_calc_row(y, w, h, image_rows, max_iteration)

    for i in range(w*h):
        x,y = i % w, i // w
        image.putpixel((x,y), image_rows[i])

    image.save(output, "PNG")
    print('Saved to',output)

重写主要涉及将代码转换为逐行构建曼德勃罗图像的代码,每行数据都是单独计算的,并且以可以并行计算的方式进行计算 - 在一个单独的进程中。

  • 在单进程版本中,我们直接在mandelbrot_calc_row函数中将像素值放入图像中。然而,由于新代码在并行进程中执行此函数,我们不能直接修改其中的图像数据。相反,新代码将一个共享字典传递给函数,并且它使用位置作为key,像素 RGB 值作为value来设置像素颜色值。

  • 因此,在mandelbrot_calc_set函数中添加了一个新的共享数据结构 - 共享字典,最终对其进行迭代,并在Image对象中填充像素数据,然后保存到最终输出中。

  • 我们使用了四个PyMP并行进程,因为该机器有四个 CPU 核心,使用了一个上下文和将外部 for 循环封装在其中。这使得代码在四个核心中并行执行,每个核心计算大约 25%的行。最终的数据写入主进程中的图像。

以下是代码的PyMP版本的结果时间:

分形图 - 缩放曼德勃罗集实现

使用 PyMP 进行 10000 次迭代的并行进程曼德勃罗程序的时间

该程序在实时方面快了大约 33%。在 CPU 使用方面,您可以看到PyMP版本的用户 CPU 时间与实际 CPU 时间的比率更高,表明进程对 CPU 的使用比单进程版本更高。

注意

注意:我们可以通过避免使用共享数据结构 image_rows 来编写程序的更高效版本,该数据结构用于保存图像的像素值。然而,这个版本使用了 PyMP 的特性来展示。本书的代码存档中包含程序的另外两个版本,一个使用了多进程,另一个使用了 PyMP 但没有共享字典。

这是程序运行产生的分形图像输出:

分形图 - 缩放曼德勃罗集实现

使用 PyMP 进行 10000 次迭代的曼德勃罗集分形图像

您可以观察到颜色不同,这张图片由于迭代次数增加,提供了更多的细节和更精细的结构。

网络扩展

到目前为止,我们讨论的所有可扩展性和并发技术都涉及在单个服务器或机器的范围内进行可扩展性 - 换句话说,扩展。在现实世界中,应用程序也通过扩展其计算到多台机器上来进行扩展。这是大多数现实世界的 Web 应用程序目前的运行和扩展方式。

我们将研究一些技术,包括在通信/工作流程方面扩展应用程序、扩展计算和使用不同协议进行水平扩展。

扩展工作流程 - 消息队列和任务队列

可扩展性的一个重要方面是减少系统之间的耦合。当两个系统紧密耦合时,它们会阻止彼此在一定限制之上进行扩展。

例如,串行编写的代码,其中数据和计算绑定到同一个函数中,阻止程序利用多个 CPU 核心等现有资源。当同一个程序被重写以使用多个线程(或进程)和消息传递系统,如队列之间,我们发现它可以很好地扩展到多个 CPU。在我们的并发讨论中,我们已经看到了很多这样的例子。

同样,通过 Web 进行系统扩展时,解耦会更好。经典的例子是 Web 本身的客户端/服务器架构,客户端通过 HTTP 等众所周知的 RestFUL 协议与世界各地的服务器进行交互。

消息队列是允许应用程序以解耦方式相互通信的系统,通过向彼此发送消息。这些应用程序通常在连接到互联网的不同机器或服务器上运行,并通过排队协议进行通信。

可以将消息队列看作是多线程同步队列的放大版本,不同机器上的应用程序取代了线程,共享的分布式队列取代了简单的进程内队列。

消息队列携带称为消息的数据包,这些数据包从发送应用程序传递到接收应用程序。大多数消息队列提供存储和转发语义,即消息存储在队列中,直到接收者可以处理消息为止。

这是一个简单的消息队列的示意模型:

扩展工作流程-消息队列和任务队列

分布式消息队列的示意模型

最流行和标准化的消息队列或消息导向中间件MoM)的实现是高级消息队列协议AMQP)。AMQP 提供了排队、路由、可靠传递和安全等功能。AMQP 的起源在金融行业,可靠和安全的消息传递语义至关重要。

AMQP(版本 1.0)的最流行的实现是 Apache Active MQ、RabbitMQ 和 Apache Qpid。

RabbitMQ 是用 Erlang 编写的 MoM。它提供了许多语言的库,包括 Python。在 RabbitMQ 中,消息总是通过交换机通过路由键传递,这些键指示消息应传递到的队列。

我们将不再在本节讨论 RabbitMQ,而是转向一个相关但略有不同的中间件,即 Celery。

Celery – 一个分布式任务队列

Celery 是一个用 Python 编写的分布式任务队列,使用分布式消息进行工作。Celery 中的每个执行单元称为任务。任务可以使用称为工作者的进程在一个或多个服务器上并发执行。默认情况下,Celery 使用multiprocessing来实现这一点,但也可以使用其他后端,例如 gevent。

任务可以同步或异步执行,结果可以在将来像对象一样可用。此外,任务结果可以存储在后端存储中,如 Redis、数据库或文件中。

Celery 与消息队列的不同之处在于,Celery 的基本单元是可执行任务-在 Python 中可调用-而不仅仅是一条消息。

然而,Celery 可以与消息队列一起工作。事实上,Celery 传递消息的默认代理是 RabbitMQ,这是 AMQP 的流行实现。Celery 也可以使用 Redis 作为代理后端。

由于 Celery 接受一个任务,并在多个工作进程上扩展它;在多台服务器上,它适用于涉及数据并行性以及计算扩展的问题。Celery 可以接受来自队列的消息,并将其作为任务分发到多台机器上,以实现分布式电子邮件传递系统,例如,实现水平扩展。或者,它可以接受单个函数,并通过将数据分割到多个进程中实现并行数据计算,实现并行数据处理。

在下面的示例中,我们将把我们的 Mandelbrot 分形程序重写为与 Celery 一起工作。我们将尝试通过在多个 celery 工作进程中计算 Mandelbrot 集的行来扩展程序,类似于我们在PyMP中所做的方式。

使用 Celery 的 Mandelbrot 集

为了实现一个利用 Celery 的程序,它需要被实现为一个任务。这并不像听起来那么困难。大多数情况下,它只涉及准备一个 celery 应用程序的实例,选择一个经纪后端,并使用特殊的装饰器@app.task装饰我们想要并行化的可调用对象-其中app是 Celery 的一个实例。

我们将逐步查看此程序清单,因为它涉及一些新内容。本次会话的软件要求如下:

  • Celery

  • AMQP 后端;首选 RabbitMQ

  • Redis 作为结果存储后端

首先,我们将提供 Mandelbrot 任务模块的清单:

# mandelbrot_tasks.py
from celery import Celery

app = Celery('tasks', broker='pyamqp://guest@localhost//',
             backend='redis://localhost')

@app.task
def mandelbrot_calc_row(y, w, h, max_iteration = 1000):
    """ Calculate one row of the mandelbrot set with size w x h """

    y0 = y * (2/float(h)) - 1 # rescale to -1 to 1

    image_rows = {}
    for x in range(w):
        x0 = x * (3.5/float(w)) - 2.5 # rescale to -2.5 to 1

        i, z = 0, 0 + 0j
        c = complex(x0, y0)
        while abs(z) < 2 and i < max_iteration:
            z = z**2 + c
            i += 1

        color = (i % 8 * 32, i % 16 * 16, i % 32 * 8)
        image_rows[y*w + x] = color

    return image_rows

让我们分析一下前面的代码:

  • 我们首先导入了 celery 所需的导入。这需要从celery模块中导入Celery类。

  • 我们准备了一个Celery类的实例作为 celery 应用程序,使用 AMQP 作为消息代理和 Redis 作为结果后端。AMQP 配置将使用系统上可用的任何 AMQP MoM(在本例中是 RabbitMQ)。

  • 我们有一个修改过的mandelbrot_calc_row版本。在PyMP版本中,image_rows字典作为参数传递给函数。在这里,函数在本地计算并返回一个值。我们将在接收端使用此返回值来创建我们的图像。

  • 我们使用@app.task装饰函数,其中 app 是Celery实例。这使得它可以被 celery 工作进程执行为 celery 任务。

接下来是主程序,它调用一系列y输入值的任务并创建图像:

# celery_mandelbrot.py
import argparse
from celery import group
from PIL import Image
from mandelbrot_tasks import mandelbrot_calc_row

def mandelbrot_main(w, h, max_iterations=1000, 
output='mandelbrot_celery.png'):
    """ Main function for mandelbrot program with celery """

    # Create a job – a group of tasks
    job = group([mandelbrot_calc_row.s(y, w, h, max_iterations) for y in range(h)])
    # Call it asynchronously
    result = job.apply_async()

    image = Image.new('RGB', (w, h))

    for image_rows in result.join():
        for k,v in image_rows.items():
            k = int(k)
            v = tuple(map(int, v))
            x,y = k % args.width, k // args.width
            image.putpixel((x,y), v)

    image.save(output, 'PNG')
    print('Saved to',output)

参数解析器是相同的,因此这里不再重复。

代码的最后一部分介绍了一些新概念,因此需要一些解释。让我们详细分析一下代码:

  1. mandelbrot_main函数与先前的mandelbrot_calc_set函数在其参数上是相似的。

  2. 此函数设置了一组任务,每个任务在给定的y输入上执行mandelbrot_calc_row,覆盖从0到图像高度的整个y输入范围。它使用 celery 的group对象来执行此操作。组是一组可以一起执行的任务。

  3. 通过在组上调用apply_async函数来执行任务。这将在多个工作进程中异步执行任务。我们得到一个异步的result对象作为返回值-任务尚未完成。

  4. 然后,我们通过在其上调用join等待此结果对象,返回结果-图像的行作为字典,来自mandelbrot_calc_row任务的单次执行。我们遍历这个,对值进行整数转换,因为 celery 返回数据为字符串,并将像素值放入图像中。

  5. 最后,图像保存在输出文件中。

那么,Celery 如何执行任务呢?这需要 celery 程序运行,处理具有一定数量工作进程的任务模块。在这种情况下,这是我们启动它的方式:

使用 Celery 的 Mandelbrot 集

Celery 控制台-使用 Mandelbrot 任务作为目标启动工作人员

该命令使用从模块mandelbrot_tasks.py加载的任务启动 celery,并使用一组 4 个工作进程。由于机器有 4 个 CPU 核心,我们选择了这个并发性。

注意

请注意,如果没有特别配置,Celery 将自动将工作进程默认为核心数。

程序在 15 秒内运行,比单进程版本和PyMP版本快了一倍。

如果您观察 celery 控制台,您会发现有很多消息被回显,因为我们将 celery 配置为INFO日志级别。所有这些都是包含有关任务及其结果的信息消息:

下面的截图显示了对10000次迭代的运行结果。这个性能比之前的PyMP版本稍好一些,大约快了 20 秒:

使用 Celery 绘制 Mandelbrot 集

Mandelbrot 程序的 Celery 版本,迭代 10000 次。

Celery 在许多组织的生产系统中使用。它为一些更受欢迎的 Python Web 应用程序框架提供了插件。例如,celery 支持 Django,具有一些基本的管道和配置。还有一些扩展模块,如django-celery-results,允许程序员使用 Django ORM 作为 celery 结果后端。

本章和本书的范围不包括详细讨论这个问题,因此建议读者参考 celery 项目网站上提供的文档。

使用 Python 在 Web 上提供 WSGI 服务

Web 服务器网关接口WSGI)是 Python Web 应用程序框架和 Web 服务器之间标准接口的规范。

在 Python Web 应用程序的早期,存在将 Web 应用程序框架连接到 Web 服务器的问题,因为没有共同的标准。Python Web 应用程序被设计为与 CGI、FastCGI 或mod_python(Apache)的现有标准之一配合使用。这意味着为一个 Web 服务器编写的应用程序可能无法在另一个 Web 服务器上运行。换句话说,统一应用程序和 Web 服务器之间的互操作性是缺失的。

WSGI 通过规定一个简单但统一的接口来解决了这个问题,允许可移植的 Web 应用程序开发。

WSGI 规定了两个方面:服务器(或网关)方面和应用程序或框架方面。WSGI 请求的处理如下:

  • 服务器端执行应用程序,为其提供环境和回调函数

  • 应用程序处理请求,并使用提供的回调函数将响应返回给服务器

以下是一个示意图,显示了 Web 服务器和 Web 应用程序使用 WSGI 的交互:

使用 Python 在 Web 上提供 WSGI 服务

显示 WSGI 协议交互的示意图

以下是与 WSGI 应用程序或框架兼容的最简单的函数:

def simple_app(environ, start_response):
    """Simplest possible application object"""

    status = '200 OK'
    response_headers = [('Content-type', 'text/plain')]
    start_response(status, response_headers)
    return ['Hello world!\n']

上述函数可以解释如下:

  1. environ变量是一个字典,包含从服务器传递到应用程序的环境变量,由公共网关接口CGI)规范定义。WSGI 在其规范中强制要求其中的一些环境变量。

  2. start_response是一个可调用的回调函数,由服务器端提供给应用程序端,用于在服务器端启动响应处理。它必须接受两个位置参数。第一个参数应该是一个带有整数状态码的状态字符串,第二个参数是一个描述 HTTP 响应头的(header_nameheader_value)元组列表。

注意

有关更多详细信息,读者可以参考 Python 语言网站上发布的 WSGI 规范 v1.0.1,即 PEP 3333。

注意

Python Enhancement Proposal (PEP) 是一个 Web 上的设计文档,描述了 Python 的新功能或功能建议,或者向 Python 社区提供有关现有功能的信息。Python 社区使用 PEPs 作为描述、讨论和采纳 Python 编程语言及其标准库的新功能和增强的标准流程。

WSGI 中间件组件是实现规范两端的软件,因此提供以下功能:

  • 从服务器到应用程序的多个请求的负载均衡

  • 远程处理请求,通过在网络上传递请求和响应

  • 在同一进程中多租户或共托管多个服务器和/或应用程序

  • 基于 URL 的请求路由到不同的应用程序对象

中间件位于服务器和应用程序之间。它将请求从服务器转发到应用程序,将应用程序的响应转发到服务器。

架构师可以选择多种 WSGI 中间件。我们将简要介绍两种最流行的中间件,即 uWSGI 和 Gunicorn。

uWSGI – WSGI 中间件的超级版本

uWSGI 是一个旨在构建托管服务的完整堆栈的开源项目和应用程序。uWSGI 项目的 WSGI 源于该项目中开发的 Python 的 WSGI 接口插件是第一个。

除了 WSGI,uWSGI 项目还支持 Perl Webserver Gateway Interface(PSGI)用于 Perl Web 应用程序,以及 Rack Web 服务器接口用于 Ruby Web 应用程序。它还提供网关、负载均衡器和请求和响应的路由器。uWSGI 的 Emperor 插件提供了对生产系统中多个 uWSGI 部署的管理和监控。

uWSGI 的组件可以以预分叉、线程、异步或绿色线程/协程模式运行。

uWSGI 还配备了一个快速的内存缓存框架,允许 Web 应用程序的响应存储在 uWSGI 服务器上的多个缓存中。缓存也可以备份到文件等持久存储。除了其他功能外,uWSGI 还支持基于 Python 的 virtualenv 部署。

uWSGI 还提供了一个本地协议,被 uWSGI 服务器使用。uWSGI 1.9 版本还增加了对 Web 套接字的本地支持。

以下是 uWSGI 配置文件的典型示例:

[uwsgi]

# the base directory (full path)
chdir           = /home/user/my-django-app/
# Django's wsgi file
module          = app.wsgi
# the virtualenv (full path)
home            = /home/user/django-virtualenv/
# process-related settings
master          = true
# maximum number of worker processes
processes       = 10
# the socket 
socket          = /home/user/my-django-app/myapp.sock
# clear environment on exit
vacuum          = true

uWSGI 的典型部署架构如下图所示。在这种情况下,Web 服务器是 Nginx,Web 应用程序框架是 Django。uWSGI 以反向代理配置与 Nginx 部署,转发请求和响应之间的 Nginx 和 Django:

uWSGI – WSGI 中间件的超级版本

Nginx 和 Django 的 uWSGI 部署

注意

Nginx Web 服务器自 0.8.40 版本以来支持 uWSGI 协议的本地实现。Apache 中也有一个名为mod_proxy_uwsgi的 uWSGI 代理模块支持。

uWSGI 是 Python Web 应用程序生产部署的理想选择,其中需要在高性能和功能之间取得良好的平衡。它是 WSGI Web 应用程序部署的瑞士军刀组件。

Gunicorn – WSGI 的独角兽

Gunicorn 项目是另一个流行的 WSGI 中间件实现,是开源的。它使用预分叉模型,并且是从 Ruby 的 unicorn 项目移植过来的。Gunicorn 有不同的工作类型,如 uWSGI 支持请求的同步和异步处理。异步工作进程使用构建在 gevent 之上的Greenlet库。

Gunicorn 中有一个主进程,运行一个事件循环,处理和响应各种信号。主进程管理工作进程,工作进程处理请求并发送响应。

Gunicorn 与 uWSGI

在选择是使用 Gunicorn 还是 uWSGI 进行 Python Web 应用程序部署时,有一些指导原则:

  • 对于不需要大量定制的简单应用程序部署,Gunicorn 是一个不错的选择。与 Gunicorn 相比,uWSGI 的学习曲线更陡,需要一段时间才能适应。Gunicorn 的默认设置对大多数部署都非常有效。

  • 如果您的部署是同质的 Python,那么 Gunicorn 是一个不错的选择。另一方面,uWSGI 允许您执行异构部署,因为它支持其他堆栈,如 PSGI 和 Rack。

  • 如果您希望使用更全面的 WSGI 中间件,并且具有很高的可定制性,那么 uWSGI 是一个安全的选择。例如,uWSGI 使基于 Python 的虚拟环境部署变得简单,而 Gunicorn 并不原生支持虚拟环境;相反,Gunicorn 本身必须部署在虚拟环境中。

  • 由于 Nginx 本身原生支持 uWSGI,因此它在生产系统上非常常见。因此,如果您使用 Nginx,并且希望具有全功能且高度可定制的 WSGI 中间件与缓存,uWSGI 是默认选择。

  • 在性能方面,根据 Web 上发布的不同基准测试,Gunicorn 和 uWSGI 在不同基准测试中得分相似。

可扩展性架构

正如讨论的那样,系统可以垂直扩展,也可以水平扩展,或者两者兼而有之。在本节中,我们将简要介绍一些架构,架构师在将系统部署到生产环境中时可以选择,以利用可扩展性选项。

垂直可扩展性架构

垂直可扩展性技术有以下两种类型:

  • 向现有系统添加更多资源:这可能意味着向物理或虚拟机器添加更多 RAM,向虚拟机器或 VPS 添加更多 vCPU 等。然而,这些选项都不是动态的,因为它们需要停止、重新配置和重新启动实例。

  • 更好地利用系统中现有资源:我们在本章中花了很多时间讨论这种方法。这是当应用程序被重写以更有效地利用现有资源,如多个 CPU 核心,通过并发技术如线程、多进程和/或异步处理。这种方法可以动态扩展,因为系统中没有添加新资源,因此不需要停止/启动。

水平可扩展性架构

水平可扩展性涉及一系列技术,架构师可以添加到其工具箱中,并进行选择。它们包括下面列出的技术:

  • 主动冗余:这是扩展的最简单技术,涉及向系统添加多个同类处理节点,通常由负载均衡器前置。这是扩展 Web 应用程序服务器部署的常见做法。多个节点确保即使其中一个或几个系统失败,其余系统仍然继续进行请求处理,确保应用程序没有停机时间。

在冗余系统中,所有节点都处于活动状态,尽管在特定时间只有一个或几个节点可能会响应请求。

  • 热备份:热备份(热备用)是一种技术,用于切换到一个准备好为请求提供服务的系统,但在主系统宕机之前并不活动。热备用在许多方面与正在提供应用程序服务的主节点(节点)完全相似。在发生关键故障时,负载均衡器被配置为切换到热备用系统。

热备用本身可能是一组冗余节点,而不仅仅是单个节点。将冗余系统与热备用结合起来,确保最大的可靠性和故障转移。

注意

热备份的变种是软件备份,它提供了一种模式,可以在极端负载下将系统切换到最低的服务质量QoS),而不是提供完整的功能。例如,一个 Web 应用程序在高负载下切换到只读模式,为大多数用户提供服务,但不允许写入。

  • 读取副本: 依赖于数据库上读取密集型操作的系统的响应可以通过添加数据库的读取副本来改善。读取副本本质上是提供热备份(在线备份)的数据库节点,它们不断地与主数据库节点同步。在某一时间点上,读取副本可能与主数据库节点不完全一致,但它们提供具有 SLA 保证的最终一致性。

云服务提供商如亚马逊提供了 RDS 数据库服务,并提供了读取副本的选择。这些副本可以在地理位置更接近活跃用户的地方分布,以确保更少的响应时间和故障转移,以防主节点崩溃或无响应。

读取副本基本上为您的系统提供了一种数据冗余。

  • 蓝绿部署: 这是一种技术,其中两个单独的系统(在文献中标记为蓝色绿色)并行运行。在任何给定的时刻,只有一个系统是活动的并提供服务。例如,蓝色是活动的,绿色是空闲的

在准备新部署时,它是在空闲系统上完成的。一旦系统准备就绪,负载均衡器就会切换到空闲系统(绿色),远离活动系统(蓝色)。此时,绿色是活动的,蓝色是空闲的。在下一次切换时,位置会再次颠倒。

如果正确执行,蓝绿部署可以确保生产应用的零到最小停机时间。

  • 故障监控和/或重启: 故障监视器是一种检测部署的关键组件(软件或硬件)故障的系统,它会通知您,并/或采取措施减轻停机时间。

例如,您可以在服务器上安装一个监控应用程序,当关键组件(例如 celery 或 rabbitmq 服务器)崩溃时,它会发送电子邮件给 DevOps 联系人,并尝试重新启动守护程序。

心跳监控是另一种技术,其中软件主动向监控软件或硬件发送 ping 或心跳,这可以在同一台机器或另一台服务器上。如果在一定时间间隔后未能发送心跳,监视器将检测系统的停机,并通知和/或尝试重新启动组件。

Nagios 是一个常见的生产监控服务器的例子,通常部署在一个单独的环境中,并监控您的部署服务器。其他系统切换监视器和重启组件的例子包括MonitSupervisord

除了这些技术之外,在执行系统部署时应遵循以下最佳实践,以确保可伸缩性、可用性和冗余/故障转移:

  • 缓存: 在系统中尽可能多地使用缓存,如果可能的话,使用分布式缓存。缓存可以有各种类型。最简单的缓存是在应用服务提供商的内容传送网络CDN)上缓存静态资源。这样的缓存可以确保资源在用户附近地理分布,从而减少响应时间和页面加载时间。

第二种缓存是应用程序的缓存,它缓存响应和数据库查询结果。Memcached 和 Redis 通常用于这些场景,并且它们提供分布式部署,通常是主/从模式。应该使用这样的缓存来加载和缓存应用程序中最常请求的内容,并设置适当的过期时间,以确保数据不会太陈旧。

有效且设计良好的缓存可以最小化系统负载,并避免多次冗余操作,这些操作可能会人为增加系统负载并降低性能:

  • 解耦:尽可能解耦组件,以充分利用网络的共享地理位置。例如,消息队列可用于解耦应用程序中需要发布和订阅数据的组件,而不是使用本地数据库或同一台机器上的套接字。解耦时,您自动引入了冗余和数据备份到系统中,因为您为解耦添加的新组件(消息队列、任务队列和分布式缓存)通常都带有自己的有状态存储和集群。

解耦的额外复杂性在于额外系统的配置。然而,在当今时代,大多数系统都能够执行自动配置或提供简单的基于 Web 的配置,这不是问题。

您可以参考文献,了解提供有效解耦的应用架构,例如观察者模式、中介者和其他中间件:

  • 优雅降级:与无法回答请求并提供超时相比,为系统提供优雅降级行为更为重要。例如,当写入密集型 Web 应用程序发现数据库节点未响应时,可以在负载过重时切换到只读模式。另一个例子是,当提供大量依赖 JS 的动态网页的系统在服务器负载过重时,可以在 JS 中间件响应不佳时切换到类似的静态页面。

优雅降级可以在应用程序本身或负载均衡器上进行配置,也可以两者兼而有之。为应用程序本身提供优雅降级行为,并配置负载均衡器在负载过重时切换到该路由是一个好主意。

  • 将数据靠近代码:性能强大的软件的黄金法则是将数据提供给计算所在的地方。例如,如果您的应用程序每次请求都要从远程数据库加载数据进行 50 次 SQL 查询,那么您的做法就不正确。

将数据靠近计算可以减少数据访问和传输时间,从而减少处理时间,降低应用程序的延迟,并使其更具可扩展性。

有不同的技术可以实现这一点:如前所述,缓存是一种受欢迎的技术。另一种技术是将数据库分为本地和远程两部分,其中大部分读取操作都来自本地读取副本,而写入(可能需要时间)则发生在远程写入主机上。需要注意的是,在这种情况下,本地可能并不意味着同一台机器,但通常指的是同一数据中心,如果可能的话,共享同一子网。

此外,常见配置可以从磁盘数据库(如 SQLite)或本地 JSON 文件中加载,从而减少准备应用实例所需的时间。

另一种技术是不在应用程序层或前端存储任何事务状态,而是将状态移至计算所在的后端。由于这使得所有应用服务器节点在中间状态上都是相等的,因此可以使用负载均衡器对其进行前置,并提供一个相等的冗余集群,其中任何一个都可以处理特定请求。

  • 根据 SLA 设计:对于架构师来说,了解应用程序向用户提供的保证,并相应地设计部署架构非常重要。

CAP 定理确保在分布式系统中发生网络分区故障时,系统只能在特定时间内保证一致性或可用性。这将分布式系统分为两种常见类型,即 CP 和 AP 系统。

当今世界上大多数网络应用都是 AP。它们确保可用性,但数据只是最终一致,这意味着它们会向用户提供过时的数据,例如在网络分区中的一个系统(比如主数据库节点)发生故障时。

另一方面,许多企业,如银行、金融和医疗保健,需要确保即使存在网络分区故障,数据也是一致的。这些是 CP 系统。这些系统中的数据不应该过时,因此,在可用性和一致性数据之间做出选择时,它们会选择后者。

软件组件的选择、应用架构和最终部署架构受到这些约束的影响。例如,AP 系统可以使用保证最终一致行为的 NoSQL 数据库。它可以更好地利用缓存。另一方面,CP 系统可能需要关系数据库系统RDBMs)提供的 ACID 保证。

总结

在本章中,我们重复使用了您在上一章关于性能的许多想法和概念。

我们从可扩展性的定义开始,并研究了它与并发、延迟和性能等其他方面的关系。我们简要比较和对比了并发及其近亲并行性。

然后,我们继续讨论了 Python 中各种并发技术,包括详细的示例和性能比较。我们以来自网络的随机 URL 的缩略图生成器为例,来说明使用 Python 中的多线程实现并发的各种技术。您还学习并看到了生产者/消费者模式的示例,并使用了一些示例来学习如何使用同步原语来实现资源约束和限制。

接下来,我们讨论了如何使用多进程来扩展应用程序,并看到了使用multiprocessing模块的一些示例,比如一个素数检查器,它向我们展示了GIL对 Python 中多线程的影响,以及一个磁盘文件排序程序,它展示了在处理大量磁盘 I/O 时,多进程在扩展程序方面的限制。

我们将异步处理作为并发的下一个技术。我们看到了基于生成器的协作式多任务调度程序,以及使用asyncio的对应部分。我们看了一些使用 asyncio 的示例,并学习了如何使用 aiohttp 模块异步执行 URL 获取。并发处理部分比较和对比了并发未来与 Python 中其他并发选项,同时勾勒了一些示例。

我们以 Mandelbrot 分形作为示例,展示了如何实现数据并行程序,并展示了使用PyMP来在多个进程和多个核心上扩展 mandelbrot 分形程序的示例。

接下来,我们讨论了如何在网络上扩展您的程序。我们简要讨论了消息队列和任务队列的理论方面。我们看了一下 celery,Python 任务队列库,并重新编写了 Mandelbrot 程序,使用 celery 工作者进行扩展,并进行了性能比较。

WSGI,Python 在 Web 服务器上提供 Web 应用程序的方式,是接下来讨论的话题。我们讨论了 WSGI 规范,并比较和对比了两个流行的 WSGI 中间件,即 uWSGI 和 Gunicorn。

在本章的最后,我们讨论了可扩展性架构,并研究了在网络上垂直和水平扩展的不同选项。我们还讨论了一些最佳实践,架构师在设计、实施和部署分布式应用程序时应遵循,以实现高可扩展性。

在下一章中,我们将讨论软件架构中的安全性,并讨论架构师应该了解的安全性方面以及使应用程序安全的策略。

第六章:安全 - 编写安全代码

软件应用程序的安全性(或缺乏安全性)在过去几年在行业和媒体中引起了很大的重视。似乎每隔一天,我们都会听到恶意黑客在世界各地的软件系统中造成大规模数据泄露,并造成数百万美元的损失。受害者可能是政府部门、金融机构、处理敏感客户数据(如密码、信用卡等)的公司等。

由于软件和硬件系统之间共享的数据数量空前增加 - 智能个人技术(如智能手机、智能手表、智能音乐播放器等)的爆炸式增长,以及其他智能系统的出现和帮助,已经在互联网上大规模传播了大量数据。随着 IPv6 的出现和预计在未来几年大规模采用物联网设备(物联网)的数量将呈指数级增长,数据量只会不断增加。

正如我们在第一章中讨论的,安全是软件架构的一个重要方面。除了使用安全原则构建系统外,架构师还应该尝试灌输团队安全编码原则,以最小化团队编写的代码中的安全漏洞。

在本章中,我们将探讨构建安全系统的原则,并探讨在 Python 中编写安全代码的技巧和技术。

我们将讨论的主题可以总结如下列表。

  • 信息安全架构

  • 安全编码

  • 常见的安全漏洞

  • Python 是否安全?

  • 读取输入

  • 评估任意输入

  • 溢出错误

  • 序列化对象

  • Web 应用程序的安全问题

  • 安全策略 - Python

  • 安全编码策略

信息安全架构

安全架构涉及创建一个能够为授权人员和系统提供数据和信息访问权限的系统,同时防止任何未经授权的访问。为您的系统创建信息安全架构涉及以下方面:

  • 机密性:一组规则或程序,限制对系统中信息的访问范围。机密性确保数据不会暴露给未经授权的访问或修改。

  • 完整性:完整性是系统的属性,确保信息通道是可信赖和可靠的,并且系统没有外部操纵。换句话说,完整性确保数据在系统中的组件之间流动时是可信的。

  • 可用性:系统将根据其服务级别协议(SLA)确保向其授权用户提供一定级别的服务的属性。可用性确保系统不会拒绝向其授权用户提供服务。

机密性、完整性和可用性这三个方面,通常称为 CIA 三位一体,构成了为系统构建信息安全架构的基石。

信息安全架构

信息安全架构的 CIA 三位一体

这些方面受到其他特征的支持,例如以下特征:

  • 身份验证:验证交易参与者的身份,并确保他们确实是他们所声称的人。例如,在电子邮件中使用的数字证书,用于登录系统的公钥等。

  • 授权:授予特定用户/角色执行特定任务或相关任务组的权限。授权确保某些用户组与某些角色相关联,限制其在系统中的访问(读取)和修改(写入)权限。

  • 不可否认性:保证参与交易的用户不能以后否认交易发生。例如,电子邮件的发送者不能以后否认他们发送了电子邮件;银行资金转账的接收方不能以后否认他们收到了钱,等等。

安全编码

安全编码是软件开发的实践,它保护程序免受安全漏洞的侵害,并使其抵抗恶意攻击,从程序设计到实施。这是关于编写固有安全的代码,而不是将安全视为后来添加的层。

安全编码背后的理念包括以下内容:

  • 安全是设计和开发程序或应用程序时需要考虑的一个方面;这不是事后的想法。

  • 安全需求应在开发周期的早期确定,并应传播到系统开发的后续阶段,以确保合规性得到维持。

  • 使用威胁建模来预测系统从一开始面临的安全威胁。威胁建模包括以下内容:

  1. 识别重要资产(代码/数据)。

  2. 将应用程序分解为组件。

  3. 识别和分类对每个资产或组件的威胁。

  4. 根据已建立的风险模型对威胁进行排名。

  5. 制定威胁缓解策略。

安全编码的实践或策略包括以下主要任务:

  1. 应用程序的兴趣领域的定义:识别应用程序中代码/数据中的重要资产,这些资产是关键的,需要得到保护。

  2. 软件架构分析:分析软件架构中的明显安全缺陷。组件之间的安全交互,以确保数据的保密性和完整性。确保通过适当的身份验证和授权技术保护机密数据。确保可用性从一开始就内置到架构中。

  3. 实施细节审查:使用安全编码技术审查代码。确保进行同行审查以发现安全漏洞。向开发人员提供反馈并确保进行更改。

  4. 逻辑和语法的验证:审查代码逻辑和语法,以确保实施中没有明显的漏洞。确保编程是根据编程语言/平台的常用安全编码指南进行的。

  5. 白盒/单元测试:开发人员对其代码进行安全测试,除了确保功能的测试之外。可以使用模拟数据和/或 API 来虚拟化测试所需的第三方数据/API。

  6. 黑盒测试:应用程序由经验丰富的质量保证工程师进行测试,他寻找安全漏洞,如未经授权访问数据,意外暴露代码或数据的路径,弱密码或哈希等。测试报告反馈给利益相关者,包括架构师,以确保修复已识别的漏洞。

实际上,安全编码是一个实践和习惯,软件开发组织应该通过经过精心制定和审查的安全编码策略来培养,如上述的策略。

常见的安全漏洞

那么,今天的专业程序员应该准备面对和减轻职业生涯中可能遇到的常见安全漏洞?从现有的文献来看,这些可以组织成几个特定的类别:

  • 溢出错误:这些包括流行且经常被滥用的缓冲区溢出错误,以及较少为人知但仍然容易受到攻击的算术或整数溢出错误:

  • 缓冲区溢出:缓冲区溢出是由编程错误产生的,允许应用程序在缓冲区的末尾或开头之外写入。缓冲区溢出允许攻击者通过精心制作的攻击数据访问应用程序的堆栈或堆内存,从而控制系统。

  • 整数或算术溢出:当对整数进行算术或数学运算产生超出所用于存储的类型的最大大小的结果时,会发生这些错误。

如果未正确处理,整数溢出可能会导致安全漏洞。在支持有符号和无符号整数的编程语言中,溢出可能会导致数据包装并产生负数,从而允许攻击者获得类似于缓冲区溢出的结果,以访问程序执行限制之外的堆或栈内存。

  • 未经验证/未正确验证的输入:现代 Web 应用程序中非常常见的安全问题,未经验证的输入可能会导致严重的漏洞,攻击者可以欺骗程序接受恶意输入,如代码数据或系统命令,当执行时可能会危害系统。旨在减轻此类攻击的系统应具有过滤器,以检查和删除恶意内容,并仅接受对系统合理和安全的数据。

此类攻击的常见子类型包括 SQL 注入、服务器端模板注入、跨站脚本XSS)和 Shell 执行漏洞。

现代 Web 应用程序框架由于使用混合代码和数据的 HTML 模板而容易受到此类攻击的影响,但其中许多都有标准的缓解程序,如转义或过滤输入。

  • 不正确的访问控制:现代应用程序应为其用户类别定义单独的角色,例如普通用户和具有特殊权限的用户,如超级用户或管理员。当应用程序未能或不正确地执行此操作时,可能会暴露路由(URL)或工作流程(由特定 URL 指定的一系列操作)的攻击向量,这可能会将敏感数据暴露给攻击者,或者在最坏的情况下,允许攻击者 compromise 并控制系统。

  • 密码学问题:仅确保访问控制已经就位并不足以加固和保护系统。相反,应验证和确定安全级别和强度,否则,您的系统仍可能被黑客入侵或妥协。以下是一些示例:

  • HTTP 而不是 HTTPS:在实现 RestFUL Web 服务时,请确保优先选择 HTTPS(SSL/TLS)而不是 HTTP。在 HTTP 中,客户端和服务器之间的所有通信都是明文的,可以被被动网络嗅探器或精心制作的数据包捕获软件或安装在路由器中的设备轻松捕获。

像 letsencrypt 这样的项目已经为系统管理员提供了便利,可以获取和更新免费的 SSL 证书,因此使用 SSL/TLS 来保护您的服务器比以往任何时候都更容易。

  • 不安全的身份验证:在 Web 服务器上,优先选择安全的身份验证技术而不是不安全的技术。例如,在 Web 服务器上,优先选择 HTTP 摘要身份验证而不是基本身份验证,因为在基本身份验证中,密码是明文传输的。同样,在大型共享网络中使用Kerberos身份验证,而不是轻量级目录访问协议LDAP)或NT LAN ManagerNTLM)等不太安全的替代方案。

  • 使用弱密码:易于猜测的或默认/琐碎的密码是许多现代 Web 应用程序的祸根。

  • 重用安全哈希/密钥 - 安全哈希或密钥通常特定于应用程序或项目,不应跨应用程序重用。每当需要时生成新的哈希和/或密钥。

  • 弱加密技术:用于在服务器(SSL 证书)或个人计算机(GPG/PGP 密钥)上加密通信的密码应该使用高级别的安全性——至少 2048 位,并使用经过同行评审和加密安全的算法。

  • 弱哈希技术:就像密码一样,用于保持敏感数据(如密码)的哈希技术应该谨慎选择强大的算法。例如,如果今天编写一个需要计算和存储哈希的应用程序,最好使用 SHA-1 或 SHA-2 算法,而不是较弱的 MD5。

  • 无效或过期的证书/密钥:网站管理员经常忘记更新其 SSL 证书,这可能成为一个大问题,损害其 Web 服务器的安全性,因为无效的证书没有提供任何保护。类似地,用于电子邮件通信的个人密钥(如 GPG 或 PGP 公钥/私钥对)应该保持更新。

启用密码的 SSH - 使用明文密码对远程系统进行 SSH 访问是一个安全漏洞。禁用基于密码的访问,只允许特定用户通过授权的 SSH 密钥进行访问。禁用远程 root SSH 访问。

  • 信息泄漏:许多 Web 服务器系统——主要是由于开放配置、或配置错误、或由于缺乏对输入的验证——可以向攻击者泄露许多关于自身的信息。以下是一些例子:

  • 服务器元信息:许多 Web 服务器通过其 404 页面泄露有关自身的信息,有时还通过其登陆页面。以下是一个例子:常见的安全漏洞

暴露服务器元信息的 Web 服务器 404 页面

仅仅通过请求一个不存在的页面,我们得知在前面截图中看到的网站在 Debian 服务器上运行 Apache 版本 2.4.10。对于狡猾的攻击者来说,这通常已经足够提供特定攻击的信息,针对特定的 Web 服务器/操作系统组合。

  • 打开索引页面:许多网站不保护其目录页面,而是让它们对世界开放。以下图片显示了一个例子:常见的安全漏洞

打开 Web 服务器的索引页面

  • 打开端口:常见的错误是在远程 Web 服务器上运行的应用程序端口提供全球访问权限,而不是通过使用防火墙(如iptables)限制它们的访问权限,例如特定 IP 地址或安全组。类似的错误是允许服务在 0.0.0.0(服务器上的所有 IP 地址)上运行,而该服务仅在本地主机上使用。这使得攻击者可以使用网络侦察工具(如nmap/hping3等)扫描此类端口,并计划他们的攻击。

对文件/文件夹/数据库开放访问 - 提供应用程序配置文件、日志文件、进程 ID 文件和其他文件的开放或全球访问是一个非常糟糕的做法,以便任何登录用户都可以访问并从这些文件中获取信息。相反,这些文件应该成为安全策略的一部分,以确保只有具有所需特权的特定角色可以访问这些文件。

  • 竞争条件:当程序有两个或更多的参与者试图访问某个资源,但输出取决于访问的正确顺序,而这不能得到保证时,就存在竞争条件。一个例子是两个线程试图在共享内存中递增一个数值而没有适当的同步。

狡猾的攻击者可以利用这种情况插入恶意代码,更改文件名,或者有时利用代码处理中的小时间间隙干扰操作的顺序。

  • 系统时钟漂移:这是一个现象,即由于不正确或缺失的同步,服务器上的系统或本地时钟时间慢慢偏离参考时间。随着时间的推移,时钟漂移可能导致严重的安全漏洞,例如 SSL 证书验证错误,可以通过高度复杂的技术(如定时攻击)利用,攻击者试图通过分析执行加密算法所需的时间来控制系统。时间同步协议如 NTP 可以用来减轻这种情况。

  • 不安全的文件/文件夹操作:程序员经常对文件或文件夹的所有权、位置或属性做出假设,而这在实践中可能并不成立。这可能导致安全漏洞或我们可能无法检测到对系统的篡改。以下是一些例子:

  • 在写操作后未检查结果,假设它成功了

  • 假设本地文件路径总是本地文件(而实际上,它们可能是对应用程序可能无法访问的系统文件的符号链接)

  • 在执行系统命令时不正确使用 sudo,如果不正确执行,可能会导致漏洞,可以用来获取系统的根访问权限

  • 对共享文件或文件夹过度使用权限,例如,打开程序的所有执行位,应该限制为一个组,或者可以被任何登录用户读取的开放家庭文件夹

  • 使用不安全的代码或数据对象序列化和反序列化

本章的范围超出了访问此列表中每一种漏洞的范围。然而,我们将尽力审查和解释影响 Python 及其一些 Web 框架的常见软件漏洞类别,并在接下来的部分中进行解释。

Python 安全吗?

Python 是一种非常易读的语言,语法简单,通常有一种清晰的方法来做事情。它配备了一组经过充分测试和紧凑的标准库模块。所有这些似乎表明 Python 应该是一种非常安全的语言。

但是真的吗?

让我们看看 Python 中的一些例子,并尝试分析 Python 及其标准库的安全性方面。

为了实用性,我们将展示本节中显示的代码示例使用 Python 2.x 和 Python 3.x 版本。这是因为 Python 2.x 版本中存在的许多安全漏洞在最近的 3.x 版本中得到了修复。然而,由于许多 Python 开发人员仍在使用 Python 2.x 的某种形式,这些代码示例对他们来说是有用的,并且还说明了迁移到 Python 3.x 的重要性。

所有示例都在运行 Linux(Ubuntu 16.0),x86_64 架构的机器上执行:

注意

注意:这些示例使用的 Python 3.x 版本是 Python 3.5.2,使用的 Python 2.x 版本是 Python 2.7.12。所有示例都在运行 Linux(Ubuntu 16.0)的机器上执行,64 位 x86 架构

$ python3
Python 3.5.2 (default, Jul  5 2016, 12:43:10) 
[GCC 5.4.0 20160609] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import sys
>>> print (sys.version)
3.5.2 (default, Jul  5 2016, 12:43:10) 
[GCC 5.4.0 20160609]
$ python2
Python 2.7.12 (default, Jul  1 2016, 15:12:24) 
[GCC 5.4.0 20160609] on linux2
Type "help", "copyright", "credits" or "license" for more information.
>>> import sys
>>> print sys.version
2.7.12 (default, Jul  1 2016, 15:12:24) 
[GCC 5.4.0 20160609]

注意

注意:大多数示例将使用一个版本的代码,该代码将在 Python 2.x 和 Python 3.x 中运行。在无法实现这一点的情况下,将列出代码的两个版本。

读取输入

让我们看看这个简单的猜数字游戏程序。它从标准输入读取一个数字,并将其与一个随机数进行比较。如果匹配,用户就赢了,否则,用户必须再试一次:

# guessing.py
import random

# Some global password information which is hard-coded
passwords={"joe": "world123",
          "jane": "hello123"}

def game():
     """A guessing game """

    # Use 'input' to read the standard input
    value=input("Please enter your guess (between 1 and 10): ")
    print("Entered value is",value)
    if value == random.randrange(1, 10):
        print("You won!")
    else:
        print("Try again")

if __name__ == "__main__":
    game()

前面的代码很简单,只是有一些敏感的全局数据,即系统中一些用户的密码。在一个现实的例子中,这些可能由一些其他函数填充,这些函数读取密码并将它们缓存在内存中。

让我们尝试使用一些标准输入运行程序。我们将首先使用 Python 2.7 运行它,如下所示:

$ python2 guessing.py
Please enter your guess (between 1 and 10): 6
('Entered value is', 6)
Try again
$ python2 guessing.py
Please enter your guess (between 1 and 10): 8
('Entered value is', 8)
You won!

现在,让我们尝试一个“非标准”的输入:

$ python2 guessing.py
Please enter your guess (between 1 and 10): passwords
('Entered value is', {'jane': 'hello123', 'joe': 'world123'})
Try again

注意前面的运行暴露了全局密码数据!

问题在于在 Python 2 中,输入值被评估为一个表达式而不进行任何检查,当它被打印时,表达式打印出它的值。在这种情况下,它恰好匹配一个全局变量,所以它的值被打印出来。

现在让我们看看这个:

$ python2 guessing.py
Please enter your guess (between 1 and 10): globals()
('Entered value is', {'passwords': {'jane': 'hello123', 
'joe' : 'world123'}, '__builtins__': <module '__builtin__' (built-in)>,
 '__file__': 'guessing.py', 'random': 
<module 'random' from '/usr/lib/python2.7/random.pyc'>,
 '__package__': None, 'game': 
<function game at 0x7f6ef9c65d70>,
 '__name__': '__main__', '__doc__': None})
Try again

现在,它不仅暴露了密码,还暴露了代码中的完整全局变量,包括密码。即使程序中没有敏感数据,使用这种方法的黑客也可以揭示有关程序的有价值的信息,如变量名、函数名、使用的包等等。

这个问题的解决方案是什么?对于 Python 2,一个解决方案是用raw_input替换inputraw_input不评估内容。由于raw_input不返回数字,需要将其转换为目标类型。(可以通过将返回数据转换为int来完成。)以下代码不仅完成了这一点,还为类型转换添加了异常处理程序以提高安全性:

# guessing_fix.py
import random

passwords={"joe": "world123",
                  "jane": "hello123"}

def game():
    value=raw_input("Please enter your guess (between 1 and 10): ")
    try:
        value=int(value)
    except TypeError:
        print ('Wrong type entered, try again',value)
        return

    print("Entered value is",value)
    if value == random.randrange(1, 10):
        print("You won!")
    else:
        print("Try again")

if __name__ == "__main__":
    game()

让我们看看这个版本如何修复评估输入的安全漏洞

$ python2 guessing_fix.py 
Please enter your guess (between 1 and 10): 9
('Entered value is', 9)
Try again
$ python2 guessing_fix.py 
Please enter your guess (between1 and 10): 2
('Entered value is', 2)
You won!

$ python2 guessing_fix.py 
Please enter your guess (between 1 and 10): passwords
(Wrong type entered, try again =>, passwords)

$ python2 guessing_fix.py 
Please enter your guess (between 1 and 10): globals()
(Wrong type entered, try again =>, globals())

新程序现在比第一个版本安全得多。

这个问题在 Python 3.x 中不存在,如下图所示。(我们使用原始版本来运行这个)。

$ python3 guessing.py 
Please enter your guess (between 1 and 10): passwords
Entered value is passwords
Try again

$ python3 guessing.py 
Please enter your guess (between 1 and 10): globals()
Entered value is globals()
Try again

评估任意输入

Python 中的eval函数非常强大,但也很危险,因为它允许将任意字符串传递给它,这可能会评估潜在危险的代码或命令。

让我们看看这个相当愚蠢的代码作为一个测试程序,看看eval能做什么:

# test_eval.py
import sys
import os

def run_code(string):
    """ Evaluate the passed string as code """

    try:
eval(string, {})
    except Exception as e:
        print(repr(e))

if __name__ == "__main__":
     run_code(sys.argv[1])

让我们假设一个攻击者试图利用这段代码来查找应用程序运行的目录的内容。(暂时可以假设攻击者可以通过 Web 应用程序运行此代码,但没有直接访问机器本身)。

假设攻击者试图列出当前文件夹的内容:

$ python2 test_eval.py "os.system('ls -a')"
NameError("name 'os' is not defined",)

这个先前的攻击不起作用,因为eval需要一个第二个参数,在评估过程中提供要使用的全局值。由于在我们的代码中,我们将这个第二个参数作为空字典传递,我们会得到错误,因为 Python 无法解析os名称。

这是否意味着eval是安全的?不,它不是。让我们看看为什么。

当我们将以下输入传递给代码时会发生什么?

$ python2 test_eval.py "__import__('os').system('ls -a')"
.   guessing_fix.py  test_eval.py    test_input.py
..  guessing.py      test_format.py  test_io.py

我们可以看到,我们仍然能够通过使用内置函数__import__来诱使eval执行我们的命令。

这样做的原因是因为像__import__这样的名称在默认内置的__builtins__全局中是可用的。我们可以通过将其作为空字典传递给第二个参数来拒绝eval。这是修改后的版本:

# test_eval.py
import sys
import os

def run_code(string):
    """ Evaluate the passed string as code """

    try:
        # Pass __builtins__ dictionary as empty
        eval(string,  {'__builtins__':{}})
    except Exception as e:
        print(repr(e))

if __name__ == "__main__":
run_code(sys.argv[1])

现在攻击者无法通过内置的__import__进行利用:

$ python2 test_eval.py "__import__('os').system('ls -a')"
NameError("name '__import__' is not defined",)

然而,这并不意味着eval更安全,因为它容易受到稍长一点但聪明的攻击。以下是这样一种攻击:

$ python2 test_eval.py "(lambda f=(lambda x: [c for c in [].__class__.__bases__[0].__subclasses__() if c.__name__ == x][0]): f('function')(f('code')(0,0,0,0,'BOOM',(), (),(),'','',0,''),{})())()"
Segmentation fault (core dumped)

我们能够使用一个看起来相当晦涩的恶意代码来使 Python 解释器崩溃。这是怎么发生的?

这里是步骤的一些详细解释。

首先,让我们考虑一下这个:

>>> [].__class__.__bases__[0]
<type 'object'>

这只是基类object。由于我们无法访问内置函数,这是一种间接访问它的方法。

接下来,以下代码行加载了 Python 解释器中当前加载的object的所有子类:

>>> [c for c in [].__class__.__bases__[0].__subclasses__()]

其中,我们想要的是code对象类型。这可以通过检查项目的名称通过__name__属性来访问:

>>> [c for c in [].__class__.__bases__[0].__subclasses__() if c.__name__ == 'code']

这是通过使用匿名lambda函数实现的相同效果:

>>> (lambda x: [c for c in [].__class__.__bases__[0].__subclasses__() if c.__name__ == x])('code')
[<type 'code'>]

接下来,我们想要执行这个代码对象。然而,code对象不能直接调用。它们需要绑定到一个函数才能被调用。这是通过将前面的lambda函数包装在外部lambda函数中实现的:

>>> (lambda f: (lambda x: [c for c in [].__class__.__bases__[0].__subclasses__() if c.__name__ == x])('code'))
<function <lambda> at 0x7f8b16a89668

现在我们的内部lambda函数可以分两步调用:

>>> (lambda f=(lambda x: [c for c in [].__class__.__bases__[0].__subclasses__() if c.__name__ == x][0]): f('function')(f('code')))
<function <lambda> at 0x7fd35e0db7d0>

最后,我们通过这个外部的lambda函数调用code对象,传递了大多数默认参数。代码字符串被传递为字符串BOOM,当然,这是一个虚假的代码字符串,会导致 Python 解释器崩溃,产生核心转储:

>>> (lambda f=(lambda x: 
[c for c in [].__class__.__bases__[0].__subclasses__() if c.__name__ == x][0]): 
f('function')(f('code')(0,0,0,0,'BOOM',(), (),(),'','',0,''),{})())()
Segmentation fault (core dumped)

这表明在任何情况下,即使没有内置模块的支持,eval都是不安全的,并且可以被聪明而恶意的黑客利用来使 Python 解释器崩溃,从而可能控制系统。

请注意,相同的利用在 Python 3 中也有效,但是我们需要对code对象的参数进行一些修改,因为在 Python 3 中,code对象需要额外的参数。此外,代码字符串和一些参数必须是byte类型。

以下是在 Python 3 上运行的利用。最终结果是相同的:

$ python3 test_eval.py 
"(lambda f=(lambda x: [c for c in ().__class__.__bases__[0].__subclasses__() 
  if c.__name__ == x][0]): f('function')(f('code')(0,0,0,0,0,b't\x00\x00j\x01\x00d\x01\x00\x83\x01\x00\x01d\x00\x00S',(), (),(),'','',0,b''),{})())()"
Segmentation fault (core dumped)

溢出错误

在 Python 2 中,如果xrange()函数的范围无法适应 Python 的整数范围,则会产生溢出错误:

>>> print xrange(2**63)
Traceback (most recent call last):
    File "<stdin>", line 1, in <module>
OverflowError: Python int too large to convert to C long

range()函数也会出现略有不同的溢出错误:

>>> print range(2**63)
Traceback (most recent call last):
    File "<stdin>", line 1, in <module>
OverflowError: range() result has too many items

问题在于xrange()range()使用普通整数对象(类型<int>),而不是自动转换为仅受系统内存限制的long类型。

然而,在 Python 3.x 版本中,这个问题已经得到解决,因为类型intlong被统一为一个(int类型),而range()对象在内部管理内存。此外,不再有单独的xrange()对象:

>>> range(2**63)
range(0, 9223372036854775808)

这是 Python 中整数溢出错误的另一个例子,这次是针对len函数。

在以下示例中,我们尝试对两个类 A 和 B 的实例使用len函数,这两个类的魔术方法__len__已被覆盖以支持len函数。请注意,A 是一个新式类,继承自object,而 B 是一个旧式类。

# len_overflow.py

class A(object):
    def __len__(self): 
        return 100 ** 100

class B:
    def __len__(self): 
        return 100 ** 100

try:
    len(A())
    print("OK: 'class A(object)' with 'return 100 ** 100' - len calculated")
except Exception as e:
    print("Not OK: 'class A(object)' with 'return 100 ** 100' - len raise Error: " + repr(e))

try:
    len(B())
    print("OK: 'class B' with 'return 100 ** 100' - len calculated")
except Exception as e:
    print("Not OK: 'class B' with 'return 100 ** 100' - len raise Error: " + repr(e))

以下是在 Python2 中执行代码时的输出:

$ python2 len_overflow.py** 
Not OK: 'class A(object)' with 'return 100 ** 100' - len raise Error: OverflowError('long int too large to convert to int',)
Not OK: 'class B' with 'return 100 ** 100' - len raise Error: TypeError('__len__() should return an int',)

在 Python 3 中执行相同的代码如下:

$ python3 len_overflow.py** 
Not OK: 'class A(object)' with 'return 100 ** 100' - len raise Error: OverflowError("cannot fit 'int' into an index-sized integer",)
Not OK: 'class B' with 'return 100 ** 100' - len raise Error: OverflowError("cannot fit 'int' into an index-sized integer",)

在前面的代码中的问题在于len返回integer对象,在这种情况下,实际值太大而无法适应int,因此 Python 引发了溢出错误。然而,在 Python 2 中,对于未从object派生的类的情况,执行的代码略有不同,它预期一个int对象,但得到了long并抛出了TypeError。在 Python 3 中,这两个示例都返回溢出错误。

这样的整数溢出错误是否存在安全问题?

在实际情况中,这取决于应用程序代码和所使用的依赖模块代码,以及它们如何处理或捕获/掩盖溢出错误。

然而,由于 Python 是用 C 编写的,任何在底层 C 代码中没有正确处理的溢出错误都可能导致缓冲区溢出异常,攻击者可以向溢出缓冲区写入并劫持底层进程/应用程序。

通常,如果一个模块或数据结构能够处理溢出错误并引发异常以阻止进一步的代码执行,那么代码利用的可能性就会减少。

对象序列化

对于 Python 开发人员来说,使用pickle模块及其 C 实现的cPickle来对 Python 中的对象进行序列化是非常常见的。然而,这两个模块都允许未经检查的代码执行,因为它们不对被序列化的对象进行任何类型检查或规则的强制,以验证它是一个良性的 Python 对象还是一个可能利用系统的潜在命令。

注意

注意:在 Python3 中,cPicklepickle模块合并为一个单独的pickle模块。

这是通过 shell 利用的示例,它列出了 Linux/POSIX 系统中根文件夹(/)的内容:

# test_serialize.py
import os
import pickle

class ShellExploit(object):
    """ A shell exploit class """

    def __reduce__(self):
        # this will list contents of root / folder.
        return (os.system, ('ls -al /',)

def serialize():
    shellcode = pickle.dumps(ShellExploit())
    return shellcode

def deserialize(exploit_code):
    pickle.loads(exploit_code)

if __name__ == '__main__':
    shellcode = serialize()
    deserialize(shellcode)

最后的代码简单地打包了一个ShellExploit类,该类在进行 pickle 时通过os.system()方法返回列出根文件系统/内容的命令。Exploit类将恶意代码伪装成pickle对象,该对象在解 pickle 时执行代码,并将机器的根文件夹内容暴露给攻击者。上述代码的输出如下所示:

序列化对象

使用 pickle 进行序列化的 shell 利用代码的输出,暴露了/文件夹的内容。

正如你所看到的,输出清楚地列出了根文件夹的内容。

如何防止这种利用的解决方法是什么?

首先,不要在应用程序中使用像pickle这样的不安全模块进行序列化。而是依赖于更安全的替代方案,如jsonyaml。如果你的应用程序确实依赖于某种原因使用pickle模块,那么使用沙箱软件或codeJail来创建防止系统上恶意代码执行的安全环境。

例如,这是对先前代码的轻微修改,现在使用一个简单的 chroot 监狱,防止在实际根文件夹上执行代码。它使用一个本地的safe_root/子文件夹作为新的根目录,通过上下文管理器钩子。请注意,这只是一个简单的例子。实际的监狱会比这个复杂得多:

# test_serialize_safe.py
import os
import pickle
from contextlib import contextmanager

class ShellExploit(object):
    def __reduce__(self):
        # this will list contents of root / folder.
        return (os.system, ('ls -al /',))

@contextmanager
def system_jail():
    """ A simple chroot jail """

    os.chroot('safe_root/')
    yield
    os.chroot('/')

def serialize():
    with system_jail():
        shellcode = pickle.dumps(ShellExploit())
        return shellcode

def deserialize(exploit_code):
    with system_jail():
        pickle.loads(exploit_code)

if __name__ == '__main__':
    shellcode = serialize()
    deserialize(shellcode)

有了这个监狱,代码执行如下:

序列化对象

使用 pickle 进行序列化的 shell 利用代码的输出,带有一个简单的 chroot 监狱。

现在不会产生任何输出,因为这是一个虚假的监狱,Python 在新根目录中找不到ls命令。当然,为了使这在生产系统中起作用,应该设置一个适当的监狱,允许程序执行,但同时防止或限制恶意程序的执行。

其他序列化格式如 JSON 怎么样?这样的利用可以使用它们吗?让我们用一个例子来看看。

这里是使用json模块编写的相同序列化代码:

# test_serialize_json.py
import os
import json
import datetime

class ExploitEncoder(json.JSONEncoder):
    def default(self, obj):
        if any(isinstance(obj, x) for x in (datetime.datetime, datetime.date)):
            return str(obj)

        # this will list contents of root / folder.
        return (os.system, ('ls -al /',))

def serialize():
    shellcode = json.dumps([range(10),
                            datetime.datetime.now()],
                           cls=ExploitEncoder)
    print(shellcode)
    return shellcode

def deserialize(exploit_code):
    print(json.loads(exploit_code))

if __name__ == '__main__':
    shellcode = serialize()
    deserialize(shellcode)

请注意,使用自定义编码器ExploitEncoder覆盖了默认的 JSON 编码器。然而,由于 JSON 格式不支持这种序列化,它返回了作为输入传递的列表的正确序列化:

$ python2 test_serialize_json.py 
[[0, 1, 2, 3, 4, 5, 6, 7, 8, 9], "2017-04-15 12:27:09.549154"]
[[0, 1, 2, 3, 4, 5, 6, 7, 8, 9], u'2017-04-15 12:27:09.549154']

使用 Python3,利用程序失败,因为 Python3 会引发异常。

序列化对象

使用 Python3 进行序列化的 shell 利用代码的输出

Web 应用程序的安全问题

到目前为止,我们已经看到了 Python 的四种安全问题,即读取输入、评估表达式、溢出错误和序列化问题。到目前为止,我们所有的例子都是在控制台上使用 Python。

然而,我们几乎每天都与 Web 应用程序进行交互,其中许多是使用 Python Web 框架编写的,如 Django、Flask、Pyramid 等。因此,我们更有可能在这些应用程序中暴露出安全问题。我们将在这里看一些例子。

服务器端模板注入

服务器端模板注入SSTI)是一种使用常见 Web 框架的服务器端模板作为攻击向量的攻击。该攻击利用了用户输入嵌入模板的方式中的弱点。SSTI 攻击可以用于查找 Web 应用程序的内部情况,执行 shell 命令,甚至完全破坏服务器。

我们将看到一个使用 Python 中非常流行的 Web 应用程序框架 Flask 的示例。

以下是一个在 Flask 中使用内联模板的相当简单的 Web 应用程序的示例代码:

# ssti-example.py
from flask import Flask
from flask import request, render_template_string, render_template

app = Flask(__name__)

@app.route('/hello-ssti')
defhello_ssti():
    person = {'name':"world", 'secret': 'jo5gmvlligcZ5YZGenWnGcol8JnwhWZd2lJZYo=='}
    if request.args.get('name'):
        person['name'] = request.args.get('name')

    template = '<h2>Hello %s!</h2>' % person['name']
    return render_template_string(template, person=person)

if __name__ == "__main__":
app.run(debug=True)

在控制台上运行,并在浏览器中打开,允许我们在hello-ssti路由中玩耍:

$ python3 ssti_example.py 
 * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)
 * Restarting with stat
 * Debugger is active!
 * Debugger pin code: 163-936-023

首先,让我们尝试一些良性输入:

服务器端模板注入

这里是另一个例子。

服务器端模板注入

接下来,让我们尝试一些攻击者可能使用的巧妙输入。

服务器端模板注入

这里发生了什么?

由于模板使用不安全的%s字符串模板,它会将传递给它的任何内容评估为 Python 表达式。我们传递了{{ person.secret }},在 Flask 模板语言(Flask 使用 Jinja2 模板)中,它被评估为字典person中密钥 secret 的值,从而有效地暴露了应用程序的秘密密钥!

我们可以进行更加雄心勃勃的攻击,因为代码中的这个漏洞允许攻击者尝试 Jinja 模板的全部功能,包括 for 循环。以下是一个示例:

服务器端模板注入

用于攻击的 URL 如下:

http://localhost:5000/hello-ssti?name={% for item in person %}<p>{{ item, person[item] }}</p>{% endfor %}

这通过一个 for 循环,尝试打印person字典的所有内容。

这也允许攻击者轻松访问敏感的服务器端配置参数。例如,他可以通过将名称参数传递为{{ config }}来打印 Flask 配置。

这是浏览器的图像,使用此攻击打印服务器配置。

服务器端模板注入

服务器端模板注入 - 缓解

我们在上一节中看到了一些使用服务器端模板作为攻击向量来暴露 Web 应用程序/服务器敏感信息的示例。在本节中,我们将看到程序员如何保护他的代码免受此类攻击。

在这种特定情况下,修复此问题的方法是在模板中使用我们想要的特定变量,而不是危险的、允许所有%s字符串。以下是带有修复的修改后的代码:

# ssti-example-fixed.py
from flask import Flask
from flask import request, render_template_string, render_template

app = Flask(__name__)

@app.route('/hello-ssti')
defhello_ssti():
    person = {'name':"world", 'secret': 'jo5gmvlligcZ5YZGenWnGcol8JnwhWZd2lJZYo=='}
    if request.args.get('name'):
        person['name'] = request.args.get('name')

    template = '<h2>Hello {{ person.name }} !</h2>'
    return render_template_string(template, person=person)

if __name__ == "__main__":
app.run(debug=True)

现在,先前的所有攻击都会失败。

这是第一次攻击的浏览器图像:

服务器端模板注入 - 缓解

以下是下一次攻击的浏览器图像。

服务器端模板注入 - 缓解

拒绝服务

现在让我们看看另一种常被恶意黑客使用的攻击,即拒绝服务DOS)。

DoS 攻击针对 Web 应用程序中的易受攻击的路由或 URL,并向其发送巧妙的数据包或 URL,这些数据包或 URL 要么迫使服务器执行无限循环或 CPU 密集型计算,要么迫使服务器从数据库中加载大量数据,这会给服务器 CPU 带来很大负载,从而阻止服务器执行其他请求。

注意

DDoS 或分布式 DoS 攻击是指以协调的方式使用多个系统针对单个域的 DoS 攻击。通常使用数千个 IP 地址,这些 IP 地址通过僵尸网络进行管理以进行 DDoS 攻击。

我们将看到一个使用我们先前示例的变体的 DoS 攻击的最小示例:

# ssti-example-dos.py
from flask import Flask
from flask import request, render_template_string, render_template

app = Flask(__name__)

TEMPLATE = '''
<html>
 <head><title> Hello {{ person.name }} </title></head>
 <body> Hello FOO </body>
</html>
'''

@app.route('/hello-ssti')
defhello_ssti():
    person = {'name':"world", 'secret': 'jo5gmvlligcZ5YZGenWnGcol8JnwhWZd2lJZYo=='} 
    if request.args.get('name'):
        person['name'] = request.args.get('name')

    # Replace FOO with person's name
    template = TEMPLATE.replace("FOO", person['name'])
    return render_template_string(template, person=person)

if __name__ == "__main__":
app.run(debug=True)

在上述代码中,我们使用一个名为TEMPLATE的全局模板变量,并使用safer {{ person.name }}模板变量作为与 SSTI 修复一起使用的模板变量。但是,这里的附加代码是用名称值替换了持有名称FOO

这个版本具有原始代码的所有漏洞,即使删除了%s代码。例如,看一下浏览器暴露了{{ person.secret }}变量值的图像,但没有在页面标题中暴露。

拒绝服务

这是由于我们添加的以下代码行。

 # Replace FOO with person's name
 template = TEMPLATE.replace("FOO", person['name'])

任何传递的表达式都会被评估,包括算术表达式。例如:

拒绝服务

这打开了通过传递服务器无法处理的 CPU 密集型计算的简单 DoS 攻击的途径。例如,在以下攻击中,我们传递了一个非常大的数字计算,它占用了系统的 CPU,减慢了系统的速度,并使应用程序无响应:

拒绝服务

使用计算密集型代码演示 DoS 风格攻击的示例。请求从未完成。

此攻击使用的 URL 是http://localhost:5000/hello-ssti?name=Tom

通过传入计算密集的算术表达式{{ 100**100000000 }},服务器被超载,无法处理其他请求。

正如您在上一张图片中所看到的,请求从未完成,也阻止了服务器响应其他请求;正如您可以从右侧打开的新标签页上对同一应用程序的正常请求也被阻塞,导致了 DoS 风格攻击的效果。

拒绝服务

右侧打开的新标签页显示应用程序已经无响应。

跨站脚本攻击(XSS)

我们在前一节中使用的代码来演示最小化 DOS 攻击也容易受到脚本注入的影响。以下是一个示例:

跨站脚本攻击(XSS)

使用服务器端模板和 JavaScript 注入演示 XSS 脚本注入的简单示例

此攻击使用的 URL 是:

http://localhost:5000/hello-ssti?name=Tom<script>alert("You are under attack!")</script>

这些脚本注入漏洞可能导致 XSS,这是一种常见的 Web 利用形式,攻击者能够将恶意脚本注入到您服务器的代码中,从其他网站加载,并控制它。

缓解- DoS 和 XSS

在上一节中,我们看到了一些 DoS 攻击和简单的 XSS 攻击的示例。现在让我们看看程序员如何在他的代码中采取措施来缓解这种攻击。

在我们用于说明的先前特定示例中,修复方法是删除替换字符串FOO的行,并将其替换为参数模板本身。为了保险起见,我们还确保输出通过使用 Jinja 2 的转义过滤器|e进行适当的转义。以下是重写的代码:

# ssti-example-dos-fix.py
from flask import Flask
from flask import request, render_template_string, render_template

app = Flask(__name__)

TEMPLATE = '''
<html>
 <head><title> Hello {{ person.name | e }} </title></head>
 <body> Hello {{ person.name | e }} </body>
</html>
'''

@app.route('/hello-ssti')
defhello_ssti():
    person = {'name':"world", 'secret': 'jo5gmvlligcZ5YZGenWnGcol8JnwhWZd2lJZYo=='} 
    if request.args.get('name'):
        person['name'] = request.args.get('name')
    return render_template_string(TEMPLATE, person=person)

if __name__ == "__main__":
app.run(debug=True)

现在这两个漏洞都得到了缓解,攻击没有效果,也没有造成伤害。

这是一个演示 DoS 攻击的图像。

缓解- DoS 和 XSS

这是一个演示 XSS 攻击的示例。

缓解- DoS 和 XSS

由于服务器端模板中的糟糕代码,类似的漏洞也存在于其他 Python Web 框架,如 Django、Pyramid、Tornado 等。然而,逐步讨论每个框架的内容超出了本章的范围。有兴趣的读者可以查阅网络上讨论此类问题的安全资源。

安全策略- Python

我们已经讨论了 Python 编程语言核心中存在的许多漏洞,还看了一些影响 Python Web 应用程序的常见安全问题。

现在是时候了解安全架构师可以使用的策略-提示和技术,以便他们的团队可以从程序设计和开发阶段开始应用安全编码原则来缓解安全问题:

  • 读取输入:在读取控制台输入时,优先使用 raw_input 而不是 input,因为前者不会评估 Python 表达式,而是将输入作为纯字符串返回。任何类型转换或验证都应手动完成,如果类型不匹配,则抛出异常或返回错误。对于读取密码,使用 getpass 等库,并对返回的数据进行验证。一旦验证成功,可以安全地对数据进行评估。

  • 评估表达式:正如我们在示例中所看到的,eval 无论如何使用都存在漏洞。因此,Python 的最佳策略是避免使用 eval 及其邪恶的表亲 exec。如果必须使用 eval,请务必不要与用户输入字符串、或从第三方库或 API 读取的数据一起使用。只能与您控制并信任的函数的输入源和返回值一起使用 eval。

  • 序列化:不要使用picklecPickle进行序列化。更倾向于其他模块,如 JASON 或 YAML。如果绝对必须使用pickle/cPickle,则使用缓解策略,如 chroot 监狱或沙盒,以避免恶意代码执行的不良影响。

  • 溢出错误:通过使用异常处理程序来防范整数溢出。Python 不会受到纯缓冲区溢出错误的影响,因为它总是检查其容器是否超出边界的读/写访问,并抛出异常。对于类中重写的__len__方法,根据需要捕获溢出或TypeError异常。

  • 字符串格式化:更倾向于使用模板字符串的新方法,而不是旧的和不安全的%s插值。

例如:

def display_safe(employee):
    """ Display details of the employee instance """

    print("Employee: {name}, Age: {age}, 
             profession: {job}".format(**employee))

def display_unsafe(employee):
    """ Display details of employee instance """

    print ("Employee: %s, Age: %d, 
              profession: %s" % (employee['name'],
                                             employee['age'],
                                             employee['job']))

>>> employee={'age': 25, 'job': 'software engineer', 'name': 'Jack'}
>>> display_safe(employee)
Employee: Jack, Age: 25, profession: software engineer
>>> display_unsafe(employee)
Employee: Jack, Age: 25, profession: software engineer
  • 文件:在处理文件时,最好使用上下文管理器来确保在操作后关闭文件描述符。

例如,更倾向于这种方法:

with open('somefile.txt','w') as fp:
 fp.write(buffer)

并避免以下情况:

fp = open('somefile.txt','w')
fp.write(buffer)

这也将确保在文件读取或写入期间发生任何异常时关闭文件描述符,而不是在系统中保持打开文件句柄。

  • 处理密码和敏感信息:在验证密码等敏感信息时,最好比较加密哈希而不是比较内存中的原始数据:

  • 这样,即使攻击者能够通过利用诸如 shell 执行漏洞或输入数据评估中的弱点等漏洞从程序中窃取敏感数据,实际的敏感数据也会受到保护,不会立即泄露。以下是一个简单的方法:

# compare_passwords.py - basic
import hashlib
import sqlite3
import getpass

def read_password(user):
    """ Read password from a password DB """
    # Using an sqlite db for demo purpose

    db = sqlite3.connect('passwd.db')
    cursor = db.cursor()
    try:
        passwd=cursor.execute("select password from passwds where user='%(user)s'" % locals()).fetchone()[0]
        return hashlib.sha1(passwd.encode('utf-8')).hexdigest()
    except TypeError:
        pass

def verify_password(user):
    """ Verify password for user """

    hash_pass = hashlib.sha1(getpass.getpass("Password: ").encode('utf-8')).hexdigest()
    print(hash_pass)
    if hash_pass==read_password(user):
        print('Password accepted')
    else:
        print('Wrong password, Try again')

if __name__ == "__main__":
    import sys
    verify_password(sys.argv[1])

更加密码学上正确的技术是使用内置盐和固定数量的哈希轮次的强密码哈希库。

以下是在 Python 中使用passlib库的示例:

# crypto_password_compare.py
import sqlite3
import getpass
from passlib.hash import bcrypt

def read_passwords():
    """ Read passwords for all users from a password DB """
    # Using an sqlite db for demo purpose

    db = sqlite3.connect('passwd.db')
    cursor = db.cursor()
    hashes = {}

    for user,passwd in cursor.execute("select user,password from passwds"):
        hashes[user] = bcrypt.encrypt(passwd, rounds=8)

    return hashes

def verify_password(user):
    """ Verify password for user """

    passwds = read_passwords()
    # get the cipher
    cipher = passwds.get(user)
    if bcrypt.verify(getpass.getpass("Password: "), cipher):
        print('Password accepted')      
    else:
        print('Wrong password, Try again')

if __name__ == "__main__":
    import sys
    verify_password(sys.argv[1])

为了说明,已创建了一个包含两个用户及其密码的passwd.db sqlite 数据库,如下截图所示:

安全策略- Python

以下是代码的实际操作:

注意

请注意,为了清晰起见,此处显示了键入的密码-实际程序中不会显示,因为它使用getpass库。

以下是代码的实际操作:

$ python3 crytpo_password_compare.py jack
Password: test
Wrong password, Try again

$ python3 crytpo_password_compare.py jack
Password: reacher123
Password accepted
  • 本地数据:尽量避免将敏感数据存储在函数的本地。函数中的任何输入验证或评估漏洞都可以被利用来访问本地堆栈,从而访问本地数据。始终将敏感数据加密或散列存储在单独的模块中。

以下是一个简单的示例:

def func(input):
  secret='e4fe5775c1834cc8bd6abb712e79d058'
  verify_secret(input, secret)
  # Do other things

上述函数对于秘钥“secret”是不安全的,因为任何攻击者访问函数堆栈的能力都可以访问秘密。

这些秘密最好保存在一个单独的模块中。如果您正在使用秘密进行哈希和验证,以下代码比第一个更安全,因为它不会暴露“秘密”的原始值:

 # This is the 'secret' encrypted via bcrypt with eight rounds.
 secret_hash=''$2a$08$Q/lrMAMe14vETxJC1kmxp./JtvF4vI7/b/VnddtUIbIzgCwA07Hty'
 def func(input):
  verify_secret(input, secret_hash)
  • 竞争条件:Python 提供了一组优秀的线程原语。如果您的程序使用多个线程和共享资源,请遵循以下准则来同步对资源的访问,以避免竞争条件和死锁:

  • 通过互斥锁(threading.Lock)保护可以同时写入的资源

  • 通过信号量(threading.BoundedSemaphore)保护需要序列化的资源,以便对多个但有限的并发访问进行处理

  • 使用条件对象唤醒同步等待可编程条件或函数的多个线程(threading.Condition

  • 避免循环一段时间后休眠,然后轮询条件或标准。而是使用条件或事件对象进行同步(threading.Event

对于使用多个进程的程序,应该使用multiprocessing库提供的类似对应物来管理对资源的并发访问

  • 保持系统更新:尽管这听起来陈词滥调,但及时了解系统中软件包的安全更新以及一般安全新闻,特别是对影响您应用程序的软件包,是保持系统和应用程序安全的简单方法。许多网站提供了许多开源项目(包括 Python 及其标准库模块)安全状态的持续更新。

这些报告通常被称为常见漏洞和暴露CVEs)-诸如 Mitre(cve.mitre.org)之类的网站提供不断更新的信息。

在这些网站上搜索 Python 显示了 213 个结果:

安全策略- Python

在 Mitre CVE 列表上搜索'python'关键字的结果

架构师、运维工程师和网站管理员也可以调整系统软件包更新,并始终默认启用安全更新。对于远程服务器,建议每两到三个月升级到最新的安全补丁。

  • 同样,Python 开放式 Web 应用安全项目OWASP)是一个免费的第三方项目,旨在创建一个比标准 Cpython 更能抵御安全威胁的 Python 强化版本。它是更大的 OWASP 计划的一部分。

  • Python OWASP 项目通过其网站和相关的 GitHub 项目提供了 Python 错误报告、工具和其他工件。主要网站是,大部分代码可从 GitHub 项目页面获取:github.com/ebranca/owasp-pysec/安全策略- Python

OWASP Python 安全项目主页

对于利益相关者来说,跟踪该项目、运行测试并阅读报告以了解 Python 安全方面的最新信息是一个好主意。

安全编码策略

我们即将结束对软件架构安全方面的讨论。现在是总结应该从安全架构师的角度向软件开发团队传授的策略的好时机。以下是总结其中前 10 个策略的表格。其中一些可能与我们之前的讨论重复,因为我们之前已经看到过它们。

SL 策略 它如何帮助
1 验证输入 验证来自所有不受信任数据源的输入。适当的输入验证可以消除绝大多数软件漏洞。
2 保持简单 尽量简化程序设计。复杂的设计增加了在实施、配置和部署过程中出现安全错误的几率。
3 最小权限原则 每个进程应以完成工作所需的最少系统权限执行。例如,要从/tmp 读取数据,不需要 root 权限,但任何非特权用户都可以。
4 清理数据 清理从所有第三方系统(如数据库、命令行 shell、COTs 组件、第三方中间件等)读取和发送的数据。这减少了 SQL 注入、shell 利用或其他类似攻击的机会。
5 授权访问 通过需要特定身份验证的角色将应用程序的各个部分分开。不要在同一代码中混合不同部分的应用程序,这些部分需要不同级别的访问权限。采用适当的路由确保不会通过未受保护的路由暴露敏感数据。
6 进行有效的 QA 良好的安全测试技术能够有效地识别和消除漏洞。模糊测试、渗透测试和源代码审计应作为程序的一部分进行。
7 分层实践防御 通过多层安全性减轻风险。例如,将安全编程技术与安全运行时配置相结合,将减少在运行时环境中暴露任何剩余代码漏洞的机会。
8 定义安全需求 在系统早期生命周期中识别和记录安全约束,并不断更新它们,确保后续功能符合这些要求。
9 建模威胁 使用威胁建模来预测软件将受到的威胁。
10 为安全策略进行架构和设计 创建并维护一个软件架构,强制执行一致的安全策略模式,覆盖系统及其子系统。

总结

在本章中,我们首先看了一个建立安全性的系统架构的细节。我们继续定义了安全编码,并研究了安全编码实践背后的哲学和原则。

然后,我们研究了软件系统中遇到的常见安全漏洞类型,如缓冲区溢出、输入验证问题、访问控制问题、加密弱点、信息泄漏、不安全的文件操作等。

然后,我们详细讨论了 Python 安全问题,并举了很多例子。我们详细研究了读取和评估输入、溢出错误和序列化问题。然后,我们继续研究了 Python Web 应用程序框架中的常见漏洞,选择了 Flask 作为候选对象。我们看到了如何利用 Web 应用程序模板的弱点,并执行 SSTI、XSS 和 DOS 等攻击。我们还看到了如何通过多个代码示例来减轻这些攻击。

然后,我们列出了 Python 中编写安全代码的具体技术。我们详细研究了在代码中管理密码和其他敏感数据的加密哈希,并讨论了一些正确的示例。还提到了保持自己了解安全新闻和项目的重要性,以及保持系统更新安全补丁的重要性。

最后,我们总结了安全编码策略的前十名,安全架构师可以向团队传授这些策略,以创建安全的代码和系统。

在下一章中,我们将看一下软件工程和设计中最有趣的方面之一,即设计模式。

第七章:Python 中的设计模式

设计模式通过重用成功的设计和架构简化软件构建。模式建立在软件工程师和架构师的集体经验之上。当遇到需要编写新代码的问题时,经验丰富的软件架构师倾向于利用可用的设计/架构模式丰富的生态系统。

当专家发现特定的设计或架构帮助他们一贯解决相关问题类时,模式会不断演变。他们倾向于越来越多地应用它,将解决方案的结构编码为模式。

Python 是一种支持动态类型和高级面向对象结构(如类和元类)、一级函数、协程、可调用对象等的语言,非常适合构建可重用的设计和架构模式。实际上,与 C++或 Java 等语言相反,你会经常发现在 Python 中实现特定设计模式的多种方法。而且,往往你会发现 Python 实现模式的方式比从 C++/Java 中复制标准实现更直观和有说明性。

本章的重点主要是后一方面——说明如何构建更符合 Python 风格的设计模式,而不是通常关于这个主题的书籍和文献所倾向于做的。它并不旨在成为设计模式的全面指南,尽管随着内容的展开,我们将涵盖大部分常见方面。

我们计划在本章中涵盖的主题如下:

  • 设计模式元素

  • 设计模式的类别

  • 可插拔哈希算法

  • 总结可插拔哈希算法

  • Python 中的模式 - 创造性

  • 单例模式

  • 波格模式

  • 工厂模式

  • 原型模式

  • 生成器模式

  • Python 中的模式 - 结构性

  • 适配器模式

  • 外观模式

  • 代理模式

  • Python 中的模式 - 行为

  • 迭代器模式

  • 观察者模式

  • 状态模式

设计模式 - 元素

设计模式试图记录面向对象系统中解决问题或一类问题的重复设计的方面。

当我们检查设计模式时,我们发现几乎所有设计模式都具有以下元素:

  • 名称:常用于描述模式的知名句柄或标题。为设计模式使用标准名称有助于沟通并增加我们的设计词汇量。

  • 背景:问题出现的情况。背景可以是通用的,如“开发 Web 应用软件”,也可以是具体的,如“在发布者-订阅者系统的共享内存实现中实现资源更改通知”。

  • 问题:描述了模式适用的实际问题。问题可以根据其力量来描述,如下所示:

  • 要求:解决方案应满足的要求,例如,“发布者-订阅者模式实现必须支持 HTTP”。

  • 约束:解决方案的约束,如果有的话,例如,“可扩展的点对点发布者模式在发布通知时不应交换超过三条消息”。

  • 属性:解决方案的期望属性,例如,“解决方案应在 Windows 和 Linux 平台上同样有效”。

  • 解决方案:显示了问题的实际解决方案。它描述了解决方案的结构和责任、静态关系以及组成解决方案的元素之间的运行时交互(协作)。解决方案还应讨论它解决的问题的“力量”,以及它不解决的问题。解决方案还应尝试提及其后果,即应用模式的结果和权衡。

注意

设计模式解决方案几乎从不解决导致它的问题的所有力量,而是留下一些力量供相关或替代实现使用。

设计模式的分类

设计模式可以根据所选择的标准以不同的方式进行分类。一个常见的分类方式是使用模式的目的作为标准。换句话说,我们问模式解决了什么类的问题。

这种分类给我们提供了三种模式类的清晰变体。它们如下:

  • 创建模式:这些模式解决了与对象创建和初始化相关的问题。这些问题是在对象和类的问题解决生命周期的最早阶段出现的。看一下以下的例子:

  • 工厂模式:"如何确保我可以以可重复和可预测的方式创建相关的类实例?"这个问题由工厂模式类解决

  • 原型模式:"如何智能地实例化一个对象,然后通过复制这个对象创建数百个类似的对象?"这个问题由原型模式解决

  • 单例和相关模式:"如何确保我创建的类的任何实例只创建和初始化一次"或"如何确保类的任何实例共享相同的初始状态?"这些问题由单例和相关模式解决

  • 结构模式:这些模式涉及对象的组合和组装成有意义的结构,为架构师和开发人员提供可重用的行为,其中“整体大于部分的总和”。自然地,它们出现在解决对象问题的下一步,一旦它们被创建。这些问题的例子如下:

  • 代理模式:"如何通过包装器控制对对象及其方法的访问,以及在顶部的行为?"

  • 组合模式:"如何使用相同的类同时表示部分和整体来表示由许多组件组成的对象,例如,一个 Widget 树?"

  • 行为模式:这些模式解决了对象在运行时交互产生的问题,以及它们如何分配责任。自然地,它们出现在后期阶段,一旦类被创建,然后组合成更大的结构。以下是一些例子:

  • 在这种情况下使用中介者模式:"确保所有对象在运行时使用松散耦合来相互引用,以促进交互的运行时动态性"

  • 在这种情况下使用观察者模式:"一个对象希望在资源的状态发生变化时得到通知,但它不想一直轮询资源来找到这一点。系统中可能有许多这样的对象实例"

注意

创建模式、结构模式和行为模式的顺序隐含地嵌入了系统中对象的生命周期。对象首先被创建(创建模式),然后组合成有用的结构(结构模式),然后它们相互作用(行为模式)。

让我们现在把注意力转向本章的主题,即以 Python 独特的方式在 Python 中实现模式。我们将看一个例子来开始讨论这个问题。

可插拔的哈希算法

让我们看一下以下的问题。

你想从输入流(文件或网络套接字)中读取数据,并以分块的方式对内容进行哈希。你写了一些像这样的代码:

# hash_stream.py
from hashlib import md5

def hash_stream(stream, chunk_size=4096):
    """ Hash a stream of data using md5 """

    shash = md5()

    for chunk in iter(lambda: stream.read(chunk_size), ''):
        shash.update(chunk)

    return shash.hexdigest()

注意

所有代码都是 Python3,除非另有明确说明。

>>> import hash_stream
>>> hash_stream.hash_stream(open('hash_stream.py'))
'e51e8ddf511d64aeb460ef12a43ce480'

所以这样做是符合预期的。

现在假设你想要一个更可重用和多功能的实现,可以与多个哈希算法一起使用。你首先尝试修改以前的代码,但很快意识到这意味着重写大量的代码,这不是一个很聪明的做法:

# hash_stream.py
from hashlib import sha1
from hashlib import md5

def hash_stream_sha1(stream, chunk_size=4096):
    """ Hash a stream of data using sha1 """

    shash = sha1()

    for chunk in iter(lambda: stream.read(chunk_size), ''):
        shash.update(chunk.encode('utf-8'))

    return shash.hexdigest()

def hash_stream_md5(stream, chunk_size=4096):
    """ Hash a stream of data using md5 """

    shash = md5()

    for chunk in iter(lambda: stream.read(chunk_size), ''):
        shash.update(chunk.encode('utf-8'))

    return shash.hexdigest()
>>> import hash_stream
>>> hash_stream.hash_stream_md5(open('hash_stream.py'))
'e752a82db93e145fcb315277f3045f8d'
>>> hash_stream.hash_stream_sha1(open('hash_stream.py'))
'360e3bd56f788ee1a2d8c7eeb3e2a5a34cca1710'

您会意识到,通过使用类,您可以重复使用大量代码。作为一名经验丰富的程序员,经过几次迭代后,您可能会得到类似这样的东西:

# hasher.py
class StreamHasher(object):
    """ Stream hasher class with configurable algorithm """

    def __init__(self, algorithm, chunk_size=4096):
        self.chunk_size = chunk_size
        self.hash = algorithm()

    def get_hash(self, stream):

        for chunk in iter(lambda: stream.read(self.chunk_size), ''):
            self.hash.update(chunk.encode('utf-8'))

        return self.hash.hexdigest()  

首先让我们尝试使用md5,如下所示:

>>> import hasher
>>> from hashlib import md5
>>> md5h = hasher.StreamHasher(algorithm=md5)
>>> md5h.get_hash(open('hasher.py'))
'7d89cdc1f11ec62ec918e0c6e5ea550d'

现在使用sha1

>>> from hashlib import sha1
>>> shah_h = hasher.StreamHasher(algorithm=sha1)
>>> shah_h.get_hash(open('hasher.py'))
'1f0976e070b3320b60819c6aef5bd6b0486389dd'

正如现在显而易见的那样,您可以构建不同的哈希对象,每个对象都有一个特定的算法,将返回流的相应哈希摘要(在这种情况下是文件)。

现在让我们总结一下我们刚刚做的事情。

我们首先开发了一个名为hash_stream的函数,它接受一个流对象,并使用md5算法逐块对其进行哈希。然后我们开发了一个名为StreamHasher的类,允许我们一次配置一个算法,从而使代码更可重用。我们通过get_hash方法获得哈希摘要,该方法接受流对象作为参数。

现在让我们把注意力转向 Python 可以为我们做的更多事情。

我们的类对于不同的哈希算法是多功能的,并且肯定更可重用,但是有没有一种方法可以像调用函数一样调用它?那将非常棒,不是吗?

这是我们的StreamHasher类的一个轻微重新实现,它就是这样做的:

# hasher.py
class StreamHasher(object):
    """ Stream hasher class with configurable algorithm """

    def __init__(self, algorithm, chunk_size=4096):
        self.chunk_size = chunk_size
        self.hash = algorithm()

    def __call__(self, stream):

        for chunk in iter(lambda: stream.read(self.chunk_size), ''):
            self.hash.update(chunk.encode('utf-8'))

        return self.hash.hexdigest() 

在上一段代码中我们做了什么?我们只是将get_hash函数重命名为Get_Call。让我们看看这会产生什么影响。

>>> from hashlib import md5, sha1
>>> md5_h = hasher.StreamHasher(md5)
>>> md5_h(open('hasher.py'))
'ad5d5673a3c9a4f421240c4dbc139b22'
>>> sha_h = hasher.StreamHasher(sha1)
>>> sha_h(open('hasher.py'))
'd174e2fae1d6e1605146ca9d7ca6ee927a74d6f2'

我们能够调用类的实例,就像调用函数一样,只需将文件对象传递给它。

因此,我们的类不仅为我们提供了可重用和多功能的代码,而且还可以像函数一样运行。这是通过在 Python 中使我们的类成为可调用类型来实现的,只需实现魔术方法__call__

注意

在 Python 中,可调用对象是指可以被调用的任何对象。换句话说,如果我们可以执行x(),那么x就是一个可调用对象,具体取决于__call__方法如何被覆盖,可以带参数也可以不带参数。函数是最简单和最熟悉的可调用对象。

在 Python 中,foo(args)foo.__call__(args)的一种语法糖。

总结可插拔的哈希算法

那么前面的例子说明了什么?它说明了 Python 的强大之处,它以一种更奇特和强大的方式解决了传统上在其他编程语言中解决的现有问题,这是由于 Python 的强大之处以及它的工作方式——在这种情况下,通过覆盖特殊方法使任何对象可调用。

但是我们在这里实现了什么模式?我们在本章开头讨论过,只有解决了一类问题,才能成为模式。这个特定的例子中是否隐藏着一种模式?

是的,这是策略行为模式的一种实现:

当我们需要从一个类中获得不同的行为,并且我们应该能够使用众多可用的行为或算法之一来配置一个类时,就会使用策略模式

在这种特殊情况下,我们需要一个支持使用不同算法执行相同操作的类——使用块从流中哈希数据,并返回摘要。该类接受算法作为参数,由于所有算法都支持相同的返回数据方法(hexdigest方法),我们能够以非常简单的方式实现该类。

让我们继续我们的旅程,找出使用 Python 编写的其他有趣模式,以及它独特解决问题的方式。在这个旅程中,我们将按照创建型、结构型和行为型模式的顺序进行。

注意

我们对接下来讨论的模式的方法非常务实。它可能不使用流行的四人帮G4)模式所使用的正式语言——这是设计模式的最基本方法。我们的重点是展示 Python 在构建模式方面的能力,而不是追求形式主义的正确性。

Python 中的模式-创建型

在本节中,我们将介绍一些常见的创建型模式。我们将从 Singleton 开始,然后按顺序进行原型、生成器和工厂。

单例模式

单例模式是设计模式中最著名和最容易理解的模式之一。它通常被定义为:

单例是一个只有一个实例和明确定义的访问点的类

单例的要求可以总结如下:

  • 一个类必须只有一个通过一个众所周知的访问点可访问的实例

  • 类必须可以通过继承进行扩展,而不会破坏模式

  • Python 中最简单的单例实现如下所示。它是通过重写基本object类型的__new__方法完成的:

# singleton.py
class Singleton(object):
    """ Singleton in Python """

    _instance = None

    def __new__(cls):
        if cls._instance == None:
            cls._instance = object.__new__(cls)
        return cls._instance
>>> from singleton import Singleton
>>> s1 = Singleton()
>>> s2 = Singleton()
>>> s1==s2
True

  • 由于我们将需要一段时间进行这个检查,让我们为此定义一个函数:
def test_single(cls):
    """ Test if passed class is a singleton """
    return cls() == cls()
  • 现在让我们看看我们的单例实现是否满足第二个要求。我们将定义一个简单的子类来测试这一点:
class SingletonA(Singleton):
    pass

>>> test_single(SingletonA)
True

太棒了!所以我们简单的实现通过了测试。我们现在完成了吗?

好吧,正如我们之前讨论过的,Python 提供了许多实现模式的方法,因为它的动态性和灵活性。所以,让我们继续关注单例一段时间,看看我们是否能得到一些有启发性的例子,这些例子会让我们了解 Python 的强大之处:

class MetaSingleton(type):
    """ A type for Singleton classes (overrides __call__) """    

    def __init__(cls, *args):
        print(cls,"__init__ method called with args", args)
        type.__init__(cls, *args)
        cls.instance = None

    def __call__(cls, *args, **kwargs):
        if not cls.instance:
            print(cls,"creating instance", args, kwargs)
            cls.instance = type.__call__(cls, *args, **kwargs)
        return cls.instance

class SingletonM(metaclass=MetaSingleton):
    pass

前面的实现将创建单例的逻辑移到了类的类型,即其元类。

我们首先创建了一个名为MetaSingleton的单例类型,通过扩展类型并在元类上重写__init____call__方法。然后我们声明SingletonM类,SingletonM,使用元类。

>>> from singleton import *
<class 'singleton.SingletonM'> __init__ method called with args ('SingletonM', (), {'__module__': 'singleton', '__qualname__': 'SingletonM'})
>>> test_single(SingletonM)
<class 'singleton.SingletonM'> creating instance ()
True

这里是一个对单例新实现背后发生的事情的一瞥:

  • 初始化类变量:我们可以在类级别(在类声明后)进行,就像我们在之前的实现中看到的那样,或者我们可以将其放在元类__init__方法中。这就是我们在这里为_instance类变量所做的,它将保存类的单个实例。

  • 覆盖类创建:可以在类级别通过重写类的__new__方法进行,就像我们在之前的实现中看到的那样,或者可以在元类中通过重写其__call__方法来进行。这就是新实现所做的。

注意

当我们重写一个类的__call__方法时,它会影响它的实例,并且实例变得可调用。同样,当我们重写元类的_call_方法时,它会影响它的类,并修改类被调用的方式-换句话说,类创建其实例的方式。

让我们来看看元类方法相对于类方法的优缺点:

  • 一个好处是我们可以创建任意数量的新顶级类,通过元类获得单例行为。使用默认实现,每个类都必须继承顶级类 Singleton 或其子类以获得单例行为。元类方法提供了更多关于类层次结构的灵活性。

  • 然而,与类方法相比,元类方法可能被解释为创建略微晦涩和难以维护的代码。这是因为了解元类和元编程的 Python 程序员数量较少,而了解类的程序员数量较多。这可能是元类解决方案的一个缺点。

现在让我们打破常规,看看我们是否可以以稍有不同的方式解决单例问题。

单例-我们需要单例吗?

让我们用一个与原始略有不同的方式来解释单例的第一个要求:

类必须提供一种让所有实例共享相同初始状态的方法。

为了解释这一点,让我们简要地看一下单例模式实际上试图实现什么。

当单例确保只有一个实例时,它保证的是类在创建和初始化时提供一个单一状态。换句话说,单例实际上提供的是一种让类确保所有实例共享单一状态的方式。

换句话说,单例的第一个要求可以用稍微不同的形式来表述,这与第一种形式有相同的结果。

一个类必须提供一种方法,使其所有实例共享相同的初始状态

确保在特定内存位置只有一个实际实例的技术只是实现这一点的一种方式。

啊!到目前为止,我们一直在用不太灵活和多用途的编程语言的实现细节来表达模式,实际上。使用 Python 这样的语言,我们不需要死板地坚持这个原始定义。

让我们看看以下类:

class Borg(object):
    """ I ain't a Singleton """

    __shared_state = {}
    def __init__(self):
        self.__dict__ = self.__shared_state

这种模式确保当你创建一个类时,你可以明确地用属于类的共享状态初始化它的所有实例(因为它是在类级别声明的)。

在单例中我们真正关心的是这种共享状态,所以Borg可以在不担心所有实例完全相同的情况下工作。

由于这是 Python,它通过在类上初始化一个共享状态字典,然后将实例的字典实例化为这个值来实现这一点,从而确保所有实例共享相同的状态。

以下是Borg实际操作的一个具体示例:

class IBorg(Borg):
    """ I am a Borg """

    def __init__(self):
        Borg.__init__(self)
        self.state = 'init'

    def __str__(self):
        return self.state

>>> i1 = IBorg()
>>> i2 = IBorg()
>>> print(i1)
init
>>> print(i2)
init
>>> i1.state='running'
>>> print(i2)
running
>>> print(i1)
running
>>> i1==i2
False

所以使用Borg,我们成功创建了一个类,其实例共享相同的状态,即使实例实际上并不相同。状态的改变也传播到了实例;正如前面的例子所示,当我们改变i1中的状态值时,i2中的状态值也会改变。

动态值呢?我们知道它在单例中可以工作,因为它总是相同的对象,但是波尔格呢?

>>> i1.x='test'
>>> i2.x
'test'

所以我们给实例i1附加了一个动态属性x,它也出现在实例i2中。很整洁!

所以让我们看看Borg是否比单例有任何好处:

  • 在一个复杂的系统中,我们可能有多个类从根单例类继承,由于导入问题或竞争条件(例如,如果系统正在使用线程),要求一个单一实例可能很难实现。波尔格模式通过巧妙地摆脱了内存中单一实例的要求,解决了这些问题。

  • 波尔格模式还允许在波尔格类和其所有子类之间简单共享状态。这对于单例来说并非如此,因为每个子类都创建自己的状态。我们将在接下来的示例中看到一个说明。

状态共享——波尔格与单例

波尔格模式总是从顶级类(波尔格)向下到所有子类共享相同的状态。这在单例中并非如此。让我们看一个例子。

在这个练习中,我们将创建我们原始单例类的两个子类,即SingletonASingletonB

>>> class SingletonA(Singleton): pass
... 
>>> class SingletonB(Singleton): pass
... 

让我们创建SingletonA的一个子类,即SingletonA1

>>> class SingletonA1(SingletonA): pass
...

现在让我们创建实例:

>>> a = SingletonA()
>>> a1 = SingletonA1()
>>> b = SingletonB()

让我们给a附加一个值为 100 的动态属性x

>>> a.x = 100
>>> print(a.x)
100

让我们检查一下子类SingletonA1的实例a1上是否可用:

>>> a1.x
100

好了!现在让我们检查它是否在实例b上可用:

>>> b.x
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'SingletonB' object has no attribute 'x'

糟糕!看起来SingletonASingletonB并不共享相同的状态。这就是为什么附加到SingletonA实例的动态属性会出现在其子类的实例上,但不会出现在同级或同级子类SingletonB的实例上的原因——因为它是类层次结构中与顶级Singleton类不同的分支。

让我们看看波尔格是否能做得更好。

首先,让我们创建类和它们的实例:

>>> class ABorg(Borg):pass
... 
>>> class BBorg(Borg):pass
... 
>>> class A1Borg(ABorg):pass
... 
>>> a = ABorg()
>>> a1 = A1Borg()
>>> b = BBorg()

现在让我们给a附加一个值为 100 的动态属性 x:

>>> a.x = 100
>>> a.x
100
>>> a1.x
100

让我们检查同级类波尔格的实例是否也有它:

>>> b.x
100

这证明了 Borg 模式在跨类和子类之间共享状态方面比 Singleton 模式更好,并且这样做不需要大量的麻烦或确保单个实例的开销。

现在让我们转向其他创建模式。

工厂模式

工厂模式解决了创建与另一个类相关的类的实例的问题,通常通过单个方法实现实例创建,通常在父工厂类上定义,并由子类(根据需要)覆盖。

工厂模式为类的客户(用户)提供了一个方便的方式,通过Factory类的特定方法传递参数,通常是通过创建类和子类的实例的单个入口点。

让我们看一个具体的例子:

from abc import ABCMeta, abstractmethod

class Employee(metaclass=ABCMeta):
    """ An Employee class """

    def __init__(self, name, age, gender):
        self.name = name
        self.age = age
        self.gender = gender

    @abstractmethod
    def get_role(self):
        pass

    def __str__(self):
        return "{} - {}, {} years old {}".format(self.__class__.__name__,
                                                 self.name,
                                                 self.age,
                                                 self.gender)

class Engineer(Employee):
    """ An Engineer Employee """

    def get_role(self):
        return "engineering"

class Accountant(Employee):
    """ An Accountant Employee """

    def get_role(self):
        return "accountant" 

class Admin(Employee):
    """ An Admin Employee """

    def get_role(self):
        return "administration"

我们创建了一个通用的Employee类,具有一些属性和三个子类,分别是EngineerAccountantAdmin

由于它们都是相关类,因此Factory类对于抽象化这些类的实例创建非常有用。

这是我们的EmployeeFactory类:

class EmployeeFactory(object):
    """ An Employee factory class """

    @classmethod
    def create(cls, name, *args):
        """ Factory method for creating an Employee instance """

        name = name.lower().strip()

        if name == 'engineer':
            return Engineer(*args)
        elif name == 'accountant':
            return Accountant(*args)
        elif name == 'admin':
            return Admin(*args)

该类提供了一个create工厂方法,接受一个name参数,该参数与类的名称匹配,并相应地创建实例。其余参数是实例化类实例所需的参数,这些参数不变地传递给其构造函数。

让我们看看我们的Factory类如何运作:

>>> factory = EmployeeFactory()
>>> print(factory.create('engineer','Sam',25,'M'))
Engineer - Sam, 25 years old M
>>> print(factory.create('engineer','Tracy',28,'F'))
Engineer - Tracy, 28 years old F

>>> accountant = factory.create('accountant','Hema',39,'F')
>>> print(accountant)

Accountant - Hema, 39 years old F
>>> accountant.get_role()

accounting
>>> admin = factory.create('Admin','Supritha',32,'F')
>>> admin.get_role()
'administration'

以下是关于我们的Factory类的一些有趣的注释:

  • 单个工厂类可以创建员工层次结构中任何类的实例。

  • 在工厂模式中,通常使用一个与类族(类及其子类层次结构)相关联的Factory类是常规做法。例如,Person类可以使用PersonFactory,汽车类可以使用AutomobileFactory,依此类推。

  • 在 Python 中,工厂方法通常被装饰为classmethod。这样可以直接通过类命名空间调用它。例如:

    >>> print(EmployeeFactory.create('engineer','Vishal',24,'M'))
    Engineer - Vishal, 24 years old M

换句话说,这种模式实际上不需要Factory类的实例。

原型模式

原型设计模式允许程序员创建一个类的实例作为模板实例,然后通过复制或克隆该原型来创建新实例。

原型在以下情况下最有用:

  • 当系统中实例化的类是动态的,即作为配置的一部分指定,或者在运行时可以发生变化时。

  • 当实例只有少量初始状态的组合时。与跟踪状态并每次实例化一个实例相比,更方便的是创建与每个状态匹配的原型并进行克隆。

原型对象通常支持通过clone方法复制自身。

以下是 Python 中原型的简单实现:

import copy

class Prototype(object):
    """ A prototype base class """

    def clone(self):
        """ Return a clone of self """
        return copy.deepcopy(self)

clone方法使用copy模块实现,该模块深度复制对象并返回克隆。

让我们看看这是如何工作的。为此,我们需要创建一个有意义的子类:

class Register(Prototype):
    """ A student Register class  """

    def __init__(self, names=[]):
        self.names = names

>>> r1=Register(names=['amy','stu','jack'])
>>> r2=r1.clone()
>>> print(r1)
<prototype.Register object at 0x7f42894e0128>
>>> print(r2)
<prototype.Register object at 0x7f428b7b89b0>

>>> r2.__class__
<class 'prototype.Register'>

原型-深复制与浅复制

现在让我们更深入地了解我们的原型类的实现细节。

您可能注意到我们使用copy模块的deepcopy方法来实现对象克隆。该模块还有一个copy方法,用于实现浅复制。

如果我们实现浅复制,您会发现所有对象都是通过引用复制的。对于不可变对象(如字符串或元组),这是可以接受的。

然而,对于像列表或字典这样的可变对象来说,这是一个问题,因为实例的状态是共享的,而不是完全由实例拥有的,对一个实例中可变对象的修改也会同时修改克隆实例中的相同对象!

让我们看一个例子。我们将使用我们的原型类的修改实现,该实现使用浅复制来演示这一点:

class SPrototype(object):
    """ A prototype base class using shallow copy """

    def clone(self):
        """ Return a clone of self """
        return copy.copy(self)

SRegister类继承自新的原型类:

class SRegister(SPrototype):
    """ Sub-class of SPrototype """

    def __init__(self, names=[]):
        self.names = names

>>> r1=SRegister(names=['amy','stu','jack'])
>>> r2=r1.clone()

让我们给r1实例的名称注册一个名称:

>>> r1.names.append('bob')

现在让我们检查r2.names

>>> r2.names
['amy', 'stu', 'jack', 'bob']

哎呀!这不是我们想要的,但由于浅拷贝,r1r2最终共享相同的names列表,因为只复制了引用,而不是整个对象。可以通过简单的检查来验证:

>>> r1.names is r2.names
True

另一方面,深拷贝会对克隆的对象中包含的所有对象递归调用copy,因此没有任何共享,但每个克隆最终都会有自己的所有引用对象的副本。

使用元类构建原型

我们已经看到如何使用类构建原型模式。由于我们已经在单例模式示例中看到了 Python 中的一些元编程,因此有助于找出我们是否可以在原型中做同样的事情。

我们需要做的是将clone方法附加到所有原型类上。像这样动态地将方法附加到类中可以通过元类的__init__方法来完成。

这提供了使用元类的原型的简单实现:

import copy

class MetaPrototype(type):

    """ A metaclass for Prototypes """

    def __init__(cls, *args):
        type.__init__(cls, *args)
        cls.clone = lambda self: copy.deepcopy(self) 

class PrototypeM(metaclass=MetaPrototype):
    pass

PrototypeM类现在实现了原型模式。让我们通过使用一个子类来进行说明:

class ItemCollection(PrototypeM):
    """ An item collection class """

    def __init__(self, items=[]):
        self.items = items

首先我们将创建一个ItemCollection对象:

>>> i1=ItemCollection(items=['apples','grapes','oranges'])
>>> i1
<prototype.ItemCollection object at 0x7fd4ba6d3da0>

现在我们将克隆它如下:

>>> i2 = i1.clone()

克隆显然是一个不同的对象:

>>> i2
<prototype.ItemCollection object at 0x7fd4ba6aceb8>

它有自己的属性副本:

>>> i2.items is i1.items
False

使用元类组合模式

通过使用元类的强大功能,可以创建有趣和定制的模式。以下示例说明了一种既是单例又是原型的类型:

class MetaSingletonPrototype(type):
    """ A metaclass for Singleton & Prototype patterns """

    def __init__(cls, *args):
        print(cls,"__init__ method called with args", args)
        type.__init__(cls, *args)
        cls.instance = None
        cls.clone = lambda self: copy.deepcopy(cls.instance)

    def __call__(cls, *args, **kwargs):
        if not cls.instance:
            print(cls,"creating prototypical instance", args, kwargs)
            cls.instance = type.__call__(cls,*args, **kwargs)
        return cls.instance

使用这个元类作为其类型的任何类都会显示单例和原型行为。

这可能看起来有点奇怪,因为一个单例只允许一个实例,而原型允许克隆来派生多个实例,但是如果我们从它们的 API 来考虑模式,那么它开始感觉更自然一些:

  • 使用构造函数调用类总是会返回相同的实例 - 它的行为就像单例模式。

  • 在类的实例上调用clone总是会返回克隆的实例。实例总是使用单例实例作为源进行克隆 - 它的行为就像原型模式。

在这里,我们修改了我们的PrototypeM类,现在使用新的元类:

class PrototypeM(metaclass=MetaSingletonPrototype):
    pass

由于ItemCollection继续子类化PrototypeM,它会自动获得新的行为。

看一下以下代码:

>>> i1=ItemCollection(items=['apples','grapes','oranges'])
<class 'prototype.ItemCollection'> creating prototypical instance () {'items': ['apples'
, 'grapes', 'oranges']}
>>> i1
<prototype.ItemCollection object at 0x7fbfc033b048>
>>> i2=i1.clone()

clone方法按预期工作,并产生一个克隆:

>>> i2
<prototype.ItemCollection object at 0x7fbfc033b080>
>>> i2.items is i1.items
False

然而,通过构造函数构建实例总是只返回单例(原型)实例,因为它调用了单例 API:

>>> i3=ItemCollection(items=['apples','grapes','mangoes'])
>>> i3 is i1
True

元类允许对类的创建进行强大的定制。在这个具体的例子中,我们通过元类将单例和原型模式的行为组合到一个类中。Python 使用元类的强大功能使程序员能够超越传统模式,提出这样的创造性技术。

原型工厂

原型类可以通过一个辅助的原型工厂注册类进行增强,它可以提供用于创建配置的产品系列或产品组的原型实例的工厂函数。将其视为我们以前工厂模式的变体。

这是这个类的代码。看到我们从Borg继承它以自动共享状态从层次结构的顶部:

class PrototypeFactory(Borg):
    """ A Prototype factory/registry class """

    def __init__(self):
        """ Initializer """

        self._registry = {}

    def register(self, instance):
        """ Register a given instance """

        self._registry[instance.__class__] = instance

    def clone(self, klass):
        """  Return cloned instance of given class """

        instance = self._registry.get(klass)
        if instance == None:
            print('Error:',klass,'not registered')
        else:
            return instance.clone()

让我们创建一些原型的子类,我们可以在工厂上注册它们的实例:

class Name(SPrototype):
    """ A class representing a person's name """

    def __init__(self, first, second):
        self.first = first
        self.second = second

    def __str__(self):
        return ' '.join((self.first, self.second))

class Animal(SPrototype):
    """ A class representing an animal """

    def __init__(self, name, type='Wild'):
        self.name = name
        self.type = type

    def __str__(self):
        return ' '.join((str(self.type), self.name))

我们有两个类 - 一个是Name类,另一个是动物类,两者都继承自SPrototype

首先创建一个名称和动物对象:

>>> name = Name('Bill', 'Bryson')
>>> animal = Animal('Elephant')
>>> print(name)
Bill Bryson
>>> print(animal)
Wild Elephant

现在,让我们创建一个原型工厂的实例。

>>> factory = PrototypeFactory()

现在让我们在工厂上注册这两个实例:

>>> factory.register(animal)
>>> factory.register(name)

现在工厂已经准备好从配置的实例中克隆任意数量的实例:

>>> factory.clone(Name)
<prototype.Name object at 0x7ffb552f9c50>

>> factory.clone(Animal)
<prototype.Animal object at 0x7ffb55321a58>

工厂如果尝试克隆未注册实例的类,会合理地抱怨:

>>> class C(object): pass
... 
>>> factory.clone(C)
Error: <class '__main__.C'> not registered

注意

这里显示的工厂类可以通过检查已注册类上的clone方法的存在来增强,以确保任何注册的类都遵守原型类的 API。这留给读者作为练习。

如果读者还没有注意到,讨论我们选择的这个特定示例的一些方面是很有启发性的:

  • PrototypeFactory类是一个工厂类,因此通常是一个单例。在这种情况下,我们将其制作成了一个 Borg,因为我们已经看到Borgs在类层次结构之间的状态共享方面做得更好。

  • Name类和Animal类继承自SPrototype,因为它们的属性是不可变的整数和字符串,所以在这里浅复制就可以了。这与我们的第一个原型子类不同。

  • 原型保留了原型实例中的类创建签名,即clone方法。这使得程序员很容易,因为他/她不必担心类创建签名——__new__的顺序和类型,因此也不必调用__init__方法——只需在现有实例上调用clone即可。

建造者模式

建造者模式将对象的构建与其表示(组装)分离,以便可以使用相同的构建过程来构建不同的表示。

换句话说,使用建造者模式,可以方便地创建同一类的不同类型或代表性实例,每个实例使用略有不同的构建或组装过程。

形式上,建造者模式使用一个Director类,该类指导Builder对象构建目标类的实例。不同类型(类)的构建者有助于构建同一类的略有不同的变体。

让我们看一个例子:

class Room(object):
    """ A class representing a Room in a house """

    def __init__(self, nwindows=2, doors=1, direction='S'):
        self.nwindows = nwindows
        self.doors = doors
        self.direction = direction

    def __str__(self):
        return "Room <facing:%s, windows=#%d>" % (self.direction,
                                                  self.nwindows)
class Porch(object):
    """ A class representing a Porch in a house """

    def __init__(self, ndoors=2, direction='W'):
        self.ndoors = ndoors
        self.direction = direction

    def __str__(self):
        return "Porch <facing:%s, doors=#%d>" % (self.direction,
                                                 self.ndoors)   

class LegoHouse(object):
    """ A lego house class """

    def __init__(self, nrooms=0, nwindows=0,nporches=0):
        # windows per room
        self.nwindows = nwindows
        self.nporches = nporches
        self.nrooms = nrooms
        self.rooms = []
        self.porches = []

    def __str__(self):
        msg="LegoHouse<rooms=#%d, porches=#%d>" % (self.nrooms,
                                                   self.nporches)

        for i in self.rooms:
            msg += str(i)

        for i in self.porches:
            msg += str(i)

        return msg

    def add_room(self,room):
        """ Add a room to the house """

        self.rooms.append(room)

    def add_porch(self,porch):
        """ Add a porch to the house """

        self.porches.append(porch)

我们的示例显示了三个类,它们分别是:

  • RoomPorch类分别表示房子的房间和门廊——房间有窗户和门,门廊有门

  • LegoHouse类代表了一个玩具示例,用于实际房子(我们想象一个孩子用乐高积木建造房子,有房间和门廊。)——乐高房子将包括任意数量的房间和门廊。

让我们尝试创建一个简单的LegoHouse实例,其中有一个房间和一个门廊,每个都有默认配置:

>>> house = LegoHouse(nrooms=1,nporches=1)
>>> print(house)
LegoHouse<rooms=#1, porches=#1>

我们完成了吗?没有!请注意,我们的LegoHouse是一个在其构造函数中并没有完全构建自身的类。房间和门廊实际上还没有建好,只是它们的计数器被初始化了。

因此,我们需要分别建造房间和门廊,并将它们添加到房子中。让我们来做:

>>> room = Room(nwindows=1)
>>> house.add_room(room)
>>> porch = Porch()
>>> house.add_porch(porch)
>>> print(house)
LegoHouse<rooms=#1, porches=#1>
Room <facing:S, windows=#1>
Porch <facing:W, doors=#1>

现在你看到我们的房子已经建好了。打印它不仅显示了房间和门廊的数量,还显示了有关它们的详细信息。一切顺利!

现在,想象一下你需要建造 100 个这样不同配置的房子实例,每个实例的房间和门廊配置都不同,而且房间本身的窗户数量和方向也经常不同!

(也许你正在制作一个移动游戏,其中使用乐高房子,可爱的小角色像巨魔或小黄人住在里面,并做有趣的事情,无论是什么。)

从示例中很明显,编写像最后一个示例那样的代码将无法解决问题。

这就是建造者模式可以帮助你的地方。让我们从一个简单的LegoHouse构建者开始。

class LegoHouseBuilder(object):
    """ Lego house builder class """

    def __init__(self, *args, **kwargs):
        self.house = LegoHouse(*args, **kwargs)

    def build(self):
        """ Build a lego house instance and return it """

        self.build_rooms()
        self.build_porches()
        return self.house

    def build_rooms(self):
        """ Method to build rooms """

        for i in range(self.house.nrooms):
            room = Room(self.house.nwindows)
            self.house.add_room(room)

    def build_porches(self):
        """ Method to build porches """     

        for i in range(self.house.nporches):
            porch = Porch(1)
            self.house.add_porch(porch)

这个类的主要方面如下:

  • 你可以使用目标类配置来配置构建者类——在这种情况下是房间和门廊的数量

  • 它提供了一个build方法,根据指定的配置构建和组装(建造)房子的组件,即RoomsPorches

  • build方法返回构建和组装好的房子

现在用两行代码构建不同类型的乐高房子,每种类型的房子都有不同的房间和门廊设计:

>>> builder=LegoHouseBuilder(nrooms=2,nporches=1,nwindows=1)
>>> print(builder.build())
LegoHouse<rooms=#2, porches=#1>
Room <facing:S, windows=#1>
Room <facing:S, windows=#1>
Porch <facing:W, doors=#1>

我们现在将建造一个类似的房子,但是房间里有两扇窗户:

>>> builder=LegoHouseBuilder(nrooms=2,nporches=1,nwindows=2)
>>> print(builder.build())
LegoHouse<rooms=#2, porches=#1>
Room <facing:S, windows=#2>
Room <facing:S, windows=#2>
Porch <facing:W, doors=#1>

假设您发现自己继续使用这个配置构建了许多乐高房子。您可以将其封装在构建者的子类中,这样前面的代码就不会重复很多次:

class SmallLegoHouseBuilder(LegoHouseBuilder):
""" Builder sub-class building small lego house with 1 room and 1porch and rooms having 2 windows """

    def __init__(self):
        self.house = LegoHouse(nrooms=2, nporches=1, nwindows=2)        

现在,房屋配置被固定到新的构建者类中,构建一个就像这样简单:

>>> small_house=SmallLegoHouseBuilder().build()
>>> print(small_house)
LegoHouse<rooms=#2, porches=#1>
Room <facing:S, windows=#2>
Room <facing:S, windows=#2>
Porch <facing:W, doors=#1>

您也可以构建许多这样的实例(比如10050用于巨魔,50用于小黄人):

>>> houses=list(map(lambda x: SmallLegoHouseBuilder().build(), range(100)))
>>> print(houses[0])
LegoHouse<rooms=#2, porches=#1>
Room <facing:S, windows=#2>
Room <facing:S, windows=#2>
Porch <facing:W, doors=#1>

>>> len(houses)
100

人们还可以创建更奇特的构建者类,做一些非常特定的事情。例如,这里有一个构建者类,它创建的房屋的房间和门廊总是朝向北方:

class NorthFacingHouseBuilder(LegoHouseBuilder):
    """ Builder building all rooms and porches facing North """

    def build_rooms(self):

        for i in range(self.house.nrooms):
            room = Room(self.house.nwindows, direction='N')
            self.house.add_room(room)

    def build_porches(self):

        for i in range(self.house.nporches):
            porch = Porch(1, direction='N')
            self.house.add_porch(porch)

>>> print(NorthFacingHouseBuilder(nrooms=2, nporches=1, nwindows=1).build())
LegoHouse<rooms=#2, porches=#1>
Room <facing:N, windows=#1>
Room <facing:N, windows=#1>
Porch <facing:N, doors=#1>

利用 Python 的多重继承功能,可以将任何这样的构建者组合成新的有趣的子类。例如,这里有一个构建者,它产生朝北的小房子:

class NorthFacingSmallHouseBuilder(NorthFacingHouseBuilder, SmallLegoHouseBuilder):
    pass

正如预期的那样,它总是重复产生朝北的小房子,有 2 个有窗的房间。也许不是很有趣,但确实非常可靠:

>>> print(NorthFacingSmallHouseBuilder().build())
LegoHouse<rooms=#2, porches=#1>
Room <facing:N, windows=#2>
Room <facing:N, windows=#2>
Porch <facing:N, doors=#1>

在我们结束对创建模式的讨论之前,让我们总结一些有趣的方面,以及它们之间的相互作用,如下所示:

  • 构建者和工厂:构建者模式将类的实例的组装过程与其创建分离。另一方面,工厂关注使用统一接口创建属于同一层次结构的不同子类的实例。构建者还将构建的实例作为最后一步返回,而工厂则立即返回实例,因为没有单独的构建步骤。

  • 构建者和原型:构建者可以在内部使用原型来创建其实例。然后可以从该实例克隆同一构建者的更多实例。例如,建立一个使用我们的原型元类之一始终克隆原型实例的构建者类是很有启发性的。

  • 原型和工厂:原型工厂可以在内部使用工厂模式来构建所讨论类的初始实例。

  • 工厂和单例:工厂类通常是传统编程语言中的单例。另一个选项是将其方法设置为类或静态方法,因此无需创建工厂本身的实例。在我们的示例中,我们将其设置为 Borg。

我们现在将转移到下一个模式类,即结构模式。

Python 中的模式-结构

结构模式关注于组合类或对象以形成更大的结构的复杂性,这些结构不仅仅是它们各自部分的总和。

结构模式通过这两种不同的方式来实现这一点:

  • 通过使用类继承将类组合成一个。这是一种静态的方法。

  • 通过在运行时使用对象组合来实现组合功能。这种方法更加动态和灵活。

由于支持多重继承,Python 可以很好地实现这两种功能。作为一种具有动态属性并使用魔术方法的语言,Python 也可以很好地进行对象组合和由此产生的方法包装。因此,使用 Python,程序员确实处于一个很好的位置,可以实现结构模式。

在本节中,我们将讨论以下结构模式:适配器,外观和代理。

适配器模式

顾名思义,适配器模式将特定接口的现有实现包装或适配到客户端期望的另一个接口中。适配器也被称为包装器

在编程时,您经常会将对象适配到您想要的接口或类型中,而往往并不自知。

例如:

看看这个包含两个水果实例及其数量的列表:

>>> fruits=[('apples',2), ('grapes',40)]

假设您想快速找到水果的数量,给定水果名称。列表不允许您将水果用作键,这是更适合操作的接口。

你该怎么办?嗯,你只需将列表转换为字典:

>>> fruits_d=dict(fruits)
>>> fruits_d['apples']
2

看!你得到了一个更方便的对象形式,适应了你的编程需求。这是一种数据或对象适应。

程序员在他们的代码中几乎不断地进行数据或对象适应,而并没有意识到。代码或数据的适应比你想象的更常见。

让我们考虑一个多边形类,表示任何形状的正规或不规则多边形:

class Polygon(object):
    """ A polygon class """

    def __init__(self, *sides):
        """ Initializer - accepts length of sides """
        self.sides = sides

    def perimeter(self):
        """ Return perimeter """

        return sum(self.sides)

    def is_valid(self):
        """ Is this a valid polygon """

        # Do some complex stuff - not implemented in base class
        raise NotImplementedError

    def is_regular(self):
        """ Is a regular polygon ? """

        # True: if all sides are equal
        side = self.sides[0]
        return all([x==side for x in self.sides[1:]])

    def area(self):
        """ Calculate and return area """

        # Not implemented in base class
        raise NotImplementedError

前面的类描述了几何学中的一个通用的封闭多边形图形。

注意

我们已经实现了一些基本方法,如perimeteris_regular,后者返回多边形是否是正规的,比如六边形或五边形。

假设我们想要为一些常见的几何形状(如三角形或矩形)实现特定的类。当然,我们可以从头开始实现这些。但是,由于有一个多边形类可用,我们可以尝试重用它,并根据我们的需求进行适应。

假设Triangle类需要以下方法:

  • is_equilateral:返回三角形是否是等边三角形

  • is_isosceles:返回三角形是否是等腰三角形

  • is_valid:实现了三角形的is_valid方法

  • area:实现了三角形的面积方法

同样,Rectangle类需要以下方法:

  • is_square:返回矩形是否为正方形

  • is_valid:实现了矩形的is_valid方法

  • area:实现了矩形的面积方法

以下是适配器模式的代码,重用Polygon类用于TriangleRectangle类。

以下是Triangle类的代码:

import itertools 

class InvalidPolygonError(Exception):
    pass

class Triangle(Polygon):
    """ Triangle class from Polygon using class adapter """

    def is_equilateral(self):
        """ Is this an equilateral triangle ? """

        if self.is_valid():
            return super(Triangle, self).is_regular()

    def is_isosceles(self):
        """ Is the triangle isosceles """

        if self.is_valid():
            # Check if any 2 sides are equal
            for a,b in itertools.combinations(self.sides, 2):
                if a == b:
                    return True
        return False

    def area(self):
        """ Calculate area """

        # Using Heron's formula
        p = self.perimeter()/2.0
        total = p
        for side in self.sides:
            total *= abs(p-side)

        return pow(total, 0.5)

    def is_valid(self):
        """ Is the triangle valid """

        # Sum of 2 sides should be > 3rd side
        perimeter = self.perimeter()
        for side in self.sides:
            sum_two = perimeter - side
            if sum_two <= side:
                raise InvalidPolygonError(str(self.__class__) + "is invalid!")

        return True

看一下以下的Rectangle类:

class Rectangle(Polygon):
    """ Rectangle class from Polygon using class adapter """

    def is_square(self):
        """ Return if I am a square """

        if self.is_valid():
            # Defaults to is_regular
            return self.is_regular()

    def is_valid(self):
        """ Is the rectangle valid """

        # Should have 4 sides
        if len(self.sides) != 4:
            return False

        # Opposite sides should be same
        for a,b in [(0,2),(1,3)]:
            if self.sides[a] != self.sides[b]:
                return False

        return True

    def area(self):
        """ Return area of rectangle """

        # Length x breadth
        if self.is_valid():
            return self.sides[0]*self.sides[1]

现在让我们看看这些类的实际应用。

让我们为第一次测试创建一个等边三角形:

>>> t1 = Triangle(20,20,20)
>>> t1.is_valid()
True

等边三角形也是等腰三角形:

>>> t1.is_equilateral()
True
>>> t1.is_isosceles()
True

让我们计算面积:

>>> t1.area()
173.20508075688772

让我们尝试一个无效的三角形:

>>> t2 = Triangle(10, 20, 30)
>>> t2.is_valid()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/home/anand/Documents/ArchitectureBook/code/chap7/adapter.py", line 75, in is_valid
    raise InvalidPolygonError(str(self.__class__) + "is invalid!")
adapter.InvalidPolygonError: <class 'adapter.Triangle'>is invalid!

注意

尺寸显示这是一条直线,而不是一个三角形。is_valid方法没有在基类中实现,因此子类需要重写它以提供适当的实现。在这种情况下,如果三角形无效,我们会引发一个异常。

以下是Rectangle类的示例:

>>> r1 = Rectangle(10,20,10,20)
>>> r1.is_valid()
True
>>> r1.area()
200
>>> r1.is_square()
False
>>> r1.perimeter()
60

让我们创建一个正方形:

>>> r2 = Rectangle(10,10,10,10)
>>> r2.is_square()
True

这里显示的Rectangle/Triangle类是类适配器的示例。这是因为它们继承了它们想要适应的类,并提供了客户端期望的方法,通常将计算委托给基类的方法。这在TriangleRectangle类的is_equilateralis_square方法中是明显的。

让我们看一下相同类的另一种实现方式——这次是通过对象组合,换句话说,对象适配器

import itertools

class Triangle (object) :
    """ Triangle class from Polygon using class adapter """

    def __init__(self, *sides):
        # Compose a polygon
        self.polygon = Polygon(*sides)

    def perimeter(self):
        return self.polygon.perimeter()

    def is_valid(f):
        """ Is the triangle valid """

        def inner(self, *args):
            # Sum of 2 sides should be > 3rd side
            perimeter = self.polygon.perimeter()
            sides = self.polygon.sides

            for side in sides:
                sum_two = perimeter - side
                if sum_two <= side:
                    raise InvalidPolygonError(str(self.__class__) + "is invalid!")

            result = f(self, *args)
            return result

        return inner

    @is_valid
    def is_equilateral(self):
        """ Is this equilateral triangle ? """

        return self.polygon.is_regular()

    @is_valid
    def is_isosceles(self):
        """ Is the triangle isoscles """

        # Check if any 2 sides are equal
        for a,b in itertools.combinations(self.polygon.sides, 2):
            if a == b:
                return True
        return False

    def area(self):
        """ Calculate area """

        # Using Heron's formula
        p = self.polygon.perimeter()/2.0
        total = p
        for side in self.polygon.sides:
            total *= abs(p-side)

        return pow(total, 0.5)

这个类与另一个类类似,尽管内部细节是通过对象组合而不是类继承实现的:

>>> t1=Triangle(2,2,2)
>>> t1.is_equilateral()
True
>>> t2 = Triangle(4,4,5)
>>> t2.is_equilateral()
False
>>> t2.is_isosceles()
True

这个实现与类适配器的主要区别如下:

  • 对象适配器类不继承我们想要适应的类。相反,它组合了该类的一个实例。

  • 任何包装方法都会转发到组合实例。例如,perimeter方法。

  • 在这个实现中,封装实例的所有属性访问都必须明确指定。没有什么是免费的(因为我们没有继承该类)。 (例如,检查我们如何访问封闭的polygon实例的sides属性的方式。)。

注意

观察我们如何将以前的is_valid方法转换为此实现中的装饰器。这是因为许多方法首先对is_valid进行检查,然后执行它们的操作,因此它是装饰器的理想候选者。这也有助于将此实现重写为更方便的形式,下面将讨论这一点。

对象适配器实现的一个问题是,对封闭的适配实例的任何属性引用都必须显式进行。例如,如果我们在这里忘记为Triangle类实现perimeter方法,那么根本就没有方法可供调用,因为我们没有从Adapter类继承。

以下是另一种实现,它利用了 Python 的一个魔术方法__getattr__的功能,以简化这个过程。我们在Rectangle类上演示这个实现:

class Rectangle(object):
    """ Rectangle class from Polygon using object adapter """

    method_mapper = {'is_square': 'is_regular'}

    def __init__(self, *sides):
        # Compose a polygon
        self.polygon = Polygon(*sides)

    def is_valid(f):
        def inner(self, *args):
            """ Is the rectangle valid """

            sides = self.sides
            # Should have 4 sides
            if len(sides) != 4:
                return False

            # Opposite sides should be same
            for a,b in [(0,2),(1,3)]:
                if sides[a] != sides[b]:
                    return False

            result = f(self, *args)
            return result

        return inner

    def __getattr__(self, name):
        """ Overloaded __getattr__ to forward methods to wrapped instance """

        if name in self.method_mapper:
            # Wrapped name
            w_name = self.method_mapper[name]
            print('Forwarding to method',w_name)
            # Map the method to correct one on the instance
            return getattr(self.polygon, w_name)
        else:
            # Assume method is the same
            return getattr(self.polygon, name)

    @is_valid
    def area(self):
        """ Return area of rectangle """

        # Length x breadth
        sides = self.sides      
        return sides[0]*sides[1]

让我们看看使用这个类的例子:

>>> r1=Rectangle(10,20,10,20)
>>> r1.perimeter()
60
>>> r1.is_square()
Forwarding to method is_regular
False

您可以看到,我们能够在Rectangle实例上调用is_perimeter方法,即使在类上实际上并没有定义这样的方法。同样,is_square似乎也能奇迹般地工作。这里发生了什么?

如果 Python 在通常的方式下找不到对象的属性,它会调用魔术方法__getattr__。它接受一个名称,因此为类提供了一个挂钩,以实现通过将方法查找路由到其他对象的方式。

在这种情况下,__getattr__方法执行以下操作:

  • method_mapper字典中检查属性名称。这是一个我们在类上创建的字典,将我们想要在类上调用的方法名称(作为键)映射到包装实例上的实际方法名称(作为值)。如果找到条目,则返回它。

  • 如果在method_mapper字典中找不到条目,则将条目原样传递给包装实例,以便按相同的名称查找。

  • 我们在两种情况下都使用getattr来查找并从包装实例返回属性。

  • 属性可以是任何东西——数据属性或方法。例如,看看我们如何在方法areais_valid装饰器中将包装的polygon实例的sides属性称为属于Rectangle类的属性。

  • 如果在包装实例上不存在属性,则会引发AttributeError

    >>> r1.convert_to_parallelogram(angle=30)
    Traceback (most recent call last):
      File "<stdin>", line 1, in <module>
     File "adapter_o.py", line 133, in __getattr__
        return getattr(self.polygon, name)
    AttributeError: 'Polygon' object has no attribute 'convert_to_parallelogram'

使用这种技术实现的对象适配器更加灵活,比常规对象适配器需要编写每个方法并将其转发到包装实例的代码量要少。

外观模式

外观是一种结构模式,为子系统中的多个接口提供统一接口。外观模式在系统由多个子系统组成,每个子系统都有自己的接口,但需要捕获一些高级功能作为通用顶层接口提供给客户端时非常有用。

一个日常生活中的经典例子是汽车,它是一个外观。

例如,汽车由发动机、动力传动系统、轴和车轮组件、电子设备、转向系统、制动系统和其他组件组成。

然而,通常情况下,你并不需要担心你的汽车刹车是盘式刹车,还是悬架是螺旋弹簧或麦弗逊减震器,对吧?

这是因为汽车制造商为您提供了一个外观,以操作和维护汽车,从而减少了复杂性,并为您提供了更简单的子系统,这些子系统本身很容易操作,例如:

  • 启动汽车的点火系统

  • 用于操纵它的转向系统

  • 控制它的离合器-油门-刹车系统

  • 管理动力和速度的齿轮和传动系统

我们周围有很多复杂的系统都是外观。就像汽车的例子一样,计算机是一个外观,工业机器人是另一个。所有工厂控制系统都是外观,为工程师提供了一些仪表板和控件,以调整其背后的复杂系统,并使其保持运行。

Python 中的外观

Python 标准库包含许多模块,它们是外观的很好的例子。compiler模块提供了解析和编译 Python 源代码的钩子,是词法分析器、解析器、ast 树生成器等的外观。

以下是此模块的帮助内容的图像。

Python 中的外观

在帮助内容的下一页中,您可以看到这个模块是其他模块的外观,这些模块用于实现此包中定义的函数。(查看图像底部的“包内容”):

Python 中的外观

让我们看一个外观模式的示例代码。在这个例子中,我们将模拟一辆汽车及其多个子系统中的一些。

这是所有子系统的代码:

class Engine(object):
    """ An Engine class """

    def __init__(self, name, bhp, rpm, volume, cylinders=4, type='petrol'):
        self.name = name
        self.bhp = bhp
        self.rpm = rpm
        self.volume = volume
        self.cylinders = cylinders
        self.type = type

    def start(self):
        """ Fire the engine """
        print('Engine started')

    def stop(self):
        """ Stop the engine """
        print('Engine stopped')

class Transmission(object):
    """ Transmission class """

    def __init__(self, gears, torque):
        self.gears = gears
        self.torque = torque
        # Start with neutral
        self.gear_pos = 0

    def shift_up(self):
        """ Shift up gears """

        if self.gear_pos == self.gears:
            print('Cant shift up anymore')
        else:
            self.gear_pos += 1
            print('Shifted up to gear',self.gear_pos)

    def shift_down(self):
        """ Shift down gears """

        if self.gear_pos == -1:
            print("In reverse, can't shift down")
        else:
            self.gear_pos -= 1
            print('Shifted down to gear',self.gear_pos)         

    def shift_reverse(self):
        """ Shift in reverse """

        print('Reverse shifting')
        self.gear_pos = -1

    def shift_to(self, gear):
        """ Shift to a gear position """

        self.gear_pos = gear
        print('Shifted to gear',self.gear_pos)      

class Brake(object):
    """ A brake class """

    def __init__(self, number, type='disc'):
        self.type = type
        self.number = number

    def engage(self):
        """ Engage the break """

        print('%s %d engaged' % (self.__class__.__name__,
                                 self.number))

    def release(self):
        """ Release the break """

        print('%s %d released' % (self.__class__.__name__,
                                  self.number))

class ParkingBrake(Brake):
    """ A parking brake class """

    def __init__(self, type='drum'):
        super(ParkingBrake, self).__init__(type=type, number=1)

class Suspension(object):
    """ A suspension class """

    def __init__(self, load, type='mcpherson'):
        self.type = type
        self.load = load

class Wheel(object):
    """ A wheel class """

    def __init__(self, material, diameter, pitch):
        self.material = material
        self.diameter = diameter
        self.pitch = pitch

class WheelAssembly(object):
    """ A wheel assembly class """

    def __init__(self, brake, suspension):
        self.brake = brake
        self.suspension = suspension
        self.wheels = Wheel('alloy', 'M12',1.25)

    def apply_brakes(self):
        """ Apply brakes """

        print('Applying brakes')
        self.brake.engage()

class Frame(object):
    """ A frame class for an automobile """

    def __init__(self, length, width):
        self.length = length
        self.width = width

正如你所看到的,我们已经涵盖了汽车中的大部分子系统,至少是那些必不可少的。

这是Car类的代码,它将它们组合为一个外观,有两个方法,即startstop汽车:

class Car(object):
    """ A car class - Facade pattern """

    def __init__(self, model, manufacturer):
        self.engine = Engine('K-series',85,5000, 1.3)
        self.frame = Frame(385, 170)
        self.wheel_assemblies = []
        for i in range(4):
            self.wheel_assemblies.append(WheelAssembly(Brake(i+1), Suspension(1000)))

        self.transmission = Transmission(5, 115)
        self.model = model
        self.manufacturer = manufacturer
        self.park_brake = ParkingBrake()
        # Ignition engaged
        self.ignition = False

    def start(self):
        """ Start the car """

        print('Starting the car')
        self.ignition = True
        self.park_brake.release()
        self.engine.start()
        self.transmission.shift_up()
        print('Car started.')

    def stop(self):
        """ Stop the car """

        print('Stopping the car')
        # Apply brakes to reduce speed
        for wheel_a in self.wheel_assemblies:
            wheel_a.apply_brakes()

        # Move to 2nd gear and then 1st
        self.transmission.shift_to(2)
        self.transmission.shift_to(1)
        self.engine.stop()
        # Shift to neutral
        self.transmission.shift_to(0)
        # Engage parking brake
        self.park_brake.engage()
        print('Car stopped.')

让我们首先建立一个Car的实例:

>>> car = Car('Swift','Suzuki')
>>> car
<facade.Car object at 0x7f0c9e29afd0>

现在让我们把车开出车库去兜风:

>>> car.start()
Starting the car
ParkingBrake 1 released
Engine started
Shifted up to gear 1

汽车已启动。

现在我们已经开了一段时间,我们可以停车了。正如你可能已经猜到的那样,停车比起开车更复杂!

>>> car.stop()
Stopping the car
Shifted to gear 2
Shifted to gear 1
Applying brakes
Brake 1 engaged
Applying brakes
Brake 2 engaged
Applying brakes
Brake 3 engaged
Applying brakes
Brake 4 engaged
Engine stopped
Shifted to gear 0
ParkingBrake 1 engaged
Car stopped.
>>>

外观对于简化系统的复杂性以便更容易地使用它们是很有用的。正如前面的例子所示,如果我们没有像在这个例子中那样构建startstop方法,那么它将会非常困难。这些方法隐藏了启动和停止Car中涉及的子系统的复杂性。

这正是外观最擅长的。

代理模式

代理模式包装另一个对象以控制对其的访问。一些使用场景如下:

  • 我们需要一个更接近客户端的虚拟资源,它在另一个网络中代替真实资源,例如,远程代理

  • 当我们需要控制/监视对资源的访问时,例如,网络代理和实例计数代理

  • 我们需要保护一个资源或对象(保护代理),因为直接访问它会导致安全问题或损害它,例如,反向代理服务器

  • 我们需要优化对昂贵计算或网络操作的结果的访问,以便不必每次都执行计算,例如,一个缓存代理

代理始终实现被代理对象的接口 - 换句话说,它的目标。这可以通过继承或组合来实现。在 Python 中,后者可以通过重写__getattr__方法更强大地实现,就像我们在适配器示例中看到的那样。

实例计数代理

我们将从一个示例开始,演示代理模式用于跟踪类的实例的用法。我们将在这里重用我们的Employee类及其子类,这些子类来自工厂模式:

class EmployeeProxy(object):
    """ Counting proxy class for Employees """

    # Count of employees
    count = 0

    def __new__(cls, *args):
        """ Overloaded __new__ """
        # To keep track of counts
        instance = object.__new__(cls)
        cls.incr_count()
        return instance

    def __init__(self, employee):
        self.employee = employee

    @classmethod
    def incr_count(cls):
        """ Increment employee count """
        cls.count += 1

    @classmethod
    def decr_count(cls):
        """ Decrement employee count """
        cls.count -= 1

    @classmethod
    def get_count(cls):
        """ Get employee count """
        return cls.count

    def __str__(self):
        return str(self.employee)

    def __getattr__(self, name):
        """ Redirect attributes to employee instance """

        return getattr(self.employee, name)

    def __del__(self):
        """ Overloaded __del__ method """
        # Decrement employee count
        self.decr_count()

class EmployeeProxyFactory(object):
    """ An Employee factory class returning proxy objects """

    @classmethod
    def create(cls, name, *args):
        """ Factory method for creating an Employee instance """

        name = name.lower().strip()

        if name == 'engineer':
            return EmployeeProxy(Engineer(*args))
        elif name == 'accountant':
            return EmployeeProxy(Accountant(*args))
        elif name == 'admin':
            return EmployeeProxy(Admin(*args))

注意

我们没有复制员工子类的代码,因为这些已经在工厂模式讨论中可用。

这里有两个类,即EmployeeProxy和修改后的原始factory类,用于返回EmployeeProxy的实例而不是 Employee 的实例。修改后的工厂类使我们能够轻松创建代理实例,而不必自己去做。

在这里实现的代理是一个组合或对象代理,因为它包装目标对象(员工)并重载__getattr__以将属性访问重定向到它。它通过重写__new____del__方法来跟踪实例的数量,分别用于实例创建和实例删除。

让我们看一个使用代理的例子:

>>> factory = EmployeeProxyFactory()
>>> engineer = factory.create('engineer','Sam',25,'M')
>>> print(engineer)
Engineer - Sam, 25 years old M

注意

这通过代理打印了工程师的详细信息,因为我们在代理类中重写了__str__方法,该方法调用了员工实例的相同方法。

>>> admin = factory.create('admin','Tracy',32,'F')
>>> print(admin)
Admin - Tracy, 32 years old F

现在让我们检查实例计数。这可以通过实例或类来完成,因为它无论如何都引用一个类变量:

>>> admin.get_count()
2
>>> EmployeeProxy.get_count()
2

让我们删除这些实例,看看会发生什么!

>>> del engineer
>>> EmployeeProxy.get_count()
1
>>> del admin
>>> EmployeeProxy.get_count()
0

注意

Python 中的弱引用模块提供了一个代理对象,它执行了与我们实现的非常相似的操作,通过代理访问类实例。

这里有一个例子:

>>> import weakref
>>> import gc
>>> engineer=Engineer('Sam',25,'M')

让我们检查一下新对象的引用计数:

>>> len(gc.get_referrers(engineer))
1

现在创建一个对它的弱引用:

>>> engineer_proxy=weakref.proxy(engineer)

weakref对象在所有方面都像它的代理对象一样:

>>> print(engineer_proxy)
Engineer - Sam, 25 years old M
>>> engineer_proxy.get_role()
'engineering'

但是,请注意,weakref代理不会增加被代理对象的引用计数:

>>> len(gc.get_referrers(engineer))
      1

Python 中的模式-行为

行为模式是模式的复杂性和功能的最后阶段。它们也是系统中对象生命周期中的最后阶段,因为对象首先被创建,然后构建成更大的结构,然后彼此交互。

这些模式封装了对象之间的通信和交互模型。这些模式允许我们描述可能在运行时难以跟踪的复杂工作流程。

通常,行为模式更青睐对象组合而不是继承,因为系统中交互的对象通常来自不同的类层次结构。

在这个简短的讨论中,我们将看一下以下模式:迭代器观察者状态

迭代器模式

迭代器提供了一种顺序访问容器对象元素的方法,而不暴露底层对象本身。换句话说,迭代器是一个代理,提供了一个遍历容器对象的方法。

在 Python 中,迭代器随处可见,因此没有特别需要引入它们。

Python 中的所有容器/序列类型,即列表、元组、字符串和集合都实现了自己的迭代器。字典也实现了对其键的迭代器。

在 Python 中,迭代器是实现魔术方法__iter__的任何对象,并且还响应于返回迭代器实例的函数 iter。

通常,在 Python 中,创建的迭代器对象是隐藏在幕后的。

例如,我们可以这样遍历列表:

>>> for i in range(5):
...         print(i)
...** 
0
1
2
3
4

在内部,类似于以下的事情发生:

>>> I = iter(range(5))
>>> for i in I:
...         print(i)
...** 
0
1
2
3
4

每种序列类型在 Python 中都实现了自己的迭代器类型。以下是一些示例:

  • 列表
>>> fruits = ['apple','oranges','grapes']
>>> iter(fruits)
<list_iterator object at 0x7fd626bedba8>

  • 元组
>>> prices_per_kg = (('apple', 350), ('oranges', 80), ('grapes', 120))
>>> iter(prices_per_kg)
<tuple_iterator object at 0x7fd626b86fd0>

  • 集合
>>> subjects = {'Maths','Chemistry','Biology','Physics'}
>>> iter(subjects)
<set_iterator object at 0x7fd626b91558>

即使在 Python3 中,字典也有自己的特殊键迭代器类型:

>>> iter(dict(prices_per_kg))
<dict_keyiterator object at 0x7fd626c35ae8>

现在我们将看到一个在 Python 中实现自己的迭代器类/类型的小例子:

class Prime(object):
    """ An iterator for prime numbers """

    def __init__(self, initial, final=0):
        """ Initializer - accepts a number """
        # This may or may not be prime
        self.current = initial
        self.final = final

    def __iter__(self):
        return self

    def __next__(self):
        """ Return next item in iterator """
        return self._compute()

    def _compute(self):
        """ Compute the next prime number """

        num = self.current

        while True:
            is_prime = True

            # Check this number
            for x in range(2, int(pow(self.current, 0.5)+1)):
                if self.current%x==0:
                    is_prime = False
                    break

            num = self.current
            self.current += 1

            if is_prime:
                return num

            # If there is an end range, look for it
            if self.final > 0 and self.current>self.final:
                raise StopIteration

上述的类是一个素数迭代器,它返回两个限制之间的素数:

>>> p=Prime(2,10)
>>> for num in p:
... print(num)
... 
2
3
5
7
>>> list(Prime(2,50))
[2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47]

没有结束限制的素数迭代器是一个无限迭代器。例如,以下迭代器将返回从2开始的所有素数,并且永远不会停止:

>>> p = Prime(2)

然而,通过与 itertools 模块结合,可以从这样的无限迭代器中提取所需的特定数据。

例如,在这里,我们使用itertoolsislice方法计算前 100 个素数:

>>> import itertools
>>> list(itertools.islice(Prime(2), 100))
[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, 101, 103, 107, 109, 113, 127, 131, 137, 139, 149, 151, 157, 163, 167, 173, 179, 181, 191, 193, 197, 199, 211, 223, 227, 229, 233, 239, 241, 251, 257, 263, 269, 271, 277, 281, 283, 293, 307, 311, 313, 317, 331, 337, 347, 349, 353, 359, 367, 373, 379, 383, 389, 397, 401, 409, 419, 421, 431, 433, 439, 443, 449, 457, 461, 463, 467, 479, 487, 491, 499, 503, 509, 521, 523, 541]

同样地,这里是以 1 结尾的前 10 个素数,使用filterfalse方法:

>>> list(itertools.islice(itertools.filterfalse(lambda x: x % 10 != 1, Prime(2)), 10))
[11, 31, 41, 61, 71, 101, 131, 151, 181, 191]

同样地,这里是前 10 个回文素数:

>>> list(itertools.islice(itertools.filterfalse(lambda x: str(x)!=str(x)[-1::-1], Prime(2)), 10))
[2, 3, 5, 7, 11, 101, 131, 151, 181, 191]

感兴趣的读者可以参考itertools模块及其方法的文档,找出使用和操作这样的无限生成器数据的有趣方法。

观察者模式

观察者模式解耦了对象,但同时允许一组对象(订阅者)跟踪另一个对象(发布者)的变化。这避免了一对多的依赖和引用,同时保持它们的交互活跃。

这种模式也被称为发布-订阅

这是一个相当简单的例子,使用了一个Alarm类,它在自己的线程中运行,并每秒(默认)生成周期性的警报。它还作为一个Publisher类工作,通知其订阅者每当警报发生时。

import threading
import time

from datetime import datetime

class Alarm(threading.Thread):
    """ A class which generates periodic alarms """

    def __init__(self, duration=1):
        self.duration = duration
        # Subscribers
        self.subscribers = []
        self.flag = True
        threading.Thread.__init__(self, None, None)

    def register(self, subscriber):
        """ Register a subscriber for alarm notifications """

        self.subscribers.append(subscriber)

    def notify(self):
        """ Notify all the subscribers """

        for subscriber in self.subscribers:
            subscriber.update(self.duration)

    def stop(self):
        """ Stop the thread """

        self.flag = False

    def run(self):
        """ Run the alarm generator """

        while self.flag:
            time.sleep(self.duration)
            # Notify
            self.notify()

我们的订阅者是一个简单的DumbClock类,它订阅Alarm对象以获取通知,并使用它来更新时间:

class DumbClock(object):
    """ A dumb clock class using an Alarm object """

    def __init__(self):
        # Start time
        self.current = time.time()

    def update(self, *args):
        """ Callback method from publisher """

        self.current += args[0]

    def __str__(self):
        """ Display local time """

        return datetime.fromtimestamp(self.current).strftime('%H:%M:%S')

让我们让这些对象开始运行:

  1. 首先创建一个通知周期为一秒的闹钟。这允许:
>>> alarm=Alarm(duration=1)
  1. 接下来创建DumbClock对象:
>>> clock=DumbClock()
  1. 最后,将时钟对象注册为观察者,以便它可以接收通知:
>>> alarm.register(clock)
  1. 现在时钟将不断接收来自闹钟的更新。每次打印时钟时,它将显示当前时间,精确到秒:
>>> print(clock)
10:04:27

过一段时间它会显示如下内容:

>>> print(clock)
10:08:20
  1. 睡一会儿然后打印。
>>> print(clock);time.sleep(20);print(clock)
10:08:23
10:08:43

在实现观察者时要记住的一些方面:

  • 订阅者的引用:发布者可以选择保留对订阅者的引用,也可以使用中介者模式在需要时获取引用。中介者模式将系统中的许多对象从强烈相互引用中解耦。例如,在 Python 中,这可以是弱引用或代理的集合,或者如果发布者和订阅者对象都在同一个 Python 运行时中,则可以是管理这样一个集合的对象。对于远程引用,可以使用远程代理。

  • 实现回调:在这个例子中,Alarm类直接通过调用其update方法来更新订阅者的状态。另一种实现方式是发布者简单地通知订阅者,然后订阅者使用get_state类型的方法查询发布者的状态来实现自己的状态改变。

这是与不同类型/类的订阅者交互的首选选项。这也允许从发布者到订阅者的代码解耦,因为如果订阅者的updatenotify方法发生变化,发布者就不必更改其代码。

  • 同步与异步:在这个例子中,当状态改变时,通知在与发布者相同的线程中调用,因为时钟需要可靠和即时的通知才能准确。在异步实现中,这可以异步完成,以便发布者的主线程继续运行。例如,在使用异步执行返回一个 future 对象的系统中,这可能是首选的方法,但实际通知可能在稍后发生。

由于我们已经在第五章中遇到了异步处理,关于可扩展性,我们将用一个更多的例子来结束我们对观察者模式的讨论,展示一个异步的例子,展示发布者和订阅者异步交互。我们将在 Python 中使用 asyncio 模块。

在这个例子中,我们将使用新闻发布的领域。我们的发布者从各种来源获取新闻故事作为新闻 URL,这些 URL 被标记为特定的新闻频道。这些频道的示例可能是 - "体育","国际","技术","印度"等等。

新闻订阅者注册他们感兴趣的新闻频道,以 URL 形式获取新闻故事。一旦他们获得 URL,他们会异步获取 URL 的数据。发布者到订阅者的通知也是异步进行的。

这是我们发布者的源代码:

  import weakref
  import asyncio

  from collections import defaultdict, deque

  class NewsPublisher(object):
    """ A news publisher class with asynchronous notifications """

    def __init__(self):
        # News channels
        self.channels = defaultdict(deque)
        self.subscribers = defaultdict(list)
        self.flag = True

    def add_news(self, channel, url):
        """ Add a news story """

        self.channels[channel].append(url)

    def register(self, subscriber, channel):
        """ Register a subscriber for a news channel """

        self.subscribers[channel].append(weakref.proxy(subscriber))

    def stop(self):
        """ Stop the publisher """

        self.flag = False

    async def notify(self):
        """ Notify subscribers """

        self.data_null_count = 0

        while self.flag:
            # Subscribers who were notified
            subs = []

            for channel in self.channels:
                try:
                    data = self.channels[channel].popleft()
                except IndexError:
                    self.data_null_count += 1
                    continue

                subscribers = self.subscribers[channel]
                for sub in subscribers:
                    print('Notifying',sub,'on channel',channel,'with data=>',data)
                    response = await sub.callback(channel, data)
                    print('Response from',sub,'for channel',channel,'=>',response)
                    subs.append(sub)

            await asyncio.sleep(2.0)

发布者的notify方法是异步的。它遍历通道列表,找出每个通道的订阅者,并使用其callback方法回调订阅者,提供来自通道的最新数据。

callback方法本身是异步的,它返回一个 future 而不是任何最终处理的结果。这个 future 的进一步处理在订阅者的fetch_urls方法中异步进行。

这是我们订阅者的源代码:

import aiohttp

class NewsSubscriber(object):
    """ A news subscriber class with asynchronous callbacks """

    def __init__(self):
        self.stories = {}
        self.futures = []
        self.future_status = {}
        self.flag = True

    async def callback(self, channel, data):
        """ Callback method """

        # The data is a URL
        url = data
        # We return the response immediately
        print('Fetching URL',url,'...')
        future = aiohttp.request('GET', url)
        self.futures.append(future)

        return future

    async def fetch_urls(self):

        while self.flag:

            for future in self.futures:
                # Skip processed futures
                if self.future_status.get(future):
                    continue

                response = await future

                # Read data
                data = await response.read()

                print('\t',self,'Got data for URL',response.url,'length:',len(data))
                self.stories[response.url] = data
                # Mark as such
                self.future_status[future] = 1

            await asyncio.sleep(2.0)

注意callbackfetch_urls方法都声明为异步。callback方法将 URL 从发布者传递给aiohttp模块的GET方法,该方法简单地返回一个 future。

未来将被添加到本地的未来列表中,再次异步处理 - 通过fetch_urls方法获取 URL 数据,然后将其附加到本地的故事字典中,URL 作为键。

这是代码的异步循环部分。

看一下以下步骤:

  1. 为了开始,我们创建一个发布者,并通过特定的 URL 添加一些新闻故事到发布者的几个频道上:
      publisher = NewsPublisher()

      # Append some stories to the 'sports' and 'india' channel

      publisher.add_news('sports', 'http://www.cricbuzz.com/cricket-news/94018/collective-dd-show-hands-massive-loss-to-kings-xi-punjab')

      publisher.add_news('sports', 'https://sports.ndtv.com/indian-premier-league-2017/ipl-2017-this-is-how-virat-kohli-recovered-from-the-loss-against-mumbai-indians-1681955')

publisher.add_news('india','http://www.business-standard.com/article/current-affairs/mumbai-chennai-and-hyderabad-airports-put-on-hijack-alert-report-117041600183_1.html')
    publisher.add_news('india','http://timesofindia.indiatimes.com/india/pakistan-to-submit-new-dossier-on-jadhav-to-un-report/articleshow/58204955.cms')
  1. 然后我们创建两个订阅者,一个监听sports频道,另一个监听india频道:
    subscriber1 = NewsSubscriber()
    subscriber2 = NewsSubscriber()  
    publisher.register(subscriber1, 'sports')
    publisher.register(subscriber2, 'india') 
  1. 现在我们创建异步事件循环:
    loop = asyncio.get_event_loop()
  1. 接下来,我们将任务作为协程添加到循环中,以使异步循环开始处理。我们需要添加以下三个任务:
  • publisher.notify():

  • subscriber.fetch_urls(): 每个订阅者一个

  1. 由于发布者和订阅者处理循环都不会退出,我们通过其wait方法添加了一个超时来处理:
    tasks = map(lambda x: x.fetch_urls(), (subscriber1, subscriber2))
    loop.run_until_complete(asyncio.wait([publisher.notify(), *tasks],                                    timeout=120))

    print('Ending loop')
    loop.close()

这是我们异步的发布者和订阅者在控制台上的操作。

观察者模式

现在,我们继续讨论设计模式中的最后一个模式,即状态模式。

状态模式

状态模式将对象的内部状态封装在另一个类(状态对象)中。对象通过将内部封装的状态对象切换到不同的值来改变其状态。

状态对象及其相关的表亲,有限状态机FSM),允许程序员在不需要复杂代码的情况下实现对象在不同状态之间的状态转换。

在 Python 中,状态模式可以很容易地实现,因为 Python 为对象的类有一个魔术属性,即__class__属性。

听起来有点奇怪,但在 Python 中,这个属性可以在实例的字典上进行修改!这允许实例动态地改变其类,这是我们可以利用来在 Python 中实现这种模式的东西。

这是一个简单的例子:

>>> class C(object):
...     def f(self): return 'hi'
... 
>>> class D(object): pass
... 
>>> c = C()
>>> c
<__main__.C object at 0x7fa026ac94e0>
>>> c.f()
'hi'
>>> c.__class__=D
>>> c
<__main__.D object at 0x7fa026ac94e0>
>>> c.f()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'D' object has no attribute 'f'

我们能够在运行时更改对象c的类。现在,在这个例子中,这证明是危险的,因为CD是不相关的类,所以在这种情况下做这样的事情从来不是明智的。这在 c 在切换到D类的实例时忘记了它的方法f的方式中是显而易见的(D没有f方法)。

然而,对于相关的类,更具体地说,实现相同接口的父类的子类,这给了很大的权力,并且可以用来实现诸如状态之类的模式。

在下面的例子中,我们使用了这种技术来实现状态模式。它展示了一个可以从一个状态切换到另一个状态的计算机。

请注意,我们在定义这个类时使用了迭代器,因为迭代器通过其本质自然地定义了移动到下一个位置。我们利用了这一事实来实现我们的状态模式:

import random

class ComputerState(object):
    """ Base class for state of a computer """

    # This is an iterator
    name = "state"
    next_states = []
    random_states = []

    def __init__(self):
        self.index = 0

    def __str__(self):
        return self.__class__.__name__

    def __iter__(self):
        return self

    def change(self):
        return self.__next__()

    def set(self, state):
        """ Set a state """

        if self.index < len(self.next_states):
            if state in self.next_states:
                # Set index
                self.index = self.next_states.index(state)
                self.__class__ = eval(state)
                return self.__class__
            else:
                # Raise an exception for invalid state change    
              current = self.__class__
                new = eval(state)
                raise Exception('Illegal transition from %s to %s' % (current, new))
        else:
            self.index = 0
            if state in self.random_states:
                self.__class__ = eval(state)
                return self.__class__

    def __next__(self):
        """ Switch to next state """

        if self.index < len(self.next_states):
            # Always move to next state first
            self.__class__ = eval(self.next_states[self.index])
            # Keep track of the iterator position
            self.index += 1
            return self.__class__
        else:
             # Can switch to a random state once it completes
            # list of mandatory next states.
            # Reset index
            self.index = 0
            if len(self.random_states):
                state = random.choice(self.random_states)
                self.__class__ = eval(state)
                return self.__class__
            else:
                raise StopIteration

现在让我们定义ComputerState类的一些具体子类。

每个类都可以定义一个next_states列表,其中包含当前状态可以切换到的合法状态集。它还可以定义一个随机状态列表,这些是它可以切换到的随机合法状态,一旦它切换到下一个状态。

例如,这里是第一个状态,即计算机的off状态。下一个强制状态当然是on状态。一旦计算机开启,这个状态可以转移到任何其他随机状态。

因此,定义如下:

class ComputerOff(ComputerState):
    next_states = ['ComputerOn']
    random_states = ['ComputerSuspend', 'ComputerHibernate', 'ComputerOff']

同样,这里是其他状态类的定义:

class ComputerOn(ComputerState):
    # No compulsory next state    
    random_states = ['ComputerSuspend', 'ComputerHibernate', 'ComputerOff']

class ComputerWakeUp(ComputerState):
    # No compulsory next state
    random_states = ['ComputerSuspend', 'ComputerHibernate', 'ComputerOff']

class ComputerSuspend(ComputerState):
    next_states = ['ComputerWakeUp']  
    random_states = ['ComputerSuspend', 'ComputerHibernate', 'ComputerOff']

class ComputerHibernate(ComputerState):
    next_states = ['ComputerOn']  
    random_states = ['ComputerSuspend', 'ComputerHibernate', 'ComputerOff']

最后,这是使用状态类设置其内部状态的计算机类。

class Computer(object):
    """ A class representing a computer """

    def __init__(self, model):
        self.model = model
        # State of the computer - default is off.
        self.state = ComputerOff()

    def change(self, state=None):
        """ Change state """

        if state==None:
            return self.state.change()
        else:
            return self.state.set(state)

    def __str__(self):
        """ Return state """
        return str(self.state)

这个实现的一些有趣方面:

  • 状态作为迭代器:我们将ComputerState类实现为迭代器。这是因为状态自然地具有可以切换到的即时未来状态列表,没有其他内容。例如,处于“关闭”状态的计算机只能转移到下一个“打开”状态。将其定义为迭代器使我们能够利用迭代器从一个状态自然地进展到下一个状态。

  • 随机状态:我们在这个例子中实现了随机状态的概念。一旦计算机从一个状态移动到其强制的下一个状态(从打开到关闭,从暂停到唤醒),它有一个可以移动到的随机状态列表。处于打开状态的计算机不一定总是要关闭。它也可以进入睡眠(暂停)或休眠。

  • 手动更改:计算机可以通过change方法的第二个可选参数移动到特定状态。但是,只有在状态更改有效时才可能,否则会引发异常。

现在我们将看到我们的状态模式在实际中的应用。

计算机开始关闭,当然:

>>> c = Computer('ASUS')
>>> print(c)
ComputerOff

让我们看一些自动状态更改:

>>> c.change()
<class 'state.ComputerOn'>

现在,让状态机决定它的下一个状态——注意这些是随机状态,直到计算机进入必须移动到下一个状态的状态为止:

>>> c.change()
<class 'state.ComputerHibernate'>

现在状态是休眠,这意味着下一个状态必须是打开,因为这是一个强制的下一个状态:

>>> c.change()
<class 'state.ComputerOn'>
>>> c.change()
<class 'state.ComputerOff'>

现在状态是关闭,这意味着下一个状态必须是打开:

>>> c.change()
<class 'state.ComputerOn'>

以下是所有随机状态更改:

>>> c.change()
<class 'state.ComputerSuspend'>
>>> c.change()
<class 'state.ComputerWakeUp'>
>> c.change()
<class 'state.ComputerHibernate'>

现在,由于底层状态是一个迭代器,因此可以使用 itertools 等模块对状态进行迭代。

这是一个例子——迭代计算机的下一个五个状态:

>>> import itertools
>>> for s in itertools.islice(c.state, 5):
... print (s)
... 
<class 'state.ComputerOn'>
<class 'state.ComputerOff'>
<class 'state.ComputerOn'>
<class 'state.ComputerOff'>
<class 'state.ComputerOn'>

现在让我们尝试一些手动状态更改:

>>> c.change('ComputerOn')
<class 'state.ComputerOn'>
>>> c.change('ComputerSuspend')
<class 'state.ComputerSuspend'>

>>> c.change('ComputerHibernate')
Traceback (most recent call last):
  File "state.py", line 133, in <module>
      print(c.change('ComputerHibernate'))        
  File "state.py", line 108, in change
      return self.state.set(state)
  File "state.py", line 45, in set
      raise Exception('Illegal transition from %s to %s' % (current, new))
Exception: Illegal transition from <class '__main__.ComputerSuspend'> to <class '__main__.ComputerHibernate'>

当我们尝试无效的状态转换时,我们会得到一个异常,因为计算机不能直接从暂停转换到休眠。它必须先唤醒!

>>> c.change('ComputerWakeUp')
<class 'state.ComputerWakeUp'>
>>> c.change('ComputerHibernate')
<class 'state.ComputerHibernate'>

现在一切都很好。

我们讨论了 Python 中设计模式的讨论到此结束,现在是总结我们迄今为止学到的东西的时候了。

总结

在本章中,我们详细介绍了面向对象设计模式,并发现了在 Python 中实现它们的新方法和不同方法。我们从设计模式及其分类(创建型、结构型和行为型模式)开始。

我们继续看了一个策略设计模式的例子,并看到如何以 Python 的方式实现它。然后我们开始正式讨论 Python 中的模式。

在创建型模式中,我们涵盖了单例、Borg、原型、工厂和生成器模式。我们看到了为什么 Borg 通常比单例在 Python 中更好,因为它能够在类层次结构中保持状态。我们看到了生成器、原型和工厂模式之间的相互作用,并看到了一些例子。在可能的情况下,引入了元类讨论,并使用元类实现了模式。

在结构型模式中,我们的重点是适配器、facade 和代理模式。我们通过适配器模式看到了详细的例子,并讨论了通过继承和对象组合的方法。当我们通过__getattr__技术实现适配器和代理模式时,我们看到了 Python 中魔术方法的威力。

在 Facade 中,使用Car类,我们看到了一个详细的例子,说明了 Facade 如何帮助程序员征服复杂性,并在子系统上提供通用接口。我们还看到许多 Python 标准库模块本身就是 facade。

在行为部分,我们讨论了迭代器、观察者和状态模式。我们看到迭代器是 Python 的一部分。我们实现了一个迭代器作为生成器来构建素数。

我们通过使用Alarm类作为发布者和时钟类作为订阅者,看到了观察者模式的一个简单例子。我们还看到了在 Python 中使用 asyncio 模块的异步观察者模式的例子。

最后,我们以状态模式结束了模式的讨论。我们讨论了通过允许的状态更改来切换计算机状态的详细示例,以及如何使用 Python 的__class__作为动态属性来更改实例的类。在状态的实现中,我们借鉴了迭代器模式的技术,并将状态示例类实现为迭代器。

在我们的下一章中,我们从设计转向软件架构中模式的下一个更高范式,即架构模式。

第八章:Python - 架构模式

架构模式是软件模式体系中最高级别的模式。架构模式允许架构师指定应用程序的基本结构。为给定的软件问题选择的架构模式控制着其余的活动,例如所涉及系统的设计,系统不同部分之间的通信等等。

根据手头的问题,可以选择多种架构模式。不同的模式解决不同类或系列的问题,创造出自己的风格或架构类别。例如,某一类模式解决了客户端/服务器系统的架构,另一类模式帮助构建分布式系统,第三类模式帮助设计高度解耦的对等系统。

在本章中,我们将讨论并专注于 Python 世界中经常遇到的一些架构模式。我们在本章中的讨论模式将是采用一个众所周知的架构模式,并探索一个或两个实现它的流行软件应用程序或框架,或者它的变体。

在本章中,我们不会讨论大量的代码 - 代码的使用将仅限于那些绝对必要使用程序进行说明的模式。另一方面,大部分讨论将集中在架构细节,参与子系统,所选应用程序/框架实现的架构变化等方面。

我们可以研究任意数量的架构模式。在本章中,我们将重点关注 MVC 及其相关模式,事件驱动编程架构,微服务架构以及管道和过滤器。

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

  • 介绍 MVC:

  • 模型视图模板 - Django

  • Flask 微框架

  • 事件驱动编程:

  • 使用 select 的聊天服务器和客户端

  • 事件驱动与并发编程

  • 扭曲

扭曲聊天服务器和客户端

  • Eventlet

Eventlet 聊天服务器

  • Greenlets 和 gevent

Gevent 聊天服务器

  • 微服务架构:

  • Python 中的微服务框架

  • 微服务示例

  • 微服务优势

  • 管道和过滤器架构:

  • Python 中的管道和过滤器 - 示例

介绍 MVC

模型视图控制器或 MVC 是用于构建交互式应用程序的众所周知和流行的架构模式。MVC 将应用程序分为三个组件:模型,视图和控制器。

介绍 MVC

模型-视图-控制器(MVC)架构

这三个组件执行以下职责:

  • 模型:模型包含应用程序的核心数据和逻辑。

  • 视图:视图形成应用程序向用户的输出。它们向用户显示信息。可以有同一数据的多个视图。

  • 控制器:控制器接收和处理用户输入,如键盘点击或鼠标点击/移动,并将它们转换为对模型或视图的更改请求。

使用这三个组件分离关注避免了应用程序的数据和其表示之间的紧密耦合。它允许同一数据(模型)的多个表示(视图),可以根据通过控制器接收的用户输入进行计算和呈现。

MVC 模式允许以下交互:

  1. 模型可以根据从控制器接收的输入更改其数据。

  2. 更改的数据反映在视图上,这些视图订阅了模型的更改。

  3. 控制器可以发送命令来更新模型的状态,例如在对文档进行更改时。控制器还可以发送命令来修改视图的呈现,而不对模型进行任何更改,例如放大图表或图表。

  4. MVC 模式隐含地包括一个变更传播机制,以通知其他依赖组件的变更。

  5. Python 世界中的许多 Web 应用程序实现了 MVC 或其变体。我们将在接下来的部分中看一些,即 Django 和 Flask。

模板视图(MTV) - Django

Django 项目是 Python 世界中最受欢迎的 Web 应用程序框架之一。Django 实现了类似 MVC 模式的东西,但有一些细微的差异。

Django(核心)组件架构如下图所示:

Model Template View (MTV) – Django

Django 核心组件架构

Django 框架的核心组件如下:

  • 对象关系映射器(ORM),充当数据模型(Python)和数据库(关系数据库管理系统)之间的中介 - 这可以被认为是模型层。

  • Python 中的一组回调函数,将数据呈现给特定 URL 的用户界面 - 这可以被认为是 VIEW 层。视图侧重于构建和转换内容,而不是实际呈现。

  • 一组 HTML 模板,用于以不同的方式呈现内容。视图委托给特定模板,该模板负责数据的呈现方式。

  • 基于正则表达式的 URL DISPATCHER,将服务器上的相对路径连接到特定视图及其变量参数。这可以被认为是一个基本的控制器。

  • 在 Django 中,由于呈现是由 TEMPLATE 层执行的,而只有 VIEW 层执行内容映射,因此 Django 经常被描述为实现 Model Template View(MTV)框架。

  • Django 中的控制器并没有很好地定义 - 它可以被认为是整个框架本身 - 或者限于 URL DISPATCHER 层。

Django admin - 自动化的模型中心视图

Django 框架最强大的组件之一是其自动管理员系统,它从 Django 模型中读取元数据,并生成快速的、以模型为中心的管理员视图,系统管理员可以通过简单的 HTML 表单查看和编辑数据模型。

为了说明,以下是一个描述将术语添加到网站作为“词汇”术语的 Django 模型的示例(词汇是描述与特定主题、文本或方言相关的词汇含义的列表或索引):

from django.db import models

class GlossaryTerm(models.Model):
    """ Model for describing a glossary word (term) """

    term = models.CharField(max_length=1024)
    meaning = models.CharField(max_length=1024)
    meaning_html = models.CharField('Meaning with HTML markup',
                    max_length=4096, null=True, blank=True)
    example = models.CharField(max_length=4096, null=True, blank=True)

    # can be a ManyToManyField?
    domains = models.CharField(max_length=128, null=True, blank=True)

    notes = models.CharField(max_length=2048, null=True, blank=True)
    url = models.CharField('URL', max_length=2048, null=True, blank=True)
    name = models.ForeignKey('GlossarySource', verbose_name='Source', blank=True)

    def __unicode__(self):
        return self.term

    class Meta:
        unique_together = ('term', 'meaning', 'url')

这与一个注册模型以获得自动化管理员视图的管理员系统相结合:

from django.contrib import admin

admin.site.register(GlossaryTerm)
admin.site.register(GlossarySource)

以下是通过 Django admin 界面添加术语词汇的自动化管理员视图(HTML 表单)的图像:

Django admin – automated model-centric views

Django 自动管理员视图(HTML 表单)用于添加词汇术语

快速观察告诉您 Django 管理员如何为模型中的不同数据字段生成正确的字段类型,并生成添加数据的表单。这是 Django 中的一个强大模式,允许您以几乎零编码工作量生成自动化的管理员视图以添加/编辑模型。

现在让我们来看另一个流行的 Python Web 应用程序框架,即 Flask。

灵活的微框架 - Flask

Flask 是一个微型 Web 框架,它使用了一种最小主义的哲学来构建 Web 应用程序。Flask 仅依赖于两个库:Werkzeug(werkzeug.pocoo.org/)WSGI 工具包和 Jinja2 模板框架。

Flask 通过装饰器提供了简单的 URL 路由。Flask 中的“微”一词表明框架的核心很小。对数据库、表单和其他功能的支持是由 Python 社区围绕 Flask 构建的多个扩展提供的。

因此,Flask 的核心可以被认为是一个 MTV 框架减去 M(视图模板),因为核心不实现对模型的支持。

以下是 Flask 组件架构的近似示意图:

Flexible Microframework – Flask

Flask 组件的示意图

使用模板的简单 Flask 应用程序看起来是这样的:

from flask import Flask
app = Flask(__name__)

@app.route('/')
def index():
    data = 'some data'
    return render_template('index.html', **locals())

我们可以在这里找到 MVC 模式的一些组件:

  • @app.route装饰器将浏览器的请求路由到index函数。应用程序路由器可以被视为控制器。

  • index函数返回数据,并使用模板进行渲染。index函数可以被视为生成视图或视图组件。

  • Flask 使用类似 Django 的模板来将内容与呈现分开。这可以被视为模板组件。

  • 在 Flask 核心中没有特定的模型组件。但是,可以借助附加插件来添加模型组件。

  • Flask 使用插件架构来支持附加功能。例如,可以使用 Flask-SQLAlchemy 添加模型,使用 Flask-RESTful 支持 RESTful API,使用 Flask-marshmallow 进行序列化等。

事件驱动编程

事件驱动编程是一种系统架构范式,其中程序内部的逻辑流由事件驱动,例如用户操作、来自其他程序的消息或硬件(传感器)输入。

在事件驱动架构中,通常有一个主事件循环,它监听事件,然后在检测到事件时触发具有特定参数的回调函数。

在像 Linux 这样的现代操作系统中,对输入文件描述符(如套接字或已打开的文件)的事件的支持是通过系统调用(如selectpollepoll)来实现的。

Python 通过其select模块提供了对这些系统调用的包装。使用select模块在 Python 中编写简单的事件驱动程序并不是很困难。

以下一组程序一起使用 Python 实现了基本的聊天服务器和客户端,利用了 select 模块的强大功能。

使用 select 模块进行 I/O 多路复用的聊天服务器和客户端

我们的聊天服务器使用select模块通过select系统调用来创建频道,客户端可以连接到这些频道并相互交谈。它处理输入准备好的事件(套接字)-如果事件是客户端连接到服务器,则连接并进行握手;如果事件是要从标准输入读取数据,则服务器读取数据,否则将从一个客户端接收到的数据传递给其他客户端。

这是我们的聊天服务器:

注意

由于聊天服务器的代码很大,我们只包含了主函数,即serve函数,显示服务器如何使用基于 select 的 I/O 多路复用。serve函数中的大量代码也已经被修剪,以保持打印的代码较小。

完整的源代码可以从本书的代码存档中下载,也可以从本书的网站上下载。

# chatserver.py

import socket
import select
import signal
import sys
from communication import send, receive

class ChatServer(object):
    """ Simple chat server using select """

    def serve(self):
        inputs = [self.server,sys.stdin]
        self.outputs = []

        while True:

                inputready,outputready,exceptready = select.select(inputs, self.outputs, [])

            for s in inputready:

                if s == self.server:
                    # handle the server socket
                    client, address = self.server.accept()

                    # Read the login name
                    cname = receive(client).split('NAME: ')[1]

                    # Compute client name and send back
                    self.clients += 1
                    send(client, 'CLIENT: ' + str(address[0]))
                    inputs.append(client)

                    self.clientmap[client] = (address, cname)
                    self.outputs.append(client)

                elif s == sys.stdin:
                    # handle standard input – the server exits 
                    junk = sys.stdin.readline()
		  break
                else:
                    # handle all other sockets
                    try:
                        data = receive(s)
                        if data:
                            # Send as new client's message...
                            msg = '\n#[' + self.get_name(s) + ']>> ' + data
                            # Send data to all except ourselves
                            for o in self.outputs:
                                if o != s:
                                    send(o, msg)
                        else:
                            print('chatserver: %d hung up' % s.fileno())
                            self.clients -= 1
                            s.close()
                            inputs.remove(s)
                            self.outputs.remove(s)

                    except socket.error as e:
                        # Remove
                        inputs.remove(s)
                        self.outputs.remove(s)

        self.server.close()

if __name__ == "__main__":
    ChatServer().serve()

注意

通过发送一行空输入可以停止聊天服务器。

聊天客户端也使用select系统调用。它使用套接字连接到服务器,然后在套接字和标准输入上等待事件。如果事件来自标准输入,则读取数据。否则,它通过套接字将数据发送到服务器:

# chatclient.py
import socket
import select
import sys
from communication import send, receive

class ChatClient(object):
    """ A simple command line chat client using select """

    def __init__(self, name, host='127.0.0.1', port=3490):
        self.name = name
        # Quit flag
        self.flag = False
        self.port = int(port)
        self.host = host
        # Initial prompt
        self.prompt='[' + '@'.join((name, socket.gethostname().split('.')[0])) + ']> '
        # Connect to server at port
        try:
            self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
            self.sock.connect((host, self.port))
            print('Connected to chat server@%d' % self.port)
            # Send my name...
            send(self.sock,'NAME: ' + self.name) 
            data = receive(self.sock)
            # Contains client address, set it
            addr = data.split('CLIENT: ')[1]
            self.prompt = '[' + '@'.join((self.name, addr)) + ']> '
        except socket.error as e:
            print('Could not connect to chat server @%d' % self.port)
            sys.exit(1)

    def chat(self):
        """ Main chat method """

        while not self.flag:
            try:
                sys.stdout.write(self.prompt)
                sys.stdout.flush()

                # Wait for input from stdin & socket
                inputready, outputready,exceptrdy = select.select([0, self.sock], [],[])

                for i in inputready:
                    if i == 0:
                        data = sys.stdin.readline().strip()
                        if data: send(self.sock, data)
                    elif i == self.sock:
                        data = receive(self.sock)
                        if not data:
                            print('Shutting down.')
                            self.flag = True
                            break
                        else:
                            sys.stdout.write(data + '\n')
                            sys.stdout.flush()

            except KeyboardInterrupt:
                print('Interrupted.')
                self.sock.close()
                break

if __name__ == "__main__":
    if len(sys.argv)<3:
        sys.exit('Usage: %s chatid host portno' % sys.argv[0])

    client = ChatClient(sys.argv[1],sys.argv[2], int(sys.argv[3]))
    client.chat()

注意

聊天客户端可以通过在终端上按下Ctrl + C来停止。

为了通过套接字发送和接收数据,这两个脚本都使用了一个名为communication的第三方模块,该模块具有sendreceive函数。该模块分别在sendreceive函数中使用 pickle 对数据进行序列化和反序列化:

# communication.py
import pickle
import socket
import struct

def send(channel, *args):
    """ Send a message to a channel """

    buf = pickle.dumps(args)
    value = socket.htonl(len(buf))
    size = struct.pack("L",value)
    channel.send(size)
    channel.send(buf)

def receive(channel):
    """ Receive a message from a channel """

    size = struct.calcsize("L")
    size = channel.recv(size)
    try:
        size = socket.ntohl(struct.unpack("L", size)[0])
    except struct.error as e:
        return ''

    buf = ""

    while len(buf) < size:
        buf = channel.recv(size - len(buf))

    return pickle.loads(buf)[0]

以下是服务器运行的一些图像,以及通过聊天服务器相互连接的两个客户端:

这是连接到聊天服务器的名为andy的客户端#1 的图像:

使用 select 模块进行 I/O 多路复用的聊天服务器和客户端

聊天客户端#1 的聊天会话(客户端名称:andy)

同样,这是一个名为betty的客户端,它连接到聊天服务器并与andy进行交谈:

使用 select 模块进行 I/O 多路复用的聊天服务器和客户端

聊天客户端#2 的聊天会话(客户端名称:betty)

程序的一些有趣点列举如下:

  • 看看客户端是如何看到彼此的消息的。这是因为服务器将一个客户端发送的数据发送给所有其他连接的客户端。我们的聊天服务器使用井号#作为前缀来指示这条消息来自另一个客户端。

  • 看看服务器是如何将客户端的连接和断开信息发送给所有其他客户端的。这通知了客户端另一个客户端何时连接到或从会话中断开。

  • 服务器在客户端断开连接时会回显消息,表示客户端已经“挂断”:

注意

前面的聊天服务器和客户端示例是作者在 ASPN Cookbook 中的 Python 配方的一个小变化,网址为code.activestate.com/recipes/531824

像 Twisted、Eventlet 和 Gevent 这样的库将简单的基于 select 的多路复用提升到了下一个级别,以构建提供高级基于事件的编程例程的系统,通常基于类似于我们聊天服务器示例的核心事件循环的核心事件循环。

我们将在接下来的章节中讨论这些框架的架构。

事件驱动编程与并发编程

在前一节中我们看到的例子使用了异步事件的技术,正如我们在并发章节中看到的那样。这与真正的并发或并行编程是不同的。

事件编程库也使用了异步事件的技术。在其中只有一个执行线程,任务根据接收到的事件依次交错执行。

在下面的例子中,考虑通过三个线程或进程并行执行三个任务:

事件驱动编程与并发编程

使用三个线程并行执行三个任务

与通过事件驱动编程执行任务时发生的情况形成对比,如下图所示:

事件驱动编程与并发编程

在单个线程中异步执行三个任务

在异步模型中,只有一个单一的执行线程,任务以交错的方式执行。每个任务在异步处理服务器的事件循环中有自己的处理时间段,但在任何给定时间只有一个任务在执行。任务将控制权交还给循环,以便它可以在下一个时间片中安排一个不同的任务来执行当前正在执行的任务。正如我们在第五章中所看到的,“编写可扩展的应用程序”,这是一种协作式多任务处理。

Twisted

Twisted 是一个事件驱动的网络引擎,支持多种协议,如 DNS、SMTP、POP3、IMAP 等。它还支持编写 SSH 客户端和服务器,并构建消息和 IRC 客户端和服务器。

Twisted 还提供了一组模式(风格)来编写常见的服务器和客户端,例如 Web 服务器/客户端(HTTP)、发布/订阅模式、消息客户端和服务器(SOAP/XML-RPC)等。

它使用了反应器设计模式,将来自多个来源的事件多路复用并分派给它们的事件处理程序在一个单线程中。

它接收来自多个并发客户端的消息、请求和连接,并使用事件处理程序顺序处理这些帖子,而无需并发线程或进程。

反应器伪代码大致如下:

while True:
    timeout = time_until_next_timed_event()
    events = wait_for_events(timeout)
    events += timed_events_until(now())
    for event in events:
        event.process()

Twisted 使用回调来在事件发生时调用事件处理程序。为了处理特定事件,为该事件注册一个回调。回调可以用于常规处理,也可以用于管理异常(错误回调)。

asyncio模块一样,Twisted 使用类似于 futures 的对象来包装任务执行的结果,其实际结果仍然不可用。在 Twisted 中,这些对象称为Deferreds

延迟对象有一对回调链:一个用于处理结果(回调),另一个用于管理错误(errbacks)。当获得执行结果时,将创建一个延迟对象,并按照添加的顺序调用其回调和/或 errbacks。

以下是 Twisted 的架构图,显示了高级组件:

Twisted

扭曲 - 核心组件

Twisted - 一个简单的 Web 客户端

以下是使用 Twisted 的简单 Web HTTP 客户端的示例,获取给定 URL 并将其内容保存到特定文件名:

# twisted_fetch_url.py
from twisted.internet import reactor
from twisted.web.client import getPage
import sys

def save_page(page, filename='content.html'):
    print type(page)
    open(filename,'w').write(page)
    print 'Length of data',len(page)
    print 'Data saved to',filename

def handle_error(error):
    print error

def finish_processing(value):
    print "Shutting down..."
    reactor.stop()

if __name__ == "__main__":
    url = sys.argv[1]
    deferred = getPage(url) 
    deferred.addCallbacks(save_page, handle_error)
    deferred.addBoth(finish_processing)

    reactor.run()

正如您在前面的代码中所看到的,getPage方法返回一个延迟对象,而不是 URL 的数据。对于延迟对象,我们添加了两个回调:一个用于处理数据(save_page函数),另一个用于处理错误(handle_error函数)。延迟的addBoth方法将一个函数添加为回调和 errback。

事件处理是通过运行反应器来启动的。在结束时调用finish_processing回调,停止反应器。由于事件处理程序是按添加顺序调用的,因此此函数只会在最后调用。

当反应器运行时,会发生以下事件:

  • 页面被获取并创建了延迟。

  • 回调按顺序在延迟上调用。首先调用save_page函数,将页面内容保存到content.html文件中。然后调用handle_error事件处理程序,打印任何错误字符串。

  • 最后,调用finish_processing,停止反应器,事件处理结束,退出程序。

注意

在撰写本文时,Twisted 尚未适用于 Python3,因此前面的代码是针对 Python2 编写的。

  • 当您运行代码时,您会看到产生以下输出:
$ python2 twisted_fetch_url.py http://www.google.com
Length of data 13280
Data saved to content.html
Shutting down...

使用 Twisted 的聊天服务器

现在让我们看看如何在 Twisted 上编写一个简单的聊天服务器,类似于我们使用select模块的聊天服务器。

在 Twisted 中,服务器是通过实现协议和协议工厂来构建的。协议类通常继承自 Twisted 的Protocol类。

工厂只是作为协议对象的工厂模式的类。

使用这个,这是我们使用 Twisted 的聊天服务器:

from twisted.internet import protocol, reactor

class Chat(protocol.Protocol):
    """ Chat protocol """

    transports = {}
    peers = {}

    def connectionMade(self):
        self._peer = self.transport.getPeer()
        print 'Connected',self._peer

    def connectionLost(self, reason):
        self._peer = self.transport.getPeer()
        # Find out and inform other clients
        user = self.peers.get((self._peer.host, self._peer.port))
        if user != None:
            self.broadcast('(User %s disconnected)\n' % user, user)
            print 'User %s disconnected from %s' % (user, self._peer)

    def broadcast(self, msg, user):
        """ Broadcast chat message to all connected users except 'user' """

        for key in self.transports.keys():
            if key != user:
                if msg != "<handshake>":
                    self.transports[key].write('#[' + user + "]>>> " + msg)
                else:
                    # Inform other clients of connection
                    self.transports[key].write('(User %s connected from %s)\n' % (user, self._peer))                

    def dataReceived(self, data):
        """ Callback when data is ready to be read from the socket """

        user, msg = data.split(":")
        print "Got data=>",msg,"from",user
        self.transports[user] = self.transport
        # Make an entry in the peers dictionary
        self.peers[(self._peer.host, self._peer.port)] = user
        self.broadcast(msg, user)

class ChatFactory(protocol.Factory):
    """ Chat protocol factory """

    def buildProtocol(self, addr):
        return Chat()

if __name__ == "__main__":
    reactor.listenTCP(3490, ChatFactory())
    reactor.run()

我们的聊天服务器比以前的更复杂,因为它执行以下附加步骤:

  1. 它有一个单独的握手协议,使用特殊的<handshake>消息。

  2. 当客户端连接时,会向其他客户端广播通知他们客户端的名称和连接详细信息。

  3. 当客户端断开连接时,其他客户端会收到通知。

聊天客户端还使用 Twisted,并使用两个协议 - 分别是用于与服务器通信的ChatClientProtocol和用于从标准输入读取数据并将从服务器接收的数据回显到标准输出的StdioClientProtocol

后一个协议还将前一个协议连接到其输入,以便将接收到的任何数据发送到服务器作为聊天消息。

看一下以下代码:

import sys
import socket
from twisted.internet import stdio, reactor, protocol

class ChatProtocol(protocol.Protocol):
    """ Base protocol for chat """

    def __init__(self, client):
        self.output = None
        # Client name: E.g: andy
        self.client = client
        self.prompt='[' + '@'.join((self.client, socket.gethostname().split('.')[0])) + ']> '             

    def input_prompt(self):
        """ The input prefix for client """
        sys.stdout.write(self.prompt)
        sys.stdout.flush()

    def dataReceived(self, data):
        self.processData(data)

class ChatClientProtocol(ChatProtocol):
    """ Chat client protocol """

    def connectionMade(self):
        print 'Connection made'
        self.output.write(self.client + ":<handshake>")

    def processData(self, data):
        """ Process data received """

        if not len(data.strip()):
            return

        self.input_prompt()

        if self.output:
            # Send data in this form to server
            self.output.write(self.client + ":" + data)

class StdioClientProtocol(ChatProtocol):
    """ Protocol which reads data from input and echoes
    data to standard output """

    def connectionMade(self):
        # Create chat client protocol
        chat = ChatClientProtocol(client=sys.argv[1])
        chat.output = self.transport

        # Create stdio wrapper
        stdio_wrapper = stdio.StandardIO(chat)
        # Connect to output
        self.output = stdio_wrapper
        print "Connected to server"
        self.input_prompt()

    def input_prompt(self):
        # Since the output is directly connected
        # to stdout, use that to write.
        self.output.write(self.prompt)

    def processData(self, data):
        """ Process data received """

        if self.output:
            self.output.write('\n' + data)
            self.input_prompt()

class StdioClientFactory(protocol.ClientFactory):

    def buildProtocol(self, addr):
        return StdioClientProtocol(sys.argv[1])

def main():
    reactor.connectTCP("localhost", 3490, StdioClientFactory())
    reactor.run()

if __name__ == '__main__':
    main()

以下是两个客户端andybetty使用这个聊天服务器和客户端进行通信的一些屏幕截图:

使用 Twisted 的聊天服务器

使用 Twisted 的聊天客户端 - 客户端#1(andy)的会话

这是第二个会话,针对客户端 betty:

使用 Twisted 的聊天服务器

使用 Twisted 的聊天客户端 - 客户端#2(betty)的会话

您可以通过交替查看屏幕截图来跟踪对话的流程。

请注意,服务器在用户 betty 连接和用户 andy 断开连接时发送的连接和断开连接消息。

Eventlet

Eventlet 是 Python 世界中另一个知名的网络库,允许使用异步执行的概念编写事件驱动程序。

Eventlet 使用协程来执行这些任务,借助一组所谓的绿色线程,这些线程是轻量级的用户空间线程,执行协作式多任务。

Eventlet 使用一组绿色线程的抽象,Greenpool类,以执行其任务。

Greenpool类运行预定义的一组Greenpool线程(默认为1000),并提供不同方式将函数和可调用对象映射到线程的方法。

以下是使用 Eventlet 重写的多用户聊天服务器:

# eventlet_chat.py

import eventlet
from eventlet.green import socket

participants = set()

def new_chat_channel(conn):
    """ New chat channel for a given connection """

    data = conn.recv(1024)
    user = ''

    while data:
        print("Chat:", data.strip())
        for p in participants:
            try:
                if p is not conn:
                    data = data.decode('utf-8')
                    user, msg = data.split(':')
                    if msg != '<handshake>':
                        data_s = '\n#[' + user + ']>>> says ' + msg
                    else:
                        data_s = '(User %s connected)\n' % user

                    p.send(bytearray(data_s, 'utf-8'))
            except socket.error as e:
                # ignore broken pipes, they just mean the participant
                # closed its connection already
                if e[0] != 32:
                    raise
        data = conn.recv(1024)

    participants.remove(conn)
    print("Participant %s left chat." % user)

if __name__ == "__main__":
    port = 3490
    try:
        print("ChatServer starting up on port", port)
        server = eventlet.listen(('0.0.0.0', port))

        while True:
            new_connection, address = server.accept()
            print("Participant joined chat.")
            participants.add(new_connection)
            print(eventlet.spawn(new_chat_channel,
                                 new_connection))

    except (KeyboardInterrupt, SystemExit):
        print("ChatServer exiting.")

注意

这个服务器可以与我们在之前示例中看到的 Twisted 聊天客户端一起使用,并且行为完全相同。因此,我们不会展示此服务器的运行示例。

Eventlet 库在内部使用greenlets,这是一个在 Python 运行时上提供绿色线程的包。我们将在下一节中看到 greenlet 和一个相关库 Gevent。

Greenlets 和 Gevent

Greenlet 是一个在 Python 解释器之上提供绿色或微线程版本的包。它受 Stackless 的启发,Stackless 是支持称为 stacklets 的微线程的 CPython 的一个版本。然而,greenlets 能够在标准 CPython 运行时上运行。

Gevent 是一个 Python 网络库,提供在 C 语言编写的libev之上的高级同步 API。

Gevent 受到 gevent 的启发,但它具有更一致的 API 和更好的性能。

与 Eventlet 一样,gevent 对系统库进行了大量的猴子补丁,以提供协作式多任务支持。例如,gevent 自带自己的套接字,就像 Eventlet 一样。

与 Eventlet 不同,gevent 还需要程序员显式进行猴子补丁。它提供了在模块本身上执行此操作的方法。

话不多说,让我们看看使用 gevent 的多用户聊天服务器是什么样子的:

# gevent_chat_server.py

import gevent
from gevent import monkey
from gevent import socket
from gevent.server import StreamServer

monkey.patch_all()

participants = set()

def new_chat_channel(conn, address):
    """ New chat channel for a given connection """

    participants.add(conn)
    data = conn.recv(1024)
    user = ''

    while data:
        print("Chat:", data.strip())
        for p in participants:
            try:
                if p is not conn:
                    data = data.decode('utf-8')
                    user, msg = data.split(':')
                    if msg != '<handshake>':
                        data_s = '\n#[' + user + ']>>> says ' + msg
                    else:
                        data_s = '(User %s connected)\n' % user

                    p.send(bytearray(data_s, 'utf-8'))                  
            except socket.error as e:
                # ignore broken pipes, they just mean the participant
                # closed its connection already
                if e[0] != 32:
                    raise
        data = conn.recv(1024)

    participants.remove(conn)
    print("Participant %s left chat." % user)

if __name__ == "__main__":
    port = 3490
    try:
        print("ChatServer starting up on port", port)
        server = StreamServer(('0.0.0.0', port), new_chat_channel)
        server.serve_forever()
    except (KeyboardInterrupt, SystemExit):
        print("ChatServer exiting.")

基于 gevent 的聊天服务器的代码几乎与使用 Eventlet 的代码相同。原因是它们都通过在建立新连接时将控制权交给回调函数的方式以非常相似的方式工作。在这两种情况下,回调函数的名称都是new_chat_channel,具有相同的功能,因此代码非常相似。

两者之间的区别如下:

  • gevent 提供了自己的 TCP 服务器类——StreamingServer,因此我们使用它来代替直接监听模块

  • 在 gevent 服务器中,对于每个连接,都会调用new_chat_channel处理程序,因此参与者集合在那里进行管理

  • 由于 gevent 服务器有自己的事件循环,因此无需创建用于监听传入连接的 while 循环,就像我们在 Eventlet 中所做的那样

这个示例与之前的示例完全相同,并且与 Twisted 聊天客户端一起使用。

微服务架构

微服务架构是开发单个应用程序的一种架构风格,将其作为一套小型独立服务运行,每个服务在自己的进程中运行,并通过轻量级机制进行通信,通常使用 HTTP 协议。

微服务是独立部署的组件,通常没有或者只有极少的中央管理或配置。

微服务可以被视为面向服务的架构SOA)的特定实现风格,其中应用程序不是自上而下构建为单体应用程序,而是构建为相互交互的独立服务的动态组。

传统上,企业应用程序是以单块模式构建的,通常由这三个层组成:

  1. 由 HTML 和 JavaScript 组成的客户端用户界面(UI)层。

  2. 由业务逻辑组成的服务器端应用程序。

  3. 数据库和数据访问层,保存业务数据。

另一方面,微服务架构将这一层拆分为多个服务。例如,业务逻辑不再在单个应用程序中,而是拆分为多个组件服务,它们的交互定义了应用程序内部的逻辑流程。这些服务可能查询单个数据库或独立的本地数据库,后者的配置更常见。

微服务架构中的数据通常以文档对象的形式进行处理和返回 - 通常以 JSON 编码。

以下示意图说明了单体架构与微服务架构的区别:

微服务架构

单体架构(左)与微服务架构(右)

Python 中的微服务框架

由于微服务更多地是一种哲学或架构风格,没有明确的软件框架类别可以说是它们的最佳选择。然而,人们仍然可以对框架应该具有的属性做出一些合理的预测,以便为在 Python 中构建 Web 应用程序的微服务架构选择一个好的框架。这些属性包括以下内容:

  • 组件架构应该是灵活的。框架不应该在规定使系统的不同部分工作的组件选择方面变得死板。

  • 框架的核心应该是轻量级的。这是有道理的,因为如果我们从头开始,比如说,微服务框架本身有很多依赖,软件在一开始就会感觉很沉重。这可能会导致部署、测试等方面出现问题。

  • 框架应该支持零或最小化配置。微服务架构通常是自动配置的(零配置)或具有一组最小配置输入,这些输入在一个地方可用。通常,配置本身作为微服务可供其他服务查询,并使配置共享变得简单、一致和可扩展。

  • 它应该非常容易将现有的业务逻辑,比如编码为类或函数的业务逻辑,转换为 HTTP 或 RCP 服务。这允许代码的重用和智能重构。

如果您遵循这些原则并在 Python 软件生态系统中寻找,您会发现一些 Web 应用程序框架符合要求,而另一些则不符合。

例如,Flask 及其单文件对应物 Bottle 由于其最小的占用空间、小的核心和简单的配置,是微服务框架的良好选择。

Pyramid 等框架也可以用于微服务架构,因为它促进了组件选择的灵活性,并避免了紧密集成。

像 Django 这样更复杂的 Web 框架由于正好相反的原因 - 组件的紧密垂直集成、在选择组件方面缺乏灵活性、复杂的配置等等,因此不适合作为微服务框架的选择。

另一个专门用于在 Python 中实现微服务的框架是 Nameko。Nameko 旨在测试应用程序,并提供对不同通信协议的支持,如 HTTP、RPC(通过 AMQP)- 发布-订阅系统和定时器服务。

我们不会详细介绍这些框架。另一方面,我们将看一下如何使用微服务来设计和构建一个真实的 Web 应用程序示例。

微服务示例 - 餐厅预订

让我们以一个 Python Web 应用程序的真实例子为例,尝试将其设计为一组微服务。

我们的应用是一个餐厅预订应用程序,帮助用户在靠近他们当前位置的餐厅预订特定时间的一定人数。假设预订只能在同一天进行。

应用程序需要执行以下操作:

  1. 返回在用户想要进行预订的时间营业的餐厅列表。

  2. 对于给定的餐厅,返回足够的元信息,如菜肴选择、评分、定价等,并允许用户根据其标准筛选酒店。

  3. 一旦用户做出选择,允许他们为选定的餐厅预订一定数量的座位,预订时间。

这些要求中的每一个都足够细粒度,可以拥有自己的微服务。

因此,我们的应用程序将设计为以下一组微服务:

  • 使用用户的位置,并返回一份营业中的餐厅列表,并支持在线预订 API 的服务。

  • 第二个服务根据餐厅 ID 检索给定酒店的元数据。应用程序可以使用此元数据与用户的标准进行比较,以查看是否匹配。

  • 第三个服务,根据餐厅 ID、用户信息、所需座位数和预订时间,使用预订 API 进行座位预订,并返回状态。

应用程序逻辑的核心部分现在适合这三个微服务。一旦它们被实现,调用这些服务并执行预订的管道将直接发生在应用程序逻辑中。

我们不会展示此应用程序的任何代码,因为那是一个独立的项目,但我们将向读者展示微服务的 API 和返回数据是什么样子的。

微服务示例-餐厅预订

使用微服务的餐厅预订应用程序架构

微服务通常以 JSON 形式返回数据。例如,我们的第一个返回餐厅列表的服务将返回类似于以下内容的 JSON:

GET /restaurants?geohash=tdr1y1g1zgzc

{
    "8f95e6ad-17a7-48a9-9f82-07972d2bc660": {
        "name": "Tandoor",
        "address": "Centenary building, #28, MG Road b-01"
        "hours": "12.00 – 23.30"
	},
  "4307a4b1-6f35-481b-915b-c57d2d625e93": {
        "name": "Karavalli",
        "address": "The Gateway Hotel, 66, Ground Floor"
        "hours": "12.30 – 01:00"
	},
   ...
} 

返回餐厅元数据的第二个服务,大多会返回类似于以下内容的 JSON:

GET /restaurants/8f95e6ad-17a7-48a9-9f82-07972d2bc660

{

   "name": "Tandoor",
   "address": "Centenary building, #28, MG Road b-01"
   "hours": "12.00 – 23.30",
   "rating": 4.5,
   "cuisine": "north indian",
   "lunch buffet": "no",
   "dinner buffet": "no",
   "price": 800

} 

这是第三个互动,根据餐厅 ID 进行预订:

由于此服务需要用户提供预订信息,因此需要一个包含预订详细信息的 JSON 有效负载。因此,最好以 HTTP POST 调用进行。

POST  /restaurants/reserve

在这种情况下,该服务将使用以下给定的有效负载作为 POST 数据:

{
   "name": "Anand B Pillai",
   "phone": 9880078014,
   "time": "2017-04-14 20:40:00",
   "seats": 3,
   "id": "8f95e6ad-17a7-48a9-9f82-07972d2bc660"
} 

它将返回类似于以下内容的 JSON 作为响应:

{
   "status": "confirmed",
   "code": "WJ7D2B",
   "time": "2017-04-14 20:40:00",
   "seats": 3
}

有了这样的设计,很容易在您选择的框架中实现应用程序,无论是 Flask、Bottle、Nameko 还是其他任何东西。

微服务-优势

那么使用微服务而不是单体应用程序有哪些优势呢?让我们看看其中一些重要的优势:

  • 微服务通过将应用程序逻辑拆分为多个服务来增强关注点分离。这提高了内聚性,减少了耦合。由于业务逻辑不在一个地方,因此无需对系统进行自上而下的预先设计。相反,架构师可以专注于微服务和应用程序之间的相互作用和通信,让微服务的设计和架构通过重构逐步出现。

  • 微服务改善了可测试性,因为现在逻辑的每个部分都可以作为独立的服务进行独立测试,因此很容易与其他部分隔离并进行测试。

  • 团队可以围绕业务能力而不是应用程序或技术层的层次进行组织。由于每个微服务都包括逻辑、数据和部署,使用微服务的公司鼓励跨功能角色。这有助于构建更具敏捷性的组织。

  • 微服务鼓励去中心化数据。通常,每个服务都将拥有自己的本地数据库或数据存储,而不是单体应用程序所偏爱的中央数据库。

  • 微服务促进了持续交付和集成,以及快速部署。由于对业务逻辑的更改通常只需要对一个或几个服务进行小的更改,因此测试和重新部署通常可以在紧密的周期内完成,并且在大多数情况下可以完全自动化。

管道和过滤器架构

管道和过滤器是一种简单的架构风格,它连接了一些处理数据流的组件,每个组件通过管道连接到处理管道中的下一个组件。

管道和过滤器架构受到了 Unix 技术的启发,该技术通过 shell 上的管道将一个应用程序的输出连接到另一个应用程序的输入。

管道和过滤器架构由一个或多个数据源组成。数据源通过管道连接到数据过滤器。过滤器处理它们接收到的数据,并将它们传递给管道中的其他过滤器。最终的数据接收到一个数据接收器

管道和过滤器架构

管道和过滤器架构

管道和过滤器通常用于执行大量数据处理的应用程序,如数据分析、数据转换、元数据提取等。

过滤器可以在同一台机器上运行,并且它们使用实际的 Unix 管道或共享内存进行通信。然而,在大型系统中,这些通常在单独的机器上运行,管道不需要是实际的管道,而可以是任何类型的数据通道,如套接字、共享内存、队列等。

可以连接多个过滤器管道以执行复杂的数据处理和数据分段。

一个很好的使用这种架构的 Linux 应用程序的例子是gstreamer——这是一个多媒体处理库,可以对多媒体视频和音频执行多项任务,包括播放、录制、编辑和流式传输。

Python 中的管道和过滤器

在 Python 中,我们在多进程模块中以最纯粹的形式遇到管道。多进程模块提供了管道作为一种从一个进程到另一个进程进行通信的方式。

创建一个父子连接对的管道。在连接的一侧写入的内容可以在另一侧读取,反之亦然。

这使我们能够构建非常简单的数据处理管道。

例如,在 Linux 上,可以通过以下一系列命令计算文件中的单词数:

$ cat filename | wc -w

我们将使用多进程模块编写一个简单的程序,模拟这个管道:

# pipe_words.py
from multiprocessing import Process, Pipe
import sys

def read(filename, conn):
    """ Read data from a file and send it to a pipe """

    conn.send(open(filename).read())

def words(conn):
    """ Read data from a connection and print number of words """

    data = conn.recv()
    print('Words',len(data.split()))

if __name__ == "__main__":
    parent, child = Pipe()
    p1 = Process(target=read, args=(sys.argv[1], child))
    p1.start()
    p2 = Process(target=words, args=(parent,))
    p2.start()
    p1.join();p2.join()

以下是工作流程的分析:

  1. 创建了一个管道,并获得了两个连接。

  2. read函数作为一个进程执行,传递管道的一端(子进程)和要读取的文件名。

  3. 该进程读取文件,将数据写入连接。

  4. words函数作为第二个进程执行,将管道的另一端传递给它。

  5. 当此函数作为一个进程执行时,它从连接中读取数据,并打印单词的数量。

以下屏幕截图显示了相同文件上的 shell 命令和前面程序的输出:

Python 中的管道和过滤器

使用管道和其等效的 Python 程序的 shell 命令的输出

您不需要使用看起来像实际管道的对象来创建管道。另一方面,Python 中的生成器提供了一个很好的方式来创建一组可调用对象,它们相互调用,消耗和处理彼此的数据,产生数据处理的管道。

以下是与前一个示例相同的示例,重写为使用生成器,并且这次是处理文件夹中匹配特定模式的所有文件:

# pipe_words_gen.py

# A simple data processing pipeline using generators
# to print count of words in files matching a pattern.
import os

def read(filenames):
    """ Generator that yields data from filenames as (filename, data) tuple """

    for filename in filenames:
        yield filename, open(filename).read()

def words(input):
    """ Generator that calculates words in its input """

    for filename, data in input:
        yield filename, len(data.split())

def filter(input, pattern):
    """ Filter input stream according to a pattern """

    for item in input:
        if item.endswith(pattern):
            yield item

if __name__ == "__main__":
    # Source
    stream1 = filter(os.listdir('.'), '.py')
    # Piped to next filter
    stream2 = read(stream1)
    # Piped to last filter (sink)
    stream3 = words(stream2)

    for item in stream3:
        print(item)

以下是输出的屏幕截图:

Python 中的管道和过滤器

使用生成器输出管道的输出,打印 Python 程序的单词计数

注意

可以使用以下命令验证类似于前面程序的输出:

$ wc -w *.py

这是另一个程序,它使用另外两个数据过滤生成器来构建一个程序,该程序监视与特定模式匹配的文件并打印有关最近文件的信息,类似于 Linux 上的 watch 程序所做的事情:

# pipe_recent_gen.py
# Using generators, print details of the most recently modified file
# matching a pattern.

import glob
import os
from time import sleep

def watch(pattern):
    """ Watch a folder for modified files matching a pattern """

    while True:
        files = glob.glob(pattern)
        # sort by modified time
        files = sorted(files, key=os.path.getmtime)
        recent = files[-1]
        yield recent        
        # Sleep a bit
        sleep(1)

def get(input):
    """ For a given file input, print its meta data """
    for item in input:
        data = os.popen("ls -lh " + item).read()
        # Clear screen
        os.system("clear")
        yield data

if __name__ == "__main__":
    import sys

    # Source + Filter #1
    stream1 = watch('*.' + sys.argv[1])

    while True:
        # Filter #2 + sink
        stream2 = get(stream1)
        print(stream2.__next__())
        sleep(2)

这个最后一个程序的细节应该对读者是不言自明的。

这是我们在控制台上程序的输出,监视 Python 源文件:

Python 中的管道和过滤器

监视最近修改的 Python 源文件的程序输出

如果我们创建一个空的 Python 源文件,比如example.py,两秒后输出会发生变化:

Python 中的管道和过滤器

监视程序更改的输出,始终显示最近修改的文件

使用生成器(协程)构建这样的管道的基本技术是将一个生成器的输出连接到下一个生成器的输入。通过在系列中连接许多这样的生成器,可以构建从简单到复杂的数据处理管道。

当然,除了这些之外,我们还可以使用许多技术来构建管道。一些常见的选择是使用队列连接的生产者-消费者任务,可以使用线程或进程。我们在可扩展性章节中看到了这方面的例子。

微服务还可以通过将一个微服务的输入连接到另一个微服务的输出来构建简单的处理管道。

在 Python 第三方软件生态系统中,有许多模块和框架可以让您构建复杂的数据管道。Celery 虽然是一个任务队列,但可以用于构建具有有限管道支持的简单批处理工作流。管道不是 Celery 的强项,但它对于链接任务具有有限的支持,可以用于此目的。

Luigi 是另一个强大的框架,专为需要管道和过滤器架构的复杂、长时间运行的批处理作业而编写。Luigi 具有内置的支持 Hadoop 作业的功能,因此它是构建数据分析管道的良好选择。

总结

在本章中,我们看了一些构建软件的常见架构模式。我们从模型视图控制器架构开始,并在 Django 和 Flask 中看了一些例子。您了解了 MVC 架构的组件,并了解到 Django 使用模板实现了 MVC 的变体。

我们以 Flask 作为一个微框架的例子,它通过使用插件架构实现了 Web 应用程序的最小占地面积,并可以添加额外的服务。

我们继续讨论事件驱动的编程架构,这是一种使用协程和事件的异步编程。我们从一个在 Python 中使用select模块的多用户聊天示例开始。然后,我们继续讨论更大的框架和库。

我们讨论了 Twisted 的架构和其组件。我们还讨论了 Eventlet 及其近亲 gevent。对于这些框架,我们看到了多用户聊天服务器的实现。

接下来,我们以微服务作为架构,通过将核心业务逻辑分割到多个服务中来构建可扩展的服务和部署。我们设计了一个使用微服务的餐厅预订应用程序的示例,并简要介绍了可以用于构建微服务的 Python Web 框架的情况。

在本章的最后,我们看到了使用管道和过滤器进行串行和可扩展数据处理的架构。我们使用 Python 中的多进程模块构建了一个实际管道的简单示例,模仿了 Unix 的管道命令。然后,我们看了使用生成器构建管道的技术,并看了一些例子。我们总结了构建管道和 Python 第三方软件生态系统中可用框架的技术。

这就是应用架构章节的结束。在下一章中,我们将讨论可部署性-即将软件部署到生产系统等环境的方面。

第九章:部署 Python 应用程序

将代码推送到生产环境通常是将应用程序从开发环境带给客户的最后一步。尽管这是一项重要的活动,但在软件架构师的检查表中往往被忽视。

假设如果系统在开发环境中运行良好,它也会在生产环境中忠实地运行是一个非常常见且致命的错误。首先,生产系统的配置通常与开发环境大不相同。在开发人员的环境中可以使用和理所当然的许多优化和调试,在生产设置中通常是不可用的。

部署到生产环境是一门艺术,而不是一门精确的科学。系统部署的复杂性取决于许多因素,例如系统开发的语言、运行时可移植性和性能、配置参数的数量、系统是在同质环境还是异质环境中部署、二进制依赖关系、部署的地理分布、部署自动化工具等等。

近年来,作为一种开源语言,Python 在为生产系统部署软件包提供的自动化和支持水平上已经成熟。凭借其丰富的内置和第三方支持工具,生产部署和保持部署系统最新的痛苦和麻烦已经减少。

在本章中,我们将简要讨论可部署系统和可部署性的概念。我们将花一些时间了解 Python 应用程序的部署,以及架构师可以添加到其工具库中的工具和流程,以便轻松部署和维护使用 Python 编写的生产系统运行的应用程序。我们还将探讨架构师可以采用的技术和最佳实践,以使其生产系统在没有频繁停机的情况下健康安全地运行。

本章我们将讨论的主题列表如下。

  • 可部署性

  • 影响可部署性的因素

  • 软件部署架构的层次

  • Python 中的软件部署

  • 打包 Python 代码

Pip

Virtualenv

Virtualenv 和 Pip

PyPI- Python 软件包索引

应用程序的打包和提交

PyPA

  • 使用 Fabric 进行远程部署

  • 使用 Ansible 进行远程部署

  • 使用 Supervisor 管理远程守护程序

  • 部署-模式和最佳实践

可部署性

软件系统的可部署性是指将其从开发环境部署到生产环境的便捷程度。它可以根据部署代码所需的工作量(以人时计)或部署代码所需的不同步骤的数量来衡量其复杂性。

常见的错误是假设在开发或暂存系统中运行良好的代码在生产系统中会以类似的方式运行。由于生产系统与开发系统相比具有截然不同的要求,这种情况并不经常发生。

影响可部署性的因素

以下是一些区分生产系统和开发系统的因素的简要介绍,这些因素通常会导致部署中出现意外问题,从而导致生产陷阱

  • 优化和调试:在开发系统中关闭代码优化是非常常见的。

如果您的代码在像 Python 这样的解释运行时中运行,通常会打开调试配置,这允许程序员在发生异常时生成大量的回溯。此外,通常会关闭任何 Python 解释器优化。

另一方面,在生产系统中,情况正好相反 - 优化被打开,调试被关闭。这通常需要额外的配置才能使代码以类似的方式工作。也有可能(虽然很少)在某些情况下,程序在优化后的行为与在未经优化时运行时的行为不同。

  • 依赖项和版本:开发环境通常具有丰富的开发和支持库的安装,用于运行开发人员可能正在开发的多个应用程序。这些通常是开发人员经常使用的最新代码的依赖项。

生产系统,另一方面,需要使用预先编译的依赖项及其版本的列表进行精心准备。通常只指定成熟或稳定的版本用于在生产系统上部署是非常常见的。因此,如果开发人员依赖于下游依赖项的不稳定(alpha、beta 或发布候选)版本上可用的功能或错误修复,可能会发现 - 太迟了 - 该功能在生产中无法按预期工作。

另一个常见的问题是未记录的依赖项或需要从源代码编译的依赖项 - 这通常是首次部署时的问题。

  • 资源配置和访问权限:开发系统和生产系统在本地和网络资源的级别、权限和访问细节上通常有所不同。开发系统可能有一个本地数据库,而生产系统往往会为应用程序和数据库系统使用单独的托管。开发系统可能使用标准配置文件,而在生产中,配置可能需要使用特定脚本专门为主机或环境生成。同样,在生产中,可能需要以较低的权限作为特定用户/组运行应用程序,而在开发中,通常会以 root 或超级用户身份运行程序。用户权限和配置上的差异可能影响资源访问,并可能导致软件在生产中失败,而在开发环境中正常运行。

  • 异构的生产环境:代码通常是在通常是同质的开发环境中开发的。但通常需要在生产中部署到异构系统上。例如,软件可能在 Linux 上开发,但可能需要在 Windows 上进行客户部署。

部署的复杂性与环境的异质性成正比增加。在将此类代码带入生产之前,需要良好管理的分级和测试环境。此外,异构系统使依赖管理变得更加复杂,因为需要为每个目标系统架构维护一个单独的依赖项列表。

  • 安全性:在开发和测试环境中,通常会对安全性方面给予宽容以节省时间并减少测试的配置复杂性。例如,在 Web 应用程序中,需要登录的路由可能会使用特殊的开发环境标志来禁用,以便快速编程和测试。

同样,在开发环境中使用的系统可能经常使用易于猜测的密码,例如数据库系统、Web 应用程序登录等,以便轻松进行常规回忆和使用。此外,可能会忽略基于角色的授权以便进行测试。

然而,在生产中安全性至关重要,因此这些方面需要相反的处理。需要强制执行需要登录的路由。应该使用强密码。需要强制执行基于角色的身份验证。这些通常会在生产中引起微妙的错误,即在开发环境中正常工作的功能在生产中失败。

由于这些以及其他类似的问题是在生产中部署代码的困扰,已经定义了标准的实践方法,以使运维从业者的生活变得稍微容易一些。大多数公司都遵循使用隔离环境来开发、测试和验证代码和应用程序,然后再将它们推送到生产的做法。让我们来看一下。

软件部署架构的层

为了避免在从开发到测试,再到生产的过程中出现复杂性,通常在应用程序部署到生产之前的每个阶段使用多层架构是很常见的。

让我们来看一下以下一些常见的部署层:

  • 开发/测试/阶段/生产:这是传统的四层架构。

  • 开发人员将他们的代码推送到开发环境,进行单元测试和开发人员测试。这个环境总是处于最新的代码状态。很多时候这个环境会被跳过,用开发人员的笔记本电脑上的本地设置替代。

  • 然后,软件由测试工程师在测试环境中使用黑盒技术进行测试。他们也可能在这个环境上运行性能测试。这个环境在代码更新方面总是落后于开发环境。通常,内部发布、标签或代码转储用于将 QA 环境与开发环境同步。

  • 阶段环境试图尽可能地模拟生产环境。这是预生产阶段,在这个环境中,软件在尽可能接近部署环境的环境中进行测试,以提前发现可能在生产中出现的问题。这个环境通常用于运行压力测试或负载测试。它还允许运维工程师测试他的部署自动化脚本、定时作业,并验证系统配置。

  • 生产环境当然是最终的阶段,经过阶段测试的软件被推送和部署。许多部署通常使用相同的阶段/生产阶段,并且只是从一个切换到另一个。

  • 开发和测试/阶段/生产:这是前一个层的变体,其中开发环境也兼具测试环境的双重职责。这种系统用于采用敏捷软件开发实践的公司,其中代码至少每周推送一次到生产环境,没有空间或时间来保留和管理一个单独的测试环境。当没有单独的开发环境时——即开发人员使用他们的笔记本电脑进行编程时——测试环境也是一个本地环境。

  • 开发和测试/阶段和生产:在这种设置中,阶段和生产环境完全相同,使用多个服务器。一旦系统在阶段中经过测试和验证,它就会通过简单地切换主机被推送到生产环境——当前的生产系统切换到阶段,阶段切换到生产。

除此之外,还可以有更复杂的架构,其中使用一个单独的集成环境进行集成测试,一个沙盒环境用于测试实验性功能,等等。

使用分阶段系统对确保软件在类生产环境中经过充分测试和协调后再推送代码到生产环境是很重要的。

Python 中的软件部署

正如前面提到的,Python 开发人员在 Python 提供的各种工具以及第三方生态系统中,可以轻松自动化地部署使用 Python 编写的应用程序和代码。

在这一部分,我们将简要地看一下其中一些工具。

打包 Python 代码

Python 内置支持为各种分发打包应用程序——源代码、二进制和特定的操作系统级打包。

在 Python 中打包源代码的主要方式是编写一个setup.py文件。然后可以借助内置的distutils库或更复杂、丰富的setuptools框架来打包源代码。

在我们开始了解 Python 打包的内部机制之前,让我们先熟悉一下几个相关的工具,即pipvirtualenv

Pip

Pip 是Pip installs packages的递归缩写。Pip 是 Python 中安装软件包的标准和建议工具。

在本书中我们一直看到 pip 在工作,但到目前为止,我们从未看到 pip 本身被安装过,对吧?

让我们在以下截图中看到这一点:

Pip

下载并安装 Python3 的 pip

pip 安装脚本可在bootstrap.pypa.io/get-pip.py找到。

这些步骤应该是不言自明的。

注意

在上面的例子中,已经有一个 pip 版本,所以这个操作是升级现有版本,而不是进行全新安装。我们可以通过使用–version选项来尝试程序来查看版本详细信息,如下所示:

看一下以下截图:

Pip

打印当前 pip 版本(pip3)

看到 pip 清楚地打印出其版本号以及安装的目录位置,以及其所安装的 Python 版本。

注意

要区分 Python2 和 Python3 版本的 pip,记住为 Python3 安装的版本始终命名为pip3。Python2 版本是pip2,或者只是pip

使用 pip 安装软件包,只需通过install命令提供软件包名称即可。例如,以下截图显示了使用pip安装numpy软件包:

Pip

我们不会在这里进一步讨论使用 pip 的细节。相反,让我们来看看另一个与 pip 密切相关的工具,它用于安装 Python 软件。

Virtualenv

Virtualenv 是一个允许开发人员为本地开发创建沙盒式 Python 环境的工具。假设您想要为同时开发的两个不同应用程序维护特定库或框架的两个不同版本。

如果要将所有内容安装到系统 Python 中,那么您一次只能保留一个版本。另一个选项是在不同的根文件夹中创建不同的系统 Python 安装——比如/opt而不是/usr。然而,这会带来额外的开销和路径管理方面的麻烦。而且,如果您希望在没有超级用户权限的共享主机上维护版本依赖关系,那么您将无法获得对这些文件夹的写入权限。

Virtualenv 解决了权限和版本问题。它创建一个带有自己的 Python 可执行标准库和安装程序(默认为 pip)的本地安装目录。

一旦开发人员激活了这样创建的虚拟环境,任何进一步的安装都会进入这个环境,而不是系统 Python 环境。

可以使用 pip 来安装 Virtualenv。

以下截图显示了使用virtualenv命令创建名为appvenv的虚拟环境,并激活该环境以及在环境中安装软件包。

注意

安装还会安装 pip、setuptools 和其他依赖项。

Virtualenv

注意

看到pythonpip命令指向虚拟环境内部的命令。pip –version命令清楚地显示了虚拟环境文件夹内pip的路径。

从 Python 3.3 开始,对虚拟环境的支持已经内置到 Python 安装中,通过新的venv库。

以下截图显示了在 Python 3.5 中使用该库安装虚拟环境,并在其中安装一些软件包。像往常一样,查看 Python 和 pip 可执行文件的路径:

Virtualenv

注意

上述屏幕截图还显示了如何通过pip命令升级 pip 本身。

Virtualenv 和 pip

一旦为您的应用程序设置了虚拟环境并安装了所需的软件包,最好生成依赖项及其版本。可以通过以下命令轻松完成:

$ pip freeze

此命令要求 pip 输出所有已安装的 Python 软件包及其版本的列表。这可以保存到一个 requirements 文件中,并在服务器上进行镜像部署时进行设置复制:

Virtualenv and pip

以下屏幕截图显示了通过 pip install 命令的-r选项在另一个虚拟环境中重新创建相同的设置,该选项接受此类文件作为输入:

Virtualenv 和 pip

注意

我们的源虚拟环境是 Python2,目标是 Python3。但是,pip 能够无任何问题地从requirements.txt文件中安装依赖项。

可重定位的虚拟环境

从一个虚拟环境复制软件包依赖项到另一个虚拟环境的建议方法是执行冻结,并按照前一节中所示通过 pip 进行安装。例如,这是从开发环境中冻结 Python 软件包要求并成功地在生产服务器上重新创建的最常见方法。

还可以尝试使虚拟环境可重定位,以便可以将其存档并移动到兼容的系统。

可重定位的虚拟环境

创建可重定位的虚拟环境

它是如何工作的:

  1. 首先,通常创建虚拟环境。

  2. 然后通过运行virtualenv –relocatable lenv来使其可重定位。

  3. 这会将 setuptools 使用的一些路径更改为相对路径,并设置系统可重定位。

  4. 这样的虚拟环境可以重定位到同一台机器上的另一个文件夹,或者重定位到远程和相似的机器上的文件夹。

注意

可重定位的虚拟环境并不保证在远程环境与机器环境不同时能正常工作。例如,如果您的远程机器是不同的架构,甚至使用另一种类型的 Linux 发行版进行打包,重定位将无法正常工作。这就是所谓的相似的机器

PyPI

我们了解到 Pip 是 Python 中进行软件包安装的标准化工具。只要存在,它就能够按名称选择任何软件包。正如我们在 requirements 文件的示例中看到的,它也能够按版本安装软件包。

但是 pip 从哪里获取软件包呢?

要回答这个问题,我们转向 Python 软件包索引,更常被称为 PyPI。

Python 软件包索引(PyPI)是官方的第三方 Python 软件包在 Web 上托管元数据的存储库。顾名思义,它是 Web 上 Python 软件包的索引,其元数据发布并在服务器上进行索引。PyPI 托管在 URL pypi.python.org

PyPI 目前托管了接近一百万个软件包。这些软件包是使用 Python 的打包和分发工具 distutils 和 setuptools 提交到 PyPI 的,这些工具具有用于将软件包元数据发布到 PyPI 的钩子。许多软件包还在 PyPI 中托管实际软件包数据,尽管 PyPI 可以用于指向位于另一台服务器上 URL 的软件包数据。

当您使用 pip 安装软件包时,实际上是在 PyPI 上搜索软件包,并下载元数据。它使用元数据来查找软件包的下载 URL 和其他信息,例如进一步的下游依赖项,这些信息用于为您获取和安装软件包。

以下是 PyPI 的屏幕截图,显示了此时软件包的实际数量:

PyPI

开发人员可以在 PyPI 网站上直接执行许多操作:

  1. 使用电子邮件地址注册并登录网站。

  2. 登录后,直接在网站上提交您的软件包。

  3. 通过关键字搜索软件包。

  4. 通过一些顶级trove分类器浏览软件包,例如主题、平台/操作系统、开发状态、许可证等。

现在我们已经熟悉了所有 Python 打包和安装工具及其关系,让我们尝试一个小例子,将一个简单的 Python 模块打包并提交到 PyPI。

软件包的打包和提交

请记住,我们曾经开发过一个 mandelbrot 程序,它使用 pymp 进行缩放,在第五章中,编写可扩展的应用程序。我们将以此作为一个开发软件包的示例程序,并使用setup.py文件将该应用程序提交到 PyPI。

我们将 mandelbrot 应用程序打包成一个主包,其中包含两个子包,如下所示:

  • mandelbrot.simple:包含 mandelbrot 基本实现的子包(子模块)

  • mandelbrot.mp:包含 mandelbrot 的 PyMP 实现的子包(子模块)

以下是我们软件包的文件夹结构:

软件包的打包和提交

mandelbrot 软件包的文件夹布局

让我们快速分析一下我们将要打包的应用程序的文件夹结构:

  • 顶级目录名为mandelbrot。它有一个__init__.py,一个README和一个setup.py文件。

  • 该目录有两个子目录——mpsimple

  • 每个子文件夹都包括两个文件,即__init__.pymandelbrot.py。这些子文件夹将形成我们的子模块,每个子模块包含 mandelbrot 集的相应实现。

注意

为了将 mandelbrot 模块安装为可执行脚本,代码已更改以向我们的每个mandelbrot.py模块添加main方法。

__init__.py文件

__init__.py文件允许将 Python 应用程序中的文件夹转换为软件包。我们的文件夹结构有三个:第一个是顶级软件包mandelbrot,其余两个分别是每个子包mandelbrot.simplemandelbrot.mp

顶级__init__.py为空。其他两个有以下单行:

from . import mandelbrot

注意

相对导入是为了确保子包导入本地的mandelbrot.py模块,而不是顶级mandelbrot软件包。

setup.py文件

setup.py文件是整个软件包的中心点。让我们来看一下:

from setuptools import setup, find_packages
setup(
    name = "mandelbrot",
    version = "0.1",
    author = "Anand B Pillai",
    author_email = "abpillai@gmail.com",
    description = ("A program for generating Mandelbrot fractal images"),
    license = "BSD",
    keywords = "fractal mandelbrot example chaos",
    url = "http://packages.python.org/mandelbrot",
    packages = find_packages(),
    long_description=open('README').read(),
    classifiers=[
        "Development Status :: 4 - Beta",
        "Topic :: Scientific/Engineering :: Visualization",
        "License :: OSI Approved :: BSD License",
    ],
    install_requires = [
        'Pillow>=3.1.2',
        'pymp-pypi>=0.3.1'
        ],
    entry_points = {
        'console_scripts': [
            'mandelbrot = mandelbrot.simple.mandelbrot:main',
            'mandelbrot_mp = mandelbrot.mp.mandelbrot:main'
            ]
        }
)

setup.py文件的全面讨论超出了本章的范围,但请注意以下几个关键点:

  • setup.py文件允许作者创建许多软件包元数据,例如名称、作者名称、电子邮件、软件包关键字等。这些对于创建软件包元信息非常有用,一旦提交到 PyPI,就可以帮助人们搜索软件包。

  • 该文件中的一个主要字段是packages,它是由此setup.py文件创建的软件包(和子软件包)的列表。我们使用 setuptools 模块提供的find_packages辅助函数来实现这一点。

  • 我们在install-requires键中提供了安装要求,以 PIP 格式逐个列出依赖项。

  • entry_points键用于配置此软件包安装的控制台脚本(可执行程序)。让我们看其中一个:

mandelbrot = mandelbrot.simple.mandelbrot:main

这告诉包资源加载器加载名为mandelbrot.simple.mandelbrot的模块,并在调用脚本mandelbrot时执行其函数main

安装软件包

现在可以使用以下命令安装软件包:

$ python setup.py install

安装的以下截图显示了一些初始步骤:

安装软件包

注意

我们已将此软件包安装到名为env3的虚拟环境中。

将软件包提交到 PyPI

Python 中的setup.py文件加上 setuptools/distutils 生态系统不仅可以用于安装和打包代码,还可以用于将代码提交到 Python 软件包索引。

将软件包注册到 PyPI 非常容易。只有以下两个要求:

  • 具有适当setup.py文件的软件包。

  • PyPI 网站上的一个帐户。

现在,我们将通过以下步骤将我们的新 mandelbrot 软件包提交到 PyPI:

  1. 首先,需要在家目录中创建一个名为.pypirc的文件,其中包含一些细节,主要是 PyPI 帐户的身份验证细节。

这是作者的.pypirc文件,其中密码被隐藏:

将软件包提交到 PyPI

  1. 完成此操作后,注册就像运行setup.py并使用register命令一样简单:
$ python setup.py register

下一张截图显示了控制台上实际命令的执行情况:

将软件包提交到 PyPI

然而,这最后一步只是通过提交其元数据注册了软件包。在此步骤中并未提交软件包数据,如源代码数据。

  1. 要将源代码提交到 PyPI,应运行以下命令:
$ python setup.py sdist upload

将软件包提交到 PyPI

这是我们在 PyPI 服务器上的新软件包的视图:

将软件包提交到 PyPI

现在,通过 pip 安装软件包,完成了软件开发的循环:首先是打包、部署,然后是安装。

PyPA

Python Packaging AuthorityPyPA)是一群维护 Python 打包标准和相关应用程序的 Python 开发人员的工作组。PyPA 在www.pypa.io/上有他们的网站,并在 GitHub 上维护应用程序github.com/pypa/

以下表格列出了由 PyPA 维护的项目。您已经看到了其中一些,比如 pip、virtualenv 和 setuptools;其他可能是新的:

项目 描述
setuptools 对 Python distutils 的增强集合
virtualenv 用于创建沙盒 Python 环境的工具
pip 用于安装 Python 软件包的工具
packaging pip 和 setuptools 使用的核心 Python 打包实用程序
wheel 用于创建 wheel 分发的 setuptools 扩展,它是 Python eggs(ZIP 文件)的替代方案,并在 PEP 427 中指定
twine 用于创建 wheel 分发的setup.py上传的安全替代品
warehouse 新的 PyPI 应用程序,可以在pypi.org上查看
distlib 一个实现与 Python 代码打包和分发相关功能的低级库
bandersnatch 用于镜像 PyPI 内容的 PyPI 镜像客户端

有兴趣的开发人员可以访问 PyPA 网站,并注册其中一个项目,并通过访问 PyPA 的 github 存储库,以进行测试、提交补丁等方面的贡献。

使用 Fabric 进行远程部署

Fabric 是一个用 Python 编写的命令行工具和库,它通过一组对 SSH 协议的良好定义的包装器来自动化服务器上的远程部署。它在幕后使用ssh-wrapperparamiko

Fabric 仅适用于 Python 2.x 版本。但是,有一个名为 Fabric3 的分支,可以同时适用于 Python 2.x 和 3.x 版本。

使用 fabric 时,devops 用户通常将远程系统管理员命令部署为名为fabfile.py的 Python 函数。

当远程系统已经配置了用户机器的 ssh 公钥时,Fabric 的工作效果最佳,因此无需提供用户名和密码。

以下是在服务器上进行远程部署的示例。在这种情况下,我们正在将我们的 mandelbrot 应用程序安装到远程服务器上。

fabfile 如下所示。请注意,它是为 Python3 编写的:

from fabric.api import run

def remote_install(application):

    print ('Installing',application)
    run('sudo pip install ' + application)

以下是一个在远程服务器上安装并运行的示例:

使用 Fabric 进行远程部署

Devops 工程师和系统管理员可以使用预定义的 fabfiles 集合来自动化不同的系统和应用程序部署任务,跨多个服务器。

注意

虽然 Fabric 是用 Python 编写的,但可以用于自动化任何类型的远程服务器管理和配置任务。

使用 Ansible 进行远程部署

Ansible 是用 Python 编写的配置管理和部署工具。Ansible 可以被视为在 SSH 上使用脚本的包装器,支持通过易于管理的单元(称为playbooks)组装的任务进行编排,将一组主机映射到一组角色。

Ansible 使用“facts”,这是它在运行任务之前收集的系统和环境信息。它使用这些 facts 来检查是否有任何需要在运行任务之前改变任何状态的情况。

这使得 Ansible 任务可以安全地在服务器上以重复的方式运行。良好编写的 ansible 任务是幂等的,对远程系统几乎没有副作用。

Ansible 是用 Python 编写的,可以使用 pip 安装。

它使用自己的主机文件,即/etc/ansible/hosts,来保存其运行任务的主机信息。

典型的 ansible 主机文件可能如下所示,

[local]
127.0.0.1

[webkaffe]
139.162.58.8

以下是一个名为dependencies.yaml的 Ansible playbook 的片段,它在名为webkaffe的远程主机上通过 pip 安装了一些 Python 包。

---
- hosts: webkaffe
  tasks:
    - name: Pip - Install Python Dependencies
      pip:
          name="{{ python_packages_to_install | join(' ') }}"

      vars:
          python_packages_to_install:
          - Flask
          - Bottle
          - bokeh

这是在使用 ansible-playbook 命令行运行此 playbook 的图像。

使用 Ansible 进行远程部署

Ansible 是管理远程依赖项的一种简单有效的方式,由于其幂等 playbooks,比 Fabric 更适合执行任务。

使用 Supervisor 管理远程守护进程

Supervisor 是一个客户端/服务器系统,对于控制 Unix 和类 Unix 系统上的进程非常有用。它主要由一个名为supervisord的服务器守护进程和一个与服务器交互的命令行客户端supervisorctl组成。

Supervisor 还带有一个基本的 Web 服务器,可以通过端口 9001 访问。可以通过此界面查看运行进程的状态,并通过此界面启动/停止它们。Supervisor 不在任何版本的 Windows 上运行。

Supervisor 是一个使用 Python 编写的应用程序,因此可以通过 pip 安装。它仅在 Python 2.x 版本上运行。

通过 supervisor 管理的应用程序应该通过 supervisor 守护程序的配置文件进行配置。默认情况下,这些文件位于/etc/supervisor.d/conf文件夹中。

然而,也可以通过将其安装到虚拟环境中并将配置保留在虚拟环境中来在本地运行 Supervisor。事实上,这是运行多个管理特定于虚拟环境的进程的常见方式。

我们不会详细介绍或举例使用 Supervisor,但以下是使用 Supervisor 与传统方法(如系统rc.d脚本)相比的一些好处:

  • 通过使用客户端/服务器系统来解耦进程创建/管理和进程控制。supervisor.d文件通过子进程管理进程。用户可以通过客户端 supervisorctl 获取进程状态信息。此外,大多数传统的 rc.d 进程需要 root 或 sudo 访问权限,而 supervisor 进程可以通过系统的普通用户通过客户端或 Web UI 进行控制。

  • 由于 supervisord 通过子进程启动进程,可以配置它们在崩溃时自动重新启动。相比依赖 PID 文件,更容易获得子进程的更准确状态。

  • 监管者支持进程组,允许用户按优先级顺序定义进程。进程可以作为一组按特定顺序启动和停止。当应用程序中的进程之间存在时间依赖性时,这允许实现精细的进程控制。(进程 B 需要 A 正在运行,C 需要 B 正在运行,依此类推。)

我们将在本章中完成讨论,概述常见的部署模式,架构师可以选择以解决可部署性的常见问题。

部署-模式和最佳实践

有不同的部署方法或模式可用于解决停机时间、减少部署风险以及无缝开发和部署软件的问题。

  • 持续部署:持续部署是一种部署模型,其中软件随时可以准备上线。只有在包括开发、测试和暂存在内的各个层次不断集成的情况下,才能实现持续交付。在持续部署模型中,一天内可以发生多次生产部署,并且可以通过部署管道自动完成。由于不断部署增量更改,持续部署模式最小化了部署风险。在敏捷软件开发公司,这也有助于客户通过几乎在开发和测试结束后立即在生产中看到实时代码来直接跟踪进展。还有一个额外的优势,即更快地获得用户反馈,从而允许更快地对代码和功能进行迭代。

  • 蓝绿部署:我们已经在第五章中讨论过这个问题。蓝绿部署保持两个生产环境,彼此非常相似。在某个时刻,一个环境是活跃的(蓝色)。您将新的部署更改准备到另一个环境(绿色),一旦测试并准备好上线,切换系统——绿色变为活跃,蓝色变为备份。蓝绿部署大大降低了部署风险,因为对于新部署出现的任何问题,您只需要切换路由器或负载均衡器到新环境。通常,在典型的蓝绿系统中,一个系统是生产(活跃)的,另一个是暂存的,您可以在它们之间切换角色。

  • 金丝雀发布:如果您想在将软件更改部署给所有客户的整个受众之前,先在用户的子集上测试这些更改,您可以使用这种方法。在金丝雀发布中,更改首先针对一小部分用户进行推出。一个简单的方法是狗食,首先将更改内部推出给员工。另一种方法是测试版,邀请一组特定的受众来测试您的早期功能。其他涉及的方法包括根据地理位置、人口统计和个人资料选择用户。金丝雀发布除了使公司免受对糟糕管理的功能的突然用户反应之外,还可以以递增方式管理负载和容量扩展。例如,如果某个特定功能变得受欢迎,并且开始将比以前多 100 倍的用户驱动到您的服务器,传统的部署可能会导致服务器故障和可用性问题,而不是使用金丝雀发布进行逐步部署。地理路由是一种技术,可以用来选择用户的子集,如果您不想进行复杂的用户分析和分析。这是将负载发送到部署在特定地理位置或数据中心的节点,而不是其他节点。金丝雀发布也与增量部署或分阶段部署的概念相关。

  • 桶测试(A/B 测试):这是一种在生产中部署两个不同版本的应用程序或网页来测试哪个版本更受欢迎和/或更具吸引力的技术。在生产中,你的一部分受众看到应用程序(或页面)的 A 版本——控制或基本版本——另一部分看到 B 版本或修改(变体)版本。通常,这是一个 50-50 的分割,尽管与金丝雀发布一样,用户配置文件、地理位置或其他复杂模型可以被使用。用户体验和参与度是通过分析仪表板收集的,然后确定更改是否有积极、消极或中性的响应。

  • 诱发混乱:这是一种故意引入错误或禁用生产部署系统的一部分来测试其对故障的弹性和/或可用性的技术。

生产服务器存在漂移问题——除非你使用持续部署或类似的方法进行同步,否则,生产服务器通常会偏离标准配置。测试系统的一种方法是去故意禁用生产系统的一部分——例如,通过禁用负载均衡器配置中随机 50%的节点,然后观察系统的其余部分的表现。

寻找和清除未使用代码的类似方法是去注入随机的秘密部分配置,使用一个你怀疑是多余且不再需要的 API。然后观察应用在生产环境中的表现。如果一个随机的秘密会导致 API 失败,那么如果应用的某个部分仍然使用依赖的代码,它将在生产中失败。否则,这表明代码可以安全地移除。

Netflix 有一个名为混沌猴的工具,它会自动在生产系统中引入故障,然后衡量影响。

诱发混乱允许 DevOps 工程师和架构师了解系统的弱点,了解正在经历配置漂移的系统,并找到并清除应用程序中不必要或未使用的部分。

总结

这一章是关于将你的 Python 代码部署到生产环境。我们看了影响系统可部署性的不同因素。我们继续讨论了部署架构中的层次,比如传统的四层和三层、两层架构,包括开发、测试、暂存/QA 和生产层的组合。

然后我们讨论了打包 Python 代码的细节。我们详细讨论了 pip 和 virtualenv 这两个工具。我们看了 pip 和 virtualenv 如何一起工作,以及如何使用 pip 安装一组要求,并使用它设置类似的虚拟环境。我们还简要介绍了可重定位的虚拟环境。

然后我们讨论了 PyPI——Python 包索引,它在网络上托管 Python 第三方包。然后我们通过一个详细的例子讨论了如何使用 setuptools 和setup.py文件设置 Python 包。在这种情况下,我们使用 mandelbrot 应用程序作为例子。

我们通过展示如何使用元数据将包注册到 PyPI,并且如何上传包括代码在内的包数据来结束了这次讨论。我们还简要介绍了 PyPA,即 Python Packaging Authority 及其项目。

之后,我们讨论了两个工具——都是用 Python 开发的——Fabric 用于远程自动部署,Supervisor 用于 Unix 系统上的远程进程管理。我们以概述常见的部署模式结束了这一章,这些模式可以用来解决部署问题。

在本书的最后一章中,我们讨论了一系列调试代码的技术,以找出潜在的问题。

第十章:调试技术

调试程序通常会像编写程序一样困难,有时甚至更困难。很多时候,程序员似乎会花费大量的时间寻找那个难以捉摸的错误,其原因可能正盯着他们,却不显露出来。

许多开发人员,甚至是优秀的开发人员,发现故障排除是一门困难的艺术。大多数情况下,程序员在简单的方法,如适当放置的打印语句和策略性注释的代码等方法无法解决问题时,就会求助于复杂的调试技术。

Python 在调试代码时会带来自己的一套问题。作为一种动态类型的语言,由于程序员假设类型是某种类型(当它实际上是其他类型),类型相关的异常在 Python 中是非常常见的。名称错误和属性错误也属于类似的范畴。

在本章中,我们将专注于软件的这一少讨论的方面。

这是一个按主题分类的列表,我们将在本章中遇到的内容:

  • 最大子数组问题:

  • “打印”的力量

  • 分析和重写

  • 计时和优化代码

  • 简单的调试技巧和技术:

  • 单词搜索程序

  • 单词搜索程序-调试步骤 1

  • 单词搜索程序-调试步骤 2

  • 单词搜索程序-最终代码

  • 跳过代码块

  • 停止执行

  • 外部依赖-使用包装器

  • 用返回值/数据替换函数(模拟)

  • 将数据保存到/从文件加载为缓存

  • 将数据保存到/从内存加载为缓存

  • 返回随机/模拟数据

生成随机患者数据

  • 日志记录作为调试技术:

  • 简单的应用程序日志记录

  • 高级日志记录-记录器对象

高级日志记录-自定义格式和记录器

高级日志记录-写入 syslog

  • 调试工具-使用调试器:

  • 与 pdb 一起进行调试会话

  • Pdb-类似工具

iPdb

Pdb++

  • 高级调试-跟踪:

  • 跟踪模块

  • lptrace 程序

  • 使用 strace 进行系统调用跟踪

好的,让我们调试一下!

最大子数组问题

首先,让我们看一个有趣的问题。在这个问题中,目标是找到一个混合负数和正数的整数数组(序列)的最大连续子数组。

例如,假设我们有以下数组:

>>> a  = [-5, 20, -10, 30, 15]

通过快速扫描很明显,最大和的子数组是[20, -10, 30, 15],得到和55

让我们说,作为第一步,你写下了这段代码:

import itertools

# max_subarray: v1
def max_subarray(sequence):
    """ Find sub-sequence in sequence having maximum sum """

    sums = []

    for i in range(len(sequence)):
        # Create all sub-sequences in given size
        for sub_seq in itertools.combinations(sequence, i):
            # Append sum
            sums.append(sum(sub_seq))

    return max(sums)

现在让我们试一下:

>>>  max_subarray([-5, 20, -10, 30, 15])
65

这个输出看起来显然是错误的,因为在数组中手动添加任何子数组似乎都不会产生大于 55 的数字。我们需要调试代码。

“打印”的力量

为了调试前面的例子,一个简单而策略性放置的“打印”语句就可以解决问题。让我们在内部的for循环中打印出子序列:

函数修改如下:

max_subarray:v1

def max_subarray(sequence):
    """ Find sub-sequence in sequence having maximum sum """

    sums = []
    for i in range(len(sequence)):
        for sub_seq in itertools.combinations(sequence, i):
            sub_seq_sum = sum(sub_seq)
            print(sub_seq,'=>',sub_seq_sum)
            sums.append(sub_seq_sum)

    return max(sums)

现在代码执行并打印出这个输出:

>>> max_subarray([-5, 20, -10, 30, 15])
((), '=>', 0)
((-5,), '=>', -5)
((20,), '=>', 20)
((-10,), '=>', -10)
((30,), '=>', 30)
((15,), '=>', 15)
((-5, 20), '=>', 15)
((-5, -10), '=>', -15)
((-5, 30), '=>', 25)
((-5, 15), '=>', 10)
((20, -10), '=>', 10)
((20, 30), '=>', 50)
((20, 15), '=>', 35)
((-10, 30), '=>', 20)
((-10, 15), '=>', 5)
((30, 15), '=>', 45)
((-5, 20, -10), '=>', 5)
((-5, 20, 30), '=>', 45)
((-5, 20, 15), '=>', 30)
((-5, -10, 30), '=>', 15)
((-5, -10, 15), '=>', 0)
((-5, 30, 15), '=>', 40)
((20, -10, 30), '=>', 40)
((20, -10, 15), '=>', 25)
((20, 30, 15), '=>', 65)
((-10, 30, 15), '=>', 35)
((-5, 20, -10, 30), '=>', 35)
((-5, 20, -10, 15), '=>', 20)
((-5, 20, 30, 15), '=>', 60)
((-5, -10, 30, 15), '=>', 30)
((20, -10, 30, 15), '=>', 55)
65

通过查看打印语句的输出,问题现在变得清晰了。

有一个子数组[20, 30, 15](在前面的输出中用粗体标出),产生和65。然而,这不是一个有效的子数组,因为元素在原始数组中不是连续的。

显然,程序是错误的,需要修复。

分析和重写

快速分析告诉我们,使用itertools.combinations在这里是罪魁祸首。我们使用它作为一种快速从数组中生成所有不同长度的子数组的方法,但是使用组合尊重项目的顺序,并生成所有组合,产生不连续的子数组。

显然,我们需要重写这个。这是重写的第一次尝试:

max_subarray:v2

def max_subarray(sequence):
    """ Find sub-sequence in sequence having maximum sum """

    sums = []

    for i in range(len(sequence)):
        for j in range(i+1, len(sequence)):
            sub_seq = sequence[i:j]
            sub_seq_sum = sum(sub_seq)
            print(sub_seq,'=>',sub_seq_sum)
            sums.append(sum(sub_seq))

    return max(sums)

现在输出如下:

>>> max_subarray([-5, 20, -10, 30, 15])
([-5], '=>', -5)
([-5, 20], '=>', 15)
([-5, 20, -10], '=>', 5)
([-5, 20, -10, 30], '=>', 35)
([20], '=>', 20)
([20, -10], '=>', 10)
([20, -10, 30], '=>', 40)
([-10], '=>', -10)
([-10, 30], '=>', 20)
([30], '=>', 30)
40

答案再次不正确,因为它给出了次优解40,而不是正确的解答55。再次,打印语句挺身而出,因为它清楚地告诉我们,主数组本身没有被考虑进去-我们有一个偏移一个的错误。

注意

在编程中,当用于迭代序列(数组)的数组索引比正确值要少一个或多一个时,就会出现一个偏差或一次性错误。这经常出现在序列的索引从零开始的语言中,比如 C/C++、Java 或 Python。

在这种情况下,off-by-one错误在这一行中:

    "sub_seq = sequence[i:j]"

正确的代码应该是这样的:

    "sub_seq = sequence[i:j+1]"

有了这个修复,我们的代码产生了预期的输出:

max_subarray: v2

def max_subarray(sequence):
    """ Find sub-sequence in sequence having maximum sum """

    sums = []

    for i in range(len(sequence)):
        for j in range(i+1, len(sequence)):
            sub_seq = sequence[i:j+1]
            sub_seq_sum = sum(sub_seq)
          print(sub_seq,'=>',sub_seq_sum)
            sums.append(sub_seq_sum)

    return max(sums)

以下是输出:

>>> max_subarray([-5, 20, -10, 30, 15])
([-5, 20], '=>', 15)
([-5, 20, -10], '=>', 5)
([-5, 20, -10, 30], '=>', 35)
([-5, 20, -10, 30, 15], '=>', 50)
([20, -10], '=>', 10)
([20, -10, 30], '=>', 40)
([20, -10, 30, 15], '=>', 55)
([-10, 30], '=>', 20)
([-10, 30, 15], '=>', 35)
([30, 15], '=>', 45)
55

让我们在这一点上假设您认为代码已经完成。

您将代码传递给审阅人员,他们提到您的代码,尽管被称为max_subarray,但实际上忘记了返回子数组本身,而只返回了总和。还有反馈说您不需要维护一个总和数组。

您结合这些反馈,生成了修复了这两个问题的代码版本 3.0:

max_subarray: v3

def max_subarray(sequence):
    """ Find sub-sequence in sequence having maximum sum """

    # Trackers for max sum and max sub-array
    max_sum, max_sub = 0, []

    for i in range(len(sequence)):
        for j in range(i+1, len(sequence)):
            sub_seq = sequence[i:j+1]
            sum_s = sum(sub_seq)
            if sum_s > max_sum:
                # If current sum > max sum so far, replace the values
                max_sum, max_sub = sum_s, sub_seq

    return max_sum, max_sub

>>>  max_subarray([-5, 20, -10, 30, 15])
(55, [20, -10, 30, 15])

注意,我们在最后一个版本中删除了打印语句,因为逻辑已经正确,所以不需要调试。

一切正常。

计时和优化代码

如果您稍微分析一下代码,您会发现代码对整个序列进行了两次遍历,一次外部遍历,一次内部遍历。因此,如果序列包含n个项目,代码将执行nn*次遍历。

我们从第四章中知道,良好的性能是值得的!,关于性能,这样一段代码的性能是O(n2)。我们可以使用简单的上下文管理器with运算符来测量代码的实际运行时间。

我们的上下文管理器如下:

import time
from contextlib import contextmanager

@contextmanager
def timer():
    """ Measure real-time execution of a block of code """

    try:
        start = time.time()
        yield
    finally:
        end = (time.time() - start)*1000
        print 'time taken=> %.2f ms' % end

让我们修改代码,创建一个不同大小的随机数数组来测量所花费的时间。我们将为此编写一个函数:

import random

def num_array(size):
    """ Return a list of numbers in a fixed random range
    of given size """

    nums = []
    for i in range(size):
        nums.append(random.randrange(-25, 30))
    return nums

让我们测试各种大小的数组的逻辑,从 100 开始:

>>> with timer():
... max_subarray(num_array(100))
... (121, [7, 10, -17, 3, 21, 26, -2, 5, 14, 2, -19, -18, 23, 12, 8, -12, -23, 28, -16, -19, -3, 14, 16, -25, 26, -16, 4, 12, -23, 26, 22, 12, 23])
time taken=> 16.45 ms

对于一个大小为 1000 的数组,代码将如下:

>>> with timer():
... max_subarray(num_array(100))
... (121, [7, 10, -17, 3, 21, 26, -2, 5, 14, 2, -19, -18, 23, 12, 8, -12, -23, 28, -16, -19, -3, 14, 16, -25, 26, -16, 4, 12, -23, 26, 22, 12, 23])
time taken=> 16.45 ms

所以大约需要 3.3 秒。

可以证明,对于输入大小为 10000,代码运行大约需要 2 到 3 小时。

有没有一种方法可以优化代码?是的,有一个O(n)版本的相同代码,看起来像这样:

def max_subarray(sequence):
    """ Maximum subarray – optimized version """

    max_ending_here = max_so_far = 0

    for x in sequence:
        max_ending_here = max(0, max_ending_here + x)
        max_so_far = max(max_so_far, max_ending_here)

    return max_so_far

有了这个版本,所花费的时间要好得多:

>>> with timer():
... max_subarray(num_array(100))
... 240
time taken=> 0.77 ms

对于一个大小为 1000 的数组,所花费的时间如下:

>>> with timer():
... max_subarray(num_array(1000))
... 2272
time taken=> 6.05 ms

对于一个大小为 10000 的数组,时间大约为 44 毫秒:

>>> with timer():
... max_subarray(num_array(10000))
... 19362
time taken=> 43.89 ms

简单的调试技巧和技术

我们在前面的示例中看到了简单的print语句的威力。类似的其他简单技术也可以用来调试程序,而无需使用调试器。

调试可以被认为是一个逐步排除的过程,直到程序员找到真相——错误的原因。它基本上涉及以下步骤:

  • 分析代码,并得出一组可能的假设(原因),可能是错误的来源。

  • 逐个测试每个假设,使用适当的调试技术。

  • 在测试的每一步,您要么找到了错误的原因——因为测试成功告诉您问题出在您正在测试的特定原因;要么测试失败,您继续测试下一个假设。

  • 重复上一步,直到找到原因或放弃当前一组可能的假设。然后重新开始整个循环,直到(希望)找到原因。

单词搜索程序

在本节中,我们将逐个使用示例来看一些简单的调试技巧。我们将从一个单词搜索程序的示例开始,该程序在文件列表中查找包含特定单词的行,并将这些行附加并返回到一个列表中。

以下是单词搜索程序的代码清单:

import os
import glob

def grep_word(word, filenames):
    """ Open the given files and look for a specific word.
    Append lines containing word to a list and
    return it """

    lines, words = [], []

    for filename in filenames:
        print('Processing',filename)
        lines += open(filename).readlines()

    word = word.lower()
    for line in lines:
        if word in line.lower():
            lines.append(line.strip())

    # Now sort the list according to length of lines
    return sorted(words, key=len)

您可能已经注意到前面的代码中有一个细微的错误——它附加到了错误的列表上。它从列表“lines”中读取,并附加到同一个列表,这将导致列表无限增长;当遇到包含给定单词的一行时,程序将进入无限循环。

让我们在当前目录上运行程序:

>>> parse_filename('lines', glob.glob('*.py'))
(hangs)

在任何一天,你可能会很容易地找到这个 bug。在糟糕的一天,你可能会卡在这里一段时间,没有注意到正在读取的列表是被追加的。

以下是你可以做的一些事情:

  • 由于代码挂起并且有两个循环,找出导致问题的循环。为了做到这一点,可以在两个循环之间放置一个打印语句,或者放置一个sys.exit函数,这将导致解释器在那一点退出。

  • 开发人员可能会忽略打印语句,特别是如果代码中有很多其他打印语句,但sys.exit当然不会被忽略。

单词搜索程序-调试步骤 1

代码重写如下,插入了一个特定的sys.exit(…)调用在两个循环之间:

import os
import glob

def grep_word(word, filenames):
    """ Open the given files and look for a specific word.
    Append lines containing word to a list and
    return it """

    lines, words = [], []

    for filename in filenames:
        print('Processing',filename)
        lines += open(filename).readlines()

    sys.exit('Exiting after first loop')

    word = word.lower()
    for line in lines:
        if word in line.lower():
            lines.append(line.strip())

    # Now sort the list according to length of lines
    return sorted(words, key=len)

第二次尝试时,我们得到了这个输出:

>>> grep_word('lines', glob.glob('*.py'))
Exiting after first loop

现在很明显问题不在第一个循环中。现在你可以继续调试第二个循环(我们假设你完全不知道错误的变量使用方式,所以你正在通过调试的方式艰难地找出问题)。

单词搜索程序-调试步骤 2

每当你怀疑循环内的一段代码可能导致 bug 时,有一些调试技巧可以帮助你确认这一点。这些包括以下内容:

  • 在代码块之前放置一个策略性的continue。如果问题消失了,那么你已经确认了特定的代码块或下一个代码块是问题所在。你可以继续移动你的continue语句,直到找到引起问题的具体代码块。

  • 让 Python 跳过代码块,通过在其前面加上if 0:。如果代码块是一行代码或几行代码,这将更有用。

  • 如果循环内有大量的代码,并且循环执行多次,打印语句可能不会对你有太大帮助,因为会打印出大量的数据,很难筛选和扫描找出问题所在。

在这种情况下,我们将使用第一个技巧来找出问题。以下是修改后的代码:

def grep_word(word, filenames):
    """ Open the given files and look for a specific word.
    Append lines containing word to a list and
    return it """

    lines, words = [], []

    for filename in filenames:
        print('Processing',filename)
        lines += open(filename).readlines()

    # Debugging steps
    # 1\. sys.exit
    # sys.exit('Exiting after first loop')

    word = word.lower()
    for line in lines:
        if word in line.lower():
            words.append(line.strip())
            continue

    # Now sort the list according to length of lines
    return sorted(words, key=len)

>>> grep_word('lines', glob.glob('*.py'))
[]

现在代码执行了,很明显问题出在处理步骤中。希望从那里只需一步就能找出 bug,因为程序员终于通过调试过程找到了引起问题的代码行。

单词搜索程序-最终代码

我们花了一些时间通过前几节中记录的一些调试步骤来解决程序中的问题。通过这些步骤,我们假设的程序员能够找到代码中的问题并解决它。

以下是修复了 bug 的最终代码:

def grep_word(word, filenames):
    """ Open the given files and look for a specific word.
    Append lines containing word to a list and
    return it """

    lines, words = [], []

    for filename in filenames:
        print('Processing',filename)
        lines += open(filename).readlines()

    word = word.lower()
    for line in lines:
        if word in line.lower():
            words.append(line.strip())

    # Now sort the list according to length of lines
    return sorted(words, key=len)

输出如下:

>>> grep_word('lines', glob.glob('*.py'))
['for line in lines:', 'lines, words = [], []', 
  '#lines.append(line.strip())', 
  'lines += open(filename).readlines()',
  'Append lines containing word to a list and', 
  'and return list of lines containing the word.', 
  '# Now sort the list according to length of lines', 
  "print('Lines => ', grep_word('lines', glob.glob('*.py')))"]

让我们总结一下我们在本节中学到的简单调试技巧,并看一些相关的技巧和方法。

跳过代码块

在调试期间,程序员可以跳过他们怀疑会导致 bug 的代码块。如果代码块在循环内,可以通过continue语句跳过执行。我们已经看到了一个例子。

如果代码块在循环之外,可以通过使用if 0,并将怀疑的代码移动到依赖块中来完成:

if 0:# Suspected code block
     perform_suspect_operation1(args1, args2, ...)
     perform_suspect_operation2(…)

如果 bug 在此之后消失了,那么你可以确定问题出在怀疑的代码块中。

这个技巧有其自身的不足之处,因为它需要将大块的代码缩进到右侧,一旦调试完成,就应该将其重新缩进。因此,不建议用于超过 5-6 行代码的任何情况。

停止执行

如果你正在进行紧张的编程工作,并且正在尝试找出一个难以捉摸的 bug,已经尝试了打印语句、使用调试器和其他方法,一个相当激进但通常非常有用的方法是在怀疑的代码路径之前或之后停止执行,使用函数sys.exit表达式。

sys.exit(<strategic message>)会使程序立即停止,因此程序员不会错过它。在以下情况下,这通常非常有用:

  • 一段复杂的代码存在一个难以捉摸的 bug,取决于特定的输入值或范围,导致一个被捕获并忽略的异常,但后来导致程序出现问题。

  • 在这种情况下,检查特定值或范围,然后通过sys.exit在异常处理程序中使用正确的消息退出代码,将允许你找出问题的根源。程序员然后可以决定通过纠正输入或变量处理代码来解决问题。

在编写并发程序时,资源锁定的错误使用或其他问题可能会使跟踪死锁、竞争条件等 bug 变得困难。由于通过调试器调试多线程或多进程程序非常困难,一个简单的技巧是在怀疑的函数中放置sys.exit,在实现正确的异常处理代码后。

  • 当你的代码存在严重的内存泄漏或无限循环时,随着时间的推移,调试变得困难,你无法找出问题的根源。将sys.exit(<message>)这一行代码从一行移到下一行,直到确定问题,可以作为最后的手段。

外部依赖-使用包装器

在你怀疑问题不在你的函数内部,而是在你从代码中调用的函数中时,可以使用这种方法。

由于该函数不在你的控制范围之内,你可以尝试用你可以控制的模块中的包装器函数替换它。

例如,以下是用于处理串行 JSON 数据的通用代码。假设程序员发现处理某些数据的 bug(可能具有某个键值对),并怀疑外部 API 是 bug 的来源。bug 可能是 API 超时、返回损坏的响应,或在最坏的情况下导致崩溃:

import external_api
def process_data(data):
    """ Process data using external API """

    # Clean up data—local function
    data = clean_up(data)
    # Drop duplicates from data—local function
    data = drop_duplicates(data)

    # Process line by line JSON
    for json_elem in data:
        # Bug ?
        external_api.process(json_elem)

验证的一种方法是对特定范围或数据的 API 进行虚拟,在这种情况下,可以通过创建以下包装器函数来实现:

def process(json_data, skey='suspect_key',svalue='suspect_value'):
    """ Fake the external API except for the suspect key & value """

    # Assume each JSON element maps to a Python dictionary

    for json_elem in json_data:
        skip = False

        for key in json_elem:
            if key == skey:
                if json_elem[key] == svalue:
                    # Suspect key,value combination - dont process
                    # this JSON element
                    skip = True
                    break

        # Pass on to the API
        if not skip:
            external_api.process(json_elem)

def process_data(data):
    """ Process data using external API """

    # Clean up data—local function
    data = clean_up(data)
    # Drop duplicates from data—local function
    data = drop_duplicates(data)

    # Process line by line JSON using local wrapper
    process(data)

如果你的怀疑是正确的,这将导致问题消失。然后你可以将其用作测试代码,并与外部 API 的利益相关者沟通,以解决问题,或编写代码确保在发送到 API 的数据中跳过问题的键值对。

用返回值/数据替换函数(模拟)

在现代 Web 应用程序编程中,你的程序中从来不会离开阻塞 I/O 调用太远。这可能是一个简单的 URL 请求,稍微复杂的外部 API 请求,或者可能是一个昂贵的数据库查询,这些调用可能是 bug 的来源。

你可能会遇到以下情况之一:

  • 这样的调用返回数据可能是问题的原因

  • 调用本身是问题的原因,比如 I/O 或网络错误、超时或资源争用

当你遇到昂贵 I/O 的问题时,复制它们通常会成为一个问题。这是因为以下原因:

  • I/O 调用需要时间,因此调试会浪费大量时间,无法专注于真正的问题。

  • 后续调用可能无法重复出现问题,因为外部请求可能每次返回略有不同的数据

  • 如果你使用的是外部付费 API,调用实际上可能会花费你的钱,因此你不能在调试和测试上花费大量这样的调用

在这些情况下非常有用的一种常见技术是保存这些 API/函数的返回数据,然后通过使用它们的返回数据来替换函数/API 本身来模拟函数。这是一种类似于模拟测试的方法,但是它是在调试的上下文中使用的。

让我们看一个 API 的示例,它根据企业地址返回网站上的商家列表,包括名称、街道地址、城市等详细信息。代码如下:

import config

search_api = 'http://api.%(site)s/listings/search'

def get_api_key(site):
    """ Return API key for a site """

    # Assumes the configuration is available via a config module
    return config.get_key(site)

def api_search(address, site='yellowpages.com'):
    """ API to search for a given business address
    on a site and return results """

    req_params = {}
    req_params.update({
        'key': get_api_key(site),
        'term': address['name'],
        'searchloc': '{0}, {1}, {1}'.format(address['street'],
                                            address['city'],
                                            address['state'])})
    return requests.post(search_api % locals(),
                         params=req_params)

def parse_listings(addresses, sites):
    """ Given a list of addresses, fetch their listings
    for a given set of sites, process them """

    for site in sites:
        for address in addresses:
            listing = api_search(address, site)
            # Process the listing
            process_listing(listing, site)

def process_listings(listing, site):
    """ Process a listing and analzye it """

     # Some heavy computational code
     # whose details we are not interested.

注意

该代码做出了一些假设,其中之一是每个站点都具有相同的 API URL 和参数。请注意,这仅用于说明目的。实际上,每个站点的 API 格式都会有很大不同,包括其 URL 和接受的参数。

请注意,在这段代码的最后,实际工作是在process_listings函数中完成的,由于示例是说明性的,因此未显示代码。

假设您正在尝试调试此函数。但是,由于 API 调用的延迟或错误,您发现自己在获取列表本身方面浪费了大量宝贵的时间。您可以使用哪些技术来避免这种依赖?以下是一些您可以做的事情:

  • 不要通过 API 获取列表,而是将它们保存到文件、数据库或内存存储中,并按需加载

  • 通过缓存或记忆模式缓存api_search函数的返回值,以便在第一次调用后,进一步调用从内存返回数据

  • 模拟数据,并返回具有与原始数据相同特征的随机数据

我们将依次查看这些内容。

将数据保存到/从文件中加载作为缓存

在这种技术中,您使用输入数据的唯一键构造文件名。如果磁盘上存在匹配的文件,则打开该文件并返回数据,否则进行调用并写入数据。可以通过使用文件缓存装饰器来实现,如下面的代码所示:

import hashlib
import json
import os

def unique_key(address, site):
    """ Return a unique key for the given arguments """

    return hashlib.md5(''.join((address['name'],
                               address['street'],
                               address['city'],
                               site)).encode('utf-8')).hexdigest()

def filecache(func):
    """ A file caching decorator """

    def wrapper(*args, **kwargs):
        # Construct a unique cache filename
        filename = unique_key(args[0], args[1]) + '.data'

        if os.path.isfile(filename):
            print('=>from file<=')
            # Return cached data from file
            return json.load(open(filename))

        # Else compute and write into file
        result = func(*args, **kwargs)
        json.dump(result, open(filename,'w'))

        return result

    return wrapper

@filecache
def api_search(address, site='yellowpages.com'):
    """ API to search for a given business address
    on a site and return results """

    req_params = {}
    req_params.update({
        'key': get_api_key(site),
        'term': address['name'],
        'searchloc': '{0}, {1}, {1}'.format(address['street'],
                                            address['city'],
                                            address['state'])})
    return requests.post(search_api % locals(),
                         params=req_params)

以下是这段代码的工作原理:

  1. api_search函数被装饰为filecache

  2. filecache使用unique_key作为计算存储 API 调用结果的唯一文件名的函数。在这种情况下,unique_key函数使用业务名称、街道和城市的组合的哈希值,以及查询的站点来构建唯一值。

  3. 第一次调用函数时,数据通过 API 获取并存储在文件中。在进一步调用期间,数据直接从文件返回。

这在大多数情况下效果相当不错。大多数数据只加载一次,再次调用时从文件缓存返回。然而,这会遇到“陈旧数据”的问题,因为一旦文件创建,数据总是从中返回。与此同时,服务器上的数据可能已经发生了变化。

这可以通过使用内存键值存储解决,并将数据保存在内存中,而不是在磁盘上的文件中。可以使用著名的键值存储,如MemcachedMongoDBRedis来实现这一目的。在下面的示例中,我们将向您展示如何使用 Redis 将filecache装饰器替换为memorycache装饰器。

将数据保存到/从内存中加载作为缓存

在这种技术中,使用输入参数的唯一值构造唯一的内存缓存键。如果通过使用键查询在缓存存储中找到缓存,则从存储中返回其值;否则进行调用并写入缓存。为了确保数据不会太陈旧,使用了固定的生存时间TTL)。我们使用 Redis 作为缓存存储引擎:

from redis import StrictRedis

def memoize(func, ttl=86400):
    """ A memory caching decorator """

    # Local redis as in-memory cache
    cache = StrictRedis(host='localhost', port=6379)

    def wrapper(*args, **kwargs):
        # Construct a unique key

        key = unique_key(args[0], args[1])
        # Check if its in redis
        cached_data = cache.get(key)
        if cached_data != None:
             print('=>from cache<=')
             return json.loads(cached_data)
         # Else calculate and store while putting a TTL
         result = func(*args, **kwargs)
         cache.set(key, json.dumps(result), ttl)

         return result

    return wrapper

注意

请注意,我们正在重用先前代码示例中的unique_key的定义。

在代码的其余部分中唯一变化的是我们用memoize替换了filecache装饰器:

@memoize    
def api_search(address, site='yellowpages.com'):
    """ API to search for a given business address
    on a site and return results """

    req_params = {}
    req_params.update({
        'key': get_api_key(site),
        'term': address['name'],
        'searchloc': '{0}, {1}, {1}'.format(address['street'],
                                            address['city'],
                                            address['state'])})
    return requests.post(search_api % locals(),
                         params=req_params)

这个版本相对于之前的版本的优势如下:

  • 缓存存储在内存中。不会创建额外的文件。

  • 缓存是使用 TTL 创建的,超过 TTL 后会过期。因此,陈旧数据的问题被规避了。TTL 是可定制的,在这个例子中默认为一天(86400 秒)。

还有一些模拟外部 API 调用和类似依赖的技术。以下是其中一些:

  • 在 Python 中使用StringIO对象读取/写入数据,而不是使用文件。例如,filecachememoize装饰器可以很容易地修改为使用StringIO对象。

  • 使用可变默认参数,如字典或列表,作为缓存并将结果写入其中。由于 Python 中的可变参数在重复调用后保持其状态,因此它实际上可以作为内存缓存。

  • 通过编辑系统主机文件,为外部 API 替换为对本地机器上的服务的调用(127.0.0.1 IP 地址)添加一个主机条目,并将其 IP 设置为127.0.0.1。对 localhost 的调用总是可以返回标准(预设)响应。

例如,在 Linux 和其他 POSIX 系统上,可以在/etc/hosts文件中添加以下行:

# Only for testing—comment out after that!
127.0.0.1 api.website.com

注意

请注意,只要记得在测试后注释掉这些行,这种技术就是一种非常有用和巧妙的方法!

返回随机/模拟数据

另一种技术,主要用于性能测试和调试,是使用相似但不同于原始数据的数据来提供函数。

例如,假设您正在开发一个应用程序,该应用程序与特定保险计划(例如美国的 Medicare/Medicaid,印度的 ESI)下的患者/医生数据一起工作,以分析并找出常见疾病、政府支出前 10 位的健康问题等模式。

假设您的应用程序预计一次从数据库加载和分析成千上万行患者数据,并且在高峰负载下预计扩展到 100-200 万行。您想要调试应用程序,并找出在这种负载下的性能特征,但是您没有任何真实数据,因为数据还处于收集阶段。

在这种情况下,生成和返回模拟数据的库或函数非常有用。在本节中,我们将使用第三方 Python 库来实现这一点。

生成随机患者数据

假设,对于一个患者,我们需要以下基本字段:

  • 姓名

  • 年龄

  • 性别

  • 健康问题

  • 医生的姓名

  • 血型

  • 有无保险

  • 最后一次就医日期

Python 中的schematics库提供了一种使用简单类型生成这些数据结构的方法,然后可以对其进行验证、转换和模拟。

schematics是一个可通过以下命令使用pip安装的库:

$ pip install schematics

要生成只有姓名和年龄的人的模型,只需在schematics中编写一个类即可:

from schematics import Model
from schematics.types import StringType, DecimalType

class Person(Model):
    name = StringType()
    age = DecimalType()

生成模拟数据时,返回一个模拟对象,并使用此对象创建一个primitive

>>> Person.get_mock_object().to_primitive()
{'age': u'12', 'name': u'Y7bnqRt'}
>>> Person.get_mock_object().to_primitive()
{'age': u'1', 'name': u'xyrh40EO3'}

可以使用 Schematics 创建自定义类型。例如,对于Patient模型,假设我们只对 18-80 岁的年龄组感兴趣,因此需要返回该范围内的年龄数据。

以下自定义类型为我们做到了这一点:

from schematics.types import IntType

class AgeType(IntType):
    """ An age type for schematics """

    def __init__(self, **kwargs):
        kwargs['default'] = 18
        IntType.__init__(self, **kwargs)

    def to_primitive(self, value, context=None):
        return random.randrange(18, 80)

此外,由于 Schematics 库返回的姓名只是随机字符串,还有改进的空间。以下的NameType类通过返回包含元音和辅音巧妙混合的姓名来改进:

import string
import random

class NameType(StringType):
    """ A schematics custom name type """

    vowels='aeiou'
    consonants = ''.join(set(string.ascii_lowercase) - set(vowels))

    def __init__(self, **kwargs):
        kwargs['default'] = ''
        StringType.__init__(self, **kwargs)

   def get_name(self):
        """ A random name generator which generates
        names by clever placing of vowels and consontants """

        items = ['']*4

        items[0] = random.choice(self.consonants)
        items[2] = random.choice(self.consonants)

        for i in (1, 3):
            items[i] = random.choice(self.vowels)            

        return ''.join(items).capitalize()

    def to_primitive(self, value, context=None):
        return self.get_name()

将这两种新类型结合起来后,我们的Person类在返回模拟数据时看起来更好:

class Person(Model):
    name = NameType()
    age = AgeType()
>>> Person.get_mock_object().to_primitive()
{'age': 36, 'name': 'Qixi'}
>>> Person.get_mock_object().to_primitive()
{'age': 58, 'name': 'Ziru'}
>>> Person.get_mock_object().to_primitive()
{'age': 32, 'name': 'Zanu'}

以类似的方式,很容易提出一组自定义类型和标准类型,以满足Patient模型所需的所有字段:

class GenderType(BaseType):
    """A gender type for schematics """

    def __init__(self, **kwargs):
        kwargs['choices'] = ['male','female']
        kwargs['default'] = 'male'
        BaseType.__init__(self, **kwargs)

class ConditionType(StringType):
    """ A gender type for a health condition """

    def __init__(self, **kwargs):
        kwargs['default'] = 'cardiac'
        StringType.__init__(self, **kwargs)     

    def to_primitive(self, value, context=None):
        return random.choice(('cardiac',
                              'respiratory',
                              'nasal',
                              'gynec',
                              'urinal',
                              'lungs',
                              'thyroid',
                              'tumour'))

import itertools

class BloodGroupType(StringType):
    """ A blood group type for schematics  """

    def __init__(self, **kwargs):
        kwargs['default'] = 'AB+'
        StringType.__init__(self, **kwargs)

    def to_primitive(self, value, context=None):
        return ''.join(random.choice(list(itertools.product(['AB','A','O','B'],['+','-']))))    

现在,将所有这些与一些标准类型和默认值结合到一个Patient模型中,我们得到以下代码:

class Patient(Model):
    """ A model class for patients """

    name = NameType()
    age = AgeType()
    gender = GenderType()
    condition = ConditionType()
    doctor = NameType()
    blood_group = BloodGroupType()
    insured = BooleanType(default=True)
    last_visit = DateTimeType(default='2000-01-01T13:30:30')

现在,创建任意大小的随机数据就像在Patient类上调用get_mock_object方法一样简单:

patients = map(lambda x: Patient.get_mock_object().to_primitive(), range(n))

例如,要创建 10,000 个随机患者数据,我们可以使用以下方法:

>>> patients = map(lambda x: Patient.get_mock_object().to_primitive(), range(1000))

这些数据可以作为模拟数据输入到处理函数中,直到真实数据可用为止。

注意

注意:Python 中的 Faker 库也可用于生成各种假数据,如姓名、地址、URI、随机文本等。

现在让我们从这些简单的技巧和技术转移到更复杂的内容,主要是配置应用程序中的日志记录。

作为调试技术的日志记录

Python 自带了对日志记录的标准库支持,通过名为logging的模块。虽然可以使用打印语句作为快速和简陋的调试工具,但现实生活中的调试大多需要系统或应用程序生成一些日志。日志记录是有用的,因为有以下原因:

  • 日志通常保存在特定的日志文件中,通常带有时间戳,并在服务器上保留一段时间,直到它们被轮换出去。这使得即使程序员在发生问题一段时间后进行调试,调试也变得容易。

  • 可以在不同级别进行日志记录,从基本的 INFO 到冗长的 DEBUG 级别,改变应用程序输出的信息量。这使程序员能够在不同级别的日志记录中进行调试,提取他们想要的信息,并找出问题所在。

  • 可以编写自定义记录器,可以将日志记录到各种输出。在最基本的情况下,日志记录是写入日志文件的,但也可以编写将日志记录到套接字、HTTP 流、数据库等的记录器。

简单的应用程序日志记录

在 Python 中配置简单的日志记录相当容易,如下所示:

>>> import logging
>>> logging.warning('I will be back!')
WARNING:root:I will be back!

>>> logging.info('Hello World')
>>>

执行前面的代码不会发生任何事情,因为默认情况下,logging被配置为WARNING级别。但是,很容易配置日志以更改其级别。

以下代码将日志记录更改为以info级别记录,并添加一个目标文件来保存日志:

>>> logging.basicConfig(filename='application.log', level=logging.DEBUG)
>>> logging.info('Hello World')

如果我们检查application.log文件,我们会发现它包含以下行:

INFO:root:Hello World

为了在日志行中添加时间戳,我们需要配置日志格式。可以按以下方式完成:

>>> logging.basicConfig(format='%(asctime)s %(message)s')

结合起来,我们得到最终的日志配置如下:

>>> logging.basicConfig(format='%(asctime)s %(message)s', filename='application.log', level=logging.DEBUG)
>>> logging.info('Hello World!')

现在,application.log的内容看起来像下面这样:

INFO:root:Hello World
2016-12-26 19:10:37,236 Hello World!

日志支持变量参数,用于向作为第一个参数提供的模板字符串提供参数。

逗号分隔的参数的直接日志记录不起作用。例如:

>>> import logging
>>> logging.basicConfig(level=logging.DEBUG)
>>> x,y=10,20
>>> logging.info('Addition of',x,'and',y,'produces',x+y)
--- Logging error ---
Traceback (most recent call last):
 **File "/usr/lib/python3.5/logging/__init__.py", line 980, in emit
 **msg = self.format(record)
 **File "/usr/lib/python3.5/logging/__init__.py", line 830, in format
 **return fmt.format(record)
 **File "/usr/lib/python3.5/logging/__init__.py", line 567, in format
 **record.message = record.getMessage()
 **File "/usr/lib/python3.5/logging/__init__.py", line 330, in getMessage
 **msg = msg % self.args
TypeError: not all arguments converted during string formatting
Call stack:
 **File "<stdin>", line 1, in <module>
Message: 'Addition of'
Arguments: (10, 'and', 20, 'produces', 30)

但是,我们可以使用以下方法:

>>> logging.info('Addition of %s and %s produces %s',x,y,x+y)
INFO:root:Addition of 10 and 20 produces 30

之前的例子运行得很好。

高级日志记录-记录器对象

直接使用logging模块进行日志记录在大多数简单情况下都可以工作。但是,为了从logging模块中获得最大的价值,我们应该使用记录器对象。它还允许我们执行许多自定义操作,比如自定义格式化程序、自定义处理程序等。

让我们编写一个返回这样一个自定义记录器的函数。它接受应用程序名称、日志级别和另外两个选项-日志文件名和是否打开控制台日志记录:

import logging
def create_logger(app_name, logfilename=None, 
                             level=logging.INFO, console=False):

    """ Build and return a custom logger. Accepts the application name,
    log filename, loglevel and console logging toggle """

    log=logging.getLogger(app_name)
    log.setLevel(logging.DEBUG)
    # Add file handler
    if logfilename != None:
        log.addHandler(logging.FileHandler(logfilename))

    if console:
        log.addHandler(logging.StreamHandler())

    # Add formatter
    for handle in log.handlers:
        formatter = logging.Formatter('%(asctime)s : %(levelname)-8s - %(message)s', datefmt='%Y-%m-%d %H:%M:%S')

        handle.setFormatter(formatter)

    return log

让我们检查一下这个函数:

  1. 不直接使用logging,而是使用logging.getLogger工厂函数创建一个logger对象。

  2. 默认情况下,logger对象是无用的,因为它没有配置任何处理程序。处理程序是流包装器,负责将日志记录到特定流,如控制台、文件、套接字等。

  3. 在这个记录器对象上进行配置,比如设置级别(通过setLevel方法)和添加处理程序,比如用于记录到文件的FileHandler和用于记录到控制台的StreamHandler

  4. 日志消息的格式化是在处理程序上完成的,而不是在记录器对象本身上完成的。我们使用YY-mm-dd HH:MM:SS的日期格式作为时间戳的标准格式。

让我们看看它的运行情况:

>>> log=create_logger('myapp',logfilename='app.log', console=True)
>>> log
<logging.Logger object at 0x7fc09afa55c0>
>>> log.info('Started application')
2016-12-26 19:38:12 : INFO     - Started application
>>> log.info('Initializing objects...')
2016-12-26 19:38:25 : INFO     - Initializing objects…

在同一目录中检查 app.log 文件会发现以下内容:

2016-12-26 19:38:12 : INFO    —Started application
2016-12-26 19:38:25 : INFO    —Initializing objects…

高级日志记录-自定义格式和记录器

我们看了如何根据我们的要求创建和配置记录器对象。有时,需要超越并在日志行中打印额外的数据,这有助于调试。

在调试应用程序中经常出现的一个常见问题,特别是那些对性能至关重要的应用程序,就是找出每个函数或方法需要多少时间。尽管可以通过使用性能分析器对应用程序进行性能分析等方法来找出这一点,并且通过使用之前讨论过的一些技术,如计时器上下文管理器,很多时候,可以编写一个自定义记录器来实现这一点。

假设您的应用程序是一个业务列表 API 服务器,响应类似于我们在前一节中讨论的列表 API 请求。当它启动时,需要初始化一些对象并从数据库加载一些数据。

假设作为性能优化的一部分,您已经调整了这些例程,并希望记录这些例程需要多少时间。我们将看看是否可以编写一个自定义记录器来为我们完成这项工作:

import logging
import time
from functools import partial

class LoggerWrapper(object):
    """ A wrapper class for logger objects with
    calculation of time spent in each step """

    def __init__(self, app_name, filename=None, 
                       level=logging.INFO, console=False):
        self.log = logging.getLogger(app_name)
        self.log.setLevel(level)

        # Add handlers
        if console:
            self.log.addHandler(logging.StreamHandler())

        if filename != None:
            self.log.addHandler(logging.FileHandler(filename))

        # Set formatting
        for handle in self.log.handlers:

          formatter = logging.Formatter('%(asctime)s [%(timespent)s]: %(levelname)-8s - %(message)s', datefmt='%Y-%m-%d %H:%M:%S')                                   
             handle.setFormatter(formatter)

        for name in ('debug','info','warning','error','critical'):
            # Creating convenient wrappers by using functools
            func = partial(self._dolog, name)
            # Set on this class as methods
            setattr(self, name, func)

        # Mark timestamp
        self._markt = time.time()

    def _calc_time(self):
        """ Calculate time spent so far """

        tnow = time.time()
        tdiff = int(round(tnow - self._markt))

        hr, rem = divmod(tdiff, 3600)
        mins, sec = divmod(rem, 60)
        # Reset mark
        self._markt = tnow
        return '%.2d:%.2d:%.2d' % (hr, mins, sec)

    def _dolog(self, levelname, msg, *args, **kwargs):
        """ Generic method for logging at different levels """

        logfunc = getattr(self.log, levelname)
        return logfunc(msg, *args, extra={'timespent': self._calc_time()})         

我们已经构建了一个名为LoggerWrapper的自定义类。让我们分析一下代码并看看它的作用:

  1. 这个类的__init__方法与之前编写的create_logger函数非常相似。它接受相同的参数,构造处理程序对象,并配置logger。但是,这一次,logger对象是外部LoggerWrapper实例的一部分。

  2. 格式化程序接受一个名为timespent的额外变量模板。

  3. 似乎没有定义直接的日志记录方法。但是,使用部分函数技术,我们在不同级别的日志记录中包装_dolog方法,并将它们动态地设置为类的logging方法,使用setattr

  4. _dolog方法通过使用标记时间戳来计算每个例程中花费的时间——第一次初始化,然后在每次调用时重置。花费的时间使用一个名为 extra 的字典参数发送到日志记录方法。

让我们看看应用程序如何使用这个记录器包装器来测量关键例程中花费的时间。以下是一个假设使用 Flask Web 应用程序的示例:

    # Application code
    log=LoggerWrapper('myapp', filename='myapp.log',console=True)

    app = Flask(__name__)
    log.info("Starting application...")
    log.info("Initializing objects.")
    init()
    log.info("Initialization complete.")
    log.info("Loading configuration and data …")
    load_objects()
    log.info('Loading complete. Listening for connections …')
    mainloop()

请注意,花费的时间在时间戳之后的方括号内记录。

假设最后的代码产生了以下输出:

2016-12-26 20:08:28 [00:00:00]: INFO    —Starting application...
2016-12-26 20:08:28 [00:00:00]: INFO     - Initializing objects.
2016-12-26 20:08:42 [00:00:14]: INFO     - Initialization complete.
2016-12-26 20:08:42 [00:00:00]: INFO     - Loading configuration and data ...
2016-12-26 20:10:37 [00:01:55]: INFO     - Loading complete. Listening for connections

从日志行可以明显看出,初始化花费了 14 秒,而配置和数据的加载花费了 1 分 55 秒。

通过添加类似的日志行,您可以快速而相当准确地估计应用程序关键部分的时间。保存在日志文件中,另一个额外的优势是您不需要特别计算和保存它在其他地方。

注意

使用这个自定义记录器,请注意,显示为给定日志行花费的时间是在前一行例程中花费的时间。

高级日志记录——写入 syslog

像 Linux 和 Mac OS X 这样的 POSIX 系统有一个系统日志文件,应用程序可以写入。通常,该文件存在为/var/log/syslog。让我们看看如何配置 Python 日志记录以写入系统日志文件。

您需要做的主要更改是向记录器对象添加系统日志处理程序,如下所示:

log.addHandler(logging.handlers.SysLogHandler(address='/dev/log'))

让我们修改我们的create_logger函数,使其能够写入syslog,并查看完整的代码运行情况:

import logging
import logging.handlers

def create_logger(app_name, logfilename=None, level=logging.INFO, 
                             console=False, syslog=False):
    """ Build and return a custom logger. Accepts the application name,
    log filename, loglevel and console logging toggle and syslog toggle """

    log=logging.getLogger(app_name)
    log.setLevel(logging.DEBUG)
    # Add file handler
    if logfilename != None:
        log.addHandler(logging.FileHandler(logfilename))

    if syslog:
        log.addHandler(logging.handlers.SysLogHandler(address='/dev/log'))

    if console:
        log.addHandler(logging.StreamHandler())

    # Add formatter
    for handle in log.handlers:
        formatter = logging.Formatter('%(asctime)s : %(levelname)-8s - %(message)s',  datefmt='%Y-%m-%d %H:%M:%S')
        handle.setFormatter(formatter)                             

    return log

现在让我们尝试创建一个记录器,同时记录到syslog

>>> create_logger('myapp',console=True, syslog=True)
>>> log.info('Myapp - starting up…')

让我们检查 syslog,看看它是否真的被记录了下来:

$ tail -3 /var/log/syslog
Dec 26 20:39:54 ubuntu-pro-book kernel: [36696.308437] psmouse serio1: TouchPad at isa0060/serio1/input0 - driver resynced.
Dec 26 20:44:39 ubuntu-pro-book 2016-12-26 20:44:39 : INFO     - Myapp - starting up...
Dec 26 20:45:01 ubuntu-pro-book CRON[11522]: (root) CMD (command -v debian-sa1 > /dev/null && debian-sa1 1 1)

输出显示它确实做到了。

调试工具——使用调试器

大多数程序员倾向于将调试视为他们应该使用调试器进行的事情。在本章中,我们迄今为止已经看到,调试不仅仅是一门精确的科学,而且是一门艺术,可以使用许多技巧和技术来完成,而不是直接跳到调试器。然而,迟早,我们期望在本章中遇到调试器——现在就是时候了!

Python 调试器,或者称为 pdb,是 Python 运行时的一部分。

可以在从头开始运行脚本时调用 Pdb,如下所示:

$ python3 -m pdb script.py

然而,程序员通常调用 pdb 的最常见方式是在代码中想要进入调试器的地方插入以下行:

import pdb; pdb.set_trace()

让我们使用这个,并尝试调试本章第一个示例的一个实例,也就是最大子数组的和。我们将调试代码的O(n)版本作为示例:

def max_subarray(sequence):
    """ Maximum subarray - optimized version """

    max_ending_here = max_so_far = 0
    for x in sequence:
        # Enter the debugger
        import pdb; pdb.set_trace()
        max_ending_here = max(0, max_ending_here + x)
        max_so_far = max(max_so_far, max_ending_here)

    return max_so_far

使用 pdb 进行调试会话

在程序运行后立即进入调试器的第一个循环中:

>>> max_subarray([20, -5, -10, 30, 10])
> /home/user/programs/maxsubarray.py(8)max_subarray()
-> max_ending_here = max(0, max_ending_here + x)
-> for x in sequence:
(Pdb) max_so_far
20

您可以使用(s)来停止执行。Pdb 将执行当前行,并停止:

> /home/user/programs/maxsubarray.py(7)max_subarray()
-> max_ending_here = max(0, max_ending_here + x)

您可以通过简单地输入变量名称并按[Enter]来检查变量:

(Pdb) max_so_far
20

可以使用(w)或 where 打印当前堆栈跟踪。箭头(→)表示当前堆栈帧:

(Pdb) w

<stdin>(1)<module>()
> /home/user/programs/maxsubarray.py(7)max_subarray()
-> max_ending_here = max(0, max_ending_here + x)

可以使用(c)或 continue 继续执行,直到下一个断点:

> /home/user/programs/maxsubarray.py(6)max_subarray()
-> for x in sequence:
(Pdb) max_so_far
20
(Pdb) c
> /home/user/programs/maxsubarray.py(6)max_subarray()
-> for x in sequence:
(Pdb) max_so_far
20
(Pdb) c
> /home/user/programs/maxsubarray.py(6)max_subarray()
-> for x in sequence:
(Pdb) max_so_far
35
(Pdb) max_ending_here
35

在前面的代码中,我们继续了for循环的三次迭代,直到最大值从 20 变为 35。让我们检查一下我们在序列中的位置:

(Pdb) x
30

我们还有一个项目要在列表中完成,即最后一个项目。让我们使用(l)或list命令来检查此时的源代码:

(Pdb) l
  1     
  2     def max_subarray(sequence):
  3         """ Maximum subarray - optimized version """
  4     
  5         max_ending_here = max_so_far = 0
  6  ->     for x in sequence:
  7             max_ending_here = max(0, max_ending_here + x)
  8             max_so_far = max(max_so_far, max_ending_here)
  9             import pdb; pdb.set_trace()
 10     
 11         return max_so_far

可以使用(u)或up和(d)或down命令在堆栈帧上下移动:

(Pdb) up
> <stdin>(1)<module>()
(Pdb) up
*** Oldest frame
(Pdb) list
[EOF]
(Pdb) d
> /home/user/programs/maxsubarray.py(6)max_subarray()
-> for x in sequence:

现在让我们从函数中返回:

(Pdb) r
> /home/user/programs/maxsubarray.py(6)max_subarray()
-> for x in sequence:
(Pdb) r
--Return--
> /home/user/programs/maxsubarray.py(11)max_subarray()->45
-> return max_so_far

函数的返回值是45

Pdb 有很多其他命令,不仅限于我们在这里介绍的内容。但是,我们不打算让本次会话成为一个完整的 pdb 教程。有兴趣的程序员可以参考网络上的文档以了解更多信息。

Pdb-类似的工具

Python 社区已经构建了许多有用的工具,这些工具是在 pdb 的基础上构建的,但添加了更多有用的功能、开发者的易用性,或者两者兼而有之。

iPdb

iPdb 是启用 iPython 的 pdb。它导出函数以访问 iPython 调试器。它还具有制表完成、语法高亮和更好的回溯和内省方法。

iPdb 可以通过 pip 安装。

以下屏幕截图显示了使用 iPdb 进行调试会话,与之前使用 pdb 相同的功能。注意iPdb提供的语法高亮:

iPdb

iPdb 在操作中,显示语法高亮

还要注意,iPdb 提供了比 pdb 更完整的堆栈跟踪:

iPdb

iPdb 在操作中,显示比 pdb 更完整的堆栈跟踪

请注意,iPdb 使用 iPython 作为默认运行时,而不是 Python。

Pdb++

Pdb++是 pdb 的一个替代品,具有类似于 iPdb 的功能,但它适用于默认的 Python 运行时,而不需要 iPython。Pdb++也可以通过 pip 安装。

安装 pdb++后,它将接管所有导入 pdb 的地方,因此根本不需要更改代码。

Pdb++进行智能命令解析。例如,如果变量名与标准 Pdb 命令冲突,pdb 将优先显示变量内容而不是命令。Pdb++能够智能地解决这个问题。

以下是显示 Pdb++在操作中的屏幕截图,包括语法高亮、制表完成和智能命令解析:

Pdb++

Pdb++在操作中-请注意智能命令解析,其中变量 c 被正确解释

高级调试-跟踪

从一开始跟踪程序的执行通常可以作为一种高级调试技术。跟踪允许开发人员跟踪程序执行,找到调用者/被调用者关系,并找出程序运行期间执行的所有函数。

跟踪模块

Python 自带了一个默认的trace模块作为其标准库的一部分。

trace 模块接受-trace--count-listfuncs选项之一。第一个选项跟踪并打印所有源行的执行情况。第二个选项生成一个文件的注释列表,显示语句执行的次数。后者简单地显示程序运行期间执行的所有函数。

以下是使用trace模块的-trace选项调用子数组问题的屏幕截图:

trace 模块

通过使用其-trace 选项,可以使用 trace 模块跟踪程序执行。

正如您所看到的,trace模块跟踪了整个程序执行过程,逐行打印代码行。由于大部分代码都是for循环,您实际上会看到循环中的代码行被打印出循环执行的次数(五次)。

-trackcalls选项跟踪并打印调用者和被调用函数之间的关系。

trace 模块还有许多其他选项,例如跟踪调用、生成带注释的文件列表、报告等。我们不会对这些进行详尽的讨论,因为读者可以参考 Web 上有关此模块的文档以获取更多信息。

lptrace 程序

在调试服务器并尝试在生产环境中查找性能或其他问题时,程序员需要的通常不是由trace模块提供的 Python 系统或堆栈跟踪,而是实时附加到进程并查看正在执行哪些函数。

注意

lptrace 可以使用 pip 安装。请注意,它不适用于Python3

lptrace包允许您执行此操作。它不是提供要运行的脚本,而是通过其进程 ID 附加到正在运行 Python 程序的现有进程,例如运行服务器、应用程序等。

在下面的屏幕截图中,您可以看到* lptrace 调试我们在第八章中开发的 Twisted 聊天服务器,架构模式- Pythonic 方法*实时。会话显示了客户端 andy 连接时的活动:

lptrace 程序

lptrace 命令调试 Twisted 中的聊天服务器

有很多日志行,但您可以观察到一些 Twisted 协议的众所周知的方法被记录,例如客户端连接时的connectionMade。还可以看到接受来自客户端的连接的 Socket 调用,例如accept

使用 strace 进行系统调用跟踪

Strace是一个 Linux 命令,允许用户跟踪运行程序调用的系统调用和信号。它不仅适用于 Python,还可以用于调试任何程序。Strace 可以与 lptrace 结合使用,以便就其系统调用进行故障排除。

Stracelptrace类似,可以附加到正在运行的进程。它也可以被调用以从命令行运行进程,但在附加到服务器等进程时更有用。

例如,此屏幕截图显示了附加到我们的聊天服务器时的 strace 输出:

使用 strace 进行系统调用跟踪

附加到 Twisted 聊天服务器的 strace 命令

strace命令证实了服务器正在等待epoll句柄以接收连接的lptrace命令的结论。

这是客户端连接时发生的情况:

使用 strace 进行系统调用跟踪

strace 命令显示客户端连接到 Twisted 聊天服务器的系统调用

Strace 是一个非常强大的工具,可以与特定于运行时的工具(例如 Python 的 lptrace)结合使用,以便在生产环境中进行高级调试。

总结

在本章中,我们学习了使用 Python 的不同调试技术。我们从简单的print语句开始,然后使用continue语句在循环中进行简单的调试技巧,以及在代码块之间 strategically placed sys.exit 调用等。

然后,我们详细讨论了一些调试技术,特别是模拟和随机化数据。讨论了文件缓存和 Redis 等内存数据库的技术,并提供了示例。

使用 Python schematics 库的示例显示了在医疗保健领域的假设应用程序中生成随机数据。

接下来的部分是关于日志记录及其作为调试技术的使用。我们讨论了使用logging模块进行简单日志记录,使用logger对象进行高级日志记录,并通过创建具有自定义格式的日志记录函数内部所花费时间的记录器包装器来结束讨论。我们还学习了一个写入 syslog 的示例。

本章的结尾专门讨论了调试工具。您学习了 pdb,Python 调试器的基本命令,并快速了解了提供更好体验的类似工具,即 iPdb 和 Pdb++。我们在本章结束时简要讨论了诸如 lptrace 和 Linux 上无处不在的strace程序之类的跟踪工具。

这就是本章和本书的结论。

posted @ 2024-05-04 21:29  绝不原创的飞龙  阅读(187)  评论(0编辑  收藏  举报